seo 0.2.15 → 0.2.17

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/index.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-C-Oa9Mgz.js";
3
3
  import { Headers, fetch as fetch$1 } from "undici";
4
4
  import { chmodSync, createReadStream, createWriteStream, existsSync, mkdirSync, readFileSync, readdirSync, renameSync, rmSync, statSync, writeFileSync } from "node:fs";
5
5
  import { arch, homedir, platform, totalmem } from "node:os";
@@ -1102,6 +1102,157 @@ async function exchangeCode(input) {
1102
1102
  return tokenResponse.json();
1103
1103
  }
1104
1104
  //#endregion
1105
+ //#region packages/core/src/keyword-sets/schema.ts
1106
+ const KEYWORD_SET_SCHEMA_SQL = `
1107
+ CREATE TABLE IF NOT EXISTS keyword_sets (
1108
+ id TEXT PRIMARY KEY,
1109
+ project_id TEXT NOT NULL,
1110
+ name TEXT NOT NULL,
1111
+ market_json TEXT NOT NULL,
1112
+ provider TEXT CHECK(provider IS NULL OR provider IN ('dataforseo', 'semrush', 'ahrefs')),
1113
+ source_report TEXT,
1114
+ created_at INTEGER NOT NULL,
1115
+ updated_at INTEGER NOT NULL,
1116
+ last_refreshed_at INTEGER,
1117
+ UNIQUE(project_id, name COLLATE NOCASE)
1118
+ );
1119
+ CREATE INDEX IF NOT EXISTS idx_keyword_sets_project
1120
+ ON keyword_sets(project_id, updated_at DESC, id);
1121
+
1122
+ CREATE TABLE IF NOT EXISTS keyword_set_items (
1123
+ set_id TEXT NOT NULL,
1124
+ normalized_keyword TEXT NOT NULL,
1125
+ display_keyword TEXT NOT NULL,
1126
+ metric_json TEXT,
1127
+ metric_provider TEXT CHECK(metric_provider IS NULL OR metric_provider IN ('dataforseo', 'semrush', 'ahrefs')),
1128
+ metric_observed_at TEXT,
1129
+ page_kind TEXT CHECK(page_kind IS NULL OR page_kind IN ('target', 'proposed')),
1130
+ page_url TEXT,
1131
+ created_at INTEGER NOT NULL,
1132
+ updated_at INTEGER NOT NULL,
1133
+ PRIMARY KEY(set_id, normalized_keyword),
1134
+ FOREIGN KEY(set_id) REFERENCES keyword_sets(id) ON DELETE CASCADE
1135
+ ) WITHOUT ROWID;
1136
+ CREATE INDEX IF NOT EXISTS idx_keyword_set_items_set
1137
+ ON keyword_set_items(set_id, normalized_keyword);
1138
+
1139
+ CREATE TABLE IF NOT EXISTS keyword_set_tags (
1140
+ set_id TEXT NOT NULL,
1141
+ normalized_keyword TEXT NOT NULL,
1142
+ tag TEXT NOT NULL,
1143
+ PRIMARY KEY(set_id, normalized_keyword, tag),
1144
+ FOREIGN KEY(set_id, normalized_keyword)
1145
+ REFERENCES keyword_set_items(set_id, normalized_keyword) ON DELETE CASCADE
1146
+ ) WITHOUT ROWID;
1147
+ CREATE INDEX IF NOT EXISTS idx_keyword_set_tags_lookup
1148
+ ON keyword_set_tags(set_id, tag, normalized_keyword);
1149
+ `;
1150
+ //#endregion
1151
+ //#region packages/core/src/rank-tracking/schema.ts
1152
+ const RANK_TRACKING_SCHEMA_SQL = `
1153
+ CREATE TABLE IF NOT EXISTS rank_tracking_configs (
1154
+ id TEXT PRIMARY KEY,
1155
+ config_key TEXT NOT NULL UNIQUE,
1156
+ project_id TEXT NOT NULL,
1157
+ keyword_set_id TEXT NOT NULL,
1158
+ target_domain TEXT NOT NULL,
1159
+ tag TEXT,
1160
+ market_json TEXT NOT NULL,
1161
+ devices_json TEXT NOT NULL,
1162
+ provider TEXT NOT NULL CHECK(provider IN ('dataforseo', 'semrush', 'ahrefs')),
1163
+ collection_method TEXT NOT NULL CHECK(collection_method IN ('live', 'queued')),
1164
+ cadence TEXT NOT NULL CHECK(cadence IN ('manual', 'daily', 'weekly', 'monthly')),
1165
+ depth INTEGER NOT NULL CHECK(depth BETWEEN 1 AND 100),
1166
+ keyword_limit INTEGER NOT NULL CHECK(keyword_limit BETWEEN 1 AND 1000),
1167
+ next_run_at INTEGER,
1168
+ created_at INTEGER NOT NULL,
1169
+ updated_at INTEGER NOT NULL,
1170
+ FOREIGN KEY(keyword_set_id) REFERENCES keyword_sets(id) ON DELETE CASCADE
1171
+ );
1172
+ CREATE INDEX IF NOT EXISTS idx_rank_tracking_configs_project
1173
+ ON rank_tracking_configs(project_id, updated_at DESC, id);
1174
+ CREATE INDEX IF NOT EXISTS idx_rank_tracking_configs_due
1175
+ ON rank_tracking_configs(cadence, next_run_at, id);
1176
+
1177
+ CREATE TABLE IF NOT EXISTS rank_tracking_runs (
1178
+ id TEXT PRIMARY KEY,
1179
+ config_id TEXT NOT NULL,
1180
+ idempotency_key TEXT NOT NULL UNIQUE,
1181
+ state TEXT NOT NULL CHECK(state IN ('pending', 'partial', 'failed', 'complete')),
1182
+ collection_method TEXT NOT NULL CHECK(collection_method IN ('live', 'queued')),
1183
+ scheduled_for INTEGER NOT NULL,
1184
+ started_at INTEGER NOT NULL,
1185
+ completed_at INTEGER,
1186
+ keyword_count INTEGER NOT NULL,
1187
+ task_count INTEGER NOT NULL,
1188
+ snapshot_count INTEGER NOT NULL DEFAULT 0,
1189
+ pending_count INTEGER NOT NULL,
1190
+ failed_count INTEGER NOT NULL DEFAULT 0,
1191
+ estimated_cost_micros INTEGER,
1192
+ actual_cost_micros INTEGER,
1193
+ config_snapshot_json TEXT NOT NULL,
1194
+ error_summary TEXT,
1195
+ FOREIGN KEY(config_id) REFERENCES rank_tracking_configs(id) ON DELETE CASCADE
1196
+ );
1197
+ CREATE INDEX IF NOT EXISTS idx_rank_tracking_runs_config
1198
+ ON rank_tracking_runs(config_id, started_at DESC, id DESC);
1199
+ DROP INDEX IF EXISTS idx_rank_tracking_active_run;
1200
+ CREATE UNIQUE INDEX idx_rank_tracking_active_run
1201
+ ON rank_tracking_runs(config_id)
1202
+ WHERE completed_at IS NULL;
1203
+
1204
+ CREATE TABLE IF NOT EXISTS rank_tracking_tasks (
1205
+ id TEXT PRIMARY KEY,
1206
+ run_id TEXT NOT NULL,
1207
+ normalized_keyword TEXT NOT NULL,
1208
+ display_keyword TEXT NOT NULL,
1209
+ device TEXT NOT NULL CHECK(device IN ('desktop', 'mobile')),
1210
+ state TEXT NOT NULL CHECK(state IN ('pending', 'posting', 'posted', 'complete', 'failed')),
1211
+ provider_task_id TEXT,
1212
+ attempt_count INTEGER NOT NULL DEFAULT 0,
1213
+ posted_at INTEGER,
1214
+ collected_at INTEGER,
1215
+ error_code TEXT,
1216
+ error_message TEXT,
1217
+ UNIQUE(run_id, normalized_keyword, device),
1218
+ UNIQUE(provider_task_id),
1219
+ FOREIGN KEY(run_id) REFERENCES rank_tracking_runs(id) ON DELETE CASCADE
1220
+ );
1221
+ CREATE INDEX IF NOT EXISTS idx_rank_tracking_tasks_run
1222
+ ON rank_tracking_tasks(run_id, state, normalized_keyword, device);
1223
+ CREATE INDEX IF NOT EXISTS idx_rank_tracking_tasks_provider
1224
+ ON rank_tracking_tasks(provider_task_id, state);
1225
+
1226
+ CREATE TABLE IF NOT EXISTS rank_tracking_snapshots (
1227
+ task_id TEXT PRIMARY KEY,
1228
+ run_id TEXT NOT NULL,
1229
+ normalized_keyword TEXT NOT NULL,
1230
+ display_keyword TEXT NOT NULL,
1231
+ device TEXT NOT NULL CHECK(device IN ('desktop', 'mobile')),
1232
+ observation_state TEXT NOT NULL CHECK(observation_state IN ('observed', 'not_observed_within_depth')),
1233
+ organic_position INTEGER,
1234
+ absolute_position INTEGER,
1235
+ ranking_url TEXT,
1236
+ observed_features_json TEXT NOT NULL,
1237
+ checked_at TEXT NOT NULL,
1238
+ provider TEXT NOT NULL CHECK(provider IN ('dataforseo', 'semrush', 'ahrefs')),
1239
+ provider_task_id TEXT,
1240
+ requested_depth INTEGER NOT NULL,
1241
+ returned_rows INTEGER,
1242
+ retained_rows INTEGER,
1243
+ invalid_rows INTEGER NOT NULL,
1244
+ completeness TEXT NOT NULL,
1245
+ estimated_cost_micros INTEGER,
1246
+ actual_cost_micros INTEGER,
1247
+ warnings_json TEXT NOT NULL,
1248
+ created_at INTEGER NOT NULL,
1249
+ FOREIGN KEY(task_id) REFERENCES rank_tracking_tasks(id) ON DELETE CASCADE,
1250
+ FOREIGN KEY(run_id) REFERENCES rank_tracking_runs(id) ON DELETE CASCADE
1251
+ );
1252
+ CREATE INDEX IF NOT EXISTS idx_rank_tracking_snapshots_run
1253
+ ON rank_tracking_snapshots(run_id, normalized_keyword, device);
1254
+ `;
1255
+ //#endregion
1105
1256
  //#region packages/core/src/storage/cache-maintenance.ts
1106
1257
  const MEBIBYTE$2 = 1024 * 1024;
1107
1258
  const CACHE_MAX_BYTES = 256 * MEBIBYTE$2;
