seo 0.16.0 → 0.16.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cli.js +85 -85
- package/dist/cli.js.map +1 -1
- package/dist/{dist-Bv1JGvEz.js → dist-BZ5-V3qL.js} +1 -1
- package/dist/{dist-CHIJxr2Y.js → dist-CqzyPI3J.js} +113 -113
- package/dist/dist-CqzyPI3J.js.map +1 -0
- package/dist/{esm-CV5s7esU.js → esm-Cvmg0qhZ.js} +2 -2
- package/dist/{esm-CV5s7esU.js.map → esm-Cvmg0qhZ.js.map} +1 -1
- package/dist/index.d.ts +60 -46
- package/dist/index.js +1009 -732
- package/dist/index.js.map +1 -1
- package/dist/mcp.js +588 -317
- package/dist/mcp.js.map +1 -1
- package/package.json +1 -1
- package/dist/dist-CHIJxr2Y.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 { i as __toESM, n as version, r as __commonJSMin, t as load } from "./esm-
|
|
2
|
+
import { i as __toESM, n as version, r as __commonJSMin, t as load } from "./esm-Cvmg0qhZ.js";
|
|
3
3
|
import { createHash, randomUUID, sign } from "node:crypto";
|
|
4
4
|
import { ZodOptional, z } from "zod";
|
|
5
5
|
import { arch, homedir, platform, tmpdir, totalmem } from "node:os";
|
|
@@ -19158,17 +19158,15 @@ function recordHostFetch(input) {
|
|
|
19158
19158
|
const cfg = input.rate.backpressure;
|
|
19159
19159
|
health.durations.push(input.durationMs);
|
|
19160
19160
|
if (health.durations.length > 20) health.durations.shift();
|
|
19161
|
-
const
|
|
19162
|
-
|
|
19163
|
-
|
|
19164
|
-
429
|
|
19165
|
-
].includes(input.status);
|
|
19166
|
-
const errored = input.status >= 500;
|
|
19161
|
+
const rateLimited = input.status === 429 || input.status === 503 && input.retryAfterMs !== void 0;
|
|
19162
|
+
const blocked = [401, 403].includes(input.status);
|
|
19163
|
+
const errored = input.status >= 500 && !rateLimited;
|
|
19167
19164
|
const slow = input.durationMs >= cfg.slowMs;
|
|
19168
19165
|
health.consecutiveBlocked = blocked ? health.consecutiveBlocked + 1 : 0;
|
|
19169
19166
|
health.consecutiveErrors = errored ? health.consecutiveErrors + 1 : 0;
|
|
19170
19167
|
health.consecutiveSlow = slow ? health.consecutiveSlow + 1 : 0;
|
|
19171
|
-
if (!blocked && !errored && !slow) health.reason = void 0;
|
|
19168
|
+
if (!blocked && !errored && !slow && !rateLimited) health.reason = void 0;
|
|
19169
|
+
if (rateLimited) health.reason = `origin rate limited the crawler (HTTP ${input.status})`;
|
|
19172
19170
|
const retryDelay = input.retryAfterMs === void 0 ? 0 : Math.min(input.retryAfterMs, cfg.retryAfterCapMs);
|
|
19173
19171
|
if (retryDelay > 0) {
|
|
19174
19172
|
health.reason = `origin sent Retry-After (${Math.round(retryDelay / 1e3)}s)`;
|
|
@@ -22662,6 +22660,17 @@ function isAuditableHtmlPage(page) {
|
|
|
22662
22660
|
const contentAuditAllowed = page.contentAuditAllowed ?? (!page.blocked && page.robotsTxt?.allowed !== false);
|
|
22663
22661
|
return isSuccessfulResponse(page) && isHtmlPage(page) && !isRedirectedPage(page) && contentAuditAllowed;
|
|
22664
22662
|
}
|
|
22663
|
+
/**
|
|
22664
|
+
* Cloudflare serves its own endpoints under `/cdn-cgi/`, such as the email
|
|
22665
|
+
* obfuscation link `/cdn-cgi/l/email-protection`. They are not site pages.
|
|
22666
|
+
*/
|
|
22667
|
+
function isCdnUtilityUrl(value) {
|
|
22668
|
+
try {
|
|
22669
|
+
return new URL(value).pathname.startsWith("/cdn-cgi/");
|
|
22670
|
+
} catch {
|
|
22671
|
+
return false;
|
|
22672
|
+
}
|
|
22673
|
+
}
|
|
22665
22674
|
// packages/core/dist/analyze/crawler/audit.js
|
|
22666
22675
|
function sameUrl(a, b) {
|
|
22667
22676
|
if (!a || !b) return false;
|
|
@@ -22931,7 +22940,7 @@ function auditCrawlPages(pages, opts = {}) {
|
|
|
22931
22940
|
if (page.accessBlock) continue;
|
|
22932
22941
|
if (page.status >= 500) {
|
|
22933
22942
|
issues.push(issue$1("server_error", page, String(page.status), { status: page.status }));
|
|
22934
|
-
if ((page.internalInlinkCount ?? 0) > 0) issues.push(issue$1("broken_internal_link", page, String(page.status), {
|
|
22943
|
+
if ((page.internalInlinkCount ?? 0) > 0 && !isCdnUtilityUrl(page.url)) issues.push(issue$1("broken_internal_link", page, String(page.status), {
|
|
22935
22944
|
status: page.status,
|
|
22936
22945
|
internalInlinkCount: page.internalInlinkCount
|
|
22937
22946
|
}));
|
|
@@ -22939,7 +22948,7 @@ function auditCrawlPages(pages, opts = {}) {
|
|
|
22939
22948
|
}
|
|
22940
22949
|
if (page.status >= 400) {
|
|
22941
22950
|
issues.push(issue$1("client_error", page, String(page.status), { status: page.status }));
|
|
22942
|
-
if (isBrokenLinkStatus(page.status) && (page.internalInlinkCount ?? 0) > 0) issues.push(issue$1("broken_internal_link", page, String(page.status), {
|
|
22951
|
+
if (isBrokenLinkStatus(page.status) && (page.internalInlinkCount ?? 0) > 0 && !isCdnUtilityUrl(page.url)) issues.push(issue$1("broken_internal_link", page, String(page.status), {
|
|
22943
22952
|
status: page.status,
|
|
22944
22953
|
internalInlinkCount: page.internalInlinkCount
|
|
22945
22954
|
}));
|
|
@@ -23162,6 +23171,7 @@ function auditCrawlPages(pages, opts = {}) {
|
|
|
23162
23171
|
// packages/core/dist/analyze/crawler/crawl-skip-reasons.js
|
|
23163
23172
|
const CRAWL_SKIP_REASONS = [
|
|
23164
23173
|
"asset-url",
|
|
23174
|
+
"cdn-utility-url",
|
|
23165
23175
|
"configured-exclusion",
|
|
23166
23176
|
"duplicate-normalization",
|
|
23167
23177
|
"invalid-url",
|
|
@@ -23176,6 +23186,7 @@ const CRAWL_SKIP_REASONS = [
|
|
|
23176
23186
|
];
|
|
23177
23187
|
const SKIP_IMPACT = {
|
|
23178
23188
|
"asset-url": "non-impacting",
|
|
23189
|
+
"cdn-utility-url": "non-impacting",
|
|
23179
23190
|
"configured-exclusion": "non-impacting",
|
|
23180
23191
|
"duplicate-normalization": "non-impacting",
|
|
23181
23192
|
"invalid-url": "non-impacting",
|
|
@@ -23236,161 +23247,85 @@ function crawlSkipReasonCountsFromRecord(input = {}) {
|
|
|
23236
23247
|
count
|
|
23237
23248
|
})) }).skipReasons;
|
|
23238
23249
|
}
|
|
23239
|
-
// packages/core/dist/analyze/crawler/
|
|
23240
|
-
|
|
23241
|
-
|
|
23242
|
-
medium: 1200,
|
|
23243
|
-
low: 100
|
|
23244
|
-
};
|
|
23245
|
-
const EFFORT_BY_CATEGORY = {
|
|
23246
|
-
canonical: "medium",
|
|
23247
|
-
content: "high",
|
|
23248
|
-
agents: "low",
|
|
23249
|
-
headings: "low",
|
|
23250
|
-
images: "medium",
|
|
23251
|
-
indexability: "medium",
|
|
23252
|
-
international: "low",
|
|
23253
|
-
metadata: "low",
|
|
23254
|
-
mobile: "low",
|
|
23255
|
-
response: "medium",
|
|
23256
|
-
security: "medium",
|
|
23257
|
-
social: "low",
|
|
23258
|
-
"structured-data": "medium"
|
|
23259
|
-
};
|
|
23260
|
-
const EFFORT_SCORE = {
|
|
23261
|
-
low: 60,
|
|
23262
|
-
medium: 30,
|
|
23263
|
-
high: 0
|
|
23264
|
-
};
|
|
23265
|
-
const AFFECTED_URL_WEIGHT = {
|
|
23266
|
-
high: 25,
|
|
23267
|
-
medium: 15,
|
|
23268
|
-
low: 2
|
|
23269
|
-
};
|
|
23270
|
-
const VALUE_WEIGHT = {
|
|
23271
|
-
high: 1,
|
|
23272
|
-
medium: .75,
|
|
23273
|
-
low: .15
|
|
23274
|
-
};
|
|
23275
|
-
const CRAWL_REACH_RULES = /* @__PURE__ */ new Set(["crawl_single_page_reach"]);
|
|
23276
|
-
function reachRank(ruleId) {
|
|
23277
|
-
return CRAWL_REACH_RULES.has(ruleId) ? 0 : 1;
|
|
23250
|
+
// packages/core/dist/analyze/crawler/crawl-url-queue.js
|
|
23251
|
+
function before(left, right) {
|
|
23252
|
+
return left.depth < right.depth || left.depth === right.depth && left.url < right.url;
|
|
23278
23253
|
}
|
|
23279
|
-
|
|
23280
|
-
|
|
23281
|
-
|
|
23282
|
-
|
|
23283
|
-
|
|
23284
|
-
if (!part) continue;
|
|
23285
|
-
const found = value.slice(position).indexOf(part);
|
|
23286
|
-
if (found === -1) return false;
|
|
23287
|
-
position += found + part.length;
|
|
23254
|
+
var CrawlUrlQueue = class {
|
|
23255
|
+
#items = [];
|
|
23256
|
+
#positions = /* @__PURE__ */ new Map();
|
|
23257
|
+
get size() {
|
|
23258
|
+
return this.#items.length;
|
|
23288
23259
|
}
|
|
23289
|
-
|
|
23290
|
-
|
|
23291
|
-
|
|
23292
|
-
|
|
23293
|
-
if (
|
|
23294
|
-
|
|
23295
|
-
|
|
23296
|
-
|
|
23260
|
+
has(url) {
|
|
23261
|
+
return this.#positions.has(url);
|
|
23262
|
+
}
|
|
23263
|
+
push(item) {
|
|
23264
|
+
if (this.has(item.url)) throw new Error(`URL is already queued: ${item.url}`);
|
|
23265
|
+
const index = this.#items.length;
|
|
23266
|
+
this.#items.push(item);
|
|
23267
|
+
this.#positions.set(item.url, index);
|
|
23268
|
+
this.#bubbleUp(index);
|
|
23269
|
+
}
|
|
23270
|
+
decreaseDepth(url, depth) {
|
|
23271
|
+
const index = this.#positions.get(url);
|
|
23272
|
+
if (index === void 0) return false;
|
|
23273
|
+
const item = this.#items[index];
|
|
23274
|
+
if (!item || depth >= item.depth) return false;
|
|
23275
|
+
item.depth = depth;
|
|
23276
|
+
this.#bubbleUp(index);
|
|
23297
23277
|
return true;
|
|
23298
|
-
});
|
|
23299
|
-
}
|
|
23300
|
-
function groupIssues(issues) {
|
|
23301
|
-
const groups = /* @__PURE__ */ new Map();
|
|
23302
|
-
for (const issue of issues) {
|
|
23303
|
-
const existing = groups.get(issue.ruleId) ?? {
|
|
23304
|
-
ruleId: issue.ruleId,
|
|
23305
|
-
title: issue.title,
|
|
23306
|
-
category: issue.category,
|
|
23307
|
-
severity: issue.severity,
|
|
23308
|
-
count: 0,
|
|
23309
|
-
sampleUrls: []
|
|
23310
|
-
};
|
|
23311
|
-
existing.count += 1;
|
|
23312
|
-
if (existing.sampleUrls.length < 10) existing.sampleUrls.push(issue.url);
|
|
23313
|
-
groups.set(issue.ruleId, existing);
|
|
23314
23278
|
}
|
|
23315
|
-
|
|
23316
|
-
|
|
23317
|
-
|
|
23318
|
-
|
|
23319
|
-
|
|
23320
|
-
|
|
23321
|
-
|
|
23322
|
-
|
|
23323
|
-
|
|
23324
|
-
|
|
23325
|
-
|
|
23326
|
-
|
|
23327
|
-
|
|
23328
|
-
|
|
23329
|
-
|
|
23330
|
-
|
|
23331
|
-
|
|
23332
|
-
|
|
23333
|
-
|
|
23334
|
-
|
|
23335
|
-
|
|
23336
|
-
|
|
23337
|
-
|
|
23338
|
-
|
|
23339
|
-
|
|
23340
|
-
|
|
23341
|
-
|
|
23342
|
-
|
|
23343
|
-
|
|
23344
|
-
|
|
23345
|
-
|
|
23346
|
-
|
|
23347
|
-
|
|
23348
|
-
|
|
23349
|
-
|
|
23350
|
-
|
|
23351
|
-
|
|
23352
|
-
|
|
23353
|
-
|
|
23354
|
-
|
|
23355
|
-
|
|
23356
|
-
|
|
23357
|
-
|
|
23358
|
-
|
|
23359
|
-
|
|
23360
|
-
|
|
23361
|
-
|
|
23362
|
-
|
|
23363
|
-
|
|
23364
|
-
|
|
23365
|
-
avgPosition: search.avgPosition,
|
|
23366
|
-
effort,
|
|
23367
|
-
effortScore: EFFORT_SCORE[effort]
|
|
23368
|
-
};
|
|
23369
|
-
const valueWeight = VALUE_WEIGHT[group.severity];
|
|
23370
|
-
const score = scoreFactors.severity + scoreFactors.affectedUrls * AFFECTED_URL_WEIGHT[group.severity] + scoreFactors.searchVisibleUrls * 100 * valueWeight + scoreFactors.clicks * 20 * valueWeight + Math.min(scoreFactors.impressions, 1e4) / 25 * valueWeight + scoreFactors.sessions * 2 * valueWeight + (scoreFactors.conversions ?? 0) * 100 * valueWeight + scoreFactors.effortScore;
|
|
23371
|
-
const rule = explainRule(group.ruleId);
|
|
23372
|
-
const recommendation = recommendationForRule(group.ruleId);
|
|
23373
|
-
return {
|
|
23374
|
-
...group,
|
|
23375
|
-
recommendation,
|
|
23376
|
-
score: Math.round(score),
|
|
23377
|
-
scoreFactors,
|
|
23378
|
-
whyThisRanks: whyThisRanks(scoreFactors, recommendation, report.dataSources?.searchConsole.status, report.dataSources?.analytics.provider),
|
|
23379
|
-
howToFix: rule?.howToFix ?? "",
|
|
23380
|
-
howToVerify: rule?.howToVerify ?? "",
|
|
23381
|
-
verification: {
|
|
23382
|
-
command: verificationCommand(report, group.severity),
|
|
23383
|
-
expected: rule?.howToVerify ?? ""
|
|
23384
|
-
}
|
|
23385
|
-
};
|
|
23386
|
-
}).sort((a, b) => reachRank(a.ruleId) - reachRank(b.ruleId) || b.score - a.score || SEVERITY_SCORE[b.severity] - SEVERITY_SCORE[a.severity] || b.count - a.count || a.ruleId.localeCompare(b.ruleId)).slice(0, filters.limit ?? 10);
|
|
23387
|
-
}
|
|
23388
|
-
function reviewObservations(report, filters = {}) {
|
|
23389
|
-
return topFixes(report, {
|
|
23390
|
-
...filters,
|
|
23391
|
-
recommendation: "review"
|
|
23392
|
-
});
|
|
23393
|
-
}
|
|
23279
|
+
take() {
|
|
23280
|
+
const first = this.#items[0];
|
|
23281
|
+
if (!first) return void 0;
|
|
23282
|
+
const last = this.#items.pop();
|
|
23283
|
+
this.#positions.delete(first.url);
|
|
23284
|
+
if (last && last !== first) {
|
|
23285
|
+
this.#items[0] = last;
|
|
23286
|
+
this.#positions.set(last.url, 0);
|
|
23287
|
+
this.#bubbleDown(0);
|
|
23288
|
+
}
|
|
23289
|
+
return first;
|
|
23290
|
+
}
|
|
23291
|
+
#swap(left, right) {
|
|
23292
|
+
const leftItem = this.#items[left];
|
|
23293
|
+
const rightItem = this.#items[right];
|
|
23294
|
+
if (!leftItem || !rightItem) return;
|
|
23295
|
+
this.#items[left] = rightItem;
|
|
23296
|
+
this.#items[right] = leftItem;
|
|
23297
|
+
this.#positions.set(rightItem.url, left);
|
|
23298
|
+
this.#positions.set(leftItem.url, right);
|
|
23299
|
+
}
|
|
23300
|
+
#bubbleUp(start) {
|
|
23301
|
+
let index = start;
|
|
23302
|
+
while (index > 0) {
|
|
23303
|
+
const parent = Math.floor((index - 1) / 2);
|
|
23304
|
+
const item = this.#items[index];
|
|
23305
|
+
const parentItem = this.#items[parent];
|
|
23306
|
+
if (!item || !parentItem || !before(item, parentItem)) break;
|
|
23307
|
+
this.#swap(index, parent);
|
|
23308
|
+
index = parent;
|
|
23309
|
+
}
|
|
23310
|
+
}
|
|
23311
|
+
#bubbleDown(start) {
|
|
23312
|
+
let index = start;
|
|
23313
|
+
while (true) {
|
|
23314
|
+
const left = index * 2 + 1;
|
|
23315
|
+
const right = left + 1;
|
|
23316
|
+
let next = index;
|
|
23317
|
+
const item = this.#items[next];
|
|
23318
|
+
const leftItem = this.#items[left];
|
|
23319
|
+
const rightItem = this.#items[right];
|
|
23320
|
+
if (leftItem && item && before(leftItem, item)) next = left;
|
|
23321
|
+
const nextItem = this.#items[next];
|
|
23322
|
+
if (rightItem && nextItem && before(rightItem, nextItem)) next = right;
|
|
23323
|
+
if (next === index) return;
|
|
23324
|
+
this.#swap(index, next);
|
|
23325
|
+
index = next;
|
|
23326
|
+
}
|
|
23327
|
+
}
|
|
23328
|
+
};
|
|
23394
23329
|
// packages/core/dist/analyze/crawler/report.js
|
|
23395
23330
|
const MAX_CRAWL_PAGES = 1e4;
|
|
23396
23331
|
const MAX_CRAWL_CONCURRENCY = 16;
|
|
@@ -23560,6 +23495,10 @@ function sanitizeMessages(values = []) {
|
|
|
23560
23495
|
function compareText$15(left, right) {
|
|
23561
23496
|
return left < right ? -1 : left > right ? 1 : 0;
|
|
23562
23497
|
}
|
|
23498
|
+
function compareCaveats(left, right) {
|
|
23499
|
+
const rank = (value) => value.startsWith("Coverage incomplete:") ? 0 : 1;
|
|
23500
|
+
return rank(left) - rank(right) || compareText$15(left, right);
|
|
23501
|
+
}
|
|
23563
23502
|
function sortPages(pages) {
|
|
23564
23503
|
return [...pages].sort((left, right) => compareText$15(left.url, right.url));
|
|
23565
23504
|
}
|
|
@@ -23794,9 +23733,229 @@ function createCrawlReport(input) {
|
|
|
23794
23733
|
...input.externalLinkVerification ? { externalLinkVerification: sanitizeTenantValue(input.externalLinkVerification) } : {},
|
|
23795
23734
|
...input.siteChecks ? { siteChecks: sanitizeTenantValue(input.siteChecks) } : {},
|
|
23796
23735
|
warnings: sanitizeMessages(input.warnings).sort(compareText$15),
|
|
23797
|
-
caveats: sanitizeMessages(input.caveats).sort(
|
|
23736
|
+
caveats: sanitizeMessages(input.caveats).sort(compareCaveats)
|
|
23737
|
+
};
|
|
23738
|
+
}
|
|
23739
|
+
// packages/core/dist/analyze/crawler/crawl-caveats.js
|
|
23740
|
+
/** Reports rank a caveat with this prefix first and lead with it. */
|
|
23741
|
+
const CRAWL_COVERAGE_CAVEAT_PREFIX = "Coverage incomplete:";
|
|
23742
|
+
function rateLimitRerunCommand(config, intervalMs) {
|
|
23743
|
+
const scope = config.strategy === "health" ? " --health" : config.mode === "site" ? "" : ` --mode ${config.mode}`;
|
|
23744
|
+
const sitemap = config.sitemapUrl ? ` --sitemap-url ${config.sitemapUrl}` : "";
|
|
23745
|
+
const slowMs = Math.min(8e3, Math.max(2e3, intervalMs * 2));
|
|
23746
|
+
return `seo crawl ${config.url}${scope}${sitemap} --max-pages ${config.maxPages} --concurrency 1 --fetch-interval-cap 1 --fetch-interval-ms ${slowMs}`;
|
|
23747
|
+
}
|
|
23748
|
+
/** Set when the sitemaps list more URLs than the page limit let the crawl read. */
|
|
23749
|
+
function sitemapLimitCaveatInput(config, discovery, defaulted) {
|
|
23750
|
+
if (!discovery || discovery.urlsReturned < config.maxPages || !discovery.roots.some((root) => root.possiblyTruncated)) return;
|
|
23751
|
+
return {
|
|
23752
|
+
maxPages: config.maxPages,
|
|
23753
|
+
defaulted,
|
|
23754
|
+
ceiling: MAX_CRAWL_PAGES,
|
|
23755
|
+
health: config.strategy === "health",
|
|
23756
|
+
url: config.url
|
|
23757
|
+
};
|
|
23758
|
+
}
|
|
23759
|
+
function crawlCaveats(input) {
|
|
23760
|
+
const caveats = input.cancelled ? ["Crawl cancelled before all queued URLs finished."] : input.pageLimitReached ? [`Stopped after reaching maxPages (${input.maxPages}).`] : [];
|
|
23761
|
+
if (input.queueSafetySkippedUrls > 0) caveats.push(input.queueSafetyInventoryCapped ? `Left at least ${input.queueSafetySkippedUrls} unique eligible same-origin URLs unqueued to keep this crawl bounded. The excluded-URL inventory reached its reporting cap. Increase --max-pages to inspect more.` : `Left ${input.queueSafetySkippedUrls} unique eligible same-origin URLs unqueued to keep this crawl bounded. Increase --max-pages to inspect more.`);
|
|
23762
|
+
if (input.originBackpressureSkippedUrls > 0) caveats.push(`Stopped ${input.originBackpressureSkippedUrls} queued URL${input.originBackpressureSkippedUrls === 1 ? "" : "s"} because the origin stayed slow. This is incomplete crawl evidence, not a failed URL check.`);
|
|
23763
|
+
if (input.rateLimit) {
|
|
23764
|
+
const { readPages, skippedUrls, rerunCommand } = input.rateLimit;
|
|
23765
|
+
caveats.push(`${CRAWL_COVERAGE_CAVEAT_PREFIX} read ${readPages} of ${readPages + skippedUrls} pages because the origin kept rate limiting the crawl (HTTP 429) after it backed off. Rerun slower: ${rerunCommand}`);
|
|
23766
|
+
}
|
|
23767
|
+
if (input.sitemapLimit) {
|
|
23768
|
+
const { maxPages, defaulted, ceiling, health, url } = input.sitemapLimit;
|
|
23769
|
+
caveats.push(`Checked only the first ${maxPages} sitemap URLs because the page limit is ${maxPages}${defaulted ? " by default" : ""}, and the sitemaps list more. Raise --max-pages, up to ${ceiling.toLocaleString("en-US")}, to check more: seo crawl ${url}${health ? " --health" : ""} --max-pages ${ceiling}`);
|
|
23770
|
+
}
|
|
23771
|
+
if (input.memoryPressureSkippedUrls > 0) caveats.push(`Left ${input.memoryPressureSkippedUrls} eligible URL${input.memoryPressureSkippedUrls === 1 ? "" : "s"} unchecked after the local memory safety limit was reached. Start with --health or lower --max-pages.`);
|
|
23772
|
+
const renderingFallbackUrls = input.renderingFallbackUrls ?? 0;
|
|
23773
|
+
if (renderingFallbackUrls > 0) caveats.push(`JavaScript rendering could not run for ${renderingFallbackUrls} page${renderingFallbackUrls === 1 ? "" : "s"}, so ${renderingFallbackUrls === 1 ? "it was" : "they were"} audited from raw HTML. Client-rendered content, metadata and links may be missing. Install Chromium or set SEO_BROWSER_EXECUTABLE_PATH, then rerun.`);
|
|
23774
|
+
const queryVariantSkippedUrls = input.queryVariantSkippedUrls ?? 0;
|
|
23775
|
+
if (queryVariantSkippedUrls > 0) caveats.push(`Left ${queryVariantSkippedUrls} query-string variant${queryVariantSkippedUrls === 1 ? "" : "s"} unqueued after 5 variants of the same path, so tracking parameters do not fill the crawl.`);
|
|
23776
|
+
for (const issue of input.reachIssues ?? []) if (issue.detail) caveats.push(issue.detail);
|
|
23777
|
+
return caveats;
|
|
23778
|
+
}
|
|
23779
|
+
/**
|
|
23780
|
+
* A site crawl that found no internal links on the start page and no sitemap
|
|
23781
|
+
* URLs checked one page, so its other findings cannot stand for the site.
|
|
23782
|
+
*/
|
|
23783
|
+
function crawlReachIssues(input) {
|
|
23784
|
+
const [page] = input.pages;
|
|
23785
|
+
if (input.crawledPages !== 1 || input.pages.length !== 1 || !page || page.outgoingInternalCount > 0 || input.sitemapUrlsReturned > 0) return [];
|
|
23786
|
+
const rule = explainRule("crawl_single_page_reach");
|
|
23787
|
+
if (!rule) throw new Error("Missing rule guidance for crawl_single_page_reach");
|
|
23788
|
+
return [{
|
|
23789
|
+
ruleId: "crawl_single_page_reach",
|
|
23790
|
+
title: rule.title,
|
|
23791
|
+
category: rule.category,
|
|
23792
|
+
severity: rule.defaultSeverity,
|
|
23793
|
+
url: page.url,
|
|
23794
|
+
detail: page.usedJs ? "The rendered start page had 0 internal links and no sitemap returned URLs, so the crawl checked only this page. List the pages to check with --mode list." : "The start page had 0 internal links in its HTML and no sitemap returned URLs, so the crawl checked only this page. It probably builds its links with JavaScript. Rerun with --rendering on, or list the pages with --mode list.",
|
|
23795
|
+
evidence: {
|
|
23796
|
+
startUrl: input.startUrl,
|
|
23797
|
+
crawledPages: 1,
|
|
23798
|
+
outgoingInternalCount: 0,
|
|
23799
|
+
sitemapUrlsReturned: 0,
|
|
23800
|
+
rendered: Boolean(page.usedJs)
|
|
23801
|
+
}
|
|
23802
|
+
}];
|
|
23803
|
+
}
|
|
23804
|
+
// packages/core/dist/analyze/crawler/top-fixes.js
|
|
23805
|
+
const SEVERITY_SCORE = {
|
|
23806
|
+
high: 5e3,
|
|
23807
|
+
medium: 1200,
|
|
23808
|
+
low: 100
|
|
23809
|
+
};
|
|
23810
|
+
const EFFORT_BY_CATEGORY = {
|
|
23811
|
+
canonical: "medium",
|
|
23812
|
+
content: "high",
|
|
23813
|
+
agents: "low",
|
|
23814
|
+
headings: "low",
|
|
23815
|
+
images: "medium",
|
|
23816
|
+
indexability: "medium",
|
|
23817
|
+
international: "low",
|
|
23818
|
+
metadata: "low",
|
|
23819
|
+
mobile: "low",
|
|
23820
|
+
response: "medium",
|
|
23821
|
+
security: "medium",
|
|
23822
|
+
social: "low",
|
|
23823
|
+
"structured-data": "medium"
|
|
23824
|
+
};
|
|
23825
|
+
const EFFORT_SCORE = {
|
|
23826
|
+
low: 60,
|
|
23827
|
+
medium: 30,
|
|
23828
|
+
high: 0
|
|
23829
|
+
};
|
|
23830
|
+
const AFFECTED_URL_WEIGHT = {
|
|
23831
|
+
high: 25,
|
|
23832
|
+
medium: 15,
|
|
23833
|
+
low: 2
|
|
23834
|
+
};
|
|
23835
|
+
const VALUE_WEIGHT = {
|
|
23836
|
+
high: 1,
|
|
23837
|
+
medium: .75,
|
|
23838
|
+
low: .15
|
|
23839
|
+
};
|
|
23840
|
+
const CRAWL_REACH_RULES = /* @__PURE__ */ new Set(["crawl_single_page_reach"]);
|
|
23841
|
+
function reachRank(ruleId) {
|
|
23842
|
+
return CRAWL_REACH_RULES.has(ruleId) ? 0 : 1;
|
|
23843
|
+
}
|
|
23844
|
+
function matchesPattern(pattern, value) {
|
|
23845
|
+
if (!pattern.includes("*")) return value.includes(pattern);
|
|
23846
|
+
const parts = pattern.split("*");
|
|
23847
|
+
let position = 0;
|
|
23848
|
+
for (const part of parts) {
|
|
23849
|
+
if (!part) continue;
|
|
23850
|
+
const found = value.slice(position).indexOf(part);
|
|
23851
|
+
if (found === -1) return false;
|
|
23852
|
+
position += found + part.length;
|
|
23853
|
+
}
|
|
23854
|
+
return true;
|
|
23855
|
+
}
|
|
23856
|
+
function filteredIssues(report, filters) {
|
|
23857
|
+
return report.issues.filter((issue) => {
|
|
23858
|
+
if (filters.category && issue.category !== filters.category) return false;
|
|
23859
|
+
if (filters.severity && issue.severity !== filters.severity) return false;
|
|
23860
|
+
if (filters.url && !matchesPattern(filters.url, issue.url)) return false;
|
|
23861
|
+
if ((filters.recommendation ?? "fix") !== recommendationForRule(issue.ruleId)) return false;
|
|
23862
|
+
return true;
|
|
23863
|
+
});
|
|
23864
|
+
}
|
|
23865
|
+
function groupIssues(issues) {
|
|
23866
|
+
const groups = /* @__PURE__ */ new Map();
|
|
23867
|
+
for (const issue of issues) {
|
|
23868
|
+
const existing = groups.get(issue.ruleId) ?? {
|
|
23869
|
+
ruleId: issue.ruleId,
|
|
23870
|
+
title: issue.title,
|
|
23871
|
+
category: issue.category,
|
|
23872
|
+
severity: issue.severity,
|
|
23873
|
+
count: 0,
|
|
23874
|
+
sampleUrls: []
|
|
23875
|
+
};
|
|
23876
|
+
existing.count += 1;
|
|
23877
|
+
if (existing.sampleUrls.length < 10) existing.sampleUrls.push(issue.url);
|
|
23878
|
+
groups.set(issue.ruleId, existing);
|
|
23879
|
+
}
|
|
23880
|
+
return [...groups.values()];
|
|
23881
|
+
}
|
|
23882
|
+
function searchValueForGroup(report, urls) {
|
|
23883
|
+
const wanted = new Set(urls);
|
|
23884
|
+
const pages = report.pages.filter((page) => wanted.has(page.url) || wanted.has(page.finalUrl));
|
|
23885
|
+
const metrics = pages.map((page) => page.searchMetrics).filter((item) => Boolean(item));
|
|
23886
|
+
const clicks = metrics.reduce((sum, item) => sum + item.clicks, 0);
|
|
23887
|
+
const impressions = metrics.reduce((sum, item) => sum + item.impressions, 0);
|
|
23888
|
+
const sessions = pages.reduce((sum, page) => sum + (page.analytics?.sessions ?? 0), 0);
|
|
23889
|
+
const observedMetrics = new Set(report.dataSources?.analytics.observedMetrics ?? [
|
|
23890
|
+
"sessions",
|
|
23891
|
+
"totalUsers",
|
|
23892
|
+
"conversions"
|
|
23893
|
+
]);
|
|
23894
|
+
const totalUsers = observedMetrics.has("totalUsers") ? pages.reduce((sum, page) => sum + (page.analytics?.totalUsers ?? 0), 0) : void 0;
|
|
23895
|
+
const conversions = observedMetrics.has("conversions") ? pages.reduce((sum, page) => sum + (page.analytics?.conversions ?? 0), 0) : void 0;
|
|
23896
|
+
const avgPosition = metrics.length ? metrics.reduce((sum, item) => sum + item.position, 0) / metrics.length : void 0;
|
|
23897
|
+
return {
|
|
23898
|
+
searchVisibleUrls: metrics.length,
|
|
23899
|
+
clicks,
|
|
23900
|
+
impressions,
|
|
23901
|
+
sessions,
|
|
23902
|
+
totalUsers,
|
|
23903
|
+
conversions,
|
|
23904
|
+
avgPosition
|
|
23798
23905
|
};
|
|
23799
23906
|
}
|
|
23907
|
+
function whyThisRanks(input, recommendation, searchStatus, analyticsProvider) {
|
|
23908
|
+
const visibility = input.searchVisibleUrls ? `${input.searchVisibleUrls} affected URLs have GSC visibility (${input.clicks} clicks, ${input.impressions} impressions).` : searchStatus && !["joined", "none"].includes(searchStatus) ? `No joined GSC visibility is available for these affected URLs; Search Console evidence is ${searchStatus}.` : "No affected URL has joined GSC visibility yet.";
|
|
23909
|
+
const analyticsDetails = analyticsProvider ? analyticsProviderDetails(analyticsProvider) : analyticsProviderDetails("google");
|
|
23910
|
+
const analyticsLabel = analyticsDetails.label;
|
|
23911
|
+
const sessionLabel = analyticsDetails.landingMetricLabel;
|
|
23912
|
+
return `${visibility}${input.sessions ? ` ${analyticsLabel} adds ${input.sessions} ${sessionLabel}${input.conversions === void 0 ? "" : ` and ${input.conversions} conversions`} from affected pages.` : ""} ${recommendation === "review" ? "This is a review observation. Confirm it before scheduling implementation work." : `This ranks as a fix. Severity is ${input.severity}; ${input.affectedUrls} URL${input.affectedUrls === 1 ? " is" : "s are"} affected; estimated effort is ${input.effort}.`}`;
|
|
23913
|
+
}
|
|
23914
|
+
function verificationCommand(report, severity) {
|
|
23915
|
+
return `seo crawl ${report.config.url} --severity ${severity} --max-pages ${Math.min(report.config.maxPages, 100)}`;
|
|
23916
|
+
}
|
|
23917
|
+
function topFixes(report, filters = {}) {
|
|
23918
|
+
return groupIssues(filteredIssues(report, filters)).map((group) => {
|
|
23919
|
+
const search = searchValueForGroup(report, group.sampleUrls);
|
|
23920
|
+
const effort = EFFORT_BY_CATEGORY[group.category] ?? "medium";
|
|
23921
|
+
const scoreFactors = {
|
|
23922
|
+
severity: SEVERITY_SCORE[group.severity],
|
|
23923
|
+
affectedUrls: group.count,
|
|
23924
|
+
searchVisibleUrls: search.searchVisibleUrls,
|
|
23925
|
+
clicks: search.clicks,
|
|
23926
|
+
impressions: search.impressions,
|
|
23927
|
+
sessions: search.sessions,
|
|
23928
|
+
totalUsers: search.totalUsers,
|
|
23929
|
+
conversions: search.conversions,
|
|
23930
|
+
avgPosition: search.avgPosition,
|
|
23931
|
+
effort,
|
|
23932
|
+
effortScore: EFFORT_SCORE[effort]
|
|
23933
|
+
};
|
|
23934
|
+
const valueWeight = VALUE_WEIGHT[group.severity];
|
|
23935
|
+
const score = scoreFactors.severity + scoreFactors.affectedUrls * AFFECTED_URL_WEIGHT[group.severity] + scoreFactors.searchVisibleUrls * 100 * valueWeight + scoreFactors.clicks * 20 * valueWeight + Math.min(scoreFactors.impressions, 1e4) / 25 * valueWeight + scoreFactors.sessions * 2 * valueWeight + (scoreFactors.conversions ?? 0) * 100 * valueWeight + scoreFactors.effortScore;
|
|
23936
|
+
const rule = explainRule(group.ruleId);
|
|
23937
|
+
const recommendation = recommendationForRule(group.ruleId);
|
|
23938
|
+
return {
|
|
23939
|
+
...group,
|
|
23940
|
+
recommendation,
|
|
23941
|
+
score: Math.round(score),
|
|
23942
|
+
scoreFactors,
|
|
23943
|
+
whyThisRanks: whyThisRanks(scoreFactors, recommendation, report.dataSources?.searchConsole.status, report.dataSources?.analytics.provider),
|
|
23944
|
+
howToFix: rule?.howToFix ?? "",
|
|
23945
|
+
howToVerify: rule?.howToVerify ?? "",
|
|
23946
|
+
verification: {
|
|
23947
|
+
command: verificationCommand(report, group.severity),
|
|
23948
|
+
expected: rule?.howToVerify ?? ""
|
|
23949
|
+
}
|
|
23950
|
+
};
|
|
23951
|
+
}).sort((a, b) => reachRank(a.ruleId) - reachRank(b.ruleId) || b.score - a.score || SEVERITY_SCORE[b.severity] - SEVERITY_SCORE[a.severity] || b.count - a.count || a.ruleId.localeCompare(b.ruleId)).slice(0, filters.limit ?? 10);
|
|
23952
|
+
}
|
|
23953
|
+
function reviewObservations(report, filters = {}) {
|
|
23954
|
+
return topFixes(report, {
|
|
23955
|
+
...filters,
|
|
23956
|
+
recommendation: "review"
|
|
23957
|
+
});
|
|
23958
|
+
}
|
|
23800
23959
|
// packages/core/dist/analyze/crawler/site-checks-schema.js
|
|
23801
23960
|
const crawlSiteChecksSchema = z.object({ soft404: z.object({
|
|
23802
23961
|
status: z.enum([
|
|
@@ -24912,123 +25071,6 @@ function agentReadyIssues(input) {
|
|
|
24912
25071
|
}
|
|
24913
25072
|
return issues;
|
|
24914
25073
|
}
|
|
24915
|
-
// packages/core/dist/analyze/crawler/crawl-url-queue.js
|
|
24916
|
-
function before(left, right) {
|
|
24917
|
-
return left.depth < right.depth || left.depth === right.depth && left.url < right.url;
|
|
24918
|
-
}
|
|
24919
|
-
var CrawlUrlQueue = class {
|
|
24920
|
-
#items = [];
|
|
24921
|
-
#positions = /* @__PURE__ */ new Map();
|
|
24922
|
-
get size() {
|
|
24923
|
-
return this.#items.length;
|
|
24924
|
-
}
|
|
24925
|
-
has(url) {
|
|
24926
|
-
return this.#positions.has(url);
|
|
24927
|
-
}
|
|
24928
|
-
push(item) {
|
|
24929
|
-
if (this.has(item.url)) throw new Error(`URL is already queued: ${item.url}`);
|
|
24930
|
-
const index = this.#items.length;
|
|
24931
|
-
this.#items.push(item);
|
|
24932
|
-
this.#positions.set(item.url, index);
|
|
24933
|
-
this.#bubbleUp(index);
|
|
24934
|
-
}
|
|
24935
|
-
decreaseDepth(url, depth) {
|
|
24936
|
-
const index = this.#positions.get(url);
|
|
24937
|
-
if (index === void 0) return false;
|
|
24938
|
-
const item = this.#items[index];
|
|
24939
|
-
if (!item || depth >= item.depth) return false;
|
|
24940
|
-
item.depth = depth;
|
|
24941
|
-
this.#bubbleUp(index);
|
|
24942
|
-
return true;
|
|
24943
|
-
}
|
|
24944
|
-
take() {
|
|
24945
|
-
const first = this.#items[0];
|
|
24946
|
-
if (!first) return void 0;
|
|
24947
|
-
const last = this.#items.pop();
|
|
24948
|
-
this.#positions.delete(first.url);
|
|
24949
|
-
if (last && last !== first) {
|
|
24950
|
-
this.#items[0] = last;
|
|
24951
|
-
this.#positions.set(last.url, 0);
|
|
24952
|
-
this.#bubbleDown(0);
|
|
24953
|
-
}
|
|
24954
|
-
return first;
|
|
24955
|
-
}
|
|
24956
|
-
#swap(left, right) {
|
|
24957
|
-
const leftItem = this.#items[left];
|
|
24958
|
-
const rightItem = this.#items[right];
|
|
24959
|
-
if (!leftItem || !rightItem) return;
|
|
24960
|
-
this.#items[left] = rightItem;
|
|
24961
|
-
this.#items[right] = leftItem;
|
|
24962
|
-
this.#positions.set(rightItem.url, left);
|
|
24963
|
-
this.#positions.set(leftItem.url, right);
|
|
24964
|
-
}
|
|
24965
|
-
#bubbleUp(start) {
|
|
24966
|
-
let index = start;
|
|
24967
|
-
while (index > 0) {
|
|
24968
|
-
const parent = Math.floor((index - 1) / 2);
|
|
24969
|
-
const item = this.#items[index];
|
|
24970
|
-
const parentItem = this.#items[parent];
|
|
24971
|
-
if (!item || !parentItem || !before(item, parentItem)) break;
|
|
24972
|
-
this.#swap(index, parent);
|
|
24973
|
-
index = parent;
|
|
24974
|
-
}
|
|
24975
|
-
}
|
|
24976
|
-
#bubbleDown(start) {
|
|
24977
|
-
let index = start;
|
|
24978
|
-
while (true) {
|
|
24979
|
-
const left = index * 2 + 1;
|
|
24980
|
-
const right = left + 1;
|
|
24981
|
-
let next = index;
|
|
24982
|
-
const item = this.#items[next];
|
|
24983
|
-
const leftItem = this.#items[left];
|
|
24984
|
-
const rightItem = this.#items[right];
|
|
24985
|
-
if (leftItem && item && before(leftItem, item)) next = left;
|
|
24986
|
-
const nextItem = this.#items[next];
|
|
24987
|
-
if (rightItem && nextItem && before(rightItem, nextItem)) next = right;
|
|
24988
|
-
if (next === index) return;
|
|
24989
|
-
this.#swap(index, next);
|
|
24990
|
-
index = next;
|
|
24991
|
-
}
|
|
24992
|
-
}
|
|
24993
|
-
};
|
|
24994
|
-
// packages/core/dist/analyze/crawler/crawl-caveats.js
|
|
24995
|
-
function crawlCaveats(input) {
|
|
24996
|
-
const caveats = input.cancelled ? ["Crawl cancelled before all queued URLs finished."] : input.pageLimitReached ? [`Stopped after reaching maxPages (${input.maxPages}).`] : [];
|
|
24997
|
-
if (input.queueSafetySkippedUrls > 0) caveats.push(input.queueSafetyInventoryCapped ? `Left at least ${input.queueSafetySkippedUrls} unique eligible same-origin URLs unqueued to keep this crawl bounded. The excluded-URL inventory reached its reporting cap. Increase --max-pages to inspect more.` : `Left ${input.queueSafetySkippedUrls} unique eligible same-origin URLs unqueued to keep this crawl bounded. Increase --max-pages to inspect more.`);
|
|
24998
|
-
if (input.originBackpressureSkippedUrls > 0) caveats.push(`Stopped ${input.originBackpressureSkippedUrls} queued URL${input.originBackpressureSkippedUrls === 1 ? "" : "s"} because the origin stayed slow. This is incomplete crawl evidence, not a failed URL check.`);
|
|
24999
|
-
if (input.memoryPressureSkippedUrls > 0) caveats.push(`Left ${input.memoryPressureSkippedUrls} eligible URL${input.memoryPressureSkippedUrls === 1 ? "" : "s"} unchecked after the local memory safety limit was reached. Start with --health or lower --max-pages.`);
|
|
25000
|
-
const renderingFallbackUrls = input.renderingFallbackUrls ?? 0;
|
|
25001
|
-
if (renderingFallbackUrls > 0) caveats.push(`JavaScript rendering could not run for ${renderingFallbackUrls} page${renderingFallbackUrls === 1 ? "" : "s"}, so ${renderingFallbackUrls === 1 ? "it was" : "they were"} audited from raw HTML. Client-rendered content, metadata and links may be missing. Install Chromium or set SEO_BROWSER_EXECUTABLE_PATH, then rerun.`);
|
|
25002
|
-
const queryVariantSkippedUrls = input.queryVariantSkippedUrls ?? 0;
|
|
25003
|
-
if (queryVariantSkippedUrls > 0) caveats.push(`Left ${queryVariantSkippedUrls} query-string variant${queryVariantSkippedUrls === 1 ? "" : "s"} unqueued after 5 variants of the same path, so tracking parameters do not fill the crawl.`);
|
|
25004
|
-
for (const issue of input.reachIssues ?? []) if (issue.detail) caveats.push(issue.detail);
|
|
25005
|
-
return caveats;
|
|
25006
|
-
}
|
|
25007
|
-
/**
|
|
25008
|
-
* A site crawl that found no internal links on the start page and no sitemap
|
|
25009
|
-
* URLs checked one page, so its other findings cannot stand for the site.
|
|
25010
|
-
*/
|
|
25011
|
-
function crawlReachIssues(input) {
|
|
25012
|
-
const [page] = input.pages;
|
|
25013
|
-
if (input.crawledPages !== 1 || input.pages.length !== 1 || !page || page.outgoingInternalCount > 0 || input.sitemapUrlsReturned > 0) return [];
|
|
25014
|
-
const rule = explainRule("crawl_single_page_reach");
|
|
25015
|
-
if (!rule) throw new Error("Missing rule guidance for crawl_single_page_reach");
|
|
25016
|
-
return [{
|
|
25017
|
-
ruleId: "crawl_single_page_reach",
|
|
25018
|
-
title: rule.title,
|
|
25019
|
-
category: rule.category,
|
|
25020
|
-
severity: rule.defaultSeverity,
|
|
25021
|
-
url: page.url,
|
|
25022
|
-
detail: page.usedJs ? "The rendered start page had 0 internal links and no sitemap returned URLs, so the crawl checked only this page. List the pages to check with --mode list." : "The start page had 0 internal links in its HTML and no sitemap returned URLs, so the crawl checked only this page. It probably builds its links with JavaScript. Rerun with --rendering on, or list the pages with --mode list.",
|
|
25023
|
-
evidence: {
|
|
25024
|
-
startUrl: input.startUrl,
|
|
25025
|
-
crawledPages: 1,
|
|
25026
|
-
outgoingInternalCount: 0,
|
|
25027
|
-
sitemapUrlsReturned: 0,
|
|
25028
|
-
rendered: Boolean(page.usedJs)
|
|
25029
|
-
}
|
|
25030
|
-
}];
|
|
25031
|
-
}
|
|
25032
25074
|
// packages/core/dist/analyze/crawler/crawl-control.js
|
|
25033
25075
|
const CRAWL_CANCELLED = Symbol("crawl-cancelled");
|
|
25034
25076
|
function abortController(input) {
|
|
@@ -25070,6 +25112,96 @@ function crawlRssLimitBytes(totalMemoryBytes) {
|
|
|
25070
25112
|
function crawlMemoryPressure(input) {
|
|
25071
25113
|
return input.memoryUsage.heapUsed + input.memoryUsage.external >= crawlMemoryLimitBytes(input.totalMemoryBytes) || input.memoryUsage.rss >= crawlRssLimitBytes(input.totalMemoryBytes);
|
|
25072
25114
|
}
|
|
25115
|
+
// packages/core/dist/analyze/crawler/crawl-rate-limit.js
|
|
25116
|
+
const RETRIES_PER_URL = 3;
|
|
25117
|
+
const BASE_BACKOFF_MS = 2e3;
|
|
25118
|
+
const MAX_BACKOFF_MS = 3e4;
|
|
25119
|
+
const COOLDOWN_BUDGET_MS = 18e4;
|
|
25120
|
+
const LIMITED_INTERVAL_MS = 1e3;
|
|
25121
|
+
const LIMITED_MAX_INTERVAL_MS = 4e3;
|
|
25122
|
+
function rateLimitEvidence(result, status, nowMs) {
|
|
25123
|
+
if (status !== 429 && status !== 503) return void 0;
|
|
25124
|
+
const header = retryAfterMs(result.page?.responseHeaders?.["retry-after"]);
|
|
25125
|
+
const cooldownUntil = Date.parse(result.page?.fetchDiagnostics?.backpressure?.cooldownUntil ?? "");
|
|
25126
|
+
const fromCooldown = Number.isFinite(cooldownUntil) ? Math.max(0, cooldownUntil - nowMs) : void 0;
|
|
25127
|
+
const wait = header ?? fromCooldown;
|
|
25128
|
+
if (status === 503 && wait === void 0) return void 0;
|
|
25129
|
+
return wait === void 0 ? {} : { retryAfterMs: Math.min(wait, MAX_BACKOFF_MS) };
|
|
25130
|
+
}
|
|
25131
|
+
/**
|
|
25132
|
+
* Rate-limit state for one crawl. After the first 429 the crawl drops to one
|
|
25133
|
+
* request at a time with a per-host interval and never speeds back up.
|
|
25134
|
+
*/
|
|
25135
|
+
var CrawlRateLimit = class {
|
|
25136
|
+
rate;
|
|
25137
|
+
responses = 0;
|
|
25138
|
+
skippedUrls = 0;
|
|
25139
|
+
pendingMs = 0;
|
|
25140
|
+
spentMs = 0;
|
|
25141
|
+
#level = 0;
|
|
25142
|
+
#budgetSpent = false;
|
|
25143
|
+
#retries = /* @__PURE__ */ new Map();
|
|
25144
|
+
constructor(rate) {
|
|
25145
|
+
this.rate = rate;
|
|
25146
|
+
}
|
|
25147
|
+
get limited() {
|
|
25148
|
+
return this.responses > 0;
|
|
25149
|
+
}
|
|
25150
|
+
get intervalMs() {
|
|
25151
|
+
return this.rate.intervalMs ?? LIMITED_INTERVAL_MS;
|
|
25152
|
+
}
|
|
25153
|
+
/** The cool-down before probes that run after the crawl. */
|
|
25154
|
+
get settleMs() {
|
|
25155
|
+
return Math.min(MAX_BACKOFF_MS, BASE_BACKOFF_MS * 2 ** this.#level);
|
|
25156
|
+
}
|
|
25157
|
+
/**
|
|
25158
|
+
* `retry` requeues the URL after `pendingMs`. `skip` gives it up, and
|
|
25159
|
+
* `stop` gives up the rest of the queue too once the budget is spent.
|
|
25160
|
+
*/
|
|
25161
|
+
observe(url, result, status, nowMs) {
|
|
25162
|
+
const evidence = rateLimitEvidence(result, status, nowMs);
|
|
25163
|
+
if (!evidence) return void 0;
|
|
25164
|
+
this.responses += 1;
|
|
25165
|
+
if (this.pendingMs === 0) this.#level += 1;
|
|
25166
|
+
const step = 2 ** Math.max(0, this.#level - 1);
|
|
25167
|
+
const waitMs = evidence.retryAfterMs ?? Math.min(MAX_BACKOFF_MS, BASE_BACKOFF_MS * step);
|
|
25168
|
+
this.rate = {
|
|
25169
|
+
...this.rate,
|
|
25170
|
+
concurrency: 1,
|
|
25171
|
+
intervalCap: 1,
|
|
25172
|
+
intervalMs: Math.min(LIMITED_MAX_INTERVAL_MS, Math.max(this.rate.intervalMs ?? 0, LIMITED_INTERVAL_MS * step))
|
|
25173
|
+
};
|
|
25174
|
+
const nextMs = Math.max(this.pendingMs, waitMs);
|
|
25175
|
+
if (this.spentMs + nextMs > COOLDOWN_BUDGET_MS) this.#budgetSpent = true;
|
|
25176
|
+
const retries = this.#retries.get(url) ?? 0;
|
|
25177
|
+
if (!this.#budgetSpent && retries < RETRIES_PER_URL) {
|
|
25178
|
+
this.#retries.set(url, retries + 1);
|
|
25179
|
+
this.pendingMs = nextMs;
|
|
25180
|
+
return "retry";
|
|
25181
|
+
}
|
|
25182
|
+
this.skippedUrls += 1;
|
|
25183
|
+
return this.#budgetSpent ? "stop" : "skip";
|
|
25184
|
+
}
|
|
25185
|
+
async settle(sleep, signal) {
|
|
25186
|
+
if (this.pendingMs <= 0) return;
|
|
25187
|
+
await sleep(this.pendingMs, signal);
|
|
25188
|
+
this.spentMs += this.pendingMs;
|
|
25189
|
+
this.pendingMs = 0;
|
|
25190
|
+
}
|
|
25191
|
+
caveatInput(config, readPages) {
|
|
25192
|
+
if (this.skippedUrls === 0) return void 0;
|
|
25193
|
+
return {
|
|
25194
|
+
readPages,
|
|
25195
|
+
skippedUrls: this.skippedUrls,
|
|
25196
|
+
rerunCommand: rateLimitRerunCommand(config, this.intervalMs)
|
|
25197
|
+
};
|
|
25198
|
+
}
|
|
25199
|
+
warning() {
|
|
25200
|
+
if (!this.limited) return void 0;
|
|
25201
|
+
const plural = (count, noun) => `${count} ${noun}${count === 1 ? "" : "s"}`;
|
|
25202
|
+
return `The origin rate limited ${plural(this.responses, "request")}. The crawl slowed to one request at a time, at least ${Math.round(this.intervalMs / 1e3)}s apart, and waited ${Math.round(this.spentMs / 1e3)}s in cool-downs${this.skippedUrls > 0 ? `, then left ${plural(this.skippedUrls, "URL")} unread` : " before reading every queued URL"}.`;
|
|
25203
|
+
}
|
|
25204
|
+
};
|
|
25073
25205
|
// packages/core/dist/analyze/crawler/external-link-checks.js
|
|
25074
25206
|
const EXTERNAL_LINK_SELECTION_LIMIT = 200;
|
|
25075
25207
|
const EXTERNAL_LINK_STATES = [
|
|
@@ -25330,21 +25462,32 @@ function probeObservation(url, result) {
|
|
|
25330
25462
|
error: request.outcome === "failure" ? request.error : `Probe skipped: ${request.reason}`
|
|
25331
25463
|
};
|
|
25332
25464
|
}
|
|
25465
|
+
function isConclusive(probe) {
|
|
25466
|
+
return probe.status !== void 0 && !probe.accessBlocked && probe.status !== 429 && probe.status !== 503;
|
|
25467
|
+
}
|
|
25333
25468
|
async function collectCrawlSiteChecks(input) {
|
|
25469
|
+
const probe = async (url) => probeObservation(url, await input.fetchStatusPage(url, {
|
|
25470
|
+
js: "off",
|
|
25471
|
+
refresh: true,
|
|
25472
|
+
timeoutMs: input.timeoutMs,
|
|
25473
|
+
signal: input.signal,
|
|
25474
|
+
writeCache: false,
|
|
25475
|
+
respectRobots: input.respectRobots,
|
|
25476
|
+
...input.rate ? { rate: input.rate } : {}
|
|
25477
|
+
}));
|
|
25334
25478
|
const probes = [];
|
|
25335
25479
|
for (const url of probeUrls(input.startUrl)) {
|
|
25336
25480
|
if (input.signal?.aborted) break;
|
|
25337
|
-
|
|
25338
|
-
js: "off",
|
|
25339
|
-
refresh: true,
|
|
25340
|
-
timeoutMs: input.timeoutMs,
|
|
25341
|
-
signal: input.signal,
|
|
25342
|
-
writeCache: false,
|
|
25343
|
-
respectRobots: input.respectRobots
|
|
25344
|
-
});
|
|
25345
|
-
probes.push(probeObservation(url, result));
|
|
25481
|
+
probes.push(await probe(url));
|
|
25346
25482
|
}
|
|
25347
|
-
|
|
25483
|
+
if (input.sleep && !input.signal?.aborted && probes.some((item) => !isConclusive(item))) {
|
|
25484
|
+
await input.sleep(input.retryDelayMs ?? 2e3, input.signal);
|
|
25485
|
+
for (const [index, item] of probes.entries()) {
|
|
25486
|
+
if (input.signal?.aborted) break;
|
|
25487
|
+
if (!isConclusive(item)) probes[index] = await probe(item.url);
|
|
25488
|
+
}
|
|
25489
|
+
}
|
|
25490
|
+
const conclusive = probes.filter(isConclusive);
|
|
25348
25491
|
const successful = conclusive.filter((probe) => (probe.status ?? 0) >= 200 && (probe.status ?? 0) < 400);
|
|
25349
25492
|
const correctMissing = conclusive.filter((probe) => probe.status === 404 || probe.status === 410);
|
|
25350
25493
|
const status = successful.length > 0 ? "warning" : correctMissing.length === PROBE_COUNT ? "pass" : "unknown";
|
|
@@ -25352,7 +25495,7 @@ async function collectCrawlSiteChecks(input) {
|
|
|
25352
25495
|
status,
|
|
25353
25496
|
probes,
|
|
25354
25497
|
probeLimit: PROBE_COUNT,
|
|
25355
|
-
complete:
|
|
25498
|
+
complete: conclusive.length === PROBE_COUNT
|
|
25356
25499
|
} };
|
|
25357
25500
|
if (status !== "warning") return {
|
|
25358
25501
|
checks,
|
|
@@ -27800,6 +27943,18 @@ async function fetchLandingPageValues(input, dependencies = defaultDependencies$
|
|
|
27800
27943
|
}[connection.provider](connection);
|
|
27801
27944
|
}
|
|
27802
27945
|
// packages/core/dist/analyze/crawler/site-crawl-dependencies.js
|
|
27946
|
+
function sleep$2(ms, signal) {
|
|
27947
|
+
return new Promise((resolve) => {
|
|
27948
|
+
if (signal?.aborted) return resolve();
|
|
27949
|
+
const done = () => {
|
|
27950
|
+
clearTimeout(timer);
|
|
27951
|
+
signal?.removeEventListener("abort", done);
|
|
27952
|
+
resolve();
|
|
27953
|
+
};
|
|
27954
|
+
const timer = setTimeout(done, ms);
|
|
27955
|
+
signal?.addEventListener("abort", done, { once: true });
|
|
27956
|
+
});
|
|
27957
|
+
}
|
|
27803
27958
|
function resolveCrawlSiteDependencies(dependencies = {}) {
|
|
27804
27959
|
const hasInjectedPerPageSearchProvider = Boolean(dependencies.queryPageMetrics || dependencies.queryPageTopQuery);
|
|
27805
27960
|
const hasInjectedLegacyBulkSearchProvider = Boolean(dependencies.queryPagesMetrics || dependencies.queryPagesTopQueries);
|
|
@@ -27820,6 +27975,7 @@ function resolveCrawlSiteDependencies(dependencies = {}) {
|
|
|
27820
27975
|
fetchLandingPageValues: dependencies.fetchLandingPageValues ?? fetchLandingPageValues,
|
|
27821
27976
|
landingValueForUrl: dependencies.landingValueForUrl ?? landingValueForUrl,
|
|
27822
27977
|
now: dependencies.now ?? (() => /* @__PURE__ */ new Date()),
|
|
27978
|
+
sleep: dependencies.sleep ?? sleep$2,
|
|
27823
27979
|
memoryUsage: dependencies.memoryUsage ?? (() => process.memoryUsage()),
|
|
27824
27980
|
totalMemory: dependencies.totalMemory ?? totalmem
|
|
27825
27981
|
};
|
|
@@ -28038,6 +28194,7 @@ async function crawlSite(input, dependencies) {
|
|
|
28038
28194
|
const queryVariantsByPath = /* @__PURE__ */ new Map();
|
|
28039
28195
|
const queryVariantSkipped = /* @__PURE__ */ new Set();
|
|
28040
28196
|
let renderingFallbackUrls = 0;
|
|
28197
|
+
const rateLimit = new CrawlRateLimit(config.fetchRate);
|
|
28041
28198
|
let failedUrls = 0;
|
|
28042
28199
|
let observedInternalLinks = 0;
|
|
28043
28200
|
let memoryLimited = false;
|
|
@@ -28122,6 +28279,15 @@ async function crawlSite(input, dependencies) {
|
|
|
28122
28279
|
});
|
|
28123
28280
|
return;
|
|
28124
28281
|
}
|
|
28282
|
+
if (isCdnUtilityUrl(normalized)) {
|
|
28283
|
+
recordSkip("cdn-utility-url");
|
|
28284
|
+
emitStatus("url_skipped", {
|
|
28285
|
+
url: normalized,
|
|
28286
|
+
depth,
|
|
28287
|
+
reason: "cdn_utility_url"
|
|
28288
|
+
});
|
|
28289
|
+
return;
|
|
28290
|
+
}
|
|
28125
28291
|
if (isLikelyAsset(normalized)) {
|
|
28126
28292
|
recordSkip("asset-url");
|
|
28127
28293
|
emitStatus("url_skipped", {
|
|
@@ -28226,7 +28392,7 @@ async function crawlSite(input, dependencies) {
|
|
|
28226
28392
|
js: config.js,
|
|
28227
28393
|
refresh: config.refresh,
|
|
28228
28394
|
timeoutMs: config.timeoutMs,
|
|
28229
|
-
rate:
|
|
28395
|
+
rate: rateLimit.rate,
|
|
28230
28396
|
signal,
|
|
28231
28397
|
writeCache: false,
|
|
28232
28398
|
respectRobots: config.respectRobots,
|
|
@@ -28257,7 +28423,11 @@ async function crawlSite(input, dependencies) {
|
|
|
28257
28423
|
});
|
|
28258
28424
|
};
|
|
28259
28425
|
while (!isCancelled() && (queue.size || inFlight.size) && (!memoryLimited || inFlight.size > 0) && pages.length < config.maxPages) {
|
|
28260
|
-
|
|
28426
|
+
if (rateLimit.pendingMs > 0 && inFlight.size === 0) {
|
|
28427
|
+
await rateLimit.settle(deps.sleep, signal);
|
|
28428
|
+
if (isCancelled()) break;
|
|
28429
|
+
}
|
|
28430
|
+
while (!isCancelled() && !memoryLimited && rateLimit.pendingMs === 0 && inFlight.size < activeConcurrency && pages.length + inFlight.size < config.maxPages) {
|
|
28261
28431
|
const item = nextQueueItem();
|
|
28262
28432
|
if (!item) break;
|
|
28263
28433
|
startTask(item);
|
|
@@ -28277,11 +28447,46 @@ async function crawlSite(input, dependencies) {
|
|
|
28277
28447
|
error: result.warning ?? "Crawler returned no page snapshot.",
|
|
28278
28448
|
extraction: "not-applicable"
|
|
28279
28449
|
});
|
|
28450
|
+
const limited = rateLimit.observe(task.url, result, request.outcome === "response" ? request.status : void 0, deps.now().getTime());
|
|
28451
|
+
if (limited) {
|
|
28452
|
+
activeConcurrency = 1;
|
|
28453
|
+
healthyProbeStreak = 0;
|
|
28454
|
+
}
|
|
28455
|
+
if (limited === "retry") {
|
|
28456
|
+
visited.delete(task.url);
|
|
28457
|
+
if (!queue.has(task.url)) queue.push({
|
|
28458
|
+
url: task.url,
|
|
28459
|
+
depth: task.depth
|
|
28460
|
+
});
|
|
28461
|
+
emitStatus("url_queued", {
|
|
28462
|
+
url: task.url,
|
|
28463
|
+
depth: task.depth,
|
|
28464
|
+
reason: "rate_limited_retry",
|
|
28465
|
+
message: `The origin rate limited ${task.url}. Waiting ${Math.ceil(rateLimit.pendingMs / 1e3)}s, then retrying one request at a time.`
|
|
28466
|
+
});
|
|
28467
|
+
continue;
|
|
28468
|
+
}
|
|
28469
|
+
if (limited) {
|
|
28470
|
+
requests.push(request);
|
|
28471
|
+
recordSkip("origin-backpressure");
|
|
28472
|
+
emitStatus("page_skipped", {
|
|
28473
|
+
url: task.url,
|
|
28474
|
+
depth: task.depth,
|
|
28475
|
+
reason: "origin-backpressure"
|
|
28476
|
+
});
|
|
28477
|
+
if (limited === "stop") {
|
|
28478
|
+
let left = 0;
|
|
28479
|
+
while (queue.take()) left += 1;
|
|
28480
|
+
recordSkips("origin-backpressure", left);
|
|
28481
|
+
rateLimit.skippedUrls += left;
|
|
28482
|
+
}
|
|
28483
|
+
continue;
|
|
28484
|
+
}
|
|
28280
28485
|
requests.push(request);
|
|
28281
28486
|
if (healthStrategy) {
|
|
28282
28487
|
if (request.outcome === "response" && request.status < 400 && !request.accessBlock) {
|
|
28283
28488
|
healthyProbeStreak += 1;
|
|
28284
|
-
activeConcurrency = Math.min(config.concurrency, 1 + Math.floor(healthyProbeStreak / 20));
|
|
28489
|
+
activeConcurrency = Math.min(rateLimit.limited ? 1 : config.concurrency, 1 + Math.floor(healthyProbeStreak / 20));
|
|
28285
28490
|
} else {
|
|
28286
28491
|
healthyProbeStreak = 0;
|
|
28287
28492
|
activeConcurrency = 1;
|
|
@@ -28354,7 +28559,7 @@ async function crawlSite(input, dependencies) {
|
|
|
28354
28559
|
const storeDocument = (urls) => {
|
|
28355
28560
|
const sourceRequest = normalizeUrl$1(task.url, config.url) ?? task.url;
|
|
28356
28561
|
const directlyRequested = sourceRequest === finalUrl;
|
|
28357
|
-
const normalizedLinks = urls.map((url) => normalizeUrl$1(url, finalUrl)).filter((url) => Boolean(url));
|
|
28562
|
+
const normalizedLinks = urls.map((url) => normalizeUrl$1(url, finalUrl)).filter((url) => Boolean(url) && !isCdnUtilityUrl(url ?? ""));
|
|
28358
28563
|
const syncLinkEvidence = (target, links) => {
|
|
28359
28564
|
const sortedLinks = [...links].sort((left, right) => left < right ? -1 : left > right ? 1 : 0);
|
|
28360
28565
|
target.outgoingInternalCount = sortedLinks.length;
|
|
@@ -28433,7 +28638,7 @@ async function crawlSite(input, dependencies) {
|
|
|
28433
28638
|
if (followLinks && task.depth < config.maxDepth) {
|
|
28434
28639
|
if (memoryLimited) for (const value of result.urls) {
|
|
28435
28640
|
const normalized = normalizeUrl$1(value, finalUrl);
|
|
28436
|
-
if (normalized && sameOrigin(normalized, origin) && !isLikelyAsset(normalized) && passesFilters(normalized, config.include, config.exclude) && !visited.has(normalized) && !queue.has(normalized)) {
|
|
28641
|
+
if (normalized && sameOrigin(normalized, origin) && !isCdnUtilityUrl(normalized) && !isLikelyAsset(normalized) && passesFilters(normalized, config.include, config.exclude) && !visited.has(normalized) && !queue.has(normalized)) {
|
|
28437
28642
|
discovered.add(normalized);
|
|
28438
28643
|
memoryDeferredUrls.add(normalized);
|
|
28439
28644
|
}
|
|
@@ -28458,6 +28663,8 @@ async function crawlSite(input, dependencies) {
|
|
|
28458
28663
|
cancelled = isCancelled();
|
|
28459
28664
|
const robotsDisallowed = skipReasonCounts["robots-disallowed"] ?? 0;
|
|
28460
28665
|
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.`);
|
|
28666
|
+
const rateLimitWarning = rateLimit.warning();
|
|
28667
|
+
if (rateLimitWarning) warnings.push(rateLimitWarning);
|
|
28461
28668
|
if (cancelled) {
|
|
28462
28669
|
warnings.push("Crawl cancelled; returning a partial report.");
|
|
28463
28670
|
emitStatus("cancelled", {
|
|
@@ -28469,7 +28676,7 @@ async function crawlSite(input, dependencies) {
|
|
|
28469
28676
|
const sitemapEvidencePartial = sitemapDiscovery?.dataStatus === "partial";
|
|
28470
28677
|
const accessEvidencePartial = requests.some((request) => request.outcome === "response" && request.accessBlock);
|
|
28471
28678
|
const requiredSitemapUnavailable = config.mode === "sitemap" && sitemapDiscovery?.dataStatus === "unavailable";
|
|
28472
|
-
const partial = cancelled || failedUrls > 0 || pageLimitReached || robotsDeferredUrls > 0 || originBackpressureSkippedUrls > 0 || queueSafetySkippedUrls > 0 || queue.size > 0 || inFlight.size > 0 || stoppedForMemory || sitemapEvidencePartial || accessEvidencePartial || requiredSitemapUnavailable;
|
|
28679
|
+
const partial = cancelled || failedUrls > 0 || pageLimitReached || robotsDeferredUrls > 0 || originBackpressureSkippedUrls > 0 || rateLimit.skippedUrls > 0 || queueSafetySkippedUrls > 0 || queue.size > 0 || inFlight.size > 0 || stoppedForMemory || sitemapEvidencePartial || accessEvidencePartial || requiredSitemapUnavailable;
|
|
28473
28680
|
const failed = !cancelled && pages.length === 0 && (requiredSitemapUnavailable || requests.length > 0 && requests.every((request) => request.outcome === "failure"));
|
|
28474
28681
|
const requestEvidenceStatus = cancelled && inFlight.size > 0 ? "partial" : "available";
|
|
28475
28682
|
const dataSources = await crawlDataSources({
|
|
@@ -28516,7 +28723,10 @@ async function crawlSite(input, dependencies) {
|
|
|
28516
28723
|
timeoutMs: config.timeoutMs,
|
|
28517
28724
|
fetchStatusPage: deps.fetchStatusPage,
|
|
28518
28725
|
respectRobots: config.respectRobots,
|
|
28519
|
-
signal
|
|
28726
|
+
signal,
|
|
28727
|
+
rate: rateLimit.rate,
|
|
28728
|
+
sleep: deps.sleep,
|
|
28729
|
+
retryDelayMs: rateLimit.settleMs
|
|
28520
28730
|
}) : void 0;
|
|
28521
28731
|
for (const page of pages) {
|
|
28522
28732
|
if (!page.geo || !llmsTxt) continue;
|
|
@@ -28585,6 +28795,8 @@ async function crawlSite(input, dependencies) {
|
|
|
28585
28795
|
queueSafetySkippedUrls,
|
|
28586
28796
|
queueSafetyInventoryCapped,
|
|
28587
28797
|
originBackpressureSkippedUrls,
|
|
28798
|
+
rateLimit: rateLimit.caveatInput(config, pages.length),
|
|
28799
|
+
sitemapLimit: sitemapLimitCaveatInput(config, sitemapDiscovery, input.maxPages === void 0),
|
|
28588
28800
|
memoryPressureSkippedUrls,
|
|
28589
28801
|
queryVariantSkippedUrls: queryVariantSkipped.size,
|
|
28590
28802
|
renderingFallbackUrls,
|
|
@@ -42279,6 +42491,31 @@ async function dataForSeoRows(keys, input, spent) {
|
|
|
42279
42491
|
}
|
|
42280
42492
|
const GAP_DOMAINS = 1e3;
|
|
42281
42493
|
const GAP_COMPETITORS = 5;
|
|
42494
|
+
const SHORTENERS = [
|
|
42495
|
+
"bit.ly",
|
|
42496
|
+
"tinyurl.com",
|
|
42497
|
+
"t.co",
|
|
42498
|
+
"ow.ly",
|
|
42499
|
+
"buff.ly",
|
|
42500
|
+
"is.gd",
|
|
42501
|
+
"cutt.ly",
|
|
42502
|
+
"rebrand.ly",
|
|
42503
|
+
"shorturl.at",
|
|
42504
|
+
"rb.gy",
|
|
42505
|
+
"lnkd.in"
|
|
42506
|
+
];
|
|
42507
|
+
const FREE_APP_HOSTS = [
|
|
42508
|
+
"web.app",
|
|
42509
|
+
"firebaseapp.com",
|
|
42510
|
+
"pages.dev"
|
|
42511
|
+
];
|
|
42512
|
+
const LINK_LIST_NAME = /(subdomain|whois|login|signin|coupon|promo|voucher|discount|sitelike|similarsite|sitesimilar|siteprice|sitevalue|siteworth|websiteworth|hypestat|statshow|websiteoutlook|siteinfo|domaininfo|domainstat|domaintools|ipaddress|dnslookup|linkdirectory|webdirectory|alternativeto-)/;
|
|
42513
|
+
function isLinkList(domain) {
|
|
42514
|
+
const within = (name) => domain === name || domain.endsWith(`.${name}`);
|
|
42515
|
+
if (SHORTENERS.some(within)) return true;
|
|
42516
|
+
if (FREE_APP_HOSTS.some((name) => domain.endsWith(`.${name}`))) return true;
|
|
42517
|
+
return LINK_LIST_NAME.test(domain.replace(/[-.]/g, ""));
|
|
42518
|
+
}
|
|
42282
42519
|
async function linkGap(provider, headers, input) {
|
|
42283
42520
|
const target = bareTarget(input.target).replace(/^www\./, "");
|
|
42284
42521
|
const competitors = [...new Set((input.competitors ?? []).map((site) => bareTarget(site).replace(/^www\./, "")))].filter((site) => site && site !== target);
|
|
@@ -42319,8 +42556,10 @@ async function linkGap(provider, headers, input) {
|
|
|
42319
42556
|
found.set(domain, entry);
|
|
42320
42557
|
}
|
|
42321
42558
|
});
|
|
42559
|
+
const gap = [...found.entries()].filter(([, entry]) => entry.sites.length >= 2 && (floor === null || entry.value >= floor));
|
|
42560
|
+
const linkLists = gap.map(([domain]) => domain).filter(isLinkList).sort((a, b) => a.localeCompare(b));
|
|
42322
42561
|
return {
|
|
42323
|
-
rows:
|
|
42562
|
+
rows: gap.filter(([domain]) => !isLinkList(domain)).sort(([a, left], [b, right]) => right.value - left.value || right.sites.length - left.sites.length || a.localeCompare(b)).map(([domain, entry]) => ({
|
|
42324
42563
|
domain,
|
|
42325
42564
|
[score]: entry.value,
|
|
42326
42565
|
competitors_linking: entry.sites.length,
|
|
@@ -42333,7 +42572,8 @@ async function linkGap(provider, headers, input) {
|
|
|
42333
42572
|
...floor !== null ? { floor: {
|
|
42334
42573
|
field: score,
|
|
42335
42574
|
value: floor
|
|
42336
|
-
} } : {}
|
|
42575
|
+
} } : {},
|
|
42576
|
+
...linkLists.length ? { leftOut: linkLists } : {}
|
|
42337
42577
|
};
|
|
42338
42578
|
}
|
|
42339
42579
|
function isPage(target) {
|
|
@@ -42700,6 +42940,7 @@ async function runAiAnswers(input, headers, deps, more) {
|
|
|
42700
42940
|
question,
|
|
42701
42941
|
impressions,
|
|
42702
42942
|
task: null,
|
|
42943
|
+
ref: null,
|
|
42703
42944
|
cost_usd: 0,
|
|
42704
42945
|
...empty
|
|
42705
42946
|
});
|
|
@@ -42711,7 +42952,8 @@ async function runAiAnswers(input, headers, deps, more) {
|
|
|
42711
42952
|
...blank("", null),
|
|
42712
42953
|
engine: engine.name,
|
|
42713
42954
|
result: "pending",
|
|
42714
|
-
task: value
|
|
42955
|
+
task: value,
|
|
42956
|
+
ref: value
|
|
42715
42957
|
};
|
|
42716
42958
|
rows.push(row);
|
|
42717
42959
|
waiting.push({
|
|
@@ -42764,6 +43006,7 @@ async function runAiAnswers(input, headers, deps, more) {
|
|
|
42764
43006
|
}
|
|
42765
43007
|
row.result = "pending";
|
|
42766
43008
|
row.task = `${engine.name}:${task.id}`;
|
|
43009
|
+
row.ref = row.task;
|
|
42767
43010
|
waiting.push({
|
|
42768
43011
|
row,
|
|
42769
43012
|
engine,
|
|
@@ -42804,6 +43047,7 @@ async function runAiAnswers(input, headers, deps, more) {
|
|
|
42804
43047
|
const cost = usd(rows.reduce((sum, row) => sum + row.cost_usd, 0));
|
|
42805
43048
|
const count = (result) => rows.filter((row) => row.result === result).length;
|
|
42806
43049
|
const left = rows.filter((row) => row.task).map((row) => row.task);
|
|
43050
|
+
const refs = rows.flatMap((row) => row.ref ? [row.ref] : []);
|
|
42807
43051
|
const cites = /* @__PURE__ */ new Map();
|
|
42808
43052
|
for (const row of rows) for (const host of row.sources) cites.set(host, (cites.get(host) ?? 0) + 1);
|
|
42809
43053
|
const topCited = [...cites].sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0])).slice(0, 10);
|
|
@@ -42837,7 +43081,7 @@ async function runAiAnswers(input, headers, deps, more) {
|
|
|
42837
43081
|
limit: 50,
|
|
42838
43082
|
detail: input.detail,
|
|
42839
43083
|
notes,
|
|
42840
|
-
more
|
|
43084
|
+
more: (offset) => more(offset, refs)
|
|
42841
43085
|
}),
|
|
42842
43086
|
data: {
|
|
42843
43087
|
provider: "dataforseo",
|
|
@@ -42851,6 +43095,7 @@ async function runAiAnswers(input, headers, deps, more) {
|
|
|
42851
43095
|
questions: [...new Set(rows.map((row) => row.question))],
|
|
42852
43096
|
costUsd: cost,
|
|
42853
43097
|
pendingTasks: left,
|
|
43098
|
+
tasks: refs,
|
|
42854
43099
|
citedSites: topCited.map(([host, answers]) => ({
|
|
42855
43100
|
host,
|
|
42856
43101
|
answers
|
|
@@ -42965,7 +43210,7 @@ function matchGa4WebStreams(site, candidates) {
|
|
|
42965
43210
|
}
|
|
42966
43211
|
// packages/core/dist/commands/analytics-site.js
|
|
42967
43212
|
const STREAM_CHECKS = 30;
|
|
42968
|
-
const AT_ONCE = 5;
|
|
43213
|
+
const AT_ONCE$1 = 5;
|
|
42969
43214
|
const TIED = 3;
|
|
42970
43215
|
const hostOf$1 = (site) => {
|
|
42971
43216
|
const value = site.replace(/^sc-domain:/, "");
|
|
@@ -43030,8 +43275,8 @@ async function lookProperty(access, site) {
|
|
|
43030
43275
|
}
|
|
43031
43276
|
const order = ranked(site, candidates).slice(0, STREAM_CHECKS);
|
|
43032
43277
|
let failures = 0;
|
|
43033
|
-
for (let start = 0; start < order.length; start += AT_ONCE) {
|
|
43034
|
-
const batch = order.slice(start, start + AT_ONCE);
|
|
43278
|
+
for (let start = 0; start < order.length; start += AT_ONCE$1) {
|
|
43279
|
+
const batch = order.slice(start, start + AT_ONCE$1);
|
|
43035
43280
|
const streams = await Promise.all(batch.map((candidate) => listAnalyticsDataStreams(candidate.google, candidate.property, { signIn: access.signIn }).catch(() => {
|
|
43036
43281
|
failures += 1;
|
|
43037
43282
|
return [];
|
|
@@ -44164,7 +44409,8 @@ async function runClickyRows(input, keys, options) {
|
|
|
44164
44409
|
// packages/core/dist/commands/domain-rating.js
|
|
44165
44410
|
const DR_ATTRIBUTION = "domain_rating: Domain Rating by Ahrefs (https://ahrefs.com/).";
|
|
44166
44411
|
const ENDPOINT = "https://api.ahrefs.com/v3/public/domain-rating-free";
|
|
44167
|
-
const MAX_DOMAINS =
|
|
44412
|
+
const MAX_DOMAINS = 50;
|
|
44413
|
+
const AT_ONCE = 25;
|
|
44168
44414
|
const TTL_SECONDS = 86400;
|
|
44169
44415
|
const DOMAIN_FIELDS = [
|
|
44170
44416
|
"domain",
|
|
@@ -44195,23 +44441,26 @@ async function lookup(domain, key) {
|
|
|
44195
44441
|
}
|
|
44196
44442
|
return body.domain_rating?.domain_rating ?? null;
|
|
44197
44443
|
}
|
|
44198
|
-
async function domainRatings(domains, key) {
|
|
44444
|
+
async function domainRatings(domains, key, max = MAX_DOMAINS) {
|
|
44199
44445
|
const found = /* @__PURE__ */ new Map();
|
|
44200
44446
|
if (!key) return found;
|
|
44201
|
-
const unique = [...new Set(domains.map(bareDomain).filter(Boolean))].slice(0,
|
|
44202
|
-
|
|
44203
|
-
|
|
44204
|
-
|
|
44205
|
-
|
|
44447
|
+
const unique = [...new Set(domains.map(bareDomain).filter(Boolean))].slice(0, max);
|
|
44448
|
+
for (let start = 0; start < unique.length; start += AT_ONCE) {
|
|
44449
|
+
const batch = unique.slice(start, start + AT_ONCE);
|
|
44450
|
+
const values = await Promise.all(batch.map((domain) => lookup(domain, key).catch(() => null)));
|
|
44451
|
+
batch.forEach((domain, index) => {
|
|
44452
|
+
found.set(domain, values[index] ?? null);
|
|
44453
|
+
});
|
|
44454
|
+
}
|
|
44206
44455
|
return found;
|
|
44207
44456
|
}
|
|
44208
|
-
async function withDomainRating(rows, key) {
|
|
44457
|
+
async function withDomainRating(rows, key, start = 0) {
|
|
44209
44458
|
const field = DOMAIN_FIELDS.find((name) => rows.some((row) => typeof row[name] === "string"));
|
|
44210
44459
|
if (!key || !field || rows.some((row) => "domain_rating" in row)) return {
|
|
44211
44460
|
rows,
|
|
44212
44461
|
added: false
|
|
44213
44462
|
};
|
|
44214
|
-
const ratings = await domainRatings(rows.map((row) => String(row[field] ?? "")), key);
|
|
44463
|
+
const ratings = await domainRatings(rows.slice(start).map((row) => String(row[field] ?? "")), key);
|
|
44215
44464
|
return {
|
|
44216
44465
|
rows: rows.map((row) => {
|
|
44217
44466
|
const next = {};
|
|
@@ -46305,7 +46554,7 @@ async function runResearch(input, keys, options) {
|
|
|
46305
46554
|
const offset = input.offset ?? 0;
|
|
46306
46555
|
const language = input.provider === "dataforseo" ? dataForSeoLanguage(country) : null;
|
|
46307
46556
|
if (input.action === "ai_answers") throw new CommandError("INVALID_INPUT", "ai_answers runs on its own.");
|
|
46308
|
-
const { rows: found, costUsd, partial, missed, noData, floor } = await research(input.provider, keys, {
|
|
46557
|
+
const { rows: found, costUsd, partial, missed, noData, floor, leftOut } = await research(input.provider, keys, {
|
|
46309
46558
|
action: input.action,
|
|
46310
46559
|
keywords: input.keywords ?? [],
|
|
46311
46560
|
target,
|
|
@@ -46319,7 +46568,8 @@ async function runResearch(input, keys, options) {
|
|
|
46319
46568
|
};
|
|
46320
46569
|
const unresolved = rows.filter((row) => row.url === null && row.url_hint !== void 0).length;
|
|
46321
46570
|
const drKey = keys.get("x-ahrefs-key") ?? options.drKey;
|
|
46322
|
-
const
|
|
46571
|
+
const byDr = input.action === "link_gap" && input.provider === "dataforseo" && drKey;
|
|
46572
|
+
const rated = byDr ? await sortedByDomainRating(rows, drKey) : await withDomainRating(rows, drKey, offset);
|
|
46323
46573
|
const targetDr = target ? (await domainRatings([target], drKey)).get(bareDomain(target)) : void 0;
|
|
46324
46574
|
const hasTargetDr = targetDr !== void 0 && targetDr !== null;
|
|
46325
46575
|
return {
|
|
@@ -46335,6 +46585,8 @@ async function runResearch(input, keys, options) {
|
|
|
46335
46585
|
...partial ? ["Partial: DataForSEO could not fetch some pages of results, so rows may be missing."] : [],
|
|
46336
46586
|
...missed.length ? [`Partial: ${input.provider} failed for ${missed.join(", ")}, so those keywords have no rows. Ask for them again.`] : [],
|
|
46337
46587
|
...noData.length ? [noDataNote(input, noData)] : [],
|
|
46588
|
+
...byDr ? [rows.length > GAP_RATED ? `Sorted by Domain Rating for the top ${GAP_RATED} rows by DataForSEO rank; the ${rows.length - GAP_RATED} rows after them follow by rank, with no Domain Rating.` : "Sorted by Domain Rating, then DataForSEO rank."] : [],
|
|
46589
|
+
...leftOut?.length ? [`Left out ${leftOut.length} link lists, URL shorteners and directories that link to most sites (a guess from the host name): ${leftOut.join(", ")}.`] : [],
|
|
46338
46590
|
...floor ? [`Cut at ${floor.field} ${floor.value}: at least one site has more than 1,000 referring domains, and only its top 1,000 were read, so domains below ${floor.field} ${floor.value} are left out rather than guessed.`] : [],
|
|
46339
46591
|
...unresolved ? [unresolvedNote(input, unresolved, rows.length)] : [],
|
|
46340
46592
|
...note ? [note] : [],
|
|
@@ -46358,7 +46610,8 @@ async function runResearch(input, keys, options) {
|
|
|
46358
46610
|
noDataKeywords: noData,
|
|
46359
46611
|
...input.action === "link_gap" ? {
|
|
46360
46612
|
competitors: input.competitors ?? [],
|
|
46361
|
-
cutAt: floor ?? null
|
|
46613
|
+
cutAt: floor ?? null,
|
|
46614
|
+
leftOut: leftOut ?? []
|
|
46362
46615
|
} : {},
|
|
46363
46616
|
total: rated.rows.length,
|
|
46364
46617
|
offset,
|
|
@@ -46374,6 +46627,24 @@ function noDataNote(input, keywords) {
|
|
|
46374
46627
|
function unresolvedNote(input, count, total) {
|
|
46375
46628
|
return `${count} of ${total} results have no exact page: ${input.provider} returned Google redirect links for them. Their domain and url_hint come from Google's breadcrumb, which is shortened.${input.provider === "dataforseo" ? " For exact pages, use provider ahrefs." : ""}`;
|
|
46376
46629
|
}
|
|
46630
|
+
const GAP_RATED = 200;
|
|
46631
|
+
async function sortedByDomainRating(rows, key) {
|
|
46632
|
+
const top = rows.slice(0, GAP_RATED);
|
|
46633
|
+
const ratings = await domainRatings(top.map((row) => String(row.domain ?? "")), key, GAP_RATED);
|
|
46634
|
+
const withDr = (row, rated) => {
|
|
46635
|
+
const { domain = null, ...rest } = row;
|
|
46636
|
+
return {
|
|
46637
|
+
domain,
|
|
46638
|
+
domain_rating: rated ? ratings.get(bareDomain(String(domain ?? ""))) ?? null : null,
|
|
46639
|
+
...rest
|
|
46640
|
+
};
|
|
46641
|
+
};
|
|
46642
|
+
const dr = (row) => Number(row.domain_rating ?? -1);
|
|
46643
|
+
return {
|
|
46644
|
+
rows: [...top.map((row) => withDr(row, true)).sort((a, b) => dr(b) - dr(a) || Number(b.rank ?? 0) - Number(a.rank ?? 0) || String(a.domain).localeCompare(String(b.domain))), ...rows.slice(GAP_RATED).map((row) => withDr(row, false))],
|
|
46645
|
+
added: true
|
|
46646
|
+
};
|
|
46647
|
+
}
|
|
46377
46648
|
// packages/core/dist/commands/sitemap-urls.js
|
|
46378
46649
|
const MAX_FILES = 20;
|
|
46379
46650
|
const MAX_URLS = 5e4;
|
|
@@ -47264,7 +47535,7 @@ const SHARED_TOOLS = [
|
|
|
47264
47535
|
offset: input.offset,
|
|
47265
47536
|
questions: input.keywords,
|
|
47266
47537
|
tasks: input.tasks
|
|
47267
|
-
}, await surface.keys(), surface.deps,
|
|
47538
|
+
}, await surface.keys(), surface.deps, (offset, tasks) => `seo_research with the same provider, action and target, tasks ${JSON.stringify(tasks)}, detail "rows", offset ${offset}. Passing the tasks reads these answers back at no cost; without them every question is asked and paid for again`);
|
|
47268
47539
|
}
|
|
47269
47540
|
return runResearch(input, await surface.keys(), {
|
|
47270
47541
|
drKey: surface.drKey,
|