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/index.js CHANGED
@@ -1,9 +1,9 @@
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
- import { chmodSync, createWriteStream, existsSync, mkdirSync, readFileSync, renameSync, rmSync, statSync, writeFileSync } from "node:fs";
4
+ import { chmodSync, createWriteStream, existsSync, mkdirSync, readFileSync, readdirSync, renameSync, rmSync, statSync, writeFileSync } from "node:fs";
5
5
  import { arch, homedir, platform } from "node:os";
6
- import { dirname, join } from "node:path";
6
+ import { basename, dirname, join } from "node:path";
7
7
  import { z } from "zod";
8
8
  import lockfile from "proper-lockfile";
9
9
  import crypto, { createHash, randomUUID, sign } from "node:crypto";
@@ -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";
@@ -512,10 +511,16 @@ function getPrivacySnapshot() {
512
511
  //#region packages/core/src/storage/lock.ts
513
512
  async function withFileLock(path, task) {
514
513
  ensureParentDir(path);
514
+ const fastLocks = process.env.SEO_LOCK_FAST === "1";
515
515
  const release = await lockfile.lock(path, {
516
- stale: 6e4,
517
- update: 5e3,
518
- retries: {
516
+ stale: fastLocks ? 2e3 : 6e4,
517
+ update: fastLocks ? 1e3 : 5e3,
518
+ retries: fastLocks ? {
519
+ retries: 5,
520
+ factor: 1.2,
521
+ minTimeout: 50,
522
+ maxTimeout: 200
523
+ } : {
519
524
  retries: 60,
520
525
  factor: 1.2,
521
526
  minTimeout: 200,
@@ -1076,6 +1081,165 @@ async function exchangeCode(input) {
1076
1081
  return tokenResponse.json();
1077
1082
  }
1078
1083
  //#endregion
1084
+ //#region packages/core/src/storage/cache-maintenance.ts
1085
+ const MEBIBYTE$1 = 1024 * 1024;
1086
+ const CACHE_MAX_BYTES = 256 * MEBIBYTE$1;
1087
+ const CACHE_MAX_AGE_MS = 720 * 60 * 60 * 1e3;
1088
+ const CACHE_LIMITS = {
1089
+ gsc_cache: 48 * MEBIBYTE$1,
1090
+ ga4_cache: 24 * MEBIBYTE$1,
1091
+ semrush_cache: 16 * MEBIBYTE$1,
1092
+ http_cache: 160 * MEBIBYTE$1,
1093
+ performance_reports: 8 * MEBIBYTE$1
1094
+ };
1095
+ const CACHE_POLICIES = [
1096
+ {
1097
+ table: "gsc_cache",
1098
+ keys: ["site_url", "query_hash"],
1099
+ timestamp: "fetched_at",
1100
+ sizeColumns: [
1101
+ "site_url",
1102
+ "query_hash",
1103
+ "request_json",
1104
+ "response_json"
1105
+ ]
1106
+ },
1107
+ {
1108
+ table: "ga4_cache",
1109
+ keys: ["property_id", "query_hash"],
1110
+ timestamp: "fetched_at",
1111
+ sizeColumns: [
1112
+ "property_id",
1113
+ "query_hash",
1114
+ "request_json",
1115
+ "response_json"
1116
+ ]
1117
+ },
1118
+ {
1119
+ table: "semrush_cache",
1120
+ keys: ["endpoint", "query_hash"],
1121
+ timestamp: "fetched_at",
1122
+ sizeColumns: [
1123
+ "endpoint",
1124
+ "query_hash",
1125
+ "request_json",
1126
+ "response_json"
1127
+ ]
1128
+ },
1129
+ {
1130
+ table: "http_cache",
1131
+ keys: ["url_hash"],
1132
+ timestamp: "fetched_at",
1133
+ sizeColumns: [
1134
+ "url_hash",
1135
+ "url",
1136
+ "headers_json",
1137
+ "body_blob",
1138
+ "metadata_json",
1139
+ "etag"
1140
+ ]
1141
+ },
1142
+ {
1143
+ table: "performance_reports",
1144
+ keys: ["id"],
1145
+ timestamp: "created_at",
1146
+ sizeColumns: [
1147
+ "id",
1148
+ "url",
1149
+ "strategy",
1150
+ "report_json"
1151
+ ]
1152
+ }
1153
+ ];
1154
+ function sizeExpression(columns) {
1155
+ return `${columns.map((column) => `COALESCE(LENGTH(CAST(${column} AS BLOB)), 0)`).join(" + ")} + 256`;
1156
+ }
1157
+ function policySize(database, policy) {
1158
+ const row = database.prepare(`SELECT COALESCE(SUM(${sizeExpression(policy.sizeColumns)}), 0) AS size_bytes
1159
+ FROM ${policy.table}`).get();
1160
+ return Number(row?.size_bytes ?? 0);
1161
+ }
1162
+ function cacheLogicalSizes(database) {
1163
+ return Object.fromEntries(CACHE_POLICIES.map((policy) => [policy.table, policySize(database, policy)]));
1164
+ }
1165
+ function deleteExpired(database, policy, now) {
1166
+ const cutoff = now - CACHE_MAX_AGE_MS;
1167
+ return database.prepare(`DELETE FROM ${policy.table}
1168
+ WHERE (expires_at IS NOT NULL AND expires_at <= ?)
1169
+ OR (${policy.timestamp} IS NOT NULL AND ${policy.timestamp} < ?)`).run(now, cutoff).changes;
1170
+ }
1171
+ function pruneToLimit(database, policy, limit) {
1172
+ if (policySize(database, policy) <= limit) return 0;
1173
+ const order = [`${policy.timestamp} DESC`, ...policy.keys.map((key) => `${key} ASC`)].join(", ");
1174
+ const rows = database.prepare(`SELECT ${policy.keys.join(", ")},
1175
+ ${policy.timestamp} AS fetched_at,
1176
+ ${sizeExpression(policy.sizeColumns)} AS size_bytes
1177
+ FROM ${policy.table}
1178
+ ORDER BY ${order}`).all();
1179
+ const remove = [];
1180
+ let retainedBytes = 0;
1181
+ for (const row of rows) {
1182
+ const rowBytes = Number(row.size_bytes);
1183
+ if (retainedBytes + rowBytes <= limit) retainedBytes += rowBytes;
1184
+ else remove.push(row);
1185
+ }
1186
+ if (!remove.length) return 0;
1187
+ const statement = database.prepare(`DELETE FROM ${policy.table} WHERE ${policy.keys.map((key) => `${key} = ?`).join(" AND ")}`);
1188
+ return database.transaction(() => {
1189
+ let removed = 0;
1190
+ for (const row of remove) removed += statement.run(...policy.keys.map((key) => row[key])).changes;
1191
+ return removed;
1192
+ }).immediate();
1193
+ }
1194
+ function pragmaNumber(database, pragma, field) {
1195
+ const result = database.pragma(pragma);
1196
+ const row = Array.isArray(result) ? result[0] : result;
1197
+ if (!row || typeof row !== "object") return 0;
1198
+ return Number(row[field] ?? 0);
1199
+ }
1200
+ function compactCacheDatabase(database, options = {}) {
1201
+ const autoVacuum = pragmaNumber(database, "auto_vacuum", "auto_vacuum");
1202
+ try {
1203
+ if (autoVacuum === 2) {
1204
+ database.pragma("incremental_vacuum");
1205
+ database.pragma("wal_checkpoint(PASSIVE)");
1206
+ return "incremental";
1207
+ }
1208
+ if (!options.allowFullVacuum) return "skipped";
1209
+ database.pragma("busy_timeout = 100");
1210
+ database.pragma("auto_vacuum = INCREMENTAL");
1211
+ database.exec("VACUUM");
1212
+ database.pragma("wal_checkpoint(PASSIVE)");
1213
+ return "full";
1214
+ } catch {
1215
+ return "skipped";
1216
+ } finally {
1217
+ database.pragma("busy_timeout = 5000");
1218
+ }
1219
+ }
1220
+ function runCacheMaintenance(database, options = {}) {
1221
+ const now = options.now ?? Date.now();
1222
+ const limits = {
1223
+ ...CACHE_LIMITS,
1224
+ ...options.limits
1225
+ };
1226
+ const removedByTable = Object.fromEntries(CACHE_POLICIES.map((policy) => [policy.table, 0]));
1227
+ database.transaction(() => {
1228
+ for (const policy of CACHE_POLICIES) removedByTable[policy.table] += deleteExpired(database, policy, now);
1229
+ }).immediate();
1230
+ for (const policy of CACHE_POLICIES) removedByTable[policy.table] += pruneToLimit(database, policy, limits[policy.table]);
1231
+ const removed = Object.values(removedByTable).reduce((total, count) => total + count, 0);
1232
+ const compaction = options.compact ? compactCacheDatabase(database, { allowFullVacuum: options.allowFullVacuum }) : "skipped";
1233
+ const sizes = cacheLogicalSizes(database);
1234
+ return {
1235
+ removed,
1236
+ removedByTable,
1237
+ logicalSizeBytes: Object.values(sizes).reduce((total, size) => total + size, 0),
1238
+ sizes,
1239
+ compaction
1240
+ };
1241
+ }
1242
+ //#endregion
1079
1243
  //#region packages/core/src/storage/sqlite.ts
1080
1244
  function withoutDriverMetadata(row) {
1081
1245
  if (!row || typeof row !== "object" || Array.isArray(row)) return row;
@@ -1166,6 +1330,7 @@ CREATE TABLE IF NOT EXISTS semrush_cache (
1166
1330
  expires_at INTEGER,
1167
1331
  PRIMARY KEY(endpoint, query_hash)
1168
1332
  ) WITHOUT ROWID;
1333
+ CREATE INDEX IF NOT EXISTS idx_semrush_expires ON semrush_cache(expires_at);
1169
1334
 
1170
1335
  CREATE TABLE IF NOT EXISTS http_cache (
1171
1336
  url_hash TEXT PRIMARY KEY,
@@ -1178,6 +1343,8 @@ CREATE TABLE IF NOT EXISTS http_cache (
1178
1343
  fetched_at INTEGER,
1179
1344
  expires_at INTEGER
1180
1345
  );
1346
+ CREATE INDEX IF NOT EXISTS idx_http_expires ON http_cache(expires_at);
1347
+ CREATE INDEX IF NOT EXISTS idx_http_fetched ON http_cache(fetched_at);
1181
1348
 
1182
1349
  CREATE TABLE IF NOT EXISTS content_groups (
1183
1350
  id TEXT PRIMARY KEY,
@@ -1221,6 +1388,7 @@ CREATE TABLE IF NOT EXISTS crawl_reports (
1221
1388
  total_pages INTEGER NOT NULL,
1222
1389
  issue_count INTEGER NOT NULL,
1223
1390
  created_at INTEGER NOT NULL,
1391
+ retention_class TEXT NOT NULL DEFAULT 'saved',
1224
1392
  report_json TEXT NOT NULL
1225
1393
  );
1226
1394
  CREATE INDEX IF NOT EXISTS idx_crawl_reports_latest ON crawl_reports(created_at DESC);
@@ -1364,23 +1532,28 @@ CREATE TABLE IF NOT EXISTS performance_reports (
1364
1532
  expires_at INTEGER NOT NULL
1365
1533
  );
1366
1534
  CREATE INDEX IF NOT EXISTS idx_performance_reports_url ON performance_reports(url, strategy, created_at);
1535
+ CREATE INDEX IF NOT EXISTS idx_performance_reports_expires ON performance_reports(expires_at);
1367
1536
  `;
1368
1537
  let db;
1538
+ let cacheWritesSinceMaintenance = 0;
1539
+ let cacheBytesSinceMaintenance = 0;
1369
1540
  function ensureColumn(database, table, column, definition) {
1370
1541
  if (database.prepare(`PRAGMA table_info(${table})`).all().some((item) => item.name === column)) return;
1371
1542
  database.exec(`ALTER TABLE ${table} ADD COLUMN ${column} ${definition}`);
1372
1543
  }
1373
- function initDb(database) {
1544
+ function initDb(database, isNewDatabase) {
1374
1545
  database.pragma("journal_mode = WAL");
1375
1546
  database.pragma("synchronous = NORMAL");
1376
1547
  database.pragma("foreign_keys = ON");
1377
1548
  database.pragma("temp_store = MEMORY");
1378
1549
  database.pragma("mmap_size = 268435456");
1379
1550
  database.pragma("busy_timeout = 5000");
1551
+ if (isNewDatabase) database.pragma("auto_vacuum = INCREMENTAL");
1380
1552
  database.transaction(() => {
1381
1553
  database.exec(CREATE_SQL);
1382
1554
  ensureColumn(database, "crawl_pages", "snapshot_json", "TEXT");
1383
1555
  ensureColumn(database, "http_cache", "metadata_json", "TEXT");
1556
+ ensureColumn(database, "crawl_reports", "retention_class", "TEXT NOT NULL DEFAULT 'saved'");
1384
1557
  ensureColumn(database, "index_watch_snapshots", "root_site_url", "TEXT");
1385
1558
  ensureColumn(database, "index_watch_snapshots", "property_site_url", "TEXT");
1386
1559
  ensureColumn(database, "index_watch_snapshots", "error_code", "TEXT");
@@ -1395,20 +1568,35 @@ function initDb(database) {
1395
1568
  ON index_watch_snapshots(root_site_url, url, inspected_at DESC, id DESC);
1396
1569
  CREATE INDEX IF NOT EXISTS idx_index_watch_property_url
1397
1570
  ON index_watch_snapshots(property_site_url, url, inspected_at DESC, id DESC);
1571
+ CREATE INDEX IF NOT EXISTS idx_crawl_reports_retention
1572
+ ON crawl_reports(retention_class, site_url, created_at DESC, id DESC);
1398
1573
  `);
1399
1574
  }).immediate();
1575
+ runCacheMaintenance(database, {
1576
+ compact: true,
1577
+ allowFullVacuum: true
1578
+ });
1400
1579
  }
1401
1580
  function getDb() {
1402
1581
  if (db) return db;
1403
1582
  const path = getSeoCliPaths().cacheDbFile;
1583
+ const isNewDatabase = !existsSync(path);
1404
1584
  mkdirSync(dirname(path), {
1405
1585
  recursive: true,
1406
1586
  mode: 448
1407
1587
  });
1408
1588
  db = new SqliteDatabase(path);
1409
- initDb(db);
1589
+ initDb(db, isNewDatabase);
1410
1590
  return db;
1411
1591
  }
1592
+ function noteCacheWrite(sizeBytes = 0) {
1593
+ cacheWritesSinceMaintenance += 1;
1594
+ cacheBytesSinceMaintenance += Math.max(0, sizeBytes);
1595
+ if (cacheWritesSinceMaintenance < 50 && cacheBytesSinceMaintenance < 16777216) return;
1596
+ runCacheMaintenance(getDb());
1597
+ cacheWritesSinceMaintenance = 0;
1598
+ cacheBytesSinceMaintenance = 0;
1599
+ }
1412
1600
  function checkDatabaseReadiness() {
1413
1601
  if (getDb().prepare("SELECT 1 AS ready").get()?.ready !== 1) throw new Error("The local SQLite database did not return a readiness row.");
1414
1602
  return { dbPath: getSeoCliPaths().cacheDbFile };
@@ -1420,6 +1608,7 @@ function hashKey(parts) {
1420
1608
  function getCacheStats() {
1421
1609
  const database = getDb();
1422
1610
  const dbPath = getSeoCliPaths().cacheDbFile;
1611
+ const logicalSizes = cacheLogicalSizes(database);
1423
1612
  const counts = {
1424
1613
  sites: database.prepare("SELECT COUNT(*) AS count FROM sites").get(),
1425
1614
  gsc_cache: database.prepare("SELECT COUNT(*) AS count FROM gsc_cache").get(),
@@ -1429,10 +1618,19 @@ function getCacheStats() {
1429
1618
  };
1430
1619
  return {
1431
1620
  dbPath,
1432
- sizeBytes: existsSync(dbPath) ? fileSize(dbPath) : 0,
1621
+ sizeBytes: databaseFootprint(dbPath),
1622
+ logicalSizeBytes: Object.values(logicalSizes).reduce((total, size) => total + size, 0),
1623
+ maxSizeBytes: CACHE_MAX_BYTES,
1433
1624
  counts: Object.fromEntries(Object.entries(counts).map(([key, value]) => [key, value.count]))
1434
1625
  };
1435
1626
  }
1627
+ function databaseFootprint(path) {
1628
+ return [
1629
+ path,
1630
+ `${path}-wal`,
1631
+ `${path}-shm`
1632
+ ].reduce((total, file) => total + fileSize(file), 0);
1633
+ }
1436
1634
  function clearCache(provider, olderThanMs) {
1437
1635
  const database = getDb();
1438
1636
  const cutoff = olderThanMs ? Date.now() - olderThanMs : void 0;
@@ -1448,6 +1646,7 @@ function clearCache(provider, olderThanMs) {
1448
1646
  const info = cutoff ? database.prepare(sql).run(cutoff) : database.prepare(sql).run();
1449
1647
  removed += info.changes;
1450
1648
  }
1649
+ compactCacheDatabase(database, { allowFullVacuum: true });
1451
1650
  return removed;
1452
1651
  }
1453
1652
  //#endregion
@@ -1484,9 +1683,14 @@ async function runGa4Report(propertyId, body, opts = {}) {
1484
1683
  throw new Error(`Google Analytics report failed with ${response.status}.`);
1485
1684
  }
1486
1685
  const result = await response.json();
1487
- if (cacheable) db.prepare(`INSERT OR REPLACE INTO ga4_cache
1686
+ if (cacheable) {
1687
+ const requestJson = JSON.stringify(body);
1688
+ const responseJson = JSON.stringify(result);
1689
+ db.prepare(`INSERT OR REPLACE INTO ga4_cache
1488
1690
  (property_id, query_hash, request_json, response_json, row_count, fetched_at, expires_at)
1489
- VALUES (?, ?, ?, ?, ?, ?, ?)`).run(propertyId, queryHash, JSON.stringify(body), JSON.stringify(result), result.rowCount ?? result.rows?.length ?? 0, Date.now(), Date.now() + 864e5);
1691
+ VALUES (?, ?, ?, ?, ?, ?, ?)`).run(propertyId, queryHash, requestJson, responseJson, result.rowCount ?? result.rows?.length ?? 0, Date.now(), Date.now() + 864e5);
1692
+ noteCacheWrite(Buffer.byteLength(requestJson) + Buffer.byteLength(responseJson));
1693
+ }
1490
1694
  return result;
1491
1695
  }
1492
1696
  async function listGa4AccountSummaries() {
@@ -2178,7 +2382,7 @@ function effectiveSnippetControl(input) {
2178
2382
  }
2179
2383
  //#endregion
2180
2384
  //#region packages/core/src/extract/canonical.ts
2181
- function headerValue$4(headers, name) {
2385
+ function headerValue$5(headers, name) {
2182
2386
  const lower = name.toLowerCase();
2183
2387
  return Object.entries(headers).find(([key]) => key.toLowerCase() === lower)?.[1];
2184
2388
  }
@@ -2262,7 +2466,7 @@ function parameter(value, name) {
2262
2466
  return match?.[1] ?? match?.[2] ?? match?.[3];
2263
2467
  }
2264
2468
  function headerCandidates(headers, baseUrl) {
2265
- const link = headerValue$4(headers, "link");
2469
+ const link = headerValue$5(headers, "link");
2266
2470
  if (!link) return [];
2267
2471
  const candidates = [];
2268
2472
  for (const item of splitLinkHeader(link)) {
@@ -2279,14 +2483,14 @@ function headerCandidates(headers, baseUrl) {
2279
2483
  }
2280
2484
  return candidates;
2281
2485
  }
2282
- function normalizedUrl$2(value) {
2486
+ function normalizedUrl$1(value) {
2283
2487
  const url = new URL(value);
2284
2488
  url.hash = "";
2285
2489
  return url.toString();
2286
2490
  }
2287
2491
  function evidenceFromCandidates(candidates) {
2288
2492
  const eligible = candidates.filter((candidate) => candidate.resolved && !candidate.ignoredReason);
2289
- const distinct = [...new Set(eligible.map((candidate) => normalizedUrl$2(candidate.resolved ?? "")))];
2493
+ const distinct = [...new Set(eligible.map((candidate) => normalizedUrl$1(candidate.resolved ?? "")))];
2290
2494
  if (distinct.length > 1) return {
2291
2495
  status: "conflicting",
2292
2496
  candidates
@@ -2931,7 +3135,7 @@ function safeText$1(value) {
2931
3135
  const trimmed = value?.replace(/\s+/g, " ").trim();
2932
3136
  return trimmed ? trimmed : void 0;
2933
3137
  }
2934
- function headerValue$3(headers, name) {
3138
+ function headerValue$4(headers, name) {
2935
3139
  const target = name.toLowerCase();
2936
3140
  return Object.entries(headers).find(([key]) => key.toLowerCase() === target)?.[1];
2937
3141
  }
@@ -2949,8 +3153,7 @@ function httpUrl(value, base) {
2949
3153
  const protocol = new URL(resolved).protocol;
2950
3154
  return protocol === "http:" || protocol === "https:" ? resolved : void 0;
2951
3155
  }
2952
- function sanitizedContentHtml(html, base) {
2953
- const $ = load(html);
3156
+ function sanitizedContentHtml($, html, base) {
2954
3157
  let changed = false;
2955
3158
  for (const element of $("[href], [src]").toArray()) for (const attribute of ["href", "src"]) {
2956
3159
  const value = $(element).attr(attribute);
@@ -2961,6 +3164,22 @@ function sanitizedContentHtml(html, base) {
2961
3164
  }
2962
3165
  return changed ? $.html() : html;
2963
3166
  }
3167
+ function crawlerMainContent($, baseUrl) {
3168
+ const text = ($("main").first().length ? $("main").first() : $("article").first().length ? $("article").first() : $("body").first()).text().replace(/\s+/g, " ").trim();
3169
+ return {
3170
+ text,
3171
+ excerpt: safeText$1($("meta[name=\"description\"]").attr("content")),
3172
+ wordCount: countCjkAwareWords(text),
3173
+ diagnostics: {
3174
+ requested: "crawler",
3175
+ used: "crawler",
3176
+ fallback: false,
3177
+ wordCountSource: "local_cjk_aware",
3178
+ baseUrl
3179
+ },
3180
+ warnings: []
3181
+ };
3182
+ }
2964
3183
  function numericAttribute(value) {
2965
3184
  if (!value) return void 0;
2966
3185
  const match = value.trim().match(/^\d+/);
@@ -2985,12 +3204,10 @@ function imageDimensionsFromUrl(src) {
2985
3204
  };
2986
3205
  }
2987
3206
  async function extractPage(fetchResult, extractor = "defuddle", dependencies = {}) {
2988
- const $ = load(fetchResult.html);
2989
- const content = extractMainContent({
2990
- ...fetchResult,
2991
- html: sanitizedContentHtml(fetchResult.html, fetchResult.finalUrl)
2992
- }, extractor, dependencies);
2993
- const { text, excerpt } = content;
3207
+ const $ = extractor === "crawler" && /<html(?:\s|>)/i.test(fetchResult.html) ? load(fetchResult.html, { xml: {
3208
+ xmlMode: false,
3209
+ decodeEntities: true
3210
+ } }) : load(fetchResult.html);
2994
3211
  const url = new URL(fetchResult.finalUrl);
2995
3212
  const canonicalEvidence = extractCanonicalEvidence($, fetchResult.headers, fetchResult.finalUrl);
2996
3213
  const headings = $("h1, h2, h3, h4, h5, h6").toArray().map((element) => ({
@@ -3072,13 +3289,18 @@ async function extractPage(fetchResult, extractor = "defuddle", dependencies = {
3072
3289
  const name = $(element).attr("name")?.trim().toLowerCase();
3073
3290
  return name === "robots" || name === "googlebot";
3074
3291
  }).map((element) => $(element).attr("content")));
3292
+ const content = extractor === "crawler" ? crawlerMainContent($, fetchResult.finalUrl) : extractMainContent({
3293
+ ...fetchResult,
3294
+ html: sanitizedContentHtml($, fetchResult.html, fetchResult.finalUrl)
3295
+ }, extractor, dependencies);
3296
+ const { text, excerpt } = content;
3075
3297
  return {
3076
3298
  url: fetchResult.url,
3077
3299
  finalUrl: fetchResult.finalUrl,
3078
3300
  title: safeText$1($("title").first().text()),
3079
3301
  metaDescription: safeText$1($("meta[name=\"description\"]").attr("content")),
3080
3302
  metaRobots,
3081
- xRobotsTag: safeText$1(headerValue$3(fetchResult.headers, "x-robots-tag")),
3303
+ xRobotsTag: safeText$1(headerValue$4(fetchResult.headers, "x-robots-tag")),
3082
3304
  canonical: canonicalEvidence.selectedRaw,
3083
3305
  canonicalEvidence,
3084
3306
  lang: safeText$1($("html").attr("lang")),
@@ -3129,14 +3351,108 @@ async function extractPage(fetchResult, extractor = "defuddle", dependencies = {
3129
3351
  };
3130
3352
  }
3131
3353
  //#endregion
3354
+ //#region packages/core/src/version.ts
3355
+ const SEO_VERSION = version;
3356
+ //#endregion
3357
+ //#region packages/core/src/fetch/crawler-identity.ts
3358
+ const SEO_CRAWLER_TOKEN = "SEO-Skill";
3359
+ const SEO_CRAWLER_USER_AGENT = `${SEO_CRAWLER_TOKEN}/${SEO_VERSION} (+https://seoskill.dev)`;
3360
+ const SEO_CRAWLER_IDENTITY = {
3361
+ name: "SEO Skill",
3362
+ robotsToken: SEO_CRAWLER_TOKEN,
3363
+ version: SEO_VERSION,
3364
+ userAgent: SEO_CRAWLER_USER_AGENT,
3365
+ documentationUrl: "https://seoskill.dev/docs/crawler/"
3366
+ };
3367
+ //#endregion
3368
+ //#region packages/core/src/fetch/access-block.ts
3369
+ const CLOUDFLARE_SKIP_DOCS = "https://developers.cloudflare.com/waf/custom-rules/skip/";
3370
+ function isHeadersLike(headers) {
3371
+ return "get" in headers && typeof headers.get === "function";
3372
+ }
3373
+ function headerValue$3(headers, name) {
3374
+ if (isHeadersLike(headers)) return headers.get(name) ?? void 0;
3375
+ const lower = name.toLowerCase();
3376
+ return headers[name] ?? headers[lower] ?? Object.entries(headers).find(([key]) => key.toLowerCase() === lower)?.[1];
3377
+ }
3378
+ function cloudflareEvidence(headers) {
3379
+ const mitigated = headerValue$3(headers, "cf-mitigated");
3380
+ const rayId = headerValue$3(headers, "cf-ray");
3381
+ const server = headerValue$3(headers, "server");
3382
+ const challenge = mitigated?.toLowerCase() === "challenge";
3383
+ const indicators = [
3384
+ ...challenge ? ["cf-mitigated: challenge"] : [],
3385
+ ...rayId ? [`cf-ray: ${rayId}`] : [],
3386
+ ...server?.toLowerCase().includes("cloudflare") ? [`server: ${server}`] : []
3387
+ ];
3388
+ return {
3389
+ detected: challenge || Boolean(rayId) || Boolean(indicators.length),
3390
+ challenge,
3391
+ rayId,
3392
+ indicators
3393
+ };
3394
+ }
3395
+ function blockKind(status, cloudflareChallenge) {
3396
+ if (cloudflareChallenge) return "challenge";
3397
+ if (status === 429) return "rate-limit";
3398
+ if (status === 401) return "authentication";
3399
+ return "firewall";
3400
+ }
3401
+ function detectAccessBlock(input) {
3402
+ const cloudflare = cloudflareEvidence(input.headers);
3403
+ if (![
3404
+ 401,
3405
+ 403,
3406
+ 429
3407
+ ].includes(input.status) && !cloudflare.challenge) return void 0;
3408
+ const provider = cloudflare.detected ? "cloudflare" : "unknown";
3409
+ const kind = blockKind(input.status, cloudflare.challenge);
3410
+ const indicators = [`HTTP ${input.status}`, ...cloudflare.indicators];
3411
+ if (provider === "cloudflare") return {
3412
+ provider,
3413
+ kind,
3414
+ status: input.status,
3415
+ crawler: SEO_CRAWLER_IDENTITY,
3416
+ indicators,
3417
+ ...cloudflare.rayId ? { requestId: cloudflare.rayId } : {},
3418
+ guidance: {
3419
+ summary: kind === "challenge" ? "Cloudflare returned a Challenge Page to the crawler." : "The crawler received a denied or rate-limited response through Cloudflare.",
3420
+ 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.",
3421
+ 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.",
3422
+ documentationUrl: CLOUDFLARE_SKIP_DOCS
3423
+ }
3424
+ };
3425
+ return {
3426
+ provider,
3427
+ kind,
3428
+ status: input.status,
3429
+ crawler: SEO_CRAWLER_IDENTITY,
3430
+ indicators,
3431
+ guidance: {
3432
+ summary: kind === "rate-limit" ? "The site rate-limited the crawler request." : "The site denied the crawler request.",
3433
+ 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.",
3434
+ 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."
3435
+ }
3436
+ };
3437
+ }
3438
+ //#endregion
3132
3439
  //#region packages/core/src/fetch/http-client.ts
3133
- const BROWSER_USER_AGENT = new UserAgent({ deviceCategory: "desktop" }).toString();
3440
+ var ResponseSizeLimitError = class extends Error {
3441
+ maxBytes;
3442
+ constructor(maxBytes, label = "Response") {
3443
+ super(`${label} exceeds the ${maxBytes}-byte response limit.`);
3444
+ this.maxBytes = maxBytes;
3445
+ this.name = "ResponseSizeLimitError";
3446
+ }
3447
+ };
3448
+ /** @deprecated Use SEO_CRAWLER_USER_AGENT. */
3449
+ const BROWSER_USER_AGENT = SEO_CRAWLER_USER_AGENT;
3134
3450
  function requestHeaders(profile) {
3135
3451
  if (profile === "bot") return new Headers({
3136
3452
  accept: "text/plain,*/*;q=0.8",
3137
3453
  "accept-language": "en-US,en;q=0.9",
3138
3454
  connection: "close",
3139
- "user-agent": BROWSER_USER_AGENT
3455
+ "user-agent": SEO_CRAWLER_USER_AGENT
3140
3456
  });
3141
3457
  return new Headers({
3142
3458
  accept: "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
@@ -3145,7 +3461,7 @@ function requestHeaders(profile) {
3145
3461
  "sec-ch-ua-mobile": "?0",
3146
3462
  "sec-ch-ua-platform": "\"macOS\"",
3147
3463
  "upgrade-insecure-requests": "1",
3148
- "user-agent": BROWSER_USER_AGENT
3464
+ "user-agent": SEO_CRAWLER_USER_AGENT
3149
3465
  });
3150
3466
  }
3151
3467
  function publicHttpFetch(url, input = {}) {
@@ -3161,6 +3477,32 @@ function publicHttpFetch(url, input = {}) {
3161
3477
  headers: base
3162
3478
  });
3163
3479
  }
3480
+ async function readBoundedResponseText(response, maxBytes, label = "Response") {
3481
+ const declaredLength = Number(response.headers.get("content-length"));
3482
+ if (Number.isFinite(declaredLength) && declaredLength > maxBytes) {
3483
+ await response.body?.cancel().catch(() => void 0);
3484
+ throw new ResponseSizeLimitError(maxBytes, label);
3485
+ }
3486
+ const reader = response.body?.getReader();
3487
+ if (!reader) return "";
3488
+ const chunks = [];
3489
+ let total = 0;
3490
+ try {
3491
+ while (true) {
3492
+ const { done, value } = await reader.read();
3493
+ if (done) break;
3494
+ total += value.byteLength;
3495
+ if (total > maxBytes) {
3496
+ await reader.cancel().catch(() => void 0);
3497
+ throw new ResponseSizeLimitError(maxBytes, label);
3498
+ }
3499
+ chunks.push(value);
3500
+ }
3501
+ } finally {
3502
+ reader.releaseLock();
3503
+ }
3504
+ return Buffer.concat(chunks, total).toString("utf8");
3505
+ }
3164
3506
  //#endregion
3165
3507
  //#region packages/core/src/fetch/page-fetcher/rate-controls.ts
3166
3508
  const HOST_QUEUES = /* @__PURE__ */ new Map();
@@ -3217,8 +3559,10 @@ function snapshotForHost(host, status) {
3217
3559
  };
3218
3560
  }
3219
3561
  function normalizeRateControls(rate) {
3562
+ const concurrency = rate?.concurrency ?? numberEnv("SEO_FETCH_CONCURRENCY", 4);
3563
+ if (!Number.isInteger(concurrency) || concurrency < 1 || concurrency > 16) throw new RangeError(`Fetch concurrency must be an integer from 1 to 16.`);
3220
3564
  return {
3221
- concurrency: rate?.concurrency ?? numberEnv("SEO_FETCH_CONCURRENCY", 4),
3565
+ concurrency,
3222
3566
  intervalCap: rate?.intervalCap ?? numberEnv("SEO_FETCH_INTERVAL_CAP", 4),
3223
3567
  intervalMs: rate?.intervalMs ?? numberEnv("SEO_FETCH_INTERVAL_MS", 1e3),
3224
3568
  backpressure: {
@@ -3306,7 +3650,34 @@ function recordHostFetch(input) {
3306
3650
  return snapshotForHost(input.host);
3307
3651
  }
3308
3652
  //#endregion
3653
+ //#region packages/core/src/fetch/page-fetcher/redirects.ts
3654
+ async function fetchWithRedirectChain(url, signal) {
3655
+ const redirectChain = [];
3656
+ let currentUrl = url;
3657
+ for (let redirectCount = 0; redirectCount <= 10; redirectCount += 1) {
3658
+ const response = await publicHttpFetch(currentUrl, {
3659
+ redirect: "manual",
3660
+ signal
3661
+ });
3662
+ const location = response.headers.get("location");
3663
+ if (response.status < 300 || response.status >= 400 || !location) return {
3664
+ response,
3665
+ redirectChain
3666
+ };
3667
+ const nextUrl = new URL(location, currentUrl).toString();
3668
+ redirectChain.push({
3669
+ url: currentUrl,
3670
+ status: response.status,
3671
+ location: nextUrl
3672
+ });
3673
+ await response.body?.cancel().catch(() => void 0);
3674
+ currentUrl = nextUrl;
3675
+ }
3676
+ throw new Error(`Too many redirects for ${url}`);
3677
+ }
3678
+ //#endregion
3309
3679
  //#region packages/core/src/fetch/page-fetcher/robots.ts
3680
+ const MAX_ROBOTS_RESPONSE_BYTES = 1024 * 1024;
3310
3681
  var RobotsAccessError = class extends Error {
3311
3682
  reason;
3312
3683
  evidence;
@@ -3326,10 +3697,10 @@ function cacheableStatus(status) {
3326
3697
  function resultFromResponse(input) {
3327
3698
  if (input.status >= 200 && input.status < 300) {
3328
3699
  const parsed = parseRobots$1(input.robotsUrl, input.text);
3329
- const lineNumber = parsed.getMatchingLineNumber?.(input.targetUrl, BROWSER_USER_AGENT);
3700
+ const lineNumber = parsed.getMatchingLineNumber?.(input.targetUrl, SEO_CRAWLER_USER_AGENT);
3330
3701
  const matchedLine = lineNumber ? input.text.split(/\r?\n/)[lineNumber - 1]?.trim() : void 0;
3331
3702
  return {
3332
- allowed: parsed.isAllowed(input.targetUrl, BROWSER_USER_AGENT) ?? true,
3703
+ allowed: parsed.isAllowed(input.targetUrl, SEO_CRAWLER_USER_AGENT) ?? true,
3333
3704
  availability: "available",
3334
3705
  status: input.status,
3335
3706
  cache: input.cache,
@@ -3353,44 +3724,105 @@ function resultFromResponse(input) {
3353
3724
  url: input.robotsUrl
3354
3725
  };
3355
3726
  }
3356
- async function fetchRobots(origin, targetUrl = origin, refresh = false) {
3357
- const db = getDb();
3358
- const robotsUrl = new URL("/robots.txt", origin).toString();
3359
- const key = hashKey(["robots", robotsUrl]);
3360
- const cached = db.prepare("SELECT status, body_blob, expires_at FROM http_cache WHERE url_hash = ?").get(key);
3361
- if (!refresh && cached?.body_blob && cached.status !== void 0 && cacheableStatus(cached.status) && cached.expires_at && cached.expires_at > Date.now()) return resultFromResponse({
3362
- robotsUrl,
3727
+ function resultFromSource(source, targetUrl) {
3728
+ if (source.status !== void 0 && source.text !== void 0) return resultFromResponse({
3729
+ robotsUrl: source.robotsUrl,
3363
3730
  targetUrl,
3364
- status: cached.status,
3365
- text: cached.body_blob.toString("utf8"),
3366
- cache: "hit"
3731
+ status: source.status,
3732
+ text: source.text,
3733
+ cache: source.cache
3367
3734
  });
3735
+ return {
3736
+ allowed: null,
3737
+ availability: "unreachable",
3738
+ error: source.error ?? "robots.txt could not be fetched.",
3739
+ cache: source.cache,
3740
+ url: source.robotsUrl
3741
+ };
3742
+ }
3743
+ async function fetchRobotsSource(origin, refresh, options) {
3744
+ const robotsUrl = new URL("/robots.txt", origin).toString();
3745
+ const key = hashKey(["robots", robotsUrl]);
3746
+ if (!refresh) {
3747
+ const cached = getDb().prepare("SELECT status, body_blob, expires_at FROM http_cache WHERE url_hash = ?").get(key);
3748
+ if (cached?.body_blob && cached.status !== void 0 && cacheableStatus(cached.status) && cached.expires_at && cached.expires_at > Date.now()) return {
3749
+ robotsUrl,
3750
+ status: cached.status,
3751
+ text: cached.body_blob.toString("utf8"),
3752
+ cache: "hit"
3753
+ };
3754
+ }
3755
+ const controller = new AbortController();
3756
+ const abort = () => controller.abort();
3757
+ const timer = setTimeout(abort, options.timeoutMs ?? 2e4);
3758
+ if (options.signal?.aborted) controller.abort();
3759
+ else options.signal?.addEventListener("abort", abort, { once: true });
3368
3760
  try {
3369
- const response = await publicHttpFetch(robotsUrl, { profile: "bot" });
3370
- const text = await response.text();
3371
- const result = resultFromResponse({
3761
+ const response = await publicHttpFetch(robotsUrl, {
3762
+ profile: "bot",
3763
+ signal: controller.signal
3764
+ });
3765
+ const text = await readBoundedResponseText(response, MAX_ROBOTS_RESPONSE_BYTES, "robots.txt response");
3766
+ if (options.writeCache !== false && cacheableStatus(response.status)) {
3767
+ getDb().prepare(`INSERT OR REPLACE INTO http_cache
3768
+ (url_hash, url, status, headers_json, body_blob, etag, fetched_at, expires_at)
3769
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?)`).run(key, robotsUrl, response.status, "{}", Buffer.from(text), null, Date.now(), Date.now() + 864e5);
3770
+ noteCacheWrite(Buffer.byteLength(text));
3771
+ }
3772
+ return {
3372
3773
  robotsUrl,
3373
- targetUrl,
3374
3774
  status: response.status,
3375
3775
  text,
3376
3776
  cache: refresh ? "bypass" : "miss"
3377
- });
3378
- if (cacheableStatus(response.status)) db.prepare(`INSERT OR REPLACE INTO http_cache
3379
- (url_hash, url, status, headers_json, body_blob, etag, fetched_at, expires_at)
3380
- VALUES (?, ?, ?, ?, ?, ?, ?, ?)`).run(key, robotsUrl, response.status, "{}", Buffer.from(text), null, Date.now(), Date.now() + 864e5);
3381
- return result;
3777
+ };
3382
3778
  } catch (error) {
3383
3779
  return {
3384
- allowed: null,
3385
- availability: "unreachable",
3780
+ robotsUrl,
3386
3781
  error: error instanceof Error ? error.message : String(error),
3387
- cache: refresh ? "bypass" : "miss",
3388
- url: robotsUrl
3782
+ cache: refresh ? "bypass" : "miss"
3389
3783
  };
3390
- }
3784
+ } finally {
3785
+ clearTimeout(timer);
3786
+ options.signal?.removeEventListener("abort", abort);
3787
+ }
3788
+ }
3789
+ async function fetchRobots(origin, targetUrl = origin, refresh = false, options = {}) {
3790
+ return resultFromSource(await fetchRobotsSource(origin, refresh, options), targetUrl);
3791
+ }
3792
+ function createRobotsSession(input = {}) {
3793
+ const sources = /* @__PURE__ */ new Map();
3794
+ const sourceFor = (origin) => {
3795
+ const normalizedOrigin = new URL(origin).origin;
3796
+ let source = sources.get(normalizedOrigin);
3797
+ if (!source) {
3798
+ source = fetchRobotsSource(normalizedOrigin, input.refresh ?? false, {
3799
+ writeCache: input.writeCache,
3800
+ timeoutMs: input.timeoutMs,
3801
+ signal: input.signal
3802
+ });
3803
+ sources.set(normalizedOrigin, source);
3804
+ }
3805
+ return source;
3806
+ };
3807
+ return {
3808
+ resolve: async (origin, targetUrl) => resultFromSource(await sourceFor(origin), targetUrl),
3809
+ sitemapUrls: async (origin) => {
3810
+ const source = await sourceFor(origin);
3811
+ if (source.status === void 0 || source.status >= 300 || !source.text) return [];
3812
+ const urls = /* @__PURE__ */ new Set();
3813
+ for (const match of source.text.matchAll(/^\s*sitemap\s*:\s*(\S+)\s*$/gim)) {
3814
+ const value = match[1]?.trim();
3815
+ if (!value) continue;
3816
+ try {
3817
+ urls.add(new URL(value).toString());
3818
+ } catch {}
3819
+ }
3820
+ return [...urls].sort();
3821
+ }
3822
+ };
3391
3823
  }
3392
3824
  //#endregion
3393
- //#region packages/core/src/fetch/page-fetcher/plain.ts
3825
+ //#region packages/core/src/fetch/page-fetcher/robots-evidence.ts
3394
3826
  function pageRobotsEvidence(robots) {
3395
3827
  return {
3396
3828
  url: robots.url,
@@ -3411,10 +3843,14 @@ function diagnosticRobotsEvidence(robots) {
3411
3843
  error: robots.error
3412
3844
  };
3413
3845
  }
3846
+ //#endregion
3847
+ //#region packages/core/src/fetch/page-fetcher/plain.ts
3848
+ const MAX_PAGE_RESPONSE_BYTES = 5 * 1024 * 1024;
3414
3849
  function encodePageFetchCacheEvidence(result) {
3415
3850
  return JSON.stringify({
3416
3851
  finalUrl: result.finalUrl,
3417
3852
  blocked: result.diagnostics.blocked,
3853
+ accessBlock: result.diagnostics.accessBlock,
3418
3854
  robotsTxt: result.robotsTxt,
3419
3855
  diagnosticsRobotsTxt: result.diagnostics.robotsTxt,
3420
3856
  redirectChain: result.diagnostics.redirectChain,
@@ -3431,36 +3867,15 @@ function decodePageFetchCacheEvidence(value) {
3431
3867
  return;
3432
3868
  }
3433
3869
  }
3434
- async function fetchWithRedirectChain(url, signal) {
3435
- const redirectChain = [];
3436
- let currentUrl = url;
3437
- for (let redirectCount = 0; redirectCount <= 10; redirectCount += 1) {
3438
- const response = await publicHttpFetch(currentUrl, {
3439
- redirect: "manual",
3440
- signal
3441
- });
3442
- const location = response.headers.get("location");
3443
- if (response.status < 300 || response.status >= 400 || !location) return {
3444
- response,
3445
- redirectChain
3446
- };
3447
- const nextUrl = new URL(location, currentUrl).toString();
3448
- redirectChain.push({
3449
- url: currentUrl,
3450
- status: response.status,
3451
- location: nextUrl
3452
- });
3453
- await response.body?.cancel().catch(() => void 0);
3454
- currentUrl = nextUrl;
3455
- }
3456
- throw new Error(`Too many redirects for ${url}`);
3457
- }
3458
3870
  async function fetchPlain(url, refresh = false, timeoutMs = 2e4, rate, signal, respectRobots = false) {
3459
3871
  const startedAt = Date.now();
3460
3872
  const db = getDb();
3461
3873
  const key = hashKey(["page", url]);
3462
3874
  const host = new URL(url).host;
3463
- let robots = respectRobots ? await fetchRobots(new URL(url).origin, url, refresh) : void 0;
3875
+ let robots = respectRobots ? await fetchRobots(new URL(url).origin, url, refresh, {
3876
+ timeoutMs,
3877
+ signal
3878
+ }) : void 0;
3464
3879
  if (robots && robots.allowed !== true) throw new RobotsAccessError(robots.allowed === false ? "robots-disallowed" : "robots-deferred", robots);
3465
3880
  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());
3466
3881
  if (!refresh && cached) {
@@ -3486,6 +3901,7 @@ async function fetchPlain(url, refresh = false, timeoutMs = 2e4, rate, signal, r
3486
3901
  retries: 0,
3487
3902
  rateLimit: { ...rateLimitDiagnostics(host, rate) },
3488
3903
  backpressure: hostBackpressureSnapshot(host),
3904
+ accessBlock: evidence?.accessBlock,
3489
3905
  robotsTxt: robots ? diagnosticRobotsEvidence(robots) : evidence?.diagnosticsRobotsTxt ? {
3490
3906
  ...evidence.diagnosticsRobotsTxt,
3491
3907
  cache: "hit"
@@ -3496,7 +3912,10 @@ async function fetchPlain(url, refresh = false, timeoutMs = 2e4, rate, signal, r
3496
3912
  robotsTxt: robots ? pageRobotsEvidence(robots) : evidence?.robotsTxt
3497
3913
  };
3498
3914
  }
3499
- robots ??= await fetchRobots(new URL(url).origin, url, refresh);
3915
+ robots ??= await fetchRobots(new URL(url).origin, url, refresh, {
3916
+ timeoutMs,
3917
+ signal
3918
+ });
3500
3919
  const beforeFetch = await waitForHostBackpressure(host, rate);
3501
3920
  let attempts = 0;
3502
3921
  const response = await pRetry(async () => {
@@ -3516,8 +3935,14 @@ async function fetchPlain(url, refresh = false, timeoutMs = 2e4, rate, signal, r
3516
3935
  signal?.removeEventListener("abort", abort);
3517
3936
  }
3518
3937
  }, { retries: 2 });
3519
- const html = await response.response.text();
3520
3938
  const headerMap = Object.fromEntries(response.response.headers.entries());
3939
+ const accessBlock = detectAccessBlock({
3940
+ status: response.response.status,
3941
+ headers: response.response.headers
3942
+ });
3943
+ let html = "";
3944
+ if (accessBlock) await response.response.body?.cancel().catch(() => void 0);
3945
+ else html = await readBoundedResponseText(response.response, MAX_PAGE_RESPONSE_BYTES, "Page response");
3521
3946
  const durationMs = Date.now() - startedAt;
3522
3947
  const backpressure = recordHostFetch({
3523
3948
  host,
@@ -3538,7 +3963,7 @@ async function fetchPlain(url, refresh = false, timeoutMs = 2e4, rate, signal, r
3538
3963
  cache: refresh ? "bypass" : "miss",
3539
3964
  fetched: true,
3540
3965
  rendered: false,
3541
- blocked: robots.allowed === false || [
3966
+ blocked: robots.allowed === false || Boolean(accessBlock) || [
3542
3967
  401,
3543
3968
  403,
3544
3969
  429
@@ -3547,15 +3972,19 @@ async function fetchPlain(url, refresh = false, timeoutMs = 2e4, rate, signal, r
3547
3972
  retries: Math.max(0, attempts - 1),
3548
3973
  rateLimit: { ...rateLimitDiagnostics(host, rate) },
3549
3974
  backpressure: backpressure.status === "ok" && beforeFetch.status !== "ok" ? beforeFetch : backpressure,
3975
+ accessBlock,
3550
3976
  robotsTxt: diagnosticRobotsEvidence(robots),
3551
3977
  redirectChain: response.redirectChain
3552
3978
  },
3553
- warnings: robots.allowed === null ? [`robots.txt availability is unknown${robots.status ? ` after HTTP ${robots.status}` : ""}; this fetch does not prove crawler access is allowed.`] : [],
3979
+ 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}.`] : []],
3554
3980
  robotsTxt: pageRobotsEvidence(robots)
3555
3981
  };
3556
- db.prepare(`INSERT OR REPLACE INTO http_cache
3557
- (url_hash, url, status, headers_json, body_blob, metadata_json, etag, fetched_at, expires_at)
3558
- 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);
3982
+ if (!accessBlock) {
3983
+ db.prepare(`INSERT OR REPLACE INTO http_cache
3984
+ (url_hash, url, status, headers_json, body_blob, metadata_json, etag, fetched_at, expires_at)
3985
+ 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);
3986
+ noteCacheWrite(Buffer.byteLength(html));
3987
+ }
3559
3988
  return result;
3560
3989
  }
3561
3990
  //#endregion
@@ -3768,7 +4197,7 @@ async function renderPage(input) {
3768
4197
  const beforeFetch = await waitForHostBackpressure(host, input.rate);
3769
4198
  const timeoutMs = input.timeoutMs ?? DEFAULT_RENDER_TIMEOUT_MS;
3770
4199
  const context = await input.browser.browser.newContext({
3771
- userAgent: BROWSER_USER_AGENT,
4200
+ userAgent: SEO_CRAWLER_USER_AGENT,
3772
4201
  viewport: {
3773
4202
  width: 1440,
3774
4203
  height: 900
@@ -3797,6 +4226,11 @@ async function renderPage(input) {
3797
4226
  });
3798
4227
  const html = await page.content();
3799
4228
  const status = response?.status() ?? 200;
4229
+ const headers = response?.headers() ?? {};
4230
+ const accessBlock = detectAccessBlock({
4231
+ status,
4232
+ headers
4233
+ });
3800
4234
  const durationMs = Date.now() - startedAt;
3801
4235
  const backpressure = recordHostFetch({
3802
4236
  host,
@@ -3809,7 +4243,7 @@ async function renderPage(input) {
3809
4243
  url: input.url,
3810
4244
  finalUrl: page.url(),
3811
4245
  status,
3812
- headers: response?.headers() ?? {},
4246
+ headers,
3813
4247
  html,
3814
4248
  usedJs: true,
3815
4249
  diagnostics: {
@@ -3817,7 +4251,7 @@ async function renderPage(input) {
3817
4251
  cache: "bypass",
3818
4252
  fetched: true,
3819
4253
  rendered: true,
3820
- blocked: [
4254
+ blocked: Boolean(accessBlock) || [
3821
4255
  401,
3822
4256
  403,
3823
4257
  429
@@ -3826,6 +4260,7 @@ async function renderPage(input) {
3826
4260
  retries: 0,
3827
4261
  rateLimit: rateLimitDiagnostics(host, input.rate),
3828
4262
  backpressure: backpressure.status === "ok" && beforeFetch.status !== "ok" ? beforeFetch : backpressure,
4263
+ accessBlock,
3829
4264
  rendering: {
3830
4265
  mode: "on",
3831
4266
  status: "rendered",
@@ -3842,7 +4277,7 @@ async function renderPage(input) {
3842
4277
  ...renderDiagnostics
3843
4278
  }
3844
4279
  },
3845
- warnings: networkIdleReached ? [] : [`Rendered page did not reach network idle within ${Math.min(NETWORK_IDLE_TIMEOUT_MS, timeoutMs)}ms; captured DOM after DOMContentLoaded.`]
4280
+ 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}.`] : []]
3846
4281
  };
3847
4282
  } catch (error) {
3848
4283
  if (error instanceof JavaScriptRenderingError) throw error;
@@ -3989,6 +4424,85 @@ function renderingDocumentDifference(raw, rendered) {
3989
4424
  };
3990
4425
  }
3991
4426
  //#endregion
4427
+ //#region packages/core/src/fetch/page-fetcher/status-probe.ts
4428
+ async function statusProbe(url, opts) {
4429
+ const startedAt = Date.now();
4430
+ const host = new URL(url).host;
4431
+ const rate = normalizeRateControls(opts.rate);
4432
+ const origin = new URL(url).origin;
4433
+ const robots = opts.robotsResolver ? await opts.robotsResolver(origin, url) : await fetchRobots(origin, url, opts.refresh ?? false, {
4434
+ writeCache: false,
4435
+ timeoutMs: opts.timeoutMs,
4436
+ signal: opts.signal
4437
+ });
4438
+ if (opts.respectRobots && robots.allowed !== true) throw new RobotsAccessError(robots.allowed === false ? "robots-disallowed" : "robots-deferred", robots);
4439
+ const beforeFetch = await waitForHostBackpressure(host, rate);
4440
+ let attempts = 0;
4441
+ const result = await pRetry(async () => {
4442
+ attempts += 1;
4443
+ const controller = new AbortController();
4444
+ const abort = () => controller.abort();
4445
+ const timer = setTimeout(abort, opts.timeoutMs ?? 2e4);
4446
+ if (opts.signal?.aborted) controller.abort();
4447
+ else opts.signal?.addEventListener("abort", abort, { once: true });
4448
+ try {
4449
+ return await fetchWithRedirectChain(url, controller.signal);
4450
+ } catch (error) {
4451
+ if (opts.signal?.aborted) throw new AbortError(error instanceof Error ? error : new Error(String(error)));
4452
+ throw error;
4453
+ } finally {
4454
+ clearTimeout(timer);
4455
+ opts.signal?.removeEventListener("abort", abort);
4456
+ }
4457
+ }, { retries: 1 });
4458
+ const headers = Object.fromEntries(result.response.headers.entries());
4459
+ const accessBlock = detectAccessBlock({
4460
+ status: result.response.status,
4461
+ headers: result.response.headers
4462
+ });
4463
+ await result.response.body?.cancel().catch(() => void 0);
4464
+ const durationMs = Date.now() - startedAt;
4465
+ const backpressure = recordHostFetch({
4466
+ host,
4467
+ status: result.response.status,
4468
+ durationMs,
4469
+ retryAfterMs: retryAfterMs(result.response.headers.get("retry-after")),
4470
+ rate
4471
+ });
4472
+ return {
4473
+ url,
4474
+ finalUrl: result.response.url || url,
4475
+ status: result.response.status,
4476
+ headers,
4477
+ html: "",
4478
+ usedJs: false,
4479
+ diagnostics: {
4480
+ source: "network",
4481
+ cache: "bypass",
4482
+ fetched: true,
4483
+ rendered: false,
4484
+ blocked: robots.allowed === false || Boolean(accessBlock) || [
4485
+ 401,
4486
+ 403,
4487
+ 429
4488
+ ].includes(result.response.status),
4489
+ durationMs,
4490
+ retries: Math.max(0, attempts - 1),
4491
+ rateLimit: rateLimitDiagnostics(host, rate),
4492
+ backpressure: backpressure.status === "ok" && beforeFetch.status !== "ok" ? beforeFetch : backpressure,
4493
+ accessBlock,
4494
+ robotsTxt: diagnosticRobotsEvidence(robots),
4495
+ redirectChain: result.redirectChain
4496
+ },
4497
+ 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}.`] : []],
4498
+ robotsTxt: pageRobotsEvidence(robots)
4499
+ };
4500
+ }
4501
+ async function fetchPageStatus(url, opts = {}) {
4502
+ const rate = normalizeRateControls(opts.rate);
4503
+ return await queueForHost(new URL(url).host, rate).add(() => statusProbe(url, opts));
4504
+ }
4505
+ //#endregion
3992
4506
  //#region packages/core/src/fetch/page-fetcher.ts
3993
4507
  function normalizeJavaScriptRenderingMode(input) {
3994
4508
  if (input === true) return "on";
@@ -4034,7 +4548,7 @@ async function fetchPage(url, opts = {}, dependencies = {}) {
4034
4548
  const rate = normalizeRateControls(opts.rate);
4035
4549
  return await queueForHost(new URL(url).host, rate).add(async () => {
4036
4550
  const first = await plainFetch(url, opts.refresh, opts.timeoutMs, rate, opts.signal, opts.respectRobots);
4037
- 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");
4551
+ 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");
4038
4552
  const ownsRenderer = !opts.renderer;
4039
4553
  const renderer = opts.renderer ?? makeRenderer();
4040
4554
  try {
@@ -4048,6 +4562,7 @@ async function fetchPage(url, opts = {}, dependencies = {}) {
4048
4562
  ...rendered.diagnostics,
4049
4563
  robotsTxt: first.diagnostics.robotsTxt,
4050
4564
  redirectChain: first.diagnostics.redirectChain,
4565
+ accessBlock: rendered.diagnostics.accessBlock ?? first.diagnostics.accessBlock,
4051
4566
  rendering: {
4052
4567
  ...rendered.diagnostics.rendering,
4053
4568
  mode: renderingMode,
@@ -4423,9 +4938,12 @@ async function querySearchAnalytics(site, body, opts = {}) {
4423
4938
  if (rows.length < rowLimit || typeof maxRows === "number" && allRows.length >= maxRows) break;
4424
4939
  startRow += rowLimit;
4425
4940
  }
4941
+ const requestJson = JSON.stringify(body);
4942
+ const responseJson = JSON.stringify(allRows);
4426
4943
  db.prepare(`INSERT OR REPLACE INTO gsc_cache
4427
4944
  (site_url, query_hash, request_json, response_json, row_count, fetched_at, expires_at)
4428
- VALUES (?, ?, ?, ?, ?, ?, ?)`).run(site, queryHash, JSON.stringify(body), JSON.stringify(allRows), allRows.length, Date.now(), Date.now() + 864e5);
4945
+ VALUES (?, ?, ?, ?, ?, ?, ?)`).run(site, queryHash, requestJson, responseJson, allRows.length, Date.now(), Date.now() + 864e5);
4946
+ noteCacheWrite(Buffer.byteLength(requestJson) + Buffer.byteLength(responseJson));
4429
4947
  return {
4430
4948
  rows: allRows,
4431
4949
  calls,
@@ -4570,7 +5088,7 @@ async function listSites(_refresh = false) {
4570
5088
  }
4571
5089
  //#endregion
4572
5090
  //#region packages/core/src/analyze/page-technical-signals.ts
4573
- function normalizedUrl$1(value) {
5091
+ function normalizePageUrl(value) {
4574
5092
  try {
4575
5093
  const parsed = new URL(value);
4576
5094
  parsed.hash = "";
@@ -4581,7 +5099,7 @@ function normalizedUrl$1(value) {
4581
5099
  }
4582
5100
  }
4583
5101
  function samePageUrl(left, right) {
4584
- return normalizedUrl$1(left) === normalizedUrl$1(right);
5102
+ return normalizePageUrl(left) === normalizePageUrl(right);
4585
5103
  }
4586
5104
  function absoluteUrl$1(value, base) {
4587
5105
  try {
@@ -5036,12 +5554,13 @@ const CTR_BASELINE = {
5036
5554
  9: .015,
5037
5555
  10: .012
5038
5556
  };
5557
+ const WORD_SEGMENTER = typeof Intl !== "undefined" && "Segmenter" in Intl ? new Intl.Segmenter("und", { granularity: "word" }) : void 0;
5039
5558
  function normalizeText$1(value) {
5040
5559
  return value.normalize("NFKD").replace(/\p{Diacritic}/gu, "").replace(/[’']/g, "'").toLowerCase().replace(/\s+/g, " ").trim();
5041
5560
  }
5042
5561
  function unicodeTokens(value) {
5043
5562
  const normalized = normalizeText$1(value);
5044
- 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);
5563
+ if (WORD_SEGMENTER) return [...WORD_SEGMENTER.segment(normalized)].filter((part) => part.isWordLike).map((part) => part.segment.trim()).filter((token) => token.length > 1);
5045
5564
  return (normalized.match(/[\p{L}\p{N}]+/gu) ?? []).filter((token) => token.length > 1);
5046
5565
  }
5047
5566
  function tokenize(value) {
@@ -5688,6 +6207,7 @@ const MIN_BENCHMARK_URL_SAMPLES = 5;
5688
6207
  const MIN_POSITIVE_URL_SAMPLES = 3;
5689
6208
  const SITE_BENCHMARK_FLOOR_MULTIPLIER = .5;
5690
6209
  const SITE_BENCHMARK_CAP_MULTIPLIER = 1.5;
6210
+ const URL_BENCHMARK_CACHE_LIMIT = 1e4;
5691
6211
  function roundedPosition(position) {
5692
6212
  return Math.max(1, Math.min(10, Math.round(position)));
5693
6213
  }
@@ -5704,7 +6224,8 @@ function bucketBenchmark(input) {
5704
6224
  const impressions = peerRows.reduce((sum, row) => sum + row.impressions, 0);
5705
6225
  const byUrl = /* @__PURE__ */ new Map();
5706
6226
  for (const row of peerRows) {
5707
- const key = row.keys?.[1] || `${row.keys?.[0] ?? "query"}:${row.position}`;
6227
+ const rawKey = row.keys?.[1] || `${row.keys?.[0] ?? "query"}:${row.position}`;
6228
+ const key = row.keys?.[1] ? normalizePageUrl(rawKey) : rawKey;
5708
6229
  const current = byUrl.get(key) ?? {
5709
6230
  clicks: 0,
5710
6231
  impressions: 0
@@ -5738,10 +6259,81 @@ function bucketBenchmark(input) {
5738
6259
  positiveUrlSamples: positiveUrlCtrSamples.length
5739
6260
  };
5740
6261
  }
5741
- function smoothMonotonicBenchmarks(input) {
5742
- const byPosition = {};
5743
- let previousCtr = Number.POSITIVE_INFINITY;
5744
- for (const position of Object.keys(CTR_BASELINE).map(Number)) {
6262
+ function indexedPositionBucket(rows) {
6263
+ const byUrl = /* @__PURE__ */ new Map();
6264
+ let impressions = 0;
6265
+ for (const row of rows) {
6266
+ impressions += row.impressions;
6267
+ const rawKey = row.keys?.[1] || `${row.keys?.[0] ?? "query"}:${row.position}`;
6268
+ const key = row.keys?.[1] ? normalizePageUrl(rawKey) : rawKey;
6269
+ const current = byUrl.get(key) ?? {
6270
+ key,
6271
+ clicks: 0,
6272
+ impressions: 0,
6273
+ rows: 0,
6274
+ ctr: 0
6275
+ };
6276
+ current.clicks += row.clicks;
6277
+ current.impressions += row.impressions;
6278
+ current.rows += 1;
6279
+ current.ctr = current.impressions ? current.clicks / current.impressions : 0;
6280
+ byUrl.set(key, current);
6281
+ }
6282
+ const compareSample = (left, right) => left.ctr - right.ctr || (left.key < right.key ? -1 : left.key > right.key ? 1 : 0);
6283
+ const qualified = [...byUrl.values()].filter((sample) => sample.impressions >= MIN_BENCHMARK_URL_IMPRESSIONS).sort(compareSample);
6284
+ const positive = qualified.filter((sample) => sample.ctr > 0).sort(compareSample);
6285
+ return {
6286
+ rows: rows.length,
6287
+ impressions,
6288
+ byUrl,
6289
+ qualified,
6290
+ qualifiedIndexes: new Map(qualified.map((sample, index) => [sample.key, index])),
6291
+ positive,
6292
+ positiveIndexes: new Map(positive.map((sample, index) => [sample.key, index])),
6293
+ qualifiedImpressions: qualified.reduce((sum, sample) => sum + sample.impressions, 0)
6294
+ };
6295
+ }
6296
+ function indexedPercentile(samples, indexes, excludedKey, percentileValue) {
6297
+ const excludedIndex = indexes.get(excludedKey);
6298
+ const length = samples.length - (excludedIndex === void 0 ? 0 : 1);
6299
+ if (length === 0) return void 0;
6300
+ const rank = Math.min(length - 1, Math.max(0, Math.ceil(percentileValue / 100 * length) - 1));
6301
+ return samples[excludedIndex !== void 0 && rank >= excludedIndex ? rank + 1 : rank]?.ctr;
6302
+ }
6303
+ function indexedBucketBenchmark(input) {
6304
+ const excluded = input.bucket.byUrl.get(input.excludedKey);
6305
+ const excludesQualified = excluded && excluded.impressions >= MIN_BENCHMARK_URL_IMPRESSIONS ? excluded : void 0;
6306
+ const rows = input.bucket.rows - (excluded?.rows ?? 0);
6307
+ const impressions = input.bucket.impressions - (excluded?.impressions ?? 0);
6308
+ const qualifiedImpressions = input.bucket.qualifiedImpressions - (excludesQualified?.impressions ?? 0);
6309
+ const urlSamples = input.bucket.qualified.length - (excludesQualified ? 1 : 0);
6310
+ const excludesPositive = Boolean(excludesQualified && excludesQualified.ctr > 0);
6311
+ const positiveUrlSamples = input.bucket.positive.length - (excludesPositive ? 1 : 0);
6312
+ const hasEnoughSiteData = qualifiedImpressions >= MIN_BENCHMARK_IMPRESSIONS && urlSamples >= MIN_BENCHMARK_URL_SAMPLES && positiveUrlSamples >= MIN_POSITIVE_URL_SAMPLES;
6313
+ const samplePopulation = input.samplePopulation ?? "positive_url_samples";
6314
+ 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;
6315
+ const fallbackCtr = expectedCtrForPosition(input.position);
6316
+ const floorCtr = fallbackCtr * SITE_BENCHMARK_FLOOR_MULTIPLIER;
6317
+ const capCtr = fallbackCtr * SITE_BENCHMARK_CAP_MULTIPLIER;
6318
+ const hasSiteCtr = rawSiteCtr !== void 0;
6319
+ const ctr = hasSiteCtr ? Math.max(floorCtr, Math.min(rawSiteCtr, capCtr)) : fallbackCtr;
6320
+ const adjustment = hasSiteCtr && rawSiteCtr < floorCtr ? "_floored" : hasSiteCtr && rawSiteCtr > capCtr ? "_capped" : "";
6321
+ const source = hasSiteCtr ? `site_gsc_position_bucket_robust_p75${samplePopulation === "all_qualified_url_samples" ? "_all_samples" : ""}${input.sourceSuffix}${adjustment}` : input.fallbackSource ?? "default_position_curve";
6322
+ return {
6323
+ ctr: Number(ctr.toFixed(4)),
6324
+ source,
6325
+ samplePopulation,
6326
+ rows,
6327
+ impressions,
6328
+ qualifiedImpressions,
6329
+ urlSamples,
6330
+ positiveUrlSamples
6331
+ };
6332
+ }
6333
+ function smoothMonotonicBenchmarks(input) {
6334
+ const byPosition = {};
6335
+ let previousCtr = Number.POSITIVE_INFINITY;
6336
+ for (const position of Object.keys(CTR_BASELINE).map(Number)) {
5745
6337
  const benchmark = input[position] ?? bucketBenchmark({
5746
6338
  rows: [],
5747
6339
  position
@@ -5759,9 +6351,25 @@ function smoothMonotonicBenchmarks(input) {
5759
6351
  }
5760
6352
  function createCtrBenchmarkContext(rows, options = {}) {
5761
6353
  const buckets = /* @__PURE__ */ new Map();
6354
+ const diagnostics = {
6355
+ indexRowVisits: 0,
6356
+ urlLookups: 0,
6357
+ fallbackRowScans: 0
6358
+ };
5762
6359
  for (const row of rows) {
5763
6360
  const position = roundedPosition(row.position);
5764
- buckets.set(position, [...buckets.get(position) ?? [], row]);
6361
+ const bucket = buckets.get(position) ?? [];
6362
+ bucket.push(row);
6363
+ buckets.set(position, bucket);
6364
+ diagnostics.indexRowVisits += 1;
6365
+ }
6366
+ const indexedBuckets = new Map([...buckets].map(([position, bucketRows]) => [position, indexedPositionBucket(bucketRows)]));
6367
+ const emptyIndexedBucket = indexedPositionBucket([]);
6368
+ const urlBenchmarkCache = /* @__PURE__ */ new Map();
6369
+ const urlRowCounts = /* @__PURE__ */ new Map();
6370
+ for (const row of rows) {
6371
+ const key = normalizePageUrl(row.keys?.[1] ?? "");
6372
+ urlRowCounts.set(key, (urlRowCounts.get(key) ?? 0) + 1);
5765
6373
  }
5766
6374
  const raw = {};
5767
6375
  for (const position of Object.keys(CTR_BASELINE).map(Number)) raw[position] = bucketBenchmark({
@@ -5772,6 +6380,7 @@ function createCtrBenchmarkContext(rows, options = {}) {
5772
6380
  });
5773
6381
  const byPosition = smoothMonotonicBenchmarks(raw);
5774
6382
  function benchmarkForRow(row, excludedRows, sourceSuffix) {
6383
+ diagnostics.fallbackRowScans += rows.length;
5775
6384
  const position = roundedPosition(row.position);
5776
6385
  const excludedSet = new Set(excludedRows);
5777
6386
  const rowRaw = {};
@@ -5788,18 +6397,49 @@ function createCtrBenchmarkContext(rows, options = {}) {
5788
6397
  position
5789
6398
  });
5790
6399
  }
6400
+ function benchmarkForUrl(row) {
6401
+ diagnostics.urlLookups += 1;
6402
+ const position = roundedPosition(row.position);
6403
+ const excludedKey = normalizePageUrl(row.keys?.[1] ?? "");
6404
+ const cached = excludedKey ? urlBenchmarkCache.get(excludedKey)?.get(position) : void 0;
6405
+ if (cached) return cached;
6406
+ const rowRaw = {};
6407
+ for (const bucketPosition of Object.keys(CTR_BASELINE).map(Number)) rowRaw[bucketPosition] = indexedBucketBenchmark({
6408
+ bucket: indexedBuckets.get(bucketPosition) ?? emptyIndexedBucket,
6409
+ position: bucketPosition,
6410
+ excludedKey,
6411
+ sourceSuffix: "_leave_target_url_out",
6412
+ samplePopulation: options.samplePopulation,
6413
+ fallbackSource: options.fallbackSource
6414
+ });
6415
+ const benchmark = smoothMonotonicBenchmarks(rowRaw)[String(position)] ?? byPosition[String(position)] ?? bucketBenchmark({
6416
+ rows: [],
6417
+ position
6418
+ });
6419
+ let byPositionForUrl = excludedKey ? urlBenchmarkCache.get(excludedKey) : void 0;
6420
+ if (excludedKey && !byPositionForUrl && urlBenchmarkCache.size < URL_BENCHMARK_CACHE_LIMIT) {
6421
+ byPositionForUrl = /* @__PURE__ */ new Map();
6422
+ urlBenchmarkCache.set(excludedKey, byPositionForUrl);
6423
+ }
6424
+ byPositionForUrl?.set(position, benchmark);
6425
+ return benchmark;
6426
+ }
5791
6427
  return {
5792
6428
  byPosition,
5793
6429
  forRow(row) {
5794
6430
  return benchmarkForRow(row, [row], "_leave_one_out");
5795
6431
  },
5796
6432
  forUrl(row) {
5797
- const url = row.keys?.[1];
5798
- return benchmarkForRow(row, url ? rows.filter((candidate) => samePageUrl(candidate.keys?.[1] ?? "", url)) : [row], "_leave_target_url_out");
6433
+ return benchmarkForUrl(row);
6434
+ },
6435
+ urlRowCount(row) {
6436
+ const key = normalizePageUrl(row.keys?.[1] ?? "");
6437
+ return urlRowCounts.get(key) ?? 0;
5799
6438
  },
5800
6439
  forAggregate(row, excludedRows = []) {
5801
6440
  return benchmarkForRow(row, excludedRows, excludedRows.length ? "_leave_group_out" : void 0);
5802
- }
6441
+ },
6442
+ diagnostics
5803
6443
  };
5804
6444
  }
5805
6445
  function queryFieldCoverage(query, input) {
@@ -6604,6 +7244,210 @@ function matchesFilters(issue, filters) {
6604
7244
  return true;
6605
7245
  }
6606
7246
  //#endregion
7247
+ //#region packages/core/src/analyze/crawler/agent-endpoints.ts
7248
+ const MAX_LINK_HEADER_ENTRIES = 25;
7249
+ const REGISTERED_AGENT_LINK_RELS = [
7250
+ "api-catalog",
7251
+ "describedby",
7252
+ "service-desc",
7253
+ "service-doc"
7254
+ ];
7255
+ const EMERGING_AGENT_LINK_RELS = ["agent-skills", "llms-txt"];
7256
+ const AGENT_ENDPOINT_PROBES = [
7257
+ {
7258
+ id: "mcp-server-card",
7259
+ path: "/.well-known/mcp/server-card.json",
7260
+ accept: "application/json",
7261
+ expects: "json",
7262
+ interestFields: [
7263
+ "serverInfo",
7264
+ "transport",
7265
+ "capabilities"
7266
+ ],
7267
+ requiredFields: ["serverInfo"]
7268
+ },
7269
+ {
7270
+ id: "mcp-server-cards",
7271
+ path: "/.well-known/mcp/server-cards.json",
7272
+ accept: "application/json",
7273
+ expects: "json",
7274
+ interestFields: ["serverCards", "servers"]
7275
+ },
7276
+ {
7277
+ id: "a2a-agent-card",
7278
+ path: "/.well-known/agent-card.json",
7279
+ accept: "application/json",
7280
+ expects: "json",
7281
+ interestFields: [
7282
+ "name",
7283
+ "description",
7284
+ "url",
7285
+ "capabilities",
7286
+ "skills"
7287
+ ],
7288
+ requiredFields: ["name"]
7289
+ },
7290
+ {
7291
+ id: "openid-configuration",
7292
+ path: "/.well-known/openid-configuration",
7293
+ accept: "application/json",
7294
+ expects: "json",
7295
+ interestFields: [
7296
+ "issuer",
7297
+ "authorization_endpoint",
7298
+ "token_endpoint",
7299
+ "jwks_uri",
7300
+ "response_types_supported"
7301
+ ],
7302
+ requiredFields: ["issuer"]
7303
+ },
7304
+ {
7305
+ id: "oauth-authorization-server",
7306
+ path: "/.well-known/oauth-authorization-server",
7307
+ accept: "application/json",
7308
+ expects: "json",
7309
+ interestFields: [
7310
+ "issuer",
7311
+ "authorization_endpoint",
7312
+ "token_endpoint",
7313
+ "jwks_uri",
7314
+ "grant_types_supported"
7315
+ ],
7316
+ requiredFields: ["issuer"]
7317
+ },
7318
+ {
7319
+ id: "oauth-protected-resource",
7320
+ path: "/.well-known/oauth-protected-resource",
7321
+ accept: "application/json",
7322
+ expects: "json",
7323
+ interestFields: [
7324
+ "resource",
7325
+ "authorization_servers",
7326
+ "scopes_supported"
7327
+ ],
7328
+ requiredFields: ["resource"]
7329
+ },
7330
+ {
7331
+ id: "api-catalog",
7332
+ path: "/.well-known/api-catalog",
7333
+ accept: "application/linkset+json,application/json;q=0.9",
7334
+ expects: "json",
7335
+ interestFields: ["linkset"],
7336
+ requiredFields: ["linkset"]
7337
+ },
7338
+ {
7339
+ id: "web-bot-auth-directory",
7340
+ path: "/.well-known/http-message-signatures-directory",
7341
+ accept: "application/http-message-signatures-directory+json,application/json;q=0.9",
7342
+ expects: "json",
7343
+ interestFields: ["keys"],
7344
+ requiredFields: ["keys"]
7345
+ },
7346
+ {
7347
+ id: "auth-md",
7348
+ path: "/auth.md",
7349
+ accept: "text/markdown,text/plain;q=0.9",
7350
+ expects: "text"
7351
+ },
7352
+ {
7353
+ id: "llms-full-txt",
7354
+ path: "/llms-full.txt",
7355
+ accept: "text/plain,text/markdown;q=0.9",
7356
+ expects: "text"
7357
+ }
7358
+ ];
7359
+ async function inspectAgentEndpoint(input) {
7360
+ const url = new URL(input.probe.path, input.origin).toString();
7361
+ try {
7362
+ const { response, body } = await fetchText({
7363
+ url,
7364
+ timeoutMs: input.timeoutMs,
7365
+ fetch: input.fetch,
7366
+ signal: input.signal,
7367
+ accept: input.probe.accept
7368
+ });
7369
+ const contentType = response.headers.get("content-type") ?? void 0;
7370
+ const htmlFallback = /\btext\/html\b/iu.test(contentType ?? "");
7371
+ const exists = response.status >= 200 && response.status < 300 && !htmlFallback;
7372
+ const observation = {
7373
+ id: input.probe.id,
7374
+ url: response.url || url,
7375
+ status: response.status,
7376
+ exists,
7377
+ ...contentType === void 0 ? {} : { contentType }
7378
+ };
7379
+ if (exists && input.probe.expects === "json") {
7380
+ let parsed;
7381
+ try {
7382
+ const value = JSON.parse(body);
7383
+ observation.validJson = true;
7384
+ if (value && typeof value === "object" && !Array.isArray(value)) parsed = value;
7385
+ } catch {
7386
+ observation.validJson = false;
7387
+ }
7388
+ if (parsed) {
7389
+ observation.presentFields = [.../* @__PURE__ */ new Set([...input.probe.interestFields ?? [], ...input.probe.requiredFields ?? []])].sort().filter((field) => parsed[field] !== void 0);
7390
+ observation.missingFields = (input.probe.requiredFields ?? []).filter((field) => parsed[field] === void 0).sort();
7391
+ } else if (observation.validJson) {
7392
+ observation.presentFields = [];
7393
+ observation.missingFields = [...input.probe.requiredFields ?? []].sort();
7394
+ }
7395
+ }
7396
+ return observation;
7397
+ } catch (error) {
7398
+ return {
7399
+ id: input.probe.id,
7400
+ url,
7401
+ exists: false,
7402
+ error: safeError(error)
7403
+ };
7404
+ }
7405
+ }
7406
+ async function inspectAgentEndpoints(input) {
7407
+ const [linkHeader, ...endpoints] = await Promise.all([(async () => {
7408
+ try {
7409
+ const { response } = await fetchText({
7410
+ url: input.startUrl,
7411
+ timeoutMs: input.timeoutMs,
7412
+ fetch: input.fetch,
7413
+ signal: input.signal,
7414
+ accept: "text/html,application/xhtml+xml;q=0.9"
7415
+ });
7416
+ const entries = linkEntries(response.headers.get("link") ?? void 0, input.startUrl).slice(0, MAX_LINK_HEADER_ENTRIES).map((entry) => ({
7417
+ url: entry.url,
7418
+ rel: entry.rel.filter(Boolean),
7419
+ ...entry.type ? { type: entry.type } : {}
7420
+ }));
7421
+ const observedRels = new Set(entries.flatMap((entry) => entry.rel));
7422
+ return {
7423
+ url: response.url || input.startUrl,
7424
+ status: response.status,
7425
+ entries,
7426
+ registeredRels: REGISTERED_AGENT_LINK_RELS.filter((rel) => observedRels.has(rel)),
7427
+ emergingRels: EMERGING_AGENT_LINK_RELS.filter((rel) => observedRels.has(rel))
7428
+ };
7429
+ } catch (error) {
7430
+ return {
7431
+ url: input.startUrl,
7432
+ entries: [],
7433
+ registeredRels: [],
7434
+ emergingRels: [],
7435
+ error: safeError(error)
7436
+ };
7437
+ }
7438
+ })(), ...AGENT_ENDPOINT_PROBES.map((probe) => inspectAgentEndpoint({
7439
+ origin: input.origin,
7440
+ probe,
7441
+ timeoutMs: input.timeoutMs,
7442
+ fetch: input.fetch,
7443
+ signal: input.signal
7444
+ }))]);
7445
+ return {
7446
+ linkHeader,
7447
+ endpoints
7448
+ };
7449
+ }
7450
+ //#endregion
6607
7451
  //#region packages/core/src/analyze/crawler/agent-discovery.ts
6608
7452
  const MAX_BODY_BYTES = 2e6;
6609
7453
  const MAX_LLMS_LINKS = 100;
@@ -7124,7 +7968,7 @@ async function collectAgentDiscovery(input) {
7124
7968
  fetch: input.fetch,
7125
7969
  signal: input.signal
7126
7970
  }))));
7127
- const [agentSkills, llmsTxt, routeManifest] = await Promise.all([
7971
+ const [agentSkills, llmsTxt, routeManifest, endpointDiscovery] = await Promise.all([
7128
7972
  inspectAgentSkills({
7129
7973
  ...input,
7130
7974
  origin
@@ -7139,6 +7983,10 @@ async function collectAgentDiscovery(input) {
7139
7983
  origin,
7140
7984
  pages,
7141
7985
  observations
7986
+ }),
7987
+ inspectAgentEndpoints({
7988
+ ...input,
7989
+ origin
7142
7990
  })
7143
7991
  ]);
7144
7992
  const qZero = await fetchRepresentation({
@@ -7210,6 +8058,7 @@ async function collectAgentDiscovery(input) {
7210
8058
  routeManifest,
7211
8059
  agentSkills,
7212
8060
  llmsTxt,
8061
+ endpointDiscovery,
7213
8062
  contentSignals: {
7214
8063
  htmlValues: [...new Set(htmlContentSignals)].sort(),
7215
8064
  markdownValues: [...new Set(markdownContentSignals)].sort(),
@@ -7365,7 +8214,59 @@ function markdownChecks(discovery) {
7365
8214
  })
7366
8215
  ];
7367
8216
  }
7368
- function discoveryChecks(discovery) {
8217
+ function contentSignalsCheck(discovery, robotsContentSignals) {
8218
+ const signals = discovery.contentSignals;
8219
+ const robotsDeclared = robotsContentSignals ?? [];
8220
+ const robotsCollected = robotsContentSignals !== void 0;
8221
+ const headersObserved = signals.htmlValues.length > 0 || signals.markdownValues.length > 0;
8222
+ const headersConsistent = signals.consistent === true;
8223
+ const evidence = {
8224
+ robotsDirectives: robotsDeclared,
8225
+ robotsDirectivesCollected: robotsCollected,
8226
+ ...signals
8227
+ };
8228
+ if (headersConsistent) return check$1("discovery", {
8229
+ id: "content-signals",
8230
+ status: "pass",
8231
+ title: "Every response publishes one explicit Content Signals policy",
8232
+ 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" : ""}.`,
8233
+ action: "Keep the same Content-Signal value on robots.txt, HTML, and Markdown responses when the policy changes.",
8234
+ evidence
8235
+ });
8236
+ if (robotsDeclared.length > 0 && !headersObserved) return check$1("discovery", {
8237
+ id: "content-signals",
8238
+ status: "pass",
8239
+ title: "robots.txt declares a Content Signals policy",
8240
+ 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.`,
8241
+ action: "Keep the robots.txt directive as the single policy, or mirror the same value in response headers if you add them.",
8242
+ evidence
8243
+ });
8244
+ if (robotsDeclared.length > 0 || headersObserved) return check$1("discovery", {
8245
+ id: "content-signals",
8246
+ status: "warning",
8247
+ title: "Content Signals are published inconsistently",
8248
+ 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.`,
8249
+ action: "Choose the site policy deliberately and publish the same Content-Signal value everywhere it appears.",
8250
+ evidence
8251
+ });
8252
+ if (signals.consistent === null && !robotsCollected) return check$1("discovery", {
8253
+ id: "content-signals",
8254
+ status: "unknown",
8255
+ title: "Content Signals evidence was not collected",
8256
+ plainEnglish: "No pages were evaluated for the header and robots.txt directive evidence is unavailable in this crawl.",
8257
+ action: "Re-run the report with a URL so robots.txt and response headers can be checked.",
8258
+ evidence
8259
+ });
8260
+ return check$1("discovery", {
8261
+ id: "content-signals",
8262
+ status: "info",
8263
+ title: "No Content Signals policy is published",
8264
+ plainEnglish: "Neither robots.txt nor the evaluated responses declare a Content-Signal policy. Publishing one is optional.",
8265
+ action: "Add a Content-Signal directive to robots.txt only when you want to state an explicit search, ai-input, or ai-train preference.",
8266
+ evidence
8267
+ });
8268
+ }
8269
+ function discoveryChecks(discovery, robotsContentSignals) {
7369
8270
  const skills = discovery.agentSkills;
7370
8271
  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);
7371
8272
  const llms = discovery.llmsTxt;
@@ -7409,14 +8310,7 @@ function discoveryChecks(discovery) {
7409
8310
  ...llms.missingCrawlRoutes
7410
8311
  ].slice(0, 25)
7411
8312
  }),
7412
- check$1("discovery", {
7413
- id: "content-signals",
7414
- status: discovery.contentSignals.consistent === null ? "unknown" : discovery.contentSignals.consistent ? "pass" : "warning",
7415
- title: discovery.contentSignals.consistent ? "HTML and Markdown publish one explicit Content Signals policy" : "Content Signals are missing or inconsistent across representations",
7416
- 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.`,
7417
- action: "Choose the site policy deliberately and return the same Content-Signal value on HTML and Markdown responses.",
7418
- evidence: discovery.contentSignals
7419
- }),
8313
+ contentSignalsCheck(discovery, robotsContentSignals),
7420
8314
  check$1("discovery", {
7421
8315
  id: "route-manifest",
7422
8316
  status: !discovery.routeManifest.valid ? "info" : discovery.routeManifest.missingHtmlRoutes.length === 0 && discovery.routeManifest.missingMarkdownRoutes.length === 0 && discovery.routeManifest.orphanMarkdownRoutes.length === 0 ? "pass" : "warning",
@@ -7427,6 +8321,126 @@ function discoveryChecks(discovery) {
7427
8321
  })
7428
8322
  ];
7429
8323
  }
8324
+ function optionalEndpointCheck(endpoints, copy) {
8325
+ const found = endpoints.filter((endpoint) => endpoint.exists);
8326
+ const evidence = { endpoints };
8327
+ if (found.length === 0) return check$1("endpoints", {
8328
+ id: copy.id,
8329
+ status: "info",
8330
+ title: `${copy.subject} is not published`,
8331
+ plainEnglish: copy.absent,
8332
+ action: copy.absentAction,
8333
+ evidence
8334
+ });
8335
+ const valid = found.filter((endpoint) => endpoint.validJson !== false && (endpoint.missingFields ?? []).length === 0);
8336
+ if (valid.length > 0) return check$1("endpoints", {
8337
+ id: copy.id,
8338
+ status: "pass",
8339
+ title: `${copy.subject} is published and parses`,
8340
+ plainEnglish: `${valid[0]?.url} returned ${valid[0]?.contentType ?? "a response"} with the expected structure. ${copy.requiredNote}`,
8341
+ action: copy.presentAction,
8342
+ evidence,
8343
+ urls: valid.map((endpoint) => endpoint.url)
8344
+ });
8345
+ const broken = found[0];
8346
+ return check$1("endpoints", {
8347
+ id: copy.id,
8348
+ status: "warning",
8349
+ title: `${copy.subject} is published but needs review`,
8350
+ plainEnglish: `${broken?.url} responded, but ${broken?.validJson === false ? "the body is not valid JSON" : `required fields are missing: ${(broken?.missingFields ?? []).join(", ")}`}. ${copy.requiredNote}`,
8351
+ action: copy.presentAction,
8352
+ evidence,
8353
+ urls: found.map((endpoint) => endpoint.url)
8354
+ });
8355
+ }
8356
+ function endpointChecks(discovery) {
8357
+ const endpointDiscovery = discovery.endpointDiscovery;
8358
+ if (!endpointDiscovery) return [check$1("endpoints", {
8359
+ id: "agent-endpoints-evidence",
8360
+ status: "unknown",
8361
+ title: "Agent endpoint evidence was not collected",
8362
+ plainEnglish: "This saved crawl predates the Link header and well-known endpoint probes, so that evidence is unavailable.",
8363
+ action: "Run the agent-readiness report with a URL to collect endpoint evidence."
8364
+ })];
8365
+ const byId = new Map(endpointDiscovery.endpoints.map((endpoint) => [endpoint.id, endpoint]));
8366
+ const pick = (...ids) => ids.map((id) => byId.get(id)).filter((endpoint) => Boolean(endpoint));
8367
+ const linkHeader = endpointDiscovery.linkHeader;
8368
+ const advertisedRels = [...linkHeader.registeredRels, ...linkHeader.emergingRels];
8369
+ return [
8370
+ check$1("endpoints", {
8371
+ id: "link-headers",
8372
+ status: linkHeader.error ? "unknown" : advertisedRels.length > 0 ? "pass" : "info",
8373
+ 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",
8374
+ 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.",
8375
+ 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.",
8376
+ evidence: linkHeader
8377
+ }),
8378
+ optionalEndpointCheck(pick("mcp-server-card", "mcp-server-cards"), {
8379
+ id: "mcp-server-card",
8380
+ subject: "An MCP server card",
8381
+ 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.",
8382
+ absentAction: "Publish a server card only when the site exposes a real public MCP endpoint.",
8383
+ presentAction: "Keep serverInfo, the transport endpoint, and capabilities accurate for the deployed MCP server.",
8384
+ requiredNote: "The server card format is a draft MCP proposal, so this is a structural observation rather than a compliance verdict."
8385
+ }),
8386
+ optionalEndpointCheck(pick("a2a-agent-card"), {
8387
+ id: "a2a-agent-card",
8388
+ subject: "An A2A agent card",
8389
+ absent: "/.well-known/agent-card.json returned no machine-readable document. Agent cards matter only for sites that expose an agent-to-agent endpoint.",
8390
+ absentAction: "Publish an agent card only when the site hosts a real A2A endpoint.",
8391
+ presentAction: "Keep the agent card name, url, and capabilities aligned with the deployed endpoint.",
8392
+ requiredNote: "A2A is an emerging protocol, so this is a structural observation rather than a compliance verdict."
8393
+ }),
8394
+ optionalEndpointCheck(pick("openid-configuration", "oauth-authorization-server"), {
8395
+ id: "oauth-discovery",
8396
+ subject: "OAuth or OpenID Connect discovery metadata",
8397
+ 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.",
8398
+ absentAction: "Publish RFC 8414 or OpenID Connect discovery metadata only when a real authorization server issues tokens for this origin.",
8399
+ presentAction: "Keep the issuer and endpoint URLs accurate so clients can discover how to authenticate.",
8400
+ requiredNote: "Structural presence of issuer metadata does not prove the authorization flow works."
8401
+ }),
8402
+ optionalEndpointCheck(pick("oauth-protected-resource"), {
8403
+ id: "oauth-protected-resource",
8404
+ subject: "OAuth protected resource metadata",
8405
+ absent: "/.well-known/oauth-protected-resource returned no metadata. RFC 9728 metadata matters only for origins that serve OAuth-protected APIs.",
8406
+ absentAction: "Publish protected resource metadata only when this origin actually serves OAuth-protected APIs.",
8407
+ presentAction: "Keep the resource identifier and authorization_servers list accurate.",
8408
+ requiredNote: "Structural presence does not prove tokens are issued or accepted."
8409
+ }),
8410
+ optionalEndpointCheck(pick("api-catalog"), {
8411
+ id: "api-catalog",
8412
+ subject: "An API catalog",
8413
+ absent: "/.well-known/api-catalog returned no linkset document. An RFC 9727 catalog matters only for sites that publish APIs.",
8414
+ absentAction: "Publish an API catalog only when the site has real APIs.",
8415
+ presentAction: "Keep the linkset entries pointing at live service-desc and service-doc resources.",
8416
+ requiredNote: "The catalog was checked for structure only, not for whether each linked API works."
8417
+ }),
8418
+ optionalEndpointCheck(pick("web-bot-auth-directory"), {
8419
+ id: "web-bot-auth",
8420
+ subject: "A Web Bot Auth key directory",
8421
+ absent: "/.well-known/http-message-signatures-directory returned no key set. Publishing one matters only for operators that sign their own outbound bot requests.",
8422
+ absentAction: "Publish a signature directory only when this origin operates bots that sign requests with Web Bot Auth.",
8423
+ presentAction: "Keep the published keys current and rotate them safely.",
8424
+ requiredNote: "Web Bot Auth is an emerging draft, so this is a structural observation rather than a compliance verdict."
8425
+ }),
8426
+ optionalEndpointCheck(pick("auth-md"), {
8427
+ id: "auth-md",
8428
+ subject: "An auth.md registration guide",
8429
+ 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.",
8430
+ absentAction: "Add auth.md only when agents can genuinely register for and authenticate with this site.",
8431
+ presentAction: "Keep the registration steps accurate and pair the file with real OAuth discovery metadata.",
8432
+ requiredNote: "auth.md is an emerging proposal, so this is a presence observation only."
8433
+ }),
8434
+ optionalEndpointCheck(pick("llms-full-txt"), {
8435
+ id: "llms-full-txt",
8436
+ subject: "llms-full.txt",
8437
+ absent: "/llms-full.txt returned no document. The expanded companion to llms.txt is optional, and its absence is not a search ranking problem.",
8438
+ absentAction: "Add llms-full.txt only when an intended consumer wants the full expanded content in one file.",
8439
+ presentAction: "Keep the file deterministic and consistent with the curated llms.txt entry points.",
8440
+ requiredNote: "Only presence and content type were checked, not the quality of the expanded content."
8441
+ })
8442
+ ];
8443
+ }
7430
8444
  function accessChecks(report, discovery) {
7431
8445
  const bots = report.ai?.robotsTxt?.botAccess ?? [];
7432
8446
  const blocked = bots.filter((bot) => bot.allowed === false);
@@ -7528,7 +8542,8 @@ function agentReadiness(report) {
7528
8542
  section$1("profile", "Profile scope", profileChecks(discovery)),
7529
8543
  section$1("access", "Crawler and protocol access", accessChecks(report, discovery)),
7530
8544
  section$1("representations", "HTML and Markdown representations", markdownChecks(discovery)),
7531
- section$1("discovery", "Agent discovery files", discoveryChecks(discovery)),
8545
+ section$1("discovery", "Agent discovery files", discoveryChecks(discovery, report.ai?.robotsTxt?.contentSignals)),
8546
+ section$1("endpoints", "Agent endpoints and auth discovery", endpointChecks(discovery)),
7532
8547
  section$1("identity", "Identity evidence", identityChecks(report))
7533
8548
  ];
7534
8549
  const checks = sections.flatMap((item) => item.checks);
@@ -7565,6 +8580,7 @@ function agentReadiness(report) {
7565
8580
  ...discovery.warnings,
7566
8581
  "A clean content profile does not prove indexing, rankings, AI mentions, citations, or selection.",
7567
8582
  "A crawler token being allowed does not prove that a service fetched or used the page.",
8583
+ "MCP server cards, A2A agent cards, Web Bot Auth directories, and auth.md are emerging conventions. Their absence is an observation, not a defect.",
7568
8584
  "API, application, and commerce checks were not applicable to this content-site run."
7569
8585
  ]
7570
8586
  };
@@ -8169,6 +9185,15 @@ function originHost(url) {
8169
9185
  function cleanTitle(page) {
8170
9186
  return page.title ?? page.h1 ?? (new URL(page.finalUrl).pathname || page.finalUrl);
8171
9187
  }
9188
+ function truncateAtWord(value, maxLength) {
9189
+ const normalized = value.replace(/\s+/g, " ").trim();
9190
+ if (normalized.length <= maxLength) return normalized;
9191
+ const room = Math.max(1, maxLength - 3);
9192
+ const prefix = normalized.slice(0, room);
9193
+ const boundary = prefix.lastIndexOf(" ");
9194
+ if (boundary < 0) return "...";
9195
+ return `${prefix.slice(0, boundary).trimEnd()}...`;
9196
+ }
8172
9197
  function sectionForPage(page) {
8173
9198
  const path = new URL(page.finalUrl).pathname.toLowerCase();
8174
9199
  if (/\/(docs|documentation|guide|guides|help|kb|learn)\b/.test(path)) return "Docs and guides";
@@ -8301,7 +9326,7 @@ function generateLlmsTxt(report, options = {}) {
8301
9326
  let sectionCount = 0;
8302
9327
  for (const page of sectionPages) {
8303
9328
  const title = cleanTitle(page).replace(/\]/g, "\\]");
8304
- const note = page.metaDescription ? ` - ${page.metaDescription.replace(/\s+/g, " ").slice(0, 160)}` : "";
9329
+ const note = page.metaDescription ? ` - ${truncateAtWord(page.metaDescription, 160)}` : "";
8305
9330
  const line = `- [${title}](${page.finalUrl})${note}`;
8306
9331
  if (estimatedTokens([
8307
9332
  ...lines,
@@ -8319,14 +9344,29 @@ function generateLlmsTxt(report, options = {}) {
8319
9344
  counts[sectionName] = sectionCount;
8320
9345
  if (sectionCount < sectionPages.length) break;
8321
9346
  }
8322
- 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}.`);
9347
+ 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.`);
9348
+ if (report.sitemapDiscovery) lines.push(`- Sitemap discovery: ${report.sitemapDiscovery.dataStatus}; returned ${report.sitemapDiscovery.urlsReturned} URLs.`);
8323
9349
  if (report.caveats.length) lines.push(...report.caveats.map((caveat) => `- Caveat: ${caveat}`));
9350
+ if (report.warnings.length) {
9351
+ lines.push(...report.warnings.slice(0, 10).map((warning) => `- Source warning: ${warning}`));
9352
+ if (report.warnings.length > 10) lines.push(`- Source warnings: ${report.warnings.length - 10} more warnings remain in crawl report ${report.id}.`);
9353
+ }
8324
9354
  const content = `${lines.join("\n")}\n`;
8325
9355
  return {
8326
9356
  content,
8327
9357
  includedUrls,
8328
9358
  estimatedTokens: estimatedTokens(content),
8329
- sections: counts
9359
+ sections: counts,
9360
+ source: {
9361
+ reportId: report.id,
9362
+ status: report.status,
9363
+ crawledUrls: report.summary.crawledUrls,
9364
+ discoveredUrls: report.summary.discoveredUrls,
9365
+ sitemapUrlsReturned: report.sitemapDiscovery?.urlsReturned,
9366
+ warnings: report.warnings.slice(0, 10),
9367
+ warningCount: report.warnings.length,
9368
+ warningsTruncated: report.warnings.length > 10
9369
+ }
8330
9370
  };
8331
9371
  }
8332
9372
  //#endregion
@@ -8939,6 +9979,24 @@ const RULE_DEFINITIONS = [
8939
9979
  impactIfIgnored: "The page cannot be crawled, ranked, or cited while the connection failure remains.",
8940
9980
  howToVerify: "Re-run the crawl and confirm the URL returns a normal HTTP status instead of status 0."
8941
9981
  },
9982
+ {
9983
+ id: "crawler_access_blocked",
9984
+ title: "Crawler access blocked",
9985
+ category: "response",
9986
+ defaultSeverity: "medium",
9987
+ 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.",
9988
+ 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.",
9989
+ impactIfIgnored: "The affected URL remains unverified in this crawl, so content and indexability checks cannot support a pass or an all-clear.",
9990
+ howToVerify: "Re-run the same health check and confirm access.blockedRequests is zero and the URL returns its intended status without challenge evidence.",
9991
+ agentHints: {
9992
+ evidenceFields: [
9993
+ "request.accessBlock",
9994
+ "report.access",
9995
+ "issue.evidence.accessBlock"
9996
+ ],
9997
+ suggestedCommands: ["seo crawl --sitemap-url <url> --health --json"]
9998
+ }
9999
+ },
8942
10000
  {
8943
10001
  id: "client_error",
8944
10002
  title: "Client error",
@@ -9427,6 +10485,7 @@ const RULE_RECOMMENDATIONS = {
9427
10485
  nofollow: "review",
9428
10486
  x_robots_noindex: "review",
9429
10487
  robots_blocked: "review",
10488
+ crawler_access_blocked: "review",
9430
10489
  orphan_page: "review",
9431
10490
  redirected_url: "review",
9432
10491
  slow_response: "review",
@@ -9648,6 +10707,14 @@ function auditCrawlRequests(requests) {
9648
10707
  continue;
9649
10708
  }
9650
10709
  if (request.outcome === "skipped") continue;
10710
+ if (request.accessBlock) {
10711
+ issues.push(requestIssue("crawler_access_blocked", request, request.accessBlock.guidance.summary, {
10712
+ requestedUrl: request.requestedUrl,
10713
+ finalUrl: request.finalUrl,
10714
+ accessBlock: request.accessBlock
10715
+ }));
10716
+ continue;
10717
+ }
9651
10718
  const redirectChain = request.redirectChain ?? [];
9652
10719
  if (redirectChain.length > 0 || !sameUrl(request.requestedUrl, request.finalUrl)) issues.push(requestIssue("redirected_url", request, request.finalUrl ? `Final URL: ${request.finalUrl}` : void 0, {
9653
10720
  requestedUrl: request.requestedUrl,
@@ -9683,6 +10750,7 @@ function auditCrawlPages(pages, opts = {}) {
9683
10750
  }));
9684
10751
  continue;
9685
10752
  }
10753
+ if (page.accessBlock) continue;
9686
10754
  if (page.status >= 500) {
9687
10755
  issues.push(issue("server_error", page, String(page.status), { status: page.status }));
9688
10756
  if ((page.internalInlinkCount ?? 0) > 0) issues.push(issue("broken_internal_link", page, String(page.status), {
@@ -9723,6 +10791,7 @@ function auditCrawlPages(pages, opts = {}) {
9723
10791
  responseTimeMs: page.responseTimeMs,
9724
10792
  thresholdMs: SLOW_RESPONSE_MS
9725
10793
  }));
10794
+ if (page.auditScope === "status") continue;
9726
10795
  if (redirected) continue;
9727
10796
  if (isHtmlPage(page) && (page.sizeBytes ?? 0) > LARGE_HTML_BYTES) issues.push(issue("large_html", page, `${Math.round((page.sizeBytes ?? 0) / 1024)} KB`, {
9728
10797
  sizeBytes: page.sizeBytes,
@@ -9958,6 +11027,10 @@ function crawlSkipReasonCountsFromRecord(input = {}) {
9958
11027
  }
9959
11028
  //#endregion
9960
11029
  //#region packages/core/src/analyze/crawler/report.ts
11030
+ const MAX_CRAWL_PAGES = 1e4;
11031
+ const MAX_CRAWL_DEPTH = 64;
11032
+ const MAX_CRAWL_CONCURRENCY = 16;
11033
+ const MAX_CRAWL_TIMEOUT_MS = 12e4;
9961
11034
  const SENSITIVE_FIELD_NAMES = /* @__PURE__ */ new Set([
9962
11035
  "authorization",
9963
11036
  "cookie",
@@ -9973,33 +11046,53 @@ const SENSITIVE_PARAM_PATTERN = /^(access_?token|auth|api_?key|client_?secret|co
9973
11046
  function uniqueSorted(values = []) {
9974
11047
  return [...new Set(values.filter(Boolean))].sort();
9975
11048
  }
9976
- function normalizeFetchRate(input) {
11049
+ function normalizeFetchRate(input, strategy) {
11050
+ const defaultConcurrency = strategy === "health" ? 4 : 8;
9977
11051
  return {
9978
- concurrency: input.fetchRate?.concurrency ?? input.concurrency ?? 8,
11052
+ concurrency: input.fetchRate?.concurrency ?? input.concurrency ?? defaultConcurrency,
9979
11053
  ...input.fetchRate?.intervalCap !== void 0 ? { intervalCap: input.fetchRate.intervalCap } : {},
9980
11054
  ...input.fetchRate?.intervalMs !== void 0 ? { intervalMs: input.fetchRate.intervalMs } : {},
9981
11055
  ...input.fetchRate?.backpressure ? { backpressure: input.fetchRate.backpressure } : {}
9982
11056
  };
9983
11057
  }
9984
11058
  function normalizeCrawlConfig(input) {
11059
+ const strategy = input.strategy ?? "full";
11060
+ const concurrency = input.concurrency ?? (strategy === "health" ? 4 : 8);
9985
11061
  return {
9986
11062
  url: new URL(input.url).toString(),
9987
- mode: input.mode ?? "site",
11063
+ mode: input.mode ?? (strategy === "health" ? "sitemap" : "site"),
11064
+ strategy,
11065
+ ...input.sitemapUrl ? { sitemapUrl: new URL(input.sitemapUrl).toString() } : {},
9988
11066
  urls: uniqueSorted(input.urls ?? []),
9989
11067
  maxPages: input.maxPages ?? 500,
9990
11068
  maxDepth: input.maxDepth ?? 16,
9991
- concurrency: input.concurrency ?? 8,
11069
+ concurrency,
9992
11070
  timeoutMs: input.timeoutMs ?? 2e4,
9993
11071
  include: uniqueSorted(input.include ?? []),
9994
11072
  exclude: uniqueSorted(input.exclude ?? []),
9995
11073
  respectRobots: input.respectRobots ?? true,
9996
11074
  useSitemap: input.useSitemap ?? true,
9997
- checkExternal: input.checkExternal ?? true,
9998
- checkAgentDiscovery: input.checkAgentDiscovery ?? false,
9999
- js: normalizeJavaScriptRenderingMode(input.js),
10000
- refresh: input.refresh ?? false,
10001
- fetchRate: normalizeFetchRate(input)
10002
- };
11075
+ checkExternal: strategy === "health" ? false : input.checkExternal ?? true,
11076
+ checkAgentDiscovery: strategy === "health" ? false : input.checkAgentDiscovery ?? false,
11077
+ js: strategy === "health" ? "off" : normalizeJavaScriptRenderingMode(input.js),
11078
+ refresh: strategy === "health" ? true : input.refresh ?? false,
11079
+ fetchRate: normalizeFetchRate(input, strategy)
11080
+ };
11081
+ }
11082
+ function assertIntegerRange(value, label, minimum, maximum) {
11083
+ if (!Number.isInteger(value) || value < minimum || value > maximum) throw new RangeError(`${label} must be an integer from ${minimum} to ${maximum}.`);
11084
+ }
11085
+ function assertCrawlConfigLimits(config) {
11086
+ if (config.strategy === "health" && config.mode !== "sitemap") throw new RangeError("The health strategy requires sitemap mode.");
11087
+ if (config.strategy === "health" && !config.useSitemap) throw new RangeError("The health strategy requires sitemap discovery.");
11088
+ assertIntegerRange(config.maxPages, "maxPages", 1, MAX_CRAWL_PAGES);
11089
+ assertIntegerRange(config.maxDepth, "maxDepth", 0, 64);
11090
+ assertIntegerRange(config.concurrency, "concurrency", 1, 16);
11091
+ assertIntegerRange(config.timeoutMs, "timeoutMs", 1, MAX_CRAWL_TIMEOUT_MS);
11092
+ if (config.fetchRate.concurrency !== void 0) assertIntegerRange(config.fetchRate.concurrency, "fetchRate.concurrency", 1, 16);
11093
+ if (config.strategy === "health" && config.mode !== "sitemap") throw new RangeError("Health strategy requires sitemap mode.");
11094
+ if (config.sitemapUrl && config.mode !== "sitemap") throw new RangeError("sitemapUrl requires sitemap mode.");
11095
+ return config;
10003
11096
  }
10004
11097
  function crawlConfigHash(config) {
10005
11098
  const normalized = normalizeCrawlConfig(config);
@@ -10116,10 +11209,12 @@ function summarizeCrawlReport(input) {
10116
11209
  }
10117
11210
  for (const issue of input.issues) byCategory[issue.category] = (byCategory[issue.category] ?? 0) + 1;
10118
11211
  const runStats = crawlRunStats(input.pages, input.stats);
11212
+ const indexabilityPages = input.pages.filter((page) => page.auditScope !== "status");
10119
11213
  return {
10120
11214
  totalPages: input.pages.length,
10121
- indexablePages: input.pages.filter((page) => page.indexable).length,
10122
- nonIndexablePages: input.pages.filter((page) => !page.indexable).length,
11215
+ statusOnlyPages: input.pages.filter((page) => page.auditScope === "status").length,
11216
+ indexablePages: indexabilityPages.filter((page) => page.indexable).length,
11217
+ nonIndexablePages: indexabilityPages.filter((page) => !page.indexable).length,
10123
11218
  statusErrors: input.pages.filter((page) => page.status === 0 || page.status >= 400).length,
10124
11219
  ...runStats,
10125
11220
  attemptedRequests: input.requestEvidenceStatus === "unavailable" ? 0 : requests.length,
@@ -10137,6 +11232,23 @@ function summarizeCrawlReport(input) {
10137
11232
  byCategory
10138
11233
  };
10139
11234
  }
11235
+ const ACCESS_BLOCK_SAMPLE_LIMIT = 10;
11236
+ function crawlAccessSummary(requests) {
11237
+ const blocked = requests.flatMap((request) => request.outcome === "response" && request.accessBlock ? [{
11238
+ url: request.requestedUrl,
11239
+ evidence: request.accessBlock
11240
+ }] : []);
11241
+ const providers = {};
11242
+ for (const item of blocked) providers[item.evidence.provider] = (providers[item.evidence.provider] ?? 0) + 1;
11243
+ return {
11244
+ crawler: SEO_CRAWLER_IDENTITY,
11245
+ blockedRequests: blocked.length,
11246
+ providers,
11247
+ samples: blocked.slice(0, ACCESS_BLOCK_SAMPLE_LIMIT),
11248
+ sampleLimit: ACCESS_BLOCK_SAMPLE_LIMIT,
11249
+ truncated: blocked.length > ACCESS_BLOCK_SAMPLE_LIMIT
11250
+ };
11251
+ }
10140
11252
  function crawlRunStats(pages, stats = {}) {
10141
11253
  const discovered = /* @__PURE__ */ new Set();
10142
11254
  let observedInternalLinks = 0;
@@ -10230,6 +11342,7 @@ function createCrawlReport(input) {
10230
11342
  status: input.status ?? "completed",
10231
11343
  configHash: crawlConfigHash(config),
10232
11344
  config,
11345
+ access: crawlAccessSummary(requests),
10233
11346
  summary: summarizeCrawlReport({
10234
11347
  pages,
10235
11348
  requests,
@@ -10252,16 +11365,19 @@ function createCrawlReport(input) {
10252
11365
  };
10253
11366
  }
10254
11367
  function normalizeLoadedCrawlReport(report) {
11368
+ const config = sanitizeCrawlConfig(normalizeCrawlConfig(report.config));
10255
11369
  const requestEvidenceStatus = report.requestEvidenceStatus ?? (Object.hasOwn(report, "requests") ? "available" : "unavailable");
10256
11370
  const issues = sanitizeIssues(report.issues ?? []);
10257
11371
  const requests = sortRequests(sanitizeTenantValue(report.requests ?? []) ?? []);
10258
11372
  const pages = sortPages(sanitizePages(deriveInternalLinkAuthority(report.pages ?? [])));
10259
11373
  return {
10260
11374
  ...sanitizeTenantValue(report),
11375
+ config,
11376
+ access: crawlAccessSummary(requests),
10261
11377
  requestEvidenceStatus,
10262
11378
  requests,
10263
11379
  definitionId: report.definitionId ?? crawlDefinitionId({
10264
- config: report.config,
11380
+ config,
10265
11381
  site: report.site,
10266
11382
  googleAnalyticsPropertyId: report.googleAnalyticsPropertyId
10267
11383
  }),
@@ -10282,6 +11398,8 @@ function normalizeLoadedCrawlReport(report) {
10282
11398
  //#endregion
10283
11399
  //#region packages/core/src/analyze/crawler/report-store.ts
10284
11400
  const CRAWL_REPORT_STORAGE_VERSION = 6;
11401
+ const AUTOMATIC_BASELINE_SITE_RETENTION = 2;
11402
+ const AUTOMATIC_BASELINE_TOTAL_RETENTION = 50;
10285
11403
  function toMeta(row) {
10286
11404
  return {
10287
11405
  id: row.id,
@@ -10320,14 +11438,61 @@ function reportFromJson(value) {
10320
11438
  if (parsed && typeof parsed === "object" && "kind" in parsed && parsed.kind === "seo.crawl_report" && "report" in parsed) return normalizeLoadedCrawlReport(parsed.report);
10321
11439
  return normalizeLoadedCrawlReport(parsed);
10322
11440
  }
10323
- function saveCrawlReportToSqlite(report) {
10324
- if (getDb().prepare(`INSERT INTO crawl_reports
10325
- (id, config_hash, site_url, url, status, total_pages, issue_count, created_at, report_json)
10326
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
10327
- 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) {
10328
- const existing = getDb().prepare("SELECT * FROM crawl_reports WHERE id = ?").get(report.id);
11441
+ function pruneAutomaticBaselines() {
11442
+ getDb().prepare(`WITH ranked AS (
11443
+ SELECT id,
11444
+ ROW_NUMBER() OVER (
11445
+ PARTITION BY site_url
11446
+ ORDER BY created_at DESC, id DESC
11447
+ ) AS site_rank,
11448
+ ROW_NUMBER() OVER (
11449
+ ORDER BY created_at DESC, id DESC
11450
+ ) AS total_rank
11451
+ FROM crawl_reports
11452
+ WHERE retention_class = 'baseline'
11453
+ )
11454
+ DELETE FROM crawl_reports
11455
+ WHERE id IN (
11456
+ SELECT id FROM ranked
11457
+ WHERE site_rank > ? OR total_rank > ?
11458
+ )`).run(2, 50);
11459
+ }
11460
+ function crawlReportSiteWhere(value) {
11461
+ const siteValues = /* @__PURE__ */ new Set([value]);
11462
+ const origins = /* @__PURE__ */ new Set();
11463
+ try {
11464
+ origins.add(new URL(value).origin);
11465
+ } catch {
11466
+ const host = value.replace(/^sc-domain:/, "").replace(/\/$/, "");
11467
+ if (host && !host.includes("/")) {
11468
+ siteValues.add(`sc-domain:${host}`);
11469
+ origins.add(`https://${host}`);
11470
+ origins.add(`http://${host}`);
11471
+ }
11472
+ }
11473
+ const clauses = [...siteValues].map(() => "site_url = ?");
11474
+ const params = [...siteValues];
11475
+ for (const origin of origins) {
11476
+ clauses.push("url = ?", "url LIKE ?");
11477
+ params.push(`${origin}/`, `${origin}/%`);
11478
+ }
11479
+ return {
11480
+ sql: `(${clauses.join(" OR ")})`,
11481
+ params
11482
+ };
11483
+ }
11484
+ function saveCrawlReportToSqlite(report, options = {}) {
11485
+ const database = getDb();
11486
+ const retention = options.retention ?? "saved";
11487
+ if (database.prepare(`INSERT INTO crawl_reports
11488
+ (id, config_hash, site_url, url, status, total_pages, issue_count, created_at,
11489
+ retention_class, report_json)
11490
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
11491
+ 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) {
11492
+ const existing = database.prepare("SELECT * FROM crawl_reports WHERE id = ?").get(report.id);
10329
11493
  if (existing) return toMeta(existing);
10330
11494
  }
11495
+ if (retention === "baseline") pruneAutomaticBaselines();
10331
11496
  return {
10332
11497
  id: report.id,
10333
11498
  configHash: report.configHash,
@@ -10342,10 +11507,13 @@ function saveCrawlReportToSqlite(report) {
10342
11507
  }
10343
11508
  function listCrawlReportsFromSqlite(input = {}) {
10344
11509
  const limit = input.limit ?? 20;
10345
- return (input.site ? getDb().prepare(`SELECT * FROM crawl_reports
10346
- WHERE site_url = ?
10347
- ORDER BY created_at DESC, id DESC
10348
- LIMIT ?`).all(input.site, limit) : getDb().prepare(`SELECT * FROM crawl_reports
11510
+ return (input.site ? (() => {
11511
+ const where = crawlReportSiteWhere(input.site);
11512
+ return getDb().prepare(`SELECT * FROM crawl_reports
11513
+ WHERE ${where.sql}
11514
+ ORDER BY created_at DESC, id DESC
11515
+ LIMIT ?`).all(...where.params, limit);
11516
+ })() : getDb().prepare(`SELECT * FROM crawl_reports
10349
11517
  ORDER BY created_at DESC, id DESC
10350
11518
  LIMIT ?`).all(limit)).map(toMeta);
10351
11519
  }
@@ -10358,10 +11526,13 @@ function deleteCrawlReportFromSqlite(id) {
10358
11526
  return getDb().prepare("DELETE FROM crawl_reports WHERE id = ?").run(id).changes > 0;
10359
11527
  }
10360
11528
  function latestCrawlReportFromSqlite(site) {
10361
- const row = site ? getDb().prepare(`SELECT report_json FROM crawl_reports
10362
- WHERE site_url = ?
10363
- ORDER BY created_at DESC, id DESC
10364
- LIMIT 1`).get(site) : getDb().prepare(`SELECT report_json FROM crawl_reports
11529
+ const row = site ? (() => {
11530
+ const where = crawlReportSiteWhere(site);
11531
+ return getDb().prepare(`SELECT report_json FROM crawl_reports
11532
+ WHERE ${where.sql}
11533
+ ORDER BY created_at DESC, id DESC
11534
+ LIMIT 1`).get(...where.params);
11535
+ })() : getDb().prepare(`SELECT report_json FROM crawl_reports
10365
11536
  ORDER BY created_at DESC, id DESC
10366
11537
  LIMIT 1`).get();
10367
11538
  if (!row) return void 0;
@@ -10375,8 +11546,8 @@ const sqliteCrawlReportStore = {
10375
11546
  latest: latestCrawlReportFromSqlite
10376
11547
  };
10377
11548
  const crawlReportStore = sqliteCrawlReportStore;
10378
- function saveCrawlReport(report, store = crawlReportStore) {
10379
- return store.save(report);
11549
+ function saveCrawlReport(report, store = crawlReportStore, options = {}) {
11550
+ return store.save(report, options);
10380
11551
  }
10381
11552
  function listCrawlReports(input = {}, store = crawlReportStore) {
10382
11553
  return store.list(input);
@@ -10422,6 +11593,86 @@ function cancellationRace(signal) {
10422
11593
  });
10423
11594
  }
10424
11595
  //#endregion
11596
+ //#region packages/core/src/analyze/crawler/crawl-url-queue.ts
11597
+ function before(left, right) {
11598
+ return left.depth < right.depth || left.depth === right.depth && left.url < right.url;
11599
+ }
11600
+ var CrawlUrlQueue = class {
11601
+ #items = [];
11602
+ #positions = /* @__PURE__ */ new Map();
11603
+ get size() {
11604
+ return this.#items.length;
11605
+ }
11606
+ has(url) {
11607
+ return this.#positions.has(url);
11608
+ }
11609
+ push(item) {
11610
+ if (this.has(item.url)) throw new Error(`URL is already queued: ${item.url}`);
11611
+ const index = this.#items.length;
11612
+ this.#items.push(item);
11613
+ this.#positions.set(item.url, index);
11614
+ this.#bubbleUp(index);
11615
+ }
11616
+ decreaseDepth(url, depth) {
11617
+ const index = this.#positions.get(url);
11618
+ if (index === void 0) return false;
11619
+ const item = this.#items[index];
11620
+ if (!item || depth >= item.depth) return false;
11621
+ item.depth = depth;
11622
+ this.#bubbleUp(index);
11623
+ return true;
11624
+ }
11625
+ take() {
11626
+ const first = this.#items[0];
11627
+ if (!first) return void 0;
11628
+ const last = this.#items.pop();
11629
+ this.#positions.delete(first.url);
11630
+ if (last && last !== first) {
11631
+ this.#items[0] = last;
11632
+ this.#positions.set(last.url, 0);
11633
+ this.#bubbleDown(0);
11634
+ }
11635
+ return first;
11636
+ }
11637
+ #swap(left, right) {
11638
+ const leftItem = this.#items[left];
11639
+ const rightItem = this.#items[right];
11640
+ if (!leftItem || !rightItem) return;
11641
+ this.#items[left] = rightItem;
11642
+ this.#items[right] = leftItem;
11643
+ this.#positions.set(rightItem.url, left);
11644
+ this.#positions.set(leftItem.url, right);
11645
+ }
11646
+ #bubbleUp(start) {
11647
+ let index = start;
11648
+ while (index > 0) {
11649
+ const parent = Math.floor((index - 1) / 2);
11650
+ const item = this.#items[index];
11651
+ const parentItem = this.#items[parent];
11652
+ if (!item || !parentItem || !before(item, parentItem)) break;
11653
+ this.#swap(index, parent);
11654
+ index = parent;
11655
+ }
11656
+ }
11657
+ #bubbleDown(start) {
11658
+ let index = start;
11659
+ while (true) {
11660
+ const left = index * 2 + 1;
11661
+ const right = left + 1;
11662
+ let next = index;
11663
+ const item = this.#items[next];
11664
+ const leftItem = this.#items[left];
11665
+ const rightItem = this.#items[right];
11666
+ if (leftItem && item && before(leftItem, item)) next = left;
11667
+ const nextItem = this.#items[next];
11668
+ if (rightItem && nextItem && before(rightItem, nextItem)) next = right;
11669
+ if (next === index) return;
11670
+ this.#swap(index, next);
11671
+ index = next;
11672
+ }
11673
+ }
11674
+ };
11675
+ //#endregion
10425
11676
  //#region packages/core/src/analyze/crawler/external-link-checks.ts
10426
11677
  const EXTERNAL_LINK_SELECTION_LIMIT = 200;
10427
11678
  const EXTERNAL_LINK_STATES = [
@@ -10623,18 +11874,13 @@ function resolveFetchedAlias(url, aliases) {
10623
11874
  }
10624
11875
  return current;
10625
11876
  }
10626
- function resolveLinkGraphAliases(linkGraph, aliases) {
10627
- const resolved = {};
10628
- for (const [source, targets] of Object.entries(linkGraph)) {
10629
- const resolvedSource = resolveFetchedAlias(source, aliases);
10630
- let resolvedTargets = resolved[resolvedSource];
10631
- if (!resolvedTargets) {
10632
- resolvedTargets = /* @__PURE__ */ new Set();
10633
- resolved[resolvedSource] = resolvedTargets;
10634
- }
10635
- for (const target of targets) resolvedTargets.add(resolveFetchedAlias(target, aliases));
11877
+ function resolveLinkCountAliases(counts, aliases) {
11878
+ const resolved = /* @__PURE__ */ new Map();
11879
+ for (const [url, count] of counts) {
11880
+ const target = resolveFetchedAlias(url, aliases);
11881
+ resolved.set(target, (resolved.get(target) ?? 0) + count);
10636
11882
  }
10637
- return Object.fromEntries(Object.entries(resolved).map(([source, targets]) => [source, [...targets].sort((left, right) => left < right ? -1 : left > right ? 1 : 0)]));
11883
+ return resolved;
10638
11884
  }
10639
11885
  //#endregion
10640
11886
  //#region packages/core/src/analyze/crawler/request-evidence.ts
@@ -10646,7 +11892,9 @@ function observationFromPage(requestedUrl, page) {
10646
11892
  status: page.status,
10647
11893
  contentType: page.contentType,
10648
11894
  durationMs: page.responseTimeMs,
10649
- redirectChain: page.fetchDiagnostics?.redirectChain
11895
+ redirectChain: page.fetchDiagnostics?.redirectChain,
11896
+ accessBlock: page.accessBlock ?? page.fetchDiagnostics?.accessBlock,
11897
+ robotsTxt: page.robotsTxt
10650
11898
  };
10651
11899
  if (page.extractionStatus === "failed") return {
10652
11900
  ...observation,
@@ -10659,23 +11907,230 @@ function observationFromPage(requestedUrl, page) {
10659
11907
  };
10660
11908
  }
10661
11909
  //#endregion
10662
- //#region packages/core/src/analyze/crawler/site-crawl-providers.ts
10663
- function crawlMetricsWindow(now) {
10664
- return {
10665
- ...defaultDateRange(28, now),
10666
- days: 28
10667
- };
10668
- }
10669
- async function joinAnalytics(input) {
10670
- let analytics;
11910
+ //#region packages/core/src/analyze/crawler/site-crawl-ai-signals.ts
11911
+ const MAX_AUXILIARY_RESPONSE_BYTES = 2 * 1024 * 1024;
11912
+ const AI_BOT_USER_AGENTS = [
11913
+ "GPTBot",
11914
+ "OAI-SearchBot",
11915
+ "ChatGPT-User",
11916
+ "ClaudeBot",
11917
+ "Claude-User",
11918
+ "Claude-SearchBot",
11919
+ "PerplexityBot",
11920
+ "Perplexity-User",
11921
+ "Google-Extended",
11922
+ "CCBot",
11923
+ "Applebot",
11924
+ "Applebot-Extended",
11925
+ "Bingbot",
11926
+ "Amazonbot",
11927
+ "Bytespider",
11928
+ "Meta-ExternalAgent",
11929
+ "Meta-ExternalFetcher",
11930
+ "DuckAssistBot",
11931
+ "MistralAI-User",
11932
+ "Diffbot",
11933
+ "cohere-ai"
11934
+ ];
11935
+ const AGENT_RESOURCE_PATHS = [
11936
+ "/.well-known/agent.json",
11937
+ "/agent.json",
11938
+ "/.well-known/mcp.json",
11939
+ "/.well-known/ai-plugin.json",
11940
+ "/.well-known/openapi.json",
11941
+ "/openapi.json"
11942
+ ];
11943
+ async function checkLlmsTxt(input) {
11944
+ const llmsUrl = new URL("/llms.txt", input.url).toString();
11945
+ const controller = abortController({
11946
+ timeoutMs: input.timeoutMs,
11947
+ signal: input.signal
11948
+ });
10671
11949
  try {
10672
- analytics = await input.fetchLandingPageValues({
10673
- propertyId: input.propertyId,
10674
- startDate: input.window.startDate,
10675
- endDate: input.window.endDate,
10676
- limit: input.limit
10677
- });
10678
- } catch (error) {
11950
+ const response = await input.fetch(llmsUrl, {
11951
+ profile: "bot",
11952
+ redirect: "follow",
11953
+ signal: controller.signal
11954
+ });
11955
+ const contentType = response.headers.get("content-type") ?? "";
11956
+ const exists = response.status >= 200 && response.status < 300 && !/\btext\/html\b/i.test(contentType);
11957
+ await response.body?.cancel().catch(() => void 0);
11958
+ return {
11959
+ url: response.url || llmsUrl,
11960
+ exists,
11961
+ status: response.status
11962
+ };
11963
+ } catch {
11964
+ return {
11965
+ url: llmsUrl,
11966
+ exists: false
11967
+ };
11968
+ } finally {
11969
+ controller.cleanup();
11970
+ }
11971
+ }
11972
+ function parseRobots(robotsUrl, text) {
11973
+ return robotsParserModule(robotsUrl, text);
11974
+ }
11975
+ function declaredUserAgents(text) {
11976
+ const declared = /* @__PURE__ */ new Set();
11977
+ for (const match of text.matchAll(/^\s*user-agent\s*:\s*(.+?)\s*$/gim)) {
11978
+ const value = match[1]?.trim().toLowerCase();
11979
+ if (value) declared.add(value);
11980
+ }
11981
+ return declared;
11982
+ }
11983
+ function contentSignalsFromRobots(text) {
11984
+ const values = /* @__PURE__ */ new Set();
11985
+ for (const match of text.matchAll(/^\s*content-signal\s*:\s*(.+?)\s*$/gim)) {
11986
+ const value = match[1]?.trim();
11987
+ if (value) values.add(value);
11988
+ }
11989
+ return [...values].sort();
11990
+ }
11991
+ function sitemapUrlsFromRobots(text) {
11992
+ const urls = /* @__PURE__ */ new Set();
11993
+ for (const match of text.matchAll(/^\s*sitemap\s*:\s*(\S+)\s*$/gim)) {
11994
+ const value = match[1]?.trim();
11995
+ if (!value) continue;
11996
+ try {
11997
+ urls.add(new URL(value).toString());
11998
+ } catch {}
11999
+ }
12000
+ return [...urls];
12001
+ }
12002
+ async function checkRobotsAiAccess(input) {
12003
+ const robotsUrl = new URL("/robots.txt", input.url).toString();
12004
+ const controller = abortController({
12005
+ timeoutMs: input.timeoutMs,
12006
+ signal: input.signal
12007
+ });
12008
+ try {
12009
+ const response = await input.fetch(robotsUrl, {
12010
+ profile: "bot",
12011
+ redirect: "follow",
12012
+ signal: controller.signal
12013
+ });
12014
+ const text = await readBoundedResponseText(response, MAX_AUXILIARY_RESPONSE_BYTES, "robots.txt response");
12015
+ const available = response.status >= 200 && response.status < 300;
12016
+ const absent = response.status >= 400 && response.status < 500 && response.status !== 429;
12017
+ const availability = available ? "available" : [401, 403].includes(response.status) ? "access-blocked" : response.status === 429 ? "rate-limited" : absent ? "absent" : "unreachable";
12018
+ const exists = available;
12019
+ const declared = exists ? declaredUserAgents(text) : /* @__PURE__ */ new Set();
12020
+ const parsed = parseRobots(robotsUrl, exists ? text : "");
12021
+ return {
12022
+ url: response.url || robotsUrl,
12023
+ exists,
12024
+ availability,
12025
+ status: response.status,
12026
+ ...["rate-limited", "unreachable"].includes(availability) ? { error: `robots.txt returned HTTP ${response.status}.` } : {},
12027
+ sitemapUrls: exists ? sitemapUrlsFromRobots(text) : [],
12028
+ contentSignals: exists ? contentSignalsFromRobots(text) : [],
12029
+ botAccess: AI_BOT_USER_AGENTS.map((userAgent) => {
12030
+ const lower = userAgent.toLowerCase();
12031
+ return {
12032
+ userAgent,
12033
+ allowed: availability === "rate-limited" || availability === "unreachable" ? null : parsed.isAllowed(input.url, userAgent) ?? true,
12034
+ declared: declared.has(lower),
12035
+ coveredByWildcard: declared.has("*")
12036
+ };
12037
+ })
12038
+ };
12039
+ } catch (error) {
12040
+ return {
12041
+ url: robotsUrl,
12042
+ exists: false,
12043
+ availability: "unreachable",
12044
+ error: error instanceof Error ? error.message : String(error),
12045
+ sitemapUrls: [],
12046
+ botAccess: AI_BOT_USER_AGENTS.map((userAgent) => ({
12047
+ userAgent,
12048
+ allowed: null,
12049
+ declared: false,
12050
+ coveredByWildcard: false
12051
+ }))
12052
+ };
12053
+ } finally {
12054
+ controller.cleanup();
12055
+ }
12056
+ }
12057
+ async function checkAgentResource(input) {
12058
+ const url = new URL(input.path, input.baseUrl).toString();
12059
+ const controller = abortController({
12060
+ timeoutMs: input.timeoutMs,
12061
+ signal: input.signal
12062
+ });
12063
+ try {
12064
+ const response = await input.fetch(url, {
12065
+ profile: "bot",
12066
+ redirect: "follow",
12067
+ signal: controller.signal
12068
+ });
12069
+ const contentType = response.headers.get("content-type") ?? "";
12070
+ const text = await readBoundedResponseText(response, MAX_AUXILIARY_RESPONSE_BYTES, "Agent resource response");
12071
+ const exists = response.status >= 200 && response.status < 300;
12072
+ let validJson;
12073
+ if (exists && /\bjson\b/i.test(contentType)) try {
12074
+ JSON.parse(text);
12075
+ validJson = true;
12076
+ } catch {
12077
+ validJson = false;
12078
+ }
12079
+ return {
12080
+ url: response.url || url,
12081
+ exists,
12082
+ status: response.status,
12083
+ contentType,
12084
+ ...validJson === void 0 ? {} : { validJson }
12085
+ };
12086
+ } catch {
12087
+ return {
12088
+ url,
12089
+ exists: false
12090
+ };
12091
+ } finally {
12092
+ controller.cleanup();
12093
+ }
12094
+ }
12095
+ async function checkAgentResources(input) {
12096
+ const resources = [];
12097
+ for (const path of AGENT_RESOURCE_PATHS) {
12098
+ if (input.signal?.aborted) break;
12099
+ resources.push(await checkAgentResource({
12100
+ baseUrl: input.url,
12101
+ path,
12102
+ timeoutMs: input.timeoutMs,
12103
+ fetch: input.fetch,
12104
+ signal: input.signal
12105
+ }));
12106
+ }
12107
+ return resources;
12108
+ }
12109
+ function collectCrawlAiSignals(input) {
12110
+ return Promise.all([
12111
+ checkLlmsTxt(input),
12112
+ checkRobotsAiAccess(input),
12113
+ checkAgentResources(input)
12114
+ ]);
12115
+ }
12116
+ //#endregion
12117
+ //#region packages/core/src/analyze/crawler/site-crawl-providers.ts
12118
+ function crawlMetricsWindow(now) {
12119
+ return {
12120
+ ...defaultDateRange(28, now),
12121
+ days: 28
12122
+ };
12123
+ }
12124
+ async function joinAnalytics(input) {
12125
+ let analytics;
12126
+ try {
12127
+ analytics = await input.fetchLandingPageValues({
12128
+ propertyId: input.propertyId,
12129
+ startDate: input.window.startDate,
12130
+ endDate: input.window.endDate,
12131
+ limit: input.limit
12132
+ });
12133
+ } catch (error) {
10679
12134
  const warning = `Google Analytics metrics unavailable: ${error instanceof Error ? error.message : String(error)}`;
10680
12135
  input.warnings.push(warning);
10681
12136
  return {
@@ -11024,7 +12479,9 @@ function responseObservation(requestedUrl, fetched, extraction, extractionError)
11024
12479
  status: fetched.status,
11025
12480
  contentType: headerValue(fetched.headers, "content-type"),
11026
12481
  durationMs: fetched.diagnostics.durationMs,
11027
- redirectChain: fetched.diagnostics.redirectChain
12482
+ redirectChain: fetched.diagnostics.redirectChain,
12483
+ accessBlock: fetched.diagnostics.accessBlock,
12484
+ robotsTxt: fetched.robotsTxt
11028
12485
  };
11029
12486
  if (extraction === "failed") {
11030
12487
  if (!extractionError) throw new Error("Failed extraction observations require an error.");
@@ -11049,13 +12506,13 @@ function unextractedPage(fetched, contentType) {
11049
12506
  finalUrl: fetched.finalUrl,
11050
12507
  status: fetched.status,
11051
12508
  contentType,
11052
- responseHeaders: safeResponseHeaders(fetched.headers),
11053
12509
  responseTimeMs: fetched.diagnostics.durationMs,
11054
12510
  sizeBytes: Buffer.byteLength(fetched.html),
11055
12511
  usedJs: fetched.usedJs,
11056
12512
  fetchSource: fetched.diagnostics.source,
11057
12513
  cacheState: fetched.diagnostics.cache,
11058
12514
  fetchDiagnostics: fetched.diagnostics,
12515
+ accessBlock: fetched.diagnostics.accessBlock,
11059
12516
  blocked: fetched.diagnostics.blocked,
11060
12517
  robotsTxt: fetched.robotsTxt,
11061
12518
  xRobotsTag,
@@ -11076,6 +12533,35 @@ function unextractedPage(fetched, contentType) {
11076
12533
  compression: headerValue(fetched.headers, "content-encoding")
11077
12534
  };
11078
12535
  }
12536
+ function statusOnlyPage(fetched) {
12537
+ const contentType = headerValue(fetched.headers, "content-type");
12538
+ return {
12539
+ url: fetched.finalUrl,
12540
+ finalUrl: fetched.finalUrl,
12541
+ status: fetched.status,
12542
+ contentType,
12543
+ responseHeaders: safeResponseHeaders(fetched.headers),
12544
+ responseTimeMs: fetched.diagnostics.durationMs,
12545
+ usedJs: false,
12546
+ fetchSource: "network",
12547
+ cacheState: "bypass",
12548
+ fetchDiagnostics: fetched.diagnostics,
12549
+ auditScope: "status",
12550
+ accessBlock: fetched.diagnostics.accessBlock,
12551
+ blocked: fetched.diagnostics.blocked,
12552
+ contentAuditAllowed: false,
12553
+ robotsTxt: fetched.robotsTxt,
12554
+ indexable: false,
12555
+ indexability: "Not evaluated by status-only health probe",
12556
+ declaredIndexability: "unknown",
12557
+ extractionStatus: "not-applicable",
12558
+ wordCount: 0,
12559
+ contentHash: "",
12560
+ outgoingInternalCount: 0,
12561
+ outgoingExternalCount: 0,
12562
+ isHttps: new URL(fetched.finalUrl).protocol === "https:"
12563
+ };
12564
+ }
11079
12565
  function indexabilityReason(page) {
11080
12566
  if (page.status < 200 || page.status >= 300) return `Status ${page.status}`;
11081
12567
  if (hasMetaRobotsDirective(page.metaRobots, "noindex")) return "Meta robots noindex";
@@ -11129,13 +12615,26 @@ async function crawlOne(url, opts = {}) {
11129
12615
  };
11130
12616
  }
11131
12617
  const contentType = headerValue(fetched.headers, "content-type");
12618
+ if (fetched.diagnostics.accessBlock) {
12619
+ const page = unextractedPage(fetched, contentType);
12620
+ page.contentAuditAllowed = false;
12621
+ page.indexable = false;
12622
+ page.indexability = "Crawler access blocked";
12623
+ page.declaredIndexability = "unknown";
12624
+ page.extractionStatus = "not-applicable";
12625
+ return {
12626
+ request: responseObservation(url, fetched, "not-applicable"),
12627
+ page,
12628
+ urls: []
12629
+ };
12630
+ }
11132
12631
  if (!isHtmlContentType(contentType)) return {
11133
12632
  request: responseObservation(url, fetched, contentType ? "not-applicable" : "unknown-media-type"),
11134
12633
  page: unextractedPage(fetched, contentType),
11135
12634
  urls: []
11136
12635
  };
11137
12636
  try {
11138
- const extracted = await extractPage(fetched);
12637
+ const extracted = await extractPage(fetched, "crawler");
11139
12638
  const base = new URL(extracted.finalUrl);
11140
12639
  const internalLinks = extracted.links.map((link) => sameOriginUrl(link.href, base)).filter((value) => Boolean(value));
11141
12640
  const externalLinks = extracted.links.filter((link) => !link.internal).map((link) => link.href).filter((href) => /^https?:\/\//.test(href));
@@ -11256,6 +12755,62 @@ async function crawlOne(url, opts = {}) {
11256
12755
  };
11257
12756
  }
11258
12757
  }
12758
+ async function crawlStatusOnly(url, opts = {}) {
12759
+ const startedAt = Date.now();
12760
+ try {
12761
+ const fetched = await fetchPageStatus(url, {
12762
+ ...opts,
12763
+ js: "off",
12764
+ refresh: true
12765
+ });
12766
+ return {
12767
+ request: responseObservation(url, fetched, "not-applicable"),
12768
+ page: statusOnlyPage(fetched),
12769
+ urls: []
12770
+ };
12771
+ } catch (error) {
12772
+ if (error instanceof OriginBackpressureError) return {
12773
+ request: {
12774
+ requestedUrl: url,
12775
+ outcome: "skipped",
12776
+ reason: "origin-backpressure",
12777
+ error: error.message,
12778
+ extraction: "not-applicable"
12779
+ },
12780
+ urls: []
12781
+ };
12782
+ if (error instanceof RobotsAccessError) return {
12783
+ request: {
12784
+ requestedUrl: url,
12785
+ outcome: "skipped",
12786
+ reason: error.reason,
12787
+ robotsTxt: {
12788
+ url: error.evidence.url,
12789
+ allowed: error.evidence.allowed,
12790
+ availability: error.evidence.availability,
12791
+ status: error.evidence.status,
12792
+ error: error.evidence.error,
12793
+ matchedLine: error.evidence.matchedLine
12794
+ },
12795
+ extraction: "not-applicable"
12796
+ },
12797
+ urls: []
12798
+ };
12799
+ const message = error instanceof Error ? error.message : String(error);
12800
+ return {
12801
+ request: {
12802
+ requestedUrl: url,
12803
+ outcome: "failure",
12804
+ durationMs: Date.now() - startedAt,
12805
+ failureKind: fetchFailureKind(error, opts.signal),
12806
+ error: message,
12807
+ extraction: "not-applicable"
12808
+ },
12809
+ urls: [],
12810
+ warning: `${url}: ${message}`
12811
+ };
12812
+ }
12813
+ }
11259
12814
  //#endregion
11260
12815
  //#region packages/core/src/analyze/monitoring/sitemaps.ts
11261
12816
  const INVALID_LOC_SAMPLE_LIMIT = 10;
@@ -11498,6 +13053,7 @@ async function fetchSitemapUrls(input) {
11498
13053
  const nestedSitemaps = [];
11499
13054
  const discoveredSitemaps = /* @__PURE__ */ new Set();
11500
13055
  const scheduledSitemaps = /* @__PURE__ */ new Set();
13056
+ const fetchedSitemaps = /* @__PURE__ */ new Set();
11501
13057
  const invalidLocSamples = [];
11502
13058
  const malformedLastmodSamples = [];
11503
13059
  const futureLastmodSamples = [];
@@ -11544,12 +13100,18 @@ async function fetchSitemapUrls(input) {
11544
13100
  const sitemapUrl = queue.shift();
11545
13101
  if (!sitemapUrl) continue;
11546
13102
  const fetched = await fetchSitemapXml(sitemapUrl);
13103
+ fetchedSitemaps.add(sitemapUrl);
11547
13104
  documents.push(fetched.document);
11548
13105
  if (fetched.document.warning) warnings.push(fetched.document.warning);
11549
13106
  if (fetched.document.dataStatus !== "unavailable") sitemapsFetched += 1;
11550
13107
  if (!fetched.xml || !fetched.document.root) continue;
11551
13108
  const $ = load(fetched.xml, { xmlMode: true });
11552
13109
  $(fetched.document.root === "urlset" ? "urlset > url" : "").each((_, element) => {
13110
+ if (urls.length >= limit) {
13111
+ urlLimitExceeded = true;
13112
+ omittedUrlsAtLeast += 1;
13113
+ return false;
13114
+ }
11553
13115
  urlLocs += 1;
11554
13116
  const entry = $(element);
11555
13117
  const value = entry.children("loc").first().text().trim();
@@ -11574,12 +13136,7 @@ async function fetchSitemapUrls(input) {
11574
13136
  return;
11575
13137
  }
11576
13138
  seenUrls.add(url);
11577
- if (urls.length < limit) {
11578
- urls.push(url);
11579
- return;
11580
- }
11581
- urlLimitExceeded = true;
11582
- omittedUrlsAtLeast += 1;
13139
+ urls.push(url);
11583
13140
  });
11584
13141
  $(fetched.document.root === "sitemapindex" ? "sitemapindex > sitemap" : "").each((_, element) => {
11585
13142
  sitemapLocs += 1;
@@ -11612,8 +13169,13 @@ async function fetchSitemapUrls(input) {
11612
13169
  queue.push(child);
11613
13170
  } else nestedSitemapLimitExceeded = true;
11614
13171
  });
13172
+ if (urls.length >= limit && queue.length > 0) {
13173
+ urlLimitExceeded = true;
13174
+ omittedUrlsAtLeast = Math.max(omittedUrlsAtLeast, 1);
13175
+ break;
13176
+ }
11615
13177
  }
11616
- const unprocessedSitemaps = nestedSitemaps.filter((sitemap) => !scheduledSitemaps.has(sitemap)).length;
13178
+ const unprocessedSitemaps = nestedSitemaps.filter((sitemap) => !fetchedSitemaps.has(sitemap)).length;
11617
13179
  const possiblyTruncated = urlLimitExceeded || nestedSitemapLimitExceeded || unprocessedSitemaps > 0;
11618
13180
  if (invalidLocCount) warnings.push(`Ignored ${invalidLocCount} invalid sitemap <loc> ${invalidLocCount === 1 ? "entry" : "entries"}.`);
11619
13181
  if (possiblyTruncated) warnings.push("Sitemap discovery exceeded a configured URL or sitemap boundary; the returned inventory is incomplete.");
@@ -11793,6 +13355,7 @@ function resolveCrawlSiteDependencies(dependencies = {}) {
11793
13355
  const emptyPageTopQuery = async () => void 0;
11794
13356
  return {
11795
13357
  fetchPage: dependencies.fetchPage ?? crawlOne,
13358
+ fetchStatusPage: dependencies.fetchStatusPage ?? crawlStatusOnly,
11796
13359
  fetchSitemapUrls: dependencies.fetchSitemapUrls ?? fetchSitemapUrls,
11797
13360
  fetch: dependencies.fetch ?? publicHttpFetch,
11798
13361
  queryPageMetrics: (injectedMode === "per-page" ? dependencies.queryPageMetrics : void 0) ?? (injectedMode ? emptyPageMetrics : queryPageMetrics),
@@ -11818,6 +13381,10 @@ function isSameOrigin(url, origin) {
11818
13381
  }
11819
13382
  }
11820
13383
  function sitemapRootUrls(input) {
13384
+ if (input.sitemapUrl) return [{
13385
+ url: new URL(input.sitemapUrl).toString(),
13386
+ source: "explicit"
13387
+ }];
11821
13388
  const origin = new URL(input.url).origin;
11822
13389
  const declared = input.declaredSitemapUrls.filter((url) => isSameOrigin(url, origin));
11823
13390
  const ignored = input.declaredSitemapUrls.length - declared.length;
@@ -11868,247 +13435,71 @@ async function sitemapSeeds(input) {
11868
13435
  sitemapUrl: fallbackUrl,
11869
13436
  limit: input.maxPages
11870
13437
  });
11871
- input.warnings.push(...sitemap.warnings);
11872
- for (const url of sitemap.urls) urls.add(url);
11873
- roots.push({
11874
- url: sitemap.sitemapUrl,
11875
- source: "default-path",
11876
- dataStatus: sitemap.source.sitemapsFetched > 0 ? sitemap.dataStatus : "unavailable",
11877
- urlsReturned: sitemap.urls.length,
11878
- sitemapsFetched: sitemap.source.sitemapsFetched,
11879
- lastmods: sitemap.source.lastmods,
11880
- documents: sitemap.source.documents,
11881
- possiblyTruncated: sitemap.truncation.possiblyTruncated,
11882
- warnings: sitemap.warnings
11883
- });
11884
- }
11885
- }
11886
- return {
11887
- urls: [...urls],
11888
- discovery: {
11889
- dataStatus: sitemapDiscoveryStatus(roots),
11890
- urlsReturned: urls.size,
11891
- roots
11892
- }
11893
- };
11894
- }
11895
- //#endregion
11896
- //#region packages/core/src/analyze/crawler/site-crawl.ts
11897
- const AI_BOT_USER_AGENTS = [
11898
- "GPTBot",
11899
- "OAI-SearchBot",
11900
- "ChatGPT-User",
11901
- "ClaudeBot",
11902
- "PerplexityBot",
11903
- "Google-Extended",
11904
- "CCBot",
11905
- "Applebot",
11906
- "Bingbot"
11907
- ];
11908
- const AGENT_RESOURCE_PATHS = [
11909
- "/.well-known/agent.json",
11910
- "/agent.json",
11911
- "/.well-known/mcp.json",
11912
- "/.well-known/ai-plugin.json",
11913
- "/.well-known/openapi.json",
11914
- "/openapi.json"
11915
- ];
11916
- function normalizeUrl$1(value, base) {
11917
- try {
11918
- const url = base ? new URL(value, base) : new URL(value);
11919
- url.hash = "";
11920
- if (!["http:", "https:"].includes(url.protocol)) return void 0;
11921
- return url.toString();
11922
- } catch {
11923
- return;
11924
- }
11925
- }
11926
- function sameOrigin(url, origin) {
11927
- try {
11928
- return new URL(url).origin === origin;
11929
- } catch {
11930
- return false;
11931
- }
11932
- }
11933
- function isLikelyAsset(url) {
11934
- const path = new URL(url).pathname.toLowerCase();
11935
- return /\.(avif|bmp|css|csv|dmg|eot|gif|gz|ico|jpeg|jpg|js|json|map|mov|mp3|mp4|ogg|otf|pdf|png|svg|tar|ttf|wav|webm|webp|woff|woff2|zip)$/.test(path);
11936
- }
11937
- function globMatch(pattern, value) {
11938
- if (!pattern.includes("*")) return value.includes(pattern);
11939
- const parts = pattern.split("*");
11940
- let position = 0;
11941
- for (const part of parts) {
11942
- if (!part) continue;
11943
- const found = value.slice(position).indexOf(part);
11944
- if (found === -1) return false;
11945
- position += found + part.length;
11946
- }
11947
- return true;
11948
- }
11949
- function passesFilters(url, include, exclude) {
11950
- return (!include.length || include.some((pattern) => globMatch(pattern, url))) && !exclude.some((pattern) => globMatch(pattern, url));
11951
- }
11952
- async function checkLlmsTxt(input) {
11953
- const llmsUrl = new URL("/llms.txt", input.url).toString();
11954
- const controller = abortController({
11955
- timeoutMs: input.timeoutMs,
11956
- signal: input.signal
11957
- });
11958
- try {
11959
- const response = await input.fetch(llmsUrl, {
11960
- profile: "bot",
11961
- redirect: "follow",
11962
- signal: controller.signal
11963
- });
11964
- const contentType = response.headers.get("content-type") ?? "";
11965
- const exists = response.status >= 200 && response.status < 300 && !/\btext\/html\b/i.test(contentType);
11966
- await response.body?.cancel().catch(() => void 0);
11967
- return {
11968
- url: response.url || llmsUrl,
11969
- exists,
11970
- status: response.status
11971
- };
11972
- } catch {
11973
- return {
11974
- url: llmsUrl,
11975
- exists: false
11976
- };
11977
- } finally {
11978
- controller.cleanup();
11979
- }
11980
- }
11981
- function parseRobots(robotsUrl, text) {
11982
- return robotsParserModule(robotsUrl, text);
11983
- }
11984
- function declaredUserAgents(text) {
11985
- const declared = /* @__PURE__ */ new Set();
11986
- for (const match of text.matchAll(/^\s*user-agent\s*:\s*(.+?)\s*$/gim)) {
11987
- const value = match[1]?.trim().toLowerCase();
11988
- if (value) declared.add(value);
11989
- }
11990
- return declared;
11991
- }
11992
- function sitemapUrlsFromRobots(text) {
11993
- const urls = /* @__PURE__ */ new Set();
11994
- for (const match of text.matchAll(/^\s*sitemap\s*:\s*(\S+)\s*$/gim)) {
11995
- const value = match[1]?.trim();
11996
- if (!value) continue;
11997
- try {
11998
- urls.add(new URL(value).toString());
11999
- } catch {}
12000
- }
12001
- return [...urls];
12002
- }
12003
- async function checkRobotsAiAccess(input) {
12004
- const robotsUrl = new URL("/robots.txt", input.url).toString();
12005
- const controller = abortController({
12006
- timeoutMs: input.timeoutMs,
12007
- signal: input.signal
12008
- });
12009
- try {
12010
- const response = await input.fetch(robotsUrl, {
12011
- profile: "bot",
12012
- redirect: "follow",
12013
- signal: controller.signal
12014
- });
12015
- const text = await response.text();
12016
- const available = response.status >= 200 && response.status < 300;
12017
- const absent = response.status >= 400 && response.status < 500 && response.status !== 429;
12018
- const availability = available ? "available" : [401, 403].includes(response.status) ? "access-blocked" : response.status === 429 ? "rate-limited" : absent ? "absent" : "unreachable";
12019
- const exists = available;
12020
- const declared = exists ? declaredUserAgents(text) : /* @__PURE__ */ new Set();
12021
- const parsed = parseRobots(robotsUrl, exists ? text : "");
12022
- return {
12023
- url: response.url || robotsUrl,
12024
- exists,
12025
- availability,
12026
- status: response.status,
12027
- ...["rate-limited", "unreachable"].includes(availability) ? { error: `robots.txt returned HTTP ${response.status}.` } : {},
12028
- sitemapUrls: exists ? sitemapUrlsFromRobots(text) : [],
12029
- botAccess: AI_BOT_USER_AGENTS.map((userAgent) => {
12030
- const lower = userAgent.toLowerCase();
12031
- return {
12032
- userAgent,
12033
- allowed: availability === "rate-limited" || availability === "unreachable" ? null : parsed.isAllowed(input.url, userAgent) ?? true,
12034
- declared: declared.has(lower),
12035
- coveredByWildcard: declared.has("*")
12036
- };
12037
- })
12038
- };
12039
- } catch (error) {
12040
- return {
12041
- url: robotsUrl,
12042
- exists: false,
12043
- availability: "unreachable",
12044
- error: error instanceof Error ? error.message : String(error),
12045
- sitemapUrls: [],
12046
- botAccess: AI_BOT_USER_AGENTS.map((userAgent) => ({
12047
- userAgent,
12048
- allowed: null,
12049
- declared: false,
12050
- coveredByWildcard: false
12051
- }))
12052
- };
12053
- } finally {
12054
- controller.cleanup();
13438
+ input.warnings.push(...sitemap.warnings);
13439
+ for (const url of sitemap.urls) urls.add(url);
13440
+ roots.push({
13441
+ url: sitemap.sitemapUrl,
13442
+ source: "default-path",
13443
+ dataStatus: sitemap.source.sitemapsFetched > 0 ? sitemap.dataStatus : "unavailable",
13444
+ urlsReturned: sitemap.urls.length,
13445
+ sitemapsFetched: sitemap.source.sitemapsFetched,
13446
+ lastmods: sitemap.source.lastmods,
13447
+ documents: sitemap.source.documents,
13448
+ possiblyTruncated: sitemap.truncation.possiblyTruncated,
13449
+ warnings: sitemap.warnings
13450
+ });
13451
+ }
12055
13452
  }
13453
+ return {
13454
+ urls: [...urls],
13455
+ discovery: {
13456
+ dataStatus: sitemapDiscoveryStatus(roots),
13457
+ urlsReturned: urls.size,
13458
+ roots
13459
+ }
13460
+ };
12056
13461
  }
12057
- async function checkAgentResource(input) {
12058
- const url = new URL(input.path, input.baseUrl).toString();
12059
- const controller = abortController({
12060
- timeoutMs: input.timeoutMs,
12061
- signal: input.signal
12062
- });
13462
+ //#endregion
13463
+ //#region packages/core/src/analyze/crawler/site-crawl.ts
13464
+ function normalizeUrl$1(value, base) {
12063
13465
  try {
12064
- const response = await input.fetch(url, {
12065
- profile: "bot",
12066
- redirect: "follow",
12067
- signal: controller.signal
12068
- });
12069
- const contentType = response.headers.get("content-type") ?? "";
12070
- const text = await response.text();
12071
- const exists = response.status >= 200 && response.status < 300;
12072
- let validJson;
12073
- if (exists && /\bjson\b/i.test(contentType)) try {
12074
- JSON.parse(text);
12075
- validJson = true;
12076
- } catch {
12077
- validJson = false;
12078
- }
12079
- return {
12080
- url: response.url || url,
12081
- exists,
12082
- status: response.status,
12083
- contentType,
12084
- ...validJson === void 0 ? {} : { validJson }
12085
- };
13466
+ const url = base ? new URL(value, base) : new URL(value);
13467
+ url.hash = "";
13468
+ if (!["http:", "https:"].includes(url.protocol)) return void 0;
13469
+ return url.toString();
12086
13470
  } catch {
12087
- return {
12088
- url,
12089
- exists: false
12090
- };
12091
- } finally {
12092
- controller.cleanup();
13471
+ return;
12093
13472
  }
12094
13473
  }
12095
- async function checkAgentResources(input) {
12096
- const resources = [];
12097
- for (const path of AGENT_RESOURCE_PATHS) {
12098
- if (input.signal?.aborted) break;
12099
- resources.push(await checkAgentResource({
12100
- baseUrl: input.url,
12101
- path,
12102
- timeoutMs: input.timeoutMs,
12103
- fetch: input.fetch,
12104
- signal: input.signal
12105
- }));
13474
+ function sameOrigin(url, origin) {
13475
+ try {
13476
+ return new URL(url).origin === origin;
13477
+ } catch {
13478
+ return false;
12106
13479
  }
12107
- return resources;
13480
+ }
13481
+ function isLikelyAsset(url) {
13482
+ const path = new URL(url).pathname.toLowerCase();
13483
+ return /\.(avif|bmp|css|csv|dmg|eot|gif|gz|ico|jpeg|jpg|js|json|map|mov|mp3|mp4|ogg|otf|pdf|png|svg|tar|ttf|wav|webm|webp|woff|woff2|zip)$/.test(path);
13484
+ }
13485
+ function globMatch(pattern, value) {
13486
+ if (!pattern.includes("*")) return value.includes(pattern);
13487
+ const parts = pattern.split("*");
13488
+ let position = 0;
13489
+ for (const part of parts) {
13490
+ if (!part) continue;
13491
+ const found = value.slice(position).indexOf(part);
13492
+ if (found === -1) return false;
13493
+ position += found + part.length;
13494
+ }
13495
+ return true;
13496
+ }
13497
+ function passesFilters(url, include, exclude) {
13498
+ return (!include.length || include.some((pattern) => globMatch(pattern, url))) && !exclude.some((pattern) => globMatch(pattern, url));
12108
13499
  }
12109
13500
  async function crawlSite(input, dependencies) {
12110
13501
  const deps = resolveCrawlSiteDependencies(dependencies);
12111
- const config = normalizeCrawlConfig(input);
13502
+ const config = assertCrawlConfigLimits(normalizeCrawlConfig(input));
12112
13503
  const { searchMetricsLimit, analyticsLimit } = crawlProviderLimits(input);
12113
13504
  const signal = input.signal;
12114
13505
  let cancelled = Boolean(signal?.aborted);
@@ -12116,17 +13507,19 @@ async function crawlSite(input, dependencies) {
12116
13507
  const site = input.site;
12117
13508
  const origin = new URL(config.url).origin;
12118
13509
  const warnings = [];
12119
- const queue = [];
13510
+ const queue = new CrawlUrlQueue();
12120
13511
  const visited = /* @__PURE__ */ new Set();
12121
- const queued = /* @__PURE__ */ new Set();
12122
13512
  const discovered = /* @__PURE__ */ new Set();
12123
- const linkGraph = {};
13513
+ const incomingLinkCounts = /* @__PURE__ */ new Map();
12124
13514
  const fetchedAliases = /* @__PURE__ */ new Map();
12125
13515
  const pages = [];
12126
13516
  const requests = [];
12127
13517
  const documentIndexes = /* @__PURE__ */ new Map();
12128
13518
  const inFlight = /* @__PURE__ */ new Set();
12129
13519
  const followLinks = config.mode === "site";
13520
+ const healthStrategy = config.strategy === "health";
13521
+ let activeConcurrency = healthStrategy ? 1 : config.concurrency;
13522
+ let healthyProbeStreak = 0;
12130
13523
  let queuedUrls = 0;
12131
13524
  let skippedUrls = 0;
12132
13525
  const skipReasonCounts = {};
@@ -12150,7 +13543,7 @@ async function crawlSite(input, dependencies) {
12150
13543
  generatedAt: deps.now().toISOString(),
12151
13544
  discoveredUrls: discovered.size,
12152
13545
  queuedUrls,
12153
- pendingUrls: queue.length,
13546
+ pendingUrls: queue.size,
12154
13547
  inFlightUrls: inFlight.size,
12155
13548
  crawledUrls: pages.length,
12156
13549
  skippedUrls,
@@ -12164,36 +13557,27 @@ async function crawlSite(input, dependencies) {
12164
13557
  const flushStatusEvents = async () => {
12165
13558
  await statusEventChain;
12166
13559
  };
12167
- const renderer = config.js === "off" ? void 0 : createPageRenderer({ concurrency: config.concurrency });
13560
+ const renderer = config.js === "off" || healthStrategy ? void 0 : createPageRenderer({ concurrency: config.concurrency });
13561
+ const robotsSession = healthStrategy ? createRobotsSession({
13562
+ refresh: true,
13563
+ writeCache: false,
13564
+ timeoutMs: config.timeoutMs,
13565
+ signal
13566
+ }) : void 0;
13567
+ const robotsResolver = robotsSession?.resolve;
12168
13568
  try {
12169
13569
  emitStatus("started", {
12170
13570
  url: config.url,
12171
13571
  message: `Started crawl for ${config.url}.`
12172
13572
  });
12173
- const aiSignals = isCancelled() ? void 0 : await Promise.all([
12174
- checkLlmsTxt({
12175
- url: config.url,
12176
- timeoutMs: config.timeoutMs,
12177
- fetch: deps.fetch,
12178
- signal
12179
- }),
12180
- checkRobotsAiAccess({
12181
- url: config.url,
12182
- timeoutMs: config.timeoutMs,
12183
- fetch: deps.fetch,
12184
- signal
12185
- }),
12186
- checkAgentResources({
12187
- url: config.url,
12188
- timeoutMs: config.timeoutMs,
12189
- fetch: deps.fetch,
12190
- signal
12191
- })
12192
- ]);
12193
- const llmsTxt = aiSignals?.[0] ?? {
12194
- url: new URL("/llms.txt", config.url).toString(),
12195
- exists: false
12196
- };
13573
+ const aiSignals = isCancelled() || healthStrategy ? void 0 : await collectCrawlAiSignals({
13574
+ url: config.url,
13575
+ timeoutMs: config.timeoutMs,
13576
+ fetch: deps.fetch,
13577
+ signal
13578
+ });
13579
+ const llmsTxt = aiSignals?.[0];
13580
+ const healthSitemapUrls = healthStrategy && !config.sitemapUrl && !isCancelled() ? await robotsSession?.sitemapUrls(origin) : void 0;
12197
13581
  const robotsTxt = aiSignals?.[1];
12198
13582
  const agentResources = aiSignals?.[2];
12199
13583
  cancelled = isCancelled();
@@ -12208,12 +13592,9 @@ async function crawlSite(input, dependencies) {
12208
13592
  });
12209
13593
  return;
12210
13594
  }
12211
- discovered.add(normalized);
12212
13595
  if (visited.has(normalized)) return;
12213
- if (queued.has(normalized)) {
12214
- const existing = queue.find((item) => item.url === normalized);
12215
- if (existing && depth < existing.depth) existing.depth = depth;
12216
- queue.sort((left, right) => left.depth - right.depth || (left.url < right.url ? -1 : left.url > right.url ? 1 : 0));
13596
+ if (queue.has(normalized)) {
13597
+ queue.decreaseDepth(normalized, depth);
12217
13598
  return;
12218
13599
  }
12219
13600
  if (!sameOrigin(normalized, origin)) {
@@ -12243,7 +13624,7 @@ async function crawlSite(input, dependencies) {
12243
13624
  });
12244
13625
  return;
12245
13626
  }
12246
- if (queue.length + visited.size + inFlight.size >= config.maxPages * 5) {
13627
+ if (queue.size + visited.size + inFlight.size >= config.maxPages * 5) {
12247
13628
  recordSkip("queue-safety-limit");
12248
13629
  queueSafetySkippedUrls += 1;
12249
13630
  emitStatus("url_skipped", {
@@ -12253,13 +13634,12 @@ async function crawlSite(input, dependencies) {
12253
13634
  });
12254
13635
  return;
12255
13636
  }
12256
- queued.add(normalized);
13637
+ discovered.add(normalized);
12257
13638
  queuedUrls += 1;
12258
13639
  queue.push({
12259
13640
  url: normalized,
12260
13641
  depth
12261
13642
  });
12262
- queue.sort((left, right) => left.depth - right.depth || (left.url < right.url ? -1 : left.url > right.url ? 1 : 0));
12263
13643
  emitStatus("url_queued", {
12264
13644
  url: normalized,
12265
13645
  depth
@@ -12272,8 +13652,9 @@ async function crawlSite(input, dependencies) {
12272
13652
  if (!isCancelled() && config.useSitemap && (config.mode === "site" || config.mode === "sitemap")) {
12273
13653
  const sitemap = await sitemapSeeds({
12274
13654
  url: config.url,
13655
+ sitemapUrl: config.sitemapUrl,
12275
13656
  maxPages: config.maxPages,
12276
- declaredSitemapUrls: robotsTxt?.sitemapUrls ?? [],
13657
+ declaredSitemapUrls: robotsTxt?.sitemapUrls ?? healthSitemapUrls ?? [],
12277
13658
  warnings,
12278
13659
  fetchSitemapUrls: deps.fetchSitemapUrls
12279
13660
  });
@@ -12281,8 +13662,8 @@ async function crawlSite(input, dependencies) {
12281
13662
  sitemapDiscovery = sitemap.discovery;
12282
13663
  }
12283
13664
  const nextQueueItem = () => {
12284
- while (queue.length) {
12285
- const item = queue.shift();
13665
+ while (queue.size) {
13666
+ const item = queue.take();
12286
13667
  if (!item) continue;
12287
13668
  const normalized = normalizeUrl$1(item.url, config.url);
12288
13669
  if (!normalized) continue;
@@ -12296,15 +13677,17 @@ async function crawlSite(input, dependencies) {
12296
13677
  };
12297
13678
  const startTask = (item) => {
12298
13679
  let task;
13680
+ const fetcher = healthStrategy ? deps.fetchStatusPage : deps.fetchPage;
12299
13681
  task = {
12300
13682
  ...item,
12301
- promise: deps.fetchPage(item.url, {
13683
+ promise: fetcher(item.url, {
12302
13684
  js: config.js,
12303
13685
  refresh: config.refresh,
12304
13686
  timeoutMs: config.timeoutMs,
12305
13687
  rate: config.fetchRate,
12306
13688
  signal,
12307
13689
  respectRobots: config.respectRobots,
13690
+ robotsResolver,
12308
13691
  renderer
12309
13692
  }).then((result) => ({
12310
13693
  task,
@@ -12330,8 +13713,8 @@ async function crawlSite(input, dependencies) {
12330
13713
  depth: item.depth
12331
13714
  });
12332
13715
  };
12333
- while (!isCancelled() && (queue.length || inFlight.size) && pages.length < config.maxPages) {
12334
- while (!isCancelled() && inFlight.size < config.concurrency && pages.length + inFlight.size < config.maxPages) {
13716
+ while (!isCancelled() && (queue.size || inFlight.size) && pages.length < config.maxPages) {
13717
+ while (!isCancelled() && inFlight.size < activeConcurrency && pages.length + inFlight.size < config.maxPages) {
12335
13718
  const item = nextQueueItem();
12336
13719
  if (!item) break;
12337
13720
  startTask(item);
@@ -12352,6 +13735,13 @@ async function crawlSite(input, dependencies) {
12352
13735
  extraction: "not-applicable"
12353
13736
  });
12354
13737
  requests.push(request);
13738
+ if (healthStrategy) if (request.outcome === "response" && request.status < 400 && !request.accessBlock) {
13739
+ healthyProbeStreak += 1;
13740
+ activeConcurrency = Math.min(config.concurrency, 1 + Math.floor(healthyProbeStreak / 20));
13741
+ } else {
13742
+ healthyProbeStreak = 0;
13743
+ activeConcurrency = 1;
13744
+ }
12355
13745
  if (request.outcome === "skipped") {
12356
13746
  recordSkip(request.reason === "robots-disallowed" ? "robots-disallowed" : request.reason === "robots-deferred" ? "robots-uncertain" : "origin-backpressure");
12357
13747
  if (request.reason === "robots-deferred") {
@@ -12414,40 +13804,49 @@ async function crawlSite(input, dependencies) {
12414
13804
  };
12415
13805
  const existing = documentIndexes.get(finalUrl);
12416
13806
  if (existing) {
12417
- const links = /* @__PURE__ */ new Set([...existing.links, ...normalizedLinks]);
12418
13807
  const minDepth = Math.min(existing.minDepth, task.depth);
12419
13808
  const replace = !existing.directlyRequested && directlyRequested || existing.directlyRequested === directlyRequested && sourceRequest < existing.sourceRequest;
12420
13809
  if (replace) {
13810
+ const existingPage = pages[existing.index];
13811
+ const previousLinks = existing.pendingAliasLinks ?? (existingPage && existingPage.outgoingInternalCount === existingPage.sampleInternalLinks?.length ? new Set(existingPage.sampleInternalLinks) : void 0);
13812
+ const links = new Set(normalizedLinks);
13813
+ if (previousLinks) {
13814
+ for (const target of previousLinks) {
13815
+ const nextCount = (incomingLinkCounts.get(target) ?? 0) - 1;
13816
+ if (nextCount > 0) incomingLinkCounts.set(target, nextCount);
13817
+ else incomingLinkCounts.delete(target);
13818
+ }
13819
+ observedInternalLinks -= previousLinks.size;
13820
+ }
13821
+ for (const target of links) incomingLinkCounts.set(target, (incomingLinkCounts.get(target) ?? 0) + 1);
13822
+ observedInternalLinks += links.size;
13823
+ syncLinkEvidence(page, links);
12421
13824
  page.crawlDepth = minDepth;
12422
13825
  pages[existing.index] = page;
12423
13826
  } else {
12424
13827
  const existingPage = pages[existing.index];
12425
13828
  if (existingPage) existingPage.crawlDepth = minDepth;
12426
13829
  }
12427
- const retainedPage = pages[existing.index];
12428
- if (retainedPage) syncLinkEvidence(retainedPage, links);
12429
- observedInternalLinks += links.size - existing.links.size;
12430
13830
  documentIndexes.set(finalUrl, {
12431
13831
  ...existing,
12432
13832
  directlyRequested: existing.directlyRequested || directlyRequested,
12433
13833
  sourceRequest: replace ? sourceRequest : existing.sourceRequest,
12434
- links,
13834
+ pendingAliasLinks: void 0,
12435
13835
  minDepth
12436
13836
  });
12437
- linkGraph[finalUrl] = [...links].sort((left, right) => left < right ? -1 : left > right ? 1 : 0);
12438
13837
  } else {
12439
13838
  const links = new Set(normalizedLinks);
12440
13839
  syncLinkEvidence(page, links);
12441
13840
  pages.push(page);
12442
13841
  observedInternalLinks += links.size;
13842
+ for (const target of links) incomingLinkCounts.set(target, (incomingLinkCounts.get(target) ?? 0) + 1);
12443
13843
  documentIndexes.set(finalUrl, {
12444
13844
  index: pages.length - 1,
12445
13845
  directlyRequested,
12446
13846
  sourceRequest,
12447
- links,
13847
+ pendingAliasLinks: !directlyRequested ? links : void 0,
12448
13848
  minDepth: task.depth
12449
13849
  });
12450
- linkGraph[finalUrl] = [...links].sort((left, right) => left < right ? -1 : left > right ? 1 : 0);
12451
13850
  }
12452
13851
  };
12453
13852
  if (config.respectRobots && page.robotsTxt?.allowed === false) {
@@ -12475,27 +13874,16 @@ async function crawlSite(input, dependencies) {
12475
13874
  });
12476
13875
  if (followLinks && task.depth < config.maxDepth) for (const url of result.urls) enqueue(url, task.depth + 1);
12477
13876
  }
12478
- const resolvedLinkGraph = resolveLinkGraphAliases(linkGraph, fetchedAliases);
12479
- const pageIndexes = new Map(pages.map((page, index) => [page.url, index]));
12480
- const depths = pages.map((page) => page.crawlDepth ?? 0);
12481
- for (let pass = 0; pass < pages.length; pass += 1) {
12482
- let changed = false;
12483
- for (const [sourceUrl, targets] of Object.entries(resolvedLinkGraph)) {
12484
- const sourceIndex = pageIndexes.get(sourceUrl);
12485
- if (sourceIndex === void 0) continue;
12486
- for (const target of targets) {
12487
- const targetIndex = pageIndexes.get(target);
12488
- if (targetIndex === void 0) continue;
12489
- const nextDepth = (depths[sourceIndex] ?? 0) + 1;
12490
- if (nextDepth >= (depths[targetIndex] ?? Number.POSITIVE_INFINITY)) continue;
12491
- depths[targetIndex] = nextDepth;
12492
- changed = true;
12493
- }
12494
- }
12495
- if (!changed) break;
13877
+ const resolvedInlinkCounts = resolveLinkCountAliases(incomingLinkCounts, fetchedAliases);
13878
+ const maxInlinks = Math.max(0, ...pages.map((page) => resolvedInlinkCounts.get(page.finalUrl) ?? 0));
13879
+ for (const page of pages) {
13880
+ const internalInlinkCount = resolvedInlinkCounts.get(page.finalUrl) ?? 0;
13881
+ page.internalInlinkCount = internalInlinkCount;
13882
+ page.internalLinkAuthorityScore = maxInlinks ? Math.round(internalInlinkCount / maxInlinks * 100) : 0;
12496
13883
  }
12497
- for (const [index, page] of pages.entries()) page.crawlDepth = depths[index];
12498
13884
  cancelled = isCancelled();
13885
+ const robotsDisallowed = skipReasonCounts["robots-disallowed"] ?? 0;
13886
+ 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.`);
12499
13887
  if (cancelled) {
12500
13888
  warnings.push("Crawl cancelled; returning a partial report.");
12501
13889
  emitStatus("cancelled", {
@@ -12503,15 +13891,17 @@ async function crawlSite(input, dependencies) {
12503
13891
  message: "Crawl cancelled; returning a partial report."
12504
13892
  });
12505
13893
  }
12506
- const pageLimitReached = pages.length >= config.maxPages && (queue.length > 0 || inFlight.size > 0);
13894
+ const pageLimitReached = pages.length >= config.maxPages && (queue.size > 0 || inFlight.size > 0);
12507
13895
  const sitemapEvidencePartial = sitemapDiscovery?.dataStatus === "partial";
12508
- const partial = cancelled || failedUrls > 0 || pageLimitReached || robotsDeferredUrls > 0 || originBackpressureSkippedUrls > 0 || queueSafetySkippedUrls > 0 || queue.length > 0 || inFlight.size > 0 || sitemapEvidencePartial;
12509
- const failed = !cancelled && pages.length === 0 && requests.length > 0 && requests.every((request) => request.outcome === "failure");
13896
+ const accessEvidencePartial = requests.some((request) => request.outcome === "response" && request.accessBlock);
13897
+ const requiredSitemapUnavailable = config.mode === "sitemap" && sitemapDiscovery?.dataStatus === "unavailable";
13898
+ const partial = cancelled || failedUrls > 0 || pageLimitReached || robotsDeferredUrls > 0 || originBackpressureSkippedUrls > 0 || queueSafetySkippedUrls > 0 || queue.size > 0 || inFlight.size > 0 || sitemapEvidencePartial || accessEvidencePartial || requiredSitemapUnavailable;
13899
+ const failed = !cancelled && pages.length === 0 && (requiredSitemapUnavailable || requests.length > 0 && requests.every((request) => request.outcome === "failure"));
12510
13900
  const requestEvidenceStatus = cancelled && inFlight.size > 0 ? "partial" : "available";
12511
13901
  const dataSources = await crawlDataSources({
12512
13902
  cancelled,
12513
- site,
12514
- googleAnalyticsPropertyId: input.googleAnalyticsPropertyId,
13903
+ site: healthStrategy ? void 0 : site,
13904
+ googleAnalyticsPropertyId: healthStrategy ? void 0 : input.googleAnalyticsPropertyId,
12515
13905
  pages,
12516
13906
  warnings,
12517
13907
  searchMetricsLimit,
@@ -12554,7 +13944,7 @@ async function crawlSite(input, dependencies) {
12554
13944
  concurrency: Math.min(config.concurrency, 4)
12555
13945
  }) : void 0;
12556
13946
  for (const page of pages) {
12557
- if (!page.geo) continue;
13947
+ if (!page.geo || !llmsTxt) continue;
12558
13948
  page.geo = {
12559
13949
  ...page.geo,
12560
13950
  hasLlmsTxt: llmsTxt.exists,
@@ -12571,12 +13961,11 @@ async function crawlSite(input, dependencies) {
12571
13961
  pages,
12572
13962
  requests,
12573
13963
  requestEvidenceStatus,
12574
- linkGraph: resolvedLinkGraph,
12575
- ai: {
12576
- llmsTxt,
13964
+ ...!healthStrategy ? { ai: {
13965
+ ...llmsTxt ? { llmsTxt } : {},
12577
13966
  ...robotsTxt ? { robotsTxt } : {},
12578
13967
  ...agentResources ? { agentResources } : {}
12579
- },
13968
+ } } : {},
12580
13969
  ...sitemapDiscovery ? { sitemapDiscovery } : {},
12581
13970
  ...externalLinkVerification ? { externalLinkVerification } : {},
12582
13971
  ...agentDiscovery ? { agentDiscovery } : {},
@@ -12620,7 +14009,7 @@ const REPORT_BASELINE_MAX_AGE_MS = 10080 * 60 * 1e3;
12620
14009
  const defaultDependencies$13 = {
12621
14010
  latestCrawlReport,
12622
14011
  crawlSite,
12623
- saveCrawlReport,
14012
+ saveCrawlReport: (report) => saveCrawlReport(report, crawlReportStore, { retention: "baseline" }),
12624
14013
  now: () => /* @__PURE__ */ new Date()
12625
14014
  };
12626
14015
  function normalizePathname(value) {
@@ -13937,6 +15326,8 @@ function renderCrawlPretty(report, fixes = topFixes(report)) {
13937
15326
  `Crawl report for ${report.config.url}`,
13938
15327
  "",
13939
15328
  `Status: ${report.status}`,
15329
+ `Strategy: ${report.config.strategy === "health" ? "sitemap health pass" : "full audit"}`,
15330
+ `Crawler: ${report.access.crawler.userAgent}`,
13940
15331
  `Documents: ${report.summary.totalPages} retained; ${requestSummary}, ${report.summary.discoveredUrls} URLs discovered, ${report.summary.failedUrls} failed, ${report.summary.skippedUrls} skipped`,
13941
15332
  `Issues: ${report.issues.length} (${report.summary.highIssues} high, ${report.summary.mediumIssues} medium, ${report.summary.lowIssues} low)`
13942
15333
  ];
@@ -13944,6 +15335,14 @@ function renderCrawlPretty(report, fixes = topFixes(report)) {
13944
15335
  const external = report.externalLinkVerification;
13945
15336
  lines.push(`External links: ${external.fetchedUrls} fetched, ${external.failedUrls} request failure${external.failedUrls === 1 ? "" : "s"}, ${external.deferredUrls} deferred (${external.dataStatus})`);
13946
15337
  }
15338
+ if (report.access.blockedRequests > 0) {
15339
+ lines.push("", `Access blocks: ${report.access.blockedRequests}`);
15340
+ for (const sample of report.access.samples.slice(0, 3)) {
15341
+ const requestId = sample.evidence.requestId ? `, request ID ${sample.evidence.requestId}` : "";
15342
+ lines.push(`- ${sample.url}: ${sample.evidence.guidance.summary} (${sample.evidence.provider}${requestId})`, ` Action: ${sample.evidence.guidance.recommendedAction}`, ` Security: ${sample.evidence.guidance.securityNote}`);
15343
+ if (sample.evidence.guidance.documentationUrl) lines.push(` Provider docs: ${sample.evidence.guidance.documentationUrl}`);
15344
+ }
15345
+ }
13947
15346
  if (fixes.length) {
13948
15347
  lines.push("", "Prioritised fixes");
13949
15348
  for (const fix of fixes.slice(0, 5)) {
@@ -13958,12 +15357,108 @@ function renderCrawlPretty(report, fixes = topFixes(report)) {
13958
15357
  if (observation.sampleUrls[0]) lines.push(` First URL: ${observation.sampleUrls[0]}`);
13959
15358
  }
13960
15359
  }
13961
- lines.push("", "Next commands", `- seo crawl ${report.config.url} --json`);
15360
+ lines.push("", "Next commands", report.config.strategy === "health" ? `- seo crawl ${report.config.url} --max-pages ${report.config.maxPages} --json` : `- seo crawl ${report.config.url} --json`);
13962
15361
  for (const command of [...new Set(fixes.slice(0, 3).map((fix) => fix.verification.command))]) lines.push(`- ${command}`);
13963
15362
  if (report.warnings.length) lines.push("", "Warnings", ...report.warnings.map((item) => `- ${item}`));
13964
15363
  if (report.caveats.length) lines.push("", "Caveats", ...report.caveats.map((item) => `- ${item}`));
13965
15364
  return `${lines.join("\n")}\n`;
13966
15365
  }
15366
+ function renderCrawlJunit(report) {
15367
+ const issuesByUrl = /* @__PURE__ */ new Map();
15368
+ for (const issue of report.issues) {
15369
+ const existing = issuesByUrl.get(issue.url) ?? [];
15370
+ existing.push(issue);
15371
+ issuesByUrl.set(issue.url, existing);
15372
+ }
15373
+ const sitemapCases = (report.sitemapDiscovery?.roots ?? []).flatMap((root) => {
15374
+ return (root.documents.length ? root.documents : [{
15375
+ url: root.url,
15376
+ dataStatus: root.dataStatus,
15377
+ compression: "none",
15378
+ warning: root.warnings.join(" ") || void 0
15379
+ }]).map((document) => {
15380
+ const failures = [
15381
+ ...document.dataStatus !== "complete" ? [document.warning ?? `Sitemap evidence is ${document.dataStatus}.`] : [],
15382
+ ...document.redirected ? [`Sitemap redirected to ${document.finalUrl ?? "an unknown URL"}.`] : [],
15383
+ ...document.status !== void 0 && (document.status < 200 || document.status >= 300) ? [`Sitemap returned HTTP ${document.status}.`] : []
15384
+ ];
15385
+ const output = [
15386
+ `dataStatus=${document.dataStatus}`,
15387
+ ...document.status !== void 0 ? [`status=${document.status}`] : [],
15388
+ ...document.finalUrl ? [`finalUrl=${document.finalUrl}`] : [],
15389
+ ...document.root ? [`root=${document.root}`] : [],
15390
+ ...document.warning ? [`warning=${document.warning}`] : []
15391
+ ].join("\n");
15392
+ return {
15393
+ xml: [
15394
+ ` <testcase classname="seo.sitemap-document" name="${escapeXml(document.url)}" time="0.000">`,
15395
+ ...failures.length ? [` <failure message="${escapeXml(failures[0] ?? "Sitemap check failed")}">${escapeXml(failures.join("\n"))}</failure>`] : [],
15396
+ ` <system-out>${escapeXml(output)}</system-out>`,
15397
+ " </testcase>"
15398
+ ].join("\n"),
15399
+ failed: failures.length > 0
15400
+ };
15401
+ });
15402
+ });
15403
+ const requestCases = report.requests.map((request) => {
15404
+ const failures = [
15405
+ ...(issuesByUrl.get(request.requestedUrl) ?? []).map((issue) => `${issue.severity}: ${issue.title}${issue.detail ? `: ${issue.detail}` : ""}`),
15406
+ ...request.outcome === "failure" ? [`${request.failureKind}: ${request.error}`] : [],
15407
+ ...request.outcome === "skipped" ? [request.reason === "origin-backpressure" ? request.error : `${request.reason}: ${request.robotsTxt.matchedLine ?? request.robotsTxt.availability}`] : []
15408
+ ];
15409
+ const output = request.outcome === "response" ? [
15410
+ `status=${request.status}`,
15411
+ `finalUrl=${request.finalUrl}`,
15412
+ ...request.robotsTxt ? [
15413
+ `robotsUrl=${request.robotsTxt.url}`,
15414
+ `robotsAllowed=${request.robotsTxt.allowed ?? "unknown"}`,
15415
+ `robotsAvailability=${request.robotsTxt.availability}`,
15416
+ ...request.robotsTxt.status !== void 0 ? [`robotsStatus=${request.robotsTxt.status}`] : [],
15417
+ ...request.robotsTxt.matchedLine ? [`robotsMatchedLine=${request.robotsTxt.matchedLine}`] : [],
15418
+ ...request.robotsTxt.error ? [`robotsError=${request.robotsTxt.error}`] : []
15419
+ ] : [],
15420
+ ...request.accessBlock ? [
15421
+ `accessProvider=${request.accessBlock.provider}`,
15422
+ `crawler=${request.accessBlock.crawler.userAgent}`,
15423
+ ...request.accessBlock.requestId ? [`requestId=${request.accessBlock.requestId}`] : []
15424
+ ] : []
15425
+ ].join("\n") : request.outcome === "failure" ? request.error : request.reason === "origin-backpressure" ? `reason=${request.reason}\nerror=${request.error}` : [
15426
+ `reason=${request.reason}`,
15427
+ `robotsUrl=${request.robotsTxt.url}`,
15428
+ `robotsAllowed=${request.robotsTxt.allowed ?? "unknown"}`,
15429
+ `robotsAvailability=${request.robotsTxt.availability}`,
15430
+ ...request.robotsTxt.status !== void 0 ? [`robotsStatus=${request.robotsTxt.status}`] : [],
15431
+ ...request.robotsTxt.matchedLine ? [`robotsMatchedLine=${request.robotsTxt.matchedLine}`] : [],
15432
+ ...request.robotsTxt.error ? [`robotsError=${request.robotsTxt.error}`] : []
15433
+ ].join("\n");
15434
+ const duration = ((request.durationMs ?? 0) / 1e3).toFixed(3);
15435
+ return {
15436
+ xml: [
15437
+ ` <testcase classname="seo.sitemap-health" name="${escapeXml(request.requestedUrl)}" time="${duration}">`,
15438
+ ...failures.length ? [` <failure message="${escapeXml(failures[0] ?? "Health check failed")}">${escapeXml(failures.join("\n"))}</failure>`] : [],
15439
+ ` <system-out>${escapeXml(output)}</system-out>`,
15440
+ " </testcase>"
15441
+ ].join("\n"),
15442
+ failed: failures.length > 0
15443
+ };
15444
+ });
15445
+ const cases = [...sitemapCases, ...requestCases];
15446
+ const failures = cases.filter((item) => item.failed).length;
15447
+ const elapsed = report.requests.reduce((sum, request) => sum + (request.durationMs ?? 0), 0);
15448
+ return [
15449
+ "<?xml version=\"1.0\" encoding=\"UTF-8\"?>",
15450
+ `<testsuite name="seo sitemap health" tests="${cases.length}" failures="${failures}" errors="0" skipped="0" time="${(elapsed / 1e3).toFixed(3)}">`,
15451
+ " <properties>",
15452
+ ` <property name="report.id" value="${escapeXml(report.id)}"/>`,
15453
+ ` <property name="report.status" value="${escapeXml(report.status)}"/>`,
15454
+ ` <property name="crawl.strategy" value="${escapeXml(report.config.strategy)}"/>`,
15455
+ ` <property name="crawler.userAgent" value="${escapeXml(report.access.crawler.userAgent)}"/>`,
15456
+ " </properties>",
15457
+ ...cases.map((item) => item.xml),
15458
+ "</testsuite>",
15459
+ ""
15460
+ ].join("\n");
15461
+ }
13967
15462
  function renderCrawlHtml(report, fixes = topFixes(report)) {
13968
15463
  const reviews = reviewObservations(report);
13969
15464
  const issueRows = report.issueGroups.map((group) => {
@@ -13974,6 +15469,7 @@ function renderCrawlHtml(report, fixes = topFixes(report)) {
13974
15469
  const reviewItems = reviews.slice(0, 10).map((observation) => `<li><strong>${escapeHtml(observation.title)}</strong><br>${escapeHtml(observation.howToFix)}<br><span>Check before scheduling work. ${escapeHtml(observation.howToVerify)}</span></li>`).join("\n");
13975
15470
  const caveatItems = report.caveats.map((caveat) => `<li>${escapeHtml(caveat)}</li>`).join("\n");
13976
15471
  const warningItems = report.warnings.map((warning) => `<li>${escapeHtml(warning)}</li>`).join("\n");
15472
+ const accessItems = report.access.samples.map((sample) => `<li><strong>${escapeHtml(sample.url)}</strong><br>${escapeHtml(sample.evidence.guidance.summary)}<br>${escapeHtml(sample.evidence.guidance.recommendedAction)}<br><span>${escapeHtml(sample.evidence.guidance.securityNote)}</span></li>`).join("\n");
13977
15473
  return `<!doctype html>
13978
15474
  <html lang="en">
13979
15475
  <head>
@@ -13996,6 +15492,7 @@ function renderCrawlHtml(report, fixes = topFixes(report)) {
13996
15492
  <body>
13997
15493
  <h1>Crawl report</h1>
13998
15494
  <p>${escapeHtml(report.config.url)}</p>
15495
+ <p>Strategy: ${escapeHtml(report.config.strategy === "health" ? "sitemap health pass" : "full audit")}<br>Crawler: <code>${escapeHtml(report.access.crawler.userAgent)}</code></p>
13999
15496
  <section class="summary">
14000
15497
  <div class="metric"><span>Status</span><strong>${escapeHtml(report.status)}</strong></div>
14001
15498
  <div class="metric"><span>Documents</span><strong>${report.summary.totalPages}</strong></div>
@@ -14008,6 +15505,7 @@ function renderCrawlHtml(report, fixes = topFixes(report)) {
14008
15505
  <div class="metric"><span>Issues</span><strong>${report.issues.length}</strong></div>
14009
15506
  <div class="metric"><span>High</span><strong>${report.summary.highIssues}</strong></div>
14010
15507
  </section>
15508
+ ${accessItems ? `<h2>Access blocks</h2><ul>${accessItems}</ul>` : ""}
14011
15509
  <h2>Prioritised fixes</h2>
14012
15510
  ${fixItems ? `<ol>${fixItems}</ol>` : "<p>No prioritised fixes found.</p>"}
14013
15511
  ${reviewItems ? `<h2>Review observations</h2>
@@ -14034,10 +15532,17 @@ function renderCrawlMarkdownTickets(report, fixes = topFixes(report)) {
14034
15532
  `Source: ${report.config.url}`,
14035
15533
  `Report: ${report.id}`,
14036
15534
  `Status: ${report.status}`,
15535
+ `Strategy: ${report.config.strategy}`,
15536
+ `Crawler: ${report.access.crawler.userAgent}`,
14037
15537
  ""
14038
15538
  ];
14039
15539
  if (report.caveats.length) lines.push("## Caveats", "", ...report.caveats.map((item) => `- ${item}`), "");
14040
15540
  if (report.warnings.length) lines.push("## Warnings", "", ...report.warnings.map((item) => `- ${item}`), "");
15541
+ if (report.access.blockedRequests > 0) lines.push("## Access blocks", "", ...report.access.samples.flatMap((sample) => [
15542
+ `- ${sample.url}: ${sample.evidence.guidance.summary}`,
15543
+ ` - Action: ${sample.evidence.guidance.recommendedAction}`,
15544
+ ` - Security: ${sample.evidence.guidance.securityNote}`
15545
+ ]), "");
14041
15546
  for (const [index, fix] of fixes.entries()) lines.push(`## ${index + 1}. ${fix.title}`, "", `- [ ] Fix ${fix.count} affected URL${fix.count === 1 ? "" : "s"}`, `- Severity: ${fix.severity}`, `- Rule: ${fix.ruleId}`, `- Why this ranks: ${fix.whyThisRanks}`, `- Plain-English fix: ${fix.howToFix}`, `- Verify: ${fix.howToVerify}`, `- Command: ${fix.verification.command}`, "", `Affected URLs:`, ...fix.sampleUrls.slice(0, 10).map((url) => `- ${url}`), "");
14042
15547
  if (!fixes.length) lines.push("No implementation tickets were generated.");
14043
15548
  if (reviews.length) lines.push("## Review observations", "", "These are not implementation tickets. Check the evidence before deciding whether to work on them.", "", ...reviews.slice(0, 10).flatMap((observation) => [`- ${observation.title} (${observation.severity}, ${observation.count} URL${observation.count === 1 ? "" : "s"}): ${observation.howToFix}`, ` - First URL: ${observation.sampleUrls[0] ?? "No sample URL recorded"}`]), "");
@@ -14046,6 +15551,9 @@ function renderCrawlMarkdownTickets(report, fixes = topFixes(report)) {
14046
15551
  function escapeHtml(value) {
14047
15552
  return value.replaceAll("&", "&amp;").replaceAll("<", "&lt;").replaceAll(">", "&gt;").replaceAll("\"", "&quot;").replaceAll("'", "&#39;");
14048
15553
  }
15554
+ function escapeXml(value) {
15555
+ return escapeHtml(value);
15556
+ }
14049
15557
  //#endregion
14050
15558
  //#region packages/core/src/analyze/crawler/history-provenance.ts
14051
15559
  function snapshotCaps(report) {
@@ -14361,6 +15869,14 @@ function compareCrawlReports(input) {
14361
15869
  };
14362
15870
  }
14363
15871
  //#endregion
15872
+ //#region packages/core/src/phrasing.ts
15873
+ function plural(count, singular, pluralLabel = `${singular}s`) {
15874
+ return count === 1 ? singular : pluralLabel;
15875
+ }
15876
+ function countLabel(count, singular, pluralLabel = `${singular}s`) {
15877
+ return `${count.toLocaleString("en-GB")} ${plural(count, singular, pluralLabel)}`;
15878
+ }
15879
+ //#endregion
14364
15880
  //#region packages/core/src/analyze/crawler/okf.ts
14365
15881
  const OKF_DEFAULT_CONCEPTS = 500;
14366
15882
  const OKF_MAX_CONCEPTS = 5e3;
@@ -14727,7 +16243,7 @@ function explainOkfValidation(validation) {
14727
16243
  return {
14728
16244
  title: "OKF bundle",
14729
16245
  valid: validation.valid,
14730
- 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"}.`,
16246
+ summary: validation.valid ? `This bundle passes seo OKF checks with ${countLabel(validation.concepts, "concept file")}.` : `This OKF bundle has ${countLabel(errors.length, "validation error")}.`,
14731
16247
  files: validation.files,
14732
16248
  concepts: validation.concepts,
14733
16249
  errors: errors.length,
@@ -14879,6 +16395,31 @@ const agentDiscoverySchema = z.object({
14879
16395
  missingMarkdownPages: z.number().int().nonnegative(),
14880
16396
  consistent: z.boolean().nullable()
14881
16397
  }),
16398
+ endpointDiscovery: z.object({
16399
+ linkHeader: z.object({
16400
+ url: z.string().url(),
16401
+ status: z.number().int().optional(),
16402
+ entries: z.array(z.object({
16403
+ url: z.string().url(),
16404
+ rel: z.array(z.string()),
16405
+ type: z.string().optional()
16406
+ })),
16407
+ registeredRels: z.array(z.string()),
16408
+ emergingRels: z.array(z.string()),
16409
+ error: z.string().optional()
16410
+ }),
16411
+ endpoints: z.array(z.object({
16412
+ id: z.string(),
16413
+ url: z.string().url(),
16414
+ status: z.number().int().optional(),
16415
+ exists: z.boolean(),
16416
+ contentType: z.string().optional(),
16417
+ validJson: z.boolean().optional(),
16418
+ presentFields: z.array(z.string()).optional(),
16419
+ missingFields: z.array(z.string()).optional(),
16420
+ error: z.string().optional()
16421
+ }))
16422
+ }).optional(),
14882
16423
  protocolVariants: z.object({
14883
16424
  http: z.object({
14884
16425
  url: z.string().url(),
@@ -14950,6 +16491,46 @@ const analyticsSchema = z.object({
14950
16491
  totalUsers: z.number(),
14951
16492
  conversions: z.number()
14952
16493
  });
16494
+ const crawlerIdentitySchema = z.object({
16495
+ name: z.string(),
16496
+ robotsToken: z.string(),
16497
+ version: z.string(),
16498
+ userAgent: z.string(),
16499
+ documentationUrl: z.string().url()
16500
+ });
16501
+ const accessBlockEvidenceSchema = z.object({
16502
+ provider: z.enum(["cloudflare", "unknown"]),
16503
+ kind: z.enum([
16504
+ "challenge",
16505
+ "firewall",
16506
+ "rate-limit",
16507
+ "authentication"
16508
+ ]),
16509
+ status: z.number().int(),
16510
+ crawler: crawlerIdentitySchema,
16511
+ indicators: z.array(z.string()),
16512
+ requestId: z.string().optional(),
16513
+ guidance: z.object({
16514
+ summary: z.string(),
16515
+ recommendedAction: z.string(),
16516
+ securityNote: z.string(),
16517
+ documentationUrl: z.string().url().optional()
16518
+ })
16519
+ });
16520
+ const crawlRobotsEvidenceSchema = z.object({
16521
+ url: z.string().url(),
16522
+ allowed: z.boolean().nullable(),
16523
+ availability: z.enum([
16524
+ "available",
16525
+ "absent",
16526
+ "access-blocked",
16527
+ "rate-limited",
16528
+ "unreachable"
16529
+ ]),
16530
+ status: z.number().int().optional(),
16531
+ error: z.string().optional(),
16532
+ matchedLine: z.string().optional()
16533
+ });
14953
16534
  const geoSignalsSchema = z.object({
14954
16535
  semanticHtml: z.boolean(),
14955
16536
  structuredData: z.boolean(),
@@ -15123,6 +16704,7 @@ const crawlPageSnapshotSchema = z.object({
15123
16704
  consecutiveErrors: z.number().int(),
15124
16705
  recentP95Ms: z.number().optional()
15125
16706
  }).optional(),
16707
+ accessBlock: accessBlockEvidenceSchema.optional(),
15126
16708
  robotsTxt: z.object({
15127
16709
  url: z.string().url(),
15128
16710
  cache: z.enum([
@@ -15205,23 +16787,12 @@ const crawlPageSnapshotSchema = z.object({
15205
16787
  }).optional()
15206
16788
  }).optional(),
15207
16789
  blocked: z.boolean().optional(),
16790
+ auditScope: z.enum(["full", "status"]).optional(),
16791
+ accessBlock: accessBlockEvidenceSchema.optional(),
15208
16792
  contentAuditAllowed: z.boolean().optional(),
15209
16793
  crawlDepth: z.number().int().optional(),
15210
16794
  error: z.string().optional(),
15211
- robotsTxt: z.object({
15212
- url: z.string().url(),
15213
- allowed: z.boolean().nullable(),
15214
- availability: z.enum([
15215
- "available",
15216
- "absent",
15217
- "access-blocked",
15218
- "rate-limited",
15219
- "unreachable"
15220
- ]),
15221
- status: z.number().int().optional(),
15222
- error: z.string().optional(),
15223
- matchedLine: z.string().optional()
15224
- }).optional(),
16795
+ robotsTxt: crawlRobotsEvidenceSchema.optional(),
15225
16796
  title: z.string().optional(),
15226
16797
  metaDescription: z.string().optional(),
15227
16798
  canonical: z.string().url().optional(),
@@ -15276,8 +16847,16 @@ const crawlPageSnapshotSchema = z.object({
15276
16847
  extractionError: z.string().optional(),
15277
16848
  wordCount: z.number().int(),
15278
16849
  contentExtraction: z.object({
15279
- requested: z.enum(["defuddle", "readability"]),
15280
- used: z.enum(["defuddle", "readability"]),
16850
+ requested: z.enum([
16851
+ "crawler",
16852
+ "defuddle",
16853
+ "readability"
16854
+ ]),
16855
+ used: z.enum([
16856
+ "crawler",
16857
+ "defuddle",
16858
+ "readability"
16859
+ ]),
15281
16860
  fallback: z.boolean(),
15282
16861
  fallbackReason: z.enum(["defuddle_error", "defuddle_empty"]).optional(),
15283
16862
  fallbackDetail: z.string().optional(),
@@ -15411,7 +16990,9 @@ const crawlResponseObservationBaseSchema = z.object({
15411
16990
  url: z.string().url(),
15412
16991
  status: z.number().int(),
15413
16992
  location: z.string().url().optional()
15414
- })).optional()
16993
+ })).optional(),
16994
+ accessBlock: accessBlockEvidenceSchema.optional(),
16995
+ robotsTxt: crawlRobotsEvidenceSchema.optional()
15415
16996
  });
15416
16997
  const crawlRequestObservationSchema = z.union([
15417
16998
  crawlResponseObservationBaseSchema.extend({ extraction: z.enum([
@@ -15428,20 +17009,7 @@ const crawlRequestObservationSchema = z.union([
15428
17009
  outcome: z.literal("skipped"),
15429
17010
  durationMs: z.number().optional(),
15430
17011
  reason: z.enum(["robots-disallowed", "robots-deferred"]),
15431
- robotsTxt: z.object({
15432
- url: z.string().url(),
15433
- allowed: z.boolean().nullable(),
15434
- availability: z.enum([
15435
- "available",
15436
- "absent",
15437
- "access-blocked",
15438
- "rate-limited",
15439
- "unreachable"
15440
- ]),
15441
- status: z.number().int().optional(),
15442
- error: z.string().optional(),
15443
- matchedLine: z.string().optional()
15444
- }),
17012
+ robotsTxt: crawlRobotsEvidenceSchema,
15445
17013
  extraction: z.literal("not-applicable")
15446
17014
  }),
15447
17015
  z.object({
@@ -15500,6 +17068,7 @@ const crawlAiSignalsSchema = z.object({
15500
17068
  status: z.number().int().optional(),
15501
17069
  error: z.string().optional(),
15502
17070
  sitemapUrls: z.array(z.string().url()),
17071
+ contentSignals: z.array(z.string()).optional(),
15503
17072
  botAccess: z.array(z.object({
15504
17073
  userAgent: z.string(),
15505
17074
  allowed: z.boolean().nullable(),
@@ -15557,7 +17126,11 @@ const crawlSitemapDiscoverySchema = z.object({
15557
17126
  urlsReturned: z.number().int().nonnegative(),
15558
17127
  roots: z.array(z.object({
15559
17128
  url: z.string().url(),
15560
- source: z.enum(["robots-txt", "default-path"]),
17129
+ source: z.enum([
17130
+ "explicit",
17131
+ "robots-txt",
17132
+ "default-path"
17133
+ ]),
15561
17134
  dataStatus: z.enum([
15562
17135
  "complete",
15563
17136
  "partial",
@@ -15641,6 +17214,8 @@ const crawlConfigSchema = z.object({
15641
17214
  "list",
15642
17215
  "sitemap"
15643
17216
  ]),
17217
+ strategy: z.enum(["full", "health"]),
17218
+ sitemapUrl: z.string().url().optional(),
15644
17219
  urls: z.array(z.string().url()),
15645
17220
  maxPages: z.number().int(),
15646
17221
  maxDepth: z.number().int(),
@@ -15675,6 +17250,7 @@ const crawlConfigSchema = z.object({
15675
17250
  });
15676
17251
  const crawlReportSummarySchema = z.object({
15677
17252
  totalPages: z.number().int(),
17253
+ statusOnlyPages: z.number().int(),
15678
17254
  indexablePages: z.number().int(),
15679
17255
  nonIndexablePages: z.number().int(),
15680
17256
  statusErrors: z.number().int(),
@@ -15765,6 +17341,17 @@ const crawlReportBaseSchema = z.object({
15765
17341
  ]),
15766
17342
  configHash: z.string(),
15767
17343
  config: crawlConfigSchema,
17344
+ access: z.object({
17345
+ crawler: crawlerIdentitySchema,
17346
+ blockedRequests: z.number().int().nonnegative(),
17347
+ providers: z.partialRecord(z.enum(["cloudflare", "unknown"]), z.number().int().nonnegative()),
17348
+ samples: z.array(z.object({
17349
+ url: z.string().url(),
17350
+ evidence: accessBlockEvidenceSchema
17351
+ })),
17352
+ sampleLimit: z.number().int().positive(),
17353
+ truncated: z.boolean()
17354
+ }).optional(),
15768
17355
  summary: crawlReportSummarySchema,
15769
17356
  pages: z.array(crawlPageSnapshotSchema),
15770
17357
  issues: z.array(crawlIssueSchema),
@@ -16080,14 +17667,6 @@ async function ctrUnderperformersReport(input, dependencies = defaultDependencie
16080
17667
  };
16081
17668
  }
16082
17669
  //#endregion
16083
- //#region packages/core/src/phrasing.ts
16084
- function plural(count, singular, pluralLabel = `${singular}s`) {
16085
- return count === 1 ? singular : pluralLabel;
16086
- }
16087
- function countLabel(count, singular, pluralLabel = `${singular}s`) {
16088
- return `${count.toLocaleString("en-GB")} ${plural(count, singular, pluralLabel)}`;
16089
- }
16090
- //#endregion
16091
17670
  //#region packages/core/src/analyze/diagnosis-priorities.ts
16092
17671
  function topDelta(report) {
16093
17672
  const top = report.items[0];
@@ -16114,14 +17693,14 @@ function buildDiagnosisPriorities(input) {
16114
17693
  const topGroup = input.decay.groups[0];
16115
17694
  priorities.push({
16116
17695
  label: "Refresh decaying content",
16117
- 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.`,
17696
+ 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.`,
16118
17697
  action: topGroup ? topGroup.recommendation : "Start with declines that continued outside the update window. Check indexability first, then ranking and CTR causes.",
16119
17698
  confidence: input.decay.dataStatus === "partial" ? "low" : "medium"
16120
17699
  });
16121
17700
  }
16122
17701
  if (input.cannibal.items.length) priorities.push({
16123
17702
  label: "Review multi-URL query candidates",
16124
- reason: `${input.cannibal.selection.eligibleClusters} multi-URL query candidates found.`,
17703
+ reason: `${countLabel(input.cannibal.selection.eligibleClusters, "multi-URL query candidate")} found.`,
16125
17704
  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.",
16126
17705
  confidence: "low"
16127
17706
  });
@@ -16139,7 +17718,7 @@ function buildDiagnosisPriorities(input) {
16139
17718
  const top = input.striking.items[0];
16140
17719
  priorities.push({
16141
17720
  label: "Investigate striking-distance candidates",
16142
- reason: `${input.striking.items.length} query/page rows have an average GSC position above 10 and at most 20.`,
17721
+ 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.`,
16143
17722
  action: top?.recommendation.action ?? "Check technical state, query coverage, competing URLs, and relevant internal links before choosing a change.",
16144
17723
  confidence: input.striking.source.possiblyTruncated ? "low" : top?.recommendation.confidence ?? "low"
16145
17724
  });
@@ -17972,8 +19551,7 @@ function analyzeQuickWinsFromRows(input) {
17972
19551
  selected.selection.belowMinimumRows++;
17973
19552
  continue;
17974
19553
  }
17975
- const targetUrl = row.keys[1] ?? "";
17976
- const excludedTargetRows = selected.rows.filter((candidate) => samePageUrl(candidate.keys[1] ?? "", targetUrl)).length;
19554
+ const excludedTargetRows = benchmarkContext.urlRowCount(row);
17977
19555
  const benchmark = benchmarkContext.forUrl(row);
17978
19556
  if (row.ctr >= benchmark.ctr) {
17979
19557
  selected.selection.atOrAboveTargetRows++;
@@ -18167,7 +19745,7 @@ async function quickWinsReport(input, dependencies = defaultDependencies$8) {
18167
19745
  brandTerms: input.brandTerms,
18168
19746
  includeBrand: input.includeBrand
18169
19747
  });
18170
- const verificationRequested = input.verifyContent === true || input.verifyLimit !== void 0;
19748
+ const verificationRequested = input.verifyContent === true || input.verifyContent !== false && input.verifyLimit !== void 0;
18171
19749
  const verificationItems = verificationRequested ? analysis.items.slice(0, verifyLimit) : [];
18172
19750
  const fetches = /* @__PURE__ */ new Map();
18173
19751
  const cachedFetch = (url, options) => {
@@ -18266,7 +19844,7 @@ async function quickWinsReport(input, dependencies = defaultDependencies$8) {
18266
19844
  repeatedQueryGroups: groups.length,
18267
19845
  templatePatterns: templateActions.length,
18268
19846
  brandFiltering: input.includeBrand ? "included" : "excluded",
18269
- 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.`
19847
+ 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.`
18270
19848
  },
18271
19849
  caveats: [
18272
19850
  `Date window: ${range.startDate} to ${range.endDate}, using final GSC data where available.`,
@@ -18557,7 +20135,7 @@ function templatesFromGroups(groups) {
18557
20135
  function verdict$1(analysis) {
18558
20136
  if (analysis.dataStatus === "empty") return "Search Console returned no retained query/page rows for this date window.";
18559
20137
  if (analysis.dataStatus === "filtered") return "No retained query/page rows met the report filters.";
18560
- return `${analysis.summary.eligibleRows} eligible query/page rows found; ${analysis.summary.returnedRows} returned for review.`;
20138
+ return `${countLabel(analysis.summary.eligibleRows, "eligible query/page row")} found; ${countLabel(analysis.summary.returnedRows, "row")} returned for review.`;
18561
20139
  }
18562
20140
  async function strikingDistance(input, dependencies = defaultDependencies$7) {
18563
20141
  const days = reportDays(input.days);
@@ -18579,7 +20157,7 @@ async function strikingDistance(input, dependencies = defaultDependencies$7) {
18579
20157
  includeBrand: input.includeBrand
18580
20158
  });
18581
20159
  const verifyLimit = boundedInteger$2(input.verifyLimit, DEFAULT_VERIFY_LIMIT, 0, analysis.limit);
18582
- const verificationRequested = input.verifyContent === true || input.verifyLimit !== void 0;
20160
+ const verificationRequested = input.verifyContent === true || input.verifyContent !== false && input.verifyLimit !== void 0;
18583
20161
  const attempted = verificationRequested ? Math.min(verifyLimit, analysis.items.length) : 0;
18584
20162
  const items = await Promise.all(analysis.items.map(async (item, index) => {
18585
20163
  if (index >= attempted) return item;
@@ -21157,6 +22735,7 @@ function topCrawlRecommendations(items) {
21157
22735
  }
21158
22736
  //#endregion
21159
22737
  //#region packages/core/src/analyze/monitoring/crawl-store.ts
22738
+ const CRAWL_RUN_RETENTION = 20;
21160
22739
  function toRun(row) {
21161
22740
  return {
21162
22741
  id: row.id,
@@ -21228,34 +22807,52 @@ function insertCrawlRun(run, pages, recommendations = []) {
21228
22807
  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));
21229
22808
  for (const recommendation of recommendations) insertRecommendation.run(run.id, run.site, recommendation.url, recommendation.severity, recommendation.category, recommendation.title, recommendation.action, recommendation.confidence, createdAt);
21230
22809
  })();
22810
+ db.prepare(`DELETE FROM crawl_runs
22811
+ WHERE site_url = ? AND start_url = ?
22812
+ AND id NOT IN (
22813
+ SELECT id FROM crawl_runs
22814
+ WHERE site_url = ? AND start_url = ?
22815
+ ORDER BY created_at DESC, id DESC
22816
+ LIMIT ?
22817
+ )`).run(run.site, run.startUrl, run.site, run.startUrl, 20);
21231
22818
  }
21232
22819
  function latestCrawlSummaries(site, limit = 5) {
21233
- return getDb().prepare(`WITH page_counts AS (
22820
+ return getDb().prepare(`WITH recent_runs AS (
22821
+ SELECT *
22822
+ FROM crawl_runs
22823
+ WHERE site_url = ?
22824
+ ORDER BY created_at DESC
22825
+ LIMIT ?
22826
+ ),
22827
+ page_counts AS (
21234
22828
  SELECT
21235
- run_id,
21236
- COALESCE(SUM(CASE WHEN status >= 400 THEN 1 ELSE 0 END), 0) AS status_errors,
21237
- COALESCE(SUM(CASE WHEN indexable = 0 THEN 1 ELSE 0 END), 0) AS non_indexable
22829
+ crawl_pages.run_id,
22830
+ COALESCE(SUM(CASE WHEN crawl_pages.status >= 400 THEN 1 ELSE 0 END), 0) AS status_errors,
22831
+ COALESCE(SUM(CASE WHEN crawl_pages.indexable = 0 THEN 1 ELSE 0 END), 0) AS non_indexable
21238
22832
  FROM crawl_pages
21239
- GROUP BY run_id
22833
+ INNER JOIN recent_runs ON recent_runs.id = crawl_pages.run_id
22834
+ GROUP BY crawl_pages.run_id
21240
22835
  ),
21241
22836
  recommendation_counts AS (
21242
22837
  SELECT
21243
- run_id,
22838
+ crawl_recommendations.run_id,
21244
22839
  COUNT(*) AS recommendation_count,
21245
- COALESCE(SUM(CASE WHEN severity = 'high' THEN 1 ELSE 0 END), 0) AS high_recommendation_count
22840
+ COALESCE(SUM(CASE WHEN crawl_recommendations.severity = 'high' THEN 1 ELSE 0 END), 0) AS high_recommendation_count
21246
22841
  FROM crawl_recommendations
21247
- GROUP BY run_id
22842
+ INNER JOIN recent_runs ON recent_runs.id = crawl_recommendations.run_id
22843
+ GROUP BY crawl_recommendations.run_id
21248
22844
  ),
21249
22845
  recommendation_rank AS (
21250
22846
  SELECT
21251
22847
  crawl_recommendations.*,
21252
22848
  ROW_NUMBER() OVER (
21253
- PARTITION BY run_id
22849
+ PARTITION BY crawl_recommendations.run_id
21254
22850
  ORDER BY
21255
- CASE severity WHEN 'high' THEN 3 WHEN 'medium' THEN 2 ELSE 1 END DESC,
21256
- created_at DESC
22851
+ CASE crawl_recommendations.severity WHEN 'high' THEN 3 WHEN 'medium' THEN 2 ELSE 1 END DESC,
22852
+ crawl_recommendations.created_at DESC
21257
22853
  ) AS rank
21258
22854
  FROM crawl_recommendations
22855
+ INNER JOIN recent_runs ON recent_runs.id = crawl_recommendations.run_id
21259
22856
  )
21260
22857
  SELECT
21261
22858
  crawl_runs.*,
@@ -21267,14 +22864,12 @@ function latestCrawlSummaries(site, limit = 5) {
21267
22864
  top.title AS top_recommendation_title,
21268
22865
  top.action AS top_recommendation_action,
21269
22866
  top.severity AS top_recommendation_severity
21270
- FROM crawl_runs
22867
+ FROM recent_runs AS crawl_runs
21271
22868
  LEFT JOIN page_counts ON page_counts.run_id = crawl_runs.id
21272
22869
  LEFT JOIN recommendation_counts ON recommendation_counts.run_id = crawl_runs.id
21273
22870
  LEFT JOIN recommendation_rank top ON top.run_id = crawl_runs.id AND top.rank = 1
21274
- WHERE crawl_runs.site_url = ?
21275
22871
  GROUP BY crawl_runs.id
21276
- ORDER BY crawl_runs.created_at DESC
21277
- LIMIT ?`).all(site, limit).map((row) => ({
22872
+ ORDER BY crawl_runs.created_at DESC`).all(site, limit).map((row) => ({
21278
22873
  ...toRun(row),
21279
22874
  statusErrors: row.status_errors,
21280
22875
  nonIndexable: row.non_indexable,
@@ -22336,6 +23931,7 @@ function indexWatchFailureItem(input) {
22336
23931
  //#endregion
22337
23932
  //#region packages/core/src/analyze/monitoring/index-watch.ts
22338
23933
  const MAX_DIRECT_INSPECTIONS = 100;
23934
+ const INDEX_WATCH_ATTEMPT_RETENTION = 20;
22339
23935
  function previousFromRow(row) {
22340
23936
  if (!row.inspected_at) return void 0;
22341
23937
  return {
@@ -22350,6 +23946,36 @@ function previousFromRow(row) {
22350
23946
  lastCrawlTime: row.last_crawl_time ?? void 0
22351
23947
  };
22352
23948
  }
23949
+ function pruneIndexWatchSnapshots(rootSite, url, database = getDb()) {
23950
+ return database.prepare(`WITH attempts AS (
23951
+ SELECT id,
23952
+ ROW_NUMBER() OVER (
23953
+ PARTITION BY root_site_url, url
23954
+ ORDER BY inspected_at DESC, id DESC
23955
+ ) AS attempt_rank
23956
+ FROM index_watch_snapshots
23957
+ WHERE root_site_url = ? AND url = ?
23958
+ ), latest_success AS (
23959
+ SELECT id FROM (
23960
+ SELECT id,
23961
+ ROW_NUMBER() OVER (
23962
+ ORDER BY inspected_at DESC, id DESC
23963
+ ) AS success_rank
23964
+ FROM index_watch_snapshots
23965
+ WHERE root_site_url = ? AND url = ?
23966
+ AND inspection_status = 'succeeded'
23967
+ AND error_code IS NULL
23968
+ )
23969
+ WHERE success_rank = 1
23970
+ )
23971
+ DELETE FROM index_watch_snapshots
23972
+ WHERE id IN (
23973
+ SELECT attempts.id
23974
+ FROM attempts
23975
+ LEFT JOIN latest_success ON latest_success.id = attempts.id
23976
+ WHERE attempts.attempt_rank > ? AND latest_success.id IS NULL
23977
+ )`).run(rootSite, url, rootSite, url, 20).changes;
23978
+ }
22353
23979
  const defaultDependencies$2 = {
22354
23980
  inspectUrl,
22355
23981
  now: () => /* @__PURE__ */ new Date(),
@@ -22363,12 +23989,14 @@ const defaultDependencies$2 = {
22363
23989
  return row ? previousFromRow(row) : void 0;
22364
23990
  },
22365
23991
  insert(item) {
22366
- getDb().prepare(`INSERT INTO index_watch_snapshots
23992
+ const db = getDb();
23993
+ db.prepare(`INSERT INTO index_watch_snapshots
22367
23994
  (id, site_url, root_site_url, property_site_url, url, verdict,
22368
23995
  coverage_state, indexing_state, robots_txt_state, page_fetch_state,
22369
23996
  google_canonical, user_canonical, last_crawl_time, inspection_status,
22370
23997
  error_code, error_message, inspected_at)
22371
23998
  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));
23999
+ pruneIndexWatchSnapshots(item.rootSite, item.url, db);
22372
24000
  }
22373
24001
  }
22374
24002
  };
@@ -22912,6 +24540,7 @@ async function indexMonitor(input) {
22912
24540
  }
22913
24541
  //#endregion
22914
24542
  //#region packages/core/src/analyze/monitoring/link-recover-store.ts
24543
+ const LINK_RECOVER_RUN_RETENTION = 20;
22915
24544
  function toSummary(row) {
22916
24545
  return {
22917
24546
  id: row.id,
@@ -22954,6 +24583,14 @@ function insertLinkRecoverRun(report) {
22954
24583
  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);
22955
24584
  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);
22956
24585
  })();
24586
+ db.prepare(`DELETE FROM link_recover_runs
24587
+ WHERE site_url = ?
24588
+ AND id NOT IN (
24589
+ SELECT id FROM link_recover_runs
24590
+ WHERE site_url = ?
24591
+ ORDER BY created_at DESC, id DESC
24592
+ LIMIT ?
24593
+ )`).run(report.site, report.site, 20);
22957
24594
  return id;
22958
24595
  }
22959
24596
  function latestLinkRecoverSummary(site) {
@@ -23868,9 +25505,11 @@ function cacheSet(report, now) {
23868
25505
  ...report,
23869
25506
  raw: void 0
23870
25507
  };
25508
+ const reportJson = JSON.stringify(stored);
23871
25509
  getDb().prepare(`INSERT OR REPLACE INTO performance_reports
23872
25510
  (id, url, strategy, report_json, created_at, expires_at)
23873
- VALUES (?, ?, ?, ?, ?, ?)`).run(report.id, report.url, report.strategy, JSON.stringify(stored), Date.parse(report.generatedAt), now + CACHE_TTL_MS);
25511
+ VALUES (?, ?, ?, ?, ?, ?)`).run(report.id, report.url, report.strategy, reportJson, Date.parse(report.generatedAt), now + CACHE_TTL_MS);
25512
+ noteCacheWrite(Buffer.byteLength(reportJson));
23874
25513
  }
23875
25514
  async function fallbackReport(input) {
23876
25515
  const fetched = await fetchPage(input.url, {
@@ -25419,7 +27058,7 @@ function clusterRecommendation(input) {
25419
27058
  return `This ${clusterName} cluster already gets clicks. Use it to refine page structure, FAQs, and internal anchors rather than creating duplicate pages.`;
25420
27059
  }
25421
27060
  function clusterSummary(input) {
25422
- return `${input.queries} queries, ${input.impressions.toFixed(0)} impressions, ${input.clicks.toFixed(0)} clicks, average position ${input.position.toFixed(1)}.`;
27061
+ return `${countLabel(input.queries, "query", "queries")}, ${countLabel(input.impressions, "impression")}, ${countLabel(input.clicks, "click")}, average position ${input.position.toFixed(1)}.`;
25423
27062
  }
25424
27063
  function clusterLabel(rows) {
25425
27064
  return [...rows].sort((a, b) => b.impressions - a.impressions || compareQueryClusterText(a.query, b.query))[0]?.query ?? "";
@@ -25892,7 +27531,7 @@ function contentOpportunityBullets(report) {
25892
27531
  if (report.cannibalization.selection.suppressedQueries) bullets.push(cannibalSuppressionLine(report));
25893
27532
  if (report.summary.strikingDistanceItems) {
25894
27533
  const top = report.strikingDistance.items[0];
25895
- bullets.push(top ? `${report.summary.strikingDistanceItems} query/page ${plural$1(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$1(report.summary.strikingDistanceItems, "candidate")} have an average GSC position above 10 and at most 20.`);
27534
+ bullets.push(top ? `${countLabel(report.summary.strikingDistanceItems, "query/page candidate")} ${plural$1(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$1(report.summary.strikingDistanceItems, "has", "have")} an average GSC position above 10 and at most 20.`);
25896
27535
  }
25897
27536
  if (!skippedReason(report, "quick-win opportunities") && report.summary.quickWinItems) {
25898
27537
  const top = report.quickWins.items[0];
@@ -26373,10 +28012,10 @@ function applyVerification(item, coverage) {
26373
28012
  function verdict(report) {
26374
28013
  if (report.dataStatus === "empty") return "Google Search Console returned no retained query/page rows for this window.";
26375
28014
  if (report.dataStatus === "filtered") return "No pages met the position, brand, and minimum-impression criteria.";
26376
- 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.`;
26377
- 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.`;
26378
- if (report.content > 0) return `${report.eligible} eligible average-position pages found; ${report.content} returned pages have verified content or search-framing gaps.`;
26379
- return `${report.eligible} eligible average-position pages found; ${report.returned} are returned in priority order for investigation.`;
28015
+ if (report.technical > 0) return `${countLabel(report.eligible, "eligible average-position page")} found; ${countLabel(report.technical, "returned page")} ${plural(report.technical, "has", "have")} verified technical issues to fix before content changes.`;
28016
+ if (report.failed > 0) return `${countLabel(report.eligible, "eligible average-position page")} found; ${countLabel(report.failed, "returned page")} could not be verified and ${plural(report.failed, "needs", "need")} a fetch check before content changes.`;
28017
+ if (report.content > 0) return `${countLabel(report.eligible, "eligible average-position page")} found; ${countLabel(report.content, "returned page")} ${plural(report.content, "has", "have")} verified content or search-framing gaps.`;
28018
+ return `${countLabel(report.eligible, "eligible average-position page")} found; ${countLabel(report.returned, "page")} ${plural(report.returned, "is", "are")} returned in priority order for investigation.`;
26380
28019
  }
26381
28020
  function reportRecommendations(items) {
26382
28021
  const first = items[0];
@@ -26423,7 +28062,7 @@ async function secondPage(input, dependencies = defaultDependencies) {
26423
28062
  });
26424
28063
  const generatedAt = now.toISOString();
26425
28064
  const warnings = [];
26426
- const verificationRequested = input.verifyContent === true || input.verifyLimit !== void 0;
28065
+ const verificationRequested = input.verifyContent === true || input.verifyContent !== false && input.verifyLimit !== void 0;
26427
28066
  const verificationItems = verificationRequested ? analysis.items.slice(0, verifyLimit) : [];
26428
28067
  for (const item of verificationItems) {
26429
28068
  const coverage = await verifyQueryContent({
@@ -27171,7 +28810,7 @@ async function refreshPrioritiesWorkflow(input) {
27171
28810
  const diagnosis = await diagnoseProperty({
27172
28811
  ...input,
27173
28812
  verifyContent: input.verifyContent ?? true,
27174
- verifyLimit: input.verifyLimit ?? Math.min(input.limit ?? 5, 5)
28813
+ verifyLimit: input.verifyContent === false ? void 0 : input.verifyLimit ?? Math.min(input.limit ?? 5, 5)
27175
28814
  });
27176
28815
  const range = defaultDateRange$1(input.days ?? 28);
27177
28816
  const analytics = await fetchLandingPageValues({
@@ -28265,11 +29904,76 @@ function ga4MatchReason(match, site) {
28265
29904
  return `Matched Google Analytics web stream ${stream} (${match.defaultUri}): its hostname ${location} ${site}.`;
28266
29905
  }
28267
29906
  //#endregion
29907
+ //#region packages/core/src/storage/log-retention.ts
29908
+ const MEBIBYTE = 1024 * 1024;
29909
+ const LOG_MAX_FILE_BYTES = 8 * MEBIBYTE;
29910
+ const LOG_MAX_TOTAL_BYTES = 64 * MEBIBYTE;
29911
+ const LOG_MAX_AGE_MS = 336 * 60 * 60 * 1e3;
29912
+ function logFiles(directory) {
29913
+ if (!existsSync(directory)) return [];
29914
+ return readdirSync(directory, { withFileTypes: true }).filter((entry) => entry.isFile() && entry.name.endsWith(".log")).map((entry) => {
29915
+ const path = join(directory, entry.name);
29916
+ const stats = statSync(path);
29917
+ return {
29918
+ path,
29919
+ size: stats.size,
29920
+ modifiedAt: stats.mtimeMs
29921
+ };
29922
+ });
29923
+ }
29924
+ function rotatedPath(path, now) {
29925
+ const directory = dirname(path);
29926
+ const stem = basename(path, ".log");
29927
+ let suffix = 0;
29928
+ while (true) {
29929
+ const candidate = join(directory, `${stem}.${now}${suffix ? `-${suffix}` : ""}.log`);
29930
+ if (!existsSync(candidate)) return candidate;
29931
+ suffix += 1;
29932
+ }
29933
+ }
29934
+ function pruneLogs(options = {}) {
29935
+ const directory = options.directory ?? getSeoCliPaths().logDir;
29936
+ const now = options.now ?? Date.now();
29937
+ const maxFileBytes = options.maxFileBytes ?? 8388608;
29938
+ const maxTotalBytes = options.maxTotalBytes ?? 67108864;
29939
+ const maxAgeMs = options.maxAgeMs ?? 12096e5;
29940
+ mkdirSync(directory, {
29941
+ recursive: true,
29942
+ mode: 448
29943
+ });
29944
+ let rotated = 0;
29945
+ let removed = 0;
29946
+ for (const file of logFiles(directory)) {
29947
+ if (file.size <= maxFileBytes) continue;
29948
+ renameSync(file.path, rotatedPath(file.path, now));
29949
+ rotated += 1;
29950
+ }
29951
+ for (const file of logFiles(directory)) {
29952
+ if (file.modifiedAt >= now - maxAgeMs) continue;
29953
+ rmSync(file.path, { force: true });
29954
+ removed += 1;
29955
+ }
29956
+ const retained = logFiles(directory).sort((left, right) => left.modifiedAt - right.modifiedAt || (left.path < right.path ? -1 : left.path > right.path ? 1 : 0));
29957
+ let sizeBytes = retained.reduce((total, file) => total + file.size, 0);
29958
+ for (const file of retained) {
29959
+ if (sizeBytes <= maxTotalBytes) break;
29960
+ rmSync(file.path, { force: true });
29961
+ sizeBytes -= file.size;
29962
+ removed += 1;
29963
+ }
29964
+ return {
29965
+ rotated,
29966
+ removed,
29967
+ sizeBytes
29968
+ };
29969
+ }
29970
+ //#endregion
28268
29971
  //#region packages/core/src/logger.ts
28269
29972
  let loggerInstance;
28270
29973
  function createLogger(runId) {
28271
29974
  if (loggerInstance) return loggerInstance.child({ runId });
28272
29975
  const paths = ensureSeoCliDirs();
29976
+ pruneLogs({ directory: paths.logDir });
28273
29977
  const date = (/* @__PURE__ */ new Date()).toISOString().slice(0, 10);
28274
29978
  const fileStream = createWriteStream(join(paths.logDir, `${date}.log`), { flags: "a" });
28275
29979
  const destination = process.stderr.isTTY ? pretty({
@@ -28337,6 +30041,7 @@ function parseSemicolonCsv(text) {
28337
30041
  //#endregion
28338
30042
  //#region packages/core/src/providers/semrush/cache.ts
28339
30043
  const BASE_URL = "https://api.semrush.com/";
30044
+ const MAX_RESPONSE_BYTES = 10 * 1024 * 1024;
28340
30045
  function estimateUsd(units) {
28341
30046
  return units / 1e3 * .05;
28342
30047
  }
@@ -28366,14 +30071,17 @@ async function cachedSemrushCall(endpoint, params, map, ttlMs, creditsPerLine, r
28366
30071
  const url = new URL(BASE_URL);
28367
30072
  url.search = new URLSearchParams(requestParams).toString();
28368
30073
  const response = await fetch$1(url);
28369
- const text = await response.text();
30074
+ const text = await readBoundedResponseText(response, MAX_RESPONSE_BYTES, "Semrush response");
28370
30075
  if (!response.ok || text.startsWith("ERROR ::")) throw new Error(text || `Semrush request failed with ${response.status}.`);
28371
30076
  const rows = parseSemicolonCsv(text);
28372
30077
  const data = map(rows);
28373
30078
  const credits = Math.max(0, rows.length - 1) * creditsPerLine;
30079
+ const requestJson = JSON.stringify(requestParams);
30080
+ const responseJson = JSON.stringify(data);
28374
30081
  db.prepare(`INSERT OR REPLACE INTO semrush_cache
28375
30082
  (endpoint, query_hash, request_json, response_json, credits_used, fetched_at, expires_at)
28376
- VALUES (?, ?, ?, ?, ?, ?, ?)`).run(endpoint, queryHash, JSON.stringify(requestParams), JSON.stringify(data), credits, Date.now(), Date.now() + ttlMs);
30083
+ VALUES (?, ?, ?, ?, ?, ?, ?)`).run(endpoint, queryHash, requestJson, responseJson, credits, Date.now(), Date.now() + ttlMs);
30084
+ noteCacheWrite(Buffer.byteLength(requestJson) + Buffer.byteLength(responseJson));
28377
30085
  return {
28378
30086
  data,
28379
30087
  usage: {
@@ -28570,9 +30278,6 @@ var JsonFileStorageAdapter = class {
28570
30278
  };
28571
30279
  const localCredentialsProvider = new LocalCredentialsProvider();
28572
30280
  //#endregion
28573
- //#region packages/core/src/version.ts
28574
- const SEO_VERSION = version;
28575
- //#endregion
28576
30281
  //#region packages/core/src/telemetry.ts
28577
30282
  const TELEMETRY_ENDPOINT = "https://seoskill.dev/api/t";
28578
30283
  const TELEMETRY_SCHEMA_VERSION = 1;
@@ -28969,6 +30674,6 @@ function isoDate(daysAgo = 0) {
28969
30674
  return date.toISOString().slice(0, 10);
28970
30675
  }
28971
30676
  //#endregion
28972
- export { AI_REFERRAL_SOURCES, AI_REFERRAL_SOURCES_VERSION, AI_SEARCH_SCORECARD_METHODOLOGY_ID, AI_SEARCH_SCORECARD_METHODOLOGY_VERSION, AUDIT_PAGE_RULE_IDS, BROWSER_USER_AGENT, CANNIBAL_MAXIMUM_DOMINANT_SHARE, CANNIBAL_MINIMUM_PAGE_SHARE, CRAWL_REPORT_STORAGE_VERSION, CRAWL_SKIP_REASONS, CTR_DEFAULT_LIMIT, CTR_DEFAULT_MIN_IMPRESSIONS, CTR_MAX_LIMIT, CTR_MAX_MIN_IMPRESSIONS, DataForSeoProvider, GSC_FINAL_DATA_LAG_DAYS, GSC_TIME_ZONE, INTERNAL_LINK_LEXICAL_TARGET_LIMIT, INTERNAL_LINK_MATCH_EVIDENCE_LIMIT, JsonFileStorageAdapter, LocalCredentialsProvider, OKF_DEFAULT_CONCEPTS, OKF_MAX_CONCEPTS, REPORT_BASELINE_MAX_AGE_MS, REPORT_BASELINE_MAX_DEPTH, REPORT_BASELINE_MAX_PAGES, SCORECARD_CHECK_WEIGHTS, SCORECARD_STATUS_CREDIT, SEO_VERSION, SERP_TITLE_REFERENCE_WIDTH_PX, SERP_TITLE_WIDTH_PROFILE, SemrushProvider, SeoError, ServiceAccountAccessTokenClient, SessionLedger, TELEMETRY_AGENTS, TELEMETRY_ENDPOINT, TELEMETRY_ERROR_CATEGORIES, TELEMETRY_EVENTS, TELEMETRY_NOTICE, TELEMETRY_REPORTS, TELEMETRY_SCHEMA_VERSION, TELEMETRY_TIMEOUT_MS, affectedUrls, agentReadiness, aiPromptsForQuery, aiReadiness, aiReferralSourceForValue, aiReferralsReport, aiSearchScorecard, allocateIndexUrlsToProperties, analyticsConnectionsSchema, analyzeAiReferralRows, analyzeCannibalRows, analyzeCtrUnderperformersFromRows, analyzeDecay, analyzeIndexCoverageSignals, analyzeInternalLinksFromRows, analyzePageOpportunitiesFromRows, analyzeQueryClustersFromRows, analyzeQuickWinsFromRows, analyzeSecondPageRows, analyzeSegmentRows, analyzeStrikingDistanceRows, analyzeTrafficAnomalyRows, assertUrlMatchesGscProperty, attachCrawlRecommendations, auditCrawlPages, auditCrawlRequests, auditLlmsTxt, auditPage, authStatus, boundedSitemapInventory, buildOkfBundle, buildPseoAuditReportFromRows, buildTelemetryPayload, cannibalReport, changeMeasurementCsvRows, checkDatabaseReadiness, classifyCommunityIntent, clearCache, clientProfileSchema, clusterPseoTemplates, clusterQueryRows, collectAgentDiscovery, combineRobotsValues, communityIntentReport, compareCrawlPages, compareCrawlReports, compareSegmentRows, configSchema, contentCoverageRecommendation, contentCoverageType, contentOptimizationFromPageOpportunities, contentOptimizationReport, countLabel, crawlConfigHash, crawlConfigSchema, crawlDefinitionId, crawlDiff, crawlFixToQueueItem, crawlImplementationQueueWorkflow, crawlIssueGroupSchema, crawlIssueSchema, crawlOne, crawlPageSnapshotSchema, crawlReportSchema, crawlReportStore, crawlReportSummarySchema, crawlRequestObservationSchema, crawlRunId, crawlSite, crawlSkipImpact, crawlSkipReasonCountsFromRecord, crawlTopFixSchema, crawlerJsonSchemas, crawlerRuleCategorySchema, crawlerRuleIdSchema, crawlerRuleInfoSchema, crawlerRuleRecommendationSchema, crawlerRuleSeveritySchema, crawlerSchemas, createAuthorizedClient, createContentGroup, createCrawlReport, createCtrBenchmarkContext, createGoogleAccessTokenClient, createLogger, createPageRenderer, ctrUnderperformersReport, decayClusterDrafts, decayComparisonRange, decayingReport, deleteChange, deleteClient, deleteContentGroup, deleteCrawlReport, deleteTokens, deriveBrandTerms, detectBrandTerms, detectTelemetryAgent, diagnoseCsvFiles, diagnoseProperty, diagnosePropertyWorkflow, effectiveRobotsDirectives, effectiveSnippetControl, ensureSeoCliDirs, entityReadiness, estimateSerpTitleWidth, expectedCtrForPosition, explainOkfValidation, explainRule, fetchCruxFieldData, fetchPage, fetchSearchStatusIncidents, fetchSitemapUrls, fileSize, finalGscDateRange, findOverlappingSearchUpdates, formatRelativeExpiry, ga4MatchReason, ga4PropertyIdFromName, ga4ReportQualityWarnings, ga4RequestCanUseCache, ga4RowsToObjects, generateLlmsTxt, geoGaps, geoGapsReport, getAuthModeStatus, getCacheStats, getChange, getClient, getClientConfig, getContentGroup, getDb, getKeywordProvider, getPreviousRun, getPrivacySnapshot, getRepeatedLinkRecoverUrls, getRunPages, getSeoCliPaths, getServiceAccountConfig, getServiceAccountStatus, getTelemetryStatus, getTokenStorageStatus, googleAnalyticsPropertyId, groupCrawlIssues, hasMetaRobotsDirective, hasRule, hasXRobotsDirective, hashKey, indexCoveragePlan, indexCoverageSignals, indexMonitor, indexWatch, inferPseoTemplate, inferTemplateMovement, initializeTelemetry, insertCrawlRun, insertLinkRecoverRun, inspectUrl, internalLinksReport, interpretUpdateCorrelation, isBrandQuery, isCompatibleTechnicalBaseline, isCurrentTechnicalBaseline, isEntityEvidenceSchemaType, isSiteIdentitySchemaType, isSkippableReportError, isTelemetryCi, isTelemetryReport, isoDate, isoWeek, latestCrawlReport, latestCrawlSummaries, latestFinalGscDate, latestIndexWatchSummary, latestLinkRecoverSummary, linkRecover, linkRecoverRecommendation, listChanges, listClients, listContentGroups, listCrawlReports, listGa4AccountSummaries, listGa4DataStreams, listRules, listSearchUpdates, listSites, loadCrawlReport, localCredentialsProvider, loginWithLoopback, makeRunId, matchGa4WebStreams, measureChange, measureCoverage, metaRobotsDirectives, migrateLegacyMacConfig, missingOAuthClientMessage, missingSharedClientLoginMessage, monitoringStatus, monthlyReport, monthlyReportWorkflow, narrativeCsvFiles, normalizeCrawlConfig, normalizeCrawlSkipReasonCounts, normalizeForCoverage, normalizeHttpUrl, normalizeJavaScriptRenderingMode, normalizeLoadedCrawlReport, okfConceptLimit, pageEvidenceRecommendation, pageOpportunitiesReport, parseOlderThan, parsePseoPath, performanceAudit, performanceReportIsCacheable, planDueIndexUrls, planIndexCoverageFromUrls, plural, propertyMatchesUrl, providerPreferenceSchema, pseoAuditOptions, pseoAuditReport, pseoCsvFiles, pseoIndexStatus, pseoPresentation, publicHttpFetch, queryClusterReport, queryClusterTokens, queryContentCoverageFromPage, queryContentFieldsFromPage, queryFieldCoverage, queryOpportunityRecommendation, queryPageMetrics, queryPageTopQuery, queryPagesMetrics, queryPagesMetricsBatch, queryPagesTopQueries, queryPagesTopQueriesBatch, querySearchAnalytics, quickWinsCsvRows, quickWinsReport, readConfig, readOauthClient, readTokens, recommendCrawlDiffItem, recommendationForRule, recordChange, redirectTrace, refreshAuthToken, refreshPrioritiesCsvFiles, refreshPrioritiesWorkflow, renderCrawlCsv, renderCrawlHtml, renderCrawlMarkdownTickets, renderCrawlPagesCsv, renderCrawlPretty, renderCsv, renderPresentationTablesMarkdown, renderPseoMarkdown, renderWorkflowMarkdown, reportNarrative, reportPresentation, requestHeaders, resolveSegmentRanges, resolveSeoCliPaths, resolveTechnicalBaseline, reviewObservations, roundedPosition, runDoctor, runGa4Report, saveClient, saveCrawlReport, secondPage, segmentComparisonRange, segmentImpact, segmentRangeDays, selectAffectedUrls, selectDueIndexUrls, seoErrorEnvelope, seoToAiQueryReport, sessionSourceInListFilter, setDefaultClient, setTelemetryEnabled, setTokenStorageMode, shiftIsoDate, shouldExcludeBrandQuery, siteSchema, sqliteCrawlReportStore, strikingDistance, summarizeCrawlReport, pageTechnicalSignals as technicalPageSignals, technicalWatchWorkflow, telemetryErrorCategory, templateForUrl, toSeoError, tokenSchema, topCrawlRecommendations, topFixes, trackTelemetryReportComplete, trackTelemetryReportFailed, trackTelemetryReportStart, trackTelemetrySetupComplete, trafficAnomaly, unavailableSegmentImpactReport, unicodeTokens, updateCorrelation, updatePostmortemCsvFiles, updatePostmortemWorkflow, validateOkfFiles, verifyQueryContent, workflowPresentation, writeConfig, writeOauthClient, writeTokens, xRobotsDirectives };
30677
+ export { AI_REFERRAL_SOURCES, AI_REFERRAL_SOURCES_VERSION, AI_SEARCH_SCORECARD_METHODOLOGY_ID, AI_SEARCH_SCORECARD_METHODOLOGY_VERSION, AUDIT_PAGE_RULE_IDS, AUTOMATIC_BASELINE_SITE_RETENTION, AUTOMATIC_BASELINE_TOTAL_RETENTION, BROWSER_USER_AGENT, CANNIBAL_MAXIMUM_DOMINANT_SHARE, CANNIBAL_MINIMUM_PAGE_SHARE, CRAWL_REPORT_STORAGE_VERSION, CRAWL_RUN_RETENTION, CRAWL_SKIP_REASONS, CTR_DEFAULT_LIMIT, CTR_DEFAULT_MIN_IMPRESSIONS, CTR_MAX_LIMIT, CTR_MAX_MIN_IMPRESSIONS, DataForSeoProvider, GSC_FINAL_DATA_LAG_DAYS, GSC_TIME_ZONE, INDEX_WATCH_ATTEMPT_RETENTION, INTERNAL_LINK_LEXICAL_TARGET_LIMIT, INTERNAL_LINK_MATCH_EVIDENCE_LIMIT, JsonFileStorageAdapter, LINK_RECOVER_RUN_RETENTION, LOG_MAX_AGE_MS, LOG_MAX_FILE_BYTES, LOG_MAX_TOTAL_BYTES, LocalCredentialsProvider, MAX_CRAWL_CONCURRENCY, MAX_CRAWL_DEPTH, MAX_CRAWL_PAGES, MAX_CRAWL_TIMEOUT_MS, OKF_DEFAULT_CONCEPTS, OKF_MAX_CONCEPTS, REPORT_BASELINE_MAX_AGE_MS, REPORT_BASELINE_MAX_DEPTH, REPORT_BASELINE_MAX_PAGES, ResponseSizeLimitError, SCORECARD_CHECK_WEIGHTS, SCORECARD_STATUS_CREDIT, SEO_CRAWLER_IDENTITY, SEO_CRAWLER_TOKEN, SEO_CRAWLER_USER_AGENT, SEO_VERSION, SERP_TITLE_REFERENCE_WIDTH_PX, SERP_TITLE_WIDTH_PROFILE, SemrushProvider, SeoError, ServiceAccountAccessTokenClient, SessionLedger, TELEMETRY_AGENTS, TELEMETRY_ENDPOINT, TELEMETRY_ERROR_CATEGORIES, TELEMETRY_EVENTS, TELEMETRY_NOTICE, TELEMETRY_REPORTS, TELEMETRY_SCHEMA_VERSION, TELEMETRY_TIMEOUT_MS, affectedUrls, agentReadiness, aiPromptsForQuery, aiReadiness, aiReferralSourceForValue, aiReferralsReport, aiSearchScorecard, allocateIndexUrlsToProperties, analyticsConnectionsSchema, analyzeAiReferralRows, analyzeCannibalRows, analyzeCtrUnderperformersFromRows, analyzeDecay, analyzeIndexCoverageSignals, analyzeInternalLinksFromRows, analyzePageOpportunitiesFromRows, analyzeQueryClustersFromRows, analyzeQuickWinsFromRows, analyzeSecondPageRows, analyzeSegmentRows, analyzeStrikingDistanceRows, analyzeTrafficAnomalyRows, assertCrawlConfigLimits, assertUrlMatchesGscProperty, attachCrawlRecommendations, auditCrawlPages, auditCrawlRequests, auditLlmsTxt, auditPage, authStatus, boundedSitemapInventory, buildOkfBundle, buildPseoAuditReportFromRows, buildTelemetryPayload, cannibalReport, changeMeasurementCsvRows, checkDatabaseReadiness, classifyCommunityIntent, clearCache, clientProfileSchema, clusterPseoTemplates, clusterQueryRows, collectAgentDiscovery, combineRobotsValues, communityIntentReport, compareCrawlPages, compareCrawlReports, compareSegmentRows, configSchema, contentCoverageRecommendation, contentCoverageType, contentOptimizationFromPageOpportunities, contentOptimizationReport, countLabel, crawlConfigHash, crawlConfigSchema, crawlDefinitionId, crawlDiff, crawlFixToQueueItem, crawlImplementationQueueWorkflow, crawlIssueGroupSchema, crawlIssueSchema, crawlOne, crawlPageSnapshotSchema, crawlReportSchema, crawlReportStore, crawlReportSummarySchema, crawlRequestObservationSchema, crawlRunId, crawlSite, crawlSkipImpact, crawlSkipReasonCountsFromRecord, crawlStatusOnly, crawlTopFixSchema, crawlerJsonSchemas, crawlerRuleCategorySchema, crawlerRuleIdSchema, crawlerRuleInfoSchema, crawlerRuleRecommendationSchema, crawlerRuleSeveritySchema, crawlerSchemas, createAuthorizedClient, createContentGroup, createCrawlReport, createCtrBenchmarkContext, createGoogleAccessTokenClient, createLogger, createPageRenderer, ctrUnderperformersReport, decayClusterDrafts, decayComparisonRange, decayingReport, deleteChange, deleteClient, deleteContentGroup, deleteCrawlReport, deleteTokens, deriveBrandTerms, detectBrandTerms, detectTelemetryAgent, diagnoseCsvFiles, diagnoseProperty, diagnosePropertyWorkflow, effectiveRobotsDirectives, effectiveSnippetControl, ensureSeoCliDirs, entityReadiness, estimateSerpTitleWidth, expectedCtrForPosition, explainOkfValidation, explainRule, fetchCruxFieldData, fetchPage, fetchPageStatus, fetchSearchStatusIncidents, fetchSitemapUrls, fetchText, fileSize, finalGscDateRange, findOverlappingSearchUpdates, formatRelativeExpiry, ga4MatchReason, ga4PropertyIdFromName, ga4ReportQualityWarnings, ga4RequestCanUseCache, ga4RowsToObjects, generateLlmsTxt, geoGaps, geoGapsReport, getAuthModeStatus, getCacheStats, getChange, getClient, getClientConfig, getContentGroup, getDb, getKeywordProvider, getPreviousRun, getPrivacySnapshot, getRepeatedLinkRecoverUrls, getRunPages, getSeoCliPaths, getServiceAccountConfig, getServiceAccountStatus, getTelemetryStatus, getTokenStorageStatus, googleAnalyticsPropertyId, groupCrawlIssues, hasMetaRobotsDirective, hasRule, hasXRobotsDirective, hashKey, indexCoveragePlan, indexCoverageSignals, indexMonitor, indexWatch, inferPseoTemplate, inferTemplateMovement, initializeTelemetry, insertCrawlRun, insertLinkRecoverRun, inspectUrl, internalLinksReport, interpretUpdateCorrelation, isBrandQuery, isCompatibleTechnicalBaseline, isCurrentTechnicalBaseline, isEntityEvidenceSchemaType, isSiteIdentitySchemaType, isSkippableReportError, isTelemetryCi, isTelemetryReport, isoDate, isoWeek, latestCrawlReport, latestCrawlSummaries, latestFinalGscDate, latestIndexWatchSummary, latestLinkRecoverSummary, linkEntries, linkRecover, linkRecoverRecommendation, listChanges, listClients, listContentGroups, listCrawlReports, listGa4AccountSummaries, listGa4DataStreams, listRules, listSearchUpdates, listSites, loadCrawlReport, localCredentialsProvider, loginWithLoopback, makeRunId, matchGa4WebStreams, measureChange, measureCoverage, metaRobotsDirectives, migrateLegacyMacConfig, missingOAuthClientMessage, missingSharedClientLoginMessage, monitoringStatus, monthlyReport, monthlyReportWorkflow, narrativeCsvFiles, normalizeCrawlConfig, normalizeCrawlSkipReasonCounts, normalizeForCoverage, normalizeHttpUrl, normalizeJavaScriptRenderingMode, normalizeLoadedCrawlReport, noteCacheWrite, okfConceptLimit, pageEvidenceRecommendation, pageOpportunitiesReport, parseOlderThan, parsePseoPath, performanceAudit, performanceReportIsCacheable, planDueIndexUrls, planIndexCoverageFromUrls, plural, propertyMatchesUrl, providerPreferenceSchema, pruneIndexWatchSnapshots, pruneLogs, pseoAuditOptions, pseoAuditReport, pseoCsvFiles, pseoIndexStatus, pseoPresentation, publicHttpFetch, queryClusterReport, queryClusterTokens, queryContentCoverageFromPage, queryContentFieldsFromPage, queryFieldCoverage, queryOpportunityRecommendation, queryPageMetrics, queryPageTopQuery, queryPagesMetrics, queryPagesMetricsBatch, queryPagesTopQueries, queryPagesTopQueriesBatch, querySearchAnalytics, quickWinsCsvRows, quickWinsReport, readBoundedResponseText, readConfig, readOauthClient, readTokens, recommendCrawlDiffItem, recommendationForRule, recordChange, redirectTrace, refreshAuthToken, refreshPrioritiesCsvFiles, refreshPrioritiesWorkflow, renderCrawlCsv, renderCrawlHtml, renderCrawlJunit, renderCrawlMarkdownTickets, renderCrawlPagesCsv, renderCrawlPretty, renderCsv, renderPresentationTablesMarkdown, renderPseoMarkdown, renderWorkflowMarkdown, reportNarrative, reportPresentation, requestHeaders, resolveSegmentRanges, resolveSeoCliPaths, resolveTechnicalBaseline, reviewObservations, roundedPosition, runDoctor, runGa4Report, safeError, saveClient, saveCrawlReport, secondPage, segmentComparisonRange, segmentImpact, segmentRangeDays, selectAffectedUrls, selectDueIndexUrls, seoErrorEnvelope, seoToAiQueryReport, sessionSourceInListFilter, setDefaultClient, setTelemetryEnabled, setTokenStorageMode, shiftIsoDate, shouldExcludeBrandQuery, siteSchema, sqliteCrawlReportStore, strikingDistance, summarizeCrawlReport, pageTechnicalSignals as technicalPageSignals, technicalWatchWorkflow, telemetryErrorCategory, templateForUrl, toSeoError, tokenSchema, topCrawlRecommendations, topFixes, trackTelemetryReportComplete, trackTelemetryReportFailed, trackTelemetryReportStart, trackTelemetrySetupComplete, trafficAnomaly, unavailableSegmentImpactReport, unicodeTokens, updateCorrelation, updatePostmortemCsvFiles, updatePostmortemWorkflow, validateOkfFiles, verifyQueryContent, workflowPresentation, writeConfig, writeOauthClient, writeTokens, xRobotsDirectives };
28973
30678
 
28974
30679
  //# sourceMappingURL=index.js.map