katman-mcp 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (97) hide show
  1. package/LICENSE +13 -0
  2. package/README.md +160 -0
  3. package/dist/cli.js +71 -0
  4. package/dist/context.js +43 -0
  5. package/dist/crawlers.js +86 -0
  6. package/dist/index.js +25 -0
  7. package/dist/license.js +448 -0
  8. package/dist/meta.js +28 -0
  9. package/dist/net.js +128 -0
  10. package/dist/server.js +17 -0
  11. package/dist/state.js +81 -0
  12. package/dist/tools/account/account.js +64 -0
  13. package/dist/tools/account/cli.js +21 -0
  14. package/dist/tools/account/index.js +48 -0
  15. package/dist/tools/account/sync.js +142 -0
  16. package/dist/tools/account/upsell.js +290 -0
  17. package/dist/tools/crawl/audit/analyze.js +719 -0
  18. package/dist/tools/crawl/audit/bots.js +114 -0
  19. package/dist/tools/crawl/audit/checks.js +126 -0
  20. package/dist/tools/crawl/audit/html.js +382 -0
  21. package/dist/tools/crawl/audit/index.js +364 -0
  22. package/dist/tools/crawl/audit/model.js +1 -0
  23. package/dist/tools/crawl/audit/report.js +199 -0
  24. package/dist/tools/crawl/audit/robots.js +199 -0
  25. package/dist/tools/crawl/audit/sitemap.js +147 -0
  26. package/dist/tools/crawl/cli.js +106 -0
  27. package/dist/tools/crawl/common/advice.js +61 -0
  28. package/dist/tools/crawl/common/findings.js +57 -0
  29. package/dist/tools/crawl/common/routes.js +80 -0
  30. package/dist/tools/crawl/common/text.js +89 -0
  31. package/dist/tools/crawl/common/urls.js +64 -0
  32. package/dist/tools/crawl/index.js +226 -0
  33. package/dist/tools/crawl/indexnow.js +271 -0
  34. package/dist/tools/crawl/keywords.js +178 -0
  35. package/dist/tools/crawl/scan/framework.js +645 -0
  36. package/dist/tools/crawl/scan/index.js +175 -0
  37. package/dist/tools/crawl/scan/project.js +180 -0
  38. package/dist/tools/crawl/scan/routes.js +354 -0
  39. package/dist/tools/crawl/scan/signals.js +414 -0
  40. package/dist/tools/method/cli.js +42 -0
  41. package/dist/tools/method/core/brief.js +302 -0
  42. package/dist/tools/method/core/extract.js +435 -0
  43. package/dist/tools/method/core/generate.js +504 -0
  44. package/dist/tools/method/core/lint.js +556 -0
  45. package/dist/tools/method/core/plan.js +405 -0
  46. package/dist/tools/method/core/prompt.js +150 -0
  47. package/dist/tools/method/core/questions.js +196 -0
  48. package/dist/tools/method/core/report.js +182 -0
  49. package/dist/tools/method/core/research-run.js +148 -0
  50. package/dist/tools/method/core/research.js +448 -0
  51. package/dist/tools/method/core/snippets.js +292 -0
  52. package/dist/tools/method/core/start.js +277 -0
  53. package/dist/tools/method/data/anatomy.js +141 -0
  54. package/dist/tools/method/data/appendices.js +202 -0
  55. package/dist/tools/method/data/cloudflare.js +192 -0
  56. package/dist/tools/method/data/evidence.js +137 -0
  57. package/dist/tools/method/data/geo-jobs.js +128 -0
  58. package/dist/tools/method/data/index.js +19 -0
  59. package/dist/tools/method/data/landing.js +67 -0
  60. package/dist/tools/method/data/lessons.js +127 -0
  61. package/dist/tools/method/data/measurement.js +142 -0
  62. package/dist/tools/method/data/method.js +141 -0
  63. package/dist/tools/method/data/offsite.js +148 -0
  64. package/dist/tools/method/data/pages.js +167 -0
  65. package/dist/tools/method/data/prompts.js +633 -0
  66. package/dist/tools/method/data/publish.js +113 -0
  67. package/dist/tools/method/data/render.js +117 -0
  68. package/dist/tools/method/data/research.js +131 -0
  69. package/dist/tools/method/data/roadmap.js +105 -0
  70. package/dist/tools/method/data/traps.js +74 -0
  71. package/dist/tools/method/data/verify.js +88 -0
  72. package/dist/tools/method/data/viral-list.js +39 -0
  73. package/dist/tools/method/index.js +209 -0
  74. package/dist/tools/method/lib/calls.js +53 -0
  75. package/dist/tools/method/lib/framework.js +525 -0
  76. package/dist/tools/method/lib/l10n.js +17 -0
  77. package/dist/tools/method/lib/routes.js +118 -0
  78. package/dist/tools/method/lib/state.js +454 -0
  79. package/dist/tools/method/lib/text.js +165 -0
  80. package/dist/tools/method/mcp/prompts.js +79 -0
  81. package/dist/tools/method/mcp/resources.js +50 -0
  82. package/dist/tools/visibility/analyze.js +463 -0
  83. package/dist/tools/visibility/cli.js +86 -0
  84. package/dist/tools/visibility/estimate.js +105 -0
  85. package/dist/tools/visibility/index.js +108 -0
  86. package/dist/tools/visibility/judge.js +156 -0
  87. package/dist/tools/visibility/manual.js +275 -0
  88. package/dist/tools/visibility/models.js +277 -0
  89. package/dist/tools/visibility/monitoring.js +165 -0
  90. package/dist/tools/visibility/openrouter.js +449 -0
  91. package/dist/tools/visibility/render.js +295 -0
  92. package/dist/tools/visibility/run.js +684 -0
  93. package/dist/tools/visibility/summary.js +164 -0
  94. package/dist/types.js +3 -0
  95. package/dist/util.js +92 -0
  96. package/package.json +54 -0
  97. package/server.json +34 -0
