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/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-Cuxp-LBy.js";
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: "any"
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: "any"
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") throw new SeoError("PROVIDER_UNAVAILABLE", 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.");
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: result.se_results_count === void 0 ? null : result.se_results_count,
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
  },