@@ -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.
- | URL | Status | Title | Inlinks | Words |
+ | URL | Status | Title | Inlinks | Words | {'Custom extract | ' if has_custom_extract else ''}
{link_rows or '| 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..a262eafa
--- /dev/null
+++ b/tests/test_alert_checker.py
@@ -0,0 +1,145 @@
+"""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")
+ 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
+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, generated_at)
+ 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, 9002, 70, '{}', '{}', NOW() - INTERVAL '1 day')""",
+ (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 ? (