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/mcp.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { createRequire as __seoCreateRequire } from 'node:module'; const require = __seoCreateRequire(import.meta.url);
|
|
2
|
-
import { n as load, t as version } from "./package-
|
|
2
|
+
import { n as load, t as version } from "./package-C51oI-6o.js";
|
|
3
3
|
import { Headers, fetch as fetch$1 } from "undici";
|
|
4
4
|
import { chmodSync, existsSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs";
|
|
5
5
|
import { arch, homedir, platform } from "node:os";
|
|
@@ -14,7 +14,6 @@ import { Readability } from "@mozilla/readability";
|
|
|
14
14
|
import Defuddle from "defuddle";
|
|
15
15
|
import { parseHTML } from "linkedom";
|
|
16
16
|
import pRetry, { AbortError } from "p-retry";
|
|
17
|
-
import UserAgent from "user-agents";
|
|
18
17
|
import PQueue from "p-queue";
|
|
19
18
|
import robotsParserModule from "robots-parser";
|
|
20
19
|
import { chromium } from "playwright-core";
|
|
@@ -400,10 +399,16 @@ function readOauthClient() {
|
|
|
400
399
|
//#region packages/core/dist/storage/lock.js
|
|
401
400
|
async function withFileLock(path, task) {
|
|
402
401
|
ensureParentDir(path);
|
|
402
|
+
const fastLocks = process.env.SEO_LOCK_FAST === "1";
|
|
403
403
|
const release = await lockfile.lock(path, {
|
|
404
|
-
stale: 6e4,
|
|
405
|
-
update: 5e3,
|
|
406
|
-
retries: {
|
|
404
|
+
stale: fastLocks ? 2e3 : 6e4,
|
|
405
|
+
update: fastLocks ? 1e3 : 5e3,
|
|
406
|
+
retries: fastLocks ? {
|
|
407
|
+
retries: 5,
|
|
408
|
+
factor: 1.2,
|
|
409
|
+
minTimeout: 50,
|
|
410
|
+
maxTimeout: 200
|
|
411
|
+
} : {
|
|
407
412
|
retries: 60,
|
|
408
413
|
factor: 1.2,
|
|
409
414
|
minTimeout: 200,
|
|
@@ -763,6 +768,164 @@ async function createGoogleAccessTokenClient() {
|
|
|
763
768
|
};
|
|
764
769
|
}
|
|
765
770
|
//#endregion
|
|
771
|
+
//#region packages/core/dist/storage/cache-maintenance.js
|
|
772
|
+
const MEBIBYTE$1 = 1024 * 1024;
|
|
773
|
+
const CACHE_MAX_AGE_MS = 720 * 60 * 60 * 1e3;
|
|
774
|
+
const CACHE_LIMITS = {
|
|
775
|
+
gsc_cache: 48 * MEBIBYTE$1,
|
|
776
|
+
ga4_cache: 24 * MEBIBYTE$1,
|
|
777
|
+
semrush_cache: 16 * MEBIBYTE$1,
|
|
778
|
+
http_cache: 160 * MEBIBYTE$1,
|
|
779
|
+
performance_reports: 8 * MEBIBYTE$1
|
|
780
|
+
};
|
|
781
|
+
const CACHE_POLICIES = [
|
|
782
|
+
{
|
|
783
|
+
table: "gsc_cache",
|
|
784
|
+
keys: ["site_url", "query_hash"],
|
|
785
|
+
timestamp: "fetched_at",
|
|
786
|
+
sizeColumns: [
|
|
787
|
+
"site_url",
|
|
788
|
+
"query_hash",
|
|
789
|
+
"request_json",
|
|
790
|
+
"response_json"
|
|
791
|
+
]
|
|
792
|
+
},
|
|
793
|
+
{
|
|
794
|
+
table: "ga4_cache",
|
|
795
|
+
keys: ["property_id", "query_hash"],
|
|
796
|
+
timestamp: "fetched_at",
|
|
797
|
+
sizeColumns: [
|
|
798
|
+
"property_id",
|
|
799
|
+
"query_hash",
|
|
800
|
+
"request_json",
|
|
801
|
+
"response_json"
|
|
802
|
+
]
|
|
803
|
+
},
|
|
804
|
+
{
|
|
805
|
+
table: "semrush_cache",
|
|
806
|
+
keys: ["endpoint", "query_hash"],
|
|
807
|
+
timestamp: "fetched_at",
|
|
808
|
+
sizeColumns: [
|
|
809
|
+
"endpoint",
|
|
810
|
+
"query_hash",
|
|
811
|
+
"request_json",
|
|
812
|
+
"response_json"
|
|
813
|
+
]
|
|
814
|
+
},
|
|
815
|
+
{
|
|
816
|
+
table: "http_cache",
|
|
817
|
+
keys: ["url_hash"],
|
|
818
|
+
timestamp: "fetched_at",
|
|
819
|
+
sizeColumns: [
|
|
820
|
+
"url_hash",
|
|
821
|
+
"url",
|
|
822
|
+
"headers_json",
|
|
823
|
+
"body_blob",
|
|
824
|
+
"metadata_json",
|
|
825
|
+
"etag"
|
|
826
|
+
]
|
|
827
|
+
},
|
|
828
|
+
{
|
|
829
|
+
table: "performance_reports",
|
|
830
|
+
keys: ["id"],
|
|
831
|
+
timestamp: "created_at",
|
|
832
|
+
sizeColumns: [
|
|
833
|
+
"id",
|
|
834
|
+
"url",
|
|
835
|
+
"strategy",
|
|
836
|
+
"report_json"
|
|
837
|
+
]
|
|
838
|
+
}
|
|
839
|
+
];
|
|
840
|
+
function sizeExpression(columns) {
|
|
841
|
+
return `${columns.map((column) => `COALESCE(LENGTH(CAST(${column} AS BLOB)), 0)`).join(" + ")} + 256`;
|
|
842
|
+
}
|
|
843
|
+
function policySize(database, policy) {
|
|
844
|
+
const row = database.prepare(`SELECT COALESCE(SUM(${sizeExpression(policy.sizeColumns)}), 0) AS size_bytes
|
|
845
|
+
FROM ${policy.table}`).get();
|
|
846
|
+
return Number(row?.size_bytes ?? 0);
|
|
847
|
+
}
|
|
848
|
+
function cacheLogicalSizes(database) {
|
|
849
|
+
return Object.fromEntries(CACHE_POLICIES.map((policy) => [policy.table, policySize(database, policy)]));
|
|
850
|
+
}
|
|
851
|
+
function deleteExpired(database, policy, now) {
|
|
852
|
+
const cutoff = now - CACHE_MAX_AGE_MS;
|
|
853
|
+
return database.prepare(`DELETE FROM ${policy.table}
|
|
854
|
+
WHERE (expires_at IS NOT NULL AND expires_at <= ?)
|
|
855
|
+
OR (${policy.timestamp} IS NOT NULL AND ${policy.timestamp} < ?)`).run(now, cutoff).changes;
|
|
856
|
+
}
|
|
857
|
+
function pruneToLimit(database, policy, limit) {
|
|
858
|
+
if (policySize(database, policy) <= limit) return 0;
|
|
859
|
+
const order = [`${policy.timestamp} DESC`, ...policy.keys.map((key) => `${key} ASC`)].join(", ");
|
|
860
|
+
const rows = database.prepare(`SELECT ${policy.keys.join(", ")},
|
|
861
|
+
${policy.timestamp} AS fetched_at,
|
|
862
|
+
${sizeExpression(policy.sizeColumns)} AS size_bytes
|
|
863
|
+
FROM ${policy.table}
|
|
864
|
+
ORDER BY ${order}`).all();
|
|
865
|
+
const remove = [];
|
|
866
|
+
let retainedBytes = 0;
|
|
867
|
+
for (const row of rows) {
|
|
868
|
+
const rowBytes = Number(row.size_bytes);
|
|
869
|
+
if (retainedBytes + rowBytes <= limit) retainedBytes += rowBytes;
|
|
870
|
+
else remove.push(row);
|
|
871
|
+
}
|
|
872
|
+
if (!remove.length) return 0;
|
|
873
|
+
const statement = database.prepare(`DELETE FROM ${policy.table} WHERE ${policy.keys.map((key) => `${key} = ?`).join(" AND ")}`);
|
|
874
|
+
return database.transaction(() => {
|
|
875
|
+
let removed = 0;
|
|
876
|
+
for (const row of remove) removed += statement.run(...policy.keys.map((key) => row[key])).changes;
|
|
877
|
+
return removed;
|
|
878
|
+
}).immediate();
|
|
879
|
+
}
|
|
880
|
+
function pragmaNumber(database, pragma, field) {
|
|
881
|
+
const result = database.pragma(pragma);
|
|
882
|
+
const row = Array.isArray(result) ? result[0] : result;
|
|
883
|
+
if (!row || typeof row !== "object") return 0;
|
|
884
|
+
return Number(row[field] ?? 0);
|
|
885
|
+
}
|
|
886
|
+
function compactCacheDatabase(database, options = {}) {
|
|
887
|
+
const autoVacuum = pragmaNumber(database, "auto_vacuum", "auto_vacuum");
|
|
888
|
+
try {
|
|
889
|
+
if (autoVacuum === 2) {
|
|
890
|
+
database.pragma("incremental_vacuum");
|
|
891
|
+
database.pragma("wal_checkpoint(PASSIVE)");
|
|
892
|
+
return "incremental";
|
|
893
|
+
}
|
|
894
|
+
if (!options.allowFullVacuum) return "skipped";
|
|
895
|
+
database.pragma("busy_timeout = 100");
|
|
896
|
+
database.pragma("auto_vacuum = INCREMENTAL");
|
|
897
|
+
database.exec("VACUUM");
|
|
898
|
+
database.pragma("wal_checkpoint(PASSIVE)");
|
|
899
|
+
return "full";
|
|
900
|
+
} catch {
|
|
901
|
+
return "skipped";
|
|
902
|
+
} finally {
|
|
903
|
+
database.pragma("busy_timeout = 5000");
|
|
904
|
+
}
|
|
905
|
+
}
|
|
906
|
+
function runCacheMaintenance(database, options = {}) {
|
|
907
|
+
const now = options.now ?? Date.now();
|
|
908
|
+
const limits = {
|
|
909
|
+
...CACHE_LIMITS,
|
|
910
|
+
...options.limits
|
|
911
|
+
};
|
|
912
|
+
const removedByTable = Object.fromEntries(CACHE_POLICIES.map((policy) => [policy.table, 0]));
|
|
913
|
+
database.transaction(() => {
|
|
914
|
+
for (const policy of CACHE_POLICIES) removedByTable[policy.table] += deleteExpired(database, policy, now);
|
|
915
|
+
}).immediate();
|
|
916
|
+
for (const policy of CACHE_POLICIES) removedByTable[policy.table] += pruneToLimit(database, policy, limits[policy.table]);
|
|
917
|
+
const removed = Object.values(removedByTable).reduce((total, count) => total + count, 0);
|
|
918
|
+
const compaction = options.compact ? compactCacheDatabase(database, { allowFullVacuum: options.allowFullVacuum }) : "skipped";
|
|
919
|
+
const sizes = cacheLogicalSizes(database);
|
|
920
|
+
return {
|
|
921
|
+
removed,
|
|
922
|
+
removedByTable,
|
|
923
|
+
logicalSizeBytes: Object.values(sizes).reduce((total, size) => total + size, 0),
|
|
924
|
+
sizes,
|
|
925
|
+
compaction
|
|
926
|
+
};
|
|
927
|
+
}
|
|
928
|
+
//#endregion
|
|
766
929
|
//#region packages/core/dist/storage/sqlite.js
|
|
767
930
|
function withoutDriverMetadata(row) {
|
|
768
931
|
if (!row || typeof row !== "object" || Array.isArray(row)) return row;
|
|
@@ -853,6 +1016,7 @@ CREATE TABLE IF NOT EXISTS semrush_cache (
|
|
|
853
1016
|
expires_at INTEGER,
|
|
854
1017
|
PRIMARY KEY(endpoint, query_hash)
|
|
855
1018
|
) WITHOUT ROWID;
|
|
1019
|
+
CREATE INDEX IF NOT EXISTS idx_semrush_expires ON semrush_cache(expires_at);
|
|
856
1020
|
|
|
857
1021
|
CREATE TABLE IF NOT EXISTS http_cache (
|
|
858
1022
|
url_hash TEXT PRIMARY KEY,
|
|
@@ -865,6 +1029,8 @@ CREATE TABLE IF NOT EXISTS http_cache (
|
|
|
865
1029
|
fetched_at INTEGER,
|
|
866
1030
|
expires_at INTEGER
|
|
867
1031
|
);
|
|
1032
|
+
CREATE INDEX IF NOT EXISTS idx_http_expires ON http_cache(expires_at);
|
|
1033
|
+
CREATE INDEX IF NOT EXISTS idx_http_fetched ON http_cache(fetched_at);
|
|
868
1034
|
|
|
869
1035
|
CREATE TABLE IF NOT EXISTS content_groups (
|
|
870
1036
|
id TEXT PRIMARY KEY,
|
|
@@ -908,6 +1074,7 @@ CREATE TABLE IF NOT EXISTS crawl_reports (
|
|
|
908
1074
|
total_pages INTEGER NOT NULL,
|
|
909
1075
|
issue_count INTEGER NOT NULL,
|
|
910
1076
|
created_at INTEGER NOT NULL,
|
|
1077
|
+
retention_class TEXT NOT NULL DEFAULT 'saved',
|
|
911
1078
|
report_json TEXT NOT NULL
|
|
912
1079
|
);
|
|
913
1080
|
CREATE INDEX IF NOT EXISTS idx_crawl_reports_latest ON crawl_reports(created_at DESC);
|
|
@@ -1051,23 +1218,28 @@ CREATE TABLE IF NOT EXISTS performance_reports (
|
|
|
1051
1218
|
expires_at INTEGER NOT NULL
|
|
1052
1219
|
);
|
|
1053
1220
|
CREATE INDEX IF NOT EXISTS idx_performance_reports_url ON performance_reports(url, strategy, created_at);
|
|
1221
|
+
CREATE INDEX IF NOT EXISTS idx_performance_reports_expires ON performance_reports(expires_at);
|
|
1054
1222
|
`;
|
|
1055
1223
|
let db;
|
|
1224
|
+
let cacheWritesSinceMaintenance = 0;
|
|
1225
|
+
let cacheBytesSinceMaintenance = 0;
|
|
1056
1226
|
function ensureColumn(database, table, column, definition) {
|
|
1057
1227
|
if (database.prepare(`PRAGMA table_info(${table})`).all().some((item) => item.name === column)) return;
|
|
1058
1228
|
database.exec(`ALTER TABLE ${table} ADD COLUMN ${column} ${definition}`);
|
|
1059
1229
|
}
|
|
1060
|
-
function initDb(database) {
|
|
1230
|
+
function initDb(database, isNewDatabase) {
|
|
1061
1231
|
database.pragma("journal_mode = WAL");
|
|
1062
1232
|
database.pragma("synchronous = NORMAL");
|
|
1063
1233
|
database.pragma("foreign_keys = ON");
|
|
1064
1234
|
database.pragma("temp_store = MEMORY");
|
|
1065
1235
|
database.pragma("mmap_size = 268435456");
|
|
1066
1236
|
database.pragma("busy_timeout = 5000");
|
|
1237
|
+
if (isNewDatabase) database.pragma("auto_vacuum = INCREMENTAL");
|
|
1067
1238
|
database.transaction(() => {
|
|
1068
1239
|
database.exec(CREATE_SQL);
|
|
1069
1240
|
ensureColumn(database, "crawl_pages", "snapshot_json", "TEXT");
|
|
1070
1241
|
ensureColumn(database, "http_cache", "metadata_json", "TEXT");
|
|
1242
|
+
ensureColumn(database, "crawl_reports", "retention_class", "TEXT NOT NULL DEFAULT 'saved'");
|
|
1071
1243
|
ensureColumn(database, "index_watch_snapshots", "root_site_url", "TEXT");
|
|
1072
1244
|
ensureColumn(database, "index_watch_snapshots", "property_site_url", "TEXT");
|
|
1073
1245
|
ensureColumn(database, "index_watch_snapshots", "error_code", "TEXT");
|
|
@@ -1082,20 +1254,35 @@ function initDb(database) {
|
|
|
1082
1254
|
ON index_watch_snapshots(root_site_url, url, inspected_at DESC, id DESC);
|
|
1083
1255
|
CREATE INDEX IF NOT EXISTS idx_index_watch_property_url
|
|
1084
1256
|
ON index_watch_snapshots(property_site_url, url, inspected_at DESC, id DESC);
|
|
1257
|
+
CREATE INDEX IF NOT EXISTS idx_crawl_reports_retention
|
|
1258
|
+
ON crawl_reports(retention_class, site_url, created_at DESC, id DESC);
|
|
1085
1259
|
`);
|
|
1086
1260
|
}).immediate();
|
|
1261
|
+
runCacheMaintenance(database, {
|
|
1262
|
+
compact: true,
|
|
1263
|
+
allowFullVacuum: true
|
|
1264
|
+
});
|
|
1087
1265
|
}
|
|
1088
1266
|
function getDb() {
|
|
1089
1267
|
if (db) return db;
|
|
1090
1268
|
const path = getSeoCliPaths().cacheDbFile;
|
|
1269
|
+
const isNewDatabase = !existsSync(path);
|
|
1091
1270
|
mkdirSync(dirname(path), {
|
|
1092
1271
|
recursive: true,
|
|
1093
1272
|
mode: 448
|
|
1094
1273
|
});
|
|
1095
1274
|
db = new SqliteDatabase(path);
|
|
1096
|
-
initDb(db);
|
|
1275
|
+
initDb(db, isNewDatabase);
|
|
1097
1276
|
return db;
|
|
1098
1277
|
}
|
|
1278
|
+
function noteCacheWrite(sizeBytes = 0) {
|
|
1279
|
+
cacheWritesSinceMaintenance += 1;
|
|
1280
|
+
cacheBytesSinceMaintenance += Math.max(0, sizeBytes);
|
|
1281
|
+
if (cacheWritesSinceMaintenance < 50 && cacheBytesSinceMaintenance < 16777216) return;
|
|
1282
|
+
runCacheMaintenance(getDb());
|
|
1283
|
+
cacheWritesSinceMaintenance = 0;
|
|
1284
|
+
cacheBytesSinceMaintenance = 0;
|
|
1285
|
+
}
|
|
1099
1286
|
function checkDatabaseReadiness() {
|
|
1100
1287
|
if (getDb().prepare("SELECT 1 AS ready").get()?.ready !== 1) throw new Error("The local SQLite database did not return a readiness row.");
|
|
1101
1288
|
return { dbPath: getSeoCliPaths().cacheDbFile };
|
|
@@ -1138,9 +1325,14 @@ async function runGa4Report(propertyId, body, opts = {}) {
|
|
|
1138
1325
|
throw new Error(`Google Analytics report failed with ${response.status}.`);
|
|
1139
1326
|
}
|
|
1140
1327
|
const result = await response.json();
|
|
1141
|
-
if (cacheable)
|
|
1328
|
+
if (cacheable) {
|
|
1329
|
+
const requestJson = JSON.stringify(body);
|
|
1330
|
+
const responseJson = JSON.stringify(result);
|
|
1331
|
+
db.prepare(`INSERT OR REPLACE INTO ga4_cache
|
|
1142
1332
|
(property_id, query_hash, request_json, response_json, row_count, fetched_at, expires_at)
|
|
1143
|
-
VALUES (?, ?, ?, ?, ?, ?, ?)`).run(propertyId, queryHash,
|
|
1333
|
+
VALUES (?, ?, ?, ?, ?, ?, ?)`).run(propertyId, queryHash, requestJson, responseJson, result.rowCount ?? result.rows?.length ?? 0, Date.now(), Date.now() + 864e5);
|
|
1334
|
+
noteCacheWrite(Buffer.byteLength(requestJson) + Buffer.byteLength(responseJson));
|
|
1335
|
+
}
|
|
1144
1336
|
return result;
|
|
1145
1337
|
}
|
|
1146
1338
|
function ga4RowsToObjects(result) {
|
|
@@ -1798,7 +1990,7 @@ function effectiveSnippetControl(input) {
|
|
|
1798
1990
|
}
|
|
1799
1991
|
//#endregion
|
|
1800
1992
|
//#region packages/core/dist/extract/canonical.js
|
|
1801
|
-
function headerValue$
|
|
1993
|
+
function headerValue$5(headers, name) {
|
|
1802
1994
|
const lower = name.toLowerCase();
|
|
1803
1995
|
return Object.entries(headers).find(([key]) => key.toLowerCase() === lower)?.[1];
|
|
1804
1996
|
}
|
|
@@ -1882,7 +2074,7 @@ function parameter(value, name) {
|
|
|
1882
2074
|
return match?.[1] ?? match?.[2] ?? match?.[3];
|
|
1883
2075
|
}
|
|
1884
2076
|
function headerCandidates(headers, baseUrl) {
|
|
1885
|
-
const link = headerValue$
|
|
2077
|
+
const link = headerValue$5(headers, "link");
|
|
1886
2078
|
if (!link) return [];
|
|
1887
2079
|
const candidates = [];
|
|
1888
2080
|
for (const item of splitLinkHeader(link)) {
|
|
@@ -1899,14 +2091,14 @@ function headerCandidates(headers, baseUrl) {
|
|
|
1899
2091
|
}
|
|
1900
2092
|
return candidates;
|
|
1901
2093
|
}
|
|
1902
|
-
function normalizedUrl$
|
|
2094
|
+
function normalizedUrl$1(value) {
|
|
1903
2095
|
const url = new URL(value);
|
|
1904
2096
|
url.hash = "";
|
|
1905
2097
|
return url.toString();
|
|
1906
2098
|
}
|
|
1907
2099
|
function evidenceFromCandidates(candidates) {
|
|
1908
2100
|
const eligible = candidates.filter((candidate) => candidate.resolved && !candidate.ignoredReason);
|
|
1909
|
-
const distinct = [...new Set(eligible.map((candidate) => normalizedUrl$
|
|
2101
|
+
const distinct = [...new Set(eligible.map((candidate) => normalizedUrl$1(candidate.resolved ?? "")))];
|
|
1910
2102
|
if (distinct.length > 1) return {
|
|
1911
2103
|
status: "conflicting",
|
|
1912
2104
|
candidates
|
|
@@ -2551,7 +2743,7 @@ function safeText$1(value) {
|
|
|
2551
2743
|
const trimmed = value?.replace(/\s+/g, " ").trim();
|
|
2552
2744
|
return trimmed ? trimmed : void 0;
|
|
2553
2745
|
}
|
|
2554
|
-
function headerValue$
|
|
2746
|
+
function headerValue$4(headers, name) {
|
|
2555
2747
|
const target = name.toLowerCase();
|
|
2556
2748
|
return Object.entries(headers).find(([key]) => key.toLowerCase() === target)?.[1];
|
|
2557
2749
|
}
|
|
@@ -2569,8 +2761,7 @@ function httpUrl(value, base) {
|
|
|
2569
2761
|
const protocol = new URL(resolved).protocol;
|
|
2570
2762
|
return protocol === "http:" || protocol === "https:" ? resolved : void 0;
|
|
2571
2763
|
}
|
|
2572
|
-
function sanitizedContentHtml(html, base) {
|
|
2573
|
-
const $ = load(html);
|
|
2764
|
+
function sanitizedContentHtml($, html, base) {
|
|
2574
2765
|
let changed = false;
|
|
2575
2766
|
for (const element of $("[href], [src]").toArray()) for (const attribute of ["href", "src"]) {
|
|
2576
2767
|
const value = $(element).attr(attribute);
|
|
@@ -2581,6 +2772,22 @@ function sanitizedContentHtml(html, base) {
|
|
|
2581
2772
|
}
|
|
2582
2773
|
return changed ? $.html() : html;
|
|
2583
2774
|
}
|
|
2775
|
+
function crawlerMainContent($, baseUrl) {
|
|
2776
|
+
const text = ($("main").first().length ? $("main").first() : $("article").first().length ? $("article").first() : $("body").first()).text().replace(/\s+/g, " ").trim();
|
|
2777
|
+
return {
|
|
2778
|
+
text,
|
|
2779
|
+
excerpt: safeText$1($("meta[name=\"description\"]").attr("content")),
|
|
2780
|
+
wordCount: countCjkAwareWords(text),
|
|
2781
|
+
diagnostics: {
|
|
2782
|
+
requested: "crawler",
|
|
2783
|
+
used: "crawler",
|
|
2784
|
+
fallback: false,
|
|
2785
|
+
wordCountSource: "local_cjk_aware",
|
|
2786
|
+
baseUrl
|
|
2787
|
+
},
|
|
2788
|
+
warnings: []
|
|
2789
|
+
};
|
|
2790
|
+
}
|
|
2584
2791
|
function numericAttribute(value) {
|
|
2585
2792
|
if (!value) return void 0;
|
|
2586
2793
|
const match = value.trim().match(/^\d+/);
|
|
@@ -2605,12 +2812,10 @@ function imageDimensionsFromUrl(src) {
|
|
|
2605
2812
|
};
|
|
2606
2813
|
}
|
|
2607
2814
|
async function extractPage(fetchResult, extractor = "defuddle", dependencies = {}) {
|
|
2608
|
-
const $ = load(fetchResult.html
|
|
2609
|
-
|
|
2610
|
-
|
|
2611
|
-
|
|
2612
|
-
}, extractor, dependencies);
|
|
2613
|
-
const { text, excerpt } = content;
|
|
2815
|
+
const $ = extractor === "crawler" && /<html(?:\s|>)/i.test(fetchResult.html) ? load(fetchResult.html, { xml: {
|
|
2816
|
+
xmlMode: false,
|
|
2817
|
+
decodeEntities: true
|
|
2818
|
+
} }) : load(fetchResult.html);
|
|
2614
2819
|
const url = new URL(fetchResult.finalUrl);
|
|
2615
2820
|
const canonicalEvidence = extractCanonicalEvidence($, fetchResult.headers, fetchResult.finalUrl);
|
|
2616
2821
|
const headings = $("h1, h2, h3, h4, h5, h6").toArray().map((element) => ({
|
|
@@ -2692,13 +2897,18 @@ async function extractPage(fetchResult, extractor = "defuddle", dependencies = {
|
|
|
2692
2897
|
const name = $(element).attr("name")?.trim().toLowerCase();
|
|
2693
2898
|
return name === "robots" || name === "googlebot";
|
|
2694
2899
|
}).map((element) => $(element).attr("content")));
|
|
2900
|
+
const content = extractor === "crawler" ? crawlerMainContent($, fetchResult.finalUrl) : extractMainContent({
|
|
2901
|
+
...fetchResult,
|
|
2902
|
+
html: sanitizedContentHtml($, fetchResult.html, fetchResult.finalUrl)
|
|
2903
|
+
}, extractor, dependencies);
|
|
2904
|
+
const { text, excerpt } = content;
|
|
2695
2905
|
return {
|
|
2696
2906
|
url: fetchResult.url,
|
|
2697
2907
|
finalUrl: fetchResult.finalUrl,
|
|
2698
2908
|
title: safeText$1($("title").first().text()),
|
|
2699
2909
|
metaDescription: safeText$1($("meta[name=\"description\"]").attr("content")),
|
|
2700
2910
|
metaRobots,
|
|
2701
|
-
xRobotsTag: safeText$1(headerValue$
|
|
2911
|
+
xRobotsTag: safeText$1(headerValue$4(fetchResult.headers, "x-robots-tag")),
|
|
2702
2912
|
canonical: canonicalEvidence.selectedRaw,
|
|
2703
2913
|
canonicalEvidence,
|
|
2704
2914
|
lang: safeText$1($("html").attr("lang")),
|
|
@@ -2749,14 +2959,106 @@ async function extractPage(fetchResult, extractor = "defuddle", dependencies = {
|
|
|
2749
2959
|
};
|
|
2750
2960
|
}
|
|
2751
2961
|
//#endregion
|
|
2962
|
+
//#region packages/core/dist/version.js
|
|
2963
|
+
const SEO_VERSION = version;
|
|
2964
|
+
//#endregion
|
|
2965
|
+
//#region packages/core/dist/fetch/crawler-identity.js
|
|
2966
|
+
const SEO_CRAWLER_TOKEN = "SEO-Skill";
|
|
2967
|
+
const SEO_CRAWLER_USER_AGENT = `${SEO_CRAWLER_TOKEN}/${SEO_VERSION} (+https://seoskill.dev)`;
|
|
2968
|
+
const SEO_CRAWLER_IDENTITY = {
|
|
2969
|
+
name: "SEO Skill",
|
|
2970
|
+
robotsToken: SEO_CRAWLER_TOKEN,
|
|
2971
|
+
version: SEO_VERSION,
|
|
2972
|
+
userAgent: SEO_CRAWLER_USER_AGENT,
|
|
2973
|
+
documentationUrl: "https://seoskill.dev/docs/crawler/"
|
|
2974
|
+
};
|
|
2975
|
+
//#endregion
|
|
2976
|
+
//#region packages/core/dist/fetch/access-block.js
|
|
2977
|
+
const CLOUDFLARE_SKIP_DOCS = "https://developers.cloudflare.com/waf/custom-rules/skip/";
|
|
2978
|
+
function isHeadersLike(headers) {
|
|
2979
|
+
return "get" in headers && typeof headers.get === "function";
|
|
2980
|
+
}
|
|
2981
|
+
function headerValue$3(headers, name) {
|
|
2982
|
+
if (isHeadersLike(headers)) return headers.get(name) ?? void 0;
|
|
2983
|
+
const lower = name.toLowerCase();
|
|
2984
|
+
return headers[name] ?? headers[lower] ?? Object.entries(headers).find(([key]) => key.toLowerCase() === lower)?.[1];
|
|
2985
|
+
}
|
|
2986
|
+
function cloudflareEvidence(headers) {
|
|
2987
|
+
const mitigated = headerValue$3(headers, "cf-mitigated");
|
|
2988
|
+
const rayId = headerValue$3(headers, "cf-ray");
|
|
2989
|
+
const server = headerValue$3(headers, "server");
|
|
2990
|
+
const challenge = mitigated?.toLowerCase() === "challenge";
|
|
2991
|
+
const indicators = [
|
|
2992
|
+
...challenge ? ["cf-mitigated: challenge"] : [],
|
|
2993
|
+
...rayId ? [`cf-ray: ${rayId}`] : [],
|
|
2994
|
+
...server?.toLowerCase().includes("cloudflare") ? [`server: ${server}`] : []
|
|
2995
|
+
];
|
|
2996
|
+
return {
|
|
2997
|
+
detected: challenge || Boolean(rayId) || Boolean(indicators.length),
|
|
2998
|
+
challenge,
|
|
2999
|
+
rayId,
|
|
3000
|
+
indicators
|
|
3001
|
+
};
|
|
3002
|
+
}
|
|
3003
|
+
function blockKind(status, cloudflareChallenge) {
|
|
3004
|
+
if (cloudflareChallenge) return "challenge";
|
|
3005
|
+
if (status === 429) return "rate-limit";
|
|
3006
|
+
if (status === 401) return "authentication";
|
|
3007
|
+
return "firewall";
|
|
3008
|
+
}
|
|
3009
|
+
function detectAccessBlock(input) {
|
|
3010
|
+
const cloudflare = cloudflareEvidence(input.headers);
|
|
3011
|
+
if (![
|
|
3012
|
+
401,
|
|
3013
|
+
403,
|
|
3014
|
+
429
|
|
3015
|
+
].includes(input.status) && !cloudflare.challenge) return void 0;
|
|
3016
|
+
const provider = cloudflare.detected ? "cloudflare" : "unknown";
|
|
3017
|
+
const kind = blockKind(input.status, cloudflare.challenge);
|
|
3018
|
+
const indicators = [`HTTP ${input.status}`, ...cloudflare.indicators];
|
|
3019
|
+
if (provider === "cloudflare") return {
|
|
3020
|
+
provider,
|
|
3021
|
+
kind,
|
|
3022
|
+
status: input.status,
|
|
3023
|
+
crawler: SEO_CRAWLER_IDENTITY,
|
|
3024
|
+
indicators,
|
|
3025
|
+
...cloudflare.rayId ? { requestId: cloudflare.rayId } : {},
|
|
3026
|
+
guidance: {
|
|
3027
|
+
summary: kind === "challenge" ? "Cloudflare returned a Challenge Page to the crawler." : "The crawler received a denied or rate-limited response through Cloudflare.",
|
|
3028
|
+
recommendedAction: "Look for this request in Cloudflare Security Events. If Cloudflare blocked it, create the narrowest custom Skip rule that matches the audit machine source IP, hostname or required paths, and this exact User-Agent. Skip only the product or rule that caused the block. If there is no matching event, inspect the origin security logs.",
|
|
3029
|
+
securityNote: "A User-Agent can be spoofed. Do not trust it by itself or create a broad site-wide bypass. Remove temporary access after the audit. Bot Fight Mode cannot be skipped by a custom rule.",
|
|
3030
|
+
documentationUrl: CLOUDFLARE_SKIP_DOCS
|
|
3031
|
+
}
|
|
3032
|
+
};
|
|
3033
|
+
return {
|
|
3034
|
+
provider,
|
|
3035
|
+
kind,
|
|
3036
|
+
status: input.status,
|
|
3037
|
+
crawler: SEO_CRAWLER_IDENTITY,
|
|
3038
|
+
indicators,
|
|
3039
|
+
guidance: {
|
|
3040
|
+
summary: kind === "rate-limit" ? "The site rate-limited the crawler request." : "The site denied the crawler request.",
|
|
3041
|
+
recommendedAction: "Inspect the site, CDN, and origin security logs for this request. If the audit should have access, allow the audit machine source IP and this exact User-Agent only on the public paths being checked, or lower the crawl rate.",
|
|
3042
|
+
securityNote: "A User-Agent can be spoofed. Do not trust it by itself or create a broad site-wide bypass. Remove temporary access after the audit."
|
|
3043
|
+
}
|
|
3044
|
+
};
|
|
3045
|
+
}
|
|
3046
|
+
//#endregion
|
|
2752
3047
|
//#region packages/core/dist/fetch/http-client.js
|
|
2753
|
-
|
|
3048
|
+
var ResponseSizeLimitError = class extends Error {
|
|
3049
|
+
maxBytes;
|
|
3050
|
+
constructor(maxBytes, label = "Response") {
|
|
3051
|
+
super(`${label} exceeds the ${maxBytes}-byte response limit.`);
|
|
3052
|
+
this.maxBytes = maxBytes;
|
|
3053
|
+
this.name = "ResponseSizeLimitError";
|
|
3054
|
+
}
|
|
3055
|
+
};
|
|
2754
3056
|
function requestHeaders(profile) {
|
|
2755
3057
|
if (profile === "bot") return new Headers({
|
|
2756
3058
|
accept: "text/plain,*/*;q=0.8",
|
|
2757
3059
|
"accept-language": "en-US,en;q=0.9",
|
|
2758
3060
|
connection: "close",
|
|
2759
|
-
"user-agent":
|
|
3061
|
+
"user-agent": SEO_CRAWLER_USER_AGENT
|
|
2760
3062
|
});
|
|
2761
3063
|
return new Headers({
|
|
2762
3064
|
accept: "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
|
|
@@ -2765,7 +3067,7 @@ function requestHeaders(profile) {
|
|
|
2765
3067
|
"sec-ch-ua-mobile": "?0",
|
|
2766
3068
|
"sec-ch-ua-platform": "\"macOS\"",
|
|
2767
3069
|
"upgrade-insecure-requests": "1",
|
|
2768
|
-
"user-agent":
|
|
3070
|
+
"user-agent": SEO_CRAWLER_USER_AGENT
|
|
2769
3071
|
});
|
|
2770
3072
|
}
|
|
2771
3073
|
function publicHttpFetch(url, input = {}) {
|
|
@@ -2781,6 +3083,32 @@ function publicHttpFetch(url, input = {}) {
|
|
|
2781
3083
|
headers: base
|
|
2782
3084
|
});
|
|
2783
3085
|
}
|
|
3086
|
+
async function readBoundedResponseText(response, maxBytes, label = "Response") {
|
|
3087
|
+
const declaredLength = Number(response.headers.get("content-length"));
|
|
3088
|
+
if (Number.isFinite(declaredLength) && declaredLength > maxBytes) {
|
|
3089
|
+
await response.body?.cancel().catch(() => void 0);
|
|
3090
|
+
throw new ResponseSizeLimitError(maxBytes, label);
|
|
3091
|
+
}
|
|
3092
|
+
const reader = response.body?.getReader();
|
|
3093
|
+
if (!reader) return "";
|
|
3094
|
+
const chunks = [];
|
|
3095
|
+
let total = 0;
|
|
3096
|
+
try {
|
|
3097
|
+
while (true) {
|
|
3098
|
+
const { done, value } = await reader.read();
|
|
3099
|
+
if (done) break;
|
|
3100
|
+
total += value.byteLength;
|
|
3101
|
+
if (total > maxBytes) {
|
|
3102
|
+
await reader.cancel().catch(() => void 0);
|
|
3103
|
+
throw new ResponseSizeLimitError(maxBytes, label);
|
|
3104
|
+
}
|
|
3105
|
+
chunks.push(value);
|
|
3106
|
+
}
|
|
3107
|
+
} finally {
|
|
3108
|
+
reader.releaseLock();
|
|
3109
|
+
}
|
|
3110
|
+
return Buffer.concat(chunks, total).toString("utf8");
|
|
3111
|
+
}
|
|
2784
3112
|
//#endregion
|
|
2785
3113
|
//#region packages/core/dist/fetch/page-fetcher/rate-controls.js
|
|
2786
3114
|
const HOST_QUEUES = /* @__PURE__ */ new Map();
|
|
@@ -2837,8 +3165,10 @@ function snapshotForHost(host, status) {
|
|
|
2837
3165
|
};
|
|
2838
3166
|
}
|
|
2839
3167
|
function normalizeRateControls(rate) {
|
|
3168
|
+
const concurrency = rate?.concurrency ?? numberEnv("SEO_FETCH_CONCURRENCY", 4);
|
|
3169
|
+
if (!Number.isInteger(concurrency) || concurrency < 1 || concurrency > 16) throw new RangeError(`Fetch concurrency must be an integer from 1 to 16.`);
|
|
2840
3170
|
return {
|
|
2841
|
-
concurrency
|
|
3171
|
+
concurrency,
|
|
2842
3172
|
intervalCap: rate?.intervalCap ?? numberEnv("SEO_FETCH_INTERVAL_CAP", 4),
|
|
2843
3173
|
intervalMs: rate?.intervalMs ?? numberEnv("SEO_FETCH_INTERVAL_MS", 1e3),
|
|
2844
3174
|
backpressure: {
|
|
@@ -2926,7 +3256,34 @@ function recordHostFetch(input) {
|
|
|
2926
3256
|
return snapshotForHost(input.host);
|
|
2927
3257
|
}
|
|
2928
3258
|
//#endregion
|
|
3259
|
+
//#region packages/core/dist/fetch/page-fetcher/redirects.js
|
|
3260
|
+
async function fetchWithRedirectChain(url, signal) {
|
|
3261
|
+
const redirectChain = [];
|
|
3262
|
+
let currentUrl = url;
|
|
3263
|
+
for (let redirectCount = 0; redirectCount <= 10; redirectCount += 1) {
|
|
3264
|
+
const response = await publicHttpFetch(currentUrl, {
|
|
3265
|
+
redirect: "manual",
|
|
3266
|
+
signal
|
|
3267
|
+
});
|
|
3268
|
+
const location = response.headers.get("location");
|
|
3269
|
+
if (response.status < 300 || response.status >= 400 || !location) return {
|
|
3270
|
+
response,
|
|
3271
|
+
redirectChain
|
|
3272
|
+
};
|
|
3273
|
+
const nextUrl = new URL(location, currentUrl).toString();
|
|
3274
|
+
redirectChain.push({
|
|
3275
|
+
url: currentUrl,
|
|
3276
|
+
status: response.status,
|
|
3277
|
+
location: nextUrl
|
|
3278
|
+
});
|
|
3279
|
+
await response.body?.cancel().catch(() => void 0);
|
|
3280
|
+
currentUrl = nextUrl;
|
|
3281
|
+
}
|
|
3282
|
+
throw new Error(`Too many redirects for ${url}`);
|
|
3283
|
+
}
|
|
3284
|
+
//#endregion
|
|
2929
3285
|
//#region packages/core/dist/fetch/page-fetcher/robots.js
|
|
3286
|
+
const MAX_ROBOTS_RESPONSE_BYTES = 1024 * 1024;
|
|
2930
3287
|
var RobotsAccessError = class extends Error {
|
|
2931
3288
|
reason;
|
|
2932
3289
|
evidence;
|
|
@@ -2946,10 +3303,10 @@ function cacheableStatus(status) {
|
|
|
2946
3303
|
function resultFromResponse(input) {
|
|
2947
3304
|
if (input.status >= 200 && input.status < 300) {
|
|
2948
3305
|
const parsed = parseRobots$1(input.robotsUrl, input.text);
|
|
2949
|
-
const lineNumber = parsed.getMatchingLineNumber?.(input.targetUrl,
|
|
3306
|
+
const lineNumber = parsed.getMatchingLineNumber?.(input.targetUrl, SEO_CRAWLER_USER_AGENT);
|
|
2950
3307
|
const matchedLine = lineNumber ? input.text.split(/\r?\n/)[lineNumber - 1]?.trim() : void 0;
|
|
2951
3308
|
return {
|
|
2952
|
-
allowed: parsed.isAllowed(input.targetUrl,
|
|
3309
|
+
allowed: parsed.isAllowed(input.targetUrl, SEO_CRAWLER_USER_AGENT) ?? true,
|
|
2953
3310
|
availability: "available",
|
|
2954
3311
|
status: input.status,
|
|
2955
3312
|
cache: input.cache,
|
|
@@ -2973,44 +3330,105 @@ function resultFromResponse(input) {
|
|
|
2973
3330
|
url: input.robotsUrl
|
|
2974
3331
|
};
|
|
2975
3332
|
}
|
|
2976
|
-
|
|
2977
|
-
|
|
2978
|
-
|
|
2979
|
-
const key = hashKey(["robots", robotsUrl]);
|
|
2980
|
-
const cached = db.prepare("SELECT status, body_blob, expires_at FROM http_cache WHERE url_hash = ?").get(key);
|
|
2981
|
-
if (!refresh && cached?.body_blob && cached.status !== void 0 && cacheableStatus(cached.status) && cached.expires_at && cached.expires_at > Date.now()) return resultFromResponse({
|
|
2982
|
-
robotsUrl,
|
|
3333
|
+
function resultFromSource(source, targetUrl) {
|
|
3334
|
+
if (source.status !== void 0 && source.text !== void 0) return resultFromResponse({
|
|
3335
|
+
robotsUrl: source.robotsUrl,
|
|
2983
3336
|
targetUrl,
|
|
2984
|
-
status:
|
|
2985
|
-
text:
|
|
2986
|
-
cache:
|
|
3337
|
+
status: source.status,
|
|
3338
|
+
text: source.text,
|
|
3339
|
+
cache: source.cache
|
|
2987
3340
|
});
|
|
3341
|
+
return {
|
|
3342
|
+
allowed: null,
|
|
3343
|
+
availability: "unreachable",
|
|
3344
|
+
error: source.error ?? "robots.txt could not be fetched.",
|
|
3345
|
+
cache: source.cache,
|
|
3346
|
+
url: source.robotsUrl
|
|
3347
|
+
};
|
|
3348
|
+
}
|
|
3349
|
+
async function fetchRobotsSource(origin, refresh, options) {
|
|
3350
|
+
const robotsUrl = new URL("/robots.txt", origin).toString();
|
|
3351
|
+
const key = hashKey(["robots", robotsUrl]);
|
|
3352
|
+
if (!refresh) {
|
|
3353
|
+
const cached = getDb().prepare("SELECT status, body_blob, expires_at FROM http_cache WHERE url_hash = ?").get(key);
|
|
3354
|
+
if (cached?.body_blob && cached.status !== void 0 && cacheableStatus(cached.status) && cached.expires_at && cached.expires_at > Date.now()) return {
|
|
3355
|
+
robotsUrl,
|
|
3356
|
+
status: cached.status,
|
|
3357
|
+
text: cached.body_blob.toString("utf8"),
|
|
3358
|
+
cache: "hit"
|
|
3359
|
+
};
|
|
3360
|
+
}
|
|
3361
|
+
const controller = new AbortController();
|
|
3362
|
+
const abort = () => controller.abort();
|
|
3363
|
+
const timer = setTimeout(abort, options.timeoutMs ?? 2e4);
|
|
3364
|
+
if (options.signal?.aborted) controller.abort();
|
|
3365
|
+
else options.signal?.addEventListener("abort", abort, { once: true });
|
|
2988
3366
|
try {
|
|
2989
|
-
const response = await publicHttpFetch(robotsUrl, {
|
|
2990
|
-
|
|
2991
|
-
|
|
3367
|
+
const response = await publicHttpFetch(robotsUrl, {
|
|
3368
|
+
profile: "bot",
|
|
3369
|
+
signal: controller.signal
|
|
3370
|
+
});
|
|
3371
|
+
const text = await readBoundedResponseText(response, MAX_ROBOTS_RESPONSE_BYTES, "robots.txt response");
|
|
3372
|
+
if (options.writeCache !== false && cacheableStatus(response.status)) {
|
|
3373
|
+
getDb().prepare(`INSERT OR REPLACE INTO http_cache
|
|
3374
|
+
(url_hash, url, status, headers_json, body_blob, etag, fetched_at, expires_at)
|
|
3375
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`).run(key, robotsUrl, response.status, "{}", Buffer.from(text), null, Date.now(), Date.now() + 864e5);
|
|
3376
|
+
noteCacheWrite(Buffer.byteLength(text));
|
|
3377
|
+
}
|
|
3378
|
+
return {
|
|
2992
3379
|
robotsUrl,
|
|
2993
|
-
targetUrl,
|
|
2994
3380
|
status: response.status,
|
|
2995
3381
|
text,
|
|
2996
3382
|
cache: refresh ? "bypass" : "miss"
|
|
2997
|
-
}
|
|
2998
|
-
if (cacheableStatus(response.status)) db.prepare(`INSERT OR REPLACE INTO http_cache
|
|
2999
|
-
(url_hash, url, status, headers_json, body_blob, etag, fetched_at, expires_at)
|
|
3000
|
-
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`).run(key, robotsUrl, response.status, "{}", Buffer.from(text), null, Date.now(), Date.now() + 864e5);
|
|
3001
|
-
return result;
|
|
3383
|
+
};
|
|
3002
3384
|
} catch (error) {
|
|
3003
3385
|
return {
|
|
3004
|
-
|
|
3005
|
-
availability: "unreachable",
|
|
3386
|
+
robotsUrl,
|
|
3006
3387
|
error: error instanceof Error ? error.message : String(error),
|
|
3007
|
-
cache: refresh ? "bypass" : "miss"
|
|
3008
|
-
url: robotsUrl
|
|
3388
|
+
cache: refresh ? "bypass" : "miss"
|
|
3009
3389
|
};
|
|
3010
|
-
}
|
|
3390
|
+
} finally {
|
|
3391
|
+
clearTimeout(timer);
|
|
3392
|
+
options.signal?.removeEventListener("abort", abort);
|
|
3393
|
+
}
|
|
3394
|
+
}
|
|
3395
|
+
async function fetchRobots(origin, targetUrl = origin, refresh = false, options = {}) {
|
|
3396
|
+
return resultFromSource(await fetchRobotsSource(origin, refresh, options), targetUrl);
|
|
3397
|
+
}
|
|
3398
|
+
function createRobotsSession(input = {}) {
|
|
3399
|
+
const sources = /* @__PURE__ */ new Map();
|
|
3400
|
+
const sourceFor = (origin) => {
|
|
3401
|
+
const normalizedOrigin = new URL(origin).origin;
|
|
3402
|
+
let source = sources.get(normalizedOrigin);
|
|
3403
|
+
if (!source) {
|
|
3404
|
+
source = fetchRobotsSource(normalizedOrigin, input.refresh ?? false, {
|
|
3405
|
+
writeCache: input.writeCache,
|
|
3406
|
+
timeoutMs: input.timeoutMs,
|
|
3407
|
+
signal: input.signal
|
|
3408
|
+
});
|
|
3409
|
+
sources.set(normalizedOrigin, source);
|
|
3410
|
+
}
|
|
3411
|
+
return source;
|
|
3412
|
+
};
|
|
3413
|
+
return {
|
|
3414
|
+
resolve: async (origin, targetUrl) => resultFromSource(await sourceFor(origin), targetUrl),
|
|
3415
|
+
sitemapUrls: async (origin) => {
|
|
3416
|
+
const source = await sourceFor(origin);
|
|
3417
|
+
if (source.status === void 0 || source.status >= 300 || !source.text) return [];
|
|
3418
|
+
const urls = /* @__PURE__ */ new Set();
|
|
3419
|
+
for (const match of source.text.matchAll(/^\s*sitemap\s*:\s*(\S+)\s*$/gim)) {
|
|
3420
|
+
const value = match[1]?.trim();
|
|
3421
|
+
if (!value) continue;
|
|
3422
|
+
try {
|
|
3423
|
+
urls.add(new URL(value).toString());
|
|
3424
|
+
} catch {}
|
|
3425
|
+
}
|
|
3426
|
+
return [...urls].sort();
|
|
3427
|
+
}
|
|
3428
|
+
};
|
|
3011
3429
|
}
|
|
3012
3430
|
//#endregion
|
|
3013
|
-
//#region packages/core/dist/fetch/page-fetcher/
|
|
3431
|
+
//#region packages/core/dist/fetch/page-fetcher/robots-evidence.js
|
|
3014
3432
|
function pageRobotsEvidence(robots) {
|
|
3015
3433
|
return {
|
|
3016
3434
|
url: robots.url,
|
|
@@ -3031,10 +3449,14 @@ function diagnosticRobotsEvidence(robots) {
|
|
|
3031
3449
|
error: robots.error
|
|
3032
3450
|
};
|
|
3033
3451
|
}
|
|
3452
|
+
//#endregion
|
|
3453
|
+
//#region packages/core/dist/fetch/page-fetcher/plain.js
|
|
3454
|
+
const MAX_PAGE_RESPONSE_BYTES = 5 * 1024 * 1024;
|
|
3034
3455
|
function encodePageFetchCacheEvidence(result) {
|
|
3035
3456
|
return JSON.stringify({
|
|
3036
3457
|
finalUrl: result.finalUrl,
|
|
3037
3458
|
blocked: result.diagnostics.blocked,
|
|
3459
|
+
accessBlock: result.diagnostics.accessBlock,
|
|
3038
3460
|
robotsTxt: result.robotsTxt,
|
|
3039
3461
|
diagnosticsRobotsTxt: result.diagnostics.robotsTxt,
|
|
3040
3462
|
redirectChain: result.diagnostics.redirectChain,
|
|
@@ -3051,36 +3473,15 @@ function decodePageFetchCacheEvidence(value) {
|
|
|
3051
3473
|
return;
|
|
3052
3474
|
}
|
|
3053
3475
|
}
|
|
3054
|
-
async function fetchWithRedirectChain(url, signal) {
|
|
3055
|
-
const redirectChain = [];
|
|
3056
|
-
let currentUrl = url;
|
|
3057
|
-
for (let redirectCount = 0; redirectCount <= 10; redirectCount += 1) {
|
|
3058
|
-
const response = await publicHttpFetch(currentUrl, {
|
|
3059
|
-
redirect: "manual",
|
|
3060
|
-
signal
|
|
3061
|
-
});
|
|
3062
|
-
const location = response.headers.get("location");
|
|
3063
|
-
if (response.status < 300 || response.status >= 400 || !location) return {
|
|
3064
|
-
response,
|
|
3065
|
-
redirectChain
|
|
3066
|
-
};
|
|
3067
|
-
const nextUrl = new URL(location, currentUrl).toString();
|
|
3068
|
-
redirectChain.push({
|
|
3069
|
-
url: currentUrl,
|
|
3070
|
-
status: response.status,
|
|
3071
|
-
location: nextUrl
|
|
3072
|
-
});
|
|
3073
|
-
await response.body?.cancel().catch(() => void 0);
|
|
3074
|
-
currentUrl = nextUrl;
|
|
3075
|
-
}
|
|
3076
|
-
throw new Error(`Too many redirects for ${url}`);
|
|
3077
|
-
}
|
|
3078
3476
|
async function fetchPlain(url, refresh = false, timeoutMs = 2e4, rate, signal, respectRobots = false) {
|
|
3079
3477
|
const startedAt = Date.now();
|
|
3080
3478
|
const db = getDb();
|
|
3081
3479
|
const key = hashKey(["page", url]);
|
|
3082
3480
|
const host = new URL(url).host;
|
|
3083
|
-
let robots = respectRobots ? await fetchRobots(new URL(url).origin, url, refresh
|
|
3481
|
+
let robots = respectRobots ? await fetchRobots(new URL(url).origin, url, refresh, {
|
|
3482
|
+
timeoutMs,
|
|
3483
|
+
signal
|
|
3484
|
+
}) : void 0;
|
|
3084
3485
|
if (robots && robots.allowed !== true) throw new RobotsAccessError(robots.allowed === false ? "robots-disallowed" : "robots-deferred", robots);
|
|
3085
3486
|
const cached = db.prepare("SELECT status, headers_json, body_blob, metadata_json, expires_at FROM http_cache WHERE url_hash = ? AND expires_at > ?").get(key, Date.now());
|
|
3086
3487
|
if (!refresh && cached) {
|
|
@@ -3106,6 +3507,7 @@ async function fetchPlain(url, refresh = false, timeoutMs = 2e4, rate, signal, r
|
|
|
3106
3507
|
retries: 0,
|
|
3107
3508
|
rateLimit: { ...rateLimitDiagnostics(host, rate) },
|
|
3108
3509
|
backpressure: hostBackpressureSnapshot(host),
|
|
3510
|
+
accessBlock: evidence?.accessBlock,
|
|
3109
3511
|
robotsTxt: robots ? diagnosticRobotsEvidence(robots) : evidence?.diagnosticsRobotsTxt ? {
|
|
3110
3512
|
...evidence.diagnosticsRobotsTxt,
|
|
3111
3513
|
cache: "hit"
|
|
@@ -3116,7 +3518,10 @@ async function fetchPlain(url, refresh = false, timeoutMs = 2e4, rate, signal, r
|
|
|
3116
3518
|
robotsTxt: robots ? pageRobotsEvidence(robots) : evidence?.robotsTxt
|
|
3117
3519
|
};
|
|
3118
3520
|
}
|
|
3119
|
-
robots ??= await fetchRobots(new URL(url).origin, url, refresh
|
|
3521
|
+
robots ??= await fetchRobots(new URL(url).origin, url, refresh, {
|
|
3522
|
+
timeoutMs,
|
|
3523
|
+
signal
|
|
3524
|
+
});
|
|
3120
3525
|
const beforeFetch = await waitForHostBackpressure(host, rate);
|
|
3121
3526
|
let attempts = 0;
|
|
3122
3527
|
const response = await pRetry(async () => {
|
|
@@ -3136,8 +3541,14 @@ async function fetchPlain(url, refresh = false, timeoutMs = 2e4, rate, signal, r
|
|
|
3136
3541
|
signal?.removeEventListener("abort", abort);
|
|
3137
3542
|
}
|
|
3138
3543
|
}, { retries: 2 });
|
|
3139
|
-
const html = await response.response.text();
|
|
3140
3544
|
const headerMap = Object.fromEntries(response.response.headers.entries());
|
|
3545
|
+
const accessBlock = detectAccessBlock({
|
|
3546
|
+
status: response.response.status,
|
|
3547
|
+
headers: response.response.headers
|
|
3548
|
+
});
|
|
3549
|
+
let html = "";
|
|
3550
|
+
if (accessBlock) await response.response.body?.cancel().catch(() => void 0);
|
|
3551
|
+
else html = await readBoundedResponseText(response.response, MAX_PAGE_RESPONSE_BYTES, "Page response");
|
|
3141
3552
|
const durationMs = Date.now() - startedAt;
|
|
3142
3553
|
const backpressure = recordHostFetch({
|
|
3143
3554
|
host,
|
|
@@ -3158,7 +3569,7 @@ async function fetchPlain(url, refresh = false, timeoutMs = 2e4, rate, signal, r
|
|
|
3158
3569
|
cache: refresh ? "bypass" : "miss",
|
|
3159
3570
|
fetched: true,
|
|
3160
3571
|
rendered: false,
|
|
3161
|
-
blocked: robots.allowed === false || [
|
|
3572
|
+
blocked: robots.allowed === false || Boolean(accessBlock) || [
|
|
3162
3573
|
401,
|
|
3163
3574
|
403,
|
|
3164
3575
|
429
|
|
@@ -3167,15 +3578,19 @@ async function fetchPlain(url, refresh = false, timeoutMs = 2e4, rate, signal, r
|
|
|
3167
3578
|
retries: Math.max(0, attempts - 1),
|
|
3168
3579
|
rateLimit: { ...rateLimitDiagnostics(host, rate) },
|
|
3169
3580
|
backpressure: backpressure.status === "ok" && beforeFetch.status !== "ok" ? beforeFetch : backpressure,
|
|
3581
|
+
accessBlock,
|
|
3170
3582
|
robotsTxt: diagnosticRobotsEvidence(robots),
|
|
3171
3583
|
redirectChain: response.redirectChain
|
|
3172
3584
|
},
|
|
3173
|
-
warnings: robots.allowed === null ? [`robots.txt availability is unknown${robots.status ? ` after HTTP ${robots.status}` : ""}; this fetch does not prove crawler access is allowed.`] : [],
|
|
3585
|
+
warnings: [...robots.allowed === null ? [`robots.txt availability is unknown${robots.status ? ` after HTTP ${robots.status}` : ""}; this fetch does not prove crawler access is allowed.`] : [], ...accessBlock ? [`${accessBlock.guidance.summary} Identify the crawler as ${accessBlock.crawler.userAgent}.`] : []],
|
|
3174
3586
|
robotsTxt: pageRobotsEvidence(robots)
|
|
3175
3587
|
};
|
|
3176
|
-
|
|
3177
|
-
|
|
3178
|
-
|
|
3588
|
+
if (!accessBlock) {
|
|
3589
|
+
db.prepare(`INSERT OR REPLACE INTO http_cache
|
|
3590
|
+
(url_hash, url, status, headers_json, body_blob, metadata_json, etag, fetched_at, expires_at)
|
|
3591
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`).run(key, url, response.response.status, JSON.stringify(headerMap), Buffer.from(html), encodePageFetchCacheEvidence(result), response.response.headers.get("etag"), Date.now(), Date.now() + 36e5);
|
|
3592
|
+
noteCacheWrite(Buffer.byteLength(html));
|
|
3593
|
+
}
|
|
3179
3594
|
return result;
|
|
3180
3595
|
}
|
|
3181
3596
|
//#endregion
|
|
@@ -3388,7 +3803,7 @@ async function renderPage(input) {
|
|
|
3388
3803
|
const beforeFetch = await waitForHostBackpressure(host, input.rate);
|
|
3389
3804
|
const timeoutMs = input.timeoutMs ?? DEFAULT_RENDER_TIMEOUT_MS;
|
|
3390
3805
|
const context = await input.browser.browser.newContext({
|
|
3391
|
-
userAgent:
|
|
3806
|
+
userAgent: SEO_CRAWLER_USER_AGENT,
|
|
3392
3807
|
viewport: {
|
|
3393
3808
|
width: 1440,
|
|
3394
3809
|
height: 900
|
|
@@ -3417,6 +3832,11 @@ async function renderPage(input) {
|
|
|
3417
3832
|
});
|
|
3418
3833
|
const html = await page.content();
|
|
3419
3834
|
const status = response?.status() ?? 200;
|
|
3835
|
+
const headers = response?.headers() ?? {};
|
|
3836
|
+
const accessBlock = detectAccessBlock({
|
|
3837
|
+
status,
|
|
3838
|
+
headers
|
|
3839
|
+
});
|
|
3420
3840
|
const durationMs = Date.now() - startedAt;
|
|
3421
3841
|
const backpressure = recordHostFetch({
|
|
3422
3842
|
host,
|
|
@@ -3429,7 +3849,7 @@ async function renderPage(input) {
|
|
|
3429
3849
|
url: input.url,
|
|
3430
3850
|
finalUrl: page.url(),
|
|
3431
3851
|
status,
|
|
3432
|
-
headers
|
|
3852
|
+
headers,
|
|
3433
3853
|
html,
|
|
3434
3854
|
usedJs: true,
|
|
3435
3855
|
diagnostics: {
|
|
@@ -3437,7 +3857,7 @@ async function renderPage(input) {
|
|
|
3437
3857
|
cache: "bypass",
|
|
3438
3858
|
fetched: true,
|
|
3439
3859
|
rendered: true,
|
|
3440
|
-
blocked: [
|
|
3860
|
+
blocked: Boolean(accessBlock) || [
|
|
3441
3861
|
401,
|
|
3442
3862
|
403,
|
|
3443
3863
|
429
|
|
@@ -3446,6 +3866,7 @@ async function renderPage(input) {
|
|
|
3446
3866
|
retries: 0,
|
|
3447
3867
|
rateLimit: rateLimitDiagnostics(host, input.rate),
|
|
3448
3868
|
backpressure: backpressure.status === "ok" && beforeFetch.status !== "ok" ? beforeFetch : backpressure,
|
|
3869
|
+
accessBlock,
|
|
3449
3870
|
rendering: {
|
|
3450
3871
|
mode: "on",
|
|
3451
3872
|
status: "rendered",
|
|
@@ -3462,7 +3883,7 @@ async function renderPage(input) {
|
|
|
3462
3883
|
...renderDiagnostics
|
|
3463
3884
|
}
|
|
3464
3885
|
},
|
|
3465
|
-
warnings: networkIdleReached ? [] : [`Rendered page did not reach network idle within ${Math.min(NETWORK_IDLE_TIMEOUT_MS, timeoutMs)}ms; captured DOM after DOMContentLoaded.`]
|
|
3886
|
+
warnings: [...networkIdleReached ? [] : [`Rendered page did not reach network idle within ${Math.min(NETWORK_IDLE_TIMEOUT_MS, timeoutMs)}ms; captured DOM after DOMContentLoaded.`], ...accessBlock ? [`${accessBlock.guidance.summary} Identify the crawler as ${accessBlock.crawler.userAgent}.`] : []]
|
|
3466
3887
|
};
|
|
3467
3888
|
} catch (error) {
|
|
3468
3889
|
if (error instanceof JavaScriptRenderingError) throw error;
|
|
@@ -3609,6 +4030,85 @@ function renderingDocumentDifference(raw, rendered) {
|
|
|
3609
4030
|
};
|
|
3610
4031
|
}
|
|
3611
4032
|
//#endregion
|
|
4033
|
+
//#region packages/core/dist/fetch/page-fetcher/status-probe.js
|
|
4034
|
+
async function statusProbe(url, opts) {
|
|
4035
|
+
const startedAt = Date.now();
|
|
4036
|
+
const host = new URL(url).host;
|
|
4037
|
+
const rate = normalizeRateControls(opts.rate);
|
|
4038
|
+
const origin = new URL(url).origin;
|
|
4039
|
+
const robots = opts.robotsResolver ? await opts.robotsResolver(origin, url) : await fetchRobots(origin, url, opts.refresh ?? false, {
|
|
4040
|
+
writeCache: false,
|
|
4041
|
+
timeoutMs: opts.timeoutMs,
|
|
4042
|
+
signal: opts.signal
|
|
4043
|
+
});
|
|
4044
|
+
if (opts.respectRobots && robots.allowed !== true) throw new RobotsAccessError(robots.allowed === false ? "robots-disallowed" : "robots-deferred", robots);
|
|
4045
|
+
const beforeFetch = await waitForHostBackpressure(host, rate);
|
|
4046
|
+
let attempts = 0;
|
|
4047
|
+
const result = await pRetry(async () => {
|
|
4048
|
+
attempts += 1;
|
|
4049
|
+
const controller = new AbortController();
|
|
4050
|
+
const abort = () => controller.abort();
|
|
4051
|
+
const timer = setTimeout(abort, opts.timeoutMs ?? 2e4);
|
|
4052
|
+
if (opts.signal?.aborted) controller.abort();
|
|
4053
|
+
else opts.signal?.addEventListener("abort", abort, { once: true });
|
|
4054
|
+
try {
|
|
4055
|
+
return await fetchWithRedirectChain(url, controller.signal);
|
|
4056
|
+
} catch (error) {
|
|
4057
|
+
if (opts.signal?.aborted) throw new AbortError(error instanceof Error ? error : new Error(String(error)));
|
|
4058
|
+
throw error;
|
|
4059
|
+
} finally {
|
|
4060
|
+
clearTimeout(timer);
|
|
4061
|
+
opts.signal?.removeEventListener("abort", abort);
|
|
4062
|
+
}
|
|
4063
|
+
}, { retries: 1 });
|
|
4064
|
+
const headers = Object.fromEntries(result.response.headers.entries());
|
|
4065
|
+
const accessBlock = detectAccessBlock({
|
|
4066
|
+
status: result.response.status,
|
|
4067
|
+
headers: result.response.headers
|
|
4068
|
+
});
|
|
4069
|
+
await result.response.body?.cancel().catch(() => void 0);
|
|
4070
|
+
const durationMs = Date.now() - startedAt;
|
|
4071
|
+
const backpressure = recordHostFetch({
|
|
4072
|
+
host,
|
|
4073
|
+
status: result.response.status,
|
|
4074
|
+
durationMs,
|
|
4075
|
+
retryAfterMs: retryAfterMs(result.response.headers.get("retry-after")),
|
|
4076
|
+
rate
|
|
4077
|
+
});
|
|
4078
|
+
return {
|
|
4079
|
+
url,
|
|
4080
|
+
finalUrl: result.response.url || url,
|
|
4081
|
+
status: result.response.status,
|
|
4082
|
+
headers,
|
|
4083
|
+
html: "",
|
|
4084
|
+
usedJs: false,
|
|
4085
|
+
diagnostics: {
|
|
4086
|
+
source: "network",
|
|
4087
|
+
cache: "bypass",
|
|
4088
|
+
fetched: true,
|
|
4089
|
+
rendered: false,
|
|
4090
|
+
blocked: robots.allowed === false || Boolean(accessBlock) || [
|
|
4091
|
+
401,
|
|
4092
|
+
403,
|
|
4093
|
+
429
|
|
4094
|
+
].includes(result.response.status),
|
|
4095
|
+
durationMs,
|
|
4096
|
+
retries: Math.max(0, attempts - 1),
|
|
4097
|
+
rateLimit: rateLimitDiagnostics(host, rate),
|
|
4098
|
+
backpressure: backpressure.status === "ok" && beforeFetch.status !== "ok" ? beforeFetch : backpressure,
|
|
4099
|
+
accessBlock,
|
|
4100
|
+
robotsTxt: diagnosticRobotsEvidence(robots),
|
|
4101
|
+
redirectChain: result.redirectChain
|
|
4102
|
+
},
|
|
4103
|
+
warnings: ["Status-only health probe. The response body was not downloaded, parsed, rendered, or cached.", ...accessBlock ? [`${accessBlock.guidance.summary} Identify the crawler as ${accessBlock.crawler.userAgent}.`] : []],
|
|
4104
|
+
robotsTxt: pageRobotsEvidence(robots)
|
|
4105
|
+
};
|
|
4106
|
+
}
|
|
4107
|
+
async function fetchPageStatus(url, opts = {}) {
|
|
4108
|
+
const rate = normalizeRateControls(opts.rate);
|
|
4109
|
+
return await queueForHost(new URL(url).host, rate).add(() => statusProbe(url, opts));
|
|
4110
|
+
}
|
|
4111
|
+
//#endregion
|
|
3612
4112
|
//#region packages/core/dist/fetch/page-fetcher.js
|
|
3613
4113
|
function normalizeJavaScriptRenderingMode(input) {
|
|
3614
4114
|
if (input === true) return "on";
|
|
@@ -3654,7 +4154,7 @@ async function fetchPage(url, opts = {}, dependencies = {}) {
|
|
|
3654
4154
|
const rate = normalizeRateControls(opts.rate);
|
|
3655
4155
|
return await queueForHost(new URL(url).host, rate).add(async () => {
|
|
3656
4156
|
const first = await plainFetch(url, opts.refresh, opts.timeoutMs, rate, opts.signal, opts.respectRobots);
|
|
3657
|
-
if (!(renderingMode === "on" || renderingMode === "auto" && looksLikeSpa(first.html)) || opts.signal?.aborted) return withRenderingState(first, renderingMode, opts.signal?.aborted ? "skipped" : renderingMode === "off" ? "not-requested" : "not-needed");
|
|
4157
|
+
if (!(!first.diagnostics.accessBlock && (renderingMode === "on" || renderingMode === "auto" && looksLikeSpa(first.html))) || opts.signal?.aborted) return withRenderingState(first, renderingMode, opts.signal?.aborted ? "skipped" : renderingMode === "off" ? "not-requested" : "not-needed");
|
|
3658
4158
|
const ownsRenderer = !opts.renderer;
|
|
3659
4159
|
const renderer = opts.renderer ?? makeRenderer();
|
|
3660
4160
|
try {
|
|
@@ -3668,6 +4168,7 @@ async function fetchPage(url, opts = {}, dependencies = {}) {
|
|
|
3668
4168
|
...rendered.diagnostics,
|
|
3669
4169
|
robotsTxt: first.diagnostics.robotsTxt,
|
|
3670
4170
|
redirectChain: first.diagnostics.redirectChain,
|
|
4171
|
+
accessBlock: rendered.diagnostics.accessBlock ?? first.diagnostics.accessBlock,
|
|
3671
4172
|
rendering: {
|
|
3672
4173
|
...rendered.diagnostics.rendering,
|
|
3673
4174
|
mode: renderingMode,
|
|
@@ -4042,9 +4543,12 @@ async function querySearchAnalytics(site, body, opts = {}) {
|
|
|
4042
4543
|
if (rows.length < rowLimit || typeof maxRows === "number" && allRows.length >= maxRows) break;
|
|
4043
4544
|
startRow += rowLimit;
|
|
4044
4545
|
}
|
|
4546
|
+
const requestJson = JSON.stringify(body);
|
|
4547
|
+
const responseJson = JSON.stringify(allRows);
|
|
4045
4548
|
db.prepare(`INSERT OR REPLACE INTO gsc_cache
|
|
4046
4549
|
(site_url, query_hash, request_json, response_json, row_count, fetched_at, expires_at)
|
|
4047
|
-
VALUES (?, ?, ?, ?, ?, ?, ?)`).run(site, queryHash,
|
|
4550
|
+
VALUES (?, ?, ?, ?, ?, ?, ?)`).run(site, queryHash, requestJson, responseJson, allRows.length, Date.now(), Date.now() + 864e5);
|
|
4551
|
+
noteCacheWrite(Buffer.byteLength(requestJson) + Buffer.byteLength(responseJson));
|
|
4048
4552
|
return {
|
|
4049
4553
|
rows: allRows,
|
|
4050
4554
|
calls,
|
|
@@ -4189,7 +4693,7 @@ async function listSites(_refresh = false) {
|
|
|
4189
4693
|
}
|
|
4190
4694
|
//#endregion
|
|
4191
4695
|
//#region packages/core/dist/analyze/page-technical-signals.js
|
|
4192
|
-
function
|
|
4696
|
+
function normalizePageUrl(value) {
|
|
4193
4697
|
try {
|
|
4194
4698
|
const parsed = new URL(value);
|
|
4195
4699
|
parsed.hash = "";
|
|
@@ -4200,7 +4704,7 @@ function normalizedUrl$1(value) {
|
|
|
4200
4704
|
}
|
|
4201
4705
|
}
|
|
4202
4706
|
function samePageUrl(left, right) {
|
|
4203
|
-
return
|
|
4707
|
+
return normalizePageUrl(left) === normalizePageUrl(right);
|
|
4204
4708
|
}
|
|
4205
4709
|
function absoluteUrl$1(value, base) {
|
|
4206
4710
|
try {
|
|
@@ -4576,12 +5080,13 @@ const CTR_BASELINE = {
|
|
|
4576
5080
|
9: .015,
|
|
4577
5081
|
10: .012
|
|
4578
5082
|
};
|
|
5083
|
+
const WORD_SEGMENTER = typeof Intl !== "undefined" && "Segmenter" in Intl ? new Intl.Segmenter("und", { granularity: "word" }) : void 0;
|
|
4579
5084
|
function normalizeText$1(value) {
|
|
4580
5085
|
return value.normalize("NFKD").replace(/\p{Diacritic}/gu, "").replace(/[’']/g, "'").toLowerCase().replace(/\s+/g, " ").trim();
|
|
4581
5086
|
}
|
|
4582
5087
|
function unicodeTokens(value) {
|
|
4583
5088
|
const normalized = normalizeText$1(value);
|
|
4584
|
-
if (
|
|
5089
|
+
if (WORD_SEGMENTER) return [...WORD_SEGMENTER.segment(normalized)].filter((part) => part.isWordLike).map((part) => part.segment.trim()).filter((token) => token.length > 1);
|
|
4585
5090
|
return (normalized.match(/[\p{L}\p{N}]+/gu) ?? []).filter((token) => token.length > 1);
|
|
4586
5091
|
}
|
|
4587
5092
|
function tokenize(value) {
|
|
@@ -5228,6 +5733,7 @@ const MIN_BENCHMARK_URL_SAMPLES = 5;
|
|
|
5228
5733
|
const MIN_POSITIVE_URL_SAMPLES = 3;
|
|
5229
5734
|
const SITE_BENCHMARK_FLOOR_MULTIPLIER = .5;
|
|
5230
5735
|
const SITE_BENCHMARK_CAP_MULTIPLIER = 1.5;
|
|
5736
|
+
const URL_BENCHMARK_CACHE_LIMIT = 1e4;
|
|
5231
5737
|
function roundedPosition(position) {
|
|
5232
5738
|
return Math.max(1, Math.min(10, Math.round(position)));
|
|
5233
5739
|
}
|
|
@@ -5244,7 +5750,8 @@ function bucketBenchmark(input) {
|
|
|
5244
5750
|
const impressions = peerRows.reduce((sum, row) => sum + row.impressions, 0);
|
|
5245
5751
|
const byUrl = /* @__PURE__ */ new Map();
|
|
5246
5752
|
for (const row of peerRows) {
|
|
5247
|
-
const
|
|
5753
|
+
const rawKey = row.keys?.[1] || `${row.keys?.[0] ?? "query"}:${row.position}`;
|
|
5754
|
+
const key = row.keys?.[1] ? normalizePageUrl(rawKey) : rawKey;
|
|
5248
5755
|
const current = byUrl.get(key) ?? {
|
|
5249
5756
|
clicks: 0,
|
|
5250
5757
|
impressions: 0
|
|
@@ -5278,14 +5785,85 @@ function bucketBenchmark(input) {
|
|
|
5278
5785
|
positiveUrlSamples: positiveUrlCtrSamples.length
|
|
5279
5786
|
};
|
|
5280
5787
|
}
|
|
5281
|
-
function
|
|
5282
|
-
const
|
|
5283
|
-
let
|
|
5284
|
-
for (const
|
|
5285
|
-
|
|
5286
|
-
|
|
5287
|
-
|
|
5288
|
-
|
|
5788
|
+
function indexedPositionBucket(rows) {
|
|
5789
|
+
const byUrl = /* @__PURE__ */ new Map();
|
|
5790
|
+
let impressions = 0;
|
|
5791
|
+
for (const row of rows) {
|
|
5792
|
+
impressions += row.impressions;
|
|
5793
|
+
const rawKey = row.keys?.[1] || `${row.keys?.[0] ?? "query"}:${row.position}`;
|
|
5794
|
+
const key = row.keys?.[1] ? normalizePageUrl(rawKey) : rawKey;
|
|
5795
|
+
const current = byUrl.get(key) ?? {
|
|
5796
|
+
key,
|
|
5797
|
+
clicks: 0,
|
|
5798
|
+
impressions: 0,
|
|
5799
|
+
rows: 0,
|
|
5800
|
+
ctr: 0
|
|
5801
|
+
};
|
|
5802
|
+
current.clicks += row.clicks;
|
|
5803
|
+
current.impressions += row.impressions;
|
|
5804
|
+
current.rows += 1;
|
|
5805
|
+
current.ctr = current.impressions ? current.clicks / current.impressions : 0;
|
|
5806
|
+
byUrl.set(key, current);
|
|
5807
|
+
}
|
|
5808
|
+
const compareSample = (left, right) => left.ctr - right.ctr || (left.key < right.key ? -1 : left.key > right.key ? 1 : 0);
|
|
5809
|
+
const qualified = [...byUrl.values()].filter((sample) => sample.impressions >= MIN_BENCHMARK_URL_IMPRESSIONS).sort(compareSample);
|
|
5810
|
+
const positive = qualified.filter((sample) => sample.ctr > 0).sort(compareSample);
|
|
5811
|
+
return {
|
|
5812
|
+
rows: rows.length,
|
|
5813
|
+
impressions,
|
|
5814
|
+
byUrl,
|
|
5815
|
+
qualified,
|
|
5816
|
+
qualifiedIndexes: new Map(qualified.map((sample, index) => [sample.key, index])),
|
|
5817
|
+
positive,
|
|
5818
|
+
positiveIndexes: new Map(positive.map((sample, index) => [sample.key, index])),
|
|
5819
|
+
qualifiedImpressions: qualified.reduce((sum, sample) => sum + sample.impressions, 0)
|
|
5820
|
+
};
|
|
5821
|
+
}
|
|
5822
|
+
function indexedPercentile(samples, indexes, excludedKey, percentileValue) {
|
|
5823
|
+
const excludedIndex = indexes.get(excludedKey);
|
|
5824
|
+
const length = samples.length - (excludedIndex === void 0 ? 0 : 1);
|
|
5825
|
+
if (length === 0) return void 0;
|
|
5826
|
+
const rank = Math.min(length - 1, Math.max(0, Math.ceil(percentileValue / 100 * length) - 1));
|
|
5827
|
+
return samples[excludedIndex !== void 0 && rank >= excludedIndex ? rank + 1 : rank]?.ctr;
|
|
5828
|
+
}
|
|
5829
|
+
function indexedBucketBenchmark(input) {
|
|
5830
|
+
const excluded = input.bucket.byUrl.get(input.excludedKey);
|
|
5831
|
+
const excludesQualified = excluded && excluded.impressions >= MIN_BENCHMARK_URL_IMPRESSIONS ? excluded : void 0;
|
|
5832
|
+
const rows = input.bucket.rows - (excluded?.rows ?? 0);
|
|
5833
|
+
const impressions = input.bucket.impressions - (excluded?.impressions ?? 0);
|
|
5834
|
+
const qualifiedImpressions = input.bucket.qualifiedImpressions - (excludesQualified?.impressions ?? 0);
|
|
5835
|
+
const urlSamples = input.bucket.qualified.length - (excludesQualified ? 1 : 0);
|
|
5836
|
+
const excludesPositive = Boolean(excludesQualified && excludesQualified.ctr > 0);
|
|
5837
|
+
const positiveUrlSamples = input.bucket.positive.length - (excludesPositive ? 1 : 0);
|
|
5838
|
+
const hasEnoughSiteData = qualifiedImpressions >= MIN_BENCHMARK_IMPRESSIONS && urlSamples >= MIN_BENCHMARK_URL_SAMPLES && positiveUrlSamples >= MIN_POSITIVE_URL_SAMPLES;
|
|
5839
|
+
const samplePopulation = input.samplePopulation ?? "positive_url_samples";
|
|
5840
|
+
const rawSiteCtr = hasEnoughSiteData ? samplePopulation === "all_qualified_url_samples" ? indexedPercentile(input.bucket.qualified, input.bucket.qualifiedIndexes, input.excludedKey, 75) : indexedPercentile(input.bucket.positive, input.bucket.positiveIndexes, input.excludedKey, 75) : void 0;
|
|
5841
|
+
const fallbackCtr = expectedCtrForPosition(input.position);
|
|
5842
|
+
const floorCtr = fallbackCtr * SITE_BENCHMARK_FLOOR_MULTIPLIER;
|
|
5843
|
+
const capCtr = fallbackCtr * SITE_BENCHMARK_CAP_MULTIPLIER;
|
|
5844
|
+
const hasSiteCtr = rawSiteCtr !== void 0;
|
|
5845
|
+
const ctr = hasSiteCtr ? Math.max(floorCtr, Math.min(rawSiteCtr, capCtr)) : fallbackCtr;
|
|
5846
|
+
const adjustment = hasSiteCtr && rawSiteCtr < floorCtr ? "_floored" : hasSiteCtr && rawSiteCtr > capCtr ? "_capped" : "";
|
|
5847
|
+
const source = hasSiteCtr ? `site_gsc_position_bucket_robust_p75${samplePopulation === "all_qualified_url_samples" ? "_all_samples" : ""}${input.sourceSuffix}${adjustment}` : input.fallbackSource ?? "default_position_curve";
|
|
5848
|
+
return {
|
|
5849
|
+
ctr: Number(ctr.toFixed(4)),
|
|
5850
|
+
source,
|
|
5851
|
+
samplePopulation,
|
|
5852
|
+
rows,
|
|
5853
|
+
impressions,
|
|
5854
|
+
qualifiedImpressions,
|
|
5855
|
+
urlSamples,
|
|
5856
|
+
positiveUrlSamples
|
|
5857
|
+
};
|
|
5858
|
+
}
|
|
5859
|
+
function smoothMonotonicBenchmarks(input) {
|
|
5860
|
+
const byPosition = {};
|
|
5861
|
+
let previousCtr = Number.POSITIVE_INFINITY;
|
|
5862
|
+
for (const position of Object.keys(CTR_BASELINE).map(Number)) {
|
|
5863
|
+
const benchmark = input[position] ?? bucketBenchmark({
|
|
5864
|
+
rows: [],
|
|
5865
|
+
position
|
|
5866
|
+
});
|
|
5289
5867
|
const ctr = Math.min(benchmark.ctr, previousCtr);
|
|
5290
5868
|
const smoothed = ctr < benchmark.ctr;
|
|
5291
5869
|
previousCtr = ctr;
|
|
@@ -5299,9 +5877,25 @@ function smoothMonotonicBenchmarks(input) {
|
|
|
5299
5877
|
}
|
|
5300
5878
|
function createCtrBenchmarkContext(rows, options = {}) {
|
|
5301
5879
|
const buckets = /* @__PURE__ */ new Map();
|
|
5880
|
+
const diagnostics = {
|
|
5881
|
+
indexRowVisits: 0,
|
|
5882
|
+
urlLookups: 0,
|
|
5883
|
+
fallbackRowScans: 0
|
|
5884
|
+
};
|
|
5302
5885
|
for (const row of rows) {
|
|
5303
5886
|
const position = roundedPosition(row.position);
|
|
5304
|
-
|
|
5887
|
+
const bucket = buckets.get(position) ?? [];
|
|
5888
|
+
bucket.push(row);
|
|
5889
|
+
buckets.set(position, bucket);
|
|
5890
|
+
diagnostics.indexRowVisits += 1;
|
|
5891
|
+
}
|
|
5892
|
+
const indexedBuckets = new Map([...buckets].map(([position, bucketRows]) => [position, indexedPositionBucket(bucketRows)]));
|
|
5893
|
+
const emptyIndexedBucket = indexedPositionBucket([]);
|
|
5894
|
+
const urlBenchmarkCache = /* @__PURE__ */ new Map();
|
|
5895
|
+
const urlRowCounts = /* @__PURE__ */ new Map();
|
|
5896
|
+
for (const row of rows) {
|
|
5897
|
+
const key = normalizePageUrl(row.keys?.[1] ?? "");
|
|
5898
|
+
urlRowCounts.set(key, (urlRowCounts.get(key) ?? 0) + 1);
|
|
5305
5899
|
}
|
|
5306
5900
|
const raw = {};
|
|
5307
5901
|
for (const position of Object.keys(CTR_BASELINE).map(Number)) raw[position] = bucketBenchmark({
|
|
@@ -5312,6 +5906,7 @@ function createCtrBenchmarkContext(rows, options = {}) {
|
|
|
5312
5906
|
});
|
|
5313
5907
|
const byPosition = smoothMonotonicBenchmarks(raw);
|
|
5314
5908
|
function benchmarkForRow(row, excludedRows, sourceSuffix) {
|
|
5909
|
+
diagnostics.fallbackRowScans += rows.length;
|
|
5315
5910
|
const position = roundedPosition(row.position);
|
|
5316
5911
|
const excludedSet = new Set(excludedRows);
|
|
5317
5912
|
const rowRaw = {};
|
|
@@ -5328,18 +5923,49 @@ function createCtrBenchmarkContext(rows, options = {}) {
|
|
|
5328
5923
|
position
|
|
5329
5924
|
});
|
|
5330
5925
|
}
|
|
5926
|
+
function benchmarkForUrl(row) {
|
|
5927
|
+
diagnostics.urlLookups += 1;
|
|
5928
|
+
const position = roundedPosition(row.position);
|
|
5929
|
+
const excludedKey = normalizePageUrl(row.keys?.[1] ?? "");
|
|
5930
|
+
const cached = excludedKey ? urlBenchmarkCache.get(excludedKey)?.get(position) : void 0;
|
|
5931
|
+
if (cached) return cached;
|
|
5932
|
+
const rowRaw = {};
|
|
5933
|
+
for (const bucketPosition of Object.keys(CTR_BASELINE).map(Number)) rowRaw[bucketPosition] = indexedBucketBenchmark({
|
|
5934
|
+
bucket: indexedBuckets.get(bucketPosition) ?? emptyIndexedBucket,
|
|
5935
|
+
position: bucketPosition,
|
|
5936
|
+
excludedKey,
|
|
5937
|
+
sourceSuffix: "_leave_target_url_out",
|
|
5938
|
+
samplePopulation: options.samplePopulation,
|
|
5939
|
+
fallbackSource: options.fallbackSource
|
|
5940
|
+
});
|
|
5941
|
+
const benchmark = smoothMonotonicBenchmarks(rowRaw)[String(position)] ?? byPosition[String(position)] ?? bucketBenchmark({
|
|
5942
|
+
rows: [],
|
|
5943
|
+
position
|
|
5944
|
+
});
|
|
5945
|
+
let byPositionForUrl = excludedKey ? urlBenchmarkCache.get(excludedKey) : void 0;
|
|
5946
|
+
if (excludedKey && !byPositionForUrl && urlBenchmarkCache.size < URL_BENCHMARK_CACHE_LIMIT) {
|
|
5947
|
+
byPositionForUrl = /* @__PURE__ */ new Map();
|
|
5948
|
+
urlBenchmarkCache.set(excludedKey, byPositionForUrl);
|
|
5949
|
+
}
|
|
5950
|
+
byPositionForUrl?.set(position, benchmark);
|
|
5951
|
+
return benchmark;
|
|
5952
|
+
}
|
|
5331
5953
|
return {
|
|
5332
5954
|
byPosition,
|
|
5333
5955
|
forRow(row) {
|
|
5334
5956
|
return benchmarkForRow(row, [row], "_leave_one_out");
|
|
5335
5957
|
},
|
|
5336
5958
|
forUrl(row) {
|
|
5337
|
-
|
|
5338
|
-
|
|
5959
|
+
return benchmarkForUrl(row);
|
|
5960
|
+
},
|
|
5961
|
+
urlRowCount(row) {
|
|
5962
|
+
const key = normalizePageUrl(row.keys?.[1] ?? "");
|
|
5963
|
+
return urlRowCounts.get(key) ?? 0;
|
|
5339
5964
|
},
|
|
5340
5965
|
forAggregate(row, excludedRows = []) {
|
|
5341
5966
|
return benchmarkForRow(row, excludedRows, excludedRows.length ? "_leave_group_out" : void 0);
|
|
5342
|
-
}
|
|
5967
|
+
},
|
|
5968
|
+
diagnostics
|
|
5343
5969
|
};
|
|
5344
5970
|
}
|
|
5345
5971
|
//#endregion
|
|
@@ -6098,6 +6724,210 @@ function matchesFilters(issue, filters) {
|
|
|
6098
6724
|
return true;
|
|
6099
6725
|
}
|
|
6100
6726
|
//#endregion
|
|
6727
|
+
//#region packages/core/dist/analyze/crawler/agent-endpoints.js
|
|
6728
|
+
const MAX_LINK_HEADER_ENTRIES = 25;
|
|
6729
|
+
const REGISTERED_AGENT_LINK_RELS = [
|
|
6730
|
+
"api-catalog",
|
|
6731
|
+
"describedby",
|
|
6732
|
+
"service-desc",
|
|
6733
|
+
"service-doc"
|
|
6734
|
+
];
|
|
6735
|
+
const EMERGING_AGENT_LINK_RELS = ["agent-skills", "llms-txt"];
|
|
6736
|
+
const AGENT_ENDPOINT_PROBES = [
|
|
6737
|
+
{
|
|
6738
|
+
id: "mcp-server-card",
|
|
6739
|
+
path: "/.well-known/mcp/server-card.json",
|
|
6740
|
+
accept: "application/json",
|
|
6741
|
+
expects: "json",
|
|
6742
|
+
interestFields: [
|
|
6743
|
+
"serverInfo",
|
|
6744
|
+
"transport",
|
|
6745
|
+
"capabilities"
|
|
6746
|
+
],
|
|
6747
|
+
requiredFields: ["serverInfo"]
|
|
6748
|
+
},
|
|
6749
|
+
{
|
|
6750
|
+
id: "mcp-server-cards",
|
|
6751
|
+
path: "/.well-known/mcp/server-cards.json",
|
|
6752
|
+
accept: "application/json",
|
|
6753
|
+
expects: "json",
|
|
6754
|
+
interestFields: ["serverCards", "servers"]
|
|
6755
|
+
},
|
|
6756
|
+
{
|
|
6757
|
+
id: "a2a-agent-card",
|
|
6758
|
+
path: "/.well-known/agent-card.json",
|
|
6759
|
+
accept: "application/json",
|
|
6760
|
+
expects: "json",
|
|
6761
|
+
interestFields: [
|
|
6762
|
+
"name",
|
|
6763
|
+
"description",
|
|
6764
|
+
"url",
|
|
6765
|
+
"capabilities",
|
|
6766
|
+
"skills"
|
|
6767
|
+
],
|
|
6768
|
+
requiredFields: ["name"]
|
|
6769
|
+
},
|
|
6770
|
+
{
|
|
6771
|
+
id: "openid-configuration",
|
|
6772
|
+
path: "/.well-known/openid-configuration",
|
|
6773
|
+
accept: "application/json",
|
|
6774
|
+
expects: "json",
|
|
6775
|
+
interestFields: [
|
|
6776
|
+
"issuer",
|
|
6777
|
+
"authorization_endpoint",
|
|
6778
|
+
"token_endpoint",
|
|
6779
|
+
"jwks_uri",
|
|
6780
|
+
"response_types_supported"
|
|
6781
|
+
],
|
|
6782
|
+
requiredFields: ["issuer"]
|
|
6783
|
+
},
|
|
6784
|
+
{
|
|
6785
|
+
id: "oauth-authorization-server",
|
|
6786
|
+
path: "/.well-known/oauth-authorization-server",
|
|
6787
|
+
accept: "application/json",
|
|
6788
|
+
expects: "json",
|
|
6789
|
+
interestFields: [
|
|
6790
|
+
"issuer",
|
|
6791
|
+
"authorization_endpoint",
|
|
6792
|
+
"token_endpoint",
|
|
6793
|
+
"jwks_uri",
|
|
6794
|
+
"grant_types_supported"
|
|
6795
|
+
],
|
|
6796
|
+
requiredFields: ["issuer"]
|
|
6797
|
+
},
|
|
6798
|
+
{
|
|
6799
|
+
id: "oauth-protected-resource",
|
|
6800
|
+
path: "/.well-known/oauth-protected-resource",
|
|
6801
|
+
accept: "application/json",
|
|
6802
|
+
expects: "json",
|
|
6803
|
+
interestFields: [
|
|
6804
|
+
"resource",
|
|
6805
|
+
"authorization_servers",
|
|
6806
|
+
"scopes_supported"
|
|
6807
|
+
],
|
|
6808
|
+
requiredFields: ["resource"]
|
|
6809
|
+
},
|
|
6810
|
+
{
|
|
6811
|
+
id: "api-catalog",
|
|
6812
|
+
path: "/.well-known/api-catalog",
|
|
6813
|
+
accept: "application/linkset+json,application/json;q=0.9",
|
|
6814
|
+
expects: "json",
|
|
6815
|
+
interestFields: ["linkset"],
|
|
6816
|
+
requiredFields: ["linkset"]
|
|
6817
|
+
},
|
|
6818
|
+
{
|
|
6819
|
+
id: "web-bot-auth-directory",
|
|
6820
|
+
path: "/.well-known/http-message-signatures-directory",
|
|
6821
|
+
accept: "application/http-message-signatures-directory+json,application/json;q=0.9",
|
|
6822
|
+
expects: "json",
|
|
6823
|
+
interestFields: ["keys"],
|
|
6824
|
+
requiredFields: ["keys"]
|
|
6825
|
+
},
|
|
6826
|
+
{
|
|
6827
|
+
id: "auth-md",
|
|
6828
|
+
path: "/auth.md",
|
|
6829
|
+
accept: "text/markdown,text/plain;q=0.9",
|
|
6830
|
+
expects: "text"
|
|
6831
|
+
},
|
|
6832
|
+
{
|
|
6833
|
+
id: "llms-full-txt",
|
|
6834
|
+
path: "/llms-full.txt",
|
|
6835
|
+
accept: "text/plain,text/markdown;q=0.9",
|
|
6836
|
+
expects: "text"
|
|
6837
|
+
}
|
|
6838
|
+
];
|
|
6839
|
+
async function inspectAgentEndpoint(input) {
|
|
6840
|
+
const url = new URL(input.probe.path, input.origin).toString();
|
|
6841
|
+
try {
|
|
6842
|
+
const { response, body } = await fetchText({
|
|
6843
|
+
url,
|
|
6844
|
+
timeoutMs: input.timeoutMs,
|
|
6845
|
+
fetch: input.fetch,
|
|
6846
|
+
signal: input.signal,
|
|
6847
|
+
accept: input.probe.accept
|
|
6848
|
+
});
|
|
6849
|
+
const contentType = response.headers.get("content-type") ?? void 0;
|
|
6850
|
+
const htmlFallback = /\btext\/html\b/iu.test(contentType ?? "");
|
|
6851
|
+
const exists = response.status >= 200 && response.status < 300 && !htmlFallback;
|
|
6852
|
+
const observation = {
|
|
6853
|
+
id: input.probe.id,
|
|
6854
|
+
url: response.url || url,
|
|
6855
|
+
status: response.status,
|
|
6856
|
+
exists,
|
|
6857
|
+
...contentType === void 0 ? {} : { contentType }
|
|
6858
|
+
};
|
|
6859
|
+
if (exists && input.probe.expects === "json") {
|
|
6860
|
+
let parsed;
|
|
6861
|
+
try {
|
|
6862
|
+
const value = JSON.parse(body);
|
|
6863
|
+
observation.validJson = true;
|
|
6864
|
+
if (value && typeof value === "object" && !Array.isArray(value)) parsed = value;
|
|
6865
|
+
} catch {
|
|
6866
|
+
observation.validJson = false;
|
|
6867
|
+
}
|
|
6868
|
+
if (parsed) {
|
|
6869
|
+
observation.presentFields = [.../* @__PURE__ */ new Set([...input.probe.interestFields ?? [], ...input.probe.requiredFields ?? []])].sort().filter((field) => parsed[field] !== void 0);
|
|
6870
|
+
observation.missingFields = (input.probe.requiredFields ?? []).filter((field) => parsed[field] === void 0).sort();
|
|
6871
|
+
} else if (observation.validJson) {
|
|
6872
|
+
observation.presentFields = [];
|
|
6873
|
+
observation.missingFields = [...input.probe.requiredFields ?? []].sort();
|
|
6874
|
+
}
|
|
6875
|
+
}
|
|
6876
|
+
return observation;
|
|
6877
|
+
} catch (error) {
|
|
6878
|
+
return {
|
|
6879
|
+
id: input.probe.id,
|
|
6880
|
+
url,
|
|
6881
|
+
exists: false,
|
|
6882
|
+
error: safeError(error)
|
|
6883
|
+
};
|
|
6884
|
+
}
|
|
6885
|
+
}
|
|
6886
|
+
async function inspectAgentEndpoints(input) {
|
|
6887
|
+
const [linkHeader, ...endpoints] = await Promise.all([(async () => {
|
|
6888
|
+
try {
|
|
6889
|
+
const { response } = await fetchText({
|
|
6890
|
+
url: input.startUrl,
|
|
6891
|
+
timeoutMs: input.timeoutMs,
|
|
6892
|
+
fetch: input.fetch,
|
|
6893
|
+
signal: input.signal,
|
|
6894
|
+
accept: "text/html,application/xhtml+xml;q=0.9"
|
|
6895
|
+
});
|
|
6896
|
+
const entries = linkEntries(response.headers.get("link") ?? void 0, input.startUrl).slice(0, MAX_LINK_HEADER_ENTRIES).map((entry) => ({
|
|
6897
|
+
url: entry.url,
|
|
6898
|
+
rel: entry.rel.filter(Boolean),
|
|
6899
|
+
...entry.type ? { type: entry.type } : {}
|
|
6900
|
+
}));
|
|
6901
|
+
const observedRels = new Set(entries.flatMap((entry) => entry.rel));
|
|
6902
|
+
return {
|
|
6903
|
+
url: response.url || input.startUrl,
|
|
6904
|
+
status: response.status,
|
|
6905
|
+
entries,
|
|
6906
|
+
registeredRels: REGISTERED_AGENT_LINK_RELS.filter((rel) => observedRels.has(rel)),
|
|
6907
|
+
emergingRels: EMERGING_AGENT_LINK_RELS.filter((rel) => observedRels.has(rel))
|
|
6908
|
+
};
|
|
6909
|
+
} catch (error) {
|
|
6910
|
+
return {
|
|
6911
|
+
url: input.startUrl,
|
|
6912
|
+
entries: [],
|
|
6913
|
+
registeredRels: [],
|
|
6914
|
+
emergingRels: [],
|
|
6915
|
+
error: safeError(error)
|
|
6916
|
+
};
|
|
6917
|
+
}
|
|
6918
|
+
})(), ...AGENT_ENDPOINT_PROBES.map((probe) => inspectAgentEndpoint({
|
|
6919
|
+
origin: input.origin,
|
|
6920
|
+
probe,
|
|
6921
|
+
timeoutMs: input.timeoutMs,
|
|
6922
|
+
fetch: input.fetch,
|
|
6923
|
+
signal: input.signal
|
|
6924
|
+
}))]);
|
|
6925
|
+
return {
|
|
6926
|
+
linkHeader,
|
|
6927
|
+
endpoints
|
|
6928
|
+
};
|
|
6929
|
+
}
|
|
6930
|
+
//#endregion
|
|
6101
6931
|
//#region packages/core/dist/analyze/crawler/agent-discovery.js
|
|
6102
6932
|
const MAX_BODY_BYTES = 2e6;
|
|
6103
6933
|
const MAX_LLMS_LINKS = 100;
|
|
@@ -6618,7 +7448,7 @@ async function collectAgentDiscovery(input) {
|
|
|
6618
7448
|
fetch: input.fetch,
|
|
6619
7449
|
signal: input.signal
|
|
6620
7450
|
}))));
|
|
6621
|
-
const [agentSkills, llmsTxt, routeManifest] = await Promise.all([
|
|
7451
|
+
const [agentSkills, llmsTxt, routeManifest, endpointDiscovery] = await Promise.all([
|
|
6622
7452
|
inspectAgentSkills({
|
|
6623
7453
|
...input,
|
|
6624
7454
|
origin
|
|
@@ -6633,6 +7463,10 @@ async function collectAgentDiscovery(input) {
|
|
|
6633
7463
|
origin,
|
|
6634
7464
|
pages,
|
|
6635
7465
|
observations
|
|
7466
|
+
}),
|
|
7467
|
+
inspectAgentEndpoints({
|
|
7468
|
+
...input,
|
|
7469
|
+
origin
|
|
6636
7470
|
})
|
|
6637
7471
|
]);
|
|
6638
7472
|
const qZero = await fetchRepresentation({
|
|
@@ -6704,6 +7538,7 @@ async function collectAgentDiscovery(input) {
|
|
|
6704
7538
|
routeManifest,
|
|
6705
7539
|
agentSkills,
|
|
6706
7540
|
llmsTxt,
|
|
7541
|
+
endpointDiscovery,
|
|
6707
7542
|
contentSignals: {
|
|
6708
7543
|
htmlValues: [...new Set(htmlContentSignals)].sort(),
|
|
6709
7544
|
markdownValues: [...new Set(markdownContentSignals)].sort(),
|
|
@@ -6859,7 +7694,59 @@ function markdownChecks(discovery) {
|
|
|
6859
7694
|
})
|
|
6860
7695
|
];
|
|
6861
7696
|
}
|
|
6862
|
-
function
|
|
7697
|
+
function contentSignalsCheck(discovery, robotsContentSignals) {
|
|
7698
|
+
const signals = discovery.contentSignals;
|
|
7699
|
+
const robotsDeclared = robotsContentSignals ?? [];
|
|
7700
|
+
const robotsCollected = robotsContentSignals !== void 0;
|
|
7701
|
+
const headersObserved = signals.htmlValues.length > 0 || signals.markdownValues.length > 0;
|
|
7702
|
+
const headersConsistent = signals.consistent === true;
|
|
7703
|
+
const evidence = {
|
|
7704
|
+
robotsDirectives: robotsDeclared,
|
|
7705
|
+
robotsDirectivesCollected: robotsCollected,
|
|
7706
|
+
...signals
|
|
7707
|
+
};
|
|
7708
|
+
if (headersConsistent) return check$1("discovery", {
|
|
7709
|
+
id: "content-signals",
|
|
7710
|
+
status: "pass",
|
|
7711
|
+
title: "Every response publishes one explicit Content Signals policy",
|
|
7712
|
+
plainEnglish: `The same ${signals.htmlValues[0]} policy was observed on every evaluated HTML and Markdown response${robotsDeclared.length ? ", and robots.txt declares a Content-Signal directive" : ""}.`,
|
|
7713
|
+
action: "Keep the same Content-Signal value on robots.txt, HTML, and Markdown responses when the policy changes.",
|
|
7714
|
+
evidence
|
|
7715
|
+
});
|
|
7716
|
+
if (robotsDeclared.length > 0 && !headersObserved) return check$1("discovery", {
|
|
7717
|
+
id: "content-signals",
|
|
7718
|
+
status: "pass",
|
|
7719
|
+
title: "robots.txt declares a Content Signals policy",
|
|
7720
|
+
plainEnglish: `robots.txt declares ${robotsDeclared.length === 1 ? "one Content-Signal directive" : `${robotsDeclared.length} Content-Signal directives`}. No response-header variant was observed, which is fine because robots.txt is the primary published location.`,
|
|
7721
|
+
action: "Keep the robots.txt directive as the single policy, or mirror the same value in response headers if you add them.",
|
|
7722
|
+
evidence
|
|
7723
|
+
});
|
|
7724
|
+
if (robotsDeclared.length > 0 || headersObserved) return check$1("discovery", {
|
|
7725
|
+
id: "content-signals",
|
|
7726
|
+
status: "warning",
|
|
7727
|
+
title: "Content Signals are published inconsistently",
|
|
7728
|
+
plainEnglish: `${signals.missingHtmlPages} HTML responses and ${signals.missingMarkdownPages} Markdown responses were missing the header, the observed values differed, or the header policy does not line up with robots.txt.`,
|
|
7729
|
+
action: "Choose the site policy deliberately and publish the same Content-Signal value everywhere it appears.",
|
|
7730
|
+
evidence
|
|
7731
|
+
});
|
|
7732
|
+
if (signals.consistent === null && !robotsCollected) return check$1("discovery", {
|
|
7733
|
+
id: "content-signals",
|
|
7734
|
+
status: "unknown",
|
|
7735
|
+
title: "Content Signals evidence was not collected",
|
|
7736
|
+
plainEnglish: "No pages were evaluated for the header and robots.txt directive evidence is unavailable in this crawl.",
|
|
7737
|
+
action: "Re-run the report with a URL so robots.txt and response headers can be checked.",
|
|
7738
|
+
evidence
|
|
7739
|
+
});
|
|
7740
|
+
return check$1("discovery", {
|
|
7741
|
+
id: "content-signals",
|
|
7742
|
+
status: "info",
|
|
7743
|
+
title: "No Content Signals policy is published",
|
|
7744
|
+
plainEnglish: "Neither robots.txt nor the evaluated responses declare a Content-Signal policy. Publishing one is optional.",
|
|
7745
|
+
action: "Add a Content-Signal directive to robots.txt only when you want to state an explicit search, ai-input, or ai-train preference.",
|
|
7746
|
+
evidence
|
|
7747
|
+
});
|
|
7748
|
+
}
|
|
7749
|
+
function discoveryChecks(discovery, robotsContentSignals) {
|
|
6863
7750
|
const skills = discovery.agentSkills;
|
|
6864
7751
|
const skillsValid = skills.validIndex && skills.skills.length > 0 && skills.skills.every((skill) => skill.sameOrigin && skill.status === 200 && /^\s*text\/markdown\b/iu.test(skill.contentType ?? "") && Boolean(skill.cors) && skill.digestMatches === true && skill.frontmatterValid === true) && Boolean(skills.cors);
|
|
6865
7752
|
const llms = discovery.llmsTxt;
|
|
@@ -6903,14 +7790,7 @@ function discoveryChecks(discovery) {
|
|
|
6903
7790
|
...llms.missingCrawlRoutes
|
|
6904
7791
|
].slice(0, 25)
|
|
6905
7792
|
}),
|
|
6906
|
-
|
|
6907
|
-
id: "content-signals",
|
|
6908
|
-
status: discovery.contentSignals.consistent === null ? "unknown" : discovery.contentSignals.consistent ? "pass" : "warning",
|
|
6909
|
-
title: discovery.contentSignals.consistent ? "HTML and Markdown publish one explicit Content Signals policy" : "Content Signals are missing or inconsistent across representations",
|
|
6910
|
-
plainEnglish: discovery.contentSignals.consistent ? `The same ${discovery.contentSignals.htmlValues[0]} policy was observed on every evaluated HTML and Markdown response.` : `${discovery.contentSignals.missingHtmlPages} HTML responses and ${discovery.contentSignals.missingMarkdownPages} Markdown responses were missing the header, or the observed values differed.`,
|
|
6911
|
-
action: "Choose the site policy deliberately and return the same Content-Signal value on HTML and Markdown responses.",
|
|
6912
|
-
evidence: discovery.contentSignals
|
|
6913
|
-
}),
|
|
7793
|
+
contentSignalsCheck(discovery, robotsContentSignals),
|
|
6914
7794
|
check$1("discovery", {
|
|
6915
7795
|
id: "route-manifest",
|
|
6916
7796
|
status: !discovery.routeManifest.valid ? "info" : discovery.routeManifest.missingHtmlRoutes.length === 0 && discovery.routeManifest.missingMarkdownRoutes.length === 0 && discovery.routeManifest.orphanMarkdownRoutes.length === 0 ? "pass" : "warning",
|
|
@@ -6921,6 +7801,126 @@ function discoveryChecks(discovery) {
|
|
|
6921
7801
|
})
|
|
6922
7802
|
];
|
|
6923
7803
|
}
|
|
7804
|
+
function optionalEndpointCheck(endpoints, copy) {
|
|
7805
|
+
const found = endpoints.filter((endpoint) => endpoint.exists);
|
|
7806
|
+
const evidence = { endpoints };
|
|
7807
|
+
if (found.length === 0) return check$1("endpoints", {
|
|
7808
|
+
id: copy.id,
|
|
7809
|
+
status: "info",
|
|
7810
|
+
title: `${copy.subject} is not published`,
|
|
7811
|
+
plainEnglish: copy.absent,
|
|
7812
|
+
action: copy.absentAction,
|
|
7813
|
+
evidence
|
|
7814
|
+
});
|
|
7815
|
+
const valid = found.filter((endpoint) => endpoint.validJson !== false && (endpoint.missingFields ?? []).length === 0);
|
|
7816
|
+
if (valid.length > 0) return check$1("endpoints", {
|
|
7817
|
+
id: copy.id,
|
|
7818
|
+
status: "pass",
|
|
7819
|
+
title: `${copy.subject} is published and parses`,
|
|
7820
|
+
plainEnglish: `${valid[0]?.url} returned ${valid[0]?.contentType ?? "a response"} with the expected structure. ${copy.requiredNote}`,
|
|
7821
|
+
action: copy.presentAction,
|
|
7822
|
+
evidence,
|
|
7823
|
+
urls: valid.map((endpoint) => endpoint.url)
|
|
7824
|
+
});
|
|
7825
|
+
const broken = found[0];
|
|
7826
|
+
return check$1("endpoints", {
|
|
7827
|
+
id: copy.id,
|
|
7828
|
+
status: "warning",
|
|
7829
|
+
title: `${copy.subject} is published but needs review`,
|
|
7830
|
+
plainEnglish: `${broken?.url} responded, but ${broken?.validJson === false ? "the body is not valid JSON" : `required fields are missing: ${(broken?.missingFields ?? []).join(", ")}`}. ${copy.requiredNote}`,
|
|
7831
|
+
action: copy.presentAction,
|
|
7832
|
+
evidence,
|
|
7833
|
+
urls: found.map((endpoint) => endpoint.url)
|
|
7834
|
+
});
|
|
7835
|
+
}
|
|
7836
|
+
function endpointChecks(discovery) {
|
|
7837
|
+
const endpointDiscovery = discovery.endpointDiscovery;
|
|
7838
|
+
if (!endpointDiscovery) return [check$1("endpoints", {
|
|
7839
|
+
id: "agent-endpoints-evidence",
|
|
7840
|
+
status: "unknown",
|
|
7841
|
+
title: "Agent endpoint evidence was not collected",
|
|
7842
|
+
plainEnglish: "This saved crawl predates the Link header and well-known endpoint probes, so that evidence is unavailable.",
|
|
7843
|
+
action: "Run the agent-readiness report with a URL to collect endpoint evidence."
|
|
7844
|
+
})];
|
|
7845
|
+
const byId = new Map(endpointDiscovery.endpoints.map((endpoint) => [endpoint.id, endpoint]));
|
|
7846
|
+
const pick = (...ids) => ids.map((id) => byId.get(id)).filter((endpoint) => Boolean(endpoint));
|
|
7847
|
+
const linkHeader = endpointDiscovery.linkHeader;
|
|
7848
|
+
const advertisedRels = [...linkHeader.registeredRels, ...linkHeader.emergingRels];
|
|
7849
|
+
return [
|
|
7850
|
+
check$1("endpoints", {
|
|
7851
|
+
id: "link-headers",
|
|
7852
|
+
status: linkHeader.error ? "unknown" : advertisedRels.length > 0 ? "pass" : "info",
|
|
7853
|
+
title: linkHeader.error ? "Link header evidence was not collected" : advertisedRels.length > 0 ? "The start page Link header advertises agent resources" : linkHeader.entries.length > 0 ? "Link headers exist but advertise no agent resources" : "No Link header advertises agent resources",
|
|
7854
|
+
plainEnglish: linkHeader.error ? `The start page request failed: ${linkHeader.error}` : advertisedRels.length > 0 ? `${linkHeader.entries.length} Link header ${linkHeader.entries.length === 1 ? "entry was" : "entries were"} observed. Registered relation types: ${linkHeader.registeredRels.join(", ") || "none"}. Emerging relation types: ${linkHeader.emergingRels.join(", ") || "none"}.` : linkHeader.entries.length > 0 ? `${linkHeader.entries.length} Link header ${linkHeader.entries.length === 1 ? "entry was" : "entries were"} observed, but none used a recognised agent relation type such as api-catalog, service-desc, service-doc, describedby, llms-txt, or agent-skills.` : "The start page response includes no Link header. Advertising machine-readable resources this way is optional.",
|
|
7855
|
+
action: "Advertise real machine-readable resources with Link response headers. Use registered relation types such as api-catalog and service-desc, and emerging types such as llms-txt only when the target resolves.",
|
|
7856
|
+
evidence: linkHeader
|
|
7857
|
+
}),
|
|
7858
|
+
optionalEndpointCheck(pick("mcp-server-card", "mcp-server-cards"), {
|
|
7859
|
+
id: "mcp-server-card",
|
|
7860
|
+
subject: "An MCP server card",
|
|
7861
|
+
absent: "Neither /.well-known/mcp/server-card.json nor server-cards.json returned a machine-readable document. The server card format is a draft MCP proposal, and a content site does not need one.",
|
|
7862
|
+
absentAction: "Publish a server card only when the site exposes a real public MCP endpoint.",
|
|
7863
|
+
presentAction: "Keep serverInfo, the transport endpoint, and capabilities accurate for the deployed MCP server.",
|
|
7864
|
+
requiredNote: "The server card format is a draft MCP proposal, so this is a structural observation rather than a compliance verdict."
|
|
7865
|
+
}),
|
|
7866
|
+
optionalEndpointCheck(pick("a2a-agent-card"), {
|
|
7867
|
+
id: "a2a-agent-card",
|
|
7868
|
+
subject: "An A2A agent card",
|
|
7869
|
+
absent: "/.well-known/agent-card.json returned no machine-readable document. Agent cards matter only for sites that expose an agent-to-agent endpoint.",
|
|
7870
|
+
absentAction: "Publish an agent card only when the site hosts a real A2A endpoint.",
|
|
7871
|
+
presentAction: "Keep the agent card name, url, and capabilities aligned with the deployed endpoint.",
|
|
7872
|
+
requiredNote: "A2A is an emerging protocol, so this is a structural observation rather than a compliance verdict."
|
|
7873
|
+
}),
|
|
7874
|
+
optionalEndpointCheck(pick("openid-configuration", "oauth-authorization-server"), {
|
|
7875
|
+
id: "oauth-discovery",
|
|
7876
|
+
subject: "OAuth or OpenID Connect discovery metadata",
|
|
7877
|
+
absent: "No authorization-server metadata was found at /.well-known/openid-configuration or /.well-known/oauth-authorization-server. That only matters when the site exposes protected APIs that agents authenticate with.",
|
|
7878
|
+
absentAction: "Publish RFC 8414 or OpenID Connect discovery metadata only when a real authorization server issues tokens for this origin.",
|
|
7879
|
+
presentAction: "Keep the issuer and endpoint URLs accurate so clients can discover how to authenticate.",
|
|
7880
|
+
requiredNote: "Structural presence of issuer metadata does not prove the authorization flow works."
|
|
7881
|
+
}),
|
|
7882
|
+
optionalEndpointCheck(pick("oauth-protected-resource"), {
|
|
7883
|
+
id: "oauth-protected-resource",
|
|
7884
|
+
subject: "OAuth protected resource metadata",
|
|
7885
|
+
absent: "/.well-known/oauth-protected-resource returned no metadata. RFC 9728 metadata matters only for origins that serve OAuth-protected APIs.",
|
|
7886
|
+
absentAction: "Publish protected resource metadata only when this origin actually serves OAuth-protected APIs.",
|
|
7887
|
+
presentAction: "Keep the resource identifier and authorization_servers list accurate.",
|
|
7888
|
+
requiredNote: "Structural presence does not prove tokens are issued or accepted."
|
|
7889
|
+
}),
|
|
7890
|
+
optionalEndpointCheck(pick("api-catalog"), {
|
|
7891
|
+
id: "api-catalog",
|
|
7892
|
+
subject: "An API catalog",
|
|
7893
|
+
absent: "/.well-known/api-catalog returned no linkset document. An RFC 9727 catalog matters only for sites that publish APIs.",
|
|
7894
|
+
absentAction: "Publish an API catalog only when the site has real APIs.",
|
|
7895
|
+
presentAction: "Keep the linkset entries pointing at live service-desc and service-doc resources.",
|
|
7896
|
+
requiredNote: "The catalog was checked for structure only, not for whether each linked API works."
|
|
7897
|
+
}),
|
|
7898
|
+
optionalEndpointCheck(pick("web-bot-auth-directory"), {
|
|
7899
|
+
id: "web-bot-auth",
|
|
7900
|
+
subject: "A Web Bot Auth key directory",
|
|
7901
|
+
absent: "/.well-known/http-message-signatures-directory returned no key set. Publishing one matters only for operators that sign their own outbound bot requests.",
|
|
7902
|
+
absentAction: "Publish a signature directory only when this origin operates bots that sign requests with Web Bot Auth.",
|
|
7903
|
+
presentAction: "Keep the published keys current and rotate them safely.",
|
|
7904
|
+
requiredNote: "Web Bot Auth is an emerging draft, so this is a structural observation rather than a compliance verdict."
|
|
7905
|
+
}),
|
|
7906
|
+
optionalEndpointCheck(pick("auth-md"), {
|
|
7907
|
+
id: "auth-md",
|
|
7908
|
+
subject: "An auth.md registration guide",
|
|
7909
|
+
absent: "/auth.md returned no document. auth.md is an emerging proposal for telling agents how to register and authenticate, and most content sites do not need one.",
|
|
7910
|
+
absentAction: "Add auth.md only when agents can genuinely register for and authenticate with this site.",
|
|
7911
|
+
presentAction: "Keep the registration steps accurate and pair the file with real OAuth discovery metadata.",
|
|
7912
|
+
requiredNote: "auth.md is an emerging proposal, so this is a presence observation only."
|
|
7913
|
+
}),
|
|
7914
|
+
optionalEndpointCheck(pick("llms-full-txt"), {
|
|
7915
|
+
id: "llms-full-txt",
|
|
7916
|
+
subject: "llms-full.txt",
|
|
7917
|
+
absent: "/llms-full.txt returned no document. The expanded companion to llms.txt is optional, and its absence is not a search ranking problem.",
|
|
7918
|
+
absentAction: "Add llms-full.txt only when an intended consumer wants the full expanded content in one file.",
|
|
7919
|
+
presentAction: "Keep the file deterministic and consistent with the curated llms.txt entry points.",
|
|
7920
|
+
requiredNote: "Only presence and content type were checked, not the quality of the expanded content."
|
|
7921
|
+
})
|
|
7922
|
+
];
|
|
7923
|
+
}
|
|
6924
7924
|
function accessChecks(report, discovery) {
|
|
6925
7925
|
const bots = report.ai?.robotsTxt?.botAccess ?? [];
|
|
6926
7926
|
const blocked = bots.filter((bot) => bot.allowed === false);
|
|
@@ -7022,7 +8022,8 @@ function agentReadiness(report) {
|
|
|
7022
8022
|
section$1("profile", "Profile scope", profileChecks(discovery)),
|
|
7023
8023
|
section$1("access", "Crawler and protocol access", accessChecks(report, discovery)),
|
|
7024
8024
|
section$1("representations", "HTML and Markdown representations", markdownChecks(discovery)),
|
|
7025
|
-
section$1("discovery", "Agent discovery files", discoveryChecks(discovery)),
|
|
8025
|
+
section$1("discovery", "Agent discovery files", discoveryChecks(discovery, report.ai?.robotsTxt?.contentSignals)),
|
|
8026
|
+
section$1("endpoints", "Agent endpoints and auth discovery", endpointChecks(discovery)),
|
|
7026
8027
|
section$1("identity", "Identity evidence", identityChecks(report))
|
|
7027
8028
|
];
|
|
7028
8029
|
const checks = sections.flatMap((item) => item.checks);
|
|
@@ -7059,6 +8060,7 @@ function agentReadiness(report) {
|
|
|
7059
8060
|
...discovery.warnings,
|
|
7060
8061
|
"A clean content profile does not prove indexing, rankings, AI mentions, citations, or selection.",
|
|
7061
8062
|
"A crawler token being allowed does not prove that a service fetched or used the page.",
|
|
8063
|
+
"MCP server cards, A2A agent cards, Web Bot Auth directories, and auth.md are emerging conventions. Their absence is an observation, not a defect.",
|
|
7062
8064
|
"API, application, and commerce checks were not applicable to this content-site run."
|
|
7063
8065
|
]
|
|
7064
8066
|
};
|
|
@@ -7660,6 +8662,15 @@ function originHost(url) {
|
|
|
7660
8662
|
function cleanTitle(page) {
|
|
7661
8663
|
return page.title ?? page.h1 ?? (new URL(page.finalUrl).pathname || page.finalUrl);
|
|
7662
8664
|
}
|
|
8665
|
+
function truncateAtWord(value, maxLength) {
|
|
8666
|
+
const normalized = value.replace(/\s+/g, " ").trim();
|
|
8667
|
+
if (normalized.length <= maxLength) return normalized;
|
|
8668
|
+
const room = Math.max(1, maxLength - 3);
|
|
8669
|
+
const prefix = normalized.slice(0, room);
|
|
8670
|
+
const boundary = prefix.lastIndexOf(" ");
|
|
8671
|
+
if (boundary < 0) return "...";
|
|
8672
|
+
return `${prefix.slice(0, boundary).trimEnd()}...`;
|
|
8673
|
+
}
|
|
7663
8674
|
function sectionForPage(page) {
|
|
7664
8675
|
const path = new URL(page.finalUrl).pathname.toLowerCase();
|
|
7665
8676
|
if (/\/(docs|documentation|guide|guides|help|kb|learn)\b/.test(path)) return "Docs and guides";
|
|
@@ -7792,7 +8803,7 @@ function generateLlmsTxt(report, options = {}) {
|
|
|
7792
8803
|
let sectionCount = 0;
|
|
7793
8804
|
for (const page of sectionPages) {
|
|
7794
8805
|
const title = cleanTitle(page).replace(/\]/g, "\\]");
|
|
7795
|
-
const note = page.metaDescription ? ` - ${page.metaDescription
|
|
8806
|
+
const note = page.metaDescription ? ` - ${truncateAtWord(page.metaDescription, 160)}` : "";
|
|
7796
8807
|
const line = `- [${title}](${page.finalUrl})${note}`;
|
|
7797
8808
|
if (estimatedTokens([
|
|
7798
8809
|
...lines,
|
|
@@ -7810,14 +8821,29 @@ function generateLlmsTxt(report, options = {}) {
|
|
|
7810
8821
|
counts[sectionName] = sectionCount;
|
|
7811
8822
|
if (sectionCount < sectionPages.length) break;
|
|
7812
8823
|
}
|
|
7813
|
-
lines.push("## Notes", "", "- Optional metadata for agents and services that explicitly support llms.txt.", "- [Google Search guidance](https://developers.google.com/search/updates#clarifying-guidance-on-llms-txt-files) says llms.txt does not affect visibility or rankings.", `- Generated from crawl report ${report.id}.`, `- Crawl generated at ${report.generatedAt}.`);
|
|
8824
|
+
lines.push("## Notes", "", "- Optional metadata for agents and services that explicitly support llms.txt.", "- [Google Search guidance](https://developers.google.com/search/updates#clarifying-guidance-on-llms-txt-files) says llms.txt does not affect visibility or rankings.", `- Generated from crawl report ${report.id}.`, `- Crawl generated at ${report.generatedAt}.`, `- Crawl coverage: ${report.status}; retained ${report.summary.crawledUrls} of ${report.summary.discoveredUrls} discovered URLs.`);
|
|
8825
|
+
if (report.sitemapDiscovery) lines.push(`- Sitemap discovery: ${report.sitemapDiscovery.dataStatus}; returned ${report.sitemapDiscovery.urlsReturned} URLs.`);
|
|
7814
8826
|
if (report.caveats.length) lines.push(...report.caveats.map((caveat) => `- Caveat: ${caveat}`));
|
|
8827
|
+
if (report.warnings.length) {
|
|
8828
|
+
lines.push(...report.warnings.slice(0, 10).map((warning) => `- Source warning: ${warning}`));
|
|
8829
|
+
if (report.warnings.length > 10) lines.push(`- Source warnings: ${report.warnings.length - 10} more warnings remain in crawl report ${report.id}.`);
|
|
8830
|
+
}
|
|
7815
8831
|
const content = `${lines.join("\n")}\n`;
|
|
7816
8832
|
return {
|
|
7817
8833
|
content,
|
|
7818
8834
|
includedUrls,
|
|
7819
8835
|
estimatedTokens: estimatedTokens(content),
|
|
7820
|
-
sections: counts
|
|
8836
|
+
sections: counts,
|
|
8837
|
+
source: {
|
|
8838
|
+
reportId: report.id,
|
|
8839
|
+
status: report.status,
|
|
8840
|
+
crawledUrls: report.summary.crawledUrls,
|
|
8841
|
+
discoveredUrls: report.summary.discoveredUrls,
|
|
8842
|
+
sitemapUrlsReturned: report.sitemapDiscovery?.urlsReturned,
|
|
8843
|
+
warnings: report.warnings.slice(0, 10),
|
|
8844
|
+
warningCount: report.warnings.length,
|
|
8845
|
+
warningsTruncated: report.warnings.length > 10
|
|
8846
|
+
}
|
|
7821
8847
|
};
|
|
7822
8848
|
}
|
|
7823
8849
|
//#endregion
|
|
@@ -8429,6 +9455,24 @@ const RULE_DEFINITIONS = [
|
|
|
8429
9455
|
impactIfIgnored: "The page cannot be crawled, ranked, or cited while the connection failure remains.",
|
|
8430
9456
|
howToVerify: "Re-run the crawl and confirm the URL returns a normal HTTP status instead of status 0."
|
|
8431
9457
|
},
|
|
9458
|
+
{
|
|
9459
|
+
id: "crawler_access_blocked",
|
|
9460
|
+
title: "Crawler access blocked",
|
|
9461
|
+
category: "response",
|
|
9462
|
+
defaultSeverity: "medium",
|
|
9463
|
+
whyItMatters: "The audit could not observe the intended page because an authentication layer, rate limit, firewall, or challenge intercepted this crawler. This limits the audit evidence but does not prove that search engines or users are blocked.",
|
|
9464
|
+
howToFix: "First confirm the block is unintended. If the audit should have access, use the provider evidence in the report to grant temporary, narrow access for the audit machine source IP, required hostname or paths, and the exact crawler User-Agent. Do not trust a User-Agent by itself.",
|
|
9465
|
+
impactIfIgnored: "The affected URL remains unverified in this crawl, so content and indexability checks cannot support a pass or an all-clear.",
|
|
9466
|
+
howToVerify: "Re-run the same health check and confirm access.blockedRequests is zero and the URL returns its intended status without challenge evidence.",
|
|
9467
|
+
agentHints: {
|
|
9468
|
+
evidenceFields: [
|
|
9469
|
+
"request.accessBlock",
|
|
9470
|
+
"report.access",
|
|
9471
|
+
"issue.evidence.accessBlock"
|
|
9472
|
+
],
|
|
9473
|
+
suggestedCommands: ["seo crawl --sitemap-url <url> --health --json"]
|
|
9474
|
+
}
|
|
9475
|
+
},
|
|
8432
9476
|
{
|
|
8433
9477
|
id: "client_error",
|
|
8434
9478
|
title: "Client error",
|
|
@@ -8917,6 +9961,7 @@ const RULE_RECOMMENDATIONS = {
|
|
|
8917
9961
|
nofollow: "review",
|
|
8918
9962
|
x_robots_noindex: "review",
|
|
8919
9963
|
robots_blocked: "review",
|
|
9964
|
+
crawler_access_blocked: "review",
|
|
8920
9965
|
orphan_page: "review",
|
|
8921
9966
|
redirected_url: "review",
|
|
8922
9967
|
slow_response: "review",
|
|
@@ -9135,6 +10180,14 @@ function auditCrawlRequests(requests) {
|
|
|
9135
10180
|
continue;
|
|
9136
10181
|
}
|
|
9137
10182
|
if (request.outcome === "skipped") continue;
|
|
10183
|
+
if (request.accessBlock) {
|
|
10184
|
+
issues.push(requestIssue("crawler_access_blocked", request, request.accessBlock.guidance.summary, {
|
|
10185
|
+
requestedUrl: request.requestedUrl,
|
|
10186
|
+
finalUrl: request.finalUrl,
|
|
10187
|
+
accessBlock: request.accessBlock
|
|
10188
|
+
}));
|
|
10189
|
+
continue;
|
|
10190
|
+
}
|
|
9138
10191
|
const redirectChain = request.redirectChain ?? [];
|
|
9139
10192
|
if (redirectChain.length > 0 || !sameUrl(request.requestedUrl, request.finalUrl)) issues.push(requestIssue("redirected_url", request, request.finalUrl ? `Final URL: ${request.finalUrl}` : void 0, {
|
|
9140
10193
|
requestedUrl: request.requestedUrl,
|
|
@@ -9170,6 +10223,7 @@ function auditCrawlPages(pages, opts = {}) {
|
|
|
9170
10223
|
}));
|
|
9171
10224
|
continue;
|
|
9172
10225
|
}
|
|
10226
|
+
if (page.accessBlock) continue;
|
|
9173
10227
|
if (page.status >= 500) {
|
|
9174
10228
|
issues.push(issue("server_error", page, String(page.status), { status: page.status }));
|
|
9175
10229
|
if ((page.internalInlinkCount ?? 0) > 0) issues.push(issue("broken_internal_link", page, String(page.status), {
|
|
@@ -9210,6 +10264,7 @@ function auditCrawlPages(pages, opts = {}) {
|
|
|
9210
10264
|
responseTimeMs: page.responseTimeMs,
|
|
9211
10265
|
thresholdMs: SLOW_RESPONSE_MS
|
|
9212
10266
|
}));
|
|
10267
|
+
if (page.auditScope === "status") continue;
|
|
9213
10268
|
if (redirected) continue;
|
|
9214
10269
|
if (isHtmlPage(page) && (page.sizeBytes ?? 0) > LARGE_HTML_BYTES) issues.push(issue("large_html", page, `${Math.round((page.sizeBytes ?? 0) / 1024)} KB`, {
|
|
9215
10270
|
sizeBytes: page.sizeBytes,
|
|
@@ -9445,6 +10500,9 @@ function crawlSkipReasonCountsFromRecord(input = {}) {
|
|
|
9445
10500
|
}
|
|
9446
10501
|
//#endregion
|
|
9447
10502
|
//#region packages/core/dist/analyze/crawler/report.js
|
|
10503
|
+
const MAX_CRAWL_PAGES = 1e4;
|
|
10504
|
+
const MAX_CRAWL_CONCURRENCY = 16;
|
|
10505
|
+
const MAX_CRAWL_TIMEOUT_MS = 12e4;
|
|
9448
10506
|
const SENSITIVE_FIELD_NAMES = /* @__PURE__ */ new Set([
|
|
9449
10507
|
"authorization",
|
|
9450
10508
|
"cookie",
|
|
@@ -9460,33 +10518,53 @@ const SENSITIVE_PARAM_PATTERN = /^(access_?token|auth|api_?key|client_?secret|co
|
|
|
9460
10518
|
function uniqueSorted(values = []) {
|
|
9461
10519
|
return [...new Set(values.filter(Boolean))].sort();
|
|
9462
10520
|
}
|
|
9463
|
-
function normalizeFetchRate(input) {
|
|
10521
|
+
function normalizeFetchRate(input, strategy) {
|
|
10522
|
+
const defaultConcurrency = strategy === "health" ? 4 : 8;
|
|
9464
10523
|
return {
|
|
9465
|
-
concurrency: input.fetchRate?.concurrency ?? input.concurrency ??
|
|
10524
|
+
concurrency: input.fetchRate?.concurrency ?? input.concurrency ?? defaultConcurrency,
|
|
9466
10525
|
...input.fetchRate?.intervalCap !== void 0 ? { intervalCap: input.fetchRate.intervalCap } : {},
|
|
9467
10526
|
...input.fetchRate?.intervalMs !== void 0 ? { intervalMs: input.fetchRate.intervalMs } : {},
|
|
9468
10527
|
...input.fetchRate?.backpressure ? { backpressure: input.fetchRate.backpressure } : {}
|
|
9469
10528
|
};
|
|
9470
10529
|
}
|
|
9471
10530
|
function normalizeCrawlConfig(input) {
|
|
10531
|
+
const strategy = input.strategy ?? "full";
|
|
10532
|
+
const concurrency = input.concurrency ?? (strategy === "health" ? 4 : 8);
|
|
9472
10533
|
return {
|
|
9473
10534
|
url: new URL(input.url).toString(),
|
|
9474
|
-
mode: input.mode ?? "site",
|
|
10535
|
+
mode: input.mode ?? (strategy === "health" ? "sitemap" : "site"),
|
|
10536
|
+
strategy,
|
|
10537
|
+
...input.sitemapUrl ? { sitemapUrl: new URL(input.sitemapUrl).toString() } : {},
|
|
9475
10538
|
urls: uniqueSorted(input.urls ?? []),
|
|
9476
10539
|
maxPages: input.maxPages ?? 500,
|
|
9477
10540
|
maxDepth: input.maxDepth ?? 16,
|
|
9478
|
-
concurrency
|
|
10541
|
+
concurrency,
|
|
9479
10542
|
timeoutMs: input.timeoutMs ?? 2e4,
|
|
9480
10543
|
include: uniqueSorted(input.include ?? []),
|
|
9481
10544
|
exclude: uniqueSorted(input.exclude ?? []),
|
|
9482
10545
|
respectRobots: input.respectRobots ?? true,
|
|
9483
10546
|
useSitemap: input.useSitemap ?? true,
|
|
9484
|
-
checkExternal: input.checkExternal ?? true,
|
|
9485
|
-
checkAgentDiscovery: input.checkAgentDiscovery ?? false,
|
|
9486
|
-
js: normalizeJavaScriptRenderingMode(input.js),
|
|
9487
|
-
refresh: input.refresh ?? false,
|
|
9488
|
-
fetchRate: normalizeFetchRate(input)
|
|
9489
|
-
};
|
|
10547
|
+
checkExternal: strategy === "health" ? false : input.checkExternal ?? true,
|
|
10548
|
+
checkAgentDiscovery: strategy === "health" ? false : input.checkAgentDiscovery ?? false,
|
|
10549
|
+
js: strategy === "health" ? "off" : normalizeJavaScriptRenderingMode(input.js),
|
|
10550
|
+
refresh: strategy === "health" ? true : input.refresh ?? false,
|
|
10551
|
+
fetchRate: normalizeFetchRate(input, strategy)
|
|
10552
|
+
};
|
|
10553
|
+
}
|
|
10554
|
+
function assertIntegerRange(value, label, minimum, maximum) {
|
|
10555
|
+
if (!Number.isInteger(value) || value < minimum || value > maximum) throw new RangeError(`${label} must be an integer from ${minimum} to ${maximum}.`);
|
|
10556
|
+
}
|
|
10557
|
+
function assertCrawlConfigLimits(config) {
|
|
10558
|
+
if (config.strategy === "health" && config.mode !== "sitemap") throw new RangeError("The health strategy requires sitemap mode.");
|
|
10559
|
+
if (config.strategy === "health" && !config.useSitemap) throw new RangeError("The health strategy requires sitemap discovery.");
|
|
10560
|
+
assertIntegerRange(config.maxPages, "maxPages", 1, MAX_CRAWL_PAGES);
|
|
10561
|
+
assertIntegerRange(config.maxDepth, "maxDepth", 0, 64);
|
|
10562
|
+
assertIntegerRange(config.concurrency, "concurrency", 1, 16);
|
|
10563
|
+
assertIntegerRange(config.timeoutMs, "timeoutMs", 1, MAX_CRAWL_TIMEOUT_MS);
|
|
10564
|
+
if (config.fetchRate.concurrency !== void 0) assertIntegerRange(config.fetchRate.concurrency, "fetchRate.concurrency", 1, 16);
|
|
10565
|
+
if (config.strategy === "health" && config.mode !== "sitemap") throw new RangeError("Health strategy requires sitemap mode.");
|
|
10566
|
+
if (config.sitemapUrl && config.mode !== "sitemap") throw new RangeError("sitemapUrl requires sitemap mode.");
|
|
10567
|
+
return config;
|
|
9490
10568
|
}
|
|
9491
10569
|
function crawlConfigHash(config) {
|
|
9492
10570
|
const normalized = normalizeCrawlConfig(config);
|
|
@@ -9603,10 +10681,12 @@ function summarizeCrawlReport(input) {
|
|
|
9603
10681
|
}
|
|
9604
10682
|
for (const issue of input.issues) byCategory[issue.category] = (byCategory[issue.category] ?? 0) + 1;
|
|
9605
10683
|
const runStats = crawlRunStats(input.pages, input.stats);
|
|
10684
|
+
const indexabilityPages = input.pages.filter((page) => page.auditScope !== "status");
|
|
9606
10685
|
return {
|
|
9607
10686
|
totalPages: input.pages.length,
|
|
9608
|
-
|
|
9609
|
-
|
|
10687
|
+
statusOnlyPages: input.pages.filter((page) => page.auditScope === "status").length,
|
|
10688
|
+
indexablePages: indexabilityPages.filter((page) => page.indexable).length,
|
|
10689
|
+
nonIndexablePages: indexabilityPages.filter((page) => !page.indexable).length,
|
|
9610
10690
|
statusErrors: input.pages.filter((page) => page.status === 0 || page.status >= 400).length,
|
|
9611
10691
|
...runStats,
|
|
9612
10692
|
attemptedRequests: input.requestEvidenceStatus === "unavailable" ? 0 : requests.length,
|
|
@@ -9624,6 +10704,23 @@ function summarizeCrawlReport(input) {
|
|
|
9624
10704
|
byCategory
|
|
9625
10705
|
};
|
|
9626
10706
|
}
|
|
10707
|
+
const ACCESS_BLOCK_SAMPLE_LIMIT = 10;
|
|
10708
|
+
function crawlAccessSummary(requests) {
|
|
10709
|
+
const blocked = requests.flatMap((request) => request.outcome === "response" && request.accessBlock ? [{
|
|
10710
|
+
url: request.requestedUrl,
|
|
10711
|
+
evidence: request.accessBlock
|
|
10712
|
+
}] : []);
|
|
10713
|
+
const providers = {};
|
|
10714
|
+
for (const item of blocked) providers[item.evidence.provider] = (providers[item.evidence.provider] ?? 0) + 1;
|
|
10715
|
+
return {
|
|
10716
|
+
crawler: SEO_CRAWLER_IDENTITY,
|
|
10717
|
+
blockedRequests: blocked.length,
|
|
10718
|
+
providers,
|
|
10719
|
+
samples: blocked.slice(0, ACCESS_BLOCK_SAMPLE_LIMIT),
|
|
10720
|
+
sampleLimit: ACCESS_BLOCK_SAMPLE_LIMIT,
|
|
10721
|
+
truncated: blocked.length > ACCESS_BLOCK_SAMPLE_LIMIT
|
|
10722
|
+
};
|
|
10723
|
+
}
|
|
9627
10724
|
function crawlRunStats(pages, stats = {}) {
|
|
9628
10725
|
const discovered = /* @__PURE__ */ new Set();
|
|
9629
10726
|
let observedInternalLinks = 0;
|
|
@@ -9717,6 +10814,7 @@ function createCrawlReport(input) {
|
|
|
9717
10814
|
status: input.status ?? "completed",
|
|
9718
10815
|
configHash: crawlConfigHash(config),
|
|
9719
10816
|
config,
|
|
10817
|
+
access: crawlAccessSummary(requests),
|
|
9720
10818
|
summary: summarizeCrawlReport({
|
|
9721
10819
|
pages,
|
|
9722
10820
|
requests,
|
|
@@ -9739,16 +10837,19 @@ function createCrawlReport(input) {
|
|
|
9739
10837
|
};
|
|
9740
10838
|
}
|
|
9741
10839
|
function normalizeLoadedCrawlReport(report) {
|
|
10840
|
+
const config = sanitizeCrawlConfig(normalizeCrawlConfig(report.config));
|
|
9742
10841
|
const requestEvidenceStatus = report.requestEvidenceStatus ?? (Object.hasOwn(report, "requests") ? "available" : "unavailable");
|
|
9743
10842
|
const issues = sanitizeIssues(report.issues ?? []);
|
|
9744
10843
|
const requests = sortRequests(sanitizeTenantValue(report.requests ?? []) ?? []);
|
|
9745
10844
|
const pages = sortPages(sanitizePages(deriveInternalLinkAuthority(report.pages ?? [])));
|
|
9746
10845
|
return {
|
|
9747
10846
|
...sanitizeTenantValue(report),
|
|
10847
|
+
config,
|
|
10848
|
+
access: crawlAccessSummary(requests),
|
|
9748
10849
|
requestEvidenceStatus,
|
|
9749
10850
|
requests,
|
|
9750
10851
|
definitionId: report.definitionId ?? crawlDefinitionId({
|
|
9751
|
-
config
|
|
10852
|
+
config,
|
|
9752
10853
|
site: report.site,
|
|
9753
10854
|
googleAnalyticsPropertyId: report.googleAnalyticsPropertyId
|
|
9754
10855
|
}),
|
|
@@ -9804,14 +10905,61 @@ function reportFromJson(value) {
|
|
|
9804
10905
|
if (parsed && typeof parsed === "object" && "kind" in parsed && parsed.kind === "seo.crawl_report" && "report" in parsed) return normalizeLoadedCrawlReport(parsed.report);
|
|
9805
10906
|
return normalizeLoadedCrawlReport(parsed);
|
|
9806
10907
|
}
|
|
9807
|
-
function
|
|
9808
|
-
|
|
9809
|
-
|
|
9810
|
-
|
|
9811
|
-
|
|
9812
|
-
|
|
10908
|
+
function pruneAutomaticBaselines() {
|
|
10909
|
+
getDb().prepare(`WITH ranked AS (
|
|
10910
|
+
SELECT id,
|
|
10911
|
+
ROW_NUMBER() OVER (
|
|
10912
|
+
PARTITION BY site_url
|
|
10913
|
+
ORDER BY created_at DESC, id DESC
|
|
10914
|
+
) AS site_rank,
|
|
10915
|
+
ROW_NUMBER() OVER (
|
|
10916
|
+
ORDER BY created_at DESC, id DESC
|
|
10917
|
+
) AS total_rank
|
|
10918
|
+
FROM crawl_reports
|
|
10919
|
+
WHERE retention_class = 'baseline'
|
|
10920
|
+
)
|
|
10921
|
+
DELETE FROM crawl_reports
|
|
10922
|
+
WHERE id IN (
|
|
10923
|
+
SELECT id FROM ranked
|
|
10924
|
+
WHERE site_rank > ? OR total_rank > ?
|
|
10925
|
+
)`).run(2, 50);
|
|
10926
|
+
}
|
|
10927
|
+
function crawlReportSiteWhere(value) {
|
|
10928
|
+
const siteValues = /* @__PURE__ */ new Set([value]);
|
|
10929
|
+
const origins = /* @__PURE__ */ new Set();
|
|
10930
|
+
try {
|
|
10931
|
+
origins.add(new URL(value).origin);
|
|
10932
|
+
} catch {
|
|
10933
|
+
const host = value.replace(/^sc-domain:/, "").replace(/\/$/, "");
|
|
10934
|
+
if (host && !host.includes("/")) {
|
|
10935
|
+
siteValues.add(`sc-domain:${host}`);
|
|
10936
|
+
origins.add(`https://${host}`);
|
|
10937
|
+
origins.add(`http://${host}`);
|
|
10938
|
+
}
|
|
10939
|
+
}
|
|
10940
|
+
const clauses = [...siteValues].map(() => "site_url = ?");
|
|
10941
|
+
const params = [...siteValues];
|
|
10942
|
+
for (const origin of origins) {
|
|
10943
|
+
clauses.push("url = ?", "url LIKE ?");
|
|
10944
|
+
params.push(`${origin}/`, `${origin}/%`);
|
|
10945
|
+
}
|
|
10946
|
+
return {
|
|
10947
|
+
sql: `(${clauses.join(" OR ")})`,
|
|
10948
|
+
params
|
|
10949
|
+
};
|
|
10950
|
+
}
|
|
10951
|
+
function saveCrawlReportToSqlite(report, options = {}) {
|
|
10952
|
+
const database = getDb();
|
|
10953
|
+
const retention = options.retention ?? "saved";
|
|
10954
|
+
if (database.prepare(`INSERT INTO crawl_reports
|
|
10955
|
+
(id, config_hash, site_url, url, status, total_pages, issue_count, created_at,
|
|
10956
|
+
retention_class, report_json)
|
|
10957
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
10958
|
+
ON CONFLICT(id) DO NOTHING`).run(report.id, report.configHash, report.site ?? null, report.config.url, report.status, report.summary.totalPages, report.issues.length, Date.parse(report.generatedAt), retention, JSON.stringify(storageEnvelope(report))).changes === 0) {
|
|
10959
|
+
const existing = database.prepare("SELECT * FROM crawl_reports WHERE id = ?").get(report.id);
|
|
9813
10960
|
if (existing) return toMeta(existing);
|
|
9814
10961
|
}
|
|
10962
|
+
if (retention === "baseline") pruneAutomaticBaselines();
|
|
9815
10963
|
return {
|
|
9816
10964
|
id: report.id,
|
|
9817
10965
|
configHash: report.configHash,
|
|
@@ -9826,10 +10974,13 @@ function saveCrawlReportToSqlite(report) {
|
|
|
9826
10974
|
}
|
|
9827
10975
|
function listCrawlReportsFromSqlite(input = {}) {
|
|
9828
10976
|
const limit = input.limit ?? 20;
|
|
9829
|
-
return (input.site ?
|
|
9830
|
-
|
|
9831
|
-
|
|
9832
|
-
|
|
10977
|
+
return (input.site ? (() => {
|
|
10978
|
+
const where = crawlReportSiteWhere(input.site);
|
|
10979
|
+
return getDb().prepare(`SELECT * FROM crawl_reports
|
|
10980
|
+
WHERE ${where.sql}
|
|
10981
|
+
ORDER BY created_at DESC, id DESC
|
|
10982
|
+
LIMIT ?`).all(...where.params, limit);
|
|
10983
|
+
})() : getDb().prepare(`SELECT * FROM crawl_reports
|
|
9833
10984
|
ORDER BY created_at DESC, id DESC
|
|
9834
10985
|
LIMIT ?`).all(limit)).map(toMeta);
|
|
9835
10986
|
}
|
|
@@ -9842,10 +10993,13 @@ function deleteCrawlReportFromSqlite(id) {
|
|
|
9842
10993
|
return getDb().prepare("DELETE FROM crawl_reports WHERE id = ?").run(id).changes > 0;
|
|
9843
10994
|
}
|
|
9844
10995
|
function latestCrawlReportFromSqlite(site) {
|
|
9845
|
-
const row = site ?
|
|
9846
|
-
|
|
9847
|
-
|
|
9848
|
-
|
|
10996
|
+
const row = site ? (() => {
|
|
10997
|
+
const where = crawlReportSiteWhere(site);
|
|
10998
|
+
return getDb().prepare(`SELECT report_json FROM crawl_reports
|
|
10999
|
+
WHERE ${where.sql}
|
|
11000
|
+
ORDER BY created_at DESC, id DESC
|
|
11001
|
+
LIMIT 1`).get(...where.params);
|
|
11002
|
+
})() : getDb().prepare(`SELECT report_json FROM crawl_reports
|
|
9849
11003
|
ORDER BY created_at DESC, id DESC
|
|
9850
11004
|
LIMIT 1`).get();
|
|
9851
11005
|
if (!row) return void 0;
|
|
@@ -9858,8 +11012,8 @@ const crawlReportStore = {
|
|
|
9858
11012
|
delete: deleteCrawlReportFromSqlite,
|
|
9859
11013
|
latest: latestCrawlReportFromSqlite
|
|
9860
11014
|
};
|
|
9861
|
-
function saveCrawlReport(report, store = crawlReportStore) {
|
|
9862
|
-
return store.save(report);
|
|
11015
|
+
function saveCrawlReport(report, store = crawlReportStore, options = {}) {
|
|
11016
|
+
return store.save(report, options);
|
|
9863
11017
|
}
|
|
9864
11018
|
function listCrawlReports(input = {}, store = crawlReportStore) {
|
|
9865
11019
|
return store.list(input);
|
|
@@ -9902,6 +11056,86 @@ function cancellationRace(signal) {
|
|
|
9902
11056
|
});
|
|
9903
11057
|
}
|
|
9904
11058
|
//#endregion
|
|
11059
|
+
//#region packages/core/dist/analyze/crawler/crawl-url-queue.js
|
|
11060
|
+
function before(left, right) {
|
|
11061
|
+
return left.depth < right.depth || left.depth === right.depth && left.url < right.url;
|
|
11062
|
+
}
|
|
11063
|
+
var CrawlUrlQueue = class {
|
|
11064
|
+
#items = [];
|
|
11065
|
+
#positions = /* @__PURE__ */ new Map();
|
|
11066
|
+
get size() {
|
|
11067
|
+
return this.#items.length;
|
|
11068
|
+
}
|
|
11069
|
+
has(url) {
|
|
11070
|
+
return this.#positions.has(url);
|
|
11071
|
+
}
|
|
11072
|
+
push(item) {
|
|
11073
|
+
if (this.has(item.url)) throw new Error(`URL is already queued: ${item.url}`);
|
|
11074
|
+
const index = this.#items.length;
|
|
11075
|
+
this.#items.push(item);
|
|
11076
|
+
this.#positions.set(item.url, index);
|
|
11077
|
+
this.#bubbleUp(index);
|
|
11078
|
+
}
|
|
11079
|
+
decreaseDepth(url, depth) {
|
|
11080
|
+
const index = this.#positions.get(url);
|
|
11081
|
+
if (index === void 0) return false;
|
|
11082
|
+
const item = this.#items[index];
|
|
11083
|
+
if (!item || depth >= item.depth) return false;
|
|
11084
|
+
item.depth = depth;
|
|
11085
|
+
this.#bubbleUp(index);
|
|
11086
|
+
return true;
|
|
11087
|
+
}
|
|
11088
|
+
take() {
|
|
11089
|
+
const first = this.#items[0];
|
|
11090
|
+
if (!first) return void 0;
|
|
11091
|
+
const last = this.#items.pop();
|
|
11092
|
+
this.#positions.delete(first.url);
|
|
11093
|
+
if (last && last !== first) {
|
|
11094
|
+
this.#items[0] = last;
|
|
11095
|
+
this.#positions.set(last.url, 0);
|
|
11096
|
+
this.#bubbleDown(0);
|
|
11097
|
+
}
|
|
11098
|
+
return first;
|
|
11099
|
+
}
|
|
11100
|
+
#swap(left, right) {
|
|
11101
|
+
const leftItem = this.#items[left];
|
|
11102
|
+
const rightItem = this.#items[right];
|
|
11103
|
+
if (!leftItem || !rightItem) return;
|
|
11104
|
+
this.#items[left] = rightItem;
|
|
11105
|
+
this.#items[right] = leftItem;
|
|
11106
|
+
this.#positions.set(rightItem.url, left);
|
|
11107
|
+
this.#positions.set(leftItem.url, right);
|
|
11108
|
+
}
|
|
11109
|
+
#bubbleUp(start) {
|
|
11110
|
+
let index = start;
|
|
11111
|
+
while (index > 0) {
|
|
11112
|
+
const parent = Math.floor((index - 1) / 2);
|
|
11113
|
+
const item = this.#items[index];
|
|
11114
|
+
const parentItem = this.#items[parent];
|
|
11115
|
+
if (!item || !parentItem || !before(item, parentItem)) break;
|
|
11116
|
+
this.#swap(index, parent);
|
|
11117
|
+
index = parent;
|
|
11118
|
+
}
|
|
11119
|
+
}
|
|
11120
|
+
#bubbleDown(start) {
|
|
11121
|
+
let index = start;
|
|
11122
|
+
while (true) {
|
|
11123
|
+
const left = index * 2 + 1;
|
|
11124
|
+
const right = left + 1;
|
|
11125
|
+
let next = index;
|
|
11126
|
+
const item = this.#items[next];
|
|
11127
|
+
const leftItem = this.#items[left];
|
|
11128
|
+
const rightItem = this.#items[right];
|
|
11129
|
+
if (leftItem && item && before(leftItem, item)) next = left;
|
|
11130
|
+
const nextItem = this.#items[next];
|
|
11131
|
+
if (rightItem && nextItem && before(rightItem, nextItem)) next = right;
|
|
11132
|
+
if (next === index) return;
|
|
11133
|
+
this.#swap(index, next);
|
|
11134
|
+
index = next;
|
|
11135
|
+
}
|
|
11136
|
+
}
|
|
11137
|
+
};
|
|
11138
|
+
//#endregion
|
|
9905
11139
|
//#region packages/core/dist/analyze/crawler/external-link-checks.js
|
|
9906
11140
|
const EXTERNAL_LINK_SELECTION_LIMIT = 200;
|
|
9907
11141
|
const EXTERNAL_LINK_STATES = [
|
|
@@ -10103,18 +11337,13 @@ function resolveFetchedAlias(url, aliases) {
|
|
|
10103
11337
|
}
|
|
10104
11338
|
return current;
|
|
10105
11339
|
}
|
|
10106
|
-
function
|
|
10107
|
-
const resolved =
|
|
10108
|
-
for (const [
|
|
10109
|
-
const
|
|
10110
|
-
|
|
10111
|
-
if (!resolvedTargets) {
|
|
10112
|
-
resolvedTargets = /* @__PURE__ */ new Set();
|
|
10113
|
-
resolved[resolvedSource] = resolvedTargets;
|
|
10114
|
-
}
|
|
10115
|
-
for (const target of targets) resolvedTargets.add(resolveFetchedAlias(target, aliases));
|
|
11340
|
+
function resolveLinkCountAliases(counts, aliases) {
|
|
11341
|
+
const resolved = /* @__PURE__ */ new Map();
|
|
11342
|
+
for (const [url, count] of counts) {
|
|
11343
|
+
const target = resolveFetchedAlias(url, aliases);
|
|
11344
|
+
resolved.set(target, (resolved.get(target) ?? 0) + count);
|
|
10116
11345
|
}
|
|
10117
|
-
return
|
|
11346
|
+
return resolved;
|
|
10118
11347
|
}
|
|
10119
11348
|
//#endregion
|
|
10120
11349
|
//#region packages/core/dist/analyze/crawler/request-evidence.js
|
|
@@ -10126,7 +11355,9 @@ function observationFromPage(requestedUrl, page) {
|
|
|
10126
11355
|
status: page.status,
|
|
10127
11356
|
contentType: page.contentType,
|
|
10128
11357
|
durationMs: page.responseTimeMs,
|
|
10129
|
-
redirectChain: page.fetchDiagnostics?.redirectChain
|
|
11358
|
+
redirectChain: page.fetchDiagnostics?.redirectChain,
|
|
11359
|
+
accessBlock: page.accessBlock ?? page.fetchDiagnostics?.accessBlock,
|
|
11360
|
+
robotsTxt: page.robotsTxt
|
|
10130
11361
|
};
|
|
10131
11362
|
if (page.extractionStatus === "failed") return {
|
|
10132
11363
|
...observation,
|
|
@@ -10139,23 +11370,230 @@ function observationFromPage(requestedUrl, page) {
|
|
|
10139
11370
|
};
|
|
10140
11371
|
}
|
|
10141
11372
|
//#endregion
|
|
10142
|
-
//#region packages/core/dist/analyze/crawler/site-crawl-
|
|
10143
|
-
|
|
10144
|
-
|
|
10145
|
-
|
|
10146
|
-
|
|
10147
|
-
|
|
10148
|
-
|
|
10149
|
-
|
|
10150
|
-
|
|
11373
|
+
//#region packages/core/dist/analyze/crawler/site-crawl-ai-signals.js
|
|
11374
|
+
const MAX_AUXILIARY_RESPONSE_BYTES = 2 * 1024 * 1024;
|
|
11375
|
+
const AI_BOT_USER_AGENTS = [
|
|
11376
|
+
"GPTBot",
|
|
11377
|
+
"OAI-SearchBot",
|
|
11378
|
+
"ChatGPT-User",
|
|
11379
|
+
"ClaudeBot",
|
|
11380
|
+
"Claude-User",
|
|
11381
|
+
"Claude-SearchBot",
|
|
11382
|
+
"PerplexityBot",
|
|
11383
|
+
"Perplexity-User",
|
|
11384
|
+
"Google-Extended",
|
|
11385
|
+
"CCBot",
|
|
11386
|
+
"Applebot",
|
|
11387
|
+
"Applebot-Extended",
|
|
11388
|
+
"Bingbot",
|
|
11389
|
+
"Amazonbot",
|
|
11390
|
+
"Bytespider",
|
|
11391
|
+
"Meta-ExternalAgent",
|
|
11392
|
+
"Meta-ExternalFetcher",
|
|
11393
|
+
"DuckAssistBot",
|
|
11394
|
+
"MistralAI-User",
|
|
11395
|
+
"Diffbot",
|
|
11396
|
+
"cohere-ai"
|
|
11397
|
+
];
|
|
11398
|
+
const AGENT_RESOURCE_PATHS = [
|
|
11399
|
+
"/.well-known/agent.json",
|
|
11400
|
+
"/agent.json",
|
|
11401
|
+
"/.well-known/mcp.json",
|
|
11402
|
+
"/.well-known/ai-plugin.json",
|
|
11403
|
+
"/.well-known/openapi.json",
|
|
11404
|
+
"/openapi.json"
|
|
11405
|
+
];
|
|
11406
|
+
async function checkLlmsTxt(input) {
|
|
11407
|
+
const llmsUrl = new URL("/llms.txt", input.url).toString();
|
|
11408
|
+
const controller = abortController({
|
|
11409
|
+
timeoutMs: input.timeoutMs,
|
|
11410
|
+
signal: input.signal
|
|
11411
|
+
});
|
|
10151
11412
|
try {
|
|
10152
|
-
|
|
10153
|
-
|
|
10154
|
-
|
|
10155
|
-
|
|
10156
|
-
|
|
10157
|
-
|
|
10158
|
-
|
|
11413
|
+
const response = await input.fetch(llmsUrl, {
|
|
11414
|
+
profile: "bot",
|
|
11415
|
+
redirect: "follow",
|
|
11416
|
+
signal: controller.signal
|
|
11417
|
+
});
|
|
11418
|
+
const contentType = response.headers.get("content-type") ?? "";
|
|
11419
|
+
const exists = response.status >= 200 && response.status < 300 && !/\btext\/html\b/i.test(contentType);
|
|
11420
|
+
await response.body?.cancel().catch(() => void 0);
|
|
11421
|
+
return {
|
|
11422
|
+
url: response.url || llmsUrl,
|
|
11423
|
+
exists,
|
|
11424
|
+
status: response.status
|
|
11425
|
+
};
|
|
11426
|
+
} catch {
|
|
11427
|
+
return {
|
|
11428
|
+
url: llmsUrl,
|
|
11429
|
+
exists: false
|
|
11430
|
+
};
|
|
11431
|
+
} finally {
|
|
11432
|
+
controller.cleanup();
|
|
11433
|
+
}
|
|
11434
|
+
}
|
|
11435
|
+
function parseRobots(robotsUrl, text) {
|
|
11436
|
+
return robotsParserModule(robotsUrl, text);
|
|
11437
|
+
}
|
|
11438
|
+
function declaredUserAgents(text) {
|
|
11439
|
+
const declared = /* @__PURE__ */ new Set();
|
|
11440
|
+
for (const match of text.matchAll(/^\s*user-agent\s*:\s*(.+?)\s*$/gim)) {
|
|
11441
|
+
const value = match[1]?.trim().toLowerCase();
|
|
11442
|
+
if (value) declared.add(value);
|
|
11443
|
+
}
|
|
11444
|
+
return declared;
|
|
11445
|
+
}
|
|
11446
|
+
function contentSignalsFromRobots(text) {
|
|
11447
|
+
const values = /* @__PURE__ */ new Set();
|
|
11448
|
+
for (const match of text.matchAll(/^\s*content-signal\s*:\s*(.+?)\s*$/gim)) {
|
|
11449
|
+
const value = match[1]?.trim();
|
|
11450
|
+
if (value) values.add(value);
|
|
11451
|
+
}
|
|
11452
|
+
return [...values].sort();
|
|
11453
|
+
}
|
|
11454
|
+
function sitemapUrlsFromRobots(text) {
|
|
11455
|
+
const urls = /* @__PURE__ */ new Set();
|
|
11456
|
+
for (const match of text.matchAll(/^\s*sitemap\s*:\s*(\S+)\s*$/gim)) {
|
|
11457
|
+
const value = match[1]?.trim();
|
|
11458
|
+
if (!value) continue;
|
|
11459
|
+
try {
|
|
11460
|
+
urls.add(new URL(value).toString());
|
|
11461
|
+
} catch {}
|
|
11462
|
+
}
|
|
11463
|
+
return [...urls];
|
|
11464
|
+
}
|
|
11465
|
+
async function checkRobotsAiAccess(input) {
|
|
11466
|
+
const robotsUrl = new URL("/robots.txt", input.url).toString();
|
|
11467
|
+
const controller = abortController({
|
|
11468
|
+
timeoutMs: input.timeoutMs,
|
|
11469
|
+
signal: input.signal
|
|
11470
|
+
});
|
|
11471
|
+
try {
|
|
11472
|
+
const response = await input.fetch(robotsUrl, {
|
|
11473
|
+
profile: "bot",
|
|
11474
|
+
redirect: "follow",
|
|
11475
|
+
signal: controller.signal
|
|
11476
|
+
});
|
|
11477
|
+
const text = await readBoundedResponseText(response, MAX_AUXILIARY_RESPONSE_BYTES, "robots.txt response");
|
|
11478
|
+
const available = response.status >= 200 && response.status < 300;
|
|
11479
|
+
const absent = response.status >= 400 && response.status < 500 && response.status !== 429;
|
|
11480
|
+
const availability = available ? "available" : [401, 403].includes(response.status) ? "access-blocked" : response.status === 429 ? "rate-limited" : absent ? "absent" : "unreachable";
|
|
11481
|
+
const exists = available;
|
|
11482
|
+
const declared = exists ? declaredUserAgents(text) : /* @__PURE__ */ new Set();
|
|
11483
|
+
const parsed = parseRobots(robotsUrl, exists ? text : "");
|
|
11484
|
+
return {
|
|
11485
|
+
url: response.url || robotsUrl,
|
|
11486
|
+
exists,
|
|
11487
|
+
availability,
|
|
11488
|
+
status: response.status,
|
|
11489
|
+
...["rate-limited", "unreachable"].includes(availability) ? { error: `robots.txt returned HTTP ${response.status}.` } : {},
|
|
11490
|
+
sitemapUrls: exists ? sitemapUrlsFromRobots(text) : [],
|
|
11491
|
+
contentSignals: exists ? contentSignalsFromRobots(text) : [],
|
|
11492
|
+
botAccess: AI_BOT_USER_AGENTS.map((userAgent) => {
|
|
11493
|
+
const lower = userAgent.toLowerCase();
|
|
11494
|
+
return {
|
|
11495
|
+
userAgent,
|
|
11496
|
+
allowed: availability === "rate-limited" || availability === "unreachable" ? null : parsed.isAllowed(input.url, userAgent) ?? true,
|
|
11497
|
+
declared: declared.has(lower),
|
|
11498
|
+
coveredByWildcard: declared.has("*")
|
|
11499
|
+
};
|
|
11500
|
+
})
|
|
11501
|
+
};
|
|
11502
|
+
} catch (error) {
|
|
11503
|
+
return {
|
|
11504
|
+
url: robotsUrl,
|
|
11505
|
+
exists: false,
|
|
11506
|
+
availability: "unreachable",
|
|
11507
|
+
error: error instanceof Error ? error.message : String(error),
|
|
11508
|
+
sitemapUrls: [],
|
|
11509
|
+
botAccess: AI_BOT_USER_AGENTS.map((userAgent) => ({
|
|
11510
|
+
userAgent,
|
|
11511
|
+
allowed: null,
|
|
11512
|
+
declared: false,
|
|
11513
|
+
coveredByWildcard: false
|
|
11514
|
+
}))
|
|
11515
|
+
};
|
|
11516
|
+
} finally {
|
|
11517
|
+
controller.cleanup();
|
|
11518
|
+
}
|
|
11519
|
+
}
|
|
11520
|
+
async function checkAgentResource(input) {
|
|
11521
|
+
const url = new URL(input.path, input.baseUrl).toString();
|
|
11522
|
+
const controller = abortController({
|
|
11523
|
+
timeoutMs: input.timeoutMs,
|
|
11524
|
+
signal: input.signal
|
|
11525
|
+
});
|
|
11526
|
+
try {
|
|
11527
|
+
const response = await input.fetch(url, {
|
|
11528
|
+
profile: "bot",
|
|
11529
|
+
redirect: "follow",
|
|
11530
|
+
signal: controller.signal
|
|
11531
|
+
});
|
|
11532
|
+
const contentType = response.headers.get("content-type") ?? "";
|
|
11533
|
+
const text = await readBoundedResponseText(response, MAX_AUXILIARY_RESPONSE_BYTES, "Agent resource response");
|
|
11534
|
+
const exists = response.status >= 200 && response.status < 300;
|
|
11535
|
+
let validJson;
|
|
11536
|
+
if (exists && /\bjson\b/i.test(contentType)) try {
|
|
11537
|
+
JSON.parse(text);
|
|
11538
|
+
validJson = true;
|
|
11539
|
+
} catch {
|
|
11540
|
+
validJson = false;
|
|
11541
|
+
}
|
|
11542
|
+
return {
|
|
11543
|
+
url: response.url || url,
|
|
11544
|
+
exists,
|
|
11545
|
+
status: response.status,
|
|
11546
|
+
contentType,
|
|
11547
|
+
...validJson === void 0 ? {} : { validJson }
|
|
11548
|
+
};
|
|
11549
|
+
} catch {
|
|
11550
|
+
return {
|
|
11551
|
+
url,
|
|
11552
|
+
exists: false
|
|
11553
|
+
};
|
|
11554
|
+
} finally {
|
|
11555
|
+
controller.cleanup();
|
|
11556
|
+
}
|
|
11557
|
+
}
|
|
11558
|
+
async function checkAgentResources(input) {
|
|
11559
|
+
const resources = [];
|
|
11560
|
+
for (const path of AGENT_RESOURCE_PATHS) {
|
|
11561
|
+
if (input.signal?.aborted) break;
|
|
11562
|
+
resources.push(await checkAgentResource({
|
|
11563
|
+
baseUrl: input.url,
|
|
11564
|
+
path,
|
|
11565
|
+
timeoutMs: input.timeoutMs,
|
|
11566
|
+
fetch: input.fetch,
|
|
11567
|
+
signal: input.signal
|
|
11568
|
+
}));
|
|
11569
|
+
}
|
|
11570
|
+
return resources;
|
|
11571
|
+
}
|
|
11572
|
+
function collectCrawlAiSignals(input) {
|
|
11573
|
+
return Promise.all([
|
|
11574
|
+
checkLlmsTxt(input),
|
|
11575
|
+
checkRobotsAiAccess(input),
|
|
11576
|
+
checkAgentResources(input)
|
|
11577
|
+
]);
|
|
11578
|
+
}
|
|
11579
|
+
//#endregion
|
|
11580
|
+
//#region packages/core/dist/analyze/crawler/site-crawl-providers.js
|
|
11581
|
+
function crawlMetricsWindow(now) {
|
|
11582
|
+
return {
|
|
11583
|
+
...defaultDateRange(28, now),
|
|
11584
|
+
days: 28
|
|
11585
|
+
};
|
|
11586
|
+
}
|
|
11587
|
+
async function joinAnalytics(input) {
|
|
11588
|
+
let analytics;
|
|
11589
|
+
try {
|
|
11590
|
+
analytics = await input.fetchLandingPageValues({
|
|
11591
|
+
propertyId: input.propertyId,
|
|
11592
|
+
startDate: input.window.startDate,
|
|
11593
|
+
endDate: input.window.endDate,
|
|
11594
|
+
limit: input.limit
|
|
11595
|
+
});
|
|
11596
|
+
} catch (error) {
|
|
10159
11597
|
const warning = `Google Analytics metrics unavailable: ${error instanceof Error ? error.message : String(error)}`;
|
|
10160
11598
|
input.warnings.push(warning);
|
|
10161
11599
|
return {
|
|
@@ -10504,7 +11942,9 @@ function responseObservation(requestedUrl, fetched, extraction, extractionError)
|
|
|
10504
11942
|
status: fetched.status,
|
|
10505
11943
|
contentType: headerValue(fetched.headers, "content-type"),
|
|
10506
11944
|
durationMs: fetched.diagnostics.durationMs,
|
|
10507
|
-
redirectChain: fetched.diagnostics.redirectChain
|
|
11945
|
+
redirectChain: fetched.diagnostics.redirectChain,
|
|
11946
|
+
accessBlock: fetched.diagnostics.accessBlock,
|
|
11947
|
+
robotsTxt: fetched.robotsTxt
|
|
10508
11948
|
};
|
|
10509
11949
|
if (extraction === "failed") {
|
|
10510
11950
|
if (!extractionError) throw new Error("Failed extraction observations require an error.");
|
|
@@ -10529,13 +11969,13 @@ function unextractedPage(fetched, contentType) {
|
|
|
10529
11969
|
finalUrl: fetched.finalUrl,
|
|
10530
11970
|
status: fetched.status,
|
|
10531
11971
|
contentType,
|
|
10532
|
-
responseHeaders: safeResponseHeaders(fetched.headers),
|
|
10533
11972
|
responseTimeMs: fetched.diagnostics.durationMs,
|
|
10534
11973
|
sizeBytes: Buffer.byteLength(fetched.html),
|
|
10535
11974
|
usedJs: fetched.usedJs,
|
|
10536
11975
|
fetchSource: fetched.diagnostics.source,
|
|
10537
11976
|
cacheState: fetched.diagnostics.cache,
|
|
10538
11977
|
fetchDiagnostics: fetched.diagnostics,
|
|
11978
|
+
accessBlock: fetched.diagnostics.accessBlock,
|
|
10539
11979
|
blocked: fetched.diagnostics.blocked,
|
|
10540
11980
|
robotsTxt: fetched.robotsTxt,
|
|
10541
11981
|
xRobotsTag,
|
|
@@ -10556,6 +11996,35 @@ function unextractedPage(fetched, contentType) {
|
|
|
10556
11996
|
compression: headerValue(fetched.headers, "content-encoding")
|
|
10557
11997
|
};
|
|
10558
11998
|
}
|
|
11999
|
+
function statusOnlyPage(fetched) {
|
|
12000
|
+
const contentType = headerValue(fetched.headers, "content-type");
|
|
12001
|
+
return {
|
|
12002
|
+
url: fetched.finalUrl,
|
|
12003
|
+
finalUrl: fetched.finalUrl,
|
|
12004
|
+
status: fetched.status,
|
|
12005
|
+
contentType,
|
|
12006
|
+
responseHeaders: safeResponseHeaders(fetched.headers),
|
|
12007
|
+
responseTimeMs: fetched.diagnostics.durationMs,
|
|
12008
|
+
usedJs: false,
|
|
12009
|
+
fetchSource: "network",
|
|
12010
|
+
cacheState: "bypass",
|
|
12011
|
+
fetchDiagnostics: fetched.diagnostics,
|
|
12012
|
+
auditScope: "status",
|
|
12013
|
+
accessBlock: fetched.diagnostics.accessBlock,
|
|
12014
|
+
blocked: fetched.diagnostics.blocked,
|
|
12015
|
+
contentAuditAllowed: false,
|
|
12016
|
+
robotsTxt: fetched.robotsTxt,
|
|
12017
|
+
indexable: false,
|
|
12018
|
+
indexability: "Not evaluated by status-only health probe",
|
|
12019
|
+
declaredIndexability: "unknown",
|
|
12020
|
+
extractionStatus: "not-applicable",
|
|
12021
|
+
wordCount: 0,
|
|
12022
|
+
contentHash: "",
|
|
12023
|
+
outgoingInternalCount: 0,
|
|
12024
|
+
outgoingExternalCount: 0,
|
|
12025
|
+
isHttps: new URL(fetched.finalUrl).protocol === "https:"
|
|
12026
|
+
};
|
|
12027
|
+
}
|
|
10559
12028
|
function indexabilityReason(page) {
|
|
10560
12029
|
if (page.status < 200 || page.status >= 300) return `Status ${page.status}`;
|
|
10561
12030
|
if (hasMetaRobotsDirective(page.metaRobots, "noindex")) return "Meta robots noindex";
|
|
@@ -10609,13 +12078,26 @@ async function crawlOne(url, opts = {}) {
|
|
|
10609
12078
|
};
|
|
10610
12079
|
}
|
|
10611
12080
|
const contentType = headerValue(fetched.headers, "content-type");
|
|
12081
|
+
if (fetched.diagnostics.accessBlock) {
|
|
12082
|
+
const page = unextractedPage(fetched, contentType);
|
|
12083
|
+
page.contentAuditAllowed = false;
|
|
12084
|
+
page.indexable = false;
|
|
12085
|
+
page.indexability = "Crawler access blocked";
|
|
12086
|
+
page.declaredIndexability = "unknown";
|
|
12087
|
+
page.extractionStatus = "not-applicable";
|
|
12088
|
+
return {
|
|
12089
|
+
request: responseObservation(url, fetched, "not-applicable"),
|
|
12090
|
+
page,
|
|
12091
|
+
urls: []
|
|
12092
|
+
};
|
|
12093
|
+
}
|
|
10612
12094
|
if (!isHtmlContentType(contentType)) return {
|
|
10613
12095
|
request: responseObservation(url, fetched, contentType ? "not-applicable" : "unknown-media-type"),
|
|
10614
12096
|
page: unextractedPage(fetched, contentType),
|
|
10615
12097
|
urls: []
|
|
10616
12098
|
};
|
|
10617
12099
|
try {
|
|
10618
|
-
const extracted = await extractPage(fetched);
|
|
12100
|
+
const extracted = await extractPage(fetched, "crawler");
|
|
10619
12101
|
const base = new URL(extracted.finalUrl);
|
|
10620
12102
|
const internalLinks = extracted.links.map((link) => sameOriginUrl(link.href, base)).filter((value) => Boolean(value));
|
|
10621
12103
|
const externalLinks = extracted.links.filter((link) => !link.internal).map((link) => link.href).filter((href) => /^https?:\/\//.test(href));
|
|
@@ -10736,6 +12218,62 @@ async function crawlOne(url, opts = {}) {
|
|
|
10736
12218
|
};
|
|
10737
12219
|
}
|
|
10738
12220
|
}
|
|
12221
|
+
async function crawlStatusOnly(url, opts = {}) {
|
|
12222
|
+
const startedAt = Date.now();
|
|
12223
|
+
try {
|
|
12224
|
+
const fetched = await fetchPageStatus(url, {
|
|
12225
|
+
...opts,
|
|
12226
|
+
js: "off",
|
|
12227
|
+
refresh: true
|
|
12228
|
+
});
|
|
12229
|
+
return {
|
|
12230
|
+
request: responseObservation(url, fetched, "not-applicable"),
|
|
12231
|
+
page: statusOnlyPage(fetched),
|
|
12232
|
+
urls: []
|
|
12233
|
+
};
|
|
12234
|
+
} catch (error) {
|
|
12235
|
+
if (error instanceof OriginBackpressureError) return {
|
|
12236
|
+
request: {
|
|
12237
|
+
requestedUrl: url,
|
|
12238
|
+
outcome: "skipped",
|
|
12239
|
+
reason: "origin-backpressure",
|
|
12240
|
+
error: error.message,
|
|
12241
|
+
extraction: "not-applicable"
|
|
12242
|
+
},
|
|
12243
|
+
urls: []
|
|
12244
|
+
};
|
|
12245
|
+
if (error instanceof RobotsAccessError) return {
|
|
12246
|
+
request: {
|
|
12247
|
+
requestedUrl: url,
|
|
12248
|
+
outcome: "skipped",
|
|
12249
|
+
reason: error.reason,
|
|
12250
|
+
robotsTxt: {
|
|
12251
|
+
url: error.evidence.url,
|
|
12252
|
+
allowed: error.evidence.allowed,
|
|
12253
|
+
availability: error.evidence.availability,
|
|
12254
|
+
status: error.evidence.status,
|
|
12255
|
+
error: error.evidence.error,
|
|
12256
|
+
matchedLine: error.evidence.matchedLine
|
|
12257
|
+
},
|
|
12258
|
+
extraction: "not-applicable"
|
|
12259
|
+
},
|
|
12260
|
+
urls: []
|
|
12261
|
+
};
|
|
12262
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
12263
|
+
return {
|
|
12264
|
+
request: {
|
|
12265
|
+
requestedUrl: url,
|
|
12266
|
+
outcome: "failure",
|
|
12267
|
+
durationMs: Date.now() - startedAt,
|
|
12268
|
+
failureKind: fetchFailureKind(error, opts.signal),
|
|
12269
|
+
error: message,
|
|
12270
|
+
extraction: "not-applicable"
|
|
12271
|
+
},
|
|
12272
|
+
urls: [],
|
|
12273
|
+
warning: `${url}: ${message}`
|
|
12274
|
+
};
|
|
12275
|
+
}
|
|
12276
|
+
}
|
|
10739
12277
|
//#endregion
|
|
10740
12278
|
//#region packages/core/dist/analyze/monitoring/sitemaps.js
|
|
10741
12279
|
const INVALID_LOC_SAMPLE_LIMIT = 10;
|
|
@@ -10978,6 +12516,7 @@ async function fetchSitemapUrls(input) {
|
|
|
10978
12516
|
const nestedSitemaps = [];
|
|
10979
12517
|
const discoveredSitemaps = /* @__PURE__ */ new Set();
|
|
10980
12518
|
const scheduledSitemaps = /* @__PURE__ */ new Set();
|
|
12519
|
+
const fetchedSitemaps = /* @__PURE__ */ new Set();
|
|
10981
12520
|
const invalidLocSamples = [];
|
|
10982
12521
|
const malformedLastmodSamples = [];
|
|
10983
12522
|
const futureLastmodSamples = [];
|
|
@@ -11024,12 +12563,18 @@ async function fetchSitemapUrls(input) {
|
|
|
11024
12563
|
const sitemapUrl = queue.shift();
|
|
11025
12564
|
if (!sitemapUrl) continue;
|
|
11026
12565
|
const fetched = await fetchSitemapXml(sitemapUrl);
|
|
12566
|
+
fetchedSitemaps.add(sitemapUrl);
|
|
11027
12567
|
documents.push(fetched.document);
|
|
11028
12568
|
if (fetched.document.warning) warnings.push(fetched.document.warning);
|
|
11029
12569
|
if (fetched.document.dataStatus !== "unavailable") sitemapsFetched += 1;
|
|
11030
12570
|
if (!fetched.xml || !fetched.document.root) continue;
|
|
11031
12571
|
const $ = load(fetched.xml, { xmlMode: true });
|
|
11032
12572
|
$(fetched.document.root === "urlset" ? "urlset > url" : "").each((_, element) => {
|
|
12573
|
+
if (urls.length >= limit) {
|
|
12574
|
+
urlLimitExceeded = true;
|
|
12575
|
+
omittedUrlsAtLeast += 1;
|
|
12576
|
+
return false;
|
|
12577
|
+
}
|
|
11033
12578
|
urlLocs += 1;
|
|
11034
12579
|
const entry = $(element);
|
|
11035
12580
|
const value = entry.children("loc").first().text().trim();
|
|
@@ -11054,12 +12599,7 @@ async function fetchSitemapUrls(input) {
|
|
|
11054
12599
|
return;
|
|
11055
12600
|
}
|
|
11056
12601
|
seenUrls.add(url);
|
|
11057
|
-
|
|
11058
|
-
urls.push(url);
|
|
11059
|
-
return;
|
|
11060
|
-
}
|
|
11061
|
-
urlLimitExceeded = true;
|
|
11062
|
-
omittedUrlsAtLeast += 1;
|
|
12602
|
+
urls.push(url);
|
|
11063
12603
|
});
|
|
11064
12604
|
$(fetched.document.root === "sitemapindex" ? "sitemapindex > sitemap" : "").each((_, element) => {
|
|
11065
12605
|
sitemapLocs += 1;
|
|
@@ -11092,8 +12632,13 @@ async function fetchSitemapUrls(input) {
|
|
|
11092
12632
|
queue.push(child);
|
|
11093
12633
|
} else nestedSitemapLimitExceeded = true;
|
|
11094
12634
|
});
|
|
12635
|
+
if (urls.length >= limit && queue.length > 0) {
|
|
12636
|
+
urlLimitExceeded = true;
|
|
12637
|
+
omittedUrlsAtLeast = Math.max(omittedUrlsAtLeast, 1);
|
|
12638
|
+
break;
|
|
12639
|
+
}
|
|
11095
12640
|
}
|
|
11096
|
-
const unprocessedSitemaps = nestedSitemaps.filter((sitemap) => !
|
|
12641
|
+
const unprocessedSitemaps = nestedSitemaps.filter((sitemap) => !fetchedSitemaps.has(sitemap)).length;
|
|
11097
12642
|
const possiblyTruncated = urlLimitExceeded || nestedSitemapLimitExceeded || unprocessedSitemaps > 0;
|
|
11098
12643
|
if (invalidLocCount) warnings.push(`Ignored ${invalidLocCount} invalid sitemap <loc> ${invalidLocCount === 1 ? "entry" : "entries"}.`);
|
|
11099
12644
|
if (possiblyTruncated) warnings.push("Sitemap discovery exceeded a configured URL or sitemap boundary; the returned inventory is incomplete.");
|
|
@@ -11273,6 +12818,7 @@ function resolveCrawlSiteDependencies(dependencies = {}) {
|
|
|
11273
12818
|
const emptyPageTopQuery = async () => void 0;
|
|
11274
12819
|
return {
|
|
11275
12820
|
fetchPage: dependencies.fetchPage ?? crawlOne,
|
|
12821
|
+
fetchStatusPage: dependencies.fetchStatusPage ?? crawlStatusOnly,
|
|
11276
12822
|
fetchSitemapUrls: dependencies.fetchSitemapUrls ?? fetchSitemapUrls,
|
|
11277
12823
|
fetch: dependencies.fetch ?? publicHttpFetch,
|
|
11278
12824
|
queryPageMetrics: (injectedMode === "per-page" ? dependencies.queryPageMetrics : void 0) ?? (injectedMode ? emptyPageMetrics : queryPageMetrics),
|
|
@@ -11298,6 +12844,10 @@ function isSameOrigin(url, origin) {
|
|
|
11298
12844
|
}
|
|
11299
12845
|
}
|
|
11300
12846
|
function sitemapRootUrls(input) {
|
|
12847
|
+
if (input.sitemapUrl) return [{
|
|
12848
|
+
url: new URL(input.sitemapUrl).toString(),
|
|
12849
|
+
source: "explicit"
|
|
12850
|
+
}];
|
|
11301
12851
|
const origin = new URL(input.url).origin;
|
|
11302
12852
|
const declared = input.declaredSitemapUrls.filter((url) => isSameOrigin(url, origin));
|
|
11303
12853
|
const ignored = input.declaredSitemapUrls.length - declared.length;
|
|
@@ -11374,25 +12924,6 @@ async function sitemapSeeds(input) {
|
|
|
11374
12924
|
}
|
|
11375
12925
|
//#endregion
|
|
11376
12926
|
//#region packages/core/dist/analyze/crawler/site-crawl.js
|
|
11377
|
-
const AI_BOT_USER_AGENTS = [
|
|
11378
|
-
"GPTBot",
|
|
11379
|
-
"OAI-SearchBot",
|
|
11380
|
-
"ChatGPT-User",
|
|
11381
|
-
"ClaudeBot",
|
|
11382
|
-
"PerplexityBot",
|
|
11383
|
-
"Google-Extended",
|
|
11384
|
-
"CCBot",
|
|
11385
|
-
"Applebot",
|
|
11386
|
-
"Bingbot"
|
|
11387
|
-
];
|
|
11388
|
-
const AGENT_RESOURCE_PATHS = [
|
|
11389
|
-
"/.well-known/agent.json",
|
|
11390
|
-
"/agent.json",
|
|
11391
|
-
"/.well-known/mcp.json",
|
|
11392
|
-
"/.well-known/ai-plugin.json",
|
|
11393
|
-
"/.well-known/openapi.json",
|
|
11394
|
-
"/openapi.json"
|
|
11395
|
-
];
|
|
11396
12927
|
function normalizeUrl$1(value, base) {
|
|
11397
12928
|
try {
|
|
11398
12929
|
const url = base ? new URL(value, base) : new URL(value);
|
|
@@ -11416,179 +12947,22 @@ function isLikelyAsset(url) {
|
|
|
11416
12947
|
}
|
|
11417
12948
|
function globMatch(pattern, value) {
|
|
11418
12949
|
if (!pattern.includes("*")) return value.includes(pattern);
|
|
11419
|
-
const parts = pattern.split("*");
|
|
11420
|
-
let position = 0;
|
|
11421
|
-
for (const part of parts) {
|
|
11422
|
-
if (!part) continue;
|
|
11423
|
-
const found = value.slice(position).indexOf(part);
|
|
11424
|
-
if (found === -1) return false;
|
|
11425
|
-
position += found + part.length;
|
|
11426
|
-
}
|
|
11427
|
-
return true;
|
|
11428
|
-
}
|
|
11429
|
-
function passesFilters(url, include, exclude) {
|
|
11430
|
-
return (!include.length || include.some((pattern) => globMatch(pattern, url))) && !exclude.some((pattern) => globMatch(pattern, url));
|
|
11431
|
-
}
|
|
11432
|
-
async function checkLlmsTxt(input) {
|
|
11433
|
-
const llmsUrl = new URL("/llms.txt", input.url).toString();
|
|
11434
|
-
const controller = abortController({
|
|
11435
|
-
timeoutMs: input.timeoutMs,
|
|
11436
|
-
signal: input.signal
|
|
11437
|
-
});
|
|
11438
|
-
try {
|
|
11439
|
-
const response = await input.fetch(llmsUrl, {
|
|
11440
|
-
profile: "bot",
|
|
11441
|
-
redirect: "follow",
|
|
11442
|
-
signal: controller.signal
|
|
11443
|
-
});
|
|
11444
|
-
const contentType = response.headers.get("content-type") ?? "";
|
|
11445
|
-
const exists = response.status >= 200 && response.status < 300 && !/\btext\/html\b/i.test(contentType);
|
|
11446
|
-
await response.body?.cancel().catch(() => void 0);
|
|
11447
|
-
return {
|
|
11448
|
-
url: response.url || llmsUrl,
|
|
11449
|
-
exists,
|
|
11450
|
-
status: response.status
|
|
11451
|
-
};
|
|
11452
|
-
} catch {
|
|
11453
|
-
return {
|
|
11454
|
-
url: llmsUrl,
|
|
11455
|
-
exists: false
|
|
11456
|
-
};
|
|
11457
|
-
} finally {
|
|
11458
|
-
controller.cleanup();
|
|
11459
|
-
}
|
|
11460
|
-
}
|
|
11461
|
-
function parseRobots(robotsUrl, text) {
|
|
11462
|
-
return robotsParserModule(robotsUrl, text);
|
|
11463
|
-
}
|
|
11464
|
-
function declaredUserAgents(text) {
|
|
11465
|
-
const declared = /* @__PURE__ */ new Set();
|
|
11466
|
-
for (const match of text.matchAll(/^\s*user-agent\s*:\s*(.+?)\s*$/gim)) {
|
|
11467
|
-
const value = match[1]?.trim().toLowerCase();
|
|
11468
|
-
if (value) declared.add(value);
|
|
11469
|
-
}
|
|
11470
|
-
return declared;
|
|
11471
|
-
}
|
|
11472
|
-
function sitemapUrlsFromRobots(text) {
|
|
11473
|
-
const urls = /* @__PURE__ */ new Set();
|
|
11474
|
-
for (const match of text.matchAll(/^\s*sitemap\s*:\s*(\S+)\s*$/gim)) {
|
|
11475
|
-
const value = match[1]?.trim();
|
|
11476
|
-
if (!value) continue;
|
|
11477
|
-
try {
|
|
11478
|
-
urls.add(new URL(value).toString());
|
|
11479
|
-
} catch {}
|
|
11480
|
-
}
|
|
11481
|
-
return [...urls];
|
|
11482
|
-
}
|
|
11483
|
-
async function checkRobotsAiAccess(input) {
|
|
11484
|
-
const robotsUrl = new URL("/robots.txt", input.url).toString();
|
|
11485
|
-
const controller = abortController({
|
|
11486
|
-
timeoutMs: input.timeoutMs,
|
|
11487
|
-
signal: input.signal
|
|
11488
|
-
});
|
|
11489
|
-
try {
|
|
11490
|
-
const response = await input.fetch(robotsUrl, {
|
|
11491
|
-
profile: "bot",
|
|
11492
|
-
redirect: "follow",
|
|
11493
|
-
signal: controller.signal
|
|
11494
|
-
});
|
|
11495
|
-
const text = await response.text();
|
|
11496
|
-
const available = response.status >= 200 && response.status < 300;
|
|
11497
|
-
const absent = response.status >= 400 && response.status < 500 && response.status !== 429;
|
|
11498
|
-
const availability = available ? "available" : [401, 403].includes(response.status) ? "access-blocked" : response.status === 429 ? "rate-limited" : absent ? "absent" : "unreachable";
|
|
11499
|
-
const exists = available;
|
|
11500
|
-
const declared = exists ? declaredUserAgents(text) : /* @__PURE__ */ new Set();
|
|
11501
|
-
const parsed = parseRobots(robotsUrl, exists ? text : "");
|
|
11502
|
-
return {
|
|
11503
|
-
url: response.url || robotsUrl,
|
|
11504
|
-
exists,
|
|
11505
|
-
availability,
|
|
11506
|
-
status: response.status,
|
|
11507
|
-
...["rate-limited", "unreachable"].includes(availability) ? { error: `robots.txt returned HTTP ${response.status}.` } : {},
|
|
11508
|
-
sitemapUrls: exists ? sitemapUrlsFromRobots(text) : [],
|
|
11509
|
-
botAccess: AI_BOT_USER_AGENTS.map((userAgent) => {
|
|
11510
|
-
const lower = userAgent.toLowerCase();
|
|
11511
|
-
return {
|
|
11512
|
-
userAgent,
|
|
11513
|
-
allowed: availability === "rate-limited" || availability === "unreachable" ? null : parsed.isAllowed(input.url, userAgent) ?? true,
|
|
11514
|
-
declared: declared.has(lower),
|
|
11515
|
-
coveredByWildcard: declared.has("*")
|
|
11516
|
-
};
|
|
11517
|
-
})
|
|
11518
|
-
};
|
|
11519
|
-
} catch (error) {
|
|
11520
|
-
return {
|
|
11521
|
-
url: robotsUrl,
|
|
11522
|
-
exists: false,
|
|
11523
|
-
availability: "unreachable",
|
|
11524
|
-
error: error instanceof Error ? error.message : String(error),
|
|
11525
|
-
sitemapUrls: [],
|
|
11526
|
-
botAccess: AI_BOT_USER_AGENTS.map((userAgent) => ({
|
|
11527
|
-
userAgent,
|
|
11528
|
-
allowed: null,
|
|
11529
|
-
declared: false,
|
|
11530
|
-
coveredByWildcard: false
|
|
11531
|
-
}))
|
|
11532
|
-
};
|
|
11533
|
-
} finally {
|
|
11534
|
-
controller.cleanup();
|
|
11535
|
-
}
|
|
11536
|
-
}
|
|
11537
|
-
async function checkAgentResource(input) {
|
|
11538
|
-
const url = new URL(input.path, input.baseUrl).toString();
|
|
11539
|
-
const controller = abortController({
|
|
11540
|
-
timeoutMs: input.timeoutMs,
|
|
11541
|
-
signal: input.signal
|
|
11542
|
-
});
|
|
11543
|
-
try {
|
|
11544
|
-
const response = await input.fetch(url, {
|
|
11545
|
-
profile: "bot",
|
|
11546
|
-
redirect: "follow",
|
|
11547
|
-
signal: controller.signal
|
|
11548
|
-
});
|
|
11549
|
-
const contentType = response.headers.get("content-type") ?? "";
|
|
11550
|
-
const text = await response.text();
|
|
11551
|
-
const exists = response.status >= 200 && response.status < 300;
|
|
11552
|
-
let validJson;
|
|
11553
|
-
if (exists && /\bjson\b/i.test(contentType)) try {
|
|
11554
|
-
JSON.parse(text);
|
|
11555
|
-
validJson = true;
|
|
11556
|
-
} catch {
|
|
11557
|
-
validJson = false;
|
|
11558
|
-
}
|
|
11559
|
-
return {
|
|
11560
|
-
url: response.url || url,
|
|
11561
|
-
exists,
|
|
11562
|
-
status: response.status,
|
|
11563
|
-
contentType,
|
|
11564
|
-
...validJson === void 0 ? {} : { validJson }
|
|
11565
|
-
};
|
|
11566
|
-
} catch {
|
|
11567
|
-
return {
|
|
11568
|
-
url,
|
|
11569
|
-
exists: false
|
|
11570
|
-
};
|
|
11571
|
-
} finally {
|
|
11572
|
-
controller.cleanup();
|
|
11573
|
-
}
|
|
11574
|
-
}
|
|
11575
|
-
async function checkAgentResources(input) {
|
|
11576
|
-
const resources = [];
|
|
11577
|
-
for (const path of AGENT_RESOURCE_PATHS) {
|
|
11578
|
-
if (input.signal?.aborted) break;
|
|
11579
|
-
resources.push(await checkAgentResource({
|
|
11580
|
-
baseUrl: input.url,
|
|
11581
|
-
path,
|
|
11582
|
-
timeoutMs: input.timeoutMs,
|
|
11583
|
-
fetch: input.fetch,
|
|
11584
|
-
signal: input.signal
|
|
11585
|
-
}));
|
|
12950
|
+
const parts = pattern.split("*");
|
|
12951
|
+
let position = 0;
|
|
12952
|
+
for (const part of parts) {
|
|
12953
|
+
if (!part) continue;
|
|
12954
|
+
const found = value.slice(position).indexOf(part);
|
|
12955
|
+
if (found === -1) return false;
|
|
12956
|
+
position += found + part.length;
|
|
11586
12957
|
}
|
|
11587
|
-
return
|
|
12958
|
+
return true;
|
|
12959
|
+
}
|
|
12960
|
+
function passesFilters(url, include, exclude) {
|
|
12961
|
+
return (!include.length || include.some((pattern) => globMatch(pattern, url))) && !exclude.some((pattern) => globMatch(pattern, url));
|
|
11588
12962
|
}
|
|
11589
12963
|
async function crawlSite(input, dependencies) {
|
|
11590
12964
|
const deps = resolveCrawlSiteDependencies(dependencies);
|
|
11591
|
-
const config = normalizeCrawlConfig(input);
|
|
12965
|
+
const config = assertCrawlConfigLimits(normalizeCrawlConfig(input));
|
|
11592
12966
|
const { searchMetricsLimit, analyticsLimit } = crawlProviderLimits(input);
|
|
11593
12967
|
const signal = input.signal;
|
|
11594
12968
|
let cancelled = Boolean(signal?.aborted);
|
|
@@ -11596,17 +12970,19 @@ async function crawlSite(input, dependencies) {
|
|
|
11596
12970
|
const site = input.site;
|
|
11597
12971
|
const origin = new URL(config.url).origin;
|
|
11598
12972
|
const warnings = [];
|
|
11599
|
-
const queue =
|
|
12973
|
+
const queue = new CrawlUrlQueue();
|
|
11600
12974
|
const visited = /* @__PURE__ */ new Set();
|
|
11601
|
-
const queued = /* @__PURE__ */ new Set();
|
|
11602
12975
|
const discovered = /* @__PURE__ */ new Set();
|
|
11603
|
-
const
|
|
12976
|
+
const incomingLinkCounts = /* @__PURE__ */ new Map();
|
|
11604
12977
|
const fetchedAliases = /* @__PURE__ */ new Map();
|
|
11605
12978
|
const pages = [];
|
|
11606
12979
|
const requests = [];
|
|
11607
12980
|
const documentIndexes = /* @__PURE__ */ new Map();
|
|
11608
12981
|
const inFlight = /* @__PURE__ */ new Set();
|
|
11609
12982
|
const followLinks = config.mode === "site";
|
|
12983
|
+
const healthStrategy = config.strategy === "health";
|
|
12984
|
+
let activeConcurrency = healthStrategy ? 1 : config.concurrency;
|
|
12985
|
+
let healthyProbeStreak = 0;
|
|
11610
12986
|
let queuedUrls = 0;
|
|
11611
12987
|
let skippedUrls = 0;
|
|
11612
12988
|
const skipReasonCounts = {};
|
|
@@ -11630,7 +13006,7 @@ async function crawlSite(input, dependencies) {
|
|
|
11630
13006
|
generatedAt: deps.now().toISOString(),
|
|
11631
13007
|
discoveredUrls: discovered.size,
|
|
11632
13008
|
queuedUrls,
|
|
11633
|
-
pendingUrls: queue.
|
|
13009
|
+
pendingUrls: queue.size,
|
|
11634
13010
|
inFlightUrls: inFlight.size,
|
|
11635
13011
|
crawledUrls: pages.length,
|
|
11636
13012
|
skippedUrls,
|
|
@@ -11644,36 +13020,27 @@ async function crawlSite(input, dependencies) {
|
|
|
11644
13020
|
const flushStatusEvents = async () => {
|
|
11645
13021
|
await statusEventChain;
|
|
11646
13022
|
};
|
|
11647
|
-
const renderer = config.js === "off" ? void 0 : createPageRenderer({ concurrency: config.concurrency });
|
|
13023
|
+
const renderer = config.js === "off" || healthStrategy ? void 0 : createPageRenderer({ concurrency: config.concurrency });
|
|
13024
|
+
const robotsSession = healthStrategy ? createRobotsSession({
|
|
13025
|
+
refresh: true,
|
|
13026
|
+
writeCache: false,
|
|
13027
|
+
timeoutMs: config.timeoutMs,
|
|
13028
|
+
signal
|
|
13029
|
+
}) : void 0;
|
|
13030
|
+
const robotsResolver = robotsSession?.resolve;
|
|
11648
13031
|
try {
|
|
11649
13032
|
emitStatus("started", {
|
|
11650
13033
|
url: config.url,
|
|
11651
13034
|
message: `Started crawl for ${config.url}.`
|
|
11652
13035
|
});
|
|
11653
|
-
const aiSignals = isCancelled() ? void 0 : await
|
|
11654
|
-
|
|
11655
|
-
|
|
11656
|
-
|
|
11657
|
-
|
|
11658
|
-
|
|
11659
|
-
|
|
11660
|
-
|
|
11661
|
-
url: config.url,
|
|
11662
|
-
timeoutMs: config.timeoutMs,
|
|
11663
|
-
fetch: deps.fetch,
|
|
11664
|
-
signal
|
|
11665
|
-
}),
|
|
11666
|
-
checkAgentResources({
|
|
11667
|
-
url: config.url,
|
|
11668
|
-
timeoutMs: config.timeoutMs,
|
|
11669
|
-
fetch: deps.fetch,
|
|
11670
|
-
signal
|
|
11671
|
-
})
|
|
11672
|
-
]);
|
|
11673
|
-
const llmsTxt = aiSignals?.[0] ?? {
|
|
11674
|
-
url: new URL("/llms.txt", config.url).toString(),
|
|
11675
|
-
exists: false
|
|
11676
|
-
};
|
|
13036
|
+
const aiSignals = isCancelled() || healthStrategy ? void 0 : await collectCrawlAiSignals({
|
|
13037
|
+
url: config.url,
|
|
13038
|
+
timeoutMs: config.timeoutMs,
|
|
13039
|
+
fetch: deps.fetch,
|
|
13040
|
+
signal
|
|
13041
|
+
});
|
|
13042
|
+
const llmsTxt = aiSignals?.[0];
|
|
13043
|
+
const healthSitemapUrls = healthStrategy && !config.sitemapUrl && !isCancelled() ? await robotsSession?.sitemapUrls(origin) : void 0;
|
|
11677
13044
|
const robotsTxt = aiSignals?.[1];
|
|
11678
13045
|
const agentResources = aiSignals?.[2];
|
|
11679
13046
|
cancelled = isCancelled();
|
|
@@ -11688,12 +13055,9 @@ async function crawlSite(input, dependencies) {
|
|
|
11688
13055
|
});
|
|
11689
13056
|
return;
|
|
11690
13057
|
}
|
|
11691
|
-
discovered.add(normalized);
|
|
11692
13058
|
if (visited.has(normalized)) return;
|
|
11693
|
-
if (
|
|
11694
|
-
|
|
11695
|
-
if (existing && depth < existing.depth) existing.depth = depth;
|
|
11696
|
-
queue.sort((left, right) => left.depth - right.depth || (left.url < right.url ? -1 : left.url > right.url ? 1 : 0));
|
|
13059
|
+
if (queue.has(normalized)) {
|
|
13060
|
+
queue.decreaseDepth(normalized, depth);
|
|
11697
13061
|
return;
|
|
11698
13062
|
}
|
|
11699
13063
|
if (!sameOrigin(normalized, origin)) {
|
|
@@ -11723,7 +13087,7 @@ async function crawlSite(input, dependencies) {
|
|
|
11723
13087
|
});
|
|
11724
13088
|
return;
|
|
11725
13089
|
}
|
|
11726
|
-
if (queue.
|
|
13090
|
+
if (queue.size + visited.size + inFlight.size >= config.maxPages * 5) {
|
|
11727
13091
|
recordSkip("queue-safety-limit");
|
|
11728
13092
|
queueSafetySkippedUrls += 1;
|
|
11729
13093
|
emitStatus("url_skipped", {
|
|
@@ -11733,13 +13097,12 @@ async function crawlSite(input, dependencies) {
|
|
|
11733
13097
|
});
|
|
11734
13098
|
return;
|
|
11735
13099
|
}
|
|
11736
|
-
|
|
13100
|
+
discovered.add(normalized);
|
|
11737
13101
|
queuedUrls += 1;
|
|
11738
13102
|
queue.push({
|
|
11739
13103
|
url: normalized,
|
|
11740
13104
|
depth
|
|
11741
13105
|
});
|
|
11742
|
-
queue.sort((left, right) => left.depth - right.depth || (left.url < right.url ? -1 : left.url > right.url ? 1 : 0));
|
|
11743
13106
|
emitStatus("url_queued", {
|
|
11744
13107
|
url: normalized,
|
|
11745
13108
|
depth
|
|
@@ -11752,8 +13115,9 @@ async function crawlSite(input, dependencies) {
|
|
|
11752
13115
|
if (!isCancelled() && config.useSitemap && (config.mode === "site" || config.mode === "sitemap")) {
|
|
11753
13116
|
const sitemap = await sitemapSeeds({
|
|
11754
13117
|
url: config.url,
|
|
13118
|
+
sitemapUrl: config.sitemapUrl,
|
|
11755
13119
|
maxPages: config.maxPages,
|
|
11756
|
-
declaredSitemapUrls: robotsTxt?.sitemapUrls ?? [],
|
|
13120
|
+
declaredSitemapUrls: robotsTxt?.sitemapUrls ?? healthSitemapUrls ?? [],
|
|
11757
13121
|
warnings,
|
|
11758
13122
|
fetchSitemapUrls: deps.fetchSitemapUrls
|
|
11759
13123
|
});
|
|
@@ -11761,8 +13125,8 @@ async function crawlSite(input, dependencies) {
|
|
|
11761
13125
|
sitemapDiscovery = sitemap.discovery;
|
|
11762
13126
|
}
|
|
11763
13127
|
const nextQueueItem = () => {
|
|
11764
|
-
while (queue.
|
|
11765
|
-
const item = queue.
|
|
13128
|
+
while (queue.size) {
|
|
13129
|
+
const item = queue.take();
|
|
11766
13130
|
if (!item) continue;
|
|
11767
13131
|
const normalized = normalizeUrl$1(item.url, config.url);
|
|
11768
13132
|
if (!normalized) continue;
|
|
@@ -11776,15 +13140,17 @@ async function crawlSite(input, dependencies) {
|
|
|
11776
13140
|
};
|
|
11777
13141
|
const startTask = (item) => {
|
|
11778
13142
|
let task;
|
|
13143
|
+
const fetcher = healthStrategy ? deps.fetchStatusPage : deps.fetchPage;
|
|
11779
13144
|
task = {
|
|
11780
13145
|
...item,
|
|
11781
|
-
promise:
|
|
13146
|
+
promise: fetcher(item.url, {
|
|
11782
13147
|
js: config.js,
|
|
11783
13148
|
refresh: config.refresh,
|
|
11784
13149
|
timeoutMs: config.timeoutMs,
|
|
11785
13150
|
rate: config.fetchRate,
|
|
11786
13151
|
signal,
|
|
11787
13152
|
respectRobots: config.respectRobots,
|
|
13153
|
+
robotsResolver,
|
|
11788
13154
|
renderer
|
|
11789
13155
|
}).then((result) => ({
|
|
11790
13156
|
task,
|
|
@@ -11810,8 +13176,8 @@ async function crawlSite(input, dependencies) {
|
|
|
11810
13176
|
depth: item.depth
|
|
11811
13177
|
});
|
|
11812
13178
|
};
|
|
11813
|
-
while (!isCancelled() && (queue.
|
|
11814
|
-
while (!isCancelled() && inFlight.size <
|
|
13179
|
+
while (!isCancelled() && (queue.size || inFlight.size) && pages.length < config.maxPages) {
|
|
13180
|
+
while (!isCancelled() && inFlight.size < activeConcurrency && pages.length + inFlight.size < config.maxPages) {
|
|
11815
13181
|
const item = nextQueueItem();
|
|
11816
13182
|
if (!item) break;
|
|
11817
13183
|
startTask(item);
|
|
@@ -11832,6 +13198,13 @@ async function crawlSite(input, dependencies) {
|
|
|
11832
13198
|
extraction: "not-applicable"
|
|
11833
13199
|
});
|
|
11834
13200
|
requests.push(request);
|
|
13201
|
+
if (healthStrategy) if (request.outcome === "response" && request.status < 400 && !request.accessBlock) {
|
|
13202
|
+
healthyProbeStreak += 1;
|
|
13203
|
+
activeConcurrency = Math.min(config.concurrency, 1 + Math.floor(healthyProbeStreak / 20));
|
|
13204
|
+
} else {
|
|
13205
|
+
healthyProbeStreak = 0;
|
|
13206
|
+
activeConcurrency = 1;
|
|
13207
|
+
}
|
|
11835
13208
|
if (request.outcome === "skipped") {
|
|
11836
13209
|
recordSkip(request.reason === "robots-disallowed" ? "robots-disallowed" : request.reason === "robots-deferred" ? "robots-uncertain" : "origin-backpressure");
|
|
11837
13210
|
if (request.reason === "robots-deferred") {
|
|
@@ -11894,40 +13267,49 @@ async function crawlSite(input, dependencies) {
|
|
|
11894
13267
|
};
|
|
11895
13268
|
const existing = documentIndexes.get(finalUrl);
|
|
11896
13269
|
if (existing) {
|
|
11897
|
-
const links = /* @__PURE__ */ new Set([...existing.links, ...normalizedLinks]);
|
|
11898
13270
|
const minDepth = Math.min(existing.minDepth, task.depth);
|
|
11899
13271
|
const replace = !existing.directlyRequested && directlyRequested || existing.directlyRequested === directlyRequested && sourceRequest < existing.sourceRequest;
|
|
11900
13272
|
if (replace) {
|
|
13273
|
+
const existingPage = pages[existing.index];
|
|
13274
|
+
const previousLinks = existing.pendingAliasLinks ?? (existingPage && existingPage.outgoingInternalCount === existingPage.sampleInternalLinks?.length ? new Set(existingPage.sampleInternalLinks) : void 0);
|
|
13275
|
+
const links = new Set(normalizedLinks);
|
|
13276
|
+
if (previousLinks) {
|
|
13277
|
+
for (const target of previousLinks) {
|
|
13278
|
+
const nextCount = (incomingLinkCounts.get(target) ?? 0) - 1;
|
|
13279
|
+
if (nextCount > 0) incomingLinkCounts.set(target, nextCount);
|
|
13280
|
+
else incomingLinkCounts.delete(target);
|
|
13281
|
+
}
|
|
13282
|
+
observedInternalLinks -= previousLinks.size;
|
|
13283
|
+
}
|
|
13284
|
+
for (const target of links) incomingLinkCounts.set(target, (incomingLinkCounts.get(target) ?? 0) + 1);
|
|
13285
|
+
observedInternalLinks += links.size;
|
|
13286
|
+
syncLinkEvidence(page, links);
|
|
11901
13287
|
page.crawlDepth = minDepth;
|
|
11902
13288
|
pages[existing.index] = page;
|
|
11903
13289
|
} else {
|
|
11904
13290
|
const existingPage = pages[existing.index];
|
|
11905
13291
|
if (existingPage) existingPage.crawlDepth = minDepth;
|
|
11906
13292
|
}
|
|
11907
|
-
const retainedPage = pages[existing.index];
|
|
11908
|
-
if (retainedPage) syncLinkEvidence(retainedPage, links);
|
|
11909
|
-
observedInternalLinks += links.size - existing.links.size;
|
|
11910
13293
|
documentIndexes.set(finalUrl, {
|
|
11911
13294
|
...existing,
|
|
11912
13295
|
directlyRequested: existing.directlyRequested || directlyRequested,
|
|
11913
13296
|
sourceRequest: replace ? sourceRequest : existing.sourceRequest,
|
|
11914
|
-
|
|
13297
|
+
pendingAliasLinks: void 0,
|
|
11915
13298
|
minDepth
|
|
11916
13299
|
});
|
|
11917
|
-
linkGraph[finalUrl] = [...links].sort((left, right) => left < right ? -1 : left > right ? 1 : 0);
|
|
11918
13300
|
} else {
|
|
11919
13301
|
const links = new Set(normalizedLinks);
|
|
11920
13302
|
syncLinkEvidence(page, links);
|
|
11921
13303
|
pages.push(page);
|
|
11922
13304
|
observedInternalLinks += links.size;
|
|
13305
|
+
for (const target of links) incomingLinkCounts.set(target, (incomingLinkCounts.get(target) ?? 0) + 1);
|
|
11923
13306
|
documentIndexes.set(finalUrl, {
|
|
11924
13307
|
index: pages.length - 1,
|
|
11925
13308
|
directlyRequested,
|
|
11926
13309
|
sourceRequest,
|
|
11927
|
-
links,
|
|
13310
|
+
pendingAliasLinks: !directlyRequested ? links : void 0,
|
|
11928
13311
|
minDepth: task.depth
|
|
11929
13312
|
});
|
|
11930
|
-
linkGraph[finalUrl] = [...links].sort((left, right) => left < right ? -1 : left > right ? 1 : 0);
|
|
11931
13313
|
}
|
|
11932
13314
|
};
|
|
11933
13315
|
if (config.respectRobots && page.robotsTxt?.allowed === false) {
|
|
@@ -11955,27 +13337,16 @@ async function crawlSite(input, dependencies) {
|
|
|
11955
13337
|
});
|
|
11956
13338
|
if (followLinks && task.depth < config.maxDepth) for (const url of result.urls) enqueue(url, task.depth + 1);
|
|
11957
13339
|
}
|
|
11958
|
-
const
|
|
11959
|
-
const
|
|
11960
|
-
const
|
|
11961
|
-
|
|
11962
|
-
|
|
11963
|
-
|
|
11964
|
-
const sourceIndex = pageIndexes.get(sourceUrl);
|
|
11965
|
-
if (sourceIndex === void 0) continue;
|
|
11966
|
-
for (const target of targets) {
|
|
11967
|
-
const targetIndex = pageIndexes.get(target);
|
|
11968
|
-
if (targetIndex === void 0) continue;
|
|
11969
|
-
const nextDepth = (depths[sourceIndex] ?? 0) + 1;
|
|
11970
|
-
if (nextDepth >= (depths[targetIndex] ?? Number.POSITIVE_INFINITY)) continue;
|
|
11971
|
-
depths[targetIndex] = nextDepth;
|
|
11972
|
-
changed = true;
|
|
11973
|
-
}
|
|
11974
|
-
}
|
|
11975
|
-
if (!changed) break;
|
|
13340
|
+
const resolvedInlinkCounts = resolveLinkCountAliases(incomingLinkCounts, fetchedAliases);
|
|
13341
|
+
const maxInlinks = Math.max(0, ...pages.map((page) => resolvedInlinkCounts.get(page.finalUrl) ?? 0));
|
|
13342
|
+
for (const page of pages) {
|
|
13343
|
+
const internalInlinkCount = resolvedInlinkCounts.get(page.finalUrl) ?? 0;
|
|
13344
|
+
page.internalInlinkCount = internalInlinkCount;
|
|
13345
|
+
page.internalLinkAuthorityScore = maxInlinks ? Math.round(internalInlinkCount / maxInlinks * 100) : 0;
|
|
11976
13346
|
}
|
|
11977
|
-
for (const [index, page] of pages.entries()) page.crawlDepth = depths[index];
|
|
11978
13347
|
cancelled = isCancelled();
|
|
13348
|
+
const robotsDisallowed = skipReasonCounts["robots-disallowed"] ?? 0;
|
|
13349
|
+
if (robotsDisallowed > 0) warnings.push(`${robotsDisallowed} URL${robotsDisallowed === 1 ? "" : "s"} disallowed ${SEO_CRAWLER_USER_AGENT} in robots.txt. If the audit should reach them, allow the SEO-Skill token on only those public paths.`);
|
|
11979
13350
|
if (cancelled) {
|
|
11980
13351
|
warnings.push("Crawl cancelled; returning a partial report.");
|
|
11981
13352
|
emitStatus("cancelled", {
|
|
@@ -11983,15 +13354,17 @@ async function crawlSite(input, dependencies) {
|
|
|
11983
13354
|
message: "Crawl cancelled; returning a partial report."
|
|
11984
13355
|
});
|
|
11985
13356
|
}
|
|
11986
|
-
const pageLimitReached = pages.length >= config.maxPages && (queue.
|
|
13357
|
+
const pageLimitReached = pages.length >= config.maxPages && (queue.size > 0 || inFlight.size > 0);
|
|
11987
13358
|
const sitemapEvidencePartial = sitemapDiscovery?.dataStatus === "partial";
|
|
11988
|
-
const
|
|
11989
|
-
const
|
|
13359
|
+
const accessEvidencePartial = requests.some((request) => request.outcome === "response" && request.accessBlock);
|
|
13360
|
+
const requiredSitemapUnavailable = config.mode === "sitemap" && sitemapDiscovery?.dataStatus === "unavailable";
|
|
13361
|
+
const partial = cancelled || failedUrls > 0 || pageLimitReached || robotsDeferredUrls > 0 || originBackpressureSkippedUrls > 0 || queueSafetySkippedUrls > 0 || queue.size > 0 || inFlight.size > 0 || sitemapEvidencePartial || accessEvidencePartial || requiredSitemapUnavailable;
|
|
13362
|
+
const failed = !cancelled && pages.length === 0 && (requiredSitemapUnavailable || requests.length > 0 && requests.every((request) => request.outcome === "failure"));
|
|
11990
13363
|
const requestEvidenceStatus = cancelled && inFlight.size > 0 ? "partial" : "available";
|
|
11991
13364
|
const dataSources = await crawlDataSources({
|
|
11992
13365
|
cancelled,
|
|
11993
|
-
site,
|
|
11994
|
-
googleAnalyticsPropertyId: input.googleAnalyticsPropertyId,
|
|
13366
|
+
site: healthStrategy ? void 0 : site,
|
|
13367
|
+
googleAnalyticsPropertyId: healthStrategy ? void 0 : input.googleAnalyticsPropertyId,
|
|
11995
13368
|
pages,
|
|
11996
13369
|
warnings,
|
|
11997
13370
|
searchMetricsLimit,
|
|
@@ -12034,7 +13407,7 @@ async function crawlSite(input, dependencies) {
|
|
|
12034
13407
|
concurrency: Math.min(config.concurrency, 4)
|
|
12035
13408
|
}) : void 0;
|
|
12036
13409
|
for (const page of pages) {
|
|
12037
|
-
if (!page.geo) continue;
|
|
13410
|
+
if (!page.geo || !llmsTxt) continue;
|
|
12038
13411
|
page.geo = {
|
|
12039
13412
|
...page.geo,
|
|
12040
13413
|
hasLlmsTxt: llmsTxt.exists,
|
|
@@ -12051,12 +13424,11 @@ async function crawlSite(input, dependencies) {
|
|
|
12051
13424
|
pages,
|
|
12052
13425
|
requests,
|
|
12053
13426
|
requestEvidenceStatus,
|
|
12054
|
-
|
|
12055
|
-
|
|
12056
|
-
llmsTxt,
|
|
13427
|
+
...!healthStrategy ? { ai: {
|
|
13428
|
+
...llmsTxt ? { llmsTxt } : {},
|
|
12057
13429
|
...robotsTxt ? { robotsTxt } : {},
|
|
12058
13430
|
...agentResources ? { agentResources } : {}
|
|
12059
|
-
},
|
|
13431
|
+
} } : {},
|
|
12060
13432
|
...sitemapDiscovery ? { sitemapDiscovery } : {},
|
|
12061
13433
|
...externalLinkVerification ? { externalLinkVerification } : {},
|
|
12062
13434
|
...agentDiscovery ? { agentDiscovery } : {},
|
|
@@ -12550,6 +13922,14 @@ function compareCrawlReports(input) {
|
|
|
12550
13922
|
issueChanges: issues
|
|
12551
13923
|
};
|
|
12552
13924
|
}
|
|
13925
|
+
//#endregion
|
|
13926
|
+
//#region packages/core/dist/phrasing.js
|
|
13927
|
+
function plural$4(count, singular, pluralLabel = `${singular}s`) {
|
|
13928
|
+
return count === 1 ? singular : pluralLabel;
|
|
13929
|
+
}
|
|
13930
|
+
function countLabel(count, singular, pluralLabel = `${singular}s`) {
|
|
13931
|
+
return `${count.toLocaleString("en-GB")} ${plural$4(count, singular, pluralLabel)}`;
|
|
13932
|
+
}
|
|
12553
13933
|
const OKF_MAX_CONCEPTS = 5e3;
|
|
12554
13934
|
function compareText$11(left, right) {
|
|
12555
13935
|
const leftPoints = [...left].map((value) => value.codePointAt(0) ?? 0);
|
|
@@ -12914,7 +14294,7 @@ function explainOkfValidation(validation) {
|
|
|
12914
14294
|
return {
|
|
12915
14295
|
title: "OKF bundle",
|
|
12916
14296
|
valid: validation.valid,
|
|
12917
|
-
summary: validation.valid ? `This bundle passes seo OKF checks with ${validation.concepts
|
|
14297
|
+
summary: validation.valid ? `This bundle passes seo OKF checks with ${countLabel(validation.concepts, "concept file")}.` : `This OKF bundle has ${countLabel(errors.length, "validation error")}.`,
|
|
12918
14298
|
files: validation.files,
|
|
12919
14299
|
concepts: validation.concepts,
|
|
12920
14300
|
errors: errors.length,
|
|
@@ -13066,6 +14446,31 @@ const agentDiscoverySchema = z.object({
|
|
|
13066
14446
|
missingMarkdownPages: z.number().int().nonnegative(),
|
|
13067
14447
|
consistent: z.boolean().nullable()
|
|
13068
14448
|
}),
|
|
14449
|
+
endpointDiscovery: z.object({
|
|
14450
|
+
linkHeader: z.object({
|
|
14451
|
+
url: z.string().url(),
|
|
14452
|
+
status: z.number().int().optional(),
|
|
14453
|
+
entries: z.array(z.object({
|
|
14454
|
+
url: z.string().url(),
|
|
14455
|
+
rel: z.array(z.string()),
|
|
14456
|
+
type: z.string().optional()
|
|
14457
|
+
})),
|
|
14458
|
+
registeredRels: z.array(z.string()),
|
|
14459
|
+
emergingRels: z.array(z.string()),
|
|
14460
|
+
error: z.string().optional()
|
|
14461
|
+
}),
|
|
14462
|
+
endpoints: z.array(z.object({
|
|
14463
|
+
id: z.string(),
|
|
14464
|
+
url: z.string().url(),
|
|
14465
|
+
status: z.number().int().optional(),
|
|
14466
|
+
exists: z.boolean(),
|
|
14467
|
+
contentType: z.string().optional(),
|
|
14468
|
+
validJson: z.boolean().optional(),
|
|
14469
|
+
presentFields: z.array(z.string()).optional(),
|
|
14470
|
+
missingFields: z.array(z.string()).optional(),
|
|
14471
|
+
error: z.string().optional()
|
|
14472
|
+
}))
|
|
14473
|
+
}).optional(),
|
|
13069
14474
|
protocolVariants: z.object({
|
|
13070
14475
|
http: z.object({
|
|
13071
14476
|
url: z.string().url(),
|
|
@@ -13137,6 +14542,46 @@ const analyticsSchema = z.object({
|
|
|
13137
14542
|
totalUsers: z.number(),
|
|
13138
14543
|
conversions: z.number()
|
|
13139
14544
|
});
|
|
14545
|
+
const crawlerIdentitySchema = z.object({
|
|
14546
|
+
name: z.string(),
|
|
14547
|
+
robotsToken: z.string(),
|
|
14548
|
+
version: z.string(),
|
|
14549
|
+
userAgent: z.string(),
|
|
14550
|
+
documentationUrl: z.string().url()
|
|
14551
|
+
});
|
|
14552
|
+
const accessBlockEvidenceSchema = z.object({
|
|
14553
|
+
provider: z.enum(["cloudflare", "unknown"]),
|
|
14554
|
+
kind: z.enum([
|
|
14555
|
+
"challenge",
|
|
14556
|
+
"firewall",
|
|
14557
|
+
"rate-limit",
|
|
14558
|
+
"authentication"
|
|
14559
|
+
]),
|
|
14560
|
+
status: z.number().int(),
|
|
14561
|
+
crawler: crawlerIdentitySchema,
|
|
14562
|
+
indicators: z.array(z.string()),
|
|
14563
|
+
requestId: z.string().optional(),
|
|
14564
|
+
guidance: z.object({
|
|
14565
|
+
summary: z.string(),
|
|
14566
|
+
recommendedAction: z.string(),
|
|
14567
|
+
securityNote: z.string(),
|
|
14568
|
+
documentationUrl: z.string().url().optional()
|
|
14569
|
+
})
|
|
14570
|
+
});
|
|
14571
|
+
const crawlRobotsEvidenceSchema = z.object({
|
|
14572
|
+
url: z.string().url(),
|
|
14573
|
+
allowed: z.boolean().nullable(),
|
|
14574
|
+
availability: z.enum([
|
|
14575
|
+
"available",
|
|
14576
|
+
"absent",
|
|
14577
|
+
"access-blocked",
|
|
14578
|
+
"rate-limited",
|
|
14579
|
+
"unreachable"
|
|
14580
|
+
]),
|
|
14581
|
+
status: z.number().int().optional(),
|
|
14582
|
+
error: z.string().optional(),
|
|
14583
|
+
matchedLine: z.string().optional()
|
|
14584
|
+
});
|
|
13140
14585
|
const geoSignalsSchema = z.object({
|
|
13141
14586
|
semanticHtml: z.boolean(),
|
|
13142
14587
|
structuredData: z.boolean(),
|
|
@@ -13310,6 +14755,7 @@ const crawlPageSnapshotSchema = z.object({
|
|
|
13310
14755
|
consecutiveErrors: z.number().int(),
|
|
13311
14756
|
recentP95Ms: z.number().optional()
|
|
13312
14757
|
}).optional(),
|
|
14758
|
+
accessBlock: accessBlockEvidenceSchema.optional(),
|
|
13313
14759
|
robotsTxt: z.object({
|
|
13314
14760
|
url: z.string().url(),
|
|
13315
14761
|
cache: z.enum([
|
|
@@ -13392,23 +14838,12 @@ const crawlPageSnapshotSchema = z.object({
|
|
|
13392
14838
|
}).optional()
|
|
13393
14839
|
}).optional(),
|
|
13394
14840
|
blocked: z.boolean().optional(),
|
|
14841
|
+
auditScope: z.enum(["full", "status"]).optional(),
|
|
14842
|
+
accessBlock: accessBlockEvidenceSchema.optional(),
|
|
13395
14843
|
contentAuditAllowed: z.boolean().optional(),
|
|
13396
14844
|
crawlDepth: z.number().int().optional(),
|
|
13397
14845
|
error: z.string().optional(),
|
|
13398
|
-
robotsTxt:
|
|
13399
|
-
url: z.string().url(),
|
|
13400
|
-
allowed: z.boolean().nullable(),
|
|
13401
|
-
availability: z.enum([
|
|
13402
|
-
"available",
|
|
13403
|
-
"absent",
|
|
13404
|
-
"access-blocked",
|
|
13405
|
-
"rate-limited",
|
|
13406
|
-
"unreachable"
|
|
13407
|
-
]),
|
|
13408
|
-
status: z.number().int().optional(),
|
|
13409
|
-
error: z.string().optional(),
|
|
13410
|
-
matchedLine: z.string().optional()
|
|
13411
|
-
}).optional(),
|
|
14846
|
+
robotsTxt: crawlRobotsEvidenceSchema.optional(),
|
|
13412
14847
|
title: z.string().optional(),
|
|
13413
14848
|
metaDescription: z.string().optional(),
|
|
13414
14849
|
canonical: z.string().url().optional(),
|
|
@@ -13463,8 +14898,16 @@ const crawlPageSnapshotSchema = z.object({
|
|
|
13463
14898
|
extractionError: z.string().optional(),
|
|
13464
14899
|
wordCount: z.number().int(),
|
|
13465
14900
|
contentExtraction: z.object({
|
|
13466
|
-
requested: z.enum([
|
|
13467
|
-
|
|
14901
|
+
requested: z.enum([
|
|
14902
|
+
"crawler",
|
|
14903
|
+
"defuddle",
|
|
14904
|
+
"readability"
|
|
14905
|
+
]),
|
|
14906
|
+
used: z.enum([
|
|
14907
|
+
"crawler",
|
|
14908
|
+
"defuddle",
|
|
14909
|
+
"readability"
|
|
14910
|
+
]),
|
|
13468
14911
|
fallback: z.boolean(),
|
|
13469
14912
|
fallbackReason: z.enum(["defuddle_error", "defuddle_empty"]).optional(),
|
|
13470
14913
|
fallbackDetail: z.string().optional(),
|
|
@@ -13598,7 +15041,9 @@ const crawlResponseObservationBaseSchema = z.object({
|
|
|
13598
15041
|
url: z.string().url(),
|
|
13599
15042
|
status: z.number().int(),
|
|
13600
15043
|
location: z.string().url().optional()
|
|
13601
|
-
})).optional()
|
|
15044
|
+
})).optional(),
|
|
15045
|
+
accessBlock: accessBlockEvidenceSchema.optional(),
|
|
15046
|
+
robotsTxt: crawlRobotsEvidenceSchema.optional()
|
|
13602
15047
|
});
|
|
13603
15048
|
const crawlRequestObservationSchema = z.union([
|
|
13604
15049
|
crawlResponseObservationBaseSchema.extend({ extraction: z.enum([
|
|
@@ -13615,20 +15060,7 @@ const crawlRequestObservationSchema = z.union([
|
|
|
13615
15060
|
outcome: z.literal("skipped"),
|
|
13616
15061
|
durationMs: z.number().optional(),
|
|
13617
15062
|
reason: z.enum(["robots-disallowed", "robots-deferred"]),
|
|
13618
|
-
robotsTxt:
|
|
13619
|
-
url: z.string().url(),
|
|
13620
|
-
allowed: z.boolean().nullable(),
|
|
13621
|
-
availability: z.enum([
|
|
13622
|
-
"available",
|
|
13623
|
-
"absent",
|
|
13624
|
-
"access-blocked",
|
|
13625
|
-
"rate-limited",
|
|
13626
|
-
"unreachable"
|
|
13627
|
-
]),
|
|
13628
|
-
status: z.number().int().optional(),
|
|
13629
|
-
error: z.string().optional(),
|
|
13630
|
-
matchedLine: z.string().optional()
|
|
13631
|
-
}),
|
|
15063
|
+
robotsTxt: crawlRobotsEvidenceSchema,
|
|
13632
15064
|
extraction: z.literal("not-applicable")
|
|
13633
15065
|
}),
|
|
13634
15066
|
z.object({
|
|
@@ -13687,6 +15119,7 @@ const crawlAiSignalsSchema = z.object({
|
|
|
13687
15119
|
status: z.number().int().optional(),
|
|
13688
15120
|
error: z.string().optional(),
|
|
13689
15121
|
sitemapUrls: z.array(z.string().url()),
|
|
15122
|
+
contentSignals: z.array(z.string()).optional(),
|
|
13690
15123
|
botAccess: z.array(z.object({
|
|
13691
15124
|
userAgent: z.string(),
|
|
13692
15125
|
allowed: z.boolean().nullable(),
|
|
@@ -13744,7 +15177,11 @@ const crawlSitemapDiscoverySchema = z.object({
|
|
|
13744
15177
|
urlsReturned: z.number().int().nonnegative(),
|
|
13745
15178
|
roots: z.array(z.object({
|
|
13746
15179
|
url: z.string().url(),
|
|
13747
|
-
source: z.enum([
|
|
15180
|
+
source: z.enum([
|
|
15181
|
+
"explicit",
|
|
15182
|
+
"robots-txt",
|
|
15183
|
+
"default-path"
|
|
15184
|
+
]),
|
|
13748
15185
|
dataStatus: z.enum([
|
|
13749
15186
|
"complete",
|
|
13750
15187
|
"partial",
|
|
@@ -13828,6 +15265,8 @@ const crawlConfigSchema = z.object({
|
|
|
13828
15265
|
"list",
|
|
13829
15266
|
"sitemap"
|
|
13830
15267
|
]),
|
|
15268
|
+
strategy: z.enum(["full", "health"]),
|
|
15269
|
+
sitemapUrl: z.string().url().optional(),
|
|
13831
15270
|
urls: z.array(z.string().url()),
|
|
13832
15271
|
maxPages: z.number().int(),
|
|
13833
15272
|
maxDepth: z.number().int(),
|
|
@@ -13862,6 +15301,7 @@ const crawlConfigSchema = z.object({
|
|
|
13862
15301
|
});
|
|
13863
15302
|
const crawlReportSummarySchema = z.object({
|
|
13864
15303
|
totalPages: z.number().int(),
|
|
15304
|
+
statusOnlyPages: z.number().int(),
|
|
13865
15305
|
indexablePages: z.number().int(),
|
|
13866
15306
|
nonIndexablePages: z.number().int(),
|
|
13867
15307
|
statusErrors: z.number().int(),
|
|
@@ -13952,6 +15392,17 @@ const crawlReportBaseSchema = z.object({
|
|
|
13952
15392
|
]),
|
|
13953
15393
|
configHash: z.string(),
|
|
13954
15394
|
config: crawlConfigSchema,
|
|
15395
|
+
access: z.object({
|
|
15396
|
+
crawler: crawlerIdentitySchema,
|
|
15397
|
+
blockedRequests: z.number().int().nonnegative(),
|
|
15398
|
+
providers: z.partialRecord(z.enum(["cloudflare", "unknown"]), z.number().int().nonnegative()),
|
|
15399
|
+
samples: z.array(z.object({
|
|
15400
|
+
url: z.string().url(),
|
|
15401
|
+
evidence: accessBlockEvidenceSchema
|
|
15402
|
+
})),
|
|
15403
|
+
sampleLimit: z.number().int().positive(),
|
|
15404
|
+
truncated: z.boolean()
|
|
15405
|
+
}).optional(),
|
|
13955
15406
|
summary: crawlReportSummarySchema,
|
|
13956
15407
|
pages: z.array(crawlPageSnapshotSchema),
|
|
13957
15408
|
issues: z.array(crawlIssueSchema),
|
|
@@ -14149,7 +15600,7 @@ function analyzeCtrUnderperformersFromRows(input) {
|
|
|
14149
15600
|
//#endregion
|
|
14150
15601
|
//#region packages/core/dist/analyze/ctr-underperformers.js
|
|
14151
15602
|
const MAX_GSC_ROWS$4 = 1e5;
|
|
14152
|
-
function plural$
|
|
15603
|
+
function plural$3(count, singular, pluralLabel = `${singular}s`) {
|
|
14153
15604
|
return count === 1 ? singular : pluralLabel;
|
|
14154
15605
|
}
|
|
14155
15606
|
const defaultDependencies$12 = {
|
|
@@ -14225,7 +15676,7 @@ async function ctrUnderperformersReport(input, dependencies = defaultDependencie
|
|
|
14225
15676
|
minImpressions,
|
|
14226
15677
|
limit,
|
|
14227
15678
|
brandFiltering: input.includeBrand ? "included" : "excluded",
|
|
14228
|
-
verdict: top ? `${selection.eligibleUnderperformers} CTR ${plural$
|
|
15679
|
+
verdict: top ? `${selection.eligibleUnderperformers} CTR ${plural$3(selection.eligibleUnderperformers, "underperformer")} found in retained rows, with a calculated heuristic shortfall of ${totalClickShortfall.toFixed(0)} clicks for this window. Start review with "${top.query}" because it has the largest gap.` : dataStatus === "partial" ? "No material CTR underperformers remained in the validated retained rows, but partial evidence prevents an all-clear." : "No high-impression page-one queries in the retained rows are materially below the heuristic CTR benchmark."
|
|
14229
15680
|
},
|
|
14230
15681
|
items,
|
|
14231
15682
|
caveats: [
|
|
@@ -14234,10 +15685,10 @@ async function ctrUnderperformersReport(input, dependencies = defaultDependencie
|
|
|
14234
15685
|
`Only queries ranking position 1-10 with at least ${minImpressions} impressions were checked.`,
|
|
14235
15686
|
"Expected CTR uses a robust site-aware position benchmark when enough peer data exists, otherwise the fallback position curve.",
|
|
14236
15687
|
"CTR benchmarks and calculated click shortfalls are directional heuristics, not forecasts or proof that clicks are available. Validate the search intent and live SERP before editing.",
|
|
14237
|
-
...selection.invalidRows ? [`${selection.invalidRows} invalid provider ${plural$
|
|
14238
|
-
...selection.duplicateRows ? [`${selection.duplicateRows} repeated query/page ${plural$
|
|
15688
|
+
...selection.invalidRows ? [`${selection.invalidRows} invalid provider ${plural$3(selection.invalidRows, "row")} ${selection.invalidRows === 1 ? "was" : "were"} excluded, so this report is partial.`] : [],
|
|
15689
|
+
...selection.duplicateRows ? [`${selection.duplicateRows} repeated query/page ${plural$3(selection.duplicateRows, "row")} ${selection.duplicateRows === 1 ? "was" : "were"} aggregated before CTR and average position were calculated.`] : [],
|
|
14239
15690
|
...possiblyTruncated ? [`The retained-row limit of ${MAX_GSC_ROWS$4} was reached, so lower-ranked source rows may be absent.`] : [],
|
|
14240
|
-
...selection.limitedUnderperformers ? [`${selection.limitedUnderperformers} eligible ${plural$
|
|
15691
|
+
...selection.limitedUnderperformers ? [`${selection.limitedUnderperformers} eligible ${plural$3(selection.limitedUnderperformers, "underperformer")} ${selection.limitedUnderperformers === 1 ? "was" : "were"} omitted by the output limit.`] : []
|
|
14241
15692
|
],
|
|
14242
15693
|
recommendations: top ? [
|
|
14243
15694
|
`Review the live SERP and displayed snippet for "${top.query}" first. Test the title or meta description only if the observed framing does not match the query intent.`,
|
|
@@ -14247,14 +15698,6 @@ async function ctrUnderperformersReport(input, dependencies = defaultDependencie
|
|
|
14247
15698
|
};
|
|
14248
15699
|
}
|
|
14249
15700
|
//#endregion
|
|
14250
|
-
//#region packages/core/dist/phrasing.js
|
|
14251
|
-
function plural$3(count, singular, pluralLabel = `${singular}s`) {
|
|
14252
|
-
return count === 1 ? singular : pluralLabel;
|
|
14253
|
-
}
|
|
14254
|
-
function countLabel(count, singular, pluralLabel = `${singular}s`) {
|
|
14255
|
-
return `${count.toLocaleString("en-GB")} ${plural$3(count, singular, pluralLabel)}`;
|
|
14256
|
-
}
|
|
14257
|
-
//#endregion
|
|
14258
15701
|
//#region packages/core/dist/analyze/diagnosis-priorities.js
|
|
14259
15702
|
function topDelta(report) {
|
|
14260
15703
|
const top = report.items[0];
|
|
@@ -14281,14 +15724,14 @@ function buildDiagnosisPriorities(input) {
|
|
|
14281
15724
|
const topGroup = input.decay.groups[0];
|
|
14282
15725
|
priorities.push({
|
|
14283
15726
|
label: "Refresh decaying content",
|
|
14284
|
-
reason: topGroup ? `${input.decay.selection.eligibleRows
|
|
15727
|
+
reason: topGroup ? `${countLabel(input.decay.selection.eligibleRows, "observed retained query/page decline")} found; ${topGroup.label} declined by ${topGroup.totalClickLoss.toFixed(0)} clicks.` : `${countLabel(input.decay.selection.eligibleRows, "observed retained query/page decline")} found.`,
|
|
14285
15728
|
action: topGroup ? topGroup.recommendation : "Start with declines that continued outside the update window. Check indexability first, then ranking and CTR causes.",
|
|
14286
15729
|
confidence: input.decay.dataStatus === "partial" ? "low" : "medium"
|
|
14287
15730
|
});
|
|
14288
15731
|
}
|
|
14289
15732
|
if (input.cannibal.items.length) priorities.push({
|
|
14290
15733
|
label: "Review multi-URL query candidates",
|
|
14291
|
-
reason: `${input.cannibal.selection.eligibleClusters
|
|
15734
|
+
reason: `${countLabel(input.cannibal.selection.eligibleClusters, "multi-URL query candidate")} found.`,
|
|
14292
15735
|
action: "Confirm whether each URL set satisfies the same intent and inspect technical state. Consolidate only verified duplicate or same-intent pages; otherwise clarify the distinction.",
|
|
14293
15736
|
confidence: "low"
|
|
14294
15737
|
});
|
|
@@ -14306,7 +15749,7 @@ function buildDiagnosisPriorities(input) {
|
|
|
14306
15749
|
const top = input.striking.items[0];
|
|
14307
15750
|
priorities.push({
|
|
14308
15751
|
label: "Investigate striking-distance candidates",
|
|
14309
|
-
reason: `${input.striking.items.length
|
|
15752
|
+
reason: `${countLabel(input.striking.items.length, "query/page row")} ${input.striking.items.length === 1 ? "has" : "have"} an average GSC position above 10 and at most 20.`,
|
|
14310
15753
|
action: top?.recommendation.action ?? "Check technical state, query coverage, competing URLs, and relevant internal links before choosing a change.",
|
|
14311
15754
|
confidence: input.striking.source.possiblyTruncated ? "low" : top?.recommendation.confidence ?? "low"
|
|
14312
15755
|
});
|
|
@@ -16090,8 +17533,7 @@ function analyzeQuickWinsFromRows(input) {
|
|
|
16090
17533
|
selected.selection.belowMinimumRows++;
|
|
16091
17534
|
continue;
|
|
16092
17535
|
}
|
|
16093
|
-
const
|
|
16094
|
-
const excludedTargetRows = selected.rows.filter((candidate) => samePageUrl(candidate.keys[1] ?? "", targetUrl)).length;
|
|
17536
|
+
const excludedTargetRows = benchmarkContext.urlRowCount(row);
|
|
16095
17537
|
const benchmark = benchmarkContext.forUrl(row);
|
|
16096
17538
|
if (row.ctr >= benchmark.ctr) {
|
|
16097
17539
|
selected.selection.atOrAboveTargetRows++;
|
|
@@ -16285,7 +17727,7 @@ async function quickWinsReport(input, dependencies = defaultDependencies$8) {
|
|
|
16285
17727
|
brandTerms: input.brandTerms,
|
|
16286
17728
|
includeBrand: input.includeBrand
|
|
16287
17729
|
});
|
|
16288
|
-
const verificationRequested = input.verifyContent === true || input.verifyLimit !== void 0;
|
|
17730
|
+
const verificationRequested = input.verifyContent === true || input.verifyContent !== false && input.verifyLimit !== void 0;
|
|
16289
17731
|
const verificationItems = verificationRequested ? analysis.items.slice(0, verifyLimit) : [];
|
|
16290
17732
|
const fetches = /* @__PURE__ */ new Map();
|
|
16291
17733
|
const cachedFetch = (url, options) => {
|
|
@@ -16384,7 +17826,7 @@ async function quickWinsReport(input, dependencies = defaultDependencies$8) {
|
|
|
16384
17826
|
repeatedQueryGroups: groups.length,
|
|
16385
17827
|
templatePatterns: templateActions.length,
|
|
16386
17828
|
brandFiltering: input.includeBrand ? "included" : "excluded",
|
|
16387
|
-
verdict: analysis.dataStatus === "empty" ? "Search Console returned no retained query/page rows for this date window." : analysis.dataStatus === "filtered" ? "No retained rows met the quick-win report criteria." : `${analysis.summary.eligibleRows
|
|
17829
|
+
verdict: analysis.dataStatus === "empty" ? "Search Console returned no retained query/page rows for this date window." : analysis.dataStatus === "filtered" ? "No retained rows met the quick-win report criteria." : `${countLabel(analysis.summary.eligibleRows, "eligible CTR-target row")} found; ${countLabel(analysis.summary.returnedRows, "row")} returned for review.`
|
|
16388
17830
|
},
|
|
16389
17831
|
caveats: [
|
|
16390
17832
|
`Date window: ${range.startDate} to ${range.endDate}, using final GSC data where available.`,
|
|
@@ -16675,7 +18117,7 @@ function templatesFromGroups(groups) {
|
|
|
16675
18117
|
function verdict$1(analysis) {
|
|
16676
18118
|
if (analysis.dataStatus === "empty") return "Search Console returned no retained query/page rows for this date window.";
|
|
16677
18119
|
if (analysis.dataStatus === "filtered") return "No retained query/page rows met the report filters.";
|
|
16678
|
-
return `${analysis.summary.eligibleRows
|
|
18120
|
+
return `${countLabel(analysis.summary.eligibleRows, "eligible query/page row")} found; ${countLabel(analysis.summary.returnedRows, "row")} returned for review.`;
|
|
16679
18121
|
}
|
|
16680
18122
|
async function strikingDistance(input, dependencies = defaultDependencies$7) {
|
|
16681
18123
|
const days = reportDays(input.days);
|
|
@@ -16697,7 +18139,7 @@ async function strikingDistance(input, dependencies = defaultDependencies$7) {
|
|
|
16697
18139
|
includeBrand: input.includeBrand
|
|
16698
18140
|
});
|
|
16699
18141
|
const verifyLimit = boundedInteger$2(input.verifyLimit, DEFAULT_VERIFY_LIMIT, 0, analysis.limit);
|
|
16700
|
-
const verificationRequested = input.verifyContent === true || input.verifyLimit !== void 0;
|
|
18142
|
+
const verificationRequested = input.verifyContent === true || input.verifyContent !== false && input.verifyLimit !== void 0;
|
|
16701
18143
|
const attempted = verificationRequested ? Math.min(verifyLimit, analysis.items.length) : 0;
|
|
16702
18144
|
const items = await Promise.all(analysis.items.map(async (item, index) => {
|
|
16703
18145
|
if (index >= attempted) return item;
|
|
@@ -19271,8 +20713,6 @@ function topCrawlRecommendations(items) {
|
|
|
19271
20713
|
url: item.url
|
|
19272
20714
|
})).sort((a, b) => priority[b.severity] - priority[a.severity]);
|
|
19273
20715
|
}
|
|
19274
|
-
//#endregion
|
|
19275
|
-
//#region packages/core/dist/analyze/monitoring/crawl-store.js
|
|
19276
20716
|
function toRun(row) {
|
|
19277
20717
|
return {
|
|
19278
20718
|
id: row.id,
|
|
@@ -19344,34 +20784,52 @@ function insertCrawlRun(run, pages, recommendations = []) {
|
|
|
19344
20784
|
for (const page of pages) insertPage.run(run.id, page.url, page.finalUrl, page.status, page.title ?? null, page.metaDescription ?? null, page.canonical ?? null, page.metaRobots ?? null, page.xRobotsTag ?? null, page.h1 ?? null, page.indexable ? 1 : 0, page.wordCount, page.contentHash, page.outgoingInternalCount, JSON.stringify(page));
|
|
19345
20785
|
for (const recommendation of recommendations) insertRecommendation.run(run.id, run.site, recommendation.url, recommendation.severity, recommendation.category, recommendation.title, recommendation.action, recommendation.confidence, createdAt);
|
|
19346
20786
|
})();
|
|
20787
|
+
db.prepare(`DELETE FROM crawl_runs
|
|
20788
|
+
WHERE site_url = ? AND start_url = ?
|
|
20789
|
+
AND id NOT IN (
|
|
20790
|
+
SELECT id FROM crawl_runs
|
|
20791
|
+
WHERE site_url = ? AND start_url = ?
|
|
20792
|
+
ORDER BY created_at DESC, id DESC
|
|
20793
|
+
LIMIT ?
|
|
20794
|
+
)`).run(run.site, run.startUrl, run.site, run.startUrl, 20);
|
|
19347
20795
|
}
|
|
19348
20796
|
function latestCrawlSummaries(site, limit = 5) {
|
|
19349
|
-
return getDb().prepare(`WITH
|
|
20797
|
+
return getDb().prepare(`WITH recent_runs AS (
|
|
20798
|
+
SELECT *
|
|
20799
|
+
FROM crawl_runs
|
|
20800
|
+
WHERE site_url = ?
|
|
20801
|
+
ORDER BY created_at DESC
|
|
20802
|
+
LIMIT ?
|
|
20803
|
+
),
|
|
20804
|
+
page_counts AS (
|
|
19350
20805
|
SELECT
|
|
19351
|
-
run_id,
|
|
19352
|
-
COALESCE(SUM(CASE WHEN status >= 400 THEN 1 ELSE 0 END), 0) AS status_errors,
|
|
19353
|
-
COALESCE(SUM(CASE WHEN indexable = 0 THEN 1 ELSE 0 END), 0) AS non_indexable
|
|
20806
|
+
crawl_pages.run_id,
|
|
20807
|
+
COALESCE(SUM(CASE WHEN crawl_pages.status >= 400 THEN 1 ELSE 0 END), 0) AS status_errors,
|
|
20808
|
+
COALESCE(SUM(CASE WHEN crawl_pages.indexable = 0 THEN 1 ELSE 0 END), 0) AS non_indexable
|
|
19354
20809
|
FROM crawl_pages
|
|
19355
|
-
|
|
20810
|
+
INNER JOIN recent_runs ON recent_runs.id = crawl_pages.run_id
|
|
20811
|
+
GROUP BY crawl_pages.run_id
|
|
19356
20812
|
),
|
|
19357
20813
|
recommendation_counts AS (
|
|
19358
20814
|
SELECT
|
|
19359
|
-
run_id,
|
|
20815
|
+
crawl_recommendations.run_id,
|
|
19360
20816
|
COUNT(*) AS recommendation_count,
|
|
19361
|
-
COALESCE(SUM(CASE WHEN severity = 'high' THEN 1 ELSE 0 END), 0) AS high_recommendation_count
|
|
20817
|
+
COALESCE(SUM(CASE WHEN crawl_recommendations.severity = 'high' THEN 1 ELSE 0 END), 0) AS high_recommendation_count
|
|
19362
20818
|
FROM crawl_recommendations
|
|
19363
|
-
|
|
20819
|
+
INNER JOIN recent_runs ON recent_runs.id = crawl_recommendations.run_id
|
|
20820
|
+
GROUP BY crawl_recommendations.run_id
|
|
19364
20821
|
),
|
|
19365
20822
|
recommendation_rank AS (
|
|
19366
20823
|
SELECT
|
|
19367
20824
|
crawl_recommendations.*,
|
|
19368
20825
|
ROW_NUMBER() OVER (
|
|
19369
|
-
PARTITION BY run_id
|
|
20826
|
+
PARTITION BY crawl_recommendations.run_id
|
|
19370
20827
|
ORDER BY
|
|
19371
|
-
CASE severity WHEN 'high' THEN 3 WHEN 'medium' THEN 2 ELSE 1 END DESC,
|
|
19372
|
-
created_at DESC
|
|
20828
|
+
CASE crawl_recommendations.severity WHEN 'high' THEN 3 WHEN 'medium' THEN 2 ELSE 1 END DESC,
|
|
20829
|
+
crawl_recommendations.created_at DESC
|
|
19373
20830
|
) AS rank
|
|
19374
20831
|
FROM crawl_recommendations
|
|
20832
|
+
INNER JOIN recent_runs ON recent_runs.id = crawl_recommendations.run_id
|
|
19375
20833
|
)
|
|
19376
20834
|
SELECT
|
|
19377
20835
|
crawl_runs.*,
|
|
@@ -19383,14 +20841,12 @@ function latestCrawlSummaries(site, limit = 5) {
|
|
|
19383
20841
|
top.title AS top_recommendation_title,
|
|
19384
20842
|
top.action AS top_recommendation_action,
|
|
19385
20843
|
top.severity AS top_recommendation_severity
|
|
19386
|
-
FROM crawl_runs
|
|
20844
|
+
FROM recent_runs AS crawl_runs
|
|
19387
20845
|
LEFT JOIN page_counts ON page_counts.run_id = crawl_runs.id
|
|
19388
20846
|
LEFT JOIN recommendation_counts ON recommendation_counts.run_id = crawl_runs.id
|
|
19389
20847
|
LEFT JOIN recommendation_rank top ON top.run_id = crawl_runs.id AND top.rank = 1
|
|
19390
|
-
WHERE crawl_runs.site_url = ?
|
|
19391
20848
|
GROUP BY crawl_runs.id
|
|
19392
|
-
ORDER BY crawl_runs.created_at DESC
|
|
19393
|
-
LIMIT ?`).all(site, limit).map((row) => ({
|
|
20849
|
+
ORDER BY crawl_runs.created_at DESC`).all(site, limit).map((row) => ({
|
|
19394
20850
|
...toRun(row),
|
|
19395
20851
|
statusErrors: row.status_errors,
|
|
19396
20852
|
nonIndexable: row.non_indexable,
|
|
@@ -20466,6 +21922,36 @@ function previousFromRow(row) {
|
|
|
20466
21922
|
lastCrawlTime: row.last_crawl_time ?? void 0
|
|
20467
21923
|
};
|
|
20468
21924
|
}
|
|
21925
|
+
function pruneIndexWatchSnapshots(rootSite, url, database = getDb()) {
|
|
21926
|
+
return database.prepare(`WITH attempts AS (
|
|
21927
|
+
SELECT id,
|
|
21928
|
+
ROW_NUMBER() OVER (
|
|
21929
|
+
PARTITION BY root_site_url, url
|
|
21930
|
+
ORDER BY inspected_at DESC, id DESC
|
|
21931
|
+
) AS attempt_rank
|
|
21932
|
+
FROM index_watch_snapshots
|
|
21933
|
+
WHERE root_site_url = ? AND url = ?
|
|
21934
|
+
), latest_success AS (
|
|
21935
|
+
SELECT id FROM (
|
|
21936
|
+
SELECT id,
|
|
21937
|
+
ROW_NUMBER() OVER (
|
|
21938
|
+
ORDER BY inspected_at DESC, id DESC
|
|
21939
|
+
) AS success_rank
|
|
21940
|
+
FROM index_watch_snapshots
|
|
21941
|
+
WHERE root_site_url = ? AND url = ?
|
|
21942
|
+
AND inspection_status = 'succeeded'
|
|
21943
|
+
AND error_code IS NULL
|
|
21944
|
+
)
|
|
21945
|
+
WHERE success_rank = 1
|
|
21946
|
+
)
|
|
21947
|
+
DELETE FROM index_watch_snapshots
|
|
21948
|
+
WHERE id IN (
|
|
21949
|
+
SELECT attempts.id
|
|
21950
|
+
FROM attempts
|
|
21951
|
+
LEFT JOIN latest_success ON latest_success.id = attempts.id
|
|
21952
|
+
WHERE attempts.attempt_rank > ? AND latest_success.id IS NULL
|
|
21953
|
+
)`).run(rootSite, url, rootSite, url, 20).changes;
|
|
21954
|
+
}
|
|
20469
21955
|
const defaultDependencies$2 = {
|
|
20470
21956
|
inspectUrl,
|
|
20471
21957
|
now: () => /* @__PURE__ */ new Date(),
|
|
@@ -20479,12 +21965,14 @@ const defaultDependencies$2 = {
|
|
|
20479
21965
|
return row ? previousFromRow(row) : void 0;
|
|
20480
21966
|
},
|
|
20481
21967
|
insert(item) {
|
|
20482
|
-
getDb()
|
|
21968
|
+
const db = getDb();
|
|
21969
|
+
db.prepare(`INSERT INTO index_watch_snapshots
|
|
20483
21970
|
(id, site_url, root_site_url, property_site_url, url, verdict,
|
|
20484
21971
|
coverage_state, indexing_state, robots_txt_state, page_fetch_state,
|
|
20485
21972
|
google_canonical, user_canonical, last_crawl_time, inspection_status,
|
|
20486
21973
|
error_code, error_message, inspected_at)
|
|
20487
21974
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`).run(randomUUID(), item.property, item.rootSite, item.property, item.url, item.verdict ?? null, item.coverageState ?? null, item.indexingState ?? null, item.robotsTxtState ?? null, item.pageFetchState ?? null, item.googleCanonical ?? null, item.userCanonical ?? null, item.lastCrawlTime ?? null, item.inspectionStatus, item.errorCode ?? null, item.errorMessage ?? null, Date.parse(item.inspectedAt));
|
|
21975
|
+
pruneIndexWatchSnapshots(item.rootSite, item.url, db);
|
|
20488
21976
|
}
|
|
20489
21977
|
}
|
|
20490
21978
|
};
|
|
@@ -21023,8 +22511,6 @@ async function indexMonitor(input) {
|
|
|
21023
22511
|
warnings
|
|
21024
22512
|
};
|
|
21025
22513
|
}
|
|
21026
|
-
//#endregion
|
|
21027
|
-
//#region packages/core/dist/analyze/monitoring/link-recover-store.js
|
|
21028
22514
|
function toSummary(row) {
|
|
21029
22515
|
return {
|
|
21030
22516
|
id: row.id,
|
|
@@ -21067,6 +22553,14 @@ function insertLinkRecoverRun(report) {
|
|
|
21067
22553
|
insertRun.run(id, report.site, createdAt, report.range.startDate, report.range.endDate, report.range.days, report.summary.checked, report.summary.recoverable, report.summary.high, report.summary.medium, report.summary.low, report.summary.clicksAtRisk, report.summary.impressionsAtRisk, top?.issue ?? null, top?.url ?? null, top?.recommendation.action ?? null);
|
|
21068
22554
|
for (const item of report.items) insertItem.run(id, report.site, item.url, item.finalUrl, item.issue, JSON.stringify(item.issues), item.severity, item.clicks, item.impressions, item.position, item.recommendation.action, createdAt);
|
|
21069
22555
|
})();
|
|
22556
|
+
db.prepare(`DELETE FROM link_recover_runs
|
|
22557
|
+
WHERE site_url = ?
|
|
22558
|
+
AND id NOT IN (
|
|
22559
|
+
SELECT id FROM link_recover_runs
|
|
22560
|
+
WHERE site_url = ?
|
|
22561
|
+
ORDER BY created_at DESC, id DESC
|
|
22562
|
+
LIMIT ?
|
|
22563
|
+
)`).run(report.site, report.site, 20);
|
|
21070
22564
|
return id;
|
|
21071
22565
|
}
|
|
21072
22566
|
function latestLinkRecoverSummary(site) {
|
|
@@ -21900,9 +23394,11 @@ function cacheSet(report, now) {
|
|
|
21900
23394
|
...report,
|
|
21901
23395
|
raw: void 0
|
|
21902
23396
|
};
|
|
23397
|
+
const reportJson = JSON.stringify(stored);
|
|
21903
23398
|
getDb().prepare(`INSERT OR REPLACE INTO performance_reports
|
|
21904
23399
|
(id, url, strategy, report_json, created_at, expires_at)
|
|
21905
|
-
VALUES (?, ?, ?, ?, ?, ?)`).run(report.id, report.url, report.strategy,
|
|
23400
|
+
VALUES (?, ?, ?, ?, ?, ?)`).run(report.id, report.url, report.strategy, reportJson, Date.parse(report.generatedAt), now + CACHE_TTL_MS);
|
|
23401
|
+
noteCacheWrite(Buffer.byteLength(reportJson));
|
|
21906
23402
|
}
|
|
21907
23403
|
async function fallbackReport(input) {
|
|
21908
23404
|
const fetched = await fetchPage(input.url, {
|
|
@@ -23451,7 +24947,7 @@ function clusterRecommendation(input) {
|
|
|
23451
24947
|
return `This ${clusterName} cluster already gets clicks. Use it to refine page structure, FAQs, and internal anchors rather than creating duplicate pages.`;
|
|
23452
24948
|
}
|
|
23453
24949
|
function clusterSummary(input) {
|
|
23454
|
-
return `${input.queries
|
|
24950
|
+
return `${countLabel(input.queries, "query", "queries")}, ${countLabel(input.impressions, "impression")}, ${countLabel(input.clicks, "click")}, average position ${input.position.toFixed(1)}.`;
|
|
23455
24951
|
}
|
|
23456
24952
|
function clusterLabel(rows) {
|
|
23457
24953
|
return [...rows].sort((a, b) => b.impressions - a.impressions || compareQueryClusterText(a.query, b.query))[0]?.query ?? "";
|
|
@@ -23924,7 +25420,7 @@ function contentOpportunityBullets(report) {
|
|
|
23924
25420
|
if (report.cannibalization.selection.suppressedQueries) bullets.push(cannibalSuppressionLine(report));
|
|
23925
25421
|
if (report.summary.strikingDistanceItems) {
|
|
23926
25422
|
const top = report.strikingDistance.items[0];
|
|
23927
|
-
bullets.push(top ? `${report.summary.strikingDistanceItems
|
|
25423
|
+
bullets.push(top ? `${countLabel(report.summary.strikingDistanceItems, "query/page candidate")} ${plural(report.summary.strikingDistanceItems, "has", "have")} an average GSC position above 10 and at most 20. Start by investigating "${top.query}" on ${top.url}; it averages position ${top.position.toFixed(1)} with ${top.impressions.toFixed(0)} impressions.` : `${countLabel(report.summary.strikingDistanceItems, "query/page candidate")} ${plural(report.summary.strikingDistanceItems, "has", "have")} an average GSC position above 10 and at most 20.`);
|
|
23928
25424
|
}
|
|
23929
25425
|
if (!skippedReason(report, "quick-win opportunities") && report.summary.quickWinItems) {
|
|
23930
25426
|
const top = report.quickWins.items[0];
|
|
@@ -24405,10 +25901,10 @@ function applyVerification(item, coverage) {
|
|
|
24405
25901
|
function verdict(report) {
|
|
24406
25902
|
if (report.dataStatus === "empty") return "Google Search Console returned no retained query/page rows for this window.";
|
|
24407
25903
|
if (report.dataStatus === "filtered") return "No pages met the position, brand, and minimum-impression criteria.";
|
|
24408
|
-
if (report.technical > 0) return `${report.eligible
|
|
24409
|
-
if (report.failed > 0) return `${report.eligible
|
|
24410
|
-
if (report.content > 0) return `${report.eligible
|
|
24411
|
-
return `${report.eligible
|
|
25904
|
+
if (report.technical > 0) return `${countLabel(report.eligible, "eligible average-position page")} found; ${countLabel(report.technical, "returned page")} ${plural$4(report.technical, "has", "have")} verified technical issues to fix before content changes.`;
|
|
25905
|
+
if (report.failed > 0) return `${countLabel(report.eligible, "eligible average-position page")} found; ${countLabel(report.failed, "returned page")} could not be verified and ${plural$4(report.failed, "needs", "need")} a fetch check before content changes.`;
|
|
25906
|
+
if (report.content > 0) return `${countLabel(report.eligible, "eligible average-position page")} found; ${countLabel(report.content, "returned page")} ${plural$4(report.content, "has", "have")} verified content or search-framing gaps.`;
|
|
25907
|
+
return `${countLabel(report.eligible, "eligible average-position page")} found; ${countLabel(report.returned, "page")} ${plural$4(report.returned, "is", "are")} returned in priority order for investigation.`;
|
|
24412
25908
|
}
|
|
24413
25909
|
function reportRecommendations(items) {
|
|
24414
25910
|
const first = items[0];
|
|
@@ -24455,7 +25951,7 @@ async function secondPage(input, dependencies = defaultDependencies) {
|
|
|
24455
25951
|
});
|
|
24456
25952
|
const generatedAt = now.toISOString();
|
|
24457
25953
|
const warnings = [];
|
|
24458
|
-
const verificationRequested = input.verifyContent === true || input.verifyLimit !== void 0;
|
|
25954
|
+
const verificationRequested = input.verifyContent === true || input.verifyContent !== false && input.verifyLimit !== void 0;
|
|
24459
25955
|
const verificationItems = verificationRequested ? analysis.items.slice(0, verifyLimit) : [];
|
|
24460
25956
|
for (const item of verificationItems) {
|
|
24461
25957
|
const coverage = await verifyQueryContent({
|
|
@@ -25066,7 +26562,7 @@ async function refreshPrioritiesWorkflow(input) {
|
|
|
25066
26562
|
const diagnosis = await diagnoseProperty({
|
|
25067
26563
|
...input,
|
|
25068
26564
|
verifyContent: input.verifyContent ?? true,
|
|
25069
|
-
verifyLimit: input.verifyLimit ?? Math.min(input.limit ?? 5, 5)
|
|
26565
|
+
verifyLimit: input.verifyContent === false ? void 0 : input.verifyLimit ?? Math.min(input.limit ?? 5, 5)
|
|
25070
26566
|
});
|
|
25071
26567
|
const range = defaultDateRange$1(input.days ?? 28);
|
|
25072
26568
|
const analytics = await fetchLandingPageValues({
|
|
@@ -25767,6 +27263,7 @@ function parseSemicolonCsv(text) {
|
|
|
25767
27263
|
//#endregion
|
|
25768
27264
|
//#region packages/core/dist/providers/semrush/cache.js
|
|
25769
27265
|
const BASE_URL = "https://api.semrush.com/";
|
|
27266
|
+
const MAX_RESPONSE_BYTES = 10 * 1024 * 1024;
|
|
25770
27267
|
function estimateUsd(units) {
|
|
25771
27268
|
return units / 1e3 * .05;
|
|
25772
27269
|
}
|
|
@@ -25796,14 +27293,17 @@ async function cachedSemrushCall(endpoint, params, map, ttlMs, creditsPerLine, r
|
|
|
25796
27293
|
const url = new URL(BASE_URL);
|
|
25797
27294
|
url.search = new URLSearchParams(requestParams).toString();
|
|
25798
27295
|
const response = await fetch$1(url);
|
|
25799
|
-
const text = await response
|
|
27296
|
+
const text = await readBoundedResponseText(response, MAX_RESPONSE_BYTES, "Semrush response");
|
|
25800
27297
|
if (!response.ok || text.startsWith("ERROR ::")) throw new Error(text || `Semrush request failed with ${response.status}.`);
|
|
25801
27298
|
const rows = parseSemicolonCsv(text);
|
|
25802
27299
|
const data = map(rows);
|
|
25803
27300
|
const credits = Math.max(0, rows.length - 1) * creditsPerLine;
|
|
27301
|
+
const requestJson = JSON.stringify(requestParams);
|
|
27302
|
+
const responseJson = JSON.stringify(data);
|
|
25804
27303
|
db.prepare(`INSERT OR REPLACE INTO semrush_cache
|
|
25805
27304
|
(endpoint, query_hash, request_json, response_json, credits_used, fetched_at, expires_at)
|
|
25806
|
-
VALUES (?, ?, ?, ?, ?, ?, ?)`).run(endpoint, queryHash,
|
|
27305
|
+
VALUES (?, ?, ?, ?, ?, ?, ?)`).run(endpoint, queryHash, requestJson, responseJson, credits, Date.now(), Date.now() + ttlMs);
|
|
27306
|
+
noteCacheWrite(Buffer.byteLength(requestJson) + Buffer.byteLength(responseJson));
|
|
25807
27307
|
return {
|
|
25808
27308
|
data,
|
|
25809
27309
|
usage: {
|
|
@@ -25963,9 +27463,6 @@ var LocalCredentialsProvider = class {
|
|
|
25963
27463
|
}
|
|
25964
27464
|
};
|
|
25965
27465
|
new LocalCredentialsProvider();
|
|
25966
|
-
//#endregion
|
|
25967
|
-
//#region packages/core/dist/version.js
|
|
25968
|
-
const SEO_VERSION = version;
|
|
25969
27466
|
const TELEMETRY_TIMEOUT_MS = 2e3;
|
|
25970
27467
|
const TELEMETRY_REPORTS = [
|
|
25971
27468
|
"affected-urls",
|
|
@@ -26560,17 +28057,7 @@ function registerAiOpportunityTools(server, dependencies = {}) {
|
|
|
26560
28057
|
});
|
|
26561
28058
|
}
|
|
26562
28059
|
//#endregion
|
|
26563
|
-
//#region packages/mcp/src/
|
|
26564
|
-
function fetchRateInput(input) {
|
|
26565
|
-
if (input.fetchConcurrency === void 0 && input.fetchIntervalCap === void 0 && input.fetchIntervalMs === void 0) return;
|
|
26566
|
-
return {
|
|
26567
|
-
concurrency: input.fetchConcurrency,
|
|
26568
|
-
intervalCap: input.fetchIntervalCap,
|
|
26569
|
-
intervalMs: input.fetchIntervalMs
|
|
26570
|
-
};
|
|
26571
|
-
}
|
|
26572
|
-
//#endregion
|
|
26573
|
-
//#region packages/mcp/src/crawler-tools.ts
|
|
28060
|
+
//#region packages/mcp/src/crawler-tool-helpers.ts
|
|
26574
28061
|
function compactCrawlResult(report, opts = {}) {
|
|
26575
28062
|
const requestHeadline = report.requestEvidenceStatus === "available" ? `Processed ${report.requests.length} URL requests into ${report.summary.totalPages} unique documents.` : report.requestEvidenceStatus === "partial" ? `Observed ${report.requests.length} URL requests and retained ${report.summary.totalPages} unique documents; some started requests were still in flight when the crawl stopped.` : `Loaded ${report.summary.totalPages} unique documents; request evidence is unavailable for this legacy report.`;
|
|
26576
28063
|
const payload = {
|
|
@@ -26580,6 +28067,8 @@ function compactCrawlResult(report, opts = {}) {
|
|
|
26580
28067
|
status: report.status,
|
|
26581
28068
|
requestEvidenceStatus: report.requestEvidenceStatus,
|
|
26582
28069
|
configHash: report.configHash,
|
|
28070
|
+
config: report.config,
|
|
28071
|
+
access: report.access,
|
|
26583
28072
|
summary: report.summary,
|
|
26584
28073
|
dataSources: report.dataSources,
|
|
26585
28074
|
ai: report.ai ? {
|
|
@@ -26613,16 +28102,35 @@ function resolveSavedReportAlias(input) {
|
|
|
26613
28102
|
function assertExclusiveReportInput(url, reportId) {
|
|
26614
28103
|
if (url && reportId) throw new SeoError("INVALID_INPUT", "Use either url or reportId, not both.");
|
|
26615
28104
|
}
|
|
28105
|
+
//#endregion
|
|
28106
|
+
//#region packages/mcp/src/fetch-rate.ts
|
|
28107
|
+
function fetchRateInput(input) {
|
|
28108
|
+
if (input.fetchConcurrency === void 0 && input.fetchIntervalCap === void 0 && input.fetchIntervalMs === void 0) return;
|
|
28109
|
+
return {
|
|
28110
|
+
concurrency: input.fetchConcurrency,
|
|
28111
|
+
intervalCap: input.fetchIntervalCap,
|
|
28112
|
+
intervalMs: input.fetchIntervalMs
|
|
28113
|
+
};
|
|
28114
|
+
}
|
|
28115
|
+
//#endregion
|
|
28116
|
+
//#region packages/mcp/src/crawler-tools.ts
|
|
28117
|
+
function boundedPositiveInteger(maximum) {
|
|
28118
|
+
return z$1.number().int().min(1).max(maximum).optional();
|
|
28119
|
+
}
|
|
28120
|
+
const crawlPageLimit = boundedPositiveInteger(MAX_CRAWL_PAGES);
|
|
28121
|
+
const crawlDepthLimit = z$1.number().int().min(0).max(64).optional();
|
|
26616
28122
|
function registerCrawlerTools(server) {
|
|
26617
28123
|
server.registerTool("seo_crawl_site", {
|
|
26618
|
-
description: "
|
|
28124
|
+
description: "Run a site crawl. Start with health=true for a fast uncached sitemap status and redirect pass, especially on large or unknown sites. Run the full crawl second when content-level evidence is needed. The compact result includes crawler identity and access-block guidance.",
|
|
26619
28125
|
inputSchema: {
|
|
26620
|
-
url: z$1.string().url(),
|
|
28126
|
+
url: z$1.string().url().optional().describe("Site start URL. Optional when sitemapUrl supplies the health-pass scope."),
|
|
28127
|
+
health: z$1.boolean().optional().describe("Run the fast sitemap health pass before a full crawl. Downloads no page bodies and writes no page cache entries."),
|
|
28128
|
+
sitemapUrl: z$1.string().url().optional().describe("Explicit sitemap or sitemap index URL for the health pass."),
|
|
26621
28129
|
site: z$1.string().optional(),
|
|
26622
28130
|
googleAnalyticsPropertyId: z$1.string().optional(),
|
|
26623
|
-
maxPages:
|
|
26624
|
-
maxDepth:
|
|
26625
|
-
concurrency:
|
|
28131
|
+
maxPages: crawlPageLimit,
|
|
28132
|
+
maxDepth: crawlDepthLimit,
|
|
28133
|
+
concurrency: boundedPositiveInteger(MAX_CRAWL_CONCURRENCY),
|
|
26626
28134
|
fetchIntervalCap: z$1.number().int().positive().optional(),
|
|
26627
28135
|
fetchIntervalMs: z$1.number().int().positive().optional(),
|
|
26628
28136
|
refresh: z$1.boolean().optional(),
|
|
@@ -26635,10 +28143,15 @@ function registerCrawlerTools(server) {
|
|
|
26635
28143
|
includeIssues: z$1.boolean().optional(),
|
|
26636
28144
|
saveReport: z$1.boolean().optional()
|
|
26637
28145
|
}
|
|
26638
|
-
}, async ({ url, site, googleAnalyticsPropertyId, maxPages, maxDepth, concurrency, fetchIntervalCap, fetchIntervalMs, refresh, include, exclude, useSitemap, respectRobots, js, includePages, includeIssues, saveReport }) => {
|
|
28146
|
+
}, async ({ url, health, sitemapUrl, site, googleAnalyticsPropertyId, maxPages, maxDepth, concurrency, fetchIntervalCap, fetchIntervalMs, refresh, include, exclude, useSitemap, respectRobots, js, includePages, includeIssues, saveReport }) => {
|
|
26639
28147
|
try {
|
|
28148
|
+
const crawlUrl = url ?? (sitemapUrl ? new URL("/", sitemapUrl).toString() : void 0);
|
|
28149
|
+
if (!crawlUrl) throw new SeoError("INVALID_INPUT", "Pass url or an explicit sitemapUrl.");
|
|
26640
28150
|
const report = await crawlSite({
|
|
26641
|
-
url,
|
|
28151
|
+
url: crawlUrl,
|
|
28152
|
+
mode: health || sitemapUrl ? "sitemap" : "site",
|
|
28153
|
+
strategy: health ? "health" : "full",
|
|
28154
|
+
sitemapUrl,
|
|
26642
28155
|
site,
|
|
26643
28156
|
googleAnalyticsPropertyId,
|
|
26644
28157
|
maxPages,
|
|
@@ -26657,7 +28170,7 @@ function registerCrawlerTools(server) {
|
|
|
26657
28170
|
js: Boolean(js)
|
|
26658
28171
|
});
|
|
26659
28172
|
const saved = saveReport ? saveCrawlReport(report) : void 0;
|
|
26660
|
-
return toolSuccess(
|
|
28173
|
+
return toolSuccess(`${health ? "Sitemap health pass" : "Crawl"} complete for ${crawlUrl}. Found ${report.issues.length} issues across ${report.summary.totalPages} pages.`, {
|
|
26661
28174
|
...compactCrawlResult(report, {
|
|
26662
28175
|
includePages,
|
|
26663
28176
|
includeIssues
|
|
@@ -26671,11 +28184,11 @@ function registerCrawlerTools(server) {
|
|
|
26671
28184
|
server.registerTool("seo_audit_urls", {
|
|
26672
28185
|
description: "Audit an explicit list of URLs with technical SEO checks. The compact result separates prioritised fixes from review observations. Set includePages/includeIssues for raw data.",
|
|
26673
28186
|
inputSchema: {
|
|
26674
|
-
urls: z$1.array(z$1.string().url()).min(1),
|
|
28187
|
+
urls: z$1.array(z$1.string().url()).min(1).max(MAX_CRAWL_PAGES),
|
|
26675
28188
|
site: z$1.string().optional(),
|
|
26676
28189
|
googleAnalyticsPropertyId: z$1.string().optional(),
|
|
26677
|
-
maxPages:
|
|
26678
|
-
concurrency:
|
|
28190
|
+
maxPages: crawlPageLimit,
|
|
28191
|
+
concurrency: boundedPositiveInteger(MAX_CRAWL_CONCURRENCY),
|
|
26679
28192
|
fetchIntervalCap: z$1.number().int().positive().optional(),
|
|
26680
28193
|
fetchIntervalMs: z$1.number().int().positive().optional(),
|
|
26681
28194
|
refresh: z$1.boolean().optional(),
|
|
@@ -26719,8 +28232,8 @@ function registerCrawlerTools(server) {
|
|
|
26719
28232
|
url: z$1.string().url().optional(),
|
|
26720
28233
|
reportId: z$1.string().optional(),
|
|
26721
28234
|
site: z$1.string().optional(),
|
|
26722
|
-
maxPages:
|
|
26723
|
-
maxDepth:
|
|
28235
|
+
maxPages: crawlPageLimit,
|
|
28236
|
+
maxDepth: crawlDepthLimit,
|
|
26724
28237
|
fetchIntervalCap: z$1.number().int().positive().optional(),
|
|
26725
28238
|
fetchIntervalMs: z$1.number().int().positive().optional(),
|
|
26726
28239
|
refresh: z$1.boolean().optional(),
|
|
@@ -26746,7 +28259,8 @@ function registerCrawlerTools(server) {
|
|
|
26746
28259
|
category,
|
|
26747
28260
|
limit
|
|
26748
28261
|
});
|
|
26749
|
-
|
|
28262
|
+
const coverage = report.status === "completed" ? "" : ` Coverage is ${report.status}: ${countLabel(report.summary.crawledUrls, "URL")} crawled from ${countLabel(report.summary.discoveredUrls, "discovered URL")}. Review the returned warnings and caveats before treating this as sitewide.`;
|
|
28263
|
+
return toolSuccess(`Found ${countLabel(groups.length, "top fix group")} for ${report.config.url}.${coverage}`, {
|
|
26750
28264
|
url: report.config.url,
|
|
26751
28265
|
reportId: report.id,
|
|
26752
28266
|
summary: report.summary,
|
|
@@ -26772,7 +28286,7 @@ function registerCrawlerTools(server) {
|
|
|
26772
28286
|
inputSchema: { category: crawlerRuleCategorySchema.optional() }
|
|
26773
28287
|
}, async ({ category }) => {
|
|
26774
28288
|
const rules = listRules().filter((rule) => category ? rule.category === category : true);
|
|
26775
|
-
return toolSuccess(`Found ${rules.length
|
|
28289
|
+
return toolSuccess(`Found ${countLabel(rules.length, "crawler rule")}.`, { rules });
|
|
26776
28290
|
});
|
|
26777
28291
|
server.registerTool("seo_affected_urls", {
|
|
26778
28292
|
description: "Return affected URLs for a saved or freshly crawled report, filtered by rule, category, or severity.",
|
|
@@ -26783,7 +28297,7 @@ function registerCrawlerTools(server) {
|
|
|
26783
28297
|
ruleId: z$1.string().optional(),
|
|
26784
28298
|
category: z$1.string().optional(),
|
|
26785
28299
|
severity: z$1.string().optional(),
|
|
26786
|
-
maxPages:
|
|
28300
|
+
maxPages: crawlPageLimit,
|
|
26787
28301
|
fetchIntervalCap: z$1.number().int().positive().optional(),
|
|
26788
28302
|
fetchIntervalMs: z$1.number().int().positive().optional(),
|
|
26789
28303
|
refresh: z$1.boolean().optional(),
|
|
@@ -26827,7 +28341,7 @@ function registerCrawlerTools(server) {
|
|
|
26827
28341
|
url: z$1.string().url().optional(),
|
|
26828
28342
|
reportId: z$1.string().optional(),
|
|
26829
28343
|
site: z$1.string().optional(),
|
|
26830
|
-
maxPages:
|
|
28344
|
+
maxPages: crawlPageLimit,
|
|
26831
28345
|
fetchIntervalCap: z$1.number().int().positive().optional(),
|
|
26832
28346
|
fetchIntervalMs: z$1.number().int().positive().optional(),
|
|
26833
28347
|
refresh: z$1.boolean().optional(),
|
|
@@ -26862,7 +28376,7 @@ function registerCrawlerTools(server) {
|
|
|
26862
28376
|
url: z$1.string().url().optional(),
|
|
26863
28377
|
reportId: z$1.string().optional(),
|
|
26864
28378
|
site: z$1.string().optional(),
|
|
26865
|
-
maxPages:
|
|
28379
|
+
maxPages: crawlPageLimit,
|
|
26866
28380
|
fetchIntervalCap: z$1.number().int().positive().optional(),
|
|
26867
28381
|
fetchIntervalMs: z$1.number().int().positive().optional(),
|
|
26868
28382
|
refresh: z$1.boolean().optional()
|
|
@@ -26896,7 +28410,7 @@ function registerCrawlerTools(server) {
|
|
|
26896
28410
|
url: z$1.string().url().optional(),
|
|
26897
28411
|
reportId: z$1.string().optional(),
|
|
26898
28412
|
site: z$1.string().optional(),
|
|
26899
|
-
maxPages:
|
|
28413
|
+
maxPages: crawlPageLimit,
|
|
26900
28414
|
fetchIntervalCap: z$1.number().int().positive().optional(),
|
|
26901
28415
|
fetchIntervalMs: z$1.number().int().positive().optional(),
|
|
26902
28416
|
refresh: z$1.boolean().optional()
|
|
@@ -26926,7 +28440,7 @@ function registerCrawlerTools(server) {
|
|
|
26926
28440
|
url: z$1.string().url().optional(),
|
|
26927
28441
|
reportId: z$1.string().optional(),
|
|
26928
28442
|
site: z$1.string().optional(),
|
|
26929
|
-
maxPages:
|
|
28443
|
+
maxPages: crawlPageLimit,
|
|
26930
28444
|
fetchIntervalCap: z$1.number().int().positive().optional(),
|
|
26931
28445
|
fetchIntervalMs: z$1.number().int().positive().optional(),
|
|
26932
28446
|
refresh: z$1.boolean().optional()
|
|
@@ -26956,7 +28470,7 @@ function registerCrawlerTools(server) {
|
|
|
26956
28470
|
url: z$1.string().url().optional(),
|
|
26957
28471
|
reportId: z$1.string().optional(),
|
|
26958
28472
|
site: z$1.string().optional(),
|
|
26959
|
-
maxPages:
|
|
28473
|
+
maxPages: crawlPageLimit,
|
|
26960
28474
|
fetchIntervalCap: z$1.number().int().positive().optional(),
|
|
26961
28475
|
fetchIntervalMs: z$1.number().int().positive().optional(),
|
|
26962
28476
|
refresh: z$1.boolean().optional()
|
|
@@ -26986,7 +28500,7 @@ function registerCrawlerTools(server) {
|
|
|
26986
28500
|
url: z$1.string().url().optional(),
|
|
26987
28501
|
reportId: z$1.string().optional(),
|
|
26988
28502
|
site: z$1.string().optional(),
|
|
26989
|
-
maxPages:
|
|
28503
|
+
maxPages: crawlPageLimit,
|
|
26990
28504
|
maxUrls: z$1.number().int().positive().optional(),
|
|
26991
28505
|
tokenBudget: z$1.number().int().positive().optional(),
|
|
26992
28506
|
exclude: z$1.array(z$1.string()).optional(),
|
|
@@ -27027,7 +28541,7 @@ function registerCrawlerTools(server) {
|
|
|
27027
28541
|
url: z$1.string().url().optional(),
|
|
27028
28542
|
reportId: z$1.string().optional(),
|
|
27029
28543
|
site: z$1.string().optional(),
|
|
27030
|
-
maxPages:
|
|
28544
|
+
maxPages: crawlPageLimit,
|
|
27031
28545
|
fetchIntervalCap: z$1.number().int().positive().optional(),
|
|
27032
28546
|
fetchIntervalMs: z$1.number().int().positive().optional(),
|
|
27033
28547
|
refresh: z$1.boolean().optional()
|
|
@@ -27057,7 +28571,7 @@ function registerCrawlerTools(server) {
|
|
|
27057
28571
|
url: z$1.string().url().optional(),
|
|
27058
28572
|
reportId: z$1.string().optional(),
|
|
27059
28573
|
site: z$1.string().optional(),
|
|
27060
|
-
maxPages:
|
|
28574
|
+
maxPages: crawlPageLimit,
|
|
27061
28575
|
maxConcepts: z$1.number().int().min(1).max(100).optional(),
|
|
27062
28576
|
includeFiles: z$1.boolean().optional(),
|
|
27063
28577
|
title: z$1.string().min(1).max(200).optional(),
|
|
@@ -28006,7 +29520,7 @@ function registerPseoTools(server) {
|
|
|
28006
29520
|
includeBrand: z$1.boolean().optional(),
|
|
28007
29521
|
refresh: z$1.boolean().optional(),
|
|
28008
29522
|
js: z$1.boolean().optional(),
|
|
28009
|
-
fetchConcurrency: z$1.number().int().min(1).max(
|
|
29523
|
+
fetchConcurrency: z$1.number().int().min(1).max(16).optional(),
|
|
28010
29524
|
fetchIntervalCap: z$1.number().int().min(1).max(100).optional(),
|
|
28011
29525
|
fetchIntervalMs: z$1.number().int().min(100).max(6e4).optional(),
|
|
28012
29526
|
detail: z$1.enum(["summary", "full"]).optional()
|
|
@@ -28079,13 +29593,15 @@ const REPORT_DEPTH = {
|
|
|
28079
29593
|
"profile, profileApplicability, dataStatus, and crawl caveats",
|
|
28080
29594
|
"summary and topActions",
|
|
28081
29595
|
"HTML and Markdown representation checks with affected URLs",
|
|
28082
|
-
"Agent Skills, llms.txt, route manifest, crawler access, and identity evidence"
|
|
29596
|
+
"Agent Skills, llms.txt, route manifest, crawler access, and identity evidence",
|
|
29597
|
+
"endpoint checks for Link headers and optional well-known files, where info means absent and optional",
|
|
29598
|
+
"for any failed or warning check, request seo_describe_report with the check id to get fix guidance"
|
|
28083
29599
|
],
|
|
28084
29600
|
doNotClaim: [
|
|
28085
29601
|
"A clean content profile is not proof of indexing, rankings, AI mentions, citations, or selection.",
|
|
28086
29602
|
"API, application, and commerce checks marked notApplicable were outside this content-site run. They did not fail.",
|
|
28087
29603
|
"An allowed crawler token does not prove that a service fetched or used the page.",
|
|
28088
|
-
"Optional discovery files are not
|
|
29604
|
+
"Optional discovery files and emerging endpoints such as MCP server cards, A2A agent cards, OAuth metadata, and auth.md are observations, not requirements or defects."
|
|
28089
29605
|
],
|
|
28090
29606
|
verify: "Run the same content profile against the same public route scope after deployment and confirm each failed or warning check changed.",
|
|
28091
29607
|
related: [
|
|
@@ -29276,16 +30792,19 @@ const REPORT_DEPTH = {
|
|
|
29276
30792
|
},
|
|
29277
30793
|
"site-crawl": {
|
|
29278
30794
|
readOrder: [
|
|
30795
|
+
"config.strategy first; use health before full on large or unknown sites",
|
|
29279
30796
|
"status, requestEvidenceStatus, attempted and fetched counts, failures, retained pages, and pageLimitReached",
|
|
30797
|
+
"access crawler identity, blockedRequests, provider counts, and samples",
|
|
29280
30798
|
"data-source states",
|
|
29281
30799
|
"warnings and caveats, then opt-in pages, requests, and issues"
|
|
29282
30800
|
],
|
|
29283
30801
|
doNotClaim: [
|
|
29284
30802
|
"A partial or capped crawl cannot support an all-clear or a definitive zero.",
|
|
29285
30803
|
"A crawl is not an index, traffic source, or guarantee of complete site coverage.",
|
|
29286
|
-
"
|
|
30804
|
+
"A health pass checks response access, status, and redirects only. It does not download page bodies or evaluate content, metadata, canonicals, indexability directives, internal links, or rendered HTML. For full crawls, state when JavaScript rendering was disabled.",
|
|
30805
|
+
"A User-Agent can be spoofed. Never ask the user for a broad firewall bypass based on crawler identity alone."
|
|
29287
30806
|
],
|
|
29288
|
-
verify: "
|
|
30807
|
+
verify: "Re-run the same health configuration first. Use a full crawl only when the health evidence is clean and the question needs page-body analysis.",
|
|
29289
30808
|
related: [
|
|
29290
30809
|
{
|
|
29291
30810
|
id: "top-fixes",
|
|
@@ -29792,11 +31311,15 @@ const REPORT_GUIDANCE = {
|
|
|
29792
31311
|
outcome: "A local setup status with the exact problems that need fixing."
|
|
29793
31312
|
},
|
|
29794
31313
|
"site-crawl": {
|
|
29795
|
-
name: "
|
|
29796
|
-
description: "
|
|
29797
|
-
useWhen: [
|
|
29798
|
-
|
|
29799
|
-
|
|
31314
|
+
name: "Sitemap health pass and technical site crawl",
|
|
31315
|
+
description: "Start with a lightweight sitemap health pass, then run a full technical crawl only when page bodies and content-level evidence are needed.",
|
|
31316
|
+
useWhen: [
|
|
31317
|
+
"You need a fast first check of sitemap URLs, statuses, redirects, robots decisions, or access blocks.",
|
|
31318
|
+
"You need to discover technical issues across linked pages.",
|
|
31319
|
+
"You want a crawl snapshot for later filtering or comparison."
|
|
31320
|
+
],
|
|
31321
|
+
avoidWhen: ["You only need to check one page or a known URL list.", "Do not run a full crawl on a large or unknown site before health=true, or when important content requires browser rendering that is not enabled."],
|
|
31322
|
+
outcome: "A fast sitemap response gate or a full crawl summary with page evidence, technical findings, crawler identity, access guidance, and an optional saved report ID."
|
|
29800
31323
|
},
|
|
29801
31324
|
"striking-distance": {
|
|
29802
31325
|
name: "Queries near page one",
|
|
@@ -30020,6 +31543,133 @@ function registerReportTools(server) {
|
|
|
30020
31543
|
registerSecondPageTool(server);
|
|
30021
31544
|
}
|
|
30022
31545
|
//#endregion
|
|
31546
|
+
//#region packages/mcp/src/agent-output-budget.ts
|
|
31547
|
+
const AGENT_STRUCTURED_OUTPUT_MAX_BYTES = 96 * 1024;
|
|
31548
|
+
function jsonBytes(value) {
|
|
31549
|
+
return Buffer.byteLength(JSON.stringify(value));
|
|
31550
|
+
}
|
|
31551
|
+
function setReturnedBytes(value) {
|
|
31552
|
+
let measured = jsonBytes(value);
|
|
31553
|
+
while (value.outputBudget.returnedBytes !== measured) {
|
|
31554
|
+
value.outputBudget.returnedBytes = measured;
|
|
31555
|
+
measured = jsonBytes(value);
|
|
31556
|
+
}
|
|
31557
|
+
return measured;
|
|
31558
|
+
}
|
|
31559
|
+
function compactValue(value, path, options, omissions) {
|
|
31560
|
+
if (typeof value === "string") {
|
|
31561
|
+
if (value.length <= options.stringLimit) return value;
|
|
31562
|
+
omissions.push({
|
|
31563
|
+
path,
|
|
31564
|
+
kind: "string",
|
|
31565
|
+
available: value.length,
|
|
31566
|
+
returned: options.stringLimit
|
|
31567
|
+
});
|
|
31568
|
+
return `${value.slice(0, Math.max(1, options.stringLimit - 3)).trimEnd()}...`;
|
|
31569
|
+
}
|
|
31570
|
+
if (Array.isArray(value)) {
|
|
31571
|
+
if (value.length > options.arrayLimit) omissions.push({
|
|
31572
|
+
path,
|
|
31573
|
+
kind: "array",
|
|
31574
|
+
available: value.length,
|
|
31575
|
+
returned: options.arrayLimit
|
|
31576
|
+
});
|
|
31577
|
+
return value.slice(0, options.arrayLimit).map((item, index) => compactValue(item, `${path}[${index}]`, options, omissions));
|
|
31578
|
+
}
|
|
31579
|
+
if (!value || typeof value !== "object") return value;
|
|
31580
|
+
const compact = {};
|
|
31581
|
+
for (const [key, item] of Object.entries(value)) {
|
|
31582
|
+
const itemPath = path ? `${path}.${key}` : key;
|
|
31583
|
+
if (key === "markdown") {
|
|
31584
|
+
omissions.push({
|
|
31585
|
+
path: itemPath,
|
|
31586
|
+
kind: "field"
|
|
31587
|
+
});
|
|
31588
|
+
continue;
|
|
31589
|
+
}
|
|
31590
|
+
compact[key] = compactValue(item, itemPath, options, omissions);
|
|
31591
|
+
}
|
|
31592
|
+
return compact;
|
|
31593
|
+
}
|
|
31594
|
+
function compactAgentWorkflowOutput(report) {
|
|
31595
|
+
const originalBytes = jsonBytes(report);
|
|
31596
|
+
for (const options of [
|
|
31597
|
+
{
|
|
31598
|
+
arrayLimit: 10,
|
|
31599
|
+
stringLimit: 4e3
|
|
31600
|
+
},
|
|
31601
|
+
{
|
|
31602
|
+
arrayLimit: 5,
|
|
31603
|
+
stringLimit: 2e3
|
|
31604
|
+
},
|
|
31605
|
+
{
|
|
31606
|
+
arrayLimit: 3,
|
|
31607
|
+
stringLimit: 1e3
|
|
31608
|
+
},
|
|
31609
|
+
{
|
|
31610
|
+
arrayLimit: 1,
|
|
31611
|
+
stringLimit: 500
|
|
31612
|
+
}
|
|
31613
|
+
]) {
|
|
31614
|
+
const omissions = [];
|
|
31615
|
+
const result = {
|
|
31616
|
+
...compactValue(report, "", options, omissions),
|
|
31617
|
+
outputBudget: {
|
|
31618
|
+
schemaVersion: 1,
|
|
31619
|
+
maxBytes: AGENT_STRUCTURED_OUTPUT_MAX_BYTES,
|
|
31620
|
+
originalBytes,
|
|
31621
|
+
returnedBytes: 0,
|
|
31622
|
+
truncated: omissions.length > 0,
|
|
31623
|
+
arrayLimit: options.arrayLimit,
|
|
31624
|
+
stringLimit: options.stringLimit,
|
|
31625
|
+
omissions: omissions.slice(0, 100),
|
|
31626
|
+
omissionsTruncated: omissions.length > 100,
|
|
31627
|
+
detail: "Counts, provenance, caveats, and compact evidence are retained. Run the related focused report when more rows are needed."
|
|
31628
|
+
}
|
|
31629
|
+
};
|
|
31630
|
+
if (setReturnedBytes(result) <= 98304) return result;
|
|
31631
|
+
}
|
|
31632
|
+
const fallbackOmissions = [];
|
|
31633
|
+
const fallbackOutput = report.output;
|
|
31634
|
+
const retainedEvidence = compactValue(Object.fromEntries(Object.entries({
|
|
31635
|
+
provenance: report.provenance ?? fallbackOutput?.provenance,
|
|
31636
|
+
caveats: report.caveats ?? fallbackOutput?.caveats,
|
|
31637
|
+
warnings: report.warnings ?? fallbackOutput?.warnings,
|
|
31638
|
+
selection: report.selection ?? fallbackOutput?.selection,
|
|
31639
|
+
totals: report.totals ?? fallbackOutput?.totals
|
|
31640
|
+
}).filter(([, value]) => value !== void 0)), "retainedEvidence", {
|
|
31641
|
+
arrayLimit: 10,
|
|
31642
|
+
stringLimit: 2e3
|
|
31643
|
+
}, fallbackOmissions);
|
|
31644
|
+
const fallback = {
|
|
31645
|
+
workflow: report.workflow,
|
|
31646
|
+
site: report.site,
|
|
31647
|
+
generatedAt: report.generatedAt,
|
|
31648
|
+
summary: report.summary,
|
|
31649
|
+
steps: report.steps,
|
|
31650
|
+
actions: Array.isArray(report.actions) ? report.actions.slice(0, 5) : [],
|
|
31651
|
+
output: {
|
|
31652
|
+
dataStatus: report.output?.dataStatus ?? "partial",
|
|
31653
|
+
detail: "The detailed workflow exceeded the agent output budget. Run the related focused reports for section evidence."
|
|
31654
|
+
},
|
|
31655
|
+
retainedEvidence,
|
|
31656
|
+
outputBudget: {
|
|
31657
|
+
schemaVersion: 1,
|
|
31658
|
+
maxBytes: AGENT_STRUCTURED_OUTPUT_MAX_BYTES,
|
|
31659
|
+
originalBytes,
|
|
31660
|
+
returnedBytes: 0,
|
|
31661
|
+
truncated: true,
|
|
31662
|
+
fallback: true,
|
|
31663
|
+
omissions: [{
|
|
31664
|
+
path: "output",
|
|
31665
|
+
kind: "field"
|
|
31666
|
+
}, ...fallbackOmissions.slice(0, 99)]
|
|
31667
|
+
}
|
|
31668
|
+
};
|
|
31669
|
+
setReturnedBytes(fallback);
|
|
31670
|
+
return fallback;
|
|
31671
|
+
}
|
|
31672
|
+
//#endregion
|
|
30023
31673
|
//#region packages/mcp/src/workflow-tools.ts
|
|
30024
31674
|
const technicalWatchInputSchema = z$1.strictObject({
|
|
30025
31675
|
site: z$1.string().trim().min(1).max(2048),
|
|
@@ -30048,10 +31698,12 @@ const technicalWatchInputSchema = z$1.strictObject({
|
|
|
30048
31698
|
});
|
|
30049
31699
|
});
|
|
30050
31700
|
function workflowSuccess(result) {
|
|
30051
|
-
|
|
31701
|
+
const presentation = workflowPresentation(result);
|
|
31702
|
+
const structured = compactAgentWorkflowOutput({
|
|
30052
31703
|
...result,
|
|
30053
|
-
presentation
|
|
30054
|
-
}
|
|
31704
|
+
presentation
|
|
31705
|
+
});
|
|
31706
|
+
return toolSuccess(result.summary, structured, { markdown: renderWorkflowMarkdown(result) });
|
|
30055
31707
|
}
|
|
30056
31708
|
function registerWorkflowTools(server) {
|
|
30057
31709
|
server.registerTool("seo_workflow_diagnose_property", {
|
|
@@ -30404,6 +32056,226 @@ function getReportDefinition(id) {
|
|
|
30404
32056
|
return definitionsById.get(id);
|
|
30405
32057
|
}
|
|
30406
32058
|
//#endregion
|
|
32059
|
+
//#region packages/mcp/src/check-fixes.ts
|
|
32060
|
+
const CHECK_FIXES = { "agent-readiness": {
|
|
32061
|
+
"crawler-access": {
|
|
32062
|
+
goal: "Keep robots.txt policy for AI crawler tokens intentional.",
|
|
32063
|
+
fix: "Review the blocked tokens in the check evidence. Remove accidental Disallow rules for crawlers you want, and keep deliberate blocks documented so nobody reverts them by mistake.",
|
|
32064
|
+
prompt: "Open robots.txt and list every rule that affects the AI crawler tokens named in the agent-readiness crawler-access evidence. For each blocked token, decide with the site owner whether the block is intentional. Remove only the accidental rules, redeploy, then re-run the agent-readiness report and confirm the crawler-access check lists no unintended blocks.",
|
|
32065
|
+
resources: [{
|
|
32066
|
+
title: "RFC 9309: Robots Exclusion Protocol",
|
|
32067
|
+
url: "https://www.rfc-editor.org/rfc/rfc9309"
|
|
32068
|
+
}],
|
|
32069
|
+
verify: "Re-run the report and confirm the blocked list contains only intentional tokens. An allowed token still does not prove the service crawls the site."
|
|
32070
|
+
},
|
|
32071
|
+
"protocol-canonicalization": {
|
|
32072
|
+
goal: "Serve one canonical HTTPS origin with a permanent redirect and HSTS.",
|
|
32073
|
+
fix: "Redirect the HTTP variant to HTTPS with a 301 or 308, redirect the alternate www or bare host to the preferred host, and send Strict-Transport-Security once HTTPS is stable everywhere.",
|
|
32074
|
+
prompt: "Configure the web server or CDN so http:// requests return one permanent redirect to the https:// start URL, and the non-preferred host redirects to the preferred host. Add a Strict-Transport-Security header on HTTPS responses. Then re-run the agent-readiness report and confirm protocol-canonicalization passes.",
|
|
32075
|
+
resources: [{
|
|
32076
|
+
title: "MDN: Strict-Transport-Security",
|
|
32077
|
+
url: "https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Strict-Transport-Security"
|
|
32078
|
+
}],
|
|
32079
|
+
verify: "curl -I the HTTP and alternate-host variants and confirm one permanent redirect each, then re-run the report."
|
|
32080
|
+
},
|
|
32081
|
+
"markdown-coverage": {
|
|
32082
|
+
goal: "Give every public HTML page a Markdown representation.",
|
|
32083
|
+
fix: "Serve text/markdown through content negotiation or advertise one working Markdown alternative per page with a link rel=\"alternate\" type=\"text/markdown\" tag, generated from the same content source as the HTML.",
|
|
32084
|
+
prompt: "For each URL listed in the markdown-coverage check, make the route return Markdown when requested with Accept: text/markdown, or publish a .md alternate advertised from the HTML head. Generate the Markdown from the same source as the HTML so the two cannot drift. Re-run the agent-readiness report and confirm markdown-coverage passes.",
|
|
32085
|
+
resources: [{
|
|
32086
|
+
title: "Markdown for Agents",
|
|
32087
|
+
url: "https://developers.cloudflare.com/agents/markdown-for-agents/"
|
|
32088
|
+
}],
|
|
32089
|
+
verify: "Request each affected URL with Accept: text/markdown and confirm a 200 text/markdown response."
|
|
32090
|
+
},
|
|
32091
|
+
"markdown-negotiation": {
|
|
32092
|
+
goal: "Make Markdown content negotiation honest and cache-safe.",
|
|
32093
|
+
fix: "Honour Accept q-values, send Vary: Accept on negotiated responses, and keep the explicit .md file and the negotiated response byte-identical.",
|
|
32094
|
+
prompt: "Fix the routes listed in the markdown-negotiation check so that Accept: text/markdown returns Markdown, an Accept header that refuses Markdown returns HTML, every negotiated response carries Vary: Accept, and the explicit alternate matches the negotiated body byte for byte. Re-run the agent-readiness report and confirm markdown-negotiation passes.",
|
|
32095
|
+
resources: [{
|
|
32096
|
+
title: "RFC 9110: Content negotiation",
|
|
32097
|
+
url: "https://www.rfc-editor.org/rfc/rfc9110#name-content-negotiation"
|
|
32098
|
+
}],
|
|
32099
|
+
verify: "Request one affected URL twice, once with Accept: text/markdown and once with text/markdown;q=0, and confirm the content types differ while Vary: Accept is present."
|
|
32100
|
+
},
|
|
32101
|
+
"markdown-determinism": {
|
|
32102
|
+
goal: "Return identical Markdown bytes for repeated requests.",
|
|
32103
|
+
fix: "Remove timestamps, request ids, random ordering, or per-request rewriting from the Markdown pipeline so the same route always produces the same bytes.",
|
|
32104
|
+
prompt: "Fetch one affected URL from the markdown-determinism check twice and diff the responses. Remove whatever changes between requests, usually timestamps or randomised output, from the generator. Re-run the agent-readiness report and confirm markdown-determinism passes.",
|
|
32105
|
+
resources: [],
|
|
32106
|
+
verify: "Fetch the same Markdown URL twice and confirm the SHA-256 digests match."
|
|
32107
|
+
},
|
|
32108
|
+
"markdown-token-estimates": {
|
|
32109
|
+
goal: "Publish a stable token estimate on Markdown responses.",
|
|
32110
|
+
fix: "Add an X-Markdown-Tokens header with a deterministic estimate, and keep the value identical between the explicit and negotiated responses for the same document.",
|
|
32111
|
+
prompt: "Add an X-Markdown-Tokens response header to the Markdown routes listed in the markdown-token-estimates check, computed deterministically from the response body. Re-run the agent-readiness report and confirm the check passes.",
|
|
32112
|
+
resources: [],
|
|
32113
|
+
verify: "Request an affected URL and confirm the header is present and unchanged on a second request."
|
|
32114
|
+
},
|
|
32115
|
+
"markdown-quality": {
|
|
32116
|
+
goal: "Keep Markdown alternatives clean, structured, and complete.",
|
|
32117
|
+
fix: "Fix the shared HTML to Markdown converter rather than individual files. Keep one H1 and the frontmatter title, exclude layout components, and stop SVG, script, and style markup leaking into the output.",
|
|
32118
|
+
prompt: "Open the Markdown for each URL in the markdown-quality check and identify the defects the evidence names, such as leaked markup, duplicate prose, or navigation-only output. Fix the converter or component exclusion rules that cause them, then re-run the agent-readiness report and confirm markdown-quality passes.",
|
|
32119
|
+
resources: [],
|
|
32120
|
+
verify: "Read one regenerated Markdown file end to end and confirm the useful copy survived without presentation markup."
|
|
32121
|
+
},
|
|
32122
|
+
"agent-skills": {
|
|
32123
|
+
goal: "Publish a valid, digest-verified Agent Skills index.",
|
|
32124
|
+
fix: "Serve /.well-known/agent-skills/index.json with a $schema field and a skills array. Each skill needs a same-origin Markdown SKILL.md with name and description frontmatter, and a sha256 digest generated from the exact deployed bytes.",
|
|
32125
|
+
prompt: "Publish or repair the Agent Skills discovery index at /.well-known/agent-skills/index.json. Regenerate every declared digest from the deployed SKILL.md bytes, keep skill URLs same-origin, serve them as text/markdown with an Access-Control-Allow-Origin header, and confirm each file has name and description frontmatter. Re-run the agent-readiness report and confirm agent-skills passes.",
|
|
32126
|
+
resources: [{
|
|
32127
|
+
title: "Agent Skills discovery specification",
|
|
32128
|
+
url: "https://agentskills.io/specification"
|
|
32129
|
+
}],
|
|
32130
|
+
verify: "Fetch the index and one skill file, recompute the SHA-256 digest, and confirm it matches the declared value."
|
|
32131
|
+
},
|
|
32132
|
+
"llms-txt": {
|
|
32133
|
+
goal: "Keep llms.txt short, stable, and pointing at pages that resolve.",
|
|
32134
|
+
fix: "Curate the file to useful entry points. Remove duplicate, redirected, broken, and non-indexable links, keep it under the size cap, and generate it deterministically so repeated fetches return identical bytes.",
|
|
32135
|
+
prompt: "Fix the llms.txt issues listed in the check evidence: remove or repair every malformed, duplicate, redirected, broken, or non-indexable link, and make the file deterministic. The generate-llms-txt report can rebuild it from the crawl. Re-run the agent-readiness report and confirm llms-txt passes. Do not present llms.txt as a Google ranking factor.",
|
|
32136
|
+
resources: [{
|
|
32137
|
+
title: "llms.txt proposal",
|
|
32138
|
+
url: "https://llmstxt.org/"
|
|
32139
|
+
}, {
|
|
32140
|
+
title: "Google guidance on llms.txt",
|
|
32141
|
+
url: "https://developers.google.com/search/updates#clarifying-guidance-on-llms-txt-files"
|
|
32142
|
+
}],
|
|
32143
|
+
verify: "Fetch llms.txt twice and confirm identical bytes, then spot-check that each listed link returns 200 without redirecting."
|
|
32144
|
+
},
|
|
32145
|
+
"llms-full-txt": {
|
|
32146
|
+
goal: "Publish llms-full.txt only when a consumer wants the expanded file.",
|
|
32147
|
+
fix: "Generate /llms-full.txt from the same source as llms.txt, serve it as text/plain or text/markdown, and keep it deterministic. Skip it entirely if nothing consumes it.",
|
|
32148
|
+
prompt: "If an intended consumer needs the expanded companion file, generate /llms-full.txt deterministically from the same content source as llms.txt and serve it with a text content type. Re-run the agent-readiness report and confirm the llms-full-txt check reflects the published file.",
|
|
32149
|
+
resources: [{
|
|
32150
|
+
title: "llms.txt proposal",
|
|
32151
|
+
url: "https://llmstxt.org/"
|
|
32152
|
+
}],
|
|
32153
|
+
verify: "Fetch /llms-full.txt and confirm a 200 with a text content type and stable bytes across two requests."
|
|
32154
|
+
},
|
|
32155
|
+
"content-signals": {
|
|
32156
|
+
goal: "Declare one deliberate Content Signals policy.",
|
|
32157
|
+
fix: "Add a Content-Signal directive to robots.txt stating the search, ai-input, and ai-train preferences the site owner actually wants. If response headers also carry the policy, keep every surface identical.",
|
|
32158
|
+
prompt: "Agree the content usage policy with the site owner, then add one Content-Signal line to robots.txt, for example Content-Signal: search=yes, ai-input=yes, ai-train=no. If HTML or Markdown responses also send a content-signal header, make the values match robots.txt exactly. Re-run the agent-readiness report and confirm content-signals passes.",
|
|
32159
|
+
resources: [{
|
|
32160
|
+
title: "Content Signals policy",
|
|
32161
|
+
url: "https://contentsignals.org/"
|
|
32162
|
+
}],
|
|
32163
|
+
verify: "Fetch robots.txt and one page and confirm every observed Content-Signal value is identical."
|
|
32164
|
+
},
|
|
32165
|
+
"route-manifest": {
|
|
32166
|
+
goal: "Publish a deterministic route manifest when HTML and Markdown parity matters.",
|
|
32167
|
+
fix: "Generate /agent-routes.json from the build with a pages array of htmlPath and markdownPath entries, so a crawl can prove no orphan or missing Markdown routes were deployed.",
|
|
32168
|
+
prompt: "Generate /agent-routes.json at build time listing every public HTML route and its Markdown alternate. Fix any routes the route-manifest evidence lists as missing or orphaned, then re-run the agent-readiness report and confirm route-manifest passes.",
|
|
32169
|
+
resources: [],
|
|
32170
|
+
verify: "Compare the manifest against the deployed routes and confirm no missing or orphan entries remain."
|
|
32171
|
+
},
|
|
32172
|
+
"link-headers": {
|
|
32173
|
+
goal: "Advertise machine-readable resources with Link response headers.",
|
|
32174
|
+
fix: "Add Link headers to the start page pointing at resources that exist. Use registered relation types such as api-catalog, service-desc, service-doc, or describedby, and emerging types such as llms-txt or agent-skills only when the target resolves.",
|
|
32175
|
+
prompt: "Add Link response headers to the homepage that point at real machine-readable resources, for example Link: </.well-known/api-catalog>; rel=\"api-catalog\" or Link: </llms.txt>; rel=\"llms-txt\"; type=\"text/markdown\". Confirm every advertised target returns 200, then re-run the agent-readiness report and confirm link-headers passes.",
|
|
32176
|
+
resources: [{
|
|
32177
|
+
title: "RFC 8288: Web Linking",
|
|
32178
|
+
url: "https://www.rfc-editor.org/rfc/rfc8288"
|
|
32179
|
+
}, {
|
|
32180
|
+
title: "IANA link relation registry",
|
|
32181
|
+
url: "https://www.iana.org/assignments/link-relations/link-relations.xhtml"
|
|
32182
|
+
}],
|
|
32183
|
+
verify: "curl -I the homepage and confirm the Link header lists the intended relation types and each target resolves."
|
|
32184
|
+
},
|
|
32185
|
+
"mcp-server-card": {
|
|
32186
|
+
goal: "Describe a real public MCP endpoint with a server card.",
|
|
32187
|
+
fix: "Serve /.well-known/mcp/server-card.json with serverInfo (name and version), the transport endpoint, and capabilities. Publish it only when the MCP endpoint actually exists.",
|
|
32188
|
+
prompt: "If this site exposes a public MCP endpoint, publish /.well-known/mcp/server-card.json as JSON with serverInfo, the transport endpoint, and capabilities that match the deployed server. The format is a draft MCP proposal, so note that in any claims. Re-run the agent-readiness report and confirm mcp-server-card reflects the published card.",
|
|
32189
|
+
resources: [{
|
|
32190
|
+
title: "MCP server card proposal (SEP-1649)",
|
|
32191
|
+
url: "https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2127"
|
|
32192
|
+
}],
|
|
32193
|
+
verify: "Fetch the card, confirm it parses as JSON with serverInfo, and confirm the declared endpoint accepts an MCP connection."
|
|
32194
|
+
},
|
|
32195
|
+
"a2a-agent-card": {
|
|
32196
|
+
goal: "Describe a real agent-to-agent endpoint with an agent card.",
|
|
32197
|
+
fix: "Serve /.well-known/agent-card.json with the agent name, url, and capabilities from the A2A specification. Publish it only when the site hosts an A2A endpoint.",
|
|
32198
|
+
prompt: "If this site hosts an agent-to-agent endpoint, publish /.well-known/agent-card.json as JSON with at least name, url, and capabilities matching the deployed endpoint. Re-run the agent-readiness report and confirm a2a-agent-card reflects the published card.",
|
|
32199
|
+
resources: [{
|
|
32200
|
+
title: "A2A protocol specification",
|
|
32201
|
+
url: "https://a2a-protocol.org/latest/specification/"
|
|
32202
|
+
}],
|
|
32203
|
+
verify: "Fetch the card, confirm it parses as JSON with a name, and confirm the declared endpoint responds."
|
|
32204
|
+
},
|
|
32205
|
+
"oauth-discovery": {
|
|
32206
|
+
goal: "Let clients discover the authorization server for protected APIs.",
|
|
32207
|
+
fix: "Publish /.well-known/openid-configuration for OpenID Connect or /.well-known/oauth-authorization-server for plain OAuth 2.0, including issuer, authorization_endpoint, token_endpoint, and jwks_uri.",
|
|
32208
|
+
prompt: "If this origin has OAuth-protected APIs, publish authorization server metadata at /.well-known/oauth-authorization-server or /.well-known/openid-configuration with the issuer and endpoint URLs of the real authorization server. Re-run the agent-readiness report and confirm oauth-discovery reflects the published metadata.",
|
|
32209
|
+
resources: [{
|
|
32210
|
+
title: "RFC 8414: OAuth 2.0 Authorization Server Metadata",
|
|
32211
|
+
url: "https://www.rfc-editor.org/rfc/rfc8414"
|
|
32212
|
+
}, {
|
|
32213
|
+
title: "OpenID Connect Discovery",
|
|
32214
|
+
url: "https://openid.net/specs/openid-connect-discovery-1_0.html"
|
|
32215
|
+
}],
|
|
32216
|
+
verify: "Fetch the metadata, confirm the issuer matches the origin, and complete one token flow against the declared endpoints."
|
|
32217
|
+
},
|
|
32218
|
+
"oauth-protected-resource": {
|
|
32219
|
+
goal: "Tell clients how to obtain tokens for protected resources.",
|
|
32220
|
+
fix: "Publish /.well-known/oauth-protected-resource with the resource identifier, the authorization_servers list, and scopes_supported per RFC 9728.",
|
|
32221
|
+
prompt: "If this origin serves OAuth-protected APIs, publish /.well-known/oauth-protected-resource as JSON with resource, authorization_servers, and scopes_supported values that match the real deployment. Re-run the agent-readiness report and confirm oauth-protected-resource reflects the published metadata.",
|
|
32222
|
+
resources: [{
|
|
32223
|
+
title: "RFC 9728: OAuth 2.0 Protected Resource Metadata",
|
|
32224
|
+
url: "https://www.rfc-editor.org/rfc/rfc9728"
|
|
32225
|
+
}],
|
|
32226
|
+
verify: "Fetch the metadata and confirm each listed authorization server issues tokens the resource accepts."
|
|
32227
|
+
},
|
|
32228
|
+
"api-catalog": {
|
|
32229
|
+
goal: "Make published APIs discoverable through an API catalog.",
|
|
32230
|
+
fix: "Serve /.well-known/api-catalog as application/linkset+json with a linkset array. Each entry needs an anchor URL plus service-desc and service-doc links for the API.",
|
|
32231
|
+
prompt: "If this site publishes APIs, create /.well-known/api-catalog returning application/linkset+json with one linkset entry per API, each carrying an anchor and service-desc or service-doc links that resolve. Re-run the agent-readiness report and confirm api-catalog reflects the published catalog.",
|
|
32232
|
+
resources: [{
|
|
32233
|
+
title: "RFC 9727: api-catalog well-known URI",
|
|
32234
|
+
url: "https://www.rfc-editor.org/rfc/rfc9727"
|
|
32235
|
+
}, {
|
|
32236
|
+
title: "RFC 9264: Linkset",
|
|
32237
|
+
url: "https://www.rfc-editor.org/rfc/rfc9264"
|
|
32238
|
+
}],
|
|
32239
|
+
verify: "Fetch the catalog, confirm the linkset parses, and confirm each linked description resolves."
|
|
32240
|
+
},
|
|
32241
|
+
"web-bot-auth": {
|
|
32242
|
+
goal: "Publish signing keys when this operator signs its own bot requests.",
|
|
32243
|
+
fix: "Serve /.well-known/http-message-signatures-directory with the JSON Web Key Set used to sign outbound bot requests, per the Web Bot Auth draft.",
|
|
32244
|
+
prompt: "If this operator runs bots that sign requests with Web Bot Auth, publish the signing keys as a JWKS at /.well-known/http-message-signatures-directory. Re-run the agent-readiness report and confirm web-bot-auth reflects the published directory.",
|
|
32245
|
+
resources: [{
|
|
32246
|
+
title: "Web Bot Auth draft",
|
|
32247
|
+
url: "https://datatracker.ietf.org/doc/draft-meunier-web-bot-auth-architecture/"
|
|
32248
|
+
}],
|
|
32249
|
+
verify: "Fetch the directory, confirm the keys array parses, and confirm a signed request validates against a published key."
|
|
32250
|
+
},
|
|
32251
|
+
"auth-md": {
|
|
32252
|
+
goal: "Explain agent registration when agents can really sign up.",
|
|
32253
|
+
fix: "Serve /auth.md as Markdown describing how an agent registers and authenticates, and pair it with real OAuth discovery metadata at the well-known paths.",
|
|
32254
|
+
prompt: "If agents can register for and authenticate with this site, publish /auth.md as a Markdown guide covering registration, credential types, and token endpoints, consistent with the OAuth metadata the site publishes. Re-run the agent-readiness report and confirm auth-md reflects the published file.",
|
|
32255
|
+
resources: [{
|
|
32256
|
+
title: "auth.md proposal",
|
|
32257
|
+
url: "https://github.com/workos/auth.md"
|
|
32258
|
+
}],
|
|
32259
|
+
verify: "Fetch /auth.md, confirm a text or Markdown content type, and follow the documented registration steps once."
|
|
32260
|
+
},
|
|
32261
|
+
"identity-graph": {
|
|
32262
|
+
goal: "Connect truthful WebSite, creator, and page-level structured data.",
|
|
32263
|
+
fix: "Add JSON-LD that links the WebSite node, a Person or Organization creator, and page-level types. Use sameAs only for official profiles, and add SoftwareApplication only when the site represents software.",
|
|
32264
|
+
prompt: "Add or repair the JSON-LD identity graph: one WebSite node, a truthful Person or Organization creator or publisher, and appropriate page-level types, all linked by id references. Use sameAs only for official profiles of the same entity. Re-run the agent-readiness report and confirm identity-graph passes.",
|
|
32265
|
+
resources: [{
|
|
32266
|
+
title: "Schema.org WebSite",
|
|
32267
|
+
url: "https://schema.org/WebSite"
|
|
32268
|
+
}],
|
|
32269
|
+
verify: "Validate the JSON-LD parses and confirm each claimed profile link belongs to the same entity."
|
|
32270
|
+
}
|
|
32271
|
+
} };
|
|
32272
|
+
function listFixableChecks(reportId) {
|
|
32273
|
+
return Object.keys(CHECK_FIXES[reportId] ?? {}).sort();
|
|
32274
|
+
}
|
|
32275
|
+
function getCheckFix(reportId, checkId) {
|
|
32276
|
+
return CHECK_FIXES[reportId]?.[checkId];
|
|
32277
|
+
}
|
|
32278
|
+
//#endregion
|
|
30407
32279
|
//#region packages/mcp/src/reports.ts
|
|
30408
32280
|
function validationMessage(error) {
|
|
30409
32281
|
return error.issues.map((issue) => {
|
|
@@ -30413,9 +32285,25 @@ function validationMessage(error) {
|
|
|
30413
32285
|
function listReports(category) {
|
|
30414
32286
|
return listReportDefinitions(category);
|
|
30415
32287
|
}
|
|
32288
|
+
function describeReportCheck(id, check) {
|
|
32289
|
+
const report = getReportDefinition(id);
|
|
32290
|
+
if (!report) throw new SeoError("INVALID_INPUT", `Unknown report: ${id}.`);
|
|
32291
|
+
const fixableChecks = listFixableChecks(report.id);
|
|
32292
|
+
if (fixableChecks.length === 0) throw new SeoError("INVALID_INPUT", `Report ${report.id} has no per-check fix guidance yet.`);
|
|
32293
|
+
const checkFix = getCheckFix(report.id, check);
|
|
32294
|
+
if (!checkFix) throw new SeoError("INVALID_INPUT", `Unknown check ${check} for ${report.id}. Fix guidance exists for: ${fixableChecks.join(", ")}.`);
|
|
32295
|
+
return {
|
|
32296
|
+
id: report.id,
|
|
32297
|
+
category: report.category,
|
|
32298
|
+
name: report.name,
|
|
32299
|
+
check,
|
|
32300
|
+
checkFix
|
|
32301
|
+
};
|
|
32302
|
+
}
|
|
30416
32303
|
function describeReport(id) {
|
|
30417
32304
|
const report = getReportDefinition(id);
|
|
30418
32305
|
if (!report) throw new SeoError("INVALID_INPUT", `Unknown report: ${id}.`);
|
|
32306
|
+
const fixableChecks = listFixableChecks(report.id);
|
|
30419
32307
|
return {
|
|
30420
32308
|
id: report.id,
|
|
30421
32309
|
category: report.category,
|
|
@@ -30428,6 +32316,7 @@ function describeReport(id) {
|
|
|
30428
32316
|
doNotClaim: report.doNotClaim,
|
|
30429
32317
|
verify: report.verify,
|
|
30430
32318
|
related: report.related,
|
|
32319
|
+
...fixableChecks.length > 0 ? { fixableChecks } : {},
|
|
30431
32320
|
inputSchema: z$1.toJSONSchema(report.inputSchema)
|
|
30432
32321
|
};
|
|
30433
32322
|
}
|
|
@@ -30468,16 +32357,23 @@ function registerDiscoveryTools(server, options = {}) {
|
|
|
30468
32357
|
});
|
|
30469
32358
|
});
|
|
30470
32359
|
server.registerTool("seo_describe_report", {
|
|
30471
|
-
description: "Explain when to use one SEO report, what it returns, and which parameters it accepts",
|
|
30472
|
-
inputSchema: {
|
|
32360
|
+
description: "Explain when to use one SEO report, what it returns, and which parameters it accepts. Pass check to get fix guidance for one failed check id.",
|
|
32361
|
+
inputSchema: {
|
|
32362
|
+
id: z$1.string().trim().min(1).max(100),
|
|
32363
|
+
check: z$1.string().trim().min(1).max(100).optional()
|
|
32364
|
+
},
|
|
30473
32365
|
outputSchema: openOutputSchema,
|
|
30474
32366
|
annotations: {
|
|
30475
32367
|
readOnlyHint: true,
|
|
30476
32368
|
destructiveHint: false,
|
|
30477
32369
|
idempotentHint: true
|
|
30478
32370
|
}
|
|
30479
|
-
}, async ({ id }) => {
|
|
32371
|
+
}, async ({ id, check }) => {
|
|
30480
32372
|
try {
|
|
32373
|
+
if (check !== void 0) {
|
|
32374
|
+
const report = describeReportCheck(id, check);
|
|
32375
|
+
return toolSuccess(`${report.id} ${report.check}: ${report.checkFix.goal}`, { report });
|
|
32376
|
+
}
|
|
30481
32377
|
const report = describeReport(id);
|
|
30482
32378
|
return toolSuccess(`${report.id}: ${report.description}`, { report });
|
|
30483
32379
|
} catch (error) {
|
|
@@ -30530,6 +32426,6 @@ async function startMcpServer(opts = {}) {
|
|
|
30530
32426
|
await server.connect(transport);
|
|
30531
32427
|
}
|
|
30532
32428
|
//#endregion
|
|
30533
|
-
export { REPORT_CATEGORIES, createServer, describeReport, executeReport, listReports, runReport, startMcpServer };
|
|
32429
|
+
export { REPORT_CATEGORIES, createServer, describeReport, describeReportCheck, executeReport, listReports, runReport, startMcpServer };
|
|
30534
32430
|
|
|
30535
32431
|
//# sourceMappingURL=mcp.js.map
|