seo 0.2.7 → 0.2.9
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/PRIVACY.md +35 -11
- package/README.md +34 -4
- package/dist/cli.js +111 -105
- package/dist/cli.js.map +1 -1
- package/dist/{dist-B6UxI6sc.js → dist-BrBR9umh.js} +1 -1
- package/dist/dist-C6zXDARm.js +651 -0
- package/dist/dist-C6zXDARm.js.map +1 -0
- package/dist/index.d.ts +2707 -48
- package/dist/index.js +2332 -611
- package/dist/index.js.map +1 -1
- package/dist/mcp.d.ts +25 -4
- package/dist/mcp.js +2539 -627
- package/dist/mcp.js.map +1 -1
- package/dist/{package-BI-bLeV4.js → package-C51oI-6o.js} +2 -2
- package/dist/{package-BI-bLeV4.js.map → package-C51oI-6o.js.map} +1 -1
- package/package.json +3 -3
- package/skills/seo/SKILL.md +31 -5
- package/dist/dist-C0LWlNAw.js +0 -560
- package/dist/dist-C0LWlNAw.js.map +0 -1
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-
|
|
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:
|
|
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)
|
|
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,
|
|
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$
|
|
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$
|
|
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$
|
|
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$
|
|
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$
|
|
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
|
-
|
|
2990
|
-
|
|
2991
|
-
|
|
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$
|
|
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
|
-
|
|
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":
|
|
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":
|
|
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
|
|
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,
|
|
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,
|
|
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
|
-
|
|
3357
|
-
|
|
3358
|
-
|
|
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:
|
|
3365
|
-
text:
|
|
3366
|
-
cache:
|
|
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, {
|
|
3370
|
-
|
|
3371
|
-
|
|
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
|
-
|
|
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/
|
|
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
|
|
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
|
-
|
|
3557
|
-
|
|
3558
|
-
|
|
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:
|
|
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
|
|
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,
|
|
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
|
|
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
|
|
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 (
|
|
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
|
|
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
|
|
5742
|
-
const
|
|
5743
|
-
let
|
|
5744
|
-
for (const
|
|
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
|
-
|
|
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
|
-
|
|
5798
|
-
|
|
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;
|
|
@@ -6797,16 +7641,15 @@ async function inspectMarkdownPage(input) {
|
|
|
6797
7641
|
repeatedHashStable: null,
|
|
6798
7642
|
markdownCanonicalMatchesHtml: null
|
|
6799
7643
|
};
|
|
6800
|
-
|
|
6801
|
-
const explicitUrl = advertisedUrls[0];
|
|
7644
|
+
const explicitUrl = advertisedUrls.length === 1 ? advertisedUrls[0] : void 0;
|
|
6802
7645
|
const [explicit, negotiated, repeated] = await Promise.all([
|
|
6803
|
-
fetchRepresentation({
|
|
7646
|
+
explicitUrl ? fetchRepresentation({
|
|
6804
7647
|
url: explicitUrl,
|
|
6805
7648
|
accept: "text/markdown",
|
|
6806
7649
|
timeoutMs: input.timeoutMs,
|
|
6807
7650
|
fetch: input.fetch,
|
|
6808
7651
|
signal: input.signal
|
|
6809
|
-
}),
|
|
7652
|
+
}) : Promise.resolve(void 0),
|
|
6810
7653
|
fetchRepresentation({
|
|
6811
7654
|
url: input.page.finalUrl,
|
|
6812
7655
|
accept: "text/markdown",
|
|
@@ -6815,20 +7658,21 @@ async function inspectMarkdownPage(input) {
|
|
|
6815
7658
|
signal: input.signal
|
|
6816
7659
|
}),
|
|
6817
7660
|
fetchRepresentation({
|
|
6818
|
-
url: explicitUrl,
|
|
7661
|
+
url: explicitUrl ?? input.page.finalUrl,
|
|
6819
7662
|
accept: "text/markdown",
|
|
6820
7663
|
timeoutMs: input.timeoutMs,
|
|
6821
7664
|
fetch: input.fetch,
|
|
6822
7665
|
signal: input.signal
|
|
6823
7666
|
})
|
|
6824
7667
|
]);
|
|
6825
|
-
observation.explicit = explicit.observation;
|
|
7668
|
+
if (explicit) observation.explicit = explicit.observation;
|
|
6826
7669
|
observation.negotiated = negotiated.observation;
|
|
6827
7670
|
observation.repeated = repeated.observation;
|
|
6828
|
-
observation.explicitMatchesNegotiated = explicit
|
|
6829
|
-
|
|
6830
|
-
observation.
|
|
6831
|
-
|
|
7671
|
+
observation.explicitMatchesNegotiated = explicit?.observation.sha256 && negotiated.observation.sha256 ? explicit.observation.sha256 === negotiated.observation.sha256 : null;
|
|
7672
|
+
const primary = explicit ?? negotiated;
|
|
7673
|
+
observation.repeatedHashStable = primary.observation.sha256 && repeated.observation.sha256 ? primary.observation.sha256 === repeated.observation.sha256 : null;
|
|
7674
|
+
observation.markdownCanonicalMatchesHtml = sameDocument(primary.observation.canonicalUrl, input.page.finalUrl);
|
|
7675
|
+
if (primary.body) observation.quality = markdownQuality(primary.body, input.page);
|
|
6832
7676
|
return observation;
|
|
6833
7677
|
}
|
|
6834
7678
|
async function fetchText(input) {
|
|
@@ -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({
|
|
@@ -7164,10 +8012,13 @@ async function collectAgentDiscovery(input) {
|
|
|
7164
8012
|
const wwwLocation = www.location ? new URL(www.location, wwwUrl).toString() : void 0;
|
|
7165
8013
|
const qZeroContentType = qZero.observation.contentType;
|
|
7166
8014
|
const advertisedPages = observations.filter((observation) => observation.htmlAlternateUnique).length;
|
|
7167
|
-
const evaluatedPages = observations.filter((observation) =>
|
|
8015
|
+
const evaluatedPages = observations.filter((observation) => {
|
|
8016
|
+
const representation = observation.explicit ?? observation.negotiated;
|
|
8017
|
+
return representation?.status !== void 0 && representation.status >= 200 && representation.status < 300 && /^\s*text\/markdown\b/iu.test(representation.contentType ?? "");
|
|
8018
|
+
}).length;
|
|
7168
8019
|
const warnings = [...pages.length !== input.pages.length ? [`Agent representations were evaluated for ${pages.length} successful HTML pages from ${input.pages.length} retained documents.`] : [], ...routeManifest.valid ? [] : ["No valid public agent route manifest was available for orphan checks."]].sort();
|
|
7169
8020
|
const htmlContentSignals = pages.map((page) => headerValue$1(page.responseHeaders, "content-signal")).filter((value) => Boolean(value));
|
|
7170
|
-
const markdownContentSignals = observations.map((observation) => observation.explicit?.contentSignal).filter((value) => Boolean(value));
|
|
8021
|
+
const markdownContentSignals = observations.map((observation) => observation.explicit?.contentSignal ?? observation.negotiated?.contentSignal).filter((value) => Boolean(value));
|
|
7171
8022
|
const contentSignalValues = [.../* @__PURE__ */ new Set([...htmlContentSignals, ...markdownContentSignals])].sort();
|
|
7172
8023
|
return {
|
|
7173
8024
|
profile: "content",
|
|
@@ -7207,6 +8058,7 @@ async function collectAgentDiscovery(input) {
|
|
|
7207
8058
|
routeManifest,
|
|
7208
8059
|
agentSkills,
|
|
7209
8060
|
llmsTxt,
|
|
8061
|
+
endpointDiscovery,
|
|
7210
8062
|
contentSignals: {
|
|
7211
8063
|
htmlValues: [...new Set(htmlContentSignals)].sort(),
|
|
7212
8064
|
markdownValues: [...new Set(markdownContentSignals)].sort(),
|
|
@@ -7250,7 +8102,11 @@ function section$1(id, title, checks) {
|
|
|
7250
8102
|
};
|
|
7251
8103
|
}
|
|
7252
8104
|
function failedMarkdownUrls(discovery) {
|
|
7253
|
-
return discovery.markdownAlternates.pages.filter((page) =>
|
|
8105
|
+
return discovery.markdownAlternates.pages.filter((page) => {
|
|
8106
|
+
const negotiatedOk = page.negotiated?.status === 200 && /^\s*text\/markdown\b/iu.test(page.negotiated.contentType ?? "") && page.negotiated.varyAccept;
|
|
8107
|
+
const explicitOk = !page.htmlAlternateUnique ? true : page.httpAlternateUrls.length === 1 && page.explicit?.status === 200 && /^\s*text\/markdown\b/iu.test(page.explicit.contentType ?? "") && page.explicitMatchesNegotiated === true && page.markdownCanonicalMatchesHtml === true;
|
|
8108
|
+
return !negotiatedOk || !explicitOk || page.repeatedHashStable !== true;
|
|
8109
|
+
}).map((page) => page.htmlUrl);
|
|
7254
8110
|
}
|
|
7255
8111
|
function qualityFailureUrls(discovery) {
|
|
7256
8112
|
return discovery.markdownAlternates.pages.filter((page) => {
|
|
@@ -7292,20 +8148,25 @@ function markdownChecks(discovery) {
|
|
|
7292
8148
|
const markdown = discovery.markdownAlternates;
|
|
7293
8149
|
const failedUrls = failedMarkdownUrls(discovery);
|
|
7294
8150
|
const qualityUrls = qualityFailureUrls(discovery);
|
|
7295
|
-
const
|
|
7296
|
-
const
|
|
8151
|
+
const negotiatedPages = markdown.pages.filter((page) => page.negotiated?.status === 200 && /^\s*text\/markdown\b/iu.test(page.negotiated.contentType ?? "")).length;
|
|
8152
|
+
const explicitPages = markdown.pages.filter((page) => page.htmlAlternateUnique).length;
|
|
8153
|
+
const coverageComplete = markdown.eligibleHtmlPages > 0 && markdown.evaluatedPages === markdown.eligibleHtmlPages;
|
|
8154
|
+
const negotiationComplete = markdown.eligibleHtmlPages > 0 && negotiatedPages === markdown.eligibleHtmlPages && markdown.pages.every((page) => page.negotiated?.varyAccept) && markdown.pages.every((page) => !page.htmlAlternateUnique || page.explicitMatchesNegotiated === true);
|
|
7297
8155
|
const stable = markdown.eligibleHtmlPages > 0 && markdown.stableResponses === markdown.eligibleHtmlPages;
|
|
7298
|
-
const tokenHeaders = markdown.pages.filter((page) =>
|
|
8156
|
+
const tokenHeaders = markdown.pages.filter((page) => {
|
|
8157
|
+
return (page.explicit ?? page.negotiated)?.markdownTokens !== void 0 && (!page.explicit || !page.negotiated || page.explicit.markdownTokens === page.negotiated.markdownTokens);
|
|
8158
|
+
}).length;
|
|
7299
8159
|
return [
|
|
7300
8160
|
check$1("representations", {
|
|
7301
8161
|
id: "markdown-coverage",
|
|
7302
8162
|
status: markdown.eligibleHtmlPages === 0 ? "unknown" : coverageComplete ? "pass" : "fail",
|
|
7303
|
-
title: coverageComplete ? "Every successful HTML page
|
|
7304
|
-
plainEnglish: `${markdown.
|
|
7305
|
-
action: coverageComplete ? "Keep
|
|
8163
|
+
title: coverageComplete ? "Every successful HTML page has a Markdown representation" : "Markdown representation coverage is incomplete",
|
|
8164
|
+
plainEnglish: `${markdown.evaluatedPages} of ${markdown.eligibleHtmlPages} successful HTML pages returned Markdown. ${explicitPages} advertised an explicit alternative and ${negotiatedPages} supported content negotiation.`,
|
|
8165
|
+
action: coverageComplete ? "Keep the negotiated or explicit Markdown response stable as routes change." : "Return text/markdown through content negotiation or advertise one working Markdown alternative for each public HTML page.",
|
|
7306
8166
|
evidence: {
|
|
7307
8167
|
eligibleHtmlPages: markdown.eligibleHtmlPages,
|
|
7308
8168
|
advertisedPages: markdown.advertisedPages,
|
|
8169
|
+
negotiatedPages,
|
|
7309
8170
|
evaluatedPages: markdown.evaluatedPages
|
|
7310
8171
|
},
|
|
7311
8172
|
urls: failedUrls.slice(0, 25)
|
|
@@ -7313,18 +8174,22 @@ function markdownChecks(discovery) {
|
|
|
7313
8174
|
check$1("representations", {
|
|
7314
8175
|
id: "markdown-token-estimates",
|
|
7315
8176
|
status: markdown.evaluatedPages === 0 ? "unknown" : tokenHeaders === markdown.evaluatedPages ? "pass" : "warning",
|
|
7316
|
-
title: tokenHeaders === markdown.evaluatedPages ? "Every Markdown response includes
|
|
7317
|
-
plainEnglish: `${tokenHeaders} of ${markdown.evaluatedPages} evaluated pages returned
|
|
7318
|
-
action: "
|
|
7319
|
-
urls: markdown.pages.filter((page) =>
|
|
8177
|
+
title: tokenHeaders === markdown.evaluatedPages ? "Every Markdown response includes a token estimate" : "Some Markdown responses are missing a stable token estimate",
|
|
8178
|
+
plainEnglish: `${tokenHeaders} of ${markdown.evaluatedPages} evaluated pages returned X-Markdown-Tokens. Paired explicit and negotiated responses agreed where both existed. The value is an estimate, not an exact model-specific token count.`,
|
|
8179
|
+
action: "Return a stable token estimate and keep it consistent when both explicit and negotiated responses exist.",
|
|
8180
|
+
urls: markdown.pages.filter((page) => {
|
|
8181
|
+
return (page.explicit ?? page.negotiated)?.markdownTokens === void 0 || page.explicit !== void 0 && page.negotiated !== void 0 && page.explicit.markdownTokens !== page.negotiated.markdownTokens;
|
|
8182
|
+
}).map((page) => page.htmlUrl).slice(0, 25)
|
|
7320
8183
|
}),
|
|
7321
8184
|
check$1("representations", {
|
|
7322
8185
|
id: "markdown-negotiation",
|
|
7323
|
-
status: markdown.eligibleHtmlPages === 0 ? "unknown" :
|
|
7324
|
-
title:
|
|
7325
|
-
plainEnglish: `${
|
|
7326
|
-
action: "
|
|
8186
|
+
status: markdown.eligibleHtmlPages === 0 ? "unknown" : negotiationComplete && discovery.contentNegotiation.qZeroHonoured ? "pass" : "fail",
|
|
8187
|
+
title: negotiationComplete && discovery.contentNegotiation.qZeroHonoured ? "Markdown content negotiation works across the site" : "Markdown content negotiation needs attention",
|
|
8188
|
+
plainEnglish: `${negotiatedPages} of ${markdown.eligibleHtmlPages} pages returned negotiated Markdown. ${markdown.exactByteMatches} of ${explicitPages} paired explicit responses matched byte for byte. A request that refuses Markdown ${discovery.contentNegotiation.qZeroHonoured ? "received HTML" : "did not produce confirmed HTML evidence"}.`,
|
|
8189
|
+
action: "Honour Accept q-values, send Vary: Accept, and keep paired explicit and negotiated responses identical when both exist.",
|
|
7327
8190
|
evidence: {
|
|
8191
|
+
negotiatedPages,
|
|
8192
|
+
explicitPages,
|
|
7328
8193
|
exactByteMatches: markdown.exactByteMatches,
|
|
7329
8194
|
qZero: discovery.contentNegotiation
|
|
7330
8195
|
},
|
|
@@ -7334,8 +8199,8 @@ function markdownChecks(discovery) {
|
|
|
7334
8199
|
id: "markdown-determinism",
|
|
7335
8200
|
status: markdown.eligibleHtmlPages === 0 ? "unknown" : stable ? "pass" : "fail",
|
|
7336
8201
|
title: stable ? "Repeated Markdown responses are stable" : "Repeated Markdown responses changed during the audit",
|
|
7337
|
-
plainEnglish: `${markdown.stableResponses} of ${markdown.eligibleHtmlPages} repeated
|
|
7338
|
-
action: "
|
|
8202
|
+
plainEnglish: `${markdown.stableResponses} of ${markdown.eligibleHtmlPages} repeated Markdown responses kept the same SHA-256 digest.`,
|
|
8203
|
+
action: "Remove timestamps, random output, or other runtime rewriting that changes the same Markdown response between requests.",
|
|
7339
8204
|
evidence: { stableResponses: markdown.stableResponses },
|
|
7340
8205
|
urls: failedUrls.slice(0, 25)
|
|
7341
8206
|
}),
|
|
@@ -7349,7 +8214,59 @@ function markdownChecks(discovery) {
|
|
|
7349
8214
|
})
|
|
7350
8215
|
];
|
|
7351
8216
|
}
|
|
7352
|
-
function
|
|
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) {
|
|
7353
8270
|
const skills = discovery.agentSkills;
|
|
7354
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);
|
|
7355
8272
|
const llms = discovery.llmsTxt;
|
|
@@ -7393,14 +8310,7 @@ function discoveryChecks(discovery) {
|
|
|
7393
8310
|
...llms.missingCrawlRoutes
|
|
7394
8311
|
].slice(0, 25)
|
|
7395
8312
|
}),
|
|
7396
|
-
|
|
7397
|
-
id: "content-signals",
|
|
7398
|
-
status: discovery.contentSignals.consistent === null ? "unknown" : discovery.contentSignals.consistent ? "pass" : "warning",
|
|
7399
|
-
title: discovery.contentSignals.consistent ? "HTML and Markdown publish one explicit Content Signals policy" : "Content Signals are missing or inconsistent across representations",
|
|
7400
|
-
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.`,
|
|
7401
|
-
action: "Choose the site policy deliberately and return the same Content-Signal value on HTML and Markdown responses.",
|
|
7402
|
-
evidence: discovery.contentSignals
|
|
7403
|
-
}),
|
|
8313
|
+
contentSignalsCheck(discovery, robotsContentSignals),
|
|
7404
8314
|
check$1("discovery", {
|
|
7405
8315
|
id: "route-manifest",
|
|
7406
8316
|
status: !discovery.routeManifest.valid ? "info" : discovery.routeManifest.missingHtmlRoutes.length === 0 && discovery.routeManifest.missingMarkdownRoutes.length === 0 && discovery.routeManifest.orphanMarkdownRoutes.length === 0 ? "pass" : "warning",
|
|
@@ -7411,6 +8321,126 @@ function discoveryChecks(discovery) {
|
|
|
7411
8321
|
})
|
|
7412
8322
|
];
|
|
7413
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
|
+
}
|
|
7414
8444
|
function accessChecks(report, discovery) {
|
|
7415
8445
|
const bots = report.ai?.robotsTxt?.botAccess ?? [];
|
|
7416
8446
|
const blocked = bots.filter((bot) => bot.allowed === false);
|
|
@@ -7449,10 +8479,10 @@ function identityChecks(report) {
|
|
|
7449
8479
|
const officialProfiles = [...new Set(report.pages.flatMap((page) => [...page.schemaSameAs ?? [], ...page.socialProfileLinks ?? []]))];
|
|
7450
8480
|
return [check$1("identity", {
|
|
7451
8481
|
id: "identity-graph",
|
|
7452
|
-
status:
|
|
7453
|
-
title:
|
|
7454
|
-
plainEnglish: `
|
|
7455
|
-
action: "Connect truthful
|
|
8482
|
+
status: hasWebsite && hasCreator && pageTypes.length > 0 ? "pass" : "warning",
|
|
8483
|
+
title: hasWebsite && hasCreator && pageTypes.length > 0 ? "Structured identity covers the website, creator, and pages" : "The structured identity graph is missing a useful entity layer",
|
|
8484
|
+
plainEnglish: `Website identity: ${hasWebsite ? "present" : "missing"}. Creator or publisher identity: ${hasCreator ? "present" : "missing"}. Page-level types: ${pageTypes.length ? pageTypes.join(", ") : "missing"}. Software identity: ${hasSoftware ? "present" : "not observed and not required for a content site"}.`,
|
|
8485
|
+
action: "Connect truthful WebSite, creator or publisher, and page nodes. Add SoftwareApplication only when the site actually represents software. Use sameAs only for official profiles that identify the same entity.",
|
|
7456
8486
|
evidence: {
|
|
7457
8487
|
schemaTypes: [...types].sort(),
|
|
7458
8488
|
officialProfiles: officialProfiles.sort()
|
|
@@ -7512,7 +8542,8 @@ function agentReadiness(report) {
|
|
|
7512
8542
|
section$1("profile", "Profile scope", profileChecks(discovery)),
|
|
7513
8543
|
section$1("access", "Crawler and protocol access", accessChecks(report, discovery)),
|
|
7514
8544
|
section$1("representations", "HTML and Markdown representations", markdownChecks(discovery)),
|
|
7515
|
-
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)),
|
|
7516
8547
|
section$1("identity", "Identity evidence", identityChecks(report))
|
|
7517
8548
|
];
|
|
7518
8549
|
const checks = sections.flatMap((item) => item.checks);
|
|
@@ -7549,6 +8580,7 @@ function agentReadiness(report) {
|
|
|
7549
8580
|
...discovery.warnings,
|
|
7550
8581
|
"A clean content profile does not prove indexing, rankings, AI mentions, citations, or selection.",
|
|
7551
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.",
|
|
7552
8584
|
"API, application, and commerce checks were not applicable to this content-site run."
|
|
7553
8585
|
]
|
|
7554
8586
|
};
|
|
@@ -7940,7 +8972,7 @@ function aiReadiness(report) {
|
|
|
7940
8972
|
score: 0,
|
|
7941
8973
|
evaluated: Boolean(agentDiscovery),
|
|
7942
8974
|
title: agentDiscovery ? "Markdown alternatives were checked" : "Markdown alternatives were not evaluated",
|
|
7943
|
-
plainEnglish: agentDiscovery ? `${agentDiscovery.markdownAlternates.
|
|
8975
|
+
plainEnglish: agentDiscovery ? `${agentDiscovery.markdownAlternates.evaluatedPages} of ${agentDiscovery.markdownAlternates.eligibleHtmlPages} successful HTML pages returned Markdown through an explicit alternative or content negotiation. ${agentDiscovery.markdownAlternates.exactByteMatches} paired explicit responses matched content negotiation byte for byte.` : "This crawl does not include the focused representation checks, so it cannot say whether HTML pages publish stable Markdown alternatives.",
|
|
7944
8976
|
action: "Use the focused agent-readiness report when you need route coverage, content negotiation, byte stability, or extraction-quality evidence.",
|
|
7945
8977
|
evidence: agentDiscovery ? {
|
|
7946
8978
|
eligibleHtmlPages: agentDiscovery.markdownAlternates.eligibleHtmlPages,
|
|
@@ -8153,6 +9185,15 @@ function originHost(url) {
|
|
|
8153
9185
|
function cleanTitle(page) {
|
|
8154
9186
|
return page.title ?? page.h1 ?? (new URL(page.finalUrl).pathname || page.finalUrl);
|
|
8155
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
|
+
}
|
|
8156
9197
|
function sectionForPage(page) {
|
|
8157
9198
|
const path = new URL(page.finalUrl).pathname.toLowerCase();
|
|
8158
9199
|
if (/\/(docs|documentation|guide|guides|help|kb|learn)\b/.test(path)) return "Docs and guides";
|
|
@@ -8285,7 +9326,7 @@ function generateLlmsTxt(report, options = {}) {
|
|
|
8285
9326
|
let sectionCount = 0;
|
|
8286
9327
|
for (const page of sectionPages) {
|
|
8287
9328
|
const title = cleanTitle(page).replace(/\]/g, "\\]");
|
|
8288
|
-
const note = page.metaDescription ? ` - ${page.metaDescription
|
|
9329
|
+
const note = page.metaDescription ? ` - ${truncateAtWord(page.metaDescription, 160)}` : "";
|
|
8289
9330
|
const line = `- [${title}](${page.finalUrl})${note}`;
|
|
8290
9331
|
if (estimatedTokens([
|
|
8291
9332
|
...lines,
|
|
@@ -8303,14 +9344,29 @@ function generateLlmsTxt(report, options = {}) {
|
|
|
8303
9344
|
counts[sectionName] = sectionCount;
|
|
8304
9345
|
if (sectionCount < sectionPages.length) break;
|
|
8305
9346
|
}
|
|
8306
|
-
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.`);
|
|
8307
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
|
+
}
|
|
8308
9354
|
const content = `${lines.join("\n")}\n`;
|
|
8309
9355
|
return {
|
|
8310
9356
|
content,
|
|
8311
9357
|
includedUrls,
|
|
8312
9358
|
estimatedTokens: estimatedTokens(content),
|
|
8313
|
-
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
|
+
}
|
|
8314
9370
|
};
|
|
8315
9371
|
}
|
|
8316
9372
|
//#endregion
|
|
@@ -8923,6 +9979,24 @@ const RULE_DEFINITIONS = [
|
|
|
8923
9979
|
impactIfIgnored: "The page cannot be crawled, ranked, or cited while the connection failure remains.",
|
|
8924
9980
|
howToVerify: "Re-run the crawl and confirm the URL returns a normal HTTP status instead of status 0."
|
|
8925
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
|
+
},
|
|
8926
10000
|
{
|
|
8927
10001
|
id: "client_error",
|
|
8928
10002
|
title: "Client error",
|
|
@@ -9411,6 +10485,7 @@ const RULE_RECOMMENDATIONS = {
|
|
|
9411
10485
|
nofollow: "review",
|
|
9412
10486
|
x_robots_noindex: "review",
|
|
9413
10487
|
robots_blocked: "review",
|
|
10488
|
+
crawler_access_blocked: "review",
|
|
9414
10489
|
orphan_page: "review",
|
|
9415
10490
|
redirected_url: "review",
|
|
9416
10491
|
slow_response: "review",
|
|
@@ -9632,6 +10707,14 @@ function auditCrawlRequests(requests) {
|
|
|
9632
10707
|
continue;
|
|
9633
10708
|
}
|
|
9634
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
|
+
}
|
|
9635
10718
|
const redirectChain = request.redirectChain ?? [];
|
|
9636
10719
|
if (redirectChain.length > 0 || !sameUrl(request.requestedUrl, request.finalUrl)) issues.push(requestIssue("redirected_url", request, request.finalUrl ? `Final URL: ${request.finalUrl}` : void 0, {
|
|
9637
10720
|
requestedUrl: request.requestedUrl,
|
|
@@ -9667,6 +10750,7 @@ function auditCrawlPages(pages, opts = {}) {
|
|
|
9667
10750
|
}));
|
|
9668
10751
|
continue;
|
|
9669
10752
|
}
|
|
10753
|
+
if (page.accessBlock) continue;
|
|
9670
10754
|
if (page.status >= 500) {
|
|
9671
10755
|
issues.push(issue("server_error", page, String(page.status), { status: page.status }));
|
|
9672
10756
|
if ((page.internalInlinkCount ?? 0) > 0) issues.push(issue("broken_internal_link", page, String(page.status), {
|
|
@@ -9707,6 +10791,7 @@ function auditCrawlPages(pages, opts = {}) {
|
|
|
9707
10791
|
responseTimeMs: page.responseTimeMs,
|
|
9708
10792
|
thresholdMs: SLOW_RESPONSE_MS
|
|
9709
10793
|
}));
|
|
10794
|
+
if (page.auditScope === "status") continue;
|
|
9710
10795
|
if (redirected) continue;
|
|
9711
10796
|
if (isHtmlPage(page) && (page.sizeBytes ?? 0) > LARGE_HTML_BYTES) issues.push(issue("large_html", page, `${Math.round((page.sizeBytes ?? 0) / 1024)} KB`, {
|
|
9712
10797
|
sizeBytes: page.sizeBytes,
|
|
@@ -9942,6 +11027,10 @@ function crawlSkipReasonCountsFromRecord(input = {}) {
|
|
|
9942
11027
|
}
|
|
9943
11028
|
//#endregion
|
|
9944
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;
|
|
9945
11034
|
const SENSITIVE_FIELD_NAMES = /* @__PURE__ */ new Set([
|
|
9946
11035
|
"authorization",
|
|
9947
11036
|
"cookie",
|
|
@@ -9957,33 +11046,53 @@ const SENSITIVE_PARAM_PATTERN = /^(access_?token|auth|api_?key|client_?secret|co
|
|
|
9957
11046
|
function uniqueSorted(values = []) {
|
|
9958
11047
|
return [...new Set(values.filter(Boolean))].sort();
|
|
9959
11048
|
}
|
|
9960
|
-
function normalizeFetchRate(input) {
|
|
11049
|
+
function normalizeFetchRate(input, strategy) {
|
|
11050
|
+
const defaultConcurrency = strategy === "health" ? 4 : 8;
|
|
9961
11051
|
return {
|
|
9962
|
-
concurrency: input.fetchRate?.concurrency ?? input.concurrency ??
|
|
11052
|
+
concurrency: input.fetchRate?.concurrency ?? input.concurrency ?? defaultConcurrency,
|
|
9963
11053
|
...input.fetchRate?.intervalCap !== void 0 ? { intervalCap: input.fetchRate.intervalCap } : {},
|
|
9964
11054
|
...input.fetchRate?.intervalMs !== void 0 ? { intervalMs: input.fetchRate.intervalMs } : {},
|
|
9965
11055
|
...input.fetchRate?.backpressure ? { backpressure: input.fetchRate.backpressure } : {}
|
|
9966
11056
|
};
|
|
9967
11057
|
}
|
|
9968
11058
|
function normalizeCrawlConfig(input) {
|
|
11059
|
+
const strategy = input.strategy ?? "full";
|
|
11060
|
+
const concurrency = input.concurrency ?? (strategy === "health" ? 4 : 8);
|
|
9969
11061
|
return {
|
|
9970
11062
|
url: new URL(input.url).toString(),
|
|
9971
|
-
mode: input.mode ?? "site",
|
|
11063
|
+
mode: input.mode ?? (strategy === "health" ? "sitemap" : "site"),
|
|
11064
|
+
strategy,
|
|
11065
|
+
...input.sitemapUrl ? { sitemapUrl: new URL(input.sitemapUrl).toString() } : {},
|
|
9972
11066
|
urls: uniqueSorted(input.urls ?? []),
|
|
9973
11067
|
maxPages: input.maxPages ?? 500,
|
|
9974
11068
|
maxDepth: input.maxDepth ?? 16,
|
|
9975
|
-
concurrency
|
|
11069
|
+
concurrency,
|
|
9976
11070
|
timeoutMs: input.timeoutMs ?? 2e4,
|
|
9977
11071
|
include: uniqueSorted(input.include ?? []),
|
|
9978
11072
|
exclude: uniqueSorted(input.exclude ?? []),
|
|
9979
11073
|
respectRobots: input.respectRobots ?? true,
|
|
9980
11074
|
useSitemap: input.useSitemap ?? true,
|
|
9981
|
-
checkExternal: input.checkExternal ?? true,
|
|
9982
|
-
checkAgentDiscovery: input.checkAgentDiscovery ?? false,
|
|
9983
|
-
js: normalizeJavaScriptRenderingMode(input.js),
|
|
9984
|
-
refresh: input.refresh ?? false,
|
|
9985
|
-
fetchRate: normalizeFetchRate(input)
|
|
9986
|
-
};
|
|
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;
|
|
9987
11096
|
}
|
|
9988
11097
|
function crawlConfigHash(config) {
|
|
9989
11098
|
const normalized = normalizeCrawlConfig(config);
|
|
@@ -10100,10 +11209,12 @@ function summarizeCrawlReport(input) {
|
|
|
10100
11209
|
}
|
|
10101
11210
|
for (const issue of input.issues) byCategory[issue.category] = (byCategory[issue.category] ?? 0) + 1;
|
|
10102
11211
|
const runStats = crawlRunStats(input.pages, input.stats);
|
|
11212
|
+
const indexabilityPages = input.pages.filter((page) => page.auditScope !== "status");
|
|
10103
11213
|
return {
|
|
10104
11214
|
totalPages: input.pages.length,
|
|
10105
|
-
|
|
10106
|
-
|
|
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,
|
|
10107
11218
|
statusErrors: input.pages.filter((page) => page.status === 0 || page.status >= 400).length,
|
|
10108
11219
|
...runStats,
|
|
10109
11220
|
attemptedRequests: input.requestEvidenceStatus === "unavailable" ? 0 : requests.length,
|
|
@@ -10121,6 +11232,23 @@ function summarizeCrawlReport(input) {
|
|
|
10121
11232
|
byCategory
|
|
10122
11233
|
};
|
|
10123
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
|
+
}
|
|
10124
11252
|
function crawlRunStats(pages, stats = {}) {
|
|
10125
11253
|
const discovered = /* @__PURE__ */ new Set();
|
|
10126
11254
|
let observedInternalLinks = 0;
|
|
@@ -10214,6 +11342,7 @@ function createCrawlReport(input) {
|
|
|
10214
11342
|
status: input.status ?? "completed",
|
|
10215
11343
|
configHash: crawlConfigHash(config),
|
|
10216
11344
|
config,
|
|
11345
|
+
access: crawlAccessSummary(requests),
|
|
10217
11346
|
summary: summarizeCrawlReport({
|
|
10218
11347
|
pages,
|
|
10219
11348
|
requests,
|
|
@@ -10236,16 +11365,19 @@ function createCrawlReport(input) {
|
|
|
10236
11365
|
};
|
|
10237
11366
|
}
|
|
10238
11367
|
function normalizeLoadedCrawlReport(report) {
|
|
11368
|
+
const config = sanitizeCrawlConfig(normalizeCrawlConfig(report.config));
|
|
10239
11369
|
const requestEvidenceStatus = report.requestEvidenceStatus ?? (Object.hasOwn(report, "requests") ? "available" : "unavailable");
|
|
10240
11370
|
const issues = sanitizeIssues(report.issues ?? []);
|
|
10241
11371
|
const requests = sortRequests(sanitizeTenantValue(report.requests ?? []) ?? []);
|
|
10242
11372
|
const pages = sortPages(sanitizePages(deriveInternalLinkAuthority(report.pages ?? [])));
|
|
10243
11373
|
return {
|
|
10244
11374
|
...sanitizeTenantValue(report),
|
|
11375
|
+
config,
|
|
11376
|
+
access: crawlAccessSummary(requests),
|
|
10245
11377
|
requestEvidenceStatus,
|
|
10246
11378
|
requests,
|
|
10247
11379
|
definitionId: report.definitionId ?? crawlDefinitionId({
|
|
10248
|
-
config
|
|
11380
|
+
config,
|
|
10249
11381
|
site: report.site,
|
|
10250
11382
|
googleAnalyticsPropertyId: report.googleAnalyticsPropertyId
|
|
10251
11383
|
}),
|
|
@@ -10266,6 +11398,8 @@ function normalizeLoadedCrawlReport(report) {
|
|
|
10266
11398
|
//#endregion
|
|
10267
11399
|
//#region packages/core/src/analyze/crawler/report-store.ts
|
|
10268
11400
|
const CRAWL_REPORT_STORAGE_VERSION = 6;
|
|
11401
|
+
const AUTOMATIC_BASELINE_SITE_RETENTION = 2;
|
|
11402
|
+
const AUTOMATIC_BASELINE_TOTAL_RETENTION = 50;
|
|
10269
11403
|
function toMeta(row) {
|
|
10270
11404
|
return {
|
|
10271
11405
|
id: row.id,
|
|
@@ -10304,14 +11438,61 @@ function reportFromJson(value) {
|
|
|
10304
11438
|
if (parsed && typeof parsed === "object" && "kind" in parsed && parsed.kind === "seo.crawl_report" && "report" in parsed) return normalizeLoadedCrawlReport(parsed.report);
|
|
10305
11439
|
return normalizeLoadedCrawlReport(parsed);
|
|
10306
11440
|
}
|
|
10307
|
-
function
|
|
10308
|
-
|
|
10309
|
-
|
|
10310
|
-
|
|
10311
|
-
|
|
10312
|
-
|
|
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);
|
|
10313
11493
|
if (existing) return toMeta(existing);
|
|
10314
11494
|
}
|
|
11495
|
+
if (retention === "baseline") pruneAutomaticBaselines();
|
|
10315
11496
|
return {
|
|
10316
11497
|
id: report.id,
|
|
10317
11498
|
configHash: report.configHash,
|
|
@@ -10326,10 +11507,13 @@ function saveCrawlReportToSqlite(report) {
|
|
|
10326
11507
|
}
|
|
10327
11508
|
function listCrawlReportsFromSqlite(input = {}) {
|
|
10328
11509
|
const limit = input.limit ?? 20;
|
|
10329
|
-
return (input.site ?
|
|
10330
|
-
|
|
10331
|
-
|
|
10332
|
-
|
|
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
|
|
10333
11517
|
ORDER BY created_at DESC, id DESC
|
|
10334
11518
|
LIMIT ?`).all(limit)).map(toMeta);
|
|
10335
11519
|
}
|
|
@@ -10342,10 +11526,13 @@ function deleteCrawlReportFromSqlite(id) {
|
|
|
10342
11526
|
return getDb().prepare("DELETE FROM crawl_reports WHERE id = ?").run(id).changes > 0;
|
|
10343
11527
|
}
|
|
10344
11528
|
function latestCrawlReportFromSqlite(site) {
|
|
10345
|
-
const row = site ?
|
|
10346
|
-
|
|
10347
|
-
|
|
10348
|
-
|
|
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
|
|
10349
11536
|
ORDER BY created_at DESC, id DESC
|
|
10350
11537
|
LIMIT 1`).get();
|
|
10351
11538
|
if (!row) return void 0;
|
|
@@ -10359,8 +11546,8 @@ const sqliteCrawlReportStore = {
|
|
|
10359
11546
|
latest: latestCrawlReportFromSqlite
|
|
10360
11547
|
};
|
|
10361
11548
|
const crawlReportStore = sqliteCrawlReportStore;
|
|
10362
|
-
function saveCrawlReport(report, store = crawlReportStore) {
|
|
10363
|
-
return store.save(report);
|
|
11549
|
+
function saveCrawlReport(report, store = crawlReportStore, options = {}) {
|
|
11550
|
+
return store.save(report, options);
|
|
10364
11551
|
}
|
|
10365
11552
|
function listCrawlReports(input = {}, store = crawlReportStore) {
|
|
10366
11553
|
return store.list(input);
|
|
@@ -10406,6 +11593,86 @@ function cancellationRace(signal) {
|
|
|
10406
11593
|
});
|
|
10407
11594
|
}
|
|
10408
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
|
|
10409
11676
|
//#region packages/core/src/analyze/crawler/external-link-checks.ts
|
|
10410
11677
|
const EXTERNAL_LINK_SELECTION_LIMIT = 200;
|
|
10411
11678
|
const EXTERNAL_LINK_STATES = [
|
|
@@ -10607,18 +11874,13 @@ function resolveFetchedAlias(url, aliases) {
|
|
|
10607
11874
|
}
|
|
10608
11875
|
return current;
|
|
10609
11876
|
}
|
|
10610
|
-
function
|
|
10611
|
-
const resolved =
|
|
10612
|
-
for (const [
|
|
10613
|
-
const
|
|
10614
|
-
|
|
10615
|
-
if (!resolvedTargets) {
|
|
10616
|
-
resolvedTargets = /* @__PURE__ */ new Set();
|
|
10617
|
-
resolved[resolvedSource] = resolvedTargets;
|
|
10618
|
-
}
|
|
10619
|
-
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);
|
|
10620
11882
|
}
|
|
10621
|
-
return
|
|
11883
|
+
return resolved;
|
|
10622
11884
|
}
|
|
10623
11885
|
//#endregion
|
|
10624
11886
|
//#region packages/core/src/analyze/crawler/request-evidence.ts
|
|
@@ -10630,7 +11892,9 @@ function observationFromPage(requestedUrl, page) {
|
|
|
10630
11892
|
status: page.status,
|
|
10631
11893
|
contentType: page.contentType,
|
|
10632
11894
|
durationMs: page.responseTimeMs,
|
|
10633
|
-
redirectChain: page.fetchDiagnostics?.redirectChain
|
|
11895
|
+
redirectChain: page.fetchDiagnostics?.redirectChain,
|
|
11896
|
+
accessBlock: page.accessBlock ?? page.fetchDiagnostics?.accessBlock,
|
|
11897
|
+
robotsTxt: page.robotsTxt
|
|
10634
11898
|
};
|
|
10635
11899
|
if (page.extractionStatus === "failed") return {
|
|
10636
11900
|
...observation,
|
|
@@ -10643,18 +11907,225 @@ function observationFromPage(requestedUrl, page) {
|
|
|
10643
11907
|
};
|
|
10644
11908
|
}
|
|
10645
11909
|
//#endregion
|
|
10646
|
-
//#region packages/core/src/analyze/crawler/site-crawl-
|
|
10647
|
-
|
|
10648
|
-
|
|
10649
|
-
|
|
10650
|
-
|
|
10651
|
-
|
|
10652
|
-
|
|
10653
|
-
|
|
10654
|
-
|
|
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
|
+
});
|
|
10655
11949
|
try {
|
|
10656
|
-
|
|
10657
|
-
|
|
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,
|
|
10658
12129
|
startDate: input.window.startDate,
|
|
10659
12130
|
endDate: input.window.endDate,
|
|
10660
12131
|
limit: input.limit
|
|
@@ -11008,7 +12479,9 @@ function responseObservation(requestedUrl, fetched, extraction, extractionError)
|
|
|
11008
12479
|
status: fetched.status,
|
|
11009
12480
|
contentType: headerValue(fetched.headers, "content-type"),
|
|
11010
12481
|
durationMs: fetched.diagnostics.durationMs,
|
|
11011
|
-
redirectChain: fetched.diagnostics.redirectChain
|
|
12482
|
+
redirectChain: fetched.diagnostics.redirectChain,
|
|
12483
|
+
accessBlock: fetched.diagnostics.accessBlock,
|
|
12484
|
+
robotsTxt: fetched.robotsTxt
|
|
11012
12485
|
};
|
|
11013
12486
|
if (extraction === "failed") {
|
|
11014
12487
|
if (!extractionError) throw new Error("Failed extraction observations require an error.");
|
|
@@ -11033,13 +12506,13 @@ function unextractedPage(fetched, contentType) {
|
|
|
11033
12506
|
finalUrl: fetched.finalUrl,
|
|
11034
12507
|
status: fetched.status,
|
|
11035
12508
|
contentType,
|
|
11036
|
-
responseHeaders: safeResponseHeaders(fetched.headers),
|
|
11037
12509
|
responseTimeMs: fetched.diagnostics.durationMs,
|
|
11038
12510
|
sizeBytes: Buffer.byteLength(fetched.html),
|
|
11039
12511
|
usedJs: fetched.usedJs,
|
|
11040
12512
|
fetchSource: fetched.diagnostics.source,
|
|
11041
12513
|
cacheState: fetched.diagnostics.cache,
|
|
11042
12514
|
fetchDiagnostics: fetched.diagnostics,
|
|
12515
|
+
accessBlock: fetched.diagnostics.accessBlock,
|
|
11043
12516
|
blocked: fetched.diagnostics.blocked,
|
|
11044
12517
|
robotsTxt: fetched.robotsTxt,
|
|
11045
12518
|
xRobotsTag,
|
|
@@ -11060,6 +12533,35 @@ function unextractedPage(fetched, contentType) {
|
|
|
11060
12533
|
compression: headerValue(fetched.headers, "content-encoding")
|
|
11061
12534
|
};
|
|
11062
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
|
+
}
|
|
11063
12565
|
function indexabilityReason(page) {
|
|
11064
12566
|
if (page.status < 200 || page.status >= 300) return `Status ${page.status}`;
|
|
11065
12567
|
if (hasMetaRobotsDirective(page.metaRobots, "noindex")) return "Meta robots noindex";
|
|
@@ -11113,13 +12615,26 @@ async function crawlOne(url, opts = {}) {
|
|
|
11113
12615
|
};
|
|
11114
12616
|
}
|
|
11115
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
|
+
}
|
|
11116
12631
|
if (!isHtmlContentType(contentType)) return {
|
|
11117
12632
|
request: responseObservation(url, fetched, contentType ? "not-applicable" : "unknown-media-type"),
|
|
11118
12633
|
page: unextractedPage(fetched, contentType),
|
|
11119
12634
|
urls: []
|
|
11120
12635
|
};
|
|
11121
12636
|
try {
|
|
11122
|
-
const extracted = await extractPage(fetched);
|
|
12637
|
+
const extracted = await extractPage(fetched, "crawler");
|
|
11123
12638
|
const base = new URL(extracted.finalUrl);
|
|
11124
12639
|
const internalLinks = extracted.links.map((link) => sameOriginUrl(link.href, base)).filter((value) => Boolean(value));
|
|
11125
12640
|
const externalLinks = extracted.links.filter((link) => !link.internal).map((link) => link.href).filter((href) => /^https?:\/\//.test(href));
|
|
@@ -11240,6 +12755,62 @@ async function crawlOne(url, opts = {}) {
|
|
|
11240
12755
|
};
|
|
11241
12756
|
}
|
|
11242
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
|
+
}
|
|
11243
12814
|
//#endregion
|
|
11244
12815
|
//#region packages/core/src/analyze/monitoring/sitemaps.ts
|
|
11245
12816
|
const INVALID_LOC_SAMPLE_LIMIT = 10;
|
|
@@ -11482,6 +13053,7 @@ async function fetchSitemapUrls(input) {
|
|
|
11482
13053
|
const nestedSitemaps = [];
|
|
11483
13054
|
const discoveredSitemaps = /* @__PURE__ */ new Set();
|
|
11484
13055
|
const scheduledSitemaps = /* @__PURE__ */ new Set();
|
|
13056
|
+
const fetchedSitemaps = /* @__PURE__ */ new Set();
|
|
11485
13057
|
const invalidLocSamples = [];
|
|
11486
13058
|
const malformedLastmodSamples = [];
|
|
11487
13059
|
const futureLastmodSamples = [];
|
|
@@ -11528,12 +13100,18 @@ async function fetchSitemapUrls(input) {
|
|
|
11528
13100
|
const sitemapUrl = queue.shift();
|
|
11529
13101
|
if (!sitemapUrl) continue;
|
|
11530
13102
|
const fetched = await fetchSitemapXml(sitemapUrl);
|
|
13103
|
+
fetchedSitemaps.add(sitemapUrl);
|
|
11531
13104
|
documents.push(fetched.document);
|
|
11532
13105
|
if (fetched.document.warning) warnings.push(fetched.document.warning);
|
|
11533
13106
|
if (fetched.document.dataStatus !== "unavailable") sitemapsFetched += 1;
|
|
11534
13107
|
if (!fetched.xml || !fetched.document.root) continue;
|
|
11535
13108
|
const $ = load(fetched.xml, { xmlMode: true });
|
|
11536
13109
|
$(fetched.document.root === "urlset" ? "urlset > url" : "").each((_, element) => {
|
|
13110
|
+
if (urls.length >= limit) {
|
|
13111
|
+
urlLimitExceeded = true;
|
|
13112
|
+
omittedUrlsAtLeast += 1;
|
|
13113
|
+
return false;
|
|
13114
|
+
}
|
|
11537
13115
|
urlLocs += 1;
|
|
11538
13116
|
const entry = $(element);
|
|
11539
13117
|
const value = entry.children("loc").first().text().trim();
|
|
@@ -11558,12 +13136,7 @@ async function fetchSitemapUrls(input) {
|
|
|
11558
13136
|
return;
|
|
11559
13137
|
}
|
|
11560
13138
|
seenUrls.add(url);
|
|
11561
|
-
|
|
11562
|
-
urls.push(url);
|
|
11563
|
-
return;
|
|
11564
|
-
}
|
|
11565
|
-
urlLimitExceeded = true;
|
|
11566
|
-
omittedUrlsAtLeast += 1;
|
|
13139
|
+
urls.push(url);
|
|
11567
13140
|
});
|
|
11568
13141
|
$(fetched.document.root === "sitemapindex" ? "sitemapindex > sitemap" : "").each((_, element) => {
|
|
11569
13142
|
sitemapLocs += 1;
|
|
@@ -11596,8 +13169,13 @@ async function fetchSitemapUrls(input) {
|
|
|
11596
13169
|
queue.push(child);
|
|
11597
13170
|
} else nestedSitemapLimitExceeded = true;
|
|
11598
13171
|
});
|
|
13172
|
+
if (urls.length >= limit && queue.length > 0) {
|
|
13173
|
+
urlLimitExceeded = true;
|
|
13174
|
+
omittedUrlsAtLeast = Math.max(omittedUrlsAtLeast, 1);
|
|
13175
|
+
break;
|
|
13176
|
+
}
|
|
11599
13177
|
}
|
|
11600
|
-
const unprocessedSitemaps = nestedSitemaps.filter((sitemap) => !
|
|
13178
|
+
const unprocessedSitemaps = nestedSitemaps.filter((sitemap) => !fetchedSitemaps.has(sitemap)).length;
|
|
11601
13179
|
const possiblyTruncated = urlLimitExceeded || nestedSitemapLimitExceeded || unprocessedSitemaps > 0;
|
|
11602
13180
|
if (invalidLocCount) warnings.push(`Ignored ${invalidLocCount} invalid sitemap <loc> ${invalidLocCount === 1 ? "entry" : "entries"}.`);
|
|
11603
13181
|
if (possiblyTruncated) warnings.push("Sitemap discovery exceeded a configured URL or sitemap boundary; the returned inventory is incomplete.");
|
|
@@ -11777,6 +13355,7 @@ function resolveCrawlSiteDependencies(dependencies = {}) {
|
|
|
11777
13355
|
const emptyPageTopQuery = async () => void 0;
|
|
11778
13356
|
return {
|
|
11779
13357
|
fetchPage: dependencies.fetchPage ?? crawlOne,
|
|
13358
|
+
fetchStatusPage: dependencies.fetchStatusPage ?? crawlStatusOnly,
|
|
11780
13359
|
fetchSitemapUrls: dependencies.fetchSitemapUrls ?? fetchSitemapUrls,
|
|
11781
13360
|
fetch: dependencies.fetch ?? publicHttpFetch,
|
|
11782
13361
|
queryPageMetrics: (injectedMode === "per-page" ? dependencies.queryPageMetrics : void 0) ?? (injectedMode ? emptyPageMetrics : queryPageMetrics),
|
|
@@ -11802,6 +13381,10 @@ function isSameOrigin(url, origin) {
|
|
|
11802
13381
|
}
|
|
11803
13382
|
}
|
|
11804
13383
|
function sitemapRootUrls(input) {
|
|
13384
|
+
if (input.sitemapUrl) return [{
|
|
13385
|
+
url: new URL(input.sitemapUrl).toString(),
|
|
13386
|
+
source: "explicit"
|
|
13387
|
+
}];
|
|
11805
13388
|
const origin = new URL(input.url).origin;
|
|
11806
13389
|
const declared = input.declaredSitemapUrls.filter((url) => isSameOrigin(url, origin));
|
|
11807
13390
|
const ignored = input.declaredSitemapUrls.length - declared.length;
|
|
@@ -11852,247 +13435,71 @@ async function sitemapSeeds(input) {
|
|
|
11852
13435
|
sitemapUrl: fallbackUrl,
|
|
11853
13436
|
limit: input.maxPages
|
|
11854
13437
|
});
|
|
11855
|
-
input.warnings.push(...sitemap.warnings);
|
|
11856
|
-
for (const url of sitemap.urls) urls.add(url);
|
|
11857
|
-
roots.push({
|
|
11858
|
-
url: sitemap.sitemapUrl,
|
|
11859
|
-
source: "default-path",
|
|
11860
|
-
dataStatus: sitemap.source.sitemapsFetched > 0 ? sitemap.dataStatus : "unavailable",
|
|
11861
|
-
urlsReturned: sitemap.urls.length,
|
|
11862
|
-
sitemapsFetched: sitemap.source.sitemapsFetched,
|
|
11863
|
-
lastmods: sitemap.source.lastmods,
|
|
11864
|
-
documents: sitemap.source.documents,
|
|
11865
|
-
possiblyTruncated: sitemap.truncation.possiblyTruncated,
|
|
11866
|
-
warnings: sitemap.warnings
|
|
11867
|
-
});
|
|
11868
|
-
}
|
|
11869
|
-
}
|
|
11870
|
-
return {
|
|
11871
|
-
urls: [...urls],
|
|
11872
|
-
discovery: {
|
|
11873
|
-
dataStatus: sitemapDiscoveryStatus(roots),
|
|
11874
|
-
urlsReturned: urls.size,
|
|
11875
|
-
roots
|
|
11876
|
-
}
|
|
11877
|
-
};
|
|
11878
|
-
}
|
|
11879
|
-
//#endregion
|
|
11880
|
-
//#region packages/core/src/analyze/crawler/site-crawl.ts
|
|
11881
|
-
const AI_BOT_USER_AGENTS = [
|
|
11882
|
-
"GPTBot",
|
|
11883
|
-
"OAI-SearchBot",
|
|
11884
|
-
"ChatGPT-User",
|
|
11885
|
-
"ClaudeBot",
|
|
11886
|
-
"PerplexityBot",
|
|
11887
|
-
"Google-Extended",
|
|
11888
|
-
"CCBot",
|
|
11889
|
-
"Applebot",
|
|
11890
|
-
"Bingbot"
|
|
11891
|
-
];
|
|
11892
|
-
const AGENT_RESOURCE_PATHS = [
|
|
11893
|
-
"/.well-known/agent.json",
|
|
11894
|
-
"/agent.json",
|
|
11895
|
-
"/.well-known/mcp.json",
|
|
11896
|
-
"/.well-known/ai-plugin.json",
|
|
11897
|
-
"/.well-known/openapi.json",
|
|
11898
|
-
"/openapi.json"
|
|
11899
|
-
];
|
|
11900
|
-
function normalizeUrl$1(value, base) {
|
|
11901
|
-
try {
|
|
11902
|
-
const url = base ? new URL(value, base) : new URL(value);
|
|
11903
|
-
url.hash = "";
|
|
11904
|
-
if (!["http:", "https:"].includes(url.protocol)) return void 0;
|
|
11905
|
-
return url.toString();
|
|
11906
|
-
} catch {
|
|
11907
|
-
return;
|
|
11908
|
-
}
|
|
11909
|
-
}
|
|
11910
|
-
function sameOrigin(url, origin) {
|
|
11911
|
-
try {
|
|
11912
|
-
return new URL(url).origin === origin;
|
|
11913
|
-
} catch {
|
|
11914
|
-
return false;
|
|
11915
|
-
}
|
|
11916
|
-
}
|
|
11917
|
-
function isLikelyAsset(url) {
|
|
11918
|
-
const path = new URL(url).pathname.toLowerCase();
|
|
11919
|
-
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);
|
|
11920
|
-
}
|
|
11921
|
-
function globMatch(pattern, value) {
|
|
11922
|
-
if (!pattern.includes("*")) return value.includes(pattern);
|
|
11923
|
-
const parts = pattern.split("*");
|
|
11924
|
-
let position = 0;
|
|
11925
|
-
for (const part of parts) {
|
|
11926
|
-
if (!part) continue;
|
|
11927
|
-
const found = value.slice(position).indexOf(part);
|
|
11928
|
-
if (found === -1) return false;
|
|
11929
|
-
position += found + part.length;
|
|
11930
|
-
}
|
|
11931
|
-
return true;
|
|
11932
|
-
}
|
|
11933
|
-
function passesFilters(url, include, exclude) {
|
|
11934
|
-
return (!include.length || include.some((pattern) => globMatch(pattern, url))) && !exclude.some((pattern) => globMatch(pattern, url));
|
|
11935
|
-
}
|
|
11936
|
-
async function checkLlmsTxt(input) {
|
|
11937
|
-
const llmsUrl = new URL("/llms.txt", input.url).toString();
|
|
11938
|
-
const controller = abortController({
|
|
11939
|
-
timeoutMs: input.timeoutMs,
|
|
11940
|
-
signal: input.signal
|
|
11941
|
-
});
|
|
11942
|
-
try {
|
|
11943
|
-
const response = await input.fetch(llmsUrl, {
|
|
11944
|
-
profile: "bot",
|
|
11945
|
-
redirect: "follow",
|
|
11946
|
-
signal: controller.signal
|
|
11947
|
-
});
|
|
11948
|
-
const contentType = response.headers.get("content-type") ?? "";
|
|
11949
|
-
const exists = response.status >= 200 && response.status < 300 && !/\btext\/html\b/i.test(contentType);
|
|
11950
|
-
await response.body?.cancel().catch(() => void 0);
|
|
11951
|
-
return {
|
|
11952
|
-
url: response.url || llmsUrl,
|
|
11953
|
-
exists,
|
|
11954
|
-
status: response.status
|
|
11955
|
-
};
|
|
11956
|
-
} catch {
|
|
11957
|
-
return {
|
|
11958
|
-
url: llmsUrl,
|
|
11959
|
-
exists: false
|
|
11960
|
-
};
|
|
11961
|
-
} finally {
|
|
11962
|
-
controller.cleanup();
|
|
11963
|
-
}
|
|
11964
|
-
}
|
|
11965
|
-
function parseRobots(robotsUrl, text) {
|
|
11966
|
-
return robotsParserModule(robotsUrl, text);
|
|
11967
|
-
}
|
|
11968
|
-
function declaredUserAgents(text) {
|
|
11969
|
-
const declared = /* @__PURE__ */ new Set();
|
|
11970
|
-
for (const match of text.matchAll(/^\s*user-agent\s*:\s*(.+?)\s*$/gim)) {
|
|
11971
|
-
const value = match[1]?.trim().toLowerCase();
|
|
11972
|
-
if (value) declared.add(value);
|
|
11973
|
-
}
|
|
11974
|
-
return declared;
|
|
11975
|
-
}
|
|
11976
|
-
function sitemapUrlsFromRobots(text) {
|
|
11977
|
-
const urls = /* @__PURE__ */ new Set();
|
|
11978
|
-
for (const match of text.matchAll(/^\s*sitemap\s*:\s*(\S+)\s*$/gim)) {
|
|
11979
|
-
const value = match[1]?.trim();
|
|
11980
|
-
if (!value) continue;
|
|
11981
|
-
try {
|
|
11982
|
-
urls.add(new URL(value).toString());
|
|
11983
|
-
} catch {}
|
|
11984
|
-
}
|
|
11985
|
-
return [...urls];
|
|
11986
|
-
}
|
|
11987
|
-
async function checkRobotsAiAccess(input) {
|
|
11988
|
-
const robotsUrl = new URL("/robots.txt", input.url).toString();
|
|
11989
|
-
const controller = abortController({
|
|
11990
|
-
timeoutMs: input.timeoutMs,
|
|
11991
|
-
signal: input.signal
|
|
11992
|
-
});
|
|
11993
|
-
try {
|
|
11994
|
-
const response = await input.fetch(robotsUrl, {
|
|
11995
|
-
profile: "bot",
|
|
11996
|
-
redirect: "follow",
|
|
11997
|
-
signal: controller.signal
|
|
11998
|
-
});
|
|
11999
|
-
const text = await response.text();
|
|
12000
|
-
const available = response.status >= 200 && response.status < 300;
|
|
12001
|
-
const absent = response.status >= 400 && response.status < 500 && response.status !== 429;
|
|
12002
|
-
const availability = available ? "available" : [401, 403].includes(response.status) ? "access-blocked" : response.status === 429 ? "rate-limited" : absent ? "absent" : "unreachable";
|
|
12003
|
-
const exists = available;
|
|
12004
|
-
const declared = exists ? declaredUserAgents(text) : /* @__PURE__ */ new Set();
|
|
12005
|
-
const parsed = parseRobots(robotsUrl, exists ? text : "");
|
|
12006
|
-
return {
|
|
12007
|
-
url: response.url || robotsUrl,
|
|
12008
|
-
exists,
|
|
12009
|
-
availability,
|
|
12010
|
-
status: response.status,
|
|
12011
|
-
...["rate-limited", "unreachable"].includes(availability) ? { error: `robots.txt returned HTTP ${response.status}.` } : {},
|
|
12012
|
-
sitemapUrls: exists ? sitemapUrlsFromRobots(text) : [],
|
|
12013
|
-
botAccess: AI_BOT_USER_AGENTS.map((userAgent) => {
|
|
12014
|
-
const lower = userAgent.toLowerCase();
|
|
12015
|
-
return {
|
|
12016
|
-
userAgent,
|
|
12017
|
-
allowed: availability === "rate-limited" || availability === "unreachable" ? null : parsed.isAllowed(input.url, userAgent) ?? true,
|
|
12018
|
-
declared: declared.has(lower),
|
|
12019
|
-
coveredByWildcard: declared.has("*")
|
|
12020
|
-
};
|
|
12021
|
-
})
|
|
12022
|
-
};
|
|
12023
|
-
} catch (error) {
|
|
12024
|
-
return {
|
|
12025
|
-
url: robotsUrl,
|
|
12026
|
-
exists: false,
|
|
12027
|
-
availability: "unreachable",
|
|
12028
|
-
error: error instanceof Error ? error.message : String(error),
|
|
12029
|
-
sitemapUrls: [],
|
|
12030
|
-
botAccess: AI_BOT_USER_AGENTS.map((userAgent) => ({
|
|
12031
|
-
userAgent,
|
|
12032
|
-
allowed: null,
|
|
12033
|
-
declared: false,
|
|
12034
|
-
coveredByWildcard: false
|
|
12035
|
-
}))
|
|
12036
|
-
};
|
|
12037
|
-
} finally {
|
|
12038
|
-
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
|
+
}
|
|
12039
13452
|
}
|
|
13453
|
+
return {
|
|
13454
|
+
urls: [...urls],
|
|
13455
|
+
discovery: {
|
|
13456
|
+
dataStatus: sitemapDiscoveryStatus(roots),
|
|
13457
|
+
urlsReturned: urls.size,
|
|
13458
|
+
roots
|
|
13459
|
+
}
|
|
13460
|
+
};
|
|
12040
13461
|
}
|
|
12041
|
-
|
|
12042
|
-
|
|
12043
|
-
|
|
12044
|
-
timeoutMs: input.timeoutMs,
|
|
12045
|
-
signal: input.signal
|
|
12046
|
-
});
|
|
13462
|
+
//#endregion
|
|
13463
|
+
//#region packages/core/src/analyze/crawler/site-crawl.ts
|
|
13464
|
+
function normalizeUrl$1(value, base) {
|
|
12047
13465
|
try {
|
|
12048
|
-
const
|
|
12049
|
-
|
|
12050
|
-
|
|
12051
|
-
|
|
12052
|
-
});
|
|
12053
|
-
const contentType = response.headers.get("content-type") ?? "";
|
|
12054
|
-
const text = await response.text();
|
|
12055
|
-
const exists = response.status >= 200 && response.status < 300;
|
|
12056
|
-
let validJson;
|
|
12057
|
-
if (exists && /\bjson\b/i.test(contentType)) try {
|
|
12058
|
-
JSON.parse(text);
|
|
12059
|
-
validJson = true;
|
|
12060
|
-
} catch {
|
|
12061
|
-
validJson = false;
|
|
12062
|
-
}
|
|
12063
|
-
return {
|
|
12064
|
-
url: response.url || url,
|
|
12065
|
-
exists,
|
|
12066
|
-
status: response.status,
|
|
12067
|
-
contentType,
|
|
12068
|
-
...validJson === void 0 ? {} : { validJson }
|
|
12069
|
-
};
|
|
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();
|
|
12070
13470
|
} catch {
|
|
12071
|
-
return
|
|
12072
|
-
url,
|
|
12073
|
-
exists: false
|
|
12074
|
-
};
|
|
12075
|
-
} finally {
|
|
12076
|
-
controller.cleanup();
|
|
13471
|
+
return;
|
|
12077
13472
|
}
|
|
12078
13473
|
}
|
|
12079
|
-
|
|
12080
|
-
|
|
12081
|
-
|
|
12082
|
-
|
|
12083
|
-
|
|
12084
|
-
baseUrl: input.url,
|
|
12085
|
-
path,
|
|
12086
|
-
timeoutMs: input.timeoutMs,
|
|
12087
|
-
fetch: input.fetch,
|
|
12088
|
-
signal: input.signal
|
|
12089
|
-
}));
|
|
13474
|
+
function sameOrigin(url, origin) {
|
|
13475
|
+
try {
|
|
13476
|
+
return new URL(url).origin === origin;
|
|
13477
|
+
} catch {
|
|
13478
|
+
return false;
|
|
12090
13479
|
}
|
|
12091
|
-
|
|
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));
|
|
12092
13499
|
}
|
|
12093
13500
|
async function crawlSite(input, dependencies) {
|
|
12094
13501
|
const deps = resolveCrawlSiteDependencies(dependencies);
|
|
12095
|
-
const config = normalizeCrawlConfig(input);
|
|
13502
|
+
const config = assertCrawlConfigLimits(normalizeCrawlConfig(input));
|
|
12096
13503
|
const { searchMetricsLimit, analyticsLimit } = crawlProviderLimits(input);
|
|
12097
13504
|
const signal = input.signal;
|
|
12098
13505
|
let cancelled = Boolean(signal?.aborted);
|
|
@@ -12100,17 +13507,19 @@ async function crawlSite(input, dependencies) {
|
|
|
12100
13507
|
const site = input.site;
|
|
12101
13508
|
const origin = new URL(config.url).origin;
|
|
12102
13509
|
const warnings = [];
|
|
12103
|
-
const queue =
|
|
13510
|
+
const queue = new CrawlUrlQueue();
|
|
12104
13511
|
const visited = /* @__PURE__ */ new Set();
|
|
12105
|
-
const queued = /* @__PURE__ */ new Set();
|
|
12106
13512
|
const discovered = /* @__PURE__ */ new Set();
|
|
12107
|
-
const
|
|
13513
|
+
const incomingLinkCounts = /* @__PURE__ */ new Map();
|
|
12108
13514
|
const fetchedAliases = /* @__PURE__ */ new Map();
|
|
12109
13515
|
const pages = [];
|
|
12110
13516
|
const requests = [];
|
|
12111
13517
|
const documentIndexes = /* @__PURE__ */ new Map();
|
|
12112
13518
|
const inFlight = /* @__PURE__ */ new Set();
|
|
12113
13519
|
const followLinks = config.mode === "site";
|
|
13520
|
+
const healthStrategy = config.strategy === "health";
|
|
13521
|
+
let activeConcurrency = healthStrategy ? 1 : config.concurrency;
|
|
13522
|
+
let healthyProbeStreak = 0;
|
|
12114
13523
|
let queuedUrls = 0;
|
|
12115
13524
|
let skippedUrls = 0;
|
|
12116
13525
|
const skipReasonCounts = {};
|
|
@@ -12134,7 +13543,7 @@ async function crawlSite(input, dependencies) {
|
|
|
12134
13543
|
generatedAt: deps.now().toISOString(),
|
|
12135
13544
|
discoveredUrls: discovered.size,
|
|
12136
13545
|
queuedUrls,
|
|
12137
|
-
pendingUrls: queue.
|
|
13546
|
+
pendingUrls: queue.size,
|
|
12138
13547
|
inFlightUrls: inFlight.size,
|
|
12139
13548
|
crawledUrls: pages.length,
|
|
12140
13549
|
skippedUrls,
|
|
@@ -12148,36 +13557,27 @@ async function crawlSite(input, dependencies) {
|
|
|
12148
13557
|
const flushStatusEvents = async () => {
|
|
12149
13558
|
await statusEventChain;
|
|
12150
13559
|
};
|
|
12151
|
-
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;
|
|
12152
13568
|
try {
|
|
12153
13569
|
emitStatus("started", {
|
|
12154
13570
|
url: config.url,
|
|
12155
13571
|
message: `Started crawl for ${config.url}.`
|
|
12156
13572
|
});
|
|
12157
|
-
const aiSignals = isCancelled() ? void 0 : await
|
|
12158
|
-
|
|
12159
|
-
|
|
12160
|
-
|
|
12161
|
-
|
|
12162
|
-
|
|
12163
|
-
|
|
12164
|
-
|
|
12165
|
-
url: config.url,
|
|
12166
|
-
timeoutMs: config.timeoutMs,
|
|
12167
|
-
fetch: deps.fetch,
|
|
12168
|
-
signal
|
|
12169
|
-
}),
|
|
12170
|
-
checkAgentResources({
|
|
12171
|
-
url: config.url,
|
|
12172
|
-
timeoutMs: config.timeoutMs,
|
|
12173
|
-
fetch: deps.fetch,
|
|
12174
|
-
signal
|
|
12175
|
-
})
|
|
12176
|
-
]);
|
|
12177
|
-
const llmsTxt = aiSignals?.[0] ?? {
|
|
12178
|
-
url: new URL("/llms.txt", config.url).toString(),
|
|
12179
|
-
exists: false
|
|
12180
|
-
};
|
|
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;
|
|
12181
13581
|
const robotsTxt = aiSignals?.[1];
|
|
12182
13582
|
const agentResources = aiSignals?.[2];
|
|
12183
13583
|
cancelled = isCancelled();
|
|
@@ -12192,12 +13592,9 @@ async function crawlSite(input, dependencies) {
|
|
|
12192
13592
|
});
|
|
12193
13593
|
return;
|
|
12194
13594
|
}
|
|
12195
|
-
discovered.add(normalized);
|
|
12196
13595
|
if (visited.has(normalized)) return;
|
|
12197
|
-
if (
|
|
12198
|
-
|
|
12199
|
-
if (existing && depth < existing.depth) existing.depth = depth;
|
|
12200
|
-
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);
|
|
12201
13598
|
return;
|
|
12202
13599
|
}
|
|
12203
13600
|
if (!sameOrigin(normalized, origin)) {
|
|
@@ -12227,7 +13624,7 @@ async function crawlSite(input, dependencies) {
|
|
|
12227
13624
|
});
|
|
12228
13625
|
return;
|
|
12229
13626
|
}
|
|
12230
|
-
if (queue.
|
|
13627
|
+
if (queue.size + visited.size + inFlight.size >= config.maxPages * 5) {
|
|
12231
13628
|
recordSkip("queue-safety-limit");
|
|
12232
13629
|
queueSafetySkippedUrls += 1;
|
|
12233
13630
|
emitStatus("url_skipped", {
|
|
@@ -12237,13 +13634,12 @@ async function crawlSite(input, dependencies) {
|
|
|
12237
13634
|
});
|
|
12238
13635
|
return;
|
|
12239
13636
|
}
|
|
12240
|
-
|
|
13637
|
+
discovered.add(normalized);
|
|
12241
13638
|
queuedUrls += 1;
|
|
12242
13639
|
queue.push({
|
|
12243
13640
|
url: normalized,
|
|
12244
13641
|
depth
|
|
12245
13642
|
});
|
|
12246
|
-
queue.sort((left, right) => left.depth - right.depth || (left.url < right.url ? -1 : left.url > right.url ? 1 : 0));
|
|
12247
13643
|
emitStatus("url_queued", {
|
|
12248
13644
|
url: normalized,
|
|
12249
13645
|
depth
|
|
@@ -12256,8 +13652,9 @@ async function crawlSite(input, dependencies) {
|
|
|
12256
13652
|
if (!isCancelled() && config.useSitemap && (config.mode === "site" || config.mode === "sitemap")) {
|
|
12257
13653
|
const sitemap = await sitemapSeeds({
|
|
12258
13654
|
url: config.url,
|
|
13655
|
+
sitemapUrl: config.sitemapUrl,
|
|
12259
13656
|
maxPages: config.maxPages,
|
|
12260
|
-
declaredSitemapUrls: robotsTxt?.sitemapUrls ?? [],
|
|
13657
|
+
declaredSitemapUrls: robotsTxt?.sitemapUrls ?? healthSitemapUrls ?? [],
|
|
12261
13658
|
warnings,
|
|
12262
13659
|
fetchSitemapUrls: deps.fetchSitemapUrls
|
|
12263
13660
|
});
|
|
@@ -12265,8 +13662,8 @@ async function crawlSite(input, dependencies) {
|
|
|
12265
13662
|
sitemapDiscovery = sitemap.discovery;
|
|
12266
13663
|
}
|
|
12267
13664
|
const nextQueueItem = () => {
|
|
12268
|
-
while (queue.
|
|
12269
|
-
const item = queue.
|
|
13665
|
+
while (queue.size) {
|
|
13666
|
+
const item = queue.take();
|
|
12270
13667
|
if (!item) continue;
|
|
12271
13668
|
const normalized = normalizeUrl$1(item.url, config.url);
|
|
12272
13669
|
if (!normalized) continue;
|
|
@@ -12280,15 +13677,17 @@ async function crawlSite(input, dependencies) {
|
|
|
12280
13677
|
};
|
|
12281
13678
|
const startTask = (item) => {
|
|
12282
13679
|
let task;
|
|
13680
|
+
const fetcher = healthStrategy ? deps.fetchStatusPage : deps.fetchPage;
|
|
12283
13681
|
task = {
|
|
12284
13682
|
...item,
|
|
12285
|
-
promise:
|
|
13683
|
+
promise: fetcher(item.url, {
|
|
12286
13684
|
js: config.js,
|
|
12287
13685
|
refresh: config.refresh,
|
|
12288
13686
|
timeoutMs: config.timeoutMs,
|
|
12289
13687
|
rate: config.fetchRate,
|
|
12290
13688
|
signal,
|
|
12291
13689
|
respectRobots: config.respectRobots,
|
|
13690
|
+
robotsResolver,
|
|
12292
13691
|
renderer
|
|
12293
13692
|
}).then((result) => ({
|
|
12294
13693
|
task,
|
|
@@ -12314,8 +13713,8 @@ async function crawlSite(input, dependencies) {
|
|
|
12314
13713
|
depth: item.depth
|
|
12315
13714
|
});
|
|
12316
13715
|
};
|
|
12317
|
-
while (!isCancelled() && (queue.
|
|
12318
|
-
while (!isCancelled() && inFlight.size <
|
|
13716
|
+
while (!isCancelled() && (queue.size || inFlight.size) && pages.length < config.maxPages) {
|
|
13717
|
+
while (!isCancelled() && inFlight.size < activeConcurrency && pages.length + inFlight.size < config.maxPages) {
|
|
12319
13718
|
const item = nextQueueItem();
|
|
12320
13719
|
if (!item) break;
|
|
12321
13720
|
startTask(item);
|
|
@@ -12336,6 +13735,13 @@ async function crawlSite(input, dependencies) {
|
|
|
12336
13735
|
extraction: "not-applicable"
|
|
12337
13736
|
});
|
|
12338
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
|
+
}
|
|
12339
13745
|
if (request.outcome === "skipped") {
|
|
12340
13746
|
recordSkip(request.reason === "robots-disallowed" ? "robots-disallowed" : request.reason === "robots-deferred" ? "robots-uncertain" : "origin-backpressure");
|
|
12341
13747
|
if (request.reason === "robots-deferred") {
|
|
@@ -12398,40 +13804,49 @@ async function crawlSite(input, dependencies) {
|
|
|
12398
13804
|
};
|
|
12399
13805
|
const existing = documentIndexes.get(finalUrl);
|
|
12400
13806
|
if (existing) {
|
|
12401
|
-
const links = /* @__PURE__ */ new Set([...existing.links, ...normalizedLinks]);
|
|
12402
13807
|
const minDepth = Math.min(existing.minDepth, task.depth);
|
|
12403
13808
|
const replace = !existing.directlyRequested && directlyRequested || existing.directlyRequested === directlyRequested && sourceRequest < existing.sourceRequest;
|
|
12404
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);
|
|
12405
13824
|
page.crawlDepth = minDepth;
|
|
12406
13825
|
pages[existing.index] = page;
|
|
12407
13826
|
} else {
|
|
12408
13827
|
const existingPage = pages[existing.index];
|
|
12409
13828
|
if (existingPage) existingPage.crawlDepth = minDepth;
|
|
12410
13829
|
}
|
|
12411
|
-
const retainedPage = pages[existing.index];
|
|
12412
|
-
if (retainedPage) syncLinkEvidence(retainedPage, links);
|
|
12413
|
-
observedInternalLinks += links.size - existing.links.size;
|
|
12414
13830
|
documentIndexes.set(finalUrl, {
|
|
12415
13831
|
...existing,
|
|
12416
13832
|
directlyRequested: existing.directlyRequested || directlyRequested,
|
|
12417
13833
|
sourceRequest: replace ? sourceRequest : existing.sourceRequest,
|
|
12418
|
-
|
|
13834
|
+
pendingAliasLinks: void 0,
|
|
12419
13835
|
minDepth
|
|
12420
13836
|
});
|
|
12421
|
-
linkGraph[finalUrl] = [...links].sort((left, right) => left < right ? -1 : left > right ? 1 : 0);
|
|
12422
13837
|
} else {
|
|
12423
13838
|
const links = new Set(normalizedLinks);
|
|
12424
13839
|
syncLinkEvidence(page, links);
|
|
12425
13840
|
pages.push(page);
|
|
12426
13841
|
observedInternalLinks += links.size;
|
|
13842
|
+
for (const target of links) incomingLinkCounts.set(target, (incomingLinkCounts.get(target) ?? 0) + 1);
|
|
12427
13843
|
documentIndexes.set(finalUrl, {
|
|
12428
13844
|
index: pages.length - 1,
|
|
12429
13845
|
directlyRequested,
|
|
12430
13846
|
sourceRequest,
|
|
12431
|
-
links,
|
|
13847
|
+
pendingAliasLinks: !directlyRequested ? links : void 0,
|
|
12432
13848
|
minDepth: task.depth
|
|
12433
13849
|
});
|
|
12434
|
-
linkGraph[finalUrl] = [...links].sort((left, right) => left < right ? -1 : left > right ? 1 : 0);
|
|
12435
13850
|
}
|
|
12436
13851
|
};
|
|
12437
13852
|
if (config.respectRobots && page.robotsTxt?.allowed === false) {
|
|
@@ -12459,27 +13874,16 @@ async function crawlSite(input, dependencies) {
|
|
|
12459
13874
|
});
|
|
12460
13875
|
if (followLinks && task.depth < config.maxDepth) for (const url of result.urls) enqueue(url, task.depth + 1);
|
|
12461
13876
|
}
|
|
12462
|
-
const
|
|
12463
|
-
const
|
|
12464
|
-
const
|
|
12465
|
-
|
|
12466
|
-
|
|
12467
|
-
|
|
12468
|
-
const sourceIndex = pageIndexes.get(sourceUrl);
|
|
12469
|
-
if (sourceIndex === void 0) continue;
|
|
12470
|
-
for (const target of targets) {
|
|
12471
|
-
const targetIndex = pageIndexes.get(target);
|
|
12472
|
-
if (targetIndex === void 0) continue;
|
|
12473
|
-
const nextDepth = (depths[sourceIndex] ?? 0) + 1;
|
|
12474
|
-
if (nextDepth >= (depths[targetIndex] ?? Number.POSITIVE_INFINITY)) continue;
|
|
12475
|
-
depths[targetIndex] = nextDepth;
|
|
12476
|
-
changed = true;
|
|
12477
|
-
}
|
|
12478
|
-
}
|
|
12479
|
-
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;
|
|
12480
13883
|
}
|
|
12481
|
-
for (const [index, page] of pages.entries()) page.crawlDepth = depths[index];
|
|
12482
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.`);
|
|
12483
13887
|
if (cancelled) {
|
|
12484
13888
|
warnings.push("Crawl cancelled; returning a partial report.");
|
|
12485
13889
|
emitStatus("cancelled", {
|
|
@@ -12487,15 +13891,17 @@ async function crawlSite(input, dependencies) {
|
|
|
12487
13891
|
message: "Crawl cancelled; returning a partial report."
|
|
12488
13892
|
});
|
|
12489
13893
|
}
|
|
12490
|
-
const pageLimitReached = pages.length >= config.maxPages && (queue.
|
|
13894
|
+
const pageLimitReached = pages.length >= config.maxPages && (queue.size > 0 || inFlight.size > 0);
|
|
12491
13895
|
const sitemapEvidencePartial = sitemapDiscovery?.dataStatus === "partial";
|
|
12492
|
-
const
|
|
12493
|
-
const
|
|
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"));
|
|
12494
13900
|
const requestEvidenceStatus = cancelled && inFlight.size > 0 ? "partial" : "available";
|
|
12495
13901
|
const dataSources = await crawlDataSources({
|
|
12496
13902
|
cancelled,
|
|
12497
|
-
site,
|
|
12498
|
-
googleAnalyticsPropertyId: input.googleAnalyticsPropertyId,
|
|
13903
|
+
site: healthStrategy ? void 0 : site,
|
|
13904
|
+
googleAnalyticsPropertyId: healthStrategy ? void 0 : input.googleAnalyticsPropertyId,
|
|
12499
13905
|
pages,
|
|
12500
13906
|
warnings,
|
|
12501
13907
|
searchMetricsLimit,
|
|
@@ -12538,7 +13944,7 @@ async function crawlSite(input, dependencies) {
|
|
|
12538
13944
|
concurrency: Math.min(config.concurrency, 4)
|
|
12539
13945
|
}) : void 0;
|
|
12540
13946
|
for (const page of pages) {
|
|
12541
|
-
if (!page.geo) continue;
|
|
13947
|
+
if (!page.geo || !llmsTxt) continue;
|
|
12542
13948
|
page.geo = {
|
|
12543
13949
|
...page.geo,
|
|
12544
13950
|
hasLlmsTxt: llmsTxt.exists,
|
|
@@ -12555,12 +13961,11 @@ async function crawlSite(input, dependencies) {
|
|
|
12555
13961
|
pages,
|
|
12556
13962
|
requests,
|
|
12557
13963
|
requestEvidenceStatus,
|
|
12558
|
-
|
|
12559
|
-
|
|
12560
|
-
llmsTxt,
|
|
13964
|
+
...!healthStrategy ? { ai: {
|
|
13965
|
+
...llmsTxt ? { llmsTxt } : {},
|
|
12561
13966
|
...robotsTxt ? { robotsTxt } : {},
|
|
12562
13967
|
...agentResources ? { agentResources } : {}
|
|
12563
|
-
},
|
|
13968
|
+
} } : {},
|
|
12564
13969
|
...sitemapDiscovery ? { sitemapDiscovery } : {},
|
|
12565
13970
|
...externalLinkVerification ? { externalLinkVerification } : {},
|
|
12566
13971
|
...agentDiscovery ? { agentDiscovery } : {},
|
|
@@ -12604,7 +14009,7 @@ const REPORT_BASELINE_MAX_AGE_MS = 10080 * 60 * 1e3;
|
|
|
12604
14009
|
const defaultDependencies$13 = {
|
|
12605
14010
|
latestCrawlReport,
|
|
12606
14011
|
crawlSite,
|
|
12607
|
-
saveCrawlReport,
|
|
14012
|
+
saveCrawlReport: (report) => saveCrawlReport(report, crawlReportStore, { retention: "baseline" }),
|
|
12608
14013
|
now: () => /* @__PURE__ */ new Date()
|
|
12609
14014
|
};
|
|
12610
14015
|
function normalizePathname(value) {
|
|
@@ -13921,6 +15326,8 @@ function renderCrawlPretty(report, fixes = topFixes(report)) {
|
|
|
13921
15326
|
`Crawl report for ${report.config.url}`,
|
|
13922
15327
|
"",
|
|
13923
15328
|
`Status: ${report.status}`,
|
|
15329
|
+
`Strategy: ${report.config.strategy === "health" ? "sitemap health pass" : "full audit"}`,
|
|
15330
|
+
`Crawler: ${report.access.crawler.userAgent}`,
|
|
13924
15331
|
`Documents: ${report.summary.totalPages} retained; ${requestSummary}, ${report.summary.discoveredUrls} URLs discovered, ${report.summary.failedUrls} failed, ${report.summary.skippedUrls} skipped`,
|
|
13925
15332
|
`Issues: ${report.issues.length} (${report.summary.highIssues} high, ${report.summary.mediumIssues} medium, ${report.summary.lowIssues} low)`
|
|
13926
15333
|
];
|
|
@@ -13928,6 +15335,14 @@ function renderCrawlPretty(report, fixes = topFixes(report)) {
|
|
|
13928
15335
|
const external = report.externalLinkVerification;
|
|
13929
15336
|
lines.push(`External links: ${external.fetchedUrls} fetched, ${external.failedUrls} request failure${external.failedUrls === 1 ? "" : "s"}, ${external.deferredUrls} deferred (${external.dataStatus})`);
|
|
13930
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
|
+
}
|
|
13931
15346
|
if (fixes.length) {
|
|
13932
15347
|
lines.push("", "Prioritised fixes");
|
|
13933
15348
|
for (const fix of fixes.slice(0, 5)) {
|
|
@@ -13942,12 +15357,108 @@ function renderCrawlPretty(report, fixes = topFixes(report)) {
|
|
|
13942
15357
|
if (observation.sampleUrls[0]) lines.push(` First URL: ${observation.sampleUrls[0]}`);
|
|
13943
15358
|
}
|
|
13944
15359
|
}
|
|
13945
|
-
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`);
|
|
13946
15361
|
for (const command of [...new Set(fixes.slice(0, 3).map((fix) => fix.verification.command))]) lines.push(`- ${command}`);
|
|
13947
15362
|
if (report.warnings.length) lines.push("", "Warnings", ...report.warnings.map((item) => `- ${item}`));
|
|
13948
15363
|
if (report.caveats.length) lines.push("", "Caveats", ...report.caveats.map((item) => `- ${item}`));
|
|
13949
15364
|
return `${lines.join("\n")}\n`;
|
|
13950
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
|
+
}
|
|
13951
15462
|
function renderCrawlHtml(report, fixes = topFixes(report)) {
|
|
13952
15463
|
const reviews = reviewObservations(report);
|
|
13953
15464
|
const issueRows = report.issueGroups.map((group) => {
|
|
@@ -13958,6 +15469,7 @@ function renderCrawlHtml(report, fixes = topFixes(report)) {
|
|
|
13958
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");
|
|
13959
15470
|
const caveatItems = report.caveats.map((caveat) => `<li>${escapeHtml(caveat)}</li>`).join("\n");
|
|
13960
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");
|
|
13961
15473
|
return `<!doctype html>
|
|
13962
15474
|
<html lang="en">
|
|
13963
15475
|
<head>
|
|
@@ -13980,6 +15492,7 @@ function renderCrawlHtml(report, fixes = topFixes(report)) {
|
|
|
13980
15492
|
<body>
|
|
13981
15493
|
<h1>Crawl report</h1>
|
|
13982
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>
|
|
13983
15496
|
<section class="summary">
|
|
13984
15497
|
<div class="metric"><span>Status</span><strong>${escapeHtml(report.status)}</strong></div>
|
|
13985
15498
|
<div class="metric"><span>Documents</span><strong>${report.summary.totalPages}</strong></div>
|
|
@@ -13992,6 +15505,7 @@ function renderCrawlHtml(report, fixes = topFixes(report)) {
|
|
|
13992
15505
|
<div class="metric"><span>Issues</span><strong>${report.issues.length}</strong></div>
|
|
13993
15506
|
<div class="metric"><span>High</span><strong>${report.summary.highIssues}</strong></div>
|
|
13994
15507
|
</section>
|
|
15508
|
+
${accessItems ? `<h2>Access blocks</h2><ul>${accessItems}</ul>` : ""}
|
|
13995
15509
|
<h2>Prioritised fixes</h2>
|
|
13996
15510
|
${fixItems ? `<ol>${fixItems}</ol>` : "<p>No prioritised fixes found.</p>"}
|
|
13997
15511
|
${reviewItems ? `<h2>Review observations</h2>
|
|
@@ -14018,10 +15532,17 @@ function renderCrawlMarkdownTickets(report, fixes = topFixes(report)) {
|
|
|
14018
15532
|
`Source: ${report.config.url}`,
|
|
14019
15533
|
`Report: ${report.id}`,
|
|
14020
15534
|
`Status: ${report.status}`,
|
|
15535
|
+
`Strategy: ${report.config.strategy}`,
|
|
15536
|
+
`Crawler: ${report.access.crawler.userAgent}`,
|
|
14021
15537
|
""
|
|
14022
15538
|
];
|
|
14023
15539
|
if (report.caveats.length) lines.push("## Caveats", "", ...report.caveats.map((item) => `- ${item}`), "");
|
|
14024
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
|
+
]), "");
|
|
14025
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}`), "");
|
|
14026
15547
|
if (!fixes.length) lines.push("No implementation tickets were generated.");
|
|
14027
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"}`]), "");
|
|
@@ -14030,6 +15551,9 @@ function renderCrawlMarkdownTickets(report, fixes = topFixes(report)) {
|
|
|
14030
15551
|
function escapeHtml(value) {
|
|
14031
15552
|
return value.replaceAll("&", "&").replaceAll("<", "<").replaceAll(">", ">").replaceAll("\"", """).replaceAll("'", "'");
|
|
14032
15553
|
}
|
|
15554
|
+
function escapeXml(value) {
|
|
15555
|
+
return escapeHtml(value);
|
|
15556
|
+
}
|
|
14033
15557
|
//#endregion
|
|
14034
15558
|
//#region packages/core/src/analyze/crawler/history-provenance.ts
|
|
14035
15559
|
function snapshotCaps(report) {
|
|
@@ -14345,6 +15869,14 @@ function compareCrawlReports(input) {
|
|
|
14345
15869
|
};
|
|
14346
15870
|
}
|
|
14347
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
|
|
14348
15880
|
//#region packages/core/src/analyze/crawler/okf.ts
|
|
14349
15881
|
const OKF_DEFAULT_CONCEPTS = 500;
|
|
14350
15882
|
const OKF_MAX_CONCEPTS = 5e3;
|
|
@@ -14711,7 +16243,7 @@ function explainOkfValidation(validation) {
|
|
|
14711
16243
|
return {
|
|
14712
16244
|
title: "OKF bundle",
|
|
14713
16245
|
valid: validation.valid,
|
|
14714
|
-
summary: validation.valid ? `This bundle passes seo OKF checks with ${validation.concepts
|
|
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")}.`,
|
|
14715
16247
|
files: validation.files,
|
|
14716
16248
|
concepts: validation.concepts,
|
|
14717
16249
|
errors: errors.length,
|
|
@@ -14863,6 +16395,31 @@ const agentDiscoverySchema = z.object({
|
|
|
14863
16395
|
missingMarkdownPages: z.number().int().nonnegative(),
|
|
14864
16396
|
consistent: z.boolean().nullable()
|
|
14865
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(),
|
|
14866
16423
|
protocolVariants: z.object({
|
|
14867
16424
|
http: z.object({
|
|
14868
16425
|
url: z.string().url(),
|
|
@@ -14934,6 +16491,46 @@ const analyticsSchema = z.object({
|
|
|
14934
16491
|
totalUsers: z.number(),
|
|
14935
16492
|
conversions: z.number()
|
|
14936
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
|
+
});
|
|
14937
16534
|
const geoSignalsSchema = z.object({
|
|
14938
16535
|
semanticHtml: z.boolean(),
|
|
14939
16536
|
structuredData: z.boolean(),
|
|
@@ -15107,6 +16704,7 @@ const crawlPageSnapshotSchema = z.object({
|
|
|
15107
16704
|
consecutiveErrors: z.number().int(),
|
|
15108
16705
|
recentP95Ms: z.number().optional()
|
|
15109
16706
|
}).optional(),
|
|
16707
|
+
accessBlock: accessBlockEvidenceSchema.optional(),
|
|
15110
16708
|
robotsTxt: z.object({
|
|
15111
16709
|
url: z.string().url(),
|
|
15112
16710
|
cache: z.enum([
|
|
@@ -15189,23 +16787,12 @@ const crawlPageSnapshotSchema = z.object({
|
|
|
15189
16787
|
}).optional()
|
|
15190
16788
|
}).optional(),
|
|
15191
16789
|
blocked: z.boolean().optional(),
|
|
16790
|
+
auditScope: z.enum(["full", "status"]).optional(),
|
|
16791
|
+
accessBlock: accessBlockEvidenceSchema.optional(),
|
|
15192
16792
|
contentAuditAllowed: z.boolean().optional(),
|
|
15193
16793
|
crawlDepth: z.number().int().optional(),
|
|
15194
16794
|
error: z.string().optional(),
|
|
15195
|
-
robotsTxt:
|
|
15196
|
-
url: z.string().url(),
|
|
15197
|
-
allowed: z.boolean().nullable(),
|
|
15198
|
-
availability: z.enum([
|
|
15199
|
-
"available",
|
|
15200
|
-
"absent",
|
|
15201
|
-
"access-blocked",
|
|
15202
|
-
"rate-limited",
|
|
15203
|
-
"unreachable"
|
|
15204
|
-
]),
|
|
15205
|
-
status: z.number().int().optional(),
|
|
15206
|
-
error: z.string().optional(),
|
|
15207
|
-
matchedLine: z.string().optional()
|
|
15208
|
-
}).optional(),
|
|
16795
|
+
robotsTxt: crawlRobotsEvidenceSchema.optional(),
|
|
15209
16796
|
title: z.string().optional(),
|
|
15210
16797
|
metaDescription: z.string().optional(),
|
|
15211
16798
|
canonical: z.string().url().optional(),
|
|
@@ -15260,8 +16847,16 @@ const crawlPageSnapshotSchema = z.object({
|
|
|
15260
16847
|
extractionError: z.string().optional(),
|
|
15261
16848
|
wordCount: z.number().int(),
|
|
15262
16849
|
contentExtraction: z.object({
|
|
15263
|
-
requested: z.enum([
|
|
15264
|
-
|
|
16850
|
+
requested: z.enum([
|
|
16851
|
+
"crawler",
|
|
16852
|
+
"defuddle",
|
|
16853
|
+
"readability"
|
|
16854
|
+
]),
|
|
16855
|
+
used: z.enum([
|
|
16856
|
+
"crawler",
|
|
16857
|
+
"defuddle",
|
|
16858
|
+
"readability"
|
|
16859
|
+
]),
|
|
15265
16860
|
fallback: z.boolean(),
|
|
15266
16861
|
fallbackReason: z.enum(["defuddle_error", "defuddle_empty"]).optional(),
|
|
15267
16862
|
fallbackDetail: z.string().optional(),
|
|
@@ -15395,7 +16990,9 @@ const crawlResponseObservationBaseSchema = z.object({
|
|
|
15395
16990
|
url: z.string().url(),
|
|
15396
16991
|
status: z.number().int(),
|
|
15397
16992
|
location: z.string().url().optional()
|
|
15398
|
-
})).optional()
|
|
16993
|
+
})).optional(),
|
|
16994
|
+
accessBlock: accessBlockEvidenceSchema.optional(),
|
|
16995
|
+
robotsTxt: crawlRobotsEvidenceSchema.optional()
|
|
15399
16996
|
});
|
|
15400
16997
|
const crawlRequestObservationSchema = z.union([
|
|
15401
16998
|
crawlResponseObservationBaseSchema.extend({ extraction: z.enum([
|
|
@@ -15412,20 +17009,7 @@ const crawlRequestObservationSchema = z.union([
|
|
|
15412
17009
|
outcome: z.literal("skipped"),
|
|
15413
17010
|
durationMs: z.number().optional(),
|
|
15414
17011
|
reason: z.enum(["robots-disallowed", "robots-deferred"]),
|
|
15415
|
-
robotsTxt:
|
|
15416
|
-
url: z.string().url(),
|
|
15417
|
-
allowed: z.boolean().nullable(),
|
|
15418
|
-
availability: z.enum([
|
|
15419
|
-
"available",
|
|
15420
|
-
"absent",
|
|
15421
|
-
"access-blocked",
|
|
15422
|
-
"rate-limited",
|
|
15423
|
-
"unreachable"
|
|
15424
|
-
]),
|
|
15425
|
-
status: z.number().int().optional(),
|
|
15426
|
-
error: z.string().optional(),
|
|
15427
|
-
matchedLine: z.string().optional()
|
|
15428
|
-
}),
|
|
17012
|
+
robotsTxt: crawlRobotsEvidenceSchema,
|
|
15429
17013
|
extraction: z.literal("not-applicable")
|
|
15430
17014
|
}),
|
|
15431
17015
|
z.object({
|
|
@@ -15484,6 +17068,7 @@ const crawlAiSignalsSchema = z.object({
|
|
|
15484
17068
|
status: z.number().int().optional(),
|
|
15485
17069
|
error: z.string().optional(),
|
|
15486
17070
|
sitemapUrls: z.array(z.string().url()),
|
|
17071
|
+
contentSignals: z.array(z.string()).optional(),
|
|
15487
17072
|
botAccess: z.array(z.object({
|
|
15488
17073
|
userAgent: z.string(),
|
|
15489
17074
|
allowed: z.boolean().nullable(),
|
|
@@ -15541,7 +17126,11 @@ const crawlSitemapDiscoverySchema = z.object({
|
|
|
15541
17126
|
urlsReturned: z.number().int().nonnegative(),
|
|
15542
17127
|
roots: z.array(z.object({
|
|
15543
17128
|
url: z.string().url(),
|
|
15544
|
-
source: z.enum([
|
|
17129
|
+
source: z.enum([
|
|
17130
|
+
"explicit",
|
|
17131
|
+
"robots-txt",
|
|
17132
|
+
"default-path"
|
|
17133
|
+
]),
|
|
15545
17134
|
dataStatus: z.enum([
|
|
15546
17135
|
"complete",
|
|
15547
17136
|
"partial",
|
|
@@ -15625,6 +17214,8 @@ const crawlConfigSchema = z.object({
|
|
|
15625
17214
|
"list",
|
|
15626
17215
|
"sitemap"
|
|
15627
17216
|
]),
|
|
17217
|
+
strategy: z.enum(["full", "health"]),
|
|
17218
|
+
sitemapUrl: z.string().url().optional(),
|
|
15628
17219
|
urls: z.array(z.string().url()),
|
|
15629
17220
|
maxPages: z.number().int(),
|
|
15630
17221
|
maxDepth: z.number().int(),
|
|
@@ -15659,6 +17250,7 @@ const crawlConfigSchema = z.object({
|
|
|
15659
17250
|
});
|
|
15660
17251
|
const crawlReportSummarySchema = z.object({
|
|
15661
17252
|
totalPages: z.number().int(),
|
|
17253
|
+
statusOnlyPages: z.number().int(),
|
|
15662
17254
|
indexablePages: z.number().int(),
|
|
15663
17255
|
nonIndexablePages: z.number().int(),
|
|
15664
17256
|
statusErrors: z.number().int(),
|
|
@@ -15749,6 +17341,17 @@ const crawlReportBaseSchema = z.object({
|
|
|
15749
17341
|
]),
|
|
15750
17342
|
configHash: z.string(),
|
|
15751
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(),
|
|
15752
17355
|
summary: crawlReportSummarySchema,
|
|
15753
17356
|
pages: z.array(crawlPageSnapshotSchema),
|
|
15754
17357
|
issues: z.array(crawlIssueSchema),
|
|
@@ -16064,14 +17667,6 @@ async function ctrUnderperformersReport(input, dependencies = defaultDependencie
|
|
|
16064
17667
|
};
|
|
16065
17668
|
}
|
|
16066
17669
|
//#endregion
|
|
16067
|
-
//#region packages/core/src/phrasing.ts
|
|
16068
|
-
function plural(count, singular, pluralLabel = `${singular}s`) {
|
|
16069
|
-
return count === 1 ? singular : pluralLabel;
|
|
16070
|
-
}
|
|
16071
|
-
function countLabel(count, singular, pluralLabel = `${singular}s`) {
|
|
16072
|
-
return `${count.toLocaleString("en-GB")} ${plural(count, singular, pluralLabel)}`;
|
|
16073
|
-
}
|
|
16074
|
-
//#endregion
|
|
16075
17670
|
//#region packages/core/src/analyze/diagnosis-priorities.ts
|
|
16076
17671
|
function topDelta(report) {
|
|
16077
17672
|
const top = report.items[0];
|
|
@@ -16098,14 +17693,14 @@ function buildDiagnosisPriorities(input) {
|
|
|
16098
17693
|
const topGroup = input.decay.groups[0];
|
|
16099
17694
|
priorities.push({
|
|
16100
17695
|
label: "Refresh decaying content",
|
|
16101
|
-
reason: topGroup ? `${input.decay.selection.eligibleRows
|
|
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.`,
|
|
16102
17697
|
action: topGroup ? topGroup.recommendation : "Start with declines that continued outside the update window. Check indexability first, then ranking and CTR causes.",
|
|
16103
17698
|
confidence: input.decay.dataStatus === "partial" ? "low" : "medium"
|
|
16104
17699
|
});
|
|
16105
17700
|
}
|
|
16106
17701
|
if (input.cannibal.items.length) priorities.push({
|
|
16107
17702
|
label: "Review multi-URL query candidates",
|
|
16108
|
-
reason: `${input.cannibal.selection.eligibleClusters
|
|
17703
|
+
reason: `${countLabel(input.cannibal.selection.eligibleClusters, "multi-URL query candidate")} found.`,
|
|
16109
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.",
|
|
16110
17705
|
confidence: "low"
|
|
16111
17706
|
});
|
|
@@ -16123,7 +17718,7 @@ function buildDiagnosisPriorities(input) {
|
|
|
16123
17718
|
const top = input.striking.items[0];
|
|
16124
17719
|
priorities.push({
|
|
16125
17720
|
label: "Investigate striking-distance candidates",
|
|
16126
|
-
reason: `${input.striking.items.length
|
|
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.`,
|
|
16127
17722
|
action: top?.recommendation.action ?? "Check technical state, query coverage, competing URLs, and relevant internal links before choosing a change.",
|
|
16128
17723
|
confidence: input.striking.source.possiblyTruncated ? "low" : top?.recommendation.confidence ?? "low"
|
|
16129
17724
|
});
|
|
@@ -17956,8 +19551,7 @@ function analyzeQuickWinsFromRows(input) {
|
|
|
17956
19551
|
selected.selection.belowMinimumRows++;
|
|
17957
19552
|
continue;
|
|
17958
19553
|
}
|
|
17959
|
-
const
|
|
17960
|
-
const excludedTargetRows = selected.rows.filter((candidate) => samePageUrl(candidate.keys[1] ?? "", targetUrl)).length;
|
|
19554
|
+
const excludedTargetRows = benchmarkContext.urlRowCount(row);
|
|
17961
19555
|
const benchmark = benchmarkContext.forUrl(row);
|
|
17962
19556
|
if (row.ctr >= benchmark.ctr) {
|
|
17963
19557
|
selected.selection.atOrAboveTargetRows++;
|
|
@@ -18151,7 +19745,7 @@ async function quickWinsReport(input, dependencies = defaultDependencies$8) {
|
|
|
18151
19745
|
brandTerms: input.brandTerms,
|
|
18152
19746
|
includeBrand: input.includeBrand
|
|
18153
19747
|
});
|
|
18154
|
-
const verificationRequested = input.verifyContent === true || input.verifyLimit !== void 0;
|
|
19748
|
+
const verificationRequested = input.verifyContent === true || input.verifyContent !== false && input.verifyLimit !== void 0;
|
|
18155
19749
|
const verificationItems = verificationRequested ? analysis.items.slice(0, verifyLimit) : [];
|
|
18156
19750
|
const fetches = /* @__PURE__ */ new Map();
|
|
18157
19751
|
const cachedFetch = (url, options) => {
|
|
@@ -18250,7 +19844,7 @@ async function quickWinsReport(input, dependencies = defaultDependencies$8) {
|
|
|
18250
19844
|
repeatedQueryGroups: groups.length,
|
|
18251
19845
|
templatePatterns: templateActions.length,
|
|
18252
19846
|
brandFiltering: input.includeBrand ? "included" : "excluded",
|
|
18253
|
-
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
|
|
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.`
|
|
18254
19848
|
},
|
|
18255
19849
|
caveats: [
|
|
18256
19850
|
`Date window: ${range.startDate} to ${range.endDate}, using final GSC data where available.`,
|
|
@@ -18541,7 +20135,7 @@ function templatesFromGroups(groups) {
|
|
|
18541
20135
|
function verdict$1(analysis) {
|
|
18542
20136
|
if (analysis.dataStatus === "empty") return "Search Console returned no retained query/page rows for this date window.";
|
|
18543
20137
|
if (analysis.dataStatus === "filtered") return "No retained query/page rows met the report filters.";
|
|
18544
|
-
return `${analysis.summary.eligibleRows
|
|
20138
|
+
return `${countLabel(analysis.summary.eligibleRows, "eligible query/page row")} found; ${countLabel(analysis.summary.returnedRows, "row")} returned for review.`;
|
|
18545
20139
|
}
|
|
18546
20140
|
async function strikingDistance(input, dependencies = defaultDependencies$7) {
|
|
18547
20141
|
const days = reportDays(input.days);
|
|
@@ -18563,7 +20157,7 @@ async function strikingDistance(input, dependencies = defaultDependencies$7) {
|
|
|
18563
20157
|
includeBrand: input.includeBrand
|
|
18564
20158
|
});
|
|
18565
20159
|
const verifyLimit = boundedInteger$2(input.verifyLimit, DEFAULT_VERIFY_LIMIT, 0, analysis.limit);
|
|
18566
|
-
const verificationRequested = input.verifyContent === true || input.verifyLimit !== void 0;
|
|
20160
|
+
const verificationRequested = input.verifyContent === true || input.verifyContent !== false && input.verifyLimit !== void 0;
|
|
18567
20161
|
const attempted = verificationRequested ? Math.min(verifyLimit, analysis.items.length) : 0;
|
|
18568
20162
|
const items = await Promise.all(analysis.items.map(async (item, index) => {
|
|
18569
20163
|
if (index >= attempted) return item;
|
|
@@ -21141,6 +22735,7 @@ function topCrawlRecommendations(items) {
|
|
|
21141
22735
|
}
|
|
21142
22736
|
//#endregion
|
|
21143
22737
|
//#region packages/core/src/analyze/monitoring/crawl-store.ts
|
|
22738
|
+
const CRAWL_RUN_RETENTION = 20;
|
|
21144
22739
|
function toRun(row) {
|
|
21145
22740
|
return {
|
|
21146
22741
|
id: row.id,
|
|
@@ -21212,34 +22807,52 @@ function insertCrawlRun(run, pages, recommendations = []) {
|
|
|
21212
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));
|
|
21213
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);
|
|
21214
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);
|
|
21215
22818
|
}
|
|
21216
22819
|
function latestCrawlSummaries(site, limit = 5) {
|
|
21217
|
-
return getDb().prepare(`WITH
|
|
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 (
|
|
21218
22828
|
SELECT
|
|
21219
|
-
run_id,
|
|
21220
|
-
COALESCE(SUM(CASE WHEN status >= 400 THEN 1 ELSE 0 END), 0) AS status_errors,
|
|
21221
|
-
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
|
|
21222
22832
|
FROM crawl_pages
|
|
21223
|
-
|
|
22833
|
+
INNER JOIN recent_runs ON recent_runs.id = crawl_pages.run_id
|
|
22834
|
+
GROUP BY crawl_pages.run_id
|
|
21224
22835
|
),
|
|
21225
22836
|
recommendation_counts AS (
|
|
21226
22837
|
SELECT
|
|
21227
|
-
run_id,
|
|
22838
|
+
crawl_recommendations.run_id,
|
|
21228
22839
|
COUNT(*) AS recommendation_count,
|
|
21229
|
-
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
|
|
21230
22841
|
FROM crawl_recommendations
|
|
21231
|
-
|
|
22842
|
+
INNER JOIN recent_runs ON recent_runs.id = crawl_recommendations.run_id
|
|
22843
|
+
GROUP BY crawl_recommendations.run_id
|
|
21232
22844
|
),
|
|
21233
22845
|
recommendation_rank AS (
|
|
21234
22846
|
SELECT
|
|
21235
22847
|
crawl_recommendations.*,
|
|
21236
22848
|
ROW_NUMBER() OVER (
|
|
21237
|
-
PARTITION BY run_id
|
|
22849
|
+
PARTITION BY crawl_recommendations.run_id
|
|
21238
22850
|
ORDER BY
|
|
21239
|
-
CASE severity WHEN 'high' THEN 3 WHEN 'medium' THEN 2 ELSE 1 END DESC,
|
|
21240
|
-
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
|
|
21241
22853
|
) AS rank
|
|
21242
22854
|
FROM crawl_recommendations
|
|
22855
|
+
INNER JOIN recent_runs ON recent_runs.id = crawl_recommendations.run_id
|
|
21243
22856
|
)
|
|
21244
22857
|
SELECT
|
|
21245
22858
|
crawl_runs.*,
|
|
@@ -21251,14 +22864,12 @@ function latestCrawlSummaries(site, limit = 5) {
|
|
|
21251
22864
|
top.title AS top_recommendation_title,
|
|
21252
22865
|
top.action AS top_recommendation_action,
|
|
21253
22866
|
top.severity AS top_recommendation_severity
|
|
21254
|
-
FROM crawl_runs
|
|
22867
|
+
FROM recent_runs AS crawl_runs
|
|
21255
22868
|
LEFT JOIN page_counts ON page_counts.run_id = crawl_runs.id
|
|
21256
22869
|
LEFT JOIN recommendation_counts ON recommendation_counts.run_id = crawl_runs.id
|
|
21257
22870
|
LEFT JOIN recommendation_rank top ON top.run_id = crawl_runs.id AND top.rank = 1
|
|
21258
|
-
WHERE crawl_runs.site_url = ?
|
|
21259
22871
|
GROUP BY crawl_runs.id
|
|
21260
|
-
ORDER BY crawl_runs.created_at DESC
|
|
21261
|
-
LIMIT ?`).all(site, limit).map((row) => ({
|
|
22872
|
+
ORDER BY crawl_runs.created_at DESC`).all(site, limit).map((row) => ({
|
|
21262
22873
|
...toRun(row),
|
|
21263
22874
|
statusErrors: row.status_errors,
|
|
21264
22875
|
nonIndexable: row.non_indexable,
|
|
@@ -22320,6 +23931,7 @@ function indexWatchFailureItem(input) {
|
|
|
22320
23931
|
//#endregion
|
|
22321
23932
|
//#region packages/core/src/analyze/monitoring/index-watch.ts
|
|
22322
23933
|
const MAX_DIRECT_INSPECTIONS = 100;
|
|
23934
|
+
const INDEX_WATCH_ATTEMPT_RETENTION = 20;
|
|
22323
23935
|
function previousFromRow(row) {
|
|
22324
23936
|
if (!row.inspected_at) return void 0;
|
|
22325
23937
|
return {
|
|
@@ -22334,6 +23946,36 @@ function previousFromRow(row) {
|
|
|
22334
23946
|
lastCrawlTime: row.last_crawl_time ?? void 0
|
|
22335
23947
|
};
|
|
22336
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
|
+
}
|
|
22337
23979
|
const defaultDependencies$2 = {
|
|
22338
23980
|
inspectUrl,
|
|
22339
23981
|
now: () => /* @__PURE__ */ new Date(),
|
|
@@ -22347,12 +23989,14 @@ const defaultDependencies$2 = {
|
|
|
22347
23989
|
return row ? previousFromRow(row) : void 0;
|
|
22348
23990
|
},
|
|
22349
23991
|
insert(item) {
|
|
22350
|
-
getDb()
|
|
23992
|
+
const db = getDb();
|
|
23993
|
+
db.prepare(`INSERT INTO index_watch_snapshots
|
|
22351
23994
|
(id, site_url, root_site_url, property_site_url, url, verdict,
|
|
22352
23995
|
coverage_state, indexing_state, robots_txt_state, page_fetch_state,
|
|
22353
23996
|
google_canonical, user_canonical, last_crawl_time, inspection_status,
|
|
22354
23997
|
error_code, error_message, inspected_at)
|
|
22355
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);
|
|
22356
24000
|
}
|
|
22357
24001
|
}
|
|
22358
24002
|
};
|
|
@@ -22896,6 +24540,7 @@ async function indexMonitor(input) {
|
|
|
22896
24540
|
}
|
|
22897
24541
|
//#endregion
|
|
22898
24542
|
//#region packages/core/src/analyze/monitoring/link-recover-store.ts
|
|
24543
|
+
const LINK_RECOVER_RUN_RETENTION = 20;
|
|
22899
24544
|
function toSummary(row) {
|
|
22900
24545
|
return {
|
|
22901
24546
|
id: row.id,
|
|
@@ -22938,6 +24583,14 @@ function insertLinkRecoverRun(report) {
|
|
|
22938
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);
|
|
22939
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);
|
|
22940
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);
|
|
22941
24594
|
return id;
|
|
22942
24595
|
}
|
|
22943
24596
|
function latestLinkRecoverSummary(site) {
|
|
@@ -23852,9 +25505,11 @@ function cacheSet(report, now) {
|
|
|
23852
25505
|
...report,
|
|
23853
25506
|
raw: void 0
|
|
23854
25507
|
};
|
|
25508
|
+
const reportJson = JSON.stringify(stored);
|
|
23855
25509
|
getDb().prepare(`INSERT OR REPLACE INTO performance_reports
|
|
23856
25510
|
(id, url, strategy, report_json, created_at, expires_at)
|
|
23857
|
-
VALUES (?, ?, ?, ?, ?, ?)`).run(report.id, report.url, report.strategy,
|
|
25511
|
+
VALUES (?, ?, ?, ?, ?, ?)`).run(report.id, report.url, report.strategy, reportJson, Date.parse(report.generatedAt), now + CACHE_TTL_MS);
|
|
25512
|
+
noteCacheWrite(Buffer.byteLength(reportJson));
|
|
23858
25513
|
}
|
|
23859
25514
|
async function fallbackReport(input) {
|
|
23860
25515
|
const fetched = await fetchPage(input.url, {
|
|
@@ -25403,7 +27058,7 @@ function clusterRecommendation(input) {
|
|
|
25403
27058
|
return `This ${clusterName} cluster already gets clicks. Use it to refine page structure, FAQs, and internal anchors rather than creating duplicate pages.`;
|
|
25404
27059
|
}
|
|
25405
27060
|
function clusterSummary(input) {
|
|
25406
|
-
return `${input.queries
|
|
27061
|
+
return `${countLabel(input.queries, "query", "queries")}, ${countLabel(input.impressions, "impression")}, ${countLabel(input.clicks, "click")}, average position ${input.position.toFixed(1)}.`;
|
|
25407
27062
|
}
|
|
25408
27063
|
function clusterLabel(rows) {
|
|
25409
27064
|
return [...rows].sort((a, b) => b.impressions - a.impressions || compareQueryClusterText(a.query, b.query))[0]?.query ?? "";
|
|
@@ -25876,7 +27531,7 @@ function contentOpportunityBullets(report) {
|
|
|
25876
27531
|
if (report.cannibalization.selection.suppressedQueries) bullets.push(cannibalSuppressionLine(report));
|
|
25877
27532
|
if (report.summary.strikingDistanceItems) {
|
|
25878
27533
|
const top = report.strikingDistance.items[0];
|
|
25879
|
-
bullets.push(top ? `${report.summary.strikingDistanceItems
|
|
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.`);
|
|
25880
27535
|
}
|
|
25881
27536
|
if (!skippedReason(report, "quick-win opportunities") && report.summary.quickWinItems) {
|
|
25882
27537
|
const top = report.quickWins.items[0];
|
|
@@ -26357,10 +28012,10 @@ function applyVerification(item, coverage) {
|
|
|
26357
28012
|
function verdict(report) {
|
|
26358
28013
|
if (report.dataStatus === "empty") return "Google Search Console returned no retained query/page rows for this window.";
|
|
26359
28014
|
if (report.dataStatus === "filtered") return "No pages met the position, brand, and minimum-impression criteria.";
|
|
26360
|
-
if (report.technical > 0) return `${report.eligible
|
|
26361
|
-
if (report.failed > 0) return `${report.eligible
|
|
26362
|
-
if (report.content > 0) return `${report.eligible
|
|
26363
|
-
return `${report.eligible
|
|
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.`;
|
|
26364
28019
|
}
|
|
26365
28020
|
function reportRecommendations(items) {
|
|
26366
28021
|
const first = items[0];
|
|
@@ -26407,7 +28062,7 @@ async function secondPage(input, dependencies = defaultDependencies) {
|
|
|
26407
28062
|
});
|
|
26408
28063
|
const generatedAt = now.toISOString();
|
|
26409
28064
|
const warnings = [];
|
|
26410
|
-
const verificationRequested = input.verifyContent === true || input.verifyLimit !== void 0;
|
|
28065
|
+
const verificationRequested = input.verifyContent === true || input.verifyContent !== false && input.verifyLimit !== void 0;
|
|
26411
28066
|
const verificationItems = verificationRequested ? analysis.items.slice(0, verifyLimit) : [];
|
|
26412
28067
|
for (const item of verificationItems) {
|
|
26413
28068
|
const coverage = await verifyQueryContent({
|
|
@@ -27155,7 +28810,7 @@ async function refreshPrioritiesWorkflow(input) {
|
|
|
27155
28810
|
const diagnosis = await diagnoseProperty({
|
|
27156
28811
|
...input,
|
|
27157
28812
|
verifyContent: input.verifyContent ?? true,
|
|
27158
|
-
verifyLimit: input.verifyLimit ?? Math.min(input.limit ?? 5, 5)
|
|
28813
|
+
verifyLimit: input.verifyContent === false ? void 0 : input.verifyLimit ?? Math.min(input.limit ?? 5, 5)
|
|
27159
28814
|
});
|
|
27160
28815
|
const range = defaultDateRange$1(input.days ?? 28);
|
|
27161
28816
|
const analytics = await fetchLandingPageValues({
|
|
@@ -28249,11 +29904,76 @@ function ga4MatchReason(match, site) {
|
|
|
28249
29904
|
return `Matched Google Analytics web stream ${stream} (${match.defaultUri}): its hostname ${location} ${site}.`;
|
|
28250
29905
|
}
|
|
28251
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
|
|
28252
29971
|
//#region packages/core/src/logger.ts
|
|
28253
29972
|
let loggerInstance;
|
|
28254
29973
|
function createLogger(runId) {
|
|
28255
29974
|
if (loggerInstance) return loggerInstance.child({ runId });
|
|
28256
29975
|
const paths = ensureSeoCliDirs();
|
|
29976
|
+
pruneLogs({ directory: paths.logDir });
|
|
28257
29977
|
const date = (/* @__PURE__ */ new Date()).toISOString().slice(0, 10);
|
|
28258
29978
|
const fileStream = createWriteStream(join(paths.logDir, `${date}.log`), { flags: "a" });
|
|
28259
29979
|
const destination = process.stderr.isTTY ? pretty({
|
|
@@ -28321,6 +30041,7 @@ function parseSemicolonCsv(text) {
|
|
|
28321
30041
|
//#endregion
|
|
28322
30042
|
//#region packages/core/src/providers/semrush/cache.ts
|
|
28323
30043
|
const BASE_URL = "https://api.semrush.com/";
|
|
30044
|
+
const MAX_RESPONSE_BYTES = 10 * 1024 * 1024;
|
|
28324
30045
|
function estimateUsd(units) {
|
|
28325
30046
|
return units / 1e3 * .05;
|
|
28326
30047
|
}
|
|
@@ -28350,14 +30071,17 @@ async function cachedSemrushCall(endpoint, params, map, ttlMs, creditsPerLine, r
|
|
|
28350
30071
|
const url = new URL(BASE_URL);
|
|
28351
30072
|
url.search = new URLSearchParams(requestParams).toString();
|
|
28352
30073
|
const response = await fetch$1(url);
|
|
28353
|
-
const text = await response
|
|
30074
|
+
const text = await readBoundedResponseText(response, MAX_RESPONSE_BYTES, "Semrush response");
|
|
28354
30075
|
if (!response.ok || text.startsWith("ERROR ::")) throw new Error(text || `Semrush request failed with ${response.status}.`);
|
|
28355
30076
|
const rows = parseSemicolonCsv(text);
|
|
28356
30077
|
const data = map(rows);
|
|
28357
30078
|
const credits = Math.max(0, rows.length - 1) * creditsPerLine;
|
|
30079
|
+
const requestJson = JSON.stringify(requestParams);
|
|
30080
|
+
const responseJson = JSON.stringify(data);
|
|
28358
30081
|
db.prepare(`INSERT OR REPLACE INTO semrush_cache
|
|
28359
30082
|
(endpoint, query_hash, request_json, response_json, credits_used, fetched_at, expires_at)
|
|
28360
|
-
VALUES (?, ?, ?, ?, ?, ?, ?)`).run(endpoint, queryHash,
|
|
30083
|
+
VALUES (?, ?, ?, ?, ?, ?, ?)`).run(endpoint, queryHash, requestJson, responseJson, credits, Date.now(), Date.now() + ttlMs);
|
|
30084
|
+
noteCacheWrite(Buffer.byteLength(requestJson) + Buffer.byteLength(responseJson));
|
|
28361
30085
|
return {
|
|
28362
30086
|
data,
|
|
28363
30087
|
usage: {
|
|
@@ -28554,9 +30278,6 @@ var JsonFileStorageAdapter = class {
|
|
|
28554
30278
|
};
|
|
28555
30279
|
const localCredentialsProvider = new LocalCredentialsProvider();
|
|
28556
30280
|
//#endregion
|
|
28557
|
-
//#region packages/core/src/version.ts
|
|
28558
|
-
const SEO_VERSION = version;
|
|
28559
|
-
//#endregion
|
|
28560
30281
|
//#region packages/core/src/telemetry.ts
|
|
28561
30282
|
const TELEMETRY_ENDPOINT = "https://seoskill.dev/api/t";
|
|
28562
30283
|
const TELEMETRY_SCHEMA_VERSION = 1;
|
|
@@ -28953,6 +30674,6 @@ function isoDate(daysAgo = 0) {
|
|
|
28953
30674
|
return date.toISOString().slice(0, 10);
|
|
28954
30675
|
}
|
|
28955
30676
|
//#endregion
|
|
28956
|
-
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 };
|
|
28957
30678
|
|
|
28958
30679
|
//# sourceMappingURL=index.js.map
|