seo 0.2.12 → 0.2.13

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-B_swz2Xd.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);
@@ -3149,6 +3186,48 @@ const SOCIAL_PROFILE_HOSTS = [
3149
3186
  "x.com",
3150
3187
  "youtube.com"
3151
3188
  ];
3189
+ const LOGIN_PATH_PATTERN = /(?:^|\/)(?:auth|login|log-in|signin|sign-in|sso)(?:\/|$)/iu;
3190
+ const LOGIN_TITLE_PATTERN = /\b(?:authenticate|log[ -]?in|sign[ -]?in)\b/iu;
3191
+ function softAuthenticationGate($, finalUrl, title) {
3192
+ const passwordForms = $("form").toArray().filter((form) => $(form).find("input[type=\"password\" i]").length > 0);
3193
+ if (passwordForms.length === 0) return void 0;
3194
+ const pagePath = new URL(finalUrl).pathname;
3195
+ const action = $(passwordForms[0]).attr("action");
3196
+ const actionUrl = action ? httpUrl$1(action, finalUrl) : void 0;
3197
+ const actionPath = actionUrl ? new URL(actionUrl).pathname : void 0;
3198
+ const indicators = [
3199
+ ...LOGIN_PATH_PATTERN.test(pagePath) ? ["login-path"] : [],
3200
+ ...LOGIN_TITLE_PATTERN.test(title ?? "") ? ["login-title"] : [],
3201
+ ...LOGIN_PATH_PATTERN.test(actionPath ?? "") ? ["login-form-action"] : []
3202
+ ];
3203
+ if (indicators.length === 0) return void 0;
3204
+ return {
3205
+ kind: "login-form",
3206
+ indicators,
3207
+ ...actionPath ? { formActionPath: actionPath } : {}
3208
+ };
3209
+ }
3210
+ function tabbedContent($) {
3211
+ const groups = $("[role=\"tablist\" i]").length;
3212
+ const panels = $("[role=\"tabpanel\" i]").toArray();
3213
+ if (groups === 0 || panels.length === 0) return void 0;
3214
+ const panelSketches = panels.slice(0, 12).map((panel) => {
3215
+ const labelledBy = $(panel).attr("aria-labelledby");
3216
+ const labelledElement = labelledBy ? $("[id]").toArray().find((element) => $(element).attr("id") === labelledBy) : void 0;
3217
+ const label = safeText$1($(panel).attr("aria-label") ?? (labelledElement ? $(labelledElement).text() : void 0));
3218
+ return {
3219
+ ...label ? { label: label.slice(0, 80) } : {},
3220
+ sketch: contentSketch($(panel).text())
3221
+ };
3222
+ });
3223
+ return {
3224
+ groups,
3225
+ panels: panels.length,
3226
+ retainedPanels: panelSketches.length,
3227
+ truncated: panels.length > panelSketches.length,
3228
+ panelSketches
3229
+ };
3230
+ }
3152
3231
  function safeText$1(value) {
3153
3232
  const trimmed = value?.replace(/\s+/g, " ").trim();
3154
3233
  return trimmed ? trimmed : void 0;
@@ -3375,10 +3454,11 @@ async function extractPage(fetchResult, extractor = "defuddle", dependencies = {
3375
3454
  html: sanitizedContentHtml($, fetchResult.html, fetchResult.finalUrl)
3376
3455
  }, extractor, dependencies);
3377
3456
  const { text, excerpt } = content;
3457
+ const title = safeText$1($("title").first().text());
3378
3458
  return {
3379
3459
  url: fetchResult.url,
3380
3460
  finalUrl: fetchResult.finalUrl,
3381
- title: safeText$1($("title").first().text()),
3461
+ title,
3382
3462
  metaDescription: safeText$1($("meta[name=\"description\"]").attr("content")),
3383
3463
  metaRobots,
3384
3464
  xRobotsTag: safeText$1(headerValue$4(fetchResult.headers, "x-robots-tag")),
@@ -3398,7 +3478,10 @@ async function extractPage(fetchResult, extractor = "defuddle", dependencies = {
3398
3478
  externalLinks: [...crawlerExternalLinks],
3399
3479
  internalAnchorSamples,
3400
3480
  externalAnchorSamples,
3401
- socialProfileLinks: [...socialProfileLinks]
3481
+ socialProfileLinks: [...socialProfileLinks],
3482
+ contentSketch: contentSketch(text),
3483
+ softAuthenticationGate: softAuthenticationGate($, fetchResult.finalUrl, title),
3484
+ tabbedContent: tabbedContent($)
3402
3485
  } } : {},
3403
3486
  markdownAlternates,
3404
3487
  hreflang,
@@ -7631,6 +7714,8 @@ function markdownQuality(markdown, page) {
7631
7714
  const codeFences = markdown.match(/^```/gmu)?.length ?? 0;
7632
7715
  const wordCount = bodyWordCount(markdown);
7633
7716
  const sourceWordCount = page.wordCount ?? 0;
7717
+ const evaluatedPanels = (page.tabbedContent?.panelSketches ?? []).map((panel) => contentSketchCoverage(panel.sketch, markdown)).filter((coverage) => coverage !== null);
7718
+ const retainedPanels = evaluatedPanels.filter((coverage) => coverage >= .6).length;
7634
7719
  return {
7635
7720
  frontmatterTitle: /^---[\s\S]*?^title:\s*.+$/mu.test(markdown),
7636
7721
  h1Count: markdown.match(/^#\s+\S.+$/gmu)?.length ?? 0,
@@ -7647,7 +7732,15 @@ function markdownQuality(markdown, page) {
7647
7732
  sourceWordCount,
7648
7733
  wordRetentionRatio: sourceWordCount > 0 ? Math.round(wordCount / sourceWordCount * 1e3) / 1e3 : null,
7649
7734
  introductoryCopyRetained: introProbe ? normalizedMarkdown.includes(introProbe) : null,
7650
- navigationOnly: bodyWordCount(markdown) < 25
7735
+ navigationOnly: bodyWordCount(markdown) < 25,
7736
+ contentSketchCoverage: contentSketchCoverage(page.contentSketch, markdown),
7737
+ tabbedContent: {
7738
+ detectedPanels: page.tabbedContent?.panels ?? 0,
7739
+ evaluatedPanels: evaluatedPanels.length,
7740
+ retainedPanels,
7741
+ missingPanels: Math.max(0, evaluatedPanels.length - retainedPanels),
7742
+ complete: evaluatedPanels.length === 0 ? null : retainedPanels === evaluatedPanels.length
7743
+ }
7651
7744
  };
7652
7745
  }
7653
7746
  function combinedSignal(timeoutMs, signal) {
@@ -7701,6 +7794,8 @@ async function fetchRepresentation(input) {
7701
7794
  status: response.status,
7702
7795
  contentType: response.headers.get("content-type") ?? void 0,
7703
7796
  bytes: Buffer.byteLength(body),
7797
+ characters: body.length,
7798
+ estimatedTokens: Math.ceil(body.length / 4),
7704
7799
  sha256: sha256(body),
7705
7800
  canonicalUrl: link.find((entry) => entry.rel.includes("canonical"))?.url,
7706
7801
  alternateUrl: link.find((entry) => entry.rel.includes("alternate") && entry.type === "text/markdown")?.url,
@@ -7759,11 +7854,15 @@ async function inspectMarkdownPage(input) {
7759
7854
  if (explicit) observation.explicit = explicit.observation;
7760
7855
  observation.negotiated = negotiated.observation;
7761
7856
  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);
7857
+ 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 ?? "");
7858
+ const explicitMarkdown = isMarkdown(explicit) ? explicit : void 0;
7859
+ const negotiatedMarkdown = isMarkdown(negotiated) ? negotiated : void 0;
7860
+ const repeatedMarkdown = isMarkdown(repeated) ? repeated : void 0;
7861
+ observation.explicitMatchesNegotiated = explicitMarkdown?.observation.sha256 && negotiatedMarkdown?.observation.sha256 ? explicitMarkdown.observation.sha256 === negotiatedMarkdown.observation.sha256 : null;
7862
+ const primary = explicitMarkdown ?? negotiatedMarkdown;
7863
+ observation.repeatedHashStable = primary?.observation.sha256 && repeatedMarkdown?.observation.sha256 ? primary.observation.sha256 === repeatedMarkdown.observation.sha256 : null;
7864
+ observation.markdownCanonicalMatchesHtml = sameDocument(primary?.observation.canonicalUrl, input.page.finalUrl);
7865
+ if (primary?.body) observation.quality = markdownQuality(primary.body, input.page);
7767
7866
  return observation;
7768
7867
  }
7769
7868
  async function fetchText(input) {
@@ -8007,17 +8106,16 @@ async function inspectRouteManifest(input) {
8007
8106
  const declared = Array.isArray(parsed.pages) ? parsed.pages : [];
8008
8107
  const declaredHtmlRoutes = declared.map((page) => page.htmlPath).filter((path) => typeof path === "string").sort();
8009
8108
  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
8109
  return {
8013
8110
  url,
8014
8111
  status: result.response.status,
8015
8112
  valid: Array.isArray(parsed.pages),
8016
8113
  declaredHtmlRoutes,
8017
8114
  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()
8115
+ missingHtmlRoutes: [],
8116
+ missingMarkdownRoutes: [],
8117
+ orphanMarkdownRoutes: [],
8118
+ comparisonStatus: "partial"
8021
8119
  };
8022
8120
  } catch (error) {
8023
8121
  return {
@@ -8028,6 +8126,7 @@ async function inspectRouteManifest(input) {
8028
8126
  missingHtmlRoutes: [],
8029
8127
  missingMarkdownRoutes: [],
8030
8128
  orphanMarkdownRoutes: [],
8129
+ comparisonStatus: "not-applicable",
8031
8130
  error: safeError(error)
8032
8131
  };
8033
8132
  }
@@ -8071,9 +8170,7 @@ async function collectAgentDiscovery(input) {
8071
8170
  }),
8072
8171
  inspectRouteManifest({
8073
8172
  ...input,
8074
- origin,
8075
- pages,
8076
- observations
8173
+ origin
8077
8174
  }),
8078
8175
  inspectAgentEndpoints({
8079
8176
  ...input,
@@ -8178,20 +8275,13 @@ async function collectAgentDiscovery(input) {
8178
8275
  };
8179
8276
  }
8180
8277
  //#endregion
8181
- //#region packages/core/src/analyze/crawler/agent-readiness.ts
8182
- function check$1(section, input) {
8278
+ //#region packages/core/src/analyze/crawler/agent-readiness-markdown.ts
8279
+ function check$2(section, input) {
8183
8280
  return {
8184
8281
  section,
8185
8282
  ...input
8186
8283
  };
8187
8284
  }
8188
- function section$2(id, title, checks) {
8189
- return {
8190
- id,
8191
- title,
8192
- checks
8193
- };
8194
- }
8195
8285
  function failedMarkdownUrls(discovery) {
8196
8286
  return discovery.markdownAlternates.pages.filter((page) => {
8197
8287
  const negotiatedOk = page.negotiated?.status === 200 && /^\s*text\/markdown\b/iu.test(page.negotiated.contentType ?? "") && page.negotiated.varyAccept;
@@ -8202,38 +8292,12 @@ function failedMarkdownUrls(discovery) {
8202
8292
  function qualityFailureUrls(discovery) {
8203
8293
  return discovery.markdownAlternates.pages.filter((page) => {
8204
8294
  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;
8295
+ if (!quality) return false;
8296
+ 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
8297
  }).map((page) => page.htmlUrl);
8208
8298
  }
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
- };
8299
+ function confirmedMarkdown(page) {
8300
+ 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
8301
  }
8238
8302
  function markdownChecks(discovery) {
8239
8303
  const markdown = discovery.markdownAlternates;
@@ -8243,12 +8307,28 @@ function markdownChecks(discovery) {
8243
8307
  const explicitPages = markdown.pages.filter((page) => page.htmlAlternateUnique).length;
8244
8308
  const coverageComplete = markdown.eligibleHtmlPages > 0 && markdown.evaluatedPages === markdown.eligibleHtmlPages;
8245
8309
  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;
8310
+ const stable = markdown.evaluatedPages > 0 && markdown.stableResponses === markdown.evaluatedPages;
8247
8311
  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);
8312
+ return confirmedMarkdown(page)?.markdownTokens !== void 0 && (!page.explicit || !page.negotiated || page.explicit.markdownTokens === page.negotiated.markdownTokens);
8249
8313
  }).length;
8314
+ const sizedPages = markdown.pages.filter((page) => {
8315
+ return confirmedMarkdown(page)?.characters !== void 0;
8316
+ });
8317
+ const largePages = sizedPages.filter((page) => {
8318
+ return (confirmedMarkdown(page)?.characters ?? 0) > 5e4;
8319
+ });
8320
+ const veryLargePages = sizedPages.filter((page) => {
8321
+ return (confirmedMarkdown(page)?.characters ?? 0) > 1e5;
8322
+ });
8323
+ const maxCharacters = Math.max(0, ...sizedPages.map((page) => confirmedMarkdown(page)?.characters ?? 0));
8324
+ const maxEstimatedTokens = Math.max(0, ...sizedPages.map((page) => confirmedMarkdown(page)?.estimatedTokens ?? 0));
8325
+ const parityPages = markdown.pages.filter((page) => page.quality?.contentSketchCoverage !== null && page.quality?.contentSketchCoverage !== void 0);
8326
+ const parityFailures = parityPages.filter((page) => (page.quality?.contentSketchCoverage ?? 0) < .6);
8327
+ const tabbedPages = markdown.pages.filter((page) => (page.quality?.tabbedContent?.detectedPanels ?? 0) > 0);
8328
+ const tabFailures = tabbedPages.filter((page) => page.quality?.tabbedContent?.complete === false);
8329
+ const tabUnknown = tabbedPages.filter((page) => page.quality?.tabbedContent?.complete === null);
8250
8330
  return [
8251
- check$1("representations", {
8331
+ check$2("representations", {
8252
8332
  id: "markdown-coverage",
8253
8333
  status: markdown.eligibleHtmlPages === 0 ? "unknown" : coverageComplete ? "pass" : "fail",
8254
8334
  title: coverageComplete ? "Every successful HTML page has a Markdown representation" : "Markdown representation coverage is incomplete",
@@ -8262,17 +8342,62 @@ function markdownChecks(discovery) {
8262
8342
  },
8263
8343
  urls: failedUrls.slice(0, 25)
8264
8344
  }),
8265
- check$1("representations", {
8345
+ check$2("representations", {
8266
8346
  id: "markdown-token-estimates",
8267
- status: markdown.evaluatedPages === 0 ? "unknown" : tokenHeaders === markdown.evaluatedPages ? "pass" : "warning",
8347
+ status: markdown.evaluatedPages === 0 ? "unknown" : tokenHeaders === markdown.evaluatedPages ? "pass" : "info",
8268
8348
  title: tokenHeaders === markdown.evaluatedPages ? "Every Markdown response includes a token estimate" : "Some Markdown responses are missing a stable token estimate",
8269
8349
  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.",
8350
+ action: 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
8351
  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;
8352
+ return confirmedMarkdown(page)?.markdownTokens === void 0 || page.explicit !== void 0 && page.negotiated !== void 0 && page.explicit.markdownTokens !== page.negotiated.markdownTokens;
8273
8353
  }).map((page) => page.htmlUrl).slice(0, 25)
8274
8354
  }),
8275
- check$1("representations", {
8355
+ check$2("representations", {
8356
+ id: "markdown-size",
8357
+ status: sizedPages.length === 0 ? "unknown" : largePages.length === 0 ? "pass" : "warning",
8358
+ 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",
8359
+ 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.`,
8360
+ action: 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.",
8361
+ evidence: {
8362
+ measuredPages: sizedPages.length,
8363
+ reviewThresholdCharacters: 5e4,
8364
+ highRiskThresholdCharacters: 1e5,
8365
+ largePages: largePages.length,
8366
+ veryLargePages: veryLargePages.length,
8367
+ maxCharacters,
8368
+ maxEstimatedTokens,
8369
+ heuristic: true
8370
+ },
8371
+ urls: largePages.map((page) => page.htmlUrl).slice(0, 25)
8372
+ }),
8373
+ check$2("representations", {
8374
+ id: "markdown-content-parity",
8375
+ status: parityPages.length === 0 ? "unknown" : parityFailures.length === 0 ? "pass" : "warning",
8376
+ 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",
8377
+ 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.`,
8378
+ action: 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.",
8379
+ evidence: {
8380
+ evaluatedPages: parityPages.length,
8381
+ failedPages: parityFailures.length,
8382
+ minimumCoverage: .6,
8383
+ sampling: "bounded-content-shingles"
8384
+ },
8385
+ urls: parityFailures.map((page) => page.htmlUrl).slice(0, 25)
8386
+ }),
8387
+ check$2("representations", {
8388
+ id: "markdown-tab-serialization",
8389
+ status: tabbedPages.length === 0 ? "notApplicable" : tabFailures.length > 0 ? "warning" : tabUnknown.length > 0 ? "unknown" : "pass",
8390
+ 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",
8391
+ 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.`,
8392
+ 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.",
8393
+ evidence: {
8394
+ tabbedPages: tabbedPages.length,
8395
+ failedPages: tabFailures.length,
8396
+ unknownPages: tabUnknown.length
8397
+ },
8398
+ urls: [...tabFailures, ...tabUnknown].map((page) => page.htmlUrl).slice(0, 25)
8399
+ }),
8400
+ check$2("representations", {
8276
8401
  id: "markdown-negotiation",
8277
8402
  status: markdown.eligibleHtmlPages === 0 ? "unknown" : negotiationComplete && discovery.contentNegotiation.qZeroHonoured ? "pass" : "fail",
8278
8403
  title: negotiationComplete && discovery.contentNegotiation.qZeroHonoured ? "Markdown content negotiation works across the site" : "Markdown content negotiation needs attention",
@@ -8286,16 +8411,16 @@ function markdownChecks(discovery) {
8286
8411
  },
8287
8412
  urls: failedUrls.slice(0, 25)
8288
8413
  }),
8289
- check$1("representations", {
8414
+ check$2("representations", {
8290
8415
  id: "markdown-determinism",
8291
- status: markdown.eligibleHtmlPages === 0 ? "unknown" : stable ? "pass" : "fail",
8416
+ status: markdown.evaluatedPages === 0 ? "unknown" : stable ? "pass" : "fail",
8292
8417
  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.`,
8418
+ plainEnglish: `${markdown.stableResponses} of ${markdown.evaluatedPages} repeated Markdown responses kept the same SHA-256 digest.`,
8294
8419
  action: "Remove timestamps, random output, or other runtime rewriting that changes the same Markdown response between requests.",
8295
8420
  evidence: { stableResponses: markdown.stableResponses },
8296
8421
  urls: failedUrls.slice(0, 25)
8297
8422
  }),
8298
- check$1("representations", {
8423
+ check$2("representations", {
8299
8424
  id: "markdown-quality",
8300
8425
  status: markdown.evaluatedPages === 0 ? "unknown" : qualityUrls.length === 0 ? "pass" : "warning",
8301
8426
  title: qualityUrls.length === 0 ? "Markdown keeps the useful document structure cleanly" : "Some Markdown alternatives need an extraction review",
@@ -8305,6 +8430,50 @@ function markdownChecks(discovery) {
8305
8430
  })
8306
8431
  ];
8307
8432
  }
8433
+ //#endregion
8434
+ //#region packages/core/src/analyze/crawler/agent-readiness.ts
8435
+ function check$1(section, input) {
8436
+ return {
8437
+ section,
8438
+ ...input
8439
+ };
8440
+ }
8441
+ function section$2(id, title, checks) {
8442
+ return {
8443
+ id,
8444
+ title,
8445
+ checks
8446
+ };
8447
+ }
8448
+ function profileChecks(discovery) {
8449
+ return [
8450
+ "api",
8451
+ "application",
8452
+ "commerce"
8453
+ ].map((profile) => check$1("profile", {
8454
+ id: `profile-${profile}`,
8455
+ status: "notApplicable",
8456
+ title: `${profile[0]?.toUpperCase()}${profile.slice(1)} checks are not applicable`,
8457
+ plainEnglish: discovery.profileApplicability[profile].reason,
8458
+ action: "Choose this profile only when the site exposes that capability publicly.",
8459
+ evidence: {
8460
+ profile,
8461
+ selectedProfile: discovery.profile
8462
+ }
8463
+ }));
8464
+ }
8465
+ function summariseChecks(checks) {
8466
+ const count = (status) => checks.filter((item) => item.status === status).length;
8467
+ return {
8468
+ checks: checks.length,
8469
+ passed: count("pass"),
8470
+ warnings: count("warning"),
8471
+ failed: count("fail"),
8472
+ unknown: count("unknown"),
8473
+ information: count("info"),
8474
+ notApplicable: count("notApplicable")
8475
+ };
8476
+ }
8308
8477
  function contentSignalsCheck(discovery, robotsContentSignals) {
8309
8478
  const signals = discovery.contentSignals;
8310
8479
  const robotsDeclared = robotsContentSignals ?? [];
@@ -8405,8 +8574,8 @@ function discoveryChecks(discovery, robotsContentSignals) {
8405
8574
  check$1("discovery", {
8406
8575
  id: "route-manifest",
8407
8576
  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.",
8577
+ title: discovery.routeManifest.valid ? "The public route manifest is valid" : "No public route manifest was available",
8578
+ 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
8579
  action: "Generate one deterministic route inventory from the build when exact HTML and Markdown parity matters.",
8411
8580
  evidence: discovery.routeManifest
8412
8581
  })
@@ -8537,25 +8706,42 @@ function accessChecks(report, discovery) {
8537
8706
  const blocked = bots.filter((bot) => bot.allowed === false);
8538
8707
  const robotsKnown = report.ai?.robotsTxt?.availability === "available" || report.ai?.robotsTxt?.availability === "absent";
8539
8708
  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
- })];
8709
+ const authenticationGates = report.issues.filter((issue) => issue.ruleId === "soft_authentication_gate");
8710
+ const clientRenderedPages = report.issues.filter((issue) => issue.ruleId === "client_rendered_content");
8711
+ return [
8712
+ check$1("access", {
8713
+ id: "crawler-access",
8714
+ status: !robotsKnown ? "unknown" : blocked.length > 0 ? "warning" : "pass",
8715
+ title: blocked.length ? "robots.txt blocks selected crawler tokens" : "No selected crawler token is blocked at the start URL",
8716
+ 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.",
8717
+ action: "Keep blocks intentional. A crawler token being allowed here does not prove that the service will crawl, index, or cite the site.",
8718
+ evidence: {
8719
+ availability: report.ai?.robotsTxt?.availability,
8720
+ blocked,
8721
+ botAccess: bots
8722
+ }
8723
+ }),
8724
+ check$1("access", {
8725
+ id: "public-content-access",
8726
+ status: authenticationGates.length > 0 || clientRenderedPages.length > 0 ? "warning" : "pass",
8727
+ 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",
8728
+ 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.`,
8729
+ 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.",
8730
+ evidence: {
8731
+ authenticationGates: authenticationGates.length,
8732
+ clientRenderedPages: clientRenderedPages.length
8733
+ },
8734
+ urls: [...authenticationGates, ...clientRenderedPages].map((issue) => issue.url).slice(0, 25)
8735
+ }),
8736
+ check$1("access", {
8737
+ id: "protocol-canonicalization",
8738
+ status: protocol.http.permanentRedirectToHttps === true && protocol.hstsOnStartPage === true ? "pass" : protocol.http.permanentRedirectToHttps === null ? "unknown" : "warning",
8739
+ 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",
8740
+ 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"}.`,
8741
+ action: "Use one permanent HTTP-to-HTTPS redirect and send HSTS only after HTTPS is stable across the site.",
8742
+ evidence: protocol
8743
+ })
8744
+ ];
8559
8745
  }
8560
8746
  function identityChecks(report) {
8561
8747
  const types = new Set(report.pages.flatMap((page) => page.schemaTypes ?? []));
@@ -10088,6 +10274,48 @@ const RULE_DEFINITIONS = [
10088
10274
  suggestedCommands: ["seo crawl --sitemap-url <url> --health --json"]
10089
10275
  }
10090
10276
  },
10277
+ {
10278
+ id: "soft_authentication_gate",
10279
+ title: "Page appears to be an authentication gate",
10280
+ category: "response",
10281
+ defaultSeverity: "medium",
10282
+ 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.",
10283
+ 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.",
10284
+ impactIfIgnored: "A login page can be mistaken for the intended content, producing misleading crawl results and weak search or agent access.",
10285
+ howToVerify: "Fetch the affected URL without a session and confirm it returns the intended public page, or a deliberate authentication status for protected content.",
10286
+ agentHints: {
10287
+ evidenceFields: ["page.softAuthenticationGate", "page.finalUrl"],
10288
+ suggestedCommands: ["seo crawl <url> --max-pages 1 --json"]
10289
+ }
10290
+ },
10291
+ {
10292
+ id: "client_rendered_content",
10293
+ title: "Important content requires JavaScript rendering",
10294
+ category: "response",
10295
+ defaultSeverity: "medium",
10296
+ 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.",
10297
+ 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.",
10298
+ impactIfIgnored: "Clients that do not execute JavaScript may see an empty shell or miss important content and links.",
10299
+ howToVerify: "Re-run the crawl and confirm the raw and rendered content observations contain the same important document content.",
10300
+ agentHints: {
10301
+ evidenceFields: ["page.fetchDiagnostics.rendering.documentDifference", "page.usedJs"],
10302
+ suggestedCommands: ["seo crawl <url> --json"]
10303
+ }
10304
+ },
10305
+ {
10306
+ id: "soft_404",
10307
+ title: "Unknown URLs return a successful response",
10308
+ category: "response",
10309
+ defaultSeverity: "high",
10310
+ 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.",
10311
+ 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.",
10312
+ impactIfIgnored: "Missing URLs can remain in crawl queues, waste crawl attention, and create confusing search or monitoring evidence.",
10313
+ howToVerify: "Request several known-nonexistent paths and confirm each returns 404 or 410 without redirecting to a generic successful page.",
10314
+ agentHints: {
10315
+ evidenceFields: ["report.siteChecks.soft404"],
10316
+ suggestedCommands: ["seo crawl <url> --json"]
10317
+ }
10318
+ },
10091
10319
  {
10092
10320
  id: "client_error",
10093
10321
  title: "Client error",
@@ -10577,6 +10805,8 @@ const RULE_RECOMMENDATIONS = {
10577
10805
  x_robots_noindex: "review",
10578
10806
  robots_blocked: "review",
10579
10807
  crawler_access_blocked: "review",
10808
+ soft_authentication_gate: "review",
10809
+ client_rendered_content: "review",
10580
10810
  orphan_page: "review",
10581
10811
  redirected_url: "review",
10582
10812
  slow_response: "review",
@@ -10670,6 +10900,28 @@ const WEAK_VALUABLE_INLINKS = 1;
10670
10900
  const QUERY_COVERAGE_MIN_IMPRESSIONS = 50;
10671
10901
  const QUERY_COVERAGE_MIN = .6;
10672
10902
  const OVERSIZED_IMAGE_CANDIDATE_LIMIT = 2e3;
10903
+ const EMPTY_INITIAL_HTML_CHARACTERS = 200;
10904
+ const SUBSTANTIVE_RENDERED_CHARACTERS = 500;
10905
+ function clientRenderedContentEvidence(page) {
10906
+ const rendering = page.fetchDiagnostics?.rendering;
10907
+ const difference = rendering?.documentDifference;
10908
+ if (rendering?.status !== "rendered" || !difference?.changed.includes("content")) return;
10909
+ const rawCharacters = difference.raw.content.characters;
10910
+ const renderedCharacters = difference.rendered.content.characters;
10911
+ if (rawCharacters >= EMPTY_INITIAL_HTML_CHARACTERS || renderedCharacters < SUBSTANTIVE_RENDERED_CHARACTERS || renderedCharacters < rawCharacters * 3) return;
10912
+ return {
10913
+ rawCharacters,
10914
+ rawWords: difference.raw.content.wordCount,
10915
+ renderedCharacters,
10916
+ renderedWords: difference.rendered.content.wordCount,
10917
+ changed: difference.changed,
10918
+ thresholds: {
10919
+ emptyInitialHtmlCharacters: EMPTY_INITIAL_HTML_CHARACTERS,
10920
+ substantiveRenderedCharacters: SUBSTANTIVE_RENDERED_CHARACTERS,
10921
+ minimumGrowthRatio: 3
10922
+ }
10923
+ };
10924
+ }
10673
10925
  function issue(ruleId, page, detail, evidence) {
10674
10926
  const rule = explainRule(ruleId);
10675
10927
  if (!rule) throw new Error(`Missing rule guidance for ${ruleId}`);
@@ -10884,6 +11136,9 @@ function auditCrawlPages(pages, opts = {}) {
10884
11136
  }));
10885
11137
  if (page.auditScope === "status") continue;
10886
11138
  if (redirected) continue;
11139
+ if (page.softAuthenticationGate) issues.push(issue("soft_authentication_gate", page, "A password form and login-specific page signals were observed.", page.softAuthenticationGate));
11140
+ const renderedContent = clientRenderedContentEvidence(page);
11141
+ if (renderedContent) issues.push(issue("client_rendered_content", page, "The raw response was an empty or near-empty shell.", renderedContent));
10887
11142
  if (isHtmlPage(page) && (page.sizeBytes ?? 0) > LARGE_HTML_BYTES) issues.push(issue("large_html", page, `${Math.round((page.sizeBytes ?? 0) / 1024)} KB`, {
10888
11143
  sizeBytes: page.sizeBytes,
10889
11144
  thresholdBytes: LARGE_HTML_BYTES
@@ -11489,7 +11744,7 @@ function createCrawlReport(input) {
11489
11744
  const requests = sortRequests(sanitizeTenantValue(input.requests ?? []));
11490
11745
  const requestEvidenceStatus = input.requestEvidenceStatus ?? (input.requests ? "available" : "unavailable");
11491
11746
  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 })]);
11747
+ const safeIssues = sanitizeIssues([...input.issues ?? [...auditCrawlRequests(requests), ...auditCrawlPages(safePagesWithLinks, { startUrl: config.url })], ...input.additionalIssues ?? []]);
11493
11748
  const pages = safePagesWithLinks;
11494
11749
  return {
11495
11750
  id: input.id ?? crawlRunId(),
@@ -11518,6 +11773,7 @@ function createCrawlReport(input) {
11518
11773
  ...input.ai ? { ai: sanitizeTenantValue(input.ai) } : {},
11519
11774
  ...input.sitemapDiscovery ? { sitemapDiscovery: sanitizeTenantValue(input.sitemapDiscovery) } : {},
11520
11775
  ...input.externalLinkVerification ? { externalLinkVerification: sanitizeTenantValue(input.externalLinkVerification) } : {},
11776
+ ...input.siteChecks ? { siteChecks: sanitizeTenantValue(input.siteChecks) } : {},
11521
11777
  ...input.agentDiscovery ? { agentDiscovery: sanitizeTenantValue(input.agentDiscovery) } : {},
11522
11778
  warnings: sanitizeMessages(input.warnings).sort(compareText$13),
11523
11779
  caveats: sanitizeMessages(input.caveats).sort(compareText$13)
@@ -12087,6 +12343,75 @@ function observationFromPage(requestedUrl, page) {
12087
12343
  };
12088
12344
  }
12089
12345
  //#endregion
12346
+ //#region packages/core/src/analyze/crawler/site-checks.ts
12347
+ const PROBE_COUNT = 2;
12348
+ function probeUrls(startUrl) {
12349
+ const origin = new URL(startUrl).origin;
12350
+ const seed = createHash("sha256").update(origin).digest("hex").slice(0, 12);
12351
+ return Array.from({ length: PROBE_COUNT }, (_, index) => new URL(`/.well-known/seo-audit/not-found-${seed}-${index + 1}`, origin).toString());
12352
+ }
12353
+ function probeObservation(url, result) {
12354
+ const request = result.request;
12355
+ if (!request) return {
12356
+ url,
12357
+ error: result.warning ?? "No response evidence."
12358
+ };
12359
+ if (request.outcome === "response") return {
12360
+ url,
12361
+ status: request.status,
12362
+ finalUrl: request.finalUrl,
12363
+ redirected: request.finalUrl !== url,
12364
+ accessBlocked: Boolean(request.accessBlock)
12365
+ };
12366
+ return {
12367
+ url,
12368
+ error: request.outcome === "failure" ? request.error : `Probe skipped: ${request.reason}`
12369
+ };
12370
+ }
12371
+ async function collectCrawlSiteChecks(input) {
12372
+ const probes = [];
12373
+ for (const url of probeUrls(input.startUrl)) {
12374
+ if (input.signal?.aborted) break;
12375
+ const result = await input.fetchStatusPage(url, {
12376
+ js: "off",
12377
+ refresh: true,
12378
+ timeoutMs: input.timeoutMs,
12379
+ signal: input.signal,
12380
+ writeCache: false,
12381
+ respectRobots: input.respectRobots
12382
+ });
12383
+ probes.push(probeObservation(url, result));
12384
+ }
12385
+ const conclusive = probes.filter((probe) => probe.status !== void 0 && !probe.accessBlocked);
12386
+ const successful = conclusive.filter((probe) => (probe.status ?? 0) >= 200 && (probe.status ?? 0) < 400);
12387
+ const correctMissing = conclusive.filter((probe) => probe.status === 404 || probe.status === 410);
12388
+ const status = conclusive.length !== PROBE_COUNT ? "unknown" : successful.length === PROBE_COUNT ? "warning" : correctMissing.length === PROBE_COUNT ? "pass" : "unknown";
12389
+ const checks = { soft404: {
12390
+ status,
12391
+ probes,
12392
+ probeLimit: PROBE_COUNT,
12393
+ complete: probes.length === PROBE_COUNT
12394
+ } };
12395
+ if (status !== "warning") return {
12396
+ checks,
12397
+ issues: []
12398
+ };
12399
+ const rule = explainRule("soft_404");
12400
+ if (!rule) throw new Error("Missing rule guidance for soft_404");
12401
+ return {
12402
+ checks,
12403
+ issues: [{
12404
+ ruleId: "soft_404",
12405
+ title: rule.title,
12406
+ category: rule.category,
12407
+ severity: rule.defaultSeverity,
12408
+ url: new URL("/", input.startUrl).toString(),
12409
+ detail: `${successful.length} known-nonexistent paths returned a successful response or redirect.`,
12410
+ evidence: checks.soft404
12411
+ }]
12412
+ };
12413
+ }
12414
+ //#endregion
12090
12415
  //#region packages/core/src/analyze/crawler/site-crawl-ai-signals.ts
12091
12416
  const MAX_AUXILIARY_RESPONSE_BYTES = 2 * 1024 * 1024;
12092
12417
  const AI_BOT_USER_AGENTS = [
@@ -12806,6 +13131,9 @@ async function crawlOne(url, opts = {}) {
12806
13131
  mainContentHash: hashText(extracted.contentText),
12807
13132
  textRatio: fetched.html.length ? Math.min(1, extracted.contentText.length / fetched.html.length) : void 0,
12808
13133
  contentSample: truncate$1(extracted.contentText, 300),
13134
+ contentSketch: crawlerEvidence.contentSketch,
13135
+ softAuthenticationGate: crawlerEvidence.softAuthenticationGate,
13136
+ tabbedContent: crawlerEvidence.tabbedContent,
12809
13137
  lang: extracted.lang,
12810
13138
  hasViewport: extracted.hasViewport,
12811
13139
  isHttps: new URL(extracted.finalUrl).protocol === "https:",
@@ -14090,6 +14418,13 @@ async function crawlSite(input, dependencies) {
14090
14418
  signal,
14091
14419
  concurrency: Math.min(config.concurrency, 4)
14092
14420
  }) : void 0;
14421
+ const siteCheckResult = !healthStrategy && !cancelled && !stoppedForMemory ? await collectCrawlSiteChecks({
14422
+ startUrl: config.url,
14423
+ timeoutMs: config.timeoutMs,
14424
+ fetchStatusPage: deps.fetchStatusPage,
14425
+ respectRobots: config.respectRobots,
14426
+ signal
14427
+ }) : void 0;
14093
14428
  for (const page of pages) {
14094
14429
  if (!page.geo || !llmsTxt) continue;
14095
14430
  page.geo = {
@@ -14115,6 +14450,10 @@ async function crawlSite(input, dependencies) {
14115
14450
  } } : {},
14116
14451
  ...sitemapDiscovery ? { sitemapDiscovery } : {},
14117
14452
  ...externalLinkVerification ? { externalLinkVerification } : {},
14453
+ ...siteCheckResult ? {
14454
+ siteChecks: siteCheckResult.checks,
14455
+ additionalIssues: siteCheckResult.issues
14456
+ } : {},
14118
14457
  ...agentDiscovery ? { agentDiscovery } : {},
14119
14458
  status: reportStatus,
14120
14459
  warnings,
@@ -16407,6 +16746,8 @@ const agentRepresentationResponseSchema = z.object({
16407
16746
  status: z.number().int().optional(),
16408
16747
  contentType: z.string().optional(),
16409
16748
  bytes: z.number().int().nonnegative().optional(),
16749
+ characters: z.number().int().nonnegative().optional(),
16750
+ estimatedTokens: z.number().int().nonnegative().optional(),
16410
16751
  sha256: z.string().optional(),
16411
16752
  canonicalUrl: z.string().url().optional(),
16412
16753
  alternateUrl: z.string().url().optional(),
@@ -16464,7 +16805,15 @@ const agentDiscoverySchema = z.object({
16464
16805
  sourceWordCount: z.number().int().nonnegative(),
16465
16806
  wordRetentionRatio: z.number().nonnegative().nullable(),
16466
16807
  introductoryCopyRetained: z.boolean().nullable(),
16467
- navigationOnly: z.boolean()
16808
+ navigationOnly: z.boolean(),
16809
+ contentSketchCoverage: z.number().min(0).max(1).nullable().optional(),
16810
+ tabbedContent: z.object({
16811
+ detectedPanels: z.number().int().nonnegative(),
16812
+ evaluatedPanels: z.number().int().nonnegative(),
16813
+ retainedPanels: z.number().int().nonnegative(),
16814
+ missingPanels: z.number().int().nonnegative(),
16815
+ complete: z.boolean().nullable()
16816
+ }).optional()
16468
16817
  }).optional()
16469
16818
  }))
16470
16819
  }),
@@ -16483,6 +16832,11 @@ const agentDiscoverySchema = z.object({
16483
16832
  missingHtmlRoutes: z.array(z.string()),
16484
16833
  missingMarkdownRoutes: z.array(z.string()),
16485
16834
  orphanMarkdownRoutes: z.array(z.string()),
16835
+ comparisonStatus: z.enum([
16836
+ "complete",
16837
+ "partial",
16838
+ "not-applicable"
16839
+ ]).optional(),
16486
16840
  error: z.string().optional()
16487
16841
  }),
16488
16842
  agentSkills: z.object({
@@ -16588,6 +16942,25 @@ const agentDiscoverySchema = z.object({
16588
16942
  warnings: z.array(z.string())
16589
16943
  });
16590
16944
  //#endregion
16945
+ //#region packages/core/src/analyze/crawler/site-checks-schema.ts
16946
+ const crawlSiteChecksSchema = z.object({ soft404: z.object({
16947
+ status: z.enum([
16948
+ "pass",
16949
+ "warning",
16950
+ "unknown"
16951
+ ]),
16952
+ probes: z.array(z.object({
16953
+ url: z.string().url(),
16954
+ status: z.number().int().optional(),
16955
+ finalUrl: z.string().url().optional(),
16956
+ redirected: z.boolean().optional(),
16957
+ accessBlocked: z.boolean().optional(),
16958
+ error: z.string().optional()
16959
+ })),
16960
+ probeLimit: z.number().int().positive(),
16961
+ complete: z.boolean()
16962
+ }) });
16963
+ //#endregion
16591
16964
  //#region packages/core/src/analyze/crawler/schemas.ts
16592
16965
  const crawlerRuleSeveritySchema = z.enum([
16593
16966
  "low",
@@ -17017,6 +17390,34 @@ const crawlPageSnapshotSchema = z.object({
17017
17390
  mainContentHash: z.string().optional(),
17018
17391
  textRatio: z.number().min(0).max(1).optional(),
17019
17392
  contentSample: z.string().optional(),
17393
+ contentSketch: z.object({
17394
+ version: z.literal(1),
17395
+ wordCount: z.number().int().nonnegative(),
17396
+ shingleSize: z.number().int().positive(),
17397
+ sampledShingles: z.number().int().nonnegative(),
17398
+ hashes: z.array(z.string())
17399
+ }).optional(),
17400
+ softAuthenticationGate: z.object({
17401
+ kind: z.literal("login-form"),
17402
+ indicators: z.array(z.string()),
17403
+ formActionPath: z.string().optional()
17404
+ }).optional(),
17405
+ tabbedContent: z.object({
17406
+ groups: z.number().int().positive(),
17407
+ panels: z.number().int().positive(),
17408
+ retainedPanels: z.number().int().nonnegative(),
17409
+ truncated: z.boolean(),
17410
+ panelSketches: z.array(z.object({
17411
+ label: z.string().optional(),
17412
+ sketch: z.object({
17413
+ version: z.literal(1),
17414
+ wordCount: z.number().int().nonnegative(),
17415
+ shingleSize: z.number().int().positive(),
17416
+ sampledShingles: z.number().int().nonnegative(),
17417
+ hashes: z.array(z.string())
17418
+ })
17419
+ }))
17420
+ }).optional(),
17020
17421
  lang: z.string().optional(),
17021
17422
  hasViewport: z.boolean().optional(),
17022
17423
  isHttps: z.boolean().optional(),
@@ -17508,6 +17909,7 @@ const crawlReportBaseSchema = z.object({
17508
17909
  ai: crawlAiSignalsSchema.optional(),
17509
17910
  sitemapDiscovery: crawlSitemapDiscoverySchema.optional(),
17510
17911
  externalLinkVerification: crawlExternalLinkVerificationSchema.optional(),
17912
+ siteChecks: crawlSiteChecksSchema.optional(),
17511
17913
  agentDiscovery: agentDiscoverySchema.optional(),
17512
17914
  warnings: z.array(z.string()),
17513
17915
  caveats: z.array(z.string())