seo 0.15.1 → 0.16.0

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-D12ZCG_1.js";
2
+ import { n as version, t as load } from "./esm-CV5s7esU.js";
3
3
  import { SEO_PROVIDER_API_VERSION } from "./provider-sdk.js";
4
4
  import crypto, { createHash, randomUUID, sign } from "node:crypto";
5
5
  import { z } from "zod";
@@ -343,6 +343,24 @@ const RULE_DEFINITIONS = [
343
343
  suggestedCommands: ["seo crawl <url> --max-pages 1 --json"]
344
344
  }
345
345
  },
346
+ {
347
+ id: "crawl_single_page_reach",
348
+ title: "Crawl could reach only the start page",
349
+ category: "links",
350
+ defaultSeverity: "high",
351
+ whyItMatters: "The start page had no crawlable internal links and no sitemap returned URLs, so the crawl checked one page. Every other finding in this report covers that page only. The page probably builds its links with JavaScript.",
352
+ howToFix: "Rerun with `--rendering on` so the crawler reads the links a browser sees, or pass the pages to check with `--mode list`. For crawlers that do not run JavaScript, put plain `<a href>` links in the initial HTML and publish a sitemap.",
353
+ impactIfIgnored: "Crawlers and audits that do not run JavaScript can find only this page, so the rest of the site stays unchecked and harder to discover.",
354
+ howToVerify: "Rerun the crawl and confirm it retains more than one page and the start page reports internal links.",
355
+ agentHints: {
356
+ evidenceFields: [
357
+ "page.outgoingInternalCount",
358
+ "report.sitemapDiscovery.urlsReturned",
359
+ "page.fetchDiagnostics.rendering"
360
+ ],
361
+ suggestedCommands: ["seo crawl <url> --rendering on --json", "seo crawl --mode list --urls <url>,<url> --json"]
362
+ }
363
+ },
346
364
  {
347
365
  id: "soft_404",
348
366
  title: "Unknown URLs return a successful response",
@@ -351,7 +369,7 @@ const RULE_DEFINITIONS = [
351
369
  whyItMatters: "Known-nonexistent URLs returned a successful response or redirected to another successful page. Search engines and users cannot reliably distinguish missing content from a real page.",
352
370
  howToFix: "Return 404 or 410 for missing URLs. If a replacement exists, redirect only the specific retired URL to its closest relevant replacement rather than sending every unknown path to a generic page.",
353
371
  impactIfIgnored: "Missing URLs can remain in crawl queues, waste crawl attention, and create confusing search or monitoring evidence.",
354
- howToVerify: "Request several known-nonexistent paths and confirm each returns 404 or 410 without redirecting to a generic successful page.",
372
+ howToVerify: "Request several made-up paths, including one at the site root and one nested path, and confirm each returns 404 or 410 without redirecting to a generic successful page.",
355
373
  agentHints: {
356
374
  evidenceFields: ["report.siteChecks.soft404"],
357
375
  suggestedCommands: ["seo crawl <url> --json"]
@@ -1803,7 +1821,7 @@ function day$3(value) {
1803
1821
  const date = legacy ? new Date(Number(legacy[1])) : new Date(value);
1804
1822
  return Number.isNaN(date.getTime()) ? null : date.toISOString().slice(0, 10);
1805
1823
  }
1806
- const count$3 = (value) => typeof value === "number" && Number.isFinite(value) && value >= 0 ? value : null;
1824
+ const count$4 = (value) => typeof value === "number" && Number.isFinite(value) && value >= 0 ? value : null;
1807
1825
  function list$2(data, label) {
1808
1826
  if (!Array.isArray(data)) throw new SeoError("OPTIONAL_PROVIDER_UNAVAILABLE", `Bing Webmaster returned an invalid ${label}.`);
1809
1827
  return data.filter((item) => Boolean(item) && typeof item === "object");
@@ -1819,8 +1837,8 @@ async function bingTraffic(call, site) {
1819
1837
  let invalidRows = 0;
1820
1838
  for (const row of list$2(await call("GetRankAndTrafficStats", { siteUrl: site }), "traffic list")) {
1821
1839
  const date = day$3(row.Date);
1822
- const clicks = count$3(row.Clicks);
1823
- const impressions = count$3(row.Impressions);
1840
+ const clicks = count$4(row.Clicks);
1841
+ const impressions = count$4(row.Impressions);
1824
1842
  if (!date || clicks === null || impressions === null) {
1825
1843
  invalidRows += 1;
1826
1844
  continue;
@@ -1843,9 +1861,9 @@ async function keyed(call, method, params) {
1843
1861
  for (const row of list$2(await call(method, params), "row list")) {
1844
1862
  const date = day$3(row.Date);
1845
1863
  const key = typeof row.Query === "string" ? row.Query.trim() : "";
1846
- const clicks = count$3(row.Clicks);
1847
- const impressions = count$3(row.Impressions);
1848
- const position = count$3(row.AvgImpressionPosition);
1864
+ const clicks = count$4(row.Clicks);
1865
+ const impressions = count$4(row.Impressions);
1866
+ const position = count$4(row.AvgImpressionPosition);
1849
1867
  if (!date || !key || clicks === null || impressions === null) {
1850
1868
  invalidRows += 1;
1851
1869
  continue;
@@ -2377,6 +2395,14 @@ function sharedReader(read, redirects = /* @__PURE__ */ new Map()) {
2377
2395
  };
2378
2396
  }
2379
2397
  const MAX_STATUS_BYTES = 10485760;
2398
+ async function rankingUpdates() {
2399
+ const incidents = await fetch("https://status.search.google.com/incidents.json", { signal: AbortSignal.timeout(8e3) }).then((response) => response.ok ? readJson(response, "Google Search Status", { maxBytes: MAX_STATUS_BYTES }) : null).catch(() => null);
2400
+ if (!Array.isArray(incidents)) return "Google Search Status could not be read.";
2401
+ return searchUpdatesFromIncidents(withRankingHistory(incidents), {
2402
+ product: "Ranking",
2403
+ limit: Number.POSITIVE_INFINITY
2404
+ });
2405
+ }
2380
2406
  async function googleUpdates(window) {
2381
2407
  if (!window) return [];
2382
2408
  const incidents = await fetch("https://status.search.google.com/incidents.json", { signal: AbortSignal.timeout(8e3) }).then((response) => response.ok ? readJson(response, "Google Search Status", { maxBytes: MAX_STATUS_BYTES }) : null).catch(() => null);
@@ -2709,7 +2735,7 @@ async function readableSites(google, signIn) {
2709
2735
  permissionLevel: entry.permissionLevel ?? ""
2710
2736
  })).sort((a, b) => a.siteUrl.localeCompare(b.siteUrl));
2711
2737
  }
2712
- const hostOf$2 = (value) => {
2738
+ const hostOf$3 = (value) => {
2713
2739
  try {
2714
2740
  return new URL(value.includes("://") ? value : `https://${value.replace(/^sc-domain:/, "")}`).hostname.replace(/^www\./, "");
2715
2741
  } catch {
@@ -2727,14 +2753,14 @@ async function runSites(input) {
2727
2753
  };
2728
2754
  })), input.bing ? listBingSites(input.bing).catch((error) => error instanceof Error ? error : new Error(String(error))) : Promise.resolve(null)]);
2729
2755
  const bingSites = Array.isArray(bing) ? bing.filter((site) => site.verified) : [];
2730
- const bingByHost = new Map(bingSites.map((site) => [hostOf$2(site.url), site]));
2756
+ const bingByHost = new Map(bingSites.map((site) => [hostOf$3(site.url), site]));
2731
2757
  const properties = google.flatMap((account) => account.properties instanceof Error ? [] : account.properties.map((property) => ({
2732
2758
  ...property,
2733
2759
  ...named ? { account: account.email ?? "" } : {},
2734
- ...Array.isArray(bing) ? { bing: bingByHost.get(hostOf$2(property.siteUrl))?.url ?? "" } : {}
2760
+ ...Array.isArray(bing) ? { bing: bingByHost.get(hostOf$3(property.siteUrl))?.url ?? "" } : {}
2735
2761
  })));
2736
- const googleHosts = new Set(properties.map((property) => hostOf$2(property.siteUrl)));
2737
- const bingOnly = bingSites.filter((site) => !googleHosts.has(hostOf$2(site.url)));
2762
+ const googleHosts = new Set(properties.map((property) => hostOf$3(property.siteUrl)));
2763
+ const bingOnly = bingSites.filter((site) => !googleHosts.has(hostOf$3(site.url)));
2738
2764
  const failed = google.filter((account) => account.properties instanceof Error);
2739
2765
  const analytics = google.flatMap((account) => (account.analytics ?? []).flatMap((summary) => summary.propertySummaries.map((property) => ({
2740
2766
  property: property.property.replace("properties/", ""),
@@ -8113,6 +8139,56 @@ function systemBrowserCandidates(input) {
8113
8139
  product: path.includes("google-chrome") ? "Google Chrome" : "Chromium"
8114
8140
  }));
8115
8141
  }
8142
+ function listDirectorySafely(path) {
8143
+ try {
8144
+ return readdirSync(path);
8145
+ } catch {
8146
+ return [];
8147
+ }
8148
+ }
8149
+ function versionParts(name) {
8150
+ return (name.match(/\d+/g) ?? []).map(Number);
8151
+ }
8152
+ function newestFirst(left, right) {
8153
+ const a = versionParts(left);
8154
+ const b = versionParts(right);
8155
+ for (let index = 0; index < Math.max(a.length, b.length); index += 1) {
8156
+ const difference = (b[index] ?? -1) - (a[index] ?? -1);
8157
+ if (difference) return difference;
8158
+ }
8159
+ return left < right ? -1 : left > right ? 1 : 0;
8160
+ }
8161
+ const MAX_CACHED_BROWSER_VERSIONS = 20;
8162
+ function versionDirectories(root, prefix, listDirectory) {
8163
+ return listDirectory(root).filter((name) => name.startsWith(prefix)).sort(newestFirst).slice(0, MAX_CACHED_BROWSER_VERSIONS).map((name) => join(root, name));
8164
+ }
8165
+ const CHROME_FOR_TESTING_APP = join("Google Chrome for Testing.app", "Contents", "MacOS", "Google Chrome for Testing");
8166
+ /**
8167
+ * Browsers that other local tools already downloaded: agent-browser keeps
8168
+ * Chrome for Testing builds and Playwright keeps one Chromium per revision.
8169
+ */
8170
+ function cachedBrowserCandidates(input) {
8171
+ const agentBrowserExecutables = input.platform === "darwin" ? [CHROME_FOR_TESTING_APP] : input.platform === "win32" ? ["chrome.exe"] : ["chrome"];
8172
+ const agentBrowser = versionDirectories(join(input.homeDirectory, ".agent-browser", "browsers"), "chrome-", input.listDirectory).flatMap((directory) => agentBrowserExecutables.map((executable) => ({
8173
+ path: join(directory, executable),
8174
+ source: "agent-browser-cache",
8175
+ product: "agent-browser Chrome for Testing"
8176
+ })));
8177
+ const configuredRoot = input.environment.PLAYWRIGHT_BROWSERS_PATH?.trim();
8178
+ const playwrightRoot = configuredRoot && configuredRoot !== "0" ? configuredRoot : input.platform === "darwin" ? join(input.homeDirectory, "Library", "Caches", "ms-playwright") : input.platform === "win32" ? join(input.environment.LOCALAPPDATA ?? join(input.homeDirectory, "AppData", "Local"), "ms-playwright") : join(input.environment.XDG_CACHE_HOME ?? join(input.homeDirectory, ".cache"), "ms-playwright");
8179
+ const playwrightExecutables = input.platform === "darwin" ? [
8180
+ join("chrome-mac-arm64", CHROME_FOR_TESTING_APP),
8181
+ join("chrome-mac-x64", CHROME_FOR_TESTING_APP),
8182
+ join("chrome-mac", CHROME_FOR_TESTING_APP),
8183
+ join("chrome-mac", "Chromium.app", "Contents", "MacOS", "Chromium")
8184
+ ] : input.platform === "win32" ? [join("chrome-win64", "chrome.exe"), join("chrome-win", "chrome.exe")] : [join("chrome-linux64", "chrome"), join("chrome-linux", "chrome")];
8185
+ const playwright = versionDirectories(playwrightRoot, "chromium-", input.listDirectory).flatMap((directory) => playwrightExecutables.map((executable) => ({
8186
+ path: join(directory, executable),
8187
+ source: "playwright-cache",
8188
+ product: "Playwright Chromium"
8189
+ })));
8190
+ return [...agentBrowser, ...playwright];
8191
+ }
8116
8192
  /**
8117
8193
  * Resolve a browser without downloading one during normal package installation.
8118
8194
  * The explicit path wins so CI and managed workstations can select their own
@@ -8144,11 +8220,23 @@ function resolveBrowserExecutable(playwrightExecutablePath, dependencies = {}) {
8144
8220
  product: "Playwright Chromium"
8145
8221
  }
8146
8222
  };
8147
- const browser = systemBrowserCandidates({
8148
- platform: dependencies.platform ?? process.platform,
8149
- homeDirectory: dependencies.homeDirectory ?? homedir(),
8223
+ const platform = dependencies.platform ?? process.platform;
8224
+ const homeDirectory = dependencies.homeDirectory ?? homedir();
8225
+ const systemBrowser = systemBrowserCandidates({
8226
+ platform,
8227
+ homeDirectory,
8150
8228
  environment
8151
8229
  }).find((candidate) => fileExists(candidate.path));
8230
+ if (systemBrowser) return {
8231
+ status: "available",
8232
+ browser: systemBrowser
8233
+ };
8234
+ const browser = cachedBrowserCandidates({
8235
+ platform,
8236
+ homeDirectory,
8237
+ environment,
8238
+ listDirectory: dependencies.listDirectory ?? listDirectorySafely
8239
+ }).find((candidate) => fileExists(candidate.path));
8152
8240
  return browser ? {
8153
8241
  status: "available",
8154
8242
  browser
@@ -11312,38 +11400,61 @@ function isValuablePage(page) {
11312
11400
  function normalizedMetadata(value) {
11313
11401
  return value?.trim().replace(/\s+/g, " ").toLowerCase() || void 0;
11314
11402
  }
11315
- function metadataDuplicates(pages, read) {
11316
- const counts = /* @__PURE__ */ new Map();
11403
+ /**
11404
+ * A page whose canonical names another crawled page in its group is already
11405
+ * handled. A group counts only when two or more of its pages are
11406
+ * self-canonical or declare no canonical, and only those pages are reported.
11407
+ */
11408
+ function duplicateGroups(pages, read) {
11409
+ const grouped = /* @__PURE__ */ new Map();
11317
11410
  for (const page of pages) {
11318
11411
  if (!isAuditableHtmlPage(page)) continue;
11319
- const raw = read(page)?.trim();
11320
- const key = normalizedMetadata(raw);
11321
- if (!key || !raw) continue;
11322
- const existing = counts.get(key) ?? {
11323
- count: 0,
11324
- sampleUrls: [],
11325
- value: raw
11412
+ const entry = read(page);
11413
+ if (!entry) continue;
11414
+ const group = grouped.get(entry.key) ?? {
11415
+ value: entry.value,
11416
+ pages: []
11326
11417
  };
11327
- existing.count += 1;
11328
- if (existing.sampleUrls.length < 10) existing.sampleUrls.push(page.url);
11329
- counts.set(key, existing);
11418
+ group.pages.push(page);
11419
+ grouped.set(entry.key, group);
11420
+ }
11421
+ const duplicates = /* @__PURE__ */ new Map();
11422
+ for (const [key, group] of grouped) {
11423
+ if (group.pages.length < 2) continue;
11424
+ const groupUrls = /* @__PURE__ */ new Set();
11425
+ for (const page of group.pages) for (const value of [page.url, page.finalUrl]) {
11426
+ const url = urlKey(value);
11427
+ if (url) groupUrls.add(url);
11428
+ }
11429
+ const counted = group.pages.filter((page) => {
11430
+ const canonical = urlKey(page.canonical);
11431
+ return !canonical || canonical === urlKey(page.url) || canonical === urlKey(page.finalUrl) || !groupUrls.has(canonical);
11432
+ });
11433
+ if (counted.length < 2) continue;
11434
+ duplicates.set(key, {
11435
+ count: counted.length,
11436
+ sampleUrls: counted.slice(0, 10).map((page) => page.url),
11437
+ value: group.value,
11438
+ urls: new Set(counted.map((page) => page.url))
11439
+ });
11330
11440
  }
11331
- return new Map([...counts].filter(([, value]) => value.count > 1));
11441
+ return duplicates;
11442
+ }
11443
+ function metadataDuplicates(pages, read) {
11444
+ return duplicateGroups(pages, (page) => {
11445
+ const value = read(page)?.trim();
11446
+ const key = normalizedMetadata(value);
11447
+ return key && value ? {
11448
+ key,
11449
+ value
11450
+ } : void 0;
11451
+ });
11332
11452
  }
11333
11453
  function contentDuplicates(pages) {
11334
- const counts = /* @__PURE__ */ new Map();
11335
- for (const page of pages) {
11336
- if (!isAuditableHtmlPage(page)) continue;
11337
- if (page.wordCount < 20 || !page.mainContentHash) continue;
11338
- const existing = counts.get(page.mainContentHash) ?? {
11339
- count: 0,
11340
- sampleUrls: []
11341
- };
11342
- existing.count += 1;
11343
- if (existing.sampleUrls.length < 10) existing.sampleUrls.push(page.url);
11344
- counts.set(page.mainContentHash, existing);
11345
- }
11346
- return new Map([...counts].filter(([, value]) => value.count > 1));
11454
+ return duplicateGroups(pages, (page) => page.wordCount >= 20 && page.mainContentHash ? {
11455
+ key: page.mainContentHash,
11456
+ value: page.mainContentHash
11457
+ } : void 0);
11347
11458
  }
11348
11459
  const QUERY_STOP_WORDS = /* @__PURE__ */ new Set([
11349
11460
  "a",
@@ -11553,7 +11664,7 @@ function auditCrawlPages(pages, opts = {}) {
11553
11664
  title,
11554
11665
  ...titleWidth
11555
11666
  }));
11556
- if (title && duplicateTitle) issues.push(issue$1("title_duplicate", page, duplicateTitle.value, {
11667
+ if (title && duplicateTitle?.urls.has(page.url)) issues.push(issue$1("title_duplicate", page, duplicateTitle.value, {
11557
11668
  title,
11558
11669
  duplicateCount: duplicateTitle.count,
11559
11670
  sampleUrls: duplicateTitle.sampleUrls
@@ -11561,7 +11672,7 @@ function auditCrawlPages(pages, opts = {}) {
11561
11672
  const metaDescription = page.metaDescription?.trim();
11562
11673
  const duplicateDescription = duplicateDescriptions.get(normalizedMetadata(metaDescription) ?? "");
11563
11674
  if (!metaDescription) issues.push(issue$1("missing_meta_description", page));
11564
- if (metaDescription && duplicateDescription) issues.push(issue$1("meta_description_duplicate", page, duplicateDescription.value, {
11675
+ if (metaDescription && duplicateDescription?.urls.has(page.url)) issues.push(issue$1("meta_description_duplicate", page, duplicateDescription.value, {
11565
11676
  metaDescription,
11566
11677
  duplicateCount: duplicateDescription.count,
11567
11678
  sampleUrls: duplicateDescription.sampleUrls
@@ -11609,7 +11720,7 @@ function auditCrawlPages(pages, opts = {}) {
11609
11720
  finalUrl: page.finalUrl
11610
11721
  }));
11611
11722
  const contentDuplicate = page.mainContentHash ? duplicateContent.get(page.mainContentHash) : void 0;
11612
- if (contentDuplicate) issues.push(issue$1("duplicate_content", page, "Duplicate main content", {
11723
+ if (contentDuplicate?.urls.has(page.url)) issues.push(issue$1("duplicate_content", page, "Duplicate main content", {
11613
11724
  mainContentHash: page.mainContentHash,
11614
11725
  duplicateCount: contentDuplicate.count,
11615
11726
  sampleUrls: contentDuplicate.sampleUrls
@@ -11684,6 +11795,7 @@ const CRAWL_SKIP_REASONS = [
11684
11795
  "off-origin",
11685
11796
  "origin-backpressure",
11686
11797
  "queue-safety-limit",
11798
+ "query-variant-limit",
11687
11799
  "robots-disallowed",
11688
11800
  "robots-uncertain",
11689
11801
  "legacy-unclassified"
@@ -11697,6 +11809,7 @@ const SKIP_IMPACT = {
11697
11809
  "off-origin": "non-impacting",
11698
11810
  "origin-backpressure": "coverage-affecting",
11699
11811
  "queue-safety-limit": "coverage-affecting",
11812
+ "query-variant-limit": "coverage-affecting",
11700
11813
  "robots-disallowed": "coverage-affecting",
11701
11814
  "robots-uncertain": "coverage-affecting",
11702
11815
  "legacy-unclassified": "coverage-affecting"
@@ -12351,6 +12464,10 @@ const VALUE_WEIGHT = {
12351
12464
  medium: .75,
12352
12465
  low: .15
12353
12466
  };
12467
+ const CRAWL_REACH_RULES = /* @__PURE__ */ new Set(["crawl_single_page_reach"]);
12468
+ function reachRank(ruleId) {
12469
+ return CRAWL_REACH_RULES.has(ruleId) ? 0 : 1;
12470
+ }
12354
12471
  function matchesPattern(pattern, value) {
12355
12472
  if (!pattern.includes("*")) return value.includes(pattern);
12356
12473
  const parts = pattern.split("*");
@@ -12458,7 +12575,7 @@ function topFixes(report, filters = {}) {
12458
12575
  expected: rule?.howToVerify ?? ""
12459
12576
  }
12460
12577
  };
12461
- }).sort((a, b) => b.score - a.score || SEVERITY_SCORE[b.severity] - SEVERITY_SCORE[a.severity] || b.count - a.count || a.ruleId.localeCompare(b.ruleId)).slice(0, filters.limit ?? 10);
12578
+ }).sort((a, b) => reachRank(a.ruleId) - reachRank(b.ruleId) || b.score - a.score || SEVERITY_SCORE[b.severity] - SEVERITY_SCORE[a.severity] || b.count - a.count || a.ruleId.localeCompare(b.ruleId)).slice(0, filters.limit ?? 10);
12462
12579
  }
12463
12580
  function reviewObservations(report, filters = {}) {
12464
12581
  return topFixes(report, {
@@ -13643,6 +13760,7 @@ const crawlPageSnapshotSchema = z.object({
13643
13760
  source: z.enum([
13644
13761
  "environment",
13645
13762
  "playwright-cache",
13763
+ "agent-browser-cache",
13646
13764
  "system"
13647
13765
  ]),
13648
13766
  product: z.string(),
@@ -14346,55 +14464,6 @@ function agentReadyIssues(input) {
14346
14464
  }
14347
14465
  return issues;
14348
14466
  }
14349
- // packages/core/src/analyze/crawler/crawl-caveats.ts
14350
- function crawlCaveats(input) {
14351
- const caveats = input.cancelled ? ["Crawl cancelled before all queued URLs finished."] : input.pageLimitReached ? [`Stopped after reaching maxPages (${input.maxPages}).`] : [];
14352
- if (input.queueSafetySkippedUrls > 0) caveats.push(input.queueSafetyInventoryCapped ? `Left at least ${input.queueSafetySkippedUrls} unique eligible same-origin URLs unqueued to keep this crawl bounded. The excluded-URL inventory reached its reporting cap. Increase --max-pages to inspect more.` : `Left ${input.queueSafetySkippedUrls} unique eligible same-origin URLs unqueued to keep this crawl bounded. Increase --max-pages to inspect more.`);
14353
- if (input.originBackpressureSkippedUrls > 0) caveats.push(`Stopped ${input.originBackpressureSkippedUrls} queued URL${input.originBackpressureSkippedUrls === 1 ? "" : "s"} because the origin stayed slow. This is incomplete crawl evidence, not a failed URL check.`);
14354
- 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.`);
14355
- return caveats;
14356
- }
14357
- // packages/core/src/analyze/crawler/crawl-control.ts
14358
- const CRAWL_CANCELLED = Symbol("crawl-cancelled");
14359
- function abortController(input) {
14360
- const controller = new AbortController();
14361
- const abort = () => controller.abort();
14362
- const timer = setTimeout(abort, Math.min(input.timeoutMs, 5e3));
14363
- if (input.signal?.aborted) controller.abort();
14364
- else input.signal?.addEventListener("abort", abort, { once: true });
14365
- return {
14366
- signal: controller.signal,
14367
- cleanup: () => {
14368
- clearTimeout(timer);
14369
- input.signal?.removeEventListener("abort", abort);
14370
- }
14371
- };
14372
- }
14373
- function cancellationRace(signal) {
14374
- if (signal.aborted) return Promise.resolve(CRAWL_CANCELLED);
14375
- return new Promise((resolve) => {
14376
- signal.addEventListener("abort", () => resolve(CRAWL_CANCELLED), { once: true });
14377
- });
14378
- }
14379
- // packages/core/src/analyze/crawler/crawl-memory.ts
14380
- const MEBIBYTE$1 = 1048576;
14381
- const MIN_CRAWL_MEMORY_LIMIT = 384 * MEBIBYTE$1;
14382
- const MAX_CRAWL_MEMORY_LIMIT = 640 * MEBIBYTE$1;
14383
- const MIN_CRAWL_RSS_LIMIT = 512 * MEBIBYTE$1;
14384
- const MAX_CRAWL_RSS_LIMIT = 896 * MEBIBYTE$1;
14385
- const SYSTEM_MEMORY_FRACTION = .1;
14386
- const SYSTEM_RSS_FRACTION = .2;
14387
- function crawlMemoryLimitBytes(totalMemoryBytes) {
14388
- if (!Number.isFinite(totalMemoryBytes) || totalMemoryBytes <= 0) return MAX_CRAWL_MEMORY_LIMIT;
14389
- return Math.min(MAX_CRAWL_MEMORY_LIMIT, Math.max(MIN_CRAWL_MEMORY_LIMIT, Math.floor(totalMemoryBytes * SYSTEM_MEMORY_FRACTION)));
14390
- }
14391
- function crawlRssLimitBytes(totalMemoryBytes) {
14392
- if (!Number.isFinite(totalMemoryBytes) || totalMemoryBytes <= 0) return MAX_CRAWL_RSS_LIMIT;
14393
- return Math.min(MAX_CRAWL_RSS_LIMIT, Math.max(MIN_CRAWL_RSS_LIMIT, Math.floor(totalMemoryBytes * SYSTEM_RSS_FRACTION)));
14394
- }
14395
- function crawlMemoryPressure(input) {
14396
- return input.memoryUsage.heapUsed + input.memoryUsage.external >= crawlMemoryLimitBytes(input.totalMemoryBytes) || input.memoryUsage.rss >= crawlRssLimitBytes(input.totalMemoryBytes);
14397
- }
14398
14467
  // packages/core/src/analyze/crawler/crawl-url-queue.ts
14399
14468
  function before(left, right) {
14400
14469
  return left.depth < right.depth || left.depth === right.depth && left.url < right.url;
@@ -14474,6 +14543,85 @@ var CrawlUrlQueue = class {
14474
14543
  }
14475
14544
  }
14476
14545
  };
14546
+ // packages/core/src/analyze/crawler/crawl-caveats.ts
14547
+ function crawlCaveats(input) {
14548
+ const caveats = input.cancelled ? ["Crawl cancelled before all queued URLs finished."] : input.pageLimitReached ? [`Stopped after reaching maxPages (${input.maxPages}).`] : [];
14549
+ if (input.queueSafetySkippedUrls > 0) caveats.push(input.queueSafetyInventoryCapped ? `Left at least ${input.queueSafetySkippedUrls} unique eligible same-origin URLs unqueued to keep this crawl bounded. The excluded-URL inventory reached its reporting cap. Increase --max-pages to inspect more.` : `Left ${input.queueSafetySkippedUrls} unique eligible same-origin URLs unqueued to keep this crawl bounded. Increase --max-pages to inspect more.`);
14550
+ if (input.originBackpressureSkippedUrls > 0) caveats.push(`Stopped ${input.originBackpressureSkippedUrls} queued URL${input.originBackpressureSkippedUrls === 1 ? "" : "s"} because the origin stayed slow. This is incomplete crawl evidence, not a failed URL check.`);
14551
+ if (input.memoryPressureSkippedUrls > 0) caveats.push(`Left ${input.memoryPressureSkippedUrls} eligible URL${input.memoryPressureSkippedUrls === 1 ? "" : "s"} unchecked after the local memory safety limit was reached. Start with --health or lower --max-pages.`);
14552
+ const renderingFallbackUrls = input.renderingFallbackUrls ?? 0;
14553
+ if (renderingFallbackUrls > 0) caveats.push(`JavaScript rendering could not run for ${renderingFallbackUrls} page${renderingFallbackUrls === 1 ? "" : "s"}, so ${renderingFallbackUrls === 1 ? "it was" : "they were"} audited from raw HTML. Client-rendered content, metadata and links may be missing. Install Chromium or set SEO_BROWSER_EXECUTABLE_PATH, then rerun.`);
14554
+ const queryVariantSkippedUrls = input.queryVariantSkippedUrls ?? 0;
14555
+ if (queryVariantSkippedUrls > 0) caveats.push(`Left ${queryVariantSkippedUrls} query-string variant${queryVariantSkippedUrls === 1 ? "" : "s"} unqueued after 5 variants of the same path, so tracking parameters do not fill the crawl.`);
14556
+ for (const issue of input.reachIssues ?? []) if (issue.detail) caveats.push(issue.detail);
14557
+ return caveats;
14558
+ }
14559
+ /**
14560
+ * A site crawl that found no internal links on the start page and no sitemap
14561
+ * URLs checked one page, so its other findings cannot stand for the site.
14562
+ */
14563
+ function crawlReachIssues(input) {
14564
+ const [page] = input.pages;
14565
+ if (input.crawledPages !== 1 || input.pages.length !== 1 || !page || page.outgoingInternalCount > 0 || input.sitemapUrlsReturned > 0) return [];
14566
+ const rule = explainRule("crawl_single_page_reach");
14567
+ if (!rule) throw new Error("Missing rule guidance for crawl_single_page_reach");
14568
+ return [{
14569
+ ruleId: "crawl_single_page_reach",
14570
+ title: rule.title,
14571
+ category: rule.category,
14572
+ severity: rule.defaultSeverity,
14573
+ url: page.url,
14574
+ detail: page.usedJs ? "The rendered start page had 0 internal links and no sitemap returned URLs, so the crawl checked only this page. List the pages to check with --mode list." : "The start page had 0 internal links in its HTML and no sitemap returned URLs, so the crawl checked only this page. It probably builds its links with JavaScript. Rerun with --rendering on, or list the pages with --mode list.",
14575
+ evidence: {
14576
+ startUrl: input.startUrl,
14577
+ crawledPages: 1,
14578
+ outgoingInternalCount: 0,
14579
+ sitemapUrlsReturned: 0,
14580
+ rendered: Boolean(page.usedJs)
14581
+ }
14582
+ }];
14583
+ }
14584
+ // packages/core/src/analyze/crawler/crawl-control.ts
14585
+ const CRAWL_CANCELLED = Symbol("crawl-cancelled");
14586
+ function abortController(input) {
14587
+ const controller = new AbortController();
14588
+ const abort = () => controller.abort();
14589
+ const timer = setTimeout(abort, Math.min(input.timeoutMs, 5e3));
14590
+ if (input.signal?.aborted) controller.abort();
14591
+ else input.signal?.addEventListener("abort", abort, { once: true });
14592
+ return {
14593
+ signal: controller.signal,
14594
+ cleanup: () => {
14595
+ clearTimeout(timer);
14596
+ input.signal?.removeEventListener("abort", abort);
14597
+ }
14598
+ };
14599
+ }
14600
+ function cancellationRace(signal) {
14601
+ if (signal.aborted) return Promise.resolve(CRAWL_CANCELLED);
14602
+ return new Promise((resolve) => {
14603
+ signal.addEventListener("abort", () => resolve(CRAWL_CANCELLED), { once: true });
14604
+ });
14605
+ }
14606
+ // packages/core/src/analyze/crawler/crawl-memory.ts
14607
+ const MEBIBYTE$1 = 1048576;
14608
+ const MIN_CRAWL_MEMORY_LIMIT = 384 * MEBIBYTE$1;
14609
+ const MAX_CRAWL_MEMORY_LIMIT = 640 * MEBIBYTE$1;
14610
+ const MIN_CRAWL_RSS_LIMIT = 512 * MEBIBYTE$1;
14611
+ const MAX_CRAWL_RSS_LIMIT = 896 * MEBIBYTE$1;
14612
+ const SYSTEM_MEMORY_FRACTION = .1;
14613
+ const SYSTEM_RSS_FRACTION = .2;
14614
+ function crawlMemoryLimitBytes(totalMemoryBytes) {
14615
+ if (!Number.isFinite(totalMemoryBytes) || totalMemoryBytes <= 0) return MAX_CRAWL_MEMORY_LIMIT;
14616
+ return Math.min(MAX_CRAWL_MEMORY_LIMIT, Math.max(MIN_CRAWL_MEMORY_LIMIT, Math.floor(totalMemoryBytes * SYSTEM_MEMORY_FRACTION)));
14617
+ }
14618
+ function crawlRssLimitBytes(totalMemoryBytes) {
14619
+ if (!Number.isFinite(totalMemoryBytes) || totalMemoryBytes <= 0) return MAX_CRAWL_RSS_LIMIT;
14620
+ return Math.min(MAX_CRAWL_RSS_LIMIT, Math.max(MIN_CRAWL_RSS_LIMIT, Math.floor(totalMemoryBytes * SYSTEM_RSS_FRACTION)));
14621
+ }
14622
+ function crawlMemoryPressure(input) {
14623
+ return input.memoryUsage.heapUsed + input.memoryUsage.external >= crawlMemoryLimitBytes(input.totalMemoryBytes) || input.memoryUsage.rss >= crawlRssLimitBytes(input.totalMemoryBytes);
14624
+ }
14477
14625
  // packages/core/src/analyze/crawler/external-link-checks.ts
14478
14626
  const EXTERNAL_LINK_SELECTION_LIMIT = 200;
14479
14627
  const EXTERNAL_LINK_STATES = [
@@ -14706,11 +14854,15 @@ function observationFromPage(requestedUrl, page) {
14706
14854
  };
14707
14855
  }
14708
14856
  // packages/core/src/analyze/crawler/site-checks.ts
14709
- const PROBE_COUNT = 2;
14857
+ const PROBE_COUNT = 3;
14710
14858
  function probeUrls(startUrl) {
14711
14859
  const origin = new URL(startUrl).origin;
14712
14860
  const seed = createHash("sha256").update(origin).digest("hex").slice(0, 12);
14713
- return Array.from({ length: PROBE_COUNT }, (_, index) => new URL(`/.well-known/seo-audit/not-found-${seed}-${index + 1}`, origin).toString());
14861
+ return [
14862
+ `/${seed}-not-found`,
14863
+ `/${seed}-not-found/${seed}-missing`,
14864
+ `/.well-known/seo-audit/not-found-${seed}-1`
14865
+ ].map((path) => new URL(path, origin).toString());
14714
14866
  }
14715
14867
  function probeObservation(url, result) {
14716
14868
  const request = result.request;
@@ -14747,7 +14899,7 @@ async function collectCrawlSiteChecks(input) {
14747
14899
  const conclusive = probes.filter((probe) => probe.status !== void 0 && !probe.accessBlocked);
14748
14900
  const successful = conclusive.filter((probe) => (probe.status ?? 0) >= 200 && (probe.status ?? 0) < 400);
14749
14901
  const correctMissing = conclusive.filter((probe) => probe.status === 404 || probe.status === 410);
14750
- const status = conclusive.length !== PROBE_COUNT ? "unknown" : successful.length === PROBE_COUNT ? "warning" : correctMissing.length === PROBE_COUNT ? "pass" : "unknown";
14902
+ const status = successful.length > 0 ? "warning" : correctMissing.length === PROBE_COUNT ? "pass" : "unknown";
14751
14903
  const checks = { soft404: {
14752
14904
  status,
14753
14905
  probes,
@@ -14768,7 +14920,7 @@ async function collectCrawlSiteChecks(input) {
14768
14920
  category: rule.category,
14769
14921
  severity: rule.defaultSeverity,
14770
14922
  url: new URL("/", input.startUrl).toString(),
14771
- detail: `${successful.length} known-nonexistent paths returned a successful response or redirect.`,
14923
+ detail: `${successful.length} of ${probes.length} made-up paths returned a successful response or redirect: ${successful.map((probe) => `${new URL(probe.url).pathname} (${probe.status})`).join(", ")}.`,
14772
14924
  evidence: checks.soft404
14773
14925
  }]
14774
14926
  };
@@ -17543,6 +17695,9 @@ async function crawlSite(input, dependencies) {
17543
17695
  const queueSafetyDeferredUrls = /* @__PURE__ */ new Set();
17544
17696
  const maxTrackedQueueSafetyUrls = Math.min(5e4, Math.max(1e3, config.maxPages * 5));
17545
17697
  let queueSafetyInventoryCapped = false;
17698
+ const queryVariantsByPath = /* @__PURE__ */ new Map();
17699
+ const queryVariantSkipped = /* @__PURE__ */ new Set();
17700
+ let renderingFallbackUrls = 0;
17546
17701
  let failedUrls = 0;
17547
17702
  let observedInternalLinks = 0;
17548
17703
  let memoryLimited = false;
@@ -17645,6 +17800,20 @@ async function crawlSite(input, dependencies) {
17645
17800
  });
17646
17801
  return;
17647
17802
  }
17803
+ const parsed = new URL(normalized);
17804
+ const pathKey = parsed.search ? `${parsed.origin}${parsed.pathname}` : "";
17805
+ if (pathKey && (queryVariantsByPath.get(pathKey) ?? 0) >= 5) {
17806
+ if (!queryVariantSkipped.has(normalized) && queryVariantSkipped.size < maxTrackedQueueSafetyUrls) {
17807
+ queryVariantSkipped.add(normalized);
17808
+ recordSkip("query-variant-limit");
17809
+ emitStatus("url_skipped", {
17810
+ url: normalized,
17811
+ depth,
17812
+ reason: "query_variant_limit"
17813
+ });
17814
+ }
17815
+ return;
17816
+ }
17648
17817
  if (queue.size + visited.size + inFlight.size >= config.maxPages * 5) {
17649
17818
  if (!queueSafetyDeferredUrls.has(normalized)) {
17650
17819
  if (queueSafetyDeferredUrls.size < maxTrackedQueueSafetyUrls) {
@@ -17660,6 +17829,7 @@ async function crawlSite(input, dependencies) {
17660
17829
  }
17661
17830
  return;
17662
17831
  }
17832
+ if (pathKey) queryVariantsByPath.set(pathKey, (queryVariantsByPath.get(pathKey) ?? 0) + 1);
17663
17833
  discovered.add(normalized);
17664
17834
  queuedUrls += 1;
17665
17835
  queue.push({
@@ -17812,6 +17982,9 @@ async function crawlSite(input, dependencies) {
17812
17982
  continue;
17813
17983
  }
17814
17984
  const page = result.page;
17985
+ const rendering = page.fetchDiagnostics?.rendering;
17986
+ 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());
17987
+ if (rendering?.status === "unavailable" || rendering?.status === "failed") renderingFallbackUrls += 1;
17815
17988
  const finalUrl = normalizeUrl$1(page.finalUrl, config.url);
17816
17989
  if (!finalUrl) {
17817
17990
  failedUrls += 1;
@@ -18034,6 +18207,12 @@ async function crawlSite(input, dependencies) {
18034
18207
  ...startPage.responseHeaders?.["content-signal"] ? { contentSignal: startPage.responseHeaders["content-signal"] } : {}
18035
18208
  } } : {}
18036
18209
  } : aiSignals?.signals;
18210
+ const reachIssues = followLinks && config.maxDepth > 0 && config.maxPages > 1 && !cancelled ? crawlReachIssues({
18211
+ startUrl: config.url,
18212
+ pages: pages.filter((page) => page.status >= 200 && page.status < 300),
18213
+ crawledPages: pages.length,
18214
+ sitemapUrlsReturned: sitemapDiscovery?.urlsReturned ?? 0
18215
+ }) : [];
18037
18216
  const report = createCrawlReport({
18038
18217
  config,
18039
18218
  site,
@@ -18048,6 +18227,7 @@ async function crawlSite(input, dependencies) {
18048
18227
  ...externalLinkVerification ? { externalLinkVerification } : {},
18049
18228
  ...siteCheckResult ? { siteChecks: siteCheckResult.checks } : {},
18050
18229
  additionalIssues: [
18230
+ ...reachIssues,
18051
18231
  ...siteCheckResult?.issues ?? [],
18052
18232
  ...sitemapDiscovery ? sitemapHostIssues(sitemapDiscovery, config.url) : [],
18053
18233
  ...ai ? agentReadyIssues({
@@ -18065,7 +18245,10 @@ async function crawlSite(input, dependencies) {
18065
18245
  queueSafetySkippedUrls,
18066
18246
  queueSafetyInventoryCapped,
18067
18247
  originBackpressureSkippedUrls,
18068
- memoryPressureSkippedUrls
18248
+ memoryPressureSkippedUrls,
18249
+ queryVariantSkippedUrls: queryVariantSkipped.size,
18250
+ renderingFallbackUrls,
18251
+ reachIssues
18069
18252
  }),
18070
18253
  stats: {
18071
18254
  discoveredUrls: discovered.size,
@@ -18620,7 +18803,7 @@ function analyzeCtrUnderperformersFromRows(input) {
18620
18803
  // packages/core/src/analyze/ctr-underperformers-report.ts
18621
18804
  const MAX_GSC_ROWS$3 = 1e5;
18622
18805
  const DEFAULT_DAYS$3 = 28;
18623
- const MAX_DAYS$2 = 548;
18806
+ const MAX_DAYS$3 = 548;
18624
18807
  function plural$8(count, singular, pluralLabel = `${singular}s`) {
18625
18808
  return count === 1 ? singular : pluralLabel;
18626
18809
  }
@@ -18639,12 +18822,12 @@ async function ctrUnderperformersReportWith(input, dependencies) {
18639
18822
  maximum: 100,
18640
18823
  label: "limit"
18641
18824
  });
18642
- const explicitRange = explicitDateRange(input, MAX_DAYS$2);
18825
+ const explicitRange = explicitDateRange(input, MAX_DAYS$3);
18643
18826
  const days = explicitRange?.days ?? integerOption$1({
18644
18827
  value: input.days,
18645
18828
  fallback: DEFAULT_DAYS$3,
18646
18829
  minimum: 1,
18647
- maximum: MAX_DAYS$2,
18830
+ maximum: MAX_DAYS$3,
18648
18831
  label: "Days"
18649
18832
  });
18650
18833
  const now = dependencies.now();
@@ -21885,7 +22068,7 @@ function quickWinTemplateSummaries(items) {
21885
22068
  }
21886
22069
  // packages/core/src/analyze/site-diagnostics/quick-wins-report.ts
21887
22070
  const DEFAULT_DAYS$2 = 28;
21888
- const MAX_DAYS$1 = 548;
22071
+ const MAX_DAYS$2 = 548;
21889
22072
  const DEFAULT_VERIFY_LIMIT$1 = 5;
21890
22073
  const MAX_VERIFY_LIMIT = 100;
21891
22074
  const MAX_SOURCE_ROWS$2 = 1e5;
@@ -21903,12 +22086,12 @@ function applyVerification$1(item, coverage) {
21903
22086
  };
21904
22087
  }
21905
22088
  async function quickWinsReportWith(input, dependencies) {
21906
- const explicitRange = explicitDateRange(input, MAX_DAYS$1);
22089
+ const explicitRange = explicitDateRange(input, MAX_DAYS$2);
21907
22090
  const days = explicitRange?.days ?? integerOption$1({
21908
22091
  value: input.days,
21909
22092
  fallback: DEFAULT_DAYS$2,
21910
22093
  minimum: 1,
21911
- maximum: MAX_DAYS$1,
22094
+ maximum: MAX_DAYS$2,
21912
22095
  label: "Days"
21913
22096
  });
21914
22097
  const verifyLimit = integerOption$1({
@@ -22327,7 +22510,7 @@ function analyzeStrikingDistanceRows(input) {
22327
22510
  }
22328
22511
  // packages/core/src/analyze/striking-distance-report.ts
22329
22512
  const DEFAULT_DAYS$1 = 28;
22330
- const MAX_DAYS = 548;
22513
+ const MAX_DAYS$1 = 548;
22331
22514
  const DEFAULT_VERIFY_LIMIT = 5;
22332
22515
  const MAX_SOURCE_ROWS$1 = 1e5;
22333
22516
  function boundedInteger$2(value, fallback, minimum, maximum) {
@@ -22336,7 +22519,7 @@ function boundedInteger$2(value, fallback, minimum, maximum) {
22336
22519
  }
22337
22520
  function reportDays(value) {
22338
22521
  if (value === void 0) return DEFAULT_DAYS$1;
22339
- if (!Number.isInteger(value) || value < 1 || value > MAX_DAYS) throw new SeoError("INVALID_INPUT", `Days must be a whole number between 1 and ${MAX_DAYS}.`);
22522
+ if (!Number.isInteger(value) || value < 1 || value > MAX_DAYS$1) throw new SeoError("INVALID_INPUT", `Days must be a whole number between 1 and ${MAX_DAYS$1}.`);
22340
22523
  return value;
22341
22524
  }
22342
22525
  function dateRange(days, now) {
@@ -22376,7 +22559,7 @@ function verdict$1(analysis) {
22376
22559
  return `${countLabel(analysis.summary.eligibleRows, "eligible query/page row")} found; ${countLabel(analysis.summary.returnedRows, "row")} returned for review.`;
22377
22560
  }
22378
22561
  async function strikingDistanceWith(input, dependencies) {
22379
- const explicitRange = explicitDateRange(input, MAX_DAYS);
22562
+ const explicitRange = explicitDateRange(input, MAX_DAYS$1);
22380
22563
  const days = explicitRange?.days ?? reportDays(input.days);
22381
22564
  const generatedAt = dependencies.now().toISOString();
22382
22565
  const range = explicitRange?.range ?? dateRange(days, new Date(generatedAt));
@@ -23563,7 +23746,7 @@ function typesafeDecisionModel(input) {
23563
23746
  const MAX_CONCURRENCY = 32;
23564
23747
  const MIN_CONCURRENCY = 2;
23565
23748
  const MAX_ATTEMPTS = 5;
23566
- const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
23749
+ const sleep$1 = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
23567
23750
  var DecisionRunner = class {
23568
23751
  model;
23569
23752
  limit = MAX_CONCURRENCY;
@@ -23628,7 +23811,7 @@ var DecisionRunner = class {
23628
23811
  } finally {
23629
23812
  this.release();
23630
23813
  }
23631
- await sleep(delay);
23814
+ await sleep$1(delay);
23632
23815
  }
23633
23816
  this.failed += 1;
23634
23817
  return null;
@@ -31789,6 +31972,7 @@ const wait = (ms) => new Promise((done) => setTimeout(done, ms));
31789
31972
  function requireInput(input) {
31790
31973
  const needsKeywords = input.action === "keyword_metrics" || input.action === "serp";
31791
31974
  if (needsKeywords && input.keywords.length === 0) throw new CommandError("INVALID_INPUT", `${input.action} needs keywords.`);
31975
+ if (input.action === "link_gap" && !input.target) throw new CommandError("INVALID_INPUT", "link_gap needs a target domain and competitors.");
31792
31976
  if (!needsKeywords && !input.target) throw new CommandError("INVALID_INPUT", `${input.action} needs a target domain or URL.`);
31793
31977
  }
31794
31978
  async function eachKeyword(keywords, missed, rowsFor) {
@@ -31806,14 +31990,17 @@ async function eachKeyword(keywords, missed, rowsFor) {
31806
31990
  }
31807
31991
  async function research(provider, headers, input) {
31808
31992
  requireInput(input);
31993
+ if (input.action === "link_gap") return linkGap(provider, headers, input);
31809
31994
  const keys = need(headers, provider);
31810
31995
  if (provider === "dataforseo") return dataForSeo(keys, input);
31811
31996
  const missed = [];
31997
+ const rows = await ahrefs(keys[0] ?? "", input, missed);
31812
31998
  return {
31813
- rows: await ahrefs(keys[0] ?? "", input, missed),
31999
+ rows,
31814
32000
  costUsd: null,
31815
32001
  partial: false,
31816
- missed
32002
+ missed,
32003
+ noData: withoutData(input, rows, missed, 100)
31817
32004
  };
31818
32005
  }
31819
32006
  function countryName(code) {
@@ -31865,7 +32052,8 @@ async function dataForSeoTask([login, password], path, task) {
31865
32052
  if (result?.status_code === NO_RESULTS) return {
31866
32053
  cost: result.cost ?? 0,
31867
32054
  result: null,
31868
- partial: false
32055
+ partial: false,
32056
+ noResults: true
31869
32057
  };
31870
32058
  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 });
31871
32059
  return {
@@ -31876,24 +32064,59 @@ async function dataForSeoTask([login, password], path, task) {
31876
32064
  }
31877
32065
  const NO_RESULTS = 40102;
31878
32066
  const PARTIAL = 40106;
32067
+ const queued = (task) => ({
32068
+ id: String(task.id ?? ""),
32069
+ status_code: Number(task.status_code ?? 0),
32070
+ status_message: String(task.status_message ?? ""),
32071
+ cost: Number(task.cost ?? 0),
32072
+ data: task.data ?? {},
32073
+ result: task.result?.[0] ?? null
32074
+ });
32075
+ async function dataForSeoPost([login, password], path, tasks) {
32076
+ return ((await readJson(await call("dataforseo", new Request(`https://api.dataforseo.com/${path}`, {
32077
+ method: "POST",
32078
+ headers: {
32079
+ authorization: `Basic ${btoa(`${login}:${password}`)}`,
32080
+ "content-type": "application/json"
32081
+ },
32082
+ body: JSON.stringify(tasks)
32083
+ })), "DataForSEO")).tasks ?? []).map(queued);
32084
+ }
32085
+ const DATAFORSEO_NOT_READY = /* @__PURE__ */ new Set([40601, 40602]);
32086
+ async function dataForSeoGet([login, password], path) {
32087
+ const task = (await readJson(await call("dataforseo", new Request(`https://api.dataforseo.com/${path}`, { headers: { authorization: `Basic ${btoa(`${login}:${password}`)}` } })), "DataForSEO")).tasks?.[0];
32088
+ if (!task) throw new CommandError("PROVIDER_ERROR", `dataforseo: no task at ${path}`);
32089
+ return queued(task);
32090
+ }
31879
32091
  async function dataForSeo(keys, input) {
31880
32092
  const spent = {
31881
32093
  cost: 0,
31882
32094
  partial: false,
31883
32095
  missed: []
31884
32096
  };
32097
+ const rows = await dataForSeoRows(keys, input, spent);
31885
32098
  return {
31886
- rows: await dataForSeoRows(keys, input, spent),
32099
+ rows,
31887
32100
  costUsd: Math.round(spent.cost * 1e4) / 1e4,
31888
32101
  partial: spent.partial,
31889
- missed: spent.missed
32102
+ missed: spent.missed,
32103
+ noData: withoutData(input, rows, spent.missed, 700)
31890
32104
  };
31891
32105
  }
32106
+ function withoutData(input, rows, missed, keywordLimit) {
32107
+ if (input.action === "keyword_metrics" || input.action === "serp") {
32108
+ const max = input.action === "serp" ? 5 : keywordLimit;
32109
+ const answered = new Set(rows.map((row) => String(row.keyword ?? "").toLowerCase()));
32110
+ const failed = new Set(missed);
32111
+ return input.keywords.slice(0, max).filter((keyword) => !failed.has(keyword) && !answered.has(keyword.toLowerCase()));
32112
+ }
32113
+ return [];
32114
+ }
31892
32115
  async function dataForSeoRows(keys, input, spent) {
31893
32116
  const language = dataForSeoLanguage(input.country);
31894
32117
  if (!language && input.action !== "serp" && input.action !== "backlinks") throw new CommandError("INVALID_INPUT", `DataForSEO has no ${input.action} data for country "${input.country}".`);
31895
32118
  const market = dataForSeoMarket(input.country, language);
31896
- const post = async (path, task) => {
32119
+ const post = async (path, task, attempts = 1) => {
31897
32120
  const done = await dataForSeoTask(keys, path, {
31898
32121
  ...market,
31899
32122
  ...task
@@ -31903,6 +32126,7 @@ async function dataForSeoRows(keys, input, spent) {
31903
32126
  });
31904
32127
  spent.cost += done.cost;
31905
32128
  spent.partial ||= done.partial;
32129
+ if (done.noResults && attempts > 1) return post(path, task, attempts - 1);
31906
32130
  return done.result?.items ?? [];
31907
32131
  };
31908
32132
  const get = (value, path) => path.split(".").reduce((current, key) => current && typeof current === "object" ? current[key] : void 0, value);
@@ -31932,7 +32156,7 @@ async function dataForSeoRows(keys, input, spent) {
31932
32156
  return (await post("v3/serp/google/organic/live/advanced", {
31933
32157
  keyword,
31934
32158
  depth: Math.min(input.limit, 100)
31935
- })).filter((item) => item.type === "organic").map((item) => {
32159
+ }, 2)).filter((item) => item.type === "organic").map((item) => {
31936
32160
  const row = pick(item, {
31937
32161
  position: "rank_absolute",
31938
32162
  url: "url",
@@ -31940,9 +32164,11 @@ async function dataForSeoRows(keys, input, spent) {
31940
32164
  title: "title"
31941
32165
  });
31942
32166
  if (/^https?:\/\/(www\.)?google\.[a-z.]+\/goto\b/.test(String(row.url))) {
31943
- const site = /^https?:\/\/([^/\s›]+)/.exec(String(item.breadcrumb ?? ""));
32167
+ const crumb = String(item.breadcrumb ?? "");
32168
+ const site = /^https?:\/\/([^/\s›]+)/.exec(crumb);
31944
32169
  row.url = null;
31945
32170
  row.domain = site?.[1] ?? null;
32171
+ row.url_hint = site ? crumb.replace(/^https?:\/\//, "") : null;
31946
32172
  }
31947
32173
  return {
31948
32174
  keyword,
@@ -31972,6 +32198,65 @@ async function dataForSeoRows(keys, input, spent) {
31972
32198
  organic_etv: "full_domain_metrics.organic.etv"
31973
32199
  }));
31974
32200
  }
32201
+ const GAP_DOMAINS = 1e3;
32202
+ const GAP_COMPETITORS = 5;
32203
+ async function linkGap(provider, headers, input) {
32204
+ const target = bareTarget(input.target).replace(/^www\./, "");
32205
+ const competitors = [...new Set((input.competitors ?? []).map((site) => bareTarget(site).replace(/^www\./, "")))].filter((site) => site && site !== target);
32206
+ if (competitors.length < 2 || competitors.length > GAP_COMPETITORS) throw new CommandError("INVALID_INPUT", `link_gap needs a target and 2 to ${GAP_COMPETITORS} competitors.`);
32207
+ const score = provider === "ahrefs" ? "domain_rating" : "rank";
32208
+ const sites = [target, ...competitors];
32209
+ const lists = [];
32210
+ let cost = 0;
32211
+ let partial = false;
32212
+ for (const site of sites) {
32213
+ const got = await research(provider, headers, {
32214
+ ...input,
32215
+ action: "backlinks",
32216
+ target: site,
32217
+ limit: GAP_DOMAINS
32218
+ });
32219
+ cost += got.costUsd ?? 0;
32220
+ partial ||= got.partial;
32221
+ lists.push(got.rows);
32222
+ }
32223
+ const domainOf = (row) => String(row.domain ?? "").toLowerCase().replace(/^www\./, "");
32224
+ const authority = (row) => Number(row[score] ?? 0);
32225
+ const floors = lists.filter((rows) => rows.length >= GAP_DOMAINS).map((rows) => Math.min(...rows.map(authority)));
32226
+ const floor = floors.length ? Math.max(...floors) : null;
32227
+ const own = new Set((lists[0] ?? []).map(domainOf));
32228
+ const found = /* @__PURE__ */ new Map();
32229
+ competitors.forEach((site, index) => {
32230
+ for (const row of lists[index + 1] ?? []) {
32231
+ const domain = domainOf(row);
32232
+ if (!domain || own.has(domain) || domain === target) continue;
32233
+ if (domain.endsWith(`.${target}`)) continue;
32234
+ const entry = found.get(domain) ?? {
32235
+ value: 0,
32236
+ sites: []
32237
+ };
32238
+ entry.value = Math.max(entry.value, authority(row));
32239
+ if (!entry.sites.includes(site)) entry.sites.push(site);
32240
+ found.set(domain, entry);
32241
+ }
32242
+ });
32243
+ return {
32244
+ rows: [...found.entries()].filter(([, entry]) => entry.sites.length >= 2 && (floor === null || entry.value >= floor)).sort(([a, left], [b, right]) => right.value - left.value || right.sites.length - left.sites.length || a.localeCompare(b)).map(([domain, entry]) => ({
32245
+ domain,
32246
+ [score]: entry.value,
32247
+ competitors_linking: entry.sites.length,
32248
+ links_to: entry.sites.join(" ")
32249
+ })),
32250
+ costUsd: provider === "dataforseo" ? Math.round(cost * 1e4) / 1e4 : null,
32251
+ partial,
32252
+ missed: [],
32253
+ noData: [],
32254
+ ...floor !== null ? { floor: {
32255
+ field: score,
32256
+ value: floor
32257
+ } } : {}
32258
+ };
32259
+ }
31975
32260
  function isPage(target) {
31976
32261
  return /\/./.test(bareTarget(target));
31977
32262
  }
@@ -32062,7 +32347,7 @@ const DIMENSIONS = [
32062
32347
  "device",
32063
32348
  "searchAppearance"
32064
32349
  ];
32065
- const ROWS = 25e3;
32350
+ const ROWS$1 = 25e3;
32066
32351
  async function runRows(input, deps) {
32067
32352
  const dimensions = input.dimensions?.length ? input.dimensions : ["query"];
32068
32353
  const filters = input.filters ?? [];
@@ -32071,7 +32356,7 @@ async function runRows(input, deps) {
32071
32356
  const query = {
32072
32357
  dimensions,
32073
32358
  type: "web",
32074
- maxRows: ROWS,
32359
+ maxRows: ROWS$1,
32075
32360
  ...filters.length ? { dimensionFilterGroups: [{
32076
32361
  groupType: "and",
32077
32362
  filters
@@ -32128,9 +32413,9 @@ async function runRows(input, deps) {
32128
32413
  const sum = (key) => rows.reduce((total, row) => total + Number(row[key] ?? 0), 0);
32129
32414
  notes.push(`Totals: clicks ${sum("clicks")}, impressions ${sum("impressions")}; previous period clicks ${sum("previous_clicks")}.`);
32130
32415
  }
32131
- notes.push(`Compared with ${previous.startDate} to ${previous.endDate}${before.rowsFetched >= ROWS ? " (earlier period hit the row limit)" : ""}. 0 clicks with no position means the row had no impressions in the current period.`);
32416
+ notes.push(`Compared with ${previous.startDate} to ${previous.endDate}${before.rowsFetched >= ROWS$1 ? " (earlier period hit the row limit)" : ""}. 0 clicks with no position means the row had no impressions in the current period.`);
32132
32417
  if (pageAt >= 0) notes.push("Page URLs with #fragments are merged into their page.");
32133
- if (current.rowsFetched >= ROWS) notes.push(`Search Console has more than ${ROWS} rows; add a filter to narrow it.`);
32418
+ if (current.rowsFetched >= ROWS$1) notes.push(`Search Console has more than ${ROWS$1} rows; add a filter to narrow it.`);
32134
32419
  notes.push("Search Console hides some rare queries for privacy, so query rows do not add up to the site totals.");
32135
32420
  const detail = input.detail ?? "summary";
32136
32421
  return {
@@ -32175,7 +32460,8 @@ const ENGINES = [
32175
32460
  {
32176
32461
  name: "chatgpt",
32177
32462
  means: "a ChatGPT search, read by DataForSEO",
32178
- path: "v3/ai_optimization/chat_gpt/llm_scraper/live/advanced",
32463
+ post: "v3/ai_optimization/chat_gpt/llm_scraper/task_post",
32464
+ get: "v3/ai_optimization/chat_gpt/llm_scraper/task_get/advanced/",
32179
32465
  languages: "v3/ai_optimization/chat_gpt/llm_scraper/languages",
32180
32466
  task: (question, market) => ({
32181
32467
  keyword: question,
@@ -32195,7 +32481,8 @@ const ENGINES = [
32195
32481
  {
32196
32482
  name: "google_ai_mode",
32197
32483
  means: "Google AI Mode",
32198
- path: "v3/serp/google/ai_mode/live/advanced",
32484
+ post: "v3/serp/google/ai_mode/task_post",
32485
+ get: "v3/serp/google/ai_mode/task_get/advanced/",
32199
32486
  languages: "v3/serp/google/ai_mode/languages",
32200
32487
  task: (question, market) => ({
32201
32488
  keyword: question,
@@ -32206,7 +32493,8 @@ const ENGINES = [
32206
32493
  {
32207
32494
  name: "google_ai_overview",
32208
32495
  means: "the AI Overview on a Google search",
32209
- path: "v3/serp/google/organic/live/advanced",
32496
+ post: "v3/serp/google/organic/task_post",
32497
+ get: "v3/serp/google/organic/task_get/advanced/",
32210
32498
  languages: "v3/serp/google/languages",
32211
32499
  task: (question, market) => ({
32212
32500
  keyword: question,
@@ -32228,56 +32516,71 @@ function onSite(url, domain) {
32228
32516
  return false;
32229
32517
  }
32230
32518
  }
32231
- async function ask(keys, engine, question, market, site) {
32232
- if (typeof market === "string") return {
32233
- result: "unavailable",
32234
- model: null,
32235
- cited_url: null,
32236
- answered_at: null,
32237
- cost_usd: 0,
32238
- error: market
32239
- };
32519
+ const MAX_SOURCES = 8;
32520
+ const hostOf$2 = (url) => {
32240
32521
  try {
32241
- const done = await dataForSeoTask(keys, engine.path, engine.task(question, market));
32242
- const answered = {
32243
- answered_at: String(done.result?.datetime ?? "").slice(0, 10) || null,
32244
- cost_usd: done.cost
32245
- };
32246
- const shown = done.result && engine.shown(done.result);
32247
- if (!shown) return {
32248
- result: "no_answer",
32249
- model: null,
32250
- cited_url: null,
32251
- ...answered
32252
- };
32253
- const base = {
32254
- model: shown.model,
32255
- ...answered
32256
- };
32257
- const linked = shown.markdown.match(/https?:\/\/[^\s)\]]+/g) ?? [];
32258
- const cited = [...shown.sources, ...linked].find((url) => onSite(url, site.domain));
32259
- if (cited) return {
32260
- result: "cited",
32261
- cited_url: cited,
32262
- ...base
32263
- };
32264
- const text = prose(shown.markdown);
32265
- return {
32266
- result: text.toLowerCase().includes(site.domain) || isBrandQuery(text, site.terms) ? "mentioned" : "neither",
32267
- cited_url: null,
32268
- ...base
32269
- };
32270
- } catch (error) {
32271
- if (error instanceof CommandError && error.code === "NO_KEY") throw error;
32272
- return {
32273
- result: "unavailable",
32274
- model: null,
32275
- cited_url: null,
32276
- answered_at: null,
32277
- cost_usd: error.cost ?? 0,
32278
- error: error instanceof Error ? error.message.slice(0, 200) : "failed"
32279
- };
32522
+ return new URL(url).hostname.toLowerCase().replace(/^www\./, "");
32523
+ } catch {
32524
+ return null;
32280
32525
  }
32526
+ };
32527
+ const NOT_SOURCES = [
32528
+ "dataforseo.com",
32529
+ "images.openai.com",
32530
+ "gstatic.com",
32531
+ "googleusercontent.com",
32532
+ "ggpht.com",
32533
+ "example.com",
32534
+ "example.org",
32535
+ "example.net"
32536
+ ];
32537
+ const IMAGE = /\.(png|jpe?g|gif|webp|avif|svg)(\?|#|$)/i;
32538
+ const isSource = (url) => {
32539
+ const host = hostOf$2(url);
32540
+ if (!host || IMAGE.test(url)) return false;
32541
+ return !NOT_SOURCES.some((name) => host === name || host.endsWith(`.${name}`));
32542
+ };
32543
+ const empty = {
32544
+ cited_url: null,
32545
+ sources: [],
32546
+ model: null,
32547
+ answered_at: null
32548
+ };
32549
+ function judge(engine, task, site) {
32550
+ if (task.status_code === 40102) return {
32551
+ result: "no_answer",
32552
+ ...empty
32553
+ };
32554
+ if (task.status_code >= 4e4 || !task.result) return {
32555
+ result: "unavailable",
32556
+ ...empty,
32557
+ error: `dataforseo: ${task.status_message}`.slice(0, 200)
32558
+ };
32559
+ const answered_at = String(task.result.datetime ?? "").slice(0, 10) || null;
32560
+ const shown = engine.shown(task.result);
32561
+ if (!shown) return {
32562
+ result: "no_answer",
32563
+ ...empty,
32564
+ answered_at
32565
+ };
32566
+ const linked = shown.markdown.match(/https?:\/\/[^\s)\]]+/g) ?? [];
32567
+ const all = [...shown.sources, ...linked];
32568
+ const cited = all.find((url) => onSite(url, site.domain)) ?? null;
32569
+ const base = {
32570
+ cited_url: cited,
32571
+ sources: [...new Set(all.filter((url) => !onSite(url, site.domain) && isSource(url)).map(hostOf$2).filter((host) => Boolean(host)))].slice(0, MAX_SOURCES),
32572
+ model: shown.model,
32573
+ answered_at
32574
+ };
32575
+ if (cited) return {
32576
+ result: "cited",
32577
+ ...base
32578
+ };
32579
+ const text = prose(shown.markdown);
32580
+ return {
32581
+ result: text.toLowerCase().includes(site.domain) || isBrandQuery(text, site.terms) ? "mentioned" : "neither",
32582
+ ...base
32583
+ };
32281
32584
  }
32282
32585
  async function marketsFor(keys, country) {
32283
32586
  const code = country.toUpperCase();
@@ -32295,45 +32598,162 @@ async function marketsFor(keys, country) {
32295
32598
  }));
32296
32599
  }
32297
32600
  const usd = (value) => Math.round(value * 1e4) / 1e4;
32601
+ const FIRST_WAIT_MS = 1e4;
32602
+ const ROUND_WAIT_MS = 5e3;
32603
+ const sleep = (ms) => new Promise((done) => setTimeout(done, ms));
32604
+ const TASK_PATTERN = /^([a-z_]+):([0-9a-f-]{20,64})$/;
32605
+ const AI_ANSWER_TASK_PATTERN = TASK_PATTERN;
32298
32606
  async function runAiAnswers(input, headers, deps, more) {
32299
32607
  const keys = dataForSeoKeys(headers);
32300
32608
  const domain = registrableDomain(input.site)?.domain;
32301
32609
  if (!domain) throw new CommandError("INVALID_INPUT", `ai_answers needs a site with a domain, not "${input.site}".`);
32302
- const given = [...new Set((input.questions ?? []).map((q) => q.trim()).filter(Boolean))];
32303
- if (given.length > 10) throw new CommandError("INVALID_INPUT", `ai_answers takes up to 10 questions, not ${given.length}.`);
32304
- const range = dateWindow({}, QUESTION_DAYS);
32305
- let questions;
32306
- if (given.length) questions = given.map((query) => ({
32307
- query,
32308
- impressions: null
32309
- }));
32310
- else questions = ((await aiQueriesReport({
32311
- site: input.site,
32312
- ...range
32313
- }, deps.searchAnalytics)).items ?? []).filter((item) => item.askable).slice(0, 10);
32314
32610
  const site = {
32315
32611
  domain,
32316
32612
  terms: deriveBrandTerms({ siteUrl: input.site })
32317
32613
  };
32318
- const markets = questions.length ? await marketsFor(keys, input.country) : /* @__PURE__ */ new Map();
32319
- const answers = await Promise.all(questions.flatMap((question) => ENGINES.map(async (engine) => ({
32320
- question: question.query,
32321
- impressions: question.impressions,
32322
- engine: engine.name,
32323
- ...await ask(keys, engine, question.query, markets.get(engine.name) ?? "no market", site)
32324
- }))));
32325
- const cost = usd(answers.reduce((sum, row) => sum + row.cost_usd, 0));
32326
- const count = (result) => answers.filter((row) => row.result === result).length;
32327
- const notes = [given.length ? `The ${given.length} questions you gave, asked as written. Impressions are blank because they don't come from Search Console.` : questions.length ? `The top ${questions.length} askable questions in Search Console’s AI-shaped queries for ${range.startDate} to ${range.endDate}, by impressions. No calls were made for other questions.` : `No askable questions in Search Console’s AI-shaped queries for ${range.startDate} to ${range.endDate}, so no answers were checked and nothing was spent.`, ...questions.length ? [
32328
- `Cited ${count("cited")}, mentioned ${count("mentioned")}, neither ${count("neither")}, no answer shown ${count("no_answer")}, unavailable ${count("unavailable")}. DataForSEO cost: $${cost}.`,
32614
+ const given = [...new Set((input.questions ?? []).map((q) => q.trim()).filter(Boolean))];
32615
+ if (given.length > 10) throw new CommandError("INVALID_INPUT", `ai_answers takes up to 10 questions, not ${given.length}.`);
32616
+ const tasks = [...new Set(input.tasks ?? [])];
32617
+ const range = dateWindow({}, QUESTION_DAYS);
32618
+ const rows = [];
32619
+ const waiting = [];
32620
+ const blank = (question, impressions) => ({
32621
+ question,
32622
+ impressions,
32623
+ task: null,
32624
+ cost_usd: 0,
32625
+ ...empty
32626
+ });
32627
+ if (tasks.length) for (const value of tasks) {
32628
+ const match = TASK_PATTERN.exec(value);
32629
+ const engine = ENGINES.find((item) => item.name === match?.[1]);
32630
+ if (!match || !engine) throw new CommandError("INVALID_INPUT", `"${value}" is not an ai_answers task. Tasks look like <engine>:<id>, with the engine one of ${ENGINES.map((item) => item.name).join(", ")}.`);
32631
+ const row = {
32632
+ ...blank("", null),
32633
+ engine: engine.name,
32634
+ result: "pending",
32635
+ task: value
32636
+ };
32637
+ rows.push(row);
32638
+ waiting.push({
32639
+ row,
32640
+ engine,
32641
+ id: match[2]
32642
+ });
32643
+ }
32644
+ else {
32645
+ let questions;
32646
+ if (given.length) questions = given.map((query) => ({
32647
+ query,
32648
+ impressions: null
32649
+ }));
32650
+ else questions = ((await aiQueriesReport({
32651
+ site: input.site,
32652
+ ...range
32653
+ }, deps.searchAnalytics)).items ?? []).filter((item) => item.askable).slice(0, 10);
32654
+ const markets = questions.length ? await marketsFor(keys, input.country) : /* @__PURE__ */ new Map();
32655
+ await Promise.all(ENGINES.map(async (engine) => {
32656
+ const engineRows = questions.map((question) => ({
32657
+ ...blank(question.query, question.impressions),
32658
+ engine: engine.name,
32659
+ result: "unavailable"
32660
+ }));
32661
+ rows.push(...engineRows);
32662
+ if (!questions.length) return;
32663
+ const market = markets.get(engine.name) ?? "no market";
32664
+ if (typeof market === "string") {
32665
+ for (const row of engineRows) row.error = market;
32666
+ return;
32667
+ }
32668
+ let queuedTasks;
32669
+ try {
32670
+ queuedTasks = await dataForSeoPost(keys, engine.post, questions.map((question) => ({
32671
+ ...engine.task(question.query, market),
32672
+ priority: 2
32673
+ })));
32674
+ } catch (error) {
32675
+ if (error instanceof CommandError && error.code === "NO_KEY") throw error;
32676
+ for (const row of engineRows) row.error = error instanceof Error ? error.message.slice(0, 200) : "failed";
32677
+ return;
32678
+ }
32679
+ engineRows.forEach((row, index) => {
32680
+ const task = queuedTasks[index];
32681
+ row.cost_usd = task?.cost ?? 0;
32682
+ if (!task || task.status_code >= 4e4 || !task.id) {
32683
+ row.error = `dataforseo: ${task?.status_message ?? "no task"}`;
32684
+ return;
32685
+ }
32686
+ row.result = "pending";
32687
+ row.task = `${engine.name}:${task.id}`;
32688
+ waiting.push({
32689
+ row,
32690
+ engine,
32691
+ id: task.id
32692
+ });
32693
+ });
32694
+ }));
32695
+ }
32696
+ const deadline = Date.now() + (input.waitSeconds ?? 40) * 1e3;
32697
+ let pending = waiting;
32698
+ let first = !tasks.length;
32699
+ while (pending.length) {
32700
+ const pause = first ? FIRST_WAIT_MS : ROUND_WAIT_MS;
32701
+ first = false;
32702
+ if (!tasks.length || pending !== waiting) {
32703
+ if (Date.now() + pause > deadline) break;
32704
+ await sleep(pause);
32705
+ }
32706
+ const still = [];
32707
+ await Promise.all(pending.map(async (item) => {
32708
+ try {
32709
+ const task = await dataForSeoGet(keys, `${item.engine.get}${item.id}`);
32710
+ if (!item.row.question) item.row.question = String(task.data.keyword ?? "");
32711
+ if (DATAFORSEO_NOT_READY.has(task.status_code)) {
32712
+ still.push(item);
32713
+ return;
32714
+ }
32715
+ Object.assign(item.row, judge(item.engine, task, site), { task: null });
32716
+ } catch (error) {
32717
+ if (error instanceof CommandError && error.code === "NO_KEY") throw error;
32718
+ still.push(item);
32719
+ }
32720
+ }));
32721
+ pending = still;
32722
+ if (tasks.length && Date.now() >= deadline) break;
32723
+ }
32724
+ rows.sort((a, b) => a.question.localeCompare(b.question) || ENGINES.findIndex((e) => e.name === a.engine) - ENGINES.findIndex((e) => e.name === b.engine));
32725
+ const cost = usd(rows.reduce((sum, row) => sum + row.cost_usd, 0));
32726
+ const count = (result) => rows.filter((row) => row.result === result).length;
32727
+ const left = rows.filter((row) => row.task).map((row) => row.task);
32728
+ const cites = /* @__PURE__ */ new Map();
32729
+ for (const row of rows) for (const host of row.sources) cites.set(host, (cites.get(host) ?? 0) + 1);
32730
+ const topCited = [...cites].sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0])).slice(0, 10);
32731
+ const questionCount = new Set(rows.map((row) => row.question)).size;
32732
+ const notes = [tasks.length ? `${tasks.length === 1 ? "One answer" : `${tasks.length} answers`} queued by an earlier run, read back at no cost.` : given.length ? `The ${given.length} questions you gave, asked as written. Impressions are blank because they don't come from Search Console.` : questionCount ? `The top ${questionCount} askable questions in Search Console’s AI-shaped queries for ${range.startDate} to ${range.endDate}, by impressions. No calls were made for other questions.` : `No askable questions in Search Console’s AI-shaped queries for ${range.startDate} to ${range.endDate}, so no answers were checked and nothing was spent.`, ...rows.length ? [
32733
+ `Cited ${count("cited")}, mentioned ${count("mentioned")}, neither ${count("neither")}, no answer shown ${count("no_answer")}, unavailable ${count("unavailable")}, not ready ${count("pending")}. DataForSEO cost: $${cost}.`,
32734
+ ...left.length ? [`${left.length === 1 ? "One answer wasn't" : `${left.length} answers weren't`} ready yet. They're paid for: ask ai_answers again with the same target and tasks ${JSON.stringify(left)} in a minute to read them at no cost (DataForSEO keeps them for 30 days).`] : [],
32735
+ ...topCited.length ? [`Other sites these answers cite: ${topCited.map(([host, n]) => `${host} (${n})`).join(", ")}.`] : [],
32329
32736
  "Each row is one sample of an answer that changes from run to run, not a ranking.",
32330
- `cited: the answer lists or links a page on ${domain} or a subdomain. mentioned: no such source, but the answer text names ${[domain, ...site.terms].join(" or ")}, a text match. no_answer: the engine showed no AI answer for the question. model: the model DataForSEO reports for the answer, when it reports one.`,
32737
+ `cited: the answer lists or links a page on ${domain} or a subdomain. mentioned: no such source, but the answer text names ${[domain, ...site.terms].join(" or ")}, a text match. no_answer: the engine showed no AI answer for the question. sources: the other sites the answer cites, up to ${MAX_SOURCES}. model: the model DataForSEO reports for the answer, when it reports one.`,
32331
32738
  `Engines: ${ENGINES.map((engine) => `${engine.name} is ${engine.means}`).join("; ")}.`
32332
32739
  ] : []];
32740
+ const shown = rows.map((row) => ({
32741
+ question: row.question,
32742
+ impressions: row.impressions,
32743
+ engine: row.engine,
32744
+ result: row.result,
32745
+ cited_url: row.cited_url,
32746
+ sources: row.sources.join(", "),
32747
+ model: row.model,
32748
+ answered_at: row.answered_at,
32749
+ cost_usd: row.cost_usd,
32750
+ task: row.task,
32751
+ error: row.error ?? null
32752
+ }));
32333
32753
  return {
32334
32754
  text: rowsText({
32335
32755
  title: `AI answers for ${input.site}, ${input.country}.`,
32336
- rows: answers,
32756
+ rows: shown,
32337
32757
  offset: input.offset,
32338
32758
  limit: 50,
32339
32759
  detail: input.detail,
@@ -32345,15 +32765,23 @@ async function runAiAnswers(input, headers, deps, more) {
32345
32765
  action: "ai_answers",
32346
32766
  site: input.site,
32347
32767
  country: input.country,
32348
- questionsFrom: given.length ? { section: "given" } : {
32768
+ questionsFrom: tasks.length ? { section: "tasks" } : given.length ? { section: "given" } : {
32349
32769
  section: "ai-queries",
32350
32770
  ...range
32351
32771
  },
32352
- questions: questions.map((question) => question.query),
32772
+ questions: [...new Set(rows.map((row) => row.question))],
32353
32773
  costUsd: cost,
32354
- total: answers.length,
32774
+ pendingTasks: left,
32775
+ citedSites: topCited.map(([host, answers]) => ({
32776
+ host,
32777
+ answers
32778
+ })),
32779
+ total: rows.length,
32355
32780
  offset: input.offset,
32356
- rows: answers.slice(input.offset, input.offset + 50)
32781
+ rows: rows.slice(input.offset, input.offset + 50).map(({ error, ...row }) => error ? {
32782
+ ...row,
32783
+ error
32784
+ } : row)
32357
32785
  }
32358
32786
  };
32359
32787
  }
@@ -32730,7 +33158,7 @@ async function totals$1(found, access, range, before, pages) {
32730
33158
  notes
32731
33159
  };
32732
33160
  }
32733
- const change$1 = (value) => value === null ? "n/a" : `${value > 0 ? "+" : ""}${value}%`;
33161
+ const change$2 = (value) => value === null ? "n/a" : `${value > 0 ? "+" : ""}${value}%`;
32734
33162
  function engineText(rows) {
32735
33163
  const shown = rows.filter((row) => row.sessions || row.previousSessions);
32736
33164
  if (shown.length < 2) return "";
@@ -32740,7 +33168,7 @@ function aiText(ai) {
32740
33168
  const list = (rows, key) => rows.slice(0, AI_ROWS).map((row) => `${row[key]} ${row.sessions} (was ${row.previousSessions})`).join(", ");
32741
33169
  if (!ai.sessions && !ai.previousSessions) return "Visits from AI assistants (ChatGPT, Perplexity, Claude, Gemini, Copilot and others): none in either period.";
32742
33170
  return [
32743
- `Visits from AI assistants (ChatGPT, Perplexity, Claude, Gemini, Copilot and others): ${ai.sessions} sessions (was ${ai.previousSessions}, ${change$1(ai.sessionsChangePct)}). By source: ${list(ai.bySource, "source")}.`,
33171
+ `Visits from AI assistants (ChatGPT, Perplexity, Claude, Gemini, Copilot and others): ${ai.sessions} sessions (was ${ai.previousSessions}, ${change$2(ai.sessionsChangePct)}). By source: ${list(ai.bySource, "source")}.`,
32744
33172
  ai.topLandingPages.length ? `Top landing pages from AI assistants: ${list(ai.topLandingPages, "page")}.` : "",
32745
33173
  "Only visits that pass a referrer or utm_source count; AI apps that send none show as Direct."
32746
33174
  ].filter(Boolean).join(" ");
@@ -32857,7 +33285,7 @@ async function analyticsBeside(access, site, range, before, pages) {
32857
33285
  const keyEvents = organic.keyEvents || organic.previousKeyEvents ? `, Organic Search key events ${organic.keyEvents} (was ${organic.previousKeyEvents})` : "";
32858
33286
  return {
32859
33287
  text: [
32860
- `Google Analytics, property ${data.propertyName} (${data.property}), matched by its web stream ${data.webStream}${pages ? `, landing pages whose path contains "${pages}"` : ""}: Organic Search sessions from Google ${google.sessions} (was ${google.previousSessions}, ${change$1(google.sessionsChangePct)}), from every search engine ${organic.sessions} (was ${organic.previousSessions}, ${change$1(organic.sessionsChangePct)})${keyEvents}, sessions from all channels ${all.sessions} (was ${all.previousSessions}, ${change$1(all.sessionsChangePct)}).`,
33288
+ `Google Analytics, property ${data.propertyName} (${data.property}), matched by its web stream ${data.webStream}${pages ? `, landing pages whose path contains "${pages}"` : ""}: Organic Search sessions from Google ${google.sessions} (was ${google.previousSessions}, ${change$2(google.sessionsChangePct)}), from every search engine ${organic.sessions} (was ${organic.previousSessions}, ${change$2(organic.sessionsChangePct)})${keyEvents}, sessions from all channels ${all.sessions} (was ${all.previousSessions}, ${change$2(all.sessionsChangePct)}).`,
32861
33289
  engineText(data.organicSearchByEngine),
32862
33290
  "Sessions are Google Analytics’ own count, not Search Console clicks.",
32863
33291
  ...data.notes,
@@ -33084,7 +33512,7 @@ function auditPreview(snapshot) {
33084
33512
  }
33085
33513
  const WEEK_MS = 6048e5;
33086
33514
  const day$1 = (ms) => ms === null ? "" : new Date(ms).toISOString().slice(0, 10);
33087
- const count$2 = (value) => value.toLocaleString("en-US");
33515
+ const count$3 = (value) => value.toLocaleString("en-US");
33088
33516
  function relative$2(value, origin) {
33089
33517
  if (!value) return "";
33090
33518
  return origin && value.startsWith(origin) ? value.slice(origin.length) || "/" : value;
@@ -33105,7 +33533,7 @@ function rowsTable(rows, checks) {
33105
33533
  changed(row) ? row.prev_coverage ?? "" : "",
33106
33534
  changed(row) ? day$1(row.changed_at) : "",
33107
33535
  day$1(row.checked_at),
33108
- `${count$2(row.clicks)} (${count$2(row.previous_clicks)})${row.lost ? " lost" : ""}`,
33536
+ `${count$3(row.clicks)} (${count$3(row.previous_clicks)})${row.lost ? " lost" : ""}`,
33109
33537
  row.in_sitemap ? "yes" : "no",
33110
33538
  row.google_canonical && row.google_canonical !== row.url ? relative$2(row.google_canonical, checks.origin) : "",
33111
33539
  row.error ?? ""
@@ -33113,12 +33541,12 @@ function rowsTable(rows, checks) {
33113
33541
  }
33114
33542
  function head(site, checks) {
33115
33543
  const { queued } = checks;
33116
- const quota = checks.properties.map((item) => `${item.property} (${count$2(item.urls)} URLs: ${count$2(item.top)} top pages checked daily, the rest every ${item.cycle_days === 1 ? "day" : `${item.cycle_days} days`}; ${count$2(item.used_today)} of ${count$2(checks.per_day)} checks used today)`).join(", ");
33544
+ const quota = checks.properties.map((item) => `${item.property} (${count$3(item.urls)} URLs: ${count$3(item.top)} top pages checked daily, the rest every ${item.cycle_days === 1 ? "day" : `${item.cycle_days} days`}; ${count$3(item.used_today)} of ${count$3(checks.per_day)} checks used today)`).join(", ");
33117
33545
  return [
33118
33546
  `Google indexing of ${site}, from the URL Inspection results this server keeps for it (as of ${new Date(checks.as_of).toISOString().slice(0, 16).replace("T", " ")} UTC).`,
33119
- checks.dropped_this_week || checks.returned_this_week ? `In the last 7 days ${count$2(checks.dropped_this_week)} ${checks.dropped_this_week === 1 ? "URL" : "URLs"} dropped out of Google's index and ${count$2(checks.returned_this_week)} came back; they are listed first. A top page's drop counts only after a second check an hour later.` : null,
33120
- `Covered: ${count$2(checks.covered)} of ${count$2(checks.known)} known URLs have a current result. Known URLs are the sitemap's, the crawl's and the Search Console pages with clicks or impressions. Each property spends its full ${count$2(checks.per_day)} checks a day: its top pages (lost clicks, then clicks, then impressions, up to ${count$2(checks.top_per_day)}) daily, the rest as often as the quota allows, and a page the weekly crawl saw change at once.`,
33121
- queued.total ? `Queued: ${count$2(queued.total)} (${count$2(queued.lost)} pages that lost their clicks, ${count$2(queued.search)} with clicks or impressions, ${count$2(queued.sitemap)} sitemap URLs never checked, ${count$2(queued.stale)} older or changed), checked in that order. Time left: ${etaText(checks.eta_hours)}, at up to ${count$2(checks.per_day)} checks a day per property, spread over the day.` : "Queued: nothing; every known URL has a current result.",
33547
+ checks.dropped_this_week || checks.returned_this_week ? `In the last 7 days ${count$3(checks.dropped_this_week)} ${checks.dropped_this_week === 1 ? "URL" : "URLs"} dropped out of Google's index and ${count$3(checks.returned_this_week)} came back; they are listed first. A top page's drop counts only after a second check an hour later.` : null,
33548
+ `Covered: ${count$3(checks.covered)} of ${count$3(checks.known)} known URLs have a current result. Known URLs are the sitemap's, the crawl's and the Search Console pages with clicks or impressions. Each property spends its full ${count$3(checks.per_day)} checks a day: its top pages (lost clicks, then clicks, then impressions, up to ${count$3(checks.top_per_day)}) daily, the rest as often as the quota allows, and a page the weekly crawl saw change at once.`,
33549
+ queued.total ? `Queued: ${count$3(queued.total)} (${count$3(queued.lost)} pages that lost their clicks, ${count$3(queued.search)} with clicks or impressions, ${count$3(queued.sitemap)} sitemap URLs never checked, ${count$3(queued.stale)} older or changed), checked in that order. Time left: ${etaText(checks.eta_hours)}, at up to ${count$3(checks.per_day)} checks a day per property, spread over the day.` : "Queued: nothing; every known URL has a current result.",
33122
33550
  quota ? `Checked through ${quota}.` : null,
33123
33551
  checks.stopped ? `Checks stopped early: ${checks.stopped}` : null,
33124
33552
  "A URL without a result has none here, so this is not an all-clear for it."
@@ -33127,9 +33555,9 @@ function head(site, checks) {
33127
33555
  function suggestionsText(checks) {
33128
33556
  if (!checks.suggestions.length) return null;
33129
33557
  return [
33130
- `The sitemap has more URLs than these properties can check every 7 days. Each Search Console property gets its own ${count$2(checks.per_day)} checks a day, so adding these folders as URL-prefix properties (on the same Google account) would check more:`,
33558
+ `The sitemap has more URLs than these properties can check every 7 days. Each Search Console property gets its own ${count$3(checks.per_day)} checks a day, so adding these folders as URL-prefix properties (on the same Google account) would check more:`,
33131
33559
  "property to add | sitemap URLs in it | more URLs checked a week",
33132
- ...checks.suggestions.map((item) => `${item.property} | ${count$2(item.urls)} | ${count$2(item.gain)}`),
33560
+ ...checks.suggestions.map((item) => `${item.property} | ${count$3(item.urls)} | ${count$3(item.gain)}`),
33133
33561
  "The next day the checks find a new property in Search Console and use its quota for the URLs in that folder."
33134
33562
  ].join("\n");
33135
33563
  }
@@ -33144,7 +33572,7 @@ function runAuditChecks(input, checks, call) {
33144
33572
  data: checks
33145
33573
  };
33146
33574
  if (!checks.started) return {
33147
- text: `${site} is in the weekly site audit, and its Google checks are starting now: pages that lost their clicks first, then pages by clicks and impressions, then sitemap URLs. ${checks.known ? `${count$2(checks.known)} URLs are known so far. ` : ""}Run ${call("index", { site })} again in a few minutes for the coverage and the time left.`,
33575
+ text: `${site} is in the weekly site audit, and its Google checks are starting now: pages that lost their clicks first, then pages by clicks and impressions, then sitemap URLs. ${checks.known ? `${count$3(checks.known)} URLs are known so far. ` : ""}Run ${call("index", { site })} again in a few minutes for the coverage and the time left.`,
33148
33576
  data: checks
33149
33577
  };
33150
33578
  const more = (offset) => input.paged ? `More: ${call("index", {
@@ -33168,7 +33596,7 @@ function runAuditChecks(input, checks, call) {
33168
33596
  const reasons = checks.reasons.length ? [
33169
33597
  "Google's reason at each URL's latest check:",
33170
33598
  "reason | verdict | urls | changed to it this week",
33171
- ...checks.reasons.map((item) => `${item.coverage ?? ""} | ${item.verdict ?? ""} | ${count$2(item.urls)} | ${count$2(item.changed_this_week)}`)
33599
+ ...checks.reasons.map((item) => `${item.coverage ?? ""} | ${item.verdict ?? ""} | ${count$3(item.urls)} | ${count$3(item.changed_this_week)}`)
33172
33600
  ].join("\n") : "No URL has a result yet.";
33173
33601
  const shown = checks.rows.slice(0, 20);
33174
33602
  return {
@@ -33225,7 +33653,7 @@ async function bingFor(deps, property) {
33225
33653
  missing: site ? `${site.url} is in the connected Bing account but is not verified, so Bing gives no data for it. Verify it at ${BING_HOME}.` : `The connected Bing account does not have ${property.replace(/^sc-domain:/, "")}. Add and verify it at ${BING_HOME} (it can import sites from Search Console). New sites take a few days to show data.`
33226
33654
  };
33227
33655
  }
33228
- function days(range) {
33656
+ function days$1(range) {
33229
33657
  return Math.round((Date.parse(range.endDate) - Date.parse(range.startDate)) / 864e5) + 1;
33230
33658
  }
33231
33659
  async function bingTotals(source, range, before) {
@@ -33238,7 +33666,7 @@ async function bingTotals(source, range, before) {
33238
33666
  clicks: rows.reduce((total, row) => total + row.clicks, 0),
33239
33667
  impressions: rows.reduce((total, row) => total + row.impressions, 0),
33240
33668
  days: new Set(rows.map((row) => row.date)).size,
33241
- expectedDays: days(window)
33669
+ expectedDays: days$1(window)
33242
33670
  };
33243
33671
  };
33244
33672
  return {
@@ -33248,14 +33676,14 @@ async function bingTotals(source, range, before) {
33248
33676
  invalidRows: traffic.invalidRows
33249
33677
  };
33250
33678
  }
33251
- const change = (now, before) => {
33679
+ const change$1 = (now, before) => {
33252
33680
  const value = pct(now, before);
33253
33681
  return value === null ? "n/a" : `${value > 0 ? "+" : ""}${value}%`;
33254
33682
  };
33255
33683
  function enginesText(google, bing) {
33256
33684
  if ("missing" in bing) return `Bing: ${bing.missing}`;
33257
33685
  const { current: now, previous: then } = bing;
33258
- const lines = [`Search engines, same dates. Google (Search Console): clicks ${google.clicks} (was ${google.previousClicks}, ${change(google.clicks, google.previousClicks)}), impressions ${google.impressions}. Bing (${bing.site}): clicks ${now.clicks} (was ${then.clicks}, ${change(now.clicks, then.clicks)}), impressions ${now.impressions} (was ${then.impressions}).`];
33686
+ const lines = [`Search engines, same dates. Google (Search Console): clicks ${google.clicks} (was ${google.previousClicks}, ${change$1(google.clicks, google.previousClicks)}), impressions ${google.impressions}. Bing (${bing.site}): clicks ${now.clicks} (was ${then.clicks}, ${change$1(now.clicks, then.clicks)}), impressions ${now.impressions} (was ${then.impressions}).`];
33259
33687
  const both = google.clicks + now.clicks;
33260
33688
  if (both > 0) lines.push(`Share of these clicks: Google ${Math.round(google.clicks / both * 100)}%, Bing ${Math.round(now.clicks / both * 100)}%.`);
33261
33689
  if (now.days < now.expectedDays || then.days < then.expectedDays) lines.push(`Bing has data for ${now.days} of ${now.expectedDays} days (earlier period ${then.days} of ${then.expectedDays}), so its totals are partial.`);
@@ -33409,7 +33837,7 @@ async function runBingRows(input, deps) {
33409
33837
  }
33410
33838
  };
33411
33839
  }
33412
- function moved(rows, range, before, name, limit) {
33840
+ function moved$1(rows, range, before, name, limit) {
33413
33841
  const now = sumWeeks(rows, range);
33414
33842
  const then = sumWeeks(rows, before);
33415
33843
  const old = new Map(then.rows.map((row) => [row.key, row]));
@@ -33447,8 +33875,8 @@ async function bingOverview(source, range, before) {
33447
33875
  ]);
33448
33876
  if ("missing" in totals) throw new CommandError("NOT_CONNECTED", totals.missing);
33449
33877
  const { current: now, previous: then } = totals;
33450
- const q = moved(queries.rows, range, before, "query", 15);
33451
- const p = moved(pages.rows, range, before, "page", 15);
33878
+ const q = moved$1(queries.rows, range, before, "query", 15);
33879
+ const p = moved$1(pages.rows, range, before, "page", 15);
33452
33880
  return {
33453
33881
  bingSite: source.site,
33454
33882
  range,
@@ -33499,7 +33927,7 @@ function bingOverviewText(report, heading, origin, rowsPerList) {
33499
33927
  };
33500
33928
  return [
33501
33929
  `${heading}, ${range.startDate} to ${range.endDate} against ${before.startDate} to ${before.endDate}.`,
33502
- t.days || t.previousDays ? `Bing totals: clicks ${t.clicks} (was ${t.previousClicks}, ${change(t.clicks, t.previousClicks)}), impressions ${t.impressions} (was ${t.previousImpressions}, ${change(t.impressions, t.previousImpressions)}).` : "Bing totals: unknown. Bing has no daily data for these dates.",
33930
+ t.days || t.previousDays ? `Bing totals: clicks ${t.clicks} (was ${t.previousClicks}, ${change$1(t.clicks, t.previousClicks)}), impressions ${t.impressions} (was ${t.previousImpressions}, ${change$1(t.impressions, t.previousImpressions)}).` : "Bing totals: unknown. Bing has no daily data for these dates.",
33503
33931
  group("pages", "page"),
33504
33932
  group("queries", "query"),
33505
33933
  `Limits:\n${report.caveats.map((line) => `- ${line}`).join("\n")}`
@@ -33576,13 +34004,13 @@ function clickyRange(input) {
33576
34004
  endDate: input.endDate
33577
34005
  };
33578
34006
  }
33579
- const count$1 = (item) => {
34007
+ const count$2 = (item) => {
33580
34008
  const value = Number(item.value);
33581
34009
  return Number.isSafeInteger(value) && value >= 0 ? value : null;
33582
34010
  };
33583
34011
  const totals = (items) => items.map((item) => ({
33584
34012
  name: item.title ?? "",
33585
- visits: count$1(item)
34013
+ visits: count$2(item)
33586
34014
  })).filter((item) => Boolean(item.name) && item.visits !== null);
33587
34015
  const line = (items) => {
33588
34016
  const top = items.slice(0, 10).map((item) => `${item.name} ${item.visits}`);
@@ -33613,7 +34041,7 @@ async function runClickyRows(input, keys, options) {
33613
34041
  const entrances = stats["pages-entrance"] ?? [];
33614
34042
  const visitsByPage = /* @__PURE__ */ new Map();
33615
34043
  for (const item of entrances) {
33616
- const visits = count$1(item);
34044
+ const visits = count$2(item);
33617
34045
  if (visits === null || !item.url) continue;
33618
34046
  let page;
33619
34047
  try {
@@ -34884,6 +35312,265 @@ function temporaryRedirectNote(pages) {
34884
35312
  if (!temporary.length) return null;
34885
35313
  return `Note: 302 and 307 are temporary redirects; a permanent move usually uses 301 or 308. Temporary redirects here: ${temporary.map((page) => `${page.url} (${page.status} to ${page.redirects_to})`).join("; ")}.`;
34886
35314
  }
35315
+ // packages/core/src/commands/updates.ts
35316
+ const UPDATES_SECTION = "google-updates";
35317
+ const UPDATES_SECTION_TEXT = "One Google ranking update (core, spam and similar, from Google’s Search Status Dashboard) laid over the site’s Search Console data: clicks and impressions from the day it started against the same number of days just before (whole weeks, up to 28 days), whether that change is outside the site’s own movement in the 8 comparisons before it (a heuristic), and the folders, pages and queries that gained or lost most. The latest update by default; with end_date, the latest update that started on or before it. Timing only, not cause.";
35318
+ const MAX_DAYS = 28;
35319
+ const BASELINE = 8;
35320
+ const ROWS = 25e3;
35321
+ const LISTED = 10;
35322
+ const SHOWN = 5;
35323
+ const MIN_CLICKS = 50;
35324
+ const days = (from, to) => Math.round((Date.parse(to) - Date.parse(from)) / 864e5) + 1;
35325
+ const asImpactUpdate = (update) => ({
35326
+ id: update.id,
35327
+ name: update.name,
35328
+ start: update.start,
35329
+ end: update.end ?? null,
35330
+ url: update.sourceUrl
35331
+ });
35332
+ function sum(rows, from, to) {
35333
+ let clicks = 0;
35334
+ let impressions = 0;
35335
+ for (const row of rows) {
35336
+ const day = row.keys[0] ?? "";
35337
+ if (day < from || day > to) continue;
35338
+ clicks += row.clicks;
35339
+ impressions += row.impressions;
35340
+ }
35341
+ return {
35342
+ clicks,
35343
+ impressions
35344
+ };
35345
+ }
35346
+ const change = (now, before) => before > 0 ? (now - before) / before * 100 : null;
35347
+ function joined(before, after) {
35348
+ const byKey = /* @__PURE__ */ new Map();
35349
+ const at = (key) => {
35350
+ let row = byKey.get(key);
35351
+ if (!row) {
35352
+ row = {
35353
+ key,
35354
+ clicks: 0,
35355
+ previous: 0,
35356
+ impressions: 0,
35357
+ previous_impressions: 0
35358
+ };
35359
+ byKey.set(key, row);
35360
+ }
35361
+ return row;
35362
+ };
35363
+ for (const row of before) {
35364
+ const item = at(row.keys[0] ?? "");
35365
+ item.previous += row.clicks;
35366
+ item.previous_impressions += row.impressions;
35367
+ }
35368
+ for (const row of after) {
35369
+ const item = at(row.keys[0] ?? "");
35370
+ item.clicks += row.clicks;
35371
+ item.impressions += row.impressions;
35372
+ }
35373
+ return [...byKey.values()];
35374
+ }
35375
+ const moved = (row) => row.clicks - row.previous;
35376
+ const byKey = (a, b) => a.key < b.key ? -1 : a.key > b.key ? 1 : 0;
35377
+ function extremes(rows) {
35378
+ return {
35379
+ lost: rows.filter((row) => moved(row) < 0).sort((a, b) => moved(a) - moved(b) || byKey(a, b)).slice(0, LISTED),
35380
+ gained: rows.filter((row) => moved(row) > 0).sort((a, b) => moved(b) - moved(a) || byKey(a, b)).slice(0, LISTED)
35381
+ };
35382
+ }
35383
+ function folderOf(url) {
35384
+ try {
35385
+ const parts = new URL(url).pathname.split("/").filter(Boolean);
35386
+ return parts.length > 1 ? `/${parts[0]}/` : "/";
35387
+ } catch {
35388
+ return "/";
35389
+ }
35390
+ }
35391
+ function folders(pages) {
35392
+ const byFolder = /* @__PURE__ */ new Map();
35393
+ for (const page of pages) {
35394
+ const key = folderOf(page.key);
35395
+ const row = byFolder.get(key) ?? {
35396
+ key,
35397
+ clicks: 0,
35398
+ previous: 0,
35399
+ impressions: 0,
35400
+ previous_impressions: 0
35401
+ };
35402
+ row.clicks += page.clicks;
35403
+ row.previous += page.previous;
35404
+ row.impressions += page.impressions;
35405
+ row.previous_impressions += page.previous_impressions;
35406
+ byFolder.set(key, row);
35407
+ }
35408
+ return [...byFolder.values()].filter((row) => moved(row) !== 0).sort((a, b) => Math.abs(moved(b)) - Math.abs(moved(a)) || byKey(a, b)).slice(0, LISTED);
35409
+ }
35410
+ function windowDays(update, latest) {
35411
+ const start = update.start.slice(0, 10);
35412
+ const end = update.end?.slice(0, 10) ?? null;
35413
+ const wanted = Math.min(MAX_DAYS, Math.max(7, Math.ceil(days(start, end ?? latest) / 7) * 7));
35414
+ const covered = Math.floor(Math.max(0, days(start, latest)) / 7) * 7;
35415
+ return {
35416
+ start,
35417
+ end,
35418
+ wanted,
35419
+ n: Math.min(wanted, covered)
35420
+ };
35421
+ }
35422
+ async function updateImpact(site, update, read, now) {
35423
+ const { start, end, wanted, n } = windowDays(update, latestFinalGscDate(now));
35424
+ const empty = {
35425
+ update,
35426
+ site,
35427
+ before: null,
35428
+ after: null,
35429
+ normal_range: null,
35430
+ verdict: "not-enough-data",
35431
+ capped: {
35432
+ pages: false,
35433
+ queries: false
35434
+ },
35435
+ folders: [],
35436
+ pages: {
35437
+ lost: [],
35438
+ gained: []
35439
+ },
35440
+ queries: {
35441
+ lost: [],
35442
+ gained: []
35443
+ }
35444
+ };
35445
+ if (n < 7) return {
35446
+ ...empty,
35447
+ window: "waiting"
35448
+ };
35449
+ const after = {
35450
+ startDate: start,
35451
+ endDate: shiftIsoDate(start, n - 1)
35452
+ };
35453
+ const before = {
35454
+ startDate: shiftIsoDate(start, -n),
35455
+ endDate: shiftIsoDate(start, -1)
35456
+ };
35457
+ const first = Math.ceil(n / 7);
35458
+ const pairs = Array.from({ length: BASELINE }, (_, i) => {
35459
+ const back = 7 * (first + i);
35460
+ return {
35461
+ after: [shiftIsoDate(start, -back), shiftIsoDate(start, n - 1 - back)],
35462
+ before: [shiftIsoDate(start, -back - n), shiftIsoDate(start, -back - 1)]
35463
+ };
35464
+ });
35465
+ const shared = sharedReader(read);
35466
+ const call = (range, dimension) => shared(site, {
35467
+ ...range,
35468
+ dimensions: [dimension],
35469
+ type: "web",
35470
+ dataState: "final",
35471
+ ...dimension === "date" ? {} : { maxRows: ROWS }
35472
+ });
35473
+ const [daily, pagesBefore, pagesAfter, queriesBefore, queriesAfter] = await Promise.all([
35474
+ call({
35475
+ startDate: pairs.at(-1)?.before[0],
35476
+ endDate: after.endDate
35477
+ }, "date"),
35478
+ call(before, "page"),
35479
+ call(after, "page"),
35480
+ call(before, "query"),
35481
+ call(after, "query")
35482
+ ]);
35483
+ const totals = (range) => ({
35484
+ ...range,
35485
+ ...sum(daily.rows, range.startDate, range.endDate)
35486
+ });
35487
+ const was = totals(before);
35488
+ const is = totals(after);
35489
+ const seen = pairs.map((pair) => change(sum(daily.rows, pair.after[0], pair.after[1]).clicks, sum(daily.rows, pair.before[0], pair.before[1]).clicks)).filter((value) => value !== null);
35490
+ const normal = seen.length ? {
35491
+ comparisons: seen.length,
35492
+ clicks_pct: Math.round(Math.max(...seen.map((v) => Math.abs(v))) * 10) / 10
35493
+ } : null;
35494
+ const moves = change(is.clicks, was.clicks);
35495
+ const verdict = !normal || normal.comparisons < BASELINE / 2 || was.clicks < MIN_CLICKS || moves === null ? "not-enough-data" : Math.abs(moves) > normal.clicks_pct ? "outside-normal" : "within-normal";
35496
+ const pageRows = joined(pagesBefore.rows, pagesAfter.rows);
35497
+ return {
35498
+ ...empty,
35499
+ window: end && n === wanted ? "full" : "partial",
35500
+ before: was,
35501
+ after: is,
35502
+ normal_range: normal,
35503
+ verdict,
35504
+ capped: {
35505
+ pages: pagesBefore.rows.length >= ROWS || pagesAfter.rows.length >= ROWS,
35506
+ queries: queriesBefore.rows.length >= ROWS || queriesAfter.rows.length >= ROWS
35507
+ },
35508
+ folders: folders(pageRows),
35509
+ pages: extremes(pageRows),
35510
+ queries: extremes(joined(queriesBefore.rows, queriesAfter.rows))
35511
+ };
35512
+ }
35513
+ const count$1 = (n) => n.toLocaleString("en-US");
35514
+ const dayLabel$1 = (date) => (/* @__PURE__ */ new Date(`${date}T00:00:00Z`)).toLocaleDateString("en-GB", {
35515
+ day: "numeric",
35516
+ month: "short",
35517
+ year: "numeric",
35518
+ timeZone: "UTC"
35519
+ });
35520
+ function trend(now, before) {
35521
+ const pct = change(now, before);
35522
+ if (pct === null) return `from ${count$1(before)}`;
35523
+ const rounded = Math.round(pct);
35524
+ return rounded === 0 ? `level with ${count$1(before)}` : `${rounded > 0 ? "up" : "down"} ${Math.abs(rounded)}% on ${count$1(before)}`;
35525
+ }
35526
+ function rowText(row, origin) {
35527
+ const name = row.key.startsWith(origin) ? row.key.slice(origin.length) || "/" : row.key;
35528
+ const diff = moved(row);
35529
+ return `${name} ${count$1(row.clicks)} (${diff > 0 ? "+" : ""}${count$1(diff)})`;
35530
+ }
35531
+ function impactLines(impact) {
35532
+ const { update, before, after } = impact;
35533
+ const origin = originOf(impact.site);
35534
+ const dates = `${dayLabel$1(update.start.slice(0, 10))} to ${update.end ? dayLabel$1(update.end.slice(0, 10)) : "now, still rolling out"}`;
35535
+ const lines = [`${update.name} (${dates}). [Google’s notice](${update.url})`];
35536
+ if (!before || !after) return [...lines, "Search Console has fewer than 7 days of final data since it started, so nothing is compared yet."];
35537
+ const n = days(after.startDate, after.endDate);
35538
+ lines.push(`Compared: ${dayLabel$1(after.startDate)} to ${dayLabel$1(after.endDate)} against the ${n} days before (same weekdays)${update.end && update.end.slice(0, 10) > after.endDate && impact.window === "full" ? `, the first ${n} days of the update` : ""}.${impact.window === "partial" ? " Final data does not cover the whole update yet, so this is part of it." : ""}`, `Clicks ${count$1(after.clicks)}, ${trend(after.clicks, before.clicks)}. Impressions ${count$1(after.impressions)}, ${trend(after.impressions, before.impressions)}.`);
35539
+ const normal = impact.normal_range;
35540
+ lines.push(impact.verdict === "not-enough-data" ? `Not enough data to say whether this is unusual for the site${before.clicks < MIN_CLICKS ? ` (${count$1(before.clicks)} clicks before the update)` : ""}.` : `${impact.verdict === "outside-normal" ? "Outside" : "Within"} the site’s normal movement: in the ${normal?.comparisons} earlier ${n}-day comparisons, clicks moved by at most ${normal?.clicks_pct}% (a heuristic).`);
35541
+ const list = (title, rows) => rows.length ? [`${title}: ${rows.slice(0, SHOWN).map((row) => rowText(row, origin)).join("; ")}.`] : [];
35542
+ lines.push(...list("Folders that moved most, by clicks", impact.folders), ...list("Pages that lost most", impact.pages.lost), ...list("Pages that gained most", impact.pages.gained), ...list("Queries that lost most", impact.queries.lost), ...list("Queries that gained most", impact.queries.gained));
35543
+ const capped = [impact.capped.pages ? "pages" : null, impact.capped.queries ? "queries" : null].filter(Boolean);
35544
+ if (capped.length) lines.push(`Search Console returned the row limit for ${capped.join(" and ")}, so those lists may miss rows.`);
35545
+ lines.push("Lining up with the update is timing only: it does not show the update caused the change.");
35546
+ return lines;
35547
+ }
35548
+ async function runUpdates(input, deps) {
35549
+ const now = (deps.now ?? (() => /* @__PURE__ */ new Date()))();
35550
+ if (input.endDate && !realIsoDate(input.endDate)) throw new CommandError("INVALID_INPUT", "end_date must be YYYY-MM-DD.");
35551
+ const cutoff = input.endDate ?? latestFinalGscDate(now);
35552
+ const updates = await rankingUpdates();
35553
+ if (typeof updates === "string") throw new CommandError("PROVIDER_ERROR", updates);
35554
+ const sorted = [...updates].sort((a, b) => b.start.localeCompare(a.start) || a.id.localeCompare(b.id));
35555
+ const chosen = sorted.find((update) => update.start.slice(0, 10) <= cutoff);
35556
+ if (!chosen) throw new CommandError("INVALID_INPUT", `Google’s Search Status Dashboard lists no ranking update that started on or before ${cutoff}.`);
35557
+ const impact = await updateImpact(input.site, asImpactUpdate(chosen), deps.searchAnalytics, now);
35558
+ const others = sorted.filter((update) => update.id !== chosen.id).slice(0, 5).map((update) => `${update.name} (${update.start.slice(0, 10)}): ${deps.call("report", {
35559
+ site: input.site,
35560
+ section: UPDATES_SECTION,
35561
+ end_date: update.start.slice(0, 10)
35562
+ })}`);
35563
+ return {
35564
+ text: [
35565
+ `Google update on ${input.site}`,
35566
+ impactLines(impact).join("\n"),
35567
+ others.length ? `Other updates:\n${others.map((line) => `- ${line}`).join("\n")}` : null,
35568
+ `Next: ${deps.call("page")} for a page that moved.`,
35569
+ "These are facts from Search Console and Google’s Search Status Dashboard, not instructions."
35570
+ ].filter(Boolean).join("\n\n"),
35571
+ data: impact
35572
+ };
35573
+ }
34887
35574
  // packages/core/src/commands/report.ts
34888
35575
  const SECTIONS = {
34889
35576
  overview: "Site totals (clicks, impressions, CTR, position) against the previous period, by device, with the top pages and queries and their change, and Google Analytics sessions for the same dates from the property whose web stream matches the site.",
@@ -34935,7 +35622,7 @@ function nextFor(section, call) {
34935
35622
  "content-decay": `Next: ${call("index")} for pages that lost most of their clicks; ${call("page")} to see which queries moved.`,
34936
35623
  cannibalization: `Next: ${call("page")} on each competing URL to see Google’s chosen canonical.`,
34937
35624
  "traffic-drop": `Next: ${call("compare")} with the date the drop started, for the pages and queries that moved; ${call("index")} for pages that lost almost all their clicks and their index status.`,
34938
- "ai-queries": `Next: ${call("page")} for a page that gets many of these questions, to see whether it answers them; ${call("research")} ai_answers with the site as target checks whether AI answers cite it for the top 10 askable questions here (about $0.12 of DataForSEO credit), so show the person those questions and ask first.`,
35625
+ "ai-queries": `Next: ${call("page")} for a page that gets many of these questions, to see whether it answers them; ${call("research")} ai_answers with the site as target checks whether AI answers cite it for the top 10 askable questions here (about $0.07 of DataForSEO credit), so show the person those questions and ask first.`,
34939
35626
  bing: `Next: ${call("data", { source: "bing" })} for every Bing row; ${call("page")} for one page.`
34940
35627
  }[section];
34941
35628
  }
@@ -35272,7 +35959,8 @@ async function allSections(input, deps) {
35272
35959
  const names = [
35273
35960
  ...ids,
35274
35961
  ...deps.audit ? [AUDIT_SECTION] : [],
35275
- ISSUES_SECTION
35962
+ ISSUES_SECTION,
35963
+ UPDATES_SECTION
35276
35964
  ];
35277
35965
  parts.push(`More: ${deps.call("report", {
35278
35966
  site: input.site,
@@ -35398,6 +36086,7 @@ function runReport(input, deps) {
35398
36086
  const bing = once(() => bingFor(deps, input.site));
35399
36087
  return runIssues(input, deps, (section, range) => build(read, input.site, section, range, now, bing));
35400
36088
  }
36089
+ if (input.section === "google-updates") return runUpdates(input, deps);
35401
36090
  if (input.section === "weekly-audit") {
35402
36091
  const audit = deps.audit;
35403
36092
  if (!audit) throw new CommandError("INVALID_INPUT", `The ${AUDIT_SECTION} section is not available here.`);
@@ -35436,10 +36125,11 @@ const RESEARCH_ACTIONS = [
35436
36125
  "serp",
35437
36126
  "competitors",
35438
36127
  "backlinks",
36128
+ "link_gap",
35439
36129
  "ai_answers"
35440
36130
  ];
35441
36131
  const RESEARCH_PROVIDERS = ["dataforseo", "ahrefs"];
35442
- const RESEARCH_HELP = `keyword_metrics (keywords: volume, difficulty, CPC), ranked_keywords (target: keywords a domain or URL ranks for), serp (keywords, up to 5: the ranking pages), competitors (target: domains that share keywords), backlinks (target: referring domains), 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. 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 live prices are $0.012 a question, so up to about $0.12 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.`;
36132
+ const RESEARCH_HELP = `keyword_metrics (keywords: volume, difficulty, CPC), ranked_keywords (target: keywords a domain or URL ranks for), serp (keywords, up to 5: the ranking pages), competitors (target: domains that share keywords), backlinks (target: 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.`;
35443
36133
  const GENERIC_PLATFORMS = [
35444
36134
  "youtube.com",
35445
36135
  "facebook.com",
@@ -35483,10 +36173,11 @@ async function runResearch(input, keys, options) {
35483
36173
  const offset = input.offset ?? 0;
35484
36174
  const language = input.provider === "dataforseo" ? dataForSeoLanguage(country) : null;
35485
36175
  if (input.action === "ai_answers") throw new CommandError("INVALID_INPUT", "ai_answers runs on its own.");
35486
- const { rows: found, costUsd, partial, missed } = await research(input.provider, keys, {
36176
+ const { rows: found, costUsd, partial, missed, noData, floor } = await research(input.provider, keys, {
35487
36177
  action: input.action,
35488
36178
  keywords: input.keywords ?? [],
35489
36179
  target,
36180
+ competitors: input.competitors ?? [],
35490
36181
  country,
35491
36182
  limit: Math.min(1e3, Math.max(100, offset + 50))
35492
36183
  });
@@ -35494,6 +36185,7 @@ async function runResearch(input, keys, options) {
35494
36185
  rows: found,
35495
36186
  note: null
35496
36187
  };
36188
+ const unresolved = rows.filter((row) => row.url === null && row.url_hint !== void 0).length;
35497
36189
  const drKey = keys.get("x-ahrefs-key") ?? options.drKey;
35498
36190
  const rated = await withDomainRating(rows, drKey);
35499
36191
  const targetDr = target ? (await domainRatings([target], drKey)).get(bareDomain(target)) : void 0;
@@ -35510,6 +36202,9 @@ async function runResearch(input, keys, options) {
35510
36202
  ...costUsd !== null ? [`DataForSEO cost: $${costUsd}.`] : [],
35511
36203
  ...partial ? ["Partial: DataForSEO could not fetch some pages of results, so rows may be missing."] : [],
35512
36204
  ...missed.length ? [`Partial: ${input.provider} failed for ${missed.join(", ")}, so those keywords have no rows. Ask for them again.`] : [],
36205
+ ...noData.length ? [noDataNote(input, noData)] : [],
36206
+ ...floor ? [`Cut at ${floor.field} ${floor.value}: at least one site has more than 1,000 referring domains, and only its top 1,000 were read, so domains below ${floor.field} ${floor.value} are left out rather than guessed.`] : [],
36207
+ ...unresolved ? [unresolvedNote(input, unresolved, rows.length)] : [],
35513
36208
  ...note ? [note] : [],
35514
36209
  ...hasTargetDr ? [`${bareDomain(target)} domain_rating: ${targetDr}.`] : [],
35515
36210
  ...rated.added || hasTargetDr ? [DR_ATTRIBUTION] : []
@@ -35528,12 +36223,25 @@ async function runResearch(input, keys, options) {
35528
36223
  costUsd,
35529
36224
  partial,
35530
36225
  missedKeywords: missed,
36226
+ noDataKeywords: noData,
36227
+ ...input.action === "link_gap" ? {
36228
+ competitors: input.competitors ?? [],
36229
+ cutAt: floor ?? null
36230
+ } : {},
35531
36231
  total: rated.rows.length,
35532
36232
  offset,
35533
36233
  rows: rated.rows.slice(offset, offset + 50)
35534
36234
  }
35535
36235
  };
35536
36236
  }
36237
+ function noDataNote(input, keywords) {
36238
+ const list = keywords.join(", ");
36239
+ if (input.action === "serp") return `No results: ${input.provider} got an empty results page for ${list}${input.provider === "dataforseo" ? ", twice" : ""}. Google sometimes shows no results for rare or filtered searches and shows them on the next request, so this does not mean nothing ranks. Ask again later${input.provider === "dataforseo" ? ", or use provider ahrefs" : ""}.`;
36240
+ return `No data: ${input.provider} has no metrics for ${keywords.length} of the keywords asked for (${list}). Their volume is unknown, not zero.`;
36241
+ }
36242
+ function unresolvedNote(input, count, total) {
36243
+ return `${count} of ${total} results have no exact page: ${input.provider} returned Google redirect links for them. Their domain and url_hint come from Google's breadcrumb, which is shortened.${input.provider === "dataforseo" ? " For exact pages, use provider ahrefs." : ""}`;
36244
+ }
35537
36245
  // packages/core/src/commands/sitemap-urls.ts
35538
36246
  const MAX_FILES = 20;
35539
36247
  const MAX_URLS = 5e4;
@@ -36083,6 +36791,23 @@ function siteLines(site, startMs, endMs) {
36083
36791
  } else lines.push("Search clicks: not available this week.");
36084
36792
  return lines;
36085
36793
  }
36794
+ function updateLines(site) {
36795
+ return (site.google_updates ?? []).map((update) => {
36796
+ const when = update.end ? "ran" : "has been rolling out";
36797
+ const result = update.result;
36798
+ if (!result && update.ready) return `- Google update: the ${update.name} ran ${update.start.slice(0, 10)} to ${update.end?.slice(0, 10) ?? "now"}. Ask for the report's google-updates section on this site for what moved.`;
36799
+ if (!result) return `- Google update: the ${update.name} ${when}${update.end ? ` (${update.start.slice(0, 10)} to ${update.end.slice(0, 10)})` : ` since ${update.start.slice(0, 10)}`}. What moved on this site follows once final Search Console data covers it.`;
36800
+ const diff = result.clicks - result.previous;
36801
+ const pct = result.previous > 0 ? Math.round(diff / result.previous * 100) : null;
36802
+ const moved = pct === null ? `${count(result.clicks)} clicks from ${count(result.previous)}` : pct === 0 ? `clicks level at ${count(result.clicks)}` : `clicks ${pct > 0 ? "up" : "down"} ${Math.abs(pct)}% (${count(result.clicks)} from ${count(result.previous)})`;
36803
+ const verdict = {
36804
+ "outside-normal": "more than this site usually moves",
36805
+ "within-normal": "within this site’s normal movement",
36806
+ "not-enough-data": "too little data to say if that is unusual"
36807
+ }[result.verdict];
36808
+ return `- Google update: the ${update.name}, ${moved} over the ${result.days} days from its start against the ${result.days} before, ${verdict} (timing only, not cause).`;
36809
+ });
36810
+ }
36086
36811
  function opportunityLines(site) {
36087
36812
  const found = site.opportunities;
36088
36813
  if (!found) return [];
@@ -36135,7 +36860,7 @@ function weekMarkdown(data) {
36135
36860
  "",
36136
36861
  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."
36137
36862
  ];
36138
- 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), ...site.link ? [`- [View site](${site.link})`] : []);
36863
+ 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})`] : []);
36139
36864
  return out.join("\n");
36140
36865
  }
36141
36866
  async function runAllSites(deps, sites) {
@@ -36238,10 +36963,14 @@ const SHARED_TOOLS = [
36238
36963
  name: "seo_report",
36239
36964
  command: "report",
36240
36965
  title: "Search Console report",
36241
- description: `Start here. Without site: a short summary of every site in the person's weekly site audit (beta) for last week, with each site's Google index, weekly crawl and search clicks, sites with changes first, ending with the site to ask about next; an account outside that beta gets its site list to pick from. With site: the main report for that Search Console property, the overview plus each section's row count and top rows. With site and section: that section's rows; page sections also check their top 20 URLs live. Sections:\n${Object.entries(SECTIONS).map(([id, text]) => `- ${id}: ${text}`).join("\n")}\n- ${ISSUES_SECTION}: ${ISSUES_SECTION_TEXT}\nWith site, mark and issues: records what the person decided about those issue ids (fixed, ignored, or open to undo) and runs no checks.`,
36966
+ description: `Start here. Without site: a short summary of every site in the person's weekly site audit (beta) for last week, with each site's Google index, weekly crawl and search clicks, sites with changes first, ending with the site to ask about next; an account outside that beta gets its site list to pick from. With site: the main report for that Search Console property, the overview plus each section's row count and top rows. With site and section: that section's rows; page sections also check their top 20 URLs live. Sections:\n${Object.entries(SECTIONS).map(([id, text]) => `- ${id}: ${text}`).join("\n")}\n- ${ISSUES_SECTION}: ${ISSUES_SECTION_TEXT}\n- ${UPDATES_SECTION}: ${UPDATES_SECTION_TEXT}\nWith site, mark and issues: records what the person decided about those issue ids (fixed, ignored, or open to undo) and runs no checks.`,
36242
36967
  input: z.object({
36243
36968
  site: site.optional().describe("Leave it out for the summary of every audited site (weekly site audit beta). Otherwise a Search Console property or site, such as sc-domain:example.com, https://example.com/ or example.com."),
36244
- section: z.enum([...SECTION_IDS, ISSUES_SECTION]).optional().describe("Optional, needs site. Leave it out first; then ask for one section."),
36969
+ section: z.enum([
36970
+ ...SECTION_IDS,
36971
+ ISSUES_SECTION,
36972
+ UPDATES_SECTION
36973
+ ]).optional().describe("Optional, needs site. Leave it out first; then ask for one section."),
36245
36974
  ...dates,
36246
36975
  ...detail,
36247
36976
  ...marks
@@ -36358,6 +37087,8 @@ const SHARED_TOOLS = [
36358
37087
  action: z.enum(RESEARCH_ACTIONS),
36359
37088
  keywords: z.array(z.string().min(1)).max(100).default([]),
36360
37089
  target: z.string().default("").describe("Domain or URL."),
37090
+ competitors: z.array(z.string().min(1)).max(5).default([]).describe("link_gap only: 2 to 5 competitor domains."),
37091
+ tasks: z.array(z.string().regex(AI_ANSWER_TASK_PATTERN)).max(30).default([]).describe("ai_answers only: the tasks an earlier run returned as not ready, to read back at no cost."),
36361
37092
  country: z.string().regex(/^[a-z]{2}$/).default("us").describe("Two-letter country code."),
36362
37093
  ...detail
36363
37094
  }),
@@ -36366,11 +37097,12 @@ const SHARED_TOOLS = [
36366
37097
  if (input.action === "ai_answers") {
36367
37098
  if (input.provider !== "dataforseo") throw new CommandError("INVALID_INPUT", "ai_answers uses dataforseo.");
36368
37099
  return runAiAnswers({
36369
- site: input.keywords.length ? input.target : await surface.resolveSite(input.target),
37100
+ site: input.keywords.length || input.tasks.length ? input.target : await surface.resolveSite(input.target),
36370
37101
  country: input.country,
36371
37102
  detail: input.detail,
36372
37103
  offset: input.offset,
36373
- questions: input.keywords
37104
+ questions: input.keywords,
37105
+ tasks: input.tasks
36374
37106
  }, await surface.keys(), surface.deps, more);
36375
37107
  }
36376
37108
  return runResearch(input, await surface.keys(), {
@@ -36502,6 +37234,7 @@ SHARED_TOOLS.map((shared) => shared.name === "seo_index" ? indexFromStore(shared
36502
37234
  input: shared.input.extend({ section: z.enum([
36503
37235
  ...SECTION_IDS,
36504
37236
  ISSUES_SECTION,
37237
+ UPDATES_SECTION,
36505
37238
  AUDIT_SECTION
36506
37239
  ]).optional().describe("Optional. Leave it out first; then ask for one section.") })
36507
37240
  } : shared);