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/mcp.js CHANGED
@@ -1,5 +1,5 @@
1
1
  import { createRequire as __seoCreateRequire } from 'node:module'; const require = __seoCreateRequire(import.meta.url);
2
- import { i as __toESM, n as version, r as __commonJSMin, t as load } from "./esm-G8B5TPzB.js";
2
+ import { i as __toESM, n as version, r as __commonJSMin, t as load } from "./esm-BR8_7rhM.js";
3
3
  import { createHash, randomUUID, sign } from "node:crypto";
4
4
  import { ZodOptional, z } from "zod";
5
5
  import { arch, homedir, platform, tmpdir, totalmem } from "node:os";
@@ -12571,13 +12571,20 @@ const RULE_DEFINITIONS = [
12571
12571
  title: "Large HTML response",
12572
12572
  category: "performance",
12573
12573
  defaultSeverity: "low",
12574
- whyItMatters: "Large HTML takes longer to download and parse before the browser can even start the rest of the page.",
12575
- howToFix: "Trim duplicated markup, remove heavy inline data, split non-critical content, and move large scripts or styles out of the HTML response.",
12576
- impactIfIgnored: "Pages can feel slow on mobile connections and crawlers spend more time processing each URL.",
12577
- howToVerify: "Re-run the crawl and confirm sizeBytes is below the large HTML threshold for normal pages.",
12574
+ 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.",
12575
+ 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.",
12576
+ impactIfIgnored: "Content past the 2MB cut is not indexed, and every page of the template carries the same weight on slow connections.",
12577
+ 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.",
12578
12578
  agentHints: {
12579
- evidenceFields: ["page.sizeBytes", "issue.evidence.thresholdBytes"],
12580
- suggestedCommands: ["seo crawl <url> --json"]
12579
+ evidenceFields: [
12580
+ "page.sizeBytes",
12581
+ "issue.evidence.thresholdBytes",
12582
+ "issue.evidence.mainContentStartBytes",
12583
+ "issue.evidence.bytesAfterLimit",
12584
+ "issue.evidence.visibleWordsAfterLimit",
12585
+ "issue.evidence.indexingRisk"
12586
+ ],
12587
+ suggestedCommands: ["seo crawl <url> --mode page --json"]
12581
12588
  }
12582
12589
  },
