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/index.js CHANGED
@@ -1,5 +1,5 @@
1
1
  import { createRequire as __seoCreateRequire } from 'node:module'; const require = __seoCreateRequire(import.meta.url);
2
- import { n as version, t as load } from "./esm-CV5s7esU.js";
2
+ import { n as version, t as load } from "./esm-Cvmg0qhZ.js";
3
3
  import { SEO_PROVIDER_API_VERSION } from "./provider-sdk.js";
4
4
  import crypto, { createHash, randomUUID, sign } from "node:crypto";
5
5
  import { z } from "zod";
@@ -7728,17 +7728,15 @@ function recordHostFetch(input) {
7728
7728
  const cfg = input.rate.backpressure;
7729
7729
  health.durations.push(input.durationMs);
7730
7730
  if (health.durations.length > 20) health.durations.shift();
7731
- const blocked = [
7732
- 401,
7733
- 403,
7734
- 429
7735
- ].includes(input.status);
7736
- const errored = input.status >= 500;
7731
+ const rateLimited = input.status === 429 || input.status === 503 && input.retryAfterMs !== void 0;
7732
+ const blocked = [401, 403].includes(input.status);
7733
+ const errored = input.status >= 500 && !rateLimited;
7737
7734
  const slow = input.durationMs >= cfg.slowMs;
7738
7735
  health.consecutiveBlocked = blocked ? health.consecutiveBlocked + 1 : 0;
7739
7736
  health.consecutiveErrors = errored ? health.consecutiveErrors + 1 : 0;
7740
7737
  health.consecutiveSlow = slow ? health.consecutiveSlow + 1 : 0;
7741
- if (!blocked && !errored && !slow) health.reason = void 0;
7738
+ if (!blocked && !errored && !slow && !rateLimited) health.reason = void 0;
7739
+ if (rateLimited) health.reason = `origin rate limited the crawler (HTTP ${input.status})`;
7742
7740
  const retryDelay = input.retryAfterMs === void 0 ? 0 : Math.min(input.retryAfterMs, cfg.retryAfterCapMs);
7743
7741
  if (retryDelay > 0) {
7744
7742
  health.reason = `origin sent Retry-After (${Math.round(retryDelay / 1e3)}s)`;
@@ -11288,6 +11286,17 @@ function isAuditableHtmlPage(page) {
11288
11286
  const contentAuditAllowed = page.contentAuditAllowed ?? (!page.blocked && page.robotsTxt?.allowed !== false);
11289
11287
  return isSuccessfulResponse(page) && isHtmlPage(page) && !isRedirectedPage(page) && contentAuditAllowed;
11290
11288
  }
11289
+ /**
11290
+ * Cloudflare serves its own endpoints under `/cdn-cgi/`, such as the email
11291
+ * obfuscation link `/cdn-cgi/l/email-protection`. They are not site pages.
11292
+ */
11293
+ function isCdnUtilityUrl(value) {
11294
+ try {
11295
+ return new URL(value).pathname.startsWith("/cdn-cgi/");
11296
+ } catch {
11297
+ return false;
11298
+ }
11299
+ }
11291
11300
  // packages/core/src/analyze/crawler/audit.ts
11292
11301
  function sameUrl(a, b) {
11293
11302
  if (!a || !b) return false;
@@ -11557,7 +11566,7 @@ function auditCrawlPages(pages, opts = {}) {
11557
11566
  if (page.accessBlock) continue;
11558
11567
  if (page.status >= 500) {
11559
11568
  issues.push(issue$1("server_error", page, String(page.status), { status: page.status }));
11560
- if ((page.internalInlinkCount ?? 0) > 0) issues.push(issue$1("broken_internal_link", page, String(page.status), {
11569
+ if ((page.internalInlinkCount ?? 0) > 0 && !isCdnUtilityUrl(page.url)) issues.push(issue$1("broken_internal_link", page, String(page.status), {
11561
11570
  status: page.status,
11562
11571
  internalInlinkCount: page.internalInlinkCount
11563
11572
  }));
@@ -11565,7 +11574,7 @@ function auditCrawlPages(pages, opts = {}) {
11565
11574
  }
11566
11575
  if (page.status >= 400) {
11567
11576
  issues.push(issue$1("client_error", page, String(page.status), { status: page.status }));
11568
- if (isBrokenLinkStatus(page.status) && (page.internalInlinkCount ?? 0) > 0) issues.push(issue$1("broken_internal_link", page, String(page.status), {
11577
+ if (isBrokenLinkStatus(page.status) && (page.internalInlinkCount ?? 0) > 0 && !isCdnUtilityUrl(page.url)) issues.push(issue$1("broken_internal_link", page, String(page.status), {
11569
11578
  status: page.status,
11570
11579
  internalInlinkCount: page.internalInlinkCount
11571
11580
  }));
@@ -11788,6 +11797,7 @@ function auditCrawlPages(pages, opts = {}) {
11788
11797
  // packages/core/src/analyze/crawler/crawl-skip-reasons.ts
11789
11798
  const CRAWL_SKIP_REASONS = [
11790
11799
  "asset-url",
11800
+ "cdn-utility-url",
11791
11801
  "configured-exclusion",
11792
11802
  "duplicate-normalization",
11793
11803
  "invalid-url",
@@ -11802,6 +11812,7 @@ const CRAWL_SKIP_REASONS = [
11802
11812
  ];
11803
11813
  const SKIP_IMPACT = {
11804
11814
  "asset-url": "non-impacting",
11815
+ "cdn-utility-url": "non-impacting",
11805
11816
  "configured-exclusion": "non-impacting",
11806
11817
  "duplicate-normalization": "non-impacting",
11807
11818
  "invalid-url": "non-impacting",
@@ -12428,473 +12439,85 @@ function refreshPrioritiesCsvFiles(report) {
12428
12439
  ...prefixCsvFiles("diagnosis", diagnoseCsvFiles(report.output.diagnosis))
12429
12440
  ]);
12430
12441
  }
12431
- // packages/core/src/analyze/crawler/top-fixes.ts
12432
- const SEVERITY_SCORE = {
12433
- high: 5e3,
12434
- medium: 1200,
12435
- low: 100
12436
- };
12437
- const EFFORT_BY_CATEGORY = {
12438
- canonical: "medium",
12439
- content: "high",
12440
- agents: "low",
12441
- headings: "low",
12442
- images: "medium",
12443
- indexability: "medium",
12444
- international: "low",
12445
- metadata: "low",
12446
- mobile: "low",
12447
- response: "medium",
12448
- security: "medium",
12449
- social: "low",
12450
- "structured-data": "medium"
12451
- };
12452
- const EFFORT_SCORE = {
12453
- low: 60,
12454
- medium: 30,
12455
- high: 0
12456
- };
12457
- const AFFECTED_URL_WEIGHT = {
12458
- high: 25,
12459
- medium: 15,
12460
- low: 2
12461
- };
12462
- const VALUE_WEIGHT = {
12463
- high: 1,
12464
- medium: .75,
12465
- low: .15
12466
- };
12467
- const CRAWL_REACH_RULES = /* @__PURE__ */ new Set(["crawl_single_page_reach"]);
12468
- function reachRank(ruleId) {
12469
- return CRAWL_REACH_RULES.has(ruleId) ? 0 : 1;
12442
+ // packages/core/src/analyze/crawler/crawl-url-queue.ts
12443
+ function before(left, right) {
12444
+ return left.depth < right.depth || left.depth === right.depth && left.url < right.url;
12470
12445
  }
12471
- function matchesPattern(pattern, value) {
12472
- if (!pattern.includes("*")) return value.includes(pattern);
12473
- const parts = pattern.split("*");
12474
- let position = 0;
12475
- for (const part of parts) {
12476
- if (!part) continue;
12477
- const found = value.slice(position).indexOf(part);
12478
- if (found === -1) return false;
12479
- position += found + part.length;
12446
+ var CrawlUrlQueue = class {
12447
+ #items = [];
12448
+ #positions = /* @__PURE__ */ new Map();
12449
+ get size() {
12450
+ return this.#items.length;
12480
12451
  }
12481
- return true;
12482
- }
12483
- function filteredIssues(report, filters) {
12484
- return report.issues.filter((issue) => {
12485
- if (filters.category && issue.category !== filters.category) return false;
12486
- if (filters.severity && issue.severity !== filters.severity) return false;
12487
- if (filters.url && !matchesPattern(filters.url, issue.url)) return false;
12488
- if ((filters.recommendation ?? "fix") !== recommendationForRule(issue.ruleId)) return false;
12489
- return true;
12490
- });
12491
- }
12492
- function groupIssues(issues) {
12493
- const groups = /* @__PURE__ */ new Map();
12494
- for (const issue of issues) {
12495
- const existing = groups.get(issue.ruleId) ?? {
12496
- ruleId: issue.ruleId,
12497
- title: issue.title,
12498
- category: issue.category,
12499
- severity: issue.severity,
12500
- count: 0,
12501
- sampleUrls: []
12502
- };
12503
- existing.count += 1;
12504
- if (existing.sampleUrls.length < 10) existing.sampleUrls.push(issue.url);
12505
- groups.set(issue.ruleId, existing);
12452
+ has(url) {
12453
+ return this.#positions.has(url);
12506
12454
  }
12507
- return [...groups.values()];
12508
- }
12509
- function searchValueForGroup(report, urls) {
12510
- const wanted = new Set(urls);
12511
- const pages = report.pages.filter((page) => wanted.has(page.url) || wanted.has(page.finalUrl));
12512
- const metrics = pages.map((page) => page.searchMetrics).filter((item) => Boolean(item));
12513
- const clicks = metrics.reduce((sum, item) => sum + item.clicks, 0);
12514
- const impressions = metrics.reduce((sum, item) => sum + item.impressions, 0);
12515
- const sessions = pages.reduce((sum, page) => sum + (page.analytics?.sessions ?? 0), 0);
12516
- const observedMetrics = new Set(report.dataSources?.analytics.observedMetrics ?? [
12517
- "sessions",
12518
- "totalUsers",
12519
- "conversions"
12520
- ]);
12521
- const totalUsers = observedMetrics.has("totalUsers") ? pages.reduce((sum, page) => sum + (page.analytics?.totalUsers ?? 0), 0) : void 0;
12522
- const conversions = observedMetrics.has("conversions") ? pages.reduce((sum, page) => sum + (page.analytics?.conversions ?? 0), 0) : void 0;
12523
- const avgPosition = metrics.length ? metrics.reduce((sum, item) => sum + item.position, 0) / metrics.length : void 0;
12524
- return {
12525
- searchVisibleUrls: metrics.length,
12526
- clicks,
12527
- impressions,
12528
- sessions,
12529
- totalUsers,
12530
- conversions,
12531
- avgPosition
12532
- };
12533
- }
12534
- function whyThisRanks(input, recommendation, searchStatus, analyticsProvider) {
12535
- 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.";
12536
- const analyticsDetails = analyticsProvider ? analyticsProviderDetails(analyticsProvider) : analyticsProviderDetails("google");
12537
- const analyticsLabel = analyticsDetails.label;
12538
- const sessionLabel = analyticsDetails.landingMetricLabel;
12539
- 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}.`}`;
12540
- }
12541
- function verificationCommand(report, severity) {
12542
- return `seo crawl ${report.config.url} --severity ${severity} --max-pages ${Math.min(report.config.maxPages, 100)}`;
12543
- }
12544
- function topFixes(report, filters = {}) {
12545
- return groupIssues(filteredIssues(report, filters)).map((group) => {
12546
- const search = searchValueForGroup(report, group.sampleUrls);
12547
- const effort = EFFORT_BY_CATEGORY[group.category] ?? "medium";
12548
- const scoreFactors = {
12549
- severity: SEVERITY_SCORE[group.severity],
12550
- affectedUrls: group.count,
12551
- searchVisibleUrls: search.searchVisibleUrls,
12552
- clicks: search.clicks,
12553
- impressions: search.impressions,
12554
- sessions: search.sessions,
12555
- totalUsers: search.totalUsers,
12556
- conversions: search.conversions,
12557
- avgPosition: search.avgPosition,
12558
- effort,
12559
- effortScore: EFFORT_SCORE[effort]
12560
- };
12561
- const valueWeight = VALUE_WEIGHT[group.severity];
12562
- 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;
12563
- const rule = explainRule(group.ruleId);
12564
- const recommendation = recommendationForRule(group.ruleId);
12565
- return {
12566
- ...group,
12567
- recommendation,
12568
- score: Math.round(score),
12569
- scoreFactors,
12570
- whyThisRanks: whyThisRanks(scoreFactors, recommendation, report.dataSources?.searchConsole.status, report.dataSources?.analytics.provider),
12571
- howToFix: rule?.howToFix ?? "",
12572
- howToVerify: rule?.howToVerify ?? "",
12573
- verification: {
12574
- command: verificationCommand(report, group.severity),
12575
- expected: rule?.howToVerify ?? ""
12576
- }
12577
- };
12578
- }).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);
12579
- }
12580
- function reviewObservations(report, filters = {}) {
12581
- return topFixes(report, {
12582
- ...filters,
12583
- recommendation: "review"
12584
- });
12585
- }
12586
- // packages/core/src/analyze/crawler/export.ts
12587
- const issueHeaders = [
12588
- "rule_id",
12589
- "title",
12590
- "category",
12591
- "severity",
12592
- "url",
12593
- "detail",
12594
- "clicks",
12595
- "impressions",
12596
- "position"
12597
- ];
12598
- const pageHeaders = [
12599
- "url",
12600
- "final_url",
12601
- "status",
12602
- "indexable",
12603
- "title",
12604
- "meta_description",
12605
- "canonical",
12606
- "word_count",
12607
- "content_extractor",
12608
- "content_extractor_type",
12609
- "content_extraction_fallback",
12610
- "word_count_source",
12611
- "internal_links",
12612
- "internal_inlinks",
12613
- "internal_authority_score",
12614
- "external_links",
12615
- "images_total",
12616
- "images_missing_alt",
12617
- "oversized_image_candidates",
12618
- "schema_types",
12619
- "clicks",
12620
- "impressions",
12621
- "sessions"
12622
- ];
12623
- function renderCrawlCsv(report) {
12624
- return renderCsv(report.issues.map((issue) => ({
12625
- rule_id: issue.ruleId,
12626
- title: issue.title,
12627
- category: issue.category,
12628
- severity: issue.severity,
12629
- url: issue.url,
12630
- detail: issue.detail,
12631
- clicks: issue.searchMetrics?.clicks,
12632
- impressions: issue.searchMetrics?.impressions,
12633
- position: issue.searchMetrics?.position
12634
- })), issueHeaders);
12635
- }
12636
- function renderCrawlPagesCsv(report) {
12637
- return renderCsv(report.pages.map((page) => ({
12638
- url: page.url,
12639
- final_url: page.finalUrl,
12640
- status: page.status,
12641
- indexable: page.indexable,
12642
- title: page.title,
12643
- meta_description: page.metaDescription,
12644
- canonical: page.canonical,
12645
- word_count: page.wordCount,
12646
- content_extractor: page.contentExtraction?.used,
12647
- content_extractor_type: page.contentExtraction?.extractorType,
12648
- content_extraction_fallback: page.contentExtraction?.fallback,
12649
- word_count_source: page.contentExtraction?.wordCountSource,
12650
- internal_links: page.outgoingInternalCount,
12651
- internal_inlinks: page.internalInlinkCount,
12652
- internal_authority_score: page.internalLinkAuthorityScore,
12653
- external_links: page.outgoingExternalCount,
12654
- images_total: page.imagesTotal,
12655
- images_missing_alt: page.imagesMissingAlt,
12656
- oversized_image_candidates: page.oversizedImageCandidates?.length ?? 0,
12657
- schema_types: page.schemaTypes?.join("|"),
12658
- clicks: page.searchMetrics?.clicks,
12659
- impressions: page.searchMetrics?.impressions,
12660
- sessions: page.analytics?.sessions
12661
- })), pageHeaders);
12662
- }
12663
- function renderCrawlPretty(report, fixes = topFixes(report)) {
12664
- const reviews = reviewObservations(report);
12665
- const requestSummary = report.requestEvidenceStatus === "available" ? `${report.requests.length} requests` : report.requestEvidenceStatus === "partial" ? `${report.requests.length} observed requests; some started requests were still in flight when the crawl stopped` : "request evidence unavailable for this legacy report";
12666
- const lines = [
12667
- `Crawl report for ${report.config.url}`,
12668
- "",
12669
- `Status: ${report.status}`,
12670
- `Strategy: ${report.config.strategy === "health" ? "sitemap health pass" : "full audit"}`,
12671
- `Crawler: ${report.access.crawler.userAgent}`,
12672
- `Documents: ${report.summary.totalPages} retained; ${requestSummary}, ${report.summary.discoveredUrls} URLs discovered, ${report.summary.failedUrls} failed, ${report.summary.skippedUrls} skipped`,
12673
- `Issues: ${report.issues.length} (${report.summary.highIssues} high, ${report.summary.mediumIssues} medium, ${report.summary.lowIssues} low)`
12674
- ];
12675
- if (report.externalLinkVerification) {
12676
- const external = report.externalLinkVerification;
12677
- lines.push(`External links: ${external.fetchedUrls} fetched, ${external.failedUrls} request failure${external.failedUrls === 1 ? "" : "s"}, ${external.deferredUrls} deferred (${external.dataStatus})`);
12455
+ push(item) {
12456
+ if (this.has(item.url)) throw new Error(`URL is already queued: ${item.url}`);
12457
+ const index = this.#items.length;
12458
+ this.#items.push(item);
12459
+ this.#positions.set(item.url, index);
12460
+ this.#bubbleUp(index);
12678
12461
  }
12679
- if (report.access.blockedRequests > 0) {
12680
- lines.push("", `Access blocks: ${report.access.blockedRequests}`);
12681
- for (const sample of report.access.samples.slice(0, 3)) {
12682
- const requestId = sample.evidence.requestId ? `, request ID ${sample.evidence.requestId}` : "";
12683
- lines.push(`- ${sample.url}: ${sample.evidence.guidance.summary} (${sample.evidence.provider}${requestId})`, ` Action: ${sample.evidence.guidance.recommendedAction}`, ` Security: ${sample.evidence.guidance.securityNote}`);
12684
- if (sample.evidence.guidance.documentationUrl) lines.push(` Provider docs: ${sample.evidence.guidance.documentationUrl}`);
12685
- }
12462
+ decreaseDepth(url, depth) {
12463
+ const index = this.#positions.get(url);
12464
+ if (index === void 0) return false;
12465
+ const item = this.#items[index];
12466
+ if (!item || depth >= item.depth) return false;
12467
+ item.depth = depth;
12468
+ this.#bubbleUp(index);
12469
+ return true;
12686
12470
  }
12687
- if (fixes.length) {
12688
- lines.push("", "Prioritised fixes");
12689
- for (const fix of fixes.slice(0, 5)) {
12690
- lines.push(`- ${fix.title} (${fix.severity}, ${fix.count} URL${fix.count === 1 ? "" : "s"}): ${fix.howToFix}`);
12691
- if (fix.sampleUrls[0]) lines.push(` First URL: ${fix.sampleUrls[0]}`);
12471
+ take() {
12472
+ const first = this.#items[0];
12473
+ if (!first) return void 0;
12474
+ const last = this.#items.pop();
12475
+ this.#positions.delete(first.url);
12476
+ if (last && last !== first) {
12477
+ this.#items[0] = last;
12478
+ this.#positions.set(last.url, 0);
12479
+ this.#bubbleDown(0);
12692
12480
  }
12693
- } else lines.push("", "No prioritised fixes found.");
12694
- if (reviews.length) {
12695
- lines.push("", "Review observations (check before scheduling work)");
12696
- for (const observation of reviews.slice(0, 5)) {
12697
- lines.push(`- ${observation.title} (${observation.severity}, ${observation.count} URL${observation.count === 1 ? "" : "s"}): ${observation.howToFix}`);
12698
- if (observation.sampleUrls[0]) lines.push(` First URL: ${observation.sampleUrls[0]}`);
12481
+ return first;
12482
+ }
12483
+ #swap(left, right) {
12484
+ const leftItem = this.#items[left];
12485
+ const rightItem = this.#items[right];
12486
+ if (!leftItem || !rightItem) return;
12487
+ this.#items[left] = rightItem;
12488
+ this.#items[right] = leftItem;
12489
+ this.#positions.set(rightItem.url, left);
12490
+ this.#positions.set(leftItem.url, right);
12491
+ }
12492
+ #bubbleUp(start) {
12493
+ let index = start;
12494
+ while (index > 0) {
12495
+ const parent = Math.floor((index - 1) / 2);
12496
+ const item = this.#items[index];
12497
+ const parentItem = this.#items[parent];
12498
+ if (!item || !parentItem || !before(item, parentItem)) break;
12499
+ this.#swap(index, parent);
12500
+ index = parent;
12699
12501
  }
12700
12502
  }
12701
- lines.push("", "Next commands", report.config.strategy === "health" ? `- seo crawl ${report.config.url} --max-pages ${report.config.maxPages} --json` : `- seo crawl ${report.config.url} --json`);
12702
- for (const command of [...new Set(fixes.slice(0, 3).map((fix) => fix.verification.command))]) lines.push(`- ${command}`);
12703
- if (report.warnings.length) lines.push("", "Warnings", ...report.warnings.map((item) => `- ${item}`));
12704
- if (report.caveats.length) lines.push("", "Caveats", ...report.caveats.map((item) => `- ${item}`));
12705
- return `${lines.join("\n")}\n`;
12706
- }
12707
- function renderCrawlJunit(report) {
12708
- const issuesByUrl = /* @__PURE__ */ new Map();
12709
- for (const issue of report.issues) {
12710
- const existing = issuesByUrl.get(issue.url) ?? [];
12711
- existing.push(issue);
12712
- issuesByUrl.set(issue.url, existing);
12503
+ #bubbleDown(start) {
12504
+ let index = start;
12505
+ while (true) {
12506
+ const left = index * 2 + 1;
12507
+ const right = left + 1;
12508
+ let next = index;
12509
+ const item = this.#items[next];
12510
+ const leftItem = this.#items[left];
12511
+ const rightItem = this.#items[right];
12512
+ if (leftItem && item && before(leftItem, item)) next = left;
12513
+ const nextItem = this.#items[next];
12514
+ if (rightItem && nextItem && before(rightItem, nextItem)) next = right;
12515
+ if (next === index) return;
12516
+ this.#swap(index, next);
12517
+ index = next;
12518
+ }
12713
12519
  }
12714
- const sitemapCases = (report.sitemapDiscovery?.roots ?? []).flatMap((root) => {
12715
- return (root.documents.length ? root.documents : [{
12716
- url: root.url,
12717
- dataStatus: root.dataStatus,
12718
- compression: "none",
12719
- warning: root.warnings.join(" ") || void 0
12720
- }]).map((document) => {
12721
- const failures = [
12722
- ...document.dataStatus !== "complete" ? [document.warning ?? `Sitemap evidence is ${document.dataStatus}.`] : [],
12723
- ...document.redirected ? [`Sitemap redirected to ${document.finalUrl ?? "an unknown URL"}.`] : [],
12724
- ...document.status !== void 0 && (document.status < 200 || document.status >= 300) ? [`Sitemap returned HTTP ${document.status}.`] : []
12725
- ];
12726
- const output = [
12727
- `dataStatus=${document.dataStatus}`,
12728
- ...document.status !== void 0 ? [`status=${document.status}`] : [],
12729
- ...document.finalUrl ? [`finalUrl=${document.finalUrl}`] : [],
12730
- ...document.root ? [`root=${document.root}`] : [],
12731
- ...document.warning ? [`warning=${document.warning}`] : []
12732
- ].join("\n");
12733
- return {
12734
- xml: [
12735
- ` <testcase classname="seo.sitemap-document" name="${escapeXml(document.url)}" time="0.000">`,
12736
- ...failures.length ? [` <failure message="${escapeXml(failures[0] ?? "Sitemap check failed")}">${escapeXml(failures.join("\n"))}</failure>`] : [],
12737
- ` <system-out>${escapeXml(output)}</system-out>`,
12738
- " </testcase>"
12739
- ].join("\n"),
12740
- failed: failures.length > 0
12741
- };
12742
- });
12743
- });
12744
- const requestCases = report.requests.map((request) => {
12745
- const failures = [
12746
- ...(issuesByUrl.get(request.requestedUrl) ?? []).map((issue) => `${issue.severity}: ${issue.title}${issue.detail ? `: ${issue.detail}` : ""}`),
12747
- ...request.outcome === "failure" ? [`${request.failureKind}: ${request.error}`] : [],
12748
- ...request.outcome === "skipped" ? [request.reason === "origin-backpressure" ? request.error : `${request.reason}: ${request.robotsTxt.matchedLine ?? request.robotsTxt.availability}`] : []
12749
- ];
12750
- const output = request.outcome === "response" ? [
12751
- `status=${request.status}`,
12752
- `finalUrl=${request.finalUrl}`,
12753
- ...request.robotsTxt ? [
12754
- `robotsUrl=${request.robotsTxt.url}`,
12755
- `robotsAllowed=${request.robotsTxt.allowed ?? "unknown"}`,
12756
- `robotsAvailability=${request.robotsTxt.availability}`,
12757
- ...request.robotsTxt.status !== void 0 ? [`robotsStatus=${request.robotsTxt.status}`] : [],
12758
- ...request.robotsTxt.matchedLine ? [`robotsMatchedLine=${request.robotsTxt.matchedLine}`] : [],
12759
- ...request.robotsTxt.error ? [`robotsError=${request.robotsTxt.error}`] : []
12760
- ] : [],
12761
- ...request.accessBlock ? [
12762
- `accessProvider=${request.accessBlock.provider}`,
12763
- `crawler=${request.accessBlock.crawler.userAgent}`,
12764
- ...request.accessBlock.requestId ? [`requestId=${request.accessBlock.requestId}`] : []
12765
- ] : []
12766
- ].join("\n") : request.outcome === "failure" ? request.error : request.reason === "origin-backpressure" ? `reason=${request.reason}\nerror=${request.error}` : [
12767
- `reason=${request.reason}`,
12768
- `robotsUrl=${request.robotsTxt.url}`,
12769
- `robotsAllowed=${request.robotsTxt.allowed ?? "unknown"}`,
12770
- `robotsAvailability=${request.robotsTxt.availability}`,
12771
- ...request.robotsTxt.status !== void 0 ? [`robotsStatus=${request.robotsTxt.status}`] : [],
12772
- ...request.robotsTxt.matchedLine ? [`robotsMatchedLine=${request.robotsTxt.matchedLine}`] : [],
12773
- ...request.robotsTxt.error ? [`robotsError=${request.robotsTxt.error}`] : []
12774
- ].join("\n");
12775
- const duration = ((request.durationMs ?? 0) / 1e3).toFixed(3);
12776
- return {
12777
- xml: [
12778
- ` <testcase classname="seo.sitemap-health" name="${escapeXml(request.requestedUrl)}" time="${duration}">`,
12779
- ...failures.length ? [` <failure message="${escapeXml(failures[0] ?? "Health check failed")}">${escapeXml(failures.join("\n"))}</failure>`] : [],
12780
- ` <system-out>${escapeXml(output)}</system-out>`,
12781
- " </testcase>"
12782
- ].join("\n"),
12783
- failed: failures.length > 0
12784
- };
12785
- });
12786
- const cases = [...sitemapCases, ...requestCases];
12787
- const failures = cases.filter((item) => item.failed).length;
12788
- const elapsed = report.requests.reduce((sum, request) => sum + (request.durationMs ?? 0), 0);
12789
- return [
12790
- "<?xml version=\"1.0\" encoding=\"UTF-8\"?>",
12791
- `<testsuite name="seo sitemap health" tests="${cases.length}" failures="${failures}" errors="0" skipped="0" time="${(elapsed / 1e3).toFixed(3)}">`,
12792
- " <properties>",
12793
- ` <property name="report.id" value="${escapeXml(report.id)}"/>`,
12794
- ` <property name="report.status" value="${escapeXml(report.status)}"/>`,
12795
- ` <property name="crawl.strategy" value="${escapeXml(report.config.strategy)}"/>`,
12796
- ` <property name="crawler.userAgent" value="${escapeXml(report.access.crawler.userAgent)}"/>`,
12797
- " </properties>",
12798
- ...cases.map((item) => item.xml),
12799
- "</testsuite>",
12800
- ""
12801
- ].join("\n");
12802
- }
12803
- function renderCrawlHtml(report, fixes = topFixes(report)) {
12804
- const reviews = reviewObservations(report);
12805
- const issueRows = report.issueGroups.map((group) => {
12806
- const rule = explainRule(group.ruleId);
12807
- return `<tr><td>${escapeHtml(group.severity)}</td><td>${escapeHtml(group.title)}</td><td>${group.count}</td><td>${escapeHtml(rule?.howToFix ?? "")}</td><td>${escapeHtml(group.sampleUrls[0] ?? "")}</td></tr>`;
12808
- }).join("\n");
12809
- const fixItems = fixes.slice(0, 10).map((fix) => `<li><strong>${escapeHtml(fix.title)}</strong><br>${escapeHtml(fix.howToFix)}<br><span>Verify: ${escapeHtml(fix.howToVerify)}</span><br><code>${escapeHtml(fix.verification.command)}</code></li>`).join("\n");
12810
- const reviewItems = reviews.slice(0, 10).map((observation) => `<li><strong>${escapeHtml(observation.title)}</strong><br>${escapeHtml(observation.howToFix)}<br><span>Check before scheduling work. ${escapeHtml(observation.howToVerify)}</span></li>`).join("\n");
12811
- const caveatItems = report.caveats.map((caveat) => `<li>${escapeHtml(caveat)}</li>`).join("\n");
12812
- const warningItems = report.warnings.map((warning) => `<li>${escapeHtml(warning)}</li>`).join("\n");
12813
- const accessItems = report.access.samples.map((sample) => `<li><strong>${escapeHtml(sample.url)}</strong><br>${escapeHtml(sample.evidence.guidance.summary)}<br>${escapeHtml(sample.evidence.guidance.recommendedAction)}<br><span>${escapeHtml(sample.evidence.guidance.securityNote)}</span></li>`).join("\n");
12814
- return `<!doctype html>
12815
- <html lang="en">
12816
- <head>
12817
- <meta charset="utf-8">
12818
- <meta name="viewport" content="width=device-width, initial-scale=1">
12819
- <title>Crawl report for ${escapeHtml(report.config.url)}</title>
12820
- <style>
12821
- body { color: #17202a; font: 15px/1.5 system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; margin: 32px; max-width: 1120px; }
12822
- h1, h2 { line-height: 1.2; }
12823
- .summary { display: grid; gap: 12px; grid-template-columns: repeat(auto-fit, minmax(160px, 1fr)); margin: 24px 0; }
12824
- .metric { border: 1px solid #d8dee4; border-radius: 8px; padding: 12px; }
12825
- .metric strong { display: block; font-size: 24px; }
12826
- table { border-collapse: collapse; width: 100%; }
12827
- th, td { border-bottom: 1px solid #d8dee4; padding: 10px; text-align: left; vertical-align: top; }
12828
- th { background: #f6f8fa; }
12829
- li { margin: 0 0 12px; }
12830
- span { color: #57606a; }
12831
- </style>
12832
- </head>
12833
- <body>
12834
- <h1>Crawl report</h1>
12835
- <p>${escapeHtml(report.config.url)}</p>
12836
- <p>Strategy: ${escapeHtml(report.config.strategy === "health" ? "sitemap health pass" : "full audit")}<br>Crawler: <code>${escapeHtml(report.access.crawler.userAgent)}</code></p>
12837
- <section class="summary">
12838
- <div class="metric"><span>Status</span><strong>${escapeHtml(report.status)}</strong></div>
12839
- <div class="metric"><span>Documents</span><strong>${report.summary.totalPages}</strong></div>
12840
- <div class="metric"><span>Requests</span><strong>${report.requestEvidenceStatus === "unavailable" ? "n/a" : report.requests.length}${report.requestEvidenceStatus === "partial" ? " (partial)" : ""}</strong></div>
12841
- <div class="metric"><span>Discovered</span><strong>${report.summary.discoveredUrls}</strong></div>
12842
- <div class="metric"><span>Queued</span><strong>${report.summary.queuedUrls}</strong></div>
12843
- <div class="metric"><span>Skipped</span><strong>${report.summary.skippedUrls}</strong></div>
12844
- <div class="metric"><span>Failed fetches</span><strong>${report.summary.failedUrls}</strong></div>
12845
- <div class="metric"><span>Observed internal links</span><strong>${report.summary.observedInternalLinks}</strong></div>
12846
- <div class="metric"><span>Issues</span><strong>${report.issues.length}</strong></div>
12847
- <div class="metric"><span>High</span><strong>${report.summary.highIssues}</strong></div>
12848
- </section>
12849
- ${accessItems ? `<h2>Access blocks</h2><ul>${accessItems}</ul>` : ""}
12850
- <h2>Prioritised fixes</h2>
12851
- ${fixItems ? `<ol>${fixItems}</ol>` : "<p>No prioritised fixes found.</p>"}
12852
- ${reviewItems ? `<h2>Review observations</h2>
12853
- <p>Check these before scheduling implementation work.</p>
12854
- <ul>${reviewItems}</ul>` : ""}
12855
- ${report.caveats.length ? `<h2>Caveats</h2>
12856
- <ul>${caveatItems}</ul>` : ""}
12857
- ${report.warnings.length ? `<h2>Warnings</h2>
12858
- <ul>${warningItems}</ul>` : ""}
12859
- <h2>Issue groups</h2>
12860
- <table>
12861
- <thead><tr><th>Severity</th><th>Issue</th><th>URLs</th><th>Fix</th><th>Sample URL</th></tr></thead>
12862
- <tbody>${issueRows}</tbody>
12863
- </table>
12864
- </body>
12865
- </html>
12866
- `;
12867
- }
12868
- function renderCrawlMarkdownTickets(report, fixes = topFixes(report)) {
12869
- const reviews = reviewObservations(report);
12870
- const lines = [
12871
- `# Crawl Implementation Tickets`,
12872
- "",
12873
- `Source: ${report.config.url}`,
12874
- `Report: ${report.id}`,
12875
- `Status: ${report.status}`,
12876
- `Strategy: ${report.config.strategy}`,
12877
- `Crawler: ${report.access.crawler.userAgent}`,
12878
- ""
12879
- ];
12880
- if (report.caveats.length) lines.push("## Caveats", "", ...report.caveats.map((item) => `- ${item}`), "");
12881
- if (report.warnings.length) lines.push("## Warnings", "", ...report.warnings.map((item) => `- ${item}`), "");
12882
- if (report.access.blockedRequests > 0) lines.push("## Access blocks", "", ...report.access.samples.flatMap((sample) => [
12883
- `- ${sample.url}: ${sample.evidence.guidance.summary}`,
12884
- ` - Action: ${sample.evidence.guidance.recommendedAction}`,
12885
- ` - Security: ${sample.evidence.guidance.securityNote}`
12886
- ]), "");
12887
- for (const [index, fix] of fixes.entries()) lines.push(`## ${index + 1}. ${fix.title}`, "", `- [ ] Fix ${fix.count} affected URL${fix.count === 1 ? "" : "s"}`, `- Severity: ${fix.severity}`, `- Rule: ${fix.ruleId}`, `- Why this ranks: ${fix.whyThisRanks}`, `- Plain-English fix: ${fix.howToFix}`, `- Verify: ${fix.howToVerify}`, `- Command: ${fix.verification.command}`, "", `Affected URLs:`, ...fix.sampleUrls.slice(0, 10).map((url) => `- ${url}`), "");
12888
- if (!fixes.length) lines.push("No implementation tickets were generated.");
12889
- if (reviews.length) lines.push("## Review observations", "", "These are not implementation tickets. Check the evidence before deciding whether to work on them.", "", ...reviews.slice(0, 10).flatMap((observation) => [`- ${observation.title} (${observation.severity}, ${observation.count} URL${observation.count === 1 ? "" : "s"}): ${observation.howToFix}`, ` - First URL: ${observation.sampleUrls[0] ?? "No sample URL recorded"}`]), "");
12890
- return `${lines.join("\n")}\n`;
12891
- }
12892
- function escapeHtml(value) {
12893
- return value.replaceAll("&", "&amp;").replaceAll("<", "&lt;").replaceAll(">", "&gt;").replaceAll("\"", "&quot;").replaceAll("'", "&#39;");
12894
- }
12895
- function escapeXml(value) {
12896
- return escapeHtml(value);
12897
- }
12520
+ };
12898
12521
  // packages/core/src/analyze/crawler/report.ts
12899
12522
  const MAX_CRAWL_PAGES = 1e4;
12900
12523
  const MAX_CRAWL_DEPTH = 64;
@@ -13065,6 +12688,10 @@ function sanitizeMessages(values = []) {
13065
12688
  function compareText$17(left, right) {
13066
12689
  return left < right ? -1 : left > right ? 1 : 0;
13067
12690
  }
12691
+ function compareCaveats(left, right) {
12692
+ const rank = (value) => value.startsWith("Coverage incomplete:") ? 0 : 1;
12693
+ return rank(left) - rank(right) || compareText$17(left, right);
12694
+ }
13068
12695
  function sortPages(pages) {
13069
12696
  return [...pages].sort((left, right) => compareText$17(left.url, right.url));
13070
12697
  }
@@ -13216,123 +12843,661 @@ function normalizeLinkUrl(value) {
13216
12843
  return;
13217
12844
  }
13218
12845
  }
13219
- function deriveInternalLinkAuthority(pages, linkGraph) {
13220
- const hasLinkGraph = linkGraph && Object.keys(linkGraph).length > 0;
13221
- const hasStoredScores = pages.some((page) => page.internalInlinkCount !== void 0 || page.internalLinkAuthorityScore !== void 0);
13222
- if (!hasLinkGraph && hasStoredScores) {
13223
- if (pages.every((page) => page.internalInlinkCount !== void 0 && page.internalLinkAuthorityScore !== void 0)) return pages;
13224
- return pages.map((page) => page.internalInlinkCount !== void 0 && page.internalLinkAuthorityScore !== void 0 ? page : {
13225
- ...page,
13226
- internalInlinkCount: page.internalInlinkCount ?? 0,
13227
- internalLinkAuthorityScore: page.internalLinkAuthorityScore ?? 0
13228
- });
12846
+ function deriveInternalLinkAuthority(pages, linkGraph) {
12847
+ const hasLinkGraph = linkGraph && Object.keys(linkGraph).length > 0;
12848
+ const hasStoredScores = pages.some((page) => page.internalInlinkCount !== void 0 || page.internalLinkAuthorityScore !== void 0);
12849
+ if (!hasLinkGraph && hasStoredScores) {
12850
+ if (pages.every((page) => page.internalInlinkCount !== void 0 && page.internalLinkAuthorityScore !== void 0)) return pages;
12851
+ return pages.map((page) => page.internalInlinkCount !== void 0 && page.internalLinkAuthorityScore !== void 0 ? page : {
12852
+ ...page,
12853
+ internalInlinkCount: page.internalInlinkCount ?? 0,
12854
+ internalLinkAuthorityScore: page.internalLinkAuthorityScore ?? 0
12855
+ });
12856
+ }
12857
+ const pageByUrl = /* @__PURE__ */ new Map();
12858
+ for (const page of pages) for (const value of [page.url, page.finalUrl]) {
12859
+ const normalized = normalizeLinkUrl(value);
12860
+ if (normalized) pageByUrl.set(normalized, page.url);
12861
+ }
12862
+ const inlinks = /* @__PURE__ */ new Map();
12863
+ for (const page of pages) inlinks.set(page.url, 0);
12864
+ for (const page of pages) {
12865
+ const targets = hasLinkGraph ? linkGraph?.[page.url] ?? linkGraph?.[page.finalUrl] ?? [] : page.sampleInternalLinks ?? [];
12866
+ const linkedPages = /* @__PURE__ */ new Set();
12867
+ for (const target of targets) {
12868
+ const normalized = normalizeLinkUrl(target);
12869
+ const linkedPage = normalized ? pageByUrl.get(normalized) : void 0;
12870
+ if (!linkedPage || linkedPage === page.url) continue;
12871
+ linkedPages.add(linkedPage);
12872
+ }
12873
+ for (const linkedPage of linkedPages) inlinks.set(linkedPage, (inlinks.get(linkedPage) ?? 0) + 1);
12874
+ }
12875
+ let maxInlinks = 0;
12876
+ for (const count of inlinks.values()) maxInlinks = Math.max(maxInlinks, count);
12877
+ return pages.map((page) => {
12878
+ const internalInlinkCount = inlinks.get(page.url) ?? 0;
12879
+ return {
12880
+ ...page,
12881
+ internalInlinkCount,
12882
+ internalLinkAuthorityScore: maxInlinks ? Math.round(internalInlinkCount / maxInlinks * 100) : 0
12883
+ };
12884
+ });
12885
+ }
12886
+ function createCrawlReport(input) {
12887
+ const config = sanitizeCrawlConfig(normalizeCrawlConfig(input.config));
12888
+ const definitionId = crawlDefinitionId({
12889
+ config,
12890
+ site: input.site,
12891
+ googleAnalyticsPropertyId: input.googleAnalyticsPropertyId,
12892
+ analyticsConnection: input.analyticsConnection
12893
+ });
12894
+ const safePagesWithLinks = sortPages(sanitizePages(deriveInternalLinkAuthority(input.pages ?? [], input.linkGraph)));
12895
+ const requests = sortRequests(sanitizeTenantValue(input.requests ?? []));
12896
+ const requestEvidenceStatus = input.requestEvidenceStatus ?? (input.requests ? "available" : "unavailable");
12897
+ if (requestEvidenceStatus === "unavailable" && requests.length > 0) throw new Error("Unavailable request evidence cannot include request observations.");
12898
+ const safeIssues = sanitizeIssues([...input.issues ?? [...auditCrawlRequests(requests), ...auditCrawlPages(safePagesWithLinks, { startUrl: config.url })], ...input.additionalIssues ?? []]);
12899
+ const pages = safePagesWithLinks;
12900
+ return {
12901
+ id: input.id ?? crawlRunId(),
12902
+ definitionId,
12903
+ site: input.site,
12904
+ googleAnalyticsPropertyId: input.googleAnalyticsPropertyId,
12905
+ analyticsConnection: input.analyticsConnection,
12906
+ generatedAt: input.generatedAt ?? (/* @__PURE__ */ new Date()).toISOString(),
12907
+ status: input.status ?? "completed",
12908
+ configHash: crawlConfigHash(config),
12909
+ config,
12910
+ access: crawlAccessSummary(requests),
12911
+ summary: summarizeCrawlReport({
12912
+ pages,
12913
+ requests,
12914
+ requestEvidenceStatus,
12915
+ issues: safeIssues,
12916
+ stats: input.stats
12917
+ }),
12918
+ requestEvidenceStatus,
12919
+ requests,
12920
+ pages,
12921
+ issues: safeIssues,
12922
+ issueGroups: groupCrawlIssues(safeIssues),
12923
+ ...input.dataSources ? { dataSources: sanitizeTenantValue(input.dataSources) } : {},
12924
+ ...input.ai ? { ai: sanitizeTenantValue(input.ai) } : {},
12925
+ ...input.sitemapDiscovery ? { sitemapDiscovery: sanitizeTenantValue(input.sitemapDiscovery) } : {},
12926
+ ...input.externalLinkVerification ? { externalLinkVerification: sanitizeTenantValue(input.externalLinkVerification) } : {},
12927
+ ...input.siteChecks ? { siteChecks: sanitizeTenantValue(input.siteChecks) } : {},
12928
+ warnings: sanitizeMessages(input.warnings).sort(compareText$17),
12929
+ caveats: sanitizeMessages(input.caveats).sort(compareCaveats)
12930
+ };
12931
+ }
12932
+ function normalizeLoadedCrawlReport(report) {
12933
+ const config = sanitizeCrawlConfig(normalizeCrawlConfig(report.config));
12934
+ const requestEvidenceStatus = report.requestEvidenceStatus ?? (Object.hasOwn(report, "requests") ? "available" : "unavailable");
12935
+ const issues = sanitizeIssues(report.issues ?? []);
12936
+ const requests = sortRequests(sanitizeTenantValue(report.requests ?? []) ?? []);
12937
+ const pages = sortPages(sanitizePages(deriveInternalLinkAuthority(report.pages ?? [])));
12938
+ return {
12939
+ ...sanitizeTenantValue(report),
12940
+ config,
12941
+ access: crawlAccessSummary(requests),
12942
+ requestEvidenceStatus,
12943
+ requests,
12944
+ definitionId: report.definitionId ?? crawlDefinitionId({
12945
+ config,
12946
+ site: report.site,
12947
+ googleAnalyticsPropertyId: report.googleAnalyticsPropertyId,
12948
+ analyticsConnection: report.analyticsConnection
12949
+ }),
12950
+ summary: summarizeCrawlReport({
12951
+ pages,
12952
+ requests,
12953
+ requestEvidenceStatus,
12954
+ issues,
12955
+ stats: report.summary
12956
+ }),
12957
+ pages,
12958
+ issues,
12959
+ issueGroups: groupCrawlIssues(issues),
12960
+ warnings: sanitizeMessages(report.warnings).sort(compareText$17),
12961
+ caveats: sanitizeMessages(report.caveats).sort(compareCaveats)
12962
+ };
12963
+ }
12964
+ // packages/core/src/analyze/crawler/crawl-caveats.ts
12965
+ /** Reports rank a caveat with this prefix first and lead with it. */
12966
+ const CRAWL_COVERAGE_CAVEAT_PREFIX = "Coverage incomplete:";
12967
+ function rateLimitRerunCommand(config, intervalMs) {
12968
+ const scope = config.strategy === "health" ? " --health" : config.mode === "site" ? "" : ` --mode ${config.mode}`;
12969
+ const sitemap = config.sitemapUrl ? ` --sitemap-url ${config.sitemapUrl}` : "";
12970
+ const slowMs = Math.min(8e3, Math.max(2e3, intervalMs * 2));
12971
+ return `seo crawl ${config.url}${scope}${sitemap} --max-pages ${config.maxPages} --concurrency 1 --fetch-interval-cap 1 --fetch-interval-ms ${slowMs}`;
12972
+ }
12973
+ /** Set when the sitemaps list more URLs than the page limit let the crawl read. */
12974
+ function sitemapLimitCaveatInput(config, discovery, defaulted) {
12975
+ if (!discovery || discovery.urlsReturned < config.maxPages || !discovery.roots.some((root) => root.possiblyTruncated)) return;
12976
+ return {
12977
+ maxPages: config.maxPages,
12978
+ defaulted,
12979
+ ceiling: MAX_CRAWL_PAGES,
12980
+ health: config.strategy === "health",
12981
+ url: config.url
12982
+ };
12983
+ }
12984
+ function crawlCaveats(input) {
12985
+ const caveats = input.cancelled ? ["Crawl cancelled before all queued URLs finished."] : input.pageLimitReached ? [`Stopped after reaching maxPages (${input.maxPages}).`] : [];
12986
+ 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.`);
12987
+ 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.`);
12988
+ if (input.rateLimit) {
12989
+ const { readPages, skippedUrls, rerunCommand } = input.rateLimit;
12990
+ 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}`);
12991
+ }
12992
+ if (input.sitemapLimit) {
12993
+ const { maxPages, defaulted, ceiling, health, url } = input.sitemapLimit;
12994
+ 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}`);
12995
+ }
12996
+ 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.`);
12997
+ const renderingFallbackUrls = input.renderingFallbackUrls ?? 0;
12998
+ 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.`);
12999
+ const queryVariantSkippedUrls = input.queryVariantSkippedUrls ?? 0;
13000
+ 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.`);
13001
+ for (const issue of input.reachIssues ?? []) if (issue.detail) caveats.push(issue.detail);
13002
+ return caveats;
13003
+ }
13004
+ /**
13005
+ * A site crawl that found no internal links on the start page and no sitemap
13006
+ * URLs checked one page, so its other findings cannot stand for the site.
13007
+ */
13008
+ function crawlReachIssues(input) {
13009
+ const [page] = input.pages;
13010
+ if (input.crawledPages !== 1 || input.pages.length !== 1 || !page || page.outgoingInternalCount > 0 || input.sitemapUrlsReturned > 0) return [];
13011
+ const rule = explainRule("crawl_single_page_reach");
13012
+ if (!rule) throw new Error("Missing rule guidance for crawl_single_page_reach");
13013
+ return [{
13014
+ ruleId: "crawl_single_page_reach",
13015
+ title: rule.title,
13016
+ category: rule.category,
13017
+ severity: rule.defaultSeverity,
13018
+ url: page.url,
13019
+ 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.",
13020
+ evidence: {
13021
+ startUrl: input.startUrl,
13022
+ crawledPages: 1,
13023
+ outgoingInternalCount: 0,
13024
+ sitemapUrlsReturned: 0,
13025
+ rendered: Boolean(page.usedJs)
13026
+ }
13027
+ }];
13028
+ }
13029
+ // packages/core/src/analyze/crawler/top-fixes.ts
13030
+ const SEVERITY_SCORE = {
13031
+ high: 5e3,
13032
+ medium: 1200,
13033
+ low: 100
13034
+ };
13035
+ const EFFORT_BY_CATEGORY = {
13036
+ canonical: "medium",
13037
+ content: "high",
13038
+ agents: "low",
13039
+ headings: "low",
13040
+ images: "medium",
13041
+ indexability: "medium",
13042
+ international: "low",
13043
+ metadata: "low",
13044
+ mobile: "low",
13045
+ response: "medium",
13046
+ security: "medium",
13047
+ social: "low",
13048
+ "structured-data": "medium"
13049
+ };
13050
+ const EFFORT_SCORE = {
13051
+ low: 60,
13052
+ medium: 30,
13053
+ high: 0
13054
+ };
13055
+ const AFFECTED_URL_WEIGHT = {
13056
+ high: 25,
13057
+ medium: 15,
13058
+ low: 2
13059
+ };
13060
+ const VALUE_WEIGHT = {
13061
+ high: 1,
13062
+ medium: .75,
13063
+ low: .15
13064
+ };
13065
+ const CRAWL_REACH_RULES = /* @__PURE__ */ new Set(["crawl_single_page_reach"]);
13066
+ function reachRank(ruleId) {
13067
+ return CRAWL_REACH_RULES.has(ruleId) ? 0 : 1;
13068
+ }
13069
+ function matchesPattern(pattern, value) {
13070
+ if (!pattern.includes("*")) return value.includes(pattern);
13071
+ const parts = pattern.split("*");
13072
+ let position = 0;
13073
+ for (const part of parts) {
13074
+ if (!part) continue;
13075
+ const found = value.slice(position).indexOf(part);
13076
+ if (found === -1) return false;
13077
+ position += found + part.length;
13078
+ }
13079
+ return true;
13080
+ }
13081
+ function filteredIssues(report, filters) {
13082
+ return report.issues.filter((issue) => {
13083
+ if (filters.category && issue.category !== filters.category) return false;
13084
+ if (filters.severity && issue.severity !== filters.severity) return false;
13085
+ if (filters.url && !matchesPattern(filters.url, issue.url)) return false;
13086
+ if ((filters.recommendation ?? "fix") !== recommendationForRule(issue.ruleId)) return false;
13087
+ return true;
13088
+ });
13089
+ }
13090
+ function groupIssues(issues) {
13091
+ const groups = /* @__PURE__ */ new Map();
13092
+ for (const issue of issues) {
13093
+ const existing = groups.get(issue.ruleId) ?? {
13094
+ ruleId: issue.ruleId,
13095
+ title: issue.title,
13096
+ category: issue.category,
13097
+ severity: issue.severity,
13098
+ count: 0,
13099
+ sampleUrls: []
13100
+ };
13101
+ existing.count += 1;
13102
+ if (existing.sampleUrls.length < 10) existing.sampleUrls.push(issue.url);
13103
+ groups.set(issue.ruleId, existing);
13104
+ }
13105
+ return [...groups.values()];
13106
+ }
13107
+ function searchValueForGroup(report, urls) {
13108
+ const wanted = new Set(urls);
13109
+ const pages = report.pages.filter((page) => wanted.has(page.url) || wanted.has(page.finalUrl));
13110
+ const metrics = pages.map((page) => page.searchMetrics).filter((item) => Boolean(item));
13111
+ const clicks = metrics.reduce((sum, item) => sum + item.clicks, 0);
13112
+ const impressions = metrics.reduce((sum, item) => sum + item.impressions, 0);
13113
+ const sessions = pages.reduce((sum, page) => sum + (page.analytics?.sessions ?? 0), 0);
13114
+ const observedMetrics = new Set(report.dataSources?.analytics.observedMetrics ?? [
13115
+ "sessions",
13116
+ "totalUsers",
13117
+ "conversions"
13118
+ ]);
13119
+ const totalUsers = observedMetrics.has("totalUsers") ? pages.reduce((sum, page) => sum + (page.analytics?.totalUsers ?? 0), 0) : void 0;
13120
+ const conversions = observedMetrics.has("conversions") ? pages.reduce((sum, page) => sum + (page.analytics?.conversions ?? 0), 0) : void 0;
13121
+ const avgPosition = metrics.length ? metrics.reduce((sum, item) => sum + item.position, 0) / metrics.length : void 0;
13122
+ return {
13123
+ searchVisibleUrls: metrics.length,
13124
+ clicks,
13125
+ impressions,
13126
+ sessions,
13127
+ totalUsers,
13128
+ conversions,
13129
+ avgPosition
13130
+ };
13131
+ }
13132
+ function whyThisRanks(input, recommendation, searchStatus, analyticsProvider) {
13133
+ 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.";
13134
+ const analyticsDetails = analyticsProvider ? analyticsProviderDetails(analyticsProvider) : analyticsProviderDetails("google");
13135
+ const analyticsLabel = analyticsDetails.label;
13136
+ const sessionLabel = analyticsDetails.landingMetricLabel;
13137
+ 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}.`}`;
13138
+ }
13139
+ function verificationCommand(report, severity) {
13140
+ return `seo crawl ${report.config.url} --severity ${severity} --max-pages ${Math.min(report.config.maxPages, 100)}`;
13141
+ }
13142
+ function topFixes(report, filters = {}) {
13143
+ return groupIssues(filteredIssues(report, filters)).map((group) => {
13144
+ const search = searchValueForGroup(report, group.sampleUrls);
13145
+ const effort = EFFORT_BY_CATEGORY[group.category] ?? "medium";
13146
+ const scoreFactors = {
13147
+ severity: SEVERITY_SCORE[group.severity],
13148
+ affectedUrls: group.count,
13149
+ searchVisibleUrls: search.searchVisibleUrls,
13150
+ clicks: search.clicks,
13151
+ impressions: search.impressions,
13152
+ sessions: search.sessions,
13153
+ totalUsers: search.totalUsers,
13154
+ conversions: search.conversions,
13155
+ avgPosition: search.avgPosition,
13156
+ effort,
13157
+ effortScore: EFFORT_SCORE[effort]
13158
+ };
13159
+ const valueWeight = VALUE_WEIGHT[group.severity];
13160
+ 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;
13161
+ const rule = explainRule(group.ruleId);
13162
+ const recommendation = recommendationForRule(group.ruleId);
13163
+ return {
13164
+ ...group,
13165
+ recommendation,
13166
+ score: Math.round(score),
13167
+ scoreFactors,
13168
+ whyThisRanks: whyThisRanks(scoreFactors, recommendation, report.dataSources?.searchConsole.status, report.dataSources?.analytics.provider),
13169
+ howToFix: rule?.howToFix ?? "",
13170
+ howToVerify: rule?.howToVerify ?? "",
13171
+ verification: {
13172
+ command: verificationCommand(report, group.severity),
13173
+ expected: rule?.howToVerify ?? ""
13174
+ }
13175
+ };
13176
+ }).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);
13177
+ }
13178
+ function reviewObservations(report, filters = {}) {
13179
+ return topFixes(report, {
13180
+ ...filters,
13181
+ recommendation: "review"
13182
+ });
13183
+ }
13184
+ // packages/core/src/analyze/crawler/export.ts
13185
+ const issueHeaders = [
13186
+ "rule_id",
13187
+ "title",
13188
+ "category",
13189
+ "severity",
13190
+ "url",
13191
+ "detail",
13192
+ "clicks",
13193
+ "impressions",
13194
+ "position"
13195
+ ];
13196
+ const pageHeaders = [
13197
+ "url",
13198
+ "final_url",
13199
+ "status",
13200
+ "indexable",
13201
+ "title",
13202
+ "meta_description",
13203
+ "canonical",
13204
+ "word_count",
13205
+ "content_extractor",
13206
+ "content_extractor_type",
13207
+ "content_extraction_fallback",
13208
+ "word_count_source",
13209
+ "internal_links",
13210
+ "internal_inlinks",
13211
+ "internal_authority_score",
13212
+ "external_links",
13213
+ "images_total",
13214
+ "images_missing_alt",
13215
+ "oversized_image_candidates",
13216
+ "schema_types",
13217
+ "clicks",
13218
+ "impressions",
13219
+ "sessions"
13220
+ ];
13221
+ function renderCrawlCsv(report) {
13222
+ return renderCsv(report.issues.map((issue) => ({
13223
+ rule_id: issue.ruleId,
13224
+ title: issue.title,
13225
+ category: issue.category,
13226
+ severity: issue.severity,
13227
+ url: issue.url,
13228
+ detail: issue.detail,
13229
+ clicks: issue.searchMetrics?.clicks,
13230
+ impressions: issue.searchMetrics?.impressions,
13231
+ position: issue.searchMetrics?.position
13232
+ })), issueHeaders);
13233
+ }
13234
+ function renderCrawlPagesCsv(report) {
13235
+ return renderCsv(report.pages.map((page) => ({
13236
+ url: page.url,
13237
+ final_url: page.finalUrl,
13238
+ status: page.status,
13239
+ indexable: page.indexable,
13240
+ title: page.title,
13241
+ meta_description: page.metaDescription,
13242
+ canonical: page.canonical,
13243
+ word_count: page.wordCount,
13244
+ content_extractor: page.contentExtraction?.used,
13245
+ content_extractor_type: page.contentExtraction?.extractorType,
13246
+ content_extraction_fallback: page.contentExtraction?.fallback,
13247
+ word_count_source: page.contentExtraction?.wordCountSource,
13248
+ internal_links: page.outgoingInternalCount,
13249
+ internal_inlinks: page.internalInlinkCount,
13250
+ internal_authority_score: page.internalLinkAuthorityScore,
13251
+ external_links: page.outgoingExternalCount,
13252
+ images_total: page.imagesTotal,
13253
+ images_missing_alt: page.imagesMissingAlt,
13254
+ oversized_image_candidates: page.oversizedImageCandidates?.length ?? 0,
13255
+ schema_types: page.schemaTypes?.join("|"),
13256
+ clicks: page.searchMetrics?.clicks,
13257
+ impressions: page.searchMetrics?.impressions,
13258
+ sessions: page.analytics?.sessions
13259
+ })), pageHeaders);
13260
+ }
13261
+ /** The caveat a report must lead with when the crawl read too little. */
13262
+ function crawlCoverageCaveat(report) {
13263
+ return report.caveats.find((caveat) => caveat.startsWith(CRAWL_COVERAGE_CAVEAT_PREFIX));
13264
+ }
13265
+ function renderCrawlPretty(report, fixes = topFixes(report)) {
13266
+ const reviews = reviewObservations(report);
13267
+ const requestSummary = report.requestEvidenceStatus === "available" ? `${report.requests.length} requests` : report.requestEvidenceStatus === "partial" ? `${report.requests.length} observed requests; some started requests were still in flight when the crawl stopped` : "request evidence unavailable for this legacy report";
13268
+ const coverage = crawlCoverageCaveat(report);
13269
+ const lines = [
13270
+ ...coverage ? [coverage, ""] : [],
13271
+ `Crawl report for ${report.config.url}`,
13272
+ "",
13273
+ `Status: ${report.status}`,
13274
+ `Strategy: ${report.config.strategy === "health" ? "sitemap health pass" : "full audit"}`,
13275
+ `Crawler: ${report.access.crawler.userAgent}`,
13276
+ `Documents: ${report.summary.totalPages} retained; ${requestSummary}, ${report.summary.discoveredUrls} URLs discovered, ${report.summary.failedUrls} failed, ${report.summary.skippedUrls} skipped`,
13277
+ `Issues: ${report.issues.length} (${report.summary.highIssues} high, ${report.summary.mediumIssues} medium, ${report.summary.lowIssues} low)`
13278
+ ];
13279
+ if (report.externalLinkVerification) {
13280
+ const external = report.externalLinkVerification;
13281
+ lines.push(`External links: ${external.fetchedUrls} fetched, ${external.failedUrls} request failure${external.failedUrls === 1 ? "" : "s"}, ${external.deferredUrls} deferred (${external.dataStatus})`);
13229
13282
  }
13230
- const pageByUrl = /* @__PURE__ */ new Map();
13231
- for (const page of pages) for (const value of [page.url, page.finalUrl]) {
13232
- const normalized = normalizeLinkUrl(value);
13233
- if (normalized) pageByUrl.set(normalized, page.url);
13283
+ if (report.access.blockedRequests > 0) {
13284
+ lines.push("", `Access blocks: ${report.access.blockedRequests}`);
13285
+ for (const sample of report.access.samples.slice(0, 3)) {
13286
+ const requestId = sample.evidence.requestId ? `, request ID ${sample.evidence.requestId}` : "";
13287
+ lines.push(`- ${sample.url}: ${sample.evidence.guidance.summary} (${sample.evidence.provider}${requestId})`, ` Action: ${sample.evidence.guidance.recommendedAction}`, ` Security: ${sample.evidence.guidance.securityNote}`);
13288
+ if (sample.evidence.guidance.documentationUrl) lines.push(` Provider docs: ${sample.evidence.guidance.documentationUrl}`);
13289
+ }
13234
13290
  }
13235
- const inlinks = /* @__PURE__ */ new Map();
13236
- for (const page of pages) inlinks.set(page.url, 0);
13237
- for (const page of pages) {
13238
- const targets = hasLinkGraph ? linkGraph?.[page.url] ?? linkGraph?.[page.finalUrl] ?? [] : page.sampleInternalLinks ?? [];
13239
- const linkedPages = /* @__PURE__ */ new Set();
13240
- for (const target of targets) {
13241
- const normalized = normalizeLinkUrl(target);
13242
- const linkedPage = normalized ? pageByUrl.get(normalized) : void 0;
13243
- if (!linkedPage || linkedPage === page.url) continue;
13244
- linkedPages.add(linkedPage);
13291
+ if (fixes.length) {
13292
+ lines.push("", "Prioritised fixes");
13293
+ for (const fix of fixes.slice(0, 5)) {
13294
+ lines.push(`- ${fix.title} (${fix.severity}, ${fix.count} URL${fix.count === 1 ? "" : "s"}): ${fix.howToFix}`);
13295
+ if (fix.sampleUrls[0]) lines.push(` First URL: ${fix.sampleUrls[0]}`);
13296
+ }
13297
+ } else lines.push("", "No prioritised fixes found.");
13298
+ if (reviews.length) {
13299
+ lines.push("", "Review observations (check before scheduling work)");
13300
+ for (const observation of reviews.slice(0, 5)) {
13301
+ lines.push(`- ${observation.title} (${observation.severity}, ${observation.count} URL${observation.count === 1 ? "" : "s"}): ${observation.howToFix}`);
13302
+ if (observation.sampleUrls[0]) lines.push(` First URL: ${observation.sampleUrls[0]}`);
13245
13303
  }
13246
- for (const linkedPage of linkedPages) inlinks.set(linkedPage, (inlinks.get(linkedPage) ?? 0) + 1);
13247
13304
  }
13248
- let maxInlinks = 0;
13249
- for (const count of inlinks.values()) maxInlinks = Math.max(maxInlinks, count);
13250
- return pages.map((page) => {
13251
- const internalInlinkCount = inlinks.get(page.url) ?? 0;
13305
+ lines.push("", "Next commands", report.config.strategy === "health" ? `- seo crawl ${report.config.url} --max-pages ${report.config.maxPages} --json` : `- seo crawl ${report.config.url} --json`);
13306
+ for (const command of [...new Set(fixes.slice(0, 3).map((fix) => fix.verification.command))]) lines.push(`- ${command}`);
13307
+ if (report.warnings.length) lines.push("", "Warnings", ...report.warnings.map((item) => `- ${item}`));
13308
+ if (report.caveats.length) lines.push("", "Caveats", ...report.caveats.map((item) => `- ${item}`));
13309
+ return `${lines.join("\n")}\n`;
13310
+ }
13311
+ function renderCrawlJunit(report) {
13312
+ const issuesByUrl = /* @__PURE__ */ new Map();
13313
+ for (const issue of report.issues) {
13314
+ const existing = issuesByUrl.get(issue.url) ?? [];
13315
+ existing.push(issue);
13316
+ issuesByUrl.set(issue.url, existing);
13317
+ }
13318
+ const sitemapCases = (report.sitemapDiscovery?.roots ?? []).flatMap((root) => {
13319
+ return (root.documents.length ? root.documents : [{
13320
+ url: root.url,
13321
+ dataStatus: root.dataStatus,
13322
+ compression: "none",
13323
+ warning: root.warnings.join(" ") || void 0
13324
+ }]).map((document) => {
13325
+ const failures = [
13326
+ ...document.dataStatus !== "complete" ? [document.warning ?? `Sitemap evidence is ${document.dataStatus}.`] : [],
13327
+ ...document.redirected ? [`Sitemap redirected to ${document.finalUrl ?? "an unknown URL"}.`] : [],
13328
+ ...document.status !== void 0 && (document.status < 200 || document.status >= 300) ? [`Sitemap returned HTTP ${document.status}.`] : []
13329
+ ];
13330
+ const output = [
13331
+ `dataStatus=${document.dataStatus}`,
13332
+ ...document.status !== void 0 ? [`status=${document.status}`] : [],
13333
+ ...document.finalUrl ? [`finalUrl=${document.finalUrl}`] : [],
13334
+ ...document.root ? [`root=${document.root}`] : [],
13335
+ ...document.warning ? [`warning=${document.warning}`] : []
13336
+ ].join("\n");
13337
+ return {
13338
+ xml: [
13339
+ ` <testcase classname="seo.sitemap-document" name="${escapeXml(document.url)}" time="0.000">`,
13340
+ ...failures.length ? [` <failure message="${escapeXml(failures[0] ?? "Sitemap check failed")}">${escapeXml(failures.join("\n"))}</failure>`] : [],
13341
+ ` <system-out>${escapeXml(output)}</system-out>`,
13342
+ " </testcase>"
13343
+ ].join("\n"),
13344
+ failed: failures.length > 0
13345
+ };
13346
+ });
13347
+ });
13348
+ const requestCases = report.requests.map((request) => {
13349
+ const failures = [
13350
+ ...(issuesByUrl.get(request.requestedUrl) ?? []).map((issue) => `${issue.severity}: ${issue.title}${issue.detail ? `: ${issue.detail}` : ""}`),
13351
+ ...request.outcome === "failure" ? [`${request.failureKind}: ${request.error}`] : [],
13352
+ ...request.outcome === "skipped" ? [request.reason === "origin-backpressure" ? request.error : `${request.reason}: ${request.robotsTxt.matchedLine ?? request.robotsTxt.availability}`] : []
13353
+ ];
13354
+ const output = request.outcome === "response" ? [
13355
+ `status=${request.status}`,
13356
+ `finalUrl=${request.finalUrl}`,
13357
+ ...request.robotsTxt ? [
13358
+ `robotsUrl=${request.robotsTxt.url}`,
13359
+ `robotsAllowed=${request.robotsTxt.allowed ?? "unknown"}`,
13360
+ `robotsAvailability=${request.robotsTxt.availability}`,
13361
+ ...request.robotsTxt.status !== void 0 ? [`robotsStatus=${request.robotsTxt.status}`] : [],
13362
+ ...request.robotsTxt.matchedLine ? [`robotsMatchedLine=${request.robotsTxt.matchedLine}`] : [],
13363
+ ...request.robotsTxt.error ? [`robotsError=${request.robotsTxt.error}`] : []
13364
+ ] : [],
13365
+ ...request.accessBlock ? [
13366
+ `accessProvider=${request.accessBlock.provider}`,
13367
+ `crawler=${request.accessBlock.crawler.userAgent}`,
13368
+ ...request.accessBlock.requestId ? [`requestId=${request.accessBlock.requestId}`] : []
13369
+ ] : []
13370
+ ].join("\n") : request.outcome === "failure" ? request.error : request.reason === "origin-backpressure" ? `reason=${request.reason}\nerror=${request.error}` : [
13371
+ `reason=${request.reason}`,
13372
+ `robotsUrl=${request.robotsTxt.url}`,
13373
+ `robotsAllowed=${request.robotsTxt.allowed ?? "unknown"}`,
13374
+ `robotsAvailability=${request.robotsTxt.availability}`,
13375
+ ...request.robotsTxt.status !== void 0 ? [`robotsStatus=${request.robotsTxt.status}`] : [],
13376
+ ...request.robotsTxt.matchedLine ? [`robotsMatchedLine=${request.robotsTxt.matchedLine}`] : [],
13377
+ ...request.robotsTxt.error ? [`robotsError=${request.robotsTxt.error}`] : []
13378
+ ].join("\n");
13379
+ const duration = ((request.durationMs ?? 0) / 1e3).toFixed(3);
13252
13380
  return {
13253
- ...page,
13254
- internalInlinkCount,
13255
- internalLinkAuthorityScore: maxInlinks ? Math.round(internalInlinkCount / maxInlinks * 100) : 0
13381
+ xml: [
13382
+ ` <testcase classname="seo.sitemap-health" name="${escapeXml(request.requestedUrl)}" time="${duration}">`,
13383
+ ...failures.length ? [` <failure message="${escapeXml(failures[0] ?? "Health check failed")}">${escapeXml(failures.join("\n"))}</failure>`] : [],
13384
+ ` <system-out>${escapeXml(output)}</system-out>`,
13385
+ " </testcase>"
13386
+ ].join("\n"),
13387
+ failed: failures.length > 0
13256
13388
  };
13257
13389
  });
13390
+ const cases = [...sitemapCases, ...requestCases];
13391
+ const failures = cases.filter((item) => item.failed).length;
13392
+ const elapsed = report.requests.reduce((sum, request) => sum + (request.durationMs ?? 0), 0);
13393
+ return [
13394
+ "<?xml version=\"1.0\" encoding=\"UTF-8\"?>",
13395
+ `<testsuite name="seo sitemap health" tests="${cases.length}" failures="${failures}" errors="0" skipped="0" time="${(elapsed / 1e3).toFixed(3)}">`,
13396
+ " <properties>",
13397
+ ` <property name="report.id" value="${escapeXml(report.id)}"/>`,
13398
+ ` <property name="report.status" value="${escapeXml(report.status)}"/>`,
13399
+ ` <property name="crawl.strategy" value="${escapeXml(report.config.strategy)}"/>`,
13400
+ ` <property name="crawler.userAgent" value="${escapeXml(report.access.crawler.userAgent)}"/>`,
13401
+ " </properties>",
13402
+ ...cases.map((item) => item.xml),
13403
+ "</testsuite>",
13404
+ ""
13405
+ ].join("\n");
13258
13406
  }
13259
- function createCrawlReport(input) {
13260
- const config = sanitizeCrawlConfig(normalizeCrawlConfig(input.config));
13261
- const definitionId = crawlDefinitionId({
13262
- config,
13263
- site: input.site,
13264
- googleAnalyticsPropertyId: input.googleAnalyticsPropertyId,
13265
- analyticsConnection: input.analyticsConnection
13266
- });
13267
- const safePagesWithLinks = sortPages(sanitizePages(deriveInternalLinkAuthority(input.pages ?? [], input.linkGraph)));
13268
- const requests = sortRequests(sanitizeTenantValue(input.requests ?? []));
13269
- const requestEvidenceStatus = input.requestEvidenceStatus ?? (input.requests ? "available" : "unavailable");
13270
- if (requestEvidenceStatus === "unavailable" && requests.length > 0) throw new Error("Unavailable request evidence cannot include request observations.");
13271
- const safeIssues = sanitizeIssues([...input.issues ?? [...auditCrawlRequests(requests), ...auditCrawlPages(safePagesWithLinks, { startUrl: config.url })], ...input.additionalIssues ?? []]);
13272
- const pages = safePagesWithLinks;
13273
- return {
13274
- id: input.id ?? crawlRunId(),
13275
- definitionId,
13276
- site: input.site,
13277
- googleAnalyticsPropertyId: input.googleAnalyticsPropertyId,
13278
- analyticsConnection: input.analyticsConnection,
13279
- generatedAt: input.generatedAt ?? (/* @__PURE__ */ new Date()).toISOString(),
13280
- status: input.status ?? "completed",
13281
- configHash: crawlConfigHash(config),
13282
- config,
13283
- access: crawlAccessSummary(requests),
13284
- summary: summarizeCrawlReport({
13285
- pages,
13286
- requests,
13287
- requestEvidenceStatus,
13288
- issues: safeIssues,
13289
- stats: input.stats
13290
- }),
13291
- requestEvidenceStatus,
13292
- requests,
13293
- pages,
13294
- issues: safeIssues,
13295
- issueGroups: groupCrawlIssues(safeIssues),
13296
- ...input.dataSources ? { dataSources: sanitizeTenantValue(input.dataSources) } : {},
13297
- ...input.ai ? { ai: sanitizeTenantValue(input.ai) } : {},
13298
- ...input.sitemapDiscovery ? { sitemapDiscovery: sanitizeTenantValue(input.sitemapDiscovery) } : {},
13299
- ...input.externalLinkVerification ? { externalLinkVerification: sanitizeTenantValue(input.externalLinkVerification) } : {},
13300
- ...input.siteChecks ? { siteChecks: sanitizeTenantValue(input.siteChecks) } : {},
13301
- warnings: sanitizeMessages(input.warnings).sort(compareText$17),
13302
- caveats: sanitizeMessages(input.caveats).sort(compareText$17)
13303
- };
13407
+ function renderCrawlHtml(report, fixes = topFixes(report)) {
13408
+ const reviews = reviewObservations(report);
13409
+ const issueRows = report.issueGroups.map((group) => {
13410
+ const rule = explainRule(group.ruleId);
13411
+ return `<tr><td>${escapeHtml(group.severity)}</td><td>${escapeHtml(group.title)}</td><td>${group.count}</td><td>${escapeHtml(rule?.howToFix ?? "")}</td><td>${escapeHtml(group.sampleUrls[0] ?? "")}</td></tr>`;
13412
+ }).join("\n");
13413
+ const fixItems = fixes.slice(0, 10).map((fix) => `<li><strong>${escapeHtml(fix.title)}</strong><br>${escapeHtml(fix.howToFix)}<br><span>Verify: ${escapeHtml(fix.howToVerify)}</span><br><code>${escapeHtml(fix.verification.command)}</code></li>`).join("\n");
13414
+ const reviewItems = reviews.slice(0, 10).map((observation) => `<li><strong>${escapeHtml(observation.title)}</strong><br>${escapeHtml(observation.howToFix)}<br><span>Check before scheduling work. ${escapeHtml(observation.howToVerify)}</span></li>`).join("\n");
13415
+ const caveatItems = report.caveats.map((caveat) => `<li>${escapeHtml(caveat)}</li>`).join("\n");
13416
+ const warningItems = report.warnings.map((warning) => `<li>${escapeHtml(warning)}</li>`).join("\n");
13417
+ const accessItems = report.access.samples.map((sample) => `<li><strong>${escapeHtml(sample.url)}</strong><br>${escapeHtml(sample.evidence.guidance.summary)}<br>${escapeHtml(sample.evidence.guidance.recommendedAction)}<br><span>${escapeHtml(sample.evidence.guidance.securityNote)}</span></li>`).join("\n");
13418
+ return `<!doctype html>
13419
+ <html lang="en">
13420
+ <head>
13421
+ <meta charset="utf-8">
13422
+ <meta name="viewport" content="width=device-width, initial-scale=1">
13423
+ <title>Crawl report for ${escapeHtml(report.config.url)}</title>
13424
+ <style>
13425
+ body { color: #17202a; font: 15px/1.5 system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; margin: 32px; max-width: 1120px; }
13426
+ h1, h2 { line-height: 1.2; }
13427
+ .summary { display: grid; gap: 12px; grid-template-columns: repeat(auto-fit, minmax(160px, 1fr)); margin: 24px 0; }
13428
+ .metric { border: 1px solid #d8dee4; border-radius: 8px; padding: 12px; }
13429
+ .metric strong { display: block; font-size: 24px; }
13430
+ table { border-collapse: collapse; width: 100%; }
13431
+ th, td { border-bottom: 1px solid #d8dee4; padding: 10px; text-align: left; vertical-align: top; }
13432
+ th { background: #f6f8fa; }
13433
+ li { margin: 0 0 12px; }
13434
+ span { color: #57606a; }
13435
+ </style>
13436
+ </head>
13437
+ <body>
13438
+ <h1>Crawl report</h1>
13439
+ <p>${escapeHtml(report.config.url)}</p>
13440
+ <p>Strategy: ${escapeHtml(report.config.strategy === "health" ? "sitemap health pass" : "full audit")}<br>Crawler: <code>${escapeHtml(report.access.crawler.userAgent)}</code></p>
13441
+ <section class="summary">
13442
+ <div class="metric"><span>Status</span><strong>${escapeHtml(report.status)}</strong></div>
13443
+ <div class="metric"><span>Documents</span><strong>${report.summary.totalPages}</strong></div>
13444
+ <div class="metric"><span>Requests</span><strong>${report.requestEvidenceStatus === "unavailable" ? "n/a" : report.requests.length}${report.requestEvidenceStatus === "partial" ? " (partial)" : ""}</strong></div>
13445
+ <div class="metric"><span>Discovered</span><strong>${report.summary.discoveredUrls}</strong></div>
13446
+ <div class="metric"><span>Queued</span><strong>${report.summary.queuedUrls}</strong></div>
13447
+ <div class="metric"><span>Skipped</span><strong>${report.summary.skippedUrls}</strong></div>
13448
+ <div class="metric"><span>Failed fetches</span><strong>${report.summary.failedUrls}</strong></div>
13449
+ <div class="metric"><span>Observed internal links</span><strong>${report.summary.observedInternalLinks}</strong></div>
13450
+ <div class="metric"><span>Issues</span><strong>${report.issues.length}</strong></div>
13451
+ <div class="metric"><span>High</span><strong>${report.summary.highIssues}</strong></div>
13452
+ </section>
13453
+ ${accessItems ? `<h2>Access blocks</h2><ul>${accessItems}</ul>` : ""}
13454
+ <h2>Prioritised fixes</h2>
13455
+ ${fixItems ? `<ol>${fixItems}</ol>` : "<p>No prioritised fixes found.</p>"}
13456
+ ${reviewItems ? `<h2>Review observations</h2>
13457
+ <p>Check these before scheduling implementation work.</p>
13458
+ <ul>${reviewItems}</ul>` : ""}
13459
+ ${report.caveats.length ? `<h2>Caveats</h2>
13460
+ <ul>${caveatItems}</ul>` : ""}
13461
+ ${report.warnings.length ? `<h2>Warnings</h2>
13462
+ <ul>${warningItems}</ul>` : ""}
13463
+ <h2>Issue groups</h2>
13464
+ <table>
13465
+ <thead><tr><th>Severity</th><th>Issue</th><th>URLs</th><th>Fix</th><th>Sample URL</th></tr></thead>
13466
+ <tbody>${issueRows}</tbody>
13467
+ </table>
13468
+ </body>
13469
+ </html>
13470
+ `;
13304
13471
  }
13305
- function normalizeLoadedCrawlReport(report) {
13306
- const config = sanitizeCrawlConfig(normalizeCrawlConfig(report.config));
13307
- const requestEvidenceStatus = report.requestEvidenceStatus ?? (Object.hasOwn(report, "requests") ? "available" : "unavailable");
13308
- const issues = sanitizeIssues(report.issues ?? []);
13309
- const requests = sortRequests(sanitizeTenantValue(report.requests ?? []) ?? []);
13310
- const pages = sortPages(sanitizePages(deriveInternalLinkAuthority(report.pages ?? [])));
13311
- return {
13312
- ...sanitizeTenantValue(report),
13313
- config,
13314
- access: crawlAccessSummary(requests),
13315
- requestEvidenceStatus,
13316
- requests,
13317
- definitionId: report.definitionId ?? crawlDefinitionId({
13318
- config,
13319
- site: report.site,
13320
- googleAnalyticsPropertyId: report.googleAnalyticsPropertyId,
13321
- analyticsConnection: report.analyticsConnection
13322
- }),
13323
- summary: summarizeCrawlReport({
13324
- pages,
13325
- requests,
13326
- requestEvidenceStatus,
13327
- issues,
13328
- stats: report.summary
13329
- }),
13330
- pages,
13331
- issues,
13332
- issueGroups: groupCrawlIssues(issues),
13333
- warnings: sanitizeMessages(report.warnings).sort(compareText$17),
13334
- caveats: sanitizeMessages(report.caveats).sort(compareText$17)
13335
- };
13472
+ function renderCrawlMarkdownTickets(report, fixes = topFixes(report)) {
13473
+ const reviews = reviewObservations(report);
13474
+ const lines = [
13475
+ `# Crawl Implementation Tickets`,
13476
+ "",
13477
+ `Source: ${report.config.url}`,
13478
+ `Report: ${report.id}`,
13479
+ `Status: ${report.status}`,
13480
+ `Strategy: ${report.config.strategy}`,
13481
+ `Crawler: ${report.access.crawler.userAgent}`,
13482
+ ""
13483
+ ];
13484
+ if (report.caveats.length) lines.push("## Caveats", "", ...report.caveats.map((item) => `- ${item}`), "");
13485
+ if (report.warnings.length) lines.push("## Warnings", "", ...report.warnings.map((item) => `- ${item}`), "");
13486
+ if (report.access.blockedRequests > 0) lines.push("## Access blocks", "", ...report.access.samples.flatMap((sample) => [
13487
+ `- ${sample.url}: ${sample.evidence.guidance.summary}`,
13488
+ ` - Action: ${sample.evidence.guidance.recommendedAction}`,
13489
+ ` - Security: ${sample.evidence.guidance.securityNote}`
13490
+ ]), "");
13491
+ for (const [index, fix] of fixes.entries()) lines.push(`## ${index + 1}. ${fix.title}`, "", `- [ ] Fix ${fix.count} affected URL${fix.count === 1 ? "" : "s"}`, `- Severity: ${fix.severity}`, `- Rule: ${fix.ruleId}`, `- Why this ranks: ${fix.whyThisRanks}`, `- Plain-English fix: ${fix.howToFix}`, `- Verify: ${fix.howToVerify}`, `- Command: ${fix.verification.command}`, "", `Affected URLs:`, ...fix.sampleUrls.slice(0, 10).map((url) => `- ${url}`), "");
13492
+ if (!fixes.length) lines.push("No implementation tickets were generated.");
13493
+ if (reviews.length) lines.push("## Review observations", "", "These are not implementation tickets. Check the evidence before deciding whether to work on them.", "", ...reviews.slice(0, 10).flatMap((observation) => [`- ${observation.title} (${observation.severity}, ${observation.count} URL${observation.count === 1 ? "" : "s"}): ${observation.howToFix}`, ` - First URL: ${observation.sampleUrls[0] ?? "No sample URL recorded"}`]), "");
13494
+ return `${lines.join("\n")}\n`;
13495
+ }
13496
+ function escapeHtml(value) {
13497
+ return value.replaceAll("&", "&amp;").replaceAll("<", "&lt;").replaceAll(">", "&gt;").replaceAll("\"", "&quot;").replaceAll("'", "&#39;");
13498
+ }
13499
+ function escapeXml(value) {
13500
+ return escapeHtml(value);
13336
13501
  }
13337
13502
  // packages/core/src/analyze/crawler/site-checks-schema.ts
13338
13503
  const crawlSiteChecksSchema = z.object({ soft404: z.object({
@@ -14464,123 +14629,6 @@ function agentReadyIssues(input) {
14464
14629
  }
14465
14630
  return issues;
14466
14631
  }
14467
- // packages/core/src/analyze/crawler/crawl-url-queue.ts
14468
- function before(left, right) {
14469
- return left.depth < right.depth || left.depth === right.depth && left.url < right.url;
14470
- }
14471
- var CrawlUrlQueue = class {
14472
- #items = [];
14473
- #positions = /* @__PURE__ */ new Map();
14474
- get size() {
14475
- return this.#items.length;
14476
- }
14477
- has(url) {
14478
- return this.#positions.has(url);
14479
- }
14480
- push(item) {
14481
- if (this.has(item.url)) throw new Error(`URL is already queued: ${item.url}`);
14482
- const index = this.#items.length;
14483
- this.#items.push(item);
14484
- this.#positions.set(item.url, index);
14485
- this.#bubbleUp(index);
14486
- }
14487
- decreaseDepth(url, depth) {
14488
- const index = this.#positions.get(url);
14489
- if (index === void 0) return false;
14490
- const item = this.#items[index];
14491
- if (!item || depth >= item.depth) return false;
14492
- item.depth = depth;
14493
- this.#bubbleUp(index);
14494
- return true;
14495
- }
14496
- take() {
14497
- const first = this.#items[0];
14498
- if (!first) return void 0;
14499
- const last = this.#items.pop();
14500
- this.#positions.delete(first.url);
14501
- if (last && last !== first) {
14502
- this.#items[0] = last;
14503
- this.#positions.set(last.url, 0);
14504
- this.#bubbleDown(0);
14505
- }
14506
- return first;
14507
- }
14508
- #swap(left, right) {
14509
- const leftItem = this.#items[left];
14510
- const rightItem = this.#items[right];
14511
- if (!leftItem || !rightItem) return;
14512
- this.#items[left] = rightItem;
14513
- this.#items[right] = leftItem;
14514
- this.#positions.set(rightItem.url, left);
14515
- this.#positions.set(leftItem.url, right);
14516
- }
14517
- #bubbleUp(start) {
14518
- let index = start;
14519
- while (index > 0) {
14520
- const parent = Math.floor((index - 1) / 2);
14521
- const item = this.#items[index];
14522
- const parentItem = this.#items[parent];
14523
- if (!item || !parentItem || !before(item, parentItem)) break;
14524
- this.#swap(index, parent);
14525
- index = parent;
14526
- }
14527
- }
14528
- #bubbleDown(start) {
14529
- let index = start;
14530
- while (true) {
14531
- const left = index * 2 + 1;
14532
- const right = left + 1;
14533
- let next = index;
14534
- const item = this.#items[next];
14535
- const leftItem = this.#items[left];
14536
- const rightItem = this.#items[right];
14537
- if (leftItem && item && before(leftItem, item)) next = left;
14538
- const nextItem = this.#items[next];
14539
- if (rightItem && nextItem && before(rightItem, nextItem)) next = right;
14540
- if (next === index) return;
14541
- this.#swap(index, next);
14542
- index = next;
14543
- }
14544
- }
14545
- };
14546
- // packages/core/src/analyze/crawler/crawl-caveats.ts
14547
- function crawlCaveats(input) {
14548
- const caveats = input.cancelled ? ["Crawl cancelled before all queued URLs finished."] : input.pageLimitReached ? [`Stopped after reaching maxPages (${input.maxPages}).`] : [];
14549
- 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.`);
14550
- 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.`);
14551
- 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.`);
14552
- const renderingFallbackUrls = input.renderingFallbackUrls ?? 0;
14553
- 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.`);
14554
- const queryVariantSkippedUrls = input.queryVariantSkippedUrls ?? 0;
14555
- 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.`);
14556
- for (const issue of input.reachIssues ?? []) if (issue.detail) caveats.push(issue.detail);
14557
- return caveats;
14558
- }
14559
- /**
14560
- * A site crawl that found no internal links on the start page and no sitemap
14561
- * URLs checked one page, so its other findings cannot stand for the site.
14562
- */
14563
- function crawlReachIssues(input) {
14564
- const [page] = input.pages;
14565
- if (input.crawledPages !== 1 || input.pages.length !== 1 || !page || page.outgoingInternalCount > 0 || input.sitemapUrlsReturned > 0) return [];
14566
- const rule = explainRule("crawl_single_page_reach");
14567
- if (!rule) throw new Error("Missing rule guidance for crawl_single_page_reach");
14568
- return [{
14569
- ruleId: "crawl_single_page_reach",
14570
- title: rule.title,
14571
- category: rule.category,
14572
- severity: rule.defaultSeverity,
14573
- url: page.url,
14574
- 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.",
14575
- evidence: {
14576
- startUrl: input.startUrl,
14577
- crawledPages: 1,
14578
- outgoingInternalCount: 0,
14579
- sitemapUrlsReturned: 0,
14580
- rendered: Boolean(page.usedJs)
14581
- }
14582
- }];
14583
- }
14584
14632
  // packages/core/src/analyze/crawler/crawl-control.ts
14585
14633
  const CRAWL_CANCELLED = Symbol("crawl-cancelled");
14586
14634
  function abortController(input) {
@@ -14622,6 +14670,96 @@ function crawlRssLimitBytes(totalMemoryBytes) {
14622
14670
  function crawlMemoryPressure(input) {
14623
14671
  return input.memoryUsage.heapUsed + input.memoryUsage.external >= crawlMemoryLimitBytes(input.totalMemoryBytes) || input.memoryUsage.rss >= crawlRssLimitBytes(input.totalMemoryBytes);
14624
14672
  }
14673
+ // packages/core/src/analyze/crawler/crawl-rate-limit.ts
14674
+ const RETRIES_PER_URL = 3;
14675
+ const BASE_BACKOFF_MS = 2e3;
14676
+ const MAX_BACKOFF_MS = 3e4;
14677
+ const COOLDOWN_BUDGET_MS = 18e4;
14678
+ const LIMITED_INTERVAL_MS = 1e3;
14679
+ const LIMITED_MAX_INTERVAL_MS = 4e3;
14680
+ function rateLimitEvidence(result, status, nowMs) {
14681
+ if (status !== 429 && status !== 503) return void 0;
14682
+ const header = retryAfterMs(result.page?.responseHeaders?.["retry-after"]);
14683
+ const cooldownUntil = Date.parse(result.page?.fetchDiagnostics?.backpressure?.cooldownUntil ?? "");
14684
+ const fromCooldown = Number.isFinite(cooldownUntil) ? Math.max(0, cooldownUntil - nowMs) : void 0;
14685
+ const wait = header ?? fromCooldown;
14686
+ if (status === 503 && wait === void 0) return void 0;
14687
+ return wait === void 0 ? {} : { retryAfterMs: Math.min(wait, MAX_BACKOFF_MS) };
14688
+ }
14689
+ /**
14690
+ * Rate-limit state for one crawl. After the first 429 the crawl drops to one
14691
+ * request at a time with a per-host interval and never speeds back up.
14692
+ */
14693
+ var CrawlRateLimit = class {
14694
+ rate;
14695
+ responses = 0;
14696
+ skippedUrls = 0;
14697
+ pendingMs = 0;
14698
+ spentMs = 0;
14699
+ #level = 0;
14700
+ #budgetSpent = false;
14701
+ #retries = /* @__PURE__ */ new Map();
14702
+ constructor(rate) {
14703
+ this.rate = rate;
14704
+ }
14705
+ get limited() {
14706
+ return this.responses > 0;
14707
+ }
14708
+ get intervalMs() {
14709
+ return this.rate.intervalMs ?? LIMITED_INTERVAL_MS;
14710
+ }
14711
+ /** The cool-down before probes that run after the crawl. */
14712
+ get settleMs() {
14713
+ return Math.min(MAX_BACKOFF_MS, BASE_BACKOFF_MS * 2 ** this.#level);
14714
+ }
14715
+ /**
14716
+ * `retry` requeues the URL after `pendingMs`. `skip` gives it up, and
14717
+ * `stop` gives up the rest of the queue too once the budget is spent.
14718
+ */
14719
+ observe(url, result, status, nowMs) {
14720
+ const evidence = rateLimitEvidence(result, status, nowMs);
14721
+ if (!evidence) return void 0;
14722
+ this.responses += 1;
14723
+ if (this.pendingMs === 0) this.#level += 1;
14724
+ const step = 2 ** Math.max(0, this.#level - 1);
14725
+ const waitMs = evidence.retryAfterMs ?? Math.min(MAX_BACKOFF_MS, BASE_BACKOFF_MS * step);
14726
+ this.rate = {
14727
+ ...this.rate,
14728
+ concurrency: 1,
14729
+ intervalCap: 1,
14730
+ intervalMs: Math.min(LIMITED_MAX_INTERVAL_MS, Math.max(this.rate.intervalMs ?? 0, LIMITED_INTERVAL_MS * step))
14731
+ };
14732
+ const nextMs = Math.max(this.pendingMs, waitMs);
14733
+ if (this.spentMs + nextMs > COOLDOWN_BUDGET_MS) this.#budgetSpent = true;
14734
+ const retries = this.#retries.get(url) ?? 0;
14735
+ if (!this.#budgetSpent && retries < RETRIES_PER_URL) {
14736
+ this.#retries.set(url, retries + 1);
14737
+ this.pendingMs = nextMs;
14738
+ return "retry";
14739
+ }
14740
+ this.skippedUrls += 1;
14741
+ return this.#budgetSpent ? "stop" : "skip";
14742
+ }
14743
+ async settle(sleep, signal) {
14744
+ if (this.pendingMs <= 0) return;
14745
+ await sleep(this.pendingMs, signal);
14746
+ this.spentMs += this.pendingMs;
14747
+ this.pendingMs = 0;
14748
+ }
14749
+ caveatInput(config, readPages) {
14750
+ if (this.skippedUrls === 0) return void 0;
14751
+ return {
14752
+ readPages,
14753
+ skippedUrls: this.skippedUrls,
14754
+ rerunCommand: rateLimitRerunCommand(config, this.intervalMs)
14755
+ };
14756
+ }
14757
+ warning() {
14758
+ if (!this.limited) return void 0;
14759
+ const plural = (count, noun) => `${count} ${noun}${count === 1 ? "" : "s"}`;
14760
+ 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"}.`;
14761
+ }
14762
+ };
14625
14763
  // packages/core/src/analyze/crawler/external-link-checks.ts
14626
14764
  const EXTERNAL_LINK_SELECTION_LIMIT = 200;
14627
14765
  const EXTERNAL_LINK_STATES = [
@@ -14882,21 +15020,32 @@ function probeObservation(url, result) {
14882
15020
  error: request.outcome === "failure" ? request.error : `Probe skipped: ${request.reason}`
14883
15021
  };
14884
15022
  }
15023
+ function isConclusive(probe) {
15024
+ return probe.status !== void 0 && !probe.accessBlocked && probe.status !== 429 && probe.status !== 503;
15025
+ }
14885
15026
  async function collectCrawlSiteChecks(input) {
15027
+ const probe = async (url) => probeObservation(url, await input.fetchStatusPage(url, {
15028
+ js: "off",
15029
+ refresh: true,
15030
+ timeoutMs: input.timeoutMs,
15031
+ signal: input.signal,
15032
+ writeCache: false,
15033
+ respectRobots: input.respectRobots,
15034
+ ...input.rate ? { rate: input.rate } : {}
15035
+ }));
14886
15036
  const probes = [];
14887
15037
  for (const url of probeUrls(input.startUrl)) {
14888
15038
  if (input.signal?.aborted) break;
14889
- const result = await input.fetchStatusPage(url, {
14890
- js: "off",
14891
- refresh: true,
14892
- timeoutMs: input.timeoutMs,
14893
- signal: input.signal,
14894
- writeCache: false,
14895
- respectRobots: input.respectRobots
14896
- });
14897
- probes.push(probeObservation(url, result));
15039
+ probes.push(await probe(url));
15040
+ }
15041
+ if (input.sleep && !input.signal?.aborted && probes.some((item) => !isConclusive(item))) {
15042
+ await input.sleep(input.retryDelayMs ?? 2e3, input.signal);
15043
+ for (const [index, item] of probes.entries()) {
15044
+ if (input.signal?.aborted) break;
15045
+ if (!isConclusive(item)) probes[index] = await probe(item.url);
15046
+ }
14898
15047
  }
14899
- const conclusive = probes.filter((probe) => probe.status !== void 0 && !probe.accessBlocked);
15048
+ const conclusive = probes.filter(isConclusive);
14900
15049
  const successful = conclusive.filter((probe) => (probe.status ?? 0) >= 200 && (probe.status ?? 0) < 400);
14901
15050
  const correctMissing = conclusive.filter((probe) => probe.status === 404 || probe.status === 410);
14902
15051
  const status = successful.length > 0 ? "warning" : correctMissing.length === PROBE_COUNT ? "pass" : "unknown";
@@ -14904,7 +15053,7 @@ async function collectCrawlSiteChecks(input) {
14904
15053
  status,
14905
15054
  probes,
14906
15055
  probeLimit: PROBE_COUNT,
14907
- complete: probes.length === PROBE_COUNT
15056
+ complete: conclusive.length === PROBE_COUNT
14908
15057
  } };
14909
15058
  if (status !== "warning") return {
14910
15059
  checks,
@@ -17460,6 +17609,18 @@ async function fetchLandingPageValues(input, dependencies = defaultDependencies$
17460
17609
  }[connection.provider](connection);
17461
17610
  }
17462
17611
  // packages/core/src/analyze/crawler/site-crawl-dependencies.ts
17612
+ function sleep$2(ms, signal) {
17613
+ return new Promise((resolve) => {
17614
+ if (signal?.aborted) return resolve();
17615
+ const done = () => {
17616
+ clearTimeout(timer);
17617
+ signal?.removeEventListener("abort", done);
17618
+ resolve();
17619
+ };
17620
+ const timer = setTimeout(done, ms);
17621
+ signal?.addEventListener("abort", done, { once: true });
17622
+ });
17623
+ }
17463
17624
  function resolveCrawlSiteDependencies(dependencies = {}) {
17464
17625
  const hasInjectedPerPageSearchProvider = Boolean(dependencies.queryPageMetrics || dependencies.queryPageTopQuery);
17465
17626
  const hasInjectedLegacyBulkSearchProvider = Boolean(dependencies.queryPagesMetrics || dependencies.queryPagesTopQueries);
@@ -17480,6 +17641,7 @@ function resolveCrawlSiteDependencies(dependencies = {}) {
17480
17641
  fetchLandingPageValues: dependencies.fetchLandingPageValues ?? fetchLandingPageValues,
17481
17642
  landingValueForUrl: dependencies.landingValueForUrl ?? landingValueForUrl,
17482
17643
  now: dependencies.now ?? (() => /* @__PURE__ */ new Date()),
17644
+ sleep: dependencies.sleep ?? sleep$2,
17483
17645
  memoryUsage: dependencies.memoryUsage ?? (() => process.memoryUsage()),
17484
17646
  totalMemory: dependencies.totalMemory ?? totalmem
17485
17647
  };
@@ -17698,6 +17860,7 @@ async function crawlSite(input, dependencies) {
17698
17860
  const queryVariantsByPath = /* @__PURE__ */ new Map();
17699
17861
  const queryVariantSkipped = /* @__PURE__ */ new Set();
17700
17862
  let renderingFallbackUrls = 0;
17863
+ const rateLimit = new CrawlRateLimit(config.fetchRate);
17701
17864
  let failedUrls = 0;
17702
17865
  let observedInternalLinks = 0;
17703
17866
  let memoryLimited = false;
@@ -17782,6 +17945,15 @@ async function crawlSite(input, dependencies) {
17782
17945
  });
17783
17946
  return;
17784
17947
  }
17948
+ if (isCdnUtilityUrl(normalized)) {
17949
+ recordSkip("cdn-utility-url");
17950
+ emitStatus("url_skipped", {
17951
+ url: normalized,
17952
+ depth,
17953
+ reason: "cdn_utility_url"
17954
+ });
17955
+ return;
17956
+ }
17785
17957
  if (isLikelyAsset(normalized)) {
17786
17958
  recordSkip("asset-url");
17787
17959
  emitStatus("url_skipped", {
@@ -17886,7 +18058,7 @@ async function crawlSite(input, dependencies) {
17886
18058
  js: config.js,
17887
18059
  refresh: config.refresh,
17888
18060
  timeoutMs: config.timeoutMs,
17889
- rate: config.fetchRate,
18061
+ rate: rateLimit.rate,
17890
18062
  signal,
17891
18063
  writeCache: false,
17892
18064
  respectRobots: config.respectRobots,
@@ -17917,7 +18089,11 @@ async function crawlSite(input, dependencies) {
17917
18089
  });
17918
18090
  };
17919
18091
  while (!isCancelled() && (queue.size || inFlight.size) && (!memoryLimited || inFlight.size > 0) && pages.length < config.maxPages) {
17920
- while (!isCancelled() && !memoryLimited && inFlight.size < activeConcurrency && pages.length + inFlight.size < config.maxPages) {
18092
+ if (rateLimit.pendingMs > 0 && inFlight.size === 0) {
18093
+ await rateLimit.settle(deps.sleep, signal);
18094
+ if (isCancelled()) break;
18095
+ }
18096
+ while (!isCancelled() && !memoryLimited && rateLimit.pendingMs === 0 && inFlight.size < activeConcurrency && pages.length + inFlight.size < config.maxPages) {
17921
18097
  const item = nextQueueItem();
17922
18098
  if (!item) break;
17923
18099
  startTask(item);
@@ -17937,11 +18113,46 @@ async function crawlSite(input, dependencies) {
17937
18113
  error: result.warning ?? "Crawler returned no page snapshot.",
17938
18114
  extraction: "not-applicable"
17939
18115
  });
18116
+ const limited = rateLimit.observe(task.url, result, request.outcome === "response" ? request.status : void 0, deps.now().getTime());
18117
+ if (limited) {
18118
+ activeConcurrency = 1;
18119
+ healthyProbeStreak = 0;
18120
+ }
18121
+ if (limited === "retry") {
18122
+ visited.delete(task.url);
18123
+ if (!queue.has(task.url)) queue.push({
18124
+ url: task.url,
18125
+ depth: task.depth
18126
+ });
18127
+ emitStatus("url_queued", {
18128
+ url: task.url,
18129
+ depth: task.depth,
18130
+ reason: "rate_limited_retry",
18131
+ message: `The origin rate limited ${task.url}. Waiting ${Math.ceil(rateLimit.pendingMs / 1e3)}s, then retrying one request at a time.`
18132
+ });
18133
+ continue;
18134
+ }
18135
+ if (limited) {
18136
+ requests.push(request);
18137
+ recordSkip("origin-backpressure");
18138
+ emitStatus("page_skipped", {
18139
+ url: task.url,
18140
+ depth: task.depth,
18141
+ reason: "origin-backpressure"
18142
+ });
18143
+ if (limited === "stop") {
18144
+ let left = 0;
18145
+ while (queue.take()) left += 1;
18146
+ recordSkips("origin-backpressure", left);
18147
+ rateLimit.skippedUrls += left;
18148
+ }
18149
+ continue;
18150
+ }
17940
18151
  requests.push(request);
17941
18152
  if (healthStrategy) {
17942
18153
  if (request.outcome === "response" && request.status < 400 && !request.accessBlock) {
17943
18154
  healthyProbeStreak += 1;
17944
- activeConcurrency = Math.min(config.concurrency, 1 + Math.floor(healthyProbeStreak / 20));
18155
+ activeConcurrency = Math.min(rateLimit.limited ? 1 : config.concurrency, 1 + Math.floor(healthyProbeStreak / 20));
17945
18156
  } else {
17946
18157
  healthyProbeStreak = 0;
17947
18158
  activeConcurrency = 1;
@@ -18014,7 +18225,7 @@ async function crawlSite(input, dependencies) {
18014
18225
  const storeDocument = (urls) => {
18015
18226
  const sourceRequest = normalizeUrl$1(task.url, config.url) ?? task.url;
18016
18227
  const directlyRequested = sourceRequest === finalUrl;
18017
- const normalizedLinks = urls.map((url) => normalizeUrl$1(url, finalUrl)).filter((url) => Boolean(url));
18228
+ const normalizedLinks = urls.map((url) => normalizeUrl$1(url, finalUrl)).filter((url) => Boolean(url) && !isCdnUtilityUrl(url ?? ""));
18018
18229
  const syncLinkEvidence = (target, links) => {
18019
18230
  const sortedLinks = [...links].sort((left, right) => left < right ? -1 : left > right ? 1 : 0);
18020
18231
  target.outgoingInternalCount = sortedLinks.length;
@@ -18093,7 +18304,7 @@ async function crawlSite(input, dependencies) {
18093
18304
  if (followLinks && task.depth < config.maxDepth) {
18094
18305
  if (memoryLimited) for (const value of result.urls) {
18095
18306
  const normalized = normalizeUrl$1(value, finalUrl);
18096
- if (normalized && sameOrigin(normalized, origin) && !isLikelyAsset(normalized) && passesFilters(normalized, config.include, config.exclude) && !visited.has(normalized) && !queue.has(normalized)) {
18307
+ if (normalized && sameOrigin(normalized, origin) && !isCdnUtilityUrl(normalized) && !isLikelyAsset(normalized) && passesFilters(normalized, config.include, config.exclude) && !visited.has(normalized) && !queue.has(normalized)) {
18097
18308
  discovered.add(normalized);
18098
18309
  memoryDeferredUrls.add(normalized);
18099
18310
  }
@@ -18118,6 +18329,8 @@ async function crawlSite(input, dependencies) {
18118
18329
  cancelled = isCancelled();
18119
18330
  const robotsDisallowed = skipReasonCounts["robots-disallowed"] ?? 0;
18120
18331
  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.`);
18332
+ const rateLimitWarning = rateLimit.warning();
18333
+ if (rateLimitWarning) warnings.push(rateLimitWarning);
18121
18334
  if (cancelled) {
18122
18335
  warnings.push("Crawl cancelled; returning a partial report.");
18123
18336
  emitStatus("cancelled", {
@@ -18129,7 +18342,7 @@ async function crawlSite(input, dependencies) {
18129
18342
  const sitemapEvidencePartial = sitemapDiscovery?.dataStatus === "partial";
18130
18343
  const accessEvidencePartial = requests.some((request) => request.outcome === "response" && request.accessBlock);
18131
18344
  const requiredSitemapUnavailable = config.mode === "sitemap" && sitemapDiscovery?.dataStatus === "unavailable";
18132
- const partial = cancelled || failedUrls > 0 || pageLimitReached || robotsDeferredUrls > 0 || originBackpressureSkippedUrls > 0 || queueSafetySkippedUrls > 0 || queue.size > 0 || inFlight.size > 0 || stoppedForMemory || sitemapEvidencePartial || accessEvidencePartial || requiredSitemapUnavailable;
18345
+ 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;
18133
18346
  const failed = !cancelled && pages.length === 0 && (requiredSitemapUnavailable || requests.length > 0 && requests.every((request) => request.outcome === "failure"));
18134
18347
  const requestEvidenceStatus = cancelled && inFlight.size > 0 ? "partial" : "available";
18135
18348
  const dataSources = await crawlDataSources({
@@ -18176,7 +18389,10 @@ async function crawlSite(input, dependencies) {
18176
18389
  timeoutMs: config.timeoutMs,
18177
18390
  fetchStatusPage: deps.fetchStatusPage,
18178
18391
  respectRobots: config.respectRobots,
18179
- signal
18392
+ signal,
18393
+ rate: rateLimit.rate,
18394
+ sleep: deps.sleep,
18395
+ retryDelayMs: rateLimit.settleMs
18180
18396
  }) : void 0;
18181
18397
  for (const page of pages) {
18182
18398
  if (!page.geo || !llmsTxt) continue;
@@ -18245,6 +18461,8 @@ async function crawlSite(input, dependencies) {
18245
18461
  queueSafetySkippedUrls,
18246
18462
  queueSafetyInventoryCapped,
18247
18463
  originBackpressureSkippedUrls,
18464
+ rateLimit: rateLimit.caveatInput(config, pages.length),
18465
+ sitemapLimit: sitemapLimitCaveatInput(config, sitemapDiscovery, input.maxPages === void 0),
18248
18466
  memoryPressureSkippedUrls,
18249
18467
  queryVariantSkippedUrls: queryVariantSkipped.size,
18250
18468
  renderingFallbackUrls,
@@ -32200,6 +32418,31 @@ async function dataForSeoRows(keys, input, spent) {
32200
32418
  }
32201
32419
  const GAP_DOMAINS = 1e3;
32202
32420
  const GAP_COMPETITORS = 5;
32421
+ const SHORTENERS = [
32422
+ "bit.ly",
32423
+ "tinyurl.com",
32424
+ "t.co",
32425
+ "ow.ly",
32426
+ "buff.ly",
32427
+ "is.gd",
32428
+ "cutt.ly",
32429
+ "rebrand.ly",
32430
+ "shorturl.at",
32431
+ "rb.gy",
32432
+ "lnkd.in"
32433
+ ];
32434
+ const FREE_APP_HOSTS = [
32435
+ "web.app",
32436
+ "firebaseapp.com",
32437
+ "pages.dev"
32438
+ ];
32439
+ 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-)/;
32440
+ function isLinkList(domain) {
32441
+ const within = (name) => domain === name || domain.endsWith(`.${name}`);
32442
+ if (SHORTENERS.some(within)) return true;
32443
+ if (FREE_APP_HOSTS.some((name) => domain.endsWith(`.${name}`))) return true;
32444
+ return LINK_LIST_NAME.test(domain.replace(/[-.]/g, ""));
32445
+ }
32203
32446
  async function linkGap(provider, headers, input) {
32204
32447
  const target = bareTarget(input.target).replace(/^www\./, "");
32205
32448
  const competitors = [...new Set((input.competitors ?? []).map((site) => bareTarget(site).replace(/^www\./, "")))].filter((site) => site && site !== target);
@@ -32240,8 +32483,10 @@ async function linkGap(provider, headers, input) {
32240
32483
  found.set(domain, entry);
32241
32484
  }
32242
32485
  });
32486
+ const gap = [...found.entries()].filter(([, entry]) => entry.sites.length >= 2 && (floor === null || entry.value >= floor));
32487
+ const linkLists = gap.map(([domain]) => domain).filter(isLinkList).sort((a, b) => a.localeCompare(b));
32243
32488
  return {
32244
- 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]) => ({
32489
+ 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]) => ({
32245
32490
  domain,
32246
32491
  [score]: entry.value,
32247
32492
  competitors_linking: entry.sites.length,
@@ -32254,7 +32499,8 @@ async function linkGap(provider, headers, input) {
32254
32499
  ...floor !== null ? { floor: {
32255
32500
  field: score,
32256
32501
  value: floor
32257
- } } : {}
32502
+ } } : {},
32503
+ ...linkLists.length ? { leftOut: linkLists } : {}
32258
32504
  };
32259
32505
  }
32260
32506
  function isPage(target) {
@@ -32621,6 +32867,7 @@ async function runAiAnswers(input, headers, deps, more) {
32621
32867
  question,
32622
32868
  impressions,
32623
32869
  task: null,
32870
+ ref: null,
32624
32871
  cost_usd: 0,
32625
32872
  ...empty
32626
32873
  });
@@ -32632,7 +32879,8 @@ async function runAiAnswers(input, headers, deps, more) {
32632
32879
  ...blank("", null),
32633
32880
  engine: engine.name,
32634
32881
  result: "pending",
32635
- task: value
32882
+ task: value,
32883
+ ref: value
32636
32884
  };
32637
32885
  rows.push(row);
32638
32886
  waiting.push({
@@ -32685,6 +32933,7 @@ async function runAiAnswers(input, headers, deps, more) {
32685
32933
  }
32686
32934
  row.result = "pending";
32687
32935
  row.task = `${engine.name}:${task.id}`;
32936
+ row.ref = row.task;
32688
32937
  waiting.push({
32689
32938
  row,
32690
32939
  engine,
@@ -32725,6 +32974,7 @@ async function runAiAnswers(input, headers, deps, more) {
32725
32974
  const cost = usd(rows.reduce((sum, row) => sum + row.cost_usd, 0));
32726
32975
  const count = (result) => rows.filter((row) => row.result === result).length;
32727
32976
  const left = rows.filter((row) => row.task).map((row) => row.task);
32977
+ const refs = rows.flatMap((row) => row.ref ? [row.ref] : []);
32728
32978
  const cites = /* @__PURE__ */ new Map();
32729
32979
  for (const row of rows) for (const host of row.sources) cites.set(host, (cites.get(host) ?? 0) + 1);
32730
32980
  const topCited = [...cites].sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0])).slice(0, 10);
@@ -32758,7 +33008,7 @@ async function runAiAnswers(input, headers, deps, more) {
32758
33008
  limit: 50,
32759
33009
  detail: input.detail,
32760
33010
  notes,
32761
- more
33011
+ more: (offset) => more(offset, refs)
32762
33012
  }),
32763
33013
  data: {
32764
33014
  provider: "dataforseo",
@@ -32772,6 +33022,7 @@ async function runAiAnswers(input, headers, deps, more) {
32772
33022
  questions: [...new Set(rows.map((row) => row.question))],
32773
33023
  costUsd: cost,
32774
33024
  pendingTasks: left,
33025
+ tasks: refs,
32775
33026
  citedSites: topCited.map(([host, answers]) => ({
32776
33027
  host,
32777
33028
  answers
@@ -32891,7 +33142,7 @@ function ga4MatchReason(match, site) {
32891
33142
  }
32892
33143
  // packages/core/src/commands/analytics-site.ts
32893
33144
  const STREAM_CHECKS = 30;
32894
- const AT_ONCE = 5;
33145
+ const AT_ONCE$1 = 5;
32895
33146
  const TIED = 3;
32896
33147
  const hostOf$1 = (site) => {
32897
33148
  const value = site.replace(/^sc-domain:/, "");
@@ -32956,8 +33207,8 @@ async function lookProperty(access, site) {
32956
33207
  }
32957
33208
  const order = ranked(site, candidates).slice(0, STREAM_CHECKS);
32958
33209
  let failures = 0;
32959
- for (let start = 0; start < order.length; start += AT_ONCE) {
32960
- const batch = order.slice(start, start + AT_ONCE);
33210
+ for (let start = 0; start < order.length; start += AT_ONCE$1) {
33211
+ const batch = order.slice(start, start + AT_ONCE$1);
32961
33212
  const streams = await Promise.all(batch.map((candidate) => listAnalyticsDataStreams(candidate.google, candidate.property, { signIn: access.signIn }).catch(() => {
32962
33213
  failures += 1;
32963
33214
  return [];
@@ -34090,7 +34341,8 @@ async function runClickyRows(input, keys, options) {
34090
34341
  // packages/core/src/commands/domain-rating.ts
34091
34342
  const DR_ATTRIBUTION = "domain_rating: Domain Rating by Ahrefs (https://ahrefs.com/).";
34092
34343
  const ENDPOINT = "https://api.ahrefs.com/v3/public/domain-rating-free";
34093
- const MAX_DOMAINS = 25;
34344
+ const MAX_DOMAINS = 50;
34345
+ const AT_ONCE = 25;
34094
34346
  const TTL_SECONDS = 86400;
34095
34347
  const DOMAIN_FIELDS = [
34096
34348
  "domain",
@@ -34121,23 +34373,26 @@ async function lookup(domain, key) {
34121
34373
  }
34122
34374
  return body.domain_rating?.domain_rating ?? null;
34123
34375
  }
34124
- async function domainRatings(domains, key) {
34376
+ async function domainRatings(domains, key, max = MAX_DOMAINS) {
34125
34377
  const found = /* @__PURE__ */ new Map();
34126
34378
  if (!key) return found;
34127
- const unique = [...new Set(domains.map(bareDomain).filter(Boolean))].slice(0, MAX_DOMAINS);
34128
- const values = await Promise.all(unique.map((domain) => lookup(domain, key).catch(() => null)));
34129
- unique.forEach((domain, index) => {
34130
- found.set(domain, values[index] ?? null);
34131
- });
34379
+ const unique = [...new Set(domains.map(bareDomain).filter(Boolean))].slice(0, max);
34380
+ for (let start = 0; start < unique.length; start += AT_ONCE) {
34381
+ const batch = unique.slice(start, start + AT_ONCE);
34382
+ const values = await Promise.all(batch.map((domain) => lookup(domain, key).catch(() => null)));
34383
+ batch.forEach((domain, index) => {
34384
+ found.set(domain, values[index] ?? null);
34385
+ });
34386
+ }
34132
34387
  return found;
34133
34388
  }
34134
- async function withDomainRating(rows, key) {
34389
+ async function withDomainRating(rows, key, start = 0) {
34135
34390
  const field = DOMAIN_FIELDS.find((name) => rows.some((row) => typeof row[name] === "string"));
34136
34391
  if (!key || !field || rows.some((row) => "domain_rating" in row)) return {
34137
34392
  rows,
34138
34393
  added: false
34139
34394
  };
34140
- const ratings = await domainRatings(rows.map((row) => String(row[field] ?? "")), key);
34395
+ const ratings = await domainRatings(rows.slice(start).map((row) => String(row[field] ?? "")), key);
34141
34396
  return {
34142
34397
  rows: rows.map((row) => {
34143
34398
  const next = {};
@@ -36173,7 +36428,7 @@ async function runResearch(input, keys, options) {
36173
36428
  const offset = input.offset ?? 0;
36174
36429
  const language = input.provider === "dataforseo" ? dataForSeoLanguage(country) : null;
36175
36430
  if (input.action === "ai_answers") throw new CommandError("INVALID_INPUT", "ai_answers runs on its own.");
36176
- const { rows: found, costUsd, partial, missed, noData, floor } = await research(input.provider, keys, {
36431
+ const { rows: found, costUsd, partial, missed, noData, floor, leftOut } = await research(input.provider, keys, {
36177
36432
  action: input.action,
36178
36433
  keywords: input.keywords ?? [],
36179
36434
  target,
@@ -36187,7 +36442,8 @@ async function runResearch(input, keys, options) {
36187
36442
  };
36188
36443
  const unresolved = rows.filter((row) => row.url === null && row.url_hint !== void 0).length;
36189
36444
  const drKey = keys.get("x-ahrefs-key") ?? options.drKey;
36190
- const rated = await withDomainRating(rows, drKey);
36445
+ const byDr = input.action === "link_gap" && input.provider === "dataforseo" && drKey;
36446
+ const rated = byDr ? await sortedByDomainRating(rows, drKey) : await withDomainRating(rows, drKey, offset);
36191
36447
  const targetDr = target ? (await domainRatings([target], drKey)).get(bareDomain(target)) : void 0;
36192
36448
  const hasTargetDr = targetDr !== void 0 && targetDr !== null;
36193
36449
  return {
@@ -36203,6 +36459,8 @@ async function runResearch(input, keys, options) {
36203
36459
  ...partial ? ["Partial: DataForSEO could not fetch some pages of results, so rows may be missing."] : [],
36204
36460
  ...missed.length ? [`Partial: ${input.provider} failed for ${missed.join(", ")}, so those keywords have no rows. Ask for them again.`] : [],
36205
36461
  ...noData.length ? [noDataNote(input, noData)] : [],
36462
+ ...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."] : [],
36463
+ ...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(", ")}.`] : [],
36206
36464
  ...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.`] : [],
36207
36465
  ...unresolved ? [unresolvedNote(input, unresolved, rows.length)] : [],
36208
36466
  ...note ? [note] : [],
@@ -36226,7 +36484,8 @@ async function runResearch(input, keys, options) {
36226
36484
  noDataKeywords: noData,
36227
36485
  ...input.action === "link_gap" ? {
36228
36486
  competitors: input.competitors ?? [],
36229
- cutAt: floor ?? null
36487
+ cutAt: floor ?? null,
36488
+ leftOut: leftOut ?? []
36230
36489
  } : {},
36231
36490
  total: rated.rows.length,
36232
36491
  offset,
@@ -36242,6 +36501,24 @@ function noDataNote(input, keywords) {
36242
36501
  function unresolvedNote(input, count, total) {
36243
36502
  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." : ""}`;
36244
36503
  }
36504
+ const GAP_RATED = 200;
36505
+ async function sortedByDomainRating(rows, key) {
36506
+ const top = rows.slice(0, GAP_RATED);
36507
+ const ratings = await domainRatings(top.map((row) => String(row.domain ?? "")), key, GAP_RATED);
36508
+ const withDr = (row, rated) => {
36509
+ const { domain = null, ...rest } = row;
36510
+ return {
36511
+ domain,
36512
+ domain_rating: rated ? ratings.get(bareDomain(String(domain ?? ""))) ?? null : null,
36513
+ ...rest
36514
+ };
36515
+ };
36516
+ const dr = (row) => Number(row.domain_rating ?? -1);
36517
+ return {
36518
+ 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))],
36519
+ added: true
36520
+ };
36521
+ }
36245
36522
  // packages/core/src/commands/sitemap-urls.ts
36246
36523
  const MAX_FILES = 20;
36247
36524
  const MAX_URLS = 5e4;
@@ -37103,7 +37380,7 @@ const SHARED_TOOLS = [
37103
37380
  offset: input.offset,
37104
37381
  questions: input.keywords,
37105
37382
  tasks: input.tasks
37106
- }, await surface.keys(), surface.deps, more);
37383
+ }, 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`);
37107
37384
  }
37108
37385
  return runResearch(input, await surface.keys(), {
37109
37386
  drKey: surface.drKey,
@@ -39947,6 +40224,6 @@ function isoDate(daysAgo = 0) {
39947
40224
  date.setUTCDate(date.getUTCDate() - daysAgo);
39948
40225
  return date.toISOString().slice(0, 10);
39949
40226
  }
39950
- export { AHREFS_API_KEY_ENV, AHREFS_API_KEY_SECRET, AI_REFERRAL_SOURCES, AI_REFERRAL_SOURCES_VERSION, ANALYTICS_PROVIDER_IDS, AUDIT_PAGE_RULE_IDS, AhrefsClient, BING_API_KEY_ENV, BING_API_KEY_SECRET, BROWSER_USER_AGENT, CANNIBAL_MAXIMUM_DOMINANT_SHARE, CANNIBAL_MINIMUM_PAGE_IMPRESSIONS, CANNIBAL_MINIMUM_PAGE_SHARE, CLICKY_SITEKEY_ENV, CLICKY_SITE_KEYS_SECRET, CLOUD_SIGN_IN, CLOUD_URL, CRAWL_SKIP_REASONS, CTR_DEFAULT_LIMIT, CTR_MAX_LIMIT, CTR_MAX_MIN_IMPRESSIONS, DATAFORSEO_CREDENTIAL_SECRET, DATAFORSEO_LOGIN_ENV, DATAFORSEO_PASSWORD_ENV, DEFAULT_MAX_PAGES, DEFAULT_SEARCH_CONSOLE_EXPORT_ROW_LIMIT, DEFAULT_UNREACHED_PAGE_LIMIT, DataForSeoClient, DecisionModelError, DecisionRunner, GSC_FINAL_DATA_LAG_DAYS, GSC_TIME_ZONE, GoogleApiError, INCIDENTS_URL, JsonFileStorageAdapter, LINK_ROLES, LINT_MAX_FILES, LOCAL_BING_CONNECT, LOCAL_SIGN_IN, LOG_MAX_AGE_MS, LOG_MAX_FILE_BYTES, LOG_MAX_TOTAL_BYTES, LocalCredentialsProvider, MANAGED_PROVIDER_SECRET_NAMES, MAX_CRAWL_CONCURRENCY, MAX_CRAWL_DEPTH, MAX_CRAWL_PAGES, MAX_CRAWL_TIMEOUT_MS, MAX_MAX_PAGES, MAX_SEARCH_CONSOLE_EXPORT_FILES, MAX_SEARCH_CONSOLE_EXPORT_ROW_LIMIT, PAGE_TYPES, PROVIDER_ERROR_CODES, PROVIDER_SECRET_NAMES, ProviderError, ProviderExtensionRegistry, RESEARCH_IMPORT_COLUMN_KEYS, ResponseSizeLimitError, SEARCH_INTENTS, SEO_CRAWLER_IDENTITY, SEO_CRAWLER_TOKEN, SEO_CRAWLER_USER_AGENT, SEO_PROVIDER_API_VERSION, SEO_VERSION, SERP_TITLE_REFERENCE_WIDTH_PX, SERP_TITLE_WIDTH_PROFILE, SITEMAP_FILE_URL_LIMIT, SITE_CHECK_MAX_ISSUES, SITE_CHECK_MAX_PAGES, STEP_CHANGE_RULES, SeoError, ServiceAccountAccessTokenClient, SessionLedger, TELEMETRY_AGENTS, TELEMETRY_ENDPOINT, TELEMETRY_ERROR_CATEGORIES, TELEMETRY_EVENTS, TELEMETRY_FAILURE_CONTEXTS, TELEMETRY_FAILURE_REASONS, TELEMETRY_NOTICE, TELEMETRY_OPERATIONS, TELEMETRY_REPORTS, TELEMETRY_SCHEMA_VERSION, TELEMETRY_TIMEOUT_MS, TYPESAFE_API_KEY_ENV, agentActionsView, aiReferralSourceForValue, aiReferralsReport, analyticsConnectionLabel, analyticsConnectionProviderId, analyticsConnectionSchema, analyticsProviderDetails, analyticsProviderSchema, analyzeAiReferralRows, analyzeCannibalImportRows, analyzeCannibalRows, analyzeCtrUnderperformersFromRows, analyzeDecay, analyzeIndexCoverageSignals, analyzePageOpportunitiesFromRows, analyzeQueryClustersFromRows, analyzeQuickWinsFromRows, analyzeSecondPageRows, analyzeSegmentRows, analyzeStrikingDistanceRows, analyzeTrafficAnomalyRows, assertCrawlConfigLimits, assertUrlMatchesGscProperty, auditCrawlPages, auditCrawlRequests, auditPage, authStatus, bearerFetch, boundedSitemapInventory, buildAgentFindings, buildQueryClusterReportFromRows, buildTelemetryPayload, builtPagePath, cacheDb, cannibalDataStatus, cannibalImportReport, cannibalReport, cannibalReportWith, changeWindow, checkBuiltSite, checkDatabaseReadiness, classifyTelemetryFailure, clearCache, cloudAccountFor, cloudBing, cloudCredentials, cloudGoogleEmails, cloudGoogleToken, cloudKeys, cloudLinked, cloudLogout, cloudWeek, clusterQueryRows, combineRobotsValues, compareSegmentRows, configSchema, contentCoverageRecommendation, contentCoverageType, contentOptimizationFromPageOpportunities, contentOptimizationReport, countLabel, crawlConfigHash, crawlConfigSchema, crawlDefinitionId, crawlFixToQueueItem, crawlImplementationQueueWorkflow, crawlIssueGroupSchema, crawlIssueSchema, crawlOne, crawlPageSnapshotSchema, crawlReportSchema, crawlReportSummarySchema, crawlRequestObservationSchema, crawlRunId, crawlSite, crawlSkipImpact, crawlSkipReasonCountsFromRecord, crawlStatusOnly, crawlTopFixSchema, crawlerJsonSchemas, crawlerRuleCategorySchema, crawlerRuleIdSchema, crawlerRuleInfoSchema, crawlerRuleRecommendationSchema, crawlerRuleSeveritySchema, crawlerSchemas, createAuthorizedClient, createCrawlReport, createCtrBenchmarkContext, createGoogleAccessTokenClient, createLogger, createPageRenderer, ctrUnderperformersReport, ctrUnderperformersReportWith, decayClusterDrafts, decayComparisonRange, decayingReport, decayingReportWith, deleteAhrefsApiKey, deleteBingApiKey, deleteClickySiteKey, deleteDataForSeoCredentials, deleteManagedProviderSecrets, deleteProviderExtensionAccount, deleteProviderExtensionCredentials, deleteProviderSecret, deleteTokens, deriveBrandTerms, describeInstalledProviderExtension, detectAgentName, detectBrandTerms, detectTelemetryAgent, diagnoseCsvFiles, diagnoseProperty, diagnosePropertyWorkflow, effectiveRobotsDirectives, effectiveSnippetControl, ensurePrivateDatabaseFiles, ensureSeoCliDirs, estimateSerpTitleWidth, expectedCtrForPosition, explainRule, fetchCruxFieldData, fetchPage, fetchPageStatus, fetchSearchStatusIncidents, fetchSitemapUrls, fileSize, finalGscDateRange, findOverlappingSearchUpdates, finishCloudLogin, formatRelativeExpiry, ga4MatchReason, ga4PropertyIdFromName, ga4ReportQualityWarnings, ga4RequestCanUseCache, ga4RowsToObjects, getAuthModeStatus, getCacheStats, getClientConfig, getDb, getPrivacySnapshot, getSeoCliPaths, getServiceAccountConfig, getServiceAccountStatus, getTelemetryStatus, getTokenStorageStatus, googleJson, groupCrawlIssues, hasMetaRobotsDirective, hasRule, hasXRobotsDirective, hashKey, indexCoverageSignals, inferTemplateMovement, initializeTelemetry, inspectProviderPackage, inspectSearchConsoleUrl, inspectUrl, installProviderPackage, installedProviderPackageSchema, installedProviderPackagesSchema, internalLinksReport, internalLinksReportWith, interpretUpdateCorrelation, isBrandQuery, isNewerVersion, isSkippableReportError, isTelemetryCi, isTelemetryOperation, isTelemetryReport, isoDate, isoWeek, latestFinalGscDate, linkRecover, linkRecoverRecommendation, lintPaths, listAnalyticsAccounts, listAnalyticsDataStreams, listGa4AccountSummaries, listGa4DataStreams, listGoogleAccounts, listInstalledProviderExtensions, listRules, listSearchConsoleSites, listSearchUpdates, listSites, loadInstalledProviderExtensions, loadProviderExtensions, loadProviderPackage, loadSearchConsoleExport, localAccounts, localBing, localCredentialsProvider, localError, localGoogleFetch, localInternalLinksDependencies, localRefreshClicky, localResearchKeys, loginWithLoopback, makeRunId, matchGa4WebStreams, measureCoverage, metaRobotsDirectives, migrateLegacyMacConfig, missingOAuthClientMessage, missingSharedClientLoginMessage, nodeCommandDeps, normalizeBrandText, normalizeClickySiteId, normalizeClickySiteKey, normalizeCrawlConfig, normalizeCrawlSkipReasonCounts, normalizeForCoverage, normalizeHttpUrl, normalizeJavaScriptRenderingMode, normalizeLoadedCrawlReport, normalizeProviderExtensionAccount, normalizedResearchColumnName, notSignedIn, noteCacheWrite, openCloudLink, pageEvidenceRecommendation, pageOpportunitiesReport, parseOlderThan, pendingCloudLogin, performanceAudit, performanceReportIsCacheable, plural, providerCapabilitySchema, providerConnectionFieldSchema, providerExactVersionSchema, providerExtensionIdSchema, providerIdSchema, providerIntegritySchema, providerKindSchema, providerPackageManifestSchema, providerPackageNameSchema, providerPreferenceSchema, providerRegistrationSchema, providerRequestJson, providerRequestText, pruneLogs, publicHttpFetch, queryClusterReport, queryClusterTokens, queryContentCoverageFromPage, queryContentFieldsFromPage, queryFieldCoverage, queryOpportunityRecommendation, queryPageMetrics, queryPageTopQuery, queryPagesMetrics, queryPagesMetricsBatch, queryPagesTopQueries, queryPagesTopQueriesBatch, querySearchAnalytics, quickWinsCsvRows, quickWinsReport, quickWinsReportWith, readAhrefsApiKey, readAllTokens, readBingApiKey, readBoundedResponseText, readClickySiteKey, readClickySiteKeys, readConfig, readDataForSeoCredentials, readInstalledProviderPackages, readJson, readJsonOrUndefined, readLintFiles, readOauthClient, readProviderExtensionAccount, readProviderExtensionCredentials, readProviderSecret, readTokens, recommendationForRule, reconcileExportPagesWithCrawl, redirectTrace, refreshAuthToken, refreshPrioritiesCsvFiles, refreshPrioritiesWorkflow, registrableDomain, removeInstalledProviderPackage, renderCrawlCsv, renderCrawlHtml, renderCrawlJunit, renderCrawlMarkdownTickets, renderCrawlPagesCsv, renderCrawlPretty, renderCsv, renderPresentationTablesMarkdown, renderWorkflowMarkdown, reportNarrative, requestHeaders, requirePageVerification, researchImportColumns, resetSeoData, resolveLocalSite, resolveSegmentRanges, resolveSeoCliPaths, reviewObservations, roundedPosition, runAnalyticsProviderLandingPages, runAnalyticsReport, runDoctor, runGa4Report, runProviderExtensionAction, runtimeForProvider, safeProviderAdapterError, saveInstalledProviderPackage, searchAnalyticsRows, searchUpdatesFromIncidents, secondPage, secondPageWith, segmentComparisonRange, segmentImpact, segmentRangeDays, seoErrorEnvelope, sessionSourceInListFilter, setActiveGoogleAccount, setTelemetryEnabled, setTokenStorageMode, shiftIsoDate, shouldExcludeBrandQuery, siteStartUrl, snapshotFetchedPage, startCloudLogin, strikingDistance, strikingDistanceWith, summarizeCrawlReport, pageTechnicalSignals as technicalPageSignals, telemetryErrorCategory, toSeoError, tokenSchema, tokenStoreSchema, topFixes, trackTelemetryCommandFailed, trackTelemetryReportComplete, trackTelemetryReportFailed, trackTelemetryReportStart, trackTelemetrySetupComplete, trafficAnomaly, trafficAnomalyWith, typesafeDecisionModel, unavailableSegmentImpactReport, unicodeTokens, uninstallProviderExtension, updateCorrelation, updateNotice, updatePostmortemCsvFiles, updatePostmortemWorkflow, urlInspectionQuota, validateResearchImportColumnSources, verifyAnalyticsProvider, verifyProviderExtension, verifyQueryContent, verifyQueryContentWith, withAgentReportContract, withRankingHistory, workflowPresentation, writeAhrefsApiKey, writeBingApiKey, writeClickySiteKey, writeConfig, writeDataForSeoCredentials, writeInstalledProviderPackages, writeOauthClient, writeProviderExtensionAccount, writeProviderExtensionCredentials, writeProviderSecret, writeToCache, writeTokens, xRobotsDirectives };
40227
+ export { AHREFS_API_KEY_ENV, AHREFS_API_KEY_SECRET, AI_REFERRAL_SOURCES, AI_REFERRAL_SOURCES_VERSION, ANALYTICS_PROVIDER_IDS, AUDIT_PAGE_RULE_IDS, AhrefsClient, BING_API_KEY_ENV, BING_API_KEY_SECRET, BROWSER_USER_AGENT, CANNIBAL_MAXIMUM_DOMINANT_SHARE, CANNIBAL_MINIMUM_PAGE_IMPRESSIONS, CANNIBAL_MINIMUM_PAGE_SHARE, CLICKY_SITEKEY_ENV, CLICKY_SITE_KEYS_SECRET, CLOUD_SIGN_IN, CLOUD_URL, CRAWL_SKIP_REASONS, CTR_DEFAULT_LIMIT, CTR_MAX_LIMIT, CTR_MAX_MIN_IMPRESSIONS, DATAFORSEO_CREDENTIAL_SECRET, DATAFORSEO_LOGIN_ENV, DATAFORSEO_PASSWORD_ENV, DEFAULT_MAX_PAGES, DEFAULT_SEARCH_CONSOLE_EXPORT_ROW_LIMIT, DEFAULT_UNREACHED_PAGE_LIMIT, DataForSeoClient, DecisionModelError, DecisionRunner, GSC_FINAL_DATA_LAG_DAYS, GSC_TIME_ZONE, GoogleApiError, INCIDENTS_URL, JsonFileStorageAdapter, LINK_ROLES, LINT_MAX_FILES, LOCAL_BING_CONNECT, LOCAL_SIGN_IN, LOG_MAX_AGE_MS, LOG_MAX_FILE_BYTES, LOG_MAX_TOTAL_BYTES, LocalCredentialsProvider, MANAGED_PROVIDER_SECRET_NAMES, MAX_CRAWL_CONCURRENCY, MAX_CRAWL_DEPTH, MAX_CRAWL_PAGES, MAX_CRAWL_TIMEOUT_MS, MAX_MAX_PAGES, MAX_SEARCH_CONSOLE_EXPORT_FILES, MAX_SEARCH_CONSOLE_EXPORT_ROW_LIMIT, PAGE_TYPES, PROVIDER_ERROR_CODES, PROVIDER_SECRET_NAMES, ProviderError, ProviderExtensionRegistry, RESEARCH_IMPORT_COLUMN_KEYS, ResponseSizeLimitError, SEARCH_INTENTS, SEO_CRAWLER_IDENTITY, SEO_CRAWLER_TOKEN, SEO_CRAWLER_USER_AGENT, SEO_PROVIDER_API_VERSION, SEO_VERSION, SERP_TITLE_REFERENCE_WIDTH_PX, SERP_TITLE_WIDTH_PROFILE, SITEMAP_FILE_URL_LIMIT, SITE_CHECK_MAX_ISSUES, SITE_CHECK_MAX_PAGES, STEP_CHANGE_RULES, SeoError, ServiceAccountAccessTokenClient, SessionLedger, TELEMETRY_AGENTS, TELEMETRY_ENDPOINT, TELEMETRY_ERROR_CATEGORIES, TELEMETRY_EVENTS, TELEMETRY_FAILURE_CONTEXTS, TELEMETRY_FAILURE_REASONS, TELEMETRY_NOTICE, TELEMETRY_OPERATIONS, TELEMETRY_REPORTS, TELEMETRY_SCHEMA_VERSION, TELEMETRY_TIMEOUT_MS, TYPESAFE_API_KEY_ENV, agentActionsView, aiReferralSourceForValue, aiReferralsReport, analyticsConnectionLabel, analyticsConnectionProviderId, analyticsConnectionSchema, analyticsProviderDetails, analyticsProviderSchema, analyzeAiReferralRows, analyzeCannibalImportRows, analyzeCannibalRows, analyzeCtrUnderperformersFromRows, analyzeDecay, analyzeIndexCoverageSignals, analyzePageOpportunitiesFromRows, analyzeQueryClustersFromRows, analyzeQuickWinsFromRows, analyzeSecondPageRows, analyzeSegmentRows, analyzeStrikingDistanceRows, analyzeTrafficAnomalyRows, assertCrawlConfigLimits, assertUrlMatchesGscProperty, auditCrawlPages, auditCrawlRequests, auditPage, authStatus, bearerFetch, boundedSitemapInventory, buildAgentFindings, buildQueryClusterReportFromRows, buildTelemetryPayload, builtPagePath, cacheDb, cannibalDataStatus, cannibalImportReport, cannibalReport, cannibalReportWith, changeWindow, checkBuiltSite, checkDatabaseReadiness, classifyTelemetryFailure, clearCache, cloudAccountFor, cloudBing, cloudCredentials, cloudGoogleEmails, cloudGoogleToken, cloudKeys, cloudLinked, cloudLogout, cloudWeek, clusterQueryRows, combineRobotsValues, compareSegmentRows, configSchema, contentCoverageRecommendation, contentCoverageType, contentOptimizationFromPageOpportunities, contentOptimizationReport, countLabel, crawlConfigHash, crawlConfigSchema, crawlCoverageCaveat, crawlDefinitionId, crawlFixToQueueItem, crawlImplementationQueueWorkflow, crawlIssueGroupSchema, crawlIssueSchema, crawlOne, crawlPageSnapshotSchema, crawlReportSchema, crawlReportSummarySchema, crawlRequestObservationSchema, crawlRunId, crawlSite, crawlSkipImpact, crawlSkipReasonCountsFromRecord, crawlStatusOnly, crawlTopFixSchema, crawlerJsonSchemas, crawlerRuleCategorySchema, crawlerRuleIdSchema, crawlerRuleInfoSchema, crawlerRuleRecommendationSchema, crawlerRuleSeveritySchema, crawlerSchemas, createAuthorizedClient, createCrawlReport, createCtrBenchmarkContext, createGoogleAccessTokenClient, createLogger, createPageRenderer, ctrUnderperformersReport, ctrUnderperformersReportWith, decayClusterDrafts, decayComparisonRange, decayingReport, decayingReportWith, deleteAhrefsApiKey, deleteBingApiKey, deleteClickySiteKey, deleteDataForSeoCredentials, deleteManagedProviderSecrets, deleteProviderExtensionAccount, deleteProviderExtensionCredentials, deleteProviderSecret, deleteTokens, deriveBrandTerms, describeInstalledProviderExtension, detectAgentName, detectBrandTerms, detectTelemetryAgent, diagnoseCsvFiles, diagnoseProperty, diagnosePropertyWorkflow, effectiveRobotsDirectives, effectiveSnippetControl, ensurePrivateDatabaseFiles, ensureSeoCliDirs, estimateSerpTitleWidth, expectedCtrForPosition, explainRule, fetchCruxFieldData, fetchPage, fetchPageStatus, fetchSearchStatusIncidents, fetchSitemapUrls, fileSize, finalGscDateRange, findOverlappingSearchUpdates, finishCloudLogin, formatRelativeExpiry, ga4MatchReason, ga4PropertyIdFromName, ga4ReportQualityWarnings, ga4RequestCanUseCache, ga4RowsToObjects, getAuthModeStatus, getCacheStats, getClientConfig, getDb, getPrivacySnapshot, getSeoCliPaths, getServiceAccountConfig, getServiceAccountStatus, getTelemetryStatus, getTokenStorageStatus, googleJson, groupCrawlIssues, hasMetaRobotsDirective, hasRule, hasXRobotsDirective, hashKey, indexCoverageSignals, inferTemplateMovement, initializeTelemetry, inspectProviderPackage, inspectSearchConsoleUrl, inspectUrl, installProviderPackage, installedProviderPackageSchema, installedProviderPackagesSchema, internalLinksReport, internalLinksReportWith, interpretUpdateCorrelation, isBrandQuery, isNewerVersion, isSkippableReportError, isTelemetryCi, isTelemetryOperation, isTelemetryReport, isoDate, isoWeek, latestFinalGscDate, linkRecover, linkRecoverRecommendation, lintPaths, listAnalyticsAccounts, listAnalyticsDataStreams, listGa4AccountSummaries, listGa4DataStreams, listGoogleAccounts, listInstalledProviderExtensions, listRules, listSearchConsoleSites, listSearchUpdates, listSites, loadInstalledProviderExtensions, loadProviderExtensions, loadProviderPackage, loadSearchConsoleExport, localAccounts, localBing, localCredentialsProvider, localError, localGoogleFetch, localInternalLinksDependencies, localRefreshClicky, localResearchKeys, loginWithLoopback, makeRunId, matchGa4WebStreams, measureCoverage, metaRobotsDirectives, migrateLegacyMacConfig, missingOAuthClientMessage, missingSharedClientLoginMessage, nodeCommandDeps, normalizeBrandText, normalizeClickySiteId, normalizeClickySiteKey, normalizeCrawlConfig, normalizeCrawlSkipReasonCounts, normalizeForCoverage, normalizeHttpUrl, normalizeJavaScriptRenderingMode, normalizeLoadedCrawlReport, normalizeProviderExtensionAccount, normalizedResearchColumnName, notSignedIn, noteCacheWrite, openCloudLink, pageEvidenceRecommendation, pageOpportunitiesReport, parseOlderThan, pendingCloudLogin, performanceAudit, performanceReportIsCacheable, plural, providerCapabilitySchema, providerConnectionFieldSchema, providerExactVersionSchema, providerExtensionIdSchema, providerIdSchema, providerIntegritySchema, providerKindSchema, providerPackageManifestSchema, providerPackageNameSchema, providerPreferenceSchema, providerRegistrationSchema, providerRequestJson, providerRequestText, pruneLogs, publicHttpFetch, queryClusterReport, queryClusterTokens, queryContentCoverageFromPage, queryContentFieldsFromPage, queryFieldCoverage, queryOpportunityRecommendation, queryPageMetrics, queryPageTopQuery, queryPagesMetrics, queryPagesMetricsBatch, queryPagesTopQueries, queryPagesTopQueriesBatch, querySearchAnalytics, quickWinsCsvRows, quickWinsReport, quickWinsReportWith, readAhrefsApiKey, readAllTokens, readBingApiKey, readBoundedResponseText, readClickySiteKey, readClickySiteKeys, readConfig, readDataForSeoCredentials, readInstalledProviderPackages, readJson, readJsonOrUndefined, readLintFiles, readOauthClient, readProviderExtensionAccount, readProviderExtensionCredentials, readProviderSecret, readTokens, recommendationForRule, reconcileExportPagesWithCrawl, redirectTrace, refreshAuthToken, refreshPrioritiesCsvFiles, refreshPrioritiesWorkflow, registrableDomain, removeInstalledProviderPackage, renderCrawlCsv, renderCrawlHtml, renderCrawlJunit, renderCrawlMarkdownTickets, renderCrawlPagesCsv, renderCrawlPretty, renderCsv, renderPresentationTablesMarkdown, renderWorkflowMarkdown, reportNarrative, requestHeaders, requirePageVerification, researchImportColumns, resetSeoData, resolveLocalSite, resolveSegmentRanges, resolveSeoCliPaths, reviewObservations, roundedPosition, runAnalyticsProviderLandingPages, runAnalyticsReport, runDoctor, runGa4Report, runProviderExtensionAction, runtimeForProvider, safeProviderAdapterError, saveInstalledProviderPackage, searchAnalyticsRows, searchUpdatesFromIncidents, secondPage, secondPageWith, segmentComparisonRange, segmentImpact, segmentRangeDays, seoErrorEnvelope, sessionSourceInListFilter, setActiveGoogleAccount, setTelemetryEnabled, setTokenStorageMode, shiftIsoDate, shouldExcludeBrandQuery, siteStartUrl, snapshotFetchedPage, startCloudLogin, strikingDistance, strikingDistanceWith, summarizeCrawlReport, pageTechnicalSignals as technicalPageSignals, telemetryErrorCategory, toSeoError, tokenSchema, tokenStoreSchema, topFixes, trackTelemetryCommandFailed, trackTelemetryReportComplete, trackTelemetryReportFailed, trackTelemetryReportStart, trackTelemetrySetupComplete, trafficAnomaly, trafficAnomalyWith, typesafeDecisionModel, unavailableSegmentImpactReport, unicodeTokens, uninstallProviderExtension, updateCorrelation, updateNotice, updatePostmortemCsvFiles, updatePostmortemWorkflow, urlInspectionQuota, validateResearchImportColumnSources, verifyAnalyticsProvider, verifyProviderExtension, verifyQueryContent, verifyQueryContentWith, withAgentReportContract, withRankingHistory, workflowPresentation, writeAhrefsApiKey, writeBingApiKey, writeClickySiteKey, writeConfig, writeDataForSeoCredentials, writeInstalledProviderPackages, writeOauthClient, writeProviderExtensionAccount, writeProviderExtensionCredentials, writeProviderSecret, writeToCache, writeTokens, xRobotsDirectives };
39951
40228
 
39952
40229
  //# sourceMappingURL=index.js.map