seo 0.15.2 → 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-Dt4JJZ0P.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);
@@ -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));
@@ -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 {
@@ -31906,18 +32094,29 @@ async function dataForSeo(keys, input) {
31906
32094
  partial: false,
31907
32095
  missed: []
31908
32096
  };
32097
+ const rows = await dataForSeoRows(keys, input, spent);
31909
32098
  return {
31910
- rows: await dataForSeoRows(keys, input, spent),
32099
+ rows,
31911
32100
  costUsd: Math.round(spent.cost * 1e4) / 1e4,
31912
32101
  partial: spent.partial,
31913
- missed: spent.missed
32102
+ missed: spent.missed,
32103
+ noData: withoutData(input, rows, spent.missed, 700)
31914
32104
  };
31915
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
+ }
31916
32115
  async function dataForSeoRows(keys, input, spent) {
31917
32116
  const language = dataForSeoLanguage(input.country);
31918
32117
  if (!language && input.action !== "serp" && input.action !== "backlinks") throw new CommandError("INVALID_INPUT", `DataForSEO has no ${input.action} data for country "${input.country}".`);
31919
32118
  const market = dataForSeoMarket(input.country, language);
31920
- const post = async (path, task) => {
32119
+ const post = async (path, task, attempts = 1) => {
31921
32120
  const done = await dataForSeoTask(keys, path, {
31922
32121
  ...market,
31923
32122
  ...task
@@ -31927,6 +32126,7 @@ async function dataForSeoRows(keys, input, spent) {
31927
32126
  });
31928
32127
  spent.cost += done.cost;
31929
32128
  spent.partial ||= done.partial;
32129
+ if (done.noResults && attempts > 1) return post(path, task, attempts - 1);
31930
32130
  return done.result?.items ?? [];
31931
32131
  };
31932
32132
  const get = (value, path) => path.split(".").reduce((current, key) => current && typeof current === "object" ? current[key] : void 0, value);
@@ -31956,7 +32156,7 @@ async function dataForSeoRows(keys, input, spent) {
31956
32156
  return (await post("v3/serp/google/organic/live/advanced", {
31957
32157
  keyword,
31958
32158
  depth: Math.min(input.limit, 100)
31959
- })).filter((item) => item.type === "organic").map((item) => {
32159
+ }, 2)).filter((item) => item.type === "organic").map((item) => {
31960
32160
  const row = pick(item, {
31961
32161
  position: "rank_absolute",
31962
32162
  url: "url",
@@ -31964,9 +32164,11 @@ async function dataForSeoRows(keys, input, spent) {
31964
32164
  title: "title"
31965
32165
  });
31966
32166
  if (/^https?:\/\/(www\.)?google\.[a-z.]+\/goto\b/.test(String(row.url))) {
31967
- const site = /^https?:\/\/([^/\s›]+)/.exec(String(item.breadcrumb ?? ""));
32167
+ const crumb = String(item.breadcrumb ?? "");
32168
+ const site = /^https?:\/\/([^/\s›]+)/.exec(crumb);
31968
32169
  row.url = null;
31969
32170
  row.domain = site?.[1] ?? null;
32171
+ row.url_hint = site ? crumb.replace(/^https?:\/\//, "") : null;
31970
32172
  }
31971
32173
  return {
31972
32174
  keyword,
@@ -31996,6 +32198,65 @@ async function dataForSeoRows(keys, input, spent) {
31996
32198
  organic_etv: "full_domain_metrics.organic.etv"
31997
32199
  }));
31998
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
+ }
31999
32260
  function isPage(target) {
32000
32261
  return /\/./.test(bareTarget(target));
32001
32262
  }
@@ -32086,7 +32347,7 @@ const DIMENSIONS = [
32086
32347
  "device",
32087
32348
  "searchAppearance"
32088
32349
  ];
32089
- const ROWS = 25e3;
32350
+ const ROWS$1 = 25e3;
32090
32351
  async function runRows(input, deps) {
32091
32352
  const dimensions = input.dimensions?.length ? input.dimensions : ["query"];
32092
32353
  const filters = input.filters ?? [];
@@ -32095,7 +32356,7 @@ async function runRows(input, deps) {
32095
32356
  const query = {
32096
32357
  dimensions,
32097
32358
  type: "web",
32098
- maxRows: ROWS,
32359
+ maxRows: ROWS$1,
32099
32360
  ...filters.length ? { dimensionFilterGroups: [{
32100
32361
  groupType: "and",
32101
32362
  filters
@@ -32152,9 +32413,9 @@ async function runRows(input, deps) {
32152
32413
  const sum = (key) => rows.reduce((total, row) => total + Number(row[key] ?? 0), 0);
32153
32414
  notes.push(`Totals: clicks ${sum("clicks")}, impressions ${sum("impressions")}; previous period clicks ${sum("previous_clicks")}.`);
32154
32415
  }
32155
- 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.`);
32156
32417
  if (pageAt >= 0) notes.push("Page URLs with #fragments are merged into their page.");
32157
- 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.`);
32158
32419
  notes.push("Search Console hides some rare queries for privacy, so query rows do not add up to the site totals.");
32159
32420
  const detail = input.detail ?? "summary";
32160
32421
  return {
@@ -32263,6 +32524,22 @@ const hostOf$2 = (url) => {
32263
32524
  return null;
32264
32525
  }
32265
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
+ };
32266
32543
  const empty = {
32267
32544
  cited_url: null,
32268
32545
  sources: [],
@@ -32291,7 +32568,7 @@ function judge(engine, task, site) {
32291
32568
  const cited = all.find((url) => onSite(url, site.domain)) ?? null;
32292
32569
  const base = {
32293
32570
  cited_url: cited,
32294
- sources: [...new Set(all.filter((url) => !onSite(url, site.domain)).map(hostOf$2).filter((host) => Boolean(host)))].slice(0, MAX_SOURCES),
32571
+ sources: [...new Set(all.filter((url) => !onSite(url, site.domain) && isSource(url)).map(hostOf$2).filter((host) => Boolean(host)))].slice(0, MAX_SOURCES),
32295
32572
  model: shown.model,
32296
32573
  answered_at
32297
32574
  };
@@ -32881,7 +33158,7 @@ async function totals$1(found, access, range, before, pages) {
32881
33158
  notes
32882
33159
  };
32883
33160
  }
32884
- const change$1 = (value) => value === null ? "n/a" : `${value > 0 ? "+" : ""}${value}%`;
33161
+ const change$2 = (value) => value === null ? "n/a" : `${value > 0 ? "+" : ""}${value}%`;
32885
33162
  function engineText(rows) {
32886
33163
  const shown = rows.filter((row) => row.sessions || row.previousSessions);
32887
33164
  if (shown.length < 2) return "";
@@ -32891,7 +33168,7 @@ function aiText(ai) {
32891
33168
  const list = (rows, key) => rows.slice(0, AI_ROWS).map((row) => `${row[key]} ${row.sessions} (was ${row.previousSessions})`).join(", ");
32892
33169
  if (!ai.sessions && !ai.previousSessions) return "Visits from AI assistants (ChatGPT, Perplexity, Claude, Gemini, Copilot and others): none in either period.";
32893
33170
  return [
32894
- `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")}.`,
32895
33172
  ai.topLandingPages.length ? `Top landing pages from AI assistants: ${list(ai.topLandingPages, "page")}.` : "",
32896
33173
  "Only visits that pass a referrer or utm_source count; AI apps that send none show as Direct."
32897
33174
  ].filter(Boolean).join(" ");
@@ -33008,7 +33285,7 @@ async function analyticsBeside(access, site, range, before, pages) {
33008
33285
  const keyEvents = organic.keyEvents || organic.previousKeyEvents ? `, Organic Search key events ${organic.keyEvents} (was ${organic.previousKeyEvents})` : "";
33009
33286
  return {
33010
33287
  text: [
33011
- `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)}).`,
33012
33289
  engineText(data.organicSearchByEngine),
33013
33290
  "Sessions are Google Analytics’ own count, not Search Console clicks.",
33014
33291
  ...data.notes,
@@ -33235,7 +33512,7 @@ function auditPreview(snapshot) {
33235
33512
  }
33236
33513
  const WEEK_MS = 6048e5;
33237
33514
  const day$1 = (ms) => ms === null ? "" : new Date(ms).toISOString().slice(0, 10);
33238
- const count$2 = (value) => value.toLocaleString("en-US");
33515
+ const count$3 = (value) => value.toLocaleString("en-US");
33239
33516
  function relative$2(value, origin) {
33240
33517
  if (!value) return "";
33241
33518
  return origin && value.startsWith(origin) ? value.slice(origin.length) || "/" : value;
@@ -33256,7 +33533,7 @@ function rowsTable(rows, checks) {
33256
33533
  changed(row) ? row.prev_coverage ?? "" : "",
33257
33534
  changed(row) ? day$1(row.changed_at) : "",
33258
33535
  day$1(row.checked_at),
33259
- `${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" : ""}`,
33260
33537
  row.in_sitemap ? "yes" : "no",
33261
33538
  row.google_canonical && row.google_canonical !== row.url ? relative$2(row.google_canonical, checks.origin) : "",
33262
33539
  row.error ?? ""
@@ -33264,12 +33541,12 @@ function rowsTable(rows, checks) {
33264
33541
  }
33265
33542
  function head(site, checks) {
33266
33543
  const { queued } = checks;
33267
- 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(", ");
33268
33545
  return [
33269
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).`,
33270
- 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,
33271
- `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.`,
33272
- 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.",
33273
33550
  quota ? `Checked through ${quota}.` : null,
33274
33551
  checks.stopped ? `Checks stopped early: ${checks.stopped}` : null,
33275
33552
  "A URL without a result has none here, so this is not an all-clear for it."
@@ -33278,9 +33555,9 @@ function head(site, checks) {
33278
33555
  function suggestionsText(checks) {
33279
33556
  if (!checks.suggestions.length) return null;
33280
33557
  return [
33281
- `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:`,
33282
33559
  "property to add | sitemap URLs in it | more URLs checked a week",
33283
- ...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)}`),
33284
33561
  "The next day the checks find a new property in Search Console and use its quota for the URLs in that folder."
33285
33562
  ].join("\n");
33286
33563
  }
@@ -33295,7 +33572,7 @@ function runAuditChecks(input, checks, call) {
33295
33572
  data: checks
33296
33573
  };
33297
33574
  if (!checks.started) return {
33298
- 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.`,
33299
33576
  data: checks
33300
33577
  };
33301
33578
  const more = (offset) => input.paged ? `More: ${call("index", {
@@ -33319,7 +33596,7 @@ function runAuditChecks(input, checks, call) {
33319
33596
  const reasons = checks.reasons.length ? [
33320
33597
  "Google's reason at each URL's latest check:",
33321
33598
  "reason | verdict | urls | changed to it this week",
33322
- ...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)}`)
33323
33600
  ].join("\n") : "No URL has a result yet.";
33324
33601
  const shown = checks.rows.slice(0, 20);
33325
33602
  return {
@@ -33376,7 +33653,7 @@ async function bingFor(deps, property) {
33376
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.`
33377
33654
  };
33378
33655
  }
33379
- function days(range) {
33656
+ function days$1(range) {
33380
33657
  return Math.round((Date.parse(range.endDate) - Date.parse(range.startDate)) / 864e5) + 1;
33381
33658
  }
33382
33659
  async function bingTotals(source, range, before) {
@@ -33389,7 +33666,7 @@ async function bingTotals(source, range, before) {
33389
33666
  clicks: rows.reduce((total, row) => total + row.clicks, 0),
33390
33667
  impressions: rows.reduce((total, row) => total + row.impressions, 0),
33391
33668
  days: new Set(rows.map((row) => row.date)).size,
33392
- expectedDays: days(window)
33669
+ expectedDays: days$1(window)
33393
33670
  };
33394
33671
  };
33395
33672
  return {
@@ -33399,14 +33676,14 @@ async function bingTotals(source, range, before) {
33399
33676
  invalidRows: traffic.invalidRows
33400
33677
  };
33401
33678
  }
33402
- const change = (now, before) => {
33679
+ const change$1 = (now, before) => {
33403
33680
  const value = pct(now, before);
33404
33681
  return value === null ? "n/a" : `${value > 0 ? "+" : ""}${value}%`;
33405
33682
  };
33406
33683
  function enginesText(google, bing) {
33407
33684
  if ("missing" in bing) return `Bing: ${bing.missing}`;
33408
33685
  const { current: now, previous: then } = bing;
33409
- 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}).`];
33410
33687
  const both = google.clicks + now.clicks;
33411
33688
  if (both > 0) lines.push(`Share of these clicks: Google ${Math.round(google.clicks / both * 100)}%, Bing ${Math.round(now.clicks / both * 100)}%.`);
33412
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.`);
@@ -33560,7 +33837,7 @@ async function runBingRows(input, deps) {
33560
33837
  }
33561
33838
  };
33562
33839
  }
33563
- function moved(rows, range, before, name, limit) {
33840
+ function moved$1(rows, range, before, name, limit) {
33564
33841
  const now = sumWeeks(rows, range);
33565
33842
  const then = sumWeeks(rows, before);
33566
33843
  const old = new Map(then.rows.map((row) => [row.key, row]));
@@ -33598,8 +33875,8 @@ async function bingOverview(source, range, before) {
33598
33875
  ]);
33599
33876
  if ("missing" in totals) throw new CommandError("NOT_CONNECTED", totals.missing);
33600
33877
  const { current: now, previous: then } = totals;
33601
- const q = moved(queries.rows, range, before, "query", 15);
33602
- 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);
33603
33880
  return {
33604
33881
  bingSite: source.site,
33605
33882
  range,
@@ -33650,7 +33927,7 @@ function bingOverviewText(report, heading, origin, rowsPerList) {
33650
33927
  };
33651
33928
  return [
33652
33929
  `${heading}, ${range.startDate} to ${range.endDate} against ${before.startDate} to ${before.endDate}.`,
33653
- 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.",
33654
33931
  group("pages", "page"),
33655
33932
  group("queries", "query"),
33656
33933
  `Limits:\n${report.caveats.map((line) => `- ${line}`).join("\n")}`
@@ -33727,13 +34004,13 @@ function clickyRange(input) {
33727
34004
  endDate: input.endDate
33728
34005
  };
33729
34006
  }
33730
- const count$1 = (item) => {
34007
+ const count$2 = (item) => {
33731
34008
  const value = Number(item.value);
33732
34009
  return Number.isSafeInteger(value) && value >= 0 ? value : null;
33733
34010
  };
33734
34011
  const totals = (items) => items.map((item) => ({
33735
34012
  name: item.title ?? "",
33736
- visits: count$1(item)
34013
+ visits: count$2(item)
33737
34014
  })).filter((item) => Boolean(item.name) && item.visits !== null);
33738
34015
  const line = (items) => {
33739
34016
  const top = items.slice(0, 10).map((item) => `${item.name} ${item.visits}`);
@@ -33764,7 +34041,7 @@ async function runClickyRows(input, keys, options) {
33764
34041
  const entrances = stats["pages-entrance"] ?? [];
33765
34042
  const visitsByPage = /* @__PURE__ */ new Map();
33766
34043
  for (const item of entrances) {
33767
- const visits = count$1(item);
34044
+ const visits = count$2(item);
33768
34045
  if (visits === null || !item.url) continue;
33769
34046
  let page;
33770
34047
  try {
@@ -35035,6 +35312,265 @@ function temporaryRedirectNote(pages) {
35035
35312
  if (!temporary.length) return null;
35036
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("; ")}.`;
35037
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
+ }
35038
35574
  // packages/core/src/commands/report.ts
35039
35575
  const SECTIONS = {
35040
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.",
@@ -35423,7 +35959,8 @@ async function allSections(input, deps) {
35423
35959
  const names = [
35424
35960
  ...ids,
35425
35961
  ...deps.audit ? [AUDIT_SECTION] : [],
35426
- ISSUES_SECTION
35962
+ ISSUES_SECTION,
35963
+ UPDATES_SECTION
35427
35964
  ];
35428
35965
  parts.push(`More: ${deps.call("report", {
35429
35966
  site: input.site,
@@ -35549,6 +36086,7 @@ function runReport(input, deps) {
35549
36086
  const bing = once(() => bingFor(deps, input.site));
35550
36087
  return runIssues(input, deps, (section, range) => build(read, input.site, section, range, now, bing));
35551
36088
  }
36089
+ if (input.section === "google-updates") return runUpdates(input, deps);
35552
36090
  if (input.section === "weekly-audit") {
35553
36091
  const audit = deps.audit;
35554
36092
  if (!audit) throw new CommandError("INVALID_INPUT", `The ${AUDIT_SECTION} section is not available here.`);
@@ -35587,10 +36125,11 @@ const RESEARCH_ACTIONS = [
35587
36125
  "serp",
35588
36126
  "competitors",
35589
36127
  "backlinks",
36128
+ "link_gap",
35590
36129
  "ai_answers"
35591
36130
  ];
35592
36131
  const RESEARCH_PROVIDERS = ["dataforseo", "ahrefs"];
35593
- 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. 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.`;
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.`;
35594
36133
  const GENERIC_PLATFORMS = [
35595
36134
  "youtube.com",
35596
36135
  "facebook.com",
@@ -35634,10 +36173,11 @@ async function runResearch(input, keys, options) {
35634
36173
  const offset = input.offset ?? 0;
35635
36174
  const language = input.provider === "dataforseo" ? dataForSeoLanguage(country) : null;
35636
36175
  if (input.action === "ai_answers") throw new CommandError("INVALID_INPUT", "ai_answers runs on its own.");
35637
- 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, {
35638
36177
  action: input.action,
35639
36178
  keywords: input.keywords ?? [],
35640
36179
  target,
36180
+ competitors: input.competitors ?? [],
35641
36181
  country,
35642
36182
  limit: Math.min(1e3, Math.max(100, offset + 50))
35643
36183
  });
@@ -35645,6 +36185,7 @@ async function runResearch(input, keys, options) {
35645
36185
  rows: found,
35646
36186
  note: null
35647
36187
  };
36188
+ const unresolved = rows.filter((row) => row.url === null && row.url_hint !== void 0).length;
35648
36189
  const drKey = keys.get("x-ahrefs-key") ?? options.drKey;
35649
36190
  const rated = await withDomainRating(rows, drKey);
35650
36191
  const targetDr = target ? (await domainRatings([target], drKey)).get(bareDomain(target)) : void 0;
@@ -35661,6 +36202,9 @@ async function runResearch(input, keys, options) {
35661
36202
  ...costUsd !== null ? [`DataForSEO cost: $${costUsd}.`] : [],
35662
36203
  ...partial ? ["Partial: DataForSEO could not fetch some pages of results, so rows may be missing."] : [],
35663
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)] : [],
35664
36208
  ...note ? [note] : [],
35665
36209
  ...hasTargetDr ? [`${bareDomain(target)} domain_rating: ${targetDr}.`] : [],
35666
36210
  ...rated.added || hasTargetDr ? [DR_ATTRIBUTION] : []
@@ -35679,12 +36223,25 @@ async function runResearch(input, keys, options) {
35679
36223
  costUsd,
35680
36224
  partial,
35681
36225
  missedKeywords: missed,
36226
+ noDataKeywords: noData,
36227
+ ...input.action === "link_gap" ? {
36228
+ competitors: input.competitors ?? [],
36229
+ cutAt: floor ?? null
36230
+ } : {},
35682
36231
  total: rated.rows.length,
35683
36232
  offset,
35684
36233
  rows: rated.rows.slice(offset, offset + 50)
35685
36234
  }
35686
36235
  };
35687
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
+ }
35688
36245
  // packages/core/src/commands/sitemap-urls.ts
35689
36246
  const MAX_FILES = 20;
35690
36247
  const MAX_URLS = 5e4;
@@ -36234,6 +36791,23 @@ function siteLines(site, startMs, endMs) {
36234
36791
  } else lines.push("Search clicks: not available this week.");
36235
36792
  return lines;
36236
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
+ }
36237
36811
  function opportunityLines(site) {
36238
36812
  const found = site.opportunities;
36239
36813
  if (!found) return [];
@@ -36286,7 +36860,7 @@ function weekMarkdown(data) {
36286
36860
  "",
36287
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."
36288
36862
  ];
36289
- 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})`] : []);
36290
36864
  return out.join("\n");
36291
36865
  }
36292
36866
  async function runAllSites(deps, sites) {
@@ -36389,10 +36963,14 @@ const SHARED_TOOLS = [
36389
36963
  name: "seo_report",
36390
36964
  command: "report",
36391
36965
  title: "Search Console report",
36392
- 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.`,
36393
36967
  input: z.object({
36394
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."),
36395
- 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."),
36396
36974
  ...dates,
36397
36975
  ...detail,
36398
36976
  ...marks
@@ -36509,6 +37087,7 @@ const SHARED_TOOLS = [
36509
37087
  action: z.enum(RESEARCH_ACTIONS),
36510
37088
  keywords: z.array(z.string().min(1)).max(100).default([]),
36511
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."),
36512
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."),
36513
37092
  country: z.string().regex(/^[a-z]{2}$/).default("us").describe("Two-letter country code."),
36514
37093
  ...detail
@@ -36655,6 +37234,7 @@ SHARED_TOOLS.map((shared) => shared.name === "seo_index" ? indexFromStore(shared
36655
37234
  input: shared.input.extend({ section: z.enum([
36656
37235
  ...SECTION_IDS,
36657
37236
  ISSUES_SECTION,
37237
+ UPDATES_SECTION,
36658
37238
  AUDIT_SECTION
36659
37239
  ]).optional().describe("Optional. Leave it out first; then ask for one section.") })
36660
37240
  } : shared);