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/index.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, createWriteStream, existsSync, mkdirSync, readFileSync, readdirSync, renameSync, rmSync, statSync, writeFileSync } from "node:fs";
5
5
  import { arch, homedir, platform, totalmem } from "node:os";
@@ -2532,6 +2532,43 @@ function extractCanonicalEvidence($, headers, baseUrl) {
2532
2532
  return evidenceFromCandidates([...htmlCandidates($, baseUrl), ...headerCandidates(headers, baseUrl)]);
2533
2533
  }
2534
2534
  //#endregion
2535
+ //#region packages/core/src/extract/content-sketch.ts
2536
+ const SHINGLE_SIZE = 5;
2537
+ const MAX_HASHES = 32;
2538
+ function words(value) {
2539
+ return value.normalize("NFKC").toLocaleLowerCase("en").match(/[\p{L}\p{N}]+/gu) ?? [];
2540
+ }
2541
+ function sampleIndexes(total, limit) {
2542
+ if (total <= limit) return Array.from({ length: total }, (_, index) => index);
2543
+ return Array.from({ length: limit }, (_, index) => Math.round(index * (total - 1) / (limit - 1)));
2544
+ }
2545
+ function digest(value) {
2546
+ return createHash("sha256").update(value).digest("hex").slice(0, 16);
2547
+ }
2548
+ function contentSketch(value) {
2549
+ const tokens = words(value);
2550
+ const hashes = sampleIndexes(Math.max(0, tokens.length - SHINGLE_SIZE + 1), MAX_HASHES).map((index) => digest(tokens.slice(index, index + SHINGLE_SIZE).join(" ")));
2551
+ return {
2552
+ version: 1,
2553
+ wordCount: tokens.length,
2554
+ shingleSize: SHINGLE_SIZE,
2555
+ sampledShingles: hashes.length,
2556
+ hashes
2557
+ };
2558
+ }
2559
+ function contentSketchCoverage(source, candidate) {
2560
+ if (!source || source.hashes.length < 4) return null;
2561
+ const targets = new Set(source.hashes);
2562
+ const matched = /* @__PURE__ */ new Set();
2563
+ const tokens = words(candidate);
2564
+ for (let index = 0; index <= tokens.length - source.shingleSize; index += 1) {
2565
+ const hash = digest(tokens.slice(index, index + source.shingleSize).join(" "));
2566
+ if (targets.has(hash)) matched.add(hash);
2567
+ if (matched.size === targets.size) break;
2568
+ }
2569
+ return Math.round(matched.size / targets.size * 1e3) / 1e3;
2570
+ }
2571
+ //#endregion
2535
2572
  //#region packages/core/src/extract/main-content.ts
