seo 0.17.1 → 0.17.2

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-G8B5TPzB.js";
2
+ import { n as version, t as load } from "./esm-BR8_7rhM.js";
3
3
  import { SEO_PROVIDER_API_VERSION } from "./provider-sdk.js";
4
4
  import crypto$1, { createHash, randomUUID, sign } from "node:crypto";
5
5
  import { z } from "zod";
@@ -453,13 +453,20 @@ const RULE_DEFINITIONS = [
453
453
  title: "Large HTML response",
454
454
  category: "performance",
455
455
  defaultSeverity: "low",
456
- whyItMatters: "Large HTML takes longer to download and parse before the browser can even start the rest of the page.",
457
- howToFix: "Trim duplicated markup, remove heavy inline data, split non-critical content, and move large scripts or styles out of the HTML response.",
458
- impactIfIgnored: "Pages can feel slow on mobile connections and crawlers spend more time processing each URL.",
459
- howToVerify: "Re-run the crawl and confirm sizeBytes is below the large HTML threshold for normal pages.",
456
+ whyItMatters: "Googlebot reads only the first 2MB of uncompressed HTML for Google Search and indexes the page as if the file ended there. When the h1 or main element, or most of the visible text, sits past that cut, Google may index the page without its main content. Large HTML is also slow to download and parse.",
457
+ howToFix: "Move the main content earlier and trim what comes before it: large inline CSS, inline SVG, JSON data and duplicated markup. Load non-critical styles and scripts as separate files.",
458
+ impactIfIgnored: "Content past the 2MB cut is not indexed, and every page of the template carries the same weight on slow connections.",
459
+ howToVerify: "Re-run the crawl and confirm sizeBytes is under 2,097,152, or that mainContentStartBytes is well before it and few visible words sit after it. The word split is a heuristic from the raw HTML.",
460
460
  agentHints: {
461
- evidenceFields: ["page.sizeBytes", "issue.evidence.thresholdBytes"],
462
- suggestedCommands: ["seo crawl <url> --json"]
461
+ evidenceFields: [
462
+ "page.sizeBytes",
463
+ "issue.evidence.thresholdBytes",
464
+ "issue.evidence.mainContentStartBytes",
465
+ "issue.evidence.bytesAfterLimit",
466
+ "issue.evidence.visibleWordsAfterLimit",
467
+ "issue.evidence.indexingRisk"
468
+ ],
469
+ suggestedCommands: ["seo crawl <url> --mode page --json"]
463
470
  }
464
471
  },
