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/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-CV5s7esU.js";
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 blocked = [
19162
- 401,
19163
- 403,
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/top-fixes.js
23240
- const SEVERITY_SCORE = {
23241
- high: 5e3,
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
- function matchesPattern(pattern, value) {
23280
- if (!pattern.includes("*")) return value.includes(pattern);
23281
- const parts = pattern.split("*");
23282
- let position = 0;
23283
- for (const part of parts) {
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
- return true;
23290
- }
23291
- function filteredIssues(report, filters) {
23292
- return report.issues.filter((issue) => {
23293
- if (filters.category && issue.category !== filters.category) return false;
23294
- if (filters.severity && issue.severity !== filters.severity) return false;
23295
- if (filters.url && !matchesPattern(filters.url, issue.url)) return false;
23296
- if ((filters.recommendation ?? "fix") !== recommendationForRule(issue.ruleId)) return false;
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
- return [...groups.values()];
23316
- }
23317
- function searchValueForGroup(report, urls) {
23318
- const wanted = new Set(urls);
23319
- const pages = report.pages.filter((page) => wanted.has(page.url) || wanted.has(page.finalUrl));
23320
- const metrics = pages.map((page) => page.searchMetrics).filter((item) => Boolean(item));
23321
- const clicks = metrics.reduce((sum, item) => sum + item.clicks, 0);
23322
- const impressions = metrics.reduce((sum, item) => sum + item.impressions, 0);
23323
- const sessions = pages.reduce((sum, page) => sum + (page.analytics?.sessions ?? 0), 0);
23324
- const observedMetrics = new Set(report.dataSources?.analytics.observedMetrics ?? [
23325
- "sessions",
23326
- "totalUsers",
23327
- "conversions"
23328
- ]);
23329
- const totalUsers = observedMetrics.has("totalUsers") ? pages.reduce((sum, page) => sum + (page.analytics?.totalUsers ?? 0), 0) : void 0;
23330
- const conversions = observedMetrics.has("conversions") ? pages.reduce((sum, page) => sum + (page.analytics?.conversions ?? 0), 0) : void 0;
23331
- const avgPosition = metrics.length ? metrics.reduce((sum, item) => sum + item.position, 0) / metrics.length : void 0;
23332
- return {
23333
- searchVisibleUrls: metrics.length,
23334
- clicks,
23335
- impressions,
23336
- sessions,
23337
- totalUsers,
23338
- conversions,
23339
- avgPosition
23340
- };
23341
- }
23342
- function whyThisRanks(input, recommendation, searchStatus, analyticsProvider) {
23343
- 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.";
23344
- const analyticsDetails = analyticsProvider ? analyticsProviderDetails(analyticsProvider) : analyticsProviderDetails("google");
23345
- const analyticsLabel = analyticsDetails.label;
23346
- const sessionLabel = analyticsDetails.landingMetricLabel;
23347
- 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}.`}`;
23348
- }
23349
- function verificationCommand(report, severity) {
23350
- return `seo crawl ${report.config.url} --severity ${severity} --max-pages ${Math.min(report.config.maxPages, 100)}`;
23351
- }
23352
- function topFixes(report, filters = {}) {
23353
- return groupIssues(filteredIssues(report, filters)).map((group) => {
23354
- const search = searchValueForGroup(report, group.sampleUrls);
23355
- const effort = EFFORT_BY_CATEGORY[group.category] ?? "medium";
23356
- const scoreFactors = {
23357
- severity: SEVERITY_SCORE[group.severity],
23358
- affectedUrls: group.count,
23359
- searchVisibleUrls: search.searchVisibleUrls,
23360
- clicks: search.clicks,
23361
- impressions: search.impressions,
23362
- sessions: search.sessions,
23363
- totalUsers: search.totalUsers,
23364
- conversions: search.conversions,
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(compareText$15)
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
- const result = await input.fetchStatusPage(url, {
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
- const conclusive = probes.filter((probe) => probe.status !== void 0 && !probe.accessBlocked);
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: probes.length === PROBE_COUNT
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: config.fetchRate,
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
- while (!isCancelled() && !memoryLimited && inFlight.size < activeConcurrency && pages.length + inFlight.size < config.maxPages) {
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: [...found.entries()].filter(([, entry]) => entry.sites.length >= 2 && (floor === null || entry.value >= floor)).sort(([a, left], [b, right]) => right.value - left.value || right.sites.length - left.sites.length || a.localeCompare(b)).map(([domain, entry]) => ({
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 = 25;
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, MAX_DOMAINS);
44202
- const values = await Promise.all(unique.map((domain) => lookup(domain, key).catch(() => null)));
44203
- unique.forEach((domain, index) => {
44204
- found.set(domain, values[index] ?? null);
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 rated = await withDomainRating(rows, drKey);
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, more);
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,