seo 0.2.7 → 0.2.9
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/PRIVACY.md +35 -11
- package/README.md +34 -4
- package/dist/cli.js +111 -105
- package/dist/cli.js.map +1 -1
- package/dist/{dist-B6UxI6sc.js → dist-BrBR9umh.js} +1 -1
- package/dist/dist-C6zXDARm.js +651 -0
- package/dist/dist-C6zXDARm.js.map +1 -0
- package/dist/index.d.ts +2707 -48
- package/dist/index.js +2332 -611
- package/dist/index.js.map +1 -1
- package/dist/mcp.d.ts +25 -4
- package/dist/mcp.js +2539 -627
- package/dist/mcp.js.map +1 -1
- package/dist/{package-BI-bLeV4.js → package-C51oI-6o.js} +2 -2
- package/dist/{package-BI-bLeV4.js.map → package-C51oI-6o.js.map} +1 -1
- package/package.json +3 -3
- package/skills/seo/SKILL.md +31 -5
- package/dist/dist-C0LWlNAw.js +0 -560
- package/dist/dist-C0LWlNAw.js.map +0 -1
package/dist/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;
|
|
@@ -6291,16 +7121,15 @@ async function inspectMarkdownPage(input) {
|
|
|
6291
7121
|
repeatedHashStable: null,
|
|
6292
7122
|
markdownCanonicalMatchesHtml: null
|
|
6293
7123
|
};
|
|
6294
|
-
|
|
6295
|
-
const explicitUrl = advertisedUrls[0];
|
|
7124
|
+
const explicitUrl = advertisedUrls.length === 1 ? advertisedUrls[0] : void 0;
|
|
6296
7125
|
const [explicit, negotiated, repeated] = await Promise.all([
|
|
6297
|
-
fetchRepresentation({
|
|
7126
|
+
explicitUrl ? fetchRepresentation({
|
|
6298
7127
|
url: explicitUrl,
|
|
6299
7128
|
accept: "text/markdown",
|
|
6300
7129
|
timeoutMs: input.timeoutMs,
|
|
6301
7130
|
fetch: input.fetch,
|
|
6302
7131
|
signal: input.signal
|
|
6303
|
-
}),
|
|
7132
|
+
}) : Promise.resolve(void 0),
|
|
6304
7133
|
fetchRepresentation({
|
|
6305
7134
|
url: input.page.finalUrl,
|
|
6306
7135
|
accept: "text/markdown",
|
|
@@ -6309,20 +7138,21 @@ async function inspectMarkdownPage(input) {
|
|
|
6309
7138
|
signal: input.signal
|
|
6310
7139
|
}),
|
|
6311
7140
|
fetchRepresentation({
|
|
6312
|
-
url: explicitUrl,
|
|
7141
|
+
url: explicitUrl ?? input.page.finalUrl,
|
|
6313
7142
|
accept: "text/markdown",
|
|
6314
7143
|
timeoutMs: input.timeoutMs,
|
|
6315
7144
|
fetch: input.fetch,
|
|
6316
7145
|
signal: input.signal
|
|
6317
7146
|
})
|
|
6318
7147
|
]);
|
|
6319
|
-
observation.explicit = explicit.observation;
|
|
7148
|
+
if (explicit) observation.explicit = explicit.observation;
|
|
6320
7149
|
observation.negotiated = negotiated.observation;
|
|
6321
7150
|
observation.repeated = repeated.observation;
|
|
6322
|
-
observation.explicitMatchesNegotiated = explicit
|
|
6323
|
-
|
|
6324
|
-
observation.
|
|
6325
|
-
|
|
7151
|
+
observation.explicitMatchesNegotiated = explicit?.observation.sha256 && negotiated.observation.sha256 ? explicit.observation.sha256 === negotiated.observation.sha256 : null;
|
|
7152
|
+
const primary = explicit ?? negotiated;
|
|
7153
|
+
observation.repeatedHashStable = primary.observation.sha256 && repeated.observation.sha256 ? primary.observation.sha256 === repeated.observation.sha256 : null;
|
|
7154
|
+
observation.markdownCanonicalMatchesHtml = sameDocument(primary.observation.canonicalUrl, input.page.finalUrl);
|
|
7155
|
+
if (primary.body) observation.quality = markdownQuality(primary.body, input.page);
|
|
6326
7156
|
return observation;
|
|
6327
7157
|
}
|
|
6328
7158
|
async function fetchText(input) {
|
|
@@ -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({
|
|
@@ -6658,10 +7492,13 @@ async function collectAgentDiscovery(input) {
|
|
|
6658
7492
|
const wwwLocation = www.location ? new URL(www.location, wwwUrl).toString() : void 0;
|
|
6659
7493
|
const qZeroContentType = qZero.observation.contentType;
|
|
6660
7494
|
const advertisedPages = observations.filter((observation) => observation.htmlAlternateUnique).length;
|
|
6661
|
-
const evaluatedPages = observations.filter((observation) =>
|
|
7495
|
+
const evaluatedPages = observations.filter((observation) => {
|
|
7496
|
+
const representation = observation.explicit ?? observation.negotiated;
|
|
7497
|
+
return representation?.status !== void 0 && representation.status >= 200 && representation.status < 300 && /^\s*text\/markdown\b/iu.test(representation.contentType ?? "");
|
|
7498
|
+
}).length;
|
|
6662
7499
|
const warnings = [...pages.length !== input.pages.length ? [`Agent representations were evaluated for ${pages.length} successful HTML pages from ${input.pages.length} retained documents.`] : [], ...routeManifest.valid ? [] : ["No valid public agent route manifest was available for orphan checks."]].sort();
|
|
6663
7500
|
const htmlContentSignals = pages.map((page) => headerValue$1(page.responseHeaders, "content-signal")).filter((value) => Boolean(value));
|
|
6664
|
-
const markdownContentSignals = observations.map((observation) => observation.explicit?.contentSignal).filter((value) => Boolean(value));
|
|
7501
|
+
const markdownContentSignals = observations.map((observation) => observation.explicit?.contentSignal ?? observation.negotiated?.contentSignal).filter((value) => Boolean(value));
|
|
6665
7502
|
const contentSignalValues = [.../* @__PURE__ */ new Set([...htmlContentSignals, ...markdownContentSignals])].sort();
|
|
6666
7503
|
return {
|
|
6667
7504
|
profile: "content",
|
|
@@ -6701,6 +7538,7 @@ async function collectAgentDiscovery(input) {
|
|
|
6701
7538
|
routeManifest,
|
|
6702
7539
|
agentSkills,
|
|
6703
7540
|
llmsTxt,
|
|
7541
|
+
endpointDiscovery,
|
|
6704
7542
|
contentSignals: {
|
|
6705
7543
|
htmlValues: [...new Set(htmlContentSignals)].sort(),
|
|
6706
7544
|
markdownValues: [...new Set(markdownContentSignals)].sort(),
|
|
@@ -6744,7 +7582,11 @@ function section$1(id, title, checks) {
|
|
|
6744
7582
|
};
|
|
6745
7583
|
}
|
|
6746
7584
|
function failedMarkdownUrls(discovery) {
|
|
6747
|
-
return discovery.markdownAlternates.pages.filter((page) =>
|
|
7585
|
+
return discovery.markdownAlternates.pages.filter((page) => {
|
|
7586
|
+
const negotiatedOk = page.negotiated?.status === 200 && /^\s*text\/markdown\b/iu.test(page.negotiated.contentType ?? "") && page.negotiated.varyAccept;
|
|
7587
|
+
const explicitOk = !page.htmlAlternateUnique ? true : page.httpAlternateUrls.length === 1 && page.explicit?.status === 200 && /^\s*text\/markdown\b/iu.test(page.explicit.contentType ?? "") && page.explicitMatchesNegotiated === true && page.markdownCanonicalMatchesHtml === true;
|
|
7588
|
+
return !negotiatedOk || !explicitOk || page.repeatedHashStable !== true;
|
|
7589
|
+
}).map((page) => page.htmlUrl);
|
|
6748
7590
|
}
|
|
6749
7591
|
function qualityFailureUrls(discovery) {
|
|
6750
7592
|
return discovery.markdownAlternates.pages.filter((page) => {
|
|
@@ -6786,20 +7628,25 @@ function markdownChecks(discovery) {
|
|
|
6786
7628
|
const markdown = discovery.markdownAlternates;
|
|
6787
7629
|
const failedUrls = failedMarkdownUrls(discovery);
|
|
6788
7630
|
const qualityUrls = qualityFailureUrls(discovery);
|
|
6789
|
-
const
|
|
6790
|
-
const
|
|
7631
|
+
const negotiatedPages = markdown.pages.filter((page) => page.negotiated?.status === 200 && /^\s*text\/markdown\b/iu.test(page.negotiated.contentType ?? "")).length;
|
|
7632
|
+
const explicitPages = markdown.pages.filter((page) => page.htmlAlternateUnique).length;
|
|
7633
|
+
const coverageComplete = markdown.eligibleHtmlPages > 0 && markdown.evaluatedPages === markdown.eligibleHtmlPages;
|
|
7634
|
+
const negotiationComplete = markdown.eligibleHtmlPages > 0 && negotiatedPages === markdown.eligibleHtmlPages && markdown.pages.every((page) => page.negotiated?.varyAccept) && markdown.pages.every((page) => !page.htmlAlternateUnique || page.explicitMatchesNegotiated === true);
|
|
6791
7635
|
const stable = markdown.eligibleHtmlPages > 0 && markdown.stableResponses === markdown.eligibleHtmlPages;
|
|
6792
|
-
const tokenHeaders = markdown.pages.filter((page) =>
|
|
7636
|
+
const tokenHeaders = markdown.pages.filter((page) => {
|
|
7637
|
+
return (page.explicit ?? page.negotiated)?.markdownTokens !== void 0 && (!page.explicit || !page.negotiated || page.explicit.markdownTokens === page.negotiated.markdownTokens);
|
|
7638
|
+
}).length;
|
|
6793
7639
|
return [
|
|
6794
7640
|
check$1("representations", {
|
|
6795
7641
|
id: "markdown-coverage",
|
|
6796
7642
|
status: markdown.eligibleHtmlPages === 0 ? "unknown" : coverageComplete ? "pass" : "fail",
|
|
6797
|
-
title: coverageComplete ? "Every successful HTML page
|
|
6798
|
-
plainEnglish: `${markdown.
|
|
6799
|
-
action: coverageComplete ? "Keep
|
|
7643
|
+
title: coverageComplete ? "Every successful HTML page has a Markdown representation" : "Markdown representation coverage is incomplete",
|
|
7644
|
+
plainEnglish: `${markdown.evaluatedPages} of ${markdown.eligibleHtmlPages} successful HTML pages returned Markdown. ${explicitPages} advertised an explicit alternative and ${negotiatedPages} supported content negotiation.`,
|
|
7645
|
+
action: coverageComplete ? "Keep the negotiated or explicit Markdown response stable as routes change." : "Return text/markdown through content negotiation or advertise one working Markdown alternative for each public HTML page.",
|
|
6800
7646
|
evidence: {
|
|
6801
7647
|
eligibleHtmlPages: markdown.eligibleHtmlPages,
|
|
6802
7648
|
advertisedPages: markdown.advertisedPages,
|
|
7649
|
+
negotiatedPages,
|
|
6803
7650
|
evaluatedPages: markdown.evaluatedPages
|
|
6804
7651
|
},
|
|
6805
7652
|
urls: failedUrls.slice(0, 25)
|
|
@@ -6807,18 +7654,22 @@ function markdownChecks(discovery) {
|
|
|
6807
7654
|
check$1("representations", {
|
|
6808
7655
|
id: "markdown-token-estimates",
|
|
6809
7656
|
status: markdown.evaluatedPages === 0 ? "unknown" : tokenHeaders === markdown.evaluatedPages ? "pass" : "warning",
|
|
6810
|
-
title: tokenHeaders === markdown.evaluatedPages ? "Every Markdown response includes
|
|
6811
|
-
plainEnglish: `${tokenHeaders} of ${markdown.evaluatedPages} evaluated pages returned
|
|
6812
|
-
action: "
|
|
6813
|
-
urls: markdown.pages.filter((page) =>
|
|
7657
|
+
title: tokenHeaders === markdown.evaluatedPages ? "Every Markdown response includes a token estimate" : "Some Markdown responses are missing a stable token estimate",
|
|
7658
|
+
plainEnglish: `${tokenHeaders} of ${markdown.evaluatedPages} evaluated pages returned X-Markdown-Tokens. Paired explicit and negotiated responses agreed where both existed. The value is an estimate, not an exact model-specific token count.`,
|
|
7659
|
+
action: "Return a stable token estimate and keep it consistent when both explicit and negotiated responses exist.",
|
|
7660
|
+
urls: markdown.pages.filter((page) => {
|
|
7661
|
+
return (page.explicit ?? page.negotiated)?.markdownTokens === void 0 || page.explicit !== void 0 && page.negotiated !== void 0 && page.explicit.markdownTokens !== page.negotiated.markdownTokens;
|
|
7662
|
+
}).map((page) => page.htmlUrl).slice(0, 25)
|
|
6814
7663
|
}),
|
|
6815
7664
|
check$1("representations", {
|
|
6816
7665
|
id: "markdown-negotiation",
|
|
6817
|
-
status: markdown.eligibleHtmlPages === 0 ? "unknown" :
|
|
6818
|
-
title:
|
|
6819
|
-
plainEnglish: `${
|
|
6820
|
-
action: "
|
|
7666
|
+
status: markdown.eligibleHtmlPages === 0 ? "unknown" : negotiationComplete && discovery.contentNegotiation.qZeroHonoured ? "pass" : "fail",
|
|
7667
|
+
title: negotiationComplete && discovery.contentNegotiation.qZeroHonoured ? "Markdown content negotiation works across the site" : "Markdown content negotiation needs attention",
|
|
7668
|
+
plainEnglish: `${negotiatedPages} of ${markdown.eligibleHtmlPages} pages returned negotiated Markdown. ${markdown.exactByteMatches} of ${explicitPages} paired explicit responses matched byte for byte. A request that refuses Markdown ${discovery.contentNegotiation.qZeroHonoured ? "received HTML" : "did not produce confirmed HTML evidence"}.`,
|
|
7669
|
+
action: "Honour Accept q-values, send Vary: Accept, and keep paired explicit and negotiated responses identical when both exist.",
|
|
6821
7670
|
evidence: {
|
|
7671
|
+
negotiatedPages,
|
|
7672
|
+
explicitPages,
|
|
6822
7673
|
exactByteMatches: markdown.exactByteMatches,
|
|
6823
7674
|
qZero: discovery.contentNegotiation
|
|
6824
7675
|
},
|
|
@@ -6828,8 +7679,8 @@ function markdownChecks(discovery) {
|
|
|
6828
7679
|
id: "markdown-determinism",
|
|
6829
7680
|
status: markdown.eligibleHtmlPages === 0 ? "unknown" : stable ? "pass" : "fail",
|
|
6830
7681
|
title: stable ? "Repeated Markdown responses are stable" : "Repeated Markdown responses changed during the audit",
|
|
6831
|
-
plainEnglish: `${markdown.stableResponses} of ${markdown.eligibleHtmlPages} repeated
|
|
6832
|
-
action: "
|
|
7682
|
+
plainEnglish: `${markdown.stableResponses} of ${markdown.eligibleHtmlPages} repeated Markdown responses kept the same SHA-256 digest.`,
|
|
7683
|
+
action: "Remove timestamps, random output, or other runtime rewriting that changes the same Markdown response between requests.",
|
|
6833
7684
|
evidence: { stableResponses: markdown.stableResponses },
|
|
6834
7685
|
urls: failedUrls.slice(0, 25)
|
|
6835
7686
|
}),
|
|
@@ -6843,7 +7694,59 @@ function markdownChecks(discovery) {
|
|
|
6843
7694
|
})
|
|
6844
7695
|
];
|
|
6845
7696
|
}
|
|
6846
|
-
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) {
|
|
6847
7750
|
const skills = discovery.agentSkills;
|
|
6848
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);
|
|
6849
7752
|
const llms = discovery.llmsTxt;
|
|
@@ -6887,14 +7790,7 @@ function discoveryChecks(discovery) {
|
|
|
6887
7790
|
...llms.missingCrawlRoutes
|
|
6888
7791
|
].slice(0, 25)
|
|
6889
7792
|
}),
|
|
6890
|
-
|
|
6891
|
-
id: "content-signals",
|
|
6892
|
-
status: discovery.contentSignals.consistent === null ? "unknown" : discovery.contentSignals.consistent ? "pass" : "warning",
|
|
6893
|
-
title: discovery.contentSignals.consistent ? "HTML and Markdown publish one explicit Content Signals policy" : "Content Signals are missing or inconsistent across representations",
|
|
6894
|
-
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.`,
|
|
6895
|
-
action: "Choose the site policy deliberately and return the same Content-Signal value on HTML and Markdown responses.",
|
|
6896
|
-
evidence: discovery.contentSignals
|
|
6897
|
-
}),
|
|
7793
|
+
contentSignalsCheck(discovery, robotsContentSignals),
|
|
6898
7794
|
check$1("discovery", {
|
|
6899
7795
|
id: "route-manifest",
|
|
6900
7796
|
status: !discovery.routeManifest.valid ? "info" : discovery.routeManifest.missingHtmlRoutes.length === 0 && discovery.routeManifest.missingMarkdownRoutes.length === 0 && discovery.routeManifest.orphanMarkdownRoutes.length === 0 ? "pass" : "warning",
|
|
@@ -6905,6 +7801,126 @@ function discoveryChecks(discovery) {
|
|
|
6905
7801
|
})
|
|
6906
7802
|
];
|
|
6907
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
|
+
}
|
|
6908
7924
|
function accessChecks(report, discovery) {
|
|
6909
7925
|
const bots = report.ai?.robotsTxt?.botAccess ?? [];
|
|
6910
7926
|
const blocked = bots.filter((bot) => bot.allowed === false);
|
|
@@ -6943,10 +7959,10 @@ function identityChecks(report) {
|
|
|
6943
7959
|
const officialProfiles = [...new Set(report.pages.flatMap((page) => [...page.schemaSameAs ?? [], ...page.socialProfileLinks ?? []]))];
|
|
6944
7960
|
return [check$1("identity", {
|
|
6945
7961
|
id: "identity-graph",
|
|
6946
|
-
status:
|
|
6947
|
-
title:
|
|
6948
|
-
plainEnglish: `
|
|
6949
|
-
action: "Connect truthful
|
|
7962
|
+
status: hasWebsite && hasCreator && pageTypes.length > 0 ? "pass" : "warning",
|
|
7963
|
+
title: hasWebsite && hasCreator && pageTypes.length > 0 ? "Structured identity covers the website, creator, and pages" : "The structured identity graph is missing a useful entity layer",
|
|
7964
|
+
plainEnglish: `Website identity: ${hasWebsite ? "present" : "missing"}. Creator or publisher identity: ${hasCreator ? "present" : "missing"}. Page-level types: ${pageTypes.length ? pageTypes.join(", ") : "missing"}. Software identity: ${hasSoftware ? "present" : "not observed and not required for a content site"}.`,
|
|
7965
|
+
action: "Connect truthful WebSite, creator or publisher, and page nodes. Add SoftwareApplication only when the site actually represents software. Use sameAs only for official profiles that identify the same entity.",
|
|
6950
7966
|
evidence: {
|
|
6951
7967
|
schemaTypes: [...types].sort(),
|
|
6952
7968
|
officialProfiles: officialProfiles.sort()
|
|
@@ -7006,7 +8022,8 @@ function agentReadiness(report) {
|
|
|
7006
8022
|
section$1("profile", "Profile scope", profileChecks(discovery)),
|
|
7007
8023
|
section$1("access", "Crawler and protocol access", accessChecks(report, discovery)),
|
|
7008
8024
|
section$1("representations", "HTML and Markdown representations", markdownChecks(discovery)),
|
|
7009
|
-
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)),
|
|
7010
8027
|
section$1("identity", "Identity evidence", identityChecks(report))
|
|
7011
8028
|
];
|
|
7012
8029
|
const checks = sections.flatMap((item) => item.checks);
|
|
@@ -7043,6 +8060,7 @@ function agentReadiness(report) {
|
|
|
7043
8060
|
...discovery.warnings,
|
|
7044
8061
|
"A clean content profile does not prove indexing, rankings, AI mentions, citations, or selection.",
|
|
7045
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.",
|
|
7046
8064
|
"API, application, and commerce checks were not applicable to this content-site run."
|
|
7047
8065
|
]
|
|
7048
8066
|
};
|
|
@@ -7434,7 +8452,7 @@ function aiReadiness(report) {
|
|
|
7434
8452
|
score: 0,
|
|
7435
8453
|
evaluated: Boolean(agentDiscovery),
|
|
7436
8454
|
title: agentDiscovery ? "Markdown alternatives were checked" : "Markdown alternatives were not evaluated",
|
|
7437
|
-
plainEnglish: agentDiscovery ? `${agentDiscovery.markdownAlternates.
|
|
8455
|
+
plainEnglish: agentDiscovery ? `${agentDiscovery.markdownAlternates.evaluatedPages} of ${agentDiscovery.markdownAlternates.eligibleHtmlPages} successful HTML pages returned Markdown through an explicit alternative or content negotiation. ${agentDiscovery.markdownAlternates.exactByteMatches} paired explicit responses matched content negotiation byte for byte.` : "This crawl does not include the focused representation checks, so it cannot say whether HTML pages publish stable Markdown alternatives.",
|
|
7438
8456
|
action: "Use the focused agent-readiness report when you need route coverage, content negotiation, byte stability, or extraction-quality evidence.",
|
|
7439
8457
|
evidence: agentDiscovery ? {
|
|
7440
8458
|
eligibleHtmlPages: agentDiscovery.markdownAlternates.eligibleHtmlPages,
|
|
@@ -7644,6 +8662,15 @@ function originHost(url) {
|
|
|
7644
8662
|
function cleanTitle(page) {
|
|
7645
8663
|
return page.title ?? page.h1 ?? (new URL(page.finalUrl).pathname || page.finalUrl);
|
|
7646
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
|
+
}
|
|
7647
8674
|
function sectionForPage(page) {
|
|
7648
8675
|
const path = new URL(page.finalUrl).pathname.toLowerCase();
|
|
7649
8676
|
if (/\/(docs|documentation|guide|guides|help|kb|learn)\b/.test(path)) return "Docs and guides";
|
|
@@ -7776,7 +8803,7 @@ function generateLlmsTxt(report, options = {}) {
|
|
|
7776
8803
|
let sectionCount = 0;
|
|
7777
8804
|
for (const page of sectionPages) {
|
|
7778
8805
|
const title = cleanTitle(page).replace(/\]/g, "\\]");
|
|
7779
|
-
const note = page.metaDescription ? ` - ${page.metaDescription
|
|
8806
|
+
const note = page.metaDescription ? ` - ${truncateAtWord(page.metaDescription, 160)}` : "";
|
|
7780
8807
|
const line = `- [${title}](${page.finalUrl})${note}`;
|
|
7781
8808
|
if (estimatedTokens([
|
|
7782
8809
|
...lines,
|
|
@@ -7794,14 +8821,29 @@ function generateLlmsTxt(report, options = {}) {
|
|
|
7794
8821
|
counts[sectionName] = sectionCount;
|
|
7795
8822
|
if (sectionCount < sectionPages.length) break;
|
|
7796
8823
|
}
|
|
7797
|
-
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.`);
|
|
7798
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
|
+
}
|
|
7799
8831
|
const content = `${lines.join("\n")}\n`;
|
|
7800
8832
|
return {
|
|
7801
8833
|
content,
|
|
7802
8834
|
includedUrls,
|
|
7803
8835
|
estimatedTokens: estimatedTokens(content),
|
|
7804
|
-
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
|
+
}
|
|
7805
8847
|
};
|
|
7806
8848
|
}
|
|
7807
8849
|
//#endregion
|
|
@@ -8413,6 +9455,24 @@ const RULE_DEFINITIONS = [
|
|
|
8413
9455
|
impactIfIgnored: "The page cannot be crawled, ranked, or cited while the connection failure remains.",
|
|
8414
9456
|
howToVerify: "Re-run the crawl and confirm the URL returns a normal HTTP status instead of status 0."
|
|
8415
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
|
+
},
|
|
8416
9476
|
{
|
|
8417
9477
|
id: "client_error",
|
|
8418
9478
|
title: "Client error",
|
|
@@ -8901,6 +9961,7 @@ const RULE_RECOMMENDATIONS = {
|
|
|
8901
9961
|
nofollow: "review",
|
|
8902
9962
|
x_robots_noindex: "review",
|
|
8903
9963
|
robots_blocked: "review",
|
|
9964
|
+
crawler_access_blocked: "review",
|
|
8904
9965
|
orphan_page: "review",
|
|
8905
9966
|
redirected_url: "review",
|
|
8906
9967
|
slow_response: "review",
|
|
@@ -9119,6 +10180,14 @@ function auditCrawlRequests(requests) {
|
|
|
9119
10180
|
continue;
|
|
9120
10181
|
}
|
|
9121
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
|
+
}
|
|
9122
10191
|
const redirectChain = request.redirectChain ?? [];
|
|
9123
10192
|
if (redirectChain.length > 0 || !sameUrl(request.requestedUrl, request.finalUrl)) issues.push(requestIssue("redirected_url", request, request.finalUrl ? `Final URL: ${request.finalUrl}` : void 0, {
|
|
9124
10193
|
requestedUrl: request.requestedUrl,
|
|
@@ -9154,6 +10223,7 @@ function auditCrawlPages(pages, opts = {}) {
|
|
|
9154
10223
|
}));
|
|
9155
10224
|
continue;
|
|
9156
10225
|
}
|
|
10226
|
+
if (page.accessBlock) continue;
|
|
9157
10227
|
if (page.status >= 500) {
|
|
9158
10228
|
issues.push(issue("server_error", page, String(page.status), { status: page.status }));
|
|
9159
10229
|
if ((page.internalInlinkCount ?? 0) > 0) issues.push(issue("broken_internal_link", page, String(page.status), {
|
|
@@ -9194,6 +10264,7 @@ function auditCrawlPages(pages, opts = {}) {
|
|
|
9194
10264
|
responseTimeMs: page.responseTimeMs,
|
|
9195
10265
|
thresholdMs: SLOW_RESPONSE_MS
|
|
9196
10266
|
}));
|
|
10267
|
+
if (page.auditScope === "status") continue;
|
|
9197
10268
|
if (redirected) continue;
|
|
9198
10269
|
if (isHtmlPage(page) && (page.sizeBytes ?? 0) > LARGE_HTML_BYTES) issues.push(issue("large_html", page, `${Math.round((page.sizeBytes ?? 0) / 1024)} KB`, {
|
|
9199
10270
|
sizeBytes: page.sizeBytes,
|
|
@@ -9429,6 +10500,9 @@ function crawlSkipReasonCountsFromRecord(input = {}) {
|
|
|
9429
10500
|
}
|
|
9430
10501
|
//#endregion
|
|
9431
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;
|
|
9432
10506
|
const SENSITIVE_FIELD_NAMES = /* @__PURE__ */ new Set([
|
|
9433
10507
|
"authorization",
|
|
9434
10508
|
"cookie",
|
|
@@ -9444,33 +10518,53 @@ const SENSITIVE_PARAM_PATTERN = /^(access_?token|auth|api_?key|client_?secret|co
|
|
|
9444
10518
|
function uniqueSorted(values = []) {
|
|
9445
10519
|
return [...new Set(values.filter(Boolean))].sort();
|
|
9446
10520
|
}
|
|
9447
|
-
function normalizeFetchRate(input) {
|
|
10521
|
+
function normalizeFetchRate(input, strategy) {
|
|
10522
|
+
const defaultConcurrency = strategy === "health" ? 4 : 8;
|
|
9448
10523
|
return {
|
|
9449
|
-
concurrency: input.fetchRate?.concurrency ?? input.concurrency ??
|
|
10524
|
+
concurrency: input.fetchRate?.concurrency ?? input.concurrency ?? defaultConcurrency,
|
|
9450
10525
|
...input.fetchRate?.intervalCap !== void 0 ? { intervalCap: input.fetchRate.intervalCap } : {},
|
|
9451
10526
|
...input.fetchRate?.intervalMs !== void 0 ? { intervalMs: input.fetchRate.intervalMs } : {},
|
|
9452
10527
|
...input.fetchRate?.backpressure ? { backpressure: input.fetchRate.backpressure } : {}
|
|
9453
10528
|
};
|
|
9454
10529
|
}
|
|
9455
10530
|
function normalizeCrawlConfig(input) {
|
|
10531
|
+
const strategy = input.strategy ?? "full";
|
|
10532
|
+
const concurrency = input.concurrency ?? (strategy === "health" ? 4 : 8);
|
|
9456
10533
|
return {
|
|
9457
10534
|
url: new URL(input.url).toString(),
|
|
9458
|
-
mode: input.mode ?? "site",
|
|
10535
|
+
mode: input.mode ?? (strategy === "health" ? "sitemap" : "site"),
|
|
10536
|
+
strategy,
|
|
10537
|
+
...input.sitemapUrl ? { sitemapUrl: new URL(input.sitemapUrl).toString() } : {},
|
|
9459
10538
|
urls: uniqueSorted(input.urls ?? []),
|
|
9460
10539
|
maxPages: input.maxPages ?? 500,
|
|
9461
10540
|
maxDepth: input.maxDepth ?? 16,
|
|
9462
|
-
concurrency
|
|
10541
|
+
concurrency,
|
|
9463
10542
|
timeoutMs: input.timeoutMs ?? 2e4,
|
|
9464
10543
|
include: uniqueSorted(input.include ?? []),
|
|
9465
10544
|
exclude: uniqueSorted(input.exclude ?? []),
|
|
9466
10545
|
respectRobots: input.respectRobots ?? true,
|
|
9467
10546
|
useSitemap: input.useSitemap ?? true,
|
|
9468
|
-
checkExternal: input.checkExternal ?? true,
|
|
9469
|
-
checkAgentDiscovery: input.checkAgentDiscovery ?? false,
|
|
9470
|
-
js: normalizeJavaScriptRenderingMode(input.js),
|
|
9471
|
-
refresh: input.refresh ?? false,
|
|
9472
|
-
fetchRate: normalizeFetchRate(input)
|
|
9473
|
-
};
|
|
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;
|
|
9474
10568
|
}
|
|
9475
10569
|
function crawlConfigHash(config) {
|
|
9476
10570
|
const normalized = normalizeCrawlConfig(config);
|
|
@@ -9587,10 +10681,12 @@ function summarizeCrawlReport(input) {
|
|
|
9587
10681
|
}
|
|
9588
10682
|
for (const issue of input.issues) byCategory[issue.category] = (byCategory[issue.category] ?? 0) + 1;
|
|
9589
10683
|
const runStats = crawlRunStats(input.pages, input.stats);
|
|
10684
|
+
const indexabilityPages = input.pages.filter((page) => page.auditScope !== "status");
|
|
9590
10685
|
return {
|
|
9591
10686
|
totalPages: input.pages.length,
|
|
9592
|
-
|
|
9593
|
-
|
|
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,
|
|
9594
10690
|
statusErrors: input.pages.filter((page) => page.status === 0 || page.status >= 400).length,
|
|
9595
10691
|
...runStats,
|
|
9596
10692
|
attemptedRequests: input.requestEvidenceStatus === "unavailable" ? 0 : requests.length,
|
|
@@ -9608,6 +10704,23 @@ function summarizeCrawlReport(input) {
|
|
|
9608
10704
|
byCategory
|
|
9609
10705
|
};
|
|
9610
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
|
+
}
|
|
9611
10724
|
function crawlRunStats(pages, stats = {}) {
|
|
9612
10725
|
const discovered = /* @__PURE__ */ new Set();
|
|
9613
10726
|
let observedInternalLinks = 0;
|
|
@@ -9701,6 +10814,7 @@ function createCrawlReport(input) {
|
|
|
9701
10814
|
status: input.status ?? "completed",
|
|
9702
10815
|
configHash: crawlConfigHash(config),
|
|
9703
10816
|
config,
|
|
10817
|
+
access: crawlAccessSummary(requests),
|
|
9704
10818
|
summary: summarizeCrawlReport({
|
|
9705
10819
|
pages,
|
|
9706
10820
|
requests,
|
|
@@ -9723,16 +10837,19 @@ function createCrawlReport(input) {
|
|
|
9723
10837
|
};
|
|
9724
10838
|
}
|
|
9725
10839
|
function normalizeLoadedCrawlReport(report) {
|
|
10840
|
+
const config = sanitizeCrawlConfig(normalizeCrawlConfig(report.config));
|
|
9726
10841
|
const requestEvidenceStatus = report.requestEvidenceStatus ?? (Object.hasOwn(report, "requests") ? "available" : "unavailable");
|
|
9727
10842
|
const issues = sanitizeIssues(report.issues ?? []);
|
|
9728
10843
|
const requests = sortRequests(sanitizeTenantValue(report.requests ?? []) ?? []);
|
|
9729
10844
|
const pages = sortPages(sanitizePages(deriveInternalLinkAuthority(report.pages ?? [])));
|
|
9730
10845
|
return {
|
|
9731
10846
|
...sanitizeTenantValue(report),
|
|
10847
|
+
config,
|
|
10848
|
+
access: crawlAccessSummary(requests),
|
|
9732
10849
|
requestEvidenceStatus,
|
|
9733
10850
|
requests,
|
|
9734
10851
|
definitionId: report.definitionId ?? crawlDefinitionId({
|
|
9735
|
-
config
|
|
10852
|
+
config,
|
|
9736
10853
|
site: report.site,
|
|
9737
10854
|
googleAnalyticsPropertyId: report.googleAnalyticsPropertyId
|
|
9738
10855
|
}),
|
|
@@ -9788,14 +10905,61 @@ function reportFromJson(value) {
|
|
|
9788
10905
|
if (parsed && typeof parsed === "object" && "kind" in parsed && parsed.kind === "seo.crawl_report" && "report" in parsed) return normalizeLoadedCrawlReport(parsed.report);
|
|
9789
10906
|
return normalizeLoadedCrawlReport(parsed);
|
|
9790
10907
|
}
|
|
9791
|
-
function
|
|
9792
|
-
|
|
9793
|
-
|
|
9794
|
-
|
|
9795
|
-
|
|
9796
|
-
|
|
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);
|
|
9797
10960
|
if (existing) return toMeta(existing);
|
|
9798
10961
|
}
|
|
10962
|
+
if (retention === "baseline") pruneAutomaticBaselines();
|
|
9799
10963
|
return {
|
|
9800
10964
|
id: report.id,
|
|
9801
10965
|
configHash: report.configHash,
|
|
@@ -9810,10 +10974,13 @@ function saveCrawlReportToSqlite(report) {
|
|
|
9810
10974
|
}
|
|
9811
10975
|
function listCrawlReportsFromSqlite(input = {}) {
|
|
9812
10976
|
const limit = input.limit ?? 20;
|
|
9813
|
-
return (input.site ?
|
|
9814
|
-
|
|
9815
|
-
|
|
9816
|
-
|
|
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
|
|
9817
10984
|
ORDER BY created_at DESC, id DESC
|
|
9818
10985
|
LIMIT ?`).all(limit)).map(toMeta);
|
|
9819
10986
|
}
|
|
@@ -9826,10 +10993,13 @@ function deleteCrawlReportFromSqlite(id) {
|
|
|
9826
10993
|
return getDb().prepare("DELETE FROM crawl_reports WHERE id = ?").run(id).changes > 0;
|
|
9827
10994
|
}
|
|
9828
10995
|
function latestCrawlReportFromSqlite(site) {
|
|
9829
|
-
const row = site ?
|
|
9830
|
-
|
|
9831
|
-
|
|
9832
|
-
|
|
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
|
|
9833
11003
|
ORDER BY created_at DESC, id DESC
|
|
9834
11004
|
LIMIT 1`).get();
|
|
9835
11005
|
if (!row) return void 0;
|
|
@@ -9842,8 +11012,8 @@ const crawlReportStore = {
|
|
|
9842
11012
|
delete: deleteCrawlReportFromSqlite,
|
|
9843
11013
|
latest: latestCrawlReportFromSqlite
|
|
9844
11014
|
};
|
|
9845
|
-
function saveCrawlReport(report, store = crawlReportStore) {
|
|
9846
|
-
return store.save(report);
|
|
11015
|
+
function saveCrawlReport(report, store = crawlReportStore, options = {}) {
|
|
11016
|
+
return store.save(report, options);
|
|
9847
11017
|
}
|
|
9848
11018
|
function listCrawlReports(input = {}, store = crawlReportStore) {
|
|
9849
11019
|
return store.list(input);
|
|
@@ -9886,6 +11056,86 @@ function cancellationRace(signal) {
|
|
|
9886
11056
|
});
|
|
9887
11057
|
}
|
|
9888
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
|
|
9889
11139
|
//#region packages/core/dist/analyze/crawler/external-link-checks.js
|
|
9890
11140
|
const EXTERNAL_LINK_SELECTION_LIMIT = 200;
|
|
9891
11141
|
const EXTERNAL_LINK_STATES = [
|
|
@@ -10087,18 +11337,13 @@ function resolveFetchedAlias(url, aliases) {
|
|
|
10087
11337
|
}
|
|
10088
11338
|
return current;
|
|
10089
11339
|
}
|
|
10090
|
-
function
|
|
10091
|
-
const resolved =
|
|
10092
|
-
for (const [
|
|
10093
|
-
const
|
|
10094
|
-
|
|
10095
|
-
if (!resolvedTargets) {
|
|
10096
|
-
resolvedTargets = /* @__PURE__ */ new Set();
|
|
10097
|
-
resolved[resolvedSource] = resolvedTargets;
|
|
10098
|
-
}
|
|
10099
|
-
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);
|
|
10100
11345
|
}
|
|
10101
|
-
return
|
|
11346
|
+
return resolved;
|
|
10102
11347
|
}
|
|
10103
11348
|
//#endregion
|
|
10104
11349
|
//#region packages/core/dist/analyze/crawler/request-evidence.js
|
|
@@ -10110,7 +11355,9 @@ function observationFromPage(requestedUrl, page) {
|
|
|
10110
11355
|
status: page.status,
|
|
10111
11356
|
contentType: page.contentType,
|
|
10112
11357
|
durationMs: page.responseTimeMs,
|
|
10113
|
-
redirectChain: page.fetchDiagnostics?.redirectChain
|
|
11358
|
+
redirectChain: page.fetchDiagnostics?.redirectChain,
|
|
11359
|
+
accessBlock: page.accessBlock ?? page.fetchDiagnostics?.accessBlock,
|
|
11360
|
+
robotsTxt: page.robotsTxt
|
|
10114
11361
|
};
|
|
10115
11362
|
if (page.extractionStatus === "failed") return {
|
|
10116
11363
|
...observation,
|
|
@@ -10123,18 +11370,225 @@ function observationFromPage(requestedUrl, page) {
|
|
|
10123
11370
|
};
|
|
10124
11371
|
}
|
|
10125
11372
|
//#endregion
|
|
10126
|
-
//#region packages/core/dist/analyze/crawler/site-crawl-
|
|
10127
|
-
|
|
10128
|
-
|
|
10129
|
-
|
|
10130
|
-
|
|
10131
|
-
|
|
10132
|
-
|
|
10133
|
-
|
|
10134
|
-
|
|
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
|
+
});
|
|
10135
11412
|
try {
|
|
10136
|
-
|
|
10137
|
-
|
|
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,
|
|
10138
11592
|
startDate: input.window.startDate,
|
|
10139
11593
|
endDate: input.window.endDate,
|
|
10140
11594
|
limit: input.limit
|
|
@@ -10488,7 +11942,9 @@ function responseObservation(requestedUrl, fetched, extraction, extractionError)
|
|
|
10488
11942
|
status: fetched.status,
|
|
10489
11943
|
contentType: headerValue(fetched.headers, "content-type"),
|
|
10490
11944
|
durationMs: fetched.diagnostics.durationMs,
|
|
10491
|
-
redirectChain: fetched.diagnostics.redirectChain
|
|
11945
|
+
redirectChain: fetched.diagnostics.redirectChain,
|
|
11946
|
+
accessBlock: fetched.diagnostics.accessBlock,
|
|
11947
|
+
robotsTxt: fetched.robotsTxt
|
|
10492
11948
|
};
|
|
10493
11949
|
if (extraction === "failed") {
|
|
10494
11950
|
if (!extractionError) throw new Error("Failed extraction observations require an error.");
|
|
@@ -10513,13 +11969,13 @@ function unextractedPage(fetched, contentType) {
|
|
|
10513
11969
|
finalUrl: fetched.finalUrl,
|
|
10514
11970
|
status: fetched.status,
|
|
10515
11971
|
contentType,
|
|
10516
|
-
responseHeaders: safeResponseHeaders(fetched.headers),
|
|
10517
11972
|
responseTimeMs: fetched.diagnostics.durationMs,
|
|
10518
11973
|
sizeBytes: Buffer.byteLength(fetched.html),
|
|
10519
11974
|
usedJs: fetched.usedJs,
|
|
10520
11975
|
fetchSource: fetched.diagnostics.source,
|
|
10521
11976
|
cacheState: fetched.diagnostics.cache,
|
|
10522
11977
|
fetchDiagnostics: fetched.diagnostics,
|
|
11978
|
+
accessBlock: fetched.diagnostics.accessBlock,
|
|
10523
11979
|
blocked: fetched.diagnostics.blocked,
|
|
10524
11980
|
robotsTxt: fetched.robotsTxt,
|
|
10525
11981
|
xRobotsTag,
|
|
@@ -10540,6 +11996,35 @@ function unextractedPage(fetched, contentType) {
|
|
|
10540
11996
|
compression: headerValue(fetched.headers, "content-encoding")
|
|
10541
11997
|
};
|
|
10542
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
|
+
}
|
|
10543
12028
|
function indexabilityReason(page) {
|
|
10544
12029
|
if (page.status < 200 || page.status >= 300) return `Status ${page.status}`;
|
|
10545
12030
|
if (hasMetaRobotsDirective(page.metaRobots, "noindex")) return "Meta robots noindex";
|
|
@@ -10593,13 +12078,26 @@ async function crawlOne(url, opts = {}) {
|
|
|
10593
12078
|
};
|
|
10594
12079
|
}
|
|
10595
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
|
+
}
|
|
10596
12094
|
if (!isHtmlContentType(contentType)) return {
|
|
10597
12095
|
request: responseObservation(url, fetched, contentType ? "not-applicable" : "unknown-media-type"),
|
|
10598
12096
|
page: unextractedPage(fetched, contentType),
|
|
10599
12097
|
urls: []
|
|
10600
12098
|
};
|
|
10601
12099
|
try {
|
|
10602
|
-
const extracted = await extractPage(fetched);
|
|
12100
|
+
const extracted = await extractPage(fetched, "crawler");
|
|
10603
12101
|
const base = new URL(extracted.finalUrl);
|
|
10604
12102
|
const internalLinks = extracted.links.map((link) => sameOriginUrl(link.href, base)).filter((value) => Boolean(value));
|
|
10605
12103
|
const externalLinks = extracted.links.filter((link) => !link.internal).map((link) => link.href).filter((href) => /^https?:\/\//.test(href));
|
|
@@ -10720,6 +12218,62 @@ async function crawlOne(url, opts = {}) {
|
|
|
10720
12218
|
};
|
|
10721
12219
|
}
|
|
10722
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
|
+
}
|
|
10723
12277
|
//#endregion
|
|
10724
12278
|
//#region packages/core/dist/analyze/monitoring/sitemaps.js
|
|
10725
12279
|
const INVALID_LOC_SAMPLE_LIMIT = 10;
|
|
@@ -10962,6 +12516,7 @@ async function fetchSitemapUrls(input) {
|
|
|
10962
12516
|
const nestedSitemaps = [];
|
|
10963
12517
|
const discoveredSitemaps = /* @__PURE__ */ new Set();
|
|
10964
12518
|
const scheduledSitemaps = /* @__PURE__ */ new Set();
|
|
12519
|
+
const fetchedSitemaps = /* @__PURE__ */ new Set();
|
|
10965
12520
|
const invalidLocSamples = [];
|
|
10966
12521
|
const malformedLastmodSamples = [];
|
|
10967
12522
|
const futureLastmodSamples = [];
|
|
@@ -11008,12 +12563,18 @@ async function fetchSitemapUrls(input) {
|
|
|
11008
12563
|
const sitemapUrl = queue.shift();
|
|
11009
12564
|
if (!sitemapUrl) continue;
|
|
11010
12565
|
const fetched = await fetchSitemapXml(sitemapUrl);
|
|
12566
|
+
fetchedSitemaps.add(sitemapUrl);
|
|
11011
12567
|
documents.push(fetched.document);
|
|
11012
12568
|
if (fetched.document.warning) warnings.push(fetched.document.warning);
|
|
11013
12569
|
if (fetched.document.dataStatus !== "unavailable") sitemapsFetched += 1;
|
|
11014
12570
|
if (!fetched.xml || !fetched.document.root) continue;
|
|
11015
12571
|
const $ = load(fetched.xml, { xmlMode: true });
|
|
11016
12572
|
$(fetched.document.root === "urlset" ? "urlset > url" : "").each((_, element) => {
|
|
12573
|
+
if (urls.length >= limit) {
|
|
12574
|
+
urlLimitExceeded = true;
|
|
12575
|
+
omittedUrlsAtLeast += 1;
|
|
12576
|
+
return false;
|
|
12577
|
+
}
|
|
11017
12578
|
urlLocs += 1;
|
|
11018
12579
|
const entry = $(element);
|
|
11019
12580
|
const value = entry.children("loc").first().text().trim();
|
|
@@ -11038,12 +12599,7 @@ async function fetchSitemapUrls(input) {
|
|
|
11038
12599
|
return;
|
|
11039
12600
|
}
|
|
11040
12601
|
seenUrls.add(url);
|
|
11041
|
-
|
|
11042
|
-
urls.push(url);
|
|
11043
|
-
return;
|
|
11044
|
-
}
|
|
11045
|
-
urlLimitExceeded = true;
|
|
11046
|
-
omittedUrlsAtLeast += 1;
|
|
12602
|
+
urls.push(url);
|
|
11047
12603
|
});
|
|
11048
12604
|
$(fetched.document.root === "sitemapindex" ? "sitemapindex > sitemap" : "").each((_, element) => {
|
|
11049
12605
|
sitemapLocs += 1;
|
|
@@ -11076,8 +12632,13 @@ async function fetchSitemapUrls(input) {
|
|
|
11076
12632
|
queue.push(child);
|
|
11077
12633
|
} else nestedSitemapLimitExceeded = true;
|
|
11078
12634
|
});
|
|
12635
|
+
if (urls.length >= limit && queue.length > 0) {
|
|
12636
|
+
urlLimitExceeded = true;
|
|
12637
|
+
omittedUrlsAtLeast = Math.max(omittedUrlsAtLeast, 1);
|
|
12638
|
+
break;
|
|
12639
|
+
}
|
|
11079
12640
|
}
|
|
11080
|
-
const unprocessedSitemaps = nestedSitemaps.filter((sitemap) => !
|
|
12641
|
+
const unprocessedSitemaps = nestedSitemaps.filter((sitemap) => !fetchedSitemaps.has(sitemap)).length;
|
|
11081
12642
|
const possiblyTruncated = urlLimitExceeded || nestedSitemapLimitExceeded || unprocessedSitemaps > 0;
|
|
11082
12643
|
if (invalidLocCount) warnings.push(`Ignored ${invalidLocCount} invalid sitemap <loc> ${invalidLocCount === 1 ? "entry" : "entries"}.`);
|
|
11083
12644
|
if (possiblyTruncated) warnings.push("Sitemap discovery exceeded a configured URL or sitemap boundary; the returned inventory is incomplete.");
|
|
@@ -11257,6 +12818,7 @@ function resolveCrawlSiteDependencies(dependencies = {}) {
|
|
|
11257
12818
|
const emptyPageTopQuery = async () => void 0;
|
|
11258
12819
|
return {
|
|
11259
12820
|
fetchPage: dependencies.fetchPage ?? crawlOne,
|
|
12821
|
+
fetchStatusPage: dependencies.fetchStatusPage ?? crawlStatusOnly,
|
|
11260
12822
|
fetchSitemapUrls: dependencies.fetchSitemapUrls ?? fetchSitemapUrls,
|
|
11261
12823
|
fetch: dependencies.fetch ?? publicHttpFetch,
|
|
11262
12824
|
queryPageMetrics: (injectedMode === "per-page" ? dependencies.queryPageMetrics : void 0) ?? (injectedMode ? emptyPageMetrics : queryPageMetrics),
|
|
@@ -11282,6 +12844,10 @@ function isSameOrigin(url, origin) {
|
|
|
11282
12844
|
}
|
|
11283
12845
|
}
|
|
11284
12846
|
function sitemapRootUrls(input) {
|
|
12847
|
+
if (input.sitemapUrl) return [{
|
|
12848
|
+
url: new URL(input.sitemapUrl).toString(),
|
|
12849
|
+
source: "explicit"
|
|
12850
|
+
}];
|
|
11285
12851
|
const origin = new URL(input.url).origin;
|
|
11286
12852
|
const declared = input.declaredSitemapUrls.filter((url) => isSameOrigin(url, origin));
|
|
11287
12853
|
const ignored = input.declaredSitemapUrls.length - declared.length;
|
|
@@ -11358,25 +12924,6 @@ async function sitemapSeeds(input) {
|
|
|
11358
12924
|
}
|
|
11359
12925
|
//#endregion
|
|
11360
12926
|
//#region packages/core/dist/analyze/crawler/site-crawl.js
|
|
11361
|
-
const AI_BOT_USER_AGENTS = [
|
|
11362
|
-
"GPTBot",
|
|
11363
|
-
"OAI-SearchBot",
|
|
11364
|
-
"ChatGPT-User",
|
|
11365
|
-
"ClaudeBot",
|
|
11366
|
-
"PerplexityBot",
|
|
11367
|
-
"Google-Extended",
|
|
11368
|
-
"CCBot",
|
|
11369
|
-
"Applebot",
|
|
11370
|
-
"Bingbot"
|
|
11371
|
-
];
|
|
11372
|
-
const AGENT_RESOURCE_PATHS = [
|
|
11373
|
-
"/.well-known/agent.json",
|
|
11374
|
-
"/agent.json",
|
|
11375
|
-
"/.well-known/mcp.json",
|
|
11376
|
-
"/.well-known/ai-plugin.json",
|
|
11377
|
-
"/.well-known/openapi.json",
|
|
11378
|
-
"/openapi.json"
|
|
11379
|
-
];
|
|
11380
12927
|
function normalizeUrl$1(value, base) {
|
|
11381
12928
|
try {
|
|
11382
12929
|
const url = base ? new URL(value, base) : new URL(value);
|
|
@@ -11400,179 +12947,22 @@ function isLikelyAsset(url) {
|
|
|
11400
12947
|
}
|
|
11401
12948
|
function globMatch(pattern, value) {
|
|
11402
12949
|
if (!pattern.includes("*")) return value.includes(pattern);
|
|
11403
|
-
const parts = pattern.split("*");
|
|
11404
|
-
let position = 0;
|
|
11405
|
-
for (const part of parts) {
|
|
11406
|
-
if (!part) continue;
|
|
11407
|
-
const found = value.slice(position).indexOf(part);
|
|
11408
|
-
if (found === -1) return false;
|
|
11409
|
-
position += found + part.length;
|
|
11410
|
-
}
|
|
11411
|
-
return true;
|
|
11412
|
-
}
|
|
11413
|
-
function passesFilters(url, include, exclude) {
|
|
11414
|
-
return (!include.length || include.some((pattern) => globMatch(pattern, url))) && !exclude.some((pattern) => globMatch(pattern, url));
|
|
11415
|
-
}
|
|
11416
|
-
async function checkLlmsTxt(input) {
|
|
11417
|
-
const llmsUrl = new URL("/llms.txt", input.url).toString();
|
|
11418
|
-
const controller = abortController({
|
|
11419
|
-
timeoutMs: input.timeoutMs,
|
|
11420
|
-
signal: input.signal
|
|
11421
|
-
});
|
|
11422
|
-
try {
|
|
11423
|
-
const response = await input.fetch(llmsUrl, {
|
|
11424
|
-
profile: "bot",
|
|
11425
|
-
redirect: "follow",
|
|
11426
|
-
signal: controller.signal
|
|
11427
|
-
});
|
|
11428
|
-
const contentType = response.headers.get("content-type") ?? "";
|
|
11429
|
-
const exists = response.status >= 200 && response.status < 300 && !/\btext\/html\b/i.test(contentType);
|
|
11430
|
-
await response.body?.cancel().catch(() => void 0);
|
|
11431
|
-
return {
|
|
11432
|
-
url: response.url || llmsUrl,
|
|
11433
|
-
exists,
|
|
11434
|
-
status: response.status
|
|
11435
|
-
};
|
|
11436
|
-
} catch {
|
|
11437
|
-
return {
|
|
11438
|
-
url: llmsUrl,
|
|
11439
|
-
exists: false
|
|
11440
|
-
};
|
|
11441
|
-
} finally {
|
|
11442
|
-
controller.cleanup();
|
|
11443
|
-
}
|
|
11444
|
-
}
|
|
11445
|
-
function parseRobots(robotsUrl, text) {
|
|
11446
|
-
return robotsParserModule(robotsUrl, text);
|
|
11447
|
-
}
|
|
11448
|
-
function declaredUserAgents(text) {
|
|
11449
|
-
const declared = /* @__PURE__ */ new Set();
|
|
11450
|
-
for (const match of text.matchAll(/^\s*user-agent\s*:\s*(.+?)\s*$/gim)) {
|
|
11451
|
-
const value = match[1]?.trim().toLowerCase();
|
|
11452
|
-
if (value) declared.add(value);
|
|
11453
|
-
}
|
|
11454
|
-
return declared;
|
|
11455
|
-
}
|
|
11456
|
-
function sitemapUrlsFromRobots(text) {
|
|
11457
|
-
const urls = /* @__PURE__ */ new Set();
|
|
11458
|
-
for (const match of text.matchAll(/^\s*sitemap\s*:\s*(\S+)\s*$/gim)) {
|
|
11459
|
-
const value = match[1]?.trim();
|
|
11460
|
-
if (!value) continue;
|
|
11461
|
-
try {
|
|
11462
|
-
urls.add(new URL(value).toString());
|
|
11463
|
-
} catch {}
|
|
11464
|
-
}
|
|
11465
|
-
return [...urls];
|
|
11466
|
-
}
|
|
11467
|
-
async function checkRobotsAiAccess(input) {
|
|
11468
|
-
const robotsUrl = new URL("/robots.txt", input.url).toString();
|
|
11469
|
-
const controller = abortController({
|
|
11470
|
-
timeoutMs: input.timeoutMs,
|
|
11471
|
-
signal: input.signal
|
|
11472
|
-
});
|
|
11473
|
-
try {
|
|
11474
|
-
const response = await input.fetch(robotsUrl, {
|
|
11475
|
-
profile: "bot",
|
|
11476
|
-
redirect: "follow",
|
|
11477
|
-
signal: controller.signal
|
|
11478
|
-
});
|
|
11479
|
-
const text = await response.text();
|
|
11480
|
-
const available = response.status >= 200 && response.status < 300;
|
|
11481
|
-
const absent = response.status >= 400 && response.status < 500 && response.status !== 429;
|
|
11482
|
-
const availability = available ? "available" : [401, 403].includes(response.status) ? "access-blocked" : response.status === 429 ? "rate-limited" : absent ? "absent" : "unreachable";
|
|
11483
|
-
const exists = available;
|
|
11484
|
-
const declared = exists ? declaredUserAgents(text) : /* @__PURE__ */ new Set();
|
|
11485
|
-
const parsed = parseRobots(robotsUrl, exists ? text : "");
|
|
11486
|
-
return {
|
|
11487
|
-
url: response.url || robotsUrl,
|
|
11488
|
-
exists,
|
|
11489
|
-
availability,
|
|
11490
|
-
status: response.status,
|
|
11491
|
-
...["rate-limited", "unreachable"].includes(availability) ? { error: `robots.txt returned HTTP ${response.status}.` } : {},
|
|
11492
|
-
sitemapUrls: exists ? sitemapUrlsFromRobots(text) : [],
|
|
11493
|
-
botAccess: AI_BOT_USER_AGENTS.map((userAgent) => {
|
|
11494
|
-
const lower = userAgent.toLowerCase();
|
|
11495
|
-
return {
|
|
11496
|
-
userAgent,
|
|
11497
|
-
allowed: availability === "rate-limited" || availability === "unreachable" ? null : parsed.isAllowed(input.url, userAgent) ?? true,
|
|
11498
|
-
declared: declared.has(lower),
|
|
11499
|
-
coveredByWildcard: declared.has("*")
|
|
11500
|
-
};
|
|
11501
|
-
})
|
|
11502
|
-
};
|
|
11503
|
-
} catch (error) {
|
|
11504
|
-
return {
|
|
11505
|
-
url: robotsUrl,
|
|
11506
|
-
exists: false,
|
|
11507
|
-
availability: "unreachable",
|
|
11508
|
-
error: error instanceof Error ? error.message : String(error),
|
|
11509
|
-
sitemapUrls: [],
|
|
11510
|
-
botAccess: AI_BOT_USER_AGENTS.map((userAgent) => ({
|
|
11511
|
-
userAgent,
|
|
11512
|
-
allowed: null,
|
|
11513
|
-
declared: false,
|
|
11514
|
-
coveredByWildcard: false
|
|
11515
|
-
}))
|
|
11516
|
-
};
|
|
11517
|
-
} finally {
|
|
11518
|
-
controller.cleanup();
|
|
11519
|
-
}
|
|
11520
|
-
}
|
|
11521
|
-
async function checkAgentResource(input) {
|
|
11522
|
-
const url = new URL(input.path, input.baseUrl).toString();
|
|
11523
|
-
const controller = abortController({
|
|
11524
|
-
timeoutMs: input.timeoutMs,
|
|
11525
|
-
signal: input.signal
|
|
11526
|
-
});
|
|
11527
|
-
try {
|
|
11528
|
-
const response = await input.fetch(url, {
|
|
11529
|
-
profile: "bot",
|
|
11530
|
-
redirect: "follow",
|
|
11531
|
-
signal: controller.signal
|
|
11532
|
-
});
|
|
11533
|
-
const contentType = response.headers.get("content-type") ?? "";
|
|
11534
|
-
const text = await response.text();
|
|
11535
|
-
const exists = response.status >= 200 && response.status < 300;
|
|
11536
|
-
let validJson;
|
|
11537
|
-
if (exists && /\bjson\b/i.test(contentType)) try {
|
|
11538
|
-
JSON.parse(text);
|
|
11539
|
-
validJson = true;
|
|
11540
|
-
} catch {
|
|
11541
|
-
validJson = false;
|
|
11542
|
-
}
|
|
11543
|
-
return {
|
|
11544
|
-
url: response.url || url,
|
|
11545
|
-
exists,
|
|
11546
|
-
status: response.status,
|
|
11547
|
-
contentType,
|
|
11548
|
-
...validJson === void 0 ? {} : { validJson }
|
|
11549
|
-
};
|
|
11550
|
-
} catch {
|
|
11551
|
-
return {
|
|
11552
|
-
url,
|
|
11553
|
-
exists: false
|
|
11554
|
-
};
|
|
11555
|
-
} finally {
|
|
11556
|
-
controller.cleanup();
|
|
11557
|
-
}
|
|
11558
|
-
}
|
|
11559
|
-
async function checkAgentResources(input) {
|
|
11560
|
-
const resources = [];
|
|
11561
|
-
for (const path of AGENT_RESOURCE_PATHS) {
|
|
11562
|
-
if (input.signal?.aborted) break;
|
|
11563
|
-
resources.push(await checkAgentResource({
|
|
11564
|
-
baseUrl: input.url,
|
|
11565
|
-
path,
|
|
11566
|
-
timeoutMs: input.timeoutMs,
|
|
11567
|
-
fetch: input.fetch,
|
|
11568
|
-
signal: input.signal
|
|
11569
|
-
}));
|
|
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;
|
|
11570
12957
|
}
|
|
11571
|
-
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));
|
|
11572
12962
|
}
|
|
11573
12963
|
async function crawlSite(input, dependencies) {
|
|
11574
12964
|
const deps = resolveCrawlSiteDependencies(dependencies);
|
|
11575
|
-
const config = normalizeCrawlConfig(input);
|
|
12965
|
+
const config = assertCrawlConfigLimits(normalizeCrawlConfig(input));
|
|
11576
12966
|
const { searchMetricsLimit, analyticsLimit } = crawlProviderLimits(input);
|
|
11577
12967
|
const signal = input.signal;
|
|
11578
12968
|
let cancelled = Boolean(signal?.aborted);
|
|
@@ -11580,17 +12970,19 @@ async function crawlSite(input, dependencies) {
|
|
|
11580
12970
|
const site = input.site;
|
|
11581
12971
|
const origin = new URL(config.url).origin;
|
|
11582
12972
|
const warnings = [];
|
|
11583
|
-
const queue =
|
|
12973
|
+
const queue = new CrawlUrlQueue();
|
|
11584
12974
|
const visited = /* @__PURE__ */ new Set();
|
|
11585
|
-
const queued = /* @__PURE__ */ new Set();
|
|
11586
12975
|
const discovered = /* @__PURE__ */ new Set();
|
|
11587
|
-
const
|
|
12976
|
+
const incomingLinkCounts = /* @__PURE__ */ new Map();
|
|
11588
12977
|
const fetchedAliases = /* @__PURE__ */ new Map();
|
|
11589
12978
|
const pages = [];
|
|
11590
12979
|
const requests = [];
|
|
11591
12980
|
const documentIndexes = /* @__PURE__ */ new Map();
|
|
11592
12981
|
const inFlight = /* @__PURE__ */ new Set();
|
|
11593
12982
|
const followLinks = config.mode === "site";
|
|
12983
|
+
const healthStrategy = config.strategy === "health";
|
|
12984
|
+
let activeConcurrency = healthStrategy ? 1 : config.concurrency;
|
|
12985
|
+
let healthyProbeStreak = 0;
|
|
11594
12986
|
let queuedUrls = 0;
|
|
11595
12987
|
let skippedUrls = 0;
|
|
11596
12988
|
const skipReasonCounts = {};
|
|
@@ -11614,7 +13006,7 @@ async function crawlSite(input, dependencies) {
|
|
|
11614
13006
|
generatedAt: deps.now().toISOString(),
|
|
11615
13007
|
discoveredUrls: discovered.size,
|
|
11616
13008
|
queuedUrls,
|
|
11617
|
-
pendingUrls: queue.
|
|
13009
|
+
pendingUrls: queue.size,
|
|
11618
13010
|
inFlightUrls: inFlight.size,
|
|
11619
13011
|
crawledUrls: pages.length,
|
|
11620
13012
|
skippedUrls,
|
|
@@ -11628,36 +13020,27 @@ async function crawlSite(input, dependencies) {
|
|
|
11628
13020
|
const flushStatusEvents = async () => {
|
|
11629
13021
|
await statusEventChain;
|
|
11630
13022
|
};
|
|
11631
|
-
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;
|
|
11632
13031
|
try {
|
|
11633
13032
|
emitStatus("started", {
|
|
11634
13033
|
url: config.url,
|
|
11635
13034
|
message: `Started crawl for ${config.url}.`
|
|
11636
13035
|
});
|
|
11637
|
-
const aiSignals = isCancelled() ? void 0 : await
|
|
11638
|
-
|
|
11639
|
-
|
|
11640
|
-
|
|
11641
|
-
|
|
11642
|
-
|
|
11643
|
-
|
|
11644
|
-
|
|
11645
|
-
url: config.url,
|
|
11646
|
-
timeoutMs: config.timeoutMs,
|
|
11647
|
-
fetch: deps.fetch,
|
|
11648
|
-
signal
|
|
11649
|
-
}),
|
|
11650
|
-
checkAgentResources({
|
|
11651
|
-
url: config.url,
|
|
11652
|
-
timeoutMs: config.timeoutMs,
|
|
11653
|
-
fetch: deps.fetch,
|
|
11654
|
-
signal
|
|
11655
|
-
})
|
|
11656
|
-
]);
|
|
11657
|
-
const llmsTxt = aiSignals?.[0] ?? {
|
|
11658
|
-
url: new URL("/llms.txt", config.url).toString(),
|
|
11659
|
-
exists: false
|
|
11660
|
-
};
|
|
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;
|
|
11661
13044
|
const robotsTxt = aiSignals?.[1];
|
|
11662
13045
|
const agentResources = aiSignals?.[2];
|
|
11663
13046
|
cancelled = isCancelled();
|
|
@@ -11672,12 +13055,9 @@ async function crawlSite(input, dependencies) {
|
|
|
11672
13055
|
});
|
|
11673
13056
|
return;
|
|
11674
13057
|
}
|
|
11675
|
-
discovered.add(normalized);
|
|
11676
13058
|
if (visited.has(normalized)) return;
|
|
11677
|
-
if (
|
|
11678
|
-
|
|
11679
|
-
if (existing && depth < existing.depth) existing.depth = depth;
|
|
11680
|
-
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);
|
|
11681
13061
|
return;
|
|
11682
13062
|
}
|
|
11683
13063
|
if (!sameOrigin(normalized, origin)) {
|
|
@@ -11707,7 +13087,7 @@ async function crawlSite(input, dependencies) {
|
|
|
11707
13087
|
});
|
|
11708
13088
|
return;
|
|
11709
13089
|
}
|
|
11710
|
-
if (queue.
|
|
13090
|
+
if (queue.size + visited.size + inFlight.size >= config.maxPages * 5) {
|
|
11711
13091
|
recordSkip("queue-safety-limit");
|
|
11712
13092
|
queueSafetySkippedUrls += 1;
|
|
11713
13093
|
emitStatus("url_skipped", {
|
|
@@ -11717,13 +13097,12 @@ async function crawlSite(input, dependencies) {
|
|
|
11717
13097
|
});
|
|
11718
13098
|
return;
|
|
11719
13099
|
}
|
|
11720
|
-
|
|
13100
|
+
discovered.add(normalized);
|
|
11721
13101
|
queuedUrls += 1;
|
|
11722
13102
|
queue.push({
|
|
11723
13103
|
url: normalized,
|
|
11724
13104
|
depth
|
|
11725
13105
|
});
|
|
11726
|
-
queue.sort((left, right) => left.depth - right.depth || (left.url < right.url ? -1 : left.url > right.url ? 1 : 0));
|
|
11727
13106
|
emitStatus("url_queued", {
|
|
11728
13107
|
url: normalized,
|
|
11729
13108
|
depth
|
|
@@ -11736,8 +13115,9 @@ async function crawlSite(input, dependencies) {
|
|
|
11736
13115
|
if (!isCancelled() && config.useSitemap && (config.mode === "site" || config.mode === "sitemap")) {
|
|
11737
13116
|
const sitemap = await sitemapSeeds({
|
|
11738
13117
|
url: config.url,
|
|
13118
|
+
sitemapUrl: config.sitemapUrl,
|
|
11739
13119
|
maxPages: config.maxPages,
|
|
11740
|
-
declaredSitemapUrls: robotsTxt?.sitemapUrls ?? [],
|
|
13120
|
+
declaredSitemapUrls: robotsTxt?.sitemapUrls ?? healthSitemapUrls ?? [],
|
|
11741
13121
|
warnings,
|
|
11742
13122
|
fetchSitemapUrls: deps.fetchSitemapUrls
|
|
11743
13123
|
});
|
|
@@ -11745,8 +13125,8 @@ async function crawlSite(input, dependencies) {
|
|
|
11745
13125
|
sitemapDiscovery = sitemap.discovery;
|
|
11746
13126
|
}
|
|
11747
13127
|
const nextQueueItem = () => {
|
|
11748
|
-
while (queue.
|
|
11749
|
-
const item = queue.
|
|
13128
|
+
while (queue.size) {
|
|
13129
|
+
const item = queue.take();
|
|
11750
13130
|
if (!item) continue;
|
|
11751
13131
|
const normalized = normalizeUrl$1(item.url, config.url);
|
|
11752
13132
|
if (!normalized) continue;
|
|
@@ -11760,15 +13140,17 @@ async function crawlSite(input, dependencies) {
|
|
|
11760
13140
|
};
|
|
11761
13141
|
const startTask = (item) => {
|
|
11762
13142
|
let task;
|
|
13143
|
+
const fetcher = healthStrategy ? deps.fetchStatusPage : deps.fetchPage;
|
|
11763
13144
|
task = {
|
|
11764
13145
|
...item,
|
|
11765
|
-
promise:
|
|
13146
|
+
promise: fetcher(item.url, {
|
|
11766
13147
|
js: config.js,
|
|
11767
13148
|
refresh: config.refresh,
|
|
11768
13149
|
timeoutMs: config.timeoutMs,
|
|
11769
13150
|
rate: config.fetchRate,
|
|
11770
13151
|
signal,
|
|
11771
13152
|
respectRobots: config.respectRobots,
|
|
13153
|
+
robotsResolver,
|
|
11772
13154
|
renderer
|
|
11773
13155
|
}).then((result) => ({
|
|
11774
13156
|
task,
|
|
@@ -11794,8 +13176,8 @@ async function crawlSite(input, dependencies) {
|
|
|
11794
13176
|
depth: item.depth
|
|
11795
13177
|
});
|
|
11796
13178
|
};
|
|
11797
|
-
while (!isCancelled() && (queue.
|
|
11798
|
-
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) {
|
|
11799
13181
|
const item = nextQueueItem();
|
|
11800
13182
|
if (!item) break;
|
|
11801
13183
|
startTask(item);
|
|
@@ -11816,6 +13198,13 @@ async function crawlSite(input, dependencies) {
|
|
|
11816
13198
|
extraction: "not-applicable"
|
|
11817
13199
|
});
|
|
11818
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
|
+
}
|
|
11819
13208
|
if (request.outcome === "skipped") {
|
|
11820
13209
|
recordSkip(request.reason === "robots-disallowed" ? "robots-disallowed" : request.reason === "robots-deferred" ? "robots-uncertain" : "origin-backpressure");
|
|
11821
13210
|
if (request.reason === "robots-deferred") {
|
|
@@ -11878,40 +13267,49 @@ async function crawlSite(input, dependencies) {
|
|
|
11878
13267
|
};
|
|
11879
13268
|
const existing = documentIndexes.get(finalUrl);
|
|
11880
13269
|
if (existing) {
|
|
11881
|
-
const links = /* @__PURE__ */ new Set([...existing.links, ...normalizedLinks]);
|
|
11882
13270
|
const minDepth = Math.min(existing.minDepth, task.depth);
|
|
11883
13271
|
const replace = !existing.directlyRequested && directlyRequested || existing.directlyRequested === directlyRequested && sourceRequest < existing.sourceRequest;
|
|
11884
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);
|
|
11885
13287
|
page.crawlDepth = minDepth;
|
|
11886
13288
|
pages[existing.index] = page;
|
|
11887
13289
|
} else {
|
|
11888
13290
|
const existingPage = pages[existing.index];
|
|
11889
13291
|
if (existingPage) existingPage.crawlDepth = minDepth;
|
|
11890
13292
|
}
|
|
11891
|
-
const retainedPage = pages[existing.index];
|
|
11892
|
-
if (retainedPage) syncLinkEvidence(retainedPage, links);
|
|
11893
|
-
observedInternalLinks += links.size - existing.links.size;
|
|
11894
13293
|
documentIndexes.set(finalUrl, {
|
|
11895
13294
|
...existing,
|
|
11896
13295
|
directlyRequested: existing.directlyRequested || directlyRequested,
|
|
11897
13296
|
sourceRequest: replace ? sourceRequest : existing.sourceRequest,
|
|
11898
|
-
|
|
13297
|
+
pendingAliasLinks: void 0,
|
|
11899
13298
|
minDepth
|
|
11900
13299
|
});
|
|
11901
|
-
linkGraph[finalUrl] = [...links].sort((left, right) => left < right ? -1 : left > right ? 1 : 0);
|
|
11902
13300
|
} else {
|
|
11903
13301
|
const links = new Set(normalizedLinks);
|
|
11904
13302
|
syncLinkEvidence(page, links);
|
|
11905
13303
|
pages.push(page);
|
|
11906
13304
|
observedInternalLinks += links.size;
|
|
13305
|
+
for (const target of links) incomingLinkCounts.set(target, (incomingLinkCounts.get(target) ?? 0) + 1);
|
|
11907
13306
|
documentIndexes.set(finalUrl, {
|
|
11908
13307
|
index: pages.length - 1,
|
|
11909
13308
|
directlyRequested,
|
|
11910
13309
|
sourceRequest,
|
|
11911
|
-
links,
|
|
13310
|
+
pendingAliasLinks: !directlyRequested ? links : void 0,
|
|
11912
13311
|
minDepth: task.depth
|
|
11913
13312
|
});
|
|
11914
|
-
linkGraph[finalUrl] = [...links].sort((left, right) => left < right ? -1 : left > right ? 1 : 0);
|
|
11915
13313
|
}
|
|
11916
13314
|
};
|
|
11917
13315
|
if (config.respectRobots && page.robotsTxt?.allowed === false) {
|
|
@@ -11939,27 +13337,16 @@ async function crawlSite(input, dependencies) {
|
|
|
11939
13337
|
});
|
|
11940
13338
|
if (followLinks && task.depth < config.maxDepth) for (const url of result.urls) enqueue(url, task.depth + 1);
|
|
11941
13339
|
}
|
|
11942
|
-
const
|
|
11943
|
-
const
|
|
11944
|
-
const
|
|
11945
|
-
|
|
11946
|
-
|
|
11947
|
-
|
|
11948
|
-
const sourceIndex = pageIndexes.get(sourceUrl);
|
|
11949
|
-
if (sourceIndex === void 0) continue;
|
|
11950
|
-
for (const target of targets) {
|
|
11951
|
-
const targetIndex = pageIndexes.get(target);
|
|
11952
|
-
if (targetIndex === void 0) continue;
|
|
11953
|
-
const nextDepth = (depths[sourceIndex] ?? 0) + 1;
|
|
11954
|
-
if (nextDepth >= (depths[targetIndex] ?? Number.POSITIVE_INFINITY)) continue;
|
|
11955
|
-
depths[targetIndex] = nextDepth;
|
|
11956
|
-
changed = true;
|
|
11957
|
-
}
|
|
11958
|
-
}
|
|
11959
|
-
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;
|
|
11960
13346
|
}
|
|
11961
|
-
for (const [index, page] of pages.entries()) page.crawlDepth = depths[index];
|
|
11962
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.`);
|
|
11963
13350
|
if (cancelled) {
|
|
11964
13351
|
warnings.push("Crawl cancelled; returning a partial report.");
|
|
11965
13352
|
emitStatus("cancelled", {
|
|
@@ -11967,15 +13354,17 @@ async function crawlSite(input, dependencies) {
|
|
|
11967
13354
|
message: "Crawl cancelled; returning a partial report."
|
|
11968
13355
|
});
|
|
11969
13356
|
}
|
|
11970
|
-
const pageLimitReached = pages.length >= config.maxPages && (queue.
|
|
13357
|
+
const pageLimitReached = pages.length >= config.maxPages && (queue.size > 0 || inFlight.size > 0);
|
|
11971
13358
|
const sitemapEvidencePartial = sitemapDiscovery?.dataStatus === "partial";
|
|
11972
|
-
const
|
|
11973
|
-
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"));
|
|
11974
13363
|
const requestEvidenceStatus = cancelled && inFlight.size > 0 ? "partial" : "available";
|
|
11975
13364
|
const dataSources = await crawlDataSources({
|
|
11976
13365
|
cancelled,
|
|
11977
|
-
site,
|
|
11978
|
-
googleAnalyticsPropertyId: input.googleAnalyticsPropertyId,
|
|
13366
|
+
site: healthStrategy ? void 0 : site,
|
|
13367
|
+
googleAnalyticsPropertyId: healthStrategy ? void 0 : input.googleAnalyticsPropertyId,
|
|
11979
13368
|
pages,
|
|
11980
13369
|
warnings,
|
|
11981
13370
|
searchMetricsLimit,
|
|
@@ -12018,7 +13407,7 @@ async function crawlSite(input, dependencies) {
|
|
|
12018
13407
|
concurrency: Math.min(config.concurrency, 4)
|
|
12019
13408
|
}) : void 0;
|
|
12020
13409
|
for (const page of pages) {
|
|
12021
|
-
if (!page.geo) continue;
|
|
13410
|
+
if (!page.geo || !llmsTxt) continue;
|
|
12022
13411
|
page.geo = {
|
|
12023
13412
|
...page.geo,
|
|
12024
13413
|
hasLlmsTxt: llmsTxt.exists,
|
|
@@ -12035,12 +13424,11 @@ async function crawlSite(input, dependencies) {
|
|
|
12035
13424
|
pages,
|
|
12036
13425
|
requests,
|
|
12037
13426
|
requestEvidenceStatus,
|
|
12038
|
-
|
|
12039
|
-
|
|
12040
|
-
llmsTxt,
|
|
13427
|
+
...!healthStrategy ? { ai: {
|
|
13428
|
+
...llmsTxt ? { llmsTxt } : {},
|
|
12041
13429
|
...robotsTxt ? { robotsTxt } : {},
|
|
12042
13430
|
...agentResources ? { agentResources } : {}
|
|
12043
|
-
},
|
|
13431
|
+
} } : {},
|
|
12044
13432
|
...sitemapDiscovery ? { sitemapDiscovery } : {},
|
|
12045
13433
|
...externalLinkVerification ? { externalLinkVerification } : {},
|
|
12046
13434
|
...agentDiscovery ? { agentDiscovery } : {},
|
|
@@ -12534,6 +13922,14 @@ function compareCrawlReports(input) {
|
|
|
12534
13922
|
issueChanges: issues
|
|
12535
13923
|
};
|
|
12536
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
|
+
}
|
|
12537
13933
|
const OKF_MAX_CONCEPTS = 5e3;
|
|
12538
13934
|
function compareText$11(left, right) {
|
|
12539
13935
|
const leftPoints = [...left].map((value) => value.codePointAt(0) ?? 0);
|
|
@@ -12898,7 +14294,7 @@ function explainOkfValidation(validation) {
|
|
|
12898
14294
|
return {
|
|
12899
14295
|
title: "OKF bundle",
|
|
12900
14296
|
valid: validation.valid,
|
|
12901
|
-
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")}.`,
|
|
12902
14298
|
files: validation.files,
|
|
12903
14299
|
concepts: validation.concepts,
|
|
12904
14300
|
errors: errors.length,
|
|
@@ -13050,6 +14446,31 @@ const agentDiscoverySchema = z.object({
|
|
|
13050
14446
|
missingMarkdownPages: z.number().int().nonnegative(),
|
|
13051
14447
|
consistent: z.boolean().nullable()
|
|
13052
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(),
|
|
13053
14474
|
protocolVariants: z.object({
|
|
13054
14475
|
http: z.object({
|
|
13055
14476
|
url: z.string().url(),
|
|
@@ -13121,6 +14542,46 @@ const analyticsSchema = z.object({
|
|
|
13121
14542
|
totalUsers: z.number(),
|
|
13122
14543
|
conversions: z.number()
|
|
13123
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
|
+
});
|
|
13124
14585
|
const geoSignalsSchema = z.object({
|
|
13125
14586
|
semanticHtml: z.boolean(),
|
|
13126
14587
|
structuredData: z.boolean(),
|
|
@@ -13294,6 +14755,7 @@ const crawlPageSnapshotSchema = z.object({
|
|
|
13294
14755
|
consecutiveErrors: z.number().int(),
|
|
13295
14756
|
recentP95Ms: z.number().optional()
|
|
13296
14757
|
}).optional(),
|
|
14758
|
+
accessBlock: accessBlockEvidenceSchema.optional(),
|
|
13297
14759
|
robotsTxt: z.object({
|
|
13298
14760
|
url: z.string().url(),
|
|
13299
14761
|
cache: z.enum([
|
|
@@ -13376,23 +14838,12 @@ const crawlPageSnapshotSchema = z.object({
|
|
|
13376
14838
|
}).optional()
|
|
13377
14839
|
}).optional(),
|
|
13378
14840
|
blocked: z.boolean().optional(),
|
|
14841
|
+
auditScope: z.enum(["full", "status"]).optional(),
|
|
14842
|
+
accessBlock: accessBlockEvidenceSchema.optional(),
|
|
13379
14843
|
contentAuditAllowed: z.boolean().optional(),
|
|
13380
14844
|
crawlDepth: z.number().int().optional(),
|
|
13381
14845
|
error: z.string().optional(),
|
|
13382
|
-
robotsTxt:
|
|
13383
|
-
url: z.string().url(),
|
|
13384
|
-
allowed: z.boolean().nullable(),
|
|
13385
|
-
availability: z.enum([
|
|
13386
|
-
"available",
|
|
13387
|
-
"absent",
|
|
13388
|
-
"access-blocked",
|
|
13389
|
-
"rate-limited",
|
|
13390
|
-
"unreachable"
|
|
13391
|
-
]),
|
|
13392
|
-
status: z.number().int().optional(),
|
|
13393
|
-
error: z.string().optional(),
|
|
13394
|
-
matchedLine: z.string().optional()
|
|
13395
|
-
}).optional(),
|
|
14846
|
+
robotsTxt: crawlRobotsEvidenceSchema.optional(),
|
|
13396
14847
|
title: z.string().optional(),
|
|
13397
14848
|
metaDescription: z.string().optional(),
|
|
13398
14849
|
canonical: z.string().url().optional(),
|
|
@@ -13447,8 +14898,16 @@ const crawlPageSnapshotSchema = z.object({
|
|
|
13447
14898
|
extractionError: z.string().optional(),
|
|
13448
14899
|
wordCount: z.number().int(),
|
|
13449
14900
|
contentExtraction: z.object({
|
|
13450
|
-
requested: z.enum([
|
|
13451
|
-
|
|
14901
|
+
requested: z.enum([
|
|
14902
|
+
"crawler",
|
|
14903
|
+
"defuddle",
|
|
14904
|
+
"readability"
|
|
14905
|
+
]),
|
|
14906
|
+
used: z.enum([
|
|
14907
|
+
"crawler",
|
|
14908
|
+
"defuddle",
|
|
14909
|
+
"readability"
|
|
14910
|
+
]),
|
|
13452
14911
|
fallback: z.boolean(),
|
|
13453
14912
|
fallbackReason: z.enum(["defuddle_error", "defuddle_empty"]).optional(),
|
|
13454
14913
|
fallbackDetail: z.string().optional(),
|
|
@@ -13582,7 +15041,9 @@ const crawlResponseObservationBaseSchema = z.object({
|
|
|
13582
15041
|
url: z.string().url(),
|
|
13583
15042
|
status: z.number().int(),
|
|
13584
15043
|
location: z.string().url().optional()
|
|
13585
|
-
})).optional()
|
|
15044
|
+
})).optional(),
|
|
15045
|
+
accessBlock: accessBlockEvidenceSchema.optional(),
|
|
15046
|
+
robotsTxt: crawlRobotsEvidenceSchema.optional()
|
|
13586
15047
|
});
|
|
13587
15048
|
const crawlRequestObservationSchema = z.union([
|
|
13588
15049
|
crawlResponseObservationBaseSchema.extend({ extraction: z.enum([
|
|
@@ -13599,20 +15060,7 @@ const crawlRequestObservationSchema = z.union([
|
|
|
13599
15060
|
outcome: z.literal("skipped"),
|
|
13600
15061
|
durationMs: z.number().optional(),
|
|
13601
15062
|
reason: z.enum(["robots-disallowed", "robots-deferred"]),
|
|
13602
|
-
robotsTxt:
|
|
13603
|
-
url: z.string().url(),
|
|
13604
|
-
allowed: z.boolean().nullable(),
|
|
13605
|
-
availability: z.enum([
|
|
13606
|
-
"available",
|
|
13607
|
-
"absent",
|
|
13608
|
-
"access-blocked",
|
|
13609
|
-
"rate-limited",
|
|
13610
|
-
"unreachable"
|
|
13611
|
-
]),
|
|
13612
|
-
status: z.number().int().optional(),
|
|
13613
|
-
error: z.string().optional(),
|
|
13614
|
-
matchedLine: z.string().optional()
|
|
13615
|
-
}),
|
|
15063
|
+
robotsTxt: crawlRobotsEvidenceSchema,
|
|
13616
15064
|
extraction: z.literal("not-applicable")
|
|
13617
15065
|
}),
|
|
13618
15066
|
z.object({
|
|
@@ -13671,6 +15119,7 @@ const crawlAiSignalsSchema = z.object({
|
|
|
13671
15119
|
status: z.number().int().optional(),
|
|
13672
15120
|
error: z.string().optional(),
|
|
13673
15121
|
sitemapUrls: z.array(z.string().url()),
|
|
15122
|
+
contentSignals: z.array(z.string()).optional(),
|
|
13674
15123
|
botAccess: z.array(z.object({
|
|
13675
15124
|
userAgent: z.string(),
|
|
13676
15125
|
allowed: z.boolean().nullable(),
|
|
@@ -13728,7 +15177,11 @@ const crawlSitemapDiscoverySchema = z.object({
|
|
|
13728
15177
|
urlsReturned: z.number().int().nonnegative(),
|
|
13729
15178
|
roots: z.array(z.object({
|
|
13730
15179
|
url: z.string().url(),
|
|
13731
|
-
source: z.enum([
|
|
15180
|
+
source: z.enum([
|
|
15181
|
+
"explicit",
|
|
15182
|
+
"robots-txt",
|
|
15183
|
+
"default-path"
|
|
15184
|
+
]),
|
|
13732
15185
|
dataStatus: z.enum([
|
|
13733
15186
|
"complete",
|
|
13734
15187
|
"partial",
|
|
@@ -13812,6 +15265,8 @@ const crawlConfigSchema = z.object({
|
|
|
13812
15265
|
"list",
|
|
13813
15266
|
"sitemap"
|
|
13814
15267
|
]),
|
|
15268
|
+
strategy: z.enum(["full", "health"]),
|
|
15269
|
+
sitemapUrl: z.string().url().optional(),
|
|
13815
15270
|
urls: z.array(z.string().url()),
|
|
13816
15271
|
maxPages: z.number().int(),
|
|
13817
15272
|
maxDepth: z.number().int(),
|
|
@@ -13846,6 +15301,7 @@ const crawlConfigSchema = z.object({
|
|
|
13846
15301
|
});
|
|
13847
15302
|
const crawlReportSummarySchema = z.object({
|
|
13848
15303
|
totalPages: z.number().int(),
|
|
15304
|
+
statusOnlyPages: z.number().int(),
|
|
13849
15305
|
indexablePages: z.number().int(),
|
|
13850
15306
|
nonIndexablePages: z.number().int(),
|
|
13851
15307
|
statusErrors: z.number().int(),
|
|
@@ -13936,6 +15392,17 @@ const crawlReportBaseSchema = z.object({
|
|
|
13936
15392
|
]),
|
|
13937
15393
|
configHash: z.string(),
|
|
13938
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(),
|
|
13939
15406
|
summary: crawlReportSummarySchema,
|
|
13940
15407
|
pages: z.array(crawlPageSnapshotSchema),
|
|
13941
15408
|
issues: z.array(crawlIssueSchema),
|
|
@@ -14133,7 +15600,7 @@ function analyzeCtrUnderperformersFromRows(input) {
|
|
|
14133
15600
|
//#endregion
|
|
14134
15601
|
//#region packages/core/dist/analyze/ctr-underperformers.js
|
|
14135
15602
|
const MAX_GSC_ROWS$4 = 1e5;
|
|
14136
|
-
function plural$
|
|
15603
|
+
function plural$3(count, singular, pluralLabel = `${singular}s`) {
|
|
14137
15604
|
return count === 1 ? singular : pluralLabel;
|
|
14138
15605
|
}
|
|
14139
15606
|
const defaultDependencies$12 = {
|
|
@@ -14209,7 +15676,7 @@ async function ctrUnderperformersReport(input, dependencies = defaultDependencie
|
|
|
14209
15676
|
minImpressions,
|
|
14210
15677
|
limit,
|
|
14211
15678
|
brandFiltering: input.includeBrand ? "included" : "excluded",
|
|
14212
|
-
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."
|
|
14213
15680
|
},
|
|
14214
15681
|
items,
|
|
14215
15682
|
caveats: [
|
|
@@ -14218,10 +15685,10 @@ async function ctrUnderperformersReport(input, dependencies = defaultDependencie
|
|
|
14218
15685
|
`Only queries ranking position 1-10 with at least ${minImpressions} impressions were checked.`,
|
|
14219
15686
|
"Expected CTR uses a robust site-aware position benchmark when enough peer data exists, otherwise the fallback position curve.",
|
|
14220
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.",
|
|
14221
|
-
...selection.invalidRows ? [`${selection.invalidRows} invalid provider ${plural$
|
|
14222
|
-
...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.`] : [],
|
|
14223
15690
|
...possiblyTruncated ? [`The retained-row limit of ${MAX_GSC_ROWS$4} was reached, so lower-ranked source rows may be absent.`] : [],
|
|
14224
|
-
...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.`] : []
|
|
14225
15692
|
],
|
|
14226
15693
|
recommendations: top ? [
|
|
14227
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.`,
|
|
@@ -14231,14 +15698,6 @@ async function ctrUnderperformersReport(input, dependencies = defaultDependencie
|
|
|
14231
15698
|
};
|
|
14232
15699
|
}
|
|
14233
15700
|
//#endregion
|
|
14234
|
-
//#region packages/core/dist/phrasing.js
|
|
14235
|
-
function plural$3(count, singular, pluralLabel = `${singular}s`) {
|
|
14236
|
-
return count === 1 ? singular : pluralLabel;
|
|
14237
|
-
}
|
|
14238
|
-
function countLabel(count, singular, pluralLabel = `${singular}s`) {
|
|
14239
|
-
return `${count.toLocaleString("en-GB")} ${plural$3(count, singular, pluralLabel)}`;
|
|
14240
|
-
}
|
|
14241
|
-
//#endregion
|
|
14242
15701
|
//#region packages/core/dist/analyze/diagnosis-priorities.js
|
|
14243
15702
|
function topDelta(report) {
|
|
14244
15703
|
const top = report.items[0];
|
|
@@ -14265,14 +15724,14 @@ function buildDiagnosisPriorities(input) {
|
|
|
14265
15724
|
const topGroup = input.decay.groups[0];
|
|
14266
15725
|
priorities.push({
|
|
14267
15726
|
label: "Refresh decaying content",
|
|
14268
|
-
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.`,
|
|
14269
15728
|
action: topGroup ? topGroup.recommendation : "Start with declines that continued outside the update window. Check indexability first, then ranking and CTR causes.",
|
|
14270
15729
|
confidence: input.decay.dataStatus === "partial" ? "low" : "medium"
|
|
14271
15730
|
});
|
|
14272
15731
|
}
|
|
14273
15732
|
if (input.cannibal.items.length) priorities.push({
|
|
14274
15733
|
label: "Review multi-URL query candidates",
|
|
14275
|
-
reason: `${input.cannibal.selection.eligibleClusters
|
|
15734
|
+
reason: `${countLabel(input.cannibal.selection.eligibleClusters, "multi-URL query candidate")} found.`,
|
|
14276
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.",
|
|
14277
15736
|
confidence: "low"
|
|
14278
15737
|
});
|
|
@@ -14290,7 +15749,7 @@ function buildDiagnosisPriorities(input) {
|
|
|
14290
15749
|
const top = input.striking.items[0];
|
|
14291
15750
|
priorities.push({
|
|
14292
15751
|
label: "Investigate striking-distance candidates",
|
|
14293
|
-
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.`,
|
|
14294
15753
|
action: top?.recommendation.action ?? "Check technical state, query coverage, competing URLs, and relevant internal links before choosing a change.",
|
|
14295
15754
|
confidence: input.striking.source.possiblyTruncated ? "low" : top?.recommendation.confidence ?? "low"
|
|
14296
15755
|
});
|
|
@@ -16074,8 +17533,7 @@ function analyzeQuickWinsFromRows(input) {
|
|
|
16074
17533
|
selected.selection.belowMinimumRows++;
|
|
16075
17534
|
continue;
|
|
16076
17535
|
}
|
|
16077
|
-
const
|
|
16078
|
-
const excludedTargetRows = selected.rows.filter((candidate) => samePageUrl(candidate.keys[1] ?? "", targetUrl)).length;
|
|
17536
|
+
const excludedTargetRows = benchmarkContext.urlRowCount(row);
|
|
16079
17537
|
const benchmark = benchmarkContext.forUrl(row);
|
|
16080
17538
|
if (row.ctr >= benchmark.ctr) {
|
|
16081
17539
|
selected.selection.atOrAboveTargetRows++;
|
|
@@ -16269,7 +17727,7 @@ async function quickWinsReport(input, dependencies = defaultDependencies$8) {
|
|
|
16269
17727
|
brandTerms: input.brandTerms,
|
|
16270
17728
|
includeBrand: input.includeBrand
|
|
16271
17729
|
});
|
|
16272
|
-
const verificationRequested = input.verifyContent === true || input.verifyLimit !== void 0;
|
|
17730
|
+
const verificationRequested = input.verifyContent === true || input.verifyContent !== false && input.verifyLimit !== void 0;
|
|
16273
17731
|
const verificationItems = verificationRequested ? analysis.items.slice(0, verifyLimit) : [];
|
|
16274
17732
|
const fetches = /* @__PURE__ */ new Map();
|
|
16275
17733
|
const cachedFetch = (url, options) => {
|
|
@@ -16368,7 +17826,7 @@ async function quickWinsReport(input, dependencies = defaultDependencies$8) {
|
|
|
16368
17826
|
repeatedQueryGroups: groups.length,
|
|
16369
17827
|
templatePatterns: templateActions.length,
|
|
16370
17828
|
brandFiltering: input.includeBrand ? "included" : "excluded",
|
|
16371
|
-
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.`
|
|
16372
17830
|
},
|
|
16373
17831
|
caveats: [
|
|
16374
17832
|
`Date window: ${range.startDate} to ${range.endDate}, using final GSC data where available.`,
|
|
@@ -16659,7 +18117,7 @@ function templatesFromGroups(groups) {
|
|
|
16659
18117
|
function verdict$1(analysis) {
|
|
16660
18118
|
if (analysis.dataStatus === "empty") return "Search Console returned no retained query/page rows for this date window.";
|
|
16661
18119
|
if (analysis.dataStatus === "filtered") return "No retained query/page rows met the report filters.";
|
|
16662
|
-
return `${analysis.summary.eligibleRows
|
|
18120
|
+
return `${countLabel(analysis.summary.eligibleRows, "eligible query/page row")} found; ${countLabel(analysis.summary.returnedRows, "row")} returned for review.`;
|
|
16663
18121
|
}
|
|
16664
18122
|
async function strikingDistance(input, dependencies = defaultDependencies$7) {
|
|
16665
18123
|
const days = reportDays(input.days);
|
|
@@ -16681,7 +18139,7 @@ async function strikingDistance(input, dependencies = defaultDependencies$7) {
|
|
|
16681
18139
|
includeBrand: input.includeBrand
|
|
16682
18140
|
});
|
|
16683
18141
|
const verifyLimit = boundedInteger$2(input.verifyLimit, DEFAULT_VERIFY_LIMIT, 0, analysis.limit);
|
|
16684
|
-
const verificationRequested = input.verifyContent === true || input.verifyLimit !== void 0;
|
|
18142
|
+
const verificationRequested = input.verifyContent === true || input.verifyContent !== false && input.verifyLimit !== void 0;
|
|
16685
18143
|
const attempted = verificationRequested ? Math.min(verifyLimit, analysis.items.length) : 0;
|
|
16686
18144
|
const items = await Promise.all(analysis.items.map(async (item, index) => {
|
|
16687
18145
|
if (index >= attempted) return item;
|
|
@@ -19255,8 +20713,6 @@ function topCrawlRecommendations(items) {
|
|
|
19255
20713
|
url: item.url
|
|
19256
20714
|
})).sort((a, b) => priority[b.severity] - priority[a.severity]);
|
|
19257
20715
|
}
|
|
19258
|
-
//#endregion
|
|
19259
|
-
//#region packages/core/dist/analyze/monitoring/crawl-store.js
|
|
19260
20716
|
function toRun(row) {
|
|
19261
20717
|
return {
|
|
19262
20718
|
id: row.id,
|
|
@@ -19328,34 +20784,52 @@ function insertCrawlRun(run, pages, recommendations = []) {
|
|
|
19328
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));
|
|
19329
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);
|
|
19330
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);
|
|
19331
20795
|
}
|
|
19332
20796
|
function latestCrawlSummaries(site, limit = 5) {
|
|
19333
|
-
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 (
|
|
19334
20805
|
SELECT
|
|
19335
|
-
run_id,
|
|
19336
|
-
COALESCE(SUM(CASE WHEN status >= 400 THEN 1 ELSE 0 END), 0) AS status_errors,
|
|
19337
|
-
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
|
|
19338
20809
|
FROM crawl_pages
|
|
19339
|
-
|
|
20810
|
+
INNER JOIN recent_runs ON recent_runs.id = crawl_pages.run_id
|
|
20811
|
+
GROUP BY crawl_pages.run_id
|
|
19340
20812
|
),
|
|
19341
20813
|
recommendation_counts AS (
|
|
19342
20814
|
SELECT
|
|
19343
|
-
run_id,
|
|
20815
|
+
crawl_recommendations.run_id,
|
|
19344
20816
|
COUNT(*) AS recommendation_count,
|
|
19345
|
-
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
|
|
19346
20818
|
FROM crawl_recommendations
|
|
19347
|
-
|
|
20819
|
+
INNER JOIN recent_runs ON recent_runs.id = crawl_recommendations.run_id
|
|
20820
|
+
GROUP BY crawl_recommendations.run_id
|
|
19348
20821
|
),
|
|
19349
20822
|
recommendation_rank AS (
|
|
19350
20823
|
SELECT
|
|
19351
20824
|
crawl_recommendations.*,
|
|
19352
20825
|
ROW_NUMBER() OVER (
|
|
19353
|
-
PARTITION BY run_id
|
|
20826
|
+
PARTITION BY crawl_recommendations.run_id
|
|
19354
20827
|
ORDER BY
|
|
19355
|
-
CASE severity WHEN 'high' THEN 3 WHEN 'medium' THEN 2 ELSE 1 END DESC,
|
|
19356
|
-
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
|
|
19357
20830
|
) AS rank
|
|
19358
20831
|
FROM crawl_recommendations
|
|
20832
|
+
INNER JOIN recent_runs ON recent_runs.id = crawl_recommendations.run_id
|
|
19359
20833
|
)
|
|
19360
20834
|
SELECT
|
|
19361
20835
|
crawl_runs.*,
|
|
@@ -19367,14 +20841,12 @@ function latestCrawlSummaries(site, limit = 5) {
|
|
|
19367
20841
|
top.title AS top_recommendation_title,
|
|
19368
20842
|
top.action AS top_recommendation_action,
|
|
19369
20843
|
top.severity AS top_recommendation_severity
|
|
19370
|
-
FROM crawl_runs
|
|
20844
|
+
FROM recent_runs AS crawl_runs
|
|
19371
20845
|
LEFT JOIN page_counts ON page_counts.run_id = crawl_runs.id
|
|
19372
20846
|
LEFT JOIN recommendation_counts ON recommendation_counts.run_id = crawl_runs.id
|
|
19373
20847
|
LEFT JOIN recommendation_rank top ON top.run_id = crawl_runs.id AND top.rank = 1
|
|
19374
|
-
WHERE crawl_runs.site_url = ?
|
|
19375
20848
|
GROUP BY crawl_runs.id
|
|
19376
|
-
ORDER BY crawl_runs.created_at DESC
|
|
19377
|
-
LIMIT ?`).all(site, limit).map((row) => ({
|
|
20849
|
+
ORDER BY crawl_runs.created_at DESC`).all(site, limit).map((row) => ({
|
|
19378
20850
|
...toRun(row),
|
|
19379
20851
|
statusErrors: row.status_errors,
|
|
19380
20852
|
nonIndexable: row.non_indexable,
|
|
@@ -20450,6 +21922,36 @@ function previousFromRow(row) {
|
|
|
20450
21922
|
lastCrawlTime: row.last_crawl_time ?? void 0
|
|
20451
21923
|
};
|
|
20452
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
|
+
}
|
|
20453
21955
|
const defaultDependencies$2 = {
|
|
20454
21956
|
inspectUrl,
|
|
20455
21957
|
now: () => /* @__PURE__ */ new Date(),
|
|
@@ -20463,12 +21965,14 @@ const defaultDependencies$2 = {
|
|
|
20463
21965
|
return row ? previousFromRow(row) : void 0;
|
|
20464
21966
|
},
|
|
20465
21967
|
insert(item) {
|
|
20466
|
-
getDb()
|
|
21968
|
+
const db = getDb();
|
|
21969
|
+
db.prepare(`INSERT INTO index_watch_snapshots
|
|
20467
21970
|
(id, site_url, root_site_url, property_site_url, url, verdict,
|
|
20468
21971
|
coverage_state, indexing_state, robots_txt_state, page_fetch_state,
|
|
20469
21972
|
google_canonical, user_canonical, last_crawl_time, inspection_status,
|
|
20470
21973
|
error_code, error_message, inspected_at)
|
|
20471
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);
|
|
20472
21976
|
}
|
|
20473
21977
|
}
|
|
20474
21978
|
};
|
|
@@ -21007,8 +22511,6 @@ async function indexMonitor(input) {
|
|
|
21007
22511
|
warnings
|
|
21008
22512
|
};
|
|
21009
22513
|
}
|
|
21010
|
-
//#endregion
|
|
21011
|
-
//#region packages/core/dist/analyze/monitoring/link-recover-store.js
|
|
21012
22514
|
function toSummary(row) {
|
|
21013
22515
|
return {
|
|
21014
22516
|
id: row.id,
|
|
@@ -21051,6 +22553,14 @@ function insertLinkRecoverRun(report) {
|
|
|
21051
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);
|
|
21052
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);
|
|
21053
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);
|
|
21054
22564
|
return id;
|
|
21055
22565
|
}
|
|
21056
22566
|
function latestLinkRecoverSummary(site) {
|
|
@@ -21884,9 +23394,11 @@ function cacheSet(report, now) {
|
|
|
21884
23394
|
...report,
|
|
21885
23395
|
raw: void 0
|
|
21886
23396
|
};
|
|
23397
|
+
const reportJson = JSON.stringify(stored);
|
|
21887
23398
|
getDb().prepare(`INSERT OR REPLACE INTO performance_reports
|
|
21888
23399
|
(id, url, strategy, report_json, created_at, expires_at)
|
|
21889
|
-
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));
|
|
21890
23402
|
}
|
|
21891
23403
|
async function fallbackReport(input) {
|
|
21892
23404
|
const fetched = await fetchPage(input.url, {
|
|
@@ -23435,7 +24947,7 @@ function clusterRecommendation(input) {
|
|
|
23435
24947
|
return `This ${clusterName} cluster already gets clicks. Use it to refine page structure, FAQs, and internal anchors rather than creating duplicate pages.`;
|
|
23436
24948
|
}
|
|
23437
24949
|
function clusterSummary(input) {
|
|
23438
|
-
return `${input.queries
|
|
24950
|
+
return `${countLabel(input.queries, "query", "queries")}, ${countLabel(input.impressions, "impression")}, ${countLabel(input.clicks, "click")}, average position ${input.position.toFixed(1)}.`;
|
|
23439
24951
|
}
|
|
23440
24952
|
function clusterLabel(rows) {
|
|
23441
24953
|
return [...rows].sort((a, b) => b.impressions - a.impressions || compareQueryClusterText(a.query, b.query))[0]?.query ?? "";
|
|
@@ -23908,7 +25420,7 @@ function contentOpportunityBullets(report) {
|
|
|
23908
25420
|
if (report.cannibalization.selection.suppressedQueries) bullets.push(cannibalSuppressionLine(report));
|
|
23909
25421
|
if (report.summary.strikingDistanceItems) {
|
|
23910
25422
|
const top = report.strikingDistance.items[0];
|
|
23911
|
-
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.`);
|
|
23912
25424
|
}
|
|
23913
25425
|
if (!skippedReason(report, "quick-win opportunities") && report.summary.quickWinItems) {
|
|
23914
25426
|
const top = report.quickWins.items[0];
|
|
@@ -24389,10 +25901,10 @@ function applyVerification(item, coverage) {
|
|
|
24389
25901
|
function verdict(report) {
|
|
24390
25902
|
if (report.dataStatus === "empty") return "Google Search Console returned no retained query/page rows for this window.";
|
|
24391
25903
|
if (report.dataStatus === "filtered") return "No pages met the position, brand, and minimum-impression criteria.";
|
|
24392
|
-
if (report.technical > 0) return `${report.eligible
|
|
24393
|
-
if (report.failed > 0) return `${report.eligible
|
|
24394
|
-
if (report.content > 0) return `${report.eligible
|
|
24395
|
-
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.`;
|
|
24396
25908
|
}
|
|
24397
25909
|
function reportRecommendations(items) {
|
|
24398
25910
|
const first = items[0];
|
|
@@ -24439,7 +25951,7 @@ async function secondPage(input, dependencies = defaultDependencies) {
|
|
|
24439
25951
|
});
|
|
24440
25952
|
const generatedAt = now.toISOString();
|
|
24441
25953
|
const warnings = [];
|
|
24442
|
-
const verificationRequested = input.verifyContent === true || input.verifyLimit !== void 0;
|
|
25954
|
+
const verificationRequested = input.verifyContent === true || input.verifyContent !== false && input.verifyLimit !== void 0;
|
|
24443
25955
|
const verificationItems = verificationRequested ? analysis.items.slice(0, verifyLimit) : [];
|
|
24444
25956
|
for (const item of verificationItems) {
|
|
24445
25957
|
const coverage = await verifyQueryContent({
|
|
@@ -25050,7 +26562,7 @@ async function refreshPrioritiesWorkflow(input) {
|
|
|
25050
26562
|
const diagnosis = await diagnoseProperty({
|
|
25051
26563
|
...input,
|
|
25052
26564
|
verifyContent: input.verifyContent ?? true,
|
|
25053
|
-
verifyLimit: input.verifyLimit ?? Math.min(input.limit ?? 5, 5)
|
|
26565
|
+
verifyLimit: input.verifyContent === false ? void 0 : input.verifyLimit ?? Math.min(input.limit ?? 5, 5)
|
|
25054
26566
|
});
|
|
25055
26567
|
const range = defaultDateRange$1(input.days ?? 28);
|
|
25056
26568
|
const analytics = await fetchLandingPageValues({
|
|
@@ -25751,6 +27263,7 @@ function parseSemicolonCsv(text) {
|
|
|
25751
27263
|
//#endregion
|
|
25752
27264
|
//#region packages/core/dist/providers/semrush/cache.js
|
|
25753
27265
|
const BASE_URL = "https://api.semrush.com/";
|
|
27266
|
+
const MAX_RESPONSE_BYTES = 10 * 1024 * 1024;
|
|
25754
27267
|
function estimateUsd(units) {
|
|
25755
27268
|
return units / 1e3 * .05;
|
|
25756
27269
|
}
|
|
@@ -25780,14 +27293,17 @@ async function cachedSemrushCall(endpoint, params, map, ttlMs, creditsPerLine, r
|
|
|
25780
27293
|
const url = new URL(BASE_URL);
|
|
25781
27294
|
url.search = new URLSearchParams(requestParams).toString();
|
|
25782
27295
|
const response = await fetch$1(url);
|
|
25783
|
-
const text = await response
|
|
27296
|
+
const text = await readBoundedResponseText(response, MAX_RESPONSE_BYTES, "Semrush response");
|
|
25784
27297
|
if (!response.ok || text.startsWith("ERROR ::")) throw new Error(text || `Semrush request failed with ${response.status}.`);
|
|
25785
27298
|
const rows = parseSemicolonCsv(text);
|
|
25786
27299
|
const data = map(rows);
|
|
25787
27300
|
const credits = Math.max(0, rows.length - 1) * creditsPerLine;
|
|
27301
|
+
const requestJson = JSON.stringify(requestParams);
|
|
27302
|
+
const responseJson = JSON.stringify(data);
|
|
25788
27303
|
db.prepare(`INSERT OR REPLACE INTO semrush_cache
|
|
25789
27304
|
(endpoint, query_hash, request_json, response_json, credits_used, fetched_at, expires_at)
|
|
25790
|
-
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));
|
|
25791
27307
|
return {
|
|
25792
27308
|
data,
|
|
25793
27309
|
usage: {
|
|
@@ -25947,9 +27463,6 @@ var LocalCredentialsProvider = class {
|
|
|
25947
27463
|
}
|
|
25948
27464
|
};
|
|
25949
27465
|
new LocalCredentialsProvider();
|
|
25950
|
-
//#endregion
|
|
25951
|
-
//#region packages/core/dist/version.js
|
|
25952
|
-
const SEO_VERSION = version;
|
|
25953
27466
|
const TELEMETRY_TIMEOUT_MS = 2e3;
|
|
25954
27467
|
const TELEMETRY_REPORTS = [
|
|
25955
27468
|
"affected-urls",
|
|
@@ -26544,17 +28057,7 @@ function registerAiOpportunityTools(server, dependencies = {}) {
|
|
|
26544
28057
|
});
|
|
26545
28058
|
}
|
|
26546
28059
|
//#endregion
|
|
26547
|
-
//#region packages/mcp/src/
|
|
26548
|
-
function fetchRateInput(input) {
|
|
26549
|
-
if (input.fetchConcurrency === void 0 && input.fetchIntervalCap === void 0 && input.fetchIntervalMs === void 0) return;
|
|
26550
|
-
return {
|
|
26551
|
-
concurrency: input.fetchConcurrency,
|
|
26552
|
-
intervalCap: input.fetchIntervalCap,
|
|
26553
|
-
intervalMs: input.fetchIntervalMs
|
|
26554
|
-
};
|
|
26555
|
-
}
|
|
26556
|
-
//#endregion
|
|
26557
|
-
//#region packages/mcp/src/crawler-tools.ts
|
|
28060
|
+
//#region packages/mcp/src/crawler-tool-helpers.ts
|
|
26558
28061
|
function compactCrawlResult(report, opts = {}) {
|
|
26559
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.`;
|
|
26560
28063
|
const payload = {
|
|
@@ -26564,6 +28067,8 @@ function compactCrawlResult(report, opts = {}) {
|
|
|
26564
28067
|
status: report.status,
|
|
26565
28068
|
requestEvidenceStatus: report.requestEvidenceStatus,
|
|
26566
28069
|
configHash: report.configHash,
|
|
28070
|
+
config: report.config,
|
|
28071
|
+
access: report.access,
|
|
26567
28072
|
summary: report.summary,
|
|
26568
28073
|
dataSources: report.dataSources,
|
|
26569
28074
|
ai: report.ai ? {
|
|
@@ -26597,16 +28102,35 @@ function resolveSavedReportAlias(input) {
|
|
|
26597
28102
|
function assertExclusiveReportInput(url, reportId) {
|
|
26598
28103
|
if (url && reportId) throw new SeoError("INVALID_INPUT", "Use either url or reportId, not both.");
|
|
26599
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();
|
|
26600
28122
|
function registerCrawlerTools(server) {
|
|
26601
28123
|
server.registerTool("seo_crawl_site", {
|
|
26602
|
-
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.",
|
|
26603
28125
|
inputSchema: {
|
|
26604
|
-
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."),
|
|
26605
28129
|
site: z$1.string().optional(),
|
|
26606
28130
|
googleAnalyticsPropertyId: z$1.string().optional(),
|
|
26607
|
-
maxPages:
|
|
26608
|
-
maxDepth:
|
|
26609
|
-
concurrency:
|
|
28131
|
+
maxPages: crawlPageLimit,
|
|
28132
|
+
maxDepth: crawlDepthLimit,
|
|
28133
|
+
concurrency: boundedPositiveInteger(MAX_CRAWL_CONCURRENCY),
|
|
26610
28134
|
fetchIntervalCap: z$1.number().int().positive().optional(),
|
|
26611
28135
|
fetchIntervalMs: z$1.number().int().positive().optional(),
|
|
26612
28136
|
refresh: z$1.boolean().optional(),
|
|
@@ -26619,10 +28143,15 @@ function registerCrawlerTools(server) {
|
|
|
26619
28143
|
includeIssues: z$1.boolean().optional(),
|
|
26620
28144
|
saveReport: z$1.boolean().optional()
|
|
26621
28145
|
}
|
|
26622
|
-
}, 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 }) => {
|
|
26623
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.");
|
|
26624
28150
|
const report = await crawlSite({
|
|
26625
|
-
url,
|
|
28151
|
+
url: crawlUrl,
|
|
28152
|
+
mode: health || sitemapUrl ? "sitemap" : "site",
|
|
28153
|
+
strategy: health ? "health" : "full",
|
|
28154
|
+
sitemapUrl,
|
|
26626
28155
|
site,
|
|
26627
28156
|
googleAnalyticsPropertyId,
|
|
26628
28157
|
maxPages,
|
|
@@ -26641,7 +28170,7 @@ function registerCrawlerTools(server) {
|
|
|
26641
28170
|
js: Boolean(js)
|
|
26642
28171
|
});
|
|
26643
28172
|
const saved = saveReport ? saveCrawlReport(report) : void 0;
|
|
26644
|
-
return toolSuccess(
|
|
28173
|
+
return toolSuccess(`${health ? "Sitemap health pass" : "Crawl"} complete for ${crawlUrl}. Found ${report.issues.length} issues across ${report.summary.totalPages} pages.`, {
|
|
26645
28174
|
...compactCrawlResult(report, {
|
|
26646
28175
|
includePages,
|
|
26647
28176
|
includeIssues
|
|
@@ -26655,11 +28184,11 @@ function registerCrawlerTools(server) {
|
|
|
26655
28184
|
server.registerTool("seo_audit_urls", {
|
|
26656
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.",
|
|
26657
28186
|
inputSchema: {
|
|
26658
|
-
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),
|
|
26659
28188
|
site: z$1.string().optional(),
|
|
26660
28189
|
googleAnalyticsPropertyId: z$1.string().optional(),
|
|
26661
|
-
maxPages:
|
|
26662
|
-
concurrency:
|
|
28190
|
+
maxPages: crawlPageLimit,
|
|
28191
|
+
concurrency: boundedPositiveInteger(MAX_CRAWL_CONCURRENCY),
|
|
26663
28192
|
fetchIntervalCap: z$1.number().int().positive().optional(),
|
|
26664
28193
|
fetchIntervalMs: z$1.number().int().positive().optional(),
|
|
26665
28194
|
refresh: z$1.boolean().optional(),
|
|
@@ -26703,8 +28232,8 @@ function registerCrawlerTools(server) {
|
|
|
26703
28232
|
url: z$1.string().url().optional(),
|
|
26704
28233
|
reportId: z$1.string().optional(),
|
|
26705
28234
|
site: z$1.string().optional(),
|
|
26706
|
-
maxPages:
|
|
26707
|
-
maxDepth:
|
|
28235
|
+
maxPages: crawlPageLimit,
|
|
28236
|
+
maxDepth: crawlDepthLimit,
|
|
26708
28237
|
fetchIntervalCap: z$1.number().int().positive().optional(),
|
|
26709
28238
|
fetchIntervalMs: z$1.number().int().positive().optional(),
|
|
26710
28239
|
refresh: z$1.boolean().optional(),
|
|
@@ -26730,7 +28259,8 @@ function registerCrawlerTools(server) {
|
|
|
26730
28259
|
category,
|
|
26731
28260
|
limit
|
|
26732
28261
|
});
|
|
26733
|
-
|
|
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}`, {
|
|
26734
28264
|
url: report.config.url,
|
|
26735
28265
|
reportId: report.id,
|
|
26736
28266
|
summary: report.summary,
|
|
@@ -26756,7 +28286,7 @@ function registerCrawlerTools(server) {
|
|
|
26756
28286
|
inputSchema: { category: crawlerRuleCategorySchema.optional() }
|
|
26757
28287
|
}, async ({ category }) => {
|
|
26758
28288
|
const rules = listRules().filter((rule) => category ? rule.category === category : true);
|
|
26759
|
-
return toolSuccess(`Found ${rules.length
|
|
28289
|
+
return toolSuccess(`Found ${countLabel(rules.length, "crawler rule")}.`, { rules });
|
|
26760
28290
|
});
|
|
26761
28291
|
server.registerTool("seo_affected_urls", {
|
|
26762
28292
|
description: "Return affected URLs for a saved or freshly crawled report, filtered by rule, category, or severity.",
|
|
@@ -26767,7 +28297,7 @@ function registerCrawlerTools(server) {
|
|
|
26767
28297
|
ruleId: z$1.string().optional(),
|
|
26768
28298
|
category: z$1.string().optional(),
|
|
26769
28299
|
severity: z$1.string().optional(),
|
|
26770
|
-
maxPages:
|
|
28300
|
+
maxPages: crawlPageLimit,
|
|
26771
28301
|
fetchIntervalCap: z$1.number().int().positive().optional(),
|
|
26772
28302
|
fetchIntervalMs: z$1.number().int().positive().optional(),
|
|
26773
28303
|
refresh: z$1.boolean().optional(),
|
|
@@ -26811,7 +28341,7 @@ function registerCrawlerTools(server) {
|
|
|
26811
28341
|
url: z$1.string().url().optional(),
|
|
26812
28342
|
reportId: z$1.string().optional(),
|
|
26813
28343
|
site: z$1.string().optional(),
|
|
26814
|
-
maxPages:
|
|
28344
|
+
maxPages: crawlPageLimit,
|
|
26815
28345
|
fetchIntervalCap: z$1.number().int().positive().optional(),
|
|
26816
28346
|
fetchIntervalMs: z$1.number().int().positive().optional(),
|
|
26817
28347
|
refresh: z$1.boolean().optional(),
|
|
@@ -26846,7 +28376,7 @@ function registerCrawlerTools(server) {
|
|
|
26846
28376
|
url: z$1.string().url().optional(),
|
|
26847
28377
|
reportId: z$1.string().optional(),
|
|
26848
28378
|
site: z$1.string().optional(),
|
|
26849
|
-
maxPages:
|
|
28379
|
+
maxPages: crawlPageLimit,
|
|
26850
28380
|
fetchIntervalCap: z$1.number().int().positive().optional(),
|
|
26851
28381
|
fetchIntervalMs: z$1.number().int().positive().optional(),
|
|
26852
28382
|
refresh: z$1.boolean().optional()
|
|
@@ -26880,7 +28410,7 @@ function registerCrawlerTools(server) {
|
|
|
26880
28410
|
url: z$1.string().url().optional(),
|
|
26881
28411
|
reportId: z$1.string().optional(),
|
|
26882
28412
|
site: z$1.string().optional(),
|
|
26883
|
-
maxPages:
|
|
28413
|
+
maxPages: crawlPageLimit,
|
|
26884
28414
|
fetchIntervalCap: z$1.number().int().positive().optional(),
|
|
26885
28415
|
fetchIntervalMs: z$1.number().int().positive().optional(),
|
|
26886
28416
|
refresh: z$1.boolean().optional()
|
|
@@ -26910,7 +28440,7 @@ function registerCrawlerTools(server) {
|
|
|
26910
28440
|
url: z$1.string().url().optional(),
|
|
26911
28441
|
reportId: z$1.string().optional(),
|
|
26912
28442
|
site: z$1.string().optional(),
|
|
26913
|
-
maxPages:
|
|
28443
|
+
maxPages: crawlPageLimit,
|
|
26914
28444
|
fetchIntervalCap: z$1.number().int().positive().optional(),
|
|
26915
28445
|
fetchIntervalMs: z$1.number().int().positive().optional(),
|
|
26916
28446
|
refresh: z$1.boolean().optional()
|
|
@@ -26940,7 +28470,7 @@ function registerCrawlerTools(server) {
|
|
|
26940
28470
|
url: z$1.string().url().optional(),
|
|
26941
28471
|
reportId: z$1.string().optional(),
|
|
26942
28472
|
site: z$1.string().optional(),
|
|
26943
|
-
maxPages:
|
|
28473
|
+
maxPages: crawlPageLimit,
|
|
26944
28474
|
fetchIntervalCap: z$1.number().int().positive().optional(),
|
|
26945
28475
|
fetchIntervalMs: z$1.number().int().positive().optional(),
|
|
26946
28476
|
refresh: z$1.boolean().optional()
|
|
@@ -26970,7 +28500,7 @@ function registerCrawlerTools(server) {
|
|
|
26970
28500
|
url: z$1.string().url().optional(),
|
|
26971
28501
|
reportId: z$1.string().optional(),
|
|
26972
28502
|
site: z$1.string().optional(),
|
|
26973
|
-
maxPages:
|
|
28503
|
+
maxPages: crawlPageLimit,
|
|
26974
28504
|
maxUrls: z$1.number().int().positive().optional(),
|
|
26975
28505
|
tokenBudget: z$1.number().int().positive().optional(),
|
|
26976
28506
|
exclude: z$1.array(z$1.string()).optional(),
|
|
@@ -27011,7 +28541,7 @@ function registerCrawlerTools(server) {
|
|
|
27011
28541
|
url: z$1.string().url().optional(),
|
|
27012
28542
|
reportId: z$1.string().optional(),
|
|
27013
28543
|
site: z$1.string().optional(),
|
|
27014
|
-
maxPages:
|
|
28544
|
+
maxPages: crawlPageLimit,
|
|
27015
28545
|
fetchIntervalCap: z$1.number().int().positive().optional(),
|
|
27016
28546
|
fetchIntervalMs: z$1.number().int().positive().optional(),
|
|
27017
28547
|
refresh: z$1.boolean().optional()
|
|
@@ -27041,7 +28571,7 @@ function registerCrawlerTools(server) {
|
|
|
27041
28571
|
url: z$1.string().url().optional(),
|
|
27042
28572
|
reportId: z$1.string().optional(),
|
|
27043
28573
|
site: z$1.string().optional(),
|
|
27044
|
-
maxPages:
|
|
28574
|
+
maxPages: crawlPageLimit,
|
|
27045
28575
|
maxConcepts: z$1.number().int().min(1).max(100).optional(),
|
|
27046
28576
|
includeFiles: z$1.boolean().optional(),
|
|
27047
28577
|
title: z$1.string().min(1).max(200).optional(),
|
|
@@ -27990,7 +29520,7 @@ function registerPseoTools(server) {
|
|
|
27990
29520
|
includeBrand: z$1.boolean().optional(),
|
|
27991
29521
|
refresh: z$1.boolean().optional(),
|
|
27992
29522
|
js: z$1.boolean().optional(),
|
|
27993
|
-
fetchConcurrency: z$1.number().int().min(1).max(
|
|
29523
|
+
fetchConcurrency: z$1.number().int().min(1).max(16).optional(),
|
|
27994
29524
|
fetchIntervalCap: z$1.number().int().min(1).max(100).optional(),
|
|
27995
29525
|
fetchIntervalMs: z$1.number().int().min(100).max(6e4).optional(),
|
|
27996
29526
|
detail: z$1.enum(["summary", "full"]).optional()
|
|
@@ -28063,13 +29593,15 @@ const REPORT_DEPTH = {
|
|
|
28063
29593
|
"profile, profileApplicability, dataStatus, and crawl caveats",
|
|
28064
29594
|
"summary and topActions",
|
|
28065
29595
|
"HTML and Markdown representation checks with affected URLs",
|
|
28066
|
-
"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"
|
|
28067
29599
|
],
|
|
28068
29600
|
doNotClaim: [
|
|
28069
29601
|
"A clean content profile is not proof of indexing, rankings, AI mentions, citations, or selection.",
|
|
28070
29602
|
"API, application, and commerce checks marked notApplicable were outside this content-site run. They did not fail.",
|
|
28071
29603
|
"An allowed crawler token does not prove that a service fetched or used the page.",
|
|
28072
|
-
"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."
|
|
28073
29605
|
],
|
|
28074
29606
|
verify: "Run the same content profile against the same public route scope after deployment and confirm each failed or warning check changed.",
|
|
28075
29607
|
related: [
|
|
@@ -29260,16 +30792,19 @@ const REPORT_DEPTH = {
|
|
|
29260
30792
|
},
|
|
29261
30793
|
"site-crawl": {
|
|
29262
30794
|
readOrder: [
|
|
30795
|
+
"config.strategy first; use health before full on large or unknown sites",
|
|
29263
30796
|
"status, requestEvidenceStatus, attempted and fetched counts, failures, retained pages, and pageLimitReached",
|
|
30797
|
+
"access crawler identity, blockedRequests, provider counts, and samples",
|
|
29264
30798
|
"data-source states",
|
|
29265
30799
|
"warnings and caveats, then opt-in pages, requests, and issues"
|
|
29266
30800
|
],
|
|
29267
30801
|
doNotClaim: [
|
|
29268
30802
|
"A partial or capped crawl cannot support an all-clear or a definitive zero.",
|
|
29269
30803
|
"A crawl is not an index, traffic source, or guarantee of complete site coverage.",
|
|
29270
|
-
"
|
|
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."
|
|
29271
30806
|
],
|
|
29272
|
-
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.",
|
|
29273
30808
|
related: [
|
|
29274
30809
|
{
|
|
29275
30810
|
id: "top-fixes",
|
|
@@ -29776,11 +31311,15 @@ const REPORT_GUIDANCE = {
|
|
|
29776
31311
|
outcome: "A local setup status with the exact problems that need fixing."
|
|
29777
31312
|
},
|
|
29778
31313
|
"site-crawl": {
|
|
29779
|
-
name: "
|
|
29780
|
-
description: "
|
|
29781
|
-
useWhen: [
|
|
29782
|
-
|
|
29783
|
-
|
|
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."
|
|
29784
31323
|
},
|
|
29785
31324
|
"striking-distance": {
|
|
29786
31325
|
name: "Queries near page one",
|
|
@@ -30004,6 +31543,133 @@ function registerReportTools(server) {
|
|
|
30004
31543
|
registerSecondPageTool(server);
|
|
30005
31544
|
}
|
|
30006
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
|
|
30007
31673
|
//#region packages/mcp/src/workflow-tools.ts
|
|
30008
31674
|
const technicalWatchInputSchema = z$1.strictObject({
|
|
30009
31675
|
site: z$1.string().trim().min(1).max(2048),
|
|
@@ -30032,10 +31698,12 @@ const technicalWatchInputSchema = z$1.strictObject({
|
|
|
30032
31698
|
});
|
|
30033
31699
|
});
|
|
30034
31700
|
function workflowSuccess(result) {
|
|
30035
|
-
|
|
31701
|
+
const presentation = workflowPresentation(result);
|
|
31702
|
+
const structured = compactAgentWorkflowOutput({
|
|
30036
31703
|
...result,
|
|
30037
|
-
presentation
|
|
30038
|
-
}
|
|
31704
|
+
presentation
|
|
31705
|
+
});
|
|
31706
|
+
return toolSuccess(result.summary, structured, { markdown: renderWorkflowMarkdown(result) });
|
|
30039
31707
|
}
|
|
30040
31708
|
function registerWorkflowTools(server) {
|
|
30041
31709
|
server.registerTool("seo_workflow_diagnose_property", {
|
|
@@ -30388,6 +32056,226 @@ function getReportDefinition(id) {
|
|
|
30388
32056
|
return definitionsById.get(id);
|
|
30389
32057
|
}
|
|
30390
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
|
|
30391
32279
|
//#region packages/mcp/src/reports.ts
|
|
30392
32280
|
function validationMessage(error) {
|
|
30393
32281
|
return error.issues.map((issue) => {
|
|
@@ -30397,9 +32285,25 @@ function validationMessage(error) {
|
|
|
30397
32285
|
function listReports(category) {
|
|
30398
32286
|
return listReportDefinitions(category);
|
|
30399
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
|
+
}
|
|
30400
32303
|
function describeReport(id) {
|
|
30401
32304
|
const report = getReportDefinition(id);
|
|
30402
32305
|
if (!report) throw new SeoError("INVALID_INPUT", `Unknown report: ${id}.`);
|
|
32306
|
+
const fixableChecks = listFixableChecks(report.id);
|
|
30403
32307
|
return {
|
|
30404
32308
|
id: report.id,
|
|
30405
32309
|
category: report.category,
|
|
@@ -30412,6 +32316,7 @@ function describeReport(id) {
|
|
|
30412
32316
|
doNotClaim: report.doNotClaim,
|
|
30413
32317
|
verify: report.verify,
|
|
30414
32318
|
related: report.related,
|
|
32319
|
+
...fixableChecks.length > 0 ? { fixableChecks } : {},
|
|
30415
32320
|
inputSchema: z$1.toJSONSchema(report.inputSchema)
|
|
30416
32321
|
};
|
|
30417
32322
|
}
|
|
@@ -30452,16 +32357,23 @@ function registerDiscoveryTools(server, options = {}) {
|
|
|
30452
32357
|
});
|
|
30453
32358
|
});
|
|
30454
32359
|
server.registerTool("seo_describe_report", {
|
|
30455
|
-
description: "Explain when to use one SEO report, what it returns, and which parameters it accepts",
|
|
30456
|
-
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
|
+
},
|
|
30457
32365
|
outputSchema: openOutputSchema,
|
|
30458
32366
|
annotations: {
|
|
30459
32367
|
readOnlyHint: true,
|
|
30460
32368
|
destructiveHint: false,
|
|
30461
32369
|
idempotentHint: true
|
|
30462
32370
|
}
|
|
30463
|
-
}, async ({ id }) => {
|
|
32371
|
+
}, async ({ id, check }) => {
|
|
30464
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
|
+
}
|
|
30465
32377
|
const report = describeReport(id);
|
|
30466
32378
|
return toolSuccess(`${report.id}: ${report.description}`, { report });
|
|
30467
32379
|
} catch (error) {
|
|
@@ -30514,6 +32426,6 @@ async function startMcpServer(opts = {}) {
|
|
|
30514
32426
|
await server.connect(transport);
|
|
30515
32427
|
}
|
|
30516
32428
|
//#endregion
|
|
30517
|
-
export { REPORT_CATEGORIES, createServer, describeReport, executeReport, listReports, runReport, startMcpServer };
|
|
32429
|
+
export { REPORT_CATEGORIES, createServer, describeReport, describeReportCheck, executeReport, listReports, runReport, startMcpServer };
|
|
30518
32430
|
|
|
30519
32431
|
//# sourceMappingURL=mcp.js.map
|