diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0402ea0e..fb319d44 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -47,13 +47,14 @@ jobs: pytest tests/test_alert_checker.py tests/test_schedule_runner.py tests/test_export_audit.py \ tests/test_export_audit_coverage.py tests/test_audit_tools.py tests/test_audit_tools_expanded.py \ tests/test_audit_tools_coverage.py tests/test_audit_tools_dispatch_coverage.py \ - tests/test_export_custom_coverage.py tests/test_export_artifacts_coverage.py \ - tests/test_export_compare_coverage.py tests/test_export_tools_coverage.py \ - tests/test_image_tools.py tests/test_export_custom.py tests/test_export_artifacts.py \ - tests/test_export_compare.py \ - tests/test_mcp_registry.py tests/test_mcp_resources.py \ + tests/test_audit_tools_links_extras.py tests/test_export_custom_coverage.py \ + tests/test_export_artifacts_coverage.py tests/test_export_compare_coverage.py \ + tests/test_export_tools_coverage.py tests/test_image_tools.py tests/test_export_custom.py \ + tests/test_export_artifacts.py tests/test_export_compare.py tests/test_export_workbook.py \ + tests/test_export_sitemap.py tests/test_mcp_registry.py tests/test_mcp_resources.py \ + tests/test_tools_branch_coverage.py \ --cov=website_profiling.tools --cov-config=.coveragerc.tools \ - --cov-report=term-missing --cov-fail-under=95 -q -o addopts= + --cov-report=term-missing --cov-fail-under=100 -q -o addopts= - name: CLI smoke run: python -m src --help diff --git a/alembic/versions/013_crawl_discovery_and_link_edges.py b/alembic/versions/013_crawl_discovery_and_link_edges.py new file mode 100644 index 00000000..a666036c --- /dev/null +++ b/alembic/versions/013_crawl_discovery_and_link_edges.py @@ -0,0 +1,56 @@ +"""Crawl discovery mode on crawl_runs and rich link_edges table. + +Revision ID: 013_crawl_discovery_edges +Revises: 012_chat_sessions +""" +from __future__ import annotations + +from alembic import op + +revision = "013_crawl_discovery_edges" +down_revision = "012_chat_sessions" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.execute(""" + ALTER TABLE crawl_runs + ADD COLUMN IF NOT EXISTS discovery_mode TEXT DEFAULT 'spider'; + + CREATE TABLE IF NOT EXISTS link_edges ( + crawl_run_id BIGINT NOT NULL REFERENCES crawl_runs(id) ON DELETE CASCADE, + from_url TEXT NOT NULL, + to_url TEXT NOT NULL, + anchor_text TEXT NOT NULL DEFAULT '', + rel TEXT NOT NULL DEFAULT '', + is_nofollow BOOLEAN NOT NULL DEFAULT FALSE, + is_sponsored BOOLEAN NOT NULL DEFAULT FALSE, + is_ugc BOOLEAN NOT NULL DEFAULT FALSE, + link_type TEXT NOT NULL DEFAULT 'internal', + PRIMARY KEY (crawl_run_id, from_url, to_url, anchor_text, rel) + ); + CREATE INDEX IF NOT EXISTS idx_link_edges_run_from + ON link_edges(crawl_run_id, from_url); + CREATE INDEX IF NOT EXISTS idx_link_edges_run_to + ON link_edges(crawl_run_id, to_url); + + CREATE TABLE IF NOT EXISTS saved_crawl_filters ( + id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, + property_id BIGINT NOT NULL REFERENCES properties(id) ON DELETE CASCADE, + name TEXT NOT NULL, + filter_json JSONB NOT NULL DEFAULT '{}', + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + UNIQUE (property_id, name) + ); + CREATE INDEX IF NOT EXISTS idx_saved_crawl_filters_property + ON saved_crawl_filters(property_id); + """) + + +def downgrade() -> None: + op.execute(""" + DROP TABLE IF EXISTS saved_crawl_filters CASCADE; + DROP TABLE IF EXISTS link_edges CASCADE; + ALTER TABLE crawl_runs DROP COLUMN IF EXISTS discovery_mode; + """) diff --git a/docs/GLOSSARY.md b/docs/GLOSSARY.md index bc8c9ca0..436850f4 100644 --- a/docs/GLOSSARY.md +++ b/docs/GLOSSARY.md @@ -39,17 +39,21 @@ UI terms agencies recognize, mapped to internal keys and data sources. | Scheduled audits | `properties.schedule_cron`, `/api/schedule/check` | Cron + pipeline spawn | Recurring site audit — see [OPS.md](OPS.md) | | Property alerts | `alert_webhook_url`, `/api/alerts/check` | Health snapshot rules | Ops notifications | | Content brief | Keywords Brief button, `/api/keywords/content-brief` | LLM or deterministic | Content planning | -| AI issue fix | `llm_recommendation`, `/api/issues/fix-suggestion` | LLM on demand + report build | Actionable remediation | +| AI fix suggestions | `llm_recommendation`, `/api/ai/fix-suggestion`, `/api/issues/fix-suggestion` (legacy) | LLM on demand + report build | Actionable remediation across Issues, Lighthouse, Security, and other surfaces | | AI Chat | `/chat`, `/api/chat`, `chat_sessions` | LLM + read-only audit tools | Conversational site audit queries | | MCP tools | `python -m website_profiling.mcp` | Same `audit_tools` as chat | Cursor / Claude Desktop integration — see [MCP.md](MCP.md) | | Read-only session | `AUTH_DEFAULT_ROLE=client-readonly`, `/api/auth/session` | Session cookie | Client view-only access | | Export executive summary | `export_audit_html/pdf/csv`, `export_audit_report` (chat/MCP), Export view | Report payload + optional AI | Client deliverable | +| ads.txt / security.txt | `site_level`, `get_ads_txt_status`, `get_security_txt_status` | Root file fetch at report build | Publisher / contact file hygiene | +| Subdomain inventory | `subdomains`, `list_subdomains`, `/subdomains` view | Crawl + GSC + optional crt.sh | Host footprint vs crawl scope | +| Contact intelligence | `contact_intelligence`, `get_contact_intelligence`, `/contacts` view | Crawl schema/mailto + security.txt + RDAP org | Business identity consistency | ## Metric names | UI | Field | Source | |----|-------|--------| -| Inlinks | `inlinks` | Crawl graph | +| Impact score | `impact_score` on issues | GSC clicks + GA4 sessions + priority weight (see below) | +| Link edges | `link_edges`, `link_rel_summary` | Crawl anchor/rel attributes | | Outlinks | `outlinks` | Crawl graph | | Status code | `status` | HTTP | | Crawl rendering | `crawl_render_mode` on run; `fetch_method` per URL | `static`, `javascript`, or `auto` crawl config; `static` vs `rendered` per page | @@ -62,6 +66,8 @@ UI terms agencies recognize, mapped to internal keys and data sources. | On-site frequency | `volume` (heuristic) | Estimated from crawl | | Sessions | GA4 metrics | Analytics | +**Impact score:** `priority_weight + (gsc_clicks × 10) + (ga4_sessions × 5)` with Critical=1000, High=100, Medium=10, Low=1. + ## Provenance badges | Badge | Meaning | diff --git a/docs/MCP.md b/docs/MCP.md index dd473f8e..d3ba6f94 100644 --- a/docs/MCP.md +++ b/docs/MCP.md @@ -43,11 +43,11 @@ Add to `.cursor/mcp.json` (or Cursor MCP settings): | `audit://glossary` | Excerpt from `docs/GLOSSARY.md` | | `audit://tools` | Tool catalog grouped by SEO domain | -## Tools (171 read-only + export) +## Tools (176 read-only + export) ### Export and deliverables -`export_audit_report`, `export_compare_csv`, `export_list_as_csv`, `compose_custom_report`, `export_custom_report`, `list_export_formats` +`export_audit_report`, `export_compare_csv`, `export_list_as_csv`, `export_sitemap_xml`, `validate_rich_results`, `compose_custom_report`, `export_custom_report`, `list_export_formats` Full audit exports reuse the same generators as the Export view (PDF requires `reportlab`). Export tools store files as artifacts (24h TTL); in-app chat renders download buttons via `/api/chat/artifacts/{id}`. @@ -79,7 +79,7 @@ Size-based tools require `probe_image_inventory=true` in pipeline config when bu ### Links and architecture -`list_orphan_pages`, `get_top_linked_pages`, `get_top_crawled_pages`, `get_outbound_link_domains`, `get_link_graph_summary`, `get_url_fingerprints`, `list_broken_link_sources`, `get_mime_type_breakdown`, `get_title_length_distribution`, `get_domain_link_distribution`, `get_outlink_distribution` +`get_link_rel_summary`, `get_inlink_anchors`, `list_nofollow_internal_links`, `list_orphan_pages`, `get_top_linked_pages`, `get_top_crawled_pages`, `get_outbound_link_domains`, `get_link_graph_summary`, `get_url_fingerprints`, `list_broken_link_sources`, `get_mime_type_breakdown`, `get_title_length_distribution`, `get_domain_link_distribution`, `get_outlink_distribution` ### Indexation and international @@ -115,12 +115,13 @@ Size-based tools require `probe_image_inventory=true` in pipeline config when bu ## Future pipeline items (not yet exposed as tools) -These require additional crawl or third-party integrations before dedicated tools are useful: +These require additional third-party integrations or product scope beyond current crawl data: -- Google Rich Results / schema validation API -- Full backlink index and anchor-text analytics -- axe / color-contrast accessibility audits +- Full backlink index and anchor-text analytics (beyond GSC Links import) - SERP rank tracking beyond GSC position snapshots +- Standalone Google Rich Results Test API (current `validate_rich_results` uses crawl heuristics + GSC URL Inspection) + +Already available: `validate_rich_results`, `export_sitemap_xml`, workbook export, axe audits via `enable_axe` on browser crawls. ## Example prompts diff --git a/input.txt.example b/input.txt.example index 4d1b4d0e..a6a9c56e 100644 --- a/input.txt.example +++ b/input.txt.example @@ -18,6 +18,16 @@ content_excerpt_max_chars = 4096 preserve_crawl_history = true crawl_stream_to_db = false crawl_exclude_urls = +crawl_discovery_mode = spider +crawl_url_list = +crawl_user_agent_preset = default +crawl_user_agent_custom = +crawl_auth_username = +crawl_auth_password = +crawl_extra_headers = +crawl_cookies = +crawl_robots_txt_override = +custom_extractors = # crawl_render_mode: static | javascript | auto (auto = static first, browser when SPA heuristics match) crawl_render_mode = static crawl_js_concurrency = 3 @@ -46,6 +56,9 @@ max_image_probe_urls = 500 image_probe_concurrency = 6 image_probe_timeout = 8 image_unoptimized_min_kb = 200 +enable_subdomain_discovery = true +subdomain_ct_lookup = true +enable_rdap_org_lookup = true # --- Lighthouse --- lighthouse_url = @@ -56,6 +69,13 @@ lighthouse_iterations = 1 run_lighthouse = true run_lighthouse_on_pages = true enable_crux = false +enable_rich_results_validation = false +google_rich_results_api_key = +enable_axe = false +enable_spell_check = false +enable_html_validation = false +enable_amp_audit = false +enable_wayback_lookup = false competitor_domains = bing_webmaster_api_key = serp_api_key = diff --git a/pipeline-config.example.txt b/pipeline-config.example.txt index b1c384b3..a43055d3 100644 --- a/pipeline-config.example.txt +++ b/pipeline-config.example.txt @@ -19,6 +19,16 @@ content_excerpt_max_chars = 4096 preserve_crawl_history = true crawl_stream_to_db = false crawl_exclude_urls = +crawl_discovery_mode = spider +crawl_url_list = +crawl_user_agent_preset = default +crawl_user_agent_custom = +crawl_auth_username = +crawl_auth_password = +crawl_extra_headers = +crawl_cookies = +crawl_robots_txt_override = +custom_extractors = # crawl_render_mode: static | javascript | auto crawl_render_mode = static crawl_js_concurrency = 3 @@ -47,6 +57,9 @@ max_image_probe_urls = 500 image_probe_concurrency = 6 image_probe_timeout = 8 image_unoptimized_min_kb = 200 +enable_subdomain_discovery = true +subdomain_ct_lookup = true +enable_rdap_org_lookup = true # --- Lighthouse --- lighthouse_url = @@ -57,6 +70,13 @@ lighthouse_iterations = 1 run_lighthouse = true run_lighthouse_on_pages = true enable_crux = false +enable_rich_results_validation = false +google_rich_results_api_key = +enable_axe = false +enable_spell_check = false +enable_html_validation = false +enable_amp_audit = false +enable_wayback_lookup = false competitor_domains = bing_webmaster_api_key = serp_api_key = diff --git a/requirements-optional.txt b/requirements-optional.txt new file mode 100644 index 00000000..488a3046 --- /dev/null +++ b/requirements-optional.txt @@ -0,0 +1,3 @@ +# Optional audit dependencies (install when enabling spell-check / HTML validation extras) +pyspellchecker>=0.8.1 +html5lib>=1.1 diff --git a/scripts/local-test.ps1 b/scripts/local-test.ps1 index a354c8fe..14b0e56f 100644 --- a/scripts/local-test.ps1 +++ b/scripts/local-test.ps1 @@ -245,7 +245,7 @@ function Invoke-PytestReporting { } function Invoke-PytestTools { - Write-Log "Pytest (tools coverage gate, 95%)" + Write-Log "Pytest (tools coverage gate, 100%)" & $VENV_PYTEST ` tests/test_alert_checker.py ` tests/test_schedule_runner.py ` @@ -265,10 +265,11 @@ function Invoke-PytestTools { tests/test_export_compare.py ` tests/test_mcp_registry.py ` tests/test_mcp_resources.py ` + tests/test_tools_branch_coverage.py ` --cov=website_profiling.tools ` --cov-config=.coveragerc.tools ` --cov-report=term-missing ` - --cov-fail-under=95 ` + --cov-fail-under=100 ` -q ` -o addopts= Assert-LastExitCode "Tools coverage gate failed" diff --git a/scripts/local-test.sh b/scripts/local-test.sh index 960683fb..a24022a8 100755 --- a/scripts/local-test.sh +++ b/scripts/local-test.sh @@ -119,10 +119,14 @@ run_pytest_reporting() { tests/test_categories_roadmap.py \ tests/test_report_categories_golden.py \ tests/test_categories_coverage.py \ + tests/test_contrast_issues.py \ tests/test_indexation_coverage.py \ tests/test_crawl_segments.py \ tests/test_terminology.py \ tests/test_compare_payload.py \ + tests/test_optional_audits.py \ + tests/test_property_profile.py \ + tests/test_reporting_gaps.py \ --cov=website_profiling.reporting \ --cov-config=.coveragerc.reporting \ --cov-report=term-missing \ @@ -133,7 +137,7 @@ run_pytest_reporting() { run_pytest_tools() { [[ "$PYTEST_NO_COV" -eq 1 ]] && return 0 - log "Pytest (tools coverage gate, 95%)" + log "Pytest (tools coverage gate, 100%)" "$VENV/bin/pytest" \ tests/test_alert_checker.py \ tests/test_schedule_runner.py \ @@ -143,6 +147,7 @@ run_pytest_tools() { tests/test_audit_tools_expanded.py \ tests/test_audit_tools_coverage.py \ tests/test_audit_tools_dispatch_coverage.py \ + tests/test_audit_tools_links_extras.py \ tests/test_export_custom_coverage.py \ tests/test_export_artifacts_coverage.py \ tests/test_export_compare_coverage.py \ @@ -151,12 +156,15 @@ run_pytest_tools() { tests/test_export_custom.py \ tests/test_export_artifacts.py \ tests/test_export_compare.py \ + tests/test_export_workbook.py \ + tests/test_export_sitemap.py \ tests/test_mcp_registry.py \ tests/test_mcp_resources.py \ + tests/test_tools_branch_coverage.py \ --cov=website_profiling.tools \ --cov-config=.coveragerc.tools \ --cov-report=term-missing \ - --cov-fail-under=95 \ + --cov-fail-under=100 \ -q \ -o addopts= } diff --git a/src/website_profiling/analysis/page.py b/src/website_profiling/analysis/page.py index 6ae3612e..e9185a97 100644 --- a/src/website_profiling/analysis/page.py +++ b/src/website_profiling/analysis/page.py @@ -85,6 +85,97 @@ def walk(obj: object) -> bool: return walk(data) +_CONTACT_ORG_TYPES = frozenset({ + "organization", + "localbusiness", + "corporation", + "store", + "restaurant", + "professionalService", + "newsmediaorganization", +}) +_CONTACT_CAP = 10 + + +def _normalize_type_name(raw: object) -> str: + if isinstance(raw, str): + return raw.strip() + if isinstance(raw, list) and raw: + return _normalize_type_name(raw[0]) + return "" + + +def _collect_json_ld_types(data: object, types: set[str]) -> None: + if isinstance(data, dict): + t = data.get("@type") + name = _normalize_type_name(t) + if name: + types.add(name) + graph = data.get("@graph") + if isinstance(graph, list): + for item in graph: + _collect_json_ld_types(item, types) + for key, val in data.items(): + if key in ("@graph", "@type"): + continue + if isinstance(val, (dict, list)): + _collect_json_ld_types(val, types) + elif isinstance(data, list): + for item in data: + _collect_json_ld_types(item, types) + + +def _format_postal_address(addr: object) -> str: + if isinstance(addr, str): + return addr.strip()[:500] + if not isinstance(addr, dict): + return "" + parts: list[str] = [] + for key in ("streetAddress", "addressLocality", "addressRegion", "postalCode", "addressCountry"): + val = addr.get(key) + if isinstance(val, str) and val.strip(): + parts.append(val.strip()) + return ", ".join(parts)[:500] + + +def _append_contact(signals: dict[str, list[str]], key: str, value: object) -> None: + if not isinstance(value, str): + return + val = value.strip() + if not val: + return + bucket = signals.setdefault(key, []) + if len(bucket) >= _CONTACT_CAP: + return + if val not in bucket: + bucket.append(val) + + +def _collect_json_ld_contacts(data: object, signals: dict[str, list[str]]) -> None: + if isinstance(data, dict): + type_name = _normalize_type_name(data.get("@type")).lower() + is_org = any(t in type_name for t in _CONTACT_ORG_TYPES) or type_name in _CONTACT_ORG_TYPES + if is_org: + _append_contact(signals, "organization_names", data.get("name")) + _append_contact(signals, "emails", data.get("email")) + _append_contact(signals, "phones", data.get("telephone")) + addr = _format_postal_address(data.get("address")) + if addr: + _append_contact(signals, "addresses", addr) + graph = data.get("@graph") + if isinstance(graph, list): + for item in graph: + _collect_json_ld_contacts(item, signals) + for key, val in data.items(): + if key in ("@graph",): + continue + if isinstance(val, (dict, list)): + _collect_json_ld_contacts(val, signals) + elif isinstance(data, list): + for item in data: + _collect_json_ld_contacts(item, signals) + + def analyze_html( html: str, page_url: str, @@ -322,6 +413,14 @@ def _script_in_head(sc) -> bool: image_urls.append(url) out["image_urls"] = _cap(image_urls) + json_ld_types: set[str] = set() + contact_signals: dict[str, list[str]] = { + "emails": [], + "phones": [], + "addresses": [], + "organization_names": [], + } + # JSON-LD for idx, sc in enumerate(soup.find_all("script", type=lambda t: t and "ld+json" in str(t).lower())): raw = (sc.string or "").strip() @@ -332,6 +431,8 @@ def _script_in_head(sc) -> bool: except json.JSONDecodeError: warn("json_ld_parse", "medium", "Invalid JSON-LD block", f"Block index {idx}") continue + _collect_json_ld_types(data, json_ld_types) + _collect_json_ld_contacts(data, contact_signals) if _json_ld_missing_type(data): warn( "json_ld_missing_type", @@ -341,6 +442,20 @@ def _script_in_head(sc) -> bool: ) break + if json_ld_types: + out["json_ld_types"] = sorted(json_ld_types)[:_CONTACT_CAP] + + for a in soup.find_all("a", href=True): + href = (a.get("href") or "").strip() + lower = href.lower() + if lower.startswith("mailto:"): + _append_contact(contact_signals, "emails", href[7:].split("?")[0]) + elif lower.startswith("tel:"): + _append_contact(contact_signals, "phones", href[4:].split("?")[0]) + + if any(contact_signals[k] for k in contact_signals): + out["contact_signals"] = contact_signals + # Empty anchors empty_anchors = 0 for a in soup.find_all("a", href=True): @@ -363,5 +478,21 @@ def _script_in_head(sc) -> bool: "Use