seo 0.2.7 → 0.2.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/mcp.js CHANGED
@@ -1,5 +1,5 @@
1
1
  import { createRequire as __seoCreateRequire } from 'node:module'; const require = __seoCreateRequire(import.meta.url);
2
- import { n as load, t as version } from "./package-BI-bLeV4.js";
2
+ import { n as load, t as version } from "./package-C51oI-6o.js";
3
3
  import { Headers, fetch as fetch$1 } from "undici";
4
4
  import { chmodSync, existsSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs";
5
5
  import { arch, homedir, platform } from "node:os";
@@ -14,7 +14,6 @@ import { Readability } from "@mozilla/readability";
14
14
  import Defuddle from "defuddle";
15
15
  import { parseHTML } from "linkedom";
16
16
  import pRetry, { AbortError } from "p-retry";
17
- import UserAgent from "user-agents";
18
17
  import PQueue from "p-queue";
19
18
  import robotsParserModule from "robots-parser";
20
19
  import { chromium } from "playwright-core";
@@ -400,10 +399,16 @@ function readOauthClient() {
400
399
  //#region packages/core/dist/storage/lock.js
401
400
  async function withFileLock(path, task) {
402
401
  ensureParentDir(path);
402
+ const fastLocks = process.env.SEO_LOCK_FAST === "1";
403
403
  const release = await lockfile.lock(path, {
404
- stale: 6e4,
405
- update: 5e3,
406
- retries: {
404
+ stale: fastLocks ? 2e3 : 6e4,
405
+ update: fastLocks ? 1e3 : 5e3,
406
+ retries: fastLocks ? {
407
+ retries: 5,
408
+ factor: 1.2,
409
+ minTimeout: 50,
410
+ maxTimeout: 200
411
+ } : {
407
412
  retries: 60,
408
413
  factor: 1.2,
409
414
  minTimeout: 200,
@@ -763,6 +768,164 @@ async function createGoogleAccessTokenClient() {
763
768
  };
764
769
  }
765
770
  //#endregion
771
+ //#region packages/core/dist/storage/cache-maintenance.js
772
+ const MEBIBYTE$1 = 1024 * 1024;
773
+ const CACHE_MAX_AGE_MS = 720 * 60 * 60 * 1e3;
774
+ const CACHE_LIMITS = {
775
+ gsc_cache: 48 * MEBIBYTE$1,
776
+ ga4_cache: 24 * MEBIBYTE$1,
777
+ semrush_cache: 16 * MEBIBYTE$1,
778
+ http_cache: 160 * MEBIBYTE$1,
779
+ performance_reports: 8 * MEBIBYTE$1
780
+ };
781
+ const CACHE_POLICIES = [
782
+ {
783
+ table: "gsc_cache",
784
+ keys: ["site_url", "query_hash"],
785
+ timestamp: "fetched_at",
786
+ sizeColumns: [
787
+ "site_url",
788
+ "query_hash",
789
+ "request_json",
790
+ "response_json"
791
+ ]
792
+ },
793
+ {
794
+ table: "ga4_cache",
795
+ keys: ["property_id", "query_hash"],
796
+ timestamp: "fetched_at",
797
+ sizeColumns: [
798
+ "property_id",
799
+ "query_hash",
800
+ "request_json",
801
+ "response_json"
802
+ ]
803
+ },
804
+ {
805
+ table: "semrush_cache",
806
+ keys: ["endpoint", "query_hash"],
807
+ timestamp: "fetched_at",
808
+ sizeColumns: [
809
+ "endpoint",
810
+ "query_hash",
811
+ "request_json",
812
+ "response_json"
813
+ ]
814
+ },
815
+ {
816
+ table: "http_cache",
817
+ keys: ["url_hash"],
818
+ timestamp: "fetched_at",
819
+ sizeColumns: [
820
+ "url_hash",
821
+ "url",
822
+ "headers_json",
823
+ "body_blob",
824
+ "metadata_json",
825
+ "etag"
826
+ ]
827
+ },
828
+ {
829
+ table: "performance_reports",
830
+ keys: ["id"],
831
+ timestamp: "created_at",
832
+ sizeColumns: [
833
+ "id",
834
+ "url",
835
+ "strategy",
836
+ "report_json"
837
+ ]
838
+ }
839
+ ];
840
+ function sizeExpression(columns) {
841
+ return `${columns.map((column) => `COALESCE(LENGTH(CAST(${column} AS BLOB)), 0)`).join(" + ")} + 256`;
842
+ }
843
+ function policySize(database, policy) {
844
+ const row = database.prepare(`SELECT COALESCE(SUM(${sizeExpression(policy.sizeColumns)}), 0) AS size_bytes
845
+ FROM ${policy.table}`).get();
846
+ return Number(row?.size_bytes ?? 0);
847
+ }
848
+ function cacheLogicalSizes(database) {
849
+ return Object.fromEntries(CACHE_POLICIES.map((policy) => [policy.table, policySize(database, policy)]));
850
+ }
851
+ function deleteExpired(database, policy, now) {
852
+ const cutoff = now - CACHE_MAX_AGE_MS;
853
+ return database.prepare(`DELETE FROM ${policy.table}
854
+ WHERE (expires_at IS NOT NULL AND expires_at <= ?)
855
+ OR (${policy.timestamp} IS NOT NULL AND ${policy.timestamp} < ?)`).run(now, cutoff).changes;
856
+ }
857
+ function pruneToLimit(database, policy, limit) {
858
+ if (policySize(database, policy) <= limit) return 0;
859
+ const order = [`${policy.timestamp} DESC`, ...policy.keys.map((key) => `${key} ASC`)].join(", ");
860
+ const rows = database.prepare(`SELECT ${policy.keys.join(", ")},
861
+ ${policy.timestamp} AS fetched_at,
862
+ ${sizeExpression(policy.sizeColumns)} AS size_bytes
863
+ FROM ${policy.table}
864
+ ORDER BY ${order}`).all();
865
+ const remove = [];
866
+ let retainedBytes = 0;
867
+ for (const row of rows) {
868
+ const rowBytes = Number(row.size_bytes);
869
+ if (retainedBytes + rowBytes <= limit) retainedBytes += rowBytes;
870
+ else remove.push(row);
871
+ }
872
+ if (!remove.length) return 0;
873
+ const statement = database.prepare(`DELETE FROM ${policy.table} WHERE ${policy.keys.map((key) => `${key} = ?`).join(" AND ")}`);
874
+ return database.transaction(() => {
875
+ let removed = 0;
876
+ for (const row of remove) removed += statement.run(...policy.keys.map((key) => row[key])).changes;
877
+ return removed;
878
+ }).immediate();
879
+ }
880
+ function pragmaNumber(database, pragma, field) {
881
+ const result = database.pragma(pragma);
882
+ const row = Array.isArray(result) ? result[0] : result;
883
+ if (!row || typeof row !== "object") return 0;
884
+ return Number(row[field] ?? 0);
885
+ }
886
+ function compactCacheDatabase(database, options = {}) {
887
+ const autoVacuum = pragmaNumber(database, "auto_vacuum", "auto_vacuum");
888
+ try {
889
+ if (autoVacuum === 2) {
890
+ database.pragma("incremental_vacuum");
891
+ database.pragma("wal_checkpoint(PASSIVE)");
892
+ return "incremental";
893
+ }
894
+ if (!options.allowFullVacuum) return "skipped";
895
+ database.pragma("busy_timeout = 100");
896
+ database.pragma("auto_vacuum = INCREMENTAL");
897
+ database.exec("VACUUM");
898
+ database.pragma("wal_checkpoint(PASSIVE)");
899
+ return "full";
900
+ } catch {
901
+ return "skipped";
902
+ } finally {
903
+ database.pragma("busy_timeout = 5000");
904
+ }
905
+ }
906
+ function runCacheMaintenance(database, options = {}) {
907
+ const now = options.now ?? Date.now();
908
+ const limits = {
909
+ ...CACHE_LIMITS,
910
+ ...options.limits
911
+ };
912
+ const removedByTable = Object.fromEntries(CACHE_POLICIES.map((policy) => [policy.table, 0]));
913
+ database.transaction(() => {
914
+ for (const policy of CACHE_POLICIES) removedByTable[policy.table] += deleteExpired(database, policy, now);
915
+ }).immediate();
916
+ for (const policy of CACHE_POLICIES) removedByTable[policy.table] += pruneToLimit(database, policy, limits[policy.table]);
917
+ const removed = Object.values(removedByTable).reduce((total, count) => total + count, 0);
918
+ const compaction = options.compact ? compactCacheDatabase(database, { allowFullVacuum: options.allowFullVacuum }) : "skipped";
919
+ const sizes = cacheLogicalSizes(database);
920
+ return {
921
+ removed,
922
+ removedByTable,
923
+ logicalSizeBytes: Object.values(sizes).reduce((total, size) => total + size, 0),
924
+ sizes,
925
+ compaction
926
+ };
927
+ }
928
+ //#endregion
766
929
  //#region packages/core/dist/storage/sqlite.js
767
930
  function withoutDriverMetadata(row) {
768
931
  if (!row || typeof row !== "object" || Array.isArray(row)) return row;
@@ -853,6 +1016,7 @@ CREATE TABLE IF NOT EXISTS semrush_cache (
853
1016
  expires_at INTEGER,
854
1017
  PRIMARY KEY(endpoint, query_hash)
855
1018
  ) WITHOUT ROWID;
1019
+ CREATE INDEX IF NOT EXISTS idx_semrush_expires ON semrush_cache(expires_at);
856
1020
 
857
1021
  CREATE TABLE IF NOT EXISTS http_cache (
858
1022
  url_hash TEXT PRIMARY KEY,
@@ -865,6 +1029,8 @@ CREATE TABLE IF NOT EXISTS http_cache (
865
1029
  fetched_at INTEGER,
866
1030
  expires_at INTEGER
867
1031
  );
1032
+ CREATE INDEX IF NOT EXISTS idx_http_expires ON http_cache(expires_at);
1033
+ CREATE INDEX IF NOT EXISTS idx_http_fetched ON http_cache(fetched_at);
868
1034
 
869
1035
  CREATE TABLE IF NOT EXISTS content_groups (
870
1036
  id TEXT PRIMARY KEY,
@@ -908,6 +1074,7 @@ CREATE TABLE IF NOT EXISTS crawl_reports (
908
1074
  total_pages INTEGER NOT NULL,
909
1075
  issue_count INTEGER NOT NULL,
910
1076
  created_at INTEGER NOT NULL,
1077
+ retention_class TEXT NOT NULL DEFAULT 'saved',
911
1078
  report_json TEXT NOT NULL
912
1079
  );
913
1080
  CREATE INDEX IF NOT EXISTS idx_crawl_reports_latest ON crawl_reports(created_at DESC);
@@ -1051,23 +1218,28 @@ CREATE TABLE IF NOT EXISTS performance_reports (
1051
1218
  expires_at INTEGER NOT NULL
1052
1219
  );
1053
1220
  CREATE INDEX IF NOT EXISTS idx_performance_reports_url ON performance_reports(url, strategy, created_at);
1221
+ CREATE INDEX IF NOT EXISTS idx_performance_reports_expires ON performance_reports(expires_at);
1054
1222
  `;
1055
1223
  let db;
1224
+ let cacheWritesSinceMaintenance = 0;
1225
+ let cacheBytesSinceMaintenance = 0;
1056
1226
  function ensureColumn(database, table, column, definition) {
1057
1227
  if (database.prepare(`PRAGMA table_info(${table})`).all().some((item) => item.name === column)) return;
1058
1228
  database.exec(`ALTER TABLE ${table} ADD COLUMN ${column} ${definition}`);
1059
1229
  }
1060
- function initDb(database) {
1230
+ function initDb(database, isNewDatabase) {
1061
1231
  database.pragma("journal_mode = WAL");
1062
1232
  database.pragma("synchronous = NORMAL");
1063
1233
  database.pragma("foreign_keys = ON");
1064
1234
  database.pragma("temp_store = MEMORY");
1065
1235
  database.pragma("mmap_size = 268435456");
1066
1236
  database.pragma("busy_timeout = 5000");
1237
+ if (isNewDatabase) database.pragma("auto_vacuum = INCREMENTAL");
1067
1238
  database.transaction(() => {
1068
1239
  database.exec(CREATE_SQL);
1069
1240
  ensureColumn(database, "crawl_pages", "snapshot_json", "TEXT");
1070
1241
  ensureColumn(database, "http_cache", "metadata_json", "TEXT");
1242
+ ensureColumn(database, "crawl_reports", "retention_class", "TEXT NOT NULL DEFAULT 'saved'");
1071
1243
  ensureColumn(database, "index_watch_snapshots", "root_site_url", "TEXT");
1072
1244
  ensureColumn(database, "index_watch_snapshots", "property_site_url", "TEXT");
1073
1245
  ensureColumn(database, "index_watch_snapshots", "error_code", "TEXT");
@@ -1082,20 +1254,35 @@ function initDb(database) {
1082
1254
  ON index_watch_snapshots(root_site_url, url, inspected_at DESC, id DESC);
1083
1255
  CREATE INDEX IF NOT EXISTS idx_index_watch_property_url
1084
1256
  ON index_watch_snapshots(property_site_url, url, inspected_at DESC, id DESC);
1257
+ CREATE INDEX IF NOT EXISTS idx_crawl_reports_retention
1258
+ ON crawl_reports(retention_class, site_url, created_at DESC, id DESC);
1085
1259
  `);
1086
1260
  }).immediate();
1261
+ runCacheMaintenance(database, {
1262
+ compact: true,
1263
+ allowFullVacuum: true
1264
+ });
1087
1265
  }
1088
1266
  function getDb() {
1089
1267
  if (db) return db;
1090
1268
  const path = getSeoCliPaths().cacheDbFile;
1269
+ const isNewDatabase = !existsSync(path);
1091
1270
  mkdirSync(dirname(path), {
1092
1271
  recursive: true,
1093
1272
  mode: 448
1094
1273
  });
1095
1274
  db = new SqliteDatabase(path);
1096
- initDb(db);
1275
+ initDb(db, isNewDatabase);
1097
1276
  return db;
1098
1277
  }
1278
+ function noteCacheWrite(sizeBytes = 0) {
1279
+ cacheWritesSinceMaintenance += 1;
1280
+ cacheBytesSinceMaintenance += Math.max(0, sizeBytes);
1281
+ if (cacheWritesSinceMaintenance < 50 && cacheBytesSinceMaintenance < 16777216) return;
1282
+ runCacheMaintenance(getDb());
1283
+ cacheWritesSinceMaintenance = 0;
1284
+ cacheBytesSinceMaintenance = 0;
1285
+ }
1099
1286
  function checkDatabaseReadiness() {
1100
1287
  if (getDb().prepare("SELECT 1 AS ready").get()?.ready !== 1) throw new Error("The local SQLite database did not return a readiness row.");
1101
1288
  return { dbPath: getSeoCliPaths().cacheDbFile };
@@ -1138,9 +1325,14 @@ async function runGa4Report(propertyId, body, opts = {}) {
1138
1325
  throw new Error(`Google Analytics report failed with ${response.status}.`);
1139
1326
  }
1140
1327
  const result = await response.json();
1141
- if (cacheable) db.prepare(`INSERT OR REPLACE INTO ga4_cache
1328
+ if (cacheable) {
1329
+ const requestJson = JSON.stringify(body);
1330
+ const responseJson = JSON.stringify(result);
1331
+ db.prepare(`INSERT OR REPLACE INTO ga4_cache
1142
1332
  (property_id, query_hash, request_json, response_json, row_count, fetched_at, expires_at)
1143
- VALUES (?, ?, ?, ?, ?, ?, ?)`).run(propertyId, queryHash, JSON.stringify(body), JSON.stringify(result), result.rowCount ?? result.rows?.length ?? 0, Date.now(), Date.now() + 864e5);
1333
+ VALUES (?, ?, ?, ?, ?, ?, ?)`).run(propertyId, queryHash, requestJson, responseJson, result.rowCount ?? result.rows?.length ?? 0, Date.now(), Date.now() + 864e5);
1334
+ noteCacheWrite(Buffer.byteLength(requestJson) + Buffer.byteLength(responseJson));
1335
+ }
1144
1336
  return result;
1145
1337
  }
1146
1338
  function ga4RowsToObjects(result) {
@@ -1798,7 +1990,7 @@ function effectiveSnippetControl(input) {
1798
1990
  }
1799
1991
  //#endregion
1800
1992
  //#region packages/core/dist/extract/canonical.js
1801
- function headerValue$4(headers, name) {
1993
+ function headerValue$5(headers, name) {
1802
1994
  const lower = name.toLowerCase();
1803
1995
  return Object.entries(headers).find(([key]) => key.toLowerCase() === lower)?.[1];
1804
1996
  }
@@ -1882,7 +2074,7 @@ function parameter(value, name) {
1882
2074
  return match?.[1] ?? match?.[2] ?? match?.[3];
1883
2075
  }
1884
2076
  function headerCandidates(headers, baseUrl) {
1885
- const link = headerValue$4(headers, "link");
2077
+ const link = headerValue$5(headers, "link");
1886
2078
  if (!link) return [];
1887
2079
  const candidates = [];
1888
2080
  for (const item of splitLinkHeader(link)) {
@@ -1899,14 +2091,14 @@ function headerCandidates(headers, baseUrl) {
1899
2091
  }
1900
2092
  return candidates;
1901
2093
  }
1902
- function normalizedUrl$2(value) {
2094
+ function normalizedUrl$1(value) {
1903
2095
  const url = new URL(value);
1904
2096
  url.hash = "";
1905
2097
  return url.toString();
1906
2098
  }
1907
2099
  function evidenceFromCandidates(candidates) {
1908
2100
  const eligible = candidates.filter((candidate) => candidate.resolved && !candidate.ignoredReason);
1909
- const distinct = [...new Set(eligible.map((candidate) => normalizedUrl$2(candidate.resolved ?? "")))];
2101
+ const distinct = [...new Set(eligible.map((candidate) => normalizedUrl$1(candidate.resolved ?? "")))];
1910
2102
  if (distinct.length > 1) return {
1911
2103
  status: "conflicting",
1912
2104
  candidates
@@ -2551,7 +2743,7 @@ function safeText$1(value) {
2551
2743
  const trimmed = value?.replace(/\s+/g, " ").trim();
2552
2744
  return trimmed ? trimmed : void 0;
2553
2745
  }
2554
- function headerValue$3(headers, name) {
2746
+ function headerValue$4(headers, name) {
2555
2747
  const target = name.toLowerCase();
2556
2748
  return Object.entries(headers).find(([key]) => key.toLowerCase() === target)?.[1];
2557
2749
  }
@@ -2569,8 +2761,7 @@ function httpUrl(value, base) {
2569
2761
  const protocol = new URL(resolved).protocol;
2570
2762
  return protocol === "http:" || protocol === "https:" ? resolved : void 0;
2571
2763
  }
2572
- function sanitizedContentHtml(html, base) {
2573
- const $ = load(html);
2764
+ function sanitizedContentHtml($, html, base) {
2574
2765
  let changed = false;
2575
2766
  for (const element of $("[href], [src]").toArray()) for (const attribute of ["href", "src"]) {
2576
2767
  const value = $(element).attr(attribute);
@@ -2581,6 +2772,22 @@ function sanitizedContentHtml(html, base) {
2581
2772
  }
2582
2773
  return changed ? $.html() : html;
2583
2774
  }
2775
+ function crawlerMainContent($, baseUrl) {
2776
+ const text = ($("main").first().length ? $("main").first() : $("article").first().length ? $("article").first() : $("body").first()).text().replace(/\s+/g, " ").trim();
2777
+ return {
2778
+ text,
2779
+ excerpt: safeText$1($("meta[name=\"description\"]").attr("content")),
2780
+ wordCount: countCjkAwareWords(text),
2781
+ diagnostics: {
2782
+ requested: "crawler",
2783
+ used: "crawler",
2784
+ fallback: false,
2785
+ wordCountSource: "local_cjk_aware",
2786
+ baseUrl
2787
+ },
2788
+ warnings: []
2789
+ };
2790
+ }
2584
2791
  function numericAttribute(value) {
2585
2792
  if (!value) return void 0;
2586
2793
  const match = value.trim().match(/^\d+/);
@@ -2605,12 +2812,10 @@ function imageDimensionsFromUrl(src) {
2605
2812
  };
2606
2813
  }
2607
2814
  async function extractPage(fetchResult, extractor = "defuddle", dependencies = {}) {
2608
- const $ = load(fetchResult.html);
2609
- const content = extractMainContent({
2610
- ...fetchResult,
2611
- html: sanitizedContentHtml(fetchResult.html, fetchResult.finalUrl)
2612
- }, extractor, dependencies);
2613
- const { text, excerpt } = content;
2815
+ const $ = extractor === "crawler" && /<html(?:\s|>)/i.test(fetchResult.html) ? load(fetchResult.html, { xml: {
2816
+ xmlMode: false,
2817
+ decodeEntities: true
2818
+ } }) : load(fetchResult.html);
2614
2819
  const url = new URL(fetchResult.finalUrl);
2615
2820
  const canonicalEvidence = extractCanonicalEvidence($, fetchResult.headers, fetchResult.finalUrl);
2616
2821
  const headings = $("h1, h2, h3, h4, h5, h6").toArray().map((element) => ({
@@ -2692,13 +2897,18 @@ async function extractPage(fetchResult, extractor = "defuddle", dependencies = {
2692
2897
  const name = $(element).attr("name")?.trim().toLowerCase();
2693
2898
  return name === "robots" || name === "googlebot";
2694
2899
  }).map((element) => $(element).attr("content")));
2900
+ const content = extractor === "crawler" ? crawlerMainContent($, fetchResult.finalUrl) : extractMainContent({
2901
+ ...fetchResult,
2902
+ html: sanitizedContentHtml($, fetchResult.html, fetchResult.finalUrl)
2903
+ }, extractor, dependencies);
2904
+ const { text, excerpt } = content;
2695
2905
  return {
2696
2906
  url: fetchResult.url,
2697
2907
  finalUrl: fetchResult.finalUrl,
2698
2908
  title: safeText$1($("title").first().text()),
2699
2909
  metaDescription: safeText$1($("meta[name=\"description\"]").attr("content")),
2700
2910
  metaRobots,
2701
- xRobotsTag: safeText$1(headerValue$3(fetchResult.headers, "x-robots-tag")),
2911
+ xRobotsTag: safeText$1(headerValue$4(fetchResult.headers, "x-robots-tag")),
2702
2912
  canonical: canonicalEvidence.selectedRaw,
2703
2913
  canonicalEvidence,
2704
2914
  lang: safeText$1($("html").attr("lang")),
@@ -2749,14 +2959,106 @@ async function extractPage(fetchResult, extractor = "defuddle", dependencies = {
2749
2959
  };
2750
2960
  }
2751
2961
  //#endregion
2962
+ //#region packages/core/dist/version.js
2963
+ const SEO_VERSION = version;
2964
+ //#endregion
2965
+ //#region packages/core/dist/fetch/crawler-identity.js
2966
+ const SEO_CRAWLER_TOKEN = "SEO-Skill";
2967
+ const SEO_CRAWLER_USER_AGENT = `${SEO_CRAWLER_TOKEN}/${SEO_VERSION} (+https://seoskill.dev)`;
2968
+ const SEO_CRAWLER_IDENTITY = {
2969
+ name: "SEO Skill",
2970
+ robotsToken: SEO_CRAWLER_TOKEN,
2971
+ version: SEO_VERSION,
2972
+ userAgent: SEO_CRAWLER_USER_AGENT,
2973
+ documentationUrl: "https://seoskill.dev/docs/crawler/"
2974
+ };
2975
+ //#endregion
2976
+ //#region packages/core/dist/fetch/access-block.js
2977
+ const CLOUDFLARE_SKIP_DOCS = "https://developers.cloudflare.com/waf/custom-rules/skip/";
2978
+ function isHeadersLike(headers) {
2979
+ return "get" in headers && typeof headers.get === "function";
2980
+ }
2981
+ function headerValue$3(headers, name) {
2982
+ if (isHeadersLike(headers)) return headers.get(name) ?? void 0;
2983
+ const lower = name.toLowerCase();
2984
+ return headers[name] ?? headers[lower] ?? Object.entries(headers).find(([key]) => key.toLowerCase() === lower)?.[1];
2985
+ }
2986
+ function cloudflareEvidence(headers) {
2987
+ const mitigated = headerValue$3(headers, "cf-mitigated");
2988
+ const rayId = headerValue$3(headers, "cf-ray");
2989
+ const server = headerValue$3(headers, "server");
2990
+ const challenge = mitigated?.toLowerCase() === "challenge";
2991
+ const indicators = [
2992
+ ...challenge ? ["cf-mitigated: challenge"] : [],
2993
+ ...rayId ? [`cf-ray: ${rayId}`] : [],
2994
+ ...server?.toLowerCase().includes("cloudflare") ? [`server: ${server}`] : []
2995
+ ];
2996
+ return {
2997
+ detected: challenge || Boolean(rayId) || Boolean(indicators.length),
2998
+ challenge,
2999
+ rayId,
3000
+ indicators
3001
+ };
3002
+ }
3003
+ function blockKind(status, cloudflareChallenge) {
3004
+ if (cloudflareChallenge) return "challenge";
3005
+ if (status === 429) return "rate-limit";
3006
+ if (status === 401) return "authentication";
3007
+ return "firewall";
3008
+ }
3009
+ function detectAccessBlock(input) {
3010
+ const cloudflare = cloudflareEvidence(input.headers);
3011
+ if (![
3012
+ 401,
3013
+ 403,
3014
+ 429
3015
+ ].includes(input.status) && !cloudflare.challenge) return void 0;
3016
+ const provider = cloudflare.detected ? "cloudflare" : "unknown";
3017
+ const kind = blockKind(input.status, cloudflare.challenge);
3018
+ const indicators = [`HTTP ${input.status}`, ...cloudflare.indicators];
3019
+ if (provider === "cloudflare") return {
3020
+ provider,
3021
+ kind,
3022
+ status: input.status,
3023
+ crawler: SEO_CRAWLER_IDENTITY,
3024
+ indicators,
3025
+ ...cloudflare.rayId ? { requestId: cloudflare.rayId } : {},
3026
+ guidance: {
3027
+ summary: kind === "challenge" ? "Cloudflare returned a Challenge Page to the crawler." : "The crawler received a denied or rate-limited response through Cloudflare.",
3028
+ recommendedAction: "Look for this request in Cloudflare Security Events. If Cloudflare blocked it, create the narrowest custom Skip rule that matches the audit machine source IP, hostname or required paths, and this exact User-Agent. Skip only the product or rule that caused the block. If there is no matching event, inspect the origin security logs.",
3029
+ securityNote: "A User-Agent can be spoofed. Do not trust it by itself or create a broad site-wide bypass. Remove temporary access after the audit. Bot Fight Mode cannot be skipped by a custom rule.",
3030
+ documentationUrl: CLOUDFLARE_SKIP_DOCS
3031
+ }
3032
+ };
3033
+ return {
3034
+ provider,
3035
+ kind,
3036
+ status: input.status,
3037
+ crawler: SEO_CRAWLER_IDENTITY,
3038
+ indicators,
3039
+ guidance: {
3040
+ summary: kind === "rate-limit" ? "The site rate-limited the crawler request." : "The site denied the crawler request.",
3041
+ recommendedAction: "Inspect the site, CDN, and origin security logs for this request. If the audit should have access, allow the audit machine source IP and this exact User-Agent only on the public paths being checked, or lower the crawl rate.",
3042
+ securityNote: "A User-Agent can be spoofed. Do not trust it by itself or create a broad site-wide bypass. Remove temporary access after the audit."
3043
+ }
3044
+ };
3045
+ }
3046
+ //#endregion
2752
3047
  //#region packages/core/dist/fetch/http-client.js
2753
- const BROWSER_USER_AGENT = new UserAgent({ deviceCategory: "desktop" }).toString();
3048
+ var ResponseSizeLimitError = class extends Error {
3049
+ maxBytes;
3050
+ constructor(maxBytes, label = "Response") {
3051
+ super(`${label} exceeds the ${maxBytes}-byte response limit.`);
3052
+ this.maxBytes = maxBytes;
3053
+ this.name = "ResponseSizeLimitError";
3054
+ }
3055
+ };
2754
3056
  function requestHeaders(profile) {
2755
3057
  if (profile === "bot") return new Headers({
2756
3058
  accept: "text/plain,*/*;q=0.8",
2757
3059
  "accept-language": "en-US,en;q=0.9",
2758
3060
  connection: "close",
2759
- "user-agent": BROWSER_USER_AGENT
3061
+ "user-agent": SEO_CRAWLER_USER_AGENT
2760
3062
  });
2761
3063
  return new Headers({
2762
3064
  accept: "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
@@ -2765,7 +3067,7 @@ function requestHeaders(profile) {
2765
3067
  "sec-ch-ua-mobile": "?0",
2766
3068
  "sec-ch-ua-platform": "\"macOS\"",
2767
3069
  "upgrade-insecure-requests": "1",
2768
- "user-agent": BROWSER_USER_AGENT
3070
+ "user-agent": SEO_CRAWLER_USER_AGENT
2769
3071
  });
2770
3072
  }
2771
3073
  function publicHttpFetch(url, input = {}) {
@@ -2781,6 +3083,32 @@ function publicHttpFetch(url, input = {}) {
2781
3083
  headers: base
2782
3084
  });
2783
3085
  }
3086
+ async function readBoundedResponseText(response, maxBytes, label = "Response") {
3087
+ const declaredLength = Number(response.headers.get("content-length"));
3088
+ if (Number.isFinite(declaredLength) && declaredLength > maxBytes) {
3089
+ await response.body?.cancel().catch(() => void 0);
3090
+ throw new ResponseSizeLimitError(maxBytes, label);
3091
+ }
3092
+ const reader = response.body?.getReader();
3093
+ if (!reader) return "";
3094
+ const chunks = [];
3095
+ let total = 0;
3096
+ try {
3097
+ while (true) {
3098
+ const { done, value } = await reader.read();
3099
+ if (done) break;
3100
+ total += value.byteLength;
3101
+ if (total > maxBytes) {
3102
+ await reader.cancel().catch(() => void 0);
3103
+ throw new ResponseSizeLimitError(maxBytes, label);
3104
+ }
3105
+ chunks.push(value);
3106
+ }
3107
+ } finally {
3108
+ reader.releaseLock();
3109
+ }
3110
+ return Buffer.concat(chunks, total).toString("utf8");
3111
+ }
2784
3112
  //#endregion
2785
3113
  //#region packages/core/dist/fetch/page-fetcher/rate-controls.js
2786
3114
  const HOST_QUEUES = /* @__PURE__ */ new Map();
@@ -2837,8 +3165,10 @@ function snapshotForHost(host, status) {
2837
3165
  };
2838
3166
  }
2839
3167
  function normalizeRateControls(rate) {
3168
+ const concurrency = rate?.concurrency ?? numberEnv("SEO_FETCH_CONCURRENCY", 4);
3169
+ if (!Number.isInteger(concurrency) || concurrency < 1 || concurrency > 16) throw new RangeError(`Fetch concurrency must be an integer from 1 to 16.`);
2840
3170
  return {
2841
- concurrency: rate?.concurrency ?? numberEnv("SEO_FETCH_CONCURRENCY", 4),
3171
+ concurrency,
2842
3172
  intervalCap: rate?.intervalCap ?? numberEnv("SEO_FETCH_INTERVAL_CAP", 4),
2843
3173
  intervalMs: rate?.intervalMs ?? numberEnv("SEO_FETCH_INTERVAL_MS", 1e3),
2844
3174
  backpressure: {
@@ -2926,7 +3256,34 @@ function recordHostFetch(input) {
2926
3256
  return snapshotForHost(input.host);
2927
3257
  }
2928
3258
  //#endregion
3259
+ //#region packages/core/dist/fetch/page-fetcher/redirects.js
3260
+ async function fetchWithRedirectChain(url, signal) {
3261
+ const redirectChain = [];
3262
+ let currentUrl = url;
3263
+ for (let redirectCount = 0; redirectCount <= 10; redirectCount += 1) {
3264
+ const response = await publicHttpFetch(currentUrl, {
3265
+ redirect: "manual",
3266
+ signal
3267
+ });
3268
+ const location = response.headers.get("location");
3269
+ if (response.status < 300 || response.status >= 400 || !location) return {
3270
+ response,
3271
+ redirectChain
3272
+ };
3273
+ const nextUrl = new URL(location, currentUrl).toString();
3274
+ redirectChain.push({
3275
+ url: currentUrl,
3276
+ status: response.status,
3277
+ location: nextUrl
3278
+ });
3279
+ await response.body?.cancel().catch(() => void 0);
3280
+ currentUrl = nextUrl;
3281
+ }
3282
+ throw new Error(`Too many redirects for ${url}`);
3283
+ }
3284
+ //#endregion
2929
3285
  //#region packages/core/dist/fetch/page-fetcher/robots.js
3286
+ const MAX_ROBOTS_RESPONSE_BYTES = 1024 * 1024;
2930
3287
  var RobotsAccessError = class extends Error {
2931
3288
  reason;
2932
3289
  evidence;
@@ -2946,10 +3303,10 @@ function cacheableStatus(status) {
2946
3303
  function resultFromResponse(input) {
2947
3304
  if (input.status >= 200 && input.status < 300) {
2948
3305
  const parsed = parseRobots$1(input.robotsUrl, input.text);
2949
- const lineNumber = parsed.getMatchingLineNumber?.(input.targetUrl, BROWSER_USER_AGENT);
3306
+ const lineNumber = parsed.getMatchingLineNumber?.(input.targetUrl, SEO_CRAWLER_USER_AGENT);
2950
3307
  const matchedLine = lineNumber ? input.text.split(/\r?\n/)[lineNumber - 1]?.trim() : void 0;
2951
3308
  return {
2952
- allowed: parsed.isAllowed(input.targetUrl, BROWSER_USER_AGENT) ?? true,
3309
+ allowed: parsed.isAllowed(input.targetUrl, SEO_CRAWLER_USER_AGENT) ?? true,
2953
3310
  availability: "available",
2954
3311
  status: input.status,
2955
3312
  cache: input.cache,
@@ -2973,44 +3330,105 @@ function resultFromResponse(input) {
2973
3330
  url: input.robotsUrl
2974
3331
  };
2975
3332
  }
2976
- async function fetchRobots(origin, targetUrl = origin, refresh = false) {
2977
- const db = getDb();
2978
- const robotsUrl = new URL("/robots.txt", origin).toString();
2979
- const key = hashKey(["robots", robotsUrl]);
2980
- const cached = db.prepare("SELECT status, body_blob, expires_at FROM http_cache WHERE url_hash = ?").get(key);
2981
- if (!refresh && cached?.body_blob && cached.status !== void 0 && cacheableStatus(cached.status) && cached.expires_at && cached.expires_at > Date.now()) return resultFromResponse({
2982
- robotsUrl,
3333
+ function resultFromSource(source, targetUrl) {
3334
+ if (source.status !== void 0 && source.text !== void 0) return resultFromResponse({
3335
+ robotsUrl: source.robotsUrl,
2983
3336
  targetUrl,
2984
- status: cached.status,
2985
- text: cached.body_blob.toString("utf8"),
2986
- cache: "hit"
3337
+ status: source.status,
3338
+ text: source.text,
3339
+ cache: source.cache
2987
3340
  });
3341
+ return {
3342
+ allowed: null,
3343
+ availability: "unreachable",
3344
+ error: source.error ?? "robots.txt could not be fetched.",
3345
+ cache: source.cache,
3346
+ url: source.robotsUrl
3347
+ };
3348
+ }
3349
+ async function fetchRobotsSource(origin, refresh, options) {
3350
+ const robotsUrl = new URL("/robots.txt", origin).toString();
3351
+ const key = hashKey(["robots", robotsUrl]);
3352
+ if (!refresh) {
3353
+ const cached = getDb().prepare("SELECT status, body_blob, expires_at FROM http_cache WHERE url_hash = ?").get(key);
3354
+ if (cached?.body_blob && cached.status !== void 0 && cacheableStatus(cached.status) && cached.expires_at && cached.expires_at > Date.now()) return {
3355
+ robotsUrl,
3356
+ status: cached.status,
3357
+ text: cached.body_blob.toString("utf8"),
3358
+ cache: "hit"
3359
+ };
3360
+ }
3361
+ const controller = new AbortController();
3362
+ const abort = () => controller.abort();
3363
+ const timer = setTimeout(abort, options.timeoutMs ?? 2e4);
3364
+ if (options.signal?.aborted) controller.abort();
3365
+ else options.signal?.addEventListener("abort", abort, { once: true });
2988
3366
  try {
2989
- const response = await publicHttpFetch(robotsUrl, { profile: "bot" });
2990
- const text = await response.text();
2991
- const result = resultFromResponse({
3367
+ const response = await publicHttpFetch(robotsUrl, {
3368
+ profile: "bot",
3369
+ signal: controller.signal
3370
+ });
3371
+ const text = await readBoundedResponseText(response, MAX_ROBOTS_RESPONSE_BYTES, "robots.txt response");
3372
+ if (options.writeCache !== false && cacheableStatus(response.status)) {
3373
+ getDb().prepare(`INSERT OR REPLACE INTO http_cache
3374
+ (url_hash, url, status, headers_json, body_blob, etag, fetched_at, expires_at)
3375
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?)`).run(key, robotsUrl, response.status, "{}", Buffer.from(text), null, Date.now(), Date.now() + 864e5);
3376
+ noteCacheWrite(Buffer.byteLength(text));
3377
+ }
3378
+ return {
2992
3379
  robotsUrl,
2993
- targetUrl,
2994
3380
  status: response.status,
2995
3381
  text,
2996
3382
  cache: refresh ? "bypass" : "miss"
2997
- });
2998
- if (cacheableStatus(response.status)) db.prepare(`INSERT OR REPLACE INTO http_cache
2999
- (url_hash, url, status, headers_json, body_blob, etag, fetched_at, expires_at)
3000
- VALUES (?, ?, ?, ?, ?, ?, ?, ?)`).run(key, robotsUrl, response.status, "{}", Buffer.from(text), null, Date.now(), Date.now() + 864e5);
3001
- return result;
3383
+ };
3002
3384
  } catch (error) {
3003
3385
  return {
3004
- allowed: null,
3005
- availability: "unreachable",
3386
+ robotsUrl,
3006
3387
  error: error instanceof Error ? error.message : String(error),
3007
- cache: refresh ? "bypass" : "miss",
3008
- url: robotsUrl
3388
+ cache: refresh ? "bypass" : "miss"
3009
3389
  };
3010
- }
3390
+ } finally {
3391
+ clearTimeout(timer);
3392
+ options.signal?.removeEventListener("abort", abort);
3393
+ }
3394
+ }
3395
+ async function fetchRobots(origin, targetUrl = origin, refresh = false, options = {}) {
3396
+ return resultFromSource(await fetchRobotsSource(origin, refresh, options), targetUrl);
3397
+ }
3398
+ function createRobotsSession(input = {}) {
3399
+ const sources = /* @__PURE__ */ new Map();
3400
+ const sourceFor = (origin) => {
3401
+ const normalizedOrigin = new URL(origin).origin;
3402
+ let source = sources.get(normalizedOrigin);
3403
+ if (!source) {
3404
+ source = fetchRobotsSource(normalizedOrigin, input.refresh ?? false, {
3405
+ writeCache: input.writeCache,
3406
+ timeoutMs: input.timeoutMs,
3407
+ signal: input.signal
3408
+ });
3409
+ sources.set(normalizedOrigin, source);
3410
+ }
3411
+ return source;
3412
+ };
3413
+ return {
3414
+ resolve: async (origin, targetUrl) => resultFromSource(await sourceFor(origin), targetUrl),
3415
+ sitemapUrls: async (origin) => {
3416
+ const source = await sourceFor(origin);
3417
+ if (source.status === void 0 || source.status >= 300 || !source.text) return [];
3418
+ const urls = /* @__PURE__ */ new Set();
3419
+ for (const match of source.text.matchAll(/^\s*sitemap\s*:\s*(\S+)\s*$/gim)) {
3420
+ const value = match[1]?.trim();
3421
+ if (!value) continue;
3422
+ try {
3423
+ urls.add(new URL(value).toString());
3424
+ } catch {}
3425
+ }
3426
+ return [...urls].sort();
3427
+ }
3428
+ };
3011
3429
  }
3012
3430
  //#endregion
3013
- //#region packages/core/dist/fetch/page-fetcher/plain.js
3431
+ //#region packages/core/dist/fetch/page-fetcher/robots-evidence.js
3014
3432
  function pageRobotsEvidence(robots) {
3015
3433
  return {
3016
3434
  url: robots.url,
@@ -3031,10 +3449,14 @@ function diagnosticRobotsEvidence(robots) {
3031
3449
  error: robots.error
3032
3450
  };
3033
3451
  }
3452
+ //#endregion
3453
+ //#region packages/core/dist/fetch/page-fetcher/plain.js
3454
+ const MAX_PAGE_RESPONSE_BYTES = 5 * 1024 * 1024;
3034
3455
  function encodePageFetchCacheEvidence(result) {
3035
3456
  return JSON.stringify({
3036
3457
  finalUrl: result.finalUrl,
3037
3458
  blocked: result.diagnostics.blocked,
3459
+ accessBlock: result.diagnostics.accessBlock,
3038
3460
  robotsTxt: result.robotsTxt,
3039
3461
  diagnosticsRobotsTxt: result.diagnostics.robotsTxt,
3040
3462
  redirectChain: result.diagnostics.redirectChain,
@@ -3051,36 +3473,15 @@ function decodePageFetchCacheEvidence(value) {
3051
3473
  return;
3052
3474
  }
3053
3475
  }
3054
- async function fetchWithRedirectChain(url, signal) {
3055
- const redirectChain = [];
3056
- let currentUrl = url;
3057
- for (let redirectCount = 0; redirectCount <= 10; redirectCount += 1) {
3058
- const response = await publicHttpFetch(currentUrl, {
3059
- redirect: "manual",
3060
- signal
3061
- });
3062
- const location = response.headers.get("location");
3063
- if (response.status < 300 || response.status >= 400 || !location) return {
3064
- response,
3065
- redirectChain
3066
- };
3067
- const nextUrl = new URL(location, currentUrl).toString();
3068
- redirectChain.push({
3069
- url: currentUrl,
3070
- status: response.status,
3071
- location: nextUrl
3072
- });
3073
- await response.body?.cancel().catch(() => void 0);
3074
- currentUrl = nextUrl;
3075
- }
3076
- throw new Error(`Too many redirects for ${url}`);
3077
- }
3078
3476
  async function fetchPlain(url, refresh = false, timeoutMs = 2e4, rate, signal, respectRobots = false) {
3079
3477
  const startedAt = Date.now();
3080
3478
  const db = getDb();
3081
3479
  const key = hashKey(["page", url]);
3082
3480
  const host = new URL(url).host;
3083
- let robots = respectRobots ? await fetchRobots(new URL(url).origin, url, refresh) : void 0;
3481
+ let robots = respectRobots ? await fetchRobots(new URL(url).origin, url, refresh, {
3482
+ timeoutMs,
3483
+ signal
3484
+ }) : void 0;
3084
3485
  if (robots && robots.allowed !== true) throw new RobotsAccessError(robots.allowed === false ? "robots-disallowed" : "robots-deferred", robots);
3085
3486
  const cached = db.prepare("SELECT status, headers_json, body_blob, metadata_json, expires_at FROM http_cache WHERE url_hash = ? AND expires_at > ?").get(key, Date.now());
3086
3487
  if (!refresh && cached) {
@@ -3106,6 +3507,7 @@ async function fetchPlain(url, refresh = false, timeoutMs = 2e4, rate, signal, r
3106
3507
  retries: 0,
3107
3508
  rateLimit: { ...rateLimitDiagnostics(host, rate) },
3108
3509
  backpressure: hostBackpressureSnapshot(host),
3510
+ accessBlock: evidence?.accessBlock,
3109
3511
  robotsTxt: robots ? diagnosticRobotsEvidence(robots) : evidence?.diagnosticsRobotsTxt ? {
3110
3512
  ...evidence.diagnosticsRobotsTxt,
3111
3513
  cache: "hit"
@@ -3116,7 +3518,10 @@ async function fetchPlain(url, refresh = false, timeoutMs = 2e4, rate, signal, r
3116
3518
  robotsTxt: robots ? pageRobotsEvidence(robots) : evidence?.robotsTxt
3117
3519
  };
3118
3520
  }
3119
- robots ??= await fetchRobots(new URL(url).origin, url, refresh);
3521
+ robots ??= await fetchRobots(new URL(url).origin, url, refresh, {
3522
+ timeoutMs,
3523
+ signal
3524
+ });
3120
3525
  const beforeFetch = await waitForHostBackpressure(host, rate);
3121
3526
  let attempts = 0;
3122
3527
  const response = await pRetry(async () => {
@@ -3136,8 +3541,14 @@ async function fetchPlain(url, refresh = false, timeoutMs = 2e4, rate, signal, r
3136
3541
  signal?.removeEventListener("abort", abort);
3137
3542
  }
3138
3543
  }, { retries: 2 });
3139
- const html = await response.response.text();
3140
3544
  const headerMap = Object.fromEntries(response.response.headers.entries());
3545
+ const accessBlock = detectAccessBlock({
3546
+ status: response.response.status,
3547
+ headers: response.response.headers
3548
+ });
3549
+ let html = "";
3550
+ if (accessBlock) await response.response.body?.cancel().catch(() => void 0);
3551
+ else html = await readBoundedResponseText(response.response, MAX_PAGE_RESPONSE_BYTES, "Page response");
3141
3552
  const durationMs = Date.now() - startedAt;
3142
3553
  const backpressure = recordHostFetch({
3143
3554
  host,
@@ -3158,7 +3569,7 @@ async function fetchPlain(url, refresh = false, timeoutMs = 2e4, rate, signal, r
3158
3569
  cache: refresh ? "bypass" : "miss",
3159
3570
  fetched: true,
3160
3571
  rendered: false,
3161
- blocked: robots.allowed === false || [
3572
+ blocked: robots.allowed === false || Boolean(accessBlock) || [
3162
3573
  401,
3163
3574
  403,
3164
3575
  429
@@ -3167,15 +3578,19 @@ async function fetchPlain(url, refresh = false, timeoutMs = 2e4, rate, signal, r
3167
3578
  retries: Math.max(0, attempts - 1),
3168
3579
  rateLimit: { ...rateLimitDiagnostics(host, rate) },
3169
3580
  backpressure: backpressure.status === "ok" && beforeFetch.status !== "ok" ? beforeFetch : backpressure,
3581
+ accessBlock,
3170
3582
  robotsTxt: diagnosticRobotsEvidence(robots),
3171
3583
  redirectChain: response.redirectChain
3172
3584
  },
3173
- warnings: robots.allowed === null ? [`robots.txt availability is unknown${robots.status ? ` after HTTP ${robots.status}` : ""}; this fetch does not prove crawler access is allowed.`] : [],
3585
+ warnings: [...robots.allowed === null ? [`robots.txt availability is unknown${robots.status ? ` after HTTP ${robots.status}` : ""}; this fetch does not prove crawler access is allowed.`] : [], ...accessBlock ? [`${accessBlock.guidance.summary} Identify the crawler as ${accessBlock.crawler.userAgent}.`] : []],
3174
3586
  robotsTxt: pageRobotsEvidence(robots)
3175
3587
  };
3176
- db.prepare(`INSERT OR REPLACE INTO http_cache
3177
- (url_hash, url, status, headers_json, body_blob, metadata_json, etag, fetched_at, expires_at)
3178
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`).run(key, url, response.response.status, JSON.stringify(headerMap), Buffer.from(html), encodePageFetchCacheEvidence(result), response.response.headers.get("etag"), Date.now(), Date.now() + 36e5);
3588
+ if (!accessBlock) {
3589
+ db.prepare(`INSERT OR REPLACE INTO http_cache
3590
+ (url_hash, url, status, headers_json, body_blob, metadata_json, etag, fetched_at, expires_at)
3591
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`).run(key, url, response.response.status, JSON.stringify(headerMap), Buffer.from(html), encodePageFetchCacheEvidence(result), response.response.headers.get("etag"), Date.now(), Date.now() + 36e5);
3592
+ noteCacheWrite(Buffer.byteLength(html));
3593
+ }
3179
3594
  return result;
3180
3595
  }
3181
3596
  //#endregion
@@ -3388,7 +3803,7 @@ async function renderPage(input) {
3388
3803
  const beforeFetch = await waitForHostBackpressure(host, input.rate);
3389
3804
  const timeoutMs = input.timeoutMs ?? DEFAULT_RENDER_TIMEOUT_MS;
3390
3805
  const context = await input.browser.browser.newContext({
3391
- userAgent: BROWSER_USER_AGENT,
3806
+ userAgent: SEO_CRAWLER_USER_AGENT,
3392
3807
  viewport: {
3393
3808
  width: 1440,
3394
3809
  height: 900
@@ -3417,6 +3832,11 @@ async function renderPage(input) {
3417
3832
  });
3418
3833
  const html = await page.content();
3419
3834
  const status = response?.status() ?? 200;
3835
+ const headers = response?.headers() ?? {};
3836
+ const accessBlock = detectAccessBlock({
3837
+ status,
3838
+ headers
3839
+ });
3420
3840
  const durationMs = Date.now() - startedAt;
3421
3841
  const backpressure = recordHostFetch({
3422
3842
  host,
@@ -3429,7 +3849,7 @@ async function renderPage(input) {
3429
3849
  url: input.url,
3430
3850
  finalUrl: page.url(),
3431
3851
  status,
3432
- headers: response?.headers() ?? {},
3852
+ headers,
3433
3853
  html,
3434
3854
  usedJs: true,
3435
3855
  diagnostics: {
@@ -3437,7 +3857,7 @@ async function renderPage(input) {
3437
3857
  cache: "bypass",
3438
3858
  fetched: true,
3439
3859
  rendered: true,
3440
- blocked: [
3860
+ blocked: Boolean(accessBlock) || [
3441
3861
  401,
3442
3862
  403,
3443
3863
  429
@@ -3446,6 +3866,7 @@ async function renderPage(input) {
3446
3866
  retries: 0,
3447
3867
  rateLimit: rateLimitDiagnostics(host, input.rate),
3448
3868
  backpressure: backpressure.status === "ok" && beforeFetch.status !== "ok" ? beforeFetch : backpressure,
3869
+ accessBlock,
3449
3870
  rendering: {
3450
3871
  mode: "on",
3451
3872
  status: "rendered",
@@ -3462,7 +3883,7 @@ async function renderPage(input) {
3462
3883
  ...renderDiagnostics
3463
3884
  }
3464
3885
  },
3465
- warnings: networkIdleReached ? [] : [`Rendered page did not reach network idle within ${Math.min(NETWORK_IDLE_TIMEOUT_MS, timeoutMs)}ms; captured DOM after DOMContentLoaded.`]
3886
+ warnings: [...networkIdleReached ? [] : [`Rendered page did not reach network idle within ${Math.min(NETWORK_IDLE_TIMEOUT_MS, timeoutMs)}ms; captured DOM after DOMContentLoaded.`], ...accessBlock ? [`${accessBlock.guidance.summary} Identify the crawler as ${accessBlock.crawler.userAgent}.`] : []]
3466
3887
  };
3467
3888
  } catch (error) {
3468
3889
  if (error instanceof JavaScriptRenderingError) throw error;
@@ -3609,6 +4030,85 @@ function renderingDocumentDifference(raw, rendered) {
3609
4030
  };
3610
4031
  }
3611
4032
  //#endregion
4033
+ //#region packages/core/dist/fetch/page-fetcher/status-probe.js
4034
+ async function statusProbe(url, opts) {
4035
+ const startedAt = Date.now();
4036
+ const host = new URL(url).host;
4037
+ const rate = normalizeRateControls(opts.rate);
4038
+ const origin = new URL(url).origin;
4039
+ const robots = opts.robotsResolver ? await opts.robotsResolver(origin, url) : await fetchRobots(origin, url, opts.refresh ?? false, {
4040
+ writeCache: false,
4041
+ timeoutMs: opts.timeoutMs,
4042
+ signal: opts.signal
4043
+ });
4044
+ if (opts.respectRobots && robots.allowed !== true) throw new RobotsAccessError(robots.allowed === false ? "robots-disallowed" : "robots-deferred", robots);
4045
+ const beforeFetch = await waitForHostBackpressure(host, rate);
4046
+ let attempts = 0;
4047
+ const result = await pRetry(async () => {
4048
+ attempts += 1;
4049
+ const controller = new AbortController();
4050
+ const abort = () => controller.abort();
4051
+ const timer = setTimeout(abort, opts.timeoutMs ?? 2e4);
4052
+ if (opts.signal?.aborted) controller.abort();
4053
+ else opts.signal?.addEventListener("abort", abort, { once: true });
4054
+ try {
4055
+ return await fetchWithRedirectChain(url, controller.signal);
4056
+ } catch (error) {
4057
+ if (opts.signal?.aborted) throw new AbortError(error instanceof Error ? error : new Error(String(error)));
4058
+ throw error;
4059
+ } finally {
4060
+ clearTimeout(timer);
4061
+ opts.signal?.removeEventListener("abort", abort);
4062
+ }
4063
+ }, { retries: 1 });
4064
+ const headers = Object.fromEntries(result.response.headers.entries());
4065
+ const accessBlock = detectAccessBlock({
4066
+ status: result.response.status,
4067
+ headers: result.response.headers
4068
+ });
4069
+ await result.response.body?.cancel().catch(() => void 0);
4070
+ const durationMs = Date.now() - startedAt;
4071
+ const backpressure = recordHostFetch({
4072
+ host,
4073
+ status: result.response.status,
4074
+ durationMs,
4075
+ retryAfterMs: retryAfterMs(result.response.headers.get("retry-after")),
4076
+ rate
4077
+ });
4078
+ return {
4079
+ url,
4080
+ finalUrl: result.response.url || url,
4081
+ status: result.response.status,
4082
+ headers,
4083
+ html: "",
4084
+ usedJs: false,
4085
+ diagnostics: {
4086
+ source: "network",
4087
+ cache: "bypass",
4088
+ fetched: true,
4089
+ rendered: false,
4090
+ blocked: robots.allowed === false || Boolean(accessBlock) || [
4091
+ 401,
4092
+ 403,
4093
+ 429
4094
+ ].includes(result.response.status),
4095
+ durationMs,
4096
+ retries: Math.max(0, attempts - 1),
4097
+ rateLimit: rateLimitDiagnostics(host, rate),
4098
+ backpressure: backpressure.status === "ok" && beforeFetch.status !== "ok" ? beforeFetch : backpressure,
4099
+ accessBlock,
4100
+ robotsTxt: diagnosticRobotsEvidence(robots),
4101
+ redirectChain: result.redirectChain
4102
+ },
4103
+ warnings: ["Status-only health probe. The response body was not downloaded, parsed, rendered, or cached.", ...accessBlock ? [`${accessBlock.guidance.summary} Identify the crawler as ${accessBlock.crawler.userAgent}.`] : []],
4104
+ robotsTxt: pageRobotsEvidence(robots)
4105
+ };
4106
+ }
4107
+ async function fetchPageStatus(url, opts = {}) {
4108
+ const rate = normalizeRateControls(opts.rate);
4109
+ return await queueForHost(new URL(url).host, rate).add(() => statusProbe(url, opts));
4110
+ }
4111
+ //#endregion
3612
4112
  //#region packages/core/dist/fetch/page-fetcher.js
3613
4113
  function normalizeJavaScriptRenderingMode(input) {
3614
4114
  if (input === true) return "on";
@@ -3654,7 +4154,7 @@ async function fetchPage(url, opts = {}, dependencies = {}) {
3654
4154
  const rate = normalizeRateControls(opts.rate);
3655
4155
  return await queueForHost(new URL(url).host, rate).add(async () => {
3656
4156
  const first = await plainFetch(url, opts.refresh, opts.timeoutMs, rate, opts.signal, opts.respectRobots);
3657
- if (!(renderingMode === "on" || renderingMode === "auto" && looksLikeSpa(first.html)) || opts.signal?.aborted) return withRenderingState(first, renderingMode, opts.signal?.aborted ? "skipped" : renderingMode === "off" ? "not-requested" : "not-needed");
4157
+ if (!(!first.diagnostics.accessBlock && (renderingMode === "on" || renderingMode === "auto" && looksLikeSpa(first.html))) || opts.signal?.aborted) return withRenderingState(first, renderingMode, opts.signal?.aborted ? "skipped" : renderingMode === "off" ? "not-requested" : "not-needed");
3658
4158
  const ownsRenderer = !opts.renderer;
3659
4159
  const renderer = opts.renderer ?? makeRenderer();
3660
4160
  try {
@@ -3668,6 +4168,7 @@ async function fetchPage(url, opts = {}, dependencies = {}) {
3668
4168
  ...rendered.diagnostics,
3669
4169
  robotsTxt: first.diagnostics.robotsTxt,
3670
4170
  redirectChain: first.diagnostics.redirectChain,
4171
+ accessBlock: rendered.diagnostics.accessBlock ?? first.diagnostics.accessBlock,
3671
4172
  rendering: {
3672
4173
  ...rendered.diagnostics.rendering,
3673
4174
  mode: renderingMode,
@@ -4042,9 +4543,12 @@ async function querySearchAnalytics(site, body, opts = {}) {
4042
4543
  if (rows.length < rowLimit || typeof maxRows === "number" && allRows.length >= maxRows) break;
4043
4544
  startRow += rowLimit;
4044
4545
  }
4546
+ const requestJson = JSON.stringify(body);
4547
+ const responseJson = JSON.stringify(allRows);
4045
4548
  db.prepare(`INSERT OR REPLACE INTO gsc_cache
4046
4549
  (site_url, query_hash, request_json, response_json, row_count, fetched_at, expires_at)
4047
- VALUES (?, ?, ?, ?, ?, ?, ?)`).run(site, queryHash, JSON.stringify(body), JSON.stringify(allRows), allRows.length, Date.now(), Date.now() + 864e5);
4550
+ VALUES (?, ?, ?, ?, ?, ?, ?)`).run(site, queryHash, requestJson, responseJson, allRows.length, Date.now(), Date.now() + 864e5);
4551
+ noteCacheWrite(Buffer.byteLength(requestJson) + Buffer.byteLength(responseJson));
4048
4552
  return {
4049
4553
  rows: allRows,
4050
4554
  calls,
@@ -4189,7 +4693,7 @@ async function listSites(_refresh = false) {
4189
4693
  }
4190
4694
  //#endregion
4191
4695
  //#region packages/core/dist/analyze/page-technical-signals.js
4192
- function normalizedUrl$1(value) {
4696
+ function normalizePageUrl(value) {
4193
4697
  try {
4194
4698
  const parsed = new URL(value);
4195
4699
  parsed.hash = "";
@@ -4200,7 +4704,7 @@ function normalizedUrl$1(value) {
4200
4704
  }
4201
4705
  }
4202
4706
  function samePageUrl(left, right) {
4203
- return normalizedUrl$1(left) === normalizedUrl$1(right);
4707
+ return normalizePageUrl(left) === normalizePageUrl(right);
4204
4708
  }
4205
4709
  function absoluteUrl$1(value, base) {
4206
4710
  try {
@@ -4576,12 +5080,13 @@ const CTR_BASELINE = {
4576
5080
  9: .015,
4577
5081
  10: .012
4578
5082
  };
5083
+ const WORD_SEGMENTER = typeof Intl !== "undefined" && "Segmenter" in Intl ? new Intl.Segmenter("und", { granularity: "word" }) : void 0;
4579
5084
  function normalizeText$1(value) {
4580
5085
  return value.normalize("NFKD").replace(/\p{Diacritic}/gu, "").replace(/[’']/g, "'").toLowerCase().replace(/\s+/g, " ").trim();
4581
5086
  }
4582
5087
  function unicodeTokens(value) {
4583
5088
  const normalized = normalizeText$1(value);
4584
- if (typeof Intl !== "undefined" && "Segmenter" in Intl) return [...new Intl.Segmenter("und", { granularity: "word" }).segment(normalized)].filter((part) => part.isWordLike).map((part) => part.segment.trim()).filter((token) => token.length > 1);
5089
+ if (WORD_SEGMENTER) return [...WORD_SEGMENTER.segment(normalized)].filter((part) => part.isWordLike).map((part) => part.segment.trim()).filter((token) => token.length > 1);
4585
5090
  return (normalized.match(/[\p{L}\p{N}]+/gu) ?? []).filter((token) => token.length > 1);
4586
5091
  }
4587
5092
  function tokenize(value) {
@@ -5228,6 +5733,7 @@ const MIN_BENCHMARK_URL_SAMPLES = 5;
5228
5733
  const MIN_POSITIVE_URL_SAMPLES = 3;
5229
5734
  const SITE_BENCHMARK_FLOOR_MULTIPLIER = .5;
5230
5735
  const SITE_BENCHMARK_CAP_MULTIPLIER = 1.5;
5736
+ const URL_BENCHMARK_CACHE_LIMIT = 1e4;
5231
5737
  function roundedPosition(position) {
5232
5738
  return Math.max(1, Math.min(10, Math.round(position)));
5233
5739
  }
@@ -5244,7 +5750,8 @@ function bucketBenchmark(input) {
5244
5750
  const impressions = peerRows.reduce((sum, row) => sum + row.impressions, 0);
5245
5751
  const byUrl = /* @__PURE__ */ new Map();
5246
5752
  for (const row of peerRows) {
5247
- const key = row.keys?.[1] || `${row.keys?.[0] ?? "query"}:${row.position}`;
5753
+ const rawKey = row.keys?.[1] || `${row.keys?.[0] ?? "query"}:${row.position}`;
5754
+ const key = row.keys?.[1] ? normalizePageUrl(rawKey) : rawKey;
5248
5755
  const current = byUrl.get(key) ?? {
5249
5756
  clicks: 0,
5250
5757
  impressions: 0
@@ -5278,14 +5785,85 @@ function bucketBenchmark(input) {
5278
5785
  positiveUrlSamples: positiveUrlCtrSamples.length
5279
5786
  };
5280
5787
  }
5281
- function smoothMonotonicBenchmarks(input) {
5282
- const byPosition = {};
5283
- let previousCtr = Number.POSITIVE_INFINITY;
5284
- for (const position of Object.keys(CTR_BASELINE).map(Number)) {
5285
- const benchmark = input[position] ?? bucketBenchmark({
5286
- rows: [],
5287
- position
5288
- });
5788
+ function indexedPositionBucket(rows) {
5789
+ const byUrl = /* @__PURE__ */ new Map();
5790
+ let impressions = 0;
5791
+ for (const row of rows) {
5792
+ impressions += row.impressions;
5793
+ const rawKey = row.keys?.[1] || `${row.keys?.[0] ?? "query"}:${row.position}`;
5794
+ const key = row.keys?.[1] ? normalizePageUrl(rawKey) : rawKey;
5795
+ const current = byUrl.get(key) ?? {
5796
+ key,
5797
+ clicks: 0,
5798
+ impressions: 0,
5799
+ rows: 0,
5800
+ ctr: 0
5801
+ };
5802
+ current.clicks += row.clicks;
5803
+ current.impressions += row.impressions;
5804
+ current.rows += 1;
5805
+ current.ctr = current.impressions ? current.clicks / current.impressions : 0;
5806
+ byUrl.set(key, current);
5807
+ }
5808
+ const compareSample = (left, right) => left.ctr - right.ctr || (left.key < right.key ? -1 : left.key > right.key ? 1 : 0);
5809
+ const qualified = [...byUrl.values()].filter((sample) => sample.impressions >= MIN_BENCHMARK_URL_IMPRESSIONS).sort(compareSample);
5810
+ const positive = qualified.filter((sample) => sample.ctr > 0).sort(compareSample);
5811
+ return {
5812
+ rows: rows.length,
5813
+ impressions,
5814
+ byUrl,
5815
+ qualified,
5816
+ qualifiedIndexes: new Map(qualified.map((sample, index) => [sample.key, index])),
5817
+ positive,
5818
+ positiveIndexes: new Map(positive.map((sample, index) => [sample.key, index])),
5819
+ qualifiedImpressions: qualified.reduce((sum, sample) => sum + sample.impressions, 0)
5820
+ };
5821
+ }
5822
+ function indexedPercentile(samples, indexes, excludedKey, percentileValue) {
5823
+ const excludedIndex = indexes.get(excludedKey);
5824
+ const length = samples.length - (excludedIndex === void 0 ? 0 : 1);
5825
+ if (length === 0) return void 0;
5826
+ const rank = Math.min(length - 1, Math.max(0, Math.ceil(percentileValue / 100 * length) - 1));
5827
+ return samples[excludedIndex !== void 0 && rank >= excludedIndex ? rank + 1 : rank]?.ctr;
5828
+ }
5829
+ function indexedBucketBenchmark(input) {
5830
+ const excluded = input.bucket.byUrl.get(input.excludedKey);
5831
+ const excludesQualified = excluded && excluded.impressions >= MIN_BENCHMARK_URL_IMPRESSIONS ? excluded : void 0;
5832
+ const rows = input.bucket.rows - (excluded?.rows ?? 0);
5833
+ const impressions = input.bucket.impressions - (excluded?.impressions ?? 0);
5834
+ const qualifiedImpressions = input.bucket.qualifiedImpressions - (excludesQualified?.impressions ?? 0);
5835
+ const urlSamples = input.bucket.qualified.length - (excludesQualified ? 1 : 0);
5836
+ const excludesPositive = Boolean(excludesQualified && excludesQualified.ctr > 0);
5837
+ const positiveUrlSamples = input.bucket.positive.length - (excludesPositive ? 1 : 0);
5838
+ const hasEnoughSiteData = qualifiedImpressions >= MIN_BENCHMARK_IMPRESSIONS && urlSamples >= MIN_BENCHMARK_URL_SAMPLES && positiveUrlSamples >= MIN_POSITIVE_URL_SAMPLES;
5839
+ const samplePopulation = input.samplePopulation ?? "positive_url_samples";
5840
+ const rawSiteCtr = hasEnoughSiteData ? samplePopulation === "all_qualified_url_samples" ? indexedPercentile(input.bucket.qualified, input.bucket.qualifiedIndexes, input.excludedKey, 75) : indexedPercentile(input.bucket.positive, input.bucket.positiveIndexes, input.excludedKey, 75) : void 0;
5841
+ const fallbackCtr = expectedCtrForPosition(input.position);
5842
+ const floorCtr = fallbackCtr * SITE_BENCHMARK_FLOOR_MULTIPLIER;
5843
+ const capCtr = fallbackCtr * SITE_BENCHMARK_CAP_MULTIPLIER;
5844
+ const hasSiteCtr = rawSiteCtr !== void 0;
5845
+ const ctr = hasSiteCtr ? Math.max(floorCtr, Math.min(rawSiteCtr, capCtr)) : fallbackCtr;
5846
+ const adjustment = hasSiteCtr && rawSiteCtr < floorCtr ? "_floored" : hasSiteCtr && rawSiteCtr > capCtr ? "_capped" : "";
5847
+ const source = hasSiteCtr ? `site_gsc_position_bucket_robust_p75${samplePopulation === "all_qualified_url_samples" ? "_all_samples" : ""}${input.sourceSuffix}${adjustment}` : input.fallbackSource ?? "default_position_curve";
5848
+ return {
5849
+ ctr: Number(ctr.toFixed(4)),
5850
+ source,
5851
+ samplePopulation,
5852
+ rows,
5853
+ impressions,
5854
+ qualifiedImpressions,
5855
+ urlSamples,
5856
+ positiveUrlSamples
5857
+ };
5858
+ }
5859
+ function smoothMonotonicBenchmarks(input) {
5860
+ const byPosition = {};
5861
+ let previousCtr = Number.POSITIVE_INFINITY;
5862
+ for (const position of Object.keys(CTR_BASELINE).map(Number)) {
5863
+ const benchmark = input[position] ?? bucketBenchmark({
5864
+ rows: [],
5865
+ position
5866
+ });
5289
5867
  const ctr = Math.min(benchmark.ctr, previousCtr);
5290
5868
  const smoothed = ctr < benchmark.ctr;
5291
5869
  previousCtr = ctr;
@@ -5299,9 +5877,25 @@ function smoothMonotonicBenchmarks(input) {
5299
5877
  }
5300
5878
  function createCtrBenchmarkContext(rows, options = {}) {
5301
5879
  const buckets = /* @__PURE__ */ new Map();
5880
+ const diagnostics = {
5881
+ indexRowVisits: 0,
5882
+ urlLookups: 0,
5883
+ fallbackRowScans: 0
5884
+ };
5302
5885
  for (const row of rows) {
5303
5886
  const position = roundedPosition(row.position);
5304
- buckets.set(position, [...buckets.get(position) ?? [], row]);
5887
+ const bucket = buckets.get(position) ?? [];
5888
+ bucket.push(row);
5889
+ buckets.set(position, bucket);
5890
+ diagnostics.indexRowVisits += 1;
5891
+ }
5892
+ const indexedBuckets = new Map([...buckets].map(([position, bucketRows]) => [position, indexedPositionBucket(bucketRows)]));
5893
+ const emptyIndexedBucket = indexedPositionBucket([]);
5894
+ const urlBenchmarkCache = /* @__PURE__ */ new Map();
5895
+ const urlRowCounts = /* @__PURE__ */ new Map();
5896
+ for (const row of rows) {
5897
+ const key = normalizePageUrl(row.keys?.[1] ?? "");
5898
+ urlRowCounts.set(key, (urlRowCounts.get(key) ?? 0) + 1);
5305
5899
  }
5306
5900
  const raw = {};
5307
5901
  for (const position of Object.keys(CTR_BASELINE).map(Number)) raw[position] = bucketBenchmark({
@@ -5312,6 +5906,7 @@ function createCtrBenchmarkContext(rows, options = {}) {
5312
5906
  });
5313
5907
  const byPosition = smoothMonotonicBenchmarks(raw);
5314
5908
  function benchmarkForRow(row, excludedRows, sourceSuffix) {
5909
+ diagnostics.fallbackRowScans += rows.length;
5315
5910
  const position = roundedPosition(row.position);
5316
5911
  const excludedSet = new Set(excludedRows);
5317
5912
  const rowRaw = {};
@@ -5328,18 +5923,49 @@ function createCtrBenchmarkContext(rows, options = {}) {
5328
5923
  position
5329
5924
  });
5330
5925
  }
5926
+ function benchmarkForUrl(row) {
5927
+ diagnostics.urlLookups += 1;
5928
+ const position = roundedPosition(row.position);
5929
+ const excludedKey = normalizePageUrl(row.keys?.[1] ?? "");
5930
+ const cached = excludedKey ? urlBenchmarkCache.get(excludedKey)?.get(position) : void 0;
5931
+ if (cached) return cached;
5932
+ const rowRaw = {};
5933
+ for (const bucketPosition of Object.keys(CTR_BASELINE).map(Number)) rowRaw[bucketPosition] = indexedBucketBenchmark({
5934
+ bucket: indexedBuckets.get(bucketPosition) ?? emptyIndexedBucket,
5935
+ position: bucketPosition,
5936
+ excludedKey,
5937
+ sourceSuffix: "_leave_target_url_out",
5938
+ samplePopulation: options.samplePopulation,
5939
+ fallbackSource: options.fallbackSource
5940
+ });
5941
+ const benchmark = smoothMonotonicBenchmarks(rowRaw)[String(position)] ?? byPosition[String(position)] ?? bucketBenchmark({
5942
+ rows: [],
5943
+ position
5944
+ });
5945
+ let byPositionForUrl = excludedKey ? urlBenchmarkCache.get(excludedKey) : void 0;
5946
+ if (excludedKey && !byPositionForUrl && urlBenchmarkCache.size < URL_BENCHMARK_CACHE_LIMIT) {
5947
+ byPositionForUrl = /* @__PURE__ */ new Map();
5948
+ urlBenchmarkCache.set(excludedKey, byPositionForUrl);
5949
+ }
5950
+ byPositionForUrl?.set(position, benchmark);
5951
+ return benchmark;
5952
+ }
5331
5953
  return {
5332
5954
  byPosition,
5333
5955
  forRow(row) {
5334
5956
  return benchmarkForRow(row, [row], "_leave_one_out");
5335
5957
  },
5336
5958
  forUrl(row) {
5337
- const url = row.keys?.[1];
5338
- return benchmarkForRow(row, url ? rows.filter((candidate) => samePageUrl(candidate.keys?.[1] ?? "", url)) : [row], "_leave_target_url_out");
5959
+ return benchmarkForUrl(row);
5960
+ },
5961
+ urlRowCount(row) {
5962
+ const key = normalizePageUrl(row.keys?.[1] ?? "");
5963
+ return urlRowCounts.get(key) ?? 0;
5339
5964
  },
5340
5965
  forAggregate(row, excludedRows = []) {
5341
5966
  return benchmarkForRow(row, excludedRows, excludedRows.length ? "_leave_group_out" : void 0);
5342
- }
5967
+ },
5968
+ diagnostics
5343
5969
  };
5344
5970
  }
5345
5971
  //#endregion
@@ -6098,6 +6724,210 @@ function matchesFilters(issue, filters) {
6098
6724
  return true;
6099
6725
  }
6100
6726
  //#endregion
6727
+ //#region packages/core/dist/analyze/crawler/agent-endpoints.js
6728
+ const MAX_LINK_HEADER_ENTRIES = 25;
6729
+ const REGISTERED_AGENT_LINK_RELS = [
6730
+ "api-catalog",
6731
+ "describedby",
6732
+ "service-desc",
6733
+ "service-doc"
6734
+ ];
6735
+ const EMERGING_AGENT_LINK_RELS = ["agent-skills", "llms-txt"];
6736
+ const AGENT_ENDPOINT_PROBES = [
6737
+ {
6738
+ id: "mcp-server-card",
6739
+ path: "/.well-known/mcp/server-card.json",
6740
+ accept: "application/json",
6741
+ expects: "json",
6742
+ interestFields: [
6743
+ "serverInfo",
6744
+ "transport",
6745
+ "capabilities"
6746
+ ],
6747
+ requiredFields: ["serverInfo"]
6748
+ },
6749
+ {
6750
+ id: "mcp-server-cards",
6751
+ path: "/.well-known/mcp/server-cards.json",
6752
+ accept: "application/json",
6753
+ expects: "json",
6754
+ interestFields: ["serverCards", "servers"]
6755
+ },
6756
+ {
6757
+ id: "a2a-agent-card",
6758
+ path: "/.well-known/agent-card.json",
6759
+ accept: "application/json",
6760
+ expects: "json",
6761
+ interestFields: [
6762
+ "name",
6763
+ "description",
6764
+ "url",
6765
+ "capabilities",
6766
+ "skills"
6767
+ ],
6768
+ requiredFields: ["name"]
6769
+ },
6770
+ {
6771
+ id: "openid-configuration",
6772
+ path: "/.well-known/openid-configuration",
6773
+ accept: "application/json",
6774
+ expects: "json",
6775
+ interestFields: [
6776
+ "issuer",
6777
+ "authorization_endpoint",
6778
+ "token_endpoint",
6779
+ "jwks_uri",
6780
+ "response_types_supported"
6781
+ ],
6782
+ requiredFields: ["issuer"]
6783
+ },
6784
+ {
6785
+ id: "oauth-authorization-server",
6786
+ path: "/.well-known/oauth-authorization-server",
6787
+ accept: "application/json",
6788
+ expects: "json",
6789
+ interestFields: [
6790
+ "issuer",
6791
+ "authorization_endpoint",
6792
+ "token_endpoint",
6793
+ "jwks_uri",
6794
+ "grant_types_supported"
6795
+ ],
6796
+ requiredFields: ["issuer"]
6797
+ },
6798
+ {
6799
+ id: "oauth-protected-resource",
6800
+ path: "/.well-known/oauth-protected-resource",
6801
+ accept: "application/json",
6802
+ expects: "json",
6803
+ interestFields: [
6804
+ "resource",
6805
+ "authorization_servers",
6806
+ "scopes_supported"
6807
+ ],
6808
+ requiredFields: ["resource"]
6809
+ },
6810
+ {
6811
+ id: "api-catalog",
6812
+ path: "/.well-known/api-catalog",
6813
+ accept: "application/linkset+json,application/json;q=0.9",
6814
+ expects: "json",
6815
+ interestFields: ["linkset"],
6816
+ requiredFields: ["linkset"]
6817
+ },
6818
+ {
6819
+ id: "web-bot-auth-directory",
6820
+ path: "/.well-known/http-message-signatures-directory",
6821
+ accept: "application/http-message-signatures-directory+json,application/json;q=0.9",
6822
+ expects: "json",
6823
+ interestFields: ["keys"],
6824
+ requiredFields: ["keys"]
6825
+ },
6826
+ {
6827
+ id: "auth-md",
6828
+ path: "/auth.md",
6829
+ accept: "text/markdown,text/plain;q=0.9",
6830
+ expects: "text"
6831
+ },
6832
+ {
6833
+ id: "llms-full-txt",
6834
+ path: "/llms-full.txt",
6835
+ accept: "text/plain,text/markdown;q=0.9",
6836
+ expects: "text"
6837
+ }
6838
+ ];
6839
+ async function inspectAgentEndpoint(input) {
6840
+ const url = new URL(input.probe.path, input.origin).toString();
6841
+ try {
6842
+ const { response, body } = await fetchText({
6843
+ url,
6844
+ timeoutMs: input.timeoutMs,
6845
+ fetch: input.fetch,
6846
+ signal: input.signal,
6847
+ accept: input.probe.accept
6848
+ });
6849
+ const contentType = response.headers.get("content-type") ?? void 0;
6850
+ const htmlFallback = /\btext\/html\b/iu.test(contentType ?? "");
6851
+ const exists = response.status >= 200 && response.status < 300 && !htmlFallback;
6852
+ const observation = {
6853
+ id: input.probe.id,
6854
+ url: response.url || url,
6855
+ status: response.status,
6856
+ exists,
6857
+ ...contentType === void 0 ? {} : { contentType }
6858
+ };
6859
+ if (exists && input.probe.expects === "json") {
6860
+ let parsed;
6861
+ try {
6862
+ const value = JSON.parse(body);
6863
+ observation.validJson = true;
6864
+ if (value && typeof value === "object" && !Array.isArray(value)) parsed = value;
6865
+ } catch {
6866
+ observation.validJson = false;
6867
+ }
6868
+ if (parsed) {
6869
+ observation.presentFields = [.../* @__PURE__ */ new Set([...input.probe.interestFields ?? [], ...input.probe.requiredFields ?? []])].sort().filter((field) => parsed[field] !== void 0);
6870
+ observation.missingFields = (input.probe.requiredFields ?? []).filter((field) => parsed[field] === void 0).sort();
6871
+ } else if (observation.validJson) {
6872
+ observation.presentFields = [];
6873
+ observation.missingFields = [...input.probe.requiredFields ?? []].sort();
6874
+ }
6875
+ }
6876
+ return observation;
6877
+ } catch (error) {
6878
+ return {
6879
+ id: input.probe.id,
6880
+ url,
6881
+ exists: false,
6882
+ error: safeError(error)
6883
+ };
6884
+ }
6885
+ }
6886
+ async function inspectAgentEndpoints(input) {
6887
+ const [linkHeader, ...endpoints] = await Promise.all([(async () => {
6888
+ try {
6889
+ const { response } = await fetchText({
6890
+ url: input.startUrl,
6891
+ timeoutMs: input.timeoutMs,
6892
+ fetch: input.fetch,
6893
+ signal: input.signal,
6894
+ accept: "text/html,application/xhtml+xml;q=0.9"
6895
+ });
6896
+ const entries = linkEntries(response.headers.get("link") ?? void 0, input.startUrl).slice(0, MAX_LINK_HEADER_ENTRIES).map((entry) => ({
6897
+ url: entry.url,
6898
+ rel: entry.rel.filter(Boolean),
6899
+ ...entry.type ? { type: entry.type } : {}
6900
+ }));
6901
+ const observedRels = new Set(entries.flatMap((entry) => entry.rel));
6902
+ return {
6903
+ url: response.url || input.startUrl,
6904
+ status: response.status,
6905
+ entries,
6906
+ registeredRels: REGISTERED_AGENT_LINK_RELS.filter((rel) => observedRels.has(rel)),
6907
+ emergingRels: EMERGING_AGENT_LINK_RELS.filter((rel) => observedRels.has(rel))
6908
+ };
6909
+ } catch (error) {
6910
+ return {
6911
+ url: input.startUrl,
6912
+ entries: [],
6913
+ registeredRels: [],
6914
+ emergingRels: [],
6915
+ error: safeError(error)
6916
+ };
6917
+ }
6918
+ })(), ...AGENT_ENDPOINT_PROBES.map((probe) => inspectAgentEndpoint({
6919
+ origin: input.origin,
6920
+ probe,
6921
+ timeoutMs: input.timeoutMs,
6922
+ fetch: input.fetch,
6923
+ signal: input.signal
6924
+ }))]);
6925
+ return {
6926
+ linkHeader,
6927
+ endpoints
6928
+ };
6929
+ }
6930
+ //#endregion
6101
6931
  //#region packages/core/dist/analyze/crawler/agent-discovery.js
6102
6932
  const MAX_BODY_BYTES = 2e6;
6103
6933
  const MAX_LLMS_LINKS = 100;
@@ -6291,16 +7121,15 @@ async function inspectMarkdownPage(input) {
6291
7121
  repeatedHashStable: null,
6292
7122
  markdownCanonicalMatchesHtml: null
6293
7123
  };
6294
- if (advertisedUrls.length !== 1) return observation;
6295
- const explicitUrl = advertisedUrls[0];
7124
+ const explicitUrl = advertisedUrls.length === 1 ? advertisedUrls[0] : void 0;
6296
7125
  const [explicit, negotiated, repeated] = await Promise.all([
6297
- fetchRepresentation({
7126
+ explicitUrl ? fetchRepresentation({
6298
7127
  url: explicitUrl,
6299
7128
  accept: "text/markdown",
6300
7129
  timeoutMs: input.timeoutMs,
6301
7130
  fetch: input.fetch,
6302
7131
  signal: input.signal
6303
- }),
7132
+ }) : Promise.resolve(void 0),
6304
7133
  fetchRepresentation({
6305
7134
  url: input.page.finalUrl,
6306
7135
  accept: "text/markdown",
@@ -6309,20 +7138,21 @@ async function inspectMarkdownPage(input) {
6309
7138
  signal: input.signal
6310
7139
  }),
6311
7140
  fetchRepresentation({
6312
- url: explicitUrl,
7141
+ url: explicitUrl ?? input.page.finalUrl,
6313
7142
  accept: "text/markdown",
6314
7143
  timeoutMs: input.timeoutMs,
6315
7144
  fetch: input.fetch,
6316
7145
  signal: input.signal
6317
7146
  })
6318
7147
  ]);
6319
- observation.explicit = explicit.observation;
7148
+ if (explicit) observation.explicit = explicit.observation;
6320
7149
  observation.negotiated = negotiated.observation;
6321
7150
  observation.repeated = repeated.observation;
6322
- observation.explicitMatchesNegotiated = explicit.observation.sha256 && negotiated.observation.sha256 ? explicit.observation.sha256 === negotiated.observation.sha256 : null;
6323
- observation.repeatedHashStable = explicit.observation.sha256 && repeated.observation.sha256 ? explicit.observation.sha256 === repeated.observation.sha256 : null;
6324
- observation.markdownCanonicalMatchesHtml = sameDocument(explicit.observation.canonicalUrl, input.page.finalUrl);
6325
- if (explicit.body) observation.quality = markdownQuality(explicit.body, input.page);
7151
+ observation.explicitMatchesNegotiated = explicit?.observation.sha256 && negotiated.observation.sha256 ? explicit.observation.sha256 === negotiated.observation.sha256 : null;
7152
+ const primary = explicit ?? negotiated;
7153
+ observation.repeatedHashStable = primary.observation.sha256 && repeated.observation.sha256 ? primary.observation.sha256 === repeated.observation.sha256 : null;
7154
+ observation.markdownCanonicalMatchesHtml = sameDocument(primary.observation.canonicalUrl, input.page.finalUrl);
7155
+ if (primary.body) observation.quality = markdownQuality(primary.body, input.page);
6326
7156
  return observation;
6327
7157
  }
6328
7158
  async function fetchText(input) {
@@ -6618,7 +7448,7 @@ async function collectAgentDiscovery(input) {
6618
7448
  fetch: input.fetch,
6619
7449
  signal: input.signal
6620
7450
  }))));
6621
- const [agentSkills, llmsTxt, routeManifest] = await Promise.all([
7451
+ const [agentSkills, llmsTxt, routeManifest, endpointDiscovery] = await Promise.all([
6622
7452
  inspectAgentSkills({
6623
7453
  ...input,
6624
7454
  origin
@@ -6633,6 +7463,10 @@ async function collectAgentDiscovery(input) {
6633
7463
  origin,
6634
7464
  pages,
6635
7465
  observations
7466
+ }),
7467
+ inspectAgentEndpoints({
7468
+ ...input,
7469
+ origin
6636
7470
  })
6637
7471
  ]);
6638
7472
  const qZero = await fetchRepresentation({
@@ -6658,10 +7492,13 @@ async function collectAgentDiscovery(input) {
6658
7492
  const wwwLocation = www.location ? new URL(www.location, wwwUrl).toString() : void 0;
6659
7493
  const qZeroContentType = qZero.observation.contentType;
6660
7494
  const advertisedPages = observations.filter((observation) => observation.htmlAlternateUnique).length;
6661
- const evaluatedPages = observations.filter((observation) => observation.explicit?.status !== void 0).length;
7495
+ const evaluatedPages = observations.filter((observation) => {
7496
+ const representation = observation.explicit ?? observation.negotiated;
7497
+ return representation?.status !== void 0 && representation.status >= 200 && representation.status < 300 && /^\s*text\/markdown\b/iu.test(representation.contentType ?? "");
7498
+ }).length;
6662
7499
  const warnings = [...pages.length !== input.pages.length ? [`Agent representations were evaluated for ${pages.length} successful HTML pages from ${input.pages.length} retained documents.`] : [], ...routeManifest.valid ? [] : ["No valid public agent route manifest was available for orphan checks."]].sort();
6663
7500
  const htmlContentSignals = pages.map((page) => headerValue$1(page.responseHeaders, "content-signal")).filter((value) => Boolean(value));
6664
- const markdownContentSignals = observations.map((observation) => observation.explicit?.contentSignal).filter((value) => Boolean(value));
7501
+ const markdownContentSignals = observations.map((observation) => observation.explicit?.contentSignal ?? observation.negotiated?.contentSignal).filter((value) => Boolean(value));
6665
7502
  const contentSignalValues = [.../* @__PURE__ */ new Set([...htmlContentSignals, ...markdownContentSignals])].sort();
6666
7503
  return {
6667
7504
  profile: "content",
@@ -6701,6 +7538,7 @@ async function collectAgentDiscovery(input) {
6701
7538
  routeManifest,
6702
7539
  agentSkills,
6703
7540
  llmsTxt,
7541
+ endpointDiscovery,
6704
7542
  contentSignals: {
6705
7543
  htmlValues: [...new Set(htmlContentSignals)].sort(),
6706
7544
  markdownValues: [...new Set(markdownContentSignals)].sort(),
@@ -6744,7 +7582,11 @@ function section$1(id, title, checks) {
6744
7582
  };
6745
7583
  }
6746
7584
  function failedMarkdownUrls(discovery) {
6747
- return discovery.markdownAlternates.pages.filter((page) => !page.htmlAlternateUnique || page.httpAlternateUrls.length !== 1 || page.explicit?.status !== 200 || !/^\s*text\/markdown\b/iu.test(page.explicit.contentType ?? "") || page.explicitMatchesNegotiated !== true || page.repeatedHashStable !== true || page.markdownCanonicalMatchesHtml !== true || page.explicit?.varyAccept !== true || page.negotiated?.varyAccept !== true).map((page) => page.htmlUrl);
7585
+ return discovery.markdownAlternates.pages.filter((page) => {
7586
+ const negotiatedOk = page.negotiated?.status === 200 && /^\s*text\/markdown\b/iu.test(page.negotiated.contentType ?? "") && page.negotiated.varyAccept;
7587
+ const explicitOk = !page.htmlAlternateUnique ? true : page.httpAlternateUrls.length === 1 && page.explicit?.status === 200 && /^\s*text\/markdown\b/iu.test(page.explicit.contentType ?? "") && page.explicitMatchesNegotiated === true && page.markdownCanonicalMatchesHtml === true;
7588
+ return !negotiatedOk || !explicitOk || page.repeatedHashStable !== true;
7589
+ }).map((page) => page.htmlUrl);
6748
7590
  }
6749
7591
  function qualityFailureUrls(discovery) {
6750
7592
  return discovery.markdownAlternates.pages.filter((page) => {
@@ -6786,20 +7628,25 @@ function markdownChecks(discovery) {
6786
7628
  const markdown = discovery.markdownAlternates;
6787
7629
  const failedUrls = failedMarkdownUrls(discovery);
6788
7630
  const qualityUrls = qualityFailureUrls(discovery);
6789
- const coverageComplete = markdown.eligibleHtmlPages > 0 && markdown.advertisedPages === markdown.eligibleHtmlPages && markdown.evaluatedPages === markdown.eligibleHtmlPages;
6790
- const bytesMatch = markdown.eligibleHtmlPages > 0 && markdown.exactByteMatches === markdown.eligibleHtmlPages;
7631
+ const negotiatedPages = markdown.pages.filter((page) => page.negotiated?.status === 200 && /^\s*text\/markdown\b/iu.test(page.negotiated.contentType ?? "")).length;
7632
+ const explicitPages = markdown.pages.filter((page) => page.htmlAlternateUnique).length;
7633
+ const coverageComplete = markdown.eligibleHtmlPages > 0 && markdown.evaluatedPages === markdown.eligibleHtmlPages;
7634
+ const negotiationComplete = markdown.eligibleHtmlPages > 0 && negotiatedPages === markdown.eligibleHtmlPages && markdown.pages.every((page) => page.negotiated?.varyAccept) && markdown.pages.every((page) => !page.htmlAlternateUnique || page.explicitMatchesNegotiated === true);
6791
7635
  const stable = markdown.eligibleHtmlPages > 0 && markdown.stableResponses === markdown.eligibleHtmlPages;
6792
- const tokenHeaders = markdown.pages.filter((page) => page.explicit?.bytes !== void 0 && page.explicit?.markdownTokens !== void 0 && page.explicit.markdownTokens === Math.ceil(page.explicit.bytes / 4) && page.explicit.markdownTokens === page.negotiated?.markdownTokens).length;
7636
+ const tokenHeaders = markdown.pages.filter((page) => {
7637
+ return (page.explicit ?? page.negotiated)?.markdownTokens !== void 0 && (!page.explicit || !page.negotiated || page.explicit.markdownTokens === page.negotiated.markdownTokens);
7638
+ }).length;
6793
7639
  return [
6794
7640
  check$1("representations", {
6795
7641
  id: "markdown-coverage",
6796
7642
  status: markdown.eligibleHtmlPages === 0 ? "unknown" : coverageComplete ? "pass" : "fail",
6797
- title: coverageComplete ? "Every successful HTML page advertises one Markdown alternative" : "Markdown alternative coverage is incomplete",
6798
- plainEnglish: `${markdown.advertisedPages} of ${markdown.eligibleHtmlPages} successful HTML pages advertised one Markdown alternative, and ${markdown.evaluatedPages} were fetched.`,
6799
- action: coverageComplete ? "Keep route generation tied to the same deterministic page manifest." : "Add one text/markdown alternate for each public HTML page and make every advertised URL return successfully.",
7643
+ title: coverageComplete ? "Every successful HTML page has a Markdown representation" : "Markdown representation coverage is incomplete",
7644
+ plainEnglish: `${markdown.evaluatedPages} of ${markdown.eligibleHtmlPages} successful HTML pages returned Markdown. ${explicitPages} advertised an explicit alternative and ${negotiatedPages} supported content negotiation.`,
7645
+ 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.",
6800
7646
  evidence: {
6801
7647
  eligibleHtmlPages: markdown.eligibleHtmlPages,
6802
7648
  advertisedPages: markdown.advertisedPages,
7649
+ negotiatedPages,
6803
7650
  evaluatedPages: markdown.evaluatedPages
6804
7651
  },
6805
7652
  urls: failedUrls.slice(0, 25)
@@ -6807,18 +7654,22 @@ function markdownChecks(discovery) {
6807
7654
  check$1("representations", {
6808
7655
  id: "markdown-token-estimates",
6809
7656
  status: markdown.evaluatedPages === 0 ? "unknown" : tokenHeaders === markdown.evaluatedPages ? "pass" : "warning",
6810
- title: tokenHeaders === markdown.evaluatedPages ? "Every Markdown response includes the same token estimate" : "Some Markdown responses are missing a stable token estimate",
6811
- plainEnglish: `${tokenHeaders} of ${markdown.evaluatedPages} evaluated pages returned matching X-Markdown-Tokens values on the explicit and negotiated responses. The value is a quick size estimate, not an exact model-specific token count.`,
6812
- action: "Generate the estimate from the stored Markdown bytes and return the same value for explicit and negotiated responses.",
6813
- urls: markdown.pages.filter((page) => page.explicit?.markdownTokens === void 0 || page.explicit.markdownTokens !== page.negotiated?.markdownTokens).map((page) => page.htmlUrl).slice(0, 25)
7657
+ title: tokenHeaders === markdown.evaluatedPages ? "Every Markdown response includes a token estimate" : "Some Markdown responses are missing a stable token estimate",
7658
+ 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.`,
7659
+ action: "Return a stable token estimate and keep it consistent when both explicit and negotiated responses exist.",
7660
+ urls: markdown.pages.filter((page) => {
7661
+ return (page.explicit ?? page.negotiated)?.markdownTokens === void 0 || page.explicit !== void 0 && page.negotiated !== void 0 && page.explicit.markdownTokens !== page.negotiated.markdownTokens;
7662
+ }).map((page) => page.htmlUrl).slice(0, 25)
6814
7663
  }),
6815
7664
  check$1("representations", {
6816
7665
  id: "markdown-negotiation",
6817
- status: markdown.eligibleHtmlPages === 0 ? "unknown" : bytesMatch && discovery.contentNegotiation.qZeroHonoured ? "pass" : "fail",
6818
- title: bytesMatch && discovery.contentNegotiation.qZeroHonoured ? "Explicit and negotiated Markdown use the same bytes" : "Content negotiation does not match the explicit Markdown contract",
6819
- plainEnglish: `${markdown.exactByteMatches} of ${markdown.eligibleHtmlPages} pages returned identical explicit and negotiated Markdown. A request that refuses Markdown ${discovery.contentNegotiation.qZeroHonoured ? "received HTML" : "did not produce confirmed HTML evidence"}.`,
6820
- action: "Use one stored Markdown artifact for both routes, honour Accept q-values, and send Vary: Accept on negotiated responses.",
7666
+ status: markdown.eligibleHtmlPages === 0 ? "unknown" : negotiationComplete && discovery.contentNegotiation.qZeroHonoured ? "pass" : "fail",
7667
+ title: negotiationComplete && discovery.contentNegotiation.qZeroHonoured ? "Markdown content negotiation works across the site" : "Markdown content negotiation needs attention",
7668
+ 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"}.`,
7669
+ action: "Honour Accept q-values, send Vary: Accept, and keep paired explicit and negotiated responses identical when both exist.",
6821
7670
  evidence: {
7671
+ negotiatedPages,
7672
+ explicitPages,
6822
7673
  exactByteMatches: markdown.exactByteMatches,
6823
7674
  qZero: discovery.contentNegotiation
6824
7675
  },
@@ -6828,8 +7679,8 @@ function markdownChecks(discovery) {
6828
7679
  id: "markdown-determinism",
6829
7680
  status: markdown.eligibleHtmlPages === 0 ? "unknown" : stable ? "pass" : "fail",
6830
7681
  title: stable ? "Repeated Markdown responses are stable" : "Repeated Markdown responses changed during the audit",
6831
- plainEnglish: `${markdown.stableResponses} of ${markdown.eligibleHtmlPages} repeated explicit responses kept the same SHA-256 digest.`,
6832
- action: "Generate Markdown during the build and remove runtime rewriting, timestamps, or random output.",
7682
+ plainEnglish: `${markdown.stableResponses} of ${markdown.eligibleHtmlPages} repeated Markdown responses kept the same SHA-256 digest.`,
7683
+ action: "Remove timestamps, random output, or other runtime rewriting that changes the same Markdown response between requests.",
6833
7684
  evidence: { stableResponses: markdown.stableResponses },
6834
7685
  urls: failedUrls.slice(0, 25)
6835
7686
  }),
@@ -6843,7 +7694,59 @@ function markdownChecks(discovery) {
6843
7694
  })
6844
7695
  ];
6845
7696
  }
6846
- function discoveryChecks(discovery) {
7697
+ function contentSignalsCheck(discovery, robotsContentSignals) {
7698
+ const signals = discovery.contentSignals;
7699
+ const robotsDeclared = robotsContentSignals ?? [];
7700
+ const robotsCollected = robotsContentSignals !== void 0;
7701
+ const headersObserved = signals.htmlValues.length > 0 || signals.markdownValues.length > 0;
7702
+ const headersConsistent = signals.consistent === true;
7703
+ const evidence = {
7704
+ robotsDirectives: robotsDeclared,
7705
+ robotsDirectivesCollected: robotsCollected,
7706
+ ...signals
7707
+ };
7708
+ if (headersConsistent) return check$1("discovery", {
7709
+ id: "content-signals",
7710
+ status: "pass",
7711
+ title: "Every response publishes one explicit Content Signals policy",
7712
+ plainEnglish: `The same ${signals.htmlValues[0]} policy was observed on every evaluated HTML and Markdown response${robotsDeclared.length ? ", and robots.txt declares a Content-Signal directive" : ""}.`,
7713
+ action: "Keep the same Content-Signal value on robots.txt, HTML, and Markdown responses when the policy changes.",
7714
+ evidence
7715
+ });
7716
+ if (robotsDeclared.length > 0 && !headersObserved) return check$1("discovery", {
7717
+ id: "content-signals",
7718
+ status: "pass",
7719
+ title: "robots.txt declares a Content Signals policy",
7720
+ plainEnglish: `robots.txt declares ${robotsDeclared.length === 1 ? "one Content-Signal directive" : `${robotsDeclared.length} Content-Signal directives`}. No response-header variant was observed, which is fine because robots.txt is the primary published location.`,
7721
+ action: "Keep the robots.txt directive as the single policy, or mirror the same value in response headers if you add them.",
7722
+ evidence
7723
+ });
7724
+ if (robotsDeclared.length > 0 || headersObserved) return check$1("discovery", {
7725
+ id: "content-signals",
7726
+ status: "warning",
7727
+ title: "Content Signals are published inconsistently",
7728
+ plainEnglish: `${signals.missingHtmlPages} HTML responses and ${signals.missingMarkdownPages} Markdown responses were missing the header, the observed values differed, or the header policy does not line up with robots.txt.`,
7729
+ action: "Choose the site policy deliberately and publish the same Content-Signal value everywhere it appears.",
7730
+ evidence
7731
+ });
7732
+ if (signals.consistent === null && !robotsCollected) return check$1("discovery", {
7733
+ id: "content-signals",
7734
+ status: "unknown",
7735
+ title: "Content Signals evidence was not collected",
7736
+ plainEnglish: "No pages were evaluated for the header and robots.txt directive evidence is unavailable in this crawl.",
7737
+ action: "Re-run the report with a URL so robots.txt and response headers can be checked.",
7738
+ evidence
7739
+ });
7740
+ return check$1("discovery", {
7741
+ id: "content-signals",
7742
+ status: "info",
7743
+ title: "No Content Signals policy is published",
7744
+ plainEnglish: "Neither robots.txt nor the evaluated responses declare a Content-Signal policy. Publishing one is optional.",
7745
+ action: "Add a Content-Signal directive to robots.txt only when you want to state an explicit search, ai-input, or ai-train preference.",
7746
+ evidence
7747
+ });
7748
+ }
7749
+ function discoveryChecks(discovery, robotsContentSignals) {
6847
7750
  const skills = discovery.agentSkills;
6848
7751
  const skillsValid = skills.validIndex && skills.skills.length > 0 && skills.skills.every((skill) => skill.sameOrigin && skill.status === 200 && /^\s*text\/markdown\b/iu.test(skill.contentType ?? "") && Boolean(skill.cors) && skill.digestMatches === true && skill.frontmatterValid === true) && Boolean(skills.cors);
6849
7752
  const llms = discovery.llmsTxt;
@@ -6887,14 +7790,7 @@ function discoveryChecks(discovery) {
6887
7790
  ...llms.missingCrawlRoutes
6888
7791
  ].slice(0, 25)
6889
7792
  }),
6890
- check$1("discovery", {
6891
- id: "content-signals",
6892
- status: discovery.contentSignals.consistent === null ? "unknown" : discovery.contentSignals.consistent ? "pass" : "warning",
6893
- title: discovery.contentSignals.consistent ? "HTML and Markdown publish one explicit Content Signals policy" : "Content Signals are missing or inconsistent across representations",
6894
- plainEnglish: discovery.contentSignals.consistent ? `The same ${discovery.contentSignals.htmlValues[0]} policy was observed on every evaluated HTML and Markdown response.` : `${discovery.contentSignals.missingHtmlPages} HTML responses and ${discovery.contentSignals.missingMarkdownPages} Markdown responses were missing the header, or the observed values differed.`,
6895
- action: "Choose the site policy deliberately and return the same Content-Signal value on HTML and Markdown responses.",
6896
- evidence: discovery.contentSignals
6897
- }),
7793
+ contentSignalsCheck(discovery, robotsContentSignals),
6898
7794
  check$1("discovery", {
6899
7795
  id: "route-manifest",
6900
7796
  status: !discovery.routeManifest.valid ? "info" : discovery.routeManifest.missingHtmlRoutes.length === 0 && discovery.routeManifest.missingMarkdownRoutes.length === 0 && discovery.routeManifest.orphanMarkdownRoutes.length === 0 ? "pass" : "warning",
@@ -6905,6 +7801,126 @@ function discoveryChecks(discovery) {
6905
7801
  })
6906
7802
  ];
6907
7803
  }
7804
+ function optionalEndpointCheck(endpoints, copy) {
7805
+ const found = endpoints.filter((endpoint) => endpoint.exists);
7806
+ const evidence = { endpoints };
7807
+ if (found.length === 0) return check$1("endpoints", {
7808
+ id: copy.id,
7809
+ status: "info",
7810
+ title: `${copy.subject} is not published`,
7811
+ plainEnglish: copy.absent,
7812
+ action: copy.absentAction,
7813
+ evidence
7814
+ });
7815
+ const valid = found.filter((endpoint) => endpoint.validJson !== false && (endpoint.missingFields ?? []).length === 0);
7816
+ if (valid.length > 0) return check$1("endpoints", {
7817
+ id: copy.id,
7818
+ status: "pass",
7819
+ title: `${copy.subject} is published and parses`,
7820
+ plainEnglish: `${valid[0]?.url} returned ${valid[0]?.contentType ?? "a response"} with the expected structure. ${copy.requiredNote}`,
7821
+ action: copy.presentAction,
7822
+ evidence,
7823
+ urls: valid.map((endpoint) => endpoint.url)
7824
+ });
7825
+ const broken = found[0];
7826
+ return check$1("endpoints", {
7827
+ id: copy.id,
7828
+ status: "warning",
7829
+ title: `${copy.subject} is published but needs review`,
7830
+ plainEnglish: `${broken?.url} responded, but ${broken?.validJson === false ? "the body is not valid JSON" : `required fields are missing: ${(broken?.missingFields ?? []).join(", ")}`}. ${copy.requiredNote}`,
7831
+ action: copy.presentAction,
7832
+ evidence,
7833
+ urls: found.map((endpoint) => endpoint.url)
7834
+ });
7835
+ }
7836
+ function endpointChecks(discovery) {
7837
+ const endpointDiscovery = discovery.endpointDiscovery;
7838
+ if (!endpointDiscovery) return [check$1("endpoints", {
7839
+ id: "agent-endpoints-evidence",
7840
+ status: "unknown",
7841
+ title: "Agent endpoint evidence was not collected",
7842
+ plainEnglish: "This saved crawl predates the Link header and well-known endpoint probes, so that evidence is unavailable.",
7843
+ action: "Run the agent-readiness report with a URL to collect endpoint evidence."
7844
+ })];
7845
+ const byId = new Map(endpointDiscovery.endpoints.map((endpoint) => [endpoint.id, endpoint]));
7846
+ const pick = (...ids) => ids.map((id) => byId.get(id)).filter((endpoint) => Boolean(endpoint));
7847
+ const linkHeader = endpointDiscovery.linkHeader;
7848
+ const advertisedRels = [...linkHeader.registeredRels, ...linkHeader.emergingRels];
7849
+ return [
7850
+ check$1("endpoints", {
7851
+ id: "link-headers",
7852
+ status: linkHeader.error ? "unknown" : advertisedRels.length > 0 ? "pass" : "info",
7853
+ title: linkHeader.error ? "Link header evidence was not collected" : advertisedRels.length > 0 ? "The start page Link header advertises agent resources" : linkHeader.entries.length > 0 ? "Link headers exist but advertise no agent resources" : "No Link header advertises agent resources",
7854
+ plainEnglish: linkHeader.error ? `The start page request failed: ${linkHeader.error}` : advertisedRels.length > 0 ? `${linkHeader.entries.length} Link header ${linkHeader.entries.length === 1 ? "entry was" : "entries were"} observed. Registered relation types: ${linkHeader.registeredRels.join(", ") || "none"}. Emerging relation types: ${linkHeader.emergingRels.join(", ") || "none"}.` : linkHeader.entries.length > 0 ? `${linkHeader.entries.length} Link header ${linkHeader.entries.length === 1 ? "entry was" : "entries were"} observed, but none used a recognised agent relation type such as api-catalog, service-desc, service-doc, describedby, llms-txt, or agent-skills.` : "The start page response includes no Link header. Advertising machine-readable resources this way is optional.",
7855
+ action: "Advertise real machine-readable resources with Link response headers. Use registered relation types such as api-catalog and service-desc, and emerging types such as llms-txt only when the target resolves.",
7856
+ evidence: linkHeader
7857
+ }),
7858
+ optionalEndpointCheck(pick("mcp-server-card", "mcp-server-cards"), {
7859
+ id: "mcp-server-card",
7860
+ subject: "An MCP server card",
7861
+ absent: "Neither /.well-known/mcp/server-card.json nor server-cards.json returned a machine-readable document. The server card format is a draft MCP proposal, and a content site does not need one.",
7862
+ absentAction: "Publish a server card only when the site exposes a real public MCP endpoint.",
7863
+ presentAction: "Keep serverInfo, the transport endpoint, and capabilities accurate for the deployed MCP server.",
7864
+ requiredNote: "The server card format is a draft MCP proposal, so this is a structural observation rather than a compliance verdict."
7865
+ }),
7866
+ optionalEndpointCheck(pick("a2a-agent-card"), {
7867
+ id: "a2a-agent-card",
7868
+ subject: "An A2A agent card",
7869
+ absent: "/.well-known/agent-card.json returned no machine-readable document. Agent cards matter only for sites that expose an agent-to-agent endpoint.",
7870
+ absentAction: "Publish an agent card only when the site hosts a real A2A endpoint.",
7871
+ presentAction: "Keep the agent card name, url, and capabilities aligned with the deployed endpoint.",
7872
+ requiredNote: "A2A is an emerging protocol, so this is a structural observation rather than a compliance verdict."
7873
+ }),
7874
+ optionalEndpointCheck(pick("openid-configuration", "oauth-authorization-server"), {
7875
+ id: "oauth-discovery",
7876
+ subject: "OAuth or OpenID Connect discovery metadata",
7877
+ absent: "No authorization-server metadata was found at /.well-known/openid-configuration or /.well-known/oauth-authorization-server. That only matters when the site exposes protected APIs that agents authenticate with.",
7878
+ absentAction: "Publish RFC 8414 or OpenID Connect discovery metadata only when a real authorization server issues tokens for this origin.",
7879
+ presentAction: "Keep the issuer and endpoint URLs accurate so clients can discover how to authenticate.",
7880
+ requiredNote: "Structural presence of issuer metadata does not prove the authorization flow works."
7881
+ }),
7882
+ optionalEndpointCheck(pick("oauth-protected-resource"), {
7883
+ id: "oauth-protected-resource",
7884
+ subject: "OAuth protected resource metadata",
7885
+ absent: "/.well-known/oauth-protected-resource returned no metadata. RFC 9728 metadata matters only for origins that serve OAuth-protected APIs.",
7886
+ absentAction: "Publish protected resource metadata only when this origin actually serves OAuth-protected APIs.",
7887
+ presentAction: "Keep the resource identifier and authorization_servers list accurate.",
7888
+ requiredNote: "Structural presence does not prove tokens are issued or accepted."
7889
+ }),
7890
+ optionalEndpointCheck(pick("api-catalog"), {
7891
+ id: "api-catalog",
7892
+ subject: "An API catalog",
7893
+ absent: "/.well-known/api-catalog returned no linkset document. An RFC 9727 catalog matters only for sites that publish APIs.",
7894
+ absentAction: "Publish an API catalog only when the site has real APIs.",
7895
+ presentAction: "Keep the linkset entries pointing at live service-desc and service-doc resources.",
7896
+ requiredNote: "The catalog was checked for structure only, not for whether each linked API works."
7897
+ }),
7898
+ optionalEndpointCheck(pick("web-bot-auth-directory"), {
7899
+ id: "web-bot-auth",
7900
+ subject: "A Web Bot Auth key directory",
7901
+ absent: "/.well-known/http-message-signatures-directory returned no key set. Publishing one matters only for operators that sign their own outbound bot requests.",
7902
+ absentAction: "Publish a signature directory only when this origin operates bots that sign requests with Web Bot Auth.",
7903
+ presentAction: "Keep the published keys current and rotate them safely.",
7904
+ requiredNote: "Web Bot Auth is an emerging draft, so this is a structural observation rather than a compliance verdict."
7905
+ }),
7906
+ optionalEndpointCheck(pick("auth-md"), {
7907
+ id: "auth-md",
7908
+ subject: "An auth.md registration guide",
7909
+ absent: "/auth.md returned no document. auth.md is an emerging proposal for telling agents how to register and authenticate, and most content sites do not need one.",
7910
+ absentAction: "Add auth.md only when agents can genuinely register for and authenticate with this site.",
7911
+ presentAction: "Keep the registration steps accurate and pair the file with real OAuth discovery metadata.",
7912
+ requiredNote: "auth.md is an emerging proposal, so this is a presence observation only."
7913
+ }),
7914
+ optionalEndpointCheck(pick("llms-full-txt"), {
7915
+ id: "llms-full-txt",
7916
+ subject: "llms-full.txt",
7917
+ absent: "/llms-full.txt returned no document. The expanded companion to llms.txt is optional, and its absence is not a search ranking problem.",
7918
+ absentAction: "Add llms-full.txt only when an intended consumer wants the full expanded content in one file.",
7919
+ presentAction: "Keep the file deterministic and consistent with the curated llms.txt entry points.",
7920
+ requiredNote: "Only presence and content type were checked, not the quality of the expanded content."
7921
+ })
7922
+ ];
7923
+ }
6908
7924
  function accessChecks(report, discovery) {
6909
7925
  const bots = report.ai?.robotsTxt?.botAccess ?? [];
6910
7926
  const blocked = bots.filter((bot) => bot.allowed === false);
@@ -6943,10 +7959,10 @@ function identityChecks(report) {
6943
7959
  const officialProfiles = [...new Set(report.pages.flatMap((page) => [...page.schemaSameAs ?? [], ...page.socialProfileLinks ?? []]))];
6944
7960
  return [check$1("identity", {
6945
7961
  id: "identity-graph",
6946
- status: hasSoftware && hasWebsite && hasCreator && pageTypes.length > 0 ? "pass" : "warning",
6947
- title: hasSoftware && hasWebsite && hasCreator && pageTypes.length > 0 ? "Structured identity covers the software, website, creator, and pages" : "The structured identity graph is missing a useful entity layer",
6948
- plainEnglish: `Software identity: ${hasSoftware ? "present" : "missing"}. Website identity: ${hasWebsite ? "present" : "missing"}. Creator or publisher identity: ${hasCreator ? "present" : "missing"}. Page-level types: ${pageTypes.length ? pageTypes.join(", ") : "missing"}.`,
6949
- action: "Connect truthful SoftwareApplication, WebSite, creator or publisher, and page nodes. Use sameAs only for official profiles that identify the same entity.",
7962
+ status: hasWebsite && hasCreator && pageTypes.length > 0 ? "pass" : "warning",
7963
+ title: hasWebsite && hasCreator && pageTypes.length > 0 ? "Structured identity covers the website, creator, and pages" : "The structured identity graph is missing a useful entity layer",
7964
+ plainEnglish: `Website identity: ${hasWebsite ? "present" : "missing"}. Creator or publisher identity: ${hasCreator ? "present" : "missing"}. Page-level types: ${pageTypes.length ? pageTypes.join(", ") : "missing"}. Software identity: ${hasSoftware ? "present" : "not observed and not required for a content site"}.`,
7965
+ action: "Connect truthful WebSite, creator or publisher, and page nodes. Add SoftwareApplication only when the site actually represents software. Use sameAs only for official profiles that identify the same entity.",
6950
7966
  evidence: {
6951
7967
  schemaTypes: [...types].sort(),
6952
7968
  officialProfiles: officialProfiles.sort()
@@ -7006,7 +8022,8 @@ function agentReadiness(report) {
7006
8022
  section$1("profile", "Profile scope", profileChecks(discovery)),
7007
8023
  section$1("access", "Crawler and protocol access", accessChecks(report, discovery)),
7008
8024
  section$1("representations", "HTML and Markdown representations", markdownChecks(discovery)),
7009
- section$1("discovery", "Agent discovery files", discoveryChecks(discovery)),
8025
+ section$1("discovery", "Agent discovery files", discoveryChecks(discovery, report.ai?.robotsTxt?.contentSignals)),
8026
+ section$1("endpoints", "Agent endpoints and auth discovery", endpointChecks(discovery)),
7010
8027
  section$1("identity", "Identity evidence", identityChecks(report))
7011
8028
  ];
7012
8029
  const checks = sections.flatMap((item) => item.checks);
@@ -7043,6 +8060,7 @@ function agentReadiness(report) {
7043
8060
  ...discovery.warnings,
7044
8061
  "A clean content profile does not prove indexing, rankings, AI mentions, citations, or selection.",
7045
8062
  "A crawler token being allowed does not prove that a service fetched or used the page.",
8063
+ "MCP server cards, A2A agent cards, Web Bot Auth directories, and auth.md are emerging conventions. Their absence is an observation, not a defect.",
7046
8064
  "API, application, and commerce checks were not applicable to this content-site run."
7047
8065
  ]
7048
8066
  };
@@ -7434,7 +8452,7 @@ function aiReadiness(report) {
7434
8452
  score: 0,
7435
8453
  evaluated: Boolean(agentDiscovery),
7436
8454
  title: agentDiscovery ? "Markdown alternatives were checked" : "Markdown alternatives were not evaluated",
7437
- plainEnglish: agentDiscovery ? `${agentDiscovery.markdownAlternates.advertisedPages} of ${agentDiscovery.markdownAlternates.eligibleHtmlPages} successful HTML pages advertised one Markdown alternative. ${agentDiscovery.markdownAlternates.exactByteMatches} explicit responses matched content negotiation byte for byte.` : "This crawl does not include the focused representation checks, so it cannot say whether HTML pages publish stable Markdown alternatives.",
8455
+ plainEnglish: agentDiscovery ? `${agentDiscovery.markdownAlternates.evaluatedPages} of ${agentDiscovery.markdownAlternates.eligibleHtmlPages} successful HTML pages returned Markdown through an explicit alternative or content negotiation. ${agentDiscovery.markdownAlternates.exactByteMatches} paired explicit responses matched content negotiation byte for byte.` : "This crawl does not include the focused representation checks, so it cannot say whether HTML pages publish stable Markdown alternatives.",
7438
8456
  action: "Use the focused agent-readiness report when you need route coverage, content negotiation, byte stability, or extraction-quality evidence.",
7439
8457
  evidence: agentDiscovery ? {
7440
8458
  eligibleHtmlPages: agentDiscovery.markdownAlternates.eligibleHtmlPages,
@@ -7644,6 +8662,15 @@ function originHost(url) {
7644
8662
  function cleanTitle(page) {
7645
8663
  return page.title ?? page.h1 ?? (new URL(page.finalUrl).pathname || page.finalUrl);
7646
8664
  }
8665
+ function truncateAtWord(value, maxLength) {
8666
+ const normalized = value.replace(/\s+/g, " ").trim();
8667
+ if (normalized.length <= maxLength) return normalized;
8668
+ const room = Math.max(1, maxLength - 3);
8669
+ const prefix = normalized.slice(0, room);
8670
+ const boundary = prefix.lastIndexOf(" ");
8671
+ if (boundary < 0) return "...";
8672
+ return `${prefix.slice(0, boundary).trimEnd()}...`;
8673
+ }
7647
8674
  function sectionForPage(page) {
7648
8675
  const path = new URL(page.finalUrl).pathname.toLowerCase();
7649
8676
  if (/\/(docs|documentation|guide|guides|help|kb|learn)\b/.test(path)) return "Docs and guides";
@@ -7776,7 +8803,7 @@ function generateLlmsTxt(report, options = {}) {
7776
8803
  let sectionCount = 0;
7777
8804
  for (const page of sectionPages) {
7778
8805
  const title = cleanTitle(page).replace(/\]/g, "\\]");
7779
- const note = page.metaDescription ? ` - ${page.metaDescription.replace(/\s+/g, " ").slice(0, 160)}` : "";
8806
+ const note = page.metaDescription ? ` - ${truncateAtWord(page.metaDescription, 160)}` : "";
7780
8807
  const line = `- [${title}](${page.finalUrl})${note}`;
7781
8808
  if (estimatedTokens([
7782
8809
  ...lines,
@@ -7794,14 +8821,29 @@ function generateLlmsTxt(report, options = {}) {
7794
8821
  counts[sectionName] = sectionCount;
7795
8822
  if (sectionCount < sectionPages.length) break;
7796
8823
  }
7797
- lines.push("## Notes", "", "- Optional metadata for agents and services that explicitly support llms.txt.", "- [Google Search guidance](https://developers.google.com/search/updates#clarifying-guidance-on-llms-txt-files) says llms.txt does not affect visibility or rankings.", `- Generated from crawl report ${report.id}.`, `- Crawl generated at ${report.generatedAt}.`);
8824
+ lines.push("## Notes", "", "- Optional metadata for agents and services that explicitly support llms.txt.", "- [Google Search guidance](https://developers.google.com/search/updates#clarifying-guidance-on-llms-txt-files) says llms.txt does not affect visibility or rankings.", `- Generated from crawl report ${report.id}.`, `- Crawl generated at ${report.generatedAt}.`, `- Crawl coverage: ${report.status}; retained ${report.summary.crawledUrls} of ${report.summary.discoveredUrls} discovered URLs.`);
8825
+ if (report.sitemapDiscovery) lines.push(`- Sitemap discovery: ${report.sitemapDiscovery.dataStatus}; returned ${report.sitemapDiscovery.urlsReturned} URLs.`);
7798
8826
  if (report.caveats.length) lines.push(...report.caveats.map((caveat) => `- Caveat: ${caveat}`));
8827
+ if (report.warnings.length) {
8828
+ lines.push(...report.warnings.slice(0, 10).map((warning) => `- Source warning: ${warning}`));
8829
+ if (report.warnings.length > 10) lines.push(`- Source warnings: ${report.warnings.length - 10} more warnings remain in crawl report ${report.id}.`);
8830
+ }
7799
8831
  const content = `${lines.join("\n")}\n`;
7800
8832
  return {
7801
8833
  content,
7802
8834
  includedUrls,
7803
8835
  estimatedTokens: estimatedTokens(content),
7804
- sections: counts
8836
+ sections: counts,
8837
+ source: {
8838
+ reportId: report.id,
8839
+ status: report.status,
8840
+ crawledUrls: report.summary.crawledUrls,
8841
+ discoveredUrls: report.summary.discoveredUrls,
8842
+ sitemapUrlsReturned: report.sitemapDiscovery?.urlsReturned,
8843
+ warnings: report.warnings.slice(0, 10),
8844
+ warningCount: report.warnings.length,
8845
+ warningsTruncated: report.warnings.length > 10
8846
+ }
7805
8847
  };
7806
8848
  }
7807
8849
  //#endregion
@@ -8413,6 +9455,24 @@ const RULE_DEFINITIONS = [
8413
9455
  impactIfIgnored: "The page cannot be crawled, ranked, or cited while the connection failure remains.",
8414
9456
  howToVerify: "Re-run the crawl and confirm the URL returns a normal HTTP status instead of status 0."
8415
9457
  },
9458
+ {
9459
+ id: "crawler_access_blocked",
9460
+ title: "Crawler access blocked",
9461
+ category: "response",
9462
+ defaultSeverity: "medium",
9463
+ whyItMatters: "The audit could not observe the intended page because an authentication layer, rate limit, firewall, or challenge intercepted this crawler. This limits the audit evidence but does not prove that search engines or users are blocked.",
9464
+ howToFix: "First confirm the block is unintended. If the audit should have access, use the provider evidence in the report to grant temporary, narrow access for the audit machine source IP, required hostname or paths, and the exact crawler User-Agent. Do not trust a User-Agent by itself.",
9465
+ impactIfIgnored: "The affected URL remains unverified in this crawl, so content and indexability checks cannot support a pass or an all-clear.",
9466
+ howToVerify: "Re-run the same health check and confirm access.blockedRequests is zero and the URL returns its intended status without challenge evidence.",
9467
+ agentHints: {
9468
+ evidenceFields: [
9469
+ "request.accessBlock",
9470
+ "report.access",
9471
+ "issue.evidence.accessBlock"
9472
+ ],
9473
+ suggestedCommands: ["seo crawl --sitemap-url <url> --health --json"]
9474
+ }
9475
+ },
8416
9476
  {
8417
9477
  id: "client_error",
8418
9478
  title: "Client error",
@@ -8901,6 +9961,7 @@ const RULE_RECOMMENDATIONS = {
8901
9961
  nofollow: "review",
8902
9962
  x_robots_noindex: "review",
8903
9963
  robots_blocked: "review",
9964
+ crawler_access_blocked: "review",
8904
9965
  orphan_page: "review",
8905
9966
  redirected_url: "review",
8906
9967
  slow_response: "review",
@@ -9119,6 +10180,14 @@ function auditCrawlRequests(requests) {
9119
10180
  continue;
9120
10181
  }
9121
10182
  if (request.outcome === "skipped") continue;
10183
+ if (request.accessBlock) {
10184
+ issues.push(requestIssue("crawler_access_blocked", request, request.accessBlock.guidance.summary, {
10185
+ requestedUrl: request.requestedUrl,
10186
+ finalUrl: request.finalUrl,
10187
+ accessBlock: request.accessBlock
10188
+ }));
10189
+ continue;
10190
+ }
9122
10191
  const redirectChain = request.redirectChain ?? [];
9123
10192
  if (redirectChain.length > 0 || !sameUrl(request.requestedUrl, request.finalUrl)) issues.push(requestIssue("redirected_url", request, request.finalUrl ? `Final URL: ${request.finalUrl}` : void 0, {
9124
10193
  requestedUrl: request.requestedUrl,
@@ -9154,6 +10223,7 @@ function auditCrawlPages(pages, opts = {}) {
9154
10223
  }));
9155
10224
  continue;
9156
10225
  }
10226
+ if (page.accessBlock) continue;
9157
10227
  if (page.status >= 500) {
9158
10228
  issues.push(issue("server_error", page, String(page.status), { status: page.status }));
9159
10229
  if ((page.internalInlinkCount ?? 0) > 0) issues.push(issue("broken_internal_link", page, String(page.status), {
@@ -9194,6 +10264,7 @@ function auditCrawlPages(pages, opts = {}) {
9194
10264
  responseTimeMs: page.responseTimeMs,
9195
10265
  thresholdMs: SLOW_RESPONSE_MS
9196
10266
  }));
10267
+ if (page.auditScope === "status") continue;
9197
10268
  if (redirected) continue;
9198
10269
  if (isHtmlPage(page) && (page.sizeBytes ?? 0) > LARGE_HTML_BYTES) issues.push(issue("large_html", page, `${Math.round((page.sizeBytes ?? 0) / 1024)} KB`, {
9199
10270
  sizeBytes: page.sizeBytes,
@@ -9429,6 +10500,9 @@ function crawlSkipReasonCountsFromRecord(input = {}) {
9429
10500
  }
9430
10501
  //#endregion
9431
10502
  //#region packages/core/dist/analyze/crawler/report.js
10503
+ const MAX_CRAWL_PAGES = 1e4;
10504
+ const MAX_CRAWL_CONCURRENCY = 16;
10505
+ const MAX_CRAWL_TIMEOUT_MS = 12e4;
9432
10506
  const SENSITIVE_FIELD_NAMES = /* @__PURE__ */ new Set([
9433
10507
  "authorization",
9434
10508
  "cookie",
@@ -9444,33 +10518,53 @@ const SENSITIVE_PARAM_PATTERN = /^(access_?token|auth|api_?key|client_?secret|co
9444
10518
  function uniqueSorted(values = []) {
9445
10519
  return [...new Set(values.filter(Boolean))].sort();
9446
10520
  }
9447
- function normalizeFetchRate(input) {
10521
+ function normalizeFetchRate(input, strategy) {
10522
+ const defaultConcurrency = strategy === "health" ? 4 : 8;
9448
10523
  return {
9449
- concurrency: input.fetchRate?.concurrency ?? input.concurrency ?? 8,
10524
+ concurrency: input.fetchRate?.concurrency ?? input.concurrency ?? defaultConcurrency,
9450
10525
  ...input.fetchRate?.intervalCap !== void 0 ? { intervalCap: input.fetchRate.intervalCap } : {},
9451
10526
  ...input.fetchRate?.intervalMs !== void 0 ? { intervalMs: input.fetchRate.intervalMs } : {},
9452
10527
  ...input.fetchRate?.backpressure ? { backpressure: input.fetchRate.backpressure } : {}
9453
10528
  };
9454
10529
  }
9455
10530
  function normalizeCrawlConfig(input) {
10531
+ const strategy = input.strategy ?? "full";
10532
+ const concurrency = input.concurrency ?? (strategy === "health" ? 4 : 8);
9456
10533
  return {
9457
10534
  url: new URL(input.url).toString(),
9458
- mode: input.mode ?? "site",
10535
+ mode: input.mode ?? (strategy === "health" ? "sitemap" : "site"),
10536
+ strategy,
10537
+ ...input.sitemapUrl ? { sitemapUrl: new URL(input.sitemapUrl).toString() } : {},
9459
10538
  urls: uniqueSorted(input.urls ?? []),
9460
10539
  maxPages: input.maxPages ?? 500,
9461
10540
  maxDepth: input.maxDepth ?? 16,
9462
- concurrency: input.concurrency ?? 8,
10541
+ concurrency,
9463
10542
  timeoutMs: input.timeoutMs ?? 2e4,
9464
10543
  include: uniqueSorted(input.include ?? []),
9465
10544
  exclude: uniqueSorted(input.exclude ?? []),
9466
10545
  respectRobots: input.respectRobots ?? true,
9467
10546
  useSitemap: input.useSitemap ?? true,
9468
- checkExternal: input.checkExternal ?? true,
9469
- checkAgentDiscovery: input.checkAgentDiscovery ?? false,
9470
- js: normalizeJavaScriptRenderingMode(input.js),
9471
- refresh: input.refresh ?? false,
9472
- fetchRate: normalizeFetchRate(input)
9473
- };
10547
+ checkExternal: strategy === "health" ? false : input.checkExternal ?? true,
10548
+ checkAgentDiscovery: strategy === "health" ? false : input.checkAgentDiscovery ?? false,
10549
+ js: strategy === "health" ? "off" : normalizeJavaScriptRenderingMode(input.js),
10550
+ refresh: strategy === "health" ? true : input.refresh ?? false,
10551
+ fetchRate: normalizeFetchRate(input, strategy)
10552
+ };
10553
+ }
10554
+ function assertIntegerRange(value, label, minimum, maximum) {
10555
+ if (!Number.isInteger(value) || value < minimum || value > maximum) throw new RangeError(`${label} must be an integer from ${minimum} to ${maximum}.`);
10556
+ }
10557
+ function assertCrawlConfigLimits(config) {
10558
+ if (config.strategy === "health" && config.mode !== "sitemap") throw new RangeError("The health strategy requires sitemap mode.");
10559
+ if (config.strategy === "health" && !config.useSitemap) throw new RangeError("The health strategy requires sitemap discovery.");
10560
+ assertIntegerRange(config.maxPages, "maxPages", 1, MAX_CRAWL_PAGES);
10561
+ assertIntegerRange(config.maxDepth, "maxDepth", 0, 64);
10562
+ assertIntegerRange(config.concurrency, "concurrency", 1, 16);
10563
+ assertIntegerRange(config.timeoutMs, "timeoutMs", 1, MAX_CRAWL_TIMEOUT_MS);
10564
+ if (config.fetchRate.concurrency !== void 0) assertIntegerRange(config.fetchRate.concurrency, "fetchRate.concurrency", 1, 16);
10565
+ if (config.strategy === "health" && config.mode !== "sitemap") throw new RangeError("Health strategy requires sitemap mode.");
10566
+ if (config.sitemapUrl && config.mode !== "sitemap") throw new RangeError("sitemapUrl requires sitemap mode.");
10567
+ return config;
9474
10568
  }
9475
10569
  function crawlConfigHash(config) {
9476
10570
  const normalized = normalizeCrawlConfig(config);
@@ -9587,10 +10681,12 @@ function summarizeCrawlReport(input) {
9587
10681
  }
9588
10682
  for (const issue of input.issues) byCategory[issue.category] = (byCategory[issue.category] ?? 0) + 1;
9589
10683
  const runStats = crawlRunStats(input.pages, input.stats);
10684
+ const indexabilityPages = input.pages.filter((page) => page.auditScope !== "status");
9590
10685
  return {
9591
10686
  totalPages: input.pages.length,
9592
- indexablePages: input.pages.filter((page) => page.indexable).length,
9593
- nonIndexablePages: input.pages.filter((page) => !page.indexable).length,
10687
+ statusOnlyPages: input.pages.filter((page) => page.auditScope === "status").length,
10688
+ indexablePages: indexabilityPages.filter((page) => page.indexable).length,
10689
+ nonIndexablePages: indexabilityPages.filter((page) => !page.indexable).length,
9594
10690
  statusErrors: input.pages.filter((page) => page.status === 0 || page.status >= 400).length,
9595
10691
  ...runStats,
9596
10692
  attemptedRequests: input.requestEvidenceStatus === "unavailable" ? 0 : requests.length,
@@ -9608,6 +10704,23 @@ function summarizeCrawlReport(input) {
9608
10704
  byCategory
9609
10705
  };
9610
10706
  }
10707
+ const ACCESS_BLOCK_SAMPLE_LIMIT = 10;
10708
+ function crawlAccessSummary(requests) {
10709
+ const blocked = requests.flatMap((request) => request.outcome === "response" && request.accessBlock ? [{
10710
+ url: request.requestedUrl,
10711
+ evidence: request.accessBlock
10712
+ }] : []);
10713
+ const providers = {};
10714
+ for (const item of blocked) providers[item.evidence.provider] = (providers[item.evidence.provider] ?? 0) + 1;
10715
+ return {
10716
+ crawler: SEO_CRAWLER_IDENTITY,
10717
+ blockedRequests: blocked.length,
10718
+ providers,
10719
+ samples: blocked.slice(0, ACCESS_BLOCK_SAMPLE_LIMIT),
10720
+ sampleLimit: ACCESS_BLOCK_SAMPLE_LIMIT,
10721
+ truncated: blocked.length > ACCESS_BLOCK_SAMPLE_LIMIT
10722
+ };
10723
+ }
9611
10724
  function crawlRunStats(pages, stats = {}) {
9612
10725
  const discovered = /* @__PURE__ */ new Set();
9613
10726
  let observedInternalLinks = 0;
@@ -9701,6 +10814,7 @@ function createCrawlReport(input) {
9701
10814
  status: input.status ?? "completed",
9702
10815
  configHash: crawlConfigHash(config),
9703
10816
  config,
10817
+ access: crawlAccessSummary(requests),
9704
10818
  summary: summarizeCrawlReport({
9705
10819
  pages,
9706
10820
  requests,
@@ -9723,16 +10837,19 @@ function createCrawlReport(input) {
9723
10837
  };
9724
10838
  }
9725
10839
  function normalizeLoadedCrawlReport(report) {
10840
+ const config = sanitizeCrawlConfig(normalizeCrawlConfig(report.config));
9726
10841
  const requestEvidenceStatus = report.requestEvidenceStatus ?? (Object.hasOwn(report, "requests") ? "available" : "unavailable");
9727
10842
  const issues = sanitizeIssues(report.issues ?? []);
9728
10843
  const requests = sortRequests(sanitizeTenantValue(report.requests ?? []) ?? []);
9729
10844
  const pages = sortPages(sanitizePages(deriveInternalLinkAuthority(report.pages ?? [])));
9730
10845
  return {
9731
10846
  ...sanitizeTenantValue(report),
10847
+ config,
10848
+ access: crawlAccessSummary(requests),
9732
10849
  requestEvidenceStatus,
9733
10850
  requests,
9734
10851
  definitionId: report.definitionId ?? crawlDefinitionId({
9735
- config: report.config,
10852
+ config,
9736
10853
  site: report.site,
9737
10854
  googleAnalyticsPropertyId: report.googleAnalyticsPropertyId
9738
10855
  }),
@@ -9788,14 +10905,61 @@ function reportFromJson(value) {
9788
10905
  if (parsed && typeof parsed === "object" && "kind" in parsed && parsed.kind === "seo.crawl_report" && "report" in parsed) return normalizeLoadedCrawlReport(parsed.report);
9789
10906
  return normalizeLoadedCrawlReport(parsed);
9790
10907
  }
9791
- function saveCrawlReportToSqlite(report) {
9792
- if (getDb().prepare(`INSERT INTO crawl_reports
9793
- (id, config_hash, site_url, url, status, total_pages, issue_count, created_at, report_json)
9794
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
9795
- ON CONFLICT(id) DO NOTHING`).run(report.id, report.configHash, report.site ?? null, report.config.url, report.status, report.summary.totalPages, report.issues.length, Date.parse(report.generatedAt), JSON.stringify(storageEnvelope(report))).changes === 0) {
9796
- const existing = getDb().prepare("SELECT * FROM crawl_reports WHERE id = ?").get(report.id);
10908
+ function pruneAutomaticBaselines() {
10909
+ getDb().prepare(`WITH ranked AS (
10910
+ SELECT id,
10911
+ ROW_NUMBER() OVER (
10912
+ PARTITION BY site_url
10913
+ ORDER BY created_at DESC, id DESC
10914
+ ) AS site_rank,
10915
+ ROW_NUMBER() OVER (
10916
+ ORDER BY created_at DESC, id DESC
10917
+ ) AS total_rank
10918
+ FROM crawl_reports
10919
+ WHERE retention_class = 'baseline'
10920
+ )
10921
+ DELETE FROM crawl_reports
10922
+ WHERE id IN (
10923
+ SELECT id FROM ranked
10924
+ WHERE site_rank > ? OR total_rank > ?
10925
+ )`).run(2, 50);
10926
+ }
10927
+ function crawlReportSiteWhere(value) {
10928
+ const siteValues = /* @__PURE__ */ new Set([value]);
10929
+ const origins = /* @__PURE__ */ new Set();
10930
+ try {
10931
+ origins.add(new URL(value).origin);
10932
+ } catch {
10933
+ const host = value.replace(/^sc-domain:/, "").replace(/\/$/, "");
10934
+ if (host && !host.includes("/")) {
10935
+ siteValues.add(`sc-domain:${host}`);
10936
+ origins.add(`https://${host}`);
10937
+ origins.add(`http://${host}`);
10938
+ }
10939
+ }
10940
+ const clauses = [...siteValues].map(() => "site_url = ?");
10941
+ const params = [...siteValues];
10942
+ for (const origin of origins) {
10943
+ clauses.push("url = ?", "url LIKE ?");
10944
+ params.push(`${origin}/`, `${origin}/%`);
10945
+ }
10946
+ return {
10947
+ sql: `(${clauses.join(" OR ")})`,
10948
+ params
10949
+ };
10950
+ }
10951
+ function saveCrawlReportToSqlite(report, options = {}) {
10952
+ const database = getDb();
10953
+ const retention = options.retention ?? "saved";
10954
+ if (database.prepare(`INSERT INTO crawl_reports
10955
+ (id, config_hash, site_url, url, status, total_pages, issue_count, created_at,
10956
+ retention_class, report_json)
10957
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
10958
+ ON CONFLICT(id) DO NOTHING`).run(report.id, report.configHash, report.site ?? null, report.config.url, report.status, report.summary.totalPages, report.issues.length, Date.parse(report.generatedAt), retention, JSON.stringify(storageEnvelope(report))).changes === 0) {
10959
+ const existing = database.prepare("SELECT * FROM crawl_reports WHERE id = ?").get(report.id);
9797
10960
  if (existing) return toMeta(existing);
9798
10961
  }
10962
+ if (retention === "baseline") pruneAutomaticBaselines();
9799
10963
  return {
9800
10964
  id: report.id,
9801
10965
  configHash: report.configHash,
@@ -9810,10 +10974,13 @@ function saveCrawlReportToSqlite(report) {
9810
10974
  }
9811
10975
  function listCrawlReportsFromSqlite(input = {}) {
9812
10976
  const limit = input.limit ?? 20;
9813
- return (input.site ? getDb().prepare(`SELECT * FROM crawl_reports
9814
- WHERE site_url = ?
9815
- ORDER BY created_at DESC, id DESC
9816
- LIMIT ?`).all(input.site, limit) : getDb().prepare(`SELECT * FROM crawl_reports
10977
+ return (input.site ? (() => {
10978
+ const where = crawlReportSiteWhere(input.site);
10979
+ return getDb().prepare(`SELECT * FROM crawl_reports
10980
+ WHERE ${where.sql}
10981
+ ORDER BY created_at DESC, id DESC
10982
+ LIMIT ?`).all(...where.params, limit);
10983
+ })() : getDb().prepare(`SELECT * FROM crawl_reports
9817
10984
  ORDER BY created_at DESC, id DESC
9818
10985
  LIMIT ?`).all(limit)).map(toMeta);
9819
10986
  }
@@ -9826,10 +10993,13 @@ function deleteCrawlReportFromSqlite(id) {
9826
10993
  return getDb().prepare("DELETE FROM crawl_reports WHERE id = ?").run(id).changes > 0;
9827
10994
  }
9828
10995
  function latestCrawlReportFromSqlite(site) {
9829
- const row = site ? getDb().prepare(`SELECT report_json FROM crawl_reports
9830
- WHERE site_url = ?
9831
- ORDER BY created_at DESC, id DESC
9832
- LIMIT 1`).get(site) : getDb().prepare(`SELECT report_json FROM crawl_reports
10996
+ const row = site ? (() => {
10997
+ const where = crawlReportSiteWhere(site);
10998
+ return getDb().prepare(`SELECT report_json FROM crawl_reports
10999
+ WHERE ${where.sql}
11000
+ ORDER BY created_at DESC, id DESC
11001
+ LIMIT 1`).get(...where.params);
11002
+ })() : getDb().prepare(`SELECT report_json FROM crawl_reports
9833
11003
  ORDER BY created_at DESC, id DESC
9834
11004
  LIMIT 1`).get();
9835
11005
  if (!row) return void 0;
@@ -9842,8 +11012,8 @@ const crawlReportStore = {
9842
11012
  delete: deleteCrawlReportFromSqlite,
9843
11013
  latest: latestCrawlReportFromSqlite
9844
11014
  };
9845
- function saveCrawlReport(report, store = crawlReportStore) {
9846
- return store.save(report);
11015
+ function saveCrawlReport(report, store = crawlReportStore, options = {}) {
11016
+ return store.save(report, options);
9847
11017
  }
9848
11018
  function listCrawlReports(input = {}, store = crawlReportStore) {
9849
11019
  return store.list(input);
@@ -9886,6 +11056,86 @@ function cancellationRace(signal) {
9886
11056
  });
9887
11057
  }
9888
11058
  //#endregion
11059
+ //#region packages/core/dist/analyze/crawler/crawl-url-queue.js
11060
+ function before(left, right) {
11061
+ return left.depth < right.depth || left.depth === right.depth && left.url < right.url;
11062
+ }
11063
+ var CrawlUrlQueue = class {
11064
+ #items = [];
11065
+ #positions = /* @__PURE__ */ new Map();
11066
+ get size() {
11067
+ return this.#items.length;
11068
+ }
11069
+ has(url) {
11070
+ return this.#positions.has(url);
11071
+ }
11072
+ push(item) {
11073
+ if (this.has(item.url)) throw new Error(`URL is already queued: ${item.url}`);
11074
+ const index = this.#items.length;
11075
+ this.#items.push(item);
11076
+ this.#positions.set(item.url, index);
11077
+ this.#bubbleUp(index);
11078
+ }
11079
+ decreaseDepth(url, depth) {
11080
+ const index = this.#positions.get(url);
11081
+ if (index === void 0) return false;
11082
+ const item = this.#items[index];
11083
+ if (!item || depth >= item.depth) return false;
11084
+ item.depth = depth;
11085
+ this.#bubbleUp(index);
11086
+ return true;
11087
+ }
11088
+ take() {
11089
+ const first = this.#items[0];
11090
+ if (!first) return void 0;
11091
+ const last = this.#items.pop();
11092
+ this.#positions.delete(first.url);
11093
+ if (last && last !== first) {
11094
+ this.#items[0] = last;
11095
+ this.#positions.set(last.url, 0);
11096
+ this.#bubbleDown(0);
11097
+ }
11098
+ return first;
11099
+ }
11100
+ #swap(left, right) {
11101
+ const leftItem = this.#items[left];
11102
+ const rightItem = this.#items[right];
11103
+ if (!leftItem || !rightItem) return;
11104
+ this.#items[left] = rightItem;
11105
+ this.#items[right] = leftItem;
11106
+ this.#positions.set(rightItem.url, left);
11107
+ this.#positions.set(leftItem.url, right);
11108
+ }
11109
+ #bubbleUp(start) {
11110
+ let index = start;
11111
+ while (index > 0) {
11112
+ const parent = Math.floor((index - 1) / 2);
11113
+ const item = this.#items[index];
11114
+ const parentItem = this.#items[parent];
11115
+ if (!item || !parentItem || !before(item, parentItem)) break;
11116
+ this.#swap(index, parent);
11117
+ index = parent;
11118
+ }
11119
+ }
11120
+ #bubbleDown(start) {
11121
+ let index = start;
11122
+ while (true) {
11123
+ const left = index * 2 + 1;
11124
+ const right = left + 1;
11125
+ let next = index;
11126
+ const item = this.#items[next];
11127
+ const leftItem = this.#items[left];
11128
+ const rightItem = this.#items[right];
11129
+ if (leftItem && item && before(leftItem, item)) next = left;
11130
+ const nextItem = this.#items[next];
11131
+ if (rightItem && nextItem && before(rightItem, nextItem)) next = right;
11132
+ if (next === index) return;
11133
+ this.#swap(index, next);
11134
+ index = next;
11135
+ }
11136
+ }
11137
+ };
11138
+ //#endregion
9889
11139
  //#region packages/core/dist/analyze/crawler/external-link-checks.js
9890
11140
  const EXTERNAL_LINK_SELECTION_LIMIT = 200;
9891
11141
  const EXTERNAL_LINK_STATES = [
@@ -10087,18 +11337,13 @@ function resolveFetchedAlias(url, aliases) {
10087
11337
  }
10088
11338
  return current;
10089
11339
  }
10090
- function resolveLinkGraphAliases(linkGraph, aliases) {
10091
- const resolved = {};
10092
- for (const [source, targets] of Object.entries(linkGraph)) {
10093
- const resolvedSource = resolveFetchedAlias(source, aliases);
10094
- let resolvedTargets = resolved[resolvedSource];
10095
- if (!resolvedTargets) {
10096
- resolvedTargets = /* @__PURE__ */ new Set();
10097
- resolved[resolvedSource] = resolvedTargets;
10098
- }
10099
- for (const target of targets) resolvedTargets.add(resolveFetchedAlias(target, aliases));
11340
+ function resolveLinkCountAliases(counts, aliases) {
11341
+ const resolved = /* @__PURE__ */ new Map();
11342
+ for (const [url, count] of counts) {
11343
+ const target = resolveFetchedAlias(url, aliases);
11344
+ resolved.set(target, (resolved.get(target) ?? 0) + count);
10100
11345
  }
10101
- return Object.fromEntries(Object.entries(resolved).map(([source, targets]) => [source, [...targets].sort((left, right) => left < right ? -1 : left > right ? 1 : 0)]));
11346
+ return resolved;
10102
11347
  }
10103
11348
  //#endregion
10104
11349
  //#region packages/core/dist/analyze/crawler/request-evidence.js
@@ -10110,7 +11355,9 @@ function observationFromPage(requestedUrl, page) {
10110
11355
  status: page.status,
10111
11356
  contentType: page.contentType,
10112
11357
  durationMs: page.responseTimeMs,
10113
- redirectChain: page.fetchDiagnostics?.redirectChain
11358
+ redirectChain: page.fetchDiagnostics?.redirectChain,
11359
+ accessBlock: page.accessBlock ?? page.fetchDiagnostics?.accessBlock,
11360
+ robotsTxt: page.robotsTxt
10114
11361
  };
10115
11362
  if (page.extractionStatus === "failed") return {
10116
11363
  ...observation,
@@ -10123,18 +11370,225 @@ function observationFromPage(requestedUrl, page) {
10123
11370
  };
10124
11371
  }
10125
11372
  //#endregion
10126
- //#region packages/core/dist/analyze/crawler/site-crawl-providers.js
10127
- function crawlMetricsWindow(now) {
10128
- return {
10129
- ...defaultDateRange(28, now),
10130
- days: 28
10131
- };
10132
- }
10133
- async function joinAnalytics(input) {
10134
- let analytics;
11373
+ //#region packages/core/dist/analyze/crawler/site-crawl-ai-signals.js
11374
+ const MAX_AUXILIARY_RESPONSE_BYTES = 2 * 1024 * 1024;
11375
+ const AI_BOT_USER_AGENTS = [
11376
+ "GPTBot",
11377
+ "OAI-SearchBot",
11378
+ "ChatGPT-User",
11379
+ "ClaudeBot",
11380
+ "Claude-User",
11381
+ "Claude-SearchBot",
11382
+ "PerplexityBot",
11383
+ "Perplexity-User",
11384
+ "Google-Extended",
11385
+ "CCBot",
11386
+ "Applebot",
11387
+ "Applebot-Extended",
11388
+ "Bingbot",
11389
+ "Amazonbot",
11390
+ "Bytespider",
11391
+ "Meta-ExternalAgent",
11392
+ "Meta-ExternalFetcher",
11393
+ "DuckAssistBot",
11394
+ "MistralAI-User",
11395
+ "Diffbot",
11396
+ "cohere-ai"
11397
+ ];
11398
+ const AGENT_RESOURCE_PATHS = [
11399
+ "/.well-known/agent.json",
11400
+ "/agent.json",
11401
+ "/.well-known/mcp.json",
11402
+ "/.well-known/ai-plugin.json",
11403
+ "/.well-known/openapi.json",
11404
+ "/openapi.json"
11405
+ ];
11406
+ async function checkLlmsTxt(input) {
11407
+ const llmsUrl = new URL("/llms.txt", input.url).toString();
11408
+ const controller = abortController({
11409
+ timeoutMs: input.timeoutMs,
11410
+ signal: input.signal
11411
+ });
10135
11412
  try {
10136
- analytics = await input.fetchLandingPageValues({
10137
- propertyId: input.propertyId,
11413
+ const response = await input.fetch(llmsUrl, {
11414
+ profile: "bot",
11415
+ redirect: "follow",
11416
+ signal: controller.signal
11417
+ });
11418
+ const contentType = response.headers.get("content-type") ?? "";
11419
+ const exists = response.status >= 200 && response.status < 300 && !/\btext\/html\b/i.test(contentType);
11420
+ await response.body?.cancel().catch(() => void 0);
11421
+ return {
11422
+ url: response.url || llmsUrl,
11423
+ exists,
11424
+ status: response.status
11425
+ };
11426
+ } catch {
11427
+ return {
11428
+ url: llmsUrl,
11429
+ exists: false
11430
+ };
11431
+ } finally {
11432
+ controller.cleanup();
11433
+ }
11434
+ }
11435
+ function parseRobots(robotsUrl, text) {
11436
+ return robotsParserModule(robotsUrl, text);
11437
+ }
11438
+ function declaredUserAgents(text) {
11439
+ const declared = /* @__PURE__ */ new Set();
11440
+ for (const match of text.matchAll(/^\s*user-agent\s*:\s*(.+?)\s*$/gim)) {
11441
+ const value = match[1]?.trim().toLowerCase();
11442
+ if (value) declared.add(value);
11443
+ }
11444
+ return declared;
11445
+ }
11446
+ function contentSignalsFromRobots(text) {
11447
+ const values = /* @__PURE__ */ new Set();
11448
+ for (const match of text.matchAll(/^\s*content-signal\s*:\s*(.+?)\s*$/gim)) {
11449
+ const value = match[1]?.trim();
11450
+ if (value) values.add(value);
11451
+ }
11452
+ return [...values].sort();
11453
+ }
11454
+ function sitemapUrlsFromRobots(text) {
11455
+ const urls = /* @__PURE__ */ new Set();
11456
+ for (const match of text.matchAll(/^\s*sitemap\s*:\s*(\S+)\s*$/gim)) {
11457
+ const value = match[1]?.trim();
11458
+ if (!value) continue;
11459
+ try {
11460
+ urls.add(new URL(value).toString());
11461
+ } catch {}
11462
+ }
11463
+ return [...urls];
11464
+ }
11465
+ async function checkRobotsAiAccess(input) {
11466
+ const robotsUrl = new URL("/robots.txt", input.url).toString();
11467
+ const controller = abortController({
11468
+ timeoutMs: input.timeoutMs,
11469
+ signal: input.signal
11470
+ });
11471
+ try {
11472
+ const response = await input.fetch(robotsUrl, {
11473
+ profile: "bot",
11474
+ redirect: "follow",
11475
+ signal: controller.signal
11476
+ });
11477
+ const text = await readBoundedResponseText(response, MAX_AUXILIARY_RESPONSE_BYTES, "robots.txt response");
11478
+ const available = response.status >= 200 && response.status < 300;
11479
+ const absent = response.status >= 400 && response.status < 500 && response.status !== 429;
11480
+ const availability = available ? "available" : [401, 403].includes(response.status) ? "access-blocked" : response.status === 429 ? "rate-limited" : absent ? "absent" : "unreachable";
11481
+ const exists = available;
11482
+ const declared = exists ? declaredUserAgents(text) : /* @__PURE__ */ new Set();
11483
+ const parsed = parseRobots(robotsUrl, exists ? text : "");
11484
+ return {
11485
+ url: response.url || robotsUrl,
11486
+ exists,
11487
+ availability,
11488
+ status: response.status,
11489
+ ...["rate-limited", "unreachable"].includes(availability) ? { error: `robots.txt returned HTTP ${response.status}.` } : {},
11490
+ sitemapUrls: exists ? sitemapUrlsFromRobots(text) : [],
11491
+ contentSignals: exists ? contentSignalsFromRobots(text) : [],
11492
+ botAccess: AI_BOT_USER_AGENTS.map((userAgent) => {
11493
+ const lower = userAgent.toLowerCase();
11494
+ return {
11495
+ userAgent,
11496
+ allowed: availability === "rate-limited" || availability === "unreachable" ? null : parsed.isAllowed(input.url, userAgent) ?? true,
11497
+ declared: declared.has(lower),
11498
+ coveredByWildcard: declared.has("*")
11499
+ };
11500
+ })
11501
+ };
11502
+ } catch (error) {
11503
+ return {
11504
+ url: robotsUrl,
11505
+ exists: false,
11506
+ availability: "unreachable",
11507
+ error: error instanceof Error ? error.message : String(error),
11508
+ sitemapUrls: [],
11509
+ botAccess: AI_BOT_USER_AGENTS.map((userAgent) => ({
11510
+ userAgent,
11511
+ allowed: null,
11512
+ declared: false,
11513
+ coveredByWildcard: false
11514
+ }))
11515
+ };
11516
+ } finally {
11517
+ controller.cleanup();
11518
+ }
11519
+ }
11520
+ async function checkAgentResource(input) {
11521
+ const url = new URL(input.path, input.baseUrl).toString();
11522
+ const controller = abortController({
11523
+ timeoutMs: input.timeoutMs,
11524
+ signal: input.signal
11525
+ });
11526
+ try {
11527
+ const response = await input.fetch(url, {
11528
+ profile: "bot",
11529
+ redirect: "follow",
11530
+ signal: controller.signal
11531
+ });
11532
+ const contentType = response.headers.get("content-type") ?? "";
11533
+ const text = await readBoundedResponseText(response, MAX_AUXILIARY_RESPONSE_BYTES, "Agent resource response");
11534
+ const exists = response.status >= 200 && response.status < 300;
11535
+ let validJson;
11536
+ if (exists && /\bjson\b/i.test(contentType)) try {
11537
+ JSON.parse(text);
11538
+ validJson = true;
11539
+ } catch {
11540
+ validJson = false;
11541
+ }
11542
+ return {
11543
+ url: response.url || url,
11544
+ exists,
11545
+ status: response.status,
11546
+ contentType,
11547
+ ...validJson === void 0 ? {} : { validJson }
11548
+ };
11549
+ } catch {
11550
+ return {
11551
+ url,
11552
+ exists: false
11553
+ };
11554
+ } finally {
11555
+ controller.cleanup();
11556
+ }
11557
+ }
11558
+ async function checkAgentResources(input) {
11559
+ const resources = [];
11560
+ for (const path of AGENT_RESOURCE_PATHS) {
11561
+ if (input.signal?.aborted) break;
11562
+ resources.push(await checkAgentResource({
11563
+ baseUrl: input.url,
11564
+ path,
11565
+ timeoutMs: input.timeoutMs,
11566
+ fetch: input.fetch,
11567
+ signal: input.signal
11568
+ }));
11569
+ }
11570
+ return resources;
11571
+ }
11572
+ function collectCrawlAiSignals(input) {
11573
+ return Promise.all([
11574
+ checkLlmsTxt(input),
11575
+ checkRobotsAiAccess(input),
11576
+ checkAgentResources(input)
11577
+ ]);
11578
+ }
11579
+ //#endregion
11580
+ //#region packages/core/dist/analyze/crawler/site-crawl-providers.js
11581
+ function crawlMetricsWindow(now) {
11582
+ return {
11583
+ ...defaultDateRange(28, now),
11584
+ days: 28
11585
+ };
11586
+ }
11587
+ async function joinAnalytics(input) {
11588
+ let analytics;
11589
+ try {
11590
+ analytics = await input.fetchLandingPageValues({
11591
+ propertyId: input.propertyId,
10138
11592
  startDate: input.window.startDate,
10139
11593
  endDate: input.window.endDate,
10140
11594
  limit: input.limit
@@ -10488,7 +11942,9 @@ function responseObservation(requestedUrl, fetched, extraction, extractionError)
10488
11942
  status: fetched.status,
10489
11943
  contentType: headerValue(fetched.headers, "content-type"),
10490
11944
  durationMs: fetched.diagnostics.durationMs,
10491
- redirectChain: fetched.diagnostics.redirectChain
11945
+ redirectChain: fetched.diagnostics.redirectChain,
11946
+ accessBlock: fetched.diagnostics.accessBlock,
11947
+ robotsTxt: fetched.robotsTxt
10492
11948
  };
10493
11949
  if (extraction === "failed") {
10494
11950
  if (!extractionError) throw new Error("Failed extraction observations require an error.");
@@ -10513,13 +11969,13 @@ function unextractedPage(fetched, contentType) {
10513
11969
  finalUrl: fetched.finalUrl,
10514
11970
  status: fetched.status,
10515
11971
  contentType,
10516
- responseHeaders: safeResponseHeaders(fetched.headers),
10517
11972
  responseTimeMs: fetched.diagnostics.durationMs,
10518
11973
  sizeBytes: Buffer.byteLength(fetched.html),
10519
11974
  usedJs: fetched.usedJs,
10520
11975
  fetchSource: fetched.diagnostics.source,
10521
11976
  cacheState: fetched.diagnostics.cache,
10522
11977
  fetchDiagnostics: fetched.diagnostics,
11978
+ accessBlock: fetched.diagnostics.accessBlock,
10523
11979
  blocked: fetched.diagnostics.blocked,
10524
11980
  robotsTxt: fetched.robotsTxt,
10525
11981
  xRobotsTag,
@@ -10540,6 +11996,35 @@ function unextractedPage(fetched, contentType) {
10540
11996
  compression: headerValue(fetched.headers, "content-encoding")
10541
11997
  };
10542
11998
  }
11999
+ function statusOnlyPage(fetched) {
12000
+ const contentType = headerValue(fetched.headers, "content-type");
12001
+ return {
12002
+ url: fetched.finalUrl,
12003
+ finalUrl: fetched.finalUrl,
12004
+ status: fetched.status,
12005
+ contentType,
12006
+ responseHeaders: safeResponseHeaders(fetched.headers),
12007
+ responseTimeMs: fetched.diagnostics.durationMs,
12008
+ usedJs: false,
12009
+ fetchSource: "network",
12010
+ cacheState: "bypass",
12011
+ fetchDiagnostics: fetched.diagnostics,
12012
+ auditScope: "status",
12013
+ accessBlock: fetched.diagnostics.accessBlock,
12014
+ blocked: fetched.diagnostics.blocked,
12015
+ contentAuditAllowed: false,
12016
+ robotsTxt: fetched.robotsTxt,
12017
+ indexable: false,
12018
+ indexability: "Not evaluated by status-only health probe",
12019
+ declaredIndexability: "unknown",
12020
+ extractionStatus: "not-applicable",
12021
+ wordCount: 0,
12022
+ contentHash: "",
12023
+ outgoingInternalCount: 0,
12024
+ outgoingExternalCount: 0,
12025
+ isHttps: new URL(fetched.finalUrl).protocol === "https:"
12026
+ };
12027
+ }
10543
12028
  function indexabilityReason(page) {
10544
12029
  if (page.status < 200 || page.status >= 300) return `Status ${page.status}`;
10545
12030
  if (hasMetaRobotsDirective(page.metaRobots, "noindex")) return "Meta robots noindex";
@@ -10593,13 +12078,26 @@ async function crawlOne(url, opts = {}) {
10593
12078
  };
10594
12079
  }
10595
12080
  const contentType = headerValue(fetched.headers, "content-type");
12081
+ if (fetched.diagnostics.accessBlock) {
12082
+ const page = unextractedPage(fetched, contentType);
12083
+ page.contentAuditAllowed = false;
12084
+ page.indexable = false;
12085
+ page.indexability = "Crawler access blocked";
12086
+ page.declaredIndexability = "unknown";
12087
+ page.extractionStatus = "not-applicable";
12088
+ return {
12089
+ request: responseObservation(url, fetched, "not-applicable"),
12090
+ page,
12091
+ urls: []
12092
+ };
12093
+ }
10596
12094
  if (!isHtmlContentType(contentType)) return {
10597
12095
  request: responseObservation(url, fetched, contentType ? "not-applicable" : "unknown-media-type"),
10598
12096
  page: unextractedPage(fetched, contentType),
10599
12097
  urls: []
10600
12098
  };
10601
12099
  try {
10602
- const extracted = await extractPage(fetched);
12100
+ const extracted = await extractPage(fetched, "crawler");
10603
12101
  const base = new URL(extracted.finalUrl);
10604
12102
  const internalLinks = extracted.links.map((link) => sameOriginUrl(link.href, base)).filter((value) => Boolean(value));
10605
12103
  const externalLinks = extracted.links.filter((link) => !link.internal).map((link) => link.href).filter((href) => /^https?:\/\//.test(href));
@@ -10720,6 +12218,62 @@ async function crawlOne(url, opts = {}) {
10720
12218
  };
10721
12219
  }
10722
12220
  }
12221
+ async function crawlStatusOnly(url, opts = {}) {
12222
+ const startedAt = Date.now();
12223
+ try {
12224
+ const fetched = await fetchPageStatus(url, {
12225
+ ...opts,
12226
+ js: "off",
12227
+ refresh: true
12228
+ });
12229
+ return {
12230
+ request: responseObservation(url, fetched, "not-applicable"),
12231
+ page: statusOnlyPage(fetched),
12232
+ urls: []
12233
+ };
12234
+ } catch (error) {
12235
+ if (error instanceof OriginBackpressureError) return {
12236
+ request: {
12237
+ requestedUrl: url,
12238
+ outcome: "skipped",
12239
+ reason: "origin-backpressure",
12240
+ error: error.message,
12241
+ extraction: "not-applicable"
12242
+ },
12243
+ urls: []
12244
+ };
12245
+ if (error instanceof RobotsAccessError) return {
12246
+ request: {
12247
+ requestedUrl: url,
12248
+ outcome: "skipped",
12249
+ reason: error.reason,
12250
+ robotsTxt: {
12251
+ url: error.evidence.url,
12252
+ allowed: error.evidence.allowed,
12253
+ availability: error.evidence.availability,
12254
+ status: error.evidence.status,
12255
+ error: error.evidence.error,
12256
+ matchedLine: error.evidence.matchedLine
12257
+ },
12258
+ extraction: "not-applicable"
12259
+ },
12260
+ urls: []
12261
+ };
12262
+ const message = error instanceof Error ? error.message : String(error);
12263
+ return {
12264
+ request: {
12265
+ requestedUrl: url,
12266
+ outcome: "failure",
12267
+ durationMs: Date.now() - startedAt,
12268
+ failureKind: fetchFailureKind(error, opts.signal),
12269
+ error: message,
12270
+ extraction: "not-applicable"
12271
+ },
12272
+ urls: [],
12273
+ warning: `${url}: ${message}`
12274
+ };
12275
+ }
12276
+ }
10723
12277
  //#endregion
10724
12278
  //#region packages/core/dist/analyze/monitoring/sitemaps.js
10725
12279
  const INVALID_LOC_SAMPLE_LIMIT = 10;
@@ -10962,6 +12516,7 @@ async function fetchSitemapUrls(input) {
10962
12516
  const nestedSitemaps = [];
10963
12517
  const discoveredSitemaps = /* @__PURE__ */ new Set();
10964
12518
  const scheduledSitemaps = /* @__PURE__ */ new Set();
12519
+ const fetchedSitemaps = /* @__PURE__ */ new Set();
10965
12520
  const invalidLocSamples = [];
10966
12521
  const malformedLastmodSamples = [];
10967
12522
  const futureLastmodSamples = [];
@@ -11008,12 +12563,18 @@ async function fetchSitemapUrls(input) {
11008
12563
  const sitemapUrl = queue.shift();
11009
12564
  if (!sitemapUrl) continue;
11010
12565
  const fetched = await fetchSitemapXml(sitemapUrl);
12566
+ fetchedSitemaps.add(sitemapUrl);
11011
12567
  documents.push(fetched.document);
11012
12568
  if (fetched.document.warning) warnings.push(fetched.document.warning);
11013
12569
  if (fetched.document.dataStatus !== "unavailable") sitemapsFetched += 1;
11014
12570
  if (!fetched.xml || !fetched.document.root) continue;
11015
12571
  const $ = load(fetched.xml, { xmlMode: true });
11016
12572
  $(fetched.document.root === "urlset" ? "urlset > url" : "").each((_, element) => {
12573
+ if (urls.length >= limit) {
12574
+ urlLimitExceeded = true;
12575
+ omittedUrlsAtLeast += 1;
12576
+ return false;
12577
+ }
11017
12578
  urlLocs += 1;
11018
12579
  const entry = $(element);
11019
12580
  const value = entry.children("loc").first().text().trim();
@@ -11038,12 +12599,7 @@ async function fetchSitemapUrls(input) {
11038
12599
  return;
11039
12600
  }
11040
12601
  seenUrls.add(url);
11041
- if (urls.length < limit) {
11042
- urls.push(url);
11043
- return;
11044
- }
11045
- urlLimitExceeded = true;
11046
- omittedUrlsAtLeast += 1;
12602
+ urls.push(url);
11047
12603
  });
11048
12604
  $(fetched.document.root === "sitemapindex" ? "sitemapindex > sitemap" : "").each((_, element) => {
11049
12605
  sitemapLocs += 1;
@@ -11076,8 +12632,13 @@ async function fetchSitemapUrls(input) {
11076
12632
  queue.push(child);
11077
12633
  } else nestedSitemapLimitExceeded = true;
11078
12634
  });
12635
+ if (urls.length >= limit && queue.length > 0) {
12636
+ urlLimitExceeded = true;
12637
+ omittedUrlsAtLeast = Math.max(omittedUrlsAtLeast, 1);
12638
+ break;
12639
+ }
11079
12640
  }
11080
- const unprocessedSitemaps = nestedSitemaps.filter((sitemap) => !scheduledSitemaps.has(sitemap)).length;
12641
+ const unprocessedSitemaps = nestedSitemaps.filter((sitemap) => !fetchedSitemaps.has(sitemap)).length;
11081
12642
  const possiblyTruncated = urlLimitExceeded || nestedSitemapLimitExceeded || unprocessedSitemaps > 0;
11082
12643
  if (invalidLocCount) warnings.push(`Ignored ${invalidLocCount} invalid sitemap <loc> ${invalidLocCount === 1 ? "entry" : "entries"}.`);
11083
12644
  if (possiblyTruncated) warnings.push("Sitemap discovery exceeded a configured URL or sitemap boundary; the returned inventory is incomplete.");
@@ -11257,6 +12818,7 @@ function resolveCrawlSiteDependencies(dependencies = {}) {
11257
12818
  const emptyPageTopQuery = async () => void 0;
11258
12819
  return {
11259
12820
  fetchPage: dependencies.fetchPage ?? crawlOne,
12821
+ fetchStatusPage: dependencies.fetchStatusPage ?? crawlStatusOnly,
11260
12822
  fetchSitemapUrls: dependencies.fetchSitemapUrls ?? fetchSitemapUrls,
11261
12823
  fetch: dependencies.fetch ?? publicHttpFetch,
11262
12824
  queryPageMetrics: (injectedMode === "per-page" ? dependencies.queryPageMetrics : void 0) ?? (injectedMode ? emptyPageMetrics : queryPageMetrics),
@@ -11282,6 +12844,10 @@ function isSameOrigin(url, origin) {
11282
12844
  }
11283
12845
  }
11284
12846
  function sitemapRootUrls(input) {
12847
+ if (input.sitemapUrl) return [{
12848
+ url: new URL(input.sitemapUrl).toString(),
12849
+ source: "explicit"
12850
+ }];
11285
12851
  const origin = new URL(input.url).origin;
11286
12852
  const declared = input.declaredSitemapUrls.filter((url) => isSameOrigin(url, origin));
11287
12853
  const ignored = input.declaredSitemapUrls.length - declared.length;
@@ -11358,25 +12924,6 @@ async function sitemapSeeds(input) {
11358
12924
  }
11359
12925
  //#endregion
11360
12926
  //#region packages/core/dist/analyze/crawler/site-crawl.js
11361
- const AI_BOT_USER_AGENTS = [
11362
- "GPTBot",
11363
- "OAI-SearchBot",
11364
- "ChatGPT-User",
11365
- "ClaudeBot",
11366
- "PerplexityBot",
11367
- "Google-Extended",
11368
- "CCBot",
11369
- "Applebot",
11370
- "Bingbot"
11371
- ];
11372
- const AGENT_RESOURCE_PATHS = [
11373
- "/.well-known/agent.json",
11374
- "/agent.json",
11375
- "/.well-known/mcp.json",
11376
- "/.well-known/ai-plugin.json",
11377
- "/.well-known/openapi.json",
11378
- "/openapi.json"
11379
- ];
11380
12927
  function normalizeUrl$1(value, base) {
11381
12928
  try {
11382
12929
  const url = base ? new URL(value, base) : new URL(value);
@@ -11400,179 +12947,22 @@ function isLikelyAsset(url) {
11400
12947
  }
11401
12948
  function globMatch(pattern, value) {
11402
12949
  if (!pattern.includes("*")) return value.includes(pattern);
11403
- const parts = pattern.split("*");
11404
- let position = 0;
11405
- for (const part of parts) {
11406
- if (!part) continue;
11407
- const found = value.slice(position).indexOf(part);
11408
- if (found === -1) return false;
11409
- position += found + part.length;
11410
- }
11411
- return true;
11412
- }
11413
- function passesFilters(url, include, exclude) {
11414
- return (!include.length || include.some((pattern) => globMatch(pattern, url))) && !exclude.some((pattern) => globMatch(pattern, url));
11415
- }
11416
- async function checkLlmsTxt(input) {
11417
- const llmsUrl = new URL("/llms.txt", input.url).toString();
11418
- const controller = abortController({
11419
- timeoutMs: input.timeoutMs,
11420
- signal: input.signal
11421
- });
11422
- try {
11423
- const response = await input.fetch(llmsUrl, {
11424
- profile: "bot",
11425
- redirect: "follow",
11426
- signal: controller.signal
11427
- });
11428
- const contentType = response.headers.get("content-type") ?? "";
11429
- const exists = response.status >= 200 && response.status < 300 && !/\btext\/html\b/i.test(contentType);
11430
- await response.body?.cancel().catch(() => void 0);
11431
- return {
11432
- url: response.url || llmsUrl,
11433
- exists,
11434
- status: response.status
11435
- };
11436
- } catch {
11437
- return {
11438
- url: llmsUrl,
11439
- exists: false
11440
- };
11441
- } finally {
11442
- controller.cleanup();
11443
- }
11444
- }
11445
- function parseRobots(robotsUrl, text) {
11446
- return robotsParserModule(robotsUrl, text);
11447
- }
11448
- function declaredUserAgents(text) {
11449
- const declared = /* @__PURE__ */ new Set();
11450
- for (const match of text.matchAll(/^\s*user-agent\s*:\s*(.+?)\s*$/gim)) {
11451
- const value = match[1]?.trim().toLowerCase();
11452
- if (value) declared.add(value);
11453
- }
11454
- return declared;
11455
- }
11456
- function sitemapUrlsFromRobots(text) {
11457
- const urls = /* @__PURE__ */ new Set();
11458
- for (const match of text.matchAll(/^\s*sitemap\s*:\s*(\S+)\s*$/gim)) {
11459
- const value = match[1]?.trim();
11460
- if (!value) continue;
11461
- try {
11462
- urls.add(new URL(value).toString());
11463
- } catch {}
11464
- }
11465
- return [...urls];
11466
- }
11467
- async function checkRobotsAiAccess(input) {
11468
- const robotsUrl = new URL("/robots.txt", input.url).toString();
11469
- const controller = abortController({
11470
- timeoutMs: input.timeoutMs,
11471
- signal: input.signal
11472
- });
11473
- try {
11474
- const response = await input.fetch(robotsUrl, {
11475
- profile: "bot",
11476
- redirect: "follow",
11477
- signal: controller.signal
11478
- });
11479
- const text = await response.text();
11480
- const available = response.status >= 200 && response.status < 300;
11481
- const absent = response.status >= 400 && response.status < 500 && response.status !== 429;
11482
- const availability = available ? "available" : [401, 403].includes(response.status) ? "access-blocked" : response.status === 429 ? "rate-limited" : absent ? "absent" : "unreachable";
11483
- const exists = available;
11484
- const declared = exists ? declaredUserAgents(text) : /* @__PURE__ */ new Set();
11485
- const parsed = parseRobots(robotsUrl, exists ? text : "");
11486
- return {
11487
- url: response.url || robotsUrl,
11488
- exists,
11489
- availability,
11490
- status: response.status,
11491
- ...["rate-limited", "unreachable"].includes(availability) ? { error: `robots.txt returned HTTP ${response.status}.` } : {},
11492
- sitemapUrls: exists ? sitemapUrlsFromRobots(text) : [],
11493
- botAccess: AI_BOT_USER_AGENTS.map((userAgent) => {
11494
- const lower = userAgent.toLowerCase();
11495
- return {
11496
- userAgent,
11497
- allowed: availability === "rate-limited" || availability === "unreachable" ? null : parsed.isAllowed(input.url, userAgent) ?? true,
11498
- declared: declared.has(lower),
11499
- coveredByWildcard: declared.has("*")
11500
- };
11501
- })
11502
- };
11503
- } catch (error) {
11504
- return {
11505
- url: robotsUrl,
11506
- exists: false,
11507
- availability: "unreachable",
11508
- error: error instanceof Error ? error.message : String(error),
11509
- sitemapUrls: [],
11510
- botAccess: AI_BOT_USER_AGENTS.map((userAgent) => ({
11511
- userAgent,
11512
- allowed: null,
11513
- declared: false,
11514
- coveredByWildcard: false
11515
- }))
11516
- };
11517
- } finally {
11518
- controller.cleanup();
11519
- }
11520
- }
11521
- async function checkAgentResource(input) {
11522
- const url = new URL(input.path, input.baseUrl).toString();
11523
- const controller = abortController({
11524
- timeoutMs: input.timeoutMs,
11525
- signal: input.signal
11526
- });
11527
- try {
11528
- const response = await input.fetch(url, {
11529
- profile: "bot",
11530
- redirect: "follow",
11531
- signal: controller.signal
11532
- });
11533
- const contentType = response.headers.get("content-type") ?? "";
11534
- const text = await response.text();
11535
- const exists = response.status >= 200 && response.status < 300;
11536
- let validJson;
11537
- if (exists && /\bjson\b/i.test(contentType)) try {
11538
- JSON.parse(text);
11539
- validJson = true;
11540
- } catch {
11541
- validJson = false;
11542
- }
11543
- return {
11544
- url: response.url || url,
11545
- exists,
11546
- status: response.status,
11547
- contentType,
11548
- ...validJson === void 0 ? {} : { validJson }
11549
- };
11550
- } catch {
11551
- return {
11552
- url,
11553
- exists: false
11554
- };
11555
- } finally {
11556
- controller.cleanup();
11557
- }
11558
- }
11559
- async function checkAgentResources(input) {
11560
- const resources = [];
11561
- for (const path of AGENT_RESOURCE_PATHS) {
11562
- if (input.signal?.aborted) break;
11563
- resources.push(await checkAgentResource({
11564
- baseUrl: input.url,
11565
- path,
11566
- timeoutMs: input.timeoutMs,
11567
- fetch: input.fetch,
11568
- signal: input.signal
11569
- }));
12950
+ const parts = pattern.split("*");
12951
+ let position = 0;
12952
+ for (const part of parts) {
12953
+ if (!part) continue;
12954
+ const found = value.slice(position).indexOf(part);
12955
+ if (found === -1) return false;
12956
+ position += found + part.length;
11570
12957
  }
11571
- return resources;
12958
+ return true;
12959
+ }
12960
+ function passesFilters(url, include, exclude) {
12961
+ return (!include.length || include.some((pattern) => globMatch(pattern, url))) && !exclude.some((pattern) => globMatch(pattern, url));
11572
12962
  }
11573
12963
  async function crawlSite(input, dependencies) {
11574
12964
  const deps = resolveCrawlSiteDependencies(dependencies);
11575
- const config = normalizeCrawlConfig(input);
12965
+ const config = assertCrawlConfigLimits(normalizeCrawlConfig(input));
11576
12966
  const { searchMetricsLimit, analyticsLimit } = crawlProviderLimits(input);
11577
12967
  const signal = input.signal;
11578
12968
  let cancelled = Boolean(signal?.aborted);
@@ -11580,17 +12970,19 @@ async function crawlSite(input, dependencies) {
11580
12970
  const site = input.site;
11581
12971
  const origin = new URL(config.url).origin;
11582
12972
  const warnings = [];
11583
- const queue = [];
12973
+ const queue = new CrawlUrlQueue();
11584
12974
  const visited = /* @__PURE__ */ new Set();
11585
- const queued = /* @__PURE__ */ new Set();
11586
12975
  const discovered = /* @__PURE__ */ new Set();
11587
- const linkGraph = {};
12976
+ const incomingLinkCounts = /* @__PURE__ */ new Map();
11588
12977
  const fetchedAliases = /* @__PURE__ */ new Map();
11589
12978
  const pages = [];
11590
12979
  const requests = [];
11591
12980
  const documentIndexes = /* @__PURE__ */ new Map();
11592
12981
  const inFlight = /* @__PURE__ */ new Set();
11593
12982
  const followLinks = config.mode === "site";
12983
+ const healthStrategy = config.strategy === "health";
12984
+ let activeConcurrency = healthStrategy ? 1 : config.concurrency;
12985
+ let healthyProbeStreak = 0;
11594
12986
  let queuedUrls = 0;
11595
12987
  let skippedUrls = 0;
11596
12988
  const skipReasonCounts = {};
@@ -11614,7 +13006,7 @@ async function crawlSite(input, dependencies) {
11614
13006
  generatedAt: deps.now().toISOString(),
11615
13007
  discoveredUrls: discovered.size,
11616
13008
  queuedUrls,
11617
- pendingUrls: queue.length,
13009
+ pendingUrls: queue.size,
11618
13010
  inFlightUrls: inFlight.size,
11619
13011
  crawledUrls: pages.length,
11620
13012
  skippedUrls,
@@ -11628,36 +13020,27 @@ async function crawlSite(input, dependencies) {
11628
13020
  const flushStatusEvents = async () => {
11629
13021
  await statusEventChain;
11630
13022
  };
11631
- const renderer = config.js === "off" ? void 0 : createPageRenderer({ concurrency: config.concurrency });
13023
+ const renderer = config.js === "off" || healthStrategy ? void 0 : createPageRenderer({ concurrency: config.concurrency });
13024
+ const robotsSession = healthStrategy ? createRobotsSession({
13025
+ refresh: true,
13026
+ writeCache: false,
13027
+ timeoutMs: config.timeoutMs,
13028
+ signal
13029
+ }) : void 0;
13030
+ const robotsResolver = robotsSession?.resolve;
11632
13031
  try {
11633
13032
  emitStatus("started", {
11634
13033
  url: config.url,
11635
13034
  message: `Started crawl for ${config.url}.`
11636
13035
  });
11637
- const aiSignals = isCancelled() ? void 0 : await Promise.all([
11638
- checkLlmsTxt({
11639
- url: config.url,
11640
- timeoutMs: config.timeoutMs,
11641
- fetch: deps.fetch,
11642
- signal
11643
- }),
11644
- checkRobotsAiAccess({
11645
- url: config.url,
11646
- timeoutMs: config.timeoutMs,
11647
- fetch: deps.fetch,
11648
- signal
11649
- }),
11650
- checkAgentResources({
11651
- url: config.url,
11652
- timeoutMs: config.timeoutMs,
11653
- fetch: deps.fetch,
11654
- signal
11655
- })
11656
- ]);
11657
- const llmsTxt = aiSignals?.[0] ?? {
11658
- url: new URL("/llms.txt", config.url).toString(),
11659
- exists: false
11660
- };
13036
+ const aiSignals = isCancelled() || healthStrategy ? void 0 : await collectCrawlAiSignals({
13037
+ url: config.url,
13038
+ timeoutMs: config.timeoutMs,
13039
+ fetch: deps.fetch,
13040
+ signal
13041
+ });
13042
+ const llmsTxt = aiSignals?.[0];
13043
+ const healthSitemapUrls = healthStrategy && !config.sitemapUrl && !isCancelled() ? await robotsSession?.sitemapUrls(origin) : void 0;
11661
13044
  const robotsTxt = aiSignals?.[1];
11662
13045
  const agentResources = aiSignals?.[2];
11663
13046
  cancelled = isCancelled();
@@ -11672,12 +13055,9 @@ async function crawlSite(input, dependencies) {
11672
13055
  });
11673
13056
  return;
11674
13057
  }
11675
- discovered.add(normalized);
11676
13058
  if (visited.has(normalized)) return;
11677
- if (queued.has(normalized)) {
11678
- const existing = queue.find((item) => item.url === normalized);
11679
- if (existing && depth < existing.depth) existing.depth = depth;
11680
- queue.sort((left, right) => left.depth - right.depth || (left.url < right.url ? -1 : left.url > right.url ? 1 : 0));
13059
+ if (queue.has(normalized)) {
13060
+ queue.decreaseDepth(normalized, depth);
11681
13061
  return;
11682
13062
  }
11683
13063
  if (!sameOrigin(normalized, origin)) {
@@ -11707,7 +13087,7 @@ async function crawlSite(input, dependencies) {
11707
13087
  });
11708
13088
  return;
11709
13089
  }
11710
- if (queue.length + visited.size + inFlight.size >= config.maxPages * 5) {
13090
+ if (queue.size + visited.size + inFlight.size >= config.maxPages * 5) {
11711
13091
  recordSkip("queue-safety-limit");
11712
13092
  queueSafetySkippedUrls += 1;
11713
13093
  emitStatus("url_skipped", {
@@ -11717,13 +13097,12 @@ async function crawlSite(input, dependencies) {
11717
13097
  });
11718
13098
  return;
11719
13099
  }
11720
- queued.add(normalized);
13100
+ discovered.add(normalized);
11721
13101
  queuedUrls += 1;
11722
13102
  queue.push({
11723
13103
  url: normalized,
11724
13104
  depth
11725
13105
  });
11726
- queue.sort((left, right) => left.depth - right.depth || (left.url < right.url ? -1 : left.url > right.url ? 1 : 0));
11727
13106
  emitStatus("url_queued", {
11728
13107
  url: normalized,
11729
13108
  depth
@@ -11736,8 +13115,9 @@ async function crawlSite(input, dependencies) {
11736
13115
  if (!isCancelled() && config.useSitemap && (config.mode === "site" || config.mode === "sitemap")) {
11737
13116
  const sitemap = await sitemapSeeds({
11738
13117
  url: config.url,
13118
+ sitemapUrl: config.sitemapUrl,
11739
13119
  maxPages: config.maxPages,
11740
- declaredSitemapUrls: robotsTxt?.sitemapUrls ?? [],
13120
+ declaredSitemapUrls: robotsTxt?.sitemapUrls ?? healthSitemapUrls ?? [],
11741
13121
  warnings,
11742
13122
  fetchSitemapUrls: deps.fetchSitemapUrls
11743
13123
  });
@@ -11745,8 +13125,8 @@ async function crawlSite(input, dependencies) {
11745
13125
  sitemapDiscovery = sitemap.discovery;
11746
13126
  }
11747
13127
  const nextQueueItem = () => {
11748
- while (queue.length) {
11749
- const item = queue.shift();
13128
+ while (queue.size) {
13129
+ const item = queue.take();
11750
13130
  if (!item) continue;
11751
13131
  const normalized = normalizeUrl$1(item.url, config.url);
11752
13132
  if (!normalized) continue;
@@ -11760,15 +13140,17 @@ async function crawlSite(input, dependencies) {
11760
13140
  };
11761
13141
  const startTask = (item) => {
11762
13142
  let task;
13143
+ const fetcher = healthStrategy ? deps.fetchStatusPage : deps.fetchPage;
11763
13144
  task = {
11764
13145
  ...item,
11765
- promise: deps.fetchPage(item.url, {
13146
+ promise: fetcher(item.url, {
11766
13147
  js: config.js,
11767
13148
  refresh: config.refresh,
11768
13149
  timeoutMs: config.timeoutMs,
11769
13150
  rate: config.fetchRate,
11770
13151
  signal,
11771
13152
  respectRobots: config.respectRobots,
13153
+ robotsResolver,
11772
13154
  renderer
11773
13155
  }).then((result) => ({
11774
13156
  task,
@@ -11794,8 +13176,8 @@ async function crawlSite(input, dependencies) {
11794
13176
  depth: item.depth
11795
13177
  });
11796
13178
  };
11797
- while (!isCancelled() && (queue.length || inFlight.size) && pages.length < config.maxPages) {
11798
- while (!isCancelled() && inFlight.size < config.concurrency && pages.length + inFlight.size < config.maxPages) {
13179
+ while (!isCancelled() && (queue.size || inFlight.size) && pages.length < config.maxPages) {
13180
+ while (!isCancelled() && inFlight.size < activeConcurrency && pages.length + inFlight.size < config.maxPages) {
11799
13181
  const item = nextQueueItem();
11800
13182
  if (!item) break;
11801
13183
  startTask(item);
@@ -11816,6 +13198,13 @@ async function crawlSite(input, dependencies) {
11816
13198
  extraction: "not-applicable"
11817
13199
  });
11818
13200
  requests.push(request);
13201
+ if (healthStrategy) if (request.outcome === "response" && request.status < 400 && !request.accessBlock) {
13202
+ healthyProbeStreak += 1;
13203
+ activeConcurrency = Math.min(config.concurrency, 1 + Math.floor(healthyProbeStreak / 20));
13204
+ } else {
13205
+ healthyProbeStreak = 0;
13206
+ activeConcurrency = 1;
13207
+ }
11819
13208
  if (request.outcome === "skipped") {
11820
13209
  recordSkip(request.reason === "robots-disallowed" ? "robots-disallowed" : request.reason === "robots-deferred" ? "robots-uncertain" : "origin-backpressure");
11821
13210
  if (request.reason === "robots-deferred") {
@@ -11878,40 +13267,49 @@ async function crawlSite(input, dependencies) {
11878
13267
  };
11879
13268
  const existing = documentIndexes.get(finalUrl);
11880
13269
  if (existing) {
11881
- const links = /* @__PURE__ */ new Set([...existing.links, ...normalizedLinks]);
11882
13270
  const minDepth = Math.min(existing.minDepth, task.depth);
11883
13271
  const replace = !existing.directlyRequested && directlyRequested || existing.directlyRequested === directlyRequested && sourceRequest < existing.sourceRequest;
11884
13272
  if (replace) {
13273
+ const existingPage = pages[existing.index];
13274
+ const previousLinks = existing.pendingAliasLinks ?? (existingPage && existingPage.outgoingInternalCount === existingPage.sampleInternalLinks?.length ? new Set(existingPage.sampleInternalLinks) : void 0);
13275
+ const links = new Set(normalizedLinks);
13276
+ if (previousLinks) {
13277
+ for (const target of previousLinks) {
13278
+ const nextCount = (incomingLinkCounts.get(target) ?? 0) - 1;
13279
+ if (nextCount > 0) incomingLinkCounts.set(target, nextCount);
13280
+ else incomingLinkCounts.delete(target);
13281
+ }
13282
+ observedInternalLinks -= previousLinks.size;
13283
+ }
13284
+ for (const target of links) incomingLinkCounts.set(target, (incomingLinkCounts.get(target) ?? 0) + 1);
13285
+ observedInternalLinks += links.size;
13286
+ syncLinkEvidence(page, links);
11885
13287
  page.crawlDepth = minDepth;
11886
13288
  pages[existing.index] = page;
11887
13289
  } else {
11888
13290
  const existingPage = pages[existing.index];
11889
13291
  if (existingPage) existingPage.crawlDepth = minDepth;
11890
13292
  }
11891
- const retainedPage = pages[existing.index];
11892
- if (retainedPage) syncLinkEvidence(retainedPage, links);
11893
- observedInternalLinks += links.size - existing.links.size;
11894
13293
  documentIndexes.set(finalUrl, {
11895
13294
  ...existing,
11896
13295
  directlyRequested: existing.directlyRequested || directlyRequested,
11897
13296
  sourceRequest: replace ? sourceRequest : existing.sourceRequest,
11898
- links,
13297
+ pendingAliasLinks: void 0,
11899
13298
  minDepth
11900
13299
  });
11901
- linkGraph[finalUrl] = [...links].sort((left, right) => left < right ? -1 : left > right ? 1 : 0);
11902
13300
  } else {
11903
13301
  const links = new Set(normalizedLinks);
11904
13302
  syncLinkEvidence(page, links);
11905
13303
  pages.push(page);
11906
13304
  observedInternalLinks += links.size;
13305
+ for (const target of links) incomingLinkCounts.set(target, (incomingLinkCounts.get(target) ?? 0) + 1);
11907
13306
  documentIndexes.set(finalUrl, {
11908
13307
  index: pages.length - 1,
11909
13308
  directlyRequested,
11910
13309
  sourceRequest,
11911
- links,
13310
+ pendingAliasLinks: !directlyRequested ? links : void 0,
11912
13311
  minDepth: task.depth
11913
13312
  });
11914
- linkGraph[finalUrl] = [...links].sort((left, right) => left < right ? -1 : left > right ? 1 : 0);
11915
13313
  }
11916
13314
  };
11917
13315
  if (config.respectRobots && page.robotsTxt?.allowed === false) {
@@ -11939,27 +13337,16 @@ async function crawlSite(input, dependencies) {
11939
13337
  });
11940
13338
  if (followLinks && task.depth < config.maxDepth) for (const url of result.urls) enqueue(url, task.depth + 1);
11941
13339
  }
11942
- const resolvedLinkGraph = resolveLinkGraphAliases(linkGraph, fetchedAliases);
11943
- const pageIndexes = new Map(pages.map((page, index) => [page.url, index]));
11944
- const depths = pages.map((page) => page.crawlDepth ?? 0);
11945
- for (let pass = 0; pass < pages.length; pass += 1) {
11946
- let changed = false;
11947
- for (const [sourceUrl, targets] of Object.entries(resolvedLinkGraph)) {
11948
- const sourceIndex = pageIndexes.get(sourceUrl);
11949
- if (sourceIndex === void 0) continue;
11950
- for (const target of targets) {
11951
- const targetIndex = pageIndexes.get(target);
11952
- if (targetIndex === void 0) continue;
11953
- const nextDepth = (depths[sourceIndex] ?? 0) + 1;
11954
- if (nextDepth >= (depths[targetIndex] ?? Number.POSITIVE_INFINITY)) continue;
11955
- depths[targetIndex] = nextDepth;
11956
- changed = true;
11957
- }
11958
- }
11959
- if (!changed) break;
13340
+ const resolvedInlinkCounts = resolveLinkCountAliases(incomingLinkCounts, fetchedAliases);
13341
+ const maxInlinks = Math.max(0, ...pages.map((page) => resolvedInlinkCounts.get(page.finalUrl) ?? 0));
13342
+ for (const page of pages) {
13343
+ const internalInlinkCount = resolvedInlinkCounts.get(page.finalUrl) ?? 0;
13344
+ page.internalInlinkCount = internalInlinkCount;
13345
+ page.internalLinkAuthorityScore = maxInlinks ? Math.round(internalInlinkCount / maxInlinks * 100) : 0;
11960
13346
  }
11961
- for (const [index, page] of pages.entries()) page.crawlDepth = depths[index];
11962
13347
  cancelled = isCancelled();
13348
+ const robotsDisallowed = skipReasonCounts["robots-disallowed"] ?? 0;
13349
+ if (robotsDisallowed > 0) warnings.push(`${robotsDisallowed} URL${robotsDisallowed === 1 ? "" : "s"} disallowed ${SEO_CRAWLER_USER_AGENT} in robots.txt. If the audit should reach them, allow the SEO-Skill token on only those public paths.`);
11963
13350
  if (cancelled) {
11964
13351
  warnings.push("Crawl cancelled; returning a partial report.");
11965
13352
  emitStatus("cancelled", {
@@ -11967,15 +13354,17 @@ async function crawlSite(input, dependencies) {
11967
13354
  message: "Crawl cancelled; returning a partial report."
11968
13355
  });
11969
13356
  }
11970
- const pageLimitReached = pages.length >= config.maxPages && (queue.length > 0 || inFlight.size > 0);
13357
+ const pageLimitReached = pages.length >= config.maxPages && (queue.size > 0 || inFlight.size > 0);
11971
13358
  const sitemapEvidencePartial = sitemapDiscovery?.dataStatus === "partial";
11972
- const partial = cancelled || failedUrls > 0 || pageLimitReached || robotsDeferredUrls > 0 || originBackpressureSkippedUrls > 0 || queueSafetySkippedUrls > 0 || queue.length > 0 || inFlight.size > 0 || sitemapEvidencePartial;
11973
- const failed = !cancelled && pages.length === 0 && requests.length > 0 && requests.every((request) => request.outcome === "failure");
13359
+ const accessEvidencePartial = requests.some((request) => request.outcome === "response" && request.accessBlock);
13360
+ const requiredSitemapUnavailable = config.mode === "sitemap" && sitemapDiscovery?.dataStatus === "unavailable";
13361
+ const partial = cancelled || failedUrls > 0 || pageLimitReached || robotsDeferredUrls > 0 || originBackpressureSkippedUrls > 0 || queueSafetySkippedUrls > 0 || queue.size > 0 || inFlight.size > 0 || sitemapEvidencePartial || accessEvidencePartial || requiredSitemapUnavailable;
13362
+ const failed = !cancelled && pages.length === 0 && (requiredSitemapUnavailable || requests.length > 0 && requests.every((request) => request.outcome === "failure"));
11974
13363
  const requestEvidenceStatus = cancelled && inFlight.size > 0 ? "partial" : "available";
11975
13364
  const dataSources = await crawlDataSources({
11976
13365
  cancelled,
11977
- site,
11978
- googleAnalyticsPropertyId: input.googleAnalyticsPropertyId,
13366
+ site: healthStrategy ? void 0 : site,
13367
+ googleAnalyticsPropertyId: healthStrategy ? void 0 : input.googleAnalyticsPropertyId,
11979
13368
  pages,
11980
13369
  warnings,
11981
13370
  searchMetricsLimit,
@@ -12018,7 +13407,7 @@ async function crawlSite(input, dependencies) {
12018
13407
  concurrency: Math.min(config.concurrency, 4)
12019
13408
  }) : void 0;
12020
13409
  for (const page of pages) {
12021
- if (!page.geo) continue;
13410
+ if (!page.geo || !llmsTxt) continue;
12022
13411
  page.geo = {
12023
13412
  ...page.geo,
12024
13413
  hasLlmsTxt: llmsTxt.exists,
@@ -12035,12 +13424,11 @@ async function crawlSite(input, dependencies) {
12035
13424
  pages,
12036
13425
  requests,
12037
13426
  requestEvidenceStatus,
12038
- linkGraph: resolvedLinkGraph,
12039
- ai: {
12040
- llmsTxt,
13427
+ ...!healthStrategy ? { ai: {
13428
+ ...llmsTxt ? { llmsTxt } : {},
12041
13429
  ...robotsTxt ? { robotsTxt } : {},
12042
13430
  ...agentResources ? { agentResources } : {}
12043
- },
13431
+ } } : {},
12044
13432
  ...sitemapDiscovery ? { sitemapDiscovery } : {},
12045
13433
  ...externalLinkVerification ? { externalLinkVerification } : {},
12046
13434
  ...agentDiscovery ? { agentDiscovery } : {},
@@ -12534,6 +13922,14 @@ function compareCrawlReports(input) {
12534
13922
  issueChanges: issues
12535
13923
  };
12536
13924
  }
13925
+ //#endregion
13926
+ //#region packages/core/dist/phrasing.js
13927
+ function plural$4(count, singular, pluralLabel = `${singular}s`) {
13928
+ return count === 1 ? singular : pluralLabel;
13929
+ }
13930
+ function countLabel(count, singular, pluralLabel = `${singular}s`) {
13931
+ return `${count.toLocaleString("en-GB")} ${plural$4(count, singular, pluralLabel)}`;
13932
+ }
12537
13933
  const OKF_MAX_CONCEPTS = 5e3;
12538
13934
  function compareText$11(left, right) {
12539
13935
  const leftPoints = [...left].map((value) => value.codePointAt(0) ?? 0);
@@ -12898,7 +14294,7 @@ function explainOkfValidation(validation) {
12898
14294
  return {
12899
14295
  title: "OKF bundle",
12900
14296
  valid: validation.valid,
12901
- summary: validation.valid ? `This bundle passes seo OKF checks with ${validation.concepts} concept files.` : `This OKF bundle has ${errors.length} validation error${errors.length === 1 ? "" : "s"}.`,
14297
+ summary: validation.valid ? `This bundle passes seo OKF checks with ${countLabel(validation.concepts, "concept file")}.` : `This OKF bundle has ${countLabel(errors.length, "validation error")}.`,
12902
14298
  files: validation.files,
12903
14299
  concepts: validation.concepts,
12904
14300
  errors: errors.length,
@@ -13050,6 +14446,31 @@ const agentDiscoverySchema = z.object({
13050
14446
  missingMarkdownPages: z.number().int().nonnegative(),
13051
14447
  consistent: z.boolean().nullable()
13052
14448
  }),
14449
+ endpointDiscovery: z.object({
14450
+ linkHeader: z.object({
14451
+ url: z.string().url(),
14452
+ status: z.number().int().optional(),
14453
+ entries: z.array(z.object({
14454
+ url: z.string().url(),
14455
+ rel: z.array(z.string()),
14456
+ type: z.string().optional()
14457
+ })),
14458
+ registeredRels: z.array(z.string()),
14459
+ emergingRels: z.array(z.string()),
14460
+ error: z.string().optional()
14461
+ }),
14462
+ endpoints: z.array(z.object({
14463
+ id: z.string(),
14464
+ url: z.string().url(),
14465
+ status: z.number().int().optional(),
14466
+ exists: z.boolean(),
14467
+ contentType: z.string().optional(),
14468
+ validJson: z.boolean().optional(),
14469
+ presentFields: z.array(z.string()).optional(),
14470
+ missingFields: z.array(z.string()).optional(),
14471
+ error: z.string().optional()
14472
+ }))
14473
+ }).optional(),
13053
14474
  protocolVariants: z.object({
13054
14475
  http: z.object({
13055
14476
  url: z.string().url(),
@@ -13121,6 +14542,46 @@ const analyticsSchema = z.object({
13121
14542
  totalUsers: z.number(),
13122
14543
  conversions: z.number()
13123
14544
  });
14545
+ const crawlerIdentitySchema = z.object({
14546
+ name: z.string(),
14547
+ robotsToken: z.string(),
14548
+ version: z.string(),
14549
+ userAgent: z.string(),
14550
+ documentationUrl: z.string().url()
14551
+ });
14552
+ const accessBlockEvidenceSchema = z.object({
14553
+ provider: z.enum(["cloudflare", "unknown"]),
14554
+ kind: z.enum([
14555
+ "challenge",
14556
+ "firewall",
14557
+ "rate-limit",
14558
+ "authentication"
14559
+ ]),
14560
+ status: z.number().int(),
14561
+ crawler: crawlerIdentitySchema,
14562
+ indicators: z.array(z.string()),
14563
+ requestId: z.string().optional(),
14564
+ guidance: z.object({
14565
+ summary: z.string(),
14566
+ recommendedAction: z.string(),
14567
+ securityNote: z.string(),
14568
+ documentationUrl: z.string().url().optional()
14569
+ })
14570
+ });
14571
+ const crawlRobotsEvidenceSchema = z.object({
14572
+ url: z.string().url(),
14573
+ allowed: z.boolean().nullable(),
14574
+ availability: z.enum([
14575
+ "available",
14576
+ "absent",
14577
+ "access-blocked",
14578
+ "rate-limited",
14579
+ "unreachable"
14580
+ ]),
14581
+ status: z.number().int().optional(),
14582
+ error: z.string().optional(),
14583
+ matchedLine: z.string().optional()
14584
+ });
13124
14585
  const geoSignalsSchema = z.object({
13125
14586
  semanticHtml: z.boolean(),
13126
14587
  structuredData: z.boolean(),
@@ -13294,6 +14755,7 @@ const crawlPageSnapshotSchema = z.object({
13294
14755
  consecutiveErrors: z.number().int(),
13295
14756
  recentP95Ms: z.number().optional()
13296
14757
  }).optional(),
14758
+ accessBlock: accessBlockEvidenceSchema.optional(),
13297
14759
  robotsTxt: z.object({
13298
14760
  url: z.string().url(),
13299
14761
  cache: z.enum([
@@ -13376,23 +14838,12 @@ const crawlPageSnapshotSchema = z.object({
13376
14838
  }).optional()
13377
14839
  }).optional(),
13378
14840
  blocked: z.boolean().optional(),
14841
+ auditScope: z.enum(["full", "status"]).optional(),
14842
+ accessBlock: accessBlockEvidenceSchema.optional(),
13379
14843
  contentAuditAllowed: z.boolean().optional(),
13380
14844
  crawlDepth: z.number().int().optional(),
13381
14845
  error: z.string().optional(),
13382
- robotsTxt: z.object({
13383
- url: z.string().url(),
13384
- allowed: z.boolean().nullable(),
13385
- availability: z.enum([
13386
- "available",
13387
- "absent",
13388
- "access-blocked",
13389
- "rate-limited",
13390
- "unreachable"
13391
- ]),
13392
- status: z.number().int().optional(),
13393
- error: z.string().optional(),
13394
- matchedLine: z.string().optional()
13395
- }).optional(),
14846
+ robotsTxt: crawlRobotsEvidenceSchema.optional(),
13396
14847
  title: z.string().optional(),
13397
14848
  metaDescription: z.string().optional(),
13398
14849
  canonical: z.string().url().optional(),
@@ -13447,8 +14898,16 @@ const crawlPageSnapshotSchema = z.object({
13447
14898
  extractionError: z.string().optional(),
13448
14899
  wordCount: z.number().int(),
13449
14900
  contentExtraction: z.object({
13450
- requested: z.enum(["defuddle", "readability"]),
13451
- used: z.enum(["defuddle", "readability"]),
14901
+ requested: z.enum([
14902
+ "crawler",
14903
+ "defuddle",
14904
+ "readability"
14905
+ ]),
14906
+ used: z.enum([
14907
+ "crawler",
14908
+ "defuddle",
14909
+ "readability"
14910
+ ]),
13452
14911
  fallback: z.boolean(),
13453
14912
  fallbackReason: z.enum(["defuddle_error", "defuddle_empty"]).optional(),
13454
14913
  fallbackDetail: z.string().optional(),
@@ -13582,7 +15041,9 @@ const crawlResponseObservationBaseSchema = z.object({
13582
15041
  url: z.string().url(),
13583
15042
  status: z.number().int(),
13584
15043
  location: z.string().url().optional()
13585
- })).optional()
15044
+ })).optional(),
15045
+ accessBlock: accessBlockEvidenceSchema.optional(),
15046
+ robotsTxt: crawlRobotsEvidenceSchema.optional()
13586
15047
  });
13587
15048
  const crawlRequestObservationSchema = z.union([
13588
15049
  crawlResponseObservationBaseSchema.extend({ extraction: z.enum([
@@ -13599,20 +15060,7 @@ const crawlRequestObservationSchema = z.union([
13599
15060
  outcome: z.literal("skipped"),
13600
15061
  durationMs: z.number().optional(),
13601
15062
  reason: z.enum(["robots-disallowed", "robots-deferred"]),
13602
- robotsTxt: z.object({
13603
- url: z.string().url(),
13604
- allowed: z.boolean().nullable(),
13605
- availability: z.enum([
13606
- "available",
13607
- "absent",
13608
- "access-blocked",
13609
- "rate-limited",
13610
- "unreachable"
13611
- ]),
13612
- status: z.number().int().optional(),
13613
- error: z.string().optional(),
13614
- matchedLine: z.string().optional()
13615
- }),
15063
+ robotsTxt: crawlRobotsEvidenceSchema,
13616
15064
  extraction: z.literal("not-applicable")
13617
15065
  }),
13618
15066
  z.object({
@@ -13671,6 +15119,7 @@ const crawlAiSignalsSchema = z.object({
13671
15119
  status: z.number().int().optional(),
13672
15120
  error: z.string().optional(),
13673
15121
  sitemapUrls: z.array(z.string().url()),
15122
+ contentSignals: z.array(z.string()).optional(),
13674
15123
  botAccess: z.array(z.object({
13675
15124
  userAgent: z.string(),
13676
15125
  allowed: z.boolean().nullable(),
@@ -13728,7 +15177,11 @@ const crawlSitemapDiscoverySchema = z.object({
13728
15177
  urlsReturned: z.number().int().nonnegative(),
13729
15178
  roots: z.array(z.object({
13730
15179
  url: z.string().url(),
13731
- source: z.enum(["robots-txt", "default-path"]),
15180
+ source: z.enum([
15181
+ "explicit",
15182
+ "robots-txt",
15183
+ "default-path"
15184
+ ]),
13732
15185
  dataStatus: z.enum([
13733
15186
  "complete",
13734
15187
  "partial",
@@ -13812,6 +15265,8 @@ const crawlConfigSchema = z.object({
13812
15265
  "list",
13813
15266
  "sitemap"
13814
15267
  ]),
15268
+ strategy: z.enum(["full", "health"]),
15269
+ sitemapUrl: z.string().url().optional(),
13815
15270
  urls: z.array(z.string().url()),
13816
15271
  maxPages: z.number().int(),
13817
15272
  maxDepth: z.number().int(),
@@ -13846,6 +15301,7 @@ const crawlConfigSchema = z.object({
13846
15301
  });
13847
15302
  const crawlReportSummarySchema = z.object({
13848
15303
  totalPages: z.number().int(),
15304
+ statusOnlyPages: z.number().int(),
13849
15305
  indexablePages: z.number().int(),
13850
15306
  nonIndexablePages: z.number().int(),
13851
15307
  statusErrors: z.number().int(),
@@ -13936,6 +15392,17 @@ const crawlReportBaseSchema = z.object({
13936
15392
  ]),
13937
15393
  configHash: z.string(),
13938
15394
  config: crawlConfigSchema,
15395
+ access: z.object({
15396
+ crawler: crawlerIdentitySchema,
15397
+ blockedRequests: z.number().int().nonnegative(),
15398
+ providers: z.partialRecord(z.enum(["cloudflare", "unknown"]), z.number().int().nonnegative()),
15399
+ samples: z.array(z.object({
15400
+ url: z.string().url(),
15401
+ evidence: accessBlockEvidenceSchema
15402
+ })),
15403
+ sampleLimit: z.number().int().positive(),
15404
+ truncated: z.boolean()
15405
+ }).optional(),
13939
15406
  summary: crawlReportSummarySchema,
13940
15407
  pages: z.array(crawlPageSnapshotSchema),
13941
15408
  issues: z.array(crawlIssueSchema),
@@ -14133,7 +15600,7 @@ function analyzeCtrUnderperformersFromRows(input) {
14133
15600
  //#endregion
14134
15601
  //#region packages/core/dist/analyze/ctr-underperformers.js
14135
15602
  const MAX_GSC_ROWS$4 = 1e5;
14136
- function plural$4(count, singular, pluralLabel = `${singular}s`) {
15603
+ function plural$3(count, singular, pluralLabel = `${singular}s`) {
14137
15604
  return count === 1 ? singular : pluralLabel;
14138
15605
  }
14139
15606
  const defaultDependencies$12 = {
@@ -14209,7 +15676,7 @@ async function ctrUnderperformersReport(input, dependencies = defaultDependencie
14209
15676
  minImpressions,
14210
15677
  limit,
14211
15678
  brandFiltering: input.includeBrand ? "included" : "excluded",
14212
- verdict: top ? `${selection.eligibleUnderperformers} CTR ${plural$4(selection.eligibleUnderperformers, "underperformer")} found in retained rows, with a calculated heuristic shortfall of ${totalClickShortfall.toFixed(0)} clicks for this window. Start review with "${top.query}" because it has the largest gap.` : dataStatus === "partial" ? "No material CTR underperformers remained in the validated retained rows, but partial evidence prevents an all-clear." : "No high-impression page-one queries in the retained rows are materially below the heuristic CTR benchmark."
15679
+ verdict: top ? `${selection.eligibleUnderperformers} CTR ${plural$3(selection.eligibleUnderperformers, "underperformer")} found in retained rows, with a calculated heuristic shortfall of ${totalClickShortfall.toFixed(0)} clicks for this window. Start review with "${top.query}" because it has the largest gap.` : dataStatus === "partial" ? "No material CTR underperformers remained in the validated retained rows, but partial evidence prevents an all-clear." : "No high-impression page-one queries in the retained rows are materially below the heuristic CTR benchmark."
14213
15680
  },
14214
15681
  items,
14215
15682
  caveats: [
@@ -14218,10 +15685,10 @@ async function ctrUnderperformersReport(input, dependencies = defaultDependencie
14218
15685
  `Only queries ranking position 1-10 with at least ${minImpressions} impressions were checked.`,
14219
15686
  "Expected CTR uses a robust site-aware position benchmark when enough peer data exists, otherwise the fallback position curve.",
14220
15687
  "CTR benchmarks and calculated click shortfalls are directional heuristics, not forecasts or proof that clicks are available. Validate the search intent and live SERP before editing.",
14221
- ...selection.invalidRows ? [`${selection.invalidRows} invalid provider ${plural$4(selection.invalidRows, "row")} ${selection.invalidRows === 1 ? "was" : "were"} excluded, so this report is partial.`] : [],
14222
- ...selection.duplicateRows ? [`${selection.duplicateRows} repeated query/page ${plural$4(selection.duplicateRows, "row")} ${selection.duplicateRows === 1 ? "was" : "were"} aggregated before CTR and average position were calculated.`] : [],
15688
+ ...selection.invalidRows ? [`${selection.invalidRows} invalid provider ${plural$3(selection.invalidRows, "row")} ${selection.invalidRows === 1 ? "was" : "were"} excluded, so this report is partial.`] : [],
15689
+ ...selection.duplicateRows ? [`${selection.duplicateRows} repeated query/page ${plural$3(selection.duplicateRows, "row")} ${selection.duplicateRows === 1 ? "was" : "were"} aggregated before CTR and average position were calculated.`] : [],
14223
15690
  ...possiblyTruncated ? [`The retained-row limit of ${MAX_GSC_ROWS$4} was reached, so lower-ranked source rows may be absent.`] : [],
14224
- ...selection.limitedUnderperformers ? [`${selection.limitedUnderperformers} eligible ${plural$4(selection.limitedUnderperformers, "underperformer")} ${selection.limitedUnderperformers === 1 ? "was" : "were"} omitted by the output limit.`] : []
15691
+ ...selection.limitedUnderperformers ? [`${selection.limitedUnderperformers} eligible ${plural$3(selection.limitedUnderperformers, "underperformer")} ${selection.limitedUnderperformers === 1 ? "was" : "were"} omitted by the output limit.`] : []
14225
15692
  ],
14226
15693
  recommendations: top ? [
14227
15694
  `Review the live SERP and displayed snippet for "${top.query}" first. Test the title or meta description only if the observed framing does not match the query intent.`,
@@ -14231,14 +15698,6 @@ async function ctrUnderperformersReport(input, dependencies = defaultDependencie
14231
15698
  };
14232
15699
  }
14233
15700
  //#endregion
14234
- //#region packages/core/dist/phrasing.js
14235
- function plural$3(count, singular, pluralLabel = `${singular}s`) {
14236
- return count === 1 ? singular : pluralLabel;
14237
- }
14238
- function countLabel(count, singular, pluralLabel = `${singular}s`) {
14239
- return `${count.toLocaleString("en-GB")} ${plural$3(count, singular, pluralLabel)}`;
14240
- }
14241
- //#endregion
14242
15701
  //#region packages/core/dist/analyze/diagnosis-priorities.js
14243
15702
  function topDelta(report) {
14244
15703
  const top = report.items[0];
@@ -14265,14 +15724,14 @@ function buildDiagnosisPriorities(input) {
14265
15724
  const topGroup = input.decay.groups[0];
14266
15725
  priorities.push({
14267
15726
  label: "Refresh decaying content",
14268
- reason: topGroup ? `${input.decay.selection.eligibleRows} observed retained query/page declines found; ${topGroup.label} declined by ${topGroup.totalClickLoss.toFixed(0)} clicks.` : `${input.decay.selection.eligibleRows} observed retained query/page declines found.`,
15727
+ reason: topGroup ? `${countLabel(input.decay.selection.eligibleRows, "observed retained query/page decline")} found; ${topGroup.label} declined by ${topGroup.totalClickLoss.toFixed(0)} clicks.` : `${countLabel(input.decay.selection.eligibleRows, "observed retained query/page decline")} found.`,
14269
15728
  action: topGroup ? topGroup.recommendation : "Start with declines that continued outside the update window. Check indexability first, then ranking and CTR causes.",
14270
15729
  confidence: input.decay.dataStatus === "partial" ? "low" : "medium"
14271
15730
  });
14272
15731
  }
14273
15732
  if (input.cannibal.items.length) priorities.push({
14274
15733
  label: "Review multi-URL query candidates",
14275
- reason: `${input.cannibal.selection.eligibleClusters} multi-URL query candidates found.`,
15734
+ reason: `${countLabel(input.cannibal.selection.eligibleClusters, "multi-URL query candidate")} found.`,
14276
15735
  action: "Confirm whether each URL set satisfies the same intent and inspect technical state. Consolidate only verified duplicate or same-intent pages; otherwise clarify the distinction.",
14277
15736
  confidence: "low"
14278
15737
  });
@@ -14290,7 +15749,7 @@ function buildDiagnosisPriorities(input) {
14290
15749
  const top = input.striking.items[0];
14291
15750
  priorities.push({
14292
15751
  label: "Investigate striking-distance candidates",
14293
- reason: `${input.striking.items.length} query/page rows have an average GSC position above 10 and at most 20.`,
15752
+ reason: `${countLabel(input.striking.items.length, "query/page row")} ${input.striking.items.length === 1 ? "has" : "have"} an average GSC position above 10 and at most 20.`,
14294
15753
  action: top?.recommendation.action ?? "Check technical state, query coverage, competing URLs, and relevant internal links before choosing a change.",
14295
15754
  confidence: input.striking.source.possiblyTruncated ? "low" : top?.recommendation.confidence ?? "low"
14296
15755
  });
@@ -16074,8 +17533,7 @@ function analyzeQuickWinsFromRows(input) {
16074
17533
  selected.selection.belowMinimumRows++;
16075
17534
  continue;
16076
17535
  }
16077
- const targetUrl = row.keys[1] ?? "";
16078
- const excludedTargetRows = selected.rows.filter((candidate) => samePageUrl(candidate.keys[1] ?? "", targetUrl)).length;
17536
+ const excludedTargetRows = benchmarkContext.urlRowCount(row);
16079
17537
  const benchmark = benchmarkContext.forUrl(row);
16080
17538
  if (row.ctr >= benchmark.ctr) {
16081
17539
  selected.selection.atOrAboveTargetRows++;
@@ -16269,7 +17727,7 @@ async function quickWinsReport(input, dependencies = defaultDependencies$8) {
16269
17727
  brandTerms: input.brandTerms,
16270
17728
  includeBrand: input.includeBrand
16271
17729
  });
16272
- const verificationRequested = input.verifyContent === true || input.verifyLimit !== void 0;
17730
+ const verificationRequested = input.verifyContent === true || input.verifyContent !== false && input.verifyLimit !== void 0;
16273
17731
  const verificationItems = verificationRequested ? analysis.items.slice(0, verifyLimit) : [];
16274
17732
  const fetches = /* @__PURE__ */ new Map();
16275
17733
  const cachedFetch = (url, options) => {
@@ -16368,7 +17826,7 @@ async function quickWinsReport(input, dependencies = defaultDependencies$8) {
16368
17826
  repeatedQueryGroups: groups.length,
16369
17827
  templatePatterns: templateActions.length,
16370
17828
  brandFiltering: input.includeBrand ? "included" : "excluded",
16371
- verdict: analysis.dataStatus === "empty" ? "Search Console returned no retained query/page rows for this date window." : analysis.dataStatus === "filtered" ? "No retained rows met the quick-win report criteria." : `${analysis.summary.eligibleRows} eligible CTR-target rows found; ${analysis.summary.returnedRows} returned for review.`
17829
+ verdict: analysis.dataStatus === "empty" ? "Search Console returned no retained query/page rows for this date window." : analysis.dataStatus === "filtered" ? "No retained rows met the quick-win report criteria." : `${countLabel(analysis.summary.eligibleRows, "eligible CTR-target row")} found; ${countLabel(analysis.summary.returnedRows, "row")} returned for review.`
16372
17830
  },
16373
17831
  caveats: [
16374
17832
  `Date window: ${range.startDate} to ${range.endDate}, using final GSC data where available.`,
@@ -16659,7 +18117,7 @@ function templatesFromGroups(groups) {
16659
18117
  function verdict$1(analysis) {
16660
18118
  if (analysis.dataStatus === "empty") return "Search Console returned no retained query/page rows for this date window.";
16661
18119
  if (analysis.dataStatus === "filtered") return "No retained query/page rows met the report filters.";
16662
- return `${analysis.summary.eligibleRows} eligible query/page rows found; ${analysis.summary.returnedRows} returned for review.`;
18120
+ return `${countLabel(analysis.summary.eligibleRows, "eligible query/page row")} found; ${countLabel(analysis.summary.returnedRows, "row")} returned for review.`;
16663
18121
  }
16664
18122
  async function strikingDistance(input, dependencies = defaultDependencies$7) {
16665
18123
  const days = reportDays(input.days);
@@ -16681,7 +18139,7 @@ async function strikingDistance(input, dependencies = defaultDependencies$7) {
16681
18139
  includeBrand: input.includeBrand
16682
18140
  });
16683
18141
  const verifyLimit = boundedInteger$2(input.verifyLimit, DEFAULT_VERIFY_LIMIT, 0, analysis.limit);
16684
- const verificationRequested = input.verifyContent === true || input.verifyLimit !== void 0;
18142
+ const verificationRequested = input.verifyContent === true || input.verifyContent !== false && input.verifyLimit !== void 0;
16685
18143
  const attempted = verificationRequested ? Math.min(verifyLimit, analysis.items.length) : 0;
16686
18144
  const items = await Promise.all(analysis.items.map(async (item, index) => {
16687
18145
  if (index >= attempted) return item;
@@ -19255,8 +20713,6 @@ function topCrawlRecommendations(items) {
19255
20713
  url: item.url
19256
20714
  })).sort((a, b) => priority[b.severity] - priority[a.severity]);
19257
20715
  }
19258
- //#endregion
19259
- //#region packages/core/dist/analyze/monitoring/crawl-store.js
19260
20716
  function toRun(row) {
19261
20717
  return {
19262
20718
  id: row.id,
@@ -19328,34 +20784,52 @@ function insertCrawlRun(run, pages, recommendations = []) {
19328
20784
  for (const page of pages) insertPage.run(run.id, page.url, page.finalUrl, page.status, page.title ?? null, page.metaDescription ?? null, page.canonical ?? null, page.metaRobots ?? null, page.xRobotsTag ?? null, page.h1 ?? null, page.indexable ? 1 : 0, page.wordCount, page.contentHash, page.outgoingInternalCount, JSON.stringify(page));
19329
20785
  for (const recommendation of recommendations) insertRecommendation.run(run.id, run.site, recommendation.url, recommendation.severity, recommendation.category, recommendation.title, recommendation.action, recommendation.confidence, createdAt);
19330
20786
  })();
20787
+ db.prepare(`DELETE FROM crawl_runs
20788
+ WHERE site_url = ? AND start_url = ?
20789
+ AND id NOT IN (
20790
+ SELECT id FROM crawl_runs
20791
+ WHERE site_url = ? AND start_url = ?
20792
+ ORDER BY created_at DESC, id DESC
20793
+ LIMIT ?
20794
+ )`).run(run.site, run.startUrl, run.site, run.startUrl, 20);
19331
20795
  }
19332
20796
  function latestCrawlSummaries(site, limit = 5) {
19333
- return getDb().prepare(`WITH page_counts AS (
20797
+ return getDb().prepare(`WITH recent_runs AS (
20798
+ SELECT *
20799
+ FROM crawl_runs
20800
+ WHERE site_url = ?
20801
+ ORDER BY created_at DESC
20802
+ LIMIT ?
20803
+ ),
20804
+ page_counts AS (
19334
20805
  SELECT
19335
- run_id,
19336
- COALESCE(SUM(CASE WHEN status >= 400 THEN 1 ELSE 0 END), 0) AS status_errors,
19337
- COALESCE(SUM(CASE WHEN indexable = 0 THEN 1 ELSE 0 END), 0) AS non_indexable
20806
+ crawl_pages.run_id,
20807
+ COALESCE(SUM(CASE WHEN crawl_pages.status >= 400 THEN 1 ELSE 0 END), 0) AS status_errors,
20808
+ COALESCE(SUM(CASE WHEN crawl_pages.indexable = 0 THEN 1 ELSE 0 END), 0) AS non_indexable
19338
20809
  FROM crawl_pages
19339
- GROUP BY run_id
20810
+ INNER JOIN recent_runs ON recent_runs.id = crawl_pages.run_id
20811
+ GROUP BY crawl_pages.run_id
19340
20812
  ),
19341
20813
  recommendation_counts AS (
19342
20814
  SELECT
19343
- run_id,
20815
+ crawl_recommendations.run_id,
19344
20816
  COUNT(*) AS recommendation_count,
19345
- COALESCE(SUM(CASE WHEN severity = 'high' THEN 1 ELSE 0 END), 0) AS high_recommendation_count
20817
+ COALESCE(SUM(CASE WHEN crawl_recommendations.severity = 'high' THEN 1 ELSE 0 END), 0) AS high_recommendation_count
19346
20818
  FROM crawl_recommendations
19347
- GROUP BY run_id
20819
+ INNER JOIN recent_runs ON recent_runs.id = crawl_recommendations.run_id
20820
+ GROUP BY crawl_recommendations.run_id
19348
20821
  ),
19349
20822
  recommendation_rank AS (
19350
20823
  SELECT
19351
20824
  crawl_recommendations.*,
19352
20825
  ROW_NUMBER() OVER (
19353
- PARTITION BY run_id
20826
+ PARTITION BY crawl_recommendations.run_id
19354
20827
  ORDER BY
19355
- CASE severity WHEN 'high' THEN 3 WHEN 'medium' THEN 2 ELSE 1 END DESC,
19356
- created_at DESC
20828
+ CASE crawl_recommendations.severity WHEN 'high' THEN 3 WHEN 'medium' THEN 2 ELSE 1 END DESC,
20829
+ crawl_recommendations.created_at DESC
19357
20830
  ) AS rank
19358
20831
  FROM crawl_recommendations
20832
+ INNER JOIN recent_runs ON recent_runs.id = crawl_recommendations.run_id
19359
20833
  )
19360
20834
  SELECT
19361
20835
  crawl_runs.*,
@@ -19367,14 +20841,12 @@ function latestCrawlSummaries(site, limit = 5) {
19367
20841
  top.title AS top_recommendation_title,
19368
20842
  top.action AS top_recommendation_action,
19369
20843
  top.severity AS top_recommendation_severity
19370
- FROM crawl_runs
20844
+ FROM recent_runs AS crawl_runs
19371
20845
  LEFT JOIN page_counts ON page_counts.run_id = crawl_runs.id
19372
20846
  LEFT JOIN recommendation_counts ON recommendation_counts.run_id = crawl_runs.id
19373
20847
  LEFT JOIN recommendation_rank top ON top.run_id = crawl_runs.id AND top.rank = 1
19374
- WHERE crawl_runs.site_url = ?
19375
20848
  GROUP BY crawl_runs.id
19376
- ORDER BY crawl_runs.created_at DESC
19377
- LIMIT ?`).all(site, limit).map((row) => ({
20849
+ ORDER BY crawl_runs.created_at DESC`).all(site, limit).map((row) => ({
19378
20850
  ...toRun(row),
19379
20851
  statusErrors: row.status_errors,
19380
20852
  nonIndexable: row.non_indexable,
@@ -20450,6 +21922,36 @@ function previousFromRow(row) {
20450
21922
  lastCrawlTime: row.last_crawl_time ?? void 0
20451
21923
  };
20452
21924
  }
21925
+ function pruneIndexWatchSnapshots(rootSite, url, database = getDb()) {
21926
+ return database.prepare(`WITH attempts AS (
21927
+ SELECT id,
21928
+ ROW_NUMBER() OVER (
21929
+ PARTITION BY root_site_url, url
21930
+ ORDER BY inspected_at DESC, id DESC
21931
+ ) AS attempt_rank
21932
+ FROM index_watch_snapshots
21933
+ WHERE root_site_url = ? AND url = ?
21934
+ ), latest_success AS (
21935
+ SELECT id FROM (
21936
+ SELECT id,
21937
+ ROW_NUMBER() OVER (
21938
+ ORDER BY inspected_at DESC, id DESC
21939
+ ) AS success_rank
21940
+ FROM index_watch_snapshots
21941
+ WHERE root_site_url = ? AND url = ?
21942
+ AND inspection_status = 'succeeded'
21943
+ AND error_code IS NULL
21944
+ )
21945
+ WHERE success_rank = 1
21946
+ )
21947
+ DELETE FROM index_watch_snapshots
21948
+ WHERE id IN (
21949
+ SELECT attempts.id
21950
+ FROM attempts
21951
+ LEFT JOIN latest_success ON latest_success.id = attempts.id
21952
+ WHERE attempts.attempt_rank > ? AND latest_success.id IS NULL
21953
+ )`).run(rootSite, url, rootSite, url, 20).changes;
21954
+ }
20453
21955
  const defaultDependencies$2 = {
20454
21956
  inspectUrl,
20455
21957
  now: () => /* @__PURE__ */ new Date(),
@@ -20463,12 +21965,14 @@ const defaultDependencies$2 = {
20463
21965
  return row ? previousFromRow(row) : void 0;
20464
21966
  },
20465
21967
  insert(item) {
20466
- getDb().prepare(`INSERT INTO index_watch_snapshots
21968
+ const db = getDb();
21969
+ db.prepare(`INSERT INTO index_watch_snapshots
20467
21970
  (id, site_url, root_site_url, property_site_url, url, verdict,
20468
21971
  coverage_state, indexing_state, robots_txt_state, page_fetch_state,
20469
21972
  google_canonical, user_canonical, last_crawl_time, inspection_status,
20470
21973
  error_code, error_message, inspected_at)
20471
21974
  VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`).run(randomUUID(), item.property, item.rootSite, item.property, item.url, item.verdict ?? null, item.coverageState ?? null, item.indexingState ?? null, item.robotsTxtState ?? null, item.pageFetchState ?? null, item.googleCanonical ?? null, item.userCanonical ?? null, item.lastCrawlTime ?? null, item.inspectionStatus, item.errorCode ?? null, item.errorMessage ?? null, Date.parse(item.inspectedAt));
21975
+ pruneIndexWatchSnapshots(item.rootSite, item.url, db);
20472
21976
  }
20473
21977
  }
20474
21978
  };
@@ -21007,8 +22511,6 @@ async function indexMonitor(input) {
21007
22511
  warnings
21008
22512
  };
21009
22513
  }
21010
- //#endregion
21011
- //#region packages/core/dist/analyze/monitoring/link-recover-store.js
21012
22514
  function toSummary(row) {
21013
22515
  return {
21014
22516
  id: row.id,
@@ -21051,6 +22553,14 @@ function insertLinkRecoverRun(report) {
21051
22553
  insertRun.run(id, report.site, createdAt, report.range.startDate, report.range.endDate, report.range.days, report.summary.checked, report.summary.recoverable, report.summary.high, report.summary.medium, report.summary.low, report.summary.clicksAtRisk, report.summary.impressionsAtRisk, top?.issue ?? null, top?.url ?? null, top?.recommendation.action ?? null);
21052
22554
  for (const item of report.items) insertItem.run(id, report.site, item.url, item.finalUrl, item.issue, JSON.stringify(item.issues), item.severity, item.clicks, item.impressions, item.position, item.recommendation.action, createdAt);
21053
22555
  })();
22556
+ db.prepare(`DELETE FROM link_recover_runs
22557
+ WHERE site_url = ?
22558
+ AND id NOT IN (
22559
+ SELECT id FROM link_recover_runs
22560
+ WHERE site_url = ?
22561
+ ORDER BY created_at DESC, id DESC
22562
+ LIMIT ?
22563
+ )`).run(report.site, report.site, 20);
21054
22564
  return id;
21055
22565
  }
21056
22566
  function latestLinkRecoverSummary(site) {
@@ -21884,9 +23394,11 @@ function cacheSet(report, now) {
21884
23394
  ...report,
21885
23395
  raw: void 0
21886
23396
  };
23397
+ const reportJson = JSON.stringify(stored);
21887
23398
  getDb().prepare(`INSERT OR REPLACE INTO performance_reports
21888
23399
  (id, url, strategy, report_json, created_at, expires_at)
21889
- VALUES (?, ?, ?, ?, ?, ?)`).run(report.id, report.url, report.strategy, JSON.stringify(stored), Date.parse(report.generatedAt), now + CACHE_TTL_MS);
23400
+ VALUES (?, ?, ?, ?, ?, ?)`).run(report.id, report.url, report.strategy, reportJson, Date.parse(report.generatedAt), now + CACHE_TTL_MS);
23401
+ noteCacheWrite(Buffer.byteLength(reportJson));
21890
23402
  }
21891
23403
  async function fallbackReport(input) {
21892
23404
  const fetched = await fetchPage(input.url, {
@@ -23435,7 +24947,7 @@ function clusterRecommendation(input) {
23435
24947
  return `This ${clusterName} cluster already gets clicks. Use it to refine page structure, FAQs, and internal anchors rather than creating duplicate pages.`;
23436
24948
  }
23437
24949
  function clusterSummary(input) {
23438
- return `${input.queries} queries, ${input.impressions.toFixed(0)} impressions, ${input.clicks.toFixed(0)} clicks, average position ${input.position.toFixed(1)}.`;
24950
+ return `${countLabel(input.queries, "query", "queries")}, ${countLabel(input.impressions, "impression")}, ${countLabel(input.clicks, "click")}, average position ${input.position.toFixed(1)}.`;
23439
24951
  }
23440
24952
  function clusterLabel(rows) {
23441
24953
  return [...rows].sort((a, b) => b.impressions - a.impressions || compareQueryClusterText(a.query, b.query))[0]?.query ?? "";
@@ -23908,7 +25420,7 @@ function contentOpportunityBullets(report) {
23908
25420
  if (report.cannibalization.selection.suppressedQueries) bullets.push(cannibalSuppressionLine(report));
23909
25421
  if (report.summary.strikingDistanceItems) {
23910
25422
  const top = report.strikingDistance.items[0];
23911
- bullets.push(top ? `${report.summary.strikingDistanceItems} query/page ${plural(report.summary.strikingDistanceItems, "candidate")} have an average GSC position above 10 and at most 20. Start by investigating "${top.query}" on ${top.url}; it averages position ${top.position.toFixed(1)} with ${top.impressions.toFixed(0)} impressions.` : `${report.summary.strikingDistanceItems} query/page ${plural(report.summary.strikingDistanceItems, "candidate")} have an average GSC position above 10 and at most 20.`);
25423
+ bullets.push(top ? `${countLabel(report.summary.strikingDistanceItems, "query/page candidate")} ${plural(report.summary.strikingDistanceItems, "has", "have")} an average GSC position above 10 and at most 20. Start by investigating "${top.query}" on ${top.url}; it averages position ${top.position.toFixed(1)} with ${top.impressions.toFixed(0)} impressions.` : `${countLabel(report.summary.strikingDistanceItems, "query/page candidate")} ${plural(report.summary.strikingDistanceItems, "has", "have")} an average GSC position above 10 and at most 20.`);
23912
25424
  }
23913
25425
  if (!skippedReason(report, "quick-win opportunities") && report.summary.quickWinItems) {
23914
25426
  const top = report.quickWins.items[0];
@@ -24389,10 +25901,10 @@ function applyVerification(item, coverage) {
24389
25901
  function verdict(report) {
24390
25902
  if (report.dataStatus === "empty") return "Google Search Console returned no retained query/page rows for this window.";
24391
25903
  if (report.dataStatus === "filtered") return "No pages met the position, brand, and minimum-impression criteria.";
24392
- if (report.technical > 0) return `${report.eligible} eligible average-position pages found; ${report.technical} returned pages have verified technical issues to fix before content changes.`;
24393
- if (report.failed > 0) return `${report.eligible} eligible average-position pages found; ${report.failed} returned pages could not be verified and need a fetch check before content changes.`;
24394
- if (report.content > 0) return `${report.eligible} eligible average-position pages found; ${report.content} returned pages have verified content or search-framing gaps.`;
24395
- return `${report.eligible} eligible average-position pages found; ${report.returned} are returned in priority order for investigation.`;
25904
+ if (report.technical > 0) return `${countLabel(report.eligible, "eligible average-position page")} found; ${countLabel(report.technical, "returned page")} ${plural$4(report.technical, "has", "have")} verified technical issues to fix before content changes.`;
25905
+ if (report.failed > 0) return `${countLabel(report.eligible, "eligible average-position page")} found; ${countLabel(report.failed, "returned page")} could not be verified and ${plural$4(report.failed, "needs", "need")} a fetch check before content changes.`;
25906
+ if (report.content > 0) return `${countLabel(report.eligible, "eligible average-position page")} found; ${countLabel(report.content, "returned page")} ${plural$4(report.content, "has", "have")} verified content or search-framing gaps.`;
25907
+ return `${countLabel(report.eligible, "eligible average-position page")} found; ${countLabel(report.returned, "page")} ${plural$4(report.returned, "is", "are")} returned in priority order for investigation.`;
24396
25908
  }
24397
25909
  function reportRecommendations(items) {
24398
25910
  const first = items[0];
@@ -24439,7 +25951,7 @@ async function secondPage(input, dependencies = defaultDependencies) {
24439
25951
  });
24440
25952
  const generatedAt = now.toISOString();
24441
25953
  const warnings = [];
24442
- const verificationRequested = input.verifyContent === true || input.verifyLimit !== void 0;
25954
+ const verificationRequested = input.verifyContent === true || input.verifyContent !== false && input.verifyLimit !== void 0;
24443
25955
  const verificationItems = verificationRequested ? analysis.items.slice(0, verifyLimit) : [];
24444
25956
  for (const item of verificationItems) {
24445
25957
  const coverage = await verifyQueryContent({
@@ -25050,7 +26562,7 @@ async function refreshPrioritiesWorkflow(input) {
25050
26562
  const diagnosis = await diagnoseProperty({
25051
26563
  ...input,
25052
26564
  verifyContent: input.verifyContent ?? true,
25053
- verifyLimit: input.verifyLimit ?? Math.min(input.limit ?? 5, 5)
26565
+ verifyLimit: input.verifyContent === false ? void 0 : input.verifyLimit ?? Math.min(input.limit ?? 5, 5)
25054
26566
  });
25055
26567
  const range = defaultDateRange$1(input.days ?? 28);
25056
26568
  const analytics = await fetchLandingPageValues({
@@ -25751,6 +27263,7 @@ function parseSemicolonCsv(text) {
25751
27263
  //#endregion
25752
27264
  //#region packages/core/dist/providers/semrush/cache.js
25753
27265
  const BASE_URL = "https://api.semrush.com/";
27266
+ const MAX_RESPONSE_BYTES = 10 * 1024 * 1024;
25754
27267
  function estimateUsd(units) {
25755
27268
  return units / 1e3 * .05;
25756
27269
  }
@@ -25780,14 +27293,17 @@ async function cachedSemrushCall(endpoint, params, map, ttlMs, creditsPerLine, r
25780
27293
  const url = new URL(BASE_URL);
25781
27294
  url.search = new URLSearchParams(requestParams).toString();
25782
27295
  const response = await fetch$1(url);
25783
- const text = await response.text();
27296
+ const text = await readBoundedResponseText(response, MAX_RESPONSE_BYTES, "Semrush response");
25784
27297
  if (!response.ok || text.startsWith("ERROR ::")) throw new Error(text || `Semrush request failed with ${response.status}.`);
25785
27298
  const rows = parseSemicolonCsv(text);
25786
27299
  const data = map(rows);
25787
27300
  const credits = Math.max(0, rows.length - 1) * creditsPerLine;
27301
+ const requestJson = JSON.stringify(requestParams);
27302
+ const responseJson = JSON.stringify(data);
25788
27303
  db.prepare(`INSERT OR REPLACE INTO semrush_cache
25789
27304
  (endpoint, query_hash, request_json, response_json, credits_used, fetched_at, expires_at)
25790
- VALUES (?, ?, ?, ?, ?, ?, ?)`).run(endpoint, queryHash, JSON.stringify(requestParams), JSON.stringify(data), credits, Date.now(), Date.now() + ttlMs);
27305
+ VALUES (?, ?, ?, ?, ?, ?, ?)`).run(endpoint, queryHash, requestJson, responseJson, credits, Date.now(), Date.now() + ttlMs);
27306
+ noteCacheWrite(Buffer.byteLength(requestJson) + Buffer.byteLength(responseJson));
25791
27307
  return {
25792
27308
  data,
25793
27309
  usage: {
@@ -25947,9 +27463,6 @@ var LocalCredentialsProvider = class {
25947
27463
  }
25948
27464
  };
25949
27465
  new LocalCredentialsProvider();
25950
- //#endregion
25951
- //#region packages/core/dist/version.js
25952
- const SEO_VERSION = version;
25953
27466
  const TELEMETRY_TIMEOUT_MS = 2e3;
25954
27467
  const TELEMETRY_REPORTS = [
25955
27468
  "affected-urls",
@@ -26544,17 +28057,7 @@ function registerAiOpportunityTools(server, dependencies = {}) {
26544
28057
  });
26545
28058
  }
26546
28059
  //#endregion
26547
- //#region packages/mcp/src/fetch-rate.ts
26548
- function fetchRateInput(input) {
26549
- if (input.fetchConcurrency === void 0 && input.fetchIntervalCap === void 0 && input.fetchIntervalMs === void 0) return;
26550
- return {
26551
- concurrency: input.fetchConcurrency,
26552
- intervalCap: input.fetchIntervalCap,
26553
- intervalMs: input.fetchIntervalMs
26554
- };
26555
- }
26556
- //#endregion
26557
- //#region packages/mcp/src/crawler-tools.ts
28060
+ //#region packages/mcp/src/crawler-tool-helpers.ts
26558
28061
  function compactCrawlResult(report, opts = {}) {
26559
28062
  const requestHeadline = report.requestEvidenceStatus === "available" ? `Processed ${report.requests.length} URL requests into ${report.summary.totalPages} unique documents.` : report.requestEvidenceStatus === "partial" ? `Observed ${report.requests.length} URL requests and retained ${report.summary.totalPages} unique documents; some started requests were still in flight when the crawl stopped.` : `Loaded ${report.summary.totalPages} unique documents; request evidence is unavailable for this legacy report.`;
26560
28063
  const payload = {
@@ -26564,6 +28067,8 @@ function compactCrawlResult(report, opts = {}) {
26564
28067
  status: report.status,
26565
28068
  requestEvidenceStatus: report.requestEvidenceStatus,
26566
28069
  configHash: report.configHash,
28070
+ config: report.config,
28071
+ access: report.access,
26567
28072
  summary: report.summary,
26568
28073
  dataSources: report.dataSources,
26569
28074
  ai: report.ai ? {
@@ -26597,16 +28102,35 @@ function resolveSavedReportAlias(input) {
26597
28102
  function assertExclusiveReportInput(url, reportId) {
26598
28103
  if (url && reportId) throw new SeoError("INVALID_INPUT", "Use either url or reportId, not both.");
26599
28104
  }
28105
+ //#endregion
28106
+ //#region packages/mcp/src/fetch-rate.ts
28107
+ function fetchRateInput(input) {
28108
+ if (input.fetchConcurrency === void 0 && input.fetchIntervalCap === void 0 && input.fetchIntervalMs === void 0) return;
28109
+ return {
28110
+ concurrency: input.fetchConcurrency,
28111
+ intervalCap: input.fetchIntervalCap,
28112
+ intervalMs: input.fetchIntervalMs
28113
+ };
28114
+ }
28115
+ //#endregion
28116
+ //#region packages/mcp/src/crawler-tools.ts
28117
+ function boundedPositiveInteger(maximum) {
28118
+ return z$1.number().int().min(1).max(maximum).optional();
28119
+ }
28120
+ const crawlPageLimit = boundedPositiveInteger(MAX_CRAWL_PAGES);
28121
+ const crawlDepthLimit = z$1.number().int().min(0).max(64).optional();
26600
28122
  function registerCrawlerTools(server) {
26601
28123
  server.registerTool("seo_crawl_site", {
26602
- description: "Crawl a site and run technical SEO checks. The compact result separates prioritised fixes from review observations. Set includePages/includeIssues for raw data.",
28124
+ description: "Run a site crawl. Start with health=true for a fast uncached sitemap status and redirect pass, especially on large or unknown sites. Run the full crawl second when content-level evidence is needed. The compact result includes crawler identity and access-block guidance.",
26603
28125
  inputSchema: {
26604
- url: z$1.string().url(),
28126
+ url: z$1.string().url().optional().describe("Site start URL. Optional when sitemapUrl supplies the health-pass scope."),
28127
+ health: z$1.boolean().optional().describe("Run the fast sitemap health pass before a full crawl. Downloads no page bodies and writes no page cache entries."),
28128
+ sitemapUrl: z$1.string().url().optional().describe("Explicit sitemap or sitemap index URL for the health pass."),
26605
28129
  site: z$1.string().optional(),
26606
28130
  googleAnalyticsPropertyId: z$1.string().optional(),
26607
- maxPages: z$1.number().int().positive().optional(),
26608
- maxDepth: z$1.number().int().nonnegative().optional(),
26609
- concurrency: z$1.number().int().positive().optional(),
28131
+ maxPages: crawlPageLimit,
28132
+ maxDepth: crawlDepthLimit,
28133
+ concurrency: boundedPositiveInteger(MAX_CRAWL_CONCURRENCY),
26610
28134
  fetchIntervalCap: z$1.number().int().positive().optional(),
26611
28135
  fetchIntervalMs: z$1.number().int().positive().optional(),
26612
28136
  refresh: z$1.boolean().optional(),
@@ -26619,10 +28143,15 @@ function registerCrawlerTools(server) {
26619
28143
  includeIssues: z$1.boolean().optional(),
26620
28144
  saveReport: z$1.boolean().optional()
26621
28145
  }
26622
- }, async ({ url, site, googleAnalyticsPropertyId, maxPages, maxDepth, concurrency, fetchIntervalCap, fetchIntervalMs, refresh, include, exclude, useSitemap, respectRobots, js, includePages, includeIssues, saveReport }) => {
28146
+ }, async ({ url, health, sitemapUrl, site, googleAnalyticsPropertyId, maxPages, maxDepth, concurrency, fetchIntervalCap, fetchIntervalMs, refresh, include, exclude, useSitemap, respectRobots, js, includePages, includeIssues, saveReport }) => {
26623
28147
  try {
28148
+ const crawlUrl = url ?? (sitemapUrl ? new URL("/", sitemapUrl).toString() : void 0);
28149
+ if (!crawlUrl) throw new SeoError("INVALID_INPUT", "Pass url or an explicit sitemapUrl.");
26624
28150
  const report = await crawlSite({
26625
- url,
28151
+ url: crawlUrl,
28152
+ mode: health || sitemapUrl ? "sitemap" : "site",
28153
+ strategy: health ? "health" : "full",
28154
+ sitemapUrl,
26626
28155
  site,
26627
28156
  googleAnalyticsPropertyId,
26628
28157
  maxPages,
@@ -26641,7 +28170,7 @@ function registerCrawlerTools(server) {
26641
28170
  js: Boolean(js)
26642
28171
  });
26643
28172
  const saved = saveReport ? saveCrawlReport(report) : void 0;
26644
- return toolSuccess(`Crawl complete for ${url}. Found ${report.issues.length} issues across ${report.summary.totalPages} pages.`, {
28173
+ return toolSuccess(`${health ? "Sitemap health pass" : "Crawl"} complete for ${crawlUrl}. Found ${report.issues.length} issues across ${report.summary.totalPages} pages.`, {
26645
28174
  ...compactCrawlResult(report, {
26646
28175
  includePages,
26647
28176
  includeIssues
@@ -26655,11 +28184,11 @@ function registerCrawlerTools(server) {
26655
28184
  server.registerTool("seo_audit_urls", {
26656
28185
  description: "Audit an explicit list of URLs with technical SEO checks. The compact result separates prioritised fixes from review observations. Set includePages/includeIssues for raw data.",
26657
28186
  inputSchema: {
26658
- urls: z$1.array(z$1.string().url()).min(1),
28187
+ urls: z$1.array(z$1.string().url()).min(1).max(MAX_CRAWL_PAGES),
26659
28188
  site: z$1.string().optional(),
26660
28189
  googleAnalyticsPropertyId: z$1.string().optional(),
26661
- maxPages: z$1.number().int().positive().optional(),
26662
- concurrency: z$1.number().int().positive().optional(),
28190
+ maxPages: crawlPageLimit,
28191
+ concurrency: boundedPositiveInteger(MAX_CRAWL_CONCURRENCY),
26663
28192
  fetchIntervalCap: z$1.number().int().positive().optional(),
26664
28193
  fetchIntervalMs: z$1.number().int().positive().optional(),
26665
28194
  refresh: z$1.boolean().optional(),
@@ -26703,8 +28232,8 @@ function registerCrawlerTools(server) {
26703
28232
  url: z$1.string().url().optional(),
26704
28233
  reportId: z$1.string().optional(),
26705
28234
  site: z$1.string().optional(),
26706
- maxPages: z$1.number().int().positive().optional(),
26707
- maxDepth: z$1.number().int().nonnegative().optional(),
28235
+ maxPages: crawlPageLimit,
28236
+ maxDepth: crawlDepthLimit,
26708
28237
  fetchIntervalCap: z$1.number().int().positive().optional(),
26709
28238
  fetchIntervalMs: z$1.number().int().positive().optional(),
26710
28239
  refresh: z$1.boolean().optional(),
@@ -26730,7 +28259,8 @@ function registerCrawlerTools(server) {
26730
28259
  category,
26731
28260
  limit
26732
28261
  });
26733
- return toolSuccess(`Found ${groups.length} top fix groups for ${report.config.url}.`, {
28262
+ const coverage = report.status === "completed" ? "" : ` Coverage is ${report.status}: ${countLabel(report.summary.crawledUrls, "URL")} crawled from ${countLabel(report.summary.discoveredUrls, "discovered URL")}. Review the returned warnings and caveats before treating this as sitewide.`;
28263
+ return toolSuccess(`Found ${countLabel(groups.length, "top fix group")} for ${report.config.url}.${coverage}`, {
26734
28264
  url: report.config.url,
26735
28265
  reportId: report.id,
26736
28266
  summary: report.summary,
@@ -26756,7 +28286,7 @@ function registerCrawlerTools(server) {
26756
28286
  inputSchema: { category: crawlerRuleCategorySchema.optional() }
26757
28287
  }, async ({ category }) => {
26758
28288
  const rules = listRules().filter((rule) => category ? rule.category === category : true);
26759
- return toolSuccess(`Found ${rules.length} crawler rules.`, { rules });
28289
+ return toolSuccess(`Found ${countLabel(rules.length, "crawler rule")}.`, { rules });
26760
28290
  });
26761
28291
  server.registerTool("seo_affected_urls", {
26762
28292
  description: "Return affected URLs for a saved or freshly crawled report, filtered by rule, category, or severity.",
@@ -26767,7 +28297,7 @@ function registerCrawlerTools(server) {
26767
28297
  ruleId: z$1.string().optional(),
26768
28298
  category: z$1.string().optional(),
26769
28299
  severity: z$1.string().optional(),
26770
- maxPages: z$1.number().int().positive().optional(),
28300
+ maxPages: crawlPageLimit,
26771
28301
  fetchIntervalCap: z$1.number().int().positive().optional(),
26772
28302
  fetchIntervalMs: z$1.number().int().positive().optional(),
26773
28303
  refresh: z$1.boolean().optional(),
@@ -26811,7 +28341,7 @@ function registerCrawlerTools(server) {
26811
28341
  url: z$1.string().url().optional(),
26812
28342
  reportId: z$1.string().optional(),
26813
28343
  site: z$1.string().optional(),
26814
- maxPages: z$1.number().int().positive().optional(),
28344
+ maxPages: crawlPageLimit,
26815
28345
  fetchIntervalCap: z$1.number().int().positive().optional(),
26816
28346
  fetchIntervalMs: z$1.number().int().positive().optional(),
26817
28347
  refresh: z$1.boolean().optional(),
@@ -26846,7 +28376,7 @@ function registerCrawlerTools(server) {
26846
28376
  url: z$1.string().url().optional(),
26847
28377
  reportId: z$1.string().optional(),
26848
28378
  site: z$1.string().optional(),
26849
- maxPages: z$1.number().int().positive().optional(),
28379
+ maxPages: crawlPageLimit,
26850
28380
  fetchIntervalCap: z$1.number().int().positive().optional(),
26851
28381
  fetchIntervalMs: z$1.number().int().positive().optional(),
26852
28382
  refresh: z$1.boolean().optional()
@@ -26880,7 +28410,7 @@ function registerCrawlerTools(server) {
26880
28410
  url: z$1.string().url().optional(),
26881
28411
  reportId: z$1.string().optional(),
26882
28412
  site: z$1.string().optional(),
26883
- maxPages: z$1.number().int().positive().optional(),
28413
+ maxPages: crawlPageLimit,
26884
28414
  fetchIntervalCap: z$1.number().int().positive().optional(),
26885
28415
  fetchIntervalMs: z$1.number().int().positive().optional(),
26886
28416
  refresh: z$1.boolean().optional()
@@ -26910,7 +28440,7 @@ function registerCrawlerTools(server) {
26910
28440
  url: z$1.string().url().optional(),
26911
28441
  reportId: z$1.string().optional(),
26912
28442
  site: z$1.string().optional(),
26913
- maxPages: z$1.number().int().positive().optional(),
28443
+ maxPages: crawlPageLimit,
26914
28444
  fetchIntervalCap: z$1.number().int().positive().optional(),
26915
28445
  fetchIntervalMs: z$1.number().int().positive().optional(),
26916
28446
  refresh: z$1.boolean().optional()
@@ -26940,7 +28470,7 @@ function registerCrawlerTools(server) {
26940
28470
  url: z$1.string().url().optional(),
26941
28471
  reportId: z$1.string().optional(),
26942
28472
  site: z$1.string().optional(),
26943
- maxPages: z$1.number().int().positive().optional(),
28473
+ maxPages: crawlPageLimit,
26944
28474
  fetchIntervalCap: z$1.number().int().positive().optional(),
26945
28475
  fetchIntervalMs: z$1.number().int().positive().optional(),
26946
28476
  refresh: z$1.boolean().optional()
@@ -26970,7 +28500,7 @@ function registerCrawlerTools(server) {
26970
28500
  url: z$1.string().url().optional(),
26971
28501
  reportId: z$1.string().optional(),
26972
28502
  site: z$1.string().optional(),
26973
- maxPages: z$1.number().int().positive().optional(),
28503
+ maxPages: crawlPageLimit,
26974
28504
  maxUrls: z$1.number().int().positive().optional(),
26975
28505
  tokenBudget: z$1.number().int().positive().optional(),
26976
28506
  exclude: z$1.array(z$1.string()).optional(),
@@ -27011,7 +28541,7 @@ function registerCrawlerTools(server) {
27011
28541
  url: z$1.string().url().optional(),
27012
28542
  reportId: z$1.string().optional(),
27013
28543
  site: z$1.string().optional(),
27014
- maxPages: z$1.number().int().positive().optional(),
28544
+ maxPages: crawlPageLimit,
27015
28545
  fetchIntervalCap: z$1.number().int().positive().optional(),
27016
28546
  fetchIntervalMs: z$1.number().int().positive().optional(),
27017
28547
  refresh: z$1.boolean().optional()
@@ -27041,7 +28571,7 @@ function registerCrawlerTools(server) {
27041
28571
  url: z$1.string().url().optional(),
27042
28572
  reportId: z$1.string().optional(),
27043
28573
  site: z$1.string().optional(),
27044
- maxPages: z$1.number().int().positive().optional(),
28574
+ maxPages: crawlPageLimit,
27045
28575
  maxConcepts: z$1.number().int().min(1).max(100).optional(),
27046
28576
  includeFiles: z$1.boolean().optional(),
27047
28577
  title: z$1.string().min(1).max(200).optional(),
@@ -27990,7 +29520,7 @@ function registerPseoTools(server) {
27990
29520
  includeBrand: z$1.boolean().optional(),
27991
29521
  refresh: z$1.boolean().optional(),
27992
29522
  js: z$1.boolean().optional(),
27993
- fetchConcurrency: z$1.number().int().min(1).max(20).optional(),
29523
+ fetchConcurrency: z$1.number().int().min(1).max(16).optional(),
27994
29524
  fetchIntervalCap: z$1.number().int().min(1).max(100).optional(),
27995
29525
  fetchIntervalMs: z$1.number().int().min(100).max(6e4).optional(),
27996
29526
  detail: z$1.enum(["summary", "full"]).optional()
@@ -28063,13 +29593,15 @@ const REPORT_DEPTH = {
28063
29593
  "profile, profileApplicability, dataStatus, and crawl caveats",
28064
29594
  "summary and topActions",
28065
29595
  "HTML and Markdown representation checks with affected URLs",
28066
- "Agent Skills, llms.txt, route manifest, crawler access, and identity evidence"
29596
+ "Agent Skills, llms.txt, route manifest, crawler access, and identity evidence",
29597
+ "endpoint checks for Link headers and optional well-known files, where info means absent and optional",
29598
+ "for any failed or warning check, request seo_describe_report with the check id to get fix guidance"
28067
29599
  ],
28068
29600
  doNotClaim: [
28069
29601
  "A clean content profile is not proof of indexing, rankings, AI mentions, citations, or selection.",
28070
29602
  "API, application, and commerce checks marked notApplicable were outside this content-site run. They did not fail.",
28071
29603
  "An allowed crawler token does not prove that a service fetched or used the page.",
28072
- "Optional discovery files are not Google ranking requirements."
29604
+ "Optional discovery files and emerging endpoints such as MCP server cards, A2A agent cards, OAuth metadata, and auth.md are observations, not requirements or defects."
28073
29605
  ],
28074
29606
  verify: "Run the same content profile against the same public route scope after deployment and confirm each failed or warning check changed.",
28075
29607
  related: [
@@ -29260,16 +30792,19 @@ const REPORT_DEPTH = {
29260
30792
  },
29261
30793
  "site-crawl": {
29262
30794
  readOrder: [
30795
+ "config.strategy first; use health before full on large or unknown sites",
29263
30796
  "status, requestEvidenceStatus, attempted and fetched counts, failures, retained pages, and pageLimitReached",
30797
+ "access crawler identity, blockedRequests, provider counts, and samples",
29264
30798
  "data-source states",
29265
30799
  "warnings and caveats, then opt-in pages, requests, and issues"
29266
30800
  ],
29267
30801
  doNotClaim: [
29268
30802
  "A partial or capped crawl cannot support an all-clear or a definitive zero.",
29269
30803
  "A crawl is not an index, traffic source, or guarantee of complete site coverage.",
29270
- "If JavaScript rendering was disabled, state that discovery boundary."
30804
+ "A health pass checks response access, status, and redirects only. It does not download page bodies or evaluate content, metadata, canonicals, indexability directives, internal links, or rendered HTML. For full crawls, state when JavaScript rendering was disabled.",
30805
+ "A User-Agent can be spoofed. Never ask the user for a broad firewall bypass based on crawler identity alone."
29271
30806
  ],
29272
- verify: "Preserve the report id and settings, then re-run the same configuration to confirm a change.",
30807
+ verify: "Re-run the same health configuration first. Use a full crawl only when the health evidence is clean and the question needs page-body analysis.",
29273
30808
  related: [
29274
30809
  {
29275
30810
  id: "top-fixes",
@@ -29776,11 +31311,15 @@ const REPORT_GUIDANCE = {
29776
31311
  outcome: "A local setup status with the exact problems that need fixing."
29777
31312
  },
29778
31313
  "site-crawl": {
29779
- name: "Technical SEO site crawl",
29780
- description: "Crawl a site, check technical SEO signals, and save a local baseline for follow-up reports.",
29781
- useWhen: ["You need to discover technical issues across linked pages.", "You want a crawl snapshot for later filtering or comparison."],
29782
- avoidWhen: ["You only need to check one page or a known URL list.", "Important content requires browser rendering that is not enabled."],
29783
- outcome: "A crawl summary with page evidence, technical findings, and saved report ID."
31314
+ name: "Sitemap health pass and technical site crawl",
31315
+ description: "Start with a lightweight sitemap health pass, then run a full technical crawl only when page bodies and content-level evidence are needed.",
31316
+ useWhen: [
31317
+ "You need a fast first check of sitemap URLs, statuses, redirects, robots decisions, or access blocks.",
31318
+ "You need to discover technical issues across linked pages.",
31319
+ "You want a crawl snapshot for later filtering or comparison."
31320
+ ],
31321
+ avoidWhen: ["You only need to check one page or a known URL list.", "Do not run a full crawl on a large or unknown site before health=true, or when important content requires browser rendering that is not enabled."],
31322
+ outcome: "A fast sitemap response gate or a full crawl summary with page evidence, technical findings, crawler identity, access guidance, and an optional saved report ID."
29784
31323
  },
29785
31324
  "striking-distance": {
29786
31325
  name: "Queries near page one",
@@ -30004,6 +31543,133 @@ function registerReportTools(server) {
30004
31543
  registerSecondPageTool(server);
30005
31544
  }
30006
31545
  //#endregion
31546
+ //#region packages/mcp/src/agent-output-budget.ts
31547
+ const AGENT_STRUCTURED_OUTPUT_MAX_BYTES = 96 * 1024;
31548
+ function jsonBytes(value) {
31549
+ return Buffer.byteLength(JSON.stringify(value));
31550
+ }
31551
+ function setReturnedBytes(value) {
31552
+ let measured = jsonBytes(value);
31553
+ while (value.outputBudget.returnedBytes !== measured) {
31554
+ value.outputBudget.returnedBytes = measured;
31555
+ measured = jsonBytes(value);
31556
+ }
31557
+ return measured;
31558
+ }
31559
+ function compactValue(value, path, options, omissions) {
31560
+ if (typeof value === "string") {
31561
+ if (value.length <= options.stringLimit) return value;
31562
+ omissions.push({
31563
+ path,
31564
+ kind: "string",
31565
+ available: value.length,
31566
+ returned: options.stringLimit
31567
+ });
31568
+ return `${value.slice(0, Math.max(1, options.stringLimit - 3)).trimEnd()}...`;
31569
+ }
31570
+ if (Array.isArray(value)) {
31571
+ if (value.length > options.arrayLimit) omissions.push({
31572
+ path,
31573
+ kind: "array",
31574
+ available: value.length,
31575
+ returned: options.arrayLimit
31576
+ });
31577
+ return value.slice(0, options.arrayLimit).map((item, index) => compactValue(item, `${path}[${index}]`, options, omissions));
31578
+ }
31579
+ if (!value || typeof value !== "object") return value;
31580
+ const compact = {};
31581
+ for (const [key, item] of Object.entries(value)) {
31582
+ const itemPath = path ? `${path}.${key}` : key;
31583
+ if (key === "markdown") {
31584
+ omissions.push({
31585
+ path: itemPath,
31586
+ kind: "field"
31587
+ });
31588
+ continue;
31589
+ }
31590
+ compact[key] = compactValue(item, itemPath, options, omissions);
31591
+ }
31592
+ return compact;
31593
+ }
31594
+ function compactAgentWorkflowOutput(report) {
31595
+ const originalBytes = jsonBytes(report);
31596
+ for (const options of [
31597
+ {
31598
+ arrayLimit: 10,
31599
+ stringLimit: 4e3
31600
+ },
31601
+ {
31602
+ arrayLimit: 5,
31603
+ stringLimit: 2e3
31604
+ },
31605
+ {
31606
+ arrayLimit: 3,
31607
+ stringLimit: 1e3
31608
+ },
31609
+ {
31610
+ arrayLimit: 1,
31611
+ stringLimit: 500
31612
+ }
31613
+ ]) {
31614
+ const omissions = [];
31615
+ const result = {
31616
+ ...compactValue(report, "", options, omissions),
31617
+ outputBudget: {
31618
+ schemaVersion: 1,
31619
+ maxBytes: AGENT_STRUCTURED_OUTPUT_MAX_BYTES,
31620
+ originalBytes,
31621
+ returnedBytes: 0,
31622
+ truncated: omissions.length > 0,
31623
+ arrayLimit: options.arrayLimit,
31624
+ stringLimit: options.stringLimit,
31625
+ omissions: omissions.slice(0, 100),
31626
+ omissionsTruncated: omissions.length > 100,
31627
+ detail: "Counts, provenance, caveats, and compact evidence are retained. Run the related focused report when more rows are needed."
31628
+ }
31629
+ };
31630
+ if (setReturnedBytes(result) <= 98304) return result;
31631
+ }
31632
+ const fallbackOmissions = [];
31633
+ const fallbackOutput = report.output;
31634
+ const retainedEvidence = compactValue(Object.fromEntries(Object.entries({
31635
+ provenance: report.provenance ?? fallbackOutput?.provenance,
31636
+ caveats: report.caveats ?? fallbackOutput?.caveats,
31637
+ warnings: report.warnings ?? fallbackOutput?.warnings,
31638
+ selection: report.selection ?? fallbackOutput?.selection,
31639
+ totals: report.totals ?? fallbackOutput?.totals
31640
+ }).filter(([, value]) => value !== void 0)), "retainedEvidence", {
31641
+ arrayLimit: 10,
31642
+ stringLimit: 2e3
31643
+ }, fallbackOmissions);
31644
+ const fallback = {
31645
+ workflow: report.workflow,
31646
+ site: report.site,
31647
+ generatedAt: report.generatedAt,
31648
+ summary: report.summary,
31649
+ steps: report.steps,
31650
+ actions: Array.isArray(report.actions) ? report.actions.slice(0, 5) : [],
31651
+ output: {
31652
+ dataStatus: report.output?.dataStatus ?? "partial",
31653
+ detail: "The detailed workflow exceeded the agent output budget. Run the related focused reports for section evidence."
31654
+ },
31655
+ retainedEvidence,
31656
+ outputBudget: {
31657
+ schemaVersion: 1,
31658
+ maxBytes: AGENT_STRUCTURED_OUTPUT_MAX_BYTES,
31659
+ originalBytes,
31660
+ returnedBytes: 0,
31661
+ truncated: true,
31662
+ fallback: true,
31663
+ omissions: [{
31664
+ path: "output",
31665
+ kind: "field"
31666
+ }, ...fallbackOmissions.slice(0, 99)]
31667
+ }
31668
+ };
31669
+ setReturnedBytes(fallback);
31670
+ return fallback;
31671
+ }
31672
+ //#endregion
30007
31673
  //#region packages/mcp/src/workflow-tools.ts
30008
31674
  const technicalWatchInputSchema = z$1.strictObject({
30009
31675
  site: z$1.string().trim().min(1).max(2048),
@@ -30032,10 +31698,12 @@ const technicalWatchInputSchema = z$1.strictObject({
30032
31698
  });
30033
31699
  });
30034
31700
  function workflowSuccess(result) {
30035
- return toolSuccess(result.summary, {
31701
+ const presentation = workflowPresentation(result);
31702
+ const structured = compactAgentWorkflowOutput({
30036
31703
  ...result,
30037
- presentation: workflowPresentation(result)
30038
- }, { markdown: renderWorkflowMarkdown(result) });
31704
+ presentation
31705
+ });
31706
+ return toolSuccess(result.summary, structured, { markdown: renderWorkflowMarkdown(result) });
30039
31707
  }
30040
31708
  function registerWorkflowTools(server) {
30041
31709
  server.registerTool("seo_workflow_diagnose_property", {
@@ -30388,6 +32056,226 @@ function getReportDefinition(id) {
30388
32056
  return definitionsById.get(id);
30389
32057
  }
30390
32058
  //#endregion
32059
+ //#region packages/mcp/src/check-fixes.ts
32060
+ const CHECK_FIXES = { "agent-readiness": {
32061
+ "crawler-access": {
32062
+ goal: "Keep robots.txt policy for AI crawler tokens intentional.",
32063
+ fix: "Review the blocked tokens in the check evidence. Remove accidental Disallow rules for crawlers you want, and keep deliberate blocks documented so nobody reverts them by mistake.",
32064
+ prompt: "Open robots.txt and list every rule that affects the AI crawler tokens named in the agent-readiness crawler-access evidence. For each blocked token, decide with the site owner whether the block is intentional. Remove only the accidental rules, redeploy, then re-run the agent-readiness report and confirm the crawler-access check lists no unintended blocks.",
32065
+ resources: [{
32066
+ title: "RFC 9309: Robots Exclusion Protocol",
32067
+ url: "https://www.rfc-editor.org/rfc/rfc9309"
32068
+ }],
32069
+ verify: "Re-run the report and confirm the blocked list contains only intentional tokens. An allowed token still does not prove the service crawls the site."
32070
+ },
32071
+ "protocol-canonicalization": {
32072
+ goal: "Serve one canonical HTTPS origin with a permanent redirect and HSTS.",
32073
+ fix: "Redirect the HTTP variant to HTTPS with a 301 or 308, redirect the alternate www or bare host to the preferred host, and send Strict-Transport-Security once HTTPS is stable everywhere.",
32074
+ prompt: "Configure the web server or CDN so http:// requests return one permanent redirect to the https:// start URL, and the non-preferred host redirects to the preferred host. Add a Strict-Transport-Security header on HTTPS responses. Then re-run the agent-readiness report and confirm protocol-canonicalization passes.",
32075
+ resources: [{
32076
+ title: "MDN: Strict-Transport-Security",
32077
+ url: "https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Strict-Transport-Security"
32078
+ }],
32079
+ verify: "curl -I the HTTP and alternate-host variants and confirm one permanent redirect each, then re-run the report."
32080
+ },
32081
+ "markdown-coverage": {
32082
+ goal: "Give every public HTML page a Markdown representation.",
32083
+ fix: "Serve text/markdown through content negotiation or advertise one working Markdown alternative per page with a link rel=\"alternate\" type=\"text/markdown\" tag, generated from the same content source as the HTML.",
32084
+ prompt: "For each URL listed in the markdown-coverage check, make the route return Markdown when requested with Accept: text/markdown, or publish a .md alternate advertised from the HTML head. Generate the Markdown from the same source as the HTML so the two cannot drift. Re-run the agent-readiness report and confirm markdown-coverage passes.",
32085
+ resources: [{
32086
+ title: "Markdown for Agents",
32087
+ url: "https://developers.cloudflare.com/agents/markdown-for-agents/"
32088
+ }],
32089
+ verify: "Request each affected URL with Accept: text/markdown and confirm a 200 text/markdown response."
32090
+ },
32091
+ "markdown-negotiation": {
32092
+ goal: "Make Markdown content negotiation honest and cache-safe.",
32093
+ fix: "Honour Accept q-values, send Vary: Accept on negotiated responses, and keep the explicit .md file and the negotiated response byte-identical.",
32094
+ prompt: "Fix the routes listed in the markdown-negotiation check so that Accept: text/markdown returns Markdown, an Accept header that refuses Markdown returns HTML, every negotiated response carries Vary: Accept, and the explicit alternate matches the negotiated body byte for byte. Re-run the agent-readiness report and confirm markdown-negotiation passes.",
32095
+ resources: [{
32096
+ title: "RFC 9110: Content negotiation",
32097
+ url: "https://www.rfc-editor.org/rfc/rfc9110#name-content-negotiation"
32098
+ }],
32099
+ verify: "Request one affected URL twice, once with Accept: text/markdown and once with text/markdown;q=0, and confirm the content types differ while Vary: Accept is present."
32100
+ },
32101
+ "markdown-determinism": {
32102
+ goal: "Return identical Markdown bytes for repeated requests.",
32103
+ fix: "Remove timestamps, request ids, random ordering, or per-request rewriting from the Markdown pipeline so the same route always produces the same bytes.",
32104
+ prompt: "Fetch one affected URL from the markdown-determinism check twice and diff the responses. Remove whatever changes between requests, usually timestamps or randomised output, from the generator. Re-run the agent-readiness report and confirm markdown-determinism passes.",
32105
+ resources: [],
32106
+ verify: "Fetch the same Markdown URL twice and confirm the SHA-256 digests match."
32107
+ },
32108
+ "markdown-token-estimates": {
32109
+ goal: "Publish a stable token estimate on Markdown responses.",
32110
+ fix: "Add an X-Markdown-Tokens header with a deterministic estimate, and keep the value identical between the explicit and negotiated responses for the same document.",
32111
+ prompt: "Add an X-Markdown-Tokens response header to the Markdown routes listed in the markdown-token-estimates check, computed deterministically from the response body. Re-run the agent-readiness report and confirm the check passes.",
32112
+ resources: [],
32113
+ verify: "Request an affected URL and confirm the header is present and unchanged on a second request."
32114
+ },
32115
+ "markdown-quality": {
32116
+ goal: "Keep Markdown alternatives clean, structured, and complete.",
32117
+ fix: "Fix the shared HTML to Markdown converter rather than individual files. Keep one H1 and the frontmatter title, exclude layout components, and stop SVG, script, and style markup leaking into the output.",
32118
+ prompt: "Open the Markdown for each URL in the markdown-quality check and identify the defects the evidence names, such as leaked markup, duplicate prose, or navigation-only output. Fix the converter or component exclusion rules that cause them, then re-run the agent-readiness report and confirm markdown-quality passes.",
32119
+ resources: [],
32120
+ verify: "Read one regenerated Markdown file end to end and confirm the useful copy survived without presentation markup."
32121
+ },
32122
+ "agent-skills": {
32123
+ goal: "Publish a valid, digest-verified Agent Skills index.",
32124
+ fix: "Serve /.well-known/agent-skills/index.json with a $schema field and a skills array. Each skill needs a same-origin Markdown SKILL.md with name and description frontmatter, and a sha256 digest generated from the exact deployed bytes.",
32125
+ prompt: "Publish or repair the Agent Skills discovery index at /.well-known/agent-skills/index.json. Regenerate every declared digest from the deployed SKILL.md bytes, keep skill URLs same-origin, serve them as text/markdown with an Access-Control-Allow-Origin header, and confirm each file has name and description frontmatter. Re-run the agent-readiness report and confirm agent-skills passes.",
32126
+ resources: [{
32127
+ title: "Agent Skills discovery specification",
32128
+ url: "https://agentskills.io/specification"
32129
+ }],
32130
+ verify: "Fetch the index and one skill file, recompute the SHA-256 digest, and confirm it matches the declared value."
32131
+ },
32132
+ "llms-txt": {
32133
+ goal: "Keep llms.txt short, stable, and pointing at pages that resolve.",
32134
+ fix: "Curate the file to useful entry points. Remove duplicate, redirected, broken, and non-indexable links, keep it under the size cap, and generate it deterministically so repeated fetches return identical bytes.",
32135
+ prompt: "Fix the llms.txt issues listed in the check evidence: remove or repair every malformed, duplicate, redirected, broken, or non-indexable link, and make the file deterministic. The generate-llms-txt report can rebuild it from the crawl. Re-run the agent-readiness report and confirm llms-txt passes. Do not present llms.txt as a Google ranking factor.",
32136
+ resources: [{
32137
+ title: "llms.txt proposal",
32138
+ url: "https://llmstxt.org/"
32139
+ }, {
32140
+ title: "Google guidance on llms.txt",
32141
+ url: "https://developers.google.com/search/updates#clarifying-guidance-on-llms-txt-files"
32142
+ }],
32143
+ verify: "Fetch llms.txt twice and confirm identical bytes, then spot-check that each listed link returns 200 without redirecting."
32144
+ },
32145
+ "llms-full-txt": {
32146
+ goal: "Publish llms-full.txt only when a consumer wants the expanded file.",
32147
+ fix: "Generate /llms-full.txt from the same source as llms.txt, serve it as text/plain or text/markdown, and keep it deterministic. Skip it entirely if nothing consumes it.",
32148
+ prompt: "If an intended consumer needs the expanded companion file, generate /llms-full.txt deterministically from the same content source as llms.txt and serve it with a text content type. Re-run the agent-readiness report and confirm the llms-full-txt check reflects the published file.",
32149
+ resources: [{
32150
+ title: "llms.txt proposal",
32151
+ url: "https://llmstxt.org/"
32152
+ }],
32153
+ verify: "Fetch /llms-full.txt and confirm a 200 with a text content type and stable bytes across two requests."
32154
+ },
32155
+ "content-signals": {
32156
+ goal: "Declare one deliberate Content Signals policy.",
32157
+ fix: "Add a Content-Signal directive to robots.txt stating the search, ai-input, and ai-train preferences the site owner actually wants. If response headers also carry the policy, keep every surface identical.",
32158
+ prompt: "Agree the content usage policy with the site owner, then add one Content-Signal line to robots.txt, for example Content-Signal: search=yes, ai-input=yes, ai-train=no. If HTML or Markdown responses also send a content-signal header, make the values match robots.txt exactly. Re-run the agent-readiness report and confirm content-signals passes.",
32159
+ resources: [{
32160
+ title: "Content Signals policy",
32161
+ url: "https://contentsignals.org/"
32162
+ }],
32163
+ verify: "Fetch robots.txt and one page and confirm every observed Content-Signal value is identical."
32164
+ },
32165
+ "route-manifest": {
32166
+ goal: "Publish a deterministic route manifest when HTML and Markdown parity matters.",
32167
+ fix: "Generate /agent-routes.json from the build with a pages array of htmlPath and markdownPath entries, so a crawl can prove no orphan or missing Markdown routes were deployed.",
32168
+ prompt: "Generate /agent-routes.json at build time listing every public HTML route and its Markdown alternate. Fix any routes the route-manifest evidence lists as missing or orphaned, then re-run the agent-readiness report and confirm route-manifest passes.",
32169
+ resources: [],
32170
+ verify: "Compare the manifest against the deployed routes and confirm no missing or orphan entries remain."
32171
+ },
32172
+ "link-headers": {
32173
+ goal: "Advertise machine-readable resources with Link response headers.",
32174
+ fix: "Add Link headers to the start page pointing at resources that exist. Use registered relation types such as api-catalog, service-desc, service-doc, or describedby, and emerging types such as llms-txt or agent-skills only when the target resolves.",
32175
+ prompt: "Add Link response headers to the homepage that point at real machine-readable resources, for example Link: </.well-known/api-catalog>; rel=\"api-catalog\" or Link: </llms.txt>; rel=\"llms-txt\"; type=\"text/markdown\". Confirm every advertised target returns 200, then re-run the agent-readiness report and confirm link-headers passes.",
32176
+ resources: [{
32177
+ title: "RFC 8288: Web Linking",
32178
+ url: "https://www.rfc-editor.org/rfc/rfc8288"
32179
+ }, {
32180
+ title: "IANA link relation registry",
32181
+ url: "https://www.iana.org/assignments/link-relations/link-relations.xhtml"
32182
+ }],
32183
+ verify: "curl -I the homepage and confirm the Link header lists the intended relation types and each target resolves."
32184
+ },
32185
+ "mcp-server-card": {
32186
+ goal: "Describe a real public MCP endpoint with a server card.",
32187
+ fix: "Serve /.well-known/mcp/server-card.json with serverInfo (name and version), the transport endpoint, and capabilities. Publish it only when the MCP endpoint actually exists.",
32188
+ prompt: "If this site exposes a public MCP endpoint, publish /.well-known/mcp/server-card.json as JSON with serverInfo, the transport endpoint, and capabilities that match the deployed server. The format is a draft MCP proposal, so note that in any claims. Re-run the agent-readiness report and confirm mcp-server-card reflects the published card.",
32189
+ resources: [{
32190
+ title: "MCP server card proposal (SEP-1649)",
32191
+ url: "https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2127"
32192
+ }],
32193
+ verify: "Fetch the card, confirm it parses as JSON with serverInfo, and confirm the declared endpoint accepts an MCP connection."
32194
+ },
32195
+ "a2a-agent-card": {
32196
+ goal: "Describe a real agent-to-agent endpoint with an agent card.",
32197
+ fix: "Serve /.well-known/agent-card.json with the agent name, url, and capabilities from the A2A specification. Publish it only when the site hosts an A2A endpoint.",
32198
+ prompt: "If this site hosts an agent-to-agent endpoint, publish /.well-known/agent-card.json as JSON with at least name, url, and capabilities matching the deployed endpoint. Re-run the agent-readiness report and confirm a2a-agent-card reflects the published card.",
32199
+ resources: [{
32200
+ title: "A2A protocol specification",
32201
+ url: "https://a2a-protocol.org/latest/specification/"
32202
+ }],
32203
+ verify: "Fetch the card, confirm it parses as JSON with a name, and confirm the declared endpoint responds."
32204
+ },
32205
+ "oauth-discovery": {
32206
+ goal: "Let clients discover the authorization server for protected APIs.",
32207
+ fix: "Publish /.well-known/openid-configuration for OpenID Connect or /.well-known/oauth-authorization-server for plain OAuth 2.0, including issuer, authorization_endpoint, token_endpoint, and jwks_uri.",
32208
+ prompt: "If this origin has OAuth-protected APIs, publish authorization server metadata at /.well-known/oauth-authorization-server or /.well-known/openid-configuration with the issuer and endpoint URLs of the real authorization server. Re-run the agent-readiness report and confirm oauth-discovery reflects the published metadata.",
32209
+ resources: [{
32210
+ title: "RFC 8414: OAuth 2.0 Authorization Server Metadata",
32211
+ url: "https://www.rfc-editor.org/rfc/rfc8414"
32212
+ }, {
32213
+ title: "OpenID Connect Discovery",
32214
+ url: "https://openid.net/specs/openid-connect-discovery-1_0.html"
32215
+ }],
32216
+ verify: "Fetch the metadata, confirm the issuer matches the origin, and complete one token flow against the declared endpoints."
32217
+ },
32218
+ "oauth-protected-resource": {
32219
+ goal: "Tell clients how to obtain tokens for protected resources.",
32220
+ fix: "Publish /.well-known/oauth-protected-resource with the resource identifier, the authorization_servers list, and scopes_supported per RFC 9728.",
32221
+ prompt: "If this origin serves OAuth-protected APIs, publish /.well-known/oauth-protected-resource as JSON with resource, authorization_servers, and scopes_supported values that match the real deployment. Re-run the agent-readiness report and confirm oauth-protected-resource reflects the published metadata.",
32222
+ resources: [{
32223
+ title: "RFC 9728: OAuth 2.0 Protected Resource Metadata",
32224
+ url: "https://www.rfc-editor.org/rfc/rfc9728"
32225
+ }],
32226
+ verify: "Fetch the metadata and confirm each listed authorization server issues tokens the resource accepts."
32227
+ },
32228
+ "api-catalog": {
32229
+ goal: "Make published APIs discoverable through an API catalog.",
32230
+ fix: "Serve /.well-known/api-catalog as application/linkset+json with a linkset array. Each entry needs an anchor URL plus service-desc and service-doc links for the API.",
32231
+ prompt: "If this site publishes APIs, create /.well-known/api-catalog returning application/linkset+json with one linkset entry per API, each carrying an anchor and service-desc or service-doc links that resolve. Re-run the agent-readiness report and confirm api-catalog reflects the published catalog.",
32232
+ resources: [{
32233
+ title: "RFC 9727: api-catalog well-known URI",
32234
+ url: "https://www.rfc-editor.org/rfc/rfc9727"
32235
+ }, {
32236
+ title: "RFC 9264: Linkset",
32237
+ url: "https://www.rfc-editor.org/rfc/rfc9264"
32238
+ }],
32239
+ verify: "Fetch the catalog, confirm the linkset parses, and confirm each linked description resolves."
32240
+ },
32241
+ "web-bot-auth": {
32242
+ goal: "Publish signing keys when this operator signs its own bot requests.",
32243
+ fix: "Serve /.well-known/http-message-signatures-directory with the JSON Web Key Set used to sign outbound bot requests, per the Web Bot Auth draft.",
32244
+ prompt: "If this operator runs bots that sign requests with Web Bot Auth, publish the signing keys as a JWKS at /.well-known/http-message-signatures-directory. Re-run the agent-readiness report and confirm web-bot-auth reflects the published directory.",
32245
+ resources: [{
32246
+ title: "Web Bot Auth draft",
32247
+ url: "https://datatracker.ietf.org/doc/draft-meunier-web-bot-auth-architecture/"
32248
+ }],
32249
+ verify: "Fetch the directory, confirm the keys array parses, and confirm a signed request validates against a published key."
32250
+ },
32251
+ "auth-md": {
32252
+ goal: "Explain agent registration when agents can really sign up.",
32253
+ fix: "Serve /auth.md as Markdown describing how an agent registers and authenticates, and pair it with real OAuth discovery metadata at the well-known paths.",
32254
+ prompt: "If agents can register for and authenticate with this site, publish /auth.md as a Markdown guide covering registration, credential types, and token endpoints, consistent with the OAuth metadata the site publishes. Re-run the agent-readiness report and confirm auth-md reflects the published file.",
32255
+ resources: [{
32256
+ title: "auth.md proposal",
32257
+ url: "https://github.com/workos/auth.md"
32258
+ }],
32259
+ verify: "Fetch /auth.md, confirm a text or Markdown content type, and follow the documented registration steps once."
32260
+ },
32261
+ "identity-graph": {
32262
+ goal: "Connect truthful WebSite, creator, and page-level structured data.",
32263
+ fix: "Add JSON-LD that links the WebSite node, a Person or Organization creator, and page-level types. Use sameAs only for official profiles, and add SoftwareApplication only when the site represents software.",
32264
+ prompt: "Add or repair the JSON-LD identity graph: one WebSite node, a truthful Person or Organization creator or publisher, and appropriate page-level types, all linked by id references. Use sameAs only for official profiles of the same entity. Re-run the agent-readiness report and confirm identity-graph passes.",
32265
+ resources: [{
32266
+ title: "Schema.org WebSite",
32267
+ url: "https://schema.org/WebSite"
32268
+ }],
32269
+ verify: "Validate the JSON-LD parses and confirm each claimed profile link belongs to the same entity."
32270
+ }
32271
+ } };
32272
+ function listFixableChecks(reportId) {
32273
+ return Object.keys(CHECK_FIXES[reportId] ?? {}).sort();
32274
+ }
32275
+ function getCheckFix(reportId, checkId) {
32276
+ return CHECK_FIXES[reportId]?.[checkId];
32277
+ }
32278
+ //#endregion
30391
32279
  //#region packages/mcp/src/reports.ts
30392
32280
  function validationMessage(error) {
30393
32281
  return error.issues.map((issue) => {
@@ -30397,9 +32285,25 @@ function validationMessage(error) {
30397
32285
  function listReports(category) {
30398
32286
  return listReportDefinitions(category);
30399
32287
  }
32288
+ function describeReportCheck(id, check) {
32289
+ const report = getReportDefinition(id);
32290
+ if (!report) throw new SeoError("INVALID_INPUT", `Unknown report: ${id}.`);
32291
+ const fixableChecks = listFixableChecks(report.id);
32292
+ if (fixableChecks.length === 0) throw new SeoError("INVALID_INPUT", `Report ${report.id} has no per-check fix guidance yet.`);
32293
+ const checkFix = getCheckFix(report.id, check);
32294
+ if (!checkFix) throw new SeoError("INVALID_INPUT", `Unknown check ${check} for ${report.id}. Fix guidance exists for: ${fixableChecks.join(", ")}.`);
32295
+ return {
32296
+ id: report.id,
32297
+ category: report.category,
32298
+ name: report.name,
32299
+ check,
32300
+ checkFix
32301
+ };
32302
+ }
30400
32303
  function describeReport(id) {
30401
32304
  const report = getReportDefinition(id);
30402
32305
  if (!report) throw new SeoError("INVALID_INPUT", `Unknown report: ${id}.`);
32306
+ const fixableChecks = listFixableChecks(report.id);
30403
32307
  return {
30404
32308
  id: report.id,
30405
32309
  category: report.category,
@@ -30412,6 +32316,7 @@ function describeReport(id) {
30412
32316
  doNotClaim: report.doNotClaim,
30413
32317
  verify: report.verify,
30414
32318
  related: report.related,
32319
+ ...fixableChecks.length > 0 ? { fixableChecks } : {},
30415
32320
  inputSchema: z$1.toJSONSchema(report.inputSchema)
30416
32321
  };
30417
32322
  }
@@ -30452,16 +32357,23 @@ function registerDiscoveryTools(server, options = {}) {
30452
32357
  });
30453
32358
  });
30454
32359
  server.registerTool("seo_describe_report", {
30455
- description: "Explain when to use one SEO report, what it returns, and which parameters it accepts",
30456
- inputSchema: { id: z$1.string().trim().min(1).max(100) },
32360
+ description: "Explain when to use one SEO report, what it returns, and which parameters it accepts. Pass check to get fix guidance for one failed check id.",
32361
+ inputSchema: {
32362
+ id: z$1.string().trim().min(1).max(100),
32363
+ check: z$1.string().trim().min(1).max(100).optional()
32364
+ },
30457
32365
  outputSchema: openOutputSchema,
30458
32366
  annotations: {
30459
32367
  readOnlyHint: true,
30460
32368
  destructiveHint: false,
30461
32369
  idempotentHint: true
30462
32370
  }
30463
- }, async ({ id }) => {
32371
+ }, async ({ id, check }) => {
30464
32372
  try {
32373
+ if (check !== void 0) {
32374
+ const report = describeReportCheck(id, check);
32375
+ return toolSuccess(`${report.id} ${report.check}: ${report.checkFix.goal}`, { report });
32376
+ }
30465
32377
  const report = describeReport(id);
30466
32378
  return toolSuccess(`${report.id}: ${report.description}`, { report });
30467
32379
  } catch (error) {
@@ -30514,6 +32426,6 @@ async function startMcpServer(opts = {}) {
30514
32426
  await server.connect(transport);
30515
32427
  }
30516
32428
  //#endregion
30517
- export { REPORT_CATEGORIES, createServer, describeReport, executeReport, listReports, runReport, startMcpServer };
32429
+ export { REPORT_CATEGORIES, createServer, describeReport, describeReportCheck, executeReport, listReports, runReport, startMcpServer };
30518
32430
 
30519
32431
  //# sourceMappingURL=mcp.js.map