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/mcp.js CHANGED
@@ -1,5 +1,5 @@
1
1
  import { createRequire as __seoCreateRequire } from 'node:module'; const require = __seoCreateRequire(import.meta.url);
2
- import { n as load, t as version } from "./package-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, existsSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs";
5
5
  import { arch, homedir, platform, totalmem } from "node:os";
@@ -833,6 +833,111 @@ CREATE INDEX IF NOT EXISTS idx_keyword_set_tags_lookup
833
833
  ON keyword_set_tags(set_id, tag, normalized_keyword);
834
834
  `;
835
835
  //#endregion
836
+ //#region packages/core/dist/rank-tracking/schema.js
837
+ const RANK_TRACKING_SCHEMA_SQL = `
838
+ CREATE TABLE IF NOT EXISTS rank_tracking_configs (
839
+ id TEXT PRIMARY KEY,
840
+ config_key TEXT NOT NULL UNIQUE,
841
+ project_id TEXT NOT NULL,
842
+ keyword_set_id TEXT NOT NULL,
843
+ target_domain TEXT NOT NULL,
844
+ tag TEXT,
845
+ market_json TEXT NOT NULL,
846
+ devices_json TEXT NOT NULL,
847
+ provider TEXT NOT NULL CHECK(provider IN ('dataforseo', 'semrush', 'ahrefs')),
848
+ collection_method TEXT NOT NULL CHECK(collection_method IN ('live', 'queued')),
849
+ cadence TEXT NOT NULL CHECK(cadence IN ('manual', 'daily', 'weekly', 'monthly')),
850
+ depth INTEGER NOT NULL CHECK(depth BETWEEN 1 AND 100),
851
+ keyword_limit INTEGER NOT NULL CHECK(keyword_limit BETWEEN 1 AND 1000),
852
+ next_run_at INTEGER,
853
+ created_at INTEGER NOT NULL,
854
+ updated_at INTEGER NOT NULL,
855
+ FOREIGN KEY(keyword_set_id) REFERENCES keyword_sets(id) ON DELETE CASCADE
856
+ );
857
+ CREATE INDEX IF NOT EXISTS idx_rank_tracking_configs_project
858
+ ON rank_tracking_configs(project_id, updated_at DESC, id);
859
+ CREATE INDEX IF NOT EXISTS idx_rank_tracking_configs_due
860
+ ON rank_tracking_configs(cadence, next_run_at, id);
861
+
862
+ CREATE TABLE IF NOT EXISTS rank_tracking_runs (
863
+ id TEXT PRIMARY KEY,
864
+ config_id TEXT NOT NULL,
865
+ idempotency_key TEXT NOT NULL UNIQUE,
866
+ state TEXT NOT NULL CHECK(state IN ('pending', 'partial', 'failed', 'complete')),
867
+ collection_method TEXT NOT NULL CHECK(collection_method IN ('live', 'queued')),
868
+ scheduled_for INTEGER NOT NULL,
869
+ started_at INTEGER NOT NULL,
870
+ completed_at INTEGER,
871
+ keyword_count INTEGER NOT NULL,
872
+ task_count INTEGER NOT NULL,
873
+ snapshot_count INTEGER NOT NULL DEFAULT 0,
874
+ pending_count INTEGER NOT NULL,
875
+ failed_count INTEGER NOT NULL DEFAULT 0,
876
+ estimated_cost_micros INTEGER,
877
+ actual_cost_micros INTEGER,
878
+ config_snapshot_json TEXT NOT NULL,
879
+ error_summary TEXT,
880
+ FOREIGN KEY(config_id) REFERENCES rank_tracking_configs(id) ON DELETE CASCADE
881
+ );
882
+ CREATE INDEX IF NOT EXISTS idx_rank_tracking_runs_config
883
+ ON rank_tracking_runs(config_id, started_at DESC, id DESC);
884
+ DROP INDEX IF EXISTS idx_rank_tracking_active_run;
885
+ CREATE UNIQUE INDEX idx_rank_tracking_active_run
886
+ ON rank_tracking_runs(config_id)
887
+ WHERE completed_at IS NULL;
888
+
889
+ CREATE TABLE IF NOT EXISTS rank_tracking_tasks (
890
+ id TEXT PRIMARY KEY,
891
+ run_id TEXT NOT NULL,
892
+ normalized_keyword TEXT NOT NULL,
893
+ display_keyword TEXT NOT NULL,
894
+ device TEXT NOT NULL CHECK(device IN ('desktop', 'mobile')),
895
+ state TEXT NOT NULL CHECK(state IN ('pending', 'posting', 'posted', 'complete', 'failed')),
896
+ provider_task_id TEXT,
897
+ attempt_count INTEGER NOT NULL DEFAULT 0,
898
+ posted_at INTEGER,
899
+ collected_at INTEGER,
900
+ error_code TEXT,
901
+ error_message TEXT,
902
+ UNIQUE(run_id, normalized_keyword, device),
903
+ UNIQUE(provider_task_id),
904
+ FOREIGN KEY(run_id) REFERENCES rank_tracking_runs(id) ON DELETE CASCADE
905
+ );
906
+ CREATE INDEX IF NOT EXISTS idx_rank_tracking_tasks_run
907
+ ON rank_tracking_tasks(run_id, state, normalized_keyword, device);
908
+ CREATE INDEX IF NOT EXISTS idx_rank_tracking_tasks_provider
909
+ ON rank_tracking_tasks(provider_task_id, state);
910
+
911
+ CREATE TABLE IF NOT EXISTS rank_tracking_snapshots (
912
+ task_id TEXT PRIMARY KEY,
913
+ run_id TEXT NOT NULL,
914
+ normalized_keyword TEXT NOT NULL,
915
+ display_keyword TEXT NOT NULL,
916
+ device TEXT NOT NULL CHECK(device IN ('desktop', 'mobile')),
917
+ observation_state TEXT NOT NULL CHECK(observation_state IN ('observed', 'not_observed_within_depth')),
918
+ organic_position INTEGER,
919
+ absolute_position INTEGER,
920
+ ranking_url TEXT,
921
+ observed_features_json TEXT NOT NULL,
922
+ checked_at TEXT NOT NULL,
923
+ provider TEXT NOT NULL CHECK(provider IN ('dataforseo', 'semrush', 'ahrefs')),
924
+ provider_task_id TEXT,
925
+ requested_depth INTEGER NOT NULL,
926
+ returned_rows INTEGER,
927
+ retained_rows INTEGER,
928
+ invalid_rows INTEGER NOT NULL,
929
+ completeness TEXT NOT NULL,
930
+ estimated_cost_micros INTEGER,
931
+ actual_cost_micros INTEGER,
932
+ warnings_json TEXT NOT NULL,
933
+ created_at INTEGER NOT NULL,
934
+ FOREIGN KEY(task_id) REFERENCES rank_tracking_tasks(id) ON DELETE CASCADE,
935
+ FOREIGN KEY(run_id) REFERENCES rank_tracking_runs(id) ON DELETE CASCADE
936
+ );
937
+ CREATE INDEX IF NOT EXISTS idx_rank_tracking_snapshots_run
938
+ ON rank_tracking_snapshots(run_id, normalized_keyword, device);
939
+ `;
940
+ //#endregion
836
941
  //#region packages/core/dist/storage/cache-maintenance.js
837
942
  const MEBIBYTE$2 = 1024 * 1024;
838
943
  const CACHE_MAX_AGE_MS = 720 * 60 * 60 * 1e3;
@@ -1388,6 +1493,7 @@ function initDb(database, isNewDatabase) {
1388
1493
  database.transaction(() => {
1389
1494
  database.exec(CREATE_SQL);
1390
1495
  database.exec(KEYWORD_SET_SCHEMA_SQL);
1496
+ database.exec(RANK_TRACKING_SCHEMA_SQL);
1391
1497
  database.exec(PROVIDER_SPEND_SCHEMA_SQL);
1392
1498
  database.exec(`DELETE FROM semrush_cache WHERE request_json LIKE '%"key"%'`);
1393
1499
  ensureColumn(database, "crawl_pages", "snapshot_json", "TEXT");
@@ -4533,7 +4639,7 @@ async function fetchPage(url, opts = {}, dependencies = {}) {
4533
4639
  }
4534
4640
  //#endregion
4535
4641
  //#region packages/core/dist/gsc/property-url.js
4536
- function invalid$1(message) {
4642
+ function invalid$2(message) {
4537
4643
  throw new SeoError("INVALID_INPUT", message);
4538
4644
  }
4539
4645
  function normalizeHttpUrl(value) {
@@ -4541,10 +4647,10 @@ function normalizeHttpUrl(value) {
4541
4647
  try {
4542
4648
  url = new URL(value.trim());
4543
4649
  } catch {
4544
- return invalid$1(`Invalid URL: ${value}`);
4650
+ return invalid$2(`Invalid URL: ${value}`);
4545
4651
  }
4546
- if (url.protocol !== "http:" && url.protocol !== "https:") return invalid$1("The report URL must use http:// or https://.");
4547
- if (url.username || url.password) return invalid$1("The report URL must not contain embedded credentials.");
4652
+ if (url.protocol !== "http:" && url.protocol !== "https:") return invalid$2("The report URL must use http:// or https://.");
4653
+ if (url.username || url.password) return invalid$2("The report URL must not contain embedded credentials.");
4548
4654
  url.hash = "";
4549
4655
  return url.toString();
4550
4656
  }
@@ -4553,13 +4659,13 @@ function assertUrlMatchesGscProperty(property, value) {
4553
4659
  const parsed = new URL(url);
4554
4660
  if (property.startsWith("sc-domain:")) {
4555
4661
  const domain = property.slice(10).trim().toLowerCase();
4556
- if (!domain || /[/:]/.test(domain)) return invalid$1(`Invalid Search Console domain property: ${property}`);
4662
+ if (!domain || /[/:]/.test(domain)) return invalid$2(`Invalid Search Console domain property: ${property}`);
4557
4663
  const hostname = parsed.hostname.toLowerCase();
4558
- if (hostname !== domain && !hostname.endsWith(`.${domain}`)) return invalid$1(`${url} is outside the Search Console property ${property}.`);
4664
+ if (hostname !== domain && !hostname.endsWith(`.${domain}`)) return invalid$2(`${url} is outside the Search Console property ${property}.`);
4559
4665
  return url;
4560
4666
  }
4561
4667
  const prefix = normalizeHttpUrl(property);
4562
- if (!url.startsWith(prefix)) return invalid$1(`${url} is outside the Search Console property ${property}.`);
4668
+ if (!url.startsWith(prefix)) return invalid$2(`${url} is outside the Search Console property ${property}.`);
4563
4669
  return url;
4564
4670
  }
4565
4671
  //#endregion
@@ -6643,7 +6749,7 @@ function selectBenchmarkRows$1(input) {
6643
6749
  function compareText$15(left, right) {
6644
6750
  return left < right ? -1 : left > right ? 1 : 0;
6645
6751
  }
6646
- function compareItems$1(left, right) {
6752
+ function compareItems$2(left, right) {
6647
6753
  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);
6648
6754
  }
6649
6755
  function analyzeRow(input) {
@@ -6708,7 +6814,7 @@ function analyzePageOpportunitiesFromRows(input) {
6708
6814
  reportInput: input,
6709
6815
  benchmarkContext,
6710
6816
  excludedTargetRows
6711
- })).sort(compareItems$1).slice(0, limit);
6817
+ })).sort(compareItems$2).slice(0, limit);
6712
6818
  const estimatedCtrClickShortfall = Number(items.reduce((sum, item) => sum + (item.estimatedCtrClickShortfall ?? 0), 0).toFixed(2));
6713
6819
  return {
6714
6820
  site: input.site,
@@ -16785,7 +16891,7 @@ function compareRetainedSegmentRows(input) {
16785
16891
  }
16786
16892
  //#endregion
16787
16893
  //#region packages/core/dist/analyze/segment-impact-analysis.js
16788
- function verdict$4(input) {
16894
+ function verdict$5(input) {
16789
16895
  if (input.status === "empty") return "Search Console returned no retained segment rows for either window.";
16790
16896
  if (!input.matchedRows) return "No segment was retained in both windows, so no movement delta was inferred.";
16791
16897
  const suffix = input.unmatchedRows ? ` ${input.unmatchedRows} one-window rows were kept as unmatched evidence and not converted to zero.` : "";
@@ -16878,7 +16984,7 @@ function analyzeSegmentRows(input) {
16878
16984
  matchedRows: matched.length,
16879
16985
  returnedRows: selection.returnedRows,
16880
16986
  unmatchedRows: unmatched.length,
16881
- verdict: verdict$4({
16987
+ verdict: verdict$5({
16882
16988
  status: dataStatus,
16883
16989
  matchedRows: matched.length,
16884
16990
  returnedRows: selection.returnedRows,
@@ -17642,7 +17748,7 @@ const defaultDependencies$10 = {
17642
17748
  searchAnalytics: querySearchAnalytics,
17643
17749
  now: () => /* @__PURE__ */ new Date()
17644
17750
  };
17645
- function verdict$3(input) {
17751
+ function verdict$4(input) {
17646
17752
  if (input.status === "empty") return "Search Console returned no retained query/page rows for this window.";
17647
17753
  if (!input.eligible) {
17648
17754
  if (input.status === "partial") return "No material multi-URL query candidates remained in the validated retained rows, but partial evidence prevents an all-clear.";
@@ -17769,7 +17875,7 @@ async function cannibalReport(input, dependencies = defaultDependencies$10) {
17769
17875
  returnedClusters: analysis.selection.returnedClusters,
17770
17876
  suppressedQueries: analysis.selection.suppressedQueries,
17771
17877
  brandFiltering: input.includeBrand ? "included" : "excluded",
17772
- verdict: verdict$3({
17878
+ verdict: verdict$4({
17773
17879
  status: dataStatus,
17774
17880
  eligible: analysis.selection.eligibleClusters,
17775
17881
  returned: analysis.selection.returnedClusters
@@ -18055,7 +18161,7 @@ const defaultDependencies$9 = {
18055
18161
  searchAnalytics: querySearchAnalytics,
18056
18162
  now: () => /* @__PURE__ */ new Date()
18057
18163
  };
18058
- function verdict$2(report) {
18164
+ function verdict$3(report) {
18059
18165
  if (report.dataStatus === "empty") return "Search Console returned no retained query/page rows for either comparison window.";
18060
18166
  if (report.dataStatus === "partial" && report.selection.currentAggregatedRows === 0) return "The current retained-row window is empty, so no query/page losses were inferred.";
18061
18167
  if (report.dataStatus === "partial" && report.selection.previousAggregatedRows === 0) return "The comparison retained-row window is empty, so query/page decay could not be measured.";
@@ -18200,7 +18306,7 @@ async function decayingReport(input, dependencies = defaultDependencies$9) {
18200
18306
  observedRetainedQueryClickLoss: analysis.totals.eligibleObservedRetainedQueryClickLoss,
18201
18307
  returnedObservedRetainedQueryClickLoss: analysis.totals.returnedObservedRetainedQueryClickLoss,
18202
18308
  brandFiltering: input.includeBrand ? "included" : "excluded",
18203
- verdict: verdict$2(reportBase)
18309
+ verdict: verdict$3(reportBase)
18204
18310
  },
18205
18311
  caveats: [
18206
18312
  `Current window: ${currentRange.startDate} to ${currentRange.endDate}; comparison: ${previousRange.startDate} to ${previousRange.endDate}.`,
@@ -18994,7 +19100,7 @@ function templatesFromGroups(groups) {
18994
19100
  sampleUrls: group.sampleUrls
18995
19101
  }));
18996
19102
  }
18997
- function verdict$1(analysis) {
19103
+ function verdict$2(analysis) {
18998
19104
  if (analysis.dataStatus === "empty") return "Search Console returned no retained query/page rows for this date window.";
18999
19105
  if (analysis.dataStatus === "filtered") return "No retained query/page rows met the report filters.";
19000
19106
  return `${countLabel(analysis.summary.eligibleRows, "eligible query/page row")} found; ${countLabel(analysis.summary.returnedRows, "row")} returned for review.`;
@@ -19078,7 +19184,7 @@ async function strikingDistance(input, dependencies = defaultDependencies$7) {
19078
19184
  summary: {
19079
19185
  ...analysis.summary,
19080
19186
  brandFiltering: input.includeBrand ? "included" : "excluded",
19081
- verdict: verdict$1(analysis)
19187
+ verdict: verdict$2(analysis)
19082
19188
  },
19083
19189
  caveats: [
19084
19190
  `Date window: ${range.startDate} to ${range.endDate}, using final GSC data where available.`,
@@ -22178,7 +22284,10 @@ const userDataPriceSchema = z.object({
22178
22284
  keyword_suggestions: livePriceSchema.optional(),
22179
22285
  related_keywords: livePriceSchema.optional()
22180
22286
  }).passthrough().optional(),
22181
- serp: z.object({ live: z.object({ advanced: normalPriorityPriceSchema.optional() }).passthrough().optional() }).passthrough().optional()
22287
+ serp: z.object({
22288
+ task_post: normalPriorityPriceSchema.optional(),
22289
+ live: z.object({ advanced: normalPriorityPriceSchema.optional() }).passthrough().optional()
22290
+ }).passthrough().optional()
22182
22291
  }).passthrough();
22183
22292
  const userDataResultSchema = z.object({
22184
22293
  login: z.string().min(1).max(320),
@@ -22296,19 +22405,19 @@ const dataForSeoDiscoveryResponseSchema = z.object({
22296
22405
  }).passthrough();
22297
22406
  //#endregion
22298
22407
  //#region packages/core/dist/providers/dataforseo/paid-request.js
22299
- function usdToMicros$1(value) {
22408
+ function usdToMicros(value) {
22300
22409
  if (value === void 0) return null;
22301
22410
  return Math.round(value * 1e6);
22302
22411
  }
22303
22412
  function responseTaskIds$1(response) {
22304
22413
  return [...new Set(response.tasks.flatMap((task) => task.id ? [task.id] : []))].sort((left, right) => left < right ? -1 : left > right ? 1 : 0);
22305
22414
  }
22306
- function responseCostMicros$1(response) {
22307
- if (response.cost !== void 0) return usdToMicros$1(response.cost);
22415
+ function responseCostMicros(response) {
22416
+ if (response.cost !== void 0) return usdToMicros(response.cost);
22308
22417
  if (response.tasks.some((task) => task.cost === void 0)) return null;
22309
- return response.tasks.reduce((sum, task) => sum + (usdToMicros$1(task.cost) ?? 0), 0);
22418
+ return response.tasks.reduce((sum, task) => sum + (usdToMicros(task.cost) ?? 0), 0);
22310
22419
  }
22311
- function taskErrorCode$1(statusCode) {
22420
+ function taskErrorCode(statusCode) {
22312
22421
  if (statusCode >= 40100 && statusCode < 40200) return "authentication";
22313
22422
  if (statusCode === 40202) return "rate-limit";
22314
22423
  return "remote-error";
@@ -22398,7 +22507,7 @@ async function dataForSeoPaidPost(input) {
22398
22507
  });
22399
22508
  throw error;
22400
22509
  }
22401
- const actualCostMicros = responseCostMicros$1(response);
22510
+ const actualCostMicros = responseCostMicros(response);
22402
22511
  const returnedRows = input.rowCount(response);
22403
22512
  const taskIds = responseTaskIds$1(response);
22404
22513
  const failedTasks = response.tasks.filter((task) => task.status_code !== 2e4);
@@ -22420,7 +22529,7 @@ async function dataForSeoPaidPost(input) {
22420
22529
  throw new ProviderError({
22421
22530
  provider: "dataforseo",
22422
22531
  operation: input.operation,
22423
- code: taskErrorCode$1(statusCode),
22532
+ code: taskErrorCode(statusCode),
22424
22533
  message: `DataForSEO could not complete ${input.operation} (${statusCode}).`,
22425
22534
  retryable: statusCode === 40202
22426
22535
  });
@@ -22463,6 +22572,22 @@ async function dataForSeoPaidPost(input) {
22463
22572
  };
22464
22573
  }
22465
22574
  //#endregion
22575
+ //#region packages/core/dist/providers/dataforseo/response.js
22576
+ function dataForSeoUsdToMicros(value) {
22577
+ if (value === void 0) return null;
22578
+ return Math.round(value * 1e6);
22579
+ }
22580
+ function dataForSeoTaskErrorCode(statusCode) {
22581
+ if (statusCode >= 40100 && statusCode < 40200) return "authentication";
22582
+ if (statusCode === 40202) return "rate-limit";
22583
+ return "remote-error";
22584
+ }
22585
+ function dataForSeoResponseCostMicros(response) {
22586
+ if (response.cost !== void 0) return dataForSeoUsdToMicros(response.cost);
22587
+ if (response.tasks.some((task) => task.cost === void 0)) return null;
22588
+ return response.tasks.reduce((sum, task) => sum + (dataForSeoUsdToMicros(task.cost) ?? 0), 0);
22589
+ }
22590
+ //#endregion
22466
22591
  //#region packages/core/dist/providers/dataforseo/serp-schema.js
22467
22592
  const serpItemSchema = z.object({
22468
22593
  type: z.string().trim().min(1).max(100),
@@ -22507,6 +22632,237 @@ const dataForSeoSerpResponseSchema = z.object({
22507
22632
  tasks: z.array(serpTaskSchema).max(10)
22508
22633
  }).passthrough();
22509
22634
  //#endregion
22635
+ //#region packages/core/dist/providers/dataforseo/serp-task-schema.js
22636
+ const taskPostItemSchema = z.object({
22637
+ id: z.string().trim().min(1).max(100).optional(),
22638
+ status_code: z.number().int(),
22639
+ status_message: z.string().max(1e3),
22640
+ cost: z.number().finite().nonnegative().optional(),
22641
+ result_count: z.number().int().nonnegative().optional(),
22642
+ data: z.object({ tag: z.string().trim().min(1).max(255).optional() }).passthrough().optional()
22643
+ }).passthrough();
22644
+ const dataForSeoSerpTaskPostResponseSchema = z.object({
22645
+ status_code: z.number().int(),
22646
+ status_message: z.string().max(1e3),
22647
+ cost: z.number().finite().nonnegative().optional(),
22648
+ tasks_count: z.number().int().nonnegative(),
22649
+ tasks_error: z.number().int().nonnegative(),
22650
+ tasks: z.array(taskPostItemSchema).max(100)
22651
+ }).passthrough();
22652
+ const readyResultSchema = z.object({
22653
+ id: z.string().trim().min(1).max(100),
22654
+ tag: z.string().trim().max(255).nullable().optional(),
22655
+ se: z.string().trim().max(100).optional(),
22656
+ se_type: z.string().trim().max(100).optional(),
22657
+ endpoint_advanced: z.string().trim().max(2048).nullable().optional()
22658
+ }).passthrough();
22659
+ const readyTaskSchema = z.object({
22660
+ id: z.string().trim().min(1).max(100).optional(),
22661
+ status_code: z.number().int(),
22662
+ status_message: z.string().max(1e3),
22663
+ cost: z.number().finite().nonnegative().optional(),
22664
+ result: z.array(readyResultSchema).max(1e3).nullable().optional()
22665
+ }).passthrough();
22666
+ const dataForSeoSerpTasksReadyResponseSchema = z.object({
22667
+ status_code: z.number().int(),
22668
+ status_message: z.string().max(1e3),
22669
+ cost: z.number().finite().nonnegative().optional(),
22670
+ tasks_count: z.number().int().nonnegative(),
22671
+ tasks_error: z.number().int().nonnegative(),
22672
+ tasks: z.array(readyTaskSchema).max(10)
22673
+ }).passthrough();
22674
+ //#endregion
22675
+ //#region packages/core/dist/providers/dataforseo/serp-tasks.js
22676
+ const SERP_TASK_POST_PATH = "v3/serp/google/organic/task_post";
22677
+ const SERP_TASKS_READY_PATH = "v3/serp/google/organic/tasks_ready";
22678
+ const SERP_TASK_GET_ADVANCED_PATH = "v3/serp/google/organic/task_get/advanced/";
22679
+ function dataForSeoSerpRows(response) {
22680
+ return response.tasks.reduce((taskTotal, task) => taskTotal + (task.result ?? []).reduce((resultTotal, result) => resultTotal + (result.items?.length ?? 0), 0), 0);
22681
+ }
22682
+ function validateDataForSeoSerpTaskId(providerTaskId) {
22683
+ if (!/^[a-zA-Z0-9-]{1,100}$/u.test(providerTaskId)) throw new ProviderError({
22684
+ provider: "dataforseo",
22685
+ operation: "serp-task-get",
22686
+ code: "configuration",
22687
+ message: "Use a valid queued SERP task id."
22688
+ });
22689
+ }
22690
+ async function postDataForSeoSerpTasks(input, transport) {
22691
+ const { price, requests } = input;
22692
+ if (price.perRequestMicros === null || price.perResultMicros === null) throw new ProviderError({
22693
+ provider: "dataforseo",
22694
+ operation: "serp-task-post",
22695
+ code: "invalid-response",
22696
+ message: "DataForSEO Standard SERP pricing is unavailable, so queued tasks were not posted."
22697
+ });
22698
+ const requestUnits = requests.reduce((total, request) => total + Math.ceil(request.depth / 10), 0);
22699
+ const estimatedCostMicros = price.perRequestMicros * requestUnits + price.perResultMicros * requests.length;
22700
+ const reservation = reserveProviderSpend({
22701
+ provider: "dataforseo",
22702
+ capability: "serp-snapshot",
22703
+ endpoint: SERP_TASK_POST_PATH,
22704
+ projectId: input.context.projectId,
22705
+ reportId: input.context.reportId,
22706
+ reportRunId: input.context.reportRunId,
22707
+ requestedRows: requests.length,
22708
+ estimatedCostMicros
22709
+ }, {
22710
+ database: transport.database,
22711
+ limits: transport.spendLimits,
22712
+ now: transport.now().getTime()
22713
+ });
22714
+ let response;
22715
+ try {
22716
+ response = await providerRequestJson({
22717
+ provider: "dataforseo",
22718
+ operation: "serp-task-post",
22719
+ url: new URL(SERP_TASK_POST_PATH, transport.baseUrl),
22720
+ fetch: transport.fetch,
22721
+ maxResponseBytes: transport.maxResponseBytes,
22722
+ timeoutMs: transport.timeoutMs,
22723
+ retry: "never",
22724
+ schema: dataForSeoSerpTaskPostResponseSchema,
22725
+ init: {
22726
+ method: "POST",
22727
+ headers: {
22728
+ authorization: transport.authorization,
22729
+ "content-type": "application/json"
22730
+ },
22731
+ body: JSON.stringify(requests)
22732
+ }
22733
+ });
22734
+ } catch (error) {
22735
+ finalizeProviderSpend(reservation.id, {
22736
+ provider: "dataforseo",
22737
+ state: "failed",
22738
+ actualCostMicros: null,
22739
+ returnedRows: null,
22740
+ taskIds: []
22741
+ }, {
22742
+ database: transport.database,
22743
+ limits: transport.spendLimits,
22744
+ now: transport.now().getTime()
22745
+ });
22746
+ throw error;
22747
+ }
22748
+ const taskReceipts = response.tasks.flatMap((task) => task.id && task.data?.tag ? [{
22749
+ providerTaskId: task.id,
22750
+ tag: task.data.tag
22751
+ }] : []);
22752
+ const taskIds = taskReceipts.map((receipt) => receipt.providerTaskId);
22753
+ const expectedTags = new Set(requests.map((request) => request.tag));
22754
+ const returnedTags = new Set(taskReceipts.map((receipt) => receipt.tag));
22755
+ const receiptsMatch = expectedTags.size === requests.length && returnedTags.size === expectedTags.size && [...expectedTags].every((tag) => returnedTags.has(tag));
22756
+ const failedTasks = response.tasks.filter((task) => task.status_code !== 20100 || !task.id || !task.data?.tag);
22757
+ const actualCostMicros = dataForSeoResponseCostMicros(response);
22758
+ const state = failedTasks.length === 0 && response.tasks_error === 0 ? "succeeded" : failedTasks.length < response.tasks.length ? "partial" : "failed";
22759
+ const spendNotice = finalizeProviderSpend(reservation.id, {
22760
+ provider: "dataforseo",
22761
+ state,
22762
+ actualCostMicros,
22763
+ returnedRows: taskIds.length,
22764
+ taskIds
22765
+ }, {
22766
+ database: transport.database,
22767
+ limits: transport.spendLimits,
22768
+ now: transport.now().getTime()
22769
+ });
22770
+ if (response.status_code !== 2e4 || failedTasks.length > 0 || taskIds.length !== requests.length || !receiptsMatch) {
22771
+ const statusCode = failedTasks[0]?.status_code ?? response.status_code;
22772
+ throw new ProviderError({
22773
+ provider: "dataforseo",
22774
+ operation: "serp-task-post",
22775
+ code: dataForSeoTaskErrorCode(statusCode),
22776
+ message: `DataForSEO accepted ${taskIds.length} of ${requests.length} queued SERP tasks (${statusCode}).`
22777
+ });
22778
+ }
22779
+ return {
22780
+ taskIds,
22781
+ taskReceipts,
22782
+ estimatedCostMicros,
22783
+ actualCostMicros,
22784
+ spendNotice,
22785
+ warnings: [],
22786
+ observedAt: transport.now().toISOString()
22787
+ };
22788
+ }
22789
+ async function listReadyDataForSeoSerpTasks(transport) {
22790
+ const response = await providerRequestJson({
22791
+ provider: "dataforseo",
22792
+ operation: "serp-tasks-ready",
22793
+ url: new URL(SERP_TASKS_READY_PATH, transport.baseUrl),
22794
+ fetch: transport.fetch,
22795
+ maxResponseBytes: transport.maxResponseBytes,
22796
+ timeoutMs: transport.timeoutMs,
22797
+ retry: "safe",
22798
+ schema: dataForSeoSerpTasksReadyResponseSchema,
22799
+ init: {
22800
+ method: "GET",
22801
+ headers: { authorization: transport.authorization }
22802
+ }
22803
+ });
22804
+ const failedTask = response.tasks.find((task) => task.status_code !== 2e4);
22805
+ if (response.status_code !== 2e4 || failedTask) {
22806
+ const statusCode = failedTask?.status_code ?? response.status_code;
22807
+ throw new ProviderError({
22808
+ provider: "dataforseo",
22809
+ operation: "serp-tasks-ready",
22810
+ code: dataForSeoTaskErrorCode(statusCode),
22811
+ message: `DataForSEO could not list ready SERP tasks (${statusCode}).`,
22812
+ retryable: statusCode === 40202
22813
+ });
22814
+ }
22815
+ return response.tasks.flatMap((task) => (task.result ?? []).map((result) => ({
22816
+ providerTaskId: result.id,
22817
+ tag: result.tag ?? null
22818
+ })));
22819
+ }
22820
+ async function getDataForSeoSerpTask(providerTaskId, transport) {
22821
+ const response = await providerRequestJson({
22822
+ provider: "dataforseo",
22823
+ operation: "serp-task-get",
22824
+ url: new URL(`${SERP_TASK_GET_ADVANCED_PATH}${providerTaskId}`, transport.baseUrl),
22825
+ fetch: transport.fetch,
22826
+ maxResponseBytes: transport.maxResponseBytes,
22827
+ timeoutMs: transport.timeoutMs,
22828
+ retry: "safe",
22829
+ schema: dataForSeoSerpResponseSchema,
22830
+ init: {
22831
+ method: "GET",
22832
+ headers: { authorization: transport.authorization }
22833
+ }
22834
+ });
22835
+ const failedTask = response.tasks.find((task) => task.status_code !== 2e4);
22836
+ if (response.status_code !== 2e4 || failedTask) {
22837
+ const statusCode = failedTask?.status_code ?? response.status_code;
22838
+ throw new ProviderError({
22839
+ provider: "dataforseo",
22840
+ operation: "serp-task-get",
22841
+ code: dataForSeoTaskErrorCode(statusCode),
22842
+ message: `DataForSEO could not collect queued SERP task ${providerTaskId} (${statusCode}).`,
22843
+ retryable: statusCode === 40202
22844
+ });
22845
+ }
22846
+ return {
22847
+ response,
22848
+ observedAt: transport.now().toISOString(),
22849
+ returnedRows: dataForSeoSerpRows(response),
22850
+ cache: {
22851
+ status: "bypass",
22852
+ storedAt: null,
22853
+ expiresAt: null
22854
+ },
22855
+ cost: {
22856
+ currency: "USD",
22857
+ estimatedMicros: 0,
22858
+ actualMicros: dataForSeoResponseCostMicros(response) ?? 0,
22859
+ taskIds: [providerTaskId]
22860
+ },
22861
+ spendNotice: null,
22862
+ warnings: []
22863
+ };
22864
+ }
22865
+ //#endregion
22510
22866
  //#region packages/core/dist/providers/dataforseo/client.js
22511
22867
  const DEFAULT_BASE_URL$1 = "https://api.dataforseo.com/";
22512
22868
  const USER_DATA_PATH = "v3/appendix/user_data";
@@ -22529,9 +22885,43 @@ const MAX_KEYWORD_WORDS = 10;
22529
22885
  const MAX_DISCOVERY_SEEDS$1 = 5;
22530
22886
  const MAX_DISCOVERY_ROWS$1 = 100;
22531
22887
  const MAX_SERP_DEPTH$1 = 100;
22532
- function usdToMicros(value) {
22533
- if (value === void 0) return null;
22534
- return Math.round(value * 1e6);
22888
+ function validateSerpInput(input) {
22889
+ const keyword = input.keyword.trim().replace(/\s+/gu, " ");
22890
+ if (keyword.length < 1 || keyword.length > MAX_KEYWORD_CHARACTERS || keyword.split(/\s+/u).length > MAX_KEYWORD_WORDS) throw new ProviderError({
22891
+ provider: "dataforseo",
22892
+ operation: "serp-snapshot",
22893
+ code: "configuration",
22894
+ message: `SERP snapshots require a keyword of at most ${MAX_KEYWORD_CHARACTERS} characters and ${MAX_KEYWORD_WORDS} words.`
22895
+ });
22896
+ if (/(?:^|\s)(?:allinanchor|allintext|allintitle|allinurl|cache|define|definition|filetype|id|inanchor|info|intext|intitle|inurl|link|site):/iu.test(keyword)) throw new ProviderError({
22897
+ provider: "dataforseo",
22898
+ operation: "serp-snapshot",
22899
+ code: "configuration",
22900
+ message: "SERP snapshot keywords cannot contain search operators with multiplied provider pricing."
22901
+ });
22902
+ if (!Number.isSafeInteger(input.depth) || input.depth < 1 || input.depth > MAX_SERP_DEPTH$1) throw new ProviderError({
22903
+ provider: "dataforseo",
22904
+ operation: "serp-snapshot",
22905
+ code: "configuration",
22906
+ message: `SERP depth must be from 1 to ${MAX_SERP_DEPTH$1}.`
22907
+ });
22908
+ if (!/^[a-z]{2}$/.test(input.languageCode)) throw new ProviderError({
22909
+ provider: "dataforseo",
22910
+ operation: "serp-snapshot",
22911
+ code: "configuration",
22912
+ message: "DataForSEO language code must contain two lowercase letters."
22913
+ });
22914
+ const locationName = input.locationName?.trim();
22915
+ if (input.locationCode !== void 0 === Boolean(locationName)) throw new ProviderError({
22916
+ provider: "dataforseo",
22917
+ operation: "serp-snapshot",
22918
+ code: "configuration",
22919
+ message: "DataForSEO SERP snapshots require exactly one location code or location name."
22920
+ });
22921
+ return {
22922
+ keyword,
22923
+ locationName
22924
+ };
22535
22925
  }
22536
22926
  function unitPrice(components) {
22537
22927
  const prices = components ?? [];
@@ -22557,19 +22947,12 @@ function keywordDiscoveryPrices(account) {
22557
22947
  function serpLiveAdvancedPrice(account) {
22558
22948
  return unitPrice(account.price?.serp?.live?.advanced?.priority_normal);
22559
22949
  }
22560
- function taskErrorCode(statusCode) {
22561
- if (statusCode >= 40100 && statusCode < 40200) return "authentication";
22562
- if (statusCode === 40202) return "rate-limit";
22563
- return "remote-error";
22950
+ function serpTaskPostPrice(account) {
22951
+ return unitPrice(account.price?.serp?.task_post?.priority_normal);
22564
22952
  }
22565
22953
  function responseTaskIds(response) {
22566
22954
  return [...new Set(response.tasks.flatMap((task) => task.id ? [task.id] : []))].sort((left, right) => left < right ? -1 : left > right ? 1 : 0);
22567
22955
  }
22568
- function responseCostMicros(response) {
22569
- if (response.cost !== void 0) return usdToMicros(response.cost);
22570
- if (response.tasks.some((task) => task.cost === void 0)) return null;
22571
- return response.tasks.reduce((sum, task) => sum + (usdToMicros(task.cost) ?? 0), 0);
22572
- }
22573
22956
  function responseRows$1(response) {
22574
22957
  return response.tasks.reduce((taskTotal, task) => taskTotal + (task.result ?? []).reduce((resultTotal, result) => resultTotal + (result.items?.length ?? 0), 0), 0);
22575
22958
  }
@@ -22584,9 +22967,6 @@ function discoveryNextCursor(response) {
22584
22967
  const cursors = response.tasks.flatMap((task) => (task.result ?? []).flatMap((result) => result.offset_token ? [result.offset_token] : []));
22585
22968
  return cursors.length === 1 ? cursors[0] ?? null : null;
22586
22969
  }
22587
- function serpRows(response) {
22588
- return response.tasks.reduce((taskTotal, task) => taskTotal + (task.result ?? []).reduce((resultTotal, result) => resultTotal + (result.items?.length ?? 0), 0), 0);
22589
- }
22590
22970
  var DataForSeoClient = class {
22591
22971
  fetch;
22592
22972
  credentials;
@@ -22629,6 +23009,18 @@ var DataForSeoClient = class {
22629
23009
  authorization(credentials) {
22630
23010
  return `Basic ${Buffer.from(`${credentials.login}:${credentials.password}`).toString("base64")}`;
22631
23011
  }
23012
+ serpTaskTransport(credentials) {
23013
+ return {
23014
+ authorization: this.authorization(credentials),
23015
+ baseUrl: this.baseUrl,
23016
+ fetch: this.fetch,
23017
+ maxResponseBytes: this.maxResponseBytes,
23018
+ timeoutMs: this.timeoutMs,
23019
+ now: this.now,
23020
+ database: this.database,
23021
+ spendLimits: this.spendLimits
23022
+ };
23023
+ }
22632
23024
  async userDataForCredentials(credentials) {
22633
23025
  let response;
22634
23026
  try {
@@ -22663,7 +23055,7 @@ var DataForSeoClient = class {
22663
23055
  throw new ProviderError({
22664
23056
  provider: "dataforseo",
22665
23057
  operation: "account-status",
22666
- code: taskErrorCode(statusCode),
23058
+ code: dataForSeoTaskErrorCode(statusCode),
22667
23059
  message: `DataForSEO could not return account status (${statusCode}).`,
22668
23060
  retryable: statusCode === 40202
22669
23061
  });
@@ -22679,18 +23071,19 @@ var DataForSeoClient = class {
22679
23071
  provider: "dataforseo",
22680
23072
  login: account.login,
22681
23073
  timezone: account.timezone ?? null,
22682
- balanceMicros: usdToMicros(account.money?.balance),
22683
- depositedMicros: usdToMicros(account.money?.total),
22684
- accountDailySpendMicros: usdToMicros(account.money?.statistics?.day?.total),
23074
+ balanceMicros: dataForSeoUsdToMicros(account.money?.balance),
23075
+ depositedMicros: dataForSeoUsdToMicros(account.money?.total),
23076
+ accountDailySpendMicros: dataForSeoUsdToMicros(account.money?.statistics?.day?.total),
22685
23077
  accountDailySpendPeriod: account.money?.statistics?.day?.value ?? null,
22686
- accountDailyLimitMicros: usdToMicros(account.money?.limits?.day?.total),
23078
+ accountDailyLimitMicros: dataForSeoUsdToMicros(account.money?.limits?.day?.total),
22687
23079
  keywordOverviewPrice: keywordOverviewPrice(account),
22688
23080
  keywordDiscoveryPrices: keywordDiscoveryPrices(account),
22689
23081
  serpLiveAdvancedPrice: serpLiveAdvancedPrice(account),
23082
+ serpTaskPostPrice: serpTaskPostPrice(account),
22690
23083
  backlinksSubscriptionExpiresAt: account.backlinks_subscription_expiry_date ?? null,
22691
23084
  aiMentionsSubscriptionExpiresAt: account.llm_mentions_subscription_expiry_date ?? null,
22692
23085
  apiVersion: response.version ?? null,
22693
- requestCostMicros: usdToMicros(response.cost) ?? 0,
23086
+ requestCostMicros: dataForSeoUsdToMicros(response.cost) ?? 0,
22694
23087
  taskIds: response.tasks.flatMap((task) => task.id ? [task.id] : []),
22695
23088
  observedAt: this.now().toISOString()
22696
23089
  };
@@ -22866,7 +23259,7 @@ var DataForSeoClient = class {
22866
23259
  });
22867
23260
  throw error;
22868
23261
  }
22869
- const actualCostMicros = responseCostMicros(response);
23262
+ const actualCostMicros = dataForSeoResponseCostMicros(response);
22870
23263
  const returnedRows = responseRows$1(response);
22871
23264
  const taskIds = responseTaskIds(response);
22872
23265
  const failedTasks = response.tasks.filter((task) => task.status_code !== 2e4);
@@ -22888,7 +23281,7 @@ var DataForSeoClient = class {
22888
23281
  throw new ProviderError({
22889
23282
  provider: "dataforseo",
22890
23283
  operation: "keyword-metrics",
22891
- code: taskErrorCode(statusCode),
23284
+ code: dataForSeoTaskErrorCode(statusCode),
22892
23285
  message: `DataForSEO could not complete the keyword metrics task (${statusCode}).`,
22893
23286
  retryable: statusCode === 40202
22894
23287
  });
@@ -23000,38 +23393,7 @@ var DataForSeoClient = class {
23000
23393
  };
23001
23394
  }
23002
23395
  async serpLive(input) {
23003
- const keyword = input.keyword.trim();
23004
- if (keyword.length < 1 || keyword.length > MAX_KEYWORD_CHARACTERS || keyword.split(/\s+/u).length > MAX_KEYWORD_WORDS) throw new ProviderError({
23005
- provider: "dataforseo",
23006
- operation: "serp-snapshot",
23007
- code: "configuration",
23008
- message: `SERP snapshots require a keyword of at most ${MAX_KEYWORD_CHARACTERS} characters and ${MAX_KEYWORD_WORDS} words.`
23009
- });
23010
- if (/(?:^|\s)(?:allinanchor|allintext|allintitle|allinurl|cache|define|definition|filetype|id|inanchor|info|intext|intitle|inurl|link|site):/iu.test(keyword)) throw new ProviderError({
23011
- provider: "dataforseo",
23012
- operation: "serp-snapshot",
23013
- code: "configuration",
23014
- message: "SERP snapshot keywords cannot contain search operators with multiplied provider pricing."
23015
- });
23016
- if (!Number.isSafeInteger(input.depth) || input.depth < 1 || input.depth > MAX_SERP_DEPTH$1) throw new ProviderError({
23017
- provider: "dataforseo",
23018
- operation: "serp-snapshot",
23019
- code: "configuration",
23020
- message: `SERP depth must be from 1 to ${MAX_SERP_DEPTH$1}.`
23021
- });
23022
- if (!/^[a-z]{2}$/.test(input.languageCode)) throw new ProviderError({
23023
- provider: "dataforseo",
23024
- operation: "serp-snapshot",
23025
- code: "configuration",
23026
- message: "DataForSEO language code must contain two lowercase letters."
23027
- });
23028
- const locationName = input.locationName?.trim();
23029
- if (input.locationCode !== void 0 === Boolean(locationName)) throw new ProviderError({
23030
- provider: "dataforseo",
23031
- operation: "serp-snapshot",
23032
- code: "configuration",
23033
- message: "DataForSEO SERP snapshots require exactly one location code or location name."
23034
- });
23396
+ const { keyword, locationName } = validateSerpInput(input);
23035
23397
  const request = {
23036
23398
  keyword,
23037
23399
  language_code: input.languageCode,
@@ -23052,9 +23414,56 @@ var DataForSeoClient = class {
23052
23414
  context: input.context,
23053
23415
  ttlMs: this.serpTtlMs,
23054
23416
  refresh: input.refresh,
23055
- rowCount: serpRows
23417
+ rowCount: dataForSeoSerpRows
23418
+ });
23419
+ }
23420
+ async serpTaskPost(input) {
23421
+ if (input.tasks.length < 1 || input.tasks.length > 100) throw new ProviderError({
23422
+ provider: "dataforseo",
23423
+ operation: "serp-task-post",
23424
+ code: "configuration",
23425
+ message: `A queued SERP post requires 1 to 100 tasks.`
23426
+ });
23427
+ const requests = input.tasks.map((task) => {
23428
+ const validated = validateSerpInput(task);
23429
+ return {
23430
+ keyword: validated.keyword,
23431
+ language_code: task.languageCode,
23432
+ ...task.locationCode !== void 0 ? { location_code: task.locationCode } : { location_name: validated.locationName },
23433
+ device: task.device,
23434
+ depth: task.depth,
23435
+ tag: task.tag,
23436
+ priority: 1,
23437
+ remove_from_url: ["srsltid"]
23438
+ };
23439
+ });
23440
+ const credentials = await this.getCredentials("serp-task-post");
23441
+ const credentialScope = providerCredentialScope("dataforseo", credentials.login);
23442
+ const price = (await this.pricingForCredentials(credentials, credentialScope)).serpTaskPostPrice;
23443
+ return postDataForSeoSerpTasks({
23444
+ requests,
23445
+ context: input.context,
23446
+ price
23447
+ }, {
23448
+ authorization: this.authorization(credentials),
23449
+ baseUrl: this.baseUrl,
23450
+ fetch: this.fetch,
23451
+ maxResponseBytes: this.maxResponseBytes,
23452
+ timeoutMs: this.timeoutMs,
23453
+ now: this.now,
23454
+ database: this.database,
23455
+ spendLimits: this.spendLimits
23056
23456
  });
23057
23457
  }
23458
+ async serpTasksReady() {
23459
+ const credentials = await this.getCredentials("serp-tasks-ready");
23460
+ return listReadyDataForSeoSerpTasks(this.serpTaskTransport(credentials));
23461
+ }
23462
+ async serpTaskGet(providerTaskId) {
23463
+ validateDataForSeoSerpTaskId(providerTaskId);
23464
+ const credentials = await this.getCredentials("serp-task-get");
23465
+ return getDataForSeoSerpTask(providerTaskId, this.serpTaskTransport(credentials));
23466
+ }
23058
23467
  };
23059
23468
  //#endregion
23060
23469
  //#region packages/core/dist/providers/dataforseo/keyword-mapping.js
@@ -24338,7 +24747,7 @@ function pageItem(url, rows) {
24338
24747
  recommendation: nextRecommendation
24339
24748
  };
24340
24749
  }
24341
- function compareItems(left, right) {
24750
+ function compareItems$1(left, right) {
24342
24751
  return right.priority.score - left.priority.score || right.impressions - left.impressions || left.position - right.position || compareText$4(left.url, right.url);
24343
24752
  }
24344
24753
  function analyzeSecondPageRows(input) {
@@ -24383,7 +24792,7 @@ function analyzeSecondPageRows(input) {
24383
24792
  if (item.impressions >= minImpressions) return true;
24384
24793
  selection.belowMinimumPages++;
24385
24794
  return false;
24386
- }).sort(compareItems);
24795
+ }).sort(compareItems$1);
24387
24796
  const items = eligibleItems.slice(0, limit);
24388
24797
  selection.eligiblePages = eligibleItems.length;
24389
24798
  selection.returnedPages = items.length;
@@ -25209,7 +25618,7 @@ async function defaultCandidates$1() {
25209
25618
  priority: 10
25210
25619
  }];
25211
25620
  }
25212
- function providerError$1(error) {
25621
+ function providerError$2(error) {
25213
25622
  if (!(error instanceof ProviderError)) throw error;
25214
25623
  if (error.code === "rate-limit") throw new SeoError("RATE_LIMITED", error.message);
25215
25624
  if (error.code === "configuration") throw new SeoError("INVALID_INPUT", error.message);
@@ -25277,7 +25686,7 @@ async function keywordResearchReport(input, dependencies = {}) {
25277
25686
  try {
25278
25687
  candidates = dependencies.candidates ?? await defaultCandidates$1();
25279
25688
  } catch (error) {
25280
- return providerError$1(error);
25689
+ return providerError$2(error);
25281
25690
  }
25282
25691
  const resolution = resolveProvider({
25283
25692
  capability: "keyword-discovery",
@@ -25306,7 +25715,7 @@ async function keywordResearchReport(input, dependencies = {}) {
25306
25715
  }
25307
25716
  });
25308
25717
  } catch (error) {
25309
- return providerError$1(error);
25718
+ return providerError$2(error);
25310
25719
  }
25311
25720
  const analysis = evidence.data.map((idea) => ({
25312
25721
  keyword: idea.keyword,
@@ -29852,6 +30261,96 @@ function checkedAt(value, fallback) {
29852
30261
  const timestamp = Date.parse(value);
29853
30262
  return Number.isFinite(timestamp) ? new Date(timestamp).toISOString() : fallback;
29854
30263
  }
30264
+ function mapDataForSeoSerpSnapshot(input, snapshot, endpoint = SERP_ENDPOINT) {
30265
+ const market = searchMarketSchema.parse(input.market);
30266
+ const keyword = normalizedKeyword$1(input.keyword);
30267
+ const location = locationRequest(market, "serp-snapshot");
30268
+ const result = snapshot.response.tasks.flatMap((task) => task.result ?? []).find((item) => normalizedKeyword$1(item.keyword) === keyword);
30269
+ if (!result) throw new ProviderError({
30270
+ provider: "dataforseo",
30271
+ operation: "serp-snapshot",
30272
+ code: "invalid-response",
30273
+ message: "DataForSEO returned no matching SERP result."
30274
+ });
30275
+ const warnings = [...snapshot.warnings];
30276
+ const items = result.items ?? [];
30277
+ const organicItems = items.filter((item) => item.type === "organic");
30278
+ const mappedOrganicResults = organicItems.flatMap((item) => {
30279
+ const mapped = organicResult(item);
30280
+ return mapped ? [mapped] : [];
30281
+ }).sort((left, right) => left.rankAbsolute - right.rankAbsolute || left.rankGroup - right.rankGroup || compareCodepoints$3(left.url, right.url));
30282
+ const organicResults = mappedOrganicResults.slice(0, input.depth);
30283
+ const invalidRows = organicItems.length - mappedOrganicResults.length;
30284
+ const resultCapped = mappedOrganicResults.length > input.depth;
30285
+ const invalidResultCount = result.se_results_count !== null && result.se_results_count !== void 0 && result.se_results_count < organicResults.length;
30286
+ if (invalidRows > 0) warnings.push({
30287
+ code: "invalid-organic-results",
30288
+ field: "organicResults",
30289
+ message: `DataForSEO returned ${invalidRows} organic result${invalidRows === 1 ? "" : "s"} without a valid rank, domain, or URL.`
30290
+ });
30291
+ if (!Number.isFinite(Date.parse(result.datetime))) warnings.push({
30292
+ code: "invalid-serp-observation-time",
30293
+ field: "checkedAt",
30294
+ message: "DataForSEO returned an invalid SERP observation time; the local response time was retained."
30295
+ });
30296
+ const checkUrl = safeUrl(result.check_url);
30297
+ if (result.check_url && !checkUrl) warnings.push({
30298
+ code: "invalid-serp-check-url",
30299
+ field: "checkUrl",
30300
+ message: "DataForSEO returned an invalid SERP check URL."
30301
+ });
30302
+ if (invalidResultCount) warnings.push({
30303
+ code: "invalid-serp-result-count",
30304
+ field: "resultCount",
30305
+ message: "DataForSEO returned an estimated result count below the retained organic result count; the estimate was discarded."
30306
+ });
30307
+ const features = [.../* @__PURE__ */ new Set([...result.item_types ?? [], ...items.map((item) => item.type)])].sort(compareCodepoints$3);
30308
+ return {
30309
+ schemaVersion: 1,
30310
+ provider: "dataforseo",
30311
+ capability: "serp-snapshot",
30312
+ data: {
30313
+ keyword,
30314
+ effectiveKeyword: normalizedKeyword$1(result.spell?.keyword ?? result.keyword),
30315
+ searchEngineDomain: result.se_domain?.trim().toLowerCase() ?? null,
30316
+ checkedAt: checkedAt(result.datetime, snapshot.observedAt),
30317
+ checkUrl,
30318
+ resultCount: invalidResultCount ? null : result.se_results_count ?? null,
30319
+ pagesCount: result.pages_count === void 0 ? null : result.pages_count,
30320
+ features,
30321
+ organicResults
30322
+ },
30323
+ observedAt: snapshot.observedAt,
30324
+ market: {
30325
+ ...market,
30326
+ device: market.device ?? "desktop"
30327
+ },
30328
+ coverage: {
30329
+ requestedRows: input.depth,
30330
+ returnedRows: snapshot.returnedRows,
30331
+ retainedRows: organicResults.length,
30332
+ invalidRows,
30333
+ providerTotalRows: invalidResultCount ? null : result.se_results_count ?? null,
30334
+ completeness: invalidRows > 0 || invalidResultCount ? "partial" : resultCapped ? "capped" : "complete",
30335
+ nextCursor: null
30336
+ },
30337
+ cache: snapshot.cache,
30338
+ cost: snapshot.cost,
30339
+ request: {
30340
+ operation: "serp-snapshot",
30341
+ endpoint,
30342
+ limit: input.depth,
30343
+ filters: {
30344
+ countryCode: market.countryCode,
30345
+ languageCode: market.languageCode,
30346
+ device: market.device ?? "desktop",
30347
+ ...location.locationCode !== void 0 ? { locationCode: location.locationCode } : { locationName: location.locationName }
30348
+ },
30349
+ sort: ["rankAbsolute:ascending", "url:codepoint-ascending"]
30350
+ },
30351
+ warnings
30352
+ };
30353
+ }
29855
30354
  var DataForSeoSerpSnapshotProvider = class {
29856
30355
  provider = "dataforseo";
29857
30356
  capabilitySupport = [{
@@ -29893,7 +30392,7 @@ var DataForSeoSerpSnapshotProvider = class {
29893
30392
  reportRunId: randomUUID()
29894
30393
  };
29895
30394
  const location = locationRequest(market, "serp-snapshot");
29896
- const snapshot = await this.client.serpLive({
30395
+ return mapDataForSeoSerpSnapshot(input, await this.client.serpLive({
29897
30396
  keyword,
29898
30397
  languageCode: market.languageCode.split("-")[0],
29899
30398
  ...location,
@@ -29901,92 +30400,7 @@ var DataForSeoSerpSnapshotProvider = class {
29901
30400
  depth: input.depth,
29902
30401
  refresh: input.refresh,
29903
30402
  context
29904
- });
29905
- const result = snapshot.response.tasks.flatMap((task) => task.result ?? []).find((item) => normalizedKeyword$1(item.keyword) === keyword);
29906
- if (!result) throw new ProviderError({
29907
- provider: "dataforseo",
29908
- operation: "serp-snapshot",
29909
- code: "invalid-response",
29910
- message: "DataForSEO returned no matching SERP result."
29911
- });
29912
- const warnings = [...snapshot.warnings];
29913
- const items = result.items ?? [];
29914
- const organicItems = items.filter((item) => item.type === "organic");
29915
- const mappedOrganicResults = organicItems.flatMap((item) => {
29916
- const mapped = organicResult(item);
29917
- return mapped ? [mapped] : [];
29918
- }).sort((left, right) => left.rankAbsolute - right.rankAbsolute || left.rankGroup - right.rankGroup || compareCodepoints$3(left.url, right.url));
29919
- const organicResults = mappedOrganicResults.slice(0, input.depth);
29920
- const invalidRows = organicItems.length - mappedOrganicResults.length;
29921
- const resultCapped = mappedOrganicResults.length > input.depth;
29922
- const invalidResultCount = result.se_results_count !== null && result.se_results_count !== void 0 && result.se_results_count < organicResults.length;
29923
- if (invalidRows > 0) warnings.push({
29924
- code: "invalid-organic-results",
29925
- field: "organicResults",
29926
- message: `DataForSEO returned ${invalidRows} organic result${invalidRows === 1 ? "" : "s"} without a valid rank, domain, or URL.`
29927
- });
29928
- if (!Number.isFinite(Date.parse(result.datetime))) warnings.push({
29929
- code: "invalid-serp-observation-time",
29930
- field: "checkedAt",
29931
- message: "DataForSEO returned an invalid SERP observation time; the local response time was retained."
29932
- });
29933
- const checkUrl = safeUrl(result.check_url);
29934
- if (result.check_url && !checkUrl) warnings.push({
29935
- code: "invalid-serp-check-url",
29936
- field: "checkUrl",
29937
- message: "DataForSEO returned an invalid SERP check URL."
29938
- });
29939
- if (invalidResultCount) warnings.push({
29940
- code: "invalid-serp-result-count",
29941
- field: "resultCount",
29942
- message: "DataForSEO returned an estimated result count below the retained organic result count; the estimate was discarded."
29943
- });
29944
- const features = [.../* @__PURE__ */ new Set([...result.item_types ?? [], ...items.map((item) => item.type)])].sort(compareCodepoints$3);
29945
- return {
29946
- schemaVersion: 1,
29947
- provider: "dataforseo",
29948
- capability: "serp-snapshot",
29949
- data: {
29950
- keyword,
29951
- effectiveKeyword: normalizedKeyword$1(result.spell?.keyword ?? result.keyword),
29952
- searchEngineDomain: result.se_domain?.trim().toLowerCase() ?? null,
29953
- checkedAt: checkedAt(result.datetime, snapshot.observedAt),
29954
- checkUrl,
29955
- resultCount: invalidResultCount ? null : result.se_results_count ?? null,
29956
- pagesCount: result.pages_count === void 0 ? null : result.pages_count,
29957
- features,
29958
- organicResults
29959
- },
29960
- observedAt: snapshot.observedAt,
29961
- market: {
29962
- ...market,
29963
- device: market.device ?? "desktop"
29964
- },
29965
- coverage: {
29966
- requestedRows: input.depth,
29967
- returnedRows: snapshot.returnedRows,
29968
- retainedRows: organicResults.length,
29969
- invalidRows,
29970
- providerTotalRows: invalidResultCount ? null : result.se_results_count ?? null,
29971
- completeness: invalidRows > 0 || invalidResultCount ? "partial" : resultCapped ? "capped" : "complete",
29972
- nextCursor: null
29973
- },
29974
- cache: snapshot.cache,
29975
- cost: snapshot.cost,
29976
- request: {
29977
- operation: "serp-snapshot",
29978
- endpoint: SERP_ENDPOINT,
29979
- limit: input.depth,
29980
- filters: {
29981
- countryCode: market.countryCode,
29982
- languageCode: market.languageCode,
29983
- device: market.device ?? "desktop",
29984
- ...location.locationCode !== void 0 ? { locationCode: location.locationCode } : { locationName: location.locationName }
29985
- },
29986
- sort: ["rankAbsolute:ascending", "url:codepoint-ascending"]
29987
- },
29988
- warnings
29989
- };
30403
+ }));
29990
30404
  }
29991
30405
  };
29992
30406
  //#endregion
@@ -30005,7 +30419,7 @@ async function defaultCandidates() {
30005
30419
  priority: 10
30006
30420
  }];
30007
30421
  }
30008
- function providerError(error) {
30422
+ function providerError$1(error) {
30009
30423
  if (!(error instanceof ProviderError)) throw error;
30010
30424
  if (error.code === "rate-limit") throw new SeoError("RATE_LIMITED", error.message);
30011
30425
  if (error.code === "configuration") throw new SeoError("INVALID_INPUT", error.message);
@@ -30067,7 +30481,7 @@ async function serpResultsReport(input, dependencies = {}) {
30067
30481
  try {
30068
30482
  candidates = dependencies.candidates ?? await defaultCandidates();
30069
30483
  } catch (error) {
30070
- return providerError(error);
30484
+ return providerError$1(error);
30071
30485
  }
30072
30486
  const resolution = resolveProvider({
30073
30487
  capability: "serp-snapshot",
@@ -30092,7 +30506,7 @@ async function serpResultsReport(input, dependencies = {}) {
30092
30506
  }
30093
30507
  });
30094
30508
  } catch (error) {
30095
- return providerError(error);
30509
+ return providerError$1(error);
30096
30510
  }
30097
30511
  const domains = domainSummaries(evidence.data);
30098
30512
  const dataStatus = reportStatus$1(evidence);
@@ -30342,6 +30756,1110 @@ async function pseoOpportunitiesReport(input, dependencies = {}) {
30342
30756
  };
30343
30757
  }
30344
30758
  //#endregion
30759
+ //#region packages/core/dist/keyword-sets/limits.js
30760
+ const KEYWORD_SET_LIMITS = {
30761
+ setsPerProject: 25,
30762
+ totalSets: 100,
30763
+ keywordsPerSet: 1e4,
30764
+ totalKeywords: 1e5,
30765
+ mutationKeywords: 1e3,
30766
+ tagsPerKeyword: 20,
30767
+ outputRows: 1e3,
30768
+ logicalBytes: 64 * 1024 * 1024
30769
+ };
30770
+ const KEYWORD_SET_FIELD_LIMITS = {
30771
+ name: 80,
30772
+ projectId: 80,
30773
+ sourceReport: 100,
30774
+ keyword: 80,
30775
+ keywordWords: 10,
30776
+ tag: 40,
30777
+ url: 2048,
30778
+ metricJsonBytes: 16 * 1024
30779
+ };
30780
+ //#endregion
30781
+ //#region packages/core/dist/keyword-sets/validation.js
30782
+ const unavailableValueSchema = z.strictObject({
30783
+ state: z.enum([
30784
+ "missing",
30785
+ "unavailable",
30786
+ "invalid"
30787
+ ]),
30788
+ value: z.null(),
30789
+ reason: z.string().min(1).max(500)
30790
+ });
30791
+ const providerValueNumberSchema = z.discriminatedUnion("state", [z.strictObject({
30792
+ state: z.literal("observed"),
30793
+ value: z.number().finite()
30794
+ }), unavailableValueSchema]);
30795
+ const providerValueStringSchema = z.discriminatedUnion("state", [z.strictObject({
30796
+ state: z.literal("observed"),
30797
+ value: z.string().max(500)
30798
+ }), unavailableValueSchema]);
30799
+ const monthlySearchSchema = z.strictObject({
30800
+ year: z.number().int().min(2e3).max(2200),
30801
+ month: z.number().int().min(1).max(12),
30802
+ searchVolume: z.number().finite().nonnegative()
30803
+ });
30804
+ const providerValueMonthlySearchesSchema = z.discriminatedUnion("state", [z.strictObject({
30805
+ state: z.literal("observed"),
30806
+ value: z.array(monthlySearchSchema).max(24)
30807
+ }), unavailableValueSchema]);
30808
+ const keywordMetricSchema = z.strictObject({
30809
+ keyword: z.string().min(1).max(KEYWORD_SET_FIELD_LIMITS.keyword),
30810
+ monthlySearchVolume: providerValueNumberSchema,
30811
+ monthlySearches: providerValueMonthlySearchesSchema,
30812
+ searchVolumeUpdatedAt: providerValueStringSchema,
30813
+ cpcUsd: providerValueNumberSchema,
30814
+ paidCompetition: providerValueNumberSchema,
30815
+ keywordDifficulty: providerValueNumberSchema,
30816
+ intent: providerValueStringSchema,
30817
+ resultCount: providerValueNumberSchema
30818
+ });
30819
+ const savedMetricSchema = z.strictObject({
30820
+ schemaVersion: z.literal(1),
30821
+ provider: providerIdSchema,
30822
+ observedAt: z.iso.datetime(),
30823
+ metric: keywordMetricSchema
30824
+ });
30825
+ function invalid$1(message) {
30826
+ throw new SeoError("INVALID_INPUT", message);
30827
+ }
30828
+ function boundedText(value, label, max) {
30829
+ const normalized = value.trim();
30830
+ if (!normalized || normalized.length > max) invalid$1(`${label} must contain 1 to ${max} characters.`);
30831
+ return normalized;
30832
+ }
30833
+ function normalizeSavedKeyword(value) {
30834
+ const normalized = value.trim().replace(/\s+/gu, " ").toLowerCase();
30835
+ 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.`);
30836
+ return normalized;
30837
+ }
30838
+ function parseStoredJson(value, schema, label) {
30839
+ let decoded;
30840
+ try {
30841
+ decoded = JSON.parse(value);
30842
+ } catch {
30843
+ throw new SeoError("INTERNAL_ERROR", `${label} contains invalid JSON.`);
30844
+ }
30845
+ const parsed = schema.safeParse(decoded);
30846
+ if (!parsed.success) throw new SeoError("INTERNAL_ERROR", `${label} has an invalid shape.`);
30847
+ return parsed.data;
30848
+ }
30849
+ //#endregion
30850
+ //#region packages/core/dist/keyword-sets/rows.js
30851
+ const SET_SELECT = `
30852
+ SELECT sets.*,
30853
+ (SELECT COUNT(*) FROM keyword_set_items items WHERE items.set_id = sets.id) AS keyword_count,
30854
+ (SELECT COUNT(DISTINCT tags.tag) FROM keyword_set_tags tags WHERE tags.set_id = sets.id) AS tag_count
30855
+ FROM keyword_sets sets`;
30856
+ function storeDatabase(options) {
30857
+ return options.database ?? getDb();
30858
+ }
30859
+ function rowToSet(row) {
30860
+ const provider = row.provider ? providerIdSchema.safeParse(row.provider) : void 0;
30861
+ if (provider && !provider.success) throw new SeoError("INTERNAL_ERROR", "Saved keyword set provider is invalid.");
30862
+ return {
30863
+ schemaVersion: 1,
30864
+ id: row.id,
30865
+ projectId: row.project_id,
30866
+ name: row.name,
30867
+ market: parseStoredJson(row.market_json, searchMarketSchema, "Saved search market"),
30868
+ provider: provider?.data ?? null,
30869
+ sourceReport: row.source_report,
30870
+ keywordCount: row.keyword_count,
30871
+ tagCount: row.tag_count,
30872
+ createdAt: new Date(row.created_at).toISOString(),
30873
+ updatedAt: new Date(row.updated_at).toISOString(),
30874
+ lastRefreshedAt: row.last_refreshed_at === null ? null : new Date(row.last_refreshed_at).toISOString()
30875
+ };
30876
+ }
30877
+ function findSetRow(db, projectId, idOrName) {
30878
+ return db.prepare(`${SET_SELECT}
30879
+ WHERE sets.project_id = ?
30880
+ AND (sets.id = ? OR sets.name = ? COLLATE NOCASE)
30881
+ ORDER BY CASE WHEN sets.id = ? THEN 0 ELSE 1 END
30882
+ LIMIT 1`).get(projectId, idOrName, idOrName, idOrName);
30883
+ }
30884
+ function requireSetRow(db, projectId, idOrName) {
30885
+ const row = findSetRow(db, projectId, idOrName);
30886
+ if (!row) invalid$1(`Keyword set not found: ${idOrName}`);
30887
+ return row;
30888
+ }
30889
+ function rowCount(db, sql, ...params) {
30890
+ return db.prepare(sql).get(...params).count;
30891
+ }
30892
+ //#endregion
30893
+ //#region packages/core/dist/keyword-sets/store.js
30894
+ function getKeywordSet(input, options = {}) {
30895
+ const projectId = boundedText(input.projectId, "Project id", KEYWORD_SET_FIELD_LIMITS.projectId);
30896
+ const idOrName = boundedText(input.idOrName, "Keyword set id or name", KEYWORD_SET_FIELD_LIMITS.name);
30897
+ const tag = input.tag ? boundedText(input.tag, "Tag", KEYWORD_SET_FIELD_LIMITS.tag).toLowerCase() : null;
30898
+ const limit = input.limit ?? 100;
30899
+ const offset = input.offset ?? 0;
30900
+ 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}.`);
30901
+ if (!Number.isSafeInteger(offset) || offset < 0) invalid$1("Keyword set output offset must be zero or greater.");
30902
+ const db = storeDatabase(options);
30903
+ const setRow = requireSetRow(db, projectId, idOrName);
30904
+ const filterSql = tag ? `AND EXISTS (
30905
+ SELECT 1 FROM keyword_set_tags filter_tags
30906
+ WHERE filter_tags.set_id = items.set_id
30907
+ AND filter_tags.normalized_keyword = items.normalized_keyword
30908
+ AND filter_tags.tag = ?
30909
+ )` : "";
30910
+ const params = tag ? [setRow.id, tag] : [setRow.id];
30911
+ const total = rowCount(db, `SELECT COUNT(*) AS count FROM keyword_set_items items
30912
+ WHERE items.set_id = ? ${filterSql}`, ...params);
30913
+ const rows = db.prepare(`SELECT items.* FROM keyword_set_items items
30914
+ WHERE items.set_id = ? ${filterSql}
30915
+ ORDER BY items.normalized_keyword
30916
+ LIMIT ? OFFSET ?`).all(...params, limit, offset);
30917
+ const tagsByKeyword = loadTags(db, setRow.id, rows);
30918
+ const items = rows.map((row) => itemFromRow(row, tagsByKeyword.get(row.normalized_keyword) ?? []));
30919
+ return {
30920
+ schemaVersion: 1,
30921
+ set: rowToSet(setRow),
30922
+ items,
30923
+ pagination: {
30924
+ offset,
30925
+ limit,
30926
+ returned: items.length,
30927
+ total,
30928
+ nextOffset: offset + items.length < total ? offset + items.length : null
30929
+ },
30930
+ filter: { tag }
30931
+ };
30932
+ }
30933
+ function loadTags(db, setId, rows) {
30934
+ if (rows.length === 0) return /* @__PURE__ */ new Map();
30935
+ const tagRows = db.prepare(`SELECT normalized_keyword, tag FROM keyword_set_tags
30936
+ WHERE set_id = ? AND normalized_keyword IN (${rows.map(() => "?").join(", ")})
30937
+ ORDER BY normalized_keyword, tag`).all(setId, ...rows.map((row) => row.normalized_keyword));
30938
+ const tagsByKeyword = /* @__PURE__ */ new Map();
30939
+ for (const row of tagRows) {
30940
+ const tags = tagsByKeyword.get(row.normalized_keyword) ?? [];
30941
+ tags.push(row.tag);
30942
+ tagsByKeyword.set(row.normalized_keyword, tags);
30943
+ }
30944
+ return tagsByKeyword;
30945
+ }
30946
+ function itemFromRow(row, tags) {
30947
+ const latestMetric = row.metric_json ? parseStoredJson(row.metric_json, savedMetricSchema, "Saved keyword metric") : null;
30948
+ 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.");
30949
+ const page = row.page_kind && row.page_url ? {
30950
+ kind: row.page_kind,
30951
+ url: row.page_url
30952
+ } : null;
30953
+ return {
30954
+ keyword: row.display_keyword,
30955
+ normalizedKeyword: row.normalized_keyword,
30956
+ tags,
30957
+ page,
30958
+ latestMetric,
30959
+ createdAt: new Date(row.created_at).toISOString(),
30960
+ updatedAt: new Date(row.updated_at).toISOString()
30961
+ };
30962
+ }
30963
+ //#endregion
30964
+ //#region packages/core/dist/rank-tracking/dataforseo-collector.js
30965
+ const QUEUED_RESULT_ENDPOINT = "v3/serp/google/organic/task_get/advanced/{taskId}";
30966
+ var DataForSeoRankTrackingCollector = class {
30967
+ provider = "dataforseo";
30968
+ client;
30969
+ liveProvider;
30970
+ constructor(options = {}) {
30971
+ this.client = new DataForSeoClient(options);
30972
+ this.liveProvider = new DataForSeoSerpSnapshotProvider({ client: this.client });
30973
+ }
30974
+ live(input) {
30975
+ return this.liveProvider.serpSnapshot(input);
30976
+ }
30977
+ async post(input) {
30978
+ const snapshot = await this.client.serpTaskPost({
30979
+ tasks: input.tasks.map(({ taskKey, request }) => {
30980
+ const market = request.market;
30981
+ return {
30982
+ tag: taskKey,
30983
+ keyword: normalizedKeyword$1(request.keyword),
30984
+ languageCode: market.languageCode.split("-")[0],
30985
+ ...locationRequest(market, "serp-snapshot"),
30986
+ device: market.device ?? "desktop",
30987
+ depth: request.depth
30988
+ };
30989
+ }),
30990
+ context: input.context
30991
+ });
30992
+ return {
30993
+ provider: this.provider,
30994
+ receipts: snapshot.taskReceipts.map((receipt) => ({
30995
+ taskKey: receipt.tag,
30996
+ providerTaskId: receipt.providerTaskId
30997
+ })),
30998
+ estimatedCostMicros: snapshot.estimatedCostMicros,
30999
+ actualCostMicros: snapshot.actualCostMicros,
31000
+ warnings: snapshot.warnings
31001
+ };
31002
+ }
31003
+ ready() {
31004
+ return this.client.serpTasksReady().then((tasks) => tasks.map((task) => ({
31005
+ providerTaskId: task.providerTaskId,
31006
+ taskKey: task.tag
31007
+ })));
31008
+ }
31009
+ async collect(input) {
31010
+ const snapshot = await this.client.serpTaskGet(input.providerTaskId);
31011
+ return mapDataForSeoSerpSnapshot(input.request, snapshot, QUEUED_RESULT_ENDPOINT);
31012
+ }
31013
+ };
31014
+ //#endregion
31015
+ //#region packages/core/dist/rank-tracking/limits.js
31016
+ const RANK_TRACKING_LIMITS = {
31017
+ configurationsPerProject: 25,
31018
+ totalConfigurations: 100,
31019
+ liveKeywordsPerRun: 50,
31020
+ queuedKeywordsPerRun: 1e3,
31021
+ devicesPerRun: 2,
31022
+ tasksPerProviderPost: 100,
31023
+ retainedRunsPerConfiguration: 90,
31024
+ outputRows: 250,
31025
+ logicalBytes: 256 * 1024 * 1024
31026
+ };
31027
+ //#endregion
31028
+ //#region packages/core/dist/rank-tracking/configuration-store.js
31029
+ function invalid(message) {
31030
+ throw new SeoError("INVALID_INPUT", message);
31031
+ }
31032
+ function database(options) {
31033
+ return options.database ?? getDb();
31034
+ }
31035
+ function parseJson(value, label) {
31036
+ try {
31037
+ return JSON.parse(value);
31038
+ } catch {
31039
+ throw new SeoError("INTERNAL_ERROR", `${label} is corrupt.`);
31040
+ }
31041
+ }
31042
+ function normalizeDomain(value) {
31043
+ const raw = value.trim().toLowerCase();
31044
+ if (!raw || raw.length > 253) invalid("Use a valid target domain.");
31045
+ let hostname;
31046
+ try {
31047
+ hostname = new URL(raw.includes("://") ? raw : `https://${raw}`).hostname;
31048
+ } catch {
31049
+ invalid("Use a valid target domain.");
31050
+ }
31051
+ if (!hostname || hostname.length > 253 || hostname.includes("..") || !/^[a-z0-9.-]+$/u.test(hostname)) invalid("Use a valid target domain.");
31052
+ return hostname.replace(/\.$/u, "");
31053
+ }
31054
+ function targetMatchesDomain(targetDomain, candidate) {
31055
+ const target = normalizeDomain(targetDomain);
31056
+ const host = normalizeDomain(candidate);
31057
+ return host === target || host.endsWith(`.${target}`);
31058
+ }
31059
+ function configFromRow(row) {
31060
+ const market = searchMarketSchema.safeParse(parseJson(row.market_json, "Saved rank market"));
31061
+ const devices = parseJson(row.devices_json, "Saved rank devices");
31062
+ const provider = providerIdSchema.safeParse(row.provider);
31063
+ 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.");
31064
+ return {
31065
+ schemaVersion: 1,
31066
+ id: row.id,
31067
+ projectId: row.project_id,
31068
+ keywordSetId: row.keyword_set_id,
31069
+ targetDomain: row.target_domain,
31070
+ tag: row.tag,
31071
+ market: market.data,
31072
+ devices,
31073
+ provider: provider.data,
31074
+ collectionMethod: row.collection_method,
31075
+ cadence: row.cadence,
31076
+ depth: row.depth,
31077
+ keywordLimit: row.keyword_limit,
31078
+ nextRunAt: row.next_run_at === null ? null : new Date(row.next_run_at).toISOString(),
31079
+ createdAt: new Date(row.created_at).toISOString(),
31080
+ updatedAt: new Date(row.updated_at).toISOString()
31081
+ };
31082
+ }
31083
+ function runFromRow(row) {
31084
+ return {
31085
+ schemaVersion: 1,
31086
+ id: row.id,
31087
+ configId: row.config_id,
31088
+ state: row.state,
31089
+ collectionMethod: row.collection_method,
31090
+ scheduledFor: new Date(row.scheduled_for).toISOString(),
31091
+ startedAt: new Date(row.started_at).toISOString(),
31092
+ completedAt: row.completed_at === null ? null : new Date(row.completed_at).toISOString(),
31093
+ keywordCount: row.keyword_count,
31094
+ taskCount: row.task_count,
31095
+ snapshotCount: row.snapshot_count,
31096
+ pendingCount: row.pending_count,
31097
+ failedCount: row.failed_count,
31098
+ estimatedCostMicros: row.estimated_cost_micros,
31099
+ actualCostMicros: row.actual_cost_micros,
31100
+ errorSummary: row.error_summary
31101
+ };
31102
+ }
31103
+ function taskFromRow(row) {
31104
+ return {
31105
+ id: row.id,
31106
+ runId: row.run_id,
31107
+ normalizedKeyword: row.normalized_keyword,
31108
+ displayKeyword: row.display_keyword,
31109
+ device: row.device,
31110
+ state: row.state,
31111
+ providerTaskId: row.provider_task_id,
31112
+ attemptCount: row.attempt_count,
31113
+ errorCode: row.error_code,
31114
+ errorMessage: row.error_message
31115
+ };
31116
+ }
31117
+ function observationFromRow(row) {
31118
+ const provider = providerIdSchema.safeParse(row.provider);
31119
+ if (!provider.success) throw new SeoError("INTERNAL_ERROR", "Saved rank provider is invalid.");
31120
+ return {
31121
+ taskId: row.task_id,
31122
+ runId: row.run_id,
31123
+ keyword: row.display_keyword,
31124
+ normalizedKeyword: row.normalized_keyword,
31125
+ device: row.device,
31126
+ state: row.observation_state,
31127
+ organicPosition: row.organic_position,
31128
+ absolutePosition: row.absolute_position,
31129
+ rankingUrl: row.ranking_url,
31130
+ observedFeatures: parseJson(row.observed_features_json, "Saved rank features"),
31131
+ checkedAt: row.checked_at,
31132
+ provider: provider.data,
31133
+ providerTaskId: row.provider_task_id,
31134
+ requestedDepth: row.requested_depth,
31135
+ returnedRows: row.returned_rows,
31136
+ retainedRows: row.retained_rows,
31137
+ invalidRows: row.invalid_rows,
31138
+ completeness: row.completeness,
31139
+ estimatedCostMicros: row.estimated_cost_micros,
31140
+ actualCostMicros: row.actual_cost_micros,
31141
+ warnings: parseJson(row.warnings_json, "Saved rank warnings")
31142
+ };
31143
+ }
31144
+ function stableConfigKey(value) {
31145
+ return createHash("sha256").update(JSON.stringify(value)).digest("hex");
31146
+ }
31147
+ function nextRunAt(cadence, scheduledFor) {
31148
+ if (cadence === "manual") return null;
31149
+ const next = new Date(scheduledFor);
31150
+ if (cadence === "daily") next.setUTCDate(next.getUTCDate() + 1);
31151
+ if (cadence === "weekly") next.setUTCDate(next.getUTCDate() + 7);
31152
+ if (cadence === "monthly") {
31153
+ const desiredDay = next.getUTCDate();
31154
+ next.setUTCDate(1);
31155
+ next.setUTCMonth(next.getUTCMonth() + 1);
31156
+ const lastDay = new Date(Date.UTC(next.getUTCFullYear(), next.getUTCMonth() + 1, 0)).getUTCDate();
31157
+ next.setUTCDate(Math.min(desiredDay, lastDay));
31158
+ }
31159
+ return next.getTime();
31160
+ }
31161
+ function getOrCreateRankTrackingConfiguration(input, options = {}) {
31162
+ const projectId = input.projectId.trim();
31163
+ const keywordSetId = input.keywordSetId.trim();
31164
+ const tag = input.tag?.trim().toLowerCase() || null;
31165
+ if (!projectId || projectId.length > 80) invalid("Use a valid project id.");
31166
+ if (!keywordSetId || keywordSetId.length > 80) invalid("Use a valid keyword set id.");
31167
+ if (tag && tag.length > 40) invalid("Rank tracking tag is too long.");
31168
+ const market = searchMarketSchema.safeParse(input.market);
31169
+ if (!market.success) invalid("Use a valid rank tracking market.");
31170
+ const { device: _ignoredDevice, ...marketWithoutDevice } = market.data;
31171
+ const devices = [...new Set(input.devices)].sort();
31172
+ if (devices.length < 1 || devices.length > 2 || devices.some((item) => item !== "desktop" && item !== "mobile")) invalid("Track desktop, mobile, or both devices.");
31173
+ const provider = providerIdSchema.safeParse(input.provider);
31174
+ if (!provider.success) invalid("Use a supported rank tracking provider.");
31175
+ if (!["live", "queued"].includes(input.collectionMethod)) invalid("Use live or queued rank collection.");
31176
+ if (![
31177
+ "manual",
31178
+ "daily",
31179
+ "weekly",
31180
+ "monthly"
31181
+ ].includes(input.cadence)) invalid("Use a supported rank tracking cadence.");
31182
+ if (!Number.isSafeInteger(input.depth) || input.depth < 1 || input.depth > 100) invalid("Rank tracking depth must be from 1 to 100.");
31183
+ const maxKeywords = input.collectionMethod === "live" ? RANK_TRACKING_LIMITS.liveKeywordsPerRun : RANK_TRACKING_LIMITS.queuedKeywordsPerRun;
31184
+ 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.`);
31185
+ const targetDomain = normalizeDomain(input.targetDomain);
31186
+ const db = database(options);
31187
+ const set = getKeywordSet({
31188
+ projectId,
31189
+ idOrName: keywordSetId,
31190
+ limit: 1
31191
+ }, { database: db }).set;
31192
+ if (set.market.searchEngine !== marketWithoutDevice.searchEngine) invalid("The rank tracking search engine must match the keyword set.");
31193
+ const configKey = stableConfigKey({
31194
+ projectId,
31195
+ keywordSetId: set.id,
31196
+ targetDomain,
31197
+ tag,
31198
+ market: marketWithoutDevice,
31199
+ devices,
31200
+ provider: provider.data,
31201
+ collectionMethod: input.collectionMethod,
31202
+ cadence: input.cadence,
31203
+ depth: input.depth,
31204
+ keywordLimit: input.keywordLimit
31205
+ });
31206
+ const existing = db.prepare("SELECT * FROM rank_tracking_configs WHERE config_key = ?").get(configKey);
31207
+ if (existing) return configFromRow(existing);
31208
+ const total = db.prepare("SELECT COUNT(*) AS count FROM rank_tracking_configs").get().count;
31209
+ const projectTotal = db.prepare("SELECT COUNT(*) AS count FROM rank_tracking_configs WHERE project_id = ?").get(projectId).count;
31210
+ if (total >= RANK_TRACKING_LIMITS.totalConfigurations) invalid("Remove an unused rank tracking configuration before adding more.");
31211
+ if (projectTotal >= RANK_TRACKING_LIMITS.configurationsPerProject) invalid("This project already has the maximum rank tracking configurations.");
31212
+ const now = (options.now ?? (() => /* @__PURE__ */ new Date()))().getTime();
31213
+ const id = (options.id ?? randomUUID)();
31214
+ db.prepare(`INSERT INTO rank_tracking_configs
31215
+ (id, config_key, project_id, keyword_set_id, target_domain, tag,
31216
+ market_json, devices_json, provider, collection_method, cadence, depth,
31217
+ keyword_limit, next_run_at, created_at, updated_at)
31218
+ 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);
31219
+ return configFromRow(db.prepare("SELECT * FROM rank_tracking_configs WHERE id = ?").get(id));
31220
+ }
31221
+ //#endregion
31222
+ //#region packages/core/dist/rank-tracking/store.js
31223
+ function activeRankTrackingRun(configId, options = {}) {
31224
+ const row = database(options).prepare(`SELECT * FROM rank_tracking_runs
31225
+ WHERE config_id = ? AND completed_at IS NULL
31226
+ ORDER BY scheduled_for DESC, id DESC LIMIT 1`).get(configId);
31227
+ return row ? runFromRow(row) : null;
31228
+ }
31229
+ function startRankTrackingRun(input, options = {}) {
31230
+ const db = database(options);
31231
+ const active = activeRankTrackingRun(input.configuration.id, { database: db });
31232
+ if (active) return active;
31233
+ if (input.keywords.length < 1) invalid("The selected keyword set view is empty.");
31234
+ const now = (options.now ?? (() => /* @__PURE__ */ new Date()))().getTime();
31235
+ const scheduledFor = input.scheduledFor?.getTime() ?? now;
31236
+ const runId = (options.id ?? randomUUID)();
31237
+ const idempotencyKey = `${input.configuration.id}:${scheduledFor}`;
31238
+ const taskCount = input.keywords.length * input.configuration.devices.length;
31239
+ const create = db.transaction(() => {
31240
+ db.prepare(`INSERT INTO rank_tracking_runs
31241
+ (id, config_id, idempotency_key, state, collection_method, scheduled_for,
31242
+ started_at, keyword_count, task_count, pending_count, config_snapshot_json)
31243
+ VALUES (?, ?, ?, 'pending', ?, ?, ?, ?, ?, ?, ?)`).run(runId, input.configuration.id, idempotencyKey, input.configuration.collectionMethod, scheduledFor, now, input.keywords.length, taskCount, taskCount, JSON.stringify(input.configuration));
31244
+ const insertTask = db.prepare(`INSERT INTO rank_tracking_tasks
31245
+ (id, run_id, normalized_keyword, display_keyword, device, state)
31246
+ VALUES (?, ?, ?, ?, ?, 'pending')`);
31247
+ for (const keyword of input.keywords) for (const device of input.configuration.devices) insertTask.run((options.id ?? randomUUID)(), runId, keyword.normalizedKeyword, keyword.keyword, device);
31248
+ db.prepare(`UPDATE rank_tracking_configs
31249
+ SET next_run_at = ?, updated_at = ? WHERE id = ?`).run(nextRunAt(input.configuration.cadence, scheduledFor), now, input.configuration.id);
31250
+ });
31251
+ try {
31252
+ create.immediate();
31253
+ } catch (error) {
31254
+ if (/unique constraint/i.test(String(error))) {
31255
+ const recovered = activeRankTrackingRun(input.configuration.id, { database: db });
31256
+ if (recovered) return recovered;
31257
+ }
31258
+ throw error;
31259
+ }
31260
+ return getRankTrackingRun(runId, { database: db });
31261
+ }
31262
+ function getRankTrackingRun(runId, options = {}) {
31263
+ const row = database(options).prepare("SELECT * FROM rank_tracking_runs WHERE id = ?").get(runId);
31264
+ if (!row) throw new SeoError("INTERNAL_ERROR", "Rank tracking run is missing.");
31265
+ return runFromRow(row);
31266
+ }
31267
+ function rankTrackingTasks(runId, states, options = {}) {
31268
+ const filter = states?.length ? ` AND state IN (${states.map(() => "?").join(", ")})` : "";
31269
+ return database(options).prepare(`SELECT * FROM rank_tracking_tasks WHERE run_id = ?${filter}
31270
+ ORDER BY normalized_keyword, device, id`).all(runId, ...states ?? []).map(taskFromRow);
31271
+ }
31272
+ function markRankTasksPosted(runId, receipts, cost, options = {}) {
31273
+ const db = database(options);
31274
+ const now = (options.now ?? (() => /* @__PURE__ */ new Date()))().getTime();
31275
+ db.transaction(() => {
31276
+ const statement = db.prepare(`UPDATE rank_tracking_tasks
31277
+ SET state = 'posted', provider_task_id = ?, posted_at = ?,
31278
+ attempt_count = attempt_count + 1, error_code = NULL,
31279
+ error_message = NULL
31280
+ WHERE id = ? AND run_id = ? AND state IN ('pending', 'posting')`);
31281
+ 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.");
31282
+ db.prepare(`UPDATE rank_tracking_runs
31283
+ SET estimated_cost_micros = CASE
31284
+ WHEN ? IS NULL THEN estimated_cost_micros
31285
+ ELSE COALESCE(estimated_cost_micros, 0) + ? END,
31286
+ actual_cost_micros = CASE
31287
+ WHEN ? IS NULL THEN actual_cost_micros
31288
+ ELSE COALESCE(actual_cost_micros, 0) + ? END
31289
+ WHERE id = ?`).run(cost.estimatedMicros, cost.estimatedMicros, cost.actualMicros, cost.actualMicros, runId);
31290
+ }).immediate();
31291
+ }
31292
+ function markRankTasksPosting(runId, taskIds, options = {}) {
31293
+ if (taskIds.length === 0) return;
31294
+ const db = database(options);
31295
+ db.transaction(() => {
31296
+ const statement = db.prepare(`UPDATE rank_tracking_tasks
31297
+ SET state = 'posting', attempt_count = attempt_count + 1
31298
+ WHERE id = ? AND run_id = ? AND state = 'pending'`);
31299
+ 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.");
31300
+ }).immediate();
31301
+ }
31302
+ function recoverRankTaskReceipt(input, options = {}) {
31303
+ const now = (options.now ?? (() => /* @__PURE__ */ new Date()))().getTime();
31304
+ database(options).prepare(`UPDATE rank_tracking_tasks
31305
+ SET state = 'posted', provider_task_id = ?, posted_at = ?
31306
+ WHERE id = ? AND state = 'posting'`).run(input.providerTaskId, now, input.taskId);
31307
+ }
31308
+ function saveRankObservations(observations, options = {}) {
31309
+ if (observations.length === 0) return;
31310
+ const runId = observations[0]?.runId;
31311
+ if (!runId || observations.some((observation) => observation.runId !== runId)) throw new SeoError("INTERNAL_ERROR", "A rank observation batch must belong to one run.");
31312
+ const db = database(options);
31313
+ const now = (options.now ?? (() => /* @__PURE__ */ new Date()))().getTime();
31314
+ db.transaction(() => {
31315
+ const insertSnapshot = db.prepare(`INSERT OR IGNORE INTO rank_tracking_snapshots
31316
+ (task_id, run_id, normalized_keyword, display_keyword, device,
31317
+ observation_state, organic_position, absolute_position, ranking_url,
31318
+ observed_features_json, checked_at, provider, provider_task_id,
31319
+ requested_depth, returned_rows, retained_rows, invalid_rows,
31320
+ completeness, estimated_cost_micros, actual_cost_micros, warnings_json,
31321
+ created_at)
31322
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`);
31323
+ const completeTask = db.prepare(`UPDATE rank_tracking_tasks
31324
+ SET state = 'complete', collected_at = ?, error_code = NULL,
31325
+ error_message = NULL WHERE id = ?`);
31326
+ const addLiveCost = db.prepare(`UPDATE rank_tracking_runs
31327
+ SET estimated_cost_micros = CASE
31328
+ WHEN ? IS NULL THEN estimated_cost_micros
31329
+ ELSE COALESCE(estimated_cost_micros, 0) + ? END,
31330
+ actual_cost_micros = CASE
31331
+ WHEN ? IS NULL THEN actual_cost_micros
31332
+ ELSE COALESCE(actual_cost_micros, 0) + ? END
31333
+ WHERE id = ? AND collection_method = 'live'`);
31334
+ for (const observation of observations) {
31335
+ 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);
31336
+ completeTask.run(now, observation.taskId);
31337
+ if (inserted.changes === 1) addLiveCost.run(observation.estimatedCostMicros, observation.estimatedCostMicros, observation.actualCostMicros, observation.actualCostMicros, observation.runId);
31338
+ }
31339
+ }).immediate();
31340
+ refreshRankTrackingRun(runId, {
31341
+ database: db,
31342
+ now: options.now
31343
+ });
31344
+ }
31345
+ function failRankTrackingTask(input, options = {}) {
31346
+ const db = database(options);
31347
+ const row = db.prepare("SELECT run_id FROM rank_tracking_tasks WHERE id = ?").get(input.taskId);
31348
+ if (!row) return;
31349
+ db.prepare(`UPDATE rank_tracking_tasks
31350
+ SET state = 'failed', attempt_count = attempt_count + 1,
31351
+ error_code = ?, error_message = ? WHERE id = ?`).run(input.code.slice(0, 100), input.message.slice(0, 1e3), input.taskId);
31352
+ refreshRankTrackingRun(row.run_id, {
31353
+ database: db,
31354
+ now: options.now
31355
+ });
31356
+ }
31357
+ function refreshRankTrackingRun(runId, options = {}) {
31358
+ const db = database(options);
31359
+ const counts = db.prepare(`SELECT
31360
+ SUM(CASE WHEN state = 'complete' THEN 1 ELSE 0 END) AS snapshots,
31361
+ SUM(CASE WHEN state IN ('pending', 'posting', 'posted') THEN 1 ELSE 0 END) AS pending,
31362
+ SUM(CASE WHEN state = 'failed' THEN 1 ELSE 0 END) AS failed
31363
+ FROM rank_tracking_tasks WHERE run_id = ?`).get(runId);
31364
+ const state = counts.pending > 0 ? counts.snapshots > 0 || counts.failed > 0 ? "partial" : "pending" : counts.failed > 0 ? counts.snapshots > 0 ? "partial" : "failed" : "complete";
31365
+ const now = (options.now ?? (() => /* @__PURE__ */ new Date()))().getTime();
31366
+ db.prepare(`UPDATE rank_tracking_runs
31367
+ SET state = ?, snapshot_count = ?, pending_count = ?, failed_count = ?,
31368
+ completed_at = CASE WHEN ? = 0 THEN ? ELSE NULL END,
31369
+ error_summary = CASE WHEN ? > 0 THEN ? ELSE NULL END
31370
+ 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);
31371
+ if (counts.pending === 0) pruneRankTrackingRuns(runId, {
31372
+ ...options,
31373
+ database: db
31374
+ });
31375
+ return getRankTrackingRun(runId, { database: db });
31376
+ }
31377
+ function pruneRankTrackingRuns(runId, options = {}) {
31378
+ const db = database(options);
31379
+ const row = db.prepare("SELECT config_id FROM rank_tracking_runs WHERE id = ?").get(runId);
31380
+ if (!row) return;
31381
+ db.prepare(`DELETE FROM rank_tracking_runs
31382
+ WHERE config_id = ? AND id IN (
31383
+ SELECT id FROM rank_tracking_runs
31384
+ WHERE config_id = ? AND completed_at IS NOT NULL
31385
+ ORDER BY scheduled_for DESC, id DESC LIMIT -1 OFFSET ?
31386
+ )`).run(row.config_id, row.config_id, RANK_TRACKING_LIMITS.retainedRunsPerConfiguration);
31387
+ const maxBytes = options.maxLogicalBytes ?? RANK_TRACKING_LIMITS.logicalBytes;
31388
+ while (rankTrackingLogicalBytes(db) > maxBytes) {
31389
+ const oldest = db.prepare(`SELECT id FROM rank_tracking_runs
31390
+ WHERE config_id = ? AND id <> ?
31391
+ AND completed_at IS NOT NULL
31392
+ ORDER BY scheduled_for, id LIMIT 1`).get(row.config_id, runId);
31393
+ if (!oldest) break;
31394
+ db.prepare("DELETE FROM rank_tracking_runs WHERE id = ?").run(oldest.id);
31395
+ }
31396
+ enforceRankTrackingLogicalBytes(db, maxBytes);
31397
+ }
31398
+ function latestRankTrackingRun(configId, options = {}) {
31399
+ const row = database(options).prepare(`SELECT * FROM rank_tracking_runs WHERE config_id = ?
31400
+ ORDER BY scheduled_for DESC, id DESC LIMIT 1`).get(configId);
31401
+ return row ? runFromRow(row) : null;
31402
+ }
31403
+ function priorComparableRankTrackingRun(configId, currentRunId, options = {}) {
31404
+ const row = database(options).prepare(`SELECT previous.* FROM rank_tracking_runs previous
31405
+ JOIN rank_tracking_runs current ON current.id = ?
31406
+ WHERE previous.config_id = ? AND previous.id <> current.id
31407
+ AND previous.scheduled_for < current.scheduled_for
31408
+ AND previous.snapshot_count > 0
31409
+ ORDER BY previous.scheduled_for DESC, previous.id DESC LIMIT 1`).get(currentRunId, configId);
31410
+ return row ? runFromRow(row) : null;
31411
+ }
31412
+ function rankObservations(runId, options = {}) {
31413
+ return database(options).prepare(`SELECT * FROM rank_tracking_snapshots WHERE run_id = ?
31414
+ ORDER BY normalized_keyword, device, task_id`).all(runId).map(observationFromRow);
31415
+ }
31416
+ function rankTrackingLogicalBytes(db = getDb()) {
31417
+ return db.prepare(`SELECT
31418
+ 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) +
31419
+ 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) +
31420
+ 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) +
31421
+ 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)
31422
+ AS bytes`).get().bytes;
31423
+ }
31424
+ function enforceRankTrackingLogicalBytes(db, maxBytes = RANK_TRACKING_LIMITS.logicalBytes) {
31425
+ 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.");
31426
+ }
31427
+ //#endregion
31428
+ //#region packages/core/dist/rank-tracking/run.js
31429
+ function providerError(error) {
31430
+ if (!(error instanceof ProviderError)) return error instanceof SeoError ? error : new SeoError("INTERNAL_ERROR", "Rank collection failed unexpectedly.");
31431
+ if (error.code === "rate-limit") return new SeoError("RATE_LIMITED", error.message);
31432
+ if (error.code === "configuration") return new SeoError("INVALID_INPUT", error.message);
31433
+ return new SeoError("PROVIDER_UNAVAILABLE", error.message);
31434
+ }
31435
+ async function defaultCollector(provider) {
31436
+ if (provider !== "dataforseo") throw new SeoError("PROVIDER_UNAVAILABLE", `${provider} does not yet implement exact rank collection. Use DataForSEO for this report.`);
31437
+ if (!await readDataForSeoCredentials()) throw new SeoError("PROVIDER_UNAVAILABLE", "DataForSEO is not connected. Run `seo providers dataforseo connect` first.");
31438
+ return new DataForSeoRankTrackingCollector();
31439
+ }
31440
+ function requestForTask(configuration, task) {
31441
+ return {
31442
+ keyword: task.displayKeyword,
31443
+ market: {
31444
+ ...configuration.market,
31445
+ device: task.device
31446
+ },
31447
+ depth: configuration.depth,
31448
+ refresh: true,
31449
+ context: {
31450
+ projectId: configuration.projectId,
31451
+ reportId: "rank-tracking",
31452
+ reportRunId: task.runId
31453
+ }
31454
+ };
31455
+ }
31456
+ function observationFromEvidence(configuration, task, evidence) {
31457
+ const match = evidence.data.organicResults.find((result) => {
31458
+ try {
31459
+ return targetMatchesDomain(configuration.targetDomain, result.domain);
31460
+ } catch {
31461
+ return false;
31462
+ }
31463
+ });
31464
+ return {
31465
+ taskId: task.id,
31466
+ runId: task.runId,
31467
+ keyword: task.displayKeyword,
31468
+ normalizedKeyword: task.normalizedKeyword,
31469
+ device: task.device,
31470
+ state: match ? "observed" : "not_observed_within_depth",
31471
+ organicPosition: match?.rankGroup ?? null,
31472
+ absolutePosition: match?.rankAbsolute ?? null,
31473
+ rankingUrl: match?.url ?? null,
31474
+ observedFeatures: evidence.data.features,
31475
+ checkedAt: evidence.data.checkedAt,
31476
+ provider: evidence.provider,
31477
+ providerTaskId: task.providerTaskId,
31478
+ requestedDepth: configuration.depth,
31479
+ returnedRows: evidence.coverage.returnedRows,
31480
+ retainedRows: evidence.coverage.retainedRows,
31481
+ invalidRows: evidence.coverage.invalidRows,
31482
+ completeness: evidence.coverage.completeness,
31483
+ estimatedCostMicros: evidence.cost.estimatedMicros,
31484
+ actualCostMicros: evidence.cost.actualMicros,
31485
+ warnings: evidence.warnings
31486
+ };
31487
+ }
31488
+ async function inBoundedParallel(items, concurrency, operation) {
31489
+ let next = 0;
31490
+ const workers = Array.from({ length: Math.min(concurrency, items.length) }, async () => {
31491
+ while (next < items.length) {
31492
+ const index = next;
31493
+ next += 1;
31494
+ const item = items[index];
31495
+ if (item) await operation(item);
31496
+ }
31497
+ });
31498
+ await Promise.all(workers);
31499
+ }
31500
+ async function collectLive(configuration, run, collector, dependencies) {
31501
+ const tasks = rankTrackingTasks(run.id, ["pending"], dependencies);
31502
+ const observations = [];
31503
+ const warnings = [];
31504
+ await inBoundedParallel(tasks, 3, async (task) => {
31505
+ try {
31506
+ const evidence = await collector.live(requestForTask(configuration, task));
31507
+ observations.push(observationFromEvidence(configuration, task, evidence));
31508
+ } catch (error) {
31509
+ const mapped = providerError(error);
31510
+ failRankTrackingTask({
31511
+ taskId: task.id,
31512
+ code: mapped.code,
31513
+ message: mapped.message
31514
+ }, dependencies);
31515
+ warnings.push(`${task.displayKeyword} (${task.device}): ${mapped.message}`);
31516
+ }
31517
+ });
31518
+ saveRankObservations(observations, dependencies);
31519
+ return {
31520
+ collected: observations.length,
31521
+ warnings
31522
+ };
31523
+ }
31524
+ async function collectQueued(configuration, run, collector, dependencies) {
31525
+ 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.`);
31526
+ const warnings = [];
31527
+ const observations = [];
31528
+ let posted = 0;
31529
+ let ready = [];
31530
+ try {
31531
+ ready = await collector.ready();
31532
+ } catch (error) {
31533
+ const mapped = providerError(error);
31534
+ warnings.push(`The provider ready list was unavailable; saved task IDs will still be checked: ${mapped.message}`);
31535
+ }
31536
+ const byTaskKey = new Map(ready.flatMap((item) => item.taskKey ? [[item.taskKey, item.providerTaskId]] : []));
31537
+ for (const task of rankTrackingTasks(run.id, ["posting"], dependencies)) {
31538
+ const providerTaskId = byTaskKey.get(task.id);
31539
+ if (providerTaskId) recoverRankTaskReceipt({
31540
+ taskId: task.id,
31541
+ providerTaskId
31542
+ }, dependencies);
31543
+ }
31544
+ await inBoundedParallel(rankTrackingTasks(run.id, ["posted"], dependencies).filter((task) => task.providerTaskId), 5, async (task) => {
31545
+ try {
31546
+ const evidence = await collector.collect?.({
31547
+ providerTaskId: task.providerTaskId,
31548
+ request: requestForTask(configuration, task)
31549
+ });
31550
+ if (!evidence) return;
31551
+ observations.push(observationFromEvidence(configuration, task, evidence));
31552
+ } catch (error) {
31553
+ const mapped = providerError(error);
31554
+ warnings.push(`${task.displayKeyword} (${task.device}): ${mapped.message}`);
31555
+ }
31556
+ });
31557
+ saveRankObservations(observations, dependencies);
31558
+ const pending = rankTrackingTasks(run.id, ["pending"], dependencies);
31559
+ for (let offset = 0; offset < pending.length; offset += RANK_TRACKING_LIMITS.tasksPerProviderPost) {
31560
+ const batch = pending.slice(offset, offset + RANK_TRACKING_LIMITS.tasksPerProviderPost);
31561
+ markRankTasksPosting(run.id, batch.map((task) => task.id), dependencies);
31562
+ try {
31563
+ const result = await collector.post({
31564
+ tasks: batch.map((task) => ({
31565
+ taskKey: task.id,
31566
+ request: requestForTask(configuration, task)
31567
+ })),
31568
+ context: {
31569
+ projectId: configuration.projectId,
31570
+ reportId: "rank-tracking",
31571
+ reportRunId: run.id
31572
+ }
31573
+ });
31574
+ const receiptByKey = new Map(result.receipts.map((receipt) => [receipt.taskKey, receipt]));
31575
+ if (receiptByKey.size !== batch.length) throw new SeoError("PROVIDER_UNAVAILABLE", "The provider did not return one receipt for every queued rank task.");
31576
+ markRankTasksPosted(run.id, batch.map((task) => ({
31577
+ taskId: task.id,
31578
+ providerTaskId: receiptByKey.get(task.id)?.providerTaskId ?? ""
31579
+ })), {
31580
+ estimatedMicros: result.estimatedCostMicros,
31581
+ actualMicros: result.actualCostMicros
31582
+ }, dependencies);
31583
+ posted += batch.length;
31584
+ warnings.push(...result.warnings.map((warning) => warning.message));
31585
+ } catch (error) {
31586
+ const mapped = providerError(error);
31587
+ warnings.push(`${batch.length} queued task${batch.length === 1 ? "" : "s"} remain in recovery after posting was interrupted: ${mapped.message}`);
31588
+ break;
31589
+ }
31590
+ }
31591
+ return {
31592
+ collected: observations.length,
31593
+ posted,
31594
+ warnings
31595
+ };
31596
+ }
31597
+ function due(configuration, now) {
31598
+ return configuration.cadence === "manual" || configuration.nextRunAt === null || Date.parse(configuration.nextRunAt) <= now.getTime();
31599
+ }
31600
+ async function executeRankTracking(input, dependencies = {}) {
31601
+ const cadence = input.cadence ?? "manual";
31602
+ const collectionMethod = input.collectionMethod ?? (cadence === "manual" ? "live" : "queued");
31603
+ const keywordLimit = input.keywordLimit ?? (collectionMethod === "live" ? 25 : 100);
31604
+ const set = getKeywordSet({
31605
+ projectId: input.projectId,
31606
+ idOrName: input.set,
31607
+ tag: input.tag,
31608
+ limit: keywordLimit
31609
+ }, dependencies);
31610
+ const provider = input.provider ?? set.set.provider ?? "dataforseo";
31611
+ const defaultDevice = set.set.market.device ?? "desktop";
31612
+ const configuration = getOrCreateRankTrackingConfiguration({
31613
+ projectId: input.projectId,
31614
+ keywordSetId: set.set.id,
31615
+ targetDomain: input.targetDomain,
31616
+ tag: input.tag,
31617
+ market: set.set.market,
31618
+ devices: input.devices ?? [defaultDevice],
31619
+ provider,
31620
+ collectionMethod,
31621
+ cadence,
31622
+ depth: input.depth ?? 100,
31623
+ keywordLimit
31624
+ }, dependencies);
31625
+ const warnings = [];
31626
+ let run = activeRankTrackingRun(configuration.id, dependencies);
31627
+ let startedRun = false;
31628
+ let collectedTasks = 0;
31629
+ let postedTasks = 0;
31630
+ if (!run && (input.start ?? true) && due(configuration, dependencies.now?.() ?? /* @__PURE__ */ new Date())) {
31631
+ run = startRankTrackingRun({
31632
+ configuration,
31633
+ keywords: set.items.map((item) => ({
31634
+ keyword: item.keyword,
31635
+ normalizedKeyword: item.normalizedKeyword
31636
+ }))
31637
+ }, {
31638
+ ...dependencies,
31639
+ id: dependencies.id ?? randomUUID
31640
+ });
31641
+ startedRun = true;
31642
+ }
31643
+ if (run) {
31644
+ const collector = dependencies.collector ?? await (dependencies.collectorFactory ?? defaultCollector)(provider);
31645
+ if (collector.provider !== provider) throw new SeoError("INTERNAL_ERROR", "The selected rank collector does not match the saved provider.");
31646
+ if (configuration.collectionMethod === "live") {
31647
+ const result = await collectLive(configuration, run, collector, dependencies);
31648
+ collectedTasks += result.collected;
31649
+ warnings.push(...result.warnings);
31650
+ } else {
31651
+ const result = await collectQueued(configuration, run, collector, dependencies);
31652
+ collectedTasks += result.collected;
31653
+ postedTasks += result.posted;
31654
+ warnings.push(...result.warnings);
31655
+ }
31656
+ run = latestRankTrackingRun(configuration.id, dependencies);
31657
+ } else run = latestRankTrackingRun(configuration.id, dependencies);
31658
+ return {
31659
+ configuration,
31660
+ run,
31661
+ startedRun,
31662
+ collectedTasks,
31663
+ postedTasks,
31664
+ operationalWarnings: warnings.slice(0, 20)
31665
+ };
31666
+ }
31667
+ //#endregion
31668
+ //#region packages/core/dist/analyze/rank-tracking.js
31669
+ function key$1(item) {
31670
+ return `${item.normalizedKeyword}\u0000${item.device}`;
31671
+ }
31672
+ function changeFor(current, previous) {
31673
+ if (!previous) return {
31674
+ change: "no-history",
31675
+ organicPositionDelta: null,
31676
+ rankingUrlChanged: false
31677
+ };
31678
+ const rankingUrlChanged = current.state === "observed" && previous.state === "observed" && current.rankingUrl !== previous.rankingUrl;
31679
+ if (current.state === "observed" && previous.state === "not_observed_within_depth") return {
31680
+ change: "new",
31681
+ organicPositionDelta: null,
31682
+ rankingUrlChanged
31683
+ };
31684
+ if (current.state === "not_observed_within_depth" && previous.state === "observed") return {
31685
+ change: "lost",
31686
+ organicPositionDelta: null,
31687
+ rankingUrlChanged
31688
+ };
31689
+ if (current.organicPosition !== null && previous.organicPosition !== null) {
31690
+ const delta = previous.organicPosition - current.organicPosition;
31691
+ return {
31692
+ change: delta > 0 ? "improved" : delta < 0 ? "declined" : "unchanged",
31693
+ organicPositionDelta: delta,
31694
+ rankingUrlChanged
31695
+ };
31696
+ }
31697
+ return {
31698
+ change: "unchanged",
31699
+ organicPositionDelta: null,
31700
+ rankingUrlChanged
31701
+ };
31702
+ }
31703
+ const CHANGE_ORDER = {
31704
+ lost: 0,
31705
+ declined: 1,
31706
+ new: 2,
31707
+ improved: 3,
31708
+ unchanged: 4,
31709
+ "no-history": 5
31710
+ };
31711
+ function compareItems(left, right) {
31712
+ 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);
31713
+ }
31714
+ function reportItems(current, previous, pending) {
31715
+ const previousByKey = new Map(previous.map((item) => [key$1(item), item]));
31716
+ const items = current.map((item) => {
31717
+ const prior = previousByKey.get(key$1(item));
31718
+ const change = changeFor(item, prior);
31719
+ return {
31720
+ keyword: item.keyword,
31721
+ normalizedKeyword: item.normalizedKeyword,
31722
+ device: item.device,
31723
+ state: item.state,
31724
+ organicPosition: item.organicPosition,
31725
+ absolutePosition: item.absolutePosition,
31726
+ rankingUrl: item.rankingUrl,
31727
+ checkedAt: item.checkedAt,
31728
+ previous: prior ? {
31729
+ state: prior.state,
31730
+ organicPosition: prior.organicPosition,
31731
+ absolutePosition: prior.absolutePosition,
31732
+ rankingUrl: prior.rankingUrl,
31733
+ checkedAt: prior.checkedAt
31734
+ } : null,
31735
+ ...change,
31736
+ error: null
31737
+ };
31738
+ });
31739
+ const observedKeys = new Set(current.map(key$1));
31740
+ for (const task of pending) {
31741
+ if (observedKeys.has(key$1(task))) continue;
31742
+ const prior = previousByKey.get(key$1(task));
31743
+ items.push({
31744
+ keyword: task.displayKeyword,
31745
+ normalizedKeyword: task.normalizedKeyword,
31746
+ device: task.device,
31747
+ state: task.state === "failed" ? "failed" : "pending",
31748
+ organicPosition: null,
31749
+ absolutePosition: null,
31750
+ rankingUrl: null,
31751
+ checkedAt: null,
31752
+ previous: prior ? {
31753
+ state: prior.state,
31754
+ organicPosition: prior.organicPosition,
31755
+ absolutePosition: prior.absolutePosition,
31756
+ rankingUrl: prior.rankingUrl,
31757
+ checkedAt: prior.checkedAt
31758
+ } : null,
31759
+ change: "no-history",
31760
+ organicPositionDelta: null,
31761
+ rankingUrlChanged: false,
31762
+ error: task.state === "failed" ? {
31763
+ code: task.errorCode,
31764
+ message: task.errorMessage
31765
+ } : null
31766
+ });
31767
+ }
31768
+ return items.sort(compareItems);
31769
+ }
31770
+ function verdict$1(run, counts) {
31771
+ if (!run) return "No rank tracking run has started for this configuration.";
31772
+ if (run.pendingCount > 0) return `${run.snapshotCount} of ${run.taskCount} market and device-specific snapshots are collected; ${run.pendingCount} remain pending.`;
31773
+ if (run.failedCount > 0) return `${run.snapshotCount} snapshots were collected and ${run.failedCount} failed; use the completed rows as partial evidence.`;
31774
+ 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.`;
31775
+ return `${counts.observed} rankings were observed and ${counts.notObserved} were not observed within the tracked depth.`;
31776
+ }
31777
+ async function rankTrackingReport(input, dependencies = {}) {
31778
+ const outputLimit = input.outputLimit ?? 100;
31779
+ if (!Number.isSafeInteger(outputLimit) || outputLimit < 1 || outputLimit > 250) throw new SeoError("INVALID_INPUT", "Rank tracking output limit must be from 1 to 250.");
31780
+ const execution = await executeRankTracking(input, dependencies);
31781
+ const { configuration, run } = execution;
31782
+ const current = run ? rankObservations(run.id, dependencies) : [];
31783
+ const priorRun = run ? priorComparableRankTrackingRun(configuration.id, run.id, dependencies) : null;
31784
+ const allItems = reportItems(current, priorRun ? rankObservations(priorRun.id, dependencies) : [], run ? rankTrackingTasks(run.id, void 0, dependencies) : []);
31785
+ const count = (change) => allItems.filter((item) => item.change === change).length;
31786
+ const observed = current.filter((item) => item.state === "observed").length;
31787
+ const notObserved = current.length - observed;
31788
+ const improved = count("improved");
31789
+ const declined = count("declined");
31790
+ const newCount = count("new");
31791
+ const lost = count("lost");
31792
+ const unchanged = count("unchanged");
31793
+ const rankingUrlChanges = allItems.filter((item) => item.rankingUrlChanged).length;
31794
+ const items = allItems.slice(0, outputLimit);
31795
+ const dataStatus = !run ? "unavailable" : run.state === "complete" ? "complete" : run.state === "pending" ? "pending" : "partial";
31796
+ return {
31797
+ schemaVersion: 1,
31798
+ generatedAt: (dependencies.now ?? (() => /* @__PURE__ */ new Date()))().toISOString(),
31799
+ dataStatus,
31800
+ configuration,
31801
+ run,
31802
+ comparison: {
31803
+ priorRunId: priorRun?.id ?? null,
31804
+ priorStartedAt: priorRun?.startedAt ?? null,
31805
+ comparableItems: allItems.filter((item) => item.previous !== null).length
31806
+ },
31807
+ summary: {
31808
+ trackedKeywords: run?.keywordCount ?? 0,
31809
+ trackedDevices: configuration.devices.length,
31810
+ expectedSnapshots: run?.taskCount ?? 0,
31811
+ completedSnapshots: run?.snapshotCount ?? 0,
31812
+ pendingSnapshots: run?.pendingCount ?? 0,
31813
+ failedSnapshots: run?.failedCount ?? 0,
31814
+ observed,
31815
+ notObservedWithinDepth: notObserved,
31816
+ new: newCount,
31817
+ lost,
31818
+ improved,
31819
+ declined,
31820
+ unchanged,
31821
+ rankingUrlChanges,
31822
+ verdict: verdict$1(run, {
31823
+ observed,
31824
+ notObserved,
31825
+ improved,
31826
+ declined,
31827
+ newCount,
31828
+ lost
31829
+ })
31830
+ },
31831
+ items,
31832
+ coverage: {
31833
+ totalItems: allItems.length,
31834
+ returnedItems: items.length,
31835
+ outputLimit,
31836
+ omittedItems: allItems.length - items.length,
31837
+ requestedDepth: configuration.depth,
31838
+ collectionMethod: configuration.collectionMethod,
31839
+ runState: run?.state ?? "not-started"
31840
+ },
31841
+ cost: {
31842
+ currency: "USD",
31843
+ estimatedMicros: run?.estimatedCostMicros ?? null,
31844
+ actualMicros: run?.actualCostMicros ?? null
31845
+ },
31846
+ operationalWarnings: execution.operationalWarnings,
31847
+ caveats: [
31848
+ `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.`,
31849
+ "Exact snapshots and Search Console average position answer different questions and should not be substituted for one another.",
31850
+ "Local, national, desktop, and mobile snapshots remain separate configurations and are never blended into one position.",
31851
+ "Search results can change between checks and may differ from signed-in or personalized results.",
31852
+ ...run?.state === "partial" || run?.state === "failed" ? ["Failed or pending tasks make this run partial; do not treat missing rows as losses."] : []
31853
+ ],
31854
+ nextSteps: [
31855
+ ...run?.pendingCount ? ["Run the same report parameters again after queued tasks are ready; collection is free and resumes from saved task IDs."] : [],
31856
+ "Use Search Console opportunity reports to add clicks, impressions, and average-position context before prioritising a ranking change.",
31857
+ "Inspect ranking URL changes for intent drift or competing templates before changing content.",
31858
+ 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."
31859
+ ]
31860
+ };
31861
+ }
31862
+ //#endregion
30345
31863
  //#region packages/core/dist/analyze/reports/dates.js
30346
31864
  function rangeDays(range) {
30347
31865
  const start = Date.parse(`${range.startDate}T00:00:00Z`);
@@ -30807,211 +32325,6 @@ function reportPresentation(report) {
30807
32325
  };
30808
32326
  }
30809
32327
  //#endregion
30810
- //#region packages/core/dist/keyword-sets/limits.js
30811
- const KEYWORD_SET_LIMITS = {
30812
- setsPerProject: 25,
30813
- totalSets: 100,
30814
- keywordsPerSet: 1e4,
30815
- totalKeywords: 1e5,
30816
- mutationKeywords: 1e3,
30817
- tagsPerKeyword: 20,
30818
- outputRows: 1e3,
30819
- logicalBytes: 64 * 1024 * 1024
30820
- };
30821
- const KEYWORD_SET_FIELD_LIMITS = {
30822
- name: 80,
30823
- projectId: 80,
30824
- sourceReport: 100,
30825
- keyword: 80,
30826
- keywordWords: 10,
30827
- tag: 40,
30828
- url: 2048,
30829
- metricJsonBytes: 16 * 1024
30830
- };
30831
- //#endregion
30832
- //#region packages/core/dist/keyword-sets/validation.js
30833
- const unavailableValueSchema = z.strictObject({
30834
- state: z.enum([
30835
- "missing",
30836
- "unavailable",
30837
- "invalid"
30838
- ]),
30839
- value: z.null(),
30840
- reason: z.string().min(1).max(500)
30841
- });
30842
- const providerValueNumberSchema = z.discriminatedUnion("state", [z.strictObject({
30843
- state: z.literal("observed"),
30844
- value: z.number().finite()
30845
- }), unavailableValueSchema]);
30846
- const providerValueStringSchema = z.discriminatedUnion("state", [z.strictObject({
30847
- state: z.literal("observed"),
30848
- value: z.string().max(500)
30849
- }), unavailableValueSchema]);
30850
- const monthlySearchSchema = z.strictObject({
30851
- year: z.number().int().min(2e3).max(2200),
30852
- month: z.number().int().min(1).max(12),
30853
- searchVolume: z.number().finite().nonnegative()
30854
- });
30855
- const providerValueMonthlySearchesSchema = z.discriminatedUnion("state", [z.strictObject({
30856
- state: z.literal("observed"),
30857
- value: z.array(monthlySearchSchema).max(24)
30858
- }), unavailableValueSchema]);
30859
- const keywordMetricSchema = z.strictObject({
30860
- keyword: z.string().min(1).max(KEYWORD_SET_FIELD_LIMITS.keyword),
30861
- monthlySearchVolume: providerValueNumberSchema,
30862
- monthlySearches: providerValueMonthlySearchesSchema,
30863
- searchVolumeUpdatedAt: providerValueStringSchema,
30864
- cpcUsd: providerValueNumberSchema,
30865
- paidCompetition: providerValueNumberSchema,
30866
- keywordDifficulty: providerValueNumberSchema,
30867
- intent: providerValueStringSchema,
30868
- resultCount: providerValueNumberSchema
30869
- });
30870
- const savedMetricSchema = z.strictObject({
30871
- schemaVersion: z.literal(1),
30872
- provider: providerIdSchema,
30873
- observedAt: z.iso.datetime(),
30874
- metric: keywordMetricSchema
30875
- });
30876
- function invalid(message) {
30877
- throw new SeoError("INVALID_INPUT", message);
30878
- }
30879
- function boundedText(value, label, max) {
30880
- const normalized = value.trim();
30881
- if (!normalized || normalized.length > max) invalid(`${label} must contain 1 to ${max} characters.`);
30882
- return normalized;
30883
- }
30884
- function normalizeSavedKeyword(value) {
30885
- const normalized = value.trim().replace(/\s+/gu, " ").toLowerCase();
30886
- if (!normalized || normalized.length > KEYWORD_SET_FIELD_LIMITS.keyword || normalized.split(" ").length > KEYWORD_SET_FIELD_LIMITS.keywordWords) invalid(`Keywords must contain 1 to ${KEYWORD_SET_FIELD_LIMITS.keyword} characters and at most ${KEYWORD_SET_FIELD_LIMITS.keywordWords} words.`);
30887
- return normalized;
30888
- }
30889
- function parseStoredJson(value, schema, label) {
30890
- let decoded;
30891
- try {
30892
- decoded = JSON.parse(value);
30893
- } catch {
30894
- throw new SeoError("INTERNAL_ERROR", `${label} contains invalid JSON.`);
30895
- }
30896
- const parsed = schema.safeParse(decoded);
30897
- if (!parsed.success) throw new SeoError("INTERNAL_ERROR", `${label} has an invalid shape.`);
30898
- return parsed.data;
30899
- }
30900
- //#endregion
30901
- //#region packages/core/dist/keyword-sets/rows.js
30902
- const SET_SELECT = `
30903
- SELECT sets.*,
30904
- (SELECT COUNT(*) FROM keyword_set_items items WHERE items.set_id = sets.id) AS keyword_count,
30905
- (SELECT COUNT(DISTINCT tags.tag) FROM keyword_set_tags tags WHERE tags.set_id = sets.id) AS tag_count
30906
- FROM keyword_sets sets`;
30907
- function storeDatabase(options) {
30908
- return options.database ?? getDb();
30909
- }
30910
- function rowToSet(row) {
30911
- const provider = row.provider ? providerIdSchema.safeParse(row.provider) : void 0;
30912
- if (provider && !provider.success) throw new SeoError("INTERNAL_ERROR", "Saved keyword set provider is invalid.");
30913
- return {
30914
- schemaVersion: 1,
30915
- id: row.id,
30916
- projectId: row.project_id,
30917
- name: row.name,
30918
- market: parseStoredJson(row.market_json, searchMarketSchema, "Saved search market"),
30919
- provider: provider?.data ?? null,
30920
- sourceReport: row.source_report,
30921
- keywordCount: row.keyword_count,
30922
- tagCount: row.tag_count,
30923
- createdAt: new Date(row.created_at).toISOString(),
30924
- updatedAt: new Date(row.updated_at).toISOString(),
30925
- lastRefreshedAt: row.last_refreshed_at === null ? null : new Date(row.last_refreshed_at).toISOString()
30926
- };
30927
- }
30928
- function findSetRow(db, projectId, idOrName) {
30929
- return db.prepare(`${SET_SELECT}
30930
- WHERE sets.project_id = ?
30931
- AND (sets.id = ? OR sets.name = ? COLLATE NOCASE)
30932
- ORDER BY CASE WHEN sets.id = ? THEN 0 ELSE 1 END
30933
- LIMIT 1`).get(projectId, idOrName, idOrName, idOrName);
30934
- }
30935
- function requireSetRow(db, projectId, idOrName) {
30936
- const row = findSetRow(db, projectId, idOrName);
30937
- if (!row) invalid(`Keyword set not found: ${idOrName}`);
30938
- return row;
30939
- }
30940
- function rowCount(db, sql, ...params) {
30941
- return db.prepare(sql).get(...params).count;
30942
- }
30943
- //#endregion
30944
- //#region packages/core/dist/keyword-sets/store.js
30945
- function getKeywordSet(input, options = {}) {
30946
- const projectId = boundedText(input.projectId, "Project id", KEYWORD_SET_FIELD_LIMITS.projectId);
30947
- const idOrName = boundedText(input.idOrName, "Keyword set id or name", KEYWORD_SET_FIELD_LIMITS.name);
30948
- const tag = input.tag ? boundedText(input.tag, "Tag", KEYWORD_SET_FIELD_LIMITS.tag).toLowerCase() : null;
30949
- const limit = input.limit ?? 100;
30950
- const offset = input.offset ?? 0;
30951
- if (!Number.isSafeInteger(limit) || limit < 1 || limit > KEYWORD_SET_LIMITS.outputRows) invalid(`Keyword set output limit must be from 1 to ${KEYWORD_SET_LIMITS.outputRows}.`);
30952
- if (!Number.isSafeInteger(offset) || offset < 0) invalid("Keyword set output offset must be zero or greater.");
30953
- const db = storeDatabase(options);
30954
- const setRow = requireSetRow(db, projectId, idOrName);
30955
- const filterSql = tag ? `AND EXISTS (
30956
- SELECT 1 FROM keyword_set_tags filter_tags
30957
- WHERE filter_tags.set_id = items.set_id
30958
- AND filter_tags.normalized_keyword = items.normalized_keyword
30959
- AND filter_tags.tag = ?
30960
- )` : "";
30961
- const params = tag ? [setRow.id, tag] : [setRow.id];
30962
- const total = rowCount(db, `SELECT COUNT(*) AS count FROM keyword_set_items items
30963
- WHERE items.set_id = ? ${filterSql}`, ...params);
30964
- const rows = db.prepare(`SELECT items.* FROM keyword_set_items items
30965
- WHERE items.set_id = ? ${filterSql}
30966
- ORDER BY items.normalized_keyword
30967
- LIMIT ? OFFSET ?`).all(...params, limit, offset);
30968
- const tagsByKeyword = loadTags(db, setRow.id, rows);
30969
- const items = rows.map((row) => itemFromRow(row, tagsByKeyword.get(row.normalized_keyword) ?? []));
30970
- return {
30971
- schemaVersion: 1,
30972
- set: rowToSet(setRow),
30973
- items,
30974
- pagination: {
30975
- offset,
30976
- limit,
30977
- returned: items.length,
30978
- total,
30979
- nextOffset: offset + items.length < total ? offset + items.length : null
30980
- },
30981
- filter: { tag }
30982
- };
30983
- }
30984
- function loadTags(db, setId, rows) {
30985
- if (rows.length === 0) return /* @__PURE__ */ new Map();
30986
- const tagRows = db.prepare(`SELECT normalized_keyword, tag FROM keyword_set_tags
30987
- WHERE set_id = ? AND normalized_keyword IN (${rows.map(() => "?").join(", ")})
30988
- ORDER BY normalized_keyword, tag`).all(setId, ...rows.map((row) => row.normalized_keyword));
30989
- const tagsByKeyword = /* @__PURE__ */ new Map();
30990
- for (const row of tagRows) {
30991
- const tags = tagsByKeyword.get(row.normalized_keyword) ?? [];
30992
- tags.push(row.tag);
30993
- tagsByKeyword.set(row.normalized_keyword, tags);
30994
- }
30995
- return tagsByKeyword;
30996
- }
30997
- function itemFromRow(row, tags) {
30998
- const latestMetric = row.metric_json ? parseStoredJson(row.metric_json, savedMetricSchema, "Saved keyword metric") : null;
30999
- if (latestMetric && (latestMetric.provider !== row.metric_provider || latestMetric.observedAt !== row.metric_observed_at || normalizeSavedKeyword(latestMetric.metric.keyword) !== row.normalized_keyword)) throw new SeoError("INTERNAL_ERROR", "Saved keyword metric metadata is inconsistent.");
31000
- const page = row.page_kind && row.page_url ? {
31001
- kind: row.page_kind,
31002
- url: row.page_url
31003
- } : null;
31004
- return {
31005
- keyword: row.display_keyword,
31006
- normalizedKeyword: row.normalized_keyword,
31007
- tags,
31008
- page,
31009
- latestMetric,
31010
- createdAt: new Date(row.created_at).toISOString(),
31011
- updatedAt: new Date(row.updated_at).toISOString()
31012
- };
31013
- }
31014
- //#endregion
31015
32328
  //#region packages/core/dist/analyze/saved-keywords.js
31016
32329
  const DEFAULT_STALE_DAYS = 45;
31017
32330
  function savedKeywordSetReport(input, dependencies = {}) {
@@ -34658,6 +35971,7 @@ const TELEMETRY_REPORTS = [
34658
35971
  "pseo-opportunities",
34659
35972
  "query-clusters",
34660
35973
  "quick-wins",
35974
+ "rank-tracking",
34661
35975
  "redirect-trace",
34662
35976
  "narrative-report",
34663
35977
  "second-page",
@@ -36413,6 +37727,25 @@ const savedKeywordsInput = z$1.strictObject({
36413
37727
  offset: z$1.number().int().min(0).max(1e5).default(0),
36414
37728
  staleDays: z$1.number().int().min(1).max(365).default(45)
36415
37729
  });
37730
+ const rankTrackingInput = z$1.strictObject({
37731
+ projectId: z$1.string().trim().min(1).max(80),
37732
+ set: z$1.string().trim().min(1).max(80),
37733
+ targetDomain: z$1.string().trim().min(1).max(253),
37734
+ tag: z$1.string().trim().min(1).max(40).optional(),
37735
+ devices: z$1.array(z$1.enum(["desktop", "mobile"])).min(1).max(2).refine((items) => new Set(items).size === items.length, { message: "Choose each device once." }).optional(),
37736
+ provider: providerIdInput.optional(),
37737
+ collectionMethod: z$1.enum(["live", "queued"]).optional(),
37738
+ cadence: z$1.enum([
37739
+ "manual",
37740
+ "daily",
37741
+ "weekly",
37742
+ "monthly"
37743
+ ]).default("manual"),
37744
+ depth: z$1.number().int().min(1).max(100).default(100),
37745
+ keywordLimit: z$1.number().int().min(1).max(1e3).optional(),
37746
+ start: z$1.boolean().default(true),
37747
+ outputLimit: z$1.number().int().min(1).max(250).default(100)
37748
+ });
36416
37749
  function registerKeywordTools(server, dependencies = {}) {
36417
37750
  server.registerTool("seo_saved_keywords", {
36418
37751
  description: "Review one bounded local keyword set with metric freshness, tags, page mappings, and evidence limits",
@@ -36432,6 +37765,17 @@ function registerKeywordTools(server, dependencies = {}) {
36432
37765
  return toolError(error);
36433
37766
  }
36434
37767
  });
37768
+ server.registerTool("seo_rank_tracking", {
37769
+ description: "Collect and compare exact market and device-specific ranks for one bounded saved keyword set, with local history, queued recovery, coverage, and cost evidence",
37770
+ inputSchema: rankTrackingInput
37771
+ }, async (input) => {
37772
+ try {
37773
+ const report = await (dependencies.rankTrackingReport ?? rankTrackingReport)(input);
37774
+ return toolSuccess(report.summary.verdict, compactAgentWorkflowOutput(report));
37775
+ } catch (error) {
37776
+ return toolError(error);
37777
+ }
37778
+ });
36435
37779
  server.registerTool("seo_keyword_metrics", {
36436
37780
  description: "Compare bounded third-party keyword demand, cost, competition, difficulty, intent, result-count, and trend evidence",
36437
37781
  inputSchema: {
@@ -38681,6 +40025,40 @@ const REPORT_DEPTH = {
38681
40025
  }
38682
40026
  ]
38683
40027
  },
40028
+ "rank-tracking": {
40029
+ readOrder: [
40030
+ "dataStatus, configuration market, devices, targetDomain, depth, collection method, cadence, and run state",
40031
+ "coverage, pending and failed counts, provider cost, warnings, and caveats",
40032
+ "comparison prior run and per-item current and previous observation states",
40033
+ "organic position change, absolute position, ranking URL changes, and checkedAt",
40034
+ "nextSteps for first-party context, template review, or queued collection"
40035
+ ],
40036
+ doNotClaim: [
40037
+ "Not observed within the configured depth is not proof that the domain does not rank.",
40038
+ "Exact snapshots must not be substituted for Search Console average position or blended across markets and devices.",
40039
+ "A ranking change alone does not establish causation, traffic impact, or future performance.",
40040
+ "Pending or failed tasks cannot be treated as losses or zero rankings."
40041
+ ],
40042
+ verify: "Confirm the same market, device, depth, and target-domain scope, then inspect the ranking URL and Search Console evidence before acting.",
40043
+ related: [
40044
+ {
40045
+ id: "serp-results",
40046
+ reason: "Inspects the current result page."
40047
+ },
40048
+ {
40049
+ id: "saved-keywords",
40050
+ reason: "Reviews the tracked keyword set."
40051
+ },
40052
+ {
40053
+ id: "quick-wins",
40054
+ reason: "Adds first-party opportunity evidence."
40055
+ },
40056
+ {
40057
+ id: "striking-distance",
40058
+ reason: "Adds first-party average-position evidence."
40059
+ }
40060
+ ]
40061
+ },
38684
40062
  "segment-impact": {
38685
40063
  readOrder: [
38686
40064
  "before, after, and rangeDays",
@@ -39324,6 +40702,17 @@ const REPORT_GUIDANCE = {
39324
40702
  avoidWhen: ["You need rank history or scheduled tracking across many keywords.", "You want a domain strength, content quality, or ranking feasibility verdict."],
39325
40703
  outcome: "A bounded result snapshot with exact retained ranks, domain repetition, query corrections, features, cost, and coverage."
39326
40704
  },
40705
+ "rank-tracking": {
40706
+ name: "Exact rank tracking",
40707
+ description: "Collect and compare exact organic positions for a saved keyword set without mixing markets, devices, or Search Console averages.",
40708
+ useWhen: [
40709
+ "You need repeatable exact-rank snapshots for a fixed market and device.",
40710
+ "You want new, lost, improved, declined, or ranking URL change evidence over time.",
40711
+ "A recurring set should use cheaper queued collection and local history."
40712
+ ],
40713
+ avoidWhen: ["You need first-party clicks, impressions, or average position; use a Search Console report.", "One current query needs result-page inspection but no history; use live search results."],
40714
+ outcome: "A bounded exact-rank comparison with explicit depth, pending and failed states, provider cost, and local retention evidence."
40715
+ },
39327
40716
  "segment-impact": {
39328
40717
  name: "Search impact by segment",
39329
40718
  description: "Compare page or query groups across equal periods to find which parts of a site moved.",
@@ -39830,6 +41219,7 @@ const reportGroups = [
39830
41219
  "seo_keyword_metrics",
39831
41220
  "seo_keyword_opportunities",
39832
41221
  "seo_keyword_research",
41222
+ "seo_rank_tracking",
39833
41223
  "seo_saved_keywords",
39834
41224
  "seo_serp_results"
39835
41225
  ]