465
472
  {
@@ -1097,17 +1104,17 @@ function isAgentFindings(value) {
1097
1104
  const counts = record(candidate?.counts);
1098
1105
  return candidate?.schemaVersion === 1 && candidate.scope === "returned-report" && typeof counts?.total === "number" && typeof counts.returned === "number" && Array.isArray(candidate.items) && candidate.items.every((value) => {
1099
1106
  const item = record(value);
1100
- if (!text(item?.id) || item?.status !== "open" || !text(item.title) || !text(item.sourcePath) || !Array.isArray(item.allowedOutcomes)) return false;
1101
- if (item.type === "fix") return Boolean(text(record(item.fix)?.instruction));
1107
+ if (!text$1(item?.id) || item?.status !== "open" || !text$1(item.title) || !text$1(item.sourcePath) || !Array.isArray(item.allowedOutcomes)) return false;
1108
+ if (item.type === "fix") return Boolean(text$1(record(item.fix)?.instruction));
1102
1109
  if (item.type === "review") {
1103
1110
  const review = record(item.review);
1104
1111
  if (!review) return false;
1105
- return Boolean(text(review.question) && text(review.changeOnlyIf) && text(review.ifConfirmed) && text(review.ifNotNeeded) && Array.isArray(review.doNot));
1112
+ return Boolean(text$1(review.question) && text$1(review.changeOnlyIf) && text$1(review.ifConfirmed) && text$1(review.ifNotNeeded) && Array.isArray(review.doNot));
1106
1113
  }
1107
1114
  return false;
1108
1115
  });
1109
1116
  }
1110
- function text(value) {
1117
+ function text$1(value) {
1111
1118
  return typeof value === "string" && value.trim() ? value.trim() : void 0;
1112
1119
  }
1113
1120
  function stableId(value) {
@@ -1130,7 +1137,7 @@ function findingId(item, path, action) {
1130
1137
  "actionId",
1131
1138
  "id"
1132
1139
  ]) {
1133
- const id = text(item[key]);
1140
+ const id = text$1(item[key]);
1134
1141
  if (id) return id;
1135
1142
  }
1136
1143
  return `action-${stableId(`${path}|${action}`)}`;
@@ -1140,9 +1147,9 @@ function findingType(path, item) {
1140
1147
  }
1141
1148
  function findingVerification(item, fallback) {
1142
1149
  const supplied = record(item.verification);
1143
- const expected = text(supplied?.expected ?? item.howToVerify ?? fallback);
1150
+ const expected = text$1(supplied?.expected ?? item.howToVerify ?? fallback);
1144
1151
  if (!expected) return void 0;
1145
- const command = text(supplied?.command);
1152
+ const command = text$1(supplied?.command);
1146
1153
  return {
1147
1154
  ...command ? { command } : {},
1148
1155
  expected
@@ -1150,14 +1157,14 @@ function findingVerification(item, fallback) {
1150
1157
  }
1151
1158
  function stringArray(value) {
1152
1159
  if (!Array.isArray(value)) return [];
1153
- return value.flatMap((item) => text(item) ? [text(item)] : []);
1160
+ return value.flatMap((item) => text$1(item) ? [text$1(item)] : []);
1154
1161
  }
1155
1162
  function affectedUrls(item, affectedCount) {
1156
1163
  const items = stringArray(item.sampleUrls);
1157
1164
  const details = record(item.affectedUrlsReport);
1158
1165
  const params = record(details?.params);
1159
- const reportId = details?.id === "affected-urls" ? text(params?.reportId) : void 0;
1160
- const ruleId = details?.id === "affected-urls" ? text(params?.ruleId) : void 0;
1166
+ const reportId = details?.id === "affected-urls" ? text$1(params?.reportId) : void 0;
1167
+ const ruleId = details?.id === "affected-urls" ? text$1(params?.ruleId) : void 0;
1161
1168
  if (!items.length && !reportId) return void 0;
1162
1169
  const total = affectedCount ?? items.length;
1163
1170
  return {
@@ -1177,13 +1184,13 @@ function affectedUrls(item, affectedCount) {
1177
1184
  function reviewGuidance(item, title, action) {
1178
1185
  const supplied = record(item.review);
1179
1186
  const evidence = record(item.evidence);
1180
- const ruleId = text(item.ruleId ?? evidence?.ruleId);
1187
+ const ruleId = text$1(item.ruleId ?? evidence?.ruleId);
1181
1188
  const defined = (ruleId ? explainRule(ruleId) : void 0)?.review;
1182
1189
  return {
1183
- question: text(supplied?.question) ?? defined?.question ?? `Does the retained evidence and confirmed intent show that "${title}" needs a change?`,
1184
- changeOnlyIf: text(supplied?.changeOnlyIf) ?? defined?.changeOnlyIf ?? "Make a change only when the retained evidence and required intent support it.",
1185
- ifConfirmed: text(supplied?.ifConfirmed) ?? action,
1186
- ifNotNeeded: text(supplied?.ifNotNeeded) ?? defined?.ifNotNeeded ?? "Record no change and cite the evidence or confirmed intent that makes a change unnecessary.",
1190
+ question: text$1(supplied?.question) ?? defined?.question ?? `Does the retained evidence and confirmed intent show that "${title}" needs a change?`,
1191
+ changeOnlyIf: text$1(supplied?.changeOnlyIf) ?? defined?.changeOnlyIf ?? "Make a change only when the retained evidence and required intent support it.",
1192
+ ifConfirmed: text$1(supplied?.ifConfirmed) ?? action,
1193
+ ifNotNeeded: text$1(supplied?.ifNotNeeded) ?? defined?.ifNotNeeded ?? "Record no change and cite the evidence or confirmed intent that makes a change unnecessary.",
1187
1194
  doNot: stringArray(supplied?.doNot).length ? stringArray(supplied?.doNot) : [...defined?.doNot ?? []]
1188
1195
  };
1189
1196
  }
@@ -1202,7 +1209,7 @@ function findingEvidence(item) {
1202
1209
  }
1203
1210
  function normalizeFinding(value, path, index, options) {
1204
1211
  if (typeof value === "string") {
1205
- const action = text(value);
1212
+ const action = text$1(value);
1206
1213
  if (!action) return void 0;
1207
1214
  const common = {
1208
1215
  id: `action-${stableId(`${options.reportId ?? ""}|${path}|${action}`)}`,
@@ -1234,8 +1241,8 @@ function normalizeFinding(value, path, index, options) {
1234
1241
  }
1235
1242
  const item = record(value);
1236
1243
  if (!item) return void 0;
1237
- const action = text(item.action) ?? text(item.howToFix) ?? (item.recommendation === "fix" || item.recommendation === "review" ? void 0 : text(item.recommendation)) ?? text(item.nextStep);
1238
- const title = text(item.title) ?? text(item.headline) ?? text(item.name) ?? action;
1244
+ const action = text$1(item.action) ?? text$1(item.howToFix) ?? (item.recommendation === "fix" || item.recommendation === "review" ? void 0 : text$1(item.recommendation)) ?? text$1(item.nextStep);
1245
+ const title = text$1(item.title) ?? text$1(item.headline) ?? text$1(item.name) ?? action;
1239
1246
  if (!action || !title) return void 0;
1240
1247
  const confidence = [
1241
1248
  "high",
@@ -1253,7 +1260,7 @@ function normalizeFinding(value, path, index, options) {
1253
1260
  status: "open",
1254
1261
  title,
1255
1262
  ...confidence ? { confidence } : {},
1256
- ...text(item.severity) ? { severity: text(item.severity) } : {},
1263
+ ...text$1(item.severity) ? { severity: text$1(item.severity) } : {},
1257
1264
  ...typeof affectedCount === "number" && Number.isFinite(affectedCount) ? { affectedCount } : {},
1258
1265
  ...urls ? { affectedUrls: urls } : {},
1259
1266
  sourcePath: `${path}[${index}]`,
@@ -1315,7 +1322,7 @@ function inventory(value, path, reportId) {
1315
1322
  const totalItems = typeof (source.totalPages ?? source.totalItems) === "number" ? Number(source.totalPages ?? source.totalItems) : source.rows.length;
1316
1323
  const items = source.rows.map((value, index) => {
1317
1324
  const row = record(value) ?? { value };
1318
- const identity = text(row.id) ?? text(row.url) ?? text(row.path) ?? `${path}[${index}]`;
1325
+ const identity = text$1(row.id) ?? text$1(row.url) ?? text$1(row.path) ?? `${path}[${index}]`;
1319
1326
  return {
1320
1327
  ...row,
1321
1328
  id: `inventory-item-${stableId(`${reportId ?? ""}|${path}|${identity}`)}`,
@@ -1334,7 +1341,7 @@ function inventory(value, path, reportId) {
1334
1341
  returnedItems: items.length,
1335
1342
  complete: source.capped === false && items.length >= totalItems,
1336
1343
  ...source.criteria !== void 0 ? { criteria: source.criteria } : {},
1337
- ...text(source.note) ? { note: text(source.note) } : {},
1344
+ ...text$1(source.note) ? { note: text$1(source.note) } : {},
1338
1345
  ...pageCount !== void 0 ? { pagination: {
1339
1346
  page,
1340
1347
  pageCount,
@@ -2802,7 +2809,7 @@ async function readableSites(google, signIn) {
2802
2809
  permissionLevel: entry.permissionLevel ?? ""
2803
2810
  })).sort((a, b) => a.siteUrl.localeCompare(b.siteUrl));
2804
2811
  }
2805
- const hostOf$3 = (value) => {
2812
+ const hostOf$4 = (value) => {
2806
2813
  try {
2807
2814
  return new URL(value.includes("://") ? value : `https://${value.replace(/^sc-domain:/, "")}`).hostname.replace(/^www\./, "");
2808
2815
  } catch {
@@ -2820,14 +2827,14 @@ async function runSites(input) {
2820
2827
  };
2821
2828
  })), input.bing ? listBingSites(input.bing).catch((error) => error instanceof Error ? error : new Error(String(error))) : Promise.resolve(null)]);
2822
2829
  const bingSites = Array.isArray(bing) ? bing.filter((site) => site.verified) : [];
2823
- const bingByHost = new Map(bingSites.map((site) => [hostOf$3(site.url), site]));
2830
+ const bingByHost = new Map(bingSites.map((site) => [hostOf$4(site.url), site]));
2824
2831
  const properties = google.flatMap((account) => account.properties instanceof Error ? [] : account.properties.map((property) => ({
2825
2832
  ...property,
2826
2833
  ...named ? { account: account.email ?? "" } : {},
2827
- ...Array.isArray(bing) ? { bing: bingByHost.get(hostOf$3(property.siteUrl))?.url ?? "" } : {}
2834
+ ...Array.isArray(bing) ? { bing: bingByHost.get(hostOf$4(property.siteUrl))?.url ?? "" } : {}
2828
2835
  })));
2829
- const googleHosts = new Set(properties.map((property) => hostOf$3(property.siteUrl)));
2830
- const bingOnly = bingSites.filter((site) => !googleHosts.has(hostOf$3(site.url)));
2836
+ const googleHosts = new Set(properties.map((property) => hostOf$4(property.siteUrl)));
2837
+ const bingOnly = bingSites.filter((site) => !googleHosts.has(hostOf$4(site.url)));
2831
2838
  const failed = google.filter((account) => account.properties instanceof Error);
2832
2839
  const analytics = google.flatMap((account) => (account.analytics ?? []).flatMap((summary) => summary.propertySummaries.map((property) => ({
2833
2840
  property: property.property.replace("properties/", ""),
@@ -11477,6 +11484,98 @@ function contentOptimizationFromPageOpportunities(sourceReport) {
11477
11484
  async function contentOptimizationReport(input) {
11478
11485
  return contentOptimizationFromPageOpportunities(await pageOpportunitiesReport(input));
11479
11486
  }
11487
+ // packages/core/src/analyze/crawler/html-size.ts
11488
+ const GOOGLEBOT_HTML_LIMIT_BYTES = 2097152;
11489
+ const NEAR_LIMIT_SHARE = .9;
11490
+ const HIDDEN_BLOCKS = /<(script|style|svg|noscript|template)\b[\s\S]*?(?:<\/\1\s*>|$)|<!--[\s\S]*?(?:-->|$)/gi;
11491
+ function wordCount(text) {
11492
+ return text.match(/[^\s&;]+/g)?.length ?? 0;
11493
+ }
11494
+ /** Where a page over Googlebot's limit puts its main content. */
11495
+ function googlebotCutEvidence(html) {
11496
+ const bytes = Buffer.from(html, "utf8");
11497
+ if (bytes.byteLength <= 2097152) return void 0;
11498
+ const marker = /<(h1|main)[\s>/]/i.exec(html);
11499
+ const text = html.replace(HIDDEN_BLOCKS, (match) => " ".repeat(match.length)).replace(/<[^>]*>?/g, (match) => " ".repeat(match.length));
11500
+ const limitIndex = bytes.subarray(0, GOOGLEBOT_HTML_LIMIT_BYTES).toString("utf8").length;
11501
+ const startBytes = marker ? Buffer.byteLength(html.slice(0, marker.index)) : void 0;
11502
+ return {
11503
+ limitBytes: GOOGLEBOT_HTML_LIMIT_BYTES,
11504
+ bytesAfterLimit: bytes.byteLength - GOOGLEBOT_HTML_LIMIT_BYTES,
11505
+ ...marker && startBytes !== void 0 ? {
11506
+ mainContentStartBytes: startBytes,
11507
+ mainContentMarker: marker[1]?.toLowerCase()
11508
+ } : {},
11509
+ mainContentStart: startBytes === void 0 ? "not-found" : startBytes >= 2097152 ? "after-limit" : startBytes >= 2097152 * NEAR_LIMIT_SHARE ? "near-limit" : "before-limit",
11510
+ visibleWordsBeforeLimit: wordCount(text.slice(0, limitIndex)),
11511
+ visibleWordsAfterLimit: wordCount(text.slice(limitIndex))
11512
+ };
11513
+ }
11514
+ const formatBytes = (value) => value.toLocaleString("en-US");
11515
+ function largeHtmlIssue(rule, page) {
11516
+ const sizeBytes = page.sizeBytes ?? 0;
11517
+ const cut = page.googlebotCut;
11518
+ const htmlSource = page.usedJs ? "rendered" : "raw";
11519
+ const base = {
11520
+ ...rule,
11521
+ detail: `${Math.round(sizeBytes / 1024)} KB`,
11522
+ evidence: {
11523
+ sizeBytes,
11524
+ thresholdBytes: GOOGLEBOT_HTML_LIMIT_BYTES,
11525
+ htmlSource
11526
+ }
11527
+ };
11528
+ if (!cut) return base;
11529
+ const words = cut.visibleWordsBeforeLimit + cut.visibleWordsAfterLimit;
11530
+ const wordsAfterShare = words ? Math.round(cut.visibleWordsAfterLimit / words * 100) / 100 : void 0;
11531
+ const indexingRisk = htmlSource === "rendered" ? "unknown" : cut.mainContentStart === "after-limit" || cut.mainContentStart === "near-limit" || (wordsAfterShare ?? 0) > .5 ? "main-content-past-limit" : "none-found";
11532
+ const start = cut.mainContentStartBytes === void 0 ? "no <h1> or <main> found" : `main content (<${cut.mainContentMarker}>) starts at byte ${formatBytes(cut.mainContentStartBytes)}, ${cut.mainContentStart === "after-limit" ? "after" : cut.mainContentStart === "near-limit" ? "just before" : "before"} the cut`;
11533
+ const wordsNote = wordsAfterShare === void 0 ? "" : `, with about ${Math.round(wordsAfterShare * 100)}% of the visible words`;
11534
+ return {
11535
+ ...base,
11536
+ severity: indexingRisk === "main-content-past-limit" ? "high" : base.severity,
11537
+ detail: `${Math.round(sizeBytes / 1024)} KB; ${start}; ${formatBytes(cut.bytesAfterLimit)} bytes sit past Googlebot's ${formatBytes(cut.limitBytes)}-byte cut${wordsNote}.${indexingRisk === "main-content-past-limit" ? " Googlebot stops reading at the cut and indexes only what came before it (the word split is a heuristic)." : ""}`,
11538
+ evidence: {
11539
+ ...base.evidence,
11540
+ googlebotLimitBytes: cut.limitBytes,
11541
+ bytesAfterLimit: cut.bytesAfterLimit,
11542
+ mainContentStartBytes: cut.mainContentStartBytes ?? null,
11543
+ mainContentMarker: cut.mainContentMarker ?? null,
11544
+ mainContentStart: cut.mainContentStart,
11545
+ visibleWordsBeforeLimit: cut.visibleWordsBeforeLimit,
11546
+ visibleWordsAfterLimit: cut.visibleWordsAfterLimit,
11547
+ visibleWordsAfterLimitShare: wordsAfterShare ?? null,
11548
+ indexingRisk
11549
+ }
11550
+ };
11551
+ }
11552
+ const RESPONSE_CAP_ERROR = /^Page response exceeds the (\d+)-byte response limit/;
11553
+ function responseCapIssue(request) {
11554
+ if (request.outcome !== "failure") return void 0;
11555
+ const capBytes = Number(RESPONSE_CAP_ERROR.exec(request.error)?.[1]);
11556
+ if (!capBytes) return void 0;
11557
+ const rule = explainRule("large_html");
11558
+ if (!rule) throw new Error("Missing rule guidance for large_html");
11559
+ return {
11560
+ ruleId: rule.id,
11561
+ title: rule.title,
11562
+ category: rule.category,
11563
+ severity: rule.defaultSeverity,
11564
+ url: request.requestedUrl,
11565
+ detail: `Over ${Math.round(capBytes / 1024)} KB; the crawler stopped reading at its ${formatBytes(capBytes)}-byte cap, so where the main content starts is unknown.`,
11566
+ evidence: {
11567
+ dataStatus: "partial",
11568
+ sizeBytes: null,
11569
+ sizeBytesAtLeast: capBytes,
11570
+ thresholdBytes: GOOGLEBOT_HTML_LIMIT_BYTES,
11571
+ googlebotLimitBytes: GOOGLEBOT_HTML_LIMIT_BYTES,
11572
+ bytesAfterLimitAtLeast: capBytes - GOOGLEBOT_HTML_LIMIT_BYTES,
11573
+ mainContentStartBytes: null,
11574
+ mainContentStart: "unknown",
11575
+ indexingRisk: "unknown"
11576
+ }
11577
+ };
11578
+ }
11480
11579
  // packages/core/src/analyze/crawler/page-eligibility.ts
11481
11580
  function comparableUrl(value) {
11482
11581
  if (!value) return void 0;
@@ -11515,6 +11614,21 @@ function isCdnUtilityUrl(value) {
11515
11614
  return false;
11516
11615
  }
11517
11616
  }
11617
+ function globMatch(pattern, value) {
11618
+ if (!pattern.includes("*")) return value.includes(pattern);
11619
+ const parts = pattern.split("*");
11620
+ let position = 0;
11621
+ for (const part of parts) {
11622
+ if (!part) continue;
11623
+ const found = value.slice(position).indexOf(part);
11624
+ if (found === -1) return false;
11625
+ position += found + part.length;
11626
+ }
11627
+ return true;
11628
+ }
11629
+ function passesFilters(url, include, exclude) {
11630
+ return (!include.length || include.some((pattern) => globMatch(pattern, url))) && !exclude.some((pattern) => globMatch(pattern, url));
11631
+ }
11518
11632
  // packages/core/src/analyze/crawler/audit.ts
11519
11633
  function sameUrl(a, b) {
11520
11634
  if (!a || !b) return false;
@@ -11557,7 +11671,7 @@ function urlKey(value) {
11557
11671
  }
11558
11672
  }
11559
11673
  const SLOW_RESPONSE_MS = 2e3;
11560
- const LARGE_HTML_BYTES = 2097152;
11674
+ const LARGE_HTML_BYTES = GOOGLEBOT_HTML_LIMIT_BYTES;
11561
11675
  const COMPRESSION_MIN_BYTES = 4096;
11562
11676
  const DEEP_PAGE_DEPTH = 4;
11563
11677
  const WEAK_VALUABLE_INLINKS = 1;
@@ -11729,6 +11843,11 @@ function auditCrawlRequests(requests) {
11729
11843
  for (const request of requests) {
11730
11844
  if (request.outcome === "failure") {
11731
11845
  if (request.failureKind === "aborted") continue;
11846
+ const overCap = responseCapIssue(request);
11847
+ if (overCap) {
11848
+ issues.push(overCap);
11849
+ continue;
11850
+ }
11732
11851
  issues.push(requestIssue("connection_error", request, request.error, {
11733
11852
  outcome: request.outcome,
11734
11853
  failureKind: request.failureKind,
@@ -11841,10 +11960,7 @@ function auditCrawlPages(pages, opts = {}) {
11841
11960
  outgoingInternalCount: page.outgoingInternalCount,
11842
11961
  rendering: page.fetchDiagnostics?.rendering
11843
11962
  }));
11844
- if (isHtmlPage(page) && (page.sizeBytes ?? 0) > LARGE_HTML_BYTES) issues.push(issue$1("large_html", page, `${Math.round((page.sizeBytes ?? 0) / 1024)} KB`, {
11845
- sizeBytes: page.sizeBytes,
11846
- thresholdBytes: LARGE_HTML_BYTES
11847
- }));
11963
+ if (isHtmlPage(page) && (page.sizeBytes ?? 0) > LARGE_HTML_BYTES) issues.push(largeHtmlIssue(issue$1("large_html", page), page));
11848
11964
  if (isHtmlPage(page) && !page.compression && (page.sizeBytes ?? 0) > COMPRESSION_MIN_BYTES) issues.push(issue$1("no_compression", page, void 0, {
11849
11965
  sizeBytes: page.sizeBytes,
11850
11966
  thresholdBytes: COMPRESSION_MIN_BYTES,
@@ -12661,7 +12777,11 @@ function refreshPrioritiesCsvFiles(report) {
12661
12777
  }
12662
12778
  // packages/core/src/analyze/crawler/crawl-url-queue.ts
12663
12779
  function before(left, right) {
12664
- return left.depth < right.depth || left.depth === right.depth && left.url < right.url;
12780
+ if (left.depth !== right.depth) return left.depth < right.depth;
12781
+ const leftRank = left.rank ?? 0;
12782
+ const rightRank = right.rank ?? 0;
12783
+ if (leftRank !== rightRank) return leftRank < rightRank;
12784
+ return left.url < right.url;
12665
12785
  }
12666
12786
  var CrawlUrlQueue = class {
12667
12787
  #items = [];
@@ -13191,14 +13311,20 @@ function rateLimitRerunCommand(config, intervalMs) {
13191
13311
  return `seo crawl ${config.url}${scope}${sitemap} --max-pages ${config.maxPages} --concurrency 1 --fetch-interval-cap 1 --fetch-interval-ms ${slowMs}`;
13192
13312
  }
13193
13313
  /** Set when the sitemaps list more URLs than the page limit let the crawl read. */
13194
- function sitemapLimitCaveatInput(config, discovery, defaulted) {
13195
- if (!discovery || discovery.urlsReturned < config.maxPages || !discovery.roots.some((root) => root.possiblyTruncated)) return;
13314
+ function sitemapLimitCaveatInput(config, discovery, defaulted, sample) {
13315
+ if (!discovery) return void 0;
13316
+ const truncated = discovery.roots.some((root) => root.possiblyTruncated);
13317
+ if (!sample && (discovery.urlsReturned < config.maxPages || !truncated)) return;
13196
13318
  return {
13197
13319
  maxPages: config.maxPages,
13198
13320
  defaulted,
13199
13321
  ceiling: MAX_CRAWL_PAGES,
13200
13322
  health: config.strategy === "health",
13201
- url: config.url
13323
+ url: config.url,
13324
+ ...sample ? { sample: {
13325
+ ...sample,
13326
+ readCapped: truncated
13327
+ } } : {}
13202
13328
  };
13203
13329
  }
13204
13330
  function crawlCaveats(input) {
@@ -13210,8 +13336,9 @@ function crawlCaveats(input) {
13210
13336
  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}`);
13211
13337
  }
13212
13338
  if (input.sitemapLimit) {
13213
- const { maxPages, defaulted, ceiling, health, url } = input.sitemapLimit;
13214
- 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}`);
13339
+ const { maxPages, defaulted, ceiling, health, url, sample } = input.sitemapLimit;
13340
+ const rerun = `Raise --max-pages, up to ${ceiling.toLocaleString("en-US")}, to check more: seo crawl ${url}${health ? " --health" : ""} --max-pages ${ceiling}`;
13341
+ caveats.push(sample ? `Checked a sample of ${sample.checked.toLocaleString("en-US")} of the ${sample.read.toLocaleString("en-US")}${sample.readCapped ? "+" : ""} sitemap URLs read because the page limit is ${maxPages}${defaulted ? " by default" : ""}. The sample takes URLs in turn from each of ${sample.sections} sections (the first path segment), shortest paths first, so no one section fills the crawl.${sample.readCapped ? " The sitemaps list more URLs than the crawl read." : ""} ${rerun}` : `Checked only the first ${maxPages} sitemap URLs because the page limit is ${maxPages}${defaulted ? " by default" : ""}, and the sitemaps list more. ${rerun}`);
13215
13342
  }
13216
13343
  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.`);
13217
13344
  const renderingFallbackUrls = input.renderingFallbackUrls ?? 0;
@@ -14057,6 +14184,20 @@ const crawlPageSnapshotSchema = z.object({
14057
14184
  responseHeaders: z.record(z.string(), z.string()).optional(),
14058
14185
  responseTimeMs: z.number().optional(),
14059
14186
  sizeBytes: z.number().int().optional(),
14187
+ googlebotCut: z.object({
14188
+ limitBytes: z.number().int().positive(),
14189
+ bytesAfterLimit: z.number().int().nonnegative(),
14190
+ mainContentStartBytes: z.number().int().nonnegative().optional(),
14191
+ mainContentMarker: z.enum(["h1", "main"]).optional(),
14192
+ mainContentStart: z.enum([
14193
+ "before-limit",
14194
+ "near-limit",
14195
+ "after-limit",
14196
+ "not-found"
14197
+ ]),
14198
+ visibleWordsBeforeLimit: z.number().int().nonnegative(),
14199
+ visibleWordsAfterLimit: z.number().int().nonnegative()
14200
+ }).optional(),
14060
14201
  usedJs: z.boolean().optional(),
14061
14202
  fetchSource: z.enum([
14062
14203
  "cache",
@@ -15551,6 +15692,7 @@ async function snapshotFetchedPage(url, fetched) {
15551
15692
  responseTimeMs: fetched.diagnostics.durationMs,
15552
15693
  sizeBytes: Buffer.byteLength(fetched.html),
15553
15694
  usedJs: fetched.usedJs,
15695
+ googlebotCut: googlebotCutEvidence(fetched.html),
15554
15696
  fetchSource: fetched.diagnostics.source,
15555
15697
  cacheState: fetched.diagnostics.cache,
15556
15698
  fetchDiagnostics: fetched.diagnostics,
@@ -17996,6 +18138,74 @@ function sitemapDiscoveryStatus(roots) {
17996
18138
  if (!roots.some((root) => root.dataStatus !== "unavailable")) return "unavailable";
17997
18139
  return roots.every((root) => root.dataStatus === "complete") ? "complete" : "partial";
17998
18140
  }
18141
+ /** How many sitemap URLs a crawl reads to pick its sample from. */
18142
+ function sitemapReadLimit(maxPages) {
18143
+ return Math.min(5e4, Math.max(1e4, maxPages * 10));
18144
+ }
18145
+ function sectionOf(url) {
18146
+ try {
18147
+ return new URL(url).pathname.split("/")[1]?.toLowerCase() ?? "";
18148
+ } catch {
18149
+ return "";
18150
+ }
18151
+ }
18152
+ function pathDepth(url) {
18153
+ try {
18154
+ return new URL(url).pathname.split("/").filter(Boolean).length;
18155
+ } catch {
18156
+ return 0;
18157
+ }
18158
+ }
18159
+ /**
18160
+ * Picks up to `limit` URLs round-robin across sections (the first path
18161
+ * segment), so one large section such as a forum cannot fill a capped crawl.
18162
+ * Within a section, shallower paths come first, then URL order.
18163
+ */
18164
+ function spreadSitemapSample(urls, limit) {
18165
+ const groups = /* @__PURE__ */ new Map();
18166
+ for (const url of urls) {
18167
+ const key = sectionOf(url);
18168
+ const group = groups.get(key);
18169
+ if (group) group.push(url);
18170
+ else groups.set(key, [url]);
18171
+ }
18172
+ const ordered = [...groups.entries()].sort(([left], [right]) => left < right ? -1 : left > right ? 1 : 0).map(([, group]) => group.map((url) => ({
18173
+ url,
18174
+ depth: pathDepth(url)
18175
+ })).sort((left, right) => left.depth - right.depth || (left.url < right.url ? -1 : left.url > right.url ? 1 : 0)).map((entry) => entry.url));
18176
+ const picked = [];
18177
+ for (let index = 0; picked.length < limit; index += 1) {
18178
+ let added = false;
18179
+ for (const group of ordered) {
18180
+ const url = group[index];
18181
+ if (url === void 0) continue;
18182
+ added = true;
18183
+ picked.push(url);
18184
+ if (picked.length >= limit) break;
18185
+ }
18186
+ if (!added) break;
18187
+ }
18188
+ return {
18189
+ urls: picked,
18190
+ sections: groups.size
18191
+ };
18192
+ }
18193
+ /**
18194
+ * The sitemap URLs a capped crawl checks, leaving room for the seed URLs, and
18195
+ * how they were picked when the sitemaps listed more.
18196
+ */
18197
+ function sitemapCrawlSample(urls, maxPages, seedUrls, normalize) {
18198
+ const candidates = urls.filter((url) => !seedUrls.has(normalize(url) ?? url));
18199
+ const sample = spreadSitemapSample(candidates, Math.max(0, maxPages - seedUrls.size));
18200
+ return candidates.length > sample.urls.length ? {
18201
+ urls: sample.urls,
18202
+ caveat: {
18203
+ checked: sample.urls.length,
18204
+ read: candidates.length,
18205
+ sections: sample.sections
18206
+ }
18207
+ } : { urls: sample.urls };
18208
+ }
17999
18209
  async function sitemapSeeds(input) {
18000
18210
  const roots = [];
18001
18211
  const urls = /* @__PURE__ */ new Set();
@@ -18075,21 +18285,6 @@ function isLikelyAsset(url) {
18075
18285
  const path = new URL(url).pathname.toLowerCase();
18076
18286
  return /\.(avif|bmp|css|csv|dmg|eot|gif|gz|ico|jpeg|jpg|js|json|map|mov|mp3|mp4|ogg|otf|pdf|png|svg|tar|ttf|wav|webm|webp|woff|woff2|zip)$/.test(path);
18077
18287
  }
18078
- function globMatch(pattern, value) {
18079
- if (!pattern.includes("*")) return value.includes(pattern);
18080
- const parts = pattern.split("*");
18081
- let position = 0;
18082
- for (const part of parts) {
18083
- if (!part) continue;
18084
- const found = value.slice(position).indexOf(part);
18085
- if (found === -1) return false;
18086
- position += found + part.length;
18087
- }
18088
- return true;
18089
- }
18090
- function passesFilters(url, include, exclude) {
18091
- return (!include.length || include.some((pattern) => globMatch(pattern, url))) && !exclude.some((pattern) => globMatch(pattern, url));
18092
- }
18093
18288
  async function crawlSite(input, dependencies) {
18094
18289
  const deps = resolveCrawlSiteDependencies(dependencies);
18095
18290
  const config = assertCrawlConfigLimits(normalizeCrawlConfig(input));
@@ -18144,6 +18339,7 @@ async function crawlSite(input, dependencies) {
18144
18339
  });
18145
18340
  };
18146
18341
  let sitemapDiscovery;
18342
+ let sitemapSample;
18147
18343
  let externalLinkVerification;
18148
18344
  let statusEventChain = Promise.resolve();
18149
18345
  const emitStatus = (phase, event = {}) => {
@@ -18191,7 +18387,7 @@ async function crawlSite(input, dependencies) {
18191
18387
  const llmsTxt = aiSignals?.signals.llmsTxt;
18192
18388
  const healthSitemapUrls = healthStrategy && !config.sitemapUrl && !isCancelled() ? await robotsSession?.sitemapUrls(origin) : void 0;
18193
18389
  cancelled = isCancelled();
18194
- const enqueue = (value, depth) => {
18390
+ const enqueue = (value, depth, rank) => {
18195
18391
  const normalized = normalizeUrl$1(value, config.url);
18196
18392
  if (!normalized) {
18197
18393
  recordSkip("invalid-url");
@@ -18277,7 +18473,8 @@ async function crawlSite(input, dependencies) {
18277
18473
  queuedUrls += 1;
18278
18474
  queue.push({
18279
18475
  url: normalized,
18280
- depth
18476
+ depth,
18477
+ rank
18281
18478
  });
18282
18479
  emitStatus("url_queued", {
18283
18480
  url: normalized,
@@ -18297,12 +18494,16 @@ async function crawlSite(input, dependencies) {
18297
18494
  const sitemap = await sitemapSeeds({
18298
18495
  url: config.url,
18299
18496
  sitemapUrl: config.sitemapUrl,
18300
- maxPages: config.maxPages,
18497
+ maxPages: sitemapReadLimit(config.maxPages),
18301
18498
  declaredSitemapUrls: aiSignals?.signals.robotsTxt?.sitemapUrls ?? healthSitemapUrls ?? [],
18302
18499
  warnings,
18303
18500
  fetchSitemapUrls: deps.fetchSitemapUrls
18304
18501
  });
18305
- for (const url of sitemap.urls) enqueue(url, 0);
18502
+ const sample = sitemapCrawlSample(sitemap.urls, config.maxPages, seedUrls, (url) => normalizeUrl$1(url, config.url));
18503
+ sitemapSample = sample.caveat;
18504
+ sample.urls.forEach((url, index) => {
18505
+ enqueue(url, 0, index + 1);
18506
+ });
18306
18507
  sitemapDiscovery = sitemap.discovery;
18307
18508
  }
18308
18509
  checkMemoryPressure();
@@ -18463,7 +18664,7 @@ async function crawlSite(input, dependencies) {
18463
18664
  });
18464
18665
  continue;
18465
18666
  }
18466
- const page = result.page;
18667
+ const page = structuredClone(result.page);
18467
18668
  const rendering = page.fetchDiagnostics?.rendering;
18468
18669
  if (rendering?.status === "unavailable" && config.js === "on") throw new SeoError("INVALID_INPUT", `--rendering on needs a local Chrome or Chromium browser, and none could start. ${rendering.error ?? ""} Install Chromium, or set SEO_BROWSER_EXECUTABLE_PATH to a Chromium executable, then rerun. Use --rendering auto to accept raw HTML.`.replace(/\s+/g, " ").trim());
18469
18670
  if (rendering?.status === "unavailable" || rendering?.status === "failed") renderingFallbackUrls += 1;
@@ -18733,7 +18934,7 @@ async function crawlSite(input, dependencies) {
18733
18934
  queueSafetyInventoryCapped,
18734
18935
  originBackpressureSkippedUrls,
18735
18936
  rateLimit: rateLimit.caveatInput(config, pages.length),
18736
- sitemapLimit: sitemapLimitCaveatInput(config, sitemapDiscovery, input.maxPages === void 0),
18937
+ sitemapLimit: sitemapLimitCaveatInput(config, sitemapDiscovery, input.maxPages === void 0, sitemapSample),
18737
18938
  memoryPressureSkippedUrls,
18738
18939
  queryVariantSkippedUrls: queryVariantSkipped.size,
18739
18940
  renderingFallbackUrls,
@@ -31198,6 +31399,12 @@ function plainFor(word) {
31198
31399
  for (const [key, value] of Object.entries(PLAIN)) if (lower.startsWith(key.slice(0, Math.max(4, key.length - 2)))) return value;
31199
31400
  return "a plain word";
31200
31401
  }
31402
+ function swapFix(word) {
31403
+ const plain = plainFor(word).replace(/\s*\([^)]*\)/g, "");
31404
+ if (plain === "a plain word") return "Use a plain word.";
31405
+ if (/^(?:say|name|give)\b/.test(plain)) return `Cut "${word}", or swap it for a detail the text already gives.`;
31406
+ return `Use ${plain.split(", ").map((swap) => `"${swap}"`).join(" or ")}.`;
31407
+ }
31201
31408
  const PHRASE_RULES = [
31202
31409
  {
31203
31410
  id: "chatbot-residue",
@@ -31356,7 +31563,7 @@ const PHRASE_RULES = [
31356
31563
  /\bstunning(?:ly)?\b|\benchanting\b/gi
31357
31564
  ],
31358
31565
  message: (match) => `"${match.text}" is far more common in AI text than in human writing.`,
31359
- fix: (match) => `Use ${plainFor(match.text.replace(/^(?:rich|a) /i, ""))}.`
31566
+ fix: (match) => swapFix(match.text.replace(/^(?:rich|a) /i, ""))
31360
31567
  },
31361
31568
  {
31362
31569
  id: "banned-word",
@@ -31376,7 +31583,7 @@ const PHRASE_RULES = [
31376
31583
  /\bgets? you to the next fix\b/gi
31377
31584
  ],
31378
31585
  message: (match) => `"${match.text}" is on the house list of words to cut.`,
31379
- fix: (match) => /source of truth/i.test(match.text) ? "Name the actual file, field or result." : /hype|everything you need|next fix/i.test(match.text) ? "Say what the page or tool actually gives the reader." : `Use ${plainFor(match.text)}.`
31586
+ fix: (match) => /source of truth/i.test(match.text) ? "Name the actual file, field or result." : /hype|everything you need|next fix/i.test(match.text) ? "Say what the page or tool actually gives the reader." : swapFix(match.text)
31380
31587
  },
31381
31588
  {
31382
31589
  id: "buzzword",
@@ -31621,7 +31828,7 @@ const PHRASE_RULES = [
31621
31828
  /(?:^|[.!?:]\s+)(?:But|And) only (?:part|half|some|partly)\.(?=\s|$)/g
31622
31829
  ],
31623
31830
  message: "A two-word denial used for rhythm. It makes the reader wait.",
31624
- fix: "Join it to the sentence before, such as \"Nothing technical was holding these pages back.\""
31831
+ fix: "Join it to the sentence before, so the point is stated once."
31625
31832
  },
31626
31833
  {
31627
31834
  id: "strawman",
@@ -32419,10 +32626,10 @@ function stockCloser(ctx) {
32419
32626
  for (const block of ctx.blocks(RHYTHM_KINDS)) for (const sentence of ctx.sentences(block)) {
32420
32627
  const text = sentence.text;
32421
32628
  if (CLOSERS.some((re) => re.test(text))) {
32422
- ctx.hit(rule, block, sentence.start, sentence.end, "A stock kicker that tells the reader how to feel about the last point.", "Cut it. If the point needs support, give the number or fact that makes it true.");
32629
+ ctx.hit(rule, block, sentence.start, sentence.end, "A stock kicker that tells the reader how to feel about the last point.", "Cut it. If the text already has the number or fact that makes the point, end on that.");
32423
32630
  continue;
32424
32631
  }
32425
- if (/^(?:but |and )?(?:that|this)(?:'s| is) not (?:how|what|why|where|a|an|the)\b/i.test(text) && words$2(text).length <= 10) ctx.hit(reframe, block, sentence.start, sentence.end, "A short \"That's not ...\" line dismisses a view instead of stating one.", "Say what is true instead, such as \"Agents can do this work in a day.\"");
32632
+ if (/^(?:but |and )?(?:that|this)(?:'s| is) not (?:how|what|why|where|a|an|the)\b/i.test(text) && words$2(text).length <= 10) ctx.hit(reframe, block, sentence.start, sentence.end, "A short \"That's not ...\" line dismisses a view instead of stating one.", "Cut it, or say what is true with a fact the text already gives.");
32426
32633
  }
32427
32634
  }
32428
32635
  const FIGURATIVE_NOUNS = "fights?|battles?|hills?|wars?|bets?|gambles?|races?|risks?|horses?|roads?|bridges?|doors?|games?|ships?|cards?";
@@ -33212,15 +33419,24 @@ async function ahrefs(key, input, missed, totals) {
33212
33419
  order_by: "refdomains_target:desc"
33213
33420
  })).pages ?? [];
33214
33421
  if (input.action === "serp") return eachKeyword(input.keywords.slice(0, serpKeywords(input)), missed, async (keyword) => {
33215
- return ((await get("serp-overview/serp-overview", {
33422
+ const data = await get("serp-overview/serp-overview", {
33216
33423
  select: "position,type,url,title,domain_rating,traffic,keywords",
33217
33424
  keyword,
33218
33425
  country: input.country,
33219
33426
  top_positions: String(serpDepth(input))
33220
- })).positions ?? []).map((row) => ({
33221
- keyword,
33222
- ...row
33223
- }));
33427
+ });
33428
+ let organic = 0;
33429
+ return (data.positions ?? []).map((row) => {
33430
+ const { position, type, ...rest } = row;
33431
+ const kinds = Array.isArray(type) ? type.map(String) : [String(type ?? "")];
33432
+ return {
33433
+ keyword,
33434
+ position: kinds.includes("organic") ? ++organic : null,
33435
+ ahrefs_position: position ?? null,
33436
+ type: kinds.join(",") || null,
33437
+ ...rest
33438
+ };
33439
+ });
33224
33440
  });
33225
33441
  if (input.action === "backlinks") return (await get("site-explorer/refdomains", {
33226
33442
  select: "domain,domain_rating,links_to_target,dofollow_links,first_seen,last_seen",
@@ -33248,7 +33464,55 @@ function flattenIntents(row) {
33248
33464
  intents: names.join(" ") || null
33249
33465
  };
33250
33466
  }
33467
+ // packages/core/src/commands/research-serp.ts
33468
+ const GOTO = /^https?:\/\/(www\.)?google\.[a-z.]+\/goto\b/;
33469
+ const hostOf$3 = (url) => {
33470
+ try {
33471
+ return new URL(url).hostname;
33472
+ } catch {
33473
+ return "";
33474
+ }
33475
+ };
33476
+ const text = (value) => typeof value === "string" && value ? value : null;
33477
+ function dataForSeoSerp(keyword, items) {
33478
+ const found = items.find((item) => item.type === "ai_overview");
33479
+ const overview = found ? {
33480
+ keyword,
33481
+ references: (found.references ?? []).slice(0, 20).map((ref) => {
33482
+ const url = String(ref.url ?? "");
33483
+ const goto = GOTO.test(url);
33484
+ return {
33485
+ domain: goto ? null : text(ref.domain) ?? (hostOf$3(url) || null),
33486
+ url: goto || !url ? null : url,
33487
+ title: text(ref.title)
33488
+ };
33489
+ })
33490
+ } : null;
33491
+ return {
33492
+ rows: items.filter((item) => item.type === "organic" || item.type === "featured_snippet").map((item) => {
33493
+ const row = {
33494
+ keyword,
33495
+ position: item.type === "organic" ? Number(item.rank_group) : null,
33496
+ rank_absolute: Number(item.rank_absolute),
33497
+ type: String(item.type),
33498
+ url: text(item.url),
33499
+ domain: text(item.domain),
33500
+ title: text(item.title)
33501
+ };
33502
+ if (GOTO.test(String(row.url))) {
33503
+ const crumb = String(item.breadcrumb ?? "");
33504
+ const site = /^https?:\/\/([^/\s›]+)/.exec(crumb);
33505
+ row.url = null;
33506
+ row.domain = site?.[1] ?? null;
33507
+ row.url_hint = site ? crumb.replace(/^https?:\/\//, "") : null;
33508
+ }
33509
+ return row;
33510
+ }),
33511
+ overview
33512
+ };
33513
+ }
33251
33514
  // packages/core/src/commands/research-providers.ts
33515
+ const RESEARCH_DEVICES = ["desktop", "mobile"];
33252
33516
  const RESEARCH_SCOPES = [
33253
33517
  "domain",
33254
33518
  "prefix",
@@ -33372,7 +33636,25 @@ async function dataForSeoList([login, password], path) {
33372
33636
  if (!task || task.status_code >= 4e4) throw new CommandError("PROVIDER_ERROR", `dataforseo: no list at ${path}`);
33373
33637
  return task.result ?? [];
33374
33638
  }
33375
- async function dataForSeoTask([login, password], path, task) {
33639
+ async function dataForSeoTask(keys, path, task) {
33640
+ let spent = 0;
33641
+ for (let attempt = 1;; attempt += 1) try {
33642
+ const done = await dataForSeoTaskOnce(keys, path, task);
33643
+ return {
33644
+ ...done,
33645
+ cost: done.cost + spent
33646
+ };
33647
+ } catch (error) {
33648
+ const failed = error;
33649
+ spent += failed.cost ?? 0;
33650
+ if (!SE_ERRORS.has(failed.status ?? 0) || attempt >= SE_TRIES) throw Object.assign(error, {
33651
+ cost: spent,
33652
+ ...attempt > 1 ? { tries: attempt } : {}
33653
+ });
33654
+ await wait(SE_RETRY_MS[attempt - 1] ?? 3e3);
33655
+ }
33656
+ }
33657
+ async function dataForSeoTaskOnce([login, password], path, task) {
33376
33658
  const result = (await readJson(await call("dataforseo", new Request(`https://api.dataforseo.com/${path}`, {
33377
33659
  method: "POST",
33378
33660
  headers: {
@@ -33387,7 +33669,10 @@ async function dataForSeoTask([login, password], path, task) {
33387
33669
  partial: false,
33388
33670
  noResults: true
33389
33671
  };
33390
- if (!result || result.status_code >= 4e4 && result.status_code !== PARTIAL) throw Object.assign(new CommandError("PROVIDER_ERROR", `dataforseo: ${result?.status_message ?? "no task result"}`), { cost: result?.cost ?? 0 });
33672
+ if (!result || result.status_code >= 4e4 && result.status_code !== PARTIAL) throw Object.assign(new CommandError("PROVIDER_ERROR", `dataforseo: ${result?.status_message ?? "no task result"}`), {
33673
+ cost: result?.cost ?? 0,
33674
+ status: result?.status_code ?? 0
33675
+ });
33391
33676
  return {
33392
33677
  cost: result.cost ?? 0,
33393
33678
  result: result.result?.[0] ?? null,
@@ -33396,6 +33681,9 @@ async function dataForSeoTask([login, password], path, task) {
33396
33681
  }
33397
33682
  const NO_RESULTS = 40102;
33398
33683
  const PARTIAL = 40106;
33684
+ const SE_ERRORS = /* @__PURE__ */ new Set([40101, 5e4]);
33685
+ const SE_TRIES = 3;
33686
+ const SE_RETRY_MS = [1e3, 3e3];
33399
33687
  const queued = (task) => ({
33400
33688
  id: String(task.id ?? ""),
33401
33689
  status_code: Number(task.status_code ?? 0),
@@ -33435,6 +33723,7 @@ async function dataForSeo(keys, input) {
33435
33723
  noData: withoutData(input, rows, spent.missed, 700),
33436
33724
  ...spent.available !== void 0 ? { available: spent.available } : {},
33437
33725
  ...spent.siteTotal ? { siteTotal: spent.siteTotal } : {},
33726
+ ...spent.aiOverviews ? { aiOverviews: spent.aiOverviews } : {},
33438
33727
  ...spent.rankFailed ? { rankFailed: true } : {}
33439
33728
  };
33440
33729
  }
@@ -33523,7 +33812,9 @@ async function dataForSeoRows(keys, input, spent) {
33523
33812
  }
33524
33813
  return items.map((item) => pick(item, {
33525
33814
  keyword: "keyword_data.keyword",
33526
- position: "ranked_serp_element.serp_item.rank_absolute",
33815
+ position: "ranked_serp_element.serp_item.rank_group",
33816
+ rank_absolute: "ranked_serp_element.serp_item.rank_absolute",
33817
+ type: "ranked_serp_element.serp_item.type",
33527
33818
  url: "ranked_serp_element.serp_item.url",
33528
33819
  search_volume: "keyword_data.keyword_info.search_volume",
33529
33820
  etv: "ranked_serp_element.serp_item.etv",
@@ -33533,28 +33824,13 @@ async function dataForSeoRows(keys, input, spent) {
33533
33824
  }
33534
33825
  if (input.action === "serp") {
33535
33826
  const rows = await eachKeyword(input.keywords.slice(0, serpKeywords(input)), spent.missed, async (keyword) => {
33536
- return (await post("v3/serp/google/organic/live/advanced", {
33827
+ const { rows, overview } = dataForSeoSerp(keyword, await post("v3/serp/google/organic/live/advanced", {
33537
33828
  keyword,
33538
- depth: serpDepth(input)
33539
- }, 2)).filter((item) => item.type === "organic").map((item) => {
33540
- const row = pick(item, {
33541
- position: "rank_absolute",
33542
- url: "url",
33543
- domain: "domain",
33544
- title: "title"
33545
- });
33546
- if (/^https?:\/\/(www\.)?google\.[a-z.]+\/goto\b/.test(String(row.url))) {
33547
- const crumb = String(item.breadcrumb ?? "");
33548
- const site = /^https?:\/\/([^/\s›]+)/.exec(crumb);
33549
- row.url = null;
33550
- row.domain = site?.[1] ?? null;
33551
- row.url_hint = site ? crumb.replace(/^https?:\/\//, "") : null;
33552
- }
33553
- return {
33554
- keyword,
33555
- ...row
33556
- };
33557
- });
33829
+ depth: serpDepth(input),
33830
+ device: input.device ?? "desktop"
33831
+ }, 2));
33832
+ if (overview) spent.aiOverviews = [...spent.aiOverviews ?? [], overview];
33833
+ return rows;
33558
33834
  });
33559
33835
  return input.noRank ? rows : withDomainRank(keys, rows, spent);
33560
33836
  }
@@ -33672,7 +33948,7 @@ async function serpOverlap(provider, headers, input) {
33672
33948
  const keywords = [...new Set(input.keywords.slice(0, OVERLAP_KEYWORDS))].filter((keyword) => !serps.missed.includes(keyword) && !serps.noData.includes(keyword));
33673
33949
  const host = (value) => String(value ?? "").toLowerCase().replace(/^www\./, "");
33674
33950
  const top = new Map(keywords.map((keyword) => {
33675
- const rows = serps.rows.filter((row) => row.keyword === keyword).slice(0, OVERLAP_DEPTH);
33951
+ const rows = serps.rows.filter((row) => row.keyword === keyword && row.type !== "featured_snippet").slice(0, OVERLAP_DEPTH);
33676
33952
  const pages = new Set(rows.map((row) => row.url).filter((url) => typeof url === "string" && url !== "").map((url) => url.replace(/\/$/, "").toLowerCase()));
33677
33953
  const sites = new Set(rows.map((row) => host(row.domain ?? siteOf(row.url))).filter(Boolean));
33678
33954
  return [keyword, {
@@ -37779,6 +38055,121 @@ async function runBingReport(input, deps) {
37779
38055
  data: report
37780
38056
  };
37781
38057
  }
38058
+ const DEVICES = ["desktop", "mobile"];
38059
+ const DEPTH = 10;
38060
+ const bare = (host) => host.toLowerCase().replace(/^www\./, "");
38061
+ function key(url) {
38062
+ try {
38063
+ const parsed = new URL(url);
38064
+ return `${bare(parsed.hostname)}${parsed.pathname.replace(/\/+$/, "")}`.toLowerCase();
38065
+ } catch {
38066
+ return "";
38067
+ }
38068
+ }
38069
+ function hintKey(hint) {
38070
+ const parts = hint.split("›").map((part) => part.trim());
38071
+ if (!parts[0] || parts.some((part) => /^\.\.\.$|…/.test(part))) return null;
38072
+ return `${bare(parts[0])}${parts.length > 1 ? `/${parts.slice(1).join("/")}` : ""}`.toLowerCase();
38073
+ }
38074
+ function phraseFrom(text, words = 2) {
38075
+ const cleaned = (text ?? "").split(/\s+[|\u2013\u2014-]\s+/)[0]?.replace(/["“”]/g, "").trim();
38076
+ if (!cleaned || cleaned.split(/\s+/).length < words) return null;
38077
+ return cleaned.split(/\s+/).slice(0, 12).join(" ");
38078
+ }
38079
+ async function runIndexCheck(input, headers) {
38080
+ let target;
38081
+ try {
38082
+ target = new URL(/^https?:\/\//i.test(input.url) ? input.url : `https://${input.url}`);
38083
+ } catch {
38084
+ throw new CommandError("INVALID_INPUT", "index_check needs a page URL as target.");
38085
+ }
38086
+ const keys = dataForSeoKeys(headers);
38087
+ const country = input.country ?? "us";
38088
+ const market = dataForSeoMarket(country, dataForSeoLanguage(country));
38089
+ const page = key(target.href);
38090
+ const host = bare(target.hostname);
38091
+ let phrases = (input.phrases ?? []).slice(0, 2);
38092
+ let phraseSource = phrases.length ? "given" : null;
38093
+ if (!phrases.length) {
38094
+ const live = await liveCheck(target.href).catch(() => null);
38095
+ const sentence = (live?.last?.description ?? "").split(/(?<=[.!?])\s/)[0] ?? "";
38096
+ const found = [phraseFrom(live?.last?.h1 ?? null) ?? phraseFrom(live?.last?.title ?? null), phraseFrom(sentence.replace(/[.!?]$/, ""), 5)].filter((phrase) => Boolean(phrase));
38097
+ phrases = [...new Set(found)].slice(0, 2);
38098
+ phraseSource = phrases.length ? "the page’s H1 or title and its meta description" : null;
38099
+ }
38100
+ const queries = [`site:${host}${target.pathname === "/" ? "" : target.pathname.replace(/\/+$/, "")}`, ...phrases.map((phrase) => `"${phrase.replace(/"/g, "")}"`)];
38101
+ const rows = [];
38102
+ let cost = 0;
38103
+ for (const query of queries) for (const device of DEVICES) {
38104
+ const row = {
38105
+ query,
38106
+ device,
38107
+ result: "empty",
38108
+ position: null,
38109
+ rank_absolute: null,
38110
+ results: 0,
38111
+ top_domains: "",
38112
+ error: null
38113
+ };
38114
+ try {
38115
+ const done = await dataForSeoTask(keys, "v3/serp/google/organic/live/advanced", {
38116
+ ...market,
38117
+ keyword: query,
38118
+ depth: DEPTH,
38119
+ device
38120
+ });
38121
+ cost += done.cost;
38122
+ const results = dataForSeoSerp(query, done.result?.items ?? []).rows;
38123
+ row.results = results.length;
38124
+ row.top_domains = [...new Set(results.map((r) => String(r.domain ?? "?")))].slice(0, 3).join(", ");
38125
+ const onSite = results.filter((r) => {
38126
+ const domain = bare(String(r.domain ?? ""));
38127
+ return domain === host || domain.endsWith(`.${host}`);
38128
+ });
38129
+ const match = onSite.find((r) => typeof r.url === "string" && key(r.url) === page || typeof r.url_hint === "string" && hintKey(r.url_hint) === page);
38130
+ if (match) {
38131
+ row.result = "page";
38132
+ row.position = typeof match.position === "number" ? match.position : null;
38133
+ row.rank_absolute = Number(match.rank_absolute) || null;
38134
+ } else if (onSite.some((r) => r.url === null && (typeof r.url_hint !== "string" || hintKey(r.url_hint) === null || hintKey(r.url_hint) === host))) row.result = "unresolved";
38135
+ else if (onSite.length) row.result = "other_pages";
38136
+ else if (results.length) row.result = query.startsWith("site:") ? "fallback" : "other_sites";
38137
+ } catch (error) {
38138
+ if (error instanceof CommandError && error.code === "NO_KEY") throw error;
38139
+ cost += error.cost ?? 0;
38140
+ row.result = "error";
38141
+ row.error = error.message;
38142
+ }
38143
+ rows.push(row);
38144
+ }
38145
+ const has = (result) => rows.some((row) => row.result === result);
38146
+ const verdict = has("page") ? "indexed" : has("error") || has("unresolved") ? "unknown" : "not_found";
38147
+ const reason = verdict === "indexed" ? `Google returned the page itself for ${rows.filter((row) => row.result === "page").length} of ${rows.length} searches.` : verdict === "unknown" ? has("error") ? `${rows.filter((row) => row.result === "error").length} of ${rows.length} searches failed after retries, and none of the others returned the page. Ask again later.` : "Google returned results from the site, but DataForSEO gave Google redirect links whose page it could not resolve, so they may or may not be this page." : `None of ${rows.length} searches returned the page.${has("fallback") ? " Where the site: search showed other sites, Google had dropped the filter because nothing matched; those are not rankings." : ""} It may be missing from the index, or indexed without this text. Check it with URL Inspection (seo page or seo index) if the site is yours.`;
38148
+ const usd = Math.round(cost * 1e4) / 1e4;
38149
+ return {
38150
+ text: [
38151
+ `Index check for ${target.href} (${country}, desktop and mobile): ${verdict.replace("_", " ")}.`,
38152
+ reason,
38153
+ phraseSource ? `Exact phrase from ${phraseSource}.` : "No exact phrase: none was given and the page’s H1 and title could not be read, so only site: was asked.",
38154
+ "",
38155
+ ...rows.map((row) => `${row.device.padEnd(7)} ${row.result}${row.position !== null ? ` (organic ${row.position}, absolute ${row.rank_absolute})` : ""} ${row.query}${row.results ? ` [${row.results} results: ${row.top_domains}]` : ""}${row.error ? ` ${row.error}` : ""}`),
38156
+ "",
38157
+ "This asks Google search, not Search Console. Google can index a page and still not show it for a site: or phrase search, so not found is evidence, not proof.",
38158
+ `DataForSEO cost: $${usd}.`
38159
+ ].join("\n"),
38160
+ data: {
38161
+ provider: "dataforseo",
38162
+ action: "index_check",
38163
+ url: target.href,
38164
+ country,
38165
+ verdict,
38166
+ reason,
38167
+ phrases,
38168
+ checks: rows,
38169
+ costUsd: usd
38170
+ }
38171
+ };
38172
+ }
37782
38173
  // packages/core/src/commands/research-cache.ts
37783
38174
  const RESEARCH_TTL_SECONDS = 86400;
37784
38175
  function workersResearchStore() {
@@ -37823,10 +38214,11 @@ const RESEARCH_ACTIONS = [
37823
38214
  "top_pages",
37824
38215
  "pages_by_backlinks",
37825
38216
  "keyword_ideas",
37826
- "serp_overlap"
38217
+ "serp_overlap",
38218
+ "index_check"
37827
38219
  ];
37828
38220
  const RESEARCH_PROVIDERS = ["dataforseo", "ahrefs"];
37829
- const RESEARCH_HELP = `provider is optional: without it the call uses the saved key, DataForSEO when both are saved. The target is a domain (the whole site), a path ending in / such as example.com/blog/ (every URL under it), or one page; scope overrides that. keyword_metrics (keywords: volume, difficulty, CPC), keyword_ideas (up to 5 seed keywords: searches that contain each seed, by volume), ranked_keywords (target: keywords a site, subfolder or page ranks for, with the provider's traffic total for the whole target), top_pages (target: the pages with the most estimated organic traffic), serp (keywords, up to 5: the ranking pages, each with its domain's authority: DataForSEO domain_rank 0 to 100, Ahrefs domain_rating; depth sets results per keyword, default 20; rows run position by position across the keywords), serp_overlap (2 to 10 keywords: for each pair, how many of their top 10 pages and sites are the same, to decide one page or several; results without a real page are counted, not compared), competitors (target: domains that share keywords), backlinks (target: referring domains), pages_by_backlinks (target: the pages with the most referring domains), link_gap (target and 2 to 5 competitors: referring domains that link to two or more competitors and not the target, highest authority first, from each site's top 1,000 referring domains; costs one backlinks call per site), ai_answers (DataForSEO only; target: the site. Without keywords it takes the site’s top 10 chat-style and long questions from Search Console’s AI-shaped queries itself. With up to 10 questions in keywords it asks those instead, and the target can be any domain, such as a competitor's. Each answer row also lists the other sites it cites, and the notes rank them. Answers come from DataForSEO's queue: answers not ready after about 40 seconds are returned as tasks, already paid for; call again with the same target and those tasks to read them at no cost. It asks ChatGPT search, Google AI Mode and Google AI Overviews each one and says whether the answer cites or mentions the site. DataForSEO’s queue prices are about $0.0072 a question across the three engines, so about $0.07 a run: tell the person before running it). Rows with a domain, and the target, get a free Ahrefs domain_rating column. Numbers are provider estimates, not Search Console data.`;
38221
+ const RESEARCH_HELP = `provider is optional: without it the call uses the saved key, DataForSEO when both are saved. The target is a domain (the whole site), a path ending in / such as example.com/blog/ (every URL under it), or one page; scope overrides that. keyword_metrics (keywords: volume, difficulty, CPC), keyword_ideas (up to 5 seed keywords: searches that contain each seed, by volume), ranked_keywords (target: keywords a site, subfolder or page ranks for, with the provider's traffic total for the whole target), top_pages (target: the pages with the most estimated organic traffic), serp (keywords, up to 5: the ranking pages with their organic position beside DataForSEO's rank_absolute, which also counts AI Overviews and other blocks, plus the AI Overview's sources; device desktop or mobile, DataForSEO only; each with its domain's authority: DataForSEO domain_rank 0 to 100, Ahrefs domain_rating; depth sets results per keyword, default 20; rows run position by position across the keywords), index_check (DataForSEO only; target: one page URL; keywords: up to 2 exact phrases from the page, default its H1 or title. Searches Google for site:<url> and each "<phrase>" on desktop and mobile, retries failed searches, treats Google's fallback to unrelated sites as no match, and says indexed, not found or unknown with each search's evidence; about $0.01 a page), serp_overlap (2 to 10 keywords: for each pair, how many of their top 10 pages and sites are the same, to decide one page or several; results without a real page are counted, not compared), competitors (target: domains that share keywords), backlinks (target: referring domains), pages_by_backlinks (target: the pages with the most referring domains), link_gap (target and 2 to 5 competitors: referring domains that link to two or more competitors and not the target, highest authority first, from each site's top 1,000 referring domains; costs one backlinks call per site), ai_answers (DataForSEO only; target: the site. Without keywords it takes the site’s top 10 chat-style and long questions from Search Console’s AI-shaped queries itself. With up to 10 questions in keywords it asks those instead, and the target can be any domain, such as a competitor's. Each answer row also lists the other sites it cites, and the notes rank them. Answers come from DataForSEO's queue: answers not ready after about 40 seconds are returned as tasks, already paid for; call again with the same target and those tasks to read them at no cost. It asks ChatGPT search, Google AI Mode and Google AI Overviews each one and says whether the answer cites or mentions the site. DataForSEO’s queue prices are about $0.0072 a question across the three engines, so about $0.07 a run: tell the person before running it). Rows with a domain, and the target, get a free Ahrefs domain_rating column. Numbers are provider estimates, not Search Console data.`;
37830
38222
  const GENERIC_PLATFORMS = [
37831
38223
  "youtube.com",
37832
38224
  "facebook.com",
@@ -37888,6 +38280,17 @@ async function runResearch(input, keys, options) {
37888
38280
  const offset = input.offset ?? 0;
37889
38281
  if (input.action === "ai_answers") throw new CommandError("INVALID_INPUT", "ai_answers runs on its own.");
37890
38282
  const provider = chooseProvider(input.provider, keys);
38283
+ if (input.action === "index_check") {
38284
+ if (provider === "ahrefs") throw new CommandError("INVALID_INPUT", "index_check uses DataForSEO: it runs live Google searches, which Ahrefs does not offer.");
38285
+ return runIndexCheck({
38286
+ url: target,
38287
+ phrases: input.keywords,
38288
+ country
38289
+ }, keys);
38290
+ }
38291
+ if (input.device && input.action !== "serp") throw new CommandError("INVALID_INPUT", "device works with serp only. ranked_keywords and the other database actions have one ranking per keyword, from desktop results.");
38292
+ if (input.device === "mobile" && provider === "ahrefs") throw new CommandError("INVALID_INPUT", "Ahrefs' SERP overview has no device choice, so it cannot show mobile results. Use provider dataforseo for device mobile.");
38293
+ const device = input.action === "serp" ? input.device ?? "desktop" : null;
37891
38294
  const language = provider === "dataforseo" ? dataForSeoLanguage(country) : null;
37892
38295
  const scope = [
37893
38296
  "ranked_keywords",
@@ -37903,7 +38306,8 @@ async function runResearch(input, keys, options) {
37903
38306
  ...scope ? { scope } : {},
37904
38307
  competitors: input.competitors ?? [],
37905
38308
  country,
37906
- ...input.action === "serp" ? { depth: input.depth ?? 20 } : {}
38309
+ ...input.action === "serp" ? { depth: input.depth ?? 20 } : {},
38310
+ ...device === "mobile" ? { device } : {}
37907
38311
  };
37908
38312
  const store = options.store ?? workersResearchStore();
37909
38313
  const key = await researchKey([
@@ -37921,10 +38325,10 @@ async function runResearch(input, keys, options) {
37921
38325
  savedAt: (/* @__PURE__ */ new Date()).toISOString(),
37922
38326
  result
37923
38327
  });
37924
- const { rows: fetched, costUsd: paid, partial, missed, noData, floor, leftOut, available, siteTotal, rankFailed, compared } = result;
38328
+ const { rows: fetched, costUsd: paid, partial, missed, noData, floor, leftOut, available, siteTotal, rankFailed, compared, aiOverviews } = result;
37925
38329
  const costUsd = saved ? 0 : paid;
37926
38330
  const order = new Map(request.keywords.map((word, index) => [word, index]));
37927
- const found = input.action === "serp" && request.keywords.length > 1 ? [...fetched].sort((a, b) => Number(a.position ?? 1e9) - Number(b.position ?? 1e9) || (order.get(String(a.keyword)) ?? 0) - (order.get(String(b.keyword)) ?? 0)) : fetched;
38331
+ const found = input.action === "serp" && request.keywords.length > 1 ? [...fetched].sort((a, b) => Number(a.position ?? 0) - Number(b.position ?? 0) || (order.get(String(a.keyword)) ?? 0) - (order.get(String(b.keyword)) ?? 0)) : fetched;
37928
38332
  const { rows, note } = input.action === "competitors" ? realCompetitors(found, target) : {
37929
38333
  rows: found,
37930
38334
  note: null
@@ -37942,7 +38346,7 @@ async function runResearch(input, keys, options) {
37942
38346
  const perKeyword = input.action === "serp" ? Object.fromEntries(request.keywords.map((word) => [word, rated.rows.filter((row) => row.keyword === word).length])) : null;
37943
38347
  return {
37944
38348
  text: rowsText({
37945
- title: `${provider} ${input.action}${target ? ` for ${target}` : ""}${scope ? ` (${SCOPE_TEXT[scope]})` : ""}${LINK_ACTIONS.includes(input.action) ? ", all countries" : `, ${country}${language ? `, language ${language}` : ""}`}.`,
38349
+ title: `${provider} ${input.action}${device && provider === "dataforseo" ? ` (${device})` : ""}${target ? ` for ${target}` : ""}${scope ? ` (${SCOPE_TEXT[scope]})` : ""}${LINK_ACTIONS.includes(input.action) ? ", all countries" : `, ${country}${language ? `, language ${language}` : ""}`}.`,
37946
38350
  rows: rated.rows,
37947
38351
  offset,
37948
38352
  limit: size,
@@ -37951,6 +38355,8 @@ async function runResearch(input, keys, options) {
37951
38355
  `${provider} estimates, in the provider’s own units.`,
37952
38356
  ...compared ? [`Compared: ${compared.withPage} of ${compared.results} top results had a real page, so shared_pages counts only those; ${compared.results - compared.withPage} came back as Google redirect links and could not be compared by page. ${compared.withSite} had a known site for shared_sites. Unique pages and sites are counted per keyword.${compared.withPage < compared.results / 2 && provider === "dataforseo" ? " With this few pages, shared_pages cannot settle one page or several; provider ahrefs returns real pages." : ""}`] : [],
37953
38357
  ...VOLUME_ACTIONS.includes(input.action) ? [`Volume model: ${VOLUME_MODEL[provider]} ${VOLUMES_DIFFER}`] : [],
38358
+ ...input.action === "serp" || input.action === "ranked_keywords" ? [positionNote(provider)] : [],
38359
+ ...aiOverviews?.length ? [aiOverviewNote(aiOverviews, request.keywords.length)] : input.action === "serp" && provider === "dataforseo" && rows.length ? ["No AI Overview on these results pages."] : [],
37954
38360
  ...perKeyword ? [`Results per keyword: ${Object.entries(perKeyword).map(([word, count]) => `${word} ${count}`).join(", ")}. Rows run position by position across the keywords.`] : [],
37955
38361
  ...siteTotal ? [siteTotalNote(provider, siteTotal, rows.length)] : [],
37956
38362
  ...available !== void 0 && available > found.length ? [`Capped: ${provider} has ${available.toLocaleString("en-US")} rows for this; this read the first ${found.length.toLocaleString("en-US")} in its order.`] : [],
@@ -37984,6 +38390,8 @@ async function runResearch(input, keys, options) {
37984
38390
  available: available ?? null,
37985
38391
  country,
37986
38392
  language,
38393
+ ...device ? { device: provider === "dataforseo" ? device : null } : {},
38394
+ ...input.action === "serp" && provider === "dataforseo" ? { aiOverviews: aiOverviews ?? [] } : {},
37987
38395
  targetDomainRating: hasTargetDr ? targetDr : null,
37988
38396
  attribution: rated.added || hasTargetDr ? DR_ATTRIBUTION : null,
37989
38397
  leftOut: note,
@@ -38007,6 +38415,17 @@ async function runResearch(input, keys, options) {
38007
38415
  }
38008
38416
  };
38009
38417
  }
38418
+ function positionNote(provider) {
38419
+ return provider === "dataforseo" ? "position is the organic position: the nth organic link (DataForSEO rank_group). rank_absolute counts every block on the page from the top, AI Overviews, People Also Ask, videos and images included, so it is higher. A featured snippet has type featured_snippet and no organic position." : "position is the organic position: the nth row Ahrefs marks organic. ahrefs_position is Ahrefs' own position field, and type lists what each row is.";
38420
+ }
38421
+ function aiOverviewNote(overviews, keywords) {
38422
+ const each = overviews.map((overview) => {
38423
+ const sites = [...new Set(overview.references.map((ref) => ref.domain).filter((domain) => Boolean(domain)))];
38424
+ const unknown = overview.references.filter((ref) => !ref.domain).length;
38425
+ return `${overview.keyword}: ${overview.references.length} sources${sites.length ? ` (${sites.slice(0, 6).join(", ")}${sites.length > 6 ? ", …" : ""})` : ""}${unknown ? `, ${unknown} behind Google redirect links` : ""}`;
38426
+ });
38427
+ return `AI Overview shown for ${overviews.length} of ${keywords} keyword${keywords === 1 ? "" : "s"}. ${each.join("; ")}. The full list is in the JSON as aiOverviews.`;
38428
+ }
38010
38429
  const LINK_ACTIONS = [
38011
38430
  "backlinks",
38012
38431
  "pages_by_backlinks",
@@ -38634,7 +39053,7 @@ const percent = (site) => {
38634
39053
  const clicks = site.clicks;
38635
39054
  return Math.round(Math.abs(clicks.clicks - clicks.previous) / clicks.previous * 100);
38636
39055
  };
38637
- function issues(site, startMs, endMs) {
39056
+ function issues(site, startMs, endMs, first = false) {
38638
39057
  const found = [];
38639
39058
  if (site.index?.dropped) found.push({
38640
39059
  rank: 0,
@@ -38656,11 +39075,11 @@ function issues(site, startMs, endMs) {
38656
39075
  });
38657
39076
  if (crawled && crawl.new_issues > 0) found.push({
38658
39077
  rank: 3,
38659
- heading: plural$1(crawl.new_issues, "new issue")
39078
+ heading: plural$1(crawl.new_issues, first ? "open finding" : "new issue")
38660
39079
  });
38661
39080
  return found;
38662
39081
  }
38663
- function siteLines(site, startMs, endMs) {
39082
+ function siteLines(site, startMs, endMs, first = false) {
38664
39083
  const lines = [];
38665
39084
  const index = site.index;
38666
39085
  if (!index) lines.push("Google index: no checks ran this week yet.");
@@ -38677,7 +39096,7 @@ function siteLines(site, startMs, endMs) {
38677
39096
  if (crawled && crawl.status === "failed") lines.push(`Weekly crawl: stopped after ${plural$1(crawl.pages, "page")}${crawl.error ? `: ${sentence(crawl.error)}` : "."} It tries again next week.`);
38678
39097
  else if (crawled) {
38679
39098
  const rule = crawl.top_rule ? AUDIT_RULES[crawl.top_rule] : void 0;
38680
- lines.push(crawl.new_issues ? `Weekly crawl: ${plural$1(crawl.pages, "page")}, ${plural$1(crawl.new_issues, "new issue")}${rule ? `, most of them ${rule.charAt(0).toLowerCase()}${rule.slice(1)}` : ""}.` : `Weekly crawl: ${plural$1(crawl.pages, "page")}, no new issues.`);
39099
+ lines.push(crawl.new_issues ? `Weekly crawl: ${plural$1(crawl.pages, "page")}, ${plural$1(crawl.new_issues, first ? "open finding" : "new issue")}${rule ? `, most of them ${rule.charAt(0).toLowerCase()}${rule.slice(1)}` : ""}.` : `Weekly crawl: ${plural$1(crawl.pages, "page")}, no new issues.`);
38681
39100
  } else if (site.added_at < startMs) lines.push(`Weekly crawl: none finished this week${site.start_error ? `: ${sentence(site.start_error)}` : "."} It tries again each hour.`);
38682
39101
  else lines.push("Weekly crawl: the first crawl is still running.");
38683
39102
  const clicks = site.clicks;
@@ -38745,7 +39164,7 @@ function weekMarkdown(data) {
38745
39164
  const byName = (a, b) => siteName(a.property).localeCompare(siteName(b.property)) || a.property.localeCompare(b.property);
38746
39165
  const rows = data.sites.map((site) => ({
38747
39166
  site,
38748
- found: issues(site, startMs, endMs),
39167
+ found: issues(site, startMs, endMs, data.first),
38749
39168
  waiting: !site.crawl || !site.index
38750
39169
  }));
38751
39170
  const attention = rows.filter((row) => row.found.length);
@@ -38757,7 +39176,7 @@ function weekMarkdown(data) {
38757
39176
  "",
38758
39177
  attention.length ? total === 1 ? "Your site had changes worth a look this week." : `${attention.length} of your ${total} sites had changes worth a look this week.` : quiet.length ? total === 1 ? "Your site had a quiet week." : quiet.length === total ? `All ${total} of your sites had a quiet week.` : `${quiet.length} of your ${total} sites had a quiet week.` : "Your sites are still on their first checks."
38759
39178
  ];
38760
- for (const { site, found } of rows) out.push("", `## ${siteName(site.property)}${found.length ? `: ${list(found.map((item) => item.heading))}` : ""}`, "", ...siteLines(site, startMs, endMs).map((line) => `- ${line}`), ...opportunityLines(site), ...measuredLines(site), ...updateLines(site), ...site.link ? [`- [View site](${site.link})`] : []);
39179
+ for (const { site, found } of rows) out.push("", `## ${siteName(site.property)}${found.length ? `: ${list(found.map((item) => item.heading))}` : ""}`, "", ...siteLines(site, startMs, endMs, data.first).map((line) => `- ${line}`), ...opportunityLines(site), ...measuredLines(site), ...updateLines(site), ...site.link ? [`- [View site](${site.link})`] : []);
38761
39180
  return out.join("\n");
38762
39181
  }
38763
39182
  async function runAllSites(deps, sites) {
@@ -38995,6 +39414,7 @@ const SHARED_TOOLS = [
38995
39414
  brand: z.array(z.string().min(1).max(60)).max(10).default([]).describe("ai_answers only: the brand names that count as a mention, such as Datadog for datadoghq.com. Default: each domain's name."),
38996
39415
  country: z.string().regex(/^[a-z]{2}$/).default("us").describe("Two-letter country code."),
38997
39416
  depth: z.number().int().min(1).max(100).optional().describe(`serp only: results per keyword, default 20. Deeper costs more.`),
39417
+ device: z.enum(RESEARCH_DEVICES).optional().describe("serp only: desktop (default) or mobile. Google ranks pages differently on each. DataForSEO only."),
38998
39418
  ...detail
38999
39419
  }),
39000
39420
  run: async (surface, input) => {