seo 0.2.16 → 0.2.18

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-D_ix15KL.js";
2
+ import { n as load, t as version } from "./package-DLnOXW-d.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";
@@ -1148,6 +1148,111 @@ CREATE INDEX IF NOT EXISTS idx_keyword_set_tags_lookup
1148
1148
  ON keyword_set_tags(set_id, tag, normalized_keyword);
1149
1149
  `;
1150
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
1151
1256
  //#region packages/core/src/storage/cache-maintenance.ts
1152
1257
  const MEBIBYTE$2 = 1024 * 1024;
1153
1258
  const CACHE_MAX_BYTES = 256 * MEBIBYTE$2;
@@ -1704,6 +1809,7 @@ function initDb(database, isNewDatabase) {
1704
1809
  database.transaction(() => {
1705
1810
  database.exec(CREATE_SQL);
1706
1811
  database.exec(KEYWORD_SET_SCHEMA_SQL);
1812
+ database.exec(RANK_TRACKING_SCHEMA_SQL);
1707
1813
  database.exec(PROVIDER_SPEND_SCHEMA_SQL);
1708
1814
  database.exec(`DELETE FROM semrush_cache WHERE request_json LIKE '%"key"%'`);
1709
1815
  ensureColumn(database, "crawl_pages", "snapshot_json", "TEXT");
@@ -4937,7 +5043,7 @@ async function fetchPage(url, opts = {}, dependencies = {}) {
4937
5043
  }
4938
5044
  //#endregion
4939
5045
  //#region packages/core/src/gsc/property-url.ts
4940
- function invalid$1(message) {
5046
+ function invalid$2(message) {
4941
5047
  throw new SeoError("INVALID_INPUT", message);
4942
5048
  }
4943
5049
  function normalizeHttpUrl(value) {
@@ -4945,10 +5051,10 @@ function normalizeHttpUrl(value) {
4945
5051
  try {
4946
5052
  url = new URL(value.trim());
4947
5053
  } catch {
4948
- return invalid$1(`Invalid URL: ${value}`);
5054
+ return invalid$2(`Invalid URL: ${value}`);
4949
5055
  }
4950
- if (url.protocol !== "http:" && url.protocol !== "https:") return invalid$1("The report URL must use http:// or https://.");
4951
- if (url.username || url.password) return invalid$1("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.");
4952
5058
  url.hash = "";
4953
5059
  return url.toString();
4954
5060
  }
@@ -4957,13 +5063,13 @@ function assertUrlMatchesGscProperty(property, value) {
4957
5063
  const parsed = new URL(url);
4958
5064
  if (property.startsWith("sc-domain:")) {
4959
5065
  const domain = property.slice(10).trim().toLowerCase();
4960
- if (!domain || /[/:]/.test(domain)) return invalid$1(`Invalid Search Console domain property: ${property}`);
5066
+ if (!domain || /[/:]/.test(domain)) return invalid$2(`Invalid Search Console domain property: ${property}`);
4961
5067
  const hostname = parsed.hostname.toLowerCase();
4962
- if (hostname !== domain && !hostname.endsWith(`.${domain}`)) return invalid$1(`${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}.`);
4963
5069
  return url;
4964
5070
  }
4965
5071
  const prefix = normalizeHttpUrl(property);
4966
- if (!url.startsWith(prefix)) return invalid$1(`${url} is outside the Search Console property ${property}.`);
5072
+ if (!url.startsWith(prefix)) return invalid$2(`${url} is outside the Search Console property ${property}.`);
4967
5073
  return url;
4968
5074
  }
4969
5075
  //#endregion
@@ -7170,7 +7276,7 @@ function selectBenchmarkRows$1(input) {
7170
7276
  function compareText$15(left, right) {
7171
7277
  return left < right ? -1 : left > right ? 1 : 0;
7172
7278
  }
7173
- function compareItems$1(left, right) {
7279
+ function compareItems$2(left, right) {
7174
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);
7175
7281
  }
7176
7282
  function analyzeRow(input) {
@@ -7235,7 +7341,7 @@ function analyzePageOpportunitiesFromRows(input) {
7235
7341
  reportInput: input,
7236
7342
  benchmarkContext,
7237
7343
  excludedTargetRows
7238
- })).sort(compareItems$1).slice(0, limit);
7344
+ })).sort(compareItems$2).slice(0, limit);
7239
7345
  const estimatedCtrClickShortfall = Number(items.reduce((sum, item) => sum + (item.estimatedCtrClickShortfall ?? 0), 0).toFixed(2));
7240
7346
  return {
7241
7347
  site: input.site,
@@ -18764,7 +18870,7 @@ function compareRetainedSegmentRows(input) {
18764
18870
  }
18765
18871
  //#endregion
18766
18872
  //#region packages/core/src/analyze/segment-impact-analysis.ts
18767
- function verdict$4(input) {
18873
+ function verdict$5(input) {
18768
18874
  if (input.status === "empty") return "Search Console returned no retained segment rows for either window.";
18769
18875
  if (!input.matchedRows) return "No segment was retained in both windows, so no movement delta was inferred.";
18770
18876
  const suffix = input.unmatchedRows ? ` ${input.unmatchedRows} one-window rows were kept as unmatched evidence and not converted to zero.` : "";
@@ -18857,7 +18963,7 @@ function analyzeSegmentRows(input) {
18857
18963
  matchedRows: matched.length,
18858
18964
  returnedRows: selection.returnedRows,
18859
18965
  unmatchedRows: unmatched.length,
18860
- verdict: verdict$4({
18966
+ verdict: verdict$5({
18861
18967
  status: dataStatus,
18862
18968
  matchedRows: matched.length,
18863
18969
  returnedRows: selection.returnedRows,
@@ -19670,7 +19776,7 @@ const defaultDependencies$10 = {
19670
19776
  searchAnalytics: querySearchAnalytics,
19671
19777
  now: () => /* @__PURE__ */ new Date()
19672
19778
  };
19673
- function verdict$3(input) {
19779
+ function verdict$4(input) {
19674
19780
  if (input.status === "empty") return "Search Console returned no retained query/page rows for this window.";
19675
19781
  if (!input.eligible) {
19676
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.";
@@ -19797,7 +19903,7 @@ async function cannibalReport(input, dependencies = defaultDependencies$10) {
19797
19903
  returnedClusters: analysis.selection.returnedClusters,
19798
19904
  suppressedQueries: analysis.selection.suppressedQueries,
19799
19905
  brandFiltering: input.includeBrand ? "included" : "excluded",
19800
- verdict: verdict$3({
19906
+ verdict: verdict$4({
19801
19907
  status: dataStatus,
19802
19908
  eligible: analysis.selection.eligibleClusters,
19803
19909
  returned: analysis.selection.returnedClusters
@@ -20083,7 +20189,7 @@ const defaultDependencies$9 = {
20083
20189
  searchAnalytics: querySearchAnalytics,
20084
20190
  now: () => /* @__PURE__ */ new Date()
20085
20191
  };
20086
- function verdict$2(report) {
20192
+ function verdict$3(report) {
20087
20193
  if (report.dataStatus === "empty") return "Search Console returned no retained query/page rows for either comparison window.";
20088
20194
  if (report.dataStatus === "partial" && report.selection.currentAggregatedRows === 0) return "The current retained-row window is empty, so no query/page losses were inferred.";
20089
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.";
@@ -20228,7 +20334,7 @@ async function decayingReport(input, dependencies = defaultDependencies$9) {
20228
20334
  observedRetainedQueryClickLoss: analysis.totals.eligibleObservedRetainedQueryClickLoss,
20229
20335
  returnedObservedRetainedQueryClickLoss: analysis.totals.returnedObservedRetainedQueryClickLoss,
20230
20336
  brandFiltering: input.includeBrand ? "included" : "excluded",
20231
- verdict: verdict$2(reportBase)
20337
+ verdict: verdict$3(reportBase)
20232
20338
  },
20233
20339
  caveats: [
20234
20340
  `Current window: ${currentRange.startDate} to ${currentRange.endDate}; comparison: ${previousRange.startDate} to ${previousRange.endDate}.`,
@@ -21022,7 +21128,7 @@ function templatesFromGroups(groups) {
21022
21128
  sampleUrls: group.sampleUrls
21023
21129
  }));
21024
21130
  }
21025
- function verdict$1(analysis) {
21131
+ function verdict$2(analysis) {
21026
21132
  if (analysis.dataStatus === "empty") return "Search Console returned no retained query/page rows for this date window.";
21027
21133
  if (analysis.dataStatus === "filtered") return "No retained query/page rows met the report filters.";
21028
21134
  return `${countLabel(analysis.summary.eligibleRows, "eligible query/page row")} found; ${countLabel(analysis.summary.returnedRows, "row")} returned for review.`;
@@ -21106,7 +21212,7 @@ async function strikingDistance(input, dependencies = defaultDependencies$7) {
21106
21212
  summary: {
21107
21213
  ...analysis.summary,
21108
21214
  brandFiltering: input.includeBrand ? "included" : "excluded",
21109
- verdict: verdict$1(analysis)
21215
+ verdict: verdict$2(analysis)
21110
21216
  },
21111
21217
  caveats: [
21112
21218
  `Date window: ${range.startDate} to ${range.endDate}, using final GSC data where available.`,
@@ -24292,7 +24398,10 @@ const userDataPriceSchema = z.object({
24292
24398
  keyword_suggestions: livePriceSchema.optional(),
24293
24399
  related_keywords: livePriceSchema.optional()
24294
24400
  }).passthrough().optional(),
24295
- 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()
24296
24405
  }).passthrough();
24297
24406
  const userDataResultSchema = z.object({
24298
24407
  login: z.string().min(1).max(320),
@@ -24410,19 +24519,19 @@ const dataForSeoDiscoveryResponseSchema = z.object({
24410
24519
  }).passthrough();
24411
24520
  //#endregion
24412
24521
  //#region packages/core/src/providers/dataforseo/paid-request.ts
24413
- function usdToMicros$1(value) {
24522
+ function usdToMicros(value) {
24414
24523
  if (value === void 0) return null;
24415
24524
  return Math.round(value * 1e6);
24416
24525
  }
24417
24526
  function responseTaskIds$1(response) {
24418
24527
  return [...new Set(response.tasks.flatMap((task) => task.id ? [task.id] : []))].sort((left, right) => left < right ? -1 : left > right ? 1 : 0);
24419
24528
  }
24420
- function responseCostMicros$1(response) {
24421
- if (response.cost !== void 0) return usdToMicros$1(response.cost);
24529
+ function responseCostMicros(response) {
24530
+ if (response.cost !== void 0) return usdToMicros(response.cost);
24422
24531
  if (response.tasks.some((task) => task.cost === void 0)) return null;
24423
- 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);
24424
24533
  }
24425
- function taskErrorCode$1(statusCode) {
24534
+ function taskErrorCode(statusCode) {
24426
24535
  if (statusCode >= 40100 && statusCode < 40200) return "authentication";
24427
24536
  if (statusCode === 40202) return "rate-limit";
24428
24537
  return "remote-error";
@@ -24512,7 +24621,7 @@ async function dataForSeoPaidPost(input) {
24512
24621
  });
24513
24622
  throw error;
24514
24623
  }
24515
- const actualCostMicros = responseCostMicros$1(response);
24624
+ const actualCostMicros = responseCostMicros(response);
24516
24625
  const returnedRows = input.rowCount(response);
24517
24626
  const taskIds = responseTaskIds$1(response);
24518
24627
  const failedTasks = response.tasks.filter((task) => task.status_code !== 2e4);
@@ -24534,7 +24643,7 @@ async function dataForSeoPaidPost(input) {
24534
24643
  throw new ProviderError({
24535
24644
  provider: "dataforseo",
24536
24645
  operation: input.operation,
24537
- code: taskErrorCode$1(statusCode),
24646
+ code: taskErrorCode(statusCode),
24538
24647
  message: `DataForSEO could not complete ${input.operation} (${statusCode}).`,
24539
24648
  retryable: statusCode === 40202
24540
24649
  });
@@ -24577,6 +24686,22 @@ async function dataForSeoPaidPost(input) {
24577
24686
  };
24578
24687
  }
24579
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
24580
24705
  //#region packages/core/src/providers/dataforseo/serp-schema.ts
24581
24706
  const serpItemSchema = z.object({
24582
24707
  type: z.string().trim().min(1).max(100),
@@ -24621,6 +24746,237 @@ const dataForSeoSerpResponseSchema = z.object({
24621
24746
  tasks: z.array(serpTaskSchema).max(10)
24622
24747
  }).passthrough();
24623
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
24624
24980
  //#region packages/core/src/providers/dataforseo/client.ts
24625
24981
  const DEFAULT_BASE_URL$1 = "https://api.dataforseo.com/";
24626
24982
  const USER_DATA_PATH = "v3/appendix/user_data";
@@ -24643,9 +24999,43 @@ const MAX_KEYWORD_WORDS = 10;
24643
24999
  const MAX_DISCOVERY_SEEDS$1 = 5;
24644
25000
  const MAX_DISCOVERY_ROWS$1 = 100;
24645
25001
  const MAX_SERP_DEPTH$1 = 100;
24646
- function usdToMicros(value) {
24647
- if (value === void 0) return null;
24648
- 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
+ };
24649
25039
  }
24650
25040
  function unitPrice(components) {
24651
25041
  const prices = components ?? [];
@@ -24671,19 +25061,12 @@ function keywordDiscoveryPrices(account) {
24671
25061
  function serpLiveAdvancedPrice(account) {
24672
25062
  return unitPrice(account.price?.serp?.live?.advanced?.priority_normal);
24673
25063
  }
24674
- function taskErrorCode(statusCode) {
24675
- if (statusCode >= 40100 && statusCode < 40200) return "authentication";
24676
- if (statusCode === 40202) return "rate-limit";
24677
- return "remote-error";
25064
+ function serpTaskPostPrice(account) {
25065
+ return unitPrice(account.price?.serp?.task_post?.priority_normal);
24678
25066
  }
24679
25067
  function responseTaskIds(response) {
24680
25068
  return [...new Set(response.tasks.flatMap((task) => task.id ? [task.id] : []))].sort((left, right) => left < right ? -1 : left > right ? 1 : 0);
24681
25069
  }
24682
- function responseCostMicros(response) {
24683
- if (response.cost !== void 0) return usdToMicros(response.cost);
24684
- if (response.tasks.some((task) => task.cost === void 0)) return null;
24685
- return response.tasks.reduce((sum, task) => sum + (usdToMicros(task.cost) ?? 0), 0);
24686
- }
24687
25070
  function responseRows$1(response) {
24688
25071
  return response.tasks.reduce((taskTotal, task) => taskTotal + (task.result ?? []).reduce((resultTotal, result) => resultTotal + (result.items?.length ?? 0), 0), 0);
24689
25072
  }
@@ -24698,9 +25081,6 @@ function discoveryNextCursor(response) {
24698
25081
  const cursors = response.tasks.flatMap((task) => (task.result ?? []).flatMap((result) => result.offset_token ? [result.offset_token] : []));
24699
25082
  return cursors.length === 1 ? cursors[0] ?? null : null;
24700
25083
  }
24701
- function serpRows(response) {
24702
- return response.tasks.reduce((taskTotal, task) => taskTotal + (task.result ?? []).reduce((resultTotal, result) => resultTotal + (result.items?.length ?? 0), 0), 0);
24703
- }
24704
25084
  var DataForSeoClient = class {
24705
25085
  fetch;
24706
25086
  credentials;
@@ -24743,6 +25123,18 @@ var DataForSeoClient = class {
24743
25123
  authorization(credentials) {
24744
25124
  return `Basic ${Buffer.from(`${credentials.login}:${credentials.password}`).toString("base64")}`;
24745
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
+ }
24746
25138
  async userDataForCredentials(credentials) {
24747
25139
  let response;
24748
25140
  try {
@@ -24777,7 +25169,7 @@ var DataForSeoClient = class {
24777
25169
  throw new ProviderError({
24778
25170
  provider: "dataforseo",
24779
25171
  operation: "account-status",
24780
- code: taskErrorCode(statusCode),
25172
+ code: dataForSeoTaskErrorCode(statusCode),
24781
25173
  message: `DataForSEO could not return account status (${statusCode}).`,
24782
25174
  retryable: statusCode === 40202
24783
25175
  });
@@ -24793,18 +25185,19 @@ var DataForSeoClient = class {
24793
25185
  provider: "dataforseo",
24794
25186
  login: account.login,
24795
25187
  timezone: account.timezone ?? null,
24796
- balanceMicros: usdToMicros(account.money?.balance),
24797
- depositedMicros: usdToMicros(account.money?.total),
24798
- 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),
24799
25191
  accountDailySpendPeriod: account.money?.statistics?.day?.value ?? null,
24800
- accountDailyLimitMicros: usdToMicros(account.money?.limits?.day?.total),
25192
+ accountDailyLimitMicros: dataForSeoUsdToMicros(account.money?.limits?.day?.total),
24801
25193
  keywordOverviewPrice: keywordOverviewPrice(account),
24802
25194
  keywordDiscoveryPrices: keywordDiscoveryPrices(account),
24803
25195
  serpLiveAdvancedPrice: serpLiveAdvancedPrice(account),
25196
+ serpTaskPostPrice: serpTaskPostPrice(account),
24804
25197
  backlinksSubscriptionExpiresAt: account.backlinks_subscription_expiry_date ?? null,
24805
25198
  aiMentionsSubscriptionExpiresAt: account.llm_mentions_subscription_expiry_date ?? null,
24806
25199
  apiVersion: response.version ?? null,
24807
- requestCostMicros: usdToMicros(response.cost) ?? 0,
25200
+ requestCostMicros: dataForSeoUsdToMicros(response.cost) ?? 0,
24808
25201
  taskIds: response.tasks.flatMap((task) => task.id ? [task.id] : []),
24809
25202
  observedAt: this.now().toISOString()
24810
25203
  };
@@ -24980,7 +25373,7 @@ var DataForSeoClient = class {
24980
25373
  });
24981
25374
  throw error;
24982
25375
  }
24983
- const actualCostMicros = responseCostMicros(response);
25376
+ const actualCostMicros = dataForSeoResponseCostMicros(response);
24984
25377
  const returnedRows = responseRows$1(response);
24985
25378
  const taskIds = responseTaskIds(response);
24986
25379
  const failedTasks = response.tasks.filter((task) => task.status_code !== 2e4);
@@ -25002,7 +25395,7 @@ var DataForSeoClient = class {
25002
25395
  throw new ProviderError({
25003
25396
  provider: "dataforseo",
25004
25397
  operation: "keyword-metrics",
25005
- code: taskErrorCode(statusCode),
25398
+ code: dataForSeoTaskErrorCode(statusCode),
25006
25399
  message: `DataForSEO could not complete the keyword metrics task (${statusCode}).`,
25007
25400
  retryable: statusCode === 40202
25008
25401
  });
@@ -25114,38 +25507,7 @@ var DataForSeoClient = class {
25114
25507
  };
25115
25508
  }
25116
25509
  async serpLive(input) {
25117
- const keyword = input.keyword.trim();
25118
- if (keyword.length < 1 || keyword.length > MAX_KEYWORD_CHARACTERS || keyword.split(/\s+/u).length > MAX_KEYWORD_WORDS) throw new ProviderError({
25119
- provider: "dataforseo",
25120
- operation: "serp-snapshot",
25121
- code: "configuration",
25122
- message: `SERP snapshots require a keyword of at most ${MAX_KEYWORD_CHARACTERS} characters and ${MAX_KEYWORD_WORDS} words.`
25123
- });
25124
- if (/(?:^|\s)(?:allinanchor|allintext|allintitle|allinurl|cache|define|definition|filetype|id|inanchor|info|intext|intitle|inurl|link|site):/iu.test(keyword)) throw new ProviderError({
25125
- provider: "dataforseo",
25126
- operation: "serp-snapshot",
25127
- code: "configuration",
25128
- message: "SERP snapshot keywords cannot contain search operators with multiplied provider pricing."
25129
- });
25130
- if (!Number.isSafeInteger(input.depth) || input.depth < 1 || input.depth > MAX_SERP_DEPTH$1) throw new ProviderError({
25131
- provider: "dataforseo",
25132
- operation: "serp-snapshot",
25133
- code: "configuration",
25134
- message: `SERP depth must be from 1 to ${MAX_SERP_DEPTH$1}.`
25135
- });
25136
- if (!/^[a-z]{2}$/.test(input.languageCode)) throw new ProviderError({
25137
- provider: "dataforseo",
25138
- operation: "serp-snapshot",
25139
- code: "configuration",
25140
- message: "DataForSEO language code must contain two lowercase letters."
25141
- });
25142
- const locationName = input.locationName?.trim();
25143
- if (input.locationCode !== void 0 === Boolean(locationName)) throw new ProviderError({
25144
- provider: "dataforseo",
25145
- operation: "serp-snapshot",
25146
- code: "configuration",
25147
- message: "DataForSEO SERP snapshots require exactly one location code or location name."
25148
- });
25510
+ const { keyword, locationName } = validateSerpInput(input);
25149
25511
  const request = {
25150
25512
  keyword,
25151
25513
  language_code: input.languageCode,
@@ -25166,9 +25528,56 @@ var DataForSeoClient = class {
25166
25528
  context: input.context,
25167
25529
  ttlMs: this.serpTtlMs,
25168
25530
  refresh: input.refresh,
25169
- rowCount: serpRows
25531
+ rowCount: dataForSeoSerpRows
25532
+ });
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
25170
25570
  });
25171
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
+ }
25172
25581
  };
25173
25582
  //#endregion
25174
25583
  //#region packages/core/src/providers/dataforseo/keyword-mapping.ts
@@ -26455,7 +26864,7 @@ function pageItem(url, rows) {
26455
26864
  recommendation: nextRecommendation
26456
26865
  };
26457
26866
  }
26458
- function compareItems(left, right) {
26867
+ function compareItems$1(left, right) {
26459
26868
  return right.priority.score - left.priority.score || right.impressions - left.impressions || left.position - right.position || compareText$4(left.url, right.url);
26460
26869
  }
26461
26870
  function analyzeSecondPageRows(input) {
@@ -26500,7 +26909,7 @@ function analyzeSecondPageRows(input) {
26500
26909
  if (item.impressions >= minImpressions) return true;
26501
26910
  selection.belowMinimumPages++;
26502
26911
  return false;
26503
- }).sort(compareItems);
26912
+ }).sort(compareItems$1);
26504
26913
  const items = eligibleItems.slice(0, limit);
26505
26914
  selection.eligiblePages = eligibleItems.length;
26506
26915
  selection.returnedPages = items.length;
@@ -27326,7 +27735,7 @@ async function defaultCandidates$2() {
27326
27735
  priority: 10
27327
27736
  }];
27328
27737
  }
27329
- function providerError$2(error) {
27738
+ function providerError$3(error) {
27330
27739
  if (!(error instanceof ProviderError)) throw error;
27331
27740
  if (error.code === "rate-limit") throw new SeoError("RATE_LIMITED", error.message);
27332
27741
  if (error.code === "configuration") throw new SeoError("INVALID_INPUT", error.message);
@@ -27394,7 +27803,7 @@ async function keywordResearchReport(input, dependencies = {}) {
27394
27803
  try {
27395
27804
  candidates = dependencies.candidates ?? await defaultCandidates$2();
27396
27805
  } catch (error) {
27397
- return providerError$2(error);
27806
+ return providerError$3(error);
27398
27807
  }
27399
27808
  const resolution = resolveProvider({
27400
27809
  capability: "keyword-discovery",
@@ -27423,7 +27832,7 @@ async function keywordResearchReport(input, dependencies = {}) {
27423
27832
  }
27424
27833
  });
27425
27834
  } catch (error) {
27426
- return providerError$2(error);
27835
+ return providerError$3(error);
27427
27836
  }
27428
27837
  const analysis = evidence.data.map((idea) => ({
27429
27838
  keyword: idea.keyword,
@@ -32060,6 +32469,96 @@ function checkedAt(value, fallback) {
32060
32469
  const timestamp = Date.parse(value);
32061
32470
  return Number.isFinite(timestamp) ? new Date(timestamp).toISOString() : fallback;
32062
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
+ }
32063
32562
  var DataForSeoSerpSnapshotProvider = class {
32064
32563
  provider = "dataforseo";
32065
32564
  capabilitySupport = [{
@@ -32101,7 +32600,7 @@ var DataForSeoSerpSnapshotProvider = class {
32101
32600
  reportRunId: randomUUID()
32102
32601
  };
32103
32602
  const location = locationRequest(market, "serp-snapshot");
32104
- const snapshot = await this.client.serpLive({
32603
+ return mapDataForSeoSerpSnapshot(input, await this.client.serpLive({
32105
32604
  keyword,
32106
32605
  languageCode: market.languageCode.split("-")[0],
32107
32606
  ...location,
@@ -32109,92 +32608,7 @@ var DataForSeoSerpSnapshotProvider = class {
32109
32608
  depth: input.depth,
32110
32609
  refresh: input.refresh,
32111
32610
  context
32112
- });
32113
- const result = snapshot.response.tasks.flatMap((task) => task.result ?? []).find((item) => normalizedKeyword$1(item.keyword) === keyword);
32114
- if (!result) throw new ProviderError({
32115
- provider: "dataforseo",
32116
- operation: "serp-snapshot",
32117
- code: "invalid-response",
32118
- message: "DataForSEO returned no matching SERP result."
32119
- });
32120
- const warnings = [...snapshot.warnings];
32121
- const items = result.items ?? [];
32122
- const organicItems = items.filter((item) => item.type === "organic");
32123
- const mappedOrganicResults = organicItems.flatMap((item) => {
32124
- const mapped = organicResult(item);
32125
- return mapped ? [mapped] : [];
32126
- }).sort((left, right) => left.rankAbsolute - right.rankAbsolute || left.rankGroup - right.rankGroup || compareCodepoints$3(left.url, right.url));
32127
- const organicResults = mappedOrganicResults.slice(0, input.depth);
32128
- const invalidRows = organicItems.length - mappedOrganicResults.length;
32129
- const resultCapped = mappedOrganicResults.length > input.depth;
32130
- const invalidResultCount = result.se_results_count !== null && result.se_results_count !== void 0 && result.se_results_count < organicResults.length;
32131
- if (invalidRows > 0) warnings.push({
32132
- code: "invalid-organic-results",
32133
- field: "organicResults",
32134
- message: `DataForSEO returned ${invalidRows} organic result${invalidRows === 1 ? "" : "s"} without a valid rank, domain, or URL.`
32135
- });
32136
- if (!Number.isFinite(Date.parse(result.datetime))) warnings.push({
32137
- code: "invalid-serp-observation-time",
32138
- field: "checkedAt",
32139
- message: "DataForSEO returned an invalid SERP observation time; the local response time was retained."
32140
- });
32141
- const checkUrl = safeUrl(result.check_url);
32142
- if (result.check_url && !checkUrl) warnings.push({
32143
- code: "invalid-serp-check-url",
32144
- field: "checkUrl",
32145
- message: "DataForSEO returned an invalid SERP check URL."
32146
- });
32147
- if (invalidResultCount) warnings.push({
32148
- code: "invalid-serp-result-count",
32149
- field: "resultCount",
32150
- message: "DataForSEO returned an estimated result count below the retained organic result count; the estimate was discarded."
32151
- });
32152
- const features = [.../* @__PURE__ */ new Set([...result.item_types ?? [], ...items.map((item) => item.type)])].sort(compareCodepoints$3);
32153
- return {
32154
- schemaVersion: 1,
32155
- provider: "dataforseo",
32156
- capability: "serp-snapshot",
32157
- data: {
32158
- keyword,
32159
- effectiveKeyword: normalizedKeyword$1(result.spell?.keyword ?? result.keyword),
32160
- searchEngineDomain: result.se_domain?.trim().toLowerCase() ?? null,
32161
- checkedAt: checkedAt(result.datetime, snapshot.observedAt),
32162
- checkUrl,
32163
- resultCount: invalidResultCount ? null : result.se_results_count ?? null,
32164
- pagesCount: result.pages_count === void 0 ? null : result.pages_count,
32165
- features,
32166
- organicResults
32167
- },
32168
- observedAt: snapshot.observedAt,
32169
- market: {
32170
- ...market,
32171
- device: market.device ?? "desktop"
32172
- },
32173
- coverage: {
32174
- requestedRows: input.depth,
32175
- returnedRows: snapshot.returnedRows,
32176
- retainedRows: organicResults.length,
32177
- invalidRows,
32178
- providerTotalRows: invalidResultCount ? null : result.se_results_count ?? null,
32179
- completeness: invalidRows > 0 || invalidResultCount ? "partial" : resultCapped ? "capped" : "complete",
32180
- nextCursor: null
32181
- },
32182
- cache: snapshot.cache,
32183
- cost: snapshot.cost,
32184
- request: {
32185
- operation: "serp-snapshot",
32186
- endpoint: SERP_ENDPOINT,
32187
- limit: input.depth,
32188
- filters: {
32189
- countryCode: market.countryCode,
32190
- languageCode: market.languageCode,
32191
- device: market.device ?? "desktop",
32192
- ...location.locationCode !== void 0 ? { locationCode: location.locationCode } : { locationName: location.locationName }
32193
- },
32194
- sort: ["rankAbsolute:ascending", "url:codepoint-ascending"]
32195
- },
32196
- warnings
32197
- };
32611
+ }));
32198
32612
  }
32199
32613
  };
32200
32614
  //#endregion
@@ -32213,7 +32627,7 @@ async function defaultCandidates$1() {
32213
32627
  priority: 10
32214
32628
  }];
32215
32629
  }
32216
- function providerError$1(error) {
32630
+ function providerError$2(error) {
32217
32631
  if (!(error instanceof ProviderError)) throw error;
32218
32632
  if (error.code === "rate-limit") throw new SeoError("RATE_LIMITED", error.message);
32219
32633
  if (error.code === "configuration") throw new SeoError("INVALID_INPUT", error.message);
@@ -32275,7 +32689,7 @@ async function serpResultsReport(input, dependencies = {}) {
32275
32689
  try {
32276
32690
  candidates = dependencies.candidates ?? await defaultCandidates$1();
32277
32691
  } catch (error) {
32278
- return providerError$1(error);
32692
+ return providerError$2(error);
32279
32693
  }
32280
32694
  const resolution = resolveProvider({
32281
32695
  capability: "serp-snapshot",
@@ -32300,7 +32714,7 @@ async function serpResultsReport(input, dependencies = {}) {
32300
32714
  }
32301
32715
  });
32302
32716
  } catch (error) {
32303
- return providerError$1(error);
32717
+ return providerError$2(error);
32304
32718
  }
32305
32719
  const domains = domainSummaries(evidence.data);
32306
32720
  const dataStatus = reportStatus$1(evidence);
@@ -32550,6 +32964,1228 @@ async function pseoOpportunitiesReport(input, dependencies = {}) {
32550
32964
  };
32551
32965
  }
32552
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
32553
34189
  //#region packages/core/src/analyze/reports/dates.ts
32554
34190
  function rangeDays(range) {
32555
34191
  const start = Date.parse(`${range.startDate}T00:00:00Z`);
@@ -33015,210 +34651,6 @@ function reportPresentation(report) {
33015
34651
  };
33016
34652
  }
33017
34653
  //#endregion
33018
- //#region packages/core/src/keyword-sets/limits.ts
33019
- const KEYWORD_SET_LIMITS = {
33020
- setsPerProject: 25,
33021
- totalSets: 100,
33022
- keywordsPerSet: 1e4,
33023
- totalKeywords: 1e5,
33024
- mutationKeywords: 1e3,
33025
- tagsPerKeyword: 20,
33026
- outputRows: 1e3,
33027
- logicalBytes: 64 * 1024 * 1024
33028
- };
33029
- const KEYWORD_SET_FIELD_LIMITS = {
33030
- name: 80,
33031
- projectId: 80,
33032
- sourceReport: 100,
33033
- keyword: 80,
33034
- keywordWords: 10,
33035
- tag: 40,
33036
- url: 2048,
33037
- metricJsonBytes: 16 * 1024
33038
- };
33039
- //#endregion
33040
- //#region packages/core/src/keyword-sets/validation.ts
33041
- const unavailableValueSchema = z.strictObject({
33042
- state: z.enum([
33043
- "missing",
33044
- "unavailable",
33045
- "invalid"
33046
- ]),
33047
- value: z.null(),
33048
- reason: z.string().min(1).max(500)
33049
- });
33050
- const providerValueNumberSchema = z.discriminatedUnion("state", [z.strictObject({
33051
- state: z.literal("observed"),
33052
- value: z.number().finite()
33053
- }), unavailableValueSchema]);
33054
- const providerValueStringSchema = z.discriminatedUnion("state", [z.strictObject({
33055
- state: z.literal("observed"),
33056
- value: z.string().max(500)
33057
- }), unavailableValueSchema]);
33058
- const monthlySearchSchema = z.strictObject({
33059
- year: z.number().int().min(2e3).max(2200),
33060
- month: z.number().int().min(1).max(12),
33061
- searchVolume: z.number().finite().nonnegative()
33062
- });
33063
- const providerValueMonthlySearchesSchema = z.discriminatedUnion("state", [z.strictObject({
33064
- state: z.literal("observed"),
33065
- value: z.array(monthlySearchSchema).max(24)
33066
- }), unavailableValueSchema]);
33067
- const keywordMetricSchema = z.strictObject({
33068
- keyword: z.string().min(1).max(KEYWORD_SET_FIELD_LIMITS.keyword),
33069
- monthlySearchVolume: providerValueNumberSchema,
33070
- monthlySearches: providerValueMonthlySearchesSchema,
33071
- searchVolumeUpdatedAt: providerValueStringSchema,
33072
- cpcUsd: providerValueNumberSchema,
33073
- paidCompetition: providerValueNumberSchema,
33074
- keywordDifficulty: providerValueNumberSchema,
33075
- intent: providerValueStringSchema,
33076
- resultCount: providerValueNumberSchema
33077
- });
33078
- const savedMetricSchema = z.strictObject({
33079
- schemaVersion: z.literal(1),
33080
- provider: providerIdSchema,
33081
- observedAt: z.iso.datetime(),
33082
- metric: keywordMetricSchema
33083
- });
33084
- function invalid(message) {
33085
- throw new SeoError("INVALID_INPUT", message);
33086
- }
33087
- function boundedText(value, label, max) {
33088
- const normalized = value.trim();
33089
- if (!normalized || normalized.length > max) invalid(`${label} must contain 1 to ${max} characters.`);
33090
- return normalized;
33091
- }
33092
- function normalizeSavedKeyword(value) {
33093
- const normalized = value.trim().replace(/\s+/gu, " ").toLowerCase();
33094
- if (!normalized || normalized.length > KEYWORD_SET_FIELD_LIMITS.keyword || normalized.split(" ").length > KEYWORD_SET_FIELD_LIMITS.keywordWords) invalid(`Keywords must contain 1 to ${KEYWORD_SET_FIELD_LIMITS.keyword} characters and at most ${KEYWORD_SET_FIELD_LIMITS.keywordWords} words.`);
33095
- return normalized;
33096
- }
33097
- function displayKeyword(value) {
33098
- normalizeSavedKeyword(value);
33099
- return value.trim().replace(/\s+/gu, " ");
33100
- }
33101
- function normalizedTags(values = []) {
33102
- if (values.length > KEYWORD_SET_LIMITS.tagsPerKeyword) invalid(`A keyword can have at most ${KEYWORD_SET_LIMITS.tagsPerKeyword} tags.`);
33103
- const tags = [...new Set(values.map((value) => boundedText(value, "Tag", KEYWORD_SET_FIELD_LIMITS.tag).toLowerCase()))].sort();
33104
- if (tags.length > KEYWORD_SET_LIMITS.tagsPerKeyword) invalid(`A keyword can have at most ${KEYWORD_SET_LIMITS.tagsPerKeyword} tags.`);
33105
- return tags;
33106
- }
33107
- function pageMapping(value) {
33108
- if (value === null || value === void 0) return value;
33109
- if (value.kind !== "target" && value.kind !== "proposed") invalid("Page mapping kind must be target or proposed.");
33110
- const raw = boundedText(value.url, "Page URL", KEYWORD_SET_FIELD_LIMITS.url);
33111
- let url;
33112
- try {
33113
- url = new URL(raw);
33114
- } catch {
33115
- invalid("Page mapping must use an absolute HTTP or HTTPS URL.");
33116
- }
33117
- if (url.protocol !== "http:" && url.protocol !== "https:") invalid("Page mapping must use an absolute HTTP or HTTPS URL.");
33118
- return {
33119
- kind: value.kind,
33120
- url: url.toString()
33121
- };
33122
- }
33123
- function savedMetric(value, normalizedKeyword) {
33124
- if (value === null || value === void 0) return {
33125
- value,
33126
- json: null
33127
- };
33128
- const parsed = savedMetricSchema.safeParse(value);
33129
- if (!parsed.success) invalid("Use a valid normalized keyword metric snapshot.");
33130
- if (normalizeSavedKeyword(parsed.data.metric.keyword) !== normalizedKeyword) invalid("A saved metric must describe the same keyword as the set item.");
33131
- const json = JSON.stringify(parsed.data);
33132
- if (Buffer.byteLength(json) > KEYWORD_SET_FIELD_LIMITS.metricJsonBytes) invalid(`A saved keyword metric cannot exceed ${KEYWORD_SET_FIELD_LIMITS.metricJsonBytes} bytes.`);
33133
- return {
33134
- value: parsed.data,
33135
- json
33136
- };
33137
- }
33138
- function validateMutationItems(items) {
33139
- if (items.length < 1 || items.length > KEYWORD_SET_LIMITS.mutationKeywords) invalid(`Add 1 to ${KEYWORD_SET_LIMITS.mutationKeywords} keywords per operation.`);
33140
- const normalized = /* @__PURE__ */ new Map();
33141
- for (const item of items) {
33142
- const key = normalizeSavedKeyword(item.keyword);
33143
- const existing = normalized.get(key);
33144
- const tags = normalizedTags([...existing?.tags ?? [], ...item.tags ?? []]);
33145
- const page = pageMapping(item.page !== void 0 ? item.page : existing?.page);
33146
- const metric = savedMetric(item.latestMetric !== void 0 ? item.latestMetric : existing?.metric, key);
33147
- normalized.set(key, {
33148
- keyword: existing?.keyword ?? displayKeyword(item.keyword),
33149
- tags,
33150
- page,
33151
- metric: metric.value,
33152
- metricJson: metric.json
33153
- });
33154
- }
33155
- return normalized;
33156
- }
33157
- function parseStoredJson(value, schema, label) {
33158
- let decoded;
33159
- try {
33160
- decoded = JSON.parse(value);
33161
- } catch {
33162
- throw new SeoError("INTERNAL_ERROR", `${label} contains invalid JSON.`);
33163
- }
33164
- const parsed = schema.safeParse(decoded);
33165
- if (!parsed.success) throw new SeoError("INTERNAL_ERROR", `${label} has an invalid shape.`);
33166
- return parsed.data;
33167
- }
33168
- //#endregion
33169
- //#region packages/core/src/keyword-sets/rows.ts
33170
- const SET_SELECT = `
33171
- SELECT sets.*,
33172
- (SELECT COUNT(*) FROM keyword_set_items items WHERE items.set_id = sets.id) AS keyword_count,
33173
- (SELECT COUNT(DISTINCT tags.tag) FROM keyword_set_tags tags WHERE tags.set_id = sets.id) AS tag_count
33174
- FROM keyword_sets sets`;
33175
- function storeDatabase(options) {
33176
- return options.database ?? getDb();
33177
- }
33178
- function rowToSet(row) {
33179
- const provider = row.provider ? providerIdSchema.safeParse(row.provider) : void 0;
33180
- if (provider && !provider.success) throw new SeoError("INTERNAL_ERROR", "Saved keyword set provider is invalid.");
33181
- return {
33182
- schemaVersion: 1,
33183
- id: row.id,
33184
- projectId: row.project_id,
33185
- name: row.name,
33186
- market: parseStoredJson(row.market_json, searchMarketSchema, "Saved search market"),
33187
- provider: provider?.data ?? null,
33188
- sourceReport: row.source_report,
33189
- keywordCount: row.keyword_count,
33190
- tagCount: row.tag_count,
33191
- createdAt: new Date(row.created_at).toISOString(),
33192
- updatedAt: new Date(row.updated_at).toISOString(),
33193
- lastRefreshedAt: row.last_refreshed_at === null ? null : new Date(row.last_refreshed_at).toISOString()
33194
- };
33195
- }
33196
- function findSetRow(db, projectId, idOrName) {
33197
- return db.prepare(`${SET_SELECT}
33198
- WHERE sets.project_id = ?
33199
- AND (sets.id = ? OR sets.name = ? COLLATE NOCASE)
33200
- ORDER BY CASE WHEN sets.id = ? THEN 0 ELSE 1 END
33201
- LIMIT 1`).get(projectId, idOrName, idOrName, idOrName);
33202
- }
33203
- function requireSetRow(db, projectId, idOrName) {
33204
- const row = findSetRow(db, projectId, idOrName);
33205
- if (!row) invalid(`Keyword set not found: ${idOrName}`);
33206
- return row;
33207
- }
33208
- function rowCount(db, sql, ...params) {
33209
- return db.prepare(sql).get(...params).count;
33210
- }
33211
- function keywordSetLogicalBytes(db = getDb()) {
33212
- return db.prepare(`SELECT
33213
- 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) +
33214
- 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) +
33215
- COALESCE((SELECT SUM(length(set_id) + length(normalized_keyword) + length(tag)) FROM keyword_set_tags), 0)
33216
- AS bytes`).get().bytes;
33217
- }
33218
- function enforceKeywordSetLogicalBytes(db, maxLogicalBytes = KEYWORD_SET_LIMITS.logicalBytes) {
33219
- if (keywordSetLogicalBytes(db) > maxLogicalBytes) invalid(`Saved keyword data cannot exceed ${maxLogicalBytes} logical bytes. Remove an unused set before adding more.`);
33220
- }
33221
- //#endregion
33222
34654
  //#region packages/core/src/keyword-sets/mutations.ts
33223
34655
  function addKeywordsToSet(input, options = {}) {
33224
34656
  const projectId = boundedText(input.projectId, "Project id", KEYWORD_SET_FIELD_LIMITS.projectId);
@@ -33232,8 +34664,8 @@ function addKeywordsToSet(input, options = {}) {
33232
34664
  WHERE set_id = ? AND normalized_keyword IN (${[...items].map(() => "?").join(", ")})`).all(set.id, ...items.keys());
