diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 0402ea0e..fb319d44 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -47,13 +47,14 @@ jobs:
pytest tests/test_alert_checker.py tests/test_schedule_runner.py tests/test_export_audit.py \
tests/test_export_audit_coverage.py tests/test_audit_tools.py tests/test_audit_tools_expanded.py \
tests/test_audit_tools_coverage.py tests/test_audit_tools_dispatch_coverage.py \
- tests/test_export_custom_coverage.py tests/test_export_artifacts_coverage.py \
- tests/test_export_compare_coverage.py tests/test_export_tools_coverage.py \
- tests/test_image_tools.py tests/test_export_custom.py tests/test_export_artifacts.py \
- tests/test_export_compare.py \
- tests/test_mcp_registry.py tests/test_mcp_resources.py \
+ tests/test_audit_tools_links_extras.py tests/test_export_custom_coverage.py \
+ tests/test_export_artifacts_coverage.py tests/test_export_compare_coverage.py \
+ tests/test_export_tools_coverage.py tests/test_image_tools.py tests/test_export_custom.py \
+ tests/test_export_artifacts.py tests/test_export_compare.py tests/test_export_workbook.py \
+ tests/test_export_sitemap.py tests/test_mcp_registry.py tests/test_mcp_resources.py \
+ tests/test_tools_branch_coverage.py \
--cov=website_profiling.tools --cov-config=.coveragerc.tools \
- --cov-report=term-missing --cov-fail-under=95 -q -o addopts=
+ --cov-report=term-missing --cov-fail-under=100 -q -o addopts=
- name: CLI smoke
run: python -m src --help
diff --git a/alembic/versions/013_crawl_discovery_and_link_edges.py b/alembic/versions/013_crawl_discovery_and_link_edges.py
new file mode 100644
index 00000000..a666036c
--- /dev/null
+++ b/alembic/versions/013_crawl_discovery_and_link_edges.py
@@ -0,0 +1,56 @@
+"""Crawl discovery mode on crawl_runs and rich link_edges table.
+
+Revision ID: 013_crawl_discovery_edges
+Revises: 012_chat_sessions
+"""
+from __future__ import annotations
+
+from alembic import op
+
+revision = "013_crawl_discovery_edges"
+down_revision = "012_chat_sessions"
+branch_labels = None
+depends_on = None
+
+
+def upgrade() -> None:
+ op.execute("""
+ ALTER TABLE crawl_runs
+ ADD COLUMN IF NOT EXISTS discovery_mode TEXT DEFAULT 'spider';
+
+ CREATE TABLE IF NOT EXISTS link_edges (
+ crawl_run_id BIGINT NOT NULL REFERENCES crawl_runs(id) ON DELETE CASCADE,
+ from_url TEXT NOT NULL,
+ to_url TEXT NOT NULL,
+ anchor_text TEXT NOT NULL DEFAULT '',
+ rel TEXT NOT NULL DEFAULT '',
+ is_nofollow BOOLEAN NOT NULL DEFAULT FALSE,
+ is_sponsored BOOLEAN NOT NULL DEFAULT FALSE,
+ is_ugc BOOLEAN NOT NULL DEFAULT FALSE,
+ link_type TEXT NOT NULL DEFAULT 'internal',
+ PRIMARY KEY (crawl_run_id, from_url, to_url, anchor_text, rel)
+ );
+ CREATE INDEX IF NOT EXISTS idx_link_edges_run_from
+ ON link_edges(crawl_run_id, from_url);
+ CREATE INDEX IF NOT EXISTS idx_link_edges_run_to
+ ON link_edges(crawl_run_id, to_url);
+
+ CREATE TABLE IF NOT EXISTS saved_crawl_filters (
+ id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
+ property_id BIGINT NOT NULL REFERENCES properties(id) ON DELETE CASCADE,
+ name TEXT NOT NULL,
+ filter_json JSONB NOT NULL DEFAULT '{}',
+ created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
+ UNIQUE (property_id, name)
+ );
+ CREATE INDEX IF NOT EXISTS idx_saved_crawl_filters_property
+ ON saved_crawl_filters(property_id);
+ """)
+
+
+def downgrade() -> None:
+ op.execute("""
+ DROP TABLE IF EXISTS saved_crawl_filters CASCADE;
+ DROP TABLE IF EXISTS link_edges CASCADE;
+ ALTER TABLE crawl_runs DROP COLUMN IF EXISTS discovery_mode;
+ """)
diff --git a/docs/GLOSSARY.md b/docs/GLOSSARY.md
index bc8c9ca0..436850f4 100644
--- a/docs/GLOSSARY.md
+++ b/docs/GLOSSARY.md
@@ -39,17 +39,21 @@ UI terms agencies recognize, mapped to internal keys and data sources.
| Scheduled audits | `properties.schedule_cron`, `/api/schedule/check` | Cron + pipeline spawn | Recurring site audit — see [OPS.md](OPS.md) |
| Property alerts | `alert_webhook_url`, `/api/alerts/check` | Health snapshot rules | Ops notifications |
| Content brief | Keywords Brief button, `/api/keywords/content-brief` | LLM or deterministic | Content planning |
-| AI issue fix | `llm_recommendation`, `/api/issues/fix-suggestion` | LLM on demand + report build | Actionable remediation |
+| AI fix suggestions | `llm_recommendation`, `/api/ai/fix-suggestion`, `/api/issues/fix-suggestion` (legacy) | LLM on demand + report build | Actionable remediation across Issues, Lighthouse, Security, and other surfaces |
| AI Chat | `/chat`, `/api/chat`, `chat_sessions` | LLM + read-only audit tools | Conversational site audit queries |
| MCP tools | `python -m website_profiling.mcp` | Same `audit_tools` as chat | Cursor / Claude Desktop integration — see [MCP.md](MCP.md) |
| Read-only session | `AUTH_DEFAULT_ROLE=client-readonly`, `/api/auth/session` | Session cookie | Client view-only access |
| Export executive summary | `export_audit_html/pdf/csv`, `export_audit_report` (chat/MCP), Export view | Report payload + optional AI | Client deliverable |
+| ads.txt / security.txt | `site_level`, `get_ads_txt_status`, `get_security_txt_status` | Root file fetch at report build | Publisher / contact file hygiene |
+| Subdomain inventory | `subdomains`, `list_subdomains`, `/subdomains` view | Crawl + GSC + optional crt.sh | Host footprint vs crawl scope |
+| Contact intelligence | `contact_intelligence`, `get_contact_intelligence`, `/contacts` view | Crawl schema/mailto + security.txt + RDAP org | Business identity consistency |
## Metric names
| UI | Field | Source |
|----|-------|--------|
-| Inlinks | `inlinks` | Crawl graph |
+| Impact score | `impact_score` on issues | GSC clicks + GA4 sessions + priority weight (see below) |
+| Link edges | `link_edges`, `link_rel_summary` | Crawl anchor/rel attributes |
| Outlinks | `outlinks` | Crawl graph |
| Status code | `status` | HTTP |
| Crawl rendering | `crawl_render_mode` on run; `fetch_method` per URL | `static`, `javascript`, or `auto` crawl config; `static` vs `rendered` per page |
@@ -62,6 +66,8 @@ UI terms agencies recognize, mapped to internal keys and data sources.
| On-site frequency | `volume` (heuristic) | Estimated from crawl |
| Sessions | GA4 metrics | Analytics |
+**Impact score:** `priority_weight + (gsc_clicks × 10) + (ga4_sessions × 5)` with Critical=1000, High=100, Medium=10, Low=1.
+
## Provenance badges
| Badge | Meaning |
diff --git a/docs/MCP.md b/docs/MCP.md
index dd473f8e..d3ba6f94 100644
--- a/docs/MCP.md
+++ b/docs/MCP.md
@@ -43,11 +43,11 @@ Add to `.cursor/mcp.json` (or Cursor MCP settings):
| `audit://glossary` | Excerpt from `docs/GLOSSARY.md` |
| `audit://tools` | Tool catalog grouped by SEO domain |
-## Tools (171 read-only + export)
+## Tools (176 read-only + export)
### Export and deliverables
-`export_audit_report`, `export_compare_csv`, `export_list_as_csv`, `compose_custom_report`, `export_custom_report`, `list_export_formats`
+`export_audit_report`, `export_compare_csv`, `export_list_as_csv`, `export_sitemap_xml`, `validate_rich_results`, `compose_custom_report`, `export_custom_report`, `list_export_formats`
Full audit exports reuse the same generators as the Export view (PDF requires `reportlab`). Export tools store files as artifacts (24h TTL); in-app chat renders download buttons via `/api/chat/artifacts/{id}`.
@@ -79,7 +79,7 @@ Size-based tools require `probe_image_inventory=true` in pipeline config when bu
### Links and architecture
-`list_orphan_pages`, `get_top_linked_pages`, `get_top_crawled_pages`, `get_outbound_link_domains`, `get_link_graph_summary`, `get_url_fingerprints`, `list_broken_link_sources`, `get_mime_type_breakdown`, `get_title_length_distribution`, `get_domain_link_distribution`, `get_outlink_distribution`
+`get_link_rel_summary`, `get_inlink_anchors`, `list_nofollow_internal_links`, `list_orphan_pages`, `get_top_linked_pages`, `get_top_crawled_pages`, `get_outbound_link_domains`, `get_link_graph_summary`, `get_url_fingerprints`, `list_broken_link_sources`, `get_mime_type_breakdown`, `get_title_length_distribution`, `get_domain_link_distribution`, `get_outlink_distribution`
### Indexation and international
@@ -115,12 +115,13 @@ Size-based tools require `probe_image_inventory=true` in pipeline config when bu
## Future pipeline items (not yet exposed as tools)
-These require additional crawl or third-party integrations before dedicated tools are useful:
+These require additional third-party integrations or product scope beyond current crawl data:
-- Google Rich Results / schema validation API
-- Full backlink index and anchor-text analytics
-- axe / color-contrast accessibility audits
+- Full backlink index and anchor-text analytics (beyond GSC Links import)
- SERP rank tracking beyond GSC position snapshots
+- Standalone Google Rich Results Test API (current `validate_rich_results` uses crawl heuristics + GSC URL Inspection)
+
+Already available: `validate_rich_results`, `export_sitemap_xml`, workbook export, axe audits via `enable_axe` on browser crawls.
## Example prompts
diff --git a/input.txt.example b/input.txt.example
index 4d1b4d0e..a6a9c56e 100644
--- a/input.txt.example
+++ b/input.txt.example
@@ -18,6 +18,16 @@ content_excerpt_max_chars = 4096
preserve_crawl_history = true
crawl_stream_to_db = false
crawl_exclude_urls =
+crawl_discovery_mode = spider
+crawl_url_list =
+crawl_user_agent_preset = default
+crawl_user_agent_custom =
+crawl_auth_username =
+crawl_auth_password =
+crawl_extra_headers =
+crawl_cookies =
+crawl_robots_txt_override =
+custom_extractors =
# crawl_render_mode: static | javascript | auto (auto = static first, browser when SPA heuristics match)
crawl_render_mode = static
crawl_js_concurrency = 3
@@ -46,6 +56,9 @@ max_image_probe_urls = 500
image_probe_concurrency = 6
image_probe_timeout = 8
image_unoptimized_min_kb = 200
+enable_subdomain_discovery = true
+subdomain_ct_lookup = true
+enable_rdap_org_lookup = true
# --- Lighthouse ---
lighthouse_url =
@@ -56,6 +69,13 @@ lighthouse_iterations = 1
run_lighthouse = true
run_lighthouse_on_pages = true
enable_crux = false
+enable_rich_results_validation = false
+google_rich_results_api_key =
+enable_axe = false
+enable_spell_check = false
+enable_html_validation = false
+enable_amp_audit = false
+enable_wayback_lookup = false
competitor_domains =
bing_webmaster_api_key =
serp_api_key =
diff --git a/pipeline-config.example.txt b/pipeline-config.example.txt
index b1c384b3..a43055d3 100644
--- a/pipeline-config.example.txt
+++ b/pipeline-config.example.txt
@@ -19,6 +19,16 @@ content_excerpt_max_chars = 4096
preserve_crawl_history = true
crawl_stream_to_db = false
crawl_exclude_urls =
+crawl_discovery_mode = spider
+crawl_url_list =
+crawl_user_agent_preset = default
+crawl_user_agent_custom =
+crawl_auth_username =
+crawl_auth_password =
+crawl_extra_headers =
+crawl_cookies =
+crawl_robots_txt_override =
+custom_extractors =
# crawl_render_mode: static | javascript | auto
crawl_render_mode = static
crawl_js_concurrency = 3
@@ -47,6 +57,9 @@ max_image_probe_urls = 500
image_probe_concurrency = 6
image_probe_timeout = 8
image_unoptimized_min_kb = 200
+enable_subdomain_discovery = true
+subdomain_ct_lookup = true
+enable_rdap_org_lookup = true
# --- Lighthouse ---
lighthouse_url =
@@ -57,6 +70,13 @@ lighthouse_iterations = 1
run_lighthouse = true
run_lighthouse_on_pages = true
enable_crux = false
+enable_rich_results_validation = false
+google_rich_results_api_key =
+enable_axe = false
+enable_spell_check = false
+enable_html_validation = false
+enable_amp_audit = false
+enable_wayback_lookup = false
competitor_domains =
bing_webmaster_api_key =
serp_api_key =
diff --git a/requirements-optional.txt b/requirements-optional.txt
new file mode 100644
index 00000000..488a3046
--- /dev/null
+++ b/requirements-optional.txt
@@ -0,0 +1,3 @@
+# Optional audit dependencies (install when enabling spell-check / HTML validation extras)
+pyspellchecker>=0.8.1
+html5lib>=1.1
diff --git a/scripts/local-test.ps1 b/scripts/local-test.ps1
index a354c8fe..14b0e56f 100644
--- a/scripts/local-test.ps1
+++ b/scripts/local-test.ps1
@@ -245,7 +245,7 @@ function Invoke-PytestReporting {
}
function Invoke-PytestTools {
- Write-Log "Pytest (tools coverage gate, 95%)"
+ Write-Log "Pytest (tools coverage gate, 100%)"
& $VENV_PYTEST `
tests/test_alert_checker.py `
tests/test_schedule_runner.py `
@@ -265,10 +265,11 @@ function Invoke-PytestTools {
tests/test_export_compare.py `
tests/test_mcp_registry.py `
tests/test_mcp_resources.py `
+ tests/test_tools_branch_coverage.py `
--cov=website_profiling.tools `
--cov-config=.coveragerc.tools `
--cov-report=term-missing `
- --cov-fail-under=95 `
+ --cov-fail-under=100 `
-q `
-o addopts=
Assert-LastExitCode "Tools coverage gate failed"
diff --git a/scripts/local-test.sh b/scripts/local-test.sh
index 960683fb..a24022a8 100755
--- a/scripts/local-test.sh
+++ b/scripts/local-test.sh
@@ -119,10 +119,14 @@ run_pytest_reporting() {
tests/test_categories_roadmap.py \
tests/test_report_categories_golden.py \
tests/test_categories_coverage.py \
+ tests/test_contrast_issues.py \
tests/test_indexation_coverage.py \
tests/test_crawl_segments.py \
tests/test_terminology.py \
tests/test_compare_payload.py \
+ tests/test_optional_audits.py \
+ tests/test_property_profile.py \
+ tests/test_reporting_gaps.py \
--cov=website_profiling.reporting \
--cov-config=.coveragerc.reporting \
--cov-report=term-missing \
@@ -133,7 +137,7 @@ run_pytest_reporting() {
run_pytest_tools() {
[[ "$PYTEST_NO_COV" -eq 1 ]] && return 0
- log "Pytest (tools coverage gate, 95%)"
+ log "Pytest (tools coverage gate, 100%)"
"$VENV/bin/pytest" \
tests/test_alert_checker.py \
tests/test_schedule_runner.py \
@@ -143,6 +147,7 @@ run_pytest_tools() {
tests/test_audit_tools_expanded.py \
tests/test_audit_tools_coverage.py \
tests/test_audit_tools_dispatch_coverage.py \
+ tests/test_audit_tools_links_extras.py \
tests/test_export_custom_coverage.py \
tests/test_export_artifacts_coverage.py \
tests/test_export_compare_coverage.py \
@@ -151,12 +156,15 @@ run_pytest_tools() {
tests/test_export_custom.py \
tests/test_export_artifacts.py \
tests/test_export_compare.py \
+ tests/test_export_workbook.py \
+ tests/test_export_sitemap.py \
tests/test_mcp_registry.py \
tests/test_mcp_resources.py \
+ tests/test_tools_branch_coverage.py \
--cov=website_profiling.tools \
--cov-config=.coveragerc.tools \
--cov-report=term-missing \
- --cov-fail-under=95 \
+ --cov-fail-under=100 \
-q \
-o addopts=
}
diff --git a/src/website_profiling/analysis/page.py b/src/website_profiling/analysis/page.py
index 6ae3612e..e9185a97 100644
--- a/src/website_profiling/analysis/page.py
+++ b/src/website_profiling/analysis/page.py
@@ -85,6 +85,97 @@ def walk(obj: object) -> bool:
return walk(data)
+_CONTACT_ORG_TYPES = frozenset({
+ "organization",
+ "localbusiness",
+ "corporation",
+ "store",
+ "restaurant",
+ "professionalService",
+ "newsmediaorganization",
+})
+_CONTACT_CAP = 10
+
+
+def _normalize_type_name(raw: object) -> str:
+ if isinstance(raw, str):
+ return raw.strip()
+ if isinstance(raw, list) and raw:
+ return _normalize_type_name(raw[0])
+ return ""
+
+
+def _collect_json_ld_types(data: object, types: set[str]) -> None:
+ if isinstance(data, dict):
+ t = data.get("@type")
+ name = _normalize_type_name(t)
+ if name:
+ types.add(name)
+ graph = data.get("@graph")
+ if isinstance(graph, list):
+ for item in graph:
+ _collect_json_ld_types(item, types)
+ for key, val in data.items():
+ if key in ("@graph", "@type"):
+ continue
+ if isinstance(val, (dict, list)):
+ _collect_json_ld_types(val, types)
+ elif isinstance(data, list):
+ for item in data:
+ _collect_json_ld_types(item, types)
+
+
+def _format_postal_address(addr: object) -> str:
+ if isinstance(addr, str):
+ return addr.strip()[:500]
+ if not isinstance(addr, dict):
+ return ""
+ parts: list[str] = []
+ for key in ("streetAddress", "addressLocality", "addressRegion", "postalCode", "addressCountry"):
+ val = addr.get(key)
+ if isinstance(val, str) and val.strip():
+ parts.append(val.strip())
+ return ", ".join(parts)[:500]
+
+
+def _append_contact(signals: dict[str, list[str]], key: str, value: object) -> None:
+ if not isinstance(value, str):
+ return
+ val = value.strip()
+ if not val:
+ return
+ bucket = signals.setdefault(key, [])
+ if len(bucket) >= _CONTACT_CAP:
+ return
+ if val not in bucket:
+ bucket.append(val)
+
+
+def _collect_json_ld_contacts(data: object, signals: dict[str, list[str]]) -> None:
+ if isinstance(data, dict):
+ type_name = _normalize_type_name(data.get("@type")).lower()
+ is_org = any(t in type_name for t in _CONTACT_ORG_TYPES) or type_name in _CONTACT_ORG_TYPES
+ if is_org:
+ _append_contact(signals, "organization_names", data.get("name"))
+ _append_contact(signals, "emails", data.get("email"))
+ _append_contact(signals, "phones", data.get("telephone"))
+ addr = _format_postal_address(data.get("address"))
+ if addr:
+ _append_contact(signals, "addresses", addr)
+ graph = data.get("@graph")
+ if isinstance(graph, list):
+ for item in graph:
+ _collect_json_ld_contacts(item, signals)
+ for key, val in data.items():
+ if key in ("@graph",):
+ continue
+ if isinstance(val, (dict, list)):
+ _collect_json_ld_contacts(val, signals)
+ elif isinstance(data, list):
+ for item in data:
+ _collect_json_ld_contacts(item, signals)
+
+
def analyze_html(
html: str,
page_url: str,
@@ -322,6 +413,14 @@ def _script_in_head(sc) -> bool:
image_urls.append(url)
out["image_urls"] = _cap(image_urls)
+ json_ld_types: set[str] = set()
+ contact_signals: dict[str, list[str]] = {
+ "emails": [],
+ "phones": [],
+ "addresses": [],
+ "organization_names": [],
+ }
+
# JSON-LD
for idx, sc in enumerate(soup.find_all("script", type=lambda t: t and "ld+json" in str(t).lower())):
raw = (sc.string or "").strip()
@@ -332,6 +431,8 @@ def _script_in_head(sc) -> bool:
except json.JSONDecodeError:
warn("json_ld_parse", "medium", "Invalid JSON-LD block", f"Block index {idx}")
continue
+ _collect_json_ld_types(data, json_ld_types)
+ _collect_json_ld_contacts(data, contact_signals)
if _json_ld_missing_type(data):
warn(
"json_ld_missing_type",
@@ -341,6 +442,20 @@ def _script_in_head(sc) -> bool:
)
break
+ if json_ld_types:
+ out["json_ld_types"] = sorted(json_ld_types)[:_CONTACT_CAP]
+
+ for a in soup.find_all("a", href=True):
+ href = (a.get("href") or "").strip()
+ lower = href.lower()
+ if lower.startswith("mailto:"):
+ _append_contact(contact_signals, "emails", href[7:].split("?")[0])
+ elif lower.startswith("tel:"):
+ _append_contact(contact_signals, "phones", href[4:].split("?")[0])
+
+ if any(contact_signals[k] for k in contact_signals):
+ out["contact_signals"] = contact_signals
+
# Empty anchors
empty_anchors = 0
for a in soup.find_all("a", href=True):
@@ -363,5 +478,21 @@ def _script_in_head(sc) -> bool:
"Use
+ {vca.richResultsHint}
+ {meta && (meta.checked ?? 0) > 0 ? (
+
+ {format(vca.richResultsMeta, {
+ gsc: meta.gsc_count ?? 0,
+ api: meta.api_count ?? 0,
+ heuristic: meta.heuristic_count ?? 0,
+ })}
+
+ ) : null}
+ {heuristicOnly ? (
+ {vca.richResultsUpgradeHint}
+ ) : (
+
+ )}
+ >}
+ defaultSort="status"
+ rowKeyField="url"
+ emptyMessage={vca.richResultsEmpty}
+ paginationLabels={strings.views.keywordsExplorer.table}
+ />
+
+ );
+}
diff --git a/web/src/components/issues/IssueAiFixButton.tsx b/web/src/components/issues/IssueAiFixButton.tsx
index 9061f72e..ab424a8b 100644
--- a/web/src/components/issues/IssueAiFixButton.tsx
+++ b/web/src/components/issues/IssueAiFixButton.tsx
@@ -1,11 +1,8 @@
'use client';
-import { useState, useCallback } from 'react';
-import { Loader2, Sparkles } from 'lucide-react';
-import { apiUrl } from '@/lib/publicBase';
-import { strings } from '@/lib/strings';
+import AiSuggestionButton from '@/components/ai/AiSuggestionButton';
+import { buildIssueContext } from '@/lib/fixSuggestionContext';
import type { ReportIssue } from '@/types';
-import { useReadOnlySession } from '@/hooks/useReadOnlySession';
export interface IssueAiFixButtonProps {
issue: ReportIssue;
@@ -13,67 +10,12 @@ export interface IssueAiFixButtonProps {
}
export default function IssueAiFixButton({ issue, category }: IssueAiFixButtonProps) {
- const s = strings.views.issues.aiFix;
- const { readOnly } = useReadOnlySession();
- const [loading, setLoading] = useState(false);
- const [text, setText] = useState(
- typeof issue.llm_recommendation === 'string' ? issue.llm_recommendation : null,
- );
- const [error, setError] = useState(null);
-
- const handleClick = useCallback(async () => {
- if (readOnly) return;
- setLoading(true);
- setError(null);
- try {
- const res = await fetch(apiUrl('/issues/fix-suggestion'), {
- method: 'POST',
- headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify({
- message: issue.message,
- url: issue.url,
- priority: issue.priority,
- category,
- recommendation: issue.recommendation,
- type: issue.type || issue.finding_type,
- refresh: !!text,
- }),
- });
- const payload = await res.json();
- if (!res.ok) throw new Error(payload.error || s.failed);
- const fix = payload.fix as { fix?: string } | undefined;
- setText(String(fix?.fix || payload.fix || '').trim() || s.empty);
- } catch (e) {
- setError(e instanceof Error ? e.message : s.failed);
- } finally {
- setLoading(false);
- }
- }, [issue, category, text, readOnly, s.failed, s.empty]);
-
+ const initialText =
+ typeof issue.llm_recommendation === 'string' ? issue.llm_recommendation : null;
return (
-
- {!readOnly ? (
-
- ) : null}
- {error ?
{error}
: null}
- {text ? (
-
- {s.label}:
- {text}
-
- ) : null}
-
+
);
}
diff --git a/web/src/components/keywordsExplorer/CompetitorKeywordGapPanel.tsx b/web/src/components/keywordsExplorer/CompetitorKeywordGapPanel.tsx
new file mode 100644
index 00000000..35c57fab
--- /dev/null
+++ b/web/src/components/keywordsExplorer/CompetitorKeywordGapPanel.tsx
@@ -0,0 +1,50 @@
+'use client';
+
+import { useMemo } from 'react';
+import SortablePaginatedTable from '@/components/google/SortablePaginatedTable';
+import { strings } from '@/lib/strings';
+import type { CompetitorKeywordGapRow } from '@/types/report';
+import type { TableColumn } from '@/types/components';
+import AiSuggestionButton from '@/components/ai/AiSuggestionButton';
+import { buildKeywordGapContext } from '@/lib/fixSuggestionContext';
+
+interface CompetitorKeywordGapPanelProps {
+ rows: CompetitorKeywordGapRow[];
+}
+
+export default function CompetitorKeywordGapPanel({ rows }: CompetitorKeywordGapPanelProps) {
+ const ke = strings.views.keywordsExplorer.competitorGap;
+ const columns = useMemo((): TableColumn[] => [
+ { key: 'keyword', label: ke.colKeyword },
+ { key: 'competitor', label: ke.colCompetitor },
+ { key: 'volume', label: ke.colVolume },
+ { key: 'position', label: ke.colPosition },
+ { key: 'url', label: ke.colUrl },
+ {
+ key: '_ai',
+ label: '',
+ render: (_v, row) => (
+
+ ),
+ },
+ ], [ke]);
+
+ if (!rows.length) {
+ return {ke.empty}
;
+ }
+
+ return (
+
+
{ke.title}
+
{ke.hint}
+
>}
+ defaultSort="volume"
+ rowKeyField="keyword"
+ emptyMessage={ke.empty}
+ paginationLabels={strings.views.keywordsExplorer.table}
+ />
+
+ );
+}
diff --git a/web/src/components/keywordsExplorer/CompetitorKeywordImport.tsx b/web/src/components/keywordsExplorer/CompetitorKeywordImport.tsx
new file mode 100644
index 00000000..1733ab5a
--- /dev/null
+++ b/web/src/components/keywordsExplorer/CompetitorKeywordImport.tsx
@@ -0,0 +1,68 @@
+'use client';
+
+import { useState } from 'react';
+import { Upload } from 'lucide-react';
+import { Button, Card } from '@/components';
+import { apiUrl } from '@/lib/publicBase';
+
+interface CompetitorKeywordImportProps {
+ propertyId: number;
+ onImported?: (count: number) => void;
+}
+
+export default function CompetitorKeywordImport({ propertyId, onImported }: CompetitorKeywordImportProps) {
+ const [competitor, setCompetitor] = useState('');
+ const [csvText, setCsvText] = useState('');
+ const [status, setStatus] = useState('');
+ const [busy, setBusy] = useState(false);
+
+ const handleImport = async () => {
+ if (!propertyId || !competitor.trim() || !csvText.trim()) return;
+ setBusy(true);
+ setStatus('');
+ try {
+ const res = await fetch(apiUrl('/keywords/competitor-import'), {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ propertyId, competitor: competitor.trim(), csvText }),
+ });
+ const body = await res.json();
+ if (!res.ok) throw new Error(body.error || 'Import failed');
+ setStatus(`Imported ${body.count ?? 0} keywords`);
+ onImported?.(body.count ?? 0);
+ } catch (e) {
+ setStatus(e instanceof Error ? e.message : 'Import failed');
+ } finally {
+ setBusy(false);
+ }
+ };
+
+ return (
+
+
+
+ Competitor keyword CSV (Ahrefs / Semrush export)
+
+ setCompetitor(e.target.value)}
+ className="w-full rounded-lg border border-default bg-background px-3 py-2 text-sm"
+ />
+
+ );
+}
diff --git a/web/src/components/keywordsExplorer/ContentTemplatesPanel.tsx b/web/src/components/keywordsExplorer/ContentTemplatesPanel.tsx
new file mode 100644
index 00000000..aff99aa1
--- /dev/null
+++ b/web/src/components/keywordsExplorer/ContentTemplatesPanel.tsx
@@ -0,0 +1,166 @@
+'use client';
+
+import { useCallback, useState } from 'react';
+import { FileText, Loader2, X } from 'lucide-react';
+import { apiUrl } from '@/lib/publicBase';
+import { strings } from '@/lib/strings';
+import type { KeywordRow } from '@/types/components';
+import { useReadOnlySession } from '@/hooks/useReadOnlySession';
+import { Card, Button } from '@/components';
+
+const TEMPLATES = [
+ {
+ id: 'blog',
+ title: 'Blog post',
+ outline: ['H1 with primary keyword', 'Intro (150 words)', 'H2 sections (3–5)', 'FAQ block', 'Internal links (3+)'],
+ },
+ {
+ id: 'landing',
+ title: 'Landing page',
+ outline: ['Hero + value prop', 'Social proof', 'Feature bullets', 'Primary CTA', 'Schema: Organization or Product'],
+ },
+ {
+ id: 'comparison',
+ title: 'Comparison page',
+ outline: ['H1: X vs Y', 'Summary table', 'Pros/cons per option', 'Recommendation', 'FAQ schema'],
+ },
+] as const;
+
+interface ContentBriefResult {
+ keyword?: string;
+ summary?: string;
+ provenance?: string;
+}
+
+interface ContentTemplatesPanelProps {
+ defaultKeyword?: string;
+ clusterRows?: KeywordRow[];
+}
+
+export default function ContentTemplatesPanel({ defaultKeyword = '', clusterRows = [] }: ContentTemplatesPanelProps) {
+ const s = strings.views.keywordsExplorer.contentBrief;
+ const { readOnly } = useReadOnlySession();
+ const [activeTemplate, setActiveTemplate] = useState<(typeof TEMPLATES)[number] | null>(null);
+ const [keyword, setKeyword] = useState(defaultKeyword);
+ const [loading, setLoading] = useState(false);
+ const [brief, setBrief] = useState(null);
+ const [error, setError] = useState(null);
+
+ const openTemplate = useCallback(
+ (template: (typeof TEMPLATES)[number]) => {
+ if (readOnly) return;
+ setActiveTemplate(template);
+ setBrief(null);
+ setError(null);
+ if (!keyword.trim() && defaultKeyword) setKeyword(defaultKeyword);
+ },
+ [readOnly, keyword, defaultKeyword],
+ );
+
+ const generateBrief = useCallback(async () => {
+ if (readOnly || !activeTemplate) return;
+ const kw = keyword.trim();
+ if (!kw) {
+ setError('Enter a target keyword first.');
+ return;
+ }
+ setLoading(true);
+ setError(null);
+ setBrief(null);
+ try {
+ const res = await fetch(apiUrl('/keywords/content-brief'), {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({
+ keyword: `${kw} (${activeTemplate.title} template)`,
+ rows: clusterRows.slice(0, 20),
+ templateId: activeTemplate.id,
+ }),
+ });
+ const payload = await res.json();
+ if (!res.ok) throw new Error(payload.error || s.failed);
+ setBrief((payload.brief || null) as ContentBriefResult | null);
+ } catch (e) {
+ setError(e instanceof Error ? e.message : s.failed);
+ } finally {
+ setLoading(false);
+ }
+ }, [readOnly, activeTemplate, keyword, clusterRows, s.failed]);
+
+ return (
+ <>
+
+ {TEMPLATES.map((t) => (
+
+ {t.title}
+
+ {t.outline.map((line) => (
+ - {line}
+ ))}
+
+
+
+ ))}
+
+ {activeTemplate ? (
+
+
+
+
+ {activeTemplate.title}
+
+
+
+
+
+ Target keyword
+ setKeyword(e.target.value)}
+ className="mt-1 w-full rounded-md border border-default bg-brand-900 px-3 py-2 text-sm text-foreground"
+ placeholder="primary keyword"
+ />
+
+
+ {error ?
{error}
: null}
+ {brief?.summary ? (
+ <>
+
+ {brief.summary}
+
+ {brief.provenance ? (
+
{s.provenance}: {brief.provenance}
+ ) : null}
+ >
+ ) : null}
+
+
+
+ ) : null}
+ >
+ );
+}
diff --git a/web/src/components/keywordsExplorer/KeywordPanels.tsx b/web/src/components/keywordsExplorer/KeywordPanels.tsx
index 04049bc4..2b08a2f3 100644
--- a/web/src/components/keywordsExplorer/KeywordPanels.tsx
+++ b/web/src/components/keywordsExplorer/KeywordPanels.tsx
@@ -10,6 +10,8 @@ import { useSearchParams } from 'next/navigation';
import { apiUrl } from '../../lib/publicBase';
import { buildLinksInspectHref } from '../../lib/reportNav';
import UrlInspectorButton from '@/components/UrlInspectorButton';
+import AiSuggestionButton from '@/components/ai/AiSuggestionButton';
+import { buildCannibalisationContext, buildMisalignmentContext } from '@/lib/fixSuggestionContext';
import { strings, format } from '../../lib/strings';
import { Card } from '../index';
import CopyBtn from '../links/CopyBtn';
@@ -126,6 +128,7 @@ export function CannibalisationPanel({ items }: CannibalisationPanelProps) {
))}
+
))}
@@ -206,6 +209,7 @@ export function QueryPageMisalignmentPanel({ items }: QueryPageMisalignmentPanel
+
))}
diff --git a/web/src/components/keywordsExplorer/KeywordTabBanner.tsx b/web/src/components/keywordsExplorer/KeywordTabBanner.tsx
index 7903036e..b2c6c83d 100644
--- a/web/src/components/keywordsExplorer/KeywordTabBanner.tsx
+++ b/web/src/components/keywordsExplorer/KeywordTabBanner.tsx
@@ -12,6 +12,9 @@ import {
Zap,
Target,
ArrowRightLeft,
+ Layers,
+ BookOpen,
+ Users,
} from 'lucide-react';
import type { KeywordTabId } from './keywordTabMeta';
import { strings } from '../../lib/strings';
@@ -27,6 +30,9 @@ const TAB_ICONS: Record = {
cannib: Split,
alignment: ArrowRightLeft,
bypage: FileText,
+ topics: Layers,
+ templates: BookOpen,
+ competitor: Users,
};
export interface KeywordTabBannerProps {
diff --git a/web/src/components/keywordsExplorer/TopicMapPanel.tsx b/web/src/components/keywordsExplorer/TopicMapPanel.tsx
new file mode 100644
index 00000000..0920dd0e
--- /dev/null
+++ b/web/src/components/keywordsExplorer/TopicMapPanel.tsx
@@ -0,0 +1,35 @@
+'use client';
+
+import { Card } from '@/components';
+
+interface ClusterRow {
+ topic?: string;
+ keywords?: string[];
+ size?: number;
+}
+
+interface TopicMapPanelProps {
+ clusters: ClusterRow[];
+ emptyLabel: string;
+}
+
+export default function TopicMapPanel({ clusters, emptyLabel }: TopicMapPanelProps) {
+ if (!clusters.length) {
+ return {emptyLabel}
;
+ }
+ return (
+
+ {clusters.slice(0, 24).map((c, i) => (
+
+ {c.topic || `Cluster ${i + 1}`}
+ {c.size ?? c.keywords?.length ?? 0} keywords
+
+ {(c.keywords || []).slice(0, 8).map((kw) => (
+ - {kw}
+ ))}
+
+
+ ))}
+
+ );
+}
diff --git a/web/src/components/keywordsExplorer/keywordTabMeta.ts b/web/src/components/keywordsExplorer/keywordTabMeta.ts
index ad282ef8..d287932a 100644
--- a/web/src/components/keywordsExplorer/keywordTabMeta.ts
+++ b/web/src/components/keywordsExplorer/keywordTabMeta.ts
@@ -17,7 +17,10 @@ export type KeywordTabId =
| KeywordTableTabId
| 'cannib'
| 'alignment'
- | 'bypage';
+ | 'bypage'
+ | 'topics'
+ | 'templates'
+ | 'competitor';
export function isTableTab(tab: KeywordTabId): tab is KeywordTableTabId {
return (KEYWORD_TABLE_TAB_IDS as readonly string[]).includes(tab);
diff --git a/web/src/components/lighthouse/DiagnosticItem.tsx b/web/src/components/lighthouse/DiagnosticItem.tsx
index 2d600f51..d2c423fb 100644
--- a/web/src/components/lighthouse/DiagnosticItem.tsx
+++ b/web/src/components/lighthouse/DiagnosticItem.tsx
@@ -1,5 +1,9 @@
+'use client';
+
import { useState } from 'react';
import { ChevronDown, ChevronUp } from 'lucide-react';
+import AiSuggestionButton from '@/components/ai/AiSuggestionButton';
+import { buildLighthouseDiagnosticContext } from '@/lib/fixSuggestionContext';
import type { LighthouseDiagnostic } from '@/types/report';
import LhDetailsTable from './LhDetailsTable';
@@ -53,6 +57,7 @@ export default function DiagnosticItem({ d }: DiagnosticItemProps) {
{d.detailed_fix && (
{d.detailed_fix}
)}
+
{d.estimated_impact && (
diff --git a/web/src/components/lighthouse/LhAuditExpandable.tsx b/web/src/components/lighthouse/LhAuditExpandable.tsx
index 966accc6..6cc85fc9 100644
--- a/web/src/components/lighthouse/LhAuditExpandable.tsx
+++ b/web/src/components/lighthouse/LhAuditExpandable.tsx
@@ -1,5 +1,9 @@
+'use client';
+
import { useState } from 'react';
import { ChevronDown, ChevronRight } from 'lucide-react';
+import AiSuggestionButton from '@/components/ai/AiSuggestionButton';
+import { buildLighthouseAuditContext } from '@/lib/fixSuggestionContext';
import type { LighthouseAuditRef } from '@/types/report';
import LhDetailsTable from './LhDetailsTable';
@@ -40,6 +44,7 @@ export default function LhAuditExpandable({ audit }: LhAuditExpandableProps) {
)}
{hasTable && items && }
{!hasTable && No detail rows for this audit.
}
+
)}
diff --git a/web/src/components/lighthouse/QuickWinCard.tsx b/web/src/components/lighthouse/QuickWinCard.tsx
index f57989d2..e8efa672 100644
--- a/web/src/components/lighthouse/QuickWinCard.tsx
+++ b/web/src/components/lighthouse/QuickWinCard.tsx
@@ -1,5 +1,9 @@
+'use client';
+
import { useState } from 'react';
import { CheckCircle, XCircle, ChevronDown, ChevronUp, Zap, Image, Code2, Search, Shield, Clock, type LucideIcon } from 'lucide-react';
+import AiSuggestionButton from '@/components/ai/AiSuggestionButton';
+import { buildLighthouseQuickWinContext } from '@/lib/fixSuggestionContext';
import type { LighthouseQuickWin } from '@/types/report';
const ICON_MAP: Record = { Zap, Image, Code2, Search, Shield, Clock };
@@ -59,6 +63,7 @@ export default function QuickWinCard({ win, passed }: QuickWinCardProps) {
Estimated impact:
{win.impact}
+
)}
diff --git a/web/src/components/links/InspectorTabs.tsx b/web/src/components/links/InspectorTabs.tsx
index 8d8256e5..0da6f845 100644
--- a/web/src/components/links/InspectorTabs.tsx
+++ b/web/src/components/links/InspectorTabs.tsx
@@ -13,6 +13,8 @@ import TechnicalTab from './tabs/TechnicalTab';
import IssuesTab from './tabs/IssuesTab';
import PageAnalysisTab from './tabs/PageAnalysisTab';
import SearchRetentionTab from './tabs/SearchRetentionTab';
+import PageImprovePanel from './PageImprovePanel';
+import { Sparkles } from 'lucide-react';
const ci = strings.components.inspectorTabs;
@@ -24,9 +26,10 @@ const TAB_ICONS: Record = {
content: ,
technical: ,
issues: ,
+ improve: ,
};
-const TAB_IDS = ['overview', 'analysis', 'search', 'seo', 'content', 'technical', 'issues'] as const;
+const TAB_IDS = ['overview', 'analysis', 'search', 'seo', 'content', 'technical', 'issues', 'improve'] as const;
const TAB_LABELS: Record = {
overview: ci.overview,
@@ -36,6 +39,7 @@ const TAB_LABELS: Record = {
content: ci.content,
technical: ci.technical,
issues: ci.issues,
+ improve: 'Page Improve',
};
function buildAllIssues(inspectorDetails: InspectorDetails | null): InspectorIssueRow[] {
@@ -148,7 +152,10 @@ export default function InspectorTabs({
{activeTab === 'content' && }
{activeTab === 'technical' && }
{activeTab === 'issues' && (
-
+
+ )}
+ {activeTab === 'improve' && (
+
)}
diff --git a/web/src/components/links/LinkAttributesCharts.tsx b/web/src/components/links/LinkAttributesCharts.tsx
new file mode 100644
index 00000000..b6754d5d
--- /dev/null
+++ b/web/src/components/links/LinkAttributesCharts.tsx
@@ -0,0 +1,279 @@
+'use client';
+
+import { useMemo } from 'react';
+import { Bar, Doughnut } from 'react-chartjs-2';
+import type { TooltipItem } from 'chart.js';
+import { Card } from '@/components';
+import { ChartAccessibleFallback } from '@/components/charts';
+import { strings } from '@/lib/strings';
+import type { InlinkAnchorRow, LinkRelSummary } from '@/types/report';
+import { formatPageHrefLines } from '@/utils/linkUtils';
+import { truncateLabel } from '@/components/google/tableUtils';
+import { palette } from '@/utils/chartPalette';
+import { registerChartJsBase, barOptionsHorizontal } from '@/utils/chartJsDefaults';
+import { doughnutOptionsWithPercentTooltip, filterZeroSlices, formatCompositionAria } from '@/lib/chartDoughnutUtils';
+
+registerChartJsBase();
+
+const TOP_N = 10;
+
+function aggregateInlinks(
+ rows: InlinkAnchorRow[],
+ key: 'anchor_text' | 'target_url',
+ limit = TOP_N,
+): Array<{ label: string; value: number; title?: string }> {
+ const totals = new Map();
+ for (const row of rows) {
+ const raw = (row[key] ?? '').trim();
+ const mapKey = raw || '(empty)';
+ const prev = totals.get(mapKey)?.value ?? 0;
+ const title = key === 'target_url' ? raw || mapKey : undefined;
+ totals.set(mapKey, {
+ value: prev + (row.inlink_count ?? 0),
+ title,
+ });
+ }
+ return [...totals.entries()]
+ .map(([label, { value, title }]) => ({
+ label: key === 'target_url' ? formatPageHrefLines(title ?? label).label : label,
+ value,
+ title: title ?? label,
+ }))
+ .sort((a, b) => b.value - a.value)
+ .slice(0, limit);
+}
+
+function linkBarOptions(titleAtIndex: (index: number) => string) {
+ const base = barOptionsHorizontal();
+ return {
+ ...base,
+ plugins: {
+ ...base.plugins,
+ tooltip: {
+ callbacks: {
+ title: (items: TooltipItem<'bar'>[]) => {
+ const idx = items[0]?.dataIndex ?? 0;
+ return titleAtIndex(idx) || items[0]?.label || '';
+ },
+ label: (ctx: TooltipItem<'bar'>) => ` ${Number(ctx.raw).toLocaleString()} inlinks`,
+ },
+ },
+ },
+ scales: {
+ ...base.scales,
+ y: {
+ ...(base.scales?.y as object),
+ ticks: {
+ maxWidth: 112,
+ font: { size: 11 },
+ callback(this: { getLabelForValue: (v: number) => string }, value: string | number) {
+ const label = this.getLabelForValue(Number(value));
+ return truncateLabel(label, 36);
+ },
+ },
+ },
+ },
+ };
+}
+
+interface LinkAttributesChartsProps {
+ summary?: LinkRelSummary | null;
+ anchors?: InlinkAnchorRow[];
+ labels: {
+ internal: string;
+ external: string;
+ nofollow: string;
+ sponsored: string;
+ follow: string;
+ ugc: string;
+ };
+}
+
+export default function LinkAttributesCharts({ summary, anchors, labels }: LinkAttributesChartsProps) {
+ const vl = strings.views.links;
+
+ const scopeChart = useMemo(() => {
+ if (!summary) return null;
+ const internal = summary.internal_edges ?? 0;
+ const external = summary.external_edges ?? 0;
+ const { labels: sliceLabels, values } = filterZeroSlices(
+ [labels.internal, labels.external],
+ [internal, external],
+ );
+ if (values.length === 0) return null;
+ return {
+ labels: sliceLabels,
+ values,
+ colors: palette(values.length),
+ aria: formatCompositionAria(sliceLabels, values, 'links'),
+ };
+ }, [summary, labels.internal, labels.external]);
+
+ const internalAttrsChart = useMemo(() => {
+ if (!summary) return null;
+ const internal = summary.internal_edges ?? 0;
+ const nofollow = summary.nofollow_internal ?? 0;
+ const sponsored = summary.sponsored_internal ?? 0;
+ const ugc = summary.ugc_internal ?? 0;
+ const follow = Math.max(0, internal - nofollow - sponsored - ugc);
+ const sliceLabels = [labels.follow, labels.nofollow, labels.sponsored, labels.ugc];
+ const rawValues = [follow, nofollow, sponsored, ugc];
+ const { labels: filteredLabels, values } = filterZeroSlices(sliceLabels, rawValues);
+ if (values.length === 0) return null;
+ return {
+ labels: filteredLabels,
+ values,
+ colors: palette(values.length),
+ aria: formatCompositionAria(filteredLabels, values, 'internal links'),
+ };
+ }, [summary, labels.follow, labels.nofollow, labels.sponsored, labels.ugc]);
+
+ const topAnchors = useMemo(
+ () => (anchors?.length ? aggregateInlinks(anchors, 'anchor_text') : []),
+ [anchors],
+ );
+
+ const topTargets = useMemo(
+ () => (anchors?.length ? aggregateInlinks(anchors, 'target_url') : []),
+ [anchors],
+ );
+
+ const anchorBarOpts = useMemo(
+ () => linkBarOptions((idx) => topAnchors[idx]?.label ?? ''),
+ [topAnchors],
+ );
+
+ const targetBarOpts = useMemo(
+ () => linkBarOptions((idx) => topTargets[idx]?.title ?? topTargets[idx]?.label ?? ''),
+ [topTargets],
+ );
+
+ const hasCharts = scopeChart || internalAttrsChart || topAnchors.length > 0 || topTargets.length > 0;
+ if (!hasCharts) return null;
+
+ return (
+
+ {scopeChart ? (
+
+ {vl.chartLinkScopeTitle}
+ {vl.chartLinkScopeHint}
+
+
+ [label, scopeChart.values[i] ?? 0])}
+ >
+
+
+
+
+
+ ) : null}
+
+ {internalAttrsChart ? (
+
+ {vl.chartInternalAttrsTitle}
+ {vl.chartInternalAttrsHint}
+
+
+ [label, internalAttrsChart.values[i] ?? 0])}
+ >
+
+
+
+
+
+ ) : null}
+
+ {topAnchors.length > 0 ? (
+
+ {vl.chartTopAnchorsTitle}
+ {vl.chartTopAnchorsHint}
+
+ r.label),
+ topAnchors.map((r) => r.value),
+ 'inlinks',
+ )}
+ rows={topAnchors.map((r) => [r.label, r.value])}
+ >
+ truncateLabel(r.label, 36)),
+ datasets: [
+ {
+ data: topAnchors.map((r) => r.value),
+ backgroundColor: palette(topAnchors.length),
+ borderRadius: 4,
+ },
+ ],
+ }}
+ options={anchorBarOpts}
+ />
+
+
+
+ ) : null}
+
+ {topTargets.length > 0 ? (
+
+ {vl.chartTopTargetsTitle}
+ {vl.chartTopTargetsHint}
+
+ r.title ?? r.label),
+ topTargets.map((r) => r.value),
+ 'inlinks',
+ )}
+ rows={topTargets.map((r) => [r.title ?? r.label, r.value])}
+ >
+ truncateLabel(r.label, 36)),
+ datasets: [
+ {
+ data: topTargets.map((r) => r.value),
+ backgroundColor: palette(topTargets.length),
+ borderRadius: 4,
+ },
+ ],
+ }}
+ options={targetBarOpts}
+ />
+
+
+
+ ) : null}
+
+ );
+}
diff --git a/web/src/components/links/LinkAttributesPanel.tsx b/web/src/components/links/LinkAttributesPanel.tsx
new file mode 100644
index 00000000..924e5da3
--- /dev/null
+++ b/web/src/components/links/LinkAttributesPanel.tsx
@@ -0,0 +1,88 @@
+'use client';
+
+import SortablePaginatedTable from '@/components/google/SortablePaginatedTable';
+import { Card } from '@/components';
+import { strings } from '@/lib/strings';
+import type { InlinkAnchorRow, LinkRelSummary } from '@/types/report';
+import type { TableColumn } from '@/types/components';
+import LinkAttributesCharts from './LinkAttributesCharts';
+
+const paginationLabels = {
+ showingSlice: strings.views.backlinks.table.showingSlice,
+ pageOf: strings.views.backlinks.table.pageOf,
+ of: strings.views.backlinks.table.of,
+ previous: strings.views.backlinks.table.previous,
+ next: strings.views.backlinks.table.next,
+ rowsPerPage: strings.views.backlinks.table.rowsPerPage,
+};
+
+interface LinkAttributesPanelProps {
+ summary?: LinkRelSummary | null;
+ anchors?: InlinkAnchorRow[];
+ labels: {
+ title: string;
+ total: string;
+ internal: string;
+ nofollow: string;
+ sponsored: string;
+ external: string;
+ anchorMatrix: string;
+ target: string;
+ anchor: string;
+ inlinks: string;
+ follow: string;
+ ugc: string;
+ };
+}
+
+export default function LinkAttributesPanel({ summary, anchors, labels }: LinkAttributesPanelProps) {
+ if (!summary && !(anchors?.length)) return null;
+
+ const columns: TableColumn[] = [
+ { key: 'target_url', label: labels.target },
+ { key: 'anchor_text', label: labels.anchor },
+ {
+ key: 'inlink_count',
+ label: labels.inlinks,
+ render: (v) => (typeof v === 'number' ? v.toLocaleString() : String(v ?? '')),
+ },
+ ];
+
+ return (
+
+
+ {summary ? (
+
+ {labels.total}{(summary.total_edges ?? 0).toLocaleString()}
+ {labels.internal}{(summary.internal_edges ?? 0).toLocaleString()}
+ {labels.nofollow}{(summary.nofollow_internal ?? 0).toLocaleString()}
+ {labels.sponsored}{(summary.sponsored_internal ?? 0).toLocaleString()}
+ {labels.external}{(summary.external_edges ?? 0).toLocaleString()}
+
+ ) : null}
+ {anchors?.length ? (
+
+ {labels.anchorMatrix}
+ []}
+ columns={columns}
+ defaultSort="inlink_count"
+ defaultDir="desc"
+ paginationLabels={paginationLabels}
+ />
+
+ ) : null}
+
+ );
+}
diff --git a/web/src/components/links/PageImprovePanel.tsx b/web/src/components/links/PageImprovePanel.tsx
new file mode 100644
index 00000000..94e7e7c3
--- /dev/null
+++ b/web/src/components/links/PageImprovePanel.tsx
@@ -0,0 +1,116 @@
+'use client';
+
+import { useCallback, useState } from 'react';
+import { Loader2, Sparkles } from 'lucide-react';
+import { apiUrl } from '@/lib/publicBase';
+import { strings } from '@/lib/strings';
+import { useOptionalReport } from '@/context/useReport';
+import { useReadOnlySession } from '@/hooks/useReadOnlySession';
+import type { InspectorDetails } from '@/types/report';
+import { Button } from '@/components';
+
+const FIX_TEMPLATES: Record = {
+ title: 'Add a unique, descriptive title tag (50–60 characters) with primary keyword near the start.',
+ meta: 'Write a compelling meta description (120–160 characters) that matches search intent.',
+ canonical: 'Set rel=canonical to the preferred URL version of this page.',
+ h1: 'Use exactly one H1 that describes the main topic; align it with the title where appropriate.',
+ noindex: 'Remove noindex if this page should rank; keep it only for thin or duplicate URLs.',
+ broken: 'Fix or remove links to broken URLs; update redirects at the source.',
+ accessibility: 'Address axe/Lighthouse accessibility findings on this URL.',
+};
+
+interface PageCoachResult {
+ summary?: string;
+ actions?: string[];
+ provenance?: string;
+}
+
+interface PageImprovePanelProps {
+ url: string;
+ inspectorDetails: InspectorDetails | null;
+}
+
+export default function PageImprovePanel({ url, inspectorDetails }: PageImprovePanelProps) {
+ const pi = strings.components.pageImprove;
+ const reportCtx = useOptionalReport();
+ const { readOnly } = useReadOnlySession();
+ const [coachLoading, setCoachLoading] = useState(false);
+ const [coachError, setCoachError] = useState(null);
+ const [coach, setCoach] = useState(null);
+
+ const items = inspectorDetails?.categoryIssues || [];
+ const checklist = items.length
+ ? items.map((iss) => ({
+ message: iss.message || 'Issue',
+ recommendation: iss.recommendation || FIX_TEMPLATES.accessibility,
+ priority: iss.priority || 'Medium',
+ }))
+ : [
+ { message: 'No open category issues for this URL.', recommendation: FIX_TEMPLATES.title, priority: 'Low' },
+ ];
+
+ const fetchCoach = useCallback(async () => {
+ if (readOnly) return;
+ setCoachLoading(true);
+ setCoachError(null);
+ try {
+ const body: Record = { url };
+ const reportId = reportCtx?.selectedReportId;
+ if (reportId != null) {
+ body.currentType = 'snapshot';
+ body.currentId = reportId;
+ }
+ const res = await fetch(apiUrl('/links/page-coach'), {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify(body),
+ });
+ const payload = await res.json();
+ if (!res.ok || !payload.ok) {
+ throw new Error(payload.error || pi.coachFailed);
+ }
+ setCoach((payload.coach || null) as PageCoachResult | null);
+ } catch (e) {
+ setCoachError(e instanceof Error ? e.message : pi.coachFailed);
+ } finally {
+ setCoachLoading(false);
+ }
+ }, [readOnly, url, reportCtx?.selectedReportId, pi.coachFailed]);
+
+ return (
+
+
+ Page Improve checklist for {url}
+
+ {!readOnly ? (
+
+ ) : null}
+ {coachError ?
{coachError}
: null}
+ {coach?.summary ? (
+
+
{pi.coachTitle}
+
{coach.summary}
+ {coach.actions?.length ? (
+
+ {coach.actions.slice(0, 8).map((action) => (
+ - {action}
+ ))}
+
+ ) : null}
+
+ ) : null}
+
+ {checklist.slice(0, 12).map((item, i) => (
+ -
+ {item.message}
+ {item.priority}
+
{item.recommendation}
+
+ ))}
+
+
+ );
+}
diff --git a/web/src/components/links/SavedCrawlFiltersBar.tsx b/web/src/components/links/SavedCrawlFiltersBar.tsx
new file mode 100644
index 00000000..7582a97e
--- /dev/null
+++ b/web/src/components/links/SavedCrawlFiltersBar.tsx
@@ -0,0 +1,96 @@
+'use client';
+
+import { useCallback, useEffect, useState } from 'react';
+import { BookmarkPlus } from 'lucide-react';
+import { Button } from '@/components';
+import { apiUrl } from '@/lib/publicBase';
+import type { LinksFilterValues } from './LinksFilterBar';
+
+interface SavedCrawlFiltersBarProps {
+ propertyId: number;
+ filterValues: LinksFilterValues;
+ onLoad: (values: LinksFilterValues) => void;
+}
+
+export default function SavedCrawlFiltersBar({ propertyId, filterValues, onLoad }: SavedCrawlFiltersBarProps) {
+ const [names, setNames] = useState([]);
+ const [selected, setSelected] = useState('');
+ const [status, setStatus] = useState('');
+
+ const refresh = useCallback(async () => {
+ if (!propertyId) {
+ setNames([]);
+ return;
+ }
+ try {
+ const res = await fetch(apiUrl(`/filters?propertyId=${propertyId}`));
+ const body = await res.json();
+ setNames((body.filters || []).map((f: { name: string }) => f.name));
+ } catch {
+ setNames([]);
+ }
+ }, [propertyId]);
+
+ useEffect(() => {
+ void refresh();
+ }, [refresh]);
+
+ const save = async () => {
+ const name = window.prompt('Filter name');
+ if (!name?.trim() || !propertyId) return;
+ setStatus('');
+ try {
+ const res = await fetch(apiUrl('/filters'), {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ propertyId, name: name.trim(), filterJson: filterValues }),
+ });
+ if (!res.ok) throw new Error('Save failed');
+ setStatus(`Saved "${name.trim()}"`);
+ await refresh();
+ } catch {
+ setStatus('Could not save filter');
+ }
+ };
+
+ const load = async () => {
+ if (!selected || !propertyId) return;
+ try {
+ const res = await fetch(apiUrl(`/filters?propertyId=${propertyId}`));
+ const body = await res.json();
+ const row = (body.filters || []).find((f: { name: string }) => f.name === selected);
+ if (row?.filterJson) onLoad(row.filterJson as LinksFilterValues);
+ } catch {
+ setStatus('Could not load filter');
+ }
+ };
+
+ if (!propertyId) return null;
+
+ return (
+
+
+ {names.length ? (
+ <>
+
+
+ >
+ ) : null}
+ {status ? {status} : null}
+
+ );
+}
diff --git a/web/src/components/links/SecHeaderRow.tsx b/web/src/components/links/SecHeaderRow.tsx
index ef193230..8b7da04b 100644
--- a/web/src/components/links/SecHeaderRow.tsx
+++ b/web/src/components/links/SecHeaderRow.tsx
@@ -1,13 +1,18 @@
+'use client';
+
import { useState } from 'react';
import { CheckCircle, XCircle, ChevronDown, ChevronUp } from 'lucide-react';
+import AiSuggestionButton from '@/components/ai/AiSuggestionButton';
+import { buildSecurityHeaderContext } from '@/lib/fixSuggestionContext';
export interface SecHeaderRowProps {
label: string;
value?: string | null;
recommendation?: string;
+ pageUrl?: string;
}
-export default function SecHeaderRow({ label, value, recommendation }: SecHeaderRowProps) {
+export default function SecHeaderRow({ label, value, recommendation, pageUrl }: SecHeaderRowProps) {
const [open, setOpen] = useState(false);
const present = !!value;
@@ -43,6 +48,9 @@ export default function SecHeaderRow({ label, value, recommendation }: SecHeader
Recommendation: {recommendation}
)}
+ {!present ? (
+
+ ) : null}
)}
diff --git a/web/src/components/links/SerpPreview.tsx b/web/src/components/links/SerpPreview.tsx
new file mode 100644
index 00000000..1f78b39d
--- /dev/null
+++ b/web/src/components/links/SerpPreview.tsx
@@ -0,0 +1,31 @@
+'use client';
+
+import type { LinkDetail } from '@/types/report';
+
+interface SerpPreviewProps {
+ link: LinkDetail;
+ domain?: string;
+}
+
+export default function SerpPreview({ link, domain }: SerpPreviewProps) {
+ const title = (link.title || 'Missing title').slice(0, 60);
+ const desc = (link.meta_description || 'Missing meta description').slice(0, 160);
+ const url = link.canonical_url || link.url;
+ let displayUrl = url;
+ try {
+ const u = new URL(url);
+ displayUrl = domain || `${u.hostname}${u.pathname}`.slice(0, 80);
+ } catch {
+ displayUrl = url.slice(0, 80);
+ }
+
+ return (
+
+
+ {title}
+
+
{displayUrl}
+
{desc}
+
+ );
+}
diff --git a/web/src/components/links/explorer/LinksExplorerAnchorsTab.tsx b/web/src/components/links/explorer/LinksExplorerAnchorsTab.tsx
new file mode 100644
index 00000000..7d21bd9f
--- /dev/null
+++ b/web/src/components/links/explorer/LinksExplorerAnchorsTab.tsx
@@ -0,0 +1,32 @@
+'use client';
+
+import LinkAttributesPanel from '@/components/links/LinkAttributesPanel';
+import type { InlinkAnchorRow, LinkRelSummary } from '@/types/report';
+import { LinksExplorerTabPanel } from './LinksExplorerTabPanel';
+
+export interface LinksExplorerAnchorsTabProps {
+ summary?: LinkRelSummary | null;
+ anchors?: InlinkAnchorRow[];
+ labels: {
+ title: string;
+ total: string;
+ internal: string;
+ nofollow: string;
+ sponsored: string;
+ external: string;
+ anchorMatrix: string;
+ target: string;
+ anchor: string;
+ inlinks: string;
+ follow: string;
+ ugc: string;
+ };
+}
+
+export function LinksExplorerAnchorsTab({ summary, anchors, labels }: LinksExplorerAnchorsTabProps) {
+ return (
+
+
+
+ );
+}
diff --git a/web/src/components/links/explorer/LinksExplorerTabPanel.tsx b/web/src/components/links/explorer/LinksExplorerTabPanel.tsx
index c5571c98..a4eb45bc 100644
--- a/web/src/components/links/explorer/LinksExplorerTabPanel.tsx
+++ b/web/src/components/links/explorer/LinksExplorerTabPanel.tsx
@@ -1,7 +1,9 @@
import type { ReactNode } from 'react';
+export type LinksExplorerTabId = 'urls' | 'anchors';
+
interface LinksExplorerTabPanelProps {
- tabId: 'urls';
+ tabId: LinksExplorerTabId;
className?: string;
children: ReactNode;
}
diff --git a/web/src/components/links/explorer/LinksExplorerTableTab.tsx b/web/src/components/links/explorer/LinksExplorerTableTab.tsx
index ad8a7f92..b8e2f78d 100644
--- a/web/src/components/links/explorer/LinksExplorerTableTab.tsx
+++ b/web/src/components/links/explorer/LinksExplorerTableTab.tsx
@@ -7,7 +7,9 @@ import { strings, format } from '@/lib/strings';
import { Card, Badge, Button } from '@/components';
import { formatMs, rtColor, formatPageHrefLines } from '@/utils/linkUtils';
import { linkHasBrowserErrors } from '@/lib/browserErrors';
+import { collectCustomFieldKeys, parseLinkCustomFields } from '@/lib/customFields';
import { SortTh, RowTooltip, LinksFilterBar, InlinksMetricCell } from '@/components/links';
+import SavedCrawlFiltersBar from '@/components/links/SavedCrawlFiltersBar';
import type { LinksFilterValues } from '@/components/links/LinksFilterBar';
import type { LinkSortKey } from './types';
import { LinksExplorerTabPanel } from './LinksExplorerTabPanel';
@@ -16,6 +18,8 @@ export interface LinksExplorerTableTabProps {
filterValues: LinksFilterValues;
onFilterChange: (key: keyof LinksFilterValues, value: string) => void;
onClearAllFilters: () => void;
+ propertyId?: number;
+ onLoadSavedFilter?: (values: LinksFilterValues) => void;
searchQuery: string;
filtered: ReportLink[];
pageLinks: ReportLink[];
@@ -40,6 +44,8 @@ export function LinksExplorerTableTab({
filterValues,
onFilterChange,
onClearAllFilters,
+ propertyId = 0,
+ onLoadSavedFilter,
searchQuery,
filtered,
pageLinks,
@@ -62,6 +68,7 @@ export function LinksExplorerTableTab({
const vl = strings.views.links;
const sj = strings.common;
const hasCustomExtract = links.some((l) => l.custom_extract);
+ const customFieldKeys = collectCustomFieldKeys(links);
return (
@@ -71,6 +78,13 @@ export function LinksExplorerTableTab({
onClearAll={onClearAllFilters}
searchQuery={searchQuery}
/>
+ {onLoadSavedFilter ? (
+
+ ) : null}
) : null}
+ {customFieldKeys.map((key) => (
+
+ {key}
+ |
+ ))}
{vl.thJsErrors}
|
@@ -207,6 +229,18 @@ export function LinksExplorerTableTab({
{link.custom_extract || sj.emDash}
) : null}
+ {customFieldKeys.map((key) => {
+ const value = parseLinkCustomFields(link)[key];
+ return (
+
+ {value || sj.emDash}
+ |
+ );
+ })}
{linkHasBrowserErrors(link) ? (
diff --git a/web/src/components/links/explorer/index.ts b/web/src/components/links/explorer/index.ts
index 7303b6b8..c3ce7477 100644
--- a/web/src/components/links/explorer/index.ts
+++ b/web/src/components/links/explorer/index.ts
@@ -1,3 +1,5 @@
export type { LinkSortKey } from './types';
+export type { LinksExplorerTabId } from './LinksExplorerTabPanel';
+export { LinksExplorerAnchorsTab } from './LinksExplorerAnchorsTab';
export { LinksExplorerTabPanel } from './LinksExplorerTabPanel';
export { LinksExplorerTableTab } from './LinksExplorerTableTab';
diff --git a/web/src/components/links/tabs/ContentTab.tsx b/web/src/components/links/tabs/ContentTab.tsx
index ab9d5f56..ed110c80 100644
--- a/web/src/components/links/tabs/ContentTab.tsx
+++ b/web/src/components/links/tabs/ContentTab.tsx
@@ -11,6 +11,9 @@ import { PALETTE_CATEGORICAL } from '../../../utils/chartPalette';
import HeadingPills from '../HeadingPills';
import { strings, format } from '../../../lib/strings';
import { getGridColor, getChartTitleColor } from '../../../utils/chartJsDefaults';
+import AiSuggestionButton from '@/components/ai/AiSuggestionButton';
+import { buildContentSignalContext } from '@/lib/fixSuggestionContext';
+import type { FixSuggestionRequest } from '@/types/fixSuggestion';
ChartJS.register(CategoryScale, LinearScale, BarElement, Title, Tooltip, Legend);
@@ -118,16 +121,29 @@ function SectionHeader({ icon, title, description }: SectionHeaderProps) {
);
}
-function QualityStatusRow({ label, detail, statusLabel }: { label: string; detail: string; statusLabel: string }) {
+function QualityStatusRow({
+ label,
+ detail,
+ statusLabel,
+ fixRequest,
+}: {
+ label: string;
+ detail: string;
+ statusLabel: string;
+ fixRequest?: FixSuggestionRequest;
+}) {
return (
-
-
- {label}
- {detail}
+
+
-
- {statusLabel}
-
+ {fixRequest ? : null}
);
}
@@ -177,9 +193,13 @@ export default function ContentTab({ link }: ContentTabProps) {
const metaQualLabels = vca.metaQualLabels;
const h1QualLabels = vca.h1Labels;
- const titleStatus = titleQualLabels[titleQualityIndex(titleLen)] ?? sj.emDash;
- const metaStatus = metaQualLabels[metaQualityIndex(metaLen)] ?? sj.emDash;
- const h1Status = h1c != null && !Number.isNaN(h1c) ? h1QualLabels[h1QualityIndex(h1c)] : null;
+ const titleQi = titleQualityIndex(titleLen);
+ const metaQi = metaQualityIndex(metaLen);
+ const h1Qi = h1c != null && !Number.isNaN(h1c) ? h1QualityIndex(h1c) : null;
+ const titleStatus = titleQualLabels[titleQi] ?? sj.emDash;
+ const metaStatus = metaQualLabels[metaQi] ?? sj.emDash;
+ const h1Status = h1Qi != null ? h1QualLabels[h1Qi] : null;
+ const pageUrl = link.url || '';
const kwSorted = useMemo(() => {
const rows = keywords.map((kw) => normaliseKw(kw)).filter((k) => k.word);
@@ -332,6 +352,11 @@ export default function ContentTab({ link }: ContentTabProps) {
label={lc.titleTag}
detail={format(lc.characters, { n: titleLen })}
statusLabel={titleStatus}
+ fixRequest={
+ titleQi !== 2
+ ? buildContentSignalContext('title', lc.titleTag, format(lc.characters, { n: titleLen }), titleStatus, pageUrl)
+ : undefined
+ }
/>
@@ -339,6 +364,11 @@ export default function ContentTab({ link }: ContentTabProps) {
label={lc.metaDesc}
detail={format(lc.characters, { n: metaLen })}
statusLabel={metaStatus}
+ fixRequest={
+ metaQi !== 2
+ ? buildContentSignalContext('meta', lc.metaDesc, format(lc.characters, { n: metaLen }), metaStatus, pageUrl)
+ : undefined
+ }
/>
@@ -347,6 +377,11 @@ export default function ContentTab({ link }: ContentTabProps) {
label={lc.h1Count}
detail={format(lc.headingCount, { n: h1c ?? 0 })}
statusLabel={h1Status}
+ fixRequest={
+ h1Qi !== 1
+ ? buildContentSignalContext('h1', lc.h1Count, format(lc.headingCount, { n: h1c ?? 0 }), h1Status, pageUrl)
+ : undefined
+ }
/>
) : (
{lc.noH1Data}
diff --git a/web/src/components/links/tabs/IssuesTab.tsx b/web/src/components/links/tabs/IssuesTab.tsx
index 78377ca8..89d8e345 100644
--- a/web/src/components/links/tabs/IssuesTab.tsx
+++ b/web/src/components/links/tabs/IssuesTab.tsx
@@ -9,15 +9,22 @@ import { palette, scoreBandColor } from '../../../utils/chartPalette';
import { registerChartJsBase, barOptionsHorizontal } from '../../../utils/chartJsDefaults';
import { RankedBarChart } from '../../../components/charts';
import { formatCompositionAria } from '../../../lib/chartDoughnutUtils';
+import AiSuggestionButton from '@/components/ai/AiSuggestionButton';
+import {
+ buildInspectorIssueContext,
+ buildLighthouseFailureContext,
+ buildRecommendationBulletContext,
+} from '@/lib/fixSuggestionContext';
registerChartJsBase();
export interface IssuesTabProps {
lhData?: LinkLighthouseData | null;
inspectorDetails: InspectorDetails | null;
+ pageUrl?: string;
}
-export default function IssuesTab({ lhData, inspectorDetails }: IssuesTabProps) {
+export default function IssuesTab({ lhData, inspectorDetails, pageUrl }: IssuesTabProps) {
const ci = strings.components.inspectorTabs;
const it = strings.components.linkTabs.issues;
const sj = strings.common;
@@ -160,8 +167,11 @@ export default function IssuesTab({ lhData, inspectorDetails }: IssuesTabProps)
{it.lighthouseFailures}
{topFailures.map((f: LighthouseAuditRef, i: number) => (
-
- {f.helpText || f.id}
+
))}
@@ -240,6 +250,7 @@ export default function IssuesTab({ lhData, inspectorDetails }: IssuesTabProps)
{issue.recommendation}
) : null}
+
)}
@@ -255,10 +266,13 @@ export default function IssuesTab({ lhData, inspectorDetails }: IssuesTabProps)
{inspectorDetails.recommendations.map((rec: string, i: number) => (
-
- {rec}
+
+
+ {rec}
+
+
))}
diff --git a/web/src/components/links/tabs/PageAnalysisTab.tsx b/web/src/components/links/tabs/PageAnalysisTab.tsx
index da026c9f..95c076c6 100644
--- a/web/src/components/links/tabs/PageAnalysisTab.tsx
+++ b/web/src/components/links/tabs/PageAnalysisTab.tsx
@@ -9,6 +9,8 @@ import { linkHasBrowserErrors } from '@/lib/browserErrors';
import { parseUrlTab } from '@/hooks/useUrlTab';
import { severityBg } from '../../../utils/linkUtils';
import { strings, format } from '../../../lib/strings';
+import AiSuggestionButton from '@/components/ai/AiSuggestionButton';
+import { buildOnPageWarningContext } from '@/lib/fixSuggestionContext';
const SECTION_TABS = ['insights', 'browser', 'warnings', 'resources'] as const;
type PageAnalysisSection = (typeof SECTION_TABS)[number];
@@ -251,7 +253,7 @@ function InsightsPanel({
);
}
-function WarningsPanel({ pa }: { pa: PageAnalysis }) {
+function WarningsPanel({ pa, pageUrl }: { pa: PageAnalysis; pageUrl: string }) {
const p = strings.components.linkTabs.pageAnalysis;
const [sevFilter, setSevFilter] = useState('All');
const filteredWarnings = useMemo(() => {
@@ -282,15 +284,18 @@ function WarningsPanel({ pa }: { pa: PageAnalysis }) {
{filteredWarnings.map((w, i) => (
-
- {w.severity || 'info'}
-
- {w.message}
- {w.detail && (
- {w.detail}
- )}
+
+
+ {w.severity || 'info'}
+
+ {w.message}
+ {w.detail && (
+ {w.detail}
+ )}
+
+
))}
@@ -438,7 +443,7 @@ export default function PageAnalysisTab({ link }: PageAnalysisTabProps) {
)}
{activeSection === 'warnings' && (
-
+
)}
{activeSection === 'resources' && (
diff --git a/web/src/components/links/tabs/SeoSocialTab.tsx b/web/src/components/links/tabs/SeoSocialTab.tsx
index 281290c3..2ebaad05 100644
--- a/web/src/components/links/tabs/SeoSocialTab.tsx
+++ b/web/src/components/links/tabs/SeoSocialTab.tsx
@@ -5,6 +5,7 @@ import { strings } from '../../../lib/strings';
import { parseTechStack } from '../../../utils/linkUtils';
import CopyBtn from '../CopyBtn';
import OGPreview from '../OGPreview';
+import SerpPreview from '../SerpPreview';
export interface SeoSocialTabProps {
link: LinkDetail;
@@ -36,6 +37,10 @@ export default function SeoSocialTab({ link }: SeoSocialTabProps) {
return (
+
+ SERP snippet preview
+
+
{s.canonicalUrl}
diff --git a/web/src/components/links/tabs/TechnicalTab.tsx b/web/src/components/links/tabs/TechnicalTab.tsx
index c2d3c028..d390c400 100644
--- a/web/src/components/links/tabs/TechnicalTab.tsx
+++ b/web/src/components/links/tabs/TechnicalTab.tsx
@@ -4,6 +4,8 @@ import type { LinkDetail } from '@/types/report';
import { strings, format } from '../../../lib/strings';
import SecHeaderRow from '../SecHeaderRow';
import MiniBar from '../MiniBar';
+import AiSuggestionButton from '@/components/ai/AiSuggestionButton';
+import { buildTechnicalLinkIssueContext } from '@/lib/fixSuggestionContext';
function headerPresent(val: unknown): boolean {
if (val == null) return false;
@@ -62,6 +64,7 @@ export default function TechnicalTab({ link }: TechnicalTabProps) {
label={h.label}
value={String(link[h.field as keyof LinkDetail] ?? '')}
recommendation={h.rec}
+ pageUrl={link.url}
/>
))}
@@ -73,14 +76,22 @@ export default function TechnicalTab({ link }: TechnicalTabProps) {
{perfRows.map(({ label, value, mono, warn }) => (
-
- {label}
-
- {value}
-
+
+
+ {label}
+
+ {value}
+
+
+ {warn ? (
+
+ ) : null}
))}
diff --git a/web/src/components/overview/OverviewHealthTab.tsx b/web/src/components/overview/OverviewHealthTab.tsx
index f8b37dd3..368259fc 100644
--- a/web/src/components/overview/OverviewHealthTab.tsx
+++ b/web/src/components/overview/OverviewHealthTab.tsx
@@ -1,5 +1,7 @@
'use client';
+import Link from 'next/link';
+import { useSearchParams } from 'next/navigation';
import { ChevronRight, Lightbulb } from 'lucide-react';
import type { ReportCategory, ReportPayload } from '@/types';
import { strings, format } from '@/lib/strings';
@@ -7,6 +9,9 @@ import { categoryDisplayName } from '@/lib/categoryDisplayNames';
import { CategoryScoreGauge } from '@/components/charts/CategoryScoreGauge';
import { Card } from '@/components';
import { OverviewTabPanel } from './OverviewTabPanel';
+import { PortfolioBenchmarkCard } from './PortfolioBenchmarkCard';
+import AiSuggestionButton from '@/components/ai/AiSuggestionButton';
+import { buildOverviewRecommendationContext } from '@/lib/fixSuggestionContext';
const REC_COLORS = [
{ border: 'border-l-blue-500', bg: 'bg-blue-500/10', text: 'text-link', dot: 'bg-blue-500' },
@@ -26,9 +31,21 @@ export interface OverviewHealthTabProps {
export function OverviewHealthTab({ data, categoriesFiltered, recommendationsFiltered }: OverviewHealthTabProps) {
const vo = strings.views.overview;
const sj = strings.common;
+ const searchParams = useSearchParams();
+ const querySuffix = searchParams.toString() ? `?${searchParams.toString()}` : '';
+ const hasSubdomains = Boolean(data.subdomains && !data.subdomains.disabled);
+ const hasContacts = Boolean(
+ data.contact_intelligence &&
+ ((data.contact_intelligence.emails?.length ?? 0) +
+ (data.contact_intelligence.phones?.length ?? 0) +
+ (data.contact_intelligence.addresses?.length ?? 0) +
+ (data.contact_intelligence.organization_names?.length ?? 0) >
+ 0),
+ );
return (
+
{vo.healthByCategory}
{data.categories && data.categories.length > 0 ? (
@@ -70,7 +87,50 @@ export function OverviewHealthTab({ data, categoriesFiltered, recommendationsFil
: ''}
+ {data.site_level.ads_txt_present != null && (
+
+ {vo.adsTxt}
+
+ {data.site_level.ads_txt_present ? sj.yes : sj.no}
+ {data.site_level.ads_txt_present
+ ? data.site_level.ads_txt_valid
+ ? vo.adsTxtValid
+ : vo.adsTxtInvalid
+ : ''}
+
+
+ )}
+ {data.site_level.security_txt_present != null && (
+
+ {vo.securityTxt}
+
+ {data.site_level.security_txt_present ? sj.yes : sj.no}
+ {data.site_level.security_txt_present &&
+ (data.site_level.security_txt_contact?.length ?? 0) > 0
+ ? format(vo.securityTxtContacts, {
+ count: data.site_level.security_txt_contact?.length ?? 0,
+ })
+ : ''}
+
+
+ )}
+ {(hasSubdomains || hasContacts) && (
+
+ {hasSubdomains ? (
+
+ {vo.viewSubdomains}
+
+
+ ) : null}
+ {hasContacts ? (
+
+ {vo.viewContacts}
+
+
+ ) : null}
+
+ )}
)}
@@ -86,10 +146,13 @@ export function OverviewHealthTab({ data, categoriesFiltered, recommendationsFil
return (
);
})}
diff --git a/web/src/components/overview/PortfolioBenchmarkCard.tsx b/web/src/components/overview/PortfolioBenchmarkCard.tsx
new file mode 100644
index 00000000..c598a789
--- /dev/null
+++ b/web/src/components/overview/PortfolioBenchmarkCard.tsx
@@ -0,0 +1,81 @@
+'use client';
+
+import { AlertCircle, TrendingUp } from 'lucide-react';
+import { Card } from '@/components';
+import { strings } from '@/lib/strings';
+import type { PortfolioBenchmark } from '@/types/report';
+
+interface PortfolioBenchmarkCardProps {
+ benchmark?: PortfolioBenchmark | null;
+}
+
+export function PortfolioBenchmarkCard({ benchmark }: PortfolioBenchmarkCardProps) {
+ const vo = strings.views.overview;
+ if (!benchmark) return null;
+
+ const status = benchmark.status;
+ const property = benchmark.property_health_score;
+ const median = benchmark.median_health_score;
+ const showScores = status === 'ok' || status == null;
+ const showBanner = status && status !== 'ok';
+
+ if (!showScores && !showBanner && property == null && median == null) return null;
+
+ const delta =
+ property != null && median != null ? property - median : null;
+
+ return (
+
+
+
+ {vo.portfolioBenchmarkTitle}
+
+ {vo.portfolioBenchmarkHint}
+
+ {showBanner && benchmark.message ? (
+
+
+ {benchmark.message}
+
+ ) : null}
+
+ {showScores ? (
+
+
+ {vo.portfolioPropertyScore}
+ {property ?? '—'}
+
+
+ {vo.portfolioMedianScore}
+ {median ?? '—'}
+
+
+ {vo.portfolioDelta}
+ = 0
+ ? 'text-green-700 dark:text-green-400'
+ : 'text-amber-700 dark:text-amber-400'
+ }`}
+ >
+ {delta == null ? '—' : `${delta >= 0 ? '+' : ''}${delta}`}
+
+
+
+ ) : property != null ? (
+
+ {vo.portfolioPropertyScore}
+ {property}
+
+ ) : null}
+
+ );
+}
diff --git a/web/src/components/pipeline/PipelineLogViewer.tsx b/web/src/components/pipeline/PipelineLogViewer.tsx
index f3897934..afa0863b 100644
--- a/web/src/components/pipeline/PipelineLogViewer.tsx
+++ b/web/src/components/pipeline/PipelineLogViewer.tsx
@@ -46,7 +46,15 @@ function highlightText(text: string, query: string) {
);
}
-function ProgressLine({ line, query }: { line: PipelineLogLine; query: string }) {
+function ProgressLine({
+ line,
+ query,
+ label = 'Progress',
+}: {
+ line: PipelineLogLine;
+ query: string;
+ label?: string;
+}) {
const p = line.progress;
if (!p) {
return (
@@ -56,7 +64,7 @@ function ProgressLine({ line, query }: { line: PipelineLogLine; query: string })
return (
- Crawl progress
+ {label}
{p.current}/{p.total} ({p.percent}%)
@@ -71,6 +79,22 @@ function ProgressLine({ line, query }: { line: PipelineLogLine; query: string })
);
}
+function ActivityLine({ line, query }: { line: PipelineLogLine; query: string }) {
+ const url = line.progressEvent?.url ?? line.text.replace(/^→\s*/, '');
+ return (
+
+
+ →
+ {highlightText(url, query)}
+
+ {line.progress ? : null}
+
+ );
+}
+
function LogLine({ line, query }: { line: PipelineLogLine; query: string }) {
if (line.kind === 'noise') {
return (
@@ -80,10 +104,18 @@ function LogLine({ line, query }: { line: PipelineLogLine; query: string }) {
);
}
+ if (line.kind === 'activity') {
+ return ;
+ }
+
if (line.kind === 'progress') {
return (
);
}
diff --git a/web/src/components/pipeline/PipelineProgressHeader.tsx b/web/src/components/pipeline/PipelineProgressHeader.tsx
new file mode 100644
index 00000000..bb2931f9
--- /dev/null
+++ b/web/src/components/pipeline/PipelineProgressHeader.tsx
@@ -0,0 +1,162 @@
+'use client';
+
+import { useMemo } from 'react';
+import { Check, Loader2 } from 'lucide-react';
+import type { PipelineJobStatus } from '@/types/api';
+import type { LivePipelineEstimate } from '@/lib/pipelineLiveEstimate';
+import {
+ PHASE_LABELS,
+ PIPELINE_STEPPER_PHASES,
+ computeEta,
+ formatDurationMs,
+ parsePipelineProgressEvents,
+ resolveActiveProgress,
+ stepLabel,
+ type ProgressPhase,
+} from '@/lib/formatPipelineLog';
+
+export interface PipelineProgressHeaderProps {
+ log: string;
+ status?: PipelineJobStatus | '';
+ liveEstimate?: LivePipelineEstimate | null;
+ compact?: boolean;
+ className?: string;
+}
+
+function truncateUrl(url: string, max = 56): string {
+ if (url.length <= max) return url;
+ return `${url.slice(0, max - 1)}…`;
+}
+
+function phaseIndex(phase: ProgressPhase): number {
+ const idx = PIPELINE_STEPPER_PHASES.indexOf(phase);
+ return idx >= 0 ? idx : -1;
+}
+
+export default function PipelineProgressHeader({
+ log,
+ status = '',
+ liveEstimate = null,
+ compact = false,
+ className = '',
+}: PipelineProgressHeaderProps) {
+ const events = useMemo(() => parsePipelineProgressEvents(log), [log]);
+ const latest = useMemo(() => resolveActiveProgress(events, status), [events, status]);
+ const eta = useMemo(() => computeEta(latest, events), [latest, events]);
+
+ if (!latest) return null;
+
+ const jobFinished = status === 'success' || status === 'error';
+ const activePhase = latest.phase;
+ const activeIdx = jobFinished && latest.step === 'done' ? PIPELINE_STEPPER_PHASES.length : phaseIndex(activePhase);
+ const stepText = stepLabel(latest.step, latest.message);
+ const phaseLabel = PHASE_LABELS[activePhase] ?? activePhase;
+ const isActive = !jobFinished && latest.step !== 'done';
+ const hasBar =
+ isActive &&
+ latest.current != null &&
+ latest.total != null &&
+ latest.total > 0;
+ const barPct = hasBar
+ ? Math.min(100, Math.round(((latest.current ?? 0) / (latest.total ?? 1)) * 100))
+ : null;
+
+ return (
+
+ {!compact ? (
+
+ {PIPELINE_STEPPER_PHASES.map((phase, i) => {
+ const done = jobFinished && latest.step === 'done' ? true : activeIdx >= 0 && i < activeIdx;
+ const active = !jobFinished && phase === activePhase && latest.step !== 'done';
+ const future = !done && !active;
+ return (
+
+
+ {PHASE_LABELS[phase]}
+
+ {i < PIPELINE_STEPPER_PHASES.length - 1 ? (
+ ›
+ ) : null}
+
+ );
+ })}
+
+ ) : null}
+
+
+
+ {isActive ? (
+
+ ) : jobFinished && status === 'success' ? (
+
+ ) : null}
+
+ {phaseLabel}
+ {!compact ? ` · ${stepText}` : `: ${stepText}`}
+
+
+
+ {hasBar ? (
+
+ {latest.current}/{latest.total}
+ {barPct != null ? ` (${barPct}%)` : ''}
+
+ ) : null}
+ {eta.ratePerSec != null && latest.phase === 'crawl' ? (
+ {eta.ratePerSec.toFixed(1)} pg/s
+ ) : null}
+ {eta.elapsedMs != null ? elapsed {formatDurationMs(eta.elapsedMs)} : null}
+ {eta.remainingMs != null && !liveEstimate?.remainingMs ? (
+ step ETA {formatDurationMs(eta.remainingMs)}
+ ) : null}
+ {liveEstimate?.remainingMs != null && isActive ? (
+
+ ~{formatDurationMs(liveEstimate.remainingMs)} left total
+
+ ) : null}
+
+
+
+ {liveEstimate?.ratePerSec != null && isActive && latest.phase === 'crawl' && !compact ? (
+
+ Avg {liveEstimate.ratePerSec.toFixed(2)} pages/s
+ {liveEstimate.observedCrawlPages != null ? ` · ${liveEstimate.observedCrawlPages} crawled` : ''}
+ {liveEstimate.totalMs != null ? ` · ~${formatDurationMs(liveEstimate.totalMs)} projected total` : ''}
+
+ ) : null}
+
+ {hasBar && barPct != null ? (
+
+ ) : latest.step !== 'done' ? (
+
+ ) : null}
+
+ {latest.url && !compact ? (
+
+ {truncateUrl(latest.url)}
+
+ ) : null}
+
+ );
+}
diff --git a/web/src/components/pipeline/PipelineRunPanel.tsx b/web/src/components/pipeline/PipelineRunPanel.tsx
index 8e228b29..dd622378 100644
--- a/web/src/components/pipeline/PipelineRunPanel.tsx
+++ b/web/src/components/pipeline/PipelineRunPanel.tsx
@@ -1,6 +1,6 @@
'use client';
-import { useEffect, useRef, useState, type KeyboardEvent } from 'react';
+import { useEffect, useMemo, useRef, useState, type KeyboardEvent } from 'react';
import {
ArrowLeft,
ArrowRight,
@@ -27,8 +27,12 @@ import { PIPELINE_PRESETS } from './pipelinePresets';
import { CRAWL_PRESETS, type CrawlPresetId } from '@/lib/crawlPresets';
import PipelineWizardProgress, { type WizardStep } from './PipelineWizardProgress';
import PipelineLogViewer from './PipelineLogViewer';
+import PipelineProgressHeader from './PipelineProgressHeader';
import CrawlAuthorizeCheckbox from './CrawlAuthorizeCheckbox';
import PipelineRunPreviewCard from './PipelineRunPreviewCard';
+import { buildPipelineRunPreview } from '@/lib/pipelineRunPreview';
+import { computeLivePipelineEstimate } from '@/lib/pipelineLiveEstimate';
+import { parsePipelineProgressEvents, resolveActiveProgress } from '@/lib/formatPipelineLog';
const s = strings.pipelineRunner;
const crawlPresets = s.crawlPresets as Record ;
@@ -108,6 +112,25 @@ export default function PipelineRunPanel() {
presetId === 'crawl-only' ? strings.reportSelector.crawlOnlyNote : null;
const showProgress = busy || Boolean(status) || Boolean(log);
+ const runPreview = useMemo(
+ () =>
+ buildPipelineRunPreview({
+ presetId,
+ configState,
+ customCommand,
+ crawlPresetId,
+ }),
+ [presetId, configState, customCommand, crawlPresetId],
+ );
+
+ const liveEstimate = useMemo(() => {
+ const isRunning = busy || status === 'running' || status === 'starting';
+ if (!isRunning || !log.trim()) return null;
+ const events = parsePipelineProgressEvents(log);
+ const latest = resolveActiveProgress(events, status);
+ return computeLivePipelineEstimate(runPreview, events, latest, status);
+ }, [busy, status, log, runPreview]);
+
const goToStep = (next: WizardStep) => {
setStep(next);
setMaxStep((prev) => (next > prev ? next : prev));
@@ -329,6 +352,7 @@ export default function PipelineRunPanel() {
configState={configState}
customCommand={customCommand}
crawlPresetId={crawlPresetId}
+ liveEstimate={liveEstimate}
/>
@@ -408,6 +432,15 @@ export default function PipelineRunPanel() {
+ {log ? (
+
+ ) : null}
+
{log ? (
+ {richResultsRows.length > 0 ? (
+
+ ) : null}
+
{((hreflang?.pages_200 ?? 0) > 0 || outboundDomains.length > 0) && (
{(hreflang?.pages_200 ?? 0) > 0 && (
diff --git a/web/src/views/ExportReport.tsx b/web/src/views/ExportReport.tsx
index 678cd918..acb95a20 100644
--- a/web/src/views/ExportReport.tsx
+++ b/web/src/views/ExportReport.tsx
@@ -4,7 +4,7 @@ import { useCallback, useRef, useState } from 'react';
import { Download, FileText, Printer } from 'lucide-react';
import Button from '@/components/Button';
import { useReport } from '@/context/useReport';
-import { buildAuditExportUrl } from '@/lib/exportAudit';
+import { buildAuditExportUrl, buildWorkbookExportUrl, buildSitemapExportUrl } from '@/lib/exportAudit';
import { strings } from '@/lib/strings';
import type { ViewProps } from '@/types/report';
@@ -20,6 +20,8 @@ export default function ExportReport(_props: ViewProps) {
const pdfUrl = buildAuditExportUrl('pdf', reportId);
const csvUrl = buildAuditExportUrl('csv', reportId);
const jsonUrl = buildAuditExportUrl('json', reportId);
+ const workbookUrl = buildWorkbookExportUrl(reportId);
+ const sitemapUrl = buildSitemapExportUrl(reportId);
const siteLabel = data?.site_name || strings.app.defaultSiteName;
const generated = data?.report_generated_at;
@@ -60,6 +62,22 @@ export default function ExportReport(_props: ViewProps) {
{ve.downloadCsv}
+
+
+ {ve.downloadWorkbook}
+
+
+
+ {ve.downloadSitemap}
+
{
- const aClicks = clicksByUrl.get(String(a.issue.url || '').replace(/\/$/, '')) || 0;
- const bClicks = clicksByUrl.get(String(b.issue.url || '').replace(/\/$/, '')) || 0;
+ const aImpact = Number(a.issue.impact_score) || 0;
+ const bImpact = Number(b.issue.impact_score) || 0;
+ if (bImpact !== aImpact) return bImpact - aImpact;
+ const aClicks = Number(a.issue.gsc_clicks) || clicksByUrl.get(String(a.issue.url || '').replace(/\/$/, '')) || 0;
+ const bClicks = Number(b.issue.gsc_clicks) || clicksByUrl.get(String(b.issue.url || '').replace(/\/$/, '')) || 0;
if (bClicks !== aClicks) return bClicks - aClicks;
const ao = PRIORITY_CONFIG[normalizePriority(a.issue.priority)].order;
const bo = PRIORITY_CONFIG[normalizePriority(b.issue.priority)].order;
diff --git a/web/src/views/JavaScriptErrors.tsx b/web/src/views/JavaScriptErrors.tsx
index bae59ded..0f3c6be9 100644
--- a/web/src/views/JavaScriptErrors.tsx
+++ b/web/src/views/JavaScriptErrors.tsx
@@ -19,6 +19,8 @@ import {
linksInspectHref,
type FlatBrowserErrorRow,
} from '@/lib/browserErrors';
+import AiSuggestionButton from '@/components/ai/AiSuggestionButton';
+import { buildBrowserErrorContext, buildBrowserErrorSummaryContext } from '@/lib/fixSuggestionContext';
type TypeFilter = 'All' | 'console' | 'exception';
const JS_ERRORS_TABS = ['summary', 'errors'] as const;
@@ -160,7 +162,14 @@ export default function JavaScriptErrors({ searchQuery = '' }: ViewProps) {
{topMessages.map((row) => (
- {row.text}
+
+
+
{row.count.toLocaleString()}
@@ -259,7 +268,12 @@ export default function JavaScriptErrors({ searchQuery = '' }: ViewProps) {
{row.type === 'console' ? vj.typeConsole : vj.typeException}
- {row.message}
+
+
+
{formatBrowserErrorSource(row.source_url, row.line)}
diff --git a/web/src/views/KeywordsExplorer.tsx b/web/src/views/KeywordsExplorer.tsx
index 95745cab..40165af3 100644
--- a/web/src/views/KeywordsExplorer.tsx
+++ b/web/src/views/KeywordsExplorer.tsx
@@ -7,6 +7,7 @@ import { Key, Settings2, Play } from 'lucide-react';
import { useRouter } from 'next/navigation';
import { useUrlTab } from '@/hooks/useUrlTab';
import { useReport } from '../context/useReport';
+import { useOptionalPipeline } from '../context/PipelineContext';
import { useKeywordBrandQuery } from '@/hooks/useKeywordBrandQuery';
import { filterKeywordRowsForDomain } from '@/lib/filterKeywordsForDomain';
import { apiUrl } from '../lib/publicBase';
@@ -27,6 +28,10 @@ import {
ByPagePanel,
BulkSeedPanel,
} from '../components/keywordsExplorer/KeywordPanels';
+import TopicMapPanel from '../components/keywordsExplorer/TopicMapPanel';
+import ContentTemplatesPanel from '../components/keywordsExplorer/ContentTemplatesPanel';
+import CompetitorKeywordImport from '../components/keywordsExplorer/CompetitorKeywordImport';
+import CompetitorKeywordGapPanel from '../components/keywordsExplorer/CompetitorKeywordGapPanel';
import KeywordOverviewPanel from '../components/keywordsExplorer/KeywordOverviewPanel';
import KeywordTabBanner from '../components/keywordsExplorer/KeywordTabBanner';
import KeywordFiltersBar from '../components/keywordsExplorer/KeywordFiltersBar';
@@ -42,7 +47,7 @@ import {
isTableTab,
} from '../components/keywordsExplorer/keywordTabMeta';
-const KEYWORD_TABS = ['overview', ...KEYWORD_TABLE_TAB_IDS, 'cannib', 'alignment', 'bypage'] as const;
+const KEYWORD_TABS = ['overview', ...KEYWORD_TABLE_TAB_IDS, 'cannib', 'alignment', 'bypage', 'topics', 'templates', 'competitor'] as const;
const EMPTY_ROWS: KeywordRow[] = [];
const EMPTY_HISTORY: KeywordHistoryMap = {};
@@ -50,6 +55,8 @@ const EMPTY_HISTORY: KeywordHistoryMap = {};
export default function KeywordsExplorer({ onOpenIntegrations }: ViewProps) {
const router = useRouter();
const { data, startUrlByRunId, selectedReportId } = useReport();
+ const pipeline = useOptionalPipeline();
+ const propertyId = Number(pipeline?.configState.active_property_id || 0);
const ke = strings.views.keywordsExplorer;
const brandQuery = useKeywordBrandQuery();
const kwData: KeywordReportData | undefined = data?.keywords;
@@ -86,7 +93,12 @@ export default function KeywordsExplorer({ onOpenIntegrations }: ViewProps) {
const hasGscConnected = !!data?.google?.gsc;
const showParentTopic = rows.some((r) => r.parent_topic);
const showTrend = rows.some((r) => r.trend);
- const tabLabels = ke.tabs as Record;
+ const tabLabels = {
+ ...(ke.tabs as Record),
+ topics: 'Topic map',
+ templates: 'Content templates',
+ competitor: 'Competitor keywords',
+ } as Record;
const quickWinCount = useMemo(
() =>
@@ -420,6 +432,23 @@ export default function KeywordsExplorer({ onOpenIntegrations }: ViewProps) {
) : activeTab === 'bypage' ? (
+ ) : activeTab === 'topics' ? (
+ ) || []}
+ emptyLabel="Run a report with LLM keyword clusters enabled to see topic groups."
+ />
+ ) : activeTab === 'templates' ? (
+
+ ) : activeTab === 'competitor' ? (
+ <>
+
+
+ >
) : tableEmptyContent ? (
tableEmptyContent
) : (
diff --git a/web/src/views/Links.tsx b/web/src/views/Links.tsx
index 5f5c8d4a..ec403441 100644
--- a/web/src/views/Links.tsx
+++ b/web/src/views/Links.tsx
@@ -1,10 +1,12 @@
'use client';
import { useState, useMemo, useEffect, useRef, useCallback, type MouseEvent } from 'react';
-import { Link as LinkIcon, ArrowLeft, AlertTriangle, Download } from 'lucide-react';
+import { Link as LinkIcon, ArrowLeft, AlertTriangle, Download, List, TextQuote } from 'lucide-react';
import { useReport } from '../context/useReport';
import { strings } from '../lib/strings';
-import { PageLayout, PageHeader, Card, Button, AlertBanner } from '../components';
+import { PageLayout, PageHeader, Card, Button, AlertBanner, ViewTabs } from '../components';
+import type { ViewTabItem } from '../components';
+import { useUrlTab } from '@/hooks/useUrlTab';
import { useSearchParams, useRouter, usePathname } from 'next/navigation';
import type {
InspectorBrokenItem,
@@ -22,6 +24,7 @@ import type {
import { CopyBtn, InspectorTabs } from '../components/links';
import {
type LinkSortKey,
+ LinksExplorerAnchorsTab,
LinksExplorerTableTab,
} from '../components/links/explorer';
import {
@@ -32,6 +35,12 @@ import type { LinksFilterValues } from '../components/links/LinksFilterBar';
import { linkHasBrowserErrors } from '@/lib/browserErrors';
import { browserInspectorIssueRows } from '@/components/browser/BrowserDiagnosticsPanel';
import { exportLinksCsv } from '@/utils/linkExport';
+import { useOptionalPipeline } from '../context/PipelineContext';
+import AiSuggestionButton from '@/components/ai/AiSuggestionButton';
+import { buildTechnicalLinkIssueContext } from '@/lib/fixSuggestionContext';
+
+const EXPLORER_TABS = ['urls', 'anchors'] as const;
+type ExplorerTabId = (typeof EXPLORER_TABS)[number];
const INSPECTOR_TABS = [
'overview',
@@ -64,6 +73,8 @@ export default function Links({ searchQuery = '' }: ViewProps) {
const vl = strings.views.links;
const sj = strings.common;
const { data } = useReport();
+ const pipeline = useOptionalPipeline();
+ const propertyId = Number(pipeline?.configState.active_property_id || 0);
const searchParams = useSearchParams();
const router = useRouter();
const pathname = usePathname();
@@ -87,6 +98,55 @@ export default function Links({ searchQuery = '' }: ViewProps) {
const links = useMemo(() => data?.links || [], [data]);
+ const hasLinkAttributes = Boolean(
+ data?.link_rel_summary || (data?.inlink_anchor_matrix?.length ?? 0) > 0,
+ );
+
+ const explorerValidTabs = useMemo((): readonly ExplorerTabId[] => {
+ if (hasLinkAttributes) return EXPLORER_TABS;
+ return ['urls'];
+ }, [hasLinkAttributes]);
+
+ const [explorerTab, setExplorerTab] = useUrlTab(explorerValidTabs, 'urls');
+
+ const linkAttributeLabels = useMemo(
+ () => ({
+ title: vl.linkAttributesTitle ?? 'Link attributes',
+ total: vl.linkAttrTotal ?? 'Total links',
+ internal: vl.linkAttrInternal ?? 'Internal',
+ nofollow: vl.linkAttrNofollow ?? 'Nofollow internal',
+ sponsored: vl.linkAttrSponsored ?? 'Sponsored internal',
+ external: vl.linkAttrExternal ?? 'External',
+ anchorMatrix: vl.inlinkAnchorMatrix ?? 'Inlink anchor text',
+ target: vl.inlinkTarget ?? 'Target URL',
+ anchor: vl.inlinkAnchor ?? 'Anchor text',
+ inlinks: vl.inlinkCount ?? 'Inlinks',
+ follow: vl.linkAttrFollow ?? 'Follow internal',
+ ugc: vl.linkAttrUgc ?? 'UGC internal',
+ }),
+ [vl],
+ );
+
+ const explorerTabItems = useMemo((): ViewTabItem[] => {
+ const items: ViewTabItem[] = [
+ {
+ id: 'urls',
+ label: vl.tabs.urls,
+ icon: ,
+ badge: links.length > 0 ? links.length : null,
+ },
+ ];
+ if (hasLinkAttributes) {
+ items.push({
+ id: 'anchors',
+ label: vl.tabs.anchors,
+ icon: ,
+ badge: data?.inlink_anchor_matrix?.length ?? null,
+ });
+ }
+ return items;
+ }, [vl.tabs, links.length, hasLinkAttributes, data?.inlink_anchor_matrix?.length]);
+
const inspectParam = searchParams.get('inspect');
const tabParam = searchParams.get('tab');
@@ -118,13 +178,6 @@ export default function Links({ searchQuery = '' }: ViewProps) {
[router, pathname, searchParams],
);
- useEffect(() => {
- if (inInspector || tabParam !== 'charts') return;
- replaceParams((params) => {
- params.delete('tab');
- });
- }, [inInspector, tabParam, replaceParams]);
-
useEffect(() => {
if (!inspectParam) {
setInspectNotFound(false);
@@ -307,6 +360,27 @@ export default function Links({ searchQuery = '' }: ViewProps) {
} as InspectorDetails;
}, [inspectorUrl, data, links]);
+ const siteTechnicalIssues = useMemo(() => {
+ const q = (searchQuery || '').toLowerCase().trim();
+ const issues = data?.issues || {};
+ const rows: Array<{ message: string; url: string; kind: string }> = [];
+ (issues.broken || []).forEach((item) => {
+ const url = String(item.url || '');
+ const message = `Broken link (${item.status ?? 'error'})`;
+ if (!q || `${url} ${message}`.toLowerCase().includes(q)) {
+ rows.push({ message, url, kind: 'broken_link' });
+ }
+ });
+ (issues.redirects || []).forEach((item) => {
+ const url = String(item.url || '');
+ const message = `Redirect ${item.status ?? ''} → ${item.final_url || ''}`.trim();
+ if (!q || `${url} ${message}`.toLowerCase().includes(q)) {
+ rows.push({ message, url, kind: 'redirect' });
+ }
+ });
+ return rows.slice(0, 40);
+ }, [data?.issues, searchQuery]);
+
const handleRowMouseEnter = useCallback((e: MouseEvent, link: ReportLink) => {
const rect = e.currentTarget.getBoundingClientRect();
const containerRect = tableRef.current?.getBoundingClientRect?.() || { top: 0, left: 0, width: 800 };
@@ -358,6 +432,15 @@ export default function Links({ searchQuery = '' }: ViewProps) {
setPage(1);
};
+ const loadSavedFilter = (values: LinksFilterValues) => {
+ setInlinksFilter(values.inlinksFilter);
+ setStatusFilter(values.statusFilter);
+ setRtFilter(values.rtFilter);
+ setWcFilter(values.wcFilter);
+ setJsErrorFilter(values.jsErrorFilter);
+ setPage(1);
+ };
+
const linkForInspector = inspectorUrl ? (links.find((l) => l.url === inspectorUrl) || null) : null;
return (
@@ -387,7 +470,7 @@ export default function Links({ searchQuery = '' }: ViewProps) {
}
className="mb-0"
actions={
- filtered.length > 0 ? (
+ explorerTab === 'urls' && filtered.length > 0 ? (
+ setExplorerTab(id as ExplorerTabId)}
+ ariaLabel={vl.title}
+ idPrefix="links-explorer"
+ />
+
+ {siteTechnicalIssues.length > 0 ? (
+
+ {vl.siteTechnicalIssuesTitle}
+ {vl.siteTechnicalIssuesHint}
+
+ {siteTechnicalIssues.map((row, i) => (
+ -
+
{row.url}
+ {row.message}
+
+
+ ))}
+
+
+ ) : null}
+
+ {explorerTab === 'urls' ? (
setHoveredRow(null)}
/>
+ ) : (
+
+ )}
>
) : (
<>
diff --git a/web/src/views/Redirects.tsx b/web/src/views/Redirects.tsx
index 14b65e53..ffb1bd45 100644
--- a/web/src/views/Redirects.tsx
+++ b/web/src/views/Redirects.tsx
@@ -7,6 +7,8 @@ import { PageLayout, PageHeader, Card, Table, TableHead, TableHeadCell, TableBod
import { palette } from '../utils/chartPalette';
import { registerChartJsBase, barOptionsHorizontal } from '../utils/chartJsDefaults';
import type { ReportRedirect, ViewProps } from '@/types';
+import AiSuggestionButton from '@/components/ai/AiSuggestionButton';
+import { buildRedirectContext } from '@/lib/fixSuggestionContext';
registerChartJsBase();
@@ -84,11 +86,12 @@ export default function Redirects({ searchQuery = '' }: ViewProps) {
{vr.colFrom}
{vr.colStatus}
{vr.colTo}
+
{redirects.map((r, i) => (
-
+
{r.url || r.from}
@@ -102,6 +105,9 @@ export default function Redirects({ searchQuery = '' }: ViewProps) {
{r.final_url || r.to}
+
+
+
))}
diff --git a/web/src/views/Security.tsx b/web/src/views/Security.tsx
index 65bedd28..084ffe05 100644
--- a/web/src/views/Security.tsx
+++ b/web/src/views/Security.tsx
@@ -13,6 +13,8 @@ import { doughnutOptionsWithPercentTooltip, formatCompositionAria } from '../lib
import { ChartAccessibleFallback } from '../components/charts';
import type { SecurityFinding, ViewProps } from '@/types';
import { securityFindingLabel } from '@/lib/securityFindingLabels';
+import AiSuggestionButton from '@/components/ai/AiSuggestionButton';
+import { buildSecurityFindingContext } from '@/lib/fixSuggestionContext';
registerChartJsBase();
@@ -355,6 +357,7 @@ export default function Security({ searchQuery = '' }: ViewProps) {
{f.recommendation}
)}
+
);
})}
diff --git a/web/src/views/SiteStructure.tsx b/web/src/views/SiteStructure.tsx
index 907a45fd..ba92cc54 100644
--- a/web/src/views/SiteStructure.tsx
+++ b/web/src/views/SiteStructure.tsx
@@ -1,3 +1,4 @@
+import dynamic from 'next/dynamic';
import { useMemo, useState, useCallback, useEffect } from 'react';
import { useUrlTab } from '@/hooks/useUrlTab';
import {
@@ -10,6 +11,7 @@ import {
ChevronsDownUp,
ChevronsUpDown,
BarChart3,
+ Share2,
} from 'lucide-react';
import { useReport } from '../context/useReport';
import { strings, format } from '../lib/strings';
@@ -22,16 +24,24 @@ import {
defaultExpandedPathKeys,
filterLinksBySearch,
finalizeRollup,
+ linkMatchesPathKey,
} from '../lib/siteStructureTree';
import { PageLayout, PageHeader, Card, Button, StatCard, AlertBanner, ViewTabs, ViewTabPanel } from '../components';
import UrlInspectorButton from '@/components/UrlInspectorButton';
import type { ViewTabItem } from '../components';
import PathTreeTable from '../components/siteStructure/PathTreeTable';
+import CrawlMapPanel from '../components/siteStructure/CrawlMapPanel';
+
+const SiteStructureLinkGraph = dynamic(
+ () => import('../components/siteStructure/SiteStructureLinkGraph'),
+ { ssr: false, loading: () => Loading link graph… },
+);
+
import type { CrawlSegmentEntry, CrawlSegmentsData, PathTreeNode, PathTreeTableRow, ViewProps } from '@/types';
const TREE_PAGE_SIZE = 20;
-const SITE_STRUCTURE_TABS = ['overview', 'tree'] as const;
+const SITE_STRUCTURE_TABS = ['overview', 'tree', 'map', 'graph'] as const;
type SiteStructureTabId = (typeof SITE_STRUCTURE_TABS)[number];
interface SiteStructureTreePanelProps {
@@ -240,6 +250,7 @@ export default function SiteStructure({ searchQuery = '' }: ViewProps) {
const s = strings.views.siteStructure;
const { data, compareData, startUrlByRunId, selectedReportId, compareReportId } = useReport();
const [showCompareCharts, setShowCompareCharts] = useState(true);
+ const [pathPrefixFilter, setPathPrefixFilter] = useState (null);
const [activeTab, setActiveTab] = useUrlTab(SITE_STRUCTURE_TABS, 'overview');
const expectedHost = useMemo(
@@ -247,15 +258,21 @@ export default function SiteStructure({ searchQuery = '' }: ViewProps) {
[data, startUrlByRunId]
);
- const filteredLinks = useMemo(
- () => filterLinksBySearch(data?.links || [], searchQuery),
- [data?.links, searchQuery]
- );
+ const filteredLinks = useMemo(() => {
+ let links = filterLinksBySearch(data?.links || [], searchQuery);
+ if (pathPrefixFilter) {
+ links = links.filter((l) => linkMatchesPathKey(String(l.url || ''), pathPrefixFilter, expectedHost));
+ }
+ return links;
+ }, [data?.links, searchQuery, pathPrefixFilter, expectedHost]);
- const baselineLinks = useMemo(
- () => filterLinksBySearch(compareData?.links || [], searchQuery),
- [compareData?.links, searchQuery]
- );
+ const baselineLinks = useMemo(() => {
+ let links = filterLinksBySearch(compareData?.links || [], searchQuery);
+ if (pathPrefixFilter) {
+ links = links.filter((l) => linkMatchesPathKey(String(l.url || ''), pathPrefixFilter, expectedHost));
+ }
+ return links;
+ }, [compareData?.links, searchQuery, pathPrefixFilter, expectedHost]);
const hasCompare = compareData != null && compareReportId != null;
const searchActive = (searchQuery || '').trim().length > 0;
@@ -311,8 +328,20 @@ export default function SiteStructure({ searchQuery = '' }: ViewProps) {
icon: ,
badge: filteredLinks.length > 0 ? filteredLinks.length : null,
},
+ {
+ id: 'map',
+ label: 'Crawl map',
+ icon: ,
+ badge: null,
+ },
+ {
+ id: 'graph',
+ label: 'Link graph',
+ icon: ,
+ badge: (data?.graph_nodes?.length ?? 0) > 0 ? Math.min(200, data!.graph_nodes!.length) : null,
+ },
];
- }, [s.tabs, merged.size, filteredLinks.length]);
+ }, [s.tabs, merged.size, filteredLinks.length, data?.graph_nodes?.length]);
if (!data) return null;
@@ -453,6 +482,51 @@ export default function SiteStructure({ searchQuery = '' }: ViewProps) {
)}
+
+ {activeTab === 'map' && tree ? (
+
+ {pathPrefixFilter ? (
+
+
+ Filtering tree to {pathPrefixFilter}
+
+ setPathPrefixFilter(null)}
+ >
+ Clear filter
+
+ setActiveTab('tree')}
+ >
+ View in tree
+
+
+ ) : null}
+ {
+ setPathPrefixFilter(pathKey);
+ setActiveTab('tree');
+ }}
+ />
+
+ ) : null}
+
+ {activeTab === 'graph' && data ? (
+
+
+
+
+
+ ) : null}
);
}
diff --git a/web/src/views/Subdomains.tsx b/web/src/views/Subdomains.tsx
new file mode 100644
index 00000000..e61f9037
--- /dev/null
+++ b/web/src/views/Subdomains.tsx
@@ -0,0 +1,169 @@
+'use client';
+
+import { useMemo } from 'react';
+import Link from 'next/link';
+import { useSearchParams } from 'next/navigation';
+import { Globe2 } from 'lucide-react';
+import { useReport } from '../context/useReport';
+import { strings, format } from '../lib/strings';
+import {
+ PageLayout,
+ PageHeader,
+ Card,
+ StatCard,
+ Badge,
+ Table,
+ TableHead,
+ TableHeadCell,
+ TableBody,
+ TableRow,
+ TableCell,
+} from '../components';
+import type { SubdomainHostEntry, ViewProps } from '@/types';
+
+function yesNo(value: boolean | undefined): string {
+ return value ? strings.common.yes : strings.common.no;
+}
+
+export default function Subdomains({ searchQuery = '' }: ViewProps) {
+ const { data } = useReport();
+ const searchParams = useSearchParams();
+ const vs = strings.views.subdomains;
+ const inv = data?.subdomains;
+ const q = (searchQuery || '').toLowerCase().trim();
+
+ const querySuffix = searchParams.toString() ? `?${searchParams.toString()}` : '';
+
+ const inScopeHosts = useMemo(() => {
+ const all = (inv?.hosts || []).filter((h): h is SubdomainHostEntry & { host: string } => Boolean(h.host));
+ const scoped = all.filter((h) => h.in_scope !== false);
+ if (!q) return scoped;
+ return scoped.filter((h) => {
+ const host = h.host.toLowerCase();
+ const sources = (h.sources || []).join(' ').toLowerCase();
+ return host.includes(q) || sources.includes(q);
+ });
+ }, [inv?.hosts, q]);
+
+ const gscGapHosts = inv?.gsc_hosts_not_crawled || [];
+ const outOfScope = inv?.out_of_scope_discovered || [];
+
+ if (!inv || inv.disabled) {
+ return (
+
+ } />
+
+ {vs.disabledHint}
+
+
+ );
+ }
+
+ if (!inv.hosts?.length && !gscGapHosts.length) {
+ return (
+
+ } />
+
+ {vs.emptyHint}
+
+
+ );
+ }
+
+ return (
+
+ } />
+ {inv.crtsh_error ? (
+
+ {vs.ctWarning}
+
+ ) : null}
+
+
+
+
+
+
+ {gscGapHosts.length > 0 ? (
+
+ {vs.gscGapTitle}
+ {vs.gscGapHint}
+
+ {gscGapHosts.slice(0, 20).map((host) => (
+ -
+
+
+ ))}
+
+ {gscGapHosts.length > 20 ? (
+ {format(vs.moreHosts, { count: gscGapHosts.length - 20 })}
+ ) : null}
+
+
+ {vs.viewIndexation}
+
+
+
+ ) : null}
+
+ {vs.hostsTitle}
+
+
+
+
+ {vs.colHost}
+ {vs.colSources}
+ {vs.colCrawl}
+ {vs.colGsc}
+ {vs.colCrawlUrls}
+ {vs.colGscUrls}
+
+
+
+ {inScopeHosts.length === 0 ? (
+
+ {vs.noSearchResults}
+
+
+
+
+
+
+ ) : (
+ inScopeHosts.map((row) => (
+
+ {row.host}
+
+
+ {(row.sources || []).map((s) => (
+
+ ))}
+
+
+ {yesNo(row.in_crawl)}
+ {yesNo(row.in_gsc)}
+ {row.url_count_crawl ?? 0}
+ {row.url_count_gsc ?? 0}
+
+ ))
+ )}
+
+
+
+
+ {outOfScope.length > 0 ? (
+
+ {vs.outOfScopeTitle}
+ {vs.outOfScopeHint}
+
+ {outOfScope.slice(0, 30).map((host) => (
+ -
+ {host}
+
+ ))}
+
+
+ ) : null}
+
+ );
+}
|