seo 0.2.8 → 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-BDez60C3.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;
@@ -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({
@@ -6704,6 +7538,7 @@ async function collectAgentDiscovery(input) {
6704
7538
  routeManifest,
6705
7539
  agentSkills,
6706
7540
  llmsTxt,
7541
+ endpointDiscovery,
6707
7542
  contentSignals: {
6708
7543
  htmlValues: [...new Set(htmlContentSignals)].sort(),
6709
7544
  markdownValues: [...new Set(markdownContentSignals)].sort(),
@@ -6859,7 +7694,59 @@ function markdownChecks(discovery) {
6859
7694
  })
6860
7695
  ];
6861
7696
  }
6862
- 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) {
6863
7750
  const skills = discovery.agentSkills;
6864
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);
6865
7752
  const llms = discovery.llmsTxt;
@@ -6903,14 +7790,7 @@ function discoveryChecks(discovery) {
6903
7790
  ...llms.missingCrawlRoutes
6904
7791
  ].slice(0, 25)
6905
7792
  }),
6906
- check$1("discovery", {
6907
- id: "content-signals",
6908
- status: discovery.contentSignals.consistent === null ? "unknown" : discovery.contentSignals.consistent ? "pass" : "warning",
6909
- title: discovery.contentSignals.consistent ? "HTML and Markdown publish one explicit Content Signals policy" : "Content Signals are missing or inconsistent across representations",
6910
- 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.`,
6911
- action: "Choose the site policy deliberately and return the same Content-Signal value on HTML and Markdown responses.",
6912
- evidence: discovery.contentSignals
6913
- }),
7793
+ contentSignalsCheck(discovery, robotsContentSignals),
6914
7794
  check$1("discovery", {
6915
7795
  id: "route-manifest",
6916
7796
  status: !discovery.routeManifest.valid ? "info" : discovery.routeManifest.missingHtmlRoutes.length === 0 && discovery.routeManifest.missingMarkdownRoutes.length === 0 && discovery.routeManifest.orphanMarkdownRoutes.length === 0 ? "pass" : "warning",
@@ -6921,6 +7801,126 @@ function discoveryChecks(discovery) {
6921
7801
  })
6922
7802
  ];
6923
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
+ }
6924
7924
  function accessChecks(report, discovery) {
6925
7925
  const bots = report.ai?.robotsTxt?.botAccess ?? [];
6926
7926
  const blocked = bots.filter((bot) => bot.allowed === false);
@@ -7022,7 +8022,8 @@ function agentReadiness(report) {
7022
8022
  section$1("profile", "Profile scope", profileChecks(discovery)),
7023
8023
  section$1("access", "Crawler and protocol access", accessChecks(report, discovery)),
7024
8024
  section$1("representations", "HTML and Markdown representations", markdownChecks(discovery)),
7025
- 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)),
7026
8027
  section$1("identity", "Identity evidence", identityChecks(report))
7027
8028
  ];
7028
8029
  const checks = sections.flatMap((item) => item.checks);
@@ -7059,6 +8060,7 @@ function agentReadiness(report) {
7059
8060
  ...discovery.warnings,
7060
8061
  "A clean content profile does not prove indexing, rankings, AI mentions, citations, or selection.",
7061
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.",
7062
8064
  "API, application, and commerce checks were not applicable to this content-site run."
7063
8065
  ]
7064
8066
  };
@@ -7660,6 +8662,15 @@ function originHost(url) {
7660
8662
  function cleanTitle(page) {
7661
8663
  return page.title ?? page.h1 ?? (new URL(page.finalUrl).pathname || page.finalUrl);
7662
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
+ }
7663
8674
  function sectionForPage(page) {
7664
8675
  const path = new URL(page.finalUrl).pathname.toLowerCase();
7665
8676
  if (/\/(docs|documentation|guide|guides|help|kb|learn)\b/.test(path)) return "Docs and guides";
@@ -7792,7 +8803,7 @@ function generateLlmsTxt(report, options = {}) {
7792
8803
  let sectionCount = 0;
7793
8804
  for (const page of sectionPages) {
7794
8805
  const title = cleanTitle(page).replace(/\]/g, "\\]");
7795
- const note = page.metaDescription ? ` - ${page.metaDescription.replace(/\s+/g, " ").slice(0, 160)}` : "";
8806
+ const note = page.metaDescription ? ` - ${truncateAtWord(page.metaDescription, 160)}` : "";
7796
8807
  const line = `- [${title}](${page.finalUrl})${note}`;
7797
8808
  if (estimatedTokens([
7798
8809
  ...lines,
@@ -7810,14 +8821,29 @@ function generateLlmsTxt(report, options = {}) {
7810
8821
  counts[sectionName] = sectionCount;
7811
8822
  if (sectionCount < sectionPages.length) break;
7812
8823
  }
7813
- 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.`);
7814
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
+ }
7815
8831
  const content = `${lines.join("\n")}\n`;
7816
8832
  return {
7817
8833
  content,
7818
8834
  includedUrls,
7819
8835
  estimatedTokens: estimatedTokens(content),
7820
- 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
+ }
7821
8847
  };
7822
8848
  }
7823
8849
  //#endregion