33233
34665
  const existing = new Map(existingRows.map((row) => [row.normalized_keyword, row]));
33234
34666
  const additions = items.size - existing.size;
33235
- if (currentSetCount + additions > KEYWORD_SET_LIMITS.keywordsPerSet) invalid(`A keyword set can contain at most ${KEYWORD_SET_LIMITS.keywordsPerSet} keywords.`);
33236
- if (currentTotalCount + additions > KEYWORD_SET_LIMITS.totalKeywords) invalid(`At most ${KEYWORD_SET_LIMITS.totalKeywords} keywords can be saved across all sets.`);
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.`);
33237
34669
  const now = (options.now ?? (() => /* @__PURE__ */ new Date()))().getTime();
33238
34670
  const updated = /* @__PURE__ */ new Set();
33239
34671
  db.transaction(() => {
@@ -33291,7 +34723,7 @@ function addTags(db, setId, normalizedKeyword, tags) {
33291
34723
  return changed;
33292
34724
  }
33293
34725
  function removeKeywordsFromSet(input, options = {}) {
33294
- if (input.keywords.length < 1 || input.keywords.length > KEYWORD_SET_LIMITS.mutationKeywords) invalid(`Remove 1 to ${KEYWORD_SET_LIMITS.mutationKeywords} keywords per operation.`);
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.`);
33295
34727
  const projectId = boundedText(input.projectId, "Project id", KEYWORD_SET_FIELD_LIMITS.projectId);
