seo 0.2.15 → 0.2.16
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cli.js +114 -110
- package/dist/cli.js.map +1 -1
- package/dist/{dist-DIqNaMQf.js → dist-BeDz2qIL.js} +162 -77
- package/dist/dist-BeDz2qIL.js.map +1 -0
- package/dist/{dist-C56VwYuO.js → dist-Hd0YW5ui.js} +1 -1
- package/dist/index.d.ts +284 -8
- package/dist/index.js +864 -36
- package/dist/index.js.map +1 -1
- package/dist/mcp.js +516 -23
- package/dist/mcp.js.map +1 -1
- package/dist/{package-Cuxp-LBy.js → package-D_ix15KL.js} +2 -2
- package/dist/{package-Cuxp-LBy.js.map → package-D_ix15KL.js.map} +1 -1
- package/evals/saved-keywords.json +28 -0
- package/package.json +1 -1
- package/dist/dist-DIqNaMQf.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 load, t as version } from "./package-
|
|
2
|
+
import { n as load, t as version } from "./package-D_ix15KL.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";
|
|
@@ -787,6 +787,52 @@ async function createGoogleAccessTokenClient() {
|
|
|
787
787
|
};
|
|
788
788
|
}
|
|
789
789
|
//#endregion
|
|
790
|
+
//#region packages/core/dist/keyword-sets/schema.js
|
|
791
|
+
const KEYWORD_SET_SCHEMA_SQL = `
|
|
792
|
+
CREATE TABLE IF NOT EXISTS keyword_sets (
|
|
793
|
+
id TEXT PRIMARY KEY,
|
|
794
|
+
project_id TEXT NOT NULL,
|
|
795
|
+
name TEXT NOT NULL,
|
|
796
|
+
market_json TEXT NOT NULL,
|
|
797
|
+
provider TEXT CHECK(provider IS NULL OR provider IN ('dataforseo', 'semrush', 'ahrefs')),
|
|
798
|
+
source_report TEXT,
|
|
799
|
+
created_at INTEGER NOT NULL,
|
|
800
|
+
updated_at INTEGER NOT NULL,
|
|
801
|
+
last_refreshed_at INTEGER,
|
|
802
|
+
UNIQUE(project_id, name COLLATE NOCASE)
|
|
803
|
+
);
|
|
804
|
+
CREATE INDEX IF NOT EXISTS idx_keyword_sets_project
|
|
805
|
+
ON keyword_sets(project_id, updated_at DESC, id);
|
|
806
|
+
|
|
807
|
+
CREATE TABLE IF NOT EXISTS keyword_set_items (
|
|
808
|
+
set_id TEXT NOT NULL,
|
|
809
|
+
normalized_keyword TEXT NOT NULL,
|
|
810
|
+
display_keyword TEXT NOT NULL,
|
|
811
|
+
metric_json TEXT,
|
|
812
|
+
metric_provider TEXT CHECK(metric_provider IS NULL OR metric_provider IN ('dataforseo', 'semrush', 'ahrefs')),
|
|
813
|
+
metric_observed_at TEXT,
|
|
814
|
+
page_kind TEXT CHECK(page_kind IS NULL OR page_kind IN ('target', 'proposed')),
|
|
815
|
+
page_url TEXT,
|
|
816
|
+
created_at INTEGER NOT NULL,
|
|
817
|
+
updated_at INTEGER NOT NULL,
|
|
818
|
+
PRIMARY KEY(set_id, normalized_keyword),
|
|
819
|
+
FOREIGN KEY(set_id) REFERENCES keyword_sets(id) ON DELETE CASCADE
|
|
820
|
+
) WITHOUT ROWID;
|
|
821
|
+
CREATE INDEX IF NOT EXISTS idx_keyword_set_items_set
|
|
822
|
+
ON keyword_set_items(set_id, normalized_keyword);
|
|
823
|
+
|
|
824
|
+
CREATE TABLE IF NOT EXISTS keyword_set_tags (
|
|
825
|
+
set_id TEXT NOT NULL,
|
|
826
|
+
normalized_keyword TEXT NOT NULL,
|
|
827
|
+
tag TEXT NOT NULL,
|
|
828
|
+
PRIMARY KEY(set_id, normalized_keyword, tag),
|
|
829
|
+
FOREIGN KEY(set_id, normalized_keyword)
|
|
830
|
+
REFERENCES keyword_set_items(set_id, normalized_keyword) ON DELETE CASCADE
|
|
831
|
+
) WITHOUT ROWID;
|
|
832
|
+
CREATE INDEX IF NOT EXISTS idx_keyword_set_tags_lookup
|
|
833
|
+
ON keyword_set_tags(set_id, tag, normalized_keyword);
|
|
834
|
+
`;
|
|
835
|
+
//#endregion
|
|
790
836
|
//#region packages/core/dist/storage/cache-maintenance.js
|
|
791
837
|
const MEBIBYTE$2 = 1024 * 1024;
|
|
792
838
|
const CACHE_MAX_AGE_MS = 720 * 60 * 60 * 1e3;
|
|
@@ -1341,6 +1387,7 @@ function initDb(database, isNewDatabase) {
|
|
|
1341
1387
|
if (isNewDatabase) database.pragma("auto_vacuum = INCREMENTAL");
|
|
1342
1388
|
database.transaction(() => {
|
|
1343
1389
|
database.exec(CREATE_SQL);
|
|
1390
|
+
database.exec(KEYWORD_SET_SCHEMA_SQL);
|
|
1344
1391
|
database.exec(PROVIDER_SPEND_SCHEMA_SQL);
|
|
1345
1392
|
database.exec(`DELETE FROM semrush_cache WHERE request_json LIKE '%"key"%'`);
|
|
1346
1393
|
ensureColumn(database, "crawl_pages", "snapshot_json", "TEXT");
|
|
@@ -4486,7 +4533,7 @@ async function fetchPage(url, opts = {}, dependencies = {}) {
|
|
|
4486
4533
|
}
|
|
4487
4534
|
//#endregion
|
|
4488
4535
|
//#region packages/core/dist/gsc/property-url.js
|
|
4489
|
-
function invalid(message) {
|
|
4536
|
+
function invalid$1(message) {
|
|
4490
4537
|
throw new SeoError("INVALID_INPUT", message);
|
|
4491
4538
|
}
|
|
4492
4539
|
function normalizeHttpUrl(value) {
|
|
@@ -4494,10 +4541,10 @@ function normalizeHttpUrl(value) {
|
|
|
4494
4541
|
try {
|
|
4495
4542
|
url = new URL(value.trim());
|
|
4496
4543
|
} catch {
|
|
4497
|
-
return invalid(`Invalid URL: ${value}`);
|
|
4544
|
+
return invalid$1(`Invalid URL: ${value}`);
|
|
4498
4545
|
}
|
|
4499
|
-
if (url.protocol !== "http:" && url.protocol !== "https:") return invalid("The report URL must use http:// or https://.");
|
|
4500
|
-
if (url.username || url.password) return invalid("The report URL must not contain embedded credentials.");
|
|
4546
|
+
if (url.protocol !== "http:" && url.protocol !== "https:") return invalid$1("The report URL must use http:// or https://.");
|
|
4547
|
+
if (url.username || url.password) return invalid$1("The report URL must not contain embedded credentials.");
|
|
4501
4548
|
url.hash = "";
|
|
4502
4549
|
return url.toString();
|
|
4503
4550
|
}
|
|
@@ -4506,13 +4553,13 @@ function assertUrlMatchesGscProperty(property, value) {
|
|
|
4506
4553
|
const parsed = new URL(url);
|
|
4507
4554
|
if (property.startsWith("sc-domain:")) {
|
|
4508
4555
|
const domain = property.slice(10).trim().toLowerCase();
|
|
4509
|
-
if (!domain || /[/:]/.test(domain)) return invalid(`Invalid Search Console domain property: ${property}`);
|
|
4556
|
+
if (!domain || /[/:]/.test(domain)) return invalid$1(`Invalid Search Console domain property: ${property}`);
|
|
4510
4557
|
const hostname = parsed.hostname.toLowerCase();
|
|
4511
|
-
if (hostname !== domain && !hostname.endsWith(`.${domain}`)) return invalid(`${url} is outside the Search Console property ${property}.`);
|
|
4558
|
+
if (hostname !== domain && !hostname.endsWith(`.${domain}`)) return invalid$1(`${url} is outside the Search Console property ${property}.`);
|
|
4512
4559
|
return url;
|
|
4513
4560
|
}
|
|
4514
4561
|
const prefix = normalizeHttpUrl(property);
|
|
4515
|
-
if (!url.startsWith(prefix)) return invalid(`${url} is outside the Search Console property ${property}.`);
|
|
4562
|
+
if (!url.startsWith(prefix)) return invalid$1(`${url} is outside the Search Console property ${property}.`);
|
|
4516
4563
|
return url;
|
|
4517
4564
|
}
|
|
4518
4565
|
//#endregion
|
|
@@ -21745,7 +21792,7 @@ const MAX_LEDGER_ROWS = 5e4;
|
|
|
21745
21792
|
const MAX_LEDGER_BYTES = 32 * 1024 * 1024;
|
|
21746
21793
|
const MAX_TASK_IDS = 20;
|
|
21747
21794
|
const MAX_ACTIVE_RESERVATIONS = 100;
|
|
21748
|
-
function boundedText(provider, value, label, max) {
|
|
21795
|
+
function boundedText$1(provider, value, label, max) {
|
|
21749
21796
|
const normalized = value.trim();
|
|
21750
21797
|
if (!normalized || normalized.length > max) throw new ProviderError({
|
|
21751
21798
|
provider,
|
|
@@ -21839,10 +21886,10 @@ function reserveProviderSpend(input, options = {}) {
|
|
|
21839
21886
|
const now = options.now ?? Date.now();
|
|
21840
21887
|
const normalized = {
|
|
21841
21888
|
...input,
|
|
21842
|
-
endpoint: boundedText(input.provider, input.endpoint, "Provider endpoint", 500),
|
|
21843
|
-
projectId: input.projectId ? boundedText(input.provider, input.projectId, "Project id", 200) : void 0,
|
|
21844
|
-
reportId: boundedText(input.provider, input.reportId, "Report id", 100),
|
|
21845
|
-
reportRunId: boundedText(input.provider, input.reportRunId, "Report run id", 100),
|
|
21889
|
+
endpoint: boundedText$1(input.provider, input.endpoint, "Provider endpoint", 500),
|
|
21890
|
+
projectId: input.projectId ? boundedText$1(input.provider, input.projectId, "Project id", 200) : void 0,
|
|
21891
|
+
reportId: boundedText$1(input.provider, input.reportId, "Report id", 100),
|
|
21892
|
+
reportRunId: boundedText$1(input.provider, input.reportRunId, "Report run id", 100),
|
|
21846
21893
|
requestedRows: nonnegativeInteger(input.provider, input.requestedRows, "Requested rows"),
|
|
21847
21894
|
estimatedCostMicros: nonnegativeInteger(input.provider, input.estimatedCostMicros, "Estimated cost")
|
|
21848
21895
|
};
|
|
@@ -21879,7 +21926,7 @@ function reserveProviderSpend(input, options = {}) {
|
|
|
21879
21926
|
function finalizeProviderSpend(reservationId, result, options = {}) {
|
|
21880
21927
|
const database = options.database ?? getDb();
|
|
21881
21928
|
const now = options.now ?? Date.now();
|
|
21882
|
-
const taskIds = [...new Set(result.taskIds)].sort((left, right) => left < right ? -1 : left > right ? 1 : 0).slice(0, MAX_TASK_IDS).map((id) => boundedText(result.provider, id, "Provider task id", 100));
|
|
21929
|
+
const taskIds = [...new Set(result.taskIds)].sort((left, right) => left < right ? -1 : left > right ? 1 : 0).slice(0, MAX_TASK_IDS).map((id) => boundedText$1(result.provider, id, "Provider task id", 100));
|
|
21883
21930
|
const actualCostMicros = result.actualCostMicros === null ? null : nonnegativeInteger(result.provider, result.actualCostMicros, "Actual cost");
|
|
21884
21931
|
const returnedRows = result.returnedRows === null ? null : nonnegativeInteger(result.provider, result.returnedRows, "Returned rows");
|
|
21885
21932
|
return database.transaction(() => {
|
|
@@ -22163,7 +22210,7 @@ const dataForSeoUserDataResponseSchema = z.object({
|
|
|
22163
22210
|
const MAX_PROVIDER_MONTHLY_SEARCH_ROWS = 120;
|
|
22164
22211
|
const nullableNumber = z.number().finite().nullable().optional();
|
|
22165
22212
|
const nullableCount = z.union([z.number().finite(), z.string().trim().min(1)]).nullable().optional();
|
|
22166
|
-
const monthlySearchSchema = z.object({
|
|
22213
|
+
const monthlySearchSchema$1 = z.object({
|
|
22167
22214
|
year: z.number().int(),
|
|
22168
22215
|
month: z.number().int(),
|
|
22169
22216
|
search_volume: z.number().finite().nullable().optional()
|
|
@@ -22174,7 +22221,7 @@ const keywordOverviewItemSchema = z.object({
|
|
|
22174
22221
|
search_volume: nullableNumber,
|
|
22175
22222
|
cpc: nullableNumber,
|
|
22176
22223
|
competition: nullableNumber,
|
|
22177
|
-
monthly_searches: z.array(monthlySearchSchema).max(MAX_PROVIDER_MONTHLY_SEARCH_ROWS).nullish(),
|
|
22224
|
+
monthly_searches: z.array(monthlySearchSchema$1).max(MAX_PROVIDER_MONTHLY_SEARCH_ROWS).nullish(),
|
|
22178
22225
|
last_updated_time: z.string().trim().min(1).nullable().optional()
|
|
22179
22226
|
}).nullish(),
|
|
22180
22227
|
keyword_properties: z.object({ keyword_difficulty: nullableNumber }).nullish(),
|
|
@@ -22188,7 +22235,7 @@ const keywordOverviewItemSchema = z.object({
|
|
|
22188
22235
|
}).passthrough();
|
|
22189
22236
|
const keywordOverviewResultSchema = z.object({
|
|
22190
22237
|
items_count: z.number().int().nonnegative().optional(),
|
|
22191
|
-
items: z.array(keywordOverviewItemSchema).optional()
|
|
22238
|
+
items: z.array(keywordOverviewItemSchema).nullable().optional()
|
|
22192
22239
|
}).passthrough();
|
|
22193
22240
|
const keywordOverviewTaskSchema = z.object({
|
|
22194
22241
|
id: z.string().trim().min(1).optional(),
|
|
@@ -23139,13 +23186,55 @@ var DataForSeoKeywordMetricsProvider = class {
|
|
|
23139
23186
|
status: "available",
|
|
23140
23187
|
markets: [{
|
|
23141
23188
|
searchEngines: ["google"],
|
|
23142
|
-
location: "
|
|
23189
|
+
location: "country-only"
|
|
23143
23190
|
}]
|
|
23144
23191
|
}];
|
|
23145
23192
|
client;
|
|
23146
23193
|
constructor(options = {}) {
|
|
23147
23194
|
this.client = options.client ?? new DataForSeoClient(options);
|
|
23148
23195
|
}
|
|
23196
|
+
async estimateKeywordMetricsCost(input) {
|
|
23197
|
+
if (!Number.isSafeInteger(input.requestedRows) || input.requestedRows < 1 || input.requestedRows > 1e4) throw new ProviderError({
|
|
23198
|
+
provider: "dataforseo",
|
|
23199
|
+
operation: "keyword-metrics-cost",
|
|
23200
|
+
code: "configuration",
|
|
23201
|
+
message: "Keyword metric cost estimates require 1 to 10000 rows."
|
|
23202
|
+
});
|
|
23203
|
+
if (!searchMarketSchema.safeParse(input.market).success) throw new ProviderError({
|
|
23204
|
+
provider: "dataforseo",
|
|
23205
|
+
operation: "keyword-metrics-cost",
|
|
23206
|
+
code: "configuration",
|
|
23207
|
+
message: "Keyword metric cost estimates require a valid market."
|
|
23208
|
+
});
|
|
23209
|
+
if (input.market.location) throw new ProviderError({
|
|
23210
|
+
provider: "dataforseo",
|
|
23211
|
+
operation: "keyword-metrics-cost",
|
|
23212
|
+
code: "configuration",
|
|
23213
|
+
message: "DataForSEO Labs keyword metrics supports country-level markets; omit market.location and use countryCode."
|
|
23214
|
+
});
|
|
23215
|
+
if (!this.client.userData) throw new ProviderError({
|
|
23216
|
+
provider: "dataforseo",
|
|
23217
|
+
operation: "keyword-metrics-cost",
|
|
23218
|
+
code: "configuration",
|
|
23219
|
+
message: "Keyword metric cost estimation is unavailable."
|
|
23220
|
+
});
|
|
23221
|
+
const account = await this.client.userData();
|
|
23222
|
+
const { perRequestMicros, perResultMicros } = account.keywordOverviewPrice;
|
|
23223
|
+
const requestCount = Math.ceil(input.requestedRows / 50);
|
|
23224
|
+
const complete = perRequestMicros !== null && perResultMicros !== null;
|
|
23225
|
+
return {
|
|
23226
|
+
schemaVersion: 1,
|
|
23227
|
+
provider: "dataforseo",
|
|
23228
|
+
capability: "keyword-metrics",
|
|
23229
|
+
currency: "USD",
|
|
23230
|
+
requestedRows: input.requestedRows,
|
|
23231
|
+
requestCount,
|
|
23232
|
+
estimatedMicros: complete ? requestCount * perRequestMicros + input.requestedRows * perResultMicros : null,
|
|
23233
|
+
observedAt: account.observedAt,
|
|
23234
|
+
completeness: complete ? "complete" : "unavailable",
|
|
23235
|
+
basis: complete ? "Current account pricing for normal-priority live keyword overview requests." : "The provider account response did not include complete keyword overview pricing."
|
|
23236
|
+
};
|
|
23237
|
+
}
|
|
23149
23238
|
async keywordMetrics(input) {
|
|
23150
23239
|
const market = searchMarketSchema.parse(input.market);
|
|
23151
23240
|
if (market.searchEngine !== "google") throw new ProviderError({
|
|
@@ -23154,6 +23243,12 @@ var DataForSeoKeywordMetricsProvider = class {
|
|
|
23154
23243
|
code: "configuration",
|
|
23155
23244
|
message: "DataForSEO keyword metrics currently supports Google markets."
|
|
23156
23245
|
});
|
|
23246
|
+
if (market.location) throw new ProviderError({
|
|
23247
|
+
provider: "dataforseo",
|
|
23248
|
+
operation: "keyword-metrics",
|
|
23249
|
+
code: "configuration",
|
|
23250
|
+
message: "DataForSEO Labs keyword metrics supports country-level markets; omit market.location and use countryCode."
|
|
23251
|
+
});
|
|
23157
23252
|
if (input.keywords.length < 1 || input.keywords.length > MAX_KEYWORDS_PER_REPORT || input.keywords.some((keyword) => {
|
|
23158
23253
|
const normalized = keyword.trim();
|
|
23159
23254
|
return normalized.length < 1 || normalized.length > 80 || normalized.split(/\s+/u).length > 10;
|
|
@@ -23369,6 +23464,7 @@ async function defaultCandidates$2() {
|
|
|
23369
23464
|
}
|
|
23370
23465
|
function providerResolutionError(input) {
|
|
23371
23466
|
if (input.reason === "provider-not-connected") return new SeoError("PROVIDER_UNAVAILABLE", "No connected provider can supply keyword metrics. Run `seo providers dataforseo connect` first.");
|
|
23467
|
+
if (input.provider && input.reason === "market-not-supported") return new SeoError("INVALID_INPUT", input.provider === "dataforseo" ? "DataForSEO keyword metrics uses country-level Labs data. Omit location and keep countryCode, then run serp-results separately for a local snapshot." : `${input.provider} cannot supply keyword metrics for this market.`);
|
|
23372
23468
|
if (input.provider) return new SeoError("PROVIDER_UNAVAILABLE", `${input.provider} cannot supply keyword metrics for this market.`);
|
|
23373
23469
|
return new SeoError("PROVIDER_UNAVAILABLE", "No configured provider can supply keyword metrics for this market.");
|
|
23374
23470
|
}
|
|
@@ -24970,7 +25066,7 @@ var DataForSeoKeywordDiscoveryProvider = class {
|
|
|
24970
25066
|
status: "available",
|
|
24971
25067
|
markets: [{
|
|
24972
25068
|
searchEngines: ["google"],
|
|
24973
|
-
location: "
|
|
25069
|
+
location: "country-only"
|
|
24974
25070
|
}]
|
|
24975
25071
|
}];
|
|
24976
25072
|
client;
|
|
@@ -24985,6 +25081,12 @@ var DataForSeoKeywordDiscoveryProvider = class {
|
|
|
24985
25081
|
code: "configuration",
|
|
24986
25082
|
message: "DataForSEO keyword discovery currently supports Google."
|
|
24987
25083
|
});
|
|
25084
|
+
if (market.location) throw new ProviderError({
|
|
25085
|
+
provider: "dataforseo",
|
|
25086
|
+
operation: "keyword-discovery",
|
|
25087
|
+
code: "configuration",
|
|
25088
|
+
message: "DataForSEO Labs keyword discovery supports country-level markets; omit market.location and use countryCode."
|
|
25089
|
+
});
|
|
24988
25090
|
const seeds = [...new Set(input.seeds.map(normalizedKeyword$1))].filter(Boolean).sort(compareCodepoints$3);
|
|
24989
25091
|
if (seeds.length < 1 || seeds.length > MAX_DISCOVERY_SEEDS || seeds.some((seed) => seed.length > 80 || seed.split(/\s+/u).length > 10)) throw new ProviderError({
|
|
24990
25092
|
provider: "dataforseo",
|
|
@@ -25183,7 +25285,10 @@ async function keywordResearchReport(input, dependencies = {}) {
|
|
|
25183
25285
|
candidates,
|
|
25184
25286
|
provider: validated.provider
|
|
25185
25287
|
});
|
|
25186
|
-
if (resolution.status === "unavailable")
|
|
25288
|
+
if (resolution.status === "unavailable") {
|
|
25289
|
+
const message = resolution.reason === "provider-not-connected" ? "No connected provider can discover keywords. Run `seo providers dataforseo connect` first." : validated.provider ? `${validated.provider} cannot discover keywords for this market.` : "No configured provider can discover keywords for this market.";
|
|
25290
|
+
throw new SeoError(validated.provider && resolution.reason === "market-not-supported" ? "INVALID_INPUT" : "PROVIDER_UNAVAILABLE", validated.provider === "dataforseo" && resolution.reason === "market-not-supported" ? "DataForSEO keyword discovery uses country-level Labs data. Omit location and keep countryCode, then run serp-results separately for a local snapshot." : message);
|
|
25291
|
+
}
|
|
25187
25292
|
const provider = discoveryProvider(resolution.provider);
|
|
25188
25293
|
if (!provider) throw new SeoError("PROVIDER_UNAVAILABLE", "The selected provider has no keyword discovery implementation.");
|
|
25189
25294
|
let evidence;
|
|
@@ -29814,6 +29919,7 @@ var DataForSeoSerpSnapshotProvider = class {
|
|
|
29814
29919
|
const organicResults = mappedOrganicResults.slice(0, input.depth);
|
|
29815
29920
|
const invalidRows = organicItems.length - mappedOrganicResults.length;
|
|
29816
29921
|
const resultCapped = mappedOrganicResults.length > input.depth;
|
|
29922
|
+
const invalidResultCount = result.se_results_count !== null && result.se_results_count !== void 0 && result.se_results_count < organicResults.length;
|
|
29817
29923
|
if (invalidRows > 0) warnings.push({
|
|
29818
29924
|
code: "invalid-organic-results",
|
|
29819
29925
|
field: "organicResults",
|
|
@@ -29830,6 +29936,11 @@ var DataForSeoSerpSnapshotProvider = class {
|
|
|
29830
29936
|
field: "checkUrl",
|
|
29831
29937
|
message: "DataForSEO returned an invalid SERP check URL."
|
|
29832
29938
|
});
|
|
29939
|
+
if (invalidResultCount) warnings.push({
|
|
29940
|
+
code: "invalid-serp-result-count",
|
|
29941
|
+
field: "resultCount",
|
|
29942
|
+
message: "DataForSEO returned an estimated result count below the retained organic result count; the estimate was discarded."
|
|
29943
|
+
});
|
|
29833
29944
|
const features = [.../* @__PURE__ */ new Set([...result.item_types ?? [], ...items.map((item) => item.type)])].sort(compareCodepoints$3);
|
|
29834
29945
|
return {
|
|
29835
29946
|
schemaVersion: 1,
|
|
@@ -29841,7 +29952,7 @@ var DataForSeoSerpSnapshotProvider = class {
|
|
|
29841
29952
|
searchEngineDomain: result.se_domain?.trim().toLowerCase() ?? null,
|
|
29842
29953
|
checkedAt: checkedAt(result.datetime, snapshot.observedAt),
|
|
29843
29954
|
checkUrl,
|
|
29844
|
-
resultCount:
|
|
29955
|
+
resultCount: invalidResultCount ? null : result.se_results_count ?? null,
|
|
29845
29956
|
pagesCount: result.pages_count === void 0 ? null : result.pages_count,
|
|
29846
29957
|
features,
|
|
29847
29958
|
organicResults
|
|
@@ -29856,8 +29967,8 @@ var DataForSeoSerpSnapshotProvider = class {
|
|
|
29856
29967
|
returnedRows: snapshot.returnedRows,
|
|
29857
29968
|
retainedRows: organicResults.length,
|
|
29858
29969
|
invalidRows,
|
|
29859
|
-
providerTotalRows: result.se_results_count ?? null,
|
|
29860
|
-
completeness: invalidRows > 0 ? "partial" : resultCapped ? "capped" : "complete",
|
|
29970
|
+
providerTotalRows: invalidResultCount ? null : result.se_results_count ?? null,
|
|
29971
|
+
completeness: invalidRows > 0 || invalidResultCount ? "partial" : resultCapped ? "capped" : "complete",
|
|
29861
29972
|
nextCursor: null
|
|
29862
29973
|
},
|
|
29863
29974
|
cache: snapshot.cache,
|
|
@@ -30696,6 +30807,320 @@ function reportPresentation(report) {
|
|
|
30696
30807
|
};
|
|
30697
30808
|
}
|
|
30698
30809
|
//#endregion
|
|
30810
|
+
//#region packages/core/dist/keyword-sets/limits.js
|
|
30811
|
+
const KEYWORD_SET_LIMITS = {
|
|
30812
|
+
setsPerProject: 25,
|
|
30813
|
+
totalSets: 100,
|
|
30814
|
+
keywordsPerSet: 1e4,
|
|
30815
|
+
totalKeywords: 1e5,
|
|
30816
|
+
mutationKeywords: 1e3,
|
|
30817
|
+
tagsPerKeyword: 20,
|
|
30818
|
+
outputRows: 1e3,
|
|
30819
|
+
logicalBytes: 64 * 1024 * 1024
|
|
30820
|
+
};
|
|
30821
|
+
const KEYWORD_SET_FIELD_LIMITS = {
|
|
30822
|
+
name: 80,
|
|
30823
|
+
projectId: 80,
|
|
30824
|
+
sourceReport: 100,
|
|
30825
|
+
keyword: 80,
|
|
30826
|
+
keywordWords: 10,
|
|
30827
|
+
tag: 40,
|
|
30828
|
+
url: 2048,
|
|
30829
|
+
metricJsonBytes: 16 * 1024
|
|
30830
|
+
};
|
|
30831
|
+
//#endregion
|
|
30832
|
+
//#region packages/core/dist/keyword-sets/validation.js
|
|
30833
|
+
const unavailableValueSchema = z.strictObject({
|
|
30834
|
+
state: z.enum([
|
|
30835
|
+
"missing",
|
|
30836
|
+
"unavailable",
|
|
30837
|
+
"invalid"
|
|
30838
|
+
]),
|
|
30839
|
+
value: z.null(),
|
|
30840
|
+
reason: z.string().min(1).max(500)
|
|
30841
|
+
});
|
|
30842
|
+
const providerValueNumberSchema = z.discriminatedUnion("state", [z.strictObject({
|
|
30843
|
+
state: z.literal("observed"),
|
|
30844
|
+
value: z.number().finite()
|
|
30845
|
+
}), unavailableValueSchema]);
|
|
30846
|
+
const providerValueStringSchema = z.discriminatedUnion("state", [z.strictObject({
|
|
30847
|
+
state: z.literal("observed"),
|
|
30848
|
+
value: z.string().max(500)
|
|
30849
|
+
}), unavailableValueSchema]);
|
|
30850
|
+
const monthlySearchSchema = z.strictObject({
|
|
30851
|
+
year: z.number().int().min(2e3).max(2200),
|
|
30852
|
+
month: z.number().int().min(1).max(12),
|
|
30853
|
+
searchVolume: z.number().finite().nonnegative()
|
|
30854
|
+
});
|
|
30855
|
+
const providerValueMonthlySearchesSchema = z.discriminatedUnion("state", [z.strictObject({
|
|
30856
|
+
state: z.literal("observed"),
|
|
30857
|
+
value: z.array(monthlySearchSchema).max(24)
|
|
30858
|
+
}), unavailableValueSchema]);
|
|
30859
|
+
const keywordMetricSchema = z.strictObject({
|
|
30860
|
+
keyword: z.string().min(1).max(KEYWORD_SET_FIELD_LIMITS.keyword),
|
|
30861
|
+
monthlySearchVolume: providerValueNumberSchema,
|
|
30862
|
+
monthlySearches: providerValueMonthlySearchesSchema,
|
|
30863
|
+
searchVolumeUpdatedAt: providerValueStringSchema,
|
|
30864
|
+
cpcUsd: providerValueNumberSchema,
|
|
30865
|
+
paidCompetition: providerValueNumberSchema,
|
|
30866
|
+
keywordDifficulty: providerValueNumberSchema,
|
|
30867
|
+
intent: providerValueStringSchema,
|
|
30868
|
+
resultCount: providerValueNumberSchema
|
|
30869
|
+
});
|
|
30870
|
+
const savedMetricSchema = z.strictObject({
|
|
30871
|
+
schemaVersion: z.literal(1),
|
|
30872
|
+
provider: providerIdSchema,
|
|
30873
|
+
observedAt: z.iso.datetime(),
|
|
30874
|
+
metric: keywordMetricSchema
|
|
30875
|
+
});
|
|
30876
|
+
function invalid(message) {
|
|
30877
|
+
throw new SeoError("INVALID_INPUT", message);
|
|
30878
|
+
}
|
|
30879
|
+
function boundedText(value, label, max) {
|
|
30880
|
+
const normalized = value.trim();
|
|
30881
|
+
if (!normalized || normalized.length > max) invalid(`${label} must contain 1 to ${max} characters.`);
|
|
30882
|
+
return normalized;
|
|
30883
|
+
}
|
|
30884
|
+
function normalizeSavedKeyword(value) {
|
|
30885
|
+
const normalized = value.trim().replace(/\s+/gu, " ").toLowerCase();
|
|
30886
|
+
if (!normalized || normalized.length > KEYWORD_SET_FIELD_LIMITS.keyword || normalized.split(" ").length > KEYWORD_SET_FIELD_LIMITS.keywordWords) invalid(`Keywords must contain 1 to ${KEYWORD_SET_FIELD_LIMITS.keyword} characters and at most ${KEYWORD_SET_FIELD_LIMITS.keywordWords} words.`);
|
|
30887
|
+
return normalized;
|
|
30888
|
+
}
|
|
30889
|
+
function parseStoredJson(value, schema, label) {
|
|
30890
|
+
let decoded;
|
|
30891
|
+
try {
|
|
30892
|
+
decoded = JSON.parse(value);
|
|
30893
|
+
} catch {
|
|
30894
|
+
throw new SeoError("INTERNAL_ERROR", `${label} contains invalid JSON.`);
|
|
30895
|
+
}
|
|
30896
|
+
const parsed = schema.safeParse(decoded);
|
|
30897
|
+
if (!parsed.success) throw new SeoError("INTERNAL_ERROR", `${label} has an invalid shape.`);
|
|
30898
|
+
return parsed.data;
|
|
30899
|
+
}
|
|
30900
|
+
//#endregion
|
|
30901
|
+
//#region packages/core/dist/keyword-sets/rows.js
|
|
30902
|
+
const SET_SELECT = `
|
|
30903
|
+
SELECT sets.*,
|
|
30904
|
+
(SELECT COUNT(*) FROM keyword_set_items items WHERE items.set_id = sets.id) AS keyword_count,
|
|
30905
|
+
(SELECT COUNT(DISTINCT tags.tag) FROM keyword_set_tags tags WHERE tags.set_id = sets.id) AS tag_count
|
|
30906
|
+
FROM keyword_sets sets`;
|
|
30907
|
+
function storeDatabase(options) {
|
|
30908
|
+
return options.database ?? getDb();
|
|
30909
|
+
}
|
|
30910
|
+
function rowToSet(row) {
|
|
30911
|
+
const provider = row.provider ? providerIdSchema.safeParse(row.provider) : void 0;
|
|
30912
|
+
if (provider && !provider.success) throw new SeoError("INTERNAL_ERROR", "Saved keyword set provider is invalid.");
|
|
30913
|
+
return {
|
|
30914
|
+
schemaVersion: 1,
|
|
30915
|
+
id: row.id,
|
|
30916
|
+
projectId: row.project_id,
|
|
30917
|
+
name: row.name,
|
|
30918
|
+
market: parseStoredJson(row.market_json, searchMarketSchema, "Saved search market"),
|
|
30919
|
+
provider: provider?.data ?? null,
|
|
30920
|
+
sourceReport: row.source_report,
|
|
30921
|
+
keywordCount: row.keyword_count,
|
|
30922
|
+
tagCount: row.tag_count,
|
|
30923
|
+
createdAt: new Date(row.created_at).toISOString(),
|
|
30924
|
+
updatedAt: new Date(row.updated_at).toISOString(),
|
|
30925
|
+
lastRefreshedAt: row.last_refreshed_at === null ? null : new Date(row.last_refreshed_at).toISOString()
|
|
30926
|
+
};
|
|
30927
|
+
}
|
|
30928
|
+
function findSetRow(db, projectId, idOrName) {
|
|
30929
|
+
return db.prepare(`${SET_SELECT}
|
|
30930
|
+
WHERE sets.project_id = ?
|
|
30931
|
+
AND (sets.id = ? OR sets.name = ? COLLATE NOCASE)
|
|
30932
|
+
ORDER BY CASE WHEN sets.id = ? THEN 0 ELSE 1 END
|
|
30933
|
+
LIMIT 1`).get(projectId, idOrName, idOrName, idOrName);
|
|
30934
|
+
}
|
|
30935
|
+
function requireSetRow(db, projectId, idOrName) {
|
|
30936
|
+
const row = findSetRow(db, projectId, idOrName);
|
|
30937
|
+
if (!row) invalid(`Keyword set not found: ${idOrName}`);
|
|
30938
|
+
return row;
|
|
30939
|
+
}
|
|
30940
|
+
function rowCount(db, sql, ...params) {
|
|
30941
|
+
return db.prepare(sql).get(...params).count;
|
|
30942
|
+
}
|
|
30943
|
+
//#endregion
|
|
30944
|
+
//#region packages/core/dist/keyword-sets/store.js
|
|
30945
|
+
function getKeywordSet(input, options = {}) {
|
|
30946
|
+
const projectId = boundedText(input.projectId, "Project id", KEYWORD_SET_FIELD_LIMITS.projectId);
|
|
30947
|
+
const idOrName = boundedText(input.idOrName, "Keyword set id or name", KEYWORD_SET_FIELD_LIMITS.name);
|
|
30948
|
+
const tag = input.tag ? boundedText(input.tag, "Tag", KEYWORD_SET_FIELD_LIMITS.tag).toLowerCase() : null;
|
|
30949
|
+
const limit = input.limit ?? 100;
|
|
30950
|
+
const offset = input.offset ?? 0;
|
|
30951
|
+
if (!Number.isSafeInteger(limit) || limit < 1 || limit > KEYWORD_SET_LIMITS.outputRows) invalid(`Keyword set output limit must be from 1 to ${KEYWORD_SET_LIMITS.outputRows}.`);
|
|
30952
|
+
if (!Number.isSafeInteger(offset) || offset < 0) invalid("Keyword set output offset must be zero or greater.");
|
|
30953
|
+
const db = storeDatabase(options);
|
|
30954
|
+
const setRow = requireSetRow(db, projectId, idOrName);
|
|
30955
|
+
const filterSql = tag ? `AND EXISTS (
|
|
30956
|
+
SELECT 1 FROM keyword_set_tags filter_tags
|
|
30957
|
+
WHERE filter_tags.set_id = items.set_id
|
|
30958
|
+
AND filter_tags.normalized_keyword = items.normalized_keyword
|
|
30959
|
+
AND filter_tags.tag = ?
|
|
30960
|
+
)` : "";
|
|
30961
|
+
const params = tag ? [setRow.id, tag] : [setRow.id];
|
|
30962
|
+
const total = rowCount(db, `SELECT COUNT(*) AS count FROM keyword_set_items items
|
|
30963
|
+
WHERE items.set_id = ? ${filterSql}`, ...params);
|
|
30964
|
+
const rows = db.prepare(`SELECT items.* FROM keyword_set_items items
|
|
30965
|
+
WHERE items.set_id = ? ${filterSql}
|
|
30966
|
+
ORDER BY items.normalized_keyword
|
|
30967
|
+
LIMIT ? OFFSET ?`).all(...params, limit, offset);
|
|
30968
|
+
const tagsByKeyword = loadTags(db, setRow.id, rows);
|
|
30969
|
+
const items = rows.map((row) => itemFromRow(row, tagsByKeyword.get(row.normalized_keyword) ?? []));
|
|
30970
|
+
return {
|
|
30971
|
+
schemaVersion: 1,
|
|
30972
|
+
set: rowToSet(setRow),
|
|
30973
|
+
items,
|
|
30974
|
+
pagination: {
|
|
30975
|
+
offset,
|
|
30976
|
+
limit,
|
|
30977
|
+
returned: items.length,
|
|
30978
|
+
total,
|
|
30979
|
+
nextOffset: offset + items.length < total ? offset + items.length : null
|
|
30980
|
+
},
|
|
30981
|
+
filter: { tag }
|
|
30982
|
+
};
|
|
30983
|
+
}
|
|
30984
|
+
function loadTags(db, setId, rows) {
|
|
30985
|
+
if (rows.length === 0) return /* @__PURE__ */ new Map();
|
|
30986
|
+
const tagRows = db.prepare(`SELECT normalized_keyword, tag FROM keyword_set_tags
|
|
30987
|
+
WHERE set_id = ? AND normalized_keyword IN (${rows.map(() => "?").join(", ")})
|
|
30988
|
+
ORDER BY normalized_keyword, tag`).all(setId, ...rows.map((row) => row.normalized_keyword));
|
|
30989
|
+
const tagsByKeyword = /* @__PURE__ */ new Map();
|
|
30990
|
+
for (const row of tagRows) {
|
|
30991
|
+
const tags = tagsByKeyword.get(row.normalized_keyword) ?? [];
|
|
30992
|
+
tags.push(row.tag);
|
|
30993
|
+
tagsByKeyword.set(row.normalized_keyword, tags);
|
|
30994
|
+
}
|
|
30995
|
+
return tagsByKeyword;
|
|
30996
|
+
}
|
|
30997
|
+
function itemFromRow(row, tags) {
|
|
30998
|
+
const latestMetric = row.metric_json ? parseStoredJson(row.metric_json, savedMetricSchema, "Saved keyword metric") : null;
|
|
30999
|
+
if (latestMetric && (latestMetric.provider !== row.metric_provider || latestMetric.observedAt !== row.metric_observed_at || normalizeSavedKeyword(latestMetric.metric.keyword) !== row.normalized_keyword)) throw new SeoError("INTERNAL_ERROR", "Saved keyword metric metadata is inconsistent.");
|
|
31000
|
+
const page = row.page_kind && row.page_url ? {
|
|
31001
|
+
kind: row.page_kind,
|
|
31002
|
+
url: row.page_url
|
|
31003
|
+
} : null;
|
|
31004
|
+
return {
|
|
31005
|
+
keyword: row.display_keyword,
|
|
31006
|
+
normalizedKeyword: row.normalized_keyword,
|
|
31007
|
+
tags,
|
|
31008
|
+
page,
|
|
31009
|
+
latestMetric,
|
|
31010
|
+
createdAt: new Date(row.created_at).toISOString(),
|
|
31011
|
+
updatedAt: new Date(row.updated_at).toISOString()
|
|
31012
|
+
};
|
|
31013
|
+
}
|
|
31014
|
+
//#endregion
|
|
31015
|
+
//#region packages/core/dist/analyze/saved-keywords.js
|
|
31016
|
+
const DEFAULT_STALE_DAYS = 45;
|
|
31017
|
+
function savedKeywordSetReport(input, dependencies = {}) {
|
|
31018
|
+
const staleDays = input.staleDays ?? DEFAULT_STALE_DAYS;
|
|
31019
|
+
if (!Number.isSafeInteger(staleDays) || staleDays < 1 || staleDays > 365) throw new SeoError("INVALID_INPUT", "Metric freshness must be from 1 to 365 days.");
|
|
31020
|
+
const now = (dependencies.now ?? (() => /* @__PURE__ */ new Date()))();
|
|
31021
|
+
const evidence = (dependencies.getKeywordSet ?? getKeywordSet)(input);
|
|
31022
|
+
const snapshots = evidence.items.filter((item) => item.latestMetric);
|
|
31023
|
+
const observedVolumes = snapshots.filter((item) => item.latestMetric?.metric.monthlySearchVolume.state === "observed");
|
|
31024
|
+
const staleCutoff = now.getTime() - staleDays * 864e5;
|
|
31025
|
+
const staleSnapshots = snapshots.filter((item) => Date.parse(item.latestMetric?.observedAt ?? "") < staleCutoff);
|
|
31026
|
+
const mapped = evidence.items.filter((item) => item.page);
|
|
31027
|
+
const tagGroups = groupedCounts(evidence.items.flatMap((item) => item.tags)).map(([tag, keywordCount]) => ({
|
|
31028
|
+
tag,
|
|
31029
|
+
keywordCount
|
|
31030
|
+
}));
|
|
31031
|
+
const pageMappings = groupedMappings(evidence);
|
|
31032
|
+
const missingMetrics = evidence.items.length - snapshots.length;
|
|
31033
|
+
const unavailableVolumeSnapshots = snapshots.length - observedVolumes.length;
|
|
31034
|
+
const unmapped = evidence.items.length - mapped.length;
|
|
31035
|
+
const partial = evidence.pagination.nextOffset !== null || evidence.pagination.offset > 0 || evidence.pagination.total !== evidence.set.keywordCount;
|
|
31036
|
+
const dataStatus = evidence.items.length === 0 ? "unavailable" : partial ? "partial" : "complete";
|
|
31037
|
+
return {
|
|
31038
|
+
schemaVersion: 1,
|
|
31039
|
+
generatedAt: now.toISOString(),
|
|
31040
|
+
dataStatus,
|
|
31041
|
+
summary: {
|
|
31042
|
+
name: evidence.set.name,
|
|
31043
|
+
totalKeywords: evidence.set.keywordCount,
|
|
31044
|
+
matchedKeywords: evidence.pagination.total,
|
|
31045
|
+
returnedKeywords: evidence.items.length,
|
|
31046
|
+
metricSnapshots: snapshots.length,
|
|
31047
|
+
observedVolumes: observedVolumes.length,
|
|
31048
|
+
observedZeroVolumes: observedVolumes.filter((item) => item.latestMetric?.metric.monthlySearchVolume.value === 0).length,
|
|
31049
|
+
unavailableVolumeSnapshots,
|
|
31050
|
+
staleMetricSnapshots: staleSnapshots.length,
|
|
31051
|
+
mappedKeywords: mapped.length,
|
|
31052
|
+
targetPages: pageMappings.filter((item) => item.kind === "target").length,
|
|
31053
|
+
proposedPages: pageMappings.filter((item) => item.kind === "proposed").length,
|
|
31054
|
+
distinctTags: tagGroups.length,
|
|
31055
|
+
verdict: `${evidence.items.length} saved keywords were returned; search volume is observed for ${observedVolumes.length}, unavailable in ${unavailableVolumeSnapshots} saved snapshot${unavailableVolumeSnapshots === 1 ? "" : "s"}, and ${mapped.length} have page mappings.`
|
|
31056
|
+
},
|
|
31057
|
+
evidence,
|
|
31058
|
+
analysis: {
|
|
31059
|
+
metricFreshnessDays: staleDays,
|
|
31060
|
+
tagGroups,
|
|
31061
|
+
pageMappings
|
|
31062
|
+
},
|
|
31063
|
+
findings: [
|
|
31064
|
+
...missingMetrics > 0 ? [{
|
|
31065
|
+
code: "missing-metrics",
|
|
31066
|
+
evidenceRef: "evidence.items[*].latestMetric",
|
|
31067
|
+
detail: `${missingMetrics} returned keywords have no saved provider metric snapshot.`,
|
|
31068
|
+
action: "Preview a keyword-set refresh before using provider metrics to prioritize them."
|
|
31069
|
+
}] : [],
|
|
31070
|
+
...unavailableVolumeSnapshots > 0 ? [{
|
|
31071
|
+
code: "unavailable-volume-evidence",
|
|
31072
|
+
evidenceRef: "evidence.items[*].latestMetric.metric.monthlySearchVolume",
|
|
31073
|
+
detail: `${unavailableVolumeSnapshots} saved metric snapshot${unavailableVolumeSnapshots === 1 ? "" : "s"} ${unavailableVolumeSnapshots === 1 ? "has" : "have"} no observed search volume. This is not an observed zero.`,
|
|
31074
|
+
action: "Keep these terms separate from observed-zero terms and verify them with another source or a later refresh before using demand as a filter."
|
|
31075
|
+
}] : [],
|
|
31076
|
+
...staleSnapshots.length > 0 ? [{
|
|
31077
|
+
code: "stale-metrics",
|
|
31078
|
+
evidenceRef: "evidence.items[*].latestMetric.observedAt",
|
|
31079
|
+
detail: `${staleSnapshots.length} saved metric snapshots are older than ${staleDays} days.`,
|
|
31080
|
+
action: "Preview and selectively refresh the stale provider evidence."
|
|
31081
|
+
}] : [],
|
|
31082
|
+
...unmapped > 0 ? [{
|
|
31083
|
+
code: "unmapped-keywords",
|
|
31084
|
+
evidenceRef: "evidence.items[*].page",
|
|
31085
|
+
detail: `${unmapped} returned keywords have no existing or proposed page mapping.`,
|
|
31086
|
+
action: "Review shared intent and current results before mapping terms to an existing page or a proposed page."
|
|
31087
|
+
}] : []
|
|
31088
|
+
],
|
|
31089
|
+
caveats: [
|
|
31090
|
+
"Tags are user-managed research labels, not proof that keywords share one intent or should use one page.",
|
|
31091
|
+
"Provider metrics are market estimates; missing evidence differs from an observed zero.",
|
|
31092
|
+
"Page mappings record planning state and do not prove that a page ranks, is indexed, or should be created.",
|
|
31093
|
+
...partial ? ["This view is filtered or paginated and cannot support a complete-set all-clear."] : []
|
|
31094
|
+
],
|
|
31095
|
+
nextSteps: [
|
|
31096
|
+
"Compare relevant saved terms with Search Console evidence for the project before changing priorities.",
|
|
31097
|
+
"Inspect current results in the same market before turning a tag group into a page or programmatic template.",
|
|
31098
|
+
"Use the refresh preview to check provider cost before updating saved metric snapshots."
|
|
31099
|
+
]
|
|
31100
|
+
};
|
|
31101
|
+
}
|
|
31102
|
+
function groupedCounts(values) {
|
|
31103
|
+
const counts = /* @__PURE__ */ new Map();
|
|
31104
|
+
for (const value of values) counts.set(value, (counts.get(value) ?? 0) + 1);
|
|
31105
|
+
return [...counts].sort(([left, leftCount], [right, rightCount]) => rightCount - leftCount || (left < right ? -1 : left > right ? 1 : 0)).slice(0, 50);
|
|
31106
|
+
}
|
|
31107
|
+
function groupedMappings(evidence) {
|
|
31108
|
+
const counts = /* @__PURE__ */ new Map();
|
|
31109
|
+
for (const item of evidence.items) {
|
|
31110
|
+
if (!item.page) continue;
|
|
31111
|
+
const key = `${item.page.kind}\u0000${item.page.url}`;
|
|
31112
|
+
const current = counts.get(key);
|
|
31113
|
+
counts.set(key, {
|
|
31114
|
+
...item.page,
|
|
31115
|
+
count: (current?.count ?? 0) + 1
|
|
31116
|
+
});
|
|
31117
|
+
}
|
|
31118
|
+
return [...counts.values()].sort((left, right) => right.count - left.count || (left.url < right.url ? -1 : left.url > right.url ? 1 : 0)).slice(0, 50).map(({ count, ...mapping }) => ({
|
|
31119
|
+
...mapping,
|
|
31120
|
+
keywordCount: count
|
|
31121
|
+
}));
|
|
31122
|
+
}
|
|
31123
|
+
//#endregion
|
|
30699
31124
|
//#region packages/core/dist/analyze/second-page.js
|
|
30700
31125
|
const MAX_SOURCE_ROWS = 1e5;
|
|
30701
31126
|
const defaultDependencies = {
|
|
@@ -34215,6 +34640,7 @@ const TELEMETRY_REPORTS = [
|
|
|
34215
34640
|
"keyword-metrics",
|
|
34216
34641
|
"keyword-opportunities",
|
|
34217
34642
|
"keyword-research",
|
|
34643
|
+
"saved-keywords",
|
|
34218
34644
|
"link-evidence",
|
|
34219
34645
|
"server-log-analysis",
|
|
34220
34646
|
"link-recovery",
|
|
@@ -35979,7 +36405,33 @@ const keywordOpportunitiesInput = z$1.strictObject({
|
|
|
35979
36405
|
message: "External keyword context requires countryCode and languageCode."
|
|
35980
36406
|
});
|
|
35981
36407
|
});
|
|
36408
|
+
const savedKeywordsInput = z$1.strictObject({
|
|
36409
|
+
projectId: z$1.string().trim().min(1).max(80),
|
|
36410
|
+
set: z$1.string().trim().min(1).max(80),
|
|
36411
|
+
tag: z$1.string().trim().min(1).max(40).optional(),
|
|
36412
|
+
limit: z$1.number().int().min(1).max(1e3).default(100),
|
|
36413
|
+
offset: z$1.number().int().min(0).max(1e5).default(0),
|
|
36414
|
+
staleDays: z$1.number().int().min(1).max(365).default(45)
|
|
36415
|
+
});
|
|
35982
36416
|
function registerKeywordTools(server, dependencies = {}) {
|
|
36417
|
+
server.registerTool("seo_saved_keywords", {
|
|
36418
|
+
description: "Review one bounded local keyword set with metric freshness, tags, page mappings, and evidence limits",
|
|
36419
|
+
inputSchema: savedKeywordsInput
|
|
36420
|
+
}, async ({ projectId, set, tag, limit, offset, staleDays }) => {
|
|
36421
|
+
try {
|
|
36422
|
+
const report = (dependencies.savedKeywordSetReport ?? savedKeywordSetReport)({
|
|
36423
|
+
projectId,
|
|
36424
|
+
idOrName: set,
|
|
36425
|
+
tag,
|
|
36426
|
+
limit,
|
|
36427
|
+
offset,
|
|
36428
|
+
staleDays
|
|
36429
|
+
});
|
|
36430
|
+
return toolSuccess(report.summary.verdict, compactAgentWorkflowOutput(report));
|
|
36431
|
+
} catch (error) {
|
|
36432
|
+
return toolError(error);
|
|
36433
|
+
}
|
|
36434
|
+
});
|
|
35983
36435
|
server.registerTool("seo_keyword_metrics", {
|
|
35984
36436
|
description: "Compare bounded third-party keyword demand, cost, competition, difficulty, intent, result-count, and trend evidence",
|
|
35985
36437
|
inputSchema: {
|
|
@@ -37998,6 +38450,39 @@ const REPORT_DEPTH = {
|
|
|
37998
38450
|
}
|
|
37999
38451
|
]
|
|
38000
38452
|
},
|
|
38453
|
+
"saved-keywords": {
|
|
38454
|
+
readOrder: [
|
|
38455
|
+
"dataStatus, summary, evidence pagination, and any tag filter",
|
|
38456
|
+
"the saved set market, source, refresh time, and keyword count",
|
|
38457
|
+
"each item metric value state, observation time, tags, and page mapping",
|
|
38458
|
+
"analysis groups, findings, caveats, and nextSteps"
|
|
38459
|
+
],
|
|
38460
|
+
doNotClaim: [
|
|
38461
|
+
"User-managed tags do not prove shared intent or justify one page or template.",
|
|
38462
|
+
"Provider metrics are estimates rather than first-party rankings or traffic, and a missing value differs from an observed zero.",
|
|
38463
|
+
"A target or proposed page mapping does not prove that the page should exist, ranks, or is indexed.",
|
|
38464
|
+
"A filtered, capped, or paginated view cannot support a conclusion about the complete set."
|
|
38465
|
+
],
|
|
38466
|
+
verify: "Compare relevant terms with Search Console and inspect current results in the saved market before changing a page or template plan.",
|
|
38467
|
+
related: [
|
|
38468
|
+
{
|
|
38469
|
+
id: "keyword-metrics",
|
|
38470
|
+
reason: "Refreshes a supplied term list."
|
|
38471
|
+
},
|
|
38472
|
+
{
|
|
38473
|
+
id: "keyword-research",
|
|
38474
|
+
reason: "Discovers more candidate terms."
|
|
38475
|
+
},
|
|
38476
|
+
{
|
|
38477
|
+
id: "keyword-opportunities",
|
|
38478
|
+
reason: "Adds first-party opportunity evidence."
|
|
38479
|
+
},
|
|
38480
|
+
{
|
|
38481
|
+
id: "pseo-opportunities",
|
|
38482
|
+
reason: "Reviews repeatable programmatic patterns."
|
|
38483
|
+
}
|
|
38484
|
+
]
|
|
38485
|
+
},
|
|
38001
38486
|
"query-clusters": {
|
|
38002
38487
|
readOrder: [
|
|
38003
38488
|
"summary cluster and query counts, clicks, impressions, threshold, limit, and brand filtering",
|
|
@@ -38783,6 +39268,13 @@ const REPORT_GUIDANCE = {
|
|
|
38783
39268
|
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."],
|
|
38784
39269
|
outcome: "A bounded keyword set with discovery sources, provider estimates, evidence states, and research follow-ups."
|
|
38785
39270
|
},
|
|
39271
|
+
"saved-keywords": {
|
|
39272
|
+
name: "Saved keyword set",
|
|
39273
|
+
description: "Review one local keyword set with metric freshness, tags, page mappings, and explicit view limits.",
|
|
39274
|
+
useWhen: ["You want to continue earlier keyword research from a saved project set.", "You need to review stale, unmapped, or tagged terms before deeper research."],
|
|
39275
|
+
avoidWhen: ["You need independent keyword discovery or a current result snapshot.", "You plan to treat user-managed tags as proof of shared search intent."],
|
|
39276
|
+
outcome: "A bounded local research view with saved evidence, freshness state, planning mappings, and next steps."
|
|
39277
|
+
},
|
|
38786
39278
|
"query-clusters": {
|
|
38787
39279
|
name: "Search query clusters",
|
|
38788
39280
|
description: "Group related Search Console queries so repeated demand themes are easier to review.",
|
|
@@ -39338,6 +39830,7 @@ const reportGroups = [
|
|
|
39338
39830
|
"seo_keyword_metrics",
|
|
39339
39831
|
"seo_keyword_opportunities",
|
|
39340
39832
|
"seo_keyword_research",
|
|
39833
|
+
"seo_saved_keywords",
|
|
39341
39834
|
"seo_serp_results"
|
|
39342
39835
|
]
|
|
39343
39836
|
},
|