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/mcp.js CHANGED
@@ -1,11 +1,11 @@
1
1
  import { createRequire as __seoCreateRequire } from 'node:module'; const require = __seoCreateRequire(import.meta.url);
2
- import { i as __toESM, n as version, r as __commonJSMin, t as load } from "./esm-Dt4JJZ0P.js";
2
+ import { i as __toESM, n as version, r as __commonJSMin, t as load } from "./esm-CV5s7esU.js";
3
3
  import { createHash, randomUUID, sign } from "node:crypto";
4
4
  import { ZodOptional, z } from "zod";
5
5
  import { arch, homedir, platform, tmpdir, totalmem } from "node:os";
6
6
  import { dirname, extname, isAbsolute, join, relative, resolve } from "node:path";
7
7
  import "open";
8
- import { chmodSync, createReadStream, existsSync, mkdirSync, readFileSync, realpathSync, renameSync, rmSync, writeFileSync } from "node:fs";
8
+ import { chmodSync, createReadStream, existsSync, mkdirSync, readFileSync, readdirSync, realpathSync, renameSync, rmSync, writeFileSync } from "node:fs";
9
9
  import lockfile from "proper-lockfile";
10
10
  import "node:http";
11
11
  import { Headers as Headers$1, fetch as fetch$1 } from "undici";
@@ -12461,6 +12461,24 @@ const RULE_DEFINITIONS = [
12461
12461
  suggestedCommands: ["seo crawl <url> --max-pages 1 --json"]
12462
12462
  }
12463
12463
  },