@@ -1657,6 +1808,8 @@ function initDb(database, isNewDatabase) {
1657
1808
  if (isNewDatabase) database.pragma("auto_vacuum = INCREMENTAL");
1658
1809
  database.transaction(() => {
1659
1810
  database.exec(CREATE_SQL);
1811
+ database.exec(KEYWORD_SET_SCHEMA_SQL);
1812
+ database.exec(RANK_TRACKING_SCHEMA_SQL);
1660
1813
  database.exec(PROVIDER_SPEND_SCHEMA_SQL);
1661
1814
  database.exec(`DELETE FROM semrush_cache WHERE request_json LIKE '%"key"%'`);
1662
1815
  ensureColumn(database, "crawl_pages", "snapshot_json", "TEXT");
@@ -4890,7 +5043,7 @@ async function fetchPage(url, opts = {}, dependencies = {}) {
4890
5043
  }
4891
5044
  //#endregion
4892
5045
  //#region packages/core/src/gsc/property-url.ts
4893
- function invalid(message) {
5046
+ function invalid$2(message) {
4894
5047
  throw new SeoError("INVALID_INPUT", message);
4895
5048
  }
4896
5049
  function normalizeHttpUrl(value) {
@@ -4898,10 +5051,10 @@ function normalizeHttpUrl(value) {
4898
5051
  try {
4899
5052
  url = new URL(value.trim());
4900
5053
  } catch {
4901
- return invalid(`Invalid URL: ${value}`);
5054
+ return invalid$2(`Invalid URL: ${value}`);
4902
5055
  }
4903
- if (url.protocol !== "http:" && url.protocol !== "https:") return invalid("The report URL must use http:// or https://.");
4904
- if (url.username || url.password) return invalid("The report URL must not contain embedded credentials.");
5056
+ if (url.protocol !== "http:" && url.protocol !== "https:") return invalid$2("The report URL must use http:// or https://.");
5057
+ if (url.username || url.password) return invalid$2("The report URL must not contain embedded credentials.");
4905
5058
  url.hash = "";
4906
5059
  return url.toString();
4907
5060
  }
@@ -4910,13 +5063,13 @@ function assertUrlMatchesGscProperty(property, value) {
4910
5063
  const parsed = new URL(url);
4911
5064
  if (property.startsWith("sc-domain:")) {
4912
5065
  const domain = property.slice(10).trim().toLowerCase();
4913
- if (!domain || /[/:]/.test(domain)) return invalid(`Invalid Search Console domain property: ${property}`);
5066
+ if (!domain || /[/:]/.test(domain)) return invalid$2(`Invalid Search Console domain property: ${property}`);
4914
5067
  const hostname = parsed.hostname.toLowerCase();
4915
- if (hostname !== domain && !hostname.endsWith(`.${domain}`)) return invalid(`${url} is outside the Search Console property ${property}.`);
5068
+ if (hostname !== domain && !hostname.endsWith(`.${domain}`)) return invalid$2(`${url} is outside the Search Console property ${property}.`);
4916
5069
  return url;
4917
5070
  }
4918
5071
  const prefix = normalizeHttpUrl(property);
4919
- if (!url.startsWith(prefix)) return invalid(`${url} is outside the Search Console property ${property}.`);
5072
+ if (!url.startsWith(prefix)) return invalid$2(`${url} is outside the Search Console property ${property}.`);
4920
5073
  return url;
4921
5074
  }
4922
5075
  //#endregion
@@ -7123,7 +7276,7 @@ function selectBenchmarkRows$1(input) {
7123
7276
  function compareText$15(left, right) {
7124
7277
  return left < right ? -1 : left > right ? 1 : 0;
7125
7278
  }
7126
- function compareItems$1(left, right) {
7279
+ function compareItems$2(left, right) {
7127
7280
  return (right.estimatedCtrClickShortfall ?? -1) - (left.estimatedCtrClickShortfall ?? -1) || right.impressions - left.impressions || left.position - right.position || compareText$15(left.query, right.query) || compareText$15(left.url, right.url);
7128
7281
  }
7129
7282
  function analyzeRow(input) {
@@ -7188,7 +7341,7 @@ function analyzePageOpportunitiesFromRows(input) {
7188
7341
  reportInput: input,
7189
7342
  benchmarkContext,
7190
7343
  excludedTargetRows
7191
- })).sort(compareItems$1).slice(0, limit);
7344
+ })).sort(compareItems$2).slice(0, limit);
7192
7345
  const estimatedCtrClickShortfall = Number(items.reduce((sum, item) => sum + (item.estimatedCtrClickShortfall ?? 0), 0).toFixed(2));
7193
7346
  return {
7194
7347
  site: input.site,
@@ -18717,7 +18870,7 @@ function compareRetainedSegmentRows(input) {
18717
18870
  }
18718
18871
  //#endregion
18719
18872
  //#region packages/core/src/analyze/segment-impact-analysis.ts
18720
- function verdict$4(input) {
18873
+ function verdict$5(input) {
18721
18874
  if (input.status === "empty") return "Search Console returned no retained segment rows for either window.";
18722
18875
  if (!input.matchedRows) return "No segment was retained in both windows, so no movement delta was inferred.";
18723
18876
  const suffix = input.unmatchedRows ? ` ${input.unmatchedRows} one-window rows were kept as unmatched evidence and not converted to zero.` : "";
@@ -18810,7 +18963,7 @@ function analyzeSegmentRows(input) {
18810
18963
  matchedRows: matched.length,
18811
18964
  returnedRows: selection.returnedRows,
18812
18965
  unmatchedRows: unmatched.length,
18813
- verdict: verdict$4({
18966
+ verdict: verdict$5({
18814
18967
  status: dataStatus,
18815
18968
  matchedRows: matched.length,
18816
18969
  returnedRows: selection.returnedRows,
@@ -19623,7 +19776,7 @@ const defaultDependencies$10 = {
19623
19776
  searchAnalytics: querySearchAnalytics,
19624
19777
  now: () => /* @__PURE__ */ new Date()
19625
19778
  };
19626
- function verdict$3(input) {
19779
+ function verdict$4(input) {
19627
19780
  if (input.status === "empty") return "Search Console returned no retained query/page rows for this window.";
19628
19781
  if (!input.eligible) {
19629
19782
  if (input.status === "partial") return "No material multi-URL query candidates remained in the validated retained rows, but partial evidence prevents an all-clear.";
@@ -19750,7 +19903,7 @@ async function cannibalReport(input, dependencies = defaultDependencies$10) {
19750
19903
  returnedClusters: analysis.selection.returnedClusters,
19751
19904
  suppressedQueries: analysis.selection.suppressedQueries,
19752
19905
  brandFiltering: input.includeBrand ? "included" : "excluded",
19753
- verdict: verdict$3({
19906
+ verdict: verdict$4({
19754
19907
  status: dataStatus,
19755
19908
  eligible: analysis.selection.eligibleClusters,
19756
19909
  returned: analysis.selection.returnedClusters
@@ -20036,7 +20189,7 @@ const defaultDependencies$9 = {
20036
20189
  searchAnalytics: querySearchAnalytics,
20037
20190
  now: () => /* @__PURE__ */ new Date()
20038
20191
  };
20039
- function verdict$2(report) {
20192
+ function verdict$3(report) {
20040
20193
  if (report.dataStatus === "empty") return "Search Console returned no retained query/page rows for either comparison window.";
20041
20194
  if (report.dataStatus === "partial" && report.selection.currentAggregatedRows === 0) return "The current retained-row window is empty, so no query/page losses were inferred.";
20042
20195
  if (report.dataStatus === "partial" && report.selection.previousAggregatedRows === 0) return "The comparison retained-row window is empty, so query/page decay could not be measured.";
@@ -20181,7 +20334,7 @@ async function decayingReport(input, dependencies = defaultDependencies$9) {
20181
20334
  observedRetainedQueryClickLoss: analysis.totals.eligibleObservedRetainedQueryClickLoss,
20182
20335
  returnedObservedRetainedQueryClickLoss: analysis.totals.returnedObservedRetainedQueryClickLoss,
20183
20336
  brandFiltering: input.includeBrand ? "included" : "excluded",
20184
- verdict: verdict$2(reportBase)
20337
+ verdict: verdict$3(reportBase)
20185
20338
  },
20186
20339
  caveats: [
20187
20340
  `Current window: ${currentRange.startDate} to ${currentRange.endDate}; comparison: ${previousRange.startDate} to ${previousRange.endDate}.`,
@@ -20975,7 +21128,7 @@ function templatesFromGroups(groups) {
20975
21128
  sampleUrls: group.sampleUrls
20976
21129
  }));
20977
21130
  }
20978
- function verdict$1(analysis) {
21131
+ function verdict$2(analysis) {
20979
21132
  if (analysis.dataStatus === "empty") return "Search Console returned no retained query/page rows for this date window.";
20980
21133
  if (analysis.dataStatus === "filtered") return "No retained query/page rows met the report filters.";
20981
21134
  return `${countLabel(analysis.summary.eligibleRows, "eligible query/page row")} found; ${countLabel(analysis.summary.returnedRows, "row")} returned for review.`;
@@ -21059,7 +21212,7 @@ async function strikingDistance(input, dependencies = defaultDependencies$7) {
21059
21212
  summary: {
21060
21213
  ...analysis.summary,
21061
21214
  brandFiltering: input.includeBrand ? "included" : "excluded",
21062
- verdict: verdict$1(analysis)
21215
+ verdict: verdict$2(analysis)
21063
21216
  },
21064
21217
  caveats: [
21065
21218
  `Date window: ${range.startDate} to ${range.endDate}, using final GSC data where available.`,
@@ -23807,7 +23960,7 @@ const MAX_ACTIVE_RESERVATIONS = 100;
23807
23960
  const PROVIDER_SPEND_RETENTION_DAYS = RETENTION_MS / DAY_MS$1;
23808
23961
  const PROVIDER_SPEND_MAX_ROWS = MAX_LEDGER_ROWS;
23809
23962
  const PROVIDER_SPEND_MAX_BYTES = MAX_LEDGER_BYTES;
23810
- function boundedText(provider, value, label, max) {
23963
+ function boundedText$1(provider, value, label, max) {
23811
23964
  const normalized = value.trim();
23812
23965
  if (!normalized || normalized.length > max) throw new ProviderError({
23813
23966
  provider,
@@ -23904,10 +24057,10 @@ function reserveProviderSpend(input, options = {}) {
23904
24057
  const now = options.now ?? Date.now();
23905
24058
  const normalized = {
23906
24059
  ...input,
23907
- endpoint: boundedText(input.provider, input.endpoint, "Provider endpoint", 500),
23908
- projectId: input.projectId ? boundedText(input.provider, input.projectId, "Project id", 200) : void 0,
23909
- reportId: boundedText(input.provider, input.reportId, "Report id", 100),
23910
- reportRunId: boundedText(input.provider, input.reportRunId, "Report run id", 100),
24060
+ endpoint: boundedText$1(input.provider, input.endpoint, "Provider endpoint", 500),
24061
+ projectId: input.projectId ? boundedText$1(input.provider, input.projectId, "Project id", 200) : void 0,
24062
+ reportId: boundedText$1(input.provider, input.reportId, "Report id", 100),
24063
+ reportRunId: boundedText$1(input.provider, input.reportRunId, "Report run id", 100),
23911
24064
  requestedRows: nonnegativeInteger(input.provider, input.requestedRows, "Requested rows"),
23912
24065
  estimatedCostMicros: nonnegativeInteger(input.provider, input.estimatedCostMicros, "Estimated cost")
23913
24066
  };
@@ -23944,7 +24097,7 @@ function reserveProviderSpend(input, options = {}) {
23944
24097
  function finalizeProviderSpend(reservationId, result, options = {}) {
23945
24098
  const database = options.database ?? getDb();
23946
24099
  const now = options.now ?? Date.now();
23947
- 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));
24100
+ 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));
23948
24101
  const actualCostMicros = result.actualCostMicros === null ? null : nonnegativeInteger(result.provider, result.actualCostMicros, "Actual cost");
23949
24102
  const returnedRows = result.returnedRows === null ? null : nonnegativeInteger(result.provider, result.returnedRows, "Returned rows");
23950
24103
  return database.transaction(() => {
@@ -24245,7 +24398,10 @@ const userDataPriceSchema = z.object({
24245
24398
  keyword_suggestions: livePriceSchema.optional(),
24246
24399
  related_keywords: livePriceSchema.optional()
24247
24400
  }).passthrough().optional(),
24248
- serp: z.object({ live: z.object({ advanced: normalPriorityPriceSchema.optional() }).passthrough().optional() }).passthrough().optional()
24401
+ serp: z.object({
24402
+ task_post: normalPriorityPriceSchema.optional(),
24403
+ live: z.object({ advanced: normalPriorityPriceSchema.optional() }).passthrough().optional()
24404
+ }).passthrough().optional()
24249
24405
  }).passthrough();
24250
24406
  const userDataResultSchema = z.object({
24251
24407
  login: z.string().min(1).max(320),
@@ -24277,7 +24433,7 @@ const dataForSeoUserDataResponseSchema = z.object({
24277
24433
  const MAX_PROVIDER_MONTHLY_SEARCH_ROWS = 120;
24278
24434
  const nullableNumber = z.number().finite().nullable().optional();
24279
24435
  const nullableCount = z.union([z.number().finite(), z.string().trim().min(1)]).nullable().optional();
24280
- const monthlySearchSchema = z.object({
24436
+ const monthlySearchSchema$1 = z.object({
24281
24437
  year: z.number().int(),
24282
24438
  month: z.number().int(),
24283
24439
  search_volume: z.number().finite().nullable().optional()
@@ -24288,7 +24444,7 @@ const keywordOverviewItemSchema = z.object({
24288
24444
  search_volume: nullableNumber,
24289
24445
  cpc: nullableNumber,
24290
24446
  competition: nullableNumber,
24291
- monthly_searches: z.array(monthlySearchSchema).max(MAX_PROVIDER_MONTHLY_SEARCH_ROWS).nullish(),
24447
+ monthly_searches: z.array(monthlySearchSchema$1).max(MAX_PROVIDER_MONTHLY_SEARCH_ROWS).nullish(),
24292
24448
  last_updated_time: z.string().trim().min(1).nullable().optional()
24293
24449
  }).nullish(),
24294
24450
  keyword_properties: z.object({ keyword_difficulty: nullableNumber }).nullish(),
@@ -24302,7 +24458,7 @@ const keywordOverviewItemSchema = z.object({
24302
24458
  }).passthrough();
24303
24459
  const keywordOverviewResultSchema = z.object({
24304
24460
  items_count: z.number().int().nonnegative().optional(),
24305
- items: z.array(keywordOverviewItemSchema).optional()
24461
+ items: z.array(keywordOverviewItemSchema).nullable().optional()
24306
24462
  }).passthrough();
24307
24463
  const keywordOverviewTaskSchema = z.object({
24308
24464
  id: z.string().trim().min(1).optional(),
@@ -24363,19 +24519,19 @@ const dataForSeoDiscoveryResponseSchema = z.object({
24363
24519
  }).passthrough();
24364
24520
  //#endregion
24365
24521
  //#region packages/core/src/providers/dataforseo/paid-request.ts
24366
- function usdToMicros$1(value) {
24522
+ function usdToMicros(value) {
24367
24523
  if (value === void 0) return null;
24368
24524
  return Math.round(value * 1e6);
24369
24525
  }
24370
24526
  function responseTaskIds$1(response) {
24371
24527
  return [...new Set(response.tasks.flatMap((task) => task.id ? [task.id] : []))].sort((left, right) => left < right ? -1 : left > right ? 1 : 0);
24372
24528
  }
24373
- function responseCostMicros$1(response) {
24374
- if (response.cost !== void 0) return usdToMicros$1(response.cost);
24529
+ function responseCostMicros(response) {
24530
+ if (response.cost !== void 0) return usdToMicros(response.cost);
24375
24531
  if (response.tasks.some((task) => task.cost === void 0)) return null;
24376
- return response.tasks.reduce((sum, task) => sum + (usdToMicros$1(task.cost) ?? 0), 0);
24532
+ return response.tasks.reduce((sum, task) => sum + (usdToMicros(task.cost) ?? 0), 0);
24377
24533
  }
24378
- function taskErrorCode$1(statusCode) {
24534
+ function taskErrorCode(statusCode) {
24379
24535
  if (statusCode >= 40100 && statusCode < 40200) return "authentication";
24380
24536
  if (statusCode === 40202) return "rate-limit";
24381
24537
  return "remote-error";
@@ -24465,7 +24621,7 @@ async function dataForSeoPaidPost(input) {
24465
24621
  });
24466
24622
  throw error;
24467
24623
  }
24468
- const actualCostMicros = responseCostMicros$1(response);
24624
+ const actualCostMicros = responseCostMicros(response);
24469
24625
  const returnedRows = input.rowCount(response);
24470
24626
  const taskIds = responseTaskIds$1(response);
24471
24627
  const failedTasks = response.tasks.filter((task) => task.status_code !== 2e4);
@@ -24487,7 +24643,7 @@ async function dataForSeoPaidPost(input) {
24487
24643
  throw new ProviderError({
24488
24644
  provider: "dataforseo",
24489
24645
  operation: input.operation,
24490
- code: taskErrorCode$1(statusCode),
24646
+ code: taskErrorCode(statusCode),
24491
24647
  message: `DataForSEO could not complete ${input.operation} (${statusCode}).`,
24492
24648
  retryable: statusCode === 40202
24493
24649
  });
@@ -24530,6 +24686,22 @@ async function dataForSeoPaidPost(input) {
24530
24686
  };
24531
24687
  }
24532
24688
  //#endregion
24689
+ //#region packages/core/src/providers/dataforseo/response.ts
24690
+ function dataForSeoUsdToMicros(value) {
24691
+ if (value === void 0) return null;
24692
+ return Math.round(value * 1e6);
24693
+ }
24694
+ function dataForSeoTaskErrorCode(statusCode) {
24695
+ if (statusCode >= 40100 && statusCode < 40200) return "authentication";
24696
+ if (statusCode === 40202) return "rate-limit";
24697
+ return "remote-error";
24698
+ }
24699
+ function dataForSeoResponseCostMicros(response) {
24700
+ if (response.cost !== void 0) return dataForSeoUsdToMicros(response.cost);
24701
+ if (response.tasks.some((task) => task.cost === void 0)) return null;
24702
+ return response.tasks.reduce((sum, task) => sum + (dataForSeoUsdToMicros(task.cost) ?? 0), 0);
24703
+ }
24704
+ //#endregion
24533
24705
  //#region packages/core/src/providers/dataforseo/serp-schema.ts
24534
24706
  const serpItemSchema = z.object({
24535
24707
  type: z.string().trim().min(1).max(100),
@@ -24574,6 +24746,237 @@ const dataForSeoSerpResponseSchema = z.object({
24574
24746
  tasks: z.array(serpTaskSchema).max(10)
24575
24747
  }).passthrough();
24576
24748
  //#endregion
24749
+ //#region packages/core/src/providers/dataforseo/serp-task-schema.ts
24750
+ const taskPostItemSchema = z.object({
24751
+ id: z.string().trim().min(1).max(100).optional(),
24752
+ status_code: z.number().int(),
24753
+ status_message: z.string().max(1e3),
24754
+ cost: z.number().finite().nonnegative().optional(),
24755
+ result_count: z.number().int().nonnegative().optional(),
24756
+ data: z.object({ tag: z.string().trim().min(1).max(255).optional() }).passthrough().optional()
24757
+ }).passthrough();
24758
+ const dataForSeoSerpTaskPostResponseSchema = z.object({
24759
+ status_code: z.number().int(),
24760
+ status_message: z.string().max(1e3),
24761
+ cost: z.number().finite().nonnegative().optional(),
24762
+ tasks_count: z.number().int().nonnegative(),
24763
+ tasks_error: z.number().int().nonnegative(),
24764
+ tasks: z.array(taskPostItemSchema).max(100)
24765
+ }).passthrough();
24766
+ const readyResultSchema = z.object({
24767
+ id: z.string().trim().min(1).max(100),
24768
+ tag: z.string().trim().max(255).nullable().optional(),
24769
+ se: z.string().trim().max(100).optional(),
24770
+ se_type: z.string().trim().max(100).optional(),
24771
+ endpoint_advanced: z.string().trim().max(2048).nullable().optional()
24772
+ }).passthrough();
24773
+ const readyTaskSchema = z.object({
24774
+ id: z.string().trim().min(1).max(100).optional(),
24775
+ status_code: z.number().int(),
24776
+ status_message: z.string().max(1e3),
24777
+ cost: z.number().finite().nonnegative().optional(),
24778
+ result: z.array(readyResultSchema).max(1e3).nullable().optional()
24779
+ }).passthrough();
24780
+ const dataForSeoSerpTasksReadyResponseSchema = z.object({
24781
+ status_code: z.number().int(),
24782
+ status_message: z.string().max(1e3),
24783
+ cost: z.number().finite().nonnegative().optional(),
24784
+ tasks_count: z.number().int().nonnegative(),
24785
+ tasks_error: z.number().int().nonnegative(),
24786
+ tasks: z.array(readyTaskSchema).max(10)
24787
+ }).passthrough();
24788
+ //#endregion
24789
+ //#region packages/core/src/providers/dataforseo/serp-tasks.ts
24790
+ const SERP_TASK_POST_PATH = "v3/serp/google/organic/task_post";
24791
+ const SERP_TASKS_READY_PATH = "v3/serp/google/organic/tasks_ready";
24792
+ const SERP_TASK_GET_ADVANCED_PATH = "v3/serp/google/organic/task_get/advanced/";
24793
+ function dataForSeoSerpRows(response) {
24794
+ return response.tasks.reduce((taskTotal, task) => taskTotal + (task.result ?? []).reduce((resultTotal, result) => resultTotal + (result.items?.length ?? 0), 0), 0);
24795
+ }
24796
+ function validateDataForSeoSerpTaskId(providerTaskId) {
24797
+ if (!/^[a-zA-Z0-9-]{1,100}$/u.test(providerTaskId)) throw new ProviderError({
24798
+ provider: "dataforseo",
24799
+ operation: "serp-task-get",
24800
+ code: "configuration",
24801
+ message: "Use a valid queued SERP task id."
24802
+ });
24803
+ }
24804
+ async function postDataForSeoSerpTasks(input, transport) {
24805
+ const { price, requests } = input;
24806
+ if (price.perRequestMicros === null || price.perResultMicros === null) throw new ProviderError({
24807
+ provider: "dataforseo",
24808
+ operation: "serp-task-post",
24809
+ code: "invalid-response",
24810
+ message: "DataForSEO Standard SERP pricing is unavailable, so queued tasks were not posted."
24811
+ });
24812
+ const requestUnits = requests.reduce((total, request) => total + Math.ceil(request.depth / 10), 0);
24813
+ const estimatedCostMicros = price.perRequestMicros * requestUnits + price.perResultMicros * requests.length;
24814
+ const reservation = reserveProviderSpend({
24815
+ provider: "dataforseo",
24816
+ capability: "serp-snapshot",
24817
+ endpoint: SERP_TASK_POST_PATH,
24818
+ projectId: input.context.projectId,
24819
+ reportId: input.context.reportId,
24820
+ reportRunId: input.context.reportRunId,
24821
+ requestedRows: requests.length,
24822
+ estimatedCostMicros
24823
+ }, {
24824
+ database: transport.database,
24825
+ limits: transport.spendLimits,
24826
+ now: transport.now().getTime()
24827
+ });
24828
+ let response;
24829
+ try {
24830
+ response = await providerRequestJson({
24831
+ provider: "dataforseo",
24832
+ operation: "serp-task-post",
24833
+ url: new URL(SERP_TASK_POST_PATH, transport.baseUrl),
24834
+ fetch: transport.fetch,
24835
+ maxResponseBytes: transport.maxResponseBytes,
24836
+ timeoutMs: transport.timeoutMs,
24837
+ retry: "never",
24838
+ schema: dataForSeoSerpTaskPostResponseSchema,
24839
+ init: {
24840
+ method: "POST",
24841
+ headers: {
24842
+ authorization: transport.authorization,
24843
+ "content-type": "application/json"
24844
+ },
24845
+ body: JSON.stringify(requests)
24846
+ }
24847
+ });
24848
+ } catch (error) {
24849
+ finalizeProviderSpend(reservation.id, {
24850
+ provider: "dataforseo",
24851
+ state: "failed",
24852
+ actualCostMicros: null,
24853
+ returnedRows: null,
24854
+ taskIds: []
24855
+ }, {
24856
+ database: transport.database,
24857
+ limits: transport.spendLimits,
24858
+ now: transport.now().getTime()
24859
+ });
24860
+ throw error;
24861
+ }
24862
+ const taskReceipts = response.tasks.flatMap((task) => task.id && task.data?.tag ? [{
24863
+ providerTaskId: task.id,
24864
+ tag: task.data.tag
24865
+ }] : []);
24866
+ const taskIds = taskReceipts.map((receipt) => receipt.providerTaskId);
24867
+ const expectedTags = new Set(requests.map((request) => request.tag));
24868
+ const returnedTags = new Set(taskReceipts.map((receipt) => receipt.tag));
24869
+ const receiptsMatch = expectedTags.size === requests.length && returnedTags.size === expectedTags.size && [...expectedTags].every((tag) => returnedTags.has(tag));
24870
+ const failedTasks = response.tasks.filter((task) => task.status_code !== 20100 || !task.id || !task.data?.tag);
24871
+ const actualCostMicros = dataForSeoResponseCostMicros(response);
24872
+ const state = failedTasks.length === 0 && response.tasks_error === 0 ? "succeeded" : failedTasks.length < response.tasks.length ? "partial" : "failed";
24873
+ const spendNotice = finalizeProviderSpend(reservation.id, {
24874
+ provider: "dataforseo",
24875
+ state,
24876
+ actualCostMicros,
24877
+ returnedRows: taskIds.length,
24878
+ taskIds
24879
+ }, {
24880
+ database: transport.database,
24881
+ limits: transport.spendLimits,
24882
+ now: transport.now().getTime()
24883
+ });
24884
+ if (response.status_code !== 2e4 || failedTasks.length > 0 || taskIds.length !== requests.length || !receiptsMatch) {
24885
+ const statusCode = failedTasks[0]?.status_code ?? response.status_code;
24886
+ throw new ProviderError({
24887
+ provider: "dataforseo",
24888
+ operation: "serp-task-post",
24889
+ code: dataForSeoTaskErrorCode(statusCode),
24890
+ message: `DataForSEO accepted ${taskIds.length} of ${requests.length} queued SERP tasks (${statusCode}).`
24891
+ });
24892
+ }
24893
+ return {
24894
+ taskIds,
24895
+ taskReceipts,
24896
+ estimatedCostMicros,
24897
+ actualCostMicros,
24898
+ spendNotice,
24899
+ warnings: [],
24900
+ observedAt: transport.now().toISOString()
24901
+ };
24902
+ }
24903
+ async function listReadyDataForSeoSerpTasks(transport) {
24904
+ const response = await providerRequestJson({
24905
+ provider: "dataforseo",
24906
+ operation: "serp-tasks-ready",
24907
+ url: new URL(SERP_TASKS_READY_PATH, transport.baseUrl),
24908
+ fetch: transport.fetch,
24909
+ maxResponseBytes: transport.maxResponseBytes,
24910
+ timeoutMs: transport.timeoutMs,
24911
+ retry: "safe",
24912
+ schema: dataForSeoSerpTasksReadyResponseSchema,
24913
+ init: {
24914
+ method: "GET",
24915
+ headers: { authorization: transport.authorization }
24916
+ }
24917
+ });
24918
+ const failedTask = response.tasks.find((task) => task.status_code !== 2e4);
24919
+ if (response.status_code !== 2e4 || failedTask) {
24920
+ const statusCode = failedTask?.status_code ?? response.status_code;
24921
+ throw new ProviderError({
24922
+ provider: "dataforseo",
24923
+ operation: "serp-tasks-ready",
24924
+ code: dataForSeoTaskErrorCode(statusCode),
24925
+ message: `DataForSEO could not list ready SERP tasks (${statusCode}).`,
24926
+ retryable: statusCode === 40202
24927
+ });
24928
+ }
24929
+ return response.tasks.flatMap((task) => (task.result ?? []).map((result) => ({
24930
+ providerTaskId: result.id,
24931
+ tag: result.tag ?? null
24932
+ })));
24933
+ }
24934
+ async function getDataForSeoSerpTask(providerTaskId, transport) {
24935
+ const response = await providerRequestJson({
24936
+ provider: "dataforseo",
24937
+ operation: "serp-task-get",
24938
+ url: new URL(`${SERP_TASK_GET_ADVANCED_PATH}${providerTaskId}`, transport.baseUrl),
24939
+ fetch: transport.fetch,
24940
+ maxResponseBytes: transport.maxResponseBytes,
24941
+ timeoutMs: transport.timeoutMs,
24942
+ retry: "safe",
24943
+ schema: dataForSeoSerpResponseSchema,
24944
+ init: {
24945
+ method: "GET",
24946
+ headers: { authorization: transport.authorization }
24947
+ }
24948
+ });
24949
+ const failedTask = response.tasks.find((task) => task.status_code !== 2e4);
24950
+ if (response.status_code !== 2e4 || failedTask) {
24951
+ const statusCode = failedTask?.status_code ?? response.status_code;
24952
+ throw new ProviderError({
24953
+ provider: "dataforseo",
24954
+ operation: "serp-task-get",
24955
+ code: dataForSeoTaskErrorCode(statusCode),
24956
+ message: `DataForSEO could not collect queued SERP task ${providerTaskId} (${statusCode}).`,
24957
+ retryable: statusCode === 40202
24958
+ });
24959
+ }
24960
+ return {
24961
+ response,
24962
+ observedAt: transport.now().toISOString(),
24963
+ returnedRows: dataForSeoSerpRows(response),
24964
+ cache: {
24965
+ status: "bypass",
24966
+ storedAt: null,
24967
+ expiresAt: null
24968
+ },
24969
+ cost: {
24970
+ currency: "USD",
24971
+ estimatedMicros: 0,
24972
+ actualMicros: dataForSeoResponseCostMicros(response) ?? 0,
24973
+ taskIds: [providerTaskId]
24974
+ },
24975
+ spendNotice: null,
24976
+ warnings: []
24977
+ };
24978
+ }
24979
+ //#endregion
24577
24980
  //#region packages/core/src/providers/dataforseo/client.ts
24578
24981
  const DEFAULT_BASE_URL$1 = "https://api.dataforseo.com/";
24579
24982
  const USER_DATA_PATH = "v3/appendix/user_data";
@@ -24596,9 +24999,43 @@ const MAX_KEYWORD_WORDS = 10;
24596
24999
  const MAX_DISCOVERY_SEEDS$1 = 5;
24597
25000
  const MAX_DISCOVERY_ROWS$1 = 100;
24598
25001
  const MAX_SERP_DEPTH$1 = 100;
24599
- function usdToMicros(value) {
24600
- if (value === void 0) return null;
24601
- return Math.round(value * 1e6);
25002
+ function validateSerpInput(input) {
25003
+ const keyword = input.keyword.trim().replace(/\s+/gu, " ");
25004
+ if (keyword.length < 1 || keyword.length > MAX_KEYWORD_CHARACTERS || keyword.split(/\s+/u).length > MAX_KEYWORD_WORDS) throw new ProviderError({
25005
+ provider: "dataforseo",
25006
+ operation: "serp-snapshot",
25007
+ code: "configuration",
25008
+ message: `SERP snapshots require a keyword of at most ${MAX_KEYWORD_CHARACTERS} characters and ${MAX_KEYWORD_WORDS} words.`
25009
+ });
25010
+ if (/(?:^|\s)(?:allinanchor|allintext|allintitle|allinurl|cache|define|definition|filetype|id|inanchor|info|intext|intitle|inurl|link|site):/iu.test(keyword)) throw new ProviderError({
25011
+ provider: "dataforseo",
25012
+ operation: "serp-snapshot",
25013
+ code: "configuration",
25014
+ message: "SERP snapshot keywords cannot contain search operators with multiplied provider pricing."
25015
+ });
25016
+ if (!Number.isSafeInteger(input.depth) || input.depth < 1 || input.depth > MAX_SERP_DEPTH$1) throw new ProviderError({
25017
+ provider: "dataforseo",
25018
+ operation: "serp-snapshot",
25019
+ code: "configuration",
25020
+ message: `SERP depth must be from 1 to ${MAX_SERP_DEPTH$1}.`
25021
+ });
25022
+ if (!/^[a-z]{2}$/.test(input.languageCode)) throw new ProviderError({
25023
+ provider: "dataforseo",
25024
+ operation: "serp-snapshot",
25025
+ code: "configuration",
25026
+ message: "DataForSEO language code must contain two lowercase letters."
25027
+ });
25028
+ const locationName = input.locationName?.trim();
25029
+ if (input.locationCode !== void 0 === Boolean(locationName)) throw new ProviderError({
25030
+ provider: "dataforseo",
25031
+ operation: "serp-snapshot",
25032
+ code: "configuration",
25033
+ message: "DataForSEO SERP snapshots require exactly one location code or location name."
25034
+ });
25035
+ return {
25036
+ keyword,
25037
+ locationName
25038
+ };
24602
25039
  }
24603
25040
  function unitPrice(components) {
24604
25041
  const prices = components ?? [];
@@ -24624,19 +25061,12 @@ function keywordDiscoveryPrices(account) {
24624
25061
  function serpLiveAdvancedPrice(account) {
24625
25062
  return unitPrice(account.price?.serp?.live?.advanced?.priority_normal);
24626
25063
  }
24627
- function taskErrorCode(statusCode) {
24628
- if (statusCode >= 40100 && statusCode < 40200) return "authentication";
24629
- if (statusCode === 40202) return "rate-limit";
24630
- return "remote-error";
25064
+ function serpTaskPostPrice(account) {
25065
+ return unitPrice(account.price?.serp?.task_post?.priority_normal);
24631
25066
  }
24632
25067
  function responseTaskIds(response) {
24633
25068
  return [...new Set(response.tasks.flatMap((task) => task.id ? [task.id] : []))].sort((left, right) => left < right ? -1 : left > right ? 1 : 0);
24634
25069
  }
24635
- function responseCostMicros(response) {
24636
- if (response.cost !== void 0) return usdToMicros(response.cost);
24637
- if (response.tasks.some((task) => task.cost === void 0)) return null;
24638
- return response.tasks.reduce((sum, task) => sum + (usdToMicros(task.cost) ?? 0), 0);
24639
- }
24640
25070
  function responseRows$1(response) {
24641
25071
  return response.tasks.reduce((taskTotal, task) => taskTotal + (task.result ?? []).reduce((resultTotal, result) => resultTotal + (result.items?.length ?? 0), 0), 0);
24642
25072
  }
@@ -24651,9 +25081,6 @@ function discoveryNextCursor(response) {
24651
25081
  const cursors = response.tasks.flatMap((task) => (task.result ?? []).flatMap((result) => result.offset_token ? [result.offset_token] : []));
24652
25082
  return cursors.length === 1 ? cursors[0] ?? null : null;
24653
25083
  }
24654
- function serpRows(response) {
24655
- return response.tasks.reduce((taskTotal, task) => taskTotal + (task.result ?? []).reduce((resultTotal, result) => resultTotal + (result.items?.length ?? 0), 0), 0);
24656
- }
24657
25084
  var DataForSeoClient = class {
24658
25085
  fetch;
24659
25086
  credentials;
@@ -24696,6 +25123,18 @@ var DataForSeoClient = class {
24696
25123
  authorization(credentials) {
24697
25124
  return `Basic ${Buffer.from(`${credentials.login}:${credentials.password}`).toString("base64")}`;
24698
25125
  }
25126
+ serpTaskTransport(credentials) {
25127
+ return {
25128
+ authorization: this.authorization(credentials),
25129
+ baseUrl: this.baseUrl,
25130
+ fetch: this.fetch,
25131
+ maxResponseBytes: this.maxResponseBytes,
25132
+ timeoutMs: this.timeoutMs,
25133
+ now: this.now,
25134
+ database: this.database,
25135
+ spendLimits: this.spendLimits
25136
+ };
25137
+ }
24699
25138
  async userDataForCredentials(credentials) {
24700
25139
  let response;
24701
25140
  try {
@@ -24730,7 +25169,7 @@ var DataForSeoClient = class {
24730
25169
  throw new ProviderError({
24731
25170
  provider: "dataforseo",
24732
25171
  operation: "account-status",
24733
- code: taskErrorCode(statusCode),
25172
+ code: dataForSeoTaskErrorCode(statusCode),
24734
25173
  message: `DataForSEO could not return account status (${statusCode}).`,
24735
25174
  retryable: statusCode === 40202
24736
25175
  });
@@ -24746,18 +25185,19 @@ var DataForSeoClient = class {
24746
25185
  provider: "dataforseo",
24747
25186
  login: account.login,
24748
25187
  timezone: account.timezone ?? null,
24749
- balanceMicros: usdToMicros(account.money?.balance),
24750
- depositedMicros: usdToMicros(account.money?.total),
24751
- accountDailySpendMicros: usdToMicros(account.money?.statistics?.day?.total),
25188
+ balanceMicros: dataForSeoUsdToMicros(account.money?.balance),
25189
+ depositedMicros: dataForSeoUsdToMicros(account.money?.total),
25190
+ accountDailySpendMicros: dataForSeoUsdToMicros(account.money?.statistics?.day?.total),
24752
25191
  accountDailySpendPeriod: account.money?.statistics?.day?.value ?? null,
24753
- accountDailyLimitMicros: usdToMicros(account.money?.limits?.day?.total),
25192
+ accountDailyLimitMicros: dataForSeoUsdToMicros(account.money?.limits?.day?.total),
24754
25193
  keywordOverviewPrice: keywordOverviewPrice(account),
24755
25194
  keywordDiscoveryPrices: keywordDiscoveryPrices(account),
24756
25195
  serpLiveAdvancedPrice: serpLiveAdvancedPrice(account),
25196
+ serpTaskPostPrice: serpTaskPostPrice(account),
24757
25197
  backlinksSubscriptionExpiresAt: account.backlinks_subscription_expiry_date ?? null,
24758
25198
  aiMentionsSubscriptionExpiresAt: account.llm_mentions_subscription_expiry_date ?? null,
24759
25199
  apiVersion: response.version ?? null,
24760
- requestCostMicros: usdToMicros(response.cost) ?? 0,
25200
+ requestCostMicros: dataForSeoUsdToMicros(response.cost) ?? 0,
24761
25201
  taskIds: response.tasks.flatMap((task) => task.id ? [task.id] : []),
24762
25202
  observedAt: this.now().toISOString()
24763
25203
  };
@@ -24933,7 +25373,7 @@ var DataForSeoClient = class {
24933
25373
  });
24934
25374
  throw error;
24935
25375
  }
24936
- const actualCostMicros = responseCostMicros(response);
25376
+ const actualCostMicros = dataForSeoResponseCostMicros(response);
24937
25377
  const returnedRows = responseRows$1(response);
24938
25378
  const taskIds = responseTaskIds(response);
24939
25379
  const failedTasks = response.tasks.filter((task) => task.status_code !== 2e4);
@@ -24955,7 +25395,7 @@ var DataForSeoClient = class {
24955
25395
  throw new ProviderError({
24956
25396
  provider: "dataforseo",
24957
25397
  operation: "keyword-metrics",
24958
- code: taskErrorCode(statusCode),
25398
+ code: dataForSeoTaskErrorCode(statusCode),
24959
25399
  message: `DataForSEO could not complete the keyword metrics task (${statusCode}).`,
24960
25400
  retryable: statusCode === 40202
24961
25401
  });
@@ -25067,38 +25507,7 @@ var DataForSeoClient = class {
25067
25507
  };
25068
25508
  }
25069
25509
  async serpLive(input) {
25070
- const keyword = input.keyword.trim();
25071
- if (keyword.length < 1 || keyword.length > MAX_KEYWORD_CHARACTERS || keyword.split(/\s+/u).length > MAX_KEYWORD_WORDS) throw new ProviderError({
25072
- provider: "dataforseo",
25073
- operation: "serp-snapshot",
25074
- code: "configuration",
25075
- message: `SERP snapshots require a keyword of at most ${MAX_KEYWORD_CHARACTERS} characters and ${MAX_KEYWORD_WORDS} words.`
25076
- });
25077
- if (/(?:^|\s)(?:allinanchor|allintext|allintitle|allinurl|cache|define|definition|filetype|id|inanchor|info|intext|intitle|inurl|link|site):/iu.test(keyword)) throw new ProviderError({
25078
- provider: "dataforseo",
25079
- operation: "serp-snapshot",
25080
- code: "configuration",
25081
- message: "SERP snapshot keywords cannot contain search operators with multiplied provider pricing."
25082
- });
25083
- if (!Number.isSafeInteger(input.depth) || input.depth < 1 || input.depth > MAX_SERP_DEPTH$1) throw new ProviderError({
25084
- provider: "dataforseo",
25085
- operation: "serp-snapshot",
25086
- code: "configuration",
25087
- message: `SERP depth must be from 1 to ${MAX_SERP_DEPTH$1}.`
25088
- });
25089
- if (!/^[a-z]{2}$/.test(input.languageCode)) throw new ProviderError({
25090
- provider: "dataforseo",
25091
- operation: "serp-snapshot",
25092
- code: "configuration",
25093
- message: "DataForSEO language code must contain two lowercase letters."
25094
- });
25095
- const locationName = input.locationName?.trim();
25096
- if (input.locationCode !== void 0 === Boolean(locationName)) throw new ProviderError({
25097
- provider: "dataforseo",
25098
- operation: "serp-snapshot",
25099
- code: "configuration",
25100
- message: "DataForSEO SERP snapshots require exactly one location code or location name."
25101
- });
25510
+ const { keyword, locationName } = validateSerpInput(input);
25102
25511
  const request = {
25103
25512
  keyword,
25104
25513
  language_code: input.languageCode,
@@ -25119,9 +25528,56 @@ var DataForSeoClient = class {
25119
25528
  context: input.context,
25120
25529
  ttlMs: this.serpTtlMs,
25121
25530
  refresh: input.refresh,
25122
- rowCount: serpRows
25531
+ rowCount: dataForSeoSerpRows
25123
25532
  });
25124
25533
  }
25534
+ async serpTaskPost(input) {
25535
+ if (input.tasks.length < 1 || input.tasks.length > 100) throw new ProviderError({
25536
+ provider: "dataforseo",
25537
+ operation: "serp-task-post",
25538
+ code: "configuration",
25539
+ message: `A queued SERP post requires 1 to 100 tasks.`
25540
+ });
25541
+ const requests = input.tasks.map((task) => {
25542
+ const validated = validateSerpInput(task);
25543
+ return {
25544
+ keyword: validated.keyword,
25545
+ language_code: task.languageCode,
25546
+ ...task.locationCode !== void 0 ? { location_code: task.locationCode } : { location_name: validated.locationName },
25547
+ device: task.device,
25548
+ depth: task.depth,
25549
+ tag: task.tag,
25550
+ priority: 1,
25551
+ remove_from_url: ["srsltid"]
25552
+ };
25553
+ });
25554
+ const credentials = await this.getCredentials("serp-task-post");
25555
+ const credentialScope = providerCredentialScope("dataforseo", credentials.login);
25556
+ const price = (await this.pricingForCredentials(credentials, credentialScope)).serpTaskPostPrice;
25557
+ return postDataForSeoSerpTasks({
25558
+ requests,
25559
+ context: input.context,
25560
+ price
25561
+ }, {
25562
+ authorization: this.authorization(credentials),
25563
+ baseUrl: this.baseUrl,
25564
+ fetch: this.fetch,
25565
+ maxResponseBytes: this.maxResponseBytes,
25566
+ timeoutMs: this.timeoutMs,
25567
+ now: this.now,
25568
+ database: this.database,
25569
+ spendLimits: this.spendLimits
25570
+ });
25571
+ }
25572
+ async serpTasksReady() {
25573
+ const credentials = await this.getCredentials("serp-tasks-ready");
25574
+ return listReadyDataForSeoSerpTasks(this.serpTaskTransport(credentials));
25575
+ }
25576
+ async serpTaskGet(providerTaskId) {
25577
+ validateDataForSeoSerpTaskId(providerTaskId);
25578
+ const credentials = await this.getCredentials("serp-task-get");
25579
+ return getDataForSeoSerpTask(providerTaskId, this.serpTaskTransport(credentials));
25580
+ }
25125
25581
  };
25126
25582
  //#endregion
25127
25583
  //#region packages/core/src/providers/dataforseo/keyword-mapping.ts
@@ -25253,13 +25709,55 @@ var DataForSeoKeywordMetricsProvider = class {
25253
25709
  status: "available",
25254
25710
  markets: [{
25255
25711
  searchEngines: ["google"],
25256
- location: "any"
25712
+ location: "country-only"
25257
25713
  }]
25258
25714
  }];
25259
25715
  client;
25260
25716
  constructor(options = {}) {
25261
25717
  this.client = options.client ?? new DataForSeoClient(options);
25262
25718
  }
25719
+ async estimateKeywordMetricsCost(input) {
25720
+ if (!Number.isSafeInteger(input.requestedRows) || input.requestedRows < 1 || input.requestedRows > 1e4) throw new ProviderError({
25721
+ provider: "dataforseo",
25722
+ operation: "keyword-metrics-cost",
25723
+ code: "configuration",
25724
+ message: "Keyword metric cost estimates require 1 to 10000 rows."
25725
+ });
25726
+ if (!searchMarketSchema.safeParse(input.market).success) throw new ProviderError({
25727
+ provider: "dataforseo",
25728
+ operation: "keyword-metrics-cost",
25729
+ code: "configuration",
25730
+ message: "Keyword metric cost estimates require a valid market."
25731
+ });
25732
+ if (input.market.location) throw new ProviderError({
25733
+ provider: "dataforseo",
25734
+ operation: "keyword-metrics-cost",
25735
+ code: "configuration",
25736
+ message: "DataForSEO Labs keyword metrics supports country-level markets; omit market.location and use countryCode."
25737
+ });
25738
+ if (!this.client.userData) throw new ProviderError({
25739
+ provider: "dataforseo",
25740
+ operation: "keyword-metrics-cost",
25741
+ code: "configuration",
25742
+ message: "Keyword metric cost estimation is unavailable."
25743
+ });
25744
+ const account = await this.client.userData();
25745
+ const { perRequestMicros, perResultMicros } = account.keywordOverviewPrice;
25746
+ const requestCount = Math.ceil(input.requestedRows / 50);
25747
+ const complete = perRequestMicros !== null && perResultMicros !== null;
25748
+ return {
25749
+ schemaVersion: 1,
25750
+ provider: "dataforseo",
25751
+ capability: "keyword-metrics",
25752
+ currency: "USD",
25753
+ requestedRows: input.requestedRows,
25754
+ requestCount,
25755
+ estimatedMicros: complete ? requestCount * perRequestMicros + input.requestedRows * perResultMicros : null,
25756
+ observedAt: account.observedAt,
25757
+ completeness: complete ? "complete" : "unavailable",
25758
+ basis: complete ? "Current account pricing for normal-priority live keyword overview requests." : "The provider account response did not include complete keyword overview pricing."
25759
+ };
25760
+ }
25263
25761
  async keywordMetrics(input) {
25264
25762
  const market = searchMarketSchema.parse(input.market);
25265
25763
  if (market.searchEngine !== "google") throw new ProviderError({
@@ -25268,6 +25766,12 @@ var DataForSeoKeywordMetricsProvider = class {
25268
25766
  code: "configuration",
25269
25767
  message: "DataForSEO keyword metrics currently supports Google markets."
25270
25768
  });
25769
+ if (market.location) throw new ProviderError({
25770
+ provider: "dataforseo",
25771
+ operation: "keyword-metrics",
25772
+ code: "configuration",
25773
+ message: "DataForSEO Labs keyword metrics supports country-level markets; omit market.location and use countryCode."
25774
+ });
25271
25775
  if (input.keywords.length < 1 || input.keywords.length > MAX_KEYWORDS_PER_REPORT || input.keywords.some((keyword) => {
25272
25776
  const normalized = keyword.trim();
25273
25777
  return normalized.length < 1 || normalized.length > 80 || normalized.split(/\s+/u).length > 10;
@@ -25474,7 +25978,7 @@ function analyzeKeywordTrend(metric) {
25474
25978
  function keywordMetricsProvider(candidate) {
25475
25979
  return "keywordMetrics" in candidate && typeof candidate.keywordMetrics === "function" ? candidate : null;
25476
25980
  }
25477
- async function defaultCandidates$2() {
25981
+ async function defaultCandidates$3() {
25478
25982
  return [{
25479
25983
  adapter: new DataForSeoKeywordMetricsProvider(),
25480
25984
  connected: Boolean(await readDataForSeoCredentials()),
@@ -25483,6 +25987,7 @@ async function defaultCandidates$2() {
25483
25987
  }
25484
25988
  function providerResolutionError(input) {
25485
25989
  if (input.reason === "provider-not-connected") return new SeoError("PROVIDER_UNAVAILABLE", "No connected provider can supply keyword metrics. Run `seo providers dataforseo connect` first.");
25990
+ 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.`);
25486
25991
  if (input.provider) return new SeoError("PROVIDER_UNAVAILABLE", `${input.provider} cannot supply keyword metrics for this market.`);
25487
25992
  return new SeoError("PROVIDER_UNAVAILABLE", "No configured provider can supply keyword metrics for this market.");
25488
25993
  }
@@ -25528,7 +26033,7 @@ async function keywordMetricsReport(input, dependencies = {}) {
25528
26033
  const validated = validateInput$3(input);
25529
26034
  let candidates;
25530
26035
  try {
25531
- candidates = dependencies.candidates ?? await defaultCandidates$2();
26036
+ candidates = dependencies.candidates ?? await defaultCandidates$3();
25532
26037
  } catch (error) {
25533
26038
  return providerReportError(error);
25534
26039
  }
@@ -26359,7 +26864,7 @@ function pageItem(url, rows) {
26359
26864
  recommendation: nextRecommendation
26360
26865
  };
26361
26866
  }
26362
- function compareItems(left, right) {
26867
+ function compareItems$1(left, right) {
26363
26868
  return right.priority.score - left.priority.score || right.impressions - left.impressions || left.position - right.position || compareText$4(left.url, right.url);
26364
26869
  }
26365
26870
  function analyzeSecondPageRows(input) {
@@ -26404,7 +26909,7 @@ function analyzeSecondPageRows(input) {
26404
26909
  if (item.impressions >= minImpressions) return true;
26405
26910
  selection.belowMinimumPages++;
26406
26911
  return false;
26407
- }).sort(compareItems);
26912
+ }).sort(compareItems$1);
26408
26913
  const items = eligibleItems.slice(0, limit);
26409
26914
  selection.eligiblePages = eligibleItems.length;
26410
26915
  selection.returnedPages = items.length;
@@ -27087,7 +27592,7 @@ var DataForSeoKeywordDiscoveryProvider = class {
27087
27592
  status: "available",
27088
27593
  markets: [{
27089
27594
  searchEngines: ["google"],
27090
- location: "any"
27595
+ location: "country-only"
27091
27596
  }]
27092
27597
  }];
27093
27598
  client;
@@ -27102,6 +27607,12 @@ var DataForSeoKeywordDiscoveryProvider = class {
27102
27607
  code: "configuration",
27103
27608
  message: "DataForSEO keyword discovery currently supports Google."
27104
27609
  });
27610
+ if (market.location) throw new ProviderError({
27611
+ provider: "dataforseo",
27612
+ operation: "keyword-discovery",
27613
+ code: "configuration",
27614
+ message: "DataForSEO Labs keyword discovery supports country-level markets; omit market.location and use countryCode."
27615
+ });
27105
27616
  const seeds = [...new Set(input.seeds.map(normalizedKeyword$1))].filter(Boolean).sort(compareCodepoints$3);
27106
27617
  if (seeds.length < 1 || seeds.length > MAX_DISCOVERY_SEEDS || seeds.some((seed) => seed.length > 80 || seed.split(/\s+/u).length > 10)) throw new ProviderError({
27107
27618
  provider: "dataforseo",
@@ -27217,14 +27728,14 @@ function compareCodepoints$1(left, right) {
27217
27728
  function discoveryProvider(candidate) {
27218
27729
  return "discoverKeywords" in candidate && typeof candidate.discoverKeywords === "function" ? candidate : null;
27219
27730
  }
27220
- async function defaultCandidates$1() {
27731
+ async function defaultCandidates$2() {
27221
27732
  return [{
27222
27733
  adapter: new DataForSeoKeywordDiscoveryProvider(),
27223
27734
  connected: Boolean(await readDataForSeoCredentials()),
27224
27735
  priority: 10
27225
27736
  }];
27226
27737
  }
27227
- function providerError$1(error) {
27738
+ function providerError$3(error) {
27228
27739
  if (!(error instanceof ProviderError)) throw error;
27229
27740
  if (error.code === "rate-limit") throw new SeoError("RATE_LIMITED", error.message);
27230
27741
  if (error.code === "configuration") throw new SeoError("INVALID_INPUT", error.message);
@@ -27290,9 +27801,9 @@ async function keywordResearchReport(input, dependencies = {}) {
27290
27801
  });
27291
27802
  let candidates;
27292
27803
  try {
27293
- candidates = dependencies.candidates ?? await defaultCandidates$1();
27804
+ candidates = dependencies.candidates ?? await defaultCandidates$2();
27294
27805
  } catch (error) {
27295
- return providerError$1(error);
27806
+ return providerError$3(error);
27296
27807
  }
27297
27808
  const resolution = resolveProvider({
27298
27809
  capability: "keyword-discovery",
@@ -27300,7 +27811,10 @@ async function keywordResearchReport(input, dependencies = {}) {
27300
27811
  candidates,
27301
27812
  provider: validated.provider
27302
27813
  });
27303
- 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.");
27814
+ if (resolution.status === "unavailable") {
27815
+ 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.";
27816
+ 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);
27817
+ }
27304
27818
  const provider = discoveryProvider(resolution.provider);
27305
27819
  if (!provider) throw new SeoError("PROVIDER_UNAVAILABLE", "The selected provider has no keyword discovery implementation.");
27306
27820
  let evidence;
@@ -27318,7 +27832,7 @@ async function keywordResearchReport(input, dependencies = {}) {
27318
27832
  }
27319
27833
  });
27320
27834
  } catch (error) {
27321
- return providerError$1(error);
27835
+ return providerError$3(error);
27322
27836
  }
27323
27837
  const analysis = evidence.data.map((idea) => ({
27324
27838
  keyword: idea.keyword,
@@ -31955,6 +32469,96 @@ function checkedAt(value, fallback) {
31955
32469
  const timestamp = Date.parse(value);
31956
32470
  return Number.isFinite(timestamp) ? new Date(timestamp).toISOString() : fallback;
31957
32471
  }
32472
+ function mapDataForSeoSerpSnapshot(input, snapshot, endpoint = SERP_ENDPOINT) {
32473
+ const market = searchMarketSchema.parse(input.market);
32474
+ const keyword = normalizedKeyword$1(input.keyword);
32475
+ const location = locationRequest(market, "serp-snapshot");
32476
+ const result = snapshot.response.tasks.flatMap((task) => task.result ?? []).find((item) => normalizedKeyword$1(item.keyword) === keyword);
32477
+ if (!result) throw new ProviderError({
32478
+ provider: "dataforseo",
32479
+ operation: "serp-snapshot",
32480
+ code: "invalid-response",
32481
+ message: "DataForSEO returned no matching SERP result."
32482
+ });
32483
+ const warnings = [...snapshot.warnings];
32484
+ const items = result.items ?? [];
32485
+ const organicItems = items.filter((item) => item.type === "organic");
32486
+ const mappedOrganicResults = organicItems.flatMap((item) => {
32487
+ const mapped = organicResult(item);
32488
+ return mapped ? [mapped] : [];
32489
+ }).sort((left, right) => left.rankAbsolute - right.rankAbsolute || left.rankGroup - right.rankGroup || compareCodepoints$3(left.url, right.url));
32490
+ const organicResults = mappedOrganicResults.slice(0, input.depth);
32491
+ const invalidRows = organicItems.length - mappedOrganicResults.length;
32492
+ const resultCapped = mappedOrganicResults.length > input.depth;
32493
+ const invalidResultCount = result.se_results_count !== null && result.se_results_count !== void 0 && result.se_results_count < organicResults.length;
32494
+ if (invalidRows > 0) warnings.push({
32495
+ code: "invalid-organic-results",
32496
+ field: "organicResults",
32497
+ message: `DataForSEO returned ${invalidRows} organic result${invalidRows === 1 ? "" : "s"} without a valid rank, domain, or URL.`
32498
+ });
32499
+ if (!Number.isFinite(Date.parse(result.datetime))) warnings.push({
32500
+ code: "invalid-serp-observation-time",
32501
+ field: "checkedAt",
32502
+ message: "DataForSEO returned an invalid SERP observation time; the local response time was retained."
32503
+ });
32504
+ const checkUrl = safeUrl(result.check_url);
32505
+ if (result.check_url && !checkUrl) warnings.push({
32506
+ code: "invalid-serp-check-url",
32507
+ field: "checkUrl",
32508
+ message: "DataForSEO returned an invalid SERP check URL."
32509
+ });
32510
+ if (invalidResultCount) warnings.push({
32511
+ code: "invalid-serp-result-count",
32512
+ field: "resultCount",
32513
+ message: "DataForSEO returned an estimated result count below the retained organic result count; the estimate was discarded."
32514
+ });
32515
+ const features = [.../* @__PURE__ */ new Set([...result.item_types ?? [], ...items.map((item) => item.type)])].sort(compareCodepoints$3);
32516
+ return {
32517
+ schemaVersion: 1,
32518
+ provider: "dataforseo",
32519
+ capability: "serp-snapshot",
32520
+ data: {
32521
+ keyword,
32522
+ effectiveKeyword: normalizedKeyword$1(result.spell?.keyword ?? result.keyword),
32523
+ searchEngineDomain: result.se_domain?.trim().toLowerCase() ?? null,
32524
+ checkedAt: checkedAt(result.datetime, snapshot.observedAt),
32525
+ checkUrl,
32526
+ resultCount: invalidResultCount ? null : result.se_results_count ?? null,
32527
+ pagesCount: result.pages_count === void 0 ? null : result.pages_count,
32528
+ features,
32529
+ organicResults
32530
+ },
32531
+ observedAt: snapshot.observedAt,
32532
+ market: {
32533
+ ...market,
32534
+ device: market.device ?? "desktop"
32535
+ },
32536
+ coverage: {
32537
+ requestedRows: input.depth,
32538
+ returnedRows: snapshot.returnedRows,
32539
+ retainedRows: organicResults.length,
32540
+ invalidRows,
32541
+ providerTotalRows: invalidResultCount ? null : result.se_results_count ?? null,
32542
+ completeness: invalidRows > 0 || invalidResultCount ? "partial" : resultCapped ? "capped" : "complete",
32543
+ nextCursor: null
32544
+ },
32545
+ cache: snapshot.cache,
32546
+ cost: snapshot.cost,
32547
+ request: {
32548
+ operation: "serp-snapshot",
32549
+ endpoint,
32550
+ limit: input.depth,
32551
+ filters: {
32552
+ countryCode: market.countryCode,
32553
+ languageCode: market.languageCode,
32554
+ device: market.device ?? "desktop",
32555
+ ...location.locationCode !== void 0 ? { locationCode: location.locationCode } : { locationName: location.locationName }
32556
+ },
32557
+ sort: ["rankAbsolute:ascending", "url:codepoint-ascending"]
32558
+ },
32559
+ warnings
32560
+ };
32561
+ }
31958
32562
  var DataForSeoSerpSnapshotProvider = class {
31959
32563
  provider = "dataforseo";
31960
32564
  capabilitySupport = [{
@@ -31996,7 +32600,7 @@ var DataForSeoSerpSnapshotProvider = class {
31996
32600
  reportRunId: randomUUID()
31997
32601
  };
31998
32602
  const location = locationRequest(market, "serp-snapshot");
31999
- const snapshot = await this.client.serpLive({
32603
+ return mapDataForSeoSerpSnapshot(input, await this.client.serpLive({
32000
32604
  keyword,
32001
32605
  languageCode: market.languageCode.split("-")[0],
32002
32606
  ...location,
@@ -32004,86 +32608,7 @@ var DataForSeoSerpSnapshotProvider = class {
32004
32608
  depth: input.depth,
32005
32609
  refresh: input.refresh,
32006
32610
  context
32007
- });
32008
- const result = snapshot.response.tasks.flatMap((task) => task.result ?? []).find((item) => normalizedKeyword$1(item.keyword) === keyword);
32009
- if (!result) throw new ProviderError({
32010
- provider: "dataforseo",
32011
- operation: "serp-snapshot",
32012
- code: "invalid-response",
32013
- message: "DataForSEO returned no matching SERP result."
32014
- });
32015
- const warnings = [...snapshot.warnings];
32016
- const items = result.items ?? [];
32017
- const organicItems = items.filter((item) => item.type === "organic");
32018
- const mappedOrganicResults = organicItems.flatMap((item) => {
32019
- const mapped = organicResult(item);
32020
- return mapped ? [mapped] : [];
32021
- }).sort((left, right) => left.rankAbsolute - right.rankAbsolute || left.rankGroup - right.rankGroup || compareCodepoints$3(left.url, right.url));
32022
- const organicResults = mappedOrganicResults.slice(0, input.depth);
32023
- const invalidRows = organicItems.length - mappedOrganicResults.length;
32024
- const resultCapped = mappedOrganicResults.length > input.depth;
32025
- if (invalidRows > 0) warnings.push({
32026
- code: "invalid-organic-results",
32027
- field: "organicResults",
32028
- message: `DataForSEO returned ${invalidRows} organic result${invalidRows === 1 ? "" : "s"} without a valid rank, domain, or URL.`
32029
- });
32030
- if (!Number.isFinite(Date.parse(result.datetime))) warnings.push({
32031
- code: "invalid-serp-observation-time",
32032
- field: "checkedAt",
32033
- message: "DataForSEO returned an invalid SERP observation time; the local response time was retained."
32034
- });
32035
- const checkUrl = safeUrl(result.check_url);
32036
- if (result.check_url && !checkUrl) warnings.push({
32037
- code: "invalid-serp-check-url",
32038
- field: "checkUrl",
32039
- message: "DataForSEO returned an invalid SERP check URL."
32040
- });
32041
- const features = [.../* @__PURE__ */ new Set([...result.item_types ?? [], ...items.map((item) => item.type)])].sort(compareCodepoints$3);
32042
- return {
32043
- schemaVersion: 1,
32044
- provider: "dataforseo",
32045
- capability: "serp-snapshot",
32046
- data: {
32047
- keyword,
32048
- effectiveKeyword: normalizedKeyword$1(result.spell?.keyword ?? result.keyword),
32049
- searchEngineDomain: result.se_domain?.trim().toLowerCase() ?? null,
32050
- checkedAt: checkedAt(result.datetime, snapshot.observedAt),
32051
- checkUrl,
32052
- resultCount: result.se_results_count === void 0 ? null : result.se_results_count,
32053
- pagesCount: result.pages_count === void 0 ? null : result.pages_count,
32054
- features,
32055
- organicResults
32056
- },
32057
- observedAt: snapshot.observedAt,
32058
- market: {
32059
- ...market,
32060
- device: market.device ?? "desktop"
32061
- },
32062
- coverage: {
32063
- requestedRows: input.depth,
32064
- returnedRows: snapshot.returnedRows,
32065
- retainedRows: organicResults.length,
32066
- invalidRows,
32067
- providerTotalRows: result.se_results_count ?? null,
32068
- completeness: invalidRows > 0 ? "partial" : resultCapped ? "capped" : "complete",
32069
- nextCursor: null
32070
- },
32071
- cache: snapshot.cache,
32072
- cost: snapshot.cost,
32073
- request: {
32074
- operation: "serp-snapshot",
32075
- endpoint: SERP_ENDPOINT,
32076
- limit: input.depth,
32077
- filters: {
32078
- countryCode: market.countryCode,
32079
- languageCode: market.languageCode,
32080
- device: market.device ?? "desktop",
32081
- ...location.locationCode !== void 0 ? { locationCode: location.locationCode } : { locationName: location.locationName }
32082
- },
32083
- sort: ["rankAbsolute:ascending", "url:codepoint-ascending"]
32084
- },
32085
- warnings
32086
- };
32611
+ }));
32087
32612
  }
32088
32613
  };
32089
32614
  //#endregion
@@ -32095,14 +32620,14 @@ function compareCodepoints(left, right) {
32095
32620
  function snapshotProvider(candidate) {
32096
32621
  return "serpSnapshot" in candidate && typeof candidate.serpSnapshot === "function" ? candidate : null;
32097
32622
  }
32098
- async function defaultCandidates() {
32623
+ async function defaultCandidates$1() {
32099
32624
  return [{
32100
32625
  adapter: new DataForSeoSerpSnapshotProvider(),
32101
32626
  connected: Boolean(await readDataForSeoCredentials()),
32102
32627
  priority: 10
32103
32628
  }];
32104
32629
  }
32105
- function providerError(error) {
32630
+ function providerError$2(error) {
32106
32631
  if (!(error instanceof ProviderError)) throw error;
32107
32632
  if (error.code === "rate-limit") throw new SeoError("RATE_LIMITED", error.message);
32108
32633
  if (error.code === "configuration") throw new SeoError("INVALID_INPUT", error.message);
@@ -32162,9 +32687,9 @@ async function serpResultsReport(input, dependencies = {}) {
32162
32687
  });
32163
32688
  let candidates;
32164
32689
  try {
32165
- candidates = dependencies.candidates ?? await defaultCandidates();
32690
+ candidates = dependencies.candidates ?? await defaultCandidates$1();
32166
32691
  } catch (error) {
32167
- return providerError(error);
32692
+ return providerError$2(error);
32168
32693
  }
32169
32694
  const resolution = resolveProvider({
32170
32695
  capability: "serp-snapshot",
@@ -32189,7 +32714,7 @@ async function serpResultsReport(input, dependencies = {}) {
32189
32714
  }
32190
32715
  });
32191
32716
  } catch (error) {
32192
- return providerError(error);
32717
+ return providerError$2(error);
32193
32718
  }
32194
32719
  const domains = domainSummaries(evidence.data);
32195
32720
  const dataStatus = reportStatus$1(evidence);
@@ -32439,6 +32964,1228 @@ async function pseoOpportunitiesReport(input, dependencies = {}) {
32439
32964
  };
32440
32965
  }
32441
32966
  //#endregion
32967
+ //#region packages/core/src/keyword-sets/limits.ts
32968
+ const KEYWORD_SET_LIMITS = {
32969
+ setsPerProject: 25,
32970
+ totalSets: 100,
32971
+ keywordsPerSet: 1e4,
32972
+ totalKeywords: 1e5,
32973
+ mutationKeywords: 1e3,
32974
+ tagsPerKeyword: 20,
32975
+ outputRows: 1e3,
32976
+ logicalBytes: 64 * 1024 * 1024
32977
+ };
32978
+ const KEYWORD_SET_FIELD_LIMITS = {
32979
+ name: 80,
32980
+ projectId: 80,
32981
+ sourceReport: 100,
32982
+ keyword: 80,
32983
+ keywordWords: 10,
32984
+ tag: 40,
32985
+ url: 2048,
32986
+ metricJsonBytes: 16 * 1024
32987
+ };
32988
+ //#endregion
32989
+ //#region packages/core/src/keyword-sets/validation.ts
32990
+ const unavailableValueSchema = z.strictObject({
32991
+ state: z.enum([
32992
+ "missing",
32993
+ "unavailable",
32994
+ "invalid"
32995
+ ]),
32996
+ value: z.null(),
32997
+ reason: z.string().min(1).max(500)
32998
+ });
32999
+ const providerValueNumberSchema = z.discriminatedUnion("state", [z.strictObject({
33000
+ state: z.literal("observed"),
33001
+ value: z.number().finite()
33002
+ }), unavailableValueSchema]);
33003
+ const providerValueStringSchema = z.discriminatedUnion("state", [z.strictObject({
33004
+ state: z.literal("observed"),
33005
+ value: z.string().max(500)
33006
+ }), unavailableValueSchema]);
33007
+ const monthlySearchSchema = z.strictObject({
33008
+ year: z.number().int().min(2e3).max(2200),
33009
+ month: z.number().int().min(1).max(12),
33010
+ searchVolume: z.number().finite().nonnegative()
33011
+ });
33012
+ const providerValueMonthlySearchesSchema = z.discriminatedUnion("state", [z.strictObject({
33013
+ state: z.literal("observed"),
33014
+ value: z.array(monthlySearchSchema).max(24)
33015
+ }), unavailableValueSchema]);
33016
+ const keywordMetricSchema = z.strictObject({
33017
+ keyword: z.string().min(1).max(KEYWORD_SET_FIELD_LIMITS.keyword),
33018
+ monthlySearchVolume: providerValueNumberSchema,
33019
+ monthlySearches: providerValueMonthlySearchesSchema,
33020
+ searchVolumeUpdatedAt: providerValueStringSchema,
33021
+ cpcUsd: providerValueNumberSchema,
33022
+ paidCompetition: providerValueNumberSchema,
33023
+ keywordDifficulty: providerValueNumberSchema,
33024
+ intent: providerValueStringSchema,
33025
+ resultCount: providerValueNumberSchema
33026
+ });
33027
+ const savedMetricSchema = z.strictObject({
33028
+ schemaVersion: z.literal(1),
33029
+ provider: providerIdSchema,
33030
+ observedAt: z.iso.datetime(),
33031
+ metric: keywordMetricSchema
33032
+ });
33033
+ function invalid$1(message) {
33034
+ throw new SeoError("INVALID_INPUT", message);
33035
+ }
33036
+ function boundedText(value, label, max) {
33037
+ const normalized = value.trim();
33038
+ if (!normalized || normalized.length > max) invalid$1(`${label} must contain 1 to ${max} characters.`);
33039
+ return normalized;
33040
+ }
33041
+ function normalizeSavedKeyword(value) {
33042
+ const normalized = value.trim().replace(/\s+/gu, " ").toLowerCase();
33043
+ if (!normalized || normalized.length > KEYWORD_SET_FIELD_LIMITS.keyword || normalized.split(" ").length > KEYWORD_SET_FIELD_LIMITS.keywordWords) invalid$1(`Keywords must contain 1 to ${KEYWORD_SET_FIELD_LIMITS.keyword} characters and at most ${KEYWORD_SET_FIELD_LIMITS.keywordWords} words.`);
33044
+ return normalized;
33045
+ }
33046
+ function displayKeyword(value) {
33047
+ normalizeSavedKeyword(value);
33048
+ return value.trim().replace(/\s+/gu, " ");
33049
+ }
33050
+ function normalizedTags(values = []) {
33051
+ if (values.length > KEYWORD_SET_LIMITS.tagsPerKeyword) invalid$1(`A keyword can have at most ${KEYWORD_SET_LIMITS.tagsPerKeyword} tags.`);
33052
+ const tags = [...new Set(values.map((value) => boundedText(value, "Tag", KEYWORD_SET_FIELD_LIMITS.tag).toLowerCase()))].sort();
33053
+ if (tags.length > KEYWORD_SET_LIMITS.tagsPerKeyword) invalid$1(`A keyword can have at most ${KEYWORD_SET_LIMITS.tagsPerKeyword} tags.`);
33054
+ return tags;
33055
+ }
33056
+ function pageMapping(value) {
33057
+ if (value === null || value === void 0) return value;
33058
+ if (value.kind !== "target" && value.kind !== "proposed") invalid$1("Page mapping kind must be target or proposed.");
33059
+ const raw = boundedText(value.url, "Page URL", KEYWORD_SET_FIELD_LIMITS.url);
33060
+ let url;
33061
+ try {
33062
+ url = new URL(raw);
33063
+ } catch {
33064
+ invalid$1("Page mapping must use an absolute HTTP or HTTPS URL.");
33065
+ }
33066
+ if (url.protocol !== "http:" && url.protocol !== "https:") invalid$1("Page mapping must use an absolute HTTP or HTTPS URL.");
33067
+ return {
33068
+ kind: value.kind,
33069
+ url: url.toString()
33070
+ };
33071
+ }
33072
+ function savedMetric(value, normalizedKeyword) {
33073
+ if (value === null || value === void 0) return {
33074
+ value,
33075
+ json: null
33076
+ };
33077
+ const parsed = savedMetricSchema.safeParse(value);
33078
+ if (!parsed.success) invalid$1("Use a valid normalized keyword metric snapshot.");
33079
+ if (normalizeSavedKeyword(parsed.data.metric.keyword) !== normalizedKeyword) invalid$1("A saved metric must describe the same keyword as the set item.");
33080
+ const json = JSON.stringify(parsed.data);
33081
+ if (Buffer.byteLength(json) > KEYWORD_SET_FIELD_LIMITS.metricJsonBytes) invalid$1(`A saved keyword metric cannot exceed ${KEYWORD_SET_FIELD_LIMITS.metricJsonBytes} bytes.`);
33082
+ return {
33083
+ value: parsed.data,
33084
+ json
33085
+ };
33086
+ }
33087
+ function validateMutationItems(items) {
33088
+ if (items.length < 1 || items.length > KEYWORD_SET_LIMITS.mutationKeywords) invalid$1(`Add 1 to ${KEYWORD_SET_LIMITS.mutationKeywords} keywords per operation.`);
33089
+ const normalized = /* @__PURE__ */ new Map();
33090
+ for (const item of items) {
33091
+ const key = normalizeSavedKeyword(item.keyword);
33092
+ const existing = normalized.get(key);
33093
+ const tags = normalizedTags([...existing?.tags ?? [], ...item.tags ?? []]);
33094
+ const page = pageMapping(item.page !== void 0 ? item.page : existing?.page);
33095
+ const metric = savedMetric(item.latestMetric !== void 0 ? item.latestMetric : existing?.metric, key);
33096
+ normalized.set(key, {
33097
+ keyword: existing?.keyword ?? displayKeyword(item.keyword),
33098
+ tags,
33099
+ page,
33100
+ metric: metric.value,
33101
+ metricJson: metric.json
33102
+ });
33103
+ }
33104
+ return normalized;
33105
+ }
33106
+ function parseStoredJson(value, schema, label) {
33107
+ let decoded;
33108
+ try {
33109
+ decoded = JSON.parse(value);
33110
+ } catch {
33111
+ throw new SeoError("INTERNAL_ERROR", `${label} contains invalid JSON.`);
33112
+ }
33113
+ const parsed = schema.safeParse(decoded);
33114
+ if (!parsed.success) throw new SeoError("INTERNAL_ERROR", `${label} has an invalid shape.`);
33115
+ return parsed.data;
33116
+ }
33117
+ //#endregion
33118
+ //#region packages/core/src/keyword-sets/rows.ts
33119
+ const SET_SELECT = `
33120
+ SELECT sets.*,
33121
+ (SELECT COUNT(*) FROM keyword_set_items items WHERE items.set_id = sets.id) AS keyword_count,
33122
+ (SELECT COUNT(DISTINCT tags.tag) FROM keyword_set_tags tags WHERE tags.set_id = sets.id) AS tag_count
33123
+ FROM keyword_sets sets`;
33124
+ function storeDatabase(options) {
33125
+ return options.database ?? getDb();
33126
+ }
33127
+ function rowToSet(row) {
33128
+ const provider = row.provider ? providerIdSchema.safeParse(row.provider) : void 0;
33129
+ if (provider && !provider.success) throw new SeoError("INTERNAL_ERROR", "Saved keyword set provider is invalid.");
33130
+ return {
33131
+ schemaVersion: 1,
33132
+ id: row.id,
33133
+ projectId: row.project_id,
33134
+ name: row.name,
33135
+ market: parseStoredJson(row.market_json, searchMarketSchema, "Saved search market"),
33136
+ provider: provider?.data ?? null,
33137
+ sourceReport: row.source_report,
33138
+ keywordCount: row.keyword_count,
33139
+ tagCount: row.tag_count,
33140
+ createdAt: new Date(row.created_at).toISOString(),
33141
+ updatedAt: new Date(row.updated_at).toISOString(),
33142
+ lastRefreshedAt: row.last_refreshed_at === null ? null : new Date(row.last_refreshed_at).toISOString()
33143
+ };
33144
+ }
33145
+ function findSetRow(db, projectId, idOrName) {
33146
+ return db.prepare(`${SET_SELECT}
33147
+ WHERE sets.project_id = ?
33148
+ AND (sets.id = ? OR sets.name = ? COLLATE NOCASE)
33149
+ ORDER BY CASE WHEN sets.id = ? THEN 0 ELSE 1 END
33150
+ LIMIT 1`).get(projectId, idOrName, idOrName, idOrName);
33151
+ }
33152
+ function requireSetRow(db, projectId, idOrName) {
33153
+ const row = findSetRow(db, projectId, idOrName);
33154
+ if (!row) invalid$1(`Keyword set not found: ${idOrName}`);
33155
+ return row;
33156
+ }
33157
+ function rowCount(db, sql, ...params) {
33158
+ return db.prepare(sql).get(...params).count;
33159
+ }
33160
+ function keywordSetLogicalBytes(db = getDb()) {
33161
+ return db.prepare(`SELECT
33162
+ COALESCE((SELECT SUM(length(id) + length(project_id) + length(name) + length(market_json) + COALESCE(length(provider), 0) + COALESCE(length(source_report), 0)) FROM keyword_sets), 0) +
33163
+ COALESCE((SELECT SUM(length(set_id) + length(normalized_keyword) + length(display_keyword) + COALESCE(length(metric_json), 0) + COALESCE(length(metric_provider), 0) + COALESCE(length(metric_observed_at), 0) + COALESCE(length(page_kind), 0) + COALESCE(length(page_url), 0)) FROM keyword_set_items), 0) +
33164
+ COALESCE((SELECT SUM(length(set_id) + length(normalized_keyword) + length(tag)) FROM keyword_set_tags), 0)
33165
+ AS bytes`).get().bytes;
33166
+ }
33167
+ function enforceKeywordSetLogicalBytes(db, maxLogicalBytes = KEYWORD_SET_LIMITS.logicalBytes) {
33168
+ if (keywordSetLogicalBytes(db) > maxLogicalBytes) invalid$1(`Saved keyword data cannot exceed ${maxLogicalBytes} logical bytes. Remove an unused set before adding more.`);
33169
+ }
33170
+ //#endregion
33171
+ //#region packages/core/src/keyword-sets/store.ts
33172
+ function createKeywordSet(input, options = {}) {
33173
+ const projectId = boundedText(input.projectId, "Project id", KEYWORD_SET_FIELD_LIMITS.projectId);
33174
+ const name = boundedText(input.name, "Keyword set name", KEYWORD_SET_FIELD_LIMITS.name);
33175
+ const market = searchMarketSchema.safeParse(input.market);
33176
+ if (!market.success) invalid$1("Use a valid search market.");
33177
+ const provider = input.provider === void 0 ? void 0 : providerIdSchema.safeParse(input.provider);
33178
+ if (provider && !provider.success) invalid$1("Use a supported keyword provider.");
33179
+ const sourceReport = input.sourceReport ? boundedText(input.sourceReport, "Source report", KEYWORD_SET_FIELD_LIMITS.sourceReport) : null;
33180
+ const db = storeDatabase(options);
33181
+ if (rowCount(db, "SELECT COUNT(*) AS count FROM keyword_sets") >= KEYWORD_SET_LIMITS.totalSets) invalid$1(`At most ${KEYWORD_SET_LIMITS.totalSets} keyword sets can be saved.`);
33182
+ if (rowCount(db, "SELECT COUNT(*) AS count FROM keyword_sets WHERE project_id = ?", projectId) >= KEYWORD_SET_LIMITS.setsPerProject) invalid$1(`A project can have at most ${KEYWORD_SET_LIMITS.setsPerProject} keyword sets.`);
33183
+ const now = (options.now ?? (() => /* @__PURE__ */ new Date()))().getTime();
33184
+ const id = (options.id ?? randomUUID)();
33185
+ try {
33186
+ db.transaction(() => {
33187
+ db.prepare(`INSERT INTO keyword_sets
33188
+ (id, project_id, name, market_json, provider, source_report, created_at, updated_at)
33189
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?)`).run(id, projectId, name, JSON.stringify(market.data), provider?.data ?? null, sourceReport, now, now);
33190
+ enforceKeywordSetLogicalBytes(db, options.maxLogicalBytes);
33191
+ }).immediate();
33192
+ } catch (error) {
33193
+ if (/unique constraint/i.test(String(error))) invalid$1(`A keyword set named ${name} already exists for this project.`);
33194
+ throw error;
33195
+ }
33196
+ return rowToSet(requireSetRow(db, projectId, id));
33197
+ }
33198
+ function listKeywordSets(input, options = {}) {
33199
+ const projectId = boundedText(input.projectId, "Project id", KEYWORD_SET_FIELD_LIMITS.projectId);
33200
+ const limit = input.limit ?? 100;
33201
+ const offset = input.offset ?? 0;
33202
+ if (!Number.isSafeInteger(limit) || limit < 1 || limit > 100) invalid$1("Keyword set list limit must be from 1 to 100.");
33203
+ if (!Number.isSafeInteger(offset) || offset < 0) invalid$1("Keyword set list offset must be zero or greater.");
33204
+ return storeDatabase(options).prepare(`${SET_SELECT}
33205
+ WHERE sets.project_id = ?
33206
+ ORDER BY sets.updated_at DESC, sets.id
33207
+ LIMIT ? OFFSET ?`).all(projectId, limit, offset).map(rowToSet);
33208
+ }
33209
+ function getKeywordSet(input, options = {}) {
33210
+ const projectId = boundedText(input.projectId, "Project id", KEYWORD_SET_FIELD_LIMITS.projectId);
33211
+ const idOrName = boundedText(input.idOrName, "Keyword set id or name", KEYWORD_SET_FIELD_LIMITS.name);
33212
+ const tag = input.tag ? boundedText(input.tag, "Tag", KEYWORD_SET_FIELD_LIMITS.tag).toLowerCase() : null;
33213
+ const limit = input.limit ?? 100;
33214
+ const offset = input.offset ?? 0;
33215
+ if (!Number.isSafeInteger(limit) || limit < 1 || limit > KEYWORD_SET_LIMITS.outputRows) invalid$1(`Keyword set output limit must be from 1 to ${KEYWORD_SET_LIMITS.outputRows}.`);
33216
+ if (!Number.isSafeInteger(offset) || offset < 0) invalid$1("Keyword set output offset must be zero or greater.");
33217
+ const db = storeDatabase(options);
33218
+ const setRow = requireSetRow(db, projectId, idOrName);
33219
+ const filterSql = tag ? `AND EXISTS (
33220
+ SELECT 1 FROM keyword_set_tags filter_tags
33221
+ WHERE filter_tags.set_id = items.set_id
33222
+ AND filter_tags.normalized_keyword = items.normalized_keyword
33223
+ AND filter_tags.tag = ?
33224
+ )` : "";
33225
+ const params = tag ? [setRow.id, tag] : [setRow.id];
33226
+ const total = rowCount(db, `SELECT COUNT(*) AS count FROM keyword_set_items items
33227
+ WHERE items.set_id = ? ${filterSql}`, ...params);
33228
+ const rows = db.prepare(`SELECT items.* FROM keyword_set_items items
33229
+ WHERE items.set_id = ? ${filterSql}
33230
+ ORDER BY items.normalized_keyword
33231
+ LIMIT ? OFFSET ?`).all(...params, limit, offset);
33232
+ const tagsByKeyword = loadTags(db, setRow.id, rows);
33233
+ const items = rows.map((row) => itemFromRow(row, tagsByKeyword.get(row.normalized_keyword) ?? []));
33234
+ return {
33235
+ schemaVersion: 1,
33236
+ set: rowToSet(setRow),
33237
+ items,
33238
+ pagination: {
33239
+ offset,
33240
+ limit,
33241
+ returned: items.length,
33242
+ total,
33243
+ nextOffset: offset + items.length < total ? offset + items.length : null
33244
+ },
33245
+ filter: { tag }
33246
+ };
33247
+ }
33248
+ function loadTags(db, setId, rows) {
33249
+ if (rows.length === 0) return /* @__PURE__ */ new Map();
33250
+ const tagRows = db.prepare(`SELECT normalized_keyword, tag FROM keyword_set_tags
33251
+ WHERE set_id = ? AND normalized_keyword IN (${rows.map(() => "?").join(", ")})
33252
+ ORDER BY normalized_keyword, tag`).all(setId, ...rows.map((row) => row.normalized_keyword));
33253
+ const tagsByKeyword = /* @__PURE__ */ new Map();
33254
+ for (const row of tagRows) {
33255
+ const tags = tagsByKeyword.get(row.normalized_keyword) ?? [];
33256
+ tags.push(row.tag);
33257
+ tagsByKeyword.set(row.normalized_keyword, tags);
33258
+ }
33259
+ return tagsByKeyword;
33260
+ }
33261
+ function itemFromRow(row, tags) {
33262
+ const latestMetric = row.metric_json ? parseStoredJson(row.metric_json, savedMetricSchema, "Saved keyword metric") : null;
33263
+ 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.");
33264
+ const page = row.page_kind && row.page_url ? {
33265
+ kind: row.page_kind,
33266
+ url: row.page_url
33267
+ } : null;
33268
+ return {
33269
+ keyword: row.display_keyword,
33270
+ normalizedKeyword: row.normalized_keyword,
33271
+ tags,
33272
+ page,
33273
+ latestMetric,
33274
+ createdAt: new Date(row.created_at).toISOString(),
33275
+ updatedAt: new Date(row.updated_at).toISOString()
33276
+ };
33277
+ }
33278
+ function deleteKeywordSet(input, options = {}) {
33279
+ const projectId = boundedText(input.projectId, "Project id", KEYWORD_SET_FIELD_LIMITS.projectId);
33280
+ const idOrName = boundedText(input.idOrName, "Keyword set id or name", KEYWORD_SET_FIELD_LIMITS.name);
33281
+ const db = storeDatabase(options);
33282
+ const row = findSetRow(db, projectId, idOrName);
33283
+ if (!row) return false;
33284
+ return db.prepare("DELETE FROM keyword_sets WHERE id = ?").run(row.id).changes > 0;
33285
+ }
33286
+ //#endregion
33287
+ //#region packages/core/src/rank-tracking/dataforseo-collector.ts
33288
+ const QUEUED_RESULT_ENDPOINT = "v3/serp/google/organic/task_get/advanced/{taskId}";
33289
+ var DataForSeoRankTrackingCollector = class {
33290
+ provider = "dataforseo";
33291
+ client;
33292
+ liveProvider;
33293
+ constructor(options = {}) {
33294
+ this.client = new DataForSeoClient(options);
33295
+ this.liveProvider = new DataForSeoSerpSnapshotProvider({ client: this.client });
33296
+ }
33297
+ live(input) {
33298
+ return this.liveProvider.serpSnapshot(input);
33299
+ }
33300
+ async post(input) {
33301
+ const snapshot = await this.client.serpTaskPost({
33302
+ tasks: input.tasks.map(({ taskKey, request }) => {
33303
+ const market = request.market;
33304
+ return {
33305
+ tag: taskKey,
33306
+ keyword: normalizedKeyword$1(request.keyword),
33307
+ languageCode: market.languageCode.split("-")[0],
33308
+ ...locationRequest(market, "serp-snapshot"),
33309
+ device: market.device ?? "desktop",
33310
+ depth: request.depth
33311
+ };
33312
+ }),
33313
+ context: input.context
33314
+ });
33315
+ return {
33316
+ provider: this.provider,
33317
+ receipts: snapshot.taskReceipts.map((receipt) => ({
33318
+ taskKey: receipt.tag,
33319
+ providerTaskId: receipt.providerTaskId
33320
+ })),
33321
+ estimatedCostMicros: snapshot.estimatedCostMicros,
33322
+ actualCostMicros: snapshot.actualCostMicros,
33323
+ warnings: snapshot.warnings
33324
+ };
33325
+ }
33326
+ ready() {
33327
+ return this.client.serpTasksReady().then((tasks) => tasks.map((task) => ({
33328
+ providerTaskId: task.providerTaskId,
33329
+ taskKey: task.tag
33330
+ })));
33331
+ }
33332
+ async collect(input) {
33333
+ const snapshot = await this.client.serpTaskGet(input.providerTaskId);
33334
+ return mapDataForSeoSerpSnapshot(input.request, snapshot, QUEUED_RESULT_ENDPOINT);
33335
+ }
33336
+ };
33337
+ //#endregion
33338
+ //#region packages/core/src/rank-tracking/limits.ts
33339
+ const RANK_TRACKING_LIMITS = {
33340
+ configurationsPerProject: 25,
33341
+ totalConfigurations: 100,
33342
+ liveKeywordsPerRun: 50,
33343
+ queuedKeywordsPerRun: 1e3,
33344
+ devicesPerRun: 2,
33345
+ tasksPerProviderPost: 100,
33346
+ retainedRunsPerConfiguration: 90,
33347
+ outputRows: 250,
33348
+ logicalBytes: 256 * 1024 * 1024
33349
+ };
33350
+ //#endregion
33351
+ //#region packages/core/src/rank-tracking/configuration-store.ts
33352
+ function invalid(message) {
33353
+ throw new SeoError("INVALID_INPUT", message);
33354
+ }
33355
+ function database(options) {
33356
+ return options.database ?? getDb();
33357
+ }
33358
+ function parseJson(value, label) {
33359
+ try {
33360
+ return JSON.parse(value);
33361
+ } catch {
33362
+ throw new SeoError("INTERNAL_ERROR", `${label} is corrupt.`);
33363
+ }
33364
+ }
33365
+ function normalizeDomain(value) {
33366
+ const raw = value.trim().toLowerCase();
33367
+ if (!raw || raw.length > 253) invalid("Use a valid target domain.");
33368
+ let hostname;
33369
+ try {
33370
+ hostname = new URL(raw.includes("://") ? raw : `https://${raw}`).hostname;
33371
+ } catch {
33372
+ invalid("Use a valid target domain.");
33373
+ }
33374
+ if (!hostname || hostname.length > 253 || hostname.includes("..") || !/^[a-z0-9.-]+$/u.test(hostname)) invalid("Use a valid target domain.");
33375
+ return hostname.replace(/\.$/u, "");
33376
+ }
33377
+ function targetMatchesDomain(targetDomain, candidate) {
33378
+ const target = normalizeDomain(targetDomain);
33379
+ const host = normalizeDomain(candidate);
33380
+ return host === target || host.endsWith(`.${target}`);
33381
+ }
33382
+ function configFromRow(row) {
33383
+ const market = searchMarketSchema.safeParse(parseJson(row.market_json, "Saved rank market"));
33384
+ const devices = parseJson(row.devices_json, "Saved rank devices");
33385
+ const provider = providerIdSchema.safeParse(row.provider);
33386
+ if (!market.success || market.data.device !== void 0 || !Array.isArray(devices) || devices.length < 1 || devices.length > 2 || devices.some((item) => item !== "desktop" && item !== "mobile") || !provider.success) throw new SeoError("INTERNAL_ERROR", "Saved rank tracking configuration is invalid.");
33387
+ return {
33388
+ schemaVersion: 1,
33389
+ id: row.id,
33390
+ projectId: row.project_id,
33391
+ keywordSetId: row.keyword_set_id,
33392
+ targetDomain: row.target_domain,
33393
+ tag: row.tag,
33394
+ market: market.data,
33395
+ devices,
33396
+ provider: provider.data,
33397
+ collectionMethod: row.collection_method,
33398
+ cadence: row.cadence,
33399
+ depth: row.depth,
33400
+ keywordLimit: row.keyword_limit,
33401
+ nextRunAt: row.next_run_at === null ? null : new Date(row.next_run_at).toISOString(),
33402
+ createdAt: new Date(row.created_at).toISOString(),
33403
+ updatedAt: new Date(row.updated_at).toISOString()
33404
+ };
33405
+ }
33406
+ function runFromRow(row) {
33407
+ return {
33408
+ schemaVersion: 1,
33409
+ id: row.id,
33410
+ configId: row.config_id,
33411
+ state: row.state,
33412
+ collectionMethod: row.collection_method,
33413
+ scheduledFor: new Date(row.scheduled_for).toISOString(),
33414
+ startedAt: new Date(row.started_at).toISOString(),
33415
+ completedAt: row.completed_at === null ? null : new Date(row.completed_at).toISOString(),
33416
+ keywordCount: row.keyword_count,
33417
+ taskCount: row.task_count,
33418
+ snapshotCount: row.snapshot_count,
33419
+ pendingCount: row.pending_count,
33420
+ failedCount: row.failed_count,
33421
+ estimatedCostMicros: row.estimated_cost_micros,
33422
+ actualCostMicros: row.actual_cost_micros,
33423
+ errorSummary: row.error_summary
33424
+ };
33425
+ }
33426
+ function taskFromRow(row) {
33427
+ return {
33428
+ id: row.id,
33429
+ runId: row.run_id,
33430
+ normalizedKeyword: row.normalized_keyword,
33431
+ displayKeyword: row.display_keyword,
33432
+ device: row.device,
33433
+ state: row.state,
33434
+ providerTaskId: row.provider_task_id,
33435
+ attemptCount: row.attempt_count,
33436
+ errorCode: row.error_code,
33437
+ errorMessage: row.error_message
33438
+ };
33439
+ }
33440
+ function observationFromRow(row) {
33441
+ const provider = providerIdSchema.safeParse(row.provider);
33442
+ if (!provider.success) throw new SeoError("INTERNAL_ERROR", "Saved rank provider is invalid.");
33443
+ return {
33444
+ taskId: row.task_id,
33445
+ runId: row.run_id,
33446
+ keyword: row.display_keyword,
33447
+ normalizedKeyword: row.normalized_keyword,
33448
+ device: row.device,
33449
+ state: row.observation_state,
33450
+ organicPosition: row.organic_position,
33451
+ absolutePosition: row.absolute_position,
33452
+ rankingUrl: row.ranking_url,
33453
+ observedFeatures: parseJson(row.observed_features_json, "Saved rank features"),
33454
+ checkedAt: row.checked_at,
33455
+ provider: provider.data,
33456
+ providerTaskId: row.provider_task_id,
33457
+ requestedDepth: row.requested_depth,
33458
+ returnedRows: row.returned_rows,
33459
+ retainedRows: row.retained_rows,
33460
+ invalidRows: row.invalid_rows,
33461
+ completeness: row.completeness,
33462
+ estimatedCostMicros: row.estimated_cost_micros,
33463
+ actualCostMicros: row.actual_cost_micros,
33464
+ warnings: parseJson(row.warnings_json, "Saved rank warnings")
33465
+ };
33466
+ }
33467
+ function stableConfigKey(value) {
33468
+ return createHash("sha256").update(JSON.stringify(value)).digest("hex");
33469
+ }
33470
+ function nextRunAt(cadence, scheduledFor) {
33471
+ if (cadence === "manual") return null;
33472
+ const next = new Date(scheduledFor);
33473
+ if (cadence === "daily") next.setUTCDate(next.getUTCDate() + 1);
33474
+ if (cadence === "weekly") next.setUTCDate(next.getUTCDate() + 7);
33475
+ if (cadence === "monthly") {
33476
+ const desiredDay = next.getUTCDate();
33477
+ next.setUTCDate(1);
33478
+ next.setUTCMonth(next.getUTCMonth() + 1);
33479
+ const lastDay = new Date(Date.UTC(next.getUTCFullYear(), next.getUTCMonth() + 1, 0)).getUTCDate();
33480
+ next.setUTCDate(Math.min(desiredDay, lastDay));
33481
+ }
33482
+ return next.getTime();
33483
+ }
33484
+ function getOrCreateRankTrackingConfiguration(input, options = {}) {
33485
+ const projectId = input.projectId.trim();
33486
+ const keywordSetId = input.keywordSetId.trim();
33487
+ const tag = input.tag?.trim().toLowerCase() || null;
33488
+ if (!projectId || projectId.length > 80) invalid("Use a valid project id.");
33489
+ if (!keywordSetId || keywordSetId.length > 80) invalid("Use a valid keyword set id.");
33490
+ if (tag && tag.length > 40) invalid("Rank tracking tag is too long.");
33491
+ const market = searchMarketSchema.safeParse(input.market);
33492
+ if (!market.success) invalid("Use a valid rank tracking market.");
33493
+ const { device: _ignoredDevice, ...marketWithoutDevice } = market.data;
33494
+ const devices = [...new Set(input.devices)].sort();
33495
+ if (devices.length < 1 || devices.length > 2 || devices.some((item) => item !== "desktop" && item !== "mobile")) invalid("Track desktop, mobile, or both devices.");
33496
+ const provider = providerIdSchema.safeParse(input.provider);
33497
+ if (!provider.success) invalid("Use a supported rank tracking provider.");
33498
+ if (!["live", "queued"].includes(input.collectionMethod)) invalid("Use live or queued rank collection.");
33499
+ if (![
33500
+ "manual",
33501
+ "daily",
33502
+ "weekly",
33503
+ "monthly"
33504
+ ].includes(input.cadence)) invalid("Use a supported rank tracking cadence.");
33505
+ if (!Number.isSafeInteger(input.depth) || input.depth < 1 || input.depth > 100) invalid("Rank tracking depth must be from 1 to 100.");
33506
+ const maxKeywords = input.collectionMethod === "live" ? RANK_TRACKING_LIMITS.liveKeywordsPerRun : RANK_TRACKING_LIMITS.queuedKeywordsPerRun;
33507
+ if (!Number.isSafeInteger(input.keywordLimit) || input.keywordLimit < 1 || input.keywordLimit > maxKeywords) invalid(`${input.collectionMethod === "live" ? "Live" : "Queued"} rank tracking can collect 1 to ${maxKeywords} keywords per run.`);
33508
+ const targetDomain = normalizeDomain(input.targetDomain);
33509
+ const db = database(options);
33510
+ const set = getKeywordSet({
33511
+ projectId,
33512
+ idOrName: keywordSetId,
33513
+ limit: 1
33514
+ }, { database: db }).set;
33515
+ if (set.market.searchEngine !== marketWithoutDevice.searchEngine) invalid("The rank tracking search engine must match the keyword set.");
33516
+ const configKey = stableConfigKey({
33517
+ projectId,
33518
+ keywordSetId: set.id,
33519
+ targetDomain,
33520
+ tag,
33521
+ market: marketWithoutDevice,
33522
+ devices,
33523
+ provider: provider.data,
33524
+ collectionMethod: input.collectionMethod,
33525
+ cadence: input.cadence,
33526
+ depth: input.depth,
33527
+ keywordLimit: input.keywordLimit
33528
+ });
33529
+ const existing = db.prepare("SELECT * FROM rank_tracking_configs WHERE config_key = ?").get(configKey);
33530
+ if (existing) return configFromRow(existing);
33531
+ const total = db.prepare("SELECT COUNT(*) AS count FROM rank_tracking_configs").get().count;
33532
+ const projectTotal = db.prepare("SELECT COUNT(*) AS count FROM rank_tracking_configs WHERE project_id = ?").get(projectId).count;
33533
+ if (total >= RANK_TRACKING_LIMITS.totalConfigurations) invalid("Remove an unused rank tracking configuration before adding more.");
33534
+ if (projectTotal >= RANK_TRACKING_LIMITS.configurationsPerProject) invalid("This project already has the maximum rank tracking configurations.");
33535
+ const now = (options.now ?? (() => /* @__PURE__ */ new Date()))().getTime();
33536
+ const id = (options.id ?? randomUUID)();
33537
+ db.prepare(`INSERT INTO rank_tracking_configs
33538
+ (id, config_key, project_id, keyword_set_id, target_domain, tag,
33539
+ market_json, devices_json, provider, collection_method, cadence, depth,
33540
+ keyword_limit, next_run_at, created_at, updated_at)
33541
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`).run(id, configKey, projectId, set.id, targetDomain, tag, JSON.stringify(marketWithoutDevice), JSON.stringify(devices), provider.data, input.collectionMethod, input.cadence, input.depth, input.keywordLimit, input.cadence === "manual" ? null : now, now, now);
33542
+ return configFromRow(db.prepare("SELECT * FROM rank_tracking_configs WHERE id = ?").get(id));
33543
+ }
33544
+ //#endregion
33545
+ //#region packages/core/src/rank-tracking/store.ts
33546
+ function activeRankTrackingRun(configId, options = {}) {
33547
+ const row = database(options).prepare(`SELECT * FROM rank_tracking_runs
33548
+ WHERE config_id = ? AND completed_at IS NULL
33549
+ ORDER BY scheduled_for DESC, id DESC LIMIT 1`).get(configId);
33550
+ return row ? runFromRow(row) : null;
33551
+ }
33552
+ function startRankTrackingRun(input, options = {}) {
33553
+ const db = database(options);
33554
+ const active = activeRankTrackingRun(input.configuration.id, { database: db });
33555
+ if (active) return active;
33556
+ if (input.keywords.length < 1) invalid("The selected keyword set view is empty.");
33557
+ const now = (options.now ?? (() => /* @__PURE__ */ new Date()))().getTime();
33558
+ const scheduledFor = input.scheduledFor?.getTime() ?? now;
33559
+ const runId = (options.id ?? randomUUID)();
33560
+ const idempotencyKey = `${input.configuration.id}:${scheduledFor}`;
33561
+ const taskCount = input.keywords.length * input.configuration.devices.length;
33562
+ const create = db.transaction(() => {
33563
+ db.prepare(`INSERT INTO rank_tracking_runs
33564
+ (id, config_id, idempotency_key, state, collection_method, scheduled_for,
33565
+ started_at, keyword_count, task_count, pending_count, config_snapshot_json)
33566
+ VALUES (?, ?, ?, 'pending', ?, ?, ?, ?, ?, ?, ?)`).run(runId, input.configuration.id, idempotencyKey, input.configuration.collectionMethod, scheduledFor, now, input.keywords.length, taskCount, taskCount, JSON.stringify(input.configuration));
33567
+ const insertTask = db.prepare(`INSERT INTO rank_tracking_tasks
33568
+ (id, run_id, normalized_keyword, display_keyword, device, state)
33569
+ VALUES (?, ?, ?, ?, ?, 'pending')`);
33570
+ for (const keyword of input.keywords) for (const device of input.configuration.devices) insertTask.run((options.id ?? randomUUID)(), runId, keyword.normalizedKeyword, keyword.keyword, device);
33571
+ db.prepare(`UPDATE rank_tracking_configs
33572
+ SET next_run_at = ?, updated_at = ? WHERE id = ?`).run(nextRunAt(input.configuration.cadence, scheduledFor), now, input.configuration.id);
33573
+ });
33574
+ try {
33575
+ create.immediate();
33576
+ } catch (error) {
33577
+ if (/unique constraint/i.test(String(error))) {
33578
+ const recovered = activeRankTrackingRun(input.configuration.id, { database: db });
33579
+ if (recovered) return recovered;
33580
+ }
33581
+ throw error;
33582
+ }
33583
+ return getRankTrackingRun(runId, { database: db });
33584
+ }
33585
+ function getRankTrackingRun(runId, options = {}) {
33586
+ const row = database(options).prepare("SELECT * FROM rank_tracking_runs WHERE id = ?").get(runId);
33587
+ if (!row) throw new SeoError("INTERNAL_ERROR", "Rank tracking run is missing.");
33588
+ return runFromRow(row);
33589
+ }
33590
+ function rankTrackingTasks(runId, states, options = {}) {
33591
+ const filter = states?.length ? ` AND state IN (${states.map(() => "?").join(", ")})` : "";
33592
+ return database(options).prepare(`SELECT * FROM rank_tracking_tasks WHERE run_id = ?${filter}
33593
+ ORDER BY normalized_keyword, device, id`).all(runId, ...states ?? []).map(taskFromRow);
33594
+ }
33595
+ function markRankTasksPosted(runId, receipts, cost, options = {}) {
33596
+ const db = database(options);
33597
+ const now = (options.now ?? (() => /* @__PURE__ */ new Date()))().getTime();
33598
+ db.transaction(() => {
33599
+ const statement = db.prepare(`UPDATE rank_tracking_tasks
33600
+ SET state = 'posted', provider_task_id = ?, posted_at = ?,
33601
+ attempt_count = attempt_count + 1, error_code = NULL,
33602
+ error_message = NULL
33603
+ WHERE id = ? AND run_id = ? AND state IN ('pending', 'posting')`);
33604
+ for (const receipt of receipts) if (statement.run(receipt.providerTaskId, now, receipt.taskId, runId).changes !== 1) throw new SeoError("INTERNAL_ERROR", "A queued rank task changed before its receipt was saved.");
33605
+ db.prepare(`UPDATE rank_tracking_runs
33606
+ SET estimated_cost_micros = CASE
33607
+ WHEN ? IS NULL THEN estimated_cost_micros
33608
+ ELSE COALESCE(estimated_cost_micros, 0) + ? END,
33609
+ actual_cost_micros = CASE
33610
+ WHEN ? IS NULL THEN actual_cost_micros
33611
+ ELSE COALESCE(actual_cost_micros, 0) + ? END
33612
+ WHERE id = ?`).run(cost.estimatedMicros, cost.estimatedMicros, cost.actualMicros, cost.actualMicros, runId);
33613
+ }).immediate();
33614
+ }
33615
+ function markRankTasksPosting(runId, taskIds, options = {}) {
33616
+ if (taskIds.length === 0) return;
33617
+ const db = database(options);
33618
+ db.transaction(() => {
33619
+ const statement = db.prepare(`UPDATE rank_tracking_tasks
33620
+ SET state = 'posting', attempt_count = attempt_count + 1
33621
+ WHERE id = ? AND run_id = ? AND state = 'pending'`);
33622
+ for (const taskId of taskIds) if (statement.run(taskId, runId).changes !== 1) throw new SeoError("INTERNAL_ERROR", "A queued rank task changed before posting started.");
33623
+ }).immediate();
33624
+ }
33625
+ function recoverRankTaskReceipt(input, options = {}) {
33626
+ const now = (options.now ?? (() => /* @__PURE__ */ new Date()))().getTime();
33627
+ database(options).prepare(`UPDATE rank_tracking_tasks
33628
+ SET state = 'posted', provider_task_id = ?, posted_at = ?
33629
+ WHERE id = ? AND state = 'posting'`).run(input.providerTaskId, now, input.taskId);
33630
+ }
33631
+ function saveRankObservation(observation, options = {}) {
33632
+ saveRankObservations([observation], options);
33633
+ }
33634
+ function saveRankObservations(observations, options = {}) {
33635
+ if (observations.length === 0) return;
33636
+ const runId = observations[0]?.runId;
33637
+ if (!runId || observations.some((observation) => observation.runId !== runId)) throw new SeoError("INTERNAL_ERROR", "A rank observation batch must belong to one run.");
33638
+ const db = database(options);
33639
+ const now = (options.now ?? (() => /* @__PURE__ */ new Date()))().getTime();
33640
+ db.transaction(() => {
33641
+ const insertSnapshot = db.prepare(`INSERT OR IGNORE INTO rank_tracking_snapshots
33642
+ (task_id, run_id, normalized_keyword, display_keyword, device,
33643
+ observation_state, organic_position, absolute_position, ranking_url,
33644
+ observed_features_json, checked_at, provider, provider_task_id,
33645
+ requested_depth, returned_rows, retained_rows, invalid_rows,
33646
+ completeness, estimated_cost_micros, actual_cost_micros, warnings_json,
33647
+ created_at)
33648
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`);
33649
+ const completeTask = db.prepare(`UPDATE rank_tracking_tasks
33650
+ SET state = 'complete', collected_at = ?, error_code = NULL,
33651
+ error_message = NULL WHERE id = ?`);
33652
+ const addLiveCost = db.prepare(`UPDATE rank_tracking_runs
33653
+ SET estimated_cost_micros = CASE
33654
+ WHEN ? IS NULL THEN estimated_cost_micros
33655
+ ELSE COALESCE(estimated_cost_micros, 0) + ? END,
33656
+ actual_cost_micros = CASE
33657
+ WHEN ? IS NULL THEN actual_cost_micros
33658
+ ELSE COALESCE(actual_cost_micros, 0) + ? END
33659
+ WHERE id = ? AND collection_method = 'live'`);
33660
+ for (const observation of observations) {
33661
+ const inserted = insertSnapshot.run(observation.taskId, observation.runId, observation.normalizedKeyword, observation.keyword, observation.device, observation.state, observation.organicPosition, observation.absolutePosition, observation.rankingUrl, JSON.stringify(observation.observedFeatures), observation.checkedAt, observation.provider, observation.providerTaskId, observation.requestedDepth, observation.returnedRows, observation.retainedRows, observation.invalidRows, observation.completeness, observation.estimatedCostMicros, observation.actualCostMicros, JSON.stringify(observation.warnings), now);
33662
+ completeTask.run(now, observation.taskId);
33663
+ if (inserted.changes === 1) addLiveCost.run(observation.estimatedCostMicros, observation.estimatedCostMicros, observation.actualCostMicros, observation.actualCostMicros, observation.runId);
33664
+ }
33665
+ }).immediate();
33666
+ refreshRankTrackingRun(runId, {
33667
+ database: db,
33668
+ now: options.now
33669
+ });
33670
+ }
33671
+ function failRankTrackingTask(input, options = {}) {
33672
+ const db = database(options);
33673
+ const row = db.prepare("SELECT run_id FROM rank_tracking_tasks WHERE id = ?").get(input.taskId);
33674
+ if (!row) return;
33675
+ db.prepare(`UPDATE rank_tracking_tasks
33676
+ SET state = 'failed', attempt_count = attempt_count + 1,
33677
+ error_code = ?, error_message = ? WHERE id = ?`).run(input.code.slice(0, 100), input.message.slice(0, 1e3), input.taskId);
33678
+ refreshRankTrackingRun(row.run_id, {
33679
+ database: db,
33680
+ now: options.now
33681
+ });
33682
+ }
33683
+ function refreshRankTrackingRun(runId, options = {}) {
33684
+ const db = database(options);
33685
+ const counts = db.prepare(`SELECT
33686
+ SUM(CASE WHEN state = 'complete' THEN 1 ELSE 0 END) AS snapshots,
33687
+ SUM(CASE WHEN state IN ('pending', 'posting', 'posted') THEN 1 ELSE 0 END) AS pending,
33688
+ SUM(CASE WHEN state = 'failed' THEN 1 ELSE 0 END) AS failed
33689
+ FROM rank_tracking_tasks WHERE run_id = ?`).get(runId);
33690
+ const state = counts.pending > 0 ? counts.snapshots > 0 || counts.failed > 0 ? "partial" : "pending" : counts.failed > 0 ? counts.snapshots > 0 ? "partial" : "failed" : "complete";
33691
+ const now = (options.now ?? (() => /* @__PURE__ */ new Date()))().getTime();
33692
+ db.prepare(`UPDATE rank_tracking_runs
33693
+ SET state = ?, snapshot_count = ?, pending_count = ?, failed_count = ?,
33694
+ completed_at = CASE WHEN ? = 0 THEN ? ELSE NULL END,
33695
+ error_summary = CASE WHEN ? > 0 THEN ? ELSE NULL END
33696
+ WHERE id = ?`).run(state, counts.snapshots, counts.pending, counts.failed, counts.pending, now, counts.failed, `${counts.failed} rank task${counts.failed === 1 ? "" : "s"} failed.`, runId);
33697
+ if (counts.pending === 0) pruneRankTrackingRuns(runId, {
33698
+ ...options,
33699
+ database: db
33700
+ });
33701
+ return getRankTrackingRun(runId, { database: db });
33702
+ }
33703
+ function pruneRankTrackingRuns(runId, options = {}) {
33704
+ const db = database(options);
33705
+ const row = db.prepare("SELECT config_id FROM rank_tracking_runs WHERE id = ?").get(runId);
33706
+ if (!row) return;
33707
+ db.prepare(`DELETE FROM rank_tracking_runs
33708
+ WHERE config_id = ? AND id IN (
33709
+ SELECT id FROM rank_tracking_runs
33710
+ WHERE config_id = ? AND completed_at IS NOT NULL
33711
+ ORDER BY scheduled_for DESC, id DESC LIMIT -1 OFFSET ?
33712
+ )`).run(row.config_id, row.config_id, RANK_TRACKING_LIMITS.retainedRunsPerConfiguration);
33713
+ const maxBytes = options.maxLogicalBytes ?? RANK_TRACKING_LIMITS.logicalBytes;
33714
+ while (rankTrackingLogicalBytes(db) > maxBytes) {
33715
+ const oldest = db.prepare(`SELECT id FROM rank_tracking_runs
33716
+ WHERE config_id = ? AND id <> ?
33717
+ AND completed_at IS NOT NULL
33718
+ ORDER BY scheduled_for, id LIMIT 1`).get(row.config_id, runId);
33719
+ if (!oldest) break;
33720
+ db.prepare("DELETE FROM rank_tracking_runs WHERE id = ?").run(oldest.id);
33721
+ }
33722
+ enforceRankTrackingLogicalBytes(db, maxBytes);
33723
+ }
33724
+ function latestRankTrackingRun(configId, options = {}) {
33725
+ const row = database(options).prepare(`SELECT * FROM rank_tracking_runs WHERE config_id = ?
33726
+ ORDER BY scheduled_for DESC, id DESC LIMIT 1`).get(configId);
33727
+ return row ? runFromRow(row) : null;
33728
+ }
33729
+ function priorComparableRankTrackingRun(configId, currentRunId, options = {}) {
33730
+ const row = database(options).prepare(`SELECT previous.* FROM rank_tracking_runs previous
33731
+ JOIN rank_tracking_runs current ON current.id = ?
33732
+ WHERE previous.config_id = ? AND previous.id <> current.id
33733
+ AND previous.scheduled_for < current.scheduled_for
33734
+ AND previous.snapshot_count > 0
33735
+ ORDER BY previous.scheduled_for DESC, previous.id DESC LIMIT 1`).get(currentRunId, configId);
33736
+ return row ? runFromRow(row) : null;
33737
+ }
33738
+ function rankObservations(runId, options = {}) {
33739
+ return database(options).prepare(`SELECT * FROM rank_tracking_snapshots WHERE run_id = ?
33740
+ ORDER BY normalized_keyword, device, task_id`).all(runId).map(observationFromRow);
33741
+ }
33742
+ function rankTrackingLogicalBytes(db = getDb()) {
33743
+ return db.prepare(`SELECT
33744
+ COALESCE((SELECT SUM(length(id) + length(config_key) + length(project_id) + length(keyword_set_id) + length(target_domain) + COALESCE(length(tag), 0) + length(market_json) + length(devices_json) + length(provider) + length(collection_method) + length(cadence)) FROM rank_tracking_configs), 0) +
33745
+ COALESCE((SELECT SUM(length(id) + length(config_id) + length(idempotency_key) + length(state) + length(collection_method) + length(config_snapshot_json) + COALESCE(length(error_summary), 0)) FROM rank_tracking_runs), 0) +
33746
+ COALESCE((SELECT SUM(length(id) + length(run_id) + length(normalized_keyword) + length(display_keyword) + length(device) + length(state) + COALESCE(length(provider_task_id), 0) + COALESCE(length(error_code), 0) + COALESCE(length(error_message), 0)) FROM rank_tracking_tasks), 0) +
33747
+ COALESCE((SELECT SUM(length(task_id) + length(run_id) + length(normalized_keyword) + length(display_keyword) + length(device) + length(observation_state) + COALESCE(length(ranking_url), 0) + length(observed_features_json) + length(checked_at) + length(provider) + COALESCE(length(provider_task_id), 0) + length(completeness) + length(warnings_json)) FROM rank_tracking_snapshots), 0)
33748
+ AS bytes`).get().bytes;
33749
+ }
33750
+ function enforceRankTrackingLogicalBytes(db, maxBytes = RANK_TRACKING_LIMITS.logicalBytes) {
33751
+ if (rankTrackingLogicalBytes(db) > maxBytes) throw new SeoError("INVALID_INPUT", "Saved rank history reached its local storage limit. Remove an unused tracking configuration before collecting more.");
33752
+ }
33753
+ //#endregion
33754
+ //#region packages/core/src/rank-tracking/run.ts
33755
+ function providerError$1(error) {
33756
+ if (!(error instanceof ProviderError)) return error instanceof SeoError ? error : new SeoError("INTERNAL_ERROR", "Rank collection failed unexpectedly.");
33757
+ if (error.code === "rate-limit") return new SeoError("RATE_LIMITED", error.message);
33758
+ if (error.code === "configuration") return new SeoError("INVALID_INPUT", error.message);
33759
+ return new SeoError("PROVIDER_UNAVAILABLE", error.message);
33760
+ }
33761
+ async function defaultCollector(provider) {
33762
+ if (provider !== "dataforseo") throw new SeoError("PROVIDER_UNAVAILABLE", `${provider} does not yet implement exact rank collection. Use DataForSEO for this report.`);
33763
+ if (!await readDataForSeoCredentials()) throw new SeoError("PROVIDER_UNAVAILABLE", "DataForSEO is not connected. Run `seo providers dataforseo connect` first.");
33764
+ return new DataForSeoRankTrackingCollector();
33765
+ }
33766
+ function requestForTask(configuration, task) {
33767
+ return {
33768
+ keyword: task.displayKeyword,
33769
+ market: {
33770
+ ...configuration.market,
33771
+ device: task.device
33772
+ },
33773
+ depth: configuration.depth,
33774
+ refresh: true,
33775
+ context: {
33776
+ projectId: configuration.projectId,
33777
+ reportId: "rank-tracking",
33778
+ reportRunId: task.runId
33779
+ }
33780
+ };
33781
+ }
33782
+ function observationFromEvidence(configuration, task, evidence) {
33783
+ const match = evidence.data.organicResults.find((result) => {
33784
+ try {
33785
+ return targetMatchesDomain(configuration.targetDomain, result.domain);
33786
+ } catch {
33787
+ return false;
33788
+ }
33789
+ });
33790
+ return {
33791
+ taskId: task.id,
33792
+ runId: task.runId,
33793
+ keyword: task.displayKeyword,
33794
+ normalizedKeyword: task.normalizedKeyword,
33795
+ device: task.device,
33796
+ state: match ? "observed" : "not_observed_within_depth",
33797
+ organicPosition: match?.rankGroup ?? null,
33798
+ absolutePosition: match?.rankAbsolute ?? null,
33799
+ rankingUrl: match?.url ?? null,
33800
+ observedFeatures: evidence.data.features,
33801
+ checkedAt: evidence.data.checkedAt,
33802
+ provider: evidence.provider,
33803
+ providerTaskId: task.providerTaskId,
33804
+ requestedDepth: configuration.depth,
33805
+ returnedRows: evidence.coverage.returnedRows,
33806
+ retainedRows: evidence.coverage.retainedRows,
33807
+ invalidRows: evidence.coverage.invalidRows,
33808
+ completeness: evidence.coverage.completeness,
33809
+ estimatedCostMicros: evidence.cost.estimatedMicros,
33810
+ actualCostMicros: evidence.cost.actualMicros,
33811
+ warnings: evidence.warnings
33812
+ };
33813
+ }
33814
+ async function inBoundedParallel(items, concurrency, operation) {
33815
+ let next = 0;
33816
+ const workers = Array.from({ length: Math.min(concurrency, items.length) }, async () => {
33817
+ while (next < items.length) {
33818
+ const index = next;
33819
+ next += 1;
33820
+ const item = items[index];
33821
+ if (item) await operation(item);
33822
+ }
33823
+ });
33824
+ await Promise.all(workers);
33825
+ }
33826
+ async function collectLive(configuration, run, collector, dependencies) {
33827
+ const tasks = rankTrackingTasks(run.id, ["pending"], dependencies);
33828
+ const observations = [];
33829
+ const warnings = [];
33830
+ await inBoundedParallel(tasks, 3, async (task) => {
33831
+ try {
33832
+ const evidence = await collector.live(requestForTask(configuration, task));
33833
+ observations.push(observationFromEvidence(configuration, task, evidence));
33834
+ } catch (error) {
33835
+ const mapped = providerError$1(error);
33836
+ failRankTrackingTask({
33837
+ taskId: task.id,
33838
+ code: mapped.code,
33839
+ message: mapped.message
33840
+ }, dependencies);
33841
+ warnings.push(`${task.displayKeyword} (${task.device}): ${mapped.message}`);
33842
+ }
33843
+ });
33844
+ saveRankObservations(observations, dependencies);
33845
+ return {
33846
+ collected: observations.length,
33847
+ warnings
33848
+ };
33849
+ }
33850
+ async function collectQueued(configuration, run, collector, dependencies) {
33851
+ if (!collector.post || !collector.ready || !collector.collect) throw new SeoError("PROVIDER_UNAVAILABLE", `${collector.provider} does not support queued rank collection. Use live collection or another provider.`);
33852
+ const warnings = [];
33853
+ const observations = [];
33854
+ let posted = 0;
33855
+ let ready = [];
33856
+ try {
33857
+ ready = await collector.ready();
33858
+ } catch (error) {
33859
+ const mapped = providerError$1(error);
33860
+ warnings.push(`The provider ready list was unavailable; saved task IDs will still be checked: ${mapped.message}`);
33861
+ }
33862
+ const byTaskKey = new Map(ready.flatMap((item) => item.taskKey ? [[item.taskKey, item.providerTaskId]] : []));
33863
+ for (const task of rankTrackingTasks(run.id, ["posting"], dependencies)) {
33864
+ const providerTaskId = byTaskKey.get(task.id);
33865
+ if (providerTaskId) recoverRankTaskReceipt({
33866
+ taskId: task.id,
33867
+ providerTaskId
33868
+ }, dependencies);
33869
+ }
33870
+ await inBoundedParallel(rankTrackingTasks(run.id, ["posted"], dependencies).filter((task) => task.providerTaskId), 5, async (task) => {
33871
+ try {
33872
+ const evidence = await collector.collect?.({
33873
+ providerTaskId: task.providerTaskId,
33874
+ request: requestForTask(configuration, task)
33875
+ });
33876
+ if (!evidence) return;
33877
+ observations.push(observationFromEvidence(configuration, task, evidence));
33878
+ } catch (error) {
33879
+ const mapped = providerError$1(error);
33880
+ warnings.push(`${task.displayKeyword} (${task.device}): ${mapped.message}`);
33881
+ }
33882
+ });
33883
+ saveRankObservations(observations, dependencies);
33884
+ const pending = rankTrackingTasks(run.id, ["pending"], dependencies);
33885
+ for (let offset = 0; offset < pending.length; offset += RANK_TRACKING_LIMITS.tasksPerProviderPost) {
33886
+ const batch = pending.slice(offset, offset + RANK_TRACKING_LIMITS.tasksPerProviderPost);
33887
+ markRankTasksPosting(run.id, batch.map((task) => task.id), dependencies);
33888
+ try {
33889
+ const result = await collector.post({
33890
+ tasks: batch.map((task) => ({
33891
+ taskKey: task.id,
33892
+ request: requestForTask(configuration, task)
33893
+ })),
33894
+ context: {
33895
+ projectId: configuration.projectId,
33896
+ reportId: "rank-tracking",
33897
+ reportRunId: run.id
33898
+ }
33899
+ });
33900
+ const receiptByKey = new Map(result.receipts.map((receipt) => [receipt.taskKey, receipt]));
33901
+ if (receiptByKey.size !== batch.length) throw new SeoError("PROVIDER_UNAVAILABLE", "The provider did not return one receipt for every queued rank task.");
33902
+ markRankTasksPosted(run.id, batch.map((task) => ({
33903
+ taskId: task.id,
33904
+ providerTaskId: receiptByKey.get(task.id)?.providerTaskId ?? ""
33905
+ })), {
33906
+ estimatedMicros: result.estimatedCostMicros,
33907
+ actualMicros: result.actualCostMicros
33908
+ }, dependencies);
33909
+ posted += batch.length;
33910
+ warnings.push(...result.warnings.map((warning) => warning.message));
33911
+ } catch (error) {
33912
+ const mapped = providerError$1(error);
33913
+ warnings.push(`${batch.length} queued task${batch.length === 1 ? "" : "s"} remain in recovery after posting was interrupted: ${mapped.message}`);
33914
+ break;
33915
+ }
33916
+ }
33917
+ return {
33918
+ collected: observations.length,
33919
+ posted,
33920
+ warnings
33921
+ };
33922
+ }
33923
+ function due(configuration, now) {
33924
+ return configuration.cadence === "manual" || configuration.nextRunAt === null || Date.parse(configuration.nextRunAt) <= now.getTime();
33925
+ }
33926
+ async function executeRankTracking(input, dependencies = {}) {
33927
+ const cadence = input.cadence ?? "manual";
33928
+ const collectionMethod = input.collectionMethod ?? (cadence === "manual" ? "live" : "queued");
33929
+ const keywordLimit = input.keywordLimit ?? (collectionMethod === "live" ? 25 : 100);
33930
+ const set = getKeywordSet({
33931
+ projectId: input.projectId,
33932
+ idOrName: input.set,
33933
+ tag: input.tag,
33934
+ limit: keywordLimit
33935
+ }, dependencies);
33936
+ const provider = input.provider ?? set.set.provider ?? "dataforseo";
33937
+ const defaultDevice = set.set.market.device ?? "desktop";
33938
+ const configuration = getOrCreateRankTrackingConfiguration({
33939
+ projectId: input.projectId,
33940
+ keywordSetId: set.set.id,
33941
+ targetDomain: input.targetDomain,
33942
+ tag: input.tag,
33943
+ market: set.set.market,
33944
+ devices: input.devices ?? [defaultDevice],
33945
+ provider,
33946
+ collectionMethod,
33947
+ cadence,
33948
+ depth: input.depth ?? 100,
33949
+ keywordLimit
33950
+ }, dependencies);
33951
+ const warnings = [];
33952
+ let run = activeRankTrackingRun(configuration.id, dependencies);
33953
+ let startedRun = false;
33954
+ let collectedTasks = 0;
33955
+ let postedTasks = 0;
33956
+ if (!run && (input.start ?? true) && due(configuration, dependencies.now?.() ?? /* @__PURE__ */ new Date())) {
33957
+ run = startRankTrackingRun({
33958
+ configuration,
33959
+ keywords: set.items.map((item) => ({
33960
+ keyword: item.keyword,
33961
+ normalizedKeyword: item.normalizedKeyword
33962
+ }))
33963
+ }, {
33964
+ ...dependencies,
33965
+ id: dependencies.id ?? randomUUID
33966
+ });
33967
+ startedRun = true;
33968
+ }
33969
+ if (run) {
33970
+ const collector = dependencies.collector ?? await (dependencies.collectorFactory ?? defaultCollector)(provider);
33971
+ if (collector.provider !== provider) throw new SeoError("INTERNAL_ERROR", "The selected rank collector does not match the saved provider.");
33972
+ if (configuration.collectionMethod === "live") {
33973
+ const result = await collectLive(configuration, run, collector, dependencies);
33974
+ collectedTasks += result.collected;
33975
+ warnings.push(...result.warnings);
33976
+ } else {
33977
+ const result = await collectQueued(configuration, run, collector, dependencies);
33978
+ collectedTasks += result.collected;
33979
+ postedTasks += result.posted;
33980
+ warnings.push(...result.warnings);
33981
+ }
33982
+ run = latestRankTrackingRun(configuration.id, dependencies);
33983
+ } else run = latestRankTrackingRun(configuration.id, dependencies);
33984
+ return {
33985
+ configuration,
33986
+ run,
33987
+ startedRun,
33988
+ collectedTasks,
33989
+ postedTasks,
33990
+ operationalWarnings: warnings.slice(0, 20)
33991
+ };
33992
+ }
33993
+ //#endregion
33994
+ //#region packages/core/src/analyze/rank-tracking.ts
33995
+ function key$1(item) {
33996
+ return `${item.normalizedKeyword}\u0000${item.device}`;
33997
+ }
33998
+ function changeFor(current, previous) {
33999
+ if (!previous) return {
34000
+ change: "no-history",
34001
+ organicPositionDelta: null,
34002
+ rankingUrlChanged: false
34003
+ };
34004
+ const rankingUrlChanged = current.state === "observed" && previous.state === "observed" && current.rankingUrl !== previous.rankingUrl;
34005
+ if (current.state === "observed" && previous.state === "not_observed_within_depth") return {
34006
+ change: "new",
34007
+ organicPositionDelta: null,
34008
+ rankingUrlChanged
34009
+ };
34010
+ if (current.state === "not_observed_within_depth" && previous.state === "observed") return {
34011
+ change: "lost",
34012
+ organicPositionDelta: null,
34013
+ rankingUrlChanged
34014
+ };
34015
+ if (current.organicPosition !== null && previous.organicPosition !== null) {
34016
+ const delta = previous.organicPosition - current.organicPosition;
34017
+ return {
34018
+ change: delta > 0 ? "improved" : delta < 0 ? "declined" : "unchanged",
34019
+ organicPositionDelta: delta,
34020
+ rankingUrlChanged
34021
+ };
34022
+ }
34023
+ return {
34024
+ change: "unchanged",
34025
+ organicPositionDelta: null,
34026
+ rankingUrlChanged
34027
+ };
34028
+ }
34029
+ const CHANGE_ORDER = {
34030
+ lost: 0,
34031
+ declined: 1,
34032
+ new: 2,
34033
+ improved: 3,
34034
+ unchanged: 4,
34035
+ "no-history": 5
34036
+ };
34037
+ function compareItems(left, right) {
34038
+ return CHANGE_ORDER[left.change] - CHANGE_ORDER[right.change] || Math.abs(right.organicPositionDelta ?? 0) - Math.abs(left.organicPositionDelta ?? 0) || (left.normalizedKeyword < right.normalizedKeyword ? -1 : left.normalizedKeyword > right.normalizedKeyword ? 1 : left.device < right.device ? -1 : left.device > right.device ? 1 : 0);
34039
+ }
34040
+ function reportItems(current, previous, pending) {
34041
+ const previousByKey = new Map(previous.map((item) => [key$1(item), item]));
34042
+ const items = current.map((item) => {
34043
+ const prior = previousByKey.get(key$1(item));
34044
+ const change = changeFor(item, prior);
34045
+ return {
34046
+ keyword: item.keyword,
34047
+ normalizedKeyword: item.normalizedKeyword,
34048
+ device: item.device,
34049
+ state: item.state,
34050
+ organicPosition: item.organicPosition,
34051
+ absolutePosition: item.absolutePosition,
34052
+ rankingUrl: item.rankingUrl,
34053
+ checkedAt: item.checkedAt,
34054
+ previous: prior ? {
34055
+ state: prior.state,
34056
+ organicPosition: prior.organicPosition,
34057
+ absolutePosition: prior.absolutePosition,
34058
+ rankingUrl: prior.rankingUrl,
34059
+ checkedAt: prior.checkedAt
34060
+ } : null,
34061
+ ...change,
34062
+ error: null
34063
+ };
34064
+ });
34065
+ const observedKeys = new Set(current.map(key$1));
34066
+ for (const task of pending) {
34067
+ if (observedKeys.has(key$1(task))) continue;
34068
+ const prior = previousByKey.get(key$1(task));
34069
+ items.push({
34070
+ keyword: task.displayKeyword,
34071
+ normalizedKeyword: task.normalizedKeyword,
34072
+ device: task.device,
34073
+ state: task.state === "failed" ? "failed" : "pending",
34074
+ organicPosition: null,
34075
+ absolutePosition: null,
34076
+ rankingUrl: null,
34077
+ checkedAt: null,
34078
+ previous: prior ? {
34079
+ state: prior.state,
34080
+ organicPosition: prior.organicPosition,
34081
+ absolutePosition: prior.absolutePosition,
34082
+ rankingUrl: prior.rankingUrl,
34083
+ checkedAt: prior.checkedAt
34084
+ } : null,
34085
+ change: "no-history",
34086
+ organicPositionDelta: null,
34087
+ rankingUrlChanged: false,
34088
+ error: task.state === "failed" ? {
34089
+ code: task.errorCode,
34090
+ message: task.errorMessage
34091
+ } : null
34092
+ });
34093
+ }
34094
+ return items.sort(compareItems);
34095
+ }
34096
+ function verdict$1(run, counts) {
34097
+ if (!run) return "No rank tracking run has started for this configuration.";
34098
+ if (run.pendingCount > 0) return `${run.snapshotCount} of ${run.taskCount} market and device-specific snapshots are collected; ${run.pendingCount} remain pending.`;
34099
+ if (run.failedCount > 0) return `${run.snapshotCount} snapshots were collected and ${run.failedCount} failed; use the completed rows as partial evidence.`;
34100
+ if (counts.improved + counts.declined + counts.newCount + counts.lost > 0) return `${counts.improved} improved, ${counts.declined} declined, ${counts.newCount} became observed, and ${counts.lost} were no longer observed within the tracked depth.`;
34101
+ return `${counts.observed} rankings were observed and ${counts.notObserved} were not observed within the tracked depth.`;
34102
+ }
34103
+ async function rankTrackingReport(input, dependencies = {}) {
34104
+ const outputLimit = input.outputLimit ?? 100;
34105
+ if (!Number.isSafeInteger(outputLimit) || outputLimit < 1 || outputLimit > 250) throw new SeoError("INVALID_INPUT", "Rank tracking output limit must be from 1 to 250.");
34106
+ const execution = await executeRankTracking(input, dependencies);
34107
+ const { configuration, run } = execution;
34108
+ const current = run ? rankObservations(run.id, dependencies) : [];
34109
+ const priorRun = run ? priorComparableRankTrackingRun(configuration.id, run.id, dependencies) : null;
34110
+ const allItems = reportItems(current, priorRun ? rankObservations(priorRun.id, dependencies) : [], run ? rankTrackingTasks(run.id, void 0, dependencies) : []);
34111
+ const count = (change) => allItems.filter((item) => item.change === change).length;
34112
+ const observed = current.filter((item) => item.state === "observed").length;
34113
+ const notObserved = current.length - observed;
34114
+ const improved = count("improved");
34115
+ const declined = count("declined");
34116
+ const newCount = count("new");
34117
+ const lost = count("lost");
34118
+ const unchanged = count("unchanged");
34119
+ const rankingUrlChanges = allItems.filter((item) => item.rankingUrlChanged).length;
34120
+ const items = allItems.slice(0, outputLimit);
34121
+ const dataStatus = !run ? "unavailable" : run.state === "complete" ? "complete" : run.state === "pending" ? "pending" : "partial";
34122
+ return {
34123
+ schemaVersion: 1,
34124
+ generatedAt: (dependencies.now ?? (() => /* @__PURE__ */ new Date()))().toISOString(),
34125
+ dataStatus,
34126
+ configuration,
34127
+ run,
34128
+ comparison: {
34129
+ priorRunId: priorRun?.id ?? null,
34130
+ priorStartedAt: priorRun?.startedAt ?? null,
34131
+ comparableItems: allItems.filter((item) => item.previous !== null).length
34132
+ },
34133
+ summary: {
34134
+ trackedKeywords: run?.keywordCount ?? 0,
34135
+ trackedDevices: configuration.devices.length,
34136
+ expectedSnapshots: run?.taskCount ?? 0,
34137
+ completedSnapshots: run?.snapshotCount ?? 0,
34138
+ pendingSnapshots: run?.pendingCount ?? 0,
34139
+ failedSnapshots: run?.failedCount ?? 0,
34140
+ observed,
34141
+ notObservedWithinDepth: notObserved,
34142
+ new: newCount,
34143
+ lost,
34144
+ improved,
34145
+ declined,
34146
+ unchanged,
34147
+ rankingUrlChanges,
34148
+ verdict: verdict$1(run, {
34149
+ observed,
34150
+ notObserved,
34151
+ improved,
34152
+ declined,
34153
+ newCount,
34154
+ lost
34155
+ })
34156
+ },
34157
+ items,
34158
+ coverage: {
34159
+ totalItems: allItems.length,
34160
+ returnedItems: items.length,
34161
+ outputLimit,
34162
+ omittedItems: allItems.length - items.length,
34163
+ requestedDepth: configuration.depth,
34164
+ collectionMethod: configuration.collectionMethod,
34165
+ runState: run?.state ?? "not-started"
34166
+ },
34167
+ cost: {
34168
+ currency: "USD",
34169
+ estimatedMicros: run?.estimatedCostMicros ?? null,
34170
+ actualMicros: run?.actualCostMicros ?? null
34171
+ },
34172
+ operationalWarnings: execution.operationalWarnings,
34173
+ caveats: [
34174
+ `Not observed means the target domain was not retained within the first ${configuration.depth} organic results for that exact market and device. It is not proof that the domain does not rank elsewhere.`,
34175
+ "Exact snapshots and Search Console average position answer different questions and should not be substituted for one another.",
34176
+ "Local, national, desktop, and mobile snapshots remain separate configurations and are never blended into one position.",
34177
+ "Search results can change between checks and may differ from signed-in or personalized results.",
34178
+ ...run?.state === "partial" || run?.state === "failed" ? ["Failed or pending tasks make this run partial; do not treat missing rows as losses."] : []
34179
+ ],
34180
+ nextSteps: [
34181
+ ...run?.pendingCount ? ["Run the same report parameters again after queued tasks are ready; collection is free and resumes from saved task IDs."] : [],
34182
+ "Use Search Console opportunity reports to add clicks, impressions, and average-position context before prioritising a ranking change.",
34183
+ "Inspect ranking URL changes for intent drift or competing templates before changing content.",
34184
+ configuration.cadence === "manual" ? "Use queued collection with a daily, weekly, or monthly cadence when this set needs recurring local tracking." : "Run this report from local cron at the configured cadence; overlapping runs are refused and schedule advancement is deterministic."
34185
+ ]
34186
+ };
34187
+ }
34188
+ //#endregion
32442
34189
  //#region packages/core/src/analyze/reports/dates.ts
32443
34190
  function rangeDays(range) {
32444
34191
  const start = Date.parse(`${range.startDate}T00:00:00Z`);
@@ -32904,6 +34651,402 @@ function reportPresentation(report) {
32904
34651
  };
32905
34652
  }
32906
34653
  //#endregion
34654
+ //#region packages/core/src/keyword-sets/mutations.ts
34655
+ function addKeywordsToSet(input, options = {}) {
34656
+ const projectId = boundedText(input.projectId, "Project id", KEYWORD_SET_FIELD_LIMITS.projectId);
34657
+ const idOrName = boundedText(input.idOrName, "Keyword set id or name", KEYWORD_SET_FIELD_LIMITS.name);
34658
+ const items = validateMutationItems(input.items);
34659
+ const db = storeDatabase(options);
34660
+ const set = requireSetRow(db, projectId, idOrName);
34661
+ const currentSetCount = rowCount(db, "SELECT COUNT(*) AS count FROM keyword_set_items WHERE set_id = ?", set.id);
34662
+ const currentTotalCount = rowCount(db, "SELECT COUNT(*) AS count FROM keyword_set_items");
34663
+ const existingRows = db.prepare(`SELECT * FROM keyword_set_items
34664
+ WHERE set_id = ? AND normalized_keyword IN (${[...items].map(() => "?").join(", ")})`).all(set.id, ...items.keys());
34665
+ const existing = new Map(existingRows.map((row) => [row.normalized_keyword, row]));
34666
+ const additions = items.size - existing.size;
34667
+ if (currentSetCount + additions > KEYWORD_SET_LIMITS.keywordsPerSet) invalid$1(`A keyword set can contain at most ${KEYWORD_SET_LIMITS.keywordsPerSet} keywords.`);
34668
+ if (currentTotalCount + additions > KEYWORD_SET_LIMITS.totalKeywords) invalid$1(`At most ${KEYWORD_SET_LIMITS.totalKeywords} keywords can be saved across all sets.`);
34669
+ const now = (options.now ?? (() => /* @__PURE__ */ new Date()))().getTime();
34670
+ const updated = /* @__PURE__ */ new Set();
34671
+ db.transaction(() => {
34672
+ for (const [normalizedKeyword, item] of items) {
34673
+ const existingItem = existing.get(normalizedKeyword);
34674
+ if (!existingItem) insertKeyword(db, set.id, normalizedKeyword, item, now);
34675
+ else if (updateKeyword(db, set.id, normalizedKeyword, item, existingItem, now)) updated.add(normalizedKeyword);
34676
+ if (addTags(db, set.id, normalizedKeyword, item.tags) && existingItem) {
34677
+ updated.add(normalizedKeyword);
34678
+ db.prepare(`UPDATE keyword_set_items SET updated_at = ?
34679
+ WHERE set_id = ? AND normalized_keyword = ?`).run(now, set.id, normalizedKeyword);
34680
+ }
34681
+ }
34682
+ if (additions > 0 || updated.size > 0) db.prepare("UPDATE keyword_sets SET updated_at = ? WHERE id = ?").run(now, set.id);
34683
+ enforceKeywordSetLogicalBytes(db, options.maxLogicalBytes);
34684
+ }).immediate();
34685
+ return {
34686
+ setId: set.id,
34687
+ requested: input.items.length,
34688
+ normalized: items.size,
34689
+ added: additions,
34690
+ removed: 0,
34691
+ existing: existing.size,
34692
+ updated: updated.size,
34693
+ keywordCount: currentSetCount + additions
34694
+ };
34695
+ }
34696
+ function insertKeyword(db, setId, normalizedKeyword, item, now) {
34697
+ db.prepare(`INSERT INTO keyword_set_items
34698
+ (set_id, normalized_keyword, display_keyword, metric_json, metric_provider,
34699
+ metric_observed_at, page_kind, page_url, created_at, updated_at)
34700
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`).run(setId, normalizedKeyword, item.keyword, item.metricJson, item.metric?.provider ?? null, item.metric?.observedAt ?? null, item.page?.kind ?? null, item.page?.url ?? null, now, now);
34701
+ }
34702
+ function updateKeyword(db, setId, normalizedKeyword, item, existingItem, now) {
34703
+ const pageChanged = item.page !== void 0 && (existingItem.page_kind !== (item.page?.kind ?? null) || existingItem.page_url !== (item.page?.url ?? null));
34704
+ const metricChanged = item.metric !== void 0 && existingItem.metric_json !== item.metricJson;
34705
+ if (!pageChanged && !metricChanged) return false;
34706
+ db.prepare(`UPDATE keyword_set_items SET
34707
+ page_kind = CASE WHEN ? THEN ? ELSE page_kind END,
34708
+ page_url = CASE WHEN ? THEN ? ELSE page_url END,
34709
+ metric_json = CASE WHEN ? THEN ? ELSE metric_json END,
34710
+ metric_provider = CASE WHEN ? THEN ? ELSE metric_provider END,
34711
+ metric_observed_at = CASE WHEN ? THEN ? ELSE metric_observed_at END,
34712
+ updated_at = ?
34713
+ WHERE set_id = ? AND normalized_keyword = ?`).run(pageChanged ? 1 : 0, item.page?.kind ?? null, pageChanged ? 1 : 0, item.page?.url ?? null, metricChanged ? 1 : 0, item.metricJson, metricChanged ? 1 : 0, item.metric?.provider ?? null, metricChanged ? 1 : 0, item.metric?.observedAt ?? null, now, setId, normalizedKeyword);
34714
+ return true;
34715
+ }
34716
+ function addTags(db, setId, normalizedKeyword, tags) {
34717
+ let changed = false;
34718
+ for (const tag of tags) {
34719
+ const inserted = db.prepare(`INSERT OR IGNORE INTO keyword_set_tags
34720
+ (set_id, normalized_keyword, tag) VALUES (?, ?, ?)`).run(setId, normalizedKeyword, tag);
34721
+ changed ||= inserted.changes > 0;
34722
+ }
34723
+ return changed;
34724
+ }
34725
+ function removeKeywordsFromSet(input, options = {}) {
34726
+ if (input.keywords.length < 1 || input.keywords.length > KEYWORD_SET_LIMITS.mutationKeywords) invalid$1(`Remove 1 to ${KEYWORD_SET_LIMITS.mutationKeywords} keywords per operation.`);
34727
+ const projectId = boundedText(input.projectId, "Project id", KEYWORD_SET_FIELD_LIMITS.projectId);
34728
+ const idOrName = boundedText(input.idOrName, "Keyword set id or name", KEYWORD_SET_FIELD_LIMITS.name);
34729
+ const keywords = [...new Set(input.keywords.map(normalizeSavedKeyword))];
34730
+ const db = storeDatabase(options);
34731
+ const set = requireSetRow(db, projectId, idOrName);
34732
+ const removed = db.prepare(`DELETE FROM keyword_set_items
34733
+ WHERE set_id = ? AND normalized_keyword IN (${keywords.map(() => "?").join(", ")})`).run(set.id, ...keywords).changes;
34734
+ const now = (options.now ?? (() => /* @__PURE__ */ new Date()))().getTime();
34735
+ if (removed > 0) db.prepare("UPDATE keyword_sets SET updated_at = ? WHERE id = ?").run(now, set.id);
34736
+ const keywordCount = rowCount(db, "SELECT COUNT(*) AS count FROM keyword_set_items WHERE set_id = ?", set.id);
34737
+ return {
34738
+ setId: set.id,
34739
+ requested: input.keywords.length,
34740
+ normalized: keywords.length,
34741
+ added: 0,
34742
+ removed,
34743
+ existing: keywords.length - removed,
34744
+ updated: 0,
34745
+ keywordCount
34746
+ };
34747
+ }
34748
+ function setKeywordSetRefreshTime(input, options = {}) {
34749
+ const projectId = boundedText(input.projectId, "Project id", KEYWORD_SET_FIELD_LIMITS.projectId);
34750
+ const idOrName = boundedText(input.idOrName, "Keyword set id or name", KEYWORD_SET_FIELD_LIMITS.name);
34751
+ const refreshedAt = Date.parse(input.refreshedAt);
34752
+ if (!Number.isFinite(refreshedAt)) invalid$1("Use a valid refresh timestamp.");
34753
+ const db = storeDatabase(options);
34754
+ const row = requireSetRow(db, projectId, idOrName);
34755
+ const now = (options.now ?? (() => /* @__PURE__ */ new Date()))().getTime();
34756
+ db.prepare("UPDATE keyword_sets SET last_refreshed_at = ?, updated_at = ? WHERE id = ?").run(refreshedAt, now, row.id);
34757
+ return rowToSet(requireSetRow(db, projectId, row.id));
34758
+ }
34759
+ //#endregion
34760
+ //#region packages/core/src/keyword-sets/refresh.ts
34761
+ const REFRESH_BATCH_SIZE = 50;
34762
+ function estimator(adapter) {
34763
+ return "estimateKeywordMetricsCost" in adapter && typeof adapter.estimateKeywordMetricsCost === "function" ? adapter : null;
34764
+ }
34765
+ async function defaultCandidates() {
34766
+ return [{
34767
+ adapter: new DataForSeoKeywordMetricsProvider(),
34768
+ connected: Boolean(await readDataForSeoCredentials()),
34769
+ priority: 10
34770
+ }];
34771
+ }
34772
+ function providerError(error) {
34773
+ if (!(error instanceof ProviderError)) throw error;
34774
+ throw new SeoError(error.code === "rate-limit" ? "RATE_LIMITED" : "PROVIDER_UNAVAILABLE", error.message);
34775
+ }
34776
+ function summedCost(values) {
34777
+ return values.length > 0 && values.every((value) => value !== null) ? values.reduce((sum, value) => sum + (value ?? 0), 0) : null;
34778
+ }
34779
+ async function refreshKeywordSet(input, dependencies = {}) {
34780
+ const limit = input.limit ?? KEYWORD_SET_LIMITS.outputRows;
34781
+ if (!Number.isSafeInteger(limit) || limit < 1 || limit > 1e3) throw new SeoError("INVALID_INPUT", "Refresh limit must be from 1 to 1000.");
34782
+ if (input.provider && !providerIdSchema.safeParse(input.provider).success) throw new SeoError("INVALID_INPUT", "Use a supported keyword provider.");
34783
+ const offset = input.offset ?? 0;
34784
+ if (!Number.isSafeInteger(offset) || offset < 0 || offset > 1e5) throw new SeoError("INVALID_INPUT", "Refresh offset must be from 0 to 100000.");
34785
+ const detail = getKeywordSet({
34786
+ projectId: input.projectId,
34787
+ idOrName: input.idOrName,
34788
+ limit,
34789
+ offset
34790
+ }, dependencies.store);
34791
+ if (detail.items.length === 0) throw new SeoError("INSUFFICIENT_DATA", detail.set.keywordCount === 0 ? "The keyword set is empty." : "No keywords exist in the selected refresh window.");
34792
+ let candidates;
34793
+ try {
34794
+ candidates = dependencies.candidates ?? await defaultCandidates();
34795
+ } catch (error) {
34796
+ return providerError(error);
34797
+ }
34798
+ const resolution = resolveProvider({
34799
+ capability: "keyword-metrics",
34800
+ market: detail.set.market,
34801
+ candidates,
34802
+ provider: input.provider ?? detail.set.provider ?? void 0
34803
+ });
34804
+ if (resolution.status === "unavailable") {
34805
+ const selectedProvider = input.provider ?? detail.set.provider ?? void 0;
34806
+ throw new SeoError(resolution.reason === "market-not-supported" && selectedProvider ? "INVALID_INPUT" : "PROVIDER_UNAVAILABLE", resolution.reason === "provider-not-connected" ? "No connected provider can refresh this keyword set." : resolution.reason === "market-not-supported" && selectedProvider === "dataforseo" ? "DataForSEO keyword estimates require a country-level saved set. Create the set without --location, then use a location-specific serp-results report for local result evidence." : "No configured provider can estimate and refresh this keyword set.");
34807
+ }
34808
+ const costEstimator = estimator(resolution.provider);
34809
+ if (!costEstimator) throw new SeoError("PROVIDER_UNAVAILABLE", `${resolution.provider.provider} cannot preview keyword metric costs.`);
34810
+ let cost;
34811
+ try {
34812
+ cost = await costEstimator.estimateKeywordMetricsCost({
34813
+ requestedRows: detail.items.length,
34814
+ market: detail.set.market
34815
+ });
34816
+ } catch (error) {
34817
+ return providerError(error);
34818
+ }
34819
+ if (cost.estimatedMicros === null) throw new SeoError("PROVIDER_UNAVAILABLE", "The provider did not return a usable cost estimate, so no paid refresh was started.");
34820
+ const now = dependencies.now ?? (() => /* @__PURE__ */ new Date());
34821
+ const generatedAt = now().toISOString();
34822
+ const completeSelection = detail.pagination.offset === 0 && detail.items.length === detail.set.keywordCount;
34823
+ const common = {
34824
+ schemaVersion: 1,
34825
+ generatedAt,
34826
+ set: {
34827
+ id: detail.set.id,
34828
+ name: detail.set.name,
34829
+ projectId: detail.set.projectId,
34830
+ totalKeywords: detail.set.keywordCount
34831
+ },
34832
+ selection: {
34833
+ offset: detail.pagination.offset,
34834
+ limit: detail.pagination.limit,
34835
+ selectedKeywords: detail.items.length,
34836
+ nextOffset: detail.pagination.nextOffset,
34837
+ completeness: completeSelection ? "complete" : "capped"
34838
+ },
34839
+ cost
34840
+ };
34841
+ if (!input.execute) return {
34842
+ ...common,
34843
+ mode: "preview",
34844
+ dataStatus: completeSelection ? "complete" : "partial",
34845
+ execution: null,
34846
+ caveats: ["This preview used current provider account pricing and did not start a paid keyword request.", ...completeSelection ? [] : ["The refresh limit covers only part of the saved keyword set."]],
34847
+ nextSteps: ["Review the selected market, keyword count, request count, and estimate before executing the refresh."]
34848
+ };
34849
+ const batches = Array.from({ length: Math.ceil(detail.items.length / REFRESH_BATCH_SIZE) }, (_, index) => detail.items.slice(index * REFRESH_BATCH_SIZE, (index + 1) * REFRESH_BATCH_SIZE));
34850
+ const warnings = [];
34851
+ const errors = [];
34852
+ const actualCosts = [];
34853
+ let savedSnapshots = 0;
34854
+ let completeBatches = 0;
34855
+ let partialBatches = 0;
34856
+ const reportRunId = randomUUID();
34857
+ for (const [index, batch] of batches.entries()) try {
34858
+ const report = await (dependencies.keywordMetrics ?? keywordMetricsReport)({
34859
+ keywords: batch.map((item) => item.keyword),
34860
+ market: detail.set.market,
34861
+ provider: resolution.provider.provider,
34862
+ projectId: detail.set.projectId,
34863
+ refresh: true,
34864
+ context: {
34865
+ reportId: "saved-keyword-refresh",
34866
+ reportRunId
34867
+ }
34868
+ }, {
34869
+ candidates,
34870
+ now
34871
+ });
34872
+ const metrics = new Map(report.evidence.data.map((metric) => [normalizeSavedKeyword(metric.keyword), metric]));
34873
+ const saved = batch.flatMap((item) => {
34874
+ const metric = metrics.get(item.normalizedKeyword);
34875
+ return metric ? [savedMetricItem(metric, report.evidence.provider, report.evidence.observedAt)] : [];
34876
+ });
34877
+ if (saved.length > 0) addKeywordsToSet({
34878
+ projectId: detail.set.projectId,
34879
+ idOrName: detail.set.id,
34880
+ items: saved
34881
+ }, dependencies.store);
34882
+ savedSnapshots += saved.length;
34883
+ actualCosts.push(report.evidence.cost.actualMicros);
34884
+ if (report.dataStatus !== "complete" || report.evidence.coverage.completeness !== "complete" || saved.length !== batch.length) {
34885
+ partialBatches += 1;
34886
+ warnings.push({
34887
+ batch: index + 1,
34888
+ message: saved.length !== batch.length ? `Batch retained ${saved.length} typed snapshots for ${batch.length} keywords.` : `Batch returned ${report.evidence.coverage.completeness} provider coverage for ${batch.length} keywords.`
34889
+ });
34890
+ continue;
34891
+ }
34892
+ completeBatches += 1;
34893
+ } catch (error) {
34894
+ errors.push({
34895
+ batch: index + 1,
34896
+ message: error instanceof Error ? error.message : String(error)
34897
+ });
34898
+ break;
34899
+ }
34900
+ if (errors.length === 0 && partialBatches === 0 && completeSelection) setKeywordSetRefreshTime({
34901
+ projectId: detail.set.projectId,
34902
+ idOrName: detail.set.id,
34903
+ refreshedAt: generatedAt
34904
+ }, dependencies.store);
34905
+ const complete = errors.length === 0 && partialBatches === 0 && completeSelection;
34906
+ return {
34907
+ ...common,
34908
+ mode: "executed",
34909
+ dataStatus: complete ? "complete" : savedSnapshots > 0 ? "partial" : "unavailable",
34910
+ execution: {
34911
+ attemptedBatches: completeBatches + partialBatches + errors.length,
34912
+ completeBatches,
34913
+ partialBatches,
34914
+ failedBatches: errors.length,
34915
+ savedSnapshots,
34916
+ actualMicros: summedCost(actualCosts),
34917
+ warnings,
34918
+ errors
34919
+ },
34920
+ caveats: [
34921
+ "Saved metrics are provider estimates for the set market, not first-party rankings or traffic forecasts.",
34922
+ ...completeSelection ? [] : ["The refresh limit covered only part of the saved keyword set."],
34923
+ ...errors.length ? ["Completed batches were retained; the failed batch and later batches remain stale."] : [],
34924
+ ...partialBatches > 0 ? ["Partial provider batches were retained with explicit missing or invalid states; the set was not marked fully refreshed."] : []
34925
+ ],
34926
+ nextSteps: [complete ? "Compare changed demand and competition evidence with Search Console before changing priorities." : "Resolve the incomplete coverage before treating the set as fully refreshed."]
34927
+ };
34928
+ }
34929
+ function savedMetricItem(metric, provider, observedAt) {
34930
+ return {
34931
+ keyword: metric.keyword,
34932
+ latestMetric: {
34933
+ schemaVersion: 1,
34934
+ provider,
34935
+ observedAt,
34936
+ metric
34937
+ }
34938
+ };
34939
+ }
34940
+ //#endregion
34941
+ //#region packages/core/src/analyze/saved-keywords.ts
34942
+ const DEFAULT_STALE_DAYS = 45;
34943
+ function savedKeywordSetReport(input, dependencies = {}) {
34944
+ const staleDays = input.staleDays ?? DEFAULT_STALE_DAYS;
34945
+ if (!Number.isSafeInteger(staleDays) || staleDays < 1 || staleDays > 365) throw new SeoError("INVALID_INPUT", "Metric freshness must be from 1 to 365 days.");
34946
+ const now = (dependencies.now ?? (() => /* @__PURE__ */ new Date()))();
34947
+ const evidence = (dependencies.getKeywordSet ?? getKeywordSet)(input);
34948
+ const snapshots = evidence.items.filter((item) => item.latestMetric);
34949
+ const observedVolumes = snapshots.filter((item) => item.latestMetric?.metric.monthlySearchVolume.state === "observed");
34950
+ const staleCutoff = now.getTime() - staleDays * 864e5;
34951
+ const staleSnapshots = snapshots.filter((item) => Date.parse(item.latestMetric?.observedAt ?? "") < staleCutoff);
34952
+ const mapped = evidence.items.filter((item) => item.page);
34953
+ const tagGroups = groupedCounts(evidence.items.flatMap((item) => item.tags)).map(([tag, keywordCount]) => ({
34954
+ tag,
34955
+ keywordCount
34956
+ }));
34957
+ const pageMappings = groupedMappings(evidence);
34958
+ const missingMetrics = evidence.items.length - snapshots.length;
34959
+ const unavailableVolumeSnapshots = snapshots.length - observedVolumes.length;
34960
+ const unmapped = evidence.items.length - mapped.length;
34961
+ const partial = evidence.pagination.nextOffset !== null || evidence.pagination.offset > 0 || evidence.pagination.total !== evidence.set.keywordCount;
34962
+ const dataStatus = evidence.items.length === 0 ? "unavailable" : partial ? "partial" : "complete";
34963
+ return {
34964
+ schemaVersion: 1,
34965
+ generatedAt: now.toISOString(),
34966
+ dataStatus,
34967
+ summary: {
34968
+ name: evidence.set.name,
34969
+ totalKeywords: evidence.set.keywordCount,
34970
+ matchedKeywords: evidence.pagination.total,
34971
+ returnedKeywords: evidence.items.length,
34972
+ metricSnapshots: snapshots.length,
34973
+ observedVolumes: observedVolumes.length,
34974
+ observedZeroVolumes: observedVolumes.filter((item) => item.latestMetric?.metric.monthlySearchVolume.value === 0).length,
34975
+ unavailableVolumeSnapshots,
34976
+ staleMetricSnapshots: staleSnapshots.length,
34977
+ mappedKeywords: mapped.length,
34978
+ targetPages: pageMappings.filter((item) => item.kind === "target").length,
34979
+ proposedPages: pageMappings.filter((item) => item.kind === "proposed").length,
34980
+ distinctTags: tagGroups.length,
34981
+ 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.`
34982
+ },
34983
+ evidence,
34984
+ analysis: {
34985
+ metricFreshnessDays: staleDays,
34986
+ tagGroups,
34987
+ pageMappings
34988
+ },
34989
+ findings: [
34990
+ ...missingMetrics > 0 ? [{
34991
+ code: "missing-metrics",
34992
+ evidenceRef: "evidence.items[*].latestMetric",
34993
+ detail: `${missingMetrics} returned keywords have no saved provider metric snapshot.`,
34994
+ action: "Preview a keyword-set refresh before using provider metrics to prioritize them."
34995
+ }] : [],
34996
+ ...unavailableVolumeSnapshots > 0 ? [{
34997
+ code: "unavailable-volume-evidence",
34998
+ evidenceRef: "evidence.items[*].latestMetric.metric.monthlySearchVolume",
34999
+ detail: `${unavailableVolumeSnapshots} saved metric snapshot${unavailableVolumeSnapshots === 1 ? "" : "s"} ${unavailableVolumeSnapshots === 1 ? "has" : "have"} no observed search volume. This is not an observed zero.`,
35000
+ 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."
35001
+ }] : [],
35002
+ ...staleSnapshots.length > 0 ? [{
35003
+ code: "stale-metrics",
35004
+ evidenceRef: "evidence.items[*].latestMetric.observedAt",
35005
+ detail: `${staleSnapshots.length} saved metric snapshots are older than ${staleDays} days.`,
35006
+ action: "Preview and selectively refresh the stale provider evidence."
35007
+ }] : [],
35008
+ ...unmapped > 0 ? [{
35009
+ code: "unmapped-keywords",
35010
+ evidenceRef: "evidence.items[*].page",
35011
+ detail: `${unmapped} returned keywords have no existing or proposed page mapping.`,
35012
+ action: "Review shared intent and current results before mapping terms to an existing page or a proposed page."
35013
+ }] : []
35014
+ ],
35015
+ caveats: [
35016
+ "Tags are user-managed research labels, not proof that keywords share one intent or should use one page.",
35017
+ "Provider metrics are market estimates; missing evidence differs from an observed zero.",
35018
+ "Page mappings record planning state and do not prove that a page ranks, is indexed, or should be created.",
35019
+ ...partial ? ["This view is filtered or paginated and cannot support a complete-set all-clear."] : []
35020
+ ],
35021
+ nextSteps: [
35022
+ "Compare relevant saved terms with Search Console evidence for the project before changing priorities.",
35023
+ "Inspect current results in the same market before turning a tag group into a page or programmatic template.",
35024
+ "Use the refresh preview to check provider cost before updating saved metric snapshots."
35025
+ ]
35026
+ };
35027
+ }
35028
+ function groupedCounts(values) {
35029
+ const counts = /* @__PURE__ */ new Map();
35030
+ for (const value of values) counts.set(value, (counts.get(value) ?? 0) + 1);
35031
+ return [...counts].sort(([left, leftCount], [right, rightCount]) => rightCount - leftCount || (left < right ? -1 : left > right ? 1 : 0)).slice(0, 50);
35032
+ }
35033
+ function groupedMappings(evidence) {
35034
+ const counts = /* @__PURE__ */ new Map();
35035
+ for (const item of evidence.items) {
35036
+ if (!item.page) continue;
35037
+ const key = `${item.page.kind}\u0000${item.page.url}`;
35038
+ const current = counts.get(key);
35039
+ counts.set(key, {
35040
+ ...item.page,
35041
+ count: (current?.count ?? 0) + 1
35042
+ });
35043
+ }
35044
+ 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 }) => ({
35045
+ ...mapping,
35046
+ keywordCount: count
35047
+ }));
35048
+ }
35049
+ //#endregion
32907
35050
  //#region packages/core/src/analyze/second-page.ts
32908
35051
  const MAX_SOURCE_ROWS = 1e5;
32909
35052
  const defaultDependencies = {
@@ -37546,6 +39689,7 @@ const TELEMETRY_REPORTS = [
37546
39689
  "keyword-metrics",
37547
39690
  "keyword-opportunities",
37548
39691
  "keyword-research",
39692
+ "saved-keywords",
37549
39693
  "link-evidence",
37550
39694
  "server-log-analysis",
37551
39695
  "link-recovery",
@@ -37563,6 +39707,7 @@ const TELEMETRY_REPORTS = [
37563
39707
  "pseo-opportunities",
37564
39708
  "query-clusters",
37565
39709
  "quick-wins",
39710
+ "rank-tracking",
37566
39711
  "redirect-trace",
37567
39712
  "narrative-report",
37568
39713
  "second-page",
@@ -37891,6 +40036,6 @@ function isoDate(daysAgo = 0) {
37891
40036
  return date.toISOString().slice(0, 10);
37892
40037
  }
37893
40038
  //#endregion
37894
- export { AI_REFERRAL_SOURCES, AI_REFERRAL_SOURCES_VERSION, AI_SEARCH_SCORECARD_METHODOLOGY_ID, AI_SEARCH_SCORECARD_METHODOLOGY_VERSION, AUDIT_PAGE_RULE_IDS, AUTOMATIC_BASELINE_SITE_RETENTION, AUTOMATIC_BASELINE_TOTAL_RETENTION, BING_API_KEY_ENV, BING_API_KEY_SECRET, BROWSER_USER_AGENT, BingWebmasterClient, CANNIBAL_MAXIMUM_DOMINANT_SHARE, CANNIBAL_MINIMUM_PAGE_SHARE, CRAWL_REPORT_STORAGE_VERSION, CRAWL_RUN_RETENTION, CRAWL_SKIP_REASONS, CTR_DEFAULT_LIMIT, CTR_DEFAULT_MIN_IMPRESSIONS, CTR_MAX_LIMIT, CTR_MAX_MIN_IMPRESSIONS, DATAFORSEO_CREDENTIAL_SECRET, DATAFORSEO_LOGIN_ENV, DATAFORSEO_PASSWORD_ENV, DEFAULT_PROVIDER_SPEND_LIMITS, DataForSeoClient, DataForSeoKeywordDiscoveryProvider, DataForSeoKeywordMetricsProvider, DataForSeoProvider, DataForSeoSerpSnapshotProvider, GSC_FINAL_DATA_LAG_DAYS, GSC_TIME_ZONE, INDEXNOW_ENDPOINT, INDEXNOW_KEYS_SECRET, INDEXNOW_KEY_ENV, INDEXNOW_MAX_URLS, INDEX_WATCH_ATTEMPT_RETENTION, INTERNAL_LINK_LEXICAL_TARGET_LIMIT, INTERNAL_LINK_MATCH_EVIDENCE_LIMIT, JsonFileStorageAdapter, LINK_RECOVER_RUN_RETENTION, LOG_MAX_AGE_MS, LOG_MAX_FILE_BYTES, LOG_MAX_TOTAL_BYTES, LocalCredentialsProvider, MAX_CRAWL_CONCURRENCY, MAX_CRAWL_DEPTH, MAX_CRAWL_PAGES, MAX_CRAWL_TIMEOUT_MS, OKF_DEFAULT_CONCEPTS, OKF_MAX_CONCEPTS, PROVIDER_SPEND_MAX_BYTES, PROVIDER_SPEND_MAX_ROWS, PROVIDER_SPEND_RETENTION_DAYS, ProviderError, REPORT_BASELINE_MAX_AGE_MS, REPORT_BASELINE_MAX_DEPTH, REPORT_BASELINE_MAX_PAGES, ResponseSizeLimitError, SCORECARD_CHECK_WEIGHTS, SCORECARD_STATUS_CREDIT, SEO_CRAWLER_IDENTITY, SEO_CRAWLER_TOKEN, SEO_CRAWLER_USER_AGENT, SEO_VERSION, SERP_TITLE_REFERENCE_WIDTH_PX, SERP_TITLE_WIDTH_PROFILE, SemrushProvider, SeoError, ServiceAccountAccessTokenClient, SessionLedger, TELEMETRY_AGENTS, TELEMETRY_ENDPOINT, TELEMETRY_ERROR_CATEGORIES, TELEMETRY_EVENTS, TELEMETRY_NOTICE, TELEMETRY_REPORTS, TELEMETRY_SCHEMA_VERSION, TELEMETRY_TIMEOUT_MS, acquirePseoAuditEvidence, addStatus, affectedUrls, agentReadiness, aiPromptsForQuery, aiReadiness, aiReferralSourceForValue, aiReferralsReport, aiSearchScorecard, allocateIndexUrlsToProperties, analyticsConnectionsSchema, analyzeAiReferralRows, analyzeBingCrawl, analyzeBingDimensions, analyzeBingTraffic, analyzeCannibalRows, analyzeCtrUnderperformersFromRows, analyzeDecay, analyzeIndexCoverageSignals, analyzeInternalLinksFromRows, analyzeKeywordTrend, analyzePageOpportunitiesFromRows, analyzeQueryClustersFromRows, analyzeQuickWinsFromRows, analyzeSecondPageRows, analyzeSegmentRows, analyzeStrikingDistanceRows, analyzeTrafficAnomalyRows, assertCrawlConfigLimits, assertUrlMatchesGscProperty, attachCrawlRecommendations, auditCrawlPages, auditCrawlRequests, auditLlmsTxt, auditPage, authStatus, bingAnalysisDefaults, bingWebmasterOverview, bingWebmasterSiteUrl, boundedSitemapInventory, buildOkfBundle, buildPseoAuditFromAcquisition, buildPseoAuditReportFromRows, buildQueryClusterReportFromRows, buildTelemetryPayload, cannibalReport, changeMeasurementCsvRows, checkDatabaseReadiness, classifyCommunityIntent, classifyCrawler, clearCache, clientProfileSchema, clusterPseoTemplates, clusterQueryRows, collectAgentDiscovery, collectBingLinkEvidence, combineRobotsValues, communityIntentReport, compareCrawlPages, compareCrawlReports, compareLinkEvidence, compareSegmentRows, configSchema, contentCoverageRecommendation, contentCoverageType, contentOptimizationFromPageOpportunities, contentOptimizationReport, countLabel, crawlConfigHash, crawlConfigSchema, crawlDefinitionId, crawlDiff, crawlFixToQueueItem, crawlImplementationQueueWorkflow, crawlIssueGroupSchema, crawlIssueSchema, crawlOne, crawlPageSnapshotSchema, crawlReportSchema, crawlReportStore, crawlReportSummarySchema, crawlRequestObservationSchema, crawlRunId, crawlSite, crawlSkipImpact, crawlSkipReasonCountsFromRecord, crawlStatusOnly, crawlTopFixSchema, crawlerJsonSchemas, crawlerRuleCategorySchema, crawlerRuleIdSchema, crawlerRuleInfoSchema, crawlerRuleRecommendationSchema, crawlerRuleSeveritySchema, crawlerSchemas, createAuthorizedClient, createBingWebmasterClient, createContentGroup, createCrawlReport, createCtrBenchmarkContext, createGoogleAccessTokenClient, createIndexNowKey, createIndexNowKeyRecord, createLogger, createPageRenderer, ctrUnderperformersReport, decayClusterDrafts, decayComparisonRange, decayingReport, deleteChange, deleteClient, deleteContentGroup, deleteCrawlReport, deleteDataForSeoCredentials, deleteProviderSecret, deleteTokens, deriveBrandTerms, detectBrandTerms, detectTelemetryAgent, diagnoseCsvFiles, diagnoseProperty, diagnosePropertyWorkflow, effectiveRobotsDirectives, effectiveSnippetControl, emptyStatusBreakdown, ensurePrivateDatabaseFiles, ensureSeoCliDirs, entityReadiness, estimateSerpTitleWidth, expectedCtrForPosition, explainOkfValidation, explainRule, fetchCruxFieldData, fetchPage, fetchPageStatus, fetchSearchStatusIncidents, fetchSitemapUrls, fetchText, fileSize, finalGscDateRange, finalizeProviderSpend, findOverlappingSearchUpdates, formatRelativeExpiry, ga4MatchReason, ga4PropertyIdFromName, ga4ReportQualityWarnings, ga4RequestCanUseCache, ga4RowsToObjects, generateLlmsTxt, geoGaps, geoGapsReport, getAuthModeStatus, getCacheStats, getChange, getClient, getClientConfig, getContentGroup, getDb, getKeywordProvider, getPreviousRun, getPrivacySnapshot, getProviderSpendLimits, getProviderSpendSummary, getRepeatedLinkRecoverUrls, getRunPages, getSeoCliPaths, getServiceAccountConfig, getServiceAccountStatus, getTelemetryStatus, getTokenStorageStatus, googleAnalyticsPropertyId, groupCrawlIssues, hasMetaRobotsDirective, hasRule, hasXRobotsDirective, hashKey, importLinkEvidence, importServerLog, indexCoveragePlan, indexCoverageSignals, indexMonitor, indexWatch, inferPseoTemplate, inferTemplateMovement, initializeTelemetry, insertCrawlRun, insertLinkRecoverRun, inspectUrl, internalLinksReport, interpretUpdateCorrelation, isBrandQuery, isCompatibleTechnicalBaseline, isCurrentTechnicalBaseline, isEntityEvidenceSchemaType, isSiteIdentitySchemaType, isSkippableReportError, isTelemetryCi, isTelemetryReport, isoDate, isoWeek, keywordDiscoverySourceSchema, keywordMetricsReport, keywordOpportunitiesReport, keywordResearchReport, latestCrawlReport, latestCrawlSummaries, latestFinalGscDate, latestIndexWatchSummary, latestLinkRecoverSummary, linkEntries, linkEvidenceKey, linkEvidenceReport, linkRecover, linkRecoverRecommendation, listChanges, listClients, listContentGroups, listCrawlReports, listGa4AccountSummaries, listGa4DataStreams, listIndexNowKeys, listRules, listSearchUpdates, listSites, loadCrawlReport, localCredentialsProvider, loginWithLoopback, makeRunId, matchGa4WebStreams, measureChange, measureCoverage, metaRobotsDirectives, migrateLegacyMacConfig, missingOAuthClientMessage, missingSharedClientLoginMessage, monitoringStatus, monthlyReport, monthlyReportWorkflow, narrativeCsvFiles, normalizeCrawlConfig, normalizeCrawlSkipReasonCounts, normalizeForCoverage, normalizeHttpUrl, normalizeJavaScriptRenderingMode, normalizeLinkEvidenceRow, normalizeLoadedCrawlReport, noteCacheWrite, observedValue, okfConceptLimit, pageEvidenceRecommendation, pageOpportunitiesReport, parseCombinedLogLine, parseIndexNowSite, parseJsonLogLine, parseOlderThan, parsePseoPath, performanceAudit, performanceReportIsCacheable, planDueIndexUrls, planIndexCoverageFromUrls, plural, propertyMatchesUrl, providerCapabilitySchema, providerIdSchema, providerPreferenceSchema, providerRequestJson, providerRequestText, providerSpendLimitOverridesSchema, pruneIndexWatchSnapshots, pruneLogs, pseoAuditOptions, pseoAuditReport, pseoCsvFiles, pseoIndexStatus, pseoOpportunitiesReport, pseoPresentation, publicHttpFetch, queryClusterReport, queryClusterTokens, queryContentCoverageFromPage, queryContentFieldsFromPage, queryFieldCoverage, queryOpportunityRecommendation, queryPageMetrics, queryPageTopQuery, queryPagesMetrics, queryPagesMetricsBatch, queryPagesTopQueries, queryPagesTopQueriesBatch, querySearchAnalytics, quickWinsCsvRows, quickWinsReport, readBoundedResponseText, readConfig, readDataForSeoCredentials, readOauthClient, readProviderSecret, readTokens, recommendCrawlDiffItem, recommendationForRule, recordChange, redirectTrace, refreshAuthToken, refreshPrioritiesCsvFiles, refreshPrioritiesWorkflow, removeIndexNowKey, renderCrawlCsv, renderCrawlHtml, renderCrawlJunit, renderCrawlMarkdownTickets, renderCrawlPagesCsv, renderCrawlPretty, renderCsv, renderPresentationTablesMarkdown, renderPseoMarkdown, renderReportHtml, renderWorkflowMarkdown, reportNarrative, reportPresentation, requestHeaders, reserveProviderSpend, resolveIndexNowKey, resolveProvider, resolveSegmentRanges, resolveSeoCliPaths, resolveTechnicalBaseline, reviewObservations, roundedPosition, runDoctor, runGa4Report, safeError, saveClient, saveCrawlReport, saveIndexNowKey, searchMarketSchema, secondPage, segmentComparisonRange, segmentImpact, segmentRangeDays, selectAffectedUrls, selectDueIndexUrls, seoErrorEnvelope, seoToAiQueryReport, serpResultsReport, serverLogReport, sessionSourceInListFilter, setClientBingSite, setDefaultClient, setProviderSpendLimits, setTelemetryEnabled, setTokenStorageMode, shiftIsoDate, shouldExcludeBrandQuery, siteSchema, sqliteCrawlReportStore, strikingDistance, submitIndexNow, summarizeCrawlReport, pageTechnicalSignals as technicalPageSignals, technicalWatchWorkflow, telemetryErrorCategory, templateForUrl, toSeoError, tokenSchema, topCrawlRecommendations, topFixes, trackTelemetryReportComplete, trackTelemetryReportFailed, trackTelemetryReportStart, trackTelemetrySetupComplete, trafficAnomaly, unavailableSegmentImpactReport, unavailableValue, unicodeTokens, updateCorrelation, updatePostmortemCsvFiles, updatePostmortemWorkflow, validateIndexNowKey, validateIndexNowKeyRecord, validateOkfFiles, verifyIndexNowKey, verifyQueryContent, workflowPresentation, writeConfig, writeDataForSeoCredentials, writeOauthClient, writeProviderSecret, writeTokens, xRobotsDirectives };
40039
+ export { AI_REFERRAL_SOURCES, AI_REFERRAL_SOURCES_VERSION, AI_SEARCH_SCORECARD_METHODOLOGY_ID, AI_SEARCH_SCORECARD_METHODOLOGY_VERSION, AUDIT_PAGE_RULE_IDS, AUTOMATIC_BASELINE_SITE_RETENTION, AUTOMATIC_BASELINE_TOTAL_RETENTION, BING_API_KEY_ENV, BING_API_KEY_SECRET, BROWSER_USER_AGENT, BingWebmasterClient, CANNIBAL_MAXIMUM_DOMINANT_SHARE, CANNIBAL_MINIMUM_PAGE_SHARE, CRAWL_REPORT_STORAGE_VERSION, CRAWL_RUN_RETENTION, CRAWL_SKIP_REASONS, CTR_DEFAULT_LIMIT, CTR_DEFAULT_MIN_IMPRESSIONS, CTR_MAX_LIMIT, CTR_MAX_MIN_IMPRESSIONS, DATAFORSEO_CREDENTIAL_SECRET, DATAFORSEO_LOGIN_ENV, DATAFORSEO_PASSWORD_ENV, DEFAULT_PROVIDER_SPEND_LIMITS, DataForSeoClient, DataForSeoKeywordDiscoveryProvider, DataForSeoKeywordMetricsProvider, DataForSeoProvider, DataForSeoRankTrackingCollector, DataForSeoSerpSnapshotProvider, GSC_FINAL_DATA_LAG_DAYS, GSC_TIME_ZONE, INDEXNOW_ENDPOINT, INDEXNOW_KEYS_SECRET, INDEXNOW_KEY_ENV, INDEXNOW_MAX_URLS, INDEX_WATCH_ATTEMPT_RETENTION, INTERNAL_LINK_LEXICAL_TARGET_LIMIT, INTERNAL_LINK_MATCH_EVIDENCE_LIMIT, JsonFileStorageAdapter, KEYWORD_SET_FIELD_LIMITS, KEYWORD_SET_LIMITS, KEYWORD_SET_SCHEMA_SQL, LINK_RECOVER_RUN_RETENTION, LOG_MAX_AGE_MS, LOG_MAX_FILE_BYTES, LOG_MAX_TOTAL_BYTES, LocalCredentialsProvider, MAX_CRAWL_CONCURRENCY, MAX_CRAWL_DEPTH, MAX_CRAWL_PAGES, MAX_CRAWL_TIMEOUT_MS, OKF_DEFAULT_CONCEPTS, OKF_MAX_CONCEPTS, PROVIDER_SPEND_MAX_BYTES, PROVIDER_SPEND_MAX_ROWS, PROVIDER_SPEND_RETENTION_DAYS, ProviderError, RANK_TRACKING_LIMITS, RANK_TRACKING_SCHEMA_SQL, REPORT_BASELINE_MAX_AGE_MS, REPORT_BASELINE_MAX_DEPTH, REPORT_BASELINE_MAX_PAGES, ResponseSizeLimitError, SCORECARD_CHECK_WEIGHTS, SCORECARD_STATUS_CREDIT, SEO_CRAWLER_IDENTITY, SEO_CRAWLER_TOKEN, SEO_CRAWLER_USER_AGENT, SEO_VERSION, SERP_TITLE_REFERENCE_WIDTH_PX, SERP_TITLE_WIDTH_PROFILE, SemrushProvider, SeoError, ServiceAccountAccessTokenClient, SessionLedger, TELEMETRY_AGENTS, TELEMETRY_ENDPOINT, TELEMETRY_ERROR_CATEGORIES, TELEMETRY_EVENTS, TELEMETRY_NOTICE, TELEMETRY_REPORTS, TELEMETRY_SCHEMA_VERSION, TELEMETRY_TIMEOUT_MS, acquirePseoAuditEvidence, activeRankTrackingRun, addKeywordsToSet, addStatus, affectedUrls, agentReadiness, aiPromptsForQuery, aiReadiness, aiReferralSourceForValue, aiReferralsReport, aiSearchScorecard, allocateIndexUrlsToProperties, analyticsConnectionsSchema, analyzeAiReferralRows, analyzeBingCrawl, analyzeBingDimensions, analyzeBingTraffic, analyzeCannibalRows, analyzeCtrUnderperformersFromRows, analyzeDecay, analyzeIndexCoverageSignals, analyzeInternalLinksFromRows, analyzeKeywordTrend, analyzePageOpportunitiesFromRows, analyzeQueryClustersFromRows, analyzeQuickWinsFromRows, analyzeSecondPageRows, analyzeSegmentRows, analyzeStrikingDistanceRows, analyzeTrafficAnomalyRows, assertCrawlConfigLimits, assertUrlMatchesGscProperty, attachCrawlRecommendations, auditCrawlPages, auditCrawlRequests, auditLlmsTxt, auditPage, authStatus, bingAnalysisDefaults, bingWebmasterOverview, bingWebmasterSiteUrl, boundedSitemapInventory, buildOkfBundle, buildPseoAuditFromAcquisition, buildPseoAuditReportFromRows, buildQueryClusterReportFromRows, buildTelemetryPayload, cannibalReport, changeMeasurementCsvRows, checkDatabaseReadiness, classifyCommunityIntent, classifyCrawler, clearCache, clientProfileSchema, clusterPseoTemplates, clusterQueryRows, collectAgentDiscovery, collectBingLinkEvidence, combineRobotsValues, communityIntentReport, compareCrawlPages, compareCrawlReports, compareLinkEvidence, compareSegmentRows, configSchema, contentCoverageRecommendation, contentCoverageType, contentOptimizationFromPageOpportunities, contentOptimizationReport, countLabel, crawlConfigHash, crawlConfigSchema, crawlDefinitionId, crawlDiff, crawlFixToQueueItem, crawlImplementationQueueWorkflow, crawlIssueGroupSchema, crawlIssueSchema, crawlOne, crawlPageSnapshotSchema, crawlReportSchema, crawlReportStore, crawlReportSummarySchema, crawlRequestObservationSchema, crawlRunId, crawlSite, crawlSkipImpact, crawlSkipReasonCountsFromRecord, crawlStatusOnly, crawlTopFixSchema, crawlerJsonSchemas, crawlerRuleCategorySchema, crawlerRuleIdSchema, crawlerRuleInfoSchema, crawlerRuleRecommendationSchema, crawlerRuleSeveritySchema, crawlerSchemas, createAuthorizedClient, createBingWebmasterClient, createContentGroup, createCrawlReport, createCtrBenchmarkContext, createGoogleAccessTokenClient, createIndexNowKey, createIndexNowKeyRecord, createKeywordSet, createLogger, createPageRenderer, ctrUnderperformersReport, decayClusterDrafts, decayComparisonRange, decayingReport, deleteChange, deleteClient, deleteContentGroup, deleteCrawlReport, deleteDataForSeoCredentials, deleteKeywordSet, deleteProviderSecret, deleteTokens, deriveBrandTerms, detectBrandTerms, detectTelemetryAgent, diagnoseCsvFiles, diagnoseProperty, diagnosePropertyWorkflow, effectiveRobotsDirectives, effectiveSnippetControl, emptyStatusBreakdown, enforceRankTrackingLogicalBytes, ensurePrivateDatabaseFiles, ensureSeoCliDirs, entityReadiness, estimateSerpTitleWidth, executeRankTracking, expectedCtrForPosition, explainOkfValidation, explainRule, failRankTrackingTask, fetchCruxFieldData, fetchPage, fetchPageStatus, fetchSearchStatusIncidents, fetchSitemapUrls, fetchText, fileSize, finalGscDateRange, finalizeProviderSpend, findOverlappingSearchUpdates, formatRelativeExpiry, ga4MatchReason, ga4PropertyIdFromName, ga4ReportQualityWarnings, ga4RequestCanUseCache, ga4RowsToObjects, generateLlmsTxt, geoGaps, geoGapsReport, getAuthModeStatus, getCacheStats, getChange, getClient, getClientConfig, getContentGroup, getDb, getKeywordProvider, getKeywordSet, getOrCreateRankTrackingConfiguration, getPreviousRun, getPrivacySnapshot, getProviderSpendLimits, getProviderSpendSummary, getRankTrackingRun, getRepeatedLinkRecoverUrls, getRunPages, getSeoCliPaths, getServiceAccountConfig, getServiceAccountStatus, getTelemetryStatus, getTokenStorageStatus, googleAnalyticsPropertyId, groupCrawlIssues, hasMetaRobotsDirective, hasRule, hasXRobotsDirective, hashKey, importLinkEvidence, importServerLog, indexCoveragePlan, indexCoverageSignals, indexMonitor, indexWatch, inferPseoTemplate, inferTemplateMovement, initializeTelemetry, insertCrawlRun, insertLinkRecoverRun, inspectUrl, internalLinksReport, interpretUpdateCorrelation, isBrandQuery, isCompatibleTechnicalBaseline, isCurrentTechnicalBaseline, isEntityEvidenceSchemaType, isSiteIdentitySchemaType, isSkippableReportError, isTelemetryCi, isTelemetryReport, isoDate, isoWeek, keywordDiscoverySourceSchema, keywordMetricsReport, keywordOpportunitiesReport, keywordResearchReport, keywordSetLogicalBytes, latestCrawlReport, latestCrawlSummaries, latestFinalGscDate, latestIndexWatchSummary, latestLinkRecoverSummary, latestRankTrackingRun, linkEntries, linkEvidenceKey, linkEvidenceReport, linkRecover, linkRecoverRecommendation, listChanges, listClients, listContentGroups, listCrawlReports, listGa4AccountSummaries, listGa4DataStreams, listIndexNowKeys, listKeywordSets, listRules, listSearchUpdates, listSites, loadCrawlReport, localCredentialsProvider, loginWithLoopback, makeRunId, mapDataForSeoSerpSnapshot, markRankTasksPosted, markRankTasksPosting, matchGa4WebStreams, measureChange, measureCoverage, metaRobotsDirectives, migrateLegacyMacConfig, missingOAuthClientMessage, missingSharedClientLoginMessage, monitoringStatus, monthlyReport, monthlyReportWorkflow, narrativeCsvFiles, normalizeCrawlConfig, normalizeCrawlSkipReasonCounts, normalizeForCoverage, normalizeHttpUrl, normalizeJavaScriptRenderingMode, normalizeLinkEvidenceRow, normalizeLoadedCrawlReport, normalizeSavedKeyword, noteCacheWrite, observedValue, okfConceptLimit, pageEvidenceRecommendation, pageOpportunitiesReport, parseCombinedLogLine, parseIndexNowSite, parseJsonLogLine, parseOlderThan, parsePseoPath, performanceAudit, performanceReportIsCacheable, planDueIndexUrls, planIndexCoverageFromUrls, plural, priorComparableRankTrackingRun, propertyMatchesUrl, providerCapabilitySchema, providerIdSchema, providerPreferenceSchema, providerRequestJson, providerRequestText, providerSpendLimitOverridesSchema, pruneIndexWatchSnapshots, pruneLogs, pseoAuditOptions, pseoAuditReport, pseoCsvFiles, pseoIndexStatus, pseoOpportunitiesReport, pseoPresentation, publicHttpFetch, queryClusterReport, queryClusterTokens, queryContentCoverageFromPage, queryContentFieldsFromPage, queryFieldCoverage, queryOpportunityRecommendation, queryPageMetrics, queryPageTopQuery, queryPagesMetrics, queryPagesMetricsBatch, queryPagesTopQueries, queryPagesTopQueriesBatch, querySearchAnalytics, quickWinsCsvRows, quickWinsReport, rankObservations, rankTrackingLogicalBytes, rankTrackingReport, rankTrackingTasks, readBoundedResponseText, readConfig, readDataForSeoCredentials, readOauthClient, readProviderSecret, readTokens, recommendCrawlDiffItem, recommendationForRule, recordChange, recoverRankTaskReceipt, redirectTrace, refreshAuthToken, refreshKeywordSet, refreshPrioritiesCsvFiles, refreshPrioritiesWorkflow, refreshRankTrackingRun, removeIndexNowKey, removeKeywordsFromSet, renderCrawlCsv, renderCrawlHtml, renderCrawlJunit, renderCrawlMarkdownTickets, renderCrawlPagesCsv, renderCrawlPretty, renderCsv, renderPresentationTablesMarkdown, renderPseoMarkdown, renderReportHtml, renderWorkflowMarkdown, reportNarrative, reportPresentation, requestHeaders, reserveProviderSpend, resolveIndexNowKey, resolveProvider, resolveSegmentRanges, resolveSeoCliPaths, resolveTechnicalBaseline, reviewObservations, roundedPosition, runDoctor, runGa4Report, safeError, saveClient, saveCrawlReport, saveIndexNowKey, saveRankObservation, saveRankObservations, savedKeywordSetReport, searchMarketSchema, secondPage, segmentComparisonRange, segmentImpact, segmentRangeDays, selectAffectedUrls, selectDueIndexUrls, seoErrorEnvelope, seoToAiQueryReport, serpResultsReport, serverLogReport, sessionSourceInListFilter, setClientBingSite, setDefaultClient, setKeywordSetRefreshTime, setProviderSpendLimits, setTelemetryEnabled, setTokenStorageMode, shiftIsoDate, shouldExcludeBrandQuery, siteSchema, sqliteCrawlReportStore, startRankTrackingRun, strikingDistance, submitIndexNow, summarizeCrawlReport, targetMatchesDomain, pageTechnicalSignals as technicalPageSignals, technicalWatchWorkflow, telemetryErrorCategory, templateForUrl, toSeoError, tokenSchema, topCrawlRecommendations, topFixes, trackTelemetryReportComplete, trackTelemetryReportFailed, trackTelemetryReportStart, trackTelemetrySetupComplete, trafficAnomaly, unavailableSegmentImpactReport, unavailableValue, unicodeTokens, updateCorrelation, updatePostmortemCsvFiles, updatePostmortemWorkflow, validateIndexNowKey, validateIndexNowKeyRecord, validateOkfFiles, verifyIndexNowKey, verifyQueryContent, workflowPresentation, writeConfig, writeDataForSeoCredentials, writeOauthClient, writeProviderSecret, writeTokens, xRobotsDirectives };
37895
40040
 
37896
40041
  //# sourceMappingURL=index.js.map