12583
12590
  {
@@ -13212,17 +13219,17 @@ function isAgentFindings(value) {
13212
13219
  const counts = record(candidate?.counts);
13213
13220
  return candidate?.schemaVersion === 1 && candidate.scope === "returned-report" && typeof counts?.total === "number" && typeof counts.returned === "number" && Array.isArray(candidate.items) && candidate.items.every((value) => {
13214
13221
  const item = record(value);
13215
- if (!text$1(item?.id) || item?.status !== "open" || !text$1(item.title) || !text$1(item.sourcePath) || !Array.isArray(item.allowedOutcomes)) return false;
13216
- if (item.type === "fix") return Boolean(text$1(record(item.fix)?.instruction));
13222
+ if (!text$2(item?.id) || item?.status !== "open" || !text$2(item.title) || !text$2(item.sourcePath) || !Array.isArray(item.allowedOutcomes)) return false;
13223
+ if (item.type === "fix") return Boolean(text$2(record(item.fix)?.instruction));
13217
13224
  if (item.type === "review") {
13218
13225
  const review = record(item.review);
13219
13226
  if (!review) return false;
13220
- return Boolean(text$1(review.question) && text$1(review.changeOnlyIf) && text$1(review.ifConfirmed) && text$1(review.ifNotNeeded) && Array.isArray(review.doNot));
13227
+ return Boolean(text$2(review.question) && text$2(review.changeOnlyIf) && text$2(review.ifConfirmed) && text$2(review.ifNotNeeded) && Array.isArray(review.doNot));
13221
13228
  }
13222
13229
  return false;
13223
13230
  });
13224
13231
  }
13225
- function text$1(value) {
13232
+ function text$2(value) {
13226
13233
  return typeof value === "string" && value.trim() ? value.trim() : void 0;
13227
13234
  }
13228
13235
  function stableId(value) {
@@ -13245,7 +13252,7 @@ function findingId(item, path, action) {
13245
13252
  "actionId",
13246
13253
  "id"
13247
13254
  ]) {
13248
- const id = text$1(item[key]);
13255
+ const id = text$2(item[key]);
13249
13256
  if (id) return id;
13250
13257
  }
13251
13258
  return `action-${stableId(`${path}|${action}`)}`;
@@ -13255,9 +13262,9 @@ function findingType(path, item) {
13255
13262
  }
13256
13263
  function findingVerification(item, fallback) {
13257
13264
  const supplied = record(item.verification);
13258
- const expected = text$1(supplied?.expected ?? item.howToVerify ?? fallback);
13265
+ const expected = text$2(supplied?.expected ?? item.howToVerify ?? fallback);
13259
13266
  if (!expected) return void 0;
13260
- const command = text$1(supplied?.command);
13267
+ const command = text$2(supplied?.command);
13261
13268
  return {
13262
13269
  ...command ? { command } : {},
13263
13270
  expected
@@ -13265,14 +13272,14 @@ function findingVerification(item, fallback) {
13265
13272
  }
13266
13273
  function stringArray(value) {
13267
13274
  if (!Array.isArray(value)) return [];
13268
- return value.flatMap((item) => text$1(item) ? [text$1(item)] : []);
13275
+ return value.flatMap((item) => text$2(item) ? [text$2(item)] : []);
13269
13276
  }
13270
13277
  function affectedUrls(item, affectedCount) {
13271
13278
  const items = stringArray(item.sampleUrls);
13272
13279
  const details = record(item.affectedUrlsReport);
13273
13280
  const params = record(details?.params);
13274
- const reportId = details?.id === "affected-urls" ? text$1(params?.reportId) : void 0;
13275
- const ruleId = details?.id === "affected-urls" ? text$1(params?.ruleId) : void 0;
13281
+ const reportId = details?.id === "affected-urls" ? text$2(params?.reportId) : void 0;
13282
+ const ruleId = details?.id === "affected-urls" ? text$2(params?.ruleId) : void 0;
13276
13283
  if (!items.length && !reportId) return void 0;
13277
13284
  const total = affectedCount ?? items.length;
13278
13285
  return {
@@ -13292,13 +13299,13 @@ function affectedUrls(item, affectedCount) {
13292
13299
  function reviewGuidance(item, title, action) {
13293
13300
  const supplied = record(item.review);
13294
13301
  const evidence = record(item.evidence);
13295
- const ruleId = text$1(item.ruleId ?? evidence?.ruleId);
13302
+ const ruleId = text$2(item.ruleId ?? evidence?.ruleId);
13296
13303
  const defined = (ruleId ? explainRule(ruleId) : void 0)?.review;
13297
13304
  return {
13298
- question: text$1(supplied?.question) ?? defined?.question ?? `Does the retained evidence and confirmed intent show that "${title}" needs a change?`,
13299
- changeOnlyIf: text$1(supplied?.changeOnlyIf) ?? defined?.changeOnlyIf ?? "Make a change only when the retained evidence and required intent support it.",
13300
- ifConfirmed: text$1(supplied?.ifConfirmed) ?? action,
13301
- ifNotNeeded: text$1(supplied?.ifNotNeeded) ?? defined?.ifNotNeeded ?? "Record no change and cite the evidence or confirmed intent that makes a change unnecessary.",
13305
+ question: text$2(supplied?.question) ?? defined?.question ?? `Does the retained evidence and confirmed intent show that "${title}" needs a change?`,
13306
+ changeOnlyIf: text$2(supplied?.changeOnlyIf) ?? defined?.changeOnlyIf ?? "Make a change only when the retained evidence and required intent support it.",
13307
+ ifConfirmed: text$2(supplied?.ifConfirmed) ?? action,
13308
+ ifNotNeeded: text$2(supplied?.ifNotNeeded) ?? defined?.ifNotNeeded ?? "Record no change and cite the evidence or confirmed intent that makes a change unnecessary.",
13302
13309
  doNot: stringArray(supplied?.doNot).length ? stringArray(supplied?.doNot) : [...defined?.doNot ?? []]
13303
13310
  };
13304
13311
  }
@@ -13317,7 +13324,7 @@ function findingEvidence(item) {
13317
13324
  }
13318
13325
  function normalizeFinding(value, path, index, options) {
13319
13326
  if (typeof value === "string") {
13320
- const action = text$1(value);
13327
+ const action = text$2(value);
13321
13328
  if (!action) return void 0;
13322
13329
  const common = {
13323
13330
  id: `action-${stableId(`${options.reportId ?? ""}|${path}|${action}`)}`,
@@ -13349,8 +13356,8 @@ function normalizeFinding(value, path, index, options) {
13349
13356
  }
13350
13357
  const item = record(value);
13351
13358
  if (!item) return void 0;
13352
- 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);
13353
- const title = text$1(item.title) ?? text$1(item.headline) ?? text$1(item.name) ?? action;
13359
+ const action = text$2(item.action) ?? text$2(item.howToFix) ?? (item.recommendation === "fix" || item.recommendation === "review" ? void 0 : text$2(item.recommendation)) ?? text$2(item.nextStep);
13360
+ const title = text$2(item.title) ?? text$2(item.headline) ?? text$2(item.name) ?? action;
13354
13361
  if (!action || !title) return void 0;
13355
13362
  const confidence = [
13356
13363
  "high",
@@ -13368,7 +13375,7 @@ function normalizeFinding(value, path, index, options) {
13368
13375
  status: "open",
13369
13376
  title,
13370
13377
  ...confidence ? { confidence } : {},
13371
- ...text$1(item.severity) ? { severity: text$1(item.severity) } : {},
13378
+ ...text$2(item.severity) ? { severity: text$2(item.severity) } : {},
13372
13379
  ...typeof affectedCount === "number" && Number.isFinite(affectedCount) ? { affectedCount } : {},
13373
13380
  ...urls ? { affectedUrls: urls } : {},
13374
13381
  sourcePath: `${path}[${index}]`,
@@ -13430,7 +13437,7 @@ function inventory(value, path, reportId) {
13430
13437
  const totalItems = typeof (source.totalPages ?? source.totalItems) === "number" ? Number(source.totalPages ?? source.totalItems) : source.rows.length;
13431
13438
  const items = source.rows.map((value, index) => {
13432
13439
  const row = record(value) ?? { value };
13433
- const identity = text$1(row.id) ?? text$1(row.url) ?? text$1(row.path) ?? `${path}[${index}]`;
13440
+ const identity = text$2(row.id) ?? text$2(row.url) ?? text$2(row.path) ?? `${path}[${index}]`;
13434
13441
  return {
13435
13442
  ...row,
13436
13443
  id: `inventory-item-${stableId(`${reportId ?? ""}|${path}|${identity}`)}`,
@@ -13449,7 +13456,7 @@ function inventory(value, path, reportId) {
13449
13456
  returnedItems: items.length,
13450
13457
  complete: source.capped === false && items.length >= totalItems,
13451
13458
  ...source.criteria !== void 0 ? { criteria: source.criteria } : {},
13452
- ...text$1(source.note) ? { note: text$1(source.note) } : {},
13459
+ ...text$2(source.note) ? { note: text$2(source.note) } : {},
13453
13460
  ...pageCount !== void 0 ? { pagination: {
13454
13461
  page,
13455
13462
  pageCount,
@@ -14915,7 +14922,7 @@ async function readableSites(google, signIn) {
14915
14922
  permissionLevel: entry.permissionLevel ?? ""
14916
14923
  })).sort((a, b) => a.siteUrl.localeCompare(b.siteUrl));
14917
14924
  }
14918
- const hostOf$3 = (value) => {
14925
+ const hostOf$4 = (value) => {
14919
14926
  try {
14920
14927
  return new URL(value.includes("://") ? value : `https://${value.replace(/^sc-domain:/, "")}`).hostname.replace(/^www\./, "");
14921
14928
  } catch {
@@ -14933,14 +14940,14 @@ async function runSites(input) {
14933
14940
  };
14934
14941
  })), input.bing ? listBingSites(input.bing).catch((error) => error instanceof Error ? error : new Error(String(error))) : Promise.resolve(null)]);
14935
14942
  const bingSites = Array.isArray(bing) ? bing.filter((site) => site.verified) : [];
14936
- const bingByHost = new Map(bingSites.map((site) => [hostOf$3(site.url), site]));
14943
+ const bingByHost = new Map(bingSites.map((site) => [hostOf$4(site.url), site]));
14937
14944
  const properties = google.flatMap((account) => account.properties instanceof Error ? [] : account.properties.map((property) => ({
14938
14945
  ...property,
14939
14946
  ...named ? { account: account.email ?? "" } : {},
14940
- ...Array.isArray(bing) ? { bing: bingByHost.get(hostOf$3(property.siteUrl))?.url ?? "" } : {}
14947
+ ...Array.isArray(bing) ? { bing: bingByHost.get(hostOf$4(property.siteUrl))?.url ?? "" } : {}
14941
14948
  })));
14942
- const googleHosts = new Set(properties.map((property) => hostOf$3(property.siteUrl)));
14943
- const bingOnly = bingSites.filter((site) => !googleHosts.has(hostOf$3(site.url)));
14949
+ const googleHosts = new Set(properties.map((property) => hostOf$4(property.siteUrl)));
14950
+ const bingOnly = bingSites.filter((site) => !googleHosts.has(hostOf$4(site.url)));
14944
14951
  const failed = google.filter((account) => account.properties instanceof Error);
14945
14952
  const analytics = google.flatMap((account) => (account.analytics ?? []).flatMap((summary) => summary.propertySummaries.map((property) => ({
14946
14953
  property: property.property.replace("properties/", ""),
@@ -22849,6 +22856,98 @@ function contentOptimizationFromPageOpportunities(sourceReport) {
22849
22856
  async function contentOptimizationReport(input) {
22850
22857
  return contentOptimizationFromPageOpportunities(await pageOpportunitiesReport(input));
22851
22858
  }
22859
+ // packages/core/dist/analyze/crawler/html-size.js
22860
+ const GOOGLEBOT_HTML_LIMIT_BYTES = 2097152;
22861
+ const NEAR_LIMIT_SHARE = .9;
22862
+ const HIDDEN_BLOCKS = /<(script|style|svg|noscript|template)\b[\s\S]*?(?:<\/\1\s*>|$)|<!--[\s\S]*?(?:-->|$)/gi;
22863
+ function wordCount(text) {
22864
+ return text.match(/[^\s&;]+/g)?.length ?? 0;
22865
+ }
22866
+ /** Where a page over Googlebot's limit puts its main content. */
22867
+ function googlebotCutEvidence(html) {
22868
+ const bytes = Buffer.from(html, "utf8");
22869
+ if (bytes.byteLength <= 2097152) return void 0;
22870
+ const marker = /<(h1|main)[\s>/]/i.exec(html);
22871
+ const text = html.replace(HIDDEN_BLOCKS, (match) => " ".repeat(match.length)).replace(/<[^>]*>?/g, (match) => " ".repeat(match.length));
22872
+ const limitIndex = bytes.subarray(0, GOOGLEBOT_HTML_LIMIT_BYTES).toString("utf8").length;
22873
+ const startBytes = marker ? Buffer.byteLength(html.slice(0, marker.index)) : void 0;
22874
+ return {
22875
+ limitBytes: GOOGLEBOT_HTML_LIMIT_BYTES,
22876
+ bytesAfterLimit: bytes.byteLength - GOOGLEBOT_HTML_LIMIT_BYTES,
22877
+ ...marker && startBytes !== void 0 ? {
22878
+ mainContentStartBytes: startBytes,
22879
+ mainContentMarker: marker[1]?.toLowerCase()
22880
+ } : {},
22881
+ mainContentStart: startBytes === void 0 ? "not-found" : startBytes >= 2097152 ? "after-limit" : startBytes >= 2097152 * NEAR_LIMIT_SHARE ? "near-limit" : "before-limit",
22882
+ visibleWordsBeforeLimit: wordCount(text.slice(0, limitIndex)),
22883
+ visibleWordsAfterLimit: wordCount(text.slice(limitIndex))
22884
+ };
22885
+ }
22886
+ const formatBytes = (value) => value.toLocaleString("en-US");
22887
+ function largeHtmlIssue(rule, page) {
22888
+ const sizeBytes = page.sizeBytes ?? 0;
22889
+ const cut = page.googlebotCut;
22890
+ const htmlSource = page.usedJs ? "rendered" : "raw";
22891
+ const base = {
22892
+ ...rule,
22893
+ detail: `${Math.round(sizeBytes / 1024)} KB`,
22894
+ evidence: {
22895
+ sizeBytes,
22896
+ thresholdBytes: GOOGLEBOT_HTML_LIMIT_BYTES,
22897
+ htmlSource
22898
+ }
22899
+ };
22900
+ if (!cut) return base;
22901
+ const words = cut.visibleWordsBeforeLimit + cut.visibleWordsAfterLimit;
22902
+ const wordsAfterShare = words ? Math.round(cut.visibleWordsAfterLimit / words * 100) / 100 : void 0;
22903
+ const indexingRisk = htmlSource === "rendered" ? "unknown" : cut.mainContentStart === "after-limit" || cut.mainContentStart === "near-limit" || (wordsAfterShare ?? 0) > .5 ? "main-content-past-limit" : "none-found";
22904
+ 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`;
22905
+ const wordsNote = wordsAfterShare === void 0 ? "" : `, with about ${Math.round(wordsAfterShare * 100)}% of the visible words`;
22906
+ return {
22907
+ ...base,
22908
+ severity: indexingRisk === "main-content-past-limit" ? "high" : base.severity,
22909
+ 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)." : ""}`,
22910
+ evidence: {
22911
+ ...base.evidence,
22912
+ googlebotLimitBytes: cut.limitBytes,
22913
+ bytesAfterLimit: cut.bytesAfterLimit,
22914
+ mainContentStartBytes: cut.mainContentStartBytes ?? null,
22915
+ mainContentMarker: cut.mainContentMarker ?? null,
22916
+ mainContentStart: cut.mainContentStart,
22917
+ visibleWordsBeforeLimit: cut.visibleWordsBeforeLimit,
22918
+ visibleWordsAfterLimit: cut.visibleWordsAfterLimit,
22919
+ visibleWordsAfterLimitShare: wordsAfterShare ?? null,
22920
+ indexingRisk
22921
+ }
22922
+ };
22923
+ }
22924
+ const RESPONSE_CAP_ERROR = /^Page response exceeds the (\d+)-byte response limit/;
22925
+ function responseCapIssue(request) {
22926
+ if (request.outcome !== "failure") return void 0;
22927
+ const capBytes = Number(RESPONSE_CAP_ERROR.exec(request.error)?.[1]);
22928
+ if (!capBytes) return void 0;
22929
+ const rule = explainRule("large_html");
22930
+ if (!rule) throw new Error("Missing rule guidance for large_html");
22931
+ return {
22932
+ ruleId: rule.id,
22933
+ title: rule.title,
22934
+ category: rule.category,
22935
+ severity: rule.defaultSeverity,
22936
+ url: request.requestedUrl,
22937
+ 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.`,
22938
+ evidence: {
22939
+ dataStatus: "partial",
22940
+ sizeBytes: null,
22941
+ sizeBytesAtLeast: capBytes,
22942
+ thresholdBytes: GOOGLEBOT_HTML_LIMIT_BYTES,
22943
+ googlebotLimitBytes: GOOGLEBOT_HTML_LIMIT_BYTES,
22944
+ bytesAfterLimitAtLeast: capBytes - GOOGLEBOT_HTML_LIMIT_BYTES,
22945
+ mainContentStartBytes: null,
22946
+ mainContentStart: "unknown",
22947
+ indexingRisk: "unknown"
22948
+ }
22949
+ };
22950
+ }
22852
22951
  // packages/core/dist/analyze/crawler/page-eligibility.js
22853
22952
  function comparableUrl(value) {
22854
22953
  if (!value) return void 0;
@@ -22887,6 +22986,21 @@ function isCdnUtilityUrl(value) {
22887
22986
  return false;
22888
22987
  }
22889
22988
  }
22989
+ function globMatch(pattern, value) {
22990
+ if (!pattern.includes("*")) return value.includes(pattern);
22991
+ const parts = pattern.split("*");
22992
+ let position = 0;
22993
+ for (const part of parts) {
22994
+ if (!part) continue;
22995
+ const found = value.slice(position).indexOf(part);
22996
+ if (found === -1) return false;
22997
+ position += found + part.length;
22998
+ }
22999
+ return true;
23000
+ }
23001
+ function passesFilters(url, include, exclude) {
23002
+ return (!include.length || include.some((pattern) => globMatch(pattern, url))) && !exclude.some((pattern) => globMatch(pattern, url));
23003
+ }
22890
23004
  // packages/core/dist/analyze/crawler/audit.js
22891
23005
  function sameUrl(a, b) {
22892
23006
  if (!a || !b) return false;
@@ -22929,7 +23043,7 @@ function urlKey(value) {
22929
23043
  }
22930
23044
  }
22931
23045
  const SLOW_RESPONSE_MS = 2e3;
22932
- const LARGE_HTML_BYTES = 2097152;
23046
+ const LARGE_HTML_BYTES = GOOGLEBOT_HTML_LIMIT_BYTES;
22933
23047
  const COMPRESSION_MIN_BYTES = 4096;
22934
23048
  const DEEP_PAGE_DEPTH = 4;
22935
23049
  const WEAK_VALUABLE_INLINKS = 1;
@@ -23101,6 +23215,11 @@ function auditCrawlRequests(requests) {
23101
23215
  for (const request of requests) {
23102
23216
  if (request.outcome === "failure") {
23103
23217
  if (request.failureKind === "aborted") continue;
23218
+ const overCap = responseCapIssue(request);
23219
+ if (overCap) {
23220
+ issues.push(overCap);
23221
+ continue;
23222
+ }
23104
23223
  issues.push(requestIssue("connection_error", request, request.error, {
23105
23224
  outcome: request.outcome,
23106
23225
  failureKind: request.failureKind,
@@ -23213,10 +23332,7 @@ function auditCrawlPages(pages, opts = {}) {
23213
23332
  outgoingInternalCount: page.outgoingInternalCount,
23214
23333
  rendering: page.fetchDiagnostics?.rendering
23215
23334
  }));
23216
- if (isHtmlPage(page) && (page.sizeBytes ?? 0) > LARGE_HTML_BYTES) issues.push(issue$1("large_html", page, `${Math.round((page.sizeBytes ?? 0) / 1024)} KB`, {
23217
- sizeBytes: page.sizeBytes,
23218
- thresholdBytes: LARGE_HTML_BYTES
23219
- }));
23335
+ if (isHtmlPage(page) && (page.sizeBytes ?? 0) > LARGE_HTML_BYTES) issues.push(largeHtmlIssue(issue$1("large_html", page), page));
23220
23336
  if (isHtmlPage(page) && !page.compression && (page.sizeBytes ?? 0) > COMPRESSION_MIN_BYTES) issues.push(issue$1("no_compression", page, void 0, {
23221
23337
  sizeBytes: page.sizeBytes,
23222
23338
  thresholdBytes: COMPRESSION_MIN_BYTES,
@@ -23467,7 +23583,11 @@ function crawlSkipReasonCountsFromRecord(input = {}) {
23467
23583
  }
23468
23584
  // packages/core/dist/analyze/crawler/crawl-url-queue.js
23469
23585
  function before(left, right) {
23470
- return left.depth < right.depth || left.depth === right.depth && left.url < right.url;
23586
+ if (left.depth !== right.depth) return left.depth < right.depth;
23587
+ const leftRank = left.rank ?? 0;
23588
+ const rightRank = right.rank ?? 0;
23589
+ if (leftRank !== rightRank) return leftRank < rightRank;
23590
+ return left.url < right.url;
23471
23591
  }
23472
23592
  var CrawlUrlQueue = class {
23473
23593
  #items = [];
@@ -23964,14 +24084,20 @@ function rateLimitRerunCommand(config, intervalMs) {
23964
24084
  return `seo crawl ${config.url}${scope}${sitemap} --max-pages ${config.maxPages} --concurrency 1 --fetch-interval-cap 1 --fetch-interval-ms ${slowMs}`;
23965
24085
  }
23966
24086
  /** Set when the sitemaps list more URLs than the page limit let the crawl read. */
23967
- function sitemapLimitCaveatInput(config, discovery, defaulted) {
23968
- if (!discovery || discovery.urlsReturned < config.maxPages || !discovery.roots.some((root) => root.possiblyTruncated)) return;
24087
+ function sitemapLimitCaveatInput(config, discovery, defaulted, sample) {
24088
+ if (!discovery) return void 0;
24089
+ const truncated = discovery.roots.some((root) => root.possiblyTruncated);
24090
+ if (!sample && (discovery.urlsReturned < config.maxPages || !truncated)) return;
23969
24091
  return {
23970
24092
  maxPages: config.maxPages,
23971
24093
  defaulted,
23972
24094
  ceiling: MAX_CRAWL_PAGES,
23973
24095
  health: config.strategy === "health",
23974
- url: config.url
24096
+ url: config.url,
24097
+ ...sample ? { sample: {
24098
+ ...sample,
24099
+ readCapped: truncated
24100
+ } } : {}
23975
24101
  };
23976
24102
  }
23977
24103
  function crawlCaveats(input) {
@@ -23983,8 +24109,9 @@ function crawlCaveats(input) {
23983
24109
  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}`);
23984
24110
  }
23985
24111
  if (input.sitemapLimit) {
23986
- const { maxPages, defaulted, ceiling, health, url } = input.sitemapLimit;
23987
- 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}`);
24112
+ const { maxPages, defaulted, ceiling, health, url, sample } = input.sitemapLimit;
24113
+ const rerun = `Raise --max-pages, up to ${ceiling.toLocaleString("en-US")}, to check more: seo crawl ${url}${health ? " --health" : ""} --max-pages ${ceiling}`;
24114
+ 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}`);
23988
24115
  }
23989
24116
  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.`);
23990
24117
  const renderingFallbackUrls = input.renderingFallbackUrls ?? 0;
@@ -24512,6 +24639,20 @@ const crawlPageSnapshotSchema = z.object({
24512
24639
  responseHeaders: z.record(z.string(), z.string()).optional(),
24513
24640
  responseTimeMs: z.number().optional(),
24514
24641
  sizeBytes: z.number().int().optional(),
24642
+ googlebotCut: z.object({
24643
+ limitBytes: z.number().int().positive(),
24644
+ bytesAfterLimit: z.number().int().nonnegative(),
24645
+ mainContentStartBytes: z.number().int().nonnegative().optional(),
24646
+ mainContentMarker: z.enum(["h1", "main"]).optional(),
24647
+ mainContentStart: z.enum([
24648
+ "before-limit",
24649
+ "near-limit",
24650
+ "after-limit",
24651
+ "not-found"
24652
+ ]),
24653
+ visibleWordsBeforeLimit: z.number().int().nonnegative(),
24654
+ visibleWordsAfterLimit: z.number().int().nonnegative()
24655
+ }).optional(),
24515
24656
  usedJs: z.boolean().optional(),
24516
24657
  fetchSource: z.enum([
24517
24658
  "cache",
@@ -25991,6 +26132,7 @@ async function snapshotFetchedPage(url, fetched) {
25991
26132
  responseTimeMs: fetched.diagnostics.durationMs,
25992
26133
  sizeBytes: Buffer.byteLength(fetched.html),
25993
26134
  usedJs: fetched.usedJs,
26135
+ googlebotCut: googlebotCutEvidence(fetched.html),
25994
26136
  fetchSource: fetched.diagnostics.source,
25995
26137
  cacheState: fetched.diagnostics.cache,
25996
26138
  fetchDiagnostics: fetched.diagnostics,
@@ -28328,6 +28470,74 @@ function sitemapDiscoveryStatus(roots) {
28328
28470
  if (!roots.some((root) => root.dataStatus !== "unavailable")) return "unavailable";
28329
28471
  return roots.every((root) => root.dataStatus === "complete") ? "complete" : "partial";
28330
28472
  }
28473
+ /** How many sitemap URLs a crawl reads to pick its sample from. */
28474
+ function sitemapReadLimit(maxPages) {
28475
+ return Math.min(5e4, Math.max(1e4, maxPages * 10));
28476
+ }
28477
+ function sectionOf(url) {
28478
+ try {
28479
+ return new URL(url).pathname.split("/")[1]?.toLowerCase() ?? "";
28480
+ } catch {
28481
+ return "";
28482
+ }
28483
+ }
28484
+ function pathDepth(url) {
28485
+ try {
28486
+ return new URL(url).pathname.split("/").filter(Boolean).length;
28487
+ } catch {
28488
+ return 0;
28489
+ }
28490
+ }
28491
+ /**
28492
+ * Picks up to `limit` URLs round-robin across sections (the first path
28493
+ * segment), so one large section such as a forum cannot fill a capped crawl.
28494
+ * Within a section, shallower paths come first, then URL order.
28495
+ */
28496
+ function spreadSitemapSample(urls, limit) {
28497
+ const groups = /* @__PURE__ */ new Map();
28498
+ for (const url of urls) {
28499
+ const key = sectionOf(url);
28500
+ const group = groups.get(key);
28501
+ if (group) group.push(url);
28502
+ else groups.set(key, [url]);
28503
+ }
28504
+ const ordered = [...groups.entries()].sort(([left], [right]) => left < right ? -1 : left > right ? 1 : 0).map(([, group]) => group.map((url) => ({
28505
+ url,
28506
+ depth: pathDepth(url)
28507
+ })).sort((left, right) => left.depth - right.depth || (left.url < right.url ? -1 : left.url > right.url ? 1 : 0)).map((entry) => entry.url));
28508
+ const picked = [];
28509
+ for (let index = 0; picked.length < limit; index += 1) {
28510
+ let added = false;
28511
+ for (const group of ordered) {
28512
+ const url = group[index];
28513
+ if (url === void 0) continue;
28514
+ added = true;
28515
+ picked.push(url);
28516
+ if (picked.length >= limit) break;
28517
+ }
28518
+ if (!added) break;
28519
+ }
28520
+ return {
28521
+ urls: picked,
28522
+ sections: groups.size
28523
+ };
28524
+ }
28525
+ /**
28526
+ * The sitemap URLs a capped crawl checks, leaving room for the seed URLs, and
28527
+ * how they were picked when the sitemaps listed more.
28528
+ */
28529
+ function sitemapCrawlSample(urls, maxPages, seedUrls, normalize) {
28530
+ const candidates = urls.filter((url) => !seedUrls.has(normalize(url) ?? url));
28531
+ const sample = spreadSitemapSample(candidates, Math.max(0, maxPages - seedUrls.size));
28532
+ return candidates.length > sample.urls.length ? {
28533
+ urls: sample.urls,
28534
+ caveat: {
28535
+ checked: sample.urls.length,
28536
+ read: candidates.length,
28537
+ sections: sample.sections
28538
+ }
28539
+ } : { urls: sample.urls };
28540
+ }
28331
28541
  async function sitemapSeeds(input) {
28332
28542
  const roots = [];
28333
28543
  const urls = /* @__PURE__ */ new Set();
@@ -28407,21 +28617,6 @@ function isLikelyAsset(url) {
28407
28617
  const path = new URL(url).pathname.toLowerCase();
28408
28618
  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);
28409
28619
  }
28410
- function globMatch(pattern, value) {
28411
- if (!pattern.includes("*")) return value.includes(pattern);
28412
- const parts = pattern.split("*");
28413
- let position = 0;
28414
- for (const part of parts) {
28415
- if (!part) continue;
28416
- const found = value.slice(position).indexOf(part);
28417
- if (found === -1) return false;
28418
- position += found + part.length;
28419
- }
28420
- return true;
28421
- }
28422
- function passesFilters(url, include, exclude) {
28423
- return (!include.length || include.some((pattern) => globMatch(pattern, url))) && !exclude.some((pattern) => globMatch(pattern, url));
28424
- }
28425
28620
  async function crawlSite(input, dependencies) {
28426
28621
  const deps = resolveCrawlSiteDependencies(dependencies);
28427
28622
  const config = assertCrawlConfigLimits(normalizeCrawlConfig(input));
@@ -28476,6 +28671,7 @@ async function crawlSite(input, dependencies) {
28476
28671
  });
28477
28672
  };
28478
28673
  let sitemapDiscovery;
28674
+ let sitemapSample;
28479
28675
  let externalLinkVerification;
28480
28676
  let statusEventChain = Promise.resolve();
28481
28677
  const emitStatus = (phase, event = {}) => {
@@ -28523,7 +28719,7 @@ async function crawlSite(input, dependencies) {
28523
28719
  const llmsTxt = aiSignals?.signals.llmsTxt;
28524
28720
  const healthSitemapUrls = healthStrategy && !config.sitemapUrl && !isCancelled() ? await robotsSession?.sitemapUrls(origin) : void 0;
28525
28721
  cancelled = isCancelled();
28526
- const enqueue = (value, depth) => {
28722
+ const enqueue = (value, depth, rank) => {
28527
28723
  const normalized = normalizeUrl$1(value, config.url);
28528
28724
  if (!normalized) {
28529
28725
  recordSkip("invalid-url");
@@ -28609,7 +28805,8 @@ async function crawlSite(input, dependencies) {
28609
28805
  queuedUrls += 1;
28610
28806
  queue.push({
28611
28807
  url: normalized,
28612
- depth
28808
+ depth,
28809
+ rank
28613
28810
  });
28614
28811
  emitStatus("url_queued", {
28615
28812
  url: normalized,
@@ -28629,12 +28826,16 @@ async function crawlSite(input, dependencies) {
28629
28826
  const sitemap = await sitemapSeeds({
28630
28827
  url: config.url,
28631
28828
  sitemapUrl: config.sitemapUrl,
28632
- maxPages: config.maxPages,
28829
+ maxPages: sitemapReadLimit(config.maxPages),
28633
28830
  declaredSitemapUrls: aiSignals?.signals.robotsTxt?.sitemapUrls ?? healthSitemapUrls ?? [],
28634
28831
  warnings,
28635
28832
  fetchSitemapUrls: deps.fetchSitemapUrls
28636
28833
  });
28637
- for (const url of sitemap.urls) enqueue(url, 0);
28834
+ const sample = sitemapCrawlSample(sitemap.urls, config.maxPages, seedUrls, (url) => normalizeUrl$1(url, config.url));
28835
+ sitemapSample = sample.caveat;
28836
+ sample.urls.forEach((url, index) => {
28837
+ enqueue(url, 0, index + 1);
28838
+ });
28638
28839
  sitemapDiscovery = sitemap.discovery;
28639
28840
  }
28640
28841
  checkMemoryPressure();
@@ -28795,7 +28996,7 @@ async function crawlSite(input, dependencies) {
28795
28996
  });
28796
28997
  continue;
28797
28998
  }
28798
- const page = result.page;
28999
+ const page = structuredClone(result.page);
28799
29000
  const rendering = page.fetchDiagnostics?.rendering;
28800
29001
  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());
28801
29002
  if (rendering?.status === "unavailable" || rendering?.status === "failed") renderingFallbackUrls += 1;
@@ -29065,7 +29266,7 @@ async function crawlSite(input, dependencies) {
29065
29266
  queueSafetyInventoryCapped,
29066
29267
  originBackpressureSkippedUrls,
29067
29268
  rateLimit: rateLimit.caveatInput(config, pages.length),
29068
- sitemapLimit: sitemapLimitCaveatInput(config, sitemapDiscovery, input.maxPages === void 0),
29269
+ sitemapLimit: sitemapLimitCaveatInput(config, sitemapDiscovery, input.maxPages === void 0, sitemapSample),
29069
29270
  memoryPressureSkippedUrls,
29070
29271
  queryVariantSkippedUrls: queryVariantSkipped.size,
29071
29272
  renderingFallbackUrls,
@@ -40984,6 +41185,12 @@ function plainFor(word) {
40984
41185
  for (const [key, value] of Object.entries(PLAIN)) if (lower.startsWith(key.slice(0, Math.max(4, key.length - 2)))) return value;
40985
41186
  return "a plain word";
40986
41187
  }
41188
+ function swapFix(word) {
41189
+ const plain = plainFor(word).replace(/\s*\([^)]*\)/g, "");
41190
+ if (plain === "a plain word") return "Use a plain word.";
41191
+ if (/^(?:say|name|give)\b/.test(plain)) return `Cut "${word}", or swap it for a detail the text already gives.`;
41192
+ return `Use ${plain.split(", ").map((swap) => `"${swap}"`).join(" or ")}.`;
41193
+ }
40987
41194
  const PHRASE_RULES = [
40988
41195
  {
40989
41196
  id: "chatbot-residue",
@@ -41142,7 +41349,7 @@ const PHRASE_RULES = [
41142
41349
  /\bstunning(?:ly)?\b|\benchanting\b/gi
41143
41350
  ],
41144
41351
  message: (match) => `"${match.text}" is far more common in AI text than in human writing.`,
41145
- fix: (match) => `Use ${plainFor(match.text.replace(/^(?:rich|a) /i, ""))}.`
41352
+ fix: (match) => swapFix(match.text.replace(/^(?:rich|a) /i, ""))
41146
41353
  },
41147
41354
  {
41148
41355
  id: "banned-word",
@@ -41162,7 +41369,7 @@ const PHRASE_RULES = [
41162
41369
  /\bgets? you to the next fix\b/gi
41163
41370
  ],
41164
41371
  message: (match) => `"${match.text}" is on the house list of words to cut.`,
41165
- 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)}.`
41372
+ 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)
41166
41373
  },
41167
41374
  {
41168
41375
  id: "buzzword",
@@ -41407,7 +41614,7 @@ const PHRASE_RULES = [
41407
41614
  /(?:^|[.!?:]\s+)(?:But|And) only (?:part|half|some|partly)\.(?=\s|$)/g
41408
41615
  ],
41409
41616
  message: "A two-word denial used for rhythm. It makes the reader wait.",
41410
- fix: "Join it to the sentence before, such as \"Nothing technical was holding these pages back.\""
41617
+ fix: "Join it to the sentence before, so the point is stated once."
41411
41618
  },
41412
41619
  {
41413
41620
  id: "strawman",
@@ -42205,10 +42412,10 @@ function stockCloser(ctx) {
42205
42412
  for (const block of ctx.blocks(RHYTHM_KINDS)) for (const sentence of ctx.sentences(block)) {
42206
42413
  const text = sentence.text;
42207
42414
  if (CLOSERS.some((re) => re.test(text))) {
42208
- 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.");
42415
+ 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.");
42209
42416
  continue;
42210
42417
  }
42211
- 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.\"");
42418
+ 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.");
42212
42419
  }
42213
42420
  }
42214
42421
  const FIGURATIVE_NOUNS = "fights?|battles?|hills?|wars?|bets?|gambles?|races?|risks?|horses?|roads?|bridges?|doors?|games?|ships?|cards?";
@@ -42998,15 +43205,24 @@ async function ahrefs(key, input, missed, totals) {
42998
43205
  order_by: "refdomains_target:desc"
42999
43206
  })).pages ?? [];
43000
43207
  if (input.action === "serp") return eachKeyword(input.keywords.slice(0, serpKeywords(input)), missed, async (keyword) => {
43001
- return ((await get("serp-overview/serp-overview", {
43208
+ const data = await get("serp-overview/serp-overview", {
43002
43209
  select: "position,type,url,title,domain_rating,traffic,keywords",
43003
43210
  keyword,
43004
43211
  country: input.country,
43005
43212
  top_positions: String(serpDepth(input))
43006
- })).positions ?? []).map((row) => ({
43007
- keyword,
43008
- ...row
43009
- }));
43213
+ });
43214
+ let organic = 0;
43215
+ return (data.positions ?? []).map((row) => {
43216
+ const { position, type, ...rest } = row;
43217
+ const kinds = Array.isArray(type) ? type.map(String) : [String(type ?? "")];
43218
+ return {
43219
+ keyword,
43220
+ position: kinds.includes("organic") ? ++organic : null,
43221
+ ahrefs_position: position ?? null,
43222
+ type: kinds.join(",") || null,
43223
+ ...rest
43224
+ };
43225
+ });
43010
43226
  });
43011
43227
  if (input.action === "backlinks") return (await get("site-explorer/refdomains", {
43012
43228
  select: "domain,domain_rating,links_to_target,dofollow_links,first_seen,last_seen",
@@ -43034,7 +43250,55 @@ function flattenIntents(row) {
43034
43250
  intents: names.join(" ") || null
43035
43251
  };
43036
43252
  }
43253
+ // packages/core/dist/commands/research-serp.js
43254
+ const GOTO = /^https?:\/\/(www\.)?google\.[a-z.]+\/goto\b/;
43255
+ const hostOf$3 = (url) => {
43256
+ try {
43257
+ return new URL(url).hostname;
43258
+ } catch {
43259
+ return "";
43260
+ }
43261
+ };
43262
+ const text$1 = (value) => typeof value === "string" && value ? value : null;
43263
+ function dataForSeoSerp(keyword, items) {
43264
+ const found = items.find((item) => item.type === "ai_overview");
43265
+ const overview = found ? {
43266
+ keyword,
43267
+ references: (found.references ?? []).slice(0, 20).map((ref) => {
43268
+ const url = String(ref.url ?? "");
43269
+ const goto = GOTO.test(url);
43270
+ return {
43271
+ domain: goto ? null : text$1(ref.domain) ?? (hostOf$3(url) || null),
43272
+ url: goto || !url ? null : url,
43273
+ title: text$1(ref.title)
43274
+ };
43275
+ })
43276
+ } : null;
43277
+ return {
43278
+ rows: items.filter((item) => item.type === "organic" || item.type === "featured_snippet").map((item) => {
43279
+ const row = {
43280
+ keyword,
43281
+ position: item.type === "organic" ? Number(item.rank_group) : null,
43282
+ rank_absolute: Number(item.rank_absolute),
43283
+ type: String(item.type),
43284
+ url: text$1(item.url),
43285
+ domain: text$1(item.domain),
43286
+ title: text$1(item.title)
43287
+ };
43288
+ if (GOTO.test(String(row.url))) {
43289
+ const crumb = String(item.breadcrumb ?? "");
43290
+ const site = /^https?:\/\/([^/\s›]+)/.exec(crumb);
43291
+ row.url = null;
43292
+ row.domain = site?.[1] ?? null;
43293
+ row.url_hint = site ? crumb.replace(/^https?:\/\//, "") : null;
43294
+ }
43295
+ return row;
43296
+ }),
43297
+ overview
43298
+ };
43299
+ }
43037
43300
  // packages/core/dist/commands/research-providers.js
43301
+ const RESEARCH_DEVICES = ["desktop", "mobile"];
43038
43302
  const RESEARCH_SCOPES = [
43039
43303
  "domain",
43040
43304
  "prefix",
@@ -43158,7 +43422,25 @@ async function dataForSeoList([login, password], path) {
43158
43422
  if (!task || task.status_code >= 4e4) throw new CommandError("PROVIDER_ERROR", `dataforseo: no list at ${path}`);
43159
43423
  return task.result ?? [];
43160
43424
  }
43161
- async function dataForSeoTask([login, password], path, task) {
43425
+ async function dataForSeoTask(keys, path, task) {
43426
+ let spent = 0;
43427
+ for (let attempt = 1;; attempt += 1) try {
43428
+ const done = await dataForSeoTaskOnce(keys, path, task);
43429
+ return {
43430
+ ...done,
43431
+ cost: done.cost + spent
43432
+ };
43433
+ } catch (error) {
43434
+ const failed = error;
43435
+ spent += failed.cost ?? 0;
43436
+ if (!SE_ERRORS.has(failed.status ?? 0) || attempt >= SE_TRIES) throw Object.assign(error, {
43437
+ cost: spent,
43438
+ ...attempt > 1 ? { tries: attempt } : {}
43439
+ });
43440
+ await wait(SE_RETRY_MS[attempt - 1] ?? 3e3);
43441
+ }
43442
+ }
43443
+ async function dataForSeoTaskOnce([login, password], path, task) {
43162
43444
  const result = (await readJson(await call("dataforseo", new Request(`https://api.dataforseo.com/${path}`, {
43163
43445
  method: "POST",
43164
43446
  headers: {
@@ -43173,7 +43455,10 @@ async function dataForSeoTask([login, password], path, task) {
43173
43455
  partial: false,
43174
43456
  noResults: true
43175
43457
  };
43176
- 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 });
43458
+ if (!result || result.status_code >= 4e4 && result.status_code !== PARTIAL) throw Object.assign(new CommandError("PROVIDER_ERROR", `dataforseo: ${result?.status_message ?? "no task result"}`), {
43459
+ cost: result?.cost ?? 0,
43460
+ status: result?.status_code ?? 0
43461
+ });
43177
43462
  return {
43178
43463
  cost: result.cost ?? 0,
43179
43464
  result: result.result?.[0] ?? null,
@@ -43182,6 +43467,9 @@ async function dataForSeoTask([login, password], path, task) {
43182
43467
  }
43183
43468
  const NO_RESULTS = 40102;
43184
43469
  const PARTIAL = 40106;
43470
+ const SE_ERRORS = /* @__PURE__ */ new Set([40101, 5e4]);
43471
+ const SE_TRIES = 3;
43472
+ const SE_RETRY_MS = [1e3, 3e3];
43185
43473
  const queued = (task) => ({
43186
43474
  id: String(task.id ?? ""),
43187
43475
  status_code: Number(task.status_code ?? 0),
@@ -43221,6 +43509,7 @@ async function dataForSeo(keys, input) {
43221
43509
  noData: withoutData(input, rows, spent.missed, 700),
43222
43510
  ...spent.available !== void 0 ? { available: spent.available } : {},
43223
43511
  ...spent.siteTotal ? { siteTotal: spent.siteTotal } : {},
43512
+ ...spent.aiOverviews ? { aiOverviews: spent.aiOverviews } : {},
43224
43513
  ...spent.rankFailed ? { rankFailed: true } : {}
43225
43514
  };
43226
43515
  }
@@ -43309,7 +43598,9 @@ async function dataForSeoRows(keys, input, spent) {
43309
43598
  }
43310
43599
  return items.map((item) => pick(item, {
43311
43600
  keyword: "keyword_data.keyword",
43312
- position: "ranked_serp_element.serp_item.rank_absolute",
43601
+ position: "ranked_serp_element.serp_item.rank_group",
43602
+ rank_absolute: "ranked_serp_element.serp_item.rank_absolute",
43603
+ type: "ranked_serp_element.serp_item.type",
43313
43604
  url: "ranked_serp_element.serp_item.url",
43314
43605
  search_volume: "keyword_data.keyword_info.search_volume",
43315
43606
  etv: "ranked_serp_element.serp_item.etv",
@@ -43319,28 +43610,13 @@ async function dataForSeoRows(keys, input, spent) {
43319
43610
  }
43320
43611
  if (input.action === "serp") {
43321
43612
  const rows = await eachKeyword(input.keywords.slice(0, serpKeywords(input)), spent.missed, async (keyword) => {
43322
- return (await post("v3/serp/google/organic/live/advanced", {
43613
+ const { rows, overview } = dataForSeoSerp(keyword, await post("v3/serp/google/organic/live/advanced", {
43323
43614
  keyword,
43324
- depth: serpDepth(input)
43325
- }, 2)).filter((item) => item.type === "organic").map((item) => {
43326
- const row = pick(item, {
43327
- position: "rank_absolute",
43328
- url: "url",
43329
- domain: "domain",
43330
- title: "title"
43331
- });
43332
- if (/^https?:\/\/(www\.)?google\.[a-z.]+\/goto\b/.test(String(row.url))) {
43333
- const crumb = String(item.breadcrumb ?? "");
43334
- const site = /^https?:\/\/([^/\s›]+)/.exec(crumb);
43335
- row.url = null;
43336
- row.domain = site?.[1] ?? null;
43337
- row.url_hint = site ? crumb.replace(/^https?:\/\//, "") : null;
43338
- }
43339
- return {
43340
- keyword,
43341
- ...row
43342
- };
43343
- });
43615
+ depth: serpDepth(input),
43616
+ device: input.device ?? "desktop"
43617
+ }, 2));
43618
+ if (overview) spent.aiOverviews = [...spent.aiOverviews ?? [], overview];
43619
+ return rows;
43344
43620
  });
43345
43621
  return input.noRank ? rows : withDomainRank(keys, rows, spent);
43346
43622
  }
@@ -43458,7 +43734,7 @@ async function serpOverlap(provider, headers, input) {
43458
43734
  const keywords = [...new Set(input.keywords.slice(0, OVERLAP_KEYWORDS))].filter((keyword) => !serps.missed.includes(keyword) && !serps.noData.includes(keyword));
43459
43735
  const host = (value) => String(value ?? "").toLowerCase().replace(/^www\./, "");
43460
43736
  const top = new Map(keywords.map((keyword) => {
43461
- const rows = serps.rows.filter((row) => row.keyword === keyword).slice(0, OVERLAP_DEPTH);
43737
+ const rows = serps.rows.filter((row) => row.keyword === keyword && row.type !== "featured_snippet").slice(0, OVERLAP_DEPTH);
43462
43738
  const pages = new Set(rows.map((row) => row.url).filter((url) => typeof url === "string" && url !== "").map((url) => url.replace(/\/$/, "").toLowerCase()));
43463
43739
  const sites = new Set(rows.map((row) => host(row.domain ?? siteOf(row.url))).filter(Boolean));
43464
43740
  return [keyword, {
@@ -47618,6 +47894,121 @@ async function runBingReport(input, deps) {
47618
47894
  data: report
47619
47895
  };
47620
47896
  }
47897
+ const DEVICES = ["desktop", "mobile"];
47898
+ const DEPTH = 10;
47899
+ const bare = (host) => host.toLowerCase().replace(/^www\./, "");
47900
+ function key(url) {
47901
+ try {
47902
+ const parsed = new URL(url);
47903
+ return `${bare(parsed.hostname)}${parsed.pathname.replace(/\/+$/, "")}`.toLowerCase();
47904
+ } catch {
47905
+ return "";
47906
+ }
47907
+ }
47908
+ function hintKey(hint) {
47909
+ const parts = hint.split("›").map((part) => part.trim());
47910
+ if (!parts[0] || parts.some((part) => /^\.\.\.$|…/.test(part))) return null;
47911
+ return `${bare(parts[0])}${parts.length > 1 ? `/${parts.slice(1).join("/")}` : ""}`.toLowerCase();
47912
+ }
47913
+ function phraseFrom(text, words = 2) {
47914
+ const cleaned = (text ?? "").split(/\s+[|\u2013\u2014-]\s+/)[0]?.replace(/["“”]/g, "").trim();
47915
+ if (!cleaned || cleaned.split(/\s+/).length < words) return null;
47916
+ return cleaned.split(/\s+/).slice(0, 12).join(" ");
47917
+ }
47918
+ async function runIndexCheck(input, headers) {
47919
+ let target;
47920
+ try {
47921
+ target = new URL(/^https?:\/\//i.test(input.url) ? input.url : `https://${input.url}`);
47922
+ } catch {
47923
+ throw new CommandError("INVALID_INPUT", "index_check needs a page URL as target.");
47924
+ }
47925
+ const keys = dataForSeoKeys(headers);
47926
+ const country = input.country ?? "us";
47927
+ const market = dataForSeoMarket(country, dataForSeoLanguage(country));
47928
+ const page = key(target.href);
47929
+ const host = bare(target.hostname);
47930
+ let phrases = (input.phrases ?? []).slice(0, 2);
47931
+ let phraseSource = phrases.length ? "given" : null;
47932
+ if (!phrases.length) {
47933
+ const live = await liveCheck(target.href).catch(() => null);
47934
+ const sentence = (live?.last?.description ?? "").split(/(?<=[.!?])\s/)[0] ?? "";
47935
+ const found = [phraseFrom(live?.last?.h1 ?? null) ?? phraseFrom(live?.last?.title ?? null), phraseFrom(sentence.replace(/[.!?]$/, ""), 5)].filter((phrase) => Boolean(phrase));
47936
+ phrases = [...new Set(found)].slice(0, 2);
47937
+ phraseSource = phrases.length ? "the page’s H1 or title and its meta description" : null;
47938
+ }
47939
+ const queries = [`site:${host}${target.pathname === "/" ? "" : target.pathname.replace(/\/+$/, "")}`, ...phrases.map((phrase) => `"${phrase.replace(/"/g, "")}"`)];
47940
+ const rows = [];
47941
+ let cost = 0;
47942
+ for (const query of queries) for (const device of DEVICES) {
47943
+ const row = {
47944
+ query,
47945
+ device,
47946
+ result: "empty",
47947
+ position: null,
47948
+ rank_absolute: null,
47949
+ results: 0,
47950
+ top_domains: "",
47951
+ error: null
47952
+ };
47953
+ try {
47954
+ const done = await dataForSeoTask(keys, "v3/serp/google/organic/live/advanced", {
47955
+ ...market,
47956
+ keyword: query,
47957
+ depth: DEPTH,
47958
+ device
47959
+ });
47960
+ cost += done.cost;
47961
+ const results = dataForSeoSerp(query, done.result?.items ?? []).rows;
47962
+ row.results = results.length;
47963
+ row.top_domains = [...new Set(results.map((r) => String(r.domain ?? "?")))].slice(0, 3).join(", ");
47964
+ const onSite = results.filter((r) => {
47965
+ const domain = bare(String(r.domain ?? ""));
47966
+ return domain === host || domain.endsWith(`.${host}`);
47967
+ });
47968
+ const match = onSite.find((r) => typeof r.url === "string" && key(r.url) === page || typeof r.url_hint === "string" && hintKey(r.url_hint) === page);
47969
+ if (match) {
47970
+ row.result = "page";
47971
+ row.position = typeof match.position === "number" ? match.position : null;
47972
+ row.rank_absolute = Number(match.rank_absolute) || null;
47973
+ } 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";
47974
+ else if (onSite.length) row.result = "other_pages";
47975
+ else if (results.length) row.result = query.startsWith("site:") ? "fallback" : "other_sites";
47976
+ } catch (error) {
47977
+ if (error instanceof CommandError && error.code === "NO_KEY") throw error;
47978
+ cost += error.cost ?? 0;
47979
+ row.result = "error";
47980
+ row.error = error.message;
47981
+ }
47982
+ rows.push(row);
47983
+ }
47984
+ const has = (result) => rows.some((row) => row.result === result);
47985
+ const verdict = has("page") ? "indexed" : has("error") || has("unresolved") ? "unknown" : "not_found";
47986
+ 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.`;
47987
+ const usd = Math.round(cost * 1e4) / 1e4;
47988
+ return {
47989
+ text: [
47990
+ `Index check for ${target.href} (${country}, desktop and mobile): ${verdict.replace("_", " ")}.`,
47991
+ reason,
47992
+ 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.",
47993
+ "",
47994
+ ...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}` : ""}`),
47995
+ "",
47996
+ "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.",
47997
+ `DataForSEO cost: $${usd}.`
47998
+ ].join("\n"),
47999
+ data: {
48000
+ provider: "dataforseo",
48001
+ action: "index_check",
48002
+ url: target.href,
48003
+ country,
48004
+ verdict,
48005
+ reason,
48006
+ phrases,
48007
+ checks: rows,
48008
+ costUsd: usd
48009
+ }
48010
+ };
48011
+ }
47621
48012
  // packages/core/dist/commands/research-cache.js
47622
48013
  const RESEARCH_TTL_SECONDS = 86400;
47623
48014
  function workersResearchStore() {
@@ -47662,10 +48053,11 @@ const RESEARCH_ACTIONS = [
47662
48053
  "top_pages",
47663
48054
  "pages_by_backlinks",
47664
48055
  "keyword_ideas",
47665
- "serp_overlap"
48056
+ "serp_overlap",
48057
+ "index_check"
47666
48058
  ];
47667
48059
  const RESEARCH_PROVIDERS = ["dataforseo", "ahrefs"];
47668
- 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.`;
48060
+ 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.`;
47669
48061
  const GENERIC_PLATFORMS = [
47670
48062
  "youtube.com",
47671
48063
  "facebook.com",
@@ -47727,6 +48119,17 @@ async function runResearch(input, keys, options) {
47727
48119
  const offset = input.offset ?? 0;
47728
48120
  if (input.action === "ai_answers") throw new CommandError("INVALID_INPUT", "ai_answers runs on its own.");
47729
48121
  const provider = chooseProvider(input.provider, keys);
48122
+ if (input.action === "index_check") {
48123
+ if (provider === "ahrefs") throw new CommandError("INVALID_INPUT", "index_check uses DataForSEO: it runs live Google searches, which Ahrefs does not offer.");
48124
+ return runIndexCheck({
48125
+ url: target,
48126
+ phrases: input.keywords,
48127
+ country
48128
+ }, keys);
48129
+ }
48130
+ 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.");
48131
+ 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.");
48132
+ const device = input.action === "serp" ? input.device ?? "desktop" : null;
47730
48133
  const language = provider === "dataforseo" ? dataForSeoLanguage(country) : null;
47731
48134
  const scope = [
47732
48135
  "ranked_keywords",
@@ -47742,7 +48145,8 @@ async function runResearch(input, keys, options) {
47742
48145
  ...scope ? { scope } : {},
47743
48146
  competitors: input.competitors ?? [],
47744
48147
  country,
47745
- ...input.action === "serp" ? { depth: input.depth ?? 20 } : {}
48148
+ ...input.action === "serp" ? { depth: input.depth ?? 20 } : {},
48149
+ ...device === "mobile" ? { device } : {}
47746
48150
  };
47747
48151
  const store = options.store ?? workersResearchStore();
47748
48152
  const key = await researchKey([
@@ -47760,10 +48164,10 @@ async function runResearch(input, keys, options) {
47760
48164
  savedAt: (/* @__PURE__ */ new Date()).toISOString(),
47761
48165
  result
47762
48166
  });
47763
- const { rows: fetched, costUsd: paid, partial, missed, noData, floor, leftOut, available, siteTotal, rankFailed, compared } = result;
48167
+ const { rows: fetched, costUsd: paid, partial, missed, noData, floor, leftOut, available, siteTotal, rankFailed, compared, aiOverviews } = result;
47764
48168
  const costUsd = saved ? 0 : paid;
47765
48169
  const order = new Map(request.keywords.map((word, index) => [word, index]));
47766
- 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;
48170
+ 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;
47767
48171
  const { rows, note } = input.action === "competitors" ? realCompetitors(found, target) : {
47768
48172
  rows: found,
47769
48173
  note: null
@@ -47781,7 +48185,7 @@ async function runResearch(input, keys, options) {
47781
48185
  const perKeyword = input.action === "serp" ? Object.fromEntries(request.keywords.map((word) => [word, rated.rows.filter((row) => row.keyword === word).length])) : null;
47782
48186
  return {
47783
48187
  text: rowsText({
47784
- title: `${provider} ${input.action}${target ? ` for ${target}` : ""}${scope ? ` (${SCOPE_TEXT[scope]})` : ""}${LINK_ACTIONS.includes(input.action) ? ", all countries" : `, ${country}${language ? `, language ${language}` : ""}`}.`,
48188
+ 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}` : ""}`}.`,
47785
48189
  rows: rated.rows,
47786
48190
  offset,
47787
48191
  limit: size,
@@ -47790,6 +48194,8 @@ async function runResearch(input, keys, options) {
47790
48194
  `${provider} estimates, in the provider’s own units.`,
47791
48195
  ...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." : ""}`] : [],
47792
48196
  ...VOLUME_ACTIONS.includes(input.action) ? [`Volume model: ${VOLUME_MODEL[provider]} ${VOLUMES_DIFFER}`] : [],
48197
+ ...input.action === "serp" || input.action === "ranked_keywords" ? [positionNote(provider)] : [],
48198
+ ...aiOverviews?.length ? [aiOverviewNote(aiOverviews, request.keywords.length)] : input.action === "serp" && provider === "dataforseo" && rows.length ? ["No AI Overview on these results pages."] : [],
47793
48199
  ...perKeyword ? [`Results per keyword: ${Object.entries(perKeyword).map(([word, count]) => `${word} ${count}`).join(", ")}. Rows run position by position across the keywords.`] : [],
47794
48200
  ...siteTotal ? [siteTotalNote(provider, siteTotal, rows.length)] : [],
47795
48201
  ...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.`] : [],
@@ -47823,6 +48229,8 @@ async function runResearch(input, keys, options) {
47823
48229
  available: available ?? null,
47824
48230
  country,
47825
48231
  language,
48232
+ ...device ? { device: provider === "dataforseo" ? device : null } : {},
48233
+ ...input.action === "serp" && provider === "dataforseo" ? { aiOverviews: aiOverviews ?? [] } : {},
47826
48234
  targetDomainRating: hasTargetDr ? targetDr : null,
47827
48235
  attribution: rated.added || hasTargetDr ? DR_ATTRIBUTION : null,
47828
48236
  leftOut: note,
@@ -47846,6 +48254,17 @@ async function runResearch(input, keys, options) {
47846
48254
  }
47847
48255
  };
47848
48256
  }
48257
+ function positionNote(provider) {
48258
+ 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.";
48259
+ }
48260
+ function aiOverviewNote(overviews, keywords) {
48261
+ const each = overviews.map((overview) => {
48262
+ const sites = [...new Set(overview.references.map((ref) => ref.domain).filter((domain) => Boolean(domain)))];
48263
+ const unknown = overview.references.filter((ref) => !ref.domain).length;
48264
+ return `${overview.keyword}: ${overview.references.length} sources${sites.length ? ` (${sites.slice(0, 6).join(", ")}${sites.length > 6 ? ", …" : ""})` : ""}${unknown ? `, ${unknown} behind Google redirect links` : ""}`;
48265
+ });
48266
+ return `AI Overview shown for ${overviews.length} of ${keywords} keyword${keywords === 1 ? "" : "s"}. ${each.join("; ")}. The full list is in the JSON as aiOverviews.`;
48267
+ }
47849
48268
  const LINK_ACTIONS = [
47850
48269
  "backlinks",
47851
48270
  "pages_by_backlinks",
@@ -48502,7 +48921,7 @@ const percent = (site) => {
48502
48921
  const clicks = site.clicks;
48503
48922
  return Math.round(Math.abs(clicks.clicks - clicks.previous) / clicks.previous * 100);
48504
48923
  };
48505
- function issues(site, startMs, endMs) {
48924
+ function issues(site, startMs, endMs, first = false) {
48506
48925
  const found = [];
48507
48926
  if (site.index?.dropped) found.push({
48508
48927
  rank: 0,
@@ -48524,11 +48943,11 @@ function issues(site, startMs, endMs) {
48524
48943
  });
48525
48944
  if (crawled && crawl.new_issues > 0) found.push({
48526
48945
  rank: 3,
48527
- heading: plural(crawl.new_issues, "new issue")
48946
+ heading: plural(crawl.new_issues, first ? "open finding" : "new issue")
48528
48947
  });
48529
48948
  return found;
48530
48949
  }
48531
- function siteLines(site, startMs, endMs) {
48950
+ function siteLines(site, startMs, endMs, first = false) {
48532
48951
  const lines = [];
48533
48952
  const index = site.index;
48534
48953
  if (!index) lines.push("Google index: no checks ran this week yet.");
@@ -48545,7 +48964,7 @@ function siteLines(site, startMs, endMs) {
48545
48964
  if (crawled && crawl.status === "failed") lines.push(`Weekly crawl: stopped after ${plural(crawl.pages, "page")}${crawl.error ? `: ${sentence(crawl.error)}` : "."} It tries again next week.`);
48546
48965
  else if (crawled) {
48547
48966
  const rule = crawl.top_rule ? AUDIT_RULES[crawl.top_rule] : void 0;
48548
- lines.push(crawl.new_issues ? `Weekly crawl: ${plural(crawl.pages, "page")}, ${plural(crawl.new_issues, "new issue")}${rule ? `, most of them ${rule.charAt(0).toLowerCase()}${rule.slice(1)}` : ""}.` : `Weekly crawl: ${plural(crawl.pages, "page")}, no new issues.`);
48967
+ lines.push(crawl.new_issues ? `Weekly crawl: ${plural(crawl.pages, "page")}, ${plural(crawl.new_issues, first ? "open finding" : "new issue")}${rule ? `, most of them ${rule.charAt(0).toLowerCase()}${rule.slice(1)}` : ""}.` : `Weekly crawl: ${plural(crawl.pages, "page")}, no new issues.`);
48549
48968
  } 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.`);
48550
48969
  else lines.push("Weekly crawl: the first crawl is still running.");
48551
48970
  const clicks = site.clicks;
@@ -48613,7 +49032,7 @@ function weekMarkdown(data) {
48613
49032
  const byName = (a, b) => siteName(a.property).localeCompare(siteName(b.property)) || a.property.localeCompare(b.property);
48614
49033
  const rows = data.sites.map((site) => ({
48615
49034
  site,
48616
- found: issues(site, startMs, endMs),
49035
+ found: issues(site, startMs, endMs, data.first),
48617
49036
  waiting: !site.crawl || !site.index
48618
49037
  }));
48619
49038
  const attention = rows.filter((row) => row.found.length);
@@ -48625,7 +49044,7 @@ function weekMarkdown(data) {
48625
49044
  "",
48626
49045
  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."
48627
49046
  ];
48628
- 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})`] : []);
49047
+ 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})`] : []);
48629
49048
  return out.join("\n");
48630
49049
  }
48631
49050
  async function runAllSites(deps, sites) {
@@ -48863,6 +49282,7 @@ const SHARED_TOOLS = [
48863
49282
  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."),
48864
49283
  country: z.string().regex(/^[a-z]{2}$/).default("us").describe("Two-letter country code."),
48865
49284
  depth: z.number().int().min(1).max(100).optional().describe(`serp only: results per keyword, default 20. Deeper costs more.`),
49285
+ device: z.enum(RESEARCH_DEVICES).optional().describe("serp only: desktop (default) or mobile. Google ranks pages differently on each. DataForSEO only."),
48866
49286
  ...detail
48867
49287
  }),
48868
49288
  run: async (surface, input) => {