Developer reference for agents and contributors. User-facing overview: README.md. Full doc index: docs/README.md.
What it is: python -m src from repo root (src/__main__.py -> package website_profiling). Config: stored in PostgreSQL typed settings tables (crawl_settings, report_settings, llm_settings, integration_secrets, etc.). Schema inventory: config/typed_config_manifest.json; parity tests in tests/test_typed_config_schema_parity.py.
LLM / AI: Settings live in llm_settings + llm_provider_profiles. Providers: OpenAI, Google Gemini, Anthropic, Groq, Ollama (web/src/lib/llmConfigSchema.ts). Browser writes for API keys and LLM toggles go BFF → AiService (PUT /api/secrets, PUT /api/llm-settings). Configure via Secrets (/secrets) and Run audit → AI settings. Worker spawn reads typed DB settings only. Worker/CLI calls AiService via ai_service_client.py (AI_SERVICE_URL, default :8092).
Typed configuration (PostgreSQL): Flat keys (start_url, llm_provider, bing_webmaster_api_key, …) map to typed columns via config/typed_config_manifest.json. Stores include crawl_settings, report_settings, lighthouse_settings, llm_settings, llm_provider_profiles, integration_secrets, mcp_settings, feature_flags, workspace_settings, and ui_preferences. Python: src/website_profiling/db/typed_config/ + config_store.py. .NET AiService: TypedConfigRepositories / LlmSettingsRepository. Migrations 026_typed_config / 027_drop_eav_config.
Frontend: web/ (Vite + React SPA) — browser calls services/Bff/ for all /api/*; BFF proxies to FastAPI, AiService, and Data.
Key paths
src/website_profiling/--cli.py,config.py,crawl/,db/storage.py,lighthouse/,reporting/,analysis/,ai_service_client.py,tools/services/Bff/-- .NET BFF (auth, CORS,/api/*proxy to FastAPI + AiService + Data)services/AiService/-- .NET AI (chat, secrets, LLM config, MCP, enrichment; port 8092)services/Data/-- .NET data service (report payloads, portfolio, issue status, typed config, PDF/Excel/CSV/JSON/sitemap export; port 8091)services/ReportService/-- .NET report build + pipeline orchestration (port 8094). Worker runs crawl+Lighthouse; report viaREPORT_SERVICE_URLweb/src/-- React SPA (AppRoutes.tsx,views/,components/); pipeline UI:PipelineRunnerFab,PipelineContextservices/Schema/-- EF Core schema migrations (schema owner)
Local dev: ./local-run (Postgres in Docker wp-pg, Data :8091, AiService :8092, ReportService :8094, FastAPI :8096, BFF :8090, Vite :3000; default DATABASE_URL: postgres://postgres:dev@127.0.0.1:5432/website_profiling). See scripts/local-run.sh. Local tests: ./local-test runs three Python coverage gates (core 100%, reporting 100%, tools 100%) plus web and .NET checks — mirrors CI; Docker CI is separate (see .github/workflows/ci.yml). ./local-test browser for @pytest.mark.browser integration tests — see scripts/local-test.sh. Mocked browser unit tests: tests/test_browser_fetcher_unit.py.
JavaScript crawl (optional): Config keys crawl_render_mode (static | javascript | auto) and crawl_js_* in pipeline config / pipelineConfigSchema.ts. JS/auto crawls can capture browser console errors and uncaught exceptions (crawl_js_capture_console, stored under page_analysis.browser). Auto mode uses static-first fetch, pre-parse SPA heuristics (needs_js_render), then post-parse low-outlink fallback (needs_js_render_after_parse) in crawler.py. Preflight: GET /api/crawl/browser-status (localhost) spawns Python browser_status(); Run audit settings/run validation calls it when render mode is javascript or auto. Browser deps: Playwright from requirements.txt (installed by ./local-run setup and ./local-test). Runtime needs Chromium on PATH or CHROME_PATH (Docker sets CHROME_PATH=/usr/bin/chromium). Integration tests: @pytest.mark.browser — excluded by default in pytest.ini; Docker CI runs tests/test_crawl_fetchers.py and tests/test_crawler_browser_e2e.py -m browser; locally ./local-test browser.
Run / APIs
- Run audit (worker):
python -m src— reads typed pipeline settings from PostgreSQL (DATABASE_URLrequired) - Optional step:
crawl|report|plot|lighthouse|keywords|warnings|enrich|google|chat preserve_crawl_history(default true): append crawls;falsetruncates crawl tables but restoresreport_payload, Lighthouse,google_data,keyword_data,keyword_history,keyword_suggest_cache, andcrawl_runsDATABASE_URLenv: PostgreSQL connection string (required).DATA_DIR: local artifacts (Docker:/data); settings and API keys live in Postgres.- Pipeline storage (crawl, edges, nodes, report payload, Lighthouse, keywords, warnings) lives in PostgreSQL only. Deliverables use the Export view,
GET /api/report/export, or MCPexport_*tools — not files written by the main pipeline step. - Pool tuning:
DB_POOL_MIN/DB_POOL_MAX(Python). Bulk crawl writes viaexecutemany; optionalcrawl_stream_to_dbstreams rows during fetch. Per-URL raw HTML:crawl_page_htmltable (migration015); APIGET/POST /api/crawl/page-html. - Browser API (BFF): All
/api/*routes are served byservices/Bff/. FastAPI:/api/run,/api/jobs/*,/api/pipeline-config, crawl, integrations (OAuth reads), properties, content drafts, etc. ReportService: report build + full-audit orchestration (internal; worker usesREPORT_SERVICE_URL). IntegrationsService: Google/Bing OAuth and fetch (browser + internal report enrichment). AiService:/api/chat(SSE),/api/llm-settings,/api/secrets,/api/ollama/status, etc. Data: report payload reads, portfolio, issue status, saved filters,/api/pipeline-settings,/api/ui-preferences,/api/client-preferences(seeDATA_ROUTES), and PDF/workbook export.PipelineRunnerFabsaves pipeline config (FastAPI) and LLM state (PUT /api/llm-settings→ AiService) before each run. - MCP: AiService (.NET) — stdio host or HTTP at
/mcpwhenWP_MCP_HTTP=1on:8092. Configure at/mcpin the web UI. Seedocs/MCP.mdand services/AiService/README.md. - AI Chat UI:
/chat— property-scoped chat with saved sessions (chat_sessions,chat_messages; migration012_chat_sessions). - Job store: PostgreSQL
pipeline_jobs(FastAPI); live job status via/api/jobs/*through the BFF. - Docker: Root
Dockerfile(Python backend);web/Dockerfile(Vite SPA + nginx);docker-compose.yml(postgres + fastapi + worker + report + integrations + ai + data + bff + web);docker-compose.prod.yml(production + optional MCP profile mapping host:8000→ AiService:8092);docker-compose.pull.ymlfor pre-built images (BACKEND_IMAGE,WEB_IMAGE);LIGHTHOUSE_CHROME_FLAGS
Where to edit
| Task | Where |
|---|---|
| Crawl | crawl/crawler.py, crawl/fetchers/, parsing/links.py (normalize_link preserves trailing slashes from href) |
| Report (native build) | services/ReportService/src/ReportService.Application/Build/ |
| Report (Python bridge) | reporting/builder.py, reporting/categories/ |
| PDF / workbook export | services/Data/src/Data.Rendering/ (rendering), Data.Api/Controllers/ReportExportController.cs; BFF routes /api/report/export and /api/report/export-workbook to Data |
| DB schema | services/Schema/src/Schema.Model/ |
| Local analysis | analysis/local.py, requirements.txt |
| AI insights (LLM) | services/AiService/ (browser-facing + MCP + native audit tools), ai_service_client.py (worker), llm_config.py (typed loader) |
| Audit query tools (MCP + chat) | services/AiService/src/AiService.Tools/, services/AiService/src/AiService.Mcp/, tools/audit_tools/, commands/chat_cmd.py |
| Agent readiness checks | tools/audit_tools/geo/agent_readiness.py, tools/audit_tools/_aeo_helpers.py |
| Typed settings / DB | db/typed_config/, config/typed_config_manifest.json, db/config_store.py |
| Config / CLI | config.py (load_config, load_config_from_db), cli.py |
| UI pipeline schema | web/src/lib/pipelineConfigSchema.ts |
| UI LLM schema | web/src/lib/llmConfigSchema.ts |
| UI secrets schema | web/src/lib/secretsConfigSchema.ts, web/src/hooks/useSecrets.ts |
| Browser API client | web/src/lib/publicBase.ts (apiUrl, apiFetch, VITE_BFF_BASE_URL) |
| D3 charts (custom / compare / overview) | web/src/components/charts/d3/, web/src/lib/viz/ |
| Chart.js charts (standard bar/line/doughnut) | web/src/utils/chartJsDefaults.ts, react-chartjs-2 in views under web/src/views/, web/src/components/searchPerformance/, web/src/components/traffic/ |
| Dev widget JSON copy (report cards + dashboards) | web/src/components/Card.tsx (devData), web/src/components/DevCopyJsonButton.tsx, web/src/lib/dashboard/widgets/WidgetFrame.tsx — see Dev widget JSON copy below |
Schema changes: add an EF Core migration (dotnet ef migrations add <Name> in services/Schema/src/Schema.Model/).
Charts — Chart.js + D3 (hybrid)
The web UI uses both Chart.js and D3.js. Pick the library that fits each chart; do not migrate everything to one stack.
| Prefer Chart.js when… | Prefer D3 when… |
|---|---|
| Standard bar, line, or doughnut with typical legend/tooltip/responsive canvas | Custom layout (grouped compare bars, dual lines with null gaps, arc gauges) |
| Quick add with minimal custom SVG | Tight theme control via CSS vars (--chart-grid, --chart-title, etc.) |
| Page already on Chart.js (GSC, GA4, Links, Content Analytics) | Reusing shared components in web/src/components/charts/d3/ |
| Chart.js plugins or defaults are enough | Neutral data types + adapters in web/src/lib/viz/ |
Decision rule: If a D3 component already exists (D3GroupedBarChart, D3DualLineChart, D3VerticalBarChart, D3DonutChart, compact charts, arcGauge.ts), reuse it. If it is a one-off standard chart on a Chart.js page, stay on Chart.js unless D3 clearly wins.
Current split (indicative)
| Area | Library |
|---|---|
Overview dashboard (/dashboard) |
D3 |
Compare (/compare) |
D3 |
Content analytics — Analytics tab (/content-analytics?tab=analytics) |
D3 |
GSC / GA4 / scatter (GscCharts, Ga4Charts) |
Chart.js |
| Links explorer, Content Analytics, Text Content Analysis | Chart.js |
| Score rings, distribution donuts, compact sparklines | D3 |
Conventions (both stacks)
- Wrap charts in
ChartPanel,ChartAccessibleFallback, and/orChartCardwhere applicable. - Theme helpers live in
web/src/utils/chartJsDefaults.ts(getGridColor,getChartTitleColor,truncateChartLabel) — use them from D3 as well as Chart.js. - Keep chart-library types out of data-prep: use neutral shapes (
BarChartData,DualSeriesChartDatainweb/src/lib/viz/types.tsandweb/src/lib/compareChartData.ts); convert at the render layer viaweb/src/lib/viz/adapters.tswhen needed. - Migrate page-by-page when D3 is the better fit; do not remove
chart.jsfrompackage.jsonuntil all consumers are migrated.
Dev widget JSON copy (local dev only)
In npm run dev (import.meta.env.DEV), report widgets (cards, panels, stat blocks) and custom dashboard widgets expose a top-right { } button on hover. Clicking copies pretty-printed JSON of what that widget displays. Production builds omit the button entirely (dead-code eliminated at build time).
| Piece | Path |
|---|---|
| Overlay button | web/src/components/DevCopyJsonButton.tsx |
| Report card hook | web/src/components/Card.tsx — optional devData?: unknown |
| Dashboard grid widgets | web/src/lib/dashboard/widgets/WidgetFrame.tsx — passes devData when status === 'loaded' |
| Tooltip copy | strings.json → components.devCopyJson.title |
When adding or touching a widget, wire devData. Goal: every user-visible widget in report views eventually has copy support.
Report views — use Card devData
Pass a view-model object (what the widget renders), not necessarily a single raw report key. Derived UI (computed counts, API-fetched trends, filtered rows) belongs in the payload.
const widgetDevData = useMemo(
() => ({
widget: 'views.overview.executiveSummary.healthHero', // stable id for debugging
currentHealth,
healthDelta,
topIssues: topIssues.slice(0, 5),
raw: { executive_summary: data.executive_summary }, // optional source slices
}),
[currentHealth, healthDelta, topIssues, data.executive_summary],
);
<Card shadow devData={widgetDevData}>…</Card>Reference implementation: web/src/components/overview/OverviewExecutiveSummary.tsx (health hero, AI summary, text summary cards).
StatCard and non-Card wrappers: wrap with <Card devData={…}> only when it fits layout; otherwise wrap the section in a relative group/dev-card container and render <DevCopyJsonButton data={…} /> directly (same hover behaviour).
Dashboard widgets: WidgetFrame already copies { widget, status, result } when loaded. No extra work unless you add a new widget shell outside WidgetFrame.
Conventions
- Always include a stable
widget: 'view.section.name'string id. - Copy displayed values (including async-fetched state once available); add
raw: { … }when source report slices help backend/debug work. - Use
useMemofordevDatawhen the payload depends on props/state/effects. - Do not gate on a custom env var unless staging preview also needs copy — default is
import.meta.env.DEVonly. - Async widgets: omit
devDatauntil data is ready, or include partial payload plus flags likehistoryLoaded: false.
Company standards: UI copy in web/src/strings.json (Site Audit, Properties, Run audit). Data provenance on report_meta in report payload. Docs: docs/COMPANY_STANDARDS.md, docs/GLOSSARY.md. Migration 003_company_standards (properties, pipeline_jobs, audit_log). Export: PDF/workbook via FileService (FILE_SERVICE_URL on MCP; REPORT_API_URL on FileService); CSV/JSON via GET /api/report/export and src/website_profiling/tools/export_audit.py.
Common footguns (check before finishing web or DB work)
These recur when adding features. Verify explicitly — do not assume tests caught them.
-
React context —
useReport/ReportProvider- Report views call
useReport(). That only works insideReportAppClient→ReportProvider. - Do: Render report views via
ReportShellinsideReportLayout(AppRoutes.tsx→/:slug). - Don't: Mount a report view outside
ReportAppClient/ReportProvider. - Standalone routes (
/pipeline,/chat,/write, etc.) are defined inweb/src/AppRoutes.tsx, not wrapped byReportLayout.
// ✅ ReportSlugPage in web/src/pages/ReportSlugPage.tsx import ReportShell from '@/ReportShell'; export default function ReportSlugPage() { const { slug } = useParams(); return <ReportShell slug={slug!} />; }
- Report views call
-
Python — local imports shadow module imports
from ..config import get_intanywhere inside a function makes that name local for the entire function. Using it earlier →UnboundLocalError.- Do: Use the module-level import (see top of
reporting/builder.py). - Don't: Re-import inside a function if the same name is used above that line in the same function.
-
PostgreSQL rows — never
row[0]- Connections may use psycopg
dict_row.row[0]→KeyError: 0on dict rows; tuple-only unit tests still pass. - Do:
_row_field(row, "id", index=0)fromwebsite_profiling.db._common(pattern inproperty_store.py). - Don't:
fetchone()[0]onINSERT … RETURNINGwithout_row_field.
from ._common import _row_field row = cur.fetchone() rid = _row_field(row, "id", index=0) report_id = int(rid) if rid is not None else None
- Connections may use psycopg
3a. Crawl / page URLs — preserve trailing slashes
normalize_linkand crawl storage keep the URL shape fromhrefor the server (norstrip("/")on page URLs)./pageand/page/are distinct crawl keys.- Do: Follow 3xx redirect targets with exact
final_url != url(seecrawler.py). Useintegrations/google/normalize.normalize_urlfor GSC join keys (path shape preserved). - Don't: Strip trailing slashes on crawl rows, link edges, or report
links[].url. Service base URLs (AI_SERVICE_URL, etc.) may still trim for path joining only.
-
Python — local vs CI coverage gates (three jobs, not one)
- CI runs three separate pytest coverage jobs (see
.github/workflows/ci.ymlandscripts/local-test.sh):Gate Config Source Threshold Test scope Core .coveragercall packages except tools/andreporting/100% pytest tests/ -m "not browser"Reporting .coveragerc.reportingwebsite_profiling.reporting100% pytest tests/reporting/Tools .coveragerc.toolswebsite_profiling.tools100% pytest tests/tools/ - Symptom:
./local-testor core pytest passes at 100%, but CI fails on tools/reporting (e.g. 84% tools). - Causes: (a) only ran core pytest, not reporting/tools gates; (b) added reporting/tools tests outside
tests/reporting/ortests/tools/; (c) changed code underwebsite_profiling/tools/without tests that hit those lines in the tools gate subset. - Do: Run full
./local-testbefore push. Put reporting coverage tests intests/reporting/and tools coverage tests intests/tools/(one module per file, e.g.test_<module>_coverage.py). Keep bash and PowerShell local-test scripts in sync. - Don't: Assume
pytest tests/alone matches CI. Don't maintain long per-file lists in CI — use the directory gates above.
- CI runs three separate pytest coverage jobs (see
-
Python —
runpy.run_module/__main__guard tests- Tests that execute a module as
__main__viarunpy.run_module(..., run_name="__main__")emit:RuntimeWarning: '<module>' found in sys.modules after import of package ...when the same module was already imported at the top of the test file (or by another import). - Do: Before
runpy.run_module, remove the target fromsys.modulesso Python re-executes__main__cleanly. Name teststest_module_main_guard(seetests/test_schedule_runner.py). - Don't: Call
runpy.run_moduleon a module already imported in that test file without popping it first.
import runpy import sys sys.modules.pop("website_profiling.tools.schedule_runner", None) runpy.run_module( "website_profiling.tools.schedule_runner", run_name="__main__", alter_sys=False, )
- Tests that execute a module as
-
.NET — DI lifetime / host registration
- Unit tests do not build the ASP.NET DI graph. Singleton injecting scoped services (e.g.
DbContext) only fails at./local-rununless you test registration. - Do: Every API service enables
ValidateOnBuild+ValidateScopesinProgram.csand shipsServiceRegistrationValidationTests(WebApplicationFactory<Program>). Shared env helper:services/Shared/WebsiteProfiling.Testing/. - Don't: Add scoped dependencies to singletons without resolving via
IServiceScopeFactory, or skip the registration test when adding new host services.
- Unit tests do not build the ASP.NET DI graph. Singleton injecting scoped services (e.g.
Checklist: new report page uses ReportShell · report/card widgets pass Card devData (see AGENT.md § Dev widget JSON copy) · no duplicate local imports in long functions · new fetchone() uses _row_field · ./local-test passes all three coverage gates · new tools coverage test file listed in CI + both local-test scripts · runpy main-guard tests pop sys.modules first · new .NET API service has DI validation test