2536
2573
  function readableError(error) {
2537
2574
  return error instanceof Error ? error.message : String(error);
@@ -3139,6 +3176,22 @@ function extractStructuredData($, baseUrl, options = {}) {
3139
3176
  }
3140
3177
  //#endregion
3141
3178
  //#region packages/core/src/extract/page-extractor.ts
3179
+ function metaRefresh($, finalUrl) {
3180
+ for (const element of $("meta[http-equiv]").toArray()) {
3181
+ if ($(element).attr("http-equiv")?.trim().toLowerCase() !== "refresh") continue;
3182
+ const match = ($(element).attr("content")?.trim())?.match(/^([0-9]+(?:\.[0-9]+)?)\s*;\s*url\s*=\s*(?:"([^"]*)"|'([^']*)'|(.+))$/iu);
3183
+ if (!match) continue;
3184
+ try {
3185
+ const target = new URL((match[2] ?? match[3] ?? match[4] ?? "").trim(), finalUrl);
3186
+ if (!["http:", "https:"].includes(target.protocol)) continue;
3187
+ target.hash = "";
3188
+ return {
3189
+ targetUrl: target.toString(),
3190
+ delaySeconds: Number(match[1])
3191
+ };
3192
+ } catch {}
3193
+ }
3194
+ }
3142
3195
  const SOCIAL_PROFILE_HOSTS = [
3143
3196
  "facebook.com",
3144
3197
  "instagram.com",
@@ -3149,6 +3202,48 @@ const SOCIAL_PROFILE_HOSTS = [
3149
3202
  "x.com",
3150
3203
  "youtube.com"
3151
3204
  ];
3205
+ const LOGIN_PATH_PATTERN = /(?:^|\/)(?:auth|login|log-in|signin|sign-in|sso)(?:\/|$)/iu;
3206
+ const LOGIN_TITLE_PATTERN = /\b(?:authenticate|log[ -]?in|sign[ -]?in)\b/iu;
3207
+ function softAuthenticationGate($, finalUrl, title) {
3208
+ const passwordForms = $("form").toArray().filter((form) => $(form).find("input[type=\"password\" i]").length > 0);
3209
+ if (passwordForms.length === 0) return void 0;
3210
+ const pagePath = new URL(finalUrl).pathname;
3211
+ const action = $(passwordForms[0]).attr("action");
3212
+ const actionUrl = action ? httpUrl$1(action, finalUrl) : void 0;
3213
+ const actionPath = actionUrl ? new URL(actionUrl).pathname : void 0;
3214
+ const indicators = [
3215
+ ...LOGIN_PATH_PATTERN.test(pagePath) ? ["login-path"] : [],
3216
+ ...LOGIN_TITLE_PATTERN.test(title ?? "") ? ["login-title"] : [],
3217
+ ...LOGIN_PATH_PATTERN.test(actionPath ?? "") ? ["login-form-action"] : []
3218
+ ];
3219
+ if (indicators.length === 0) return void 0;
3220
+ return {
3221
+ kind: "login-form",
3222
+ indicators,
3223
+ ...actionPath ? { formActionPath: actionPath } : {}
3224
+ };
3225
+ }
3226
+ function tabbedContent($) {
3227
+ const groups = $("[role=\"tablist\" i]").length;
3228
+ const panels = $("[role=\"tabpanel\" i]").toArray();
3229
+ if (groups === 0 || panels.length === 0) return void 0;
3230
+ const panelSketches = panels.slice(0, 12).map((panel) => {
3231
+ const labelledBy = $(panel).attr("aria-labelledby");
3232
+ const labelledElement = labelledBy ? $("[id]").toArray().find((element) => $(element).attr("id") === labelledBy) : void 0;
3233
+ const label = safeText$1($(panel).attr("aria-label") ?? (labelledElement ? $(labelledElement).text() : void 0));
3234
+ return {
3235
+ ...label ? { label: label.slice(0, 80) } : {},
3236
+ sketch: contentSketch($(panel).text())
3237
+ };
3238
+ });
3239
+ return {
3240
+ groups,
3241
+ panels: panels.length,
3242
+ retainedPanels: panelSketches.length,
3243
+ truncated: panels.length > panelSketches.length,
3244
+ panelSketches
3245
+ };
3246
+ }
3152
3247
  function safeText$1(value) {
3153
3248
  const trimmed = value?.replace(/\s+/g, " ").trim();
3154
3249
  return trimmed ? trimmed : void 0;
@@ -3375,10 +3470,13 @@ async function extractPage(fetchResult, extractor = "defuddle", dependencies = {
3375
3470
  html: sanitizedContentHtml($, fetchResult.html, fetchResult.finalUrl)
3376
3471
  }, extractor, dependencies);
3377
3472
  const { text, excerpt } = content;
3473
+ const title = safeText$1($("title").first().text());
3474
+ const crawlerMetaRefresh = extractor === "crawler" ? metaRefresh($, fetchResult.finalUrl) : void 0;
3475
+ if (crawlerMetaRefresh && new URL(crawlerMetaRefresh.targetUrl).origin === url.origin) crawlerInternalLinks.add(crawlerMetaRefresh.targetUrl);
3378
3476
  return {
3379
3477
  url: fetchResult.url,
3380
3478
  finalUrl: fetchResult.finalUrl,
3381
- title: safeText$1($("title").first().text()),
3479
+ title,
3382
3480
  metaDescription: safeText$1($("meta[name=\"description\"]").attr("content")),
3383
3481
  metaRobots,
3384
3482
  xRobotsTag: safeText$1(headerValue$4(fetchResult.headers, "x-robots-tag")),
@@ -3398,7 +3496,11 @@ async function extractPage(fetchResult, extractor = "defuddle", dependencies = {
3398
3496
  externalLinks: [...crawlerExternalLinks],
3399
3497
  internalAnchorSamples,
3400
3498
  externalAnchorSamples,
3401
- socialProfileLinks: [...socialProfileLinks]
3499
+ socialProfileLinks: [...socialProfileLinks],
3500
+ contentSketch: contentSketch(text),
3501
+ metaRefresh: crawlerMetaRefresh,
3502
+ softAuthenticationGate: softAuthenticationGate($, fetchResult.finalUrl, title),
3503
+ tabbedContent: tabbedContent($)
3402
3504
  } } : {},
3403
3505
  markdownAlternates,
3404
3506
  hreflang,
@@ -7631,6 +7733,8 @@ function markdownQuality(markdown, page) {
7631
7733
  const codeFences = markdown.match(/^```/gmu)?.length ?? 0;
7632
7734
  const wordCount = bodyWordCount(markdown);
7633
7735
  const sourceWordCount = page.wordCount ?? 0;
7736
+ const evaluatedPanels = (page.tabbedContent?.panelSketches ?? []).map((panel) => contentSketchCoverage(panel.sketch, markdown)).filter((coverage) => coverage !== null);
7737
+ const retainedPanels = evaluatedPanels.filter((coverage) => coverage >= .6).length;
7634
7738
  return {
7635
7739
  frontmatterTitle: /^---[\s\S]*?^title:\s*.+$/mu.test(markdown),
7636
7740
  h1Count: markdown.match(/^#\s+\S.+$/gmu)?.length ?? 0,
@@ -7647,7 +7751,15 @@ function markdownQuality(markdown, page) {
7647
7751
  sourceWordCount,
7648
7752
  wordRetentionRatio: sourceWordCount > 0 ? Math.round(wordCount / sourceWordCount * 1e3) / 1e3 : null,
7649
7753
  introductoryCopyRetained: introProbe ? normalizedMarkdown.includes(introProbe) : null,
7650
- navigationOnly: bodyWordCount(markdown) < 25
7754
+ navigationOnly: bodyWordCount(markdown) < 25,
7755
+ contentSketchCoverage: contentSketchCoverage(page.contentSketch, markdown),
7756
+ tabbedContent: {
7757
+ detectedPanels: page.tabbedContent?.panels ?? 0,
7758
+ evaluatedPanels: evaluatedPanels.length,
7759
+ retainedPanels,
7760
+ missingPanels: Math.max(0, evaluatedPanels.length - retainedPanels),
7761
+ complete: evaluatedPanels.length === 0 ? null : retainedPanels === evaluatedPanels.length
7762
+ }
7651
7763
  };
7652
7764
  }
7653
7765
  function combinedSignal(timeoutMs, signal) {
@@ -7701,6 +7813,8 @@ async function fetchRepresentation(input) {
7701
7813
  status: response.status,
7702
7814
  contentType: response.headers.get("content-type") ?? void 0,
7703
7815
  bytes: Buffer.byteLength(body),
7816
+ characters: body.length,
7817
+ estimatedTokens: Math.ceil(body.length / 4),
7704
7818
  sha256: sha256(body),
7705
7819
  canonicalUrl: link.find((entry) => entry.rel.includes("canonical"))?.url,
7706
7820
  alternateUrl: link.find((entry) => entry.rel.includes("alternate") && entry.type === "text/markdown")?.url,
@@ -7759,11 +7873,15 @@ async function inspectMarkdownPage(input) {
7759
7873
  if (explicit) observation.explicit = explicit.observation;
7760
7874
  observation.negotiated = negotiated.observation;
7761
7875
  observation.repeated = repeated.observation;
7762
- observation.explicitMatchesNegotiated = explicit?.observation.sha256 && negotiated.observation.sha256 ? explicit.observation.sha256 === negotiated.observation.sha256 : null;
7763
- const primary = explicit ?? negotiated;
7764
- observation.repeatedHashStable = primary.observation.sha256 && repeated.observation.sha256 ? primary.observation.sha256 === repeated.observation.sha256 : null;
7765
- observation.markdownCanonicalMatchesHtml = sameDocument(primary.observation.canonicalUrl, input.page.finalUrl);
7766
- if (primary.body) observation.quality = markdownQuality(primary.body, input.page);
7876
+ 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 ?? "");
7877
+ const explicitMarkdown = isMarkdown(explicit) ? explicit : void 0;
7878
+ const negotiatedMarkdown = isMarkdown(negotiated) ? negotiated : void 0;
7879
+ const repeatedMarkdown = isMarkdown(repeated) ? repeated : void 0;
7880
+ observation.explicitMatchesNegotiated = explicitMarkdown?.observation.sha256 && negotiatedMarkdown?.observation.sha256 ? explicitMarkdown.observation.sha256 === negotiatedMarkdown.observation.sha256 : null;
7881
+ const primary = explicitMarkdown ?? negotiatedMarkdown;
7882
+ observation.repeatedHashStable = primary?.observation.sha256 && repeatedMarkdown?.observation.sha256 ? primary.observation.sha256 === repeatedMarkdown.observation.sha256 : null;
7883
+ observation.markdownCanonicalMatchesHtml = sameDocument(primary?.observation.canonicalUrl, input.page.finalUrl);
7884
+ if (primary?.body) observation.quality = markdownQuality(primary.body, input.page);
7767
7885
  return observation;
7768
7886
  }
7769
7887
  async function fetchText(input) {
@@ -8007,17 +8125,16 @@ async function inspectRouteManifest(input) {
8007
8125
  const declared = Array.isArray(parsed.pages) ? parsed.pages : [];
8008
8126
  const declaredHtmlRoutes = declared.map((page) => page.htmlPath).filter((path) => typeof path === "string").sort();
8009
8127
  const declaredMarkdownRoutes = declared.map((page) => page.markdownPath).filter((path) => typeof path === "string").sort();
8010
- const crawledHtmlRoutes = new Set(input.pages.map((page) => new URL(page.finalUrl).pathname.replace(/\/$/u, "") || "/"));
8011
- const observedMarkdownRoutes = new Set(input.observations.flatMap((page) => page.advertisedUrls.map((value) => new URL(value).pathname)));
8012
8128
  return {
8013
8129
  url,
8014
8130
  status: result.response.status,
8015
8131
  valid: Array.isArray(parsed.pages),
8016
8132
  declaredHtmlRoutes,
8017
8133
  declaredMarkdownRoutes,
8018
- missingHtmlRoutes: declaredHtmlRoutes.filter((path) => !crawledHtmlRoutes.has(path.replace(/\/$/u, "") || "/")).sort(),
8019
- missingMarkdownRoutes: declaredMarkdownRoutes.filter((path) => !observedMarkdownRoutes.has(path)).sort(),
8020
- orphanMarkdownRoutes: [...observedMarkdownRoutes].filter((path) => !declaredMarkdownRoutes.includes(path)).sort()
8134
+ missingHtmlRoutes: [],
8135
+ missingMarkdownRoutes: [],
8136
+ orphanMarkdownRoutes: [],
8137
+ comparisonStatus: "partial"
8021
8138
  };
8022
8139
  } catch (error) {
8023
8140
  return {
@@ -8028,6 +8145,7 @@ async function inspectRouteManifest(input) {
8028
8145
  missingHtmlRoutes: [],
8029
8146
  missingMarkdownRoutes: [],
8030
8147
  orphanMarkdownRoutes: [],
8148
+ comparisonStatus: "not-applicable",
8031
8149
  error: safeError(error)
8032
8150
  };
8033
8151
  }
@@ -8071,9 +8189,7 @@ async function collectAgentDiscovery(input) {
8071
8189
  }),
8072
8190
  inspectRouteManifest({
8073
8191
  ...input,
8074
- origin,
8075
- pages,
8076
- observations
8192
+ origin
8077
8193
  }),
8078
8194
  inspectAgentEndpoints({
8079
8195
  ...input,
@@ -8178,20 +8294,13 @@ async function collectAgentDiscovery(input) {
8178
8294
  };
8179
8295
  }
8180
8296
  //#endregion
8181
- //#region packages/core/src/analyze/crawler/agent-readiness.ts
8182
- function check$1(section, input) {
8297
+ //#region packages/core/src/analyze/crawler/agent-readiness-markdown.ts
8298
+ function check$2(section, input) {
8183
8299
  return {
8184
8300
  section,
8185
8301
  ...input
8186
8302
  };
8187
8303
  }
8188
- function section$2(id, title, checks) {
8189
- return {
8190
- id,
8191
- title,
8192
- checks
8193
- };
8194
- }
8195
8304
  function failedMarkdownUrls(discovery) {
8196
8305
  return discovery.markdownAlternates.pages.filter((page) => {
8197
8306
  const negotiatedOk = page.negotiated?.status === 200 && /^\s*text\/markdown\b/iu.test(page.negotiated.contentType ?? "") && page.negotiated.varyAccept;
@@ -8199,112 +8308,200 @@ function failedMarkdownUrls(discovery) {
8199
8308
  return !negotiatedOk || !explicitOk || page.repeatedHashStable !== true;
8200
8309
  }).map((page) => page.htmlUrl);
8201
8310
  }
8311
+ function unstableMarkdownUrls(discovery) {
8312
+ return discovery.markdownAlternates.pages.filter((page) => confirmedMarkdown(page) !== void 0 && page.repeatedHashStable !== true).map((page) => page.htmlUrl);
8313
+ }
8202
8314
  function qualityFailureUrls(discovery) {
8203
8315
  return discovery.markdownAlternates.pages.filter((page) => {
8204
8316
  const quality = page.quality;
8205
- if (!quality) return true;
8206
- 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;
8317
+ if (!quality) return false;
8318
+ 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;
8207
8319
  }).map((page) => page.htmlUrl);
8208
8320
  }
8209
- function profileChecks(discovery) {
8210
- return [
8211
- "api",
8212
- "application",
8213
- "commerce"
8214
- ].map((profile) => check$1("profile", {
8215
- id: `profile-${profile}`,
8216
- status: "notApplicable",
8217
- title: `${profile[0]?.toUpperCase()}${profile.slice(1)} checks are not applicable`,
8218
- plainEnglish: discovery.profileApplicability[profile].reason,
8219
- action: "Choose this profile only when the site exposes that capability publicly.",
8220
- evidence: {
8221
- profile,
8222
- selectedProfile: discovery.profile
8223
- }
8224
- }));
8225
- }
8226
- function summariseChecks(checks) {
8227
- const count = (status) => checks.filter((item) => item.status === status).length;
8228
- return {
8229
- checks: checks.length,
8230
- passed: count("pass"),
8231
- warnings: count("warning"),
8232
- failed: count("fail"),
8233
- unknown: count("unknown"),
8234
- information: count("info"),
8235
- notApplicable: count("notApplicable")
8236
- };
8321
+ function confirmedMarkdown(page) {
8322
+ 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;
8237
8323
  }
8238
8324
  function markdownChecks(discovery) {
8239
8325
  const markdown = discovery.markdownAlternates;
8240
8326
  const failedUrls = failedMarkdownUrls(discovery);
8327
+ const unstableUrls = unstableMarkdownUrls(discovery);
8241
8328
  const qualityUrls = qualityFailureUrls(discovery);
8329
+ const hasEligibleHtml = markdown.eligibleHtmlPages > 0;
8330
+ const hasEvaluatedMarkdown = markdown.evaluatedPages > 0;
8242
8331
  const negotiatedPages = markdown.pages.filter((page) => page.negotiated?.status === 200 && /^\s*text\/markdown\b/iu.test(page.negotiated.contentType ?? "")).length;
8243
8332
  const explicitPages = markdown.pages.filter((page) => page.htmlAlternateUnique).length;
8244
8333
  const coverageComplete = markdown.eligibleHtmlPages > 0 && markdown.evaluatedPages === markdown.eligibleHtmlPages;
8245
8334
  const negotiationComplete = markdown.eligibleHtmlPages > 0 && negotiatedPages === markdown.eligibleHtmlPages && markdown.pages.every((page) => page.negotiated?.varyAccept) && markdown.pages.every((page) => !page.htmlAlternateUnique || page.explicitMatchesNegotiated === true);
8246
- const stable = markdown.eligibleHtmlPages > 0 && markdown.stableResponses === markdown.eligibleHtmlPages;
8335
+ const stable = markdown.evaluatedPages > 0 && markdown.stableResponses === markdown.evaluatedPages;
8247
8336
  const tokenHeaders = markdown.pages.filter((page) => {
8248
- return (page.explicit ?? page.negotiated)?.markdownTokens !== void 0 && (!page.explicit || !page.negotiated || page.explicit.markdownTokens === page.negotiated.markdownTokens);
8337
+ return confirmedMarkdown(page)?.markdownTokens !== void 0 && (!page.explicit || !page.negotiated || page.explicit.markdownTokens === page.negotiated.markdownTokens);
8249
8338
  }).length;
8339
+ const sizedPages = markdown.pages.filter((page) => {
8340
+ return confirmedMarkdown(page)?.characters !== void 0;
8341
+ });
8342
+ const largePages = sizedPages.filter((page) => {
8343
+ return (confirmedMarkdown(page)?.characters ?? 0) > 5e4;
8344
+ });
8345
+ const veryLargePages = sizedPages.filter((page) => {
8346
+ return (confirmedMarkdown(page)?.characters ?? 0) > 1e5;
8347
+ });
8348
+ const maxCharacters = Math.max(0, ...sizedPages.map((page) => confirmedMarkdown(page)?.characters ?? 0));
8349
+ const maxEstimatedTokens = Math.max(0, ...sizedPages.map((page) => confirmedMarkdown(page)?.estimatedTokens ?? 0));
8350
+ const parityPages = markdown.pages.filter((page) => page.quality?.contentSketchCoverage !== null && page.quality?.contentSketchCoverage !== void 0);
8351
+ const parityFailures = parityPages.filter((page) => (page.quality?.contentSketchCoverage ?? 0) < .6);
8352
+ const tabbedPages = markdown.pages.filter((page) => (page.quality?.tabbedContent?.detectedPanels ?? 0) > 0);
8353
+ const tabFailures = tabbedPages.filter((page) => page.quality?.tabbedContent?.complete === false);
8354
+ const tabUnknown = tabbedPages.filter((page) => page.quality?.tabbedContent?.complete === null);
8250
8355
  return [
8251
- check$1("representations", {
8356
+ check$2("representations", {
8252
8357
  id: "markdown-coverage",
8253
8358
  status: markdown.eligibleHtmlPages === 0 ? "unknown" : coverageComplete ? "pass" : "fail",
8254
- title: coverageComplete ? "Every successful HTML page has a Markdown representation" : "Markdown representation coverage is incomplete",
8255
- plainEnglish: `${markdown.evaluatedPages} of ${markdown.eligibleHtmlPages} successful HTML pages returned Markdown. ${explicitPages} advertised an explicit alternative and ${negotiatedPages} supported content negotiation.`,
8256
- 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.",
8359
+ title: coverageComplete ? "Every successful HTML page has a Markdown representation" : !hasEligibleHtml ? "Markdown representation coverage was not evaluated" : "Markdown representation coverage is incomplete",
8360
+ 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.",
8361
+ 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.",
8257
8362
  evidence: {
8258
8363
  eligibleHtmlPages: markdown.eligibleHtmlPages,
8259
8364
  advertisedPages: markdown.advertisedPages,
8260
8365
  negotiatedPages,
8261
8366
  evaluatedPages: markdown.evaluatedPages
8262
8367
  },
8263
- urls: failedUrls.slice(0, 25)
8368
+ urls: hasEligibleHtml ? failedUrls.slice(0, 25) : []
8264
8369
  }),
8265
- check$1("representations", {
8370
+ check$2("representations", {
8266
8371
  id: "markdown-token-estimates",
8267
- status: markdown.evaluatedPages === 0 ? "unknown" : tokenHeaders === markdown.evaluatedPages ? "pass" : "warning",
8268
- title: tokenHeaders === markdown.evaluatedPages ? "Every Markdown response includes a token estimate" : "Some Markdown responses are missing a stable token estimate",
8269
- 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.`,
8270
- action: "Return a stable token estimate and keep it consistent when both explicit and negotiated responses exist.",
8372
+ status: markdown.evaluatedPages === 0 ? "unknown" : tokenHeaders === markdown.evaluatedPages ? "pass" : "info",
8373
+ 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",
8374
+ 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.",
8375
+ 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.",
8271
8376
  urls: markdown.pages.filter((page) => {
8272
- return (page.explicit ?? page.negotiated)?.markdownTokens === void 0 || page.explicit !== void 0 && page.negotiated !== void 0 && page.explicit.markdownTokens !== page.negotiated.markdownTokens;
8377
+ const primary = confirmedMarkdown(page);
8378
+ if (!primary) return false;
8379
+ 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 ?? ""));
8380
+ return primary?.markdownTokens === void 0 || paired.length === 2 && paired[0]?.markdownTokens !== paired[1]?.markdownTokens;
8273
8381
  }).map((page) => page.htmlUrl).slice(0, 25)
8274
8382
  }),
8275
- check$1("representations", {
8383
+ check$2("representations", {
8384
+ id: "markdown-size",
8385
+ status: sizedPages.length === 0 ? "unknown" : largePages.length === 0 ? "pass" : "warning",
8386
+ 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",
8387
+ 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.`,
8388
+ 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.",
8389
+ evidence: {
8390
+ measuredPages: sizedPages.length,
8391
+ reviewThresholdCharacters: 5e4,
8392
+ highRiskThresholdCharacters: 1e5,
8393
+ largePages: largePages.length,
8394
+ veryLargePages: veryLargePages.length,
8395
+ maxCharacters,
8396
+ maxEstimatedTokens,
8397
+ heuristic: true
8398
+ },
8399
+ urls: largePages.map((page) => page.htmlUrl).slice(0, 25)
8400
+ }),
8401
+ check$2("representations", {
8402
+ id: "markdown-content-parity",
8403
+ status: parityPages.length === 0 ? "unknown" : parityFailures.length === 0 ? "pass" : "warning",
8404
+ 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",
8405
+ 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.`,
8406
+ 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.",
8407
+ evidence: {
8408
+ evaluatedPages: parityPages.length,
8409
+ failedPages: parityFailures.length,
8410
+ minimumCoverage: .6,
8411
+ sampling: "bounded-content-shingles"
8412
+ },
8413
+ urls: parityFailures.map((page) => page.htmlUrl).slice(0, 25)
8414
+ }),
8415
+ check$2("representations", {
8416
+ id: "markdown-tab-serialization",
8417
+ status: tabbedPages.length === 0 ? "notApplicable" : tabFailures.length > 0 ? "warning" : tabUnknown.length > 0 ? "unknown" : "pass",
8418
+ 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",
8419
+ 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.`,
8420
+ 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.",
8421
+ evidence: {
8422
+ tabbedPages: tabbedPages.length,
8423
+ failedPages: tabFailures.length,
8424
+ unknownPages: tabUnknown.length
8425
+ },
8426
+ urls: [...tabFailures, ...tabUnknown].map((page) => page.htmlUrl).slice(0, 25)
8427
+ }),
8428
+ check$2("representations", {
8276
8429
  id: "markdown-negotiation",
8277
8430
  status: markdown.eligibleHtmlPages === 0 ? "unknown" : negotiationComplete && discovery.contentNegotiation.qZeroHonoured ? "pass" : "fail",
8278
- title: negotiationComplete && discovery.contentNegotiation.qZeroHonoured ? "Markdown content negotiation works across the site" : "Markdown content negotiation needs attention",
8279
- 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"}.`,
8280
- action: "Honour Accept q-values, send Vary: Accept, and keep paired explicit and negotiated responses identical when both exist.",
8431
+ title: !hasEligibleHtml ? "Markdown content negotiation was not evaluated" : negotiationComplete && discovery.contentNegotiation.qZeroHonoured ? "Markdown content negotiation works across the site" : "Markdown content negotiation needs attention",
8432
+ 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.",
8433
+ 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.",
8281
8434
  evidence: {
8282
8435
  negotiatedPages,
8283
8436
  explicitPages,
8284
8437
  exactByteMatches: markdown.exactByteMatches,
8285
8438
  qZero: discovery.contentNegotiation
8286
8439
  },
8287
- urls: failedUrls.slice(0, 25)
8440
+ urls: hasEligibleHtml ? failedUrls.slice(0, 25) : []
8288
8441
  }),
8289
- check$1("representations", {
8442
+ check$2("representations", {
8290
8443
  id: "markdown-determinism",
8291
- status: markdown.eligibleHtmlPages === 0 ? "unknown" : stable ? "pass" : "fail",
8292
- title: stable ? "Repeated Markdown responses are stable" : "Repeated Markdown responses changed during the audit",
8293
- plainEnglish: `${markdown.stableResponses} of ${markdown.eligibleHtmlPages} repeated Markdown responses kept the same SHA-256 digest.`,
8294
- action: "Remove timestamps, random output, or other runtime rewriting that changes the same Markdown response between requests.",
8444
+ status: markdown.evaluatedPages === 0 ? "unknown" : stable ? "pass" : "fail",
8445
+ title: !hasEvaluatedMarkdown ? "Markdown response stability was not evaluated" : stable ? "Repeated Markdown responses are stable" : "Repeated Markdown responses changed during the audit",
8446
+ 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.",
8447
+ 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.",
8295
8448
  evidence: { stableResponses: markdown.stableResponses },
8296
- urls: failedUrls.slice(0, 25)
8449
+ urls: unstableUrls.slice(0, 25)
8297
8450
  }),
8298
- check$1("representations", {
8451
+ check$2("representations", {
8299
8452
  id: "markdown-quality",
8300
8453
  status: markdown.evaluatedPages === 0 ? "unknown" : qualityUrls.length === 0 ? "pass" : "warning",
8301
- title: qualityUrls.length === 0 ? "Markdown keeps the useful document structure cleanly" : "Some Markdown alternatives need an extraction review",
8302
- 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.`,
8303
- action: "Open the affected Markdown directly and fix the shared converter or component exclusion rules rather than patching generated files.",
8454
+ title: !hasEvaluatedMarkdown ? "Markdown quality was not evaluated" : qualityUrls.length === 0 ? "Markdown keeps the useful document structure cleanly" : "Some Markdown alternatives need an extraction review",
8455
+ 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.`,
8456
+ 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.",
8304
8457
  urls: qualityUrls.slice(0, 25)
8305
8458
  })
8306
8459
  ];
8307
8460
  }
8461
+ //#endregion
8462
+ //#region packages/core/src/analyze/crawler/agent-readiness.ts
8463
+ function check$1(section, input) {
8464
+ return {
8465
+ section,
8466
+ ...input
8467
+ };
8468
+ }
8469
+ function section$2(id, title, checks) {
8470
+ return {
8471
+ id,
8472
+ title,
8473
+ checks
8474
+ };
8475
+ }
8476
+ function profileChecks(discovery) {
8477
+ return [
8478
+ "api",
8479
+ "application",
8480
+ "commerce"
8481
+ ].map((profile) => check$1("profile", {
8482
+ id: `profile-${profile}`,
8483
+ status: "notApplicable",
8484
+ title: `${profile[0]?.toUpperCase()}${profile.slice(1)} checks are not applicable`,
8485
+ plainEnglish: discovery.profileApplicability[profile].reason,
8486
+ action: "Choose this profile only when the site exposes that capability publicly.",
8487
+ evidence: {
8488
+ profile,
8489
+ selectedProfile: discovery.profile
8490
+ }
8491
+ }));
8492
+ }
8493
+ function summariseChecks(checks) {
8494
+ const count = (status) => checks.filter((item) => item.status === status).length;
8495
+ return {
8496
+ checks: checks.length,
8497
+ passed: count("pass"),
8498
+ warnings: count("warning"),
8499
+ failed: count("fail"),
8500
+ unknown: count("unknown"),
8501
+ information: count("info"),
8502
+ notApplicable: count("notApplicable")
8503
+ };
8504
+ }
8308
8505
  function contentSignalsCheck(discovery, robotsContentSignals) {
8309
8506
  const signals = discovery.contentSignals;
8310
8507
  const robotsDeclared = robotsContentSignals ?? [];
@@ -8405,8 +8602,8 @@ function discoveryChecks(discovery, robotsContentSignals) {
8405
8602
  check$1("discovery", {
8406
8603
  id: "route-manifest",
8407
8604
  status: !discovery.routeManifest.valid ? "info" : discovery.routeManifest.missingHtmlRoutes.length === 0 && discovery.routeManifest.missingMarkdownRoutes.length === 0 && discovery.routeManifest.orphanMarkdownRoutes.length === 0 ? "pass" : "warning",
8408
- title: discovery.routeManifest.valid ? "The public route manifest agrees with the crawl" : "No public route manifest was available",
8409
- 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.",
8605
+ title: discovery.routeManifest.valid ? "The public route manifest is valid" : "No public route manifest was available",
8606
+ 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.",
8410
8607
  action: "Generate one deterministic route inventory from the build when exact HTML and Markdown parity matters.",
8411
8608
  evidence: discovery.routeManifest
8412
8609
  })
@@ -8537,25 +8734,42 @@ function accessChecks(report, discovery) {
8537
8734
  const blocked = bots.filter((bot) => bot.allowed === false);
8538
8735
  const robotsKnown = report.ai?.robotsTxt?.availability === "available" || report.ai?.robotsTxt?.availability === "absent";
8539
8736
  const protocol = discovery.protocolVariants;
8540
- return [check$1("access", {
8541
- id: "crawler-access",
8542
- status: !robotsKnown ? "unknown" : blocked.length > 0 ? "warning" : "pass",
8543
- title: blocked.length ? "robots.txt blocks selected crawler tokens" : "No selected crawler token is blocked at the start URL",
8544
- 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.",
8545
- action: "Keep blocks intentional. A crawler token being allowed here does not prove that the service will crawl, index, or cite the site.",
8546
- evidence: {
8547
- availability: report.ai?.robotsTxt?.availability,
8548
- blocked,
8549
- botAccess: bots
8550
- }
8551
- }), check$1("access", {
8552
- id: "protocol-canonicalization",
8553
- status: protocol.http.permanentRedirectToHttps === true && protocol.hstsOnStartPage === true ? "pass" : protocol.http.permanentRedirectToHttps === null ? "unknown" : "warning",
8554
- 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",
8555
- 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"}.`,
8556
- action: "Use one permanent HTTP-to-HTTPS redirect and send HSTS only after HTTPS is stable across the site.",
8557
- evidence: protocol
8558
- })];
8737
+ const authenticationGates = report.issues.filter((issue) => issue.ruleId === "soft_authentication_gate");
8738
+ const clientRenderedPages = report.issues.filter((issue) => issue.ruleId === "client_rendered_content");
8739
+ return [
8740
+ check$1("access", {
8741
+ id: "crawler-access",
8742
+ status: !robotsKnown ? "unknown" : blocked.length > 0 ? "warning" : "pass",
8743
+ title: blocked.length ? "robots.txt blocks selected crawler tokens" : "No selected crawler token is blocked at the start URL",
8744
+ 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.",
8745
+ action: "Keep blocks intentional. A crawler token being allowed here does not prove that the service will crawl, index, or cite the site.",
8746
+ evidence: {
8747
+ availability: report.ai?.robotsTxt?.availability,
8748
+ blocked,
8749
+ botAccess: bots
8750
+ }
8751
+ }),
8752
+ check$1("access", {
8753
+ id: "public-content-access",
8754
+ status: authenticationGates.length > 0 || clientRenderedPages.length > 0 ? "warning" : "pass",
8755
+ 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",
8756
+ 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.`,
8757
+ 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.",
8758
+ evidence: {
8759
+ authenticationGates: authenticationGates.length,
8760
+ clientRenderedPages: clientRenderedPages.length
8761
+ },
8762
+ urls: [...authenticationGates, ...clientRenderedPages].map((issue) => issue.url).slice(0, 25)
8763
+ }),
8764
+ check$1("access", {
8765
+ id: "protocol-canonicalization",
8766
+ status: protocol.http.permanentRedirectToHttps === true && protocol.hstsOnStartPage === true ? "pass" : protocol.http.permanentRedirectToHttps === null ? "unknown" : "warning",
8767
+ 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",
8768
+ 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"}.`,
8769
+ action: "Use one permanent HTTP-to-HTTPS redirect and send HSTS only after HTTPS is stable across the site.",
8770
+ evidence: protocol
8771
+ })
8772
+ ];
8559
8773
  }
8560
8774
  function identityChecks(report) {
8561
8775
  const types = new Set(report.pages.flatMap((page) => page.schemaTypes ?? []));
@@ -10088,6 +10302,48 @@ const RULE_DEFINITIONS = [
10088
10302
  suggestedCommands: ["seo crawl --sitemap-url <url> --health --json"]
10089
10303
  }
10090
10304
  },
10305
+ {
10306
+ id: "soft_authentication_gate",
10307
+ title: "Page appears to be an authentication gate",
10308
+ category: "response",
10309
+ defaultSeverity: "medium",
10310
+ 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.",
10311
+ 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.",
10312
+ impactIfIgnored: "A login page can be mistaken for the intended content, producing misleading crawl results and weak search or agent access.",
10313
+ howToVerify: "Fetch the affected URL without a session and confirm it returns the intended public page, or a deliberate authentication status for protected content.",
10314
+ agentHints: {
10315
+ evidenceFields: ["page.softAuthenticationGate", "page.finalUrl"],
10316
+ suggestedCommands: ["seo crawl <url> --max-pages 1 --json"]
10317
+ }
10318
+ },
10319
+ {
10320
+ id: "client_rendered_content",
10321
+ title: "Important content requires JavaScript rendering",
10322
+ category: "response",
10323
+ defaultSeverity: "medium",
10324
+ 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.",
10325
+ 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.",
10326
+ impactIfIgnored: "Clients that do not execute JavaScript may see an empty shell or miss important content and links.",
10327
+ howToVerify: "Re-run the crawl and confirm the raw and rendered content observations contain the same important document content.",
10328
+ agentHints: {
10329
+ evidenceFields: ["page.fetchDiagnostics.rendering.documentDifference", "page.usedJs"],
10330
+ suggestedCommands: ["seo crawl <url> --json"]
10331
+ }
10332
+ },
10333
+ {
10334
+ id: "soft_404",
10335
+ title: "Unknown URLs return a successful response",
10336
+ category: "response",
10337
+ defaultSeverity: "high",
10338
+ 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.",
10339
+ 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.",
10340
+ impactIfIgnored: "Missing URLs can remain in crawl queues, waste crawl attention, and create confusing search or monitoring evidence.",
10341
+ howToVerify: "Request several known-nonexistent paths and confirm each returns 404 or 410 without redirecting to a generic successful page.",
10342
+ agentHints: {
10343
+ evidenceFields: ["report.siteChecks.soft404"],
10344
+ suggestedCommands: ["seo crawl <url> --json"]
10345
+ }
10346
+ },
10091
10347
  {
10092
10348
  id: "client_error",
10093
10349
  title: "Client error",
@@ -10121,7 +10377,8 @@ const RULE_DEFINITIONS = [
10121
10377
  evidenceFields: [
10122
10378
  "page.url",
10123
10379
  "page.finalUrl",
10124
- "page.status"
10380
+ "page.status",
10381
+ "page.metaRefresh"
10125
10382
  ],
10126
10383
  suggestedCommands: ["seo redirect-trace --url <url> --json"]
10127
10384
  }
@@ -10577,6 +10834,8 @@ const RULE_RECOMMENDATIONS = {
10577
10834
  x_robots_noindex: "review",
10578
10835
  robots_blocked: "review",
10579
10836
  crawler_access_blocked: "review",
10837
+ soft_authentication_gate: "review",
10838
+ client_rendered_content: "review",
10580
10839
  orphan_page: "review",
10581
10840
  redirected_url: "review",
10582
10841
  slow_response: "review",
@@ -10616,6 +10875,7 @@ function comparableUrl(value) {
10616
10875
  }
10617
10876
  }
10618
10877
  function isRedirectedPage(page) {
10878
+ if (page.metaRefresh) return true;
10619
10879
  const requested = comparableUrl(page.url);
10620
10880
  const final = comparableUrl(page.finalUrl);
10621
10881
  return Boolean(requested && final && requested !== final);
@@ -10670,6 +10930,28 @@ const WEAK_VALUABLE_INLINKS = 1;
10670
10930
  const QUERY_COVERAGE_MIN_IMPRESSIONS = 50;
10671
10931
  const QUERY_COVERAGE_MIN = .6;
10672
10932
  const OVERSIZED_IMAGE_CANDIDATE_LIMIT = 2e3;
10933
+ const EMPTY_INITIAL_HTML_CHARACTERS = 200;
10934
+ const SUBSTANTIVE_RENDERED_CHARACTERS = 500;
10935
+ function clientRenderedContentEvidence(page) {
10936
+ const rendering = page.fetchDiagnostics?.rendering;
10937
+ const difference = rendering?.documentDifference;
10938
+ if (rendering?.status !== "rendered" || !difference?.changed.includes("content")) return;
10939
+ const rawCharacters = difference.raw.content.characters;
10940
+ const renderedCharacters = difference.rendered.content.characters;
10941
+ if (rawCharacters >= EMPTY_INITIAL_HTML_CHARACTERS || renderedCharacters < SUBSTANTIVE_RENDERED_CHARACTERS || renderedCharacters < rawCharacters * 3) return;
10942
+ return {
10943
+ rawCharacters,
10944
+ rawWords: difference.raw.content.wordCount,
10945
+ renderedCharacters,
10946
+ renderedWords: difference.rendered.content.wordCount,
10947
+ changed: difference.changed,
10948
+ thresholds: {
10949
+ emptyInitialHtmlCharacters: EMPTY_INITIAL_HTML_CHARACTERS,
10950
+ substantiveRenderedCharacters: SUBSTANTIVE_RENDERED_CHARACTERS,
10951
+ minimumGrowthRatio: 3
10952
+ }
10953
+ };
10954
+ }
10673
10955
  function issue(ruleId, page, detail, evidence) {
10674
10956
  const rule = explainRule(ruleId);
10675
10957
  if (!rule) throw new Error(`Missing rule guidance for ${ruleId}`);
@@ -10868,11 +11150,18 @@ function auditCrawlPages(pages, opts = {}) {
10868
11150
  }
10869
11151
  if (page.status < 200) continue;
10870
11152
  const redirected = isRedirectedPage(page);
10871
- if (redirected) issues.push(issue("redirected_url", page, `Final URL: ${page.finalUrl}`, {
10872
- requestedUrl: page.url,
10873
- finalUrl: page.finalUrl,
10874
- status: page.status
10875
- }));
11153
+ if (redirected) {
11154
+ const redirectTarget = page.metaRefresh?.targetUrl ?? page.finalUrl;
11155
+ issues.push(issue("redirected_url", page, `Final URL: ${redirectTarget}`, {
11156
+ requestedUrl: page.url,
11157
+ finalUrl: redirectTarget,
11158
+ status: page.status,
11159
+ ...page.metaRefresh ? {
11160
+ redirectType: "meta-refresh",
11161
+ delaySeconds: page.metaRefresh.delaySeconds
11162
+ } : {}
11163
+ }));
11164
+ }
10876
11165
  const redirectChain = page.fetchDiagnostics?.redirectChain ?? [];
10877
11166
  if (redirectChain.length > 1) issues.push(issue("redirect_chain", page, `${redirectChain.length} hops`, {
10878
11167
  redirectChain,
@@ -10884,6 +11173,9 @@ function auditCrawlPages(pages, opts = {}) {
10884
11173
  }));
10885
11174
  if (page.auditScope === "status") continue;
10886
11175
  if (redirected) continue;
11176
+ if (page.softAuthenticationGate) issues.push(issue("soft_authentication_gate", page, "A password form and login-specific page signals were observed.", page.softAuthenticationGate));
11177
+ const renderedContent = clientRenderedContentEvidence(page);
11178
+ if (renderedContent) issues.push(issue("client_rendered_content", page, "The raw response was an empty or near-empty shell.", renderedContent));
10887
11179
  if (isHtmlPage(page) && (page.sizeBytes ?? 0) > LARGE_HTML_BYTES) issues.push(issue("large_html", page, `${Math.round((page.sizeBytes ?? 0) / 1024)} KB`, {
10888
11180
  sizeBytes: page.sizeBytes,
10889
11181
  thresholdBytes: LARGE_HTML_BYTES
@@ -11489,7 +11781,7 @@ function createCrawlReport(input) {
11489
11781
  const requests = sortRequests(sanitizeTenantValue(input.requests ?? []));
11490
11782
  const requestEvidenceStatus = input.requestEvidenceStatus ?? (input.requests ? "available" : "unavailable");
11491
11783
  if (requestEvidenceStatus === "unavailable" && requests.length > 0) throw new Error("Unavailable request evidence cannot include request observations.");
11492
- const safeIssues = sanitizeIssues(input.issues ?? [...auditCrawlRequests(requests), ...auditCrawlPages(safePagesWithLinks, { startUrl: config.url })]);
11784
+ const safeIssues = sanitizeIssues([...input.issues ?? [...auditCrawlRequests(requests), ...auditCrawlPages(safePagesWithLinks, { startUrl: config.url })], ...input.additionalIssues ?? []]);
11493
11785
  const pages = safePagesWithLinks;
11494
11786
  return {
11495
11787
  id: input.id ?? crawlRunId(),
@@ -11518,6 +11810,7 @@ function createCrawlReport(input) {
11518
11810
  ...input.ai ? { ai: sanitizeTenantValue(input.ai) } : {},
11519
11811
  ...input.sitemapDiscovery ? { sitemapDiscovery: sanitizeTenantValue(input.sitemapDiscovery) } : {},
11520
11812
  ...input.externalLinkVerification ? { externalLinkVerification: sanitizeTenantValue(input.externalLinkVerification) } : {},
11813
+ ...input.siteChecks ? { siteChecks: sanitizeTenantValue(input.siteChecks) } : {},
11521
11814
  ...input.agentDiscovery ? { agentDiscovery: sanitizeTenantValue(input.agentDiscovery) } : {},
11522
11815
  warnings: sanitizeMessages(input.warnings).sort(compareText$13),
11523
11816
  caveats: sanitizeMessages(input.caveats).sort(compareText$13)
@@ -11724,7 +12017,7 @@ function latestCrawlReport(site, store = crawlReportStore) {
11724
12017
  //#region packages/core/src/analyze/crawler/crawl-caveats.ts
11725
12018
  function crawlCaveats(input) {
11726
12019
  const caveats = input.cancelled ? ["Crawl cancelled before all queued URLs finished."] : input.pageLimitReached ? [`Stopped after reaching maxPages (${input.maxPages}).`] : [];
11727
- 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.`);
12020
+ 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.`);
11728
12021
  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.`);
11729
12022
  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.`);
11730
12023
  return caveats;
@@ -12087,6 +12380,75 @@ function observationFromPage(requestedUrl, page) {
12087
12380
  };
12088
12381
  }
12089
12382
  //#endregion
12383
+ //#region packages/core/src/analyze/crawler/site-checks.ts
12384
+ const PROBE_COUNT = 2;
12385
+ function probeUrls(startUrl) {
12386
+ const origin = new URL(startUrl).origin;
12387
+ const seed = createHash("sha256").update(origin).digest("hex").slice(0, 12);
12388
+ return Array.from({ length: PROBE_COUNT }, (_, index) => new URL(`/.well-known/seo-audit/not-found-${seed}-${index + 1}`, origin).toString());
12389
+ }
12390
+ function probeObservation(url, result) {
12391
+ const request = result.request;
12392
+ if (!request) return {
12393
+ url,
12394
+ error: result.warning ?? "No response evidence."
12395
+ };
12396
+ if (request.outcome === "response") return {
12397
+ url,
12398
+ status: request.status,
12399
+ finalUrl: request.finalUrl,
12400
+ redirected: request.finalUrl !== url,
12401
+ accessBlocked: Boolean(request.accessBlock)
12402
+ };
12403
+ return {
12404
+ url,
12405
+ error: request.outcome === "failure" ? request.error : `Probe skipped: ${request.reason}`
12406
+ };
12407
+ }
12408
+ async function collectCrawlSiteChecks(input) {
12409
+ const probes = [];
12410
+ for (const url of probeUrls(input.startUrl)) {
12411
+ if (input.signal?.aborted) break;
12412
+ const result = await input.fetchStatusPage(url, {
12413
+ js: "off",
12414
+ refresh: true,
12415
+ timeoutMs: input.timeoutMs,
12416
+ signal: input.signal,
12417
+ writeCache: false,
12418
+ respectRobots: input.respectRobots
12419
+ });
12420
+ probes.push(probeObservation(url, result));
12421
+ }
12422
+ const conclusive = probes.filter((probe) => probe.status !== void 0 && !probe.accessBlocked);
12423
+ const successful = conclusive.filter((probe) => (probe.status ?? 0) >= 200 && (probe.status ?? 0) < 400);
12424
+ const correctMissing = conclusive.filter((probe) => probe.status === 404 || probe.status === 410);
12425
+ const status = conclusive.length !== PROBE_COUNT ? "unknown" : successful.length === PROBE_COUNT ? "warning" : correctMissing.length === PROBE_COUNT ? "pass" : "unknown";
12426
+ const checks = { soft404: {
12427
+ status,
12428
+ probes,
12429
+ probeLimit: PROBE_COUNT,
12430
+ complete: probes.length === PROBE_COUNT
12431
+ } };
12432
+ if (status !== "warning") return {
12433
+ checks,
12434
+ issues: []
12435
+ };
12436
+ const rule = explainRule("soft_404");
12437
+ if (!rule) throw new Error("Missing rule guidance for soft_404");
12438
+ return {
12439
+ checks,
12440
+ issues: [{
12441
+ ruleId: "soft_404",
12442
+ title: rule.title,
12443
+ category: rule.category,
12444
+ severity: rule.defaultSeverity,
12445
+ url: new URL("/", input.startUrl).toString(),
12446
+ detail: `${successful.length} known-nonexistent paths returned a successful response or redirect.`,
12447
+ evidence: checks.soft404
12448
+ }]
12449
+ };
12450
+ }
12451
+ //#endregion
12090
12452
  //#region packages/core/src/analyze/crawler/site-crawl-ai-signals.ts
12091
12453
  const MAX_AUXILIARY_RESPONSE_BYTES = 2 * 1024 * 1024;
12092
12454
  const AI_BOT_USER_AGENTS = [
@@ -12806,6 +13168,10 @@ async function crawlOne(url, opts = {}) {
12806
13168
  mainContentHash: hashText(extracted.contentText),
12807
13169
  textRatio: fetched.html.length ? Math.min(1, extracted.contentText.length / fetched.html.length) : void 0,
12808
13170
  contentSample: truncate$1(extracted.contentText, 300),
13171
+ contentSketch: crawlerEvidence.contentSketch,
13172
+ softAuthenticationGate: crawlerEvidence.softAuthenticationGate,
13173
+ metaRefresh: crawlerEvidence.metaRefresh,
13174
+ tabbedContent: crawlerEvidence.tabbedContent,
12809
13175
  lang: extracted.lang,
12810
13176
  hasViewport: extracted.hasViewport,
12811
13177
  isHttps: new URL(extracted.finalUrl).protocol === "https:",
@@ -12855,9 +13221,9 @@ async function crawlOne(url, opts = {}) {
12855
13221
  hasQapageSchema: extracted.schemaTypes.includes("QAPage")
12856
13222
  }
12857
13223
  };
12858
- page.indexability = indexabilityReason(page);
13224
+ page.indexability = page.metaRefresh ? `Meta refresh to ${page.metaRefresh.targetUrl}` : indexabilityReason(page);
12859
13225
  page.indexable = !page.indexability;
12860
- 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";
13226
+ 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";
12861
13227
  return {
12862
13228
  request: responseObservation(url, fetched, "complete"),
12863
13229
  page,
@@ -13651,6 +14017,9 @@ async function crawlSite(input, dependencies) {
13651
14017
  let robotsDeferredUrls = 0;
13652
14018
  let originBackpressureSkippedUrls = 0;
13653
14019
  let queueSafetySkippedUrls = 0;
14020
+ const queueSafetyDeferredUrls = /* @__PURE__ */ new Set();
14021
+ const maxTrackedQueueSafetyUrls = Math.min(5e4, Math.max(1e3, config.maxPages * 5));
14022
+ let queueSafetyInventoryCapped = false;
13654
14023
  let failedUrls = 0;
13655
14024
  let observedInternalLinks = 0;
13656
14025
  let memoryLimited = false;
@@ -13755,13 +14124,16 @@ async function crawlSite(input, dependencies) {
13755
14124
  return;
13756
14125
  }
13757
14126
  if (queue.size + visited.size + inFlight.size >= config.maxPages * 5) {
13758
- recordSkip("queue-safety-limit");
13759
- queueSafetySkippedUrls += 1;
13760
- emitStatus("url_skipped", {
13761
- url: normalized,
13762
- depth,
13763
- reason: "queue_safety_limit"
13764
- });
14127
+ if (!queueSafetyDeferredUrls.has(normalized)) if (queueSafetyDeferredUrls.size < maxTrackedQueueSafetyUrls) {
14128
+ queueSafetyDeferredUrls.add(normalized);
14129
+ queueSafetySkippedUrls = queueSafetyDeferredUrls.size;
14130
+ recordSkip("queue-safety-limit");
14131
+ emitStatus("url_skipped", {
14132
+ url: normalized,
14133
+ depth,
14134
+ reason: "queue_safety_limit"
14135
+ });
14136
+ } else queueSafetyInventoryCapped = true;
13765
14137
  return;
13766
14138
  }
13767
14139
  discovered.add(normalized);
@@ -14090,6 +14462,13 @@ async function crawlSite(input, dependencies) {
14090
14462
  signal,
14091
14463
  concurrency: Math.min(config.concurrency, 4)
14092
14464
  }) : void 0;
14465
+ const siteCheckResult = !healthStrategy && !cancelled && !stoppedForMemory ? await collectCrawlSiteChecks({
14466
+ startUrl: config.url,
14467
+ timeoutMs: config.timeoutMs,
14468
+ fetchStatusPage: deps.fetchStatusPage,
14469
+ respectRobots: config.respectRobots,
14470
+ signal
14471
+ }) : void 0;
14093
14472
  for (const page of pages) {
14094
14473
  if (!page.geo || !llmsTxt) continue;
14095
14474
  page.geo = {
@@ -14115,6 +14494,10 @@ async function crawlSite(input, dependencies) {
14115
14494
  } } : {},
14116
14495
  ...sitemapDiscovery ? { sitemapDiscovery } : {},
14117
14496
  ...externalLinkVerification ? { externalLinkVerification } : {},
14497
+ ...siteCheckResult ? {
14498
+ siteChecks: siteCheckResult.checks,
14499
+ additionalIssues: siteCheckResult.issues
14500
+ } : {},
14118
14501
  ...agentDiscovery ? { agentDiscovery } : {},
14119
14502
  status: reportStatus,
14120
14503
  warnings,
@@ -14123,6 +14506,7 @@ async function crawlSite(input, dependencies) {
14123
14506
  pageLimitReached,
14124
14507
  maxPages: config.maxPages,
14125
14508
  queueSafetySkippedUrls,
14509
+ queueSafetyInventoryCapped,
14126
14510
  originBackpressureSkippedUrls,
14127
14511
  memoryPressureSkippedUrls
14128
14512
  }),
@@ -16407,6 +16791,8 @@ const agentRepresentationResponseSchema = z.object({
16407
16791
  status: z.number().int().optional(),
16408
16792
  contentType: z.string().optional(),
16409
16793
  bytes: z.number().int().nonnegative().optional(),
16794
+ characters: z.number().int().nonnegative().optional(),
16795
+ estimatedTokens: z.number().int().nonnegative().optional(),
16410
16796
  sha256: z.string().optional(),
16411
16797
  canonicalUrl: z.string().url().optional(),
16412
16798
  alternateUrl: z.string().url().optional(),
@@ -16464,7 +16850,15 @@ const agentDiscoverySchema = z.object({
16464
16850
  sourceWordCount: z.number().int().nonnegative(),
16465
16851
  wordRetentionRatio: z.number().nonnegative().nullable(),
16466
16852
  introductoryCopyRetained: z.boolean().nullable(),
16467
- navigationOnly: z.boolean()
16853
+ navigationOnly: z.boolean(),
16854
+ contentSketchCoverage: z.number().min(0).max(1).nullable().optional(),
16855
+ tabbedContent: z.object({
16856
+ detectedPanels: z.number().int().nonnegative(),
16857
+ evaluatedPanels: z.number().int().nonnegative(),
16858
+ retainedPanels: z.number().int().nonnegative(),
16859
+ missingPanels: z.number().int().nonnegative(),
16860
+ complete: z.boolean().nullable()
16861
+ }).optional()
16468
16862
  }).optional()
16469
16863
  }))
16470
16864
  }),
@@ -16483,6 +16877,11 @@ const agentDiscoverySchema = z.object({
16483
16877
  missingHtmlRoutes: z.array(z.string()),
16484
16878
  missingMarkdownRoutes: z.array(z.string()),
16485
16879
  orphanMarkdownRoutes: z.array(z.string()),
16880
+ comparisonStatus: z.enum([
16881
+ "complete",
16882
+ "partial",
16883
+ "not-applicable"
16884
+ ]).optional(),
16486
16885
  error: z.string().optional()
16487
16886
  }),
16488
16887
  agentSkills: z.object({
@@ -16588,6 +16987,25 @@ const agentDiscoverySchema = z.object({
16588
16987
  warnings: z.array(z.string())
16589
16988
  });
16590
16989
  //#endregion
16990
+ //#region packages/core/src/analyze/crawler/site-checks-schema.ts
16991
+ const crawlSiteChecksSchema = z.object({ soft404: z.object({
16992
+ status: z.enum([
16993
+ "pass",
16994
+ "warning",
16995
+ "unknown"
16996
+ ]),
16997
+ probes: z.array(z.object({
16998
+ url: z.string().url(),
16999
+ status: z.number().int().optional(),
17000
+ finalUrl: z.string().url().optional(),
17001
+ redirected: z.boolean().optional(),
17002
+ accessBlocked: z.boolean().optional(),
17003
+ error: z.string().optional()
17004
+ })),
17005
+ probeLimit: z.number().int().positive(),
17006
+ complete: z.boolean()
17007
+ }) });
17008
+ //#endregion
16591
17009
  //#region packages/core/src/analyze/crawler/schemas.ts
16592
17010
  const crawlerRuleSeveritySchema = z.enum([
16593
17011
  "low",
@@ -16983,6 +17401,7 @@ const crawlPageSnapshotSchema = z.object({
16983
17401
  "robots-blocked",
16984
17402
  "canonical-conflict",
16985
17403
  "canonical-hint-other",
17404
+ "meta-refresh",
16986
17405
  "not-html",
16987
17406
  "unknown"
16988
17407
  ]).optional(),
@@ -17017,6 +17436,38 @@ const crawlPageSnapshotSchema = z.object({
17017
17436
  mainContentHash: z.string().optional(),
17018
17437
  textRatio: z.number().min(0).max(1).optional(),
17019
17438
  contentSample: z.string().optional(),
17439
+ contentSketch: z.object({
17440
+ version: z.literal(1),
17441
+ wordCount: z.number().int().nonnegative(),
17442
+ shingleSize: z.number().int().positive(),
17443
+ sampledShingles: z.number().int().nonnegative(),
17444
+ hashes: z.array(z.string())
17445
+ }).optional(),
17446
+ softAuthenticationGate: z.object({
17447
+ kind: z.literal("login-form"),
17448
+ indicators: z.array(z.string()),
17449
+ formActionPath: z.string().optional()
17450
+ }).optional(),
17451
+ metaRefresh: z.object({
17452
+ targetUrl: z.string().url(),
17453
+ delaySeconds: z.number().nonnegative()
17454
+ }).optional(),
17455
+ tabbedContent: z.object({
17456
+ groups: z.number().int().positive(),
17457
+ panels: z.number().int().positive(),
17458
+ retainedPanels: z.number().int().nonnegative(),
17459
+ truncated: z.boolean(),
17460
+ panelSketches: z.array(z.object({
17461
+ label: z.string().optional(),
17462
+ sketch: z.object({
17463
+ version: z.literal(1),
17464
+ wordCount: z.number().int().nonnegative(),
17465
+ shingleSize: z.number().int().positive(),
17466
+ sampledShingles: z.number().int().nonnegative(),
17467
+ hashes: z.array(z.string())
17468
+ })
17469
+ }))
17470
+ }).optional(),
17020
17471
  lang: z.string().optional(),
17021
17472
  hasViewport: z.boolean().optional(),
17022
17473
  isHttps: z.boolean().optional(),
@@ -17508,6 +17959,7 @@ const crawlReportBaseSchema = z.object({
17508
17959
  ai: crawlAiSignalsSchema.optional(),
17509
17960
  sitemapDiscovery: crawlSitemapDiscoverySchema.optional(),
17510
17961
  externalLinkVerification: crawlExternalLinkVerificationSchema.optional(),
17962
+ siteChecks: crawlSiteChecksSchema.optional(),
17511
17963
  agentDiscovery: agentDiscoverySchema.optional(),
17512
17964
  warnings: z.array(z.string()),
17513
17965
  caveats: z.array(z.string())