seo 0.2.20 → 0.2.22

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/mcp.js CHANGED
@@ -1,5 +1,5 @@
1
1
  import { createRequire as __seoCreateRequire } from 'node:module'; const require = __seoCreateRequire(import.meta.url);
2
- import { n as version, t as load } from "./esm-DvJj2-vq.js";
2
+ import { n as version, t as load } from "./esm-insIkRuo.js";
3
3
  import { Headers, fetch as fetch$1 } from "undici";
4
4
  import { chmodSync, createReadStream, existsSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs";
5
5
  import { arch, homedir, platform, totalmem } from "node:os";
@@ -1811,10 +1811,10 @@ function aiReferralSourceForValue(value) {
1811
1811
  }
1812
1812
  //#endregion
1813
1813
  //#region packages/core/dist/analyze/ai-referrals-analysis.js
1814
- function compareText$18(left, right) {
1814
+ function compareText$19(left, right) {
1815
1815
  return left < right ? -1 : left > right ? 1 : 0;
1816
1816
  }
1817
- function metric(row, name) {
1817
+ function metric$1(row, name) {
1818
1818
  const value = row[name];
1819
1819
  const parsed = Number(value);
1820
1820
  if (value === void 0 || value.trim() === "" || !Number.isInteger(parsed) || parsed < 0) throw new SeoError("PROVIDER_UNAVAILABLE", `Google Analytics returned an invalid ${name} value.`);
@@ -1849,8 +1849,8 @@ function analyzeAiReferralRows(input) {
1849
1849
  ...emptyMetrics()
1850
1850
  };
1851
1851
  addMetrics(total, {
1852
- sessions: metric(row, "sessions"),
1853
- eventCount: metric(row, "eventCount")
1852
+ sessions: metric$1(row, "sessions"),
1853
+ eventCount: metric$1(row, "eventCount")
1854
1854
  });
1855
1855
  total.observedSessionSources.add(row.sessionSource ?? "");
1856
1856
  sourceTotals.set(definition.id, total);
@@ -1862,8 +1862,8 @@ function analyzeAiReferralRows(input) {
1862
1862
  const definition = aiReferralSourceForValue(row.sessionSource ?? "");
1863
1863
  if (!definition || !sourceTotals.has(definition.id)) throw new SeoError("PROVIDER_UNAVAILABLE", "Google Analytics detail rows contained an unexpected session source.");
1864
1864
  const metrics = {
1865
- sessions: metric(row, "sessions"),
1866
- eventCount: metric(row, "eventCount")
1865
+ sessions: metric$1(row, "sessions"),
1866
+ eventCount: metric$1(row, "eventCount")
1867
1867
  };
1868
1868
  addMetrics(detailTotal, metrics);
1869
1869
  const landingPage = row.landingPagePlusQueryString || "(not set)";
@@ -1900,7 +1900,7 @@ function analyzeAiReferralRows(input) {
1900
1900
  const generalCaveat = "Google Analytics only attributes visits that arrive with a recognized session source; some AI visits appear as direct, unassigned, or another source.";
1901
1901
  const possiblyTruncated = input.sourceDiscovery.truncated || input.detail.truncated || input.totalUsersEvidence.truncated;
1902
1902
  const allLandingPages = [...pageTotals.entries()].map(([landingPage, metrics]) => {
1903
- const topSourceId = [...metrics.sources.entries()].sort((left, right) => right[1] - left[1] || compareText$18(left[0], right[0]))[0]?.[0];
1903
+ const topSourceId = [...metrics.sources.entries()].sort((left, right) => right[1] - left[1] || compareText$19(left[0], right[0]))[0]?.[0];
1904
1904
  const topSource = topSourceId ? sourceTotals.get(topSourceId)?.definition : void 0;
1905
1905
  if (!topSource) throw new SeoError("PROVIDER_UNAVAILABLE", "Could not resolve the top AI referral source.");
1906
1906
  return {
@@ -1914,7 +1914,7 @@ function analyzeAiReferralRows(input) {
1914
1914
  label: topSource.label
1915
1915
  }
1916
1916
  };
1917
- }).sort((left, right) => right.sessions - left.sessions || compareText$18(left.landingPage, right.landingPage));
1917
+ }).sort((left, right) => right.sessions - left.sessions || compareText$19(left.landingPage, right.landingPage));
1918
1918
  const landingPages = allLandingPages.slice(0, input.resultLimit);
1919
1919
  const selection = {
1920
1920
  limit: input.resultLimit,
@@ -1959,19 +1959,19 @@ function analyzeAiReferralRows(input) {
1959
1959
  id: source.definition.id,
1960
1960
  label: source.definition.label,
1961
1961
  source: source.definition.label,
1962
- observedSessionSources: [...source.observedSessionSources].sort(compareText$18),
1962
+ observedSessionSources: [...source.observedSessionSources].sort(compareText$19),
1963
1963
  sessions: source.sessions,
1964
1964
  eventCount: source.eventCount,
1965
1965
  totalUsers: null,
1966
1966
  shareOfAiSessions: total.sessions ? source.sessions / total.sessions : 0,
1967
1967
  share: total.sessions ? source.sessions / total.sessions : 0
1968
- })).sort((left, right) => right.sessions - left.sessions || compareText$18(left.id, right.id)),
1968
+ })).sort((left, right) => right.sessions - left.sessions || compareText$19(left.id, right.id)),
1969
1969
  landingPages,
