Hello world again.
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9a4389d5..134057fa 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -43,6 +43,8 @@ jobs: 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 \ + tests/test_text_content_analysis.py tests/test_builder_image_buckets.py \ + tests/test_pipeline_report_pool_unit.py tests/test_reporting_builder_modules.py \ --cov=website_profiling.reporting --cov-config=.coveragerc.reporting \ --cov-report=term-missing --cov-fail-under=100 -q -o addopts= - name: Pytest (tools coverage gate) @@ -51,11 +53,13 @@ jobs: 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_audit_tools_links_extras.py tests/test_audit_tools_expansion.py \ - tests/test_audit_tools_expansion_coverage.py tests/test_export_custom_coverage.py \ + tests/test_audit_tools_expansion_coverage.py tests/test_audit_tools_batch100_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_export_workbook.py \ - tests/test_export_sitemap.py tests/test_mcp_registry.py tests/test_mcp_resources.py \ + tests/test_export_sitemap.py tests/test_mcp_registry.py tests/test_mcp_resources.py \ + tests/test_router_tools.py tests/test_tool_selector.py \ + tests/test_tools_gate100_coverage.py \ tests/test_tools_branch_coverage.py \ --cov=website_profiling.tools --cov-config=.coveragerc.tools \ --cov-report=term-missing --cov-fail-under=100 -q -o addopts= diff --git a/AGENT.md b/AGENT.md index e59a6fa9..ec06cbe5 100644 --- a/AGENT.md +++ b/AGENT.md @@ -25,7 +25,7 @@ - **Pipeline data** (crawl, edges, nodes, report payload, Lighthouse, keywords, warnings) is stored in **PostgreSQL only** — no JSON/CSV/HTML exports from the main pipeline. - **Pool tuning:** `DB_POOL_MIN` / `DB_POOL_MAX` (Python), `PGPOOL_MAX` (Node). Bulk crawl writes via `executemany`; optional **`crawl_stream_to_db`** streams rows during fetch. - **`web/`:** `/api/report/*` (PostgreSQL); `/api/run` spawns Python (localhost only); `/api/crawl/browser-status` GET (localhost, Playwright/Chromium preflight); `/api/pipeline-config` GET/PUT; `/api/llm-config` GET/PUT (AI only); `/api/chat` POST (SSE agent); `/api/chat/sessions` GET/POST; `/api/properties/{id}/google/links/import` POST (GSC Links CSV); `PipelineRunnerFab` saves pipeline + LLM state before each run -- **MCP:** `python -m website_profiling.mcp` (stdio, **221 read-only audit tools** + MCP resources). See `docs/MCP.md`. Requires `pip install -r requirements.txt`. +- **MCP:** `python -m website_profiling.mcp` (stdio, **340 read-only audit tools**, domain-scoped via `WP_MCP_DOMAIN`). See `docs/MCP.md`. Requires `pip install -r requirements.txt`. - **AI Chat UI:** `/chat` — property-scoped chat with saved sessions (`chat_sessions`, `chat_messages` tables, migration `012_chat_sessions`). - **Job store:** in-memory on `globalThis` in `web/src/server/pipelineJobs.ts` — job status/log is lost on server restart (single-process dev/Docker only). - **Docker:** `Dockerfile` + `docker-compose.yml` (postgres + web); **`docker-compose.pull.yml`** for pre-built images (`WEB_IMAGE`); **`LIGHTHOUSE_CHROME_FLAGS`** diff --git a/README.md b/README.md index d531cd4f..9059ecc6 100644 --- a/README.md +++ b/README.md @@ -73,7 +73,7 @@ Repository: [codefrydev/WebsiteProfiling](https://github.com/codefrydev/WebsiteP -Also included: **AI chat** over audit data (optional), **221 MCP tools**, keyword explorer, backlinks, compare runs, and portfolio management for agencies. +Also included: **AI chat** over audit data (optional), **340 MCP tools** (domain-scoped servers), keyword explorer, backlinks, compare runs, and portfolio management for agencies.
@@ -91,7 +91,7 @@ WebsiteProfiling/
│ ├── integrations/ # Google Search Console, GA4, Bing, CrUX
│ ├── llm/ # AI enrich + chat agent
│ ├── tools/ # Exports, audit query tools, MCP helpers
-│ ├── mcp/ # MCP server (221 read-only tools)
+│ ├── mcp/ # MCP server (340 read-only tools, domain bundles)
│ ├── db/ # PostgreSQL storage layer
│ ├── commands/ # CLI subcommands
│ ├── cli.py # Pipeline entrypoint
@@ -147,6 +147,10 @@ Open [http://localhost:3000/home](http://localhost:3000/home).
./local-run stop # stop Postgres container
```
+`requirements.txt` pins direct Python dependencies to versions verified by `./local-test python`. Re-run the full test suite after intentional upgrades.
+
+Pipeline jobs: stuck `running` rows are reconciled after **1 hour** by default (`PIPELINE_JOB_STALE_HOURS`). Orphan jobs with no live server process are cleared after **5 minutes** (`PIPELINE_JOB_ORPHAN_MINUTES`). Increase `PIPELINE_JOB_STALE_HOURS` for crawls that routinely run longer than an hour.
+
**Tests**
```bash
@@ -182,7 +186,7 @@ Google Search Console / Analytics: connect via **Integrations** (gear icon) in t
| **Ollama** | Local daemon at `http://127.0.0.1:11434`. Chat UI lists installed models plus the live Ollama cloud catalog (billing: free local, account free tier, Pro). Native tool calling when supported; otherwise ReAct fallback. Pick the model in-chat without leaving the page. |
| **OpenAI** / **Anthropic** | API key in AI settings; native tool calling with streaming. |
-The agent uses the same **221 read-only audit tools** as the MCP server (`docs/MCP.md`). Responses stream over SSE (`POST /api/chat`) with status, tool activity, and tokens. Sessions are saved per property (`chat_sessions` / `chat_messages`).
+The agent uses the same **340 read-only audit tools** as the MCP server (`docs/MCP.md`), with **dynamic routing** (~45 tools per turn plus router meta-tools). Responses stream over SSE (`POST /api/chat`) with status, tool activity, and tokens. Sessions are saved per property (`chat_sessions` / `chat_messages`).
Production: `docker-compose.prod.yml` (set `POSTGRES_PASSWORD`, `AUTH_SECRET`).
diff --git a/alembic/versions/014_pipeline_job_log_truncated.py b/alembic/versions/014_pipeline_job_log_truncated.py
new file mode 100644
index 00000000..55cb6ec1
--- /dev/null
+++ b/alembic/versions/014_pipeline_job_log_truncated.py
@@ -0,0 +1,27 @@
+"""Add log_truncated flag to pipeline_jobs.
+
+Revision ID: 014_pipeline_log_truncated
+Revises: 013_crawl_discovery_edges
+"""
+from __future__ import annotations
+
+from alembic import op
+
+revision = "014_pipeline_log_truncated"
+down_revision = "013_crawl_discovery_edges"
+branch_labels = None
+depends_on = None
+
+
+def upgrade() -> None:
+ op.execute("""
+ ALTER TABLE pipeline_jobs
+ ADD COLUMN IF NOT EXISTS log_truncated BOOLEAN NOT NULL DEFAULT false;
+ """)
+
+
+def downgrade() -> None:
+ op.execute("""
+ ALTER TABLE pipeline_jobs
+ DROP COLUMN IF EXISTS log_truncated;
+ """)
diff --git a/alembic/versions/015_crawl_page_html.py b/alembic/versions/015_crawl_page_html.py
new file mode 100644
index 00000000..8c894ad8
--- /dev/null
+++ b/alembic/versions/015_crawl_page_html.py
@@ -0,0 +1,38 @@
+"""Add crawl_page_html table for per-URL raw HTML storage.
+
+Revision ID: 015_crawl_page_html
+Revises: 014_pipeline_log_truncated
+"""
+from __future__ import annotations
+
+from alembic import op
+
+revision = "015_crawl_page_html"
+down_revision = "014_pipeline_log_truncated"
+branch_labels = None
+depends_on = None
+
+
+def upgrade() -> None:
+ op.execute("""
+ CREATE TABLE crawl_page_html (
+ crawl_run_id BIGINT NOT NULL REFERENCES crawl_runs(id) ON DELETE CASCADE,
+ url TEXT NOT NULL,
+ html TEXT NOT NULL,
+ status TEXT,
+ content_type TEXT,
+ fetch_method TEXT NOT NULL DEFAULT 'static',
+ byte_length INTEGER NOT NULL DEFAULT 0,
+ captured_at TIMESTAMPTZ NOT NULL DEFAULT now(),
+ PRIMARY KEY (crawl_run_id, url)
+ );
+ CREATE INDEX IF NOT EXISTS idx_crawl_page_html_run
+ ON crawl_page_html (crawl_run_id);
+ """)
+
+
+def downgrade() -> None:
+ op.execute("""
+ DROP INDEX IF EXISTS idx_crawl_page_html_run;
+ DROP TABLE IF EXISTS crawl_page_html;
+ """)
diff --git a/docs/MCP.md b/docs/MCP.md
index 2458bfa0..b39b8bab 100644
--- a/docs/MCP.md
+++ b/docs/MCP.md
@@ -12,8 +12,47 @@ export PYTHONPATH=src
## Cursor configuration
+Site Audit exposes **domain-scoped MCP servers** (like Cursor optional plugins). Connect only the bundles you need instead of loading all 340 tools in one server.
+
+| `WP_MCP_DOMAIN` | Typical tools | Use when |
+|-----------------|---------------|----------|
+| `core` (default) | Router, workflows, insight (~12) | General chat, tool search, coverage |
+| `crawl` | Crawl, on-page, schema, accessibility | Technical crawl audits |
+| `google` | Google, insight, CTR, keywords | GSC/GA4 analysis |
+| `links` | Links, backlinks, indexation | Link architecture |
+| `full` | All 340 tools | Debugging / legacy single-server setup |
+
Add to `.cursor/mcp.json` (or Cursor MCP settings):
+```json
+{
+ "mcpServers": {
+ "site-audit-core": {
+ "command": "python",
+ "args": ["-m", "website_profiling.mcp"],
+ "env": {
+ "DATABASE_URL": "postgres://profiling:profiling@localhost:5432/website_profiling",
+ "PYTHONPATH": "src",
+ "WP_MCP_DOMAIN": "core",
+ "WP_PROPERTY_ID": "1"
+ }
+ },
+ "site-audit-google": {
+ "command": "python",
+ "args": ["-m", "website_profiling.mcp"],
+ "env": {
+ "DATABASE_URL": "postgres://profiling:profiling@localhost:5432/website_profiling",
+ "PYTHONPATH": "src",
+ "WP_MCP_DOMAIN": "google",
+ "WP_PROPERTY_ID": "1"
+ }
+ }
+ }
+}
+```
+
+Single-server legacy setup (all tools):
+
```json
{
"mcpServers": {
@@ -23,6 +62,7 @@ Add to `.cursor/mcp.json` (or Cursor MCP settings):
"env": {
"DATABASE_URL": "postgres://profiling:profiling@localhost:5432/website_profiling",
"PYTHONPATH": "src",
+ "WP_MCP_DOMAIN": "full",
"WP_PROPERTY_ID": "1"
}
}
@@ -41,9 +81,14 @@ Add to `.cursor/mcp.json` (or Cursor MCP settings):
| `audit://property/{id}/report/latest` | Payload key index (counts, not full blob) |
| `audit://property/{id}/report/{report_id}` | Payload key index for a specific report |
| `audit://glossary` | Excerpt from `docs/GLOSSARY.md` |
-| `audit://tools` | Tool catalog grouped by SEO domain |
+| `audit://tools` | Tool catalog for the connected `WP_MCP_DOMAIN` server |
+| `audit://domains` | Available MCP domain bundles and tool groupings |
-## Tools (221 read-only + export)
+## Tools (340 read-only + export)
+
+### Router and insight (Tier 0 — `WP_MCP_DOMAIN=core`)
+
+`search_audit_tools`, `list_tool_domains`, `get_data_coverage_report`, `run_insight_workflow`, `run_technical_workflow`, `run_keyword_workflow`, `run_domain_agent`, `get_landing_page_blended_table`, `get_opportunity_matrix`, `get_traffic_health_check`, `get_landing_page_full_diagnosis`, `get_issue_to_traffic_map`
### Export and deliverables
@@ -99,11 +144,11 @@ Size-based tools require `probe_image_inventory=true` in pipeline config when bu
### Keywords
-`get_keyword_summary`, `search_keywords`, `get_striking_distance_keywords`, `get_keyword_cannibalisation`, `get_query_page_misalignment`, `get_semantic_keyword_clusters`, `get_keyword_history`, `get_keyword_serp_overlay`, `get_serp_feature_overlay`, `list_keywords_by_action`, `list_keywords_by_position`, `list_keywords_by_impressions`, `list_keywords_ctr_opportunity`, `expand_keywords`, `generate_content_brief`
+`get_keyword_summary`, `search_keywords`, `get_striking_distance_keywords`, `get_keyword_cannibalisation`, `get_query_page_misalignment`, `get_semantic_keyword_clusters`, `get_keyword_history`, `get_keyword_serp_overlay`, `get_serp_feature_overlay`, `list_keywords_by_action`, `list_keywords_by_position`, `list_keywords_by_impressions`, `list_keywords_ctr_opportunity`, `expand_keywords`, `generate_content_brief`, `get_brand_keyword_split`, `list_keywords_by_intent`
### Google and CTR
-`get_google_summary`, `get_google_integration_status`, `get_gsc_top_queries`, `get_gsc_top_pages`, `get_gsc_ctr_opportunity_pages`, `get_ga4_summary`, `get_ga4_page_metrics`, `get_gsc_page_query_slice`, `get_gsc_url_inspection`, `get_gsc_index_coverage`, `analyze_serp_snippet_for_url`
+`get_google_summary`, `get_google_integration_status`, `get_gsc_top_queries`, `get_gsc_top_pages`, `get_gsc_ctr_opportunity_pages`, `get_ga4_summary`, `get_ga4_page_metrics`, `get_gsc_page_query_slice`, `get_gsc_url_inspection`, `get_gsc_index_coverage`, `analyze_serp_snippet_for_url`, `get_gsc_daily_trend`, `get_ga4_daily_trend`, `get_ga4_by_device`, `get_ga4_by_channel`, `get_gsc_page_queries`
### Backlinks
@@ -163,6 +208,8 @@ Already available: `validate_rich_results`, `get_gsc_url_inspection`, `export_si
The same tools power **AI Chat** at [http://localhost:3000/chat](http://localhost:3000/chat). Enable AI in Run audit → AI settings.
+In-app chat uses **dynamic tool routing**: each turn loads Tier 0 router tools plus a domain-scoped subset (~45 tools), not the full catalog. Set `CHAT_TOOL_MODE=full` to load all tools for debugging.
+
## Ollama note
When the local Ollama daemon supports native tools (most current models, including Ollama cloud refs like `minimax-m3:cloud`), chat uses Ollama’s `/api/chat` tool format. Older or tool-less models fall back to JSON ReAct parsing. OpenAI and Anthropic always use native tool calling with streaming in the chat UI.
diff --git a/input.txt.example b/input.txt.example
index a6a9c56e..7494b553 100644
--- a/input.txt.example
+++ b/input.txt.example
@@ -15,6 +15,11 @@ allow_external = false
store_outlinks = true
store_content_excerpt = true
content_excerpt_max_chars = 4096
+store_page_html = false
+max_stored_html_bytes = 2097152
+run_content_analysis = false
+content_analysis_strategy = main_only
+content_analysis_workers = 4
preserve_crawl_history = true
crawl_stream_to_db = false
crawl_exclude_urls =
diff --git a/pipeline-config.example.txt b/pipeline-config.example.txt
index a43055d3..d1957b9e 100644
--- a/pipeline-config.example.txt
+++ b/pipeline-config.example.txt
@@ -16,6 +16,11 @@ allow_external = false
store_outlinks = true
store_content_excerpt = true
content_excerpt_max_chars = 4096
+store_page_html = false
+max_stored_html_bytes = 2097152
+run_content_analysis = false
+content_analysis_strategy = main_only
+content_analysis_workers = 4
preserve_crawl_history = true
crawl_stream_to_db = false
crawl_exclude_urls =
diff --git a/requirements.txt b/requirements.txt
index 9138a6e1..002458fe 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -1,21 +1,21 @@
-requests>=2.28.0
-beautifulsoup4>=4.11.0
-lxml>=4.9.0
-pandas>=1.5.0
-tqdm>=4.64.0
-networkx>=2.8.0
-python-Wappalyzer>=0.3.1
+requests==2.34.2
+beautifulsoup4==4.14.3
+lxml==6.1.1
+pandas==3.0.3
+tqdm==4.67.3
+networkx==3.6.1
+python-Wappalyzer==0.3.1
# Local content analysis (duplicates, language)
-rapidfuzz>=3.0.0
-langdetect>=1.0.9
+rapidfuzz==3.14.5
+langdetect==1.0.9
# Google Search Console + GA4 integration (optional; required for `python -m src google`)
-google-auth>=2.0.0
-google-auth-oauthlib>=1.0.0
-google-api-python-client>=2.0.0
-google-analytics-data>=0.18.0
-google-analytics-admin>=0.22.0
+google-auth==2.53.0
+google-auth-oauthlib==1.4.0
+google-api-python-client==2.197.0
+google-analytics-data==0.23.0
+google-analytics-admin==0.30.0
# Keywords Explorer — Google Suggest + Wikipedia + Datamuse (all free, no auth needed)
# requests already listed above
@@ -23,28 +23,28 @@ google-analytics-admin>=0.22.0
# pytrends>=4.9,<5
# PostgreSQL
-psycopg[binary,pool]>=3.2
-sqlalchemy>=2.0.0
-alembic>=1.13
+psycopg[binary,pool]==3.3.4
+sqlalchemy==2.0.50
+alembic==1.18.4
# Audit export (PDF)
-reportlab>=4.0.0
+reportlab==4.5.1
# JavaScript rendering crawl (headless Chromium via Playwright)
-playwright>=1.49.0
+playwright==1.60.0
# LLM providers for AI enrichment (configure via web UI AI tab)
-httpx>=0.27.0
-openai>=1.0.0
-anthropic>=0.25.0
+httpx==0.28.1
+openai==2.41.0
+anthropic==0.107.0
# Spell-check / HTML validation extras
-pyspellchecker>=0.8.1
-html5lib>=1.1
+pyspellchecker==0.9.0
+html5lib==1.1
# MCP server for Cursor / Claude Desktop
-mcp>=1.0.0
+mcp~=1.0.0
# Dev / test
-pytest>=7.0.0
-pytest-cov>=5.0.0
+pytest==9.0.3
+pytest-cov==7.1.0
diff --git a/scripts/local-test.ps1 b/scripts/local-test.ps1
index ef9c7297..adc865d2 100644
--- a/scripts/local-test.ps1
+++ b/scripts/local-test.ps1
@@ -237,6 +237,10 @@ function Invoke-PytestReporting {
tests/test_optional_audits.py `
tests/test_property_profile.py `
tests/test_reporting_gaps.py `
+ tests/test_text_content_analysis.py `
+ tests/test_builder_image_buckets.py `
+ tests/test_pipeline_report_pool_unit.py `
+ tests/test_reporting_builder_modules.py `
--cov=website_profiling.reporting `
--cov-config=.coveragerc.reporting `
--cov-report=term-missing `
@@ -272,6 +276,7 @@ function Invoke-PytestTools {
tests/test_export_sitemap.py `
tests/test_mcp_registry.py `
tests/test_mcp_resources.py `
+ tests/test_tools_gate100_coverage.py `
tests/test_tools_branch_coverage.py `
--cov=website_profiling.tools `
--cov-config=.coveragerc.tools `
diff --git a/scripts/local-test.sh b/scripts/local-test.sh
index 3d32f7e5..357f1d53 100755
--- a/scripts/local-test.sh
+++ b/scripts/local-test.sh
@@ -126,6 +126,10 @@ run_pytest_reporting() {
tests/test_optional_audits.py \
tests/test_property_profile.py \
tests/test_reporting_gaps.py \
+ tests/test_text_content_analysis.py \
+ tests/test_builder_image_buckets.py \
+ tests/test_pipeline_report_pool_unit.py \
+ tests/test_reporting_builder_modules.py \
--cov=website_profiling.reporting \
--cov-config=.coveragerc.reporting \
--cov-report=term-missing \
@@ -149,6 +153,7 @@ run_pytest_tools() {
tests/test_audit_tools_links_extras.py \
tests/test_audit_tools_expansion.py \
tests/test_audit_tools_expansion_coverage.py \
+ tests/test_audit_tools_batch100_coverage.py \
tests/test_export_custom_coverage.py \
tests/test_export_artifacts_coverage.py \
tests/test_export_compare_coverage.py \
@@ -161,6 +166,9 @@ run_pytest_tools() {
tests/test_export_sitemap.py \
tests/test_mcp_registry.py \
tests/test_mcp_resources.py \
+ tests/test_router_tools.py \
+ tests/test_tool_selector.py \
+ tests/test_tools_gate100_coverage.py \
tests/test_tools_branch_coverage.py \
--cov=website_profiling.tools \
--cov-config=.coveragerc.tools \
diff --git a/src/website_profiling/analysis/log_parser.py b/src/website_profiling/analysis/log_parser.py
index 63999bc0..0ec4d519 100644
--- a/src/website_profiling/analysis/log_parser.py
+++ b/src/website_profiling/analysis/log_parser.py
@@ -15,6 +15,8 @@ def parse_access_log_lines(lines: list[str]) -> dict[str, Any]:
"""Return hit counts and URL sets from access log lines."""
url_hits: Counter[str] = Counter()
status_hits: Counter[str] = Counter()
+ paths_5xx: Counter[str] = Counter()
+ googlebot_path_hits: Counter[str] = Counter()
googlebot_hits = 0
parsed_lines = 0
@@ -29,16 +31,23 @@ def parse_access_log_lines(lines: list[str]) -> dict[str, Any]:
path, status, ua = m.group(1), m.group(2), m.group(3).lower()
url_hits[path] += 1
status_hits[status] += 1
+ if status.startswith("5"):
+ paths_5xx[path] += 1
if "googlebot" in ua:
googlebot_hits += 1
+ googlebot_path_hits[path] += 1
top_urls = [{"path": p, "hits": c} for p, c in url_hits.most_common(100)]
+ paths_5xx_rows = [{"path": p, "hits": c} for p, c in paths_5xx.most_common(100)]
+ googlebot_paths = [{"path": p, "hits": c} for p, c in googlebot_path_hits.most_common(100)]
return {
"parsed_lines": parsed_lines,
"unique_paths": len(url_hits),
"googlebot_hits": googlebot_hits,
"status_counts": dict(status_hits),
"top_paths": top_urls,
+ "paths_5xx": paths_5xx_rows,
+ "googlebot_paths": googlebot_paths,
}
diff --git a/src/website_profiling/commands/config_resolve.py b/src/website_profiling/commands/config_resolve.py
index 690a8a20..5468b51d 100644
--- a/src/website_profiling/commands/config_resolve.py
+++ b/src/website_profiling/commands/config_resolve.py
@@ -268,6 +268,7 @@ def build_parser() -> argparse.ArgumentParser:
nargs="?",
choices=[
"crawl",
+ "content_analysis",
"report",
"plot",
"lighthouse",
diff --git a/src/website_profiling/commands/pipeline_cmd.py b/src/website_profiling/commands/pipeline_cmd.py
index 24c00679..d50078fa 100644
--- a/src/website_profiling/commands/pipeline_cmd.py
+++ b/src/website_profiling/commands/pipeline_cmd.py
@@ -125,6 +125,10 @@ def run(cfg: dict, args: argparse.Namespace) -> None:
use_database = True
run_crawl = args.command == "crawl" or (args.command is None and get_bool(cfg, "run_crawl", True))
+ run_content_analysis = (
+ args.command == "content_analysis"
+ or (args.command is None and get_bool(cfg, "run_content_analysis", False))
+ )
run_report = args.command == "report" or (args.command is None and get_bool(cfg, "run_report", True))
run_plot = args.command == "plot" or (args.command is None and get_bool(cfg, "run_plot", False))
run_lighthouse = args.command is None and get_bool(cfg, "run_lighthouse", False)
@@ -132,12 +136,14 @@ def run(cfg: dict, args: argparse.Namespace) -> None:
lighthouse_max_pages = _cfg_int(cfg, "lighthouse_max_pages", 20)
if args.command is None and (
- run_crawl or run_lighthouse or run_lighthouse_on_pages or run_report or run_plot
+ run_crawl or run_content_analysis or run_lighthouse or run_lighthouse_on_pages or run_report or run_plot
):
emit_phase_start("config", message="Resolving pipeline configuration")
steps = []
if run_crawl:
steps.append("crawl")
+ if run_content_analysis:
+ steps.append("content-analysis")
if run_lighthouse_on_pages:
steps.append("lighthouse-on-pages")
elif run_lighthouse:
@@ -154,6 +160,11 @@ def run(cfg: dict, args: argparse.Namespace) -> None:
if run_crawl:
phase_results.append(run_pipeline_phase("crawl", lambda: _run_crawl(cfg, use_database)))
+ if run_content_analysis and use_database:
+ phase_results.append(
+ run_pipeline_phase("content_analysis", lambda: _run_content_analysis(cfg, use_database))
+ )
+
if run_lighthouse_on_pages and use_database:
phase_results.append(
run_pipeline_phase(
@@ -215,6 +226,11 @@ def _run_crawl(cfg: dict, use_database: bool) -> None:
preserve_crawl_history = get_bool(cfg, "preserve_crawl_history", True)
store_content_excerpt = get_bool(cfg, "store_content_excerpt", False)
content_excerpt_max_chars = _cfg_int(cfg, "content_excerpt_max_chars", 4096)
+ store_page_html = get_bool(cfg, "store_page_html", False)
+ max_stored_html_bytes = _cfg_int(cfg, "max_stored_html_bytes", 2_097_152)
+ run_content_analysis = get_bool(cfg, "run_content_analysis", False)
+ content_analysis_strategy = (cfg.get("content_analysis_strategy") or "main_only").strip()
+ content_analysis_workers = _cfg_int(cfg, "content_analysis_workers", 4)
crawl_stream_to_db = get_bool(cfg, "crawl_stream_to_db", False)
property_id = active_property_id_from_cfg(cfg)
render_mode = _normalize_render_mode(cfg)
@@ -258,6 +274,11 @@ def _run_crawl(cfg: dict, use_database: bool) -> None:
preserve_crawl_history=preserve_crawl_history,
store_content_excerpt=store_content_excerpt,
content_excerpt_max_chars=content_excerpt_max_chars,
+ store_page_html=store_page_html,
+ max_stored_html_bytes=max_stored_html_bytes,
+ run_content_analysis=run_content_analysis,
+ content_analysis_strategy=content_analysis_strategy,
+ content_analysis_workers=content_analysis_workers,
crawl_stream_to_db=crawl_stream_to_db,
property_id=property_id,
render_mode=render_mode,
@@ -289,6 +310,33 @@ def _run_crawl(cfg: dict, use_database: bool) -> None:
console_print("Crawl results: PostgreSQL")
+def _run_content_analysis(cfg: dict, use_database: bool) -> None:
+ if not use_database:
+ console_print("[Content analysis] Skipped (database required).", flush=True)
+ return
+ if not get_bool(cfg, "store_page_html", False):
+ console_print(
+ "[Content analysis] Skipped: enable store_page_html to persist HTML for analysis.",
+ flush=True,
+ )
+ return
+
+ from ..content_analysis import run_content_analysis
+
+ store_content_excerpt = get_bool(cfg, "store_content_excerpt", False)
+ excerpt_max = _cfg_int(cfg, "content_excerpt_max_chars", 4096)
+ strategy = (cfg.get("content_analysis_strategy") or "main_only").strip().lower()
+ workers = _cfg_int(cfg, "content_analysis_workers", 4)
+
+ console_print("[Content analysis] Starting...", flush=True)
+ run_content_analysis(
+ excerpt_max_chars=excerpt_max if store_content_excerpt else 0,
+ strategy=strategy,
+ workers=workers,
+ )
+ console_print("[Content analysis] Done.", flush=True)
+
+
def _run_lighthouse_on_pages(cfg: dict, lighthouse_max_pages: int) -> None:
from ..db import db_session, get_latest_crawl_run_id, read_crawl
from ..lighthouse.runner import run_lighthouse_on_pages as do_lighthouse_on_pages
diff --git a/src/website_profiling/common.py b/src/website_profiling/common.py
index b19c528b..899ebe09 100644
--- a/src/website_profiling/common.py
+++ b/src/website_profiling/common.py
@@ -1,575 +1,57 @@
"""
-Shared helpers for crawler and report/plot scripts.
+Shared helpers for crawler and report/plot scripts (re-export facade).
"""
-import json
-import os
-import warnings
-from urllib.parse import urljoin, urldefrag, urlparse
-import urllib.robotparser as robotparser
-import ast
-import math
-
-import pandas as pd
-from bs4 import BeautifulSoup
-
-
-def load_dataframe(path: str) -> pd.DataFrame:
- """Load a DataFrame from CSV or JSON (by extension)."""
- if not os.path.isfile(path):
- raise FileNotFoundError(path)
- path_lower = path.lower()
- if path_lower.endswith(".json"):
- return pd.read_json(path, orient="records")
- return pd.read_csv(path)
-
-
-def save_dataframe(df: pd.DataFrame, path: str) -> None:
- """Save a DataFrame to CSV or JSON (by extension). Uses default_handler for JSON to avoid numpy types."""
- path_lower = path.lower()
- if path_lower.endswith(".json"):
- df.to_json(path, orient="records", indent=2, date_format="iso", default_handler=str)
- else:
- df.to_csv(path, index=False)
-
-
-def load_edges(path: str) -> list[tuple[str, str]]:
- """Load edge list from CSV or JSON (by extension). Returns list of (from_url, to_url)."""
- if not os.path.isfile(path):
- return []
- path_lower = path.lower()
- try:
- if path_lower.endswith(".json"):
- with open(path, "r", encoding="utf-8") as f:
- data = json.load(f)
- if isinstance(data, list) and data and isinstance(data[0], dict):
- return [(str(o.get("from", "")), str(o.get("to", ""))) for o in data if o.get("from") and o.get("to")]
- return []
- edf = pd.read_csv(path)
- if {"from", "to"}.issubset(edf.columns):
- return [(str(a).rstrip("/"), str(b).rstrip("/")) for a, b in edf[["from", "to"]].values]
- except Exception:
- pass
- return []
-
-
-def save_edges(edges: list[tuple[str, str]], path: str) -> None:
- """Save edge list to CSV or JSON (by extension)."""
- path_lower = path.lower()
- if path_lower.endswith(".json"):
- data = [{"from": a, "to": b} for a, b in edges]
- with open(path, "w", encoding="utf-8") as f:
- json.dump(data, f, indent=2)
- else:
- pd.DataFrame(edges, columns=["from", "to"]).to_csv(path, index=False)
-
-
-_TRACKING_PARAM_PREFIXES = ("utm_",)
-_FACET_PARAM_NAMES = frozenset({"sort", "filter", "page", "offset", "limit"})
-
-
-def strip_crawl_query_params(url: str, ignore_params: list[str] | None = None) -> str:
- """Remove tracking and facet query params for crawl deduplication."""
- parsed = urlparse(url)
- if not parsed.query:
- return url.rstrip("/")
- ignore = {p.lower() for p in (ignore_params or [])}
- parts = []
- for pair in parsed.query.split("&"):
- if not pair:
- continue
- key = pair.split("=", 1)[0].lower()
- if key in ignore:
- continue
- if any(key.startswith(p) for p in _TRACKING_PARAM_PREFIXES):
- continue
- if key in _FACET_PARAM_NAMES:
- continue
- parts.append(pair)
- query = "&".join(parts)
- rebuilt = parsed._replace(query=query).geturl()
- return rebuilt.rstrip("/")
-
-
-def normalize_link(
- base: str,
- href: str,
- strip_params: bool = True,
- ignore_params: list[str] | None = None,
-) -> str | None:
- if not href:
- return None
- href = href.strip()
- if href.startswith(("mailto:", "javascript:", "tel:", "data:")):
- return None
- joined = urljoin(base, href)
- joined, _ = urldefrag(joined)
- parsed = urlparse(joined)
- if parsed.scheme not in ("http", "https"):
- return None
- out = joined.rstrip("/")
- if strip_params:
- out = strip_crawl_query_params(out, ignore_params)
- return out
-
-
-def _parse_rel_flags(rel_raw: str) -> tuple[bool, bool, bool]:
- parts = {p.strip().lower() for p in (rel_raw or "").split() if p.strip()}
- return ("nofollow" in parts, "sponsored" in parts, "ugc" in parts)
-
-
-def _anchor_text_from_tag(a) -> str:
- parts: list[str] = []
- for child in a.children:
- if getattr(child, "name", None) == "img":
- parts.append("[image]")
- elif isinstance(child, str):
- t = child.strip()
- if t:
- parts.append(t)
- text = " ".join(parts).strip() or a.get_text(separator=" ", strip=True)
- return (text or "")[:500]
-
-
-def parse_link_edges(base_url: str, html_text: str) -> tuple[str, list[dict]]:
- """Extract title and rich outbound link records from HTML."""
- soup = BeautifulSoup(html_text, "lxml")
- title_tag = (
- soup.title.string.strip()
- if soup.title and soup.title.string
- else ""
- )
- start_netloc = urlparse(base_url).netloc
- edges: list[dict] = []
- for a in soup.find_all("a", href=True):
- ln = normalize_link(base_url, a["href"])
- if not ln:
- continue
- rel_raw = a.get("rel") or ""
- if isinstance(rel_raw, list):
- rel_str = " ".join(str(x) for x in rel_raw)
- else:
- rel_str = str(rel_raw)
- nofollow, sponsored, ugc = _parse_rel_flags(rel_str)
- link_type = "internal" if urlparse(ln).netloc == start_netloc else "external"
- edges.append({
- "to_url": ln.rstrip("/"),
- "anchor_text": _anchor_text_from_tag(a),
- "rel": rel_str.strip(),
- "is_nofollow": nofollow,
- "is_sponsored": sponsored,
- "is_ugc": ugc,
- "link_type": link_type,
- })
- return title_tag, edges
-
-
-def parse_links(base_url: str, html_text: str) -> tuple[str, set[str]]:
- """Extract page title and set of absolute links from HTML. Returns (title, links)."""
- title, edges = parse_link_edges(base_url, html_text)
- return title, {e["to_url"] for e in edges}
-
-
-def parse_seo(base_url: str, html_text: str) -> tuple[str, int, str, int, str]:
- """
- Extract SEO-related fields from HTML.
- Returns (meta_description, meta_description_len, h1_text, h1_count, canonical_url).
- """
- soup = BeautifulSoup(html_text, "lxml")
- meta_desc = ""
- meta = soup.find("meta", attrs={"name": "description"})
- if meta and meta.get("content"):
- meta_desc = (meta["content"] or "").strip()
- if not meta_desc:
- og = soup.find("meta", attrs={"property": "og:description"})
- if og and og.get("content"):
- meta_desc = (og["content"] or "").strip()
- meta_desc_len = len(meta_desc)
-
- h1_tags = soup.find_all("h1")
- h1_count = len(h1_tags)
- h1_text = (h1_tags[0].get_text(separator=" ", strip=True) if h1_tags else "") or ""
-
- canonical_url = ""
- link_canonical = soup.find("link", attrs={"rel": "canonical"})
- if link_canonical and link_canonical.get("href"):
- canonical_url = normalize_link(base_url, link_canonical["href"]) or ""
-
- return meta_desc, meta_desc_len, h1_text, h1_count, canonical_url
-
-
-def parse_seo_extended(html_text: str, base_url: str) -> dict:
- """
- Extract extended SEO/accessibility/performance-related fields from HTML.
- Returns a dict with: viewport_present, viewport_content, noindex, has_schema,
- heading_sequence, images_without_alt, images_total, img_without_lazy, img_without_dimensions,
- aria_count, mixed_content_count.
- """
- soup = BeautifulSoup(html_text, "lxml")
- out = {
- "viewport_present": False,
- "viewport_content": "",
- "noindex": False,
- "has_schema": False,
- "heading_sequence": [],
- "heading_text": [],
- "images_without_alt": 0,
- "images_total": 0,
- "img_without_lazy": 0,
- "img_without_dimensions": 0,
- "aria_count": 0,
- "mixed_content_count": 0,
- }
- # Viewport
- viewport = soup.find("meta", attrs={"name": "viewport"})
- if viewport and viewport.get("content"):
- out["viewport_present"] = True
- out["viewport_content"] = (viewport["content"] or "").strip()
- # noindex
- robots = soup.find("meta", attrs={"name": "robots"})
- if robots and robots.get("content"):
- content = (robots["content"] or "").lower()
- out["noindex"] = "noindex" in content
- # Structured data: JSON-LD or microdata
- if soup.find("script", type="application/ld+json"):
- out["has_schema"] = True
- if soup.find(attrs={"itemscope": True}):
- out["has_schema"] = True
- # Heading order (h1..h6 tag names) and visible heading copy (for keywords / fingerprints)
- for tag in soup.find_all(["h1", "h2", "h3", "h4", "h5", "h6"]):
- if tag.name:
- out["heading_sequence"].append(tag.name)
- text = (tag.get_text(separator=" ", strip=True) or "").strip()
- if text:
- out["heading_text"].append(text)
- # Images: alt, lazy, dimensions
- base_scheme = urlparse(base_url).scheme.lower()
- for img in soup.find_all("img"):
- out["images_total"] += 1
- if not img.get("alt") and not img.get("aria-label"):
- out["images_without_alt"] += 1
- loading = (img.get("loading") or "").strip().lower()
- if loading != "lazy":
- out["img_without_lazy"] += 1
- if not img.get("width") and not img.get("height"):
- out["img_without_dimensions"] += 1
- src = img.get("src") or ""
- if base_scheme == "https" and src.strip().lower().startswith("http://"):
- out["mixed_content_count"] += 1
- # ARIA: count elements with any aria- attribute
- for el in soup.find_all(True):
- if getattr(el, "attrs", None) and any(k.startswith("aria-") for k in el.attrs):
- out["aria_count"] += 1
- # Mixed content: links and other src/href
- for tag in soup.find_all(True):
- for attr in ("href", "src", "srcset"):
- val = tag.get(attr)
- if not val or base_scheme != "https":
- continue
- val = str(val).strip().lower()
- if val.startswith("http://"):
- out["mixed_content_count"] += 1
- elif attr == "srcset":
- for part in val.split(","):
- part = part.strip().split()[0] if part.strip() else ""
- if part.startswith("http://"):
- out["mixed_content_count"] += 1
- return out
-
-
-_STOP_WORDS = frozenset({
- "the", "and", "for", "that", "this", "with", "from", "your", "have", "are",
- "was", "were", "been", "will", "would", "could", "should", "about", "which",
- "their", "there", "what", "when", "where", "more", "some", "than", "them",
- "other", "into", "over", "also", "just", "after", "before", "only", "then",
- "very", "most", "each", "such", "like", "does", "here", "because", "being",
- "well", "while", "these", "those", "both", "many", "much", "even", "back",
- "through", "still", "between", "every", "under", "last", "long", "great",
- "make", "same", "come", "take", "know", "they", "page", "site", "home",
- "click", "read", "view", "next", "menu", "main", "skip", "content", "link",
- "http", "https", "www", "html", "class", "none", "true", "false", "null",
-})
-
-
-def _count_syllables(word: str) -> int:
- word = word.lower().strip()
- if len(word) <= 3:
- return 1
- vowels = "aeiouy"
- count = 0
- prev_vowel = False
- for ch in word:
- is_vowel = ch in vowels
- if is_vowel and not prev_vowel:
- count += 1
- prev_vowel = is_vowel
- if word.endswith("e") and count > 1:
- count -= 1
- return max(1, count)
-
-
-def parse_content_text(soup, raw_html: str, excerpt_max_chars: int = 0) -> dict:
- """Extract content analytics: word count, reading level, content-to-HTML ratio, top keywords.
-
- excerpt_max_chars: when > 0, strip script/style from body and store a whitespace-normalized
- plain-text excerpt (truncated) in ``content_excerpt`` for analysis / AI / UI.
- """
- import re
- from collections import Counter
-
- body = soup.find("body")
- if body:
- for tag in body.find_all(["script", "style", "noscript"]):
- tag.decompose()
- body_text = body.get_text(separator=" ", strip=True) if body else ""
- words = [w for w in re.findall(r"[a-zA-Z]+", body_text) if len(w) >= 2]
- word_count = len(words)
-
- sentences = [s.strip() for s in re.split(r"[.!?]+", body_text) if len(s.strip()) > 5]
- sentence_count = max(1, len(sentences))
-
- total_syllables = sum(_count_syllables(w) for w in words) if words else 0
-
- reading_level = 0.0
- if word_count > 30:
- reading_level = (
- 0.39 * (word_count / sentence_count)
- + 11.8 * (total_syllables / max(1, word_count))
- - 15.59
- )
- reading_level = max(0.0, min(18.0, round(reading_level, 1)))
-
- html_len = max(1, len(raw_html))
- content_html_ratio = round(len(body_text) / html_len * 100, 1)
-
- keyword_words = [w.lower() for w in words if len(w) >= 4 and w.lower() not in _STOP_WORDS]
- top_keywords = Counter(keyword_words).most_common(10)
- max_kw = top_keywords[0][1] if top_keywords else 0
- kw_rows = []
- for w, c in top_keywords:
- score = round(100 * c / max_kw) if max_kw else 0
- kw_rows.append({"word": w, "count": c, "score": int(score)})
-
- excerpt = ""
- if excerpt_max_chars and excerpt_max_chars > 0 and body_text:
- excerpt = re.sub(r"\s+", " ", body_text.strip())
- if len(excerpt) > excerpt_max_chars:
- excerpt = excerpt[: excerpt_max_chars].rsplit(" ", 1)[0].strip() or excerpt[:excerpt_max_chars]
-
- return {
- "word_count": word_count,
- "reading_level": reading_level,
- "content_html_ratio": content_html_ratio,
- "top_keywords": json.dumps(kw_rows),
- "content_excerpt": excerpt,
- }
-
-
-def parse_social_meta(soup) -> dict:
- """Extract Open Graph and Twitter Card meta tags."""
- def _meta_content(attrs: dict) -> str:
- tag = soup.find("meta", attrs=attrs)
- return (tag.get("content") or "").strip() if tag else ""
-
- return {
- "og_title": _meta_content({"property": "og:title"}),
- "og_description": _meta_content({"property": "og:description"}),
- "og_image": _meta_content({"property": "og:image"}),
- "og_type": _meta_content({"property": "og:type"}),
- "twitter_card": _meta_content({"name": "twitter:card"}),
- "twitter_title": _meta_content({"name": "twitter:title"}),
- "twitter_image": _meta_content({"name": "twitter:image"}),
- }
-
-
-_TECH_PATTERNS = [
- ("WordPress", "html", "/wp-content/"),
- ("WordPress", "html", "/wp-includes/"),
- ("Drupal", "meta_generator", "Drupal"),
- ("Joomla", "meta_generator", "Joomla"),
- ("Shopify", "html", "cdn.shopify.com"),
- ("Squarespace", "html", "squarespace.com"),
- ("Wix", "html", "wix.com"),
- ("Next.js", "html", "__NEXT_DATA__"),
- ("Next.js", "html", "_next/static"),
- ("Nuxt.js", "html", "__NUXT__"),
- ("Gatsby", "html", "gatsby-"),
- ("React", "html", "data-reactroot"),
- ("React", "html", "__REACT_DEVTOOLS"),
- ("React", "html", "react.production.min"),
- ("Vue.js", "html", "__vue"),
- ("Vue.js", "html", "vue.min.js"),
- ("Angular", "html", "ng-version"),
- ("Angular", "html", "ng-app"),
- ("Svelte", "html", "svelte"),
- ("jQuery", "html", "jquery"),
- ("Bootstrap", "html", "bootstrap"),
- ("Tailwind CSS", "html", "tailwindcss"),
- ("Google Analytics", "html", "google-analytics.com/analytics.js"),
- ("Google Analytics", "html", "googletagmanager.com/gtag"),
- ("Google Tag Manager", "html", "googletagmanager.com/gtm.js"),
- ("Facebook Pixel", "html", "connect.facebook.net"),
- ("Hotjar", "html", "hotjar.com"),
- ("Google Fonts", "html", "fonts.googleapis.com"),
- ("Font Awesome", "html", "fontawesome"),
- ("Cloudflare", "header", "cf-ray"),
- ("Nginx", "header_server", "nginx"),
- ("Apache", "header_server", "apache"),
- ("LiteSpeed", "header_server", "litespeed"),
- ("Vercel", "header_server", "vercel"),
- ("Netlify", "header_server", "netlify"),
- ("Amazon CloudFront", "header", "x-amz-cf-id"),
- ("AWS", "header_server", "amazons3"),
-]
-
-# Module-level cache for Wappalyzer instance (avoids reloading technologies file per page).
-_wappalyzer_instance = None
-_wappalyzer_disabled = False
-
-
-def _is_wappalyzer_regex_warning(msg: str) -> bool:
- lower = msg.lower()
- return "compiling regex" in lower and "unbalanced parenthesis" in lower
-
-
-def detect_tech_wappalyzer(
- url: str,
- html: str,
- headers: dict,
- soup,
- wappalyzer=None,
-) -> str:
- """
- Detect technologies using python-Wappalyzer from existing HTML and headers.
- Returns JSON list of tech names. On any failure, falls back to parse_tech_stack(soup, headers, url).
- """
- global _wappalyzer_instance, _wappalyzer_disabled
- if _wappalyzer_disabled:
- return parse_tech_stack(soup, headers, url)
- try:
- from Wappalyzer import Wappalyzer, WebPage
- except ImportError:
- return parse_tech_stack(soup, headers, url)
- try:
- with warnings.catch_warnings(record=True) as caught:
- warnings.simplefilter("always")
- instance = wappalyzer if wappalyzer is not None else _wappalyzer_instance
- if instance is None:
- instance = Wappalyzer.latest()
- if wappalyzer is None:
- _wappalyzer_instance = instance
- webpage = WebPage(url, html=html, headers=headers)
- detected = instance.analyze(webpage)
- if any(_is_wappalyzer_regex_warning(str(w.message)) for w in caught):
- _wappalyzer_disabled = True
- _wappalyzer_instance = None
- return parse_tech_stack(soup, headers, url)
- return json.dumps(sorted(detected))
- except Exception:
- return parse_tech_stack(soup, headers, url)
-
-
-def parse_tech_stack(soup, headers: dict, url: str) -> str:
- """Detect technologies from HTML patterns and HTTP headers. Returns JSON list of tech names."""
- detected = set()
- html_str = str(soup).lower()
- meta_gen = soup.find("meta", attrs={"name": "generator"})
- generator = (meta_gen.get("content") or "").strip().lower() if meta_gen else ""
- server_header = (headers.get("Server") or headers.get("server") or "").lower()
-
- for name, source, pattern in _TECH_PATTERNS:
- pat = pattern.lower()
- if source == "html" and pat in html_str:
- detected.add(name)
- elif source == "meta_generator" and pat in generator:
- detected.add(name)
- elif source == "header":
- for v in headers.values():
- if isinstance(v, str) and pat in v.lower():
- detected.add(name)
- break
- elif source == "header_server" and pat in server_header:
- detected.add(name)
-
- return json.dumps(sorted(detected))
-
-
-def parse_resources(html_text: str, base_url: str) -> dict:
- """
- Extract script/link resource counts and total sizes (same-origin only, no fetch).
- Returns dict: script_count, link_stylesheet_count, script_urls, stylesheet_urls
- (URLs for optional later HEAD/GET). Does not fetch; caller may fetch with limit.
- """
- soup = BeautifulSoup(html_text, "lxml")
- parsed_base = urlparse(base_url)
- script_urls = []
- for s in soup.find_all("script", src=True):
- url = normalize_link(base_url, s["src"])
- if url and urlparse(url).netloc == parsed_base.netloc:
- script_urls.append(url)
- stylesheet_urls = []
- for link in soup.find_all("link", rel=lambda r: r and "stylesheet" in (r.lower() if isinstance(r, str) else "")):
- url = link.get("href") and normalize_link(base_url, link["href"])
- if url and urlparse(url).netloc == parsed_base.netloc:
- stylesheet_urls.append(url)
- return {
- "script_count": len(script_urls),
- "link_stylesheet_count": len(stylesheet_urls),
- "script_urls": script_urls,
- "stylesheet_urls": stylesheet_urls,
- }
-
-
-def _is_empty(raw) -> bool:
- if raw is None:
- return True
- if isinstance(raw, float) and math.isnan(raw):
- return True
- if raw == "":
- return True
- return False
-
-
-def parse_links_serialized(raw) -> list[str]:
- """
- Parse a serialized list of URLs from CSV/DataFrame (string list repr, comma-separated, or list).
- """
- if _is_empty(raw):
- return []
- if isinstance(raw, list):
- return [str(x).strip().rstrip("/") for x in raw if x]
- s = str(raw).strip()
- if not s:
- return []
- if s.startswith("[") and s.endswith("]"):
- try:
- v = ast.literal_eval(s)
- if isinstance(v, (list, tuple)):
- return [str(x).strip().rstrip("/") for x in v if x]
- except Exception:
- pass
- return [t.strip().rstrip("/") for t in s.split(",") if t.strip()]
-
-
-def load_robots(start_url: str):
- """Load robots.txt for the given URL; returns RobotFileParser or None on error."""
- parsed = urlparse(start_url)
- robots_url = f"{parsed.scheme}://{parsed.netloc}/robots.txt"
- rp = robotparser.RobotFileParser()
- rp.set_url(robots_url)
- try:
- rp.read()
- return rp
- except Exception:
- return None
-
-
-# Column names that may contain serialized outlink lists (for building edges from crawl CSV)
-LINK_COLUMN_NAMES = (
- "links",
- "edges",
- "outlinks",
- "outlink_targets",
- "targets",
- "link_targets",
- "links_list",
+from .parsing import tech as _tech
+from .parsing.content import parse_content_text, parse_social_meta
+from .parsing.io import load_dataframe, load_edges, save_dataframe, save_edges
+from .parsing.links import (
+ LINK_COLUMN_NAMES,
+ normalize_link,
+ parse_link_edges,
+ parse_links,
+ parse_links_serialized,
+ strip_crawl_query_params,
+ _is_empty,
)
+from .parsing.robots import load_robots
+from .parsing.seo import parse_resources, parse_seo, parse_seo_extended
+from .parsing.tech import parse_tech_stack, _is_wappalyzer_regex_warning
+
+_wappalyzer_instance = _tech._wappalyzer_instance
+_wappalyzer_disabled = _tech._wappalyzer_disabled
+
+
+def detect_tech_wappalyzer(url, html, headers, soup, wappalyzer=None):
+ """Detect technologies; syncs wappalyzer module state with this facade for tests."""
+ _tech._wappalyzer_disabled = _wappalyzer_disabled
+ _tech._wappalyzer_instance = _wappalyzer_instance
+ result = _tech.detect_tech_wappalyzer(url, html, headers, soup, wappalyzer)
+ globals()["_wappalyzer_disabled"] = _tech._wappalyzer_disabled
+ globals()["_wappalyzer_instance"] = _tech._wappalyzer_instance
+ return result
+
+
+__all__ = [
+ "load_dataframe",
+ "save_dataframe",
+ "load_edges",
+ "save_edges",
+ "strip_crawl_query_params",
+ "normalize_link",
+ "parse_link_edges",
+ "parse_links",
+ "parse_seo",
+ "parse_seo_extended",
+ "parse_content_text",
+ "parse_social_meta",
+ "detect_tech_wappalyzer",
+ "parse_tech_stack",
+ "parse_resources",
+ "parse_links_serialized",
+ "load_robots",
+ "LINK_COLUMN_NAMES",
+ "_is_wappalyzer_regex_warning",
+ "_is_empty",
+ "_wappalyzer_disabled",
+ "_wappalyzer_instance",
+]
diff --git a/src/website_profiling/content_analysis/__init__.py b/src/website_profiling/content_analysis/__init__.py
new file mode 100644
index 00000000..71e1aded
--- /dev/null
+++ b/src/website_profiling/content_analysis/__init__.py
@@ -0,0 +1,7 @@
+"""Post-crawl content analysis from stored HTML."""
+from __future__ import annotations
+
+from .page import analyze_page_html
+from .pipeline import run_content_analysis
+
+__all__ = ["analyze_page_html", "run_content_analysis"]
diff --git a/src/website_profiling/content_analysis/batch.py b/src/website_profiling/content_analysis/batch.py
new file mode 100644
index 00000000..7e77a686
--- /dev/null
+++ b/src/website_profiling/content_analysis/batch.py
@@ -0,0 +1,78 @@
+"""Batch content analysis for a crawl run."""
+from __future__ import annotations
+
+from concurrent.futures import ThreadPoolExecutor, as_completed
+from typing import Any, Iterator
+
+from psycopg import Connection
+
+from ..db.html_store import read_page_html_for_run
+from .page import ContentStrategy, analyze_page_html
+
+_PAGE_BATCH = 500
+
+
+def iter_html_pages(conn: Connection, crawl_run_id: int) -> Iterator[dict[str, Any]]:
+ offset = 0
+ while True:
+ chunk = list(read_page_html_for_run(conn, crawl_run_id, limit=_PAGE_BATCH, offset=offset))
+ if not chunk:
+ break
+ for row in chunk:
+ yield row
+ if len(chunk) < _PAGE_BATCH:
+ break
+ offset += _PAGE_BATCH
+
+
+def _analyze_row(
+ row: dict[str, Any],
+ *,
+ excerpt_max_chars: int,
+ strategy: ContentStrategy,
+) -> dict[str, Any] | None:
+ html = row.get("html")
+ url = row.get("url")
+ if not url or not html:
+ return None
+ fields = analyze_page_html(
+ str(html),
+ excerpt_max_chars=excerpt_max_chars,
+ strategy=strategy,
+ )
+ return {"url": str(url).rstrip("/"), **fields}
+
+
+def analyze_run_html(
+ conn: Connection,
+ crawl_run_id: int,
+ *,
+ excerpt_max_chars: int = 0,
+ strategy: ContentStrategy = "main_only",
+ workers: int = 4,
+) -> list[dict[str, Any]]:
+ """Analyze all stored HTML for a crawl run; returns merge payloads keyed by url."""
+ rows = list(iter_html_pages(conn, crawl_run_id))
+ if not rows:
+ return []
+
+ worker_count = max(1, int(workers))
+ if worker_count == 1 or len(rows) == 1:
+ out: list[dict[str, Any]] = []
+ for row in rows:
+ merged = _analyze_row(row, excerpt_max_chars=excerpt_max_chars, strategy=strategy)
+ if merged:
+ out.append(merged)
+ return out
+
+ results: list[dict[str, Any]] = []
+ with ThreadPoolExecutor(max_workers=worker_count) as pool:
+ futures = [
+ pool.submit(_analyze_row, row, excerpt_max_chars=excerpt_max_chars, strategy=strategy)
+ for row in rows
+ ]
+ for fut in as_completed(futures):
+ merged = fut.result()
+ if merged:
+ results.append(merged)
+ return results
diff --git a/src/website_profiling/content_analysis/constants.py b/src/website_profiling/content_analysis/constants.py
new file mode 100644
index 00000000..d1d7b7c8
--- /dev/null
+++ b/src/website_profiling/content_analysis/constants.py
@@ -0,0 +1,17 @@
+"""Shared constants for content analysis."""
+from __future__ import annotations
+
+STOP_WORDS = frozenset({
+ "the", "and", "for", "that", "this", "with", "from", "your", "have", "are",
+ "was", "were", "been", "will", "would", "could", "should", "about", "which",
+ "their", "there", "what", "when", "where", "more", "some", "than", "them",
+ "other", "into", "over", "also", "just", "after", "before", "only", "then",
+ "very", "most", "each", "such", "like", "does", "here", "because", "being",
+ "well", "while", "these", "those", "both", "many", "much", "even", "back",
+ "through", "still", "between", "every", "under", "last", "long", "great",
+ "make", "same", "come", "take", "know", "they", "page", "site", "home",
+ "click", "read", "view", "next", "menu", "main", "skip", "content", "link",
+ "http", "https", "www", "html", "class", "none", "true", "false", "null",
+})
+
+CHROME_TAGS = ("nav", "footer", "header", "aside")
diff --git a/src/website_profiling/content_analysis/dom_cleanup.py b/src/website_profiling/content_analysis/dom_cleanup.py
new file mode 100644
index 00000000..b35f554b
--- /dev/null
+++ b/src/website_profiling/content_analysis/dom_cleanup.py
@@ -0,0 +1,16 @@
+"""Remove non-content DOM nodes before text extraction."""
+from __future__ import annotations
+
+from bs4 import BeautifulSoup
+
+from .constants import CHROME_TAGS
+
+
+def cleanup_dom(soup: BeautifulSoup) -> BeautifulSoup:
+ for tag in soup.find_all(["script", "style", "noscript"]):
+ tag.decompose()
+ for tag in soup.find_all(CHROME_TAGS):
+ tag.decompose()
+ for tag in soup.find_all(attrs={"aria-hidden": "true"}):
+ tag.decompose()
+ return soup
diff --git a/src/website_profiling/content_analysis/excerpt.py b/src/website_profiling/content_analysis/excerpt.py
new file mode 100644
index 00000000..ae33a75e
--- /dev/null
+++ b/src/website_profiling/content_analysis/excerpt.py
@@ -0,0 +1,14 @@
+"""Plain-text excerpt generation."""
+from __future__ import annotations
+
+import re
+
+
+def build_excerpt(body_text: str, max_chars: int) -> str:
+ if not max_chars or max_chars <= 0 or not body_text:
+ return ""
+ excerpt = re.sub(r"\s+", " ", body_text.strip())
+ if len(excerpt) <= max_chars:
+ return excerpt
+ truncated = excerpt[:max_chars].rsplit(" ", 1)[0].strip()
+ return truncated or excerpt[:max_chars]
diff --git a/src/website_profiling/content_analysis/html_loader.py b/src/website_profiling/content_analysis/html_loader.py
new file mode 100644
index 00000000..9e34a2f5
--- /dev/null
+++ b/src/website_profiling/content_analysis/html_loader.py
@@ -0,0 +1,8 @@
+"""Load BeautifulSoup from raw HTML."""
+from __future__ import annotations
+
+from bs4 import BeautifulSoup
+
+
+def load_soup(raw_html: str) -> BeautifulSoup:
+ return BeautifulSoup(raw_html or "", "lxml")
diff --git a/src/website_profiling/content_analysis/html_ratio.py b/src/website_profiling/content_analysis/html_ratio.py
new file mode 100644
index 00000000..f14f399b
--- /dev/null
+++ b/src/website_profiling/content_analysis/html_ratio.py
@@ -0,0 +1,7 @@
+"""Content-to-HTML size ratio."""
+from __future__ import annotations
+
+
+def content_html_ratio(body_text: str, raw_html: str) -> float:
+ html_len = max(1, len(raw_html or ""))
+ return round(len(body_text or "") / html_len * 100, 1)
diff --git a/src/website_profiling/content_analysis/keywords.py b/src/website_profiling/content_analysis/keywords.py
new file mode 100644
index 00000000..b6368c3c
--- /dev/null
+++ b/src/website_profiling/content_analysis/keywords.py
@@ -0,0 +1,18 @@
+"""Per-page keyword extraction."""
+from __future__ import annotations
+
+import json
+from collections import Counter
+
+from .constants import STOP_WORDS
+
+
+def top_keywords_json(words: list[str], *, limit: int = 10) -> str:
+ keyword_words = [w.lower() for w in words if len(w) >= 4 and w.lower() not in STOP_WORDS]
+ top_keywords = Counter(keyword_words).most_common(limit)
+ max_kw = top_keywords[0][1] if top_keywords else 0
+ kw_rows = []
+ for w, c in top_keywords:
+ score = round(100 * c / max_kw) if max_kw else 0
+ kw_rows.append({"word": w, "count": c, "score": int(score)})
+ return json.dumps(kw_rows)
diff --git a/src/website_profiling/content_analysis/main_content.py b/src/website_profiling/content_analysis/main_content.py
new file mode 100644
index 00000000..0a00e956
--- /dev/null
+++ b/src/website_profiling/content_analysis/main_content.py
@@ -0,0 +1,25 @@
+"""Select the primary content root element from a page."""
+from __future__ import annotations
+
+from typing import Literal
+
+from bs4 import BeautifulSoup, Tag
+
+ContentStrategy = Literal["main_only", "full_body"]
+
+
+def find_main_content(soup: BeautifulSoup, strategy: ContentStrategy = "main_only") -> Tag | BeautifulSoup:
+ if strategy == "full_body":
+ return soup.find("body") or soup
+
+ candidates: list[Tag | None] = [
+ soup.find("main"),
+ soup.find("article"),
+ soup.find(attrs={"role": "main"}),
+ soup.find(id="content"),
+ soup.find(class_="content"),
+ ]
+ for el in candidates:
+ if el is not None and (el.get_text(separator=" ", strip=True) or "").strip():
+ return el
+ return soup.find("body") or soup
diff --git a/src/website_profiling/content_analysis/page.py b/src/website_profiling/content_analysis/page.py
new file mode 100644
index 00000000..6384dc4a
--- /dev/null
+++ b/src/website_profiling/content_analysis/page.py
@@ -0,0 +1,45 @@
+"""Per-page content analysis orchestration."""
+from __future__ import annotations
+
+from typing import Literal
+
+from .dom_cleanup import cleanup_dom
+from .excerpt import build_excerpt
+from .html_loader import load_soup
+from .html_ratio import content_html_ratio
+from .keywords import top_keywords_json
+from .main_content import find_main_content
+from .reading_level import flesch_kincaid_grade
+from .text_extract import extract_text
+from .tokenize import count_words, tokenize_words
+
+ContentStrategy = Literal["main_only", "full_body"]
+
+CONTENT_FIELDS = (
+ "word_count",
+ "reading_level",
+ "content_html_ratio",
+ "top_keywords",
+ "content_excerpt",
+)
+
+
+def analyze_page_html(
+ raw_html: str,
+ *,
+ excerpt_max_chars: int = 0,
+ strategy: ContentStrategy = "main_only",
+) -> dict:
+ """Analyze stored HTML and return crawl row content fields."""
+ soup = load_soup(raw_html)
+ cleaned = cleanup_dom(soup)
+ root = find_main_content(cleaned, strategy=strategy)
+ body_text = extract_text(root)
+ words = tokenize_words(body_text)
+ return {
+ "word_count": count_words(words),
+ "reading_level": flesch_kincaid_grade(words, body_text),
+ "content_html_ratio": content_html_ratio(body_text, raw_html),
+ "top_keywords": top_keywords_json(words),
+ "content_excerpt": build_excerpt(body_text, excerpt_max_chars),
+ }
diff --git a/src/website_profiling/content_analysis/pipeline.py b/src/website_profiling/content_analysis/pipeline.py
new file mode 100644
index 00000000..97f7a3d6
--- /dev/null
+++ b/src/website_profiling/content_analysis/pipeline.py
@@ -0,0 +1,50 @@
+"""Pipeline entrypoint for post-crawl content analysis."""
+from __future__ import annotations
+
+from typing import Any, Optional
+
+from ..console_io import console_print
+from ..db import db_session, get_latest_crawl_run_id
+from ..db.crawl_store import merge_crawl_result_fields_batch
+from ..progress import emit_phase_done, emit_phase_start, emit_progress
+from .batch import analyze_run_html
+from .page import ContentStrategy
+
+
+def run_content_analysis(
+ crawl_run_id: Optional[int] = None,
+ *,
+ excerpt_max_chars: int = 0,
+ strategy: str = "main_only",
+ workers: int = 4,
+) -> dict[str, Any]:
+ """Analyze stored HTML for a crawl run and merge metrics into crawl_results."""
+ emit_phase_start("content_analysis", message="Analyzing stored page HTML")
+ console_print(" Content analysis: reading stored HTML...", flush=True)
+
+ strat: ContentStrategy = "full_body" if strategy == "full_body" else "main_only"
+ summary: dict[str, Any] = {"crawl_run_id": None, "pages_analyzed": 0, "strategy": strat}
+
+ with db_session() as conn:
+ run_id = crawl_run_id if crawl_run_id is not None else get_latest_crawl_run_id(conn)
+ if run_id is None:
+ console_print(" Content analysis skipped: no crawl run in database.", flush=True)
+ emit_phase_done("content_analysis")
+ return summary
+
+ summary["crawl_run_id"] = int(run_id)
+ emit_progress("content_analysis", "analyze", message="Analyzing page HTML")
+ updates = analyze_run_html(
+ conn,
+ int(run_id),
+ excerpt_max_chars=excerpt_max_chars,
+ strategy=strat,
+ workers=workers,
+ )
+ if updates:
+ merge_crawl_result_fields_batch(conn, int(run_id), updates)
+ summary["pages_analyzed"] = len(updates)
+
+ console_print(f" Content analysis complete ({summary['pages_analyzed']} pages).", flush=True)
+ emit_phase_done("content_analysis")
+ return summary
diff --git a/src/website_profiling/content_analysis/reading_level.py b/src/website_profiling/content_analysis/reading_level.py
new file mode 100644
index 00000000..92e6876a
--- /dev/null
+++ b/src/website_profiling/content_analysis/reading_level.py
@@ -0,0 +1,39 @@
+"""Reading level (Flesch-Kincaid grade) helpers."""
+from __future__ import annotations
+
+import re
+
+
+def count_syllables(word: str) -> int:
+ word = word.lower().strip()
+ if len(word) <= 3:
+ return 1
+ vowels = "aeiouy"
+ count = 0
+ prev_vowel = False
+ for ch in word:
+ is_vowel = ch in vowels
+ if is_vowel and not prev_vowel:
+ count += 1
+ prev_vowel = is_vowel
+ if word.endswith("e") and count > 1:
+ count -= 1
+ return max(1, count)
+
+
+def split_sentences(body_text: str) -> list[str]:
+ return [s.strip() for s in re.split(r"[.!?]+", body_text or "") if len(s.strip()) > 5]
+
+
+def flesch_kincaid_grade(words: list[str], body_text: str) -> float:
+ word_count = len(words)
+ if word_count <= 30:
+ return 0.0
+ sentence_count = max(1, len(split_sentences(body_text)))
+ total_syllables = sum(count_syllables(w) for w in words)
+ reading_level = (
+ 0.39 * (word_count / sentence_count)
+ + 11.8 * (total_syllables / max(1, word_count))
+ - 15.59
+ )
+ return max(0.0, min(18.0, round(reading_level, 1)))
diff --git a/src/website_profiling/content_analysis/text_extract.py b/src/website_profiling/content_analysis/text_extract.py
new file mode 100644
index 00000000..e65cccdf
--- /dev/null
+++ b/src/website_profiling/content_analysis/text_extract.py
@@ -0,0 +1,8 @@
+"""Plain-text extraction from a DOM subtree."""
+from __future__ import annotations
+
+from bs4 import BeautifulSoup, Tag
+
+
+def extract_text(root: Tag | BeautifulSoup) -> str:
+ return root.get_text(separator=" ", strip=True) if root is not None else ""
diff --git a/src/website_profiling/content_analysis/tokenize.py b/src/website_profiling/content_analysis/tokenize.py
new file mode 100644
index 00000000..b5bc86db
--- /dev/null
+++ b/src/website_profiling/content_analysis/tokenize.py
@@ -0,0 +1,12 @@
+"""Word tokenization for content metrics."""
+from __future__ import annotations
+
+import re
+
+
+def tokenize_words(body_text: str) -> list[str]:
+ return [w for w in re.findall(r"[a-zA-Z]+", body_text or "") if len(w) >= 2]
+
+
+def count_words(tokens: list[str]) -> int:
+ return len(tokens)
diff --git a/src/website_profiling/crawl/config.py b/src/website_profiling/crawl/config.py
new file mode 100644
index 00000000..2acbffe2
--- /dev/null
+++ b/src/website_profiling/crawl/config.py
@@ -0,0 +1,132 @@
+"""Crawler configuration dataclass."""
+
+from __future__ import annotations
+
+from dataclasses import dataclass
+from typing import Optional
+
+from .discovery import follow_links_for_mode, normalize_discovery_mode
+
+DEFAULT_USER_AGENT = "WebsiteProfilingCrawler/1.0"
+MOBILE_USER_AGENT = (
+ "Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) "
+ "AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Mobile/15E148 Safari/604.1"
+)
+
+
+def resolve_crawl_user_agent(
+ preset: str | None, custom: str | None, default: str | None = None
+) -> str:
+ p = (preset or "default").strip().lower()
+ if p == "mobile":
+ return MOBILE_USER_AGENT
+ if p == "custom" and custom and str(custom).strip():
+ return str(custom).strip()
+ return (default or DEFAULT_USER_AGENT).strip() or DEFAULT_USER_AGENT
+
+
+@dataclass
+class CrawlConfig:
+ start_url: str
+ max_pages: Optional[int] = None
+ concurrency: int = 6
+ timeout: int = 12
+ ignore_robots: bool = False
+ allow_external: bool = False
+ max_depth: Optional[int] = None
+ user_agent: Optional[str] = None
+ polite_delay: float = 0.0
+ store_outlinks: bool = False
+ exclude_urls: Optional[list[str]] = None
+ use_wappalyzer: bool = True
+ store_content_excerpt: bool = False
+ content_excerpt_max_chars: int = 4096
+ store_page_html: bool = False
+ max_stored_html_bytes: int = 2_097_152
+ run_content_analysis: bool = False
+ content_analysis_strategy: str = "main_only"
+ content_analysis_workers: int = 4
+ render_mode: str = "static"
+ js_concurrency: int = 3
+ js_timeout: int = 30
+ js_wait_until: str = "domcontentloaded"
+ js_extra_wait_ms: int = 1500
+ js_block_resources: bool = True
+ capture_console: bool = True
+ js_console_levels: str = "error,warning"
+ capture_failed_requests: bool = False
+ console_max_per_page: int = 20
+ custom_extraction_regex: str = ""
+ crawl_ignore_params: Optional[list[str]] = None
+ discovery_mode: str = "spider"
+ crawl_url_list: Optional[list[str]] = None
+ crawl_user_agent_preset: str = "default"
+ crawl_user_agent_custom: str = ""
+ crawl_auth_username: str = ""
+ crawl_auth_password: str = ""
+ crawl_extra_headers: str = ""
+ crawl_cookies: str = ""
+ crawl_robots_txt_override: str = ""
+ custom_extractors: Optional[list[dict]] = None
+ enable_axe: bool = False
+
+ @classmethod
+ def from_kwargs(cls, **kwargs: object) -> CrawlConfig:
+ """Build config from Crawler keyword arguments (unknown keys ignored)."""
+ fields = {f.name for f in cls.__dataclass_fields__.values()}
+ return cls(**{k: v for k, v in kwargs.items() if k in fields})
+
+ def normalized(self) -> CrawlConfig:
+ """Apply normalized derived fields in-place."""
+ self.start_url = self.start_url.rstrip("/")
+ self.discovery_mode = normalize_discovery_mode(self.discovery_mode)
+ self.render_mode = (self.render_mode or "static").strip().lower()
+ self.js_concurrency = max(1, int(self.js_concurrency))
+ self.max_pages = (
+ self.max_pages if (self.max_pages is not None and self.max_pages > 0) else float("inf")
+ )
+ self.max_depth = None if self.max_depth is None else int(self.max_depth)
+ self.polite_delay = max(0.0, float(self.polite_delay))
+ self.exclude_urls = list(self.exclude_urls) if self.exclude_urls else []
+ self.store_content_excerpt = bool(self.store_content_excerpt)
+ self.content_excerpt_max_chars = max(0, int(self.content_excerpt_max_chars or 0))
+ self.store_page_html = bool(self.store_page_html)
+ self.max_stored_html_bytes = max(1, int(self.max_stored_html_bytes or 2_097_152))
+ self.run_content_analysis = bool(self.run_content_analysis)
+ strat = (self.content_analysis_strategy or "main_only").strip().lower()
+ self.content_analysis_strategy = strat if strat in ("main_only", "full_body") else "main_only"
+ self.content_analysis_workers = max(1, int(self.content_analysis_workers or 4))
+ self.custom_extraction_regex = (self.custom_extraction_regex or "").strip()
+ self.custom_extractors = list(self.custom_extractors or [])
+ self.crawl_ignore_params = list(self.crawl_ignore_params or [])
+ self.crawl_url_list = [
+ u.rstrip("/") for u in (self.crawl_url_list or []) if u and str(u).strip()
+ ]
+ self.user_agent = resolve_crawl_user_agent(
+ self.crawl_user_agent_preset,
+ self.crawl_user_agent_custom,
+ self.user_agent,
+ )
+ return self
+
+ @property
+ def defer_content_analysis(self) -> bool:
+ return self.store_page_html and self.run_content_analysis
+
+ @property
+ def effective_concurrency(self) -> int:
+ if self.render_mode == "javascript":
+ return self.js_concurrency
+ return max(1, int(self.concurrency))
+
+ @property
+ def follow_links(self) -> bool:
+ return follow_links_for_mode(self.discovery_mode)
+
+ @property
+ def fetcher_render_mode(self) -> str:
+ if self.render_mode == "javascript":
+ return "javascript"
+ if self.render_mode == "auto":
+ return "auto"
+ return "static"
diff --git a/src/website_profiling/crawl/crawler.py b/src/website_profiling/crawl/crawler.py
index 39b0ac51..a00b4c9d 100644
--- a/src/website_profiling/crawl/crawler.py
+++ b/src/website_profiling/crawl/crawler.py
@@ -1,73 +1,47 @@
"""
Website crawler: threaded, respects robots.txt, returns DataFrame and optional CSV.
"""
+from __future__ import annotations
+
import json
-import threading
import time
from concurrent.futures import ThreadPoolExecutor
-from queue import Queue
from typing import Optional
-from urllib.parse import urlparse
-
-
-def _url_matches_exclude(url: str, exclude_urls: list[str]) -> bool:
- """True if url equals or is under any exclude prefix (trailing-slash normalized)."""
- if not exclude_urls:
- return False
- u = url.rstrip("/")
- for prefix in exclude_urls:
- p = prefix.strip().rstrip("/")
- if not p:
- continue
- if u == p or u.startswith(p + "/"):
- return True
- return False
import pandas as pd
import requests
from tqdm.auto import tqdm
from ..console_io import console_print
-from ..common import (
- detect_tech_wappalyzer,
- load_robots,
- normalize_link,
- parse_content_text,
- parse_link_edges,
- parse_resources,
- parse_seo,
- parse_seo_extended,
- parse_social_meta,
- parse_tech_stack,
+from ..common import strip_crawl_query_params
+from .config import (
+ DEFAULT_USER_AGENT,
+ MOBILE_USER_AGENT,
+ CrawlConfig,
+ resolve_crawl_user_agent,
)
-from ..analysis.page import analyze_html
-from .discovery import (
- follow_links_for_mode,
- normalize_discovery_mode,
- seed_sitemap_for_mode,
-)
-from .extraction import parse_extractors_config, run_extractors
+from .db_writer import CrawlDbWriter, _CrawlDbWriter
+from .discovery import normalize_discovery_mode
from .fetchers import build_fetcher
+from .sitemap import discover_sitemap_urls
from .fetchers.base import FetchResult
-from .fetchers.browser_diagnostics import merge_browser_into_page_analysis
from .fetchers.hybrid import HybridFetcher
-from .fetchers.spa_heuristics import needs_js_render_after_parse
-from .sitemap import discover_sitemap_urls
-
-DEFAULT_USER_AGENT = "WebsiteProfilingCrawler/1.0"
-MOBILE_USER_AGENT = (
- "Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) "
- "AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Mobile/15E148 Safari/604.1"
-)
+from .frontier import CrawlFrontier, url_matches_exclude
+from .page_record import PageRecordBuilder
+from .schema import crawl_dataframe_columns, empty_crawl_row
+# Re-export for backward compatibility.
+_url_matches_exclude = url_matches_exclude
-def resolve_crawl_user_agent(preset: str | None, custom: str | None, default: str | None = None) -> str:
- p = (preset or "default").strip().lower()
- if p == "mobile":
- return MOBILE_USER_AGENT
- if p == "custom" and custom and str(custom).strip():
- return str(custom).strip()
- return (default or DEFAULT_USER_AGENT).strip() or DEFAULT_USER_AGENT
+__all__ = [
+ "Crawler",
+ "run_crawler",
+ "resolve_crawl_user_agent",
+ "DEFAULT_USER_AGENT",
+ "MOBILE_USER_AGENT",
+ "_url_matches_exclude",
+ "_CrawlDbWriter",
+]
class Crawler:
@@ -87,6 +61,11 @@ def __init__(
use_wappalyzer: bool = True,
store_content_excerpt: bool = False,
content_excerpt_max_chars: int = 4096,
+ store_page_html: bool = False,
+ max_stored_html_bytes: int = 2_097_152,
+ run_content_analysis: bool = False,
+ content_analysis_strategy: str = "main_only",
+ content_analysis_workers: int = 4,
render_mode: str = "static",
js_concurrency: int = 3,
js_timeout: int = 30,
@@ -110,331 +89,202 @@ def __init__(
crawl_robots_txt_override: str = "",
custom_extractors: Optional[list[dict]] = None,
enable_axe: bool = False,
+ *,
+ config: Optional[CrawlConfig] = None,
):
- self.start_url = start_url.rstrip("/")
- self.start_netloc = urlparse(self.start_url).netloc
- self.discovery_mode = normalize_discovery_mode(discovery_mode)
- self.follow_links = follow_links_for_mode(self.discovery_mode)
- self.crawl_url_list = [u.rstrip("/") for u in (crawl_url_list or []) if u and str(u).strip()]
+ if config is None:
+ config = CrawlConfig.from_kwargs(
+ start_url=start_url,
+ max_pages=max_pages,
+ concurrency=concurrency,
+ timeout=timeout,
+ ignore_robots=ignore_robots,
+ allow_external=allow_external,
+ max_depth=max_depth,
+ user_agent=user_agent,
+ polite_delay=polite_delay,
+ store_outlinks=store_outlinks,
+ exclude_urls=exclude_urls,
+ use_wappalyzer=use_wappalyzer,
+ store_content_excerpt=store_content_excerpt,
+ content_excerpt_max_chars=content_excerpt_max_chars,
+ store_page_html=store_page_html,
+ max_stored_html_bytes=max_stored_html_bytes,
+ run_content_analysis=run_content_analysis,
+ content_analysis_strategy=content_analysis_strategy,
+ content_analysis_workers=content_analysis_workers,
+ render_mode=render_mode,
+ js_concurrency=js_concurrency,
+ js_timeout=js_timeout,
+ js_wait_until=js_wait_until,
+ js_extra_wait_ms=js_extra_wait_ms,
+ js_block_resources=js_block_resources,
+ capture_console=capture_console,
+ js_console_levels=js_console_levels,
+ capture_failed_requests=capture_failed_requests,
+ console_max_per_page=console_max_per_page,
+ custom_extraction_regex=custom_extraction_regex,
+ crawl_ignore_params=crawl_ignore_params,
+ discovery_mode=discovery_mode,
+ crawl_url_list=crawl_url_list,
+ crawl_user_agent_preset=crawl_user_agent_preset,
+ crawl_user_agent_custom=crawl_user_agent_custom,
+ crawl_auth_username=crawl_auth_username,
+ crawl_auth_password=crawl_auth_password,
+ crawl_extra_headers=crawl_extra_headers,
+ crawl_cookies=crawl_cookies,
+ crawl_robots_txt_override=crawl_robots_txt_override,
+ custom_extractors=custom_extractors,
+ enable_axe=enable_axe,
+ )
+ config.normalized()
+ self.config = config
+
+ self.start_url = config.start_url
+ self.discovery_mode = config.discovery_mode
+ self.follow_links = config.follow_links
+ self.crawl_url_list = config.crawl_url_list
self.link_edges_accum: list[dict] = []
- self.render_mode = (render_mode or "static").strip().lower()
- self.js_concurrency = max(1, int(js_concurrency))
- effective_concurrency = (
- self.js_concurrency
- if self.render_mode == "javascript"
- else max(1, int(concurrency))
- )
- self.max_pages = (
- max_pages if (max_pages is not None and max_pages > 0) else float("inf")
+ self.render_mode = config.render_mode
+ self.max_pages = config.max_pages
+ self.concurrency = config.effective_concurrency
+ self.timeout = config.timeout
+ self.polite_delay = config.polite_delay
+ self.store_outlinks = config.store_outlinks
+ self.exclude_urls = config.exclude_urls
+ self.crawl_ignore_params = config.crawl_ignore_params
+ self.custom_extraction_regex = config.custom_extraction_regex
+ self.custom_extractors = config.custom_extractors
+ self.store_page_html = config.store_page_html
+ self.max_stored_html_bytes = config.max_stored_html_bytes
+ self._html_buffer: list[dict] = []
+ self._db_writer: Optional[CrawlDbWriter] = None
+
+ self.page_builder = PageRecordBuilder(
+ use_wappalyzer=config.use_wappalyzer,
+ store_content_excerpt=config.store_content_excerpt,
+ content_excerpt_max_chars=config.content_excerpt_max_chars,
+ defer_content_analysis=config.defer_content_analysis,
+ custom_extraction_regex=config.custom_extraction_regex,
+ custom_extractors=config.custom_extractors,
)
- self.concurrency = effective_concurrency
- self.timeout = timeout
- self.ignore_robots = ignore_robots
- self.allow_external = allow_external
- self.max_depth = None if max_depth is None else int(max_depth)
- self.user_agent = resolve_crawl_user_agent(
- crawl_user_agent_preset, crawl_user_agent_custom, user_agent
+
+ self.frontier = CrawlFrontier(
+ config.start_url,
+ allow_external=config.allow_external,
+ max_depth=config.max_depth,
+ exclude_urls=config.exclude_urls,
+ follow_links=config.follow_links,
+ ignore_robots=config.ignore_robots,
+ user_agent=config.user_agent or DEFAULT_USER_AGENT,
+ crawl_robots_txt_override=config.crawl_robots_txt_override,
)
- self.polite_delay = max(0.0, float(polite_delay))
- self.store_outlinks = store_outlinks
- self.exclude_urls = list(exclude_urls) if exclude_urls else []
- self.use_wappalyzer = use_wappalyzer
- self.store_content_excerpt = bool(store_content_excerpt)
- self.content_excerpt_max_chars = max(0, int(content_excerpt_max_chars or 0))
- self._wappalyzer_instance = None
- self.custom_extraction_regex = (custom_extraction_regex or "").strip()
- self.custom_extractors = list(custom_extractors or [])
- self.crawl_ignore_params = list(crawl_ignore_params or [])
+ self.queue = self.frontier.queue
+ self.depths = self.frontier.depths
+ self.visited = self.frontier.visited
+ self.lock = self.frontier.lock
- self.queue = Queue()
- self.depths: dict[str, int] = {}
- self.visited = set()
- self.results = []
- self.lock = threading.Lock()
+ self.results: list[dict] = []
self.session = requests.Session()
- self.session.headers.update({"User-Agent": self.user_agent})
- if crawl_auth_username:
- self.session.auth = (crawl_auth_username, crawl_auth_password or "")
- for line in (crawl_extra_headers or "").replace("\r", "").split("\n"):
+ self.session.headers.update({"User-Agent": config.user_agent})
+ if config.crawl_auth_username:
+ self.session.auth = (config.crawl_auth_username, config.crawl_auth_password or "")
+ for line in (config.crawl_extra_headers or "").replace("\r", "").split("\n"):
if ":" in line:
key, val = line.split(":", 1)
k, v = key.strip(), val.strip()
if k:
self.session.headers[k] = v
- if crawl_cookies and str(crawl_cookies).strip():
- self.session.headers["Cookie"] = str(crawl_cookies).strip()
- self.rp = None
- if not self.ignore_robots:
- override = (crawl_robots_txt_override or "").strip()
- if override:
- import io
- import urllib.robotparser as robotparser
+ if config.crawl_cookies and str(config.crawl_cookies).strip():
+ self.session.headers["Cookie"] = str(config.crawl_cookies).strip()
- self.rp = robotparser.RobotFileParser()
- self.rp.parse(override.splitlines())
- else:
- self.rp = load_robots(self.start_url)
self.fetcher = build_fetcher(
- render_mode="javascript" if self.render_mode == "javascript" else ("auto" if self.render_mode == "auto" else "static"),
- timeout=timeout,
- user_agent=self.user_agent,
+ render_mode=config.fetcher_render_mode,
+ timeout=config.timeout,
+ user_agent=config.user_agent,
session=self.session,
- js_concurrency=self.js_concurrency,
- js_timeout=js_timeout,
- js_wait_until=js_wait_until,
- js_extra_wait_ms=js_extra_wait_ms,
- js_block_resources=js_block_resources,
- capture_console=capture_console,
- js_console_levels=js_console_levels,
- capture_failed_requests=capture_failed_requests,
- console_max_per_page=console_max_per_page,
- run_axe=enable_axe,
+ js_concurrency=config.js_concurrency,
+ js_timeout=config.js_timeout,
+ js_wait_until=config.js_wait_until,
+ js_extra_wait_ms=config.js_extra_wait_ms,
+ js_block_resources=config.js_block_resources,
+ capture_console=config.capture_console,
+ js_console_levels=config.js_console_levels,
+ capture_failed_requests=config.capture_failed_requests,
+ console_max_per_page=config.console_max_per_page,
+ run_axe=config.enable_axe,
)
self._hybrid_fetcher = (
self.fetcher if isinstance(self.fetcher, HybridFetcher) else None
)
- self._seed_initial_urls(timeout)
+ self.frontier.seed_initial_urls(
+ discovery_mode=config.discovery_mode,
+ crawl_url_list=config.crawl_url_list,
+ timeout=config.timeout,
+ session=self.session,
+ )
- def _enqueue_seed(self, url: str, depth: int = 0) -> None:
- u = url.rstrip("/")
- if _url_matches_exclude(u, self.exclude_urls):
- return
- if not self.allow_external and not self.same_domain(u):
- return
- if u in self.depths:
- return
- self.queue.put(u)
- self.depths[u] = depth
+ @property
+ def rp(self):
+ return self.frontier.rp
- def _seed_initial_urls(self, timeout: int) -> None:
- mode = self.discovery_mode
- if mode in ("list", "hybrid"):
- for url in self.crawl_url_list:
- self._enqueue_seed(url, 0)
- if mode in ("spider", "hybrid"):
- self._enqueue_seed(self.start_url, 0)
- if seed_sitemap_for_mode(mode):
- self._seed_sitemap_urls(timeout)
+ @rp.setter
+ def rp(self, value) -> None:
+ self.frontier.rp = value
- def same_domain(self, url):
- return urlparse(url).netloc == self.start_netloc
+ def same_domain(self, url: str) -> bool:
+ return self.frontier.same_domain(url)
- def allowed_by_robots(self, url):
- if self.ignore_robots or not self.rp:
- return True
- try:
- return self.rp.can_fetch(self.user_agent, url)
- except Exception:
- return True
+ def allowed_by_robots(self, url: str) -> bool:
+ return self.frontier.allowed_by_robots(url)
- def _seed_sitemap_urls(self, timeout: int) -> None:
- try:
- seeds = discover_sitemap_urls(
- self.start_url,
- timeout=timeout,
- session=self.session,
- )
- except Exception:
- return
- for url in seeds:
- self._enqueue_seed(url, 0)
+ def _queue_contains(self, item: str) -> bool:
+ return self.frontier.queue_contains(item)
- def fetch(self, url) -> FetchResult:
+ def fetch(self, url: str) -> FetchResult:
return self.fetcher.fetch(url)
- def _empty_seo(self, url: str, headers_dict: Optional[dict] = None, redirect_chain_length: int = 0) -> dict:
- """Default SEO/performance fields when no HTML or error."""
- h = headers_dict or {}
- return {
- "response_time_ms": "",
- "content_length": 0,
- "final_url": url,
- "meta_description": "",
- "meta_description_len": 0,
- "h1": "",
- "h1_count": 0,
- "canonical_url": "",
- "viewport_present": False,
- "viewport_content": "",
- "noindex": False,
- "has_schema": False,
- "heading_sequence": "",
- "heading_text": "",
- "images_without_alt": 0,
- "images_total": 0,
- "img_without_lazy": 0,
- "img_without_dimensions": 0,
- "aria_count": 0,
- "mixed_content_count": 0,
- "redirect_chain_length": redirect_chain_length,
- "cache_control": h.get("Cache-Control", ""),
- "etag": h.get("ETag", ""),
- "x_robots_tag": h.get("X-Robots-Tag", ""),
- "strict_transport_security": h.get("Strict-Transport-Security", ""),
- "x_content_type_options": h.get("X-Content-Type-Options", ""),
- "x_frame_options": h.get("X-Frame-Options", ""),
- "content_security_policy": h.get("Content-Security-Policy", ""),
- "script_count": 0,
- "link_stylesheet_count": 0,
- "total_js_bytes": 0,
- "total_css_bytes": 0,
- "word_count": 0,
- "reading_level": 0.0,
- "content_html_ratio": 0.0,
- "top_keywords": "[]",
- "content_excerpt": "",
- "og_title": "",
- "og_description": "",
- "og_image": "",
- "og_type": "",
- "twitter_card": "",
- "twitter_title": "",
- "twitter_image": "",
- "tech_stack": "[]",
- "depth": None,
- "page_analysis": "{}",
- }
-
- def _parse_page_content(
+ def _capture_page_html(
self,
url: str,
- text: str,
- final_url: str,
- headers_dict: dict,
- redirect_chain_length: int,
- ) -> dict:
- """Extract title, links, and SEO/content fields from HTML."""
- ext = self._empty_seo(url, headers_dict, redirect_chain_length)
- title, link_edge_rows = parse_link_edges(url, text)
- links = {e["to_url"] for e in link_edge_rows}
- meta_description, meta_description_len, h1_text, h1_count, canonical_url = (
- parse_seo(url, text)
+ text: str | None,
+ status: object,
+ content_type: str | None,
+ fetch_method: str,
+ ) -> None:
+ from .html_capture import build_page_html_record
+
+ record = build_page_html_record(
+ url=url,
+ html=text or "",
+ status=status,
+ content_type=content_type,
+ fetch_method=fetch_method,
+ max_bytes=self.max_stored_html_bytes,
+ enabled=self.store_page_html,
)
- seo_ext = parse_seo_extended(text, final_url or url)
- ext["viewport_present"] = seo_ext.get("viewport_present", False)
- ext["viewport_content"] = seo_ext.get("viewport_content", "")
- ext["noindex"] = seo_ext.get("noindex", False)
- if (headers_dict.get("X-Robots-Tag") or "").lower().find("noindex") >= 0:
- ext["noindex"] = True
- ext["has_schema"] = seo_ext.get("has_schema", False)
- ext["heading_sequence"] = ",".join(seo_ext.get("heading_sequence") or [])
- ext["heading_text"] = " | ".join(seo_ext.get("heading_text") or [])
- ext["images_without_alt"] = seo_ext.get("images_without_alt", 0)
- ext["images_total"] = seo_ext.get("images_total", 0)
- ext["img_without_lazy"] = seo_ext.get("img_without_lazy", 0)
- ext["img_without_dimensions"] = seo_ext.get("img_without_dimensions", 0)
- ext["aria_count"] = seo_ext.get("aria_count", 0)
- ext["mixed_content_count"] = seo_ext.get("mixed_content_count", 0)
- res_res = parse_resources(text, final_url or url)
- ext["script_count"] = res_res.get("script_count", 0)
- ext["link_stylesheet_count"] = res_res.get("link_stylesheet_count", 0)
- from bs4 import BeautifulSoup as _BS
-
- _soup = _BS(text, "lxml")
- excerpt_max = self.content_excerpt_max_chars if self.store_content_excerpt else 0
- ct_data = parse_content_text(_soup, text, excerpt_max_chars=excerpt_max)
- ext["word_count"] = ct_data.get("word_count", 0)
- ext["reading_level"] = ct_data.get("reading_level", 0.0)
- ext["content_html_ratio"] = ct_data.get("content_html_ratio", 0.0)
- ext["top_keywords"] = ct_data.get("top_keywords", "[]")
- ext["content_excerpt"] = ct_data.get("content_excerpt") or ""
- social = parse_social_meta(_soup)
- ext["og_title"] = social.get("og_title", "")
- ext["og_description"] = social.get("og_description", "")
- ext["og_image"] = social.get("og_image", "")
- ext["og_type"] = social.get("og_type", "")
- ext["twitter_card"] = social.get("twitter_card", "")
- ext["twitter_title"] = social.get("twitter_title", "")
- ext["twitter_image"] = social.get("twitter_image", "")
- if self.use_wappalyzer:
- ext["tech_stack"] = detect_tech_wappalyzer(
- final_url or url, text, headers_dict, _soup, self._wappalyzer_instance
- )
+ if record is None:
+ return
+ if self._db_writer is not None:
+ self._db_writer.enqueue_html(record)
else:
- ext["tech_stack"] = parse_tech_stack(_soup, headers_dict, final_url or url)
- ext["page_analysis"] = json.dumps(
- analyze_html(text, final_url or url, final_url or url, canonical_url)
- )
- return {
- "title": title,
- "links": links,
- "link_edges": link_edge_rows,
- "meta_description": meta_description,
- "meta_description_len": meta_description_len,
- "h1_text": h1_text,
- "h1_count": h1_count,
- "canonical_url": canonical_url,
- "ext": ext,
- }
-
- def _maybe_refetch_after_parse(
- self,
- url: str,
- result: FetchResult,
- *,
- link_count: int,
- same_domain_link_count: int,
- ) -> FetchResult:
- """Post-parse auto-mode fallback when static HTML has too few links."""
- if self.render_mode != "auto" or self._hybrid_fetcher is None:
- return result
- if result.fetch_method != "static":
- return result
- if not needs_js_render_after_parse(
- result,
- link_count=link_count,
- same_domain_link_count=same_domain_link_count,
- ):
- return result
- rendered = self._hybrid_fetcher.refetch_rendered(url)
- if rendered.status == 200 and rendered.text:
- return rendered
- return result
+ self._html_buffer.append(record)
- @staticmethod
- def _sync_from_fetch_result(
- result: FetchResult,
- url: str,
- *,
- text: Optional[str],
- fetch_method: str,
- final_url: str,
- content_length: int,
- response_time_ms: Optional[int],
- headers_dict: dict,
- redirect_chain_length: int,
- status: Optional[int],
- ct: Optional[str],
- ) -> dict:
- """Copy all FetchResult fields after a post-parse browser refetch."""
- return {
- "text": result.text,
- "fetch_method": result.fetch_method,
- "final_url": result.final_url or url,
- "content_length": result.content_length or content_length,
- "response_time_ms": result.response_time_ms,
- "headers_dict": result.headers_dict or headers_dict,
- "redirect_chain_length": result.redirect_chain_length,
- "status": result.status,
- "ct": result.content_type,
- }
-
- def worker(self, url):
+ def worker(self, url: str) -> dict:
if not self.allowed_by_robots(url):
- out = {
- "url": url,
- "status": "blocked_by_robots",
- "content_type": "",
- "title": "",
- "outlinks": 0,
- "fetch_method": "static",
- **self._empty_seo(url),
- }
- if self.store_outlinks:
- out["outlink_targets"] = "[]"
- return out
+ return PageRecordBuilder.build_robots_blocked_row(
+ url, store_outlinks=self.store_outlinks
+ )
result = self.fetch(url)
+ if result.status is None:
+ return PageRecordBuilder.build_fetch_error_row(
+ url,
+ result,
+ fetch_method=result.fetch_method,
+ store_outlinks=self.store_outlinks,
+ )
+
status = result.status
ct = result.content_type
text = result.text
@@ -445,59 +295,33 @@ def worker(self, url):
redirect_chain_length = result.redirect_chain_length
fetch_method = result.fetch_method
- if status is None:
- out = {
- "url": url,
- "status": "error",
- "content_type": "",
- "title": "",
- "outlinks": 0,
- "fetch_method": fetch_method,
- **self._empty_seo(url, headers_dict, redirect_chain_length),
- }
- if self.store_outlinks:
- out["outlink_targets"] = "[]"
- if result.browser_diagnostics:
- out["page_analysis"] = merge_browser_into_page_analysis(
- None, result.browser_diagnostics
- )
- return out
-
title = ""
outlinks_count = 0
- outlink_list = []
+ outlink_list: list[str] = []
meta_description = ""
meta_description_len = 0
h1_text = ""
h1_count = 0
canonical_url = ""
- ext = self._empty_seo(url, headers_dict, redirect_chain_length)
+ ext = self.page_builder.empty_ext(url, headers_dict, redirect_chain_length)
if text:
- parsed = self._parse_page_content(
+ parsed = self.page_builder.parse_page_content(
url, text, final_url or url, headers_dict, redirect_chain_length
)
links = parsed["links"]
same_domain_link_count = sum(1 for link in links if self.same_domain(link))
- result = self._maybe_refetch_after_parse(
+ result = PageRecordBuilder.maybe_refetch_after_parse(
url,
result,
+ render_mode=self.render_mode,
+ hybrid_fetcher=self._hybrid_fetcher,
link_count=len(links),
same_domain_link_count=same_domain_link_count,
)
if result.text and result.text != text:
- synced = self._sync_from_fetch_result(
- result,
- url,
- text=text,
- fetch_method=fetch_method,
- final_url=final_url,
- content_length=content_length,
- response_time_ms=response_time_ms,
- headers_dict=headers_dict,
- redirect_chain_length=redirect_chain_length,
- status=status,
- ct=ct,
+ synced = PageRecordBuilder.sync_from_fetch_result(
+ result, url, content_length=content_length, headers_dict=headers_dict
)
text = synced["text"]
fetch_method = synced["fetch_method"]
@@ -508,7 +332,7 @@ def worker(self, url):
redirect_chain_length = synced["redirect_chain_length"]
status = synced["status"]
ct = synced["ct"]
- parsed = self._parse_page_content(
+ parsed = self.page_builder.parse_page_content(
url, text, final_url, headers_dict, redirect_chain_length
)
links = parsed["links"]
@@ -523,8 +347,6 @@ def worker(self, url):
ext = parsed["ext"]
if self.crawl_ignore_params:
- from ..common import strip_crawl_query_params
-
links = [strip_crawl_query_params(l, self.crawl_ignore_params) for l in links]
link_edge_rows = parsed.get("link_edges") or []
@@ -533,23 +355,7 @@ def worker(self, url):
if self.store_outlinks:
outlink_list.append(link)
self.link_edges_accum.append({"from_url": url, **edge})
- if not self.follow_links:
- continue
- if _url_matches_exclude(link, self.exclude_urls):
- continue
- if not self.allow_external and not self.same_domain(link):
- continue
- cur_depth = self.depths.get(url, 0)
- if self.max_depth is not None and cur_depth >= self.max_depth:
- continue
- with self.lock:
- if (
- link not in self.visited
- and link not in self.depths
- and not self._queue_contains(link)
- ):
- self.queue.put(link)
- self.depths[link] = cur_depth + 1
+ self.frontier.try_enqueue_link(link, url)
ext["response_time_ms"] = response_time_ms if response_time_ms is not None else ""
ext["content_length"] = content_length or 0
@@ -566,31 +372,17 @@ def worker(self, url):
ext["x_content_type_options"] = headers_dict.get("X-Content-Type-Options", "")
ext["x_frame_options"] = headers_dict.get("X-Frame-Options", "")
ext["content_security_policy"] = headers_dict.get("Content-Security-Policy", "")
-
ext["depth"] = self.depths.get(url)
- if self.custom_extraction_regex and text:
- import re
-
- try:
- match = re.search(self.custom_extraction_regex, text)
- if match:
- ext["custom_extract"] = match.group(1) if match.lastindex else match.group(0)
- except re.error:
- pass
-
- if self.custom_extractors and text:
- fields = run_extractors(text, self.custom_extractors)
- if fields:
- ext["custom_fields"] = json.dumps(fields)
+ self.page_builder.apply_custom_extractions(ext, text)
if self.polite_delay:
time.sleep(self.polite_delay)
- if result.browser_diagnostics:
- ext["page_analysis"] = merge_browser_into_page_analysis(
- ext.get("page_analysis"), result.browser_diagnostics
- )
+ PageRecordBuilder.merge_browser_diagnostics(ext, result)
+
+ if text:
+ self._capture_page_html(url, text, status, ct, fetch_method)
res = {
"url": url,
@@ -605,18 +397,12 @@ def worker(self, url):
res["outlink_targets"] = json.dumps(list(outlink_list))
return res
- def _queue_contains(self, item):
- try:
- return item in list(self.queue.queue)
- except Exception:
- return False
-
def crawl(
self,
show_progress: bool = True,
stream_crawl_run_id: Optional[int] = None,
stream_batch_size: int = 500,
- ):
+ ) -> pd.DataFrame:
start_time = time.time()
from ..progress import CrawlProgressTracker, emit_phase_start
@@ -628,10 +414,17 @@ def crawl(
)
emit_phase_start("crawl", message="Crawling pages")
futures = []
- db_writer: Optional[_CrawlDbWriter] = None
+ db_writer: Optional[CrawlDbWriter] = None
pages_crawled = 0
+ self._db_writer = None
+ self._html_buffer = []
if stream_crawl_run_id is not None:
- db_writer = _CrawlDbWriter(stream_crawl_run_id, stream_batch_size)
+ db_writer = _CrawlDbWriter(
+ stream_crawl_run_id,
+ stream_batch_size,
+ store_page_html=self.store_page_html,
+ )
+ self._db_writer = db_writer
db_writer.start()
use_tqdm = show_progress and stream_crawl_run_id is None
pbar = tqdm(
@@ -650,12 +443,10 @@ def crawl(
and len(self.results) + len(futures) < self.max_pages
):
url = self.queue.get()
- if _url_matches_exclude(url, self.exclude_urls):
+ if self.frontier.should_skip_dequeued(url):
+ continue
+ if not self.frontier.mark_visited(url):
continue
- with self.lock:
- if url in self.visited:
- continue
- self.visited.add(url)
futures.append(ex.submit(self.worker, url))
remaining = []
@@ -664,60 +455,7 @@ def crawl(
try:
res = f.result()
except Exception:
- res = {
- "url": None,
- "status": "error",
- "content_type": "",
- "title": "",
- "outlinks": 0,
- "response_time_ms": "",
- "content_length": 0,
- "final_url": "",
- "meta_description": "",
- "meta_description_len": 0,
- "h1": "",
- "h1_count": 0,
- "canonical_url": "",
- "viewport_present": False,
- "viewport_content": "",
- "noindex": False,
- "has_schema": False,
- "heading_sequence": "",
- "heading_text": "",
- "images_without_alt": 0,
- "images_total": 0,
- "img_without_lazy": 0,
- "img_without_dimensions": 0,
- "aria_count": 0,
- "mixed_content_count": 0,
- "redirect_chain_length": 0,
- "cache_control": "",
- "etag": "",
- "x_robots_tag": "",
- "strict_transport_security": "",
- "x_content_type_options": "",
- "x_frame_options": "",
- "content_security_policy": "",
- "script_count": 0,
- "link_stylesheet_count": 0,
- "total_js_bytes": 0,
- "total_css_bytes": 0,
- "word_count": 0,
- "reading_level": 0.0,
- "content_html_ratio": 0.0,
- "top_keywords": "[]",
- "content_excerpt": "",
- "og_title": "",
- "og_description": "",
- "og_image": "",
- "og_type": "",
- "twitter_card": "",
- "twitter_title": "",
- "twitter_image": "",
- "tech_stack": "[]",
- "depth": None,
- "page_analysis": "{}",
- }
+ res = empty_crawl_row(status="error")
if self.store_outlinks:
res["outlink_targets"] = "[]"
self.results.append(res)
@@ -729,10 +467,7 @@ def crawl(
db_writer.enqueue(res)
if use_tqdm:
pbar.update(1)
- progress_tracker.maybe_emit(
- pages_crawled,
- page_url,
- )
+ progress_tracker.maybe_emit(pages_crawled, page_url)
else:
remaining.append(f)
futures = remaining
@@ -755,117 +490,15 @@ def crawl(
db_writer.finish()
db_writer.join()
db_writer.raise_if_failed()
+ self._db_writer = None
elapsed = time.time() - start_time
df = pd.DataFrame(self.results)
if df.empty:
- cols = [
- "url",
- "status",
- "content_type",
- "title",
- "outlinks",
- "response_time_ms",
- "content_length",
- "final_url",
- "meta_description",
- "meta_description_len",
- "h1",
- "h1_count",
- "canonical_url",
- "viewport_present",
- "viewport_content",
- "noindex",
- "has_schema",
- "heading_sequence",
- "heading_text",
- "images_without_alt",
- "images_total",
- "img_without_lazy",
- "img_without_dimensions",
- "aria_count",
- "mixed_content_count",
- "redirect_chain_length",
- "cache_control",
- "etag",
- "x_robots_tag",
- "strict_transport_security",
- "x_content_type_options",
- "x_frame_options",
- "content_security_policy",
- "script_count",
- "link_stylesheet_count",
- "total_js_bytes",
- "total_css_bytes",
- "word_count",
- "reading_level",
- "content_html_ratio",
- "top_keywords",
- "content_excerpt",
- "og_title",
- "og_description",
- "og_image",
- "og_type",
- "twitter_card",
- "twitter_title",
- "twitter_image",
- "tech_stack",
- "depth",
- "page_analysis",
- "fetch_method",
- ]
- if self.store_outlinks:
- cols.append("outlink_targets")
- df = pd.DataFrame(columns=cols)
+ df = pd.DataFrame(columns=crawl_dataframe_columns(store_outlinks=self.store_outlinks))
df["crawl_time_s"] = elapsed
return df
-class _CrawlDbWriter(threading.Thread):
- """Background thread: batch-insert crawl rows via PostgreSQL connection pool."""
-
- def __init__(self, crawl_run_id: int, batch_size: int = 500) -> None:
- super().__init__(daemon=True)
- self.crawl_run_id = crawl_run_id
- self.batch_size = max(50, batch_size)
- self._queue: Queue = Queue()
- self._error: Optional[BaseException] = None
-
- def enqueue(self, record: dict) -> None:
- self._queue.put(record)
-
- def finish(self) -> None:
- self._queue.put(None)
-
- def run(self) -> None:
- from ..db import db_session
- from ..db.crawl_store import _crawl_rows_from_df, write_crawl_batch
-
- buffer: list[dict] = []
- try:
- while True:
- item = self._queue.get()
- if item is None:
- if buffer:
- chunk = pd.DataFrame(buffer)
- with db_session() as conn:
- rows = _crawl_rows_from_df(chunk, self.crawl_run_id)
- write_crawl_batch(conn, rows, self.crawl_run_id, commit=True)
- break
- buffer.append(item)
- if len(buffer) >= self.batch_size:
- chunk = pd.DataFrame(buffer)
- buffer = []
- with db_session() as conn:
- rows = _crawl_rows_from_df(chunk, self.crawl_run_id)
- write_crawl_batch(conn, rows, self.crawl_run_id, commit=True)
- except BaseException as e:
- self._error = e
-
- def raise_if_failed(self) -> None:
- if self._error is not None:
- raise self._error
-
-
def run_crawler(
start_url: str,
max_pages: Optional[int] = None,
@@ -883,6 +516,11 @@ def run_crawler(
preserve_crawl_history: bool = True,
store_content_excerpt: bool = False,
content_excerpt_max_chars: int = 4096,
+ store_page_html: bool = False,
+ max_stored_html_bytes: int = 2_097_152,
+ run_content_analysis: bool = False,
+ content_analysis_strategy: str = "main_only",
+ content_analysis_workers: int = 4,
crawl_stream_to_db: bool = False,
property_id: Optional[int] = None,
render_mode: str = "static",
@@ -932,6 +570,11 @@ def run_crawler(
exclude_urls=exclude_urls,
store_content_excerpt=store_content_excerpt,
content_excerpt_max_chars=content_excerpt_max_chars,
+ store_page_html=store_page_html,
+ max_stored_html_bytes=max_stored_html_bytes,
+ run_content_analysis=run_content_analysis,
+ content_analysis_strategy=content_analysis_strategy,
+ content_analysis_workers=content_analysis_workers,
render_mode=render_mode,
js_concurrency=js_concurrency,
js_timeout=js_timeout,
@@ -998,7 +641,6 @@ def run_crawler(
with db_session() as conn:
write_link_edges(conn, crawler.link_edges_accum, crawl_run_id=run_id)
if output_db and not df.empty and stream_run_id is None:
- import sys
console_print(" Writing crawl results to DB...", flush=True)
from ..db import backup_db_if_exists, create_crawl_run, db_session, read_historical_data, restore_historical_data, write_crawl
from ..db.storage import ensure_crawl_tables_cleared
@@ -1022,6 +664,11 @@ def run_crawler(
discovery_mode=disc_label,
)
write_crawl(conn, df, crawl_run_id=run_id)
+ html_buffer = getattr(crawler, "_html_buffer", None) or []
+ if getattr(crawler, "store_page_html", False) and html_buffer:
+ from ..db.html_store import write_page_html_batch
+
+ write_page_html_batch(conn, html_buffer, run_id, commit=True)
if crawler.link_edges_accum:
from ..db.crawl_store import write_link_edges
diff --git a/src/website_profiling/crawl/db_writer.py b/src/website_profiling/crawl/db_writer.py
new file mode 100644
index 00000000..302ba7fc
--- /dev/null
+++ b/src/website_profiling/crawl/db_writer.py
@@ -0,0 +1,84 @@
+"""Background thread: batch-insert crawl rows and optional HTML via PostgreSQL connection pool."""
+
+from __future__ import annotations
+
+import threading
+from queue import Queue
+from typing import Optional
+
+import pandas as pd
+
+
+class CrawlDbWriter(threading.Thread):
+ """Background thread: batch-insert crawl rows and optional page HTML."""
+
+ def __init__(self, crawl_run_id: int, batch_size: int = 500, *, store_page_html: bool = False) -> None:
+ super().__init__(daemon=True)
+ self.crawl_run_id = crawl_run_id
+ self.batch_size = max(50, batch_size)
+ self.store_page_html = bool(store_page_html)
+ self._queue: Queue = Queue()
+ self._error: Optional[BaseException] = None
+
+ def enqueue(self, record: dict) -> None:
+ self._queue.put(("crawl", record))
+
+ def enqueue_html(self, record: dict) -> None:
+ if not self.store_page_html:
+ return
+ self._queue.put(("html", record))
+
+ def finish(self) -> None:
+ self._queue.put(None)
+
+ def _flush_crawl(self, buffer: list[dict]) -> None:
+ if not buffer:
+ return
+ from ..db import db_session
+ from ..db.crawl_store import _crawl_rows_from_df, write_crawl_batch
+
+ chunk = pd.DataFrame(buffer)
+ with db_session() as conn:
+ rows = _crawl_rows_from_df(chunk, self.crawl_run_id)
+ write_crawl_batch(conn, rows, self.crawl_run_id, commit=True)
+
+ def _flush_html(self, buffer: list[dict]) -> None:
+ if not buffer:
+ return
+ from ..db import db_session
+ from ..db.html_store import write_page_html_batch
+
+ with db_session() as conn:
+ write_page_html_batch(conn, buffer, self.crawl_run_id, commit=True)
+
+ def run(self) -> None:
+ crawl_buffer: list[dict] = []
+ html_buffer: list[dict] = []
+ try:
+ while True:
+ item = self._queue.get()
+ if item is None:
+ self._flush_crawl(crawl_buffer)
+ self._flush_html(html_buffer)
+ break
+ kind, payload = item
+ if kind == "html":
+ html_buffer.append(payload)
+ if len(html_buffer) >= self.batch_size:
+ self._flush_html(html_buffer)
+ html_buffer = []
+ else:
+ crawl_buffer.append(payload)
+ if len(crawl_buffer) >= self.batch_size:
+ self._flush_crawl(crawl_buffer)
+ crawl_buffer = []
+ except BaseException as e:
+ self._error = e
+
+ def raise_if_failed(self) -> None:
+ if self._error is not None:
+ raise self._error
+
+
+# Backward-compatible alias for tests and internal imports.
+_CrawlDbWriter = CrawlDbWriter
diff --git a/src/website_profiling/crawl/frontier.py b/src/website_profiling/crawl/frontier.py
new file mode 100644
index 00000000..cb1e65ac
--- /dev/null
+++ b/src/website_profiling/crawl/frontier.py
@@ -0,0 +1,156 @@
+"""Crawl frontier: URL queue, depth tracking, and link discovery."""
+
+from __future__ import annotations
+
+import threading
+from queue import Queue
+from typing import Optional
+from urllib.parse import urlparse
+
+import requests
+
+from ..common import load_robots
+from . import sitemap
+from .discovery import seed_sitemap_for_mode
+
+
+def url_matches_exclude(url: str, exclude_urls: list[str]) -> bool:
+ """True if url equals or is under any exclude prefix (trailing-slash normalized)."""
+ if not exclude_urls:
+ return False
+ u = url.rstrip("/")
+ for prefix in exclude_urls:
+ p = prefix.strip().rstrip("/")
+ if not p:
+ continue
+ if u == p or u.startswith(p + "/"):
+ return True
+ return False
+
+
+class CrawlFrontier:
+ """Manages crawl queue, visited set, depth limits, and robots rules."""
+
+ def __init__(
+ self,
+ start_url: str,
+ *,
+ allow_external: bool = False,
+ max_depth: Optional[int] = None,
+ exclude_urls: Optional[list[str]] = None,
+ follow_links: bool = True,
+ ignore_robots: bool = False,
+ user_agent: str = "",
+ crawl_robots_txt_override: str = "",
+ ) -> None:
+ self.start_url = start_url.rstrip("/")
+ self.start_netloc = urlparse(self.start_url).netloc
+ self.allow_external = allow_external
+ self.max_depth = max_depth
+ self.exclude_urls = list(exclude_urls) if exclude_urls else []
+ self.follow_links = follow_links
+ self.user_agent = user_agent
+ self.queue: Queue = Queue()
+ self.depths: dict[str, int] = {}
+ self.visited: set[str] = set()
+ self.lock = threading.Lock()
+ self.rp = None
+ if not ignore_robots:
+ override = (crawl_robots_txt_override or "").strip()
+ if override:
+ import urllib.robotparser as robotparser
+
+ self.rp = robotparser.RobotFileParser()
+ self.rp.parse(override.splitlines())
+ else:
+ self.rp = load_robots(self.start_url)
+
+ def same_domain(self, url: str) -> bool:
+ return urlparse(url).netloc == self.start_netloc
+
+ def allowed_by_robots(self, url: str) -> bool:
+ if not self.rp:
+ return True
+ try:
+ return self.rp.can_fetch(self.user_agent, url)
+ except Exception:
+ return True
+
+ def queue_contains(self, item: str) -> bool:
+ try:
+ return item in list(self.queue.queue)
+ except Exception:
+ return False
+
+ def enqueue_seed(self, url: str, depth: int = 0) -> None:
+ u = url.rstrip("/")
+ if url_matches_exclude(u, self.exclude_urls):
+ return
+ if not self.allow_external and not self.same_domain(u):
+ return
+ if u in self.depths:
+ return
+ self.queue.put(u)
+ self.depths[u] = depth
+
+ def seed_initial_urls(
+ self,
+ *,
+ discovery_mode: str,
+ crawl_url_list: list[str],
+ timeout: int,
+ session: requests.Session,
+ ) -> None:
+ mode = discovery_mode
+ if mode in ("list", "hybrid"):
+ for url in crawl_url_list:
+ self.enqueue_seed(url, 0)
+ if mode in ("spider", "hybrid"):
+ self.enqueue_seed(self.start_url, 0)
+ if seed_sitemap_for_mode(mode):
+ self.seed_sitemap_urls(timeout, session)
+
+ def seed_sitemap_urls(self, timeout: int, session: requests.Session) -> None:
+ try:
+ seeds = sitemap.discover_sitemap_urls(
+ self.start_url,
+ timeout=timeout,
+ session=session,
+ )
+ except Exception:
+ return
+ for url in seeds:
+ self.enqueue_seed(url, 0)
+
+ def try_enqueue_link(self, link: str, from_url: str) -> bool:
+ """Enqueue a discovered link if frontier rules allow. Returns True if enqueued."""
+ if not self.follow_links:
+ return False
+ if url_matches_exclude(link, self.exclude_urls):
+ return False
+ if not self.allow_external and not self.same_domain(link):
+ return False
+ cur_depth = self.depths.get(from_url, 0)
+ if self.max_depth is not None and cur_depth >= self.max_depth:
+ return False
+ with self.lock:
+ if (
+ link not in self.visited
+ and link not in self.depths
+ and not self.queue_contains(link)
+ ):
+ self.queue.put(link)
+ self.depths[link] = cur_depth + 1
+ return True
+ return False
+
+ def mark_visited(self, url: str) -> bool:
+ """Mark URL visited; return False if already visited."""
+ with self.lock:
+ if url in self.visited:
+ return False
+ self.visited.add(url)
+ return True
+
+ def should_skip_dequeued(self, url: str) -> bool:
+ return url_matches_exclude(url, self.exclude_urls)
diff --git a/src/website_profiling/crawl/html_capture.py b/src/website_profiling/crawl/html_capture.py
new file mode 100644
index 00000000..021947ee
--- /dev/null
+++ b/src/website_profiling/crawl/html_capture.py
@@ -0,0 +1,60 @@
+"""Helpers for deciding whether and how to persist fetched HTML."""
+from __future__ import annotations
+
+from typing import Any, Optional
+
+
+def _is_html_content_type(content_type: str | None) -> bool:
+ ct = (content_type or "").lower()
+ return "text/html" in ct or "application/xhtml+xml" in ct
+
+
+def should_store_page_html(
+ *,
+ enabled: bool,
+ status: Any,
+ content_type: str | None,
+ html: str | None,
+ max_bytes: int,
+) -> bool:
+ """True when HTML should be persisted for a crawl URL."""
+ if not enabled or not html or not str(html).strip():
+ return False
+ try:
+ if int(status) != 200:
+ return False
+ except (TypeError, ValueError):
+ return False
+ if not _is_html_content_type(content_type):
+ return False
+ return len(str(html).encode("utf-8")) <= max(1, int(max_bytes))
+
+
+def build_page_html_record(
+ *,
+ url: str,
+ html: str,
+ status: Any,
+ content_type: str | None,
+ fetch_method: str,
+ max_bytes: int,
+ enabled: bool = True,
+) -> Optional[dict[str, Any]]:
+ """Build a side-channel HTML record, or None if storage should be skipped."""
+ if not should_store_page_html(
+ enabled=enabled,
+ status=status,
+ content_type=content_type,
+ html=html,
+ max_bytes=max_bytes,
+ ):
+ return None
+ text = str(html)
+ return {
+ "url": str(url or "").rstrip("/"),
+ "html": text,
+ "status": str(status),
+ "content_type": str(content_type or ""),
+ "fetch_method": str(fetch_method or "static").strip() or "static",
+ "byte_length": len(text.encode("utf-8")),
+ }
diff --git a/src/website_profiling/crawl/page_record.py b/src/website_profiling/crawl/page_record.py
new file mode 100644
index 00000000..c76322a1
--- /dev/null
+++ b/src/website_profiling/crawl/page_record.py
@@ -0,0 +1,229 @@
+"""Build crawl page records from fetched HTML."""
+
+from __future__ import annotations
+
+import json
+import re
+from typing import Any, Optional
+
+from ..analysis.page import analyze_html
+from ..common import (
+ detect_tech_wappalyzer,
+ parse_content_text,
+ parse_link_edges,
+ parse_resources,
+ parse_seo,
+ parse_seo_extended,
+ parse_social_meta,
+ parse_tech_stack,
+)
+from .extraction import run_extractors
+from .fetchers.base import FetchResult
+from .fetchers.browser_diagnostics import merge_browser_into_page_analysis
+from .fetchers.hybrid import HybridFetcher
+from .fetchers.spa_heuristics import needs_js_render_after_parse
+from .schema import empty_crawl_row, empty_crawl_row_ext
+
+
+class PageRecordBuilder:
+ """Extract SEO/content fields and assemble crawl result rows."""
+
+ def __init__(
+ self,
+ *,
+ use_wappalyzer: bool = True,
+ store_content_excerpt: bool = False,
+ content_excerpt_max_chars: int = 4096,
+ defer_content_analysis: bool = False,
+ custom_extraction_regex: str = "",
+ custom_extractors: Optional[list[dict]] = None,
+ ) -> None:
+ self.use_wappalyzer = use_wappalyzer
+ self.store_content_excerpt = store_content_excerpt
+ self.content_excerpt_max_chars = content_excerpt_max_chars
+ self.defer_content_analysis = defer_content_analysis
+ self.custom_extraction_regex = custom_extraction_regex
+ self.custom_extractors = list(custom_extractors or [])
+ self._wappalyzer_instance = None
+
+ def empty_ext(
+ self,
+ url: str,
+ headers_dict: Optional[dict] = None,
+ redirect_chain_length: int = 0,
+ ) -> dict[str, Any]:
+ return empty_crawl_row_ext(url, headers_dict, redirect_chain_length)
+
+ def parse_page_content(
+ self,
+ url: str,
+ text: str,
+ final_url: str,
+ headers_dict: dict,
+ redirect_chain_length: int,
+ ) -> dict[str, Any]:
+ """Extract title, links, and SEO/content fields from HTML."""
+ ext = self.empty_ext(url, headers_dict, redirect_chain_length)
+ title, link_edge_rows = parse_link_edges(url, text)
+ links = {e["to_url"] for e in link_edge_rows}
+ meta_description, meta_description_len, h1_text, h1_count, canonical_url = parse_seo(
+ url, text
+ )
+ seo_ext = parse_seo_extended(text, final_url or url)
+ ext["viewport_present"] = seo_ext.get("viewport_present", False)
+ ext["viewport_content"] = seo_ext.get("viewport_content", "")
+ ext["noindex"] = seo_ext.get("noindex", False)
+ if (headers_dict.get("X-Robots-Tag") or "").lower().find("noindex") >= 0:
+ ext["noindex"] = True
+ ext["has_schema"] = seo_ext.get("has_schema", False)
+ ext["heading_sequence"] = ",".join(seo_ext.get("heading_sequence") or [])
+ ext["heading_text"] = " | ".join(seo_ext.get("heading_text") or [])
+ ext["images_without_alt"] = seo_ext.get("images_without_alt", 0)
+ ext["images_total"] = seo_ext.get("images_total", 0)
+ ext["img_without_lazy"] = seo_ext.get("img_without_lazy", 0)
+ ext["img_without_dimensions"] = seo_ext.get("img_without_dimensions", 0)
+ ext["aria_count"] = seo_ext.get("aria_count", 0)
+ ext["mixed_content_count"] = seo_ext.get("mixed_content_count", 0)
+ res_res = parse_resources(text, final_url or url)
+ ext["script_count"] = res_res.get("script_count", 0)
+ ext["link_stylesheet_count"] = res_res.get("link_stylesheet_count", 0)
+ from bs4 import BeautifulSoup as _BS
+
+ _soup = _BS(text, "lxml")
+ excerpt_max = self.content_excerpt_max_chars if self.store_content_excerpt else 0
+ if not self.defer_content_analysis:
+ ct_data = parse_content_text(_soup, text, excerpt_max_chars=excerpt_max)
+ ext["word_count"] = ct_data.get("word_count", 0)
+ ext["reading_level"] = ct_data.get("reading_level", 0.0)
+ ext["content_html_ratio"] = ct_data.get("content_html_ratio", 0.0)
+ ext["top_keywords"] = ct_data.get("top_keywords", "[]")
+ ext["content_excerpt"] = ct_data.get("content_excerpt") or ""
+ social = parse_social_meta(_soup)
+ ext["og_title"] = social.get("og_title", "")
+ ext["og_description"] = social.get("og_description", "")
+ ext["og_image"] = social.get("og_image", "")
+ ext["og_type"] = social.get("og_type", "")
+ ext["twitter_card"] = social.get("twitter_card", "")
+ ext["twitter_title"] = social.get("twitter_title", "")
+ ext["twitter_image"] = social.get("twitter_image", "")
+ if self.use_wappalyzer:
+ ext["tech_stack"] = detect_tech_wappalyzer(
+ final_url or url, text, headers_dict, _soup, self._wappalyzer_instance
+ )
+ else:
+ ext["tech_stack"] = parse_tech_stack(_soup, headers_dict, final_url or url)
+ ext["page_analysis"] = json.dumps(
+ analyze_html(text, final_url or url, final_url or url, canonical_url)
+ )
+ return {
+ "title": title,
+ "links": links,
+ "link_edges": link_edge_rows,
+ "meta_description": meta_description,
+ "meta_description_len": meta_description_len,
+ "h1_text": h1_text,
+ "h1_count": h1_count,
+ "canonical_url": canonical_url,
+ "ext": ext,
+ }
+
+ def apply_custom_extractions(self, ext: dict[str, Any], text: Optional[str]) -> None:
+ if self.custom_extraction_regex and text:
+ try:
+ match = re.search(self.custom_extraction_regex, text)
+ if match:
+ ext["custom_extract"] = match.group(1) if match.lastindex else match.group(0)
+ except re.error:
+ pass
+ if self.custom_extractors and text:
+ fields = run_extractors(text, self.custom_extractors)
+ if fields:
+ ext["custom_fields"] = json.dumps(fields)
+
+ @staticmethod
+ def maybe_refetch_after_parse(
+ url: str,
+ result: FetchResult,
+ *,
+ render_mode: str,
+ hybrid_fetcher: Optional[HybridFetcher],
+ link_count: int,
+ same_domain_link_count: int,
+ ) -> FetchResult:
+ """Post-parse auto-mode fallback when static HTML has too few links."""
+ if render_mode != "auto" or hybrid_fetcher is None:
+ return result
+ if result.fetch_method != "static":
+ return result
+ if not needs_js_render_after_parse(
+ result,
+ link_count=link_count,
+ same_domain_link_count=same_domain_link_count,
+ ):
+ return result
+ rendered = hybrid_fetcher.refetch_rendered(url)
+ if rendered.status == 200 and rendered.text:
+ return rendered
+ return result
+
+ @staticmethod
+ def sync_from_fetch_result(
+ result: FetchResult,
+ url: str,
+ *,
+ content_length: int,
+ headers_dict: dict,
+ ) -> dict[str, Any]:
+ """Copy FetchResult fields after a post-parse browser refetch."""
+ return {
+ "text": result.text,
+ "fetch_method": result.fetch_method,
+ "final_url": result.final_url or url,
+ "content_length": result.content_length or content_length,
+ "response_time_ms": result.response_time_ms,
+ "headers_dict": result.headers_dict or headers_dict,
+ "redirect_chain_length": result.redirect_chain_length,
+ "status": result.status,
+ "ct": result.content_type,
+ }
+
+ @staticmethod
+ def build_robots_blocked_row(url: str, *, store_outlinks: bool) -> dict[str, Any]:
+ row = empty_crawl_row(
+ url=url,
+ status="blocked_by_robots",
+ fetch_method="static",
+ )
+ if store_outlinks:
+ row["outlink_targets"] = "[]"
+ return row
+
+ @staticmethod
+ def build_fetch_error_row(
+ url: str,
+ result: FetchResult,
+ *,
+ fetch_method: str,
+ store_outlinks: bool,
+ ) -> dict[str, Any]:
+ row = empty_crawl_row(
+ url=url,
+ status="error",
+ fetch_method=fetch_method,
+ headers_dict=result.headers_dict or {},
+ redirect_chain_length=result.redirect_chain_length,
+ )
+ if store_outlinks:
+ row["outlink_targets"] = "[]"
+ if result.browser_diagnostics:
+ row["page_analysis"] = merge_browser_into_page_analysis(
+ None, result.browser_diagnostics
+ )
+ return row
+
+ @staticmethod
+ def merge_browser_diagnostics(ext: dict[str, Any], result: FetchResult) -> None:
+ if result.browser_diagnostics:
+ ext["page_analysis"] = merge_browser_into_page_analysis(
+ ext.get("page_analysis"), result.browser_diagnostics
+ )
diff --git a/src/website_profiling/crawl/schema.py b/src/website_profiling/crawl/schema.py
new file mode 100644
index 00000000..3d3cf077
--- /dev/null
+++ b/src/website_profiling/crawl/schema.py
@@ -0,0 +1,154 @@
+"""Crawl row schema: single source for DataFrame columns and default field values."""
+
+from __future__ import annotations
+
+from typing import Any, Optional
+
+# Core crawl columns (excluding optional outlink_targets).
+CRAWL_ROW_COLUMNS: list[str] = [
+ "url",
+ "status",
+ "content_type",
+ "title",
+ "outlinks",
+ "response_time_ms",
+ "content_length",
+ "final_url",
+ "meta_description",
+ "meta_description_len",
+ "h1",
+ "h1_count",
+ "canonical_url",
+ "viewport_present",
+ "viewport_content",
+ "noindex",
+ "has_schema",
+ "heading_sequence",
+ "heading_text",
+ "images_without_alt",
+ "images_total",
+ "img_without_lazy",
+ "img_without_dimensions",
+ "aria_count",
+ "mixed_content_count",
+ "redirect_chain_length",
+ "cache_control",
+ "etag",
+ "x_robots_tag",
+ "strict_transport_security",
+ "x_content_type_options",
+ "x_frame_options",
+ "content_security_policy",
+ "script_count",
+ "link_stylesheet_count",
+ "total_js_bytes",
+ "total_css_bytes",
+ "word_count",
+ "reading_level",
+ "content_html_ratio",
+ "top_keywords",
+ "content_excerpt",
+ "og_title",
+ "og_description",
+ "og_image",
+ "og_type",
+ "twitter_card",
+ "twitter_title",
+ "twitter_image",
+ "tech_stack",
+ "depth",
+ "page_analysis",
+ "fetch_method",
+]
+
+
+def empty_crawl_row_ext(
+ url: str,
+ headers_dict: Optional[dict] = None,
+ redirect_chain_length: int = 0,
+) -> dict[str, Any]:
+ """Default SEO/performance extension fields when no HTML or on error."""
+ h = headers_dict or {}
+ return {
+ "response_time_ms": "",
+ "content_length": 0,
+ "final_url": url,
+ "meta_description": "",
+ "meta_description_len": 0,
+ "h1": "",
+ "h1_count": 0,
+ "canonical_url": "",
+ "viewport_present": False,
+ "viewport_content": "",
+ "noindex": False,
+ "has_schema": False,
+ "heading_sequence": "",
+ "heading_text": "",
+ "images_without_alt": 0,
+ "images_total": 0,
+ "img_without_lazy": 0,
+ "img_without_dimensions": 0,
+ "aria_count": 0,
+ "mixed_content_count": 0,
+ "redirect_chain_length": redirect_chain_length,
+ "cache_control": h.get("Cache-Control", ""),
+ "etag": h.get("ETag", ""),
+ "x_robots_tag": h.get("X-Robots-Tag", ""),
+ "strict_transport_security": h.get("Strict-Transport-Security", ""),
+ "x_content_type_options": h.get("X-Content-Type-Options", ""),
+ "x_frame_options": h.get("X-Frame-Options", ""),
+ "content_security_policy": h.get("Content-Security-Policy", ""),
+ "script_count": 0,
+ "link_stylesheet_count": 0,
+ "total_js_bytes": 0,
+ "total_css_bytes": 0,
+ "word_count": 0,
+ "reading_level": 0.0,
+ "content_html_ratio": 0.0,
+ "top_keywords": "[]",
+ "content_excerpt": "",
+ "og_title": "",
+ "og_description": "",
+ "og_image": "",
+ "og_type": "",
+ "twitter_card": "",
+ "twitter_title": "",
+ "twitter_image": "",
+ "tech_stack": "[]",
+ "depth": None,
+ "page_analysis": "{}",
+ }
+
+
+def empty_crawl_row(
+ url: Optional[str] = None,
+ status: str | int = "error",
+ *,
+ content_type: str = "",
+ title: str = "",
+ outlinks: int = 0,
+ fetch_method: str = "static",
+ headers_dict: Optional[dict] = None,
+ redirect_chain_length: int = 0,
+ **overrides: Any,
+) -> dict[str, Any]:
+ """Build a full crawl result row with defaults; overrides merge on top."""
+ row: dict[str, Any] = {
+ "url": url,
+ "status": status,
+ "content_type": content_type,
+ "title": title,
+ "outlinks": outlinks,
+ "fetch_method": fetch_method,
+ **empty_crawl_row_ext(url or "", headers_dict, redirect_chain_length),
+ }
+ row.update(overrides)
+ return row
+
+
+def crawl_dataframe_columns(*, store_outlinks: bool = False) -> list[str]:
+ """Column list for an empty crawl DataFrame."""
+ cols = list(CRAWL_ROW_COLUMNS)
+ if store_outlinks:
+ cols.append("outlink_targets")
+ return cols
diff --git a/src/website_profiling/db/crawl_store.py b/src/website_profiling/db/crawl_store.py
index a4122d17..fc50d327 100644
--- a/src/website_profiling/db/crawl_store.py
+++ b/src/website_profiling/db/crawl_store.py
@@ -234,6 +234,10 @@ def write_crawl(conn: Connection, df: pd.DataFrame, crawl_run_id: Optional[int]
with conn.transaction():
if crawl_run_id is not None:
conn.execute("DELETE FROM crawl_results WHERE crawl_run_id = %s", (crawl_run_id,))
+ try:
+ conn.execute("DELETE FROM crawl_page_html WHERE crawl_run_id = %s", (crawl_run_id,))
+ except Exception:
+ pass
target_run_id = crawl_run_id
else:
conn.execute("DELETE FROM crawl_results")
@@ -251,6 +255,39 @@ def write_crawl(conn: Connection, df: pd.DataFrame, crawl_run_id: Optional[int]
_write_crawl_rows(conn, rows)
+_MERGE_FIELDS_BATCH_SIZE = 200
+_MERGE_FIELDS_SQL = """UPDATE crawl_results
+SET data = COALESCE(data, '{}'::jsonb) || %s::jsonb
+WHERE crawl_run_id = %s AND url = %s"""
+
+
+def merge_crawl_result_fields_batch(
+ conn: Connection,
+ crawl_run_id: int,
+ updates: list[dict[str, Any]],
+ *,
+ commit: bool = True,
+) -> int:
+ """Merge per-URL content fields into crawl_results.data JSONB. Returns rows updated."""
+ if not updates:
+ return 0
+ params: list[tuple] = []
+ for item in updates:
+ url = str(item.get("url") or "").rstrip("/")
+ if not url:
+ continue
+ fields = {k: _sanitize_for_json(v) for k, v in item.items() if k != "url"}
+ if not fields:
+ continue
+ params.append((_json_val(fields), crawl_run_id, url))
+ if not params:
+ return 0
+ _executemany(conn, _MERGE_FIELDS_SQL, params, page_size=_MERGE_FIELDS_BATCH_SIZE)
+ if commit:
+ conn.commit()
+ return len(params)
+
+
def read_crawl(conn: Connection, run_id: Optional[int] = None) -> pd.DataFrame:
try:
return _read_crawl_rows(conn, run_id, include_fetch_method=True)
diff --git a/src/website_profiling/db/historical.py b/src/website_profiling/db/historical.py
index 0879c28e..b1e62798 100644
--- a/src/website_profiling/db/historical.py
+++ b/src/website_profiling/db/historical.py
@@ -207,7 +207,7 @@ def _bulk(
def ensure_crawl_tables_cleared(conn: Connection) -> None:
"""Clear crawl-scoped tables before a non-append crawl (preserves reports, Google, etc.)."""
- conn.execute("TRUNCATE crawl_results, edges, nodes RESTART IDENTITY CASCADE")
+ conn.execute("TRUNCATE crawl_results, crawl_page_html, edges, nodes RESTART IDENTITY CASCADE")
conn.commit()
diff --git a/src/website_profiling/db/html_store.py b/src/website_profiling/db/html_store.py
new file mode 100644
index 00000000..5b5a96d5
--- /dev/null
+++ b/src/website_profiling/db/html_store.py
@@ -0,0 +1,112 @@
+"""Per-URL raw HTML storage for crawl runs."""
+from __future__ import annotations
+
+from typing import Any, Iterator, Optional
+
+from psycopg import Connection
+
+from ._common import _executemany, _now_iso
+
+_HTML_BATCH_SIZE = 200
+
+_HTML_UPSERT_SQL = """INSERT INTO crawl_page_html (
+ crawl_run_id, url, html, status, content_type, fetch_method, byte_length, captured_at
+) VALUES (%s, %s, %s, %s, %s, %s, %s, %s)
+ON CONFLICT (crawl_run_id, url) DO UPDATE SET
+ html = EXCLUDED.html,
+ status = EXCLUDED.status,
+ content_type = EXCLUDED.content_type,
+ fetch_method = EXCLUDED.fetch_method,
+ byte_length = EXCLUDED.byte_length,
+ captured_at = EXCLUDED.captured_at"""
+
+
+def _normalize_url(url: str) -> str:
+ return str(url or "").rstrip("/")
+
+
+def _rows_from_records(records: list[dict[str, Any]], crawl_run_id: int) -> list[tuple]:
+ rows: list[tuple] = []
+ captured_at = _now_iso()
+ for rec in records:
+ url = _normalize_url(str(rec.get("url") or ""))
+ html = rec.get("html")
+ if not url or not html:
+ continue
+ status = str(rec.get("status") or "") if rec.get("status") is not None else None
+ content_type = str(rec.get("content_type") or "") if rec.get("content_type") is not None else None
+ fetch_method = str(rec.get("fetch_method") or "static").strip() or "static"
+ byte_length = int(rec.get("byte_length") or len(str(html).encode("utf-8")))
+ rows.append(
+ (crawl_run_id, url, str(html), status, content_type, fetch_method, byte_length, captured_at)
+ )
+ return rows
+
+
+def write_page_html_batch(
+ conn: Connection,
+ records: list[dict[str, Any]],
+ crawl_run_id: int,
+ *,
+ commit: bool = True,
+) -> None:
+ """Upsert HTML rows for a crawl run (each record: url, html, status, content_type, fetch_method, byte_length)."""
+ rows = _rows_from_records(records, crawl_run_id)
+ if not rows:
+ return
+ _executemany(conn, _HTML_UPSERT_SQL, rows, page_size=_HTML_BATCH_SIZE)
+ if commit:
+ conn.commit()
+
+
+def read_page_html(conn: Connection, crawl_run_id: int, url: str) -> Optional[dict[str, Any]]:
+ """Return stored HTML and metadata for one URL, or None."""
+ norm = _normalize_url(url)
+ if not norm:
+ return None
+ try:
+ cur = conn.execute(
+ """SELECT url, html, status, content_type, fetch_method, byte_length, captured_at
+ FROM crawl_page_html
+ WHERE crawl_run_id = %s AND url = %s""",
+ (crawl_run_id, norm),
+ )
+ row = cur.fetchone()
+ if row is None:
+ return None
+ return dict(row)
+ except Exception:
+ return None
+
+
+def read_page_html_for_run(
+ conn: Connection,
+ crawl_run_id: int,
+ *,
+ limit: int = 5000,
+ offset: int = 0,
+) -> Iterator[dict[str, Any]]:
+ """Yield stored HTML rows for a crawl run (paginated)."""
+ try:
+ cur = conn.execute(
+ """SELECT url, html, status, content_type, fetch_method, byte_length, captured_at
+ FROM crawl_page_html
+ WHERE crawl_run_id = %s
+ ORDER BY url
+ LIMIT %s OFFSET %s""",
+ (crawl_run_id, max(1, int(limit)), max(0, int(offset))),
+ )
+ for row in cur.fetchall():
+ yield dict(row)
+ except Exception:
+ return
+
+
+def delete_page_html_for_run(conn: Connection, crawl_run_id: int, *, commit: bool = True) -> None:
+ """Delete all stored HTML for a crawl run."""
+ try:
+ conn.execute("DELETE FROM crawl_page_html WHERE crawl_run_id = %s", (crawl_run_id,))
+ if commit:
+ conn.commit()
+ except Exception:
+ pass
diff --git a/src/website_profiling/db/storage.py b/src/website_profiling/db/storage.py
index 443b7bf2..0999017a 100644
--- a/src/website_profiling/db/storage.py
+++ b/src/website_profiling/db/storage.py
@@ -29,9 +29,16 @@
read_nodes,
write_crawl,
write_crawl_batch,
+ merge_crawl_result_fields_batch,
write_edges,
write_nodes,
)
+from .html_store import (
+ delete_page_html_for_run,
+ read_page_html,
+ read_page_html_for_run,
+ write_page_html_batch,
+)
from .historical import backup_db_if_exists, ensure_crawl_tables_cleared, read_historical_data, restore_historical_data
from .lighthouse_store import (
read_latest_lighthouse_run_json,
@@ -59,6 +66,7 @@
"close_db_pool",
"create_crawl_run",
"create_session",
+ "delete_page_html_for_run",
"delete_session",
"db_session",
"ensure_crawl_tables_cleared",
@@ -70,7 +78,10 @@
"get_latest_crawl_run_id",
"init_schema",
"list_sessions",
+ "merge_crawl_result_fields_batch",
"read_crawl",
+ "read_page_html",
+ "read_page_html_for_run",
"read_edges",
"read_historical_data",
"read_latest_lighthouse_run_json",
@@ -90,6 +101,7 @@
"update_session_title",
"write_crawl",
"write_crawl_batch",
+ "write_page_html_batch",
"write_edges",
"write_lh_audits_from_run",
"write_lighthouse_page_summary",
diff --git a/src/website_profiling/integrations/google/auth.py b/src/website_profiling/integrations/google/auth.py
index 6364f077..6eda7087 100644
--- a/src/website_profiling/integrations/google/auth.py
+++ b/src/website_profiling/integrations/google/auth.py
@@ -36,7 +36,7 @@ def _app_client_credentials() -> tuple[str, str]:
return app_client_credentials()
-def _property_refresh_token(property_id: int) -> tuple[str, str | None]:
+def _property_google_auth(property_id: int) -> tuple[str, str | None, str]:
from ...db import db_session
from ...db.property_store import get_property_by_id
@@ -44,21 +44,16 @@ def _property_refresh_token(property_id: int) -> tuple[str, str | None]:
prop = get_property_by_id(conn, property_id)
if not prop:
raise RuntimeError(f"Property id {property_id} not found.")
- domain = prop.get("canonical_domain") or "this site"
token = (prop.get("google_refresh_token") or "").strip()
- if not token:
- raise RuntimeError(
- f"Google not connected for {domain}. "
- "Set Site URL, open Integrations, and click Connect with Google for this site."
- )
- return token, prop.get("google_auth_mode")
+ domain = prop.get("canonical_domain") or "this site"
+ return token, prop.get("google_auth_mode"), domain
def build_credentials(property_id: int | None = None):
"""
Load Google OAuth2 credentials.
property_id is required for OAuth user tokens.
- Service account uses google_app_settings.service_account_json when property_id is None.
+ Service account uses google_app_settings.service_account_json (app-wide).
"""
try:
from google.oauth2.credentials import Credentials
@@ -66,11 +61,17 @@ def build_credentials(property_id: int | None = None):
except ImportError as e:
raise ImportError(f"{INSTALL_HINT}\n({e})") from e
+ from ...db.google_app_store import has_service_account, build_service_account_credentials
+
if property_id is not None:
- refresh_token, prop_auth_mode = _property_refresh_token(property_id)
- if prop_auth_mode == "service_account":
+ refresh_token, prop_auth_mode, domain = _property_google_auth(property_id)
+ if prop_auth_mode == "service_account" or (not refresh_token and has_service_account()):
+ return build_service_account_credentials()
+ if not refresh_token:
raise RuntimeError(
- "Per-property service account is not implemented yet. Use OAuth Connect."
+ f"Google not connected for {domain}. "
+ "Set Site URL, open Integrations, and click Connect with Google for this site, "
+ "or upload an app-wide service account JSON in Integrations."
)
client_id, client_secret = _app_client_credentials()
creds = Credentials(
@@ -83,8 +84,6 @@ def build_credentials(property_id: int | None = None):
creds.refresh(Request())
return creds
- from ...db.google_app_store import has_service_account, build_service_account_credentials
-
if has_service_account():
return build_service_account_credentials()
diff --git a/src/website_profiling/integrations/google/keyword_store.py b/src/website_profiling/integrations/google/keyword_store.py
index c1d9b71b..6a6b5f4a 100644
--- a/src/website_profiling/integrations/google/keyword_store.py
+++ b/src/website_profiling/integrations/google/keyword_store.py
@@ -97,6 +97,35 @@ def append_keyword_history(
conn.commit()
+def read_keyword_snapshots_for_property(
+ conn: Connection,
+ property_id: int | None,
+ *,
+ limit: int = 2,
+) -> list[dict[str, Any]]:
+ """Return the most recent keyword_data snapshots for rank delta tools."""
+ if property_id is None:
+ return []
+ try:
+ cur = conn.execute(
+ """
+ SELECT fetched_at, data FROM keyword_data
+ WHERE property_id = %s
+ ORDER BY id DESC
+ LIMIT %s
+ """,
+ (property_id, max(1, int(limit))),
+ )
+ out: list[dict[str, Any]] = []
+ for row in cur.fetchall():
+ data = _parse_row_json(row)
+ if isinstance(data, dict):
+ out.append({"fetched_at": row["fetched_at"], **data})
+ return out
+ except Exception:
+ return []
+
+
def read_keyword_history(
conn: Connection,
keyword: str,
diff --git a/src/website_profiling/integrations/google/store.py b/src/website_profiling/integrations/google/store.py
index 0a4da7ae..ba5fdece 100644
--- a/src/website_profiling/integrations/google/store.py
+++ b/src/website_profiling/integrations/google/store.py
@@ -63,3 +63,103 @@ def read_latest_google_data(
def _to_payload_shape(data: dict[str, Any]) -> dict[str, Any]:
"""Strip gsc_full/ga4_full keys from the payload."""
return {k: v for k, v in data.items() if k not in ("gsc_full", "ga4_full")}
+
+
+def read_google_data_full(
+ conn: Connection,
+ property_id: int | None = None,
+) -> Optional[dict[str, Any]]:
+ """Return latest google_data row including gsc_full and ga4_full blobs."""
+ try:
+ if property_id is not None:
+ cur = conn.execute(
+ """
+ SELECT data FROM google_data
+ WHERE property_id = %s
+ ORDER BY id DESC LIMIT 1
+ """,
+ (property_id,),
+ )
+ else:
+ cur = conn.execute("SELECT data FROM google_data ORDER BY id DESC LIMIT 1")
+ row = cur.fetchone()
+ if row is None:
+ return None
+ data = _parse_row_json(row)
+ return data if isinstance(data, dict) else None
+ except Exception:
+ return None
+
+
+def read_prior_google_snapshot(
+ conn: Connection,
+ property_id: int | None = None,
+ *,
+ skip: int = 1,
+) -> Optional[dict[str, Any]]:
+ """Return the Nth-most-recent google_data row (skip=1 → prior snapshot)."""
+ try:
+ offset = max(0, int(skip))
+ if property_id is not None:
+ cur = conn.execute(
+ """
+ SELECT data FROM google_data
+ WHERE property_id = %s
+ ORDER BY id DESC
+ OFFSET %s LIMIT 1
+ """,
+ (property_id, offset),
+ )
+ else:
+ cur = conn.execute(
+ """
+ SELECT data FROM google_data
+ ORDER BY id DESC
+ OFFSET %s LIMIT 1
+ """,
+ (offset,),
+ )
+ row = cur.fetchone()
+ if row is None:
+ return None
+ data = _parse_row_json(row)
+ return data if isinstance(data, dict) else None
+ except Exception:
+ return None
+
+
+def gsc_row_deltas(
+ current_rows: list[dict[str, Any]],
+ prior_rows: list[dict[str, Any]],
+ *,
+ key_field: str,
+) -> list[dict[str, Any]]:
+ """Compute click/impression/position deltas keyed by page or query field."""
+ prior_by_key: dict[str, dict[str, Any]] = {}
+ for row in prior_rows:
+ if not isinstance(row, dict):
+ continue
+ key = str(row.get(key_field) or "").strip().lower()
+ if key:
+ prior_by_key[key] = row
+
+ deltas: list[dict[str, Any]] = []
+ for row in current_rows:
+ if not isinstance(row, dict):
+ continue
+ key = str(row.get(key_field) or "").strip().lower()
+ if not key:
+ continue
+ prior = prior_by_key.get(key) or {}
+ cur_clicks = float(row.get("clicks") or 0)
+ pri_clicks = float(prior.get("clicks") or 0)
+ cur_impr = float(row.get("impressions") or 0)
+ pri_impr = float(prior.get("impressions") or 0)
+ cur_pos = float(row.get("position") or row.get("avg_position") or 0)
+ pri_pos = float(prior.get("position") or prior.get("avg_position") or 0)
+ out = dict(row)
+ out["clicks_delta"] = cur_clicks - pri_clicks
+ out["impressions_delta"] = cur_impr - pri_impr
+ out["position_delta"] = cur_pos - pri_pos if pri_pos else None
+ deltas.append(out)
+ return deltas
diff --git a/src/website_profiling/lighthouse/config.py b/src/website_profiling/lighthouse/config.py
new file mode 100644
index 00000000..1a6c7b0b
--- /dev/null
+++ b/src/website_profiling/lighthouse/config.py
@@ -0,0 +1,166 @@
+"""Lighthouse CLI configuration and command helpers."""
+from __future__ import annotations
+
+import os
+import shutil
+import subprocess
+import threading
+from pathlib import Path
+
+# Lighthouse "good" thresholds for human summary
+LCP_GOOD_MS = 2500
+CLS_GOOD = 0.1
+TBT_GOOD_MS = 200
+FCP_GOOD_MS = 1800
+
+_LIGHTHOUSE_INSTALL_MSG = (
+ "Lighthouse not found. Install Node/npm (https://nodejs.org), then run: npm install -g lighthouse. "
+ "Chrome or Chromium is also required for headless mode."
+)
+
+_NPX_LIGHTHOUSE_LOCK = threading.Lock()
+_LIGHTHOUSE_FLOW_MODES = frozenset({"snapshot", "timespan"})
+
+def _repo_root() -> str:
+ explicit = (os.environ.get("WEBSITE_PROFILING_ROOT") or "").strip()
+ if explicit:
+ return explicit
+ return str(Path(__file__).resolve().parents[3])
+
+
+def _lighthouse_flow_script() -> str:
+ return os.path.join(_repo_root(), "scripts", "lighthouse_user_flow.mjs")
+
+
+def _normalize_lighthouse_mode(mode: str | None) -> str:
+ m = (mode or "navigation").strip().lower() or "navigation"
+ if m not in ("navigation", "snapshot", "timespan"):
+ raise RuntimeError(
+ f"Invalid lighthouse_mode {m!r}; use navigation, snapshot, or timespan."
+ )
+ return m
+
+
+def _node_cmd() -> str:
+ node = shutil.which("node")
+ if node is None:
+ raise RuntimeError(
+ "Node.js not found. Install Node.js (https://nodejs.org) for Lighthouse user flows."
+ )
+ return node
+
+
+def _build_report_html_content(summary: dict[str, Any]) -> str:
+ """Build report.html content (for DB or file). Returns HTML string."""
+ import html as html_module
+ mm = summary.get("median_metrics") or {}
+ cs = summary.get("category_scores") or {}
+ failures = summary.get("top_failures") or []
+ raw_reports = summary.get("raw_reports") or []
+ url = html_module.escape(summary.get("url", ""))
+ path_summary = "summary.json"
+ path_human = "human_summary.txt"
+ path_diag = "diagnostics.json"
+ raw_dir = "raw_runs"
+ rows_fail = "".join(
+ f"
URL: {url}
+| Metric | Value |
|---|---|
| LCP (ms) | {mm.get('lcp_ms') or '—'} |
| CLS | {mm.get('cls') or '—'} |
| TBT (ms) | {mm.get('tbt_ms') or '—'} |
| FCP (ms) | {mm.get('fcp_ms') or '—'} |
| Category | Score |
|---|---|
| performance | {cs.get('performance') or '—'} |
| accessibility | {cs.get('accessibility') or '—'} |
| best-practices | {cs.get('best-practices') or '—'} |
| seo | {cs.get('seo') or '—'} |
| pwa | {cs.get('pwa') or '—'} |
| Audit | Impact | Help |
|---|
summary.json | human_summary.txt | diagnostics.json
+Raw runs: {raw_links or '—'}
+ + +""" + + +def _write_report_html(output_dir: str, summary: dict[str, Any]) -> None: + """Write report.html to output_dir (used when not using DB).""" + content = summary.get("report_html") or _build_report_html_content(summary) + report_path = os.path.join(output_dir, "report.html") + with open(report_path, "w", encoding="utf-8") as f: + f.write(content) + + +def _url_safe(s: str) -> str: + """Return a filesystem-safe slug from URL for filenames.""" + return re.sub(r"[^\w\-.]", "_", s.strip().rstrip("/"))[:80] + + +def _lighthouse_cmd() -> list[str]: + """Return argv prefix: [resolved lighthouse] or [resolved npx, -y, lighthouse]. Paths from shutil.which (portable).""" + explicit = (os.environ.get("LIGHTHOUSE_PATH") or os.environ.get("LIGHTHOUSE_BIN") or "").strip() + if explicit and os.path.isfile(explicit) and os.access(explicit, os.X_OK): + return [explicit] + lh = shutil.which("lighthouse") + if lh is not None: + return [lh] + npx = shutil.which("npx") + if npx is not None: + return [npx, "-y", "lighthouse"] + raise RuntimeError(_LIGHTHOUSE_INSTALL_MSG) + + +def _uses_npx(cmd: list[str]) -> bool: + base = os.path.basename(cmd[0]).lower() + return base in ("npx", "npx.cmd") + + +def is_lighthouse_available() -> bool: + """Return True if lighthouse or npx is on PATH (so we can run Lighthouse).""" + try: + _lighthouse_cmd() + return True + except RuntimeError: + return False + + +def _preset_for_strategy(strategy: str) -> str: + """Map user strategy 'mobile'|'desktop' to Lighthouse CLI preset. Newer Lighthouse only accepts perf, experimental, desktop.""" + s = (strategy or "mobile").lower() + if s == "desktop": + return "desktop" + return "perf" # mobile -> perf (mobile-like throttling in current Lighthouse) + + +# Valid Lighthouse category IDs for --only-categories +LIGHTHOUSE_CATEGORY_IDS = {"performance", "accessibility", "best-practices", "seo", "pwa"} + + +def _parse_categories(categories: str | list[str] | None) -> list[str] | None: + """Return list of valid category IDs, or None to run all categories.""" + if categories is None: + return None + if isinstance(categories, str): + categories = [c.strip().lower() for c in categories.split(",") if c.strip()] + if not categories: + return None + out = [c for c in categories if c in LIGHTHOUSE_CATEGORY_IDS] + return out if out else None + diff --git a/src/website_profiling/lighthouse/result_parser.py b/src/website_profiling/lighthouse/result_parser.py new file mode 100644 index 00000000..f3f195d5 --- /dev/null +++ b/src/website_profiling/lighthouse/result_parser.py @@ -0,0 +1,107 @@ +"""Parse Lighthouse JSON output into summary metrics.""" +from __future__ import annotations + +from typing import Any + +import statistics + +def _evidence_from_audit(audit: dict[str, Any]) -> list[str]: + """Extract resource URLs or selectors from audit details.""" + evidence: list[str] = [] + details = audit.get("details") + if not details or not isinstance(details, dict): + return evidence + items = details.get("items") or details.get("nodes") or [] + if not isinstance(items, list): + return evidence + for item in items[:5]: + if isinstance(item, dict): + url = item.get("url") + if url and isinstance(url, str) and not str(url).startswith("data:"): + evidence.append(str(url)[:500]) + node = item.get("node") + if isinstance(node, dict) and node.get("selector"): + evidence.append(str(node["selector"])[:200]) + if item.get("selector"): + evidence.append(str(item["selector"])[:200]) + return evidence[:15] + + +def extract_from_lighthouse_json(data: dict) -> dict[str, Any]: + """Extract LCP, CLS, TBT, FCP, Speed Index, category scores (all 5), and top 10 failing audits with impact and evidence.""" + out: dict[str, Any] = { + "lcp_ms": None, + "cls": None, + "tbt_ms": None, + "fcp_ms": None, + "speed_index_ms": None, + "performance_score": None, + "accessibility_score": None, + "seo_score": None, + "best_practices_score": None, + "pwa_score": None, + "category_scores": {}, + "top_failures": [], + } + lr = data.get("lighthouseResult") or data + audits = lr.get("audits") or {} + cats = lr.get("categories") or {} + + for audit_id, key in [ + ("largest-contentful-paint", "lcp_ms"), + ("cumulative-layout-shift", "cls"), + ("total-blocking-time", "tbt_ms"), + ("first-contentful-paint", "fcp_ms"), + ("speed-index", "speed_index_ms"), + ]: + a = audits.get(audit_id) + if a is not None and "numericValue" in a: + out[key] = a["numericValue"] + + for cat_id, key in [ + ("performance", "performance_score"), + ("accessibility", "accessibility_score"), + ("seo", "seo_score"), + ("best-practices", "best_practices_score"), + ("pwa", "pwa_score"), + ]: + c = cats.get(cat_id) + if c is not None and "score" in c: + s = c["score"] + out[key] = s + out["category_scores"][cat_id] = round((s * 100)) if s is not None else None + + # Resolve impact from warning_mapper for each failure + from ..tools.warnings import resolve_impact + failures = [] + for aid, a in audits.items(): + if a is None: + continue + score = a.get("score") + if score is None: + continue + if score < 1: + title = a.get("title") or aid + help_text = a.get("helpText") or "" + impact = resolve_impact(aid, title, help_text) + evidence = _evidence_from_audit(a) + failures.append({ + "id": aid, + "score": score, + "helpText": help_text, + "impact": impact, + "evidence": evidence, + }) + failures.sort(key=lambda x: (x["score"] or 0)) + out["top_failures"] = failures[:10] + + return out + + +def median_or_none(values: list[float]) -> float | None: + """Return median of list; None if empty or all None.""" + clean = [v for v in values if v is not None] + if not clean: + return None + return statistics.median(clean) + diff --git a/src/website_profiling/lighthouse/runner.py b/src/website_profiling/lighthouse/runner.py index ee79e736..3b0d0ca7 100644 --- a/src/website_profiling/lighthouse/runner.py +++ b/src/website_profiling/lighthouse/runner.py @@ -1,68 +1,38 @@ """ Run Lighthouse locally via CLI for a given URL; return machine-readable summary with median metrics. -Writes raw_runs/, summary.json, diagnostics.json, human_summary.txt, and optionally report.html. -Uses global lighthouse if on PATH (or LIGHTHOUSE_PATH), otherwise runs via npx (serialized to avoid cache races). -Requires: Node + npm, Chrome/Chromium. """ +from __future__ import annotations + import json import os import re import shutil -import statistics import subprocess import sys -import threading from datetime import datetime, timezone -from pathlib import Path from typing import Any from ..console_io import console_print - -# Lighthouse "good" thresholds for human summary -LCP_GOOD_MS = 2500 -CLS_GOOD = 0.1 -TBT_GOOD_MS = 200 -FCP_GOOD_MS = 1800 - -_LIGHTHOUSE_INSTALL_MSG = ( - "Lighthouse not found. Install Node/npm (https://nodejs.org), then run: npm install -g lighthouse. " - "Chrome or Chromium is also required for headless mode." +from .config import ( + CLS_GOOD, + FCP_GOOD_MS, + LCP_GOOD_MS, + TBT_GOOD_MS, + _LIGHTHOUSE_INSTALL_MSG, + _LIGHTHOUSE_FLOW_MODES, + _NPX_LIGHTHOUSE_LOCK, + _lighthouse_cmd, + _lighthouse_flow_script, + _node_cmd, + _normalize_lighthouse_mode, + _parse_categories, + _preset_for_strategy, + _repo_root, + _url_safe, + _uses_npx, + is_lighthouse_available, ) - -# Serialise npx-on-demand installs — parallel npx runs corrupt /root/.npm/_npx cache in Docker. -_NPX_LIGHTHOUSE_LOCK = threading.Lock() - -_LIGHTHOUSE_FLOW_MODES = frozenset({"snapshot", "timespan"}) - - -def _repo_root() -> str: - explicit = (os.environ.get("WEBSITE_PROFILING_ROOT") or "").strip() - if explicit: - return explicit - return str(Path(__file__).resolve().parents[3]) - - -def _lighthouse_flow_script() -> str: - return os.path.join(_repo_root(), "scripts", "lighthouse_user_flow.mjs") - - -def _normalize_lighthouse_mode(mode: str | None) -> str: - m = (mode or "navigation").strip().lower() or "navigation" - if m not in ("navigation", "snapshot", "timespan"): - raise RuntimeError( - f"Invalid lighthouse_mode {m!r}; use navigation, snapshot, or timespan." - ) - return m - - -def _node_cmd() -> str: - node = shutil.which("node") - if node is None: - raise RuntimeError( - "Node.js not found. Install Node.js (https://nodejs.org) for Lighthouse user flows." - ) - return node - +from .result_parser import _evidence_from_audit, extract_from_lighthouse_json, median_or_none def _build_report_html_content(summary: dict[str, Any]) -> str: """Build report.html content (for DB or file). Returns HTML string.""" @@ -272,108 +242,6 @@ def run_lighthouse_once( except FileNotFoundError as e: raise RuntimeError(_LIGHTHOUSE_INSTALL_MSG) from e - -def _evidence_from_audit(audit: dict[str, Any]) -> list[str]: - """Extract resource URLs or selectors from audit details.""" - evidence: list[str] = [] - details = audit.get("details") - if not details or not isinstance(details, dict): - return evidence - items = details.get("items") or details.get("nodes") or [] - if not isinstance(items, list): - return evidence - for item in items[:5]: - if isinstance(item, dict): - url = item.get("url") - if url and isinstance(url, str) and not str(url).startswith("data:"): - evidence.append(str(url)[:500]) - node = item.get("node") - if isinstance(node, dict) and node.get("selector"): - evidence.append(str(node["selector"])[:200]) - if item.get("selector"): - evidence.append(str(item["selector"])[:200]) - return evidence[:15] - - -def extract_from_lighthouse_json(data: dict) -> dict[str, Any]: - """Extract LCP, CLS, TBT, FCP, Speed Index, category scores (all 5), and top 10 failing audits with impact and evidence.""" - out: dict[str, Any] = { - "lcp_ms": None, - "cls": None, - "tbt_ms": None, - "fcp_ms": None, - "speed_index_ms": None, - "performance_score": None, - "accessibility_score": None, - "seo_score": None, - "best_practices_score": None, - "pwa_score": None, - "category_scores": {}, - "top_failures": [], - } - lr = data.get("lighthouseResult") or data - audits = lr.get("audits") or {} - cats = lr.get("categories") or {} - - for audit_id, key in [ - ("largest-contentful-paint", "lcp_ms"), - ("cumulative-layout-shift", "cls"), - ("total-blocking-time", "tbt_ms"), - ("first-contentful-paint", "fcp_ms"), - ("speed-index", "speed_index_ms"), - ]: - a = audits.get(audit_id) - if a is not None and "numericValue" in a: - out[key] = a["numericValue"] - - for cat_id, key in [ - ("performance", "performance_score"), - ("accessibility", "accessibility_score"), - ("seo", "seo_score"), - ("best-practices", "best_practices_score"), - ("pwa", "pwa_score"), - ]: - c = cats.get(cat_id) - if c is not None and "score" in c: - s = c["score"] - out[key] = s - out["category_scores"][cat_id] = round((s * 100)) if s is not None else None - - # Resolve impact from warning_mapper for each failure - from ..tools.warnings import resolve_impact - failures = [] - for aid, a in audits.items(): - if a is None: - continue - score = a.get("score") - if score is None: - continue - if score < 1: - title = a.get("title") or aid - help_text = a.get("helpText") or "" - impact = resolve_impact(aid, title, help_text) - evidence = _evidence_from_audit(a) - failures.append({ - "id": aid, - "score": score, - "helpText": help_text, - "impact": impact, - "evidence": evidence, - }) - failures.sort(key=lambda x: (x["score"] or 0)) - out["top_failures"] = failures[:10] - - return out - - -def median_or_none(values: list[float]) -> float | None: - """Return median of list; None if empty or all None.""" - clean = [v for v in values if v is not None] - if not clean: - return None - return statistics.median(clean) - - def run_lighthouse_audit( url: str, strategy: str = "mobile", diff --git a/src/website_profiling/llm/agent.py b/src/website_profiling/llm/agent.py index 9194ddae..ecdcaa9f 100644 --- a/src/website_profiling/llm/agent.py +++ b/src/website_profiling/llm/agent.py @@ -8,6 +8,12 @@ from ..text_sanitize import sanitize_unicode_deep, strip_surrogates from ..tools.audit_tools import AuditToolContext from ..tools.audit_tools.registry import TOOL_DEFINITIONS, dispatch_tool, openai_tools_schema +from ..tools.audit_tools.tool_selector import ( + apply_tool_cap, + chat_tool_mode, + chat_tool_search_cap, + select_tools_for_turn, +) from .base import ChatResult, ToolCall, get_llm_client MAX_TOOL_ROUNDS = 10 @@ -15,24 +21,13 @@ SYSTEM_PROMPT = """You are Site Audit AI, a technical SEO assistant for a self-hosted site audit platform. You help users understand crawl results, audit issues, Lighthouse scores, keywords, and Search Console data. -Tool domains (prefer specific tools over generic list_issues): -- Portfolio/report: get_report_summary, get_category_scores, list_audit_categories, get_executive_summary, get_audit_recommendations, list_report_history, get_portfolio_summary -- Issues: list_issues, search_issues, list_top_impact_issues, prioritize_fix_roadmap, get_critical_issues, list_issues_by_category, get_category_issues, list_issues_with_ai_fixes, generate_issue_fix, list_issue_workflow -- On-page: list_pages_missing_title, list_pages_noindex, list_seo_onpage_issues, list_content_url_issues, list_pages_missing_canonical, list_canonical_mismatch, list_pages_with_missing_alt, list_pages_missing_viewport -- Crawl/pages: search_pages, search_pages_advanced, get_page_details, get_page_analysis, list_status_4xx_pages, list_pages_soft_404, list_dead_end_pages, list_duplicate_title_groups, list_heavy_pages_by_bytes, get_asset_weight_summary, get_readability_summary, get_status_code_breakdown, get_depth_distribution, list_long_redirect_chains, list_robots_blocked_urls, get_top_pages_by_pagerank -- Schema/technical: get_schema_coverage, get_seo_health, get_security_findings, get_security_findings_summary, get_tech_stack_summary, list_pages_by_technology -- Indexation: get_indexation_coverage, list_indexation_gaps, get_indexation_url_join -- Keywords: get_keyword_summary, get_striking_distance_keywords, list_keywords_ctr_opportunity, list_keywords_by_position, get_keyword_serp_overlay, get_serp_feature_overlay, expand_keywords, generate_content_brief -- Google: get_google_summary, get_gsc_top_queries, get_gsc_top_pages, get_gsc_ctr_opportunity_pages, get_google_integration_status, get_gsc_page_query_slice, get_gsc_url_inspection, get_gsc_index_coverage, get_ga4_page_metrics, analyze_serp_snippet_for_url -- Links/backlinks: get_gsc_sample_links, get_backlinks_velocity, get_third_party_links_overlay, list_broken_link_sources, get_page_coach -- Performance: get_lighthouse_summary, list_slow_pages, get_crux_summary, get_lighthouse_human_summary, list_lighthouse_poor_accessibility_pages, list_lighthouse_cwv_failures -- Content/charts: get_issue_priority_breakdown, get_mime_type_breakdown, get_title_length_distribution, get_domain_link_distribution, get_outlink_distribution, get_content_analytics, get_top_crawled_pages, get_duplicate_cluster -- Ops/logs: get_property_ops, list_crawl_runs, get_latest_log_analysis, get_log_top_paths, list_log_only_paths, list_crawl_only_paths, get_log_googlebot_stats -- Drift: compare_reports, compare_category_deltas, compare_issue_deltas, compare_indexation_deltas, compare_orphan_deltas, compare_url_set_diff, compare_google_metrics, compare_security_deltas, compare_health_score_delta, get_health_history, get_category_health_history -- GEO/AEO: get_geo_readiness_score, get_aeo_content_signals_for_url, get_llms_txt_status, draft_llms_txt, get_faq_schema_coverage, get_eeat_signals_summary, get_internal_link_suggestions, check_ai_citation_presence -- Accessibility/assets: list_pages_with_axe_violations, get_axe_audit_summary, list_pages_with_mixed_content, list_pages_poor_cache_headers, get_rich_results_summary, list_rich_results_failures -- Export/deliverables: export_audit_report, export_compare_csv, export_list_as_csv, compose_custom_report, export_custom_report, list_export_formats -- Images: get_image_audit_summary, list_pages_with_missing_alt, list_pages_without_lazy_images, list_pages_with_images_missing_dimensions, list_site_image_urls, list_lighthouse_image_opportunities, list_largest_images, list_unoptimized_images, list_images_needing_attention +Tool routing (only a subset of tools is loaded each turn): +- Always available: search_audit_tools, list_tool_domains, get_data_coverage_report, run_insight_workflow, run_technical_workflow, run_keyword_workflow, run_domain_agent, plus top insight tools (get_report_summary, get_opportunity_matrix, get_traffic_health_check, etc.) +- Use search_audit_tools(query) to discover specialized tools by topic (e.g. "broken links", "GSC CTR", "export PDF"). +- Use list_tool_domains to see domain groupings and example prompts. +- Use run_*_workflow for common multi-step analyses (insight, technical, keyword). +- Use run_domain_agent(task, domain) for deep exploration within one domain. +- Use get_data_coverage_report when tools return empty or missing data. Image playbook: - Overview: get_image_audit_summary first — the UI renders summary cards, page preview lists (alt/lazy/OG/dimensions), and Lighthouse image findings. Write only ### Power Insights and ### Recommended actions (interpretation). Never repeat counts, URL lists, or markdown tables of pages. @@ -120,9 +115,11 @@ def _react_step( return ChatResult(content=text) -def _tools_description(*, compact: bool = False) -> str: +def _tools_description(*, names: set[str] | None = None, compact: bool = False) -> str: lines = [] for t in TOOL_DEFINITIONS: + if names is not None and t["name"] not in names: + continue if compact: lines.append(f"- {t['name']}") else: @@ -130,6 +127,41 @@ def _tools_description(*, compact: bool = False) -> str: return "\n".join(lines) +def _last_user_message(messages: list[dict[str, str]]) -> str: + for msg in reversed(messages): + if msg.get("role") == "user": + return str(msg.get("content") or "") + return "" + + +def _expand_active_tools_from_result( + tc_name: str, + tool_result: dict[str, Any], + active: set[str], +) -> set[str]: + expanded = set(active) + pinned: set[str] = set() + + if tc_name == "search_audit_tools": + names = tool_result.get("tool_names") + if isinstance(names, list): + for name in names[:12]: + if isinstance(name, str) and name: + expanded.add(name) + pinned.add(name) + elif tc_name == "run_domain_agent": + names = tool_result.get("tools_used") + if isinstance(names, list): + for name in names: + if isinstance(name, str) and name: + expanded.add(name) + pinned.add(name) + + if chat_tool_mode() != "full" and pinned: + expanded = apply_tool_cap(expanded, chat_tool_search_cap(), pinned=pinned) + return expanded + + def _build_openai_messages(history: list[dict[str, str]]) -> list[dict[str, Any]]: out: list[dict[str, Any]] = [{"role": "system", "content": SYSTEM_PROMPT}] for msg in history: @@ -164,7 +196,9 @@ def run_agent_turn( return {"ok": False, "error": msg} openai_messages = _build_openai_messages(messages) - tools = openai_tools_schema() + last_user = _last_user_message(messages) + active_names = select_tools_for_turn(last_user, messages) + tools = openai_tools_schema(active_names) tool_events: list[dict[str, Any]] = [] final_message = "" @@ -182,7 +216,12 @@ def on_token(text: str) -> None: if _supports_native_tools(client): result = client.chat_with_tools(llm_messages, tools, on_token=on_token) else: - result = _react_step(client, llm_messages, _tools_description(compact=True), on_token) + result = _react_step( + client, + llm_messages, + _tools_description(names=active_names, compact=True), + on_token, + ) except Exception as e: msg = str(e).strip() or type(e).__name__ if "httpx" in msg.lower() or "requirements.txt" in msg.lower(): @@ -227,12 +266,22 @@ def on_token(text: str) -> None: for tc in result.tool_calls: _emit(on_event, {"type": "tool_start", "name": tc.name, "args": tc.arguments}) - tool_result = sanitize_unicode_deep( - dispatch_tool(tc.name, tc.arguments, context=context), - ) + if chat_tool_mode() != "full" and tc.name not in active_names: + tool_result = { + "error": f"tool not loaded this turn: {tc.name}", + "hint": "Call search_audit_tools to load specialized tools, or rephrase your request.", + } + else: + tool_result = sanitize_unicode_deep( + dispatch_tool(tc.name, tc.arguments, context=context), + ) _emit(on_event, {"type": "tool_end", "name": tc.name, "result": tool_result}) tool_events.append({"name": tc.name, "args": tc.arguments, "result": tool_result}) + active_names = _expand_active_tools_from_result(tc.name, tool_result, active_names) + if chat_tool_mode() != "full": + tools = openai_tools_schema(active_names) + tool_content = json.dumps(tool_result, default=str) if ollama_format: openai_messages.append({ diff --git a/src/website_profiling/mcp/core_server.py b/src/website_profiling/mcp/core_server.py new file mode 100644 index 00000000..7026c4af --- /dev/null +++ b/src/website_profiling/mcp/core_server.py @@ -0,0 +1,8 @@ +"""MCP core router server (Tier 0 + insight tools).""" +from __future__ import annotations + +from .domain_server import run_domain_server + + +def main() -> None: + run_domain_server("core") diff --git a/src/website_profiling/mcp/domain_server.py b/src/website_profiling/mcp/domain_server.py new file mode 100644 index 00000000..96f617dc --- /dev/null +++ b/src/website_profiling/mcp/domain_server.py @@ -0,0 +1,11 @@ +"""MCP domain server entry — set WP_MCP_DOMAIN before starting.""" +from __future__ import annotations + +import os + +from .server import main + + +def run_domain_server(domain: str) -> None: + os.environ["WP_MCP_DOMAIN"] = domain + main() diff --git a/src/website_profiling/mcp/server.py b/src/website_profiling/mcp/server.py index 5b150e61..4ca37356 100644 --- a/src/website_profiling/mcp/server.py +++ b/src/website_profiling/mcp/server.py @@ -9,7 +9,14 @@ from ..db.storage import db_session from ..tools.audit_tools import AuditToolContext -from ..tools.audit_tools.registry import TOOL_DEFINITIONS, dispatch_tool, tool_handler_names +from ..tools.audit_tools.registry import ( + TOOL_DEFINITIONS, + dispatch_tool, + list_domains_catalog, + mcp_tool_names, + tools_catalog_by_domain, +) +from ..tools.audit_tools.tool_domains import MCP_DOMAIN_BUNDLES _URI_PROPERTY = re.compile(r"^audit://property/(\d+)$") _URI_REPORT_LATEST = re.compile(r"^audit://property/(\d+)/report/latest$") @@ -64,90 +71,41 @@ def _read_glossary_excerpt() -> str: return text[:12000] +def _mcp_domain() -> str: + return (os.environ.get("WP_MCP_DOMAIN") or "core").strip().lower() + + +def _exposed_tool_names() -> set[str]: + return mcp_tool_names(_mcp_domain()) + + def _tools_catalog_json() -> str: - domains: dict[str, list[str]] = { - "portfolio": [], - "issues": [], - "crawl": [], - "schema": [], - "links": [], - "indexation": [], - "content": [], - "keywords": [], - "google": [], - "backlinks": [], - "performance": [], - "drift": [], - "security": [], - "ops": [], - "export": [], - "images": [], - "geo": [], - "accessibility": [], - "assets": [], - "ctr": [], - "integrations": [], - } - for tool in TOOL_DEFINITIONS: - name = tool["name"] - if name.startswith("export_") or name == "compose_custom_report" or name == "list_export_formats": - domains["export"].append(name) - elif name.startswith(("get_image_", "list_pages_without_lazy", "list_pages_with_images_missing", "list_site_image", "list_lighthouse_image", "list_largest_images", "list_unoptimized_images", "list_images_needing")): - domains["images"].append(name) - elif name.startswith(("list_propert", "get_propert", "get_report", "get_executive", "get_site", "list_report", "get_portfolio")) or name in ( - "get_ads_txt_status", - "get_security_txt_status", - "get_contact_intelligence", - "get_rich_results_summary", - "list_rich_results_failures", - "get_competitor_keyword_gap", - "get_pagination_audit_summary", - ): - domains["portfolio"].append(name) - elif name in ( - "list_top_impact_issues", - "prioritize_fix_roadmap", - "generate_issue_fix", - "summarize_category_for_client", - ) or "issue" in name or "category" in name or "workflow" in name: - domains["issues"].append(name) - elif name.startswith(("get_geo_", "get_aeo_", "get_llms_", "get_eeat_", "get_faq_", "list_pages_missing_faq", "draft_llms", "check_ai_citation")): - domains["geo"].append(name) - elif "axe" in name or "mixed_content" in name or name == "get_heading_outline_for_url": - domains["accessibility"].append(name) - elif name in ("get_asset_weight_summary", "get_readability_summary", "list_heavy_pages_by_bytes", "list_pages_poor_cache_headers", "list_pages_low_content_ratio"): - domains["assets"].append(name) - elif "ctr" in name or name in ("list_keywords_ctr_opportunity", "analyze_serp_snippet_for_url"): - domains["ctr"].append(name) - elif name in ("get_gsc_url_inspection", "get_gsc_index_coverage", "get_bing_index_status", "get_serp_feature_overlay"): - domains["integrations"].append(name) - elif name.startswith(("list_pages_", "list_canonical", "list_long_", "list_robots_", "get_top_pages_by", "search_pages", "get_page_", "list_redirects", "list_broken", "list_status_", "get_status_code", "get_response_time", "get_depth", "get_crawl_", "get_browser", "list_pages_with", "list_pages_by")): - domains["crawl"].append(name) - elif "schema" in name or name == "get_seo_health": - domains["schema"].append(name) - elif "orphan" in name or "link" in name or "fingerprint" in name or "pagerank" in name: - domains["links"].append(name) - elif "indexation" in name or "hreflang" in name or "language" in name or name == "list_subdomains": - domains["indexation"].append(name) - elif "content" in name or "social" in name or "ner" in name or "thin" in name or "opportunit" in name or "duplicate" in name: - domains["content"].append(name) - elif "keyword" in name or "cannibal" in name or "misalignment" in name or "striking" in name or "semantic" in name or name == "expand_keywords" or name == "generate_content_brief": - domains["keywords"].append(name) - elif "google" in name or "gsc" in name or "ga4" in name: - domains["google"].append(name) - elif "backlink" in name or "competitor" in name or "bing" in name or "gsc_links" in name: - domains["backlinks"].append(name) - elif "lighthouse" in name or "crux" in name or "slow" in name or "cwv" in name: - domains["performance"].append(name) - elif "health" in name or "compare" in name or "alert" in name or "tech_stack" in name or name == "list_pages_by_technology": - domains["drift"].append(name) - elif "security" in name: - domains["security"].append(name) - elif "log" in name or name in ("get_property_ops", "list_crawl_runs", "list_log_uploads", "get_page_coach"): - domains["ops"].append(name) - else: - domains["portfolio"].append(name) - return json.dumps({"tool_count": len(TOOL_DEFINITIONS), "handlers": sorted(tool_handler_names()), "domains": domains}, indent=2) + domain = _mcp_domain() + exposed = _exposed_tool_names() + by_domain = tools_catalog_by_domain() + scoped: dict[str, list[str]] = {} + for d, names in by_domain.items(): + filtered = [n for n in names if n in exposed] + if filtered: + scoped[d] = filtered + return json.dumps({ + "mcp_domain": domain, + "tool_count": len(exposed), + "handlers": sorted(exposed), + "domains": scoped, + "available_mcp_domains": sorted(MCP_DOMAIN_BUNDLES.keys()), + }, indent=2) + + +def _domains_resource_json() -> str: + return json.dumps({ + "current_mcp_domain": _mcp_domain(), + "bundles": { + key: sorted(domains) + for key, domains in MCP_DOMAIN_BUNDLES.items() + }, + "catalog": list_domains_catalog(), + }, indent=2) def _resolve_resource(uri: str) -> str: @@ -161,6 +119,9 @@ def _resolve_resource(uri: str) -> str: if uri == "audit://tools": return _tools_catalog_json() + if uri == "audit://domains": + return _domains_resource_json() + m = _URI_PROPERTY.match(uri) if m: pid = int(m.group(1)) @@ -202,13 +163,16 @@ def main() -> None: "MCP SDK not installed. Run: pip install -r requirements.txt", ) from e - server = Server("site-audit") + server = Server(f"site-audit-{_mcp_domain()}") default_pid = _default_property_id() + exposed = _exposed_tool_names() @server.list_tools() async def list_tools() -> list[Tool]: out: list[Tool] = [] for spec in TOOL_DEFINITIONS: + if spec["name"] not in exposed: + continue out.append( Tool( name=spec["name"], @@ -220,6 +184,12 @@ async def list_tools() -> list[Tool]: @server.call_tool() async def call_tool(name: str, arguments: dict[str, Any] | None) -> list[TextContent]: + if name not in exposed: + result = { + "error": f"tool not exposed in MCP domain {_mcp_domain()}: {name}", + "hint": "Connect WP_MCP_DOMAIN=full or the domain server that includes this tool.", + } + return [TextContent(type="text", text=json.dumps(result, indent=2, default=str))] args = dict(arguments or {}) ctx = _merge_context(args) result = dispatch_tool(name, args, context=ctx) @@ -231,7 +201,8 @@ async def list_resources() -> list[Resource]: resources = [ Resource(uri="audit://properties", name="Properties", description="All configured site properties", mimeType="application/json"), Resource(uri="audit://glossary", name="Glossary", description="Site Audit field glossary excerpt", mimeType="text/markdown"), - Resource(uri="audit://tools", name="Tool catalog", description="MCP tool catalog grouped by domain", mimeType="application/json"), + Resource(uri="audit://tools", name="Tool catalog", description="MCP tool catalog for the connected domain server", mimeType="application/json"), + Resource(uri="audit://domains", name="MCP domain servers", description="Available WP_MCP_DOMAIN bundles and domain groupings", mimeType="application/json"), ] if default_pid: resources.extend([ diff --git a/src/website_profiling/parsing/__init__.py b/src/website_profiling/parsing/__init__.py new file mode 100644 index 00000000..6af83821 --- /dev/null +++ b/src/website_profiling/parsing/__init__.py @@ -0,0 +1 @@ +"""HTML/CSV parsing utilities.""" diff --git a/src/website_profiling/parsing/content.py b/src/website_profiling/parsing/content.py new file mode 100644 index 00000000..553f9f80 --- /dev/null +++ b/src/website_profiling/parsing/content.py @@ -0,0 +1,37 @@ +"""Content text and social meta parsing.""" +from __future__ import annotations + +from ..content_analysis.page import analyze_page_html + + +def _count_syllables(word: str) -> int: + from ..content_analysis.reading_level import count_syllables + + return count_syllables(word) + + +def parse_content_text(soup, raw_html: str, excerpt_max_chars: int = 0) -> dict: + """Extract content analytics: word count, reading level, content-to-HTML ratio, top keywords. + + excerpt_max_chars: when > 0, strip script/style from body and store a whitespace-normalized + plain-text excerpt (truncated) in ``content_excerpt`` for analysis / AI / UI. + """ + del soup # analyze_page_html loads from raw_html for a single code path + return analyze_page_html(raw_html, excerpt_max_chars=excerpt_max_chars, strategy="full_body") + + +def parse_social_meta(soup) -> dict: + """Extract Open Graph and Twitter Card meta tags.""" + def _meta_content(attrs: dict) -> str: + tag = soup.find("meta", attrs=attrs) + return (tag.get("content") or "").strip() if tag else "" + + return { + "og_title": _meta_content({"property": "og:title"}), + "og_description": _meta_content({"property": "og:description"}), + "og_image": _meta_content({"property": "og:image"}), + "og_type": _meta_content({"property": "og:type"}), + "twitter_card": _meta_content({"name": "twitter:card"}), + "twitter_title": _meta_content({"name": "twitter:title"}), + "twitter_image": _meta_content({"name": "twitter:image"}), + } diff --git a/src/website_profiling/parsing/io.py b/src/website_profiling/parsing/io.py new file mode 100644 index 00000000..758e0191 --- /dev/null +++ b/src/website_profiling/parsing/io.py @@ -0,0 +1,72 @@ +"""DataFrame and edge list I/O.""" +from __future__ import annotations + +import json +import os + +import pandas as pd + +""" +Shared helpers for crawler and report/plot scripts. +""" +import json +import os +import warnings +from urllib.parse import urljoin, urldefrag, urlparse +import urllib.robotparser as robotparser +import ast +import math + +import pandas as pd +from bs4 import BeautifulSoup + + +def load_dataframe(path: str) -> pd.DataFrame: + """Load a DataFrame from CSV or JSON (by extension).""" + if not os.path.isfile(path): + raise FileNotFoundError(path) + path_lower = path.lower() + if path_lower.endswith(".json"): + return pd.read_json(path, orient="records") + return pd.read_csv(path) + + +def save_dataframe(df: pd.DataFrame, path: str) -> None: + """Save a DataFrame to CSV or JSON (by extension). Uses default_handler for JSON to avoid numpy types.""" + path_lower = path.lower() + if path_lower.endswith(".json"): + df.to_json(path, orient="records", indent=2, date_format="iso", default_handler=str) + else: + df.to_csv(path, index=False) + + +def load_edges(path: str) -> list[tuple[str, str]]: + """Load edge list from CSV or JSON (by extension). Returns list of (from_url, to_url).""" + if not os.path.isfile(path): + return [] + path_lower = path.lower() + try: + if path_lower.endswith(".json"): + with open(path, "r", encoding="utf-8") as f: + data = json.load(f) + if isinstance(data, list) and data and isinstance(data[0], dict): + return [(str(o.get("from", "")), str(o.get("to", ""))) for o in data if o.get("from") and o.get("to")] + return [] + edf = pd.read_csv(path) + if {"from", "to"}.issubset(edf.columns): + return [(str(a).rstrip("/"), str(b).rstrip("/")) for a, b in edf[["from", "to"]].values] + except Exception: + pass + return [] + + +def save_edges(edges: list[tuple[str, str]], path: str) -> None: + """Save edge list to CSV or JSON (by extension).""" + path_lower = path.lower() + if path_lower.endswith(".json"): + data = [{"from": a, "to": b} for a, b in edges] + with open(path, "w", encoding="utf-8") as f: + json.dump(data, f, indent=2) + else: + pd.DataFrame(edges, columns=["from", "to"]).to_csv(path, index=False) + diff --git a/src/website_profiling/parsing/links.py b/src/website_profiling/parsing/links.py new file mode 100644 index 00000000..6f78fb31 --- /dev/null +++ b/src/website_profiling/parsing/links.py @@ -0,0 +1,155 @@ +"""Link normalization and extraction.""" +from __future__ import annotations + +import ast +import math +from urllib.parse import urldefrag, urljoin, urlparse + +from bs4 import BeautifulSoup + +_TRACKING_PARAM_PREFIXES = ("utm_",) +_FACET_PARAM_NAMES = frozenset({"sort", "filter", "page", "offset", "limit"}) + + +def strip_crawl_query_params(url: str, ignore_params: list[str] | None = None) -> str: + """Remove tracking and facet query params for crawl deduplication.""" + parsed = urlparse(url) + if not parsed.query: + return url.rstrip("/") + ignore = {p.lower() for p in (ignore_params or [])} + parts = [] + for pair in parsed.query.split("&"): + if not pair: + continue + key = pair.split("=", 1)[0].lower() + if key in ignore: + continue + if any(key.startswith(p) for p in _TRACKING_PARAM_PREFIXES): + continue + if key in _FACET_PARAM_NAMES: + continue + parts.append(pair) + query = "&".join(parts) + rebuilt = parsed._replace(query=query).geturl() + return rebuilt.rstrip("/") + + +def normalize_link( + base: str, + href: str, + strip_params: bool = True, + ignore_params: list[str] | None = None, +) -> str | None: + if not href: + return None + href = href.strip() + if href.startswith(("mailto:", "javascript:", "tel:", "data:")): + return None + joined = urljoin(base, href) + joined, _ = urldefrag(joined) + parsed = urlparse(joined) + if parsed.scheme not in ("http", "https"): + return None + out = joined.rstrip("/") + if strip_params: + out = strip_crawl_query_params(out, ignore_params) + return out + + +def _parse_rel_flags(rel_raw: str) -> tuple[bool, bool, bool]: + parts = {p.strip().lower() for p in (rel_raw or "").split() if p.strip()} + return ("nofollow" in parts, "sponsored" in parts, "ugc" in parts) + + +def _anchor_text_from_tag(a) -> str: + parts: list[str] = [] + for child in a.children: + if getattr(child, "name", None) == "img": + parts.append("[image]") + elif isinstance(child, str): + t = child.strip() + if t: + parts.append(t) + text = " ".join(parts).strip() or a.get_text(separator=" ", strip=True) + return (text or "")[:500] + + +def parse_link_edges(base_url: str, html_text: str) -> tuple[str, list[dict]]: + """Extract title and rich outbound link records from HTML.""" + soup = BeautifulSoup(html_text, "lxml") + title_tag = ( + soup.title.string.strip() + if soup.title and soup.title.string + else "" + ) + start_netloc = urlparse(base_url).netloc + edges: list[dict] = [] + for a in soup.find_all("a", href=True): + ln = normalize_link(base_url, a["href"]) + if not ln: + continue + rel_raw = a.get("rel") or "" + if isinstance(rel_raw, list): + rel_str = " ".join(str(x) for x in rel_raw) + else: + rel_str = str(rel_raw) + nofollow, sponsored, ugc = _parse_rel_flags(rel_str) + link_type = "internal" if urlparse(ln).netloc == start_netloc else "external" + edges.append({ + "to_url": ln.rstrip("/"), + "anchor_text": _anchor_text_from_tag(a), + "rel": rel_str.strip(), + "is_nofollow": nofollow, + "is_sponsored": sponsored, + "is_ugc": ugc, + "link_type": link_type, + }) + return title_tag, edges + + +def parse_links(base_url: str, html_text: str) -> tuple[str, set[str]]: + """Extract page title and set of absolute links from HTML. Returns (title, links).""" + title, edges = parse_link_edges(base_url, html_text) + return title, {e["to_url"] for e in edges} + +def _is_empty(raw) -> bool: + if raw is None: + return True + if isinstance(raw, float) and math.isnan(raw): + return True + if raw == "": + return True + return False + + +def parse_links_serialized(raw) -> list[str]: + """ + Parse a serialized list of URLs from CSV/DataFrame (string list repr, comma-separated, or list). + """ + if _is_empty(raw): + return [] + if isinstance(raw, list): + return [str(x).strip().rstrip("/") for x in raw if x] + s = str(raw).strip() + if not s: + return [] + if s.startswith("[") and s.endswith("]"): + try: + v = ast.literal_eval(s) + if isinstance(v, (list, tuple)): + return [str(x).strip().rstrip("/") for x in v if x] + except Exception: + pass + return [t.strip().rstrip("/") for t in s.split(",") if t.strip()] + + +# Column names that may contain serialized outlink lists (for building edges from crawl CSV) +LINK_COLUMN_NAMES = ( + "links", + "edges", + "outlinks", + "outlink_targets", + "targets", + "link_targets", + "links_list", +) diff --git a/src/website_profiling/parsing/robots.py b/src/website_profiling/parsing/robots.py new file mode 100644 index 00000000..de523770 --- /dev/null +++ b/src/website_profiling/parsing/robots.py @@ -0,0 +1,17 @@ +"""robots.txt loading.""" +from __future__ import annotations + +from urllib.parse import urlparse +import urllib.robotparser as robotparser + +def load_robots(start_url: str): + """Load robots.txt for the given URL; returns RobotFileParser or None on error.""" + parsed = urlparse(start_url) + robots_url = f"{parsed.scheme}://{parsed.netloc}/robots.txt" + rp = robotparser.RobotFileParser() + rp.set_url(robots_url) + try: + rp.read() + return rp + except Exception: + return None diff --git a/src/website_profiling/parsing/seo.py b/src/website_profiling/parsing/seo.py new file mode 100644 index 00000000..e64187d8 --- /dev/null +++ b/src/website_profiling/parsing/seo.py @@ -0,0 +1,138 @@ +"""SEO and resource parsing from HTML.""" +from __future__ import annotations + +from urllib.parse import urlparse + +from bs4 import BeautifulSoup + +from .links import normalize_link + +def parse_seo(base_url: str, html_text: str) -> tuple[str, int, str, int, str]: + """ + Extract SEO-related fields from HTML. + Returns (meta_description, meta_description_len, h1_text, h1_count, canonical_url). + """ + soup = BeautifulSoup(html_text, "lxml") + meta_desc = "" + meta = soup.find("meta", attrs={"name": "description"}) + if meta and meta.get("content"): + meta_desc = (meta["content"] or "").strip() + if not meta_desc: + og = soup.find("meta", attrs={"property": "og:description"}) + if og and og.get("content"): + meta_desc = (og["content"] or "").strip() + meta_desc_len = len(meta_desc) + + h1_tags = soup.find_all("h1") + h1_count = len(h1_tags) + h1_text = (h1_tags[0].get_text(separator=" ", strip=True) if h1_tags else "") or "" + + canonical_url = "" + link_canonical = soup.find("link", attrs={"rel": "canonical"}) + if link_canonical and link_canonical.get("href"): + canonical_url = normalize_link(base_url, link_canonical["href"]) or "" + + return meta_desc, meta_desc_len, h1_text, h1_count, canonical_url + + +def parse_seo_extended(html_text: str, base_url: str) -> dict: + """ + Extract extended SEO/accessibility/performance-related fields from HTML. + Returns a dict with: viewport_present, viewport_content, noindex, has_schema, + heading_sequence, images_without_alt, images_total, img_without_lazy, img_without_dimensions, + aria_count, mixed_content_count. + """ + soup = BeautifulSoup(html_text, "lxml") + out = { + "viewport_present": False, + "viewport_content": "", + "noindex": False, + "has_schema": False, + "heading_sequence": [], + "heading_text": [], + "images_without_alt": 0, + "images_total": 0, + "img_without_lazy": 0, + "img_without_dimensions": 0, + "aria_count": 0, + "mixed_content_count": 0, + } + # Viewport + viewport = soup.find("meta", attrs={"name": "viewport"}) + if viewport and viewport.get("content"): + out["viewport_present"] = True + out["viewport_content"] = (viewport["content"] or "").strip() + # noindex + robots = soup.find("meta", attrs={"name": "robots"}) + if robots and robots.get("content"): + content = (robots["content"] or "").lower() + out["noindex"] = "noindex" in content + # Structured data: JSON-LD or microdata + if soup.find("script", type="application/ld+json"): + out["has_schema"] = True + if soup.find(attrs={"itemscope": True}): + out["has_schema"] = True + # Heading order (h1..h6 tag names) and visible heading copy (for keywords / fingerprints) + for tag in soup.find_all(["h1", "h2", "h3", "h4", "h5", "h6"]): + if tag.name: + out["heading_sequence"].append(tag.name) + text = (tag.get_text(separator=" ", strip=True) or "").strip() + if text: + out["heading_text"].append(text) + # Images: alt, lazy, dimensions + base_scheme = urlparse(base_url).scheme.lower() + for img in soup.find_all("img"): + out["images_total"] += 1 + if not img.get("alt") and not img.get("aria-label"): + out["images_without_alt"] += 1 + loading = (img.get("loading") or "").strip().lower() + if loading != "lazy": + out["img_without_lazy"] += 1 + if not img.get("width") and not img.get("height"): + out["img_without_dimensions"] += 1 + src = img.get("src") or "" + if base_scheme == "https" and src.strip().lower().startswith("http://"): + out["mixed_content_count"] += 1 + # ARIA: count elements with any aria- attribute + for el in soup.find_all(True): + if getattr(el, "attrs", None) and any(k.startswith("aria-") for k in el.attrs): + out["aria_count"] += 1 + # Mixed content: links and other src/href + for tag in soup.find_all(True): + for attr in ("href", "src", "srcset"): + val = tag.get(attr) + if not val or base_scheme != "https": + continue + val = str(val).strip().lower() + if val.startswith("http://"): + out["mixed_content_count"] += 1 + elif attr == "srcset": + for part in val.split(","): + part = part.strip().split()[0] if part.strip() else "" + if part.startswith("http://"): + out["mixed_content_count"] += 1 + return out +def parse_resources(html_text: str, base_url: str) -> dict: + """ + Extract script/link resource counts and total sizes (same-origin only, no fetch). + Returns dict: script_count, link_stylesheet_count, script_urls, stylesheet_urls + (URLs for optional later HEAD/GET). Does not fetch; caller may fetch with limit. + """ + soup = BeautifulSoup(html_text, "lxml") + parsed_base = urlparse(base_url) + script_urls = [] + for s in soup.find_all("script", src=True): + url = normalize_link(base_url, s["src"]) + if url and urlparse(url).netloc == parsed_base.netloc: + script_urls.append(url) + stylesheet_urls = [] + for link in soup.find_all("link", rel=lambda r: r and "stylesheet" in (r.lower() if isinstance(r, str) else "")): + url = link.get("href") and normalize_link(base_url, link["href"]) + if url and urlparse(url).netloc == parsed_base.netloc: + stylesheet_urls.append(url) + return { + "script_count": len(script_urls), + "link_stylesheet_count": len(stylesheet_urls), + "script_urls": script_urls, + "stylesheet_urls": stylesheet_urls, + } diff --git a/src/website_profiling/parsing/tech.py b/src/website_profiling/parsing/tech.py new file mode 100644 index 00000000..587e22f5 --- /dev/null +++ b/src/website_profiling/parsing/tech.py @@ -0,0 +1,116 @@ +"""Technology stack detection.""" +from __future__ import annotations + +import json +import warnings + +_TECH_PATTERNS = [ + ("WordPress", "html", "/wp-content/"), + ("WordPress", "html", "/wp-includes/"), + ("Drupal", "meta_generator", "Drupal"), + ("Joomla", "meta_generator", "Joomla"), + ("Shopify", "html", "cdn.shopify.com"), + ("Squarespace", "html", "squarespace.com"), + ("Wix", "html", "wix.com"), + ("Next.js", "html", "__NEXT_DATA__"), + ("Next.js", "html", "_next/static"), + ("Nuxt.js", "html", "__NUXT__"), + ("Gatsby", "html", "gatsby-"), + ("React", "html", "data-reactroot"), + ("React", "html", "__REACT_DEVTOOLS"), + ("React", "html", "react.production.min"), + ("Vue.js", "html", "__vue"), + ("Vue.js", "html", "vue.min.js"), + ("Angular", "html", "ng-version"), + ("Angular", "html", "ng-app"), + ("Svelte", "html", "svelte"), + ("jQuery", "html", "jquery"), + ("Bootstrap", "html", "bootstrap"), + ("Tailwind CSS", "html", "tailwindcss"), + ("Google Analytics", "html", "google-analytics.com/analytics.js"), + ("Google Analytics", "html", "googletagmanager.com/gtag"), + ("Google Tag Manager", "html", "googletagmanager.com/gtm.js"), + ("Facebook Pixel", "html", "connect.facebook.net"), + ("Hotjar", "html", "hotjar.com"), + ("Google Fonts", "html", "fonts.googleapis.com"), + ("Font Awesome", "html", "fontawesome"), + ("Cloudflare", "header", "cf-ray"), + ("Nginx", "header_server", "nginx"), + ("Apache", "header_server", "apache"), + ("LiteSpeed", "header_server", "litespeed"), + ("Vercel", "header_server", "vercel"), + ("Netlify", "header_server", "netlify"), + ("Amazon CloudFront", "header", "x-amz-cf-id"), + ("AWS", "header_server", "amazons3"), +] + +# Module-level cache for Wappalyzer instance (avoids reloading technologies file per page). +_wappalyzer_instance = None +_wappalyzer_disabled = False + + +def _is_wappalyzer_regex_warning(msg: str) -> bool: + lower = msg.lower() + return "compiling regex" in lower and "unbalanced parenthesis" in lower + + +def detect_tech_wappalyzer( + url: str, + html: str, + headers: dict, + soup, + wappalyzer=None, +) -> str: + """ + Detect technologies using python-Wappalyzer from existing HTML and headers. + Returns JSON list of tech names. On any failure, falls back to parse_tech_stack(soup, headers, url). + """ + global _wappalyzer_instance, _wappalyzer_disabled + if _wappalyzer_disabled: + return parse_tech_stack(soup, headers, url) + try: + from Wappalyzer import Wappalyzer, WebPage + except ImportError: + return parse_tech_stack(soup, headers, url) + try: + with warnings.catch_warnings(record=True) as caught: + warnings.simplefilter("always") + instance = wappalyzer if wappalyzer is not None else _wappalyzer_instance + if instance is None: + instance = Wappalyzer.latest() + if wappalyzer is None: + _wappalyzer_instance = instance + webpage = WebPage(url, html=html, headers=headers) + detected = instance.analyze(webpage) + if any(_is_wappalyzer_regex_warning(str(w.message)) for w in caught): + _wappalyzer_disabled = True + _wappalyzer_instance = None + return parse_tech_stack(soup, headers, url) + return json.dumps(sorted(detected)) + except Exception: + return parse_tech_stack(soup, headers, url) + + +def parse_tech_stack(soup, headers: dict, url: str) -> str: + """Detect technologies from HTML patterns and HTTP headers. Returns JSON list of tech names.""" + detected = set() + html_str = str(soup).lower() + meta_gen = soup.find("meta", attrs={"name": "generator"}) + generator = (meta_gen.get("content") or "").strip().lower() if meta_gen else "" + server_header = (headers.get("Server") or headers.get("server") or "").lower() + + for name, source, pattern in _TECH_PATTERNS: + pat = pattern.lower() + if source == "html" and pat in html_str: + detected.add(name) + elif source == "meta_generator" and pat in generator: + detected.add(name) + elif source == "header": + for v in headers.values(): + if isinstance(v, str) and pat in v.lower(): + detected.add(name) + break + elif source == "header_server" and pat in server_header: + detected.add(name) + + return json.dumps(sorted(detected)) diff --git a/src/website_profiling/reporting/builder.py b/src/website_profiling/reporting/builder.py index c178dd16..f0febd25 100644 --- a/src/website_profiling/reporting/builder.py +++ b/src/website_profiling/reporting/builder.py @@ -1,1181 +1,77 @@ """ Generate report data from crawl and write to PostgreSQL. The Next.js UI in web/ reads via /api/report/*. """ -import hashlib +from __future__ import annotations + import json import os -import socket -import ssl -import time -from concurrent.futures import ThreadPoolExecutor, as_completed from datetime import datetime, timezone from typing import Any, Optional from urllib.parse import urlparse import pandas as pd import requests -from bs4 import BeautifulSoup -from tqdm.auto import tqdm - -from ..common import ( - LINK_COLUMN_NAMES, - load_edges, - normalize_link, - parse_links_serialized, -) -from ..tools.keywords import cluster_keywords, extract_candidates_from_df, score_keywords -from ..config import get_bool, get_int + from ..analysis import merge_bundles, run_local_enrichment -from ..analysis.text_hygiene import filter_topic_clusters, is_junk_semantic_term +from ..analysis.text_hygiene import is_junk_semantic_term +from ..config import get_bool, get_int from ..llm.enrich import cluster_keywords_llm, run_llm_enrichment from ..llm_config import load_llm_config_from_db, llm_is_enabled -from .categories import build_categories from ..security_scanner import run_security_scan - -# SEO thresholds for recommendations -TITLE_LEN_MIN = 30 -TITLE_LEN_MAX = 60 -META_DESC_LEN_MIN = 70 -META_DESC_LEN_MAX = 160 -THIN_CONTENT_CHARS = 300 - - -def fetch_site_ssl_expires_iso(hostname: str, timeout: float = 5.0) -> Optional[str]: - """Return certificate notAfter as ISO 8601 UTC, or None on failure.""" - host = (hostname or "").strip().lower() - if not host: - return None - try: - ctx = ssl.create_default_context() - with socket.create_connection((host, 443), timeout=timeout) as sock: - with ctx.wrap_socket(sock, server_hostname=host) as ssock: - cert = ssock.getpeercert() - if not cert: - return None - na = cert.get("notAfter") - if not na: - return None - ts = ssl.cert_time_to_seconds(na) - return datetime.fromtimestamp(ts, tz=timezone.utc).isoformat() - except Exception: - return None - - -def _strip_www(host: str) -> str: - h = (host or "").strip().lower() - return h[4:] if h.startswith("www.") else h - - -def _url_hostname(url: str) -> str: - if not url: - return "" - try: - return (urlparse(str(url).strip()).hostname or "").lower() - except Exception: - return "" - - -def _hosts_match(a: str, b: str) -> bool: - if not a or not b: - return False - a, b = a.lower(), b.lower() - return a == b or _strip_www(a) == _strip_www(b) - - -def filter_lighthouse_by_host(by_url: dict[str, Any], expected_host: str) -> dict[str, Any]: - """Keep only Lighthouse entries whose URL hostname matches expected_host (www.-tolerant).""" - if not by_url or not expected_host: - return by_url or {} - return {u: v for u, v in by_url.items() if _hosts_match(_url_hostname(u), expected_host)} - - -def _derive_expected_host(start_url: str, df: pd.DataFrame) -> str: - host = _url_hostname(start_url) - if host: - return host - if df is not None and not df.empty and "url" in df.columns: - for u in df["url"]: - h = _url_hostname(str(u)) - if h: - return h - return "" - - -def _pick_lighthouse_summary( - lighthouse_by_url: dict[str, Any], - start_url: str, - global_summary: Optional[dict[str, Any]], - expected_host: str, -) -> Optional[dict[str, Any]]: - """Prefer per-URL summary for this crawl; only use global summary if hostname matches.""" - if lighthouse_by_url and start_url: - match = lighthouse_for_url(lighthouse_by_url, start_url) - if match: - return match - if lighthouse_by_url: - first_key = next(iter(lighthouse_by_url), None) - if first_key is not None: - return lighthouse_by_url[first_key] - if global_summary: - if not expected_host or _hosts_match(_url_hostname(str(global_summary.get("url") or "")), expected_host): - return global_summary - return None - - -def build_lighthouse_by_url_for_report(conn: Any) -> dict[str, Any]: - """ - Merge per-URL Lighthouse page summaries with latest lighthouse_runs row: full audits/items - from normalized tables, uncapped top_failures and diagnostics from stored LHR JSON. - """ - from ..db import ( - read_lh_audits_with_items, - read_lh_runs_by_url, - read_lighthouse_page_summaries, - read_lighthouse_run_json, - ) - from ..lighthouse.runner import _evidence_from_audit, extract_from_lighthouse_json - from ..tools.warnings import parse_lighthouse_to_diagnostics, resolve_impact - - summaries = read_lighthouse_page_summaries(conn) - runs_map = read_lh_runs_by_url(conn) - - summaries_norm: dict[str, Any] = {} - for k, v in summaries.items(): - nk = str(k).strip().rstrip("/") - summaries_norm[nk] = v - - all_urls = set(summaries_norm.keys()) | set(runs_map.keys()) - out: dict[str, Any] = {} - - for u in sorted(all_urls): - base: dict[str, Any] = dict(summaries_norm[u]) if u in summaries_norm else {} - run_ids = runs_map.get(u, []) - run_id = run_ids[-1] if run_ids else None - - if run_id is not None: - raw = read_lighthouse_run_json(conn, run_id) - if not base and raw: - ex = extract_from_lighthouse_json(raw) - lr = raw.get("lighthouseResult") or raw - final_u = lr.get("finalUrl") or lr.get("requestedUrl") or u - base = { - "url": str(final_u).strip().rstrip("/"), - "median_metrics": { - "lcp_ms": ex.get("lcp_ms"), - "cls": ex.get("cls"), - "tbt_ms": ex.get("tbt_ms"), - "fcp_ms": ex.get("fcp_ms"), - "speed_index_ms": ex.get("speed_index_ms"), - "performance_score": ex.get("performance_score"), - "accessibility_score": ex.get("accessibility_score"), - "seo_score": ex.get("seo_score"), - "best_practices_score": ex.get("best_practices_score"), - "pwa_score": ex.get("pwa_score"), - }, - "category_scores": dict(ex.get("category_scores") or {}), - "strategy": "mobile", - "device": "mobile", - "mode": "navigation", - } - base["audits"] = read_lh_audits_with_items(conn, run_id) - if raw: - lr = raw.get("lighthouseResult") or raw - audits_map = lr.get("audits") or {} - failures: list[dict[str, Any]] = [] - for aid, a in audits_map.items(): - if not isinstance(a, dict): - continue - score = a.get("score") - if score is None or score >= 1: - continue - title = a.get("title") or aid - help_text = a.get("helpText") or "" - failures.append( - { - "id": aid, - "score": score, - "helpText": help_text, - "impact": resolve_impact(aid, title, help_text), - "evidence": _evidence_from_audit(a), - } - ) - failures.sort(key=lambda x: (x["score"] or 0)) - base["top_failures"] = failures - base["diagnostics"] = parse_lighthouse_to_diagnostics(raw, max_nodes_in_refs=None) - elif not base: - continue - - if not base.get("url"): - base["url"] = u - out[u] = base - - return out - - -def lighthouse_for_url(lighthouse_by_url: dict[str, Any], url: str) -> Optional[dict[str, Any]]: - """Resolve Lighthouse summary for a crawled URL (trailing-slash tolerant).""" - if not lighthouse_by_url or not url: - return None - u = str(url).strip().rstrip("/") - if u in lighthouse_by_url: - return lighthouse_by_url[u] - for k, v in lighthouse_by_url.items(): - if str(k).strip().rstrip("/") == u: - return v - return None - - -def build_edges_from_df( - df: pd.DataFrame, - edges_csv: str, - same_domain_only: bool, - max_fetch_for_edges: int, - concurrency: int, - timeout: int, - polite_delay: float, - render_mode: str = "static", - js_timeout: int = 30, - js_concurrency: int = 3, - js_wait_until: str = "domcontentloaded", - js_extra_wait_ms: int = 1500, - js_block_resources: bool = True, -) -> list[tuple[str, str]]: - """Build or load edges; return list of (from, to) tuples.""" - edges = load_edges(edges_csv) if (edges_csv or "").strip() else [] - if edges: - return edges - - # Prefer columns that hold URL lists (e.g. outlink_targets); skip "outlinks" (numeric count) - candidate_cols = [ - c for c in df.columns - if c.lower() in LINK_COLUMN_NAMES and c.lower() != "outlinks" - ] - if candidate_cols: - for col in candidate_cols: - if df[col].notna().sum() == 0: - continue - for src, raw in zip(df["url"], df[col].fillna("")): - for t in parse_links_serialized(raw): - if not t: - continue - if same_domain_only and urlparse(src).netloc != urlparse(t).netloc: - continue - edges.append((src, t)) - if edges: - return edges - - session = requests.Session() - session.headers.update({"User-Agent": "WebsiteProfiling/1.0"}) - urls = df["url"].tolist()[:max_fetch_for_edges] - mode = (render_mode or "static").strip().lower() - use_js = mode in ("javascript", "auto") - fetcher = None - if use_js: - from ..crawl.fetchers import build_fetcher - - fetcher = build_fetcher( - render_mode="javascript" if mode == "javascript" else "auto", - timeout=timeout, - user_agent="WebsiteProfiling/1.0", - session=session, - js_timeout=js_timeout, - js_concurrency=js_concurrency, - js_wait_until=js_wait_until, - js_extra_wait_ms=js_extra_wait_ms, - js_block_resources=js_block_resources, - ) - - def fetch(src): - try: - if fetcher is not None: - r = fetcher.fetch(src) - if r.status != 200 or not r.text: - return [] - html = r.text - else: - resp = session.get(src, timeout=timeout, allow_redirects=True) - if resp.status_code != 200 or not resp.headers.get("Content-Type", "").lower().startswith("text/html"): - return [] - html = resp.text - soup = BeautifulSoup(html, "lxml") - out = set() - for a in soup.find_all("a", href=True): - ln = normalize_link(src, a["href"]) - if not ln or (same_domain_only and urlparse(src).netloc != urlparse(ln).netloc): - continue - out.add(ln) - if polite_delay: - time.sleep(polite_delay) - return list(out) - except Exception: - return [] - - try: - with ThreadPoolExecutor(max_workers=concurrency) as ex: - futures = {ex.submit(fetch, u): u for u in urls} - for f in tqdm(as_completed(futures), total=len(futures), desc="Extracting links"): - src = futures[f] - try: - outs = f.result() - except Exception: - outs = [] - for t in outs: - edges.append((src, t)) - finally: - if fetcher is not None: - fetcher.close() - return edges - - -def _fetch_site_level(start_url: str, timeout: int = 8) -> dict: - """Fetch robots.txt, sitemap.xml, ads.txt, and security.txt from start_url origin.""" - from .site_files import fetch_ads_txt, fetch_security_txt, merge_site_file_fields - - parsed = urlparse(start_url) - if not parsed.scheme or not parsed.netloc: - return { - "robots_present": False, - "sitemap_present": False, - "sitemap_valid": False, - "ads_txt_present": False, - "security_txt_present": False, - } - base = f"{parsed.scheme}://{parsed.netloc}" - session = requests.Session() - session.headers.update({"User-Agent": "WebsiteProfiling/1.0"}) - out: dict[str, Any] = { - "robots_present": False, - "sitemap_present": False, - "sitemap_valid": False, - } - try: - r = session.get(f"{base}/robots.txt", timeout=timeout) - if r.status_code == 200 and r.text: - out["robots_present"] = True - for line in r.text.splitlines(): - line = line.strip() - if line.lower().startswith("sitemap:"): - break - except Exception: - pass - try: - r = session.get(f"{base}/sitemap.xml", timeout=timeout) - if r.status_code == 200 and r.text: - out["sitemap_present"] = True - out["sitemap_valid"] = "<" in r.text and ">" in r.text and ("urlset" in r.text or "sitemapindex" in r.text) - except Exception: - pass - merge_site_file_fields(out, fetch_ads_txt(session, base, timeout=timeout)) - merge_site_file_fields(out, fetch_security_txt(session, base, timeout=timeout)) - return out - - -def _compute_summary_seo_issues(df: pd.DataFrame) -> dict: - """Compute crawl summary, SEO health metrics, issues list, and recommendations from crawl DataFrame.""" - total = len(df) - status_str = df["status"].astype(str) if "status" in df.columns else pd.Series(["unknown"] * len(df)) - count_2xx = int((status_str.str.match(r"2\d{2}").fillna(False)).sum()) - count_3xx = int((status_str.str.match(r"3\d{2}").fillna(False)).sum()) - count_4xx = int((status_str.str.match(r"4\d{2}").fillna(False)).sum()) - count_5xx = int((status_str.str.match(r"5\d{2}").fillna(False)).sum()) - count_error = int((status_str.isin(["error", "blocked_by_robots"])).sum()) - success_rate = round(100 * count_2xx / total, 1) if total else 0 - - outlinks = ( - pd.to_numeric(df["outlinks"], errors="coerce").fillna(0).astype(int) - if "outlinks" in df.columns - else pd.Series([0] * len(df)) - ) - title_len = ( - df["title"].fillna("").astype(str).apply(len) - if "title" in df.columns - else pd.Series([0] * len(df)) - ) - crawl_time_s = float(df["crawl_time_s"].iloc[0]) if "crawl_time_s" in df.columns and len(df) else None - - summary = { - "total_urls": total, - "count_2xx": count_2xx, - "count_3xx": count_3xx, - "count_4xx": count_4xx, - "count_5xx": count_5xx, - "count_error": count_error, - "success_rate": success_rate, - "avg_outlinks": round(float(outlinks.mean()), 1) if total else 0, - "avg_title_len": round(float(title_len.mean()), 1) if total else 0, - "crawl_time_s": round(crawl_time_s, 1) if crawl_time_s is not None else None, - } - - # SEO health (when columns exist) - seo_health = {} - if "title" in df.columns: - titles = df["title"].fillna("").astype(str) - seo_health["missing_title"] = int((titles.str.len() == 0).sum()) - seo_health["title_short"] = int(((title_len > 0) & (title_len < TITLE_LEN_MIN)).sum()) - seo_health["title_long"] = int((title_len > TITLE_LEN_MAX).sum()) - seo_health["title_ok"] = int(((title_len >= TITLE_LEN_MIN) & (title_len <= TITLE_LEN_MAX)).sum()) - if "meta_description_len" in df.columns: - md_len = pd.to_numeric(df["meta_description_len"], errors="coerce").fillna(0).astype(int) - seo_health["missing_meta_desc"] = int((md_len == 0).sum()) - seo_health["meta_desc_short"] = int(((md_len > 0) & (md_len < META_DESC_LEN_MIN)).sum()) - seo_health["meta_desc_long"] = int((md_len > META_DESC_LEN_MAX).sum()) - seo_health["meta_desc_ok"] = int(((md_len >= META_DESC_LEN_MIN) & (md_len <= META_DESC_LEN_MAX)).sum()) - if "h1_count" in df.columns: - h1c = pd.to_numeric(df["h1_count"], errors="coerce").fillna(-1).astype(int) - seo_health["h1_zero"] = int((h1c == 0).sum()) - seo_health["h1_one"] = int((h1c == 1).sum()) - seo_health["h1_multi"] = int((h1c > 1).sum()) - if "content_length" in df.columns: - cl = pd.to_numeric(df["content_length"], errors="coerce").fillna(0).astype(int) - seo_health["thin_content"] = int(((cl > 0) & (cl < THIN_CONTENT_CHARS)).sum()) - - # Issues: broken, redirects, SEO - issues = {"broken": [], "redirects": [], "seo": []} - for _, row in df.iterrows(): - u = row.get("url") - if pd.isna(u) or not u: - continue - u = str(u).strip() - st = str(row.get("status", "")).strip() - if st.startswith("4") or st.startswith("5") or st in ("error", "blocked_by_robots"): - issues["broken"].append({"url": u, "status": st}) - elif st.startswith("3"): - final = row.get("final_url") or "" - issues["redirects"].append({"url": u, "status": st, "final_url": str(final) if pd.notna(final) else ""}) - - if "title" in df.columns: - for _, row in df.iterrows(): - u = row.get("url") - if pd.isna(u): - continue - u = str(u).strip() - t = row.get("title") or "" - tl = len(str(t).strip()) - if tl == 0: - issues["seo"].append({"type": "missing_title", "url": u, "message": "Missing title"}) - elif tl < TITLE_LEN_MIN: - issues["seo"].append({"type": "title_short", "url": u, "message": f"Title too short ({tl} chars)"}) - elif tl > TITLE_LEN_MAX: - issues["seo"].append({"type": "title_long", "url": u, "message": f"Title too long ({tl} chars)"}) - if "meta_description_len" in df.columns: - for _, row in df.iterrows(): - md_len = pd.to_numeric(row.get("meta_description_len"), errors="coerce") - if pd.isna(md_len) or md_len == 0: - continue - u = row.get("url") - if pd.isna(u): - continue - u = str(u).strip() - ml = int(md_len) - if ml < META_DESC_LEN_MIN: - issues["seo"].append({"type": "meta_desc_short", "url": u, "message": f"Meta description too short ({ml} chars)"}) - elif ml > META_DESC_LEN_MAX: - issues["seo"].append({"type": "meta_desc_long", "url": u, "message": f"Meta description too long ({ml} chars)"}) - if "h1_count" in df.columns: - for _, row in df.iterrows(): - h1c = pd.to_numeric(row.get("h1_count"), errors="coerce") - if pd.isna(h1c) or h1c == 1: - continue - u = row.get("url") - if pd.isna(u): - continue - u = str(u).strip() - if int(h1c) == 0: - issues["seo"].append({"type": "h1_missing", "url": u, "message": "Missing H1"}) - else: - issues["seo"].append({"type": "h1_multi", "url": u, "message": f"Multiple H1s ({int(h1c)})"}) - if "content_length" in df.columns: - for _, row in df.iterrows(): - cl = pd.to_numeric(row.get("content_length"), errors="coerce") - cl = 0 if pd.isna(cl) else int(cl) - if cl >= THIN_CONTENT_CHARS or cl == 0: - continue - u = row.get("url") - if pd.isna(u): - continue - issues["seo"].append({"type": "thin_content", "url": str(u).strip(), "message": f"Thin content ({int(cl)} chars)"}) - - # Recommendations (actionable bullets) - recommendations = [] - if issues["broken"]: - recommendations.append(f"Fix {len(issues['broken'])} broken or error URL(s).") - if issues["redirects"]: - recommendations.append(f"Review {len(issues['redirects'])} redirect(s); consolidate if possible.") - if seo_health.get("missing_title", 0) > 0: - recommendations.append(f"Add titles to {seo_health['missing_title']} page(s).") - if seo_health.get("title_short", 0) + seo_health.get("title_long", 0) > 0: - n = seo_health.get("title_short", 0) + seo_health.get("title_long", 0) - recommendations.append(f"Optimize title length on {n} page(s) (aim 30–60 chars).") - if seo_health.get("missing_meta_desc", 0) > 0: - recommendations.append(f"Add meta descriptions to {seo_health['missing_meta_desc']} page(s).") - if seo_health.get("meta_desc_short", 0) + seo_health.get("meta_desc_long", 0) > 0: - n = seo_health.get("meta_desc_short", 0) + seo_health.get("meta_desc_long", 0) - recommendations.append(f"Optimize meta description length on {n} page(s) (aim 70–160 chars).") - if seo_health.get("h1_zero", 0) > 0: - recommendations.append(f"Add one H1 per page on {seo_health['h1_zero']} page(s).") - if seo_health.get("h1_multi", 0) > 0: - recommendations.append(f"Use a single H1 per page on {seo_health['h1_multi']} page(s).") - if seo_health.get("thin_content", 0) > 0: - recommendations.append(f"Expand thin content on {seo_health['thin_content']} page(s) (under {THIN_CONTENT_CHARS} chars).") - - return { - "summary": summary, - "seo_health": seo_health, - "issues": issues, - "recommendations": recommendations, - } - - -def _build_content_analytics(df: pd.DataFrame) -> dict: - """Build content analytics: word count stats, reading level distribution, content ratio, top keywords.""" - from collections import Counter - - result = { - "word_count_stats": {"mean": 0, "median": 0, "p25": 0, "p75": 0, "min": 0, "max": 0}, - "word_count_distribution": {}, - "reading_level_distribution": {}, - "content_ratio_distribution": {}, - "top_keywords_site": [], - "thin_pages": [], - } - if "word_count" not in df.columns or df.empty: - return result - - success_df = df[df["status"].astype(str).str.match(r"2\d{2}", na=False)] if "status" in df.columns else df - if success_df.empty: - return result - - wc = pd.to_numeric(success_df["word_count"], errors="coerce").fillna(0).astype(int) - result["word_count_stats"] = { - "mean": round(float(wc.mean()), 1), - "median": round(float(wc.median()), 1), - "p25": round(float(wc.quantile(0.25)), 1), - "p75": round(float(wc.quantile(0.75)), 1), - "min": int(wc.min()), - "max": int(wc.max()), - } - - wc_bins = [(0, 100), (101, 300), (301, 600), (601, 1000), (1001, 2000), (2001, 999999)] - wc_labels = ["0-100", "101-300", "301-600", "601-1000", "1001-2000", "2001+"] - result["word_count_distribution"] = { - lbl: int(((wc >= lo) & (wc <= hi)).sum()) for (lo, hi), lbl in zip(wc_bins, wc_labels) - } - - if "reading_level" in success_df.columns: - rl = pd.to_numeric(success_df["reading_level"], errors="coerce").fillna(0) - rl_bins = [(0, 5), (6, 8), (9, 12), (13, 99)] - rl_labels = ["Elementary (0-5)", "Middle School (6-8)", "High School (9-12)", "College (13+)"] - result["reading_level_distribution"] = { - lbl: int(((rl >= lo) & (rl <= hi)).sum()) for (lo, hi), lbl in zip(rl_bins, rl_labels) - } - - if "content_html_ratio" in success_df.columns: - cr = pd.to_numeric(success_df["content_html_ratio"], errors="coerce").fillna(0) - cr_bins = [(0, 10), (10.01, 20), (20.01, 40), (40.01, 100)] - cr_labels = ["<10%", "10-20%", "20-40%", ">40%"] - result["content_ratio_distribution"] = { - lbl: int(((cr >= lo) & (cr <= hi)).sum()) for (lo, hi), lbl in zip(cr_bins, cr_labels) - } - - if "top_keywords" in success_df.columns: - kw_counter = Counter() - for raw in success_df["top_keywords"].fillna("[]"): - try: - items = json.loads(str(raw)) if isinstance(raw, str) else raw - if isinstance(items, list): - for item in items: - if isinstance(item, dict): - kw_counter[item.get("word", "")] += item.get("count", 0) - except (json.JSONDecodeError, TypeError): - pass - result["top_keywords_site"] = [ - {"word": w, "count": c} - for w, c in kw_counter.most_common(50) - if w and not is_junk_semantic_term(str(w)) - ][:30] - - for _, row in success_df.iterrows(): - u = row.get("url") - if pd.isna(u) or not u: - continue - w = int(pd.to_numeric(row.get("word_count"), errors="coerce") or 0) - if 0 < w < 300: - result["thin_pages"].append({"url": str(u).strip(), "word_count": w}) - - return result - - -def _parse_top_keywords_items(raw: Any) -> list[dict[str, Any]]: - """Parse per-page top_keywords JSON into dict items with word/count.""" - if raw is None or (isinstance(raw, float) and pd.isna(raw)): - return [] - try: - items = json.loads(str(raw)) if isinstance(raw, str) else raw - except (json.JSONDecodeError, TypeError, ValueError): - return [] - if not isinstance(items, list): - return [] - out: list[dict[str, Any]] = [] - for item in items: - if isinstance(item, dict): - word = str(item.get("word") or "").strip() - if word: - out.append({"word": word, "count": int(item.get("count") or 1)}) - return out - - -def _build_text_content_analysis(df: pd.DataFrame) -> dict: - """Cross-page keyword aggregates for the text content analysis view.""" - empty = { - "vocabulary_stats": { - "unique_terms": 0, - "pages_with_keywords": 0, - "avg_terms_per_page": 0.0, - "total_term_occurrences": 0, - }, - "keyword_index": [], - "keyword_frequency_histogram": {"1": 0, "2-5": 0, "6-20": 0, "21+": 0}, - } - if df.empty or "top_keywords" not in df.columns: - return empty - - success_df = df[df["status"].astype(str).str.match(r"2\d{2}", na=False)] if "status" in df.columns else df - if success_df.empty: - return empty - - # word -> { total_count, pages: { url -> count } } - index: dict[str, dict[str, Any]] = {} - pages_with_keywords = 0 - total_occurrences = 0 - - for _, row in success_df.iterrows(): - url = row.get("url") - if pd.isna(url) or not url: - continue - url_str = str(url).strip() - items = _parse_top_keywords_items(row.get("top_keywords")) - page_had_kw = False - for item in items: - word = item["word"].lower() - if is_junk_semantic_term(word): - continue - count = max(1, int(item.get("count") or 1)) - if word not in index: - index[word] = {"total_count": 0, "pages": {}} - index[word]["total_count"] += count - index[word]["pages"][url_str] = index[word]["pages"].get(url_str, 0) + count - total_occurrences += count - page_had_kw = True - if page_had_kw: - pages_with_keywords += 1 - - unique_terms = len(index) - avg_terms = round(total_occurrences / pages_with_keywords, 1) if pages_with_keywords else 0.0 - - histogram = {"1": 0, "2-5": 0, "6-20": 0, "21+": 0} - for data in index.values(): - pc = len(data["pages"]) - if pc == 1: - histogram["1"] += 1 - elif pc <= 5: - histogram["2-5"] += 1 - elif pc <= 20: - histogram["6-20"] += 1 - else: - histogram["21+"] += 1 - - sorted_words = sorted(index.items(), key=lambda x: x[1]["total_count"], reverse=True) - keyword_index: list[dict[str, Any]] = [] - for word, data in sorted_words: - top_pages = sorted(data["pages"].items(), key=lambda x: x[1], reverse=True)[:5] - keyword_index.append( - { - "word": word, - "total_count": data["total_count"], - "page_count": len(data["pages"]), - "top_pages": [{"url": u, "count": c} for u, c in top_pages], - } - ) - - return { - "vocabulary_stats": { - "unique_terms": unique_terms, - "pages_with_keywords": pages_with_keywords, - "avg_terms_per_page": avg_terms, - "total_term_occurrences": total_occurrences, - }, - "keyword_index": keyword_index, - "keyword_frequency_histogram": histogram, - } - - -def _build_social_coverage(df: pd.DataFrame) -> dict: - """Build social meta coverage stats: OG and Twitter Card presence percentages.""" - result = { - "og_coverage_pct": 0, - "twitter_coverage_pct": 0, - "og_image_coverage_pct": 0, - "missing_og": [], - "missing_twitter": [], - "og_image_missing": [], - } - if df.empty: - return result - - success_df = df[df["status"].astype(str).str.match(r"2\d{2}", na=False)] if "status" in df.columns else df - html_df = success_df - if "content_type" in success_df.columns: - html_df = success_df[success_df["content_type"].fillna("").str.contains("text/html", case=False, na=False)] - if html_df.empty: - return result - - total = len(html_df) - - if "og_title" in html_df.columns: - has_og = (html_df["og_title"].fillna("").astype(str).str.strip() != "").sum() - result["og_coverage_pct"] = round(100 * int(has_og) / total, 1) - for _, row in html_df.iterrows(): - u = row.get("url") - if pd.isna(u): - continue - u = str(u).strip() - og = str(row.get("og_title") or "").strip() - if not og: - result["missing_og"].append(u) - - if "twitter_card" in html_df.columns: - has_tw = (html_df["twitter_card"].fillna("").astype(str).str.strip() != "").sum() - result["twitter_coverage_pct"] = round(100 * int(has_tw) / total, 1) - for _, row in html_df.iterrows(): - u = row.get("url") - if pd.isna(u): - continue - u = str(u).strip() - tw = str(row.get("twitter_card") or "").strip() - if not tw: - result["missing_twitter"].append(u) - - if "og_image" in html_df.columns: - has_og_img = (html_df["og_image"].fillna("").astype(str).str.strip() != "").sum() - result["og_image_coverage_pct"] = round(100 * int(has_og_img) / total, 1) - for _, row in html_df.iterrows(): - u = row.get("url") - if pd.isna(u): - continue - u = str(u).strip() - img = str(row.get("og_image") or "").strip() - if not img: - result["og_image_missing"].append(u) - - result["missing_og"] = result["missing_og"][:100] - result["missing_twitter"] = result["missing_twitter"][:100] - result["og_image_missing"] = result["og_image_missing"][:100] - return result - - -def _build_tech_stack_summary(df: pd.DataFrame) -> dict: - """Build tech stack summary: detected technologies with counts and sample URLs.""" - from collections import defaultdict - - result = {"technologies": [], "total_pages_analyzed": 0} - if "tech_stack" not in df.columns or df.empty: - return result - - success_df = df[df["status"].astype(str).str.match(r"2\d{2}", na=False)] if "status" in df.columns else df - html_df = success_df - if "content_type" in success_df.columns: - html_df = success_df[success_df["content_type"].fillna("").str.contains("text/html", case=False, na=False)] - if html_df.empty: - return result - - result["total_pages_analyzed"] = len(html_df) - tech_urls = defaultdict(list) - - for _, row in html_df.iterrows(): - u = str(row.get("url", "")).strip() - raw = row.get("tech_stack") or "[]" - try: - techs = json.loads(str(raw)) if isinstance(raw, str) else raw - if isinstance(techs, list): - for t in techs: - if isinstance(t, str) and t: - tech_urls[t].append(u) - except (json.JSONDecodeError, TypeError): - pass - - result["technologies"] = sorted( - [{"name": name, "count": len(urls), "sample_urls": urls[:3]} for name, urls in tech_urls.items()], - key=lambda x: x["count"], - reverse=True, - ) - return result - - -def _build_response_time_stats(df: pd.DataFrame) -> dict: - """Build response time statistics and distribution.""" - result = { - "p25": 0, "p50": 0, "p75": 0, "p95": 0, "p99": 0, - "slow_pages": [], - "distribution": {}, - } - if "response_time_ms" not in df.columns or df.empty: - return result - - rt = pd.to_numeric(df["response_time_ms"], errors="coerce").dropna() - if rt.empty: - return result - - result["p25"] = round(float(rt.quantile(0.25)), 0) - result["p50"] = round(float(rt.quantile(0.50)), 0) - result["p75"] = round(float(rt.quantile(0.75)), 0) - result["p95"] = round(float(rt.quantile(0.95)), 0) - result["p99"] = round(float(rt.quantile(0.99)), 0) - - rt_bins = [(0, 200), (200, 500), (500, 1000), (1000, 2000), (2000, 999999)] - rt_labels = ["<200ms", "200-500ms", "500ms-1s", "1-2s", ">2s"] - rt_full = pd.to_numeric(df["response_time_ms"], errors="coerce").fillna(0) - result["distribution"] = { - lbl: int(((rt_full >= lo) & (rt_full < hi)).sum()) for (lo, hi), lbl in zip(rt_bins, rt_labels) - } - - for _, row in df.iterrows(): - u = row.get("url") - ms = pd.to_numeric(row.get("response_time_ms"), errors="coerce") - if pd.isna(u) or pd.isna(ms) or ms <= 2000: - continue - result["slow_pages"].append({"url": str(u).strip(), "response_time_ms": int(ms)}) - result["slow_pages"] = sorted(result["slow_pages"], key=lambda x: x["response_time_ms"], reverse=True)[:50] - return result - - -def _build_depth_distribution(df: pd.DataFrame) -> dict: - """Build crawl depth distribution.""" - result = {"by_depth": {}, "max_depth": 0, "avg_depth": 0} - if "depth" not in df.columns or df.empty: - return result - - depths = pd.to_numeric(df["depth"], errors="coerce").dropna().astype(int) - if depths.empty: - return result - - result["max_depth"] = int(depths.max()) - result["avg_depth"] = round(float(depths.mean()), 1) - counts = depths.value_counts().sort_index() - result["by_depth"] = {str(int(k)): int(v) for k, v in counts.items()} - return result - - -def _parse_page_analysis_cell(raw: object) -> dict[str, Any]: - if raw is None or (isinstance(raw, float) and pd.isna(raw)): - return {} - s = str(raw).strip() - if not s or s == "{}": - return {} - try: - o = json.loads(s) - return o if isinstance(o, dict) else {} - except json.JSONDecodeError: - return {} - - -def _build_outbound_link_domains( - df: pd.DataFrame, - start_url: str, - max_rows: int, -) -> list[dict[str, Any]]: - """Aggregate external hosts linked from crawled pages (outbound), not referring domains.""" - site_host = urlparse((start_url or "").strip()).netloc.lower() - host_pages: dict[str, set[str]] = {} - host_link_count: dict[str, int] = {} - for _, row in df.iterrows(): - st = str(row.get("status", "")).strip() - if st.startswith(("4", "5")): - continue - u = str(row.get("url") or "").strip().rstrip("/") - if not u: - continue - seen_on_page: set[str] = set() - pa = _parse_page_analysis_cell(row.get("page_analysis")) if "page_analysis" in df.columns else {} - for link in pa.get("external_links") or []: - if not isinstance(link, str): - continue - h = urlparse(link).netloc.lower() - if not h or h == site_host: - continue - host_pages.setdefault(h, set()).add(u) - host_link_count[h] = host_link_count.get(h, 0) + 1 - seen_on_page.add(link) - if "outlink_targets" in df.columns: - for link in parse_links_serialized(row.get("outlink_targets")): - h = urlparse(link).netloc.lower() - if not h or h == site_host: - continue - host_pages.setdefault(h, set()).add(u) - if link not in seen_on_page: - host_link_count[h] = host_link_count.get(h, 0) + 1 - seen_on_page.add(link) - rows: list[dict[str, Any]] = [] - for h in host_pages: - rows.append({ - "host": h, - "page_count": len(host_pages[h]), - "link_count": host_link_count.get(h, 0), - }) - rows.sort(key=lambda x: (-x["link_count"], -x["page_count"], x["host"])) - return rows[:max_rows] - - -def _build_url_fingerprints(df: pd.DataFrame) -> list[dict[str, Any]]: - """Stable fingerprints for comparing page content/structure between report runs (no raw HTML stored).""" - out: list[dict[str, Any]] = [] - for _, row in df.iterrows(): - u = str(row.get("url") or "").strip().rstrip("/") - if not u: - continue - title = str(row.get("title") or "") - meta = str(row.get("meta_description") or "") - h1 = str(row.get("h1") or "") - headings = str(row.get("heading_sequence") or "") - wc = int(pd.to_numeric(row.get("word_count"), errors="coerce") or 0) - cl = int(pd.to_numeric(row.get("content_length"), errors="coerce") or 0) - h1c = int(pd.to_numeric(row.get("h1_count"), errors="coerce") or 0) - sc = int(pd.to_numeric(row.get("script_count"), errors="coerce") or 0) - lc = int(pd.to_numeric(row.get("link_stylesheet_count"), errors="coerce") or 0) - # heading_sequence is structural (h1,h2,...) — keep it in structure fingerprint only. - raw_c = "|".join([title, meta, h1, str(wc), str(cl)]).encode("utf-8") - content_fp = hashlib.sha256(raw_c).hexdigest() - raw_s = "|".join([str(cl), str(sc), str(lc), str(h1c), headings]).encode("utf-8") - structure_fp = hashlib.sha256(raw_s).hexdigest() - out.append({ - "url": u, - "content_fingerprint": content_fp, - "structure_fingerprint": structure_fp, - }) - return out - - -def _build_hreflang_summary(df: pd.DataFrame) -> dict[str, Any]: - total = 0 - missing_lang = 0 - with_hreflang = 0 - for _, row in df.iterrows(): - st = str(row.get("status", "")).strip() - if not st.startswith("2"): - continue - total += 1 - pa = _parse_page_analysis_cell(row.get("page_analysis")) if "page_analysis" in df.columns else {} - if not (pa.get("html_lang") or "").strip(): - missing_lang += 1 - if pa.get("hreflang_alternates"): - with_hreflang += 1 - return { - "pages_200": total, - "pages_missing_html_lang": missing_lang, - "pages_with_hreflang_links": with_hreflang, - } - - -def _validate_report_url_counts(report_data: dict[str, Any], df_row_count: int) -> None: - """Ensure crawled URL counts are consistent across report payload fields.""" - links = report_data.get("links") or [] - summary = report_data.get("summary") or {} - scope = (report_data.get("report_meta") or {}).get("crawl_scope") or {} - link_count = len(links) if isinstance(links, list) else 0 - total_urls = int(summary.get("total_urls") or 0) - pages_crawled = int(scope.get("pages_crawled") or 0) - counts = {link_count, total_urls, pages_crawled, df_row_count} - if len(counts) > 1: - msg = ( - f"report count mismatch: links={link_count}, " - f"summary.total_urls={total_urls}, " - f"pages_crawled={pages_crawled}, df_rows={df_row_count}" - ) - print(f" WARNING: {msg}", flush=True) - report_data.setdefault("ml_errors", []).append(msg) - - -def _build_report_metadata( - df: pd.DataFrame, - config: Optional[dict[str, str]], - lighthouse_summary: Optional[dict[str, Any]], - google_data: Optional[dict[str, Any]], - keywords_data: Optional[dict[str, Any]], - ml_bundle: dict[str, Any], - run_id: Optional[int], - crawl_run_created_at: Optional[str], - gsc_links_data: Optional[dict[str, Any]] = None, -) -> dict[str, Any]: - """Provenance and crawl scope for agency-facing audits.""" - sources: list[str] = ["crawl"] - if lighthouse_summary: - sources.append("lighthouse") - if google_data: - if google_data.get("gsc") or google_data.get("gsc_summary"): - sources.append("search_console") - if google_data.get("ga4") or google_data.get("ga4_summary"): - sources.append("analytics") - if gsc_links_data and "search_console" not in sources: - sources.append("search_console") - llm_meta = ml_bundle.get("llm_meta") - if isinstance(llm_meta, dict) and llm_meta.get("model"): - sources.append("ai") - kw_rows = (keywords_data or {}).get("rows") or [] - has_gsc_kw = any( - (r.get("gsc_impressions") or r.get("gsc_clicks")) and r.get("source") in ("gsc", "site+gsc", None) - for r in kw_rows[:500] - if isinstance(r, dict) - ) - if kw_rows and not has_gsc_kw and "estimated" not in sources: - sources.append("estimated") - - max_pages_cfg = get_int(config or {}, "max_pages", 0) or 0 - pages_crawled = len(df) - blocked = 0 - if not df.empty and "status" in df.columns: - blocked = int((df["status"].astype(str) == "blocked_by_robots").sum()) - - render_mode = (str((config or {}).get("crawl_render_mode") or "static")).strip().lower() - js_concurrency = get_int(config or {}, "crawl_js_concurrency", 3) or 3 - static_html_only = render_mode == "static" - - crawl_scope: dict[str, Any] = { - "pages_crawled": pages_crawled, - "max_pages_configured": max_pages_cfg or pages_crawled, - "robots_blocked_count": blocked, - "static_html_only": static_html_only, - "render_mode": render_mode, - "js_concurrency": js_concurrency if not static_html_only else None, - "crawl_limited": bool(max_pages_cfg and pages_crawled >= max_pages_cfg), - } - if not df.empty and "fetch_method" in df.columns: - fm = df["fetch_method"].astype(str).str.strip().str.lower() - pages_static = int((fm == "static").sum()) - pages_rendered = int((fm == "rendered").sum()) - if render_mode == "auto" or pages_rendered > 0: - crawl_scope["pages_static"] = pages_static - crawl_scope["pages_rendered"] = pages_rendered - - from ..crawl.fetchers.browser_diagnostics import aggregate_browser_diagnostics_df - - browser_agg = aggregate_browser_diagnostics_df(df) - if browser_agg and (render_mode != "static" or browser_agg.get("total_console_errors", 0) > 0): - crawl_scope["browser_diagnostics"] = browser_agg - - meta: dict[str, Any] = { - "data_sources": sources, - "generated_at": datetime.now(timezone.utc).isoformat(), - "crawl_scope": crawl_scope, - } - if run_id is not None: - meta["crawl_run_id"] = run_id - if crawl_run_created_at: - meta["crawl_run_created_at"] = crawl_run_created_at - if google_data: - meta["google_fetched_at"] = google_data.get("fetched_at") - meta["google_date_range_days"] = google_data.get("date_range_days") - gsc = google_data.get("gsc") or {} - if isinstance(gsc, dict) and gsc.get("row_count") is not None: - meta["gsc_row_count"] = gsc.get("row_count") - if keywords_data: - meta["keywords_enriched_at"] = keywords_data.get("enriched_at") or keywords_data.get("fetched_at") - if gsc_links_data: - meta["gsc_links_imported_at"] = gsc_links_data.get("imported_at") - meta["gsc_links_referring_domains"] = len(gsc_links_data.get("top_linking_sites") or []) - sample_n = len(gsc_links_data.get("sample_links") or []) - latest_n = len(gsc_links_data.get("latest_links") or []) - meta["gsc_links_sample_count"] = sample_n + latest_n - if isinstance(llm_meta, dict): - meta["llm"] = llm_meta - logo_url = (str((config or {}).get("export_logo_url") or "")).strip() - if logo_url: - meta["export_logo_url"] = logo_url - return meta - - -def _build_keyword_opportunities(df: pd.DataFrame, config: dict[str, str] | None) -> dict[str, Any]: - if not get_bool(config or {}, "include_keyword_opportunities", True): - return {} - if "status" not in df.columns or df.empty: - return {"quick_wins": [], "high_value": [], "token_topic_clusters": []} - success_df = df[df["status"].astype(str).str.match(r"2\d{2}", na=False)] - if success_df.empty: - return {"quick_wins": [], "high_value": [], "token_topic_clusters": []} - candidates = extract_candidates_from_df(success_df) - if not candidates: - return {"quick_wins": [], "high_value": [], "token_topic_clusters": []} - corpus_size = len(success_df) - scored = score_keywords(candidates, corpus_size=corpus_size) - clusters = cluster_keywords(scored) - quick_wins = [s for s in scored if s.get("difficulty", 100) < 60][:10] - high_value = [s for s in scored if (s.get("volume") or 0) >= 0.5][:10] - if not high_value: - high_value = scored[:10] - return { - "quick_wins": quick_wins[:10], - "high_value": high_value[:10], - "token_topic_clusters": filter_topic_clusters(clusters)[:50], - } - - -def _build_image_inventory( - links: list[dict[str, Any]], - config: Optional[dict[str, str]], -) -> tuple[list[dict[str, Any]], dict[str, Any]]: - from ..analysis.image_probe import collect_image_refs_from_links, probe_image_urls - - refs = collect_image_refs_from_links(links) - unoptimized_min_kb = get_int(config or {}, "image_unoptimized_min_kb", 200) or 200 - summary: dict[str, Any] = { - "probed": 0, - "failed": 0, - "total_bytes": 0, - "over_threshold_count": 0, - "unoptimized_min_kb": unoptimized_min_kb, - "inventory_available": False, - } - if not get_bool(config or {}, "probe_image_inventory", False): - return [], summary - - max_urls = get_int(config or {}, "max_image_probe_urls", 500) or 500 - concurrency = get_int(config or {}, "image_probe_concurrency", 6) or 6 - probe_timeout = get_int(config or {}, "image_probe_timeout", 8) or 8 - url_list = list(refs.keys())[:max_urls] - if not url_list: - return [], summary - - print(f" Probing up to {len(url_list)} image URL(s)...", flush=True) - probed = probe_image_urls( - url_list, - concurrency=concurrency, - timeout=probe_timeout, - ) - threshold_bytes = unoptimized_min_kb * 1024 - inventory: list[dict[str, Any]] = [] - for row in probed: - url = row.get("url") - meta = refs.get(str(url or ""), {"source_pages": set(), "kinds": set()}) - size = row.get("size_bytes") - entry = { - "url": url, - "status": row.get("status"), - "content_type": row.get("content_type"), - "size_bytes": size, - "error": row.get("error"), - "source_pages": sorted(meta.get("source_pages") or []), - "kinds": sorted(meta.get("kinds") or []), - } - inventory.append(entry) - summary["probed"] += 1 - if row.get("error") or row.get("status") is None: - summary["failed"] += 1 - if size is not None: - summary["total_bytes"] += int(size) - if int(size) >= threshold_bytes: - summary["over_threshold_count"] += 1 - summary["inventory_available"] = True - print(f" Image probe complete ({summary['probed']} URLs, {summary['failed']} failed).", flush=True) - return inventory, summary - +from .categories import build_categories +from .content_analytics import ( + _build_content_analytics, + _build_depth_distribution, + _build_image_inventory, + _build_keyword_opportunities, + _build_response_time_stats, + _build_social_coverage, + _build_tech_stack_summary, + _build_text_content_analysis, + _parse_top_keywords_items, +) +from .edges_report import build_edges_from_df +from .lighthouse_report import ( + _derive_expected_host, + _pick_lighthouse_summary, + build_lighthouse_by_url_for_report, + fetch_site_ssl_expires_iso, + filter_lighthouse_by_host, + lighthouse_for_url, +) +from .report_metadata import ( + _build_hreflang_summary, + _build_outbound_link_domains, + _build_report_metadata, + _build_url_fingerprints, + _parse_page_analysis_cell, + _validate_report_url_counts, +) +from .seo_summary import ( + META_DESC_LEN_MAX, + META_DESC_LEN_MIN, + THIN_CONTENT_CHARS, + TITLE_LEN_MAX, + TITLE_LEN_MIN, + _compute_summary_seo_issues, +) +from .site_level import _fetch_site_level + +# Backward-compatible re-exports for tests and external imports. +__all__ = [ + "run_simple_report", + "build_edges_from_df", + "build_lighthouse_by_url_for_report", + "fetch_site_ssl_expires_iso", + "filter_lighthouse_by_host", + "lighthouse_for_url", + "_fetch_site_level", + "_compute_summary_seo_issues", + "_build_content_analytics", + "_build_text_content_analysis", + "_build_image_inventory", + "_build_report_metadata", +] def run_simple_report( max_fetch_for_edges: int = 300, @@ -1737,6 +633,79 @@ def _bool_col(col): "images_total": int(pd.to_numeric(row.get("images_total"), errors="coerce") or 0), }) + title_short: list[dict[str, Any]] = [] + title_long: list[dict[str, Any]] = [] + if "title" in df.columns: + titles = df["title"].fillna("").astype(str) + tl = titles.str.len() + for i, row in df.iterrows(): + u = row.get("url") + if pd.isna(u) or not u: + continue + u = str(u).strip() + title_str = titles.iloc[i].strip() + n = int(tl.iloc[i]) + if n == 0: + continue + if n < TITLE_LEN_MIN: + title_short.append({"url": u, "title": title_str, "title_length": n}) + elif n > TITLE_LEN_MAX: + title_long.append({"url": u, "title": title_str, "title_length": n}) + + slow_response: list[dict[str, Any]] = [] + if "response_time_ms" in df.columns: + rt = pd.to_numeric(df["response_time_ms"], errors="coerce") + for i, row in df.iterrows(): + ms = rt.iloc[i] + if pd.isna(ms) or float(ms) <= 2000: + continue + u = row.get("url") + if pd.isna(u) or not u: + continue + slow_response.append({"url": str(u).strip(), "response_time_ms": int(ms)}) + + missing_html_lang: list[dict[str, Any]] = [] + invalid_viewport: list[dict[str, Any]] = [] + if "html_lang" in success_df_urls.columns: + for _, row in success_df_urls.iterrows(): + u = row.get("url") + if pd.isna(u) or not u: + continue + lang = str(row.get("html_lang") or "").strip() + if not lang: + missing_html_lang.append({"url": str(u).strip()}) + if "viewport_present" in success_df_urls.columns: + vp = success_df_urls["viewport_present"] + for _, row in success_df_urls.iterrows(): + u = row.get("url") + if pd.isna(u) or not u: + continue + if not bool(row.get("viewport_present")): + invalid_viewport.append({"url": str(u).strip()}) + + high_reading_level: list[dict[str, Any]] = [] + very_thin_content: list[dict[str, Any]] = [] + if "reading_level" in success_df_urls.columns: + rl = pd.to_numeric(success_df_urls["reading_level"], errors="coerce") + for i, row in success_df_urls.iterrows(): + val = rl.loc[i] + if pd.isna(val) or float(val) <= 12: + continue + u = row.get("url") + if pd.isna(u) or not u: + continue + high_reading_level.append({"url": str(u).strip(), "reading_level": float(val)}) + if "word_count" in success_df_urls.columns: + wc = pd.to_numeric(success_df_urls["word_count"], errors="coerce").fillna(0).astype(int) + for i, row in success_df_urls.iterrows(): + w = int(wc.loc[i]) + if w <= 0 or w >= 100: + continue + u = row.get("url") + if pd.isna(u) or not u: + continue + very_thin_content.append({"url": str(u).strip(), "word_count": w}) + content_urls = { "missing_h1": missing_h1, "missing_title": missing_title, @@ -1750,6 +719,13 @@ def _bool_col(col): "missing_alt": missing_alt, "missing_lazy": missing_lazy, "missing_dimensions": missing_dimensions, + "title_short": title_short, + "title_long": title_long, + "slow_response": slow_response, + "missing_html_lang": missing_html_lang, + "invalid_viewport": invalid_viewport, + "high_reading_level": high_reading_level, + "very_thin_content": very_thin_content, } emit_progress("report", "content_analytics", message="Building content analytics") @@ -1781,6 +757,65 @@ def _bool_col(col): depth_distribution = _build_depth_distribution(df) image_inventory, image_inventory_summary = _build_image_inventory(links, config) + hreflang_issue_urls: list[dict[str, Any]] = [] + try: + from .categories._helpers import _hreflang_issues + + for issue in _hreflang_issues(success_df_urls if len(success_df_urls) else df): + hreflang_issue_urls.append({ + "url": issue.get("url") or "", + "message": issue.get("message") or "", + "priority": issue.get("priority") or "Medium", + }) + except Exception: + hreflang_issue_urls = [] + + lighthouse_failure_urls: dict[str, list[dict[str, Any]]] = { + "lcp": [], "inp": [], "cls": [], "seo": [], + } + if lighthouse_by_url: + audit_map = { + "lcp": "largest-contentful-paint", + "inp": "interaction-to-next-paint", + "cls": "cumulative-layout-shift", + "seo": "seo", + } + for url, lh in lighthouse_by_url.items(): + if not isinstance(lh, dict): + continue + audits = lh.get("audits") if isinstance(lh.get("audits"), dict) else {} + for bucket, audit_id in audit_map.items(): + audit = audits.get(audit_id) if isinstance(audits, dict) else None + if not isinstance(audit, dict): + continue + score = audit.get("score") + if score is not None and float(score) < 0.9: + lighthouse_failure_urls[bucket].append({ + "url": str(url), + "score": score, + "displayValue": audit.get("displayValue"), + }) + + optional_audit_urls: dict[str, list[dict[str, Any]]] = { + "spell": [], "html": [], "amp": [], "pagination": [], + } + for cat in categories: + if not isinstance(cat, dict): + continue + for issue in cat.get("issues") or []: + if not isinstance(issue, dict): + continue + msg = str(issue.get("message") or "").lower() + rec = {"url": issue.get("url") or "", "message": issue.get("message") or ""} + if "spell" in msg: + optional_audit_urls["spell"].append(rec) + elif "html" in msg and "validation" in msg: + optional_audit_urls["html"].append(rec) + elif "amp" in msg: + optional_audit_urls["amp"].append(rec) + elif "pagination" in msg or "rel=prev" in msg or "rel=next" in msg: + optional_audit_urls["pagination"].append(rec) + report_data = { "site_name": site_display, "report_title": report_display_title, @@ -1809,6 +844,9 @@ def _bool_col(col): "top_pages": top_pages, "links": links, "content_urls": content_urls, + "hreflang_issue_urls": hreflang_issue_urls, + "lighthouse_failure_urls": lighthouse_failure_urls, + "optional_audit_urls": optional_audit_urls, "security_findings": security_findings, "content_analytics": content_analytics, "text_content_analysis": text_content_analysis, diff --git a/src/website_profiling/reporting/categories.py b/src/website_profiling/reporting/categories.py deleted file mode 100644 index 15014e7d..00000000 --- a/src/website_profiling/reporting/categories.py +++ /dev/null @@ -1,1053 +0,0 @@ -""" -Report categories for site audits: Technical SEO, Core Web Vitals, Performance, -Accessibility & markup, Links, Mobile SEO, Security, Content quality. -""" -import json -from typing import Any, Optional -from urllib.parse import urlparse - -import pandas as pd - -from .terminology import ( - CATEGORY_ACCESSIBILITY, - CATEGORY_CONTENT_QUALITY, - CATEGORY_CORE_WEB_VITALS, - CATEGORY_LINKS, - CATEGORY_MOBILE, - CATEGORY_PERFORMANCE, - CATEGORY_SECURITY, - CATEGORY_TECHNICAL_SEO, -) - -# Priority order for sorting -PRIORITY_ORDER = {"Critical": 0, "High": 1, "Medium": 2, "Low": 3} - -# Thresholds -RESPONSE_TIME_SLOW_MS = 2000 -THIN_CONTENT_CHARS = 300 -TITLE_LEN_MIN = 30 -TITLE_LEN_MAX = 60 -META_DESC_LEN_MIN = 70 -META_DESC_LEN_MAX = 160 -REDIRECT_CHAIN_LONG = 2 - - -def _issue(message: str, url: Optional[str] = None, priority: str = "Medium", recommendation: str = "") -> dict: - return {"message": message, "url": url or "", "priority": priority, "recommendation": recommendation} - - -def _sort_issues(issues: list[dict]) -> list[dict]: - return sorted(issues, key=lambda x: PRIORITY_ORDER.get(x.get("priority", "Low"), 99)) - - -def _page_analysis_dict(row: pd.Series) -> dict: - """Parse page_analysis JSON cell from a crawl row.""" - import json - - raw = row.get("page_analysis") - if raw is None or (isinstance(raw, float) and pd.isna(raw)): - return {} - s = str(raw).strip() - if not s or s == "{}": - return {} - try: - o = json.loads(s) - return o if isinstance(o, dict) else {} - except json.JSONDecodeError: - return {} - - -def _score_deductions(max_score: int, deductions: list[tuple[int, bool]]) -> int: - """Return max(0, max_score - sum of deduction for each True).""" - total = sum(d for d, apply in deductions if apply) - return max(0, max_score - total) - - -def _hreflang_issues(success_df: pd.DataFrame) -> list[dict]: - """Hreflang cluster consistency (return tags, self-reference).""" - issues: list[dict] = [] - if "page_analysis" not in success_df.columns: - return issues - for _, row in success_df.iterrows(): - pa = _page_analysis_dict(row) - alts = pa.get("hreflang_alternates") or [] - if not alts: - continue - url = str(row.get("url") or "").strip() - langs = [str(a.get("hreflang") or a.get("lang") or "").strip().lower() for a in alts if isinstance(a, dict)] - hrefs = [str(a.get("href") or "").strip() for a in alts if isinstance(a, dict)] - if langs and len(set(langs)) < len(langs): - issues.append(_issue( - "Duplicate hreflang language codes on page.", - url=url, - priority="High", - recommendation="Each hreflang alternate should use a unique language/region code.", - )) - break - if url and hrefs and url.rstrip("/") not in [h.rstrip("/") for h in hrefs]: - issues.append(_issue( - "Hreflang cluster missing self-referencing alternate.", - url=url, - priority="Medium", - recommendation="Include a hreflang link pointing to this page URL.", - )) - break - return issues - - -def _schema_issues(success_df: pd.DataFrame) -> list[dict]: - issues: list[dict] = [] - invalid = 0 - for _, row in success_df.iterrows(): - pa = _page_analysis_dict(row) - schemas = pa.get("json_ld_types") or pa.get("schema_types") or [] - if isinstance(schemas, str): - schemas = [schemas] - url = str(row.get("url") or "").strip() - has_schema = str(row.get("has_schema", "")).lower() in ("true", "1", "yes") - if has_schema and not schemas: - invalid += 1 - if invalid == 1: - issues.append(_issue( - "Structured data present but could not parse JSON-LD @type.", - url=url, - priority="Low", - recommendation="Validate JSON-LD with Google Rich Results Test.", - )) - return issues - - -def _soft_404_issues(success_df: pd.DataFrame) -> list[dict]: - issues: list[dict] = [] - markers = ("not found", "404", "page not found", "doesn't exist", "does not exist") - for _, row in success_df.iterrows(): - title = str(row.get("title") or "").lower() - if any(m in title for m in markers): - url = str(row.get("url") or "").strip() - issues.append(_issue( - "Possible soft 404: page returns 200 but title suggests not found.", - url=url, - priority="High", - recommendation="Return 404 status or redirect to a relevant page.", - )) - if len(issues) >= 10: - break - return issues - - -def _broken_link_sources(edges: list[tuple[str, str]], broken_urls: set[str]) -> list[dict]: - """Issues listing which pages link to broken URLs.""" - issues: list[dict] = [] - if not broken_urls: - return issues - sources: dict[str, list[str]] = {} - for src, tgt in edges: - if tgt in broken_urls: - sources.setdefault(tgt, []).append(src) - for tgt, srcs in list(sources.items())[:15]: - sample = ", ".join(srcs[:3]) - more = f" (+{len(srcs) - 3} more)" if len(srcs) > 3 else "" - issues.append(_issue( - f"Broken URL linked from {len(srcs)} page(s): {sample}{more}", - url=tgt, - priority="High", - recommendation="Fix or remove links pointing to this URL.", - )) - return issues - - -def _indexation_coverage_issues( - df: pd.DataFrame, - indexation: dict | None, -) -> list[dict]: - """Sitemap vs crawl mismatches and noindex URLs listed in sitemap.""" - issues: list[dict] = [] - if not indexation: - return issues - lists = indexation.get("lists") if isinstance(indexation.get("lists"), dict) else {} - sitemap_only = lists.get("sitemap_only") or [] - for url in sitemap_only[:15]: - issues.append(_issue( - f"URL in sitemap but not crawled: {url}", - url=str(url), - priority="High", - recommendation="Verify the URL is linked internally, not blocked by robots, and within crawl scope.", - )) - sitemap_urls = indexation.get("sitemap_urls") or [] - if sitemap_urls and "noindex" in df.columns: - from ..integrations.google.normalize import normalize_url - - sitemap_norm = {normalize_url(u) for u in sitemap_urls} - success = df[df["status"].astype(str).str.match(r"2\d{2}", na=False)] if "status" in df.columns else df - for _, row in success.iterrows(): - url = str(row.get("url") or "").strip() - if not url: - continue - noindex = str(row.get("noindex") or "").lower() in ("true", "1", "yes") - if noindex and normalize_url(url) in sitemap_norm: - issues.append(_issue( - "Page has noindex but is listed in XML sitemap.", - url=url, - priority="Critical", - recommendation="Remove the URL from the sitemap or remove noindex if the page should be indexed.", - )) - break - return issues - - -def merge_indexation_issues(categories: list[dict], df: pd.DataFrame, indexation: dict | None) -> None: - """Append indexation coverage issues to the technical SEO category.""" - extra = _indexation_coverage_issues(df, indexation) - if not extra: - return - for cat in categories: - if cat.get("id") == "technical_seo": - cat["issues"] = _sort_issues((cat.get("issues") or []) + extra) - recs = {i["recommendation"] for i in cat["issues"] if i.get("recommendation")} - cat["recommendations"] = list(recs) - break - - -def merge_subdomain_issues(categories: list[dict], subdomains: dict | None) -> None: - """Append GSC subdomain gap summary to technical SEO.""" - if not subdomains or subdomains.get("disabled"): - return - hosts = subdomains.get("gsc_hosts_not_crawled") or [] - if not hosts: - return - preview = ", ".join(hosts[:5]) - suffix = f" (+{len(hosts) - 5} more)" if len(hosts) > 5 else "" - msg = f"GSC shows URLs on subdomain(s) not reached by crawl: {preview}{suffix}." - issue = _issue( - msg, - priority="Medium", - recommendation="Include these hosts in crawl scope or verify they are intentional separate properties.", - ) - for cat in categories: - if cat.get("id") == "technical_seo": - cat["issues"] = _sort_issues((cat.get("issues") or []) + [issue]) - recs = {i["recommendation"] for i in cat["issues"] if i.get("recommendation")} - cat["recommendations"] = list(recs) - break - - -def _orphan_hub_suggestions(edges: list[tuple[str, str]], orphan_urls: list[str]) -> list[dict]: - issues: list[dict] = [] - if not edges or not orphan_urls: - return issues - in_deg: dict[str, int] = {} - out_from: dict[str, list[str]] = {} - for src, tgt in edges: - in_deg[tgt] = in_deg.get(tgt, 0) + 1 - out_from.setdefault(src, []).append(tgt) - hubs = sorted(in_deg.keys(), key=lambda u: -in_deg.get(u, 0))[:5] - hub_label = hubs[0] if hubs else "" - for orphan in orphan_urls[:10]: - issues.append(_issue( - f"Orphan page (no inlinks). Consider linking from hub page: {hub_label}" if hub_label else "Orphan page (no inlinks).", - url=orphan, - priority="Medium", - recommendation="Add internal links from category or hub pages to this URL.", - )) - return issues - - -def category_technical_seo( - df: pd.DataFrame, - site_level: dict, -) -> dict: - """Technical SEO: robots, sitemap, canonical, duplicate content, noindex, schema.""" - issues = [] - deductions = [] - total = len(df) - success_df = df[df["status"].astype(str).str.match(r"2\d{2}", na=False)] if "status" in df.columns else pd.DataFrame() - - if not site_level.get("robots_present", True): - issues.append(_issue( - "robots.txt is missing or unreachable.", - priority="High", - recommendation="Add a robots.txt at the site root to control crawler access.", - )) - deductions.append((15, True)) - if not site_level.get("sitemap_present", True): - issues.append(_issue( - "sitemap.xml (or sitemap index) is missing or unreachable.", - priority="High", - recommendation="Add a sitemap at /sitemap.xml or link it in robots.txt.", - )) - deductions.append((10, True)) - if site_level.get("sitemap_present") and not site_level.get("sitemap_valid", True): - issues.append(_issue( - "sitemap.xml could not be parsed as valid XML.", - priority="Medium", - recommendation="Ensure sitemap is valid XML and follows sitemaps.org format.", - )) - deductions.append((5, True)) - if site_level.get("ads_txt_present") is False: - issues.append(_issue( - "ads.txt is missing or unreachable.", - priority="Low", - recommendation="Add an ads.txt file at the site root if you run programmatic advertising.", - )) - if site_level.get("security_txt_present") is False: - issues.append(_issue( - "security.txt is missing or unreachable.", - priority="Low", - recommendation="Publish security.txt at /.well-known/security.txt with a Contact field for security reporting.", - )) - - # Canonical: missing or self-mismatch - if "canonical_url" in df.columns and len(success_df) > 0: - for _, row in success_df.iterrows(): - url = row.get("url") - canon = row.get("canonical_url") - if pd.isna(url): - continue - url = str(url).strip() - canon = "" if pd.isna(canon) else str(canon).strip() - if not canon: - issues.append(_issue("Missing canonical URL.", url=url, priority="Medium", recommendation="Add a canonical link tag pointing to the preferred URL.")) - break - missing_canon = success_df["canonical_url"].fillna("").astype(str).str.strip().eq("").sum() - if missing_canon > 0: - deductions.append((min(15, missing_canon * 2), True)) - # Self-canonical mismatch: canonical points to different URL - for _, row in success_df.iterrows(): - url = row.get("url") - canon = row.get("canonical_url") - if pd.isna(url) or pd.isna(canon) or not str(canon).strip(): - continue - url = str(url).rstrip("/") - canon = str(canon).strip().rstrip("/") - if url != canon: - issues.append(_issue(f"Canonical points to different URL: {canon}", url=url, priority="High", recommendation="Set canonical to this page URL or the preferred duplicate.")) - deductions.append((10, True)) - break - - # Noindex on important pages (CSV may store True/False as strings) - if "noindex" in df.columns and len(success_df) > 0: - noindex_ser = success_df["noindex"].astype(str).str.lower().isin(("true", "1", "yes")) - noindex_count = int(noindex_ser.sum()) - if noindex_count > 0: - issues.append(_issue( - f"{int(noindex_count)} page(s) have noindex.", - priority="High" if noindex_count > 5 else "Medium", - recommendation="Remove noindex from pages that should be indexed, or keep for intentional no-index pages.", - )) - deductions.append((min(15, noindex_count * 3), True)) - - # Duplicate content heuristic: same title + meta description - if "title" in df.columns and "meta_description" in df.columns and len(success_df) > 1: - key = success_df["title"].fillna("").astype(str) + "|" + success_df["meta_description"].fillna("").astype(str) - dupes = key.value_counts() - dupes = dupes[dupes > 1] - if len(dupes) > 0: - issues.append(_issue( - f"Possible duplicate content: {len(dupes)} group(s) of pages share same title and meta description.", - priority="Medium", - recommendation="Differentiate titles and meta descriptions, or use canonicals to designate the preferred URL.", - )) - deductions.append((10, True)) - - # Social meta tags - if "og_title" in df.columns and len(success_df) > 0: - og_present = (success_df["og_title"].fillna("").astype(str).str.strip() != "").sum() - og_pct = og_present / len(success_df) if len(success_df) > 0 else 1 - if og_pct < 0.5: - issues.append(_issue( - f"Open Graph tags missing on {int((1 - og_pct) * 100)}% of pages.", - priority="Medium", - recommendation="Add og:title, og:description, and og:image meta tags for social sharing.", - )) - deductions.append((5, True)) - - if "twitter_card" in df.columns and len(success_df) > 0: - tw_present = (success_df["twitter_card"].fillna("").astype(str).str.strip() != "").sum() - tw_pct = tw_present / len(success_df) if len(success_df) > 0 else 1 - if tw_pct < 0.2: - issues.append(_issue( - f"Twitter Card tags missing on {int((1 - tw_pct) * 100)}% of pages.", - priority="Low", - recommendation="Add twitter:card meta tags for better Twitter/X sharing previews.", - )) - deductions.append((3, True)) - - # Structured data - if "has_schema" in df.columns and len(success_df) > 0: - with_schema = int(success_df["has_schema"].astype(str).str.lower().isin(("true", "1", "yes")).sum()) - if with_schema == 0: - issues.append(_issue( - "No structured data (JSON-LD or microdata) detected.", - priority="Low", - recommendation="Add schema.org markup (e.g. Organization, Article) for rich results.", - )) - deductions.append((5, True)) - - # Internationalization: from page_analysis (re-crawl to populate) - if "page_analysis" in df.columns and len(success_df) > 0: - missing_lang = 0 - for _, row in success_df.iterrows(): - pa = _page_analysis_dict(row) - if not (pa.get("html_lang") or "").strip(): - missing_lang += 1 - if missing_lang > 0 and len(success_df) >= 3: - ratio = missing_lang / len(success_df) - if ratio > 0.1: - issues.append(_issue( - f"{missing_lang} page(s) missing (of {len(success_df)} OK responses).", - priority="Medium" if ratio > 0.5 else "Low", - recommendation="Add matching the primary language of each page.", - )) - deductions.append((min(10, max(2, missing_lang // 5)), True)) - - issues.extend(_hreflang_issues(success_df)) - issues.extend(_schema_issues(success_df)) - issues.extend(_soft_404_issues(success_df)) - - if "page_analysis" in df.columns and len(success_df) > 0: - from ..crawl.fetchers.browser_diagnostics import browser_summary_from_page_analysis - - pages_with_console = 0 - for _, row in success_df.iterrows(): - pa = _page_analysis_dict(row) - counts = browser_summary_from_page_analysis(pa) - url = str(row.get("url") or "").strip() - if counts["console_error_count"] > 0: - pages_with_console += 1 - if counts["page_error_count"] > 0 and url: - issues.append(_issue( - "Uncaught JavaScript error during browser render.", - url=url, - priority="High", - recommendation="Fix runtime JS errors that may break page functionality or SEO signals.", - )) - deductions.append((5, True)) - if pages_with_console > 0: - issues.append(_issue( - f"{pages_with_console} page(s) logged console errors during JavaScript rendering.", - priority="High" if pages_with_console > 3 else "Medium", - recommendation="Inspect browser console errors on affected URLs; fix broken scripts or API calls.", - )) - deductions.append((min(15, pages_with_console * 2), True)) - - score = _score_deductions(100, deductions) - return { - "id": "technical_seo", - "name": CATEGORY_TECHNICAL_SEO, - "score": score, - "issues": _sort_issues(issues), - "recommendations": list({i["recommendation"] for i in issues if i["recommendation"]}), - } - - -def category_core_web_vitals() -> dict: - """Core Web Vitals: not measured; recommend Lighthouse.""" - return { - "id": "core_web_vitals", - "name": CATEGORY_CORE_WEB_VITALS, - "score": None, - "issues": [_issue( - "LCP, INP, and CLS are not measured by this crawl.", - priority="Medium", - recommendation="Run Lighthouse (PageSpeed Insights) from Run audit to measure Core Web Vitals.", - )], - "recommendations": ["Run Lighthouse from Run audit to measure LCP, INP, and CLS."], - } - - -def category_core_web_vitals_from_lighthouse( - lighthouse_summary: dict, - crux_summary: Optional[dict] = None, -) -> dict: - """Core Web Vitals from Lighthouse summary: score 0–100 from performance score, issues from top_failures.""" - issues = [] - recommendations = [] - perf_score = None - mm = lighthouse_summary.get("median_metrics") or {} - if isinstance(mm.get("performance_score"), (int, float)): - perf_score = max(0, min(100, int(round(mm["performance_score"] * 100)))) - for f in lighthouse_summary.get("top_failures") or []: - aid = f.get("id") or "" - help_text = (f.get("helpText") or "")[:200] - msg = f"{aid}: {help_text}" if aid else help_text or "Audit failed" - issues.append(_issue( - msg, - priority="High" if (f.get("score") or 0) < 0.5 else "Medium", - recommendation="See Performance (Core Web Vitals) in this audit, or re-run Lighthouse from Run audit.", - )) - if not issues and perf_score is not None and perf_score < 80: - recommendations.append("Improve Core Web Vitals (LCP, CLS, TBT) per Lighthouse recommendations.") - if crux_summary and crux_summary.get("ok"): - pw = crux_summary.get("pass") or {} - for metric, label, rec in ( - ("lcp", "LCP", "Improve largest contentful paint (field data)."), - ("inp", "INP", "Reduce interaction to next paint (field data)."), - ("cls", "CLS", "Reduce cumulative layout shift (field data)."), - ): - if pw.get(metric) is False: - issues.append(_issue( - f"CrUX field data: {label} does not pass Core Web Vitals threshold.", - priority="High", - recommendation=rec, - )) - return { - "id": "core_web_vitals", - "name": CATEGORY_CORE_WEB_VITALS, - "score": perf_score, - "issues": _sort_issues(issues), - "recommendations": recommendations or ["Core Web Vitals measured by Lighthouse; see median_metrics in lighthouse_summary.json."], - } - - -def category_performance(df: pd.DataFrame) -> dict: - """Performance: response time, JS/CSS size, images, lazy loading, caching.""" - issues = [] - deductions = [] - success_df = df[df["status"].astype(str).str.match(r"2\d{2}", na=False)] if "status" in df.columns else pd.DataFrame() - if len(success_df) == 0: - return {"id": "performance", "name": CATEGORY_PERFORMANCE, "score": 0, "issues": [], "recommendations": []} - - if "response_time_ms" in success_df.columns: - rt = pd.to_numeric(success_df["response_time_ms"], errors="coerce").fillna(0) - slow = (rt > RESPONSE_TIME_SLOW_MS).sum() - if slow > 0: - issues.append(_issue( - f"{int(slow)} page(s) have server response time > {RESPONSE_TIME_SLOW_MS // 1000}s.", - priority="High" if slow > 5 else "Medium", - recommendation="Optimize server response time (TTFB): caching, CDN, or backend tuning.", - )) - deductions.append((min(20, int(slow) * 2), True)) - valid_rt = rt[rt > 0] - if len(valid_rt) > 5: - p95 = float(valid_rt.quantile(0.95)) - if p95 > 3000: - issues.append(_issue( - f"95th percentile response time is {int(p95)}ms (over 3s).", - priority="High", - recommendation="Investigate slowest pages; consider CDN, server-side caching, or database optimization.", - )) - deductions.append((10, True)) - - if "images_total" in success_df.columns: - total_imgs = success_df["images_total"].fillna(0).astype(int).sum() - if total_imgs > 0 and "img_without_lazy" in success_df.columns: - no_lazy = success_df["img_without_lazy"].fillna(0).astype(int).sum() - if no_lazy > total_imgs * 0.5: - issues.append(_issue( - "Many images without lazy loading.", - priority="Medium", - recommendation="Add loading='lazy' to off-screen images.", - )) - deductions.append((10, True)) - if total_imgs > 0 and "img_without_dimensions" in success_df.columns: - no_dims = success_df["img_without_dimensions"].fillna(0).astype(int).sum() - if no_dims > 0: - issues.append(_issue( - f"{int(no_dims)} image(s) without width/height (can cause CLS).", - priority="High", - recommendation="Set width and height attributes on img tags to avoid layout shift.", - )) - deductions.append((10, True)) - - if "cache_control" in success_df.columns: - cache = success_df["cache_control"].fillna("").astype(str) - no_cache = (cache.str.strip() == "").sum() - if no_cache > len(success_df) * 0.5: - issues.append(_issue( - "Many pages without Cache-Control header.", - priority="Medium", - recommendation="Set Cache-Control (and optionally ETag) for static and cacheable pages.", - )) - deductions.append((10, True)) - - if "script_count" in success_df.columns: - scripts = success_df["script_count"].fillna(0).astype(int) - if scripts.sum() > len(success_df) * 10: - issues.append(_issue( - "High number of script tags across pages.", - priority="Low", - recommendation="Consider bundling and code-splitting to reduce JS payload.", - )) - deductions.append((5, True)) - - score = _score_deductions(100, deductions) - return { - "id": "performance", - "name": CATEGORY_PERFORMANCE, - "score": score, - "issues": _sort_issues(issues), - "recommendations": list({i["recommendation"] for i in issues if i["recommendation"]}), - } - - -def _parse_page_analysis_cell(raw: object) -> dict[str, Any]: - if isinstance(raw, dict): - return raw - if not raw or not isinstance(raw, str): - return {} - try: - parsed = json.loads(raw) - return parsed if isinstance(parsed, dict) else {} - except Exception: - return {} - - -def contrast_issues_from_sources( - df: pd.DataFrame, - lighthouse_by_url: Optional[dict[str, Any]] = None, -) -> list[dict]: - """Contrast issues from axe crawl data and per-URL Lighthouse failures.""" - issues: list[dict] = [] - seen_urls: set[str] = set() - - if df is not None and not df.empty and "page_analysis" in df.columns: - for _, row in df.iterrows(): - url = str(row.get("url") or "").strip() - if not url: - continue - pa = _parse_page_analysis_cell(row.get("page_analysis")) - axe = pa.get("axe_violations") - if not isinstance(axe, list): - continue - contrast_hits = [ - v for v in axe - if isinstance(v, dict) and "color-contrast" in str(v.get("id") or "") - ] - if not contrast_hits: - continue - seen_urls.add(url.rstrip("/")) - first = contrast_hits[0] - msg = str(first.get("description") or first.get("help") or "Color contrast violation") - issues.append(_issue( - f"axe: {msg}", - url=url, - priority="Medium", - recommendation=str( - first.get("help") - or "Fix text/background contrast to meet WCAG AA (axe-core)." - ), - )) - - lh_map = lighthouse_by_url or {} - for url, summary in lh_map.items(): - if not isinstance(summary, dict): - continue - u = str(url or summary.get("url") or "").strip().rstrip("/") - if not u or u in seen_urls: - continue - for fail in summary.get("top_failures") or []: - if not isinstance(fail, dict): - continue - if str(fail.get("id") or "") != "color-contrast": - continue - seen_urls.add(u) - help_text = str(fail.get("helpText") or "Low color contrast") - issues.append(_issue( - f"Lighthouse: {help_text}", - url=u, - priority="Medium", - recommendation="Increase contrast ratio between text and background to meet WCAG AA.", - )) - break - - return issues[:40] - - -def category_html_accessibility( - df: pd.DataFrame, - lighthouse_by_url: Optional[dict[str, Any]] = None, -) -> dict: - """HTML and Accessibility: semantic HTML, heading structure, alt, ARIA, contrast.""" - issues = [] - deductions = [] - success_df = df[df["status"].astype(str).str.match(r"2\d{2}", na=False)] if "status" in df.columns else pd.DataFrame() - if len(success_df) == 0: - return {"id": "html_accessibility", "name": CATEGORY_ACCESSIBILITY, "score": 0, "issues": [], "recommendations": []} - - if "h1_count" in df.columns: - h1c = pd.to_numeric(success_df["h1_count"], errors="coerce").fillna(-1).astype(int) - zero_h1 = (h1c == 0).sum() - multi_h1 = (h1c > 1).sum() - if zero_h1 > 0: - issues.append(_issue( - f"{int(zero_h1)} page(s) missing H1.", - priority="High", - recommendation="Add exactly one H1 per page describing the main content.", - )) - deductions.append((min(20, int(zero_h1) * 3), True)) - if multi_h1 > 0: - issues.append(_issue( - f"{int(multi_h1)} page(s) have multiple H1s.", - priority="Medium", - recommendation="Use a single H1 per page; use H2–H6 for subsections.", - )) - deductions.append((min(10, int(multi_h1) * 2), True)) - - if "heading_sequence" in df.columns: - pages_with_skipped_heading = 0 - for _, row in success_df.iterrows(): - seq = row.get("heading_sequence") - if pd.isna(seq) or not str(seq).strip(): - continue - parts = [p.strip() for p in str(seq).split(",") if p.strip()] - if not parts: - continue - levels = [int(h[1]) for h in parts if len(h) == 2 and h[0] == "h" and h[1] in "123456"] - for i in range(1, len(levels)): - if levels[i] > levels[i - 1] + 1: - if pages_with_skipped_heading == 0: - issues.append(_issue( - "Skipped heading level (e.g. H1 then H3).", - url=str(row.get("url", "")), - priority="Medium", - recommendation="Use heading levels in order (H1, H2, H3) without skipping.", - )) - pages_with_skipped_heading += 1 - break - if pages_with_skipped_heading > 0: - deductions.append((min(15, pages_with_skipped_heading * 5), True)) - - if "images_total" in df.columns and "images_without_alt" in df.columns: - total = success_df["images_total"].fillna(0).astype(int).sum() - missing_alt = success_df["images_without_alt"].fillna(0).astype(int).sum() - if total > 0 and missing_alt > 0: - issues.append(_issue( - f"{int(missing_alt)} image(s) without alt (or aria-label).", - priority="High", - recommendation="Add meaningful alt text to all images; use alt='' for decorative images.", - )) - deductions.append((min(15, int(missing_alt) * 2), True)) - - if "word_count" in success_df.columns: - wc = pd.to_numeric(success_df["word_count"], errors="coerce").fillna(0).astype(int) - very_thin = int(((wc > 0) & (wc < 100)).sum()) - if very_thin > 0: - issues.append(_issue( - f"{very_thin} page(s) with very thin content (under 100 words).", - priority="High", - recommendation="Expand thin pages with meaningful content (aim for 300+ words).", - )) - deductions.append((min(15, very_thin * 3), True)) - - if "reading_level" in success_df.columns: - rl = pd.to_numeric(success_df["reading_level"], errors="coerce").fillna(0) - complex_pages = int((rl > 14).sum()) - if complex_pages > 0: - issues.append(_issue( - f"{complex_pages} page(s) have very complex content (reading level > 14).", - priority="Medium", - recommendation="Simplify language for broader audience accessibility (aim for grade 8-10).", - )) - deductions.append((min(10, complex_pages * 2), True)) - - contrast_issues = contrast_issues_from_sources(df, lighthouse_by_url) - if contrast_issues: - issues.extend(contrast_issues) - deductions.append((min(25, len(contrast_issues) * 4), True)) - else: - issues.append(_issue( - "Color contrast is not measured by this tool.", - priority="Low", - recommendation="Enable axe (browser crawl) or Lighthouse to check contrast.", - )) - - score = _score_deductions(100, deductions) - if len(success_df) > 0 and score == 0: - score = 5 - score = min(100, max(0, score)) - return { - "id": "html_accessibility", - "name": CATEGORY_ACCESSIBILITY, - "score": score, - "issues": _sort_issues(issues), - "recommendations": list({i["recommendation"] for i in issues if i["recommendation"]}), - } - - -def category_link_health( - df: pd.DataFrame, - edges: list[tuple[str, str]], - issues_broken: list[dict], - issues_redirects: list[dict], -) -> dict: - """Link Health: broken links, redirect chains, internal linking.""" - issues = [] - deductions = [] - - for b in issues_broken[:30]: - status = str(b.get("status", "")) - priority = "Critical" if status.startswith("5") else "High" - issues.append(_issue( - f"Broken URL: {status}", - url=b.get("url", ""), - priority=priority, - recommendation="Fix or remove the link; return 200 or redirect to a valid URL.", - )) - broken_url_set = {str(b.get("url") or "").strip() for b in issues_broken if b.get("url")} - issues.extend(_broken_link_sources(edges, broken_url_set)) - if issues_broken: - deductions.append((min(30, len(issues_broken) * 2), True)) - - for r in issues_redirects[:20]: - issues.append(_issue( - f"Redirect: {r.get('status', '')} to {r.get('final_url', '')}", - url=r.get("url", ""), - priority="Medium", - recommendation="Prefer direct URLs or shorten redirect chains.", - )) - if issues_redirects: - deductions.append((min(15, len(issues_redirects)), True)) - - if "redirect_chain_length" in df.columns and len(df) > 0: - rcl = pd.to_numeric(df["redirect_chain_length"], errors="coerce").fillna(0).astype(int) - long_chains = (rcl >= REDIRECT_CHAIN_LONG).sum() - if long_chains > 0: - issues.append(_issue( - f"{int(long_chains)} URL(s) have redirect chains (2+ hops).", - priority="Medium", - recommendation="Consolidate redirects to a single hop where possible.", - )) - deductions.append((min(10, int(long_chains)), True)) - - if edges: - import networkx as nx - G = nx.DiGraph() - G.add_edges_from(edges) - in_deg = dict(G.in_degree()) - orphans = [n for n in G.nodes() if in_deg.get(n, 0) == 0] - if len(orphans) > len(G.nodes()) * 0.3: - issues.append(_issue( - f"Many pages have no internal links pointing to them ({len(orphans)}).", - priority="Low", - recommendation="Add internal links to important pages to improve crawlability and internal link equity.", - )) - deductions.append((5, True)) - issues.extend(_orphan_hub_suggestions(edges, orphans[:15])) - - score = _score_deductions(100, deductions) - return { - "id": "link_health", - "name": CATEGORY_LINKS, - "score": score, - "issues": _sort_issues(issues), - "recommendations": list({i["recommendation"] for i in issues if i["recommendation"]}), - } - - -def category_mobile(df: pd.DataFrame) -> dict: - """Mobile: viewport, responsive heuristic.""" - issues = [] - deductions = [] - success_df = df[df["status"].astype(str).str.match(r"2\d{2}", na=False)] if "status" in df.columns else pd.DataFrame() - if len(success_df) == 0: - return {"id": "mobile", "name": CATEGORY_MOBILE, "score": 0, "issues": [], "recommendations": []} - - if "viewport_present" in df.columns: - viewport_ok = success_df["viewport_present"].astype(str).str.lower().isin(("true", "1", "yes")) - no_viewport = int((~viewport_ok).sum()) - if no_viewport > 0: - issues.append(_issue( - f"{int(no_viewport)} page(s) missing viewport meta tag.", - priority="Critical", - recommendation="Add .", - )) - deductions.append((min(25, int(no_viewport) * 5), True)) - viewport_content = success_df["viewport_content"].fillna("").astype(str) - viewport_ok = success_df["viewport_present"].astype(str).str.lower().isin(("true", "1", "yes")) - invalid = (viewport_content.str.strip().eq("") | (~viewport_content.str.contains("width|device-width", case=False, na=False))) & viewport_ok - if invalid.sum() > 0: - issues.append(_issue( - "Some pages have viewport without width or device-width.", - priority="High", - recommendation="Use content='width=device-width, initial-scale=1' (or similar).", - )) - deductions.append((10, True)) - - score = _score_deductions(100, deductions) - return { - "id": "mobile", - "name": CATEGORY_MOBILE, - "score": score, - "issues": _sort_issues(issues), - "recommendations": list({i["recommendation"] for i in issues if i["recommendation"]}), - } - - -def category_security( - df: pd.DataFrame, - site_level: dict, - start_url: str, - security_findings: Optional[list[dict]] = None, -) -> dict: - """Security: HTTPS, security headers, mixed content, and optional vulnerability scan findings.""" - issues = [] - deductions = [] - parsed = urlparse(start_url) - if parsed.scheme and parsed.scheme.lower() != "https": - issues.append(_issue( - "Site is not using HTTPS.", - url=start_url, - priority="Critical", - recommendation="Serve the site over HTTPS and redirect HTTP to HTTPS.", - )) - deductions.append((30, True)) - - if "final_url" in df.columns and len(df) > 0: - final_urls = df["final_url"].fillna("").astype(str) - http_finals = final_urls.str.strip().str.lower().str.startswith("http://") - if http_finals.sum() > 0: - issues.append(_issue( - f"{int(http_finals.sum())} URL(s) resolve to HTTP.", - priority="Critical", - recommendation="Ensure all pages redirect to HTTPS.", - )) - deductions.append((20, True)) - - success_df = df[df["status"].astype(str).str.match(r"2\d{2}", na=False)] if "status" in df.columns else pd.DataFrame() - if len(success_df) > 0: - # Security headers: sample from first row or aggregate (optional columns) - missing_hsts = (success_df["strict_transport_security"].fillna("").astype(str).str.strip() == "").sum() if "strict_transport_security" in success_df.columns else len(success_df) - missing_xcto = (success_df["x_content_type_options"].fillna("").astype(str).str.strip() == "").sum() if "x_content_type_options" in success_df.columns else len(success_df) - missing_xfo = (success_df["x_frame_options"].fillna("").astype(str).str.strip() == "").sum() if "x_frame_options" in success_df.columns else len(success_df) - if missing_hsts >= len(success_df) * 0.5: - issues.append(_issue( - "Strict-Transport-Security header not set.", - priority="High", - recommendation="Add Strict-Transport-Security to enforce HTTPS.", - )) - deductions.append((15, True)) - if missing_xcto >= len(success_df) * 0.5: - issues.append(_issue( - "X-Content-Type-Options header not set.", - priority="Medium", - recommendation="Add X-Content-Type-Options: nosniff.", - )) - deductions.append((5, True)) - if missing_xfo >= len(success_df) * 0.5: - issues.append(_issue( - "X-Frame-Options header not set.", - priority="Medium", - recommendation="Add X-Frame-Options: DENY or SAMEORIGIN.", - )) - deductions.append((5, True)) - - if "mixed_content_count" in success_df.columns: - mixed = success_df["mixed_content_count"].fillna(0).astype(int).sum() - scheme = (parsed.scheme or "").lower() - if mixed > 0 and scheme == "https": - issues.append(_issue( - f"Mixed content: {int(mixed)} HTTP resource(s) on HTTPS pages.", - priority="High", - recommendation="Load all resources over HTTPS to avoid mixed content.", - )) - deductions.append((15, True)) - - # Merge vulnerability scan findings (same format as issues: message, url, priority, recommendation) - if security_findings: - for f in security_findings: - severity = f.get("severity", "Medium") - issues.append(_issue( - f.get("message", ""), - url=f.get("url", ""), - priority=severity, - recommendation=f.get("recommendation", ""), - )) - # Deduct by severity: Critical 15, High 10, Medium 5, Low 2 - ded = {"Critical": 15, "High": 10, "Medium": 5, "Low": 2}.get(severity, 2) - deductions.append((min(ded, 15), True)) - - score = _score_deductions(100, deductions) - return { - "id": "security", - "name": CATEGORY_SECURITY, - "score": score, - "issues": _sort_issues(issues), - "recommendations": list({i["recommendation"] for i in issues if i["recommendation"]}), - } - - -def category_intelligence(ml_bundle: Optional[dict] = None) -> dict: - """Content quality: duplicate clusters and language mix from crawl analysis and optional AI insights.""" - issues: list[dict] = [] - deductions: list[tuple[int, bool]] = [] - ml_bundle = ml_bundle or {} - - dups = ml_bundle.get("content_duplicates") or [] - if dups: - big = [g for g in dups if (g.get("member_count") or len(g.get("member_urls") or [])) >= 3] - if big: - issues.append(_issue( - f"Near-duplicate content: {len(big)} group(s) with 3+ URLs.", - priority="High", - recommendation="Consolidate or canonicalize duplicate pages; differentiate thin similar URLs.", - )) - deductions.append((min(20, 5 + len(big)), True)) - elif dups: - issues.append(_issue( - f"Possible duplicate content: {len(dups)} pair/group(s) detected.", - priority="Medium", - recommendation="Review clusters and add canonicals or noindex where appropriate.", - )) - deductions.append((8, True)) - - lang = ml_bundle.get("language_summary") or {} - if lang.get("mixed_site") and (lang.get("detected_pages") or 0) >= 10: - counts = lang.get("counts") or {} - top = sorted(counts.items(), key=lambda x: -x[1])[:3] - desc = ", ".join(f"{k}:{v}" for k, v in top) if top else "multiple" - issues.append(_issue( - f"Mixed languages detected across pages ({desc}).", - priority="Medium", - recommendation="Ensure hreflang and localized URLs match user intent; split sitemaps if needed.", - )) - deductions.append((5, True)) - - score = _score_deductions(100, deductions) - return { - "id": "intelligence", - "name": CATEGORY_CONTENT_QUALITY, - "score": score, - "issues": _sort_issues(issues), - "recommendations": list({i["recommendation"] for i in issues if i["recommendation"]}), - } - - -def build_categories( - df: pd.DataFrame, - edges: list[tuple[str, str]], - summary_seo: dict, - site_level: dict, - start_url: str, - security_findings: Optional[list[dict]] = None, - lighthouse_summary: Optional[dict] = None, - ml_bundle: Optional[dict] = None, - crux_summary: Optional[dict] = None, - lighthouse_by_url: Optional[dict[str, Any]] = None, -) -> list[dict]: - """ - Build all category dicts with score, issues (with priority and recommendation), and recommendations. - site_level should have: robots_present, sitemap_present, sitemap_valid (all optional). - summary_seo should have: issues["broken"], issues["redirects"]. - security_findings: optional list from security scanner (finding_type, severity, url, message, recommendation). - lighthouse_summary: optional dict from lighthouse_runner (median_metrics, top_failures); when set, Core Web Vitals uses real data. - ml_bundle: optional dict from analysis + AI insights (duplicates, language_summary, etc.) for Content quality category. - """ - issues_broken = summary_seo.get("issues", {}).get("broken", []) - issues_redirects = summary_seo.get("issues", {}).get("redirects", []) - - cwv = ( - category_core_web_vitals_from_lighthouse(lighthouse_summary, crux_summary) - if lighthouse_summary - else category_core_web_vitals() - ) - categories = [ - category_technical_seo(df, site_level), - cwv, - category_performance(df), - category_html_accessibility(df, lighthouse_by_url=lighthouse_by_url), - category_link_health(df, edges, issues_broken, issues_redirects), - category_mobile(df), - category_security(df, site_level, start_url or "", security_findings=security_findings), - category_intelligence(ml_bundle), - ] - return categories diff --git a/src/website_profiling/reporting/categories/__init__.py b/src/website_profiling/reporting/categories/__init__.py new file mode 100644 index 00000000..e9921746 --- /dev/null +++ b/src/website_profiling/reporting/categories/__init__.py @@ -0,0 +1,113 @@ +"""Report categories for site audits.""" +from __future__ import annotations + +from typing import Any, Optional + +import pandas as pd + +from .accessibility import category_html_accessibility, contrast_issues_from_sources +from .intelligence import category_intelligence +from .link_health import category_link_health +from .mobile import category_mobile +from .performance import ( + category_core_web_vitals, + category_core_web_vitals_from_lighthouse, + category_performance, +) +from .security import category_security +from .technical_seo import category_technical_seo +from ._helpers import ( + META_DESC_LEN_MAX, + META_DESC_LEN_MIN, + PRIORITY_ORDER, + REDIRECT_CHAIN_LONG, + RESPONSE_TIME_SLOW_MS, + THIN_CONTENT_CHARS, + TITLE_LEN_MAX, + TITLE_LEN_MIN, + _broken_link_sources, + _hreflang_issues, + _indexation_coverage_issues, + _issue, + _orphan_hub_suggestions, + _page_analysis_dict, + _schema_issues, + _score_deductions, + _soft_404_issues, + _sort_issues, + merge_indexation_issues, + merge_subdomain_issues, +) + +__all__ = [ + "build_categories", + "merge_indexation_issues", + "merge_subdomain_issues", + "category_technical_seo", + "category_core_web_vitals", + "category_core_web_vitals_from_lighthouse", + "category_performance", + "category_html_accessibility", + "contrast_issues_from_sources", + "category_link_health", + "category_mobile", + "category_security", + "category_intelligence", + "_issue", + "_sort_issues", + "_page_analysis_dict", + "_broken_link_sources", + "_hreflang_issues", + "_schema_issues", + "_soft_404_issues", + "_indexation_coverage_issues", + "_orphan_hub_suggestions", + "REDIRECT_CHAIN_LONG", + "PRIORITY_ORDER", + "RESPONSE_TIME_SLOW_MS", + "THIN_CONTENT_CHARS", + "TITLE_LEN_MIN", + "TITLE_LEN_MAX", + "META_DESC_LEN_MIN", + "META_DESC_LEN_MAX", +] + +def build_categories( + df: pd.DataFrame, + edges: list[tuple[str, str]], + summary_seo: dict, + site_level: dict, + start_url: str, + security_findings: Optional[list[dict]] = None, + lighthouse_summary: Optional[dict] = None, + ml_bundle: Optional[dict] = None, + crux_summary: Optional[dict] = None, + lighthouse_by_url: Optional[dict[str, Any]] = None, +) -> list[dict]: + """ + Build all category dicts with score, issues (with priority and recommendation), and recommendations. + site_level should have: robots_present, sitemap_present, sitemap_valid (all optional). + summary_seo should have: issues["broken"], issues["redirects"]. + security_findings: optional list from security scanner (finding_type, severity, url, message, recommendation). + lighthouse_summary: optional dict from lighthouse_runner (median_metrics, top_failures); when set, Core Web Vitals uses real data. + ml_bundle: optional dict from analysis + AI insights (duplicates, language_summary, etc.) for Content quality category. + """ + issues_broken = summary_seo.get("issues", {}).get("broken", []) + issues_redirects = summary_seo.get("issues", {}).get("redirects", []) + + cwv = ( + category_core_web_vitals_from_lighthouse(lighthouse_summary, crux_summary) + if lighthouse_summary + else category_core_web_vitals() + ) + categories = [ + category_technical_seo(df, site_level), + cwv, + category_performance(df), + category_html_accessibility(df, lighthouse_by_url=lighthouse_by_url), + category_link_health(df, edges, issues_broken, issues_redirects), + category_mobile(df), + category_security(df, site_level, start_url or "", security_findings=security_findings), + category_intelligence(ml_bundle), + ] + return categories diff --git a/src/website_profiling/reporting/categories/_helpers.py b/src/website_profiling/reporting/categories/_helpers.py new file mode 100644 index 00000000..8b5d9c87 --- /dev/null +++ b/src/website_profiling/reporting/categories/_helpers.py @@ -0,0 +1,252 @@ +"""Shared helpers for report category builders.""" +from __future__ import annotations + +import json +from typing import Any, Optional + +import pandas as pd + +# Priority order for sorting +PRIORITY_ORDER = {"Critical": 0, "High": 1, "Medium": 2, "Low": 3} + +# Thresholds +RESPONSE_TIME_SLOW_MS = 2000 +THIN_CONTENT_CHARS = 300 +TITLE_LEN_MIN = 30 +TITLE_LEN_MAX = 60 +META_DESC_LEN_MIN = 70 +META_DESC_LEN_MAX = 160 +REDIRECT_CHAIN_LONG = 2 + + +def _issue(message: str, url: Optional[str] = None, priority: str = "Medium", recommendation: str = "") -> dict: + return {"message": message, "url": url or "", "priority": priority, "recommendation": recommendation} + + +def _sort_issues(issues: list[dict]) -> list[dict]: + return sorted(issues, key=lambda x: PRIORITY_ORDER.get(x.get("priority", "Low"), 99)) + + +def _page_analysis_dict(row: pd.Series) -> dict: + """Parse page_analysis JSON cell from a crawl row.""" + import json + + raw = row.get("page_analysis") + if raw is None or (isinstance(raw, float) and pd.isna(raw)): + return {} + s = str(raw).strip() + if not s or s == "{}": + return {} + try: + o = json.loads(s) + return o if isinstance(o, dict) else {} + except json.JSONDecodeError: + return {} + + +def _score_deductions(max_score: int, deductions: list[tuple[int, bool]]) -> int: + """Return max(0, max_score - sum of deduction for each True).""" + total = sum(d for d, apply in deductions if apply) + return max(0, max_score - total) + + +def _hreflang_issues(success_df: pd.DataFrame) -> list[dict]: + """Hreflang cluster consistency (return tags, self-reference).""" + issues: list[dict] = [] + if "page_analysis" not in success_df.columns: + return issues + for _, row in success_df.iterrows(): + pa = _page_analysis_dict(row) + alts = pa.get("hreflang_alternates") or [] + if not alts: + continue + url = str(row.get("url") or "").strip() + langs = [str(a.get("hreflang") or a.get("lang") or "").strip().lower() for a in alts if isinstance(a, dict)] + hrefs = [str(a.get("href") or "").strip() for a in alts if isinstance(a, dict)] + if langs and len(set(langs)) < len(langs): + issues.append(_issue( + "Duplicate hreflang language codes on page.", + url=url, + priority="High", + recommendation="Each hreflang alternate should use a unique language/region code.", + )) + break + if url and hrefs and url.rstrip("/") not in [h.rstrip("/") for h in hrefs]: + issues.append(_issue( + "Hreflang cluster missing self-referencing alternate.", + url=url, + priority="Medium", + recommendation="Include a hreflang link pointing to this page URL.", + )) + break + return issues + + +def _schema_issues(success_df: pd.DataFrame) -> list[dict]: + issues: list[dict] = [] + invalid = 0 + for _, row in success_df.iterrows(): + pa = _page_analysis_dict(row) + schemas = pa.get("json_ld_types") or pa.get("schema_types") or [] + if isinstance(schemas, str): + schemas = [schemas] + url = str(row.get("url") or "").strip() + has_schema = str(row.get("has_schema", "")).lower() in ("true", "1", "yes") + if has_schema and not schemas: + invalid += 1 + if invalid == 1: + issues.append(_issue( + "Structured data present but could not parse JSON-LD @type.", + url=url, + priority="Low", + recommendation="Validate JSON-LD with Google Rich Results Test.", + )) + return issues + + +def _soft_404_issues(success_df: pd.DataFrame) -> list[dict]: + issues: list[dict] = [] + markers = ("not found", "404", "page not found", "doesn't exist", "does not exist") + for _, row in success_df.iterrows(): + title = str(row.get("title") or "").lower() + if any(m in title for m in markers): + url = str(row.get("url") or "").strip() + issues.append(_issue( + "Possible soft 404: page returns 200 but title suggests not found.", + url=url, + priority="High", + recommendation="Return 404 status or redirect to a relevant page.", + )) + if len(issues) >= 10: + break + return issues + + +def _broken_link_sources(edges: list[tuple[str, str]], broken_urls: set[str]) -> list[dict]: + """Issues listing which pages link to broken URLs.""" + issues: list[dict] = [] + if not broken_urls: + return issues + sources: dict[str, list[str]] = {} + for src, tgt in edges: + if tgt in broken_urls: + sources.setdefault(tgt, []).append(src) + for tgt, srcs in list(sources.items())[:15]: + sample = ", ".join(srcs[:3]) + more = f" (+{len(srcs) - 3} more)" if len(srcs) > 3 else "" + issues.append(_issue( + f"Broken URL linked from {len(srcs)} page(s): {sample}{more}", + url=tgt, + priority="High", + recommendation="Fix or remove links pointing to this URL.", + )) + return issues + + +def _indexation_coverage_issues( + df: pd.DataFrame, + indexation: dict | None, +) -> list[dict]: + """Sitemap vs crawl mismatches and noindex URLs listed in sitemap.""" + issues: list[dict] = [] + if not indexation: + return issues + lists = indexation.get("lists") if isinstance(indexation.get("lists"), dict) else {} + sitemap_only = lists.get("sitemap_only") or [] + for url in sitemap_only[:15]: + issues.append(_issue( + f"URL in sitemap but not crawled: {url}", + url=str(url), + priority="High", + recommendation="Verify the URL is linked internally, not blocked by robots, and within crawl scope.", + )) + sitemap_urls = indexation.get("sitemap_urls") or [] + if sitemap_urls and "noindex" in df.columns: + from ...integrations.google.normalize import normalize_url + + sitemap_norm = {normalize_url(u) for u in sitemap_urls} + success = df[df["status"].astype(str).str.match(r"2\d{2}", na=False)] if "status" in df.columns else df + for _, row in success.iterrows(): + url = str(row.get("url") or "").strip() + if not url: + continue + noindex = str(row.get("noindex") or "").lower() in ("true", "1", "yes") + if noindex and normalize_url(url) in sitemap_norm: + issues.append(_issue( + "Page has noindex but is listed in XML sitemap.", + url=url, + priority="Critical", + recommendation="Remove the URL from the sitemap or remove noindex if the page should be indexed.", + )) + break + return issues + + +def merge_indexation_issues(categories: list[dict], df: pd.DataFrame, indexation: dict | None) -> None: + """Append indexation coverage issues to the technical SEO category.""" + extra = _indexation_coverage_issues(df, indexation) + if not extra: + return + for cat in categories: + if cat.get("id") == "technical_seo": + cat["issues"] = _sort_issues((cat.get("issues") or []) + extra) + recs = {i["recommendation"] for i in cat["issues"] if i.get("recommendation")} + cat["recommendations"] = list(recs) + break + + +def merge_subdomain_issues(categories: list[dict], subdomains: dict | None) -> None: + """Append GSC subdomain gap summary to technical SEO.""" + if not subdomains or subdomains.get("disabled"): + return + hosts = subdomains.get("gsc_hosts_not_crawled") or [] + if not hosts: + return + preview = ", ".join(hosts[:5]) + suffix = f" (+{len(hosts) - 5} more)" if len(hosts) > 5 else "" + msg = f"GSC shows URLs on subdomain(s) not reached by crawl: {preview}{suffix}." + issue = _issue( + msg, + priority="Medium", + recommendation="Include these hosts in crawl scope or verify they are intentional separate properties.", + ) + for cat in categories: + if cat.get("id") == "technical_seo": + cat["issues"] = _sort_issues((cat.get("issues") or []) + [issue]) + recs = {i["recommendation"] for i in cat["issues"] if i.get("recommendation")} + cat["recommendations"] = list(recs) + break + + +def _orphan_hub_suggestions(edges: list[tuple[str, str]], orphan_urls: list[str]) -> list[dict]: + issues: list[dict] = [] + if not edges or not orphan_urls: + return issues + in_deg: dict[str, int] = {} + out_from: dict[str, list[str]] = {} + for src, tgt in edges: + in_deg[tgt] = in_deg.get(tgt, 0) + 1 + out_from.setdefault(src, []).append(tgt) + hubs = sorted(in_deg.keys(), key=lambda u: -in_deg.get(u, 0))[:5] + hub_label = hubs[0] if hubs else "" + for orphan in orphan_urls[:10]: + issues.append(_issue( + f"Orphan page (no inlinks). Consider linking from hub page: {hub_label}" if hub_label else "Orphan page (no inlinks).", + url=orphan, + priority="Medium", + recommendation="Add internal links from category or hub pages to this URL.", + )) + return issues + + +def _parse_page_analysis_cell(raw: object) -> dict[str, Any]: + if isinstance(raw, dict): + return raw + if not raw or not isinstance(raw, str): + return {} + try: + parsed = json.loads(raw) + return parsed if isinstance(parsed, dict) else {} + except Exception: + return {} + diff --git a/src/website_profiling/reporting/categories/accessibility.py b/src/website_profiling/reporting/categories/accessibility.py new file mode 100644 index 00000000..695c3022 --- /dev/null +++ b/src/website_profiling/reporting/categories/accessibility.py @@ -0,0 +1,188 @@ +"""Report category: accessibility.""" +from __future__ import annotations + +import json +from typing import Any, Optional + +import pandas as pd + +from ..terminology import CATEGORY_ACCESSIBILITY +from ._helpers import ( + _issue, + _page_analysis_dict, + _parse_page_analysis_cell, + _score_deductions, + _sort_issues, +) + +def contrast_issues_from_sources( + df: pd.DataFrame, + lighthouse_by_url: Optional[dict[str, Any]] = None, +) -> list[dict]: + """Contrast issues from axe crawl data and per-URL Lighthouse failures.""" + issues: list[dict] = [] + seen_urls: set[str] = set() + + if df is not None and not df.empty and "page_analysis" in df.columns: + for _, row in df.iterrows(): + url = str(row.get("url") or "").strip() + if not url: + continue + pa = _parse_page_analysis_cell(row.get("page_analysis")) + axe = pa.get("axe_violations") + if not isinstance(axe, list): + continue + contrast_hits = [ + v for v in axe + if isinstance(v, dict) and "color-contrast" in str(v.get("id") or "") + ] + if not contrast_hits: + continue + seen_urls.add(url.rstrip("/")) + first = contrast_hits[0] + msg = str(first.get("description") or first.get("help") or "Color contrast violation") + issues.append(_issue( + f"axe: {msg}", + url=url, + priority="Medium", + recommendation=str( + first.get("help") + or "Fix text/background contrast to meet WCAG AA (axe-core)." + ), + )) + + lh_map = lighthouse_by_url or {} + for url, summary in lh_map.items(): + if not isinstance(summary, dict): + continue + u = str(url or summary.get("url") or "").strip().rstrip("/") + if not u or u in seen_urls: + continue + for fail in summary.get("top_failures") or []: + if not isinstance(fail, dict): + continue + if str(fail.get("id") or "") != "color-contrast": + continue + seen_urls.add(u) + help_text = str(fail.get("helpText") or "Low color contrast") + issues.append(_issue( + f"Lighthouse: {help_text}", + url=u, + priority="Medium", + recommendation="Increase contrast ratio between text and background to meet WCAG AA.", + )) + break + + return issues[:40] + + +def category_html_accessibility( + df: pd.DataFrame, + lighthouse_by_url: Optional[dict[str, Any]] = None, +) -> dict: + """HTML and Accessibility: semantic HTML, heading structure, alt, ARIA, contrast.""" + issues = [] + deductions = [] + success_df = df[df["status"].astype(str).str.match(r"2\d{2}", na=False)] if "status" in df.columns else pd.DataFrame() + if len(success_df) == 0: + return {"id": "html_accessibility", "name": CATEGORY_ACCESSIBILITY, "score": 0, "issues": [], "recommendations": []} + + if "h1_count" in df.columns: + h1c = pd.to_numeric(success_df["h1_count"], errors="coerce").fillna(-1).astype(int) + zero_h1 = (h1c == 0).sum() + multi_h1 = (h1c > 1).sum() + if zero_h1 > 0: + issues.append(_issue( + f"{int(zero_h1)} page(s) missing H1.", + priority="High", + recommendation="Add exactly one H1 per page describing the main content.", + )) + deductions.append((min(20, int(zero_h1) * 3), True)) + if multi_h1 > 0: + issues.append(_issue( + f"{int(multi_h1)} page(s) have multiple H1s.", + priority="Medium", + recommendation="Use a single H1 per page; use H2–H6 for subsections.", + )) + deductions.append((min(10, int(multi_h1) * 2), True)) + + if "heading_sequence" in df.columns: + pages_with_skipped_heading = 0 + for _, row in success_df.iterrows(): + seq = row.get("heading_sequence") + if pd.isna(seq) or not str(seq).strip(): + continue + parts = [p.strip() for p in str(seq).split(",") if p.strip()] + if not parts: + continue + levels = [int(h[1]) for h in parts if len(h) == 2 and h[0] == "h" and h[1] in "123456"] + for i in range(1, len(levels)): + if levels[i] > levels[i - 1] + 1: + if pages_with_skipped_heading == 0: + issues.append(_issue( + "Skipped heading level (e.g. H1 then H3).", + url=str(row.get("url", "")), + priority="Medium", + recommendation="Use heading levels in order (H1, H2, H3) without skipping.", + )) + pages_with_skipped_heading += 1 + break + if pages_with_skipped_heading > 0: + deductions.append((min(15, pages_with_skipped_heading * 5), True)) + + if "images_total" in df.columns and "images_without_alt" in df.columns: + total = success_df["images_total"].fillna(0).astype(int).sum() + missing_alt = success_df["images_without_alt"].fillna(0).astype(int).sum() + if total > 0 and missing_alt > 0: + issues.append(_issue( + f"{int(missing_alt)} image(s) without alt (or aria-label).", + priority="High", + recommendation="Add meaningful alt text to all images; use alt='' for decorative images.", + )) + deductions.append((min(15, int(missing_alt) * 2), True)) + + if "word_count" in success_df.columns: + wc = pd.to_numeric(success_df["word_count"], errors="coerce").fillna(0).astype(int) + very_thin = int(((wc > 0) & (wc < 100)).sum()) + if very_thin > 0: + issues.append(_issue( + f"{very_thin} page(s) with very thin content (under 100 words).", + priority="High", + recommendation="Expand thin pages with meaningful content (aim for 300+ words).", + )) + deductions.append((min(15, very_thin * 3), True)) + + if "reading_level" in success_df.columns: + rl = pd.to_numeric(success_df["reading_level"], errors="coerce").fillna(0) + complex_pages = int((rl > 14).sum()) + if complex_pages > 0: + issues.append(_issue( + f"{complex_pages} page(s) have very complex content (reading level > 14).", + priority="Medium", + recommendation="Simplify language for broader audience accessibility (aim for grade 8-10).", + )) + deductions.append((min(10, complex_pages * 2), True)) + + contrast_issues = contrast_issues_from_sources(df, lighthouse_by_url) + if contrast_issues: + issues.extend(contrast_issues) + deductions.append((min(25, len(contrast_issues) * 4), True)) + else: + issues.append(_issue( + "Color contrast is not measured by this tool.", + priority="Low", + recommendation="Enable axe (browser crawl) or Lighthouse to check contrast.", + )) + + score = _score_deductions(100, deductions) + if len(success_df) > 0 and score == 0: + score = 5 + score = min(100, max(0, score)) + return { + "id": "html_accessibility", + "name": CATEGORY_ACCESSIBILITY, + "score": score, + "issues": _sort_issues(issues), + "recommendations": list({i["recommendation"] for i in issues if i["recommendation"]}), + } + diff --git a/src/website_profiling/reporting/categories/intelligence.py b/src/website_profiling/reporting/categories/intelligence.py new file mode 100644 index 00000000..4abb1b7d --- /dev/null +++ b/src/website_profiling/reporting/categories/intelligence.py @@ -0,0 +1,68 @@ +"""Report category: intelligence.""" +from __future__ import annotations + +from typing import Any, Optional + +import pandas as pd + +from ._helpers import ( + PRIORITY_ORDER, + _broken_link_sources, + _hreflang_issues, + _indexation_coverage_issues, + _issue, + _orphan_hub_suggestions, + _page_analysis_dict, + _schema_issues, + _score_deductions, + _soft_404_issues, + _sort_issues, +) +from ..terminology import ( + CATEGORY_CONTENT_QUALITY, +) + +def category_intelligence(ml_bundle: Optional[dict] = None) -> dict: + """Content quality: duplicate clusters and language mix from crawl analysis and optional AI insights.""" + issues: list[dict] = [] + deductions: list[tuple[int, bool]] = [] + ml_bundle = ml_bundle or {} + + dups = ml_bundle.get("content_duplicates") or [] + if dups: + big = [g for g in dups if (g.get("member_count") or len(g.get("member_urls") or [])) >= 3] + if big: + issues.append(_issue( + f"Near-duplicate content: {len(big)} group(s) with 3+ URLs.", + priority="High", + recommendation="Consolidate or canonicalize duplicate pages; differentiate thin similar URLs.", + )) + deductions.append((min(20, 5 + len(big)), True)) + elif dups: + issues.append(_issue( + f"Possible duplicate content: {len(dups)} pair/group(s) detected.", + priority="Medium", + recommendation="Review clusters and add canonicals or noindex where appropriate.", + )) + deductions.append((8, True)) + + lang = ml_bundle.get("language_summary") or {} + if lang.get("mixed_site") and (lang.get("detected_pages") or 0) >= 10: + counts = lang.get("counts") or {} + top = sorted(counts.items(), key=lambda x: -x[1])[:3] + desc = ", ".join(f"{k}:{v}" for k, v in top) if top else "multiple" + issues.append(_issue( + f"Mixed languages detected across pages ({desc}).", + priority="Medium", + recommendation="Ensure hreflang and localized URLs match user intent; split sitemaps if needed.", + )) + deductions.append((5, True)) + + score = _score_deductions(100, deductions) + return { + "id": "intelligence", + "name": CATEGORY_CONTENT_QUALITY, + "score": score, + "issues": _sort_issues(issues), + "recommendations": list({i["recommendation"] for i in issues if i["recommendation"]}), + } diff --git a/src/website_profiling/reporting/categories/link_health.py b/src/website_profiling/reporting/categories/link_health.py new file mode 100644 index 00000000..e4af71d4 --- /dev/null +++ b/src/website_profiling/reporting/categories/link_health.py @@ -0,0 +1,89 @@ +"""Report category: link_health.""" +from __future__ import annotations + +from typing import Any, Optional + +import pandas as pd + +from ._helpers import ( + PRIORITY_ORDER, + REDIRECT_CHAIN_LONG, + _broken_link_sources, + _issue, + _orphan_hub_suggestions, + _score_deductions, + _sort_issues, +) +from ..terminology import ( + CATEGORY_LINKS, +) + +def category_link_health( + df: pd.DataFrame, + edges: list[tuple[str, str]], + issues_broken: list[dict], + issues_redirects: list[dict], +) -> dict: + """Link Health: broken links, redirect chains, internal linking.""" + issues = [] + deductions = [] + + for b in issues_broken[:30]: + status = str(b.get("status", "")) + priority = "Critical" if status.startswith("5") else "High" + issues.append(_issue( + f"Broken URL: {status}", + url=b.get("url", ""), + priority=priority, + recommendation="Fix or remove the link; return 200 or redirect to a valid URL.", + )) + broken_url_set = {str(b.get("url") or "").strip() for b in issues_broken if b.get("url")} + issues.extend(_broken_link_sources(edges, broken_url_set)) + if issues_broken: + deductions.append((min(30, len(issues_broken) * 2), True)) + + for r in issues_redirects[:20]: + issues.append(_issue( + f"Redirect: {r.get('status', '')} to {r.get('final_url', '')}", + url=r.get("url", ""), + priority="Medium", + recommendation="Prefer direct URLs or shorten redirect chains.", + )) + if issues_redirects: + deductions.append((min(15, len(issues_redirects)), True)) + + if "redirect_chain_length" in df.columns and len(df) > 0: + rcl = pd.to_numeric(df["redirect_chain_length"], errors="coerce").fillna(0).astype(int) + long_chains = (rcl >= REDIRECT_CHAIN_LONG).sum() + if long_chains > 0: + issues.append(_issue( + f"{int(long_chains)} URL(s) have redirect chains (2+ hops).", + priority="Medium", + recommendation="Consolidate redirects to a single hop where possible.", + )) + deductions.append((min(10, int(long_chains)), True)) + + if edges: + import networkx as nx + G = nx.DiGraph() + G.add_edges_from(edges) + in_deg = dict(G.in_degree()) + orphans = [n for n in G.nodes() if in_deg.get(n, 0) == 0] + if len(orphans) > len(G.nodes()) * 0.3: + issues.append(_issue( + f"Many pages have no internal links pointing to them ({len(orphans)}).", + priority="Low", + recommendation="Add internal links to important pages to improve crawlability and internal link equity.", + )) + deductions.append((5, True)) + issues.extend(_orphan_hub_suggestions(edges, orphans[:15])) + + score = _score_deductions(100, deductions) + return { + "id": "link_health", + "name": CATEGORY_LINKS, + "score": score, + "issues": _sort_issues(issues), + "recommendations": list({i["recommendation"] for i in issues if i["recommendation"]}), + } + diff --git a/src/website_profiling/reporting/categories/mobile.py b/src/website_profiling/reporting/categories/mobile.py new file mode 100644 index 00000000..065dc97f --- /dev/null +++ b/src/website_profiling/reporting/categories/mobile.py @@ -0,0 +1,62 @@ +"""Report category: mobile.""" +from __future__ import annotations + +from typing import Any, Optional + +import pandas as pd + +from ._helpers import ( + PRIORITY_ORDER, + _broken_link_sources, + _hreflang_issues, + _indexation_coverage_issues, + _issue, + _orphan_hub_suggestions, + _page_analysis_dict, + _schema_issues, + _score_deductions, + _soft_404_issues, + _sort_issues, +) +from ..terminology import ( + CATEGORY_MOBILE, +) + +def category_mobile(df: pd.DataFrame) -> dict: + """Mobile: viewport, responsive heuristic.""" + issues = [] + deductions = [] + success_df = df[df["status"].astype(str).str.match(r"2\d{2}", na=False)] if "status" in df.columns else pd.DataFrame() + if len(success_df) == 0: + return {"id": "mobile", "name": CATEGORY_MOBILE, "score": 0, "issues": [], "recommendations": []} + + if "viewport_present" in df.columns: + viewport_ok = success_df["viewport_present"].astype(str).str.lower().isin(("true", "1", "yes")) + no_viewport = int((~viewport_ok).sum()) + if no_viewport > 0: + issues.append(_issue( + f"{int(no_viewport)} page(s) missing viewport meta tag.", + priority="Critical", + recommendation="Add .", + )) + deductions.append((min(25, int(no_viewport) * 5), True)) + viewport_content = success_df["viewport_content"].fillna("").astype(str) + viewport_ok = success_df["viewport_present"].astype(str).str.lower().isin(("true", "1", "yes")) + invalid = (viewport_content.str.strip().eq("") | (~viewport_content.str.contains("width|device-width", case=False, na=False))) & viewport_ok + if invalid.sum() > 0: + issues.append(_issue( + "Some pages have viewport without width or device-width.", + priority="High", + recommendation="Use content='width=device-width, initial-scale=1' (or similar).", + )) + deductions.append((10, True)) + + score = _score_deductions(100, deductions) + return { + "id": "mobile", + "name": CATEGORY_MOBILE, + "score": score, + "issues": _sort_issues(issues), + "recommendations": list({i["recommendation"] for i in issues if i["recommendation"]}), + } + diff --git a/src/website_profiling/reporting/categories/performance.py b/src/website_profiling/reporting/categories/performance.py new file mode 100644 index 00000000..b4e7ffa6 --- /dev/null +++ b/src/website_profiling/reporting/categories/performance.py @@ -0,0 +1,160 @@ +"""Report category: performance.""" +from __future__ import annotations + +from typing import Any, Optional + +import pandas as pd + +from urllib.parse import urlparse + +from ._helpers import ( + PRIORITY_ORDER, + RESPONSE_TIME_SLOW_MS, + _issue, + _score_deductions, + _sort_issues, +) +from ..terminology import ( + CATEGORY_CORE_WEB_VITALS, + CATEGORY_PERFORMANCE, +) + +def category_core_web_vitals() -> dict: + """Core Web Vitals: not measured; recommend Lighthouse.""" + return { + "id": "core_web_vitals", + "name": CATEGORY_CORE_WEB_VITALS, + "score": None, + "issues": [_issue( + "LCP, INP, and CLS are not measured by this crawl.", + priority="Medium", + recommendation="Run Lighthouse (PageSpeed Insights) from Run audit to measure Core Web Vitals.", + )], + "recommendations": ["Run Lighthouse from Run audit to measure LCP, INP, and CLS."], + } + + +def category_core_web_vitals_from_lighthouse( + lighthouse_summary: dict, + crux_summary: Optional[dict] = None, +) -> dict: + """Core Web Vitals from Lighthouse summary: score 0–100 from performance score, issues from top_failures.""" + issues = [] + recommendations = [] + perf_score = None + mm = lighthouse_summary.get("median_metrics") or {} + if isinstance(mm.get("performance_score"), (int, float)): + perf_score = max(0, min(100, int(round(mm["performance_score"] * 100)))) + for f in lighthouse_summary.get("top_failures") or []: + aid = f.get("id") or "" + help_text = (f.get("helpText") or "")[:200] + msg = f"{aid}: {help_text}" if aid else help_text or "Audit failed" + issues.append(_issue( + msg, + priority="High" if (f.get("score") or 0) < 0.5 else "Medium", + recommendation="See Performance (Core Web Vitals) in this audit, or re-run Lighthouse from Run audit.", + )) + if not issues and perf_score is not None and perf_score < 80: + recommendations.append("Improve Core Web Vitals (LCP, CLS, TBT) per Lighthouse recommendations.") + if crux_summary and crux_summary.get("ok"): + pw = crux_summary.get("pass") or {} + for metric, label, rec in ( + ("lcp", "LCP", "Improve largest contentful paint (field data)."), + ("inp", "INP", "Reduce interaction to next paint (field data)."), + ("cls", "CLS", "Reduce cumulative layout shift (field data)."), + ): + if pw.get(metric) is False: + issues.append(_issue( + f"CrUX field data: {label} does not pass Core Web Vitals threshold.", + priority="High", + recommendation=rec, + )) + return { + "id": "core_web_vitals", + "name": CATEGORY_CORE_WEB_VITALS, + "score": perf_score, + "issues": _sort_issues(issues), + "recommendations": recommendations or ["Core Web Vitals measured by Lighthouse; see median_metrics in lighthouse_summary.json."], + } + + +def category_performance(df: pd.DataFrame) -> dict: + """Performance: response time, JS/CSS size, images, lazy loading, caching.""" + issues = [] + deductions = [] + success_df = df[df["status"].astype(str).str.match(r"2\d{2}", na=False)] if "status" in df.columns else pd.DataFrame() + if len(success_df) == 0: + return {"id": "performance", "name": CATEGORY_PERFORMANCE, "score": 0, "issues": [], "recommendations": []} + + if "response_time_ms" in success_df.columns: + rt = pd.to_numeric(success_df["response_time_ms"], errors="coerce").fillna(0) + slow = (rt > RESPONSE_TIME_SLOW_MS).sum() + if slow > 0: + issues.append(_issue( + f"{int(slow)} page(s) have server response time > {RESPONSE_TIME_SLOW_MS // 1000}s.", + priority="High" if slow > 5 else "Medium", + recommendation="Optimize server response time (TTFB): caching, CDN, or backend tuning.", + )) + deductions.append((min(20, int(slow) * 2), True)) + valid_rt = rt[rt > 0] + if len(valid_rt) > 5: + p95 = float(valid_rt.quantile(0.95)) + if p95 > 3000: + issues.append(_issue( + f"95th percentile response time is {int(p95)}ms (over 3s).", + priority="High", + recommendation="Investigate slowest pages; consider CDN, server-side caching, or database optimization.", + )) + deductions.append((10, True)) + + if "images_total" in success_df.columns: + total_imgs = success_df["images_total"].fillna(0).astype(int).sum() + if total_imgs > 0 and "img_without_lazy" in success_df.columns: + no_lazy = success_df["img_without_lazy"].fillna(0).astype(int).sum() + if no_lazy > total_imgs * 0.5: + issues.append(_issue( + "Many images without lazy loading.", + priority="Medium", + recommendation="Add loading='lazy' to off-screen images.", + )) + deductions.append((10, True)) + if total_imgs > 0 and "img_without_dimensions" in success_df.columns: + no_dims = success_df["img_without_dimensions"].fillna(0).astype(int).sum() + if no_dims > 0: + issues.append(_issue( + f"{int(no_dims)} image(s) without width/height (can cause CLS).", + priority="High", + recommendation="Set width and height attributes on img tags to avoid layout shift.", + )) + deductions.append((10, True)) + + if "cache_control" in success_df.columns: + cache = success_df["cache_control"].fillna("").astype(str) + no_cache = (cache.str.strip() == "").sum() + if no_cache > len(success_df) * 0.5: + issues.append(_issue( + "Many pages without Cache-Control header.", + priority="Medium", + recommendation="Set Cache-Control (and optionally ETag) for static and cacheable pages.", + )) + deductions.append((10, True)) + + if "script_count" in success_df.columns: + scripts = success_df["script_count"].fillna(0).astype(int) + if scripts.sum() > len(success_df) * 10: + issues.append(_issue( + "High number of script tags across pages.", + priority="Low", + recommendation="Consider bundling and code-splitting to reduce JS payload.", + )) + deductions.append((5, True)) + + score = _score_deductions(100, deductions) + return { + "id": "performance", + "name": CATEGORY_PERFORMANCE, + "score": score, + "issues": _sort_issues(issues), + "recommendations": list({i["recommendation"] for i in issues if i["recommendation"]}), + } + diff --git a/src/website_profiling/reporting/categories/security.py b/src/website_profiling/reporting/categories/security.py new file mode 100644 index 00000000..31ff13ba --- /dev/null +++ b/src/website_profiling/reporting/categories/security.py @@ -0,0 +1,117 @@ +"""Report category: security.""" +from __future__ import annotations + +from typing import Any, Optional +from urllib.parse import urlparse + +import pandas as pd + +from ._helpers import ( + PRIORITY_ORDER, + _broken_link_sources, + _hreflang_issues, + _indexation_coverage_issues, + _issue, + _orphan_hub_suggestions, + _page_analysis_dict, + _schema_issues, + _score_deductions, + _soft_404_issues, + _sort_issues, +) +from ..terminology import ( + CATEGORY_SECURITY, +) + +def category_security( + df: pd.DataFrame, + site_level: dict, + start_url: str, + security_findings: Optional[list[dict]] = None, +) -> dict: + """Security: HTTPS, security headers, mixed content, and optional vulnerability scan findings.""" + issues = [] + deductions = [] + parsed = urlparse(start_url) + if parsed.scheme and parsed.scheme.lower() != "https": + issues.append(_issue( + "Site is not using HTTPS.", + url=start_url, + priority="Critical", + recommendation="Serve the site over HTTPS and redirect HTTP to HTTPS.", + )) + deductions.append((30, True)) + + if "final_url" in df.columns and len(df) > 0: + final_urls = df["final_url"].fillna("").astype(str) + http_finals = final_urls.str.strip().str.lower().str.startswith("http://") + if http_finals.sum() > 0: + issues.append(_issue( + f"{int(http_finals.sum())} URL(s) resolve to HTTP.", + priority="Critical", + recommendation="Ensure all pages redirect to HTTPS.", + )) + deductions.append((20, True)) + + success_df = df[df["status"].astype(str).str.match(r"2\d{2}", na=False)] if "status" in df.columns else pd.DataFrame() + if len(success_df) > 0: + # Security headers: sample from first row or aggregate (optional columns) + missing_hsts = (success_df["strict_transport_security"].fillna("").astype(str).str.strip() == "").sum() if "strict_transport_security" in success_df.columns else len(success_df) + missing_xcto = (success_df["x_content_type_options"].fillna("").astype(str).str.strip() == "").sum() if "x_content_type_options" in success_df.columns else len(success_df) + missing_xfo = (success_df["x_frame_options"].fillna("").astype(str).str.strip() == "").sum() if "x_frame_options" in success_df.columns else len(success_df) + if missing_hsts >= len(success_df) * 0.5: + issues.append(_issue( + "Strict-Transport-Security header not set.", + priority="High", + recommendation="Add Strict-Transport-Security to enforce HTTPS.", + )) + deductions.append((15, True)) + if missing_xcto >= len(success_df) * 0.5: + issues.append(_issue( + "X-Content-Type-Options header not set.", + priority="Medium", + recommendation="Add X-Content-Type-Options: nosniff.", + )) + deductions.append((5, True)) + if missing_xfo >= len(success_df) * 0.5: + issues.append(_issue( + "X-Frame-Options header not set.", + priority="Medium", + recommendation="Add X-Frame-Options: DENY or SAMEORIGIN.", + )) + deductions.append((5, True)) + + if "mixed_content_count" in success_df.columns: + mixed = success_df["mixed_content_count"].fillna(0).astype(int).sum() + scheme = (parsed.scheme or "").lower() + if mixed > 0 and scheme == "https": + issues.append(_issue( + f"Mixed content: {int(mixed)} HTTP resource(s) on HTTPS pages.", + priority="High", + recommendation="Load all resources over HTTPS to avoid mixed content.", + )) + deductions.append((15, True)) + + # Merge vulnerability scan findings (same format as issues: message, url, priority, recommendation) + if security_findings: + for f in security_findings: + severity = f.get("severity", "Medium") + issues.append(_issue( + f.get("message", ""), + url=f.get("url", ""), + priority=severity, + recommendation=f.get("recommendation", ""), + )) + # Deduct by severity: Critical 15, High 10, Medium 5, Low 2 + ded = {"Critical": 15, "High": 10, "Medium": 5, "Low": 2}.get(severity, 2) + deductions.append((min(ded, 15), True)) + + score = _score_deductions(100, deductions) + return { + "id": "security", + "name": CATEGORY_SECURITY, + "score": score, + "issues": _sort_issues(issues), + "recommendations": list({i["recommendation"] for i in issues if i["recommendation"]}), + } + diff --git a/src/website_profiling/reporting/categories/technical_seo.py b/src/website_profiling/reporting/categories/technical_seo.py new file mode 100644 index 00000000..725122e9 --- /dev/null +++ b/src/website_profiling/reporting/categories/technical_seo.py @@ -0,0 +1,211 @@ +"""Report category: technical_seo.""" +from __future__ import annotations + +from typing import Any, Optional + +import pandas as pd + +from ._helpers import ( + PRIORITY_ORDER, + _broken_link_sources, + _hreflang_issues, + _indexation_coverage_issues, + _issue, + _orphan_hub_suggestions, + _page_analysis_dict, + _schema_issues, + _score_deductions, + _soft_404_issues, + _sort_issues, +) +from ..terminology import ( + CATEGORY_TECHNICAL_SEO, +) + +def category_technical_seo( + df: pd.DataFrame, + site_level: dict, +) -> dict: + """Technical SEO: robots, sitemap, canonical, duplicate content, noindex, schema.""" + issues = [] + deductions = [] + total = len(df) + success_df = df[df["status"].astype(str).str.match(r"2\d{2}", na=False)] if "status" in df.columns else pd.DataFrame() + + if not site_level.get("robots_present", True): + issues.append(_issue( + "robots.txt is missing or unreachable.", + priority="High", + recommendation="Add a robots.txt at the site root to control crawler access.", + )) + deductions.append((15, True)) + if not site_level.get("sitemap_present", True): + issues.append(_issue( + "sitemap.xml (or sitemap index) is missing or unreachable.", + priority="High", + recommendation="Add a sitemap at /sitemap.xml or link it in robots.txt.", + )) + deductions.append((10, True)) + if site_level.get("sitemap_present") and not site_level.get("sitemap_valid", True): + issues.append(_issue( + "sitemap.xml could not be parsed as valid XML.", + priority="Medium", + recommendation="Ensure sitemap is valid XML and follows sitemaps.org format.", + )) + deductions.append((5, True)) + if site_level.get("ads_txt_present") is False: + issues.append(_issue( + "ads.txt is missing or unreachable.", + priority="Low", + recommendation="Add an ads.txt file at the site root if you run programmatic advertising.", + )) + if site_level.get("security_txt_present") is False: + issues.append(_issue( + "security.txt is missing or unreachable.", + priority="Low", + recommendation="Publish security.txt at /.well-known/security.txt with a Contact field for security reporting.", + )) + + # Canonical: missing or self-mismatch + if "canonical_url" in df.columns and len(success_df) > 0: + for _, row in success_df.iterrows(): + url = row.get("url") + canon = row.get("canonical_url") + if pd.isna(url): + continue + url = str(url).strip() + canon = "" if pd.isna(canon) else str(canon).strip() + if not canon: + issues.append(_issue("Missing canonical URL.", url=url, priority="Medium", recommendation="Add a canonical link tag pointing to the preferred URL.")) + break + missing_canon = success_df["canonical_url"].fillna("").astype(str).str.strip().eq("").sum() + if missing_canon > 0: + deductions.append((min(15, missing_canon * 2), True)) + # Self-canonical mismatch: canonical points to different URL + for _, row in success_df.iterrows(): + url = row.get("url") + canon = row.get("canonical_url") + if pd.isna(url) or pd.isna(canon) or not str(canon).strip(): + continue + url = str(url).rstrip("/") + canon = str(canon).strip().rstrip("/") + if url != canon: + issues.append(_issue(f"Canonical points to different URL: {canon}", url=url, priority="High", recommendation="Set canonical to this page URL or the preferred duplicate.")) + deductions.append((10, True)) + break + + # Noindex on important pages (CSV may store True/False as strings) + if "noindex" in df.columns and len(success_df) > 0: + noindex_ser = success_df["noindex"].astype(str).str.lower().isin(("true", "1", "yes")) + noindex_count = int(noindex_ser.sum()) + if noindex_count > 0: + issues.append(_issue( + f"{int(noindex_count)} page(s) have noindex.", + priority="High" if noindex_count > 5 else "Medium", + recommendation="Remove noindex from pages that should be indexed, or keep for intentional no-index pages.", + )) + deductions.append((min(15, noindex_count * 3), True)) + + # Duplicate content heuristic: same title + meta description + if "title" in df.columns and "meta_description" in df.columns and len(success_df) > 1: + key = success_df["title"].fillna("").astype(str) + "|" + success_df["meta_description"].fillna("").astype(str) + dupes = key.value_counts() + dupes = dupes[dupes > 1] + if len(dupes) > 0: + issues.append(_issue( + f"Possible duplicate content: {len(dupes)} group(s) of pages share same title and meta description.", + priority="Medium", + recommendation="Differentiate titles and meta descriptions, or use canonicals to designate the preferred URL.", + )) + deductions.append((10, True)) + + # Social meta tags + if "og_title" in df.columns and len(success_df) > 0: + og_present = (success_df["og_title"].fillna("").astype(str).str.strip() != "").sum() + og_pct = og_present / len(success_df) if len(success_df) > 0 else 1 + if og_pct < 0.5: + issues.append(_issue( + f"Open Graph tags missing on {int((1 - og_pct) * 100)}% of pages.", + priority="Medium", + recommendation="Add og:title, og:description, and og:image meta tags for social sharing.", + )) + deductions.append((5, True)) + + if "twitter_card" in df.columns and len(success_df) > 0: + tw_present = (success_df["twitter_card"].fillna("").astype(str).str.strip() != "").sum() + tw_pct = tw_present / len(success_df) if len(success_df) > 0 else 1 + if tw_pct < 0.2: + issues.append(_issue( + f"Twitter Card tags missing on {int((1 - tw_pct) * 100)}% of pages.", + priority="Low", + recommendation="Add twitter:card meta tags for better Twitter/X sharing previews.", + )) + deductions.append((3, True)) + + # Structured data + if "has_schema" in df.columns and len(success_df) > 0: + with_schema = int(success_df["has_schema"].astype(str).str.lower().isin(("true", "1", "yes")).sum()) + if with_schema == 0: + issues.append(_issue( + "No structured data (JSON-LD or microdata) detected.", + priority="Low", + recommendation="Add schema.org markup (e.g. Organization, Article) for rich results.", + )) + deductions.append((5, True)) + + # Internationalization: from page_analysis (re-crawl to populate) + if "page_analysis" in df.columns and len(success_df) > 0: + missing_lang = 0 + for _, row in success_df.iterrows(): + pa = _page_analysis_dict(row) + if not (pa.get("html_lang") or "").strip(): + missing_lang += 1 + if missing_lang > 0 and len(success_df) >= 3: + ratio = missing_lang / len(success_df) + if ratio > 0.1: + issues.append(_issue( + f"{missing_lang} page(s) missing (of {len(success_df)} OK responses).", + priority="Medium" if ratio > 0.5 else "Low", + recommendation="Add matching the primary language of each page.", + )) + deductions.append((min(10, max(2, missing_lang // 5)), True)) + + issues.extend(_hreflang_issues(success_df)) + issues.extend(_schema_issues(success_df)) + issues.extend(_soft_404_issues(success_df)) + + if "page_analysis" in df.columns and len(success_df) > 0: + from ...crawl.fetchers.browser_diagnostics import browser_summary_from_page_analysis + + pages_with_console = 0 + for _, row in success_df.iterrows(): + pa = _page_analysis_dict(row) + counts = browser_summary_from_page_analysis(pa) + url = str(row.get("url") or "").strip() + if counts["console_error_count"] > 0: + pages_with_console += 1 + if counts["page_error_count"] > 0 and url: + issues.append(_issue( + "Uncaught JavaScript error during browser render.", + url=url, + priority="High", + recommendation="Fix runtime JS errors that may break page functionality or SEO signals.", + )) + deductions.append((5, True)) + if pages_with_console > 0: + issues.append(_issue( + f"{pages_with_console} page(s) logged console errors during JavaScript rendering.", + priority="High" if pages_with_console > 3 else "Medium", + recommendation="Inspect browser console errors on affected URLs; fix broken scripts or API calls.", + )) + deductions.append((min(15, pages_with_console * 2), True)) + + score = _score_deductions(100, deductions) + return { + "id": "technical_seo", + "name": CATEGORY_TECHNICAL_SEO, + "score": score, + "issues": _sort_issues(issues), + "recommendations": list({i["recommendation"] for i in issues if i["recommendation"]}), + } + diff --git a/src/website_profiling/reporting/content_analytics.py b/src/website_profiling/reporting/content_analytics.py new file mode 100644 index 00000000..3018e131 --- /dev/null +++ b/src/website_profiling/reporting/content_analytics.py @@ -0,0 +1,434 @@ +"""Content and crawl analytics for report payloads.""" +from __future__ import annotations + +import json +from typing import Any, Optional + +import pandas as pd + +from ..analysis.text_hygiene import filter_topic_clusters, is_junk_semantic_term +from ..config import get_bool, get_int +from ..tools.keywords import cluster_keywords, extract_candidates_from_df, score_keywords + +def _build_content_analytics(df: pd.DataFrame) -> dict: + """Build content analytics: word count stats, reading level distribution, content ratio, top keywords.""" + from collections import Counter + + result = { + "word_count_stats": {"mean": 0, "median": 0, "p25": 0, "p75": 0, "min": 0, "max": 0}, + "word_count_distribution": {}, + "reading_level_distribution": {}, + "content_ratio_distribution": {}, + "top_keywords_site": [], + "thin_pages": [], + } + if "word_count" not in df.columns or df.empty: + return result + + success_df = df[df["status"].astype(str).str.match(r"2\d{2}", na=False)] if "status" in df.columns else df + if success_df.empty: + return result + + wc = pd.to_numeric(success_df["word_count"], errors="coerce").fillna(0).astype(int) + result["word_count_stats"] = { + "mean": round(float(wc.mean()), 1), + "median": round(float(wc.median()), 1), + "p25": round(float(wc.quantile(0.25)), 1), + "p75": round(float(wc.quantile(0.75)), 1), + "min": int(wc.min()), + "max": int(wc.max()), + } + + wc_bins = [(0, 100), (101, 300), (301, 600), (601, 1000), (1001, 2000), (2001, 999999)] + wc_labels = ["0-100", "101-300", "301-600", "601-1000", "1001-2000", "2001+"] + result["word_count_distribution"] = { + lbl: int(((wc >= lo) & (wc <= hi)).sum()) for (lo, hi), lbl in zip(wc_bins, wc_labels) + } + + if "reading_level" in success_df.columns: + rl = pd.to_numeric(success_df["reading_level"], errors="coerce").fillna(0) + rl_bins = [(0, 5), (6, 8), (9, 12), (13, 99)] + rl_labels = ["Elementary (0-5)", "Middle School (6-8)", "High School (9-12)", "College (13+)"] + result["reading_level_distribution"] = { + lbl: int(((rl >= lo) & (rl <= hi)).sum()) for (lo, hi), lbl in zip(rl_bins, rl_labels) + } + + if "content_html_ratio" in success_df.columns: + cr = pd.to_numeric(success_df["content_html_ratio"], errors="coerce").fillna(0) + cr_bins = [(0, 10), (10.01, 20), (20.01, 40), (40.01, 100)] + cr_labels = ["<10%", "10-20%", "20-40%", ">40%"] + result["content_ratio_distribution"] = { + lbl: int(((cr >= lo) & (cr <= hi)).sum()) for (lo, hi), lbl in zip(cr_bins, cr_labels) + } + + if "top_keywords" in success_df.columns: + kw_counter = Counter() + for raw in success_df["top_keywords"].fillna("[]"): + try: + items = json.loads(str(raw)) if isinstance(raw, str) else raw + if isinstance(items, list): + for item in items: + if isinstance(item, dict): + kw_counter[item.get("word", "")] += item.get("count", 0) + except (json.JSONDecodeError, TypeError): + pass + result["top_keywords_site"] = [ + {"word": w, "count": c} + for w, c in kw_counter.most_common(50) + if w and not is_junk_semantic_term(str(w)) + ][:30] + + for _, row in success_df.iterrows(): + u = row.get("url") + if pd.isna(u) or not u: + continue + w = int(pd.to_numeric(row.get("word_count"), errors="coerce") or 0) + if 0 < w < 300: + result["thin_pages"].append({"url": str(u).strip(), "word_count": w}) + + return result + + +def _parse_top_keywords_items(raw: Any) -> list[dict[str, Any]]: + """Parse per-page top_keywords JSON into dict items with word/count.""" + if raw is None or (isinstance(raw, float) and pd.isna(raw)): + return [] + try: + items = json.loads(str(raw)) if isinstance(raw, str) else raw + except (json.JSONDecodeError, TypeError, ValueError): + return [] + if not isinstance(items, list): + return [] + out: list[dict[str, Any]] = [] + for item in items: + if isinstance(item, dict): + word = str(item.get("word") or "").strip() + if word: + out.append({"word": word, "count": int(item.get("count") or 1)}) + return out + + +def _build_text_content_analysis(df: pd.DataFrame) -> dict: + """Cross-page keyword aggregates for the text content analysis view.""" + empty = { + "vocabulary_stats": { + "unique_terms": 0, + "pages_with_keywords": 0, + "avg_terms_per_page": 0.0, + "total_term_occurrences": 0, + }, + "keyword_index": [], + "keyword_frequency_histogram": {"1": 0, "2-5": 0, "6-20": 0, "21+": 0}, + } + if df.empty or "top_keywords" not in df.columns: + return empty + + success_df = df[df["status"].astype(str).str.match(r"2\d{2}", na=False)] if "status" in df.columns else df + if success_df.empty: + return empty + + # word -> { total_count, pages: { url -> count } } + index: dict[str, dict[str, Any]] = {} + pages_with_keywords = 0 + total_occurrences = 0 + + for _, row in success_df.iterrows(): + url = row.get("url") + if pd.isna(url) or not url: + continue + url_str = str(url).strip() + items = _parse_top_keywords_items(row.get("top_keywords")) + page_had_kw = False + for item in items: + word = item["word"].lower() + if is_junk_semantic_term(word): + continue + count = max(1, int(item.get("count") or 1)) + if word not in index: + index[word] = {"total_count": 0, "pages": {}} + index[word]["total_count"] += count + index[word]["pages"][url_str] = index[word]["pages"].get(url_str, 0) + count + total_occurrences += count + page_had_kw = True + if page_had_kw: + pages_with_keywords += 1 + + unique_terms = len(index) + avg_terms = round(total_occurrences / pages_with_keywords, 1) if pages_with_keywords else 0.0 + + histogram = {"1": 0, "2-5": 0, "6-20": 0, "21+": 0} + for data in index.values(): + pc = len(data["pages"]) + if pc == 1: + histogram["1"] += 1 + elif pc <= 5: + histogram["2-5"] += 1 + elif pc <= 20: + histogram["6-20"] += 1 + else: + histogram["21+"] += 1 + + sorted_words = sorted(index.items(), key=lambda x: x[1]["total_count"], reverse=True) + keyword_index: list[dict[str, Any]] = [] + for word, data in sorted_words: + top_pages = sorted(data["pages"].items(), key=lambda x: x[1], reverse=True)[:5] + keyword_index.append( + { + "word": word, + "total_count": data["total_count"], + "page_count": len(data["pages"]), + "top_pages": [{"url": u, "count": c} for u, c in top_pages], + } + ) + + return { + "vocabulary_stats": { + "unique_terms": unique_terms, + "pages_with_keywords": pages_with_keywords, + "avg_terms_per_page": avg_terms, + "total_term_occurrences": total_occurrences, + }, + "keyword_index": keyword_index, + "keyword_frequency_histogram": histogram, + } + + +def _build_social_coverage(df: pd.DataFrame) -> dict: + """Build social meta coverage stats: OG and Twitter Card presence percentages.""" + result = { + "og_coverage_pct": 0, + "twitter_coverage_pct": 0, + "og_image_coverage_pct": 0, + "missing_og": [], + "missing_twitter": [], + "og_image_missing": [], + } + if df.empty: + return result + + success_df = df[df["status"].astype(str).str.match(r"2\d{2}", na=False)] if "status" in df.columns else df + html_df = success_df + if "content_type" in success_df.columns: + html_df = success_df[success_df["content_type"].fillna("").str.contains("text/html", case=False, na=False)] + if html_df.empty: + return result + + total = len(html_df) + + if "og_title" in html_df.columns: + has_og = (html_df["og_title"].fillna("").astype(str).str.strip() != "").sum() + result["og_coverage_pct"] = round(100 * int(has_og) / total, 1) + for _, row in html_df.iterrows(): + u = row.get("url") + if pd.isna(u): + continue + u = str(u).strip() + og = str(row.get("og_title") or "").strip() + if not og: + result["missing_og"].append(u) + + if "twitter_card" in html_df.columns: + has_tw = (html_df["twitter_card"].fillna("").astype(str).str.strip() != "").sum() + result["twitter_coverage_pct"] = round(100 * int(has_tw) / total, 1) + for _, row in html_df.iterrows(): + u = row.get("url") + if pd.isna(u): + continue + u = str(u).strip() + tw = str(row.get("twitter_card") or "").strip() + if not tw: + result["missing_twitter"].append(u) + + if "og_image" in html_df.columns: + has_og_img = (html_df["og_image"].fillna("").astype(str).str.strip() != "").sum() + result["og_image_coverage_pct"] = round(100 * int(has_og_img) / total, 1) + for _, row in html_df.iterrows(): + u = row.get("url") + if pd.isna(u): + continue + u = str(u).strip() + img = str(row.get("og_image") or "").strip() + if not img: + result["og_image_missing"].append(u) + + result["missing_og"] = result["missing_og"][:100] + result["missing_twitter"] = result["missing_twitter"][:100] + result["og_image_missing"] = result["og_image_missing"][:100] + return result + + +def _build_tech_stack_summary(df: pd.DataFrame) -> dict: + """Build tech stack summary: detected technologies with counts and sample URLs.""" + from collections import defaultdict + + result = {"technologies": [], "total_pages_analyzed": 0} + if "tech_stack" not in df.columns or df.empty: + return result + + success_df = df[df["status"].astype(str).str.match(r"2\d{2}", na=False)] if "status" in df.columns else df + html_df = success_df + if "content_type" in success_df.columns: + html_df = success_df[success_df["content_type"].fillna("").str.contains("text/html", case=False, na=False)] + if html_df.empty: + return result + + result["total_pages_analyzed"] = len(html_df) + tech_urls = defaultdict(list) + + for _, row in html_df.iterrows(): + u = str(row.get("url", "")).strip() + raw = row.get("tech_stack") or "[]" + try: + techs = json.loads(str(raw)) if isinstance(raw, str) else raw + if isinstance(techs, list): + for t in techs: + if isinstance(t, str) and t: + tech_urls[t].append(u) + except (json.JSONDecodeError, TypeError): + pass + + result["technologies"] = sorted( + [{"name": name, "count": len(urls), "sample_urls": urls[:3]} for name, urls in tech_urls.items()], + key=lambda x: x["count"], + reverse=True, + ) + return result + + +def _build_response_time_stats(df: pd.DataFrame) -> dict: + """Build response time statistics and distribution.""" + result = { + "p25": 0, "p50": 0, "p75": 0, "p95": 0, "p99": 0, + "slow_pages": [], + "distribution": {}, + } + if "response_time_ms" not in df.columns or df.empty: + return result + + rt = pd.to_numeric(df["response_time_ms"], errors="coerce").dropna() + if rt.empty: + return result + + result["p25"] = round(float(rt.quantile(0.25)), 0) + result["p50"] = round(float(rt.quantile(0.50)), 0) + result["p75"] = round(float(rt.quantile(0.75)), 0) + result["p95"] = round(float(rt.quantile(0.95)), 0) + result["p99"] = round(float(rt.quantile(0.99)), 0) + + rt_bins = [(0, 200), (200, 500), (500, 1000), (1000, 2000), (2000, 999999)] + rt_labels = ["<200ms", "200-500ms", "500ms-1s", "1-2s", ">2s"] + rt_full = pd.to_numeric(df["response_time_ms"], errors="coerce").fillna(0) + result["distribution"] = { + lbl: int(((rt_full >= lo) & (rt_full < hi)).sum()) for (lo, hi), lbl in zip(rt_bins, rt_labels) + } + + for _, row in df.iterrows(): + u = row.get("url") + ms = pd.to_numeric(row.get("response_time_ms"), errors="coerce") + if pd.isna(u) or pd.isna(ms) or ms <= 2000: + continue + result["slow_pages"].append({"url": str(u).strip(), "response_time_ms": int(ms)}) + result["slow_pages"] = sorted(result["slow_pages"], key=lambda x: x["response_time_ms"], reverse=True)[:50] + return result + + +def _build_depth_distribution(df: pd.DataFrame) -> dict: + """Build crawl depth distribution.""" + result = {"by_depth": {}, "max_depth": 0, "avg_depth": 0} + if "depth" not in df.columns or df.empty: + return result + + depths = pd.to_numeric(df["depth"], errors="coerce").dropna().astype(int) + if depths.empty: + return result + + result["max_depth"] = int(depths.max()) + result["avg_depth"] = round(float(depths.mean()), 1) + counts = depths.value_counts().sort_index() + result["by_depth"] = {str(int(k)): int(v) for k, v in counts.items()} + return result + + +def _build_keyword_opportunities(df: pd.DataFrame, config: dict[str, str] | None) -> dict[str, Any]: + if not get_bool(config or {}, "include_keyword_opportunities", True): + return {} + if "status" not in df.columns or df.empty: + return {"quick_wins": [], "high_value": [], "token_topic_clusters": []} + success_df = df[df["status"].astype(str).str.match(r"2\d{2}", na=False)] + if success_df.empty: + return {"quick_wins": [], "high_value": [], "token_topic_clusters": []} + candidates = extract_candidates_from_df(success_df) + if not candidates: + return {"quick_wins": [], "high_value": [], "token_topic_clusters": []} + corpus_size = len(success_df) + scored = score_keywords(candidates, corpus_size=corpus_size) + clusters = cluster_keywords(scored) + quick_wins = [s for s in scored if s.get("difficulty", 100) < 60][:10] + high_value = [s for s in scored if (s.get("volume") or 0) >= 0.5][:10] + if not high_value: + high_value = scored[:10] + return { + "quick_wins": quick_wins[:10], + "high_value": high_value[:10], + "token_topic_clusters": filter_topic_clusters(clusters)[:50], + } + + +def _build_image_inventory( + links: list[dict[str, Any]], + config: Optional[dict[str, str]], +) -> tuple[list[dict[str, Any]], dict[str, Any]]: + from ..analysis.image_probe import collect_image_refs_from_links, probe_image_urls + + refs = collect_image_refs_from_links(links) + unoptimized_min_kb = get_int(config or {}, "image_unoptimized_min_kb", 200) or 200 + summary: dict[str, Any] = { + "probed": 0, + "failed": 0, + "total_bytes": 0, + "over_threshold_count": 0, + "unoptimized_min_kb": unoptimized_min_kb, + "inventory_available": False, + } + if not get_bool(config or {}, "probe_image_inventory", False): + return [], summary + + max_urls = get_int(config or {}, "max_image_probe_urls", 500) or 500 + concurrency = get_int(config or {}, "image_probe_concurrency", 6) or 6 + probe_timeout = get_int(config or {}, "image_probe_timeout", 8) or 8 + url_list = list(refs.keys())[:max_urls] + if not url_list: + return [], summary + + print(f" Probing up to {len(url_list)} image URL(s)...", flush=True) + probed = probe_image_urls( + url_list, + concurrency=concurrency, + timeout=probe_timeout, + ) + threshold_bytes = unoptimized_min_kb * 1024 + inventory: list[dict[str, Any]] = [] + for row in probed: + url = row.get("url") + meta = refs.get(str(url or ""), {"source_pages": set(), "kinds": set()}) + size = row.get("size_bytes") + entry = { + "url": url, + "status": row.get("status"), + "content_type": row.get("content_type"), + "size_bytes": size, + "error": row.get("error"), + "source_pages": sorted(meta.get("source_pages") or []), + "kinds": sorted(meta.get("kinds") or []), + } + inventory.append(entry) + summary["probed"] += 1 + if row.get("error") or row.get("status") is None: + summary["failed"] += 1 + if size is not None: + summary["total_bytes"] += int(size) + if int(size) >= threshold_bytes: + summary["over_threshold_count"] += 1 + summary["inventory_available"] = True + print(f" Image probe complete ({summary['probed']} URLs, {summary['failed']} failed).", flush=True) + return inventory, summary diff --git a/src/website_profiling/reporting/edges_report.py b/src/website_profiling/reporting/edges_report.py new file mode 100644 index 00000000..fff8ff59 --- /dev/null +++ b/src/website_profiling/reporting/edges_report.py @@ -0,0 +1,114 @@ +"""Build link edges from crawl data.""" +from __future__ import annotations + +import time +from concurrent.futures import ThreadPoolExecutor, as_completed +from urllib.parse import urlparse + +import pandas as pd +import requests +from bs4 import BeautifulSoup +from tqdm.auto import tqdm + +from ..common import LINK_COLUMN_NAMES, load_edges, normalize_link, parse_links_serialized + +def build_edges_from_df( + df: pd.DataFrame, + edges_csv: str, + same_domain_only: bool, + max_fetch_for_edges: int, + concurrency: int, + timeout: int, + polite_delay: float, + render_mode: str = "static", + js_timeout: int = 30, + js_concurrency: int = 3, + js_wait_until: str = "domcontentloaded", + js_extra_wait_ms: int = 1500, + js_block_resources: bool = True, +) -> list[tuple[str, str]]: + """Build or load edges; return list of (from, to) tuples.""" + edges = load_edges(edges_csv) if (edges_csv or "").strip() else [] + if edges: + return edges + + # Prefer columns that hold URL lists (e.g. outlink_targets); skip "outlinks" (numeric count) + candidate_cols = [ + c for c in df.columns + if c.lower() in LINK_COLUMN_NAMES and c.lower() != "outlinks" + ] + if candidate_cols: + for col in candidate_cols: + if df[col].notna().sum() == 0: + continue + for src, raw in zip(df["url"], df[col].fillna("")): + for t in parse_links_serialized(raw): + if not t: + continue + if same_domain_only and urlparse(src).netloc != urlparse(t).netloc: + continue + edges.append((src, t)) + if edges: + return edges + + session = requests.Session() + session.headers.update({"User-Agent": "WebsiteProfiling/1.0"}) + urls = df["url"].tolist()[:max_fetch_for_edges] + mode = (render_mode or "static").strip().lower() + use_js = mode in ("javascript", "auto") + fetcher = None + if use_js: + from ..crawl.fetchers import build_fetcher + + fetcher = build_fetcher( + render_mode="javascript" if mode == "javascript" else "auto", + timeout=timeout, + user_agent="WebsiteProfiling/1.0", + session=session, + js_timeout=js_timeout, + js_concurrency=js_concurrency, + js_wait_until=js_wait_until, + js_extra_wait_ms=js_extra_wait_ms, + js_block_resources=js_block_resources, + ) + + def fetch(src): + try: + if fetcher is not None: + r = fetcher.fetch(src) + if r.status != 200 or not r.text: + return [] + html = r.text + else: + resp = session.get(src, timeout=timeout, allow_redirects=True) + if resp.status_code != 200 or not resp.headers.get("Content-Type", "").lower().startswith("text/html"): + return [] + html = resp.text + soup = BeautifulSoup(html, "lxml") + out = set() + for a in soup.find_all("a", href=True): + ln = normalize_link(src, a["href"]) + if not ln or (same_domain_only and urlparse(src).netloc != urlparse(ln).netloc): + continue + out.add(ln) + if polite_delay: + time.sleep(polite_delay) + return list(out) + except Exception: + return [] + + try: + with ThreadPoolExecutor(max_workers=concurrency) as ex: + futures = {ex.submit(fetch, u): u for u in urls} + for f in tqdm(as_completed(futures), total=len(futures), desc="Extracting links"): + src = futures[f] + try: + outs = f.result() + except Exception: + outs = [] + for t in outs: + edges.append((src, t)) + finally: + if fetcher is not None: + fetcher.close() + return edges diff --git a/src/website_profiling/reporting/lighthouse_report.py b/src/website_profiling/reporting/lighthouse_report.py new file mode 100644 index 00000000..5ba6afbc --- /dev/null +++ b/src/website_profiling/reporting/lighthouse_report.py @@ -0,0 +1,195 @@ +"""Lighthouse report helpers and SSL certificate checks.""" +from __future__ import annotations + +import socket +import ssl +from datetime import datetime, timezone +from typing import Any, Optional +from urllib.parse import urlparse + + +def fetch_site_ssl_expires_iso(hostname: str, timeout: float = 5.0) -> Optional[str]: + """Return certificate notAfter as ISO 8601 UTC, or None on failure.""" + host = (hostname or "").strip().lower() + if not host: + return None + try: + ctx = ssl.create_default_context() + with socket.create_connection((host, 443), timeout=timeout) as sock: + with ctx.wrap_socket(sock, server_hostname=host) as ssock: + cert = ssock.getpeercert() + if not cert: + return None + na = cert.get("notAfter") + if not na: + return None + ts = ssl.cert_time_to_seconds(na) + return datetime.fromtimestamp(ts, tz=timezone.utc).isoformat() + except Exception: + return None + + +def _strip_www(host: str) -> str: + h = (host or "").strip().lower() + return h[4:] if h.startswith("www.") else h + + +def _url_hostname(url: str) -> str: + if not url: + return "" + try: + return (urlparse(str(url).strip()).hostname or "").lower() + except Exception: + return "" + + +def _hosts_match(a: str, b: str) -> bool: + if not a or not b: + return False + a, b = a.lower(), b.lower() + return a == b or _strip_www(a) == _strip_www(b) + + +def filter_lighthouse_by_host(by_url: dict[str, Any], expected_host: str) -> dict[str, Any]: + """Keep only Lighthouse entries whose URL hostname matches expected_host (www.-tolerant).""" + if not by_url or not expected_host: + return by_url or {} + return {u: v for u, v in by_url.items() if _hosts_match(_url_hostname(u), expected_host)} + + +def _derive_expected_host(start_url: str, df) -> str: + host = _url_hostname(start_url) + if host: + return host + if df is not None and not df.empty and "url" in df.columns: + for u in df["url"]: + h = _url_hostname(str(u)) + if h: + return h + return "" + + +def _pick_lighthouse_summary( + lighthouse_by_url: dict[str, Any], + start_url: str, + global_summary: Optional[dict[str, Any]], + expected_host: str, +) -> Optional[dict[str, Any]]: + """Prefer per-URL summary for this crawl; only use global summary if hostname matches.""" + if lighthouse_by_url and start_url: + match = lighthouse_for_url(lighthouse_by_url, start_url) + if match: + return match + if lighthouse_by_url: + first_key = next(iter(lighthouse_by_url), None) + if first_key is not None: + return lighthouse_by_url[first_key] + if global_summary: + if not expected_host or _hosts_match( + _url_hostname(str(global_summary.get("url") or "")), expected_host + ): + return global_summary + return None + + +def build_lighthouse_by_url_for_report(conn: Any) -> dict[str, Any]: + """ + Merge per-URL Lighthouse page summaries with latest lighthouse_runs row: full audits/items + from normalized tables, uncapped top_failures and diagnostics from stored LHR JSON. + """ + from ..db import ( + read_lh_audits_with_items, + read_lh_runs_by_url, + read_lighthouse_page_summaries, + read_lighthouse_run_json, + ) + from ..lighthouse.runner import _evidence_from_audit, extract_from_lighthouse_json + from ..tools.warnings import parse_lighthouse_to_diagnostics, resolve_impact + + summaries = read_lighthouse_page_summaries(conn) + runs_map = read_lh_runs_by_url(conn) + + summaries_norm: dict[str, Any] = {} + for k, v in summaries.items(): + nk = str(k).strip().rstrip("/") + summaries_norm[nk] = v + + all_urls = set(summaries_norm.keys()) | set(runs_map.keys()) + out: dict[str, Any] = {} + + for u in sorted(all_urls): + base: dict[str, Any] = dict(summaries_norm[u]) if u in summaries_norm else {} + run_ids = runs_map.get(u, []) + run_id = run_ids[-1] if run_ids else None + + if run_id is not None: + raw = read_lighthouse_run_json(conn, run_id) + if not base and raw: + ex = extract_from_lighthouse_json(raw) + lr = raw.get("lighthouseResult") or raw + final_u = lr.get("finalUrl") or lr.get("requestedUrl") or u + base = { + "url": str(final_u).strip().rstrip("/"), + "median_metrics": { + "lcp_ms": ex.get("lcp_ms"), + "cls": ex.get("cls"), + "tbt_ms": ex.get("tbt_ms"), + "fcp_ms": ex.get("fcp_ms"), + "speed_index_ms": ex.get("speed_index_ms"), + "performance_score": ex.get("performance_score"), + "accessibility_score": ex.get("accessibility_score"), + "seo_score": ex.get("seo_score"), + "best_practices_score": ex.get("best_practices_score"), + "pwa_score": ex.get("pwa_score"), + }, + "category_scores": dict(ex.get("category_scores") or {}), + "strategy": "mobile", + "device": "mobile", + "mode": "navigation", + } + base["audits"] = read_lh_audits_with_items(conn, run_id) + if raw: + lr = raw.get("lighthouseResult") or raw + audits_map = lr.get("audits") or {} + failures: list[dict[str, Any]] = [] + for aid, a in audits_map.items(): + if not isinstance(a, dict): + continue + score = a.get("score") + if score is None or score >= 1: + continue + title = a.get("title") or aid + help_text = a.get("helpText") or "" + failures.append( + { + "id": aid, + "score": score, + "helpText": help_text, + "impact": resolve_impact(aid, title, help_text), + "evidence": _evidence_from_audit(a), + } + ) + failures.sort(key=lambda x: (x["score"] or 0)) + base["top_failures"] = failures + base["diagnostics"] = parse_lighthouse_to_diagnostics(raw, max_nodes_in_refs=None) + elif not base: + continue + + if not base.get("url"): + base["url"] = u + out[u] = base + + return out + + +def lighthouse_for_url(lighthouse_by_url: dict[str, Any], url: str) -> Optional[dict[str, Any]]: + """Resolve Lighthouse summary for a crawled URL (trailing-slash tolerant).""" + if not lighthouse_by_url or not url: + return None + u = str(url).strip().rstrip("/") + if u in lighthouse_by_url: + return lighthouse_by_url[u] + for k, v in lighthouse_by_url.items(): + if str(k).strip().rstrip("/") == u: + return v + return None diff --git a/src/website_profiling/reporting/report_metadata.py b/src/website_profiling/reporting/report_metadata.py new file mode 100644 index 00000000..e55ac80b --- /dev/null +++ b/src/website_profiling/reporting/report_metadata.py @@ -0,0 +1,239 @@ +"""Report metadata and URL-level aggregates.""" +from __future__ import annotations + +import hashlib +import json +from datetime import datetime, timezone +from typing import Any, Optional +from urllib.parse import urlparse + +import pandas as pd + +from ..common import parse_links_serialized +from ..config import get_bool, get_int + +def _parse_page_analysis_cell(raw: object) -> dict[str, Any]: + if raw is None or (isinstance(raw, float) and pd.isna(raw)): + return {} + s = str(raw).strip() + if not s or s == "{}": + return {} + try: + o = json.loads(s) + return o if isinstance(o, dict) else {} + except json.JSONDecodeError: + return {} + + +def _build_outbound_link_domains( + df: pd.DataFrame, + start_url: str, + max_rows: int, +) -> list[dict[str, Any]]: + """Aggregate external hosts linked from crawled pages (outbound), not referring domains.""" + site_host = urlparse((start_url or "").strip()).netloc.lower() + host_pages: dict[str, set[str]] = {} + host_link_count: dict[str, int] = {} + for _, row in df.iterrows(): + st = str(row.get("status", "")).strip() + if st.startswith(("4", "5")): + continue + u = str(row.get("url") or "").strip().rstrip("/") + if not u: + continue + seen_on_page: set[str] = set() + pa = _parse_page_analysis_cell(row.get("page_analysis")) if "page_analysis" in df.columns else {} + for link in pa.get("external_links") or []: + if not isinstance(link, str): + continue + h = urlparse(link).netloc.lower() + if not h or h == site_host: + continue + host_pages.setdefault(h, set()).add(u) + host_link_count[h] = host_link_count.get(h, 0) + 1 + seen_on_page.add(link) + if "outlink_targets" in df.columns: + for link in parse_links_serialized(row.get("outlink_targets")): + h = urlparse(link).netloc.lower() + if not h or h == site_host: + continue + host_pages.setdefault(h, set()).add(u) + if link not in seen_on_page: + host_link_count[h] = host_link_count.get(h, 0) + 1 + seen_on_page.add(link) + rows: list[dict[str, Any]] = [] + for h in host_pages: + rows.append({ + "host": h, + "page_count": len(host_pages[h]), + "link_count": host_link_count.get(h, 0), + }) + rows.sort(key=lambda x: (-x["link_count"], -x["page_count"], x["host"])) + return rows[:max_rows] + + +def _build_url_fingerprints(df: pd.DataFrame) -> list[dict[str, Any]]: + """Stable fingerprints for comparing page content/structure between report runs (no raw HTML stored).""" + out: list[dict[str, Any]] = [] + for _, row in df.iterrows(): + u = str(row.get("url") or "").strip().rstrip("/") + if not u: + continue + title = str(row.get("title") or "") + meta = str(row.get("meta_description") or "") + h1 = str(row.get("h1") or "") + headings = str(row.get("heading_sequence") or "") + wc = int(pd.to_numeric(row.get("word_count"), errors="coerce") or 0) + cl = int(pd.to_numeric(row.get("content_length"), errors="coerce") or 0) + h1c = int(pd.to_numeric(row.get("h1_count"), errors="coerce") or 0) + sc = int(pd.to_numeric(row.get("script_count"), errors="coerce") or 0) + lc = int(pd.to_numeric(row.get("link_stylesheet_count"), errors="coerce") or 0) + # heading_sequence is structural (h1,h2,...) — keep it in structure fingerprint only. + raw_c = "|".join([title, meta, h1, str(wc), str(cl)]).encode("utf-8") + content_fp = hashlib.sha256(raw_c).hexdigest() + raw_s = "|".join([str(cl), str(sc), str(lc), str(h1c), headings]).encode("utf-8") + structure_fp = hashlib.sha256(raw_s).hexdigest() + out.append({ + "url": u, + "content_fingerprint": content_fp, + "structure_fingerprint": structure_fp, + }) + return out + + +def _build_hreflang_summary(df: pd.DataFrame) -> dict[str, Any]: + total = 0 + missing_lang = 0 + with_hreflang = 0 + for _, row in df.iterrows(): + st = str(row.get("status", "")).strip() + if not st.startswith("2"): + continue + total += 1 + pa = _parse_page_analysis_cell(row.get("page_analysis")) if "page_analysis" in df.columns else {} + if not (pa.get("html_lang") or "").strip(): + missing_lang += 1 + if pa.get("hreflang_alternates"): + with_hreflang += 1 + return { + "pages_200": total, + "pages_missing_html_lang": missing_lang, + "pages_with_hreflang_links": with_hreflang, + } + + +def _validate_report_url_counts(report_data: dict[str, Any], df_row_count: int) -> None: + """Ensure crawled URL counts are consistent across report payload fields.""" + links = report_data.get("links") or [] + summary = report_data.get("summary") or {} + scope = (report_data.get("report_meta") or {}).get("crawl_scope") or {} + link_count = len(links) if isinstance(links, list) else 0 + total_urls = int(summary.get("total_urls") or 0) + pages_crawled = int(scope.get("pages_crawled") or 0) + counts = {link_count, total_urls, pages_crawled, df_row_count} + if len(counts) > 1: + msg = ( + f"report count mismatch: links={link_count}, " + f"summary.total_urls={total_urls}, " + f"pages_crawled={pages_crawled}, df_rows={df_row_count}" + ) + print(f" WARNING: {msg}", flush=True) + report_data.setdefault("ml_errors", []).append(msg) + + +def _build_report_metadata( + df: pd.DataFrame, + config: Optional[dict[str, str]], + lighthouse_summary: Optional[dict[str, Any]], + google_data: Optional[dict[str, Any]], + keywords_data: Optional[dict[str, Any]], + ml_bundle: dict[str, Any], + run_id: Optional[int], + crawl_run_created_at: Optional[str], + gsc_links_data: Optional[dict[str, Any]] = None, +) -> dict[str, Any]: + """Provenance and crawl scope for agency-facing audits.""" + sources: list[str] = ["crawl"] + if lighthouse_summary: + sources.append("lighthouse") + if google_data: + if google_data.get("gsc") or google_data.get("gsc_summary"): + sources.append("search_console") + if google_data.get("ga4") or google_data.get("ga4_summary"): + sources.append("analytics") + if gsc_links_data and "search_console" not in sources: + sources.append("search_console") + llm_meta = ml_bundle.get("llm_meta") + if isinstance(llm_meta, dict) and llm_meta.get("model"): + sources.append("ai") + kw_rows = (keywords_data or {}).get("rows") or [] + has_gsc_kw = any( + (r.get("gsc_impressions") or r.get("gsc_clicks")) and r.get("source") in ("gsc", "site+gsc", None) + for r in kw_rows[:500] + if isinstance(r, dict) + ) + if kw_rows and not has_gsc_kw and "estimated" not in sources: + sources.append("estimated") + + max_pages_cfg = get_int(config or {}, "max_pages", 0) or 0 + pages_crawled = len(df) + blocked = 0 + if not df.empty and "status" in df.columns: + blocked = int((df["status"].astype(str) == "blocked_by_robots").sum()) + + render_mode = (str((config or {}).get("crawl_render_mode") or "static")).strip().lower() + js_concurrency = get_int(config or {}, "crawl_js_concurrency", 3) or 3 + static_html_only = render_mode == "static" + + crawl_scope: dict[str, Any] = { + "pages_crawled": pages_crawled, + "max_pages_configured": max_pages_cfg or pages_crawled, + "robots_blocked_count": blocked, + "static_html_only": static_html_only, + "render_mode": render_mode, + "js_concurrency": js_concurrency if not static_html_only else None, + "crawl_limited": bool(max_pages_cfg and pages_crawled >= max_pages_cfg), + } + if not df.empty and "fetch_method" in df.columns: + fm = df["fetch_method"].astype(str).str.strip().str.lower() + pages_static = int((fm == "static").sum()) + pages_rendered = int((fm == "rendered").sum()) + if render_mode == "auto" or pages_rendered > 0: + crawl_scope["pages_static"] = pages_static + crawl_scope["pages_rendered"] = pages_rendered + + from ..crawl.fetchers.browser_diagnostics import aggregate_browser_diagnostics_df + + browser_agg = aggregate_browser_diagnostics_df(df) + if browser_agg and (render_mode != "static" or browser_agg.get("total_console_errors", 0) > 0): + crawl_scope["browser_diagnostics"] = browser_agg + + meta: dict[str, Any] = { + "data_sources": sources, + "generated_at": datetime.now(timezone.utc).isoformat(), + "crawl_scope": crawl_scope, + } + if run_id is not None: + meta["crawl_run_id"] = run_id + if crawl_run_created_at: + meta["crawl_run_created_at"] = crawl_run_created_at + if google_data: + meta["google_fetched_at"] = google_data.get("fetched_at") + meta["google_date_range_days"] = google_data.get("date_range_days") + gsc = google_data.get("gsc") or {} + if isinstance(gsc, dict) and gsc.get("row_count") is not None: + meta["gsc_row_count"] = gsc.get("row_count") + if keywords_data: + meta["keywords_enriched_at"] = keywords_data.get("enriched_at") or keywords_data.get("fetched_at") + if gsc_links_data: + meta["gsc_links_imported_at"] = gsc_links_data.get("imported_at") + meta["gsc_links_referring_domains"] = len(gsc_links_data.get("top_linking_sites") or []) + sample_n = len(gsc_links_data.get("sample_links") or []) + latest_n = len(gsc_links_data.get("latest_links") or []) + meta["gsc_links_sample_count"] = sample_n + latest_n + if isinstance(llm_meta, dict): + meta["llm"] = llm_meta + logo_url = (str((config or {}).get("export_logo_url") or "")).strip() + if logo_url: + meta["export_logo_url"] = logo_url + return meta diff --git a/src/website_profiling/reporting/seo_summary.py b/src/website_profiling/reporting/seo_summary.py new file mode 100644 index 00000000..bd49de98 --- /dev/null +++ b/src/website_profiling/reporting/seo_summary.py @@ -0,0 +1,166 @@ +"""SEO summary and issue computation for reports.""" +from __future__ import annotations + +import pandas as pd + +# SEO thresholds for recommendations +TITLE_LEN_MIN = 30 +TITLE_LEN_MAX = 60 +META_DESC_LEN_MIN = 70 +META_DESC_LEN_MAX = 160 +THIN_CONTENT_CHARS = 300 + +def _compute_summary_seo_issues(df: pd.DataFrame) -> dict: + """Compute crawl summary, SEO health metrics, issues list, and recommendations from crawl DataFrame.""" + total = len(df) + status_str = df["status"].astype(str) if "status" in df.columns else pd.Series(["unknown"] * len(df)) + count_2xx = int((status_str.str.match(r"2\d{2}").fillna(False)).sum()) + count_3xx = int((status_str.str.match(r"3\d{2}").fillna(False)).sum()) + count_4xx = int((status_str.str.match(r"4\d{2}").fillna(False)).sum()) + count_5xx = int((status_str.str.match(r"5\d{2}").fillna(False)).sum()) + count_error = int((status_str.isin(["error", "blocked_by_robots"])).sum()) + success_rate = round(100 * count_2xx / total, 1) if total else 0 + + outlinks = ( + pd.to_numeric(df["outlinks"], errors="coerce").fillna(0).astype(int) + if "outlinks" in df.columns + else pd.Series([0] * len(df)) + ) + title_len = ( + df["title"].fillna("").astype(str).apply(len) + if "title" in df.columns + else pd.Series([0] * len(df)) + ) + crawl_time_s = float(df["crawl_time_s"].iloc[0]) if "crawl_time_s" in df.columns and len(df) else None + + summary = { + "total_urls": total, + "count_2xx": count_2xx, + "count_3xx": count_3xx, + "count_4xx": count_4xx, + "count_5xx": count_5xx, + "count_error": count_error, + "success_rate": success_rate, + "avg_outlinks": round(float(outlinks.mean()), 1) if total else 0, + "avg_title_len": round(float(title_len.mean()), 1) if total else 0, + "crawl_time_s": round(crawl_time_s, 1) if crawl_time_s is not None else None, + } + + # SEO health (when columns exist) + seo_health = {} + if "title" in df.columns: + titles = df["title"].fillna("").astype(str) + seo_health["missing_title"] = int((titles.str.len() == 0).sum()) + seo_health["title_short"] = int(((title_len > 0) & (title_len < TITLE_LEN_MIN)).sum()) + seo_health["title_long"] = int((title_len > TITLE_LEN_MAX).sum()) + seo_health["title_ok"] = int(((title_len >= TITLE_LEN_MIN) & (title_len <= TITLE_LEN_MAX)).sum()) + if "meta_description_len" in df.columns: + md_len = pd.to_numeric(df["meta_description_len"], errors="coerce").fillna(0).astype(int) + seo_health["missing_meta_desc"] = int((md_len == 0).sum()) + seo_health["meta_desc_short"] = int(((md_len > 0) & (md_len < META_DESC_LEN_MIN)).sum()) + seo_health["meta_desc_long"] = int((md_len > META_DESC_LEN_MAX).sum()) + seo_health["meta_desc_ok"] = int(((md_len >= META_DESC_LEN_MIN) & (md_len <= META_DESC_LEN_MAX)).sum()) + if "h1_count" in df.columns: + h1c = pd.to_numeric(df["h1_count"], errors="coerce").fillna(-1).astype(int) + seo_health["h1_zero"] = int((h1c == 0).sum()) + seo_health["h1_one"] = int((h1c == 1).sum()) + seo_health["h1_multi"] = int((h1c > 1).sum()) + if "content_length" in df.columns: + cl = pd.to_numeric(df["content_length"], errors="coerce").fillna(0).astype(int) + seo_health["thin_content"] = int(((cl > 0) & (cl < THIN_CONTENT_CHARS)).sum()) + + # Issues: broken, redirects, SEO + issues = {"broken": [], "redirects": [], "seo": []} + for _, row in df.iterrows(): + u = row.get("url") + if pd.isna(u) or not u: + continue + u = str(u).strip() + st = str(row.get("status", "")).strip() + if st.startswith("4") or st.startswith("5") or st in ("error", "blocked_by_robots"): + issues["broken"].append({"url": u, "status": st}) + elif st.startswith("3"): + final = row.get("final_url") or "" + issues["redirects"].append({"url": u, "status": st, "final_url": str(final) if pd.notna(final) else ""}) + + if "title" in df.columns: + for _, row in df.iterrows(): + u = row.get("url") + if pd.isna(u): + continue + u = str(u).strip() + t = row.get("title") or "" + tl = len(str(t).strip()) + if tl == 0: + issues["seo"].append({"type": "missing_title", "url": u, "message": "Missing title"}) + elif tl < TITLE_LEN_MIN: + issues["seo"].append({"type": "title_short", "url": u, "message": f"Title too short ({tl} chars)"}) + elif tl > TITLE_LEN_MAX: + issues["seo"].append({"type": "title_long", "url": u, "message": f"Title too long ({tl} chars)"}) + if "meta_description_len" in df.columns: + for _, row in df.iterrows(): + md_len = pd.to_numeric(row.get("meta_description_len"), errors="coerce") + if pd.isna(md_len) or md_len == 0: + continue + u = row.get("url") + if pd.isna(u): + continue + u = str(u).strip() + ml = int(md_len) + if ml < META_DESC_LEN_MIN: + issues["seo"].append({"type": "meta_desc_short", "url": u, "message": f"Meta description too short ({ml} chars)"}) + elif ml > META_DESC_LEN_MAX: + issues["seo"].append({"type": "meta_desc_long", "url": u, "message": f"Meta description too long ({ml} chars)"}) + if "h1_count" in df.columns: + for _, row in df.iterrows(): + h1c = pd.to_numeric(row.get("h1_count"), errors="coerce") + if pd.isna(h1c) or h1c == 1: + continue + u = row.get("url") + if pd.isna(u): + continue + u = str(u).strip() + if int(h1c) == 0: + issues["seo"].append({"type": "h1_missing", "url": u, "message": "Missing H1"}) + else: + issues["seo"].append({"type": "h1_multi", "url": u, "message": f"Multiple H1s ({int(h1c)})"}) + if "content_length" in df.columns: + for _, row in df.iterrows(): + cl = pd.to_numeric(row.get("content_length"), errors="coerce") + cl = 0 if pd.isna(cl) else int(cl) + if cl >= THIN_CONTENT_CHARS or cl == 0: + continue + u = row.get("url") + if pd.isna(u): + continue + issues["seo"].append({"type": "thin_content", "url": str(u).strip(), "message": f"Thin content ({int(cl)} chars)"}) + + # Recommendations (actionable bullets) + recommendations = [] + if issues["broken"]: + recommendations.append(f"Fix {len(issues['broken'])} broken or error URL(s).") + if issues["redirects"]: + recommendations.append(f"Review {len(issues['redirects'])} redirect(s); consolidate if possible.") + if seo_health.get("missing_title", 0) > 0: + recommendations.append(f"Add titles to {seo_health['missing_title']} page(s).") + if seo_health.get("title_short", 0) + seo_health.get("title_long", 0) > 0: + n = seo_health.get("title_short", 0) + seo_health.get("title_long", 0) + recommendations.append(f"Optimize title length on {n} page(s) (aim 30–60 chars).") + if seo_health.get("missing_meta_desc", 0) > 0: + recommendations.append(f"Add meta descriptions to {seo_health['missing_meta_desc']} page(s).") + if seo_health.get("meta_desc_short", 0) + seo_health.get("meta_desc_long", 0) > 0: + n = seo_health.get("meta_desc_short", 0) + seo_health.get("meta_desc_long", 0) + recommendations.append(f"Optimize meta description length on {n} page(s) (aim 70–160 chars).") + if seo_health.get("h1_zero", 0) > 0: + recommendations.append(f"Add one H1 per page on {seo_health['h1_zero']} page(s).") + if seo_health.get("h1_multi", 0) > 0: + recommendations.append(f"Use a single H1 per page on {seo_health['h1_multi']} page(s).") + if seo_health.get("thin_content", 0) > 0: + recommendations.append(f"Expand thin content on {seo_health['thin_content']} page(s) (under {THIN_CONTENT_CHARS} chars).") + + return { + "summary": summary, + "seo_health": seo_health, + "issues": issues, + "recommendations": recommendations, + } diff --git a/src/website_profiling/reporting/site_level.py b/src/website_profiling/reporting/site_level.py new file mode 100644 index 00000000..0c398334 --- /dev/null +++ b/src/website_profiling/reporting/site_level.py @@ -0,0 +1,49 @@ +"""Site-level file checks (robots, sitemap, ads.txt).""" +from __future__ import annotations + +from typing import Any +from urllib.parse import urlparse + +import requests + +def _fetch_site_level(start_url: str, timeout: int = 8) -> dict: + """Fetch robots.txt, sitemap.xml, ads.txt, and security.txt from start_url origin.""" + from .site_files import fetch_ads_txt, fetch_security_txt, merge_site_file_fields + + parsed = urlparse(start_url) + if not parsed.scheme or not parsed.netloc: + return { + "robots_present": False, + "sitemap_present": False, + "sitemap_valid": False, + "ads_txt_present": False, + "security_txt_present": False, + } + base = f"{parsed.scheme}://{parsed.netloc}" + session = requests.Session() + session.headers.update({"User-Agent": "WebsiteProfiling/1.0"}) + out: dict[str, Any] = { + "robots_present": False, + "sitemap_present": False, + "sitemap_valid": False, + } + try: + r = session.get(f"{base}/robots.txt", timeout=timeout) + if r.status_code == 200 and r.text: + out["robots_present"] = True + for line in r.text.splitlines(): + line = line.strip() + if line.lower().startswith("sitemap:"): + break + except Exception: + pass + try: + r = session.get(f"{base}/sitemap.xml", timeout=timeout) + if r.status_code == 200 and r.text: + out["sitemap_present"] = True + out["sitemap_valid"] = "<" in r.text and ">" in r.text and ("urlset" in r.text or "sitemapindex" in r.text) + except Exception: + pass + merge_site_file_fields(out, fetch_ads_txt(session, base, timeout=timeout)) + merge_site_file_fields(out, fetch_security_txt(session, base, timeout=timeout)) + return out diff --git a/src/website_profiling/tools/audit_tools/backlink_lists.py b/src/website_profiling/tools/audit_tools/backlink_lists.py new file mode 100644 index 00000000..de3db319 --- /dev/null +++ b/src/website_profiling/tools/audit_tools/backlink_lists.py @@ -0,0 +1,133 @@ +"""Backlink list tools from GSC Links import data.""" +from __future__ import annotations + +from collections import Counter +from typing import Any +from urllib.parse import urlparse + +from psycopg import Connection + +from ._slice import cap_list, parse_limit +from .context import AuditToolContext + + +def _load_links(scoped: AuditToolContext, conn: Connection) -> dict[str, Any] | None: + if scoped.property_id is None: + return None + return scoped.load_gsc_links(conn) + + +def _all_link_rows(data: dict[str, Any]) -> list[dict[str, Any]]: + rows: list[dict[str, Any]] = [] + for key in ("sample_links", "latest_links"): + chunk = data.get(key) or [] + if isinstance(chunk, list): + rows.extend([r for r in chunk if isinstance(r, dict)]) + return rows + + +def _norm_domain(url: str) -> str: + try: + host = urlparse(str(url or "")).netloc.lower() + return host[4:] if host.startswith("www.") else host + except Exception: + return str(url or "").lower() + + +def list_referring_domains(conn: Connection, ctx: AuditToolContext, args: dict[str, Any]) -> dict[str, Any]: + scoped = ctx.with_args(args) + if scoped.property_id is None: + return {"error": "property_id is required", "domains": [], "total": 0, "truncated": False} + data = _load_links(scoped, conn) + if not data: + return {"error": "no GSC links data", "missing": True, "domains": [], "total": 0, "truncated": False} + domains = list(data.get("top_linking_sites") or []) + if not domains: + counts: Counter[str] = Counter() + for row in _all_link_rows(data): + site = row.get("linking_site") or _norm_domain(str(row.get("source_page") or "")) + if site: + counts[site] += 1 + domains = [{"site": s, "link_count": c} for s, c in counts.most_common()] + limit = parse_limit(args.get("limit"), 30, 50) + sliced = cap_list(domains, limit, max_cap=50) + return {"domains": sliced["items"], "total": sliced["total"], "truncated": sliced["truncated"]} + + +def list_backlinks_by_anchor_text(conn: Connection, ctx: AuditToolContext, args: dict[str, Any]) -> dict[str, Any]: + scoped = ctx.with_args(args) + if scoped.property_id is None: + return {"error": "property_id is required", "links": [], "total": 0, "truncated": False} + data = _load_links(scoped, conn) + if not data: + return {"error": "no GSC links data", "missing": True, "links": [], "total": 0, "truncated": False} + anchor = str(args.get("anchor_text") or args.get("anchor") or "").strip().lower() + rows = _all_link_rows(data) + if anchor: + rows = [r for r in rows if anchor in str(r.get("anchor_text") or "").lower()] + limit = parse_limit(args.get("limit"), 30, 100) + sliced = cap_list(rows, limit, max_cap=100) + return {"links": sliced["items"], "total": sliced["total"], "truncated": sliced["truncated"]} + + +def list_backlinks_to_url(conn: Connection, ctx: AuditToolContext, args: dict[str, Any]) -> dict[str, Any]: + scoped = ctx.with_args(args) + if scoped.property_id is None: + return {"error": "property_id is required", "links": [], "total": 0, "truncated": False} + target = str(args.get("url") or args.get("target_page") or "").strip().lower().rstrip("/") + if not target: + return {"error": "url is required", "links": [], "total": 0, "truncated": False} + data = _load_links(scoped, conn) + if not data: + return {"error": "no GSC links data", "missing": True, "links": [], "total": 0, "truncated": False} + rows = _all_link_rows(data) + matched = [ + r for r in rows + if target in str(r.get("target_page") or "").lower().rstrip("/") + or target in str(r.get("target_url_on_linking_page") or "").lower().rstrip("/") + ] + limit = parse_limit(args.get("limit"), 30, 100) + sliced = cap_list(matched, limit, max_cap=100) + return {"url": target, "links": sliced["items"], "total": sliced["total"], "truncated": sliced["truncated"]} + + +def list_backlinks_from_domain(conn: Connection, ctx: AuditToolContext, args: dict[str, Any]) -> dict[str, Any]: + scoped = ctx.with_args(args) + if scoped.property_id is None: + return {"error": "property_id is required", "links": [], "total": 0, "truncated": False} + domain = str(args.get("domain") or args.get("linking_site") or "").strip().lower().lstrip("www.") + if not domain: + return {"error": "domain is required", "links": [], "total": 0, "truncated": False} + data = _load_links(scoped, conn) + if not data: + return {"error": "no GSC links data", "missing": True, "links": [], "total": 0, "truncated": False} + rows = _all_link_rows(data) + matched = [ + r for r in rows + if domain in str(r.get("linking_site") or _norm_domain(str(r.get("source_page") or ""))) + ] + limit = parse_limit(args.get("limit"), 30, 100) + sliced = cap_list(matched, limit, max_cap=100) + return {"domain": domain, "links": sliced["items"], "total": sliced["total"], "truncated": sliced["truncated"]} + + +def get_anchor_text_distribution(conn: Connection, ctx: AuditToolContext, args: dict[str, Any]) -> dict[str, Any]: + scoped = ctx.with_args(args) + if scoped.property_id is None: + return {"error": "property_id is required", "missing": True} + data = _load_links(scoped, conn) + if not data: + return {"error": "no GSC links data", "missing": True, "anchors": []} + top_text = data.get("top_linking_text") or [] + if isinstance(top_text, list) and top_text: + limit = parse_limit(args.get("limit"), 30, 100) + sliced = cap_list(top_text, limit, max_cap=100) + return {"anchors": sliced["items"], "total": sliced["total"], "truncated": sliced["truncated"], "source": "top_linking_text"} + counts: Counter[str] = Counter() + for row in _all_link_rows(data): + text = str(row.get("anchor_text") or "").strip() or "(empty)" + counts[text] += 1 + anchors = [{"anchor_text": t, "link_count": c} for t, c in counts.most_common()] + limit = parse_limit(args.get("limit"), 30, 100) + sliced = cap_list(anchors, limit, max_cap=100) + return {"anchors": sliced["items"], "total": sliced["total"], "truncated": sliced["truncated"], "source": "sample_links"} diff --git a/src/website_profiling/tools/audit_tools/compare_list_tools.py b/src/website_profiling/tools/audit_tools/compare_list_tools.py new file mode 100644 index 00000000..b95492f6 --- /dev/null +++ b/src/website_profiling/tools/audit_tools/compare_list_tools.py @@ -0,0 +1,174 @@ +"""Report compare list tools using compare_helpers and compare_payload builders.""" +from __future__ import annotations + +from typing import Any + +from psycopg import Connection + +from ...reporting.compare_payload import ( + build_issue_deltas, + build_lighthouse_url_deltas, + build_url_set_diff, +) +from ._slice import cap_list, parse_limit +from .compare_helpers import load_compare_pair +from .context import AuditToolContext +from .google_lists import _gsc_rows, _index_gsc_rows, _num + + +def _compare_meta(current_rid: int | None, baseline_rid: int | None, current: dict, baseline: dict) -> dict[str, Any]: + return { + "current_report_id": current_rid, + "baseline_report_id": baseline_rid, + "current_generated_at": current.get("report_generated_at"), + "baseline_generated_at": baseline.get("report_generated_at"), + } + + +def list_compare_new_issues(conn: Connection, ctx: AuditToolContext, args: dict[str, Any]) -> dict[str, Any]: + current, baseline, cur_rid, base_rid, err = load_compare_pair(conn, ctx, args) + if err: + return {**err, "issues": [], "total": 0, "truncated": False} + assert current is not None and baseline is not None + deltas = [d for d in build_issue_deltas(current, baseline) if d.get("kind") == "new"] + limit = parse_limit(args.get("limit"), 50, 100) + sliced = cap_list(deltas, limit, max_cap=100) + return { + **_compare_meta(cur_rid, base_rid, current, baseline), + "issues": sliced["items"], + "total": sliced["total"], + "truncated": sliced["truncated"], + } + + +def list_compare_resolved_issues(conn: Connection, ctx: AuditToolContext, args: dict[str, Any]) -> dict[str, Any]: + current, baseline, cur_rid, base_rid, err = load_compare_pair(conn, ctx, args) + if err: + return {**err, "issues": [], "total": 0, "truncated": False} + assert current is not None and baseline is not None + deltas = [d for d in build_issue_deltas(current, baseline) if d.get("kind") == "resolved"] + limit = parse_limit(args.get("limit"), 50, 100) + sliced = cap_list(deltas, limit, max_cap=100) + return { + **_compare_meta(cur_rid, base_rid, current, baseline), + "issues": sliced["items"], + "total": sliced["total"], + "truncated": sliced["truncated"], + } + + +def list_compare_new_urls(conn: Connection, ctx: AuditToolContext, args: dict[str, Any]) -> dict[str, Any]: + current, baseline, cur_rid, base_rid, err = load_compare_pair(conn, ctx, args) + if err: + return {**err, "urls": [], "total": 0, "truncated": False} + assert current is not None and baseline is not None + diff = build_url_set_diff(current, baseline) + new_urls = diff.get("new_urls") or [] + limit = parse_limit(args.get("limit"), 50, 200) + sliced = cap_list(new_urls, limit, max_cap=200) + return { + **_compare_meta(cur_rid, base_rid, current, baseline), + "urls": sliced["items"], + "total": diff.get("new_count", sliced["total"]), + "truncated": sliced["truncated"], + } + + +def list_compare_removed_urls(conn: Connection, ctx: AuditToolContext, args: dict[str, Any]) -> dict[str, Any]: + current, baseline, cur_rid, base_rid, err = load_compare_pair(conn, ctx, args) + if err: + return {**err, "urls": [], "total": 0, "truncated": False} + assert current is not None and baseline is not None + diff = build_url_set_diff(current, baseline) + removed_urls = diff.get("removed_urls") or [] + limit = parse_limit(args.get("limit"), 50, 200) + sliced = cap_list(removed_urls, limit, max_cap=200) + return { + **_compare_meta(cur_rid, base_rid, current, baseline), + "urls": sliced["items"], + "total": diff.get("removed_count", sliced["total"]), + "truncated": sliced["truncated"], + } + + +def list_compare_lighthouse_regressions(conn: Connection, ctx: AuditToolContext, args: dict[str, Any]) -> dict[str, Any]: + current, baseline, cur_rid, base_rid, err = load_compare_pair(conn, ctx, args) + if err: + return {**err, "pages": [], "total": 0, "truncated": False} + assert current is not None and baseline is not None + try: + min_drop = float(args.get("min_regression") or 5) + except (TypeError, ValueError): + min_drop = 5.0 + deltas = build_lighthouse_url_deltas(current, baseline) + regressions: list[dict[str, Any]] = [] + for row in deltas: + perf_delta = row.get("performance_delta") + seo_delta = row.get("seo_delta") + perf_drop = perf_delta is not None and perf_delta <= -min_drop + seo_drop = seo_delta is not None and seo_delta <= -min_drop + if perf_drop or seo_drop: + regressions.append({**row, "regression_type": "performance" if perf_drop else "seo"}) + regressions.sort(key=lambda r: min(r.get("performance_delta") or 0, r.get("seo_delta") or 0)) + limit = parse_limit(args.get("limit"), 30, 50) + sliced = cap_list(regressions, limit, max_cap=50) + return { + **_compare_meta(cur_rid, base_rid, current, baseline), + "pages": sliced["items"], + "total": sliced["total"], + "truncated": sliced["truncated"], + "min_regression": min_drop, + } + + +def list_compare_traffic_losers(conn: Connection, ctx: AuditToolContext, args: dict[str, Any]) -> dict[str, Any]: + scoped = ctx.with_args(args) + current, baseline, cur_rid, base_rid, err = load_compare_pair(conn, ctx, args) + if err: + return {**err, "pages": [], "total": 0, "truncated": False} + assert current is not None and baseline is not None + + cur_google = current.get("google") if isinstance(current.get("google"), dict) else None + base_google = baseline.get("google") if isinstance(baseline.get("google"), dict) else None + if not cur_google: + cur_google = scoped.load_google_full(conn) or scoped.load_google(conn) + if not cur_google or not base_google: + return { + **_compare_meta(cur_rid, base_rid, current, baseline), + "error": "google data missing on current or baseline report", + "missing": True, + "pages": [], + "total": 0, + "truncated": False, + } + cur_pages = _index_gsc_rows(_gsc_rows(cur_google, "pages"), ("page", "url")) + base_pages = _index_gsc_rows(_gsc_rows(base_google, "pages"), ("page", "url")) + + losers: list[dict[str, Any]] = [] + for key, cur_row in cur_pages.items(): + base_row = base_pages.get(key) + if not base_row: + continue + cur_clicks = _num(cur_row.get("clicks")) + base_clicks = _num(base_row.get("clicks")) + delta = cur_clicks - base_clicks + if delta >= 0: + continue + url = str(cur_row.get("page") or cur_row.get("url") or key) + losers.append({ + "url": url, + "clicks_current": cur_clicks, + "clicks_baseline": base_clicks, + "click_delta": delta, + "impressions_current": _num(cur_row.get("impressions")), + "impressions_baseline": _num(base_row.get("impressions")), + }) + losers.sort(key=lambda r: r.get("click_delta", 0)) + limit = parse_limit(args.get("limit"), 30, 50) + sliced = cap_list(losers, limit, max_cap=50) + return { + **_compare_meta(cur_rid, base_rid, current, baseline), + "pages": sliced["items"], + "total": sliced["total"], + "truncated": sliced["truncated"], + } diff --git a/src/website_profiling/tools/audit_tools/content_lists.py b/src/website_profiling/tools/audit_tools/content_lists.py new file mode 100644 index 00000000..3fdf7ff0 --- /dev/null +++ b/src/website_profiling/tools/audit_tools/content_lists.py @@ -0,0 +1,249 @@ +"""Content quality, optional audit, and schema list tools.""" +from __future__ import annotations + +import re +from typing import Any + +from psycopg import Connection + +from ._slice import _parse_page_analysis, _row_schema_types_list, cap_list, parse_limit, payload_dict_slice +from .context import AuditToolContext + +_ARTICLE_TYPES = frozenset({"article", "newsarticle", "blogposting", "scholarlyarticle"}) +_ARTICLE_URL_HINTS = ("/blog/", "/news/", "/article/", "/post/", "/posts/") + + +def _optional_audit_urls( + conn: Connection, + ctx: AuditToolContext, + args: dict[str, Any], + audit_type: str, + *, + item_key: str = "issues", +) -> dict[str, Any]: + scoped = ctx.with_args(args) + payload = scoped.load_payload(conn) + if not payload: + return {"error": "no report found", item_key: [], "total": 0, "truncated": False} + optional = payload.get("optional_audit_urls") if isinstance(payload.get("optional_audit_urls"), dict) else {} + items = optional.get(audit_type) or [] + if isinstance(items, list) and items: + limit = parse_limit(args.get("limit"), 30, 50) + sliced = cap_list(items, limit, max_cap=50) + return {item_key: sliced["items"], "total": sliced["total"], "truncated": sliced["truncated"]} + categories = payload.get("categories") or [] + needle = audit_type.replace("_", " ") + issues: list[dict[str, Any]] = [] + for cat in categories: + if not isinstance(cat, dict): + continue + for issue in cat.get("issues") or []: + if not isinstance(issue, dict): + continue + msg = str(issue.get("message") or "").lower() + if audit_type == "spell" and "spell" in msg: + issues.append(issue) + elif audit_type == "html" and ("html" in msg or "markup" in msg): + issues.append(issue) + elif audit_type == "amp" and "amp" in msg: + issues.append(issue) + elif audit_type == "pagination" and ("pagination" in msg or "rel=prev" in msg or "rel=next" in msg): + issues.append(issue) + elif needle in msg: + issues.append(issue) + if not issues: + return {"missing": True, item_key: [], "total": 0, "truncated": False, "note": f"enable optional {audit_type} audit in pipeline config"} + limit = parse_limit(args.get("limit"), 30, 50) + sliced = cap_list(issues, limit, max_cap=50) + return {item_key: sliced["items"], "total": sliced["total"], "truncated": sliced["truncated"]} + + +def get_text_content_analysis(conn: Connection, ctx: AuditToolContext, args: dict[str, Any]) -> dict[str, Any]: + scoped = ctx.with_args(args) + payload = scoped.load_payload(conn) + if not payload: + return {"error": "no report found", "missing": True} + result = payload_dict_slice(payload, "text_content_analysis") + if result.get("missing"): + content = payload.get("content_analytics") + if isinstance(content, dict) and content.get("keyword_index"): + return {"data": content, "missing": False, "note": "from content_analytics fallback"} + return result + + +def list_pages_containing_keyword(conn: Connection, ctx: AuditToolContext, args: dict[str, Any]) -> dict[str, Any]: + scoped = ctx.with_args(args) + keyword = str(args.get("keyword") or args.get("query") or "").strip().lower() + if not keyword: + return {"error": "keyword is required", "pages": [], "total": 0, "truncated": False} + payload = scoped.load_payload(conn) + if not payload: + return {"error": "no report found", "pages": [], "total": 0, "truncated": False} + tca = payload.get("text_content_analysis") if isinstance(payload.get("text_content_analysis"), dict) else {} + index = tca.get("keyword_index") or [] + pages: list[dict[str, Any]] = [] + if isinstance(index, list): + for entry in index: + if not isinstance(entry, dict): + continue + word = str(entry.get("word") or "").lower() + if keyword not in word and word not in keyword: + continue + for page in entry.get("top_pages") or []: + if isinstance(page, dict): + pages.append({"url": page.get("url"), "keyword": entry.get("word"), "count": page.get("count")}) + elif isinstance(page, (list, tuple)) and page: + pages.append({"url": page[0], "keyword": entry.get("word"), "count": page[1] if len(page) > 1 else 1}) + if not pages: + df = scoped.load_crawl_df(conn) + if df is not None and not df.empty: + for _, row in df.iterrows(): + rec = row.to_dict() + if not str(rec.get("status") or "").startswith("2"): + continue + text = " ".join([ + str(rec.get("title") or ""), + str(rec.get("h1") or ""), + str(rec.get("content_excerpt") or ""), + ]).lower() + if keyword in text: + pages.append({"url": str(rec.get("url") or ""), "keyword": keyword}) + limit = parse_limit(args.get("limit"), 30, 50) + sliced = cap_list(pages, limit, max_cap=50) + return {"keyword": keyword, "pages": sliced["items"], "total": sliced["total"], "truncated": sliced["truncated"]} + + +def list_pages_by_word_count_band(conn: Connection, ctx: AuditToolContext, args: dict[str, Any]) -> dict[str, Any]: + scoped = ctx.with_args(args) + try: + min_wc = int(args.get("min_word_count") or 0) + max_wc = int(args.get("max_word_count") or 10_000) + except (TypeError, ValueError): + min_wc, max_wc = 0, 10_000 + df = scoped.load_crawl_df(conn) + if df is None or df.empty: + return {"pages": [], "total": 0, "truncated": False, "missing": True} + pages: list[dict[str, Any]] = [] + for _, row in df.iterrows(): + rec = row.to_dict() + if not str(rec.get("status") or "").startswith("2"): + continue + try: + wc = int(rec.get("word_count") or 0) + except (TypeError, ValueError): + wc = 0 + if min_wc <= wc <= max_wc: + pages.append({"url": str(rec.get("url") or ""), "word_count": wc, "title": str(rec.get("title") or "")}) + pages.sort(key=lambda p: p.get("word_count", 0)) + limit = parse_limit(args.get("limit"), 30, 50) + sliced = cap_list(pages, limit, max_cap=50) + return { + "pages": sliced["items"], + "total": sliced["total"], + "truncated": sliced["truncated"], + "band": {"min_word_count": min_wc, "max_word_count": max_wc}, + } + + +def list_duplicate_content_pairs(conn: Connection, ctx: AuditToolContext, args: dict[str, Any]) -> dict[str, Any]: + scoped = ctx.with_args(args) + payload = scoped.load_payload(conn) + if not payload: + return {"error": "no report found", "pairs": [], "total": 0, "truncated": False} + clusters = payload.get("content_duplicates") or [] + if not isinstance(clusters, list): + clusters = [] + pairs: list[dict[str, Any]] = [] + for cluster in clusters: + if not isinstance(cluster, dict): + continue + members = cluster.get("member_urls") or [] + if not isinstance(members, list): + continue + rep = str(cluster.get("representative_url") or members[0] if members else "") + for url in members: + u = str(url or "") + if u and u != rep: + pairs.append({ + "url_a": rep, + "url_b": u, + "cluster_id": cluster.get("id"), + "similarity": cluster.get("similarity"), + }) + limit = parse_limit(args.get("limit"), 30, 50) + sliced = cap_list(pairs, limit, max_cap=50) + return {"pairs": sliced["items"], "total": sliced["total"], "truncated": sliced["truncated"]} + + +def list_spell_check_issues(conn: Connection, ctx: AuditToolContext, args: dict[str, Any]) -> dict[str, Any]: + return _optional_audit_urls(conn, ctx, args, "spell") + + +def list_html_validation_issues(conn: Connection, ctx: AuditToolContext, args: dict[str, Any]) -> dict[str, Any]: + return _optional_audit_urls(conn, ctx, args, "html") + + +def list_amp_validation_issues(conn: Connection, ctx: AuditToolContext, args: dict[str, Any]) -> dict[str, Any]: + return _optional_audit_urls(conn, ctx, args, "amp") + + +def list_pagination_issues(conn: Connection, ctx: AuditToolContext, args: dict[str, Any]) -> dict[str, Any]: + return _optional_audit_urls(conn, ctx, args, "pagination") + + +def list_schema_errors_by_type(conn: Connection, ctx: AuditToolContext, args: dict[str, Any]) -> dict[str, Any]: + scoped = ctx.with_args(args) + payload = scoped.load_payload(conn) + if not payload: + return {"error": "no report found", "errors": [], "total": 0, "truncated": False} + schema_type = str(args.get("schema_type") or args.get("type") or "").strip().lower() + validation = payload.get("rich_results_validation") or [] + if not isinstance(validation, list): + validation = [] + errors = [ + r for r in validation + if isinstance(r, dict) and str(r.get("status") or "").lower() != "pass" + ] + if schema_type: + errors = [ + r for r in errors + if schema_type in str(r.get("type") or r.get("schema_type") or "").lower() + ] + limit = parse_limit(args.get("limit"), 30, 50) + sliced = cap_list(errors, limit, max_cap=50) + return {"errors": sliced["items"], "total": sliced["total"], "truncated": sliced["truncated"]} + + +def _has_article_schema(row: dict[str, Any]) -> bool: + types = [t.lower() for t in _row_schema_types_list(row)] + return any(t in _ARTICLE_TYPES or "article" in t for t in types) + + +def list_pages_missing_article_schema(conn: Connection, ctx: AuditToolContext, args: dict[str, Any]) -> dict[str, Any]: + scoped = ctx.with_args(args) + df = scoped.load_crawl_df(conn) + if df is None or df.empty: + return {"pages": [], "total": 0, "truncated": False, "missing": True} + pages: list[dict[str, Any]] = [] + for _, row in df.iterrows(): + rec = row.to_dict() + if not str(rec.get("status") or "").startswith("2"): + continue + url = str(rec.get("url") or "").lower() + path = url.split("://", 1)[-1] + looks_article = any(h in path for h in _ARTICLE_URL_HINTS) + pa = _parse_page_analysis(rec) + types = pa.get("json_ld_types") or pa.get("schema_types") or [] + if isinstance(types, str): + types = [types] + if not looks_article and not any("article" in str(t).lower() for t in types): + excerpt = str(rec.get("content_excerpt") or "") + if len(excerpt.split()) < 200: + continue + looks_article = bool(re.search(r"\b(posted|published|author)\b", excerpt, re.I)) + if not looks_article or _has_article_schema(rec): + continue + pages.append({"url": str(rec.get("url") or ""), "title": str(rec.get("title") or ""), "reason": "article_heuristic_no_schema"}) + limit = parse_limit(args.get("limit"), 30, 50) + sliced = cap_list(pages, limit, max_cap=50) + return {"pages": sliced["items"], "total": sliced["total"], "truncated": sliced["truncated"], "provenance": "Estimated"} diff --git a/src/website_profiling/tools/audit_tools/context.py b/src/website_profiling/tools/audit_tools/context.py index 9f09ec87..f3ee260f 100644 --- a/src/website_profiling/tools/audit_tools/context.py +++ b/src/website_profiling/tools/audit_tools/context.py @@ -11,7 +11,11 @@ from ...db.report_store import read_report_payload from ...integrations.google.gsc_links_store import read_latest_gsc_links_data from ...integrations.google.keyword_store import read_latest_keyword_data -from ...integrations.google.store import read_latest_google_data +from ...integrations.google.store import ( + read_google_data_full, + read_latest_google_data, + read_prior_google_snapshot, +) @dataclass @@ -50,6 +54,24 @@ def load_keywords(self, conn: Connection) -> Optional[dict[str, Any]]: embedded = payload.get("keywords") return embedded if isinstance(embedded, dict) else None + def load_google_full(self, conn: Connection) -> Optional[dict[str, Any]]: + full = read_google_data_full(conn, self.property_id) + if full: + return full + payload = self.load_payload(conn) + embedded = payload.get("google") + return embedded if isinstance(embedded, dict) else None + + def load_google_pair(self, conn: Connection) -> tuple[Optional[dict[str, Any]], Optional[dict[str, Any]]]: + """Return (current, prior) full Google snapshots for decay/compare tools.""" + current = read_google_data_full(conn, self.property_id) + prior = read_prior_google_snapshot(conn, self.property_id, skip=1) + if current is None: + payload = self.load_payload(conn) + embedded = payload.get("google") + current = embedded if isinstance(embedded, dict) else None + return current, prior + def load_gsc_links(self, conn: Connection) -> Optional[dict[str, Any]]: links = read_latest_gsc_links_data(conn, self.property_id, for_report=False) if links: diff --git a/src/website_profiling/tools/audit_tools/crawl.py b/src/website_profiling/tools/audit_tools/crawl.py index 66d924ca..1bfc536f 100644 --- a/src/website_profiling/tools/audit_tools/crawl.py +++ b/src/website_profiling/tools/audit_tools/crawl.py @@ -320,6 +320,46 @@ def _flag(val: Any) -> bool: return {"pages": sliced["items"], "total": sliced["total"], "truncated": sliced["truncated"]} +def _console_error_entries(pa: dict[str, Any]) -> list[dict[str, Any]]: + entries: list[dict[str, Any]] = [] + browser = pa.get("browser") if isinstance(pa.get("browser"), dict) else {} + for msg in browser.get("console") or []: + if isinstance(msg, dict): + entries.append({ + "error_type": str(msg.get("type") or msg.get("level") or "console"), + "message": str(msg.get("text") or msg.get("message") or ""), + "source": "console", + }) + for msg in browser.get("page_errors") or []: + if isinstance(msg, dict): + entries.append({ + "error_type": "page_error", + "message": str(msg.get("message") or msg.get("name") or ""), + "source": "page_error", + }) + for msg in browser.get("failed_requests") or []: + if isinstance(msg, dict): + entries.append({ + "error_type": "failed_request", + "message": str(msg.get("url") or msg.get("failure") or ""), + "source": "failed_request", + }) + raw = pa.get("console_errors") or pa.get("js_errors") or [] + if isinstance(raw, str): + raw = [raw] + if isinstance(raw, list): + for item in raw: + if isinstance(item, dict): + entries.append({ + "error_type": str(item.get("type") or item.get("level") or "console"), + "message": str(item.get("text") or item.get("message") or item), + "source": "console_errors", + }) + elif item: + entries.append({"error_type": "console", "message": str(item), "source": "console_errors"}) + return entries + + def list_pages_with_console_errors(conn: Connection, ctx: AuditToolContext, args: dict[str, Any]) -> dict[str, Any]: scoped = ctx.with_args(args) df = scoped.load_crawl_df(conn) @@ -328,21 +368,90 @@ def list_pages_with_console_errors(conn: Connection, ctx: AuditToolContext, args pages = [] for _, row in df.iterrows(): pa = _parse_page_analysis(row.to_dict()) - errors = pa.get("console_errors") or pa.get("js_errors") or [] + errors = _console_error_entries(pa) if not errors: continue - if isinstance(errors, str): - errors = [errors] pages.append({ "url": str(row.get("url") or ""), - "error_count": len(errors) if isinstance(errors, list) else 1, - "errors": (errors[:5] if isinstance(errors, list) else [errors]), + "error_count": len(errors), + "errors": errors[:5], + }) + limit = parse_limit(args.get("limit"), 30, 50) + sliced = cap_list(pages, limit, max_cap=50) + return {"pages": sliced["items"], "total": sliced["total"], "truncated": sliced["truncated"]} + + +def list_pages_console_errors_by_type(conn: Connection, ctx: AuditToolContext, args: dict[str, Any]) -> dict[str, Any]: + error_type = str(args.get("error_type") or "").strip().lower() + if not error_type: + return {"error": "error_type is required", "pages": [], "total": 0, "truncated": False} + scoped = ctx.with_args(args) + df = scoped.load_crawl_df(conn) + if df is None or df.empty: + return {"pages": [], "total": 0, "truncated": False} + pages: list[dict[str, Any]] = [] + for _, row in df.iterrows(): + pa = _parse_page_analysis(row.to_dict()) + matched = [ + e for e in _console_error_entries(pa) + if error_type in str(e.get("error_type") or "").lower() + or error_type in str(e.get("source") or "").lower() + ] + if not matched: + continue + pages.append({ + "url": str(row.get("url") or ""), + "error_type": error_type, + "error_count": len(matched), + "errors": matched[:5], }) limit = parse_limit(args.get("limit"), 30, 50) sliced = cap_list(pages, limit, max_cap=50) return {"pages": sliced["items"], "total": sliced["total"], "truncated": sliced["truncated"]} +def list_pages_js_rendering_delta(conn: Connection, ctx: AuditToolContext, args: dict[str, Any]) -> dict[str, Any]: + scoped = ctx.with_args(args) + df = scoped.load_crawl_df(conn) + if df is None or df.empty or "fetch_method" not in df.columns: + return {"pages": [], "total": 0, "truncated": False, "note": "fetch_method not in crawl — use javascript or auto render mode"} + by_url: dict[str, dict[str, dict[str, Any]]] = {} + for _, row in df.iterrows(): + url = str(row.get("url") or "").rstrip("/").lower() + method = str(row.get("fetch_method") or "static").lower() + if not url: + continue + try: + word_count = int(row.get("word_count") or 0) + except (TypeError, ValueError): + word_count = 0 + by_url.setdefault(url, {})[method] = { + "title": str(row.get("title") or ""), + "word_count": word_count, + "h1": str(row.get("h1") or ""), + } + pages: list[dict[str, Any]] = [] + for url, methods in by_url.items(): + static = methods.get("static") + rendered = methods.get("rendered") or methods.get("javascript") + if not static or not rendered: + continue + title_diff = static.get("title") != rendered.get("title") + wc_diff = abs(int(static.get("word_count") or 0) - int(rendered.get("word_count") or 0)) + h1_diff = static.get("h1") != rendered.get("h1") + if title_diff or wc_diff > 50 or h1_diff: + pages.append({ + "url": url, + "title_differs": title_diff, + "word_count_delta": wc_diff, + "h1_differs": h1_diff, + }) + pages.sort(key=lambda p: -int(p.get("word_count_delta") or 0)) + limit = parse_limit(args.get("limit"), 30, 50) + sliced = cap_list(pages, limit, max_cap=50) + return {"pages": sliced["items"], "total": sliced["total"], "truncated": sliced["truncated"], "provenance": "Crawl"} + + def list_pages_by_fetch_method(conn: Connection, ctx: AuditToolContext, args: dict[str, Any]) -> dict[str, Any]: method = str(args.get("fetch_method") or "").strip().lower() if not method: diff --git a/src/website_profiling/tools/audit_tools/data_coverage.py b/src/website_profiling/tools/audit_tools/data_coverage.py new file mode 100644 index 00000000..773663e5 --- /dev/null +++ b/src/website_profiling/tools/audit_tools/data_coverage.py @@ -0,0 +1,132 @@ +"""Data coverage report — which integrations and optional audit data are populated.""" +from __future__ import annotations + +from typing import Any + +from psycopg import Connection + +from ...db.property_store import get_property_by_id +from .context import AuditToolContext + + +def _check(name: str, populated: bool, hint: str = "") -> dict[str, Any]: + return {"signal": name, "populated": populated, "config_hint": hint if not populated else ""} + + +def get_data_coverage_report(conn: Connection, ctx: AuditToolContext, args: dict[str, Any]) -> dict[str, Any]: + scoped = ctx.with_args(args) + if scoped.property_id is None: + return {"error": "property_id is required", "checks": []} + + prop = get_property_by_id(conn, scoped.property_id) + if not prop: + return {"error": "property not found", "checks": []} + + payload = scoped.load_payload(conn) + google = scoped.load_google(conn) + keywords = scoped.load_keywords(conn) + gsc_links = scoped.load_gsc_links(conn) + google_full = scoped.load_google_full(conn) + + checks: list[dict[str, Any]] = [] + checks.append(_check( + "google_oauth", + bool(prop.get("google_refresh_token")), + "Connect Google OAuth in Integrations.", + )) + checks.append(_check( + "gsc_data", + bool(google and isinstance(google.get("gsc"), dict) and (google.get("gsc") or {}).get("summary")), + "Map GSC site URL and re-run pipeline.", + )) + checks.append(_check( + "ga4_data", + bool(google and isinstance(google.get("ga4"), dict) and (google.get("ga4") or {}).get("summary")), + "Connect GA4 property in Integrations.", + )) + checks.append(_check( + "keyword_data", + bool(keywords and (keywords.get("rows") or keywords.get("total_keywords"))), + "Run keyword enrichment in pipeline.", + )) + checks.append(_check( + "gsc_links_import", + bool(gsc_links and (gsc_links.get("sample_links") or gsc_links.get("referring_domains"))), + "Import GSC Links CSV in Backlinks view.", + )) + overlays = (gsc_links or {}).get("third_party_overlays") if isinstance(gsc_links, dict) else None + checks.append(_check( + "moz_majestic_overlay", + bool(isinstance(overlays, list) and overlays), + "Upload Moz or Majestic CSV in Backlinks > third-party overlay.", + )) + checks.append(_check( + "image_inventory", + bool(payload.get("image_inventory")), + "Set probe_image_inventory=true in pipeline config and rebuild report.", + )) + checks.append(_check( + "axe_violations", + bool(payload.get("axe_audit_summary") or payload.get("axe_violations")), + "Set enable_axe=true and use javascript/auto crawl rendering.", + )) + checks.append(_check( + "rich_results_validation", + bool(payload.get("rich_results_validation") or payload.get("rich_results_meta")), + "Enable rich results validation on report build.", + )) + checks.append(_check( + "audit_report", + bool(payload), + "Run a site audit crawl and report build.", + )) + checks.append(_check( + "gsc_full_blob", + bool(google_full and isinstance(google_full.get("gsc_full"), dict)), + "Re-run Google fetch to populate gsc_full for list/decay tools.", + )) + checks.append(_check( + "ga4_full_blob", + bool(google_full and isinstance(google_full.get("ga4_full"), dict)), + "Re-run GA4 fetch to populate ga4_full for landing-page list tools.", + )) + checks.append(_check( + "keyword_history", + bool(keywords and keywords.get("fetched_at")), + "Run keyword enrichment twice for rank delta tools.", + )) + checks.append(_check( + "text_content_analysis", + bool(payload.get("text_content_analysis")), + "Report build includes text_content_analysis from crawl.", + )) + checks.append(_check( + "semantic_keyword_clusters", + bool(payload.get("semantic_keyword_clusters")), + "Enable llm_enable_keyword_clusters for cluster list tools.", + )) + checks.append(_check( + "access_log", + bool(payload.get("log_analysis") or payload.get("access_log_summary")), + "Upload access logs in Integrations for log list tools.", + )) + from ...integrations.google.store import read_prior_google_snapshot + prior_google = read_prior_google_snapshot(conn, scoped.property_id, skip=1) if scoped.property_id else None + checks.append(_check( + "prior_google_snapshot", + bool(prior_google), + "Run at least two Google data fetches for decay/compare period tools.", + )) + + missing = [c["signal"] for c in checks if not c["populated"]] + return { + "property_id": scoped.property_id, + "checks": checks, + "missing_count": len(missing), + "missing": missing, + "provenance": {"sources": ["property", "google_data", "report_payload"], "confidence": "high"}, + "insights": [ + f"{len(checks) - len(missing)}/{len(checks)} data signals populated.", + *(f"Enable: {m}" for m in missing[:5]), + ], + } diff --git a/src/website_profiling/tools/audit_tools/export_tools.py b/src/website_profiling/tools/audit_tools/export_tools.py index 253401a2..cc738ea1 100644 --- a/src/website_profiling/tools/audit_tools/export_tools.py +++ b/src/website_profiling/tools/audit_tools/export_tools.py @@ -101,6 +101,97 @@ "search_keywords", "search_pages_by_schema_type", "list_pages_without_schema", + "list_pages_title_too_short", + "list_pages_title_too_long", + "list_pages_slow_response", + "list_pages_missing_html_lang", + "list_pages_invalid_viewport", + "list_pages_color_contrast_failures", + "list_pages_high_reading_level", + "list_pages_very_thin_content", + "list_hreflang_issue_pages", + "list_pages_missing_og_tags", + "list_pages_missing_twitter_cards", + "list_pages_invalid_json_ld", + "list_pages_mixed_language", + "list_orphan_hub_suggestions", + "list_lighthouse_failure_lcp", + "list_lighthouse_failure_inp", + "list_lighthouse_failure_cls", + "list_lighthouse_failure_seo", + "list_pages_console_errors_by_type", + "list_pages_js_rendering_delta", + "list_gsc_pages_by_impressions", + "list_gsc_pages_by_clicks", + "list_gsc_queries_by_impressions", + "list_gsc_queries_by_clicks", + "list_gsc_ctr_underperformers", + "list_gsc_decaying_pages", + "list_gsc_decaying_queries", + "list_gsc_new_queries", + "list_ga4_landing_pages", + "list_ga4_pages_by_bounce_rate", + "list_ga4_pages_by_engagement_rate", + "list_gsc_ga4_mismatch_pages", + "list_gsc_pages_by_position_band", + "list_gsc_branded_queries", + "list_gsc_non_branded_queries", + "list_keyword_rank_improvements", + "list_keyword_rank_declines", + "list_keywords_new_to_top_10", + "list_keywords_fell_out_of_top_10", + "list_cannibalisation_queries", + "list_cannibalisation_urls", + "list_misaligned_queries", + "list_keywords_by_recommended_action", + "list_keywords_by_serp_feature", + "list_semantic_cluster_pages", + "list_semantic_cluster_queries", + "list_keywords_near_page_one", + "list_keywords_high_impression_zero_click", + "list_keywords_by_competition_band", + "list_keywords_with_ai_overview", + "list_keywords_local_pack", + "list_keywords_question_intent", + "list_keywords_commercial_intent", + "list_referring_domains", + "list_backlinks_by_anchor_text", + "list_backlinks_to_url", + "list_backlinks_from_domain", + "list_outbound_links", + "list_internal_links_from_url", + "list_internal_links_to_url", + "list_links_by_rel_nofollow", + "list_pagerank_low_pages", + "list_indexation_submitted_not_indexed", + "list_indexation_indexed_not_submitted", + "list_sitemap_urls_not_in_crawl", + "list_crawl_urls_not_in_sitemap", + "list_log_paths_by_hits", + "list_log_5xx_paths", + "list_log_googlebot_low_crawl", + "list_log_orphan_high_traffic", + "list_redirect_chains_by_length", + "list_hreflang_reciprocal_gaps", + "list_pages_containing_keyword", + "list_pages_by_word_count_band", + "list_duplicate_content_pairs", + "list_spell_check_issues", + "list_html_validation_issues", + "list_amp_validation_issues", + "list_pagination_issues", + "list_schema_errors_by_type", + "list_pages_missing_article_schema", + "list_pages_missing_howto_schema", + "list_pages_ai_citation_signals", + "list_pages_missing_llms_txt_reference", + "list_robots_blocked_ai_crawlers", + "list_compare_new_issues", + "list_compare_resolved_issues", + "list_compare_new_urls", + "list_compare_removed_urls", + "list_compare_lighthouse_regressions", + "list_compare_traffic_losers", }) _EXPORT_TOOL_NAMES = frozenset({ diff --git a/src/website_profiling/tools/audit_tools/geo_list_tools.py b/src/website_profiling/tools/audit_tools/geo_list_tools.py new file mode 100644 index 00000000..4aaaa240 --- /dev/null +++ b/src/website_profiling/tools/audit_tools/geo_list_tools.py @@ -0,0 +1,242 @@ +"""GEO/AEO page-level list tools.""" +from __future__ import annotations + +import re +from typing import Any +from urllib.parse import urljoin + +import requests +from psycopg import Connection + +from ._slice import _parse_page_analysis, _row_schema_types_list, cap_list, parse_limit +from .context import AuditToolContext +from .geo_tools import _fetch_llms_txt, _has_faq_schema + +_HOWTO_TYPES = frozenset({"howto", "how-to"}) +_HOWTO_URL_HINTS = ("/how-to", "/howto", "/guide/", "/tutorial/", "/recipes/") +_AI_CRAWLER_AGENTS = ( + "GPTBot", + "ChatGPT-User", + "ClaudeBot", + "anthropic-ai", + "Google-Extended", + "PerplexityBot", + "Bytespider", + "CCBot", +) + + +def _has_howto_schema(row: dict[str, Any]) -> bool: + types = [t.lower() for t in _row_schema_types_list(row)] + return any(t in _HOWTO_TYPES or "howto" in t for t in types) + + +def _looks_like_howto_page(rec: dict[str, Any]) -> bool: + url = str(rec.get("url") or "").lower() + heading = str(rec.get("heading_text") or rec.get("h1") or "").lower() + title = str(rec.get("title") or "").lower() + if any(h in url for h in _HOWTO_URL_HINTS): + return True + return any(k in heading or k in title for k in ("how to", "step-by-step", "tutorial", "guide")) + + +def _aeo_score(rec: dict[str, Any]) -> dict[str, Any]: + excerpt = str(rec.get("content_excerpt") or "") + words = excerpt.split() + lead = " ".join(words[:80]) + has_list = bool(re.search(r"^\s*[-*•]\s", excerpt, re.M)) or "Source: {html.escape(_executive_source_label(data["source"]))}
' - ) - if data["summary"]: - summary_html = html.escape(data["summary"]).replace("\n", "{summary_html}
| Priority | Issue | URL | GSC clicks | " - f"
|---|
No category scores available.
' - - -def _priority_stats_html(counts: dict[str, int]) -> str: - labels = ( - ("critical", "Critical"), - ("high", "High"), - ("medium", "Medium"), - ("low", "Low"), - ) - parts: list[str] = [] - for key, label in labels: - n = counts.get(key, 0) - parts.append( - f'Source: {html.escape(_executive_source_label(data["source"]))}
' + ) + if data["summary"]: + summary_html = html.escape(data["summary"]).replace("\n", "{summary_html}
| Priority | Issue | URL | GSC clicks | " + f"
|---|
No category scores available.
' + + +def _priority_stats_html(counts: dict[str, int]) -> str: + labels = ( + ("critical", "Critical"), + ("high", "High"), + ("medium", "Medium"), + ("low", "Low"), + ) + parts: list[str] = [] + for key, label in labels: + n = counts.get(key, 0) + parts.append( + f'Hello world again.
Article about widgets and reviews for buyers.
Visible
") + cleaned = cleanup_dom(soup) + assert "Visible" in cleaned.get_text() + assert cleaned.find("script") is None + + +def test_excerpt_truncation() -> None: + words = " ".join(f"word{i}" for i in range(80)) + html = f"{words}
Keep
' + ) + cleaned = cleanup_dom(soup) + text = cleaned.get_text() + assert "Keep" in text + assert "Hidden" not in text + + +def test_find_main_content_falls_back_to_body() -> None: + soup = load_soup("Hello content world.
", "lxml") + out = parse_content_text(soup, "Hello content world.
") + assert out["word_count"] > 0 + + +def test_count_syllables_wrapper() -> None: + assert _count_syllables("hello") >= 1 + + +def test_analyze_row_skips_missing_html() -> None: + assert ca_batch._analyze_row({"url": "https://x.com", "html": ""}, excerpt_max_chars=0, strategy="main_only") is None + + +def test_analyze_run_html_empty() -> None: + assert ca_batch.analyze_run_html(MagicMock(), 1) == [] + + +def test_analyze_run_html_single_worker(monkeypatch: pytest.MonkeyPatch) -> None: + rows = [ + { + "url": "https://example.com", + "html": "
diff --git a/web/src/components/integrations/PropertyOpsSection.tsx b/web/src/components/integrations/PropertyOpsSection.tsx
index ba0c1fa6..388ddad0 100644
--- a/web/src/components/integrations/PropertyOpsSection.tsx
+++ b/web/src/components/integrations/PropertyOpsSection.tsx
@@ -27,14 +27,20 @@ export default function PropertyOpsSection({ propertyId }: PropertyOpsSectionPro
let cancelled = false;
setLoading(true);
void fetch(apiUrl(`/properties/${propertyId}/ops`))
- .then((res) => (res.ok ? res.json() : null))
- .then((data) => {
- if (cancelled || !data) return;
+ .then(async (res) => {
+ if (cancelled) return;
+ if (!res.ok) {
+ setMessage(s.loadFailed);
+ return;
+ }
+ const data = await res.json();
setScheduleCron(String(data.schedule_cron || ''));
setAlertWebhookUrl(String(data.alert_webhook_url || ''));
setAlertEmail(String(data.alert_email || ''));
})
- .catch(() => {})
+ .catch(() => {
+ if (!cancelled) setMessage(s.loadFailed);
+ })
.finally(() => {
if (!cancelled) setLoading(false);
});
@@ -47,9 +53,11 @@ export default function PropertyOpsSection({ propertyId }: PropertyOpsSectionPro
if (propertyId == null) return undefined;
let cancelled = false;
void fetch(apiUrl(`/properties/${propertyId}/google/links/status`))
- .then((res) => (res.ok ? res.json() : null))
- .then((status) => {
- if (cancelled || !status) return;
+ .then(async (res) => {
+ if (cancelled) return;
+ if (!res.ok) return;
+ const status = await res.json();
+ if (!status) return;
if (!status.hasData) {
setGscLinksStale(s.gscLinksMissing);
return;
@@ -63,11 +71,13 @@ export default function PropertyOpsSection({ propertyId }: PropertyOpsSectionPro
setGscLinksStale(null);
}
})
- .catch(() => {});
+ .catch(() => {
+ if (!cancelled) setGscLinksStale(s.loadFailed);
+ });
return () => {
cancelled = true;
};
- }, [propertyId, s.gscLinksMissing, s.gscLinksStale]);
+ }, [propertyId, s.gscLinksMissing, s.gscLinksStale, s.loadFailed]);
const handleSave = useCallback(async () => {
if (propertyId == null || readOnly) return;
diff --git a/web/src/components/overview/OverviewSummaryTab.tsx b/web/src/components/overview/OverviewSummaryTab.tsx
index 2484d69b..80864535 100644
--- a/web/src/components/overview/OverviewSummaryTab.tsx
+++ b/web/src/components/overview/OverviewSummaryTab.tsx
@@ -44,6 +44,7 @@ export function OverviewSummaryTab({ data, exportHref, compareHref, reportCount
const sj = strings.common;
const searchParams = useSearchParams();
const [healthDelta, setHealthDelta] = useState
{sh.hint}
++ {error} +
+ ) : null} + + {loading ? ( +{sh.empty}
+ ) : ( +| {sh.colRun} | +{sh.colSite} | +{sh.colPages} | +{sh.colSize} | +{sh.colAction} | +
|---|---|---|---|---|
|
+ #{row.crawl_run_id}
+ {createdLabel}
+ {row.render_mode ? (
+ {row.render_mode}
+ ) : null}
+ |
+ + + {row.start_url} + + | ++ {row.page_count.toLocaleString()} + | ++ {formatBytes(row.total_bytes)} + | +
+ {isConfirm ? (
+
+
+ ) : (
+
+ )}
+ + {format(sh.deleteConfirm, { id: row.crawl_run_id })} + +
+
+
+
+ |
+