12464
+ {
12465
+ id: "crawl_single_page_reach",
12466
+ title: "Crawl could reach only the start page",
12467
+ category: "links",
12468
+ defaultSeverity: "high",
12469
+ 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.",
12470
+ 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.",
12471
+ 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.",
12472
+ howToVerify: "Rerun the crawl and confirm it retains more than one page and the start page reports internal links.",
12473
+ agentHints: {
12474
+ evidenceFields: [
12475
+ "page.outgoingInternalCount",
12476
+ "report.sitemapDiscovery.urlsReturned",
12477
+ "page.fetchDiagnostics.rendering"
12478
+ ],
12479
+ suggestedCommands: ["seo crawl <url> --rendering on --json", "seo crawl --mode list --urls <url>,<url> --json"]
12480
+ }
12481
+ },
12464
12482
  {
12465
12483
  id: "soft_404",
12466
12484
  title: "Unknown URLs return a successful response",
@@ -12469,7 +12487,7 @@ const RULE_DEFINITIONS = [
12469
12487
  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.",
12470
12488
  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.",
12471
12489
  impactIfIgnored: "Missing URLs can remain in crawl queues, waste crawl attention, and create confusing search or monitoring evidence.",
12472
- howToVerify: "Request several known-nonexistent paths and confirm each returns 404 or 410 without redirecting to a generic successful page.",
12490
+ 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.",
12473
12491
  agentHints: {
12474
12492
  evidenceFields: ["report.siteChecks.soft404"],
12475
12493
  suggestedCommands: ["seo crawl <url> --json"]
@@ -13918,7 +13936,7 @@ function day$3(value) {
13918
13936
  const date = legacy ? new Date(Number(legacy[1])) : new Date(value);
13919
13937
  return Number.isNaN(date.getTime()) ? null : date.toISOString().slice(0, 10);
13920
13938
  }
13921
- const count$3 = (value) => typeof value === "number" && Number.isFinite(value) && value >= 0 ? value : null;
13939
+ const count$4 = (value) => typeof value === "number" && Number.isFinite(value) && value >= 0 ? value : null;
13922
13940
  function list$2(data, label) {
13923
13941
  if (!Array.isArray(data)) throw new SeoError("OPTIONAL_PROVIDER_UNAVAILABLE", `Bing Webmaster returned an invalid ${label}.`);
13924
13942
  return data.filter((item) => Boolean(item) && typeof item === "object");
@@ -13934,8 +13952,8 @@ async function bingTraffic(call, site) {
13934
13952
  let invalidRows = 0;
13935
13953
  for (const row of list$2(await call("GetRankAndTrafficStats", { siteUrl: site }), "traffic list")) {
13936
13954
  const date = day$3(row.Date);
13937
- const clicks = count$3(row.Clicks);
13938
- const impressions = count$3(row.Impressions);
13955
+ const clicks = count$4(row.Clicks);
13956
+ const impressions = count$4(row.Impressions);
13939
13957
  if (!date || clicks === null || impressions === null) {
13940
13958
  invalidRows += 1;
13941
13959
  continue;
@@ -13958,9 +13976,9 @@ async function keyed(call, method, params) {
13958
13976
  for (const row of list$2(await call(method, params), "row list")) {
13959
13977
  const date = day$3(row.Date);
13960
13978
  const key = typeof row.Query === "string" ? row.Query.trim() : "";
13961
- const clicks = count$3(row.Clicks);
13962
- const impressions = count$3(row.Impressions);
13963
- const position = count$3(row.AvgImpressionPosition);
13979
+ const clicks = count$4(row.Clicks);
13980
+ const impressions = count$4(row.Impressions);
13981
+ const position = count$4(row.AvgImpressionPosition);
13964
13982
  if (!date || !key || clicks === null || impressions === null) {
13965
13983
  invalidRows += 1;
13966
13984
  continue;
@@ -14491,6 +14509,14 @@ function sharedReader(read, redirects = /* @__PURE__ */ new Map()) {
14491
14509
  };
14492
14510
  }
14493
14511
  const MAX_STATUS_BYTES = 10485760;
14512
+ async function rankingUpdates() {
14513
+ 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);
14514
+ if (!Array.isArray(incidents)) return "Google Search Status could not be read.";
14515
+ return searchUpdatesFromIncidents(withRankingHistory(incidents), {
14516
+ product: "Ranking",
14517
+ limit: Number.POSITIVE_INFINITY
14518
+ });
14519
+ }
14494
14520
  async function googleUpdates(window) {
14495
14521
  if (!window) return [];
14496
14522
  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);
@@ -19543,6 +19569,56 @@ function systemBrowserCandidates(input) {
19543
19569
  product: path.includes("google-chrome") ? "Google Chrome" : "Chromium"
19544
19570
  }));
19545
19571
  }
19572
+ function listDirectorySafely(path) {
19573
+ try {
19574
+ return readdirSync(path);
19575
+ } catch {
19576
+ return [];
19577
+ }
19578
+ }
19579
+ function versionParts(name) {
19580
+ return (name.match(/\d+/g) ?? []).map(Number);
19581
+ }
19582
+ function newestFirst(left, right) {
19583
+ const a = versionParts(left);
19584
+ const b = versionParts(right);
19585
+ for (let index = 0; index < Math.max(a.length, b.length); index += 1) {
19586
+ const difference = (b[index] ?? -1) - (a[index] ?? -1);
19587
+ if (difference) return difference;
19588
+ }
19589
+ return left < right ? -1 : left > right ? 1 : 0;
19590
+ }
19591
+ const MAX_CACHED_BROWSER_VERSIONS = 20;
19592
+ function versionDirectories(root, prefix, listDirectory) {
19593
+ return listDirectory(root).filter((name) => name.startsWith(prefix)).sort(newestFirst).slice(0, MAX_CACHED_BROWSER_VERSIONS).map((name) => join(root, name));
19594
+ }
19595
+ const CHROME_FOR_TESTING_APP = join("Google Chrome for Testing.app", "Contents", "MacOS", "Google Chrome for Testing");
19596
+ /**
19597
+ * Browsers that other local tools already downloaded: agent-browser keeps
19598
+ * Chrome for Testing builds and Playwright keeps one Chromium per revision.
19599
+ */
19600
+ function cachedBrowserCandidates(input) {
19601
+ const agentBrowserExecutables = input.platform === "darwin" ? [CHROME_FOR_TESTING_APP] : input.platform === "win32" ? ["chrome.exe"] : ["chrome"];
19602
+ const agentBrowser = versionDirectories(join(input.homeDirectory, ".agent-browser", "browsers"), "chrome-", input.listDirectory).flatMap((directory) => agentBrowserExecutables.map((executable) => ({
19603
+ path: join(directory, executable),
19604
+ source: "agent-browser-cache",
19605
+ product: "agent-browser Chrome for Testing"
19606
+ })));
19607
+ const configuredRoot = input.environment.PLAYWRIGHT_BROWSERS_PATH?.trim();
19608
+ 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");
19609
+ const playwrightExecutables = input.platform === "darwin" ? [
19610
+ join("chrome-mac-arm64", CHROME_FOR_TESTING_APP),
19611
+ join("chrome-mac-x64", CHROME_FOR_TESTING_APP),
19612
+ join("chrome-mac", CHROME_FOR_TESTING_APP),
19613
+ join("chrome-mac", "Chromium.app", "Contents", "MacOS", "Chromium")
19614
+ ] : input.platform === "win32" ? [join("chrome-win64", "chrome.exe"), join("chrome-win", "chrome.exe")] : [join("chrome-linux64", "chrome"), join("chrome-linux", "chrome")];
19615
+ const playwright = versionDirectories(playwrightRoot, "chromium-", input.listDirectory).flatMap((directory) => playwrightExecutables.map((executable) => ({
19616
+ path: join(directory, executable),
19617
+ source: "playwright-cache",
19618
+ product: "Playwright Chromium"
19619
+ })));
19620
+ return [...agentBrowser, ...playwright];
19621
+ }
19546
19622
  /**
19547
19623
  * Resolve a browser without downloading one during normal package installation.
19548
19624
  * The explicit path wins so CI and managed workstations can select their own
@@ -19574,11 +19650,23 @@ function resolveBrowserExecutable(playwrightExecutablePath, dependencies = {}) {
19574
19650
  product: "Playwright Chromium"
19575
19651
  }
19576
19652
  };
19577
- const browser = systemBrowserCandidates({
19578
- platform: dependencies.platform ?? process.platform,
19579
- homeDirectory: dependencies.homeDirectory ?? homedir(),
19653
+ const platform = dependencies.platform ?? process.platform;
19654
+ const homeDirectory = dependencies.homeDirectory ?? homedir();
19655
+ const systemBrowser = systemBrowserCandidates({
19656
+ platform,
19657
+ homeDirectory,
19580
19658
  environment
19581
19659
  }).find((candidate) => fileExists(candidate.path));
19660
+ if (systemBrowser) return {
19661
+ status: "available",
19662
+ browser: systemBrowser
19663
+ };
19664
+ const browser = cachedBrowserCandidates({
19665
+ platform,
19666
+ homeDirectory,
19667
+ environment,
19668
+ listDirectory: dependencies.listDirectory ?? listDirectorySafely
19669
+ }).find((candidate) => fileExists(candidate.path));
19582
19670
  return browser ? {
19583
19671
  status: "available",
19584
19672
  browser
@@ -22686,38 +22774,61 @@ function isValuablePage(page) {
22686
22774
  function normalizedMetadata(value) {
22687
22775
  return value?.trim().replace(/\s+/g, " ").toLowerCase() || void 0;
22688
22776
  }
22689
- function metadataDuplicates(pages, read) {
22690
- const counts = /* @__PURE__ */ new Map();
22777
+ /**
22778
+ * A page whose canonical names another crawled page in its group is already
22779
+ * handled. A group counts only when two or more of its pages are
22780
+ * self-canonical or declare no canonical, and only those pages are reported.
22781
+ */
22782
+ function duplicateGroups(pages, read) {
22783
+ const grouped = /* @__PURE__ */ new Map();
22691
22784
  for (const page of pages) {
22692
22785
  if (!isAuditableHtmlPage(page)) continue;
22693
- const raw = read(page)?.trim();
22694
- const key = normalizedMetadata(raw);
22695
- if (!key || !raw) continue;
22696
- const existing = counts.get(key) ?? {
22697
- count: 0,
22698
- sampleUrls: [],
22699
- value: raw
22786
+ const entry = read(page);
22787
+ if (!entry) continue;
22788
+ const group = grouped.get(entry.key) ?? {
22789
+ value: entry.value,
22790
+ pages: []
22700
22791
  };
22701
- existing.count += 1;
22702
- if (existing.sampleUrls.length < 10) existing.sampleUrls.push(page.url);
22703
- counts.set(key, existing);
22792
+ group.pages.push(page);
22793
+ grouped.set(entry.key, group);
22794
+ }
22795
+ const duplicates = /* @__PURE__ */ new Map();
22796
+ for (const [key, group] of grouped) {
22797
+ if (group.pages.length < 2) continue;
22798
+ const groupUrls = /* @__PURE__ */ new Set();
22799
+ for (const page of group.pages) for (const value of [page.url, page.finalUrl]) {
22800
+ const url = urlKey(value);
22801
+ if (url) groupUrls.add(url);
22802
+ }
22803
+ const counted = group.pages.filter((page) => {
22804
+ const canonical = urlKey(page.canonical);
22805
+ return !canonical || canonical === urlKey(page.url) || canonical === urlKey(page.finalUrl) || !groupUrls.has(canonical);
22806
+ });
22807
+ if (counted.length < 2) continue;
22808
+ duplicates.set(key, {
22809
+ count: counted.length,
22810
+ sampleUrls: counted.slice(0, 10).map((page) => page.url),
22811
+ value: group.value,
22812
+ urls: new Set(counted.map((page) => page.url))
22813
+ });
22704
22814
  }
22705
- return new Map([...counts].filter(([, value]) => value.count > 1));
22815
+ return duplicates;
22816
+ }
22817
+ function metadataDuplicates(pages, read) {
22818
+ return duplicateGroups(pages, (page) => {
22819
+ const value = read(page)?.trim();
22820
+ const key = normalizedMetadata(value);
22821
+ return key && value ? {
22822
+ key,
22823
+ value
22824
+ } : void 0;
22825
+ });
22706
22826
  }
22707
22827
  function contentDuplicates(pages) {
22708
- const counts = /* @__PURE__ */ new Map();
22709
- for (const page of pages) {
22710
- if (!isAuditableHtmlPage(page)) continue;
22711
- if (page.wordCount < 20 || !page.mainContentHash) continue;
22712
- const existing = counts.get(page.mainContentHash) ?? {
22713
- count: 0,
22714
- sampleUrls: []
22715
- };
22716
- existing.count += 1;
22717
- if (existing.sampleUrls.length < 10) existing.sampleUrls.push(page.url);
22718
- counts.set(page.mainContentHash, existing);
22719
- }
22720
- return new Map([...counts].filter(([, value]) => value.count > 1));
22828
+ return duplicateGroups(pages, (page) => page.wordCount >= 20 && page.mainContentHash ? {
22829
+ key: page.mainContentHash,
22830
+ value: page.mainContentHash
22831
+ } : void 0);
22721
22832
  }
22722
22833
  const QUERY_STOP_WORDS = /* @__PURE__ */ new Set([
22723
22834
  "a",
@@ -22927,7 +23038,7 @@ function auditCrawlPages(pages, opts = {}) {
22927
23038
  title,
22928
23039
  ...titleWidth
22929
23040
  }));
22930
- if (title && duplicateTitle) issues.push(issue$1("title_duplicate", page, duplicateTitle.value, {
23041
+ if (title && duplicateTitle?.urls.has(page.url)) issues.push(issue$1("title_duplicate", page, duplicateTitle.value, {
22931
23042
  title,
22932
23043
  duplicateCount: duplicateTitle.count,
22933
23044
  sampleUrls: duplicateTitle.sampleUrls
@@ -22935,7 +23046,7 @@ function auditCrawlPages(pages, opts = {}) {
22935
23046
  const metaDescription = page.metaDescription?.trim();
22936
23047
  const duplicateDescription = duplicateDescriptions.get(normalizedMetadata(metaDescription) ?? "");
22937
23048
  if (!metaDescription) issues.push(issue$1("missing_meta_description", page));
22938
- if (metaDescription && duplicateDescription) issues.push(issue$1("meta_description_duplicate", page, duplicateDescription.value, {
23049
+ if (metaDescription && duplicateDescription?.urls.has(page.url)) issues.push(issue$1("meta_description_duplicate", page, duplicateDescription.value, {
22939
23050
  metaDescription,
22940
23051
  duplicateCount: duplicateDescription.count,
22941
23052
  sampleUrls: duplicateDescription.sampleUrls
@@ -22983,7 +23094,7 @@ function auditCrawlPages(pages, opts = {}) {
22983
23094
  finalUrl: page.finalUrl
22984
23095
  }));
22985
23096
  const contentDuplicate = page.mainContentHash ? duplicateContent.get(page.mainContentHash) : void 0;
22986
- if (contentDuplicate) issues.push(issue$1("duplicate_content", page, "Duplicate main content", {
23097
+ if (contentDuplicate?.urls.has(page.url)) issues.push(issue$1("duplicate_content", page, "Duplicate main content", {
22987
23098
  mainContentHash: page.mainContentHash,
22988
23099
  duplicateCount: contentDuplicate.count,
22989
23100
  sampleUrls: contentDuplicate.sampleUrls
@@ -23058,6 +23169,7 @@ const CRAWL_SKIP_REASONS = [
23058
23169
  "off-origin",
23059
23170
  "origin-backpressure",
23060
23171
  "queue-safety-limit",
23172
+ "query-variant-limit",
23061
23173
  "robots-disallowed",
23062
23174
  "robots-uncertain",
23063
23175
  "legacy-unclassified"
@@ -23071,6 +23183,7 @@ const SKIP_IMPACT = {
23071
23183
  "off-origin": "non-impacting",
23072
23184
  "origin-backpressure": "coverage-affecting",
23073
23185
  "queue-safety-limit": "coverage-affecting",
23186
+ "query-variant-limit": "coverage-affecting",
23074
23187
  "robots-disallowed": "coverage-affecting",
23075
23188
  "robots-uncertain": "coverage-affecting",
23076
23189
  "legacy-unclassified": "coverage-affecting"
@@ -23159,6 +23272,10 @@ const VALUE_WEIGHT = {
23159
23272
  medium: .75,
23160
23273
  low: .15
23161
23274
  };
23275
+ const CRAWL_REACH_RULES = /* @__PURE__ */ new Set(["crawl_single_page_reach"]);
23276
+ function reachRank(ruleId) {
23277
+ return CRAWL_REACH_RULES.has(ruleId) ? 0 : 1;
23278
+ }
23162
23279
  function matchesPattern(pattern, value) {
23163
23280
  if (!pattern.includes("*")) return value.includes(pattern);
23164
23281
  const parts = pattern.split("*");
@@ -23266,7 +23383,7 @@ function topFixes(report, filters = {}) {
23266
23383
  expected: rule?.howToVerify ?? ""
23267
23384
  }
23268
23385
  };
23269
- }).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);
23386
+ }).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);
23270
23387
  }
23271
23388
  function reviewObservations(report, filters = {}) {
23272
23389
  return topFixes(report, {
@@ -24106,6 +24223,7 @@ const crawlPageSnapshotSchema = z.object({
24106
24223
  source: z.enum([
24107
24224
  "environment",
24108
24225
  "playwright-cache",
24226
+ "agent-browser-cache",
24109
24227
  "system"
24110
24228
  ]),
24111
24229
  product: z.string(),
@@ -24794,55 +24912,6 @@ function agentReadyIssues(input) {
24794
24912
  }
24795
24913
  return issues;
24796
24914
  }
24797
- // packages/core/dist/analyze/crawler/crawl-caveats.js
24798
- function crawlCaveats(input) {
24799
- const caveats = input.cancelled ? ["Crawl cancelled before all queued URLs finished."] : input.pageLimitReached ? [`Stopped after reaching maxPages (${input.maxPages}).`] : [];
24800
- 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.`);
24801
- 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.`);
24802
- 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.`);
24803
- return caveats;
24804
- }
24805
- // packages/core/dist/analyze/crawler/crawl-control.js
24806
- const CRAWL_CANCELLED = Symbol("crawl-cancelled");
24807
- function abortController(input) {
24808
- const controller = new AbortController();
24809
- const abort = () => controller.abort();
24810
- const timer = setTimeout(abort, Math.min(input.timeoutMs, 5e3));
24811
- if (input.signal?.aborted) controller.abort();
24812
- else input.signal?.addEventListener("abort", abort, { once: true });
24813
- return {
24814
- signal: controller.signal,
24815
- cleanup: () => {
24816
- clearTimeout(timer);
24817
- input.signal?.removeEventListener("abort", abort);
24818
- }
24819
- };
24820
- }
24821
- function cancellationRace(signal) {
24822
- if (signal.aborted) return Promise.resolve(CRAWL_CANCELLED);
24823
- return new Promise((resolve) => {
24824
- signal.addEventListener("abort", () => resolve(CRAWL_CANCELLED), { once: true });
24825
- });
24826
- }
24827
- // packages/core/dist/analyze/crawler/crawl-memory.js
24828
- const MEBIBYTE = 1048576;
24829
- const MIN_CRAWL_MEMORY_LIMIT = 384 * MEBIBYTE;
24830
- const MAX_CRAWL_MEMORY_LIMIT = 640 * MEBIBYTE;
24831
- const MIN_CRAWL_RSS_LIMIT = 512 * MEBIBYTE;
24832
- const MAX_CRAWL_RSS_LIMIT = 896 * MEBIBYTE;
24833
- const SYSTEM_MEMORY_FRACTION = .1;
24834
- const SYSTEM_RSS_FRACTION = .2;
24835
- function crawlMemoryLimitBytes(totalMemoryBytes) {
24836
- if (!Number.isFinite(totalMemoryBytes) || totalMemoryBytes <= 0) return MAX_CRAWL_MEMORY_LIMIT;
24837
- return Math.min(MAX_CRAWL_MEMORY_LIMIT, Math.max(MIN_CRAWL_MEMORY_LIMIT, Math.floor(totalMemoryBytes * SYSTEM_MEMORY_FRACTION)));
24838
- }
24839
- function crawlRssLimitBytes(totalMemoryBytes) {
24840
- if (!Number.isFinite(totalMemoryBytes) || totalMemoryBytes <= 0) return MAX_CRAWL_RSS_LIMIT;
24841
- return Math.min(MAX_CRAWL_RSS_LIMIT, Math.max(MIN_CRAWL_RSS_LIMIT, Math.floor(totalMemoryBytes * SYSTEM_RSS_FRACTION)));
24842
- }
24843
- function crawlMemoryPressure(input) {
24844
- return input.memoryUsage.heapUsed + input.memoryUsage.external >= crawlMemoryLimitBytes(input.totalMemoryBytes) || input.memoryUsage.rss >= crawlRssLimitBytes(input.totalMemoryBytes);
24845
- }
24846
24915
  // packages/core/dist/analyze/crawler/crawl-url-queue.js
24847
24916
  function before(left, right) {
24848
24917
  return left.depth < right.depth || left.depth === right.depth && left.url < right.url;
@@ -24922,6 +24991,85 @@ var CrawlUrlQueue = class {
24922
24991
  }
24923
24992
  }
24924
24993
  };
24994
+ // packages/core/dist/analyze/crawler/crawl-caveats.js
24995
+ function crawlCaveats(input) {
24996
+ const caveats = input.cancelled ? ["Crawl cancelled before all queued URLs finished."] : input.pageLimitReached ? [`Stopped after reaching maxPages (${input.maxPages}).`] : [];
24997
+ 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.`);
24998
+ 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.`);
24999
+ 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.`);
25000
+ const renderingFallbackUrls = input.renderingFallbackUrls ?? 0;
25001
+ 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.`);
25002
+ const queryVariantSkippedUrls = input.queryVariantSkippedUrls ?? 0;
25003
+ 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.`);
25004
+ for (const issue of input.reachIssues ?? []) if (issue.detail) caveats.push(issue.detail);
25005
+ return caveats;
25006
+ }
25007
+ /**
25008
+ * A site crawl that found no internal links on the start page and no sitemap
25009
+ * URLs checked one page, so its other findings cannot stand for the site.
25010
+ */
25011
+ function crawlReachIssues(input) {
25012
+ const [page] = input.pages;
25013
+ if (input.crawledPages !== 1 || input.pages.length !== 1 || !page || page.outgoingInternalCount > 0 || input.sitemapUrlsReturned > 0) return [];
25014
+ const rule = explainRule("crawl_single_page_reach");
25015
+ if (!rule) throw new Error("Missing rule guidance for crawl_single_page_reach");
25016
+ return [{
25017
+ ruleId: "crawl_single_page_reach",
25018
+ title: rule.title,
25019
+ category: rule.category,
25020
+ severity: rule.defaultSeverity,
25021
+ url: page.url,
25022
+ 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.",
25023
+ evidence: {
25024
+ startUrl: input.startUrl,
25025
+ crawledPages: 1,
25026
+ outgoingInternalCount: 0,
25027
+ sitemapUrlsReturned: 0,
25028
+ rendered: Boolean(page.usedJs)
25029
+ }
25030
+ }];
25031
+ }
25032
+ // packages/core/dist/analyze/crawler/crawl-control.js
25033
+ const CRAWL_CANCELLED = Symbol("crawl-cancelled");
25034
+ function abortController(input) {
25035
+ const controller = new AbortController();
25036
+ const abort = () => controller.abort();
25037
+ const timer = setTimeout(abort, Math.min(input.timeoutMs, 5e3));
25038
+ if (input.signal?.aborted) controller.abort();
25039
+ else input.signal?.addEventListener("abort", abort, { once: true });
25040
+ return {
25041
+ signal: controller.signal,
25042
+ cleanup: () => {
25043
+ clearTimeout(timer);
25044
+ input.signal?.removeEventListener("abort", abort);
25045
+ }
25046
+ };
25047
+ }
25048
+ function cancellationRace(signal) {
25049
+ if (signal.aborted) return Promise.resolve(CRAWL_CANCELLED);
25050
+ return new Promise((resolve) => {
25051
+ signal.addEventListener("abort", () => resolve(CRAWL_CANCELLED), { once: true });
25052
+ });
25053
+ }
25054
+ // packages/core/dist/analyze/crawler/crawl-memory.js
25055
+ const MEBIBYTE = 1048576;
25056
+ const MIN_CRAWL_MEMORY_LIMIT = 384 * MEBIBYTE;
25057
+ const MAX_CRAWL_MEMORY_LIMIT = 640 * MEBIBYTE;
25058
+ const MIN_CRAWL_RSS_LIMIT = 512 * MEBIBYTE;
25059
+ const MAX_CRAWL_RSS_LIMIT = 896 * MEBIBYTE;
25060
+ const SYSTEM_MEMORY_FRACTION = .1;
25061
+ const SYSTEM_RSS_FRACTION = .2;
25062
+ function crawlMemoryLimitBytes(totalMemoryBytes) {
25063
+ if (!Number.isFinite(totalMemoryBytes) || totalMemoryBytes <= 0) return MAX_CRAWL_MEMORY_LIMIT;
25064
+ return Math.min(MAX_CRAWL_MEMORY_LIMIT, Math.max(MIN_CRAWL_MEMORY_LIMIT, Math.floor(totalMemoryBytes * SYSTEM_MEMORY_FRACTION)));
25065
+ }
25066
+ function crawlRssLimitBytes(totalMemoryBytes) {
25067
+ if (!Number.isFinite(totalMemoryBytes) || totalMemoryBytes <= 0) return MAX_CRAWL_RSS_LIMIT;
25068
+ return Math.min(MAX_CRAWL_RSS_LIMIT, Math.max(MIN_CRAWL_RSS_LIMIT, Math.floor(totalMemoryBytes * SYSTEM_RSS_FRACTION)));
25069
+ }
25070
+ function crawlMemoryPressure(input) {
25071
+ return input.memoryUsage.heapUsed + input.memoryUsage.external >= crawlMemoryLimitBytes(input.totalMemoryBytes) || input.memoryUsage.rss >= crawlRssLimitBytes(input.totalMemoryBytes);
25072
+ }
24925
25073
  // packages/core/dist/analyze/crawler/external-link-checks.js
24926
25074
  const EXTERNAL_LINK_SELECTION_LIMIT = 200;
24927
25075
  const EXTERNAL_LINK_STATES = [
@@ -25154,11 +25302,15 @@ function observationFromPage(requestedUrl, page) {
25154
25302
  };
25155
25303
  }
25156
25304
  // packages/core/dist/analyze/crawler/site-checks.js
25157
- const PROBE_COUNT = 2;
25305
+ const PROBE_COUNT = 3;
25158
25306
  function probeUrls(startUrl) {
25159
25307
  const origin = new URL(startUrl).origin;
25160
25308
  const seed = createHash("sha256").update(origin).digest("hex").slice(0, 12);
25161
- return Array.from({ length: PROBE_COUNT }, (_, index) => new URL(`/.well-known/seo-audit/not-found-${seed}-${index + 1}`, origin).toString());
25309
+ return [
25310
+ `/${seed}-not-found`,
25311
+ `/${seed}-not-found/${seed}-missing`,
25312
+ `/.well-known/seo-audit/not-found-${seed}-1`
25313
+ ].map((path) => new URL(path, origin).toString());
25162
25314
  }
25163
25315
  function probeObservation(url, result) {
25164
25316
  const request = result.request;
@@ -25195,7 +25347,7 @@ async function collectCrawlSiteChecks(input) {
25195
25347
  const conclusive = probes.filter((probe) => probe.status !== void 0 && !probe.accessBlocked);
25196
25348
  const successful = conclusive.filter((probe) => (probe.status ?? 0) >= 200 && (probe.status ?? 0) < 400);
25197
25349
  const correctMissing = conclusive.filter((probe) => probe.status === 404 || probe.status === 410);
25198
- const status = conclusive.length !== PROBE_COUNT ? "unknown" : successful.length === PROBE_COUNT ? "warning" : correctMissing.length === PROBE_COUNT ? "pass" : "unknown";
25350
+ const status = successful.length > 0 ? "warning" : correctMissing.length === PROBE_COUNT ? "pass" : "unknown";
25199
25351
  const checks = { soft404: {
25200
25352
  status,
25201
25353
  probes,
@@ -25216,7 +25368,7 @@ async function collectCrawlSiteChecks(input) {
25216
25368
  category: rule.category,
25217
25369
  severity: rule.defaultSeverity,
25218
25370
  url: new URL("/", input.startUrl).toString(),
25219
- detail: `${successful.length} known-nonexistent paths returned a successful response or redirect.`,
25371
+ 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(", ")}.`,
25220
25372
  evidence: checks.soft404
25221
25373
  }]
25222
25374
  };
@@ -27883,6 +28035,9 @@ async function crawlSite(input, dependencies) {
27883
28035
  const queueSafetyDeferredUrls = /* @__PURE__ */ new Set();
27884
28036
  const maxTrackedQueueSafetyUrls = Math.min(5e4, Math.max(1e3, config.maxPages * 5));
27885
28037
  let queueSafetyInventoryCapped = false;
28038
+ const queryVariantsByPath = /* @__PURE__ */ new Map();
28039
+ const queryVariantSkipped = /* @__PURE__ */ new Set();
28040
+ let renderingFallbackUrls = 0;
27886
28041
  let failedUrls = 0;
27887
28042
  let observedInternalLinks = 0;
27888
28043
  let memoryLimited = false;
@@ -27985,6 +28140,20 @@ async function crawlSite(input, dependencies) {
27985
28140
  });
27986
28141
  return;
27987
28142
  }
28143
+ const parsed = new URL(normalized);
28144
+ const pathKey = parsed.search ? `${parsed.origin}${parsed.pathname}` : "";
28145
+ if (pathKey && (queryVariantsByPath.get(pathKey) ?? 0) >= 5) {
28146
+ if (!queryVariantSkipped.has(normalized) && queryVariantSkipped.size < maxTrackedQueueSafetyUrls) {
28147
+ queryVariantSkipped.add(normalized);
28148
+ recordSkip("query-variant-limit");
28149
+ emitStatus("url_skipped", {
28150
+ url: normalized,
28151
+ depth,
28152
+ reason: "query_variant_limit"
28153
+ });
28154
+ }
28155
+ return;
28156
+ }
27988
28157
  if (queue.size + visited.size + inFlight.size >= config.maxPages * 5) {
27989
28158
  if (!queueSafetyDeferredUrls.has(normalized)) {
27990
28159
  if (queueSafetyDeferredUrls.size < maxTrackedQueueSafetyUrls) {
@@ -28000,6 +28169,7 @@ async function crawlSite(input, dependencies) {
28000
28169
  }
28001
28170
  return;
28002
28171
  }
28172
+ if (pathKey) queryVariantsByPath.set(pathKey, (queryVariantsByPath.get(pathKey) ?? 0) + 1);
28003
28173
  discovered.add(normalized);
28004
28174
  queuedUrls += 1;
28005
28175
  queue.push({
@@ -28152,6 +28322,9 @@ async function crawlSite(input, dependencies) {
28152
28322
  continue;
28153
28323
  }
28154
28324
  const page = result.page;
28325
+ const rendering = page.fetchDiagnostics?.rendering;
28326
+ 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());
28327
+ if (rendering?.status === "unavailable" || rendering?.status === "failed") renderingFallbackUrls += 1;
28155
28328
  const finalUrl = normalizeUrl$1(page.finalUrl, config.url);
28156
28329
  if (!finalUrl) {
28157
28330
  failedUrls += 1;
@@ -28374,6 +28547,12 @@ async function crawlSite(input, dependencies) {
28374
28547
  ...startPage.responseHeaders?.["content-signal"] ? { contentSignal: startPage.responseHeaders["content-signal"] } : {}
28375
28548
  } } : {}
28376
28549
  } : aiSignals?.signals;
28550
+ const reachIssues = followLinks && config.maxDepth > 0 && config.maxPages > 1 && !cancelled ? crawlReachIssues({
28551
+ startUrl: config.url,
28552
+ pages: pages.filter((page) => page.status >= 200 && page.status < 300),
28553
+ crawledPages: pages.length,
28554
+ sitemapUrlsReturned: sitemapDiscovery?.urlsReturned ?? 0
28555
+ }) : [];
28377
28556
  const report = createCrawlReport({
28378
28557
  config,
28379
28558
  site,
@@ -28388,6 +28567,7 @@ async function crawlSite(input, dependencies) {
28388
28567
  ...externalLinkVerification ? { externalLinkVerification } : {},
28389
28568
  ...siteCheckResult ? { siteChecks: siteCheckResult.checks } : {},
28390
28569
  additionalIssues: [
28570
+ ...reachIssues,
28391
28571
  ...siteCheckResult?.issues ?? [],
28392
28572
  ...sitemapDiscovery ? sitemapHostIssues(sitemapDiscovery, config.url) : [],
28393
28573
  ...ai ? agentReadyIssues({
@@ -28405,7 +28585,10 @@ async function crawlSite(input, dependencies) {
28405
28585
  queueSafetySkippedUrls,
28406
28586
  queueSafetyInventoryCapped,
28407
28587
  originBackpressureSkippedUrls,
28408
- memoryPressureSkippedUrls
28588
+ memoryPressureSkippedUrls,
28589
+ queryVariantSkippedUrls: queryVariantSkipped.size,
28590
+ renderingFallbackUrls,
28591
+ reachIssues
28409
28592
  }),
28410
28593
  stats: {
28411
28594
  discoveredUrls: discovered.size,
@@ -28958,7 +29141,7 @@ function analyzeCtrUnderperformersFromRows(input) {
28958
29141
  // packages/core/dist/analyze/ctr-underperformers-report.js
28959
29142
  const MAX_GSC_ROWS$3 = 1e5;
28960
29143
  const DEFAULT_DAYS$3 = 28;
28961
- const MAX_DAYS$2 = 548;
29144
+ const MAX_DAYS$3 = 548;
28962
29145
  function plural$8(count, singular, pluralLabel = `${singular}s`) {
28963
29146
  return count === 1 ? singular : pluralLabel;
28964
29147
  }
@@ -28977,12 +29160,12 @@ async function ctrUnderperformersReportWith(input, dependencies) {
28977
29160
  maximum: 100,
28978
29161
  label: "limit"
28979
29162
  });
28980
- const explicitRange = explicitDateRange(input, MAX_DAYS$2);
29163
+ const explicitRange = explicitDateRange(input, MAX_DAYS$3);
28981
29164
  const days = explicitRange?.days ?? integerOption$1({
28982
29165
  value: input.days,
28983
29166
  fallback: DEFAULT_DAYS$3,
28984
29167
  minimum: 1,
28985
- maximum: MAX_DAYS$2,
29168
+ maximum: MAX_DAYS$3,
28986
29169
  label: "Days"
28987
29170
  });
28988
29171
  const now = dependencies.now();
@@ -32171,7 +32354,7 @@ function quickWinTemplateSummaries(items) {
32171
32354
  }
32172
32355
  // packages/core/dist/analyze/site-diagnostics/quick-wins-report.js
32173
32356
  const DEFAULT_DAYS$2 = 28;
32174
- const MAX_DAYS$1 = 548;
32357
+ const MAX_DAYS$2 = 548;
32175
32358
  const DEFAULT_VERIFY_LIMIT$1 = 5;
32176
32359
  const MAX_VERIFY_LIMIT = 100;
32177
32360
  const MAX_SOURCE_ROWS$2 = 1e5;
@@ -32189,12 +32372,12 @@ function applyVerification$1(item, coverage) {
32189
32372
  };
32190
32373
  }
32191
32374
  async function quickWinsReportWith(input, dependencies) {
32192
- const explicitRange = explicitDateRange(input, MAX_DAYS$1);
32375
+ const explicitRange = explicitDateRange(input, MAX_DAYS$2);
32193
32376
  const days = explicitRange?.days ?? integerOption$1({
32194
32377
  value: input.days,
32195
32378
  fallback: DEFAULT_DAYS$2,
32196
32379
  minimum: 1,
32197
- maximum: MAX_DAYS$1,
32380
+ maximum: MAX_DAYS$2,
32198
32381
  label: "Days"
32199
32382
  });
32200
32383
  const verifyLimit = integerOption$1({
@@ -32613,7 +32796,7 @@ function analyzeStrikingDistanceRows(input) {
32613
32796
  }
32614
32797
  // packages/core/dist/analyze/striking-distance-report.js
32615
32798
  const DEFAULT_DAYS$1 = 28;
32616
- const MAX_DAYS = 548;
32799
+ const MAX_DAYS$1 = 548;
32617
32800
  const DEFAULT_VERIFY_LIMIT = 5;
32618
32801
  const MAX_SOURCE_ROWS$1 = 1e5;
32619
32802
  function boundedInteger$2(value, fallback, minimum, maximum) {
@@ -32622,7 +32805,7 @@ function boundedInteger$2(value, fallback, minimum, maximum) {
32622
32805
  }
32623
32806
  function reportDays(value) {
32624
32807
  if (value === void 0) return DEFAULT_DAYS$1;
32625
- 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}.`);
32808
+ 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}.`);
32626
32809
  return value;
32627
32810
  }
32628
32811
  function dateRange(days, now) {
@@ -32662,7 +32845,7 @@ function verdict$1(analysis) {
32662
32845
  return `${countLabel(analysis.summary.eligibleRows, "eligible query/page row")} found; ${countLabel(analysis.summary.returnedRows, "row")} returned for review.`;
32663
32846
  }
32664
32847
  async function strikingDistanceWith(input, dependencies) {
32665
- const explicitRange = explicitDateRange(input, MAX_DAYS);
32848
+ const explicitRange = explicitDateRange(input, MAX_DAYS$1);
32666
32849
  const days = explicitRange?.days ?? reportDays(input.days);
32667
32850
  const generatedAt = dependencies.now().toISOString();
32668
32851
  const range = explicitRange?.range ?? dateRange(days, new Date(generatedAt));
@@ -41868,6 +42051,7 @@ const wait = (ms) => new Promise((done) => setTimeout(done, ms));
41868
42051
  function requireInput(input) {
41869
42052
  const needsKeywords = input.action === "keyword_metrics" || input.action === "serp";
41870
42053
  if (needsKeywords && input.keywords.length === 0) throw new CommandError("INVALID_INPUT", `${input.action} needs keywords.`);
42054
+ if (input.action === "link_gap" && !input.target) throw new CommandError("INVALID_INPUT", "link_gap needs a target domain and competitors.");
41871
42055
  if (!needsKeywords && !input.target) throw new CommandError("INVALID_INPUT", `${input.action} needs a target domain or URL.`);
41872
42056
  }
41873
42057
  async function eachKeyword(keywords, missed, rowsFor) {
@@ -41885,14 +42069,17 @@ async function eachKeyword(keywords, missed, rowsFor) {
41885
42069
  }
41886
42070
  async function research(provider, headers, input) {
41887
42071
  requireInput(input);
42072
+ if (input.action === "link_gap") return linkGap(provider, headers, input);
41888
42073
  const keys = need(headers, provider);
41889
42074
  if (provider === "dataforseo") return dataForSeo(keys, input);
41890
42075
  const missed = [];
42076
+ const rows = await ahrefs(keys[0] ?? "", input, missed);
41891
42077
  return {
41892
- rows: await ahrefs(keys[0] ?? "", input, missed),
42078
+ rows,
41893
42079
  costUsd: null,
41894
42080
  partial: false,
41895
- missed
42081
+ missed,
42082
+ noData: withoutData(input, rows, missed, 100)
41896
42083
  };
41897
42084
  }
41898
42085
  function countryName(code) {
@@ -41944,7 +42131,8 @@ async function dataForSeoTask([login, password], path, task) {
41944
42131
  if (result?.status_code === NO_RESULTS) return {
41945
42132
  cost: result.cost ?? 0,
41946
42133
  result: null,
41947
- partial: false
42134
+ partial: false,
42135
+ noResults: true
41948
42136
  };
41949
42137
  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 });
41950
42138
  return {
@@ -41985,18 +42173,29 @@ async function dataForSeo(keys, input) {
41985
42173
  partial: false,
41986
42174
  missed: []
41987
42175
  };
42176
+ const rows = await dataForSeoRows(keys, input, spent);
41988
42177
  return {
41989
- rows: await dataForSeoRows(keys, input, spent),
42178
+ rows,
41990
42179
  costUsd: Math.round(spent.cost * 1e4) / 1e4,
41991
42180
  partial: spent.partial,
41992
- missed: spent.missed
42181
+ missed: spent.missed,
42182
+ noData: withoutData(input, rows, spent.missed, 700)
41993
42183
  };
41994
42184
  }
42185
+ function withoutData(input, rows, missed, keywordLimit) {
42186
+ if (input.action === "keyword_metrics" || input.action === "serp") {
42187
+ const max = input.action === "serp" ? 5 : keywordLimit;
42188
+ const answered = new Set(rows.map((row) => String(row.keyword ?? "").toLowerCase()));
42189
+ const failed = new Set(missed);
42190
+ return input.keywords.slice(0, max).filter((keyword) => !failed.has(keyword) && !answered.has(keyword.toLowerCase()));
42191
+ }
42192
+ return [];
42193
+ }
41995
42194
  async function dataForSeoRows(keys, input, spent) {
41996
42195
  const language = dataForSeoLanguage(input.country);
41997
42196
  if (!language && input.action !== "serp" && input.action !== "backlinks") throw new CommandError("INVALID_INPUT", `DataForSEO has no ${input.action} data for country "${input.country}".`);
41998
42197
  const market = dataForSeoMarket(input.country, language);
41999
- const post = async (path, task) => {
42198
+ const post = async (path, task, attempts = 1) => {
42000
42199
  const done = await dataForSeoTask(keys, path, {
42001
42200
  ...market,
42002
42201
  ...task
@@ -42006,6 +42205,7 @@ async function dataForSeoRows(keys, input, spent) {
42006
42205
  });
42007
42206
  spent.cost += done.cost;
42008
42207
  spent.partial ||= done.partial;
42208
+ if (done.noResults && attempts > 1) return post(path, task, attempts - 1);
42009
42209
  return done.result?.items ?? [];
42010
42210
  };
42011
42211
  const get = (value, path) => path.split(".").reduce((current, key) => current && typeof current === "object" ? current[key] : void 0, value);
@@ -42035,7 +42235,7 @@ async function dataForSeoRows(keys, input, spent) {
42035
42235
  return (await post("v3/serp/google/organic/live/advanced", {
42036
42236
  keyword,
42037
42237
  depth: Math.min(input.limit, 100)
42038
- })).filter((item) => item.type === "organic").map((item) => {
42238
+ }, 2)).filter((item) => item.type === "organic").map((item) => {
42039
42239
  const row = pick(item, {
42040
42240
  position: "rank_absolute",
42041
42241
  url: "url",
@@ -42043,9 +42243,11 @@ async function dataForSeoRows(keys, input, spent) {
42043
42243
  title: "title"
42044
42244
  });
42045
42245
  if (/^https?:\/\/(www\.)?google\.[a-z.]+\/goto\b/.test(String(row.url))) {
42046
- const site = /^https?:\/\/([^/\s›]+)/.exec(String(item.breadcrumb ?? ""));
42246
+ const crumb = String(item.breadcrumb ?? "");
42247
+ const site = /^https?:\/\/([^/\s›]+)/.exec(crumb);
42047
42248
  row.url = null;
42048
42249
  row.domain = site?.[1] ?? null;
42250
+ row.url_hint = site ? crumb.replace(/^https?:\/\//, "") : null;
42049
42251
  }
42050
42252
  return {
42051
42253
  keyword,
@@ -42075,6 +42277,65 @@ async function dataForSeoRows(keys, input, spent) {
42075
42277
  organic_etv: "full_domain_metrics.organic.etv"
42076
42278
  }));
42077
42279
  }
42280
+ const GAP_DOMAINS = 1e3;
42281
+ const GAP_COMPETITORS = 5;
42282
+ async function linkGap(provider, headers, input) {
42283
+ const target = bareTarget(input.target).replace(/^www\./, "");
42284
+ const competitors = [...new Set((input.competitors ?? []).map((site) => bareTarget(site).replace(/^www\./, "")))].filter((site) => site && site !== target);
42285
+ if (competitors.length < 2 || competitors.length > GAP_COMPETITORS) throw new CommandError("INVALID_INPUT", `link_gap needs a target and 2 to ${GAP_COMPETITORS} competitors.`);
42286
+ const score = provider === "ahrefs" ? "domain_rating" : "rank";
42287
+ const sites = [target, ...competitors];
42288
+ const lists = [];
42289
+ let cost = 0;
42290
+ let partial = false;
42291
+ for (const site of sites) {
42292
+ const got = await research(provider, headers, {
42293
+ ...input,
42294
+ action: "backlinks",
42295
+ target: site,
42296
+ limit: GAP_DOMAINS
42297
+ });
42298
+ cost += got.costUsd ?? 0;
42299
+ partial ||= got.partial;
42300
+ lists.push(got.rows);
42301
+ }
42302
+ const domainOf = (row) => String(row.domain ?? "").toLowerCase().replace(/^www\./, "");
42303
+ const authority = (row) => Number(row[score] ?? 0);
42304
+ const floors = lists.filter((rows) => rows.length >= GAP_DOMAINS).map((rows) => Math.min(...rows.map(authority)));
42305
+ const floor = floors.length ? Math.max(...floors) : null;
42306
+ const own = new Set((lists[0] ?? []).map(domainOf));
42307
+ const found = /* @__PURE__ */ new Map();
42308
+ competitors.forEach((site, index) => {
42309
+ for (const row of lists[index + 1] ?? []) {
42310
+ const domain = domainOf(row);
42311
+ if (!domain || own.has(domain) || domain === target) continue;
42312
+ if (domain.endsWith(`.${target}`)) continue;
42313
+ const entry = found.get(domain) ?? {
42314
+ value: 0,
42315
+ sites: []
42316
+ };
42317
+ entry.value = Math.max(entry.value, authority(row));
42318
+ if (!entry.sites.includes(site)) entry.sites.push(site);
42319
+ found.set(domain, entry);
42320
+ }
42321
+ });
42322
+ return {
42323
+ 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]) => ({
42324
+ domain,
42325
+ [score]: entry.value,
42326
+ competitors_linking: entry.sites.length,
42327
+ links_to: entry.sites.join(" ")
42328
+ })),
42329
+ costUsd: provider === "dataforseo" ? Math.round(cost * 1e4) / 1e4 : null,
42330
+ partial,
42331
+ missed: [],
42332
+ noData: [],
42333
+ ...floor !== null ? { floor: {
42334
+ field: score,
42335
+ value: floor
42336
+ } } : {}
42337
+ };
42338
+ }
42078
42339
  function isPage(target) {
42079
42340
  return /\/./.test(bareTarget(target));
42080
42341
  }
@@ -42165,7 +42426,7 @@ const DIMENSIONS = [
42165
42426
  "device",
42166
42427
  "searchAppearance"
42167
42428
  ];
42168
- const ROWS = 25e3;
42429
+ const ROWS$1 = 25e3;
42169
42430
  async function runRows(input, deps) {
42170
42431
  const dimensions = input.dimensions?.length ? input.dimensions : ["query"];
42171
42432
  const filters = input.filters ?? [];
@@ -42174,7 +42435,7 @@ async function runRows(input, deps) {
42174
42435
  const query = {
42175
42436
  dimensions,
42176
42437
  type: "web",
42177
- maxRows: ROWS,
42438
+ maxRows: ROWS$1,
42178
42439
  ...filters.length ? { dimensionFilterGroups: [{
42179
42440
  groupType: "and",
42180
42441
  filters
@@ -42231,9 +42492,9 @@ async function runRows(input, deps) {
42231
42492
  const sum = (key) => rows.reduce((total, row) => total + Number(row[key] ?? 0), 0);
42232
42493
  notes.push(`Totals: clicks ${sum("clicks")}, impressions ${sum("impressions")}; previous period clicks ${sum("previous_clicks")}.`);
42233
42494
  }
42234
- 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.`);
42495
+ 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.`);
42235
42496
  if (pageAt >= 0) notes.push("Page URLs with #fragments are merged into their page.");
42236
- if (current.rowsFetched >= ROWS) notes.push(`Search Console has more than ${ROWS} rows; add a filter to narrow it.`);
42497
+ if (current.rowsFetched >= ROWS$1) notes.push(`Search Console has more than ${ROWS$1} rows; add a filter to narrow it.`);
42237
42498
  notes.push("Search Console hides some rare queries for privacy, so query rows do not add up to the site totals.");
42238
42499
  const detail = input.detail ?? "summary";
42239
42500
  return {
@@ -42342,6 +42603,22 @@ const hostOf$2 = (url) => {
42342
42603
  return null;
42343
42604
  }
42344
42605
  };
42606
+ const NOT_SOURCES = [
42607
+ "dataforseo.com",
42608
+ "images.openai.com",
42609
+ "gstatic.com",
42610
+ "googleusercontent.com",
42611
+ "ggpht.com",
42612
+ "example.com",
42613
+ "example.org",
42614
+ "example.net"
42615
+ ];
42616
+ const IMAGE = /\.(png|jpe?g|gif|webp|avif|svg)(\?|#|$)/i;
42617
+ const isSource = (url) => {
42618
+ const host = hostOf$2(url);
42619
+ if (!host || IMAGE.test(url)) return false;
42620
+ return !NOT_SOURCES.some((name) => host === name || host.endsWith(`.${name}`));
42621
+ };
42345
42622
  const empty = {
42346
42623
  cited_url: null,
42347
42624
  sources: [],
@@ -42370,7 +42647,7 @@ function judge(engine, task, site) {
42370
42647
  const cited = all.find((url) => onSite(url, site.domain)) ?? null;
42371
42648
  const base = {
42372
42649
  cited_url: cited,
42373
- sources: [...new Set(all.filter((url) => !onSite(url, site.domain)).map(hostOf$2).filter((host) => Boolean(host)))].slice(0, MAX_SOURCES),
42650
+ sources: [...new Set(all.filter((url) => !onSite(url, site.domain) && isSource(url)).map(hostOf$2).filter((host) => Boolean(host)))].slice(0, MAX_SOURCES),
42374
42651
  model: shown.model,
42375
42652
  answered_at
42376
42653
  };
@@ -42955,7 +43232,7 @@ async function totals$1(found, access, range, before, pages) {
42955
43232
  notes
42956
43233
  };
42957
43234
  }
42958
- const change$1 = (value) => value === null ? "n/a" : `${value > 0 ? "+" : ""}${value}%`;
43235
+ const change$2 = (value) => value === null ? "n/a" : `${value > 0 ? "+" : ""}${value}%`;
42959
43236
  function engineText(rows) {
42960
43237
  const shown = rows.filter((row) => row.sessions || row.previousSessions);
42961
43238
  if (shown.length < 2) return "";
@@ -42965,7 +43242,7 @@ function aiText(ai) {
42965
43242
  const list = (rows, key) => rows.slice(0, AI_ROWS).map((row) => `${row[key]} ${row.sessions} (was ${row.previousSessions})`).join(", ");
42966
43243
  if (!ai.sessions && !ai.previousSessions) return "Visits from AI assistants (ChatGPT, Perplexity, Claude, Gemini, Copilot and others): none in either period.";
42967
43244
  return [
42968
- `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")}.`,
43245
+ `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")}.`,
42969
43246
  ai.topLandingPages.length ? `Top landing pages from AI assistants: ${list(ai.topLandingPages, "page")}.` : "",
42970
43247
  "Only visits that pass a referrer or utm_source count; AI apps that send none show as Direct."
42971
43248
  ].filter(Boolean).join(" ");
@@ -43082,7 +43359,7 @@ async function analyticsBeside(access, site, range, before, pages) {
43082
43359
  const keyEvents = organic.keyEvents || organic.previousKeyEvents ? `, Organic Search key events ${organic.keyEvents} (was ${organic.previousKeyEvents})` : "";
43083
43360
  return {
43084
43361
  text: [
43085
- `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)}).`,
43362
+ `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)}).`,
43086
43363
  engineText(data.organicSearchByEngine),
43087
43364
  "Sessions are Google Analytics’ own count, not Search Console clicks.",
43088
43365
  ...data.notes,
@@ -43309,7 +43586,7 @@ function auditPreview(snapshot) {
43309
43586
  }
43310
43587
  const WEEK_MS = 6048e5;
43311
43588
  const day$1 = (ms) => ms === null ? "" : new Date(ms).toISOString().slice(0, 10);
43312
- const count$2 = (value) => value.toLocaleString("en-US");
43589
+ const count$3 = (value) => value.toLocaleString("en-US");
43313
43590
  function relative$2(value, origin) {
43314
43591
  if (!value) return "";
43315
43592
  return origin && value.startsWith(origin) ? value.slice(origin.length) || "/" : value;
@@ -43330,7 +43607,7 @@ function rowsTable(rows, checks) {
43330
43607
  changed(row) ? row.prev_coverage ?? "" : "",
43331
43608
  changed(row) ? day$1(row.changed_at) : "",
43332
43609
  day$1(row.checked_at),
43333
- `${count$2(row.clicks)} (${count$2(row.previous_clicks)})${row.lost ? " lost" : ""}`,
43610
+ `${count$3(row.clicks)} (${count$3(row.previous_clicks)})${row.lost ? " lost" : ""}`,
43334
43611
  row.in_sitemap ? "yes" : "no",
43335
43612
  row.google_canonical && row.google_canonical !== row.url ? relative$2(row.google_canonical, checks.origin) : "",
43336
43613
  row.error ?? ""
@@ -43338,12 +43615,12 @@ function rowsTable(rows, checks) {
43338
43615
  }
43339
43616
  function head(site, checks) {
43340
43617
  const { queued } = checks;
43341
- 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(", ");
43618
+ 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(", ");
43342
43619
  return [
43343
43620
  `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).`,
43344
- 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,
43345
- `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.`,
43346
- 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.",
43621
+ 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,
43622
+ `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.`,
43623
+ 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.",
43347
43624
  quota ? `Checked through ${quota}.` : null,
43348
43625
  checks.stopped ? `Checks stopped early: ${checks.stopped}` : null,
43349
43626
  "A URL without a result has none here, so this is not an all-clear for it."
@@ -43352,9 +43629,9 @@ function head(site, checks) {
43352
43629
  function suggestionsText(checks) {
43353
43630
  if (!checks.suggestions.length) return null;
43354
43631
  return [
43355
- `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:`,
43632
+ `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:`,
43356
43633
  "property to add | sitemap URLs in it | more URLs checked a week",
43357
- ...checks.suggestions.map((item) => `${item.property} | ${count$2(item.urls)} | ${count$2(item.gain)}`),
43634
+ ...checks.suggestions.map((item) => `${item.property} | ${count$3(item.urls)} | ${count$3(item.gain)}`),
43358
43635
  "The next day the checks find a new property in Search Console and use its quota for the URLs in that folder."
43359
43636
  ].join("\n");
43360
43637
  }
@@ -43369,7 +43646,7 @@ function runAuditChecks(input, checks, call) {
43369
43646
  data: checks
43370
43647
  };
43371
43648
  if (!checks.started) return {
43372
- 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.`,
43649
+ 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.`,
43373
43650
  data: checks
43374
43651
  };
43375
43652
  const more = (offset) => input.paged ? `More: ${call("index", {
@@ -43393,7 +43670,7 @@ function runAuditChecks(input, checks, call) {
43393
43670
  const reasons = checks.reasons.length ? [
43394
43671
  "Google's reason at each URL's latest check:",
43395
43672
  "reason | verdict | urls | changed to it this week",
43396
- ...checks.reasons.map((item) => `${item.coverage ?? ""} | ${item.verdict ?? ""} | ${count$2(item.urls)} | ${count$2(item.changed_this_week)}`)
43673
+ ...checks.reasons.map((item) => `${item.coverage ?? ""} | ${item.verdict ?? ""} | ${count$3(item.urls)} | ${count$3(item.changed_this_week)}`)
43397
43674
  ].join("\n") : "No URL has a result yet.";
43398
43675
  const shown = checks.rows.slice(0, 20);
43399
43676
  return {
@@ -43450,7 +43727,7 @@ async function bingFor(deps, property) {
43450
43727
  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.`
43451
43728
  };
43452
43729
  }
43453
- function days(range) {
43730
+ function days$1(range) {
43454
43731
  return Math.round((Date.parse(range.endDate) - Date.parse(range.startDate)) / 864e5) + 1;
43455
43732
  }
43456
43733
  async function bingTotals(source, range, before) {
@@ -43463,7 +43740,7 @@ async function bingTotals(source, range, before) {
43463
43740
  clicks: rows.reduce((total, row) => total + row.clicks, 0),
43464
43741
  impressions: rows.reduce((total, row) => total + row.impressions, 0),
43465
43742
  days: new Set(rows.map((row) => row.date)).size,
43466
- expectedDays: days(window)
43743
+ expectedDays: days$1(window)
43467
43744
  };
43468
43745
  };
43469
43746
  return {
@@ -43473,14 +43750,14 @@ async function bingTotals(source, range, before) {
43473
43750
  invalidRows: traffic.invalidRows
43474
43751
  };
43475
43752
  }
43476
- const change = (now, before) => {
43753
+ const change$1 = (now, before) => {
43477
43754
  const value = pct(now, before);
43478
43755
  return value === null ? "n/a" : `${value > 0 ? "+" : ""}${value}%`;
43479
43756
  };
43480
43757
  function enginesText(google, bing) {
43481
43758
  if ("missing" in bing) return `Bing: ${bing.missing}`;
43482
43759
  const { current: now, previous: then } = bing;
43483
- 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}).`];
43760
+ 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}).`];
43484
43761
  const both = google.clicks + now.clicks;
43485
43762
  if (both > 0) lines.push(`Share of these clicks: Google ${Math.round(google.clicks / both * 100)}%, Bing ${Math.round(now.clicks / both * 100)}%.`);
43486
43763
  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.`);
@@ -43634,7 +43911,7 @@ async function runBingRows(input, deps) {
43634
43911
  }
43635
43912
  };
43636
43913
  }
43637
- function moved(rows, range, before, name, limit) {
43914
+ function moved$1(rows, range, before, name, limit) {
43638
43915
  const now = sumWeeks(rows, range);
43639
43916
  const then = sumWeeks(rows, before);
43640
43917
  const old = new Map(then.rows.map((row) => [row.key, row]));
@@ -43672,8 +43949,8 @@ async function bingOverview(source, range, before) {
43672
43949
  ]);
43673
43950
  if ("missing" in totals) throw new CommandError("NOT_CONNECTED", totals.missing);
43674
43951
  const { current: now, previous: then } = totals;
43675
- const q = moved(queries.rows, range, before, "query", 15);
43676
- const p = moved(pages.rows, range, before, "page", 15);
43952
+ const q = moved$1(queries.rows, range, before, "query", 15);
43953
+ const p = moved$1(pages.rows, range, before, "page", 15);
43677
43954
  return {
43678
43955
  bingSite: source.site,
43679
43956
  range,
@@ -43724,7 +44001,7 @@ function bingOverviewText(report, heading, origin, rowsPerList) {
43724
44001
  };
43725
44002
  return [
43726
44003
  `${heading}, ${range.startDate} to ${range.endDate} against ${before.startDate} to ${before.endDate}.`,
43727
- 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.",
44004
+ 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.",
43728
44005
  group("pages", "page"),
43729
44006
  group("queries", "query"),
43730
44007
  `Limits:\n${report.caveats.map((line) => `- ${line}`).join("\n")}`
@@ -43801,13 +44078,13 @@ function clickyRange(input) {
43801
44078
  endDate: input.endDate
43802
44079
  };
43803
44080
  }
43804
- const count$1 = (item) => {
44081
+ const count$2 = (item) => {
43805
44082
  const value = Number(item.value);
43806
44083
  return Number.isSafeInteger(value) && value >= 0 ? value : null;
43807
44084
  };
43808
44085
  const totals = (items) => items.map((item) => ({
43809
44086
  name: item.title ?? "",
43810
- visits: count$1(item)
44087
+ visits: count$2(item)
43811
44088
  })).filter((item) => Boolean(item.name) && item.visits !== null);
43812
44089
  const line = (items) => {
43813
44090
  const top = items.slice(0, 10).map((item) => `${item.name} ${item.visits}`);
@@ -43838,7 +44115,7 @@ async function runClickyRows(input, keys, options) {
43838
44115
  const entrances = stats["pages-entrance"] ?? [];
43839
44116
  const visitsByPage = /* @__PURE__ */ new Map();
43840
44117
  for (const item of entrances) {
43841
- const visits = count$1(item);
44118
+ const visits = count$2(item);
43842
44119
  if (visits === null || !item.url) continue;
43843
44120
  let page;
43844
44121
  try {
@@ -45167,6 +45444,265 @@ function temporaryRedirectNote(pages) {
45167
45444
  if (!temporary.length) return null;
45168
45445
  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("; ")}.`;
45169
45446
  }
45447
+ // packages/core/dist/commands/updates.js
45448
+ const UPDATES_SECTION = "google-updates";
45449
+ 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.";
45450
+ const MAX_DAYS = 28;
45451
+ const BASELINE = 8;
45452
+ const ROWS = 25e3;
45453
+ const LISTED = 10;
45454
+ const SHOWN = 5;
45455
+ const MIN_CLICKS = 50;
45456
+ const days = (from, to) => Math.round((Date.parse(to) - Date.parse(from)) / 864e5) + 1;
45457
+ const asImpactUpdate = (update) => ({
45458
+ id: update.id,
45459
+ name: update.name,
45460
+ start: update.start,
45461
+ end: update.end ?? null,
45462
+ url: update.sourceUrl
45463
+ });
45464
+ function sum(rows, from, to) {
45465
+ let clicks = 0;
45466
+ let impressions = 0;
45467
+ for (const row of rows) {
45468
+ const day = row.keys[0] ?? "";
45469
+ if (day < from || day > to) continue;
45470
+ clicks += row.clicks;
45471
+ impressions += row.impressions;
45472
+ }
45473
+ return {
45474
+ clicks,
45475
+ impressions
45476
+ };
45477
+ }
45478
+ const change = (now, before) => before > 0 ? (now - before) / before * 100 : null;
45479
+ function joined(before, after) {
45480
+ const byKey = /* @__PURE__ */ new Map();
45481
+ const at = (key) => {
45482
+ let row = byKey.get(key);
45483
+ if (!row) {
45484
+ row = {
45485
+ key,
45486
+ clicks: 0,
45487
+ previous: 0,
45488
+ impressions: 0,
45489
+ previous_impressions: 0
45490
+ };
45491
+ byKey.set(key, row);
45492
+ }
45493
+ return row;
45494
+ };
45495
+ for (const row of before) {
45496
+ const item = at(row.keys[0] ?? "");
45497
+ item.previous += row.clicks;
45498
+ item.previous_impressions += row.impressions;
45499
+ }
45500
+ for (const row of after) {
45501
+ const item = at(row.keys[0] ?? "");
45502
+ item.clicks += row.clicks;
45503
+ item.impressions += row.impressions;
45504
+ }
45505
+ return [...byKey.values()];
45506
+ }
45507
+ const moved = (row) => row.clicks - row.previous;
45508
+ const byKey = (a, b) => a.key < b.key ? -1 : a.key > b.key ? 1 : 0;
45509
+ function extremes(rows) {
45510
+ return {
45511
+ lost: rows.filter((row) => moved(row) < 0).sort((a, b) => moved(a) - moved(b) || byKey(a, b)).slice(0, LISTED),
45512
+ gained: rows.filter((row) => moved(row) > 0).sort((a, b) => moved(b) - moved(a) || byKey(a, b)).slice(0, LISTED)
45513
+ };
45514
+ }
45515
+ function folderOf(url) {
45516
+ try {
45517
+ const parts = new URL(url).pathname.split("/").filter(Boolean);
45518
+ return parts.length > 1 ? `/${parts[0]}/` : "/";
45519
+ } catch {
45520
+ return "/";
45521
+ }
45522
+ }
45523
+ function folders(pages) {
45524
+ const byFolder = /* @__PURE__ */ new Map();
45525
+ for (const page of pages) {
45526
+ const key = folderOf(page.key);
45527
+ const row = byFolder.get(key) ?? {
45528
+ key,
45529
+ clicks: 0,
45530
+ previous: 0,
45531
+ impressions: 0,
45532
+ previous_impressions: 0
45533
+ };
45534
+ row.clicks += page.clicks;
45535
+ row.previous += page.previous;
45536
+ row.impressions += page.impressions;
45537
+ row.previous_impressions += page.previous_impressions;
45538
+ byFolder.set(key, row);
45539
+ }
45540
+ 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);
45541
+ }
45542
+ function windowDays(update, latest) {
45543
+ const start = update.start.slice(0, 10);
45544
+ const end = update.end?.slice(0, 10) ?? null;
45545
+ const wanted = Math.min(MAX_DAYS, Math.max(7, Math.ceil(days(start, end ?? latest) / 7) * 7));
45546
+ const covered = Math.floor(Math.max(0, days(start, latest)) / 7) * 7;
45547
+ return {
45548
+ start,
45549
+ end,
45550
+ wanted,
45551
+ n: Math.min(wanted, covered)
45552
+ };
45553
+ }
45554
+ async function updateImpact(site, update, read, now) {
45555
+ const { start, end, wanted, n } = windowDays(update, latestFinalGscDate(now));
45556
+ const empty = {
45557
+ update,
45558
+ site,
45559
+ before: null,
45560
+ after: null,
45561
+ normal_range: null,
45562
+ verdict: "not-enough-data",
45563
+ capped: {
45564
+ pages: false,
45565
+ queries: false
45566
+ },
45567
+ folders: [],
45568
+ pages: {
45569
+ lost: [],
45570
+ gained: []
45571
+ },
45572
+ queries: {
45573
+ lost: [],
45574
+ gained: []
45575
+ }
45576
+ };
45577
+ if (n < 7) return {
45578
+ ...empty,
45579
+ window: "waiting"
45580
+ };
45581
+ const after = {
45582
+ startDate: start,
45583
+ endDate: shiftIsoDate(start, n - 1)
45584
+ };
45585
+ const before = {
45586
+ startDate: shiftIsoDate(start, -n),
45587
+ endDate: shiftIsoDate(start, -1)
45588
+ };
45589
+ const first = Math.ceil(n / 7);
45590
+ const pairs = Array.from({ length: BASELINE }, (_, i) => {
45591
+ const back = 7 * (first + i);
45592
+ return {
45593
+ after: [shiftIsoDate(start, -back), shiftIsoDate(start, n - 1 - back)],
45594
+ before: [shiftIsoDate(start, -back - n), shiftIsoDate(start, -back - 1)]
45595
+ };
45596
+ });
45597
+ const shared = sharedReader(read);
45598
+ const call = (range, dimension) => shared(site, {
45599
+ ...range,
45600
+ dimensions: [dimension],
45601
+ type: "web",
45602
+ dataState: "final",
45603
+ ...dimension === "date" ? {} : { maxRows: ROWS }
45604
+ });
45605
+ const [daily, pagesBefore, pagesAfter, queriesBefore, queriesAfter] = await Promise.all([
45606
+ call({
45607
+ startDate: pairs.at(-1)?.before[0],
45608
+ endDate: after.endDate
45609
+ }, "date"),
45610
+ call(before, "page"),
45611
+ call(after, "page"),
45612
+ call(before, "query"),
45613
+ call(after, "query")
45614
+ ]);
45615
+ const totals = (range) => ({
45616
+ ...range,
45617
+ ...sum(daily.rows, range.startDate, range.endDate)
45618
+ });
45619
+ const was = totals(before);
45620
+ const is = totals(after);
45621
+ 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);
45622
+ const normal = seen.length ? {
45623
+ comparisons: seen.length,
45624
+ clicks_pct: Math.round(Math.max(...seen.map((v) => Math.abs(v))) * 10) / 10
45625
+ } : null;
45626
+ const moves = change(is.clicks, was.clicks);
45627
+ 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";
45628
+ const pageRows = joined(pagesBefore.rows, pagesAfter.rows);
45629
+ return {
45630
+ ...empty,
45631
+ window: end && n === wanted ? "full" : "partial",
45632
+ before: was,
45633
+ after: is,
45634
+ normal_range: normal,
45635
+ verdict,
45636
+ capped: {
45637
+ pages: pagesBefore.rows.length >= ROWS || pagesAfter.rows.length >= ROWS,
45638
+ queries: queriesBefore.rows.length >= ROWS || queriesAfter.rows.length >= ROWS
45639
+ },
45640
+ folders: folders(pageRows),
45641
+ pages: extremes(pageRows),
45642
+ queries: extremes(joined(queriesBefore.rows, queriesAfter.rows))
45643
+ };
45644
+ }
45645
+ const count$1 = (n) => n.toLocaleString("en-US");
45646
+ const dayLabel$1 = (date) => (/* @__PURE__ */ new Date(`${date}T00:00:00Z`)).toLocaleDateString("en-GB", {
45647
+ day: "numeric",
45648
+ month: "short",
45649
+ year: "numeric",
45650
+ timeZone: "UTC"
45651
+ });
45652
+ function trend(now, before) {
45653
+ const pct = change(now, before);
45654
+ if (pct === null) return `from ${count$1(before)}`;
45655
+ const rounded = Math.round(pct);
45656
+ return rounded === 0 ? `level with ${count$1(before)}` : `${rounded > 0 ? "up" : "down"} ${Math.abs(rounded)}% on ${count$1(before)}`;
45657
+ }
45658
+ function rowText(row, origin) {
45659
+ const name = row.key.startsWith(origin) ? row.key.slice(origin.length) || "/" : row.key;
45660
+ const diff = moved(row);
45661
+ return `${name} ${count$1(row.clicks)} (${diff > 0 ? "+" : ""}${count$1(diff)})`;
45662
+ }
45663
+ function impactLines(impact) {
45664
+ const { update, before, after } = impact;
45665
+ const origin = originOf(impact.site);
45666
+ const dates = `${dayLabel$1(update.start.slice(0, 10))} to ${update.end ? dayLabel$1(update.end.slice(0, 10)) : "now, still rolling out"}`;
45667
+ const lines = [`${update.name} (${dates}). [Google’s notice](${update.url})`];
45668
+ if (!before || !after) return [...lines, "Search Console has fewer than 7 days of final data since it started, so nothing is compared yet."];
45669
+ const n = days(after.startDate, after.endDate);
45670
+ 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)}.`);
45671
+ const normal = impact.normal_range;
45672
+ 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).`);
45673
+ const list = (title, rows) => rows.length ? [`${title}: ${rows.slice(0, SHOWN).map((row) => rowText(row, origin)).join("; ")}.`] : [];
45674
+ 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));
45675
+ const capped = [impact.capped.pages ? "pages" : null, impact.capped.queries ? "queries" : null].filter(Boolean);
45676
+ if (capped.length) lines.push(`Search Console returned the row limit for ${capped.join(" and ")}, so those lists may miss rows.`);
45677
+ lines.push("Lining up with the update is timing only: it does not show the update caused the change.");
45678
+ return lines;
45679
+ }
45680
+ async function runUpdates(input, deps) {
45681
+ const now = (deps.now ?? (() => /* @__PURE__ */ new Date()))();
45682
+ if (input.endDate && !realIsoDate(input.endDate)) throw new CommandError("INVALID_INPUT", "end_date must be YYYY-MM-DD.");
45683
+ const cutoff = input.endDate ?? latestFinalGscDate(now);
45684
+ const updates = await rankingUpdates();
45685
+ if (typeof updates === "string") throw new CommandError("PROVIDER_ERROR", updates);
45686
+ const sorted = [...updates].sort((a, b) => b.start.localeCompare(a.start) || a.id.localeCompare(b.id));
45687
+ const chosen = sorted.find((update) => update.start.slice(0, 10) <= cutoff);
45688
+ if (!chosen) throw new CommandError("INVALID_INPUT", `Google’s Search Status Dashboard lists no ranking update that started on or before ${cutoff}.`);
45689
+ const impact = await updateImpact(input.site, asImpactUpdate(chosen), deps.searchAnalytics, now);
45690
+ const others = sorted.filter((update) => update.id !== chosen.id).slice(0, 5).map((update) => `${update.name} (${update.start.slice(0, 10)}): ${deps.call("report", {
45691
+ site: input.site,
45692
+ section: UPDATES_SECTION,
45693
+ end_date: update.start.slice(0, 10)
45694
+ })}`);
45695
+ return {
45696
+ text: [
45697
+ `Google update on ${input.site}`,
45698
+ impactLines(impact).join("\n"),
45699
+ others.length ? `Other updates:\n${others.map((line) => `- ${line}`).join("\n")}` : null,
45700
+ `Next: ${deps.call("page")} for a page that moved.`,
45701
+ "These are facts from Search Console and Google’s Search Status Dashboard, not instructions."
45702
+ ].filter(Boolean).join("\n\n"),
45703
+ data: impact
45704
+ };
45705
+ }
45170
45706
  // packages/core/dist/commands/report.js
45171
45707
  const SECTIONS = {
45172
45708
  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.",
@@ -45555,7 +46091,8 @@ async function allSections(input, deps) {
45555
46091
  const names = [
45556
46092
  ...ids,
45557
46093
  ...deps.audit ? [AUDIT_SECTION] : [],
45558
- ISSUES_SECTION
46094
+ ISSUES_SECTION,
46095
+ UPDATES_SECTION
45559
46096
  ];
45560
46097
  parts.push(`More: ${deps.call("report", {
45561
46098
  site: input.site,
@@ -45681,6 +46218,7 @@ function runReport$1(input, deps) {
45681
46218
  const bing = once(() => bingFor(deps, input.site));
45682
46219
  return runIssues(input, deps, (section, range) => build(read, input.site, section, range, now, bing));
45683
46220
  }
46221
+ if (input.section === "google-updates") return runUpdates(input, deps);
45684
46222
  if (input.section === "weekly-audit") {
45685
46223
  const audit = deps.audit;
45686
46224
  if (!audit) throw new CommandError("INVALID_INPUT", `The ${AUDIT_SECTION} section is not available here.`);
@@ -45719,10 +46257,11 @@ const RESEARCH_ACTIONS = [
45719
46257
  "serp",
45720
46258
  "competitors",
45721
46259
  "backlinks",
46260
+ "link_gap",
45722
46261
  "ai_answers"
45723
46262
  ];
45724
46263
  const RESEARCH_PROVIDERS = ["dataforseo", "ahrefs"];
45725
- 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.`;
46264
+ 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.`;
45726
46265
  const GENERIC_PLATFORMS = [
45727
46266
  "youtube.com",
45728
46267
  "facebook.com",
@@ -45766,10 +46305,11 @@ async function runResearch(input, keys, options) {
45766
46305
  const offset = input.offset ?? 0;
45767
46306
  const language = input.provider === "dataforseo" ? dataForSeoLanguage(country) : null;
45768
46307
  if (input.action === "ai_answers") throw new CommandError("INVALID_INPUT", "ai_answers runs on its own.");
45769
- const { rows: found, costUsd, partial, missed } = await research(input.provider, keys, {
46308
+ const { rows: found, costUsd, partial, missed, noData, floor } = await research(input.provider, keys, {
45770
46309
  action: input.action,
45771
46310
  keywords: input.keywords ?? [],
45772
46311
  target,
46312
+ competitors: input.competitors ?? [],
45773
46313
  country,
45774
46314
  limit: Math.min(1e3, Math.max(100, offset + 50))
45775
46315
  });
@@ -45777,6 +46317,7 @@ async function runResearch(input, keys, options) {
45777
46317
  rows: found,
45778
46318
  note: null
45779
46319
  };
46320
+ const unresolved = rows.filter((row) => row.url === null && row.url_hint !== void 0).length;
45780
46321
  const drKey = keys.get("x-ahrefs-key") ?? options.drKey;
45781
46322
  const rated = await withDomainRating(rows, drKey);
45782
46323
  const targetDr = target ? (await domainRatings([target], drKey)).get(bareDomain(target)) : void 0;
@@ -45793,6 +46334,9 @@ async function runResearch(input, keys, options) {
45793
46334
  ...costUsd !== null ? [`DataForSEO cost: $${costUsd}.`] : [],
45794
46335
  ...partial ? ["Partial: DataForSEO could not fetch some pages of results, so rows may be missing."] : [],
45795
46336
  ...missed.length ? [`Partial: ${input.provider} failed for ${missed.join(", ")}, so those keywords have no rows. Ask for them again.`] : [],
46337
+ ...noData.length ? [noDataNote(input, noData)] : [],
46338
+ ...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.`] : [],
46339
+ ...unresolved ? [unresolvedNote(input, unresolved, rows.length)] : [],
45796
46340
  ...note ? [note] : [],
45797
46341
  ...hasTargetDr ? [`${bareDomain(target)} domain_rating: ${targetDr}.`] : [],
45798
46342
  ...rated.added || hasTargetDr ? [DR_ATTRIBUTION] : []
@@ -45811,12 +46355,25 @@ async function runResearch(input, keys, options) {
45811
46355
  costUsd,
45812
46356
  partial,
45813
46357
  missedKeywords: missed,
46358
+ noDataKeywords: noData,
46359
+ ...input.action === "link_gap" ? {
46360
+ competitors: input.competitors ?? [],
46361
+ cutAt: floor ?? null
46362
+ } : {},
45814
46363
  total: rated.rows.length,
45815
46364
  offset,
45816
46365
  rows: rated.rows.slice(offset, offset + 50)
45817
46366
  }
45818
46367
  };
45819
46368
  }
46369
+ function noDataNote(input, keywords) {
46370
+ const list = keywords.join(", ");
46371
+ 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" : ""}.`;
46372
+ return `No data: ${input.provider} has no metrics for ${keywords.length} of the keywords asked for (${list}). Their volume is unknown, not zero.`;
46373
+ }
46374
+ function unresolvedNote(input, count, total) {
46375
+ 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." : ""}`;
46376
+ }
45820
46377
  // packages/core/dist/commands/sitemap-urls.js
45821
46378
  const MAX_FILES = 20;
45822
46379
  const MAX_URLS = 5e4;
@@ -46395,6 +46952,23 @@ function siteLines(site, startMs, endMs) {
46395
46952
  } else lines.push("Search clicks: not available this week.");
46396
46953
  return lines;
46397
46954
  }
46955
+ function updateLines(site) {
46956
+ return (site.google_updates ?? []).map((update) => {
46957
+ const when = update.end ? "ran" : "has been rolling out";
46958
+ const result = update.result;
46959
+ 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.`;
46960
+ 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.`;
46961
+ const diff = result.clicks - result.previous;
46962
+ const pct = result.previous > 0 ? Math.round(diff / result.previous * 100) : null;
46963
+ 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)})`;
46964
+ const verdict = {
46965
+ "outside-normal": "more than this site usually moves",
46966
+ "within-normal": "within this site’s normal movement",
46967
+ "not-enough-data": "too little data to say if that is unusual"
46968
+ }[result.verdict];
46969
+ 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).`;
46970
+ });
46971
+ }
46398
46972
  function opportunityLines(site) {
46399
46973
  const found = site.opportunities;
46400
46974
  if (!found) return [];
@@ -46447,7 +47021,7 @@ function weekMarkdown(data) {
46447
47021
  "",
46448
47022
  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."
46449
47023
  ];
46450
- 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})`] : []);
47024
+ 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})`] : []);
46451
47025
  return out.join("\n");
46452
47026
  }
46453
47027
  async function runAllSites(deps, sites) {
@@ -46550,10 +47124,14 @@ const SHARED_TOOLS = [
46550
47124
  name: "seo_report",
46551
47125
  command: "report",
46552
47126
  title: "Search Console report",
46553
- 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.`,
47127
+ 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.`,
46554
47128
  input: z.object({
46555
47129
  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."),
46556
- section: z.enum([...SECTION_IDS, ISSUES_SECTION]).optional().describe("Optional, needs site. Leave it out first; then ask for one section."),
47130
+ section: z.enum([
47131
+ ...SECTION_IDS,
47132
+ ISSUES_SECTION,
47133
+ UPDATES_SECTION
47134
+ ]).optional().describe("Optional, needs site. Leave it out first; then ask for one section."),
46557
47135
  ...dates,
46558
47136
  ...detail,
46559
47137
  ...marks
@@ -46670,6 +47248,7 @@ const SHARED_TOOLS = [
46670
47248
  action: z.enum(RESEARCH_ACTIONS),
46671
47249
  keywords: z.array(z.string().min(1)).max(100).default([]),
46672
47250
  target: z.string().default("").describe("Domain or URL."),
47251
+ competitors: z.array(z.string().min(1)).max(5).default([]).describe("link_gap only: 2 to 5 competitor domains."),
46673
47252
  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."),
46674
47253
  country: z.string().regex(/^[a-z]{2}$/).default("us").describe("Two-letter country code."),
46675
47254
  ...detail
@@ -46816,6 +47395,7 @@ SHARED_TOOLS.map((shared) => shared.name === "seo_index" ? indexFromStore(shared
46816
47395
  input: shared.input.extend({ section: z.enum([
46817
47396
  ...SECTION_IDS,
46818
47397
  ISSUES_SECTION,
47398
+ UPDATES_SECTION,
46819
47399
  AUDIT_SECTION
46820
47400
  ]).optional().describe("Optional. Leave it out first; then ask for one section.") })
46821
47401
  } : shared);