seo 0.2.12 → 0.2.14

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.
package/dist/mcp.js CHANGED
@@ -1,5 +1,5 @@
1
1
  import { createRequire as __seoCreateRequire } from 'node:module'; const require = __seoCreateRequire(import.meta.url);
2
- import { n as load, t as version } from "./package-B-M-4PZ_.js";
2
+ import { n as load, t as version } from "./package-Cp8DwqCy.js";
3
3
  import { Headers, fetch as fetch$1 } from "undici";
4
4
  import { chmodSync, createReadStream, existsSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs";
5
5
  import { arch, homedir, platform, totalmem } from "node:os";
@@ -2134,6 +2134,43 @@ function extractCanonicalEvidence($, headers, baseUrl) {
2134
2134
  return evidenceFromCandidates([...htmlCandidates($, baseUrl), ...headerCandidates(headers, baseUrl)]);
2135
2135
  }
2136
2136
  //#endregion
2137
+ //#region packages/core/dist/extract/content-sketch.js
2138
+ const SHINGLE_SIZE = 5;
2139
+ const MAX_HASHES = 32;
2140
+ function words(value) {
2141
+ return value.normalize("NFKC").toLocaleLowerCase("en").match(/[\p{L}\p{N}]+/gu) ?? [];
2142
+ }
2143
+ function sampleIndexes(total, limit) {
2144
+ if (total <= limit) return Array.from({ length: total }, (_, index) => index);
2145
+ return Array.from({ length: limit }, (_, index) => Math.round(index * (total - 1) / (limit - 1)));
2146
+ }
2147
+ function digest(value) {
2148
+ return createHash("sha256").update(value).digest("hex").slice(0, 16);
2149
+ }
2150
+ function contentSketch(value) {
2151
+ const tokens = words(value);
2152
+ const hashes = sampleIndexes(Math.max(0, tokens.length - SHINGLE_SIZE + 1), MAX_HASHES).map((index) => digest(tokens.slice(index, index + SHINGLE_SIZE).join(" ")));
2153
+ return {
2154
+ version: 1,
2155
+ wordCount: tokens.length,
2156
+ shingleSize: SHINGLE_SIZE,
2157
+ sampledShingles: hashes.length,
2158
+ hashes
2159
+ };
2160
+ }
2161
+ function contentSketchCoverage(source, candidate) {
2162
+ if (!source || source.hashes.length < 4) return null;
2163
+ const targets = new Set(source.hashes);
2164
+ const matched = /* @__PURE__ */ new Set();
2165
+ const tokens = words(candidate);
2166
+ for (let index = 0; index <= tokens.length - source.shingleSize; index += 1) {
2167
+ const hash = digest(tokens.slice(index, index + source.shingleSize).join(" "));
2168
+ if (targets.has(hash)) matched.add(hash);
2169
+ if (matched.size === targets.size) break;
2170
+ }
2171
+ return Math.round(matched.size / targets.size * 1e3) / 1e3;
2172
+ }
2173
+ //#endregion
2137
2174
  //#region packages/core/dist/extract/main-content.js
2138
2175
  function readableError(error) {
2139
2176
  return error instanceof Error ? error.message : String(error);
@@ -2741,6 +2778,22 @@ function extractStructuredData($, baseUrl, options = {}) {
2741
2778
  }
2742
2779
  //#endregion
2743
2780
  //#region packages/core/dist/extract/page-extractor.js
2781
+ function metaRefresh($, finalUrl) {
2782
+ for (const element of $("meta[http-equiv]").toArray()) {
2783
+ if ($(element).attr("http-equiv")?.trim().toLowerCase() !== "refresh") continue;
2784
+ const match = ($(element).attr("content")?.trim())?.match(/^([0-9]+(?:\.[0-9]+)?)\s*;\s*url\s*=\s*(?:"([^"]*)"|'([^']*)'|(.+))$/iu);
2785
+ if (!match) continue;
2786
+ try {
2787
+ const target = new URL((match[2] ?? match[3] ?? match[4] ?? "").trim(), finalUrl);
2788
+ if (!["http:", "https:"].includes(target.protocol)) continue;
2789
+ target.hash = "";
2790
+ return {
2791
+ targetUrl: target.toString(),
2792
+ delaySeconds: Number(match[1])
2793
+ };
2794
+ } catch {}
2795
+ }
2796
+ }
2744
2797
  const SOCIAL_PROFILE_HOSTS = [
2745
2798
  "facebook.com",
2746
2799
  "instagram.com",
@@ -2751,6 +2804,48 @@ const SOCIAL_PROFILE_HOSTS = [
2751
2804
  "x.com",
2752
2805
  "youtube.com"
2753
2806
  ];
2807
+ const LOGIN_PATH_PATTERN = /(?:^|\/)(?:auth|login|log-in|signin|sign-in|sso)(?:\/|$)/iu;
2808
+ const LOGIN_TITLE_PATTERN = /\b(?:authenticate|log[ -]?in|sign[ -]?in)\b/iu;
2809
+ function softAuthenticationGate($, finalUrl, title) {
2810
+ const passwordForms = $("form").toArray().filter((form) => $(form).find("input[type=\"password\" i]").length > 0);
2811
+ if (passwordForms.length === 0) return void 0;
2812
+ const pagePath = new URL(finalUrl).pathname;
2813
+ const action = $(passwordForms[0]).attr("action");
2814
+ const actionUrl = action ? httpUrl$1(action, finalUrl) : void 0;
2815
+ const actionPath = actionUrl ? new URL(actionUrl).pathname : void 0;
2816
+ const indicators = [
2817
+ ...LOGIN_PATH_PATTERN.test(pagePath) ? ["login-path"] : [],
2818
+ ...LOGIN_TITLE_PATTERN.test(title ?? "") ? ["login-title"] : [],
2819
+ ...LOGIN_PATH_PATTERN.test(actionPath ?? "") ? ["login-form-action"] : []
2820
+ ];
2821
+ if (indicators.length === 0) return void 0;
2822
+ return {
2823
+ kind: "login-form",
2824
+ indicators,
2825
+ ...actionPath ? { formActionPath: actionPath } : {}
2826
+ };
2827
+ }
2828
+ function tabbedContent($) {
2829
+ const groups = $("[role=\"tablist\" i]").length;
2830
+ const panels = $("[role=\"tabpanel\" i]").toArray();
2831
+ if (groups === 0 || panels.length === 0) return void 0;
2832
+ const panelSketches = panels.slice(0, 12).map((panel) => {
2833
+ const labelledBy = $(panel).attr("aria-labelledby");
2834
+ const labelledElement = labelledBy ? $("[id]").toArray().find((element) => $(element).attr("id") === labelledBy) : void 0;
2835
+ const label = safeText$1($(panel).attr("aria-label") ?? (labelledElement ? $(labelledElement).text() : void 0));
2836
+ return {
2837
+ ...label ? { label: label.slice(0, 80) } : {},
2838
+ sketch: contentSketch($(panel).text())
2839
+ };
2840
+ });
2841
+ return {
2842
+ groups,
2843
+ panels: panels.length,
2844
+ retainedPanels: panelSketches.length,
2845
+ truncated: panels.length > panelSketches.length,
2846
+ panelSketches
2847
+ };
2848
+ }
2754
2849
  function safeText$1(value) {
2755
2850
  const trimmed = value?.replace(/\s+/g, " ").trim();
2756
2851
  return trimmed ? trimmed : void 0;
@@ -2977,10 +3072,13 @@ async function extractPage(fetchResult, extractor = "defuddle", dependencies = {
2977
3072
  html: sanitizedContentHtml($, fetchResult.html, fetchResult.finalUrl)
2978
3073
  }, extractor, dependencies);
2979
3074
  const { text, excerpt } = content;
3075
+ const title = safeText$1($("title").first().text());
3076
+ const crawlerMetaRefresh = extractor === "crawler" ? metaRefresh($, fetchResult.finalUrl) : void 0;
3077
+ if (crawlerMetaRefresh && new URL(crawlerMetaRefresh.targetUrl).origin === url.origin) crawlerInternalLinks.add(crawlerMetaRefresh.targetUrl);
2980
3078
  return {
2981
3079
  url: fetchResult.url,
2982
3080
  finalUrl: fetchResult.finalUrl,
2983
- title: safeText$1($("title").first().text()),
3081
+ title,
2984
3082
  metaDescription: safeText$1($("meta[name=\"description\"]").attr("content")),
2985
3083
  metaRobots,
2986
3084
  xRobotsTag: safeText$1(headerValue$4(fetchResult.headers, "x-robots-tag")),
@@ -3000,7 +3098,11 @@ async function extractPage(fetchResult, extractor = "defuddle", dependencies = {
3000
3098
  externalLinks: [...crawlerExternalLinks],
3001
3099
  internalAnchorSamples,
3002
3100
  externalAnchorSamples,
3003
- socialProfileLinks: [...socialProfileLinks]
3101
+ socialProfileLinks: [...socialProfileLinks],
3102
+ contentSketch: contentSketch(text),
3103
+ metaRefresh: crawlerMetaRefresh,
3104
+ softAuthenticationGate: softAuthenticationGate($, fetchResult.finalUrl, title),
3105
+ tabbedContent: tabbedContent($)
3004
3106
  } } : {},
3005
3107
  markdownAlternates,
3006
3108
  hreflang,
@@ -7105,6 +7207,8 @@ function markdownQuality(markdown, page) {
7105
7207
  const codeFences = markdown.match(/^```/gmu)?.length ?? 0;
7106
7208
  const wordCount = bodyWordCount(markdown);
7107
7209
  const sourceWordCount = page.wordCount ?? 0;
7210
+ const evaluatedPanels = (page.tabbedContent?.panelSketches ?? []).map((panel) => contentSketchCoverage(panel.sketch, markdown)).filter((coverage) => coverage !== null);
7211
+ const retainedPanels = evaluatedPanels.filter((coverage) => coverage >= .6).length;
7108
7212
  return {
7109
7213
  frontmatterTitle: /^---[\s\S]*?^title:\s*.+$/mu.test(markdown),
7110
7214
  h1Count: markdown.match(/^#\s+\S.+$/gmu)?.length ?? 0,
@@ -7121,7 +7225,15 @@ function markdownQuality(markdown, page) {
7121
7225
  sourceWordCount,
7122
7226
  wordRetentionRatio: sourceWordCount > 0 ? Math.round(wordCount / sourceWordCount * 1e3) / 1e3 : null,
7123
7227
  introductoryCopyRetained: introProbe ? normalizedMarkdown.includes(introProbe) : null,
7124
- navigationOnly: bodyWordCount(markdown) < 25
7228
+ navigationOnly: bodyWordCount(markdown) < 25,
7229
+ contentSketchCoverage: contentSketchCoverage(page.contentSketch, markdown),
7230
+ tabbedContent: {
7231
+ detectedPanels: page.tabbedContent?.panels ?? 0,
7232
+ evaluatedPanels: evaluatedPanels.length,
7233
+ retainedPanels,
7234
+ missingPanels: Math.max(0, evaluatedPanels.length - retainedPanels),
7235
+ complete: evaluatedPanels.length === 0 ? null : retainedPanels === evaluatedPanels.length
7236
+ }
7125
7237
  };
7126
7238
  }
7127
7239
  function combinedSignal(timeoutMs, signal) {
@@ -7175,6 +7287,8 @@ async function fetchRepresentation(input) {
7175
7287
  status: response.status,
7176
7288
  contentType: response.headers.get("content-type") ?? void 0,
7177
7289
  bytes: Buffer.byteLength(body),
7290
+ characters: body.length,
7291
+ estimatedTokens: Math.ceil(body.length / 4),
7178
7292
  sha256: sha256(body),
7179
7293
  canonicalUrl: link.find((entry) => entry.rel.includes("canonical"))?.url,
7180
7294
  alternateUrl: link.find((entry) => entry.rel.includes("alternate") && entry.type === "text/markdown")?.url,
@@ -7233,11 +7347,15 @@ async function inspectMarkdownPage(input) {
7233
7347
  if (explicit) observation.explicit = explicit.observation;
7234
7348
  observation.negotiated = negotiated.observation;
7235
7349
  observation.repeated = repeated.observation;
7236
- observation.explicitMatchesNegotiated = explicit?.observation.sha256 && negotiated.observation.sha256 ? explicit.observation.sha256 === negotiated.observation.sha256 : null;
7237
- const primary = explicit ?? negotiated;
7238
- observation.repeatedHashStable = primary.observation.sha256 && repeated.observation.sha256 ? primary.observation.sha256 === repeated.observation.sha256 : null;
7239
- observation.markdownCanonicalMatchesHtml = sameDocument(primary.observation.canonicalUrl, input.page.finalUrl);
7240
- if (primary.body) observation.quality = markdownQuality(primary.body, input.page);
7350
+ const isMarkdown = (result) => result?.observation.status !== void 0 && result.observation.status >= 200 && result.observation.status < 300 && /^\s*text\/markdown\b/iu.test(result.observation.contentType ?? "");
7351
+ const explicitMarkdown = isMarkdown(explicit) ? explicit : void 0;
7352
+ const negotiatedMarkdown = isMarkdown(negotiated) ? negotiated : void 0;
7353
+ const repeatedMarkdown = isMarkdown(repeated) ? repeated : void 0;
7354
+ observation.explicitMatchesNegotiated = explicitMarkdown?.observation.sha256 && negotiatedMarkdown?.observation.sha256 ? explicitMarkdown.observation.sha256 === negotiatedMarkdown.observation.sha256 : null;
7355
+ const primary = explicitMarkdown ?? negotiatedMarkdown;
7356
+ observation.repeatedHashStable = primary?.observation.sha256 && repeatedMarkdown?.observation.sha256 ? primary.observation.sha256 === repeatedMarkdown.observation.sha256 : null;
7357
+ observation.markdownCanonicalMatchesHtml = sameDocument(primary?.observation.canonicalUrl, input.page.finalUrl);
7358
+ if (primary?.body) observation.quality = markdownQuality(primary.body, input.page);
7241
7359
  return observation;
7242
7360
  }
7243
7361
  async function fetchText(input) {
@@ -7481,17 +7599,16 @@ async function inspectRouteManifest(input) {
7481
7599
  const declared = Array.isArray(parsed.pages) ? parsed.pages : [];
7482
7600
  const declaredHtmlRoutes = declared.map((page) => page.htmlPath).filter((path) => typeof path === "string").sort();
7483
7601
  const declaredMarkdownRoutes = declared.map((page) => page.markdownPath).filter((path) => typeof path === "string").sort();
7484
- const crawledHtmlRoutes = new Set(input.pages.map((page) => new URL(page.finalUrl).pathname.replace(/\/$/u, "") || "/"));
7485
- const observedMarkdownRoutes = new Set(input.observations.flatMap((page) => page.advertisedUrls.map((value) => new URL(value).pathname)));
7486
7602
  return {
7487
7603
  url,
7488
7604
  status: result.response.status,
7489
7605
  valid: Array.isArray(parsed.pages),
7490
7606
  declaredHtmlRoutes,
7491
7607
  declaredMarkdownRoutes,
7492
- missingHtmlRoutes: declaredHtmlRoutes.filter((path) => !crawledHtmlRoutes.has(path.replace(/\/$/u, "") || "/")).sort(),
7493
- missingMarkdownRoutes: declaredMarkdownRoutes.filter((path) => !observedMarkdownRoutes.has(path)).sort(),
7494
- orphanMarkdownRoutes: [...observedMarkdownRoutes].filter((path) => !declaredMarkdownRoutes.includes(path)).sort()
7608
+ missingHtmlRoutes: [],
7609
+ missingMarkdownRoutes: [],
7610
+ orphanMarkdownRoutes: [],
7611
+ comparisonStatus: "partial"
7495
7612
  };
7496
7613
  } catch (error) {
7497
7614
  return {
@@ -7502,6 +7619,7 @@ async function inspectRouteManifest(input) {
7502
7619
  missingHtmlRoutes: [],
7503
7620
  missingMarkdownRoutes: [],
7504
7621
  orphanMarkdownRoutes: [],
7622
+ comparisonStatus: "not-applicable",
7505
7623
  error: safeError(error)
7506
7624
  };
7507
7625
  }
@@ -7545,9 +7663,7 @@ async function collectAgentDiscovery(input) {
7545
7663
  }),
7546
7664
  inspectRouteManifest({
7547
7665
  ...input,
7548
- origin,
7549
- pages,
7550
- observations
7666
+ origin
7551
7667
  }),
7552
7668
  inspectAgentEndpoints({
7553
7669
  ...input,
@@ -7652,20 +7768,13 @@ async function collectAgentDiscovery(input) {
7652
7768
  };
7653
7769
  }
7654
7770
  //#endregion
7655
- //#region packages/core/dist/analyze/crawler/agent-readiness.js
7656
- function check$1(section, input) {
7771
+ //#region packages/core/dist/analyze/crawler/agent-readiness-markdown.js
7772
+ function check$2(section, input) {
7657
7773
  return {
7658
7774
  section,
7659
7775
  ...input
7660
7776
  };
7661
7777
  }
7662
- function section$2(id, title, checks) {
7663
- return {
7664
- id,
7665
- title,
7666
- checks
7667
- };
7668
- }
7669
7778
  function failedMarkdownUrls(discovery) {
7670
7779
  return discovery.markdownAlternates.pages.filter((page) => {
7671
7780
  const negotiatedOk = page.negotiated?.status === 200 && /^\s*text\/markdown\b/iu.test(page.negotiated.contentType ?? "") && page.negotiated.varyAccept;
@@ -7673,112 +7782,200 @@ function failedMarkdownUrls(discovery) {
7673
7782
  return !negotiatedOk || !explicitOk || page.repeatedHashStable !== true;
7674
7783
  }).map((page) => page.htmlUrl);
7675
7784
  }
7785
+ function unstableMarkdownUrls(discovery) {
7786
+ return discovery.markdownAlternates.pages.filter((page) => confirmedMarkdown(page) !== void 0 && page.repeatedHashStable !== true).map((page) => page.htmlUrl);
7787
+ }
7676
7788
  function qualityFailureUrls(discovery) {
7677
7789
  return discovery.markdownAlternates.pages.filter((page) => {
7678
7790
  const quality = page.quality;
7679
- if (!quality) return true;
7680
- return !quality.frontmatterTitle || quality.h1Count !== 1 || !quality.codeFenceBalanced || quality.rawHtmlTags > 0 || quality.rawSvgTags > 0 || quality.rawScriptTags > 0 || quality.rawStyleTags > 0 || quality.navigationOnly || quality.repeatedLines > 0 || quality.wordRetentionRatio !== null && quality.wordRetentionRatio < .4;
7791
+ if (!quality) return false;
7792
+ return !quality.frontmatterTitle || quality.h1Count !== 1 || !quality.codeFenceBalanced || quality.rawHtmlTags > 0 || quality.rawSvgTags > 0 || quality.rawScriptTags > 0 || quality.rawStyleTags > 0 || quality.navigationOnly || quality.repeatedLines > 0 || quality.contentSketchCoverage !== null && quality.contentSketchCoverage !== void 0 && quality.contentSketchCoverage < .6 || quality.tabbedContent?.complete === false || quality.wordRetentionRatio !== null && quality.wordRetentionRatio < .4;
7681
7793
  }).map((page) => page.htmlUrl);
7682
7794
  }
7683
- function profileChecks(discovery) {
7684
- return [
7685
- "api",
7686
- "application",
7687
- "commerce"
7688
- ].map((profile) => check$1("profile", {
7689
- id: `profile-${profile}`,
7690
- status: "notApplicable",
7691
- title: `${profile[0]?.toUpperCase()}${profile.slice(1)} checks are not applicable`,
7692
- plainEnglish: discovery.profileApplicability[profile].reason,
7693
- action: "Choose this profile only when the site exposes that capability publicly.",
7694
- evidence: {
7695
- profile,
7696
- selectedProfile: discovery.profile
7697
- }
7698
- }));
7699
- }
7700
- function summariseChecks(checks) {
7701
- const count = (status) => checks.filter((item) => item.status === status).length;
7702
- return {
7703
- checks: checks.length,
7704
- passed: count("pass"),
7705
- warnings: count("warning"),
7706
- failed: count("fail"),
7707
- unknown: count("unknown"),
7708
- information: count("info"),
7709
- notApplicable: count("notApplicable")
7710
- };
7795
+ function confirmedMarkdown(page) {
7796
+ for (const response of [page.explicit, page.negotiated]) if (response?.status !== void 0 && response.status >= 200 && response.status < 300 && /^\s*text\/markdown\b/iu.test(response.contentType ?? "")) return response;
7711
7797
  }
7712
7798
  function markdownChecks(discovery) {
7713
7799
  const markdown = discovery.markdownAlternates;
7714
7800
  const failedUrls = failedMarkdownUrls(discovery);
7801
+ const unstableUrls = unstableMarkdownUrls(discovery);
7715
7802
  const qualityUrls = qualityFailureUrls(discovery);
7803
+ const hasEligibleHtml = markdown.eligibleHtmlPages > 0;
7804
+ const hasEvaluatedMarkdown = markdown.evaluatedPages > 0;
7716
7805
  const negotiatedPages = markdown.pages.filter((page) => page.negotiated?.status === 200 && /^\s*text\/markdown\b/iu.test(page.negotiated.contentType ?? "")).length;
7717
7806
  const explicitPages = markdown.pages.filter((page) => page.htmlAlternateUnique).length;
7718
7807
  const coverageComplete = markdown.eligibleHtmlPages > 0 && markdown.evaluatedPages === markdown.eligibleHtmlPages;
7719
7808
  const negotiationComplete = markdown.eligibleHtmlPages > 0 && negotiatedPages === markdown.eligibleHtmlPages && markdown.pages.every((page) => page.negotiated?.varyAccept) && markdown.pages.every((page) => !page.htmlAlternateUnique || page.explicitMatchesNegotiated === true);
7720
- const stable = markdown.eligibleHtmlPages > 0 && markdown.stableResponses === markdown.eligibleHtmlPages;
7809
+ const stable = markdown.evaluatedPages > 0 && markdown.stableResponses === markdown.evaluatedPages;
7721
7810
  const tokenHeaders = markdown.pages.filter((page) => {
7722
- return (page.explicit ?? page.negotiated)?.markdownTokens !== void 0 && (!page.explicit || !page.negotiated || page.explicit.markdownTokens === page.negotiated.markdownTokens);
7811
+ return confirmedMarkdown(page)?.markdownTokens !== void 0 && (!page.explicit || !page.negotiated || page.explicit.markdownTokens === page.negotiated.markdownTokens);
7723
7812
  }).length;
7813
+ const sizedPages = markdown.pages.filter((page) => {
7814
+ return confirmedMarkdown(page)?.characters !== void 0;
7815
+ });
7816
+ const largePages = sizedPages.filter((page) => {
7817
+ return (confirmedMarkdown(page)?.characters ?? 0) > 5e4;
7818
+ });
7819
+ const veryLargePages = sizedPages.filter((page) => {
7820
+ return (confirmedMarkdown(page)?.characters ?? 0) > 1e5;
7821
+ });
7822
+ const maxCharacters = Math.max(0, ...sizedPages.map((page) => confirmedMarkdown(page)?.characters ?? 0));
7823
+ const maxEstimatedTokens = Math.max(0, ...sizedPages.map((page) => confirmedMarkdown(page)?.estimatedTokens ?? 0));
7824
+ const parityPages = markdown.pages.filter((page) => page.quality?.contentSketchCoverage !== null && page.quality?.contentSketchCoverage !== void 0);
7825
+ const parityFailures = parityPages.filter((page) => (page.quality?.contentSketchCoverage ?? 0) < .6);
7826
+ const tabbedPages = markdown.pages.filter((page) => (page.quality?.tabbedContent?.detectedPanels ?? 0) > 0);
7827
+ const tabFailures = tabbedPages.filter((page) => page.quality?.tabbedContent?.complete === false);
7828
+ const tabUnknown = tabbedPages.filter((page) => page.quality?.tabbedContent?.complete === null);
7724
7829
  return [
7725
- check$1("representations", {
7830
+ check$2("representations", {
7726
7831
  id: "markdown-coverage",
7727
7832
  status: markdown.eligibleHtmlPages === 0 ? "unknown" : coverageComplete ? "pass" : "fail",
7728
- title: coverageComplete ? "Every successful HTML page has a Markdown representation" : "Markdown representation coverage is incomplete",
7729
- plainEnglish: `${markdown.evaluatedPages} of ${markdown.eligibleHtmlPages} successful HTML pages returned Markdown. ${explicitPages} advertised an explicit alternative and ${negotiatedPages} supported content negotiation.`,
7730
- action: coverageComplete ? "Keep the negotiated or explicit Markdown response stable as routes change." : "Return text/markdown through content negotiation or advertise one working Markdown alternative for each public HTML page.",
7833
+ title: coverageComplete ? "Every successful HTML page has a Markdown representation" : !hasEligibleHtml ? "Markdown representation coverage was not evaluated" : "Markdown representation coverage is incomplete",
7834
+ plainEnglish: hasEligibleHtml ? `${markdown.evaluatedPages} of ${markdown.eligibleHtmlPages} successful HTML pages returned Markdown. ${explicitPages} advertised an explicit alternative and ${negotiatedPages} supported content negotiation.` : "No successful HTML page was available, so Markdown representation coverage could not be evaluated.",
7835
+ action: !hasEligibleHtml ? "Resolve the crawl or page-fetch failure, then run this check again." : coverageComplete ? "Keep the negotiated or explicit Markdown response stable as routes change." : "Return text/markdown through content negotiation or advertise one working Markdown alternative for each public HTML page.",
7731
7836
  evidence: {
7732
7837
  eligibleHtmlPages: markdown.eligibleHtmlPages,
7733
7838
  advertisedPages: markdown.advertisedPages,
7734
7839
  negotiatedPages,
7735
7840
  evaluatedPages: markdown.evaluatedPages
7736
7841
  },
7737
- urls: failedUrls.slice(0, 25)
7842
+ urls: hasEligibleHtml ? failedUrls.slice(0, 25) : []
7738
7843
  }),
7739
- check$1("representations", {
7844
+ check$2("representations", {
7740
7845
  id: "markdown-token-estimates",
7741
- status: markdown.evaluatedPages === 0 ? "unknown" : tokenHeaders === markdown.evaluatedPages ? "pass" : "warning",
7742
- title: tokenHeaders === markdown.evaluatedPages ? "Every Markdown response includes a token estimate" : "Some Markdown responses are missing a stable token estimate",
7743
- plainEnglish: `${tokenHeaders} of ${markdown.evaluatedPages} evaluated pages returned X-Markdown-Tokens. Paired explicit and negotiated responses agreed where both existed. The value is an estimate, not an exact model-specific token count.`,
7744
- action: "Return a stable token estimate and keep it consistent when both explicit and negotiated responses exist.",
7846
+ status: markdown.evaluatedPages === 0 ? "unknown" : tokenHeaders === markdown.evaluatedPages ? "pass" : "info",
7847
+ title: !hasEvaluatedMarkdown ? "Markdown token estimates were not evaluated" : tokenHeaders === markdown.evaluatedPages ? "Every Markdown response includes a token estimate" : "Some Markdown responses are missing a stable token estimate",
7848
+ plainEnglish: hasEvaluatedMarkdown ? `${tokenHeaders} of ${markdown.evaluatedPages} evaluated pages returned X-Markdown-Tokens. Paired explicit and negotiated responses agreed where both existed. The value is an estimate, not an exact model-specific token count.` : "No confirmed Markdown response was available, so optional server token estimates could not be evaluated.",
7849
+ action: !hasEvaluatedMarkdown ? "Confirm a successful text/markdown representation, then run this check again." : tokenHeaders === markdown.evaluatedPages ? "Keep the estimate stable when both explicit and negotiated responses exist." : "No change is required. X-Markdown-Tokens is optional; local size estimates are reported separately.",
7745
7850
  urls: markdown.pages.filter((page) => {
7746
- return (page.explicit ?? page.negotiated)?.markdownTokens === void 0 || page.explicit !== void 0 && page.negotiated !== void 0 && page.explicit.markdownTokens !== page.negotiated.markdownTokens;
7851
+ const primary = confirmedMarkdown(page);
7852
+ if (!primary) return false;
7853
+ const paired = [page.explicit, page.negotiated].filter((response) => response?.status !== void 0 && response.status >= 200 && response.status < 300 && /^\s*text\/markdown\b/iu.test(response.contentType ?? ""));
7854
+ return primary?.markdownTokens === void 0 || paired.length === 2 && paired[0]?.markdownTokens !== paired[1]?.markdownTokens;
7747
7855
  }).map((page) => page.htmlUrl).slice(0, 25)
7748
7856
  }),
7749
- check$1("representations", {
7857
+ check$2("representations", {
7858
+ id: "markdown-size",
7859
+ status: sizedPages.length === 0 ? "unknown" : largePages.length === 0 ? "pass" : "warning",
7860
+ title: sizedPages.length === 0 ? "Markdown size was not measured" : largePages.length === 0 ? "Markdown representations stay within the review threshold" : "Some Markdown representations are large enough to review",
7861
+ plainEnglish: `${sizedPages.length} Markdown responses were measured locally. ${largePages.length} exceeded 50,000 characters and ${veryLargePages.length} exceeded 100,000 characters. These are processing-risk thresholds, not universal model limits.`,
7862
+ action: sizedPages.length === 0 ? "Confirm a successful text/markdown representation, then run this check again." : largePages.length === 0 ? "Keep important content early and retain stable section structure as pages grow." : "Split genuinely separate topics, keep navigation and repeated chrome out of Markdown, and put the most useful content early. Do not remove necessary detail just to hit a threshold.",
7863
+ evidence: {
7864
+ measuredPages: sizedPages.length,
7865
+ reviewThresholdCharacters: 5e4,
7866
+ highRiskThresholdCharacters: 1e5,
7867
+ largePages: largePages.length,
7868
+ veryLargePages: veryLargePages.length,
7869
+ maxCharacters,
7870
+ maxEstimatedTokens,
7871
+ heuristic: true
7872
+ },
7873
+ urls: largePages.map((page) => page.htmlUrl).slice(0, 25)
7874
+ }),
7875
+ check$2("representations", {
7876
+ id: "markdown-content-parity",
7877
+ status: parityPages.length === 0 ? "unknown" : parityFailures.length === 0 ? "pass" : "warning",
7878
+ title: parityPages.length === 0 ? "HTML and Markdown content parity was not measured" : parityFailures.length === 0 ? "Markdown retains sampled content from the HTML document" : "Some Markdown responses may omit important HTML content",
7879
+ plainEnglish: `${parityPages.length} pages were compared using bounded hashes sampled across the main HTML content. ${parityFailures.length} retained less than 60% of that sample. This detects likely omissions without storing full page bodies.`,
7880
+ action: parityPages.length === 0 ? "Confirm a successful text/markdown representation, then run this check again." : parityFailures.length === 0 ? "Keep the shared conversion path covered as templates and components change." : "Compare the affected HTML and Markdown around the missing sections, then fix the shared converter or component serialization.",
7881
+ evidence: {
7882
+ evaluatedPages: parityPages.length,
7883
+ failedPages: parityFailures.length,
7884
+ minimumCoverage: .6,
7885
+ sampling: "bounded-content-shingles"
7886
+ },
7887
+ urls: parityFailures.map((page) => page.htmlUrl).slice(0, 25)
7888
+ }),
7889
+ check$2("representations", {
7890
+ id: "markdown-tab-serialization",
7891
+ status: tabbedPages.length === 0 ? "notApplicable" : tabFailures.length > 0 ? "warning" : tabUnknown.length > 0 ? "unknown" : "pass",
7892
+ title: tabbedPages.length === 0 ? "No accessible tabbed content was detected" : tabFailures.length > 0 ? "Some tab panels are missing from Markdown" : tabUnknown.length > 0 ? "Some tab panels were too small to compare reliably" : "Markdown retains detected tab panels",
7893
+ plainEnglish: tabbedPages.length === 0 ? "The crawled HTML did not contain ARIA tablists with tab panels, so this check does not apply." : `${tabbedPages.length} pages contained accessible tab panels. ${tabFailures.length} did not retain every evaluated panel in Markdown, and ${tabUnknown.length} had no panel large enough for a reliable comparison.`,
7894
+ action: tabFailures.length === 0 ? tabUnknown.length > 0 ? "Review the affected page directly if the short tab labels or values are important to the document." : "Keep every panel serialized in document order even when only one tab is visible by default." : "Serialize all tab panels into the Markdown document with headings instead of exporting only the initially active panel.",
7895
+ evidence: {
7896
+ tabbedPages: tabbedPages.length,
7897
+ failedPages: tabFailures.length,
7898
+ unknownPages: tabUnknown.length
7899
+ },
7900
+ urls: [...tabFailures, ...tabUnknown].map((page) => page.htmlUrl).slice(0, 25)
7901
+ }),
7902
+ check$2("representations", {
7750
7903
  id: "markdown-negotiation",
7751
7904
  status: markdown.eligibleHtmlPages === 0 ? "unknown" : negotiationComplete && discovery.contentNegotiation.qZeroHonoured ? "pass" : "fail",
7752
- title: negotiationComplete && discovery.contentNegotiation.qZeroHonoured ? "Markdown content negotiation works across the site" : "Markdown content negotiation needs attention",
7753
- plainEnglish: `${negotiatedPages} of ${markdown.eligibleHtmlPages} pages returned negotiated Markdown. ${markdown.exactByteMatches} of ${explicitPages} paired explicit responses matched byte for byte. A request that refuses Markdown ${discovery.contentNegotiation.qZeroHonoured ? "received HTML" : "did not produce confirmed HTML evidence"}.`,
7754
- action: "Honour Accept q-values, send Vary: Accept, and keep paired explicit and negotiated responses identical when both exist.",
7905
+ title: !hasEligibleHtml ? "Markdown content negotiation was not evaluated" : negotiationComplete && discovery.contentNegotiation.qZeroHonoured ? "Markdown content negotiation works across the site" : "Markdown content negotiation needs attention",
7906
+ plainEnglish: hasEligibleHtml ? `${negotiatedPages} of ${markdown.eligibleHtmlPages} pages returned negotiated Markdown. ${markdown.exactByteMatches} of ${explicitPages} paired explicit responses matched byte for byte. A request that refuses Markdown ${discovery.contentNegotiation.qZeroHonoured ? "received HTML" : "did not produce confirmed HTML evidence"}.` : "No successful HTML page was available, so content negotiation could not be evaluated.",
7907
+ action: hasEligibleHtml ? "Honour Accept q-values, send Vary: Accept, and keep paired explicit and negotiated responses identical when both exist." : "Resolve the crawl or page-fetch failure, then run this check again.",
7755
7908
  evidence: {
7756
7909
  negotiatedPages,
7757
7910
  explicitPages,
7758
7911
  exactByteMatches: markdown.exactByteMatches,
7759
7912
  qZero: discovery.contentNegotiation
7760
7913
  },
7761
- urls: failedUrls.slice(0, 25)
7914
+ urls: hasEligibleHtml ? failedUrls.slice(0, 25) : []
7762
7915
  }),
7763
- check$1("representations", {
7916
+ check$2("representations", {
7764
7917
  id: "markdown-determinism",
7765
- status: markdown.eligibleHtmlPages === 0 ? "unknown" : stable ? "pass" : "fail",
7766
- title: stable ? "Repeated Markdown responses are stable" : "Repeated Markdown responses changed during the audit",
7767
- plainEnglish: `${markdown.stableResponses} of ${markdown.eligibleHtmlPages} repeated Markdown responses kept the same SHA-256 digest.`,
7768
- action: "Remove timestamps, random output, or other runtime rewriting that changes the same Markdown response between requests.",
7918
+ status: markdown.evaluatedPages === 0 ? "unknown" : stable ? "pass" : "fail",
7919
+ title: !hasEvaluatedMarkdown ? "Markdown response stability was not evaluated" : stable ? "Repeated Markdown responses are stable" : "Repeated Markdown responses changed during the audit",
7920
+ plainEnglish: hasEvaluatedMarkdown ? `${markdown.stableResponses} of ${markdown.evaluatedPages} repeated Markdown responses kept the same SHA-256 digest.` : "No confirmed Markdown response was available for a repeated stability check.",
7921
+ action: !hasEvaluatedMarkdown ? "Confirm a successful text/markdown representation, then run this check again." : stable ? "Keep repeated Markdown responses byte-stable as templates and data change." : "Remove timestamps, random output, or other runtime rewriting that changes the same Markdown response between requests.",
7769
7922
  evidence: { stableResponses: markdown.stableResponses },
7770
- urls: failedUrls.slice(0, 25)
7923
+ urls: unstableUrls.slice(0, 25)
7771
7924
  }),
7772
- check$1("representations", {
7925
+ check$2("representations", {
7773
7926
  id: "markdown-quality",
7774
7927
  status: markdown.evaluatedPages === 0 ? "unknown" : qualityUrls.length === 0 ? "pass" : "warning",
7775
- title: qualityUrls.length === 0 ? "Markdown keeps the useful document structure cleanly" : "Some Markdown alternatives need an extraction review",
7776
- plainEnglish: qualityUrls.length === 0 ? "The evaluated alternatives retained one H1, frontmatter titles, balanced code fences, useful copy, and no leaked SVG, script, style, or layout tags." : `${qualityUrls.length} Markdown alternative${qualityUrls.length === 1 ? "" : "s"} lost important structure, looked navigation-only, or leaked presentation markup.`,
7777
- action: "Open the affected Markdown directly and fix the shared converter or component exclusion rules rather than patching generated files.",
7928
+ title: !hasEvaluatedMarkdown ? "Markdown quality was not evaluated" : qualityUrls.length === 0 ? "Markdown keeps the useful document structure cleanly" : "Some Markdown alternatives need an extraction review",
7929
+ plainEnglish: !hasEvaluatedMarkdown ? "No confirmed Markdown response was available for structure and extraction checks." : qualityUrls.length === 0 ? "The evaluated alternatives retained one H1, frontmatter titles, balanced code fences, useful copy, and no leaked SVG, script, style, or layout tags." : `${qualityUrls.length} Markdown alternative${qualityUrls.length === 1 ? "" : "s"} lost important structure, looked navigation-only, or leaked presentation markup.`,
7930
+ action: !hasEvaluatedMarkdown ? "Confirm a successful text/markdown representation, then run this check again." : qualityUrls.length === 0 ? "Keep the shared Markdown conversion path covered as templates and components change." : "Open the affected Markdown directly and fix the shared converter or component exclusion rules rather than patching generated files.",
7778
7931
  urls: qualityUrls.slice(0, 25)
7779
7932
  })
7780
7933
  ];
7781
7934
  }
7935
+ //#endregion
7936
+ //#region packages/core/dist/analyze/crawler/agent-readiness.js
7937
+ function check$1(section, input) {
7938
+ return {
7939
+ section,
7940
+ ...input
7941
+ };
7942
+ }
7943
+ function section$2(id, title, checks) {
7944
+ return {
7945
+ id,
7946
+ title,
7947
+ checks
7948
+ };
7949
+ }
7950
+ function profileChecks(discovery) {
7951
+ return [
7952
+ "api",
7953
+ "application",
7954
+ "commerce"
7955
+ ].map((profile) => check$1("profile", {
7956
+ id: `profile-${profile}`,
7957
+ status: "notApplicable",
7958
+ title: `${profile[0]?.toUpperCase()}${profile.slice(1)} checks are not applicable`,
7959
+ plainEnglish: discovery.profileApplicability[profile].reason,
7960
+ action: "Choose this profile only when the site exposes that capability publicly.",
7961
+ evidence: {
7962
+ profile,
7963
+ selectedProfile: discovery.profile
7964
+ }
7965
+ }));
7966
+ }
7967
+ function summariseChecks(checks) {
7968
+ const count = (status) => checks.filter((item) => item.status === status).length;
7969
+ return {
7970
+ checks: checks.length,
7971
+ passed: count("pass"),
7972
+ warnings: count("warning"),
7973
+ failed: count("fail"),
7974
+ unknown: count("unknown"),
7975
+ information: count("info"),
7976
+ notApplicable: count("notApplicable")
7977
+ };
7978
+ }
7782
7979
  function contentSignalsCheck(discovery, robotsContentSignals) {
7783
7980
  const signals = discovery.contentSignals;
7784
7981
  const robotsDeclared = robotsContentSignals ?? [];
@@ -7879,8 +8076,8 @@ function discoveryChecks(discovery, robotsContentSignals) {
7879
8076
  check$1("discovery", {
7880
8077
  id: "route-manifest",
7881
8078
  status: !discovery.routeManifest.valid ? "info" : discovery.routeManifest.missingHtmlRoutes.length === 0 && discovery.routeManifest.missingMarkdownRoutes.length === 0 && discovery.routeManifest.orphanMarkdownRoutes.length === 0 ? "pass" : "warning",
7882
- title: discovery.routeManifest.valid ? "The public route manifest agrees with the crawl" : "No public route manifest was available",
7883
- plainEnglish: discovery.routeManifest.valid ? `The manifest declared ${discovery.routeManifest.declaredHtmlRoutes.length} HTML routes and ${discovery.routeManifest.declaredMarkdownRoutes.length} Markdown routes. Missing or orphan routes are listed in the evidence.` : "A public route manifest is optional. Without one, this crawl cannot prove that no orphan Markdown files were deployed.",
8079
+ title: discovery.routeManifest.valid ? "The public route manifest is valid" : "No public route manifest was available",
8080
+ plainEnglish: discovery.routeManifest.valid ? `The manifest declared ${discovery.routeManifest.declaredHtmlRoutes.length} HTML routes and ${discovery.routeManifest.declaredMarkdownRoutes.length} Markdown routes. This bounded crawl did not treat unvisited routes as missing.` : "A public route manifest is optional. Without one, this crawl cannot prove that no orphan Markdown files were deployed.",
7884
8081
  action: "Generate one deterministic route inventory from the build when exact HTML and Markdown parity matters.",
7885
8082
  evidence: discovery.routeManifest
7886
8083
  })
@@ -8011,25 +8208,42 @@ function accessChecks(report, discovery) {
8011
8208
  const blocked = bots.filter((bot) => bot.allowed === false);
8012
8209
  const robotsKnown = report.ai?.robotsTxt?.availability === "available" || report.ai?.robotsTxt?.availability === "absent";
8013
8210
  const protocol = discovery.protocolVariants;
8014
- return [check$1("access", {
8015
- id: "crawler-access",
8016
- status: !robotsKnown ? "unknown" : blocked.length > 0 ? "warning" : "pass",
8017
- title: blocked.length ? "robots.txt blocks selected crawler tokens" : "No selected crawler token is blocked at the start URL",
8018
- plainEnglish: robotsKnown ? `${blocked.length} of ${bots.length} selected crawler tokens were blocked by the start URL robots.txt policy.` : "The robots.txt response was not stable enough to make a crawler-access claim.",
8019
- action: "Keep blocks intentional. A crawler token being allowed here does not prove that the service will crawl, index, or cite the site.",
8020
- evidence: {
8021
- availability: report.ai?.robotsTxt?.availability,
8022
- blocked,
8023
- botAccess: bots
8024
- }
8025
- }), check$1("access", {
8026
- id: "protocol-canonicalization",
8027
- status: protocol.http.permanentRedirectToHttps === true && protocol.hstsOnStartPage === true ? "pass" : protocol.http.permanentRedirectToHttps === null ? "unknown" : "warning",
8028
- title: protocol.http.permanentRedirectToHttps === true && protocol.hstsOnStartPage === true ? "HTTP redirects permanently to HTTPS and HTTPS sends HSTS" : "Protocol canonicalization needs attention or could not be proven",
8029
- plainEnglish: `The HTTP variant ${protocol.http.permanentRedirectToHttps === true ? "redirected permanently to HTTPS" : "did not produce a confirmed permanent HTTPS redirect"}. The HTTPS start page ${protocol.hstsOnStartPage ? "sent HSTS" : "did not provide confirmed HSTS evidence"}.`,
8030
- action: "Use one permanent HTTP-to-HTTPS redirect and send HSTS only after HTTPS is stable across the site.",
8031
- evidence: protocol
8032
- })];
8211
+ const authenticationGates = report.issues.filter((issue) => issue.ruleId === "soft_authentication_gate");
8212
+ const clientRenderedPages = report.issues.filter((issue) => issue.ruleId === "client_rendered_content");
8213
+ return [
8214
+ check$1("access", {
8215
+ id: "crawler-access",
8216
+ status: !robotsKnown ? "unknown" : blocked.length > 0 ? "warning" : "pass",
8217
+ title: blocked.length ? "robots.txt blocks selected crawler tokens" : "No selected crawler token is blocked at the start URL",
8218
+ plainEnglish: robotsKnown ? `${blocked.length} of ${bots.length} selected crawler tokens were blocked by the start URL robots.txt policy.` : "The robots.txt response was not stable enough to make a crawler-access claim.",
8219
+ action: "Keep blocks intentional. A crawler token being allowed here does not prove that the service will crawl, index, or cite the site.",
8220
+ evidence: {
8221
+ availability: report.ai?.robotsTxt?.availability,
8222
+ blocked,
8223
+ botAccess: bots
8224
+ }
8225
+ }),
8226
+ check$1("access", {
8227
+ id: "public-content-access",
8228
+ status: authenticationGates.length > 0 || clientRenderedPages.length > 0 ? "warning" : "pass",
8229
+ title: authenticationGates.length > 0 || clientRenderedPages.length > 0 ? "Some public content may be difficult for constrained clients to reach" : "No login gate or empty initial document was detected",
8230
+ plainEnglish: `${authenticationGates.length} crawled pages looked like login gates and ${clientRenderedPages.length} relied on JavaScript to replace an empty or near-empty initial document. These are general site-health observations reused here because they affect agents that do not maintain sessions or run JavaScript.`,
8231
+ action: authenticationGates.length > 0 || clientRenderedPages.length > 0 ? "Make intended public content available without a session and include its important document content in the initial HTML or a complete server-generated representation." : "Keep important public content available without a session or client-side rendering requirement.",
8232
+ evidence: {
8233
+ authenticationGates: authenticationGates.length,
8234
+ clientRenderedPages: clientRenderedPages.length
8235
+ },
8236
+ urls: [...authenticationGates, ...clientRenderedPages].map((issue) => issue.url).slice(0, 25)
8237
+ }),
8238
+ check$1("access", {
8239
+ id: "protocol-canonicalization",
8240
+ status: protocol.http.permanentRedirectToHttps === true && protocol.hstsOnStartPage === true ? "pass" : protocol.http.permanentRedirectToHttps === null ? "unknown" : "warning",
8241
+ title: protocol.http.permanentRedirectToHttps === true && protocol.hstsOnStartPage === true ? "HTTP redirects permanently to HTTPS and HTTPS sends HSTS" : "Protocol canonicalization needs attention or could not be proven",
8242
+ plainEnglish: `The HTTP variant ${protocol.http.permanentRedirectToHttps === true ? "redirected permanently to HTTPS" : "did not produce a confirmed permanent HTTPS redirect"}. The HTTPS start page ${protocol.hstsOnStartPage ? "sent HSTS" : "did not provide confirmed HSTS evidence"}.`,
8243
+ action: "Use one permanent HTTP-to-HTTPS redirect and send HSTS only after HTTPS is stable across the site.",
8244
+ evidence: protocol
8245
+ })
8246
+ ];
8033
8247
  }
8034
8248
  function identityChecks(report) {
8035
8249
  const types = new Set(report.pages.flatMap((page) => page.schemaTypes ?? []));
@@ -9558,6 +9772,48 @@ const RULE_DEFINITIONS = [
9558
9772
  suggestedCommands: ["seo crawl --sitemap-url <url> --health --json"]
9559
9773
  }
9560
9774
  },
9775
+ {
9776
+ id: "soft_authentication_gate",
9777
+ title: "Page appears to be an authentication gate",
9778
+ category: "response",
9779
+ defaultSeverity: "medium",
9780
+ whyItMatters: "The URL returned a successful HTML response, but the visible document looks like a login gate rather than the intended public page. Users and crawlers may receive a 200 response without the content they expected.",
9781
+ howToFix: "Confirm whether authentication is intentional. Public URLs should return their public content without a login form. Protected URLs should use a clear 401 or 403 response where appropriate and should not appear in public sitemaps or internal crawl paths.",
9782
+ impactIfIgnored: "A login page can be mistaken for the intended content, producing misleading crawl results and weak search or agent access.",
9783
+ howToVerify: "Fetch the affected URL without a session and confirm it returns the intended public page, or a deliberate authentication status for protected content.",
9784
+ agentHints: {
9785
+ evidenceFields: ["page.softAuthenticationGate", "page.finalUrl"],
9786
+ suggestedCommands: ["seo crawl <url> --max-pages 1 --json"]
9787
+ }
9788
+ },
9789
+ {
9790
+ id: "client_rendered_content",
9791
+ title: "Important content requires JavaScript rendering",
9792
+ category: "response",
9793
+ defaultSeverity: "medium",
9794
+ whyItMatters: "The raw HTML contained very little content and the useful document appeared only after JavaScript rendering. Some crawlers, agents, previews, and constrained clients do not run the page JavaScript.",
9795
+ howToFix: "Render the important page content into the initial HTML, or publish a complete server-generated representation such as negotiated Markdown. Keep navigation, headings, canonicals, and indexability signals available without client execution.",
9796
+ impactIfIgnored: "Clients that do not execute JavaScript may see an empty shell or miss important content and links.",
9797
+ howToVerify: "Re-run the crawl and confirm the raw and rendered content observations contain the same important document content.",
9798
+ agentHints: {
9799
+ evidenceFields: ["page.fetchDiagnostics.rendering.documentDifference", "page.usedJs"],
9800
+ suggestedCommands: ["seo crawl <url> --json"]
9801
+ }
9802
+ },
9803
+ {
9804
+ id: "soft_404",
9805
+ title: "Unknown URLs return a successful response",
9806
+ category: "response",
9807
+ defaultSeverity: "high",
9808
+ whyItMatters: "Known-nonexistent URLs returned a successful response or redirected to another successful page. Search engines and users cannot reliably distinguish missing content from a real page.",
9809
+ howToFix: "Return 404 or 410 for missing URLs. If a replacement exists, redirect only the specific retired URL to its closest relevant replacement rather than sending every unknown path to a generic page.",
9810
+ impactIfIgnored: "Missing URLs can remain in crawl queues, waste crawl attention, and create confusing search or monitoring evidence.",
9811
+ howToVerify: "Request several known-nonexistent paths and confirm each returns 404 or 410 without redirecting to a generic successful page.",
9812
+ agentHints: {
9813
+ evidenceFields: ["report.siteChecks.soft404"],
9814
+ suggestedCommands: ["seo crawl <url> --json"]
9815
+ }
9816
+ },
9561
9817
  {
9562
9818
  id: "client_error",
9563
9819
  title: "Client error",
@@ -9591,7 +9847,8 @@ const RULE_DEFINITIONS = [
9591
9847
  evidenceFields: [
9592
9848
  "page.url",
9593
9849
  "page.finalUrl",
9594
- "page.status"
9850
+ "page.status",
9851
+ "page.metaRefresh"
9595
9852
  ],
9596
9853
  suggestedCommands: ["seo redirect-trace --url <url> --json"]
9597
9854
  }
@@ -10047,6 +10304,8 @@ const RULE_RECOMMENDATIONS = {
10047
10304
  x_robots_noindex: "review",
10048
10305
  robots_blocked: "review",
10049
10306
  crawler_access_blocked: "review",
10307
+ soft_authentication_gate: "review",
10308
+ client_rendered_content: "review",
10050
10309
  orphan_page: "review",
10051
10310
  redirected_url: "review",
10052
10311
  slow_response: "review",
@@ -10083,6 +10342,7 @@ function comparableUrl(value) {
10083
10342
  }
10084
10343
  }
10085
10344
  function isRedirectedPage(page) {
10345
+ if (page.metaRefresh) return true;
10086
10346
  const requested = comparableUrl(page.url);
10087
10347
  const final = comparableUrl(page.finalUrl);
10088
10348
  return Boolean(requested && final && requested !== final);
@@ -10137,6 +10397,28 @@ const WEAK_VALUABLE_INLINKS = 1;
10137
10397
  const QUERY_COVERAGE_MIN_IMPRESSIONS = 50;
10138
10398
  const QUERY_COVERAGE_MIN = .6;
10139
10399
  const OVERSIZED_IMAGE_CANDIDATE_LIMIT = 2e3;
10400
+ const EMPTY_INITIAL_HTML_CHARACTERS = 200;
10401
+ const SUBSTANTIVE_RENDERED_CHARACTERS = 500;
10402
+ function clientRenderedContentEvidence(page) {
10403
+ const rendering = page.fetchDiagnostics?.rendering;
10404
+ const difference = rendering?.documentDifference;
10405
+ if (rendering?.status !== "rendered" || !difference?.changed.includes("content")) return;
10406
+ const rawCharacters = difference.raw.content.characters;
10407
+ const renderedCharacters = difference.rendered.content.characters;
10408
+ if (rawCharacters >= EMPTY_INITIAL_HTML_CHARACTERS || renderedCharacters < SUBSTANTIVE_RENDERED_CHARACTERS || renderedCharacters < rawCharacters * 3) return;
10409
+ return {
10410
+ rawCharacters,
10411
+ rawWords: difference.raw.content.wordCount,
10412
+ renderedCharacters,
10413
+ renderedWords: difference.rendered.content.wordCount,
10414
+ changed: difference.changed,
10415
+ thresholds: {
10416
+ emptyInitialHtmlCharacters: EMPTY_INITIAL_HTML_CHARACTERS,
10417
+ substantiveRenderedCharacters: SUBSTANTIVE_RENDERED_CHARACTERS,
10418
+ minimumGrowthRatio: 3
10419
+ }
10420
+ };
10421
+ }
10140
10422
  function issue(ruleId, page, detail, evidence) {
10141
10423
  const rule = explainRule(ruleId);
10142
10424
  if (!rule) throw new Error(`Missing rule guidance for ${ruleId}`);
@@ -10335,11 +10617,18 @@ function auditCrawlPages(pages, opts = {}) {
10335
10617
  }
10336
10618
  if (page.status < 200) continue;
10337
10619
  const redirected = isRedirectedPage(page);
10338
- if (redirected) issues.push(issue("redirected_url", page, `Final URL: ${page.finalUrl}`, {
10339
- requestedUrl: page.url,
10340
- finalUrl: page.finalUrl,
10341
- status: page.status
10342
- }));
10620
+ if (redirected) {
10621
+ const redirectTarget = page.metaRefresh?.targetUrl ?? page.finalUrl;
10622
+ issues.push(issue("redirected_url", page, `Final URL: ${redirectTarget}`, {
10623
+ requestedUrl: page.url,
10624
+ finalUrl: redirectTarget,
10625
+ status: page.status,
10626
+ ...page.metaRefresh ? {
10627
+ redirectType: "meta-refresh",
10628
+ delaySeconds: page.metaRefresh.delaySeconds
10629
+ } : {}
10630
+ }));
10631
+ }
10343
10632
  const redirectChain = page.fetchDiagnostics?.redirectChain ?? [];
10344
10633
  if (redirectChain.length > 1) issues.push(issue("redirect_chain", page, `${redirectChain.length} hops`, {
10345
10634
  redirectChain,
@@ -10351,6 +10640,9 @@ function auditCrawlPages(pages, opts = {}) {
10351
10640
  }));
10352
10641
  if (page.auditScope === "status") continue;
10353
10642
  if (redirected) continue;
10643
+ if (page.softAuthenticationGate) issues.push(issue("soft_authentication_gate", page, "A password form and login-specific page signals were observed.", page.softAuthenticationGate));
10644
+ const renderedContent = clientRenderedContentEvidence(page);
10645
+ if (renderedContent) issues.push(issue("client_rendered_content", page, "The raw response was an empty or near-empty shell.", renderedContent));
10354
10646
  if (isHtmlPage(page) && (page.sizeBytes ?? 0) > LARGE_HTML_BYTES) issues.push(issue("large_html", page, `${Math.round((page.sizeBytes ?? 0) / 1024)} KB`, {
10355
10647
  sizeBytes: page.sizeBytes,
10356
10648
  thresholdBytes: LARGE_HTML_BYTES
@@ -10955,7 +11247,7 @@ function createCrawlReport(input) {
10955
11247
  const requests = sortRequests(sanitizeTenantValue(input.requests ?? []));
10956
11248
  const requestEvidenceStatus = input.requestEvidenceStatus ?? (input.requests ? "available" : "unavailable");
10957
11249
  if (requestEvidenceStatus === "unavailable" && requests.length > 0) throw new Error("Unavailable request evidence cannot include request observations.");
10958
- const safeIssues = sanitizeIssues(input.issues ?? [...auditCrawlRequests(requests), ...auditCrawlPages(safePagesWithLinks, { startUrl: config.url })]);
11250
+ const safeIssues = sanitizeIssues([...input.issues ?? [...auditCrawlRequests(requests), ...auditCrawlPages(safePagesWithLinks, { startUrl: config.url })], ...input.additionalIssues ?? []]);
10959
11251
  const pages = safePagesWithLinks;
10960
11252
  return {
10961
11253
  id: input.id ?? crawlRunId(),
@@ -10984,6 +11276,7 @@ function createCrawlReport(input) {
10984
11276
  ...input.ai ? { ai: sanitizeTenantValue(input.ai) } : {},
10985
11277
  ...input.sitemapDiscovery ? { sitemapDiscovery: sanitizeTenantValue(input.sitemapDiscovery) } : {},
10986
11278
  ...input.externalLinkVerification ? { externalLinkVerification: sanitizeTenantValue(input.externalLinkVerification) } : {},
11279
+ ...input.siteChecks ? { siteChecks: sanitizeTenantValue(input.siteChecks) } : {},
10987
11280
  ...input.agentDiscovery ? { agentDiscovery: sanitizeTenantValue(input.agentDiscovery) } : {},
10988
11281
  warnings: sanitizeMessages(input.warnings).sort(compareText$13),
10989
11282
  caveats: sanitizeMessages(input.caveats).sort(compareText$13)
@@ -11181,7 +11474,7 @@ function latestCrawlReport(site, store = crawlReportStore) {
11181
11474
  //#region packages/core/dist/analyze/crawler/crawl-caveats.js
11182
11475
  function crawlCaveats(input) {
11183
11476
  const caveats = input.cancelled ? ["Crawl cancelled before all queued URLs finished."] : input.pageLimitReached ? [`Stopped after reaching maxPages (${input.maxPages}).`] : [];
11184
- if (input.queueSafetySkippedUrls > 0) caveats.push(`Left ${input.queueSafetySkippedUrls} eligible same-origin URLs unqueued to keep this crawl bounded. Increase --max-pages to inspect more.`);
11477
+ if (input.queueSafetySkippedUrls > 0) caveats.push(input.queueSafetyInventoryCapped ? `Left at least ${input.queueSafetySkippedUrls} unique eligible same-origin URLs unqueued to keep this crawl bounded. The excluded-URL inventory reached its reporting cap. Increase --max-pages to inspect more.` : `Left ${input.queueSafetySkippedUrls} unique eligible same-origin URLs unqueued to keep this crawl bounded. Increase --max-pages to inspect more.`);
11185
11478
  if (input.originBackpressureSkippedUrls > 0) caveats.push(`Stopped ${input.originBackpressureSkippedUrls} queued URL${input.originBackpressureSkippedUrls === 1 ? "" : "s"} because the origin stayed slow. This is incomplete crawl evidence, not a failed URL check.`);
11186
11479
  if (input.memoryPressureSkippedUrls > 0) caveats.push(`Left ${input.memoryPressureSkippedUrls} eligible URL${input.memoryPressureSkippedUrls === 1 ? "" : "s"} unchecked after the local memory safety limit was reached. Start with --health or lower --max-pages.`);
11187
11480
  return caveats;
@@ -11544,6 +11837,75 @@ function observationFromPage(requestedUrl, page) {
11544
11837
  };
11545
11838
  }
11546
11839
  //#endregion
11840
+ //#region packages/core/dist/analyze/crawler/site-checks.js
11841
+ const PROBE_COUNT = 2;
11842
+ function probeUrls(startUrl) {
11843
+ const origin = new URL(startUrl).origin;
11844
+ const seed = createHash("sha256").update(origin).digest("hex").slice(0, 12);
11845
+ return Array.from({ length: PROBE_COUNT }, (_, index) => new URL(`/.well-known/seo-audit/not-found-${seed}-${index + 1}`, origin).toString());
11846
+ }
11847
+ function probeObservation(url, result) {
11848
+ const request = result.request;
11849
+ if (!request) return {
11850
+ url,
11851
+ error: result.warning ?? "No response evidence."
11852
+ };
11853
+ if (request.outcome === "response") return {
11854
+ url,
11855
+ status: request.status,
11856
+ finalUrl: request.finalUrl,
11857
+ redirected: request.finalUrl !== url,
11858
+ accessBlocked: Boolean(request.accessBlock)
11859
+ };
11860
+ return {
11861
+ url,
11862
+ error: request.outcome === "failure" ? request.error : `Probe skipped: ${request.reason}`
11863
+ };
11864
+ }
11865
+ async function collectCrawlSiteChecks(input) {
11866
+ const probes = [];
11867
+ for (const url of probeUrls(input.startUrl)) {
11868
+ if (input.signal?.aborted) break;
11869
+ const result = await input.fetchStatusPage(url, {
11870
+ js: "off",
11871
+ refresh: true,
11872
+ timeoutMs: input.timeoutMs,
11873
+ signal: input.signal,
11874
+ writeCache: false,
11875
+ respectRobots: input.respectRobots
11876
+ });
11877
+ probes.push(probeObservation(url, result));
11878
+ }
11879
+ const conclusive = probes.filter((probe) => probe.status !== void 0 && !probe.accessBlocked);
11880
+ const successful = conclusive.filter((probe) => (probe.status ?? 0) >= 200 && (probe.status ?? 0) < 400);
11881
+ const correctMissing = conclusive.filter((probe) => probe.status === 404 || probe.status === 410);
11882
+ const status = conclusive.length !== PROBE_COUNT ? "unknown" : successful.length === PROBE_COUNT ? "warning" : correctMissing.length === PROBE_COUNT ? "pass" : "unknown";
11883
+ const checks = { soft404: {
11884
+ status,
11885
+ probes,
11886
+ probeLimit: PROBE_COUNT,
11887
+ complete: probes.length === PROBE_COUNT
11888
+ } };
11889
+ if (status !== "warning") return {
11890
+ checks,
11891
+ issues: []
11892
+ };
11893
+ const rule = explainRule("soft_404");
11894
+ if (!rule) throw new Error("Missing rule guidance for soft_404");
11895
+ return {
11896
+ checks,
11897
+ issues: [{
11898
+ ruleId: "soft_404",
11899
+ title: rule.title,
11900
+ category: rule.category,
11901
+ severity: rule.defaultSeverity,
11902
+ url: new URL("/", input.startUrl).toString(),
11903
+ detail: `${successful.length} known-nonexistent paths returned a successful response or redirect.`,
11904
+ evidence: checks.soft404
11905
+ }]
11906
+ };
11907
+ }
11908
+ //#endregion
11547
11909
  //#region packages/core/dist/analyze/crawler/site-crawl-ai-signals.js
11548
11910
  const MAX_AUXILIARY_RESPONSE_BYTES = 2 * 1024 * 1024;
11549
11911
  const AI_BOT_USER_AGENTS = [
@@ -12263,6 +12625,10 @@ async function crawlOne(url, opts = {}) {
12263
12625
  mainContentHash: hashText(extracted.contentText),
12264
12626
  textRatio: fetched.html.length ? Math.min(1, extracted.contentText.length / fetched.html.length) : void 0,
12265
12627
  contentSample: truncate$1(extracted.contentText, 300),
12628
+ contentSketch: crawlerEvidence.contentSketch,
12629
+ softAuthenticationGate: crawlerEvidence.softAuthenticationGate,
12630
+ metaRefresh: crawlerEvidence.metaRefresh,
12631
+ tabbedContent: crawlerEvidence.tabbedContent,
12266
12632
  lang: extracted.lang,
12267
12633
  hasViewport: extracted.hasViewport,
12268
12634
  isHttps: new URL(extracted.finalUrl).protocol === "https:",
@@ -12312,9 +12678,9 @@ async function crawlOne(url, opts = {}) {
12312
12678
  hasQapageSchema: extracted.schemaTypes.includes("QAPage")
12313
12679
  }
12314
12680
  };
12315
- page.indexability = indexabilityReason(page);
12681
+ page.indexability = page.metaRefresh ? `Meta refresh to ${page.metaRefresh.targetUrl}` : indexabilityReason(page);
12316
12682
  page.indexable = !page.indexability;
12317
- page.declaredIndexability = hasMetaRobotsDirective(page.metaRobots, "noindex") || hasXRobotsDirective(page.xRobotsTag, "noindex") ? "noindex" : page.robotsTxt?.allowed === false ? "robots-blocked" : page.canonicalStatus === "conflicting" ? "canonical-conflict" : page.canonical && !sameDocumentUrl(page.canonical, page.finalUrl) ? "canonical-hint-other" : "indexable-candidate";
12683
+ page.declaredIndexability = page.metaRefresh ? "meta-refresh" : hasMetaRobotsDirective(page.metaRobots, "noindex") || hasXRobotsDirective(page.xRobotsTag, "noindex") ? "noindex" : page.robotsTxt?.allowed === false ? "robots-blocked" : page.canonicalStatus === "conflicting" ? "canonical-conflict" : page.canonical && !sameDocumentUrl(page.canonical, page.finalUrl) ? "canonical-hint-other" : "indexable-candidate";
12318
12684
  return {
12319
12685
  request: responseObservation(url, fetched, "complete"),
12320
12686
  page,
@@ -13108,6 +13474,9 @@ async function crawlSite(input, dependencies) {
13108
13474
  let robotsDeferredUrls = 0;
13109
13475
  let originBackpressureSkippedUrls = 0;
13110
13476
  let queueSafetySkippedUrls = 0;
13477
+ const queueSafetyDeferredUrls = /* @__PURE__ */ new Set();
13478
+ const maxTrackedQueueSafetyUrls = Math.min(5e4, Math.max(1e3, config.maxPages * 5));
13479
+ let queueSafetyInventoryCapped = false;
13111
13480
  let failedUrls = 0;
13112
13481
  let observedInternalLinks = 0;
13113
13482
  let memoryLimited = false;
@@ -13212,13 +13581,16 @@ async function crawlSite(input, dependencies) {
13212
13581
  return;
13213
13582
  }
13214
13583
  if (queue.size + visited.size + inFlight.size >= config.maxPages * 5) {
13215
- recordSkip("queue-safety-limit");
13216
- queueSafetySkippedUrls += 1;
13217
- emitStatus("url_skipped", {
13218
- url: normalized,
13219
- depth,
13220
- reason: "queue_safety_limit"
13221
- });
13584
+ if (!queueSafetyDeferredUrls.has(normalized)) if (queueSafetyDeferredUrls.size < maxTrackedQueueSafetyUrls) {
13585
+ queueSafetyDeferredUrls.add(normalized);
13586
+ queueSafetySkippedUrls = queueSafetyDeferredUrls.size;
13587
+ recordSkip("queue-safety-limit");
13588
+ emitStatus("url_skipped", {
13589
+ url: normalized,
13590
+ depth,
13591
+ reason: "queue_safety_limit"
13592
+ });
13593
+ } else queueSafetyInventoryCapped = true;
13222
13594
  return;
13223
13595
  }
13224
13596
  discovered.add(normalized);
@@ -13547,6 +13919,13 @@ async function crawlSite(input, dependencies) {
13547
13919
  signal,
13548
13920
  concurrency: Math.min(config.concurrency, 4)
13549
13921
  }) : void 0;
13922
+ const siteCheckResult = !healthStrategy && !cancelled && !stoppedForMemory ? await collectCrawlSiteChecks({
13923
+ startUrl: config.url,
13924
+ timeoutMs: config.timeoutMs,
13925
+ fetchStatusPage: deps.fetchStatusPage,
13926
+ respectRobots: config.respectRobots,
13927
+ signal
13928
+ }) : void 0;
13550
13929
  for (const page of pages) {
13551
13930
  if (!page.geo || !llmsTxt) continue;
13552
13931
  page.geo = {
@@ -13572,6 +13951,10 @@ async function crawlSite(input, dependencies) {
13572
13951
  } } : {},
13573
13952
  ...sitemapDiscovery ? { sitemapDiscovery } : {},
13574
13953
  ...externalLinkVerification ? { externalLinkVerification } : {},
13954
+ ...siteCheckResult ? {
13955
+ siteChecks: siteCheckResult.checks,
13956
+ additionalIssues: siteCheckResult.issues
13957
+ } : {},
13575
13958
  ...agentDiscovery ? { agentDiscovery } : {},
13576
13959
  status: reportStatus,
13577
13960
  warnings,
@@ -13580,6 +13963,7 @@ async function crawlSite(input, dependencies) {
13580
13963
  pageLimitReached,
13581
13964
  maxPages: config.maxPages,
13582
13965
  queueSafetySkippedUrls,
13966
+ queueSafetyInventoryCapped,
13583
13967
  originBackpressureSkippedUrls,
13584
13968
  memoryPressureSkippedUrls
13585
13969
  }),
@@ -14452,6 +14836,8 @@ const agentRepresentationResponseSchema = z.object({
14452
14836
  status: z.number().int().optional(),
14453
14837
  contentType: z.string().optional(),
14454
14838
  bytes: z.number().int().nonnegative().optional(),
14839
+ characters: z.number().int().nonnegative().optional(),
14840
+ estimatedTokens: z.number().int().nonnegative().optional(),
14455
14841
  sha256: z.string().optional(),
14456
14842
  canonicalUrl: z.string().url().optional(),
14457
14843
  alternateUrl: z.string().url().optional(),
@@ -14509,7 +14895,15 @@ const agentDiscoverySchema = z.object({
14509
14895
  sourceWordCount: z.number().int().nonnegative(),
14510
14896
  wordRetentionRatio: z.number().nonnegative().nullable(),
14511
14897
  introductoryCopyRetained: z.boolean().nullable(),
14512
- navigationOnly: z.boolean()
14898
+ navigationOnly: z.boolean(),
14899
+ contentSketchCoverage: z.number().min(0).max(1).nullable().optional(),
14900
+ tabbedContent: z.object({
14901
+ detectedPanels: z.number().int().nonnegative(),
14902
+ evaluatedPanels: z.number().int().nonnegative(),
14903
+ retainedPanels: z.number().int().nonnegative(),
14904
+ missingPanels: z.number().int().nonnegative(),
14905
+ complete: z.boolean().nullable()
14906
+ }).optional()
14513
14907
  }).optional()
14514
14908
  }))
14515
14909
  }),
@@ -14528,6 +14922,11 @@ const agentDiscoverySchema = z.object({
14528
14922
  missingHtmlRoutes: z.array(z.string()),
14529
14923
  missingMarkdownRoutes: z.array(z.string()),
14530
14924
  orphanMarkdownRoutes: z.array(z.string()),
14925
+ comparisonStatus: z.enum([
14926
+ "complete",
14927
+ "partial",
14928
+ "not-applicable"
14929
+ ]).optional(),
14531
14930
  error: z.string().optional()
14532
14931
  }),
14533
14932
  agentSkills: z.object({
@@ -14633,6 +15032,25 @@ const agentDiscoverySchema = z.object({
14633
15032
  warnings: z.array(z.string())
14634
15033
  });
14635
15034
  //#endregion
15035
+ //#region packages/core/dist/analyze/crawler/site-checks-schema.js
15036
+ const crawlSiteChecksSchema = z.object({ soft404: z.object({
15037
+ status: z.enum([
15038
+ "pass",
15039
+ "warning",
15040
+ "unknown"
15041
+ ]),
15042
+ probes: z.array(z.object({
15043
+ url: z.string().url(),
15044
+ status: z.number().int().optional(),
15045
+ finalUrl: z.string().url().optional(),
15046
+ redirected: z.boolean().optional(),
15047
+ accessBlocked: z.boolean().optional(),
15048
+ error: z.string().optional()
15049
+ })),
15050
+ probeLimit: z.number().int().positive(),
15051
+ complete: z.boolean()
15052
+ }) });
15053
+ //#endregion
14636
15054
  //#region packages/core/dist/analyze/crawler/schemas.js
14637
15055
  const crawlerRuleSeveritySchema = z.enum([
14638
15056
  "low",
@@ -15028,6 +15446,7 @@ const crawlPageSnapshotSchema = z.object({
15028
15446
  "robots-blocked",
15029
15447
  "canonical-conflict",
15030
15448
  "canonical-hint-other",
15449
+ "meta-refresh",
15031
15450
  "not-html",
15032
15451
  "unknown"
15033
15452
  ]).optional(),
@@ -15062,6 +15481,38 @@ const crawlPageSnapshotSchema = z.object({
15062
15481
  mainContentHash: z.string().optional(),
15063
15482
  textRatio: z.number().min(0).max(1).optional(),
15064
15483
  contentSample: z.string().optional(),
15484
+ contentSketch: z.object({
15485
+ version: z.literal(1),
15486
+ wordCount: z.number().int().nonnegative(),
15487
+ shingleSize: z.number().int().positive(),
15488
+ sampledShingles: z.number().int().nonnegative(),
15489
+ hashes: z.array(z.string())
15490
+ }).optional(),
15491
+ softAuthenticationGate: z.object({
15492
+ kind: z.literal("login-form"),
15493
+ indicators: z.array(z.string()),
15494
+ formActionPath: z.string().optional()
15495
+ }).optional(),
15496
+ metaRefresh: z.object({
15497
+ targetUrl: z.string().url(),
15498
+ delaySeconds: z.number().nonnegative()
15499
+ }).optional(),
15500
+ tabbedContent: z.object({
15501
+ groups: z.number().int().positive(),
15502
+ panels: z.number().int().positive(),
15503
+ retainedPanels: z.number().int().nonnegative(),
15504
+ truncated: z.boolean(),
15505
+ panelSketches: z.array(z.object({
15506
+ label: z.string().optional(),
15507
+ sketch: z.object({
15508
+ version: z.literal(1),
15509
+ wordCount: z.number().int().nonnegative(),
15510
+ shingleSize: z.number().int().positive(),
15511
+ sampledShingles: z.number().int().nonnegative(),
15512
+ hashes: z.array(z.string())
15513
+ })
15514
+ }))
15515
+ }).optional(),
15065
15516
  lang: z.string().optional(),
15066
15517
  hasViewport: z.boolean().optional(),
15067
15518
  isHttps: z.boolean().optional(),
@@ -15553,6 +16004,7 @@ const crawlReportBaseSchema = z.object({
15553
16004
  ai: crawlAiSignalsSchema.optional(),
15554
16005
  sitemapDiscovery: crawlSitemapDiscoverySchema.optional(),
15555
16006
  externalLinkVerification: crawlExternalLinkVerificationSchema.optional(),
16007
+ siteChecks: crawlSiteChecksSchema.optional(),
15556
16008
  agentDiscovery: agentDiscoverySchema.optional(),
15557
16009
  warnings: z.array(z.string()),
15558
16010
  caveats: z.array(z.string())