33296
34728
  const idOrName = boundedText(input.idOrName, "Keyword set id or name", KEYWORD_SET_FIELD_LIMITS.name);
33297
34729
  const keywords = [...new Set(input.keywords.map(normalizeSavedKeyword))];
@@ -33317,7 +34749,7 @@ function setKeywordSetRefreshTime(input, options = {}) {
33317
34749
  const projectId = boundedText(input.projectId, "Project id", KEYWORD_SET_FIELD_LIMITS.projectId);
33318
34750
  const idOrName = boundedText(input.idOrName, "Keyword set id or name", KEYWORD_SET_FIELD_LIMITS.name);
33319
34751
  const refreshedAt = Date.parse(input.refreshedAt);
33320
- if (!Number.isFinite(refreshedAt)) invalid("Use a valid refresh timestamp.");
34752
+ if (!Number.isFinite(refreshedAt)) invalid$1("Use a valid refresh timestamp.");
33321
34753
  const db = storeDatabase(options);
33322
34754
  const row = requireSetRow(db, projectId, idOrName);
33323
34755
  const now = (options.now ?? (() => /* @__PURE__ */ new Date()))().getTime();
@@ -33325,122 +34757,6 @@ function setKeywordSetRefreshTime(input, options = {}) {
33325
34757
  return rowToSet(requireSetRow(db, projectId, row.id));
33326
34758
  }
33327
34759
  //#endregion
33328
- //#region packages/core/src/keyword-sets/store.ts
33329
- function createKeywordSet(input, options = {}) {
33330
- const projectId = boundedText(input.projectId, "Project id", KEYWORD_SET_FIELD_LIMITS.projectId);
33331
- const name = boundedText(input.name, "Keyword set name", KEYWORD_SET_FIELD_LIMITS.name);
33332
- const market = searchMarketSchema.safeParse(input.market);
33333
- if (!market.success) invalid("Use a valid search market.");
33334
- const provider = input.provider === void 0 ? void 0 : providerIdSchema.safeParse(input.provider);
33335
- if (provider && !provider.success) invalid("Use a supported keyword provider.");
33336
- const sourceReport = input.sourceReport ? boundedText(input.sourceReport, "Source report", KEYWORD_SET_FIELD_LIMITS.sourceReport) : null;
33337
- const db = storeDatabase(options);
33338
- if (rowCount(db, "SELECT COUNT(*) AS count FROM keyword_sets") >= KEYWORD_SET_LIMITS.totalSets) invalid(`At most ${KEYWORD_SET_LIMITS.totalSets} keyword sets can be saved.`);
33339
- if (rowCount(db, "SELECT COUNT(*) AS count FROM keyword_sets WHERE project_id = ?", projectId) >= KEYWORD_SET_LIMITS.setsPerProject) invalid(`A project can have at most ${KEYWORD_SET_LIMITS.setsPerProject} keyword sets.`);
33340
- const now = (options.now ?? (() => /* @__PURE__ */ new Date()))().getTime();
33341
- const id = (options.id ?? randomUUID)();
33342
- try {
33343
- db.transaction(() => {
33344
- db.prepare(`INSERT INTO keyword_sets
33345
- (id, project_id, name, market_json, provider, source_report, created_at, updated_at)
33346
- VALUES (?, ?, ?, ?, ?, ?, ?, ?)`).run(id, projectId, name, JSON.stringify(market.data), provider?.data ?? null, sourceReport, now, now);
33347
- enforceKeywordSetLogicalBytes(db, options.maxLogicalBytes);
33348
- }).immediate();
33349
- } catch (error) {
33350
- if (/unique constraint/i.test(String(error))) invalid(`A keyword set named ${name} already exists for this project.`);
33351
- throw error;
33352
- }
33353
- return rowToSet(requireSetRow(db, projectId, id));
33354
- }
33355
- function listKeywordSets(input, options = {}) {
33356
- const projectId = boundedText(input.projectId, "Project id", KEYWORD_SET_FIELD_LIMITS.projectId);
33357
- const limit = input.limit ?? 100;
33358
- const offset = input.offset ?? 0;
33359
- if (!Number.isSafeInteger(limit) || limit < 1 || limit > 100) invalid("Keyword set list limit must be from 1 to 100.");
33360
- if (!Number.isSafeInteger(offset) || offset < 0) invalid("Keyword set list offset must be zero or greater.");
33361
- return storeDatabase(options).prepare(`${SET_SELECT}
33362
- WHERE sets.project_id = ?
33363
- ORDER BY sets.updated_at DESC, sets.id
33364
- LIMIT ? OFFSET ?`).all(projectId, limit, offset).map(rowToSet);
33365
- }
33366
- function getKeywordSet(input, options = {}) {
33367
- const projectId = boundedText(input.projectId, "Project id", KEYWORD_SET_FIELD_LIMITS.projectId);
33368
- const idOrName = boundedText(input.idOrName, "Keyword set id or name", KEYWORD_SET_FIELD_LIMITS.name);
33369
- const tag = input.tag ? boundedText(input.tag, "Tag", KEYWORD_SET_FIELD_LIMITS.tag).toLowerCase() : null;
33370
- const limit = input.limit ?? 100;
33371
- const offset = input.offset ?? 0;
33372
- if (!Number.isSafeInteger(limit) || limit < 1 || limit > KEYWORD_SET_LIMITS.outputRows) invalid(`Keyword set output limit must be from 1 to ${KEYWORD_SET_LIMITS.outputRows}.`);
33373
- if (!Number.isSafeInteger(offset) || offset < 0) invalid("Keyword set output offset must be zero or greater.");
33374
- const db = storeDatabase(options);
33375
- const setRow = requireSetRow(db, projectId, idOrName);
33376
- const filterSql = tag ? `AND EXISTS (
33377
- SELECT 1 FROM keyword_set_tags filter_tags
33378
- WHERE filter_tags.set_id = items.set_id
33379
- AND filter_tags.normalized_keyword = items.normalized_keyword
33380
- AND filter_tags.tag = ?
33381
- )` : "";
33382
- const params = tag ? [setRow.id, tag] : [setRow.id];
33383
- const total = rowCount(db, `SELECT COUNT(*) AS count FROM keyword_set_items items
33384
- WHERE items.set_id = ? ${filterSql}`, ...params);
33385
- const rows = db.prepare(`SELECT items.* FROM keyword_set_items items
33386
- WHERE items.set_id = ? ${filterSql}
33387
- ORDER BY items.normalized_keyword
33388
- LIMIT ? OFFSET ?`).all(...params, limit, offset);
33389
- const tagsByKeyword = loadTags(db, setRow.id, rows);
33390
- const items = rows.map((row) => itemFromRow(row, tagsByKeyword.get(row.normalized_keyword) ?? []));
33391
- return {
33392
- schemaVersion: 1,
33393
- set: rowToSet(setRow),
33394
- items,
33395
- pagination: {
33396
- offset,
33397
- limit,
33398
- returned: items.length,
33399
- total,
33400
- nextOffset: offset + items.length < total ? offset + items.length : null
33401
- },
33402
- filter: { tag }
33403
- };
33404
- }
33405
- function loadTags(db, setId, rows) {
33406
- if (rows.length === 0) return /* @__PURE__ */ new Map();
33407
- const tagRows = db.prepare(`SELECT normalized_keyword, tag FROM keyword_set_tags
33408
- WHERE set_id = ? AND normalized_keyword IN (${rows.map(() => "?").join(", ")})
33409
- ORDER BY normalized_keyword, tag`).all(setId, ...rows.map((row) => row.normalized_keyword));
33410
- const tagsByKeyword = /* @__PURE__ */ new Map();
33411
- for (const row of tagRows) {
33412
- const tags = tagsByKeyword.get(row.normalized_keyword) ?? [];
33413
- tags.push(row.tag);
33414
- tagsByKeyword.set(row.normalized_keyword, tags);
33415
- }
33416
- return tagsByKeyword;
33417
- }
33418
- function itemFromRow(row, tags) {
33419
- const latestMetric = row.metric_json ? parseStoredJson(row.metric_json, savedMetricSchema, "Saved keyword metric") : null;
33420
- 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.");
33421
- const page = row.page_kind && row.page_url ? {
33422
- kind: row.page_kind,
33423
- url: row.page_url
33424
- } : null;
33425
- return {
33426
- keyword: row.display_keyword,
33427
- normalizedKeyword: row.normalized_keyword,
33428
- tags,
33429
- page,
33430
- latestMetric,
33431
- createdAt: new Date(row.created_at).toISOString(),
33432
- updatedAt: new Date(row.updated_at).toISOString()
33433
- };
33434
- }
33435
- function deleteKeywordSet(input, options = {}) {
33436
- const projectId = boundedText(input.projectId, "Project id", KEYWORD_SET_FIELD_LIMITS.projectId);
33437
- const idOrName = boundedText(input.idOrName, "Keyword set id or name", KEYWORD_SET_FIELD_LIMITS.name);
33438
- const db = storeDatabase(options);
33439
- const row = findSetRow(db, projectId, idOrName);
33440
- if (!row) return false;
33441
- return db.prepare("DELETE FROM keyword_sets WHERE id = ?").run(row.id).changes > 0;
33442
- }
33443
- //#endregion
33444
34760
  //#region packages/core/src/keyword-sets/refresh.ts
33445
34761
  const REFRESH_BATCH_SIZE = 50;
33446
34762
  function estimator(adapter) {
@@ -38391,6 +39707,7 @@ const TELEMETRY_REPORTS = [
38391
39707
  "pseo-opportunities",
38392
39708
  "query-clusters",
38393
39709
  "quick-wins",
39710
+ "rank-tracking",
38394
39711
  "redirect-trace",
38395
39712
  "narrative-report",
38396
39713
  "second-page",
@@ -38719,6 +40036,6 @@ function isoDate(daysAgo = 0) {
38719
40036
  return date.toISOString().slice(0, 10);
38720
40037
  }
38721
40038
  //#endregion
38722
- 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, 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, 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, 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, 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, getKeywordSet, 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, keywordSetLogicalBytes, latestCrawlReport, latestCrawlSummaries, latestFinalGscDate, latestIndexWatchSummary, latestLinkRecoverSummary, linkEntries, linkEvidenceKey, linkEvidenceReport, linkRecover, linkRecoverRecommendation, listChanges, listClients, listContentGroups, listCrawlReports, listGa4AccountSummaries, listGa4DataStreams, listIndexNowKeys, listKeywordSets, 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, normalizeSavedKeyword, 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, refreshKeywordSet, refreshPrioritiesCsvFiles, refreshPrioritiesWorkflow, 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, savedKeywordSetReport, searchMarketSchema, secondPage, segmentComparisonRange, segmentImpact, segmentRangeDays, selectAffectedUrls, selectDueIndexUrls, seoErrorEnvelope, seoToAiQueryReport, serpResultsReport, serverLogReport, sessionSourceInListFilter, setClientBingSite, setDefaultClient, setKeywordSetRefreshTime, 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 };
38723
40040
 
38724
40041
  //# sourceMappingURL=index.js.map