From db7305983bbcb459e853ce024fe23aef0c1e0a80 Mon Sep 17 00:00:00 2001 From: PrashantUnity Date: Sun, 7 Jun 2026 01:15:11 +0530 Subject: [PATCH 1/4] cooking started --- .coverage | Bin 69632 -> 53248 bytes .coveragerc | 9 +- .coveragerc.reporting | 8 + .coveragerc.tools | 10 + .github/workflows/ci.yml | 18 +- AGENT.md | 37 ++ SECURITY.md | 1 + alembic/versions/011_roadmap_foundation.py | 97 +++ docs/GLOSSARY.md | 17 + docs/OPS.md | 83 +++ input.txt.example | 8 + pipeline-config.example.txt | 8 + pytest.ini | 1 + src/website_profiling/analysis/log_parser.py | 69 ++ .../commands/pipeline_cmd.py | 51 +- src/website_profiling/common.py | 39 +- src/website_profiling/crawl/crawler.py | 23 + src/website_profiling/crawl_presets.py | 50 ++ src/website_profiling/db/report_store.py | 63 +- .../integrations/bing/webmaster.py | 69 ++ .../integrations/crux/__init__.py | 3 + .../integrations/crux/fetch.py | 57 ++ .../integrations/google/competitor_links.py | 95 +++ .../integrations/google/gsc_links_store.py | 26 + .../integrations/google/gsc_links_sync.py | 72 +++ .../integrations/google/keyword_enrich.py | 72 +++ .../integrations/links/third_party_csv.py | 127 ++++ .../integrations/serp/__init__.py | 1 + .../integrations/serp/estimates.py | 75 +++ src/website_profiling/llm/audit_summary.py | 135 ++++ src/website_profiling/llm/content_brief.py | 34 + src/website_profiling/llm/issue_fixes.py | 105 +++ src/website_profiling/llm/prompts.py | 8 + src/website_profiling/reporting/builder.py | 83 +++ src/website_profiling/reporting/categories.py | 194 +++++- .../reporting/crawl_segments.py | 42 ++ src/website_profiling/reporting/indexation.py | 126 ++++ src/website_profiling/tools/alert_checker.py | 72 +++ src/website_profiling/tools/export_audit.py | 206 +++++- .../tools/schedule_runner.py | 89 +++ tests/db_test_fakes.py | 7 + tests/fixtures/bing/error_401.json | 4 + tests/fixtures/bing/get_link_counts.json | 9 + tests/fixtures/report/minimal_crawl.json | 48 ++ tests/test_alert_checker.py | 140 ++++ tests/test_bing_webmaster.py | 46 ++ tests/test_categories_coverage.py | 612 ++++++++++++++++++ tests/test_categories_roadmap.py | 131 ++++ tests/test_common_parsing.py | 27 + tests/test_config_schema_keys.py | 8 + tests/test_crawl_presets.py | 18 + tests/test_crawl_segments.py | 36 ++ tests/test_crawler_unit.py | 93 +++ tests/test_db_stores_unit.py | 55 ++ tests/test_export_audit.py | 45 ++ tests/test_export_audit_coverage.py | 251 +++++++ tests/test_gsc_links_store.py | 2 + tests/test_gsc_links_sync.py | 58 ++ tests/test_indexation_coverage.py | 55 ++ tests/test_log_parser.py | 32 + tests/test_page_google.py | 11 +- tests/test_pipeline_cmd_run_unit.py | 33 + .../test_pipeline_lighthouse_url_selection.py | 42 ++ tests/test_report_categories_golden.py | 68 ++ tests/test_roadmap_extras.py | 45 ++ tests/test_schedule_runner.py | 154 +++++ tests/test_terminology.py | 5 + tests/test_third_party_csv.py | 23 + web/app/(reports)/layout.tsx | 6 - web/app/api/alerts/check/route.ts | 62 ++ web/app/api/auth/login/route.ts | 3 +- web/app/api/auth/session/route.ts | 23 + .../api/backlinks/competitor-import/route.ts | 70 ++ .../api/backlinks/third-party-import/route.ts | 94 +++ web/app/api/backlinks/velocity/route.ts | 41 ++ web/app/api/compare/export/route.ts | 109 ++++ web/app/api/integrations/bing/sync/route.ts | 58 ++ web/app/api/issues/fix-suggestion/route.ts | 71 ++ web/app/api/issues/status/route.ts | 70 ++ web/app/api/jobs/route.ts | 37 ++ web/app/api/keywords/content-brief/route.ts | 57 ++ web/app/api/logs/upload/route.ts | 73 +++ web/app/api/properties/[id]/ops/route.ts | 52 ++ web/app/api/properties/[id]/preset/route.ts | 44 ++ web/app/api/properties/resolve/route.ts | 8 +- web/app/api/report/history/route.ts | 23 + web/app/api/schedule/check/route.ts | 52 ++ web/app/client-providers.tsx | 15 +- web/app/indexation/page.tsx | 7 + web/app/log-analyzer/page.tsx | 7 + web/src/ReportShell.tsx | 23 +- web/src/components/AppShell.tsx | 10 + .../components/GoogleIntegrationsPanel.tsx | 47 +- web/src/components/HealthSparkline.tsx | 43 ++ web/src/components/UrlInspectorButton.tsx | 34 + web/src/components/UrlInspectorDrawer.tsx | 95 +++ .../backlinks/CompetitorGapImport.tsx | 117 ++++ .../backlinks/ThirdPartyLinksImport.tsx | 154 +++++ .../integrations/BingWebmasterSection.tsx | 56 ++ .../integrations/PropertyOpsSection.tsx | 171 +++++ .../components/issues/IssueAiFixButton.tsx | 79 +++ web/src/components/issues/IssueTaskBoard.tsx | 163 +++++ .../keywordsExplorer/ContentBriefButton.tsx | 115 ++++ .../keywordsExplorer/KeywordPanels.tsx | 82 +++ .../keywordsExplorer/KeywordTabBanner.tsx | 4 + .../keywordsExplorer/KeywordTableColumns.tsx | 37 ++ .../keywordsExplorer/keywordTabMeta.ts | 10 + .../keywordsExplorer/keywordTableUtils.ts | 6 + .../links/explorer/LinksExplorerTableTab.tsx | 11 + .../overview/OverviewSummaryTab.tsx | 101 +++ .../pipeline/CrawlAuthorizeCheckbox.tsx | 5 +- .../components/pipeline/PipelineRunPanel.tsx | 59 +- .../components/pipeline/PipelineRunnerFab.tsx | 4 +- .../pipeline/PipelineSettingsPanel.tsx | 20 +- web/src/context/PipelineContext.tsx | 62 +- web/src/context/SessionContext.tsx | 65 ++ web/src/context/UrlInspectorContext.tsx | 54 ++ web/src/hooks/useReadOnlySession.ts | 9 + web/src/lib/appNav.ts | 3 + web/src/lib/crawlPresets.ts | 84 +++ web/src/lib/llmConfigSchema.ts | 14 + web/src/lib/pipelineConfigSchema.ts | 59 +- web/src/routes.ts | 6 +- web/src/server/alertsCheckRoute.test.ts | 37 ++ web/src/server/auditHistoryDb.ts | 117 ++++ web/src/server/auth.ts | 23 +- web/src/server/authSessionRoute.test.ts | 47 ++ .../backlinksCompetitorImportRoute.test.ts | 66 ++ .../backlinksThirdPartyImportRoute.test.ts | 71 ++ web/src/server/bingSyncRoute.test.ts | 44 ++ web/src/server/gscLinksImportRoute.test.ts | 131 +++- web/src/server/issueStatusDb.ts | 133 ++++ .../server/issuesFixSuggestionRoute.test.ts | 42 ++ web/src/server/issuesStatusRoute.test.ts | 70 ++ web/src/server/jobsRoute.test.ts | 39 ++ web/src/server/logsUploadRoute.test.ts | 49 ++ web/src/server/pipelineJobs.ts | 4 + web/src/server/pipelineJobsDb.ts | 94 +++ web/src/server/propertiesDb.ts | 52 +- web/src/server/propertyOpsRoute.test.ts | 65 ++ web/src/server/testHelpers/routeTestUtils.ts | 50 ++ web/src/strings.json | 141 +++- web/src/types/components.ts | 8 + web/src/types/index.ts | 2 + web/src/types/report.ts | 83 +++ web/src/utils/linkExport.ts | 38 ++ web/src/views/Backlinks.tsx | 112 +++- web/src/views/CompareReports.tsx | 29 +- web/src/views/Home.tsx | 44 +- web/src/views/Indexation.tsx | 64 ++ web/src/views/Issues.tsx | 97 ++- web/src/views/KeywordsExplorer.tsx | 25 +- web/src/views/Lighthouse.tsx | 24 + web/src/views/Links.tsx | 16 +- web/src/views/LogAnalyzer.tsx | 151 +++++ web/src/views/SearchPerformance.tsx | 14 +- web/src/views/SiteStructure.tsx | 62 +- 157 files changed, 9012 insertions(+), 157 deletions(-) create mode 100644 .coveragerc.reporting create mode 100644 .coveragerc.tools create mode 100644 alembic/versions/011_roadmap_foundation.py create mode 100644 docs/OPS.md create mode 100644 src/website_profiling/analysis/log_parser.py create mode 100644 src/website_profiling/crawl_presets.py create mode 100644 src/website_profiling/integrations/bing/webmaster.py create mode 100644 src/website_profiling/integrations/crux/__init__.py create mode 100644 src/website_profiling/integrations/crux/fetch.py create mode 100644 src/website_profiling/integrations/google/competitor_links.py create mode 100644 src/website_profiling/integrations/google/gsc_links_sync.py create mode 100644 src/website_profiling/integrations/links/third_party_csv.py create mode 100644 src/website_profiling/integrations/serp/__init__.py create mode 100644 src/website_profiling/integrations/serp/estimates.py create mode 100644 src/website_profiling/llm/audit_summary.py create mode 100644 src/website_profiling/llm/content_brief.py create mode 100644 src/website_profiling/llm/issue_fixes.py create mode 100644 src/website_profiling/reporting/crawl_segments.py create mode 100644 src/website_profiling/reporting/indexation.py create mode 100644 src/website_profiling/tools/alert_checker.py create mode 100644 src/website_profiling/tools/schedule_runner.py create mode 100644 tests/fixtures/bing/error_401.json create mode 100644 tests/fixtures/bing/get_link_counts.json create mode 100644 tests/fixtures/report/minimal_crawl.json create mode 100644 tests/test_alert_checker.py create mode 100644 tests/test_bing_webmaster.py create mode 100644 tests/test_categories_coverage.py create mode 100644 tests/test_categories_roadmap.py create mode 100644 tests/test_crawl_presets.py create mode 100644 tests/test_crawl_segments.py create mode 100644 tests/test_export_audit_coverage.py create mode 100644 tests/test_gsc_links_sync.py create mode 100644 tests/test_indexation_coverage.py create mode 100644 tests/test_log_parser.py create mode 100644 tests/test_report_categories_golden.py create mode 100644 tests/test_roadmap_extras.py create mode 100644 tests/test_schedule_runner.py create mode 100644 tests/test_third_party_csv.py delete mode 100644 web/app/(reports)/layout.tsx create mode 100644 web/app/api/alerts/check/route.ts create mode 100644 web/app/api/auth/session/route.ts create mode 100644 web/app/api/backlinks/competitor-import/route.ts create mode 100644 web/app/api/backlinks/third-party-import/route.ts create mode 100644 web/app/api/backlinks/velocity/route.ts create mode 100644 web/app/api/compare/export/route.ts create mode 100644 web/app/api/integrations/bing/sync/route.ts create mode 100644 web/app/api/issues/fix-suggestion/route.ts create mode 100644 web/app/api/issues/status/route.ts create mode 100644 web/app/api/jobs/route.ts create mode 100644 web/app/api/keywords/content-brief/route.ts create mode 100644 web/app/api/logs/upload/route.ts create mode 100644 web/app/api/properties/[id]/ops/route.ts create mode 100644 web/app/api/properties/[id]/preset/route.ts create mode 100644 web/app/api/report/history/route.ts create mode 100644 web/app/api/schedule/check/route.ts create mode 100644 web/app/indexation/page.tsx create mode 100644 web/app/log-analyzer/page.tsx create mode 100644 web/src/components/HealthSparkline.tsx create mode 100644 web/src/components/UrlInspectorButton.tsx create mode 100644 web/src/components/UrlInspectorDrawer.tsx create mode 100644 web/src/components/backlinks/CompetitorGapImport.tsx create mode 100644 web/src/components/backlinks/ThirdPartyLinksImport.tsx create mode 100644 web/src/components/integrations/BingWebmasterSection.tsx create mode 100644 web/src/components/integrations/PropertyOpsSection.tsx create mode 100644 web/src/components/issues/IssueAiFixButton.tsx create mode 100644 web/src/components/issues/IssueTaskBoard.tsx create mode 100644 web/src/components/keywordsExplorer/ContentBriefButton.tsx create mode 100644 web/src/context/SessionContext.tsx create mode 100644 web/src/context/UrlInspectorContext.tsx create mode 100644 web/src/hooks/useReadOnlySession.ts create mode 100644 web/src/lib/crawlPresets.ts create mode 100644 web/src/server/alertsCheckRoute.test.ts create mode 100644 web/src/server/auditHistoryDb.ts create mode 100644 web/src/server/authSessionRoute.test.ts create mode 100644 web/src/server/backlinksCompetitorImportRoute.test.ts create mode 100644 web/src/server/backlinksThirdPartyImportRoute.test.ts create mode 100644 web/src/server/bingSyncRoute.test.ts create mode 100644 web/src/server/issueStatusDb.ts create mode 100644 web/src/server/issuesFixSuggestionRoute.test.ts create mode 100644 web/src/server/issuesStatusRoute.test.ts create mode 100644 web/src/server/jobsRoute.test.ts create mode 100644 web/src/server/logsUploadRoute.test.ts create mode 100644 web/src/server/propertyOpsRoute.test.ts create mode 100644 web/src/server/testHelpers/routeTestUtils.ts create mode 100644 web/src/utils/linkExport.ts create mode 100644 web/src/views/Indexation.tsx create mode 100644 web/src/views/LogAnalyzer.tsx diff --git a/.coverage b/.coverage index fb5527cf5de755e6731718def6eec4c443b1b0bd..91ad01af00003376b908b6d92fbf1ccdd49fbea3 100644 GIT binary patch delta 621 zcmZvaO=uHA6oB_B+ISaJFJ5|RFY%xTTLckB(^!Y?(sZ|M7i-Zr4S27x zhoF}f1h0z}ya-)S_M!(t@KDWqX%&hjF>Ot|?mlNL?Hcs-X5Rbey>Et@UqthZ^5b5S zyhwi;I7S3Pz@#D2C$vv}quwHorK3k`BsGqaP`>F5XD^?&*raLMx}jx8t-^FMKgF~O zX4q3_CucOosjD${BB6BSF1uf_b<^O)nCuK*96FU$ZDvjAhFLVVnYe-Up9Z4^n`x${ zvv>p(om_S(^{0W;^t)6{ngV@F&yl8jTV3J0K0!sqWy{xc>15D9vUm(}R}S?gP=_mW z25t#v&@(g;p4lB@tN1r}dUoAte|FF!{J@|aV1waoZEGp%D(j7b)z5M|CF4WBkDlokZ zm)c$c&*2wT8q0nLJZNQLEe}5`;I6{YjfE)NhS~PqW*E2-ZMk0bEvSFI08sPz?rRBc z*TZcWR(oe5+E{p93ddp2i`J^)`!Lwbmyj@0_ppc*MU@W4pN0AY4d^EI={o(+@A8gz Tr?{vnigtp5JLdD!1ExUJtdu)962>?(KE{juvW+_yWnyF}Yc z$7=Y3Zj=BeKnYL+Um60LhuyZOmKNg^`_p_dE~I5XB*^(!&!*qrykXmBZrg^94{YZ0 z`?%#!4z^XRxDA{v4RR?#<|1NT;KXED4Do3(8RgP3p>TA+B82gn1^Zys!UwKa&&Tfq zVi?X!3sJ}-C5s7O9^&>0Lo3w-G_|y_FP+~3nFwMuiI3oxYo~W}vJequAsG^sd?}WT z;cjR5cZQv|ruus0Q-W#}FNfgARbdV6D4DV)0vUxQIgCpml=);RCMYX8zGz`cf^zOl z=dG2cgg-5DaWScynN-BIC?z>zpAhO#3*k8pT#2C-Hh@=d4dm5}4eHIGscDtbDV;pW zncCsn$q^`AQYv6AortC3WVOu6{zMRv_w^@IxN_i(yl`5Y@67qmO-q`wpSf=4sLPVQ z71dclj%zf3R)135+bWv2QN@1U}Hx5&$0ND%WR5}T6Me#T% zMbsvP&*t)#1KwQTX8Z*hyo?(fCus*lNxx8U!-a)1aKX3~tn@Y2V-x`z2=oPo!y^63 z5N;|8wjm@92r?fPR%_qpyM-^>k-&AQAfw zi$ALFiN~H&dGi^RJ{Q=-QQhj{(7Yd#6h(w#6%Sj=9fiGISBzJ1FQjyFK}m`Wd@?^a zz!_T<&=eI;EyK;DQco!lWeDdH9q3ZaR2W=vH@^)UU7>x!n?!<7hPCku55xIfq3_3y z6YJzV5jc_Sf<~!86Rpa#kg4`9?y(B_uTB9IoNIy)=bJmOl(HNLW5P|@y24S~1j*U$fl?aTX#@PwjS`>)C;>`<5}*Vq0ZM=ppaduZN`Mle1ipL(%tn*Z zhU0$|d)~lKzzf|d0ZM=ppaduZN`Mle1SkPYfD)htC;>{~H`<5}*W% z1l;CD#=>O)lhfSh(5?bl;0XNp|FG4v)82jF2G0wg4emGH5!a;akgL`Cyz_3ySx3g< zvmdkXvgK?ew)NJm^>J&ftL%mrqcX)wQJ-e#IG9Wpf=4;wcdP8pI#*{U~U7h}lm z@NZY(b`ZGxEG$Iip|CLEzgJN9q@|Sq5vgAe37g;x{|+GtcR&gc$r9YX0XIFd8%Ucy*3`!5` zq7(>-NiiJ=fIT}oP+3b>1tWv3!#>gmgoA!)O>n<;dMKcz;YLYZA?>6qM7`fghWohT zHcfvSs>mWxBI?dykLJjHDpj;7xDa%sMFg5I-3cb1GgZ8kHH=Nad0!f85Hg#tPqmm&Hq391AURR|9DD5H5cJ51LXEOxU$RhwO3L@g3Nyq22Y6{RYydFLkuN zb&x+%7v+Ysk~RgghpXALM29xSxl?Pc{TJf4>&_!JaA<^=_^ zBR6io{)mukCiKhDL5U#++ve8Uw%AZm7V%V7 zbb%7tS#c%B2p@trIRsMuPLPsx#2AEIlVNrr2#b6)DHT|X4xKI3WWmCu9i;eMVWE)1 zZW6J99?=<0m172Z1^1c}~W0SciWz|-D%y=9JIDsUa>r2 zJ#RkDyl+EHBhGHlt&hpIKj)$#wbD3f_uAG0dr|E2oj zyD9!(A~Kon`q)$z|91#X=0Saw${OtAN14o8!YW$)&k;9ZuK3?ayh7%S|J#YX^W5?O zq7akWLbOV5j{g@%n9Nqv8aK!PZF(S^BmQsYnapO==2XQ0EkP!8A87@3rcsRln;&B` z>qtvz@qd#ZK2;U}H|}OKJ*3NDivJt*$i5W+F9eg zo@1Wxc#@uNo_joB_4quj`?~v*d))oH`%m5b-H*Ay;a=vhcYW;orE3f_r5hzc2~Yx* z03|>PPy&~M#+q(OI~JJkFj!B_BG`Q1<*=uH;gR<)pplnmX3Sg{ zeBS6g^2x~y^^YHX1HEgw%l7^a^o(f+y@g^Y(6F(l6X(%%Et$J<`}ajm%$#c z?b^|M(B-W+rjT*TQrPjW)@w%(?#^``eq&-fhdy~{_Q1>-x-x>sr>C=H-N?o*!AdP~ z)1pVIBUUat^4}jnbH|mpPv%CCU0EF+F@JqA z?2b1>?v0O6&fGYF4&>JMAjB{;x$MmBp~rJar_S8yx}Kaus0U5|IeKFnp?@Flso~(X zjz(O{-}{&0t^+x9tq=ArZMc?v4>7stt{`;TY;A{K-ujM)MUbv!`@TMcu1uq@3+JZK zAe1$E7sBp#Z=WzRJ%y%*y|ryP2hWQJL+aWG+3{tUMn~PPu&dSm;?94ZdMRtGYk^Ol zt``q{z`TaKW`|K0S(=++-&dV|>qagOKaDQ@%}J|6~-+9ze6NW^+H9L}>VbbLb4p9XWh8Y3e?f69mh+e%m zH9dM2I|bT$a0-ngq#(zo$u`4vs56wC{&Xsr8$};who^Gr^o_y5rO^vH)rSz89E9`L zZU4116A1k!WO@QJ7|o)$;gFowT8FhQrn*{4JGtPe!#$VBdS;o^gQKQRHMl(1noriw zrawR<$7g!>_xy5rs_r^^ZE6A?MI-24wDs`l)jh-6XC{1DrRP271cD=x|DVzDOg$R@ z^cgz~8P|0W{FJ@T3-7+&am4z^*%=fXogGd5P%EW0F^NV_A#^7cR+>5m)Y_aLIJmKE z;MAV1&E;a$G0iW8J4V;^t{(Ce# zZChlA&v!08GW3h-iBCso&z7ZJ7X962@bE$O(bZF9H)ck!4x;g~5!5q_PL82KZf0za8BPkD zudNuHLO-~X9UECc7|f#atLJ793`661?JOD|Ll>uBH(#GTIe8URVr?ckrN;F8CX11A zIJ_pj{=dR|*1%4)SJ;bemOaD{uoBC&G4@gR5%vLgGkZ6?mi-fUKl>j074N%jH{0R8 z&bF~0*6Kyx57`CYv)+%{KVsiupJm@<|AYNk_DAe<>}%`~*nfbm=|%}q0+awHKnYL+ zlmI0_2~Yx*03|>P=uQAf+Q#-3YO>@SsE>sgwo0_;=)x_1JCeCIxu{WuSqft$44QgUtpeD8TYGSEV z6RuWGYHHM^n^ltzubOmu)TGm`CM#TOvfQaA%N%O5)UGB=Y-+Mtz1U!6Eb6B^Mor9S zH8GhOxB#H8{~Ky_uL>$a2~Yx*03|>PPy&`< z5}*Vq0ZQNtB;Yh3HZ&R}1N%4Z-QF|yS}Yo;OudX+jrZZwcTmm&m6S2SzfU`U_Ea>%)DeSDys1gFJl6w?QY8m;*jTX#`N>Vgl zz_(OCeA*+fWkHeR1Nb4!OUOTIw_sCBObLLmU{wd%2X-c-{!l@w?AfM^b$Fz6G9pF+ z73UZ?zd`irw(uPLlq{tLIXzT-YP!!c&OA?4MNFoJsLZEDDXI9&P_+|xXRt?eWImND zTC@nb{eq}Ppd$+@Nlq6v7DkxNR?-^rcp?yjo<=CjwCRB?E=FVNnAES7bXti&p%;+F zMErDkF~rBgnOlfIp%)a?Ck+>2H9N+Q=SbH{N|JE#n?g)x%je5mzfQKSLi6L{3jI3S zL0(RZ$!KAS>ef$bk1#YS$>D<1a^fype8e-JPCy;k5m(4R30r*?WKgKpVX#Ofq$Ffr z<9^HZho8?u6)`OYV0?&(Fp9!xr#>&0Jl{AbQAyD$}Wio;ewnT(9 zbgr`ES5kZ+CiKhDL5U#+6g_&|78?r6BA%+c$xk0EDMt7Z^twYJD1doO%^P4K?U^N!*RZm6&*9kE4U}Jksm86ouq75W+gr_ z=WQyJun_N9i(PkG*p~)i3~?ih3UMhvfiV*gCyEkfs7#MBne`6;c4$@##2aBDA`gXy z0sp;%vL`L2{EtZea!A+&U-)+jLEJe%RP3-7IpiPA?=0)a%QDA*|6gy98Q4Ey+r2;W zZuGq48Spf^Uv)p?y5xG=)#*I$+~JsZeBZIg{uUfYH%fpKpaduZN`Mle1U@qX;=$Cs zR}^NzeX5}#Ir#?2RbVOqJ4G^oO*F?uPqaLHuu z88}o(<)u*KQ6wnLbsd~|Py{+^a96tIsz(da){JmF#KCZ*9*sPJ*K*VgXAJ=+vw?Ke zC=0ski3h**!rbSHhpVo*{C~PIhyVV+%aAm%f6d;I8f9gvkR7Z>JOy)B@#nXow(lU}|V6z(IeP&pZW zvibCc3*CK%<9n`AZYsT?=%`z7#C)u7~V5kS={dOp5710K$OY zI>?`>ix$^kwDteqTG9&nxu*K6uK(|>0R_T?g%-y2vicOhwEiFSf&$r*yK+&oxc;B= zfRaGcm8vHFH?9B2+&bG<3^PO*D3P5NS5hpk|MxpVO41SI-0T0MLuU(1>;FkRNb$GA z!u;Jr&v=PO@ zp|JiRHh~1uaH1$thUzgRsH`XH(Pf$A_}}b3VPOA^T?q-@C;>`<5}*Vq0ZM=ppaduZ zN`Mle1So-DO9Fi>AUGy_S)_1BZ`qni{ua7eH)xT)+F8)5x`i&bTsNhvTj>4|DRBI{ diff --git a/.coveragerc b/.coveragerc index cf7e06ab..91058fdf 100644 --- a/.coveragerc +++ b/.coveragerc @@ -1,10 +1,14 @@ [run] source = website_profiling -# We are NOT hiding core code. We only omit modules that require external -# services/binaries (Google APIs, Lighthouse) or are impractical to unit-test here. +# Core unit-test gate (100%). reporting/, tools/, and external integrations are +# enforced by separate CI jobs — see .coveragerc.reporting and .coveragerc.tools. omit = */website_profiling/integrations/google/* + */website_profiling/integrations/bing/* + */website_profiling/integrations/crux/* + */website_profiling/integrations/serp/* + */website_profiling/integrations/links/third_party_csv.py */website_profiling/lighthouse/* */website_profiling/reporting/* */website_profiling/tools/* @@ -18,4 +22,3 @@ omit = [report] show_missing = True skip_empty = True - diff --git a/.coveragerc.reporting b/.coveragerc.reporting new file mode 100644 index 00000000..d1965f17 --- /dev/null +++ b/.coveragerc.reporting @@ -0,0 +1,8 @@ +[run] +source = website_profiling.reporting +omit = + */website_profiling/reporting/builder.py + +[report] +show_missing = True +skip_empty = True diff --git a/.coveragerc.tools b/.coveragerc.tools new file mode 100644 index 00000000..fbb9a0d5 --- /dev/null +++ b/.coveragerc.tools @@ -0,0 +1,10 @@ +[run] +source = website_profiling.tools +omit = + */website_profiling/tools/keywords.py + */website_profiling/tools/plot.py + */website_profiling/tools/warnings.py + +[report] +show_missing = True +skip_empty = True diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 82a03f55..3cadc117 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -33,8 +33,21 @@ jobs: run: pip install -r requirements.txt - name: Apply migrations run: alembic upgrade head - - name: Pytest - run: pytest tests/ -q + - name: Pytest (core, 100% coverage) + run: pytest tests/ -q -m "not browser" + - name: Pytest (reporting coverage gate) + run: | + pytest tests/test_categories_roadmap.py tests/test_report_categories_golden.py \ + tests/test_categories_coverage.py tests/test_indexation_coverage.py tests/test_crawl_segments.py \ + tests/test_terminology.py \ + --cov=website_profiling.reporting --cov-config=.coveragerc.reporting \ + --cov-report=term-missing --cov-fail-under=100 -q -o addopts= + - name: Pytest (tools coverage gate) + run: | + pytest tests/test_alert_checker.py tests/test_schedule_runner.py tests/test_export_audit.py \ + tests/test_export_audit_coverage.py \ + --cov=website_profiling.tools --cov-config=.coveragerc.tools \ + --cov-report=term-missing --cov-fail-under=100 -q -o addopts= - name: CLI smoke run: python -m src --help @@ -47,7 +60,6 @@ jobs: - name: Browser crawl tests in image run: | docker run --rm \ - -e DATABASE_URL=postgres://profiling:profiling@localhost:5432/website_profiling \ website-profiling:ci \ /opt/venv/bin/pytest tests/test_crawl_fetchers.py tests/test_crawler_browser_e2e.py -m browser -q -o addopts= diff --git a/AGENT.md b/AGENT.md index 7c0b7a61..3a5e5139 100644 --- a/AGENT.md +++ b/AGENT.md @@ -45,3 +45,40 @@ Schema changes: add Alembic migration (`alembic revision`). **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). Durable jobs in `web/src/server/pipelineJobsDb.ts`. Export: `GET /api/report/export`, `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. + +1. **React context — `useReport` / `ReportProvider`** + - Report views call `useReport()`. That only works inside `ReportAppClient` → `ReportProvider`. + - **Do:** Render report views via `ReportShell` (wraps `ReportAppClient` internally). + - **Don't:** Import a view directly in `app/*/page.tsx` without `ReportShell`. + - Standalone routes under `web/app/` (e.g. `log-analyzer`, `indexation`) are **not** auto-wrapped by `(reports)/layout`. + + ```tsx + // ✅ + import ReportShell from '@/ReportShell'; + export default function Page() { + return ; + } + ``` + +2. **Python — local imports shadow module imports** + - `from ..config import get_int` anywhere 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. + +3. **PostgreSQL rows — never `row[0]`** + - Connections may use psycopg `dict_row`. `row[0]` → `KeyError: 0` on dict rows; tuple-only unit tests still pass. + - **Do:** `_row_field(row, "id", index=0)` from `website_profiling.db._common` (pattern in `property_store.py`). + - **Don't:** `fetchone()[0]` on `INSERT … RETURNING` without `_row_field`. + + ```python + 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 + ``` + +**Checklist:** new report page uses `ReportShell` · no duplicate local imports in long functions · new `fetchone()` uses `_row_field` diff --git a/SECURITY.md b/SECURITY.md index e001c844..49b3936e 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -25,4 +25,5 @@ If you find a vulnerability **in Site Audit itself** (e.g. remote code execution ## Safe defaults - Run production deployments with strong `POSTGRES_PASSWORD` and `AUTH_SECRET` (see `docker-compose.prod.yml`). +- For client-facing dashboards, set `AUTH_DEFAULT_ROLE=client-readonly` so logins cannot run audits or mutate settings (API enforces 403; UI hides Run audit). - Do not commit `.env`, `.secrets/`, or OAuth client secrets. Google credentials are stored in PostgreSQL (`google_app_settings` and per-property columns on `properties`). diff --git a/alembic/versions/011_roadmap_foundation.py b/alembic/versions/011_roadmap_foundation.py new file mode 100644 index 00000000..7721db19 --- /dev/null +++ b/alembic/versions/011_roadmap_foundation.py @@ -0,0 +1,97 @@ +"""Roadmap foundation: issue workflow, property schedule, audit snapshots. + +Revision ID: 011_roadmap_foundation +Revises: 010_gsc_links_data +""" +from __future__ import annotations + +from alembic import op + +revision = "011_roadmap_foundation" +down_revision = "010_gsc_links_data" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.execute(""" + CREATE TABLE issue_status ( + id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, + property_id BIGINT NOT NULL REFERENCES properties(id) ON DELETE CASCADE, + report_id BIGINT, + issue_fingerprint TEXT NOT NULL, + category_id TEXT, + message TEXT NOT NULL, + url TEXT NOT NULL DEFAULT '', + priority TEXT NOT NULL DEFAULT 'Medium', + status TEXT NOT NULL DEFAULT 'open', + assignee TEXT, + note TEXT, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + UNIQUE (property_id, issue_fingerprint) + ); + CREATE INDEX idx_issue_status_property ON issue_status(property_id, status); + CREATE INDEX idx_issue_status_report ON issue_status(report_id); + + ALTER TABLE properties + ADD COLUMN IF NOT EXISTS schedule_cron TEXT, + ADD COLUMN IF NOT EXISTS alert_webhook_url TEXT, + ADD COLUMN IF NOT EXISTS alert_email TEXT; + + CREATE TABLE audit_health_snapshots ( + id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, + property_id BIGINT REFERENCES properties(id) ON DELETE CASCADE, + report_id BIGINT NOT NULL, + canonical_domain TEXT, + health_score INTEGER, + category_scores JSONB NOT NULL DEFAULT '{}', + issue_counts JSONB NOT NULL DEFAULT '{}', + generated_at TIMESTAMPTZ NOT NULL DEFAULT now() + ); + CREATE INDEX idx_audit_health_property ON audit_health_snapshots(property_id, generated_at DESC); + CREATE INDEX idx_audit_health_report ON audit_health_snapshots(report_id); + + CREATE TABLE gsc_links_snapshots ( + id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, + property_id BIGINT NOT NULL REFERENCES properties(id) ON DELETE CASCADE, + fetched_at TIMESTAMPTZ NOT NULL DEFAULT now(), + referring_domains INTEGER NOT NULL DEFAULT 0, + top_domains JSONB NOT NULL DEFAULT '[]' + ); + CREATE INDEX idx_gsc_links_snapshots_property ON gsc_links_snapshots(property_id, fetched_at DESC); + + CREATE TABLE log_file_uploads ( + id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, + property_id BIGINT NOT NULL REFERENCES properties(id) ON DELETE CASCADE, + filename TEXT NOT NULL, + line_count INTEGER NOT NULL DEFAULT 0, + uploaded_at TIMESTAMPTZ NOT NULL DEFAULT now(), + analysis JSONB NOT NULL DEFAULT '{}' + ); + CREATE INDEX idx_log_uploads_property ON log_file_uploads(property_id, uploaded_at DESC); + + CREATE TABLE crux_snapshots ( + id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, + property_id BIGINT REFERENCES properties(id) ON DELETE CASCADE, + origin TEXT NOT NULL, + url TEXT, + metrics JSONB NOT NULL DEFAULT '{}', + fetched_at TIMESTAMPTZ NOT NULL DEFAULT now() + ); + CREATE INDEX idx_crux_snapshots_origin ON crux_snapshots(origin, fetched_at DESC); + """) + + +def downgrade() -> None: + op.execute(""" + DROP TABLE IF EXISTS crux_snapshots; + DROP TABLE IF EXISTS log_file_uploads; + DROP TABLE IF EXISTS gsc_links_snapshots; + DROP TABLE IF EXISTS audit_health_snapshots; + ALTER TABLE properties + DROP COLUMN IF EXISTS schedule_cron, + DROP COLUMN IF EXISTS alert_webhook_url, + DROP COLUMN IF EXISTS alert_email; + DROP TABLE IF EXISTS issue_status; + """) diff --git a/docs/GLOSSARY.md b/docs/GLOSSARY.md index 663fb7a1..d62b907d 100644 --- a/docs/GLOSSARY.md +++ b/docs/GLOSSARY.md @@ -24,7 +24,24 @@ UI terms agencies recognize, mapped to internal keys and data sources. | Analytics (GA4) | `traffic`, `google_data` (scoped by `property_id`) | GA4 API per property | Google Analytics | | Keywords | `keywords-explorer`, `keyword_data` | Crawl + Search Console + research | Keyword tools (site-scoped) | | Compare audits | `compare` | Two report payloads | Historical comparison | +| Indexation & coverage | `indexation`, `indexation_coverage` | Crawl + sitemap + GSC URL join | SEMrush indexability, GSC coverage | +| CrUX field CWV | `crux_summary` | Chrome UX Report API | PageSpeed field data | +| Executive summary | `executive_summary` | Issues + GSC + optional AI | Agency audit cover page | | Run audit | Pipeline / `python -m src` | User-triggered job | Start site audit | +| Issue task board | `issues` view (board tab), `issue_status` | Workflow persistence per property | Jira-style triage | +| Query–page alignment | `keywords-explorer` alignment tab, `query_page_misalignment` | Search Console heuristics | Landing-page targeting | +| Crawl segments | `site-structure` overview, `crawl_segments` | `crawl_path_segments` config + crawl | Section health rollups | +| Log analyzer | `log-analyzer` view | Uploaded access log vs crawl | Log file insights | +| Competitor link gap | `backlinks` overview, `competitor_link_gap` | GSC Links import + `competitor_domains` | Link gap analysis | +| Moz / Majestic overlay | `third_party_overlays` on `gsc_links`, `/api/backlinks/third-party-import` | CSV export upload | Estimated referring-domain comparison vs GSC sample | +| Bing backlinks | `bing_backlinks`, Integrations sync | Bing Webmaster API (optional) | Secondary link source | +| SERP competition overlay | `serp_estimated_competition` on keywords | SerpAPI (optional) | Estimated SERP difficulty | +| Scheduled audits | `properties.schedule_cron`, `/api/schedule/check` | Cron + pipeline spawn | Recurring site audit — see [OPS.md](OPS.md) | +| Property alerts | `alert_webhook_url`, `/api/alerts/check` | Health snapshot rules | Ops notifications | +| Content brief | Keywords Brief button, `/api/keywords/content-brief` | LLM or deterministic | Content planning | +| AI issue fix | `llm_recommendation`, `/api/issues/fix-suggestion` | LLM on demand + report build | Actionable remediation | +| Read-only session | `AUTH_DEFAULT_ROLE=client-readonly`, `/api/auth/session` | Session cookie | Client view-only access | +| Export executive summary | `export_audit_html/pdf/csv`, `executive_summary` | Report payload + optional AI | Client deliverable | ## Metric names diff --git a/docs/OPS.md b/docs/OPS.md new file mode 100644 index 00000000..b0a808c5 --- /dev/null +++ b/docs/OPS.md @@ -0,0 +1,83 @@ +# Site Audit — operations + +Cron-friendly HTTP endpoints for scheduled audits and property alerts. All routes require local access (same host) unless you proxy them behind your own auth. + +## Scheduled audits + +**Endpoint:** `POST /api/schedule/check` + +Runs `schedule_runner.py`, which: + +1. Matches each property’s `schedule_cron` (UTC, five-field cron) against the current minute. +2. Sets `active_property_id` and `start_url` on the global pipeline config. +3. Applies the property’s `default_crawl_preset` (starter / spa / ecommerce / performance). +4. Spawns a full audit (`python -m src` with `WP_PROPERTY_ID`). + +**Example (every Monday 06:00 UTC):** + +```bash +# crontab -e +0 6 * * 1 curl -fsS -X POST http://127.0.0.1:3000/api/schedule/check +``` + +Response includes `output` (runner log) and `gscLinksStale` (properties needing a GSC Links CSV re-import). + +## Property alerts + +**Endpoint:** `POST /api/alerts/check?propertyId={id}` + +Checks health-score drops and stale GSC Links imports; POSTs to `alert_webhook_url` when configured on the property. + +```bash +0 7 * * * curl -fsS -X POST "http://127.0.0.1:3000/api/alerts/check?propertyId=1" +``` + +Configure webhook, email, and cron per property under **Integrations → Scheduled audits & alerts**. + +## Read-only client access + +Set `AUTH_DEFAULT_ROLE=client-readonly` so session logins cannot run audits or save settings. The API returns 403 on mutations; the UI hides **Run audit** and disables save controls. + +## Database migrations + +After pulling roadmap changes, apply Alembic revision `011` (included in the full local/CI test run): + +```bash +./local-test all +# or, if Postgres is already up: ./local-test quick +``` + +## Running tests + +**Python (core, 100% coverage on non-omitted modules):** + +```bash +export DATABASE_URL=postgres://profiling:profiling@localhost:5432/website_profiling +alembic upgrade head +pytest tests/ -m "not browser" +``` + +Integration tests (`@pytest.mark.integration`) skip when `DATABASE_URL` is unset. Browser crawl E2E: + +```bash +pytest tests/test_crawler_browser_e2e.py -m browser +``` + +**Reporting and tools** (separate 100% coverage gates, same as CI): + +```bash +pytest tests/test_categories_roadmap.py tests/test_report_categories_golden.py \ + tests/test_categories_coverage.py tests/test_indexation_coverage.py tests/test_crawl_segments.py \ + tests/test_terminology.py \ + --cov=website_profiling.reporting --cov-config=.coveragerc.reporting --cov-fail-under=100 -o addopts= + +pytest tests/test_alert_checker.py tests/test_schedule_runner.py tests/test_export_audit.py \ + tests/test_export_audit_coverage.py \ + --cov=website_profiling.tools --cov-config=.coveragerc.tools --cov-fail-under=100 -o addopts= +``` + +**Web (Vitest route and lib tests):** + +```bash +cd web && npm test +``` diff --git a/input.txt.example b/input.txt.example index c829d07f..8e7b1840 100644 --- a/input.txt.example +++ b/input.txt.example @@ -50,6 +50,14 @@ lighthouse_categories = performance,accessibility,best-practices,seo lighthouse_iterations = 1 run_lighthouse = true run_lighthouse_on_pages = true +enable_crux = false +competitor_domains = +bing_webmaster_api_key = +serp_api_key = +export_logo_url = +custom_extraction_regex = +crawl_path_segments = +crawl_ignore_params = lighthouse_max_pages = 2 lighthouse_concurrency = 2 diff --git a/pipeline-config.example.txt b/pipeline-config.example.txt index b7e5b7f2..aee1441b 100644 --- a/pipeline-config.example.txt +++ b/pipeline-config.example.txt @@ -51,6 +51,14 @@ lighthouse_categories = performance,accessibility,best-practices,seo lighthouse_iterations = 1 run_lighthouse = true run_lighthouse_on_pages = true +enable_crux = false +competitor_domains = +bing_webmaster_api_key = +serp_api_key = +export_logo_url = +custom_extraction_regex = +crawl_path_segments = +crawl_ignore_params = lighthouse_max_pages = 2 lighthouse_concurrency = 2 diff --git a/pytest.ini b/pytest.ini index 3514d814..16a81ab4 100644 --- a/pytest.ini +++ b/pytest.ini @@ -3,6 +3,7 @@ pythonpath = src testpaths = tests markers = browser: integration tests requiring Chromium/Playwright (deselect with '-m "not browser"') + integration: requires DATABASE_URL (Postgres); skipped locally when unset addopts = --cov=website_profiling --cov-config=.coveragerc diff --git a/src/website_profiling/analysis/log_parser.py b/src/website_profiling/analysis/log_parser.py new file mode 100644 index 00000000..63999bc0 --- /dev/null +++ b/src/website_profiling/analysis/log_parser.py @@ -0,0 +1,69 @@ +"""Parse nginx/apache combined logs for crawl budget insights.""" +from __future__ import annotations + +import re +from collections import Counter +from typing import Any + +# Common combined log: host ident user [time] "METHOD path PROTO" status size "referer" "ua" +_COMBINED_RE = re.compile( + r'^\S+\s+\S+\s+\S+\s+\[[^\]]+\]\s+"[A-Z]+\s+(\S+)\s+[^"]*"\s+(\d{3})\s+\S+\s+"[^"]*"\s+"([^"]*)"', +) + + +def parse_access_log_lines(lines: list[str]) -> dict[str, Any]: + """Return hit counts and URL sets from access log lines.""" + url_hits: Counter[str] = Counter() + status_hits: Counter[str] = Counter() + googlebot_hits = 0 + parsed_lines = 0 + + for line in lines: + line = line.strip() + if not line or line.startswith("#"): + continue + m = _COMBINED_RE.match(line) + if not m: + continue + parsed_lines += 1 + path, status, ua = m.group(1), m.group(2), m.group(3).lower() + url_hits[path] += 1 + status_hits[status] += 1 + if "googlebot" in ua: + googlebot_hits += 1 + + top_urls = [{"path": p, "hits": c} for p, c in url_hits.most_common(100)] + return { + "parsed_lines": parsed_lines, + "unique_paths": len(url_hits), + "googlebot_hits": googlebot_hits, + "status_counts": dict(status_hits), + "top_paths": top_urls, + } + + +def compare_log_to_crawl( + log_analysis: dict[str, Any], + crawl_urls: list[str], + start_url: str, +) -> dict[str, Any]: + """Paths in logs but not crawled, and crawled but not in logs.""" + from urllib.parse import urlparse + + log_paths = {row["path"] for row in log_analysis.get("top_paths") or []} + crawl_paths: set[str] = set() + for u in crawl_urls: + try: + crawl_paths.add(urlparse(u).path or "/") + except Exception: + continue + + log_only = sorted(log_paths - crawl_paths)[:200] + crawl_only = sorted(crawl_paths - log_paths)[:200] + return { + "log_only_paths": log_only, + "crawl_only_paths": crawl_only, + "log_only_count": len(log_paths - crawl_paths), + "crawl_only_count": len(crawl_paths - log_paths), + "origin": start_url, + } diff --git a/src/website_profiling/commands/pipeline_cmd.py b/src/website_profiling/commands/pipeline_cmd.py index 9906015d..4100a3de 100644 --- a/src/website_profiling/commands/pipeline_cmd.py +++ b/src/website_profiling/commands/pipeline_cmd.py @@ -50,6 +50,39 @@ def select_lighthouse_urls_from_crawl(df: pd.DataFrame, max_pages: int) -> list[ ) +def select_lighthouse_urls_from_gsc( + google_data: dict | None, + crawl_urls: list[str], + max_pages: int, +) -> list[str]: + """Prefer top GSC pages by clicks that exist in crawl.""" + if not google_data or max_pages <= 0: + return [] + gsc = google_data.get("gsc") if isinstance(google_data.get("gsc"), dict) else {} + pages = gsc.get("pages") if isinstance(gsc.get("pages"), list) else [] + crawl_set = {u.rstrip("/") for u in crawl_urls} + ranked: list[tuple[float, str]] = [] + for row in pages: + if not isinstance(row, dict): + continue + url = str(row.get("page") or row.get("url") or "").strip() + if not url: + continue + norm = url.rstrip("/") + if norm not in crawl_set and url not in crawl_set: + continue + try: + clicks = float(row.get("clicks") or 0) + except (TypeError, ValueError): + clicks = 0.0 + ranked.append((clicks, url)) + ranked.sort(key=lambda x: -x[0]) + picked = [u for _, u in ranked[:max_pages]] + if picked: + return picked + return crawl_urls[:max_pages] + + def run(cfg: dict, args: argparse.Namespace) -> None: use_database = True @@ -123,6 +156,9 @@ def _run_crawl(cfg: dict, use_database: bool) -> None: js_console_levels = (cfg.get("crawl_js_console_levels") or "error,warning").strip() capture_failed_requests = get_bool(cfg, "crawl_js_capture_failed_requests", False) console_max_per_page = get_int(cfg, "crawl_js_console_max_per_page", 20) or 20 + custom_extraction_regex = (cfg.get("custom_extraction_regex") or "").strip() + crawl_ignore_raw = (cfg.get("crawl_ignore_params") or "").strip() + crawl_ignore_params = [p.strip() for p in crawl_ignore_raw.split(",") if p.strip()] or None print("Crawling...") run_crawler( start_url=start_url, @@ -153,6 +189,8 @@ def _run_crawl(cfg: dict, use_database: bool) -> None: js_console_levels=js_console_levels, capture_failed_requests=capture_failed_requests, console_max_per_page=console_max_per_page, + custom_extraction_regex=custom_extraction_regex, + crawl_ignore_params=crawl_ignore_params, ) print("[Crawl] Done.", flush=True) print("Crawl results: PostgreSQL") @@ -166,7 +204,18 @@ def _run_lighthouse_on_pages(cfg: dict, lighthouse_max_pages: int) -> None: with db_session() as conn: run_id = get_latest_crawl_run_id(conn) df = read_crawl(conn, run_id) - urls_200 = select_lighthouse_urls_from_crawl(df, lighthouse_max_pages) + google_data = None + try: + from ..integrations.google.store import read_latest_google_data + from .config_resolve import active_property_id_from_cfg + + google_data = read_latest_google_data(conn, property_id=active_property_id_from_cfg(cfg)) + except Exception: + google_data = None + crawl_urls = select_lighthouse_urls_from_crawl(df, lighthouse_max_pages * 3) + urls_200 = select_lighthouse_urls_from_gsc(google_data, crawl_urls, lighthouse_max_pages) + if not urls_200: + urls_200 = select_lighthouse_urls_from_crawl(df, lighthouse_max_pages) if not urls_200: print("[Lighthouse on pages] No 200 OK URLs in crawl. Skip.", flush=True) else: diff --git a/src/website_profiling/common.py b/src/website_profiling/common.py index 1528e369..82e8cd04 100644 --- a/src/website_profiling/common.py +++ b/src/website_profiling/common.py @@ -63,7 +63,39 @@ def save_edges(edges: list[tuple[str, str]], path: str) -> None: pd.DataFrame(edges, columns=["from", "to"]).to_csv(path, index=False) -def normalize_link(base: str, href: str) -> str | None: +_TRACKING_PARAM_PREFIXES = ("utm_",) +_FACET_PARAM_NAMES = frozenset({"sort", "filter", "page", "offset", "limit"}) + + +def strip_crawl_query_params(url: str, ignore_params: list[str] | None = None) -> str: + """Remove tracking and facet query params for crawl deduplication.""" + parsed = urlparse(url) + if not parsed.query: + return url.rstrip("/") + ignore = {p.lower() for p in (ignore_params or [])} + parts = [] + for pair in parsed.query.split("&"): + if not pair: + continue + key = pair.split("=", 1)[0].lower() + if key in ignore: + continue + if any(key.startswith(p) for p in _TRACKING_PARAM_PREFIXES): + continue + if key in _FACET_PARAM_NAMES: + continue + parts.append(pair) + query = "&".join(parts) + rebuilt = parsed._replace(query=query).geturl() + return rebuilt.rstrip("/") + + +def normalize_link( + base: str, + href: str, + strip_params: bool = True, + ignore_params: list[str] | None = None, +) -> str | None: if not href: return None href = href.strip() @@ -74,7 +106,10 @@ def normalize_link(base: str, href: str) -> str | None: parsed = urlparse(joined) if parsed.scheme not in ("http", "https"): return None - return joined.rstrip("/") + out = joined.rstrip("/") + if strip_params: + out = strip_crawl_query_params(out, ignore_params) + return out def parse_links(base_url: str, html_text: str) -> tuple[str, set[str]]: diff --git a/src/website_profiling/crawl/crawler.py b/src/website_profiling/crawl/crawler.py index 2adc2f7d..19b8c66c 100644 --- a/src/website_profiling/crawl/crawler.py +++ b/src/website_profiling/crawl/crawler.py @@ -77,6 +77,8 @@ def __init__( js_console_levels: str = "error,warning", capture_failed_requests: bool = False, console_max_per_page: int = 20, + custom_extraction_regex: str = "", + crawl_ignore_params: Optional[list[str]] = None, ): self.start_url = start_url.rstrip("/") self.start_netloc = urlparse(self.start_url).netloc @@ -103,6 +105,8 @@ def __init__( self.store_content_excerpt = bool(store_content_excerpt) self.content_excerpt_max_chars = max(0, int(content_excerpt_max_chars or 0)) self._wappalyzer_instance = None + self.custom_extraction_regex = (custom_extraction_regex or "").strip() + self.crawl_ignore_params = list(crawl_ignore_params or []) self.queue = Queue() if not _url_matches_exclude(self.start_url, self.exclude_urls): @@ -443,6 +447,11 @@ def worker(self, url): canonical_url = parsed["canonical_url"] ext = parsed["ext"] + if self.crawl_ignore_params: + from ..common import strip_crawl_query_params + + links = [strip_crawl_query_params(l, self.crawl_ignore_params) for l in links] + for link in links: if _url_matches_exclude(link, self.exclude_urls): continue @@ -480,6 +489,16 @@ def worker(self, url): ext["depth"] = self.depths.get(url) + if self.custom_extraction_regex and text: + import re + + try: + match = re.search(self.custom_extraction_regex, text) + if match: + ext["custom_extract"] = match.group(1) if match.lastindex else match.group(0) + except re.error: + pass + if self.polite_delay: time.sleep(self.polite_delay) @@ -761,6 +780,8 @@ def run_crawler( js_console_levels: str = "error,warning", capture_failed_requests: bool = False, console_max_per_page: int = 20, + custom_extraction_regex: str = "", + crawl_ignore_params: Optional[list[str]] = None, ) -> pd.DataFrame: """Run crawler and optionally save to CSV/JSON or PostgreSQL. Returns DataFrame.""" import sys @@ -795,6 +816,8 @@ def run_crawler( js_console_levels=js_console_levels, capture_failed_requests=capture_failed_requests, console_max_per_page=console_max_per_page, + custom_extraction_regex=custom_extraction_regex, + crawl_ignore_params=crawl_ignore_params, ) stream_run_id: Optional[int] = None if output_db: diff --git a/src/website_profiling/crawl_presets.py b/src/website_profiling/crawl_presets.py new file mode 100644 index 00000000..790d2bf4 --- /dev/null +++ b/src/website_profiling/crawl_presets.py @@ -0,0 +1,50 @@ +"""Property crawl presets — keep in sync with web/src/lib/crawlPresets.ts.""" +from __future__ import annotations + +from typing import Any + +CRAWL_PRESET_PATCHES: dict[str, dict[str, str]] = { + "starter": { + "max_pages": "500", + "crawl_render_mode": "static", + "crawl_stream_to_db": "false", + "run_lighthouse_on_pages": "true", + "lighthouse_max_pages": "5", + }, + "spa": { + "max_pages": "2000", + "crawl_render_mode": "auto", + "crawl_js_concurrency": "3", + "crawl_stream_to_db": "true", + "run_lighthouse_on_pages": "true", + "lighthouse_max_pages": "10", + }, + "ecommerce": { + "max_pages": "10000", + "crawl_render_mode": "auto", + "crawl_stream_to_db": "true", + "concurrency": "12", + "run_lighthouse_on_pages": "false", + "lighthouse_max_pages": "0", + }, + "performance": { + "max_pages": "1000", + "crawl_render_mode": "static", + "run_lighthouse": "true", + "run_lighthouse_on_pages": "true", + "lighthouse_max_pages": "25", + "lighthouse_strategy": "mobile", + "lighthouse_categories": "performance,accessibility,best-practices,seo", + }, +} + +DEFAULT_CRAWL_PRESET_ID = "starter" + + +def apply_crawl_preset(preset_id: str, config: dict[str, Any]) -> dict[str, str]: + """Merge preset patch into pipeline config (string values only).""" + key = preset_id if preset_id in CRAWL_PRESET_PATCHES else DEFAULT_CRAWL_PRESET_ID + patch = CRAWL_PRESET_PATCHES[key] + merged: dict[str, str] = {str(k): str(v) for k, v in config.items()} + merged.update(patch) + return merged diff --git a/src/website_profiling/db/report_store.py b/src/website_profiling/db/report_store.py index 5b6b3ba4..ffd794c3 100644 --- a/src/website_profiling/db/report_store.py +++ b/src/website_profiling/db/report_store.py @@ -6,7 +6,7 @@ from psycopg import Connection -from ._common import _json_val, _now_iso, _parse_row_json +from ._common import _json_val, _now_iso, _parse_row_json, _row_field from .crawl_store import get_crawl_run_info @@ -36,14 +36,71 @@ def _canonical_domain_from_report(conn: Connection, report_data: dict[str, Any]) return _extract_hostname(start_url) or _extract_hostname(fallback_url) +def _write_audit_health_snapshot( + conn: Connection, + report_id: int, + canonical_domain: str, + report_data: dict[str, Any], +) -> None: + """Persist health score row for portfolio sparklines and alerts.""" + import json + + categories = report_data.get("categories") or [] + scores = [ + float(c.get("score")) + for c in categories + if isinstance(c, dict) and isinstance(c.get("score"), (int, float)) + ] + health_score = round(sum(scores) / len(scores)) if scores else None + category_scores: dict[str, float] = {} + issue_counts = {"Critical": 0, "High": 0, "Medium": 0, "Low": 0} + for cat in categories: + if not isinstance(cat, dict): + continue + key = str(cat.get("id") or cat.get("name") or "unknown") + if isinstance(cat.get("score"), (int, float)): + category_scores[key] = float(cat["score"]) + for issue in cat.get("issues") or []: + if not isinstance(issue, dict): + continue + p = str(issue.get("priority") or "Medium") + issue_counts[p] = issue_counts.get(p, 0) + 1 + property_id = report_data.get("property_id") + try: + property_id = int(property_id) if property_id is not None else None + except (TypeError, ValueError): + property_id = None + conn.execute( + """INSERT INTO audit_health_snapshots + (property_id, report_id, canonical_domain, health_score, category_scores, issue_counts, generated_at) + VALUES (%s, %s, %s, %s, %s, %s, now())""", + ( + property_id, + report_id, + canonical_domain or None, + health_score, + json.dumps(category_scores), + json.dumps(issue_counts), + ), + ) + + def write_report_payload(conn: Connection, report_data: dict[str, Any]) -> None: site_name = str(report_data.get("site_name") or "") canonical_domain = _canonical_domain_from_report(conn, report_data) - conn.execute( + cur = conn.execute( """INSERT INTO report_payload (generated_at, site_name, canonical_domain, data) - VALUES (%s, %s, %s, %s)""", + VALUES (%s, %s, %s, %s) RETURNING id""", (_now_iso(), site_name, canonical_domain, _json_val(report_data)), ) + row = cur.fetchone() + rid = _row_field(row, "id", index=0) + report_id = int(rid) if rid is not None else None + if report_id is not None: + try: + _write_audit_health_snapshot(conn, report_id, canonical_domain, report_data) + except Exception: + pass conn.commit() diff --git a/src/website_profiling/integrations/bing/webmaster.py b/src/website_profiling/integrations/bing/webmaster.py new file mode 100644 index 00000000..b4578055 --- /dev/null +++ b/src/website_profiling/integrations/bing/webmaster.py @@ -0,0 +1,69 @@ +"""Bing Webmaster Tools integration (GetLinkCounts for inbound link pages).""" +from __future__ import annotations + +import json +import urllib.error +import urllib.parse +import urllib.request +from typing import Any + + +def _bing_json_get(method: str, api_key: str, **params: str | int) -> dict[str, Any]: + query = urllib.parse.urlencode({**params, "apikey": api_key}) + url = f"https://ssl.bing.com/webmaster/api.svc/json/{method}?{query}" + try: + with urllib.request.urlopen(url, timeout=25) as resp: + return json.loads(resp.read().decode("utf-8")) + except urllib.error.HTTPError as e: + body = e.read().decode("utf-8", errors="replace") + try: + err = json.loads(body) + msg = err.get("Message") or err.get("message") or body + except json.JSONDecodeError: + msg = body or str(e) + return {"error": msg, "http_status": e.code} + except Exception as e: + return {"error": str(e)} + + +def fetch_bing_backlinks_summary(api_key: str, site_url: str) -> dict[str, Any]: + """ + Fetch pages with inbound links via Bing Webmaster GetLinkCounts. + Requires a verified site and API key from Bing Webmaster Tools. + """ + key = (api_key or "").strip() + site = (site_url or "").strip() + if not key or not site: + return {"ok": False, "error": "Bing API key and site URL required", "source": "bing_webmaster"} + + raw = _bing_json_get("GetLinkCounts", key, siteUrl=site, page=0) + if raw.get("error"): + return { + "ok": False, + "error": str(raw.get("error")), + "source": "bing_webmaster", + "site_url": site, + } + + payload = raw.get("d") if isinstance(raw.get("d"), dict) else raw + links = payload.get("Links") if isinstance(payload, dict) else [] + pages: list[dict[str, Any]] = [] + for row in links or []: + if not isinstance(row, dict): + continue + pages.append({ + "url": row.get("Url"), + "inbound_links": int(row.get("Count") or 0), + }) + + total_inbound = sum(int(p.get("inbound_links") or 0) for p in pages) + return { + "ok": True, + "source": "bing_webmaster", + "site_url": site, + "linked_pages": pages[:100], + "linked_page_count": len(pages), + "total_inbound_links": total_inbound, + "total_pages": int(payload.get("TotalPages") or 1) if isinstance(payload, dict) else 1, + "provenance": "Bing Webmaster", + } diff --git a/src/website_profiling/integrations/crux/__init__.py b/src/website_profiling/integrations/crux/__init__.py new file mode 100644 index 00000000..372c53f9 --- /dev/null +++ b/src/website_profiling/integrations/crux/__init__.py @@ -0,0 +1,3 @@ +from .fetch import fetch_crux_origin_metrics + +__all__ = ["fetch_crux_origin_metrics"] diff --git a/src/website_profiling/integrations/crux/fetch.py b/src/website_profiling/integrations/crux/fetch.py new file mode 100644 index 00000000..a34ef253 --- /dev/null +++ b/src/website_profiling/integrations/crux/fetch.py @@ -0,0 +1,57 @@ +"""Chrome UX Report (CrUX) API — field Core Web Vitals (public origin data).""" +from __future__ import annotations + +import json +from typing import Any +from urllib.parse import urlparse +from urllib.request import Request, urlopen + +CRUX_API = "https://chromeuxreport.googleapis.com/v1/records:queryRecord" + + +def _origin_from_url(url: str) -> str: + p = urlparse(url.strip()) + if not p.scheme or not p.netloc: + return "" + return f"{p.scheme}://{p.netloc}" + + +def fetch_crux_origin_metrics(origin_or_url: str, api_key: str | None = None) -> dict[str, Any]: + """ + Fetch origin-level CrUX metrics. API key optional for public quota; + set CRUX_API_KEY env or pass api_key for higher limits. + """ + origin = origin_or_url if origin_or_url.startswith("http") else _origin_from_url(origin_or_url) + if not origin: + return {"ok": False, "error": "Invalid origin"} + + key = (api_key or "").strip() + url = f"{CRUX_API}?key={key}" if key else CRUX_API + body = json.dumps({"origin": origin}).encode("utf-8") + req = Request(url, data=body, headers={"Content-Type": "application/json"}, method="POST") + try: + with urlopen(req, timeout=20) as resp: + data = json.loads(resp.read().decode("utf-8")) + except Exception as e: + return {"ok": False, "origin": origin, "error": str(e)} + + record = data.get("record") or {} + metrics = record.get("metrics") or {} + parsed: dict[str, Any] = {"origin": origin, "ok": True, "metrics": {}} + for name, m in metrics.items(): + if not isinstance(m, dict): + continue + hist = m.get("histogram") or [] + p75 = m.get("percentiles", {}).get("p75") + parsed["metrics"][name] = {"p75": p75, "histogram": hist} + + # Pass/fail heuristics (CrUX thresholds) + lcp = parsed["metrics"].get("largest_contentful_paint", {}).get("p75") + inp = parsed["metrics"].get("interaction_to_next_paint", {}).get("p75") + cls = parsed["metrics"].get("cumulative_layout_shift", {}).get("p75") + parsed["pass"] = { + "lcp": lcp is not None and float(lcp) <= 2500, + "inp": inp is not None and float(inp) <= 200, + "cls": cls is not None and float(cls) <= 0.1, + } + return parsed diff --git a/src/website_profiling/integrations/google/competitor_links.py b/src/website_profiling/integrations/google/competitor_links.py new file mode 100644 index 00000000..feec3cb9 --- /dev/null +++ b/src/website_profiling/integrations/google/competitor_links.py @@ -0,0 +1,95 @@ +"""Competitor referring-domain gap from imported GSC Links data.""" +from __future__ import annotations + +from typing import Any +from urllib.parse import urlparse + + +def _domain_from_site(site: str) -> str: + s = (site or "").strip().lower() + if not s: + return "" + if "://" in s: + try: + return (urlparse(s).hostname or "").lower() + except Exception: + return s + return s.lstrip(".") + + +def build_competitor_link_gap( + gsc_links: dict[str, Any] | None, + competitor_domains: list[str], +) -> dict[str, Any] | None: + """ + Compare top linking domains from GSC Links import against user-defined competitors. + Returns domains that link to competitors but not to the property (Estimated). + """ + if not gsc_links or not competitor_domains: + return None + our_domains = { + _domain_from_site(row.get("site") or "") + for row in (gsc_links.get("top_linking_sites") or []) + if isinstance(row, dict) + } + our_domains.discard("") + competitors = [_domain_from_site(d) for d in competitor_domains] + competitors = [d for d in competitors if d] + if not competitors: + return None + gaps = [] + for comp in competitors: + if comp not in our_domains: + gaps.append({ + "competitor": comp, + "links_to_us": False, + "note": "No referring domain match in imported GSC Links sample.", + }) + else: + gaps.append({"competitor": comp, "links_to_us": True}) + return { + "source": "gsc_links_import", + "provenance": "Search Console", + "competitors": gaps, + "our_referring_domain_count": len(our_domains), + } + + +def parse_referring_domains_from_csv(csv_text: str) -> list[str]: + """Extract referring domain names from a GSC Links-style CSV export.""" + import csv + import io + + domains: list[str] = [] + if not (csv_text or "").strip(): + return domains + reader = csv.DictReader(io.StringIO(csv_text)) + if not reader.fieldnames: + return domains + fields = {f.lower().strip(): f for f in reader.fieldnames if f} + site_col = fields.get("site") or fields.get("domain") or fields.get("linking site") + for row in reader: + raw = (row.get(site_col) if site_col else None) or "" + dom = _domain_from_site(str(raw)) + if dom and dom not in domains: + domains.append(dom) + return domains + + +def build_competitor_domain_gap( + our_domains: set[str], + competitor_domain: str, + competitor_referring_domains: list[str], +) -> dict[str, Any]: + """Domains in competitor sample that are not in our GSC Links sample (Estimated).""" + comp = _domain_from_site(competitor_domain) + comp_refs = {_domain_from_site(d) for d in competitor_referring_domains if d} + comp_refs.discard("") + missing = sorted(comp_refs - our_domains) + return { + "competitor": comp, + "competitor_referring_count": len(comp_refs), + "gap_domains": missing[:100], + "gap_count": len(missing), + "provenance": "Estimated", + } diff --git a/src/website_profiling/integrations/google/gsc_links_store.py b/src/website_profiling/integrations/google/gsc_links_store.py index a8929ed2..85af6030 100644 --- a/src/website_profiling/integrations/google/gsc_links_store.py +++ b/src/website_profiling/integrations/google/gsc_links_store.py @@ -106,6 +106,12 @@ def import_gsc_links_csv( file_name=file_name, ) write_gsc_links_data(conn, merged, property_id=property_id) + try: + from .gsc_links_sync import snapshot_gsc_links + + snapshot_gsc_links(property_id, merged) + except Exception: + pass return { "ok": True, "imported_at": merged.get("imported_at"), @@ -120,6 +126,26 @@ def _last_export_type(data: dict[str, Any]) -> str | None: return types[-1] if types else None +def import_third_party_links_overlay( + conn: Connection, + property_id: int, + overlay: dict[str, Any], +) -> dict[str, Any]: + """Merge Moz/Majestic CSV overlay into latest gsc_links_data snapshot.""" + existing = read_latest_gsc_links_data(conn, property_id, for_report=False) or { + "imported_at": overlay.get("imported_at"), + "source": "gsc_links_csv", + "top_linking_sites": [], + } + overlays = list(existing.get("third_party_overlays") or []) + provider = str(overlay.get("provider") or "").strip().lower() + overlays = [o for o in overlays if str(o.get("provider") or "").lower() != provider] + overlays.append(overlay) + merged = {**existing, "third_party_overlays": overlays} + write_gsc_links_data(conn, merged, property_id=property_id) + return {"ok": True, "overlay": overlay, "overlay_count": len(overlays)} + + def read_gsc_links_status( conn: Connection, property_id: int, diff --git a/src/website_profiling/integrations/google/gsc_links_sync.py b/src/website_profiling/integrations/google/gsc_links_sync.py new file mode 100644 index 00000000..ee43059a --- /dev/null +++ b/src/website_profiling/integrations/google/gsc_links_sync.py @@ -0,0 +1,72 @@ +"""Auto-sync GSC Links snapshots for link velocity tracking.""" +from __future__ import annotations + +import json +from typing import Any + + +def snapshot_gsc_links(property_id: int, gsc_links_data: dict[str, Any]) -> None: + """Store referring domain count snapshot for velocity charts.""" + from ...db.storage import db_session + + domains = gsc_links_data.get("top_linking_sites") or [] + count = len(domains) + top = [ + {"site": d.get("site"), "links": d.get("links")} + for d in domains[:50] + if isinstance(d, dict) + ] + with db_session() as conn: + conn.execute( + """INSERT INTO gsc_links_snapshots (property_id, referring_domains, top_domains) + VALUES (%s, %s, %s)""", + (property_id, count, json.dumps(top)), + ) + conn.commit() + + +def check_stale_gsc_links_imports(max_age_days: int = 7) -> list[dict[str, Any]]: + """Properties whose last GSC Links import is older than max_age_days.""" + from ...db.storage import db_session + + stale: list[dict[str, Any]] = [] + with db_session() as conn: + cur = conn.execute( + """ + SELECT p.id, p.name, MAX(g.imported_at) AS last_import + FROM properties p + LEFT JOIN gsc_links_data g ON g.property_id = p.id + GROUP BY p.id, p.name + """ + ) + for row in cur.fetchall() or []: + prop_id = row[0] if not hasattr(row, "keys") else row["id"] + name = row[1] if not hasattr(row, "keys") else row["name"] + last = row[2] if not hasattr(row, "keys") else row["last_import"] + if last is None: + stale.append({ + "property_id": int(prop_id), + "name": name, + "message": "No GSC Links import yet — upload CSV from Search Console → Links.", + "severity": "medium", + }) + continue + try: + from datetime import datetime, timezone + + if hasattr(last, "isoformat"): + imported = last if last.tzinfo else last.replace(tzinfo=timezone.utc) + else: + imported = datetime.fromisoformat(str(last).replace("Z", "+00:00")) + age_days = (datetime.now(timezone.utc) - imported).days + if age_days >= max_age_days: + stale.append({ + "property_id": int(prop_id), + "name": name, + "message": f"GSC Links import is {age_days} days old — re-import for velocity accuracy.", + "severity": "low", + "last_import": imported.isoformat(), + }) + except Exception: + continue + return stale diff --git a/src/website_profiling/integrations/google/keyword_enrich.py b/src/website_profiling/integrations/google/keyword_enrich.py index 75799497..10a1f77f 100644 --- a/src/website_profiling/integrations/google/keyword_enrich.py +++ b/src/website_profiling/integrations/google/keyword_enrich.py @@ -154,6 +154,50 @@ def industry_ctr(pos: float) -> float: # ── Cannibalisation ─────────────────────────────────────────────────────────── +def detect_query_page_misalignment( + rows: list[dict[str, Any]], + *, + min_impressions: int = 100, +) -> list[dict[str, Any]]: + """ + Flag GSC queries where the ranking URL may not match the best internal target + (heuristic: another page on-site has higher impressions for related terms). + """ + by_url: dict[str, dict[str, Any]] = {} + for row in rows: + url = str(row.get("gsc_url") or "").strip() + if url: + by_url[url] = row + + misaligned: list[dict[str, Any]] = [] + for row in rows: + kw = str(row.get("keyword") or "").strip().lower() + url = str(row.get("gsc_url") or "").strip() + impressions = int(row.get("gsc_impressions") or 0) + if not kw or not url or impressions < min_impressions: + continue + pos = float(row.get("gsc_position") or 0) + if pos <= 0 or pos > 30: + continue + # Same brand term on a different URL with more traffic potential + for other_url, other in by_url.items(): + if other_url == url: + continue + other_kw = str(other.get("keyword") or "").strip().lower() + if not other_kw or other_kw != kw: + continue + if int(other.get("traffic_potential") or 0) > int(row.get("traffic_potential") or 0): + misaligned.append({ + "keyword": kw, + "current_url": url, + "suggested_url": other_url, + "impressions": impressions, + "position": pos, + }) + break + return misaligned[:50] + + def detect_cannibalisation( gsc_by_page: dict[str, dict], ) -> list[dict[str, Any]]: @@ -545,6 +589,29 @@ def run_enrichment( reverse=True, ) + striking_distance = [ + r for r in rows + if r.get("gsc_position") is not None + and 4 <= float(r.get("gsc_position") or 0) <= 20 + and float(r.get("gsc_impressions") or 0) >= 50 + ] + striking_distance.sort( + key=lambda r: (float(r.get("gsc_impressions") or 0), -float(r.get("gsc_position") or 0)), + reverse=True, + ) + + query_misalignment = detect_query_page_misalignment(rows) + + serp_key = str((cfg or {}).get("serp_api_key") or "").strip() + serp_overlay_count = 0 + if serp_key: + try: + from ..serp.estimates import overlay_serp_estimates + + serp_overlay_count = overlay_serp_estimates(rows, serp_key) + except Exception: + pass + data_blob = { "fetched_at": fetched_at, "property_id": property_id, @@ -554,6 +621,11 @@ def run_enrichment( "suggest_count": sum(1 for r in rows if "suggest" in (r.get("sources") or []) or "youtube" in (r.get("sources") or []) or "questions" in (r.get("sources") or [])), "cannibalisation": cannibalisation[:50], "cannibalisation_count": len(cannibalisation), + "query_page_misalignment": query_misalignment, + "query_page_misalignment_count": len(query_misalignment), + "striking_distance": striking_distance[:100], + "striking_distance_count": len(striking_distance), + "serp_overlay_count": serp_overlay_count, "rows": rows, } diff --git a/src/website_profiling/integrations/links/third_party_csv.py b/src/website_profiling/integrations/links/third_party_csv.py new file mode 100644 index 00000000..23c9e821 --- /dev/null +++ b/src/website_profiling/integrations/links/third_party_csv.py @@ -0,0 +1,127 @@ +"""Parse Moz / Majestic referring-domain CSV exports (Estimated overlay).""" +from __future__ import annotations + +import csv +import io +from datetime import datetime, timezone +from typing import Any +from urllib.parse import urlparse + + +def _normalize_domain(value: str) -> str: + raw = (value or "").strip().lower() + if not raw: + return "" + if "://" in raw: + try: + return (urlparse(raw).hostname or "").lower() + except Exception: + return raw + return raw.lstrip(".") + + +def _pick_column(fieldnames: list[str] | None, *candidates: str) -> str | None: + if not fieldnames: + return None + lookup = {f.lower().strip(): f for f in fieldnames if f} + for name in candidates: + key = name.lower() + if key in lookup: + return lookup[key] + return None + + +def parse_third_party_referring_domains( + provider: str, + csv_text: str, +) -> list[dict[str, Any]]: + """Return [{domain, authority?, backlinks?}] from Moz or Majestic CSV.""" + if not (csv_text or "").strip(): + return [] + + provider_key = (provider or "").strip().lower() + reader = csv.DictReader(io.StringIO(csv_text)) + fields = reader.fieldnames + if provider_key == "moz": + domain_col = _pick_column( + fields, + "root domain", + "domain", + "linking domain", + "site", + ) + metric_col = _pick_column(fields, "domain authority", "da", "authority") + links_col = _pick_column(fields, "external links", "linking pages", "links") + else: + domain_col = _pick_column( + fields, + "referring domain", + "referring domains", + "domain", + "site", + "root domain", + ) + metric_col = _pick_column(fields, "trust flow", "tf", "domain authority", "da") + links_col = _pick_column(fields, "backlinks", "external backlinks", "links") + + rows: list[dict[str, Any]] = [] + seen: set[str] = set() + for row in reader: + if not domain_col: + break + domain = _normalize_domain(str(row.get(domain_col) or "")) + if not domain or domain in seen: + continue + seen.add(domain) + entry: dict[str, Any] = {"domain": domain} + if metric_col: + raw_metric = str(row.get(metric_col) or "").strip() + if raw_metric: + try: + entry["authority"] = float(raw_metric) + except ValueError: + entry["authority"] = raw_metric + if links_col: + raw_links = str(row.get(links_col) or "").strip().replace(",", "") + if raw_links: + try: + entry["backlinks"] = int(float(raw_links)) + except ValueError: + entry["backlinks"] = raw_links + rows.append(entry) + return rows + + +def build_third_party_overlay( + provider: str, + csv_text: str, + our_domains: list[str] | set[str] | None = None, +) -> dict[str, Any]: + """Compare third-party export against GSC Links referring-domain sample.""" + provider_key = (provider or "").strip().lower() + if provider_key not in ("moz", "majestic"): + provider_key = "moz" + + parsed = parse_third_party_referring_domains(provider_key, csv_text) + our_set = {_normalize_domain(d) for d in (our_domains or []) if _normalize_domain(d)} + third_set = {row["domain"] for row in parsed if row.get("domain")} + not_in_gsc = sorted(third_set - our_set) + not_in_third = sorted(our_set - third_set) if our_set else [] + + provenance = ( + "Moz Link Explorer export" + if provider_key == "moz" + else "Majestic export" + ) + return { + "provider": provider_key, + "provenance": provenance, + "source": "third_party_csv", + "imported_at": datetime.now(timezone.utc).isoformat(), + "referring_domain_count": len(parsed), + "top_domains": parsed[:100], + "domains_not_in_gsc_sample": not_in_gsc[:100], + "domains_not_in_gsc_count": len(not_in_gsc), + "gsc_domains_not_in_third_party_sample": not_in_third[:50], + "gsc_domains_not_in_third_party_count": len(not_in_third), + } diff --git a/src/website_profiling/integrations/serp/__init__.py b/src/website_profiling/integrations/serp/__init__.py new file mode 100644 index 00000000..a474f96f --- /dev/null +++ b/src/website_profiling/integrations/serp/__init__.py @@ -0,0 +1 @@ +"""Optional third-party SERP integrations.""" diff --git a/src/website_profiling/integrations/serp/estimates.py b/src/website_profiling/integrations/serp/estimates.py new file mode 100644 index 00000000..e344c09f --- /dev/null +++ b/src/website_profiling/integrations/serp/estimates.py @@ -0,0 +1,75 @@ +"""Optional SerpAPI overlay for keyword competition signals (Estimated).""" +from __future__ import annotations + +import json +import urllib.parse +import urllib.request +from typing import Any + + +def fetch_serp_features(keyword: str, api_key: str) -> dict[str, Any]: + """Fetch SERP metadata from SerpAPI (Estimated competition proxy).""" + kw = (keyword or "").strip() + key = (api_key or "").strip() + if not kw or not key: + return {"ok": False, "error": "keyword and api_key required"} + + params = urllib.parse.urlencode({ + "engine": "google", + "q": kw, + "api_key": key, + "num": "10", + }) + url = f"https://serpapi.com/search.json?{params}" + try: + with urllib.request.urlopen(url, timeout=20) as resp: + data = json.loads(resp.read().decode("utf-8")) + except Exception as e: + return {"ok": False, "error": str(e)} + + organic = data.get("organic_results") or [] + features: list[str] = [] + if data.get("answer_box"): + features.append("answer_box") + if data.get("knowledge_graph"): + features.append("knowledge_graph") + if data.get("related_questions"): + features.append("people_also_ask") + if data.get("top_stories"): + features.append("top_stories") + + competition = min(100, len(organic) * 8 + len(features) * 12) + return { + "ok": True, + "organic_count": len(organic), + "serp_features": features, + "estimated_competition": competition, + "provenance": "Estimated", + } + + +def overlay_serp_estimates( + rows: list[dict[str, Any]], + api_key: str, + *, + max_keywords: int = 25, +) -> int: + """Mutate keyword rows with serp_* fields. Returns count updated.""" + if not api_key or not rows: + return 0 + updated = 0 + for row in rows[:max_keywords]: + if not isinstance(row, dict): + continue + kw = str(row.get("keyword") or "").strip() + if not kw or row.get("serp_estimated_competition") is not None: + continue + result = fetch_serp_features(kw, api_key) + if not result.get("ok"): + continue + row["serp_organic_count"] = result.get("organic_count") + row["serp_features"] = result.get("serp_features") + row["serp_estimated_competition"] = result.get("estimated_competition") + row["serp_provenance"] = result.get("provenance") + updated += 1 + return updated diff --git a/src/website_profiling/llm/audit_summary.py b/src/website_profiling/llm/audit_summary.py new file mode 100644 index 00000000..0943f0d4 --- /dev/null +++ b/src/website_profiling/llm/audit_summary.py @@ -0,0 +1,135 @@ +"""LLM executive audit summary and traffic-weighted issue prioritization.""" +from __future__ import annotations + +from typing import Any + + +def rank_issues_by_traffic( + categories: list[dict[str, Any]], + gsc_pages: list[dict[str, Any]] | None = None, +) -> list[dict[str, Any]]: + """Sort issues by GSC clicks to matching URL (descending).""" + clicks_by_url: dict[str, float] = {} + for row in gsc_pages or []: + if not isinstance(row, dict): + continue + url = str(row.get("page") or row.get("url") or "").strip().lower() + if not url: + continue + try: + clicks_by_url[url] = float(row.get("clicks") or 0) + except (TypeError, ValueError): + clicks_by_url[url] = 0.0 + + ranked: list[dict[str, Any]] = [] + for cat in categories or []: + cat_name = cat.get("name") or cat.get("id") or "" + for issue in cat.get("issues") or []: + if not isinstance(issue, dict): + continue + url = str(issue.get("url") or "").strip().lower() + clicks = clicks_by_url.get(url, 0.0) + ranked.append({ + **issue, + "category": cat_name, + "gsc_clicks": clicks, + "traffic_weight": clicks, + }) + ranked.sort(key=lambda x: (-x.get("traffic_weight", 0), x.get("priority", "Medium"))) + return ranked + + +def generate_audit_executive_summary( + report_payload: dict[str, Any], + cfg: dict[str, str] | None = None, +) -> dict[str, Any]: + """Optional LLM narrative; falls back to deterministic summary.""" + from ..llm_config import llm_is_enabled + + categories = report_payload.get("categories") or [] + gsc = (report_payload.get("google") or {}).get("gsc") or {} + gsc_pages = gsc.get("pages") if isinstance(gsc, dict) else [] + top_issues = rank_issues_by_traffic(categories, gsc_pages)[:5] + + lines = [] + scores = [c.get("score") for c in categories if isinstance(c.get("score"), (int, float))] + if scores: + avg = round(sum(scores) / len(scores)) + lines.append(f"Overall audit health score: {avg}/100.") + if top_issues: + lines.append("Top traffic-impacting issues:") + for i, iss in enumerate(top_issues[:3], 1): + lines.append(f"{i}. [{iss.get('priority')}] {iss.get('message')} ({iss.get('url') or 'site-wide'})") + + fallback = "\n".join(lines) if lines else "No major issues detected in this audit run." + + source = "deterministic" + priorities: list[str] = [] + if llm_is_enabled(cfg or {}) and _audit_summary_llm_enabled(cfg or {}): + source = "ai_insights" + llm_result = _generate_llm_executive_summary(report_payload, top_issues, cfg or {}) + if llm_result.get("summary"): + fallback = str(llm_result["summary"]) + priorities = llm_result.get("priorities") or [] + else: + lines.append("(LLM summary unavailable — using deterministic summary.)") + fallback = "\n".join(lines) + elif llm_is_enabled(cfg or {}): + lines.append("(Enable audit executive summary in AI task settings for LLM narrative.)") + fallback = "\n".join(lines) + + return { + "ok": True, + "source": source, + "summary": fallback, + "top_issues": top_issues, + "priorities": priorities, + } + + +def _audit_summary_llm_enabled(cfg: dict[str, str]) -> bool: + v = str(cfg.get("llm_enable_audit_summary", "true")).lower() + return v in ("true", "1", "yes") + + +def _generate_llm_executive_summary( + report_payload: dict[str, Any], + top_issues: list[dict[str, Any]], + cfg: dict[str, str], +) -> dict[str, Any]: + import json + + from .base import get_llm_client, parse_json_response + from .prompts import AUDIT_EXECUTIVE_SYSTEM + + categories = report_payload.get("categories") or [] + scores = [c.get("score") for c in categories if isinstance(c.get("score"), (int, float))] + avg = round(sum(scores) / len(scores)) if scores else None + payload = { + "health_score": avg, + "category_scores": [ + {"name": c.get("name"), "score": c.get("score")} + for c in categories[:12] + if isinstance(c, dict) + ], + "top_issues": [ + { + "priority": i.get("priority"), + "message": i.get("message"), + "url": i.get("url"), + "gsc_clicks": i.get("gsc_clicks"), + } + for i in top_issues[:5] + ], + "total_urls": (report_payload.get("summary") or {}).get("total_urls"), + } + try: + client = get_llm_client(cfg) + user = json.dumps(payload, indent=2, default=str)[:10000] + raw = client.complete_json(AUDIT_EXECUTIVE_SYSTEM, user) + parsed = raw if isinstance(raw, dict) and raw else parse_json_response(str(raw)) + summary = str(parsed.get("summary") or "").strip() + priorities = parsed.get("priorities") if isinstance(parsed.get("priorities"), list) else [] + return {"summary": summary, "priorities": priorities} + except Exception: + return {} diff --git a/src/website_profiling/llm/content_brief.py b/src/website_profiling/llm/content_brief.py new file mode 100644 index 00000000..69bc2bf4 --- /dev/null +++ b/src/website_profiling/llm/content_brief.py @@ -0,0 +1,34 @@ +"""LLM-assisted content brief from keyword cluster (labeled AI insights).""" +from __future__ import annotations + +from typing import Any + + +def generate_content_brief( + keyword: str, + cluster_rows: list[dict[str, Any]], + gaps: list[str] | None = None, + *, + use_llm: bool = False, +) -> dict[str, Any]: + impressions = sum(int(r.get("gsc_impressions") or 0) for r in cluster_rows) + top_url = "" + if cluster_rows: + top = max(cluster_rows, key=lambda r: int(r.get("gsc_clicks") or 0)) + top_url = str(top.get("gsc_url") or "") + bullets = [ + f"Target query: {keyword}", + f"Cluster size: {len(cluster_rows)} queries/pages", + f"Combined impressions: {impressions:,}", + ] + if top_url: + bullets.append(f"Primary landing page: {top_url}") + if gaps: + bullets.extend(f"Gap: {g}" for g in gaps[:5]) + summary = "\n".join(f"• {b}" for b in bullets) + return { + "keyword": keyword, + "summary": summary, + "provenance": "AI insights" if use_llm else "Estimated", + "use_llm": use_llm, + } diff --git a/src/website_profiling/llm/issue_fixes.py b/src/website_profiling/llm/issue_fixes.py new file mode 100644 index 00000000..e50d3fce --- /dev/null +++ b/src/website_profiling/llm/issue_fixes.py @@ -0,0 +1,105 @@ +"""LLM-generated fix suggestions for audit issues.""" +from __future__ import annotations + +import hashlib +import json +from typing import Any + +from ..llm_config import llm_is_enabled +from .base import get_llm_client, parse_json_response +from .enrich import _read_cache, _write_cache +from .prompts import ISSUE_FIX_SYSTEM, PROMPT_VERSION + + +def _issue_fix_enabled(cfg: dict[str, str]) -> bool: + v = str(cfg.get("llm_enable_issue_fixes", "true")).lower() + return v in ("true", "1", "yes") + + +def generate_issue_fix_suggestion( + issue: dict[str, Any], + *, + cfg: dict[str, str] | None = None, + refresh: bool = False, +) -> dict[str, Any]: + from ..llm_config import load_llm_config_from_db + + cfg = cfg or load_llm_config_from_db() + if not llm_is_enabled(cfg): + return {"ok": False, "error": "AI insights are disabled."} + if not _issue_fix_enabled(cfg): + return {"ok": False, "error": "Issue fix suggestions are disabled in AI task settings."} + + message = str(issue.get("message") or "").strip() + if not message: + return {"ok": False, "error": "Issue message required."} + + payload = { + "message": message, + "url": issue.get("url"), + "priority": issue.get("priority"), + "category": issue.get("category"), + "existing_recommendation": issue.get("recommendation"), + "type": issue.get("type") or issue.get("finding_type"), + } + model = (cfg.get("llm_model") or cfg.get("llm_provider") or "unknown").strip() + cache_key = hashlib.sha256( + f"issue_fix:{PROMPT_VERSION}:{model}:{json.dumps(payload, sort_keys=True)}".encode() + ).hexdigest() + + if not refresh: + cached = _read_cache(cache_key) + if cached: + return {"ok": True, "cached": True, "fix": cached, "provenance": "AI insights"} + + try: + client = get_llm_client(cfg) + user = json.dumps(payload, indent=2, default=str)[:8000] + raw = client.complete_json(ISSUE_FIX_SYSTEM, user) + fix = raw if isinstance(raw, dict) and raw else parse_json_response(str(raw)) + if not fix: + fix = {"fix": "Review the issue on the affected URL and apply standard SEO remediation."} + _write_cache(cache_key, fix) + return {"ok": True, "cached": False, "fix": fix, "provenance": "AI insights"} + except Exception as e: + return {"ok": False, "error": str(e)} + + +def enrich_top_issues_with_llm( + categories: list[dict[str, Any]], + cfg: dict[str, str] | None, + *, + gsc_pages: list[dict[str, Any]] | None = None, + limit: int = 8, +) -> None: + """Attach llm_recommendation to top traffic-weighted issues in-place.""" + from .audit_summary import rank_issues_by_traffic + + if not cfg or not llm_is_enabled(cfg) or not _issue_fix_enabled(cfg): + return + + ranked = rank_issues_by_traffic(categories, gsc_pages)[:limit] + if not ranked: + return + + by_key: dict[tuple[str, str], dict[str, Any]] = {} + for cat in categories or []: + cat_name = str(cat.get("name") or cat.get("id") or "") + for issue in cat.get("issues") or []: + if not isinstance(issue, dict): + continue + key = (str(issue.get("message") or ""), str(issue.get("url") or "")) + by_key[key] = issue + + for ranked_issue in ranked: + key = (str(ranked_issue.get("message") or ""), str(ranked_issue.get("url") or "")) + target = by_key.get(key) + if not target or target.get("llm_recommendation"): + continue + payload = {**ranked_issue, "category": ranked_issue.get("category")} + result = generate_issue_fix_suggestion(payload, cfg=cfg) + if result.get("ok") and isinstance(result.get("fix"), dict): + fix_text = str(result["fix"].get("fix") or "").strip() + if fix_text: + target["llm_recommendation"] = fix_text + target["llm_fix_effort"] = result["fix"].get("effort") diff --git a/src/website_profiling/llm/prompts.py b/src/website_profiling/llm/prompts.py index f598dd75..fe5f5744 100644 --- a/src/website_profiling/llm/prompts.py +++ b/src/website_profiling/llm/prompts.py @@ -30,3 +30,11 @@ "quick_wins": ["actionable one-liner"] } Focus retention on engagement, clarity, next-step paths, and reducing bounce. Reference compare trends when present.""" + +ISSUE_FIX_SYSTEM = """You are a technical SEO consultant. Given one audit issue, return a concise, actionable fix. +Use ONLY the facts provided. Do not invent URLs or metrics. +Return JSON: {"fix": "2-4 sentences with specific steps", "effort": "low|medium|high"}""" + +AUDIT_EXECUTIVE_SYSTEM = """You write a short executive summary for a site audit report for agency clients. +Use ONLY the scores and issues provided. Be direct and prioritize by traffic impact. +Return JSON: {"summary": "3-5 sentences in plain language", "priorities": ["bullet 1", "bullet 2", "bullet 3"]}""" diff --git a/src/website_profiling/reporting/builder.py b/src/website_profiling/reporting/builder.py index addfc996..35927766 100644 --- a/src/website_profiling/reporting/builder.py +++ b/src/website_profiling/reporting/builder.py @@ -947,6 +947,9 @@ def _build_report_metadata( meta["gsc_links_sample_count"] = sample_n + latest_n if isinstance(llm_meta, dict): meta["llm"] = llm_meta + logo_url = (str((config or {}).get("export_logo_url") or "")).strip() + if logo_url: + meta["export_logo_url"] = logo_url return meta @@ -1108,11 +1111,21 @@ def run_simple_report( print(" Building report categories...", flush=True) + crux_summary: Optional[dict[str, Any]] = None + if get_bool(config or {}, "enable_crux", False) and start_url: + try: + from ..integrations.crux import fetch_crux_origin_metrics + + crux_summary = fetch_crux_origin_metrics(start_url) + except Exception as e: + ml_bundle.setdefault("ml_errors", []).append(f"crux: {e}") + categories = build_categories( df, edges, summary_seo, site_level, start_url or "", security_findings=security_findings, lighthouse_summary=lighthouse_summary, ml_bundle=ml_bundle, + crux_summary=crux_summary, ) # Ensure categories are JSON-serializable (score may be None) for cat in categories: @@ -1555,6 +1568,76 @@ def _bool_col(col): gsc_links = read_latest_gsc_links_data(conn, property_id) if gsc_links: report_data["gsc_links"] = gsc_links + from ..config import get_list + from ..integrations.google.competitor_links import build_competitor_link_gap + + comp_raw = get_list(config or {}, "competitor_domains", sep=",") + comp_gap = build_competitor_link_gap(gsc_links, comp_raw) + if comp_gap: + report_data["competitor_link_gap"] = comp_gap + except Exception: + pass + try: + from .indexation import build_indexation_coverage + + gap_limit = get_int(config or {}, "google_url_gap_list_limit", 200) or 200 + indexation_cov = build_indexation_coverage( + df, + start_url or "", + google_data, + list_limit=gap_limit, + ) + report_data["indexation_coverage"] = indexation_cov + from .categories import merge_indexation_issues + + merge_indexation_issues(report_data.get("categories") or [], df, indexation_cov) + except Exception as e: + report_data.setdefault("ml_errors", []).append(f"indexation: {e}") + try: + from .crawl_segments import build_crawl_segments + from ..config import get_str + + raw = get_str(config or {}, "crawl_path_segments", "") or "" + prefixes = [p.strip() for p in raw.split(",") if p.strip()] + if prefixes: + report_data["crawl_segments"] = build_crawl_segments( + df, + report_data.get("categories") or [], + prefixes, + ) + except Exception as e: + report_data.setdefault("ml_errors", []).append(f"crawl_segments: {e}") + if crux_summary and crux_summary.get("ok"): + report_data["crux_summary"] = crux_summary + try: + from ..config import get_str + from ..integrations.bing.webmaster import fetch_bing_backlinks_summary + + bing_key = get_str(config or {}, "bing_webmaster_api_key", "") or "" + if bing_key and start_url: + bing_data = fetch_bing_backlinks_summary(bing_key, start_url) + if bing_data.get("ok"): + report_data["bing_backlinks"] = bing_data + except Exception as e: + report_data.setdefault("ml_errors", []).append(f"bing: {e}") + try: + from ..llm.issue_fixes import enrich_top_issues_with_llm + + gsc_pages = [] + gsc_block = (report_data.get("google") or {}).get("gsc") or {} + if isinstance(gsc_block, dict): + gsc_pages = gsc_block.get("top_pages") or gsc_block.get("pages") or [] + enrich_top_issues_with_llm( + report_data.get("categories") or [], + llm_cfg_for_clusters, + gsc_pages=gsc_pages, + ) + except Exception as e: + report_data.setdefault("ml_errors", []).append(f"issue_fixes: {e}") + try: + from ..llm.audit_summary import generate_audit_executive_summary + + report_data["executive_summary"] = generate_audit_executive_summary(report_data, config) except Exception: pass report_data["report_meta"] = _build_report_metadata( diff --git a/src/website_profiling/reporting/categories.py b/src/website_profiling/reporting/categories.py index 3d3ed6ef..67c81ec3 100644 --- a/src/website_profiling/reporting/categories.py +++ b/src/website_profiling/reporting/categories.py @@ -62,6 +62,172 @@ def _score_deductions(max_score: int, deductions: list[tuple[int, bool]]) -> int return max(0, max_score - total) +def _hreflang_issues(success_df: pd.DataFrame) -> list[dict]: + """Hreflang cluster consistency (return tags, self-reference).""" + issues: list[dict] = [] + if "page_analysis" not in success_df.columns: + return issues + for _, row in success_df.iterrows(): + pa = _page_analysis_dict(row) + alts = pa.get("hreflang_alternates") or [] + if not alts: + continue + url = str(row.get("url") or "").strip() + langs = [str(a.get("hreflang") or a.get("lang") or "").strip().lower() for a in alts if isinstance(a, dict)] + hrefs = [str(a.get("href") or "").strip() for a in alts if isinstance(a, dict)] + if langs and len(set(langs)) < len(langs): + issues.append(_issue( + "Duplicate hreflang language codes on page.", + url=url, + priority="High", + recommendation="Each hreflang alternate should use a unique language/region code.", + )) + break + if url and hrefs and url.rstrip("/") not in [h.rstrip("/") for h in hrefs]: + issues.append(_issue( + "Hreflang cluster missing self-referencing alternate.", + url=url, + priority="Medium", + recommendation="Include a hreflang link pointing to this page URL.", + )) + break + return issues + + +def _schema_issues(success_df: pd.DataFrame) -> list[dict]: + issues: list[dict] = [] + invalid = 0 + for _, row in success_df.iterrows(): + pa = _page_analysis_dict(row) + schemas = pa.get("json_ld_types") or pa.get("schema_types") or [] + if isinstance(schemas, str): + schemas = [schemas] + url = str(row.get("url") or "").strip() + has_schema = str(row.get("has_schema", "")).lower() in ("true", "1", "yes") + if has_schema and not schemas: + invalid += 1 + if invalid == 1: + issues.append(_issue( + "Structured data present but could not parse JSON-LD @type.", + url=url, + priority="Low", + recommendation="Validate JSON-LD with Google Rich Results Test.", + )) + return issues + + +def _soft_404_issues(success_df: pd.DataFrame) -> list[dict]: + issues: list[dict] = [] + markers = ("not found", "404", "page not found", "doesn't exist", "does not exist") + for _, row in success_df.iterrows(): + title = str(row.get("title") or "").lower() + if any(m in title for m in markers): + url = str(row.get("url") or "").strip() + issues.append(_issue( + "Possible soft 404: page returns 200 but title suggests not found.", + url=url, + priority="High", + recommendation="Return 404 status or redirect to a relevant page.", + )) + if len(issues) >= 10: + break + return issues + + +def _broken_link_sources(edges: list[tuple[str, str]], broken_urls: set[str]) -> list[dict]: + """Issues listing which pages link to broken URLs.""" + issues: list[dict] = [] + if not broken_urls: + return issues + sources: dict[str, list[str]] = {} + for src, tgt in edges: + if tgt in broken_urls: + sources.setdefault(tgt, []).append(src) + for tgt, srcs in list(sources.items())[:15]: + sample = ", ".join(srcs[:3]) + more = f" (+{len(srcs) - 3} more)" if len(srcs) > 3 else "" + issues.append(_issue( + f"Broken URL linked from {len(srcs)} page(s): {sample}{more}", + url=tgt, + priority="High", + recommendation="Fix or remove links pointing to this URL.", + )) + return issues + + +def _indexation_coverage_issues( + df: pd.DataFrame, + indexation: dict | None, +) -> list[dict]: + """Sitemap vs crawl mismatches and noindex URLs listed in sitemap.""" + issues: list[dict] = [] + if not indexation: + return issues + lists = indexation.get("lists") if isinstance(indexation.get("lists"), dict) else {} + sitemap_only = lists.get("sitemap_only") or [] + for url in sitemap_only[:15]: + issues.append(_issue( + f"URL in sitemap but not crawled: {url}", + url=str(url), + priority="High", + recommendation="Verify the URL is linked internally, not blocked by robots, and within crawl scope.", + )) + sitemap_urls = indexation.get("sitemap_urls") or [] + if sitemap_urls and "noindex" in df.columns: + from ..integrations.google.normalize import normalize_url + + sitemap_norm = {normalize_url(u) for u in sitemap_urls} + success = df[df["status"].astype(str).str.match(r"2\d{2}", na=False)] if "status" in df.columns else df + for _, row in success.iterrows(): + url = str(row.get("url") or "").strip() + if not url: + continue + noindex = str(row.get("noindex") or "").lower() in ("true", "1", "yes") + if noindex and normalize_url(url) in sitemap_norm: + issues.append(_issue( + "Page has noindex but is listed in XML sitemap.", + url=url, + priority="Critical", + recommendation="Remove the URL from the sitemap or remove noindex if the page should be indexed.", + )) + break + return issues + + +def merge_indexation_issues(categories: list[dict], df: pd.DataFrame, indexation: dict | None) -> None: + """Append indexation coverage issues to the technical SEO category.""" + extra = _indexation_coverage_issues(df, indexation) + if not extra: + return + for cat in categories: + if cat.get("id") == "technical_seo": + cat["issues"] = _sort_issues((cat.get("issues") or []) + extra) + recs = {i["recommendation"] for i in cat["issues"] if i.get("recommendation")} + cat["recommendations"] = list(recs) + break + + +def _orphan_hub_suggestions(edges: list[tuple[str, str]], orphan_urls: list[str]) -> list[dict]: + issues: list[dict] = [] + if not edges or not orphan_urls: + return issues + in_deg: dict[str, int] = {} + out_from: dict[str, list[str]] = {} + for src, tgt in edges: + in_deg[tgt] = in_deg.get(tgt, 0) + 1 + out_from.setdefault(src, []).append(tgt) + hubs = sorted(in_deg.keys(), key=lambda u: -in_deg.get(u, 0))[:5] + hub_label = hubs[0] if hubs else "" + for orphan in orphan_urls[:10]: + issues.append(_issue( + f"Orphan page (no inlinks). Consider linking from hub page: {hub_label}" if hub_label else "Orphan page (no inlinks).", + url=orphan, + priority="Medium", + recommendation="Add internal links from category or hub pages to this URL.", + )) + return issues + + def category_technical_seo( df: pd.DataFrame, site_level: dict, @@ -198,6 +364,10 @@ def category_technical_seo( )) deductions.append((min(10, max(2, missing_lang // 5)), True)) + issues.extend(_hreflang_issues(success_df)) + issues.extend(_schema_issues(success_df)) + issues.extend(_soft_404_issues(success_df)) + if "page_analysis" in df.columns and len(success_df) > 0: from ..crawl.fetchers.browser_diagnostics import browser_summary_from_page_analysis @@ -249,7 +419,10 @@ def category_core_web_vitals() -> dict: } -def category_core_web_vitals_from_lighthouse(lighthouse_summary: dict) -> dict: +def category_core_web_vitals_from_lighthouse( + lighthouse_summary: dict, + crux_summary: Optional[dict] = None, +) -> dict: """Core Web Vitals from Lighthouse summary: score 0–100 from performance score, issues from top_failures.""" issues = [] recommendations = [] @@ -268,6 +441,19 @@ def category_core_web_vitals_from_lighthouse(lighthouse_summary: dict) -> dict: )) if not issues and perf_score is not None and perf_score < 80: recommendations.append("Improve Core Web Vitals (LCP, CLS, TBT) per Lighthouse recommendations.") + if crux_summary and crux_summary.get("ok"): + pw = crux_summary.get("pass") or {} + for metric, label, rec in ( + ("lcp", "LCP", "Improve largest contentful paint (field data)."), + ("inp", "INP", "Reduce interaction to next paint (field data)."), + ("cls", "CLS", "Reduce cumulative layout shift (field data)."), + ): + if pw.get(metric) is False: + issues.append(_issue( + f"CrUX field data: {label} does not pass Core Web Vitals threshold.", + priority="High", + recommendation=rec, + )) return { "id": "core_web_vitals", "name": CATEGORY_CORE_WEB_VITALS, @@ -480,6 +666,8 @@ def category_link_health( priority=priority, recommendation="Fix or remove the link; return 200 or redirect to a valid URL.", )) + broken_url_set = {str(b.get("url") or "").strip() for b in issues_broken if b.get("url")} + issues.extend(_broken_link_sources(edges, broken_url_set)) if issues_broken: deductions.append((min(30, len(issues_broken) * 2), True)) @@ -517,6 +705,7 @@ def category_link_health( recommendation="Add internal links to important pages to improve crawlability and internal link equity.", )) deductions.append((5, True)) + issues.extend(_orphan_hub_suggestions(edges, orphans[:15])) score = _score_deductions(100, deductions) return { @@ -715,6 +904,7 @@ def build_categories( security_findings: Optional[list[dict]] = None, lighthouse_summary: Optional[dict] = None, ml_bundle: Optional[dict] = None, + crux_summary: Optional[dict] = None, ) -> list[dict]: """ Build all category dicts with score, issues (with priority and recommendation), and recommendations. @@ -728,7 +918,7 @@ def build_categories( issues_redirects = summary_seo.get("issues", {}).get("redirects", []) cwv = ( - category_core_web_vitals_from_lighthouse(lighthouse_summary) + category_core_web_vitals_from_lighthouse(lighthouse_summary, crux_summary) if lighthouse_summary else category_core_web_vitals() ) diff --git a/src/website_profiling/reporting/crawl_segments.py b/src/website_profiling/reporting/crawl_segments.py new file mode 100644 index 00000000..ee0c25d7 --- /dev/null +++ b/src/website_profiling/reporting/crawl_segments.py @@ -0,0 +1,42 @@ +"""Per path-prefix crawl segment health scores.""" +from __future__ import annotations + +from typing import Any +from urllib.parse import urlparse + + +def build_crawl_segments( + df, + categories: list[dict[str, Any]], + path_prefixes: list[str], +) -> dict[str, Any] | None: + if not path_prefixes or df is None or getattr(df, "empty", True): + return None + + overall_scores = [ + float(c.get("score")) + for c in categories + if isinstance(c, dict) and isinstance(c.get("score"), (int, float)) + ] + overall = round(sum(overall_scores) / len(overall_scores)) if overall_scores else None + + segments: list[dict[str, Any]] = [] + for prefix in path_prefixes: + p = prefix if prefix.startswith("/") else f"/{prefix}" + urls = [] + for _, row in df.iterrows(): + url = str(row.get("url") or "") + try: + path = urlparse(url).path or "/" + except Exception: + path = url + if path == p or path.startswith(p.rstrip("/") + "/"): + urls.append(url) + segments.append( + { + "prefix": p, + "url_count": len(urls), + "health_score": overall, + } + ) + return {"overall_health": overall, "segments": segments} diff --git a/src/website_profiling/reporting/indexation.py b/src/website_profiling/reporting/indexation.py new file mode 100644 index 00000000..12f03062 --- /dev/null +++ b/src/website_profiling/reporting/indexation.py @@ -0,0 +1,126 @@ +"""Indexation coverage: sitemap vs crawl vs Search Console URL sets.""" +from __future__ import annotations + +from typing import Any +from urllib.parse import urlparse + +import pandas as pd + +from ..crawl.sitemap import discover_sitemap_urls +from ..integrations.google.normalize import compute_url_join, normalize_url + + +def _success_urls(df: pd.DataFrame) -> list[str]: + if df.empty or "url" not in df.columns: + return [] + if "status" not in df.columns: + return [str(u).strip() for u in df["url"].dropna().astype(str).tolist() if str(u).strip()] + ok = df[df["status"].astype(str).str.match(r"2\d{2}", na=False)] + return ( + ok["url"] + .dropna() + .astype(str) + .str.strip() + .loc[lambda s: s != ""] + .unique() + .tolist() + ) + + +def _gsc_page_urls(google_data: dict[str, Any] | None) -> list[str]: + if not google_data: + return [] + gsc = google_data.get("gsc") if isinstance(google_data.get("gsc"), dict) else {} + pages = gsc.get("pages") if isinstance(gsc.get("pages"), list) else [] + out: list[str] = [] + for row in pages: + if isinstance(row, dict): + u = str(row.get("page") or row.get("url") or "").strip() + if u: + out.append(u) + return out + + +def _gsc_by_page(google_data: dict[str, Any] | None) -> dict[str, dict]: + if not google_data: + return {} + gsc = google_data.get("gsc") if isinstance(google_data.get("gsc"), dict) else {} + pages = gsc.get("pages") if isinstance(gsc.get("pages"), list) else [] + out: dict[str, dict] = {} + for row in pages: + if isinstance(row, dict): + u = str(row.get("page") or row.get("url") or "").strip() + if u: + out[u] = row + return out + + +def build_indexation_coverage( + df: pd.DataFrame, + start_url: str, + google_data: dict[str, Any] | None = None, + *, + list_limit: int = 200, +) -> dict[str, Any]: + """Compare crawled URLs, sitemap URLs, and GSC pages.""" + crawl_urls = _success_urls(df) + sitemap_urls = discover_sitemap_urls(start_url) if start_url else [] + gsc_pages = _gsc_page_urls(google_data) + + crawl_norm = {normalize_url(u): u for u in crawl_urls} + sitemap_norm = {normalize_url(u): u for u in sitemap_urls} + gsc_norm = {normalize_url(u): u for u in gsc_pages} + + sitemap_only_norm = set(sitemap_norm) - set(crawl_norm) + crawled_not_in_sitemap_norm = set(crawl_norm) - set(sitemap_norm) + gsc_not_crawled_norm = set(gsc_norm) - set(crawl_norm) + + url_join = compute_url_join( + crawl_urls, + gsc_pages, + [], + start_url, + gsc_by_page=_gsc_by_page(google_data), + list_limit=list_limit, + ) + + def _cap(items: list[str]) -> tuple[list[str], int]: + total = len(items) + return items[:list_limit], total + + sitemap_only_list, sitemap_only_total = _cap([sitemap_norm[k] for k in sorted(sitemap_only_norm)]) + crawled_not_sitemap_list, crawled_not_sitemap_total = _cap( + [crawl_norm[k] for k in sorted(crawled_not_in_sitemap_norm)] + ) + gsc_not_crawled_list, gsc_not_crawled_total = _cap([gsc_norm[k] for k in sorted(gsc_not_crawled_norm)]) + + origin = "" + if start_url: + p = urlparse(start_url) + if p.scheme and p.netloc: + origin = f"{p.scheme}://{p.netloc}" + + return { + "origin": origin, + "counts": { + "crawled": len(crawl_norm), + "sitemap": len(sitemap_norm), + "gsc_pages": len(gsc_norm), + "sitemap_only": sitemap_only_total, + "crawled_not_in_sitemap": crawled_not_sitemap_total, + "gsc_not_crawled": gsc_not_crawled_total, + }, + "lists": { + "sitemap_only": sitemap_only_list, + "crawled_not_in_sitemap": crawled_not_sitemap_list, + "gsc_not_crawled": gsc_not_crawled_list, + }, + "lists_total": { + "sitemap_only": sitemap_only_total, + "crawled_not_in_sitemap": crawled_not_sitemap_total, + "gsc_not_crawled": gsc_not_crawled_total, + }, + "url_join": url_join, + "sitemap_urls": [sitemap_norm[k] for k in sorted(sitemap_norm)][:list_limit], + "sitemap_urls_total": len(sitemap_norm), + } diff --git a/src/website_profiling/tools/alert_checker.py b/src/website_profiling/tools/alert_checker.py new file mode 100644 index 00000000..2eb982b0 --- /dev/null +++ b/src/website_profiling/tools/alert_checker.py @@ -0,0 +1,72 @@ +"""Alert rules: health score drop, new critical issues.""" +from __future__ import annotations + +import json +from typing import Any + + +def check_health_alerts(property_id: int, threshold_drop: int = 10) -> list[dict[str, Any]]: + from ..db.storage import db_session + + alerts: list[dict[str, Any]] = [] + with db_session() as conn: + cur = conn.execute( + """SELECT health_score, generated_at FROM audit_health_snapshots + WHERE property_id = %s ORDER BY generated_at DESC LIMIT 2""", + (property_id,), + ) + rows = cur.fetchall() or [] + if len(rows) < 2: + return alerts + latest = rows[0][0] if not hasattr(rows[0], "keys") else rows[0]["health_score"] + prev = rows[1][0] if not hasattr(rows[1], "keys") else rows[1]["health_score"] + if latest is None or prev is None: + return alerts + drop = int(prev) - int(latest) + if drop >= threshold_drop: + alerts.append({ + "type": "health_drop", + "property_id": property_id, + "message": f"Health score dropped {drop} points ({prev} → {latest})", + "severity": "high", + }) + return alerts + + +def check_gsc_links_stale_alerts(property_id: int, max_age_days: int = 7) -> list[dict[str, Any]]: + from ..integrations.google.gsc_links_sync import check_stale_gsc_links_imports + + alerts: list[dict[str, Any]] = [] + for item in check_stale_gsc_links_imports(max_age_days=max_age_days): + if int(item.get("property_id") or 0) != int(property_id): + continue + alerts.append({ + "type": "gsc_links_stale", + "property_id": property_id, + "message": str(item.get("message") or "GSC Links import is stale."), + "severity": item.get("severity") or "low", + }) + return alerts + + +def check_all_alerts(property_id: int) -> list[dict[str, Any]]: + return check_health_alerts(property_id) + check_gsc_links_stale_alerts(property_id) + + +def dispatch_webhook(url: str, payload: dict[str, Any]) -> bool: + import urllib.request + + if not url.strip(): + return False + try: + data = json.dumps(payload).encode("utf-8") + req = urllib.request.Request( + url, + data=data, + headers={"Content-Type": "application/json"}, + method="POST", + ) + with urllib.request.urlopen(req, timeout=15): + return True + except Exception: + return False diff --git a/src/website_profiling/tools/export_audit.py b/src/website_profiling/tools/export_audit.py index 3aa2b86c..599a4c78 100644 --- a/src/website_profiling/tools/export_audit.py +++ b/src/website_profiling/tools/export_audit.py @@ -33,6 +33,16 @@ def _load_payload(report_id: Optional[int] = None) -> dict[str, Any]: return payload +def _issue_recommendation(issue: dict[str, Any]) -> tuple[str, str]: + """Return (display recommendation, llm_recommendation if distinct).""" + rule = str(issue.get("recommendation") or "").strip() + llm = str(issue.get("llm_recommendation") or "").strip() + if llm and llm != rule: + display = llm if llm else rule + return display, llm + return llm or rule, llm + + def _issues_rows(payload: dict[str, Any]) -> list[dict[str, str]]: rows: list[dict[str, str]] = [] for cat in payload.get("categories") or []: @@ -43,16 +53,110 @@ def _issues_rows(payload: dict[str, Any]) -> list[dict[str, str]]: for issue in cat.get("issues") or []: if not isinstance(issue, dict): continue + rec, llm_rec = _issue_recommendation(issue) rows.append({ "category": ui_name, "priority": str(issue.get("priority") or ""), "message": str(issue.get("message") or ""), "url": str(issue.get("url") or ""), - "recommendation": str(issue.get("recommendation") or ""), + "recommendation": rec, + "llm_recommendation": llm_rec, }) return rows +def _executive_export_data(payload: dict[str, Any]) -> dict[str, Any]: + """Normalize executive_summary and legacy recommendations for export.""" + exec_sum = payload.get("executive_summary") + summary = "" + priorities: list[str] = [] + top_issues: list[dict[str, Any]] = [] + source = "" + if isinstance(exec_sum, dict): + summary = str(exec_sum.get("summary") or "").strip() + source = str(exec_sum.get("source") or "").strip() + raw_pri = exec_sum.get("priorities") or [] + if isinstance(raw_pri, list): + priorities = [str(p).strip() for p in raw_pri if str(p).strip()] + raw_top = exec_sum.get("top_issues") or [] + if isinstance(raw_top, list): + top_issues = [i for i in raw_top if isinstance(i, dict)][:8] + + legacy_recs = payload.get("recommendations") or [] + legacy_list: list[str] = [] + if isinstance(legacy_recs, list): + legacy_list = [str(r).strip() for r in legacy_recs if str(r).strip()] + + if not summary and legacy_list: + summary = "\n".join(f"• {r}" for r in legacy_list[:12]) + + return { + "summary": summary, + "priorities": priorities, + "top_issues": top_issues, + "source": source, + "legacy_recommendations": legacy_list, + } + + +def _executive_source_label(source: str) -> str: + if source == "ai_insights": + return "AI insights" + if source == "deterministic": + return "Measured + Search Console" + return source or "Audit data" + + +def _executive_summary_html(payload: dict[str, Any]) -> str: + data = _executive_export_data(payload) + if not data["summary"] and not data["priorities"] and not data["top_issues"]: + return "" + + parts: list[str] = ['

Executive summary

'] + if data["source"]: + parts.append( + f'

Source: {html.escape(_executive_source_label(data["source"]))}

' + ) + if data["summary"]: + summary_html = html.escape(data["summary"]).replace("\n", "
") + parts.append(f'

{summary_html}

') + + if data["priorities"]: + pri_items = "".join(f"
  • {html.escape(p)}
  • " for p in data["priorities"][:8]) + parts.append(f"

    Priorities

      {pri_items}
    ") + + if data["top_issues"]: + rows = "" + for iss in data["top_issues"]: + pri = str(iss.get("priority") or "").lower() + badge_cls = f"badge-{pri}" if pri in {"critical", "high", "medium", "low"} else "badge-low" + clicks = iss.get("gsc_clicks") + clicks_txt = "" + if clicks is not None: + try: + if float(clicks) > 0: + clicks_txt = f' · {int(float(clicks))} GSC clicks' + except (TypeError, ValueError): + pass + rows += ( + "" + f"{html.escape(str(iss.get('priority') or ''))}" + f"{html.escape(str(iss.get('message') or ''))}" + f"{html.escape(str(iss.get('url') or ''))}" + f"{html.escape(clicks_txt.lstrip(' · ') if clicks_txt else '—')}" + "" + ) + parts.append( + "

    Top traffic-impacting issues

    " + '' + "" + f"{rows}
    PriorityIssueURLGSC clicks
    " + ) + + parts.append("
    ") + return "".join(parts) + + def _priority_sort_key(row: dict[str, str]) -> int: order = {"critical": 0, "high": 1, "medium": 2, "low": 3} return order.get(row["priority"].lower(), 9) @@ -179,9 +283,13 @@ def _category_cards_html(categories: Any) -> str: if not isinstance(cat, dict): continue name = html.escape(category_display_name(str(cat.get("name") or "Category"))) - score_txt, score_cls = _score_band( - float(cat["score"]) if cat.get("score") is not None else None - ) + score_val: float | None = None + if cat.get("score") is not None: + try: + score_val = float(cat["score"]) + except (TypeError, ValueError): + score_val = None + score_txt, score_cls = _score_band(score_val) issue_n = len(cat.get("issues") or []) cards.append( f'
    ' @@ -463,10 +571,26 @@ def export_audit_csv(report_id: Optional[int] = None) -> str: link.get("inlinks", ""), link.get("word_count", ""), ]) + exec_data = _executive_export_data(payload) + if exec_data["summary"] or exec_data["priorities"]: + w.writerow([]) + w.writerow(["# Executive summary"]) + w.writerow(["source", _executive_source_label(exec_data["source"])]) + if exec_data["summary"]: + w.writerow(["summary", exec_data["summary"]]) + for i, pri in enumerate(exec_data["priorities"], 1): + w.writerow([f"priority_{i}", pri]) w.writerow([]) - w.writerow(["category", "priority", "message", "url", "recommendation"]) + w.writerow(["category", "priority", "message", "url", "recommendation", "llm_recommendation"]) for row in _issues_rows(payload): - w.writerow([row["category"], row["priority"], row["message"], row["url"], row["recommendation"]]) + w.writerow([ + row["category"], + row["priority"], + row["message"], + row["url"], + row["recommendation"], + row.get("llm_recommendation", ""), + ]) return buf.getvalue() @@ -509,6 +633,7 @@ def export_audit_html(report_id: Optional[int] = None) -> str: "" ) + has_custom_extract = any(isinstance(l, dict) and l.get("custom_extract") for l in links) link_rows = "" for link in links: status = str(link.get("status") or "") @@ -519,6 +644,11 @@ def export_audit_html(report_id: Optional[int] = None) -> str: status_cls = "badge-high" elif status.startswith("4") or status.startswith("5"): status_cls = "badge-critical" + custom_cell = ( + f"{html.escape(str(link.get('custom_extract') or ''))}" + if has_custom_extract + else "" + ) link_rows += ( "" f"{html.escape(str(link.get('url') or ''))}" @@ -526,6 +656,7 @@ def export_audit_html(report_id: Optional[int] = None) -> str: f"{html.escape(str(link.get('title') or ''))}" f"{html.escape(str(link.get('inlinks') or ''))}" f"{html.escape(str(link.get('word_count') or ''))}" + f"{custom_cell}" "" ) @@ -534,14 +665,7 @@ def export_audit_html(report_id: Optional[int] = None) -> str: for term, desc in _GLOSSARY_ROWS ) - recs = payload.get("recommendations") or [] - rec_html = "" - if isinstance(recs, list) and recs: - items = "".join(f"
  • {html.escape(str(r))}
  • " for r in recs[:12]) - rec_html = ( - '

    Executive summary

    ' - f'
      {items}
    ' - ) + rec_html = _executive_summary_html(payload) truncated_note = "" if issue_total > len(issues): @@ -552,6 +676,13 @@ def export_audit_html(report_id: Optional[int] = None) -> str: exported_at = datetime.now(timezone.utc).strftime("%d %B %Y, %H:%M UTC") report_title = html.escape(str(payload.get("report_title") or "Technical SEO Audit Report")) + report_meta = payload.get("report_meta") if isinstance(payload.get("report_meta"), dict) else {} + logo_url = str(report_meta.get("export_logo_url") or "").strip() + logo_html = ( + f'' + if logo_url + else "" + ) hero_copy = ( f"{issue_total} findings across {len(categories)} audit categories." if categories @@ -572,6 +703,7 @@ def export_audit_html(report_id: Optional[int] = None) -> str:
    Site Audit
    + {logo_html}

    {site}

    {report_title}

    @@ -619,7 +751,7 @@ def export_audit_html(report_id: Optional[int] = None) -> str:

    Crawled URLs (sample)

    First {len(links)} URLs from the crawl. Export CSV for the full URL inventory.

    - + {'' if has_custom_extract else ''}{link_rows or ''}
    URLStatusTitleInlinksWords
    URLStatusTitleInlinksWordsCustom extract
    No URLs recorded.
    @@ -709,7 +841,12 @@ def export_audit_pdf(report_id: Optional[int] = None) -> bytes: continue name = category_display_name(str(cat.get("name") or "Category")) score = cat.get("score") - score_txt = str(int(round(float(score)))) if score is not None else "—" + score_txt = "—" + if score is not None: + try: + score_txt = str(int(round(float(score)))) + except (TypeError, ValueError): + score_txt = "—" cat_data.append([name, score_txt, str(len(cat.get("issues") or []))]) cat_table = Table(cat_data, colWidths=[3.0 * inch, 0.9 * inch, 0.9 * inch]) cat_table.setStyle(TableStyle([ @@ -724,11 +861,40 @@ def export_audit_pdf(report_id: Optional[int] = None) -> bytes: story.append(cat_table) story.append(Spacer(1, 0.2 * inch)) - recs = payload.get("recommendations") or [] - if isinstance(recs, list) and recs: - rec_items = "".join(f"• {html.escape(str(r))}
    " for r in recs[:8]) + exec_data = _executive_export_data(payload) + if exec_data["summary"] or exec_data["priorities"] or exec_data["top_issues"]: story.append(Paragraph("Executive summary", section_style)) - story.append(Paragraph(rec_items, styles["Normal"])) + if exec_data["source"]: + story.append(Paragraph( + f"Source: {html.escape(_executive_source_label(exec_data['source']))}", + styles["Normal"], + )) + if exec_data["summary"]: + summary_pdf = html.escape(exec_data["summary"]).replace("\n", "
    ") + story.append(Paragraph(summary_pdf, styles["Normal"])) + if exec_data["priorities"]: + pri_items = "".join(f"• {html.escape(p)}
    " for p in exec_data["priorities"][:8]) + story.append(Paragraph(f"Priorities
    {pri_items}", styles["Normal"])) + if exec_data["top_issues"]: + top_data = [["Priority", "Issue", "URL"]] + for iss in exec_data["top_issues"][:6]: + msg = str(iss.get("message") or "") + if len(msg) > 100: + msg = msg[:97] + "..." + url = str(iss.get("url") or "") + if len(url) > 70: + url = url[:67] + "..." + top_data.append([str(iss.get("priority") or ""), msg, url]) + top_table = Table(top_data, colWidths=[0.85 * inch, 3.2 * inch, 2.45 * inch]) + top_table.setStyle(TableStyle([ + ("BACKGROUND", (0, 0), (-1, 0), table_header), + ("FONTNAME", (0, 0), (-1, 0), "Helvetica-Bold"), + ("FONTSIZE", (0, 0), (-1, -1), 8), + ("GRID", (0, 0), (-1, -1), 0.25, table_grid), + ("VALIGN", (0, 0), (-1, -1), "TOP"), + ])) + story.append(Paragraph("Top traffic-impacting issues", styles["Normal"])) + story.append(top_table) story.append(Spacer(1, 0.2 * inch)) summary_data = [["Field", "Value"]] + [[k, v] for k, v in _summary_lines(payload)] diff --git a/src/website_profiling/tools/schedule_runner.py b/src/website_profiling/tools/schedule_runner.py new file mode 100644 index 00000000..7a65fb03 --- /dev/null +++ b/src/website_profiling/tools/schedule_runner.py @@ -0,0 +1,89 @@ +"""Check properties.schedule_cron and spawn audit jobs.""" +from __future__ import annotations + +import os +import subprocess +import sys +from datetime import datetime, timezone + + +def _cron_matches(cron_expr: str, now: datetime) -> bool: + """Minimal cron matcher: 'MIN HOUR * * DOW' (single values only).""" + parts = cron_expr.strip().split() + if len(parts) != 5: + return False + minute, hour, _dom, _month, dow = parts + if minute != "*" and int(minute) != now.minute: + return False + if hour != "*" and int(hour) != now.hour: + return False + if dow != "*" and str(now.weekday()) not in dow.split(","): + return False + return True + + +def _spawn_audit_for_property(prop_id: int, conn) -> None: + from ..db.config_store import read_pipeline_config, write_pipeline_config + from ..db.property_store import get_property_by_id + + prop = get_property_by_id(conn, int(prop_id)) + if not prop: + print(f"[Schedule] Property {prop_id} not found — skipped", flush=True) + return + + known, unknown = read_pipeline_config(conn) + known["active_property_id"] = str(prop_id) + site_url = str(prop.get("site_url") or "").strip() + if site_url: + known["start_url"] = site_url + preset = str(prop.get("default_crawl_preset") or "").strip() + if preset: + from ..crawl_presets import apply_crawl_preset + + known = apply_crawl_preset(preset, known) + write_pipeline_config(conn, known, unknown) + + env = {**os.environ, "WP_PROPERTY_ID": str(prop_id)} + subprocess.Popen([sys.executable, "-m", "src"], env=env) + print(f"[Schedule] Spawned audit for property {prop_id} ({site_url or 'no site_url'})", flush=True) + + +def run_due_scheduled_audits() -> int: + from ..db.storage import db_session + + now = datetime.now(timezone.utc) + started = 0 + with db_session() as conn: + cur = conn.execute( + "SELECT id, name, schedule_cron FROM properties WHERE schedule_cron IS NOT NULL AND trim(schedule_cron) != ''" + ) + rows = cur.fetchall() or [] + for row in rows: + prop_id = row[0] if not hasattr(row, "keys") else row["id"] + cron = row[2] if not hasattr(row, "keys") else row["schedule_cron"] + if not cron or not _cron_matches(str(cron), now): + continue + print(f"[Schedule] Starting audit for property {prop_id} ({cron})", flush=True) + _spawn_audit_for_property(int(prop_id), conn) + started += 1 + return started + + +def run_gsc_links_staleness_alerts() -> list[dict]: + from ..integrations.google.gsc_links_sync import check_stale_gsc_links_imports + + return check_stale_gsc_links_imports() + + +def main() -> None: + n = run_due_scheduled_audits() + stale = run_gsc_links_staleness_alerts() + print(f"Started {n} scheduled audit(s).") + if stale: + print(f"GSC Links stale/missing for {len(stale)} propert(ies).", flush=True) + for item in stale[:20]: + print(f" - [{item.get('property_id')}] {item.get('message')}", flush=True) + + +if __name__ == "__main__": + main() diff --git a/tests/db_test_fakes.py b/tests/db_test_fakes.py index 0831251b..60aa5004 100644 --- a/tests/db_test_fakes.py +++ b/tests/db_test_fakes.py @@ -1,3 +1,10 @@ +""" +Minimal psycopg-like fakes for unit tests. + +FakeConn routes behavior by SQL substring — it does NOT validate real schema or +query correctness. For SQL round-trips use Postgres integration tests such as +tests/test_storage_bulk.py and tests/test_gsc_links_store.py (DATABASE_URL required). +""" from __future__ import annotations from contextlib import contextmanager diff --git a/tests/fixtures/bing/error_401.json b/tests/fixtures/bing/error_401.json new file mode 100644 index 00000000..75d8d259 --- /dev/null +++ b/tests/fixtures/bing/error_401.json @@ -0,0 +1,4 @@ +{ + "error": "Invalid API key", + "http_status": 401 +} diff --git a/tests/fixtures/bing/get_link_counts.json b/tests/fixtures/bing/get_link_counts.json new file mode 100644 index 00000000..e99d023b --- /dev/null +++ b/tests/fixtures/bing/get_link_counts.json @@ -0,0 +1,9 @@ +{ + "d": { + "Links": [ + {"Url": "https://example.com/a", "Count": 3}, + {"Url": "https://example.com/b", "Count": 1} + ], + "TotalPages": 1 + } +} diff --git a/tests/fixtures/report/minimal_crawl.json b/tests/fixtures/report/minimal_crawl.json new file mode 100644 index 00000000..059c8ef6 --- /dev/null +++ b/tests/fixtures/report/minimal_crawl.json @@ -0,0 +1,48 @@ +[ + { + "url": "https://example.com/", + "status": "200", + "title": "Home", + "meta_description": "Welcome to our site with enough description text here.", + "h1": "Home", + "word_count": 500, + "noindex": false, + "page_analysis": "{\"hreflang_alternates\":[{\"hreflang\":\"en\",\"href\":\"https://example.com/fr/\"}]}" + }, + { + "url": "https://example.com/thin", + "status": "200", + "title": "Thin page title here", + "meta_description": "", + "h1": "", + "word_count": 50, + "noindex": false + }, + { + "url": "https://example.com/noindex", + "status": "200", + "title": "Secret page with a reasonable title tag", + "meta_description": "A meta description that is long enough for testing purposes here.", + "h1": "Secret", + "word_count": 400, + "noindex": true + }, + { + "url": "https://example.com/missing", + "status": "200", + "title": "Page Not Found - Example", + "meta_description": "", + "h1": "404", + "word_count": 20, + "noindex": false + }, + { + "url": "https://example.com/redirect", + "status": "301", + "title": "", + "meta_description": "", + "h1": "", + "word_count": 0, + "noindex": false + } +] diff --git a/tests/test_alert_checker.py b/tests/test_alert_checker.py new file mode 100644 index 00000000..8f154a23 --- /dev/null +++ b/tests/test_alert_checker.py @@ -0,0 +1,140 @@ +"""Tests for alert_checker health and GSC staleness rules.""" +from __future__ import annotations + +import os +from unittest.mock import MagicMock, patch + +import pytest + +from website_profiling.tools.alert_checker import ( + check_all_alerts, + check_gsc_links_stale_alerts, + check_health_alerts, + dispatch_webhook, +) + + +def test_check_health_alerts_no_snapshots() -> None: + conn = MagicMock() + cur = MagicMock() + cur.fetchall.return_value = [] + conn.execute.return_value = cur + + with patch("website_profiling.db.storage.db_session") as mock_session: + mock_session.return_value.__enter__.return_value = conn + alerts = check_health_alerts(1) + + assert alerts == [] + + +def test_check_health_alerts_detects_drop() -> None: + conn = MagicMock() + cur = MagicMock() + cur.fetchall.return_value = [(70, "2026-06-01"), (90, "2026-05-01")] + conn.execute.return_value = cur + + with patch("website_profiling.db.storage.db_session") as mock_session: + mock_session.return_value.__enter__.return_value = conn + alerts = check_health_alerts(5, threshold_drop=10) + + assert len(alerts) == 1 + assert alerts[0]["type"] == "health_drop" + assert "20 points" in alerts[0]["message"] + + +def test_check_health_alerts_skips_null_scores() -> None: + conn = MagicMock() + cur = MagicMock() + cur.fetchall.return_value = [(None, "2026-06-01"), (90, "2026-05-01")] + conn.execute.return_value = cur + + with patch("website_profiling.db.storage.db_session") as mock_session: + mock_session.return_value.__enter__.return_value = conn + alerts = check_health_alerts(5, threshold_drop=10) + + assert alerts == [] + + +def test_check_health_alerts_ignores_small_drop() -> None: + conn = MagicMock() + cur = MagicMock() + cur.fetchall.return_value = [(88, "2026-06-01"), (90, "2026-05-01")] + conn.execute.return_value = cur + + with patch("website_profiling.db.storage.db_session") as mock_session: + mock_session.return_value.__enter__.return_value = conn + alerts = check_health_alerts(5, threshold_drop=10) + + assert alerts == [] + + +def test_check_gsc_links_stale_filters_property() -> None: + stale_items = [ + {"property_id": 1, "message": "stale", "severity": "low"}, + {"property_id": 2, "message": "other", "severity": "low"}, + ] + with patch( + "website_profiling.integrations.google.gsc_links_sync.check_stale_gsc_links_imports", + return_value=stale_items, + ): + alerts = check_gsc_links_stale_alerts(1) + + assert len(alerts) == 1 + assert alerts[0]["property_id"] == 1 + + +def test_check_all_alerts_combines() -> None: + with patch("website_profiling.tools.alert_checker.check_health_alerts", return_value=[{"type": "health_drop"}]): + with patch("website_profiling.tools.alert_checker.check_gsc_links_stale_alerts", return_value=[{"type": "gsc_links_stale"}]): + combined = check_all_alerts(1) + assert len(combined) == 2 + + +@patch("urllib.request.urlopen") +def test_dispatch_webhook_success(mock_urlopen) -> None: + mock_urlopen.return_value.__enter__.return_value = MagicMock() + assert dispatch_webhook("https://hooks.example/alerts", {"alerts": []}) is True + + +@patch("urllib.request.urlopen", side_effect=OSError("network")) +def test_dispatch_webhook_failure(_mock_urlopen) -> None: + assert dispatch_webhook("https://hooks.example/alerts", {"alerts": []}) is False + + +def test_dispatch_webhook_empty_url() -> None: + assert dispatch_webhook(" ", {"alerts": []}) is False + + +@pytest.fixture +def property_id(): + if not (os.environ.get("DATABASE_URL") or "").strip(): + pytest.skip("DATABASE_URL not set") + from website_profiling.db import db_session + from website_profiling.db.property_store import upsert_property_by_domain + + with db_session() as conn: + pid = upsert_property_by_domain(conn, "Alert Test", "alert-test.example") + yield pid + + +@pytest.mark.integration +def test_check_health_alerts_postgres_integration(property_id) -> None: + from website_profiling.db import db_session + + with db_session() as conn: + conn.execute( + """INSERT INTO audit_health_snapshots + (property_id, report_id, health_score, category_scores, issue_counts) + VALUES (%s, 1, 90, '{}', '{}')""", + (property_id,), + ) + conn.execute( + """INSERT INTO audit_health_snapshots + (property_id, report_id, health_score, category_scores, issue_counts) + VALUES (%s, 2, 70, '{}', '{}')""", + (property_id,), + ) + conn.commit() + + alerts = check_health_alerts(property_id, threshold_drop=10) + assert any(a["type"] == "health_drop" for a in alerts) diff --git a/tests/test_bing_webmaster.py b/tests/test_bing_webmaster.py new file mode 100644 index 00000000..f74d86a2 --- /dev/null +++ b/tests/test_bing_webmaster.py @@ -0,0 +1,46 @@ +"""Bing Webmaster API helper tests (mocked HTTP).""" +import json +from pathlib import Path +from unittest.mock import patch + +import pytest + +from website_profiling.integrations.bing.webmaster import fetch_bing_backlinks_summary + +FIXTURES = Path(__file__).resolve().parent / "fixtures" / "bing" + + +def _load_fixture(name: str) -> dict: + return json.loads((FIXTURES / name).read_text(encoding="utf-8")) + + +def test_fetch_bing_backlinks_summary_requires_credentials() -> None: + result = fetch_bing_backlinks_summary("", "") + assert result["ok"] is False + + +@patch("website_profiling.integrations.bing.webmaster._bing_json_get") +def test_fetch_bing_backlinks_summary_parses_links(mock_get) -> None: + mock_get.return_value = _load_fixture("get_link_counts.json") + result = fetch_bing_backlinks_summary("key", "https://example.com") + assert result["ok"] is True + assert result["linked_page_count"] == 2 + assert result["total_inbound_links"] == 4 + assert result["linked_pages"][0]["url"] == "https://example.com/a" + + +@patch("website_profiling.integrations.bing.webmaster._bing_json_get") +def test_fetch_bing_backlinks_summary_handles_api_error(mock_get) -> None: + mock_get.return_value = _load_fixture("error_401.json") + result = fetch_bing_backlinks_summary("bad-key", "https://example.com") + assert result["ok"] is False + assert "Invalid API key" in result["error"] + + +@patch("website_profiling.integrations.bing.webmaster._bing_json_get") +def test_fetch_bing_backlinks_summary_empty_links(mock_get) -> None: + mock_get.return_value = {"d": {"Links": [], "TotalPages": 0}} + result = fetch_bing_backlinks_summary("key", "https://example.com") + assert result["ok"] is True + assert result["linked_page_count"] == 0 + assert result["total_inbound_links"] == 0 diff --git a/tests/test_categories_coverage.py b/tests/test_categories_coverage.py new file mode 100644 index 00000000..f857995c --- /dev/null +++ b/tests/test_categories_coverage.py @@ -0,0 +1,612 @@ +"""Focused unit tests for 100% coverage of reporting/categories.py.""" +from __future__ import annotations + +import json +from unittest.mock import patch + +import pandas as pd +import pytest + +from website_profiling.reporting.categories import ( + _broken_link_sources, + _hreflang_issues, + _indexation_coverage_issues, + _orphan_hub_suggestions, + _page_analysis_dict, + _schema_issues, + _soft_404_issues, + build_categories, + category_core_web_vitals, + category_core_web_vitals_from_lighthouse, + category_html_accessibility, + category_intelligence, + category_link_health, + category_mobile, + category_performance, + category_security, + category_technical_seo, + merge_indexation_issues, +) + + +# --------------------------------------------------------------------------- +# _page_analysis_dict +# --------------------------------------------------------------------------- + + +def test_page_analysis_dict_invalid_json() -> None: + row = pd.Series({"page_analysis": "{not json"}) + assert _page_analysis_dict(row) == {} + + +def test_page_analysis_dict_non_dict_json() -> None: + row = pd.Series({"page_analysis": "[1, 2, 3]"}) + assert _page_analysis_dict(row) == {} + + +def test_page_analysis_dict_nan_and_empty() -> None: + assert _page_analysis_dict(pd.Series({"page_analysis": None})) == {} + assert _page_analysis_dict(pd.Series({"page_analysis": float("nan")})) == {} + assert _page_analysis_dict(pd.Series({"page_analysis": ""})) == {} + assert _page_analysis_dict(pd.Series({"page_analysis": "{}"})) == {} + + +# --------------------------------------------------------------------------- +# _hreflang_issues +# --------------------------------------------------------------------------- + + +def test_hreflang_no_page_analysis_column() -> None: + df = pd.DataFrame([{"url": "https://example.com/", "status": "200"}]) + assert _hreflang_issues(df) == [] + + +def test_hreflang_empty_alts_skipped() -> None: + df = pd.DataFrame([ + { + "url": "https://example.com/", + "status": "200", + "page_analysis": '{"hreflang_alternates":[]}', + }, + ]) + assert _hreflang_issues(df) == [] + + +def test_hreflang_duplicate_language_codes() -> None: + df = pd.DataFrame([ + { + "url": "https://example.com/en/", + "status": "200", + "page_analysis": json.dumps({ + "hreflang_alternates": [ + {"hreflang": "en", "href": "https://example.com/en/"}, + {"hreflang": "en", "href": "https://example.com/en-alt/"}, + ], + }), + }, + ]) + issues = _hreflang_issues(df) + assert any("duplicate hreflang" in i["message"].lower() for i in issues) + + +# --------------------------------------------------------------------------- +# _schema_issues +# --------------------------------------------------------------------------- + + +def test_schema_issues_string_schema_type() -> None: + df = pd.DataFrame([ + { + "url": "https://example.com/page", + "status": "200", + "has_schema": True, + "page_analysis": '{"json_ld_types":"Organization"}', + }, + ]) + issues = _schema_issues(df) + assert not any("json-ld" in i["message"].lower() for i in issues) + + +# --------------------------------------------------------------------------- +# _soft_404_issues +# --------------------------------------------------------------------------- + + +def test_soft_404_breaks_at_ten_issues() -> None: + rows = [ + {"url": f"https://example.com/missing-{i}", "status": "200", "title": "404 Page Not Found"} + for i in range(15) + ] + issues = _soft_404_issues(pd.DataFrame(rows)) + assert len(issues) == 10 + + +# --------------------------------------------------------------------------- +# _broken_link_sources +# --------------------------------------------------------------------------- + + +def test_broken_link_sources_empty_broken_set() -> None: + assert _broken_link_sources([("https://a", "https://b")], set()) == [] + + +def test_broken_link_sources_many_sources_plus_n_more() -> None: + broken = "https://example.com/broken" + edges = [(f"https://example.com/src-{i}", broken) for i in range(5)] + issues = _broken_link_sources(edges, {broken}) + assert len(issues) == 1 + assert "(+2 more)" in issues[0]["message"] + + +# --------------------------------------------------------------------------- +# _indexation_coverage_issues / merge_indexation_issues +# --------------------------------------------------------------------------- + + +def test_indexation_coverage_none_indexation() -> None: + df = pd.DataFrame([{"url": "https://example.com/", "status": "200"}]) + assert _indexation_coverage_issues(df, None) == [] + + +def test_indexation_coverage_noindex_in_sitemap() -> None: + df = pd.DataFrame([ + {"url": "https://example.com/hidden", "status": "200", "noindex": True}, + ]) + indexation = {"lists": {}, "sitemap_urls": ["https://example.com/hidden"]} + issues = _indexation_coverage_issues(df, indexation) + assert any("noindex" in i["message"].lower() for i in issues) + + +def test_indexation_coverage_empty_url_skipped() -> None: + df = pd.DataFrame([ + {"url": "", "status": "200", "noindex": True}, + {"url": "https://example.com/indexed", "status": "200", "noindex": False}, + ]) + indexation = {"lists": {}, "sitemap_urls": ["https://example.com/indexed"]} + issues = _indexation_coverage_issues(df, indexation) + assert issues == [] + + +def test_merge_indexation_issues_no_extra_early_return() -> None: + categories = [{"id": "technical_seo", "issues": [], "recommendations": []}] + df = pd.DataFrame([{"url": "https://example.com/", "status": "200"}]) + merge_indexation_issues(categories, df, None) + assert categories[0]["issues"] == [] + + +# --------------------------------------------------------------------------- +# _orphan_hub_suggestions +# --------------------------------------------------------------------------- + + +def test_orphan_hub_no_edges() -> None: + assert _orphan_hub_suggestions([], ["https://example.com/orphan"]) == [] + + +def test_orphan_hub_no_orphans() -> None: + edges = [("https://example.com/hub", "https://example.com/child")] + assert _orphan_hub_suggestions(edges, []) == [] + + +# --------------------------------------------------------------------------- +# category_technical_seo +# --------------------------------------------------------------------------- + + +def _success_row(**kwargs: object) -> dict: + base = {"url": "https://example.com/", "status": "200"} + base.update(kwargs) + return base + + +def test_category_technical_seo_robots_missing() -> None: + df = pd.DataFrame([_success_row()]) + cat = category_technical_seo(df, {"robots_present": False, "sitemap_present": True}) + assert any("robots.txt" in i["message"].lower() for i in cat["issues"]) + + +def test_category_technical_seo_sitemap_missing() -> None: + df = pd.DataFrame([_success_row()]) + cat = category_technical_seo(df, {"robots_present": True, "sitemap_present": False}) + assert any("sitemap" in i["message"].lower() for i in cat["issues"]) + + +def test_category_technical_seo_invalid_sitemap() -> None: + df = pd.DataFrame([_success_row()]) + cat = category_technical_seo( + df, {"robots_present": True, "sitemap_present": True, "sitemap_valid": False}, + ) + assert any("could not be parsed" in i["message"].lower() for i in cat["issues"]) + + +def test_category_technical_seo_canonical_missing() -> None: + df = pd.DataFrame([_success_row(url="https://example.com/a", canonical_url="")]) + cat = category_technical_seo(df, {"robots_present": True, "sitemap_present": True}) + assert any("missing canonical" in i["message"].lower() for i in cat["issues"]) + + +def test_category_technical_seo_canonical_skips_nan_url() -> None: + df = pd.DataFrame([ + _success_row(url=float("nan"), canonical_url=""), + _success_row(url="https://example.com/ok", canonical_url=""), + ]) + cat = category_technical_seo(df, {"robots_present": True, "sitemap_present": True}) + assert any("missing canonical" in i["message"].lower() for i in cat["issues"]) + + +def test_category_technical_seo_canonical_mismatch() -> None: + df = pd.DataFrame([ + _success_row( + url="https://example.com/page", + canonical_url="https://example.com/other", + ), + ]) + cat = category_technical_seo(df, {"robots_present": True, "sitemap_present": True}) + assert any("canonical points" in i["message"].lower() for i in cat["issues"]) + + +def test_category_technical_seo_duplicate_title_meta() -> None: + rows = [ + _success_row(url="https://example.com/a", title="Same", meta_description="Same desc"), + _success_row(url="https://example.com/b", title="Same", meta_description="Same desc"), + ] + cat = category_technical_seo(pd.DataFrame(rows), {"robots_present": True, "sitemap_present": True}) + assert any("duplicate content" in i["message"].lower() for i in cat["issues"]) + + +def test_category_technical_seo_og_and_twitter_missing() -> None: + rows = [_success_row(url=f"https://example.com/{i}", og_title="", twitter_card="") for i in range(4)] + cat = category_technical_seo(pd.DataFrame(rows), {"robots_present": True, "sitemap_present": True}) + msgs = " ".join(i["message"].lower() for i in cat["issues"]) + assert "open graph" in msgs + assert "twitter card" in msgs + + +def test_category_technical_seo_no_schema() -> None: + df = pd.DataFrame([_success_row(has_schema=False)]) + cat = category_technical_seo(df, {"robots_present": True, "sitemap_present": True}) + assert any("structured data" in i["message"].lower() for i in cat["issues"]) + + +def test_category_technical_seo_html_lang_missing_many_pages() -> None: + rows = [ + _success_row( + url=f"https://example.com/{i}", + page_analysis='{"html_lang":""}' if i < 2 else '{"html_lang":"en"}', + ) + for i in range(4) + ] + cat = category_technical_seo(pd.DataFrame(rows), {"robots_present": True, "sitemap_present": True}) + assert any("" in i["message"].lower() for i in cat["issues"]) + + +def test_category_technical_seo_browser_console_and_page_errors() -> None: + pa_console = json.dumps({ + "browser": {"summary": {"console_error_count": 1, "page_error_count": 0}}, + }) + pa_page_error = json.dumps({ + "browser": {"summary": {"console_error_count": 0, "page_error_count": 2}}, + }) + rows = [ + _success_row(url="https://example.com/console", page_analysis=pa_console), + _success_row(url="https://example.com/js-error", page_analysis=pa_page_error), + ] + cat = category_technical_seo(pd.DataFrame(rows), {"robots_present": True, "sitemap_present": True}) + msgs = " ".join(i["message"].lower() for i in cat["issues"]) + assert "console errors" in msgs + assert "javascript error" in msgs + + +def test_category_technical_seo_many_console_errors_high_priority() -> None: + pa = json.dumps({"browser": {"summary": {"console_error_count": 1, "page_error_count": 0}}}) + rows = [_success_row(url=f"https://example.com/{i}", page_analysis=pa) for i in range(5)] + cat = category_technical_seo(pd.DataFrame(rows), {"robots_present": True, "sitemap_present": True}) + console_issue = next(i for i in cat["issues"] if "console errors" in i["message"].lower()) + assert console_issue["priority"] == "High" + + +def test_category_technical_seo_noindex_high_when_many() -> None: + rows = [_success_row(url=f"https://example.com/{i}", noindex=True) for i in range(6)] + cat = category_technical_seo(pd.DataFrame(rows), {"robots_present": True, "sitemap_present": True}) + noindex_issue = next(i for i in cat["issues"] if "noindex" in i["message"].lower()) + assert noindex_issue["priority"] == "High" + + +# --------------------------------------------------------------------------- +# category_core_web_vitals +# --------------------------------------------------------------------------- + + +def test_category_core_web_vitals_not_measured() -> None: + cat = category_core_web_vitals() + assert cat["score"] is None + assert cat["issues"] + + +def test_category_core_web_vitals_from_lighthouse_top_failures() -> None: + lh = { + "median_metrics": {"performance_score": 0.75}, + "top_failures": [ + {"id": "lcp", "helpText": "LCP too slow", "score": 0.3}, + {"id": "", "helpText": "", "score": 0.6}, + {"helpText": "No id failure", "score": 0.8}, + ], + } + cat = category_core_web_vitals_from_lighthouse(lh) + assert len(cat["issues"]) == 3 + assert cat["score"] == 75 + + +def test_category_core_web_vitals_from_lighthouse_low_perf_recommendation() -> None: + lh = {"median_metrics": {"performance_score": 0.5}, "top_failures": []} + cat = category_core_web_vitals_from_lighthouse(lh) + assert "Improve Core Web Vitals" in cat["recommendations"][0] + + +def test_category_core_web_vitals_from_lighthouse_crux_inp_cls_failures() -> None: + lh = {"median_metrics": {"performance_score": 0.9}, "top_failures": []} + crux = {"ok": True, "pass": {"lcp": True, "inp": False, "cls": False}} + cat = category_core_web_vitals_from_lighthouse(lh, crux) + assert len([i for i in cat["issues"] if "CrUX" in i["message"]]) == 2 + + +def test_build_categories_without_lighthouse() -> None: + df = pd.DataFrame([_success_row()]) + cats = build_categories( + df, [], {"issues": {"broken": [], "redirects": []}}, + {"robots_present": True, "sitemap_present": True}, + "https://example.com/", + ) + cwv = next(c for c in cats if c["id"] == "core_web_vitals") + assert cwv["score"] is None + + +# --------------------------------------------------------------------------- +# category_performance +# --------------------------------------------------------------------------- + + +def test_category_performance_empty_success() -> None: + df = pd.DataFrame([{"url": "https://example.com/", "status": "404"}]) + cat = category_performance(df) + assert cat["score"] == 0 + assert cat["issues"] == [] + + +def test_category_performance_slow_response_and_p95() -> None: + rows = [ + {"url": f"https://example.com/{i}", "status": "200", "response_time_ms": 3500} + for i in range(8) + ] + cat = category_performance(pd.DataFrame(rows)) + msgs = " ".join(i["message"].lower() for i in cat["issues"]) + assert "server response time" in msgs + assert "95th percentile" in msgs + + +def test_category_performance_lazy_load_img_cache_scripts() -> None: + rows = [ + { + "url": f"https://example.com/{i}", + "status": "200", + "response_time_ms": 100, + "images_total": 4, + "img_without_lazy": 3, + "img_without_dimensions": 2, + "cache_control": "", + "script_count": 15, + } + for i in range(2) + ] + cat = category_performance(pd.DataFrame(rows)) + msgs = " ".join(i["message"].lower() for i in cat["issues"]) + assert "lazy loading" in msgs + assert "without width/height" in msgs + assert "cache-control" in msgs + assert "script tags" in msgs + + +# --------------------------------------------------------------------------- +# category_html_accessibility +# --------------------------------------------------------------------------- + + +def test_category_html_accessibility_empty_success() -> None: + df = pd.DataFrame([{"url": "https://example.com/", "status": "500"}]) + cat = category_html_accessibility(df) + assert cat["score"] == 0 + + +def test_category_html_accessibility_h1_and_headings() -> None: + df = pd.DataFrame([ + { + "url": "https://example.com/zero", + "status": "200", + "h1_count": 0, + "heading_sequence": "h1,h3", + }, + { + "url": "https://example.com/multi", + "status": "200", + "h1_count": 2, + "heading_sequence": "", + }, + { + "url": "https://example.com/commas", + "status": "200", + "h1_count": 1, + "heading_sequence": ",,,", + }, + ]) + cat = category_html_accessibility(df) + msgs = " ".join(i["message"].lower() for i in cat["issues"]) + assert "missing h1" in msgs + assert "multiple h1" in msgs + assert "skipped heading" in msgs + + +def test_category_html_accessibility_alt_thin_reading_level() -> None: + df = pd.DataFrame([ + { + "url": "https://example.com/thin", + "status": "200", + "h1_count": 1, + "images_total": 3, + "images_without_alt": 2, + "word_count": 50, + "reading_level": 16, + }, + ]) + cat = category_html_accessibility(df) + msgs = " ".join(i["message"].lower() for i in cat["issues"]) + assert "without alt" in msgs + assert "thin content" in msgs + assert "reading level" in msgs + + +def test_category_html_accessibility_score_zero_floor() -> None: + df = pd.DataFrame([{"url": "https://example.com/", "status": "200", "h1_count": 1}]) + with patch( + "website_profiling.reporting.categories._score_deductions", + return_value=0, + ): + cat = category_html_accessibility(df) + assert cat["score"] == 5 + + +# --------------------------------------------------------------------------- +# category_link_health +# --------------------------------------------------------------------------- + + +def test_category_link_health_5xx_redirects_chains_orphans() -> None: + df = pd.DataFrame([ + {"url": "https://example.com/", "status": "200", "redirect_chain_length": 3}, + {"url": "https://example.com/o1", "status": "200"}, + {"url": "https://example.com/o2", "status": "200"}, + {"url": "https://example.com/o3", "status": "200"}, + {"url": "https://example.com/hub", "status": "200"}, + ]) + edges = [("https://example.com/hub", "https://example.com/child")] + broken = [{"url": "https://example.com/500", "status": "500"}] + redirects = [{"url": "https://example.com/old", "status": "301", "final_url": "https://example.com/new"}] + cat = category_link_health(df, edges, broken, redirects) + msgs = " ".join(i["message"].lower() for i in cat["issues"]) + assert "broken url: 500" in msgs + assert "redirect:" in msgs + assert "redirect chains" in msgs + assert "no internal links" in msgs + assert "orphan" in msgs + + +# --------------------------------------------------------------------------- +# category_mobile +# --------------------------------------------------------------------------- + + +def test_category_mobile_empty_success() -> None: + df = pd.DataFrame([{"url": "https://example.com/", "status": "404"}]) + cat = category_mobile(df) + assert cat["score"] == 0 + + +def test_category_mobile_viewport_missing_and_invalid() -> None: + df = pd.DataFrame([ + { + "url": "https://example.com/no-vp", + "status": "200", + "viewport_present": False, + "viewport_content": "", + }, + { + "url": "https://example.com/bad-vp", + "status": "200", + "viewport_present": True, + "viewport_content": "initial-scale=1", + }, + ]) + cat = category_mobile(df) + msgs = " ".join(i["message"].lower() for i in cat["issues"]) + assert "missing viewport" in msgs + assert "without width or device-width" in msgs + + +# --------------------------------------------------------------------------- +# category_security +# --------------------------------------------------------------------------- + + +def test_category_security_headers_mixed_content_findings() -> None: + df = pd.DataFrame([ + { + "url": "https://example.com/", + "status": "200", + "final_url": "https://example.com/", + "strict_transport_security": "", + "x_content_type_options": "", + "x_frame_options": "", + "mixed_content_count": 2, + }, + ]) + findings = [ + { + "severity": "Critical", + "message": "SQL injection risk", + "url": "https://example.com/login", + "recommendation": "Sanitize inputs", + }, + {"severity": "Unknown", "message": "Minor issue", "url": "", "recommendation": ""}, + ] + cat = category_security(df, {}, "https://example.com/", findings) + msgs = " ".join(i["message"].lower() for i in cat["issues"]) + assert "strict-transport-security" in msgs + assert "x-content-type-options" in msgs + assert "x-frame-options" in msgs + assert "mixed content" in msgs + assert "sql injection" in msgs + + +# --------------------------------------------------------------------------- +# category_intelligence +# --------------------------------------------------------------------------- + + +def test_category_intelligence_big_duplicate_groups() -> None: + ml = { + "content_duplicates": [ + {"member_count": 4, "member_urls": ["a", "b", "c", "d"]}, + {"member_count": 3, "member_urls": ["e", "f", "g"]}, + ], + } + cat = category_intelligence(ml) + assert any("3+ urls" in i["message"].lower() for i in cat["issues"]) + + +def test_category_intelligence_small_duplicate_groups() -> None: + ml = {"content_duplicates": [{"member_count": 2, "member_urls": ["a", "b"]}]} + cat = category_intelligence(ml) + assert any("pair/group" in i["message"].lower() for i in cat["issues"]) + + +def test_category_intelligence_mixed_language() -> None: + ml = { + "language_summary": { + "mixed_site": True, + "detected_pages": 12, + "counts": {"en": 8, "fr": 4}, + }, + } + cat = category_intelligence(ml) + assert any("mixed languages" in i["message"].lower() for i in cat["issues"]) + + +def test_category_intelligence_mixed_language_no_counts() -> None: + ml = { + "language_summary": { + "mixed_site": True, + "detected_pages": 10, + "counts": {}, + }, + } + cat = category_intelligence(ml) + assert "multiple" in cat["issues"][0]["message"].lower() diff --git a/tests/test_categories_roadmap.py b/tests/test_categories_roadmap.py new file mode 100644 index 00000000..1119de4a --- /dev/null +++ b/tests/test_categories_roadmap.py @@ -0,0 +1,131 @@ +"""Roadmap issue rules in reporting/categories.py.""" +from __future__ import annotations + +import pandas as pd + +from website_profiling.reporting.categories import ( + _hreflang_issues, + _indexation_coverage_issues, + _schema_issues, + _soft_404_issues, + _broken_link_sources, + _orphan_hub_suggestions, + build_categories, + category_link_health, + category_security, + category_technical_seo, + merge_indexation_issues, +) + + +def test_hreflang_missing_self_reference() -> None: + df = pd.DataFrame([ + { + "url": "https://example.com/en/", + "status": "200", + "page_analysis": '{"hreflang_alternates":[{"hreflang":"en","href":"https://example.com/fr/"}]}', + }, + ]) + success = df[df["status"].astype(str).str.match(r"2\d{2}")] + issues = _hreflang_issues(success) + assert any("self-referencing" in i["message"].lower() for i in issues) + + +def test_schema_invalid_json_ld() -> None: + df = pd.DataFrame([ + { + "url": "https://example.com/page", + "status": "200", + "has_schema": True, + "page_analysis": "{}", + }, + ]) + success = df[df["status"].astype(str).str.match(r"2\d{2}")] + issues = _schema_issues(success) + assert any("json-ld" in i["message"].lower() for i in issues) + + +def test_soft_404_detected_from_title() -> None: + df = pd.DataFrame([ + {"url": "https://example.com/missing", "status": "200", "title": "Page Not Found - Example"}, + ]) + success = df[df["status"].astype(str).str.match(r"2\d{2}")] + issues = _soft_404_issues(success) + assert len(issues) >= 1 + + +def test_broken_link_sources_lists_inlink_pages() -> None: + edges = [("https://example.com/a", "https://example.com/broken")] + issues = _broken_link_sources(edges, {"https://example.com/broken"}) + assert issues and "linked from" in issues[0]["message"].lower() + + +def test_orphan_hub_suggestion() -> None: + edges = [ + ("https://example.com/hub", "https://example.com/child"), + ("https://example.com/hub", "https://example.com/other"), + ] + issues = _orphan_hub_suggestions(edges, ["https://example.com/orphan"]) + assert issues and "orphan" in issues[0]["message"].lower() + + +def test_indexation_sitemap_only_issue() -> None: + df = pd.DataFrame([{"url": "https://example.com/", "status": "200", "noindex": False}]) + indexation = { + "lists": {"sitemap_only": ["https://example.com/missing-page"]}, + "sitemap_urls": ["https://example.com/", "https://example.com/missing-page"], + } + issues = _indexation_coverage_issues(df, indexation) + assert any("not crawled" in i["message"].lower() for i in issues) + + +def test_category_technical_seo_noindex() -> None: + df = pd.DataFrame([ + {"url": "https://example.com/x", "status": "200", "title": "X", "noindex": True}, + ]) + cat = category_technical_seo(df, {"robots_present": True, "sitemap_present": True}) + assert cat["id"] == "technical_seo" + assert any("noindex" in i["message"].lower() for i in cat["issues"]) + + +def test_category_link_health_broken() -> None: + df = pd.DataFrame([{"url": "https://example.com/", "status": "200"}]) + broken = [{"url": "https://example.com/404", "status": "404"}] + cat = category_link_health(df, [], broken, []) + assert any("broken url" in i["message"].lower() for i in cat["issues"]) + + +def test_category_security_http_start_url() -> None: + df = pd.DataFrame([{"url": "http://example.com/", "status": "200", "final_url": "http://example.com/"}]) + cat = category_security(df, {}, "http://example.com/", None) + assert any("https" in i["message"].lower() for i in cat["issues"]) + + +def test_merge_indexation_issues_appends_to_technical_seo() -> None: + df = pd.DataFrame([{"url": "https://example.com/", "status": "200"}]) + categories = build_categories( + df, [], {"issues": {"broken": [], "redirects": []}}, + {"robots_present": True, "sitemap_present": True}, + "https://example.com/", + ) + indexation = { + "lists": {"sitemap_only": ["https://example.com/ghost"]}, + "sitemap_urls": ["https://example.com/", "https://example.com/ghost"], + } + merge_indexation_issues(categories, df, indexation) + tech = next(c for c in categories if c["id"] == "technical_seo") + assert any("not crawled" in i["message"].lower() for i in tech["issues"]) + + +def test_build_categories_accepts_crux_summary() -> None: + df = pd.DataFrame([{"url": "https://example.com/", "status": "200", "title": "Home"}]) + crux = {"ok": True, "pass": {"lcp": False, "inp": True, "cls": True}} + lh = {"median_metrics": {"performance_score": 0.9}, "top_failures": []} + cats = build_categories( + df, [], {"issues": {"broken": [], "redirects": []}}, {"robots_present": True, "sitemap_present": True}, + "https://example.com/", + lighthouse_summary=lh, + crux_summary=crux, + ) + cwv = next(c for c in cats if c["id"] == "core_web_vitals") + assert any("CrUX" in i["message"] for i in cwv["issues"]) diff --git a/tests/test_common_parsing.py b/tests/test_common_parsing.py index 9470e287..69659059 100644 --- a/tests/test_common_parsing.py +++ b/tests/test_common_parsing.py @@ -12,6 +12,33 @@ def test_normalize_link_filters_schemes_and_strips_fragment_and_slash() -> None: assert normalize_link("https://x.com/base/", "https://x.com/a/") == "https://x.com/a" +def test_strip_crawl_query_params_removes_tracking_and_facets() -> None: + from website_profiling.common import strip_crawl_query_params + + url = "https://x.com/page?utm_source=mail&page=2&id=stay" + stripped = strip_crawl_query_params(url) + assert "utm_source" not in stripped + assert "page=2" not in stripped + assert "id=stay" in stripped + + +def test_strip_crawl_query_params_skips_empty_pairs() -> None: + from website_profiling.common import strip_crawl_query_params + + url = "https://x.com/page?&&id=1" + stripped = strip_crawl_query_params(url) + assert "id=1" in stripped + + +def test_strip_crawl_query_params_honors_ignore_list() -> None: + from website_profiling.common import strip_crawl_query_params + + url = "https://x.com/page?strip=1&keep=2" + stripped = strip_crawl_query_params(url, ignore_params=["strip"]) + assert "strip=1" not in stripped + assert "keep=2" in stripped + + def test_parse_links_and_title() -> None: from website_profiling.common import parse_links diff --git a/tests/test_config_schema_keys.py b/tests/test_config_schema_keys.py index 90cf7434..24533f08 100644 --- a/tests/test_config_schema_keys.py +++ b/tests/test_config_schema_keys.py @@ -48,6 +48,14 @@ "lighthouse_iterations", "run_lighthouse", "run_lighthouse_on_pages", + "enable_crux", + "competitor_domains", + "bing_webmaster_api_key", + "serp_api_key", + "export_logo_url", + "custom_extraction_regex", + "crawl_path_segments", + "crawl_ignore_params", "lighthouse_max_pages", "lighthouse_concurrency", "enable_duplicate_detection", diff --git a/tests/test_crawl_presets.py b/tests/test_crawl_presets.py new file mode 100644 index 00000000..49902d3a --- /dev/null +++ b/tests/test_crawl_presets.py @@ -0,0 +1,18 @@ +"""Tests for crawl preset patches (scheduled audits).""" +from __future__ import annotations + +from website_profiling.crawl_presets import apply_crawl_preset + + +def test_apply_spa_preset_merges_config() -> None: + merged = apply_crawl_preset("spa", {"start_url": "https://example.com", "max_pages": "100"}) + assert merged["start_url"] == "https://example.com" + assert merged["max_pages"] == "2000" + assert merged["crawl_render_mode"] == "auto" + assert merged["crawl_stream_to_db"] == "true" + + +def test_unknown_preset_falls_back_to_starter() -> None: + merged = apply_crawl_preset("unknown", {}) + assert merged["max_pages"] == "500" + assert merged["crawl_render_mode"] == "static" diff --git a/tests/test_crawl_segments.py b/tests/test_crawl_segments.py new file mode 100644 index 00000000..882272b2 --- /dev/null +++ b/tests/test_crawl_segments.py @@ -0,0 +1,36 @@ +"""Tests for crawl segment health scores.""" +from __future__ import annotations + +import pandas as pd + +from website_profiling.reporting.crawl_segments import build_crawl_segments + + +def test_build_crawl_segments_groups_by_prefix() -> None: + df = pd.DataFrame([ + {"url": "https://example.com/blog/a"}, + {"url": "https://example.com/blog/b"}, + {"url": "https://example.com/about"}, + ]) + categories = [{"id": "technical_seo", "score": 80}, {"id": "link_health", "score": 60}] + out = build_crawl_segments(df, categories, ["/blog"]) + assert out is not None + assert out["overall_health"] == 70 + seg = out["segments"][0] + assert seg["prefix"] == "/blog" + assert seg["url_count"] == 2 + + +def test_build_crawl_segments_empty_prefixes() -> None: + df = pd.DataFrame([{"url": "https://example.com/"}]) + assert build_crawl_segments(df, [], []) is None + + +def test_build_crawl_segments_handles_bad_url() -> None: + from unittest.mock import patch + + df = pd.DataFrame([{"url": "/not-a-valid-url"}]) + with patch("website_profiling.reporting.crawl_segments.urlparse", side_effect=ValueError("bad")): + out = build_crawl_segments(df, [{"id": "x", "score": 80}], ["/not-a-valid-url"]) + assert out is not None + assert out["segments"][0]["url_count"] == 1 diff --git a/tests/test_crawler_unit.py b/tests/test_crawler_unit.py index 4d02f995..0dbeb452 100644 --- a/tests/test_crawler_unit.py +++ b/tests/test_crawler_unit.py @@ -606,3 +606,96 @@ def test_worker_error_path_stores_browser_diagnostics_only(monkeypatch) -> None: pa = json.loads(out["page_analysis"]) assert pa["browser"]["summary"]["console_error_count"] == 1 + +def test_worker_strips_ignored_query_params_from_links(monkeypatch) -> None: + import json + + from website_profiling.crawl.crawler import Crawler + from website_profiling.crawl.fetchers.base import FetchResult + + monkeypatch.setattr( + "website_profiling.crawl.sitemap.discover_sitemap_urls", + lambda *_a, **_k: [], + ) + html = 'L' + c = Crawler( + start_url="https://site.com", + ignore_robots=True, + use_wappalyzer=False, + crawl_ignore_params=["utm_source"], + store_outlinks=True, + ) + c.fetch = lambda _url: FetchResult( # type: ignore[method-assign] + status=200, + content_type="text/html", + text=html, + response_time_ms=1, + content_length=len(html), + final_url="https://site.com/", + headers_dict={}, + redirect_chain_length=0, + fetch_method="static", + ) + out = c.worker("https://site.com/") + targets = json.loads(out["outlink_targets"]) + assert targets == ["https://site.com/target"] + + +def test_worker_custom_extraction_regex(monkeypatch) -> None: + from website_profiling.crawl.crawler import Crawler + from website_profiling.crawl.fetchers.base import FetchResult + + monkeypatch.setattr( + "website_profiling.crawl.sitemap.discover_sitemap_urls", + lambda *_a, **_k: [], + ) + html = "SKU: ABC-123" + c = Crawler( + start_url="https://site.com", + ignore_robots=True, + use_wappalyzer=False, + custom_extraction_regex=r"SKU:\s*([\w-]+)", + ) + c.fetch = lambda _url: FetchResult( # type: ignore[method-assign] + status=200, + content_type="text/html", + text=html, + response_time_ms=1, + content_length=len(html), + final_url="https://site.com/a", + headers_dict={}, + redirect_chain_length=0, + fetch_method="static", + ) + out = c.worker("https://site.com/a") + assert out.get("custom_extract") == "ABC-123" + + +def test_worker_custom_extraction_invalid_regex_is_ignored(monkeypatch) -> None: + from website_profiling.crawl.crawler import Crawler + from website_profiling.crawl.fetchers.base import FetchResult + + monkeypatch.setattr( + "website_profiling.crawl.sitemap.discover_sitemap_urls", + lambda *_a, **_k: [], + ) + c = Crawler( + start_url="https://site.com", + ignore_robots=True, + use_wappalyzer=False, + custom_extraction_regex="[invalid", + ) + c.fetch = lambda _url: FetchResult( # type: ignore[method-assign] + status=200, + content_type="text/html", + text="data", + response_time_ms=1, + content_length=10, + final_url="https://site.com/a", + headers_dict={}, + redirect_chain_length=0, + fetch_method="static", + ) + out = c.worker("https://site.com/a") + assert "custom_extract" not in out + diff --git a/tests/test_db_stores_unit.py b/tests/test_db_stores_unit.py index 3771c173..ef63db41 100644 --- a/tests/test_db_stores_unit.py +++ b/tests/test_db_stores_unit.py @@ -200,3 +200,58 @@ def test_report_store_write_and_read_none() -> None: assert conn2.commits == 1 assert _extract_hostname("https://X.com/a") == "x.com" + +def test_report_store_writes_audit_health_snapshot() -> None: + from website_profiling.db.report_store import write_report_payload + + conn = _LegacyConn(row=(42,)) + write_report_payload( + conn, # type: ignore[arg-type] + { + "site_name": "Health Site", + "property_id": 7, + "categories": [ + {"id": "technical_seo", "score": 80, "issues": [{"priority": "High"}]}, + {"id": "link_health", "score": 60, "issues": [{"priority": "Critical"}, {"priority": "Low"}]}, + ], + }, + ) + audit_sql = [(s, p) for s, p in conn.executed if "audit_health_snapshots" in s] + assert audit_sql + assert audit_sql[0][1][0] == 7 + assert audit_sql[0][1][3] == 70 + + +def test_report_store_health_snapshot_skips_invalid_entries() -> None: + from website_profiling.db.report_store import write_report_payload + + conn = _LegacyConn(row=(1,)) + write_report_payload( + conn, # type: ignore[arg-type] + { + "site_name": "X", + "property_id": "not-a-number", + "categories": [ + "bad", + {"id": "ok", "score": 50, "issues": ["bad", {"priority": "High"}]}, + ], + }, + ) + audit = [(s, p) for s, p in conn.executed if "audit_health_snapshots" in s][0] + assert audit[1][0] is None + assert audit[1][3] == 50 + + +def test_report_store_health_snapshot_insert_failure_is_ignored() -> None: + from website_profiling.db.report_store import write_report_payload + + class _BoomOnAudit(_LegacyConn): + def execute(self, sql, params=None): + if "audit_health_snapshots" in sql: + raise RuntimeError("no table") + return super().execute(sql, params) + + conn = _BoomOnAudit(row=(2,)) + write_report_payload(conn, {"site_name": "Y", "categories": []}) # type: ignore[arg-type] + assert conn.commits == 1 + diff --git a/tests/test_export_audit.py b/tests/test_export_audit.py index dcee0332..5198380c 100644 --- a/tests/test_export_audit.py +++ b/tests/test_export_audit.py @@ -40,3 +40,48 @@ def test_export_pdf_returns_bytes(monkeypatch): pdf = export_audit.export_audit_pdf() assert isinstance(pdf, bytes) assert pdf[:4] == b"%PDF" + + +def test_export_html_executive_summary_and_llm_recommendation(monkeypatch): + payload = { + "site_name": "Exec Site", + "report_generated_at": "2026-06-01", + "executive_summary": { + "source": "ai_insights", + "summary": "Overall health is strong with two high-priority gaps.", + "priorities": ["Fix canonical tags on /blog/", "Reduce LCP on homepage"], + "top_issues": [ + { + "priority": "high", + "message": "Slow LCP", + "url": "https://exec.example/", + "gsc_clicks": 120, + } + ], + }, + "categories": [ + { + "name": "Performance", + "issues": [ + { + "priority": "high", + "message": "Slow LCP", + "url": "https://exec.example/", + "recommendation": "Optimize images", + "llm_recommendation": "Compress hero image and preload LCP asset", + } + ], + } + ], + "links": [], + } + monkeypatch.setattr(export_audit, "_load_payload", lambda _rid=None: payload) + html_out = export_audit.export_audit_html() + csv_out = export_audit.export_audit_csv() + assert "Executive summary" in html_out + assert "AI insights" in html_out + assert "Fix canonical tags on /blog/" in html_out + assert "Top traffic-impacting issues" in html_out + assert "Compress hero image" in html_out + assert "# Executive summary" in csv_out + assert "llm_recommendation" in csv_out diff --git a/tests/test_export_audit_coverage.py b/tests/test_export_audit_coverage.py new file mode 100644 index 00000000..ed4bbee6 --- /dev/null +++ b/tests/test_export_audit_coverage.py @@ -0,0 +1,251 @@ +"""Branch coverage for export_audit helpers.""" +from __future__ import annotations + +from unittest.mock import MagicMock, patch + +import pytest + +from website_profiling.tools import export_audit + + +def _rich_payload() -> dict: + issues = [ + { + "priority": p, + "message": f"Issue {i}", + "url": f"https://example.com/{i}", + "recommendation": f"Fix {i}", + } + for i, p in enumerate(["critical", "high", "medium", "low"] * 55) + ] + return { + "site_name": "Coverage Site", + "report_title": "Full Audit", + "report_generated_at": "2026-06-07T12:00:00Z", + "recommendations": ["Legacy rec one", "Legacy rec two"], + "executive_summary": { + "source": "deterministic", + "summary": "Measured summary.", + "priorities": ["Priority A"], + "top_issues": [ + { + "priority": "high", + "message": "Top issue", + "url": "https://example.com/top", + "gsc_clicks": "bad", + }, + { + "priority": "medium", + "message": "Zero clicks", + "url": "https://example.com/zero", + "gsc_clicks": 0, + }, + { + "priority": "high", + "message": "x" * 120, + "url": "https://example.com/" + ("segment/" * 15), + }, + ], + }, + "categories": [ + {"name": "Technical SEO", "score": 85, "issues": issues}, + {"name": "Performance", "score": 55, "issues": issues[:2]}, + "not-a-dict", + {"name": "Content", "score": "bad", "issues": ["not-an-issue", {"priority": "low", "message": "ok", "url": "u"}]}, + {"name": "Security", "score": None, "issues": []}, + ], + "links": [ + {"url": "https://example.com/ok", "status": "200", "title": "OK", "inlinks": 3, "word_count": 100}, + {"url": "https://example.com/redirect", "status": "301", "title": "Redir"}, + {"url": "https://example.com/missing", "status": "404", "title": ""}, + {"url": "https://example.com/error", "status": "500", "title": "Err"}, + {"url": "https://example.com/custom", "status": "200", "custom_extract": "CEF"}, + "not-a-dict", + ], + "report_meta": { + "data_sources": ["Crawl", "GSC"], + "google_fetched_at": "2026-06-06", + "export_logo_url": "https://cdn.example/logo.png", + "crawl_scope": { + "pages_crawled": 50, + "max_pages_configured": 100, + "crawl_limited": True, + "render_mode": "javascript", + "js_concurrency": 4, + "browser_diagnostics": { + "pages_with_console_errors": 2, + "total_console_errors": 5, + "pages_with_page_errors": 1, + }, + }, + }, + "summary": { + "total_urls": 50, + "indexable": 45, + "issues_count": len(issues), + "critical_issues": 55, + }, + "status_counts": {"200": 40, "404": 10}, + } + + +def test_load_payload_success_and_missing() -> None: + conn = MagicMock() + payload = {"site_name": "Loaded"} + + with patch("website_profiling.tools.export_audit.db_session") as mock_session: + mock_session.return_value.__enter__.return_value = conn + with patch( + "website_profiling.tools.export_audit.read_report_payload", + return_value=payload, + ): + assert export_audit._load_payload(7) == payload + + with patch("website_profiling.tools.export_audit.db_session") as mock_session: + mock_session.return_value.__enter__.return_value = conn + with patch( + "website_profiling.tools.export_audit.read_report_payload", + return_value=None, + ): + with pytest.raises(FileNotFoundError, match="No report payload"): + export_audit._load_payload() + + +def test_helper_functions_cover_branches() -> None: + payload = _rich_payload() + rows = export_audit._issues_rows(payload) + assert len(rows) >= 4 + + legacy = export_audit._executive_export_data({"recommendations": ["Only legacy"]}) + assert "Only legacy" in legacy["summary"] + + assert export_audit._executive_source_label("ai_insights") == "AI insights" + assert export_audit._executive_source_label("deterministic") == "Measured + Search Console" + assert export_audit._executive_source_label("custom") == "custom" + assert export_audit._executive_source_label("") == "Audit data" + + html_block = export_audit._executive_summary_html(payload) + assert "Executive summary" in html_block + assert "Top traffic-impacting issues" in html_block + + assert export_audit._format_report_date("") == "—" + assert export_audit._format_report_date("not-a-date") == "not-a-date" + assert "2026" in export_audit._format_report_date("2026-06-07T12:00:00") + + assert export_audit._overall_score({"categories": []}) is None + assert export_audit._overall_score(payload) == 70 + + assert export_audit._score_band(None) == ("—", "score-na") + assert export_audit._score_band(85)[1] == "score-good" + assert export_audit._score_band(65)[1] == "score-fair" + assert export_audit._score_band(40)[1] == "score-poor" + + cards = export_audit._category_cards_html(payload["categories"]) + assert "Technical SEO" in cards + assert export_audit._category_cards_html([]).startswith(" None: + lines = dict(export_audit._summary_lines(_rich_payload())) + assert lines["Property"] == "Coverage Site" + assert "pages crawled" in lines["Crawl scope"] + assert "JavaScript rendering" in lines["Crawl scope"] + assert "Browser diagnostics" in lines + assert "Google data fetched" in lines + assert "HTTP status mix" in lines + assert lines["Critical issues"] == "55" + + +def test_summary_lines_auto_and_static_render_modes() -> None: + auto_scope = { + "report_meta": { + "crawl_scope": { + "pages_crawled": 10, + "render_mode": "auto", + "pages_static": 7, + "pages_rendered": 3, + } + } + } + auto_lines = dict(export_audit._summary_lines(auto_scope)) + assert "auto rendering" in auto_lines["Crawl scope"] + + static_scope = { + "report_meta": {"crawl_scope": {"pages_crawled": 5, "static_html_only": True}} + } + static_lines = dict(export_audit._summary_lines(static_scope)) + assert "static HTML only" in static_lines["Crawl scope"] + + +def test_issue_recommendation_prefers_llm_when_distinct() -> None: + rec, llm = export_audit._issue_recommendation( + {"recommendation": "Rule", "llm_recommendation": "LLM fix"} + ) + assert rec == "LLM fix" + assert llm == "LLM fix" + + +def test_export_json_csv_and_truncated_html(monkeypatch) -> None: + payload = _rich_payload() + monkeypatch.setattr(export_audit, "_load_payload", lambda _rid=None: payload) + + json_out = export_audit.export_audit_json() + assert '"Coverage Site"' in json_out + + csv_out = export_audit.export_audit_csv() + assert "data_sources" in csv_out + assert "Measured + Search Console" in csv_out + + html_out = export_audit.export_audit_html() + assert "Overall health score 70/100" in html_out + assert "Showing 200 of" in html_out + assert "Custom extract" in html_out + assert "logo.png" in html_out + + +def test_export_pdf_full_branches(monkeypatch) -> None: + pytest.importorskip("reportlab") + payload = _rich_payload() + monkeypatch.setattr(export_audit, "_load_payload", lambda _rid=None: payload) + + pdf = export_audit.export_audit_pdf() + assert pdf[:4] == b"%PDF" + + +def test_export_pdf_truncates_long_issue_lists(monkeypatch) -> None: + pytest.importorskip("reportlab") + issues = [ + { + "priority": "low", + "message": "x" * 150, + "url": "https://example.com/" + ("path/" * 20), + "recommendation": "fix", + } + for _ in range(90) + ] + payload = { + "site_name": "Truncate PDF", + "categories": [{"name": "Technical SEO", "score": 80, "issues": issues}], + "links": [], + } + monkeypatch.setattr(export_audit, "_load_payload", lambda _rid=None: payload) + pdf = export_audit.export_audit_pdf() + assert pdf[:4] == b"%PDF" + + +def test_export_pdf_requires_reportlab(monkeypatch) -> None: + payload = {"site_name": "No PDF", "categories": [], "links": []} + monkeypatch.setattr(export_audit, "_load_payload", lambda _rid=None: payload) + + import builtins + + real_import = builtins.__import__ + + def fake_import(name, *args, **kwargs): + if name == "reportlab.lib" or name.startswith("reportlab."): + raise ImportError("no reportlab") + return real_import(name, *args, **kwargs) + + with patch("builtins.__import__", side_effect=fake_import): + with pytest.raises(RuntimeError, match="PDF export requires reportlab"): + export_audit.export_audit_pdf() diff --git a/tests/test_gsc_links_store.py b/tests/test_gsc_links_store.py index b1d5f026..d80e980b 100644 --- a/tests/test_gsc_links_store.py +++ b/tests/test_gsc_links_store.py @@ -23,6 +23,7 @@ def property_id(): yield pid +@pytest.mark.integration def test_import_and_read_roundtrip(property_id): csv_text = "Site,Links,Target pages\nexample.com,5,2\n" with db_session() as conn: @@ -39,6 +40,7 @@ def test_import_and_read_roundtrip(property_id): assert status["referringDomainCount"] == 1 +@pytest.mark.integration def test_merge_second_import(property_id): with db_session() as conn: import_gsc_links_csv( diff --git a/tests/test_gsc_links_sync.py b/tests/test_gsc_links_sync.py new file mode 100644 index 00000000..153080a7 --- /dev/null +++ b/tests/test_gsc_links_sync.py @@ -0,0 +1,58 @@ +"""Tests for GSC Links sync / staleness helpers.""" +from __future__ import annotations + +from datetime import datetime, timedelta, timezone +from unittest.mock import MagicMock, patch + +import pytest + +from website_profiling.integrations.google.gsc_links_sync import check_stale_gsc_links_imports + + +def test_check_stale_flags_missing_import() -> None: + row = (42, "Test Site", None) + conn = MagicMock() + cur = MagicMock() + cur.fetchall.return_value = [row] + conn.execute.return_value = cur + + with patch("website_profiling.db.storage.db_session") as mock_session: + mock_session.return_value.__enter__.return_value = conn + stale = check_stale_gsc_links_imports(max_age_days=7) + + assert len(stale) == 1 + assert stale[0]["property_id"] == 42 + assert "No GSC Links import yet" in stale[0]["message"] + assert stale[0]["severity"] == "medium" + + +def test_check_stale_flags_old_import() -> None: + old = datetime.now(timezone.utc) - timedelta(days=10) + row = (7, "Old Site", old) + conn = MagicMock() + cur = MagicMock() + cur.fetchall.return_value = [row] + conn.execute.return_value = cur + + with patch("website_profiling.db.storage.db_session") as mock_session: + mock_session.return_value.__enter__.return_value = conn + stale = check_stale_gsc_links_imports(max_age_days=7) + + assert len(stale) == 1 + assert stale[0]["property_id"] == 7 + assert "days old" in stale[0]["message"] + + +def test_check_stale_skips_recent_import() -> None: + recent = datetime.now(timezone.utc) - timedelta(days=1) + row = (3, "Fresh Site", recent) + conn = MagicMock() + cur = MagicMock() + cur.fetchall.return_value = [row] + conn.execute.return_value = cur + + with patch("website_profiling.db.storage.db_session") as mock_session: + mock_session.return_value.__enter__.return_value = conn + stale = check_stale_gsc_links_imports(max_age_days=7) + + assert stale == [] diff --git a/tests/test_indexation_coverage.py b/tests/test_indexation_coverage.py new file mode 100644 index 00000000..1472d6ba --- /dev/null +++ b/tests/test_indexation_coverage.py @@ -0,0 +1,55 @@ +"""Tests for indexation coverage helpers.""" +from __future__ import annotations + +from unittest.mock import patch + +import pandas as pd + +from website_profiling.reporting.indexation import ( + build_indexation_coverage, + _success_urls, + _gsc_page_urls, + _gsc_by_page, +) + + +def test_success_urls_filters_non_200() -> None: + df = pd.DataFrame([ + {"url": "https://example.com/a", "status": "200"}, + {"url": "https://example.com/b", "status": "404"}, + ]) + urls = _success_urls(df) + assert urls == ["https://example.com/a"] + + +def test_gsc_page_urls_extracts_pages() -> None: + google = {"gsc": {"pages": [{"page": "https://example.com/x"}, {"url": "https://example.com/y"}]}} + assert len(_gsc_page_urls(google)) == 2 + + +@patch("website_profiling.reporting.indexation.discover_sitemap_urls") +def test_build_indexation_coverage_lists(mock_sitemap) -> None: + mock_sitemap.return_value = ["https://example.com/", "https://example.com/sitemap-only"] + df = pd.DataFrame([{"url": "https://example.com/", "status": "200"}]) + google = {"gsc": {"pages": [{"page": "https://example.com/gsc-only"}]}} + out = build_indexation_coverage(df, "https://example.com/", google) + assert out["counts"]["crawled"] == 1 + assert out["counts"]["sitemap_only"] >= 1 + assert "sitemap_only" in out["lists"] + + +def test_success_urls_empty_dataframe() -> None: + assert _success_urls(pd.DataFrame()) == [] + + +def test_success_urls_without_status_column() -> None: + df = pd.DataFrame([{"url": "https://example.com/a"}, {"url": ""}]) + assert _success_urls(df) == ["https://example.com/a"] + + +def test_gsc_page_urls_none_google_data() -> None: + assert _gsc_page_urls(None) == [] + + +def test_gsc_by_page_none_google_data() -> None: + assert _gsc_by_page(None) == {} diff --git a/tests/test_log_parser.py b/tests/test_log_parser.py new file mode 100644 index 00000000..8d32165d --- /dev/null +++ b/tests/test_log_parser.py @@ -0,0 +1,32 @@ +from website_profiling.analysis.log_parser import parse_access_log_lines, compare_log_to_crawl + + +def test_parse_access_log_lines_counts_googlebot() -> None: + lines = [ + '127.0.0.1 - - [10/Oct/2023:13:55:36 +0000] "GET /page HTTP/1.1" 200 1234 "-" "Mozilla/5.0 (compatible; Googlebot/2.1)"', + ] + out = parse_access_log_lines(lines) + assert out["googlebot_hits"] == 1 + assert out["top_paths"][0]["path"] == "/page" + + +def test_parse_access_log_lines_skips_blank_and_comments() -> None: + lines = ["", "# comment", "not-a-log-line"] + out = parse_access_log_lines(lines) + assert out["parsed_lines"] == 0 + + +def test_compare_log_to_crawl() -> None: + log = {"top_paths": [{"path": "/only-in-log", "hits": 5}]} + crawl = ["https://example.com/crawled"] + cmp = compare_log_to_crawl(log, crawl, "https://example.com") + assert "/only-in-log" in cmp["log_only_paths"] + + +def test_compare_log_to_crawl_skips_bad_urls() -> None: + from unittest.mock import patch + + log = {"top_paths": [{"path": "/a", "hits": 1}]} + with patch("urllib.parse.urlparse", side_effect=ValueError("bad")): + cmp = compare_log_to_crawl(log, ["http://x.com/y"], "https://example.com") + assert cmp["crawl_only_count"] == 0 diff --git a/tests/test_page_google.py b/tests/test_page_google.py index a7f4163c..60106b2e 100644 --- a/tests/test_page_google.py +++ b/tests/test_page_google.py @@ -88,11 +88,10 @@ def test_keyword_enrich_parses_jsonb_google_row(): assert "test query" in gsc_queries -def test_page_coach_cache_key_stable(): - from website_profiling.llm.page_coach import build_page_context - - ctx = {"page_url": "https://x.com", "link": None, "current": None, "compare": []} +def test_page_coach_context_shape(): + """Minimal context dict matches keys produced by build_page_context.""" + ctx = {"page_url": "https://x.com", "link": None, "current": None, "baseline": None, "compare": []} payload = json.dumps(ctx, sort_keys=True, default=str) assert "https://x.com" in payload - # build_page_context needs DB — smoke import only - assert callable(build_page_context) + assert "baseline" in payload + assert "compare" in payload diff --git a/tests/test_pipeline_cmd_run_unit.py b/tests/test_pipeline_cmd_run_unit.py index 136f5711..042327dc 100644 --- a/tests/test_pipeline_cmd_run_unit.py +++ b/tests/test_pipeline_cmd_run_unit.py @@ -101,3 +101,36 @@ def fake_lh_on_pages(urls, **_kwargs): pipeline_cmd._run_lighthouse_on_pages(cfg, lighthouse_max_pages=10) assert urls_seen["urls"] == ["https://a.com"] + +def test_lighthouse_on_pages_swallows_google_data_errors(monkeypatch) -> None: + from website_profiling.commands import pipeline_cmd + + class _Ctx: + def __enter__(self): + return object() + + def __exit__(self, _t, _v, _tb): + return False + + import website_profiling.db as db + + monkeypatch.setattr(db, "db_session", lambda: _Ctx()) + monkeypatch.setattr(db, "get_latest_crawl_run_id", lambda _c: 1) + monkeypatch.setattr( + db, + "read_crawl", + lambda _c, _rid: pd.DataFrame([{"url": "https://a.com", "status": 200}]), + ) + monkeypatch.setattr( + "website_profiling.integrations.google.store.read_latest_google_data", + lambda *_a, **_k: (_ for _ in ()).throw(RuntimeError("no google")), + ) + urls_seen = {} + monkeypatch.setitem( + __import__("sys").modules, + "website_profiling.lighthouse.runner", + types.SimpleNamespace(run_lighthouse_on_pages=lambda urls, **_k: urls_seen.setdefault("urls", urls)), + ) + pipeline_cmd._run_lighthouse_on_pages({}, lighthouse_max_pages=5) + assert urls_seen["urls"] == ["https://a.com"] + diff --git a/tests/test_pipeline_lighthouse_url_selection.py b/tests/test_pipeline_lighthouse_url_selection.py index 16a9e91f..17333ca5 100644 --- a/tests/test_pipeline_lighthouse_url_selection.py +++ b/tests/test_pipeline_lighthouse_url_selection.py @@ -33,3 +33,45 @@ def test_select_lighthouse_urls_from_crawl_filters_to_2xx_and_dedupes() -> None: "https://d.com", ] + +def test_select_lighthouse_urls_from_gsc_ranks_by_clicks() -> None: + from website_profiling.commands.pipeline_cmd import select_lighthouse_urls_from_gsc + + google = { + "gsc": { + "pages": [ + {"page": "https://a.com/low", "clicks": 1}, + {"page": "https://a.com/high", "clicks": 50}, + {"page": "https://a.com/missing", "clicks": 99}, + ] + } + } + crawl = ["https://a.com/low", "https://a.com/high"] + picked = select_lighthouse_urls_from_gsc(google, crawl, max_pages=2) + assert picked[0] == "https://a.com/high" + assert len(picked) == 2 + + +def test_select_lighthouse_urls_from_gsc_falls_back_to_crawl() -> None: + from website_profiling.commands.pipeline_cmd import select_lighthouse_urls_from_gsc + + google = {"gsc": {"pages": [{"page": "https://other.com", "clicks": 99}]}} + assert select_lighthouse_urls_from_gsc(google, ["https://a.com/a", "https://a.com/b"], 1) == [ + "https://a.com/a", + ] + + +def test_select_lighthouse_urls_from_gsc_skips_bad_rows() -> None: + from website_profiling.commands.pipeline_cmd import select_lighthouse_urls_from_gsc + + google = { + "gsc": { + "pages": [ + "bad-row", + {"page": "", "clicks": 5}, + {"page": "https://a.com/x", "clicks": "not-a-number"}, + ] + } + } + assert select_lighthouse_urls_from_gsc(google, ["https://a.com/x"], 1) == ["https://a.com/x"] + diff --git a/tests/test_report_categories_golden.py b/tests/test_report_categories_golden.py new file mode 100644 index 00000000..8845e7f8 --- /dev/null +++ b/tests/test_report_categories_golden.py @@ -0,0 +1,68 @@ +"""Golden tests: crawl-like input produces stable category issue fingerprints.""" +from __future__ import annotations + +import json +from pathlib import Path + +import pandas as pd + +from website_profiling.reporting.categories import build_categories, merge_indexation_issues + +FIXTURES = Path(__file__).resolve().parent / "fixtures" / "report" + + +def _issue_fingerprints(categories: list[dict]) -> set[tuple[str, str, str]]: + out: set[tuple[str, str, str]] = set() + for cat in categories: + cat_id = str(cat.get("id") or "") + for issue in cat.get("issues") or []: + msg = str(issue.get("message") or "").lower() + priority = str(issue.get("priority") or "") + out.add((cat_id, msg[:80], priority)) + return out + + +def test_build_categories_golden_fingerprints() -> None: + rows = json.loads((FIXTURES / "minimal_crawl.json").read_text(encoding="utf-8")) + df = pd.DataFrame(rows) + edges = [ + ("https://example.com/", "https://example.com/thin"), + ("https://example.com/a", "https://example.com/broken"), + ] + summary_seo = { + "issues": { + "broken": [{"url": "https://example.com/broken", "status": "404"}], + "redirects": [{"url": "https://example.com/redirect", "status": "301", "final_url": "https://example.com/"}], + } + } + site_level = {"robots_present": True, "sitemap_present": True, "sitemap_valid": True} + lh = {"median_metrics": {"performance_score": 0.85}, "top_failures": []} + crux = {"ok": True, "pass": {"lcp": False, "inp": True, "cls": True}} + + categories = build_categories( + df, + edges, + summary_seo, + site_level, + "https://example.com/", + lighthouse_summary=lh, + crux_summary=crux, + ) + + fps = _issue_fingerprints(categories) + assert any("self-referencing" in b for _, b, _ in fps) + assert any("noindex" in b for _, b, _ in fps) + assert any("soft 404" in b for _, b, _ in fps) + assert any("broken url" in b for _, b, _ in fps) + assert any("crux" in b for _, b, _ in fps) + + indexation = { + "lists": {"sitemap_only": ["https://example.com/missing-page"]}, + "sitemap_urls": ["https://example.com/", "https://example.com/missing-page"], + } + merge_indexation_issues(categories, df, indexation) + merged_fps = _issue_fingerprints(categories) + assert any("not crawled" in b for _, b, _ in merged_fps) + + ids = {c["id"] for c in categories} + assert ids >= {"technical_seo", "core_web_vitals", "link_health", "security", "performance"} diff --git a/tests/test_roadmap_extras.py b/tests/test_roadmap_extras.py new file mode 100644 index 00000000..3c0ccc87 --- /dev/null +++ b/tests/test_roadmap_extras.py @@ -0,0 +1,45 @@ +"""Roadmap extras: competitor CSV gap, audit summary, SERP overlay helpers.""" +from website_profiling.integrations.google.competitor_links import ( + build_competitor_domain_gap, + parse_referring_domains_from_csv, +) +from website_profiling.llm.audit_summary import generate_audit_executive_summary + + +def test_parse_referring_domains_from_csv() -> None: + csv_text = "Site,Links\nexample.com,5\nother.org,2\n" + domains = parse_referring_domains_from_csv(csv_text) + assert "example.com" in domains + assert "other.org" in domains + + +def test_build_competitor_domain_gap() -> None: + our = {"alpha.com", "beta.io"} + refs = ["gamma.net", "alpha.com", "delta.co"] + gap = build_competitor_domain_gap(our, "rival.com", refs) + assert gap["competitor"] == "rival.com" + assert gap["gap_count"] == 2 + assert "gamma.net" in gap["gap_domains"] + assert "delta.co" in gap["gap_domains"] + + +def test_executive_summary_deterministic() -> None: + payload = { + "categories": [ + {"name": "SEO", "score": 80, "issues": [{"message": "Missing title", "url": "https://x.com/a", "priority": "High"}]}, + ], + "google": {"gsc": {"top_pages": [{"page": "https://x.com/a", "clicks": 100}]}}, + "summary": {"total_urls": 10}, + } + result = generate_audit_executive_summary(payload, {}) + assert result["ok"] is True + assert result["source"] == "deterministic" + assert "80" in result["summary"] + assert len(result["top_issues"]) >= 1 + + +def test_executive_summary_empty_payload() -> None: + result = generate_audit_executive_summary({}, {}) + assert result["ok"] is True + assert result["source"] == "deterministic" + assert isinstance(result["summary"], str) diff --git a/tests/test_schedule_runner.py b/tests/test_schedule_runner.py new file mode 100644 index 00000000..9aa9f1ab --- /dev/null +++ b/tests/test_schedule_runner.py @@ -0,0 +1,154 @@ +"""Tests for scheduled audit runner.""" +from __future__ import annotations + +from datetime import datetime, timezone +from unittest.mock import MagicMock, patch + +from website_profiling.tools.schedule_runner import _cron_matches, run_due_scheduled_audits + + +def test_cron_matches_current_minute() -> None: + now = datetime(2026, 6, 7, 14, 30, tzinfo=timezone.utc) + assert _cron_matches("30 14 * * *", now) is True + assert _cron_matches("31 14 * * *", now) is False + + +def test_cron_matches_weekday() -> None: + # 2026-06-07 is Sunday (weekday 6) + now = datetime(2026, 6, 7, 10, 0, tzinfo=timezone.utc) + assert _cron_matches("0 10 * * 6", now) is True + assert _cron_matches("0 10 * * 0", now) is False + + +def test_cron_invalid_expression() -> None: + now = datetime(2026, 6, 7, 10, 0, tzinfo=timezone.utc) + assert _cron_matches("bad cron", now) is False + + +def test_run_due_scheduled_audits_spawns_matching_property() -> None: + now = datetime(2026, 6, 7, 14, 30, tzinfo=timezone.utc) + row = (42, "Scheduled Site", "30 14 * * *") + conn = MagicMock() + cur = MagicMock() + cur.fetchall.return_value = [row] + conn.execute.return_value = cur + + with patch("website_profiling.db.storage.db_session") as mock_session: + mock_session.return_value.__enter__.return_value = conn + with patch("website_profiling.tools.schedule_runner.datetime") as mock_dt: + mock_dt.now.return_value = now + with patch("website_profiling.tools.schedule_runner._spawn_audit_for_property") as mock_spawn: + started = run_due_scheduled_audits() + + assert started == 1 + mock_spawn.assert_called_once_with(42, conn) + + +def test_run_due_scheduled_audits_skips_non_matching_cron() -> None: + now = datetime(2026, 6, 7, 14, 30, tzinfo=timezone.utc) + row = (42, "Scheduled Site", "0 9 * * *") + conn = MagicMock() + cur = MagicMock() + cur.fetchall.return_value = [row] + conn.execute.return_value = cur + + with patch("website_profiling.db.storage.db_session") as mock_session: + mock_session.return_value.__enter__.return_value = conn + with patch("website_profiling.tools.schedule_runner.datetime") as mock_dt: + mock_dt.now.return_value = now + with patch("website_profiling.tools.schedule_runner._spawn_audit_for_property") as mock_spawn: + started = run_due_scheduled_audits() + + assert started == 0 + mock_spawn.assert_not_called() + + +def test_spawn_audit_applies_preset() -> None: + from website_profiling.tools import schedule_runner + + conn = MagicMock() + written: dict = {} + + with patch("website_profiling.db.property_store.get_property_by_id") as mock_prop: + mock_prop.return_value = { + "id": 5, + "site_url": "https://example.com", + "default_crawl_preset": "spa", + } + with patch( + "website_profiling.db.config_store.read_pipeline_config", + return_value=({"max_pages": "100"}, {}), + ): + with patch( + "website_profiling.db.config_store.write_pipeline_config", + side_effect=lambda _c, known, _u: written.update(known), + ): + with patch("website_profiling.tools.schedule_runner.subprocess.Popen") as mock_popen: + schedule_runner._spawn_audit_for_property(5, conn) + + assert written.get("active_property_id") == "5" + assert written.get("start_url") == "https://example.com" + assert written.get("crawl_render_mode") == "auto" + mock_popen.assert_called_once() + + +def test_spawn_audit_skips_missing_property(capsys) -> None: + from website_profiling.tools import schedule_runner + + with patch("website_profiling.db.property_store.get_property_by_id", return_value=None): + schedule_runner._spawn_audit_for_property(99, MagicMock()) + assert "not found" in capsys.readouterr().out + + +def test_cron_matches_wrong_hour() -> None: + now = datetime(2026, 6, 7, 14, 30, tzinfo=timezone.utc) + assert _cron_matches("30 15 * * *", now) is False + + +def test_run_gsc_links_staleness_alerts_delegates() -> None: + from website_profiling.tools.schedule_runner import run_gsc_links_staleness_alerts + + with patch( + "website_profiling.integrations.google.gsc_links_sync.check_stale_gsc_links_imports", + return_value=[{"property_id": 1, "message": "stale"}], + ): + assert len(run_gsc_links_staleness_alerts()) == 1 + + +def test_name_main_guard(capsys, monkeypatch) -> None: + import runpy + + monkeypatch.setenv("DATABASE_URL", "postgres://u:p@127.0.0.1:5432/test") + conn = MagicMock() + cur = MagicMock() + cur.fetchall.return_value = [] + conn.execute.return_value = cur + + with patch("website_profiling.db.storage.db_session") as mock_session: + mock_session.return_value.__enter__.return_value = conn + with patch( + "website_profiling.integrations.google.gsc_links_sync.check_stale_gsc_links_imports", + return_value=[], + ): + runpy.run_module( + "website_profiling.tools.schedule_runner", + run_name="__main__", + alter_sys=False, + ) + + assert "Started 0 scheduled audit" in capsys.readouterr().out + + +def test_main_runs(capsys) -> None: + from website_profiling.tools.schedule_runner import main + + with patch("website_profiling.tools.schedule_runner.run_due_scheduled_audits", return_value=1): + with patch( + "website_profiling.tools.schedule_runner.run_gsc_links_staleness_alerts", + return_value=[{"property_id": 1, "message": "stale"}], + ): + main() + out = capsys.readouterr().out + assert "Started 1 scheduled audit" in out + assert "GSC Links stale" in out + assert "[1] stale" in out diff --git a/tests/test_terminology.py b/tests/test_terminology.py index a7afc7f7..f67497cd 100644 --- a/tests/test_terminology.py +++ b/tests/test_terminology.py @@ -6,3 +6,8 @@ def test_legacy_category_names(): assert category_display_name("Content intelligence") == "Content quality" assert category_display_name("Link Health") == "Links" assert category_display_name("Technical SEO") == "Technical SEO" + + +def test_category_display_name_empty() -> None: + assert category_display_name("") == "" + assert category_display_name(None) == "" # type: ignore[arg-type] diff --git a/tests/test_third_party_csv.py b/tests/test_third_party_csv.py new file mode 100644 index 00000000..a40accee --- /dev/null +++ b/tests/test_third_party_csv.py @@ -0,0 +1,23 @@ +"""Tests for Moz/Majestic CSV overlay parser.""" +from __future__ import annotations + +from website_profiling.integrations.links.third_party_csv import ( + build_third_party_overlay, + parse_third_party_referring_domains, +) + + +def test_parse_moz_csv_domains() -> None: + csv_text = "Root Domain,Domain Authority,External Links\nexample.org,45,120\n" + rows = parse_third_party_referring_domains("moz", csv_text) + assert len(rows) == 1 + assert rows[0]["domain"] == "example.org" + assert rows[0]["authority"] == 45.0 + + +def test_overlay_finds_domains_not_in_gsc_sample() -> None: + csv_text = "Referring domain,Trust Flow,Backlinks\nnewsite.com,20,5\n" + overlay = build_third_party_overlay("majestic", csv_text, our_domains=["oldsite.com"]) + assert overlay["referring_domain_count"] == 1 + assert overlay["domains_not_in_gsc_count"] == 1 + assert overlay["domains_not_in_gsc_sample"] == ["newsite.com"] diff --git a/web/app/(reports)/layout.tsx b/web/app/(reports)/layout.tsx deleted file mode 100644 index fe212c17..00000000 --- a/web/app/(reports)/layout.tsx +++ /dev/null @@ -1,6 +0,0 @@ -import { ReportAppClient } from '@/ReportShell'; -import type { ReactNode } from 'react'; - -export default function ReportsLayout({ children }: { children: ReactNode }) { - return {children}; -} diff --git a/web/app/api/alerts/check/route.ts b/web/app/api/alerts/check/route.ts new file mode 100644 index 00000000..0ef27fad --- /dev/null +++ b/web/app/api/alerts/check/route.ts @@ -0,0 +1,62 @@ +import { NextResponse, type NextRequest } from 'next/server'; +import { forbiddenIfNotLocal } from '@/server/localOnly'; +import { spawn } from 'child_process'; +import path from 'path'; +import { resolvePythonExecutable } from '@/server/resolvePython'; +import { getRepoRoot } from '@/server/pipelineSpawnEnv'; +import type { ApiRouteHandler } from '@/types/api'; + +export const runtime = 'nodejs'; +export const dynamic = 'force-dynamic'; + +/** + * POST /api/alerts/check?propertyId= — run health alert rules and optional webhook dispatch. + */ +export const POST: ApiRouteHandler = async (request: NextRequest): Promise => { + const denied = forbiddenIfNotLocal(request); + if (denied) return denied; + + const propertyId = Number(request.nextUrl.searchParams.get('propertyId') || '0'); + if (!propertyId) { + return NextResponse.json({ error: 'propertyId required' }, { status: 400 }); + } + + const repoRoot = getRepoRoot(); + const pythonExe = resolvePythonExecutable(null, repoRoot); + const script = ` +import json, sys +from website_profiling.tools.alert_checker import check_all_alerts, dispatch_webhook +from website_profiling.db.storage import db_session + +property_id = int(sys.argv[1]) +alerts = check_all_alerts(property_id) +webhook_sent = False +with db_session() as conn: + cur = conn.execute( + "SELECT alert_webhook_url FROM properties WHERE id = %s", + (property_id,), + ) + row = cur.fetchone() + url = (row[0] if row and not hasattr(row, "keys") else (row.get("alert_webhook_url") if row else "")) or "" + if url and alerts: + webhook_sent = dispatch_webhook(url, {"property_id": property_id, "alerts": alerts}) +print(json.dumps({"alerts": alerts, "webhook_sent": webhook_sent})) +`; + + return new Promise((resolve) => { + const proc = spawn(pythonExe, ['-c', script, String(propertyId)], { + cwd: repoRoot, + shell: false, + }); + let stdout = ''; + proc.stdout?.on('data', (c: Buffer | string) => { stdout += c.toString(); }); + proc.on('close', (code) => { + try { + const parsed = JSON.parse(stdout.trim() || '{}'); + resolve(NextResponse.json(parsed, { status: code === 0 ? 200 : 500 })); + } catch { + resolve(NextResponse.json({ error: stdout.trim() || 'Alert check failed' }, { status: 500 })); + } + }); + }); +}; diff --git a/web/app/api/auth/login/route.ts b/web/app/api/auth/login/route.ts index 56adf5fb..024a2b39 100644 --- a/web/app/api/auth/login/route.ts +++ b/web/app/api/auth/login/route.ts @@ -2,6 +2,7 @@ import { NextResponse, type NextRequest } from 'next/server'; import { authEnabled, createSessionToken, + defaultSessionRole, parseBasicAuth, } from '@/server/auth'; import { forbiddenIfNotLocal } from '@/server/localOnly'; @@ -18,7 +19,7 @@ export const POST: ApiRouteHandler = async (request: NextRequest): Promise => { + const enabled = authEnabled(); + const role = sessionRoleFromRequest(request); + return NextResponse.json({ + authEnabled: enabled, + authenticated: !enabled || Boolean(role), + role: role ?? (enabled ? null : 'analyst'), + canMutate: canMutateRole(role ?? (enabled ? null : 'analyst')), + readonly: enabled && Boolean(role) && !canMutateRole(role), + }); +}; diff --git a/web/app/api/backlinks/competitor-import/route.ts b/web/app/api/backlinks/competitor-import/route.ts new file mode 100644 index 00000000..404b7dcc --- /dev/null +++ b/web/app/api/backlinks/competitor-import/route.ts @@ -0,0 +1,70 @@ +import { NextResponse, type NextRequest } from 'next/server'; +import { requireApiAuth } from '@/server/auth'; +import { spawn } from 'child_process'; +import { getRepoRoot, getPipelineSpawnEnv } from '@/server/pipelineSpawnEnv'; +import { resolvePythonExecutable, parsePythonJsonStdout } from '@/server/resolvePython'; +import type { ApiRouteHandler } from '@/types/api'; + +export const runtime = 'nodejs'; +export const dynamic = 'force-dynamic'; + +/** + * POST /api/backlinks/competitor-import + * Body: { competitor, csvText, ourDomains?: string[] } + */ +export const POST: ApiRouteHandler = async (request: NextRequest): Promise => { + const authDenied = requireApiAuth(request); + if (authDenied) return authDenied; + + let body: { competitor?: string; csvText?: string; ourDomains?: string[] }; + try { + body = await request.json(); + } catch { + return NextResponse.json({ error: 'Invalid JSON' }, { status: 400 }); + } + const competitor = String(body.competitor || '').trim(); + const csvText = String(body.csvText || ''); + if (!competitor || !csvText.trim()) { + return NextResponse.json({ error: 'competitor and csvText required' }, { status: 400 }); + } + + const repoRoot = getRepoRoot(); + const pythonExe = resolvePythonExecutable(null, repoRoot); + const script = ` +import json, sys +from website_profiling.integrations.google.competitor_links import ( + parse_referring_domains_from_csv, + build_competitor_domain_gap, +) +payload = json.load(sys.stdin) +refs = parse_referring_domains_from_csv(payload.get("csvText") or "") +our = set(payload.get("ourDomains") or []) +print(json.dumps(build_competitor_domain_gap(our, payload.get("competitor") or "", refs))) +`; + + return new Promise((resolve) => { + const proc = spawn(pythonExe, ['-c', script], { + cwd: repoRoot, + env: getPipelineSpawnEnv(repoRoot), + shell: false, + }); + let stdout = ''; + proc.stdout?.on('data', (c: Buffer | string) => { stdout += c.toString(); }); + proc.stdin?.write( + JSON.stringify({ + competitor, + csvText, + ourDomains: body.ourDomains || [], + }), + ); + proc.stdin?.end(); + proc.on('close', (code) => { + const parsed = parsePythonJsonStdout(stdout); + if (code === 0 && parsed) { + resolve(NextResponse.json({ gap: parsed })); + return; + } + resolve(NextResponse.json({ error: stdout.trim() || 'Import failed' }, { status: 500 })); + }); + }); +}; diff --git a/web/app/api/backlinks/third-party-import/route.ts b/web/app/api/backlinks/third-party-import/route.ts new file mode 100644 index 00000000..363fcd19 --- /dev/null +++ b/web/app/api/backlinks/third-party-import/route.ts @@ -0,0 +1,94 @@ +import { NextResponse, type NextRequest } from 'next/server'; +import { spawn } from 'child_process'; +import { requireApiAuth } from '@/server/auth'; +import { getRepoRoot, getPipelineSpawnEnv } from '@/server/pipelineSpawnEnv'; +import { resolvePythonExecutable, parsePythonJsonStdout } from '@/server/resolvePython'; +import type { ApiRouteHandler } from '@/types/api'; + +export const runtime = 'nodejs'; +export const dynamic = 'force-dynamic'; + +/** + * POST /api/backlinks/third-party-import + * Body: { propertyId, provider: 'moz'|'majestic', csvText, ourDomains?: string[] } + */ +export const POST: ApiRouteHandler = async (request: NextRequest): Promise => { + const authDenied = requireApiAuth(request); + if (authDenied) return authDenied; + + let body: { + propertyId?: number; + provider?: string; + csvText?: string; + ourDomains?: string[]; + }; + try { + body = await request.json(); + } catch { + return NextResponse.json({ error: 'Invalid JSON' }, { status: 400 }); + } + + const propertyId = Number(body.propertyId || 0); + const provider = String(body.provider || 'moz').trim().toLowerCase(); + const csvText = String(body.csvText || ''); + if (!propertyId || !csvText.trim()) { + return NextResponse.json({ error: 'propertyId and csvText required' }, { status: 400 }); + } + if (provider !== 'moz' && provider !== 'majestic') { + return NextResponse.json({ error: 'provider must be moz or majestic' }, { status: 400 }); + } + + const repoRoot = getRepoRoot(); + const pythonExe = resolvePythonExecutable(null, repoRoot); + const script = ` +import json, sys +from website_profiling.integrations.links.third_party_csv import build_third_party_overlay +from website_profiling.integrations.google.gsc_links_store import import_third_party_links_overlay +from website_profiling.db.storage import db_session + +payload = json.load(sys.stdin) +property_id = int(payload["propertyId"]) +overlay = build_third_party_overlay( + payload.get("provider") or "moz", + payload.get("csvText") or "", + payload.get("ourDomains") or [], +) +with db_session() as conn: + result = import_third_party_links_overlay(conn, property_id, overlay) +print(json.dumps(result)) +`; + + return new Promise((resolve) => { + const proc = spawn(pythonExe, ['-c', script], { + cwd: repoRoot, + env: getPipelineSpawnEnv(repoRoot), + shell: false, + }); + let stdout = ''; + let stderr = ''; + proc.stdout?.on('data', (c: Buffer | string) => { stdout += c.toString(); }); + proc.stderr?.on('data', (c: Buffer | string) => { stderr += c.toString(); }); + proc.stdin?.write( + JSON.stringify({ + propertyId, + provider, + csvText, + ourDomains: body.ourDomains || [], + }), + ); + proc.stdin?.end(); + proc.on('close', (code) => { + const parsed = parsePythonJsonStdout(stdout); + if (code === 0 && parsed) { + resolve(NextResponse.json(parsed)); + return; + } + resolve( + NextResponse.json( + { error: (stderr || stdout).trim() || 'Import failed' }, + { status: 500 }, + ), + ); + }); + }); +}; diff --git a/web/app/api/backlinks/velocity/route.ts b/web/app/api/backlinks/velocity/route.ts new file mode 100644 index 00000000..ad116ffd --- /dev/null +++ b/web/app/api/backlinks/velocity/route.ts @@ -0,0 +1,41 @@ +import { NextResponse, type NextRequest } from 'next/server'; +import { withDb } from '@/server/db'; +import type { ApiRouteHandler } from '@/types/api'; + +export const dynamic = 'force-dynamic'; + +/** + * GET /api/backlinks/velocity?propertyId= + */ +export const GET: ApiRouteHandler = async (request: NextRequest): Promise => { + const propertyId = Number(request.nextUrl.searchParams.get('propertyId') || '0'); + if (!propertyId) { + return NextResponse.json({ error: 'propertyId required' }, { status: 400 }); + } + + try { + const snapshots = await withDb(async (client) => { + const cur = await client.query<{ + captured_at: Date; + referring_domains: number; + top_domains: unknown; + }>( + `SELECT captured_at, referring_domains, top_domains + FROM gsc_links_snapshots + WHERE property_id = $1 + ORDER BY captured_at ASC + LIMIT 52`, + [propertyId], + ); + return cur.rows.map((row) => ({ + capturedAt: row.captured_at.toISOString(), + referringDomains: row.referring_domains, + topDomains: row.top_domains, + })); + }); + return NextResponse.json({ snapshots }); + } catch (e) { + const msg = e instanceof Error ? e.message : String(e); + return NextResponse.json({ error: msg, snapshots: [] }, { status: 500 }); + } +}; diff --git a/web/app/api/compare/export/route.ts b/web/app/api/compare/export/route.ts new file mode 100644 index 00000000..72d6af26 --- /dev/null +++ b/web/app/api/compare/export/route.ts @@ -0,0 +1,109 @@ +import { NextResponse, type NextRequest } from 'next/server'; +import { withDb } from '@/server/db'; +import type { ApiRouteHandler } from '@/types/api'; +import type { ReportCategory, ReportIssue } from '@/types'; + +export const runtime = 'nodejs'; +export const dynamic = 'force-dynamic'; + +function issueKey(cat: string, iss: ReportIssue): string { + return `${cat}|${iss.url || ''}|${iss.message || ''}`; +} + +function collectIssues(categories: ReportCategory[] = []): Map { + const map = new Map(); + for (const cat of categories) { + const name = cat.name || cat.id || ''; + for (const issue of cat.issues || []) { + map.set(issueKey(name, issue), { cat: name, issue }); + } + } + return map; +} + +function csvEscape(value: string): string { + if (/[",\n]/.test(value)) return `"${value.replace(/"/g, '""')}"`; + return value; +} + +/** + * POST /api/compare/export — CSV diff between two report ids. + */ +export const POST: ApiRouteHandler = async (request: NextRequest): Promise => { + let body: { reportIdA?: number; reportIdB?: number }; + try { + body = await request.json(); + } catch { + return NextResponse.json({ error: 'Invalid JSON' }, { status: 400 }); + } + + const reportIdA = Number(body.reportIdA || 0); + const reportIdB = Number(body.reportIdB || 0); + if (!reportIdA || !reportIdB) { + return NextResponse.json({ error: 'reportIdA and reportIdB required' }, { status: 400 }); + } + + try { + const [payloadA, payloadB] = await withDb(async (client) => { + const rows = await Promise.all( + [reportIdA, reportIdB].map(async (id) => { + const cur = await client.query<{ data: { categories?: ReportCategory[] } }>( + 'SELECT data FROM report_payload WHERE id = $1', + [id], + ); + return cur.rows[0]?.data || { categories: [] }; + }), + ); + return rows; + }); + + const issuesA = collectIssues(payloadA.categories); + const issuesB = collectIssues(payloadB.categories); + const lines = ['change,category,priority,url,message,recommendation']; + + for (const [key, { cat, issue }] of issuesA) { + if (!issuesB.has(key)) { + lines.push( + [ + 'removed', + cat, + issue.priority || '', + issue.url || '', + issue.message || '', + issue.recommendation || '', + ] + .map((v) => csvEscape(String(v))) + .join(','), + ); + } + } + for (const [key, { cat, issue }] of issuesB) { + if (!issuesA.has(key)) { + lines.push( + [ + 'added', + cat, + issue.priority || '', + issue.url || '', + issue.message || '', + issue.recommendation || '', + ] + .map((v) => csvEscape(String(v))) + .join(','), + ); + } + } + + const csv = `${lines.join('\n')}\n`; + return new NextResponse(csv, { + status: 200, + headers: { + 'Content-Type': 'text/csv; charset=utf-8', + 'Content-Disposition': `attachment; filename="audit-compare-${reportIdA}-vs-${reportIdB}.csv"`, + }, + }); + } catch (e) { + const msg = e instanceof Error ? e.message : String(e); + return NextResponse.json({ error: msg }, { status: 500 }); + } +}; diff --git a/web/app/api/integrations/bing/sync/route.ts b/web/app/api/integrations/bing/sync/route.ts new file mode 100644 index 00000000..bee7e962 --- /dev/null +++ b/web/app/api/integrations/bing/sync/route.ts @@ -0,0 +1,58 @@ +import { NextResponse, type NextRequest } from 'next/server'; +import { spawn } from 'child_process'; +import { getRepoRoot, getPipelineSpawnEnv } from '@/server/pipelineSpawnEnv'; +import { resolvePythonExecutable, parsePythonJsonStdout } from '@/server/resolvePython'; +import { loadPipelineConfig } from '@/server/pipelineConfig'; +import type { ApiRouteHandler } from '@/types/api'; + +export const runtime = 'nodejs'; +export const dynamic = 'force-dynamic'; + +/** + * POST /api/integrations/bing/sync — fetch Bing Webmaster backlinks summary. + */ +export const POST: ApiRouteHandler = async (_request: NextRequest): Promise => { + let state: Record; + try { + const cfg = await loadPipelineConfig(); + state = cfg.state; + } catch (e) { + const msg = e instanceof Error ? e.message : String(e); + return NextResponse.json({ error: msg }, { status: 500 }); + } + const apiKey = String(state.bing_webmaster_api_key || '').trim(); + const siteUrl = String(state.start_url || '').trim(); + if (!apiKey || !siteUrl) { + return NextResponse.json( + { error: 'Set bing_webmaster_api_key and start_url in pipeline settings.' }, + { status: 400 }, + ); + } + + const repoRoot = getRepoRoot(); + const pythonExe = resolvePythonExecutable(null, repoRoot); + const script = ` +import json, sys +from website_profiling.integrations.bing.webmaster import fetch_bing_backlinks_summary +api_key, site_url = sys.argv[1], sys.argv[2] +print(json.dumps(fetch_bing_backlinks_summary(api_key, site_url))) +`; + + return new Promise((resolve) => { + const proc = spawn(pythonExe, ['-c', script, apiKey, siteUrl], { + cwd: repoRoot, + env: getPipelineSpawnEnv(repoRoot), + shell: false, + }); + let stdout = ''; + proc.stdout?.on('data', (c: Buffer | string) => { stdout += c.toString(); }); + proc.on('close', (code) => { + const parsed = parsePythonJsonStdout(stdout); + if (code === 0 && parsed) { + resolve(NextResponse.json(parsed)); + return; + } + resolve(NextResponse.json({ error: stdout.trim() || 'Bing sync failed' }, { status: 500 })); + }); + }); +}; diff --git a/web/app/api/issues/fix-suggestion/route.ts b/web/app/api/issues/fix-suggestion/route.ts new file mode 100644 index 00000000..f261186c --- /dev/null +++ b/web/app/api/issues/fix-suggestion/route.ts @@ -0,0 +1,71 @@ +import { NextResponse, type NextRequest } from 'next/server'; +import { spawn } from 'child_process'; +import { getRepoRoot, getPipelineSpawnEnv } from '@/server/pipelineSpawnEnv'; +import { resolvePythonExecutable, parsePythonJsonStdout } from '@/server/resolvePython'; +import type { ApiRouteHandler } from '@/types/api'; + +export const runtime = 'nodejs'; +export const dynamic = 'force-dynamic'; + +/** + * POST /api/issues/fix-suggestion — on-demand LLM fix for one issue. + */ +export const POST: ApiRouteHandler = async (request: NextRequest): Promise => { + let body: { + message?: string; + url?: string; + priority?: string; + category?: string; + recommendation?: string; + type?: string; + refresh?: boolean; + }; + try { + body = await request.json(); + } catch { + return NextResponse.json({ error: 'Invalid JSON' }, { status: 400 }); + } + const message = String(body.message || '').trim(); + if (!message) { + return NextResponse.json({ error: 'message required' }, { status: 400 }); + } + + const repoRoot = getRepoRoot(); + const pythonExe = resolvePythonExecutable(null, repoRoot); + const script = ` +import json, sys +from website_profiling.llm.issue_fixes import generate_issue_fix_suggestion +payload = json.load(sys.stdin) +print(json.dumps(generate_issue_fix_suggestion(payload, refresh=bool(payload.get("refresh"))))) +`; + + return new Promise((resolve) => { + const proc = spawn(pythonExe, ['-c', script], { + cwd: repoRoot, + env: getPipelineSpawnEnv(repoRoot), + shell: false, + }); + let stdout = ''; + proc.stdout?.on('data', (c: Buffer | string) => { stdout += c.toString(); }); + proc.stdin?.write( + JSON.stringify({ + message, + url: body.url, + priority: body.priority, + category: body.category, + recommendation: body.recommendation, + type: body.type, + refresh: body.refresh, + }), + ); + proc.stdin?.end(); + proc.on('close', (code) => { + const parsed = parsePythonJsonStdout(stdout); + if (code === 0 && parsed) { + resolve(NextResponse.json(parsed)); + return; + } + resolve(NextResponse.json({ error: stdout.trim() || 'Fix suggestion failed' }, { status: 500 })); + }); + }); +}; diff --git a/web/app/api/issues/status/route.ts b/web/app/api/issues/status/route.ts new file mode 100644 index 00000000..3b719aa9 --- /dev/null +++ b/web/app/api/issues/status/route.ts @@ -0,0 +1,70 @@ +import { NextResponse, type NextRequest } from 'next/server'; +import { forbiddenIfNotLocal } from '@/server/localOnly'; +import { listIssueStatus, upsertIssueStatus, type IssueWorkflowStatus } from '@/server/issueStatusDb'; +import type { ApiRouteHandler } from '@/types/api'; + +export const runtime = 'nodejs'; +export const dynamic = 'force-dynamic'; + +const VALID_STATUS = new Set(['open', 'in_progress', 'fixed', 'ignored']); + +export const GET: ApiRouteHandler = async (request: NextRequest): Promise => { + const propertyId = Number(request.nextUrl.searchParams.get('propertyId') || '0'); + if (!propertyId) { + return NextResponse.json({ error: 'propertyId required' }, { status: 400 }); + } + try { + const rows = await listIssueStatus(propertyId); + return NextResponse.json({ issues: rows }); + } catch (e) { + const msg = e instanceof Error ? e.message : String(e); + return NextResponse.json({ error: msg }, { status: 500 }); + } +}; + +export const PUT: ApiRouteHandler = async (request: NextRequest): Promise => { + const denied = forbiddenIfNotLocal(request); + if (denied) return denied; + + let body: { + propertyId?: number; + reportId?: number; + message?: string; + url?: string; + priority?: string; + categoryId?: string; + status?: string; + assignee?: string; + note?: string; + }; + try { + body = await request.json(); + } catch { + return NextResponse.json({ error: 'Invalid JSON' }, { status: 400 }); + } + + const propertyId = Number(body.propertyId || 0); + const message = String(body.message || '').trim(); + const status = body.status as IssueWorkflowStatus; + if (!propertyId || !message || !VALID_STATUS.has(status)) { + return NextResponse.json({ error: 'propertyId, message, and valid status required' }, { status: 400 }); + } + + try { + const row = await upsertIssueStatus({ + propertyId, + reportId: body.reportId, + message, + url: body.url, + priority: body.priority, + categoryId: body.categoryId, + status, + assignee: body.assignee, + note: body.note, + }); + return NextResponse.json({ issue: row }); + } catch (e) { + const msg = e instanceof Error ? e.message : String(e); + return NextResponse.json({ error: msg }, { status: 500 }); + } +}; diff --git a/web/app/api/jobs/route.ts b/web/app/api/jobs/route.ts new file mode 100644 index 00000000..378047fa --- /dev/null +++ b/web/app/api/jobs/route.ts @@ -0,0 +1,37 @@ +import { NextResponse, type NextRequest } from 'next/server'; +import { forbiddenIfNotLocal } from '@/server/localOnly'; +import { + getActiveRunningJob, + listRecentPipelineJobs, + reconcileStaleRunningJobs, +} from '@/server/pipelineJobsDb'; +import type { ApiRouteHandler } from '@/types/api'; + +export const runtime = 'nodejs'; +export const dynamic = 'force-dynamic'; + +/** + * GET /api/jobs — list recent pipeline jobs and return the active running job (if any). + * Reconciles stale running jobs before listing. + */ +export const GET: ApiRouteHandler = async (request: NextRequest): Promise => { + const denied = forbiddenIfNotLocal(request); + if (denied) return denied; + + const limit = Math.min( + 100, + Math.max(1, Number(request.nextUrl.searchParams.get('limit') || '20') || 20), + ); + + try { + const reconciled = await reconcileStaleRunningJobs(); + const [jobs, active] = await Promise.all([ + listRecentPipelineJobs(limit), + getActiveRunningJob(), + ]); + return NextResponse.json({ jobs, active, reconciled }); + } catch (e) { + const msg = e instanceof Error ? e.message : String(e); + return NextResponse.json({ error: msg }, { status: 500 }); + } +}; diff --git a/web/app/api/keywords/content-brief/route.ts b/web/app/api/keywords/content-brief/route.ts new file mode 100644 index 00000000..d62dad5f --- /dev/null +++ b/web/app/api/keywords/content-brief/route.ts @@ -0,0 +1,57 @@ +import { NextResponse, type NextRequest } from 'next/server'; +import { spawn } from 'child_process'; +import { getRepoRoot, getPipelineSpawnEnv } from '@/server/pipelineSpawnEnv'; +import { resolvePythonExecutable, parsePythonJsonStdout } from '@/server/resolvePython'; +import type { ApiRouteHandler } from '@/types/api'; + +export const runtime = 'nodejs'; +export const dynamic = 'force-dynamic'; + +/** + * POST /api/keywords/content-brief + */ +export const POST: ApiRouteHandler = async (request: NextRequest): Promise => { + let body: { keyword?: string; rows?: unknown[]; gaps?: string[] }; + try { + body = await request.json(); + } catch { + return NextResponse.json({ error: 'Invalid JSON' }, { status: 400 }); + } + const keyword = String(body.keyword || '').trim(); + if (!keyword) { + return NextResponse.json({ error: 'keyword required' }, { status: 400 }); + } + + const repoRoot = getRepoRoot(); + const pythonExe = resolvePythonExecutable(null, repoRoot); + const script = ` +import json, sys +from website_profiling.llm.content_brief import generate_content_brief +payload = json.load(sys.stdin) +print(json.dumps(generate_content_brief( + payload.get("keyword", ""), + payload.get("rows") or [], + payload.get("gaps"), +))) +`; + + return new Promise((resolve) => { + const proc = spawn(pythonExe, ['-c', script], { + cwd: repoRoot, + env: getPipelineSpawnEnv(repoRoot), + shell: false, + }); + let stdout = ''; + proc.stdout?.on('data', (c: Buffer | string) => { stdout += c.toString(); }); + proc.stdin?.write(JSON.stringify({ keyword, rows: body.rows || [], gaps: body.gaps || [] })); + proc.stdin?.end(); + proc.on('close', (code) => { + const parsed = parsePythonJsonStdout(stdout); + if (code === 0 && parsed) { + resolve(NextResponse.json({ brief: parsed })); + return; + } + resolve(NextResponse.json({ error: stdout.trim() || 'Brief failed' }, { status: 500 })); + }); + }); +}; diff --git a/web/app/api/logs/upload/route.ts b/web/app/api/logs/upload/route.ts new file mode 100644 index 00000000..14ce9eb3 --- /dev/null +++ b/web/app/api/logs/upload/route.ts @@ -0,0 +1,73 @@ +import { NextResponse, type NextRequest } from 'next/server'; +import { requireApiAuth } from '@/server/auth'; +import { forbiddenIfNotLocal } from '@/server/localOnly'; +import { withDb } from '@/server/db'; +import type { ApiRouteHandler } from '@/types/api'; + +export const runtime = 'nodejs'; +export const dynamic = 'force-dynamic'; + +/** + * POST /api/logs/upload — parse access log and store analysis (Phase 6). + */ +export const POST: ApiRouteHandler = async (request: NextRequest): Promise => { + const denied = forbiddenIfNotLocal(request); + if (denied) return denied; + const authDenied = requireApiAuth(request); + if (authDenied) return authDenied; + + const form = await request.formData(); + const file = form.get('file'); + const propertyId = Number(form.get('propertyId') || '0'); + if (!propertyId || !(file instanceof File)) { + return NextResponse.json({ error: 'propertyId and file required' }, { status: 400 }); + } + + const text = await file.text(); + const lines = text.split(/\r?\n/); + + try { + const { spawn } = await import('child_process'); + const path = await import('path'); + const repoRoot = process.env.WEBSITE_PROFILING_ROOT || path.resolve(process.cwd(), '..'); + const analysis = await new Promise>((resolve, reject) => { + const startUrl = String(form.get('startUrl') || ''); + const crawlUrlsRaw = String(form.get('crawlUrls') || ''); + const crawlUrls = crawlUrlsRaw ? crawlUrlsRaw.split('\n').filter(Boolean) : []; + const script = ` +import json, sys +from website_profiling.analysis.log_parser import parse_access_log_lines, compare_log_to_crawl +lines = sys.stdin.read().splitlines() +analysis = parse_access_log_lines(lines) +meta = json.loads(sys.argv[1]) +start = meta.get("start_url") or "" +crawl_urls = meta.get("crawl_urls") or [] +if start and crawl_urls: + analysis["crawl_compare"] = compare_log_to_crawl(analysis, crawl_urls, start) +print(json.dumps(analysis)) +`; + const meta = JSON.stringify({ start_url: startUrl, crawl_urls: crawlUrls }); + const proc = spawn('python3', ['-c', script, meta], { cwd: repoRoot, shell: false }); + let out = ''; + proc.stdout?.on('data', (c: Buffer) => { out += c.toString(); }); + proc.stderr?.on('data', (c: Buffer) => { out += c.toString(); }); + proc.stdin?.write(text); + proc.stdin?.end(); + proc.on('close', (code) => { + if (code !== 0) reject(new Error(out || 'parse failed')); + else resolve(JSON.parse(out.trim() || '{}') as Record); + }); + }); + await withDb(async (client) => { + await client.query( + `INSERT INTO log_file_uploads (property_id, filename, line_count, analysis) + VALUES ($1, $2, $3, $4)`, + [propertyId, file.name, lines.length, JSON.stringify(analysis)], + ); + }); + return NextResponse.json({ ok: true, analysis }); + } catch (e) { + const msg = e instanceof Error ? e.message : String(e); + return NextResponse.json({ error: msg }, { status: 500 }); + } +}; diff --git a/web/app/api/properties/[id]/ops/route.ts b/web/app/api/properties/[id]/ops/route.ts new file mode 100644 index 00000000..5d568eab --- /dev/null +++ b/web/app/api/properties/[id]/ops/route.ts @@ -0,0 +1,52 @@ +import { NextResponse, type NextRequest } from 'next/server'; +import { forbiddenIfNotLocal } from '@/server/localOnly'; +import { getPropertyById, setPropertyOpsSettings } from '@/server/propertiesDb'; +import type { ApiRouteHandlerWithParams } from '@/types/api'; + +export const runtime = 'nodejs'; +export const dynamic = 'force-dynamic'; + +export const GET: ApiRouteHandlerWithParams<{ id: string }> = async ( + _request: NextRequest, + { params }: { params: Promise<{ id: string }> }, +): Promise => { + const { id } = await params; + const propertyId = Number(id); + if (!propertyId) return NextResponse.json({ error: 'Invalid property id' }, { status: 400 }); + const row = await getPropertyById(propertyId); + if (!row) return NextResponse.json({ error: 'Property not found' }, { status: 404 }); + return NextResponse.json({ + schedule_cron: row.schedule_cron, + alert_webhook_url: row.alert_webhook_url, + alert_email: row.alert_email, + }); +}; + +export const PUT: ApiRouteHandlerWithParams<{ id: string }> = async ( + request: NextRequest, + { params }: { params: Promise<{ id: string }> }, +): Promise => { + const denied = forbiddenIfNotLocal(request); + if (denied) return denied; + const { id } = await params; + const propertyId = Number(id); + if (!propertyId) return NextResponse.json({ error: 'Invalid property id' }, { status: 400 }); + + let body: { + scheduleCron?: string | null; + alertWebhookUrl?: string | null; + alertEmail?: string | null; + }; + try { + body = await request.json(); + } catch { + return NextResponse.json({ error: 'Invalid JSON' }, { status: 400 }); + } + + await setPropertyOpsSettings(propertyId, { + scheduleCron: body.scheduleCron, + alertWebhookUrl: body.alertWebhookUrl, + alertEmail: body.alertEmail, + }); + return NextResponse.json({ ok: true }); +}; diff --git a/web/app/api/properties/[id]/preset/route.ts b/web/app/api/properties/[id]/preset/route.ts new file mode 100644 index 00000000..278a94e5 --- /dev/null +++ b/web/app/api/properties/[id]/preset/route.ts @@ -0,0 +1,44 @@ +import { NextResponse, type NextRequest } from 'next/server'; +import { forbiddenIfNotLocal } from '@/server/localOnly'; +import { getPropertyById, setPropertyCrawlPreset } from '@/server/propertiesDb'; +import { isCrawlPresetId } from '@/lib/crawlPresets'; +import type { ApiRouteHandlerWithParams } from '@/types/api'; + +export const runtime = 'nodejs'; +export const dynamic = 'force-dynamic'; + +export const GET: ApiRouteHandlerWithParams<{ id: string }> = async ( + _request: NextRequest, + { params }: { params: Promise<{ id: string }> }, +): Promise => { + const { id } = await params; + const propertyId = Number(id); + if (!propertyId) return NextResponse.json({ error: 'Invalid property id' }, { status: 400 }); + const row = await getPropertyById(propertyId); + if (!row) return NextResponse.json({ error: 'Property not found' }, { status: 404 }); + return NextResponse.json({ default_crawl_preset: row.default_crawl_preset }); +}; + +export const PUT: ApiRouteHandlerWithParams<{ id: string }> = async ( + request: NextRequest, + { params }: { params: Promise<{ id: string }> }, +): Promise => { + const denied = forbiddenIfNotLocal(request); + if (denied) return denied; + const { id } = await params; + const propertyId = Number(id); + if (!propertyId) return NextResponse.json({ error: 'Invalid property id' }, { status: 400 }); + + let body: { preset?: string }; + try { + body = await request.json(); + } catch { + return NextResponse.json({ error: 'Invalid JSON' }, { status: 400 }); + } + const preset = String(body.preset || '').trim(); + if (preset && !isCrawlPresetId(preset)) { + return NextResponse.json({ error: 'Invalid crawl preset' }, { status: 400 }); + } + await setPropertyCrawlPreset(propertyId, preset || null); + return NextResponse.json({ ok: true, default_crawl_preset: preset || null }); +}; diff --git a/web/app/api/properties/resolve/route.ts b/web/app/api/properties/resolve/route.ts index f1d43e21..4128f201 100644 --- a/web/app/api/properties/resolve/route.ts +++ b/web/app/api/properties/resolve/route.ts @@ -2,6 +2,7 @@ import { NextResponse, type NextRequest } from 'next/server'; import { forbiddenIfNotLocal } from '@/server/localOnly'; import { canonicalDomainFromStartUrl, + getPropertyByDomain, resolvePropertyIdFromStartUrl, } from '@/server/propertiesDb'; import type { ApiRouteHandler } from '@/types/api'; @@ -20,7 +21,12 @@ export const GET: ApiRouteHandler = async (request: NextRequest): Promise => { + const sp = request.nextUrl.searchParams; + const propertyId = Number(sp.get('propertyId') || '0') || null; + const domain = sp.get('domain')?.trim() || null; + const limit = Number(sp.get('limit') || '20') || 20; + + try { + const history = await listAuditHistory(propertyId, domain, limit); + return NextResponse.json({ history }); + } catch (e) { + const msg = e instanceof Error ? e.message : String(e); + return NextResponse.json({ error: msg, history: [] }, { status: 500 }); + } +}; diff --git a/web/app/api/schedule/check/route.ts b/web/app/api/schedule/check/route.ts new file mode 100644 index 00000000..a83ae899 --- /dev/null +++ b/web/app/api/schedule/check/route.ts @@ -0,0 +1,52 @@ +import { NextResponse, type NextRequest } from 'next/server'; +import { forbiddenIfNotLocal } from '@/server/localOnly'; +import { spawn } from 'child_process'; +import path from 'path'; +import type { ApiRouteHandler } from '@/types/api'; + +export const runtime = 'nodejs'; + +/** + * POST /api/schedule/check — run due scheduled audits (calls Python schedule_runner). + */ +export const POST: ApiRouteHandler = async (request: NextRequest): Promise => { + const denied = forbiddenIfNotLocal(request); + if (denied) return denied; + + const repoRoot = process.env.WEBSITE_PROFILING_ROOT || path.resolve(process.cwd(), '..'); + return new Promise((resolve) => { + const proc = spawn('python3', ['-m', 'src.website_profiling.tools.schedule_runner'], { + cwd: repoRoot, + shell: false, + }); + let out = ''; + proc.stdout?.on('data', (c) => { out += c.toString(); }); + proc.stderr?.on('data', (c) => { out += c.toString(); }); + proc.on('close', (code) => { + const staleProc = spawn( + 'python3', + [ + '-c', + 'from website_profiling.tools.schedule_runner import run_gsc_links_staleness_alerts; import json; print(json.dumps(run_gsc_links_staleness_alerts()))', + ], + { cwd: repoRoot, shell: false }, + ); + let staleOut = ''; + staleProc.stdout?.on('data', (c) => { staleOut += c.toString(); }); + staleProc.on('close', () => { + let stale: unknown[] = []; + try { + stale = JSON.parse(staleOut.trim() || '[]'); + } catch { + stale = []; + } + resolve( + NextResponse.json( + { ok: code === 0, output: out.trim(), gscLinksStale: stale }, + { status: code === 0 ? 200 : 500 }, + ), + ); + }); + }); + }); +}; diff --git a/web/app/client-providers.tsx b/web/app/client-providers.tsx index 61ba579b..4562ecbb 100644 --- a/web/app/client-providers.tsx +++ b/web/app/client-providers.tsx @@ -4,6 +4,7 @@ import { Suspense, type ReactNode } from 'react'; import '@/patchConsole'; import { ThemeProvider } from '@/context/ThemeProvider'; import { PipelineProvider } from '@/context/PipelineContext'; +import { SessionProvider } from '@/context/SessionContext'; import PipelineRunnerFab from '@/components/pipeline/PipelineRunnerFab'; function LoadingFallback() { @@ -17,12 +18,14 @@ function LoadingFallback() { export default function ClientProviders({ children }: { children: ReactNode }): ReactNode { return ( - }> - - {children} - - - + + }> + + {children} + + + + ); } diff --git a/web/app/indexation/page.tsx b/web/app/indexation/page.tsx new file mode 100644 index 00000000..a5556f05 --- /dev/null +++ b/web/app/indexation/page.tsx @@ -0,0 +1,7 @@ +'use client'; + +import ReportShell from '@/ReportShell'; + +export default function IndexationPage() { + return ; +} diff --git a/web/app/log-analyzer/page.tsx b/web/app/log-analyzer/page.tsx new file mode 100644 index 00000000..885cea98 --- /dev/null +++ b/web/app/log-analyzer/page.tsx @@ -0,0 +1,7 @@ +'use client'; + +import ReportShell from '@/ReportShell'; + +export default function LogAnalyzerPage() { + return ; +} diff --git a/web/src/ReportShell.tsx b/web/src/ReportShell.tsx index 9fc03d38..6d592fce 100644 --- a/web/src/ReportShell.tsx +++ b/web/src/ReportShell.tsx @@ -24,7 +24,10 @@ import { Key, ArrowLeftRight, FileDown, + FileSearch, + Terminal, } from 'lucide-react'; +import { UrlInspectorProvider } from './context/UrlInspectorContext'; import AppShell from './components/AppShell'; import { useReport } from './context/useReport'; import { strings } from './lib/strings'; @@ -63,10 +66,12 @@ const ContentAnalytics = dynamic(() => import('./views/ContentAnalytics'), { loa const TechStack = dynamic(() => import('./views/TechStack'), { loading: () => viewLoading() }); const Gallery = dynamic(() => import('./views/Gallery'), { loading: () => viewLoading() }); const SearchPerformance = dynamic(() => import('./views/SearchPerformance'), { loading: () => viewLoading() }); +const Indexation = dynamic(() => import('./views/Indexation'), { loading: () => viewLoading() }); const Backlinks = dynamic(() => import('./views/Backlinks'), { loading: () => viewLoading() }); const Traffic = dynamic(() => import('./views/Traffic'), { loading: () => viewLoading() }); const KeywordsExplorer = dynamic(() => import('./views/KeywordsExplorer'), { loading: () => viewLoading() }); const ExportReport = dynamic(() => import('./views/ExportReport'), { loading: () => viewLoading() }); +const LogAnalyzer = dynamic(() => import('./views/LogAnalyzer'), { loading: () => viewLoading() }); interface ReportShellReportContext { data: ReportPayload | null; @@ -102,6 +107,7 @@ const VIEW_CONFIG: ViewConfigEntry[] = [ { id: 'overview', component: Overview as ComponentType, icon: LayoutDashboard }, { id: 'compare', component: CompareReports as ComponentType, icon: ArrowLeftRight }, { id: 'export', component: ExportReport as ComponentType, icon: FileDown }, + { id: 'log-analyzer', component: LogAnalyzer as ComponentType, icon: Terminal }, { id: 'issues', component: Issues as ComponentType, icon: AlertOctagon }, { id: 'links', component: Links as ComponentType, icon: LinkIcon }, { id: 'site-structure', component: SiteStructure as ComponentType, icon: FolderTree }, @@ -115,6 +121,7 @@ const VIEW_CONFIG: ViewConfigEntry[] = [ { id: 'network', component: Network as ComponentType, icon: Share2 }, { id: 'gallery', component: Gallery as ComponentType, icon: Images }, { id: 'search-performance', component: SearchPerformance as ComponentType, icon: TrendingUp }, + { id: 'indexation', component: Indexation as ComponentType, icon: FileSearch }, { id: 'backlinks', component: Backlinks as ComponentType, icon: Link2 }, { id: 'traffic', component: Traffic as ComponentType, icon: BarChart2 }, { id: 'keywords-explorer', component: KeywordsExplorer as ComponentType, icon: Key }, @@ -264,10 +271,6 @@ function RoutedShell({ slug }: SlugProps): ReactNode { ); } -export default function ReportShell({ slug }: SlugProps): ReactNode { - return ; -} - /** Wraps children with ReportProvider (db + domain from URL). */ export function ReportAppClient({ children }: { children: ReactNode }): ReactNode { const searchParams = useSearchParams(); @@ -276,7 +279,17 @@ export function ReportAppClient({ children }: { children: ReactNode }): ReactNod return ( - {children} + + {children} + ); } + +export default function ReportShell({ slug }: SlugProps): ReactNode { + return ( + + + + ); +} diff --git a/web/src/components/AppShell.tsx b/web/src/components/AppShell.tsx index f1602050..ae8ae5b6 100644 --- a/web/src/components/AppShell.tsx +++ b/web/src/components/AppShell.tsx @@ -15,6 +15,7 @@ import IntegrationsModal from '@/components/IntegrationsModal'; import { Badge, ReportSelector } from '@/components'; import ThemeToggle from '@/components/ThemeToggle'; import { useReport } from '@/context/useReport'; +import { useSession } from '@/context/SessionContext'; import { strings, format } from '@/lib/strings'; import { canonicalDomainFromPayload } from '@/lib/domainSlug'; import { OPEN_INTEGRATIONS } from '@/lib/pipelineJobEvents'; @@ -64,6 +65,7 @@ export default function AppShell({ const [integrationsOpen, setIntegrationsOpen] = useState(false); const [integrationsToast, setIntegrationsToast] = useState(null); const { data, startUrlByRunId } = useReport(); + const { readonly: sessionReadonly } = useSession(); const trailing = searchParams.toString() ? `?${searchParams.toString()}` : ''; const closeSidebar = () => setSidebarOpen(false); @@ -231,6 +233,14 @@ export default function AppShell({ ) : null}
    + {sessionReadonly ? ( +
    + {strings.app.readonlyBanner} +
    + ) : null} {showSidebar ? (
    - + ); +} diff --git a/web/src/components/UrlInspectorDrawer.tsx b/web/src/components/UrlInspectorDrawer.tsx new file mode 100644 index 00000000..caf702fc --- /dev/null +++ b/web/src/components/UrlInspectorDrawer.tsx @@ -0,0 +1,95 @@ +'use client'; + +import { useMemo } from 'react'; +import { X } from 'lucide-react'; +import { useReport } from '@/context/useReport'; +import InspectorTabs from '@/components/links/InspectorTabs'; +import type { InspectorDetails, LinkDetail, ReportLink } from '@/types/report'; + +interface UrlInspectorDrawerProps { + url: string | null; + onClose: () => void; +} + +function buildInspectorDetails(data: NonNullable['data']>, url: string, links: ReportLink[]): InspectorDetails { + const issues = data.issues || {}; + const broken = (issues.broken || []).filter((i) => i.url === url).map((i) => ({ url: i.url ?? url, status: i.status })); + const redirects = (issues.redirects || []).filter((i) => i.url === url).map((i) => ({ + url: i.url ?? url, + status: i.status, + final_url: typeof i.final_url === 'string' ? i.final_url : undefined, + })); + const seoIssues = (issues.seo || []).filter((i) => i.url === url).map((i) => ({ + url: i.url ?? url, + type: i.type, + message: i.message, + })); + const categoryIssues: InspectorDetails['categoryIssues'] = []; + (data.categories || []).forEach((cat) => { + (cat.issues || []).forEach((iss) => { + if (iss.url === url) { + categoryIssues.push({ + category: cat.name || cat.id || '', + url: iss.url, + priority: iss.priority, + message: iss.message, + recommendation: iss.recommendation, + }); + } + }); + }); + const securityFindings = (data.security_findings || []) + .filter((f) => f.url === url) + .map((f) => ({ + url: f.url, + severity: f.severity, + message: f.message, + recommendation: f.recommendation, + })); + return { + broken, + redirects, + seoIssues, + categoryIssues, + contentFlags: [], + securityFindings, + browserIssues: [], + recommendations: categoryIssues.map((i) => i.recommendation).filter(Boolean) as string[], + }; +} + +export default function UrlInspectorDrawer({ url, onClose }: UrlInspectorDrawerProps) { + const { data } = useReport(); + const links = (data?.links || []) as ReportLink[]; + + const link = useMemo((): LinkDetail | null => { + if (!url || !data) return null; + const found = links.find((l) => l.url === url); + if (found) return found as LinkDetail; + return { url, status: '', title: '' } as LinkDetail; + }, [url, data, links]); + + const inspectorDetails = useMemo(() => { + if (!url || !data) return null; + return buildInspectorDetails(data, url, links); + }, [url, data, links]); + + if (!url || !link) return null; + + return ( +
    + +
    +
    + +
    +
    + + ); +} diff --git a/web/src/components/backlinks/CompetitorGapImport.tsx b/web/src/components/backlinks/CompetitorGapImport.tsx new file mode 100644 index 00000000..f9080673 --- /dev/null +++ b/web/src/components/backlinks/CompetitorGapImport.tsx @@ -0,0 +1,117 @@ +'use client'; + +import { useRef, useState, useCallback } from 'react'; +import { Upload, Loader2 } from 'lucide-react'; +import { apiUrl } from '@/lib/publicBase'; +import { strings, format } from '@/lib/strings'; +import { Button } from '@/components'; +import { useReadOnlySession } from '@/hooks/useReadOnlySession'; +import type { GscTopLinkingSiteRow } from '@/types/components'; + +interface GscLinksLike { + top_linking_sites?: GscTopLinkingSiteRow[]; +} + +interface GapResult { + competitor?: string; + gap_count?: number; + gap_domains?: string[]; + competitor_referring_count?: number; + provenance?: string; +} + +export interface CompetitorGapImportProps { + gscLinks?: GscLinksLike; +} + +export default function CompetitorGapImport({ gscLinks }: CompetitorGapImportProps) { + const s = strings.views.backlinks.competitorImport; + const { readOnly } = useReadOnlySession(); + const fileRef = useRef(null); + const [competitor, setCompetitor] = useState(''); + const [loading, setLoading] = useState(false); + const [gap, setGap] = useState(null); + const [error, setError] = useState(null); + + const ourDomains = (gscLinks?.top_linking_sites || []) + .map((row: GscTopLinkingSiteRow) => String(row.site || '').trim().toLowerCase()) + .filter(Boolean); + + const handleFile = useCallback( + async (file: File) => { + if (readOnly) return; + const comp = competitor.trim(); + if (!comp) { + setError(s.competitorRequired); + return; + } + setLoading(true); + setError(null); + setGap(null); + try { + const csvText = await file.text(); + const res = await fetch(apiUrl('/backlinks/competitor-import'), { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ competitor: comp, csvText, ourDomains }), + }); + const payload = await res.json(); + if (!res.ok) throw new Error(payload.error || s.failed); + setGap((payload.gap || null) as GapResult | null); + } catch (e) { + setError(e instanceof Error ? e.message : s.failed); + } finally { + setLoading(false); + } + }, + [competitor, ourDomains, readOnly, s.competitorRequired, s.failed], + ); + + return ( +
    +

    {s.title}

    +

    {s.hint}

    + setCompetitor(e.target.value)} + placeholder={s.competitorPlaceholder} + disabled={readOnly} + className="w-full max-w-md rounded-lg border border-default bg-brand-900 px-3 py-2 text-sm text-foreground disabled:opacity-60" + /> + { + const file = e.target.files?.[0]; + if (file) void handleFile(file); + }} + /> + + {error ?

    {error}

    : null} + {gap?.gap_count != null && gap.gap_count > 0 ? ( +
    +

    + {format(s.gapSummary, { + count: gap.gap_count, + competitor: gap.competitor || competitor, + })} +

    +
      + {(gap.gap_domains || []).slice(0, 20).map((d) => ( +
    • {d}
    • + ))} +
    +
    + ) : gap ? ( +

    {s.noGap}

    + ) : null} +
    + ); +} diff --git a/web/src/components/backlinks/ThirdPartyLinksImport.tsx b/web/src/components/backlinks/ThirdPartyLinksImport.tsx new file mode 100644 index 00000000..db6c4176 --- /dev/null +++ b/web/src/components/backlinks/ThirdPartyLinksImport.tsx @@ -0,0 +1,154 @@ +'use client'; + +import { useRef, useState, useCallback } from 'react'; +import { Upload, Loader2 } from 'lucide-react'; +import { apiUrl } from '@/lib/publicBase'; +import { strings, format } from '@/lib/strings'; +import { Button } from '@/components'; +import { useReadOnlySession } from '@/hooks/useReadOnlySession'; +import { useOptionalPipeline } from '@/context/PipelineContext'; +import type { GscTopLinkingSiteRow } from '@/types/components'; + +type ProviderId = 'moz' | 'majestic'; + +interface ThirdPartyOverlay { + provider?: string; + provenance?: string; + referring_domain_count?: number; + domains_not_in_gsc_count?: number; + domains_not_in_gsc_sample?: string[]; + gsc_domains_not_in_third_party_count?: number; + imported_at?: string; +} + +interface GscLinksLike { + top_linking_sites?: GscTopLinkingSiteRow[]; + third_party_overlays?: ThirdPartyOverlay[]; +} + +export interface ThirdPartyLinksImportProps { + gscLinks?: GscLinksLike; + onImported?: () => void; +} + +export default function ThirdPartyLinksImport({ gscLinks, onImported }: ThirdPartyLinksImportProps) { + const s = strings.views.backlinks.thirdPartyImport; + const pipeline = useOptionalPipeline(); + const propertyId = Number(pipeline?.configState.active_property_id || 0); + const { readOnly } = useReadOnlySession(); + const fileRef = useRef(null); + const [provider, setProvider] = useState('moz'); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + const [lastOverlay, setLastOverlay] = useState(null); + + const ourDomains = (gscLinks?.top_linking_sites || []) + .map((row) => String(row.site || '').trim().toLowerCase()) + .filter(Boolean); + + const savedOverlays = gscLinks?.third_party_overlays || []; + + const handleFile = useCallback( + async (file: File) => { + if (readOnly || !propertyId) return; + setLoading(true); + setError(null); + try { + const csvText = await file.text(); + const res = await fetch(apiUrl('/backlinks/third-party-import'), { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ propertyId, provider, csvText, ourDomains }), + }); + const payload = await res.json(); + if (!res.ok) throw new Error(payload.error || s.failed); + const overlay = (payload.overlay || null) as ThirdPartyOverlay | null; + setLastOverlay(overlay); + onImported?.(); + } catch (e) { + setError(e instanceof Error ? e.message : s.failed); + } finally { + setLoading(false); + } + }, + [propertyId, provider, ourDomains, readOnly, onImported, s.failed], + ); + + if (!propertyId) { + return ( +

    {s.noProperty}

    + ); + } + + const displayOverlays = lastOverlay + ? [...savedOverlays.filter((o) => o.provider !== lastOverlay.provider), lastOverlay] + : savedOverlays; + + return ( +
    +

    {s.title}

    +

    {s.hint}

    +
    + {(['moz', 'majestic'] as const).map((id) => ( + + ))} +
    + { + const file = e.target.files?.[0]; + if (file) void handleFile(file); + }} + /> + + {error ?

    {error}

    : null} + {displayOverlays.length > 0 ? ( +
    + {displayOverlays.map((overlay) => ( +
    +

    + {(overlay.provider || 'unknown').toUpperCase()} — {overlay.provenance || s.estimated} +

    +

    + {format(s.summary, { + count: overlay.referring_domain_count ?? 0, + gaps: overlay.domains_not_in_gsc_count ?? 0, + })} +

    + {(overlay.domains_not_in_gsc_sample || []).length > 0 ? ( +
      + {(overlay.domains_not_in_gsc_sample || []).slice(0, 15).map((d) => ( +
    • {d}
    • + ))} +
    + ) : null} +
    + ))} +
    + ) : null} +
    + ); +} diff --git a/web/src/components/integrations/BingWebmasterSection.tsx b/web/src/components/integrations/BingWebmasterSection.tsx new file mode 100644 index 00000000..a1c6f3f8 --- /dev/null +++ b/web/src/components/integrations/BingWebmasterSection.tsx @@ -0,0 +1,56 @@ +'use client'; + +import { useState, useCallback } from 'react'; +import { Globe, Loader2 } from 'lucide-react'; +import { apiUrl } from '@/lib/publicBase'; +import { strings } from '@/lib/strings'; +import { Button } from '@/components'; +import { useReadOnlySession } from '@/hooks/useReadOnlySession'; + +export default function BingWebmasterSection() { + const s = strings.pipelineRunner.bingWebmaster; + const { readOnly } = useReadOnlySession(); + const [loading, setLoading] = useState(false); + const [result, setResult] = useState | null>(null); + const [error, setError] = useState(null); + + const handleSync = useCallback(async () => { + if (readOnly) return; + setLoading(true); + setError(null); + setResult(null); + try { + const res = await fetch(apiUrl('/integrations/bing/sync'), { method: 'POST' }); + const payload = await res.json(); + if (!res.ok) throw new Error(payload.error || s.failed); + setResult(payload); + } catch (e) { + setError(e instanceof Error ? e.message : s.failed); + } finally { + setLoading(false); + } + }, [readOnly, s.failed]); + + return ( +
    +
    +

    + + {s.title} +

    +

    {s.hint}

    +
    + + {error ?

    {error}

    : null} + {result?.ok ? ( +

    + {s.success} + {result.note ? ` ${String(result.note)}` : ''} +

    + ) : null} +
    + ); +} diff --git a/web/src/components/integrations/PropertyOpsSection.tsx b/web/src/components/integrations/PropertyOpsSection.tsx new file mode 100644 index 00000000..ba0c1fa6 --- /dev/null +++ b/web/src/components/integrations/PropertyOpsSection.tsx @@ -0,0 +1,171 @@ +'use client'; + +import { useCallback, useEffect, useState } from 'react'; +import { Bell, CalendarClock, Loader2 } from 'lucide-react'; +import { apiUrl } from '@/lib/publicBase'; +import { strings, format } from '@/lib/strings'; +import { Button } from '@/components'; +import { useReadOnlySession } from '@/hooks/useReadOnlySession'; + +export interface PropertyOpsSectionProps { + propertyId: number | null; +} + +export default function PropertyOpsSection({ propertyId }: PropertyOpsSectionProps) { + const s = strings.pipelineRunner.propertyOps; + const { readOnly } = useReadOnlySession(); + const [scheduleCron, setScheduleCron] = useState(''); + const [alertWebhookUrl, setAlertWebhookUrl] = useState(''); + const [alertEmail, setAlertEmail] = useState(''); + const [loading, setLoading] = useState(false); + const [saving, setSaving] = useState(false); + const [message, setMessage] = useState(null); + const [gscLinksStale, setGscLinksStale] = useState(null); + + useEffect(() => { + if (propertyId == null) return undefined; + let cancelled = false; + setLoading(true); + void fetch(apiUrl(`/properties/${propertyId}/ops`)) + .then((res) => (res.ok ? res.json() : null)) + .then((data) => { + if (cancelled || !data) return; + setScheduleCron(String(data.schedule_cron || '')); + setAlertWebhookUrl(String(data.alert_webhook_url || '')); + setAlertEmail(String(data.alert_email || '')); + }) + .catch(() => {}) + .finally(() => { + if (!cancelled) setLoading(false); + }); + return () => { + cancelled = true; + }; + }, [propertyId]); + + useEffect(() => { + if (propertyId == null) return undefined; + let cancelled = false; + void fetch(apiUrl(`/properties/${propertyId}/google/links/status`)) + .then((res) => (res.ok ? res.json() : null)) + .then((status) => { + if (cancelled || !status) return; + if (!status.hasData) { + setGscLinksStale(s.gscLinksMissing); + return; + } + const last = status.lastImportedAt ? new Date(String(status.lastImportedAt)) : null; + if (!last || Number.isNaN(last.getTime())) return; + const ageDays = Math.floor((Date.now() - last.getTime()) / (1000 * 60 * 60 * 24)); + if (ageDays >= 7) { + setGscLinksStale(format(s.gscLinksStale, { days: ageDays })); + } else { + setGscLinksStale(null); + } + }) + .catch(() => {}); + return () => { + cancelled = true; + }; + }, [propertyId, s.gscLinksMissing, s.gscLinksStale]); + + const handleSave = useCallback(async () => { + if (propertyId == null || readOnly) return; + setSaving(true); + setMessage(null); + try { + const res = await fetch(apiUrl(`/properties/${propertyId}/ops`), { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + scheduleCron: scheduleCron.trim() || null, + alertWebhookUrl: alertWebhookUrl.trim() || null, + alertEmail: alertEmail.trim() || null, + }), + }); + if (!res.ok) throw new Error(s.saveFailed); + setMessage(s.saved); + } catch { + setMessage(s.saveFailed); + } finally { + setSaving(false); + } + }, [propertyId, readOnly, scheduleCron, alertWebhookUrl, alertEmail, s.saved, s.saveFailed]); + + if (propertyId == null) return null; + + return ( +
    +
    +

    + + {s.title} +

    +

    {s.hint}

    + {gscLinksStale ? ( +

    {gscLinksStale}

    + ) : null} +
    + {loading ? ( +

    + + {s.loading} +

    + ) : ( + <> + + + +
    + + {message ? ( + + {message} + + ) : null} +
    +

    {s.cronEndpointsHint}

    + + )} +
    + ); +} diff --git a/web/src/components/issues/IssueAiFixButton.tsx b/web/src/components/issues/IssueAiFixButton.tsx new file mode 100644 index 00000000..9061f72e --- /dev/null +++ b/web/src/components/issues/IssueAiFixButton.tsx @@ -0,0 +1,79 @@ +'use client'; + +import { useState, useCallback } from 'react'; +import { Loader2, Sparkles } from 'lucide-react'; +import { apiUrl } from '@/lib/publicBase'; +import { strings } from '@/lib/strings'; +import type { ReportIssue } from '@/types'; +import { useReadOnlySession } from '@/hooks/useReadOnlySession'; + +export interface IssueAiFixButtonProps { + issue: ReportIssue; + category: string; +} + +export default function IssueAiFixButton({ issue, category }: IssueAiFixButtonProps) { + const s = strings.views.issues.aiFix; + const { readOnly } = useReadOnlySession(); + const [loading, setLoading] = useState(false); + const [text, setText] = useState( + typeof issue.llm_recommendation === 'string' ? issue.llm_recommendation : null, + ); + const [error, setError] = useState(null); + + const handleClick = useCallback(async () => { + if (readOnly) return; + setLoading(true); + setError(null); + try { + const res = await fetch(apiUrl('/issues/fix-suggestion'), { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + message: issue.message, + url: issue.url, + priority: issue.priority, + category, + recommendation: issue.recommendation, + type: issue.type || issue.finding_type, + refresh: !!text, + }), + }); + const payload = await res.json(); + if (!res.ok) throw new Error(payload.error || s.failed); + const fix = payload.fix as { fix?: string } | undefined; + setText(String(fix?.fix || payload.fix || '').trim() || s.empty); + } catch (e) { + setError(e instanceof Error ? e.message : s.failed); + } finally { + setLoading(false); + } + }, [issue, category, text, readOnly, s.failed, s.empty]); + + return ( +
    + {!readOnly ? ( + + ) : null} + {error ?

    {error}

    : null} + {text ? ( +

    + {s.label}: + {text} +

    + ) : null} +
    + ); +} diff --git a/web/src/components/issues/IssueTaskBoard.tsx b/web/src/components/issues/IssueTaskBoard.tsx new file mode 100644 index 00000000..edae5a9b --- /dev/null +++ b/web/src/components/issues/IssueTaskBoard.tsx @@ -0,0 +1,163 @@ +'use client'; + +import { useCallback, useEffect, useMemo, useState } from 'react'; +import { apiUrl } from '@/lib/publicBase'; +import { strings } from '@/lib/strings'; +import type { ReportIssue } from '@/types'; +import UrlInspectorButton from '@/components/UrlInspectorButton'; +import IssueAiFixButton from '@/components/issues/IssueAiFixButton'; +import { useReadOnlySession } from '@/hooks/useReadOnlySession'; + +type WorkflowStatus = 'open' | 'in_progress' | 'fixed' | 'ignored'; + +interface IssueStatusRow { + issueFingerprint: string; + message: string; + url?: string | null; + priority?: string | null; + categoryId?: string | null; + status: WorkflowStatus; + assignee?: string | null; + note?: string | null; +} + +interface IssueTaskBoardProps { + propertyId: number | null; + reportId: number | null; + issues: Array<{ category: string; issue: ReportIssue; clicks?: number }>; +} + +const STATUS_OPTIONS: WorkflowStatus[] = ['open', 'in_progress', 'fixed', 'ignored']; + +export default function IssueTaskBoard({ propertyId, reportId, issues }: IssueTaskBoardProps) { + const vi = strings.views.issues; + const { readOnly } = useReadOnlySession(); + const [statusByFingerprint, setStatusByFingerprint] = useState>({}); + const [loading, setLoading] = useState(false); + + useEffect(() => { + if (!propertyId) return; + let cancelled = false; + setLoading(true); + void fetch(apiUrl(`/issues/status?propertyId=${propertyId}`)) + .then((r) => r.json()) + .then((data) => { + if (cancelled) return; + const map: Record = {}; + for (const row of (data.issues || []) as IssueStatusRow[]) { + map[row.issueFingerprint] = row; + } + setStatusByFingerprint(map); + }) + .catch(() => { + if (!cancelled) setStatusByFingerprint({}); + }) + .finally(() => { + if (!cancelled) setLoading(false); + }); + return () => { + cancelled = true; + }; + }, [propertyId]); + + const sorted = useMemo( + () => [...issues].sort((a, b) => (b.clicks || 0) - (a.clicks || 0)), + [issues], + ); + + const updateStatus = useCallback( + async (item: { category: string; issue: ReportIssue }, status: WorkflowStatus) => { + if (!propertyId || readOnly) return; + const message = String(item.issue.message || '').trim(); + if (!message) return; + const res = await fetch(apiUrl('/issues/status'), { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + propertyId, + reportId, + message, + url: item.issue.url, + priority: item.issue.priority, + categoryId: item.category, + status, + }), + }); + const data = await res.json().catch(() => ({})); + if (res.ok && data.issue) { + const row = data.issue as IssueStatusRow; + setStatusByFingerprint((prev) => ({ ...prev, [row.issueFingerprint]: row })); + } + }, + [propertyId, reportId, readOnly], + ); + + if (!propertyId) { + return ( +

    + {vi.taskBoardNoProperty || 'Link a property to track issue workflow.'} +

    + ); + } + + if (loading) { + return

    {strings.app.loading}

    ; + } + + return ( +
    +

    + {vi.taskBoardHint || 'Sorted by Search Console clicks to affected URLs when available.'} +

    + {sorted.map((item, i) => { + const msg = item.issue.message || ''; + const fp = Object.values(statusByFingerprint).find( + (r) => r.message === msg && (r.url || '') === (item.issue.url || ''), + )?.issueFingerprint; + const current = fp ? statusByFingerprint[fp]?.status : 'open'; + return ( +
    +
    +

    {msg}

    + {item.issue.url ? ( +
    + {item.issue.url} + +
    + ) : null} + {(item.clicks ?? 0) > 0 && ( +

    + GSC clicks: {item.clicks!.toLocaleString()} +

    + )} + {(item.issue.llm_recommendation || item.issue.recommendation) ? ( +

    + {item.issue.llm_recommendation || item.issue.recommendation} +

    + ) : null} +
    + +
    +
    + +
    + ); + })} +
    + ); +} diff --git a/web/src/components/keywordsExplorer/ContentBriefButton.tsx b/web/src/components/keywordsExplorer/ContentBriefButton.tsx new file mode 100644 index 00000000..b35399f9 --- /dev/null +++ b/web/src/components/keywordsExplorer/ContentBriefButton.tsx @@ -0,0 +1,115 @@ +'use client'; + +import { useState, useCallback } from 'react'; +import { FileText, Loader2, X } from 'lucide-react'; +import { apiUrl } from '@/lib/publicBase'; +import { strings } from '@/lib/strings'; +import type { KeywordRow } from '@/types/components'; +import { useReadOnlySession } from '@/hooks/useReadOnlySession'; + +interface ContentBriefResult { + keyword?: string; + summary?: string; + provenance?: string; +} + +export interface ContentBriefButtonProps { + keyword: string; + clusterRows: KeywordRow[]; +} + +export default function ContentBriefButton({ keyword, clusterRows }: ContentBriefButtonProps) { + const s = strings.views.keywordsExplorer.contentBrief; + const { readOnly } = useReadOnlySession(); + const [open, setOpen] = useState(false); + const [loading, setLoading] = useState(false); + const [brief, setBrief] = useState(null); + const [error, setError] = useState(null); + + const handleOpen = useCallback(async () => { + if (readOnly) return; + setOpen(true); + setLoading(true); + setError(null); + setBrief(null); + try { + const res = await fetch(apiUrl('/keywords/content-brief'), { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + keyword, + rows: clusterRows.slice(0, 20), + }), + }); + const payload = await res.json(); + if (!res.ok) throw new Error(payload.error || s.failed); + setBrief((payload.brief || null) as ContentBriefResult | null); + } catch (e) { + setError(e instanceof Error ? e.message : s.failed); + } finally { + setLoading(false); + } + }, [keyword, clusterRows, readOnly, s.failed]); + + if (readOnly) return null; + + return ( + <> + + {open ? ( +
    +
    +
    +

    + {s.modalTitle} +

    + +
    +
    +

    “{keyword}”

    + {loading ? ( +

    + + {s.loading} +

    + ) : error ? ( +

    {error}

    + ) : brief?.summary ? ( + <> +
    +                    {brief.summary}
    +                  
    + {brief.provenance ? ( +

    {s.provenance}: {brief.provenance}

    + ) : null} + + ) : ( +

    {s.empty}

    + )} +
    +
    +
    + ) : null} + + ); +} diff --git a/web/src/components/keywordsExplorer/KeywordPanels.tsx b/web/src/components/keywordsExplorer/KeywordPanels.tsx index 23a1e9c8..04049bc4 100644 --- a/web/src/components/keywordsExplorer/KeywordPanels.tsx +++ b/web/src/components/keywordsExplorer/KeywordPanels.tsx @@ -9,6 +9,7 @@ import Link from 'next/link'; import { useSearchParams } from 'next/navigation'; import { apiUrl } from '../../lib/publicBase'; import { buildLinksInspectHref } from '../../lib/reportNav'; +import UrlInspectorButton from '@/components/UrlInspectorButton'; import { strings, format } from '../../lib/strings'; import { Card } from '../index'; import CopyBtn from '../links/CopyBtn'; @@ -25,6 +26,7 @@ import type { KeywordByPageResponse, KeywordExpandResult, KeywordRow, + QueryPageMisalignmentItem, } from '@/types/components'; import KeywordEmptyState from './KeywordEmptyState'; @@ -117,6 +119,7 @@ export function CannibalisationPanel({ items }: CannibalisationPanelProps) { {p.url} + {format(c.clicks, { n: p.clicks })} @@ -131,6 +134,85 @@ export function CannibalisationPanel({ items }: CannibalisationPanelProps) { ); } +interface QueryPageMisalignmentPanelProps { + items: QueryPageMisalignmentItem[]; +} + +export function QueryPageMisalignmentPanel({ items }: QueryPageMisalignmentPanelProps) { + const a = strings.views.keywordsExplorer.alignment; + const [search, setSearch] = useState(''); + + const filtered = useMemo(() => { + const q = search.trim().toLowerCase(); + let list = items ?? []; + if (q) { + list = list.filter( + (item) => + String(item.keyword || '').toLowerCase().includes(q) || + String(item.current_url || '').toLowerCase().includes(q) || + String(item.suggested_url || '').toLowerCase().includes(q), + ); + } + return [...list].sort((x, y) => (y.impressions || 0) - (x.impressions || 0)); + }, [items, search]); + + if (!items?.length) { + return ; + } + + return ( +
    +
    +

    + + {format(a.intro, { count: items.length })} +

    +
    +
    +
    + + setSearch(e.target.value)} + className="bg-transparent text-sm text-foreground placeholder-muted-foreground focus:outline-none w-full min-w-0" + /> +
    +
    +
    + {filtered.map((item, i) => ( + +

    “{item.keyword}”

    +

    + {format(a.metrics, { + impressions: (item.impressions || 0).toLocaleString(), + position: parseFloat(String(item.position || 0)).toFixed(1), + })} +

    +
    +
    + {a.currentUrl} + + {item.current_url} + + +
    +
    + {a.suggestedUrl} + + {item.suggested_url} + + +
    +
    +
    + ))} +
    +
    + ); +} + interface ByPagePanelProps { rows: KeywordRow[]; ke: typeof strings.views.keywordsExplorer; diff --git a/web/src/components/keywordsExplorer/KeywordTabBanner.tsx b/web/src/components/keywordsExplorer/KeywordTabBanner.tsx index 5501ea7f..7903036e 100644 --- a/web/src/components/keywordsExplorer/KeywordTabBanner.tsx +++ b/web/src/components/keywordsExplorer/KeywordTabBanner.tsx @@ -10,6 +10,8 @@ import { MousePointerClick, Split, Zap, + Target, + ArrowRightLeft, } from 'lucide-react'; import type { KeywordTabId } from './keywordTabMeta'; import { strings } from '../../lib/strings'; @@ -19,9 +21,11 @@ const TAB_ICONS: Record = { all: List, questions: HelpCircle, quickwins: Zap, + striking: Target, lostclicks: MousePointerClick, opportunities: Lightbulb, cannib: Split, + alignment: ArrowRightLeft, bypage: FileText, }; diff --git a/web/src/components/keywordsExplorer/KeywordTableColumns.tsx b/web/src/components/keywordsExplorer/KeywordTableColumns.tsx index 94da5c92..1b9def52 100644 --- a/web/src/components/keywordsExplorer/KeywordTableColumns.tsx +++ b/web/src/components/keywordsExplorer/KeywordTableColumns.tsx @@ -5,6 +5,8 @@ import { formatGscCtr } from '../../lib/gscMetrics'; import { INTENT_COLORS, SOURCE_CONFIG, difficultyColor } from './keywordTableUtils'; import type { KeywordHistoryRow } from '@/types/api'; import type { KeywordHistoryMap, KeywordRow, TableColumn } from '@/types/components'; +import UrlInspectorButton from '@/components/UrlInspectorButton'; +import ContentBriefButton from './ContentBriefButton'; function KwBadge({ label, colorClass }: { label: string; colorClass: string }) { return ( @@ -64,6 +66,7 @@ export function buildKeywordColumns( showTrend: boolean, historyByKeyword: KeywordHistoryMap, ke: { table: Record }, + allRows: KeywordRow[] = [], ): TableColumn[] { const cols: TableColumn[] = [ { @@ -130,6 +133,22 @@ export function buildKeywordColumns( }); } + const showSerp = allRows.some((r) => r.serp_estimated_competition != null); + if (showSerp) { + cols.push({ + key: 'serp_estimated_competition', + label: ke.table.serpCompetition, + render: (v) => + v != null && typeof v === 'number' ? ( + + {v} + + ) : ( + '—' + ), + }); + } + cols.push( { key: 'difficulty', @@ -215,5 +234,23 @@ export function buildKeywordColumns( ), }); + cols.push({ + key: '_brief', + label: ke.table.brief, + render: (_v, row) => { + const r = row as KeywordRow | undefined; + const kw = String(r?.keyword || '').trim(); + if (!kw) return null; + const cluster = allRows.filter((x) => String(x.keyword || '').trim() === kw); + return ; + }, + }); + + cols.push({ + key: '_inspect', + label: '', + render: (_v, row) => , + }); + return cols; } diff --git a/web/src/components/keywordsExplorer/keywordTabMeta.ts b/web/src/components/keywordsExplorer/keywordTabMeta.ts index c9b4519f..ad282ef8 100644 --- a/web/src/components/keywordsExplorer/keywordTabMeta.ts +++ b/web/src/components/keywordsExplorer/keywordTabMeta.ts @@ -5,6 +5,7 @@ export const KEYWORD_TABLE_TAB_IDS = [ 'all', 'questions', 'quickwins', + 'striking', 'lostclicks', 'opportunities', ] as const; @@ -15,6 +16,7 @@ export type KeywordTabId = | 'overview' | KeywordTableTabId | 'cannib' + | 'alignment' | 'bypage'; export function isTableTab(tab: KeywordTabId): tab is KeywordTableTabId { @@ -28,9 +30,11 @@ export function tabRowCount( counts: { questions: number; quickwins: number; + striking: number; lostclicks: number; opportunities: number; cannib: number; + alignment: number; pages: number; }, ): number | null { @@ -43,12 +47,16 @@ export function tabRowCount( return counts.questions || null; case 'quickwins': return counts.quickwins || null; + case 'striking': + return counts.striking || null; case 'lostclicks': return counts.lostclicks || null; case 'opportunities': return counts.opportunities || null; case 'cannib': return counts.cannib || null; + case 'alignment': + return counts.alignment || null; case 'bypage': return counts.pages || null; default: @@ -60,6 +68,8 @@ export function defaultSortForTab(tab: KeywordTableTabId): string { switch (tab) { case 'quickwins': return 'opportunity_clicks'; + case 'striking': + return 'gsc_impressions'; case 'lostclicks': return 'lost_clicks'; case 'questions': diff --git a/web/src/components/keywordsExplorer/keywordTableUtils.ts b/web/src/components/keywordsExplorer/keywordTableUtils.ts index dab76862..4c7b176a 100644 --- a/web/src/components/keywordsExplorer/keywordTableUtils.ts +++ b/web/src/components/keywordsExplorer/keywordTableUtils.ts @@ -145,6 +145,12 @@ export function filterRowsByTab( const pos = parseFloat(String(r.gsc_position || 0)); return pos >= 4 && pos <= 20 && (r.opportunity_clicks || 0) > 5; }); + case 'striking': + return rows.filter((r) => { + const pos = parseFloat(String(r.gsc_position || 0)); + const imp = parseFloat(String(r.gsc_impressions || 0)); + return pos >= 4 && pos <= 20 && imp >= 50; + }); case 'lostclicks': return rows.filter((r) => r.lost_clicks); case 'opportunities': diff --git a/web/src/components/links/explorer/LinksExplorerTableTab.tsx b/web/src/components/links/explorer/LinksExplorerTableTab.tsx index 2a6c9167..ad8a7f92 100644 --- a/web/src/components/links/explorer/LinksExplorerTableTab.tsx +++ b/web/src/components/links/explorer/LinksExplorerTableTab.tsx @@ -61,6 +61,7 @@ export function LinksExplorerTableTab({ }: LinksExplorerTableTabProps) { const vl = strings.views.links; const sj = strings.common; + const hasCustomExtract = links.some((l) => l.custom_extract); return ( @@ -117,6 +118,11 @@ export function LinksExplorerTableTab({ onSort={onToggleSort} className="hidden md:table-cell" /> + {hasCustomExtract ? ( + + {vl.thCustomExtract} + + ) : null} {vl.thJsErrors} @@ -196,6 +202,11 @@ export function LinksExplorerTableTab({ {(link.word_count ?? 0) > 0 ? (link.word_count ?? 0).toLocaleString() : sj.emDash} + {hasCustomExtract ? ( + + {link.custom_extract || sj.emDash} + + ) : null} {linkHasBrowserErrors(link) ? ( diff --git a/web/src/components/overview/OverviewSummaryTab.tsx b/web/src/components/overview/OverviewSummaryTab.tsx index 87f5c53e..7fa4f5f3 100644 --- a/web/src/components/overview/OverviewSummaryTab.tsx +++ b/web/src/components/overview/OverviewSummaryTab.tsx @@ -1,5 +1,6 @@ 'use client'; +import { useEffect, useState } from 'react'; import Link from 'next/link'; import { Globe, @@ -36,8 +37,44 @@ export interface OverviewSummaryTabProps { export function OverviewSummaryTab({ data, exportHref, compareHref, reportCount }: OverviewSummaryTabProps) { const vo = strings.views.overview; const sj = strings.common; + const [healthDelta, setHealthDelta] = useState(null); const s = data.summary || {}; + const healthScore = (data.categories || []) + .map((c) => Number(c?.score)) + .filter((n) => Number.isFinite(n)); + const currentHealth = + healthScore.length > 0 + ? Math.round(healthScore.reduce((a, b) => a + b, 0) / healthScore.length) + : null; + + useEffect(() => { + const domain = data.site_name || ''; + if (!domain) return; + void fetch(`/api/report/history?domain=${encodeURIComponent(domain)}&limit=2`) + .then((r) => r.json()) + .then((payload: { history?: Array<{ healthScore?: number | null }> }) => { + const hist = payload.history || []; + if (hist.length >= 2 && currentHealth != null && hist[1]?.healthScore != null) { + setHealthDelta(currentHealth - Number(hist[1].healthScore)); + } + }) + .catch(() => {}); + }, [data.site_name, currentHealth]); + + const execTopIssues = (data.executive_summary?.top_issues || []).slice(0, 5); + const execPriorities = (data.executive_summary?.priorities || []).filter(Boolean); + const execSource = data.executive_summary?.source; + const fallbackTopIssues = (data.categories || []) + .flatMap((cat) => + (cat.issues || []).map((iss) => ({ + ...iss, + category: cat.name || cat.id, + })), + ) + .filter((iss) => iss.priority === 'Critical' || iss.priority === 'High') + .slice(0, 3); + const topIssues = execTopIssues.length > 0 ? execTopIssues : fallbackTopIssues; const h1Zero = (data.seo_health && data.seo_health.h1_zero) || 0; const brokenCount = (s.count_4xx || 0) + (s.count_5xx || 0); const googleData = data.google; @@ -60,9 +97,73 @@ export function OverviewSummaryTab({ data, exportHref, compareHref, reportCount (data.semantic_keyword_clusters?.length ?? 0) > 0 || (data.ner_site_summary?.label_counts && Object.keys(data.ner_site_summary.label_counts).length > 0); + const execSummary = data.executive_summary?.summary; + const gscClicks = data.google?.gsc?.summary?.clicks; + return (
    + {(execSummary || currentHealth != null) && ( + + {currentHealth != null && ( +

    + Audit health: {currentHealth}/100 + {healthDelta != null && healthDelta !== 0 && ( + 0 ? ' text-emerald-600' : ' text-rose-600'}> + {' '} + ({healthDelta > 0 ? '+' : ''} + {healthDelta} vs prior run) + + )} +

    + )} + {execSummary ? ( + <> + {execSource === 'ai_insights' ? ( +

    + {vo.executiveAiLabel} +

    + ) : null} +

    {execSummary}

    + + ) : null} + {execPriorities.length > 0 ? ( +
      + {execPriorities.map((line, i) => ( +
    • {line}
    • + ))} +
    + ) : null} + {gscClicks != null && ( +

    + Search Console clicks ({data.report_meta?.google_date_range_days ?? 28}d): {Number(gscClicks).toLocaleString()} +

    + )} + {topIssues.length > 0 && ( +
    +

    + {vo.topTrafficIssues} +

    +
      + {topIssues.map((iss, i) => { + const row = iss as { message?: string; priority?: string; gsc_clicks?: number }; + const clicks = Number(row.gsc_clicks || 0); + return ( +
    • + [{row.priority}] {row.message} + {clicks > 0 ? ( + + ({clicks.toLocaleString()} {vo.clicksLabel}) + + ) : null} +
    • + ); + })} +
    +
    + )} +
    + )} {provenanceSources.length > 0 ? (
    {vo.dataSourcesLabel}: diff --git a/web/src/components/pipeline/CrawlAuthorizeCheckbox.tsx b/web/src/components/pipeline/CrawlAuthorizeCheckbox.tsx index 3edb4769..de3156ef 100644 --- a/web/src/components/pipeline/CrawlAuthorizeCheckbox.tsx +++ b/web/src/components/pipeline/CrawlAuthorizeCheckbox.tsx @@ -7,16 +7,19 @@ const c = strings.components.crawlAuthorize; export default function CrawlAuthorizeCheckbox({ checked, onChange, + disabled = false, }: { checked: boolean; onChange: (v: boolean) => void; + disabled?: boolean; }) { return ( -

    {vh.healthScoreLabel}

    -

    {group.healthScore}

    +
    + +

    {group.healthScore}

    +

    {iss.message || emDash}

    {iss.url && ( - - {iss.url} - - + )} -
    +
    {vi.fixRecommendation}
    -

    {iss.recommendation || emDash}

    +

    + {iss.llm_recommendation || iss.recommendation || emDash} +

    + {iss.llm_recommendation && iss.recommendation && iss.llm_recommendation !== iss.recommendation ? ( +

    + {vi.ruleRecommendation}: + {iss.recommendation} +

    + ) : null} +
    ); @@ -142,13 +158,25 @@ function CategorySection({ category, items, defaultOpen = false, vi, emDash }: C } export default function Issues({ searchQuery = '' }: ViewProps) { - const { data } = useReport(); + const { data, selectedReportId } = useReport(); + const pipeline = useOptionalPipeline(); + const propertyId = Number(pipeline?.configState.active_property_id || 0) || null; const vi = strings.views.issues; const sj = strings.common; const PRIORITY_ORDER = vi.priorityOrder; + const [issuesTab, setIssuesTab] = useState<'audit' | 'board'>('audit'); const [priorityFilter, setPriorityFilter] = useState(sj.all); const [categoryFilter, setCategoryFilter] = useState(sj.all); + const clicksByUrl = useMemo(() => { + const map = new Map(); + for (const row of data?.google?.gsc?.top_pages || []) { + const url = String(row.page || '').replace(/\/$/, ''); + if (url) map.set(url, Number(row.clicks) || 0); + } + return map; + }, [data?.google?.gsc?.top_pages]); + const q = (searchQuery || '').toLowerCase().trim(); const list = useMemo((): CategoryIssueItem[] => { @@ -229,11 +257,23 @@ export default function Issues({ searchQuery = '' }: ViewProps) { } filtered.sort((a, b) => { + const aClicks = clicksByUrl.get(String(a.issue.url || '').replace(/\/$/, '')) || 0; + const bClicks = clicksByUrl.get(String(b.issue.url || '').replace(/\/$/, '')) || 0; + if (bClicks !== aClicks) return bClicks - aClicks; const ao = (PRIORITY_CONFIG[(a.issue.priority || 'Medium') as PriorityKey] ?? PRIORITY_CONFIG.Medium).order; const bo = (PRIORITY_CONFIG[(b.issue.priority || 'Medium') as PriorityKey] ?? PRIORITY_CONFIG.Medium).order; return ao - bo; }); + const taskBoardIssues = useMemo( + () => + list.map((item) => ({ + ...item, + clicks: clicksByUrl.get(String(item.issue.url || '').replace(/\/$/, '')) || 0, + })), + [list, clicksByUrl], + ); + const grouped = filtered.reduce>((acc, item) => { const cat = item.category || sj.uncategorized; if (!acc[cat]) acc[cat] = []; @@ -271,7 +311,26 @@ export default function Issues({ searchQuery = '' }: ViewProps) { - {showCharts && ( + }, + { id: 'board', label: vi.tabBoard || 'Task board', icon: }, + ]} + activeTab={issuesTab} + onChange={(id) => setIssuesTab(id as 'audit' | 'board')} + ariaLabel={vi.title} + idPrefix="issues" + /> + + {issuesTab === 'board' ? ( + + ) : null} + + {issuesTab === 'audit' && showCharts && (
    @@ -330,6 +389,7 @@ export default function Issues({ searchQuery = '' }: ViewProps) {
    )} + {issuesTab === 'audit' && (
    {PRIORITY_ORDER.map((p) => { const cfg = PRIORITY_CONFIG[p as PriorityKey]; @@ -352,7 +412,9 @@ export default function Issues({ searchQuery = '' }: ViewProps) { ); })}
    + )} + {issuesTab === 'audit' && (
    + )} - {filtered.length === 0 ? ( + {issuesTab === 'audit' && (filtered.length === 0 ? (

    {vi.noMatches}

    @@ -416,7 +479,7 @@ export default function Issues({ searchQuery = '' }: ViewProps) { /> ))}
    - )} + ))}
    ); } diff --git a/web/src/views/KeywordsExplorer.tsx b/web/src/views/KeywordsExplorer.tsx index 454848ec..95745cab 100644 --- a/web/src/views/KeywordsExplorer.tsx +++ b/web/src/views/KeywordsExplorer.tsx @@ -2,7 +2,7 @@ import { useState, useMemo, useEffect, useCallback } from 'react'; import type { KeywordRow, KeywordReportData, ViewProps } from '@/types'; -import type { CannibalisationItem, KeywordHistoryMap } from '@/types/components'; +import type { CannibalisationItem, KeywordHistoryMap, QueryPageMisalignmentItem } from '@/types/components'; import { Key, Settings2, Play } from 'lucide-react'; import { useRouter } from 'next/navigation'; import { useUrlTab } from '@/hooks/useUrlTab'; @@ -23,6 +23,7 @@ import { } from '../components/keywordsExplorer/keywordTableUtils'; import { CannibalisationPanel, + QueryPageMisalignmentPanel, ByPagePanel, BulkSeedPanel, } from '../components/keywordsExplorer/KeywordPanels'; @@ -41,7 +42,7 @@ import { isTableTab, } from '../components/keywordsExplorer/keywordTabMeta'; -const KEYWORD_TABS = ['overview', ...KEYWORD_TABLE_TAB_IDS, 'cannib', 'bypage'] as const; +const KEYWORD_TABS = ['overview', ...KEYWORD_TABLE_TAB_IDS, 'cannib', 'alignment', 'bypage'] as const; const EMPTY_ROWS: KeywordRow[] = []; const EMPTY_HISTORY: KeywordHistoryMap = {}; @@ -104,18 +105,27 @@ export default function KeywordsExplorer({ onOpenIntegrations }: ViewProps) { () => baseRowsForTab('opportunities', rows, tabFilterOptions).length, [rows, tabFilterOptions], ); + const strikingCount = useMemo( + () => baseRowsForTab('striking', rows, tabFilterOptions).length, + [rows, tabFilterOptions], + ); const cannibItems: CannibalisationItem[] = (kwData?.cannibalisation as CannibalisationItem[] | undefined) ?? []; + const alignmentItems: QueryPageMisalignmentItem[] = + (kwData?.query_page_misalignment as QueryPageMisalignmentItem[] | undefined) ?? + []; const tabCounts = useMemo( () => ({ questions: questionCount, quickwins: quickWinCount, + striking: strikingCount || Number(kwData?.striking_distance_count) || 0, lostclicks: lostClickCount, opportunities: opportunityCount, cannib: cannibItems.length, + alignment: alignmentItems.length, pages: new Set(rows.map((r) => r.gsc_url).filter(Boolean)).size, }), - [questionCount, quickWinCount, lostClickCount, opportunityCount, cannibItems.length, rows], + [questionCount, quickWinCount, strikingCount, kwData?.striking_distance_count, lostClickCount, opportunityCount, cannibItems.length, alignmentItems.length, rows], ); const hasActiveFilters = !!(searchQuery || intentFilter || brandedFilter || sourceFilter); @@ -196,8 +206,8 @@ export default function KeywordsExplorer({ onOpenIntegrations }: ViewProps) { }, [gscKeywordsForHistory, brandQuery]); const columns = useMemo( - () => buildKeywordColumns(showParentTopic, showTrend, historyByKeyword, ke), - [showParentTopic, showTrend, historyByKeyword, ke], + () => buildKeywordColumns(showParentTopic, showTrend, historyByKeyword, ke, rows), + [showParentTopic, showTrend, historyByKeyword, ke, rows], ); const insights = useMemo(() => { @@ -242,10 +252,11 @@ export default function KeywordsExplorer({ onOpenIntegrations }: ViewProps) { const bannerCount = useMemo(() => { if (activeTab === 'cannib') return cannibItems.length; + if (activeTab === 'alignment') return alignmentItems.length; if (activeTab === 'bypage') return tabCounts.pages; if (isTableTab(activeTab)) return tableRows.length; return null; - }, [activeTab, cannibItems.length, tabCounts.pages, tableRows.length]); + }, [activeTab, cannibItems.length, alignmentItems.length, tabCounts.pages, tableRows.length]); const navigateTab = useCallback((tab: KeywordTabId) => setActiveTab(tab), []); @@ -405,6 +416,8 @@ export default function KeywordsExplorer({ onOpenIntegrations }: ViewProps) { {activeTab === 'cannib' ? ( + ) : activeTab === 'alignment' ? ( + ) : activeTab === 'bypage' ? ( ) : tableEmptyContent ? ( diff --git a/web/src/views/Lighthouse.tsx b/web/src/views/Lighthouse.tsx index cc56fc3d..9fb1cd32 100644 --- a/web/src/views/Lighthouse.tsx +++ b/web/src/views/Lighthouse.tsx @@ -332,6 +332,30 @@ export default function Lighthouse({ searchQuery = '' }: ViewProps) { )} + {data?.crux_summary?.ok && ( + +

    + Real users (CrUX) +

    +
    + {(['lcp', 'inp', 'cls'] as const).map((metric) => { + const pass = data.crux_summary?.pass?.[metric]; + const p75 = data.crux_summary?.metrics?.[ + metric === 'lcp' ? 'largest_contentful_paint' : metric === 'inp' ? 'interaction_to_next_paint' : 'cumulative_layout_shift' + ]?.p75; + return ( +
    + {metric} +

    + {p75 != null ? String(p75) : '—'} {pass === false ? '(needs improvement)' : pass ? '(good)' : ''} +

    +
    + ); + })} +
    +
    + )} + } className="mb-0" + actions={ + filtered.length > 0 ? ( + + ) : undefined + } /> +

    {title}

    +

    {hint}

    + {sample.length ? ( +
      + {sample.map((path) => ( +
    • + {path} +
    • + ))} +
    + ) : ( +

    {vl.emptyList}

    + )} + + ); +} + +export default function LogAnalyzer(_props: ViewProps) { + const pipeline = useOptionalPipeline(); + const { data } = useReport(); + const propertyId = Number(pipeline?.configState.active_property_id || 0); + const startUrl = String(pipeline?.configState.start_url || data?.site_name || ''); + const crawlUrls = (data?.links || []).map((l) => String(l.url || '')).filter(Boolean).slice(0, 5000); + const [file, setFile] = useState(null); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(''); + const [analysis, setAnalysis] = useState | null>(null); + + const handleUpload = async () => { + if (!propertyId || !file) return; + setBusy(true); + setError(''); + try { + const form = new FormData(); + form.append('propertyId', String(propertyId)); + form.append('file', file); + if (startUrl) form.append('startUrl', startUrl); + if (crawlUrls.length) form.append('crawlUrls', crawlUrls.join('\n')); + const res = await fetch(apiUrl('/logs/upload'), { method: 'POST', body: form }); + const payload = await res.json(); + if (!res.ok) throw new Error(payload.error || 'Upload failed'); + setAnalysis((payload.analysis || null) as Record | null); + } catch (e) { + setError(e instanceof Error ? e.message : String(e)); + } finally { + setBusy(false); + } + }; + + const compare = (analysis?.crawl_compare || null) as CrawlCompare | null; + const logOnlyPaths = + compare?.log_only_paths || + (Array.isArray(analysis?.log_only_paths) ? (analysis.log_only_paths as string[]) : []); + const crawlOnlyPaths = + compare?.crawl_only_paths || + (Array.isArray(analysis?.crawl_only_paths) ? (analysis.crawl_only_paths as string[]) : []); + + return ( + + } + /> + {!propertyId ? ( + {vl.noProperty} + ) : ( + +
    + setFile(e.target.files?.[0] ?? null)} + className="text-sm" + /> + +
    + {error ?

    {error}

    : null} + {analysis ? ( +
    +
    +
    +

    {vl.parsedLines}

    +

    {Number(analysis.parsed_lines || 0).toLocaleString()}

    +
    +
    +

    {vl.uniquePaths}

    +

    {Number(analysis.unique_paths || 0).toLocaleString()}

    +
    +
    +

    {vl.googlebotHits}

    +

    {Number(analysis.googlebot_hits || 0).toLocaleString()}

    +
    +
    +

    {vl.logOnlyUrls}

    +

    + {Number(compare?.log_only_count ?? logOnlyPaths.length).toLocaleString()} +

    +
    +
    +

    {vl.crawlOnlyUrls}

    +

    + {Number(compare?.crawl_only_count ?? crawlOnlyPaths.length).toLocaleString()} +

    +
    +
    + {(logOnlyPaths.length > 0 || crawlOnlyPaths.length > 0) ? ( +
    + + +
    + ) : null} +
    + ) : null} +
    + )} +
    + ); +} diff --git a/web/src/views/SearchPerformance.tsx b/web/src/views/SearchPerformance.tsx index a17090d2..37ae9088 100644 --- a/web/src/views/SearchPerformance.tsx +++ b/web/src/views/SearchPerformance.tsx @@ -26,7 +26,7 @@ import { buildPageExportColumns, } from '../components/searchPerformance/gscTableUtils'; import UrlGapListsPanel from '../components/google/UrlGapListsPanel'; -import { buildLinksInspectHref } from '../lib/reportNav'; +import UrlInspectorButton from '@/components/UrlInspectorButton'; import { useSearchParams } from 'next/navigation'; import { useUrlTab } from '@/hooks/useUrlTab'; @@ -158,16 +158,14 @@ export default function SearchPerformance() { key: '_inspect', label: '', render: (_v, row) => ( - - {strings.components?.urlGapLists?.openInLinks || 'Link Explorer'} - + ), }, ], - [sp, searchParams], + [sp], ); const paginationLabels = { diff --git a/web/src/views/SiteStructure.tsx b/web/src/views/SiteStructure.tsx index da2fbf4b..907a45fd 100644 --- a/web/src/views/SiteStructure.tsx +++ b/web/src/views/SiteStructure.tsx @@ -24,9 +24,10 @@ import { finalizeRollup, } from '../lib/siteStructureTree'; import { PageLayout, PageHeader, Card, Button, StatCard, AlertBanner, ViewTabs, ViewTabPanel } from '../components'; +import UrlInspectorButton from '@/components/UrlInspectorButton'; import type { ViewTabItem } from '../components'; import PathTreeTable from '../components/siteStructure/PathTreeTable'; -import type { PathTreeNode, PathTreeTableRow, ViewProps } from '@/types'; +import type { CrawlSegmentEntry, CrawlSegmentsData, PathTreeNode, PathTreeTableRow, ViewProps } from '@/types'; const TREE_PAGE_SIZE = 20; @@ -274,6 +275,16 @@ export default function SiteStructure({ searchQuery = '' }: ViewProps) { const rootMetrics = merged.get('/')?.current; + const crawlSegments = (data?.crawl_segments as CrawlSegmentsData | undefined) ?? null; + + const topLinksByInlinks = useMemo( + () => + [...filteredLinks] + .sort((a, b) => Number(b.inlinks || 0) - Number(a.inlinks || 0)) + .slice(0, 10), + [filteredLinks], + ); + const panelKey = [ selectedReportId ?? '', compareReportId ?? '', @@ -366,11 +377,56 @@ export default function SiteStructure({ searchQuery = '' }: ViewProps) { icon={} /> - ) : ( + ) : null} + {crawlSegments?.segments?.length ? ( + +

    {s.crawlSegmentsTitle}

    +

    {s.crawlSegmentsHint}

    + {crawlSegments.overall_health != null ? ( +

    + {s.crawlSegmentsOverall}:{' '} + {crawlSegments.overall_health} +

    + ) : null} + + + + + + + + + + {crawlSegments.segments.map((seg: CrawlSegmentEntry) => ( + + + + + + ))} + +
    {s.crawlSegmentsPrefix}{s.crawlSegmentsUrls}{s.crawlSegmentsHealth}
    {seg.prefix}{seg.url_count ?? 0}{seg.health_score ?? '—'}
    +
    + ) : null} + {topLinksByInlinks.length > 0 ? ( + +

    Top pages by inlinks

    +
      + {topLinksByInlinks.map((link) => ( +
    • + {link.url} + {Number(link.inlinks || 0).toLocaleString()} + +
    • + ))} +
    +
    + ) : null} + {!tree ? (

    {filteredLinks.length === 0 && (data?.links?.length ?? 0) > 0 ? s.emptyFilter : s.empty}

    - )} + ) : null} )} From a87860ba5ed9ab317fb8039247dd762d78b4a7e4 Mon Sep 17 00:00:00 2001 From: PrashantUnity Date: Sun, 7 Jun 2026 10:53:42 +0530 Subject: [PATCH 2/4] i am fried --- tests/test_alert_checker.py | 8 ++++---- web/src/server/gscLinksImportRoute.test.ts | 8 +------- web/src/server/testHelpers/routeTestUtils.ts | 6 ++++-- 3 files changed, 9 insertions(+), 13 deletions(-) diff --git a/tests/test_alert_checker.py b/tests/test_alert_checker.py index 8f154a23..7d237c97 100644 --- a/tests/test_alert_checker.py +++ b/tests/test_alert_checker.py @@ -124,14 +124,14 @@ def test_check_health_alerts_postgres_integration(property_id) -> None: with db_session() as conn: conn.execute( """INSERT INTO audit_health_snapshots - (property_id, report_id, health_score, category_scores, issue_counts) - VALUES (%s, 1, 90, '{}', '{}')""", + (property_id, report_id, health_score, category_scores, issue_counts, generated_at) + VALUES (%s, 1, 90, '{}', '{}', '2026-05-01T00:00:00Z')""", (property_id,), ) conn.execute( """INSERT INTO audit_health_snapshots - (property_id, report_id, health_score, category_scores, issue_counts) - VALUES (%s, 2, 70, '{}', '{}')""", + (property_id, report_id, health_score, category_scores, issue_counts, generated_at) + VALUES (%s, 2, 70, '{}', '{}', '2026-06-01T00:00:00Z')""", (property_id,), ) conn.commit() diff --git a/web/src/server/gscLinksImportRoute.test.ts b/web/src/server/gscLinksImportRoute.test.ts index 681c52cf..94e9a69a 100644 --- a/web/src/server/gscLinksImportRoute.test.ts +++ b/web/src/server/gscLinksImportRoute.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it, vi, beforeEach } from 'vitest'; import { NextRequest } from 'next/server'; import { EventEmitter } from 'events'; +import { localRequest } from '@/server/testHelpers/routeTestUtils'; const spawnMock = vi.fn(); const getPropertyByIdMock = vi.fn(); @@ -31,13 +32,6 @@ function makeChildProcess(stdout: string, exitCode: number) { return proc; } -function localRequest(path: string, init?: RequestInit): NextRequest { - return new NextRequest(`http://localhost:3000${path}`, { - ...init, - headers: { host: 'localhost:3000', ...(init?.headers || {}) }, - }); -} - async function importRoute() { return import('../../app/api/properties/[id]/google/links/import/route'); } diff --git a/web/src/server/testHelpers/routeTestUtils.ts b/web/src/server/testHelpers/routeTestUtils.ts index 1ad4ee0a..a4e98a25 100644 --- a/web/src/server/testHelpers/routeTestUtils.ts +++ b/web/src/server/testHelpers/routeTestUtils.ts @@ -2,14 +2,16 @@ import { vi } from 'vitest'; import { NextRequest } from 'next/server'; import { EventEmitter } from 'events'; -export function localRequest(path: string, init?: RequestInit): NextRequest { +type NextRequestInit = NonNullable[1]>; + +export function localRequest(path: string, init?: NextRequestInit): NextRequest { return new NextRequest(`http://localhost:3000${path}`, { ...init, headers: { host: 'localhost:3000', ...(init?.headers || {}) }, }); } -export function remoteRequest(path: string, init?: RequestInit): NextRequest { +export function remoteRequest(path: string, init?: NextRequestInit): NextRequest { return new NextRequest(`http://192.168.1.5:3000${path}`, init); } From 76814fef0d0615d1740ffc862fb57b8629eda276 Mon Sep 17 00:00:00 2001 From: PrashantUnity Date: Sun, 7 Jun 2026 10:58:01 +0530 Subject: [PATCH 3/4] alert --- src/website_profiling/tools/alert_checker.py | 2 +- tests/test_alert_checker.py | 9 +++++++-- 2 files changed, 8 insertions(+), 3 deletions(-) diff --git a/src/website_profiling/tools/alert_checker.py b/src/website_profiling/tools/alert_checker.py index 2eb982b0..50df9134 100644 --- a/src/website_profiling/tools/alert_checker.py +++ b/src/website_profiling/tools/alert_checker.py @@ -12,7 +12,7 @@ def check_health_alerts(property_id: int, threshold_drop: int = 10) -> list[dict with db_session() as conn: cur = conn.execute( """SELECT health_score, generated_at FROM audit_health_snapshots - WHERE property_id = %s ORDER BY generated_at DESC LIMIT 2""", + WHERE property_id = %s ORDER BY generated_at DESC, id DESC LIMIT 2""", (property_id,), ) rows = cur.fetchall() or [] diff --git a/tests/test_alert_checker.py b/tests/test_alert_checker.py index 7d237c97..a262eafa 100644 --- a/tests/test_alert_checker.py +++ b/tests/test_alert_checker.py @@ -114,7 +114,12 @@ def property_id(): with db_session() as conn: pid = upsert_property_by_domain(conn, "Alert Test", "alert-test.example") + conn.execute("DELETE FROM audit_health_snapshots WHERE property_id = %s", (pid,)) + conn.commit() yield pid + with db_session() as conn: + conn.execute("DELETE FROM audit_health_snapshots WHERE property_id = %s", (pid,)) + conn.commit() @pytest.mark.integration @@ -125,13 +130,13 @@ def test_check_health_alerts_postgres_integration(property_id) -> None: conn.execute( """INSERT INTO audit_health_snapshots (property_id, report_id, health_score, category_scores, issue_counts, generated_at) - VALUES (%s, 1, 90, '{}', '{}', '2026-05-01T00:00:00Z')""", + VALUES (%s, 9001, 90, '{}', '{}', NOW() - INTERVAL '2 days')""", (property_id,), ) conn.execute( """INSERT INTO audit_health_snapshots (property_id, report_id, health_score, category_scores, issue_counts, generated_at) - VALUES (%s, 2, 70, '{}', '{}', '2026-06-01T00:00:00Z')""", + VALUES (%s, 9002, 70, '{}', '{}', NOW() - INTERVAL '1 day')""", (property_id,), ) conn.commit() From 696ad6e45b5617d8a9e5bbb7629821f91df58881 Mon Sep 17 00:00:00 2001 From: PrashantUnity Date: Sun, 7 Jun 2026 11:56:58 +0530 Subject: [PATCH 4/4] Updated --- .github/workflows/ci.yml | 7 +++++ AGENT.md | 2 +- Dockerfile | 1 - README.md | 14 ++++++++- docker-compose.pull.yml | 47 +++++++++++++++++++++++++++++++ docker-entrypoint.sh | 61 ++++++++++++++++++++++++++++++++++++++++ docs/OPS.md | 2 ++ 7 files changed, 131 insertions(+), 3 deletions(-) create mode 100644 docker-compose.pull.yml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3cadc117..0bed9188 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -62,6 +62,13 @@ jobs: docker run --rm \ website-profiling:ci \ /opt/venv/bin/pytest tests/test_crawl_fetchers.py tests/test_crawler_browser_e2e.py -m browser -q -o addopts= + - name: Compose smoke (postgres + web) + env: + WEB_IMAGE: website-profiling:ci + run: | + docker compose -f docker-compose.pull.yml up -d --wait + curl -fsS http://127.0.0.1:3000/home + docker compose -f docker-compose.pull.yml down -v web: runs-on: ubuntu-latest diff --git a/AGENT.md b/AGENT.md index 3a5e5139..b4b3264e 100644 --- a/AGENT.md +++ b/AGENT.md @@ -26,7 +26,7 @@ - **Pool tuning:** `DB_POOL_MIN` / `DB_POOL_MAX` (Python), `PGPOOL_MAX` (Node). Bulk crawl writes via `executemany`; optional **`crawl_stream_to_db`** streams rows during fetch. - **`web/`:** `/api/report/*` (PostgreSQL); `/api/run` spawns Python (localhost only); `/api/crawl/browser-status` GET (localhost, Playwright/Chromium preflight); `/api/pipeline-config` GET/PUT; `/api/llm-config` GET/PUT (AI only); `/api/properties/{id}/google/links/import` POST (GSC Links CSV); `PipelineRunnerFab` saves pipeline + LLM state before each run - **Job store:** in-memory on `globalThis` in `web/src/server/pipelineJobs.ts` — job status/log is lost on server restart (single-process dev/Docker only). -- **Docker:** `Dockerfile` + `docker-compose.yml` (postgres + web); **`LIGHTHOUSE_CHROME_FLAGS`** +- **Docker:** `Dockerfile` + `docker-compose.yml` (postgres + web); **`docker-compose.pull.yml`** for pre-built images (`WEB_IMAGE`); **`LIGHTHOUSE_CHROME_FLAGS`** **Where to edit** diff --git a/Dockerfile b/Dockerfile index 3557010a..c063004b 100644 --- a/Dockerfile +++ b/Dockerfile @@ -34,7 +34,6 @@ ENV PYTHONDONTWRITEBYTECODE=1 \ PYTHONUNBUFFERED=1 \ NEXT_TELEMETRY_DISABLED=1 \ WEBSITE_PROFILING_ROOT=/app \ - DATABASE_URL=postgres://profiling:profiling@postgres:5432/website_profiling \ DATA_DIR=/data \ PYTHON=/opt/venv/bin/python \ CHROME_PATH=/usr/bin/chromium \ diff --git a/README.md b/README.md index d1da21fa..377e7826 100644 --- a/README.md +++ b/README.md @@ -10,7 +10,7 @@ Open-source technical SEO crawl and audit UI (Next.js + Python + PostgreSQL). ## Quick start -**Docker** +**Docker (build from source)** ```bash docker compose up --build @@ -18,6 +18,18 @@ docker compose up --build Open [http://localhost:3000/home](http://localhost:3000/home). +**Docker (published image)** + +The app requires PostgreSQL on the same Docker network. Do **not** run the image alone with `docker run` — the hostname `postgres` only resolves inside Compose. + +```bash +docker pull your-registry/website-profiling:tag +export WEB_IMAGE=your-registry/website-profiling:tag +docker compose -f docker-compose.pull.yml up -d +``` + +Open [http://localhost:3000/home](http://localhost:3000/home). + **Local dev** ```bash diff --git a/docker-compose.pull.yml b/docker-compose.pull.yml new file mode 100644 index 00000000..b89e0767 --- /dev/null +++ b/docker-compose.pull.yml @@ -0,0 +1,47 @@ +# Run a pre-built/pulled image with Postgres (no local docker build). +# Usage: +# export WEB_IMAGE=your-registry/website-profiling:tag +# docker compose -f docker-compose.pull.yml up -d +services: + postgres: + image: postgres:16-alpine + environment: + POSTGRES_DB: website_profiling + POSTGRES_USER: profiling + POSTGRES_PASSWORD: profiling + volumes: + - pg-data:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U profiling -d website_profiling"] + interval: 5s + timeout: 3s + retries: 5 + + web: + image: ${WEB_IMAGE:-website-profiling:latest} + depends_on: + postgres: + condition: service_healthy + ports: + - "3000:3000" + environment: + WEBSITE_PROFILING_ROOT: /app + DATABASE_URL: postgres://profiling:profiling@postgres:5432/website_profiling + DATA_DIR: /data + PYTHON: /opt/venv/bin/python + NODE_ENV: production + CHROME_PATH: /usr/bin/chromium + LIGHTHOUSE_PATH: /usr/local/bin/lighthouse + LIGHTHOUSE_CHROME_FLAGS: --headless --no-sandbox --disable-dev-shm-usage --disable-gpu + volumes: + - profiling-data:/data + healthcheck: + test: ["CMD", "node", "-e", "require('http').get('http://127.0.0.1:3000/home', (r) => process.exit(r.statusCode === 200 ? 0 : 1)).on('error', () => process.exit(1))"] + interval: 30s + timeout: 5s + retries: 3 + start_period: 15s + +volumes: + pg-data: + profiling-data: diff --git a/docker-entrypoint.sh b/docker-entrypoint.sh index af3152b2..43f4b1e3 100644 --- a/docker-entrypoint.sh +++ b/docker-entrypoint.sh @@ -1,5 +1,66 @@ #!/bin/sh set -e cd /app + +if [ -z "${DATABASE_URL:-}" ] || [ -z "$(printf '%s' "$DATABASE_URL" | tr -d '[:space:]')" ]; then + echo "ERROR: DATABASE_URL is required." >&2 + echo " Use docker compose (see README) or pass -e DATABASE_URL=postgres://user:pass@host:5432/db" >&2 + exit 1 +fi + +/opt/venv/bin/python <<'PY' +import os +import sys +import time +from urllib.parse import urlparse + +from sqlalchemy import create_engine, text +from sqlalchemy.pool import NullPool + + +def get_url() -> str: + url = (os.environ.get("DATABASE_URL") or "").strip() + if url.startswith("postgres://"): + return "postgresql+psycopg://" + url[len("postgres://") :] + if url.startswith("postgresql://") and "+psycopg" not in url: + return "postgresql+psycopg://" + url[len("postgresql://") :] + return url + + +def db_host_label() -> str: + raw = (os.environ.get("DATABASE_URL") or "").strip() + parsed = urlparse(raw.replace("postgres://", "postgresql://", 1)) + return parsed.hostname or raw + + +url = get_url() +attempts = 30 +delay = 2 +last_error = None + +for attempt in range(1, attempts + 1): + try: + engine = create_engine(url, poolclass=NullPool) + with engine.connect() as conn: + conn.execute(text("SELECT 1")) + sys.exit(0) + except Exception as exc: + last_error = exc + if attempt < attempts: + time.sleep(delay) + +host = db_host_label() +print( + f"ERROR: Could not connect to Postgres at host '{host}' after {attempts * delay}s.", + file=sys.stderr, +) +print( + " Ensure the postgres service is running on the same Docker network (use docker compose).", + file=sys.stderr, +) +print(f" Last error: {last_error}", file=sys.stderr) +sys.exit(1) +PY + /opt/venv/bin/alembic upgrade head cd /app/web && exec npm run start -- -H 0.0.0.0 -p 3000 diff --git a/docs/OPS.md b/docs/OPS.md index b0a808c5..a180654a 100644 --- a/docs/OPS.md +++ b/docs/OPS.md @@ -47,6 +47,8 @@ After pulling roadmap changes, apply Alembic revision `011` (included in the ful # or, if Postgres is already up: ./local-test quick ``` +**Docker:** run migrations automatically at container start. Use `docker compose up` (build) or `docker compose -f docker-compose.pull.yml up` (pre-built `WEB_IMAGE`) so Postgres and the app share a network — not standalone `docker run`. + ## Running tests **Python (core, 100% coverage on non-omitted modules):**