@@ -0,0 +1,114 @@
1
+ // AI-bot access test: fetch the homepage with each crawler's user agent and compare with a browser.
2
+ // Spoofed user agents only exercise UA-based rules; IP-verified bot rules (e.g. Cloudflare's verified-bot
3
+ // categories, "fake Googlebot" rules) can behave differently for the real crawler.
4
+ import { BROWSER_UA, CRAWLERS } from "../../../crawlers.js";
5
+ import { mapLimit } from "../../../net.js";
6
+ export function detectCdn(h) {
7
+ const server = (h["server"] ?? "").toLowerCase();
8
+ const via = (h["via"] ?? "").toLowerCase();
9
+ if (h["cf-ray"] || server.includes("cloudflare"))
10
+ return "cloudflare";
11
+ if (h["x-vercel-id"] || server.includes("vercel"))
12
+ return "vercel";
13
+ if (h["x-nf-request-id"] || server.includes("netlify"))
14
+ return "netlify";
15
+ if (h["x-amz-cf-id"] || via.includes("cloudfront"))
16
+ return "cloudfront";
17
+ if (h["x-fastly-request-id"] || server.includes("fastly") || /^cache-[a-z]{3}/i.test(h["x-served-by"] ?? ""))
18
+ return "fastly";
19
+ if (server.includes("akamai") || h["akamai-grn"] || h["x-akamai-transformed"])
20
+ return "akamai";
21
+ if (h["x-sucuri-id"] || server.includes("sucuri"))
22
+ return "sucuri";
23
+ if (h["x-iinfo"] || (h["x-cdn"] ?? "").toLowerCase().includes("incapsula"))
24
+ return "imperva";
25
+ if (h["fly-request-id"] || server.startsWith("fly/"))
26
+ return "fly";
27
+ if (h["x-render-origin-server"] || server === "render")
28
+ return "render";
29
+ if (h["x-github-request-id"] || server.includes("github.com"))
30
+ return "github-pages";
31
+ if (h["x-railway-request-id"] || server.includes("railway"))
32
+ return "railway";
33
+ if (server.includes("bunnycdn"))
34
+ return "bunny";
35
+ if (h["x-firebase-hosting-version"] || server.includes("firebase"))
36
+ return "firebase";
37
+ return undefined;
38
+ }
39
+ const MARKERS = [
40
+ [/<title>\s*just a moment/i, 'Cloudflare challenge page ("Just a moment…")'],
41
+ [/attention required!\s*\|\s*cloudflare/i, 'Cloudflare block page ("Attention Required")'],
42
+ [/sorry, you have been blocked/i, "Cloudflare WAF block page"],
43
+ [/cf-chl-|cf_chl_opt|\/cdn-cgi\/challenge-platform/i, "Cloudflare challenge script"],
44
+ [/enable javascript and cookies to continue/i, "JavaScript + cookie challenge"],
45
+ [/checking (if the site connection is secure|your browser)/i, "browser check page"],
46
+ [/vercel security checkpoint/i, "Vercel Security Checkpoint"],
47
+ [/captcha-delivery\.com/i, "DataDome captcha"],
48
+ [/sucuri website firewall/i, "Sucuri firewall block"],
49
+ [/incapsula incident id|_incapsula_resource/i, "Imperva/Incapsula block"],
50
+ [/px-captcha|perimeterx/i, "HUMAN/PerimeterX challenge"],
51
+ [/awswaf|aws-waf-token/i, "AWS WAF challenge"],
52
+ [/<title>\s*access denied\s*<\/title>/i, "Access Denied page"],
53
+ [/ddos-guard/i, "DDoS-Guard check"],
54
+ ];
55
+ /** Challenge/block signature in headers or the first 40 KB of the body. */
56
+ export function challengeReason(res) {
57
+ const h = res.headers;
58
+ if (h["cf-mitigated"])
59
+ return `cf-mitigated: ${h["cf-mitigated"]}`;
60
+ if (h["x-vercel-mitigated"])
61
+ return `x-vercel-mitigated: ${h["x-vercel-mitigated"]}`;
62
+ if (h["x-amzn-waf-action"])
63
+ return `x-amzn-waf-action: ${h["x-amzn-waf-action"]}`;
64
+ const head = res.body.slice(0, 40_000);
65
+ for (const [re, label] of MARKERS)
66
+ if (re.test(head))
67
+ return label;
68
+ return undefined;
69
+ }
70
+ function kb(n) {
71
+ return n >= 1024 ? `${(n / 1024).toFixed(1)} KB` : `${n} B`;
72
+ }
73
+ export function classifyBotResponse(bot, browser) {
74
+ const browserOk = browser ? browser.status > 0 && browser.status < 400 && !challengeReason(browser) : true;
75
+ const tail = browser && !browserOk ? " (browsers are refused too)" : "";
76
+ if (bot.status === 0) {
77
+ return { blocked: browserOk, reason: `no response: ${bot.error ?? "connection error"}${tail}` };
78
+ }
79
+ const ch = challengeReason(bot);
80
+ if ([401, 403, 429, 503].includes(bot.status))
81
+ return { blocked: true, reason: `HTTP ${bot.status}${ch ? ` · ${ch}` : ""}${tail}` };
82
+ if (ch)
83
+ return { blocked: true, reason: `${ch}${tail}` };
84
+ if (bot.status >= 300 && bot.status < 400) {
85
+ const loc = bot.location ?? "";
86
+ if (/challenge|captcha|cdn-cgi|checkpoint/i.test(loc))
87
+ return { blocked: true, reason: `redirected to a challenge (${loc})` };
88
+ if (browser && (browser.status !== bot.status || (browser.location ?? "") !== loc)) {
89
+ return { blocked: false, different: true, reason: `HTTP ${bot.status} → ${loc || "?"} while browsers get HTTP ${browser.status}` };
90
+ }
91
+ return { blocked: false };
92
+ }
93
+ if (bot.status >= 400)
94
+ return { blocked: true, reason: `HTTP ${bot.status}${tail}` };
95
+ if (browser && browser.status === 200 && bot.status === 200 && browser.bytes > 5000 && bot.bytes < browser.bytes * 0.3) {
96
+ return { blocked: false, different: true, reason: `much smaller page than browsers get (${kb(bot.bytes)} vs ${kb(browser.bytes)})` };
97
+ }
98
+ return { blocked: false };
99
+ }
100
+ export async function runBotTest(url, fetcher, crawlers = CRAWLERS.filter((c) => c.testAccess), concurrency = 4) {
101
+ const headers = { "accept-encoding": "br, gzip" };
102
+ const browser = await fetcher(url, { ua: BROWSER_UA, redirect: "manual", headers });
103
+ const probes = await mapLimit(crawlers, Math.min(4, concurrency), async (crawler) => {
104
+ const res = await fetcher(url, { ua: crawler.ua, redirect: "manual", headers });
105
+ const v = classifyBotResponse(res, browser);
106
+ return { crawler, status: res.status, bytes: res.bytes, ms: res.ms, ...v };
107
+ });
108
+ const browserBlocked = browser.status === 0
109
+ ? `no response: ${browser.error ?? "error"}`
110
+ : [401, 403, 429, 503].includes(browser.status)
111
+ ? `HTTP ${browser.status}`
112
+ : challengeReason(browser);
113
+ return { url, browser, browserBlocked, probes, cdn: detectCdn(browser.headers) };
114
+ }
@@ -0,0 +1,126 @@
1
+ // Site-level HTTP checks (network through an injected fetcher): redirects, host canonicalisation, soft 404,
2
+ // text files (llms.txt), preview copies and og:image reachability.
3
+ import { mapLimit } from "../../../net.js";
4
+ import { safeUrl, sameSite } from "../common/urls.js";
5
+ import { headerNoindex } from "./html.js";
6
+ import { looksLikeHtml } from "./robots.js";
7
+ export const PAGE_HEADERS = { "accept-encoding": "br, gzip" };
8
+ export async function followRedirects(url, fetcher, max = 5, opts = {}) {
9
+ const chain = [];
10
+ let current = url;
11
+ for (let i = 0; i <= max; i++) {
12
+ const res = await fetcher(current, { ...opts, redirect: "manual" });
13
+ const loc = res.status >= 300 && res.status < 400 && res.location ? safeUrl(res.location, current)?.href : undefined;
14
+ chain.push({ url: current, status: res.status, location: loc });
15
+ if (!loc || i === max || chain.some((h) => h.url === loc))
16
+ return { final: { ...res, finalUrl: current }, chain };
17
+ current = loc;
18
+ }
19
+ throw new Error("unreachable");
20
+ }
21
+ function redirectResult(from, res, expect) {
22
+ if (res.status === 0)
23
+ return { from, status: 0, error: res.error };
24
+ if (res.status >= 300 && res.status < 400) {
25
+ const to = res.location ? safeUrl(res.location, from) : undefined;
26
+ return { from, status: res.status, location: to?.href ?? res.location, ok: to ? expect(to) : false, permanent: [301, 308].includes(res.status) };
27
+ }
28
+ if (res.status >= 200 && res.status < 300)
29
+ return { from, status: res.status, ok: false, servesContent: true };
30
+ return { from, status: res.status, ok: false };
31
+ }
32
+ /** http://host/ must redirect (301/308) to https. */
33
+ export async function checkHttpsRedirect(host, fetcher) {
34
+ const from = `http://${host}/`;
35
+ const res = await fetcher(from, { redirect: "manual", timeoutMs: 12000 });
36
+ return redirectResult(from, res, (to) => to.protocol === "https:");
37
+ }
38
+ /** www twin of an apex host (example.com ↔ www.example.com, example.com.tr ↔ www.example.com.tr); undefined for other subdomains. */
39
+ export function wwwTwin(host) {
40
+ const h = host.toLowerCase().replace(/:\d+$/, "");
41
+ if (h.startsWith("www."))
42
+ return h.slice(4);
43
+ if (/^\d+\.\d+\.\d+\.\d+$/.test(h) || h === "localhost")
44
+ return undefined;
45
+ const labels = h.split(".");
46
+ const secondLevel = /^(co|com|net|org|gov|edu|ac|gen|web|info|biz|ltd|plc|me|or|ne|go)$/;
47
+ const apex = labels.length === 2 || (labels.length === 3 && labels[2].length === 2 && secondLevel.test(labels[1]));
48
+ return apex ? `www.${h}` : undefined;
49
+ }
50
+ /** The www / apex twin must redirect to the main host. */
51
+ export async function checkHostRedirect(mainHost, fetcher) {
52
+ const altHost = wwwTwin(mainHost) ?? `www.${mainHost}`;
53
+ const from = `https://${altHost}/`;
54
+ const res = await fetcher(from, { redirect: "manual", timeoutMs: 12000 });
55
+ return { ...redirectResult(from, res, (to) => to.hostname.toLowerCase() === mainHost.toLowerCase()), altHost };
56
+ }
57
+ /** An address that cannot exist must answer 404/410. */
58
+ export async function checkSoft404(origin, fetcher, token) {
59
+ const url = `${origin}/katman-check-404-${token}`;
60
+ const res = await fetcher(url, { redirect: "manual", headers: PAGE_HEADERS });
61
+ const noindex = headerNoindex(res.headers) || /<meta[^>]+name=["'](?:robots|googlebot)["'][^>]+content=["'][^"']*noindex/i.test(res.body);
62
+ return {
63
+ url,
64
+ status: res.status,
65
+ location: res.location ? safeUrl(res.location, url)?.href : undefined,
66
+ noindex,
67
+ soft404: res.status >= 200 && res.status < 300,
68
+ error: res.error,
69
+ };
70
+ }
71
+ export async function checkTextFile(url, fetcher) {
72
+ const res = await fetcher(url, { redirect: "follow", maxBytes: 2_000_000, headers: { accept: "text/plain,text/markdown,*/*" } });
73
+ const isHtml = res.status === 200 && looksLikeHtml(res.body, res.headers["content-type"]);
74
+ return {
75
+ url,
76
+ status: res.status,
77
+ exists: res.status === 200 && !isHtml && res.body.trim().length > 0,
78
+ isHtml,
79
+ bytes: res.bytes,
80
+ text: isHtml ? "" : res.body,
81
+ contentType: res.headers["content-type"],
82
+ };
83
+ }
84
+ /** A preview copy passes when it 301s to the main host, sends noindex, is password-protected, or is gone. */
85
+ export async function checkPreview(url, mainHost, fetcher, mainTitle) {
86
+ const res = await fetcher(url, { redirect: "manual", headers: PAGE_HEADERS });
87
+ if (res.status === 0)
88
+ return { url, status: 0, ok: true, how: `not reachable (${res.error ?? "error"})` };
89
+ if (res.status >= 300 && res.status < 400) {
90
+ const to = res.location ? safeUrl(res.location, url) : undefined;
91
+ const toMain = !!to && sameSite(to.hostname, mainHost) && to.hostname.toLowerCase() === mainHost.toLowerCase();
92
+ const permanent = [301, 308].includes(res.status);
93
+ return {
94
+ url,
95
+ status: res.status,
96
+ location: to?.href,
97
+ ok: toMain && permanent,
98
+ how: toMain ? (permanent ? `${res.status} → main host` : `${res.status} (temporary) → main host: use 301`) : `redirects to ${to?.href ?? "?"}`,
99
+ };
100
+ }
101
+ if (res.status === 401 || res.status === 403)
102
+ return { url, status: res.status, ok: true, how: `password/access protected (${res.status})` };
103
+ if (res.status >= 400)
104
+ return { url, status: res.status, ok: true, how: `not serving the site (${res.status})` };
105
+ const title = res.body.match(/<title[^>]*>([\s\S]*?)<\/title>/i)?.[1]?.trim();
106
+ const sameTitle = mainTitle && title ? title === mainTitle : undefined;
107
+ const noindex = headerNoindex(res.headers) || /<meta[^>]+name=["'](?:robots|googlebot)["'][^>]+content=["'][^"']*noindex/i.test(res.body);
108
+ return {
109
+ url,
110
+ status: res.status,
111
+ ok: noindex,
112
+ how: noindex ? "200 with noindex" : `200 without noindex${sameTitle ? " (same <title> as the main site: a live copy)" : ""}`,
113
+ sameTitle,
114
+ };
115
+ }
116
+ export async function checkImages(urls, fetcher, max = 6) {
117
+ const out = new Map();
118
+ const list = [...new Set(urls)].slice(0, max);
119
+ const res = await mapLimit(list, 4, (u) => fetcher(u, { redirect: "follow", maxBytes: 4096, headers: { accept: "image/*,*/*" } }));
120
+ list.forEach((u, i) => {
121
+ const r = res[i];
122
+ const ct = r.headers["content-type"];
123
+ out.set(u, { status: r.status, contentType: ct, ok: r.status === 200 && (!ct || /^image\//i.test(ct)) });
124
+ });
125
+ return out;
126
+ }
@@ -0,0 +1,382 @@
1
+ // What a crawler that does not run JavaScript sees in a page's raw HTML. Pure: (html, url) → facts.
2
+ import { parse, HTMLElement } from "node-html-parser";
3
+ import { collapse, countWords, decodeEntities } from "../common/text.js";
4
+ import { safeUrl, sameSite, urlKey } from "../common/urls.js";
5
+ const SKIP_TEXT = new Set(["script", "style", "template", "svg", "noscript", "head", "iframe", "object", "canvas", "math", "title"]);
6
+ const P_SKIP_TAGS = new Set(["nav", "footer", "aside", "form", "dialog", "button", "label", "select", "figcaption"]);
7
+ const P_SKIP_ATTR = /cookie|consent|gdpr|announcement|skip-link|breadcrumb|newsletter|banner-top/i;
8
+ function tagOf(n) {
9
+ return n instanceof HTMLElement ? (n.rawTagName ?? "").toLowerCase() : "";
10
+ }
11
+ function attr(el, k) {
12
+ const v = el.getAttribute(k);
13
+ return v === undefined ? undefined : v;
14
+ }
15
+ function textOf(el) {
16
+ const out = [];
17
+ walkText(el, out, undefined);
18
+ return collapse(decodeEntities(out.join(" ")));
19
+ }
20
+ function walkText(node, out, st) {
21
+ for (const child of node.childNodes) {
22
+ if (child.nodeType === 3) {
23
+ out.push(child.rawText);
24
+ continue;
25
+ }
26
+ if (!(child instanceof HTMLElement))
27
+ continue;
28
+ const tag = tagOf(child);
29
+ if (SKIP_TEXT.has(tag))
30
+ continue;
31
+ if (child.getAttribute("hidden") !== undefined)
32
+ continue;
33
+ walkText(child, out, st);
34
+ if (st && !st.h1Seen && tag === "h1") {
35
+ st.h1Seen = true;
36
+ st.afterH1Start = out.length;
37
+ }
38
+ }
39
+ }
40
+ function parseJsonLd(raw) {
41
+ const cleaned = raw
42
+ .trim()
43
+ .replace(/^<!--/, "")
44
+ .replace(/-->$/, "")
45
+ .replace(/^\s*\/\/\s*<!\[CDATA\[/, "")
46
+ .replace(/\/\/\s*\]\]>\s*$/, "")
47
+ .trim();
48
+ return JSON.parse(cleaned);
49
+ }
50
+ function typeName(t) {
51
+ return t.replace(/^https?:\/\/schema\.org\//i, "").trim();
52
+ }
53
+ function collectTypes(node, out, dates, depth = 0) {
54
+ if (Array.isArray(node)) {
55
+ for (const n of node)
56
+ collectTypes(n, out, dates, depth);
57
+ return;
58
+ }
59
+ if (!node || typeof node !== "object")
60
+ return;
61
+ const o = node;
62
+ const t = o["@type"];
63
+ if (typeof t === "string")
64
+ out.add(typeName(t));
65
+ else if (Array.isArray(t))
66
+ for (const x of t)
67
+ if (typeof x === "string")
68
+ out.add(typeName(x));
69
+ if (typeof o["dateModified"] === "string")
70
+ dates.push(o["dateModified"]);
71
+ if (depth === 0 && Array.isArray(o["@graph"]))
72
+ for (const g of o["@graph"])
73
+ collectTypes(g, out, dates, 1);
74
+ }
75
+ const MIXED_SRC = ["img", "script", "iframe", "source", "video", "audio", "embed", "track", "input"];
76
+ export function analyzeHtml(html, pageUrl) {
77
+ const root = parse(html, {
78
+ comment: false,
79
+ lowerCaseTagName: true,
80
+ blockTextElements: { script: true, noscript: true, style: true },
81
+ });
82
+ const page = safeUrl(pageUrl);
83
+ const host = page?.hostname ?? "";
84
+ const https = page?.protocol === "https:";
85
+ // Titles (ignore <title> inside inline SVG)
86
+ const titles = root.querySelectorAll("title").filter((t) => !t.closest("svg"));
87
+ const title = titles[0] ? collapse(decodeEntities(titles[0].rawText)) : undefined;
88
+ // Meta tags
89
+ let description;
90
+ let descriptionCount = 0;
91
+ let metaRobots;
92
+ let viewport = false;
93
+ let twitterCard;
94
+ let generator;
95
+ const og = {};
96
+ for (const m of root.querySelectorAll("meta")) {
97
+ const name = (attr(m, "name") ?? "").toLowerCase().trim();
98
+ const prop = (attr(m, "property") ?? "").toLowerCase().trim();
99
+ const content = collapse(decodeEntities(attr(m, "content") ?? ""));
100
+ if (name === "description") {
101
+ descriptionCount++;
102
+ if (description === undefined)
103
+ description = content;
104
+ }
105
+ else if (name === "robots" || name === "googlebot") {
106
+ metaRobots = metaRobots ? `${metaRobots}, ${content}` : content;
107
+ }
108
+ else if (name === "viewport")
109
+ viewport = true;
110
+ else if (name === "generator")
111
+ generator = content;
112
+ const key = prop || name;
113
+ if (key === "twitter:card")
114
+ twitterCard = content;
115
+ if (key.startsWith("og:")) {
116
+ if (key === "og:title")
117
+ og.title ??= content;
118
+ else if (key === "og:description")
119
+ og.description ??= content;
120
+ else if (key === "og:image" || key === "og:image:url" || key === "og:image:secure_url")
121
+ og.image ??= content;
122
+ else if (key === "og:image:alt")
123
+ og.imageAlt ??= content;
124
+ else if (key === "og:image:width")
125
+ og.imageWidth ??= content;
126
+ else if (key === "og:url")
127
+ og.url ??= content;
128
+ else if (key === "og:locale")
129
+ og.locale ??= content;
130
+ }
131
+ }
132
+ const robotsLc = (metaRobots ?? "").toLowerCase();
133
+ const noindexMeta = /\bnoindex\b|\bnone\b/.test(robotsLc);
134
+ const nosnippet = /\bnosnippet\b/.test(robotsLc);
135
+ // Links in <head>: canonical, hreflang
136
+ let canonicalRaw;
137
+ let canonicalCount = 0;
138
+ const hreflang = [];
139
+ for (const l of root.querySelectorAll("link")) {
140
+ const rel = (attr(l, "rel") ?? "").toLowerCase().split(/\s+/);
141
+ const href = attr(l, "href");
142
+ if (!href)
143
+ continue;
144
+ if (rel.includes("canonical")) {
145
+ canonicalCount++;
146
+ canonicalRaw ??= href.trim();
147
+ }
148
+ const hl = attr(l, "hreflang");
149
+ if (rel.includes("alternate") && hl) {
150
+ const abs = safeUrl(href.trim(), pageUrl)?.href ?? href.trim();
151
+ hreflang.push({ lang: hl.trim(), href: abs });
152
+ }
153
+ }
154
+ const canonical = canonicalRaw ? (safeUrl(canonicalRaw, pageUrl)?.href ?? canonicalRaw) : undefined;
155
+ // Headings
156
+ const headings = root.querySelectorAll("h1, h2, h3, h4, h5, h6");
157
+ const h1s = headings.filter((h) => tagOf(h) === "h1");
158
+ const h1Text = h1s[0] ? textOf(h1s[0]) || undefined : undefined;
159
+ let headingSkips = 0;
160
+ const headingSkipSamples = [];
161
+ let prev = 0;
162
+ for (const h of headings) {
163
+ const lvl = Number(tagOf(h).slice(1));
164
+ if (prev && lvl > prev + 1) {
165
+ headingSkips++;
166
+ if (headingSkipSamples.length < 3)
167
+ headingSkipSamples.push(`h${prev}→h${lvl} "${textOf(h).slice(0, 40)}"`);
168
+ }
169
+ prev = lvl;
170
+ }
171
+ // Visible text of <body> (no JS): skip script/style/template/svg/noscript
172
+ const body = root.querySelector("body") ?? root;
173
+ const out = [];
174
+ const st = { h1Seen: false, afterH1Start: -1 };
175
+ walkText(body, out, st);
176
+ const bodyText = collapse(decodeEntities(out.join(" ")));
177
+ const wordsJsOff = countWords(bodyText);
178
+ const textAfterH1 = st.h1Seen ? collapse(decodeEntities(out.slice(st.afterH1Start).join(" "))).slice(0, 400) : undefined;
179
+ // First meaningful paragraph (≥40 chars), preferring <main>/<article>, skipping nav/footer/cookie banners
180
+ const scope = root.querySelector("main") ?? root.querySelector("article") ?? body;
181
+ let firstParagraph;
182
+ for (const p of scope.querySelectorAll("p")) {
183
+ if (excludedParagraph(p))
184
+ continue;
185
+ const t = textOf(p);
186
+ if (t.length >= 40) {
187
+ firstParagraph = t;
188
+ break;
189
+ }
190
+ }
191
+ if (!firstParagraph && scope !== body) {
192
+ for (const p of body.querySelectorAll("p")) {
193
+ if (excludedParagraph(p))
194
+ continue;
195
+ const t = textOf(p);
196
+ if (t.length >= 40) {
197
+ firstParagraph = t;
198
+ break;
199
+ }
200
+ }
201
+ }
202
+ // Heuristic: an answer sentence (≥30 chars, ending in . ! ?) or the first paragraph starts within ~200 characters
203
+ // after the H1.
204
+ let answerNearH1;
205
+ if (textAfterH1 !== undefined) {
206
+ const probe = firstParagraph?.slice(0, 40);
207
+ const at = probe ? textAfterH1.indexOf(probe) : -1;
208
+ const sentence = textAfterH1.search(/[\p{L}\p{N}][^.!?]{29,}[.!?](\s|$)/u);
209
+ answerNearH1 = (at >= 0 && at <= 200) || (sentence >= 0 && sentence <= 200);
210
+ }
211
+ else if (h1Text)
212
+ answerNearH1 = false;
213
+ // JSON-LD
214
+ let jsonLdBlocks = 0;
215
+ let jsonLdErrors = 0;
216
+ const jsonLdErrorSamples = [];
217
+ const types = new Set();
218
+ const dates = [];
219
+ let scriptCount = 0;
220
+ for (const s of root.querySelectorAll("script"))
221
+ if (attr(s, "src"))
222
+ scriptCount++;
223
+ // JSON-LD straight from the raw HTML: node-html-parser's script rawText can gain a stray "<" when the JSON holds
224
+ // non-ASCII text, which would report valid JSON-LD as broken.
225
+ for (const m of html.matchAll(/<script\b([^>]*)>([\s\S]*?)<\/script\s*>/gi)) {
226
+ const type = (m[1].match(/\btype\s*=\s*["']?([^"'\s>]+)/i)?.[1] ?? "").toLowerCase();
227
+ if (type !== "application/ld+json")
228
+ continue;
229
+ jsonLdBlocks++;
230
+ try {
231
+ collectTypes(parseJsonLd(m[2]), types, dates);
232
+ }
233
+ catch (e) {
234
+ jsonLdErrors++;
235
+ if (jsonLdErrorSamples.length < 2)
236
+ jsonLdErrorSamples.push(e instanceof Error ? e.message.slice(0, 120) : String(e));
237
+ }
238
+ }
239
+ let dateModified = dates[0];
240
+ if (!dateModified) {
241
+ for (const m of root.querySelectorAll("meta")) {
242
+ const k = (attr(m, "property") ?? attr(m, "name") ?? "").toLowerCase();
243
+ if (k === "article:modified_time" || k === "og:updated_time") {
244
+ dateModified = attr(m, "content");
245
+ break;
246
+ }
247
+ }
248
+ }
249
+ // Images
250
+ const imgs = body.querySelectorAll("img");
251
+ let imagesMissingAlt = 0;
252
+ let imagesMissingSize = 0;
253
+ for (const img of imgs) {
254
+ if (img.getAttribute("alt") === undefined)
255
+ imagesMissingAlt++;
256
+ if (img.getAttribute("width") === undefined || img.getAttribute("height") === undefined)
257
+ imagesMissingSize++;
258
+ }
259
+ // Links
260
+ const links = [];
261
+ const seenKeys = new Set();
262
+ let externalLinks = 0;
263
+ for (const a of root.querySelectorAll("a")) {
264
+ const raw = (attr(a, "href") ?? "").trim();
265
+ if (!raw || raw.startsWith("#") || /^(mailto|tel|javascript|data|sms|whatsapp):/i.test(raw))
266
+ continue;
267
+ const u = safeUrl(decodeEntities(raw), pageUrl);
268
+ if (!u || !/^https?:$/.test(u.protocol))
269
+ continue;
270
+ if (!sameSite(u.hostname, host)) {
271
+ externalLinks++;
272
+ continue;
273
+ }
274
+ u.hash = "";
275
+ const key = urlKey(u);
276
+ if (seenKeys.has(key))
277
+ continue;
278
+ seenKeys.add(key);
279
+ links.push({
280
+ href: u.href,
281
+ key,
282
+ text: textOf(a).slice(0, 80),
283
+ nofollow: /\bnofollow\b/i.test(attr(a, "rel") ?? ""),
284
+ offHost: u.hostname.toLowerCase() !== host.toLowerCase() || (page ? u.protocol !== page.protocol : false),
285
+ });
286
+ }
287
+ // Mixed content (http:// sub-resources on an https page)
288
+ const mixedContent = [];
289
+ if (https) {
290
+ const push = (v) => {
291
+ if (v && /^http:\/\//i.test(v.trim()) && mixedContent.length < 20)
292
+ mixedContent.push(v.trim());
293
+ };
294
+ for (const tag of MIXED_SRC)
295
+ for (const el of root.querySelectorAll(tag))
296
+ push(attr(el, "src"));
297
+ for (const el of root.querySelectorAll("link")) {
298
+ const rel = (attr(el, "rel") ?? "").toLowerCase();
299
+ if (/stylesheet|preload|icon|modulepreload|manifest/.test(rel))
300
+ push(attr(el, "href"));
301
+ }
302
+ for (const el of root.querySelectorAll("source, img")) {
303
+ for (const part of (attr(el, "srcset") ?? "").split(","))
304
+ push(part.trim().split(/\s+/)[0]);
305
+ }
306
+ for (const el of root.querySelectorAll("object"))
307
+ push(attr(el, "data"));
308
+ for (const el of root.querySelectorAll("video"))
309
+ push(attr(el, "poster"));
310
+ }
311
+ // Client-rendered shell: an empty mount point and almost no body text
312
+ let rootShell = false;
313
+ for (const sel of ["#root", "#app", "#__next", "#__nuxt", "#svelte", "#q-app", "[data-reactroot]"]) {
314
+ const el = root.querySelector(sel);
315
+ if (el && countWords(textOf(el)) <= 5) {
316
+ rootShell = wordsJsOff < 100;
317
+ break;
318
+ }
319
+ }
320
+ return {
321
+ title,
322
+ titleCount: titles.length,
323
+ description,
324
+ descriptionCount,
325
+ canonical,
326
+ canonicalRaw,
327
+ canonicalCount,
328
+ metaRobots,
329
+ noindexMeta,
330
+ nosnippet,
331
+ h1Count: h1s.length,
332
+ h1Text,
333
+ headingSkips,
334
+ headingSkipSamples,
335
+ wordsJsOff,
336
+ firstParagraph,
337
+ answerNearH1,
338
+ textAfterH1,
339
+ jsonLdBlocks,
340
+ jsonLdTypes: [...types].sort(),
341
+ jsonLdErrors,
342
+ jsonLdErrorSamples,
343
+ dateModified,
344
+ og,
345
+ twitterCard,
346
+ lang: attr(root.querySelector("html") ?? root, "lang")?.trim() || undefined,
347
+ hreflang,
348
+ imagesTotal: imgs.length,
349
+ imagesMissingAlt,
350
+ imagesMissingSize,
351
+ links,
352
+ externalLinks,
353
+ mixedContent,
354
+ viewport,
355
+ charsetOffset: html.search(/<meta[^>]+charset/i),
356
+ rootShell,
357
+ scriptCount,
358
+ generator,
359
+ cfEmailObfuscation: /__cf_email__|\/cdn-cgi\/l\/email-protection/.test(html),
360
+ };
361
+ }
362
+ function excludedParagraph(p) {
363
+ let el = p.parentNode;
364
+ while (el && el.rawTagName) {
365
+ const tag = el.rawTagName.toLowerCase();
366
+ if (P_SKIP_TAGS.has(tag))
367
+ return true;
368
+ const role = (el.getAttribute("role") ?? "").toLowerCase();
369
+ if (role === "navigation" || role === "contentinfo" || role === "dialog")
370
+ return true;
371
+ const idc = `${el.getAttribute("id") ?? ""} ${el.getAttribute("class") ?? ""}`;
372
+ if (P_SKIP_ATTR.test(idc))
373
+ return true;
374
+ el = el.parentNode;
375
+ }
376
+ return false;
377
+ }
378
+ /** noindex from the X-Robots-Tag header (any bot-specific variant counts). */
379
+ export function headerNoindex(headers) {
380
+ const v = (headers["x-robots-tag"] ?? "").toLowerCase();
381
+ return /\bnoindex\b|\bnone\b/.test(v);
382
+ }