diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index fb319d44..99a2e1d1 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -38,8 +38,10 @@ jobs: - name: Pytest (reporting coverage gate) run: | pytest tests/test_categories_roadmap.py tests/test_report_categories_golden.py \ - tests/test_categories_coverage.py tests/test_indexation_coverage.py tests/test_crawl_segments.py \ + tests/test_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 --cov-fail-under=100 -q -o addopts= - name: Pytest (tools coverage gate) diff --git a/README.md b/README.md index 67270aad..8c7f815e 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,44 @@ +

+ + Site Audit — Open Source SEO Crawl & Audit + +

+ +

+ Site Audit — Open Source SEO Crawl & Audit
+ Free, self-hosted — no vendor paywalls. +

+ +

+ CI status + MIT License + Open source + GitHub stars +

+ +

+ Next.js + Python + PostgreSQL + Docker +

+ +

+ Quick start · + Features · + Structure · + Contributing · + Docs · + License +

+ +--- + # Site Audit -Open-source technical SEO crawl and audit UI (Next.js + Python + PostgreSQL). +**Open Source SEO Crawl & Audit** — self-hosted UI built with **Next.js + Python + PostgreSQL**. + +Repository: [codefrydev/WebsiteProfiling](https://github.com/codefrydev/WebsiteProfiling) ## Overview @@ -8,6 +46,87 @@ Open-source technical SEO crawl and audit UI (Next.js + Python + PostgreSQL). **Goal** — A free, self-hosted audit you control: crawl your sites, see honest technical SEO issues, connect Search Console and Analytics when you want, and export reports for clients — without a vendor sitting between you and the data. +## Features + + + + + + + + +
+
+ Site crawl
+ Static & JS rendering, sitemap export, crawl maps +
+
+ Technical audit
+ Issues, Lighthouse, on-page checks, workbooks +
+
+ Integrations
+ Google Search Console, GA4, Bing Webmaster +
+
+ Self-hosted
+ Docker or local dev — your data stays yours +
+ +Also included: **AI chat** over audit data (optional), **121 MCP tools**, keyword explorer, backlinks, compare runs, and portfolio management for agencies. + +

+ Site Audit preview +

+ +## Project structure + +``` +WebsiteProfiling/ +├── src/website_profiling/ # Python audit engine (CLI: python -m src) +│ ├── crawl/ # Crawler, fetchers, JS rendering +│ ├── reporting/ # Report builder, issue categories +│ ├── analysis/ # On-page / local analysis +│ ├── lighthouse/ # Lighthouse runner +│ ├── integrations/ # Google Search Console, GA4, Bing, CrUX +│ ├── llm/ # AI enrich + chat agent +│ ├── tools/ # Exports, audit query tools, MCP helpers +│ ├── mcp/ # MCP server (121 read-only tools) +│ ├── db/ # PostgreSQL storage layer +│ ├── commands/ # CLI subcommands +│ ├── cli.py # Pipeline entrypoint +│ └── config.py # Config load (DB + shadow file) +├── web/ # Next.js UI +│ ├── app/ # App Router pages + /api routes +│ ├── src/components/ # React UI components +│ ├── src/views/ # Report views (overview, links, issues, …) +│ ├── src/server/ # Server-side DB, pipeline jobs, config I/O +│ └── public/ # Static assets (logo, favicon) +├── alembic/versions/ # PostgreSQL schema migrations +├── tests/ # pytest suite + fixtures +├── docs/ # Glossary, MCP, ops, brand assets +├── scripts/ # local-run.sh, local-test.sh helpers +├── .github/workflows/ # CI (Python + web + browser crawl) +├── docker-compose.yml # Dev stack (Postgres + web) +├── Dockerfile # Production image +├── local-run # Dev setup & start script +├── local-test # Full test suite (CI parity) +├── requirements*.txt # Python deps (core, browser, LLM, MCP) +└── pipeline-config.example.txt +``` + +| Path | Purpose | +|------|---------| +| `src/website_profiling/` | Crawl, analyze, report, Lighthouse, integrations, AI — run via `python -m src` | +| `web/app/api/` | REST APIs: report data, pipeline runs, chat (SSE), Google/Bing sync | +| `web/src/lib/pipelineConfigSchema.ts` | Audit settings schema (UI ↔ PostgreSQL) | +| `alembic/versions/` | Database migrations — run `./local-run migrate` | +| `tests/` | Backend tests; `./local-test browser` for Playwright crawl integration | +| `docs/MCP.md` | MCP server setup for IDE / agent integrations | +| `data/` | Local secrets + shadow `pipeline-config.txt` (gitignored) | + +For deeper layout notes and edit targets, see [AGENT.md](AGENT.md). + ## Quick start **Docker (build from source)** @@ -67,6 +186,12 @@ The agent uses the same **121 read-only audit tools** as the MCP server (`docs/M Production: `docker-compose.prod.yml` (set `POSTGRES_PASSWORD`, `AUTH_SECRET`). + + +## Star History + +[![Star History Chart](https://api.star-history.com/svg?repos=codefrydev/WebsiteProfiling&type=Date)](https://star-history.com/#codefrydev/WebsiteProfiling&Date) + ## License -Copyright (c) 2026 [codefrydev](https://github.com/codefrydev). Released under the **MIT License** — see [LICENSE](LICENSE). Issues and pull requests: [codefrydev/WebsiteProfiling](https://github.com/codefrydev/WebsiteProfiling). +Copyright (c) 2026 [codefrydev](https://github.com/codefrydev). Released under the **MIT License** — see [LICENSE](LICENSE). Issues and pull requests: [codefrydev/WebsiteProfiling](https://github.com/codefrydev/WebsiteProfiling). \ No newline at end of file diff --git a/docs/assets/banner.svg b/docs/assets/banner.svg new file mode 100644 index 00000000..e6e2159e --- /dev/null +++ b/docs/assets/banner.svg @@ -0,0 +1,55 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Site Audit + Open Source SEO Crawl & Audit + Self-hosted · No paywalls · Your data stays yours + + + + Next.js + + + Python + + + + PostgreSQL + + + + Docker + + + diff --git a/docs/assets/icon-audit.svg b/docs/assets/icon-audit.svg new file mode 100644 index 00000000..22e6bad8 --- /dev/null +++ b/docs/assets/icon-audit.svg @@ -0,0 +1,6 @@ + diff --git a/docs/assets/icon-crawl.svg b/docs/assets/icon-crawl.svg new file mode 100644 index 00000000..6734c04f --- /dev/null +++ b/docs/assets/icon-crawl.svg @@ -0,0 +1,7 @@ + diff --git a/docs/assets/icon-integrations.svg b/docs/assets/icon-integrations.svg new file mode 100644 index 00000000..e94cc672 --- /dev/null +++ b/docs/assets/icon-integrations.svg @@ -0,0 +1,6 @@ + diff --git a/docs/assets/icon-self-hosted.svg b/docs/assets/icon-self-hosted.svg new file mode 100644 index 00000000..e39e7dfc --- /dev/null +++ b/docs/assets/icon-self-hosted.svg @@ -0,0 +1,6 @@ + diff --git a/docs/assets/logo-icon.svg b/docs/assets/logo-icon.svg new file mode 100644 index 00000000..840742fc --- /dev/null +++ b/docs/assets/logo-icon.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/docs/assets/logo.svg b/docs/assets/logo.svg new file mode 100644 index 00000000..69d66982 --- /dev/null +++ b/docs/assets/logo.svg @@ -0,0 +1,13 @@ + + + + + + + + + + + + + diff --git a/docs/assets/readme-banner.png b/docs/assets/readme-banner.png new file mode 100644 index 00000000..4add8733 Binary files /dev/null and b/docs/assets/readme-banner.png differ diff --git a/docs/assets/social-preview.png b/docs/assets/social-preview.png new file mode 100644 index 00000000..10758592 Binary files /dev/null and b/docs/assets/social-preview.png differ diff --git a/scripts/local-test.ps1 b/scripts/local-test.ps1 index 14b0e56f..48b1b3be 100644 --- a/scripts/local-test.ps1 +++ b/scripts/local-test.ps1 @@ -231,10 +231,14 @@ function Invoke-PytestReporting { 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 ` diff --git a/src/website_profiling/analysis/text.py b/src/website_profiling/analysis/text.py index 511b9f54..0276ff56 100644 --- a/src/website_profiling/analysis/text.py +++ b/src/website_profiling/analysis/text.py @@ -6,6 +6,8 @@ import pandas as pd +from .text_hygiene import is_junk_semantic_term + def top_keywords_as_text(row: pd.Series, max_terms: int = 15) -> str: if "top_keywords" not in row.index: @@ -23,20 +25,26 @@ def top_keywords_as_text(row: pd.Series, max_terms: int = 15) -> str: words: list[str] = [] for item in arr[:max_terms]: if isinstance(item, dict) and item.get("word"): - words.append(str(item["word"])) + word = str(item["word"]).strip() + if word and not is_junk_semantic_term(word): + words.append(word) return " ".join(words) except json.JSONDecodeError: return "" def normalize_fingerprint_text(row: pd.Series) -> str: - """Concatenate on-page text signals for duplicates, language, and LLM context.""" + """Concatenate on-page text signals for duplicates, language, and LLM context. + + heading_sequence is excluded — it stores tag names (h1,h2), not heading copy. + Prefer heading_text (actual H2–H6 copy) when present. + """ parts: list[str] = [] for col in ( "title", "h1", "meta_description", - "heading_sequence", + "heading_text", "og_title", "og_description", "twitter_title", diff --git a/src/website_profiling/analysis/text_hygiene.py b/src/website_profiling/analysis/text_hygiene.py new file mode 100644 index 00000000..9fb0d726 --- /dev/null +++ b/src/website_profiling/analysis/text_hygiene.py @@ -0,0 +1,62 @@ +"""Filter structural HTML tokens from semantic text pipelines (keywords, fingerprints, LLM).""" +from __future__ import annotations + +import re + +# heading_sequence stores tag names (h1,h2,...) — not heading copy. +HTML_HEADING_TOKENS = frozenset({"h1", "h2", "h3", "h4", "h5", "h6"}) +STRUCTURAL_JUNK_TOKENS = HTML_HEADING_TOKENS | frozenset( + { + "html", + "body", + "head", + "div", + "span", + "class", + "href", + "http", + "https", + "www", + "com", + "org", + "net", + "null", + "undefined", + "nan", + } +) + + +def tokenize_term(term: str) -> list[str]: + return [t for t in re.findall(r"\b[\w']+\b", (term or "").lower()) if t] + + +def is_junk_semantic_term(term: str) -> bool: + """True when a term/n-gram is structural noise, not site vocabulary.""" + tokens = tokenize_term(term) + if not tokens: + return True + if all(t in HTML_HEADING_TOKENS for t in tokens): + return True + if all(t in STRUCTURAL_JUNK_TOKENS for t in tokens): + return True + return False + + +def filter_semantic_terms(terms: list[str]) -> list[str]: + return [t for t in terms if t and not is_junk_semantic_term(t)] + + +def filter_topic_clusters(clusters: list[dict]) -> list[dict]: + """Drop token clusters whose representative is structural HTML noise.""" + out: list[dict] = [] + for cl in clusters: + top = str(cl.get("top_keyword") or cl.get("representative") or "").strip() + if not top or is_junk_semantic_term(top): + continue + keywords = cl.get("keywords") + if isinstance(keywords, list): + cleaned = filter_semantic_terms([str(k) for k in keywords]) + cl = {**cl, "keywords": cleaned} + out.append(cl) + return out diff --git a/src/website_profiling/common.py b/src/website_profiling/common.py index 76f451f9..b19c528b 100644 --- a/src/website_profiling/common.py +++ b/src/website_profiling/common.py @@ -211,6 +211,7 @@ def parse_seo_extended(html_text: str, base_url: str) -> dict: "noindex": False, "has_schema": False, "heading_sequence": [], + "heading_text": [], "images_without_alt": 0, "images_total": 0, "img_without_lazy": 0, @@ -233,10 +234,13 @@ def parse_seo_extended(html_text: str, base_url: str) -> dict: out["has_schema"] = True if soup.find(attrs={"itemscope": True}): out["has_schema"] = True - # Heading order (h1..h6 only) + # Heading order (h1..h6 tag names) and visible heading copy (for keywords / fingerprints) for tag in soup.find_all(["h1", "h2", "h3", "h4", "h5", "h6"]): if tag.name: out["heading_sequence"].append(tag.name) + text = (tag.get_text(separator=" ", strip=True) or "").strip() + if text: + out["heading_text"].append(text) # Images: alt, lazy, dimensions base_scheme = urlparse(base_url).scheme.lower() for img in soup.find_all("img"): diff --git a/src/website_profiling/crawl/crawler.py b/src/website_profiling/crawl/crawler.py index 358bc300..655857a0 100644 --- a/src/website_profiling/crawl/crawler.py +++ b/src/website_profiling/crawl/crawler.py @@ -258,6 +258,7 @@ def _empty_seo(self, url: str, headers_dict: Optional[dict] = None, redirect_cha "noindex": False, "has_schema": False, "heading_sequence": "", + "heading_text": "", "images_without_alt": 0, "images_total": 0, "img_without_lazy": 0, @@ -316,6 +317,7 @@ def _parse_page_content( ext["noindex"] = True ext["has_schema"] = seo_ext.get("has_schema", False) ext["heading_sequence"] = ",".join(seo_ext.get("heading_sequence") or []) + ext["heading_text"] = " | ".join(seo_ext.get("heading_text") or []) ext["images_without_alt"] = seo_ext.get("images_without_alt", 0) ext["images_total"] = seo_ext.get("images_total", 0) ext["img_without_lazy"] = seo_ext.get("img_without_lazy", 0) @@ -617,18 +619,24 @@ def crawl( start_time = time.time() from ..progress import CrawlProgressTracker, emit_phase_start - crawl_total = None if self.max_pages == float("inf") else int(self.max_pages) - progress_tracker = CrawlProgressTracker(crawl_total, start_time=start_time) + crawl_limit = None if self.max_pages == float("inf") else int(self.max_pages) + progress_tracker = CrawlProgressTracker( + crawl_limit, + start_time=start_time, + limit=crawl_limit, + ) emit_phase_start("crawl", message="Crawling pages") futures = [] db_writer: Optional[_CrawlDbWriter] = None + pages_crawled = 0 if stream_crawl_run_id is not None: db_writer = _CrawlDbWriter(stream_crawl_run_id, stream_batch_size) db_writer.start() + use_tqdm = show_progress and stream_crawl_run_id is None pbar = tqdm( total=None if self.max_pages == float("inf") else int(self.max_pages), desc="Pages", - disable=not show_progress, + disable=not use_tqdm, ) try: with ThreadPoolExecutor(max_workers=self.concurrency) as ex: @@ -674,6 +682,7 @@ def crawl( "noindex": False, "has_schema": False, "heading_sequence": "", + "heading_text": "", "images_without_alt": 0, "images_total": 0, "img_without_lazy": 0, @@ -711,12 +720,16 @@ def crawl( if self.store_outlinks: res["outlink_targets"] = "[]" self.results.append(res) - if db_writer is not None and res.get("url"): - db_writer.enqueue(res) - pbar.update(1) + page_url = str(res.get("url") or "").strip() or None + if page_url: + pages_crawled += 1 + if db_writer is not None: + db_writer.enqueue(res) + if use_tqdm: + pbar.update(1) progress_tracker.maybe_emit( - len(self.results), - str(res.get("url") or "") or None, + pages_crawled, + page_url, ) else: remaining.append(f) @@ -727,7 +740,15 @@ def crawl( break finally: self.fetcher.close() - pbar.close() + progress_tracker.finish(pages_crawled) + if use_tqdm: + pbar.close() + limit_label = ( + str(int(self.max_pages)) + if self.max_pages != float("inf") + else "unlimited" + ) + print(f" Crawled {pages_crawled} URLs (limit {limit_label}).", flush=True) if db_writer is not None: db_writer.finish() db_writer.join() @@ -754,6 +775,7 @@ def crawl( "noindex", "has_schema", "heading_sequence", + "heading_text", "images_without_alt", "images_total", "img_without_lazy", diff --git a/src/website_profiling/progress.py b/src/website_profiling/progress.py index 6776c0f4..82f9e2c7 100644 --- a/src/website_profiling/progress.py +++ b/src/website_profiling/progress.py @@ -2,7 +2,6 @@ from __future__ import annotations import json -import sys import time from typing import Any @@ -15,6 +14,7 @@ def emit_progress( *, current: int | None = None, total: int | None = None, + limit: int | None = None, url: str | None = None, message: str | None = None, elapsed_ms: int | None = None, @@ -30,6 +30,8 @@ def emit_progress( payload["current"] = current if total is not None: payload["total"] = total + if limit is not None: + payload["limit"] = limit if url: payload["url"] = url if message: @@ -52,19 +54,30 @@ def emit_phase_done(phase: str, message: str | None = None) -> None: class CrawlProgressTracker: """Throttle crawl progress emissions (every 2s or every 5 pages).""" - def __init__(self, total: int | None, start_time: float | None = None) -> None: + def __init__( + self, + total: int | None, + start_time: float | None = None, + *, + limit: int | None = None, + ) -> None: + self.limit = limit if limit and limit != float("inf") else None + if self.limit is None and total and total != float("inf"): + self.limit = int(total) self.total = total if total and total != float("inf") else None self.start_time = start_time or time.time() self._last_emit = 0.0 self._last_count = 0 self._last_url: str | None = None + self._finished = False def maybe_emit(self, current: int, url: str | None = None, *, force: bool = False) -> None: if url: self._last_url = url now = time.time() delta_pages = current - self._last_count - is_complete = self.total is not None and current >= self.total + hit_limit = self.limit is not None and current >= self.limit + is_complete = self._finished or hit_limit if not force and not is_complete: if current > 0 and delta_pages < 5 and (now - self._last_emit) < 2.0: return @@ -75,9 +88,21 @@ def maybe_emit(self, current: int, url: str | None = None, *, force: bool = Fals "fetch", current=current, total=self.total, + limit=self.limit, url=self._last_url, elapsed_ms=elapsed_ms, avg_ms=avg_ms, ) self._last_emit = now self._last_count = current + + def finish(self, current: int) -> None: + """Emit final progress with total aligned to actual pages crawled.""" + if current <= 0: + return + self._finished = True + if self.limit is not None and current >= self.limit: + self.total = self.limit + else: + self.total = current + self.maybe_emit(current, force=True) diff --git a/src/website_profiling/reporting/builder.py b/src/website_profiling/reporting/builder.py index 1cff6ed5..c178dd16 100644 --- a/src/website_profiling/reporting/builder.py +++ b/src/website_profiling/reporting/builder.py @@ -26,6 +26,7 @@ from ..tools.keywords import cluster_keywords, extract_candidates_from_df, score_keywords from ..config import get_bool, get_int from ..analysis import merge_bundles, run_local_enrichment +from ..analysis.text_hygiene import filter_topic_clusters, is_junk_semantic_term from ..llm.enrich import cluster_keywords_llm, run_llm_enrichment from ..llm_config import load_llm_config_from_db, llm_is_enabled from .categories import build_categories @@ -588,8 +589,10 @@ def _build_content_analytics(df: pd.DataFrame) -> dict: except (json.JSONDecodeError, TypeError): pass result["top_keywords_site"] = [ - {"word": w, "count": c} for w, c in kw_counter.most_common(30) if w - ] + {"word": w, "count": c} + for w, c in kw_counter.most_common(50) + if w and not is_junk_semantic_term(str(w)) + ][:30] for _, row in success_df.iterrows(): u = row.get("url") @@ -602,6 +605,110 @@ def _build_content_analytics(df: pd.DataFrame) -> dict: return result +def _parse_top_keywords_items(raw: Any) -> list[dict[str, Any]]: + """Parse per-page top_keywords JSON into dict items with word/count.""" + if raw is None or (isinstance(raw, float) and pd.isna(raw)): + return [] + try: + items = json.loads(str(raw)) if isinstance(raw, str) else raw + except (json.JSONDecodeError, TypeError, ValueError): + return [] + if not isinstance(items, list): + return [] + out: list[dict[str, Any]] = [] + for item in items: + if isinstance(item, dict): + word = str(item.get("word") or "").strip() + if word: + out.append({"word": word, "count": int(item.get("count") or 1)}) + return out + + +def _build_text_content_analysis(df: pd.DataFrame) -> dict: + """Cross-page keyword aggregates for the text content analysis view.""" + empty = { + "vocabulary_stats": { + "unique_terms": 0, + "pages_with_keywords": 0, + "avg_terms_per_page": 0.0, + "total_term_occurrences": 0, + }, + "keyword_index": [], + "keyword_frequency_histogram": {"1": 0, "2-5": 0, "6-20": 0, "21+": 0}, + } + if df.empty or "top_keywords" not in df.columns: + return empty + + success_df = df[df["status"].astype(str).str.match(r"2\d{2}", na=False)] if "status" in df.columns else df + if success_df.empty: + return empty + + # word -> { total_count, pages: { url -> count } } + index: dict[str, dict[str, Any]] = {} + pages_with_keywords = 0 + total_occurrences = 0 + + for _, row in success_df.iterrows(): + url = row.get("url") + if pd.isna(url) or not url: + continue + url_str = str(url).strip() + items = _parse_top_keywords_items(row.get("top_keywords")) + page_had_kw = False + for item in items: + word = item["word"].lower() + if is_junk_semantic_term(word): + continue + count = max(1, int(item.get("count") or 1)) + if word not in index: + index[word] = {"total_count": 0, "pages": {}} + index[word]["total_count"] += count + index[word]["pages"][url_str] = index[word]["pages"].get(url_str, 0) + count + total_occurrences += count + page_had_kw = True + if page_had_kw: + pages_with_keywords += 1 + + unique_terms = len(index) + avg_terms = round(total_occurrences / pages_with_keywords, 1) if pages_with_keywords else 0.0 + + histogram = {"1": 0, "2-5": 0, "6-20": 0, "21+": 0} + for data in index.values(): + pc = len(data["pages"]) + if pc == 1: + histogram["1"] += 1 + elif pc <= 5: + histogram["2-5"] += 1 + elif pc <= 20: + histogram["6-20"] += 1 + else: + histogram["21+"] += 1 + + sorted_words = sorted(index.items(), key=lambda x: x[1]["total_count"], reverse=True) + keyword_index: list[dict[str, Any]] = [] + for word, data in sorted_words: + top_pages = sorted(data["pages"].items(), key=lambda x: x[1], reverse=True)[:5] + keyword_index.append( + { + "word": word, + "total_count": data["total_count"], + "page_count": len(data["pages"]), + "top_pages": [{"url": u, "count": c} for u, c in top_pages], + } + ) + + return { + "vocabulary_stats": { + "unique_terms": unique_terms, + "pages_with_keywords": pages_with_keywords, + "avg_terms_per_page": avg_terms, + "total_term_occurrences": total_occurrences, + }, + "keyword_index": keyword_index, + "keyword_frequency_histogram": histogram, + } + + def _build_social_coverage(df: pd.DataFrame) -> dict: """Build social meta coverage stats: OG and Twitter Card presence percentages.""" result = { @@ -834,9 +941,10 @@ def _build_url_fingerprints(df: pd.DataFrame) -> list[dict[str, Any]]: h1c = int(pd.to_numeric(row.get("h1_count"), errors="coerce") or 0) sc = int(pd.to_numeric(row.get("script_count"), errors="coerce") or 0) lc = int(pd.to_numeric(row.get("link_stylesheet_count"), errors="coerce") or 0) - raw_c = "|".join([title, meta, h1, headings, str(wc), str(cl)]).encode("utf-8") + # heading_sequence is structural (h1,h2,...) — keep it in structure fingerprint only. + raw_c = "|".join([title, meta, h1, str(wc), str(cl)]).encode("utf-8") content_fp = hashlib.sha256(raw_c).hexdigest() - raw_s = "|".join([str(cl), str(sc), str(lc), str(h1c)]).encode("utf-8") + raw_s = "|".join([str(cl), str(sc), str(lc), str(h1c), headings]).encode("utf-8") structure_fp = hashlib.sha256(raw_s).hexdigest() out.append({ "url": u, @@ -867,6 +975,25 @@ def _build_hreflang_summary(df: pd.DataFrame) -> dict[str, Any]: } +def _validate_report_url_counts(report_data: dict[str, Any], df_row_count: int) -> None: + """Ensure crawled URL counts are consistent across report payload fields.""" + links = report_data.get("links") or [] + summary = report_data.get("summary") or {} + scope = (report_data.get("report_meta") or {}).get("crawl_scope") or {} + link_count = len(links) if isinstance(links, list) else 0 + total_urls = int(summary.get("total_urls") or 0) + pages_crawled = int(scope.get("pages_crawled") or 0) + counts = {link_count, total_urls, pages_crawled, df_row_count} + if len(counts) > 1: + msg = ( + f"report count mismatch: links={link_count}, " + f"summary.total_urls={total_urls}, " + f"pages_crawled={pages_crawled}, df_rows={df_row_count}" + ) + print(f" WARNING: {msg}", flush=True) + report_data.setdefault("ml_errors", []).append(msg) + + def _build_report_metadata( df: pd.DataFrame, config: Optional[dict[str, str]], @@ -986,7 +1113,7 @@ def _build_keyword_opportunities(df: pd.DataFrame, config: dict[str, str] | None return { "quick_wins": quick_wins[:10], "high_value": high_value[:10], - "token_topic_clusters": clusters[:50], + "token_topic_clusters": filter_topic_clusters(clusters)[:50], } @@ -1628,13 +1755,18 @@ def _bool_col(col): emit_progress("report", "content_analytics", message="Building content analytics") print(" Building content analytics...", flush=True) content_analytics = _build_content_analytics(df) + text_content_analysis = _build_text_content_analysis(df) semantic_keyword_clusters: list[dict[str, Any]] = [] llm_cfg_for_clusters = load_llm_config_from_db() if llm_is_enabled(llm_cfg_for_clusters): try: llm_cfg = llm_cfg_for_clusters if str(llm_cfg.get("llm_enable_keyword_clusters", "")).lower() in ("true", "1", "yes"): - words = [x["word"] for x in (content_analytics.get("top_keywords_site") or []) if x.get("word")] + words = [ + x["word"] + for x in (content_analytics.get("top_keywords_site") or []) + if x.get("word") and not is_junk_semantic_term(str(x["word"])) + ] semantic_keyword_clusters = cluster_keywords_llm(words, llm_cfg) except Exception as e: ml_bundle.setdefault("ml_errors", []).append(str(e)) @@ -1679,6 +1811,7 @@ def _bool_col(col): "content_urls": content_urls, "security_findings": security_findings, "content_analytics": content_analytics, + "text_content_analysis": text_content_analysis, "social_coverage": social_coverage, "tech_stack_summary": tech_stack_summary, "response_time_stats": response_time_stats, @@ -1947,6 +2080,7 @@ def _bool_col(col): crawl_run_created_at, gsc_links, ) + _validate_report_url_counts(report_data, len(df)) db_write_report_payload(conn, report_data) return "postgresql" diff --git a/src/website_profiling/tools/keywords.py b/src/website_profiling/tools/keywords.py index bdddb22c..00095b0e 100644 --- a/src/website_profiling/tools/keywords.py +++ b/src/website_profiling/tools/keywords.py @@ -11,6 +11,8 @@ import pandas as pd +from ..analysis.text_hygiene import filter_topic_clusters, is_junk_semantic_term + # Default weights: volume 40%, relevance 30%, ctr_est 15%, (1 - difficulty) 15% DEFAULT_WEIGHTS = {"volume": 0.40, "relevance": 0.30, "ctr_est": 0.15, "ease": 0.15} @@ -36,6 +38,28 @@ def _ngrams(tokens: list[str], n: int) -> list[str]: return [" ".join(tokens[i : i + n]) for i in range(len(tokens) - n + 1)] +def _tokens_from_top_keywords(raw: Any) -> list[tuple[str, int]]: + """Parse per-page top_keywords JSON into weighted terms from body copy.""" + if raw is None or (isinstance(raw, float) and pd.isna(raw)): + return [] + try: + items = json.loads(str(raw)) if isinstance(raw, str) else raw + except (json.JSONDecodeError, TypeError, ValueError): + return [] + if not isinstance(items, list): + return [] + out: list[tuple[str, int]] = [] + for item in items: + if not isinstance(item, dict): + continue + word = str(item.get("word") or "").strip().lower() + if len(word) < 3 or is_junk_semantic_term(word): + continue + count = int(item.get("count") or 1) + out.append((word, max(1, count))) + return out + + def _slug_tokens(url: str) -> list[str]: """Extract path segments as potential keywords (slug words).""" parsed = urlparse(url) @@ -50,13 +74,31 @@ def _slug_tokens(url: str) -> list[str]: return out +def _add_candidate( + candidates: dict[str, dict[str, Any]], + keyword: str, + url: str, + *, + weight: int = 1, +) -> None: + kw = keyword.strip().lower() + if len(kw) < 2 or is_junk_semantic_term(kw): + return + if kw not in candidates: + candidates[kw] = {"sources": [], "tokens": _tokenize(kw), "count": 0} + if url not in candidates[kw]["sources"]: + candidates[kw]["sources"].append(url) + candidates[kw]["count"] += max(1, weight) + + def extract_candidates_from_df(df: pd.DataFrame) -> dict[str, dict[str, Any]]: """ From crawl DataFrame, extract candidate keywords (1–4 grams) from title, meta_description, - h1, heading_sequence, and URL slugs. Returns dict: keyword -> {sources: [urls], tokens, ...}. + h1, per-page top_keywords (body copy), and URL slugs. + heading_sequence is intentionally excluded — it stores tag names (h1,h2), not heading text. """ candidates: dict[str, dict[str, Any]] = {} - text_cols = ["title", "meta_description", "h1", "heading_sequence"] + text_cols = ["title", "meta_description", "h1", "heading_text"] for _, row in df.iterrows(): url = str(row.get("url") or "").strip() if not url or str(row.get("status", "")).startswith(("4", "5")): @@ -70,17 +112,16 @@ def extract_candidates_from_df(df: pd.DataFrame) -> dict[str, dict[str, Any]]: continue all_tokens.extend(_tokenize(str(val))) all_tokens.extend(_slug_tokens(url)) + if "top_keywords" in row.index: + for word, count in _tokens_from_top_keywords(row.get("top_keywords")): + _add_candidate(candidates, word, url, weight=count) if not all_tokens: continue for n in range(1, 5): for ng in _ngrams(all_tokens, n): if len(ng) < 2: continue - if ng not in candidates: - candidates[ng] = {"sources": [], "tokens": _tokenize(ng), "count": 0} - if url not in candidates[ng]["sources"]: - candidates[ng]["sources"].append(url) - candidates[ng]["count"] += 1 + _add_candidate(candidates, ng, url) return candidates @@ -110,6 +151,8 @@ def score_keywords( relevance_scores = _relevance_tfidf(candidates, corpus_size or len(candidates)) results: list[dict[str, Any]] = [] for kw, data in candidates.items(): + if is_junk_semantic_term(kw): + continue raw_vol = (data.get("count") or 0) / max(corpus_size or 1, 1) * 100 volume = min(1.0, raw_vol) difficulty = 50.0 @@ -160,6 +203,8 @@ def cluster_keywords(scored: list[dict[str, Any]]) -> list[dict[str, Any]]: kw_list = [s["keyword"] for s in scored] for s in scored: kw = s.get("keyword") or "" + if is_junk_semantic_term(kw): + continue if kw in used: continue cluster = {kw} @@ -244,7 +289,7 @@ def run_keyword_pipeline( corpus_size = len(df) weights = DEFAULT_WEIGHTS scored = score_keywords(candidates, weights=weights, corpus_size=corpus_size) - clusters = cluster_keywords(scored) + clusters = filter_topic_clusters(cluster_keywords(scored)) semantic_clusters: list[dict[str, Any]] = [] try: @@ -259,7 +304,11 @@ def run_keyword_pipeline( try: from ..llm.enrich import cluster_keywords_llm - top_kw = [s["keyword"] for s in scored[:200] if s.get("keyword")] + top_kw = [ + s["keyword"] + for s in scored[:200] + if s.get("keyword") and not is_junk_semantic_term(str(s["keyword"])) + ] semantic_clusters = cluster_keywords_llm(top_kw, llm_cfg) except Exception as e: print(f"Semantic keywords skipped: {e}", file=sys.stderr) diff --git a/tests/test_crawler_unit.py b/tests/test_crawler_unit.py index b7722384..339e1350 100644 --- a/tests/test_crawler_unit.py +++ b/tests/test_crawler_unit.py @@ -559,6 +559,46 @@ def test_worker_applies_polite_delay(monkeypatch) -> None: assert sleeps == [0.05] +def test_crawl_with_progress_bar(monkeypatch) -> None: + from website_profiling.crawl.crawler import Crawler + from website_profiling.crawl.fetchers.base import FetchResult + + html = "Tok" + + class DummyFetcher: + def fetch(self, url: str) -> FetchResult: + return FetchResult( + status=200, + content_type="text/html", + text=html, + response_time_ms=1, + content_length=len(html), + final_url=url, + headers_dict={}, + redirect_chain_length=0, + fetch_method="static", + ) + + def close(self) -> None: + pass + + monkeypatch.setattr( + "website_profiling.crawl.crawler.build_fetcher", + lambda **_kwargs: DummyFetcher(), + ) + monkeypatch.setattr("website_profiling.crawl.crawler.time.sleep", lambda _s: None) + + c = Crawler( + start_url="https://site.com", + ignore_robots=True, + use_wappalyzer=False, + concurrency=1, + max_pages=1, + ) + df = c.crawl(show_progress=True) + assert len(df) == 1 + + def test_queue_contains_swallows_queue_errors() -> None: from website_profiling.crawl.crawler import Crawler diff --git a/tests/test_keywords_extraction.py b/tests/test_keywords_extraction.py new file mode 100644 index 00000000..4bb55ef3 --- /dev/null +++ b/tests/test_keywords_extraction.py @@ -0,0 +1,57 @@ +"""Tests for on-site keyword candidate extraction.""" + +import json + +import pandas as pd + +from website_profiling.analysis.text_hygiene import is_junk_semantic_term +from website_profiling.tools.keywords import extract_candidates_from_df, score_keywords + + +def test_is_junk_semantic_term_rejects_heading_tag_ngrams() -> None: + assert is_junk_semantic_term("h2 h3") is True + assert is_junk_semantic_term("h3 h3 h3 h3") is True + assert is_junk_semantic_term("h1") is True + + +def test_is_junk_semantic_term_accepts_real_terms() -> None: + assert is_junk_semantic_term("video games") is False + assert is_junk_semantic_term("artificial intelligence") is False + assert is_junk_semantic_term("games") is False + + +def test_extract_candidates_ignores_heading_sequence_tag_names() -> None: + df = pd.DataFrame( + [ + { + "url": "https://example.com/page", + "status": "200", + "title": "Video Games Reviews", + "meta_description": "Latest video games news", + "h1": "Video Games", + "heading_sequence": "h1,h2,h3,h3", + "heading_text": "Best RPG Games | Indie Reviews", + "top_keywords": json.dumps( + [{"word": "games", "count": 12}, {"word": "reviews", "count": 8}] + ), + } + ] + ) + candidates = extract_candidates_from_df(df) + assert "h2 h3" not in candidates + assert "h3 h3" not in candidates + assert "games" in candidates + assert "video games" in candidates + assert "rpg games" in candidates + assert candidates["games"]["count"] >= 12 + + +def test_score_keywords_skips_junk_candidates() -> None: + candidates = { + "h2 h3": {"sources": ["https://a.com"], "tokens": ["h2", "h3"], "count": 5}, + "video games": {"sources": ["https://a.com"], "tokens": ["video", "games"], "count": 3}, + } + scored = score_keywords(candidates, corpus_size=1) + keywords = [row["keyword"] for row in scored] + assert "h2 h3" not in keywords + assert "video games" in keywords diff --git a/tests/test_progress.py b/tests/test_progress.py index 22849bc9..2a0c3515 100644 --- a/tests/test_progress.py +++ b/tests/test_progress.py @@ -49,3 +49,24 @@ def test_emit_phase_done(): payload = json.loads(args[len(PREFIX) :]) assert payload["phase"] == "report" assert payload["step"] == "done" + + +def test_crawl_tracker_finish_natural_stop(capsys): + tracker = CrawlProgressTracker(total=1500, limit=1500, start_time=1000.0) + tracker.finish(1138) + lines = [ln for ln in capsys.readouterr().out.splitlines() if ln.startswith(PREFIX)] + assert len(lines) == 1 + payload = json.loads(lines[0][len(PREFIX) :]) + assert payload["current"] == 1138 + assert payload["total"] == 1138 + assert payload["limit"] == 1500 + + +def test_crawl_tracker_finish_at_limit(capsys): + tracker = CrawlProgressTracker(total=1500, limit=1500, start_time=1000.0) + tracker.finish(1500) + lines = [ln for ln in capsys.readouterr().out.splitlines() if ln.startswith(PREFIX)] + payload = json.loads(lines[-1][len(PREFIX) :]) + assert payload["current"] == 1500 + assert payload["total"] == 1500 + assert payload["limit"] == 1500 diff --git a/tests/test_text_content_analysis.py b/tests/test_text_content_analysis.py new file mode 100644 index 00000000..afa0f53f --- /dev/null +++ b/tests/test_text_content_analysis.py @@ -0,0 +1,109 @@ +"""Tests for text content analysis aggregation in report builder.""" + +import json + +import pandas as pd + +from website_profiling.reporting.builder import _build_text_content_analysis + + +def _df(rows: list[dict]) -> pd.DataFrame: + return pd.DataFrame(rows) + + +def test_keyword_index_aggregates_across_pages() -> None: + df = _df( + [ + { + "url": "https://example.com/a", + "status": "200", + "top_keywords": json.dumps([{"word": "games", "count": 5}, {"word": "reviews", "count": 2}]), + }, + { + "url": "https://example.com/b", + "status": "200", + "top_keywords": json.dumps([{"word": "games", "count": 3}]), + }, + ] + ) + result = _build_text_content_analysis(df) + games = next(x for x in result["keyword_index"] if x["word"] == "games") + assert games["total_count"] == 8 + assert games["page_count"] == 2 + assert len(games["top_pages"]) == 2 + assert result["vocabulary_stats"]["unique_terms"] == 2 + assert result["vocabulary_stats"]["pages_with_keywords"] == 2 + assert result["vocabulary_stats"]["total_term_occurrences"] == 10 + + +def test_junk_terms_excluded() -> None: + df = _df( + [ + { + "url": "https://example.com/page", + "status": "200", + "top_keywords": json.dumps( + [{"word": "h2 h3", "count": 10}, {"word": "video games", "count": 4}] + ), + } + ] + ) + result = _build_text_content_analysis(df) + words = [x["word"] for x in result["keyword_index"]] + assert "h2 h3" not in words + assert "video games" in words + + +def test_histogram_buckets() -> None: + df = _df( + [ + { + "url": "https://example.com/one", + "status": "200", + "top_keywords": json.dumps([{"word": "solo", "count": 1}]), + }, + { + "url": "https://example.com/two", + "status": "200", + "top_keywords": json.dumps([{"word": "shared", "count": 1}]), + }, + { + "url": "https://example.com/three", + "status": "200", + "top_keywords": json.dumps([{"word": "shared", "count": 1}]), + }, + ] + ) + result = _build_text_content_analysis(df) + hist = result["keyword_frequency_histogram"] + assert hist["1"] == 1 # solo on 1 page + assert hist["2-5"] == 1 # shared on 2 pages + + +def test_empty_or_missing_column_returns_defaults() -> None: + assert _build_text_content_analysis(pd.DataFrame())["keyword_index"] == [] + df = _df([{"url": "https://example.com", "status": "200", "word_count": 100}]) + result = _build_text_content_analysis(df) + assert result["vocabulary_stats"]["unique_terms"] == 0 + assert result["keyword_index"] == [] + + +def test_non_2xx_pages_skipped() -> None: + df = _df( + [ + { + "url": "https://example.com/404", + "status": "404", + "top_keywords": json.dumps([{"word": "games", "count": 5}]), + }, + { + "url": "https://example.com/ok", + "status": "200", + "top_keywords": json.dumps([{"word": "games", "count": 2}]), + }, + ] + ) + result = _build_text_content_analysis(df) + games = next(x for x in result["keyword_index"] if x["word"] == "games") + assert games["total_count"] == 2 + assert games["page_count"] == 1 diff --git a/tests/test_text_hygiene.py b/tests/test_text_hygiene.py new file mode 100644 index 00000000..fb974d91 --- /dev/null +++ b/tests/test_text_hygiene.py @@ -0,0 +1,63 @@ +"""Tests for semantic text hygiene (structural token filtering).""" + +from website_profiling.analysis.text import normalize_fingerprint_text +from website_profiling.analysis.text_hygiene import ( + filter_semantic_terms, + filter_topic_clusters, + is_junk_semantic_term, +) + + +def test_is_junk_semantic_term_heading_tags() -> None: + assert is_junk_semantic_term("h2 h3") is True + assert is_junk_semantic_term("h1") is True + + +def test_is_junk_semantic_term_empty_or_non_word() -> None: + assert is_junk_semantic_term("") is True + assert is_junk_semantic_term(" ") is True + assert is_junk_semantic_term("!!!") is True + + +def test_is_junk_semantic_term_structural_multi_token() -> None: + assert is_junk_semantic_term("div span") is True + assert is_junk_semantic_term("html body") is True + + +def test_is_junk_semantic_term_real_words() -> None: + assert is_junk_semantic_term("video games") is False + assert is_junk_semantic_term("artificial intelligence") is False + + +def test_filter_topic_clusters_drops_junk() -> None: + clusters = [ + {"top_keyword": "h3 h3", "keywords": ["h3 h3", "h3"]}, + {"top_keyword": "games", "keywords": ["games", "reviews"]}, + ] + out = filter_topic_clusters(clusters) + assert len(out) == 1 + assert out[0]["top_keyword"] == "games" + + +def test_normalize_fingerprint_text_excludes_heading_sequence_tags() -> None: + import pandas as pd + + row = pd.Series( + { + "title": "Video Games", + "h1": "Reviews", + "meta_description": "Latest news", + "heading_sequence": "h1,h2,h3,h3", + "heading_text": "Top RPG picks | Indie highlights", + "content_excerpt": "Body copy about games", + } + ) + out = normalize_fingerprint_text(row) + assert "h2 h3" not in out + assert "h3 h3" not in out + assert "rpg picks" in out + assert "video games" in out + + +def test_filter_semantic_terms() -> None: + assert filter_semantic_terms(["h2 h3", "games", "reviews"]) == ["games", "reviews"] diff --git a/web/app/(reports)/[slug]/page.tsx b/web/app/(reports)/[slug]/page.tsx index 62bc1493..71b3d8f1 100644 --- a/web/app/(reports)/[slug]/page.tsx +++ b/web/app/(reports)/[slug]/page.tsx @@ -1,10 +1,30 @@ import ReportShell from '@/ReportShell'; import { pathSlugToViewId } from '@/routes'; +import { strings } from '@/lib/strings'; import { notFound } from 'next/navigation'; +import type { Metadata } from 'next'; import type { ReactElement } from 'react'; export const dynamic = 'force-dynamic'; +export async function generateMetadata({ + params, +}: { + params: Promise<{ slug: string }>; +}): Promise { + const { slug } = await params; + const viewId = pathSlugToViewId(slug); + if (!viewId) { + return { title: 'Not found' }; + } + const navEntry = strings.nav[viewId as keyof typeof strings.nav]; + const label = + navEntry && typeof navEntry === 'object' && 'label' in navEntry + ? String(navEntry.label) + : 'Report'; + return { title: `${label} · Site Audit` }; +} + export default async function SlugPage({ params, }: { diff --git a/web/app/(reports)/error.tsx b/web/app/(reports)/error.tsx new file mode 100644 index 00000000..e6f7f745 --- /dev/null +++ b/web/app/(reports)/error.tsx @@ -0,0 +1,38 @@ +'use client'; + +import Link from 'next/link'; +import { strings } from '@/lib/strings'; + +export default function ReportsError({ + error, + reset, +}: { + error: Error & { digest?: string }; + reset: () => void; +}) { + return ( +
+
+

