seo 0.2.21 → 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-CSE-hgHf.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,7 +1811,7 @@ 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
1817
  function metric$1(row, name) {
@@ -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) {
@@ -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() ?? "";
@@ -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) {
@@ -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) {
@@ -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;
@@ -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,
@@ -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,
@@ -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
  }
@@ -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,13 +18665,13 @@ 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
18673
  const DEFAULT_DAYS$3 = 28;
18674
- const MAX_DAYS$3 = 548;
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;
@@ -18695,12 +18695,12 @@ function applyVerification$1(item, coverage) {
18695
18695
  };
18696
18696
  }
18697
18697
  async function quickWinsReport(input, dependencies = defaultDependencies$9) {
18698
- const explicitRange = explicitDateRange(input, MAX_DAYS$3);
18698
+ const explicitRange = explicitDateRange(input, MAX_DAYS$4);
18699
18699
  const days = explicitRange?.days ?? integerOption$2({
18700
18700
  value: input.days,
18701
18701
  fallback: DEFAULT_DAYS$3,
18702
18702
  minimum: 1,
18703
- maximum: MAX_DAYS$3,
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$9) {
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
@@ -19068,7 +19068,7 @@ function analyzeStrikingDistanceRows(input) {
19068
19068
  //#endregion
19069
19069
  //#region packages/core/dist/analyze/striking-distance.js
19070
19070
  const DEFAULT_DAYS$2 = 28;
19071
- const MAX_DAYS$2 = 548;
19071
+ const MAX_DAYS$3 = 548;
19072
19072
  const DEFAULT_VERIFY_LIMIT = 5;
19073
19073
  const MAX_SOURCE_ROWS$2 = 1e5;
19074
19074
  const defaultDependencies$8 = {
@@ -19076,13 +19076,13 @@ const defaultDependencies$8 = {
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
19084
  if (value === void 0) return DEFAULT_DAYS$2;
19085
- if (!Number.isInteger(value) || value < 1 || value > MAX_DAYS$2) throw new SeoError("INVALID_INPUT", `Days must be a whole number between 1 and ${MAX_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) {
@@ -19140,7 +19140,7 @@ async function strikingDistance(input, dependencies = defaultDependencies$8) {
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) => {
@@ -23433,7 +23433,7 @@ function resolveProvider(input) {
23433
23433
  //#endregion
23434
23434
  //#region packages/core/dist/analyze/domain-research/shared.js
23435
23435
  const MAX_GSC_DOMAIN_ROWS = 1e5;
23436
- function compareText$6(left, right) {
23436
+ function compareText$7(left, right) {
23437
23437
  return left < right ? -1 : left > right ? 1 : 0;
23438
23438
  }
23439
23439
  function normalizedKeyword$1(value) {
@@ -23550,8 +23550,8 @@ function aggregateGscQueries(rows) {
23550
23550
  clicks: item.clicks,
23551
23551
  impressions: item.impressions,
23552
23552
  averagePosition: item.positionWeight ? item.weightedPosition / item.positionWeight : null,
23553
- urls: [...item.urls].sort(compareText$6).slice(0, 5)
23554
- })).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));
23555
23555
  }
23556
23556
  async function acquireGscQueries(input) {
23557
23557
  const range = finalGscDateRange(input.days, input.now);
@@ -23917,7 +23917,7 @@ function pseoQueryPatterns(rows) {
23917
23917
  //#region packages/core/dist/analyze/pseo/templates.js
23918
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;
23919
23919
  const DATE_RE = /^\d{4}-\d{2}-\d{2}$/;
23920
- function compareText$5(left, right) {
23920
+ function compareText$6(left, right) {
23921
23921
  const leftPoints = [...left].map((value) => value.codePointAt(0) ?? 0);
23922
23922
  const rightPoints = [...right].map((value) => value.codePointAt(0) ?? 0);
23923
23923
  for (let index = 0; index < Math.min(leftPoints.length, rightPoints.length); index++) {
@@ -23973,7 +23973,7 @@ function valueTokens(values) {
23973
23973
  const tokens = value.normalize("NFKC").toLowerCase().match(/[\p{L}\p{N}]+/gu) ?? [];
23974
23974
  for (const token of tokens.filter((item) => item.length > 1 || /[^\p{ASCII}]/u.test(item))) counts.set(token, (counts.get(token) ?? 0) + 1);
23975
23975
  }
23976
- 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);
23977
23977
  }
23978
23978
  function templateShape(signature, urls) {
23979
23979
  const signatureParts = signature.split("/").filter(Boolean);
@@ -24022,7 +24022,7 @@ function collectClusters(urls, signatures) {
24022
24022
  return bySignature;
24023
24023
  }
24024
24024
  function clusterPseoTemplates(urls, opts = {}) {
24025
- const uniqueUrls = [...new Set(urls)].sort(compareText$5);
24025
+ const uniqueUrls = [...new Set(urls)].sort(compareText$6);
24026
24026
  const minUrls = opts.minUrls ?? 3;
24027
24027
  const minShare = opts.minShare ?? .01;
24028
24028
  const sampleSize = opts.sampleSize ?? 5;
@@ -24040,15 +24040,15 @@ function clusterPseoTemplates(urls, opts = {}) {
24040
24040
  shape: templateShape(signature, clusterUrls),
24041
24041
  urlCount: clusterUrls.length,
24042
24042
  share: uniqueUrls.length ? clusterUrls.length / uniqueUrls.length : 0,
24043
- sampleUrls: clusterUrls.sort(compareText$5).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$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);
24045
24045
  }
24046
24046
  //#endregion
24047
24047
  //#region packages/core/dist/analyze/domain-research/gap-analysis.js
24048
24048
  const MAX_RELEVANCE_QUERY_REFS = 3;
24049
24049
  const MAX_FIRST_PARTY_PATTERN_URLS = 1e4;
24050
24050
  function terms(value) {
24051
- 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);
24052
24052
  }
24053
24053
  function observedVolume$1(row) {
24054
24054
  return value(row.monthlySearchVolume) ?? -1;
@@ -24092,7 +24092,7 @@ function relevanceForKeyword(keyword, acquisition, tokenIndex) {
24092
24092
  query: row.query,
24093
24093
  overlap: terms(row.query).filter((token) => keywordTerms.includes(token)).length,
24094
24094
  impressions: row.impressions
24095
- })).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);
24096
24096
  return {
24097
24097
  state: sharedTokens.length >= 2 ? "observed-overlap" : sharedTokens.length === 1 ? "weak-overlap" : "unavailable",
24098
24098
  sharedTokens,
@@ -24114,10 +24114,10 @@ function repeatedCompetitorPatterns(rows) {
24114
24114
  urlCount: pattern.urlCount,
24115
24115
  sampleUrls: pattern.sampleUrls,
24116
24116
  evidenceRefs: items.flatMap((item) => pattern.sampleUrls.includes(item.row.url) ? [item.evidenceRef] : [])
24117
- }))).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));
24118
24118
  }
24119
24119
  function ownPatterns(acquisition) {
24120
- 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), {
24121
24121
  minUrls: 3,
24122
24122
  minShare: 0,
24123
24123
  limit: 50,
@@ -24158,10 +24158,10 @@ function analyzeCompetitorGap(acquisition) {
24158
24158
  const byDomain = /* @__PURE__ */ new Map();
24159
24159
  for (const item of rows) {
24160
24160
  const current = byDomain.get(item.domain);
24161
- 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);
24162
24162
  }
24163
- const retainedRows = [...byDomain.values()].sort((left, right) => left.row.rankGroup - right.row.rankGroup || compareText$6(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$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];
24165
24165
  const firstParty = firstPartyByQuery.get(keyword);
24166
24166
  const relevance = relevanceForKeyword(keyword, acquisition, tokenIndex.index);
24167
24167
  const ownRank = ownRanks.get(keyword) ?? null;
@@ -24205,7 +24205,7 @@ function analyzeCompetitorGap(acquisition) {
24205
24205
  },
24206
24206
  evidenceRefs: retainedRows.map((item) => item.evidenceRef)
24207
24207
  };
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$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));
24209
24209
  return {
24210
24210
  rawRows,
24211
24211
  candidates,
@@ -24677,7 +24677,7 @@ async function rankingPagesReport(input, dependencies = {}) {
24677
24677
  impressions: row.impressions,
24678
24678
  averagePosition: row.position
24679
24679
  }] : [];
24680
- }).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));
24681
24681
  const firstPartyStatus = !input.site ? "not-requested" : (firstPartyResponse?.rowsFetched ?? 0) >= MAX_GSC_PAGE_ROWS ? "partial" : firstPartyResponse?.rows.length === 0 ? "empty" : "complete";
24682
24682
  const findings = patterns.slice(0, 5).map((pattern, index) => ({
24683
24683
  code: "repeated-ranking-page-pattern",
@@ -24758,7 +24758,7 @@ async function serpCompetitorsReport(input, dependencies = {}) {
24758
24758
  const now = (dependencies.now ?? (() => /* @__PURE__ */ new Date()))();
24759
24759
  const market = validatedMarket(input.market);
24760
24760
  const providerId = validatedProvider(input.provider);
24761
- 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);
24762
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.");
24763
24763
  const target = input.targetDomain ? normalizeDomain$1(input.targetDomain) : null;
24764
24764
  const declared = declaredCompetitors(input.declaredCompetitors);
@@ -26537,7 +26537,7 @@ function providerReportError(error) {
26537
26537
  if (error.code === "rate-limit") throw new SeoError("RATE_LIMITED", error.message);
26538
26538
  throw new SeoError("PROVIDER_UNAVAILABLE", error.message);
26539
26539
  }
26540
- function validateInput$3(input) {
26540
+ function validateInput$4(input) {
26541
26541
  if (input.keywords.length < 1 || input.keywords.length > MAX_REPORT_KEYWORDS || input.keywords.some((keyword) => {
26542
26542
  const value = keyword.trim();
26543
26543
  return value.length < 1 || value.length > 80 || value.split(/\s+/u).length > 10;
@@ -26571,7 +26571,7 @@ function findings$1(analysis) {
26571
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);
26572
26572
  }
26573
26573
  async function keywordMetricsReport(input, dependencies = {}) {
26574
- const validated = validateInput$3(input);
26574
+ const validated = validateInput$4(input);
26575
26575
  let candidates;
26576
26576
  try {
26577
26577
  candidates = dependencies.candidates ?? await defaultCandidates$2();
@@ -26866,16 +26866,16 @@ function benchmarkDetails(benchmark) {
26866
26866
  positiveUrlSamples: benchmark.positiveUrlSamples
26867
26867
  };
26868
26868
  }
26869
- function boundedInteger$2(input) {
26869
+ function boundedInteger$3(input) {
26870
26870
  const value = Number.isFinite(input.value) ? input.value ?? 0 : input.fallback;
26871
26871
  return Math.min(input.maximum ?? Number.POSITIVE_INFINITY, Math.max(1, Math.floor(value)));
26872
26872
  }
26873
26873
  function analyzeQueryClustersFromRows(input) {
26874
- const minImpressions = boundedInteger$2({
26874
+ const minImpressions = boundedInteger$3({
26875
26875
  value: input.minImpressions,
26876
26876
  fallback: 25
26877
26877
  });
26878
- const limit = boundedInteger$2({
26878
+ const limit = boundedInteger$3({
26879
26879
  value: input.limit,
26880
26880
  fallback: 25,
26881
26881
  maximum: 100
@@ -26994,7 +26994,7 @@ function integerInRange(value, fallback, minimum, maximum) {
26994
26994
  if (value === void 0 || !Number.isFinite(value)) return fallback;
26995
26995
  return Math.min(maximum, Math.max(minimum, Math.floor(value)));
26996
26996
  }
26997
- function compareText$4(left, right) {
26997
+ function compareText$5(left, right) {
26998
26998
  const leftPoints = [...left].map((character) => character.codePointAt(0) ?? 0);
26999
26999
  const rightPoints = [...right].map((character) => character.codePointAt(0) ?? 0);
27000
27000
  for (let index = 0; index < Math.min(leftPoints.length, rightPoints.length); index++) {
@@ -27013,7 +27013,7 @@ function normalizedHttpUrl(value) {
27013
27013
  return;
27014
27014
  }
27015
27015
  }
27016
- function validRow(row, query, url) {
27016
+ function validRow$1(row, query, url) {
27017
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;
27018
27018
  }
27019
27019
  function queryFromRow(row) {
@@ -27026,7 +27026,7 @@ function queryFromRow(row) {
27026
27026
  };
27027
27027
  }
27028
27028
  function compareQueries(left, right) {
27029
- 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);
27030
27030
  }
27031
27031
  function recommendation(input) {
27032
27032
  return {
@@ -27072,7 +27072,7 @@ function pageItem(url, rows) {
27072
27072
  };
27073
27073
  }
27074
27074
  function compareItems$1(left, right) {
27075
- 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);
27076
27076
  }
27077
27077
  function analyzeSecondPageRows(input) {
27078
27078
  const minImpressions = integerInRange(input.minImpressions, DEFAULT_MIN_IMPRESSIONS, 0, MAX_MIN_IMPRESSIONS);
@@ -27093,7 +27093,7 @@ function analyzeSecondPageRows(input) {
27093
27093
  for (const row of input.rows) {
27094
27094
  const query = row.keys[0]?.trim() ?? "";
27095
27095
  const url = normalizedHttpUrl(row.keys[1]?.trim() ?? "");
27096
- if (!validRow(row, query, url)) selection.invalidRows++;
27096
+ if (!validRow$1(row, query, url)) selection.invalidRows++;
27097
27097
  else if (row.position <= 10 || row.position > 20) selection.outsidePositionRows++;
27098
27098
  else if (shouldExcludeBrandQuery({
27099
27099
  query,
@@ -27185,7 +27185,7 @@ function analyzeSecondPageRows(input) {
27185
27185
  //#endregion
27186
27186
  //#region packages/core/dist/analyze/keyword-opportunities.js
27187
27187
  const DEFAULT_DAYS$1 = 28;
27188
- const MAX_DAYS$1 = 548;
27188
+ const MAX_DAYS$2 = 548;
27189
27189
  const DEFAULT_REPORT_LIMIT = 10;
27190
27190
  const MAX_REPORT_LIMIT = 25;
27191
27191
  const DEFAULT_KEYWORD_LIMIT = 30;
@@ -27202,7 +27202,7 @@ const SOURCE_ORDER = [
27202
27202
  "second-page",
27203
27203
  "striking-distance"
27204
27204
  ];
27205
- function compareText$3(left, right) {
27205
+ function compareText$4(left, right) {
27206
27206
  return left < right ? -1 : left > right ? 1 : 0;
27207
27207
  }
27208
27208
  function rounded(value, precision = 3) {
@@ -27214,7 +27214,7 @@ function integerOption$1(input) {
27214
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}.`);
27215
27215
  return input.value;
27216
27216
  }
27217
- function validateInput$2(input) {
27217
+ function validateInput$3(input) {
27218
27218
  const site = input.site.trim();
27219
27219
  if (!site || site.length > 2048) throw new SeoError("INVALID_INPUT", "Use a valid Search Console property.");
27220
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.");
@@ -27234,7 +27234,7 @@ function validateInput$2(input) {
27234
27234
  value: input.days,
27235
27235
  fallback: DEFAULT_DAYS$1,
27236
27236
  minimum: 1,
27237
- maximum: MAX_DAYS$1,
27237
+ maximum: MAX_DAYS$2,
27238
27238
  label: "Days"
27239
27239
  }),
27240
27240
  limit: integerOption$1({
@@ -27346,7 +27346,7 @@ function aggregateCandidates(rows) {
27346
27346
  impressions: rounded(row.impressions),
27347
27347
  ctr: rounded(row.ctr, 4),
27348
27348
  averagePosition: rounded(row.position, 2)
27349
- })).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));
27350
27350
  return [keyword, {
27351
27351
  keyword,
27352
27352
  clicks: rounded(clicks),
@@ -27580,7 +27580,7 @@ function nextSteps(input) {
27580
27580
  return steps;
27581
27581
  }
27582
27582
  async function keywordOpportunitiesReport(input, dependencies = {}) {
27583
- const validated = validateInput$2(input);
27583
+ const validated = validateInput$3(input);
27584
27584
  const now = (dependencies.now ?? (() => /* @__PURE__ */ new Date()))();
27585
27585
  const generatedAt = now.toISOString();
27586
27586
  const range = finalGscDateRange(validated.days, now);
@@ -27948,7 +27948,7 @@ function providerError$2(error) {
27948
27948
  if (error.code === "configuration") throw new SeoError("INVALID_INPUT", error.message);
27949
27949
  throw new SeoError("PROVIDER_UNAVAILABLE", error.message);
27950
27950
  }
27951
- function validateInput$1(input) {
27951
+ function validateInput$2(input) {
27952
27952
  if (input.seeds.length < 1 || input.seeds.length > MAX_RESEARCH_SEEDS || input.seeds.some((seed) => {
27953
27953
  const value = seed.trim();
27954
27954
  return value.length < 1 || value.length > 80 || value.split(/\s+/u).length > 10;
@@ -28001,7 +28001,7 @@ async function keywordResearchReport(input, dependencies = {}) {
28001
28001
  "suggestions"
28002
28002
  ];
28003
28003
  const limit = input.limit ?? 50;
28004
- const validated = validateInput$1({
28004
+ const validated = validateInput$2({
28005
28005
  ...input,
28006
28006
  sources,
28007
28007
  limit
@@ -28084,6 +28084,861 @@ async function keywordResearchReport(input, dependencies = {}) {
28084
28084
  };
28085
28085
  }
28086
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
28087
28942
  //#region packages/core/dist/analyze/monitoring/crawl-recommendations.js
28088
28943
  function statusMovedToError(item) {
28089
28944
  return item.kind !== "removed" && (item.after?.status ?? 0) >= 400 && (item.before?.status ?? 200) < 400;
@@ -32548,326 +33403,6 @@ function pseoOpportunityVerdict(input) {
32548
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.`;
32549
33404
  }
32550
33405
  //#endregion
32551
- //#region packages/core/dist/providers/dataforseo/serp-snapshot.js
32552
- const MAX_SERP_DEPTH = 100;
32553
- const SERP_ENDPOINT = "v3/serp/google/organic/live/advanced";
32554
- function safeUrl$1(value) {
32555
- if (!value) return null;
32556
- try {
32557
- const parsed = new URL(value);
32558
- if (!["http:", "https:"].includes(parsed.protocol)) return null;
32559
- parsed.username = "";
32560
- parsed.password = "";
32561
- return parsed.toString();
32562
- } catch {
32563
- return null;
32564
- }
32565
- }
32566
- function validOrganic(item) {
32567
- 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;
32568
- }
32569
- function organicResult(item) {
32570
- if (!validOrganic(item)) return null;
32571
- const url = safeUrl$1(item.url);
32572
- if (!url) return null;
32573
- return {
32574
- rankGroup: item.rank_group,
32575
- rankAbsolute: item.rank_absolute,
32576
- page: item.page,
32577
- domain: item.domain.trim().toLowerCase(),
32578
- url,
32579
- title: item.title ?? null,
32580
- description: item.description ?? null,
32581
- isFeaturedSnippet: item.is_featured_snippet ?? null
32582
- };
32583
- }
32584
- function checkedAt(value, fallback) {
32585
- const timestamp = Date.parse(value);
32586
- return Number.isFinite(timestamp) ? new Date(timestamp).toISOString() : fallback;
32587
- }
32588
- function mapDataForSeoSerpSnapshot(input, snapshot, endpoint = SERP_ENDPOINT) {
32589
- const market = searchMarketSchema.parse(input.market);
32590
- const keyword = normalizedKeyword$2(input.keyword);
32591
- const location = locationRequest(market, "serp-snapshot");
32592
- const result = snapshot.response.tasks.flatMap((task) => task.result ?? []).find((item) => normalizedKeyword$2(item.keyword) === keyword);
32593
- if (!result) throw new ProviderError({
32594
- provider: "dataforseo",
32595
- operation: "serp-snapshot",
32596
- code: "invalid-response",
32597
- message: "DataForSEO returned no matching SERP result."
32598
- });
32599
- const warnings = [...snapshot.warnings];
32600
- const items = result.items ?? [];
32601
- const organicItems = items.filter((item) => item.type === "organic");
32602
- const mappedOrganicResults = organicItems.flatMap((item) => {
32603
- const mapped = organicResult(item);
32604
- return mapped ? [mapped] : [];
32605
- }).sort((left, right) => left.rankAbsolute - right.rankAbsolute || left.rankGroup - right.rankGroup || compareCodepoints$3(left.url, right.url));
32606
- const organicResults = mappedOrganicResults.slice(0, input.depth);
32607
- const invalidRows = organicItems.length - mappedOrganicResults.length;
32608
- const resultCapped = mappedOrganicResults.length > input.depth;
32609
- const invalidResultCount = result.se_results_count !== null && result.se_results_count !== void 0 && result.se_results_count < organicResults.length;
32610
- if (invalidRows > 0) warnings.push({
32611
- code: "invalid-organic-results",
32612
- field: "organicResults",
32613
- message: `DataForSEO returned ${invalidRows} organic result${invalidRows === 1 ? "" : "s"} without a valid rank, domain, or URL.`
32614
- });
32615
- if (!Number.isFinite(Date.parse(result.datetime))) warnings.push({
32616
- code: "invalid-serp-observation-time",
32617
- field: "checkedAt",
32618
- message: "DataForSEO returned an invalid SERP observation time; the local response time was retained."
32619
- });
32620
- const checkUrl = safeUrl$1(result.check_url);
32621
- if (result.check_url && !checkUrl) warnings.push({
32622
- code: "invalid-serp-check-url",
32623
- field: "checkUrl",
32624
- message: "DataForSEO returned an invalid SERP check URL."
32625
- });
32626
- if (invalidResultCount) warnings.push({
32627
- code: "invalid-serp-result-count",
32628
- field: "resultCount",
32629
- message: "DataForSEO returned an estimated result count below the retained organic result count; the estimate was discarded."
32630
- });
32631
- const features = [.../* @__PURE__ */ new Set([...result.item_types ?? [], ...items.map((item) => item.type)])].sort(compareCodepoints$3);
32632
- return {
32633
- schemaVersion: 1,
32634
- provider: "dataforseo",
32635
- capability: "serp-snapshot",
32636
- data: {
32637
- keyword,
32638
- effectiveKeyword: normalizedKeyword$2(result.spell?.keyword ?? result.keyword),
32639
- searchEngineDomain: result.se_domain?.trim().toLowerCase() ?? null,
32640
- checkedAt: checkedAt(result.datetime, snapshot.observedAt),
32641
- checkUrl,
32642
- resultCount: invalidResultCount ? null : result.se_results_count ?? null,
32643
- pagesCount: result.pages_count === void 0 ? null : result.pages_count,
32644
- features,
32645
- organicResults
32646
- },
32647
- observedAt: snapshot.observedAt,
32648
- market: {
32649
- ...market,
32650
- device: market.device ?? "desktop"
32651
- },
32652
- coverage: {
32653
- requestedRows: input.depth,
32654
- returnedRows: snapshot.returnedRows,
32655
- retainedRows: organicResults.length,
32656
- invalidRows,
32657
- providerTotalRows: invalidResultCount ? null : result.se_results_count ?? null,
32658
- completeness: invalidRows > 0 || invalidResultCount ? "partial" : resultCapped ? "capped" : "complete",
32659
- nextCursor: null
32660
- },
32661
- cache: snapshot.cache,
32662
- cost: snapshot.cost,
32663
- request: {
32664
- operation: "serp-snapshot",
32665
- endpoint,
32666
- limit: input.depth,
32667
- filters: {
32668
- countryCode: market.countryCode,
32669
- languageCode: market.languageCode,
32670
- device: market.device ?? "desktop",
32671
- ...location.locationCode !== void 0 ? { locationCode: location.locationCode } : { locationName: location.locationName }
32672
- },
32673
- sort: ["rankAbsolute:ascending", "url:codepoint-ascending"]
32674
- },
32675
- warnings
32676
- };
32677
- }
32678
- var DataForSeoSerpSnapshotProvider = class {
32679
- provider = "dataforseo";
32680
- capabilitySupport = [{
32681
- capability: "serp-snapshot",
32682
- status: "available",
32683
- markets: [{
32684
- searchEngines: ["google"],
32685
- devices: ["desktop", "mobile"],
32686
- location: "any"
32687
- }]
32688
- }];
32689
- client;
32690
- constructor(options = {}) {
32691
- this.client = options.client ?? new DataForSeoClient(options);
32692
- }
32693
- async serpSnapshot(input) {
32694
- const market = searchMarketSchema.parse(input.market);
32695
- if (market.searchEngine !== "google") throw new ProviderError({
32696
- provider: "dataforseo",
32697
- operation: "serp-snapshot",
32698
- code: "configuration",
32699
- message: "DataForSEO SERP snapshots currently support Google."
32700
- });
32701
- if (!Number.isSafeInteger(input.depth) || input.depth < 1 || input.depth > MAX_SERP_DEPTH) throw new ProviderError({
32702
- provider: "dataforseo",
32703
- operation: "serp-snapshot",
32704
- code: "configuration",
32705
- message: "SERP depth must be from 1 to 100."
32706
- });
32707
- const keyword = normalizedKeyword$2(input.keyword);
32708
- if (!keyword || keyword.length > 80 || keyword.split(/\s+/u).length > 10) throw new ProviderError({
32709
- provider: "dataforseo",
32710
- operation: "serp-snapshot",
32711
- code: "configuration",
32712
- message: "SERP snapshots require a keyword of at most 80 characters and 10 words."
32713
- });
32714
- const context = input.context ?? {
32715
- reportId: "serp-results",
32716
- reportRunId: randomUUID()
32717
- };
32718
- const location = locationRequest(market, "serp-snapshot");
32719
- return mapDataForSeoSerpSnapshot(input, await this.client.serpLive({
32720
- keyword,
32721
- languageCode: market.languageCode.split("-")[0],
32722
- ...location,
32723
- device: market.device ?? "desktop",
32724
- depth: input.depth,
32725
- refresh: input.refresh,
32726
- context
32727
- }));
32728
- }
32729
- };
32730
- //#endregion
32731
- //#region packages/core/dist/analyze/serp-results.js
32732
- const MAX_REPORT_DEPTH = 100;
32733
- function compareCodepoints(left, right) {
32734
- return left < right ? -1 : left > right ? 1 : 0;
32735
- }
32736
- function snapshotProvider(candidate) {
32737
- return "serpSnapshot" in candidate && typeof candidate.serpSnapshot === "function" ? candidate : null;
32738
- }
32739
- async function defaultCandidates() {
32740
- return [{
32741
- adapter: new DataForSeoSerpSnapshotProvider(),
32742
- connected: Boolean(await readDataForSeoCredentials()),
32743
- priority: 10
32744
- }];
32745
- }
32746
- function providerError$1(error) {
32747
- if (!(error instanceof ProviderError)) throw error;
32748
- if (error.code === "rate-limit") throw new SeoError("RATE_LIMITED", error.message);
32749
- if (error.code === "configuration") throw new SeoError("INVALID_INPUT", error.message);
32750
- throw new SeoError("PROVIDER_UNAVAILABLE", error.message);
32751
- }
32752
- function validateInput(input) {
32753
- const keyword = input.keyword.trim().replace(/\s+/gu, " ");
32754
- 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.");
32755
- 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}.`);
32756
- const market = searchMarketSchema.safeParse(input.market);
32757
- if (!market.success) throw new SeoError("INVALID_INPUT", "Use a valid search market.");
32758
- const provider = input.provider ? providerIdSchema.safeParse(input.provider) : void 0;
32759
- if (provider && !provider.success) throw new SeoError("INVALID_INPUT", "Use a supported SERP provider.");
32760
- return {
32761
- keyword,
32762
- market: market.data,
32763
- provider: provider?.data
32764
- };
32765
- }
32766
- function reportStatus$1(evidence) {
32767
- if (evidence.coverage.completeness === "unavailable" || evidence.coverage.completeness === "invalid") return "unavailable";
32768
- return evidence.coverage.completeness === "complete" ? "complete" : "partial";
32769
- }
32770
- function domainSummaries(snapshot) {
32771
- const byDomain = /* @__PURE__ */ new Map();
32772
- for (const result of snapshot.organicResults) byDomain.set(result.domain, [...byDomain.get(result.domain) ?? [], result.rankAbsolute]);
32773
- return [...byDomain.entries()].map(([domain, ranks]) => ({
32774
- domain,
32775
- resultCount: ranks.length,
32776
- ranks: [...ranks].sort((left, right) => left - right)
32777
- })).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));
32778
- }
32779
- function reportFindings(snapshot, domains) {
32780
- const findings = [];
32781
- if (snapshot.effectiveKeyword !== snapshot.keyword) findings.push({
32782
- code: "query-correction",
32783
- evidenceRef: "evidence.data.effectiveKeyword",
32784
- principle: "A corrected query changes the observed result context and does not prove the original wording has the same intent.",
32785
- detail: `The provider observed results for ${snapshot.effectiveKeyword} after the requested query ${snapshot.keyword}.`
32786
- });
32787
- for (const [index, domain] of domains.entries()) {
32788
- if (domain.resultCount <= 1) continue;
32789
- findings.push({
32790
- code: "repeated-domain",
32791
- evidenceRef: `domains[${index}]`,
32792
- principle: "Repeated results are descriptive snapshot evidence, not a domain strength or quality score.",
32793
- detail: `${domain.domain} appears ${domain.resultCount} times at absolute ranks ${domain.ranks.join(", ")}.`
32794
- });
32795
- }
32796
- return findings.slice(0, 10);
32797
- }
32798
- async function serpResultsReport(input, dependencies = {}) {
32799
- const depth = input.depth ?? 10;
32800
- const validated = validateInput({
32801
- ...input,
32802
- depth
32803
- });
32804
- let candidates;
32805
- try {
32806
- candidates = dependencies.candidates ?? await defaultCandidates();
32807
- } catch (error) {
32808
- return providerError$1(error);
32809
- }
32810
- const resolution = resolveProvider({
32811
- capability: "serp-snapshot",
32812
- market: validated.market,
32813
- candidates,
32814
- provider: validated.provider
32815
- });
32816
- 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.");
32817
- const provider = snapshotProvider(resolution.provider);
32818
- if (!provider) throw new SeoError("PROVIDER_UNAVAILABLE", "The selected provider has no live SERP implementation.");
32819
- let evidence;
32820
- try {
32821
- evidence = await provider.serpSnapshot({
32822
- keyword: validated.keyword,
32823
- market: validated.market,
32824
- depth,
32825
- refresh: input.refresh,
32826
- context: {
32827
- projectId: input.context?.projectId ?? input.projectId,
32828
- reportId: input.context?.reportId ?? "serp-results",
32829
- reportRunId: input.context?.reportRunId ?? randomUUID()
32830
- }
32831
- });
32832
- } catch (error) {
32833
- return providerError$1(error);
32834
- }
32835
- const domains = domainSummaries(evidence.data);
32836
- const dataStatus = reportStatus$1(evidence);
32837
- const generatedAt = (dependencies.now ?? (() => /* @__PURE__ */ new Date()))().toISOString();
32838
- const correctedQuery = evidence.data.effectiveKeyword !== evidence.data.keyword;
32839
- return {
32840
- schemaVersion: 1,
32841
- generatedAt,
32842
- dataStatus,
32843
- market: evidence.market,
32844
- summary: {
32845
- keyword: evidence.data.keyword,
32846
- effectiveKeyword: evidence.data.effectiveKeyword,
32847
- requestedDepth: depth,
32848
- organicResults: evidence.data.organicResults.length,
32849
- uniqueDomains: domains.length,
32850
- observedFeatures: evidence.data.features.length,
32851
- correctedQuery,
32852
- verdict: `${evidence.data.organicResults.length} organic results across ${domains.length} domains were retained from the ${evidence.market.device ?? "desktop"} snapshot.`
32853
- },
32854
- evidence,
32855
- domains,
32856
- findings: reportFindings(evidence.data, domains),
32857
- caveats: [
32858
- "This is one market, location, language, and device-specific result snapshot; it is not rank history.",
32859
- "Search results can change between checks and may differ from a person's signed-in or personalized results.",
32860
- "The provider result count is an estimate and may be missing; retained organic rows are bounded by the requested depth.",
32861
- "Repeated domains, result features, titles, and snippets do not establish content quality, authority, or ranking feasibility."
32862
- ],
32863
- nextSteps: [
32864
- "Review the retained pages and result types before deciding whether the query fits an existing page, a new page, or no page.",
32865
- "Keep Search Console average position separate from these exact snapshot ranks when comparing first-party performance.",
32866
- "Repeat the same market and device check later if the decision depends on result stability."
32867
- ]
32868
- };
32869
- }
32870
- //#endregion
32871
33406
  //#region packages/core/dist/analyze/pseo-opportunities/serps.js
32872
33407
  function selectSerpKeywords(candidates, limit) {
32873
33408
  const seedRefs = /* @__PURE__ */ new Set();
@@ -39218,6 +39753,7 @@ const TELEMETRY_REPORTS = [
39218
39753
  "crawl-history",
39219
39754
  "crawler-rules",
39220
39755
  "llms-txt-audit",
39756
+ "local-search-demand",
39221
39757
  "generate-llms-txt",
39222
39758
  "measure-change",
39223
39759
  "monthly-report",
@@ -41315,6 +41851,74 @@ function registerKeywordTools(server, dependencies = {}) {
41315
41851
  });
41316
41852
  }
41317
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
41318
41922
  //#region packages/mcp/src/monitoring-tools.ts
41319
41923
  function registerMonitoringTools(server) {
41320
41924
  server.registerTool("seo_crawl_diff", {
@@ -43394,6 +43998,41 @@ const REPORT_DEPTH = {
43394
43998
  }
43395
43999
  ]
43396
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
+ },
43397
44036
  "saved-keywords": {
43398
44037
  readOrder: [
43399
44038
  "dataStatus, summary, evidence pagination, and any tag filter",
@@ -44285,6 +44924,17 @@ const REPORT_GUIDANCE = {
44285
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."],
44286
44925
  outcome: "A bounded keyword set with discovery sources, provider estimates, evidence states, and research follow-ups."
44287
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
+ },
44288
44938
  "saved-keywords": {
44289
44939
  name: "Saved keyword set",
44290
44940
  description: "Review one local keyword set with metric freshness, tags, page mappings, and explicit view limits.",
@@ -44863,6 +45513,11 @@ const reportGroups = [
44863
45513
  "seo_serp_results"
44864
45514
  ]
44865
45515
  },
45516
+ {
45517
+ category: "opportunities",
45518
+ register: registerLocalSearchTools,
45519
+ names: ["seo_local_search_demand"]
45520
+ },
44866
45521
  {
44867
45522
  category: "opportunities",
44868
45523
  register: registerDomainResearchTools,