@@ -8429,6 +9455,24 @@ const RULE_DEFINITIONS = [
8429
9455
  impactIfIgnored: "The page cannot be crawled, ranked, or cited while the connection failure remains.",
8430
9456
  howToVerify: "Re-run the crawl and confirm the URL returns a normal HTTP status instead of status 0."
8431
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
+ },
8432
9476
  {
8433
9477
  id: "client_error",
8434
9478
  title: "Client error",
@@ -8917,6 +9961,7 @@ const RULE_RECOMMENDATIONS = {
8917
9961
  nofollow: "review",
8918
9962
  x_robots_noindex: "review",
8919
9963
  robots_blocked: "review",
9964
+ crawler_access_blocked: "review",
8920
9965
  orphan_page: "review",
8921
9966
  redirected_url: "review",
8922
9967
  slow_response: "review",
@@ -9135,6 +10180,14 @@ function auditCrawlRequests(requests) {
9135
10180
  continue;
9136
10181
  }
9137
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
+ }
9138
10191
  const redirectChain = request.redirectChain ?? [];
9139
10192
  if (redirectChain.length > 0 || !sameUrl(request.requestedUrl, request.finalUrl)) issues.push(requestIssue("redirected_url", request, request.finalUrl ? `Final URL: ${request.finalUrl}` : void 0, {
9140
10193
  requestedUrl: request.requestedUrl,
@@ -9170,6 +10223,7 @@ function auditCrawlPages(pages, opts = {}) {
9170
10223
  }));
9171
10224
  continue;
9172
10225
  }
10226
+ if (page.accessBlock) continue;
9173
10227
  if (page.status >= 500) {
9174
10228
  issues.push(issue("server_error", page, String(page.status), { status: page.status }));
9175
10229
  if ((page.internalInlinkCount ?? 0) > 0) issues.push(issue("broken_internal_link", page, String(page.status), {
@@ -9210,6 +10264,7 @@ function auditCrawlPages(pages, opts = {}) {
9210
10264
  responseTimeMs: page.responseTimeMs,
9211
10265
  thresholdMs: SLOW_RESPONSE_MS
9212
10266
  }));
10267
+ if (page.auditScope === "status") continue;
9213
10268
  if (redirected) continue;
9214
10269
  if (isHtmlPage(page) && (page.sizeBytes ?? 0) > LARGE_HTML_BYTES) issues.push(issue("large_html", page, `${Math.round((page.sizeBytes ?? 0) / 1024)} KB`, {
9215
10270
  sizeBytes: page.sizeBytes,
@@ -9445,6 +10500,9 @@ function crawlSkipReasonCountsFromRecord(input = {}) {
9445
10500
  }
9446
10501
  //#endregion
9447
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;
9448
10506
  const SENSITIVE_FIELD_NAMES = /* @__PURE__ */ new Set([
9449
10507
  "authorization",
9450
10508
  "cookie",
@@ -9460,33 +10518,53 @@ const SENSITIVE_PARAM_PATTERN = /^(access_?token|auth|api_?key|client_?secret|co
9460
10518
  function uniqueSorted(values = []) {
9461
10519
  return [...new Set(values.filter(Boolean))].sort();
9462
10520
  }
9463
- function normalizeFetchRate(input) {
10521
+ function normalizeFetchRate(input, strategy) {
10522
+ const defaultConcurrency = strategy === "health" ? 4 : 8;
9464
10523
  return {
9465
- concurrency: input.fetchRate?.concurrency ?? input.concurrency ?? 8,
10524
+ concurrency: input.fetchRate?.concurrency ?? input.concurrency ?? defaultConcurrency,
9466
10525
  ...input.fetchRate?.intervalCap !== void 0 ? { intervalCap: input.fetchRate.intervalCap } : {},
9467
10526
  ...input.fetchRate?.intervalMs !== void 0 ? { intervalMs: input.fetchRate.intervalMs } : {},
9468
10527
  ...input.fetchRate?.backpressure ? { backpressure: input.fetchRate.backpressure } : {}
9469
10528
  };
9470
10529
  }
9471
10530
  function normalizeCrawlConfig(input) {
10531
+ const strategy = input.strategy ?? "full";
10532
+ const concurrency = input.concurrency ?? (strategy === "health" ? 4 : 8);
9472
10533
  return {
9473
10534
  url: new URL(input.url).toString(),
9474
- mode: input.mode ?? "site",
10535
+ mode: input.mode ?? (strategy === "health" ? "sitemap" : "site"),
10536
+ strategy,
10537
+ ...input.sitemapUrl ? { sitemapUrl: new URL(input.sitemapUrl).toString() } : {},
9475
10538
  urls: uniqueSorted(input.urls ?? []),
9476
10539
  maxPages: input.maxPages ?? 500,
9477
10540
  maxDepth: input.maxDepth ?? 16,
9478
- concurrency: input.concurrency ?? 8,
10541
+ concurrency,
9479
10542
  timeoutMs: input.timeoutMs ?? 2e4,
9480
10543
  include: uniqueSorted(input.include ?? []),
9481
10544
  exclude: uniqueSorted(input.exclude ?? []),
9482
10545
  respectRobots: input.respectRobots ?? true,
9483
10546
  useSitemap: input.useSitemap ?? true,
9484
- checkExternal: input.checkExternal ?? true,
9485
- checkAgentDiscovery: input.checkAgentDiscovery ?? false,
9486
- js: normalizeJavaScriptRenderingMode(input.js),
9487
- refresh: input.refresh ?? false,
9488
- fetchRate: normalizeFetchRate(input)
9489
- };
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;
9490
10568
  }
9491
10569
  function crawlConfigHash(config) {
9492
10570
  const normalized = normalizeCrawlConfig(config);
@@ -9603,10 +10681,12 @@ function summarizeCrawlReport(input) {
9603
10681
  }
9604
10682
  for (const issue of input.issues) byCategory[issue.category] = (byCategory[issue.category] ?? 0) + 1;
9605
10683
  const runStats = crawlRunStats(input.pages, input.stats);
10684
+ const indexabilityPages = input.pages.filter((page) => page.auditScope !== "status");
9606
10685
  return {
9607
10686
  totalPages: input.pages.length,
9608
- indexablePages: input.pages.filter((page) => page.indexable).length,
9609
- 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,
9610
10690
  statusErrors: input.pages.filter((page) => page.status === 0 || page.status >= 400).length,
9611
10691
  ...runStats,
9612
10692
  attemptedRequests: input.requestEvidenceStatus === "unavailable" ? 0 : requests.length,
@@ -9624,6 +10704,23 @@ function summarizeCrawlReport(input) {
9624
10704
  byCategory
9625
10705
  };
9626
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
+ }
9627
10724
  function crawlRunStats(pages, stats = {}) {
9628
10725
  const discovered = /* @__PURE__ */ new Set();
9629
10726
  let observedInternalLinks = 0;
@@ -9717,6 +10814,7 @@ function createCrawlReport(input) {
9717
10814
  status: input.status ?? "completed",
9718
10815
  configHash: crawlConfigHash(config),
9719
10816
  config,
10817
+ access: crawlAccessSummary(requests),
9720
10818
  summary: summarizeCrawlReport({
9721
10819
  pages,
9722
10820
  requests,
@@ -9739,16 +10837,19 @@ function createCrawlReport(input) {
9739
10837
  };
9740
10838
  }
9741
10839
  function normalizeLoadedCrawlReport(report) {
10840
+ const config = sanitizeCrawlConfig(normalizeCrawlConfig(report.config));
9742
10841
  const requestEvidenceStatus = report.requestEvidenceStatus ?? (Object.hasOwn(report, "requests") ? "available" : "unavailable");
9743
10842
  const issues = sanitizeIssues(report.issues ?? []);
9744
10843
  const requests = sortRequests(sanitizeTenantValue(report.requests ?? []) ?? []);
9745
10844
  const pages = sortPages(sanitizePages(deriveInternalLinkAuthority(report.pages ?? [])));
9746
10845
  return {
9747
10846
  ...sanitizeTenantValue(report),
10847
+ config,
10848
+ access: crawlAccessSummary(requests),
9748
10849
  requestEvidenceStatus,
9749
10850
  requests,
9750
10851
  definitionId: report.definitionId ?? crawlDefinitionId({
9751
- config: report.config,
10852
+ config,
9752
10853
  site: report.site,
9753
10854
  googleAnalyticsPropertyId: report.googleAnalyticsPropertyId
9754
10855
  }),
@@ -9804,14 +10905,61 @@ function reportFromJson(value) {
9804
10905
  if (parsed && typeof parsed === "object" && "kind" in parsed && parsed.kind === "seo.crawl_report" && "report" in parsed) return normalizeLoadedCrawlReport(parsed.report);
9805
10906
  return normalizeLoadedCrawlReport(parsed);
9806
10907
  }
9807
- function saveCrawlReportToSqlite(report) {
9808
- if (getDb().prepare(`INSERT INTO crawl_reports
9809
- (id, config_hash, site_url, url, status, total_pages, issue_count, created_at, report_json)
9810
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
9811
- 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) {
9812
- 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);
9813
10960
  if (existing) return toMeta(existing);
9814
10961
  }
10962
+ if (retention === "baseline") pruneAutomaticBaselines();
9815
10963
  return {
9816
10964
  id: report.id,
9817
10965
  configHash: report.configHash,
@@ -9826,10 +10974,13 @@ function saveCrawlReportToSqlite(report) {
9826
10974
  }
9827
10975
  function listCrawlReportsFromSqlite(input = {}) {
9828
10976
  const limit = input.limit ?? 20;
9829
- return (input.site ? getDb().prepare(`SELECT * FROM crawl_reports
9830
- WHERE site_url = ?
9831
- ORDER BY created_at DESC, id DESC
9832
- 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
9833
10984
  ORDER BY created_at DESC, id DESC
9834
10985
  LIMIT ?`).all(limit)).map(toMeta);
9835
10986
  }
@@ -9842,10 +10993,13 @@ function deleteCrawlReportFromSqlite(id) {
9842
10993
  return getDb().prepare("DELETE FROM crawl_reports WHERE id = ?").run(id).changes > 0;
9843
10994
  }
9844
10995
  function latestCrawlReportFromSqlite(site) {
9845
- const row = site ? getDb().prepare(`SELECT report_json FROM crawl_reports
9846
- WHERE site_url = ?
9847
- ORDER BY created_at DESC, id DESC
9848
- 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
9849
11003
  ORDER BY created_at DESC, id DESC
9850
11004
  LIMIT 1`).get();
9851
11005
  if (!row) return void 0;
@@ -9858,8 +11012,8 @@ const crawlReportStore = {
9858
11012
  delete: deleteCrawlReportFromSqlite,
9859
11013
  latest: latestCrawlReportFromSqlite
9860
11014
  };
9861
- function saveCrawlReport(report, store = crawlReportStore) {
9862
- return store.save(report);
11015
+ function saveCrawlReport(report, store = crawlReportStore, options = {}) {
11016
+ return store.save(report, options);
9863
11017
  }
9864
11018
  function listCrawlReports(input = {}, store = crawlReportStore) {
9865
11019
  return store.list(input);
@@ -9902,6 +11056,86 @@ function cancellationRace(signal) {
9902
11056
  });
9903
11057
  }
9904
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
9905
11139
  //#region packages/core/dist/analyze/crawler/external-link-checks.js
9906
11140
  const EXTERNAL_LINK_SELECTION_LIMIT = 200;
9907
11141
  const EXTERNAL_LINK_STATES = [
@@ -10103,18 +11337,13 @@ function resolveFetchedAlias(url, aliases) {
10103
11337
  }
10104
11338
  return current;
10105
11339
  }
10106
- function resolveLinkGraphAliases(linkGraph, aliases) {
10107
- const resolved = {};
10108
- for (const [source, targets] of Object.entries(linkGraph)) {
10109
- const resolvedSource = resolveFetchedAlias(source, aliases);
10110
- let resolvedTargets = resolved[resolvedSource];
10111
- if (!resolvedTargets) {
10112
- resolvedTargets = /* @__PURE__ */ new Set();
10113
- resolved[resolvedSource] = resolvedTargets;
10114
- }
10115
- 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);
10116
11345
  }
10117
- return Object.fromEntries(Object.entries(resolved).map(([source, targets]) => [source, [...targets].sort((left, right) => left < right ? -1 : left > right ? 1 : 0)]));
11346
+ return resolved;
10118
11347
  }
10119
11348
  //#endregion
10120
11349
  //#region packages/core/dist/analyze/crawler/request-evidence.js
@@ -10126,7 +11355,9 @@ function observationFromPage(requestedUrl, page) {
10126
11355
  status: page.status,
10127
11356
  contentType: page.contentType,
10128
11357
  durationMs: page.responseTimeMs,
10129
- redirectChain: page.fetchDiagnostics?.redirectChain
11358
+ redirectChain: page.fetchDiagnostics?.redirectChain,
11359
+ accessBlock: page.accessBlock ?? page.fetchDiagnostics?.accessBlock,
11360
+ robotsTxt: page.robotsTxt
10130
11361
  };
10131
11362
  if (page.extractionStatus === "failed") return {
10132
11363
  ...observation,
@@ -10139,23 +11370,230 @@ function observationFromPage(requestedUrl, page) {
10139
11370
  };
10140
11371
  }
10141
11372
  //#endregion
10142
- //#region packages/core/dist/analyze/crawler/site-crawl-providers.js
10143
- function crawlMetricsWindow(now) {
10144
- return {
10145
- ...defaultDateRange(28, now),
10146
- days: 28
10147
- };
10148
- }
10149
- async function joinAnalytics(input) {
10150
- 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
+ });
10151
11412
  try {
10152
- analytics = await input.fetchLandingPageValues({
10153
- propertyId: input.propertyId,
10154
- startDate: input.window.startDate,
10155
- endDate: input.window.endDate,
10156
- limit: input.limit
10157
- });
10158
- } catch (error) {
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,
11592
+ startDate: input.window.startDate,
11593
+ endDate: input.window.endDate,
11594
+ limit: input.limit
11595
+ });
11596
+ } catch (error) {
10159
11597
  const warning = `Google Analytics metrics unavailable: ${error instanceof Error ? error.message : String(error)}`;
10160
11598
  input.warnings.push(warning);
10161
11599
  return {
@@ -10504,7 +11942,9 @@ function responseObservation(requestedUrl, fetched, extraction, extractionError)
10504
11942
  status: fetched.status,
10505
11943
  contentType: headerValue(fetched.headers, "content-type"),
10506
11944
  durationMs: fetched.diagnostics.durationMs,
10507
- redirectChain: fetched.diagnostics.redirectChain
11945
+ redirectChain: fetched.diagnostics.redirectChain,
11946
+ accessBlock: fetched.diagnostics.accessBlock,
11947
+ robotsTxt: fetched.robotsTxt
10508
11948
  };
10509
11949
  if (extraction === "failed") {
10510
11950
  if (!extractionError) throw new Error("Failed extraction observations require an error.");
@@ -10529,13 +11969,13 @@ function unextractedPage(fetched, contentType) {
10529
11969
  finalUrl: fetched.finalUrl,
10530
11970
  status: fetched.status,
10531
11971
  contentType,
10532
- responseHeaders: safeResponseHeaders(fetched.headers),
10533
11972
  responseTimeMs: fetched.diagnostics.durationMs,
10534
11973
  sizeBytes: Buffer.byteLength(fetched.html),
10535
11974
  usedJs: fetched.usedJs,
10536
11975
  fetchSource: fetched.diagnostics.source,
10537
11976
  cacheState: fetched.diagnostics.cache,
10538
11977
  fetchDiagnostics: fetched.diagnostics,
11978
+ accessBlock: fetched.diagnostics.accessBlock,
10539
11979
  blocked: fetched.diagnostics.blocked,
10540
11980
  robotsTxt: fetched.robotsTxt,
10541
11981
  xRobotsTag,
@@ -10556,6 +11996,35 @@ function unextractedPage(fetched, contentType) {
10556
11996
  compression: headerValue(fetched.headers, "content-encoding")
10557
11997
  };
10558
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
+ }
10559
12028
  function indexabilityReason(page) {
10560
12029
  if (page.status < 200 || page.status >= 300) return `Status ${page.status}`;
10561
12030
  if (hasMetaRobotsDirective(page.metaRobots, "noindex")) return "Meta robots noindex";
@@ -10609,13 +12078,26 @@ async function crawlOne(url, opts = {}) {
10609
12078
  };
10610
12079
  }
10611
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
+ }
10612
12094
  if (!isHtmlContentType(contentType)) return {
10613
12095
  request: responseObservation(url, fetched, contentType ? "not-applicable" : "unknown-media-type"),
10614
12096
  page: unextractedPage(fetched, contentType),
10615
12097
  urls: []
10616
12098
  };
10617
12099
  try {
10618
- const extracted = await extractPage(fetched);
12100
+ const extracted = await extractPage(fetched, "crawler");
10619
12101
  const base = new URL(extracted.finalUrl);
10620
12102
  const internalLinks = extracted.links.map((link) => sameOriginUrl(link.href, base)).filter((value) => Boolean(value));
10621
12103
  const externalLinks = extracted.links.filter((link) => !link.internal).map((link) => link.href).filter((href) => /^https?:\/\//.test(href));
@@ -10736,6 +12218,62 @@ async function crawlOne(url, opts = {}) {
10736
12218
  };
10737
12219
  }
10738
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
+ }
10739
12277
  //#endregion
10740
12278
  //#region packages/core/dist/analyze/monitoring/sitemaps.js
10741
12279
  const INVALID_LOC_SAMPLE_LIMIT = 10;
@@ -10978,6 +12516,7 @@ async function fetchSitemapUrls(input) {
10978
12516
  const nestedSitemaps = [];
10979
12517
  const discoveredSitemaps = /* @__PURE__ */ new Set();
10980
12518
  const scheduledSitemaps = /* @__PURE__ */ new Set();
12519
+ const fetchedSitemaps = /* @__PURE__ */ new Set();
10981
12520
  const invalidLocSamples = [];
10982
12521
  const malformedLastmodSamples = [];
10983
12522
  const futureLastmodSamples = [];
@@ -11024,12 +12563,18 @@ async function fetchSitemapUrls(input) {
11024
12563
  const sitemapUrl = queue.shift();
11025
12564
  if (!sitemapUrl) continue;
11026
12565
  const fetched = await fetchSitemapXml(sitemapUrl);
12566
+ fetchedSitemaps.add(sitemapUrl);
11027
12567
  documents.push(fetched.document);
11028
12568
  if (fetched.document.warning) warnings.push(fetched.document.warning);
11029
12569
  if (fetched.document.dataStatus !== "unavailable") sitemapsFetched += 1;
11030
12570
  if (!fetched.xml || !fetched.document.root) continue;
11031
12571
  const $ = load(fetched.xml, { xmlMode: true });
11032
12572
  $(fetched.document.root === "urlset" ? "urlset > url" : "").each((_, element) => {
12573
+ if (urls.length >= limit) {
12574
+ urlLimitExceeded = true;
12575
+ omittedUrlsAtLeast += 1;
12576
+ return false;
12577
+ }
11033
12578
  urlLocs += 1;
11034
12579
  const entry = $(element);
11035
12580
  const value = entry.children("loc").first().text().trim();
@@ -11054,12 +12599,7 @@ async function fetchSitemapUrls(input) {
11054
12599
  return;
11055
12600
  }
11056
12601
  seenUrls.add(url);
11057
- if (urls.length < limit) {
11058
- urls.push(url);
11059
- return;
11060
- }
11061
- urlLimitExceeded = true;
11062
- omittedUrlsAtLeast += 1;
12602
+ urls.push(url);
11063
12603
  });
11064
12604
  $(fetched.document.root === "sitemapindex" ? "sitemapindex > sitemap" : "").each((_, element) => {
11065
12605
  sitemapLocs += 1;
@@ -11092,8 +12632,13 @@ async function fetchSitemapUrls(input) {
11092
12632
  queue.push(child);
11093
12633
  } else nestedSitemapLimitExceeded = true;
11094
12634
  });
12635
+ if (urls.length >= limit && queue.length > 0) {
12636
+ urlLimitExceeded = true;
12637
+ omittedUrlsAtLeast = Math.max(omittedUrlsAtLeast, 1);
12638
+ break;
12639
+ }
11095
12640
  }
11096
- const unprocessedSitemaps = nestedSitemaps.filter((sitemap) => !scheduledSitemaps.has(sitemap)).length;
12641
+ const unprocessedSitemaps = nestedSitemaps.filter((sitemap) => !fetchedSitemaps.has(sitemap)).length;
11097
12642
  const possiblyTruncated = urlLimitExceeded || nestedSitemapLimitExceeded || unprocessedSitemaps > 0;
11098
12643
  if (invalidLocCount) warnings.push(`Ignored ${invalidLocCount} invalid sitemap <loc> ${invalidLocCount === 1 ? "entry" : "entries"}.`);
11099
12644
  if (possiblyTruncated) warnings.push("Sitemap discovery exceeded a configured URL or sitemap boundary; the returned inventory is incomplete.");
@@ -11273,6 +12818,7 @@ function resolveCrawlSiteDependencies(dependencies = {}) {
11273
12818
  const emptyPageTopQuery = async () => void 0;
11274
12819
  return {
11275
12820
  fetchPage: dependencies.fetchPage ?? crawlOne,
12821
+ fetchStatusPage: dependencies.fetchStatusPage ?? crawlStatusOnly,
11276
12822
  fetchSitemapUrls: dependencies.fetchSitemapUrls ?? fetchSitemapUrls,
11277
12823
  fetch: dependencies.fetch ?? publicHttpFetch,
11278
12824
  queryPageMetrics: (injectedMode === "per-page" ? dependencies.queryPageMetrics : void 0) ?? (injectedMode ? emptyPageMetrics : queryPageMetrics),
@@ -11298,6 +12844,10 @@ function isSameOrigin(url, origin) {
11298
12844
  }
11299
12845
  }
11300
12846
  function sitemapRootUrls(input) {
12847
+ if (input.sitemapUrl) return [{
12848
+ url: new URL(input.sitemapUrl).toString(),
12849
+ source: "explicit"
12850
+ }];
11301
12851
  const origin = new URL(input.url).origin;
11302
12852
  const declared = input.declaredSitemapUrls.filter((url) => isSameOrigin(url, origin));
11303
12853
  const ignored = input.declaredSitemapUrls.length - declared.length;
@@ -11374,25 +12924,6 @@ async function sitemapSeeds(input) {
11374
12924
  }
11375
12925
  //#endregion
11376
12926
  //#region packages/core/dist/analyze/crawler/site-crawl.js
11377
- const AI_BOT_USER_AGENTS = [
11378
- "GPTBot",
11379
- "OAI-SearchBot",
11380
- "ChatGPT-User",
11381
- "ClaudeBot",
11382
- "PerplexityBot",
11383
- "Google-Extended",
11384
- "CCBot",
11385
- "Applebot",
11386
- "Bingbot"
11387
- ];
11388
- const AGENT_RESOURCE_PATHS = [
11389
- "/.well-known/agent.json",
11390
- "/agent.json",
11391
- "/.well-known/mcp.json",
11392
- "/.well-known/ai-plugin.json",
11393
- "/.well-known/openapi.json",
11394
- "/openapi.json"
11395
- ];
11396
12927
  function normalizeUrl$1(value, base) {
11397
12928
  try {
11398
12929
  const url = base ? new URL(value, base) : new URL(value);
@@ -11416,179 +12947,22 @@ function isLikelyAsset(url) {
11416
12947
  }
11417
12948
  function globMatch(pattern, value) {
11418
12949
  if (!pattern.includes("*")) return value.includes(pattern);
11419
- const parts = pattern.split("*");
11420
- let position = 0;
11421
- for (const part of parts) {
11422
- if (!part) continue;
11423
- const found = value.slice(position).indexOf(part);
11424
- if (found === -1) return false;
11425
- position += found + part.length;
11426
- }
11427
- return true;
11428
- }
11429
- function passesFilters(url, include, exclude) {
11430
- return (!include.length || include.some((pattern) => globMatch(pattern, url))) && !exclude.some((pattern) => globMatch(pattern, url));
11431
- }
11432
- async function checkLlmsTxt(input) {
11433
- const llmsUrl = new URL("/llms.txt", input.url).toString();
11434
- const controller = abortController({
11435
- timeoutMs: input.timeoutMs,
11436
- signal: input.signal
11437
- });
11438
- try {
11439
- const response = await input.fetch(llmsUrl, {
11440
- profile: "bot",
11441
- redirect: "follow",
11442
- signal: controller.signal
11443
- });
11444
- const contentType = response.headers.get("content-type") ?? "";
11445
- const exists = response.status >= 200 && response.status < 300 && !/\btext\/html\b/i.test(contentType);
11446
- await response.body?.cancel().catch(() => void 0);
11447
- return {
11448
- url: response.url || llmsUrl,
11449
- exists,
11450
- status: response.status
11451
- };
11452
- } catch {
11453
- return {
11454
- url: llmsUrl,
11455
- exists: false
11456
- };
11457
- } finally {
11458
- controller.cleanup();
11459
- }
11460
- }
11461
- function parseRobots(robotsUrl, text) {
11462
- return robotsParserModule(robotsUrl, text);
11463
- }
11464
- function declaredUserAgents(text) {
11465
- const declared = /* @__PURE__ */ new Set();
11466
- for (const match of text.matchAll(/^\s*user-agent\s*:\s*(.+?)\s*$/gim)) {
11467
- const value = match[1]?.trim().toLowerCase();
11468
- if (value) declared.add(value);
11469
- }
11470
- return declared;
11471
- }
11472
- function sitemapUrlsFromRobots(text) {
11473
- const urls = /* @__PURE__ */ new Set();
11474
- for (const match of text.matchAll(/^\s*sitemap\s*:\s*(\S+)\s*$/gim)) {
11475
- const value = match[1]?.trim();
11476
- if (!value) continue;
11477
- try {
11478
- urls.add(new URL(value).toString());
11479
- } catch {}
11480
- }
11481
- return [...urls];
11482
- }
11483
- async function checkRobotsAiAccess(input) {
11484
- const robotsUrl = new URL("/robots.txt", input.url).toString();
11485
- const controller = abortController({
11486
- timeoutMs: input.timeoutMs,
11487
- signal: input.signal
11488
- });
11489
- try {
11490
- const response = await input.fetch(robotsUrl, {
11491
- profile: "bot",
11492
- redirect: "follow",
11493
- signal: controller.signal
11494
- });
11495
- const text = await response.text();
11496
- const available = response.status >= 200 && response.status < 300;
11497
- const absent = response.status >= 400 && response.status < 500 && response.status !== 429;
11498
- const availability = available ? "available" : [401, 403].includes(response.status) ? "access-blocked" : response.status === 429 ? "rate-limited" : absent ? "absent" : "unreachable";
11499
- const exists = available;
11500
- const declared = exists ? declaredUserAgents(text) : /* @__PURE__ */ new Set();
11501
- const parsed = parseRobots(robotsUrl, exists ? text : "");
11502
- return {
11503
- url: response.url || robotsUrl,
11504
- exists,
11505
- availability,
11506
- status: response.status,
11507
- ...["rate-limited", "unreachable"].includes(availability) ? { error: `robots.txt returned HTTP ${response.status}.` } : {},
11508
- sitemapUrls: exists ? sitemapUrlsFromRobots(text) : [],
11509
- botAccess: AI_BOT_USER_AGENTS.map((userAgent) => {
11510
- const lower = userAgent.toLowerCase();
11511
- return {
11512
- userAgent,
11513
- allowed: availability === "rate-limited" || availability === "unreachable" ? null : parsed.isAllowed(input.url, userAgent) ?? true,
11514
- declared: declared.has(lower),
11515
- coveredByWildcard: declared.has("*")
11516
- };
11517
- })
11518
- };
11519
- } catch (error) {
11520
- return {
11521
- url: robotsUrl,
11522
- exists: false,
11523
- availability: "unreachable",
11524
- error: error instanceof Error ? error.message : String(error),
11525
- sitemapUrls: [],
11526
- botAccess: AI_BOT_USER_AGENTS.map((userAgent) => ({
11527
- userAgent,
11528
- allowed: null,
11529
- declared: false,
11530
- coveredByWildcard: false
11531
- }))
11532
- };
11533
- } finally {
11534
- controller.cleanup();
11535
- }
11536
- }
11537
- async function checkAgentResource(input) {
11538
- const url = new URL(input.path, input.baseUrl).toString();
11539
- const controller = abortController({
11540
- timeoutMs: input.timeoutMs,
11541
- signal: input.signal
11542
- });
11543
- try {
11544
- const response = await input.fetch(url, {
11545
- profile: "bot",
11546
- redirect: "follow",
11547
- signal: controller.signal
11548
- });
11549
- const contentType = response.headers.get("content-type") ?? "";
11550
- const text = await response.text();
11551
- const exists = response.status >= 200 && response.status < 300;
11552
- let validJson;
11553
- if (exists && /\bjson\b/i.test(contentType)) try {
11554
- JSON.parse(text);
11555
- validJson = true;
11556
- } catch {
11557
- validJson = false;
11558
- }
11559
- return {
11560
- url: response.url || url,
11561
- exists,
11562
- status: response.status,
11563
- contentType,
11564
- ...validJson === void 0 ? {} : { validJson }
11565
- };
11566
- } catch {
11567
- return {
11568
- url,
11569
- exists: false
11570
- };
11571
- } finally {
11572
- controller.cleanup();
11573
- }
11574
- }
11575
- async function checkAgentResources(input) {
11576
- const resources = [];
11577
- for (const path of AGENT_RESOURCE_PATHS) {
11578
- if (input.signal?.aborted) break;
11579
- resources.push(await checkAgentResource({
11580
- baseUrl: input.url,
11581
- path,
11582
- timeoutMs: input.timeoutMs,
11583
- fetch: input.fetch,
11584
- signal: input.signal
11585
- }));
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;
11586
12957
  }
11587
- 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));
11588
12962
  }
11589
12963
  async function crawlSite(input, dependencies) {
11590
12964
  const deps = resolveCrawlSiteDependencies(dependencies);
11591
- const config = normalizeCrawlConfig(input);
12965
+ const config = assertCrawlConfigLimits(normalizeCrawlConfig(input));
11592
12966
  const { searchMetricsLimit, analyticsLimit } = crawlProviderLimits(input);
11593
12967
  const signal = input.signal;
11594
12968
  let cancelled = Boolean(signal?.aborted);
@@ -11596,17 +12970,19 @@ async function crawlSite(input, dependencies) {
11596
12970
  const site = input.site;
11597
12971
  const origin = new URL(config.url).origin;
11598
12972
  const warnings = [];
11599
- const queue = [];
12973
+ const queue = new CrawlUrlQueue();
11600
12974
  const visited = /* @__PURE__ */ new Set();
11601
- const queued = /* @__PURE__ */ new Set();
11602
12975
  const discovered = /* @__PURE__ */ new Set();
11603
- const linkGraph = {};
12976
+ const incomingLinkCounts = /* @__PURE__ */ new Map();
11604
12977
  const fetchedAliases = /* @__PURE__ */ new Map();
11605
12978
  const pages = [];
11606
12979
  const requests = [];
11607
12980
  const documentIndexes = /* @__PURE__ */ new Map();
11608
12981
  const inFlight = /* @__PURE__ */ new Set();
11609
12982
  const followLinks = config.mode === "site";
12983
+ const healthStrategy = config.strategy === "health";
12984
+ let activeConcurrency = healthStrategy ? 1 : config.concurrency;
12985
+ let healthyProbeStreak = 0;
11610
12986
  let queuedUrls = 0;
11611
12987
  let skippedUrls = 0;
11612
12988
  const skipReasonCounts = {};
@@ -11630,7 +13006,7 @@ async function crawlSite(input, dependencies) {
11630
13006
  generatedAt: deps.now().toISOString(),
11631
13007
  discoveredUrls: discovered.size,
11632
13008
  queuedUrls,
11633
- pendingUrls: queue.length,
13009
+ pendingUrls: queue.size,
11634
13010
  inFlightUrls: inFlight.size,
11635
13011
  crawledUrls: pages.length,
11636
13012
  skippedUrls,
@@ -11644,36 +13020,27 @@ async function crawlSite(input, dependencies) {
11644
13020
  const flushStatusEvents = async () => {
11645
13021
  await statusEventChain;
11646
13022
  };
11647
- 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;
11648
13031
  try {
11649
13032
  emitStatus("started", {
11650
13033
  url: config.url,
11651
13034
  message: `Started crawl for ${config.url}.`
11652
13035
  });
11653
- const aiSignals = isCancelled() ? void 0 : await Promise.all([
11654
- checkLlmsTxt({
11655
- url: config.url,
11656
- timeoutMs: config.timeoutMs,
11657
- fetch: deps.fetch,
11658
- signal
11659
- }),
11660
- checkRobotsAiAccess({
11661
- url: config.url,
11662
- timeoutMs: config.timeoutMs,
11663
- fetch: deps.fetch,
11664
- signal
11665
- }),
11666
- checkAgentResources({
11667
- url: config.url,
11668
- timeoutMs: config.timeoutMs,
11669
- fetch: deps.fetch,
11670
- signal
11671
- })
11672
- ]);
11673
- const llmsTxt = aiSignals?.[0] ?? {
11674
- url: new URL("/llms.txt", config.url).toString(),
11675
- exists: false
11676
- };
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;
11677
13044
  const robotsTxt = aiSignals?.[1];
11678
13045
  const agentResources = aiSignals?.[2];
11679
13046
  cancelled = isCancelled();
@@ -11688,12 +13055,9 @@ async function crawlSite(input, dependencies) {
11688
13055
  });
11689
13056
  return;
11690
13057
  }
11691
- discovered.add(normalized);
11692
13058
  if (visited.has(normalized)) return;
11693
- if (queued.has(normalized)) {
11694
- const existing = queue.find((item) => item.url === normalized);
11695
- if (existing && depth < existing.depth) existing.depth = depth;
11696
- 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);
11697
13061
  return;
11698
13062
  }
11699
13063
  if (!sameOrigin(normalized, origin)) {
@@ -11723,7 +13087,7 @@ async function crawlSite(input, dependencies) {
11723
13087
  });
11724
13088
  return;
11725
13089
  }
11726
- if (queue.length + visited.size + inFlight.size >= config.maxPages * 5) {
13090
+ if (queue.size + visited.size + inFlight.size >= config.maxPages * 5) {
11727
13091
  recordSkip("queue-safety-limit");
11728
13092
  queueSafetySkippedUrls += 1;
11729
13093
  emitStatus("url_skipped", {
@@ -11733,13 +13097,12 @@ async function crawlSite(input, dependencies) {
11733
13097
  });
11734
13098
  return;
11735
13099
  }
11736
- queued.add(normalized);
13100
+ discovered.add(normalized);
11737
13101
  queuedUrls += 1;
11738
13102
  queue.push({
11739
13103
  url: normalized,
11740
13104
  depth
11741
13105
  });
11742
- queue.sort((left, right) => left.depth - right.depth || (left.url < right.url ? -1 : left.url > right.url ? 1 : 0));
11743
13106
  emitStatus("url_queued", {
11744
13107
  url: normalized,
11745
13108
  depth
@@ -11752,8 +13115,9 @@ async function crawlSite(input, dependencies) {
11752
13115
  if (!isCancelled() && config.useSitemap && (config.mode === "site" || config.mode === "sitemap")) {
11753
13116
  const sitemap = await sitemapSeeds({
11754
13117
  url: config.url,
13118
+ sitemapUrl: config.sitemapUrl,
11755
13119
  maxPages: config.maxPages,
11756
- declaredSitemapUrls: robotsTxt?.sitemapUrls ?? [],
13120
+ declaredSitemapUrls: robotsTxt?.sitemapUrls ?? healthSitemapUrls ?? [],
11757
13121
  warnings,
11758
13122
  fetchSitemapUrls: deps.fetchSitemapUrls
11759
13123
  });
@@ -11761,8 +13125,8 @@ async function crawlSite(input, dependencies) {
11761
13125
  sitemapDiscovery = sitemap.discovery;
11762
13126
  }
11763
13127
  const nextQueueItem = () => {
11764
- while (queue.length) {
11765
- const item = queue.shift();
13128
+ while (queue.size) {
13129
+ const item = queue.take();
11766
13130
  if (!item) continue;
11767
13131
  const normalized = normalizeUrl$1(item.url, config.url);
11768
13132
  if (!normalized) continue;
@@ -11776,15 +13140,17 @@ async function crawlSite(input, dependencies) {
11776
13140
  };
11777
13141
  const startTask = (item) => {
11778
13142
  let task;
13143
+ const fetcher = healthStrategy ? deps.fetchStatusPage : deps.fetchPage;
11779
13144
  task = {
11780
13145
  ...item,
11781
- promise: deps.fetchPage(item.url, {
13146
+ promise: fetcher(item.url, {
11782
13147
  js: config.js,
11783
13148
  refresh: config.refresh,
11784
13149
  timeoutMs: config.timeoutMs,
11785
13150
  rate: config.fetchRate,
11786
13151
  signal,
11787
13152
  respectRobots: config.respectRobots,
13153
+ robotsResolver,
11788
13154
  renderer
11789
13155
  }).then((result) => ({
11790
13156
  task,
@@ -11810,8 +13176,8 @@ async function crawlSite(input, dependencies) {
11810
13176
  depth: item.depth
11811
13177
  });
11812
13178
  };
11813
- while (!isCancelled() && (queue.length || inFlight.size) && pages.length < config.maxPages) {
11814
- 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) {
11815
13181
  const item = nextQueueItem();
11816
13182
  if (!item) break;
11817
13183
  startTask(item);
@@ -11832,6 +13198,13 @@ async function crawlSite(input, dependencies) {
11832
13198
  extraction: "not-applicable"
11833
13199
  });
11834
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
+ }
11835
13208
  if (request.outcome === "skipped") {
11836
13209
  recordSkip(request.reason === "robots-disallowed" ? "robots-disallowed" : request.reason === "robots-deferred" ? "robots-uncertain" : "origin-backpressure");
11837
13210
  if (request.reason === "robots-deferred") {
@@ -11894,40 +13267,49 @@ async function crawlSite(input, dependencies) {
11894
13267
  };
11895
13268
  const existing = documentIndexes.get(finalUrl);
11896
13269
  if (existing) {
11897
- const links = /* @__PURE__ */ new Set([...existing.links, ...normalizedLinks]);
11898
13270
  const minDepth = Math.min(existing.minDepth, task.depth);
11899
13271
  const replace = !existing.directlyRequested && directlyRequested || existing.directlyRequested === directlyRequested && sourceRequest < existing.sourceRequest;
11900
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);
11901
13287
  page.crawlDepth = minDepth;
11902
13288
  pages[existing.index] = page;
11903
13289
  } else {
11904
13290
  const existingPage = pages[existing.index];
11905
13291
  if (existingPage) existingPage.crawlDepth = minDepth;
11906
13292
  }
11907
- const retainedPage = pages[existing.index];
11908
- if (retainedPage) syncLinkEvidence(retainedPage, links);
11909
- observedInternalLinks += links.size - existing.links.size;
11910
13293
  documentIndexes.set(finalUrl, {
11911
13294
  ...existing,
11912
13295
  directlyRequested: existing.directlyRequested || directlyRequested,
11913
13296
  sourceRequest: replace ? sourceRequest : existing.sourceRequest,
11914
- links,
13297
+ pendingAliasLinks: void 0,
11915
13298
  minDepth
11916
13299
  });
11917
- linkGraph[finalUrl] = [...links].sort((left, right) => left < right ? -1 : left > right ? 1 : 0);
11918
13300
  } else {
11919
13301
  const links = new Set(normalizedLinks);
11920
13302
  syncLinkEvidence(page, links);
11921
13303
  pages.push(page);
11922
13304
  observedInternalLinks += links.size;
13305
+ for (const target of links) incomingLinkCounts.set(target, (incomingLinkCounts.get(target) ?? 0) + 1);
11923
13306
  documentIndexes.set(finalUrl, {
11924
13307
  index: pages.length - 1,
11925
13308
  directlyRequested,
11926
13309
  sourceRequest,
11927
- links,
13310
+ pendingAliasLinks: !directlyRequested ? links : void 0,
11928
13311
  minDepth: task.depth
11929
13312
  });
11930
- linkGraph[finalUrl] = [...links].sort((left, right) => left < right ? -1 : left > right ? 1 : 0);
11931
13313
  }
11932
13314
  };
11933
13315
  if (config.respectRobots && page.robotsTxt?.allowed === false) {
@@ -11955,27 +13337,16 @@ async function crawlSite(input, dependencies) {
11955
13337
  });
11956
13338
  if (followLinks && task.depth < config.maxDepth) for (const url of result.urls) enqueue(url, task.depth + 1);
11957
13339
  }
11958
- const resolvedLinkGraph = resolveLinkGraphAliases(linkGraph, fetchedAliases);
11959
- const pageIndexes = new Map(pages.map((page, index) => [page.url, index]));
11960
- const depths = pages.map((page) => page.crawlDepth ?? 0);
11961
- for (let pass = 0; pass < pages.length; pass += 1) {
11962
- let changed = false;
11963
- for (const [sourceUrl, targets] of Object.entries(resolvedLinkGraph)) {
11964
- const sourceIndex = pageIndexes.get(sourceUrl);
11965
- if (sourceIndex === void 0) continue;
11966
- for (const target of targets) {
11967
- const targetIndex = pageIndexes.get(target);
11968
- if (targetIndex === void 0) continue;
11969
- const nextDepth = (depths[sourceIndex] ?? 0) + 1;
11970
- if (nextDepth >= (depths[targetIndex] ?? Number.POSITIVE_INFINITY)) continue;
11971
- depths[targetIndex] = nextDepth;
11972
- changed = true;
11973
- }
11974
- }
11975
- 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;
11976
13346
  }
11977
- for (const [index, page] of pages.entries()) page.crawlDepth = depths[index];
11978
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.`);
11979
13350
  if (cancelled) {
11980
13351
  warnings.push("Crawl cancelled; returning a partial report.");
11981
13352
  emitStatus("cancelled", {
@@ -11983,15 +13354,17 @@ async function crawlSite(input, dependencies) {
11983
13354
  message: "Crawl cancelled; returning a partial report."
11984
13355
  });
11985
13356
  }
11986
- 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);
11987
13358
  const sitemapEvidencePartial = sitemapDiscovery?.dataStatus === "partial";
11988
- const partial = cancelled || failedUrls > 0 || pageLimitReached || robotsDeferredUrls > 0 || originBackpressureSkippedUrls > 0 || queueSafetySkippedUrls > 0 || queue.length > 0 || inFlight.size > 0 || sitemapEvidencePartial;
11989
- 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"));
11990
13363
  const requestEvidenceStatus = cancelled && inFlight.size > 0 ? "partial" : "available";
11991
13364
  const dataSources = await crawlDataSources({
11992
13365
  cancelled,
11993
- site,
11994
- googleAnalyticsPropertyId: input.googleAnalyticsPropertyId,
13366
+ site: healthStrategy ? void 0 : site,
13367
+ googleAnalyticsPropertyId: healthStrategy ? void 0 : input.googleAnalyticsPropertyId,
11995
13368
  pages,
11996
13369
  warnings,
11997
13370
  searchMetricsLimit,
@@ -12034,7 +13407,7 @@ async function crawlSite(input, dependencies) {
12034
13407
  concurrency: Math.min(config.concurrency, 4)
12035
13408
  }) : void 0;
12036
13409
  for (const page of pages) {
12037
- if (!page.geo) continue;
13410
+ if (!page.geo || !llmsTxt) continue;
12038
13411
  page.geo = {
12039
13412
  ...page.geo,
12040
13413
  hasLlmsTxt: llmsTxt.exists,
@@ -12051,12 +13424,11 @@ async function crawlSite(input, dependencies) {
12051
13424
  pages,
12052
13425
  requests,
12053
13426
  requestEvidenceStatus,
12054
- linkGraph: resolvedLinkGraph,
12055
- ai: {
12056
- llmsTxt,
13427
+ ...!healthStrategy ? { ai: {
13428
+ ...llmsTxt ? { llmsTxt } : {},
12057
13429
  ...robotsTxt ? { robotsTxt } : {},
12058
13430
  ...agentResources ? { agentResources } : {}
12059
- },
13431
+ } } : {},
12060
13432
  ...sitemapDiscovery ? { sitemapDiscovery } : {},
12061
13433
  ...externalLinkVerification ? { externalLinkVerification } : {},
12062
13434
  ...agentDiscovery ? { agentDiscovery } : {},
@@ -12550,6 +13922,14 @@ function compareCrawlReports(input) {
12550
13922
  issueChanges: issues
12551
13923
  };
12552
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
+ }
12553
13933
  const OKF_MAX_CONCEPTS = 5e3;
12554
13934
  function compareText$11(left, right) {
12555
13935
  const leftPoints = [...left].map((value) => value.codePointAt(0) ?? 0);
@@ -12914,7 +14294,7 @@ function explainOkfValidation(validation) {
12914
14294
  return {
12915
14295
  title: "OKF bundle",
12916
14296
  valid: validation.valid,
12917
- 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")}.`,
12918
14298
  files: validation.files,
12919
14299
  concepts: validation.concepts,
12920
14300
  errors: errors.length,
@@ -13066,6 +14446,31 @@ const agentDiscoverySchema = z.object({
13066
14446
  missingMarkdownPages: z.number().int().nonnegative(),
13067
14447
  consistent: z.boolean().nullable()
13068
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(),
13069
14474
  protocolVariants: z.object({
13070
14475
  http: z.object({
13071
14476
  url: z.string().url(),
@@ -13137,6 +14542,46 @@ const analyticsSchema = z.object({
13137
14542
  totalUsers: z.number(),
13138
14543
  conversions: z.number()
13139
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
+ });
13140
14585
  const geoSignalsSchema = z.object({
13141
14586
  semanticHtml: z.boolean(),
13142
14587
  structuredData: z.boolean(),
@@ -13310,6 +14755,7 @@ const crawlPageSnapshotSchema = z.object({
13310
14755
  consecutiveErrors: z.number().int(),
13311
14756
  recentP95Ms: z.number().optional()
13312
14757
  }).optional(),
14758
+ accessBlock: accessBlockEvidenceSchema.optional(),
13313
14759
  robotsTxt: z.object({
13314
14760
  url: z.string().url(),
13315
14761
  cache: z.enum([
@@ -13392,23 +14838,12 @@ const crawlPageSnapshotSchema = z.object({
13392
14838
  }).optional()
13393
14839
  }).optional(),
13394
14840
  blocked: z.boolean().optional(),
14841
+ auditScope: z.enum(["full", "status"]).optional(),
14842
+ accessBlock: accessBlockEvidenceSchema.optional(),
13395
14843
  contentAuditAllowed: z.boolean().optional(),
13396
14844
  crawlDepth: z.number().int().optional(),
13397
14845
  error: z.string().optional(),
13398
- robotsTxt: z.object({
13399
- url: z.string().url(),
13400
- allowed: z.boolean().nullable(),
13401
- availability: z.enum([
13402
- "available",
13403
- "absent",
13404
- "access-blocked",
13405
- "rate-limited",
13406
- "unreachable"
13407
- ]),
13408
- status: z.number().int().optional(),
13409
- error: z.string().optional(),
13410
- matchedLine: z.string().optional()
13411
- }).optional(),
14846
+ robotsTxt: crawlRobotsEvidenceSchema.optional(),
13412
14847
  title: z.string().optional(),
13413
14848
  metaDescription: z.string().optional(),
13414
14849
  canonical: z.string().url().optional(),
@@ -13463,8 +14898,16 @@ const crawlPageSnapshotSchema = z.object({
13463
14898
  extractionError: z.string().optional(),
13464
14899
  wordCount: z.number().int(),
13465
14900
  contentExtraction: z.object({
13466
- requested: z.enum(["defuddle", "readability"]),
13467
- 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
+ ]),
13468
14911
  fallback: z.boolean(),
13469
14912
  fallbackReason: z.enum(["defuddle_error", "defuddle_empty"]).optional(),
13470
14913
  fallbackDetail: z.string().optional(),
@@ -13598,7 +15041,9 @@ const crawlResponseObservationBaseSchema = z.object({
13598
15041
  url: z.string().url(),
13599
15042
  status: z.number().int(),
13600
15043
  location: z.string().url().optional()
13601
- })).optional()
15044
+ })).optional(),
15045
+ accessBlock: accessBlockEvidenceSchema.optional(),
15046
+ robotsTxt: crawlRobotsEvidenceSchema.optional()
13602
15047
  });
13603
15048
  const crawlRequestObservationSchema = z.union([
13604
15049
  crawlResponseObservationBaseSchema.extend({ extraction: z.enum([
@@ -13615,20 +15060,7 @@ const crawlRequestObservationSchema = z.union([
13615
15060
  outcome: z.literal("skipped"),
13616
15061
  durationMs: z.number().optional(),
13617
15062
  reason: z.enum(["robots-disallowed", "robots-deferred"]),
13618
- robotsTxt: z.object({
13619
- url: z.string().url(),
13620
- allowed: z.boolean().nullable(),
13621
- availability: z.enum([
13622
- "available",
13623
- "absent",
13624
- "access-blocked",
13625
- "rate-limited",
13626
- "unreachable"
13627
- ]),
13628
- status: z.number().int().optional(),
13629
- error: z.string().optional(),
13630
- matchedLine: z.string().optional()
13631
- }),
15063
+ robotsTxt: crawlRobotsEvidenceSchema,
13632
15064
  extraction: z.literal("not-applicable")
13633
15065
  }),
13634
15066
  z.object({
@@ -13687,6 +15119,7 @@ const crawlAiSignalsSchema = z.object({
13687
15119
  status: z.number().int().optional(),
13688
15120
  error: z.string().optional(),
13689
15121
  sitemapUrls: z.array(z.string().url()),
15122
+ contentSignals: z.array(z.string()).optional(),
13690
15123
  botAccess: z.array(z.object({
13691
15124
  userAgent: z.string(),
13692
15125
  allowed: z.boolean().nullable(),
@@ -13744,7 +15177,11 @@ const crawlSitemapDiscoverySchema = z.object({
13744
15177
  urlsReturned: z.number().int().nonnegative(),
13745
15178
  roots: z.array(z.object({
13746
15179
  url: z.string().url(),
13747
- source: z.enum(["robots-txt", "default-path"]),
15180
+ source: z.enum([
15181
+ "explicit",
15182
+ "robots-txt",
15183
+ "default-path"
15184
+ ]),
13748
15185
  dataStatus: z.enum([
13749
15186
  "complete",
13750
15187
  "partial",
@@ -13828,6 +15265,8 @@ const crawlConfigSchema = z.object({
13828
15265
  "list",
13829
15266
  "sitemap"
13830
15267
  ]),
15268
+ strategy: z.enum(["full", "health"]),
15269
+ sitemapUrl: z.string().url().optional(),
13831
15270
  urls: z.array(z.string().url()),
13832
15271
  maxPages: z.number().int(),
13833
15272
  maxDepth: z.number().int(),
@@ -13862,6 +15301,7 @@ const crawlConfigSchema = z.object({
13862
15301
  });
13863
15302
  const crawlReportSummarySchema = z.object({
13864
15303
  totalPages: z.number().int(),
15304
+ statusOnlyPages: z.number().int(),
13865
15305
  indexablePages: z.number().int(),
13866
15306
  nonIndexablePages: z.number().int(),
13867
15307
  statusErrors: z.number().int(),
@@ -13952,6 +15392,17 @@ const crawlReportBaseSchema = z.object({
13952
15392
  ]),
13953
15393
  configHash: z.string(),
13954
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(),
13955
15406
  summary: crawlReportSummarySchema,
13956
15407
  pages: z.array(crawlPageSnapshotSchema),
13957
15408
  issues: z.array(crawlIssueSchema),
@@ -14149,7 +15600,7 @@ function analyzeCtrUnderperformersFromRows(input) {
14149
15600
  //#endregion
14150
15601
  //#region packages/core/dist/analyze/ctr-underperformers.js
14151
15602
  const MAX_GSC_ROWS$4 = 1e5;
14152
- function plural$4(count, singular, pluralLabel = `${singular}s`) {
15603
+ function plural$3(count, singular, pluralLabel = `${singular}s`) {
14153
15604
  return count === 1 ? singular : pluralLabel;
14154
15605
  }
14155
15606
  const defaultDependencies$12 = {
@@ -14225,7 +15676,7 @@ async function ctrUnderperformersReport(input, dependencies = defaultDependencie
14225
15676
  minImpressions,
14226
15677
  limit,
14227
15678
  brandFiltering: input.includeBrand ? "included" : "excluded",
14228
- 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."
14229
15680
  },
14230
15681
  items,
14231
15682
  caveats: [
@@ -14234,10 +15685,10 @@ async function ctrUnderperformersReport(input, dependencies = defaultDependencie
14234
15685
  `Only queries ranking position 1-10 with at least ${minImpressions} impressions were checked.`,
14235
15686
  "Expected CTR uses a robust site-aware position benchmark when enough peer data exists, otherwise the fallback position curve.",
14236
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.",
14237
- ...selection.invalidRows ? [`${selection.invalidRows} invalid provider ${plural$4(selection.invalidRows, "row")} ${selection.invalidRows === 1 ? "was" : "were"} excluded, so this report is partial.`] : [],
14238
- ...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.`] : [],
14239
15690
  ...possiblyTruncated ? [`The retained-row limit of ${MAX_GSC_ROWS$4} was reached, so lower-ranked source rows may be absent.`] : [],
14240
- ...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.`] : []
14241
15692
  ],
14242
15693
  recommendations: top ? [
14243
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.`,
@@ -14247,14 +15698,6 @@ async function ctrUnderperformersReport(input, dependencies = defaultDependencie
14247
15698
  };
14248
15699
  }
14249
15700
  //#endregion
14250
- //#region packages/core/dist/phrasing.js
14251
- function plural$3(count, singular, pluralLabel = `${singular}s`) {
14252
- return count === 1 ? singular : pluralLabel;
14253
- }
14254
- function countLabel(count, singular, pluralLabel = `${singular}s`) {
14255
- return `${count.toLocaleString("en-GB")} ${plural$3(count, singular, pluralLabel)}`;
14256
- }
14257
- //#endregion
14258
15701
  //#region packages/core/dist/analyze/diagnosis-priorities.js
14259
15702
  function topDelta(report) {
14260
15703
  const top = report.items[0];
@@ -14281,14 +15724,14 @@ function buildDiagnosisPriorities(input) {
14281
15724
  const topGroup = input.decay.groups[0];
14282
15725
  priorities.push({
14283
15726
  label: "Refresh decaying content",
14284
- 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.`,
14285
15728
  action: topGroup ? topGroup.recommendation : "Start with declines that continued outside the update window. Check indexability first, then ranking and CTR causes.",
14286
15729
  confidence: input.decay.dataStatus === "partial" ? "low" : "medium"
14287
15730
  });
14288
15731
  }
14289
15732
  if (input.cannibal.items.length) priorities.push({
14290
15733
  label: "Review multi-URL query candidates",
14291
- reason: `${input.cannibal.selection.eligibleClusters} multi-URL query candidates found.`,
15734
+ reason: `${countLabel(input.cannibal.selection.eligibleClusters, "multi-URL query candidate")} found.`,
14292
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.",
14293
15736
  confidence: "low"
14294
15737
  });
@@ -14306,7 +15749,7 @@ function buildDiagnosisPriorities(input) {
14306
15749
  const top = input.striking.items[0];
14307
15750
  priorities.push({
14308
15751
  label: "Investigate striking-distance candidates",
14309
- 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.`,
14310
15753
  action: top?.recommendation.action ?? "Check technical state, query coverage, competing URLs, and relevant internal links before choosing a change.",
14311
15754
  confidence: input.striking.source.possiblyTruncated ? "low" : top?.recommendation.confidence ?? "low"
14312
15755
  });
@@ -16090,8 +17533,7 @@ function analyzeQuickWinsFromRows(input) {
16090
17533
  selected.selection.belowMinimumRows++;
16091
17534
  continue;
16092
17535
  }
16093
- const targetUrl = row.keys[1] ?? "";
16094
- const excludedTargetRows = selected.rows.filter((candidate) => samePageUrl(candidate.keys[1] ?? "", targetUrl)).length;
17536
+ const excludedTargetRows = benchmarkContext.urlRowCount(row);
16095
17537
  const benchmark = benchmarkContext.forUrl(row);
16096
17538
  if (row.ctr >= benchmark.ctr) {
16097
17539
  selected.selection.atOrAboveTargetRows++;
@@ -16285,7 +17727,7 @@ async function quickWinsReport(input, dependencies = defaultDependencies$8) {
16285
17727
  brandTerms: input.brandTerms,
16286
17728
  includeBrand: input.includeBrand
16287
17729
  });
16288
- const verificationRequested = input.verifyContent === true || input.verifyLimit !== void 0;
17730
+ const verificationRequested = input.verifyContent === true || input.verifyContent !== false && input.verifyLimit !== void 0;
16289
17731
  const verificationItems = verificationRequested ? analysis.items.slice(0, verifyLimit) : [];
16290
17732
  const fetches = /* @__PURE__ */ new Map();
16291
17733
  const cachedFetch = (url, options) => {
@@ -16384,7 +17826,7 @@ async function quickWinsReport(input, dependencies = defaultDependencies$8) {
16384
17826
  repeatedQueryGroups: groups.length,
16385
17827
  templatePatterns: templateActions.length,
16386
17828
  brandFiltering: input.includeBrand ? "included" : "excluded",
16387
- 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.`
16388
17830
  },
16389
17831
  caveats: [
16390
17832
  `Date window: ${range.startDate} to ${range.endDate}, using final GSC data where available.`,
@@ -16675,7 +18117,7 @@ function templatesFromGroups(groups) {
16675
18117
  function verdict$1(analysis) {
16676
18118
  if (analysis.dataStatus === "empty") return "Search Console returned no retained query/page rows for this date window.";
16677
18119
  if (analysis.dataStatus === "filtered") return "No retained query/page rows met the report filters.";
16678
- 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.`;
16679
18121
  }
16680
18122
  async function strikingDistance(input, dependencies = defaultDependencies$7) {
16681
18123
  const days = reportDays(input.days);
@@ -16697,7 +18139,7 @@ async function strikingDistance(input, dependencies = defaultDependencies$7) {
16697
18139
  includeBrand: input.includeBrand
16698
18140
  });
16699
18141
  const verifyLimit = boundedInteger$2(input.verifyLimit, DEFAULT_VERIFY_LIMIT, 0, analysis.limit);
16700
- const verificationRequested = input.verifyContent === true || input.verifyLimit !== void 0;
18142
+ const verificationRequested = input.verifyContent === true || input.verifyContent !== false && input.verifyLimit !== void 0;
16701
18143
  const attempted = verificationRequested ? Math.min(verifyLimit, analysis.items.length) : 0;
16702
18144
  const items = await Promise.all(analysis.items.map(async (item, index) => {
16703
18145
  if (index >= attempted) return item;
@@ -19271,8 +20713,6 @@ function topCrawlRecommendations(items) {
19271
20713
  url: item.url
19272
20714
  })).sort((a, b) => priority[b.severity] - priority[a.severity]);
19273
20715
  }
19274
- //#endregion
19275
- //#region packages/core/dist/analyze/monitoring/crawl-store.js
19276
20716
  function toRun(row) {
19277
20717
  return {
19278
20718
  id: row.id,
@@ -19344,34 +20784,52 @@ function insertCrawlRun(run, pages, recommendations = []) {
19344
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));
19345
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);
19346
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);
19347
20795
  }
19348
20796
  function latestCrawlSummaries(site, limit = 5) {
19349
- 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 (
19350
20805
  SELECT
19351
- run_id,
19352
- COALESCE(SUM(CASE WHEN status >= 400 THEN 1 ELSE 0 END), 0) AS status_errors,
19353
- 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
19354
20809
  FROM crawl_pages
19355
- GROUP BY run_id
20810
+ INNER JOIN recent_runs ON recent_runs.id = crawl_pages.run_id
20811
+ GROUP BY crawl_pages.run_id
19356
20812
  ),
19357
20813
  recommendation_counts AS (
19358
20814
  SELECT
19359
- run_id,
20815
+ crawl_recommendations.run_id,
19360
20816
  COUNT(*) AS recommendation_count,
19361
- 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
19362
20818
  FROM crawl_recommendations
19363
- GROUP BY run_id
20819
+ INNER JOIN recent_runs ON recent_runs.id = crawl_recommendations.run_id
20820
+ GROUP BY crawl_recommendations.run_id
19364
20821
  ),
19365
20822
  recommendation_rank AS (
19366
20823
  SELECT
19367
20824
  crawl_recommendations.*,
19368
20825
  ROW_NUMBER() OVER (
19369
- PARTITION BY run_id
20826
+ PARTITION BY crawl_recommendations.run_id
19370
20827
  ORDER BY
19371
- CASE severity WHEN 'high' THEN 3 WHEN 'medium' THEN 2 ELSE 1 END DESC,
19372
- 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
19373
20830
  ) AS rank
19374
20831
  FROM crawl_recommendations
20832
+ INNER JOIN recent_runs ON recent_runs.id = crawl_recommendations.run_id
19375
20833
  )
19376
20834
  SELECT
19377
20835
  crawl_runs.*,
@@ -19383,14 +20841,12 @@ function latestCrawlSummaries(site, limit = 5) {
19383
20841
  top.title AS top_recommendation_title,
19384
20842
  top.action AS top_recommendation_action,
19385
20843
  top.severity AS top_recommendation_severity
19386
- FROM crawl_runs
20844
+ FROM recent_runs AS crawl_runs
19387
20845
  LEFT JOIN page_counts ON page_counts.run_id = crawl_runs.id
19388
20846
  LEFT JOIN recommendation_counts ON recommendation_counts.run_id = crawl_runs.id
19389
20847
  LEFT JOIN recommendation_rank top ON top.run_id = crawl_runs.id AND top.rank = 1
19390
- WHERE crawl_runs.site_url = ?
19391
20848
  GROUP BY crawl_runs.id
19392
- ORDER BY crawl_runs.created_at DESC
19393
- LIMIT ?`).all(site, limit).map((row) => ({
20849
+ ORDER BY crawl_runs.created_at DESC`).all(site, limit).map((row) => ({
19394
20850
  ...toRun(row),
19395
20851
  statusErrors: row.status_errors,
19396
20852
  nonIndexable: row.non_indexable,
@@ -20466,6 +21922,36 @@ function previousFromRow(row) {
20466
21922
  lastCrawlTime: row.last_crawl_time ?? void 0
20467
21923
  };
20468
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
+ }
20469
21955
  const defaultDependencies$2 = {
20470
21956
  inspectUrl,
20471
21957
  now: () => /* @__PURE__ */ new Date(),
@@ -20479,12 +21965,14 @@ const defaultDependencies$2 = {
20479
21965
  return row ? previousFromRow(row) : void 0;
20480
21966
  },
20481
21967
  insert(item) {
20482
- getDb().prepare(`INSERT INTO index_watch_snapshots
21968
+ const db = getDb();
21969
+ db.prepare(`INSERT INTO index_watch_snapshots
20483
21970
  (id, site_url, root_site_url, property_site_url, url, verdict,
20484
21971
  coverage_state, indexing_state, robots_txt_state, page_fetch_state,
20485
21972
  google_canonical, user_canonical, last_crawl_time, inspection_status,
20486
21973
  error_code, error_message, inspected_at)
20487
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);
20488
21976
  }
20489
21977
  }
20490
21978
  };
@@ -21023,8 +22511,6 @@ async function indexMonitor(input) {
21023
22511
  warnings
21024
22512
  };
21025
22513
  }
21026
- //#endregion
21027
- //#region packages/core/dist/analyze/monitoring/link-recover-store.js
21028
22514
  function toSummary(row) {
21029
22515
  return {
21030
22516
  id: row.id,
@@ -21067,6 +22553,14 @@ function insertLinkRecoverRun(report) {
21067
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);
21068
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);
21069
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);
21070
22564
  return id;
21071
22565
  }
21072
22566
  function latestLinkRecoverSummary(site) {
@@ -21900,9 +23394,11 @@ function cacheSet(report, now) {
21900
23394
  ...report,
21901
23395
  raw: void 0
21902
23396
  };
23397
+ const reportJson = JSON.stringify(stored);
21903
23398
  getDb().prepare(`INSERT OR REPLACE INTO performance_reports
21904
23399
  (id, url, strategy, report_json, created_at, expires_at)
21905
- 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));
21906
23402
  }
21907
23403
  async function fallbackReport(input) {
21908
23404
  const fetched = await fetchPage(input.url, {
@@ -23451,7 +24947,7 @@ function clusterRecommendation(input) {
23451
24947
  return `This ${clusterName} cluster already gets clicks. Use it to refine page structure, FAQs, and internal anchors rather than creating duplicate pages.`;
23452
24948
  }
23453
24949
  function clusterSummary(input) {
23454
- 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)}.`;
23455
24951
  }
23456
24952
  function clusterLabel(rows) {
23457
24953
  return [...rows].sort((a, b) => b.impressions - a.impressions || compareQueryClusterText(a.query, b.query))[0]?.query ?? "";
@@ -23924,7 +25420,7 @@ function contentOpportunityBullets(report) {
23924
25420
  if (report.cannibalization.selection.suppressedQueries) bullets.push(cannibalSuppressionLine(report));
23925
25421
  if (report.summary.strikingDistanceItems) {
23926
25422
  const top = report.strikingDistance.items[0];
23927
- 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.`);
23928
25424
  }
23929
25425
  if (!skippedReason(report, "quick-win opportunities") && report.summary.quickWinItems) {
23930
25426
  const top = report.quickWins.items[0];
@@ -24405,10 +25901,10 @@ function applyVerification(item, coverage) {
24405
25901
  function verdict(report) {
24406
25902
  if (report.dataStatus === "empty") return "Google Search Console returned no retained query/page rows for this window.";
24407
25903
  if (report.dataStatus === "filtered") return "No pages met the position, brand, and minimum-impression criteria.";
24408
- 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.`;
24409
- 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.`;
24410
- if (report.content > 0) return `${report.eligible} eligible average-position pages found; ${report.content} returned pages have verified content or search-framing gaps.`;
24411
- 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.`;
24412
25908
  }
24413
25909
  function reportRecommendations(items) {
24414
25910
  const first = items[0];
@@ -24455,7 +25951,7 @@ async function secondPage(input, dependencies = defaultDependencies) {
24455
25951
  });
24456
25952
  const generatedAt = now.toISOString();
24457
25953
  const warnings = [];
24458
- const verificationRequested = input.verifyContent === true || input.verifyLimit !== void 0;
25954
+ const verificationRequested = input.verifyContent === true || input.verifyContent !== false && input.verifyLimit !== void 0;
24459
25955
  const verificationItems = verificationRequested ? analysis.items.slice(0, verifyLimit) : [];
24460
25956
  for (const item of verificationItems) {
24461
25957
  const coverage = await verifyQueryContent({
@@ -25066,7 +26562,7 @@ async function refreshPrioritiesWorkflow(input) {
25066
26562
  const diagnosis = await diagnoseProperty({
25067
26563
  ...input,
25068
26564
  verifyContent: input.verifyContent ?? true,
25069
- verifyLimit: input.verifyLimit ?? Math.min(input.limit ?? 5, 5)
26565
+ verifyLimit: input.verifyContent === false ? void 0 : input.verifyLimit ?? Math.min(input.limit ?? 5, 5)
25070
26566
  });
25071
26567
  const range = defaultDateRange$1(input.days ?? 28);
25072
26568
  const analytics = await fetchLandingPageValues({
@@ -25767,6 +27263,7 @@ function parseSemicolonCsv(text) {
25767
27263
  //#endregion
25768
27264
  //#region packages/core/dist/providers/semrush/cache.js
25769
27265
  const BASE_URL = "https://api.semrush.com/";
27266
+ const MAX_RESPONSE_BYTES = 10 * 1024 * 1024;
25770
27267
  function estimateUsd(units) {
25771
27268
  return units / 1e3 * .05;
25772
27269
  }
@@ -25796,14 +27293,17 @@ async function cachedSemrushCall(endpoint, params, map, ttlMs, creditsPerLine, r
25796
27293
  const url = new URL(BASE_URL);
25797
27294
  url.search = new URLSearchParams(requestParams).toString();
25798
27295
  const response = await fetch$1(url);
25799
- const text = await response.text();
27296
+ const text = await readBoundedResponseText(response, MAX_RESPONSE_BYTES, "Semrush response");
25800
27297
  if (!response.ok || text.startsWith("ERROR ::")) throw new Error(text || `Semrush request failed with ${response.status}.`);
25801
27298
  const rows = parseSemicolonCsv(text);
25802
27299
  const data = map(rows);
25803
27300
  const credits = Math.max(0, rows.length - 1) * creditsPerLine;
27301
+ const requestJson = JSON.stringify(requestParams);
27302
+ const responseJson = JSON.stringify(data);
25804
27303
  db.prepare(`INSERT OR REPLACE INTO semrush_cache
25805
27304
  (endpoint, query_hash, request_json, response_json, credits_used, fetched_at, expires_at)
25806
- 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));
25807
27307
  return {
25808
27308
  data,
25809
27309
  usage: {
@@ -25963,9 +27463,6 @@ var LocalCredentialsProvider = class {
25963
27463
  }
25964
27464
  };
25965
27465
  new LocalCredentialsProvider();
25966
- //#endregion
25967
- //#region packages/core/dist/version.js
25968
- const SEO_VERSION = version;
25969
27466
  const TELEMETRY_TIMEOUT_MS = 2e3;
25970
27467
  const TELEMETRY_REPORTS = [
25971
27468
  "affected-urls",
@@ -26560,17 +28057,7 @@ function registerAiOpportunityTools(server, dependencies = {}) {
26560
28057
  });
26561
28058
  }
26562
28059
  //#endregion
26563
- //#region packages/mcp/src/fetch-rate.ts
26564
- function fetchRateInput(input) {
26565
- if (input.fetchConcurrency === void 0 && input.fetchIntervalCap === void 0 && input.fetchIntervalMs === void 0) return;
26566
- return {
26567
- concurrency: input.fetchConcurrency,
26568
- intervalCap: input.fetchIntervalCap,
26569
- intervalMs: input.fetchIntervalMs
26570
- };
26571
- }
26572
- //#endregion
26573
- //#region packages/mcp/src/crawler-tools.ts
28060
+ //#region packages/mcp/src/crawler-tool-helpers.ts
26574
28061
  function compactCrawlResult(report, opts = {}) {
26575
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.`;
26576
28063
  const payload = {
@@ -26580,6 +28067,8 @@ function compactCrawlResult(report, opts = {}) {
26580
28067
  status: report.status,
26581
28068
  requestEvidenceStatus: report.requestEvidenceStatus,
26582
28069
  configHash: report.configHash,
28070
+ config: report.config,
28071
+ access: report.access,
26583
28072
  summary: report.summary,
26584
28073
  dataSources: report.dataSources,
26585
28074
  ai: report.ai ? {
@@ -26613,16 +28102,35 @@ function resolveSavedReportAlias(input) {
26613
28102
  function assertExclusiveReportInput(url, reportId) {
26614
28103
  if (url && reportId) throw new SeoError("INVALID_INPUT", "Use either url or reportId, not both.");
26615
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();
26616
28122
  function registerCrawlerTools(server) {
26617
28123
  server.registerTool("seo_crawl_site", {
26618
- 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.",
26619
28125
  inputSchema: {
26620
- 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."),
26621
28129
  site: z$1.string().optional(),
26622
28130
  googleAnalyticsPropertyId: z$1.string().optional(),
26623
- maxPages: z$1.number().int().positive().optional(),
26624
- maxDepth: z$1.number().int().nonnegative().optional(),
26625
- concurrency: z$1.number().int().positive().optional(),
28131
+ maxPages: crawlPageLimit,
28132
+ maxDepth: crawlDepthLimit,
28133
+ concurrency: boundedPositiveInteger(MAX_CRAWL_CONCURRENCY),
26626
28134
  fetchIntervalCap: z$1.number().int().positive().optional(),
26627
28135
  fetchIntervalMs: z$1.number().int().positive().optional(),
26628
28136
  refresh: z$1.boolean().optional(),
@@ -26635,10 +28143,15 @@ function registerCrawlerTools(server) {
26635
28143
  includeIssues: z$1.boolean().optional(),
26636
28144
  saveReport: z$1.boolean().optional()
26637
28145
  }
26638
- }, 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 }) => {
26639
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.");
26640
28150
  const report = await crawlSite({
26641
- url,
28151
+ url: crawlUrl,
28152
+ mode: health || sitemapUrl ? "sitemap" : "site",
28153
+ strategy: health ? "health" : "full",
28154
+ sitemapUrl,
26642
28155
  site,
26643
28156
  googleAnalyticsPropertyId,
26644
28157
  maxPages,
@@ -26657,7 +28170,7 @@ function registerCrawlerTools(server) {
26657
28170
  js: Boolean(js)
26658
28171
  });
26659
28172
  const saved = saveReport ? saveCrawlReport(report) : void 0;
26660
- 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.`, {
26661
28174
  ...compactCrawlResult(report, {
26662
28175
  includePages,
26663
28176
  includeIssues
@@ -26671,11 +28184,11 @@ function registerCrawlerTools(server) {
26671
28184
  server.registerTool("seo_audit_urls", {
26672
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.",
26673
28186
  inputSchema: {
26674
- 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),
26675
28188
  site: z$1.string().optional(),
26676
28189
  googleAnalyticsPropertyId: z$1.string().optional(),
26677
- maxPages: z$1.number().int().positive().optional(),
26678
- concurrency: z$1.number().int().positive().optional(),
28190
+ maxPages: crawlPageLimit,
28191
+ concurrency: boundedPositiveInteger(MAX_CRAWL_CONCURRENCY),
26679
28192
  fetchIntervalCap: z$1.number().int().positive().optional(),
26680
28193
  fetchIntervalMs: z$1.number().int().positive().optional(),
26681
28194
  refresh: z$1.boolean().optional(),
@@ -26719,8 +28232,8 @@ function registerCrawlerTools(server) {
26719
28232
  url: z$1.string().url().optional(),
26720
28233
  reportId: z$1.string().optional(),
26721
28234
  site: z$1.string().optional(),
26722
- maxPages: z$1.number().int().positive().optional(),
26723
- maxDepth: z$1.number().int().nonnegative().optional(),
28235
+ maxPages: crawlPageLimit,
28236
+ maxDepth: crawlDepthLimit,
26724
28237
  fetchIntervalCap: z$1.number().int().positive().optional(),
26725
28238
  fetchIntervalMs: z$1.number().int().positive().optional(),
26726
28239
  refresh: z$1.boolean().optional(),
@@ -26746,7 +28259,8 @@ function registerCrawlerTools(server) {
26746
28259
  category,
26747
28260
  limit
26748
28261
  });
26749
- 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}`, {
26750
28264
  url: report.config.url,
26751
28265
  reportId: report.id,
26752
28266
  summary: report.summary,
@@ -26772,7 +28286,7 @@ function registerCrawlerTools(server) {
26772
28286
  inputSchema: { category: crawlerRuleCategorySchema.optional() }
26773
28287
  }, async ({ category }) => {
26774
28288
  const rules = listRules().filter((rule) => category ? rule.category === category : true);
26775
- return toolSuccess(`Found ${rules.length} crawler rules.`, { rules });
28289
+ return toolSuccess(`Found ${countLabel(rules.length, "crawler rule")}.`, { rules });
26776
28290
  });
26777
28291
  server.registerTool("seo_affected_urls", {
26778
28292
  description: "Return affected URLs for a saved or freshly crawled report, filtered by rule, category, or severity.",
@@ -26783,7 +28297,7 @@ function registerCrawlerTools(server) {
26783
28297
  ruleId: z$1.string().optional(),
26784
28298
  category: z$1.string().optional(),
26785
28299
  severity: z$1.string().optional(),
26786
- maxPages: z$1.number().int().positive().optional(),
28300
+ maxPages: crawlPageLimit,
26787
28301
  fetchIntervalCap: z$1.number().int().positive().optional(),
26788
28302
  fetchIntervalMs: z$1.number().int().positive().optional(),
26789
28303
  refresh: z$1.boolean().optional(),
@@ -26827,7 +28341,7 @@ function registerCrawlerTools(server) {
26827
28341
  url: z$1.string().url().optional(),
26828
28342
  reportId: z$1.string().optional(),
26829
28343
  site: z$1.string().optional(),
26830
- maxPages: z$1.number().int().positive().optional(),
28344
+ maxPages: crawlPageLimit,
26831
28345
  fetchIntervalCap: z$1.number().int().positive().optional(),
26832
28346
  fetchIntervalMs: z$1.number().int().positive().optional(),
26833
28347
  refresh: z$1.boolean().optional(),
@@ -26862,7 +28376,7 @@ function registerCrawlerTools(server) {
26862
28376
  url: z$1.string().url().optional(),
26863
28377
  reportId: z$1.string().optional(),
26864
28378
  site: z$1.string().optional(),
26865
- maxPages: z$1.number().int().positive().optional(),
28379
+ maxPages: crawlPageLimit,
26866
28380
  fetchIntervalCap: z$1.number().int().positive().optional(),
26867
28381
  fetchIntervalMs: z$1.number().int().positive().optional(),
26868
28382
  refresh: z$1.boolean().optional()
@@ -26896,7 +28410,7 @@ function registerCrawlerTools(server) {
26896
28410
  url: z$1.string().url().optional(),
26897
28411
  reportId: z$1.string().optional(),
26898
28412
  site: z$1.string().optional(),
26899
- maxPages: z$1.number().int().positive().optional(),
28413
+ maxPages: crawlPageLimit,
26900
28414
  fetchIntervalCap: z$1.number().int().positive().optional(),
26901
28415
  fetchIntervalMs: z$1.number().int().positive().optional(),
26902
28416
  refresh: z$1.boolean().optional()
@@ -26926,7 +28440,7 @@ function registerCrawlerTools(server) {
26926
28440
  url: z$1.string().url().optional(),
26927
28441
  reportId: z$1.string().optional(),
26928
28442
  site: z$1.string().optional(),
26929
- maxPages: z$1.number().int().positive().optional(),
28443
+ maxPages: crawlPageLimit,
26930
28444
  fetchIntervalCap: z$1.number().int().positive().optional(),
26931
28445
  fetchIntervalMs: z$1.number().int().positive().optional(),
26932
28446
  refresh: z$1.boolean().optional()
@@ -26956,7 +28470,7 @@ function registerCrawlerTools(server) {
26956
28470
  url: z$1.string().url().optional(),
26957
28471
  reportId: z$1.string().optional(),
26958
28472
  site: z$1.string().optional(),
26959
- maxPages: z$1.number().int().positive().optional(),
28473
+ maxPages: crawlPageLimit,
26960
28474
  fetchIntervalCap: z$1.number().int().positive().optional(),
26961
28475
  fetchIntervalMs: z$1.number().int().positive().optional(),
26962
28476
  refresh: z$1.boolean().optional()
@@ -26986,7 +28500,7 @@ function registerCrawlerTools(server) {
26986
28500
  url: z$1.string().url().optional(),
26987
28501
  reportId: z$1.string().optional(),
26988
28502
  site: z$1.string().optional(),
26989
- maxPages: z$1.number().int().positive().optional(),
28503
+ maxPages: crawlPageLimit,
26990
28504
  maxUrls: z$1.number().int().positive().optional(),
26991
28505
  tokenBudget: z$1.number().int().positive().optional(),
26992
28506
  exclude: z$1.array(z$1.string()).optional(),
@@ -27027,7 +28541,7 @@ function registerCrawlerTools(server) {
27027
28541
  url: z$1.string().url().optional(),
27028
28542
  reportId: z$1.string().optional(),
27029
28543
  site: z$1.string().optional(),
27030
- maxPages: z$1.number().int().positive().optional(),
28544
+ maxPages: crawlPageLimit,
27031
28545
  fetchIntervalCap: z$1.number().int().positive().optional(),
27032
28546
  fetchIntervalMs: z$1.number().int().positive().optional(),
27033
28547
  refresh: z$1.boolean().optional()
@@ -27057,7 +28571,7 @@ function registerCrawlerTools(server) {
27057
28571
  url: z$1.string().url().optional(),
27058
28572
  reportId: z$1.string().optional(),
27059
28573
  site: z$1.string().optional(),
27060
- maxPages: z$1.number().int().positive().optional(),
28574
+ maxPages: crawlPageLimit,
27061
28575
  maxConcepts: z$1.number().int().min(1).max(100).optional(),
27062
28576
  includeFiles: z$1.boolean().optional(),
27063
28577
  title: z$1.string().min(1).max(200).optional(),
@@ -28006,7 +29520,7 @@ function registerPseoTools(server) {
28006
29520
  includeBrand: z$1.boolean().optional(),
28007
29521
  refresh: z$1.boolean().optional(),
28008
29522
  js: z$1.boolean().optional(),
28009
- fetchConcurrency: z$1.number().int().min(1).max(20).optional(),
29523
+ fetchConcurrency: z$1.number().int().min(1).max(16).optional(),
28010
29524
  fetchIntervalCap: z$1.number().int().min(1).max(100).optional(),
28011
29525
  fetchIntervalMs: z$1.number().int().min(100).max(6e4).optional(),
28012
29526
  detail: z$1.enum(["summary", "full"]).optional()
@@ -28079,13 +29593,15 @@ const REPORT_DEPTH = {
28079
29593
  "profile, profileApplicability, dataStatus, and crawl caveats",
28080
29594
  "summary and topActions",
28081
29595
  "HTML and Markdown representation checks with affected URLs",
28082
- "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"
28083
29599
  ],
28084
29600
  doNotClaim: [
28085
29601
  "A clean content profile is not proof of indexing, rankings, AI mentions, citations, or selection.",
28086
29602
  "API, application, and commerce checks marked notApplicable were outside this content-site run. They did not fail.",
28087
29603
  "An allowed crawler token does not prove that a service fetched or used the page.",
28088
- "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."
28089
29605
  ],
28090
29606
  verify: "Run the same content profile against the same public route scope after deployment and confirm each failed or warning check changed.",
28091
29607
  related: [
@@ -29276,16 +30792,19 @@ const REPORT_DEPTH = {
29276
30792
  },
29277
30793
  "site-crawl": {
29278
30794
  readOrder: [
30795
+ "config.strategy first; use health before full on large or unknown sites",
29279
30796
  "status, requestEvidenceStatus, attempted and fetched counts, failures, retained pages, and pageLimitReached",
30797
+ "access crawler identity, blockedRequests, provider counts, and samples",
29280
30798
  "data-source states",
29281
30799
  "warnings and caveats, then opt-in pages, requests, and issues"
29282
30800
  ],
29283
30801
  doNotClaim: [
29284
30802
  "A partial or capped crawl cannot support an all-clear or a definitive zero.",
29285
30803
  "A crawl is not an index, traffic source, or guarantee of complete site coverage.",
29286
- "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."
29287
30806
  ],
29288
- 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.",
29289
30808
  related: [
29290
30809
  {
29291
30810
  id: "top-fixes",
@@ -29792,11 +31311,15 @@ const REPORT_GUIDANCE = {
29792
31311
  outcome: "A local setup status with the exact problems that need fixing."
29793
31312
  },
29794
31313
  "site-crawl": {
29795
- name: "Technical SEO site crawl",
29796
- description: "Crawl a site, check technical SEO signals, and save a local baseline for follow-up reports.",
29797
- useWhen: ["You need to discover technical issues across linked pages.", "You want a crawl snapshot for later filtering or comparison."],
29798
- avoidWhen: ["You only need to check one page or a known URL list.", "Important content requires browser rendering that is not enabled."],
29799
- 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."
29800
31323
  },
29801
31324
  "striking-distance": {
29802
31325
  name: "Queries near page one",
@@ -30020,6 +31543,133 @@ function registerReportTools(server) {
30020
31543
  registerSecondPageTool(server);
30021
31544
  }
30022
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
30023
31673
  //#region packages/mcp/src/workflow-tools.ts
30024
31674
  const technicalWatchInputSchema = z$1.strictObject({
30025
31675
  site: z$1.string().trim().min(1).max(2048),
@@ -30048,10 +31698,12 @@ const technicalWatchInputSchema = z$1.strictObject({
30048
31698
  });
30049
31699
  });
30050
31700
  function workflowSuccess(result) {
30051
- return toolSuccess(result.summary, {
31701
+ const presentation = workflowPresentation(result);
31702
+ const structured = compactAgentWorkflowOutput({
30052
31703
  ...result,
30053
- presentation: workflowPresentation(result)
30054
- }, { markdown: renderWorkflowMarkdown(result) });
31704
+ presentation
31705
+ });
31706
+ return toolSuccess(result.summary, structured, { markdown: renderWorkflowMarkdown(result) });
30055
31707
  }
30056
31708
  function registerWorkflowTools(server) {
30057
31709
  server.registerTool("seo_workflow_diagnose_property", {
@@ -30404,6 +32056,226 @@ function getReportDefinition(id) {
30404
32056
  return definitionsById.get(id);
30405
32057
  }
30406
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
30407
32279
  //#region packages/mcp/src/reports.ts
30408
32280
  function validationMessage(error) {
30409
32281
  return error.issues.map((issue) => {
@@ -30413,9 +32285,25 @@ function validationMessage(error) {
30413
32285
  function listReports(category) {
30414
32286
  return listReportDefinitions(category);
30415
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
+ }
30416
32303
  function describeReport(id) {
30417
32304
  const report = getReportDefinition(id);
30418
32305
  if (!report) throw new SeoError("INVALID_INPUT", `Unknown report: ${id}.`);
32306
+ const fixableChecks = listFixableChecks(report.id);
30419
32307
  return {
30420
32308
  id: report.id,
30421
32309
  category: report.category,
@@ -30428,6 +32316,7 @@ function describeReport(id) {
30428
32316
  doNotClaim: report.doNotClaim,
30429
32317
  verify: report.verify,
30430
32318
  related: report.related,
32319
+ ...fixableChecks.length > 0 ? { fixableChecks } : {},
30431
32320
  inputSchema: z$1.toJSONSchema(report.inputSchema)
30432
32321
  };
30433
32322
  }
@@ -30468,16 +32357,23 @@ function registerDiscoveryTools(server, options = {}) {
30468
32357
  });
30469
32358
  });
30470
32359
  server.registerTool("seo_describe_report", {
30471
- description: "Explain when to use one SEO report, what it returns, and which parameters it accepts",
30472
- 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
+ },
30473
32365
  outputSchema: openOutputSchema,
30474
32366
  annotations: {
30475
32367
  readOnlyHint: true,
30476
32368
  destructiveHint: false,
30477
32369
  idempotentHint: true
30478
32370
  }
30479
- }, async ({ id }) => {
32371
+ }, async ({ id, check }) => {
30480
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
+ }
30481
32377
  const report = describeReport(id);
30482
32378
  return toolSuccess(`${report.id}: ${report.description}`, { report });
30483
32379
  } catch (error) {
@@ -30530,6 +32426,6 @@ async function startMcpServer(opts = {}) {
30530
32426
  await server.connect(transport);
30531
32427
  }
30532
32428
  //#endregion
30533
- export { REPORT_CATEGORIES, createServer, describeReport, executeReport, listReports, runReport, startMcpServer };
32429
+ export { REPORT_CATEGORIES, createServer, describeReport, describeReportCheck, executeReport, listReports, runReport, startMcpServer };
30534
32430
 
30535
32431
  //# sourceMappingURL=mcp.js.map