{strings.app.failedTitle}

+

+ {error.message || strings.app.failedHint} +

+
+ + + Go to Home + +
+
+
+ ); +} diff --git a/web/app/(reports)/layout.tsx b/web/app/(reports)/layout.tsx new file mode 100644 index 00000000..bb8af3ea --- /dev/null +++ b/web/app/(reports)/layout.tsx @@ -0,0 +1,11 @@ +import { Suspense, type ReactNode } from 'react'; +import { ReportAppClient } from '@/ReportShell'; +import ReportShellSkeleton from '@/components/ReportShellSkeleton'; + +export default function ReportsLayout({ children }: { children: ReactNode }) { + return ( + }> + {children} + + ); +} diff --git a/web/app/(reports)/loading.tsx b/web/app/(reports)/loading.tsx new file mode 100644 index 00000000..2468452c --- /dev/null +++ b/web/app/(reports)/loading.tsx @@ -0,0 +1,5 @@ +import ReportShellSkeleton from '@/components/ReportShellSkeleton'; + +export default function ReportsLoading() { + return ; +} diff --git a/web/app/api/report/portfolio/route.ts b/web/app/api/report/portfolio/route.ts index e4d8dab6..a6ec6e91 100644 --- a/web/app/api/report/portfolio/route.ts +++ b/web/app/api/report/portfolio/route.ts @@ -11,6 +11,7 @@ import { computeCrawlOnlyGroups, mergePortfolioGroups, } from '@/lib/homePortfolio'; +import { buildCrawlHistoryByDomain } from '@/lib/portfolioCrawlHistory'; import { strings } from '@/lib/strings'; import type { ApiRouteHandler } from '@/types/api'; import type { StringsCatalog } from '@/types/strings'; @@ -29,13 +30,19 @@ export const GET: ApiRouteHandler = async (request: NextRequest): Promise { + const portfolio = await withReportDb(async (client) => { const all = await listReportsFromDatabase(client); const idSet = new Set(ids); const reportList = ids.length ? all.filter((r) => idSet.has(r.id)) : all; const crawlRows = await getCrawlRunsRows(client); const startUrlByRunId = new Map(crawlRows.map((cr) => [cr.id, cr.start_url])); const runCreatedAtByRunId = new Map(crawlRows.map((cr) => [cr.id, cr.created_at])); + const runMetaByRunId = new Map( + crawlRows.map((cr) => [ + cr.id, + { render_mode: cr.render_mode, discovery_mode: cr.discovery_mode }, + ]), + ); const reportGroups = await computeDomainGroups( reportList, startUrlByRunId, @@ -43,6 +50,7 @@ export const GET: ApiRouteHandler = async (request: NextRequest): Promise readReportPayloadFromDatabase(client, id), + runMetaByRunId, ); const crawlSummaries = await getCrawlRunSummaries(client); const crawlOnlyGroups = computeCrawlOnlyGroups( @@ -51,11 +59,13 @@ export const GET: ApiRouteHandler = async (request: NextRequest): Promise; -} diff --git a/web/app/indexation/page.tsx b/web/app/indexation/page.tsx deleted file mode 100644 index a5556f05..00000000 --- a/web/app/indexation/page.tsx +++ /dev/null @@ -1,7 +0,0 @@ -'use client'; - -import ReportShell from '@/ReportShell'; - -export default function IndexationPage() { - return ; -} diff --git a/web/app/log-analyzer/page.tsx b/web/app/log-analyzer/page.tsx deleted file mode 100644 index 885cea98..00000000 --- a/web/app/log-analyzer/page.tsx +++ /dev/null @@ -1,7 +0,0 @@ -'use client'; - -import ReportShell from '@/ReportShell'; - -export default function LogAnalyzerPage() { - return ; -} diff --git a/web/app/subdomains/page.tsx b/web/app/subdomains/page.tsx deleted file mode 100644 index 600fc97d..00000000 --- a/web/app/subdomains/page.tsx +++ /dev/null @@ -1,7 +0,0 @@ -'use client'; - -import ReportShell from '@/ReportShell'; - -export default function SubdomainsPage() { - return ; -} diff --git a/web/next.config.mjs b/web/next.config.mjs index 503bc8e1..a19b5a1b 100644 --- a/web/next.config.mjs +++ b/web/next.config.mjs @@ -12,6 +12,16 @@ const nextConfig = { destination: '/keywords', permanent: true, }, + { + source: '/overview', + destination: '/dashboard', + permanent: true, + }, + { + source: '/charts', + destination: '/dashboard?tab=charts', + permanent: false, + }, ]; }, }; diff --git a/web/src/ReportShell.tsx b/web/src/ReportShell.tsx index 01f567c9..a4f955c2 100644 --- a/web/src/ReportShell.tsx +++ b/web/src/ReportShell.tsx @@ -28,6 +28,7 @@ import { Terminal, Globe2, Contact2, + TextSearch, } from 'lucide-react'; import { UrlInspectorProvider } from './context/UrlInspectorContext'; import AppShell from './components/AppShell'; @@ -65,6 +66,7 @@ const Network = dynamic(() => import('./views/Network'), { loading: () => viewLoading('Loading network graph…'), }); const ContentAnalytics = dynamic(() => import('./views/ContentAnalytics'), { loading: () => viewLoading() }); +const TextContentAnalysis = dynamic(() => import('./views/TextContentAnalysis'), { loading: () => viewLoading() }); const TechStack = dynamic(() => import('./views/TechStack'), { loading: () => viewLoading() }); const Gallery = dynamic(() => import('./views/Gallery'), { loading: () => viewLoading() }); const SearchPerformance = dynamic(() => import('./views/SearchPerformance'), { loading: () => viewLoading() }); @@ -121,6 +123,7 @@ const VIEW_CONFIG: ViewConfigEntry[] = [ { id: 'security', component: Security as ComponentType, icon: ShieldAlert }, { id: 'javascript-errors', component: JavaScriptErrors as ComponentType, icon: Bug }, { id: 'content-analytics', component: ContentAnalytics as ComponentType, icon: BarChart2 }, + { id: 'text-content-analysis', component: TextContentAnalysis as ComponentType, icon: TextSearch }, { id: 'tech-stack', component: TechStack as ComponentType, icon: Cpu }, { id: 'network', component: Network as ComponentType, icon: Share2 }, { id: 'gallery', component: Gallery as ComponentType, icon: Images }, @@ -183,14 +186,6 @@ function AppContent({ slug }: SlugProps): ReactNode { const view = pathSlugToViewId(slug ?? ''); - useEffect(() => { - if (slug !== 'charts') return; - const next = new URLSearchParams(searchParams.toString()); - next.set('tab', 'charts'); - const q = next.toString(); - router.replace(`/dashboard?${q}`); - }, [slug, searchParams, router]); - const selectView = (id: ViewId | string, opts?: { domain?: string; reportId?: number }): void => { if (opts?.reportId != null) { setSelectedReportId(opts.reportId); @@ -215,10 +210,6 @@ function AppContent({ slug }: SlugProps): ReactNode { return null; } - if (view === 'charts') { - return ; - } - const CurrentView = VIEWS.find((v) => v.id === view)?.component || Home; const showSidebar = view !== 'home'; const showSearch = showSidebar && view !== 'export'; @@ -293,9 +284,5 @@ export function ReportAppClient({ children }: { children: ReactNode }): ReactNod } export default function ReportShell({ slug }: SlugProps): ReactNode { - return ( - - - - ); + return ; } diff --git a/web/src/components/HealthSparkline.tsx b/web/src/components/HealthSparkline.tsx index b36f06e0..2b50be13 100644 --- a/web/src/components/HealthSparkline.tsx +++ b/web/src/components/HealthSparkline.tsx @@ -1,5 +1,7 @@ 'use client'; +import Sparkline from '@/components/Sparkline'; + interface HealthSparklineProps { scores: number[]; width?: number; @@ -13,31 +15,13 @@ export default function HealthSparkline({ height = 20, className = '', }: HealthSparklineProps) { - const valid = scores.filter((s) => Number.isFinite(s)); - if (valid.length < 2) return null; - - const min = Math.min(...valid); - const max = Math.max(...valid); - const range = max - min || 1; - const points = valid.map((score, i) => { - const x = (i / (valid.length - 1)) * width; - const y = height - ((score - min) / range) * height; - return `${x},${y}`; - }); - - const latest = valid[valid.length - 1]; - const stroke = - latest >= 80 ? '#34d399' : latest >= 60 ? '#fbbf24' : '#f87171'; - return ( - - - + mode="higher-better" + className={className} + /> ); } diff --git a/web/src/components/Skeleton.tsx b/web/src/components/Skeleton.tsx index 54945589..60d9523e 100644 --- a/web/src/components/Skeleton.tsx +++ b/web/src/components/Skeleton.tsx @@ -13,7 +13,7 @@ export function Skeleton({ className = '' }: { className?: string }) { /** Rounded rectangle mimicking a portfolio / domain card on Home. */ export function SkeletonDomainCard() { return ( -
+
@@ -25,7 +25,9 @@ export function SkeletonDomainCard() {
- + + +
diff --git a/web/src/components/Sparkline.tsx b/web/src/components/Sparkline.tsx new file mode 100644 index 00000000..d1889789 --- /dev/null +++ b/web/src/components/Sparkline.tsx @@ -0,0 +1,110 @@ +'use client'; + +export type SparklineMode = 'higher-better' | 'lower-better'; + +interface SparklineProps { + values: number[]; + width?: number; + height?: number; + mode?: SparklineMode; + className?: string; +} + +function trendStroke(values: number[], mode: SparklineMode): string { + if (values.length < 2) return '#94a3b8'; + const first = values[0]; + const last = values[values.length - 1]; + const delta = last - first; + if (Math.abs(delta) < 0.5) return '#fbbf24'; + if (mode === 'higher-better') return delta > 0 ? '#34d399' : '#f87171'; + return delta < 0 ? '#34d399' : '#f87171'; +} + +function absoluteStroke(value: number, mode: SparklineMode): string { + if (mode === 'lower-better') { + if (value <= 5) return '#34d399'; + if (value <= 20) return '#fbbf24'; + return '#f87171'; + } + if (value >= 80) return '#34d399'; + if (value >= 60) return '#fbbf24'; + return '#f87171'; +} + +export default function Sparkline({ + values, + width = 88, + height = 22, + mode = 'higher-better', + className = '', +}: SparklineProps) { + const valid = values.filter((s) => Number.isFinite(s)); + if (!valid.length) return null; + + if (valid.length === 1) { + const value = valid[0]; + const y = height / 2; + const stroke = absoluteStroke(value, mode); + return ( + + + + + ); + } + + const min = Math.min(...valid); + const max = Math.max(...valid); + const range = max - min || 1; + const points = valid.map((value, i) => { + const x = (i / (valid.length - 1)) * width; + const y = height - ((value - min) / range) * (height - 2) - 1; + return { x, y }; + }); + const polyline = points.map((p) => `${p.x},${p.y}`).join(' '); + const area = [ + `0,${height}`, + ...points.map((p) => `${p.x},${p.y}`), + `${width},${height}`, + ].join(' '); + + const latest = valid[valid.length - 1]; + const stroke = + valid.length >= 2 ? trendStroke(valid, mode) : absoluteStroke(latest, mode); + const fillId = `spark-fill-${mode}-${width}-${height}`; + + return ( + + + + + + + + + + + + ); +} diff --git a/web/src/components/chat/ChatFab.tsx b/web/src/components/chat/ChatFab.tsx index bd2e7ec0..2833c8a2 100644 --- a/web/src/components/chat/ChatFab.tsx +++ b/web/src/components/chat/ChatFab.tsx @@ -1,8 +1,17 @@ 'use client'; import { MessageSquare } from 'lucide-react'; -import Link from 'next/link'; -import { usePathname, useSearchParams } from 'next/navigation'; +import { usePathname, useRouter, useSearchParams } from 'next/navigation'; +import { useCallback, useEffect, useRef, useState, type CSSProperties, type PointerEvent as ReactPointerEvent } from 'react'; +import { + chatFabCornerStyle, + didDragFab, + loadChatFabCorner, + nearestChatFabCorner, + pointerPositionFromFabCenter, + saveChatFabCorner, + type ChatFabCorner, +} from '@/lib/chatFabPosition'; import { buildChatFabHref, isChatFabVisiblePath } from '@/lib/chatUrlState'; import { strings } from '@/lib/strings'; @@ -10,26 +19,108 @@ const s = strings.components.chat; /** * Floating entry to AI chat from domain-scoped report views (e.g. /dashboard?domain=…). + * Drag to any screen corner; position is remembered across sessions. */ export default function ChatFab() { + const router = useRouter(); const pathname = usePathname(); const searchParams = useSearchParams(); const domain = searchParams.get('domain') ?? searchParams.get('brand'); + const [corner, setCorner] = useState('bottom-right'); + const [dragPos, setDragPos] = useState<{ x: number; y: number } | null>(null); + const [isDragging, setIsDragging] = useState(false); + const dragStartRef = useRef<{ x: number; y: number } | null>(null); + const isDraggingRef = useRef(false); + const suppressClickRef = useRef(false); + const cleanupRef = useRef<(() => void) | null>(null); + + useEffect(() => { + setCorner(loadChatFabCorner()); + return () => cleanupRef.current?.(); + }, []); + + const handlePointerDown = useCallback((event: ReactPointerEvent) => { + event.preventDefault(); + cleanupRef.current?.(); + + const start = { x: event.clientX, y: event.clientY }; + dragStartRef.current = start; + isDraggingRef.current = false; + suppressClickRef.current = false; + + const handleMove = (ev: PointerEvent) => { + if (!dragStartRef.current) return; + + if (!isDraggingRef.current) { + if (!didDragFab(start.x, start.y, ev.clientX, ev.clientY)) return; + isDraggingRef.current = true; + setIsDragging(true); + } + + setDragPos(pointerPositionFromFabCenter(ev.clientX, ev.clientY)); + }; + + const finish = (ev: PointerEvent) => { + cleanupListeners(); + + if (!dragStartRef.current) return; + + if (isDraggingRef.current) { + const nextCorner = nearestChatFabCorner(ev.clientX, ev.clientY); + setCorner(nextCorner); + saveChatFabCorner(nextCorner); + suppressClickRef.current = true; + } + + dragStartRef.current = null; + isDraggingRef.current = false; + setIsDragging(false); + setDragPos(null); + }; + + const cleanupListeners = () => { + window.removeEventListener('pointermove', handleMove); + window.removeEventListener('pointerup', finish); + window.removeEventListener('pointercancel', finish); + cleanupRef.current = null; + }; + + cleanupRef.current = cleanupListeners; + window.addEventListener('pointermove', handleMove); + window.addEventListener('pointerup', finish); + window.addEventListener('pointercancel', finish); + }, []); + + const handleClick = useCallback(() => { + if (suppressClickRef.current) { + suppressClickRef.current = false; + return; + } + router.push(buildChatFabHref(domain)); + }, [router, domain]); + if (!isChatFabVisiblePath(pathname)) { return null; } - const href = buildChatFabHref(domain); + const style: CSSProperties = dragPos + ? { left: dragPos.x, top: dragPos.y, right: 'auto', bottom: 'auto' } + : chatFabCornerStyle(corner); return ( - - - + + ); } diff --git a/web/src/components/overview/OverviewKeywordOpportunitiesCard.tsx b/web/src/components/overview/OverviewKeywordOpportunitiesCard.tsx new file mode 100644 index 00000000..5d14230d --- /dev/null +++ b/web/src/components/overview/OverviewKeywordOpportunitiesCard.tsx @@ -0,0 +1,244 @@ +'use client'; + +import Link from 'next/link'; +import { Lightbulb, Zap, ChevronRight, Tag } from 'lucide-react'; +import type { KeywordRow } from '@/types/components'; +import type { ContentAnalyticsData, KeywordOpportunities, KeywordReportData } from '@/types/report'; +import { strings, format } from '@/lib/strings'; +import { viewIdToPathSlug } from '@/routes'; +import { Card } from '@/components'; +import { isJunkSemanticTerm } from '@/lib/semanticTextHygiene'; +import { + formatCrawlActionLabel, + formatCrawlPagesSuffix, + formatGscOpportunitySuffix, + formatGscQuickWinSuffix, + selectCrawlHighEmphasis, + selectCrawlQuickWins, + selectGscOpportunities, + selectGscQuickWins, + selectSiteTopKeywords, + selectTopTopicClusters, +} from './overviewKeywordOpportunities'; + +interface OverviewKeywordOpportunitiesCardProps { + keywords?: KeywordReportData; + keywordOpportunities?: KeywordOpportunities; + contentAnalytics?: ContentAnalyticsData; + keywordsHref: string; + hasGoogleConnected: boolean; +} + +function KeywordListRow({ keyword, suffix }: { keyword: string; suffix: string }) { + return ( +
  • + + {keyword} + + {suffix ? ( + + {suffix} + + ) : null} +
  • + ); +} + +export function OverviewKeywordOpportunitiesCard({ + keywords, + keywordOpportunities, + contentAnalytics, + keywordsHref, + hasGoogleConnected, +}: OverviewKeywordOpportunitiesCardProps) { + const vo = strings.views.overview; + const ke = strings.views.keywordsExplorer; + const sj = strings.common; + + const kwRows: KeywordRow[] = Array.isArray(keywords?.rows) ? keywords.rows : []; + const gscKeywordCount = keywords?.gsc_keyword_count ?? 0; + const hasGscEnrichment = gscKeywordCount > 0; + + const gscQuickWins = selectGscQuickWins(kwRows); + const gscOpportunities = selectGscOpportunities(kwRows); + const crawlQuickWins = selectCrawlQuickWins(keywordOpportunities?.quick_wins); + const crawlHighValue = selectCrawlHighEmphasis(keywordOpportunities?.high_value); + const topicClusters = selectTopTopicClusters(keywordOpportunities?.token_topic_clusters); + const siteTopTerms = selectSiteTopKeywords(contentAnalytics?.top_keywords_site); + + const useGscMode = hasGscEnrichment && (gscQuickWins.length > 0 || gscOpportunities.length > 0); + const showCrawlColumns = !useGscMode && (crawlQuickWins.length > 0 || crawlHighValue.length > 0); + const showSiteTerms = !useGscMode && !showCrawlColumns && siteTopTerms.length > 0; + const showCard = useGscMode || showCrawlColumns || showSiteTerms || topicClusters.length > 0; + + if (!showCard) return null; + + const quickWinsHref = `${keywordsHref}${keywordsHref.includes('?') ? '&' : '?'}tab=quickwins`; + const opportunitiesHref = `${keywordsHref}${keywordsHref.includes('?') ? '&' : '?'}tab=opportunities`; + + return ( + +
    +
    + +

    {vo.keywordOpportunities}

    +
    + {kwRows.length > 0 ? ( + + {vo.viewKeywords} + + + ) : null} +
    + +

    + {useGscMode ? vo.keywordOpportunitiesGscHint : vo.keywordOpportunitiesHint} +

    + + {!hasGscEnrichment && !hasGoogleConnected ? ( +

    + {ke.dataStatus.noGscDetail} +

    + ) : null} + + {(useGscMode || showCrawlColumns || showSiteTerms) && ( +
    + {useGscMode ? ( + <> + {gscQuickWins.length > 0 ? ( +
    +
    +

    + + {ke.overview.topQuickWins} +

    + + {ke.overview.viewAll} + +
    +
      + {gscQuickWins.map((row) => ( + + ))} +
    +
    + ) : null} + {gscOpportunities.length > 0 ? ( +
    +
    +

    + + {ke.overview.topOpportunities} +

    + + {ke.overview.viewAll} + +
    +
      + {gscOpportunities.map((row) => ( + + ))} +
    +
    + ) : null} + + ) : showSiteTerms ? ( +
    +

    {vo.siteTopTerms}

    +
      + {siteTopTerms.map((term) => ( + + ))} +
    +
    + ) : ( + <> + {crawlQuickWins.length > 0 ? ( +
    +

    {vo.quickWinsEase}

    +
      + {crawlQuickWins.map((k, idx) => ( + + ))} +
    +
    + ) : null} + {crawlHighValue.length > 0 ? ( +
    +

    {vo.highEmphasis}

    +
      + {crawlHighValue.map((k, idx) => ( + format(vo.onPagesCount, { n })) || sj.emDash + } + /> + ))} +
    +
    + ) : null} + + )} +
    + )} + + {topicClusters.length > 0 ? ( +
    +

    + + {vo.topThemes} +

    +
      + {topicClusters.map((cl, idx) => { + const label = String(cl.top_keyword ?? cl.representative ?? ''); + const related = Array.isArray(cl.keywords) + ? cl.keywords.filter((kw) => !isJunkSemanticTerm(String(kw))).slice(0, 4).join(', ') + : ''; + return ( +
    • +
      + {label} +
      + {related ? ( +
      + {related} +
      + ) : null} +
    • + ); + })} +
    +
    + ) : null} +
    + ); +} + +export function buildKeywordsHref(searchParams: string): string { + const base = `/${viewIdToPathSlug('keywords-explorer')}`; + return searchParams ? `${base}?${searchParams}` : base; +} diff --git a/web/src/components/overview/OverviewSummaryTab.tsx b/web/src/components/overview/OverviewSummaryTab.tsx index 7fa4f5f3..2484d69b 100644 --- a/web/src/components/overview/OverviewSummaryTab.tsx +++ b/web/src/components/overview/OverviewSummaryTab.tsx @@ -1,7 +1,8 @@ 'use client'; -import { useEffect, useState } from 'react'; +import { useEffect, useMemo, useState } from 'react'; import Link from 'next/link'; +import { useSearchParams } from 'next/navigation'; import { Globe, CheckCircle, @@ -13,7 +14,6 @@ import { Timer, TrendingUp, ChevronRight, - Lightbulb, Sparkles, ArrowLeftRight, FileDown, @@ -21,11 +21,16 @@ import { import type { ReportPayload } from '@/types'; import type { DataSourceId } from '@/lib/dataProvenance'; import { strings, format } from '@/lib/strings'; +import { crawledUrlCount } from '@/lib/crawlCounts'; import { googleSnapshotStatus } from '@/lib/googleSnapshot'; import { Card, AlertBanner, StatCard } from '@/components'; import { DataSourceBadgeRow } from '@/components/DataSourceBadge'; import LlmDisclosure from '@/components/LlmDisclosure'; import { OverviewTabPanel } from './OverviewTabPanel'; +import { + OverviewKeywordOpportunitiesCard, + buildKeywordsHref, +} from './OverviewKeywordOpportunitiesCard'; export interface OverviewSummaryTabProps { data: ReportPayload; @@ -37,9 +42,15 @@ export interface OverviewSummaryTabProps { export function OverviewSummaryTab({ data, exportHref, compareHref, reportCount }: OverviewSummaryTabProps) { const vo = strings.views.overview; const sj = strings.common; + const searchParams = useSearchParams(); const [healthDelta, setHealthDelta] = useState(null); + const keywordsHref = useMemo( + () => buildKeywordsHref(searchParams.toString()), + [searchParams], + ); const s = data.summary || {}; + const crawledCount = crawledUrlCount(data); const healthScore = (data.categories || []) .map((c) => Number(c?.score)) .filter((n) => Number.isFinite(n)); @@ -234,7 +245,7 @@ export function OverviewSummaryTab({ data, exportHref, compareHref, reportCount
    {vo.totalUrls}
    -
    {(s.total_urls || 0).toLocaleString()}
    +
    {crawledCount.toLocaleString()}
    {s.avg_outlinks ?? 0} {vo.avgOutlinks}
    @@ -325,48 +336,13 @@ export function OverviewSummaryTab({ data, exportHref, compareHref, reportCount ) : null} - {(data.keyword_opportunities?.quick_wins?.length ?? 0) > 0 || - (data.keyword_opportunities?.high_value?.length ?? 0) > 0 ? ( - -
    - -

    {vo.keywordOpportunities}

    -
    -

    {vo.keywordOpportunitiesHint}

    -
    -
    -

    {vo.quickWinsEase}

    -
      - {(data.keyword_opportunities?.quick_wins || []).slice(0, 8).map((k, idx) => ( -
    • - {k.keyword} - {k.recommended_action || sj.emDash} -
    • - ))} -
    -
    -
    -

    {vo.highEmphasis}

    -
      - {(data.keyword_opportunities?.high_value || []).slice(0, 8).map((k, idx) => ( -
    • - {k.keyword} - - {k.score != null ? Number(k.score).toFixed(3) : sj.emDash} - -
    • - ))} -
    -
    -
    -
    - ) : null} + {showContentIntelligence ? (
    diff --git a/web/src/components/overview/overviewKeywordOpportunities.test.ts b/web/src/components/overview/overviewKeywordOpportunities.test.ts new file mode 100644 index 00000000..1e04ceab --- /dev/null +++ b/web/src/components/overview/overviewKeywordOpportunities.test.ts @@ -0,0 +1,82 @@ +import { describe, expect, it } from 'vitest'; +import { + formatCrawlPagesSuffix, + formatGscQuickWinSuffix, + isJunkCrawlKeyword, + selectCrawlHighEmphasis, + selectCrawlQuickWins, + selectGscOpportunities, + selectGscQuickWins, + selectSiteTopKeywords, +} from './overviewKeywordOpportunities'; + +describe('overviewKeywordOpportunities', () => { + it('selects GSC quick wins by position and opportunity clicks', () => { + const rows = [ + { keyword: 'a', gsc_position: 8, opportunity_clicks: 20 }, + { keyword: 'b', gsc_position: 2, opportunity_clicks: 50 }, + { keyword: 'c', gsc_position: 12, opportunity_clicks: 3 }, + { keyword: 'd', gsc_position: 15, opportunity_clicks: 40 }, + ]; + expect(selectGscQuickWins(rows).map((r) => r.keyword)).toEqual(['d', 'a']); + }); + + it('selects expansion opportunities without GSC position', () => { + const rows = [ + { keyword: 'ranked', gsc_position: 5, sources: ['gsc'] }, + { keyword: 'new', sources: ['suggest'], traffic_potential: 100 }, + { keyword: 'other', sources: ['site'], traffic_potential: 50 }, + ]; + expect(selectGscOpportunities(rows).map((r) => r.keyword)).toEqual(['new', 'other']); + }); + + it('sorts crawl quick wins by sources_count', () => { + const items = [ + { keyword: 'low', sources_count: 2, relevance: 0.9 }, + { keyword: 'high', sources_count: 10, relevance: 0.2 }, + ]; + expect(selectCrawlQuickWins(items).map((k) => k.keyword)).toEqual(['high', 'low']); + }); + + it('sorts crawl high emphasis by sources_count and volume', () => { + const items = [ + { keyword: 'b', sources_count: 3, volume: 0.8 }, + { keyword: 'a', sources_count: 8, volume: 0.2 }, + ]; + expect(selectCrawlHighEmphasis(items).map((k) => k.keyword)).toEqual(['a', 'b']); + }); + + it('formats GSC quick win suffix with position', () => { + expect(formatGscQuickWinSuffix({ keyword: 'x', gsc_position: 9.2, opportunity_clicks: 120 })).toBe( + '+120 est. clicks · pos 9.2', + ); + }); + + it('formats crawl pages suffix', () => { + expect(formatCrawlPagesSuffix({ keyword: 'x', sources_count: 4 }, (n) => `on ${n} pages`)).toBe( + 'on 4 pages', + ); + }); + + it('flags heading-tag ngrams as junk', () => { + expect(isJunkCrawlKeyword('h2 h3')).toBe(true); + expect(isJunkCrawlKeyword('video games')).toBe(false); + }); + + it('filters junk crawl quick wins', () => { + const items = [ + { keyword: 'h2 h3', sources_count: 11 }, + { keyword: 'video games', sources_count: 5 }, + ]; + expect(selectCrawlQuickWins(items).map((k) => k.keyword)).toEqual(['video games']); + }); + + it('selects site top keywords from content analytics', () => { + const items = [ + { word: 'h3 h3', count: 20 }, + { word: 'games', count: 42 }, + { word: 'reviews', count: 18 }, + ]; + expect(selectSiteTopKeywords(items).map((k) => k.keyword)).toEqual(['games', 'reviews']); + }); +}); diff --git a/web/src/components/overview/overviewKeywordOpportunities.ts b/web/src/components/overview/overviewKeywordOpportunities.ts new file mode 100644 index 00000000..cf8f59f5 --- /dev/null +++ b/web/src/components/overview/overviewKeywordOpportunities.ts @@ -0,0 +1,107 @@ +import type { KeywordRow } from '@/types/components'; +import type { KeywordOpportunityItem, TopicCluster } from '@/types/report'; +import { isJunkSemanticTerm } from '@/lib/semanticTextHygiene'; + +/** @deprecated Use isJunkSemanticTerm */ +export const isJunkCrawlKeyword = isJunkSemanticTerm; + +function filterCrawlItems(items: KeywordOpportunityItem[] | undefined): KeywordOpportunityItem[] { + return (items ?? []).filter((item) => !isJunkSemanticTerm(item.keyword)); +} + +function filterTopicClusters(clusters: TopicCluster[] | undefined): TopicCluster[] { + return (clusters ?? []).filter((cl) => { + const label = String(cl.top_keyword ?? cl.representative ?? ''); + return label.length > 0 && !isJunkSemanticTerm(label); + }); +} + +export interface SiteTopKeyword { + keyword: string; + count: number; +} + +export function selectSiteTopKeywords( + items: Array<{ word?: string; count?: number }> | undefined, + limit = 8, +): SiteTopKeyword[] { + return (items ?? []) + .map((item) => ({ + keyword: String(item.word ?? '').trim(), + count: Number(item.count ?? 0), + })) + .filter((item) => item.keyword.length >= 3 && !isJunkSemanticTerm(item.keyword) && item.count > 0) + .sort((a, b) => b.count - a.count) + .slice(0, limit); +} + +export function selectGscQuickWins(rows: KeywordRow[], limit = 8): KeywordRow[] { + return [...rows] + .filter((r) => { + const pos = parseFloat(String(r.gsc_position ?? 0)); + return pos >= 4 && pos <= 20 && (r.opportunity_clicks || 0) > 5; + }) + .sort((a, b) => (b.opportunity_clicks || 0) - (a.opportunity_clicks || 0)) + .slice(0, limit); +} + +export function selectGscOpportunities(rows: KeywordRow[], limit = 8): KeywordRow[] { + return [...rows] + .filter((r) => !r.gsc_position && (r.sources || []).length > 0) + .sort((a, b) => (b.traffic_potential || 0) - (a.traffic_potential || 0)) + .slice(0, limit); +} + +export function selectCrawlQuickWins(items: KeywordOpportunityItem[] | undefined, limit = 8): KeywordOpportunityItem[] { + const filtered = filterCrawlItems(items); + if (!filtered.length) return []; + return [...filtered] + .sort((a, b) => (b.sources_count ?? 0) - (a.sources_count ?? 0) || (b.relevance ?? 0) - (a.relevance ?? 0)) + .slice(0, limit); +} + +export function selectCrawlHighEmphasis(items: KeywordOpportunityItem[] | undefined, limit = 8): KeywordOpportunityItem[] { + const filtered = filterCrawlItems(items); + if (!filtered.length) return []; + return [...filtered] + .sort((a, b) => (b.sources_count ?? 0) - (a.sources_count ?? 0) || (b.volume ?? 0) - (a.volume ?? 0)) + .slice(0, limit); +} + +export function selectTopTopicClusters(clusters: TopicCluster[] | undefined, limit = 5): TopicCluster[] { + const filtered = filterTopicClusters(clusters); + if (!filtered.length) return []; + return [...filtered] + .sort((a, b) => Number(b.cluster_score ?? 0) - Number(a.cluster_score ?? 0)) + .slice(0, limit); +} + +export function formatGscQuickWinSuffix(row: KeywordRow): string { + const clicks = row.opportunity_clicks || 0; + const pos = row.gsc_position != null ? Number(row.gsc_position).toFixed(1) : null; + if (pos != null) return `+${clicks.toLocaleString()} est. clicks · pos ${pos}`; + return `+${clicks.toLocaleString()} est. clicks`; +} + +export function formatGscOpportunitySuffix(row: KeywordRow): string { + const impr = row.gsc_impressions || 0; + if (impr > 0) return `${impr.toLocaleString()} impr.`; + const potential = row.traffic_potential || 0; + if (potential > 0) return `potential ${Math.round(potential).toLocaleString()}`; + return ''; +} + +export function formatCrawlActionLabel( + action: string | undefined, + labels: Record, +): string { + if (!action) return ''; + return labels[action] ?? action; +} + +export function formatCrawlPagesSuffix(item: KeywordOpportunityItem, onPagesLabel: (n: number) => string): string { + const count = item.sources_count; + if (count != null && count > 0) return onPagesLabel(count); + if (item.volume != null && item.volume > 0) return `${Math.round(item.volume * 100)}% site freq.`; + return ''; +} diff --git a/web/src/components/pipeline/PipelineLogViewer.tsx b/web/src/components/pipeline/PipelineLogViewer.tsx index afa0863b..0220480e 100644 --- a/web/src/components/pipeline/PipelineLogViewer.tsx +++ b/web/src/components/pipeline/PipelineLogViewer.tsx @@ -90,7 +90,6 @@ function ActivityLine({ line, query }: { line: PipelineLogLine; query: string }) {highlightText(url, query)}
    - {line.progress ? : null}
    ); } diff --git a/web/src/components/pipeline/PipelineProgressHeader.tsx b/web/src/components/pipeline/PipelineProgressHeader.tsx index bb2931f9..ab05ef86 100644 --- a/web/src/components/pipeline/PipelineProgressHeader.tsx +++ b/web/src/components/pipeline/PipelineProgressHeader.tsx @@ -8,6 +8,8 @@ import { PHASE_LABELS, PIPELINE_STEPPER_PHASES, computeEta, + crawlProgressCountLabel, + crawlProgressPercent, formatDurationMs, parsePipelineProgressEvents, resolveActiveProgress, @@ -52,14 +54,23 @@ export default function PipelineProgressHeader({ 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; + const countLabel = + latest.phase === 'crawl' && latest.current != null && latest.current > 0 + ? crawlProgressCountLabel(latest) + : latest.current != null && latest.total != null && latest.total > 0 + ? `${latest.current}/${latest.total}${ + latest.current >= latest.total ? ' (100%)' : '' + }` + : null; + const barPct = + isActive && latest.current != null && latest.current > 0 + ? latest.phase === 'crawl' + ? crawlProgressPercent(latest) + : latest.total != null && latest.total > 0 + ? Math.min(100, Math.round(((latest.current ?? 0) / latest.total) * 100)) + : null + : null; + const hasBar = isActive && barPct != null; return (
    - {hasBar ? ( - - {latest.current}/{latest.total} - {barPct != null ? ` (${barPct}%)` : ''} - - ) : null} + {countLabel ? {countLabel} : null} {eta.ratePerSec != null && latest.phase === 'crawl' ? ( {eta.ratePerSec.toFixed(1)} pg/s ) : null} diff --git a/web/src/components/portfolio/PortfolioPropertyCard.tsx b/web/src/components/portfolio/PortfolioPropertyCard.tsx new file mode 100644 index 00000000..a7ee4f3e --- /dev/null +++ b/web/src/components/portfolio/PortfolioPropertyCard.tsx @@ -0,0 +1,505 @@ +import { + AlertTriangle, + ArrowRight, + Building2, + ExternalLink, + Gauge, + Globe, + Timer, + Trash2, +} from 'lucide-react'; +import { Card } from '@/components'; +import Sparkline, { type SparklineMode } from '@/components/Sparkline'; +import { DataSourceBadgeRow } from '@/components/DataSourceBadge'; +import { PRIORITY_CONFIG } from '@/lib/issuePriority'; +import { format, strings } from '@/lib/strings'; +import { + formatPortfolioCrawlSummary, + hasPortfolioCrawlConfig, +} from '@/lib/portfolioCrawlConfig'; +import type { PortfolioAuditHistoryPoint } from '@/lib/portfolioAuditHistory'; +import type { PortfolioCrawlHistoryPoint } from '@/types/api'; +import type { PortfolioCategorySnapshot, PortfolioGroup } from '@/types'; +import { + derivePortfolioCardTrends, + healthScoreClass, + shortCategoryLabel, +} from '@/components/portfolio/portfolioCardUtils'; + +export interface PortfolioPropertyCardProps { + group: PortfolioGroup; + cardKey: string; + auditHistory: PortfolioAuditHistoryPoint[]; + crawlHistory: PortfolioCrawlHistoryPoint[]; + confirmOpen: boolean; + isDeleting: boolean; + isOpening: boolean; + onOpen: () => void; + onDeleteToggle: () => void; + onDeleteCancel: () => void; + onDeleteConfirm: () => void; +} + +function PortfolioTrendCell({ + label, + values, + displayValue, + mode, +}: { + label: string; + values: number[]; + displayValue: string; + mode: SparklineMode; +}) { + return ( +
    +

    {label}

    +
    + + + {displayValue} + +
    +
    + ); +} + +function PortfolioCategoryChip({ cat, issueLabel }: { cat: PortfolioCategorySnapshot; issueLabel: string }) { + return ( +
    +

    + {shortCategoryLabel(cat)} +

    +

    {cat.score}

    + {cat.issueCount > 0 ? ( +

    + {format(issueLabel, { count: cat.issueCount })} +

    + ) : null} +
    + ); +} + +function PortfolioSignalPill({ label, value }: { label: string; value: number }) { + if (value <= 0) return null; + return ( + + {label} + {value.toLocaleString()} + + ); +} + +export default function PortfolioPropertyCard({ + group, + cardKey, + auditHistory, + crawlHistory, + confirmOpen, + isDeleting, + isOpening, + onOpen, + onDeleteToggle, + onDeleteCancel, + onDeleteConfirm, +}: PortfolioPropertyCardProps) { + const vh = strings.views.home; + const sj = strings.common; + const disabled = isOpening || isDeleting; + const trends = derivePortfolioCardTrends(group, auditHistory, crawlHistory, { + missingTitlesLabel: vh.missingTitlesLabel, + missingMetaLabel: vh.missingMetaLabel, + thinPagesLabel: vh.thinPagesLabel, + h1IssuesLabel: vh.h1IssuesLabel, + }); + const crawlConfigSegments = formatPortfolioCrawlSummary(group.crawlConfig); + const showCrawlConfig = hasPortfolioCrawlConfig(group.crawlConfig); + const showDataSources = !group.crawlOnly && (group.dataSources?.length ?? 0) > 0; + + return ( +
    + +
    +
    + + +
    + + {confirmOpen ? ( +
    +

    + {vh.deleteConfirmTitle} +

    +

    + {group.crawlOnly + ? format(vh.deleteConfirmCrawlOnly, { + name: group.domainName, + count: group.urlCount.toLocaleString(), + }) + : format(vh.deleteConfirmBody, { name: group.domainName })} +

    +
    + + +
    +
    + ) : null} + + + + {showCrawlConfig || showDataSources ? ( +
    + {showCrawlConfig ? ( +
    +

    + {vh.crawlConfigLabel} +

    +

    {crawlConfigSegments.join(' · ')}

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

    + {strings.views.overview.dataSourcesLabel} +

    + +
    + ) : null} +
    + ) : null} + + {group.crawlOnly ? ( +
    +
    +
    +
    +

    {vh.urlCountLabel}

    +

    + {group.urlCount.toLocaleString()} +

    +
    +
    +

    {vh.titleCoverageLabel}

    +

    + {group.titleCoverage != null ? `${group.titleCoverage}%` : sj.emDash} +

    +
    +
    +

    + {vh.lastCrawlLabel}: + {group.lastCrawl || sj.emDash} +

    +
    +
    +
    +
    +

    {vh.avgWordCountLabel}

    +

    + {group.avgWordCount != null ? group.avgWordCount.toLocaleString() : sj.emDash} +

    +
    +
    +

    {vh.thinPagesLabel}

    +

    + {group.thinPages != null ? group.thinPages.toLocaleString() : sj.emDash} +

    +
    +
    +

    + {vh.crawlOnlyHint} +

    +
    +
    + ) : ( +
    + {group.categorySnapshots.length > 0 ? ( +
    +

    {vh.categoryScoresLabel}

    +
    + {group.categorySnapshots.map((cat) => ( + + ))} +
    +
    + ) : null} + +
    +
    +

    {vh.urlCountLabel}

    +

    {group.urlCount.toLocaleString()}

    + {group.medianWordCount != null ? ( +

    + {vh.medianWordsLabel}: {group.medianWordCount.toLocaleString()} +

    + ) : null} + {group.medianResponseMs != null ? ( +

    + {format(vh.responseTimeValue, { ms: group.medianResponseMs.toLocaleString() })} +

    + ) : null} +
    +
    +

    + + {vh.totalIssuesLabel} +

    +

    {group.totalIssues.toLocaleString()}

    +
    + {(['Critical', 'High', 'Medium', 'Low'] as const).map((priority) => { + const key = priority.toLowerCase() as keyof typeof group.issueCounts; + const count = group.issueCounts[key]; + if (count <= 0) return null; + const cfg = PRIORITY_CONFIG[priority]; + return ( + + {priority[0]} + {count} + + ); + })} +
    +
    +
    +

    + + Lighthouse +

    +
    +

    + {vh.perfScoreLabel} + {group.perfScore ?? sj.emDash} +

    +

    + {vh.seoScoreLabel} + {group.seoScore ?? sj.emDash} +

    +
    + {trends.urgentCount > 0 ? ( +

    + {vh.trendUrgentLabel}: {trends.urgentCount} +

    + ) : null} +
    +
    + + {(trends.seoSignalItems.length > 0 || group.securityFindings > 0 || group.duplicateClusters > 0) ? ( +
    +

    {vh.seoSignalsLabel}

    +
    + {trends.seoSignalItems.map((row) => ( + + ))} + + +
    +
    + ) : null} + +
    +
    +

    {vh.lastCrawlLabel}

    +

    + {group.lastCrawl || sj.emDash} +

    +
    +
    +

    {vh.lastAuditLabel}

    +

    + {group.lastAudit || sj.emDash} +

    +
    + {group.crawlDurationS != null ? ( +
    + +
    +

    {vh.crawlDurationLabel}

    +

    + {format(vh.crawlDurationValue, { seconds: group.crawlDurationS.toLocaleString() })} +

    +
    +
    + ) : null} +
    +
    + )} + + {group.crawlOnly ? ( +
    +

    {vh.crawlTrendsLabel}

    + {trends.hasCrawlTrendLines ? ( +
    + + + +
    + ) : ( +

    {vh.crawlTrendsNeedHistory}

    + )} +
    + ) : ( +
    +

    {vh.trendsLabel}

    + {trends.hasAuditTrendLines ? ( +
    + + + + +
    + ) : ( +

    {vh.trendsNeedHistory}

    + )} +
    + )} + + +
    +
    +
    + ); +} diff --git a/web/src/components/portfolio/portfolioCardUtils.test.ts b/web/src/components/portfolio/portfolioCardUtils.test.ts new file mode 100644 index 00000000..c4f7c6ad --- /dev/null +++ b/web/src/components/portfolio/portfolioCardUtils.test.ts @@ -0,0 +1,67 @@ +import { describe, expect, it } from 'vitest'; +import { derivePortfolioCardTrends, portfolioCardKey } from './portfolioCardUtils'; +import type { PortfolioGroup } from '@/types'; + +const baseGroup: PortfolioGroup = { + domainName: 'codefrydev.in', + crawlUrl: 'https://codefrydev.in', + urlCount: 30, + healthScore: 70, + statusCounts: { s2xx: 30, s3xx: 0, s4xx: 0, s5xx: 0, other: 0 }, + lastCrawl: '', + lastAudit: '', + totalIssues: 101, + issueCounts: { critical: 2, high: 10, medium: 50, low: 39 }, + successRate: null, + titleCoverage: null, + avgWordCount: null, + thinPages: null, + technicalSeoScore: 50, + perfScore: 1, + seoScore: 80, + crawlDurationS: null, + categorySnapshots: [], + seoSignals: { missingTitles: 3, missingMetaDesc: 1, thinContent: 0, h1Issues: 0 }, + securityFindings: 0, + duplicateClusters: 0, + medianWordCount: null, + medianResponseMs: null, + reportId: 1, + generatedAtMs: 1000, + domainParam: 'codefrydev.in', + crawlConfig: { + render_mode: 'auto', + discovery_mode: 'spider', + pages_crawled: 30, + max_pages_configured: 500, + }, + dataSources: ['crawl', 'lighthouse'], +}; + +describe('portfolioCardKey', () => { + it('builds stable keys for report cards', () => { + expect(portfolioCardKey(baseGroup)).toBe('codefrydev.in-report-1-nc-1000'); + }); +}); + +describe('derivePortfolioCardTrends', () => { + it('computes health delta and urgent count', () => { + const trends = derivePortfolioCardTrends( + baseGroup, + [ + { healthScore: 69, totalIssues: 90, urgentIssues: 8, perfScore: 2, seoScore: 75, technicalSeoScore: 48 }, + { healthScore: 70, totalIssues: 101, urgentIssues: 12, perfScore: 1, seoScore: 80, technicalSeoScore: 50 }, + ], + [], + { + missingTitlesLabel: 'Missing titles', + missingMetaLabel: 'Missing meta', + thinPagesLabel: 'Thin pages', + h1IssuesLabel: 'H1 issues', + }, + ); + expect(trends.healthDelta).toBe(1); + expect(trends.urgentCount).toBe(12); + expect(trends.seoSignalItems).toHaveLength(2); + }); +}); diff --git a/web/src/components/portfolio/portfolioCardUtils.ts b/web/src/components/portfolio/portfolioCardUtils.ts new file mode 100644 index 00000000..b9e74a8d --- /dev/null +++ b/web/src/components/portfolio/portfolioCardUtils.ts @@ -0,0 +1,92 @@ +import { historySeries } from '@/lib/portfolioAuditHistory'; +import type { PortfolioAuditHistoryPoint } from '@/lib/portfolioAuditHistory'; +import { crawlHistorySeries } from '@/lib/portfolioCrawlHistory'; +import type { PortfolioCrawlHistoryPoint } from '@/lib/portfolioCrawlHistory'; +import type { PortfolioCategorySnapshot, PortfolioGroup } from '@/types'; + +export const CATEGORY_SHORT_LABELS: Record = { + technical_seo: 'Tech SEO', + performance: 'Performance', + core_web_vitals: 'CWV', + link_health: 'Links', + security: 'Security', + html_accessibility: 'A11y', + mobile: 'Mobile', + intelligence: 'Content', +}; + +export function shortCategoryLabel(cat: PortfolioCategorySnapshot): string { + return CATEGORY_SHORT_LABELS[cat.id] || cat.name.split(' ').slice(0, 2).join(' '); +} + +export function healthScoreClass(score: number): string { + if (score >= 80) return 'text-emerald-700 dark:text-emerald-400'; + if (score >= 60) return 'text-amber-700 dark:text-amber-400'; + return 'text-rose-700 dark:text-rose-400'; +} + +export function portfolioCardKey(group: PortfolioGroup): string { + return `${group.domainParam}-${group.crawlOnly ? 'crawl' : 'report'}-${group.reportId ?? 'nr'}-${group.crawlRunId ?? 'nc'}-${group.generatedAtMs}`; +} + +export interface PortfolioCardTrends { + healthTrend: number[]; + perfTrend: number[]; + seoTrend: number[]; + issuesTrend: number[]; + pagesTrend: number[]; + titleTrend: number[]; + wordsTrend: number[]; + hasAuditTrendLines: boolean; + hasCrawlTrendLines: boolean; + healthDelta: number | null; + urgentCount: number; + seoSignalItems: Array<{ label: string; value: number }>; +} + +export function derivePortfolioCardTrends( + group: PortfolioGroup, + auditHistory: PortfolioAuditHistoryPoint[], + crawlHistory: PortfolioCrawlHistoryPoint[], + signalLabels: { + missingTitlesLabel: string; + missingMetaLabel: string; + thinPagesLabel: string; + h1IssuesLabel: string; + }, +): PortfolioCardTrends { + const healthTrend = historySeries(auditHistory, 'healthScore'); + const perfTrend = historySeries(auditHistory, 'perfScore'); + const seoTrend = historySeries(auditHistory, 'seoScore'); + const issuesTrend = historySeries(auditHistory, 'totalIssues'); + const pagesTrend = crawlHistorySeries(crawlHistory, 'pagesDiscovered'); + const titleTrend = crawlHistorySeries(crawlHistory, 'titleCoverage'); + const wordsTrend = crawlHistorySeries(crawlHistory, 'avgWordCount'); + const priorHealth = + auditHistory.length >= 2 ? auditHistory[auditHistory.length - 2].healthScore : null; + const healthDelta = + priorHealth != null && Number.isFinite(priorHealth) ? group.healthScore - priorHealth : null; + const seoSignalItems = group.seoSignals + ? [ + { label: signalLabels.missingTitlesLabel, value: group.seoSignals.missingTitles }, + { label: signalLabels.missingMetaLabel, value: group.seoSignals.missingMetaDesc }, + { label: signalLabels.thinPagesLabel, value: group.seoSignals.thinContent }, + { label: signalLabels.h1IssuesLabel, value: group.seoSignals.h1Issues }, + ].filter((row) => row.value > 0) + : []; + + return { + healthTrend, + perfTrend, + seoTrend, + issuesTrend, + pagesTrend, + titleTrend, + wordsTrend, + hasAuditTrendLines: auditHistory.length >= 1, + hasCrawlTrendLines: crawlHistory.length >= 1, + healthDelta, + urgentCount: group.issueCounts.critical + group.issueCounts.high, + seoSignalItems, + }; +} diff --git a/web/src/lib/appNav.ts b/web/src/lib/appNav.ts index 9c093bf5..5d73a295 100644 --- a/web/src/lib/appNav.ts +++ b/web/src/lib/appNav.ts @@ -8,6 +8,7 @@ import { Cpu, FileDown, FileText, + TextSearch, FolderTree, Gauge, Home as HomeIcon, @@ -53,6 +54,7 @@ const VIEW_NAV: { id: ViewId; icon: LucideIcon }[] = [ { id: 'security', icon: ShieldAlert }, { id: 'javascript-errors', icon: Bug }, { id: 'content-analytics', icon: BarChart2 }, + { id: 'text-content-analysis', icon: TextSearch }, { id: 'tech-stack', icon: Cpu }, { id: 'network', icon: Share2 }, { id: 'gallery', icon: Images }, diff --git a/web/src/lib/chatFabPosition.test.ts b/web/src/lib/chatFabPosition.test.ts new file mode 100644 index 00000000..61e79f41 --- /dev/null +++ b/web/src/lib/chatFabPosition.test.ts @@ -0,0 +1,33 @@ +import { describe, expect, it } from 'vitest'; +import { + CHAT_FAB_INSET_PX, + CHAT_FAB_SIZE_PX, + didDragFab, + isChatFabCorner, + nearestChatFabCorner, +} from './chatFabPosition'; + +describe('chatFabPosition', () => { + it('validates stored corners', () => { + expect(isChatFabCorner('bottom-right')).toBe(true); + expect(isChatFabCorner('top-left')).toBe(true); + expect(isChatFabCorner('center')).toBe(false); + }); + + it('picks nearest corner from pointer position', () => { + expect(nearestChatFabCorner(100, 100, 800, 600)).toBe('top-left'); + expect(nearestChatFabCorner(700, 100, 800, 600)).toBe('top-right'); + expect(nearestChatFabCorner(100, 500, 800, 600)).toBe('bottom-left'); + expect(nearestChatFabCorner(700, 500, 800, 600)).toBe('bottom-right'); + }); + + it('detects drag threshold', () => { + expect(didDragFab(0, 0, 2, 2)).toBe(false); + expect(didDragFab(0, 0, 10, 0)).toBe(true); + }); + + it('uses consistent fab dimensions', () => { + expect(CHAT_FAB_SIZE_PX).toBe(56); + expect(CHAT_FAB_INSET_PX).toBe(24); + }); +}); diff --git a/web/src/lib/chatFabPosition.ts b/web/src/lib/chatFabPosition.ts new file mode 100644 index 00000000..7298c733 --- /dev/null +++ b/web/src/lib/chatFabPosition.ts @@ -0,0 +1,96 @@ +import type { CSSProperties } from 'react'; + +export type ChatFabCorner = 'bottom-right' | 'bottom-left' | 'top-right' | 'top-left'; + +const STORAGE_KEY = 'wp-chat-fab-position:v1'; +export const CHAT_FAB_SIZE_PX = 56; +export const CHAT_FAB_INSET_PX = 24; +const DRAG_THRESHOLD_PX = 4; + +const VALID_CORNERS: ChatFabCorner[] = [ + 'bottom-right', + 'bottom-left', + 'top-right', + 'top-left', +]; + +export function isChatFabCorner(value: unknown): value is ChatFabCorner { + return typeof value === 'string' && (VALID_CORNERS as string[]).includes(value); +} + +export function loadChatFabCorner(defaultCorner: ChatFabCorner = 'bottom-right'): ChatFabCorner { + if (typeof window === 'undefined') return defaultCorner; + try { + const raw = localStorage.getItem(STORAGE_KEY); + if (!raw) return defaultCorner; + const parsed = JSON.parse(raw) as { corner?: unknown }; + return isChatFabCorner(parsed.corner) ? parsed.corner : defaultCorner; + } catch { + return defaultCorner; + } +} + +export function saveChatFabCorner(corner: ChatFabCorner): void { + if (typeof window === 'undefined') return; + try { + localStorage.setItem(STORAGE_KEY, JSON.stringify({ corner })); + } catch { + /* quota / private mode */ + } +} + +export function chatFabCornerStyle(corner: ChatFabCorner): CSSProperties { + switch (corner) { + case 'bottom-left': + return { bottom: CHAT_FAB_INSET_PX, left: CHAT_FAB_INSET_PX }; + case 'top-right': + return { top: CHAT_FAB_INSET_PX, right: CHAT_FAB_INSET_PX }; + case 'top-left': + return { top: CHAT_FAB_INSET_PX, left: CHAT_FAB_INSET_PX }; + default: + return { bottom: CHAT_FAB_INSET_PX, right: CHAT_FAB_INSET_PX }; + } +} + +export function clampChatFabPosition(x: number, y: number): { x: number; y: number } { + const maxX = Math.max( + CHAT_FAB_INSET_PX, + window.innerWidth - CHAT_FAB_SIZE_PX - CHAT_FAB_INSET_PX, + ); + const maxY = Math.max( + CHAT_FAB_INSET_PX, + window.innerHeight - CHAT_FAB_SIZE_PX - CHAT_FAB_INSET_PX, + ); + return { + x: Math.min(Math.max(x, CHAT_FAB_INSET_PX), maxX), + y: Math.min(Math.max(y, CHAT_FAB_INSET_PX), maxY), + }; +} + +export function nearestChatFabCorner( + centerX: number, + centerY: number, + viewportWidth = window.innerWidth, + viewportHeight = window.innerHeight, +): ChatFabCorner { + const isLeft = centerX < viewportWidth / 2; + const isTop = centerY < viewportHeight / 2; + if (isTop && isLeft) return 'top-left'; + if (isTop && !isLeft) return 'top-right'; + if (!isTop && isLeft) return 'bottom-left'; + return 'bottom-right'; +} + +export function pointerPositionFromFabCenter( + centerX: number, + centerY: number, +): { x: number; y: number } { + return clampChatFabPosition( + centerX - CHAT_FAB_SIZE_PX / 2, + centerY - CHAT_FAB_SIZE_PX / 2, + ); +} + +export function didDragFab(startX: number, startY: number, x: number, y: number): boolean { + return Math.hypot(x - startX, y - startY) >= DRAG_THRESHOLD_PX; +} diff --git a/web/src/lib/crawlCounts.test.ts b/web/src/lib/crawlCounts.test.ts new file mode 100644 index 00000000..69ad77bf --- /dev/null +++ b/web/src/lib/crawlCounts.test.ts @@ -0,0 +1,38 @@ +import { describe, expect, it } from 'vitest'; +import { crawlLimitConfigured, crawledUrlCount } from '@/lib/crawlCounts'; +import type { ReportPayload } from '@/types/report'; + +describe('crawledUrlCount', () => { + it('prefers pages_crawled over summary and links', () => { + const data = { + report_meta: { crawl_scope: { pages_crawled: 1138, max_pages_configured: 1500 } }, + summary: { total_urls: 1500 }, + links: Array.from({ length: 1500 }, (_, i) => ({ url: `https://ex.com/${i}` })), + } as unknown as ReportPayload; + expect(crawledUrlCount(data)).toBe(1138); + }); + + it('falls back to summary.total_urls', () => { + const data = { + summary: { total_urls: 42 }, + links: [{ url: 'https://ex.com' }], + } as unknown as ReportPayload; + expect(crawledUrlCount(data)).toBe(42); + }); + + it('never returns max_pages_configured', () => { + const data = { + report_meta: { crawl_scope: { max_pages_configured: 1500 } }, + } as unknown as ReportPayload; + expect(crawledUrlCount(data)).toBe(0); + }); +}); + +describe('crawlLimitConfigured', () => { + it('returns configured limit when present', () => { + const data = { + report_meta: { crawl_scope: { max_pages_configured: 1500, pages_crawled: 1138 } }, + } as unknown as ReportPayload; + expect(crawlLimitConfigured(data)).toBe(1500); + }); +}); diff --git a/web/src/lib/crawlCounts.ts b/web/src/lib/crawlCounts.ts new file mode 100644 index 00000000..5e6deeab --- /dev/null +++ b/web/src/lib/crawlCounts.ts @@ -0,0 +1,24 @@ +import type { ReportPayload } from '@/types/report'; + +/** Authoritative crawled URL count from report payload. Never uses max_pages_configured. */ +export function crawledUrlCount(data: ReportPayload | null | undefined): number { + if (!data) return 0; + const scope = data.report_meta?.crawl_scope?.pages_crawled; + if (scope != null && Number.isFinite(Number(scope)) && Number(scope) > 0) { + return Number(scope); + } + const summary = data.summary?.total_urls; + if (summary != null && Number.isFinite(Number(summary)) && Number(summary) > 0) { + return Number(summary); + } + const links = data.links?.length ?? 0; + return links > 0 ? links : 0; +} + +export function crawlLimitConfigured(data: ReportPayload | null | undefined): number | null { + const max = data?.report_meta?.crawl_scope?.max_pages_configured; + if (max == null || !Number.isFinite(Number(max)) || Number(max) <= 0) { + return null; + } + return Number(max); +} diff --git a/web/src/lib/formatPipelineLog.test.ts b/web/src/lib/formatPipelineLog.test.ts index b0b5ee61..4d5ca7e6 100644 --- a/web/src/lib/formatPipelineLog.test.ts +++ b/web/src/lib/formatPipelineLog.test.ts @@ -1,6 +1,8 @@ import { describe, expect, it } from 'vitest'; import { computeEta, + crawlProgressCountLabel, + crawlProgressPercent, extractLatestProgress, formatDurationMs, parsePipelineLog, @@ -76,3 +78,39 @@ describe('formatDurationMs', () => { expect(formatDurationMs(125000)).toBe('2m 5s'); }); }); + +describe('crawlProgressPercent', () => { + it('shows 100% when crawl finished naturally', () => { + expect(crawlProgressPercent({ current: 1138, total: 1138, limit: 1500 })).toBe(100); + }); + + it('uses limit for in-flight estimate', () => { + expect(crawlProgressPercent({ current: 500, total: 1500, limit: 1500 })).toBe(33); + }); +}); + +describe('crawlProgressCountLabel', () => { + it('labels natural completion', () => { + expect(crawlProgressCountLabel({ current: 1138, total: 1138, limit: 1500 })).toBe( + '1138/1138 (100%)', + ); + }); + + it('labels in-flight crawl with limit', () => { + expect(crawlProgressCountLabel({ current: 500, total: 1500, limit: 1500 })).toBe( + '500 crawled (max 1500)', + ); + }); +}); + +describe('parsePipelineLog tqdm suppression', () => { + it('hides tqdm when structured crawl progress exists', () => { + const raw = [ + '@progress {"phase":"crawl","step":"fetch","current":5,"total":1500,"limit":1500,"ts":1}', + 'Pages: 100%|██████████| 1500/1500 [00:10<00:00, 150.00it/s]', + ].join('\n'); + const lines = parsePipelineLog(raw); + expect(lines.some((l) => l.kind === 'progress' && l.text.includes('Pages:'))).toBe(false); + expect(lines.some((l) => l.kind === 'noise' && l.text.includes('Pages:'))).toBe(true); + }); +}); diff --git a/web/src/lib/formatPipelineLog.ts b/web/src/lib/formatPipelineLog.ts index 5f004725..bb2a1c83 100644 --- a/web/src/lib/formatPipelineLog.ts +++ b/web/src/lib/formatPipelineLog.ts @@ -28,12 +28,48 @@ export interface PipelineProgressEvent { ts: number; current?: number; total?: number; + limit?: number; url?: string; message?: string; elapsed_ms?: number; avg_ms?: number; } +export function crawlProgressPercent( + evt: Pick, +): number | null { + const current = evt.current ?? 0; + const total = evt.total ?? 0; + if (total > 0 && current >= total) { + return 100; + } + const ceiling = evt.limit ?? total; + if (ceiling > 0 && current > 0) { + return Math.min(99, Math.round((current / ceiling) * 100)); + } + return null; +} + +export function crawlProgressCountLabel( + evt: Pick, +): string | null { + const current = evt.current ?? 0; + if (current <= 0) return null; + const total = evt.total ?? 0; + const limit = evt.limit; + if (total > 0 && current >= total) { + return `${current}/${total} (100%)`; + } + if (limit != null && limit > 0 && current < limit) { + return `${current} crawled (max ${limit})`; + } + if (total > 0) { + const pct = Math.min(100, Math.round((current / total) * 100)); + return `${current}/${total} (${pct}%)`; + } + return `${current} crawled`; +} + export interface PipelineEtaResult { remainingMs: number | null; ratePerSec: number | null; @@ -296,24 +332,23 @@ function progressEventToLogLine(evt: PipelineProgressEvent, id: number): Pipelin }; } - const hasCounts = - evt.current != null && evt.total != null && evt.total > 0; - const progress = hasCounts - ? { - percent: Math.min(100, Math.round(((evt.current ?? 0) / (evt.total ?? 1)) * 100)), - current: evt.current ?? 0, - total: evt.total ?? 0, - } - : undefined; + const hasCounts = evt.current != null && evt.current > 0; + const percent = hasCounts ? crawlProgressPercent(evt) : null; + const progress = + hasCounts && percent != null + ? { + percent, + current: evt.current ?? 0, + total: evt.total ?? evt.limit ?? evt.current ?? 0, + } + : undefined; if (evt.step === 'fetch' && evt.url) { - const countSuffix = hasCounts ? ` (${evt.current}/${evt.total})` : ''; return { id, - text: `→ ${evt.url}${countSuffix}`, + text: `→ ${evt.url}`, kind: 'activity', phase, - progress, progressEvent: evt, }; } @@ -354,7 +389,9 @@ export function computeEta( } const elapsedMs = latest.elapsed_ms ?? null; let percent: number | null = null; - if ( + if (latest.phase === 'crawl' && latest.current != null && latest.current > 0) { + percent = crawlProgressPercent(latest); + } else if ( latest.current != null && latest.total != null && latest.total > 0 @@ -389,11 +426,21 @@ export function computeEta( if ( latest.current != null && latest.total != null && - latest.total > latest.current && + latest.current >= latest.total + ) { + remainingMs = 0; + } else if ( + latest.current != null && ratePerSec != null && ratePerSec > 0 ) { - remainingMs = Math.round(((latest.total - latest.current) / ratePerSec) * 1000); + const etaTotal = + latest.phase === 'crawl' && latest.limit != null && latest.limit > (latest.current ?? 0) + ? latest.limit + : latest.total; + if (etaTotal != null && etaTotal > (latest.current ?? 0)) { + remainingMs = Math.round(((etaTotal - (latest.current ?? 0)) / ratePerSec) * 1000); + } } return { remainingMs, ratePerSec, elapsedMs, percent }; @@ -408,9 +455,16 @@ export function formatDurationMs(ms: number | null): string { return rem ? `${min}m ${rem}s` : `${min}m`; } +function rawHasStructuredCrawlProgress(raw: string): boolean { + return parsePipelineProgressEvents(raw).some( + (e) => e.phase === 'crawl' && e.step === 'fetch' && e.current != null && e.current > 0, + ); +} + /** Split raw job log into display lines (collapse tqdm spam, tag phases, fold shutdown noise). */ export function parsePipelineLog(raw: string): PipelineLogLine[] { const cleaned = stripAnsi(raw); + const suppressTqdm = rawHasStructuredCrawlProgress(cleaned); const physicalLines = cleaned.split('\n'); const parsed: PipelineLogLine[] = []; let id = 0; @@ -427,7 +481,10 @@ export function parsePipelineLog(raw: string): PipelineLogLine[] { continue; } - const kind = classifyLine(line); + let kind = classifyLine(line); + if (suppressTqdm && kind === 'progress') { + kind = 'noise'; + } const entry: PipelineLogLine = { id: id++, text: line, kind, phase: 'other' }; if (kind === 'progress') { entry.progress = parseTqdmProgress(line); diff --git a/web/src/lib/homePortfolio.test.ts b/web/src/lib/homePortfolio.test.ts index 0ab4fb11..f1d5916b 100644 --- a/web/src/lib/homePortfolio.test.ts +++ b/web/src/lib/homePortfolio.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest'; -import { computeCrawlOnlyGroups } from './homePortfolio'; -import type { CrawlRunSummary, PortfolioGroup } from '@/types/report'; +import { computeCrawlOnlyGroups, computeDomainGroups } from './homePortfolio'; +import type { CrawlRunSummary, PortfolioGroup, ReportListRow, ReportPayload } from '@/types/report'; describe('computeCrawlOnlyGroups', () => { it('skips crawl runs that already have a report card', () => { @@ -12,6 +12,23 @@ describe('computeCrawlOnlyGroups', () => { healthScore: 80, statusCounts: { s2xx: 10, s3xx: 0, s4xx: 0, s5xx: 0, other: 0 }, lastCrawl: '', + lastAudit: '', + totalIssues: 0, + issueCounts: { critical: 0, high: 0, medium: 0, low: 0 }, + successRate: null, + titleCoverage: null, + avgWordCount: null, + thinPages: null, + technicalSeoScore: null, + perfScore: null, + seoScore: null, + crawlDurationS: null, + categorySnapshots: [], + seoSignals: null, + securityFindings: 0, + duplicateClusters: 0, + medianWordCount: null, + medianResponseMs: null, reportId: 1, crawlRunId: 1, generatedAtMs: 1000, @@ -29,9 +46,84 @@ describe('computeCrawlOnlyGroups', () => { s5xx: 0, other: 0, created_at: '2026-01-02', + with_title: 10, + avg_word_count: 420, + thin_pages: 1, }, ]; const crawlOnly = computeCrawlOnlyGroups(crawlSummaries, reportGroups, 'Unknown', '—'); expect(crawlOnly).toHaveLength(0); }); + + it('populates crawlConfig from crawl run mode fields', () => { + const crawlSummaries: CrawlRunSummary[] = [ + { + crawl_run_id: 2, + start_url: 'https://example.com', + url_count: 15, + s2xx: 15, + s3xx: 0, + s4xx: 0, + s5xx: 0, + other: 0, + created_at: '2026-01-02', + with_title: 10, + avg_word_count: 420, + thin_pages: 1, + render_mode: 'auto', + discovery_mode: 'sitemap', + }, + ]; + const crawlOnly = computeCrawlOnlyGroups(crawlSummaries, [], 'Unknown', '—'); + expect(crawlOnly).toHaveLength(1); + expect(crawlOnly[0]?.crawlConfig).toEqual({ + pages_crawled: 15, + render_mode: 'auto', + discovery_mode: 'sitemap', + }); + }); +}); + +describe('computeDomainGroups', () => { + it('populates crawlConfig and dataSources from report payload', async () => { + const reportList: ReportListRow[] = [ + { + id: 1, + generated_at: '2026-01-03T00:00:00Z', + site_name: 'Example', + canonical_domain: 'example.com', + }, + ]; + const payload: ReportPayload = { + crawl_run_id: 5, + report_meta: { + data_sources: ['crawl', 'lighthouse', 'search_console'], + crawl_scope: { + pages_crawled: 100, + max_pages_configured: 500, + render_mode: 'static', + crawl_limited: false, + }, + }, + categories: [{ id: 'technical_seo', name: 'Tech SEO', score: 80, issues: [] }], + summary: { total_urls: 100 }, + }; + const groups = await computeDomainGroups( + reportList, + new Map([[5, 'https://example.com']]), + new Map([[5, '2026-01-02T00:00:00Z']]), + 'Unknown', + '—', + async () => payload, + new Map([[5, { discovery_mode: 'spider' }]]), + ); + expect(groups).toHaveLength(1); + expect(groups[0]?.crawlConfig).toMatchObject({ + pages_crawled: 100, + max_pages_configured: 500, + render_mode: 'static', + discovery_mode: 'spider', + }); + expect(groups[0]?.dataSources).toEqual(['crawl', 'lighthouse', 'search_console']); + }); }); diff --git a/web/src/lib/homePortfolio.ts b/web/src/lib/homePortfolio.ts index f1f01c39..93b6387d 100644 --- a/web/src/lib/homePortfolio.ts +++ b/web/src/lib/homePortfolio.ts @@ -1,12 +1,117 @@ +import { crawledUrlCount } from './crawlCounts'; +import { DATA_SOURCE_IDS, type DataSourceId } from './dataProvenance'; import { canonicalDomainFromPayload, extractHostname, slugifyDomain } from './domainSlug'; +import { titleCoveragePct } from './portfolioCrawlHistory'; import type { CrawlRunSummary, + PortfolioCategorySnapshot, + PortfolioCrawlConfig, PortfolioGroup, + PortfolioIssueCounts, + PortfolioSeoSignals, ReportListRow, ReportPayload, StatusCounts, } from '@/types/report'; +const EMPTY_ISSUE_COUNTS: PortfolioIssueCounts = { critical: 0, high: 0, medium: 0, low: 0 }; + +const PORTFOLIO_CATEGORY_ORDER = [ + 'technical_seo', + 'performance', + 'core_web_vitals', + 'link_health', + 'security', + 'html_accessibility', + 'mobile', + 'intelligence', +] as const; + +function categorySnapshotsFromPayload(payload: ReportPayload): PortfolioCategorySnapshot[] { + const cats = payload.categories ?? []; + const byId = new Map(cats.map((c) => [String(c.id || ''), c])); + const out: PortfolioCategorySnapshot[] = []; + + const push = (id: string) => { + const cat = byId.get(id); + if (!cat || typeof cat.score !== 'number' || !Number.isFinite(cat.score)) return; + out.push({ + id, + name: String(cat.name || id), + score: Math.round(cat.score), + issueCount: (cat.issues ?? []).length, + }); + }; + + for (const id of PORTFOLIO_CATEGORY_ORDER) push(id); + for (const cat of cats) { + const id = String(cat.id || ''); + if (!id || out.some((row) => row.id === id)) continue; + if (typeof cat.score !== 'number' || !Number.isFinite(cat.score)) continue; + out.push({ + id, + name: String(cat.name || id), + score: Math.round(cat.score), + issueCount: (cat.issues ?? []).length, + }); + } + return out; +} + +function seoSignalsFromPayload(payload: ReportPayload): PortfolioSeoSignals | null { + const s = payload.seo_health; + if (!s || typeof s !== 'object') return null; + return { + missingTitles: Number(s.missing_title) || 0, + missingMetaDesc: Number(s.missing_meta_desc) || 0, + thinContent: Number(s.thin_content) || 0, + h1Issues: (Number(s.h1_zero) || 0) + (Number(s.h1_multi) || 0), + }; +} + +function medianWordCountFromPayload(payload: ReportPayload): number | null { + const median = payload.content_analytics?.word_count_stats?.median; + return typeof median === 'number' && Number.isFinite(median) ? Math.round(median) : null; +} + +function medianResponseMsFromPayload(payload: ReportPayload): number | null { + const median = payload.response_time_stats?.p50; + return typeof median === 'number' && Number.isFinite(median) ? Math.round(median) : null; +} + +function issueCountsFromPayload(payload: ReportPayload): { counts: PortfolioIssueCounts; total: number } { + const counts: PortfolioIssueCounts = { critical: 0, high: 0, medium: 0, low: 0 }; + for (const cat of payload.categories ?? []) { + for (const iss of cat.issues ?? []) { + const p = String(iss.priority || 'Medium'); + if (p === 'Critical') counts.critical += 1; + else if (p === 'High') counts.high += 1; + else if (p === 'Low') counts.low += 1; + else counts.medium += 1; + } + } + return { + counts, + total: counts.critical + counts.high + counts.medium + counts.low, + }; +} + +function categoryScoreFromPayload(payload: ReportPayload, id: string): number | null { + const cat = (payload.categories ?? []).find((c) => c.id === id); + return typeof cat?.score === 'number' && Number.isFinite(cat.score) ? Math.round(cat.score) : null; +} + +function lighthouseScoresFromPayload(payload: ReportPayload): { perf: number | null; seo: number | null } { + const summary = payload.lighthouse_summary; + const mm = summary?.median_metrics ?? {}; + const cs = summary?.category_scores ?? {}; + const perfRaw = mm.performance_score ?? cs.performance; + const seoRaw = mm.seo_score ?? cs.seo; + const perf = typeof perfRaw === 'number' && Number.isFinite(perfRaw) ? Math.round(perfRaw) : null; + const seo = typeof seoRaw === 'number' && Number.isFinite(seoRaw) ? Math.round(seoRaw) : null; + return { perf, seo }; +} + function scoreFromCategories(categories: Array<{ score?: number }> = []): number | null { const numeric = (categories || []) .map((c) => Number(c?.score)) @@ -25,6 +130,39 @@ function toLocalDateTime(value: string | null | undefined): string { type GetPayloadFn = (reportId: number) => Promise | ReportPayload; +export type CrawlRunMeta = { + render_mode?: string; + discovery_mode?: string; +}; + +function dataSourcesFromPayload(payload: ReportPayload): DataSourceId[] { + const raw = payload.report_meta?.data_sources ?? []; + const allowed = new Set(DATA_SOURCE_IDS); + return raw.filter((s): s is DataSourceId => allowed.has(String(s))); +} + +function crawlConfigFromPayload( + payload: ReportPayload, + runMeta?: CrawlRunMeta, +): PortfolioCrawlConfig | null { + const scope = payload.report_meta?.crawl_scope; + if (!scope && !runMeta?.render_mode && !runMeta?.discovery_mode) return null; + return { + ...scope, + render_mode: scope?.render_mode ?? runMeta?.render_mode, + discovery_mode: runMeta?.discovery_mode, + }; +} + +function crawlConfigFromSummary(row: CrawlRunSummary): PortfolioCrawlConfig | null { + if (!row.render_mode && !row.discovery_mode && !row.url_count) return null; + return { + pages_crawled: row.url_count, + render_mode: row.render_mode, + discovery_mode: row.discovery_mode, + }; +} + /** * Build portfolio domain cards (same logic as Home view useMemo). */ @@ -35,6 +173,7 @@ export async function computeDomainGroups( unknownBrand: string, emDash: string, getPayload: GetPayloadFn, + runMetaByRunId: Map = new Map(), ): Promise { const brandMap = new Map(); @@ -63,15 +202,37 @@ export async function computeDomainGroups( s5xx: Number(summary.count_5xx || 0), other: Number(summary.count_error || 0), }; - const urlCount = Number(summary.total_urls || payload?.links?.length || payload?.top_pages?.length || 0); + const urlCount = crawledUrlCount(payload); const successPct = urlCount > 0 ? Math.round((statusCounts.s2xx / urlCount) * 100) : 0; - const globalHealthBase = scoreFromCategories(payload?.categories) ?? Number(summary.success_rate || 0); - const healthScore = Math.round(globalHealthBase * 0.6 + successPct * 0.4); + const healthScore = scoreFromCategories(payload?.categories) ?? 0; const runCreatedAt = runId != null ? runCreatedAtByRunId.get(runId) : ''; const lastCrawl = toLocalDateTime( runCreatedAt || payload?.crawl_run_created_at || payload?.report_generated_at || r.generated_at, ); + const lastAudit = toLocalDateTime(payload?.report_generated_at || r.generated_at); const generatedAtMs = Number(new Date(r.generated_at || 0)); + const { counts: issueCounts, total: totalIssues } = issueCountsFromPayload(payload); + const { perf: perfScore, seo: seoScore } = lighthouseScoresFromPayload(payload); + const technicalSeoScore = categoryScoreFromPayload(payload, 'technical_seo'); + const successRate = + typeof summary.success_rate === 'number' && Number.isFinite(summary.success_rate) + ? Math.round(summary.success_rate) + : urlCount > 0 + ? successPct + : null; + const crawlDurationS = + typeof summary.crawl_time_s === 'number' && Number.isFinite(summary.crawl_time_s) + ? Math.round(summary.crawl_time_s) + : null; + const categorySnapshots = categorySnapshotsFromPayload(payload); + const seoSignals = seoSignalsFromPayload(payload); + const securityFindings = Array.isArray(payload.security_findings) ? payload.security_findings.length : 0; + const duplicateClusters = Array.isArray(payload.content_duplicates) ? payload.content_duplicates.length : 0; + const medianWordCount = medianWordCountFromPayload(payload); + const medianResponseMs = medianResponseMsFromPayload(payload); + const runMeta = runId != null ? runMetaByRunId.get(runId) : undefined; + const crawlConfig = crawlConfigFromPayload(payload, runMeta); + const dataSources = dataSourcesFromPayload(payload); const existing = brandMap.get(brandKey); if (!existing || generatedAtMs > existing.generatedAtMs) { @@ -84,10 +245,29 @@ export async function computeDomainGroups( healthScore, statusCounts, lastCrawl, + lastAudit, + totalIssues, + issueCounts, + successRate, + titleCoverage: null, + avgWordCount: null, + thinPages: null, + technicalSeoScore, + perfScore, + seoScore, + crawlDurationS, + categorySnapshots, + seoSignals, + securityFindings, + duplicateClusters, + medianWordCount, + medianResponseMs, reportId: r.id, crawlRunId: runId ?? undefined, generatedAtMs, domainParam: canonicalHost, + crawlConfig, + dataSources: dataSources.length > 0 ? dataSources : undefined, }); } } @@ -106,7 +286,7 @@ export function computeCrawlOnlyGroups( ): PortfolioGroup[] { const coveredDomains = new Set( reportGroups - .map((g) => (g.domainParam || slugifyDomain(g.domainName || '')).toLowerCase()) + .map((g) => (g.domainParam || extractHostname(g.crawlUrl) || g.domainName).toLowerCase()) .filter(Boolean), ); const coveredCrawlRunIds = new Set( @@ -123,7 +303,7 @@ export function computeCrawlOnlyGroups( const startUrl = String(row.start_url || '').trim(); const domainName = extractHostname(startUrl) || unknownBrand; - const domainKey = slugifyDomain(domainName).toLowerCase(); + const domainKey = domainName.toLowerCase(); if (!domainKey || coveredDomains.has(domainKey)) continue; const statusCounts: StatusCounts = { @@ -134,7 +314,10 @@ export function computeCrawlOnlyGroups( other: Number(row.other) || 0, }; const urlCount = Number(row.url_count) || 0; - const successPct = urlCount > 0 ? Math.round((statusCounts.s2xx / urlCount) * 100) : 0; + const withTitle = Number(row.with_title) || 0; + const titleCoverage = titleCoveragePct(withTitle, urlCount); + const avgWordCount = Math.round(Number(row.avg_word_count) || 0); + const thinPages = Number(row.thin_pages) || 0; const generatedAtMs = Number(new Date(row.created_at || 0)); const existing = brandMap.get(domainKey); @@ -144,14 +327,32 @@ export function computeCrawlOnlyGroups( domainName, crawlUrl: startUrl || emDash, urlCount, - healthScore: successPct, + healthScore: titleCoverage, statusCounts, lastCrawl: toLocalDateTime(row.created_at), + lastAudit: '', + totalIssues: 0, + issueCounts: EMPTY_ISSUE_COUNTS, + successRate: null, + titleCoverage, + avgWordCount, + thinPages, + technicalSeoScore: null, + perfScore: null, + seoScore: null, + crawlDurationS: null, + categorySnapshots: [], + seoSignals: null, + securityFindings: 0, + duplicateClusters: 0, + medianWordCount: avgWordCount || null, + medianResponseMs: null, reportId: null, crawlRunId: row.crawl_run_id, crawlOnly: true, generatedAtMs, domainParam: domainKey, + crawlConfig: crawlConfigFromSummary(row), }); } diff --git a/web/src/lib/loadReportDb.ts b/web/src/lib/loadReportDb.ts index 1a18c7d7..83dda6a9 100644 --- a/web/src/lib/loadReportDb.ts +++ b/web/src/lib/loadReportDb.ts @@ -25,12 +25,14 @@ async function crawlRunStartUrlsMap(client: PoolClient): Promise { try { const { rows } = await client.query( - 'SELECT id, start_url, created_at FROM crawl_runs ORDER BY id DESC', + 'SELECT id, start_url, created_at, render_mode, discovery_mode FROM crawl_runs ORDER BY id DESC', ); return rows.map((row) => ({ id: Number(row.id), start_url: String(row.start_url || ''), created_at: row.created_at ? String(row.created_at) : '', + render_mode: row.render_mode != null ? String(row.render_mode) : undefined, + discovery_mode: row.discovery_mode != null ? String(row.discovery_mode) : undefined, })); } catch { return []; @@ -44,6 +46,8 @@ export async function getCrawlRunSummaries(client: PoolClient): Promise>'title', '')), '') IS NOT NULL + )::int AS with_title, + COALESCE(ROUND(AVG(NULLIF((crl.data->>'word_count')::numeric, 0))), 0)::int AS avg_word_count, + COUNT(*) FILTER ( + WHERE COALESCE((crl.data->>'word_count')::int, 0) > 0 + AND COALESCE((crl.data->>'word_count')::int, 0) < 300 + )::int AS thin_pages FROM crawl_runs cr LEFT JOIN crawl_results crl ON crl.crawl_run_id = cr.id - GROUP BY cr.id, cr.start_url, cr.created_at + GROUP BY cr.id, cr.start_url, cr.created_at, cr.render_mode, cr.discovery_mode ORDER BY cr.id DESC`, ); return rows.map((row) => ({ @@ -69,6 +81,11 @@ export async function getCrawlRunSummaries(client: PoolClient): Promise { + it('orders oldest to newest and sums issue counts', () => { + const points = parsePortfolioAuditHistory([ + { + healthScore: 90, + issueCounts: { Critical: 1, High: 2, Medium: 3, Low: 4 }, + perfScore: 88, + seoScore: 91, + categoryScores: { technical_seo: 85 }, + }, + { + healthScore: 80, + issueCounts: { Critical: 0, High: 1, Medium: 1, Low: 0 }, + perfScore: 75, + seoScore: 82, + categoryScores: { technical_seo: 78 }, + }, + ]); + expect(points).toHaveLength(2); + expect(points[0]).toEqual({ + healthScore: 80, + totalIssues: 2, + urgentIssues: 1, + perfScore: 75, + seoScore: 82, + technicalSeoScore: 78, + }); + expect(points[1]).toEqual({ + healthScore: 90, + totalIssues: 10, + urgentIssues: 3, + perfScore: 88, + seoScore: 91, + technicalSeoScore: 85, + }); + }); + + it('extracts sparkline series', () => { + const points = parsePortfolioAuditHistory([ + { healthScore: 85, issueCounts: { High: 1 }, perfScore: 88, categoryScores: { technical_seo: 85 } }, + { healthScore: 70, issueCounts: { High: 2 }, perfScore: 75, categoryScores: { technical_seo: 78 } }, + ]); + expect(historySeries(points, 'healthScore')).toEqual([70, 85]); + expect(historySeries(points, 'urgentIssues')).toEqual([2, 1]); + expect(historySeries(points, 'perfScore')).toEqual([75, 88]); + expect(historySeries(points, 'technicalSeoScore')).toEqual([78, 85]); + }); +}); diff --git a/web/src/lib/portfolioAuditHistory.ts b/web/src/lib/portfolioAuditHistory.ts new file mode 100644 index 00000000..de76bdb9 --- /dev/null +++ b/web/src/lib/portfolioAuditHistory.ts @@ -0,0 +1,72 @@ +export interface PortfolioAuditHistoryPoint { + healthScore: number | null; + totalIssues: number; + urgentIssues: number; + perfScore: number | null; + seoScore: number | null; + technicalSeoScore: number | null; +} + +export interface AuditHistoryApiRow { + healthScore?: number | null; + issueCounts?: Record; + perfScore?: number | null; + seoScore?: number | null; + technicalSeoScore?: number | null; + categoryScores?: Record; +} + +function sumIssueCounts(counts: Record | undefined): { total: number; urgent: number } { + if (!counts || typeof counts !== 'object') return { total: 0, urgent: 0 }; + const critical = Number(counts.Critical) || 0; + const high = Number(counts.High) || 0; + const medium = Number(counts.Medium) || 0; + const low = Number(counts.Low) || 0; + const known = critical + high + medium + low; + const fallback = Object.values(counts).reduce((sum, n) => sum + (Number(n) || 0), 0); + const total = known > 0 ? known : fallback; + return { total, urgent: critical + high }; +} + +function categoryScoreFromMap( + scores: Record | undefined, + id: string, +): number | null { + if (!scores) return null; + const value = scores[id]; + return typeof value === 'number' && Number.isFinite(value) ? Math.round(value) : null; +} + +/** Oldest → newest (API returns newest first). */ +export function parsePortfolioAuditHistory(rows: AuditHistoryApiRow[]): PortfolioAuditHistoryPoint[] { + return [...rows].reverse().map((row) => { + const { total, urgent } = sumIssueCounts(row.issueCounts); + const healthScore = + typeof row.healthScore === 'number' && Number.isFinite(row.healthScore) ? row.healthScore : null; + const technicalSeoScore = + row.technicalSeoScore ?? + categoryScoreFromMap(row.categoryScores, 'technical_seo'); + return { + healthScore, + totalIssues: total, + urgentIssues: urgent, + perfScore: + typeof row.perfScore === 'number' && Number.isFinite(row.perfScore) ? Math.round(row.perfScore) : null, + seoScore: + typeof row.seoScore === 'number' && Number.isFinite(row.seoScore) ? Math.round(row.seoScore) : null, + technicalSeoScore, + }; + }); +} + +export function historySeries( + points: PortfolioAuditHistoryPoint[], + key: keyof Pick< + PortfolioAuditHistoryPoint, + 'healthScore' | 'totalIssues' | 'urgentIssues' | 'perfScore' | 'seoScore' | 'technicalSeoScore' + >, +): number[] { + return points + .map((p) => p[key]) + .filter((n): n is number => typeof n === 'number' && Number.isFinite(n)); +} diff --git a/web/src/lib/portfolioCrawlConfig.test.ts b/web/src/lib/portfolioCrawlConfig.test.ts new file mode 100644 index 00000000..5f7ef5f6 --- /dev/null +++ b/web/src/lib/portfolioCrawlConfig.test.ts @@ -0,0 +1,66 @@ +import { describe, expect, it } from 'vitest'; +import { + formatDiscoveryModeLabel, + formatPortfolioCrawlSummary, + formatRenderModeLabel, + hasPortfolioCrawlConfig, +} from './portfolioCrawlConfig'; + +describe('formatRenderModeLabel', () => { + it('maps known render modes', () => { + expect(formatRenderModeLabel('static')).toBeTruthy(); + expect(formatRenderModeLabel('javascript')).toBeTruthy(); + expect(formatRenderModeLabel('auto')).toBeTruthy(); + }); +}); + +describe('formatDiscoveryModeLabel', () => { + it('maps known discovery modes', () => { + expect(formatDiscoveryModeLabel('spider')).toBeTruthy(); + expect(formatDiscoveryModeLabel('sitemap')).toBeTruthy(); + expect(formatDiscoveryModeLabel('list')).toBeTruthy(); + expect(formatDiscoveryModeLabel('hybrid')).toBeTruthy(); + }); +}); + +describe('formatPortfolioCrawlSummary', () => { + it('returns static mode and limit line for audited crawl scope', () => { + const segments = formatPortfolioCrawlSummary({ + render_mode: 'static', + discovery_mode: 'spider', + pages_crawled: 500, + max_pages_configured: 500, + crawl_limited: true, + }); + expect(segments.length).toBeGreaterThanOrEqual(3); + expect(segments.some((s) => /500/.test(s))).toBe(true); + expect(segments.some((s) => /limit reached/i.test(s))).toBe(true); + }); + + it('includes static vs rendered mix for auto mode', () => { + const segments = formatPortfolioCrawlSummary({ + render_mode: 'auto', + discovery_mode: 'spider', + pages_crawled: 20, + max_pages_configured: 500, + pages_static: 12, + pages_rendered: 8, + }); + expect(segments.some((s) => /12/.test(s) && /8/.test(s))).toBe(true); + }); + + it('supports crawl-only fallback with render and discovery only', () => { + const segments = formatPortfolioCrawlSummary({ + render_mode: 'javascript', + discovery_mode: 'list', + pages_crawled: 42, + }); + expect(segments.length).toBeGreaterThanOrEqual(3); + expect(segments.some((s) => /42/.test(s))).toBe(true); + }); + + it('returns empty for null config', () => { + expect(formatPortfolioCrawlSummary(null)).toEqual([]); + expect(hasPortfolioCrawlConfig(null)).toBe(false); + }); +}); diff --git a/web/src/lib/portfolioCrawlConfig.ts b/web/src/lib/portfolioCrawlConfig.ts new file mode 100644 index 00000000..88f8a3d8 --- /dev/null +++ b/web/src/lib/portfolioCrawlConfig.ts @@ -0,0 +1,69 @@ +import { format, strings } from '@/lib/strings'; +import type { PortfolioCrawlConfig } from '@/types/report'; + +const vh = strings.views.home; + +export function formatRenderModeLabel(mode: string | undefined | null): string | null { + const normalized = String(mode ?? 'static').trim().toLowerCase(); + if (normalized === 'javascript') return vh.renderModeJavascript; + if (normalized === 'auto') return vh.renderModeAuto; + if (normalized === 'static') return vh.renderModeStatic; + return null; +} + +export function formatDiscoveryModeLabel(mode: string | undefined | null): string | null { + const normalized = String(mode ?? 'spider').trim().toLowerCase(); + if (normalized === 'spider') return vh.discoveryModeSpider; + if (normalized === 'sitemap') return vh.discoveryModeSitemap; + if (normalized === 'list') return vh.discoveryModeList; + if (normalized === 'hybrid') return vh.discoveryModeHybrid; + return null; +} + +export function formatPortfolioCrawlSummary(config: PortfolioCrawlConfig | null | undefined): string[] { + if (!config) return []; + + const segments: string[] = []; + const render = formatRenderModeLabel(config.render_mode); + if (render) segments.push(render); + + const discovery = formatDiscoveryModeLabel(config.discovery_mode); + if (discovery) segments.push(discovery); + + const pages = config.pages_crawled; + const max = config.max_pages_configured; + if (pages != null && pages > 0) { + if (max != null && max > 0) { + segments.push( + format(vh.crawlLimitLine, { + pages: pages.toLocaleString(), + max: max.toLocaleString(), + limitedSuffix: config.crawl_limited ? vh.crawlLimitReachedSuffix : '', + }), + ); + } else { + segments.push(format(vh.crawlPagesLine, { pages: pages.toLocaleString() })); + } + } + + const renderMode = String(config.render_mode ?? '').trim().toLowerCase(); + if ( + renderMode === 'auto' && + config.pages_static != null && + config.pages_rendered != null && + (config.pages_static > 0 || config.pages_rendered > 0) + ) { + segments.push( + format(strings.views.overview.crawlScope.fetchMethodMixLine, { + staticCount: config.pages_static.toLocaleString(), + renderedCount: config.pages_rendered.toLocaleString(), + }), + ); + } + + return segments; +} + +export function hasPortfolioCrawlConfig(config: PortfolioCrawlConfig | null | undefined): boolean { + return formatPortfolioCrawlSummary(config).length > 0; +} diff --git a/web/src/lib/portfolioCrawlHistory.test.ts b/web/src/lib/portfolioCrawlHistory.test.ts new file mode 100644 index 00000000..8f10c1c0 --- /dev/null +++ b/web/src/lib/portfolioCrawlHistory.test.ts @@ -0,0 +1,46 @@ +import { describe, expect, it } from 'vitest'; +import { buildCrawlHistoryByDomain, crawlHistorySeries } from './portfolioCrawlHistory'; +import type { CrawlRunSummary } from '@/types/report'; + +describe('buildCrawlHistoryByDomain', () => { + it('groups runs by hostname and orders oldest to newest', () => { + const summaries: CrawlRunSummary[] = [ + { + crawl_run_id: 2, + start_url: 'https://fetch.example.com', + created_at: '2026-06-10T10:00:00Z', + url_count: 4, + s2xx: 4, + s3xx: 0, + s4xx: 0, + s5xx: 0, + other: 0, + with_title: 4, + avg_word_count: 500, + thin_pages: 0, + }, + { + crawl_run_id: 1, + start_url: 'https://fetch.example.com', + created_at: '2026-06-09T10:00:00Z', + url_count: 2, + s2xx: 2, + s3xx: 0, + s4xx: 0, + s5xx: 0, + other: 0, + with_title: 1, + avg_word_count: 320, + thin_pages: 1, + }, + ]; + + const history = buildCrawlHistoryByDomain(summaries); + const points = history['fetch.example.com']; + expect(points).toHaveLength(2); + expect(points[0].pagesDiscovered).toBe(2); + expect(points[0].titleCoverage).toBe(50); + expect(points[1].pagesDiscovered).toBe(4); + expect(crawlHistorySeries(points, 'avgWordCount')).toEqual([320, 500]); + }); +}); diff --git a/web/src/lib/portfolioCrawlHistory.ts b/web/src/lib/portfolioCrawlHistory.ts new file mode 100644 index 00000000..1f54e392 --- /dev/null +++ b/web/src/lib/portfolioCrawlHistory.ts @@ -0,0 +1,56 @@ +import { extractHostname } from '@/lib/domainSlug'; +import type { CrawlRunSummary } from '@/types/report'; + +export interface PortfolioCrawlHistoryPoint { + pagesDiscovered: number; + titleCoverage: number; + avgWordCount: number; + createdAtMs: number; +} + +export function titleCoveragePct(withTitle: number, urlCount: number): number { + if (urlCount <= 0) return 0; + return Math.round((withTitle / urlCount) * 100); +} + +export function crawlSummaryToHistoryPoint(row: CrawlRunSummary): PortfolioCrawlHistoryPoint { + const pagesDiscovered = Number(row.url_count) || 0; + return { + pagesDiscovered, + titleCoverage: titleCoveragePct(Number(row.with_title) || 0, pagesDiscovered), + avgWordCount: Math.round(Number(row.avg_word_count) || 0), + createdAtMs: Number(new Date(row.created_at || 0)), + }; +} + +export function buildCrawlHistoryByDomain( + summaries: CrawlRunSummary[], +): Record { + const map = new Map(); + + for (const row of summaries) { + const key = extractHostname(row.start_url).toLowerCase(); + if (!key) continue; + + const list = map.get(key) ?? []; + list.push(crawlSummaryToHistoryPoint(row)); + map.set(key, list); + } + + const out: Record = {}; + for (const [key, list] of map) { + out[key] = list + .toSorted((a, b) => a.createdAtMs - b.createdAtMs) + .slice(-8); + } + return out; +} + +export function crawlHistorySeries( + points: PortfolioCrawlHistoryPoint[], + key: 'pagesDiscovered' | 'titleCoverage' | 'avgWordCount', +): number[] { + return points + .map((p) => p[key]) + .filter((n): n is number => typeof n === 'number' && Number.isFinite(n)); +} diff --git a/web/src/lib/semanticTextHygiene.test.ts b/web/src/lib/semanticTextHygiene.test.ts new file mode 100644 index 00000000..1dbdb2f4 --- /dev/null +++ b/web/src/lib/semanticTextHygiene.test.ts @@ -0,0 +1,25 @@ +import { describe, expect, it } from 'vitest'; +import { filterSemanticTerms, filterTopicClusters, isJunkSemanticTerm } from './semanticTextHygiene'; + +describe('semanticTextHygiene', () => { + it('flags heading-tag ngrams as junk', () => { + expect(isJunkSemanticTerm('h2 h3')).toBe(true); + expect(isJunkSemanticTerm('video games')).toBe(false); + }); + + it('filters site keyword lists', () => { + const items = [ + { word: 'h3 h3', count: 10 }, + { word: 'games', count: 42 }, + ]; + expect(filterSemanticTerms(items).map((i) => i.word)).toEqual(['games']); + }); + + it('filters topic clusters', () => { + const clusters = [ + { top_keyword: 'h2 h2', keywords: ['h2', 'h2 h2'] }, + { top_keyword: 'games', keywords: ['games', 'reviews'] }, + ]; + expect(filterTopicClusters(clusters).map((c) => c.top_keyword)).toEqual(['games']); + }); +}); diff --git a/web/src/lib/semanticTextHygiene.ts b/web/src/lib/semanticTextHygiene.ts new file mode 100644 index 00000000..ec1203de --- /dev/null +++ b/web/src/lib/semanticTextHygiene.ts @@ -0,0 +1,34 @@ +/** Filter structural HTML tokens from semantic UI (keywords, topics, charts). */ + +const HTML_HEADING_TOKENS = new Set(['h1', 'h2', 'h3', 'h4', 'h5', 'h6']); + +export function isJunkSemanticTerm(term: string | undefined): boolean { + if (!term) return true; + const tokens = term + .toLowerCase() + .trim() + .split(/\s+/) + .filter(Boolean); + if (tokens.length === 0) return true; + return tokens.every((t) => HTML_HEADING_TOKENS.has(t)); +} + +export function filterSemanticTerms(items: T[]): T[] { + return items.filter((item) => !isJunkSemanticTerm(item.word)); +} + +export function filterTopicClusters( + clusters: T[], +): T[] { + return clusters + .filter((cl) => { + const label = String(cl.top_keyword ?? cl.representative ?? ''); + return label.length > 0 && !isJunkSemanticTerm(label); + }) + .map((cl) => ({ + ...cl, + keywords: Array.isArray(cl.keywords) + ? cl.keywords.filter((kw) => !isJunkSemanticTerm(String(kw))) + : cl.keywords, + })); +} diff --git a/web/src/lib/textContentAnalysis.test.ts b/web/src/lib/textContentAnalysis.test.ts new file mode 100644 index 00000000..4f4a3490 --- /dev/null +++ b/web/src/lib/textContentAnalysis.test.ts @@ -0,0 +1,40 @@ +import { describe, expect, it } from 'vitest'; +import { buildByPageTextRows } from './textContentAnalysis'; + +describe('buildByPageTextRows', () => { + it('builds rows from 2xx links with top terms', () => { + const rows = buildByPageTextRows( + [ + { + url: 'https://example.com/a', + status: '200', + word_count: 500, + top_keywords: JSON.stringify([{ word: 'games', count: 5 }]), + reading_level: 8, + }, + ], + '', + ); + expect(rows).toHaveLength(1); + expect(rows[0].url).toBe('https://example.com/a'); + expect(rows[0].word_count).toBe(500); + expect(rows[0].top_terms).toContain('games'); + }); + + it('skips non-2xx and filters by search', () => { + const rows = buildByPageTextRows( + [ + { url: 'https://example.com/404', status: '404', word_count: 100, top_keywords: '[]' }, + { + url: 'https://example.com/blog', + status: '200', + word_count: 200, + top_keywords: JSON.stringify([{ word: 'reviews', count: 2 }]), + }, + ], + 'blog', + ); + expect(rows).toHaveLength(1); + expect(rows[0].url).toContain('blog'); + }); +}); diff --git a/web/src/lib/textContentAnalysis.ts b/web/src/lib/textContentAnalysis.ts new file mode 100644 index 00000000..d4e5b291 --- /dev/null +++ b/web/src/lib/textContentAnalysis.ts @@ -0,0 +1,53 @@ +import type { LinkDetail, ReportLink } from '@/types'; +import { parseKeywords, normaliseKw } from '@/utils/linkUtils'; +import { filterSemanticTerms } from '@/lib/semanticTextHygiene'; + +export interface ByPageTextRow extends Record { + url: string; + word_count: number; + reading_level: number; + top_terms: string; + _search: string; +} + +export function buildByPageTextRows( + links: Array | undefined, + searchQuery: string, +): ByPageTextRow[] { + const q = (searchQuery || '').trim().toLowerCase(); + const rows: ByPageTextRow[] = []; + + for (const link of links ?? []) { + const detail = link as LinkDetail; + const status = String(link.status ?? ''); + if (status && !status.match(/^2\d{2}$/)) continue; + + const url = String(link.url ?? '').trim(); + if (!url) continue; + + const kws = filterSemanticTerms( + parseKeywords(detail.top_keywords) + .map(normaliseKw) + .filter((k) => k.word), + ); + const topTerms = kws + .sort((a, b) => (Number(b.count) || 0) - (Number(a.count) || 0)) + .slice(0, 3) + .map((k) => `${k.word} (${Number(k.count) || 0})`) + .join(', '); + + const row: ByPageTextRow = { + url, + word_count: Number(link.word_count) || 0, + reading_level: Number(detail.reading_level) || 0, + top_terms: topTerms, + _search: `${url} ${topTerms}`.toLowerCase(), + }; + + if (!q || row._search.includes(q)) { + rows.push(row); + } + } + + return rows; +} diff --git a/web/src/routes.test.ts b/web/src/routes.test.ts new file mode 100644 index 00000000..f31a382c --- /dev/null +++ b/web/src/routes.test.ts @@ -0,0 +1,63 @@ +import { describe, expect, it } from 'vitest'; +import { APP_NAV_ITEMS } from '@/lib/appNav'; +import { + pathSlugToViewId, + viewIdToPathSlug, + REPORT_PATH_SLUGS, +} from '@/routes'; + +describe('pathSlugToViewId', () => { + it('maps canonical aliases to internal view ids', () => { + expect(pathSlugToViewId('dashboard')).toBe('overview'); + expect(pathSlugToViewId('keywords')).toBe('keywords-explorer'); + }); + + it('maps path-equal view ids directly', () => { + expect(pathSlugToViewId('links')).toBe('links'); + expect(pathSlugToViewId('home')).toBe('home'); + }); + + it('rejects non-canonical legacy slugs', () => { + expect(pathSlugToViewId('overview')).toBeNull(); + expect(pathSlugToViewId('charts')).toBeNull(); + expect(pathSlugToViewId('keywords-explorer')).toBeNull(); + }); + + it('rejects invalid slugs', () => { + expect(pathSlugToViewId('bogus')).toBeNull(); + expect(pathSlugToViewId(null)).toBeNull(); + expect(pathSlugToViewId('')).toBeNull(); + }); +}); + +describe('viewIdToPathSlug', () => { + it('emits canonical path slugs', () => { + expect(viewIdToPathSlug('overview')).toBe('dashboard'); + expect(viewIdToPathSlug('keywords-explorer')).toBe('keywords'); + expect(viewIdToPathSlug('links')).toBe('links'); + }); +}); + +describe('REPORT_PATH_SLUGS', () => { + it('includes dashboard and keywords but not legacy aliases', () => { + expect(REPORT_PATH_SLUGS).toContain('dashboard'); + expect(REPORT_PATH_SLUGS).toContain('keywords'); + expect(REPORT_PATH_SLUGS).not.toContain('overview'); + expect(REPORT_PATH_SLUGS).not.toContain('charts'); + expect(REPORT_PATH_SLUGS).not.toContain('keywords-explorer'); + }); +}); + +describe('nav href round-trip', () => { + it('resolves every report nav item href back to its view id', () => { + const reportItems = APP_NAV_ITEMS.filter( + (item) => item.id !== 'pipeline' && item.id !== 'chat', + ); + + for (const item of reportItems) { + const slug = item.hrefPath.replace(/^\//, ''); + const viewId = pathSlugToViewId(slug); + expect(viewId, `href ${item.hrefPath} should resolve`).toBe(item.id); + } + }); +}); diff --git a/web/src/routes.ts b/web/src/routes.ts index 591a2a74..88815374 100644 --- a/web/src/routes.ts +++ b/web/src/routes.ts @@ -1,4 +1,4 @@ -/** Known report view ids (path segments under `/`). */ +/** Internal view ids used by components and navigation. */ export type ViewId = | 'home' | 'overview' @@ -11,8 +11,8 @@ export type ViewId = | 'security' | 'javascript-errors' | 'content-analytics' + | 'text-content-analysis' | 'tech-stack' - | 'charts' | 'network' | 'gallery' | 'search-performance' @@ -26,9 +26,11 @@ export type ViewId = | 'export' | 'log-analyzer'; -const VIEW_IDS = new Set([ +/** Canonical URL path segments under `/` (validated by `[slug]/page.tsx`). */ +export const REPORT_PATH_SLUGS = [ 'home', - 'overview', + 'dashboard', + 'keywords', 'issues', 'links', 'site-structure', @@ -38,8 +40,8 @@ const VIEW_IDS = new Set([ 'security', 'javascript-errors', 'content-analytics', + 'text-content-analysis', 'tech-stack', - 'charts', 'network', 'gallery', 'search-performance', @@ -48,11 +50,14 @@ const VIEW_IDS = new Set([ 'contacts', 'backlinks', 'traffic', - 'keywords-explorer', 'compare', 'export', 'log-analyzer', -]); +] as const; + +export type ReportPathSlug = (typeof REPORT_PATH_SLUGS)[number]; + +const REPORT_PATH_SLUG_SET = new Set(REPORT_PATH_SLUGS); export function viewIdToPathSlug(viewId: string): string { if (viewId === 'overview') return 'dashboard'; @@ -64,5 +69,5 @@ export function pathSlugToViewId(slug: string | null | undefined): ViewId | null if (!slug || typeof slug !== 'string') return null; if (slug === 'dashboard') return 'overview'; if (slug === 'keywords') return 'keywords-explorer'; - return VIEW_IDS.has(slug) ? (slug as ViewId) : null; + return REPORT_PATH_SLUG_SET.has(slug) ? (slug as ViewId) : null; } diff --git a/web/src/server/auditHistoryDb.ts b/web/src/server/auditHistoryDb.ts index 63d02e62..6e3285bd 100644 --- a/web/src/server/auditHistoryDb.ts +++ b/web/src/server/auditHistoryDb.ts @@ -8,6 +8,9 @@ export interface AuditHistoryRow { healthScore: number | null; categoryScores: Record; issueCounts: Record; + perfScore: number | null; + seoScore: number | null; + technicalSeoScore: number | null; } function averageCategoryScore(categories: Array<{ score?: number | null }>): number | null { @@ -29,6 +32,33 @@ function issueCountsByPriority(categories: Array<{ issues?: Array<{ priority?: s return counts; } +function lighthouseScoresFromPayload(payload: Record): { + perfScore: number | null; + seoScore: number | null; +} { + const summary = payload.lighthouse_summary; + if (!summary || typeof summary !== 'object') return { perfScore: null, seoScore: null }; + const lh = summary as { + median_metrics?: Record; + category_scores?: Record; + }; + const mm = lh.median_metrics ?? {}; + const cs = lh.category_scores ?? {}; + const perfRaw = mm.performance_score ?? cs.performance; + const seoRaw = mm.seo_score ?? cs.seo; + const perf = typeof perfRaw === 'number' && Number.isFinite(perfRaw) ? Math.round(perfRaw) : null; + const seo = typeof seoRaw === 'number' && Number.isFinite(seoRaw) ? Math.round(seoRaw) : null; + return { perfScore: perf, seoScore: seo }; +} + +function categoryScore( + categories: Array<{ id?: string; name?: string; score?: number }>, + id: string, +): number | null { + const cat = categories.find((c) => c.id === id); + return typeof cat?.score === 'number' && Number.isFinite(cat.score) ? Math.round(cat.score) : null; +} + export async function listAuditHistory( propertyId?: number | null, domain?: string | null, @@ -44,8 +74,11 @@ export async function listAuditHistory( vals.push(propertyId); } else if (domain) { n += 1; - clauses.push(`canonical_domain = $${n}`); - vals.push(domain.toLowerCase()); + const normalized = domain.trim().toLowerCase(); + clauses.push( + `(LOWER(canonical_domain) = $${n} OR regexp_replace(LOWER(COALESCE(canonical_domain, '')), '[^a-z0-9]+', '-', 'g') = $${n})`, + ); + vals.push(normalized); } n += 1; vals.push(Math.min(100, Math.max(1, limit))); @@ -55,9 +88,12 @@ export async function listAuditHistory( canonical_domain: string | null; site_name: string | null; generated_at: Date; - payload: { categories?: Array<{ id?: string; name?: string; score?: number; issues?: Array<{ priority?: string }> }> }; + data: { + categories?: Array<{ id?: string; name?: string; score?: number; issues?: Array<{ priority?: string }> }>; + lighthouse_summary?: Record; + }; }>( - `SELECT id, canonical_domain, site_name, generated_at, payload + `SELECT id, canonical_domain, site_name, generated_at, data FROM report_payload ${where} ORDER BY generated_at DESC @@ -65,7 +101,8 @@ export async function listAuditHistory( vals, ); return cur.rows.map((row) => { - const categories = row.payload?.categories || []; + const payload = row.data || {}; + const categories = payload.categories || []; const categoryScores: Record = {}; for (const cat of categories) { const key = cat.id || cat.name || 'unknown'; @@ -73,6 +110,7 @@ export async function listAuditHistory( categoryScores[key] = cat.score; } } + const { perfScore, seoScore } = lighthouseScoresFromPayload(payload as Record); return { reportId: Number(row.id), canonicalDomain: row.canonical_domain, @@ -81,6 +119,9 @@ export async function listAuditHistory( healthScore: averageCategoryScore(categories), categoryScores, issueCounts: issueCountsByPriority(categories), + perfScore, + seoScore, + technicalSeoScore: categoryScore(categories, 'technical_seo'), }; }); }); diff --git a/web/src/strings.json b/web/src/strings.json index 078409c6..c523f88e 100644 --- a/web/src/strings.json +++ b/web/src/strings.json @@ -83,6 +83,10 @@ "label": "Content quality", "section": "On-page & content" }, + "text-content-analysis": { + "label": "Analyse website text content", + "section": "On-page & content" + }, "tech-stack": { "label": "Technologies", "section": "On-page & content" @@ -862,8 +866,55 @@ "groupPropertyCount": "{count} properties", "healthScoreLabel": "Site health", "crawlUrlLabel": "Site URL", + "crawlConfigLabel": "Crawl config", + "renderModeStatic": "Static", + "renderModeJavascript": "JavaScript", + "renderModeAuto": "Auto", + "discoveryModeSpider": "Spider", + "discoveryModeSitemap": "Sitemap", + "discoveryModeList": "URL list", + "discoveryModeHybrid": "Hybrid", + "crawlLimitLine": "{pages}/{max} URLs{limitedSuffix}", + "crawlLimitReachedSuffix": " · limit reached", + "crawlPagesLine": "{pages} URLs", "urlCountLabel": "URLs", "lastCrawlLabel": "Last crawl", + "lastAuditLabel": "Last audit", + "lastAuditNone": "No audit yet", + "totalIssuesLabel": "Issues", + "successRateLabel": "2xx rate", + "titleCoverageLabel": "Title coverage", + "avgWordCountLabel": "Avg words", + "thinPagesLabel": "Thin pages", + "technicalSeoLabel": "Technical SEO", + "perfScoreLabel": "Performance", + "seoScoreLabel": "SEO score", + "crawlDurationLabel": "Crawl time", + "crawlDurationValue": "{seconds}s", + "trendsLabel": "Audit trends", + "trendHealthLabel": "Health", + "trendIssuesLabel": "Issues", + "trendUrgentLabel": "Critical + high", + "trendsNeedHistory": "Run another audit to see trend lines.", + "crawlTrendsLabel": "Crawl trends", + "trendUrlsLabel": "Pages found", + "trendTitleCoverageLabel": "Title coverage", + "trendAvgWordsLabel": "Avg words", + "crawlTrendsNeedHistory": "Run another crawl to see trend lines.", + "categoryScoresLabel": "Category scores", + "seoSignalsLabel": "On-page signals", + "missingTitlesLabel": "Missing titles", + "missingMetaLabel": "Missing meta", + "h1IssuesLabel": "H1 issues", + "securityFindingsLabel": "Security", + "duplicateContentLabel": "Duplicates", + "medianWordsLabel": "Median words", + "responseTimeLabel": "Median response", + "responseTimeValue": "{ms}ms", + "healthDeltaUp": "+{delta} vs last", + "healthDeltaDown": "{delta} vs last", + "auditRunsLabel": "{count} audits", + "categoryIssueCount": "{count} issues", "statusBreakdownLabel": "Status mix", "openBrandCta": "Open overview", "otherStatusPill": "Other {count}", @@ -899,12 +950,20 @@ "label": "AI insights" }, "issuesByCategory": "Issues by category", - "issuesByCategoryHint": "Reflects current search and category filter, not priority filter.", + "issuesByCategoryHint": "Reflects current search and priority filter.", "issuesByPriority": "Issues by priority", - "issuesByPriorityHint": "Same slice as category chart (search + category filter only).", + "issuesByPriorityHint": "Same slice as category chart (search + priority filter only).", "allPriorities": "All Priorities", "allCategories": "All Categories", "noMatches": "No issues match the current filters.", + "pagination": { + "showingSlice": "Showing {from}–{to} of {total}", + "pageOf": "Page", + "of": "of", + "previous": "Previous", + "next": "Next", + "rowsPerPage": "{n} per page" + }, "tabAudit": "Audit issues", "tabBoard": "Task board", "taskBoardHint": "Sorted by Search Console clicks to affected URLs when available.", @@ -1252,7 +1311,15 @@ "emptyFiltered": "No findings match the current filters or search.", "emptyNoScan": "No security findings yet. Enable security checks in audit settings, then run a crawl.", "recommendation": "Recommendation", - "findingTooltip": "{n} finding{s}" + "findingTooltip": "{n} finding{s}", + "pagination": { + "showingSlice": "Showing {from}–{to} of {total}", + "pageOf": "Page", + "of": "of", + "previous": "Previous", + "next": "Next", + "rowsPerPage": "{n} per page" + } }, "javascriptErrors": { "title": "JavaScript errors", @@ -1294,7 +1361,15 @@ "exceptionFallback": "Uncaught exception", "stackPreview": "{stack}…", "inspectorConsoleRecommendation": "Fix the script at the reported source location; check network and CORS if the error references a failed load.", - "inspectorExceptionRecommendation": "Open the Page analysis tab for the full stack trace and browser console output." + "inspectorExceptionRecommendation": "Open the Page analysis tab for the full stack trace and browser console output.", + "pagination": { + "showingSlice": "Showing {from}–{to} of {total}", + "pageOf": "Page", + "of": "of", + "previous": "Previous", + "next": "Next", + "rowsPerPage": "{n} per page" + } }, "lighthouse": { "emptyTitle": "Page Speed", @@ -1334,9 +1409,17 @@ "auditTablesHint": "Expand any row for full Lighthouse detail rows (thumbnails, resource URLs, DOM nodes).", "noAuditsSearch": "No audits match your search.", "diagnostics": "Diagnostics & Fixes", - "diagnosticsHint": "Issues grouped by impact area. Click a group to expand. Click any issue for full detail and evidence.", + "diagnosticsHint": "Issues grouped by impact area. Select a tab to browse diagnostics with pagination.", "allChecksPassed": "No failing audits — all checks passed.", "noDiagnosticsSearch": "No diagnostics match your search.", + "pagination": { + "showingSlice": "Showing {from}–{to} of {total}", + "pageOf": "Page", + "of": "of", + "previous": "Previous", + "next": "Next", + "rowsPerPage": "{n} per page" + }, "defaultFix": "See Lighthouse report for fix.", "tabs": { "overview": "Overview", @@ -2093,8 +2176,19 @@ "sampleContentChanged": "Sample content changed", "keywordOpportunities": "Keyword opportunities", "keywordOpportunitiesHint": "Keywords found in crawl copy (titles, headings, meta, URLs). Frequency is estimated from this site — not Google search volume or Keyword Planner data.", - "quickWinsEase": "Quick wins (ease proxy)", - "highEmphasis": "High emphasis (frequency proxy)", + "keywordOpportunitiesGscHint": "Top opportunities from Search Console rankings and expansion sources. +est. clicks models upside if a query moves into the top 3.", + "viewKeywords": "View all keywords", + "onPagesCount": "on {n} pages", + "topThemes": "Top themes (from crawl)", + "siteTopTerms": "Top terms on site (from page copy)", + "siteTermMentions": "{n} mentions", + "crawlActionLabels": { + "internal link": "Add internal links", + "optimize page": "Strengthen landing page", + "create content": "Create dedicated content" + }, + "quickWinsEase": "Suggested actions", + "highEmphasis": "High emphasis (on-site frequency)", "contentIntelligence": "Content quality", "duplicateGroups": "Duplicate groups", "nearDuplicateGroups": "Near-duplicate groups", @@ -2411,6 +2505,62 @@ "label": "Thin (body chars)" } ] + }, + "textContentAnalysis": { + "title": "Analyse website text content", + "subtitle": "Crawl-derived vocabulary, keyword frequency across pages, text distributions, and topic signals from body copy.", + "tabs": { + "overview": "Overview", + "keywords": "Keywords", + "analytics": "Analytics", + "topics": "Topics" + }, + "uniqueTerms": "Unique terms", + "pagesWithKeywords": "Pages with keywords", + "avgTermsPerPage": "Avg term occurrences / page", + "totalOccurrences": "Total term occurrences", + "meanWords": "Mean words", + "medianWords": "Median words", + "perPage": "per page", + "topKeywordsChart": "All keywords (site-wide)", + "keywordFrequencyHist": "Terms by page spread", + "histBucket1": "1 page", + "histBucket2": "2–5 pages", + "histBucket6": "6–20 pages", + "histBucket21": "21+ pages", + "thWord": "Term", + "thTotalCount": "Total count", + "thPageCount": "Pages", + "thTopPages": "Top pages", + "expandPages": "Show pages", + "collapsePages": "Hide pages", + "byPageTitle": "Text by page", + "byPageDesc": "Per-page word count, reading level, and top body terms from the crawl.", + "thUrl": "URL", + "thWords": "Words", + "thReading": "Reading level", + "thTopTerms": "Top terms", + "wordCountDist": "Word count distribution", + "readingLevelDist": "Reading level distribution", + "contentHtmlRatio": "Content-to-HTML ratio", + "wordCountLadder": "Word count ladder (min → max)", + "languageMix": "Language mix", + "entityLabels": "Entity labels", + "parentTopicsToken": "Parent topics (token overlap)", + "parentTopicsSemantic": "Parent topics (semantic similarity)", + "thRepresentative": "Representative", + "thClusterScore": "Cluster score", + "thKeywords": "Keywords", + "noKeywordData": "No keyword data from crawl", + "wcPercLabels": ["Min", "P25", "Median", "Mean", "P75", "Max"], + "pagination": { + "showingSlice": "Showing {from}–{to} of {total}", + "pageOf": "Page", + "of": "of", + "rowsPerPage": "{n} per page", + "previous": "Previous", + "next": "Next" + } } }, "components": { @@ -2467,6 +2617,7 @@ "ollamaNoModel": "no model selected", "ollamaUnreachable": "Cannot reach Ollama", "fabTitle": "AI Chat", + "fabDragTitle": "AI Chat — drag to move to any corner", "fabAria": "Open AI chat for this site", "ollamaToolsMode": "native tools", "ollamaReactMode": "JSON tool mode", diff --git a/web/src/types/api.ts b/web/src/types/api.ts index 600cff3f..66f75c23 100644 --- a/web/src/types/api.ts +++ b/web/src/types/api.ts @@ -92,8 +92,16 @@ export interface OkDbPathResponse { dbPath: string; } +export interface PortfolioCrawlHistoryPoint { + pagesDiscovered: number; + titleCoverage: number; + avgWordCount: number; + createdAtMs: number; +} + export interface PortfolioResponse { groups: PortfolioGroup[]; + crawlHistoryByDomain?: Record; } export interface ReportPayloadResponse { diff --git a/web/src/types/index.ts b/web/src/types/index.ts index 25625c44..20ce266f 100644 --- a/web/src/types/index.ts +++ b/web/src/types/index.ts @@ -22,6 +22,9 @@ export type { ReportMetaResponse, StatusCounts, PortfolioGroup, + PortfolioCrawlConfig, + PortfolioCategorySnapshot, + PortfolioSeoSignals, ReportFingerprintDiff, PathRollupMetrics, PathRollup, @@ -37,6 +40,9 @@ export type { SeoHealthStats, SocialCoverageStats, ContentAnalyticsData, + TextContentAnalysisData, + TextContentKeywordEntry, + TextContentPageRef, ResponseTimeStats, DepthDistribution, TechStackSummary, diff --git a/web/src/types/report.ts b/web/src/types/report.ts index eacce712..ad940bcf 100644 --- a/web/src/types/report.ts +++ b/web/src/types/report.ts @@ -1,3 +1,4 @@ +import type { DataSourceId } from '@/lib/dataProvenance'; import type { Ga4ChannelRow, Ga4DeviceRow, @@ -158,6 +159,29 @@ export interface ContentAnalyticsData { [key: string]: unknown; } +export interface TextContentPageRef { + url: string; + count: number; +} + +export interface TextContentKeywordEntry { + word: string; + total_count: number; + page_count: number; + top_pages?: TextContentPageRef[]; +} + +export interface TextContentAnalysisData { + vocabulary_stats?: { + unique_terms?: number; + pages_with_keywords?: number; + avg_terms_per_page?: number; + total_term_occurrences?: number; + }; + keyword_index?: TextContentKeywordEntry[]; + keyword_frequency_histogram?: Record; +} + export interface ResponseTimeStats { p25?: number; p50?: number; @@ -396,6 +420,12 @@ export interface KeywordOpportunityItem { keyword?: string; recommended_action?: string; score?: number; + volume?: number; + relevance?: number; + sources_count?: number; + difficulty?: number; + difficulty_estimated?: boolean; + data_source?: string; } export interface KeywordOpportunities { @@ -587,6 +617,7 @@ export interface ReportPayload { seo_health?: SeoHealthStats; social_coverage?: SocialCoverageStats; content_analytics?: ContentAnalyticsData; + text_content_analysis?: TextContentAnalysisData; response_time_stats?: ResponseTimeStats; depth_distribution?: DepthDistribution; tech_stack_summary?: TechStackSummary; @@ -711,6 +742,8 @@ export interface CrawlRunRow { id: number; start_url: string; created_at: string; + render_mode?: string; + discovery_mode?: string; } export interface CrawlRunSummary { @@ -723,6 +756,24 @@ export interface CrawlRunSummary { s4xx: number; s5xx: number; other: number; + with_title: number; + avg_word_count: number; + thin_pages: number; + render_mode?: string; + discovery_mode?: string; +} + +export interface PortfolioCrawlConfig { + pages_crawled?: number; + max_pages_configured?: number; + robots_blocked_count?: number; + static_html_only?: boolean; + render_mode?: string; + js_concurrency?: number | null; + pages_static?: number; + pages_rendered?: number; + crawl_limited?: boolean; + discovery_mode?: string; } export interface ReportListRow { @@ -745,6 +796,27 @@ export interface StatusCounts { other: number; } +export interface PortfolioIssueCounts { + critical: number; + high: number; + medium: number; + low: number; +} + +export interface PortfolioCategorySnapshot { + id: string; + name: string; + score: number; + issueCount: number; +} + +export interface PortfolioSeoSignals { + missingTitles: number; + missingMetaDesc: number; + thinContent: number; + h1Issues: number; +} + export interface PortfolioGroup { domainName: string; crawlUrl: string; @@ -752,11 +824,30 @@ export interface PortfolioGroup { healthScore: number; statusCounts: StatusCounts; lastCrawl: string; + lastAudit: string; + totalIssues: number; + issueCounts: PortfolioIssueCounts; + successRate: number | null; + titleCoverage: number | null; + avgWordCount: number | null; + thinPages: number | null; + technicalSeoScore: number | null; + perfScore: number | null; + seoScore: number | null; + crawlDurationS: number | null; + categorySnapshots: PortfolioCategorySnapshot[]; + seoSignals: PortfolioSeoSignals | null; + securityFindings: number; + duplicateClusters: number; + medianWordCount: number | null; + medianResponseMs: number | null; reportId: number | null; crawlRunId?: number; crawlOnly?: boolean; generatedAtMs: number; domainParam: string; + crawlConfig?: PortfolioCrawlConfig | null; + dataSources?: DataSourceId[]; } export interface ReportFingerprintDiff { diff --git a/web/src/views/ContentAnalytics.tsx b/web/src/views/ContentAnalytics.tsx index b0bd80b1..53362ebb 100644 --- a/web/src/views/ContentAnalytics.tsx +++ b/web/src/views/ContentAnalytics.tsx @@ -15,6 +15,7 @@ import type { TopicCluster, ViewProps, } from '@/types'; +import { filterSemanticTerms, filterTopicClusters } from '@/lib/semanticTextHygiene'; import { anyChartOptions } from '../utils/chartOptions'; import { Chart as ChartJS, CategoryScale, LinearScale, BarElement, ArcElement, Title, Tooltip, Legend } from 'chart.js'; import { Bar, Doughnut } from 'react-chartjs-2'; @@ -41,6 +42,7 @@ import { useReport } from '../context/useReport'; import { strings, format } from '../lib/strings'; import { PageLayout, PageHeader, Card, Table, TableHead, TableHeadCell, TableBody, TableRow, TableCell, ViewTabs, ViewTabPanel, StatCard } from '../components'; import { StatusDistributionChart, CoverageBar, ChartAccessibleFallback } from '../components/charts'; +import { crawledUrlCount } from '@/lib/crawlCounts'; import { statusDistributionFromSummary } from '../lib/statusDistribution'; import { filterZeroSlices, doughnutOptionsWithPercentTooltip, formatCompositionAria } from '../lib/chartDoughnutUtils'; import type { ViewTabItem } from '../components'; @@ -361,9 +363,25 @@ export default function ContentAnalytics({ searchQuery = '' }: ViewProps) { }, ], [vca.tabs]); + const topKw = useMemo( + () => filterSemanticTerms(data?.content_analytics?.top_keywords_site || []), + [data?.content_analytics?.top_keywords_site], + ); + + const tokenClusters = useMemo( + () => filterTopicClusters(data?.keyword_opportunities?.token_topic_clusters ?? []), + [data?.keyword_opportunities?.token_topic_clusters], + ); + + const semanticClusters = useMemo( + () => filterTopicClusters(data?.semantic_keyword_clusters ?? []), + [data?.semantic_keyword_clusters], + ); + if (!data) return null; const summary: ReportSummary = data.summary ?? EMPTY_SUMMARY; + const crawledCount = crawledUrlCount(data); const rtStats: ResponseTimeStats = data.response_time_stats ?? EMPTY_RT; const rtDist = rtStats.distribution || {}; const contentUrls: ContentUrlsMap = data.content_urls ?? EMPTY_CONTENT_URLS; @@ -376,7 +394,6 @@ export default function ContentAnalytics({ searchQuery = '' }: ViewProps) { const wcDist = ca.word_count_distribution || {}; const rlDist = ca.reading_level_distribution || {}; const crDist = ca.content_ratio_distribution || {}; - const topKw = ca.top_keywords_site || []; const wcLabels = Object.keys(wcDist); const wcValues = Object.values(wcDist).map(Number); @@ -429,7 +446,7 @@ export default function ContentAnalytics({ searchQuery = '' }: ViewProps) { const hasDepthData = depthLabels.length > 0; const statusDistribution = statusDistributionFromSummary(summary); - const hasStatusChart = statusDistribution != null && (Number(summary.total_urls) || 0) > 0; + const hasStatusChart = statusDistribution != null && crawledCount > 0; const h1Chart = filterZeroSlices(h1Labels, h1Values); const h1Aria = formatCompositionAria(h1Chart.labels, h1Chart.values, 'pages'); @@ -495,8 +512,6 @@ export default function ContentAnalytics({ searchQuery = '' }: ViewProps) { const hreflang = data.hreflang_summary; const outboundDomains = data.outbound_link_domains ?? []; - const tokenClusters = data.keyword_opportunities?.token_topic_clusters ?? []; - const semanticClusters = data.semantic_keyword_clusters ?? []; return ( @@ -751,7 +766,7 @@ export default function ContentAnalytics({ searchQuery = '' }: ViewProps) {

    {vca.urlsByStatus}

    {vca.totalCrawled}{' '} - {Number(summary.total_urls || 0).toLocaleString()} + {crawledCount.toLocaleString()} {summary.success_rate != null && ( <> · {summary.success_rate}% {vca.returned2xx} diff --git a/web/src/views/Home.tsx b/web/src/views/Home.tsx index 38fe082c..870f120b 100644 --- a/web/src/views/Home.tsx +++ b/web/src/views/Home.tsx @@ -1,36 +1,20 @@ -import { Building2, ChevronDown, ExternalLink, Globe, ArrowRight, Search, Trash2 } from 'lucide-react'; +import { Building2, ChevronDown, Search } from 'lucide-react'; import { useMemo, useState, useEffect, useCallback } from 'react'; import AppLogo from '@/components/AppLogo'; import { PageLayout, Card } from '../components'; -import HealthSparkline from '@/components/HealthSparkline'; +import PortfolioPropertyCard from '@/components/portfolio/PortfolioPropertyCard'; +import { healthScoreClass, portfolioCardKey } from '@/components/portfolio/portfolioCardUtils'; import { Skeleton, SkeletonDomainCard } from '../components/Skeleton'; import { useReport } from '../context/useReport'; import { format, strings } from '../lib/strings'; import { extractHostname } from '@/lib/domainSlug'; import { apiUrl, reportApi } from '../lib/publicBase'; -import type { PortfolioGroup, ReportCategory, ViewProps } from '@/types'; - -function scoreFromCategories(categories: ReportCategory[] = []): number | null { - const numeric = (categories || []) - .map((c) => Number(c?.score)) - .filter((n) => Number.isFinite(n)); - if (!numeric.length) return null; - const avg = numeric.reduce((a, b) => a + b, 0) / numeric.length; - return Math.round(avg); -} - -function toLocalDateTime(value: string | null | undefined): string { - if (!value) return ''; - const d = new Date(value); - if (Number.isNaN(d.getTime())) return ''; - return d.toLocaleString(); -} - -function healthScoreClass(score: number): string { - if (score >= 80) return 'text-emerald-700 dark:text-emerald-400'; - if (score >= 60) return 'text-amber-700 dark:text-amber-400'; - return 'text-rose-700 dark:text-rose-400'; -} +import { + parsePortfolioAuditHistory, + type PortfolioAuditHistoryPoint, +} from '@/lib/portfolioAuditHistory'; +import type { PortfolioCrawlHistoryPoint } from '@/types/api'; +import type { PortfolioGroup, ViewProps } from '@/types'; function portfolioRootDomain(group: PortfolioGroup): string { const host = extractHostname(group.crawlUrl) || group.domainName.trim().toLowerCase(); @@ -51,7 +35,12 @@ export default function Home({ onNavigate }: ViewProps) { const [pendingDeleteKey, setPendingDeleteKey] = useState(null); const [deletingKey, setDeletingKey] = useState(null); const [deleteError, setDeleteError] = useState(null); - const [healthHistoryByDomain, setHealthHistoryByDomain] = useState>({}); + const [auditHistoryByDomain, setAuditHistoryByDomain] = useState< + Record + >({}); + const [crawlHistoryByDomain, setCrawlHistoryByDomain] = useState< + Record + >({}); const [collapsedGroups, setCollapsedGroups] = useState>(() => new Set()); const toggleGroupCollapsed = useCallback((rootDomain: string) => { @@ -63,9 +52,6 @@ export default function Home({ onNavigate }: ViewProps) { }); }, []); - const portfolioCardKey = (group: PortfolioGroup) => - `${group.domainParam}-${group.crawlOnly ? 'crawl' : 'report'}-${group.reportId ?? 'nr'}-${group.crawlRunId ?? 'nc'}-${group.generatedAtMs}`; - const openSite = useCallback(async (group: PortfolioGroup) => { if (group.crawlOnly && group.crawlRunId != null) { setOpeningCrawlId(group.crawlRunId); @@ -116,6 +102,7 @@ export default function Home({ onNavigate }: ViewProps) { useEffect(() => { if (!reportList.length && !crawlRuns.length) { setDomainGroups([]); + setCrawlHistoryByDomain({}); setPortfolioLoading(false); return; } @@ -126,10 +113,18 @@ export default function Home({ onNavigate }: ViewProps) { fetch(reportApi(`/portfolio${qs}`)) .then((res) => res.json()) .then((body) => { - if (!cancelled) setDomainGroups(Array.isArray(body.groups) ? body.groups : []); + if (cancelled) return; + setDomainGroups(Array.isArray(body.groups) ? body.groups : []); + const crawlHistory = body.crawlHistoryByDomain; + setCrawlHistoryByDomain( + crawlHistory && typeof crawlHistory === 'object' ? crawlHistory : {}, + ); }) .catch(() => { - if (!cancelled) setDomainGroups([]); + if (!cancelled) { + setDomainGroups([]); + setCrawlHistoryByDomain({}); + } }) .finally(() => { if (!cancelled) setPortfolioLoading(false); @@ -141,7 +136,7 @@ export default function Home({ onNavigate }: ViewProps) { useEffect(() => { if (!domainGroups.length) { - setHealthHistoryByDomain({}); + setAuditHistoryByDomain({}); return; } let cancelled = false; @@ -154,22 +149,22 @@ export default function Home({ onNavigate }: ViewProps) { apiUrl(`/report/history?domain=${encodeURIComponent(g.domainParam)}&limit=8`), ); const body = await res.json(); - const scores = [...(body.history || [])] - .map((row: { healthScore?: number | null }) => row.healthScore) - .filter((n: unknown): n is number => typeof n === 'number' && Number.isFinite(n)) - .reverse(); - return [g.domainParam, scores] as [string, number[]]; + const points = parsePortfolioAuditHistory(body.history || []); + return [g.domainParam, points] as [string, PortfolioAuditHistoryPoint[]]; } catch { - return [g.domainParam, [] as number[]] as [string, number[]]; + return [g.domainParam, [] as PortfolioAuditHistoryPoint[]] as [ + string, + PortfolioAuditHistoryPoint[], + ]; } }), ).then((entries) => { if (cancelled) return; - const map: Record = {}; - for (const [domain, scores] of entries) { - if (scores.length) map[domain] = scores; + const map: Record = {}; + for (const [domain, points] of entries) { + if (points.length) map[domain] = points; } - setHealthHistoryByDomain(map); + setAuditHistoryByDomain(map); }); return () => { cancelled = true; @@ -210,16 +205,11 @@ export default function Home({ onNavigate }: ViewProps) { .toSorted((a, b) => (b.items[0]?.generatedAtMs ?? 0) - (a.items[0]?.generatedAtMs ?? 0)); }, [filteredGroups]); - const emptyMessage = filterQuery - ? vh.noSearchResults - : vh.empty; + const emptyMessage = filterQuery ? vh.noSearchResults : vh.empty; return ( -

    +
    @@ -228,52 +218,52 @@ export default function Home({ onNavigate }: ViewProps) {
    -
    - -
    -

    {vh.title}

    -

    {vh.subtitle}

    - -
    - - setFilterQuery(e.target.value)} - placeholder={vh.searchPlaceholder} - className="w-full rounded-full border border-default bg-brand-900/30 px-9 py-2 text-xs sm:text-sm text-foreground outline-none focus:border-blue-500 focus:ring-2 focus:ring-blue-500/20" - /> -
    - -
    -
    -

    {vh.totalBrandsLabel}

    - {portfolioLoading ? ( - - ) : ( -

    {portfolioTotals.totalBrands.toLocaleString()}

    - )} +
    +
    -
    -

    {vh.totalUrlsLabel}

    - {portfolioLoading ? ( - - ) : ( -

    {portfolioTotals.totalUrls.toLocaleString()}

    - )} +

    {vh.title}

    +

    {vh.subtitle}

    + +
    + + setFilterQuery(e.target.value)} + placeholder={vh.searchPlaceholder} + className="w-full rounded-full border border-default bg-brand-900/30 px-9 py-2 text-xs sm:text-sm text-foreground outline-none focus:border-blue-500 focus:ring-2 focus:ring-blue-500/20" + />
    -
    -

    {vh.avgHealthLabel}

    - {portfolioLoading ? ( - - ) : ( -

    - {portfolioTotals.avgHealth ?? sj.emDash} -

    - )} + +
    +
    +

    {vh.totalBrandsLabel}

    + {portfolioLoading ? ( + + ) : ( +

    {portfolioTotals.totalBrands.toLocaleString()}

    + )} +
    +
    +

    {vh.totalUrlsLabel}

    + {portfolioLoading ? ( + + ) : ( +

    {portfolioTotals.totalUrls.toLocaleString()}

    + )} +
    +
    +

    {vh.avgHealthLabel}

    + {portfolioLoading ? ( + + ) : ( +

    + {portfolioTotals.avgHealth ?? sj.emDash} +

    + )} +
    -
    {deleteError ? ( @@ -299,201 +289,59 @@ export default function Home({ onNavigate }: ViewProps) { {groupedPortfolio.map(({ rootDomain, items }) => { const collapsed = collapsedGroups.has(rootDomain); return ( -
    - - {!collapsed ? ( -
    - {items.map((group) => { - const cardKey = portfolioCardKey(group); - const confirmOpen = pendingDeleteKey === cardKey; - const isDeleting = deletingKey === cardKey; - return ( -
    - -
    -
    - - -
    - - {confirmOpen ? ( -
    -

    - {vh.deleteConfirmTitle} -

    -

    - {group.crawlOnly - ? format(vh.deleteConfirmCrawlOnly, { - name: group.domainName, - count: group.urlCount.toLocaleString(), - }) - : format(vh.deleteConfirmBody, { name: group.domainName })} -

    -
    - - -
    -
    - ) : null} - - - -
    -
    -
    -

    {vh.urlCountLabel}

    -

    {group.urlCount.toLocaleString()}

    -
    -
    -

    {vh.lastCrawlLabel}

    -

    {group.lastCrawl || sj.emDash}

    -
    -
    -
    - - + {!collapsed ? ( +
    -
    -

    {vh.statusBreakdownLabel}

    -
    - - {group.crawlOnly - ? format(vh.viewUrlsCta, { count: group.urlCount }) - : vh.openBrandCta} - -
    -
    -
    - - 2xx {group.statusCounts.s2xx} - - - 3xx {group.statusCounts.s3xx} - - - 4xx {group.statusCounts.s4xx} - - - 5xx {group.statusCounts.s5xx} - - {group.statusCounts.other > 0 && ( - - {format(vh.otherStatusPill, { count: group.statusCounts.other })} - - )} -
    - -
    - -
    - ); - })} -
    - ) : null} -
    - ); + {items.map((group) => { + const cardKey = portfolioCardKey(group); + return ( + { void openSite(group); }} + onDeleteToggle={() => { + setDeleteError(null); + setPendingDeleteKey(pendingDeleteKey === cardKey ? null : cardKey); + }} + onDeleteCancel={() => setPendingDeleteKey(null)} + onDeleteConfirm={() => { void handleDeletePortfolioItem(group); }} + /> + ); + })} +
    + ) : null} + + ); })}
    ) : ( diff --git a/web/src/views/Issues.tsx b/web/src/views/Issues.tsx index d2510194..f5f41835 100644 --- a/web/src/views/Issues.tsx +++ b/web/src/views/Issues.tsx @@ -1,11 +1,12 @@ -import { useState, useMemo } from 'react'; +import { useState, useMemo, useEffect } from 'react'; import { Bar, Doughnut } from 'react-chartjs-2'; import type { TooltipItem } from 'chart.js'; -import { AlertTriangle, AlertCircle, Info, ChevronDown, ChevronRight, ExternalLink, Flame, BarChart2, ListChecks } from 'lucide-react'; +import { AlertTriangle, AlertCircle, Info, ExternalLink, Flame, BarChart2, ListChecks } from 'lucide-react'; import { useReport } from '../context/useReport'; import { useOptionalPipeline } from '../context/PipelineContext'; import { strings, format } from '../lib/strings'; -import { PageLayout, PageHeader, Card, Badge, ViewTabs } from '../components'; +import { PageLayout, PageHeader, Card, Badge, ViewTabs, ViewTabPanel, Button } from '../components'; +import { paginateSlice, PAGE_SIZE } from '@/components/google/tableUtils'; import UrlInspectorButton from '@/components/UrlInspectorButton'; import IssueTaskBoard from '@/components/issues/IssueTaskBoard'; import IssueAiFixButton from '@/components/issues/IssueAiFixButton'; @@ -38,85 +39,56 @@ interface CategoryIssueItem { issue: ReportIssue; } -interface CategorySectionProps { - category: string; - items: CategoryIssueItem[]; - defaultOpen?: boolean; +interface IssueCardProps { + item: CategoryIssueItem; vi: (typeof strings.views)['issues']; emDash: string; } -function CategorySection({ category, items, defaultOpen = false, vi, emDash }: CategorySectionProps) { - const [open, setOpen] = useState(defaultOpen); +function IssueCard({ item, vi, emDash }: IssueCardProps) { + const iss = item.issue; + const p = normalizePriority(iss.priority); + const cfg = PRIORITY_CONFIG[p]; + const Icon = PRIORITY_ICONS[p]; return ( -
    - - {open && ( -
    - {items.map((item, i) => { - const iss = item.issue; - const p = normalizePriority(iss.priority); - const cfg = PRIORITY_CONFIG[p]; - const Icon = PRIORITY_ICONS[p]; - return ( -
    -
    -
    - - - {categoryDisplayName(item.category)} -
    -

    {iss.message || emDash}

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

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

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

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

    - ) : null} - -
    -
    - ); - })} +
    +
    +
    + + + {categoryDisplayName(item.category)}
    - )} +

    {iss.message || emDash}

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

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

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

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

    + ) : null} + +
    ); } @@ -126,11 +98,13 @@ export default function Issues({ searchQuery = '' }: ViewProps) { const pipeline = useOptionalPipeline(); const propertyId = Number(pipeline?.configState.active_property_id || 0) || null; const vi = strings.views.issues; + const vlp = vi.pagination; const sj = strings.common; const priorityOrder = PRIORITY_ORDER; const [issuesTab, setIssuesTab] = useState<'audit' | 'board'>('audit'); const [priorityFilter, setPriorityFilter] = useState(sj.all); - const [categoryFilter, setCategoryFilter] = useState(sj.all); + const [activeCategory, setActiveCategory] = useState(null); + const [issuePage, setIssuePage] = useState(1); const clicksByUrl = useMemo(() => { const map = new Map(); @@ -160,10 +134,7 @@ export default function Issues({ searchQuery = '' }: ViewProps) { }); }, [data, q]); - const forCharts = useMemo(() => { - if (categoryFilter === sj.all) return list; - return list.filter((item) => item.category === categoryFilter); - }, [list, categoryFilter, sj.all]); + const forCharts = list; const { categoryChartLabels, categoryChartValues } = useMemo(() => { const m = new Map(); @@ -210,15 +181,11 @@ export default function Issues({ searchQuery = '' }: ViewProps) { return acc; }, {}); - const categories = [...new Set(list.map((item) => item.category))].filter(Boolean).sort(); let filtered = list; if (priorityFilter !== sj.all) { filtered = filtered.filter((item) => (item.issue.priority || 'Medium') === priorityFilter); } - if (categoryFilter !== sj.all) { - filtered = filtered.filter((item) => item.category === categoryFilter); - } filtered.sort((a, b) => { const aImpact = Number(a.issue.impact_score) || 0; @@ -248,6 +215,36 @@ export default function Issues({ searchQuery = '' }: ViewProps) { return acc; }, {}); + const categoryTabs = useMemo( + () => + Object.entries(grouped) + .sort((a, b) => b[1].length - a[1].length) + .map(([cat, items]) => ({ + id: cat, + label: categoryDisplayName(cat), + badge: items.length, + })), + [grouped], + ); + + const resolvedCategory = + activeCategory && grouped[activeCategory] ? activeCategory : categoryTabs[0]?.id ?? ''; + + const activeItems = grouped[resolvedCategory] || []; + + const { + slice: visibleIssues, + page: safePage, + totalPages, + total: activeTotal, + from, + to, + } = useMemo(() => paginateSlice(activeItems, issuePage, PAGE_SIZE), [activeItems, issuePage]); + + useEffect(() => { + setIssuePage(1); + }, [resolvedCategory, priorityFilter, q]); + const categoryBarOpts = useMemo(() => { const base = barOptionsHorizontal(); return { @@ -412,19 +409,6 @@ export default function Issues({ searchQuery = '' }: ViewProps) { ); })} - - {categories.length > 1 && ( - - )}
    )} @@ -434,17 +418,56 @@ export default function Issues({ searchQuery = '' }: ViewProps) {

    {vi.noMatches}

    ) : ( -
    - {Object.entries(grouped).map(([cat, items], idx) => ( - + {categoryTabs.length > 1 ? ( + setActiveCategory(id)} + ariaLabel={vi.allCategories} + idPrefix="issues-category" /> - ))} + ) : null} + + {visibleIssues.map((item, i) => ( + + ))} + + {activeTotal > 0 ? ( +
    +
    +
    {format(vlp.showingSlice, { from, to, total: activeTotal })}
    +
    + {vlp.pageOf}{' '} + {safePage} {vlp.of}{' '} + {totalPages} + + ({format(vlp.rowsPerPage, { n: PAGE_SIZE })}) + +
    +
    + {totalPages > 1 ? ( +
    + + +
    + ) : null} +
    + ) : null}
    ))} diff --git a/web/src/views/JavaScriptErrors.tsx b/web/src/views/JavaScriptErrors.tsx index 0f3c6be9..dbb855e8 100644 --- a/web/src/views/JavaScriptErrors.tsx +++ b/web/src/views/JavaScriptErrors.tsx @@ -1,6 +1,6 @@ 'use client'; -import { useMemo, useState, Fragment } from 'react'; +import { useMemo, useState, useEffect, Fragment } from 'react'; import Link from 'next/link'; import { useSearchParams } from 'next/navigation'; import { useUrlTab } from '@/hooks/useUrlTab'; @@ -8,6 +8,7 @@ import { Bug, ChevronDown, ChevronRight, ExternalLink, BarChart3, List } from 'l import { useReport } from '../context/useReport'; import { strings, format } from '../lib/strings'; import { PageLayout, PageHeader, Card, Button, StatCard, Select, Table, TableHead, TableHeadCell, TableBody, TableRow, TableCell, ViewTabs, ViewTabPanel } from '../components'; +import { paginateSlice, PAGE_SIZE } from '@/components/google/tableUtils'; import type { ViewTabItem } from '../components'; import type { ViewProps } from '@/types'; import { @@ -32,9 +33,11 @@ export default function JavaScriptErrors({ searchQuery = '' }: ViewProps) { const trailingQuery = searchParams.toString() ? `?${searchParams.toString()}` : ''; const [typeFilter, setTypeFilter] = useState('All'); const [expandedRow, setExpandedRow] = useState(null); + const [errorsPage, setErrorsPage] = useState(1); const [activeTab, setActiveTab] = useUrlTab(JS_ERRORS_TABS, 'summary'); const vj = strings.views.javascriptErrors; + const vjp = vj.pagination; const q = (searchQuery || '').toLowerCase().trim(); const scopeInfo = useMemo(() => getBrowserDiagnosticsScope(data), [data]); @@ -65,6 +68,23 @@ export default function JavaScriptErrors({ searchQuery = '' }: ViewProps) { }); }, [allRows, typeFilter, q]); + const { + slice: visibleRows, + page: safeErrorsPage, + totalPages: errorsTotalPages, + total: filteredRowsTotal, + from: errorsFrom, + to: errorsTo, + } = useMemo( + () => paginateSlice(filteredRows, errorsPage, PAGE_SIZE), + [filteredRows, errorsPage], + ); + + useEffect(() => { + setErrorsPage(1); + setExpandedRow(null); + }, [typeFilter, q]); + const tabItems = useMemo((): ViewTabItem[] => [ { id: 'summary', @@ -219,88 +239,131 @@ export default function JavaScriptErrors({ searchQuery = '' }: ViewProps) { {filteredRows.length === 0 ? (

    {vj.emptyFiltered}

    ) : ( -
    - - - - - {vj.thUrl} - {vj.thType} - {vj.thMessage} - {vj.thSource} - {vj.thActions} - - - - {filteredRows.map((row) => { - const expanded = expandedRow === row.id; - const canExpand = row.type === 'exception' && Boolean(row.stack); - return ( - - - - {canExpand ? ( -
    + + + + {vj.thUrl} + {vj.thType} + {vj.thMessage} + {vj.thSource} + {vj.thActions} + + + + {visibleRows.map((row) => { + const expanded = expandedRow === row.id; + const canExpand = row.type === 'exception' && Boolean(row.stack); + return ( + + + + {canExpand ? ( + + ) : null} + + + - {expanded ? ( - - ) : ( - - )} - - ) : null} - - - - {row.url} - - - - - {row.type === 'console' ? vj.typeConsole : vj.typeException} - - -
    - {row.message} - -
    -
    - - {formatBrowserErrorSource(row.source_url, row.line)} - - - - {vj.viewDetails} - - -
    - {expanded && row.stack ? ( -
    - - - ) : null} - - ); - })} - -
    -
    -                                  {row.stack}
    -                                
    -
    -
    + {row.url} + + + + + {row.type === 'console' ? vj.typeConsole : vj.typeException} + + +
    + {row.message} + +
    +
    + + {formatBrowserErrorSource(row.source_url, row.line)} + + + + {vj.viewDetails} + + + + {expanded && row.stack ? ( + + +
    +                                    {row.stack}
    +                                  
    + + + ) : null} + + ); + })} + + +
    + {filteredRowsTotal > 0 ? ( +
    +
    +
    {format(vjp.showingSlice, { from: errorsFrom, to: errorsTo, total: filteredRowsTotal })}
    +
    + {vjp.pageOf}{' '} + {safeErrorsPage} {vjp.of}{' '} + {errorsTotalPages} + + ({format(vjp.rowsPerPage, { n: PAGE_SIZE })}) + +
    +
    + {errorsTotalPages > 1 ? ( +
    + + +
    + ) : null} +
    + ) : null} + )} diff --git a/web/src/views/Lighthouse.tsx b/web/src/views/Lighthouse.tsx index 9fb1cd32..4e715060 100644 --- a/web/src/views/Lighthouse.tsx +++ b/web/src/views/Lighthouse.tsx @@ -1,4 +1,4 @@ -import { useState, useMemo, useRef } from 'react'; +import { useState, useMemo, useRef, useEffect } from 'react'; import type { LighthouseDiagnostic, LighthouseFailure, @@ -18,7 +18,8 @@ import { } from '../lib/domainSlug'; import { goToPipeline } from '../lib/pipelineReturn'; import { strings, format } from '../lib/strings'; -import { PageLayout, PageHeader, Card, Button, ViewTabs, Select } from '../components'; +import { PageLayout, PageHeader, Card, Button, ViewTabs, ViewTabPanel, Select } from '../components'; +import { paginateSlice, PAGE_SIZE } from '@/components/google/tableUtils'; import type { ViewTabItem } from '../components'; import { CATEGORIES, CATEGORY_LABELS, METRIC_THRESHOLDS, IMPACT_GROUPS, QUICK_WINS, @@ -26,7 +27,7 @@ import { import { ScoreRing, ThresholdBar, - DiagnosticGroup, + DiagnosticItem, QuickWinCard, MultiPageTable, LhAuditExpandable, @@ -43,7 +44,10 @@ export default function Lighthouse({ searchQuery = '' }: ViewProps) { const { data, startUrlByRunId } = useReport(); const searchParams = useSearchParams(); const detailRef = useRef(null); + const pageDetailRef = useRef(null); const [activeTab, setActiveTab] = useUrlTab(LH_TABS, 'overview'); + const [activeImpactGroup, setActiveImpactGroup] = useState(null); + const [diagnosticPage, setDiagnosticPage] = useState(1); const expectedHost = useMemo(() => { const fromPayload = canonicalDomainFromPayload(data, startUrlByRunId); @@ -82,10 +86,14 @@ export default function Lighthouse({ searchQuery = '' }: ViewProps) { const handleSelectUrl = (url: string) => { setSelectedUrl(url); - setActiveTab('overview'); - setTimeout(() => detailRef.current?.scrollIntoView({ behavior: 'smooth', block: 'start' }), 50); + setTimeout(() => pageDetailRef.current?.scrollIntoView({ behavior: 'smooth', block: 'start' }), 50); }; + const selectedPageSummary = useMemo(() => { + if (!selectedUrl || !byUrl[selectedUrl]) return null; + return byUrl[selectedUrl]; + }, [selectedUrl, byUrl]); + const summary = useMemo(() => { if (displayUrl && byUrl[displayUrl]) return byUrl[displayUrl]; const global = data?.lighthouse_summary; @@ -194,6 +202,45 @@ export default function Lighthouse({ searchQuery = '' }: ViewProps) { return maxId; }, [groupedDiagnostics]); + const impactGroupTabs = useMemo( + () => + IMPACT_GROUPS.map((group) => ({ + group, + items: groupedDiagnostics[group.id] || [], + })) + .filter(({ items }) => items.length > 0) + .sort((a, b) => b.items.length - a.items.length) + .map(({ group, items }) => ({ + id: group.id, + label: group.label, + badge: items.length, + })), + [groupedDiagnostics], + ); + + const resolvedImpactGroup = + activeImpactGroup && (groupedDiagnostics[activeImpactGroup]?.length ?? 0) > 0 + ? activeImpactGroup + : impactGroupTabs.find((t) => t.id === mostCriticalGroup)?.id ?? impactGroupTabs[0]?.id ?? ''; + + const activeDiagnostics = groupedDiagnostics[resolvedImpactGroup] || []; + + const { + slice: visibleDiagnostics, + page: safeDiagnosticPage, + totalPages: diagnosticTotalPages, + total: activeDiagnosticTotal, + from: diagnosticFrom, + to: diagnosticTo, + } = useMemo( + () => paginateSlice(activeDiagnostics, diagnosticPage, PAGE_SIZE), + [activeDiagnostics, diagnosticPage], + ); + + useEffect(() => { + setDiagnosticPage(1); + }, [resolvedImpactGroup, q]); + const quickWinStatus = useMemo(() => { const allAuditIds = new Set( diagnosticsList.map((d) => d.lighthouse_audit_id || d.id).filter(Boolean) as string[], @@ -211,6 +258,7 @@ export default function Lighthouse({ searchQuery = '' }: ViewProps) { ); const vlh = strings.views.lighthouse; + const vlp = vlh.pagination; const tabLabels = vlh.tabs as Record; const lhTabItems = useMemo((): ViewTabItem[] => { @@ -269,7 +317,7 @@ export default function Lighthouse({ searchQuery = '' }: ViewProps) { if (!hasData) { return ( - + } title={vlh.emptyTitle} @@ -294,7 +342,7 @@ export default function Lighthouse({ searchQuery = '' }: ViewProps) { } return ( - +
    } @@ -393,8 +441,32 @@ export default function Lighthouse({ searchQuery = '' }: ViewProps) {

    {vlh.multiCompareHint}

    - + + {selectedPageSummary ? ( +
    +
    +

    + {vlh.categoriesSection} +

    +
    + {CATEGORIES.map(({ id, label }) => { + const pageCs = selectedPageSummary.category_scores || {}; + const score = pageCs[id] != null ? Number(pageCs[id]) : null; + return ; + })} +
    +
    + {(selectedPageSummary.human_summary_full || selectedPageSummary.human_summary) ? ( + +

    {vlh.summary}

    +
    +                    {selectedPageSummary.human_summary_full || selectedPageSummary.human_summary}
    +                  
    +
    + ) : null} +
    + ) : null}
    )} @@ -449,19 +521,56 @@ export default function Lighthouse({ searchQuery = '' }: ViewProps) { ) : diagnosticsForGroups.length === 0 ? ( {vlh.noDiagnosticsSearch} ) : ( -
    - {IMPACT_GROUPS.map((group) => { - const items = groupedDiagnostics[group.id] || []; - if (items.length === 0) return null; - return ( - - ); - })} +
    + {impactGroupTabs.length > 1 ? ( + setActiveImpactGroup(id)} + ariaLabel={vlh.diagnostics} + idPrefix="lh-diagnostics" + /> + ) : null} + + {visibleDiagnostics.map((d, i) => ( + + ))} + + {activeDiagnosticTotal > 0 ? ( +
    +
    +
    {format(vlp.showingSlice, { from: diagnosticFrom, to: diagnosticTo, total: activeDiagnosticTotal })}
    +
    + {vlp.pageOf}{' '} + {safeDiagnosticPage} {vlp.of}{' '} + {diagnosticTotalPages} + + ({format(vlp.rowsPerPage, { n: PAGE_SIZE })}) + +
    +
    + {diagnosticTotalPages > 1 ? ( +
    + + +
    + ) : null} +
    + ) : null}
    )}
    diff --git a/web/src/views/Links.tsx b/web/src/views/Links.tsx index ec403441..fbca9033 100644 --- a/web/src/views/Links.tsx +++ b/web/src/views/Links.tsx @@ -38,6 +38,7 @@ import { exportLinksCsv } from '@/utils/linkExport'; import { useOptionalPipeline } from '../context/PipelineContext'; import AiSuggestionButton from '@/components/ai/AiSuggestionButton'; import { buildTechnicalLinkIssueContext } from '@/lib/fixSuggestionContext'; +import { crawledUrlCount } from '@/lib/crawlCounts'; const EXPLORER_TABS = ['urls', 'anchors'] as const; type ExplorerTabId = (typeof EXPLORER_TABS)[number]; @@ -97,6 +98,7 @@ export default function Links({ searchQuery = '' }: ViewProps) { const tableRef = useRef(null); const links = useMemo(() => data?.links || [], [data]); + const crawledCount = useMemo(() => crawledUrlCount(data), [data]); const hasLinkAttributes = Boolean( data?.link_rel_summary || (data?.inlink_anchor_matrix?.length ?? 0) > 0, @@ -133,7 +135,7 @@ export default function Links({ searchQuery = '' }: ViewProps) { id: 'urls', label: vl.tabs.urls, icon: , - badge: links.length > 0 ? links.length : null, + badge: crawledCount > 0 ? crawledCount : null, }, ]; if (hasLinkAttributes) { @@ -145,7 +147,7 @@ export default function Links({ searchQuery = '' }: ViewProps) { }); } return items; - }, [vl.tabs, links.length, hasLinkAttributes, data?.inlink_anchor_matrix?.length]); + }, [vl.tabs, crawledCount, hasLinkAttributes, data?.inlink_anchor_matrix?.length]); const inspectParam = searchParams.get('inspect'); const tabParam = searchParams.get('tab'); diff --git a/web/src/views/Security.tsx b/web/src/views/Security.tsx index 084ffe05..35984ee9 100644 --- a/web/src/views/Security.tsx +++ b/web/src/views/Security.tsx @@ -1,11 +1,12 @@ -import { useState, useMemo } from 'react'; +import { useState, useMemo, useEffect } from 'react'; import { useUrlTab } from '@/hooks/useUrlTab'; import { Bar, Doughnut } from 'react-chartjs-2'; import type { TooltipItem } from 'chart.js'; import { Shield, Flame, AlertTriangle, AlertCircle, Info, ExternalLink, BarChart3, List } from 'lucide-react'; import { useReport } from '../context/useReport'; import { strings, format } from '../lib/strings'; -import { PageLayout, PageHeader, Card, Badge, ViewTabs, ViewTabPanel } from '../components'; +import { PageLayout, PageHeader, Card, Badge, ViewTabs, ViewTabPanel, Button } from '../components'; +import { paginateSlice, PAGE_SIZE } from '@/components/google/tableUtils'; import type { ViewTabItem } from '../components'; import { palette } from '../utils/chartPalette'; import { registerChartJsBase, barOptionsHorizontal } from '../utils/chartJsDefaults'; @@ -97,6 +98,7 @@ export default function Security({ searchQuery = '' }: ViewProps) { const { data } = useReport(); const [severityFilter, setSeverityFilter] = useState('All'); const [activeTab, setActiveTab] = useUrlTab(SECURITY_TABS, 'findings'); + const [findingsPage, setFindingsPage] = useState(1); const q = (searchQuery || '').toLowerCase().trim(); @@ -154,6 +156,44 @@ export default function Security({ searchQuery = '' }: ViewProps) { }, []); const vs = strings.views.security; + const vsp = vs.pagination; + + const filteredFindings = useMemo(() => { + let list: SecurityFinding[] = allFindings; + if (severityFilter !== 'All') { + list = list.filter((f) => (f.severity || 'Info') === severityFilter); + } + if (q) { + list = list.filter((f) => { + const url = (f.url || '').toLowerCase(); + const msg = (f.message || '').toLowerCase(); + const rec = (f.recommendation || '').toLowerCase(); + const typ = securityFindingLabel(f.finding_type).toLowerCase(); + return url.includes(q) || msg.includes(q) || rec.includes(q) || typ.includes(q); + }); + } + return [...list].sort((a, b) => { + const ao = (SEVERITY_CONFIG[(a.severity || 'Info') as SeverityKey] ?? SEVERITY_CONFIG.Info).order; + const bo = (SEVERITY_CONFIG[(b.severity || 'Info') as SeverityKey] ?? SEVERITY_CONFIG.Info).order; + return ao - bo; + }); + }, [allFindings, severityFilter, q]); + + const { + slice: visibleFindings, + page: safeFindingsPage, + totalPages: findingsTotalPages, + total: filteredFindingsTotal, + from: findingsFrom, + to: findingsTo, + } = useMemo( + () => paginateSlice(filteredFindings, findingsPage, PAGE_SIZE), + [filteredFindings, findingsPage], + ); + + useEffect(() => { + setFindingsPage(1); + }, [severityFilter, q]); const tabItems = useMemo((): ViewTabItem[] => { const chartCount = allFindings.length > 0 ? (typeLabels.length > 0 ? 2 : 1) : 0; @@ -180,26 +220,6 @@ export default function Security({ searchQuery = '' }: ViewProps) { return acc; }, {}); - let findings: SecurityFinding[] = allFindings; - if (severityFilter !== 'All') { - findings = findings.filter((f) => (f.severity || 'Info') === severityFilter); - } - if (q) { - findings = findings.filter((f) => { - const url = (f.url || '').toLowerCase(); - const msg = (f.message || '').toLowerCase(); - const rec = (f.recommendation || '').toLowerCase(); - const typ = securityFindingLabel(f.finding_type).toLowerCase(); - return url.includes(q) || msg.includes(q) || rec.includes(q) || typ.includes(q); - }); - } - - findings = [...findings].sort((a, b) => { - const ao = (SEVERITY_CONFIG[(a.severity || 'Info') as SeverityKey] ?? SEVERITY_CONFIG.Info).order; - const bo = (SEVERITY_CONFIG[(b.severity || 'Info') as SeverityKey] ?? SEVERITY_CONFIG.Info).order; - return ao - bo; - }); - return ( )} - {findings.length === 0 ? ( + {filteredFindings.length === 0 ? (
    @@ -318,49 +338,86 @@ export default function Security({ searchQuery = '' }: ViewProps) {
    ) : ( -
    - {findings.map((f, i) => { - const sev = (f.severity || 'Info') as SeverityKey; - const cfg = SEVERITY_CONFIG[sev] ?? SEVERITY_CONFIG.Info; - const Icon = cfg.icon; - return ( -
    -
    -
    - - +
    +
    + {visibleFindings.map((f, i) => { + const sev = (f.severity || 'Info') as SeverityKey; + const cfg = SEVERITY_CONFIG[sev] ?? SEVERITY_CONFIG.Info; + const Icon = cfg.icon; + return ( +
    +
    +
    + + +
    + + {securityFindingLabel(f.finding_type)} + + {f.url && ( + + {f.url} + + + )}
    - - {securityFindingLabel(f.finding_type)} - - {f.url && ( - - {f.url} - - +

    {f.message || strings.common.emDash}

    + {f.recommendation && ( +
    + + {vs.recommendation} + + {f.recommendation} +
    )} + +
    + ); + })} +
    + {filteredFindingsTotal > 0 ? ( +
    +
    +
    {format(vsp.showingSlice, { from: findingsFrom, to: findingsTo, total: filteredFindingsTotal })}
    +
    + {vsp.pageOf}{' '} + {safeFindingsPage} {vsp.of}{' '} + {findingsTotalPages} + + ({format(vsp.rowsPerPage, { n: PAGE_SIZE })}) +
    -

    {f.message || strings.common.emDash}

    - {f.recommendation && ( -
    - - {vs.recommendation} - - {f.recommendation} -
    - )} -
    - ); - })} + {findingsTotalPages > 1 ? ( +
    + + +
    + ) : null} +
    + ) : null}
    )} diff --git a/web/src/views/TextContentAnalysis.tsx b/web/src/views/TextContentAnalysis.tsx new file mode 100644 index 00000000..5d231508 --- /dev/null +++ b/web/src/views/TextContentAnalysis.tsx @@ -0,0 +1,757 @@ +'use client'; + +import type { Chart, TooltipItem } from 'chart.js'; +import { Fragment, useState, useMemo, useEffect, type ComponentType, type ReactNode } from 'react'; +import { useUrlTab } from '@/hooks/useUrlTab'; +import type { + ContentAnalyticsData, + TextContentAnalysisData, + TextContentKeywordEntry, + TopicCluster, + ViewProps, +} from '@/types'; +import { filterTopicClusters } from '@/lib/semanticTextHygiene'; +import { buildByPageTextRows } from '@/lib/textContentAnalysis'; +import { anyChartOptions } from '../utils/chartOptions'; +import { Chart as ChartJS, CategoryScale, LinearScale, BarElement, Title, Tooltip, Legend } from 'chart.js'; +import { Bar } from 'react-chartjs-2'; +import { + BookOpen, + FileText, + BarChart2, + Tag, + Layers, + Sparkles, + ChevronDown, + ChevronRight, + LayoutDashboard, + Key, + AlignLeft, + Globe, +} from 'lucide-react'; +import { useReport } from '../context/useReport'; +import { strings, format } from '../lib/strings'; +import { + PageLayout, + PageHeader, + Card, + Table, + TableHead, + TableHeadCell, + TableBody, + TableRow, + TableCell, + ViewTabs, + ViewTabPanel, + Button, +} from '../components'; +import type { ViewTabItem } from '../components'; +import SortablePaginatedTable from '../components/google/SortablePaginatedTable'; +import { PAGE_SIZE, paginateSlice } from '../components/google/tableUtils'; +import { palette, PALETTE_CATEGORICAL } from '../utils/chartPalette'; +import { + getGridColor, + getChartTitleColor, + getChartCanvasTextColor, +} from '../utils/chartJsDefaults'; + +ChartJS.register(CategoryScale, LinearScale, BarElement, Title, Tooltip, Legend); + +const TEXT_TABS = ['overview', 'keywords', 'analytics', 'topics'] as const; +type TextTabId = (typeof TEXT_TABS)[number]; + +const EMPTY_CA: ContentAnalyticsData = {}; +const EMPTY_TCA: TextContentAnalysisData = {}; + +const barValueLabelsPlugin = { + id: 'tcaBarLabels', + afterDatasetsDraw(chart: Chart) { + const ctx = chart.ctx; + const isHorizontal = chart.options.indexAxis === 'y'; + const pad = 6; + ctx.save(); + ctx.font = '11px system-ui, sans-serif'; + ctx.textBaseline = 'middle'; + (chart.data.datasets || []).forEach((dataset, dsi: number) => { + const meta = chart.getDatasetMeta(dsi); + if (!meta?.data?.length || !dataset?.data) return; + meta.data.forEach((bar, i: number) => { + const value = dataset.data[i]; + if (value == null || value === 0) return; + const label = Number(value).toLocaleString(); + if (isHorizontal) { + const textWidth = ctx.measureText(label).width; + const fitsOutside = bar.x + pad + textWidth <= chart.chartArea.right; + if (fitsOutside) { + ctx.textAlign = 'left'; + ctx.fillStyle = getChartCanvasTextColor(); + ctx.fillText(label, bar.x + pad, bar.y); + } else { + ctx.textAlign = 'right'; + ctx.fillStyle = '#ffffff'; + ctx.fillText(label, bar.x - pad, bar.y); + } + } else { + ctx.textAlign = 'center'; + ctx.fillStyle = getChartCanvasTextColor(); + ctx.fillText(label, bar.x, bar.y - 12); + } + }); + }); + ctx.restore(); + }, +}; + +function barOpts(xTitle?: string) { + const pagesWord = strings.common.pages; + return anyChartOptions({ + responsive: true, + maintainAspectRatio: false, + plugins: { legend: { display: false } }, + scales: { + x: { grid: { color: getGridColor() }, ...(xTitle ? { title: { display: true, text: xTitle } } : {}) }, + y: { grid: { color: getGridColor() }, beginAtZero: true, title: { display: true, text: pagesWord } }, + }, + }); +} + +function barOptsH(xTitle?: string) { + const freq = strings.charts.axisFrequency; + return anyChartOptions({ + indexAxis: 'y', + responsive: true, + maintainAspectRatio: false, + plugins: { legend: { display: false } }, + scales: { + x: { + grid: { color: getGridColor() }, + beginAtZero: true, + grace: '10%', + title: { display: true, text: xTitle ?? freq }, + }, + y: { grid: { color: getGridColor() } }, + }, + }); +} + +function SectionHeader({ + icon, + title, + description, +}: { + icon: ComponentType<{ className?: string }>; + title: ReactNode; + description?: ReactNode; +}) { + const Icon = icon; + return ( +
    +
    + +
    +
    +

    {title}

    + {description ?

    {description}

    : null} +
    +
    + ); +} + +function KeywordIndexTable({ + rows, + vtca, + sj, +}: { + rows: TextContentKeywordEntry[]; + vtca: (typeof strings.views)['textContentAnalysis']; + sj: typeof strings.common; +}) { + const [expanded, setExpanded] = useState>(new Set()); + + const toggle = (word: string) => { + setExpanded((prev) => { + const next = new Set(prev); + if (next.has(word)) next.delete(word); + else next.add(word); + return next; + }); + }; + + if (rows.length === 0) { + return

    {vtca.noKeywordData}

    ; + } + + return ( +
    + + + + + {vtca.thWord} + {vtca.thTotalCount} + {vtca.thPageCount} + + + + {rows.map((row) => { + const hasPages = (row.top_pages?.length ?? 0) > 0; + const isOpen = expanded.has(row.word); + return ( + + + + {hasPages ? ( + + ) : null} + + {row.word} + {row.total_count.toLocaleString()} + {row.page_count.toLocaleString()} + + {isOpen && hasPages ? ( + + + + ) : null} + + ); + })} + +
    +
    + {vtca.thTopPages} +
    +
      + {row.top_pages!.map((p) => ( +
    • + + {p.url} + + {p.count} +
    • + ))} +
    +
    +
    + ); +} + +export default function TextContentAnalysis({ searchQuery = '' }: ViewProps) { + const vtca = strings.views.textContentAnalysis; + const sj = strings.common; + const ch = strings.charts; + const { data } = useReport(); + const [activeTab, setActiveTab] = useUrlTab(TEXT_TABS, 'overview'); + const [keywordsChartPage, setKeywordsChartPage] = useState(1); + + const tca: TextContentAnalysisData = data?.text_content_analysis ?? EMPTY_TCA; + const ca: ContentAnalyticsData = data?.content_analytics ?? EMPTY_CA; + const vocab = tca.vocabulary_stats ?? {}; + const wcStats = ca.word_count_stats ?? {}; + const keywordIndex = tca.keyword_index ?? []; + + const keywordIndexFiltered = useMemo(() => { + const q = (searchQuery || '').trim().toLowerCase(); + if (!q) return keywordIndex; + return keywordIndex.filter( + (k) => + k.word.toLowerCase().includes(q) || + (k.top_pages ?? []).some((p) => p.url.toLowerCase().includes(q)), + ); + }, [keywordIndex, searchQuery]); + + const keywordsChartPagination = useMemo( + () => paginateSlice(keywordIndexFiltered, keywordsChartPage, PAGE_SIZE), + [keywordIndexFiltered, keywordsChartPage], + ); + + useEffect(() => { + setKeywordsChartPage(1); + }, [keywordIndexFiltered]); + + useEffect(() => { + setKeywordsChartPage((p) => Math.min(Math.max(1, p), keywordsChartPagination.totalPages)); + }, [keywordsChartPagination.totalPages]); + + const keywordsChart = useMemo(() => { + const slice = keywordsChartPagination.slice; + if (slice.length === 0) return null; + return { + labels: slice.map((k) => k.word), + values: slice.map((k) => k.total_count), + }; + }, [keywordsChartPagination.slice]); + + const keywordsChartHeightPx = keywordsChart + ? Math.max(320, keywordsChart.labels.length * 28) + : 320; + + const histChart = useMemo(() => { + const hist = tca.keyword_frequency_histogram; + if (!hist) return null; + const labels = [vtca.histBucket1, vtca.histBucket2, vtca.histBucket6, vtca.histBucket21]; + const keys = ['1', '2-5', '6-20', '21+']; + const values = keys.map((k) => Number(hist[k]) || 0); + if (values.every((v) => v === 0)) return null; + return { labels, values }; + }, [tca.keyword_frequency_histogram, vtca]); + + const byPageRows = useMemo( + () => buildByPageTextRows(data?.links, searchQuery), + [data?.links, searchQuery], + ); + + const languageMlChart = useMemo(() => { + const c = data?.language_summary?.counts || {}; + const entries = Object.entries(c) + .sort((a, b) => Number(b[1]) - Number(a[1])) + .slice(0, 15); + if (entries.length === 0) return null; + return { labels: entries.map((x) => x[0]), values: entries.map((x) => Number(x[1])) }; + }, [data?.language_summary?.counts]); + + const nerSiteChart = useMemo(() => { + const lc = data?.ner_site_summary?.label_counts; + if (!lc || typeof lc !== 'object') return null; + const entries = Object.entries(lc) + .sort((a, b) => Number(b[1]) - Number(a[1])) + .slice(0, 15); + if (entries.length === 0) return null; + return { labels: entries.map((x) => x[0]), values: entries.map((x) => Number(x[1])) }; + }, [data?.ner_site_summary?.label_counts]); + + const tokenClusters = useMemo( + () => filterTopicClusters(data?.keyword_opportunities?.token_topic_clusters ?? []), + [data?.keyword_opportunities?.token_topic_clusters], + ); + + const semanticClusters = useMemo( + () => filterTopicClusters(data?.semantic_keyword_clusters ?? []), + [data?.semantic_keyword_clusters], + ); + + const wcDist = ca.word_count_distribution ?? {}; + const rlDist = ca.reading_level_distribution ?? {}; + const crDist = ca.content_ratio_distribution ?? {}; + const wcLabels = Object.keys(wcDist); + const wcValues = Object.values(wcDist).map(Number); + const rlLabels = Object.keys(rlDist); + const rlValues = Object.values(rlDist).map(Number); + const crLabels = Object.keys(crDist); + const crValues = Object.values(crDist).map(Number); + + const wcPercLabels = vtca.wcPercLabels; + const wcPercRaw = [wcStats.min, wcStats.p25, wcStats.median, wcStats.mean, wcStats.p75, wcStats.max]; + const wcPercValues = wcPercRaw.map((v) => (v != null && !Number.isNaN(Number(v)) ? Number(v) : null)); + const hasWcPercBar = wcPercValues.every((v) => v != null) && (wcStats.max ?? 0) > 0; + + const tabItems = useMemo((): ViewTabItem[] => [ + { id: 'overview', label: vtca.tabs.overview, icon: }, + { id: 'keywords', label: vtca.tabs.keywords, icon: }, + { id: 'analytics', label: vtca.tabs.analytics, icon: }, + { id: 'topics', label: vtca.tabs.topics, icon: }, + ], [vtca.tabs]); + + const byPageColumns = useMemo( + () => [ + { key: 'url', label: vtca.thUrl }, + { key: 'word_count', label: vtca.thWords }, + { key: 'reading_level', label: vtca.thReading }, + { key: 'top_terms', label: vtca.thTopTerms }, + ], + [vtca], + ); + + if (!data) return null; + + return ( + + + + setActiveTab(id as TextTabId)} + ariaLabel={vtca.title} + idPrefix="text-content-analysis" + /> + + {activeTab === 'overview' && ( + +
    + +
    {vtca.uniqueTerms}
    +
    {vocab.unique_terms ?? sj.emDash}
    +
    + +
    {vtca.pagesWithKeywords}
    +
    {vocab.pages_with_keywords ?? sj.emDash}
    +
    + +
    + {vtca.meanWords} +
    +
    + {wcStats.mean != null ? Math.round(wcStats.mean).toLocaleString() : sj.emDash} +
    +
    {vtca.perPage}
    +
    + +
    + {vtca.medianWords} +
    +
    + {wcStats.median != null ? Math.round(wcStats.median).toLocaleString() : sj.emDash} +
    +
    {vtca.perPage}
    +
    +
    + +
    + +
    {vtca.avgTermsPerPage}
    +
    {vocab.avg_terms_per_page ?? sj.emDash}
    +
    + +
    {vtca.totalOccurrences}
    +
    + {vocab.total_term_occurrences != null ? vocab.total_term_occurrences.toLocaleString() : sj.emDash} +
    +
    +
    + +
    + + +
    +
    + )} + + {activeTab === 'keywords' && ( + + {histChart ? ( + +
    + +

    {vtca.keywordFrequencyHist}

    +
    +
    + +
    +
    + ) : null} + + + + +
    + )} + + {activeTab === 'analytics' && ( + + {keywordsChart ? ( + +
    +
    + +

    {vtca.topKeywordsChart}

    +
    +

    + {keywordsChartPagination.total.toLocaleString()} terms +

    +
    +
    + +
    + {keywordsChartPagination.total > 0 ? ( +
    +
    +
    + {format(vtca.pagination.showingSlice, { + from: keywordsChartPagination.from, + to: keywordsChartPagination.to, + total: keywordsChartPagination.total, + })} +
    +
    + {vtca.pagination.pageOf}{' '} + {keywordsChartPagination.page}{' '} + {vtca.pagination.of}{' '} + {keywordsChartPagination.totalPages} + + ({format(vtca.pagination.rowsPerPage, { n: PAGE_SIZE })}) + +
    +
    + {keywordsChartPagination.totalPages > 1 ? ( +
    + + +
    + ) : null} +
    + ) : null} +
    + ) : ( +

    {vtca.noKeywordData}

    + )} + + +
    + +

    {vtca.wordCountDist}

    +
    + {wcLabels.length > 0 ? ( + + ) : ( +
    {sj.noData}
    + )} +
    +
    + + +

    {vtca.readingLevelDist}

    +
    + {rlLabels.length > 0 ? ( + ) => ` ${ctx.raw} pages` } }, + }, + }} + plugins={[barValueLabelsPlugin]} + /> + ) : ( +
    {sj.noData}
    + )} +
    +
    + + +

    {vtca.contentHtmlRatio}

    +
    + {crLabels.length > 0 ? ( + + ) : ( +
    {sj.noData}
    + )} +
    +
    + + {hasWcPercBar ? ( + +

    {vtca.wordCountLadder}

    +
    + +
    +
    + ) : null} +
    +
    + )} + + {activeTab === 'topics' && ( + + {languageMlChart ? ( + +
    + +

    {vtca.languageMix}

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

    {vtca.entityLabels}

    +
    +
    + +
    +
    + ) : null} + + {tokenClusters.length > 0 ? ( + +
    + +

    {vtca.parentTopicsToken}

    +
    +
    + + + + {vtca.thRepresentative} + {vtca.thClusterScore} + {vtca.thKeywords} + + + + {tokenClusters.map((cl: TopicCluster, idx: number) => ( + + + {String(cl.top_keyword ?? cl.representative ?? '')} + + + {String(cl.cluster_score ?? sj.emDash)} + + + {Array.isArray(cl.keywords) ? cl.keywords.join(', ') : sj.emDash} + + + ))} + +
    +
    +
    + ) : null} + + {semanticClusters.length > 0 ? ( + +
    + +

    {vtca.parentTopicsSemantic}

    +
    +
    + + + + {vtca.thRepresentative} + {vtca.thClusterScore} + {vtca.thKeywords} + + + + {semanticClusters.map((cl: TopicCluster, idx: number) => ( + + + {String(cl.top_keyword ?? cl.representative ?? '')} + + + {String(cl.cluster_score ?? sj.emDash)} + + + {Array.isArray(cl.keywords) ? cl.keywords.join(', ') : sj.emDash} + + + ))} + +
    +
    +
    + ) : null} + + {!languageMlChart && !nerSiteChart && tokenClusters.length === 0 && semanticClusters.length === 0 ? ( +

    {sj.noData}

    + ) : null} +
    + )} +
    + ); +}