1970
1970
  daily: [...dailyTotals.entries()].map(([date, metrics]) => ({
1971
1971
  date,
1972
1972
  ...metrics,
1973
1973
  totalUsers: null
1974
- })).sort((left, right) => compareText$18(left.date, right.date)),
1974
+ })).sort((left, right) => compareText$19(left.date, right.date)),
1975
1975
  caveats: [
1976
1976
  generalCaveat,
1977
1977
  "Attribution uses Google Analytics sessionSource only; landing-page text, medium text, and event referrers are never treated as referral sources.",
@@ -2061,7 +2061,7 @@ function sessionSourceInListFilter(values) {
2061
2061
  //#endregion
2062
2062
  //#region packages/core/dist/analyze/ai-referrals.js
2063
2063
  const DEFAULT_MAX_ROWS$1 = 1e5;
2064
- const MAX_ROWS = 1e5;
2064
+ const MAX_ROWS$1 = 1e5;
2065
2065
  const DEFAULT_RESULT_LIMIT = 25;
2066
2066
  const MAX_RESULT_LIMIT = 1e3;
2067
2067
  function validIsoDate$1(value) {
@@ -2087,7 +2087,7 @@ function reportRange(input) {
2087
2087
  function maxRows(input) {
2088
2088
  if (input.maxRows !== void 0 && input.limit !== void 0 && input.maxRows !== input.limit) throw new SeoError("INVALID_INPUT", "Use maxRows or the legacy limit option, not both.");
2089
2089
  const value = input.maxRows ?? input.limit ?? DEFAULT_MAX_ROWS$1;
2090
- if (!Number.isInteger(value) || value < 1 || value > MAX_ROWS) throw new SeoError("INVALID_INPUT", `maxRows must be a whole number between 1 and ${MAX_ROWS}.`);
2090
+ if (!Number.isInteger(value) || value < 1 || value > MAX_ROWS$1) throw new SeoError("INVALID_INPUT", `maxRows must be a whole number between 1 and ${MAX_ROWS$1}.`);
2091
2091
  return value;
2092
2092
  }
2093
2093
  function resultLimit(input) {
@@ -5323,7 +5323,7 @@ function estimateSerpTitleWidth(title) {
5323
5323
  }
5324
5324
  //#endregion
5325
5325
  //#region packages/core/dist/analyze/audit-page.js
5326
- const defaultDependencies$15 = {
5326
+ const defaultDependencies$16 = {
5327
5327
  fetchPage,
5328
5328
  queryPageMetrics,
5329
5329
  now: () => /* @__PURE__ */ new Date()
@@ -5354,7 +5354,7 @@ function buildRecommendations(report) {
5354
5354
  });
5355
5355
  return recommendations;
5356
5356
  }
5357
- async function auditPage(input, dependencies = defaultDependencies$15) {
5357
+ async function auditPage(input, dependencies = defaultDependencies$16) {
5358
5358
  const fetched = await dependencies.fetchPage(input.url, {
5359
5359
  js: input.js,
5360
5360
  refresh: input.refresh
@@ -5496,7 +5496,7 @@ function tokenStem(token) {
5496
5496
  function tokensMatch(a, b) {
5497
5497
  return a === b || tokenStem(a) === tokenStem(b);
5498
5498
  }
5499
- function includesTokenSequence(queryTokens, termTokens) {
5499
+ function includesTokenSequence$1(queryTokens, termTokens) {
5500
5500
  if (!termTokens.length || termTokens.length > queryTokens.length) return false;
5501
5501
  for (let index = 0; index <= queryTokens.length - termTokens.length; index++) if (termTokens.every((termToken, offset) => tokensMatch(queryTokens[index + offset] ?? "", termToken))) return true;
5502
5502
  return false;
@@ -5525,7 +5525,7 @@ function isBrandQuery(query, brandTerms) {
5525
5525
  const compactMatches = (compactTermLength >= 6 || /[^\p{ASCII}]/u.test(compactTerm) && compactTermLength >= 2) && compactQuery.includes(compactTerm);
5526
5526
  const termTokens = normalizedTerm.split(" ");
5527
5527
  if (termTokens.length === 1) return queryTokens.some((queryToken) => tokensMatch(queryToken, normalizedTerm)) || compactMatches;
5528
- return includesTokenSequence(queryTokens, termTokens) || compactMatches;
5528
+ return includesTokenSequence$1(queryTokens, termTokens) || compactMatches;
5529
5529
  });
5530
5530
  }
5531
5531
  function shouldExcludeBrandQuery(input) {
@@ -5771,7 +5771,7 @@ function resolveQueryOpportunityInput(input, now) {
5771
5771
  }
5772
5772
  //#endregion
5773
5773
  //#region packages/core/dist/analyze/query-opportunity-rows.js
5774
- function compareText$17(left, right) {
5774
+ function compareText$18(left, right) {
5775
5775
  const leftPoints = [...left].map((value) => value.codePointAt(0) ?? 0);
5776
5776
  const rightPoints = [...right].map((value) => value.codePointAt(0) ?? 0);
5777
5777
  for (let index = 0; index < Math.min(leftPoints.length, rightPoints.length); index++) {
@@ -5781,9 +5781,9 @@ function compareText$17(left, right) {
5781
5781
  return leftPoints.length - rightPoints.length;
5782
5782
  }
5783
5783
  function compareQueryOpportunityRows(left, right) {
5784
- return right.impressions - left.impressions || right.clicks - left.clicks || left.position - right.position || compareText$17(left.query, right.query);
5784
+ return right.impressions - left.impressions || right.clicks - left.clicks || left.position - right.position || compareText$18(left.query, right.query);
5785
5785
  }
5786
- function validRow$3(row, query) {
5786
+ function validRow$4(row, query) {
5787
5787
  return row.keys.length === 1 && Boolean(query) && Number.isFinite(row.clicks) && Number.isFinite(row.impressions) && Number.isFinite(row.ctr) && Number.isFinite(row.position) && row.clicks >= 0 && row.impressions > 0 && row.clicks <= row.impressions && row.ctr >= 0 && row.ctr <= 1 && row.position > 0;
5788
5788
  }
5789
5789
  function signature(row) {
@@ -5793,7 +5793,7 @@ function normalizeQueryOpportunityRows(rows, selection) {
5793
5793
  const grouped = /* @__PURE__ */ new Map();
5794
5794
  for (const row of rows) {
5795
5795
  const query = row.keys[0]?.trim() ?? "";
5796
- if (!validRow$3(row, query)) {
5796
+ if (!validRow$4(row, query)) {
5797
5797
  selection.invalidRows++;
5798
5798
  continue;
5799
5799
  }
@@ -6762,11 +6762,11 @@ function selectBenchmarkRows$1(input) {
6762
6762
  }
6763
6763
  //#endregion
6764
6764
  //#region packages/core/dist/analyze/page-opportunities-analysis.js
6765
- function compareText$16(left, right) {
6765
+ function compareText$17(left, right) {
6766
6766
  return left < right ? -1 : left > right ? 1 : 0;
6767
6767
  }
6768
6768
  function compareItems$2(left, right) {
6769
- return (right.estimatedCtrClickShortfall ?? -1) - (left.estimatedCtrClickShortfall ?? -1) || right.impressions - left.impressions || left.position - right.position || compareText$16(left.query, right.query) || compareText$16(left.url, right.url);
6769
+ return (right.estimatedCtrClickShortfall ?? -1) - (left.estimatedCtrClickShortfall ?? -1) || right.impressions - left.impressions || left.position - right.position || compareText$17(left.query, right.query) || compareText$17(left.url, right.url);
6770
6770
  }
6771
6771
  function analyzeRow(input) {
6772
6772
  const query = input.row.keys[0]?.trim() ?? "";
@@ -6928,7 +6928,7 @@ function pageOpportunityRecommendations(analysis) {
6928
6928
  //#endregion
6929
6929
  //#region packages/core/dist/analyze/page-opportunities.js
6930
6930
  const MAX_BENCHMARK_ROWS = 1e5;
6931
- const defaultDependencies$14 = {
6931
+ const defaultDependencies$15 = {
6932
6932
  searchAnalytics: querySearchAnalytics,
6933
6933
  fetch: fetchPage,
6934
6934
  extract: extractPage,
@@ -7000,7 +7000,7 @@ function reportCaveats$1(input) {
7000
7000
  input.benchmarkTruncated ? `The site-wide benchmark reached its ${MAX_BENCHMARK_ROWS.toLocaleString("en-US")}-row cap, so provenance is complete but the peer sample is truncated.` : ""
7001
7001
  ].filter(Boolean);
7002
7002
  }
7003
- async function pageOpportunitiesReport(input, dependencies = defaultDependencies$14) {
7003
+ async function pageOpportunitiesReport(input, dependencies = defaultDependencies$15) {
7004
7004
  const url = assertUrlMatchesGscProperty(input.site, input.url);
7005
7005
  const days = reportDays$1(input.days);
7006
7006
  const range = defaultDateRange$1(days);
@@ -10787,7 +10787,7 @@ function absoluteHttpUrl(value) {
10787
10787
  return false;
10788
10788
  }
10789
10789
  }
10790
- function urlKey(value) {
10790
+ function urlKey$1(value) {
10791
10791
  if (!value) return void 0;
10792
10792
  try {
10793
10793
  const url = new URL(value);
@@ -10986,7 +10986,7 @@ function auditCrawlPages(pages, opts = {}) {
10986
10986
  for (const page of pages) {
10987
10987
  if (!isAuditableHtmlPage(page)) continue;
10988
10988
  for (const value of [page.url, page.finalUrl]) {
10989
- const key = urlKey(value);
10989
+ const key = urlKey$1(value);
10990
10990
  if (key) pageByUrl.set(key, page);
10991
10991
  }
10992
10992
  }
@@ -11128,7 +11128,7 @@ function auditCrawlPages(pages, opts = {}) {
11128
11128
  canonicalRaw: page.canonicalRaw,
11129
11129
  canonical: page.canonical
11130
11130
  }));
11131
- const canonicalTarget = pageByUrl.get(urlKey(page.canonical) ?? "");
11131
+ const canonicalTarget = pageByUrl.get(urlKey$1(page.canonical) ?? "");
11132
11132
  if (canonicalTarget?.canonical && !sameUrl(canonicalTarget.canonical, page.canonical)) issues.push(issue("canonical_chain", page, page.canonical, {
11133
11133
  canonical: page.canonical,
11134
11134
  nextCanonical: canonicalTarget.canonical,
@@ -11302,7 +11302,7 @@ const SENSITIVE_FIELD_NAMES = /* @__PURE__ */ new Set([
11302
11302
  "refresh-token"
11303
11303
  ]);
11304
11304
  const SENSITIVE_PARAM_PATTERN = /^(access_?token|auth|api_?key|client_?secret|code|jwt|key|password|refresh_?token|secret|session|sig|signature|token)$/i;
11305
- function uniqueSorted(values = []) {
11305
+ function uniqueSorted$1(values = []) {
11306
11306
  return [...new Set(values.filter(Boolean))].sort();
11307
11307
  }
11308
11308
  function normalizeFetchRate(input, strategy) {
@@ -11322,13 +11322,13 @@ function normalizeCrawlConfig(input) {
11322
11322
  mode: input.mode ?? (strategy === "health" ? "sitemap" : "site"),
11323
11323
  strategy,
11324
11324
  ...input.sitemapUrl ? { sitemapUrl: new URL(input.sitemapUrl).toString() } : {},
11325
- urls: uniqueSorted(input.urls ?? []),
11325
+ urls: uniqueSorted$1(input.urls ?? []),
11326
11326
  maxPages: input.maxPages ?? 500,
11327
11327
  maxDepth: input.maxDepth ?? 16,
11328
11328
  concurrency,
11329
11329
  timeoutMs: input.timeoutMs ?? 2e4,
11330
- include: uniqueSorted(input.include ?? []),
11331
- exclude: uniqueSorted(input.exclude ?? []),
11330
+ include: uniqueSorted$1(input.include ?? []),
11331
+ exclude: uniqueSorted$1(input.exclude ?? []),
11332
11332
  respectRobots: input.respectRobots ?? true,
11333
11333
  useSitemap: input.useSitemap ?? true,
11334
11334
  checkExternal: strategy === "health" ? false : input.checkExternal ?? true,
@@ -11452,19 +11452,19 @@ function sanitizeIssues(issues) {
11452
11452
  function sanitizeMessages(values = []) {
11453
11453
  return values.map((value) => sanitizeTenantString(value));
11454
11454
  }
11455
- function compareText$15(left, right) {
11455
+ function compareText$16(left, right) {
11456
11456
  return left < right ? -1 : left > right ? 1 : 0;
11457
11457
  }
11458
11458
  function sortPages(pages) {
11459
- return [...pages].sort((left, right) => compareText$15(left.url, right.url));
11459
+ return [...pages].sort((left, right) => compareText$16(left.url, right.url));
11460
11460
  }
11461
11461
  function sortRequests(requests) {
11462
11462
  return [...requests].sort((left, right) => {
11463
- const byUrl = compareText$15(left.requestedUrl, right.requestedUrl);
11463
+ const byUrl = compareText$16(left.requestedUrl, right.requestedUrl);
11464
11464
  if (byUrl) return byUrl;
11465
- const byOutcome = compareText$15(left.outcome, right.outcome);
11465
+ const byOutcome = compareText$16(left.outcome, right.outcome);
11466
11466
  if (byOutcome) return byOutcome;
11467
- return compareText$15(left.outcome === "response" ? `${left.status}:${left.finalUrl}` : left.outcome === "skipped" ? left.reason : `${left.failureKind}:${left.error}`, right.outcome === "response" ? `${right.status}:${right.finalUrl}` : right.outcome === "skipped" ? right.reason : `${right.failureKind}:${right.error}`);
11467
+ return compareText$16(left.outcome === "response" ? `${left.status}:${left.finalUrl}` : left.outcome === "skipped" ? left.reason : `${left.failureKind}:${left.error}`, right.outcome === "response" ? `${right.status}:${right.finalUrl}` : right.outcome === "skipped" ? right.reason : `${right.failureKind}:${right.error}`);
11468
11468
  });
11469
11469
  }
11470
11470
  function summarizeCrawlReport(input) {
@@ -11686,8 +11686,8 @@ function createCrawlReport(input) {
11686
11686
  ...input.externalLinkVerification ? { externalLinkVerification: sanitizeTenantValue(input.externalLinkVerification) } : {},
11687
11687
  ...input.siteChecks ? { siteChecks: sanitizeTenantValue(input.siteChecks) } : {},
11688
11688
  ...input.agentDiscovery ? { agentDiscovery: sanitizeTenantValue(input.agentDiscovery) } : {},
11689
- warnings: sanitizeMessages(input.warnings).sort(compareText$15),
11690
- caveats: sanitizeMessages(input.caveats).sort(compareText$15)
11689
+ warnings: sanitizeMessages(input.warnings).sort(compareText$16),
11690
+ caveats: sanitizeMessages(input.caveats).sort(compareText$16)
11691
11691
  };
11692
11692
  }
11693
11693
  function normalizeLoadedCrawlReport(report) {
@@ -11717,8 +11717,8 @@ function normalizeLoadedCrawlReport(report) {
11717
11717
  pages,
11718
11718
  issues,
11719
11719
  issueGroups: groupCrawlIssues(issues),
11720
- warnings: sanitizeMessages(report.warnings).sort(compareText$15),
11721
- caveats: sanitizeMessages(report.caveats).sort(compareText$15)
11720
+ warnings: sanitizeMessages(report.warnings).sort(compareText$16),
11721
+ caveats: sanitizeMessages(report.caveats).sort(compareText$16)
11722
11722
  };
11723
11723
  }
11724
11724
  function toMeta(row) {
@@ -13573,7 +13573,7 @@ async function fetchSitemapUrls(input) {
13573
13573
  }
13574
13574
  //#endregion
13575
13575
  //#region packages/core/dist/analyze/workflows/analytics-value.js
13576
- const defaultDependencies$13 = { runGa4Report };
13576
+ const defaultDependencies$14 = { runGa4Report };
13577
13577
  function normalizePath(value) {
13578
13578
  if (!value || value === "(not set)") return "";
13579
13579
  const [path = ""] = value.split("?");
@@ -13586,12 +13586,12 @@ function urlPath(url) {
13586
13586
  return "";
13587
13587
  }
13588
13588
  }
13589
- function compareText$14(left, right) {
13589
+ function compareText$15(left, right) {
13590
13590
  return left < right ? -1 : left > right ? 1 : 0;
13591
13591
  }
13592
13592
  function landingPageValuesFromRows(rows) {
13593
13593
  const orderedRows = [...rows].sort((left, right) => {
13594
- return compareText$14(normalizePath(left.landingPagePlusQueryString ?? ""), normalizePath(right.landingPagePlusQueryString ?? "")) || compareText$14(left.landingPagePlusQueryString ?? "", right.landingPagePlusQueryString ?? "") || compareText$14(left.sessions ?? "", right.sessions ?? "") || compareText$14(left.totalUsers ?? "", right.totalUsers ?? "") || compareText$14(left.conversions ?? "", right.conversions ?? "");
13594
+ return compareText$15(normalizePath(left.landingPagePlusQueryString ?? ""), normalizePath(right.landingPagePlusQueryString ?? "")) || compareText$15(left.landingPagePlusQueryString ?? "", right.landingPagePlusQueryString ?? "") || compareText$15(left.sessions ?? "", right.sessions ?? "") || compareText$15(left.totalUsers ?? "", right.totalUsers ?? "") || compareText$15(left.conversions ?? "", right.conversions ?? "");
13595
13595
  });
13596
13596
  const values = /* @__PURE__ */ new Map();
13597
13597
  for (const row of orderedRows) {
@@ -13633,7 +13633,7 @@ function landingPageRankingPolicy(input) {
13633
13633
  warnings
13634
13634
  };
13635
13635
  }
13636
- async function fetchLandingPageValues(input, dependencies = defaultDependencies$13) {
13636
+ async function fetchLandingPageValues(input, dependencies = defaultDependencies$14) {
13637
13637
  const retainedRowLimit = input.limit ?? 5e3;
13638
13638
  if (!input.propertyId) return {
13639
13639
  values: /* @__PURE__ */ new Map(),
@@ -14865,7 +14865,7 @@ function countLabel(count, singular, pluralLabel = `${singular}s`) {
14865
14865
  return `${count.toLocaleString("en-GB")} ${plural$4(count, singular, pluralLabel)}`;
14866
14866
  }
14867
14867
  const OKF_MAX_CONCEPTS = 5e3;
14868
- function compareText$13(left, right) {
14868
+ function compareText$14(left, right) {
14869
14869
  const leftPoints = [...left].map((value) => value.codePointAt(0) ?? 0);
14870
14870
  const rightPoints = [...right].map((value) => value.codePointAt(0) ?? 0);
14871
14871
  for (let index = 0; index < Math.min(leftPoints.length, rightPoints.length); index++) {
@@ -14955,7 +14955,7 @@ function okfTitle(value, report) {
14955
14955
  }
14956
14956
  function selectedOkfPages(report, requestedLimit) {
14957
14957
  const limit = okfConceptLimit(requestedLimit);
14958
- const eligible = report.pages.filter((page) => page.indexable && page.status >= 200 && page.status < 300 && !page.blocked && !page.error).sort((left, right) => (right.searchMetrics?.clicks ?? 0) - (left.searchMetrics?.clicks ?? 0) || (right.searchMetrics?.impressions ?? 0) - (left.searchMetrics?.impressions ?? 0) || (right.internalLinkAuthorityScore ?? 0) - (left.internalLinkAuthorityScore ?? 0) || (right.internalInlinkCount ?? 0) - (left.internalInlinkCount ?? 0) || compareText$13(left.finalUrl, right.finalUrl));
14958
+ const eligible = report.pages.filter((page) => page.indexable && page.status >= 200 && page.status < 300 && !page.blocked && !page.error).sort((left, right) => (right.searchMetrics?.clicks ?? 0) - (left.searchMetrics?.clicks ?? 0) || (right.searchMetrics?.impressions ?? 0) - (left.searchMetrics?.impressions ?? 0) || (right.internalLinkAuthorityScore ?? 0) - (left.internalLinkAuthorityScore ?? 0) || (right.internalInlinkCount ?? 0) - (left.internalInlinkCount ?? 0) || compareText$14(left.finalUrl, right.finalUrl));
14959
14959
  const unique = /* @__PURE__ */ new Map();
14960
14960
  for (const page of eligible) if (!unique.has(page.finalUrl)) unique.set(page.finalUrl, page);
14961
14961
  const uniquePages = [...unique.values()];
@@ -16433,7 +16433,7 @@ const crawlReportSchema = z.discriminatedUnion("requestEvidenceStatus", [
16433
16433
  ]);
16434
16434
  z.toJSONSchema(crawlReportSchema), z.toJSONSchema(crawlIssueGroupSchema), z.toJSONSchema(crawlTopFixSchema), z.toJSONSchema(crawlerRuleInfoSchema), z.toJSONSchema(crawlRequestObservationSchema), z.toJSONSchema(crawlPageSnapshotSchema);
16435
16435
  const CTR_MAX_MIN_IMPRESSIONS = 1e9;
16436
- function compareText$12(left, right) {
16436
+ function compareText$13(left, right) {
16437
16437
  const leftPoints = [...left].map((value) => value.codePointAt(0) ?? 0);
16438
16438
  const rightPoints = [...right].map((value) => value.codePointAt(0) ?? 0);
16439
16439
  for (let index = 0; index < Math.min(leftPoints.length, rightPoints.length); index++) {
@@ -16442,7 +16442,7 @@ function compareText$12(left, right) {
16442
16442
  }
16443
16443
  return leftPoints.length - rightPoints.length;
16444
16444
  }
16445
- function boundedInteger$6(value, fallback, minimum, maximum) {
16445
+ function boundedInteger$7(value, fallback, minimum, maximum) {
16446
16446
  if (value === void 0 || !Number.isFinite(value)) return fallback;
16447
16447
  return Math.min(maximum, Math.max(minimum, Math.floor(value)));
16448
16448
  }
@@ -16481,7 +16481,7 @@ function aggregateCtrRows(rows, selection) {
16481
16481
  weightedPosition: 0,
16482
16482
  sourceRows: 0
16483
16483
  };
16484
- if (compareText$12(query, group.query) < 0) group.query = query;
16484
+ if (compareText$13(query, group.query) < 0) group.query = query;
16485
16485
  group.clicks += row.clicks;
16486
16486
  group.impressions += row.impressions;
16487
16487
  group.weightedPosition += row.position * row.impressions;
@@ -16519,8 +16519,8 @@ function initialSelection(sourceRows) {
16519
16519
  };
16520
16520
  }
16521
16521
  function analyzeCtrUnderperformersFromRows(input) {
16522
- const minImpressions = boundedInteger$6(input.minImpressions, 200, 1, CTR_MAX_MIN_IMPRESSIONS);
16523
- const limit = boundedInteger$6(input.limit, 25, 1, 100);
16522
+ const minImpressions = boundedInteger$7(input.minImpressions, 200, 1, CTR_MAX_MIN_IMPRESSIONS);
16523
+ const limit = boundedInteger$7(input.limit, 25, 1, 100);
16524
16524
  const selection = initialSelection(input.rows.length);
16525
16525
  const benchmarkRows = aggregateCtrRows(input.rows, selection).filter((row) => {
16526
16526
  const query = row.keys[0] ?? "";
@@ -16585,7 +16585,7 @@ function analyzeCtrUnderperformersFromRows(input) {
16585
16585
  confidence: "low"
16586
16586
  }
16587
16587
  };
16588
- }).filter((item) => item.actualCtr < item.expectedCtr * .6).sort((left, right) => right.clickShortfall - left.clickShortfall || compareText$12(left.query, right.query) || compareText$12(left.url, right.url));
16588
+ }).filter((item) => item.actualCtr < item.expectedCtr * .6).sort((left, right) => right.clickShortfall - left.clickShortfall || compareText$13(left.query, right.query) || compareText$13(left.url, right.url));
16589
16589
  const items = eligibleItems.slice(0, limit);
16590
16590
  selection.eligibleUnderperformers = eligibleItems.length;
16591
16591
  selection.returnedUnderperformers = items.length;
@@ -16601,15 +16601,15 @@ function analyzeCtrUnderperformersFromRows(input) {
16601
16601
  }
16602
16602
  //#endregion
16603
16603
  //#region packages/core/dist/analyze/ctr-underperformers.js
16604
- const MAX_GSC_ROWS$4 = 1e5;
16604
+ const MAX_GSC_ROWS$5 = 1e5;
16605
16605
  function plural$3(count, singular, pluralLabel = `${singular}s`) {
16606
16606
  return count === 1 ? singular : pluralLabel;
16607
16607
  }
16608
- const defaultDependencies$12 = {
16608
+ const defaultDependencies$13 = {
16609
16609
  searchAnalytics: querySearchAnalytics,
16610
16610
  now: () => /* @__PURE__ */ new Date()
16611
16611
  };
16612
- async function ctrUnderperformersReport(input, dependencies = defaultDependencies$12) {
16612
+ async function ctrUnderperformersReport(input, dependencies = defaultDependencies$13) {
16613
16613
  const minImpressions = integerOption$2({
16614
16614
  value: input.minImpressions,
16615
16615
  fallback: 200,
@@ -16631,7 +16631,7 @@ async function ctrUnderperformersReport(input, dependencies = defaultDependencie
16631
16631
  dimensions: ["query", "page"],
16632
16632
  type: "web",
16633
16633
  dataState: "final",
16634
- maxRows: MAX_GSC_ROWS$4
16634
+ maxRows: MAX_GSC_ROWS$5
16635
16635
  }, { refresh: input.refresh });
16636
16636
  const { items, totalClickShortfall, returnedClickShortfall, selection } = analyzeCtrUnderperformersFromRows({
16637
16637
  rows: source.rows,
@@ -16642,7 +16642,7 @@ async function ctrUnderperformersReport(input, dependencies = defaultDependencie
16642
16642
  includeBrand: input.includeBrand
16643
16643
  });
16644
16644
  const top = items[0];
16645
- const possiblyTruncated = source.rowsFetched >= MAX_GSC_ROWS$4;
16645
+ const possiblyTruncated = source.rowsFetched >= MAX_GSC_ROWS$5;
16646
16646
  const partialValidation = selection.invalidRows > 0;
16647
16647
  const dataStatus = selection.sourceRows === 0 ? "empty" : possiblyTruncated || partialValidation ? "partial" : selection.eligibleUnderperformers === 0 ? "filtered" : "complete";
16648
16648
  const completeness = possiblyTruncated && partialValidation ? "partial-and-possibly-truncated" : possiblyTruncated ? "possibly-truncated" : partialValidation ? "partial" : "complete";
@@ -16659,7 +16659,7 @@ async function ctrUnderperformersReport(input, dependencies = defaultDependencie
16659
16659
  dataState: "final",
16660
16660
  rowsFetched: source.rowsFetched,
16661
16661
  calls: source.calls,
16662
- maxRows: MAX_GSC_ROWS$4,
16662
+ maxRows: MAX_GSC_ROWS$5,
16663
16663
  possiblyTruncated,
16664
16664
  completeness,
16665
16665
  validation: {
@@ -16689,7 +16689,7 @@ async function ctrUnderperformersReport(input, dependencies = defaultDependencie
16689
16689
  "CTR benchmarks and calculated click shortfalls are directional heuristics, not forecasts or proof that clicks are available. Validate the search intent and live SERP before editing.",
16690
16690
  ...selection.invalidRows ? [`${selection.invalidRows} invalid provider ${plural$3(selection.invalidRows, "row")} ${selection.invalidRows === 1 ? "was" : "were"} excluded, so this report is partial.`] : [],
16691
16691
  ...selection.duplicateRows ? [`${selection.duplicateRows} repeated query/page ${plural$3(selection.duplicateRows, "row")} ${selection.duplicateRows === 1 ? "was" : "were"} aggregated before CTR and average position were calculated.`] : [],
16692
- ...possiblyTruncated ? [`The retained-row limit of ${MAX_GSC_ROWS$4} was reached, so lower-ranked source rows may be absent.`] : [],
16692
+ ...possiblyTruncated ? [`The retained-row limit of ${MAX_GSC_ROWS$5} was reached, so lower-ranked source rows may be absent.`] : [],
16693
16693
  ...selection.limitedUnderperformers ? [`${selection.limitedUnderperformers} eligible ${plural$3(selection.limitedUnderperformers, "underperformer")} ${selection.limitedUnderperformers === 1 ? "was" : "were"} omitted by the output limit.`] : []
16694
16694
  ],
16695
16695
  recommendations: top ? [
@@ -16816,7 +16816,7 @@ function diagnosisDataStatus(input) {
16816
16816
  function fixed$1(value) {
16817
16817
  return Number(value.toFixed(3));
16818
16818
  }
16819
- function compareText$11(left, right) {
16819
+ function compareText$12(left, right) {
16820
16820
  return left < right ? -1 : left > right ? 1 : 0;
16821
16821
  }
16822
16822
  function validMetric$2(value) {
@@ -16896,8 +16896,8 @@ function compareRetainedSegmentRows(input) {
16896
16896
  else unmatched.push(unmatchedRow(row, "before"));
16897
16897
  }
16898
16898
  for (const [key, row] of after.rows) if (!before.rows.has(key)) unmatched.push(unmatchedRow(row, "after"));
16899
- matched.sort((left, right) => Math.abs(right.clickDelta) - Math.abs(left.clickDelta) || compareText$11(left.key, right.key));
16900
- unmatched.sort((left, right) => right.clicks - left.clicks || compareText$11(left.retainedIn, right.retainedIn) || compareText$11(left.key, right.key));
16899
+ matched.sort((left, right) => Math.abs(right.clickDelta) - Math.abs(left.clickDelta) || compareText$12(left.key, right.key));
16900
+ unmatched.sort((left, right) => right.clicks - left.clicks || compareText$12(left.retainedIn, right.retainedIn) || compareText$12(left.key, right.key));
16901
16901
  return {
16902
16902
  before,
16903
16903
  after,
@@ -17202,11 +17202,11 @@ function unavailableSegmentImpactReport(input) {
17202
17202
  //#endregion
17203
17203
  //#region packages/core/dist/analyze/segment-impact.js
17204
17204
  const DEFAULT_MAX_ROWS = 1e5;
17205
- const defaultDependencies$11 = {
17205
+ const defaultDependencies$12 = {
17206
17206
  searchAnalytics: querySearchAnalytics,
17207
17207
  now: () => /* @__PURE__ */ new Date()
17208
17208
  };
17209
- async function segmentImpact(input, dependencies = defaultDependencies$11) {
17209
+ async function segmentImpact(input, dependencies = defaultDependencies$12) {
17210
17210
  validateSegmentSite(input.site);
17211
17211
  const now = dependencies.now();
17212
17212
  const ranges = resolveSegmentRanges({
@@ -17607,13 +17607,13 @@ const MINIMUM_PAGE_SHARE = .1;
17607
17607
  const MINIMUM_PAGE_IMPRESSIONS = 10;
17608
17608
  const MAXIMUM_DOMINANT_SHARE = .8;
17609
17609
  const MAX_SUPPRESSIONS = 100;
17610
- function boundedInteger$5(value, fallback, minimum, maximum) {
17610
+ function boundedInteger$6(value, fallback, minimum, maximum) {
17611
17611
  if (value === void 0 || !Number.isFinite(value)) return fallback;
17612
17612
  return Math.min(maximum, Math.max(minimum, Math.floor(value)));
17613
17613
  }
17614
17614
  function analyzeCannibalRows(input) {
17615
- const minImpressions = boundedInteger$5(input.minImpressions, 50, 0, 1e9);
17616
- const limit = boundedInteger$5(input.limit, 25, 1, 100);
17615
+ const minImpressions = boundedInteger$6(input.minImpressions, 50, 0, 1e9);
17616
+ const limit = boundedInteger$6(input.limit, 25, 1, 100);
17617
17617
  const selection = {
17618
17618
  sourceRows: input.rows.length,
17619
17619
  invalidRows: 0,
@@ -17759,8 +17759,8 @@ function analyzeCannibalRows(input) {
17759
17759
  }
17760
17760
  //#endregion
17761
17761
  //#region packages/core/dist/analyze/site-diagnostics/cannibal.js
17762
- const MAX_GSC_ROWS$3 = 1e5;
17763
- const defaultDependencies$10 = {
17762
+ const MAX_GSC_ROWS$4 = 1e5;
17763
+ const defaultDependencies$11 = {
17764
17764
  searchAnalytics: querySearchAnalytics,
17765
17765
  now: () => /* @__PURE__ */ new Date()
17766
17766
  };
@@ -17773,7 +17773,7 @@ function verdict$4(input) {
17773
17773
  const result = `${input.returned} of ${input.eligible} multi-URL query candidate${input.eligible === 1 ? "" : "s"} returned for intent and technical review.`;
17774
17774
  return input.status === "partial" ? `${result} Provider evidence is partial.` : result;
17775
17775
  }
17776
- async function cannibalReport(input, dependencies = defaultDependencies$10) {
17776
+ async function cannibalReport(input, dependencies = defaultDependencies$11) {
17777
17777
  const days = integerOption$2({
17778
17778
  value: input.days,
17779
17779
  fallback: 28,
@@ -17803,7 +17803,7 @@ async function cannibalReport(input, dependencies = defaultDependencies$10) {
17803
17803
  ...range,
17804
17804
  type: "web",
17805
17805
  dataState: "final",
17806
- maxRows: MAX_GSC_ROWS$3
17806
+ maxRows: MAX_GSC_ROWS$4
17807
17807
  };
17808
17808
  const [pageExposure, propertyDemand] = await Promise.all([dependencies.searchAnalytics(input.site, {
17809
17809
  ...requestBase,
@@ -17826,8 +17826,8 @@ async function cannibalReport(input, dependencies = defaultDependencies$10) {
17826
17826
  brandTerms: input.brandTerms,
17827
17827
  includeBrand: input.includeBrand
17828
17828
  });
17829
- const pageTruncated = pageExposure.rowsFetched >= MAX_GSC_ROWS$3;
17830
- const propertyTruncated = propertyDemand.rowsFetched >= MAX_GSC_ROWS$3;
17829
+ const pageTruncated = pageExposure.rowsFetched >= MAX_GSC_ROWS$4;
17830
+ const propertyTruncated = propertyDemand.rowsFetched >= MAX_GSC_ROWS$4;
17831
17831
  const possiblyTruncated = pageTruncated || propertyTruncated;
17832
17832
  const partialValidation = analysis.selection.invalidRows + analysis.selection.propertyInvalidRows > 0;
17833
17833
  const completeness = possiblyTruncated && partialValidation ? "partial-and-possibly-truncated" : possiblyTruncated ? "possibly-truncated" : partialValidation ? "partial" : "complete";
@@ -17852,7 +17852,7 @@ async function cannibalReport(input, dependencies = defaultDependencies$10) {
17852
17852
  invalidRows: analysis.selection.invalidRows
17853
17853
  },
17854
17854
  calls: pageExposure.calls,
17855
- maxRows: MAX_GSC_ROWS$3,
17855
+ maxRows: MAX_GSC_ROWS$4,
17856
17856
  possiblyTruncated: pageTruncated
17857
17857
  },
17858
17858
  propertyDemand: {
@@ -17864,7 +17864,7 @@ async function cannibalReport(input, dependencies = defaultDependencies$10) {
17864
17864
  invalidRows: analysis.selection.propertyInvalidRows
17865
17865
  },
17866
17866
  calls: propertyDemand.calls,
17867
- maxRows: MAX_GSC_ROWS$3,
17867
+ maxRows: MAX_GSC_ROWS$4,
17868
17868
  possiblyTruncated: propertyTruncated
17869
17869
  },
17870
17870
  completeness
@@ -17937,7 +17937,7 @@ function normalizeUrl(value) {
17937
17937
  return;
17938
17938
  }
17939
17939
  }
17940
- function validRow$2(row, query, url) {
17940
+ function validRow$3(row, query, url) {
17941
17941
  return Boolean(query && url) && Number.isFinite(row.clicks) && row.clicks >= 0 && Number.isFinite(row.impressions) && row.impressions > 0 && row.clicks <= row.impressions && Number.isFinite(row.ctr) && row.ctr >= 0 && row.ctr <= 1 && Number.isFinite(row.position) && row.position > 0;
17942
17942
  }
17943
17943
  function aggregateDecayRows(rows) {
@@ -17946,7 +17946,7 @@ function aggregateDecayRows(rows) {
17946
17946
  for (const row of rows) {
17947
17947
  const query = row.keys[0]?.trim() ?? "";
17948
17948
  const url = normalizeUrl(row.keys[1]?.trim() ?? "");
17949
- if (!validRow$2(row, query, url)) {
17949
+ if (!validRow$3(row, query, url)) {
17950
17950
  invalid++;
17951
17951
  continue;
17952
17952
  }
@@ -18172,8 +18172,8 @@ function analyzeDecay(input) {
18172
18172
  }
18173
18173
  //#endregion
18174
18174
  //#region packages/core/dist/analyze/site-diagnostics/decaying.js
18175
- const MAX_GSC_ROWS$2 = 1e5;
18176
- const defaultDependencies$9 = {
18175
+ const MAX_GSC_ROWS$3 = 1e5;
18176
+ const defaultDependencies$10 = {
18177
18177
  searchAnalytics: querySearchAnalytics,
18178
18178
  now: () => /* @__PURE__ */ new Date()
18179
18179
  };
@@ -18184,7 +18184,7 @@ function verdict$3(report) {
18184
18184
  if (!report.selection.eligibleRows) return "No observed retained query/page declines matched the report filters.";
18185
18185
  return `${report.selection.returnedRows} of ${report.selection.eligibleRows} observed retained query/page declines returned for review.`;
18186
18186
  }
18187
- async function decayingReport(input, dependencies = defaultDependencies$9) {
18187
+ async function decayingReport(input, dependencies = defaultDependencies$10) {
18188
18188
  if (input.comparison !== void 0 && input.comparison !== "previous-period" && input.comparison !== "year-over-year") throw new SeoError("INVALID_INPUT", "comparison must be previous-period or year-over-year.");
18189
18189
  if (input.brandTerms && (input.brandTerms.length > 20 || input.brandTerms.some((term) => !term.trim() || term.trim().length > 200))) throw new SeoError("INVALID_INPUT", "brandTerms must contain at most 20 non-empty terms of 200 characters or fewer.");
18190
18190
  const days = integerOption$2({
@@ -18237,7 +18237,7 @@ async function decayingReport(input, dependencies = defaultDependencies$9) {
18237
18237
  aggregationType: "auto",
18238
18238
  type: "web",
18239
18239
  dataState: "final",
18240
- maxRows: MAX_GSC_ROWS$2
18240
+ maxRows: MAX_GSC_ROWS$3
18241
18241
  };
18242
18242
  const [current, previous] = await Promise.all([dependencies.searchAnalytics(input.site, {
18243
18243
  ...request,
@@ -18260,8 +18260,8 @@ async function decayingReport(input, dependencies = defaultDependencies$9) {
18260
18260
  brandTerms: input.brandTerms,
18261
18261
  includeBrand: input.includeBrand
18262
18262
  });
18263
- const currentTruncated = current.rowsFetched >= MAX_GSC_ROWS$2;
18264
- const previousTruncated = previous.rowsFetched >= MAX_GSC_ROWS$2;
18263
+ const currentTruncated = current.rowsFetched >= MAX_GSC_ROWS$3;
18264
+ const previousTruncated = previous.rowsFetched >= MAX_GSC_ROWS$3;
18265
18265
  const possiblyTruncated = currentTruncated || previousTruncated;
18266
18266
  const dataStatus = current.rowsFetched === 0 && previous.rowsFetched === 0 ? "empty" : current.rowsFetched === 0 || previous.rowsFetched === 0 || possiblyTruncated || analysis.selection.currentInvalidRows > 0 || analysis.selection.previousInvalidRows > 0 || analysis.selection.currentRowNotRetained > 0 ? "partial" : analysis.selection.eligibleRows === 0 ? "filtered" : "complete";
18267
18267
  const reportBase = {
@@ -18288,13 +18288,13 @@ async function decayingReport(input, dependencies = defaultDependencies$9) {
18288
18288
  current: {
18289
18289
  rowsFetched: current.rowsFetched,
18290
18290
  calls: current.calls,
18291
- maxRows: MAX_GSC_ROWS$2,
18291
+ maxRows: MAX_GSC_ROWS$3,
18292
18292
  possiblyTruncated: currentTruncated
18293
18293
  },
18294
18294
  previous: {
18295
18295
  rowsFetched: previous.rowsFetched,
18296
18296
  calls: previous.calls,
18297
- maxRows: MAX_GSC_ROWS$2,
18297
+ maxRows: MAX_GSC_ROWS$3,
18298
18298
  possiblyTruncated: previousTruncated
18299
18299
  },
18300
18300
  completeness: possiblyTruncated ? "possibly-truncated" : "retained-query-rows-only"
@@ -18353,7 +18353,7 @@ function normalizedQuery(value) {
18353
18353
  function groupKey$1(item) {
18354
18354
  return `${item.template.id}:${normalizedQuery(item.query)}`;
18355
18355
  }
18356
- function compareText$10(left, right) {
18356
+ function compareText$11(left, right) {
18357
18357
  return left < right ? -1 : left > right ? 1 : 0;
18358
18358
  }
18359
18359
  function groupAction(input) {
@@ -18389,11 +18389,11 @@ function groupQuickWins(items) {
18389
18389
  technicalUrls
18390
18390
  });
18391
18391
  return group;
18392
- }).filter((group) => group !== void 0).sort((left, right) => right.totalEstimatedCtrClickShortfall - left.totalEstimatedCtrClickShortfall || right.totalImpressions - left.totalImpressions || compareText$10(left.id, right.id));
18392
+ }).filter((group) => group !== void 0).sort((left, right) => right.totalEstimatedCtrClickShortfall - left.totalEstimatedCtrClickShortfall || right.totalImpressions - left.totalImpressions || compareText$11(left.id, right.id));
18393
18393
  }
18394
18394
  //#endregion
18395
18395
  //#region packages/core/dist/analyze/site-diagnostics/quick-wins-analysis-primitives.js
18396
- function boundedInteger$4(value, fallback, minimum, maximum) {
18396
+ function boundedInteger$5(value, fallback, minimum, maximum) {
18397
18397
  if (value === void 0 || !Number.isFinite(value)) return fallback;
18398
18398
  return Math.min(maximum, Math.max(minimum, Math.floor(value)));
18399
18399
  }
@@ -18405,7 +18405,7 @@ function validHttpUrl(value) {
18405
18405
  return false;
18406
18406
  }
18407
18407
  }
18408
- function validRow$1(row, query, url) {
18408
+ function validRow$2(row, query, url) {
18409
18409
  return row.keys.length >= 2 && Boolean(query) && validHttpUrl(url) && Number.isFinite(row.clicks) && row.clicks >= 0 && Number.isFinite(row.impressions) && row.impressions > 0 && row.clicks <= row.impressions && Number.isFinite(row.ctr) && row.ctr >= 0 && row.ctr <= 1 && Number.isFinite(row.position) && row.position >= 1;
18410
18410
  }
18411
18411
  function selectBenchmarkRows(input) {
@@ -18427,7 +18427,7 @@ function selectBenchmarkRows(input) {
18427
18427
  for (const row of input.rows) {
18428
18428
  const query = row.keys[0]?.trim() ?? "";
18429
18429
  const url = row.keys[1]?.trim() ?? "";
18430
- if (!validRow$1(row, query, url)) selection.invalidRows++;
18430
+ if (!validRow$2(row, query, url)) selection.invalidRows++;
18431
18431
  else if (row.position > 10) selection.outsideBenchmarkPositionRows++;
18432
18432
  else if (isLowActionabilityQuery(query)) selection.lowActionabilityRows++;
18433
18433
  else if (shouldExcludeBrandQuery({
@@ -18501,11 +18501,11 @@ function quickWinItem(input) {
18501
18501
  }
18502
18502
  };
18503
18503
  }
18504
- function compareText$9(left, right) {
18504
+ function compareText$10(left, right) {
18505
18505
  return left < right ? -1 : left > right ? 1 : 0;
18506
18506
  }
18507
18507
  function compareQuickWinItems(left, right) {
18508
- return right.estimatedCtrClickShortfall - left.estimatedCtrClickShortfall || right.impressions - left.impressions || compareText$9(left.query, right.query) || compareText$9(left.url, right.url);
18508
+ return right.estimatedCtrClickShortfall - left.estimatedCtrClickShortfall || right.impressions - left.impressions || compareText$10(left.query, right.query) || compareText$10(left.url, right.url);
18509
18509
  }
18510
18510
  function roundedSum(values) {
18511
18511
  return Number(values.reduce((sum, value) => sum + value, 0).toFixed(2));
@@ -18518,8 +18518,8 @@ const DEFAULT_LIMIT$3 = 25;
18518
18518
  const MAX_LIMIT$3 = 100;
18519
18519
  const FALLBACK_SOURCE = "builtin_position_ctr_curve_v1";
18520
18520
  function analyzeQuickWinsFromRows(input) {
18521
- const minImpressions = boundedInteger$4(input.minImpressions, DEFAULT_MIN_IMPRESSIONS$2, 0, MAX_MIN_IMPRESSIONS$2);
18522
- const limit = boundedInteger$4(input.limit, DEFAULT_LIMIT$3, 1, MAX_LIMIT$3);
18521
+ const minImpressions = boundedInteger$5(input.minImpressions, DEFAULT_MIN_IMPRESSIONS$2, 0, MAX_MIN_IMPRESSIONS$2);
18522
+ const limit = boundedInteger$5(input.limit, DEFAULT_LIMIT$3, 1, MAX_LIMIT$3);
18523
18523
  const selected = selectBenchmarkRows(input);
18524
18524
  const benchmarkContext = createCtrBenchmarkContext(selected.rows, {
18525
18525
  samplePopulation: "all_qualified_url_samples",
@@ -18620,7 +18620,7 @@ function analyzeQuickWinsFromRows(input) {
18620
18620
  }
18621
18621
  //#endregion
18622
18622
  //#region packages/core/dist/analyze/site-diagnostics/quick-wins-templates.js
18623
- function compareText$8(left, right) {
18623
+ function compareText$9(left, right) {
18624
18624
  return left < right ? -1 : left > right ? 1 : 0;
18625
18625
  }
18626
18626
  function unique$2(values) {
@@ -18646,7 +18646,7 @@ function quickWinTemplateRecommendations(items) {
18646
18646
  action: technicalUrls > 0 ? `Fix verified technical issues on ${technicalUrls} affected URLs before testing shared template changes.` : `Compare live results and verified page evidence for ${urls.length} affected URLs before testing a shared title, snippet, or heading change.`,
18647
18647
  evidence: `${templateItems.length} eligible rows across ${urls.length} distinct URLs share this recognised template. Example queries: ${queries.join("; ")}.`
18648
18648
  };
18649
- }).filter((item) => item !== void 0).sort((left, right) => right.totalEstimatedCtrClickShortfall - left.totalEstimatedCtrClickShortfall || right.totalImpressions - left.totalImpressions || compareText$8(left.templateId, right.templateId));
18649
+ }).filter((item) => item !== void 0).sort((left, right) => right.totalEstimatedCtrClickShortfall - left.totalEstimatedCtrClickShortfall || right.totalImpressions - left.totalImpressions || compareText$9(left.templateId, right.templateId));
18650
18650
  }
18651
18651
  function quickWinTemplateSummaries(items) {
18652
18652
  const templates = /* @__PURE__ */ new Map();
@@ -18665,17 +18665,17 @@ function quickWinTemplateSummaries(items) {
18665
18665
  label: item.label,
18666
18666
  rowCount: item.rows,
18667
18667
  urlCount: item.urls.size,
18668
- sampleUrls: [...item.urls].sort(compareText$8).slice(0, 3)
18669
- })).sort((left, right) => right.urlCount - left.urlCount || right.rowCount - left.rowCount || compareText$8(left.id, right.id)).slice(0, 5);
18668
+ sampleUrls: [...item.urls].sort(compareText$9).slice(0, 3)
18669
+ })).sort((left, right) => right.urlCount - left.urlCount || right.rowCount - left.rowCount || compareText$9(left.id, right.id)).slice(0, 5);
18670
18670
  }
18671
18671
  //#endregion
18672
18672
  //#region packages/core/dist/analyze/site-diagnostics/quick-wins.js
18673
- const DEFAULT_DAYS$2 = 28;
18674
- const MAX_DAYS$2 = 548;
18673
+ const DEFAULT_DAYS$3 = 28;
18674
+ const MAX_DAYS$4 = 548;
18675
18675
  const DEFAULT_VERIFY_LIMIT$1 = 5;
18676
18676
  const MAX_VERIFY_LIMIT = 100;
18677
18677
  const MAX_SOURCE_ROWS$3 = 1e5;
18678
- const defaultDependencies$8 = {
18678
+ const defaultDependencies$9 = {
18679
18679
  searchAnalytics: querySearchAnalytics,
18680
18680
  fetch: fetchPage,
18681
18681
  extract: extractPage,
@@ -18694,13 +18694,13 @@ function applyVerification$1(item, coverage) {
18694
18694
  evidenceRef: `${item.recommendation.evidenceRef} ${coverage.summary}`
18695
18695
  };
18696
18696
  }
18697
- async function quickWinsReport(input, dependencies = defaultDependencies$8) {
18698
- const explicitRange = explicitDateRange(input, MAX_DAYS$2);
18697
+ async function quickWinsReport(input, dependencies = defaultDependencies$9) {
18698
+ const explicitRange = explicitDateRange(input, MAX_DAYS$4);
18699
18699
  const days = explicitRange?.days ?? integerOption$2({
18700
18700
  value: input.days,
18701
- fallback: DEFAULT_DAYS$2,
18701
+ fallback: DEFAULT_DAYS$3,
18702
18702
  minimum: 1,
18703
- maximum: MAX_DAYS$2,
18703
+ maximum: MAX_DAYS$4,
18704
18704
  label: "Days"
18705
18705
  });
18706
18706
  const verifyLimit = integerOption$2({
@@ -18850,7 +18850,7 @@ async function quickWinsReport(input, dependencies = defaultDependencies$8) {
18850
18850
  }
18851
18851
  //#endregion
18852
18852
  //#region packages/core/dist/analyze/striking-distance-analysis-primitives.js
18853
- function compareText$7(left, right) {
18853
+ function compareText$8(left, right) {
18854
18854
  const leftPoints = [...left].map((character) => character.codePointAt(0) ?? 0);
18855
18855
  const rightPoints = [...right].map((character) => character.codePointAt(0) ?? 0);
18856
18856
  const length = Math.min(leftPoints.length, rightPoints.length);
@@ -18916,7 +18916,7 @@ function strikingDistanceItem(row) {
18916
18916
  };
18917
18917
  }
18918
18918
  function compareStrikingDistanceItems(left, right) {
18919
- return right.priority.score - left.priority.score || right.impressions - left.impressions || left.position - right.position || compareText$7(left.query, right.query) || compareText$7(left.url, right.url);
18919
+ return right.priority.score - left.priority.score || right.impressions - left.impressions || left.position - right.position || compareText$8(left.query, right.query) || compareText$8(left.url, right.url);
18920
18920
  }
18921
18921
  function groupStrikingDistanceItems(items) {
18922
18922
  const grouped = /* @__PURE__ */ new Map();
@@ -18953,7 +18953,7 @@ function groupStrikingDistanceItems(items) {
18953
18953
  action: shared ? `Review the sampled ${first.template.label.toLowerCase()} URLs for a recurring technical, content, or internal-link pattern before changing the shared template.` : `Review these URLs individually; the current evidence does not support a shared-template change.`
18954
18954
  }
18955
18955
  };
18956
- }).filter((group) => group !== void 0).sort((left, right) => right.totalImpressions - left.totalImpressions || right.uniqueUrls - left.uniqueUrls || compareText$7(left.id, right.id));
18956
+ }).filter((group) => group !== void 0).sort((left, right) => right.totalImpressions - left.totalImpressions || right.uniqueUrls - left.uniqueUrls || compareText$8(left.id, right.id));
18957
18957
  }
18958
18958
  //#endregion
18959
18959
  //#region packages/core/dist/analyze/striking-distance-analysis.js
@@ -19067,22 +19067,22 @@ function analyzeStrikingDistanceRows(input) {
19067
19067
  }
19068
19068
  //#endregion
19069
19069
  //#region packages/core/dist/analyze/striking-distance.js
19070
- const DEFAULT_DAYS$1 = 28;
19071
- const MAX_DAYS$1 = 548;
19070
+ const DEFAULT_DAYS$2 = 28;
19071
+ const MAX_DAYS$3 = 548;
19072
19072
  const DEFAULT_VERIFY_LIMIT = 5;
19073
19073
  const MAX_SOURCE_ROWS$2 = 1e5;
19074
- const defaultDependencies$7 = {
19074
+ const defaultDependencies$8 = {
19075
19075
  searchAnalytics: querySearchAnalytics,
19076
19076
  verifyContent: verifyQueryContent,
19077
19077
  now: () => /* @__PURE__ */ new Date()
19078
19078
  };
19079
- function boundedInteger$3(value, fallback, minimum, maximum) {
19079
+ function boundedInteger$4(value, fallback, minimum, maximum) {
19080
19080
  if (value === void 0 || !Number.isFinite(value)) return fallback;
19081
19081
  return Math.min(maximum, Math.max(minimum, Math.floor(value)));
19082
19082
  }
19083
19083
  function reportDays(value) {
19084
- if (value === void 0) return DEFAULT_DAYS$1;
19085
- 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}.`);
19084
+ if (value === void 0) return DEFAULT_DAYS$2;
19085
+ if (!Number.isInteger(value) || value < 1 || value > MAX_DAYS$3) throw new SeoError("INVALID_INPUT", `Days must be a whole number between 1 and ${MAX_DAYS$3}.`);
19086
19086
  return value;
19087
19087
  }
19088
19088
  function dateRange(days, now) {
@@ -19121,7 +19121,7 @@ function verdict$2(analysis) {
19121
19121
  if (analysis.dataStatus === "filtered") return "No retained query/page rows met the report filters.";
19122
19122
  return `${countLabel(analysis.summary.eligibleRows, "eligible query/page row")} found; ${countLabel(analysis.summary.returnedRows, "row")} returned for review.`;
19123
19123
  }
19124
- async function strikingDistance(input, dependencies = defaultDependencies$7) {
19124
+ async function strikingDistance(input, dependencies = defaultDependencies$8) {
19125
19125
  const days = reportDays(input.days);
19126
19126
  const generatedAt = dependencies.now().toISOString();
19127
19127
  const range = dateRange(days, new Date(generatedAt));
@@ -19140,7 +19140,7 @@ async function strikingDistance(input, dependencies = defaultDependencies$7) {
19140
19140
  brandTerms: input.brandTerms,
19141
19141
  includeBrand: input.includeBrand
19142
19142
  });
19143
- const verifyLimit = boundedInteger$3(input.verifyLimit, DEFAULT_VERIFY_LIMIT, 0, analysis.limit);
19143
+ const verifyLimit = boundedInteger$4(input.verifyLimit, DEFAULT_VERIFY_LIMIT, 0, analysis.limit);
19144
19144
  const verificationRequested = input.verifyContent === true || input.verifyContent !== false && input.verifyLimit !== void 0;
19145
19145
  const attempted = verificationRequested ? Math.min(verifyLimit, analysis.items.length) : 0;
19146
19146
  const items = await Promise.all(analysis.items.map(async (item, index) => {
@@ -19968,7 +19968,7 @@ function emptyQuickWins(input) {
19968
19968
  warnings: []
19969
19969
  };
19970
19970
  }
19971
- const defaultDependencies$6 = {
19971
+ const defaultDependencies$7 = {
19972
19972
  trafficAnomaly,
19973
19973
  updateCorrelation,
19974
19974
  segmentImpact,
@@ -19979,7 +19979,7 @@ const defaultDependencies$6 = {
19979
19979
  };
19980
19980
  async function diagnoseProperty(input, dependencies = {}) {
19981
19981
  const providers = {
19982
- ...defaultDependencies$6,
19982
+ ...defaultDependencies$7,
19983
19983
  ...dependencies
19984
19984
  };
19985
19985
  const limit = input.limit ?? 10;
@@ -20843,6 +20843,42 @@ async function providerRequestJson(input) {
20843
20843
  return parsed.data;
20844
20844
  }
20845
20845
  //#endregion
20846
+ //#region packages/core/dist/providers/dataforseo/account-pricing.js
20847
+ function unitPrice(components) {
20848
+ const prices = components ?? [];
20849
+ const total = (type) => {
20850
+ const matching = prices.filter((item) => item.cost_type === type);
20851
+ return matching.length ? matching.reduce((sum, item) => sum + Math.round(item.cost * 1e6), 0) : prices.length ? 0 : null;
20852
+ };
20853
+ return {
20854
+ perRequestMicros: total("per_request"),
20855
+ perResultMicros: total("per_result")
20856
+ };
20857
+ }
20858
+ function dataForSeoAccountPrices(account) {
20859
+ return {
20860
+ keywordOverviewPrice: unitPrice(account.price?.dataforseo_labs?.keyword_overview?.live?.priority_normal),
20861
+ keywordDiscoveryPrices: {
20862
+ ideas: unitPrice(account.price?.dataforseo_labs?.keyword_ideas?.live?.priority_normal),
20863
+ related: unitPrice(account.price?.dataforseo_labs?.related_keywords?.live?.priority_normal),
20864
+ suggestions: unitPrice(account.price?.dataforseo_labs?.keyword_suggestions?.live?.priority_normal)
20865
+ },
20866
+ domainResearchPrices: {
20867
+ domainOverview: unitPrice(account.price?.dataforseo_labs?.domain_rank_overview?.live?.priority_normal),
20868
+ rankedKeywords: unitPrice(account.price?.dataforseo_labs?.ranked_keywords?.live?.priority_normal),
20869
+ rankingPages: unitPrice(account.price?.dataforseo_labs?.relevant_pages?.live?.priority_normal),
20870
+ serpCompetitors: unitPrice(account.price?.dataforseo_labs?.serp_competitors?.live?.priority_normal)
20871
+ },
20872
+ linkPrices: {
20873
+ summary: unitPrice(account.price?.backlinks?.summary?.live?.priority_normal),
20874
+ backlinks: unitPrice(account.price?.backlinks?.backlinks?.live?.priority_normal),
20875
+ referringDomains: unitPrice(account.price?.backlinks?.referring_domains?.live?.priority_normal)
20876
+ },
20877
+ serpLiveAdvancedPrice: unitPrice(account.price?.serp?.live?.advanced?.priority_normal),
20878
+ serpTaskPostPrice: unitPrice(account.price?.serp?.task_post?.priority_normal)
20879
+ };
20880
+ }
20881
+ //#endregion
20846
20882
  //#region packages/core/dist/providers/dataforseo/account-schema.js
20847
20883
  const userDataMoneySchema = z.object({
20848
20884
  total: z.number().finite().nonnegative().optional(),
@@ -20860,6 +20896,11 @@ const priceComponentSchema = z.object({
20860
20896
  const normalPriorityPriceSchema = z.object({ priority_normal: z.array(priceComponentSchema).max(20).optional() }).passthrough();
20861
20897
  const livePriceSchema = z.object({ live: normalPriorityPriceSchema.optional() }).passthrough();
20862
20898
  const userDataPriceSchema = z.object({
20899
+ backlinks: z.object({
20900
+ summary: livePriceSchema.optional(),
20901
+ backlinks: livePriceSchema.optional(),
20902
+ referring_domains: livePriceSchema.optional()
20903
+ }).passthrough().optional(),
20863
20904
  dataforseo_labs: z.object({
20864
20905
  keyword_overview: livePriceSchema.optional(),
20865
20906
  keyword_ideas: livePriceSchema.optional(),
@@ -20917,7 +20958,7 @@ function discoveryNextCursor(response) {
20917
20958
  //#region packages/core/dist/providers/dataforseo/schema.js
20918
20959
  const MAX_PROVIDER_MONTHLY_SEARCH_ROWS = 120;
20919
20960
  const nullableNumber$1 = z.number().finite().nullable().optional();
20920
- const nullableCount$1 = z.union([z.number().finite(), z.string().trim().min(1)]).nullable().optional();
20961
+ const nullableCount$2 = z.union([z.number().finite(), z.string().trim().min(1)]).nullable().optional();
20921
20962
  const monthlySearchSchema$1 = z.object({
20922
20963
  year: z.number().int(),
20923
20964
  month: z.number().int(),
@@ -20933,7 +20974,7 @@ const keywordOverviewItemSchema = z.object({
20933
20974
  last_updated_time: z.string().trim().min(1).nullable().optional()
20934
20975
  }).nullish(),
20935
20976
  keyword_properties: z.object({ keyword_difficulty: nullableNumber$1 }).nullish(),
20936
- serp_info: z.object({ se_results_count: nullableCount$1 }).nullish(),
20977
+ serp_info: z.object({ se_results_count: nullableCount$2 }).nullish(),
20937
20978
  search_intent_info: z.object({ main_intent: z.enum([
20938
20979
  "informational",
20939
20980
  "navigational",
@@ -21006,37 +21047,37 @@ const dataForSeoDiscoveryResponseSchema = z.object({
21006
21047
  //#region packages/core/dist/providers/dataforseo/domain-schema.js
21007
21048
  const nullableNumber = z.number().finite().nullable().optional();
21008
21049
  const nullableNonnegativeNumber = z.number().finite().nonnegative().nullable().optional();
21009
- const nullableCount = z.number().int().nonnegative().nullable().optional();
21050
+ const nullableCount$1 = z.number().int().nonnegative().nullable().optional();
21010
21051
  const rankingMetricsSchema = z.object({
21011
- pos_1: nullableCount,
21012
- pos_2_3: nullableCount,
21013
- pos_4_10: nullableCount,
21014
- pos_11_20: nullableCount,
21015
- pos_21_30: nullableCount,
21016
- pos_31_40: nullableCount,
21017
- pos_41_50: nullableCount,
21018
- pos_51_60: nullableCount,
21019
- pos_61_70: nullableCount,
21020
- pos_71_80: nullableCount,
21021
- pos_81_90: nullableCount,
21022
- pos_91_100: nullableCount,
21052
+ pos_1: nullableCount$1,
21053
+ pos_2_3: nullableCount$1,
21054
+ pos_4_10: nullableCount$1,
21055
+ pos_11_20: nullableCount$1,
21056
+ pos_21_30: nullableCount$1,
21057
+ pos_31_40: nullableCount$1,
21058
+ pos_41_50: nullableCount$1,
21059
+ pos_51_60: nullableCount$1,
21060
+ pos_61_70: nullableCount$1,
21061
+ pos_71_80: nullableCount$1,
21062
+ pos_81_90: nullableCount$1,
21063
+ pos_91_100: nullableCount$1,
21023
21064
  etv: nullableNonnegativeNumber,
21024
- count: nullableCount,
21065
+ count: nullableCount$1,
21025
21066
  estimated_paid_traffic_cost: nullableNonnegativeNumber,
21026
- is_new: nullableCount,
21027
- is_up: nullableCount,
21028
- is_down: nullableCount,
21029
- is_lost: nullableCount
21067
+ is_new: nullableCount$1,
21068
+ is_up: nullableCount$1,
21069
+ is_down: nullableCount$1,
21070
+ is_lost: nullableCount$1
21030
21071
  }).passthrough();
21031
21072
  const metricsSchema = z.object({ organic: rankingMetricsSchema.nullable().optional() }).passthrough();
21032
- const paidResponseSchema = z.object({
21073
+ const paidResponseSchema$1 = z.object({
21033
21074
  status_code: z.number().int(),
21034
21075
  status_message: z.string().max(1e3),
21035
21076
  cost: z.number().finite().nonnegative().optional(),
21036
21077
  tasks_count: z.number().int().nonnegative(),
21037
21078
  tasks_error: z.number().int().nonnegative()
21038
21079
  }).passthrough();
21039
- const taskFields = {
21080
+ const taskFields$1 = {
21040
21081
  id: z.string().trim().min(1).max(100).optional(),
21041
21082
  status_code: z.number().int(),
21042
21083
  status_message: z.string().max(1e3),
@@ -21056,8 +21097,8 @@ const overviewResultSchema = z.object({
21056
21097
  items_count: z.number().int().nonnegative().optional(),
21057
21098
  items: z.array(overviewItemSchema).max(1e3).nullable().optional()
21058
21099
  }).passthrough();
21059
- const dataForSeoDomainOverviewResponseSchema = paidResponseSchema.extend({ tasks: z.array(z.object({
21060
- ...taskFields,
21100
+ const dataForSeoDomainOverviewResponseSchema = paidResponseSchema$1.extend({ tasks: z.array(z.object({
21101
+ ...taskFields$1,
21061
21102
  result: z.array(overviewResultSchema).max(10).nullable().optional()
21062
21103
  }).passthrough()).max(10) });
21063
21104
  const rankedSerpItemSchema = z.object({
@@ -21077,8 +21118,8 @@ const rankedKeywordsResultSchema = z.object({
21077
21118
  items_count: z.number().int().nonnegative().optional(),
21078
21119
  items: z.array(rankedKeywordItemSchema).max(1e3).nullable().optional()
21079
21120
  }).passthrough();
21080
- const dataForSeoRankedKeywordsResponseSchema = paidResponseSchema.extend({ tasks: z.array(z.object({
21081
- ...taskFields,
21121
+ const dataForSeoRankedKeywordsResponseSchema = paidResponseSchema$1.extend({ tasks: z.array(z.object({
21122
+ ...taskFields$1,
21082
21123
  result: z.array(rankedKeywordsResultSchema).max(10).nullable().optional()
21083
21124
  }).passthrough()).max(10) });
21084
21125
  const rankingPageItemSchema = z.object({
@@ -21091,8 +21132,8 @@ const rankingPagesResultSchema = z.object({
21091
21132
  items_count: z.number().int().nonnegative().optional(),
21092
21133
  items: z.array(rankingPageItemSchema).max(1e3).nullable().optional()
21093
21134
  }).passthrough();
21094
- const dataForSeoRankingPagesResponseSchema = paidResponseSchema.extend({ tasks: z.array(z.object({
21095
- ...taskFields,
21135
+ const dataForSeoRankingPagesResponseSchema = paidResponseSchema$1.extend({ tasks: z.array(z.object({
21136
+ ...taskFields$1,
21096
21137
  result: z.array(rankingPagesResultSchema).max(10).nullable().optional()
21097
21138
  }).passthrough()).max(10) });
21098
21139
  const competitorItemSchema = z.object({
@@ -21100,9 +21141,9 @@ const competitorItemSchema = z.object({
21100
21141
  avg_position: nullableNumber,
21101
21142
  median_position: nullableNumber,
21102
21143
  etv: nullableNonnegativeNumber,
21103
- keywords_count: nullableCount,
21144
+ keywords_count: nullableCount$1,
21104
21145
  visibility: nullableNonnegativeNumber,
21105
- relevant_serp_items: nullableCount,
21146
+ relevant_serp_items: nullableCount$1,
21106
21147
  keywords_positions: z.record(z.string().trim().min(1).max(80), z.array(z.number().int().positive()).max(100)).nullable().optional()
21107
21148
  }).passthrough();
21108
21149
  const competitorsResultSchema = z.object({
@@ -21111,8 +21152,8 @@ const competitorsResultSchema = z.object({
21111
21152
  items_count: z.number().int().nonnegative().optional(),
21112
21153
  items: z.array(competitorItemSchema).max(1e3).nullable().optional()
21113
21154
  }).passthrough();
21114
- const dataForSeoSerpCompetitorsResponseSchema = paidResponseSchema.extend({ tasks: z.array(z.object({
21115
- ...taskFields,
21155
+ const dataForSeoSerpCompetitorsResponseSchema = paidResponseSchema$1.extend({ tasks: z.array(z.object({
21156
+ ...taskFields$1,
21116
21157
  result: z.array(competitorsResultSchema).max(10).nullable().optional()
21117
21158
  }).passthrough()).max(10) });
21118
21159
  const DOMAIN_OVERVIEW_PATH = "v3/dataforseo_labs/google/domain_rank_overview/live";
@@ -21302,6 +21343,209 @@ function serpCompetitorsPaidRequest(input, ttlMs) {
21302
21343
  };
21303
21344
  }
21304
21345
  //#endregion
21346
+ //#region packages/core/dist/providers/dataforseo/link-schema.js
21347
+ const nullableCount = z.number().int().nonnegative().nullable().optional();
21348
+ const nullableScore = z.number().finite().nonnegative().nullable().optional();
21349
+ const nullableText = z.string().max(2e4).nullable().optional();
21350
+ const paidResponseSchema = z.object({
21351
+ status_code: z.number().int(),
21352
+ status_message: z.string().max(1e3),
21353
+ cost: z.number().finite().nonnegative().optional(),
21354
+ tasks_count: z.number().int().nonnegative(),
21355
+ tasks_error: z.number().int().nonnegative()
21356
+ }).passthrough();
21357
+ const taskFields = {
21358
+ id: z.string().trim().min(1).max(100).optional(),
21359
+ status_code: z.number().int(),
21360
+ status_message: z.string().max(1e3),
21361
+ cost: z.number().finite().nonnegative().optional(),
21362
+ result_count: z.number().int().nonnegative().optional()
21363
+ };
21364
+ const dataForSeoLinkSummaryItemSchema = z.object({
21365
+ target: z.string().trim().min(1).max(2048).optional(),
21366
+ rank: nullableScore,
21367
+ backlinks: nullableCount,
21368
+ referring_pages: nullableCount,
21369
+ referring_domains: nullableCount,
21370
+ broken_backlinks: nullableCount,
21371
+ broken_pages: nullableCount,
21372
+ backlinks_spam_score: nullableScore,
21373
+ info: z.object({ target_spam_score: nullableScore }).passthrough().nullable().optional()
21374
+ }).passthrough();
21375
+ const dataForSeoLinkSummaryResponseSchema = paidResponseSchema.extend({ tasks: z.array(z.object({
21376
+ ...taskFields,
21377
+ result: z.array(dataForSeoLinkSummaryItemSchema).max(10).nullable().optional()
21378
+ }).passthrough()).max(10) });
21379
+ const dataForSeoBacklinkItemSchema = z.object({
21380
+ domain_from: nullableText,
21381
+ url_from: nullableText,
21382
+ url_to: nullableText,
21383
+ anchor: nullableText,
21384
+ item_type: z.string().max(100).nullable().optional(),
21385
+ dofollow: z.boolean().nullable().optional(),
21386
+ rank: nullableScore,
21387
+ domain_from_rank: nullableScore,
21388
+ page_from_rank: nullableScore,
21389
+ backlink_spam_score: nullableScore,
21390
+ backlinks_spam_score: nullableScore,
21391
+ first_seen: z.string().max(100).nullable().optional(),
21392
+ last_visited: z.string().max(100).nullable().optional(),
21393
+ lost_date: z.string().max(100).nullable().optional(),
21394
+ is_lost: z.boolean().nullable().optional(),
21395
+ is_indirect_link: z.boolean().nullable().optional(),
21396
+ links_count: nullableCount,
21397
+ group_count: nullableCount,
21398
+ rel_attributes: z.array(z.string().trim().min(1).max(100)).max(100).nullable().optional(),
21399
+ attributes: z.array(z.string().trim().min(1).max(100)).max(100).nullable().optional()
21400
+ }).passthrough();
21401
+ const backlinkResultSchema = z.object({
21402
+ target: z.string().trim().min(1).max(2048).optional(),
21403
+ total_count: nullableCount,
21404
+ items_count: nullableCount,
21405
+ search_after_token: z.string().max(2e4).nullable().optional(),
21406
+ items: z.array(dataForSeoBacklinkItemSchema).max(1e3).nullable().optional()
21407
+ }).passthrough();
21408
+ const dataForSeoBacklinksResponseSchema = paidResponseSchema.extend({ tasks: z.array(z.object({
21409
+ ...taskFields,
21410
+ result: z.array(backlinkResultSchema).max(10).nullable().optional()
21411
+ }).passthrough()).max(10) });
21412
+ const dataForSeoReferringDomainItemSchema = z.object({
21413
+ domain: z.string().trim().min(1).max(2048).nullable().optional(),
21414
+ backlinks: nullableCount,
21415
+ referring_pages: nullableCount,
21416
+ broken_backlinks: nullableCount,
21417
+ broken_pages: nullableCount,
21418
+ rank: nullableScore,
21419
+ backlinks_spam_score: nullableScore,
21420
+ first_seen: z.string().max(100).nullable().optional()
21421
+ }).passthrough();
21422
+ const referringDomainsResultSchema = z.object({
21423
+ target: z.string().trim().min(1).max(2048).optional(),
21424
+ total_count: nullableCount,
21425
+ items_count: nullableCount,
21426
+ items: z.array(dataForSeoReferringDomainItemSchema).max(1e3).nullable().optional()
21427
+ }).passthrough();
21428
+ const dataForSeoReferringDomainsResponseSchema = paidResponseSchema.extend({ tasks: z.array(z.object({
21429
+ ...taskFields,
21430
+ result: z.array(referringDomainsResultSchema).max(10).nullable().optional()
21431
+ }).passthrough()).max(10) });
21432
+ const MAX_LINK_ROWS$1 = 1e3;
21433
+ const MAX_LINK_OFFSET = 2e4;
21434
+ const LINK_ENDPOINTS = {
21435
+ summary: "v3/backlinks/summary/live",
21436
+ backlinks: "v3/backlinks/backlinks/live",
21437
+ referringDomains: "v3/backlinks/referring_domains/live"
21438
+ };
21439
+ function resultRows(response) {
21440
+ return response.tasks.reduce((taskTotal, task) => taskTotal + (task.result ?? []).reduce((resultTotal, result) => resultTotal + (result.items?.length ?? 0), 0), 0);
21441
+ }
21442
+ function summaryRows(response) {
21443
+ return response.tasks.reduce((total, task) => total + (task.result?.length ?? 0), 0);
21444
+ }
21445
+ function commonRequest(input) {
21446
+ return {
21447
+ target: input.target,
21448
+ include_subdomains: input.scope === "domain" ? input.includeSubdomains : false,
21449
+ include_indirect_links: true,
21450
+ exclude_internal_backlinks: true,
21451
+ backlinks_status_type: "live",
21452
+ rank_scale: "one_hundred"
21453
+ };
21454
+ }
21455
+ function validatePage(input) {
21456
+ if (!Number.isSafeInteger(input.limit) || input.limit < 1 || input.limit > 1e3) throw new ProviderError({
21457
+ provider: "dataforseo",
21458
+ operation: input.operation,
21459
+ code: "configuration",
21460
+ message: `Link row limit must be from 1 to ${MAX_LINK_ROWS$1}.`
21461
+ });
21462
+ if (!Number.isSafeInteger(input.offset) || input.offset < 0 || input.offset > 2e4) throw new ProviderError({
21463
+ provider: "dataforseo",
21464
+ operation: input.operation,
21465
+ code: "configuration",
21466
+ message: `Link row offset must be from 0 to ${MAX_LINK_OFFSET}.`
21467
+ });
21468
+ }
21469
+ function linkSummaryPaidRequest(input, ttlMs) {
21470
+ return {
21471
+ operation: "link-summary",
21472
+ capability: "link-summary",
21473
+ endpoint: LINK_ENDPOINTS.summary,
21474
+ request: commonRequest(input),
21475
+ schema: dataForSeoLinkSummaryResponseSchema,
21476
+ requestedRows: 1,
21477
+ price: (account) => account.linkPrices.summary,
21478
+ context: input.context,
21479
+ ttlMs,
21480
+ refresh: input.refresh,
21481
+ rowCount: summaryRows
21482
+ };
21483
+ }
21484
+ function backlinksPaidRequest(input, ttlMs) {
21485
+ validatePage({
21486
+ limit: input.limit,
21487
+ offset: input.offset,
21488
+ operation: "backlinks"
21489
+ });
21490
+ if (input.orderBy.length < 1 || input.orderBy.length > 3) throw new ProviderError({
21491
+ provider: "dataforseo",
21492
+ operation: "backlinks",
21493
+ code: "configuration",
21494
+ message: "Backlink rows need from 1 to 3 sort rules."
21495
+ });
21496
+ return {
21497
+ operation: "backlinks",
21498
+ capability: "backlinks",
21499
+ endpoint: LINK_ENDPOINTS.backlinks,
21500
+ request: {
21501
+ ...commonRequest(input),
21502
+ mode: input.mode,
21503
+ backlinks_status_type: input.status,
21504
+ limit: input.limit,
21505
+ offset: input.offset,
21506
+ order_by: input.orderBy
21507
+ },
21508
+ schema: dataForSeoBacklinksResponseSchema,
21509
+ requestedRows: input.limit,
21510
+ price: (account) => account.linkPrices.backlinks,
21511
+ context: input.context,
21512
+ ttlMs,
21513
+ refresh: input.refresh,
21514
+ rowCount: resultRows
21515
+ };
21516
+ }
21517
+ function referringDomainsPaidRequest(input, ttlMs) {
21518
+ validatePage({
21519
+ limit: input.limit,
21520
+ offset: input.offset,
21521
+ operation: "referring-domains"
21522
+ });
21523
+ if (input.orderBy.length < 1 || input.orderBy.length > 3) throw new ProviderError({
21524
+ provider: "dataforseo",
21525
+ operation: "referring-domains",
21526
+ code: "configuration",
21527
+ message: "Referring-domain rows need from 1 to 3 sort rules."
21528
+ });
21529
+ return {
21530
+ operation: "referring-domains",
21531
+ capability: "referring-domains",
21532
+ endpoint: LINK_ENDPOINTS.referringDomains,
21533
+ request: {
21534
+ ...commonRequest(input),
21535
+ limit: input.limit,
21536
+ offset: input.offset,
21537
+ order_by: input.orderBy
21538
+ },
21539
+ schema: dataForSeoReferringDomainsResponseSchema,
21540
+ requestedRows: input.limit,
21541
+ price: (account) => account.linkPrices.referringDomains,
21542
+ context: input.context,
21543
+ ttlMs,
21544
+ refresh: input.refresh,
21545
+ rowCount: resultRows
21546
+ };
21547
+ }
21548
+ //#endregion
21305
21549
  //#region packages/core/dist/providers/dataforseo/paid-request.js
21306
21550
  function usdToMicros(value) {
21307
21551
  if (value === void 0) return null;
@@ -21825,41 +22069,6 @@ const MAX_KEYWORD_CHARACTERS = 80;
21825
22069
  const MAX_KEYWORD_WORDS = 10;
21826
22070
  const MAX_DISCOVERY_SEEDS$1 = 5;
21827
22071
  const MAX_DISCOVERY_ROWS$1 = 100;
21828
- function unitPrice(components) {
21829
- const prices = components ?? [];
21830
- const total = (type) => {
21831
- const matching = prices.filter((item) => item.cost_type === type);
21832
- return matching.length ? matching.reduce((sum, item) => sum + Math.round(item.cost * 1e6), 0) : prices.length ? 0 : null;
21833
- };
21834
- return {
21835
- perRequestMicros: total("per_request"),
21836
- perResultMicros: total("per_result")
21837
- };
21838
- }
21839
- function keywordOverviewPrice(account) {
21840
- return unitPrice(account.price?.dataforseo_labs?.keyword_overview?.live?.priority_normal);
21841
- }
21842
- function keywordDiscoveryPrices(account) {
21843
- return {
21844
- ideas: unitPrice(account.price?.dataforseo_labs?.keyword_ideas?.live?.priority_normal),
21845
- related: unitPrice(account.price?.dataforseo_labs?.related_keywords?.live?.priority_normal),
21846
- suggestions: unitPrice(account.price?.dataforseo_labs?.keyword_suggestions?.live?.priority_normal)
21847
- };
21848
- }
21849
- function domainResearchPrices(account) {
21850
- return {
21851
- domainOverview: unitPrice(account.price?.dataforseo_labs?.domain_rank_overview?.live?.priority_normal),
21852
- rankedKeywords: unitPrice(account.price?.dataforseo_labs?.ranked_keywords?.live?.priority_normal),
21853
- rankingPages: unitPrice(account.price?.dataforseo_labs?.relevant_pages?.live?.priority_normal),
21854
- serpCompetitors: unitPrice(account.price?.dataforseo_labs?.serp_competitors?.live?.priority_normal)
21855
- };
21856
- }
21857
- function serpLiveAdvancedPrice(account) {
21858
- return unitPrice(account.price?.serp?.live?.advanced?.priority_normal);
21859
- }
21860
- function serpTaskPostPrice(account) {
21861
- return unitPrice(account.price?.serp?.task_post?.priority_normal);
21862
- }
21863
22072
  function responseTaskIds(response) {
21864
22073
  return [...new Set(response.tasks.flatMap((task) => task.id ? [task.id] : []))].sort((left, right) => left < right ? -1 : left > right ? 1 : 0);
21865
22074
  }
@@ -21879,6 +22088,7 @@ var DataForSeoClient = class {
21879
22088
  serpTtlMs;
21880
22089
  accountPricingTtlMs;
21881
22090
  domainResearchTtlMs;
22091
+ linkTtlMs;
21882
22092
  spendLimits;
21883
22093
  accountPricing;
21884
22094
  accountPricingRequest;
@@ -21895,6 +22105,7 @@ var DataForSeoClient = class {
21895
22105
  this.serpTtlMs = options.serpTtlMs ?? DEFAULT_SERP_TTL_MS;
21896
22106
  this.accountPricingTtlMs = options.accountPricingTtlMs ?? DEFAULT_ACCOUNT_PRICING_TTL_MS;
21897
22107
  this.domainResearchTtlMs = options.domainResearchTtlMs ?? 6048e5;
22108
+ this.linkTtlMs = options.linkTtlMs ?? 864e5;
21898
22109
  this.spendLimits = options.spendLimits;
21899
22110
  }
21900
22111
  async getCredentials(operation) {
@@ -21977,11 +22188,7 @@ var DataForSeoClient = class {
21977
22188
  accountDailySpendMicros: dataForSeoUsdToMicros(account.money?.statistics?.day?.total),
21978
22189
  accountDailySpendPeriod: account.money?.statistics?.day?.value ?? null,
21979
22190
  accountDailyLimitMicros: dataForSeoUsdToMicros(account.money?.limits?.day?.total),
21980
- keywordOverviewPrice: keywordOverviewPrice(account),
21981
- keywordDiscoveryPrices: keywordDiscoveryPrices(account),
21982
- domainResearchPrices: domainResearchPrices(account),
21983
- serpLiveAdvancedPrice: serpLiveAdvancedPrice(account),
21984
- serpTaskPostPrice: serpTaskPostPrice(account),
22191
+ ...dataForSeoAccountPrices(account),
21985
22192
  backlinksSubscriptionExpiresAt: account.backlinks_subscription_expiry_date ?? null,
21986
22193
  aiMentionsSubscriptionExpiresAt: account.llm_mentions_subscription_expiry_date ?? null,
21987
22194
  apiVersion: response.version ?? null,
@@ -22044,6 +22251,15 @@ var DataForSeoClient = class {
22044
22251
  async serpCompetitors(input) {
22045
22252
  return this.paidPost(serpCompetitorsPaidRequest(input, this.domainResearchTtlMs));
22046
22253
  }
22254
+ async linkSummary(input) {
22255
+ return this.paidPost(linkSummaryPaidRequest(input, this.linkTtlMs));
22256
+ }
22257
+ async backlinks(input) {
22258
+ return this.paidPost(backlinksPaidRequest(input, this.linkTtlMs));
22259
+ }
22260
+ async referringDomains(input) {
22261
+ return this.paidPost(referringDomainsPaidRequest(input, this.linkTtlMs));
22262
+ }
22047
22263
  async keywordOverview(input) {
22048
22264
  const keywords = input.keywords.map((keyword) => keyword.trim());
22049
22265
  if (keywords.length < 1 || keywords.length > MAX_KEYWORDS_PER_OVERVIEW_REQUEST || keywords.some((keyword) => keyword.length < 1 || keyword.length > MAX_KEYWORD_CHARACTERS || keyword.split(/\s+/u).length > MAX_KEYWORD_WORDS)) throw new ProviderError({
@@ -22387,20 +22603,20 @@ function compareCodepoints$3(left, right) {
22387
22603
  function normalizedKeyword$2(keyword) {
22388
22604
  return keyword.trim().replace(/\s+/gu, " ").toLowerCase();
22389
22605
  }
22390
- function missing$1(field) {
22606
+ function missing$2(field) {
22391
22607
  return unavailableValue("missing", `DataForSEO omitted ${field}.`);
22392
22608
  }
22393
22609
  function numericValue(values, field, valid) {
22394
22610
  const present = values.filter((value) => value !== null && value !== void 0);
22395
- if (present.length === 0) return missing$1(field);
22611
+ if (present.length === 0) return missing$2(field);
22396
22612
  const numbers = present.filter((value) => typeof value === "number" && valid(value));
22397
22613
  if (numbers.length !== present.length) return unavailableValue("invalid", `DataForSEO returned an invalid ${field}.`);
22398
22614
  const unique = [...new Set(numbers)];
22399
22615
  return unique.length === 1 ? observedValue(unique[0]) : unavailableValue("invalid", `DataForSEO returned conflicting ${field} values.`);
22400
22616
  }
22401
- function stringValue(values, field, normalize) {
22617
+ function stringValue$1(values, field, normalize) {
22402
22618
  const present = values.filter((value) => value !== null && value !== void 0);
22403
- if (present.length === 0) return missing$1(field);
22619
+ if (present.length === 0) return missing$2(field);
22404
22620
  const strings = present.flatMap((value) => {
22405
22621
  if (typeof value !== "string") return [];
22406
22622
  const normalized = normalize(value);
@@ -22412,7 +22628,7 @@ function stringValue(values, field, normalize) {
22412
22628
  }
22413
22629
  function monthlySearchesValue(rows) {
22414
22630
  const histories = rows.map((row) => row.keyword_info?.monthly_searches).filter((value) => value !== null && value !== void 0);
22415
- if (histories.length === 0) return missing$1("monthlySearches");
22631
+ if (histories.length === 0) return missing$2("monthlySearches");
22416
22632
  const byMonth = /* @__PURE__ */ new Map();
22417
22633
  let invalid = false;
22418
22634
  for (const history of histories) for (const item of history) {
@@ -22445,27 +22661,27 @@ function resultCountValues(rows) {
22445
22661
  function metricForKeyword(keyword, rows) {
22446
22662
  if (rows.length === 0) return {
22447
22663
  keyword,
22448
- monthlySearchVolume: missing$1("monthlySearchVolume"),
22449
- monthlySearches: missing$1("monthlySearches"),
22450
- searchVolumeUpdatedAt: missing$1("searchVolumeUpdatedAt"),
22451
- cpcUsd: missing$1("cpcUsd"),
22452
- paidCompetition: missing$1("paidCompetition"),
22453
- keywordDifficulty: missing$1("keywordDifficulty"),
22454
- intent: missing$1("intent"),
22455
- resultCount: missing$1("resultCount")
22664
+ monthlySearchVolume: missing$2("monthlySearchVolume"),
22665
+ monthlySearches: missing$2("monthlySearches"),
22666
+ searchVolumeUpdatedAt: missing$2("searchVolumeUpdatedAt"),
22667
+ cpcUsd: missing$2("cpcUsd"),
22668
+ paidCompetition: missing$2("paidCompetition"),
22669
+ keywordDifficulty: missing$2("keywordDifficulty"),
22670
+ intent: missing$2("intent"),
22671
+ resultCount: missing$2("resultCount")
22456
22672
  };
22457
22673
  return {
22458
22674
  keyword,
22459
22675
  monthlySearchVolume: numericValue(rows.map((row) => row.keyword_info?.search_volume), "monthlySearchVolume", (value) => Number.isSafeInteger(value) && value >= 0),
22460
22676
  monthlySearches: monthlySearchesValue(rows),
22461
- searchVolumeUpdatedAt: stringValue(rows.map((row) => row.keyword_info?.last_updated_time), "searchVolumeUpdatedAt", (value) => {
22677
+ searchVolumeUpdatedAt: stringValue$1(rows.map((row) => row.keyword_info?.last_updated_time), "searchVolumeUpdatedAt", (value) => {
22462
22678
  const timestamp = Date.parse(value);
22463
22679
  return Number.isFinite(timestamp) ? new Date(timestamp).toISOString() : null;
22464
22680
  }),
22465
22681
  cpcUsd: numericValue(rows.map((row) => row.keyword_info?.cpc), "cpcUsd", (value) => Number.isFinite(value) && value >= 0),
22466
22682
  paidCompetition: numericValue(rows.map((row) => row.keyword_info?.competition), "paidCompetition", (value) => Number.isFinite(value) && value >= 0 && value <= 1),
22467
22683
  keywordDifficulty: numericValue(rows.map((row) => row.keyword_properties?.keyword_difficulty), "keywordDifficulty", (value) => Number.isFinite(value) && value >= 0 && value <= 100),
22468
- intent: stringValue(rows.map((row) => row.search_intent_info?.main_intent), "intent", (value) => value.trim().toLowerCase() || null),
22684
+ intent: stringValue$1(rows.map((row) => row.search_intent_info?.main_intent), "intent", (value) => value.trim().toLowerCase() || null),
22469
22685
  resultCount: numericValue(resultCountValues(rows), "resultCount", (value) => Number.isSafeInteger(value) && value >= 0)
22470
22686
  };
22471
22687
  }
@@ -22518,21 +22734,21 @@ const COMPETITOR_RESULT_TYPES = /* @__PURE__ */ new Set([
22518
22734
  "featured_snippet",
22519
22735
  "local_pack"
22520
22736
  ]);
22521
- function missing(field) {
22737
+ function missing$1(field) {
22522
22738
  return unavailableValue("missing", `DataForSEO omitted ${field}.`);
22523
22739
  }
22524
- function numberValue(value, field) {
22525
- return value === null || value === void 0 ? missing(field) : observedValue(value);
22740
+ function numberValue$1(value, field) {
22741
+ return value === null || value === void 0 ? missing$1(field) : observedValue(value);
22526
22742
  }
22527
22743
  function countValue(value, field) {
22528
- return numberValue(value, field);
22744
+ return numberValue$1(value, field);
22529
22745
  }
22530
22746
  function sumBuckets(metrics, fields, label) {
22531
22747
  const values = fields.map((field) => metrics[field]);
22532
- return values.some((value) => value === null || value === void 0) ? missing(label) : observedValue(values.reduce((sum, value) => sum + (typeof value === "number" ? value : 0), 0));
22748
+ return values.some((value) => value === null || value === void 0) ? missing$1(label) : observedValue(values.reduce((sum, value) => sum + (typeof value === "number" ? value : 0), 0));
22533
22749
  }
22534
22750
  function rankingDistribution(metrics) {
22535
- if (!metrics) return missing("organic ranking distribution");
22751
+ if (!metrics) return missing$1("organic ranking distribution");
22536
22752
  const buckets = {
22537
22753
  first: sumBuckets(metrics, ["pos_1"], "position 1 rankings"),
22538
22754
  top3: sumBuckets(metrics, ["pos_1", "pos_2_3"], "top 3 rankings"),
@@ -22583,9 +22799,9 @@ function rankingDistribution(metrics) {
22583
22799
  }
22584
22800
  function organicFootprint(metrics) {
22585
22801
  return {
22586
- estimatedMonthlyTraffic: numberValue(metrics?.etv, "estimated organic monthly traffic"),
22802
+ estimatedMonthlyTraffic: numberValue$1(metrics?.etv, "estimated organic monthly traffic"),
22587
22803
  rankedKeywords: countValue(metrics?.count, "ranked organic keywords"),
22588
- estimatedMonthlyTrafficCostUsd: numberValue(metrics?.estimated_paid_traffic_cost, "estimated organic traffic cost"),
22804
+ estimatedMonthlyTrafficCostUsd: numberValue$1(metrics?.estimated_paid_traffic_cost, "estimated organic traffic cost"),
22589
22805
  rankings: rankingDistribution(metrics),
22590
22806
  newRankings: countValue(metrics?.is_new, "new rankings"),
22591
22807
  improvedRankings: countValue(metrics?.is_up, "improved rankings"),
@@ -22640,7 +22856,7 @@ function researchMarket(input, operation) {
22640
22856
  });
22641
22857
  return parsed;
22642
22858
  }
22643
- function rowLimit(limit, offset = 0) {
22859
+ function rowLimit$1(limit, offset = 0) {
22644
22860
  if (!Number.isSafeInteger(limit) || limit < 1 || limit > 1e3) throw new ProviderError({
22645
22861
  provider: "dataforseo",
22646
22862
  operation: "domain-research",
@@ -22664,7 +22880,7 @@ function resultTypes(input, allowed) {
22664
22880
  });
22665
22881
  return normalized;
22666
22882
  }
22667
- function safeUrl$1(value) {
22883
+ function safeUrl$2(value) {
22668
22884
  if (!value) return null;
22669
22885
  try {
22670
22886
  const url = new URL(value);
@@ -22684,7 +22900,7 @@ function dedupeBy(rows, key) {
22684
22900
  }
22685
22901
  return [...grouped.entries()].sort(([left], [right]) => compareCodepoints$3(left, right)).map(([, matches]) => [...matches].sort((left, right) => compareCodepoints$3(JSON.stringify(left), JSON.stringify(right)))[0]);
22686
22902
  }
22687
- function observedNumber(value) {
22903
+ function observedNumber$1(value) {
22688
22904
  return value.state === "observed" ? value.value : -1;
22689
22905
  }
22690
22906
  function duplicateWarning(available, retained, label) {
@@ -22695,7 +22911,7 @@ function duplicateWarning(available, retained, label) {
22695
22911
  message: `${duplicates} duplicate ${label} row${duplicates === 1 ? "" : "s"} were collapsed deterministically.`
22696
22912
  }] : [];
22697
22913
  }
22698
- function totalRows(values) {
22914
+ function totalRows$1(values) {
22699
22915
  const present = values.filter((value) => value !== null && value !== void 0);
22700
22916
  return present.length ? Math.max(...present) : null;
22701
22917
  }
@@ -22711,7 +22927,7 @@ function coverage(input) {
22711
22927
  nextCursor: hasMore ? String(input.offset + input.returnedRows) : null
22712
22928
  };
22713
22929
  }
22714
- function requestContext(reportId, supplied) {
22930
+ function requestContext$1(reportId, supplied) {
22715
22931
  return supplied ?? {
22716
22932
  reportId,
22717
22933
  reportRunId: randomUUID()
@@ -22760,7 +22976,7 @@ async function dataForSeoDomainOverview(client, input) {
22760
22976
  languageCode: market.languageCode.split("-")[0],
22761
22977
  ...location,
22762
22978
  refresh: input.refresh,
22763
- context: requestContext("domain-overview", input.context)
22979
+ context: requestContext$1("domain-overview", input.context)
22764
22980
  });
22765
22981
  const results = snapshot.response.tasks.flatMap((task) => task.result ?? []);
22766
22982
  const matchingResult = results.find((row) => !row.target || domain(row.target) === target);
@@ -22852,7 +23068,7 @@ async function dataForSeoRankedKeywords(client, input) {
22852
23068
  const market = researchMarket(input.market, "ranked-keywords");
22853
23069
  const target = rankedTarget(input.target);
22854
23070
  const offset = input.offset ?? 0;
22855
- rowLimit(input.limit, offset);
23071
+ rowLimit$1(input.limit, offset);
22856
23072
  const types = resultTypes(input.resultTypes, RANKED_RESULT_TYPES);
22857
23073
  const excluded = excludedTerms(input);
22858
23074
  const requestedFilters = filters$1(input, excluded);
@@ -22872,7 +23088,7 @@ async function dataForSeoRankedKeywords(client, input) {
22872
23088
  limit: input.limit,
22873
23089
  offset,
22874
23090
  refresh: input.refresh,
22875
- context: requestContext("ranked-keywords", input.context)
23091
+ context: requestContext$1("ranked-keywords", input.context)
22876
23092
  });
22877
23093
  const responseResults = snapshot.response.tasks.flatMap((task) => task.result ?? []);
22878
23094
  const rawRows = responseResults.flatMap((result) => result.items ?? []);
@@ -22881,7 +23097,7 @@ async function dataForSeoRankedKeywords(client, input) {
22881
23097
  const keywordData = row.keyword_data;
22882
23098
  const serp = row.ranked_serp_element?.serp_item;
22883
23099
  const keyword = keywordData?.keyword ? normalizedKeyword$2(keywordData.keyword) : "";
22884
- const url = safeUrl$1(serp?.url);
23100
+ const url = safeUrl$2(serp?.url);
22885
23101
  if (!keywordData || !keyword || !url || !serp?.rank_group || !serp.rank_absolute || !serp.type) {
22886
23102
  invalidRows += 1;
22887
23103
  return [];
@@ -22892,11 +23108,11 @@ async function dataForSeoRankedKeywords(client, input) {
22892
23108
  rankGroup: serp.rank_group,
22893
23109
  rankAbsolute: serp.rank_absolute,
22894
23110
  resultType: serp.type,
22895
- estimatedMonthlyTraffic: numberValue(serp.etv, "estimated keyword traffic")
23111
+ estimatedMonthlyTraffic: numberValue$1(serp.etv, "estimated keyword traffic")
22896
23112
  }];
22897
23113
  });
22898
- const rows = dedupeBy(mappedRows, (row) => `${row.keyword}\0${row.url}\0${row.resultType}`).sort((left, right) => observedNumber(right.monthlySearchVolume) - observedNumber(left.monthlySearchVolume) || left.rankGroup - right.rankGroup || compareCodepoints$3(left.keyword, right.keyword) || compareCodepoints$3(left.url, right.url));
22899
- const providerTotalRows = totalRows(responseResults.map((result) => result.total_count));
23114
+ const rows = dedupeBy(mappedRows, (row) => `${row.keyword}\0${row.url}\0${row.resultType}`).sort((left, right) => observedNumber$1(right.monthlySearchVolume) - observedNumber$1(left.monthlySearchVolume) || left.rankGroup - right.rankGroup || compareCodepoints$3(left.keyword, right.keyword) || compareCodepoints$3(left.url, right.url));
23115
+ const providerTotalRows = totalRows$1(responseResults.map((result) => result.total_count));
22900
23116
  return domainEvidence({
22901
23117
  capability: "ranked-keywords",
22902
23118
  data: {
@@ -22969,7 +23185,7 @@ async function dataForSeoRankingPages(client, input) {
22969
23185
  const market = researchMarket(input.market, "ranking-pages");
22970
23186
  const target = domain(input.domain);
22971
23187
  const offset = input.offset ?? 0;
22972
- rowLimit(input.limit, offset);
23188
+ rowLimit$1(input.limit, offset);
22973
23189
  const requestedFilters = filters(input);
22974
23190
  const orderBy = ["metrics.organic.etv,desc", "page_address,asc"];
22975
23191
  const snapshot = await client.rankingPages({
@@ -22981,13 +23197,13 @@ async function dataForSeoRankingPages(client, input) {
22981
23197
  limit: input.limit,
22982
23198
  offset,
22983
23199
  refresh: input.refresh,
22984
- context: requestContext("ranking-pages", input.context)
23200
+ context: requestContext$1("ranking-pages", input.context)
22985
23201
  });
22986
23202
  const responseResults = snapshot.response.tasks.flatMap((task) => task.result ?? []);
22987
23203
  const rawRows = responseResults.flatMap((result) => result.items ?? []);
22988
23204
  let invalidRows = 0;
22989
23205
  const mappedRows = rawRows.flatMap((row) => {
22990
- const url = safeUrl$1(row.page_address);
23206
+ const url = safeUrl$2(row.page_address);
22991
23207
  if (!url) {
22992
23208
  invalidRows += 1;
22993
23209
  return [];
@@ -22997,8 +23213,8 @@ async function dataForSeoRankingPages(client, input) {
22997
23213
  organic: organicFootprint(row.metrics?.organic)
22998
23214
  }];
22999
23215
  });
23000
- const rows = dedupeBy(mappedRows, (row) => row.url).sort((left, right) => observedNumber(right.organic.estimatedMonthlyTraffic) - observedNumber(left.organic.estimatedMonthlyTraffic) || compareCodepoints$3(left.url, right.url));
23001
- const providerTotalRows = totalRows(responseResults.map((result) => result.total_count));
23216
+ const rows = dedupeBy(mappedRows, (row) => row.url).sort((left, right) => observedNumber$1(right.organic.estimatedMonthlyTraffic) - observedNumber$1(left.organic.estimatedMonthlyTraffic) || compareCodepoints$3(left.url, right.url));
23217
+ const providerTotalRows = totalRows$1(responseResults.map((result) => result.total_count));
23002
23218
  return domainEvidence({
23003
23219
  capability: "relevant-pages",
23004
23220
  data: {
@@ -23043,7 +23259,7 @@ function keywords(input) {
23043
23259
  async function dataForSeoSerpCompetitors(client, input) {
23044
23260
  const market = researchMarket(input.market, "serp-competitors");
23045
23261
  const offset = input.offset ?? 0;
23046
- rowLimit(input.limit, offset);
23262
+ rowLimit$1(input.limit, offset);
23047
23263
  const requestedKeywords = keywords(input.keywords);
23048
23264
  const types = resultTypes(input.resultTypes, COMPETITOR_RESULT_TYPES);
23049
23265
  const orderBy = [
@@ -23061,7 +23277,7 @@ async function dataForSeoSerpCompetitors(client, input) {
23061
23277
  limit: input.limit,
23062
23278
  offset,
23063
23279
  refresh: input.refresh,
23064
- context: requestContext("serp-competitors", input.context)
23280
+ context: requestContext$1("serp-competitors", input.context)
23065
23281
  });
23066
23282
  const responseResults = snapshot.response.tasks.flatMap((task) => task.result ?? []);
23067
23283
  const rawRows = responseResults.flatMap((result) => result.items ?? []);
@@ -23084,16 +23300,16 @@ async function dataForSeoSerpCompetitors(client, input) {
23084
23300
  return [{
23085
23301
  domain: normalizedDomain,
23086
23302
  matchedKeywords: row.keywords_count,
23087
- averagePosition: numberValue(row.avg_position, "average position"),
23088
- medianPosition: numberValue(row.median_position, "median position"),
23089
- visibility: numberValue(row.visibility, "visibility"),
23090
- estimatedMonthlyTraffic: numberValue(row.etv, "estimated monthly traffic"),
23303
+ averagePosition: numberValue$1(row.avg_position, "average position"),
23304
+ medianPosition: numberValue$1(row.median_position, "median position"),
23305
+ visibility: numberValue$1(row.visibility, "visibility"),
23306
+ estimatedMonthlyTraffic: numberValue$1(row.etv, "estimated monthly traffic"),
23091
23307
  relevantResults: countValue(row.relevant_serp_items, "relevant result count"),
23092
23308
  keywordPositions
23093
23309
  }];
23094
23310
  });
23095
- const rows = dedupeBy(mappedRows, (row) => row.domain).sort((left, right) => observedNumber(right.visibility) - observedNumber(left.visibility) || right.matchedKeywords - left.matchedKeywords || compareCodepoints$3(left.domain, right.domain));
23096
- const providerTotalRows = totalRows(responseResults.map((result) => result.total_count));
23311
+ const rows = dedupeBy(mappedRows, (row) => row.domain).sort((left, right) => observedNumber$1(right.visibility) - observedNumber$1(left.visibility) || right.matchedKeywords - left.matchedKeywords || compareCodepoints$3(left.domain, right.domain));
23312
+ const providerTotalRows = totalRows$1(responseResults.map((result) => result.total_count));
23097
23313
  return domainEvidence({
23098
23314
  capability: "serp-competitors",
23099
23315
  data: {
@@ -23217,7 +23433,7 @@ function resolveProvider(input) {
23217
23433
  //#endregion
23218
23434
  //#region packages/core/dist/analyze/domain-research/shared.js
23219
23435
  const MAX_GSC_DOMAIN_ROWS = 1e5;
23220
- function compareText$6(left, right) {
23436
+ function compareText$7(left, right) {
23221
23437
  return left < right ? -1 : left > right ? 1 : 0;
23222
23438
  }
23223
23439
  function normalizedKeyword$1(value) {
@@ -23334,8 +23550,8 @@ function aggregateGscQueries(rows) {
23334
23550
  clicks: item.clicks,
23335
23551
  impressions: item.impressions,
23336
23552
  averagePosition: item.positionWeight ? item.weightedPosition / item.positionWeight : null,
23337
- urls: [...item.urls].sort(compareText$6).slice(0, 5)
23338
- })).sort((left, right) => right.impressions - left.impressions || compareText$6(left.query, right.query));
23553
+ urls: [...item.urls].sort(compareText$7).slice(0, 5)
23554
+ })).sort((left, right) => right.impressions - left.impressions || compareText$7(left.query, right.query));
23339
23555
  }
23340
23556
  async function acquireGscQueries(input) {
23341
23557
  const range = finalGscDateRange(input.days, input.now);
@@ -23701,7 +23917,7 @@ function pseoQueryPatterns(rows) {
23701
23917
  //#region packages/core/dist/analyze/pseo/templates.js
23702
23918
  const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
23703
23919
  const DATE_RE = /^\d{4}-\d{2}-\d{2}$/;
23704
- function compareText$5(left, right) {
23920
+ function compareText$6(left, right) {
23705
23921
  const leftPoints = [...left].map((value) => value.codePointAt(0) ?? 0);
23706
23922
  const rightPoints = [...right].map((value) => value.codePointAt(0) ?? 0);
23707
23923
  for (let index = 0; index < Math.min(leftPoints.length, rightPoints.length); index++) {
@@ -23757,7 +23973,7 @@ function valueTokens(values) {
23757
23973
  const tokens = value.normalize("NFKC").toLowerCase().match(/[\p{L}\p{N}]+/gu) ?? [];
23758
23974
  for (const token of tokens.filter((item) => item.length > 1 || /[^\p{ASCII}]/u.test(item))) counts.set(token, (counts.get(token) ?? 0) + 1);
23759
23975
  }
23760
- return [...counts.entries()].sort((a, b) => b[1] - a[1] || compareText$5(a[0], b[0])).slice(0, 8).map(([token]) => token);
23976
+ return [...counts.entries()].sort((a, b) => b[1] - a[1] || compareText$6(a[0], b[0])).slice(0, 8).map(([token]) => token);
23761
23977
  }
23762
23978
  function templateShape(signature, urls) {
23763
23979
  const signatureParts = signature.split("/").filter(Boolean);
@@ -23806,7 +24022,7 @@ function collectClusters(urls, signatures) {
23806
24022
  return bySignature;
23807
24023
  }
23808
24024
  function clusterPseoTemplates(urls, opts = {}) {
23809
- const uniqueUrls = [...new Set(urls)].sort(compareText$5);
24025
+ const uniqueUrls = [...new Set(urls)].sort(compareText$6);
23810
24026
  const minUrls = opts.minUrls ?? 3;
23811
24027
  const minShare = opts.minShare ?? .01;
23812
24028
  const sampleSize = opts.sampleSize ?? 5;
@@ -23824,15 +24040,15 @@ function clusterPseoTemplates(urls, opts = {}) {
23824
24040
  shape: templateShape(signature, clusterUrls),
23825
24041
  urlCount: clusterUrls.length,
23826
24042
  share: uniqueUrls.length ? clusterUrls.length / uniqueUrls.length : 0,
23827
- sampleUrls: clusterUrls.sort(compareText$5).slice(0, sampleSize)
23828
- })).filter((cluster) => cluster.shape.variableSegments.length > 0 && cluster.urlCount >= minUrls && cluster.share >= minShare).sort((a, b) => b.urlCount - a.urlCount || compareText$5(a.signature, b.signature)).slice(0, opts.limit ?? 50);
24043
+ sampleUrls: clusterUrls.sort(compareText$6).slice(0, sampleSize)
24044
+ })).filter((cluster) => cluster.shape.variableSegments.length > 0 && cluster.urlCount >= minUrls && cluster.share >= minShare).sort((a, b) => b.urlCount - a.urlCount || compareText$6(a.signature, b.signature)).slice(0, opts.limit ?? 50);
23829
24045
  }
23830
24046
  //#endregion
23831
24047
  //#region packages/core/dist/analyze/domain-research/gap-analysis.js
23832
24048
  const MAX_RELEVANCE_QUERY_REFS = 3;
23833
24049
  const MAX_FIRST_PARTY_PATTERN_URLS = 1e4;
23834
24050
  function terms(value) {
23835
- return [...new Set(pseoQueryTerms(value).map(canonicalPseoTerm).filter(Boolean))].sort(compareText$6);
24051
+ return [...new Set(pseoQueryTerms(value).map(canonicalPseoTerm).filter(Boolean))].sort(compareText$7);
23836
24052
  }
23837
24053
  function observedVolume$1(row) {
23838
24054
  return value(row.monthlySearchVolume) ?? -1;
@@ -23876,7 +24092,7 @@ function relevanceForKeyword(keyword, acquisition, tokenIndex) {
23876
24092
  query: row.query,
23877
24093
  overlap: terms(row.query).filter((token) => keywordTerms.includes(token)).length,
23878
24094
  impressions: row.impressions
23879
- })).filter((row) => row.overlap > 0).sort((left, right) => right.overlap - left.overlap || right.impressions - left.impressions || compareText$6(left.query, right.query)).slice(0, MAX_RELEVANCE_QUERY_REFS).map((row) => row.query);
24095
+ })).filter((row) => row.overlap > 0).sort((left, right) => right.overlap - left.overlap || right.impressions - left.impressions || compareText$7(left.query, right.query)).slice(0, MAX_RELEVANCE_QUERY_REFS).map((row) => row.query);
23880
24096
  return {
23881
24097
  state: sharedTokens.length >= 2 ? "observed-overlap" : sharedTokens.length === 1 ? "weak-overlap" : "unavailable",
23882
24098
  sharedTokens,
@@ -23898,10 +24114,10 @@ function repeatedCompetitorPatterns(rows) {
23898
24114
  urlCount: pattern.urlCount,
23899
24115
  sampleUrls: pattern.sampleUrls,
23900
24116
  evidenceRefs: items.flatMap((item) => pattern.sampleUrls.includes(item.row.url) ? [item.evidenceRef] : [])
23901
- }))).sort((left, right) => right.urlCount - left.urlCount || compareText$6(left.domain, right.domain) || compareText$6(left.signature, right.signature));
24117
+ }))).sort((left, right) => right.urlCount - left.urlCount || compareText$7(left.domain, right.domain) || compareText$7(left.signature, right.signature));
23902
24118
  }
23903
24119
  function ownPatterns(acquisition) {
23904
- return clusterPseoTemplates([...new Set(acquisition.sourceRows.rows.flatMap((row) => row.urls))].sort(compareText$6).slice(0, MAX_FIRST_PARTY_PATTERN_URLS), {
24120
+ return clusterPseoTemplates([...new Set(acquisition.sourceRows.rows.flatMap((row) => row.urls))].sort(compareText$7).slice(0, MAX_FIRST_PARTY_PATTERN_URLS), {
23905
24121
  minUrls: 3,
23906
24122
  minShare: 0,
23907
24123
  limit: 50,
@@ -23942,10 +24158,10 @@ function analyzeCompetitorGap(acquisition) {
23942
24158
  const byDomain = /* @__PURE__ */ new Map();
23943
24159
  for (const item of rows) {
23944
24160
  const current = byDomain.get(item.domain);
23945
- if (!current || item.row.rankGroup < current.row.rankGroup || item.row.rankGroup === current.row.rankGroup && compareText$6(item.row.url, current.row.url) < 0) byDomain.set(item.domain, item);
24161
+ if (!current || item.row.rankGroup < current.row.rankGroup || item.row.rankGroup === current.row.rankGroup && compareText$7(item.row.url, current.row.url) < 0) byDomain.set(item.domain, item);
23946
24162
  }
23947
- const retainedRows = [...byDomain.values()].sort((left, right) => left.row.rankGroup - right.row.rankGroup || compareText$6(left.domain, right.domain));
23948
- const best = [...retainedRows].sort((left, right) => observedVolume$1(right.row) - observedVolume$1(left.row) || left.row.rankGroup - right.row.rankGroup || compareText$6(left.domain, right.domain))[0];
24163
+ const retainedRows = [...byDomain.values()].sort((left, right) => left.row.rankGroup - right.row.rankGroup || compareText$7(left.domain, right.domain));
24164
+ const best = [...retainedRows].sort((left, right) => observedVolume$1(right.row) - observedVolume$1(left.row) || left.row.rankGroup - right.row.rankGroup || compareText$7(left.domain, right.domain))[0];
23949
24165
  const firstParty = firstPartyByQuery.get(keyword);
23950
24166
  const relevance = relevanceForKeyword(keyword, acquisition, tokenIndex.index);
23951
24167
  const ownRank = ownRanks.get(keyword) ?? null;
@@ -23989,7 +24205,7 @@ function analyzeCompetitorGap(acquisition) {
23989
24205
  },
23990
24206
  evidenceRefs: retainedRows.map((item) => item.evidenceRef)
23991
24207
  };
23992
- }).sort((left, right) => classificationOrder(left.classification) - classificationOrder(right.classification) || right.competitorCount - left.competitorCount || (value(right.monthlySearchVolume) ?? -1) - (value(left.monthlySearchVolume) ?? -1) || Math.min(...left.competitors.map((item) => item.rank)) - Math.min(...right.competitors.map((item) => item.rank)) || compareText$6(left.keyword, right.keyword));
24208
+ }).sort((left, right) => classificationOrder(left.classification) - classificationOrder(right.classification) || right.competitorCount - left.competitorCount || (value(right.monthlySearchVolume) ?? -1) - (value(left.monthlySearchVolume) ?? -1) || Math.min(...left.competitors.map((item) => item.rank)) - Math.min(...right.competitors.map((item) => item.rank)) || compareText$7(left.keyword, right.keyword));
23993
24209
  return {
23994
24210
  rawRows,
23995
24211
  candidates,
@@ -24008,7 +24224,7 @@ function analyzeCompetitorGap(acquisition) {
24008
24224
  }
24009
24225
  //#endregion
24010
24226
  //#region packages/core/dist/analyze/domain-research/gap-report.js
24011
- const MAX_FINDINGS = 10;
24227
+ const MAX_FINDINGS$2 = 10;
24012
24228
  const MAX_SOURCE_BRIEFS = 5;
24013
24229
  function count(candidates, classification) {
24014
24230
  return candidates.filter((item) => item.classification === classification).length;
@@ -24023,7 +24239,7 @@ function dataStatus(input) {
24023
24239
  function findings$2(input) {
24024
24240
  const result = [];
24025
24241
  for (const [index, candidate] of input.candidates.entries()) {
24026
- if (result.length >= MAX_FINDINGS) break;
24242
+ if (result.length >= MAX_FINDINGS$2) break;
24027
24243
  if (candidate.classification === "relevant-gap-candidate") result.push({
24028
24244
  code: "relevant-competitor-gap",
24029
24245
  evidenceRefs: [`candidates[${index}]`, ...candidate.evidenceRefs],
@@ -24038,7 +24254,7 @@ function findings$2(input) {
24038
24254
  });
24039
24255
  }
24040
24256
  for (const [index, pattern] of input.patterns.entries()) {
24041
- if (result.length >= MAX_FINDINGS) break;
24257
+ if (result.length >= MAX_FINDINGS$2) break;
24042
24258
  result.push({
24043
24259
  code: "competitor-template-pattern",
24044
24260
  evidenceRefs: [`repeatedCompetitorPatterns[${index}]`, ...pattern.evidenceRefs],
@@ -24461,7 +24677,7 @@ async function rankingPagesReport(input, dependencies = {}) {
24461
24677
  impressions: row.impressions,
24462
24678
  averagePosition: row.position
24463
24679
  }] : [];
24464
- }).sort((left, right) => right.impressions - left.impressions || compareText$6(left.url, right.url));
24680
+ }).sort((left, right) => right.impressions - left.impressions || compareText$7(left.url, right.url));
24465
24681
  const firstPartyStatus = !input.site ? "not-requested" : (firstPartyResponse?.rowsFetched ?? 0) >= MAX_GSC_PAGE_ROWS ? "partial" : firstPartyResponse?.rows.length === 0 ? "empty" : "complete";
24466
24682
  const findings = patterns.slice(0, 5).map((pattern, index) => ({
24467
24683
  code: "repeated-ranking-page-pattern",
@@ -24542,7 +24758,7 @@ async function serpCompetitorsReport(input, dependencies = {}) {
24542
24758
  const now = (dependencies.now ?? (() => /* @__PURE__ */ new Date()))();
24543
24759
  const market = validatedMarket(input.market);
24544
24760
  const providerId = validatedProvider(input.provider);
24545
- const keywords = [...new Set(input.keywords.map(normalizedKeyword$1))].filter(Boolean).sort(compareText$6);
24761
+ const keywords = [...new Set(input.keywords.map(normalizedKeyword$1))].filter(Boolean).sort(compareText$7);
24546
24762
  if (keywords.length < 2 || keywords.length > 200 || keywords.some((keyword) => keyword.length > 80 || keyword.split(/\s+/u).length > 10)) throw new SeoError("INVALID_INPUT", "SERP competitor research requires 2 to 200 keywords of at most 80 characters and 10 words.");
24547
24763
  const target = input.targetDomain ? normalizeDomain$1(input.targetDomain) : null;
24548
24764
  const declared = declaredCompetitors(input.declaredCompetitors);
@@ -25085,7 +25301,7 @@ function measurementWindow(input) {
25085
25301
  }
25086
25302
  //#endregion
25087
25303
  //#region packages/core/dist/analyze/experiments/measure-change.js
25088
- const defaultDependencies$5 = {
25304
+ const defaultDependencies$6 = {
25089
25305
  searchAnalytics: querySearchAnalytics,
25090
25306
  googleAnalyticsReport: runGa4Report,
25091
25307
  contentGroup: getContentGroup,
@@ -25103,7 +25319,7 @@ function counterfactualDelta(input) {
25103
25319
  const expectedAfter = input.treatmentBefore * (input.controlAfter / input.controlBefore);
25104
25320
  return fixed(input.treatmentAfter - expectedAfter);
25105
25321
  }
25106
- async function measureChange(input, dependencies = defaultDependencies$5) {
25322
+ async function measureChange(input, dependencies = defaultDependencies$6) {
25107
25323
  const now = dependencies.now();
25108
25324
  const change = (input.id ? getChange(input.id) : void 0) ?? {
25109
25325
  id: "adhoc",
@@ -25826,14 +26042,14 @@ async function verifyInternalLinkCandidate(input) {
25826
26042
  }
25827
26043
  //#endregion
25828
26044
  //#region packages/core/dist/analyze/internal-links.js
25829
- const MAX_GSC_ROWS$1 = 1e5;
25830
- const defaultDependencies$4 = {
26045
+ const MAX_GSC_ROWS$2 = 1e5;
26046
+ const defaultDependencies$5 = {
25831
26047
  searchAnalytics: querySearchAnalytics,
25832
26048
  fetch: fetchPage,
25833
26049
  extract: extractPage,
25834
26050
  now: () => /* @__PURE__ */ new Date()
25835
26051
  };
25836
- async function internalLinksReport(input, dependencies = defaultDependencies$4) {
26052
+ async function internalLinksReport(input, dependencies = defaultDependencies$5) {
25837
26053
  const targetPage = new URL(assertUrlMatchesGscProperty(input.site, input.targetUrl));
25838
26054
  targetPage.hash = "";
25839
26055
  const targetUrl = targetPage.toString();
@@ -25882,7 +26098,7 @@ async function internalLinksReport(input, dependencies = defaultDependencies$4)
25882
26098
  dimensions: ["query", "page"],
25883
26099
  type: "web",
25884
26100
  dataState: "final",
25885
- maxRows: MAX_GSC_ROWS$1,
26101
+ maxRows: MAX_GSC_ROWS$2,
25886
26102
  dimensionFilterGroups: [{
25887
26103
  groupType: "and",
25888
26104
  filters: [{
@@ -25914,7 +26130,7 @@ async function internalLinksReport(input, dependencies = defaultDependencies$4)
25914
26130
  dimensions: ["query", "page"],
25915
26131
  type: "web",
25916
26132
  dataState: "final",
25917
- maxRows: MAX_GSC_ROWS$1
26133
+ maxRows: MAX_GSC_ROWS$2
25918
26134
  }, { refresh: input.refresh }) : {
25919
26135
  rows: [],
25920
26136
  calls: 0,
@@ -25959,8 +26175,8 @@ async function internalLinksReport(input, dependencies = defaultDependencies$4)
25959
26175
  else checkedSources += 1;
25960
26176
  }
25961
26177
  const uncheckedCandidates = Math.max(0, analysis.candidates.length - attemptedSources);
25962
- const targetTruncated = targetRequests.some(({ result }) => result.rowsFetched >= MAX_GSC_ROWS$1);
25963
- const sourceTruncated = sourceResult.rowsFetched >= MAX_GSC_ROWS$1;
26178
+ const targetTruncated = targetRequests.some(({ result }) => result.rowsFetched >= MAX_GSC_ROWS$2);
26179
+ const sourceTruncated = sourceResult.rowsFetched >= MAX_GSC_ROWS$2;
25964
26180
  const invalidRows = analysis.selection.targetInvalidRows + analysis.selection.sourceInvalidRows;
25965
26181
  const structuredWarnings = uniqueInternalLinksWarnings(warnings);
25966
26182
  const dataStatus = target.verification === "technical-issue" ? "target-technical-issue" : target.verification === "failed" ? "partial" : targetRows.length === 0 ? "empty" : targetTruncated || sourceTruncated || invalidRows > 0 ? "partial" : queryCandidates && sourceResult.rows.length === 0 ? "source-empty" : analysis.selection.targetEligibleQueries === 0 || analysis.candidates.length === 0 ? "filtered" : failedChecks > 0 || uncheckedCandidates > 0 ? "partial" : "complete";
@@ -26001,18 +26217,18 @@ async function internalLinksReport(input, dependencies = defaultDependencies$4)
26001
26217
  pageFilter: alias,
26002
26218
  rowsFetched: result.rowsFetched,
26003
26219
  calls: result.calls,
26004
- possiblyTruncated: result.rowsFetched >= MAX_GSC_ROWS$1
26220
+ possiblyTruncated: result.rowsFetched >= MAX_GSC_ROWS$2
26005
26221
  })),
26006
26222
  rowsFetched: targetRequests.reduce((sum, { result }) => sum + result.rowsFetched, 0),
26007
26223
  calls: targetRequests.reduce((sum, { result }) => sum + result.calls, 0),
26008
- maxRowsPerRequest: MAX_GSC_ROWS$1,
26224
+ maxRowsPerRequest: MAX_GSC_ROWS$2,
26009
26225
  possiblyTruncated: targetTruncated
26010
26226
  },
26011
26227
  candidates: {
26012
26228
  queried: queryCandidates,
26013
26229
  rowsFetched: sourceResult.rowsFetched,
26014
26230
  calls: sourceResult.calls,
26015
- maxRows: MAX_GSC_ROWS$1,
26231
+ maxRows: MAX_GSC_ROWS$2,
26016
26232
  possiblyTruncated: sourceTruncated
26017
26233
  },
26018
26234
  completeness: !queryCandidates ? "not-queried" : targetTruncated || sourceTruncated ? "possibly-truncated" : "retained-rows-only"
@@ -26031,7 +26247,7 @@ async function internalLinksReport(input, dependencies = defaultDependencies$4)
26031
26247
  minImpressions,
26032
26248
  resultLimit: limit,
26033
26249
  checkLimit,
26034
- maxGscRowsPerRequest: MAX_GSC_ROWS$1
26250
+ maxGscRowsPerRequest: MAX_GSC_ROWS$2
26035
26251
  },
26036
26252
  target: {
26037
26253
  requestedUrl: target.requestedUrl,
@@ -26321,7 +26537,7 @@ function providerReportError(error) {
26321
26537
  if (error.code === "rate-limit") throw new SeoError("RATE_LIMITED", error.message);
26322
26538
  throw new SeoError("PROVIDER_UNAVAILABLE", error.message);
26323
26539
  }
26324
- function validateInput$3(input) {
26540
+ function validateInput$4(input) {
26325
26541
  if (input.keywords.length < 1 || input.keywords.length > MAX_REPORT_KEYWORDS || input.keywords.some((keyword) => {
26326
26542
  const value = keyword.trim();
26327
26543
  return value.length < 1 || value.length > 80 || value.split(/\s+/u).length > 10;
@@ -26355,7 +26571,7 @@ function findings$1(analysis) {
26355
26571
  }).sort((left, right) => Number(right.code === "recent-demand-increase") - Number(left.code === "recent-demand-increase") || right.recentAverage - left.recentAverage || compareCodepoints$2(left.keyword, right.keyword)).slice(0, 10).map(({ recentAverage: _recentAverage, ...finding }) => finding);
26356
26572
  }
26357
26573
  async function keywordMetricsReport(input, dependencies = {}) {
26358
- const validated = validateInput$3(input);
26574
+ const validated = validateInput$4(input);
26359
26575
  let candidates;
26360
26576
  try {
26361
26577
  candidates = dependencies.candidates ?? await defaultCandidates$2();
@@ -26650,16 +26866,16 @@ function benchmarkDetails(benchmark) {
26650
26866
  positiveUrlSamples: benchmark.positiveUrlSamples
26651
26867
  };
26652
26868
  }
26653
- function boundedInteger$2(input) {
26869
+ function boundedInteger$3(input) {
26654
26870
  const value = Number.isFinite(input.value) ? input.value ?? 0 : input.fallback;
26655
26871
  return Math.min(input.maximum ?? Number.POSITIVE_INFINITY, Math.max(1, Math.floor(value)));
26656
26872
  }
26657
26873
  function analyzeQueryClustersFromRows(input) {
26658
- const minImpressions = boundedInteger$2({
26874
+ const minImpressions = boundedInteger$3({
26659
26875
  value: input.minImpressions,
26660
26876
  fallback: 25
26661
26877
  });
26662
- const limit = boundedInteger$2({
26878
+ const limit = boundedInteger$3({
26663
26879
  value: input.limit,
26664
26880
  fallback: 25,
26665
26881
  maximum: 100
@@ -26778,7 +26994,7 @@ function integerInRange(value, fallback, minimum, maximum) {
26778
26994
  if (value === void 0 || !Number.isFinite(value)) return fallback;
26779
26995
  return Math.min(maximum, Math.max(minimum, Math.floor(value)));
26780
26996
  }
26781
- function compareText$4(left, right) {
26997
+ function compareText$5(left, right) {
26782
26998
  const leftPoints = [...left].map((character) => character.codePointAt(0) ?? 0);
26783
26999
  const rightPoints = [...right].map((character) => character.codePointAt(0) ?? 0);
26784
27000
  for (let index = 0; index < Math.min(leftPoints.length, rightPoints.length); index++) {
@@ -26797,7 +27013,7 @@ function normalizedHttpUrl(value) {
26797
27013
  return;
26798
27014
  }
26799
27015
  }
26800
- function validRow(row, query, url) {
27016
+ function validRow$1(row, query, url) {
26801
27017
  return Boolean(query && url) && Number.isFinite(row.clicks) && Number.isFinite(row.impressions) && Number.isFinite(row.ctr) && Number.isFinite(row.position) && row.clicks >= 0 && row.impressions > 0 && row.clicks <= row.impressions && row.ctr >= 0 && row.ctr <= 1 && row.position > 0;
26802
27018
  }
26803
27019
  function queryFromRow(row) {
@@ -26810,7 +27026,7 @@ function queryFromRow(row) {
26810
27026
  };
26811
27027
  }
26812
27028
  function compareQueries(left, right) {
26813
- return right.impressions - left.impressions || left.position - right.position || right.clicks - left.clicks || compareText$4(left.query, right.query);
27029
+ return right.impressions - left.impressions || left.position - right.position || right.clicks - left.clicks || compareText$5(left.query, right.query);
26814
27030
  }
26815
27031
  function recommendation(input) {
26816
27032
  return {
@@ -26856,7 +27072,7 @@ function pageItem(url, rows) {
26856
27072
  };
26857
27073
  }
26858
27074
  function compareItems$1(left, right) {
26859
- return right.priority.score - left.priority.score || right.impressions - left.impressions || left.position - right.position || compareText$4(left.url, right.url);
27075
+ return right.priority.score - left.priority.score || right.impressions - left.impressions || left.position - right.position || compareText$5(left.url, right.url);
26860
27076
  }
26861
27077
  function analyzeSecondPageRows(input) {
26862
27078
  const minImpressions = integerInRange(input.minImpressions, DEFAULT_MIN_IMPRESSIONS, 0, MAX_MIN_IMPRESSIONS);
@@ -26877,7 +27093,7 @@ function analyzeSecondPageRows(input) {
26877
27093
  for (const row of input.rows) {
26878
27094
  const query = row.keys[0]?.trim() ?? "";
26879
27095
  const url = normalizedHttpUrl(row.keys[1]?.trim() ?? "");
26880
- if (!validRow(row, query, url)) selection.invalidRows++;
27096
+ if (!validRow$1(row, query, url)) selection.invalidRows++;
26881
27097
  else if (row.position <= 10 || row.position > 20) selection.outsidePositionRows++;
26882
27098
  else if (shouldExcludeBrandQuery({
26883
27099
  query,
@@ -26968,8 +27184,8 @@ function analyzeSecondPageRows(input) {
26968
27184
  }
26969
27185
  //#endregion
26970
27186
  //#region packages/core/dist/analyze/keyword-opportunities.js
26971
- const DEFAULT_DAYS = 28;
26972
- const MAX_DAYS = 548;
27187
+ const DEFAULT_DAYS$1 = 28;
27188
+ const MAX_DAYS$2 = 548;
26973
27189
  const DEFAULT_REPORT_LIMIT = 10;
26974
27190
  const MAX_REPORT_LIMIT = 25;
26975
27191
  const DEFAULT_KEYWORD_LIMIT = 30;
@@ -26986,7 +27202,7 @@ const SOURCE_ORDER = [
26986
27202
  "second-page",
26987
27203
  "striking-distance"
26988
27204
  ];
26989
- function compareText$3(left, right) {
27205
+ function compareText$4(left, right) {
26990
27206
  return left < right ? -1 : left > right ? 1 : 0;
26991
27207
  }
26992
27208
  function rounded(value, precision = 3) {
@@ -26998,7 +27214,7 @@ function integerOption$1(input) {
26998
27214
  if (!Number.isInteger(input.value) || input.value < input.minimum || input.value > input.maximum) throw new SeoError("INVALID_INPUT", `${input.label} must be a whole number between ${input.minimum} and ${input.maximum}.`);
26999
27215
  return input.value;
27000
27216
  }
27001
- function validateInput$2(input) {
27217
+ function validateInput$3(input) {
27002
27218
  const site = input.site.trim();
27003
27219
  if (!site || site.length > 2048) throw new SeoError("INVALID_INPUT", "Use a valid Search Console property.");
27004
27220
  if (input.minImpressions !== void 0 && (!Number.isInteger(input.minImpressions) || input.minImpressions < 0 || input.minImpressions > 1e9)) throw new SeoError("INVALID_INPUT", "Minimum impressions must be a whole number between 0 and 1000000000.");
@@ -27016,9 +27232,9 @@ function validateInput$2(input) {
27016
27232
  site,
27017
27233
  days: integerOption$1({
27018
27234
  value: input.days,
27019
- fallback: DEFAULT_DAYS,
27235
+ fallback: DEFAULT_DAYS$1,
27020
27236
  minimum: 1,
27021
- maximum: MAX_DAYS,
27237
+ maximum: MAX_DAYS$2,
27022
27238
  label: "Days"
27023
27239
  }),
27024
27240
  limit: integerOption$1({
@@ -27130,7 +27346,7 @@ function aggregateCandidates(rows) {
27130
27346
  impressions: rounded(row.impressions),
27131
27347
  ctr: rounded(row.ctr, 4),
27132
27348
  averagePosition: rounded(row.position, 2)
27133
- })).sort((left, right) => right.impressions - left.impressions || left.averagePosition - right.averagePosition || compareText$3(left.url, right.url));
27349
+ })).sort((left, right) => right.impressions - left.impressions || left.averagePosition - right.averagePosition || compareText$4(left.url, right.url));
27134
27350
  return [keyword, {
27135
27351
  keyword,
27136
27352
  clicks: rounded(clicks),
@@ -27364,7 +27580,7 @@ function nextSteps(input) {
27364
27580
  return steps;
27365
27581
  }
27366
27582
  async function keywordOpportunitiesReport(input, dependencies = {}) {
27367
- const validated = validateInput$2(input);
27583
+ const validated = validateInput$3(input);
27368
27584
  const now = (dependencies.now ?? (() => /* @__PURE__ */ new Date()))();
27369
27585
  const generatedAt = now.toISOString();
27370
27586
  const range = finalGscDateRange(validated.days, now);
@@ -27732,7 +27948,7 @@ function providerError$2(error) {
27732
27948
  if (error.code === "configuration") throw new SeoError("INVALID_INPUT", error.message);
27733
27949
  throw new SeoError("PROVIDER_UNAVAILABLE", error.message);
27734
27950
  }
27735
- function validateInput$1(input) {
27951
+ function validateInput$2(input) {
27736
27952
  if (input.seeds.length < 1 || input.seeds.length > MAX_RESEARCH_SEEDS || input.seeds.some((seed) => {
27737
27953
  const value = seed.trim();
27738
27954
  return value.length < 1 || value.length > 80 || value.split(/\s+/u).length > 10;
@@ -27785,7 +28001,7 @@ async function keywordResearchReport(input, dependencies = {}) {
27785
28001
  "suggestions"
27786
28002
  ];
27787
28003
  const limit = input.limit ?? 50;
27788
- const validated = validateInput$1({
28004
+ const validated = validateInput$2({
27789
28005
  ...input,
27790
28006
  sources,
27791
28007
  limit
@@ -27868,6 +28084,861 @@ async function keywordResearchReport(input, dependencies = {}) {
27868
28084
  };
27869
28085
  }
27870
28086
  //#endregion
28087
+ //#region packages/core/dist/analyze/local-search/intent.js
28088
+ const NEARBY_PHRASES = [
28089
+ "closest to me",
28090
+ "in my area",
28091
+ "near me",
28092
+ "nearby"
28093
+ ];
28094
+ const UK_POSTCODE = /\b[A-Z]{1,2}\d[A-Z\d]?\s*\d[A-Z]{2}\b/iu;
28095
+ const US_ZIP_CODE = /\b\d{5}(?:-\d{4})?\b/u;
28096
+ const US_ZIP_CONTEXT = /\b(?:around|near|nearby|postal(?:\s+code)?|zip(?:\s+code)?)\b/iu;
28097
+ function normalizeLocalSearchText(value) {
28098
+ return value.normalize("NFKC").trim().replace(/\s+/gu, " ").toLowerCase();
28099
+ }
28100
+ function tokens(value) {
28101
+ return normalizeLocalSearchText(value).match(/[\p{L}\p{N}]+/gu) ?? [];
28102
+ }
28103
+ function includesTokenSequence(source, candidate) {
28104
+ if (candidate.length === 0 || candidate.length > source.length) return false;
28105
+ return source.some((_, start) => candidate.every((token, offset) => source[start + offset] === token));
28106
+ }
28107
+ function termMatcher(terms) {
28108
+ const root = {
28109
+ children: /* @__PURE__ */ new Map(),
28110
+ terms: []
28111
+ };
28112
+ for (const term of terms) {
28113
+ let node = root;
28114
+ for (const token of tokens(term)) {
28115
+ const child = node.children.get(token) ?? {
28116
+ children: /* @__PURE__ */ new Map(),
28117
+ terms: []
28118
+ };
28119
+ node.children.set(token, child);
28120
+ node = child;
28121
+ }
28122
+ node.terms.push(term);
28123
+ }
28124
+ return (source) => {
28125
+ const matches = /* @__PURE__ */ new Set();
28126
+ for (let start = 0; start < source.length; start++) {
28127
+ let node = root;
28128
+ for (let index = start; index < source.length; index++) {
28129
+ node = node.children.get(source[index]);
28130
+ if (!node) break;
28131
+ for (const term of node.terms) matches.add(term);
28132
+ }
28133
+ }
28134
+ return [...matches].sort((left, right) => left < right ? -1 : left > right ? 1 : 0);
28135
+ };
28136
+ }
28137
+ function normalizeLocationTerms(values = []) {
28138
+ return [...new Set(values.map(normalizeLocalSearchText).filter(Boolean))].sort((left, right) => left < right ? -1 : left > right ? 1 : 0);
28139
+ }
28140
+ function createLocalIntentClassifier(locationTerms) {
28141
+ const matchLocations = termMatcher(locationTerms);
28142
+ return (query) => {
28143
+ const queryTokens = tokens(normalizeLocalSearchText(query));
28144
+ const matchedLocations = matchLocations(queryTokens);
28145
+ const matchedNearby = NEARBY_PHRASES.filter((phrase) => includesTokenSequence(queryTokens, tokens(phrase)));
28146
+ const ukPostcode = UK_POSTCODE.exec(query)?.[0] ?? null;
28147
+ const usZipCode = US_ZIP_CONTEXT.test(query) && US_ZIP_CODE.test(query) ? US_ZIP_CODE.exec(query)?.[0] ?? null : null;
28148
+ const postalCode = ukPostcode ?? usZipCode;
28149
+ const classes = [];
28150
+ if (matchedLocations.length > 0) classes.push("named-location");
28151
+ if (matchedNearby.length > 0) classes.push("nearby");
28152
+ if (postalCode) classes.push("postal-code");
28153
+ if (classes.length === 0) return null;
28154
+ return {
28155
+ heuristic: true,
28156
+ method: "explicit-local-intent-v1",
28157
+ classes,
28158
+ matchedTerms: [
28159
+ ...matchedLocations,
28160
+ ...matchedNearby,
28161
+ ...postalCode ? [normalizeLocalSearchText(postalCode)] : []
28162
+ ]
28163
+ };
28164
+ };
28165
+ }
28166
+ //#endregion
28167
+ //#region packages/core/dist/analyze/local-search/analysis.js
28168
+ function compareText$3(left, right) {
28169
+ return left < right ? -1 : left > right ? 1 : 0;
28170
+ }
28171
+ function validRow(row) {
28172
+ const query = row.keys[0]?.trim() ?? "";
28173
+ const page = row.keys[1]?.trim() ?? "";
28174
+ let validPage = false;
28175
+ try {
28176
+ validPage = ["http:", "https:"].includes(new URL(page).protocol);
28177
+ } catch {
28178
+ validPage = false;
28179
+ }
28180
+ if (row.keys.length !== 2 || !query || !validPage || !Number.isFinite(row.clicks) || !Number.isFinite(row.impressions) || !Number.isFinite(row.ctr) || !Number.isFinite(row.position) || row.clicks < 0 || row.impressions <= 0 || row.clicks > row.impressions || row.ctr < 0 || row.ctr > 1 || row.position <= 0) return null;
28181
+ return {
28182
+ query,
28183
+ page,
28184
+ clicks: row.clicks,
28185
+ impressions: row.impressions,
28186
+ ctr: row.ctr,
28187
+ position: row.position
28188
+ };
28189
+ }
28190
+ function rowSignature(row) {
28191
+ return `${row.clicks}\u0000${row.impressions}\u0000${row.ctr}\u0000${row.position}`;
28192
+ }
28193
+ function normalizeRows(rows, selection) {
28194
+ const grouped = /* @__PURE__ */ new Map();
28195
+ for (const source of rows) {
28196
+ const row = validRow(source);
28197
+ if (!row) {
28198
+ selection.invalidRows++;
28199
+ continue;
28200
+ }
28201
+ const key = `${row.query}\u0000${row.page}`;
28202
+ grouped.set(key, [...grouped.get(key) ?? [], row]);
28203
+ }
28204
+ const result = [];
28205
+ for (const group of grouped.values()) {
28206
+ if (new Set(group.map(rowSignature)).size > 1) {
28207
+ selection.conflictingRows += group.length;
28208
+ continue;
28209
+ }
28210
+ selection.exactDuplicateRows += group.length - 1;
28211
+ const row = group[0];
28212
+ if (row) result.push(row);
28213
+ }
28214
+ return result;
28215
+ }
28216
+ function opportunityAction(position, pageCount) {
28217
+ if (pageCount > 1) return "review-page-overlap";
28218
+ if (position <= 3) return "protect-visibility";
28219
+ if (position <= 20) return "improve-existing-page";
28220
+ return "investigate-relevance";
28221
+ }
28222
+ function aggregateQueries(input) {
28223
+ const grouped = /* @__PURE__ */ new Map();
28224
+ for (const row of input.rows) {
28225
+ if (isLowActionabilityQuery(row.query)) {
28226
+ input.selection.lowActionabilityRows++;
28227
+ continue;
28228
+ }
28229
+ if (shouldExcludeBrandQuery({
28230
+ query: row.query,
28231
+ siteUrl: input.site,
28232
+ brandTerms: input.brandTerms,
28233
+ includeBrand: input.includeBrand
28234
+ })) {
28235
+ input.selection.brandRows++;
28236
+ continue;
28237
+ }
28238
+ grouped.set(row.query, [...grouped.get(row.query) ?? [], row]);
28239
+ }
28240
+ const opportunities = [];
28241
+ for (const [query, rows] of grouped) {
28242
+ const intent = input.classify(query);
28243
+ if (!intent) {
28244
+ input.selection.nonLocalRows += rows.length;
28245
+ continue;
28246
+ }
28247
+ const impressions = rows.reduce((sum, row) => sum + row.impressions, 0);
28248
+ if (impressions < input.minImpressions) {
28249
+ input.selection.belowMinimumRows += rows.length;
28250
+ continue;
28251
+ }
28252
+ const clicks = rows.reduce((sum, row) => sum + row.clicks, 0);
28253
+ const averagePosition = rows.reduce((sum, row) => sum + row.position * row.impressions, 0) / impressions;
28254
+ const pages = rows.map((row) => ({
28255
+ url: row.page,
28256
+ clicks: row.clicks,
28257
+ impressions: row.impressions,
28258
+ ctr: row.impressions ? row.clicks / row.impressions : 0,
28259
+ averagePosition: row.position
28260
+ })).sort((left, right) => right.impressions - left.impressions || right.clicks - left.clicks || left.averagePosition - right.averagePosition || compareText$3(left.url, right.url));
28261
+ opportunities.push({
28262
+ query,
28263
+ clicks,
28264
+ impressions,
28265
+ ctr: impressions ? clicks / impressions : 0,
28266
+ averagePosition,
28267
+ intent,
28268
+ action: opportunityAction(averagePosition, pages.length),
28269
+ pages,
28270
+ pageCoverage: {
28271
+ available: pages.length,
28272
+ returned: Math.min(3, pages.length),
28273
+ omitted: Math.max(0, pages.length - 3)
28274
+ }
28275
+ });
28276
+ }
28277
+ return opportunities.sort((left, right) => right.impressions - left.impressions || right.clicks - left.clicks || left.averagePosition - right.averagePosition || compareText$3(left.query, right.query));
28278
+ }
28279
+ function localTemplates(opportunities) {
28280
+ const clusters = clusterPseoTemplates(opportunities.flatMap((item) => item.pages.map((page) => page.url)), {
28281
+ minUrls: 2,
28282
+ minShare: 0,
28283
+ limit: 10,
28284
+ sampleSize: 5
28285
+ });
28286
+ return clusters.map((cluster) => {
28287
+ const matched = opportunities.flatMap((item) => item.pages.filter((page) => templateForUrl(page.url, clusters) === cluster.signature).map((page) => ({
28288
+ query: item.query,
28289
+ page
28290
+ })));
28291
+ return {
28292
+ heuristic: true,
28293
+ signature: cluster.signature,
28294
+ urlCount: cluster.urlCount,
28295
+ sampleUrls: cluster.sampleUrls,
28296
+ queryCount: new Set(matched.map((item) => item.query)).size,
28297
+ clicks: matched.reduce((sum, item) => sum + item.page.clicks, 0),
28298
+ impressions: matched.reduce((sum, item) => sum + item.page.impressions, 0)
28299
+ };
28300
+ });
28301
+ }
28302
+ function analyzeLocalSearchRows(input) {
28303
+ const selection = {
28304
+ sourceRows: input.rows.length,
28305
+ invalidRows: 0,
28306
+ exactDuplicateRows: 0,
28307
+ conflictingRows: 0,
28308
+ lowActionabilityRows: 0,
28309
+ brandRows: 0,
28310
+ nonLocalRows: 0,
28311
+ belowMinimumRows: 0,
28312
+ eligibleQueries: 0,
28313
+ returnedQueries: 0,
28314
+ omittedQueries: 0,
28315
+ limit: input.limit,
28316
+ minImpressions: input.minImpressions
28317
+ };
28318
+ const normalized = normalizeRows(input.rows, selection);
28319
+ const eligible = aggregateQueries({
28320
+ ...input,
28321
+ rows: normalized,
28322
+ selection,
28323
+ classify: createLocalIntentClassifier(input.locationTerms)
28324
+ });
28325
+ selection.eligibleQueries = eligible.length;
28326
+ selection.returnedQueries = Math.min(input.limit, eligible.length);
28327
+ selection.omittedQueries = Math.max(0, eligible.length - input.limit);
28328
+ return {
28329
+ selection,
28330
+ eligibleSummary: {
28331
+ clicks: eligible.reduce((sum, item) => sum + item.clicks, 0),
28332
+ impressions: eligible.reduce((sum, item) => sum + item.impressions, 0),
28333
+ namedLocationQueries: eligible.filter((item) => item.intent.classes.includes("named-location")).length,
28334
+ nearbyQueries: eligible.filter((item) => item.intent.classes.includes("nearby")).length,
28335
+ postalCodeQueries: eligible.filter((item) => item.intent.classes.includes("postal-code")).length,
28336
+ pageOverlapQueries: eligible.filter((item) => item.action === "review-page-overlap").length
28337
+ },
28338
+ eligiblePageCount: new Set(eligible.flatMap((item) => item.pages.map((page) => page.url))).size,
28339
+ opportunities: eligible.slice(0, input.limit).map((item) => ({
28340
+ ...item,
28341
+ pages: item.pages.slice(0, 3)
28342
+ })),
28343
+ templates: localTemplates(eligible)
28344
+ };
28345
+ }
28346
+ //#endregion
28347
+ //#region packages/core/dist/providers/dataforseo/serp-snapshot.js
28348
+ const MAX_SERP_DEPTH = 100;
28349
+ const SERP_ENDPOINT = "v3/serp/google/organic/live/advanced";
28350
+ function safeUrl$1(value) {
28351
+ if (!value) return null;
28352
+ try {
28353
+ const parsed = new URL(value);
28354
+ if (!["http:", "https:"].includes(parsed.protocol)) return null;
28355
+ parsed.username = "";
28356
+ parsed.password = "";
28357
+ return parsed.toString();
28358
+ } catch {
28359
+ return null;
28360
+ }
28361
+ }
28362
+ function validOrganic(item) {
28363
+ return item.type === "organic" && Number.isSafeInteger(item.rank_group) && (item.rank_group ?? 0) > 0 && Number.isSafeInteger(item.rank_absolute) && (item.rank_absolute ?? 0) > 0 && Number.isSafeInteger(item.page) && (item.page ?? 0) > 0 && Boolean(item.domain?.trim()) && safeUrl$1(item.url) !== null;
28364
+ }
28365
+ function organicResult(item) {
28366
+ if (!validOrganic(item)) return null;
28367
+ const url = safeUrl$1(item.url);
28368
+ if (!url) return null;
28369
+ return {
28370
+ rankGroup: item.rank_group,
28371
+ rankAbsolute: item.rank_absolute,
28372
+ page: item.page,
28373
+ domain: item.domain.trim().toLowerCase(),
28374
+ url,
28375
+ title: item.title ?? null,
28376
+ description: item.description ?? null,
28377
+ isFeaturedSnippet: item.is_featured_snippet ?? null
28378
+ };
28379
+ }
28380
+ function checkedAt(value, fallback) {
28381
+ const timestamp = Date.parse(value);
28382
+ return Number.isFinite(timestamp) ? new Date(timestamp).toISOString() : fallback;
28383
+ }
28384
+ function mapDataForSeoSerpSnapshot(input, snapshot, endpoint = SERP_ENDPOINT) {
28385
+ const market = searchMarketSchema.parse(input.market);
28386
+ const keyword = normalizedKeyword$2(input.keyword);
28387
+ const location = locationRequest(market, "serp-snapshot");
28388
+ const result = snapshot.response.tasks.flatMap((task) => task.result ?? []).find((item) => normalizedKeyword$2(item.keyword) === keyword);
28389
+ if (!result) throw new ProviderError({
28390
+ provider: "dataforseo",
28391
+ operation: "serp-snapshot",
28392
+ code: "invalid-response",
28393
+ message: "DataForSEO returned no matching SERP result."
28394
+ });
28395
+ const warnings = [...snapshot.warnings];
28396
+ const items = result.items ?? [];
28397
+ const organicItems = items.filter((item) => item.type === "organic");
28398
+ const mappedOrganicResults = organicItems.flatMap((item) => {
28399
+ const mapped = organicResult(item);
28400
+ return mapped ? [mapped] : [];
28401
+ }).sort((left, right) => left.rankAbsolute - right.rankAbsolute || left.rankGroup - right.rankGroup || compareCodepoints$3(left.url, right.url));
28402
+ const organicResults = mappedOrganicResults.slice(0, input.depth);
28403
+ const invalidRows = organicItems.length - mappedOrganicResults.length;
28404
+ const resultCapped = mappedOrganicResults.length > input.depth;
28405
+ const invalidResultCount = result.se_results_count !== null && result.se_results_count !== void 0 && result.se_results_count < organicResults.length;
28406
+ if (invalidRows > 0) warnings.push({
28407
+ code: "invalid-organic-results",
28408
+ field: "organicResults",
28409
+ message: `DataForSEO returned ${invalidRows} organic result${invalidRows === 1 ? "" : "s"} without a valid rank, domain, or URL.`
28410
+ });
28411
+ if (!Number.isFinite(Date.parse(result.datetime))) warnings.push({
28412
+ code: "invalid-serp-observation-time",
28413
+ field: "checkedAt",
28414
+ message: "DataForSEO returned an invalid SERP observation time; the local response time was retained."
28415
+ });
28416
+ const checkUrl = safeUrl$1(result.check_url);
28417
+ if (result.check_url && !checkUrl) warnings.push({
28418
+ code: "invalid-serp-check-url",
28419
+ field: "checkUrl",
28420
+ message: "DataForSEO returned an invalid SERP check URL."
28421
+ });
28422
+ if (invalidResultCount) warnings.push({
28423
+ code: "invalid-serp-result-count",
28424
+ field: "resultCount",
28425
+ message: "DataForSEO returned an estimated result count below the retained organic result count; the estimate was discarded."
28426
+ });
28427
+ const features = [.../* @__PURE__ */ new Set([...result.item_types ?? [], ...items.map((item) => item.type)])].sort(compareCodepoints$3);
28428
+ return {
28429
+ schemaVersion: 1,
28430
+ provider: "dataforseo",
28431
+ capability: "serp-snapshot",
28432
+ data: {
28433
+ keyword,
28434
+ effectiveKeyword: normalizedKeyword$2(result.spell?.keyword ?? result.keyword),
28435
+ searchEngineDomain: result.se_domain?.trim().toLowerCase() ?? null,
28436
+ checkedAt: checkedAt(result.datetime, snapshot.observedAt),
28437
+ checkUrl,
28438
+ resultCount: invalidResultCount ? null : result.se_results_count ?? null,
28439
+ pagesCount: result.pages_count === void 0 ? null : result.pages_count,
28440
+ features,
28441
+ organicResults
28442
+ },
28443
+ observedAt: snapshot.observedAt,
28444
+ market: {
28445
+ ...market,
28446
+ device: market.device ?? "desktop"
28447
+ },
28448
+ coverage: {
28449
+ requestedRows: input.depth,
28450
+ returnedRows: snapshot.returnedRows,
28451
+ retainedRows: organicResults.length,
28452
+ invalidRows,
28453
+ providerTotalRows: invalidResultCount ? null : result.se_results_count ?? null,
28454
+ completeness: invalidRows > 0 || invalidResultCount ? "partial" : resultCapped ? "capped" : "complete",
28455
+ nextCursor: null
28456
+ },
28457
+ cache: snapshot.cache,
28458
+ cost: snapshot.cost,
28459
+ request: {
28460
+ operation: "serp-snapshot",
28461
+ endpoint,
28462
+ limit: input.depth,
28463
+ filters: {
28464
+ countryCode: market.countryCode,
28465
+ languageCode: market.languageCode,
28466
+ device: market.device ?? "desktop",
28467
+ ...location.locationCode !== void 0 ? { locationCode: location.locationCode } : { locationName: location.locationName }
28468
+ },
28469
+ sort: ["rankAbsolute:ascending", "url:codepoint-ascending"]
28470
+ },
28471
+ warnings
28472
+ };
28473
+ }
28474
+ var DataForSeoSerpSnapshotProvider = class {
28475
+ provider = "dataforseo";
28476
+ capabilitySupport = [{
28477
+ capability: "serp-snapshot",
28478
+ status: "available",
28479
+ markets: [{
28480
+ searchEngines: ["google"],
28481
+ devices: ["desktop", "mobile"],
28482
+ location: "any"
28483
+ }]
28484
+ }];
28485
+ client;
28486
+ constructor(options = {}) {
28487
+ this.client = options.client ?? new DataForSeoClient(options);
28488
+ }
28489
+ async serpSnapshot(input) {
28490
+ const market = searchMarketSchema.parse(input.market);
28491
+ if (market.searchEngine !== "google") throw new ProviderError({
28492
+ provider: "dataforseo",
28493
+ operation: "serp-snapshot",
28494
+ code: "configuration",
28495
+ message: "DataForSEO SERP snapshots currently support Google."
28496
+ });
28497
+ if (!Number.isSafeInteger(input.depth) || input.depth < 1 || input.depth > MAX_SERP_DEPTH) throw new ProviderError({
28498
+ provider: "dataforseo",
28499
+ operation: "serp-snapshot",
28500
+ code: "configuration",
28501
+ message: "SERP depth must be from 1 to 100."
28502
+ });
28503
+ const keyword = normalizedKeyword$2(input.keyword);
28504
+ if (!keyword || keyword.length > 80 || keyword.split(/\s+/u).length > 10) throw new ProviderError({
28505
+ provider: "dataforseo",
28506
+ operation: "serp-snapshot",
28507
+ code: "configuration",
28508
+ message: "SERP snapshots require a keyword of at most 80 characters and 10 words."
28509
+ });
28510
+ const context = input.context ?? {
28511
+ reportId: "serp-results",
28512
+ reportRunId: randomUUID()
28513
+ };
28514
+ const location = locationRequest(market, "serp-snapshot");
28515
+ return mapDataForSeoSerpSnapshot(input, await this.client.serpLive({
28516
+ keyword,
28517
+ languageCode: market.languageCode.split("-")[0],
28518
+ ...location,
28519
+ device: market.device ?? "desktop",
28520
+ depth: input.depth,
28521
+ refresh: input.refresh,
28522
+ context
28523
+ }));
28524
+ }
28525
+ };
28526
+ //#endregion
28527
+ //#region packages/core/dist/analyze/serp-results.js
28528
+ const MAX_REPORT_DEPTH = 100;
28529
+ function compareCodepoints(left, right) {
28530
+ return left < right ? -1 : left > right ? 1 : 0;
28531
+ }
28532
+ function snapshotProvider(candidate) {
28533
+ return "serpSnapshot" in candidate && typeof candidate.serpSnapshot === "function" ? candidate : null;
28534
+ }
28535
+ async function defaultCandidates() {
28536
+ return [{
28537
+ adapter: new DataForSeoSerpSnapshotProvider(),
28538
+ connected: Boolean(await readDataForSeoCredentials()),
28539
+ priority: 10
28540
+ }];
28541
+ }
28542
+ function providerError$1(error) {
28543
+ if (!(error instanceof ProviderError)) throw error;
28544
+ if (error.code === "rate-limit") throw new SeoError("RATE_LIMITED", error.message);
28545
+ if (error.code === "configuration") throw new SeoError("INVALID_INPUT", error.message);
28546
+ throw new SeoError("PROVIDER_UNAVAILABLE", error.message);
28547
+ }
28548
+ function validateInput$1(input) {
28549
+ const keyword = input.keyword.trim().replace(/\s+/gu, " ");
28550
+ if (!keyword || keyword.length > 80 || keyword.split(/\s+/u).length > 10) throw new SeoError("INVALID_INPUT", "SERP results requires a keyword of at most 80 characters and 10 words.");
28551
+ if (!Number.isSafeInteger(input.depth) || input.depth < 1 || input.depth > MAX_REPORT_DEPTH) throw new SeoError("INVALID_INPUT", `SERP result depth must be from 1 to ${MAX_REPORT_DEPTH}.`);
28552
+ const market = searchMarketSchema.safeParse(input.market);
28553
+ if (!market.success) throw new SeoError("INVALID_INPUT", "Use a valid search market.");
28554
+ const provider = input.provider ? providerIdSchema.safeParse(input.provider) : void 0;
28555
+ if (provider && !provider.success) throw new SeoError("INVALID_INPUT", "Use a supported SERP provider.");
28556
+ return {
28557
+ keyword,
28558
+ market: market.data,
28559
+ provider: provider?.data
28560
+ };
28561
+ }
28562
+ function reportStatus$1(evidence) {
28563
+ if (evidence.coverage.completeness === "unavailable" || evidence.coverage.completeness === "invalid") return "unavailable";
28564
+ return evidence.coverage.completeness === "complete" ? "complete" : "partial";
28565
+ }
28566
+ function domainSummaries(snapshot) {
28567
+ const byDomain = /* @__PURE__ */ new Map();
28568
+ for (const result of snapshot.organicResults) byDomain.set(result.domain, [...byDomain.get(result.domain) ?? [], result.rankAbsolute]);
28569
+ return [...byDomain.entries()].map(([domain, ranks]) => ({
28570
+ domain,
28571
+ resultCount: ranks.length,
28572
+ ranks: [...ranks].sort((left, right) => left - right)
28573
+ })).sort((left, right) => right.resultCount - left.resultCount || (left.ranks[0] ?? Number.MAX_SAFE_INTEGER) - (right.ranks[0] ?? Number.MAX_SAFE_INTEGER) || compareCodepoints(left.domain, right.domain));
28574
+ }
28575
+ function reportFindings(snapshot, domains) {
28576
+ const findings = [];
28577
+ if (snapshot.effectiveKeyword !== snapshot.keyword) findings.push({
28578
+ code: "query-correction",
28579
+ evidenceRef: "evidence.data.effectiveKeyword",
28580
+ principle: "A corrected query changes the observed result context and does not prove the original wording has the same intent.",
28581
+ detail: `The provider observed results for ${snapshot.effectiveKeyword} after the requested query ${snapshot.keyword}.`
28582
+ });
28583
+ for (const [index, domain] of domains.entries()) {
28584
+ if (domain.resultCount <= 1) continue;
28585
+ findings.push({
28586
+ code: "repeated-domain",
28587
+ evidenceRef: `domains[${index}]`,
28588
+ principle: "Repeated results are descriptive snapshot evidence, not a domain strength or quality score.",
28589
+ detail: `${domain.domain} appears ${domain.resultCount} times at absolute ranks ${domain.ranks.join(", ")}.`
28590
+ });
28591
+ }
28592
+ return findings.slice(0, 10);
28593
+ }
28594
+ async function serpResultsReport(input, dependencies = {}) {
28595
+ const depth = input.depth ?? 10;
28596
+ const validated = validateInput$1({
28597
+ ...input,
28598
+ depth
28599
+ });
28600
+ let candidates;
28601
+ try {
28602
+ candidates = dependencies.candidates ?? await defaultCandidates();
28603
+ } catch (error) {
28604
+ return providerError$1(error);
28605
+ }
28606
+ const resolution = resolveProvider({
28607
+ capability: "serp-snapshot",
28608
+ market: validated.market,
28609
+ candidates,
28610
+ provider: validated.provider
28611
+ });
28612
+ if (resolution.status === "unavailable") throw new SeoError("PROVIDER_UNAVAILABLE", resolution.reason === "provider-not-connected" ? "No connected provider can return live SERP results. Run `seo providers dataforseo connect` first." : validated.provider ? `${validated.provider} cannot return live SERP results for this market.` : "No configured provider can return live SERP results for this market.");
28613
+ const provider = snapshotProvider(resolution.provider);
28614
+ if (!provider) throw new SeoError("PROVIDER_UNAVAILABLE", "The selected provider has no live SERP implementation.");
28615
+ let evidence;
28616
+ try {
28617
+ evidence = await provider.serpSnapshot({
28618
+ keyword: validated.keyword,
28619
+ market: validated.market,
28620
+ depth,
28621
+ refresh: input.refresh,
28622
+ context: {
28623
+ projectId: input.context?.projectId ?? input.projectId,
28624
+ reportId: input.context?.reportId ?? "serp-results",
28625
+ reportRunId: input.context?.reportRunId ?? randomUUID()
28626
+ }
28627
+ });
28628
+ } catch (error) {
28629
+ return providerError$1(error);
28630
+ }
28631
+ const domains = domainSummaries(evidence.data);
28632
+ const dataStatus = reportStatus$1(evidence);
28633
+ const generatedAt = (dependencies.now ?? (() => /* @__PURE__ */ new Date()))().toISOString();
28634
+ const correctedQuery = evidence.data.effectiveKeyword !== evidence.data.keyword;
28635
+ return {
28636
+ schemaVersion: 1,
28637
+ generatedAt,
28638
+ dataStatus,
28639
+ market: evidence.market,
28640
+ summary: {
28641
+ keyword: evidence.data.keyword,
28642
+ effectiveKeyword: evidence.data.effectiveKeyword,
28643
+ requestedDepth: depth,
28644
+ organicResults: evidence.data.organicResults.length,
28645
+ uniqueDomains: domains.length,
28646
+ observedFeatures: evidence.data.features.length,
28647
+ correctedQuery,
28648
+ verdict: `${evidence.data.organicResults.length} organic results across ${domains.length} domains were retained from the ${evidence.market.device ?? "desktop"} snapshot.`
28649
+ },
28650
+ evidence,
28651
+ domains,
28652
+ findings: reportFindings(evidence.data, domains),
28653
+ caveats: [
28654
+ "This is one market, location, language, and device-specific result snapshot; it is not rank history.",
28655
+ "Search results can change between checks and may differ from a person's signed-in or personalized results.",
28656
+ "The provider result count is an estimate and may be missing; retained organic rows are bounded by the requested depth.",
28657
+ "Repeated domains, result features, titles, and snippets do not establish content quality, authority, or ranking feasibility."
28658
+ ],
28659
+ nextSteps: [
28660
+ "Review the retained pages and result types before deciding whether the query fits an existing page, a new page, or no page.",
28661
+ "Keep Search Console average position separate from these exact snapshot ranks when comparing first-party performance.",
28662
+ "Repeat the same market and device check later if the decision depends on result stability."
28663
+ ]
28664
+ };
28665
+ }
28666
+ //#endregion
28667
+ //#region packages/core/dist/analyze/local-search/report.js
28668
+ const MAX_DAYS$1 = 548;
28669
+ const MAX_ROWS = 5e4;
28670
+ function boundedInteger$2(input) {
28671
+ const value = input.value ?? input.fallback;
28672
+ if (!Number.isSafeInteger(value) || value < input.minimum || value > input.maximum) throw new SeoError("INVALID_INPUT", `${input.label} must be a whole number between ${input.minimum} and ${input.maximum}.`);
28673
+ return value;
28674
+ }
28675
+ function validateInput(input) {
28676
+ const site = input.site.trim();
28677
+ if (!site) throw new SeoError("INVALID_INPUT", "site must not be empty.");
28678
+ if (input.locationTerms && (input.locationTerms.length > 100 || input.locationTerms.some((term) => !term.trim() || term.length > 100))) throw new SeoError("INVALID_INPUT", "locationTerms must contain at most 100 non-empty terms of 100 characters or fewer.");
28679
+ if (input.brandTerms && (input.brandTerms.length > 20 || input.brandTerms.some((term) => !term.trim() || term.length > 200))) throw new SeoError("INVALID_INPUT", "brandTerms must contain at most 20 non-empty terms of 200 characters or fewer.");
28680
+ const includeSerps = input.includeSerps ?? false;
28681
+ let market;
28682
+ if (input.market) {
28683
+ const parsed = searchMarketSchema.safeParse(input.market);
28684
+ if (!parsed.success) throw new SeoError("INVALID_INPUT", "Use a valid search market.");
28685
+ market = parsed.data;
28686
+ }
28687
+ if (includeSerps && !market?.location) throw new SeoError("INVALID_INPUT", "Local SERP evidence requires a country, language, and canonical location.");
28688
+ if (!includeSerps && (input.market || input.provider || input.serpLimit !== void 0 || input.serpDepth !== void 0)) throw new SeoError("INVALID_INPUT", "Set includeSerps to true before passing market, provider, or SERP options.");
28689
+ return {
28690
+ site,
28691
+ days: boundedInteger$2({
28692
+ value: input.days,
28693
+ fallback: 28,
28694
+ minimum: 1,
28695
+ maximum: MAX_DAYS$1,
28696
+ label: "days"
28697
+ }),
28698
+ locationTerms: normalizeLocationTerms(input.locationTerms),
28699
+ minImpressions: boundedInteger$2({
28700
+ value: input.minImpressions,
28701
+ fallback: 1,
28702
+ minimum: 0,
28703
+ maximum: 1e9,
28704
+ label: "minImpressions"
28705
+ }),
28706
+ limit: boundedInteger$2({
28707
+ value: input.limit,
28708
+ fallback: 25,
28709
+ minimum: 1,
28710
+ maximum: 100,
28711
+ label: "limit"
28712
+ }),
28713
+ maxRows: boundedInteger$2({
28714
+ value: input.maxRows,
28715
+ fallback: MAX_ROWS,
28716
+ minimum: 1,
28717
+ maximum: MAX_ROWS,
28718
+ label: "maxRows"
28719
+ }),
28720
+ includeSerps,
28721
+ market,
28722
+ serpLimit: boundedInteger$2({
28723
+ value: input.serpLimit,
28724
+ fallback: 3,
28725
+ minimum: 1,
28726
+ maximum: 3,
28727
+ label: "serpLimit"
28728
+ }),
28729
+ serpDepth: boundedInteger$2({
28730
+ value: input.serpDepth,
28731
+ fallback: 10,
28732
+ minimum: 1,
28733
+ maximum: 20,
28734
+ label: "serpDepth"
28735
+ })
28736
+ };
28737
+ }
28738
+ function emptyCost() {
28739
+ return {
28740
+ currency: "USD",
28741
+ estimatedMicros: 0,
28742
+ actualMicros: 0,
28743
+ taskIds: []
28744
+ };
28745
+ }
28746
+ function aggregateCosts(costs) {
28747
+ const estimated = costs.map((cost) => cost.estimatedMicros);
28748
+ const actual = costs.map((cost) => cost.actualMicros);
28749
+ return {
28750
+ currency: "USD",
28751
+ estimatedMicros: estimated.some((value) => value === null) ? null : estimated.reduce((sum, value) => sum + (value ?? 0), 0),
28752
+ actualMicros: actual.some((value) => value === null) ? null : actual.reduce((sum, value) => sum + (value ?? 0), 0),
28753
+ taskIds: [...new Set(costs.flatMap((cost) => cost.taskIds))].sort()
28754
+ };
28755
+ }
28756
+ function costsWithUnknownRequest(costs) {
28757
+ return {
28758
+ currency: "USD",
28759
+ estimatedMicros: null,
28760
+ actualMicros: null,
28761
+ taskIds: [...new Set(costs.flatMap((cost) => cost.taskIds))].sort()
28762
+ };
28763
+ }
28764
+ async function acquireSerps(input) {
28765
+ const selected = input.queries.slice(0, input.limit);
28766
+ const selection = {
28767
+ availableQueries: input.queries.length,
28768
+ requestedQueries: selected.length,
28769
+ omittedQueries: Math.max(0, input.queries.length - selected.length),
28770
+ limit: input.limit,
28771
+ depth: input.depth,
28772
+ method: "highest-impression-local-queries-v1"
28773
+ };
28774
+ if (!input.requested) return {
28775
+ requested: false,
28776
+ status: "not-requested",
28777
+ selection,
28778
+ market: null,
28779
+ reports: [],
28780
+ cost: emptyCost(),
28781
+ reason: "Local SERP snapshots were not requested, so no paid provider work was attempted."
28782
+ };
28783
+ if (selected.length === 0) return {
28784
+ requested: true,
28785
+ status: "skipped",
28786
+ selection,
28787
+ market: input.market ?? null,
28788
+ reports: [],
28789
+ cost: emptyCost(),
28790
+ reason: "No retained local-intent query was available for a SERP snapshot."
28791
+ };
28792
+ if (!input.market) throw new SeoError("INVALID_INPUT", "Local SERP evidence requires a market.");
28793
+ const reports = [];
28794
+ try {
28795
+ for (const query of selected) reports.push(await input.run({
28796
+ keyword: query,
28797
+ market: input.market,
28798
+ depth: input.depth,
28799
+ provider: input.provider,
28800
+ projectId: input.projectId,
28801
+ context: {
28802
+ reportId: "local-search-demand",
28803
+ reportRunId: input.reportRunId
28804
+ },
28805
+ refresh: input.refresh
28806
+ }));
28807
+ } catch (error) {
28808
+ if (error instanceof SeoError && (error.code === "PROVIDER_UNAVAILABLE" || error.code === "RATE_LIMITED")) return {
28809
+ requested: true,
28810
+ status: reports.length > 0 ? "partial" : "unavailable",
28811
+ selection,
28812
+ market: input.market,
28813
+ reports,
28814
+ cost: costsWithUnknownRequest(reports.map((report) => report.evidence.cost)),
28815
+ reason: "First-party local demand remains available, but not every requested SERP snapshot could be acquired.",
28816
+ error: {
28817
+ code: error.code,
28818
+ message: error.message,
28819
+ retryable: error.retryable
28820
+ }
28821
+ };
28822
+ throw error;
28823
+ }
28824
+ return {
28825
+ requested: true,
28826
+ status: reports.every((report) => report.dataStatus === "complete") ? "complete" : "partial",
28827
+ selection,
28828
+ market: input.market,
28829
+ reports,
28830
+ cost: aggregateCosts(reports.map((report) => report.evidence.cost))
28831
+ };
28832
+ }
28833
+ async function localSearchReport(input, dependencies = {}) {
28834
+ const options = validateInput(input);
28835
+ const now = (dependencies.now ?? (() => /* @__PURE__ */ new Date()))();
28836
+ const generatedAt = now.toISOString();
28837
+ const range = finalGscDateRange(options.days, now);
28838
+ const source = await (dependencies.searchAnalytics ?? querySearchAnalytics)(options.site, {
28839
+ ...range,
28840
+ dimensions: ["query", "page"],
28841
+ type: "web",
28842
+ dataState: "final",
28843
+ maxRows: options.maxRows
28844
+ }, { refresh: input.refresh });
28845
+ const analysis = analyzeLocalSearchRows({
28846
+ rows: source.rows,
28847
+ site: options.site,
28848
+ locationTerms: options.locationTerms,
28849
+ brandTerms: input.brandTerms,
28850
+ includeBrand: input.includeBrand,
28851
+ minImpressions: options.minImpressions,
28852
+ limit: options.limit
28853
+ });
28854
+ const serpEvidence = await acquireSerps({
28855
+ requested: options.includeSerps,
28856
+ queries: analysis.opportunities.map((item) => item.query),
28857
+ market: options.market,
28858
+ provider: input.provider,
28859
+ projectId: input.projectId,
28860
+ refresh: input.refresh,
28861
+ limit: options.serpLimit,
28862
+ depth: options.serpDepth,
28863
+ reportRunId: randomUUID(),
28864
+ run: dependencies.serpResults ?? serpResultsReport
28865
+ });
28866
+ const possiblyTruncated = source.rowsFetched >= options.maxRows;
28867
+ const sourceEmpty = source.rows.length === 0;
28868
+ const filtered = !sourceEmpty && analysis.selection.eligibleQueries === 0;
28869
+ const partial = possiblyTruncated || serpEvidence.status === "partial" || serpEvidence.status === "unavailable";
28870
+ const opportunities = analysis.opportunities;
28871
+ const localPackSnapshots = serpEvidence.reports.filter((report) => report.evidence.data.features.includes("local_pack")).length;
28872
+ const dataStatus = sourceEmpty ? "empty" : partial ? "partial" : filtered ? "filtered" : "complete";
28873
+ const warnings = [];
28874
+ if (possiblyTruncated) warnings.push(`Search Console reached the ${options.maxRows}-row retention cap. Lower-volume local queries may be missing.`);
28875
+ if (analysis.selection.invalidRows > 0) warnings.push(`${analysis.selection.invalidRows} malformed Search Console rows were excluded.`);
28876
+ if (analysis.selection.conflictingRows > 0) warnings.push(`${analysis.selection.conflictingRows} conflicting duplicate Search Console rows were excluded.`);
28877
+ if (serpEvidence.error) warnings.push(serpEvidence.error.message);
28878
+ const nextSteps = [];
28879
+ if (!options.includeSerps) nextSteps.push("If the shortlisted queries justify paid verification, rerun with includeSerps true and an exact location and device to inspect current local result features and organic competitors.");
28880
+ else if (serpEvidence.status === "unavailable") nextSteps.push("Check the provider connection, market support, and local spend limits before retrying the missing SERP snapshots.");
28881
+ if (analysis.templates.length > 0) nextSteps.push("Run pseo-audit on the strongest repeated local page pattern before expanding it; verify source fields, page uniqueness, indexability, and representative output.");
28882
+ if (opportunities.some((item) => item.action === "review-page-overlap")) nextSteps.push("Review overlapping pages query by query before consolidating anything; multiple retained pages do not by themselves prove harmful cannibalisation.");
28883
+ nextSteps.push("Use location terms for the actual service area and repeat the report for materially different markets or devices instead of treating one area as representative.");
28884
+ return {
28885
+ schemaVersion: 1,
28886
+ methodology: "local-search-demand-v1",
28887
+ site: options.site,
28888
+ generatedAt,
28889
+ range,
28890
+ rangeDays: options.days,
28891
+ dataStatus,
28892
+ source: {
28893
+ provider: "google-search-console",
28894
+ dimensions: ["query", "page"],
28895
+ searchType: "web",
28896
+ dataState: "final",
28897
+ rowsFetched: source.rowsFetched,
28898
+ calls: source.calls,
28899
+ maxRows: options.maxRows,
28900
+ possiblyTruncated,
28901
+ completeness: possiblyTruncated ? "possibly-truncated" : "retained-query-page-rows-only"
28902
+ },
28903
+ methodologyDetails: {
28904
+ intentMethod: "explicit-local-intent-v1",
28905
+ suppliedLocationTerms: options.locationTerms,
28906
+ automaticPatterns: [
28907
+ "nearby-phrases",
28908
+ "uk-postcodes",
28909
+ "contextual-us-zip-codes"
28910
+ ],
28911
+ opportunityOrder: "impressions-clicks-position-query-v1",
28912
+ templateMethod: "pseo-url-template-clustering-v1"
28913
+ },
28914
+ selection: analysis.selection,
28915
+ summary: {
28916
+ localQueries: analysis.selection.eligibleQueries,
28917
+ returnedQueries: opportunities.length,
28918
+ pages: analysis.eligiblePageCount,
28919
+ ...analysis.eligibleSummary,
28920
+ templates: analysis.templates.length,
28921
+ serpSnapshots: serpEvidence.reports.length,
28922
+ localPackSnapshots,
28923
+ verdict: sourceEmpty ? "Search Console returned no retained query/page rows for this date window." : filtered ? "No retained query/page rows matched the supplied terms or automatic local-intent patterns." : `${countLabel(analysis.selection.eligibleQueries, "local-intent query")} found; ${countLabel(opportunities.length, "query")} returned for review.`
28924
+ },
28925
+ opportunities,
28926
+ templates: analysis.templates,
28927
+ serpEvidence,
28928
+ warnings,
28929
+ caveats: [
28930
+ "Search Console exposes retained top query/page rows and does not guarantee every query. A row cap means lower-volume local demand may be missing.",
28931
+ "Named-place, proximity, and postal-code matching are explicit heuristics. They can miss implicit local intent and postal-looking non-location terms.",
28932
+ "A place in a query describes query wording, not the searcher's physical location. Search Console average position is not an exact local rank.",
28933
+ "A Google Analytics city or region describes measured users under Analytics rules; it is not joined here because it cannot prove which search query brought a user.",
28934
+ "A local_pack feature in a live snapshot proves only that the result type appeared in that market, device, and observation. It does not expose or validate every business listing.",
28935
+ "Repeated URL patterns and page overlap are review signals, not proof that more pages should exist or that consolidation is required.",
28936
+ input.includeBrand ? "Brand queries were included when they matched the local-intent rules." : "Brand-like queries were excluded using the site and supplied brand terms."
28937
+ ],
28938
+ nextSteps
28939
+ };
28940
+ }
28941
+ //#endregion
27871
28942
  //#region packages/core/dist/analyze/monitoring/crawl-recommendations.js
27872
28943
  function statusMovedToError(item) {
27873
28944
  return item.kind !== "removed" && (item.after?.status ?? 0) >= 400 && (item.before?.status ?? 200) < 400;
@@ -28553,7 +29624,7 @@ function analyzeIndexCoverageSignals(input) {
28553
29624
  const MAX_SITEMAP_DOCUMENTS = 20;
28554
29625
  const MAX_SEARCH_ROWS = 25e4;
28555
29626
  const MAX_SITEMAP_URLS = 25e4;
28556
- const defaultDependencies$3 = {
29627
+ const defaultDependencies$4 = {
28557
29628
  loadCrawl: loadCrawlReport,
28558
29629
  latestCrawl: latestCrawlReport,
28559
29630
  searchAnalytics: querySearchAnalytics,
@@ -28585,7 +29656,7 @@ function sitemapCompleteness(input) {
28585
29656
  if (input.inventoryTruncated) return "truncated";
28586
29657
  return input.results.every((result) => result.dataStatus === "complete") ? "complete" : "partial";
28587
29658
  }
28588
- async function indexCoverageSignals(input, dependencies = defaultDependencies$3) {
29659
+ async function indexCoverageSignals(input, dependencies = defaultDependencies$4) {
28589
29660
  const days = integerOption$2({
28590
29661
  value: input.days,
28591
29662
  fallback: 90,
@@ -29068,7 +30139,7 @@ function pruneIndexWatchSnapshots(rootSite, url, database = getDb()) {
29068
30139
  WHERE attempts.attempt_rank > ? AND latest_success.id IS NULL
29069
30140
  )`).run(rootSite, url, rootSite, url, 20).changes;
29070
30141
  }
29071
- const defaultDependencies$2 = {
30142
+ const defaultDependencies$3 = {
29072
30143
  inspectUrl,
29073
30144
  now: () => /* @__PURE__ */ new Date(),
29074
30145
  store: {
@@ -29113,7 +30184,7 @@ function isFatalBatchError(error) {
29113
30184
  "AUTH_REQUIRED"
29114
30185
  ].includes(error.code);
29115
30186
  }
29116
- async function indexWatch(input, dependencies = defaultDependencies$2) {
30187
+ async function indexWatch(input, dependencies = defaultDependencies$3) {
29117
30188
  const languageCode = validateLanguageCode(input.languageCode);
29118
30189
  const dailyLimit = integerOption$2({
29119
30190
  value: input.dailyLimit,
@@ -31208,9 +32279,9 @@ function pseoSampleUrls(report) {
31208
32279
  }
31209
32280
  //#endregion
31210
32281
  //#region packages/core/dist/analyze/pseo/audit.js
31211
- const MAX_GSC_ROWS = 5e4;
32282
+ const MAX_GSC_ROWS$1 = 5e4;
31212
32283
  const DEFAULT_SITEMAP_URLS = 5e4;
31213
- const defaultDependencies$1 = {
32284
+ const defaultDependencies$2 = {
31214
32285
  searchAnalytics: querySearchAnalytics,
31215
32286
  fetchPage,
31216
32287
  inspectUrl,
@@ -31412,7 +32483,7 @@ async function inspectSample(input) {
31412
32483
  };
31413
32484
  }
31414
32485
  }
31415
- async function acquirePseoAuditEvidence(input, dependencies = defaultDependencies$1) {
32486
+ async function acquirePseoAuditEvidence(input, dependencies = defaultDependencies$2) {
31416
32487
  const options = pseoAuditOptions(input);
31417
32488
  const now = dependencies.now();
31418
32489
  const generatedAt = now.toISOString();
@@ -31431,7 +32502,7 @@ async function acquirePseoAuditEvidence(input, dependencies = defaultDependencie
31431
32502
  type: "web",
31432
32503
  dataState: "final",
31433
32504
  aggregationType: "auto",
31434
- maxRows: MAX_GSC_ROWS
32505
+ maxRows: MAX_GSC_ROWS$1
31435
32506
  };
31436
32507
  input.progress?.(`Fetching ${options.days} days of GSC page and query/page rows`);
31437
32508
  const [pageResponse, queryPageResponse] = await Promise.all([dependencies.searchAnalytics(input.site, {
@@ -31482,7 +32553,7 @@ function buildPseoAuditFromAcquisition(input, acquisition, evidence) {
31482
32553
  minimumTemplateImpressions: options.minimumTemplateImpressions,
31483
32554
  crawlSamplesPerTemplate: options.crawlSamples,
31484
32555
  inspectionSamplesPerTemplate: options.inspectSamples,
31485
- maxRowsPerRequest: MAX_GSC_ROWS,
32556
+ maxRowsPerRequest: MAX_GSC_ROWS$1,
31486
32557
  pageRowsFetched: acquisition.pageRowsFetched,
31487
32558
  queryPageRowsFetched: acquisition.queryPageRowsFetched,
31488
32559
  sitemapsRequested: input.sitemaps?.length ?? 0,
@@ -31493,7 +32564,7 @@ function buildPseoAuditFromAcquisition(input, acquisition, evidence) {
31493
32564
  caveats: [`Data freshness: ${input.refresh ? "fresh fetch requested; caches bypassed where supported" : "local cache allowed; use --refresh to bypass cached provider and HTTP data"}.`, input.sitemaps?.length ? `Sitemaps: ${countLabel(input.sitemaps.length, "sitemap URL")} requested.` : "Sitemaps: none provided; template discovery used retained GSC page rows."]
31494
32565
  });
31495
32566
  }
31496
- async function pseoAuditReport(input, dependencies = defaultDependencies$1) {
32567
+ async function pseoAuditReport(input, dependencies = defaultDependencies$2) {
31497
32568
  const acquisition = await acquirePseoAuditEvidence(input, dependencies);
31498
32569
  const sampleUrls = pseoSampleUrls(buildPseoAuditFromAcquisition(input, acquisition));
31499
32570
  const topQueries = topQueryByUrl(acquisition.queryPageRows, input);
@@ -32332,326 +33403,6 @@ function pseoOpportunityVerdict(input) {
32332
33403
  return `${expansions} template-expansion candidate${expansions === 1 ? "" : "s"} and ${input.competitors.length} observed competitor domain${input.competitors.length === 1 ? "" : "s"} were retained for review.`;
32333
33404
  }
32334
33405
  //#endregion
32335
- //#region packages/core/dist/providers/dataforseo/serp-snapshot.js
32336
- const MAX_SERP_DEPTH = 100;
32337
- const SERP_ENDPOINT = "v3/serp/google/organic/live/advanced";
32338
- function safeUrl(value) {
32339
- if (!value) return null;
32340
- try {
32341
- const parsed = new URL(value);
32342
- if (!["http:", "https:"].includes(parsed.protocol)) return null;
32343
- parsed.username = "";
32344
- parsed.password = "";
32345
- return parsed.toString();
32346
- } catch {
32347
- return null;
32348
- }
32349
- }
32350
- function validOrganic(item) {
32351
- return item.type === "organic" && Number.isSafeInteger(item.rank_group) && (item.rank_group ?? 0) > 0 && Number.isSafeInteger(item.rank_absolute) && (item.rank_absolute ?? 0) > 0 && Number.isSafeInteger(item.page) && (item.page ?? 0) > 0 && Boolean(item.domain?.trim()) && safeUrl(item.url) !== null;
32352
- }
32353
- function organicResult(item) {
32354
- if (!validOrganic(item)) return null;
32355
- const url = safeUrl(item.url);
32356
- if (!url) return null;
32357
- return {
32358
- rankGroup: item.rank_group,
32359
- rankAbsolute: item.rank_absolute,
32360
- page: item.page,
32361
- domain: item.domain.trim().toLowerCase(),
32362
- url,
32363
- title: item.title ?? null,
32364
- description: item.description ?? null,
32365
- isFeaturedSnippet: item.is_featured_snippet ?? null
32366
- };
32367
- }
32368
- function checkedAt(value, fallback) {
32369
- const timestamp = Date.parse(value);
32370
- return Number.isFinite(timestamp) ? new Date(timestamp).toISOString() : fallback;
32371
- }
32372
- function mapDataForSeoSerpSnapshot(input, snapshot, endpoint = SERP_ENDPOINT) {
32373
- const market = searchMarketSchema.parse(input.market);
32374
- const keyword = normalizedKeyword$2(input.keyword);
32375
- const location = locationRequest(market, "serp-snapshot");
32376
- const result = snapshot.response.tasks.flatMap((task) => task.result ?? []).find((item) => normalizedKeyword$2(item.keyword) === keyword);
32377
- if (!result) throw new ProviderError({
32378
- provider: "dataforseo",
32379
- operation: "serp-snapshot",
32380
- code: "invalid-response",
32381
- message: "DataForSEO returned no matching SERP result."
32382
- });
32383
- const warnings = [...snapshot.warnings];
32384
- const items = result.items ?? [];
32385
- const organicItems = items.filter((item) => item.type === "organic");
32386
- const mappedOrganicResults = organicItems.flatMap((item) => {
32387
- const mapped = organicResult(item);
32388
- return mapped ? [mapped] : [];
32389
- }).sort((left, right) => left.rankAbsolute - right.rankAbsolute || left.rankGroup - right.rankGroup || compareCodepoints$3(left.url, right.url));
32390
- const organicResults = mappedOrganicResults.slice(0, input.depth);
32391
- const invalidRows = organicItems.length - mappedOrganicResults.length;
32392
- const resultCapped = mappedOrganicResults.length > input.depth;
32393
- const invalidResultCount = result.se_results_count !== null && result.se_results_count !== void 0 && result.se_results_count < organicResults.length;
32394
- if (invalidRows > 0) warnings.push({
32395
- code: "invalid-organic-results",
32396
- field: "organicResults",
32397
- message: `DataForSEO returned ${invalidRows} organic result${invalidRows === 1 ? "" : "s"} without a valid rank, domain, or URL.`
32398
- });
32399
- if (!Number.isFinite(Date.parse(result.datetime))) warnings.push({
32400
- code: "invalid-serp-observation-time",
32401
- field: "checkedAt",
32402
- message: "DataForSEO returned an invalid SERP observation time; the local response time was retained."
32403
- });
32404
- const checkUrl = safeUrl(result.check_url);
32405
- if (result.check_url && !checkUrl) warnings.push({
32406
- code: "invalid-serp-check-url",
32407
- field: "checkUrl",
32408
- message: "DataForSEO returned an invalid SERP check URL."
32409
- });
32410
- if (invalidResultCount) warnings.push({
32411
- code: "invalid-serp-result-count",
32412
- field: "resultCount",
32413
- message: "DataForSEO returned an estimated result count below the retained organic result count; the estimate was discarded."
32414
- });
32415
- const features = [.../* @__PURE__ */ new Set([...result.item_types ?? [], ...items.map((item) => item.type)])].sort(compareCodepoints$3);
32416
- return {
32417
- schemaVersion: 1,
32418
- provider: "dataforseo",
32419
- capability: "serp-snapshot",
32420
- data: {
32421
- keyword,
32422
- effectiveKeyword: normalizedKeyword$2(result.spell?.keyword ?? result.keyword),
32423
- searchEngineDomain: result.se_domain?.trim().toLowerCase() ?? null,
32424
- checkedAt: checkedAt(result.datetime, snapshot.observedAt),
32425
- checkUrl,
32426
- resultCount: invalidResultCount ? null : result.se_results_count ?? null,
32427
- pagesCount: result.pages_count === void 0 ? null : result.pages_count,
32428
- features,
32429
- organicResults
32430
- },
32431
- observedAt: snapshot.observedAt,
32432
- market: {
32433
- ...market,
32434
- device: market.device ?? "desktop"
32435
- },
32436
- coverage: {
32437
- requestedRows: input.depth,
32438
- returnedRows: snapshot.returnedRows,
32439
- retainedRows: organicResults.length,
32440
- invalidRows,
32441
- providerTotalRows: invalidResultCount ? null : result.se_results_count ?? null,
32442
- completeness: invalidRows > 0 || invalidResultCount ? "partial" : resultCapped ? "capped" : "complete",
32443
- nextCursor: null
32444
- },
32445
- cache: snapshot.cache,
32446
- cost: snapshot.cost,
32447
- request: {
32448
- operation: "serp-snapshot",
32449
- endpoint,
32450
- limit: input.depth,
32451
- filters: {
32452
- countryCode: market.countryCode,
32453
- languageCode: market.languageCode,
32454
- device: market.device ?? "desktop",
32455
- ...location.locationCode !== void 0 ? { locationCode: location.locationCode } : { locationName: location.locationName }
32456
- },
32457
- sort: ["rankAbsolute:ascending", "url:codepoint-ascending"]
32458
- },
32459
- warnings
32460
- };
32461
- }
32462
- var DataForSeoSerpSnapshotProvider = class {
32463
- provider = "dataforseo";
32464
- capabilitySupport = [{
32465
- capability: "serp-snapshot",
32466
- status: "available",
32467
- markets: [{
32468
- searchEngines: ["google"],
32469
- devices: ["desktop", "mobile"],
32470
- location: "any"
32471
- }]
32472
- }];
32473
- client;
32474
- constructor(options = {}) {
32475
- this.client = options.client ?? new DataForSeoClient(options);
32476
- }
32477
- async serpSnapshot(input) {
32478
- const market = searchMarketSchema.parse(input.market);
32479
- if (market.searchEngine !== "google") throw new ProviderError({
32480
- provider: "dataforseo",
32481
- operation: "serp-snapshot",
32482
- code: "configuration",
32483
- message: "DataForSEO SERP snapshots currently support Google."
32484
- });
32485
- if (!Number.isSafeInteger(input.depth) || input.depth < 1 || input.depth > MAX_SERP_DEPTH) throw new ProviderError({
32486
- provider: "dataforseo",
32487
- operation: "serp-snapshot",
32488
- code: "configuration",
32489
- message: "SERP depth must be from 1 to 100."
32490
- });
32491
- const keyword = normalizedKeyword$2(input.keyword);
32492
- if (!keyword || keyword.length > 80 || keyword.split(/\s+/u).length > 10) throw new ProviderError({
32493
- provider: "dataforseo",
32494
- operation: "serp-snapshot",
32495
- code: "configuration",
32496
- message: "SERP snapshots require a keyword of at most 80 characters and 10 words."
32497
- });
32498
- const context = input.context ?? {
32499
- reportId: "serp-results",
32500
- reportRunId: randomUUID()
32501
- };
32502
- const location = locationRequest(market, "serp-snapshot");
32503
- return mapDataForSeoSerpSnapshot(input, await this.client.serpLive({
32504
- keyword,
32505
- languageCode: market.languageCode.split("-")[0],
32506
- ...location,
32507
- device: market.device ?? "desktop",
32508
- depth: input.depth,
32509
- refresh: input.refresh,
32510
- context
32511
- }));
32512
- }
32513
- };
32514
- //#endregion
32515
- //#region packages/core/dist/analyze/serp-results.js
32516
- const MAX_REPORT_DEPTH = 100;
32517
- function compareCodepoints(left, right) {
32518
- return left < right ? -1 : left > right ? 1 : 0;
32519
- }
32520
- function snapshotProvider(candidate) {
32521
- return "serpSnapshot" in candidate && typeof candidate.serpSnapshot === "function" ? candidate : null;
32522
- }
32523
- async function defaultCandidates() {
32524
- return [{
32525
- adapter: new DataForSeoSerpSnapshotProvider(),
32526
- connected: Boolean(await readDataForSeoCredentials()),
32527
- priority: 10
32528
- }];
32529
- }
32530
- function providerError$1(error) {
32531
- if (!(error instanceof ProviderError)) throw error;
32532
- if (error.code === "rate-limit") throw new SeoError("RATE_LIMITED", error.message);
32533
- if (error.code === "configuration") throw new SeoError("INVALID_INPUT", error.message);
32534
- throw new SeoError("PROVIDER_UNAVAILABLE", error.message);
32535
- }
32536
- function validateInput(input) {
32537
- const keyword = input.keyword.trim().replace(/\s+/gu, " ");
32538
- if (!keyword || keyword.length > 80 || keyword.split(/\s+/u).length > 10) throw new SeoError("INVALID_INPUT", "SERP results requires a keyword of at most 80 characters and 10 words.");
32539
- if (!Number.isSafeInteger(input.depth) || input.depth < 1 || input.depth > MAX_REPORT_DEPTH) throw new SeoError("INVALID_INPUT", `SERP result depth must be from 1 to ${MAX_REPORT_DEPTH}.`);
32540
- const market = searchMarketSchema.safeParse(input.market);
32541
- if (!market.success) throw new SeoError("INVALID_INPUT", "Use a valid search market.");
32542
- const provider = input.provider ? providerIdSchema.safeParse(input.provider) : void 0;
32543
- if (provider && !provider.success) throw new SeoError("INVALID_INPUT", "Use a supported SERP provider.");
32544
- return {
32545
- keyword,
32546
- market: market.data,
32547
- provider: provider?.data
32548
- };
32549
- }
32550
- function reportStatus$1(evidence) {
32551
- if (evidence.coverage.completeness === "unavailable" || evidence.coverage.completeness === "invalid") return "unavailable";
32552
- return evidence.coverage.completeness === "complete" ? "complete" : "partial";
32553
- }
32554
- function domainSummaries(snapshot) {
32555
- const byDomain = /* @__PURE__ */ new Map();
32556
- for (const result of snapshot.organicResults) byDomain.set(result.domain, [...byDomain.get(result.domain) ?? [], result.rankAbsolute]);
32557
- return [...byDomain.entries()].map(([domain, ranks]) => ({
32558
- domain,
32559
- resultCount: ranks.length,
32560
- ranks: [...ranks].sort((left, right) => left - right)
32561
- })).sort((left, right) => right.resultCount - left.resultCount || (left.ranks[0] ?? Number.MAX_SAFE_INTEGER) - (right.ranks[0] ?? Number.MAX_SAFE_INTEGER) || compareCodepoints(left.domain, right.domain));
32562
- }
32563
- function reportFindings(snapshot, domains) {
32564
- const findings = [];
32565
- if (snapshot.effectiveKeyword !== snapshot.keyword) findings.push({
32566
- code: "query-correction",
32567
- evidenceRef: "evidence.data.effectiveKeyword",
32568
- principle: "A corrected query changes the observed result context and does not prove the original wording has the same intent.",
32569
- detail: `The provider observed results for ${snapshot.effectiveKeyword} after the requested query ${snapshot.keyword}.`
32570
- });
32571
- for (const [index, domain] of domains.entries()) {
32572
- if (domain.resultCount <= 1) continue;
32573
- findings.push({
32574
- code: "repeated-domain",
32575
- evidenceRef: `domains[${index}]`,
32576
- principle: "Repeated results are descriptive snapshot evidence, not a domain strength or quality score.",
32577
- detail: `${domain.domain} appears ${domain.resultCount} times at absolute ranks ${domain.ranks.join(", ")}.`
32578
- });
32579
- }
32580
- return findings.slice(0, 10);
32581
- }
32582
- async function serpResultsReport(input, dependencies = {}) {
32583
- const depth = input.depth ?? 10;
32584
- const validated = validateInput({
32585
- ...input,
32586
- depth
32587
- });
32588
- let candidates;
32589
- try {
32590
- candidates = dependencies.candidates ?? await defaultCandidates();
32591
- } catch (error) {
32592
- return providerError$1(error);
32593
- }
32594
- const resolution = resolveProvider({
32595
- capability: "serp-snapshot",
32596
- market: validated.market,
32597
- candidates,
32598
- provider: validated.provider
32599
- });
32600
- if (resolution.status === "unavailable") throw new SeoError("PROVIDER_UNAVAILABLE", resolution.reason === "provider-not-connected" ? "No connected provider can return live SERP results. Run `seo providers dataforseo connect` first." : validated.provider ? `${validated.provider} cannot return live SERP results for this market.` : "No configured provider can return live SERP results for this market.");
32601
- const provider = snapshotProvider(resolution.provider);
32602
- if (!provider) throw new SeoError("PROVIDER_UNAVAILABLE", "The selected provider has no live SERP implementation.");
32603
- let evidence;
32604
- try {
32605
- evidence = await provider.serpSnapshot({
32606
- keyword: validated.keyword,
32607
- market: validated.market,
32608
- depth,
32609
- refresh: input.refresh,
32610
- context: {
32611
- projectId: input.context?.projectId ?? input.projectId,
32612
- reportId: input.context?.reportId ?? "serp-results",
32613
- reportRunId: input.context?.reportRunId ?? randomUUID()
32614
- }
32615
- });
32616
- } catch (error) {
32617
- return providerError$1(error);
32618
- }
32619
- const domains = domainSummaries(evidence.data);
32620
- const dataStatus = reportStatus$1(evidence);
32621
- const generatedAt = (dependencies.now ?? (() => /* @__PURE__ */ new Date()))().toISOString();
32622
- const correctedQuery = evidence.data.effectiveKeyword !== evidence.data.keyword;
32623
- return {
32624
- schemaVersion: 1,
32625
- generatedAt,
32626
- dataStatus,
32627
- market: evidence.market,
32628
- summary: {
32629
- keyword: evidence.data.keyword,
32630
- effectiveKeyword: evidence.data.effectiveKeyword,
32631
- requestedDepth: depth,
32632
- organicResults: evidence.data.organicResults.length,
32633
- uniqueDomains: domains.length,
32634
- observedFeatures: evidence.data.features.length,
32635
- correctedQuery,
32636
- verdict: `${evidence.data.organicResults.length} organic results across ${domains.length} domains were retained from the ${evidence.market.device ?? "desktop"} snapshot.`
32637
- },
32638
- evidence,
32639
- domains,
32640
- findings: reportFindings(evidence.data, domains),
32641
- caveats: [
32642
- "This is one market, location, language, and device-specific result snapshot; it is not rank history.",
32643
- "Search results can change between checks and may differ from a person's signed-in or personalized results.",
32644
- "The provider result count is an estimate and may be missing; retained organic rows are bounded by the requested depth.",
32645
- "Repeated domains, result features, titles, and snippets do not establish content quality, authority, or ranking feasibility."
32646
- ],
32647
- nextSteps: [
32648
- "Review the retained pages and result types before deciding whether the query fits an existing page, a new page, or no page.",
32649
- "Keep Search Console average position separate from these exact snapshot ranks when comparing first-party performance.",
32650
- "Repeat the same market and device check later if the decision depends on result stability."
32651
- ]
32652
- };
32653
- }
32654
- //#endregion
32655
33406
  //#region packages/core/dist/analyze/pseo-opportunities/serps.js
32656
33407
  function selectSerpKeywords(candidates, limit) {
32657
33408
  const seedRefs = /* @__PURE__ */ new Set();
@@ -34544,7 +35295,7 @@ function groupedMappings(evidence) {
34544
35295
  //#endregion
34545
35296
  //#region packages/core/dist/analyze/second-page.js
34546
35297
  const MAX_SOURCE_ROWS = 1e5;
34547
- const defaultDependencies = {
35298
+ const defaultDependencies$1 = {
34548
35299
  searchAnalytics: querySearchAnalytics,
34549
35300
  fetch: fetchPage,
34550
35301
  extract: extractPage,
@@ -34621,7 +35372,7 @@ function reportRecommendations(items) {
34621
35372
  "Treat the priority score as a triage heuristic, not an estimated traffic lift."
34622
35373
  ]);
34623
35374
  }
34624
- async function secondPage(input, dependencies = defaultDependencies) {
35375
+ async function secondPage(input, dependencies = defaultDependencies$1) {
34625
35376
  const days = integerOption({
34626
35377
  value: input.range,
34627
35378
  fallback: 28,
@@ -36812,10 +37563,10 @@ const DEFAULT_TARGET_LIMIT = 20;
36812
37563
  const MAX_TARGET_LIMIT = 50;
36813
37564
  const DEFAULT_DETAIL_PAGES = 1;
36814
37565
  const MAX_DETAIL_PAGES = 3;
36815
- const DEFAULT_ROW_LIMIT$2 = 500;
36816
- const MAX_ROW_LIMIT$2 = 1e3;
37566
+ const DEFAULT_ROW_LIMIT$3 = 500;
37567
+ const MAX_ROW_LIMIT$3 = 1e3;
36817
37568
  const MAX_CONCURRENCY = 3;
36818
- function integer(value, fallback, max, name) {
37569
+ function integer$1(value, fallback, max, name) {
36819
37570
  const resolved = value ?? fallback;
36820
37571
  if (!Number.isInteger(resolved) || resolved < 1 || resolved > max) throw new SeoError("INVALID_INPUT", `${name} must be between 1 and ${max}.`);
36821
37572
  return resolved;
@@ -36876,9 +37627,9 @@ async function targetDetails(input) {
36876
37627
  }
36877
37628
  async function collectBingLinkEvidence(input) {
36878
37629
  if (!input.site.trim()) throw new SeoError("INVALID_INPUT", "Pass a Bing Webmaster site URL.");
36879
- const targetLimit = integer(input.targetLimit, DEFAULT_TARGET_LIMIT, MAX_TARGET_LIMIT, "Bing link target limit");
36880
- const detailPages = integer(input.detailPagesPerTarget, DEFAULT_DETAIL_PAGES, MAX_DETAIL_PAGES, "Bing detail page limit");
36881
- const rowLimit = integer(input.rowLimit, DEFAULT_ROW_LIMIT$2, MAX_ROW_LIMIT$2, "Bing link row limit");
37630
+ const targetLimit = integer$1(input.targetLimit, DEFAULT_TARGET_LIMIT, MAX_TARGET_LIMIT, "Bing link target limit");
37631
+ const detailPages = integer$1(input.detailPagesPerTarget, DEFAULT_DETAIL_PAGES, MAX_DETAIL_PAGES, "Bing detail page limit");
37632
+ const rowLimit = integer$1(input.rowLimit, DEFAULT_ROW_LIMIT$3, MAX_ROW_LIMIT$3, "Bing link row limit");
36882
37633
  const client = input.client ?? (await createBingWebmasterClient()).client;
36883
37634
  const countRows = [];
36884
37635
  let countPagesRequested = 0;
@@ -36999,6 +37750,884 @@ async function collectBingLinkEvidence(input) {
36999
37750
  };
37000
37751
  }
37001
37752
  //#endregion
37753
+ //#region packages/core/dist/links/context-sources.js
37754
+ function urlKey(value) {
37755
+ try {
37756
+ const url = new URL(value);
37757
+ if (!["http:", "https:"].includes(url.protocol)) return null;
37758
+ url.hash = "";
37759
+ return url.toString();
37760
+ } catch {
37761
+ return null;
37762
+ }
37763
+ }
37764
+ function compareTargets(left, right) {
37765
+ return right.observedLinks - left.observedLinks || (left.targetUrl < right.targetUrl ? -1 : left.targetUrl > right.targetUrl ? 1 : 0);
37766
+ }
37767
+ function linkTargetCounts(evidence) {
37768
+ if (evidence.targetCounts.length) return [...evidence.targetCounts].sort(compareTargets);
37769
+ const counts = /* @__PURE__ */ new Map();
37770
+ for (const row of evidence.rows) counts.set(row.targetUrl, (counts.get(row.targetUrl) ?? 0) + 1);
37771
+ return [...counts].map(([targetUrl, observedLinks]) => ({
37772
+ targetUrl,
37773
+ observedLinks
37774
+ })).sort(compareTargets);
37775
+ }
37776
+ function crawlForLinkTargets(input) {
37777
+ const values = /* @__PURE__ */ new Map();
37778
+ if (!input.report) {
37779
+ for (const target of input.targets) values.set(target.targetUrl, {
37780
+ state: "unavailable",
37781
+ reason: "No matching saved crawl was available."
37782
+ });
37783
+ return {
37784
+ values,
37785
+ matched: 0
37786
+ };
37787
+ }
37788
+ const pages = /* @__PURE__ */ new Map();
37789
+ for (const page of input.report.pages) for (const value of [page.url, page.finalUrl]) {
37790
+ const key = urlKey(value);
37791
+ if (key && !pages.has(key)) pages.set(key, page);
37792
+ }
37793
+ const issues = /* @__PURE__ */ new Map();
37794
+ for (const issue of input.report.issues) {
37795
+ const key = urlKey(issue.url);
37796
+ if (!key) continue;
37797
+ const ids = issues.get(key) ?? /* @__PURE__ */ new Set();
37798
+ ids.add(issue.ruleId);
37799
+ issues.set(key, ids);
37800
+ }
37801
+ let matched = 0;
37802
+ for (const target of input.targets) {
37803
+ const key = urlKey(target.targetUrl);
37804
+ const page = key ? pages.get(key) : void 0;
37805
+ if (!page) {
37806
+ values.set(target.targetUrl, {
37807
+ state: "not-observed",
37808
+ reason: "The target URL was not present in the retained crawl pages."
37809
+ });
37810
+ continue;
37811
+ }
37812
+ matched += 1;
37813
+ values.set(target.targetUrl, {
37814
+ state: "observed",
37815
+ reportId: input.report.id,
37816
+ observedAt: input.report.generatedAt,
37817
+ status: page.status,
37818
+ finalUrl: page.finalUrl,
37819
+ indexable: page.indexable,
37820
+ canonical: page.canonical ?? null,
37821
+ issueIds: [...issues.get(key) ?? []].sort()
37822
+ });
37823
+ }
37824
+ return {
37825
+ values,
37826
+ matched
37827
+ };
37828
+ }
37829
+ function aggregateLinkSearchRows(rows) {
37830
+ const values = /* @__PURE__ */ new Map();
37831
+ for (const row of rows) {
37832
+ const page = row.keys[0];
37833
+ const key = page ? urlKey(page) : null;
37834
+ if (!key) continue;
37835
+ const current = values.get(key) ?? {
37836
+ clicks: 0,
37837
+ impressions: 0,
37838
+ positionWeight: 0
37839
+ };
37840
+ current.clicks += row.clicks;
37841
+ current.impressions += row.impressions;
37842
+ current.positionWeight += row.position * row.impressions;
37843
+ values.set(key, current);
37844
+ }
37845
+ return values;
37846
+ }
37847
+ //#endregion
37848
+ //#region packages/core/dist/links/context-findings.js
37849
+ const MAX_FINDINGS$1 = 50;
37850
+ function findingPriority(input) {
37851
+ const searchValue = input.search.state === "observed" && (input.search.clicks > 0 || input.search.impressions >= 100);
37852
+ if (input.code === "linked-broken-target" || searchValue) return "high";
37853
+ if (input.observedLinks >= 5) return "medium";
37854
+ return "low";
37855
+ }
37856
+ function finding(input) {
37857
+ const crawl = input.row.crawl;
37858
+ if (crawl.state !== "observed") throw new Error("A link finding requires observed crawl evidence.");
37859
+ const searchEvidence = input.row.searchConsole.state === "observed" ? ` Search Console retained ${input.row.searchConsole.clicks.toFixed(0)} clicks and ${input.row.searchConsole.impressions.toFixed(0)} impressions for this page in the selected window.` : "";
37860
+ const common = {
37861
+ priority: findingPriority({
37862
+ code: input.code,
37863
+ observedLinks: input.row.observedLinks,
37864
+ crawl,
37865
+ search: input.row.searchConsole
37866
+ }),
37867
+ heuristic: true,
37868
+ targetUrl: input.row.targetUrl,
37869
+ observedLinks: input.row.observedLinks,
37870
+ evidenceRefs: [input.row.targetUrl, crawl.reportId]
37871
+ };
37872
+ if (input.code === "linked-broken-target") return {
37873
+ ...common,
37874
+ code: input.code,
37875
+ principle: "Linked target pages should resolve intentionally.",
37876
+ evidence: `The saved crawl observed status ${crawl.status} for a target with ${input.row.observedLinks} retained representative links.${searchEvidence}`,
37877
+ action: "Confirm whether the page should exist. Restore it or add one direct redirect to a genuinely equivalent live page when the missing URL is unintended.",
37878
+ verify: "Run redirect-trace for the target, then recrawl it and confirm a stable intended response."
37879
+ };
37880
+ if (input.code === "linked-redirect-target") return {
37881
+ ...common,
37882
+ code: input.code,
37883
+ principle: "External links should reach a stable final destination.",
37884
+ evidence: `The saved crawl observed ${input.row.targetUrl} resolving to ${crawl.finalUrl}.${searchEvidence}`,
37885
+ action: "Check that the redirect is intentional, direct and semantically equivalent. Update links you control to the final URL and ask important external sources to update only when the change is worth the effort.",
37886
+ verify: "Run redirect-trace and confirm one stable hop to the intended indexable page."
37887
+ };
37888
+ if (input.code === "linked-canonical-conflict") return {
37889
+ ...common,
37890
+ code: input.code,
37891
+ principle: "Canonical hints should agree with the intended linked page.",
37892
+ evidence: `The saved crawl observed canonical ${crawl.canonical} on a linked target that resolved to ${crawl.finalUrl}.${searchEvidence}`,
37893
+ action: "Confirm the preferred URL. Align the canonical and redirect only if the current disagreement is unintended.",
37894
+ verify: "Audit the final page and inspect the declared and Google-selected canonical before changing it."
37895
+ };
37896
+ return {
37897
+ ...common,
37898
+ code: input.code,
37899
+ principle: "A linked page should be indexable when search visibility is intended.",
37900
+ evidence: `The saved crawl marked a linked target as non-indexable.${searchEvidence}`,
37901
+ action: "Confirm whether non-indexing is intentional. Remove the blocking signal or redirect to an equivalent indexable page only when this URL should appear in search.",
37902
+ verify: "Audit the page, then use URL Inspection when the intended state is indexable."
37903
+ };
37904
+ }
37905
+ function linkTargetFindings(rows) {
37906
+ return rows.flatMap((row) => {
37907
+ const crawl = row.crawl;
37908
+ if (crawl.state !== "observed") return [];
37909
+ if (crawl.status >= 400) return [finding({
37910
+ row,
37911
+ code: "linked-broken-target"
37912
+ })];
37913
+ if (urlKey(crawl.finalUrl) !== urlKey(row.targetUrl)) return [finding({
37914
+ row,
37915
+ code: "linked-redirect-target"
37916
+ })];
37917
+ if (crawl.canonical && urlKey(crawl.canonical) !== urlKey(crawl.finalUrl)) return [finding({
37918
+ row,
37919
+ code: "linked-canonical-conflict"
37920
+ })];
37921
+ if (!crawl.indexable) return [finding({
37922
+ row,
37923
+ code: "linked-non-indexable-target"
37924
+ })];
37925
+ return [];
37926
+ }).sort((left, right) => ({
37927
+ high: 0,
37928
+ medium: 1,
37929
+ low: 2
37930
+ })[left.priority] - {
37931
+ high: 0,
37932
+ medium: 1,
37933
+ low: 2
37934
+ }[right.priority] || right.observedLinks - left.observedLinks || (left.targetUrl < right.targetUrl ? -1 : left.targetUrl > right.targetUrl ? 1 : 0)).slice(0, MAX_FINDINGS$1);
37935
+ }
37936
+ //#endregion
37937
+ //#region packages/core/dist/links/context.js
37938
+ const DEFAULT_DAYS = 90;
37939
+ const MAX_DAYS = 548;
37940
+ const DEFAULT_CONTEXT_LIMIT = 100;
37941
+ const MAX_CONTEXT_LIMIT = 100;
37942
+ const MAX_GSC_ROWS = 25e3;
37943
+ const defaultDependencies = {
37944
+ latestCrawl: latestCrawlReport,
37945
+ searchAnalytics: querySearchAnalytics,
37946
+ now: () => /* @__PURE__ */ new Date()
37947
+ };
37948
+ function integer(input) {
37949
+ const value = input.value ?? input.fallback;
37950
+ if (!Number.isSafeInteger(value) || value < 1 || value > input.maximum) throw new SeoError("INVALID_INPUT", `${input.label} must be between 1 and ${input.maximum}.`);
37951
+ return value;
37952
+ }
37953
+ function searchEvidence(input) {
37954
+ if (!input.site) return {
37955
+ state: "unavailable",
37956
+ reason: "No Search Console property was selected for this report."
37957
+ };
37958
+ if (input.error) return {
37959
+ state: "unavailable",
37960
+ reason: input.error
37961
+ };
37962
+ if (!input.aggregate) return {
37963
+ state: "not-retained",
37964
+ reason: input.retainedLimitReached ? `The bounded Search Console response reached ${MAX_GSC_ROWS} rows and did not retain this page.` : "Search Console did not retain a page row for this URL in the selected window."
37965
+ };
37966
+ return {
37967
+ state: "observed",
37968
+ clicks: input.aggregate.clicks,
37969
+ impressions: input.aggregate.impressions,
37970
+ ctr: input.aggregate.impressions > 0 ? input.aggregate.clicks / input.aggregate.impressions : 0,
37971
+ position: input.aggregate.impressions > 0 ? input.aggregate.positionWeight / input.aggregate.impressions : 0
37972
+ };
37973
+ }
37974
+ async function linkTargetContext(input, dependencies = defaultDependencies) {
37975
+ const days = integer({
37976
+ value: input.days,
37977
+ fallback: DEFAULT_DAYS,
37978
+ maximum: MAX_DAYS,
37979
+ label: "Link context days"
37980
+ });
37981
+ const limit = integer({
37982
+ value: input.limit,
37983
+ fallback: DEFAULT_CONTEXT_LIMIT,
37984
+ maximum: MAX_CONTEXT_LIMIT,
37985
+ label: "Link context limit"
37986
+ });
37987
+ const available = linkTargetCounts(input.evidence);
37988
+ const selected = available.slice(0, limit);
37989
+ const crawlLookup = input.crawlSite ?? input.searchConsoleSite;
37990
+ const crawl = crawlLookup ? dependencies.latestCrawl(crawlLookup) : void 0;
37991
+ const crawlValues = crawlForLinkTargets({
37992
+ report: crawl,
37993
+ targets: selected
37994
+ });
37995
+ const warnings = [];
37996
+ const range = finalGscDateRange(days, dependencies.now());
37997
+ let searchRows = /* @__PURE__ */ new Map();
37998
+ let searchCalls = 0;
37999
+ let searchRowsFetched = 0;
38000
+ let searchError = null;
38001
+ if (input.searchConsoleSite) try {
38002
+ const result = await dependencies.searchAnalytics(input.searchConsoleSite, {
38003
+ ...range,
38004
+ dimensions: ["page"],
38005
+ type: "web",
38006
+ dataState: "final",
38007
+ rowLimit: MAX_GSC_ROWS,
38008
+ maxRows: MAX_GSC_ROWS
38009
+ }, { refresh: input.refresh });
38010
+ searchRows = aggregateLinkSearchRows(result.rows);
38011
+ searchCalls = result.calls;
38012
+ searchRowsFetched = result.rowsFetched;
38013
+ } catch (error) {
38014
+ searchError = error instanceof Error ? error.message : "Search Console evidence could not be loaded.";
38015
+ warnings.push(`Search Console context was unavailable. ${searchError}`);
38016
+ }
38017
+ let searchMatches = 0;
38018
+ const retainedLimitReached = searchRowsFetched >= MAX_GSC_ROWS;
38019
+ const rows = selected.map((target) => {
38020
+ const aggregate = searchRows.get(urlKey(target.targetUrl) ?? "");
38021
+ if (aggregate) searchMatches += 1;
38022
+ return {
38023
+ ...target,
38024
+ crawl: crawlValues.values.get(target.targetUrl) ?? {
38025
+ state: "not-observed",
38026
+ reason: "The target URL was not present in the retained crawl pages."
38027
+ },
38028
+ searchConsole: searchEvidence({
38029
+ site: input.searchConsoleSite,
38030
+ error: searchError,
38031
+ retainedLimitReached,
38032
+ aggregate
38033
+ })
38034
+ };
38035
+ });
38036
+ if (!crawl) warnings.push("No matching saved crawl was available, so target response and indexability checks were skipped.");
38037
+ if (retainedLimitReached) warnings.push(`Search Console reached the ${MAX_GSC_ROWS}-row acquisition limit. Missing target rows remain not retained rather than zero.`);
38038
+ const unavailableSources = Number(!crawl) + Number(!input.searchConsoleSite);
38039
+ return {
38040
+ schemaVersion: 1,
38041
+ dataStatus: unavailableSources === 2 ? "unavailable" : unavailableSources > 0 || searchError || retainedLimitReached ? "partial" : "complete",
38042
+ selection: {
38043
+ availableTargets: available.length,
38044
+ returnedTargets: rows.length,
38045
+ omittedTargets: Math.max(0, available.length - rows.length),
38046
+ limit
38047
+ },
38048
+ provenance: {
38049
+ crawl: {
38050
+ status: crawl ? "joined" : "unavailable",
38051
+ reportId: crawl?.id ?? null,
38052
+ observedAt: crawl?.generatedAt ?? null,
38053
+ availablePages: crawl?.pages.length ?? 0,
38054
+ matchedTargets: crawlValues.matched
38055
+ },
38056
+ searchConsole: {
38057
+ status: !input.searchConsoleSite || searchError ? "unavailable" : retainedLimitReached ? "partial" : "joined",
38058
+ site: input.searchConsoleSite ?? null,
38059
+ range: input.searchConsoleSite ? {
38060
+ ...range,
38061
+ days
38062
+ } : null,
38063
+ calls: searchCalls,
38064
+ rowsFetched: searchRowsFetched,
38065
+ retainedRowLimit: MAX_GSC_ROWS,
38066
+ retainedRowLimitReached: retainedLimitReached,
38067
+ matchedTargets: searchMatches
38068
+ }
38069
+ },
38070
+ rows,
38071
+ findings: linkTargetFindings(rows),
38072
+ warnings
38073
+ };
38074
+ }
38075
+ //#endregion
38076
+ //#region packages/core/dist/providers/dataforseo/link-research-shared.js
38077
+ function linkTarget(value, requestedScope = "domain") {
38078
+ const raw = value.trim();
38079
+ if (!raw || raw.length > 2048) throw invalidTarget();
38080
+ if (requestedScope === "page") try {
38081
+ const url = new URL(raw);
38082
+ if (!["http:", "https:"].includes(url.protocol) || url.username || url.password) throw new Error();
38083
+ url.hash = "";
38084
+ return {
38085
+ target: url.toString(),
38086
+ scope: "page"
38087
+ };
38088
+ } catch {
38089
+ throw invalidTarget();
38090
+ }
38091
+ try {
38092
+ const hostname = new URL(raw.includes("://") ? raw : `https://${raw}`).hostname.toLowerCase().replace(/^www\./u, "").replace(/\.$/u, "");
38093
+ if (!hostname || hostname.length > 253 || hostname.includes("..") || !hostname.includes(".") || !/^[a-z0-9.-]+$/u.test(hostname)) throw new Error();
38094
+ return {
38095
+ target: hostname,
38096
+ scope: "domain"
38097
+ };
38098
+ } catch {
38099
+ throw invalidTarget();
38100
+ }
38101
+ }
38102
+ function invalidTarget() {
38103
+ return new ProviderError({
38104
+ provider: "dataforseo",
38105
+ operation: "link-evidence",
38106
+ code: "configuration",
38107
+ message: "Use a valid domain, or choose page scope and pass an absolute URL."
38108
+ });
38109
+ }
38110
+ function linkRowLimit(limit, offset = 0) {
38111
+ if (!Number.isSafeInteger(limit) || limit < 1 || limit > 1e3) throw new ProviderError({
38112
+ provider: "dataforseo",
38113
+ operation: "link-evidence",
38114
+ code: "configuration",
38115
+ message: `Link row limit must be from 1 to ${MAX_LINK_ROWS$1}.`
38116
+ });
38117
+ if (!Number.isSafeInteger(offset) || offset < 0 || offset > 2e4) throw new ProviderError({
38118
+ provider: "dataforseo",
38119
+ operation: "link-evidence",
38120
+ code: "configuration",
38121
+ message: `Link row offset must be from 0 to ${MAX_LINK_OFFSET}.`
38122
+ });
38123
+ }
38124
+ function requestContext(reportId, supplied) {
38125
+ return supplied ?? {
38126
+ reportId,
38127
+ reportRunId: randomUUID()
38128
+ };
38129
+ }
38130
+ function missing(field) {
38131
+ return unavailableValue("missing", `DataForSEO omitted ${field}.`);
38132
+ }
38133
+ function numberValue(value, field) {
38134
+ return value === null || value === void 0 ? missing(field) : observedValue(value);
38135
+ }
38136
+ function stringValue(value, field) {
38137
+ return value ? observedValue(value) : missing(field);
38138
+ }
38139
+ function metric(id, label, value) {
38140
+ return value === null || value === void 0 ? [] : [{
38141
+ provider: "dataforseo",
38142
+ id,
38143
+ label,
38144
+ value,
38145
+ scale: {
38146
+ minimum: 0,
38147
+ maximum: 100
38148
+ }
38149
+ }];
38150
+ }
38151
+ function safeUrl(value) {
38152
+ if (!value) return null;
38153
+ try {
38154
+ const url = new URL(value);
38155
+ if (!["http:", "https:"].includes(url.protocol)) return null;
38156
+ url.username = "";
38157
+ url.password = "";
38158
+ url.hash = "";
38159
+ return url.toString();
38160
+ } catch {
38161
+ return null;
38162
+ }
38163
+ }
38164
+ function normalizedDate(value) {
38165
+ if (!value) return null;
38166
+ const parsed = Date.parse(value);
38167
+ return Number.isFinite(parsed) ? new Date(parsed).toISOString() : null;
38168
+ }
38169
+ function uniqueSorted(values = []) {
38170
+ return [...new Set(values.map((value) => value.trim()).filter(Boolean))].sort(compareCodepoints$3);
38171
+ }
38172
+ function totalRows(values) {
38173
+ const present = values.filter((value) => value !== null && value !== void 0);
38174
+ return present.length ? Math.max(...present) : null;
38175
+ }
38176
+ function linkCoverage(input) {
38177
+ const hasMore = input.providerTotalComparable !== false && input.providerTotalRows !== null ? input.offset + input.returnedRows < input.providerTotalRows : input.returnedRows >= input.requestedRows;
38178
+ return {
38179
+ requestedRows: input.requestedRows,
38180
+ returnedRows: input.returnedRows,
38181
+ retainedRows: input.retainedRows,
38182
+ invalidRows: input.invalidRows,
38183
+ providerTotalRows: input.providerTotalRows,
38184
+ completeness: input.invalidRows > 0 ? "partial" : hasMore ? "capped" : input.filtered ? "filtered" : "complete",
38185
+ nextCursor: hasMore ? String(input.offset + input.returnedRows) : null
38186
+ };
38187
+ }
38188
+ function linkEvidence(input) {
38189
+ return {
38190
+ schemaVersion: 1,
38191
+ provider: "dataforseo",
38192
+ capability: input.capability,
38193
+ data: input.data,
38194
+ observedAt: input.snapshot.observedAt,
38195
+ market: null,
38196
+ coverage: input.coverage,
38197
+ cache: input.snapshot.cache,
38198
+ cost: input.snapshot.cost,
38199
+ request: {
38200
+ operation: input.capability,
38201
+ endpoint: input.endpoint,
38202
+ limit: input.limit,
38203
+ filters: input.filters,
38204
+ sort: input.sort
38205
+ },
38206
+ warnings: input.warnings
38207
+ };
38208
+ }
38209
+ function mappingWarnings(input) {
38210
+ return [
38211
+ ...input.snapshotWarnings,
38212
+ ...input.invalidRows ? [{
38213
+ code: `invalid-${input.label}-rows`,
38214
+ field: "data.rows",
38215
+ message: `${input.invalidRows} ${input.label} row${input.invalidRows === 1 ? "" : "s"} lacked required fields and were omitted.`
38216
+ }] : [],
38217
+ ...input.duplicateRows ? [{
38218
+ code: `duplicate-${input.label}-rows`,
38219
+ field: "data.rows",
38220
+ message: `${input.duplicateRows} duplicate ${input.label} row${input.duplicateRows === 1 ? "" : "s"} were collapsed deterministically.`
38221
+ }] : []
38222
+ ];
38223
+ }
38224
+ //#endregion
38225
+ //#region packages/core/dist/providers/dataforseo/backlinks.js
38226
+ function metricValue(row, id) {
38227
+ return row.metrics.find((item) => item.id === id)?.value ?? -1;
38228
+ }
38229
+ function compareRows(left, right) {
38230
+ return metricValue(right, "backlink-rank") - metricValue(left, "backlink-rank") || metricValue(right, "source-domain-rank") - metricValue(left, "source-domain-rank") || compareCodepoints$3(left.sourceDomain, right.sourceDomain) || compareCodepoints$3(left.sourceUrl, right.sourceUrl) || compareCodepoints$3(left.targetUrl, right.targetUrl) || compareCodepoints$3(left.anchorText ?? "", right.anchorText ?? "");
38231
+ }
38232
+ function dedupe(rows) {
38233
+ const sorted = [...rows].sort(compareRows);
38234
+ const seen = /* @__PURE__ */ new Set();
38235
+ return sorted.filter((row) => {
38236
+ const key = `${row.sourceUrl}\0${row.targetUrl}\0${row.anchorText ?? ""}`;
38237
+ if (seen.has(key)) return false;
38238
+ seen.add(key);
38239
+ return true;
38240
+ });
38241
+ }
38242
+ async function dataForSeoBacklinks(client, input) {
38243
+ const normalized = linkTarget(input.target, input.scope);
38244
+ const includeSubdomains = normalized.scope === "domain" ? input.includeSubdomains ?? true : false;
38245
+ const offset = input.offset ?? 0;
38246
+ linkRowLimit(input.limit, offset);
38247
+ const mode = input.mode ?? "representative";
38248
+ const status = input.status ?? "live";
38249
+ const providerMode = mode === "representative" ? "one_per_domain" : "as_is";
38250
+ const orderBy = [
38251
+ "rank,desc",
38252
+ "domain_from_rank,desc",
38253
+ "url_from,asc"
38254
+ ];
38255
+ const snapshot = await client.backlinks({
38256
+ ...normalized,
38257
+ includeSubdomains,
38258
+ mode: providerMode,
38259
+ status,
38260
+ limit: input.limit,
38261
+ offset,
38262
+ orderBy,
38263
+ refresh: input.refresh,
38264
+ context: requestContext("link-evidence", input.context)
38265
+ });
38266
+ const results = snapshot.response.tasks.flatMap((task) => task.result ?? []);
38267
+ const rawRows = results.flatMap((result) => result.items ?? []);
38268
+ let invalidRows = 0;
38269
+ const mapped = rawRows.flatMap((row) => {
38270
+ const sourceUrl = safeUrl(row.url_from);
38271
+ const targetUrl = safeUrl(row.url_to);
38272
+ if (!sourceUrl || !targetUrl) {
38273
+ invalidRows += 1;
38274
+ return [];
38275
+ }
38276
+ return [{
38277
+ sourceUrl,
38278
+ sourceDomain: new URL(sourceUrl).hostname.toLowerCase(),
38279
+ targetUrl,
38280
+ anchorText: row.anchor ?? null,
38281
+ linkType: row.item_type ?? null,
38282
+ dofollow: row.dofollow ?? null,
38283
+ attributes: uniqueSorted(row.rel_attributes ?? row.attributes ?? []),
38284
+ firstSeenAt: normalizedDate(row.first_seen),
38285
+ lastSeenAt: normalizedDate(row.lost_date ?? row.last_visited),
38286
+ state: row.is_lost || row.lost_date ? "lost" : "live",
38287
+ indirect: row.is_indirect_link ?? null,
38288
+ linksFromPage: row.links_count ?? null,
38289
+ linksFromDomain: row.group_count ?? null,
38290
+ metrics: [
38291
+ ...metric("backlink-rank", "DataForSEO backlink rank", row.rank),
38292
+ ...metric("source-domain-rank", "DataForSEO source domain rank", row.domain_from_rank),
38293
+ ...metric("source-page-rank", "DataForSEO source page rank", row.page_from_rank),
38294
+ ...metric("backlink-spam-score", "DataForSEO backlink spam score", row.backlink_spam_score ?? row.backlinks_spam_score)
38295
+ ]
38296
+ }];
38297
+ });
38298
+ const rows = dedupe(mapped);
38299
+ const duplicateRows = mapped.length - rows.length;
38300
+ const providerTotalRows = totalRows(results.map((result) => result.total_count));
38301
+ return linkEvidence({
38302
+ capability: "backlinks",
38303
+ data: {
38304
+ target: normalized.target,
38305
+ mode,
38306
+ rows,
38307
+ totalRows: providerTotalRows
38308
+ },
38309
+ snapshot,
38310
+ coverage: linkCoverage({
38311
+ requestedRows: input.limit,
38312
+ returnedRows: rawRows.length,
38313
+ retainedRows: rows.length,
38314
+ invalidRows,
38315
+ providerTotalRows,
38316
+ offset,
38317
+ filtered: mode === "representative" || status !== "all",
38318
+ providerTotalComparable: mode !== "representative"
38319
+ }),
38320
+ endpoint: LINK_ENDPOINTS.backlinks,
38321
+ limit: input.limit,
38322
+ filters: {
38323
+ scope: normalized.scope,
38324
+ includeSubdomains,
38325
+ mode,
38326
+ status,
38327
+ includeIndirectLinks: true,
38328
+ excludeInternalBacklinks: true,
38329
+ offset
38330
+ },
38331
+ sort: orderBy,
38332
+ warnings: mappingWarnings({
38333
+ snapshotWarnings: snapshot.warnings,
38334
+ invalidRows,
38335
+ duplicateRows,
38336
+ label: "backlink"
38337
+ })
38338
+ });
38339
+ }
38340
+ //#endregion
38341
+ //#region packages/core/dist/providers/dataforseo/link-summary.js
38342
+ async function dataForSeoLinkSummary(client, input) {
38343
+ const normalized = linkTarget(input.target, input.scope);
38344
+ const includeSubdomains = normalized.scope === "domain" ? input.includeSubdomains ?? true : false;
38345
+ const snapshot = await client.linkSummary({
38346
+ ...normalized,
38347
+ includeSubdomains,
38348
+ refresh: input.refresh,
38349
+ context: requestContext("link-evidence", input.context)
38350
+ });
38351
+ const rows = snapshot.response.tasks.flatMap((task) => task.result ?? []);
38352
+ const row = [...rows].sort((left, right) => compareCodepoints$3(left.target ?? "", right.target ?? ""))[0];
38353
+ const data = {
38354
+ target: normalized.target,
38355
+ scope: normalized.scope,
38356
+ backlinks: numberValue(row?.backlinks, "total backlinks"),
38357
+ referringDomains: numberValue(row?.referring_domains, "referring domains"),
38358
+ referringPages: numberValue(row?.referring_pages, "referring pages"),
38359
+ brokenBacklinks: numberValue(row?.broken_backlinks, "broken backlinks"),
38360
+ brokenPages: numberValue(row?.broken_pages, "broken referring pages"),
38361
+ metrics: [
38362
+ ...metric("rank", "DataForSEO rank", row?.rank),
38363
+ ...metric("backlinks-spam-score", "DataForSEO backlink spam score", row?.backlinks_spam_score),
38364
+ ...metric("target-spam-score", "DataForSEO target spam score", row?.info?.target_spam_score)
38365
+ ]
38366
+ };
38367
+ const retainedRows = row ? 1 : 0;
38368
+ return linkEvidence({
38369
+ capability: "link-summary",
38370
+ data,
38371
+ snapshot,
38372
+ coverage: {
38373
+ requestedRows: 1,
38374
+ returnedRows: rows.length,
38375
+ retainedRows,
38376
+ invalidRows: 0,
38377
+ providerTotalRows: rows.length,
38378
+ completeness: row ? "complete" : "unavailable",
38379
+ nextCursor: null
38380
+ },
38381
+ endpoint: LINK_ENDPOINTS.summary,
38382
+ limit: 1,
38383
+ filters: {
38384
+ scope: normalized.scope,
38385
+ includeSubdomains,
38386
+ status: "live",
38387
+ includeIndirectLinks: true,
38388
+ excludeInternalBacklinks: true
38389
+ },
38390
+ sort: [],
38391
+ warnings: [
38392
+ ...snapshot.warnings,
38393
+ ...rows.length > 1 ? [{
38394
+ code: "multiple-link-summary-rows",
38395
+ field: "data",
38396
+ message: "DataForSEO returned more than one summary row. One row was retained deterministically."
38397
+ }] : [],
38398
+ ...!row ? [{
38399
+ code: "missing-link-summary-row",
38400
+ field: "data",
38401
+ message: "DataForSEO returned no summary row. Counts remain unavailable rather than zero."
38402
+ }] : []
38403
+ ]
38404
+ });
38405
+ }
38406
+ //#endregion
38407
+ //#region packages/core/dist/providers/dataforseo/referring-domains.js
38408
+ function observedNumber(row) {
38409
+ return row.backlinks.state === "observed" ? row.backlinks.value : -1;
38410
+ }
38411
+ async function dataForSeoReferringDomains(client, input) {
38412
+ const normalized = linkTarget(input.target, input.scope);
38413
+ const includeSubdomains = normalized.scope === "domain" ? input.includeSubdomains ?? true : false;
38414
+ const offset = input.offset ?? 0;
38415
+ linkRowLimit(input.limit, offset);
38416
+ const orderBy = [
38417
+ "backlinks,desc",
38418
+ "rank,desc",
38419
+ "domain,asc"
38420
+ ];
38421
+ const snapshot = await client.referringDomains({
38422
+ ...normalized,
38423
+ includeSubdomains,
38424
+ limit: input.limit,
38425
+ offset,
38426
+ orderBy,
38427
+ refresh: input.refresh,
38428
+ context: requestContext("link-evidence", input.context)
38429
+ });
38430
+ const results = snapshot.response.tasks.flatMap((task) => task.result ?? []);
38431
+ const rawRows = results.flatMap((result) => result.items ?? []);
38432
+ let invalidRows = 0;
38433
+ const mapped = rawRows.flatMap((row) => {
38434
+ const domain = row.domain?.trim().toLowerCase();
38435
+ if (!domain) {
38436
+ invalidRows += 1;
38437
+ return [];
38438
+ }
38439
+ const firstSeenAt = normalizedDate(row.first_seen);
38440
+ return [{
38441
+ domain,
38442
+ backlinks: numberValue(row.backlinks, "domain backlinks"),
38443
+ referringPages: numberValue(row.referring_pages, "domain referring pages"),
38444
+ brokenBacklinks: numberValue(row.broken_backlinks, "domain broken backlinks"),
38445
+ brokenPages: numberValue(row.broken_pages, "domain broken pages"),
38446
+ firstSeenAt: stringValue(firstSeenAt, "domain first seen date"),
38447
+ metrics: [...metric("source-domain-rank", "DataForSEO source domain rank", row.rank), ...metric("domain-backlinks-spam-score", "DataForSEO domain backlink spam score", row.backlinks_spam_score)]
38448
+ }];
38449
+ });
38450
+ const byDomain = /* @__PURE__ */ new Map();
38451
+ for (const row of mapped.sort((left, right) => observedNumber(right) - observedNumber(left) || compareCodepoints$3(left.domain, right.domain))) if (!byDomain.has(row.domain)) byDomain.set(row.domain, row);
38452
+ const rows = [...byDomain.values()];
38453
+ const duplicateRows = mapped.length - rows.length;
38454
+ const providerTotalRows = totalRows(results.map((result) => result.total_count));
38455
+ return linkEvidence({
38456
+ capability: "referring-domains",
38457
+ data: {
38458
+ target: normalized.target,
38459
+ rows,
38460
+ totalRows: providerTotalRows
38461
+ },
38462
+ snapshot,
38463
+ coverage: linkCoverage({
38464
+ requestedRows: input.limit,
38465
+ returnedRows: rawRows.length,
38466
+ retainedRows: rows.length,
38467
+ invalidRows,
38468
+ providerTotalRows,
38469
+ offset,
38470
+ filtered: true
38471
+ }),
38472
+ endpoint: LINK_ENDPOINTS.referringDomains,
38473
+ limit: input.limit,
38474
+ filters: {
38475
+ scope: normalized.scope,
38476
+ includeSubdomains,
38477
+ status: "live",
38478
+ includeIndirectLinks: true,
38479
+ excludeInternalBacklinks: true,
38480
+ offset
38481
+ },
38482
+ sort: orderBy,
38483
+ warnings: mappingWarnings({
38484
+ snapshotWarnings: snapshot.warnings,
38485
+ invalidRows,
38486
+ duplicateRows,
38487
+ label: "referring-domain"
38488
+ })
38489
+ });
38490
+ }
38491
+ //#endregion
38492
+ //#region packages/core/dist/providers/dataforseo/link-research.js
38493
+ var DataForSeoLinkProvider = class {
38494
+ provider = "dataforseo";
38495
+ capabilitySupport = [
38496
+ "link-summary",
38497
+ "referring-domains",
38498
+ "backlinks"
38499
+ ].map((capability) => ({
38500
+ capability,
38501
+ status: "available",
38502
+ markets: "all"
38503
+ }));
38504
+ client;
38505
+ constructor(options = {}) {
38506
+ this.client = options.client ?? new DataForSeoClient(options);
38507
+ }
38508
+ linkSummary(input) {
38509
+ return dataForSeoLinkSummary(this.client, input);
38510
+ }
38511
+ backlinks(input) {
38512
+ return dataForSeoBacklinks(this.client, input);
38513
+ }
38514
+ referringDomains(input) {
38515
+ return dataForSeoReferringDomains(this.client, input);
38516
+ }
38517
+ };
38518
+ //#endregion
38519
+ //#region packages/core/dist/links/dataforseo.js
38520
+ const DEFAULT_ROW_LIMIT$2 = 100;
38521
+ const MAX_ROW_LIMIT$2 = 500;
38522
+ function rowLimit(value) {
38523
+ const limit = value ?? DEFAULT_ROW_LIMIT$2;
38524
+ if (!Number.isSafeInteger(limit) || limit < 1 || limit > MAX_ROW_LIMIT$2) throw new SeoError("INVALID_INPUT", `DataForSEO link row limit must be between 1 and ${MAX_ROW_LIMIT$2}.`);
38525
+ return limit;
38526
+ }
38527
+ async function collectDataForSeoLinkEvidence(input) {
38528
+ const limit = rowLimit(input.rowLimit);
38529
+ const provider = input.provider ?? new DataForSeoLinkProvider();
38530
+ const context = input.context ?? {
38531
+ reportId: "link-evidence",
38532
+ reportRunId: randomUUID()
38533
+ };
38534
+ const summary = await provider.linkSummary({
38535
+ target: input.target,
38536
+ scope: input.scope,
38537
+ includeSubdomains: input.includeSubdomains,
38538
+ refresh: input.refresh,
38539
+ context
38540
+ });
38541
+ const backlinks = await provider.backlinks({
38542
+ target: summary.data.target,
38543
+ scope: summary.data.scope,
38544
+ includeSubdomains: input.includeSubdomains,
38545
+ mode: "representative",
38546
+ status: "live",
38547
+ limit,
38548
+ refresh: input.refresh,
38549
+ context
38550
+ });
38551
+ const rows = backlinks.data.rows.map((row) => ({
38552
+ sourceUrl: row.sourceUrl,
38553
+ sourceDomain: row.sourceDomain,
38554
+ targetUrl: row.targetUrl,
38555
+ anchorText: row.anchorText ?? void 0,
38556
+ firstSeenAt: row.firstSeenAt ?? void 0,
38557
+ lastSeenAt: row.lastSeenAt ?? void 0,
38558
+ nofollow: row.dofollow === null ? void 0 : !row.dofollow,
38559
+ linkType: row.linkType ?? void 0,
38560
+ attributes: row.attributes,
38561
+ state: row.state,
38562
+ indirect: row.indirect ?? void 0,
38563
+ linksFromPage: row.linksFromPage ?? void 0,
38564
+ linksFromDomain: row.linksFromDomain ?? void 0,
38565
+ providerMetrics: row.metrics
38566
+ }));
38567
+ const counts = /* @__PURE__ */ new Map();
38568
+ for (const row of rows) counts.set(row.targetUrl, (counts.get(row.targetUrl) ?? 0) + 1);
38569
+ const targetCounts = [...counts].map(([targetUrl, observedLinks]) => ({
38570
+ targetUrl,
38571
+ observedLinks
38572
+ })).sort((left, right) => right.observedLinks - left.observedLinks || (left.targetUrl < right.targetUrl ? -1 : left.targetUrl > right.targetUrl ? 1 : 0));
38573
+ const completeness = backlinks.coverage.completeness;
38574
+ const representative = backlinks.data.mode === "representative";
38575
+ const partial = completeness !== "complete" || representative;
38576
+ return {
38577
+ rows,
38578
+ targetCounts,
38579
+ provenance: {
38580
+ provider: "dataforseo",
38581
+ observedAt: backlinks.observedAt > summary.observedAt ? backlinks.observedAt : summary.observedAt,
38582
+ cached: summary.cache.status === "hit" && backlinks.cache.status === "hit",
38583
+ suppliedRows: backlinks.coverage.returnedRows ?? rows.length,
38584
+ validRows: rows.length,
38585
+ invalidRows: backlinks.coverage.invalidRows,
38586
+ duplicateRows: Math.max(0, (backlinks.coverage.returnedRows ?? rows.length) - backlinks.coverage.invalidRows - rows.length),
38587
+ capped: ["capped", "partial"].includes(completeness),
38588
+ rowLimit: limit,
38589
+ completeness: partial ? "partial" : "complete",
38590
+ providerRequests: {
38591
+ methods: [summary.request.endpoint, backlinks.request.endpoint],
38592
+ maxConcurrentRequests: 1
38593
+ },
38594
+ providerCoverage: {
38595
+ targetCountRows: {
38596
+ returnedRows: targetCounts.length,
38597
+ retainedRows: targetCounts.length,
38598
+ invalidRows: 0
38599
+ },
38600
+ detailRows: {
38601
+ returnedRows: backlinks.coverage.returnedRows ?? rows.length,
38602
+ retainedRows: rows.length,
38603
+ invalidRows: backlinks.coverage.invalidRows
38604
+ },
38605
+ summaryRows: {
38606
+ returnedRows: summary.coverage.returnedRows ?? 0,
38607
+ retainedRows: summary.coverage.retainedRows ?? 0,
38608
+ invalidRows: summary.coverage.invalidRows
38609
+ },
38610
+ backlinkRows: {
38611
+ returnedRows: backlinks.coverage.returnedRows ?? rows.length,
38612
+ retainedRows: rows.length,
38613
+ invalidRows: backlinks.coverage.invalidRows,
38614
+ providerTotalRows: backlinks.coverage.providerTotalRows
38615
+ }
38616
+ }
38617
+ },
38618
+ externalProvider: {
38619
+ summary,
38620
+ backlinks
38621
+ },
38622
+ warnings: [
38623
+ ...summary.warnings.map((warning) => warning.message),
38624
+ ...backlinks.warnings.map((warning) => warning.message),
38625
+ ...representative ? ["The link list retains one representative backlink per referring domain. Provider summary counts remain separate from retained rows."] : [],
38626
+ ...!representative && partial ? ["The link list is bounded or partial. Provider summary counts remain separate from retained rows."] : []
38627
+ ]
38628
+ };
38629
+ }
38630
+ //#endregion
37002
38631
  //#region packages/core/dist/links/import.js
37003
38632
  const DEFAULT_ROW_LIMIT$1 = 1e4;
37004
38633
  const MAX_ROW_LIMIT$1 = 1e5;
@@ -37187,6 +38816,10 @@ async function importLinkEvidence(input) {
37187
38816
  //#region packages/core/dist/links/report.js
37188
38817
  const DEFAULT_OUTPUT_LIMIT = 100;
37189
38818
  const MAX_OUTPUT_LIMIT = 500;
38819
+ const MAX_TARGET_COUNTS = 100;
38820
+ const MAX_CONTEXT_ROWS = 100;
38821
+ const MAX_FINDINGS = 50;
38822
+ const MAX_STRUCTURED_DETAIL_ROWS = 750;
37190
38823
  function boundedLimit(value) {
37191
38824
  const limit = value ?? DEFAULT_OUTPUT_LIMIT;
37192
38825
  if (!Number.isInteger(limit) || limit < 1 || limit > MAX_OUTPUT_LIMIT) throw new SeoError("INVALID_INPUT", `Link report limit must be between 1 and ${MAX_OUTPUT_LIMIT}.`);
@@ -37198,7 +38831,7 @@ function targetCounts(rows) {
37198
38831
  return [...counts].map(([targetUrl, observedLinks]) => ({
37199
38832
  targetUrl,
37200
38833
  observedLinks
37201
- })).sort((a, b) => b.observedLinks - a.observedLinks || a.targetUrl.localeCompare(b.targetUrl, "en"));
38834
+ })).sort((a, b) => b.observedLinks - a.observedLinks || (a.targetUrl < b.targetUrl ? -1 : a.targetUrl > b.targetUrl ? 1 : 0));
37202
38835
  }
37203
38836
  function linkEvidenceReport(input) {
37204
38837
  const limit = boundedLimit(input.limit);
@@ -37206,8 +38839,24 @@ function linkEvidenceReport(input) {
37206
38839
  const domains = new Set(input.evidence.rows.map((row) => row.sourceDomain));
37207
38840
  const observedTargets = targetCounts(input.evidence.rows);
37208
38841
  const counts = input.evidence.targetCounts.length ? input.evidence.targetCounts : observedTargets;
38842
+ const providerEvidence = input.evidence.externalProvider ? {
38843
+ summary: input.evidence.externalProvider.summary,
38844
+ backlinks: {
38845
+ ...input.evidence.externalProvider.backlinks,
38846
+ data: {
38847
+ target: input.evidence.externalProvider.backlinks.data.target,
38848
+ mode: input.evidence.externalProvider.backlinks.data.mode,
38849
+ totalRows: input.evidence.externalProvider.backlinks.data.totalRows,
38850
+ retainedRows: input.evidence.externalProvider.backlinks.data.rows.length
38851
+ }
38852
+ }
38853
+ } : null;
38854
+ const targetCountsOutput = counts.slice(0, MAX_TARGET_COUNTS);
38855
+ const contextRows = input.targetContext?.rows.slice(0, MAX_CONTEXT_ROWS) ?? [];
38856
+ const findings = input.targetContext?.findings.slice(0, MAX_FINDINGS) ?? [];
38857
+ const returnedDetailRows = rows.length + targetCountsOutput.length + contextRows.length + findings.length;
37209
38858
  return {
37210
- schemaVersion: 1,
38859
+ schemaVersion: 2,
37211
38860
  generatedAt: (/* @__PURE__ */ new Date()).toISOString(),
37212
38861
  dataStatus: input.evidence.provenance.completeness === "complete" ? "complete" : "partial",
37213
38862
  summary: {
@@ -37216,6 +38865,8 @@ function linkEvidenceReport(input) {
37216
38865
  targetPages: new Set(input.evidence.rows.map((row) => row.targetUrl)).size,
37217
38866
  providerTargetPages: counts.length
37218
38867
  },
38868
+ providerSummary: providerEvidence?.summary.data ?? null,
38869
+ providerEvidence,
37219
38870
  provenance: input.evidence.provenance,
37220
38871
  selection: {
37221
38872
  availableRows: input.evidence.rows.length,
@@ -37223,13 +38874,45 @@ function linkEvidenceReport(input) {
37223
38874
  omittedRows: Math.max(0, input.evidence.rows.length - rows.length),
37224
38875
  limit
37225
38876
  },
37226
- targetCounts: counts.slice(0, 100),
38877
+ outputBudget: {
38878
+ unit: "structured-detail-rows",
38879
+ limit: MAX_STRUCTURED_DETAIL_ROWS,
38880
+ returned: returnedDetailRows,
38881
+ omitted: Math.max(0, input.evidence.rows.length - rows.length + Math.max(0, counts.length - targetCountsOutput.length) + Math.max(0, (input.targetContext?.rows.length ?? 0) - contextRows.length) + Math.max(0, (input.targetContext?.findings.length ?? 0) - findings.length)),
38882
+ sections: {
38883
+ links: {
38884
+ limit: MAX_OUTPUT_LIMIT,
38885
+ returned: rows.length
38886
+ },
38887
+ targetCounts: {
38888
+ limit: MAX_TARGET_COUNTS,
38889
+ returned: targetCountsOutput.length
38890
+ },
38891
+ targetContext: {
38892
+ limit: MAX_CONTEXT_ROWS,
38893
+ returned: contextRows.length
38894
+ },
38895
+ findings: {
38896
+ limit: MAX_FINDINGS,
38897
+ returned: findings.length
38898
+ }
38899
+ }
38900
+ },
38901
+ targetCounts: targetCountsOutput,
37227
38902
  links: rows,
37228
- warnings: input.evidence.warnings,
38903
+ targetContext: input.targetContext ? {
38904
+ ...input.targetContext,
38905
+ rows: contextRows,
38906
+ findings
38907
+ } : null,
38908
+ findings,
38909
+ warnings: [...input.evidence.warnings, ...input.targetContext?.warnings ?? []],
37229
38910
  caveats: [
37230
38911
  "This is bounded provider or imported evidence, not a complete backlink index.",
37231
38912
  "A missing link is not proof that no link exists, and an observed link is not a quality or ranking verdict.",
37232
- "Referring-domain counts describe the retained rows only."
38913
+ "Referring-domain counts describe the retained rows only.",
38914
+ ...providerEvidence ? ["Provider profile counts describe its current index. The representative link rows are a separate bounded sample.", "Provider rank and spam metrics keep their provider names and scales. They are context, not ranking factors or universal authority scores."] : [],
38915
+ ...input.targetContext ? ["Search Console page rows are retained first-party evidence. Missing or capped rows are never treated as zero.", "Saved crawl findings describe the recorded crawl time. Verify the live target before changing it."] : []
37233
38916
  ]
37234
38917
  };
37235
38918
  }
@@ -38070,6 +39753,7 @@ const TELEMETRY_REPORTS = [
38070
39753
  "crawl-history",
38071
39754
  "crawler-rules",
38072
39755
  "llms-txt-audit",
39756
+ "local-search-demand",
38073
39757
  "generate-llms-txt",
38074
39758
  "measure-change",
38075
39759
  "monthly-report",
@@ -40167,6 +41851,74 @@ function registerKeywordTools(server, dependencies = {}) {
40167
41851
  });
40168
41852
  }
40169
41853
  //#endregion
41854
+ //#region packages/mcp/src/local-search-tools.ts
41855
+ const localSearchInput = z$1.strictObject({
41856
+ site: z$1.string().trim().min(1).max(2048),
41857
+ days: z$1.number().int().min(1).max(548).optional(),
41858
+ locationTerms: z$1.array(z$1.string().trim().min(1).max(100)).max(100).optional(),
41859
+ minImpressions: z$1.number().int().min(0).max(1e9).optional(),
41860
+ limit: z$1.number().int().min(1).max(100).optional(),
41861
+ maxRows: z$1.number().int().min(1).max(5e4).optional(),
41862
+ brandTerms: z$1.array(z$1.string().trim().min(1).max(200)).max(20).optional(),
41863
+ includeBrand: z$1.boolean().optional(),
41864
+ includeSerps: z$1.boolean().default(false),
41865
+ countryCode: providerCountryCodeInput.optional(),
41866
+ languageCode: providerLanguageCodeInput.optional(),
41867
+ searchEngine: providerSearchEngineInput,
41868
+ location: providerLocationInput.optional(),
41869
+ device: providerDeviceInput.optional(),
41870
+ provider: providerIdInput.optional(),
41871
+ serpLimit: z$1.number().int().min(1).max(3).optional(),
41872
+ serpDepth: z$1.number().int().min(1).max(20).optional(),
41873
+ refresh: z$1.boolean().optional()
41874
+ }).superRefine((input, context) => {
41875
+ const hasSerpOptions = Boolean(input.countryCode || input.languageCode || input.location || input.device || input.provider || input.serpLimit || input.serpDepth);
41876
+ if (!input.includeSerps && hasSerpOptions) context.addIssue({
41877
+ code: "custom",
41878
+ path: ["includeSerps"],
41879
+ message: "Set includeSerps to true before passing market, provider, or SERP options."
41880
+ });
41881
+ if (input.includeSerps && (!input.countryCode || !input.languageCode || !input.location)) context.addIssue({
41882
+ code: "custom",
41883
+ path: ["includeSerps"],
41884
+ message: "Local SERP evidence requires countryCode, languageCode, and a canonical location."
41885
+ });
41886
+ });
41887
+ function registerLocalSearchTools(server, dependencies = {}) {
41888
+ server.registerTool("seo_local_search_demand", {
41889
+ description: "Find retained local-intent demand, landing pages, repeated local templates, and optional location-specific SERP evidence",
41890
+ inputSchema: localSearchInput
41891
+ }, async ({ site, days, locationTerms, minImpressions, limit, maxRows, brandTerms, includeBrand, includeSerps, countryCode, languageCode, searchEngine, location, device, provider, serpLimit, serpDepth, refresh }) => {
41892
+ try {
41893
+ const report = await (dependencies.localSearchReport ?? localSearchReport)({
41894
+ site,
41895
+ days,
41896
+ locationTerms,
41897
+ minImpressions,
41898
+ limit,
41899
+ maxRows,
41900
+ brandTerms,
41901
+ includeBrand,
41902
+ includeSerps,
41903
+ ...includeSerps && countryCode && languageCode && location ? { market: {
41904
+ countryCode,
41905
+ languageCode,
41906
+ searchEngine,
41907
+ location,
41908
+ device
41909
+ } } : {},
41910
+ provider,
41911
+ serpLimit,
41912
+ serpDepth,
41913
+ refresh
41914
+ });
41915
+ return toolSuccess(report.summary.verdict, compactAgentWorkflowOutput(report));
41916
+ } catch (error) {
41917
+ return toolError(error);
41918
+ }
41919
+ });
41920
+ }
41921
+ //#endregion
40170
41922
  //#region packages/mcp/src/monitoring-tools.ts
40171
41923
  function registerMonitoringTools(server) {
40172
41924
  server.registerTool("seo_crawl_diff", {
@@ -40651,9 +42403,14 @@ function registerProviderTools(server) {
40651
42403
  }
40652
42404
  });
40653
42405
  server.registerTool("seo_link_evidence", {
40654
- description: "Review bounded referring-link evidence from Bing Webmaster or a local export",
42406
+ description: "Review bounded referring-link evidence from DataForSEO, Bing Webmaster or a local export",
40655
42407
  inputSchema: {
40656
42408
  site: z$1.string().url().max(2e3).optional(),
42409
+ provider: z$1.enum(["dataforseo", "bing"]).optional(),
42410
+ target: z$1.string().trim().min(1).max(2048).optional(),
42411
+ scope: z$1.enum(["domain", "page"]).optional(),
42412
+ includeSubdomains: z$1.boolean().optional(),
42413
+ searchConsoleSite: z$1.string().trim().min(1).max(2048).optional(),
40657
42414
  file: z$1.string().min(1).max(4096).optional(),
40658
42415
  format: z$1.enum([
40659
42416
  "csv",
@@ -40663,23 +42420,43 @@ function registerProviderTools(server) {
40663
42420
  rowLimit: z$1.number().int().min(1).max(1e5).optional(),
40664
42421
  targetLimit: z$1.number().int().min(1).max(50).optional(),
40665
42422
  detailPagesPerTarget: z$1.number().int().min(1).max(3).optional(),
40666
- limit: z$1.number().int().min(1).max(500).optional()
42423
+ limit: z$1.number().int().min(1).max(500).optional(),
42424
+ days: z$1.number().int().min(1).max(548).optional(),
42425
+ refresh: z$1.boolean().optional()
40667
42426
  }
40668
- }, async ({ site, file, format, rowLimit, targetLimit, detailPagesPerTarget, limit }) => {
42427
+ }, async ({ site, provider, target, scope, includeSubdomains, searchConsoleSite, file, format, rowLimit, targetLimit, detailPagesPerTarget, limit, days, refresh }) => {
40669
42428
  try {
40670
- if (Boolean(site) === Boolean(file)) throw new SeoError("INVALID_INPUT", "Pass exactly one of site for Bing or file for a local import.");
42429
+ const liveProvider = provider ?? (target ? "dataforseo" : "bing");
42430
+ if (Number(Boolean(file)) + Number(Boolean(site)) + Number(Boolean(target)) !== 1) throw new SeoError("INVALID_INPUT", "Pass one link source: file, site for Bing, or target for DataForSEO.");
42431
+ if (file && provider) throw new SeoError("INVALID_INPUT", "Do not pass provider with a local link file.");
42432
+ if (liveProvider === "dataforseo" && !target) throw new SeoError("INVALID_INPUT", "Pass target for DataForSEO link evidence.");
42433
+ if (liveProvider === "bing" && !site && !file) throw new SeoError("INVALID_INPUT", "Pass site for Bing link evidence.");
42434
+ const evidence = file ? await importLinkEvidence({
42435
+ file,
42436
+ format,
42437
+ rowLimit
42438
+ }) : liveProvider === "dataforseo" ? await collectDataForSeoLinkEvidence({
42439
+ target: target ?? "",
42440
+ scope,
42441
+ includeSubdomains,
42442
+ rowLimit,
42443
+ refresh
42444
+ }) : await collectBingLinkEvidence({
42445
+ site: site ?? "",
42446
+ rowLimit,
42447
+ targetLimit,
42448
+ detailPagesPerTarget
42449
+ });
40671
42450
  const report = linkEvidenceReport({
40672
- evidence: file ? await importLinkEvidence({
40673
- file,
40674
- format,
40675
- rowLimit
40676
- }) : await collectBingLinkEvidence({
40677
- site: site ?? "",
40678
- rowLimit,
40679
- targetLimit,
40680
- detailPagesPerTarget
40681
- }),
40682
- limit
42451
+ evidence,
42452
+ limit,
42453
+ targetContext: searchConsoleSite || evidence.provenance.provider === "dataforseo" ? await linkTargetContext({
42454
+ evidence,
42455
+ searchConsoleSite,
42456
+ crawlSite: searchConsoleSite ?? target,
42457
+ days,
42458
+ refresh
42459
+ }) : void 0
40683
42460
  });
40684
42461
  return toolSuccess(`${report.summary.observedLinks} referring links were retained from ${report.provenance.provider}.`, report);
40685
42462
  } catch (error) {
@@ -41094,15 +42871,18 @@ const REPORT_DEPTH = {
41094
42871
  "link-evidence": {
41095
42872
  readOrder: [
41096
42873
  "dataStatus, provenance, warnings, and caveats",
41097
- "selection and summary coverage counts",
41098
- "targetCounts, then the bounded links list"
42874
+ "providerSummary and providerEvidence coverage, cache, cost, filters, and observation time when present",
42875
+ "selection, outputBudget, and retained summary counts",
42876
+ "findings and targetContext with crawl and Search Console evidence states",
42877
+ "targetCounts, then the bounded representative links list and provider-native metrics"
41099
42878
  ],
41100
42879
  doNotClaim: [
41101
- "The retained rows are not a complete backlink index.",
41102
- "Missing rows do not prove a link does not exist.",
41103
- "Observed links do not establish quality, authority, value, or ranking impact."
42880
+ "The retained rows are not a complete backlink index, and missing rows do not prove a link does not exist.",
42881
+ "Observed links do not establish quality, authority, value, or ranking impact.",
42882
+ "Provider rank and spam metrics keep their provider-specific scales. They are not search-engine ranking factors or interchangeable authority scores.",
42883
+ "A missing Search Console row is not zero, and a saved crawl finding does not prove the target still has that state."
41104
42884
  ],
41105
- verify: "Open a selected referring URL and confirm the link is still present before acting on it.",
42885
+ verify: "Open a selected referring URL to confirm the link, then check any flagged target live with redirect-trace or audit-page before changing it.",
41106
42886
  related: [
41107
42887
  {
41108
42888
  id: "link-recovery",
@@ -41115,6 +42895,10 @@ const REPORT_DEPTH = {
41115
42895
  {
41116
42896
  id: "site-crawl",
41117
42897
  reason: "Checks internal links and live pages."
42898
+ },
42899
+ {
42900
+ id: "search-performance-overview",
42901
+ reason: "Adds broader first-party search context."
41118
42902
  }
41119
42903
  ]
41120
42904
  },
@@ -42214,6 +43998,41 @@ const REPORT_DEPTH = {
42214
43998
  }
42215
43999
  ]
42216
44000
  },
44001
+ "local-search-demand": {
44002
+ readOrder: [
44003
+ "dataStatus, source row cap, possiblyTruncated, and selection exclusions",
44004
+ "methodologyDetails supplied terms and automatic heuristic patterns",
44005
+ "opportunities intent evidence, metrics, page coverage, and action",
44006
+ "templates as repeated page review signals",
44007
+ "serpEvidence requested status, exact market, coverage, cache, cost, features, and organic rows",
44008
+ "caveats and nextSteps"
44009
+ ],
44010
+ doNotClaim: [
44011
+ "A place in a query is not evidence of the searcher location, and Google Analytics geography cannot prove the query.",
44012
+ "Search Console average position is not an exact city rank, and one live snapshot is not rank history.",
44013
+ "A local pack feature does not expose every listing, prove business visibility, or replace Google Business Profile evidence.",
44014
+ "A repeated local URL pattern does not prove shared intent, page quality, unique source data, or permission to scale."
44015
+ ],
44016
+ verify: "Inspect representative landing pages and the retained live results in the same location, language, and device before changing or expanding local pages.",
44017
+ related: [
44018
+ {
44019
+ id: "pseo-audit",
44020
+ reason: "Validates repeated local page templates."
44021
+ },
44022
+ {
44023
+ id: "serp-results",
44024
+ reason: "Opens one local result snapshot."
44025
+ },
44026
+ {
44027
+ id: "rank-tracking",
44028
+ reason: "Measures exact local ranks over time."
44029
+ },
44030
+ {
44031
+ id: "keyword-research",
44032
+ reason: "Finds candidate terms beyond retained Search Console queries."
44033
+ }
44034
+ ]
44035
+ },
42217
44036
  "saved-keywords": {
42218
44037
  readOrder: [
42219
44038
  "dataStatus, summary, evidence pagination, and any tag filter",
@@ -42843,10 +44662,14 @@ const REPORT_GUIDANCE = {
42843
44662
  },
42844
44663
  "link-evidence": {
42845
44664
  name: "Referring link evidence",
42846
- description: "Review a bounded set of pages that link to the site from Bing Webmaster or a local export.",
42847
- useWhen: ["You want concrete referring URLs and anchor text where available.", "You need one normalized view of Bing or an exported CSV, JSON, or JSONL file."],
42848
- avoidWhen: ["You need a complete backlink index or a domain authority score.", "You want to find broken internal links during a crawl."],
42849
- outcome: "A bounded, deduplicated link list with target-page counts and explicit source coverage."
44665
+ description: "Review bounded referring-link evidence from a live provider, Bing Webmaster or a local export.",
44666
+ useWhen: [
44667
+ "You want concrete referring URLs and anchor text where available.",
44668
+ "You want to compare a live provider summary with saved crawl and Search Console evidence for linked target pages.",
44669
+ "You need one normalized view of DataForSEO, Bing or an exported CSV, JSON, or JSONL file."
44670
+ ],
44671
+ avoidWhen: ["You need a complete backlink index or a universal authority score.", "You want to find broken internal links during a crawl."],
44672
+ outcome: "Provider summary counts, representative links, target-page search and crawl context, and bounded technical findings with explicit source coverage."
42850
44673
  },
42851
44674
  "server-log-analysis": {
42852
44675
  name: "Server log crawler analysis",
@@ -43101,6 +44924,17 @@ const REPORT_GUIDANCE = {
43101
44924
  avoidWhen: ["You need exact current rankings or pages in the live results.", "You plan to turn every discovered term into a page without checking intent or fit."],
43102
44925
  outcome: "A bounded keyword set with discovery sources, provider estimates, evidence states, and research follow-ups."
43103
44926
  },
44927
+ "local-search-demand": {
44928
+ name: "Local search demand",
44929
+ description: "Find location-specific Search Console demand and enrich a small shortlist with exact local result snapshots.",
44930
+ useWhen: [
44931
+ "You want to find retained queries that name a service area, use a proximity phrase, or contain a postcode or ZIP code.",
44932
+ "You need to connect local demand with current landing pages and repeated programmatic page patterns.",
44933
+ "You want optional city and device-specific result evidence without replacing first-party performance data."
44934
+ ],
44935
+ avoidWhen: ["You need Google Business Profile performance or a complete business listing audit.", "You want the searcher location inferred from a place name in the query."],
44936
+ outcome: "A bounded local-intent query and page queue, repeated local template signals, and optional local SERP feature and competitor evidence."
44937
+ },
43104
44938
  "saved-keywords": {
43105
44939
  name: "Saved keyword set",
43106
44940
  description: "Review one local keyword set with metric freshness, tags, page mappings, and explicit view limits.",
@@ -43679,6 +45513,11 @@ const reportGroups = [
43679
45513
  "seo_serp_results"
43680
45514
  ]
43681
45515
  },
45516
+ {
45517
+ category: "opportunities",
45518
+ register: registerLocalSearchTools,
45519
+ names: ["seo_local_search_demand"]
45520
+ },
43682
45521
  {
43683
45522
  category: "opportunities",
43684
45523
  register: registerDomainResearchTools,