seo 0.2.8 → 0.2.9
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/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-DkWLBABR.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 +2287 -582
- package/dist/index.js.map +1 -1
- package/dist/mcp.d.ts +25 -4
- package/dist/mcp.js +2494 -598
- package/dist/mcp.js.map +1 -1
- package/dist/{package-BDez60C3.js → package-C51oI-6o.js} +2 -2
- package/dist/{package-BDez60C3.js.map → package-C51oI-6o.js.map} +1 -1
- package/package.json +3 -3
- package/skills/seo/SKILL.md +31 -5
- package/dist/dist-B8eKKp3B.js +0 -560
- package/dist/dist-B8eKKp3B.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;
|
|
@@ -7124,7 +7968,7 @@ async function collectAgentDiscovery(input) {
|
|
|
7124
7968
|
fetch: input.fetch,
|
|
7125
7969
|
signal: input.signal
|
|
7126
7970
|
}))));
|
|
7127
|
-
const [agentSkills, llmsTxt, routeManifest] = await Promise.all([
|
|
7971
|
+
const [agentSkills, llmsTxt, routeManifest, endpointDiscovery] = await Promise.all([
|
|
7128
7972
|
inspectAgentSkills({
|
|
7129
7973
|
...input,
|
|
7130
7974
|
origin
|
|
@@ -7139,6 +7983,10 @@ async function collectAgentDiscovery(input) {
|
|
|
7139
7983
|
origin,
|
|
7140
7984
|
pages,
|
|
7141
7985
|
observations
|
|
7986
|
+
}),
|
|
7987
|
+
inspectAgentEndpoints({
|
|
7988
|
+
...input,
|
|
7989
|
+
origin
|
|
7142
7990
|
})
|
|
7143
7991
|
]);
|
|
7144
7992
|
const qZero = await fetchRepresentation({
|
|
@@ -7210,6 +8058,7 @@ async function collectAgentDiscovery(input) {
|
|
|
7210
8058
|
routeManifest,
|
|
7211
8059
|
agentSkills,
|
|
7212
8060
|
llmsTxt,
|
|
8061
|
+
endpointDiscovery,
|
|
7213
8062
|
contentSignals: {
|
|
7214
8063
|
htmlValues: [...new Set(htmlContentSignals)].sort(),
|
|
7215
8064
|
markdownValues: [...new Set(markdownContentSignals)].sort(),
|
|
@@ -7365,7 +8214,59 @@ function markdownChecks(discovery) {
|
|
|
7365
8214
|
})
|
|
7366
8215
|
];
|
|
7367
8216
|
}
|
|
7368
|
-
function
|
|
8217
|
+
function contentSignalsCheck(discovery, robotsContentSignals) {
|
|
8218
|
+
const signals = discovery.contentSignals;
|
|
8219
|
+
const robotsDeclared = robotsContentSignals ?? [];
|
|
8220
|
+
const robotsCollected = robotsContentSignals !== void 0;
|
|
8221
|
+
const headersObserved = signals.htmlValues.length > 0 || signals.markdownValues.length > 0;
|
|
8222
|
+
const headersConsistent = signals.consistent === true;
|
|
8223
|
+
const evidence = {
|
|
8224
|
+
robotsDirectives: robotsDeclared,
|
|
8225
|
+
robotsDirectivesCollected: robotsCollected,
|
|
8226
|
+
...signals
|
|
8227
|
+
};
|
|
8228
|
+
if (headersConsistent) return check$1("discovery", {
|
|
8229
|
+
id: "content-signals",
|
|
8230
|
+
status: "pass",
|
|
8231
|
+
title: "Every response publishes one explicit Content Signals policy",
|
|
8232
|
+
plainEnglish: `The same ${signals.htmlValues[0]} policy was observed on every evaluated HTML and Markdown response${robotsDeclared.length ? ", and robots.txt declares a Content-Signal directive" : ""}.`,
|
|
8233
|
+
action: "Keep the same Content-Signal value on robots.txt, HTML, and Markdown responses when the policy changes.",
|
|
8234
|
+
evidence
|
|
8235
|
+
});
|
|
8236
|
+
if (robotsDeclared.length > 0 && !headersObserved) return check$1("discovery", {
|
|
8237
|
+
id: "content-signals",
|
|
8238
|
+
status: "pass",
|
|
8239
|
+
title: "robots.txt declares a Content Signals policy",
|
|
8240
|
+
plainEnglish: `robots.txt declares ${robotsDeclared.length === 1 ? "one Content-Signal directive" : `${robotsDeclared.length} Content-Signal directives`}. No response-header variant was observed, which is fine because robots.txt is the primary published location.`,
|
|
8241
|
+
action: "Keep the robots.txt directive as the single policy, or mirror the same value in response headers if you add them.",
|
|
8242
|
+
evidence
|
|
8243
|
+
});
|
|
8244
|
+
if (robotsDeclared.length > 0 || headersObserved) return check$1("discovery", {
|
|
8245
|
+
id: "content-signals",
|
|
8246
|
+
status: "warning",
|
|
8247
|
+
title: "Content Signals are published inconsistently",
|
|
8248
|
+
plainEnglish: `${signals.missingHtmlPages} HTML responses and ${signals.missingMarkdownPages} Markdown responses were missing the header, the observed values differed, or the header policy does not line up with robots.txt.`,
|
|
8249
|
+
action: "Choose the site policy deliberately and publish the same Content-Signal value everywhere it appears.",
|
|
8250
|
+
evidence
|
|
8251
|
+
});
|
|
8252
|
+
if (signals.consistent === null && !robotsCollected) return check$1("discovery", {
|
|
8253
|
+
id: "content-signals",
|
|
8254
|
+
status: "unknown",
|
|
8255
|
+
title: "Content Signals evidence was not collected",
|
|
8256
|
+
plainEnglish: "No pages were evaluated for the header and robots.txt directive evidence is unavailable in this crawl.",
|
|
8257
|
+
action: "Re-run the report with a URL so robots.txt and response headers can be checked.",
|
|
8258
|
+
evidence
|
|
8259
|
+
});
|
|
8260
|
+
return check$1("discovery", {
|
|
8261
|
+
id: "content-signals",
|
|
8262
|
+
status: "info",
|
|
8263
|
+
title: "No Content Signals policy is published",
|
|
8264
|
+
plainEnglish: "Neither robots.txt nor the evaluated responses declare a Content-Signal policy. Publishing one is optional.",
|
|
8265
|
+
action: "Add a Content-Signal directive to robots.txt only when you want to state an explicit search, ai-input, or ai-train preference.",
|
|
8266
|
+
evidence
|
|
8267
|
+
});
|
|
8268
|
+
}
|
|
8269
|
+
function discoveryChecks(discovery, robotsContentSignals) {
|
|
7369
8270
|
const skills = discovery.agentSkills;
|
|
7370
8271
|
const skillsValid = skills.validIndex && skills.skills.length > 0 && skills.skills.every((skill) => skill.sameOrigin && skill.status === 200 && /^\s*text\/markdown\b/iu.test(skill.contentType ?? "") && Boolean(skill.cors) && skill.digestMatches === true && skill.frontmatterValid === true) && Boolean(skills.cors);
|
|
7371
8272
|
const llms = discovery.llmsTxt;
|
|
@@ -7409,14 +8310,7 @@ function discoveryChecks(discovery) {
|
|
|
7409
8310
|
...llms.missingCrawlRoutes
|
|
7410
8311
|
].slice(0, 25)
|
|
7411
8312
|
}),
|
|
7412
|
-
|
|
7413
|
-
id: "content-signals",
|
|
7414
|
-
status: discovery.contentSignals.consistent === null ? "unknown" : discovery.contentSignals.consistent ? "pass" : "warning",
|
|
7415
|
-
title: discovery.contentSignals.consistent ? "HTML and Markdown publish one explicit Content Signals policy" : "Content Signals are missing or inconsistent across representations",
|
|
7416
|
-
plainEnglish: discovery.contentSignals.consistent ? `The same ${discovery.contentSignals.htmlValues[0]} policy was observed on every evaluated HTML and Markdown response.` : `${discovery.contentSignals.missingHtmlPages} HTML responses and ${discovery.contentSignals.missingMarkdownPages} Markdown responses were missing the header, or the observed values differed.`,
|
|
7417
|
-
action: "Choose the site policy deliberately and return the same Content-Signal value on HTML and Markdown responses.",
|
|
7418
|
-
evidence: discovery.contentSignals
|
|
7419
|
-
}),
|
|
8313
|
+
contentSignalsCheck(discovery, robotsContentSignals),
|
|
7420
8314
|
check$1("discovery", {
|
|
7421
8315
|
id: "route-manifest",
|
|
7422
8316
|
status: !discovery.routeManifest.valid ? "info" : discovery.routeManifest.missingHtmlRoutes.length === 0 && discovery.routeManifest.missingMarkdownRoutes.length === 0 && discovery.routeManifest.orphanMarkdownRoutes.length === 0 ? "pass" : "warning",
|
|
@@ -7427,6 +8321,126 @@ function discoveryChecks(discovery) {
|
|
|
7427
8321
|
})
|
|
7428
8322
|
];
|
|
7429
8323
|
}
|
|
8324
|
+
function optionalEndpointCheck(endpoints, copy) {
|
|
8325
|
+
const found = endpoints.filter((endpoint) => endpoint.exists);
|
|
8326
|
+
const evidence = { endpoints };
|
|
8327
|
+
if (found.length === 0) return check$1("endpoints", {
|
|
8328
|
+
id: copy.id,
|
|
8329
|
+
status: "info",
|
|
8330
|
+
title: `${copy.subject} is not published`,
|
|
8331
|
+
plainEnglish: copy.absent,
|
|
8332
|
+
action: copy.absentAction,
|
|
8333
|
+
evidence
|
|
8334
|
+
});
|
|
8335
|
+
const valid = found.filter((endpoint) => endpoint.validJson !== false && (endpoint.missingFields ?? []).length === 0);
|
|
8336
|
+
if (valid.length > 0) return check$1("endpoints", {
|
|
8337
|
+
id: copy.id,
|
|
8338
|
+
status: "pass",
|
|
8339
|
+
title: `${copy.subject} is published and parses`,
|
|
8340
|
+
plainEnglish: `${valid[0]?.url} returned ${valid[0]?.contentType ?? "a response"} with the expected structure. ${copy.requiredNote}`,
|
|
8341
|
+
action: copy.presentAction,
|
|
8342
|
+
evidence,
|
|
8343
|
+
urls: valid.map((endpoint) => endpoint.url)
|
|
8344
|
+
});
|
|
8345
|
+
const broken = found[0];
|
|
8346
|
+
return check$1("endpoints", {
|
|
8347
|
+
id: copy.id,
|
|
8348
|
+
status: "warning",
|
|
8349
|
+
title: `${copy.subject} is published but needs review`,
|
|
8350
|
+
plainEnglish: `${broken?.url} responded, but ${broken?.validJson === false ? "the body is not valid JSON" : `required fields are missing: ${(broken?.missingFields ?? []).join(", ")}`}. ${copy.requiredNote}`,
|
|
8351
|
+
action: copy.presentAction,
|
|
8352
|
+
evidence,
|
|
8353
|
+
urls: found.map((endpoint) => endpoint.url)
|
|
8354
|
+
});
|
|
8355
|
+
}
|
|
8356
|
+
function endpointChecks(discovery) {
|
|
8357
|
+
const endpointDiscovery = discovery.endpointDiscovery;
|
|
8358
|
+
if (!endpointDiscovery) return [check$1("endpoints", {
|
|
8359
|
+
id: "agent-endpoints-evidence",
|
|
8360
|
+
status: "unknown",
|
|
8361
|
+
title: "Agent endpoint evidence was not collected",
|
|
8362
|
+
plainEnglish: "This saved crawl predates the Link header and well-known endpoint probes, so that evidence is unavailable.",
|
|
8363
|
+
action: "Run the agent-readiness report with a URL to collect endpoint evidence."
|
|
8364
|
+
})];
|
|
8365
|
+
const byId = new Map(endpointDiscovery.endpoints.map((endpoint) => [endpoint.id, endpoint]));
|
|
8366
|
+
const pick = (...ids) => ids.map((id) => byId.get(id)).filter((endpoint) => Boolean(endpoint));
|
|
8367
|
+
const linkHeader = endpointDiscovery.linkHeader;
|
|
8368
|
+
const advertisedRels = [...linkHeader.registeredRels, ...linkHeader.emergingRels];
|
|
8369
|
+
return [
|
|
8370
|
+
check$1("endpoints", {
|
|
8371
|
+
id: "link-headers",
|
|
8372
|
+
status: linkHeader.error ? "unknown" : advertisedRels.length > 0 ? "pass" : "info",
|
|
8373
|
+
title: linkHeader.error ? "Link header evidence was not collected" : advertisedRels.length > 0 ? "The start page Link header advertises agent resources" : linkHeader.entries.length > 0 ? "Link headers exist but advertise no agent resources" : "No Link header advertises agent resources",
|
|
8374
|
+
plainEnglish: linkHeader.error ? `The start page request failed: ${linkHeader.error}` : advertisedRels.length > 0 ? `${linkHeader.entries.length} Link header ${linkHeader.entries.length === 1 ? "entry was" : "entries were"} observed. Registered relation types: ${linkHeader.registeredRels.join(", ") || "none"}. Emerging relation types: ${linkHeader.emergingRels.join(", ") || "none"}.` : linkHeader.entries.length > 0 ? `${linkHeader.entries.length} Link header ${linkHeader.entries.length === 1 ? "entry was" : "entries were"} observed, but none used a recognised agent relation type such as api-catalog, service-desc, service-doc, describedby, llms-txt, or agent-skills.` : "The start page response includes no Link header. Advertising machine-readable resources this way is optional.",
|
|
8375
|
+
action: "Advertise real machine-readable resources with Link response headers. Use registered relation types such as api-catalog and service-desc, and emerging types such as llms-txt only when the target resolves.",
|
|
8376
|
+
evidence: linkHeader
|
|
8377
|
+
}),
|
|
8378
|
+
optionalEndpointCheck(pick("mcp-server-card", "mcp-server-cards"), {
|
|
8379
|
+
id: "mcp-server-card",
|
|
8380
|
+
subject: "An MCP server card",
|
|
8381
|
+
absent: "Neither /.well-known/mcp/server-card.json nor server-cards.json returned a machine-readable document. The server card format is a draft MCP proposal, and a content site does not need one.",
|
|
8382
|
+
absentAction: "Publish a server card only when the site exposes a real public MCP endpoint.",
|
|
8383
|
+
presentAction: "Keep serverInfo, the transport endpoint, and capabilities accurate for the deployed MCP server.",
|
|
8384
|
+
requiredNote: "The server card format is a draft MCP proposal, so this is a structural observation rather than a compliance verdict."
|
|
8385
|
+
}),
|
|
8386
|
+
optionalEndpointCheck(pick("a2a-agent-card"), {
|
|
8387
|
+
id: "a2a-agent-card",
|
|
8388
|
+
subject: "An A2A agent card",
|
|
8389
|
+
absent: "/.well-known/agent-card.json returned no machine-readable document. Agent cards matter only for sites that expose an agent-to-agent endpoint.",
|
|
8390
|
+
absentAction: "Publish an agent card only when the site hosts a real A2A endpoint.",
|
|
8391
|
+
presentAction: "Keep the agent card name, url, and capabilities aligned with the deployed endpoint.",
|
|
8392
|
+
requiredNote: "A2A is an emerging protocol, so this is a structural observation rather than a compliance verdict."
|
|
8393
|
+
}),
|
|
8394
|
+
optionalEndpointCheck(pick("openid-configuration", "oauth-authorization-server"), {
|
|
8395
|
+
id: "oauth-discovery",
|
|
8396
|
+
subject: "OAuth or OpenID Connect discovery metadata",
|
|
8397
|
+
absent: "No authorization-server metadata was found at /.well-known/openid-configuration or /.well-known/oauth-authorization-server. That only matters when the site exposes protected APIs that agents authenticate with.",
|
|
8398
|
+
absentAction: "Publish RFC 8414 or OpenID Connect discovery metadata only when a real authorization server issues tokens for this origin.",
|
|
8399
|
+
presentAction: "Keep the issuer and endpoint URLs accurate so clients can discover how to authenticate.",
|
|
8400
|
+
requiredNote: "Structural presence of issuer metadata does not prove the authorization flow works."
|
|
8401
|
+
}),
|
|
8402
|
+
optionalEndpointCheck(pick("oauth-protected-resource"), {
|
|
8403
|
+
id: "oauth-protected-resource",
|
|
8404
|
+
subject: "OAuth protected resource metadata",
|
|
8405
|
+
absent: "/.well-known/oauth-protected-resource returned no metadata. RFC 9728 metadata matters only for origins that serve OAuth-protected APIs.",
|
|
8406
|
+
absentAction: "Publish protected resource metadata only when this origin actually serves OAuth-protected APIs.",
|
|
8407
|
+
presentAction: "Keep the resource identifier and authorization_servers list accurate.",
|
|
8408
|
+
requiredNote: "Structural presence does not prove tokens are issued or accepted."
|
|
8409
|
+
}),
|
|
8410
|
+
optionalEndpointCheck(pick("api-catalog"), {
|
|
8411
|
+
id: "api-catalog",
|
|
8412
|
+
subject: "An API catalog",
|
|
8413
|
+
absent: "/.well-known/api-catalog returned no linkset document. An RFC 9727 catalog matters only for sites that publish APIs.",
|
|
8414
|
+
absentAction: "Publish an API catalog only when the site has real APIs.",
|
|
8415
|
+
presentAction: "Keep the linkset entries pointing at live service-desc and service-doc resources.",
|
|
8416
|
+
requiredNote: "The catalog was checked for structure only, not for whether each linked API works."
|
|
8417
|
+
}),
|
|
8418
|
+
optionalEndpointCheck(pick("web-bot-auth-directory"), {
|
|
8419
|
+
id: "web-bot-auth",
|
|
8420
|
+
subject: "A Web Bot Auth key directory",
|
|
8421
|
+
absent: "/.well-known/http-message-signatures-directory returned no key set. Publishing one matters only for operators that sign their own outbound bot requests.",
|
|
8422
|
+
absentAction: "Publish a signature directory only when this origin operates bots that sign requests with Web Bot Auth.",
|
|
8423
|
+
presentAction: "Keep the published keys current and rotate them safely.",
|
|
8424
|
+
requiredNote: "Web Bot Auth is an emerging draft, so this is a structural observation rather than a compliance verdict."
|
|
8425
|
+
}),
|
|
8426
|
+
optionalEndpointCheck(pick("auth-md"), {
|
|
8427
|
+
id: "auth-md",
|
|
8428
|
+
subject: "An auth.md registration guide",
|
|
8429
|
+
absent: "/auth.md returned no document. auth.md is an emerging proposal for telling agents how to register and authenticate, and most content sites do not need one.",
|
|
8430
|
+
absentAction: "Add auth.md only when agents can genuinely register for and authenticate with this site.",
|
|
8431
|
+
presentAction: "Keep the registration steps accurate and pair the file with real OAuth discovery metadata.",
|
|
8432
|
+
requiredNote: "auth.md is an emerging proposal, so this is a presence observation only."
|
|
8433
|
+
}),
|
|
8434
|
+
optionalEndpointCheck(pick("llms-full-txt"), {
|
|
8435
|
+
id: "llms-full-txt",
|
|
8436
|
+
subject: "llms-full.txt",
|
|
8437
|
+
absent: "/llms-full.txt returned no document. The expanded companion to llms.txt is optional, and its absence is not a search ranking problem.",
|
|
8438
|
+
absentAction: "Add llms-full.txt only when an intended consumer wants the full expanded content in one file.",
|
|
8439
|
+
presentAction: "Keep the file deterministic and consistent with the curated llms.txt entry points.",
|
|
8440
|
+
requiredNote: "Only presence and content type were checked, not the quality of the expanded content."
|
|
8441
|
+
})
|
|
8442
|
+
];
|
|
8443
|
+
}
|
|
7430
8444
|
function accessChecks(report, discovery) {
|
|
7431
8445
|
const bots = report.ai?.robotsTxt?.botAccess ?? [];
|
|
7432
8446
|
const blocked = bots.filter((bot) => bot.allowed === false);
|
|
@@ -7528,7 +8542,8 @@ function agentReadiness(report) {
|
|
|
7528
8542
|
section$1("profile", "Profile scope", profileChecks(discovery)),
|
|
7529
8543
|
section$1("access", "Crawler and protocol access", accessChecks(report, discovery)),
|
|
7530
8544
|
section$1("representations", "HTML and Markdown representations", markdownChecks(discovery)),
|
|
7531
|
-
section$1("discovery", "Agent discovery files", discoveryChecks(discovery)),
|
|
8545
|
+
section$1("discovery", "Agent discovery files", discoveryChecks(discovery, report.ai?.robotsTxt?.contentSignals)),
|
|
8546
|
+
section$1("endpoints", "Agent endpoints and auth discovery", endpointChecks(discovery)),
|
|
7532
8547
|
section$1("identity", "Identity evidence", identityChecks(report))
|
|
7533
8548
|
];
|
|
7534
8549
|
const checks = sections.flatMap((item) => item.checks);
|
|
@@ -7565,6 +8580,7 @@ function agentReadiness(report) {
|
|
|
7565
8580
|
...discovery.warnings,
|
|
7566
8581
|
"A clean content profile does not prove indexing, rankings, AI mentions, citations, or selection.",
|
|
7567
8582
|
"A crawler token being allowed does not prove that a service fetched or used the page.",
|
|
8583
|
+
"MCP server cards, A2A agent cards, Web Bot Auth directories, and auth.md are emerging conventions. Their absence is an observation, not a defect.",
|
|
7568
8584
|
"API, application, and commerce checks were not applicable to this content-site run."
|
|
7569
8585
|
]
|
|
7570
8586
|
};
|
|
@@ -8169,6 +9185,15 @@ function originHost(url) {
|
|
|
8169
9185
|
function cleanTitle(page) {
|
|
8170
9186
|
return page.title ?? page.h1 ?? (new URL(page.finalUrl).pathname || page.finalUrl);
|
|
8171
9187
|
}
|
|
9188
|
+
function truncateAtWord(value, maxLength) {
|
|
9189
|
+
const normalized = value.replace(/\s+/g, " ").trim();
|
|
9190
|
+
if (normalized.length <= maxLength) return normalized;
|
|
9191
|
+
const room = Math.max(1, maxLength - 3);
|
|
9192
|
+
const prefix = normalized.slice(0, room);
|
|
9193
|
+
const boundary = prefix.lastIndexOf(" ");
|
|
9194
|
+
if (boundary < 0) return "...";
|
|
9195
|
+
return `${prefix.slice(0, boundary).trimEnd()}...`;
|
|
9196
|
+
}
|
|
8172
9197
|
function sectionForPage(page) {
|
|
8173
9198
|
const path = new URL(page.finalUrl).pathname.toLowerCase();
|
|
8174
9199
|
if (/\/(docs|documentation|guide|guides|help|kb|learn)\b/.test(path)) return "Docs and guides";
|
|
@@ -8301,7 +9326,7 @@ function generateLlmsTxt(report, options = {}) {
|
|
|
8301
9326
|
let sectionCount = 0;
|
|
8302
9327
|
for (const page of sectionPages) {
|
|
8303
9328
|
const title = cleanTitle(page).replace(/\]/g, "\\]");
|
|
8304
|
-
const note = page.metaDescription ? ` - ${page.metaDescription
|
|
9329
|
+
const note = page.metaDescription ? ` - ${truncateAtWord(page.metaDescription, 160)}` : "";
|
|
8305
9330
|
const line = `- [${title}](${page.finalUrl})${note}`;
|
|
8306
9331
|
if (estimatedTokens([
|
|
8307
9332
|
...lines,
|
|
@@ -8319,14 +9344,29 @@ function generateLlmsTxt(report, options = {}) {
|
|
|
8319
9344
|
counts[sectionName] = sectionCount;
|
|
8320
9345
|
if (sectionCount < sectionPages.length) break;
|
|
8321
9346
|
}
|
|
8322
|
-
lines.push("## Notes", "", "- Optional metadata for agents and services that explicitly support llms.txt.", "- [Google Search guidance](https://developers.google.com/search/updates#clarifying-guidance-on-llms-txt-files) says llms.txt does not affect visibility or rankings.", `- Generated from crawl report ${report.id}.`, `- Crawl generated at ${report.generatedAt}.`);
|
|
9347
|
+
lines.push("## Notes", "", "- Optional metadata for agents and services that explicitly support llms.txt.", "- [Google Search guidance](https://developers.google.com/search/updates#clarifying-guidance-on-llms-txt-files) says llms.txt does not affect visibility or rankings.", `- Generated from crawl report ${report.id}.`, `- Crawl generated at ${report.generatedAt}.`, `- Crawl coverage: ${report.status}; retained ${report.summary.crawledUrls} of ${report.summary.discoveredUrls} discovered URLs.`);
|
|
9348
|
+
if (report.sitemapDiscovery) lines.push(`- Sitemap discovery: ${report.sitemapDiscovery.dataStatus}; returned ${report.sitemapDiscovery.urlsReturned} URLs.`);
|
|
8323
9349
|
if (report.caveats.length) lines.push(...report.caveats.map((caveat) => `- Caveat: ${caveat}`));
|
|
9350
|
+
if (report.warnings.length) {
|
|
9351
|
+
lines.push(...report.warnings.slice(0, 10).map((warning) => `- Source warning: ${warning}`));
|
|
9352
|
+
if (report.warnings.length > 10) lines.push(`- Source warnings: ${report.warnings.length - 10} more warnings remain in crawl report ${report.id}.`);
|
|
9353
|
+
}
|
|
8324
9354
|
const content = `${lines.join("\n")}\n`;
|
|
8325
9355
|
return {
|
|
8326
9356
|
content,
|
|
8327
9357
|
includedUrls,
|
|
8328
9358
|
estimatedTokens: estimatedTokens(content),
|
|
8329
|
-
sections: counts
|
|
9359
|
+
sections: counts,
|
|
9360
|
+
source: {
|
|
9361
|
+
reportId: report.id,
|
|
9362
|
+
status: report.status,
|
|
9363
|
+
crawledUrls: report.summary.crawledUrls,
|
|
9364
|
+
discoveredUrls: report.summary.discoveredUrls,
|
|
9365
|
+
sitemapUrlsReturned: report.sitemapDiscovery?.urlsReturned,
|
|
9366
|
+
warnings: report.warnings.slice(0, 10),
|
|
9367
|
+
warningCount: report.warnings.length,
|
|
9368
|
+
warningsTruncated: report.warnings.length > 10
|
|
9369
|
+
}
|
|
8330
9370
|
};
|
|
8331
9371
|
}
|
|
8332
9372
|
//#endregion
|
|
@@ -8939,6 +9979,24 @@ const RULE_DEFINITIONS = [
|
|
|
8939
9979
|
impactIfIgnored: "The page cannot be crawled, ranked, or cited while the connection failure remains.",
|
|
8940
9980
|
howToVerify: "Re-run the crawl and confirm the URL returns a normal HTTP status instead of status 0."
|
|
8941
9981
|
},
|
|
9982
|
+
{
|
|
9983
|
+
id: "crawler_access_blocked",
|
|
9984
|
+
title: "Crawler access blocked",
|
|
9985
|
+
category: "response",
|
|
9986
|
+
defaultSeverity: "medium",
|
|
9987
|
+
whyItMatters: "The audit could not observe the intended page because an authentication layer, rate limit, firewall, or challenge intercepted this crawler. This limits the audit evidence but does not prove that search engines or users are blocked.",
|
|
9988
|
+
howToFix: "First confirm the block is unintended. If the audit should have access, use the provider evidence in the report to grant temporary, narrow access for the audit machine source IP, required hostname or paths, and the exact crawler User-Agent. Do not trust a User-Agent by itself.",
|
|
9989
|
+
impactIfIgnored: "The affected URL remains unverified in this crawl, so content and indexability checks cannot support a pass or an all-clear.",
|
|
9990
|
+
howToVerify: "Re-run the same health check and confirm access.blockedRequests is zero and the URL returns its intended status without challenge evidence.",
|
|
9991
|
+
agentHints: {
|
|
9992
|
+
evidenceFields: [
|
|
9993
|
+
"request.accessBlock",
|
|
9994
|
+
"report.access",
|
|
9995
|
+
"issue.evidence.accessBlock"
|
|
9996
|
+
],
|
|
9997
|
+
suggestedCommands: ["seo crawl --sitemap-url <url> --health --json"]
|
|
9998
|
+
}
|
|
9999
|
+
},
|
|
8942
10000
|
{
|
|
8943
10001
|
id: "client_error",
|
|
8944
10002
|
title: "Client error",
|
|
@@ -9427,6 +10485,7 @@ const RULE_RECOMMENDATIONS = {
|
|
|
9427
10485
|
nofollow: "review",
|
|
9428
10486
|
x_robots_noindex: "review",
|
|
9429
10487
|
robots_blocked: "review",
|
|
10488
|
+
crawler_access_blocked: "review",
|
|
9430
10489
|
orphan_page: "review",
|
|
9431
10490
|
redirected_url: "review",
|
|
9432
10491
|
slow_response: "review",
|
|
@@ -9648,6 +10707,14 @@ function auditCrawlRequests(requests) {
|
|
|
9648
10707
|
continue;
|
|
9649
10708
|
}
|
|
9650
10709
|
if (request.outcome === "skipped") continue;
|
|
10710
|
+
if (request.accessBlock) {
|
|
10711
|
+
issues.push(requestIssue("crawler_access_blocked", request, request.accessBlock.guidance.summary, {
|
|
10712
|
+
requestedUrl: request.requestedUrl,
|
|
10713
|
+
finalUrl: request.finalUrl,
|
|
10714
|
+
accessBlock: request.accessBlock
|
|
10715
|
+
}));
|
|
10716
|
+
continue;
|
|
10717
|
+
}
|
|
9651
10718
|
const redirectChain = request.redirectChain ?? [];
|
|
9652
10719
|
if (redirectChain.length > 0 || !sameUrl(request.requestedUrl, request.finalUrl)) issues.push(requestIssue("redirected_url", request, request.finalUrl ? `Final URL: ${request.finalUrl}` : void 0, {
|
|
9653
10720
|
requestedUrl: request.requestedUrl,
|
|
@@ -9683,6 +10750,7 @@ function auditCrawlPages(pages, opts = {}) {
|
|
|
9683
10750
|
}));
|
|
9684
10751
|
continue;
|
|
9685
10752
|
}
|
|
10753
|
+
if (page.accessBlock) continue;
|
|
9686
10754
|
if (page.status >= 500) {
|
|
9687
10755
|
issues.push(issue("server_error", page, String(page.status), { status: page.status }));
|
|
9688
10756
|
if ((page.internalInlinkCount ?? 0) > 0) issues.push(issue("broken_internal_link", page, String(page.status), {
|
|
@@ -9723,6 +10791,7 @@ function auditCrawlPages(pages, opts = {}) {
|
|
|
9723
10791
|
responseTimeMs: page.responseTimeMs,
|
|
9724
10792
|
thresholdMs: SLOW_RESPONSE_MS
|
|
9725
10793
|
}));
|
|
10794
|
+
if (page.auditScope === "status") continue;
|
|
9726
10795
|
if (redirected) continue;
|
|
9727
10796
|
if (isHtmlPage(page) && (page.sizeBytes ?? 0) > LARGE_HTML_BYTES) issues.push(issue("large_html", page, `${Math.round((page.sizeBytes ?? 0) / 1024)} KB`, {
|
|
9728
10797
|
sizeBytes: page.sizeBytes,
|
|
@@ -9958,6 +11027,10 @@ function crawlSkipReasonCountsFromRecord(input = {}) {
|
|
|
9958
11027
|
}
|
|
9959
11028
|
//#endregion
|
|
9960
11029
|
//#region packages/core/src/analyze/crawler/report.ts
|
|
11030
|
+
const MAX_CRAWL_PAGES = 1e4;
|
|
11031
|
+
const MAX_CRAWL_DEPTH = 64;
|
|
11032
|
+
const MAX_CRAWL_CONCURRENCY = 16;
|
|
11033
|
+
const MAX_CRAWL_TIMEOUT_MS = 12e4;
|
|
9961
11034
|
const SENSITIVE_FIELD_NAMES = /* @__PURE__ */ new Set([
|
|
9962
11035
|
"authorization",
|
|
9963
11036
|
"cookie",
|
|
@@ -9973,33 +11046,53 @@ const SENSITIVE_PARAM_PATTERN = /^(access_?token|auth|api_?key|client_?secret|co
|
|
|
9973
11046
|
function uniqueSorted(values = []) {
|
|
9974
11047
|
return [...new Set(values.filter(Boolean))].sort();
|
|
9975
11048
|
}
|
|
9976
|
-
function normalizeFetchRate(input) {
|
|
11049
|
+
function normalizeFetchRate(input, strategy) {
|
|
11050
|
+
const defaultConcurrency = strategy === "health" ? 4 : 8;
|
|
9977
11051
|
return {
|
|
9978
|
-
concurrency: input.fetchRate?.concurrency ?? input.concurrency ??
|
|
11052
|
+
concurrency: input.fetchRate?.concurrency ?? input.concurrency ?? defaultConcurrency,
|
|
9979
11053
|
...input.fetchRate?.intervalCap !== void 0 ? { intervalCap: input.fetchRate.intervalCap } : {},
|
|
9980
11054
|
...input.fetchRate?.intervalMs !== void 0 ? { intervalMs: input.fetchRate.intervalMs } : {},
|
|
9981
11055
|
...input.fetchRate?.backpressure ? { backpressure: input.fetchRate.backpressure } : {}
|
|
9982
11056
|
};
|
|
9983
11057
|
}
|
|
9984
11058
|
function normalizeCrawlConfig(input) {
|
|
11059
|
+
const strategy = input.strategy ?? "full";
|
|
11060
|
+
const concurrency = input.concurrency ?? (strategy === "health" ? 4 : 8);
|
|
9985
11061
|
return {
|
|
9986
11062
|
url: new URL(input.url).toString(),
|
|
9987
|
-
mode: input.mode ?? "site",
|
|
11063
|
+
mode: input.mode ?? (strategy === "health" ? "sitemap" : "site"),
|
|
11064
|
+
strategy,
|
|
11065
|
+
...input.sitemapUrl ? { sitemapUrl: new URL(input.sitemapUrl).toString() } : {},
|
|
9988
11066
|
urls: uniqueSorted(input.urls ?? []),
|
|
9989
11067
|
maxPages: input.maxPages ?? 500,
|
|
9990
11068
|
maxDepth: input.maxDepth ?? 16,
|
|
9991
|
-
concurrency
|
|
11069
|
+
concurrency,
|
|
9992
11070
|
timeoutMs: input.timeoutMs ?? 2e4,
|
|
9993
11071
|
include: uniqueSorted(input.include ?? []),
|
|
9994
11072
|
exclude: uniqueSorted(input.exclude ?? []),
|
|
9995
11073
|
respectRobots: input.respectRobots ?? true,
|
|
9996
11074
|
useSitemap: input.useSitemap ?? true,
|
|
9997
|
-
checkExternal: input.checkExternal ?? true,
|
|
9998
|
-
checkAgentDiscovery: input.checkAgentDiscovery ?? false,
|
|
9999
|
-
js: normalizeJavaScriptRenderingMode(input.js),
|
|
10000
|
-
refresh: input.refresh ?? false,
|
|
10001
|
-
fetchRate: normalizeFetchRate(input)
|
|
10002
|
-
};
|
|
11075
|
+
checkExternal: strategy === "health" ? false : input.checkExternal ?? true,
|
|
11076
|
+
checkAgentDiscovery: strategy === "health" ? false : input.checkAgentDiscovery ?? false,
|
|
11077
|
+
js: strategy === "health" ? "off" : normalizeJavaScriptRenderingMode(input.js),
|
|
11078
|
+
refresh: strategy === "health" ? true : input.refresh ?? false,
|
|
11079
|
+
fetchRate: normalizeFetchRate(input, strategy)
|
|
11080
|
+
};
|
|
11081
|
+
}
|
|
11082
|
+
function assertIntegerRange(value, label, minimum, maximum) {
|
|
11083
|
+
if (!Number.isInteger(value) || value < minimum || value > maximum) throw new RangeError(`${label} must be an integer from ${minimum} to ${maximum}.`);
|
|
11084
|
+
}
|
|
11085
|
+
function assertCrawlConfigLimits(config) {
|
|
11086
|
+
if (config.strategy === "health" && config.mode !== "sitemap") throw new RangeError("The health strategy requires sitemap mode.");
|
|
11087
|
+
if (config.strategy === "health" && !config.useSitemap) throw new RangeError("The health strategy requires sitemap discovery.");
|
|
11088
|
+
assertIntegerRange(config.maxPages, "maxPages", 1, MAX_CRAWL_PAGES);
|
|
11089
|
+
assertIntegerRange(config.maxDepth, "maxDepth", 0, 64);
|
|
11090
|
+
assertIntegerRange(config.concurrency, "concurrency", 1, 16);
|
|
11091
|
+
assertIntegerRange(config.timeoutMs, "timeoutMs", 1, MAX_CRAWL_TIMEOUT_MS);
|
|
11092
|
+
if (config.fetchRate.concurrency !== void 0) assertIntegerRange(config.fetchRate.concurrency, "fetchRate.concurrency", 1, 16);
|
|
11093
|
+
if (config.strategy === "health" && config.mode !== "sitemap") throw new RangeError("Health strategy requires sitemap mode.");
|
|
11094
|
+
if (config.sitemapUrl && config.mode !== "sitemap") throw new RangeError("sitemapUrl requires sitemap mode.");
|
|
11095
|
+
return config;
|
|
10003
11096
|
}
|
|
10004
11097
|
function crawlConfigHash(config) {
|
|
10005
11098
|
const normalized = normalizeCrawlConfig(config);
|
|
@@ -10116,10 +11209,12 @@ function summarizeCrawlReport(input) {
|
|
|
10116
11209
|
}
|
|
10117
11210
|
for (const issue of input.issues) byCategory[issue.category] = (byCategory[issue.category] ?? 0) + 1;
|
|
10118
11211
|
const runStats = crawlRunStats(input.pages, input.stats);
|
|
11212
|
+
const indexabilityPages = input.pages.filter((page) => page.auditScope !== "status");
|
|
10119
11213
|
return {
|
|
10120
11214
|
totalPages: input.pages.length,
|
|
10121
|
-
|
|
10122
|
-
|
|
11215
|
+
statusOnlyPages: input.pages.filter((page) => page.auditScope === "status").length,
|
|
11216
|
+
indexablePages: indexabilityPages.filter((page) => page.indexable).length,
|
|
11217
|
+
nonIndexablePages: indexabilityPages.filter((page) => !page.indexable).length,
|
|
10123
11218
|
statusErrors: input.pages.filter((page) => page.status === 0 || page.status >= 400).length,
|
|
10124
11219
|
...runStats,
|
|
10125
11220
|
attemptedRequests: input.requestEvidenceStatus === "unavailable" ? 0 : requests.length,
|
|
@@ -10137,6 +11232,23 @@ function summarizeCrawlReport(input) {
|
|
|
10137
11232
|
byCategory
|
|
10138
11233
|
};
|
|
10139
11234
|
}
|
|
11235
|
+
const ACCESS_BLOCK_SAMPLE_LIMIT = 10;
|
|
11236
|
+
function crawlAccessSummary(requests) {
|
|
11237
|
+
const blocked = requests.flatMap((request) => request.outcome === "response" && request.accessBlock ? [{
|
|
11238
|
+
url: request.requestedUrl,
|
|
11239
|
+
evidence: request.accessBlock
|
|
11240
|
+
}] : []);
|
|
11241
|
+
const providers = {};
|
|
11242
|
+
for (const item of blocked) providers[item.evidence.provider] = (providers[item.evidence.provider] ?? 0) + 1;
|
|
11243
|
+
return {
|
|
11244
|
+
crawler: SEO_CRAWLER_IDENTITY,
|
|
11245
|
+
blockedRequests: blocked.length,
|
|
11246
|
+
providers,
|
|
11247
|
+
samples: blocked.slice(0, ACCESS_BLOCK_SAMPLE_LIMIT),
|
|
11248
|
+
sampleLimit: ACCESS_BLOCK_SAMPLE_LIMIT,
|
|
11249
|
+
truncated: blocked.length > ACCESS_BLOCK_SAMPLE_LIMIT
|
|
11250
|
+
};
|
|
11251
|
+
}
|
|
10140
11252
|
function crawlRunStats(pages, stats = {}) {
|
|
10141
11253
|
const discovered = /* @__PURE__ */ new Set();
|
|
10142
11254
|
let observedInternalLinks = 0;
|
|
@@ -10230,6 +11342,7 @@ function createCrawlReport(input) {
|
|
|
10230
11342
|
status: input.status ?? "completed",
|
|
10231
11343
|
configHash: crawlConfigHash(config),
|
|
10232
11344
|
config,
|
|
11345
|
+
access: crawlAccessSummary(requests),
|
|
10233
11346
|
summary: summarizeCrawlReport({
|
|
10234
11347
|
pages,
|
|
10235
11348
|
requests,
|
|
@@ -10252,16 +11365,19 @@ function createCrawlReport(input) {
|
|
|
10252
11365
|
};
|
|
10253
11366
|
}
|
|
10254
11367
|
function normalizeLoadedCrawlReport(report) {
|
|
11368
|
+
const config = sanitizeCrawlConfig(normalizeCrawlConfig(report.config));
|
|
10255
11369
|
const requestEvidenceStatus = report.requestEvidenceStatus ?? (Object.hasOwn(report, "requests") ? "available" : "unavailable");
|
|
10256
11370
|
const issues = sanitizeIssues(report.issues ?? []);
|
|
10257
11371
|
const requests = sortRequests(sanitizeTenantValue(report.requests ?? []) ?? []);
|
|
10258
11372
|
const pages = sortPages(sanitizePages(deriveInternalLinkAuthority(report.pages ?? [])));
|
|
10259
11373
|
return {
|
|
10260
11374
|
...sanitizeTenantValue(report),
|
|
11375
|
+
config,
|
|
11376
|
+
access: crawlAccessSummary(requests),
|
|
10261
11377
|
requestEvidenceStatus,
|
|
10262
11378
|
requests,
|
|
10263
11379
|
definitionId: report.definitionId ?? crawlDefinitionId({
|
|
10264
|
-
config
|
|
11380
|
+
config,
|
|
10265
11381
|
site: report.site,
|
|
10266
11382
|
googleAnalyticsPropertyId: report.googleAnalyticsPropertyId
|
|
10267
11383
|
}),
|
|
@@ -10282,6 +11398,8 @@ function normalizeLoadedCrawlReport(report) {
|
|
|
10282
11398
|
//#endregion
|
|
10283
11399
|
//#region packages/core/src/analyze/crawler/report-store.ts
|
|
10284
11400
|
const CRAWL_REPORT_STORAGE_VERSION = 6;
|
|
11401
|
+
const AUTOMATIC_BASELINE_SITE_RETENTION = 2;
|
|
11402
|
+
const AUTOMATIC_BASELINE_TOTAL_RETENTION = 50;
|
|
10285
11403
|
function toMeta(row) {
|
|
10286
11404
|
return {
|
|
10287
11405
|
id: row.id,
|
|
@@ -10320,14 +11438,61 @@ function reportFromJson(value) {
|
|
|
10320
11438
|
if (parsed && typeof parsed === "object" && "kind" in parsed && parsed.kind === "seo.crawl_report" && "report" in parsed) return normalizeLoadedCrawlReport(parsed.report);
|
|
10321
11439
|
return normalizeLoadedCrawlReport(parsed);
|
|
10322
11440
|
}
|
|
10323
|
-
function
|
|
10324
|
-
|
|
10325
|
-
|
|
10326
|
-
|
|
10327
|
-
|
|
10328
|
-
|
|
11441
|
+
function pruneAutomaticBaselines() {
|
|
11442
|
+
getDb().prepare(`WITH ranked AS (
|
|
11443
|
+
SELECT id,
|
|
11444
|
+
ROW_NUMBER() OVER (
|
|
11445
|
+
PARTITION BY site_url
|
|
11446
|
+
ORDER BY created_at DESC, id DESC
|
|
11447
|
+
) AS site_rank,
|
|
11448
|
+
ROW_NUMBER() OVER (
|
|
11449
|
+
ORDER BY created_at DESC, id DESC
|
|
11450
|
+
) AS total_rank
|
|
11451
|
+
FROM crawl_reports
|
|
11452
|
+
WHERE retention_class = 'baseline'
|
|
11453
|
+
)
|
|
11454
|
+
DELETE FROM crawl_reports
|
|
11455
|
+
WHERE id IN (
|
|
11456
|
+
SELECT id FROM ranked
|
|
11457
|
+
WHERE site_rank > ? OR total_rank > ?
|
|
11458
|
+
)`).run(2, 50);
|
|
11459
|
+
}
|
|
11460
|
+
function crawlReportSiteWhere(value) {
|
|
11461
|
+
const siteValues = /* @__PURE__ */ new Set([value]);
|
|
11462
|
+
const origins = /* @__PURE__ */ new Set();
|
|
11463
|
+
try {
|
|
11464
|
+
origins.add(new URL(value).origin);
|
|
11465
|
+
} catch {
|
|
11466
|
+
const host = value.replace(/^sc-domain:/, "").replace(/\/$/, "");
|
|
11467
|
+
if (host && !host.includes("/")) {
|
|
11468
|
+
siteValues.add(`sc-domain:${host}`);
|
|
11469
|
+
origins.add(`https://${host}`);
|
|
11470
|
+
origins.add(`http://${host}`);
|
|
11471
|
+
}
|
|
11472
|
+
}
|
|
11473
|
+
const clauses = [...siteValues].map(() => "site_url = ?");
|
|
11474
|
+
const params = [...siteValues];
|
|
11475
|
+
for (const origin of origins) {
|
|
11476
|
+
clauses.push("url = ?", "url LIKE ?");
|
|
11477
|
+
params.push(`${origin}/`, `${origin}/%`);
|
|
11478
|
+
}
|
|
11479
|
+
return {
|
|
11480
|
+
sql: `(${clauses.join(" OR ")})`,
|
|
11481
|
+
params
|
|
11482
|
+
};
|
|
11483
|
+
}
|
|
11484
|
+
function saveCrawlReportToSqlite(report, options = {}) {
|
|
11485
|
+
const database = getDb();
|
|
11486
|
+
const retention = options.retention ?? "saved";
|
|
11487
|
+
if (database.prepare(`INSERT INTO crawl_reports
|
|
11488
|
+
(id, config_hash, site_url, url, status, total_pages, issue_count, created_at,
|
|
11489
|
+
retention_class, report_json)
|
|
11490
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
11491
|
+
ON CONFLICT(id) DO NOTHING`).run(report.id, report.configHash, report.site ?? null, report.config.url, report.status, report.summary.totalPages, report.issues.length, Date.parse(report.generatedAt), retention, JSON.stringify(storageEnvelope(report))).changes === 0) {
|
|
11492
|
+
const existing = database.prepare("SELECT * FROM crawl_reports WHERE id = ?").get(report.id);
|
|
10329
11493
|
if (existing) return toMeta(existing);
|
|
10330
11494
|
}
|
|
11495
|
+
if (retention === "baseline") pruneAutomaticBaselines();
|
|
10331
11496
|
return {
|
|
10332
11497
|
id: report.id,
|
|
10333
11498
|
configHash: report.configHash,
|
|
@@ -10342,10 +11507,13 @@ function saveCrawlReportToSqlite(report) {
|
|
|
10342
11507
|
}
|
|
10343
11508
|
function listCrawlReportsFromSqlite(input = {}) {
|
|
10344
11509
|
const limit = input.limit ?? 20;
|
|
10345
|
-
return (input.site ?
|
|
10346
|
-
|
|
10347
|
-
|
|
10348
|
-
|
|
11510
|
+
return (input.site ? (() => {
|
|
11511
|
+
const where = crawlReportSiteWhere(input.site);
|
|
11512
|
+
return getDb().prepare(`SELECT * FROM crawl_reports
|
|
11513
|
+
WHERE ${where.sql}
|
|
11514
|
+
ORDER BY created_at DESC, id DESC
|
|
11515
|
+
LIMIT ?`).all(...where.params, limit);
|
|
11516
|
+
})() : getDb().prepare(`SELECT * FROM crawl_reports
|
|
10349
11517
|
ORDER BY created_at DESC, id DESC
|
|
10350
11518
|
LIMIT ?`).all(limit)).map(toMeta);
|
|
10351
11519
|
}
|
|
@@ -10358,10 +11526,13 @@ function deleteCrawlReportFromSqlite(id) {
|
|
|
10358
11526
|
return getDb().prepare("DELETE FROM crawl_reports WHERE id = ?").run(id).changes > 0;
|
|
10359
11527
|
}
|
|
10360
11528
|
function latestCrawlReportFromSqlite(site) {
|
|
10361
|
-
const row = site ?
|
|
10362
|
-
|
|
10363
|
-
|
|
10364
|
-
|
|
11529
|
+
const row = site ? (() => {
|
|
11530
|
+
const where = crawlReportSiteWhere(site);
|
|
11531
|
+
return getDb().prepare(`SELECT report_json FROM crawl_reports
|
|
11532
|
+
WHERE ${where.sql}
|
|
11533
|
+
ORDER BY created_at DESC, id DESC
|
|
11534
|
+
LIMIT 1`).get(...where.params);
|
|
11535
|
+
})() : getDb().prepare(`SELECT report_json FROM crawl_reports
|
|
10365
11536
|
ORDER BY created_at DESC, id DESC
|
|
10366
11537
|
LIMIT 1`).get();
|
|
10367
11538
|
if (!row) return void 0;
|
|
@@ -10375,8 +11546,8 @@ const sqliteCrawlReportStore = {
|
|
|
10375
11546
|
latest: latestCrawlReportFromSqlite
|
|
10376
11547
|
};
|
|
10377
11548
|
const crawlReportStore = sqliteCrawlReportStore;
|
|
10378
|
-
function saveCrawlReport(report, store = crawlReportStore) {
|
|
10379
|
-
return store.save(report);
|
|
11549
|
+
function saveCrawlReport(report, store = crawlReportStore, options = {}) {
|
|
11550
|
+
return store.save(report, options);
|
|
10380
11551
|
}
|
|
10381
11552
|
function listCrawlReports(input = {}, store = crawlReportStore) {
|
|
10382
11553
|
return store.list(input);
|
|
@@ -10422,6 +11593,86 @@ function cancellationRace(signal) {
|
|
|
10422
11593
|
});
|
|
10423
11594
|
}
|
|
10424
11595
|
//#endregion
|
|
11596
|
+
//#region packages/core/src/analyze/crawler/crawl-url-queue.ts
|
|
11597
|
+
function before(left, right) {
|
|
11598
|
+
return left.depth < right.depth || left.depth === right.depth && left.url < right.url;
|
|
11599
|
+
}
|
|
11600
|
+
var CrawlUrlQueue = class {
|
|
11601
|
+
#items = [];
|
|
11602
|
+
#positions = /* @__PURE__ */ new Map();
|
|
11603
|
+
get size() {
|
|
11604
|
+
return this.#items.length;
|
|
11605
|
+
}
|
|
11606
|
+
has(url) {
|
|
11607
|
+
return this.#positions.has(url);
|
|
11608
|
+
}
|
|
11609
|
+
push(item) {
|
|
11610
|
+
if (this.has(item.url)) throw new Error(`URL is already queued: ${item.url}`);
|
|
11611
|
+
const index = this.#items.length;
|
|
11612
|
+
this.#items.push(item);
|
|
11613
|
+
this.#positions.set(item.url, index);
|
|
11614
|
+
this.#bubbleUp(index);
|
|
11615
|
+
}
|
|
11616
|
+
decreaseDepth(url, depth) {
|
|
11617
|
+
const index = this.#positions.get(url);
|
|
11618
|
+
if (index === void 0) return false;
|
|
11619
|
+
const item = this.#items[index];
|
|
11620
|
+
if (!item || depth >= item.depth) return false;
|
|
11621
|
+
item.depth = depth;
|
|
11622
|
+
this.#bubbleUp(index);
|
|
11623
|
+
return true;
|
|
11624
|
+
}
|
|
11625
|
+
take() {
|
|
11626
|
+
const first = this.#items[0];
|
|
11627
|
+
if (!first) return void 0;
|
|
11628
|
+
const last = this.#items.pop();
|
|
11629
|
+
this.#positions.delete(first.url);
|
|
11630
|
+
if (last && last !== first) {
|
|
11631
|
+
this.#items[0] = last;
|
|
11632
|
+
this.#positions.set(last.url, 0);
|
|
11633
|
+
this.#bubbleDown(0);
|
|
11634
|
+
}
|
|
11635
|
+
return first;
|
|
11636
|
+
}
|
|
11637
|
+
#swap(left, right) {
|
|
11638
|
+
const leftItem = this.#items[left];
|
|
11639
|
+
const rightItem = this.#items[right];
|
|
11640
|
+
if (!leftItem || !rightItem) return;
|
|
11641
|
+
this.#items[left] = rightItem;
|
|
11642
|
+
this.#items[right] = leftItem;
|
|
11643
|
+
this.#positions.set(rightItem.url, left);
|
|
11644
|
+
this.#positions.set(leftItem.url, right);
|
|
11645
|
+
}
|
|
11646
|
+
#bubbleUp(start) {
|
|
11647
|
+
let index = start;
|
|
11648
|
+
while (index > 0) {
|
|
11649
|
+
const parent = Math.floor((index - 1) / 2);
|
|
11650
|
+
const item = this.#items[index];
|
|
11651
|
+
const parentItem = this.#items[parent];
|
|
11652
|
+
if (!item || !parentItem || !before(item, parentItem)) break;
|
|
11653
|
+
this.#swap(index, parent);
|
|
11654
|
+
index = parent;
|
|
11655
|
+
}
|
|
11656
|
+
}
|
|
11657
|
+
#bubbleDown(start) {
|
|
11658
|
+
let index = start;
|
|
11659
|
+
while (true) {
|
|
11660
|
+
const left = index * 2 + 1;
|
|
11661
|
+
const right = left + 1;
|
|
11662
|
+
let next = index;
|
|
11663
|
+
const item = this.#items[next];
|
|
11664
|
+
const leftItem = this.#items[left];
|
|
11665
|
+
const rightItem = this.#items[right];
|
|
11666
|
+
if (leftItem && item && before(leftItem, item)) next = left;
|
|
11667
|
+
const nextItem = this.#items[next];
|
|
11668
|
+
if (rightItem && nextItem && before(rightItem, nextItem)) next = right;
|
|
11669
|
+
if (next === index) return;
|
|
11670
|
+
this.#swap(index, next);
|
|
11671
|
+
index = next;
|
|
11672
|
+
}
|
|
11673
|
+
}
|
|
11674
|
+
};
|
|
11675
|
+
//#endregion
|
|
10425
11676
|
//#region packages/core/src/analyze/crawler/external-link-checks.ts
|
|
10426
11677
|
const EXTERNAL_LINK_SELECTION_LIMIT = 200;
|
|
10427
11678
|
const EXTERNAL_LINK_STATES = [
|
|
@@ -10623,18 +11874,13 @@ function resolveFetchedAlias(url, aliases) {
|
|
|
10623
11874
|
}
|
|
10624
11875
|
return current;
|
|
10625
11876
|
}
|
|
10626
|
-
function
|
|
10627
|
-
const resolved =
|
|
10628
|
-
for (const [
|
|
10629
|
-
const
|
|
10630
|
-
|
|
10631
|
-
if (!resolvedTargets) {
|
|
10632
|
-
resolvedTargets = /* @__PURE__ */ new Set();
|
|
10633
|
-
resolved[resolvedSource] = resolvedTargets;
|
|
10634
|
-
}
|
|
10635
|
-
for (const target of targets) resolvedTargets.add(resolveFetchedAlias(target, aliases));
|
|
11877
|
+
function resolveLinkCountAliases(counts, aliases) {
|
|
11878
|
+
const resolved = /* @__PURE__ */ new Map();
|
|
11879
|
+
for (const [url, count] of counts) {
|
|
11880
|
+
const target = resolveFetchedAlias(url, aliases);
|
|
11881
|
+
resolved.set(target, (resolved.get(target) ?? 0) + count);
|
|
10636
11882
|
}
|
|
10637
|
-
return
|
|
11883
|
+
return resolved;
|
|
10638
11884
|
}
|
|
10639
11885
|
//#endregion
|
|
10640
11886
|
//#region packages/core/src/analyze/crawler/request-evidence.ts
|
|
@@ -10646,7 +11892,9 @@ function observationFromPage(requestedUrl, page) {
|
|
|
10646
11892
|
status: page.status,
|
|
10647
11893
|
contentType: page.contentType,
|
|
10648
11894
|
durationMs: page.responseTimeMs,
|
|
10649
|
-
redirectChain: page.fetchDiagnostics?.redirectChain
|
|
11895
|
+
redirectChain: page.fetchDiagnostics?.redirectChain,
|
|
11896
|
+
accessBlock: page.accessBlock ?? page.fetchDiagnostics?.accessBlock,
|
|
11897
|
+
robotsTxt: page.robotsTxt
|
|
10650
11898
|
};
|
|
10651
11899
|
if (page.extractionStatus === "failed") return {
|
|
10652
11900
|
...observation,
|
|
@@ -10659,23 +11907,230 @@ function observationFromPage(requestedUrl, page) {
|
|
|
10659
11907
|
};
|
|
10660
11908
|
}
|
|
10661
11909
|
//#endregion
|
|
10662
|
-
//#region packages/core/src/analyze/crawler/site-crawl-
|
|
10663
|
-
|
|
10664
|
-
|
|
10665
|
-
|
|
10666
|
-
|
|
10667
|
-
|
|
10668
|
-
|
|
10669
|
-
|
|
10670
|
-
|
|
11910
|
+
//#region packages/core/src/analyze/crawler/site-crawl-ai-signals.ts
|
|
11911
|
+
const MAX_AUXILIARY_RESPONSE_BYTES = 2 * 1024 * 1024;
|
|
11912
|
+
const AI_BOT_USER_AGENTS = [
|
|
11913
|
+
"GPTBot",
|
|
11914
|
+
"OAI-SearchBot",
|
|
11915
|
+
"ChatGPT-User",
|
|
11916
|
+
"ClaudeBot",
|
|
11917
|
+
"Claude-User",
|
|
11918
|
+
"Claude-SearchBot",
|
|
11919
|
+
"PerplexityBot",
|
|
11920
|
+
"Perplexity-User",
|
|
11921
|
+
"Google-Extended",
|
|
11922
|
+
"CCBot",
|
|
11923
|
+
"Applebot",
|
|
11924
|
+
"Applebot-Extended",
|
|
11925
|
+
"Bingbot",
|
|
11926
|
+
"Amazonbot",
|
|
11927
|
+
"Bytespider",
|
|
11928
|
+
"Meta-ExternalAgent",
|
|
11929
|
+
"Meta-ExternalFetcher",
|
|
11930
|
+
"DuckAssistBot",
|
|
11931
|
+
"MistralAI-User",
|
|
11932
|
+
"Diffbot",
|
|
11933
|
+
"cohere-ai"
|
|
11934
|
+
];
|
|
11935
|
+
const AGENT_RESOURCE_PATHS = [
|
|
11936
|
+
"/.well-known/agent.json",
|
|
11937
|
+
"/agent.json",
|
|
11938
|
+
"/.well-known/mcp.json",
|
|
11939
|
+
"/.well-known/ai-plugin.json",
|
|
11940
|
+
"/.well-known/openapi.json",
|
|
11941
|
+
"/openapi.json"
|
|
11942
|
+
];
|
|
11943
|
+
async function checkLlmsTxt(input) {
|
|
11944
|
+
const llmsUrl = new URL("/llms.txt", input.url).toString();
|
|
11945
|
+
const controller = abortController({
|
|
11946
|
+
timeoutMs: input.timeoutMs,
|
|
11947
|
+
signal: input.signal
|
|
11948
|
+
});
|
|
10671
11949
|
try {
|
|
10672
|
-
|
|
10673
|
-
|
|
10674
|
-
|
|
10675
|
-
|
|
10676
|
-
|
|
10677
|
-
|
|
10678
|
-
|
|
11950
|
+
const response = await input.fetch(llmsUrl, {
|
|
11951
|
+
profile: "bot",
|
|
11952
|
+
redirect: "follow",
|
|
11953
|
+
signal: controller.signal
|
|
11954
|
+
});
|
|
11955
|
+
const contentType = response.headers.get("content-type") ?? "";
|
|
11956
|
+
const exists = response.status >= 200 && response.status < 300 && !/\btext\/html\b/i.test(contentType);
|
|
11957
|
+
await response.body?.cancel().catch(() => void 0);
|
|
11958
|
+
return {
|
|
11959
|
+
url: response.url || llmsUrl,
|
|
11960
|
+
exists,
|
|
11961
|
+
status: response.status
|
|
11962
|
+
};
|
|
11963
|
+
} catch {
|
|
11964
|
+
return {
|
|
11965
|
+
url: llmsUrl,
|
|
11966
|
+
exists: false
|
|
11967
|
+
};
|
|
11968
|
+
} finally {
|
|
11969
|
+
controller.cleanup();
|
|
11970
|
+
}
|
|
11971
|
+
}
|
|
11972
|
+
function parseRobots(robotsUrl, text) {
|
|
11973
|
+
return robotsParserModule(robotsUrl, text);
|
|
11974
|
+
}
|
|
11975
|
+
function declaredUserAgents(text) {
|
|
11976
|
+
const declared = /* @__PURE__ */ new Set();
|
|
11977
|
+
for (const match of text.matchAll(/^\s*user-agent\s*:\s*(.+?)\s*$/gim)) {
|
|
11978
|
+
const value = match[1]?.trim().toLowerCase();
|
|
11979
|
+
if (value) declared.add(value);
|
|
11980
|
+
}
|
|
11981
|
+
return declared;
|
|
11982
|
+
}
|
|
11983
|
+
function contentSignalsFromRobots(text) {
|
|
11984
|
+
const values = /* @__PURE__ */ new Set();
|
|
11985
|
+
for (const match of text.matchAll(/^\s*content-signal\s*:\s*(.+?)\s*$/gim)) {
|
|
11986
|
+
const value = match[1]?.trim();
|
|
11987
|
+
if (value) values.add(value);
|
|
11988
|
+
}
|
|
11989
|
+
return [...values].sort();
|
|
11990
|
+
}
|
|
11991
|
+
function sitemapUrlsFromRobots(text) {
|
|
11992
|
+
const urls = /* @__PURE__ */ new Set();
|
|
11993
|
+
for (const match of text.matchAll(/^\s*sitemap\s*:\s*(\S+)\s*$/gim)) {
|
|
11994
|
+
const value = match[1]?.trim();
|
|
11995
|
+
if (!value) continue;
|
|
11996
|
+
try {
|
|
11997
|
+
urls.add(new URL(value).toString());
|
|
11998
|
+
} catch {}
|
|
11999
|
+
}
|
|
12000
|
+
return [...urls];
|
|
12001
|
+
}
|
|
12002
|
+
async function checkRobotsAiAccess(input) {
|
|
12003
|
+
const robotsUrl = new URL("/robots.txt", input.url).toString();
|
|
12004
|
+
const controller = abortController({
|
|
12005
|
+
timeoutMs: input.timeoutMs,
|
|
12006
|
+
signal: input.signal
|
|
12007
|
+
});
|
|
12008
|
+
try {
|
|
12009
|
+
const response = await input.fetch(robotsUrl, {
|
|
12010
|
+
profile: "bot",
|
|
12011
|
+
redirect: "follow",
|
|
12012
|
+
signal: controller.signal
|
|
12013
|
+
});
|
|
12014
|
+
const text = await readBoundedResponseText(response, MAX_AUXILIARY_RESPONSE_BYTES, "robots.txt response");
|
|
12015
|
+
const available = response.status >= 200 && response.status < 300;
|
|
12016
|
+
const absent = response.status >= 400 && response.status < 500 && response.status !== 429;
|
|
12017
|
+
const availability = available ? "available" : [401, 403].includes(response.status) ? "access-blocked" : response.status === 429 ? "rate-limited" : absent ? "absent" : "unreachable";
|
|
12018
|
+
const exists = available;
|
|
12019
|
+
const declared = exists ? declaredUserAgents(text) : /* @__PURE__ */ new Set();
|
|
12020
|
+
const parsed = parseRobots(robotsUrl, exists ? text : "");
|
|
12021
|
+
return {
|
|
12022
|
+
url: response.url || robotsUrl,
|
|
12023
|
+
exists,
|
|
12024
|
+
availability,
|
|
12025
|
+
status: response.status,
|
|
12026
|
+
...["rate-limited", "unreachable"].includes(availability) ? { error: `robots.txt returned HTTP ${response.status}.` } : {},
|
|
12027
|
+
sitemapUrls: exists ? sitemapUrlsFromRobots(text) : [],
|
|
12028
|
+
contentSignals: exists ? contentSignalsFromRobots(text) : [],
|
|
12029
|
+
botAccess: AI_BOT_USER_AGENTS.map((userAgent) => {
|
|
12030
|
+
const lower = userAgent.toLowerCase();
|
|
12031
|
+
return {
|
|
12032
|
+
userAgent,
|
|
12033
|
+
allowed: availability === "rate-limited" || availability === "unreachable" ? null : parsed.isAllowed(input.url, userAgent) ?? true,
|
|
12034
|
+
declared: declared.has(lower),
|
|
12035
|
+
coveredByWildcard: declared.has("*")
|
|
12036
|
+
};
|
|
12037
|
+
})
|
|
12038
|
+
};
|
|
12039
|
+
} catch (error) {
|
|
12040
|
+
return {
|
|
12041
|
+
url: robotsUrl,
|
|
12042
|
+
exists: false,
|
|
12043
|
+
availability: "unreachable",
|
|
12044
|
+
error: error instanceof Error ? error.message : String(error),
|
|
12045
|
+
sitemapUrls: [],
|
|
12046
|
+
botAccess: AI_BOT_USER_AGENTS.map((userAgent) => ({
|
|
12047
|
+
userAgent,
|
|
12048
|
+
allowed: null,
|
|
12049
|
+
declared: false,
|
|
12050
|
+
coveredByWildcard: false
|
|
12051
|
+
}))
|
|
12052
|
+
};
|
|
12053
|
+
} finally {
|
|
12054
|
+
controller.cleanup();
|
|
12055
|
+
}
|
|
12056
|
+
}
|
|
12057
|
+
async function checkAgentResource(input) {
|
|
12058
|
+
const url = new URL(input.path, input.baseUrl).toString();
|
|
12059
|
+
const controller = abortController({
|
|
12060
|
+
timeoutMs: input.timeoutMs,
|
|
12061
|
+
signal: input.signal
|
|
12062
|
+
});
|
|
12063
|
+
try {
|
|
12064
|
+
const response = await input.fetch(url, {
|
|
12065
|
+
profile: "bot",
|
|
12066
|
+
redirect: "follow",
|
|
12067
|
+
signal: controller.signal
|
|
12068
|
+
});
|
|
12069
|
+
const contentType = response.headers.get("content-type") ?? "";
|
|
12070
|
+
const text = await readBoundedResponseText(response, MAX_AUXILIARY_RESPONSE_BYTES, "Agent resource response");
|
|
12071
|
+
const exists = response.status >= 200 && response.status < 300;
|
|
12072
|
+
let validJson;
|
|
12073
|
+
if (exists && /\bjson\b/i.test(contentType)) try {
|
|
12074
|
+
JSON.parse(text);
|
|
12075
|
+
validJson = true;
|
|
12076
|
+
} catch {
|
|
12077
|
+
validJson = false;
|
|
12078
|
+
}
|
|
12079
|
+
return {
|
|
12080
|
+
url: response.url || url,
|
|
12081
|
+
exists,
|
|
12082
|
+
status: response.status,
|
|
12083
|
+
contentType,
|
|
12084
|
+
...validJson === void 0 ? {} : { validJson }
|
|
12085
|
+
};
|
|
12086
|
+
} catch {
|
|
12087
|
+
return {
|
|
12088
|
+
url,
|
|
12089
|
+
exists: false
|
|
12090
|
+
};
|
|
12091
|
+
} finally {
|
|
12092
|
+
controller.cleanup();
|
|
12093
|
+
}
|
|
12094
|
+
}
|
|
12095
|
+
async function checkAgentResources(input) {
|
|
12096
|
+
const resources = [];
|
|
12097
|
+
for (const path of AGENT_RESOURCE_PATHS) {
|
|
12098
|
+
if (input.signal?.aborted) break;
|
|
12099
|
+
resources.push(await checkAgentResource({
|
|
12100
|
+
baseUrl: input.url,
|
|
12101
|
+
path,
|
|
12102
|
+
timeoutMs: input.timeoutMs,
|
|
12103
|
+
fetch: input.fetch,
|
|
12104
|
+
signal: input.signal
|
|
12105
|
+
}));
|
|
12106
|
+
}
|
|
12107
|
+
return resources;
|
|
12108
|
+
}
|
|
12109
|
+
function collectCrawlAiSignals(input) {
|
|
12110
|
+
return Promise.all([
|
|
12111
|
+
checkLlmsTxt(input),
|
|
12112
|
+
checkRobotsAiAccess(input),
|
|
12113
|
+
checkAgentResources(input)
|
|
12114
|
+
]);
|
|
12115
|
+
}
|
|
12116
|
+
//#endregion
|
|
12117
|
+
//#region packages/core/src/analyze/crawler/site-crawl-providers.ts
|
|
12118
|
+
function crawlMetricsWindow(now) {
|
|
12119
|
+
return {
|
|
12120
|
+
...defaultDateRange(28, now),
|
|
12121
|
+
days: 28
|
|
12122
|
+
};
|
|
12123
|
+
}
|
|
12124
|
+
async function joinAnalytics(input) {
|
|
12125
|
+
let analytics;
|
|
12126
|
+
try {
|
|
12127
|
+
analytics = await input.fetchLandingPageValues({
|
|
12128
|
+
propertyId: input.propertyId,
|
|
12129
|
+
startDate: input.window.startDate,
|
|
12130
|
+
endDate: input.window.endDate,
|
|
12131
|
+
limit: input.limit
|
|
12132
|
+
});
|
|
12133
|
+
} catch (error) {
|
|
10679
12134
|
const warning = `Google Analytics metrics unavailable: ${error instanceof Error ? error.message : String(error)}`;
|
|
10680
12135
|
input.warnings.push(warning);
|
|
10681
12136
|
return {
|
|
@@ -11024,7 +12479,9 @@ function responseObservation(requestedUrl, fetched, extraction, extractionError)
|
|
|
11024
12479
|
status: fetched.status,
|
|
11025
12480
|
contentType: headerValue(fetched.headers, "content-type"),
|
|
11026
12481
|
durationMs: fetched.diagnostics.durationMs,
|
|
11027
|
-
redirectChain: fetched.diagnostics.redirectChain
|
|
12482
|
+
redirectChain: fetched.diagnostics.redirectChain,
|
|
12483
|
+
accessBlock: fetched.diagnostics.accessBlock,
|
|
12484
|
+
robotsTxt: fetched.robotsTxt
|
|
11028
12485
|
};
|
|
11029
12486
|
if (extraction === "failed") {
|
|
11030
12487
|
if (!extractionError) throw new Error("Failed extraction observations require an error.");
|
|
@@ -11049,13 +12506,13 @@ function unextractedPage(fetched, contentType) {
|
|
|
11049
12506
|
finalUrl: fetched.finalUrl,
|
|
11050
12507
|
status: fetched.status,
|
|
11051
12508
|
contentType,
|
|
11052
|
-
responseHeaders: safeResponseHeaders(fetched.headers),
|
|
11053
12509
|
responseTimeMs: fetched.diagnostics.durationMs,
|
|
11054
12510
|
sizeBytes: Buffer.byteLength(fetched.html),
|
|
11055
12511
|
usedJs: fetched.usedJs,
|
|
11056
12512
|
fetchSource: fetched.diagnostics.source,
|
|
11057
12513
|
cacheState: fetched.diagnostics.cache,
|
|
11058
12514
|
fetchDiagnostics: fetched.diagnostics,
|
|
12515
|
+
accessBlock: fetched.diagnostics.accessBlock,
|
|
11059
12516
|
blocked: fetched.diagnostics.blocked,
|
|
11060
12517
|
robotsTxt: fetched.robotsTxt,
|
|
11061
12518
|
xRobotsTag,
|
|
@@ -11076,6 +12533,35 @@ function unextractedPage(fetched, contentType) {
|
|
|
11076
12533
|
compression: headerValue(fetched.headers, "content-encoding")
|
|
11077
12534
|
};
|
|
11078
12535
|
}
|
|
12536
|
+
function statusOnlyPage(fetched) {
|
|
12537
|
+
const contentType = headerValue(fetched.headers, "content-type");
|
|
12538
|
+
return {
|
|
12539
|
+
url: fetched.finalUrl,
|
|
12540
|
+
finalUrl: fetched.finalUrl,
|
|
12541
|
+
status: fetched.status,
|
|
12542
|
+
contentType,
|
|
12543
|
+
responseHeaders: safeResponseHeaders(fetched.headers),
|
|
12544
|
+
responseTimeMs: fetched.diagnostics.durationMs,
|
|
12545
|
+
usedJs: false,
|
|
12546
|
+
fetchSource: "network",
|
|
12547
|
+
cacheState: "bypass",
|
|
12548
|
+
fetchDiagnostics: fetched.diagnostics,
|
|
12549
|
+
auditScope: "status",
|
|
12550
|
+
accessBlock: fetched.diagnostics.accessBlock,
|
|
12551
|
+
blocked: fetched.diagnostics.blocked,
|
|
12552
|
+
contentAuditAllowed: false,
|
|
12553
|
+
robotsTxt: fetched.robotsTxt,
|
|
12554
|
+
indexable: false,
|
|
12555
|
+
indexability: "Not evaluated by status-only health probe",
|
|
12556
|
+
declaredIndexability: "unknown",
|
|
12557
|
+
extractionStatus: "not-applicable",
|
|
12558
|
+
wordCount: 0,
|
|
12559
|
+
contentHash: "",
|
|
12560
|
+
outgoingInternalCount: 0,
|
|
12561
|
+
outgoingExternalCount: 0,
|
|
12562
|
+
isHttps: new URL(fetched.finalUrl).protocol === "https:"
|
|
12563
|
+
};
|
|
12564
|
+
}
|
|
11079
12565
|
function indexabilityReason(page) {
|
|
11080
12566
|
if (page.status < 200 || page.status >= 300) return `Status ${page.status}`;
|
|
11081
12567
|
if (hasMetaRobotsDirective(page.metaRobots, "noindex")) return "Meta robots noindex";
|
|
@@ -11129,13 +12615,26 @@ async function crawlOne(url, opts = {}) {
|
|
|
11129
12615
|
};
|
|
11130
12616
|
}
|
|
11131
12617
|
const contentType = headerValue(fetched.headers, "content-type");
|
|
12618
|
+
if (fetched.diagnostics.accessBlock) {
|
|
12619
|
+
const page = unextractedPage(fetched, contentType);
|
|
12620
|
+
page.contentAuditAllowed = false;
|
|
12621
|
+
page.indexable = false;
|
|
12622
|
+
page.indexability = "Crawler access blocked";
|
|
12623
|
+
page.declaredIndexability = "unknown";
|
|
12624
|
+
page.extractionStatus = "not-applicable";
|
|
12625
|
+
return {
|
|
12626
|
+
request: responseObservation(url, fetched, "not-applicable"),
|
|
12627
|
+
page,
|
|
12628
|
+
urls: []
|
|
12629
|
+
};
|
|
12630
|
+
}
|
|
11132
12631
|
if (!isHtmlContentType(contentType)) return {
|
|
11133
12632
|
request: responseObservation(url, fetched, contentType ? "not-applicable" : "unknown-media-type"),
|
|
11134
12633
|
page: unextractedPage(fetched, contentType),
|
|
11135
12634
|
urls: []
|
|
11136
12635
|
};
|
|
11137
12636
|
try {
|
|
11138
|
-
const extracted = await extractPage(fetched);
|
|
12637
|
+
const extracted = await extractPage(fetched, "crawler");
|
|
11139
12638
|
const base = new URL(extracted.finalUrl);
|
|
11140
12639
|
const internalLinks = extracted.links.map((link) => sameOriginUrl(link.href, base)).filter((value) => Boolean(value));
|
|
11141
12640
|
const externalLinks = extracted.links.filter((link) => !link.internal).map((link) => link.href).filter((href) => /^https?:\/\//.test(href));
|
|
@@ -11256,6 +12755,62 @@ async function crawlOne(url, opts = {}) {
|
|
|
11256
12755
|
};
|
|
11257
12756
|
}
|
|
11258
12757
|
}
|
|
12758
|
+
async function crawlStatusOnly(url, opts = {}) {
|
|
12759
|
+
const startedAt = Date.now();
|
|
12760
|
+
try {
|
|
12761
|
+
const fetched = await fetchPageStatus(url, {
|
|
12762
|
+
...opts,
|
|
12763
|
+
js: "off",
|
|
12764
|
+
refresh: true
|
|
12765
|
+
});
|
|
12766
|
+
return {
|
|
12767
|
+
request: responseObservation(url, fetched, "not-applicable"),
|
|
12768
|
+
page: statusOnlyPage(fetched),
|
|
12769
|
+
urls: []
|
|
12770
|
+
};
|
|
12771
|
+
} catch (error) {
|
|
12772
|
+
if (error instanceof OriginBackpressureError) return {
|
|
12773
|
+
request: {
|
|
12774
|
+
requestedUrl: url,
|
|
12775
|
+
outcome: "skipped",
|
|
12776
|
+
reason: "origin-backpressure",
|
|
12777
|
+
error: error.message,
|
|
12778
|
+
extraction: "not-applicable"
|
|
12779
|
+
},
|
|
12780
|
+
urls: []
|
|
12781
|
+
};
|
|
12782
|
+
if (error instanceof RobotsAccessError) return {
|
|
12783
|
+
request: {
|
|
12784
|
+
requestedUrl: url,
|
|
12785
|
+
outcome: "skipped",
|
|
12786
|
+
reason: error.reason,
|
|
12787
|
+
robotsTxt: {
|
|
12788
|
+
url: error.evidence.url,
|
|
12789
|
+
allowed: error.evidence.allowed,
|
|
12790
|
+
availability: error.evidence.availability,
|
|
12791
|
+
status: error.evidence.status,
|
|
12792
|
+
error: error.evidence.error,
|
|
12793
|
+
matchedLine: error.evidence.matchedLine
|
|
12794
|
+
},
|
|
12795
|
+
extraction: "not-applicable"
|
|
12796
|
+
},
|
|
12797
|
+
urls: []
|
|
12798
|
+
};
|
|
12799
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
12800
|
+
return {
|
|
12801
|
+
request: {
|
|
12802
|
+
requestedUrl: url,
|
|
12803
|
+
outcome: "failure",
|
|
12804
|
+
durationMs: Date.now() - startedAt,
|
|
12805
|
+
failureKind: fetchFailureKind(error, opts.signal),
|
|
12806
|
+
error: message,
|
|
12807
|
+
extraction: "not-applicable"
|
|
12808
|
+
},
|
|
12809
|
+
urls: [],
|
|
12810
|
+
warning: `${url}: ${message}`
|
|
12811
|
+
};
|
|
12812
|
+
}
|
|
12813
|
+
}
|
|
11259
12814
|
//#endregion
|
|
11260
12815
|
//#region packages/core/src/analyze/monitoring/sitemaps.ts
|
|
11261
12816
|
const INVALID_LOC_SAMPLE_LIMIT = 10;
|
|
@@ -11498,6 +13053,7 @@ async function fetchSitemapUrls(input) {
|
|
|
11498
13053
|
const nestedSitemaps = [];
|
|
11499
13054
|
const discoveredSitemaps = /* @__PURE__ */ new Set();
|
|
11500
13055
|
const scheduledSitemaps = /* @__PURE__ */ new Set();
|
|
13056
|
+
const fetchedSitemaps = /* @__PURE__ */ new Set();
|
|
11501
13057
|
const invalidLocSamples = [];
|
|
11502
13058
|
const malformedLastmodSamples = [];
|
|
11503
13059
|
const futureLastmodSamples = [];
|
|
@@ -11544,12 +13100,18 @@ async function fetchSitemapUrls(input) {
|
|
|
11544
13100
|
const sitemapUrl = queue.shift();
|
|
11545
13101
|
if (!sitemapUrl) continue;
|
|
11546
13102
|
const fetched = await fetchSitemapXml(sitemapUrl);
|
|
13103
|
+
fetchedSitemaps.add(sitemapUrl);
|
|
11547
13104
|
documents.push(fetched.document);
|
|
11548
13105
|
if (fetched.document.warning) warnings.push(fetched.document.warning);
|
|
11549
13106
|
if (fetched.document.dataStatus !== "unavailable") sitemapsFetched += 1;
|
|
11550
13107
|
if (!fetched.xml || !fetched.document.root) continue;
|
|
11551
13108
|
const $ = load(fetched.xml, { xmlMode: true });
|
|
11552
13109
|
$(fetched.document.root === "urlset" ? "urlset > url" : "").each((_, element) => {
|
|
13110
|
+
if (urls.length >= limit) {
|
|
13111
|
+
urlLimitExceeded = true;
|
|
13112
|
+
omittedUrlsAtLeast += 1;
|
|
13113
|
+
return false;
|
|
13114
|
+
}
|
|
11553
13115
|
urlLocs += 1;
|
|
11554
13116
|
const entry = $(element);
|
|
11555
13117
|
const value = entry.children("loc").first().text().trim();
|
|
@@ -11574,12 +13136,7 @@ async function fetchSitemapUrls(input) {
|
|
|
11574
13136
|
return;
|
|
11575
13137
|
}
|
|
11576
13138
|
seenUrls.add(url);
|
|
11577
|
-
|
|
11578
|
-
urls.push(url);
|
|
11579
|
-
return;
|
|
11580
|
-
}
|
|
11581
|
-
urlLimitExceeded = true;
|
|
11582
|
-
omittedUrlsAtLeast += 1;
|
|
13139
|
+
urls.push(url);
|
|
11583
13140
|
});
|
|
11584
13141
|
$(fetched.document.root === "sitemapindex" ? "sitemapindex > sitemap" : "").each((_, element) => {
|
|
11585
13142
|
sitemapLocs += 1;
|
|
@@ -11612,8 +13169,13 @@ async function fetchSitemapUrls(input) {
|
|
|
11612
13169
|
queue.push(child);
|
|
11613
13170
|
} else nestedSitemapLimitExceeded = true;
|
|
11614
13171
|
});
|
|
13172
|
+
if (urls.length >= limit && queue.length > 0) {
|
|
13173
|
+
urlLimitExceeded = true;
|
|
13174
|
+
omittedUrlsAtLeast = Math.max(omittedUrlsAtLeast, 1);
|
|
13175
|
+
break;
|
|
13176
|
+
}
|
|
11615
13177
|
}
|
|
11616
|
-
const unprocessedSitemaps = nestedSitemaps.filter((sitemap) => !
|
|
13178
|
+
const unprocessedSitemaps = nestedSitemaps.filter((sitemap) => !fetchedSitemaps.has(sitemap)).length;
|
|
11617
13179
|
const possiblyTruncated = urlLimitExceeded || nestedSitemapLimitExceeded || unprocessedSitemaps > 0;
|
|
11618
13180
|
if (invalidLocCount) warnings.push(`Ignored ${invalidLocCount} invalid sitemap <loc> ${invalidLocCount === 1 ? "entry" : "entries"}.`);
|
|
11619
13181
|
if (possiblyTruncated) warnings.push("Sitemap discovery exceeded a configured URL or sitemap boundary; the returned inventory is incomplete.");
|
|
@@ -11793,6 +13355,7 @@ function resolveCrawlSiteDependencies(dependencies = {}) {
|
|
|
11793
13355
|
const emptyPageTopQuery = async () => void 0;
|
|
11794
13356
|
return {
|
|
11795
13357
|
fetchPage: dependencies.fetchPage ?? crawlOne,
|
|
13358
|
+
fetchStatusPage: dependencies.fetchStatusPage ?? crawlStatusOnly,
|
|
11796
13359
|
fetchSitemapUrls: dependencies.fetchSitemapUrls ?? fetchSitemapUrls,
|
|
11797
13360
|
fetch: dependencies.fetch ?? publicHttpFetch,
|
|
11798
13361
|
queryPageMetrics: (injectedMode === "per-page" ? dependencies.queryPageMetrics : void 0) ?? (injectedMode ? emptyPageMetrics : queryPageMetrics),
|
|
@@ -11818,6 +13381,10 @@ function isSameOrigin(url, origin) {
|
|
|
11818
13381
|
}
|
|
11819
13382
|
}
|
|
11820
13383
|
function sitemapRootUrls(input) {
|
|
13384
|
+
if (input.sitemapUrl) return [{
|
|
13385
|
+
url: new URL(input.sitemapUrl).toString(),
|
|
13386
|
+
source: "explicit"
|
|
13387
|
+
}];
|
|
11821
13388
|
const origin = new URL(input.url).origin;
|
|
11822
13389
|
const declared = input.declaredSitemapUrls.filter((url) => isSameOrigin(url, origin));
|
|
11823
13390
|
const ignored = input.declaredSitemapUrls.length - declared.length;
|
|
@@ -11868,247 +13435,71 @@ async function sitemapSeeds(input) {
|
|
|
11868
13435
|
sitemapUrl: fallbackUrl,
|
|
11869
13436
|
limit: input.maxPages
|
|
11870
13437
|
});
|
|
11871
|
-
input.warnings.push(...sitemap.warnings);
|
|
11872
|
-
for (const url of sitemap.urls) urls.add(url);
|
|
11873
|
-
roots.push({
|
|
11874
|
-
url: sitemap.sitemapUrl,
|
|
11875
|
-
source: "default-path",
|
|
11876
|
-
dataStatus: sitemap.source.sitemapsFetched > 0 ? sitemap.dataStatus : "unavailable",
|
|
11877
|
-
urlsReturned: sitemap.urls.length,
|
|
11878
|
-
sitemapsFetched: sitemap.source.sitemapsFetched,
|
|
11879
|
-
lastmods: sitemap.source.lastmods,
|
|
11880
|
-
documents: sitemap.source.documents,
|
|
11881
|
-
possiblyTruncated: sitemap.truncation.possiblyTruncated,
|
|
11882
|
-
warnings: sitemap.warnings
|
|
11883
|
-
});
|
|
11884
|
-
}
|
|
11885
|
-
}
|
|
11886
|
-
return {
|
|
11887
|
-
urls: [...urls],
|
|
11888
|
-
discovery: {
|
|
11889
|
-
dataStatus: sitemapDiscoveryStatus(roots),
|
|
11890
|
-
urlsReturned: urls.size,
|
|
11891
|
-
roots
|
|
11892
|
-
}
|
|
11893
|
-
};
|
|
11894
|
-
}
|
|
11895
|
-
//#endregion
|
|
11896
|
-
//#region packages/core/src/analyze/crawler/site-crawl.ts
|
|
11897
|
-
const AI_BOT_USER_AGENTS = [
|
|
11898
|
-
"GPTBot",
|
|
11899
|
-
"OAI-SearchBot",
|
|
11900
|
-
"ChatGPT-User",
|
|
11901
|
-
"ClaudeBot",
|
|
11902
|
-
"PerplexityBot",
|
|
11903
|
-
"Google-Extended",
|
|
11904
|
-
"CCBot",
|
|
11905
|
-
"Applebot",
|
|
11906
|
-
"Bingbot"
|
|
11907
|
-
];
|
|
11908
|
-
const AGENT_RESOURCE_PATHS = [
|
|
11909
|
-
"/.well-known/agent.json",
|
|
11910
|
-
"/agent.json",
|
|
11911
|
-
"/.well-known/mcp.json",
|
|
11912
|
-
"/.well-known/ai-plugin.json",
|
|
11913
|
-
"/.well-known/openapi.json",
|
|
11914
|
-
"/openapi.json"
|
|
11915
|
-
];
|
|
11916
|
-
function normalizeUrl$1(value, base) {
|
|
11917
|
-
try {
|
|
11918
|
-
const url = base ? new URL(value, base) : new URL(value);
|
|
11919
|
-
url.hash = "";
|
|
11920
|
-
if (!["http:", "https:"].includes(url.protocol)) return void 0;
|
|
11921
|
-
return url.toString();
|
|
11922
|
-
} catch {
|
|
11923
|
-
return;
|
|
11924
|
-
}
|
|
11925
|
-
}
|
|
11926
|
-
function sameOrigin(url, origin) {
|
|
11927
|
-
try {
|
|
11928
|
-
return new URL(url).origin === origin;
|
|
11929
|
-
} catch {
|
|
11930
|
-
return false;
|
|
11931
|
-
}
|
|
11932
|
-
}
|
|
11933
|
-
function isLikelyAsset(url) {
|
|
11934
|
-
const path = new URL(url).pathname.toLowerCase();
|
|
11935
|
-
return /\.(avif|bmp|css|csv|dmg|eot|gif|gz|ico|jpeg|jpg|js|json|map|mov|mp3|mp4|ogg|otf|pdf|png|svg|tar|ttf|wav|webm|webp|woff|woff2|zip)$/.test(path);
|
|
11936
|
-
}
|
|
11937
|
-
function globMatch(pattern, value) {
|
|
11938
|
-
if (!pattern.includes("*")) return value.includes(pattern);
|
|
11939
|
-
const parts = pattern.split("*");
|
|
11940
|
-
let position = 0;
|
|
11941
|
-
for (const part of parts) {
|
|
11942
|
-
if (!part) continue;
|
|
11943
|
-
const found = value.slice(position).indexOf(part);
|
|
11944
|
-
if (found === -1) return false;
|
|
11945
|
-
position += found + part.length;
|
|
11946
|
-
}
|
|
11947
|
-
return true;
|
|
11948
|
-
}
|
|
11949
|
-
function passesFilters(url, include, exclude) {
|
|
11950
|
-
return (!include.length || include.some((pattern) => globMatch(pattern, url))) && !exclude.some((pattern) => globMatch(pattern, url));
|
|
11951
|
-
}
|
|
11952
|
-
async function checkLlmsTxt(input) {
|
|
11953
|
-
const llmsUrl = new URL("/llms.txt", input.url).toString();
|
|
11954
|
-
const controller = abortController({
|
|
11955
|
-
timeoutMs: input.timeoutMs,
|
|
11956
|
-
signal: input.signal
|
|
11957
|
-
});
|
|
11958
|
-
try {
|
|
11959
|
-
const response = await input.fetch(llmsUrl, {
|
|
11960
|
-
profile: "bot",
|
|
11961
|
-
redirect: "follow",
|
|
11962
|
-
signal: controller.signal
|
|
11963
|
-
});
|
|
11964
|
-
const contentType = response.headers.get("content-type") ?? "";
|
|
11965
|
-
const exists = response.status >= 200 && response.status < 300 && !/\btext\/html\b/i.test(contentType);
|
|
11966
|
-
await response.body?.cancel().catch(() => void 0);
|
|
11967
|
-
return {
|
|
11968
|
-
url: response.url || llmsUrl,
|
|
11969
|
-
exists,
|
|
11970
|
-
status: response.status
|
|
11971
|
-
};
|
|
11972
|
-
} catch {
|
|
11973
|
-
return {
|
|
11974
|
-
url: llmsUrl,
|
|
11975
|
-
exists: false
|
|
11976
|
-
};
|
|
11977
|
-
} finally {
|
|
11978
|
-
controller.cleanup();
|
|
11979
|
-
}
|
|
11980
|
-
}
|
|
11981
|
-
function parseRobots(robotsUrl, text) {
|
|
11982
|
-
return robotsParserModule(robotsUrl, text);
|
|
11983
|
-
}
|
|
11984
|
-
function declaredUserAgents(text) {
|
|
11985
|
-
const declared = /* @__PURE__ */ new Set();
|
|
11986
|
-
for (const match of text.matchAll(/^\s*user-agent\s*:\s*(.+?)\s*$/gim)) {
|
|
11987
|
-
const value = match[1]?.trim().toLowerCase();
|
|
11988
|
-
if (value) declared.add(value);
|
|
11989
|
-
}
|
|
11990
|
-
return declared;
|
|
11991
|
-
}
|
|
11992
|
-
function sitemapUrlsFromRobots(text) {
|
|
11993
|
-
const urls = /* @__PURE__ */ new Set();
|
|
11994
|
-
for (const match of text.matchAll(/^\s*sitemap\s*:\s*(\S+)\s*$/gim)) {
|
|
11995
|
-
const value = match[1]?.trim();
|
|
11996
|
-
if (!value) continue;
|
|
11997
|
-
try {
|
|
11998
|
-
urls.add(new URL(value).toString());
|
|
11999
|
-
} catch {}
|
|
12000
|
-
}
|
|
12001
|
-
return [...urls];
|
|
12002
|
-
}
|
|
12003
|
-
async function checkRobotsAiAccess(input) {
|
|
12004
|
-
const robotsUrl = new URL("/robots.txt", input.url).toString();
|
|
12005
|
-
const controller = abortController({
|
|
12006
|
-
timeoutMs: input.timeoutMs,
|
|
12007
|
-
signal: input.signal
|
|
12008
|
-
});
|
|
12009
|
-
try {
|
|
12010
|
-
const response = await input.fetch(robotsUrl, {
|
|
12011
|
-
profile: "bot",
|
|
12012
|
-
redirect: "follow",
|
|
12013
|
-
signal: controller.signal
|
|
12014
|
-
});
|
|
12015
|
-
const text = await response.text();
|
|
12016
|
-
const available = response.status >= 200 && response.status < 300;
|
|
12017
|
-
const absent = response.status >= 400 && response.status < 500 && response.status !== 429;
|
|
12018
|
-
const availability = available ? "available" : [401, 403].includes(response.status) ? "access-blocked" : response.status === 429 ? "rate-limited" : absent ? "absent" : "unreachable";
|
|
12019
|
-
const exists = available;
|
|
12020
|
-
const declared = exists ? declaredUserAgents(text) : /* @__PURE__ */ new Set();
|
|
12021
|
-
const parsed = parseRobots(robotsUrl, exists ? text : "");
|
|
12022
|
-
return {
|
|
12023
|
-
url: response.url || robotsUrl,
|
|
12024
|
-
exists,
|
|
12025
|
-
availability,
|
|
12026
|
-
status: response.status,
|
|
12027
|
-
...["rate-limited", "unreachable"].includes(availability) ? { error: `robots.txt returned HTTP ${response.status}.` } : {},
|
|
12028
|
-
sitemapUrls: exists ? sitemapUrlsFromRobots(text) : [],
|
|
12029
|
-
botAccess: AI_BOT_USER_AGENTS.map((userAgent) => {
|
|
12030
|
-
const lower = userAgent.toLowerCase();
|
|
12031
|
-
return {
|
|
12032
|
-
userAgent,
|
|
12033
|
-
allowed: availability === "rate-limited" || availability === "unreachable" ? null : parsed.isAllowed(input.url, userAgent) ?? true,
|
|
12034
|
-
declared: declared.has(lower),
|
|
12035
|
-
coveredByWildcard: declared.has("*")
|
|
12036
|
-
};
|
|
12037
|
-
})
|
|
12038
|
-
};
|
|
12039
|
-
} catch (error) {
|
|
12040
|
-
return {
|
|
12041
|
-
url: robotsUrl,
|
|
12042
|
-
exists: false,
|
|
12043
|
-
availability: "unreachable",
|
|
12044
|
-
error: error instanceof Error ? error.message : String(error),
|
|
12045
|
-
sitemapUrls: [],
|
|
12046
|
-
botAccess: AI_BOT_USER_AGENTS.map((userAgent) => ({
|
|
12047
|
-
userAgent,
|
|
12048
|
-
allowed: null,
|
|
12049
|
-
declared: false,
|
|
12050
|
-
coveredByWildcard: false
|
|
12051
|
-
}))
|
|
12052
|
-
};
|
|
12053
|
-
} finally {
|
|
12054
|
-
controller.cleanup();
|
|
13438
|
+
input.warnings.push(...sitemap.warnings);
|
|
13439
|
+
for (const url of sitemap.urls) urls.add(url);
|
|
13440
|
+
roots.push({
|
|
13441
|
+
url: sitemap.sitemapUrl,
|
|
13442
|
+
source: "default-path",
|
|
13443
|
+
dataStatus: sitemap.source.sitemapsFetched > 0 ? sitemap.dataStatus : "unavailable",
|
|
13444
|
+
urlsReturned: sitemap.urls.length,
|
|
13445
|
+
sitemapsFetched: sitemap.source.sitemapsFetched,
|
|
13446
|
+
lastmods: sitemap.source.lastmods,
|
|
13447
|
+
documents: sitemap.source.documents,
|
|
13448
|
+
possiblyTruncated: sitemap.truncation.possiblyTruncated,
|
|
13449
|
+
warnings: sitemap.warnings
|
|
13450
|
+
});
|
|
13451
|
+
}
|
|
12055
13452
|
}
|
|
13453
|
+
return {
|
|
13454
|
+
urls: [...urls],
|
|
13455
|
+
discovery: {
|
|
13456
|
+
dataStatus: sitemapDiscoveryStatus(roots),
|
|
13457
|
+
urlsReturned: urls.size,
|
|
13458
|
+
roots
|
|
13459
|
+
}
|
|
13460
|
+
};
|
|
12056
13461
|
}
|
|
12057
|
-
|
|
12058
|
-
|
|
12059
|
-
|
|
12060
|
-
timeoutMs: input.timeoutMs,
|
|
12061
|
-
signal: input.signal
|
|
12062
|
-
});
|
|
13462
|
+
//#endregion
|
|
13463
|
+
//#region packages/core/src/analyze/crawler/site-crawl.ts
|
|
13464
|
+
function normalizeUrl$1(value, base) {
|
|
12063
13465
|
try {
|
|
12064
|
-
const
|
|
12065
|
-
|
|
12066
|
-
|
|
12067
|
-
|
|
12068
|
-
});
|
|
12069
|
-
const contentType = response.headers.get("content-type") ?? "";
|
|
12070
|
-
const text = await response.text();
|
|
12071
|
-
const exists = response.status >= 200 && response.status < 300;
|
|
12072
|
-
let validJson;
|
|
12073
|
-
if (exists && /\bjson\b/i.test(contentType)) try {
|
|
12074
|
-
JSON.parse(text);
|
|
12075
|
-
validJson = true;
|
|
12076
|
-
} catch {
|
|
12077
|
-
validJson = false;
|
|
12078
|
-
}
|
|
12079
|
-
return {
|
|
12080
|
-
url: response.url || url,
|
|
12081
|
-
exists,
|
|
12082
|
-
status: response.status,
|
|
12083
|
-
contentType,
|
|
12084
|
-
...validJson === void 0 ? {} : { validJson }
|
|
12085
|
-
};
|
|
13466
|
+
const url = base ? new URL(value, base) : new URL(value);
|
|
13467
|
+
url.hash = "";
|
|
13468
|
+
if (!["http:", "https:"].includes(url.protocol)) return void 0;
|
|
13469
|
+
return url.toString();
|
|
12086
13470
|
} catch {
|
|
12087
|
-
return
|
|
12088
|
-
url,
|
|
12089
|
-
exists: false
|
|
12090
|
-
};
|
|
12091
|
-
} finally {
|
|
12092
|
-
controller.cleanup();
|
|
13471
|
+
return;
|
|
12093
13472
|
}
|
|
12094
13473
|
}
|
|
12095
|
-
|
|
12096
|
-
|
|
12097
|
-
|
|
12098
|
-
|
|
12099
|
-
|
|
12100
|
-
baseUrl: input.url,
|
|
12101
|
-
path,
|
|
12102
|
-
timeoutMs: input.timeoutMs,
|
|
12103
|
-
fetch: input.fetch,
|
|
12104
|
-
signal: input.signal
|
|
12105
|
-
}));
|
|
13474
|
+
function sameOrigin(url, origin) {
|
|
13475
|
+
try {
|
|
13476
|
+
return new URL(url).origin === origin;
|
|
13477
|
+
} catch {
|
|
13478
|
+
return false;
|
|
12106
13479
|
}
|
|
12107
|
-
|
|
13480
|
+
}
|
|
13481
|
+
function isLikelyAsset(url) {
|
|
13482
|
+
const path = new URL(url).pathname.toLowerCase();
|
|
13483
|
+
return /\.(avif|bmp|css|csv|dmg|eot|gif|gz|ico|jpeg|jpg|js|json|map|mov|mp3|mp4|ogg|otf|pdf|png|svg|tar|ttf|wav|webm|webp|woff|woff2|zip)$/.test(path);
|
|
13484
|
+
}
|
|
13485
|
+
function globMatch(pattern, value) {
|
|
13486
|
+
if (!pattern.includes("*")) return value.includes(pattern);
|
|
13487
|
+
const parts = pattern.split("*");
|
|
13488
|
+
let position = 0;
|
|
13489
|
+
for (const part of parts) {
|
|
13490
|
+
if (!part) continue;
|
|
13491
|
+
const found = value.slice(position).indexOf(part);
|
|
13492
|
+
if (found === -1) return false;
|
|
13493
|
+
position += found + part.length;
|
|
13494
|
+
}
|
|
13495
|
+
return true;
|
|
13496
|
+
}
|
|
13497
|
+
function passesFilters(url, include, exclude) {
|
|
13498
|
+
return (!include.length || include.some((pattern) => globMatch(pattern, url))) && !exclude.some((pattern) => globMatch(pattern, url));
|
|
12108
13499
|
}
|
|
12109
13500
|
async function crawlSite(input, dependencies) {
|
|
12110
13501
|
const deps = resolveCrawlSiteDependencies(dependencies);
|
|
12111
|
-
const config = normalizeCrawlConfig(input);
|
|
13502
|
+
const config = assertCrawlConfigLimits(normalizeCrawlConfig(input));
|
|
12112
13503
|
const { searchMetricsLimit, analyticsLimit } = crawlProviderLimits(input);
|
|
12113
13504
|
const signal = input.signal;
|
|
12114
13505
|
let cancelled = Boolean(signal?.aborted);
|
|
@@ -12116,17 +13507,19 @@ async function crawlSite(input, dependencies) {
|
|
|
12116
13507
|
const site = input.site;
|
|
12117
13508
|
const origin = new URL(config.url).origin;
|
|
12118
13509
|
const warnings = [];
|
|
12119
|
-
const queue =
|
|
13510
|
+
const queue = new CrawlUrlQueue();
|
|
12120
13511
|
const visited = /* @__PURE__ */ new Set();
|
|
12121
|
-
const queued = /* @__PURE__ */ new Set();
|
|
12122
13512
|
const discovered = /* @__PURE__ */ new Set();
|
|
12123
|
-
const
|
|
13513
|
+
const incomingLinkCounts = /* @__PURE__ */ new Map();
|
|
12124
13514
|
const fetchedAliases = /* @__PURE__ */ new Map();
|
|
12125
13515
|
const pages = [];
|
|
12126
13516
|
const requests = [];
|
|
12127
13517
|
const documentIndexes = /* @__PURE__ */ new Map();
|
|
12128
13518
|
const inFlight = /* @__PURE__ */ new Set();
|
|
12129
13519
|
const followLinks = config.mode === "site";
|
|
13520
|
+
const healthStrategy = config.strategy === "health";
|
|
13521
|
+
let activeConcurrency = healthStrategy ? 1 : config.concurrency;
|
|
13522
|
+
let healthyProbeStreak = 0;
|
|
12130
13523
|
let queuedUrls = 0;
|
|
12131
13524
|
let skippedUrls = 0;
|
|
12132
13525
|
const skipReasonCounts = {};
|
|
@@ -12150,7 +13543,7 @@ async function crawlSite(input, dependencies) {
|
|
|
12150
13543
|
generatedAt: deps.now().toISOString(),
|
|
12151
13544
|
discoveredUrls: discovered.size,
|
|
12152
13545
|
queuedUrls,
|
|
12153
|
-
pendingUrls: queue.
|
|
13546
|
+
pendingUrls: queue.size,
|
|
12154
13547
|
inFlightUrls: inFlight.size,
|
|
12155
13548
|
crawledUrls: pages.length,
|
|
12156
13549
|
skippedUrls,
|
|
@@ -12164,36 +13557,27 @@ async function crawlSite(input, dependencies) {
|
|
|
12164
13557
|
const flushStatusEvents = async () => {
|
|
12165
13558
|
await statusEventChain;
|
|
12166
13559
|
};
|
|
12167
|
-
const renderer = config.js === "off" ? void 0 : createPageRenderer({ concurrency: config.concurrency });
|
|
13560
|
+
const renderer = config.js === "off" || healthStrategy ? void 0 : createPageRenderer({ concurrency: config.concurrency });
|
|
13561
|
+
const robotsSession = healthStrategy ? createRobotsSession({
|
|
13562
|
+
refresh: true,
|
|
13563
|
+
writeCache: false,
|
|
13564
|
+
timeoutMs: config.timeoutMs,
|
|
13565
|
+
signal
|
|
13566
|
+
}) : void 0;
|
|
13567
|
+
const robotsResolver = robotsSession?.resolve;
|
|
12168
13568
|
try {
|
|
12169
13569
|
emitStatus("started", {
|
|
12170
13570
|
url: config.url,
|
|
12171
13571
|
message: `Started crawl for ${config.url}.`
|
|
12172
13572
|
});
|
|
12173
|
-
const aiSignals = isCancelled() ? void 0 : await
|
|
12174
|
-
|
|
12175
|
-
|
|
12176
|
-
|
|
12177
|
-
|
|
12178
|
-
|
|
12179
|
-
|
|
12180
|
-
|
|
12181
|
-
url: config.url,
|
|
12182
|
-
timeoutMs: config.timeoutMs,
|
|
12183
|
-
fetch: deps.fetch,
|
|
12184
|
-
signal
|
|
12185
|
-
}),
|
|
12186
|
-
checkAgentResources({
|
|
12187
|
-
url: config.url,
|
|
12188
|
-
timeoutMs: config.timeoutMs,
|
|
12189
|
-
fetch: deps.fetch,
|
|
12190
|
-
signal
|
|
12191
|
-
})
|
|
12192
|
-
]);
|
|
12193
|
-
const llmsTxt = aiSignals?.[0] ?? {
|
|
12194
|
-
url: new URL("/llms.txt", config.url).toString(),
|
|
12195
|
-
exists: false
|
|
12196
|
-
};
|
|
13573
|
+
const aiSignals = isCancelled() || healthStrategy ? void 0 : await collectCrawlAiSignals({
|
|
13574
|
+
url: config.url,
|
|
13575
|
+
timeoutMs: config.timeoutMs,
|
|
13576
|
+
fetch: deps.fetch,
|
|
13577
|
+
signal
|
|
13578
|
+
});
|
|
13579
|
+
const llmsTxt = aiSignals?.[0];
|
|
13580
|
+
const healthSitemapUrls = healthStrategy && !config.sitemapUrl && !isCancelled() ? await robotsSession?.sitemapUrls(origin) : void 0;
|
|
12197
13581
|
const robotsTxt = aiSignals?.[1];
|
|
12198
13582
|
const agentResources = aiSignals?.[2];
|
|
12199
13583
|
cancelled = isCancelled();
|
|
@@ -12208,12 +13592,9 @@ async function crawlSite(input, dependencies) {
|
|
|
12208
13592
|
});
|
|
12209
13593
|
return;
|
|
12210
13594
|
}
|
|
12211
|
-
discovered.add(normalized);
|
|
12212
13595
|
if (visited.has(normalized)) return;
|
|
12213
|
-
if (
|
|
12214
|
-
|
|
12215
|
-
if (existing && depth < existing.depth) existing.depth = depth;
|
|
12216
|
-
queue.sort((left, right) => left.depth - right.depth || (left.url < right.url ? -1 : left.url > right.url ? 1 : 0));
|
|
13596
|
+
if (queue.has(normalized)) {
|
|
13597
|
+
queue.decreaseDepth(normalized, depth);
|
|
12217
13598
|
return;
|
|
12218
13599
|
}
|
|
12219
13600
|
if (!sameOrigin(normalized, origin)) {
|
|
@@ -12243,7 +13624,7 @@ async function crawlSite(input, dependencies) {
|
|
|
12243
13624
|
});
|
|
12244
13625
|
return;
|
|
12245
13626
|
}
|
|
12246
|
-
if (queue.
|
|
13627
|
+
if (queue.size + visited.size + inFlight.size >= config.maxPages * 5) {
|
|
12247
13628
|
recordSkip("queue-safety-limit");
|
|
12248
13629
|
queueSafetySkippedUrls += 1;
|
|
12249
13630
|
emitStatus("url_skipped", {
|
|
@@ -12253,13 +13634,12 @@ async function crawlSite(input, dependencies) {
|
|
|
12253
13634
|
});
|
|
12254
13635
|
return;
|
|
12255
13636
|
}
|
|
12256
|
-
|
|
13637
|
+
discovered.add(normalized);
|
|
12257
13638
|
queuedUrls += 1;
|
|
12258
13639
|
queue.push({
|
|
12259
13640
|
url: normalized,
|
|
12260
13641
|
depth
|
|
12261
13642
|
});
|
|
12262
|
-
queue.sort((left, right) => left.depth - right.depth || (left.url < right.url ? -1 : left.url > right.url ? 1 : 0));
|
|
12263
13643
|
emitStatus("url_queued", {
|
|
12264
13644
|
url: normalized,
|
|
12265
13645
|
depth
|
|
@@ -12272,8 +13652,9 @@ async function crawlSite(input, dependencies) {
|
|
|
12272
13652
|
if (!isCancelled() && config.useSitemap && (config.mode === "site" || config.mode === "sitemap")) {
|
|
12273
13653
|
const sitemap = await sitemapSeeds({
|
|
12274
13654
|
url: config.url,
|
|
13655
|
+
sitemapUrl: config.sitemapUrl,
|
|
12275
13656
|
maxPages: config.maxPages,
|
|
12276
|
-
declaredSitemapUrls: robotsTxt?.sitemapUrls ?? [],
|
|
13657
|
+
declaredSitemapUrls: robotsTxt?.sitemapUrls ?? healthSitemapUrls ?? [],
|
|
12277
13658
|
warnings,
|
|
12278
13659
|
fetchSitemapUrls: deps.fetchSitemapUrls
|
|
12279
13660
|
});
|
|
@@ -12281,8 +13662,8 @@ async function crawlSite(input, dependencies) {
|
|
|
12281
13662
|
sitemapDiscovery = sitemap.discovery;
|
|
12282
13663
|
}
|
|
12283
13664
|
const nextQueueItem = () => {
|
|
12284
|
-
while (queue.
|
|
12285
|
-
const item = queue.
|
|
13665
|
+
while (queue.size) {
|
|
13666
|
+
const item = queue.take();
|
|
12286
13667
|
if (!item) continue;
|
|
12287
13668
|
const normalized = normalizeUrl$1(item.url, config.url);
|
|
12288
13669
|
if (!normalized) continue;
|
|
@@ -12296,15 +13677,17 @@ async function crawlSite(input, dependencies) {
|
|
|
12296
13677
|
};
|
|
12297
13678
|
const startTask = (item) => {
|
|
12298
13679
|
let task;
|
|
13680
|
+
const fetcher = healthStrategy ? deps.fetchStatusPage : deps.fetchPage;
|
|
12299
13681
|
task = {
|
|
12300
13682
|
...item,
|
|
12301
|
-
promise:
|
|
13683
|
+
promise: fetcher(item.url, {
|
|
12302
13684
|
js: config.js,
|
|
12303
13685
|
refresh: config.refresh,
|
|
12304
13686
|
timeoutMs: config.timeoutMs,
|
|
12305
13687
|
rate: config.fetchRate,
|
|
12306
13688
|
signal,
|
|
12307
13689
|
respectRobots: config.respectRobots,
|
|
13690
|
+
robotsResolver,
|
|
12308
13691
|
renderer
|
|
12309
13692
|
}).then((result) => ({
|
|
12310
13693
|
task,
|
|
@@ -12330,8 +13713,8 @@ async function crawlSite(input, dependencies) {
|
|
|
12330
13713
|
depth: item.depth
|
|
12331
13714
|
});
|
|
12332
13715
|
};
|
|
12333
|
-
while (!isCancelled() && (queue.
|
|
12334
|
-
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) {
|
|
12335
13718
|
const item = nextQueueItem();
|
|
12336
13719
|
if (!item) break;
|
|
12337
13720
|
startTask(item);
|
|
@@ -12352,6 +13735,13 @@ async function crawlSite(input, dependencies) {
|
|
|
12352
13735
|
extraction: "not-applicable"
|
|
12353
13736
|
});
|
|
12354
13737
|
requests.push(request);
|
|
13738
|
+
if (healthStrategy) if (request.outcome === "response" && request.status < 400 && !request.accessBlock) {
|
|
13739
|
+
healthyProbeStreak += 1;
|
|
13740
|
+
activeConcurrency = Math.min(config.concurrency, 1 + Math.floor(healthyProbeStreak / 20));
|
|
13741
|
+
} else {
|
|
13742
|
+
healthyProbeStreak = 0;
|
|
13743
|
+
activeConcurrency = 1;
|
|
13744
|
+
}
|
|
12355
13745
|
if (request.outcome === "skipped") {
|
|
12356
13746
|
recordSkip(request.reason === "robots-disallowed" ? "robots-disallowed" : request.reason === "robots-deferred" ? "robots-uncertain" : "origin-backpressure");
|
|
12357
13747
|
if (request.reason === "robots-deferred") {
|
|
@@ -12414,40 +13804,49 @@ async function crawlSite(input, dependencies) {
|
|
|
12414
13804
|
};
|
|
12415
13805
|
const existing = documentIndexes.get(finalUrl);
|
|
12416
13806
|
if (existing) {
|
|
12417
|
-
const links = /* @__PURE__ */ new Set([...existing.links, ...normalizedLinks]);
|
|
12418
13807
|
const minDepth = Math.min(existing.minDepth, task.depth);
|
|
12419
13808
|
const replace = !existing.directlyRequested && directlyRequested || existing.directlyRequested === directlyRequested && sourceRequest < existing.sourceRequest;
|
|
12420
13809
|
if (replace) {
|
|
13810
|
+
const existingPage = pages[existing.index];
|
|
13811
|
+
const previousLinks = existing.pendingAliasLinks ?? (existingPage && existingPage.outgoingInternalCount === existingPage.sampleInternalLinks?.length ? new Set(existingPage.sampleInternalLinks) : void 0);
|
|
13812
|
+
const links = new Set(normalizedLinks);
|
|
13813
|
+
if (previousLinks) {
|
|
13814
|
+
for (const target of previousLinks) {
|
|
13815
|
+
const nextCount = (incomingLinkCounts.get(target) ?? 0) - 1;
|
|
13816
|
+
if (nextCount > 0) incomingLinkCounts.set(target, nextCount);
|
|
13817
|
+
else incomingLinkCounts.delete(target);
|
|
13818
|
+
}
|
|
13819
|
+
observedInternalLinks -= previousLinks.size;
|
|
13820
|
+
}
|
|
13821
|
+
for (const target of links) incomingLinkCounts.set(target, (incomingLinkCounts.get(target) ?? 0) + 1);
|
|
13822
|
+
observedInternalLinks += links.size;
|
|
13823
|
+
syncLinkEvidence(page, links);
|
|
12421
13824
|
page.crawlDepth = minDepth;
|
|
12422
13825
|
pages[existing.index] = page;
|
|
12423
13826
|
} else {
|
|
12424
13827
|
const existingPage = pages[existing.index];
|
|
12425
13828
|
if (existingPage) existingPage.crawlDepth = minDepth;
|
|
12426
13829
|
}
|
|
12427
|
-
const retainedPage = pages[existing.index];
|
|
12428
|
-
if (retainedPage) syncLinkEvidence(retainedPage, links);
|
|
12429
|
-
observedInternalLinks += links.size - existing.links.size;
|
|
12430
13830
|
documentIndexes.set(finalUrl, {
|
|
12431
13831
|
...existing,
|
|
12432
13832
|
directlyRequested: existing.directlyRequested || directlyRequested,
|
|
12433
13833
|
sourceRequest: replace ? sourceRequest : existing.sourceRequest,
|
|
12434
|
-
|
|
13834
|
+
pendingAliasLinks: void 0,
|
|
12435
13835
|
minDepth
|
|
12436
13836
|
});
|
|
12437
|
-
linkGraph[finalUrl] = [...links].sort((left, right) => left < right ? -1 : left > right ? 1 : 0);
|
|
12438
13837
|
} else {
|
|
12439
13838
|
const links = new Set(normalizedLinks);
|
|
12440
13839
|
syncLinkEvidence(page, links);
|
|
12441
13840
|
pages.push(page);
|
|
12442
13841
|
observedInternalLinks += links.size;
|
|
13842
|
+
for (const target of links) incomingLinkCounts.set(target, (incomingLinkCounts.get(target) ?? 0) + 1);
|
|
12443
13843
|
documentIndexes.set(finalUrl, {
|
|
12444
13844
|
index: pages.length - 1,
|
|
12445
13845
|
directlyRequested,
|
|
12446
13846
|
sourceRequest,
|
|
12447
|
-
links,
|
|
13847
|
+
pendingAliasLinks: !directlyRequested ? links : void 0,
|
|
12448
13848
|
minDepth: task.depth
|
|
12449
13849
|
});
|
|
12450
|
-
linkGraph[finalUrl] = [...links].sort((left, right) => left < right ? -1 : left > right ? 1 : 0);
|
|
12451
13850
|
}
|
|
12452
13851
|
};
|
|
12453
13852
|
if (config.respectRobots && page.robotsTxt?.allowed === false) {
|
|
@@ -12475,27 +13874,16 @@ async function crawlSite(input, dependencies) {
|
|
|
12475
13874
|
});
|
|
12476
13875
|
if (followLinks && task.depth < config.maxDepth) for (const url of result.urls) enqueue(url, task.depth + 1);
|
|
12477
13876
|
}
|
|
12478
|
-
const
|
|
12479
|
-
const
|
|
12480
|
-
const
|
|
12481
|
-
|
|
12482
|
-
|
|
12483
|
-
|
|
12484
|
-
const sourceIndex = pageIndexes.get(sourceUrl);
|
|
12485
|
-
if (sourceIndex === void 0) continue;
|
|
12486
|
-
for (const target of targets) {
|
|
12487
|
-
const targetIndex = pageIndexes.get(target);
|
|
12488
|
-
if (targetIndex === void 0) continue;
|
|
12489
|
-
const nextDepth = (depths[sourceIndex] ?? 0) + 1;
|
|
12490
|
-
if (nextDepth >= (depths[targetIndex] ?? Number.POSITIVE_INFINITY)) continue;
|
|
12491
|
-
depths[targetIndex] = nextDepth;
|
|
12492
|
-
changed = true;
|
|
12493
|
-
}
|
|
12494
|
-
}
|
|
12495
|
-
if (!changed) break;
|
|
13877
|
+
const resolvedInlinkCounts = resolveLinkCountAliases(incomingLinkCounts, fetchedAliases);
|
|
13878
|
+
const maxInlinks = Math.max(0, ...pages.map((page) => resolvedInlinkCounts.get(page.finalUrl) ?? 0));
|
|
13879
|
+
for (const page of pages) {
|
|
13880
|
+
const internalInlinkCount = resolvedInlinkCounts.get(page.finalUrl) ?? 0;
|
|
13881
|
+
page.internalInlinkCount = internalInlinkCount;
|
|
13882
|
+
page.internalLinkAuthorityScore = maxInlinks ? Math.round(internalInlinkCount / maxInlinks * 100) : 0;
|
|
12496
13883
|
}
|
|
12497
|
-
for (const [index, page] of pages.entries()) page.crawlDepth = depths[index];
|
|
12498
13884
|
cancelled = isCancelled();
|
|
13885
|
+
const robotsDisallowed = skipReasonCounts["robots-disallowed"] ?? 0;
|
|
13886
|
+
if (robotsDisallowed > 0) warnings.push(`${robotsDisallowed} URL${robotsDisallowed === 1 ? "" : "s"} disallowed ${SEO_CRAWLER_USER_AGENT} in robots.txt. If the audit should reach them, allow the SEO-Skill token on only those public paths.`);
|
|
12499
13887
|
if (cancelled) {
|
|
12500
13888
|
warnings.push("Crawl cancelled; returning a partial report.");
|
|
12501
13889
|
emitStatus("cancelled", {
|
|
@@ -12503,15 +13891,17 @@ async function crawlSite(input, dependencies) {
|
|
|
12503
13891
|
message: "Crawl cancelled; returning a partial report."
|
|
12504
13892
|
});
|
|
12505
13893
|
}
|
|
12506
|
-
const pageLimitReached = pages.length >= config.maxPages && (queue.
|
|
13894
|
+
const pageLimitReached = pages.length >= config.maxPages && (queue.size > 0 || inFlight.size > 0);
|
|
12507
13895
|
const sitemapEvidencePartial = sitemapDiscovery?.dataStatus === "partial";
|
|
12508
|
-
const
|
|
12509
|
-
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"));
|
|
12510
13900
|
const requestEvidenceStatus = cancelled && inFlight.size > 0 ? "partial" : "available";
|
|
12511
13901
|
const dataSources = await crawlDataSources({
|
|
12512
13902
|
cancelled,
|
|
12513
|
-
site,
|
|
12514
|
-
googleAnalyticsPropertyId: input.googleAnalyticsPropertyId,
|
|
13903
|
+
site: healthStrategy ? void 0 : site,
|
|
13904
|
+
googleAnalyticsPropertyId: healthStrategy ? void 0 : input.googleAnalyticsPropertyId,
|
|
12515
13905
|
pages,
|
|
12516
13906
|
warnings,
|
|
12517
13907
|
searchMetricsLimit,
|
|
@@ -12554,7 +13944,7 @@ async function crawlSite(input, dependencies) {
|
|
|
12554
13944
|
concurrency: Math.min(config.concurrency, 4)
|
|
12555
13945
|
}) : void 0;
|
|
12556
13946
|
for (const page of pages) {
|
|
12557
|
-
if (!page.geo) continue;
|
|
13947
|
+
if (!page.geo || !llmsTxt) continue;
|
|
12558
13948
|
page.geo = {
|
|
12559
13949
|
...page.geo,
|
|
12560
13950
|
hasLlmsTxt: llmsTxt.exists,
|
|
@@ -12571,12 +13961,11 @@ async function crawlSite(input, dependencies) {
|
|
|
12571
13961
|
pages,
|
|
12572
13962
|
requests,
|
|
12573
13963
|
requestEvidenceStatus,
|
|
12574
|
-
|
|
12575
|
-
|
|
12576
|
-
llmsTxt,
|
|
13964
|
+
...!healthStrategy ? { ai: {
|
|
13965
|
+
...llmsTxt ? { llmsTxt } : {},
|
|
12577
13966
|
...robotsTxt ? { robotsTxt } : {},
|
|
12578
13967
|
...agentResources ? { agentResources } : {}
|
|
12579
|
-
},
|
|
13968
|
+
} } : {},
|
|
12580
13969
|
...sitemapDiscovery ? { sitemapDiscovery } : {},
|
|
12581
13970
|
...externalLinkVerification ? { externalLinkVerification } : {},
|
|
12582
13971
|
...agentDiscovery ? { agentDiscovery } : {},
|
|
@@ -12620,7 +14009,7 @@ const REPORT_BASELINE_MAX_AGE_MS = 10080 * 60 * 1e3;
|
|
|
12620
14009
|
const defaultDependencies$13 = {
|
|
12621
14010
|
latestCrawlReport,
|
|
12622
14011
|
crawlSite,
|
|
12623
|
-
saveCrawlReport,
|
|
14012
|
+
saveCrawlReport: (report) => saveCrawlReport(report, crawlReportStore, { retention: "baseline" }),
|
|
12624
14013
|
now: () => /* @__PURE__ */ new Date()
|
|
12625
14014
|
};
|
|
12626
14015
|
function normalizePathname(value) {
|
|
@@ -13937,6 +15326,8 @@ function renderCrawlPretty(report, fixes = topFixes(report)) {
|
|
|
13937
15326
|
`Crawl report for ${report.config.url}`,
|
|
13938
15327
|
"",
|
|
13939
15328
|
`Status: ${report.status}`,
|
|
15329
|
+
`Strategy: ${report.config.strategy === "health" ? "sitemap health pass" : "full audit"}`,
|
|
15330
|
+
`Crawler: ${report.access.crawler.userAgent}`,
|
|
13940
15331
|
`Documents: ${report.summary.totalPages} retained; ${requestSummary}, ${report.summary.discoveredUrls} URLs discovered, ${report.summary.failedUrls} failed, ${report.summary.skippedUrls} skipped`,
|
|
13941
15332
|
`Issues: ${report.issues.length} (${report.summary.highIssues} high, ${report.summary.mediumIssues} medium, ${report.summary.lowIssues} low)`
|
|
13942
15333
|
];
|
|
@@ -13944,6 +15335,14 @@ function renderCrawlPretty(report, fixes = topFixes(report)) {
|
|
|
13944
15335
|
const external = report.externalLinkVerification;
|
|
13945
15336
|
lines.push(`External links: ${external.fetchedUrls} fetched, ${external.failedUrls} request failure${external.failedUrls === 1 ? "" : "s"}, ${external.deferredUrls} deferred (${external.dataStatus})`);
|
|
13946
15337
|
}
|
|
15338
|
+
if (report.access.blockedRequests > 0) {
|
|
15339
|
+
lines.push("", `Access blocks: ${report.access.blockedRequests}`);
|
|
15340
|
+
for (const sample of report.access.samples.slice(0, 3)) {
|
|
15341
|
+
const requestId = sample.evidence.requestId ? `, request ID ${sample.evidence.requestId}` : "";
|
|
15342
|
+
lines.push(`- ${sample.url}: ${sample.evidence.guidance.summary} (${sample.evidence.provider}${requestId})`, ` Action: ${sample.evidence.guidance.recommendedAction}`, ` Security: ${sample.evidence.guidance.securityNote}`);
|
|
15343
|
+
if (sample.evidence.guidance.documentationUrl) lines.push(` Provider docs: ${sample.evidence.guidance.documentationUrl}`);
|
|
15344
|
+
}
|
|
15345
|
+
}
|
|
13947
15346
|
if (fixes.length) {
|
|
13948
15347
|
lines.push("", "Prioritised fixes");
|
|
13949
15348
|
for (const fix of fixes.slice(0, 5)) {
|
|
@@ -13958,12 +15357,108 @@ function renderCrawlPretty(report, fixes = topFixes(report)) {
|
|
|
13958
15357
|
if (observation.sampleUrls[0]) lines.push(` First URL: ${observation.sampleUrls[0]}`);
|
|
13959
15358
|
}
|
|
13960
15359
|
}
|
|
13961
|
-
lines.push("", "Next commands", `- seo crawl ${report.config.url} --json`);
|
|
15360
|
+
lines.push("", "Next commands", report.config.strategy === "health" ? `- seo crawl ${report.config.url} --max-pages ${report.config.maxPages} --json` : `- seo crawl ${report.config.url} --json`);
|
|
13962
15361
|
for (const command of [...new Set(fixes.slice(0, 3).map((fix) => fix.verification.command))]) lines.push(`- ${command}`);
|
|
13963
15362
|
if (report.warnings.length) lines.push("", "Warnings", ...report.warnings.map((item) => `- ${item}`));
|
|
13964
15363
|
if (report.caveats.length) lines.push("", "Caveats", ...report.caveats.map((item) => `- ${item}`));
|
|
13965
15364
|
return `${lines.join("\n")}\n`;
|
|
13966
15365
|
}
|
|
15366
|
+
function renderCrawlJunit(report) {
|
|
15367
|
+
const issuesByUrl = /* @__PURE__ */ new Map();
|
|
15368
|
+
for (const issue of report.issues) {
|
|
15369
|
+
const existing = issuesByUrl.get(issue.url) ?? [];
|
|
15370
|
+
existing.push(issue);
|
|
15371
|
+
issuesByUrl.set(issue.url, existing);
|
|
15372
|
+
}
|
|
15373
|
+
const sitemapCases = (report.sitemapDiscovery?.roots ?? []).flatMap((root) => {
|
|
15374
|
+
return (root.documents.length ? root.documents : [{
|
|
15375
|
+
url: root.url,
|
|
15376
|
+
dataStatus: root.dataStatus,
|
|
15377
|
+
compression: "none",
|
|
15378
|
+
warning: root.warnings.join(" ") || void 0
|
|
15379
|
+
}]).map((document) => {
|
|
15380
|
+
const failures = [
|
|
15381
|
+
...document.dataStatus !== "complete" ? [document.warning ?? `Sitemap evidence is ${document.dataStatus}.`] : [],
|
|
15382
|
+
...document.redirected ? [`Sitemap redirected to ${document.finalUrl ?? "an unknown URL"}.`] : [],
|
|
15383
|
+
...document.status !== void 0 && (document.status < 200 || document.status >= 300) ? [`Sitemap returned HTTP ${document.status}.`] : []
|
|
15384
|
+
];
|
|
15385
|
+
const output = [
|
|
15386
|
+
`dataStatus=${document.dataStatus}`,
|
|
15387
|
+
...document.status !== void 0 ? [`status=${document.status}`] : [],
|
|
15388
|
+
...document.finalUrl ? [`finalUrl=${document.finalUrl}`] : [],
|
|
15389
|
+
...document.root ? [`root=${document.root}`] : [],
|
|
15390
|
+
...document.warning ? [`warning=${document.warning}`] : []
|
|
15391
|
+
].join("\n");
|
|
15392
|
+
return {
|
|
15393
|
+
xml: [
|
|
15394
|
+
` <testcase classname="seo.sitemap-document" name="${escapeXml(document.url)}" time="0.000">`,
|
|
15395
|
+
...failures.length ? [` <failure message="${escapeXml(failures[0] ?? "Sitemap check failed")}">${escapeXml(failures.join("\n"))}</failure>`] : [],
|
|
15396
|
+
` <system-out>${escapeXml(output)}</system-out>`,
|
|
15397
|
+
" </testcase>"
|
|
15398
|
+
].join("\n"),
|
|
15399
|
+
failed: failures.length > 0
|
|
15400
|
+
};
|
|
15401
|
+
});
|
|
15402
|
+
});
|
|
15403
|
+
const requestCases = report.requests.map((request) => {
|
|
15404
|
+
const failures = [
|
|
15405
|
+
...(issuesByUrl.get(request.requestedUrl) ?? []).map((issue) => `${issue.severity}: ${issue.title}${issue.detail ? `: ${issue.detail}` : ""}`),
|
|
15406
|
+
...request.outcome === "failure" ? [`${request.failureKind}: ${request.error}`] : [],
|
|
15407
|
+
...request.outcome === "skipped" ? [request.reason === "origin-backpressure" ? request.error : `${request.reason}: ${request.robotsTxt.matchedLine ?? request.robotsTxt.availability}`] : []
|
|
15408
|
+
];
|
|
15409
|
+
const output = request.outcome === "response" ? [
|
|
15410
|
+
`status=${request.status}`,
|
|
15411
|
+
`finalUrl=${request.finalUrl}`,
|
|
15412
|
+
...request.robotsTxt ? [
|
|
15413
|
+
`robotsUrl=${request.robotsTxt.url}`,
|
|
15414
|
+
`robotsAllowed=${request.robotsTxt.allowed ?? "unknown"}`,
|
|
15415
|
+
`robotsAvailability=${request.robotsTxt.availability}`,
|
|
15416
|
+
...request.robotsTxt.status !== void 0 ? [`robotsStatus=${request.robotsTxt.status}`] : [],
|
|
15417
|
+
...request.robotsTxt.matchedLine ? [`robotsMatchedLine=${request.robotsTxt.matchedLine}`] : [],
|
|
15418
|
+
...request.robotsTxt.error ? [`robotsError=${request.robotsTxt.error}`] : []
|
|
15419
|
+
] : [],
|
|
15420
|
+
...request.accessBlock ? [
|
|
15421
|
+
`accessProvider=${request.accessBlock.provider}`,
|
|
15422
|
+
`crawler=${request.accessBlock.crawler.userAgent}`,
|
|
15423
|
+
...request.accessBlock.requestId ? [`requestId=${request.accessBlock.requestId}`] : []
|
|
15424
|
+
] : []
|
|
15425
|
+
].join("\n") : request.outcome === "failure" ? request.error : request.reason === "origin-backpressure" ? `reason=${request.reason}\nerror=${request.error}` : [
|
|
15426
|
+
`reason=${request.reason}`,
|
|
15427
|
+
`robotsUrl=${request.robotsTxt.url}`,
|
|
15428
|
+
`robotsAllowed=${request.robotsTxt.allowed ?? "unknown"}`,
|
|
15429
|
+
`robotsAvailability=${request.robotsTxt.availability}`,
|
|
15430
|
+
...request.robotsTxt.status !== void 0 ? [`robotsStatus=${request.robotsTxt.status}`] : [],
|
|
15431
|
+
...request.robotsTxt.matchedLine ? [`robotsMatchedLine=${request.robotsTxt.matchedLine}`] : [],
|
|
15432
|
+
...request.robotsTxt.error ? [`robotsError=${request.robotsTxt.error}`] : []
|
|
15433
|
+
].join("\n");
|
|
15434
|
+
const duration = ((request.durationMs ?? 0) / 1e3).toFixed(3);
|
|
15435
|
+
return {
|
|
15436
|
+
xml: [
|
|
15437
|
+
` <testcase classname="seo.sitemap-health" name="${escapeXml(request.requestedUrl)}" time="${duration}">`,
|
|
15438
|
+
...failures.length ? [` <failure message="${escapeXml(failures[0] ?? "Health check failed")}">${escapeXml(failures.join("\n"))}</failure>`] : [],
|
|
15439
|
+
` <system-out>${escapeXml(output)}</system-out>`,
|
|
15440
|
+
" </testcase>"
|
|
15441
|
+
].join("\n"),
|
|
15442
|
+
failed: failures.length > 0
|
|
15443
|
+
};
|
|
15444
|
+
});
|
|
15445
|
+
const cases = [...sitemapCases, ...requestCases];
|
|
15446
|
+
const failures = cases.filter((item) => item.failed).length;
|
|
15447
|
+
const elapsed = report.requests.reduce((sum, request) => sum + (request.durationMs ?? 0), 0);
|
|
15448
|
+
return [
|
|
15449
|
+
"<?xml version=\"1.0\" encoding=\"UTF-8\"?>",
|
|
15450
|
+
`<testsuite name="seo sitemap health" tests="${cases.length}" failures="${failures}" errors="0" skipped="0" time="${(elapsed / 1e3).toFixed(3)}">`,
|
|
15451
|
+
" <properties>",
|
|
15452
|
+
` <property name="report.id" value="${escapeXml(report.id)}"/>`,
|
|
15453
|
+
` <property name="report.status" value="${escapeXml(report.status)}"/>`,
|
|
15454
|
+
` <property name="crawl.strategy" value="${escapeXml(report.config.strategy)}"/>`,
|
|
15455
|
+
` <property name="crawler.userAgent" value="${escapeXml(report.access.crawler.userAgent)}"/>`,
|
|
15456
|
+
" </properties>",
|
|
15457
|
+
...cases.map((item) => item.xml),
|
|
15458
|
+
"</testsuite>",
|
|
15459
|
+
""
|
|
15460
|
+
].join("\n");
|
|
15461
|
+
}
|
|
13967
15462
|
function renderCrawlHtml(report, fixes = topFixes(report)) {
|
|
13968
15463
|
const reviews = reviewObservations(report);
|
|
13969
15464
|
const issueRows = report.issueGroups.map((group) => {
|
|
@@ -13974,6 +15469,7 @@ function renderCrawlHtml(report, fixes = topFixes(report)) {
|
|
|
13974
15469
|
const reviewItems = reviews.slice(0, 10).map((observation) => `<li><strong>${escapeHtml(observation.title)}</strong><br>${escapeHtml(observation.howToFix)}<br><span>Check before scheduling work. ${escapeHtml(observation.howToVerify)}</span></li>`).join("\n");
|
|
13975
15470
|
const caveatItems = report.caveats.map((caveat) => `<li>${escapeHtml(caveat)}</li>`).join("\n");
|
|
13976
15471
|
const warningItems = report.warnings.map((warning) => `<li>${escapeHtml(warning)}</li>`).join("\n");
|
|
15472
|
+
const accessItems = report.access.samples.map((sample) => `<li><strong>${escapeHtml(sample.url)}</strong><br>${escapeHtml(sample.evidence.guidance.summary)}<br>${escapeHtml(sample.evidence.guidance.recommendedAction)}<br><span>${escapeHtml(sample.evidence.guidance.securityNote)}</span></li>`).join("\n");
|
|
13977
15473
|
return `<!doctype html>
|
|
13978
15474
|
<html lang="en">
|
|
13979
15475
|
<head>
|
|
@@ -13996,6 +15492,7 @@ function renderCrawlHtml(report, fixes = topFixes(report)) {
|
|
|
13996
15492
|
<body>
|
|
13997
15493
|
<h1>Crawl report</h1>
|
|
13998
15494
|
<p>${escapeHtml(report.config.url)}</p>
|
|
15495
|
+
<p>Strategy: ${escapeHtml(report.config.strategy === "health" ? "sitemap health pass" : "full audit")}<br>Crawler: <code>${escapeHtml(report.access.crawler.userAgent)}</code></p>
|
|
13999
15496
|
<section class="summary">
|
|
14000
15497
|
<div class="metric"><span>Status</span><strong>${escapeHtml(report.status)}</strong></div>
|
|
14001
15498
|
<div class="metric"><span>Documents</span><strong>${report.summary.totalPages}</strong></div>
|
|
@@ -14008,6 +15505,7 @@ function renderCrawlHtml(report, fixes = topFixes(report)) {
|
|
|
14008
15505
|
<div class="metric"><span>Issues</span><strong>${report.issues.length}</strong></div>
|
|
14009
15506
|
<div class="metric"><span>High</span><strong>${report.summary.highIssues}</strong></div>
|
|
14010
15507
|
</section>
|
|
15508
|
+
${accessItems ? `<h2>Access blocks</h2><ul>${accessItems}</ul>` : ""}
|
|
14011
15509
|
<h2>Prioritised fixes</h2>
|
|
14012
15510
|
${fixItems ? `<ol>${fixItems}</ol>` : "<p>No prioritised fixes found.</p>"}
|
|
14013
15511
|
${reviewItems ? `<h2>Review observations</h2>
|
|
@@ -14034,10 +15532,17 @@ function renderCrawlMarkdownTickets(report, fixes = topFixes(report)) {
|
|
|
14034
15532
|
`Source: ${report.config.url}`,
|
|
14035
15533
|
`Report: ${report.id}`,
|
|
14036
15534
|
`Status: ${report.status}`,
|
|
15535
|
+
`Strategy: ${report.config.strategy}`,
|
|
15536
|
+
`Crawler: ${report.access.crawler.userAgent}`,
|
|
14037
15537
|
""
|
|
14038
15538
|
];
|
|
14039
15539
|
if (report.caveats.length) lines.push("## Caveats", "", ...report.caveats.map((item) => `- ${item}`), "");
|
|
14040
15540
|
if (report.warnings.length) lines.push("## Warnings", "", ...report.warnings.map((item) => `- ${item}`), "");
|
|
15541
|
+
if (report.access.blockedRequests > 0) lines.push("## Access blocks", "", ...report.access.samples.flatMap((sample) => [
|
|
15542
|
+
`- ${sample.url}: ${sample.evidence.guidance.summary}`,
|
|
15543
|
+
` - Action: ${sample.evidence.guidance.recommendedAction}`,
|
|
15544
|
+
` - Security: ${sample.evidence.guidance.securityNote}`
|
|
15545
|
+
]), "");
|
|
14041
15546
|
for (const [index, fix] of fixes.entries()) lines.push(`## ${index + 1}. ${fix.title}`, "", `- [ ] Fix ${fix.count} affected URL${fix.count === 1 ? "" : "s"}`, `- Severity: ${fix.severity}`, `- Rule: ${fix.ruleId}`, `- Why this ranks: ${fix.whyThisRanks}`, `- Plain-English fix: ${fix.howToFix}`, `- Verify: ${fix.howToVerify}`, `- Command: ${fix.verification.command}`, "", `Affected URLs:`, ...fix.sampleUrls.slice(0, 10).map((url) => `- ${url}`), "");
|
|
14042
15547
|
if (!fixes.length) lines.push("No implementation tickets were generated.");
|
|
14043
15548
|
if (reviews.length) lines.push("## Review observations", "", "These are not implementation tickets. Check the evidence before deciding whether to work on them.", "", ...reviews.slice(0, 10).flatMap((observation) => [`- ${observation.title} (${observation.severity}, ${observation.count} URL${observation.count === 1 ? "" : "s"}): ${observation.howToFix}`, ` - First URL: ${observation.sampleUrls[0] ?? "No sample URL recorded"}`]), "");
|
|
@@ -14046,6 +15551,9 @@ function renderCrawlMarkdownTickets(report, fixes = topFixes(report)) {
|
|
|
14046
15551
|
function escapeHtml(value) {
|
|
14047
15552
|
return value.replaceAll("&", "&").replaceAll("<", "<").replaceAll(">", ">").replaceAll("\"", """).replaceAll("'", "'");
|
|
14048
15553
|
}
|
|
15554
|
+
function escapeXml(value) {
|
|
15555
|
+
return escapeHtml(value);
|
|
15556
|
+
}
|
|
14049
15557
|
//#endregion
|
|
14050
15558
|
//#region packages/core/src/analyze/crawler/history-provenance.ts
|
|
14051
15559
|
function snapshotCaps(report) {
|
|
@@ -14361,6 +15869,14 @@ function compareCrawlReports(input) {
|
|
|
14361
15869
|
};
|
|
14362
15870
|
}
|
|
14363
15871
|
//#endregion
|
|
15872
|
+
//#region packages/core/src/phrasing.ts
|
|
15873
|
+
function plural(count, singular, pluralLabel = `${singular}s`) {
|
|
15874
|
+
return count === 1 ? singular : pluralLabel;
|
|
15875
|
+
}
|
|
15876
|
+
function countLabel(count, singular, pluralLabel = `${singular}s`) {
|
|
15877
|
+
return `${count.toLocaleString("en-GB")} ${plural(count, singular, pluralLabel)}`;
|
|
15878
|
+
}
|
|
15879
|
+
//#endregion
|
|
14364
15880
|
//#region packages/core/src/analyze/crawler/okf.ts
|
|
14365
15881
|
const OKF_DEFAULT_CONCEPTS = 500;
|
|
14366
15882
|
const OKF_MAX_CONCEPTS = 5e3;
|
|
@@ -14727,7 +16243,7 @@ function explainOkfValidation(validation) {
|
|
|
14727
16243
|
return {
|
|
14728
16244
|
title: "OKF bundle",
|
|
14729
16245
|
valid: validation.valid,
|
|
14730
|
-
summary: validation.valid ? `This bundle passes seo OKF checks with ${validation.concepts
|
|
16246
|
+
summary: validation.valid ? `This bundle passes seo OKF checks with ${countLabel(validation.concepts, "concept file")}.` : `This OKF bundle has ${countLabel(errors.length, "validation error")}.`,
|
|
14731
16247
|
files: validation.files,
|
|
14732
16248
|
concepts: validation.concepts,
|
|
14733
16249
|
errors: errors.length,
|
|
@@ -14879,6 +16395,31 @@ const agentDiscoverySchema = z.object({
|
|
|
14879
16395
|
missingMarkdownPages: z.number().int().nonnegative(),
|
|
14880
16396
|
consistent: z.boolean().nullable()
|
|
14881
16397
|
}),
|
|
16398
|
+
endpointDiscovery: z.object({
|
|
16399
|
+
linkHeader: z.object({
|
|
16400
|
+
url: z.string().url(),
|
|
16401
|
+
status: z.number().int().optional(),
|
|
16402
|
+
entries: z.array(z.object({
|
|
16403
|
+
url: z.string().url(),
|
|
16404
|
+
rel: z.array(z.string()),
|
|
16405
|
+
type: z.string().optional()
|
|
16406
|
+
})),
|
|
16407
|
+
registeredRels: z.array(z.string()),
|
|
16408
|
+
emergingRels: z.array(z.string()),
|
|
16409
|
+
error: z.string().optional()
|
|
16410
|
+
}),
|
|
16411
|
+
endpoints: z.array(z.object({
|
|
16412
|
+
id: z.string(),
|
|
16413
|
+
url: z.string().url(),
|
|
16414
|
+
status: z.number().int().optional(),
|
|
16415
|
+
exists: z.boolean(),
|
|
16416
|
+
contentType: z.string().optional(),
|
|
16417
|
+
validJson: z.boolean().optional(),
|
|
16418
|
+
presentFields: z.array(z.string()).optional(),
|
|
16419
|
+
missingFields: z.array(z.string()).optional(),
|
|
16420
|
+
error: z.string().optional()
|
|
16421
|
+
}))
|
|
16422
|
+
}).optional(),
|
|
14882
16423
|
protocolVariants: z.object({
|
|
14883
16424
|
http: z.object({
|
|
14884
16425
|
url: z.string().url(),
|
|
@@ -14950,6 +16491,46 @@ const analyticsSchema = z.object({
|
|
|
14950
16491
|
totalUsers: z.number(),
|
|
14951
16492
|
conversions: z.number()
|
|
14952
16493
|
});
|
|
16494
|
+
const crawlerIdentitySchema = z.object({
|
|
16495
|
+
name: z.string(),
|
|
16496
|
+
robotsToken: z.string(),
|
|
16497
|
+
version: z.string(),
|
|
16498
|
+
userAgent: z.string(),
|
|
16499
|
+
documentationUrl: z.string().url()
|
|
16500
|
+
});
|
|
16501
|
+
const accessBlockEvidenceSchema = z.object({
|
|
16502
|
+
provider: z.enum(["cloudflare", "unknown"]),
|
|
16503
|
+
kind: z.enum([
|
|
16504
|
+
"challenge",
|
|
16505
|
+
"firewall",
|
|
16506
|
+
"rate-limit",
|
|
16507
|
+
"authentication"
|
|
16508
|
+
]),
|
|
16509
|
+
status: z.number().int(),
|
|
16510
|
+
crawler: crawlerIdentitySchema,
|
|
16511
|
+
indicators: z.array(z.string()),
|
|
16512
|
+
requestId: z.string().optional(),
|
|
16513
|
+
guidance: z.object({
|
|
16514
|
+
summary: z.string(),
|
|
16515
|
+
recommendedAction: z.string(),
|
|
16516
|
+
securityNote: z.string(),
|
|
16517
|
+
documentationUrl: z.string().url().optional()
|
|
16518
|
+
})
|
|
16519
|
+
});
|
|
16520
|
+
const crawlRobotsEvidenceSchema = z.object({
|
|
16521
|
+
url: z.string().url(),
|
|
16522
|
+
allowed: z.boolean().nullable(),
|
|
16523
|
+
availability: z.enum([
|
|
16524
|
+
"available",
|
|
16525
|
+
"absent",
|
|
16526
|
+
"access-blocked",
|
|
16527
|
+
"rate-limited",
|
|
16528
|
+
"unreachable"
|
|
16529
|
+
]),
|
|
16530
|
+
status: z.number().int().optional(),
|
|
16531
|
+
error: z.string().optional(),
|
|
16532
|
+
matchedLine: z.string().optional()
|
|
16533
|
+
});
|
|
14953
16534
|
const geoSignalsSchema = z.object({
|
|
14954
16535
|
semanticHtml: z.boolean(),
|
|
14955
16536
|
structuredData: z.boolean(),
|
|
@@ -15123,6 +16704,7 @@ const crawlPageSnapshotSchema = z.object({
|
|
|
15123
16704
|
consecutiveErrors: z.number().int(),
|
|
15124
16705
|
recentP95Ms: z.number().optional()
|
|
15125
16706
|
}).optional(),
|
|
16707
|
+
accessBlock: accessBlockEvidenceSchema.optional(),
|
|
15126
16708
|
robotsTxt: z.object({
|
|
15127
16709
|
url: z.string().url(),
|
|
15128
16710
|
cache: z.enum([
|
|
@@ -15205,23 +16787,12 @@ const crawlPageSnapshotSchema = z.object({
|
|
|
15205
16787
|
}).optional()
|
|
15206
16788
|
}).optional(),
|
|
15207
16789
|
blocked: z.boolean().optional(),
|
|
16790
|
+
auditScope: z.enum(["full", "status"]).optional(),
|
|
16791
|
+
accessBlock: accessBlockEvidenceSchema.optional(),
|
|
15208
16792
|
contentAuditAllowed: z.boolean().optional(),
|
|
15209
16793
|
crawlDepth: z.number().int().optional(),
|
|
15210
16794
|
error: z.string().optional(),
|
|
15211
|
-
robotsTxt:
|
|
15212
|
-
url: z.string().url(),
|
|
15213
|
-
allowed: z.boolean().nullable(),
|
|
15214
|
-
availability: z.enum([
|
|
15215
|
-
"available",
|
|
15216
|
-
"absent",
|
|
15217
|
-
"access-blocked",
|
|
15218
|
-
"rate-limited",
|
|
15219
|
-
"unreachable"
|
|
15220
|
-
]),
|
|
15221
|
-
status: z.number().int().optional(),
|
|
15222
|
-
error: z.string().optional(),
|
|
15223
|
-
matchedLine: z.string().optional()
|
|
15224
|
-
}).optional(),
|
|
16795
|
+
robotsTxt: crawlRobotsEvidenceSchema.optional(),
|
|
15225
16796
|
title: z.string().optional(),
|
|
15226
16797
|
metaDescription: z.string().optional(),
|
|
15227
16798
|
canonical: z.string().url().optional(),
|
|
@@ -15276,8 +16847,16 @@ const crawlPageSnapshotSchema = z.object({
|
|
|
15276
16847
|
extractionError: z.string().optional(),
|
|
15277
16848
|
wordCount: z.number().int(),
|
|
15278
16849
|
contentExtraction: z.object({
|
|
15279
|
-
requested: z.enum([
|
|
15280
|
-
|
|
16850
|
+
requested: z.enum([
|
|
16851
|
+
"crawler",
|
|
16852
|
+
"defuddle",
|
|
16853
|
+
"readability"
|
|
16854
|
+
]),
|
|
16855
|
+
used: z.enum([
|
|
16856
|
+
"crawler",
|
|
16857
|
+
"defuddle",
|
|
16858
|
+
"readability"
|
|
16859
|
+
]),
|
|
15281
16860
|
fallback: z.boolean(),
|
|
15282
16861
|
fallbackReason: z.enum(["defuddle_error", "defuddle_empty"]).optional(),
|
|
15283
16862
|
fallbackDetail: z.string().optional(),
|
|
@@ -15411,7 +16990,9 @@ const crawlResponseObservationBaseSchema = z.object({
|
|
|
15411
16990
|
url: z.string().url(),
|
|
15412
16991
|
status: z.number().int(),
|
|
15413
16992
|
location: z.string().url().optional()
|
|
15414
|
-
})).optional()
|
|
16993
|
+
})).optional(),
|
|
16994
|
+
accessBlock: accessBlockEvidenceSchema.optional(),
|
|
16995
|
+
robotsTxt: crawlRobotsEvidenceSchema.optional()
|
|
15415
16996
|
});
|
|
15416
16997
|
const crawlRequestObservationSchema = z.union([
|
|
15417
16998
|
crawlResponseObservationBaseSchema.extend({ extraction: z.enum([
|
|
@@ -15428,20 +17009,7 @@ const crawlRequestObservationSchema = z.union([
|
|
|
15428
17009
|
outcome: z.literal("skipped"),
|
|
15429
17010
|
durationMs: z.number().optional(),
|
|
15430
17011
|
reason: z.enum(["robots-disallowed", "robots-deferred"]),
|
|
15431
|
-
robotsTxt:
|
|
15432
|
-
url: z.string().url(),
|
|
15433
|
-
allowed: z.boolean().nullable(),
|
|
15434
|
-
availability: z.enum([
|
|
15435
|
-
"available",
|
|
15436
|
-
"absent",
|
|
15437
|
-
"access-blocked",
|
|
15438
|
-
"rate-limited",
|
|
15439
|
-
"unreachable"
|
|
15440
|
-
]),
|
|
15441
|
-
status: z.number().int().optional(),
|
|
15442
|
-
error: z.string().optional(),
|
|
15443
|
-
matchedLine: z.string().optional()
|
|
15444
|
-
}),
|
|
17012
|
+
robotsTxt: crawlRobotsEvidenceSchema,
|
|
15445
17013
|
extraction: z.literal("not-applicable")
|
|
15446
17014
|
}),
|
|
15447
17015
|
z.object({
|
|
@@ -15500,6 +17068,7 @@ const crawlAiSignalsSchema = z.object({
|
|
|
15500
17068
|
status: z.number().int().optional(),
|
|
15501
17069
|
error: z.string().optional(),
|
|
15502
17070
|
sitemapUrls: z.array(z.string().url()),
|
|
17071
|
+
contentSignals: z.array(z.string()).optional(),
|
|
15503
17072
|
botAccess: z.array(z.object({
|
|
15504
17073
|
userAgent: z.string(),
|
|
15505
17074
|
allowed: z.boolean().nullable(),
|
|
@@ -15557,7 +17126,11 @@ const crawlSitemapDiscoverySchema = z.object({
|
|
|
15557
17126
|
urlsReturned: z.number().int().nonnegative(),
|
|
15558
17127
|
roots: z.array(z.object({
|
|
15559
17128
|
url: z.string().url(),
|
|
15560
|
-
source: z.enum([
|
|
17129
|
+
source: z.enum([
|
|
17130
|
+
"explicit",
|
|
17131
|
+
"robots-txt",
|
|
17132
|
+
"default-path"
|
|
17133
|
+
]),
|
|
15561
17134
|
dataStatus: z.enum([
|
|
15562
17135
|
"complete",
|
|
15563
17136
|
"partial",
|
|
@@ -15641,6 +17214,8 @@ const crawlConfigSchema = z.object({
|
|
|
15641
17214
|
"list",
|
|
15642
17215
|
"sitemap"
|
|
15643
17216
|
]),
|
|
17217
|
+
strategy: z.enum(["full", "health"]),
|
|
17218
|
+
sitemapUrl: z.string().url().optional(),
|
|
15644
17219
|
urls: z.array(z.string().url()),
|
|
15645
17220
|
maxPages: z.number().int(),
|
|
15646
17221
|
maxDepth: z.number().int(),
|
|
@@ -15675,6 +17250,7 @@ const crawlConfigSchema = z.object({
|
|
|
15675
17250
|
});
|
|
15676
17251
|
const crawlReportSummarySchema = z.object({
|
|
15677
17252
|
totalPages: z.number().int(),
|
|
17253
|
+
statusOnlyPages: z.number().int(),
|
|
15678
17254
|
indexablePages: z.number().int(),
|
|
15679
17255
|
nonIndexablePages: z.number().int(),
|
|
15680
17256
|
statusErrors: z.number().int(),
|
|
@@ -15765,6 +17341,17 @@ const crawlReportBaseSchema = z.object({
|
|
|
15765
17341
|
]),
|
|
15766
17342
|
configHash: z.string(),
|
|
15767
17343
|
config: crawlConfigSchema,
|
|
17344
|
+
access: z.object({
|
|
17345
|
+
crawler: crawlerIdentitySchema,
|
|
17346
|
+
blockedRequests: z.number().int().nonnegative(),
|
|
17347
|
+
providers: z.partialRecord(z.enum(["cloudflare", "unknown"]), z.number().int().nonnegative()),
|
|
17348
|
+
samples: z.array(z.object({
|
|
17349
|
+
url: z.string().url(),
|
|
17350
|
+
evidence: accessBlockEvidenceSchema
|
|
17351
|
+
})),
|
|
17352
|
+
sampleLimit: z.number().int().positive(),
|
|
17353
|
+
truncated: z.boolean()
|
|
17354
|
+
}).optional(),
|
|
15768
17355
|
summary: crawlReportSummarySchema,
|
|
15769
17356
|
pages: z.array(crawlPageSnapshotSchema),
|
|
15770
17357
|
issues: z.array(crawlIssueSchema),
|
|
@@ -16080,14 +17667,6 @@ async function ctrUnderperformersReport(input, dependencies = defaultDependencie
|
|
|
16080
17667
|
};
|
|
16081
17668
|
}
|
|
16082
17669
|
//#endregion
|
|
16083
|
-
//#region packages/core/src/phrasing.ts
|
|
16084
|
-
function plural(count, singular, pluralLabel = `${singular}s`) {
|
|
16085
|
-
return count === 1 ? singular : pluralLabel;
|
|
16086
|
-
}
|
|
16087
|
-
function countLabel(count, singular, pluralLabel = `${singular}s`) {
|
|
16088
|
-
return `${count.toLocaleString("en-GB")} ${plural(count, singular, pluralLabel)}`;
|
|
16089
|
-
}
|
|
16090
|
-
//#endregion
|
|
16091
17670
|
//#region packages/core/src/analyze/diagnosis-priorities.ts
|
|
16092
17671
|
function topDelta(report) {
|
|
16093
17672
|
const top = report.items[0];
|
|
@@ -16114,14 +17693,14 @@ function buildDiagnosisPriorities(input) {
|
|
|
16114
17693
|
const topGroup = input.decay.groups[0];
|
|
16115
17694
|
priorities.push({
|
|
16116
17695
|
label: "Refresh decaying content",
|
|
16117
|
-
reason: topGroup ? `${input.decay.selection.eligibleRows
|
|
17696
|
+
reason: topGroup ? `${countLabel(input.decay.selection.eligibleRows, "observed retained query/page decline")} found; ${topGroup.label} declined by ${topGroup.totalClickLoss.toFixed(0)} clicks.` : `${countLabel(input.decay.selection.eligibleRows, "observed retained query/page decline")} found.`,
|
|
16118
17697
|
action: topGroup ? topGroup.recommendation : "Start with declines that continued outside the update window. Check indexability first, then ranking and CTR causes.",
|
|
16119
17698
|
confidence: input.decay.dataStatus === "partial" ? "low" : "medium"
|
|
16120
17699
|
});
|
|
16121
17700
|
}
|
|
16122
17701
|
if (input.cannibal.items.length) priorities.push({
|
|
16123
17702
|
label: "Review multi-URL query candidates",
|
|
16124
|
-
reason: `${input.cannibal.selection.eligibleClusters
|
|
17703
|
+
reason: `${countLabel(input.cannibal.selection.eligibleClusters, "multi-URL query candidate")} found.`,
|
|
16125
17704
|
action: "Confirm whether each URL set satisfies the same intent and inspect technical state. Consolidate only verified duplicate or same-intent pages; otherwise clarify the distinction.",
|
|
16126
17705
|
confidence: "low"
|
|
16127
17706
|
});
|
|
@@ -16139,7 +17718,7 @@ function buildDiagnosisPriorities(input) {
|
|
|
16139
17718
|
const top = input.striking.items[0];
|
|
16140
17719
|
priorities.push({
|
|
16141
17720
|
label: "Investigate striking-distance candidates",
|
|
16142
|
-
reason: `${input.striking.items.length
|
|
17721
|
+
reason: `${countLabel(input.striking.items.length, "query/page row")} ${input.striking.items.length === 1 ? "has" : "have"} an average GSC position above 10 and at most 20.`,
|
|
16143
17722
|
action: top?.recommendation.action ?? "Check technical state, query coverage, competing URLs, and relevant internal links before choosing a change.",
|
|
16144
17723
|
confidence: input.striking.source.possiblyTruncated ? "low" : top?.recommendation.confidence ?? "low"
|
|
16145
17724
|
});
|
|
@@ -17972,8 +19551,7 @@ function analyzeQuickWinsFromRows(input) {
|
|
|
17972
19551
|
selected.selection.belowMinimumRows++;
|
|
17973
19552
|
continue;
|
|
17974
19553
|
}
|
|
17975
|
-
const
|
|
17976
|
-
const excludedTargetRows = selected.rows.filter((candidate) => samePageUrl(candidate.keys[1] ?? "", targetUrl)).length;
|
|
19554
|
+
const excludedTargetRows = benchmarkContext.urlRowCount(row);
|
|
17977
19555
|
const benchmark = benchmarkContext.forUrl(row);
|
|
17978
19556
|
if (row.ctr >= benchmark.ctr) {
|
|
17979
19557
|
selected.selection.atOrAboveTargetRows++;
|
|
@@ -18167,7 +19745,7 @@ async function quickWinsReport(input, dependencies = defaultDependencies$8) {
|
|
|
18167
19745
|
brandTerms: input.brandTerms,
|
|
18168
19746
|
includeBrand: input.includeBrand
|
|
18169
19747
|
});
|
|
18170
|
-
const verificationRequested = input.verifyContent === true || input.verifyLimit !== void 0;
|
|
19748
|
+
const verificationRequested = input.verifyContent === true || input.verifyContent !== false && input.verifyLimit !== void 0;
|
|
18171
19749
|
const verificationItems = verificationRequested ? analysis.items.slice(0, verifyLimit) : [];
|
|
18172
19750
|
const fetches = /* @__PURE__ */ new Map();
|
|
18173
19751
|
const cachedFetch = (url, options) => {
|
|
@@ -18266,7 +19844,7 @@ async function quickWinsReport(input, dependencies = defaultDependencies$8) {
|
|
|
18266
19844
|
repeatedQueryGroups: groups.length,
|
|
18267
19845
|
templatePatterns: templateActions.length,
|
|
18268
19846
|
brandFiltering: input.includeBrand ? "included" : "excluded",
|
|
18269
|
-
verdict: analysis.dataStatus === "empty" ? "Search Console returned no retained query/page rows for this date window." : analysis.dataStatus === "filtered" ? "No retained rows met the quick-win report criteria." : `${analysis.summary.eligibleRows
|
|
19847
|
+
verdict: analysis.dataStatus === "empty" ? "Search Console returned no retained query/page rows for this date window." : analysis.dataStatus === "filtered" ? "No retained rows met the quick-win report criteria." : `${countLabel(analysis.summary.eligibleRows, "eligible CTR-target row")} found; ${countLabel(analysis.summary.returnedRows, "row")} returned for review.`
|
|
18270
19848
|
},
|
|
18271
19849
|
caveats: [
|
|
18272
19850
|
`Date window: ${range.startDate} to ${range.endDate}, using final GSC data where available.`,
|
|
@@ -18557,7 +20135,7 @@ function templatesFromGroups(groups) {
|
|
|
18557
20135
|
function verdict$1(analysis) {
|
|
18558
20136
|
if (analysis.dataStatus === "empty") return "Search Console returned no retained query/page rows for this date window.";
|
|
18559
20137
|
if (analysis.dataStatus === "filtered") return "No retained query/page rows met the report filters.";
|
|
18560
|
-
return `${analysis.summary.eligibleRows
|
|
20138
|
+
return `${countLabel(analysis.summary.eligibleRows, "eligible query/page row")} found; ${countLabel(analysis.summary.returnedRows, "row")} returned for review.`;
|
|
18561
20139
|
}
|
|
18562
20140
|
async function strikingDistance(input, dependencies = defaultDependencies$7) {
|
|
18563
20141
|
const days = reportDays(input.days);
|
|
@@ -18579,7 +20157,7 @@ async function strikingDistance(input, dependencies = defaultDependencies$7) {
|
|
|
18579
20157
|
includeBrand: input.includeBrand
|
|
18580
20158
|
});
|
|
18581
20159
|
const verifyLimit = boundedInteger$2(input.verifyLimit, DEFAULT_VERIFY_LIMIT, 0, analysis.limit);
|
|
18582
|
-
const verificationRequested = input.verifyContent === true || input.verifyLimit !== void 0;
|
|
20160
|
+
const verificationRequested = input.verifyContent === true || input.verifyContent !== false && input.verifyLimit !== void 0;
|
|
18583
20161
|
const attempted = verificationRequested ? Math.min(verifyLimit, analysis.items.length) : 0;
|
|
18584
20162
|
const items = await Promise.all(analysis.items.map(async (item, index) => {
|
|
18585
20163
|
if (index >= attempted) return item;
|
|
@@ -21157,6 +22735,7 @@ function topCrawlRecommendations(items) {
|
|
|
21157
22735
|
}
|
|
21158
22736
|
//#endregion
|
|
21159
22737
|
//#region packages/core/src/analyze/monitoring/crawl-store.ts
|
|
22738
|
+
const CRAWL_RUN_RETENTION = 20;
|
|
21160
22739
|
function toRun(row) {
|
|
21161
22740
|
return {
|
|
21162
22741
|
id: row.id,
|
|
@@ -21228,34 +22807,52 @@ function insertCrawlRun(run, pages, recommendations = []) {
|
|
|
21228
22807
|
for (const page of pages) insertPage.run(run.id, page.url, page.finalUrl, page.status, page.title ?? null, page.metaDescription ?? null, page.canonical ?? null, page.metaRobots ?? null, page.xRobotsTag ?? null, page.h1 ?? null, page.indexable ? 1 : 0, page.wordCount, page.contentHash, page.outgoingInternalCount, JSON.stringify(page));
|
|
21229
22808
|
for (const recommendation of recommendations) insertRecommendation.run(run.id, run.site, recommendation.url, recommendation.severity, recommendation.category, recommendation.title, recommendation.action, recommendation.confidence, createdAt);
|
|
21230
22809
|
})();
|
|
22810
|
+
db.prepare(`DELETE FROM crawl_runs
|
|
22811
|
+
WHERE site_url = ? AND start_url = ?
|
|
22812
|
+
AND id NOT IN (
|
|
22813
|
+
SELECT id FROM crawl_runs
|
|
22814
|
+
WHERE site_url = ? AND start_url = ?
|
|
22815
|
+
ORDER BY created_at DESC, id DESC
|
|
22816
|
+
LIMIT ?
|
|
22817
|
+
)`).run(run.site, run.startUrl, run.site, run.startUrl, 20);
|
|
21231
22818
|
}
|
|
21232
22819
|
function latestCrawlSummaries(site, limit = 5) {
|
|
21233
|
-
return getDb().prepare(`WITH
|
|
22820
|
+
return getDb().prepare(`WITH recent_runs AS (
|
|
22821
|
+
SELECT *
|
|
22822
|
+
FROM crawl_runs
|
|
22823
|
+
WHERE site_url = ?
|
|
22824
|
+
ORDER BY created_at DESC
|
|
22825
|
+
LIMIT ?
|
|
22826
|
+
),
|
|
22827
|
+
page_counts AS (
|
|
21234
22828
|
SELECT
|
|
21235
|
-
run_id,
|
|
21236
|
-
COALESCE(SUM(CASE WHEN status >= 400 THEN 1 ELSE 0 END), 0) AS status_errors,
|
|
21237
|
-
COALESCE(SUM(CASE WHEN indexable = 0 THEN 1 ELSE 0 END), 0) AS non_indexable
|
|
22829
|
+
crawl_pages.run_id,
|
|
22830
|
+
COALESCE(SUM(CASE WHEN crawl_pages.status >= 400 THEN 1 ELSE 0 END), 0) AS status_errors,
|
|
22831
|
+
COALESCE(SUM(CASE WHEN crawl_pages.indexable = 0 THEN 1 ELSE 0 END), 0) AS non_indexable
|
|
21238
22832
|
FROM crawl_pages
|
|
21239
|
-
|
|
22833
|
+
INNER JOIN recent_runs ON recent_runs.id = crawl_pages.run_id
|
|
22834
|
+
GROUP BY crawl_pages.run_id
|
|
21240
22835
|
),
|
|
21241
22836
|
recommendation_counts AS (
|
|
21242
22837
|
SELECT
|
|
21243
|
-
run_id,
|
|
22838
|
+
crawl_recommendations.run_id,
|
|
21244
22839
|
COUNT(*) AS recommendation_count,
|
|
21245
|
-
COALESCE(SUM(CASE WHEN severity = 'high' THEN 1 ELSE 0 END), 0) AS high_recommendation_count
|
|
22840
|
+
COALESCE(SUM(CASE WHEN crawl_recommendations.severity = 'high' THEN 1 ELSE 0 END), 0) AS high_recommendation_count
|
|
21246
22841
|
FROM crawl_recommendations
|
|
21247
|
-
|
|
22842
|
+
INNER JOIN recent_runs ON recent_runs.id = crawl_recommendations.run_id
|
|
22843
|
+
GROUP BY crawl_recommendations.run_id
|
|
21248
22844
|
),
|
|
21249
22845
|
recommendation_rank AS (
|
|
21250
22846
|
SELECT
|
|
21251
22847
|
crawl_recommendations.*,
|
|
21252
22848
|
ROW_NUMBER() OVER (
|
|
21253
|
-
PARTITION BY run_id
|
|
22849
|
+
PARTITION BY crawl_recommendations.run_id
|
|
21254
22850
|
ORDER BY
|
|
21255
|
-
CASE severity WHEN 'high' THEN 3 WHEN 'medium' THEN 2 ELSE 1 END DESC,
|
|
21256
|
-
created_at DESC
|
|
22851
|
+
CASE crawl_recommendations.severity WHEN 'high' THEN 3 WHEN 'medium' THEN 2 ELSE 1 END DESC,
|
|
22852
|
+
crawl_recommendations.created_at DESC
|
|
21257
22853
|
) AS rank
|
|
21258
22854
|
FROM crawl_recommendations
|
|
22855
|
+
INNER JOIN recent_runs ON recent_runs.id = crawl_recommendations.run_id
|
|
21259
22856
|
)
|
|
21260
22857
|
SELECT
|
|
21261
22858
|
crawl_runs.*,
|
|
@@ -21267,14 +22864,12 @@ function latestCrawlSummaries(site, limit = 5) {
|
|
|
21267
22864
|
top.title AS top_recommendation_title,
|
|
21268
22865
|
top.action AS top_recommendation_action,
|
|
21269
22866
|
top.severity AS top_recommendation_severity
|
|
21270
|
-
FROM crawl_runs
|
|
22867
|
+
FROM recent_runs AS crawl_runs
|
|
21271
22868
|
LEFT JOIN page_counts ON page_counts.run_id = crawl_runs.id
|
|
21272
22869
|
LEFT JOIN recommendation_counts ON recommendation_counts.run_id = crawl_runs.id
|
|
21273
22870
|
LEFT JOIN recommendation_rank top ON top.run_id = crawl_runs.id AND top.rank = 1
|
|
21274
|
-
WHERE crawl_runs.site_url = ?
|
|
21275
22871
|
GROUP BY crawl_runs.id
|
|
21276
|
-
ORDER BY crawl_runs.created_at DESC
|
|
21277
|
-
LIMIT ?`).all(site, limit).map((row) => ({
|
|
22872
|
+
ORDER BY crawl_runs.created_at DESC`).all(site, limit).map((row) => ({
|
|
21278
22873
|
...toRun(row),
|
|
21279
22874
|
statusErrors: row.status_errors,
|
|
21280
22875
|
nonIndexable: row.non_indexable,
|
|
@@ -22336,6 +23931,7 @@ function indexWatchFailureItem(input) {
|
|
|
22336
23931
|
//#endregion
|
|
22337
23932
|
//#region packages/core/src/analyze/monitoring/index-watch.ts
|
|
22338
23933
|
const MAX_DIRECT_INSPECTIONS = 100;
|
|
23934
|
+
const INDEX_WATCH_ATTEMPT_RETENTION = 20;
|
|
22339
23935
|
function previousFromRow(row) {
|
|
22340
23936
|
if (!row.inspected_at) return void 0;
|
|
22341
23937
|
return {
|
|
@@ -22350,6 +23946,36 @@ function previousFromRow(row) {
|
|
|
22350
23946
|
lastCrawlTime: row.last_crawl_time ?? void 0
|
|
22351
23947
|
};
|
|
22352
23948
|
}
|
|
23949
|
+
function pruneIndexWatchSnapshots(rootSite, url, database = getDb()) {
|
|
23950
|
+
return database.prepare(`WITH attempts AS (
|
|
23951
|
+
SELECT id,
|
|
23952
|
+
ROW_NUMBER() OVER (
|
|
23953
|
+
PARTITION BY root_site_url, url
|
|
23954
|
+
ORDER BY inspected_at DESC, id DESC
|
|
23955
|
+
) AS attempt_rank
|
|
23956
|
+
FROM index_watch_snapshots
|
|
23957
|
+
WHERE root_site_url = ? AND url = ?
|
|
23958
|
+
), latest_success AS (
|
|
23959
|
+
SELECT id FROM (
|
|
23960
|
+
SELECT id,
|
|
23961
|
+
ROW_NUMBER() OVER (
|
|
23962
|
+
ORDER BY inspected_at DESC, id DESC
|
|
23963
|
+
) AS success_rank
|
|
23964
|
+
FROM index_watch_snapshots
|
|
23965
|
+
WHERE root_site_url = ? AND url = ?
|
|
23966
|
+
AND inspection_status = 'succeeded'
|
|
23967
|
+
AND error_code IS NULL
|
|
23968
|
+
)
|
|
23969
|
+
WHERE success_rank = 1
|
|
23970
|
+
)
|
|
23971
|
+
DELETE FROM index_watch_snapshots
|
|
23972
|
+
WHERE id IN (
|
|
23973
|
+
SELECT attempts.id
|
|
23974
|
+
FROM attempts
|
|
23975
|
+
LEFT JOIN latest_success ON latest_success.id = attempts.id
|
|
23976
|
+
WHERE attempts.attempt_rank > ? AND latest_success.id IS NULL
|
|
23977
|
+
)`).run(rootSite, url, rootSite, url, 20).changes;
|
|
23978
|
+
}
|
|
22353
23979
|
const defaultDependencies$2 = {
|
|
22354
23980
|
inspectUrl,
|
|
22355
23981
|
now: () => /* @__PURE__ */ new Date(),
|
|
@@ -22363,12 +23989,14 @@ const defaultDependencies$2 = {
|
|
|
22363
23989
|
return row ? previousFromRow(row) : void 0;
|
|
22364
23990
|
},
|
|
22365
23991
|
insert(item) {
|
|
22366
|
-
getDb()
|
|
23992
|
+
const db = getDb();
|
|
23993
|
+
db.prepare(`INSERT INTO index_watch_snapshots
|
|
22367
23994
|
(id, site_url, root_site_url, property_site_url, url, verdict,
|
|
22368
23995
|
coverage_state, indexing_state, robots_txt_state, page_fetch_state,
|
|
22369
23996
|
google_canonical, user_canonical, last_crawl_time, inspection_status,
|
|
22370
23997
|
error_code, error_message, inspected_at)
|
|
22371
23998
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`).run(randomUUID(), item.property, item.rootSite, item.property, item.url, item.verdict ?? null, item.coverageState ?? null, item.indexingState ?? null, item.robotsTxtState ?? null, item.pageFetchState ?? null, item.googleCanonical ?? null, item.userCanonical ?? null, item.lastCrawlTime ?? null, item.inspectionStatus, item.errorCode ?? null, item.errorMessage ?? null, Date.parse(item.inspectedAt));
|
|
23999
|
+
pruneIndexWatchSnapshots(item.rootSite, item.url, db);
|
|
22372
24000
|
}
|
|
22373
24001
|
}
|
|
22374
24002
|
};
|
|
@@ -22912,6 +24540,7 @@ async function indexMonitor(input) {
|
|
|
22912
24540
|
}
|
|
22913
24541
|
//#endregion
|
|
22914
24542
|
//#region packages/core/src/analyze/monitoring/link-recover-store.ts
|
|
24543
|
+
const LINK_RECOVER_RUN_RETENTION = 20;
|
|
22915
24544
|
function toSummary(row) {
|
|
22916
24545
|
return {
|
|
22917
24546
|
id: row.id,
|
|
@@ -22954,6 +24583,14 @@ function insertLinkRecoverRun(report) {
|
|
|
22954
24583
|
insertRun.run(id, report.site, createdAt, report.range.startDate, report.range.endDate, report.range.days, report.summary.checked, report.summary.recoverable, report.summary.high, report.summary.medium, report.summary.low, report.summary.clicksAtRisk, report.summary.impressionsAtRisk, top?.issue ?? null, top?.url ?? null, top?.recommendation.action ?? null);
|
|
22955
24584
|
for (const item of report.items) insertItem.run(id, report.site, item.url, item.finalUrl, item.issue, JSON.stringify(item.issues), item.severity, item.clicks, item.impressions, item.position, item.recommendation.action, createdAt);
|
|
22956
24585
|
})();
|
|
24586
|
+
db.prepare(`DELETE FROM link_recover_runs
|
|
24587
|
+
WHERE site_url = ?
|
|
24588
|
+
AND id NOT IN (
|
|
24589
|
+
SELECT id FROM link_recover_runs
|
|
24590
|
+
WHERE site_url = ?
|
|
24591
|
+
ORDER BY created_at DESC, id DESC
|
|
24592
|
+
LIMIT ?
|
|
24593
|
+
)`).run(report.site, report.site, 20);
|
|
22957
24594
|
return id;
|
|
22958
24595
|
}
|
|
22959
24596
|
function latestLinkRecoverSummary(site) {
|
|
@@ -23868,9 +25505,11 @@ function cacheSet(report, now) {
|
|
|
23868
25505
|
...report,
|
|
23869
25506
|
raw: void 0
|
|
23870
25507
|
};
|
|
25508
|
+
const reportJson = JSON.stringify(stored);
|
|
23871
25509
|
getDb().prepare(`INSERT OR REPLACE INTO performance_reports
|
|
23872
25510
|
(id, url, strategy, report_json, created_at, expires_at)
|
|
23873
|
-
VALUES (?, ?, ?, ?, ?, ?)`).run(report.id, report.url, report.strategy,
|
|
25511
|
+
VALUES (?, ?, ?, ?, ?, ?)`).run(report.id, report.url, report.strategy, reportJson, Date.parse(report.generatedAt), now + CACHE_TTL_MS);
|
|
25512
|
+
noteCacheWrite(Buffer.byteLength(reportJson));
|
|
23874
25513
|
}
|
|
23875
25514
|
async function fallbackReport(input) {
|
|
23876
25515
|
const fetched = await fetchPage(input.url, {
|
|
@@ -25419,7 +27058,7 @@ function clusterRecommendation(input) {
|
|
|
25419
27058
|
return `This ${clusterName} cluster already gets clicks. Use it to refine page structure, FAQs, and internal anchors rather than creating duplicate pages.`;
|
|
25420
27059
|
}
|
|
25421
27060
|
function clusterSummary(input) {
|
|
25422
|
-
return `${input.queries
|
|
27061
|
+
return `${countLabel(input.queries, "query", "queries")}, ${countLabel(input.impressions, "impression")}, ${countLabel(input.clicks, "click")}, average position ${input.position.toFixed(1)}.`;
|
|
25423
27062
|
}
|
|
25424
27063
|
function clusterLabel(rows) {
|
|
25425
27064
|
return [...rows].sort((a, b) => b.impressions - a.impressions || compareQueryClusterText(a.query, b.query))[0]?.query ?? "";
|
|
@@ -25892,7 +27531,7 @@ function contentOpportunityBullets(report) {
|
|
|
25892
27531
|
if (report.cannibalization.selection.suppressedQueries) bullets.push(cannibalSuppressionLine(report));
|
|
25893
27532
|
if (report.summary.strikingDistanceItems) {
|
|
25894
27533
|
const top = report.strikingDistance.items[0];
|
|
25895
|
-
bullets.push(top ? `${report.summary.strikingDistanceItems
|
|
27534
|
+
bullets.push(top ? `${countLabel(report.summary.strikingDistanceItems, "query/page candidate")} ${plural$1(report.summary.strikingDistanceItems, "has", "have")} an average GSC position above 10 and at most 20. Start by investigating "${top.query}" on ${top.url}; it averages position ${top.position.toFixed(1)} with ${top.impressions.toFixed(0)} impressions.` : `${countLabel(report.summary.strikingDistanceItems, "query/page candidate")} ${plural$1(report.summary.strikingDistanceItems, "has", "have")} an average GSC position above 10 and at most 20.`);
|
|
25896
27535
|
}
|
|
25897
27536
|
if (!skippedReason(report, "quick-win opportunities") && report.summary.quickWinItems) {
|
|
25898
27537
|
const top = report.quickWins.items[0];
|
|
@@ -26373,10 +28012,10 @@ function applyVerification(item, coverage) {
|
|
|
26373
28012
|
function verdict(report) {
|
|
26374
28013
|
if (report.dataStatus === "empty") return "Google Search Console returned no retained query/page rows for this window.";
|
|
26375
28014
|
if (report.dataStatus === "filtered") return "No pages met the position, brand, and minimum-impression criteria.";
|
|
26376
|
-
if (report.technical > 0) return `${report.eligible
|
|
26377
|
-
if (report.failed > 0) return `${report.eligible
|
|
26378
|
-
if (report.content > 0) return `${report.eligible
|
|
26379
|
-
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.`;
|
|
26380
28019
|
}
|
|
26381
28020
|
function reportRecommendations(items) {
|
|
26382
28021
|
const first = items[0];
|
|
@@ -26423,7 +28062,7 @@ async function secondPage(input, dependencies = defaultDependencies) {
|
|
|
26423
28062
|
});
|
|
26424
28063
|
const generatedAt = now.toISOString();
|
|
26425
28064
|
const warnings = [];
|
|
26426
|
-
const verificationRequested = input.verifyContent === true || input.verifyLimit !== void 0;
|
|
28065
|
+
const verificationRequested = input.verifyContent === true || input.verifyContent !== false && input.verifyLimit !== void 0;
|
|
26427
28066
|
const verificationItems = verificationRequested ? analysis.items.slice(0, verifyLimit) : [];
|
|
26428
28067
|
for (const item of verificationItems) {
|
|
26429
28068
|
const coverage = await verifyQueryContent({
|
|
@@ -27171,7 +28810,7 @@ async function refreshPrioritiesWorkflow(input) {
|
|
|
27171
28810
|
const diagnosis = await diagnoseProperty({
|
|
27172
28811
|
...input,
|
|
27173
28812
|
verifyContent: input.verifyContent ?? true,
|
|
27174
|
-
verifyLimit: input.verifyLimit ?? Math.min(input.limit ?? 5, 5)
|
|
28813
|
+
verifyLimit: input.verifyContent === false ? void 0 : input.verifyLimit ?? Math.min(input.limit ?? 5, 5)
|
|
27175
28814
|
});
|
|
27176
28815
|
const range = defaultDateRange$1(input.days ?? 28);
|
|
27177
28816
|
const analytics = await fetchLandingPageValues({
|
|
@@ -28265,11 +29904,76 @@ function ga4MatchReason(match, site) {
|
|
|
28265
29904
|
return `Matched Google Analytics web stream ${stream} (${match.defaultUri}): its hostname ${location} ${site}.`;
|
|
28266
29905
|
}
|
|
28267
29906
|
//#endregion
|
|
29907
|
+
//#region packages/core/src/storage/log-retention.ts
|
|
29908
|
+
const MEBIBYTE = 1024 * 1024;
|
|
29909
|
+
const LOG_MAX_FILE_BYTES = 8 * MEBIBYTE;
|
|
29910
|
+
const LOG_MAX_TOTAL_BYTES = 64 * MEBIBYTE;
|
|
29911
|
+
const LOG_MAX_AGE_MS = 336 * 60 * 60 * 1e3;
|
|
29912
|
+
function logFiles(directory) {
|
|
29913
|
+
if (!existsSync(directory)) return [];
|
|
29914
|
+
return readdirSync(directory, { withFileTypes: true }).filter((entry) => entry.isFile() && entry.name.endsWith(".log")).map((entry) => {
|
|
29915
|
+
const path = join(directory, entry.name);
|
|
29916
|
+
const stats = statSync(path);
|
|
29917
|
+
return {
|
|
29918
|
+
path,
|
|
29919
|
+
size: stats.size,
|
|
29920
|
+
modifiedAt: stats.mtimeMs
|
|
29921
|
+
};
|
|
29922
|
+
});
|
|
29923
|
+
}
|
|
29924
|
+
function rotatedPath(path, now) {
|
|
29925
|
+
const directory = dirname(path);
|
|
29926
|
+
const stem = basename(path, ".log");
|
|
29927
|
+
let suffix = 0;
|
|
29928
|
+
while (true) {
|
|
29929
|
+
const candidate = join(directory, `${stem}.${now}${suffix ? `-${suffix}` : ""}.log`);
|
|
29930
|
+
if (!existsSync(candidate)) return candidate;
|
|
29931
|
+
suffix += 1;
|
|
29932
|
+
}
|
|
29933
|
+
}
|
|
29934
|
+
function pruneLogs(options = {}) {
|
|
29935
|
+
const directory = options.directory ?? getSeoCliPaths().logDir;
|
|
29936
|
+
const now = options.now ?? Date.now();
|
|
29937
|
+
const maxFileBytes = options.maxFileBytes ?? 8388608;
|
|
29938
|
+
const maxTotalBytes = options.maxTotalBytes ?? 67108864;
|
|
29939
|
+
const maxAgeMs = options.maxAgeMs ?? 12096e5;
|
|
29940
|
+
mkdirSync(directory, {
|
|
29941
|
+
recursive: true,
|
|
29942
|
+
mode: 448
|
|
29943
|
+
});
|
|
29944
|
+
let rotated = 0;
|
|
29945
|
+
let removed = 0;
|
|
29946
|
+
for (const file of logFiles(directory)) {
|
|
29947
|
+
if (file.size <= maxFileBytes) continue;
|
|
29948
|
+
renameSync(file.path, rotatedPath(file.path, now));
|
|
29949
|
+
rotated += 1;
|
|
29950
|
+
}
|
|
29951
|
+
for (const file of logFiles(directory)) {
|
|
29952
|
+
if (file.modifiedAt >= now - maxAgeMs) continue;
|
|
29953
|
+
rmSync(file.path, { force: true });
|
|
29954
|
+
removed += 1;
|
|
29955
|
+
}
|
|
29956
|
+
const retained = logFiles(directory).sort((left, right) => left.modifiedAt - right.modifiedAt || (left.path < right.path ? -1 : left.path > right.path ? 1 : 0));
|
|
29957
|
+
let sizeBytes = retained.reduce((total, file) => total + file.size, 0);
|
|
29958
|
+
for (const file of retained) {
|
|
29959
|
+
if (sizeBytes <= maxTotalBytes) break;
|
|
29960
|
+
rmSync(file.path, { force: true });
|
|
29961
|
+
sizeBytes -= file.size;
|
|
29962
|
+
removed += 1;
|
|
29963
|
+
}
|
|
29964
|
+
return {
|
|
29965
|
+
rotated,
|
|
29966
|
+
removed,
|
|
29967
|
+
sizeBytes
|
|
29968
|
+
};
|
|
29969
|
+
}
|
|
29970
|
+
//#endregion
|
|
28268
29971
|
//#region packages/core/src/logger.ts
|
|
28269
29972
|
let loggerInstance;
|
|
28270
29973
|
function createLogger(runId) {
|
|
28271
29974
|
if (loggerInstance) return loggerInstance.child({ runId });
|
|
28272
29975
|
const paths = ensureSeoCliDirs();
|
|
29976
|
+
pruneLogs({ directory: paths.logDir });
|
|
28273
29977
|
const date = (/* @__PURE__ */ new Date()).toISOString().slice(0, 10);
|
|
28274
29978
|
const fileStream = createWriteStream(join(paths.logDir, `${date}.log`), { flags: "a" });
|
|
28275
29979
|
const destination = process.stderr.isTTY ? pretty({
|
|
@@ -28337,6 +30041,7 @@ function parseSemicolonCsv(text) {
|
|
|
28337
30041
|
//#endregion
|
|
28338
30042
|
//#region packages/core/src/providers/semrush/cache.ts
|
|
28339
30043
|
const BASE_URL = "https://api.semrush.com/";
|
|
30044
|
+
const MAX_RESPONSE_BYTES = 10 * 1024 * 1024;
|
|
28340
30045
|
function estimateUsd(units) {
|
|
28341
30046
|
return units / 1e3 * .05;
|
|
28342
30047
|
}
|
|
@@ -28366,14 +30071,17 @@ async function cachedSemrushCall(endpoint, params, map, ttlMs, creditsPerLine, r
|
|
|
28366
30071
|
const url = new URL(BASE_URL);
|
|
28367
30072
|
url.search = new URLSearchParams(requestParams).toString();
|
|
28368
30073
|
const response = await fetch$1(url);
|
|
28369
|
-
const text = await response
|
|
30074
|
+
const text = await readBoundedResponseText(response, MAX_RESPONSE_BYTES, "Semrush response");
|
|
28370
30075
|
if (!response.ok || text.startsWith("ERROR ::")) throw new Error(text || `Semrush request failed with ${response.status}.`);
|
|
28371
30076
|
const rows = parseSemicolonCsv(text);
|
|
28372
30077
|
const data = map(rows);
|
|
28373
30078
|
const credits = Math.max(0, rows.length - 1) * creditsPerLine;
|
|
30079
|
+
const requestJson = JSON.stringify(requestParams);
|
|
30080
|
+
const responseJson = JSON.stringify(data);
|
|
28374
30081
|
db.prepare(`INSERT OR REPLACE INTO semrush_cache
|
|
28375
30082
|
(endpoint, query_hash, request_json, response_json, credits_used, fetched_at, expires_at)
|
|
28376
|
-
VALUES (?, ?, ?, ?, ?, ?, ?)`).run(endpoint, queryHash,
|
|
30083
|
+
VALUES (?, ?, ?, ?, ?, ?, ?)`).run(endpoint, queryHash, requestJson, responseJson, credits, Date.now(), Date.now() + ttlMs);
|
|
30084
|
+
noteCacheWrite(Buffer.byteLength(requestJson) + Buffer.byteLength(responseJson));
|
|
28377
30085
|
return {
|
|
28378
30086
|
data,
|
|
28379
30087
|
usage: {
|
|
@@ -28570,9 +30278,6 @@ var JsonFileStorageAdapter = class {
|
|
|
28570
30278
|
};
|
|
28571
30279
|
const localCredentialsProvider = new LocalCredentialsProvider();
|
|
28572
30280
|
//#endregion
|
|
28573
|
-
//#region packages/core/src/version.ts
|
|
28574
|
-
const SEO_VERSION = version;
|
|
28575
|
-
//#endregion
|
|
28576
30281
|
//#region packages/core/src/telemetry.ts
|
|
28577
30282
|
const TELEMETRY_ENDPOINT = "https://seoskill.dev/api/t";
|
|
28578
30283
|
const TELEMETRY_SCHEMA_VERSION = 1;
|
|
@@ -28969,6 +30674,6 @@ function isoDate(daysAgo = 0) {
|
|
|
28969
30674
|
return date.toISOString().slice(0, 10);
|
|
28970
30675
|
}
|
|
28971
30676
|
//#endregion
|
|
28972
|
-
export { AI_REFERRAL_SOURCES, AI_REFERRAL_SOURCES_VERSION, AI_SEARCH_SCORECARD_METHODOLOGY_ID, AI_SEARCH_SCORECARD_METHODOLOGY_VERSION, AUDIT_PAGE_RULE_IDS, BROWSER_USER_AGENT, CANNIBAL_MAXIMUM_DOMINANT_SHARE, CANNIBAL_MINIMUM_PAGE_SHARE, CRAWL_REPORT_STORAGE_VERSION, CRAWL_SKIP_REASONS, CTR_DEFAULT_LIMIT, CTR_DEFAULT_MIN_IMPRESSIONS, CTR_MAX_LIMIT, CTR_MAX_MIN_IMPRESSIONS, DataForSeoProvider, GSC_FINAL_DATA_LAG_DAYS, GSC_TIME_ZONE, INTERNAL_LINK_LEXICAL_TARGET_LIMIT, INTERNAL_LINK_MATCH_EVIDENCE_LIMIT, JsonFileStorageAdapter, LocalCredentialsProvider, OKF_DEFAULT_CONCEPTS, OKF_MAX_CONCEPTS, REPORT_BASELINE_MAX_AGE_MS, REPORT_BASELINE_MAX_DEPTH, REPORT_BASELINE_MAX_PAGES, SCORECARD_CHECK_WEIGHTS, SCORECARD_STATUS_CREDIT, SEO_VERSION, SERP_TITLE_REFERENCE_WIDTH_PX, SERP_TITLE_WIDTH_PROFILE, SemrushProvider, SeoError, ServiceAccountAccessTokenClient, SessionLedger, TELEMETRY_AGENTS, TELEMETRY_ENDPOINT, TELEMETRY_ERROR_CATEGORIES, TELEMETRY_EVENTS, TELEMETRY_NOTICE, TELEMETRY_REPORTS, TELEMETRY_SCHEMA_VERSION, TELEMETRY_TIMEOUT_MS, affectedUrls, agentReadiness, aiPromptsForQuery, aiReadiness, aiReferralSourceForValue, aiReferralsReport, aiSearchScorecard, allocateIndexUrlsToProperties, analyticsConnectionsSchema, analyzeAiReferralRows, analyzeCannibalRows, analyzeCtrUnderperformersFromRows, analyzeDecay, analyzeIndexCoverageSignals, analyzeInternalLinksFromRows, analyzePageOpportunitiesFromRows, analyzeQueryClustersFromRows, analyzeQuickWinsFromRows, analyzeSecondPageRows, analyzeSegmentRows, analyzeStrikingDistanceRows, analyzeTrafficAnomalyRows, assertUrlMatchesGscProperty, attachCrawlRecommendations, auditCrawlPages, auditCrawlRequests, auditLlmsTxt, auditPage, authStatus, boundedSitemapInventory, buildOkfBundle, buildPseoAuditReportFromRows, buildTelemetryPayload, cannibalReport, changeMeasurementCsvRows, checkDatabaseReadiness, classifyCommunityIntent, clearCache, clientProfileSchema, clusterPseoTemplates, clusterQueryRows, collectAgentDiscovery, combineRobotsValues, communityIntentReport, compareCrawlPages, compareCrawlReports, compareSegmentRows, configSchema, contentCoverageRecommendation, contentCoverageType, contentOptimizationFromPageOpportunities, contentOptimizationReport, countLabel, crawlConfigHash, crawlConfigSchema, crawlDefinitionId, crawlDiff, crawlFixToQueueItem, crawlImplementationQueueWorkflow, crawlIssueGroupSchema, crawlIssueSchema, crawlOne, crawlPageSnapshotSchema, crawlReportSchema, crawlReportStore, crawlReportSummarySchema, crawlRequestObservationSchema, crawlRunId, crawlSite, crawlSkipImpact, crawlSkipReasonCountsFromRecord, crawlTopFixSchema, crawlerJsonSchemas, crawlerRuleCategorySchema, crawlerRuleIdSchema, crawlerRuleInfoSchema, crawlerRuleRecommendationSchema, crawlerRuleSeveritySchema, crawlerSchemas, createAuthorizedClient, createContentGroup, createCrawlReport, createCtrBenchmarkContext, createGoogleAccessTokenClient, createLogger, createPageRenderer, ctrUnderperformersReport, decayClusterDrafts, decayComparisonRange, decayingReport, deleteChange, deleteClient, deleteContentGroup, deleteCrawlReport, deleteTokens, deriveBrandTerms, detectBrandTerms, detectTelemetryAgent, diagnoseCsvFiles, diagnoseProperty, diagnosePropertyWorkflow, effectiveRobotsDirectives, effectiveSnippetControl, ensureSeoCliDirs, entityReadiness, estimateSerpTitleWidth, expectedCtrForPosition, explainOkfValidation, explainRule, fetchCruxFieldData, fetchPage, fetchSearchStatusIncidents, fetchSitemapUrls, fileSize, finalGscDateRange, findOverlappingSearchUpdates, formatRelativeExpiry, ga4MatchReason, ga4PropertyIdFromName, ga4ReportQualityWarnings, ga4RequestCanUseCache, ga4RowsToObjects, generateLlmsTxt, geoGaps, geoGapsReport, getAuthModeStatus, getCacheStats, getChange, getClient, getClientConfig, getContentGroup, getDb, getKeywordProvider, getPreviousRun, getPrivacySnapshot, getRepeatedLinkRecoverUrls, getRunPages, getSeoCliPaths, getServiceAccountConfig, getServiceAccountStatus, getTelemetryStatus, getTokenStorageStatus, googleAnalyticsPropertyId, groupCrawlIssues, hasMetaRobotsDirective, hasRule, hasXRobotsDirective, hashKey, indexCoveragePlan, indexCoverageSignals, indexMonitor, indexWatch, inferPseoTemplate, inferTemplateMovement, initializeTelemetry, insertCrawlRun, insertLinkRecoverRun, inspectUrl, internalLinksReport, interpretUpdateCorrelation, isBrandQuery, isCompatibleTechnicalBaseline, isCurrentTechnicalBaseline, isEntityEvidenceSchemaType, isSiteIdentitySchemaType, isSkippableReportError, isTelemetryCi, isTelemetryReport, isoDate, isoWeek, latestCrawlReport, latestCrawlSummaries, latestFinalGscDate, latestIndexWatchSummary, latestLinkRecoverSummary, linkRecover, linkRecoverRecommendation, listChanges, listClients, listContentGroups, listCrawlReports, listGa4AccountSummaries, listGa4DataStreams, listRules, listSearchUpdates, listSites, loadCrawlReport, localCredentialsProvider, loginWithLoopback, makeRunId, matchGa4WebStreams, measureChange, measureCoverage, metaRobotsDirectives, migrateLegacyMacConfig, missingOAuthClientMessage, missingSharedClientLoginMessage, monitoringStatus, monthlyReport, monthlyReportWorkflow, narrativeCsvFiles, normalizeCrawlConfig, normalizeCrawlSkipReasonCounts, normalizeForCoverage, normalizeHttpUrl, normalizeJavaScriptRenderingMode, normalizeLoadedCrawlReport, okfConceptLimit, pageEvidenceRecommendation, pageOpportunitiesReport, parseOlderThan, parsePseoPath, performanceAudit, performanceReportIsCacheable, planDueIndexUrls, planIndexCoverageFromUrls, plural, propertyMatchesUrl, providerPreferenceSchema, pseoAuditOptions, pseoAuditReport, pseoCsvFiles, pseoIndexStatus, pseoPresentation, publicHttpFetch, queryClusterReport, queryClusterTokens, queryContentCoverageFromPage, queryContentFieldsFromPage, queryFieldCoverage, queryOpportunityRecommendation, queryPageMetrics, queryPageTopQuery, queryPagesMetrics, queryPagesMetricsBatch, queryPagesTopQueries, queryPagesTopQueriesBatch, querySearchAnalytics, quickWinsCsvRows, quickWinsReport, readConfig, readOauthClient, readTokens, recommendCrawlDiffItem, recommendationForRule, recordChange, redirectTrace, refreshAuthToken, refreshPrioritiesCsvFiles, refreshPrioritiesWorkflow, renderCrawlCsv, renderCrawlHtml, renderCrawlMarkdownTickets, renderCrawlPagesCsv, renderCrawlPretty, renderCsv, renderPresentationTablesMarkdown, renderPseoMarkdown, renderWorkflowMarkdown, reportNarrative, reportPresentation, requestHeaders, resolveSegmentRanges, resolveSeoCliPaths, resolveTechnicalBaseline, reviewObservations, roundedPosition, runDoctor, runGa4Report, saveClient, saveCrawlReport, secondPage, segmentComparisonRange, segmentImpact, segmentRangeDays, selectAffectedUrls, selectDueIndexUrls, seoErrorEnvelope, seoToAiQueryReport, sessionSourceInListFilter, setDefaultClient, setTelemetryEnabled, setTokenStorageMode, shiftIsoDate, shouldExcludeBrandQuery, siteSchema, sqliteCrawlReportStore, strikingDistance, summarizeCrawlReport, pageTechnicalSignals as technicalPageSignals, technicalWatchWorkflow, telemetryErrorCategory, templateForUrl, toSeoError, tokenSchema, topCrawlRecommendations, topFixes, trackTelemetryReportComplete, trackTelemetryReportFailed, trackTelemetryReportStart, trackTelemetrySetupComplete, trafficAnomaly, unavailableSegmentImpactReport, unicodeTokens, updateCorrelation, updatePostmortemCsvFiles, updatePostmortemWorkflow, validateOkfFiles, verifyQueryContent, workflowPresentation, writeConfig, writeOauthClient, writeTokens, xRobotsDirectives };
|
|
30677
|
+
export { AI_REFERRAL_SOURCES, AI_REFERRAL_SOURCES_VERSION, AI_SEARCH_SCORECARD_METHODOLOGY_ID, AI_SEARCH_SCORECARD_METHODOLOGY_VERSION, AUDIT_PAGE_RULE_IDS, AUTOMATIC_BASELINE_SITE_RETENTION, AUTOMATIC_BASELINE_TOTAL_RETENTION, BROWSER_USER_AGENT, CANNIBAL_MAXIMUM_DOMINANT_SHARE, CANNIBAL_MINIMUM_PAGE_SHARE, CRAWL_REPORT_STORAGE_VERSION, CRAWL_RUN_RETENTION, CRAWL_SKIP_REASONS, CTR_DEFAULT_LIMIT, CTR_DEFAULT_MIN_IMPRESSIONS, CTR_MAX_LIMIT, CTR_MAX_MIN_IMPRESSIONS, DataForSeoProvider, GSC_FINAL_DATA_LAG_DAYS, GSC_TIME_ZONE, INDEX_WATCH_ATTEMPT_RETENTION, INTERNAL_LINK_LEXICAL_TARGET_LIMIT, INTERNAL_LINK_MATCH_EVIDENCE_LIMIT, JsonFileStorageAdapter, LINK_RECOVER_RUN_RETENTION, LOG_MAX_AGE_MS, LOG_MAX_FILE_BYTES, LOG_MAX_TOTAL_BYTES, LocalCredentialsProvider, MAX_CRAWL_CONCURRENCY, MAX_CRAWL_DEPTH, MAX_CRAWL_PAGES, MAX_CRAWL_TIMEOUT_MS, OKF_DEFAULT_CONCEPTS, OKF_MAX_CONCEPTS, REPORT_BASELINE_MAX_AGE_MS, REPORT_BASELINE_MAX_DEPTH, REPORT_BASELINE_MAX_PAGES, ResponseSizeLimitError, SCORECARD_CHECK_WEIGHTS, SCORECARD_STATUS_CREDIT, SEO_CRAWLER_IDENTITY, SEO_CRAWLER_TOKEN, SEO_CRAWLER_USER_AGENT, SEO_VERSION, SERP_TITLE_REFERENCE_WIDTH_PX, SERP_TITLE_WIDTH_PROFILE, SemrushProvider, SeoError, ServiceAccountAccessTokenClient, SessionLedger, TELEMETRY_AGENTS, TELEMETRY_ENDPOINT, TELEMETRY_ERROR_CATEGORIES, TELEMETRY_EVENTS, TELEMETRY_NOTICE, TELEMETRY_REPORTS, TELEMETRY_SCHEMA_VERSION, TELEMETRY_TIMEOUT_MS, affectedUrls, agentReadiness, aiPromptsForQuery, aiReadiness, aiReferralSourceForValue, aiReferralsReport, aiSearchScorecard, allocateIndexUrlsToProperties, analyticsConnectionsSchema, analyzeAiReferralRows, analyzeCannibalRows, analyzeCtrUnderperformersFromRows, analyzeDecay, analyzeIndexCoverageSignals, analyzeInternalLinksFromRows, analyzePageOpportunitiesFromRows, analyzeQueryClustersFromRows, analyzeQuickWinsFromRows, analyzeSecondPageRows, analyzeSegmentRows, analyzeStrikingDistanceRows, analyzeTrafficAnomalyRows, assertCrawlConfigLimits, assertUrlMatchesGscProperty, attachCrawlRecommendations, auditCrawlPages, auditCrawlRequests, auditLlmsTxt, auditPage, authStatus, boundedSitemapInventory, buildOkfBundle, buildPseoAuditReportFromRows, buildTelemetryPayload, cannibalReport, changeMeasurementCsvRows, checkDatabaseReadiness, classifyCommunityIntent, clearCache, clientProfileSchema, clusterPseoTemplates, clusterQueryRows, collectAgentDiscovery, combineRobotsValues, communityIntentReport, compareCrawlPages, compareCrawlReports, compareSegmentRows, configSchema, contentCoverageRecommendation, contentCoverageType, contentOptimizationFromPageOpportunities, contentOptimizationReport, countLabel, crawlConfigHash, crawlConfigSchema, crawlDefinitionId, crawlDiff, crawlFixToQueueItem, crawlImplementationQueueWorkflow, crawlIssueGroupSchema, crawlIssueSchema, crawlOne, crawlPageSnapshotSchema, crawlReportSchema, crawlReportStore, crawlReportSummarySchema, crawlRequestObservationSchema, crawlRunId, crawlSite, crawlSkipImpact, crawlSkipReasonCountsFromRecord, crawlStatusOnly, crawlTopFixSchema, crawlerJsonSchemas, crawlerRuleCategorySchema, crawlerRuleIdSchema, crawlerRuleInfoSchema, crawlerRuleRecommendationSchema, crawlerRuleSeveritySchema, crawlerSchemas, createAuthorizedClient, createContentGroup, createCrawlReport, createCtrBenchmarkContext, createGoogleAccessTokenClient, createLogger, createPageRenderer, ctrUnderperformersReport, decayClusterDrafts, decayComparisonRange, decayingReport, deleteChange, deleteClient, deleteContentGroup, deleteCrawlReport, deleteTokens, deriveBrandTerms, detectBrandTerms, detectTelemetryAgent, diagnoseCsvFiles, diagnoseProperty, diagnosePropertyWorkflow, effectiveRobotsDirectives, effectiveSnippetControl, ensureSeoCliDirs, entityReadiness, estimateSerpTitleWidth, expectedCtrForPosition, explainOkfValidation, explainRule, fetchCruxFieldData, fetchPage, fetchPageStatus, fetchSearchStatusIncidents, fetchSitemapUrls, fetchText, fileSize, finalGscDateRange, findOverlappingSearchUpdates, formatRelativeExpiry, ga4MatchReason, ga4PropertyIdFromName, ga4ReportQualityWarnings, ga4RequestCanUseCache, ga4RowsToObjects, generateLlmsTxt, geoGaps, geoGapsReport, getAuthModeStatus, getCacheStats, getChange, getClient, getClientConfig, getContentGroup, getDb, getKeywordProvider, getPreviousRun, getPrivacySnapshot, getRepeatedLinkRecoverUrls, getRunPages, getSeoCliPaths, getServiceAccountConfig, getServiceAccountStatus, getTelemetryStatus, getTokenStorageStatus, googleAnalyticsPropertyId, groupCrawlIssues, hasMetaRobotsDirective, hasRule, hasXRobotsDirective, hashKey, indexCoveragePlan, indexCoverageSignals, indexMonitor, indexWatch, inferPseoTemplate, inferTemplateMovement, initializeTelemetry, insertCrawlRun, insertLinkRecoverRun, inspectUrl, internalLinksReport, interpretUpdateCorrelation, isBrandQuery, isCompatibleTechnicalBaseline, isCurrentTechnicalBaseline, isEntityEvidenceSchemaType, isSiteIdentitySchemaType, isSkippableReportError, isTelemetryCi, isTelemetryReport, isoDate, isoWeek, latestCrawlReport, latestCrawlSummaries, latestFinalGscDate, latestIndexWatchSummary, latestLinkRecoverSummary, linkEntries, linkRecover, linkRecoverRecommendation, listChanges, listClients, listContentGroups, listCrawlReports, listGa4AccountSummaries, listGa4DataStreams, listRules, listSearchUpdates, listSites, loadCrawlReport, localCredentialsProvider, loginWithLoopback, makeRunId, matchGa4WebStreams, measureChange, measureCoverage, metaRobotsDirectives, migrateLegacyMacConfig, missingOAuthClientMessage, missingSharedClientLoginMessage, monitoringStatus, monthlyReport, monthlyReportWorkflow, narrativeCsvFiles, normalizeCrawlConfig, normalizeCrawlSkipReasonCounts, normalizeForCoverage, normalizeHttpUrl, normalizeJavaScriptRenderingMode, normalizeLoadedCrawlReport, noteCacheWrite, okfConceptLimit, pageEvidenceRecommendation, pageOpportunitiesReport, parseOlderThan, parsePseoPath, performanceAudit, performanceReportIsCacheable, planDueIndexUrls, planIndexCoverageFromUrls, plural, propertyMatchesUrl, providerPreferenceSchema, pruneIndexWatchSnapshots, pruneLogs, pseoAuditOptions, pseoAuditReport, pseoCsvFiles, pseoIndexStatus, pseoPresentation, publicHttpFetch, queryClusterReport, queryClusterTokens, queryContentCoverageFromPage, queryContentFieldsFromPage, queryFieldCoverage, queryOpportunityRecommendation, queryPageMetrics, queryPageTopQuery, queryPagesMetrics, queryPagesMetricsBatch, queryPagesTopQueries, queryPagesTopQueriesBatch, querySearchAnalytics, quickWinsCsvRows, quickWinsReport, readBoundedResponseText, readConfig, readOauthClient, readTokens, recommendCrawlDiffItem, recommendationForRule, recordChange, redirectTrace, refreshAuthToken, refreshPrioritiesCsvFiles, refreshPrioritiesWorkflow, renderCrawlCsv, renderCrawlHtml, renderCrawlJunit, renderCrawlMarkdownTickets, renderCrawlPagesCsv, renderCrawlPretty, renderCsv, renderPresentationTablesMarkdown, renderPseoMarkdown, renderWorkflowMarkdown, reportNarrative, reportPresentation, requestHeaders, resolveSegmentRanges, resolveSeoCliPaths, resolveTechnicalBaseline, reviewObservations, roundedPosition, runDoctor, runGa4Report, safeError, saveClient, saveCrawlReport, secondPage, segmentComparisonRange, segmentImpact, segmentRangeDays, selectAffectedUrls, selectDueIndexUrls, seoErrorEnvelope, seoToAiQueryReport, sessionSourceInListFilter, setDefaultClient, setTelemetryEnabled, setTokenStorageMode, shiftIsoDate, shouldExcludeBrandQuery, siteSchema, sqliteCrawlReportStore, strikingDistance, summarizeCrawlReport, pageTechnicalSignals as technicalPageSignals, technicalWatchWorkflow, telemetryErrorCategory, templateForUrl, toSeoError, tokenSchema, topCrawlRecommendations, topFixes, trackTelemetryReportComplete, trackTelemetryReportFailed, trackTelemetryReportStart, trackTelemetrySetupComplete, trafficAnomaly, unavailableSegmentImpactReport, unicodeTokens, updateCorrelation, updatePostmortemCsvFiles, updatePostmortemWorkflow, validateOkfFiles, verifyQueryContent, workflowPresentation, writeConfig, writeOauthClient, writeTokens, xRobotsDirectives };
|
|
28973
30678
|
|
|
28974
30679
|
//# sourceMappingURL=index.js.map
|