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/README.md +10 -1
- package/dist/cli.js +2 -2
- package/dist/{dist-B-zhNcyH.js → dist-DNsXiggv.js} +81 -81
- package/dist/dist-DNsXiggv.js.map +1 -0
- package/dist/{dist-BITHJak_.js → dist-DhQXrgg2.js} +1 -1
- package/dist/{esm-CSE-hgHf.js → esm-insIkRuo.js} +2 -2
- package/dist/{esm-CSE-hgHf.js.map → esm-insIkRuo.js.map} +1 -1
- package/dist/index.d.ts +261 -50
- package/dist/index.js +963 -424
- package/dist/index.js.map +1 -1
- package/dist/mcp.js +1078 -423
- package/dist/mcp.js.map +1 -1
- package/evals/local-search-demand.json +41 -0
- package/package.json +1 -1
- package/dist/dist-B-zhNcyH.js.map +0 -1
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-
|
|
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$
|
|
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$
|
|
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$
|
|
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$
|
|
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$
|
|
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$
|
|
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$
|
|
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$
|
|
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$
|
|
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$
|
|
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$
|
|
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$
|
|
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$
|
|
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$
|
|
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$
|
|
11463
|
+
const byUrl = compareText$16(left.requestedUrl, right.requestedUrl);
|
|
11464
11464
|
if (byUrl) return byUrl;
|
|
11465
|
-
const byOutcome = compareText$
|
|
11465
|
+
const byOutcome = compareText$16(left.outcome, right.outcome);
|
|
11466
11466
|
if (byOutcome) return byOutcome;
|
|
11467
|
-
return compareText$
|
|
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$
|
|
11690
|
-
caveats: sanitizeMessages(input.caveats).sort(compareText$
|
|
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$
|
|
11721
|
-
caveats: sanitizeMessages(report.caveats).sort(compareText$
|
|
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$
|
|
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$
|
|
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$
|
|
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$
|
|
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$
|
|
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$
|
|
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$
|
|
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$
|
|
16523
|
-
const limit = boundedInteger$
|
|
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$
|
|
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$
|
|
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$
|
|
16900
|
-
unmatched.sort((left, right) => right.clicks - left.clicks || compareText$
|
|
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$
|
|
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$
|
|
17616
|
-
const limit = boundedInteger$
|
|
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$
|
|
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$
|
|
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$
|
|
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$
|
|
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$
|
|
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$
|
|
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$
|
|
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$
|
|
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$
|
|
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$
|
|
18522
|
-
const limit = boundedInteger$
|
|
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$
|
|
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$
|
|
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$
|
|
18669
|
-
})).sort((left, right) => right.urlCount - left.urlCount || right.rowCount - left.rowCount || compareText$
|
|
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$
|
|
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$
|
|
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$
|
|
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$
|
|
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$
|
|
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$
|
|
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$
|
|
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$
|
|
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$
|
|
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$
|
|
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$
|
|
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$
|
|
23554
|
-
})).sort((left, right) => right.impressions - left.impressions || compareText$
|
|
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$
|
|
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$
|
|
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$
|
|
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$
|
|
24044
|
-
})).filter((cluster) => cluster.shape.variableSegments.length > 0 && cluster.urlCount >= minUrls && cluster.share >= minShare).sort((a, b) => b.urlCount - a.urlCount || compareText$
|
|
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$
|
|
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$
|
|
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$
|
|
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$
|
|
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$
|
|
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$
|
|
24164
|
-
const best = [...retainedRows].sort((left, right) => observedVolume$1(right.row) - observedVolume$1(left.row) || left.row.rankGroup - right.row.rankGroup || compareText$
|
|
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$
|
|
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$
|
|
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$
|
|
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$
|
|
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$
|
|
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$
|
|
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$
|
|
26874
|
+
const minImpressions = boundedInteger$3({
|
|
26875
26875
|
value: input.minImpressions,
|
|
26876
26876
|
fallback: 25
|
|
26877
26877
|
});
|
|
26878
|
-
const limit = boundedInteger$
|
|
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$
|
|
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$
|
|
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$
|
|
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$
|
|
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$
|
|
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$
|
|
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$
|
|
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$
|
|
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$
|
|
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$
|
|
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$
|
|
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,
|