seo 0.2.15 → 0.2.17

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/mcp.js CHANGED
@@ -1,5 +1,5 @@
1
1
  import { createRequire as __seoCreateRequire } from 'node:module'; const require = __seoCreateRequire(import.meta.url);
2
- import { n as load, t as version } from "./package-Cuxp-LBy.js";
2
+ import { n as load, t as version } from "./package-C-Oa9Mgz.js";
3
3
  import { Headers, fetch as fetch$1 } from "undici";
4
4
  import { chmodSync, createReadStream, existsSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs";
5
5
  import { arch, homedir, platform, totalmem } from "node:os";
@@ -787,6 +787,157 @@ async function createGoogleAccessTokenClient() {
787
787
  };
788
788
  }
789
789
  //#endregion
790
+ //#region packages/core/dist/keyword-sets/schema.js
791
+ const KEYWORD_SET_SCHEMA_SQL = `
792
+ CREATE TABLE IF NOT EXISTS keyword_sets (
793
+ id TEXT PRIMARY KEY,
794
+ project_id TEXT NOT NULL,
795
+ name TEXT NOT NULL,
796
+ market_json TEXT NOT NULL,
797
+ provider TEXT CHECK(provider IS NULL OR provider IN ('dataforseo', 'semrush', 'ahrefs')),
798
+ source_report TEXT,
799
+ created_at INTEGER NOT NULL,
800
+ updated_at INTEGER NOT NULL,
801
+ last_refreshed_at INTEGER,
802
+ UNIQUE(project_id, name COLLATE NOCASE)
803
+ );
804
+ CREATE INDEX IF NOT EXISTS idx_keyword_sets_project
805
+ ON keyword_sets(project_id, updated_at DESC, id);
806
+
807
+ CREATE TABLE IF NOT EXISTS keyword_set_items (
808
+ set_id TEXT NOT NULL,
809
+ normalized_keyword TEXT NOT NULL,
810
+ display_keyword TEXT NOT NULL,
811
+ metric_json TEXT,
812
+ metric_provider TEXT CHECK(metric_provider IS NULL OR metric_provider IN ('dataforseo', 'semrush', 'ahrefs')),
813
+ metric_observed_at TEXT,
814
+ page_kind TEXT CHECK(page_kind IS NULL OR page_kind IN ('target', 'proposed')),
815
+ page_url TEXT,
816
+ created_at INTEGER NOT NULL,
817
+ updated_at INTEGER NOT NULL,
818
+ PRIMARY KEY(set_id, normalized_keyword),
819
+ FOREIGN KEY(set_id) REFERENCES keyword_sets(id) ON DELETE CASCADE
820
+ ) WITHOUT ROWID;
821
+ CREATE INDEX IF NOT EXISTS idx_keyword_set_items_set
822
+ ON keyword_set_items(set_id, normalized_keyword);
823
+
824
+ CREATE TABLE IF NOT EXISTS keyword_set_tags (
825
+ set_id TEXT NOT NULL,
826
+ normalized_keyword TEXT NOT NULL,
827
+ tag TEXT NOT NULL,
828
+ PRIMARY KEY(set_id, normalized_keyword, tag),
829
+ FOREIGN KEY(set_id, normalized_keyword)
830
+ REFERENCES keyword_set_items(set_id, normalized_keyword) ON DELETE CASCADE
831
+ ) WITHOUT ROWID;
832
+ CREATE INDEX IF NOT EXISTS idx_keyword_set_tags_lookup
833
+ ON keyword_set_tags(set_id, tag, normalized_keyword);
834
+ `;
835
+ //#endregion
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
790
941
  //#region packages/core/dist/storage/cache-maintenance.js
791
942
  const MEBIBYTE$2 = 1024 * 1024;
792
943
  const CACHE_MAX_AGE_MS = 720 * 60 * 60 * 1e3;
@@ -1341,6 +1492,8 @@ function initDb(database, isNewDatabase) {
1341
1492
  if (isNewDatabase) database.pragma("auto_vacuum = INCREMENTAL");
1342
1493
  database.transaction(() => {
1343
1494
  database.exec(CREATE_SQL);
1495
+ database.exec(KEYWORD_SET_SCHEMA_SQL);
1496
+ database.exec(RANK_TRACKING_SCHEMA_SQL);
1344
1497
  database.exec(PROVIDER_SPEND_SCHEMA_SQL);
1345
1498
  database.exec(`DELETE FROM semrush_cache WHERE request_json LIKE '%"key"%'`);
1346
1499
  ensureColumn(database, "crawl_pages", "snapshot_json", "TEXT");
@@ -4486,7 +4639,7 @@ async function fetchPage(url, opts = {}, dependencies = {}) {
4486
4639
  }
4487
4640
  //#endregion
4488
4641
  //#region packages/core/dist/gsc/property-url.js
4489
- function invalid(message) {
4642
+ function invalid$2(message) {
4490
4643
  throw new SeoError("INVALID_INPUT", message);
4491
4644
  }
4492
4645
  function normalizeHttpUrl(value) {
@@ -4494,10 +4647,10 @@ function normalizeHttpUrl(value) {
4494
4647
  try {
4495
4648
  url = new URL(value.trim());
4496
4649
  } catch {
4497
- return invalid(`Invalid URL: ${value}`);
4650
+ return invalid$2(`Invalid URL: ${value}`);
4498
4651
  }
4499
- if (url.protocol !== "http:" && url.protocol !== "https:") return invalid("The report URL must use http:// or https://.");
4500
- if (url.username || url.password) return invalid("The report URL must not contain embedded credentials.");
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.");
4501
4654
  url.hash = "";
4502
4655
  return url.toString();
4503
4656
  }
@@ -4506,13 +4659,13 @@ function assertUrlMatchesGscProperty(property, value) {
4506
4659
  const parsed = new URL(url);
4507
4660
  if (property.startsWith("sc-domain:")) {
4508
4661
  const domain = property.slice(10).trim().toLowerCase();
4509
- if (!domain || /[/:]/.test(domain)) return invalid(`Invalid Search Console domain property: ${property}`);
4662
+ if (!domain || /[/:]/.test(domain)) return invalid$2(`Invalid Search Console domain property: ${property}`);
4510
4663
  const hostname = parsed.hostname.toLowerCase();
4511
- if (hostname !== domain && !hostname.endsWith(`.${domain}`)) return invalid(`${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}.`);
4512
4665
  return url;
4513
4666
  }
4514
4667
  const prefix = normalizeHttpUrl(property);
4515
- if (!url.startsWith(prefix)) return invalid(`${url} is outside the Search Console property ${property}.`);
4668
+ if (!url.startsWith(prefix)) return invalid$2(`${url} is outside the Search Console property ${property}.`);
4516
4669
  return url;
4517
4670
  }
4518
4671
  //#endregion
@@ -6596,7 +6749,7 @@ function selectBenchmarkRows$1(input) {
6596
6749
  function compareText$15(left, right) {
6597
6750
  return left < right ? -1 : left > right ? 1 : 0;
6598
6751
  }
6599
- function compareItems$1(left, right) {
6752
+ function compareItems$2(left, right) {
6600
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);
6601
6754
  }
6602
6755
  function analyzeRow(input) {
@@ -6661,7 +6814,7 @@ function analyzePageOpportunitiesFromRows(input) {
6661
6814
  reportInput: input,
6662
6815
  benchmarkContext,
6663
6816
  excludedTargetRows
6664
- })).sort(compareItems$1).slice(0, limit);
6817
+ })).sort(compareItems$2).slice(0, limit);
6665
6818
  const estimatedCtrClickShortfall = Number(items.reduce((sum, item) => sum + (item.estimatedCtrClickShortfall ?? 0), 0).toFixed(2));
6666
6819
  return {
6667
6820
  site: input.site,
@@ -16738,7 +16891,7 @@ function compareRetainedSegmentRows(input) {
16738
16891
  }
16739
16892
  //#endregion
16740
16893
  //#region packages/core/dist/analyze/segment-impact-analysis.js
16741
- function verdict$4(input) {
16894
+ function verdict$5(input) {
16742
16895
  if (input.status === "empty") return "Search Console returned no retained segment rows for either window.";
16743
16896
  if (!input.matchedRows) return "No segment was retained in both windows, so no movement delta was inferred.";
16744
16897
  const suffix = input.unmatchedRows ? ` ${input.unmatchedRows} one-window rows were kept as unmatched evidence and not converted to zero.` : "";
@@ -16831,7 +16984,7 @@ function analyzeSegmentRows(input) {
16831
16984
  matchedRows: matched.length,
16832
16985
  returnedRows: selection.returnedRows,
16833
16986
  unmatchedRows: unmatched.length,
16834
- verdict: verdict$4({
16987
+ verdict: verdict$5({
16835
16988
  status: dataStatus,
16836
16989
  matchedRows: matched.length,
16837
16990
  returnedRows: selection.returnedRows,
@@ -17595,7 +17748,7 @@ const defaultDependencies$10 = {
17595
17748
  searchAnalytics: querySearchAnalytics,
17596
17749
  now: () => /* @__PURE__ */ new Date()
17597
17750
  };
17598
- function verdict$3(input) {
17751
+ function verdict$4(input) {
17599
17752
  if (input.status === "empty") return "Search Console returned no retained query/page rows for this window.";
17600
17753
  if (!input.eligible) {
17601
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.";
@@ -17722,7 +17875,7 @@ async function cannibalReport(input, dependencies = defaultDependencies$10) {
17722
17875
  returnedClusters: analysis.selection.returnedClusters,
17723
17876
  suppressedQueries: analysis.selection.suppressedQueries,
17724
17877
  brandFiltering: input.includeBrand ? "included" : "excluded",
17725
- verdict: verdict$3({
17878
+ verdict: verdict$4({
17726
17879
  status: dataStatus,
17727
17880
  eligible: analysis.selection.eligibleClusters,
17728
17881
  returned: analysis.selection.returnedClusters
@@ -18008,7 +18161,7 @@ const defaultDependencies$9 = {
18008
18161
  searchAnalytics: querySearchAnalytics,
18009
18162
  now: () => /* @__PURE__ */ new Date()
18010
18163
  };
18011
- function verdict$2(report) {
18164
+ function verdict$3(report) {
18012
18165
  if (report.dataStatus === "empty") return "Search Console returned no retained query/page rows for either comparison window.";
18013
18166
  if (report.dataStatus === "partial" && report.selection.currentAggregatedRows === 0) return "The current retained-row window is empty, so no query/page losses were inferred.";
18014
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.";
@@ -18153,7 +18306,7 @@ async function decayingReport(input, dependencies = defaultDependencies$9) {
18153
18306
  observedRetainedQueryClickLoss: analysis.totals.eligibleObservedRetainedQueryClickLoss,
18154
18307
  returnedObservedRetainedQueryClickLoss: analysis.totals.returnedObservedRetainedQueryClickLoss,
18155
18308
  brandFiltering: input.includeBrand ? "included" : "excluded",
18156
- verdict: verdict$2(reportBase)
18309
+ verdict: verdict$3(reportBase)
18157
18310
  },
18158
18311
  caveats: [
18159
18312
  `Current window: ${currentRange.startDate} to ${currentRange.endDate}; comparison: ${previousRange.startDate} to ${previousRange.endDate}.`,
@@ -18947,7 +19100,7 @@ function templatesFromGroups(groups) {
18947
19100
  sampleUrls: group.sampleUrls
18948
19101
  }));
18949
19102
  }
18950
- function verdict$1(analysis) {
19103
+ function verdict$2(analysis) {
18951
19104
  if (analysis.dataStatus === "empty") return "Search Console returned no retained query/page rows for this date window.";
18952
19105
  if (analysis.dataStatus === "filtered") return "No retained query/page rows met the report filters.";
18953
19106
  return `${countLabel(analysis.summary.eligibleRows, "eligible query/page row")} found; ${countLabel(analysis.summary.returnedRows, "row")} returned for review.`;
@@ -19031,7 +19184,7 @@ async function strikingDistance(input, dependencies = defaultDependencies$7) {
19031
19184
  summary: {
19032
19185
  ...analysis.summary,
19033
19186
  brandFiltering: input.includeBrand ? "included" : "excluded",
19034
- verdict: verdict$1(analysis)
19187
+ verdict: verdict$2(analysis)
19035
19188
  },
19036
19189
  caveats: [
19037
19190
  `Date window: ${range.startDate} to ${range.endDate}, using final GSC data where available.`,
@@ -21745,7 +21898,7 @@ const MAX_LEDGER_ROWS = 5e4;
21745
21898
  const MAX_LEDGER_BYTES = 32 * 1024 * 1024;
21746
21899
  const MAX_TASK_IDS = 20;
21747
21900
  const MAX_ACTIVE_RESERVATIONS = 100;
21748
- function boundedText(provider, value, label, max) {
21901
+ function boundedText$1(provider, value, label, max) {
21749
21902
  const normalized = value.trim();
21750
21903
  if (!normalized || normalized.length > max) throw new ProviderError({
21751
21904
  provider,
@@ -21839,10 +21992,10 @@ function reserveProviderSpend(input, options = {}) {
21839
21992
  const now = options.now ?? Date.now();
21840
21993
  const normalized = {
21841
21994
  ...input,
21842
- endpoint: boundedText(input.provider, input.endpoint, "Provider endpoint", 500),
21843
- projectId: input.projectId ? boundedText(input.provider, input.projectId, "Project id", 200) : void 0,
21844
- reportId: boundedText(input.provider, input.reportId, "Report id", 100),
21845
- reportRunId: boundedText(input.provider, input.reportRunId, "Report run id", 100),
21995
+ endpoint: boundedText$1(input.provider, input.endpoint, "Provider endpoint", 500),
21996
+ projectId: input.projectId ? boundedText$1(input.provider, input.projectId, "Project id", 200) : void 0,
21997
+ reportId: boundedText$1(input.provider, input.reportId, "Report id", 100),
21998
+ reportRunId: boundedText$1(input.provider, input.reportRunId, "Report run id", 100),
21846
21999
  requestedRows: nonnegativeInteger(input.provider, input.requestedRows, "Requested rows"),
21847
22000
  estimatedCostMicros: nonnegativeInteger(input.provider, input.estimatedCostMicros, "Estimated cost")
21848
22001
  };
@@ -21879,7 +22032,7 @@ function reserveProviderSpend(input, options = {}) {
21879
22032
  function finalizeProviderSpend(reservationId, result, options = {}) {
21880
22033
  const database = options.database ?? getDb();
21881
22034
  const now = options.now ?? Date.now();
21882
- const taskIds = [...new Set(result.taskIds)].sort((left, right) => left < right ? -1 : left > right ? 1 : 0).slice(0, MAX_TASK_IDS).map((id) => boundedText(result.provider, id, "Provider task id", 100));
22035
+ const taskIds = [...new Set(result.taskIds)].sort((left, right) => left < right ? -1 : left > right ? 1 : 0).slice(0, MAX_TASK_IDS).map((id) => boundedText$1(result.provider, id, "Provider task id", 100));
21883
22036
  const actualCostMicros = result.actualCostMicros === null ? null : nonnegativeInteger(result.provider, result.actualCostMicros, "Actual cost");
21884
22037
  const returnedRows = result.returnedRows === null ? null : nonnegativeInteger(result.provider, result.returnedRows, "Returned rows");
21885
22038
  return database.transaction(() => {
@@ -22131,7 +22284,10 @@ const userDataPriceSchema = z.object({
22131
22284
  keyword_suggestions: livePriceSchema.optional(),
22132
22285
  related_keywords: livePriceSchema.optional()
22133
22286
  }).passthrough().optional(),
22134
- 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()
22135
22291
  }).passthrough();
22136
22292
  const userDataResultSchema = z.object({
22137
22293
  login: z.string().min(1).max(320),
@@ -22163,7 +22319,7 @@ const dataForSeoUserDataResponseSchema = z.object({
22163
22319
  const MAX_PROVIDER_MONTHLY_SEARCH_ROWS = 120;
22164
22320
  const nullableNumber = z.number().finite().nullable().optional();
22165
22321
  const nullableCount = z.union([z.number().finite(), z.string().trim().min(1)]).nullable().optional();
22166
- const monthlySearchSchema = z.object({
22322
+ const monthlySearchSchema$1 = z.object({
22167
22323
  year: z.number().int(),
22168
22324
  month: z.number().int(),
22169
22325
  search_volume: z.number().finite().nullable().optional()
@@ -22174,7 +22330,7 @@ const keywordOverviewItemSchema = z.object({
22174
22330
  search_volume: nullableNumber,
22175
22331
  cpc: nullableNumber,
22176
22332
  competition: nullableNumber,
22177
- monthly_searches: z.array(monthlySearchSchema).max(MAX_PROVIDER_MONTHLY_SEARCH_ROWS).nullish(),
22333
+ monthly_searches: z.array(monthlySearchSchema$1).max(MAX_PROVIDER_MONTHLY_SEARCH_ROWS).nullish(),
22178
22334
  last_updated_time: z.string().trim().min(1).nullable().optional()
22179
22335
  }).nullish(),
22180
22336
  keyword_properties: z.object({ keyword_difficulty: nullableNumber }).nullish(),
@@ -22188,7 +22344,7 @@ const keywordOverviewItemSchema = z.object({
22188
22344
  }).passthrough();
22189
22345
  const keywordOverviewResultSchema = z.object({
22190
22346
  items_count: z.number().int().nonnegative().optional(),
22191
- items: z.array(keywordOverviewItemSchema).optional()
22347
+ items: z.array(keywordOverviewItemSchema).nullable().optional()
22192
22348
  }).passthrough();
22193
22349
  const keywordOverviewTaskSchema = z.object({
22194
22350
  id: z.string().trim().min(1).optional(),
@@ -22249,19 +22405,19 @@ const dataForSeoDiscoveryResponseSchema = z.object({
22249
22405
  }).passthrough();
22250
22406
  //#endregion
22251
22407
  //#region packages/core/dist/providers/dataforseo/paid-request.js
22252
- function usdToMicros$1(value) {
22408
+ function usdToMicros(value) {
22253
22409
  if (value === void 0) return null;
22254
22410
  return Math.round(value * 1e6);
22255
22411
  }
22256
22412
  function responseTaskIds$1(response) {
22257
22413
  return [...new Set(response.tasks.flatMap((task) => task.id ? [task.id] : []))].sort((left, right) => left < right ? -1 : left > right ? 1 : 0);
22258
22414
  }
22259
- function responseCostMicros$1(response) {
22260
- if (response.cost !== void 0) return usdToMicros$1(response.cost);
22415
+ function responseCostMicros(response) {
22416
+ if (response.cost !== void 0) return usdToMicros(response.cost);
22261
22417
  if (response.tasks.some((task) => task.cost === void 0)) return null;
22262
- 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);
22263
22419
  }
22264
- function taskErrorCode$1(statusCode) {
22420
+ function taskErrorCode(statusCode) {
22265
22421
  if (statusCode >= 40100 && statusCode < 40200) return "authentication";
22266
22422
  if (statusCode === 40202) return "rate-limit";
22267
22423
  return "remote-error";
@@ -22351,7 +22507,7 @@ async function dataForSeoPaidPost(input) {
22351
22507
  });
22352
22508
  throw error;
22353
22509
  }
22354
- const actualCostMicros = responseCostMicros$1(response);
22510
+ const actualCostMicros = responseCostMicros(response);
22355
22511
  const returnedRows = input.rowCount(response);
22356
22512
  const taskIds = responseTaskIds$1(response);
22357
22513
  const failedTasks = response.tasks.filter((task) => task.status_code !== 2e4);
@@ -22373,7 +22529,7 @@ async function dataForSeoPaidPost(input) {
22373
22529
  throw new ProviderError({
22374
22530
  provider: "dataforseo",
22375
22531
  operation: input.operation,
22376
- code: taskErrorCode$1(statusCode),
22532
+ code: taskErrorCode(statusCode),
22377
22533
  message: `DataForSEO could not complete ${input.operation} (${statusCode}).`,
22378
22534
  retryable: statusCode === 40202
22379
22535
  });
@@ -22416,6 +22572,22 @@ async function dataForSeoPaidPost(input) {
22416
22572
  };
22417
22573
  }
22418
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
22419
22591
  //#region packages/core/dist/providers/dataforseo/serp-schema.js
22420
22592
  const serpItemSchema = z.object({
22421
22593
  type: z.string().trim().min(1).max(100),
@@ -22460,6 +22632,237 @@ const dataForSeoSerpResponseSchema = z.object({
22460
22632
  tasks: z.array(serpTaskSchema).max(10)
22461
22633
  }).passthrough();
22462
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
22463
22866
  //#region packages/core/dist/providers/dataforseo/client.js
22464
22867
  const DEFAULT_BASE_URL$1 = "https://api.dataforseo.com/";
22465
22868
  const USER_DATA_PATH = "v3/appendix/user_data";
@@ -22482,9 +22885,43 @@ const MAX_KEYWORD_WORDS = 10;
22482
22885
  const MAX_DISCOVERY_SEEDS$1 = 5;
22483
22886
  const MAX_DISCOVERY_ROWS$1 = 100;
22484
22887
  const MAX_SERP_DEPTH$1 = 100;
22485
- function usdToMicros(value) {
22486
- if (value === void 0) return null;
22487
- 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
+ };
22488
22925
  }
22489
22926
  function unitPrice(components) {
22490
22927
  const prices = components ?? [];
@@ -22510,19 +22947,12 @@ function keywordDiscoveryPrices(account) {
22510
22947
  function serpLiveAdvancedPrice(account) {
22511
22948
  return unitPrice(account.price?.serp?.live?.advanced?.priority_normal);
22512
22949
  }
22513
- function taskErrorCode(statusCode) {
22514
- if (statusCode >= 40100 && statusCode < 40200) return "authentication";
22515
- if (statusCode === 40202) return "rate-limit";
22516
- return "remote-error";
22950
+ function serpTaskPostPrice(account) {
22951
+ return unitPrice(account.price?.serp?.task_post?.priority_normal);
22517
22952
  }
22518
22953
  function responseTaskIds(response) {
22519
22954
  return [...new Set(response.tasks.flatMap((task) => task.id ? [task.id] : []))].sort((left, right) => left < right ? -1 : left > right ? 1 : 0);
22520
22955
  }
22521
- function responseCostMicros(response) {
22522
- if (response.cost !== void 0) return usdToMicros(response.cost);
22523
- if (response.tasks.some((task) => task.cost === void 0)) return null;
22524
- return response.tasks.reduce((sum, task) => sum + (usdToMicros(task.cost) ?? 0), 0);
22525
- }
22526
22956
  function responseRows$1(response) {
22527
22957
  return response.tasks.reduce((taskTotal, task) => taskTotal + (task.result ?? []).reduce((resultTotal, result) => resultTotal + (result.items?.length ?? 0), 0), 0);
22528
22958
  }
@@ -22537,9 +22967,6 @@ function discoveryNextCursor(response) {
22537
22967
  const cursors = response.tasks.flatMap((task) => (task.result ?? []).flatMap((result) => result.offset_token ? [result.offset_token] : []));
22538
22968
  return cursors.length === 1 ? cursors[0] ?? null : null;
22539
22969
  }
22540
- function serpRows(response) {
22541
- return response.tasks.reduce((taskTotal, task) => taskTotal + (task.result ?? []).reduce((resultTotal, result) => resultTotal + (result.items?.length ?? 0), 0), 0);
22542
- }
22543
22970
  var DataForSeoClient = class {
22544
22971
  fetch;
22545
22972
  credentials;
@@ -22582,6 +23009,18 @@ var DataForSeoClient = class {
22582
23009
  authorization(credentials) {
22583
23010
  return `Basic ${Buffer.from(`${credentials.login}:${credentials.password}`).toString("base64")}`;
22584
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
+ }
22585
23024
  async userDataForCredentials(credentials) {
22586
23025
  let response;
22587
23026
  try {
@@ -22616,7 +23055,7 @@ var DataForSeoClient = class {
22616
23055
  throw new ProviderError({
22617
23056
  provider: "dataforseo",
22618
23057
  operation: "account-status",
22619
- code: taskErrorCode(statusCode),
23058
+ code: dataForSeoTaskErrorCode(statusCode),
22620
23059
  message: `DataForSEO could not return account status (${statusCode}).`,
22621
23060
  retryable: statusCode === 40202
22622
23061
  });
@@ -22632,18 +23071,19 @@ var DataForSeoClient = class {
22632
23071
  provider: "dataforseo",
22633
23072
  login: account.login,
22634
23073
  timezone: account.timezone ?? null,
22635
- balanceMicros: usdToMicros(account.money?.balance),
22636
- depositedMicros: usdToMicros(account.money?.total),
22637
- 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),
22638
23077
  accountDailySpendPeriod: account.money?.statistics?.day?.value ?? null,
22639
- accountDailyLimitMicros: usdToMicros(account.money?.limits?.day?.total),
23078
+ accountDailyLimitMicros: dataForSeoUsdToMicros(account.money?.limits?.day?.total),
22640
23079
  keywordOverviewPrice: keywordOverviewPrice(account),
22641
23080
  keywordDiscoveryPrices: keywordDiscoveryPrices(account),
22642
23081
  serpLiveAdvancedPrice: serpLiveAdvancedPrice(account),
23082
+ serpTaskPostPrice: serpTaskPostPrice(account),
22643
23083
  backlinksSubscriptionExpiresAt: account.backlinks_subscription_expiry_date ?? null,
22644
23084
  aiMentionsSubscriptionExpiresAt: account.llm_mentions_subscription_expiry_date ?? null,
22645
23085
  apiVersion: response.version ?? null,
22646
- requestCostMicros: usdToMicros(response.cost) ?? 0,
23086
+ requestCostMicros: dataForSeoUsdToMicros(response.cost) ?? 0,
22647
23087
  taskIds: response.tasks.flatMap((task) => task.id ? [task.id] : []),
22648
23088
  observedAt: this.now().toISOString()
22649
23089
  };
@@ -22819,7 +23259,7 @@ var DataForSeoClient = class {
22819
23259
  });
22820
23260
  throw error;
22821
23261
  }
22822
- const actualCostMicros = responseCostMicros(response);
23262
+ const actualCostMicros = dataForSeoResponseCostMicros(response);
22823
23263
  const returnedRows = responseRows$1(response);
22824
23264
  const taskIds = responseTaskIds(response);
22825
23265
  const failedTasks = response.tasks.filter((task) => task.status_code !== 2e4);
@@ -22841,7 +23281,7 @@ var DataForSeoClient = class {
22841
23281
  throw new ProviderError({
22842
23282
  provider: "dataforseo",
22843
23283
  operation: "keyword-metrics",
22844
- code: taskErrorCode(statusCode),
23284
+ code: dataForSeoTaskErrorCode(statusCode),
22845
23285
  message: `DataForSEO could not complete the keyword metrics task (${statusCode}).`,
22846
23286
  retryable: statusCode === 40202
22847
23287
  });
@@ -22953,38 +23393,7 @@ var DataForSeoClient = class {
22953
23393
  };
22954
23394
  }
22955
23395
  async serpLive(input) {
22956
- const keyword = input.keyword.trim();
22957
- if (keyword.length < 1 || keyword.length > MAX_KEYWORD_CHARACTERS || keyword.split(/\s+/u).length > MAX_KEYWORD_WORDS) throw new ProviderError({
22958
- provider: "dataforseo",
22959
- operation: "serp-snapshot",
22960
- code: "configuration",
22961
- message: `SERP snapshots require a keyword of at most ${MAX_KEYWORD_CHARACTERS} characters and ${MAX_KEYWORD_WORDS} words.`
22962
- });
22963
- if (/(?:^|\s)(?:allinanchor|allintext|allintitle|allinurl|cache|define|definition|filetype|id|inanchor|info|intext|intitle|inurl|link|site):/iu.test(keyword)) throw new ProviderError({
22964
- provider: "dataforseo",
22965
- operation: "serp-snapshot",
22966
- code: "configuration",
22967
- message: "SERP snapshot keywords cannot contain search operators with multiplied provider pricing."
22968
- });
22969
- if (!Number.isSafeInteger(input.depth) || input.depth < 1 || input.depth > MAX_SERP_DEPTH$1) throw new ProviderError({
22970
- provider: "dataforseo",
22971
- operation: "serp-snapshot",
22972
- code: "configuration",
22973
- message: `SERP depth must be from 1 to ${MAX_SERP_DEPTH$1}.`
22974
- });
22975
- if (!/^[a-z]{2}$/.test(input.languageCode)) throw new ProviderError({
22976
- provider: "dataforseo",
22977
- operation: "serp-snapshot",
22978
- code: "configuration",
22979
- message: "DataForSEO language code must contain two lowercase letters."
22980
- });
22981
- const locationName = input.locationName?.trim();
22982
- if (input.locationCode !== void 0 === Boolean(locationName)) throw new ProviderError({
22983
- provider: "dataforseo",
22984
- operation: "serp-snapshot",
22985
- code: "configuration",
22986
- message: "DataForSEO SERP snapshots require exactly one location code or location name."
22987
- });
23396
+ const { keyword, locationName } = validateSerpInput(input);
22988
23397
  const request = {
22989
23398
  keyword,
22990
23399
  language_code: input.languageCode,
@@ -23005,9 +23414,56 @@ var DataForSeoClient = class {
23005
23414
  context: input.context,
23006
23415
  ttlMs: this.serpTtlMs,
23007
23416
  refresh: input.refresh,
23008
- rowCount: serpRows
23417
+ rowCount: dataForSeoSerpRows
23009
23418
  });
23010
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
23456
+ });
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
+ }
23011
23467
  };
23012
23468
  //#endregion
23013
23469
  //#region packages/core/dist/providers/dataforseo/keyword-mapping.js
@@ -23139,13 +23595,55 @@ var DataForSeoKeywordMetricsProvider = class {
23139
23595
  status: "available",
23140
23596
  markets: [{
23141
23597
  searchEngines: ["google"],
23142
- location: "any"
23598
+ location: "country-only"
23143
23599
  }]
23144
23600
  }];
23145
23601
  client;
23146
23602
  constructor(options = {}) {
23147
23603
  this.client = options.client ?? new DataForSeoClient(options);
23148
23604
  }
23605
+ async estimateKeywordMetricsCost(input) {
23606
+ if (!Number.isSafeInteger(input.requestedRows) || input.requestedRows < 1 || input.requestedRows > 1e4) throw new ProviderError({
23607
+ provider: "dataforseo",
23608
+ operation: "keyword-metrics-cost",
23609
+ code: "configuration",
23610
+ message: "Keyword metric cost estimates require 1 to 10000 rows."
23611
+ });
23612
+ if (!searchMarketSchema.safeParse(input.market).success) throw new ProviderError({
23613
+ provider: "dataforseo",
23614
+ operation: "keyword-metrics-cost",
23615
+ code: "configuration",
23616
+ message: "Keyword metric cost estimates require a valid market."
23617
+ });
23618
+ if (input.market.location) throw new ProviderError({
23619
+ provider: "dataforseo",
23620
+ operation: "keyword-metrics-cost",
23621
+ code: "configuration",
23622
+ message: "DataForSEO Labs keyword metrics supports country-level markets; omit market.location and use countryCode."
23623
+ });
23624
+ if (!this.client.userData) throw new ProviderError({
23625
+ provider: "dataforseo",
23626
+ operation: "keyword-metrics-cost",
23627
+ code: "configuration",
23628
+ message: "Keyword metric cost estimation is unavailable."
23629
+ });
23630
+ const account = await this.client.userData();
23631
+ const { perRequestMicros, perResultMicros } = account.keywordOverviewPrice;
23632
+ const requestCount = Math.ceil(input.requestedRows / 50);
23633
+ const complete = perRequestMicros !== null && perResultMicros !== null;
23634
+ return {
23635
+ schemaVersion: 1,
23636
+ provider: "dataforseo",
23637
+ capability: "keyword-metrics",
23638
+ currency: "USD",
23639
+ requestedRows: input.requestedRows,
23640
+ requestCount,
23641
+ estimatedMicros: complete ? requestCount * perRequestMicros + input.requestedRows * perResultMicros : null,
23642
+ observedAt: account.observedAt,
23643
+ completeness: complete ? "complete" : "unavailable",
23644
+ basis: complete ? "Current account pricing for normal-priority live keyword overview requests." : "The provider account response did not include complete keyword overview pricing."
23645
+ };
23646
+ }
23149
23647
  async keywordMetrics(input) {
23150
23648
  const market = searchMarketSchema.parse(input.market);
23151
23649
  if (market.searchEngine !== "google") throw new ProviderError({
@@ -23154,6 +23652,12 @@ var DataForSeoKeywordMetricsProvider = class {
23154
23652
  code: "configuration",
23155
23653
  message: "DataForSEO keyword metrics currently supports Google markets."
23156
23654
  });
23655
+ if (market.location) throw new ProviderError({
23656
+ provider: "dataforseo",
23657
+ operation: "keyword-metrics",
23658
+ code: "configuration",
23659
+ message: "DataForSEO Labs keyword metrics supports country-level markets; omit market.location and use countryCode."
23660
+ });
23157
23661
  if (input.keywords.length < 1 || input.keywords.length > MAX_KEYWORDS_PER_REPORT || input.keywords.some((keyword) => {
23158
23662
  const normalized = keyword.trim();
23159
23663
  return normalized.length < 1 || normalized.length > 80 || normalized.split(/\s+/u).length > 10;
@@ -23369,6 +23873,7 @@ async function defaultCandidates$2() {
23369
23873
  }
23370
23874
  function providerResolutionError(input) {
23371
23875
  if (input.reason === "provider-not-connected") return new SeoError("PROVIDER_UNAVAILABLE", "No connected provider can supply keyword metrics. Run `seo providers dataforseo connect` first.");
23876
+ if (input.provider && input.reason === "market-not-supported") return new SeoError("INVALID_INPUT", input.provider === "dataforseo" ? "DataForSEO keyword metrics uses country-level Labs data. Omit location and keep countryCode, then run serp-results separately for a local snapshot." : `${input.provider} cannot supply keyword metrics for this market.`);
23372
23877
  if (input.provider) return new SeoError("PROVIDER_UNAVAILABLE", `${input.provider} cannot supply keyword metrics for this market.`);
23373
23878
  return new SeoError("PROVIDER_UNAVAILABLE", "No configured provider can supply keyword metrics for this market.");
23374
23879
  }
@@ -24242,7 +24747,7 @@ function pageItem(url, rows) {
24242
24747
  recommendation: nextRecommendation
24243
24748
  };
24244
24749
  }
24245
- function compareItems(left, right) {
24750
+ function compareItems$1(left, right) {
24246
24751
  return right.priority.score - left.priority.score || right.impressions - left.impressions || left.position - right.position || compareText$4(left.url, right.url);
24247
24752
  }
24248
24753
  function analyzeSecondPageRows(input) {
@@ -24287,7 +24792,7 @@ function analyzeSecondPageRows(input) {
24287
24792
  if (item.impressions >= minImpressions) return true;
24288
24793
  selection.belowMinimumPages++;
24289
24794
  return false;
24290
- }).sort(compareItems);
24795
+ }).sort(compareItems$1);
24291
24796
  const items = eligibleItems.slice(0, limit);
24292
24797
  selection.eligiblePages = eligibleItems.length;
24293
24798
  selection.returnedPages = items.length;
@@ -24970,7 +25475,7 @@ var DataForSeoKeywordDiscoveryProvider = class {
24970
25475
  status: "available",
24971
25476
  markets: [{
24972
25477
  searchEngines: ["google"],
24973
- location: "any"
25478
+ location: "country-only"
24974
25479
  }]
24975
25480
  }];
24976
25481
  client;
@@ -24985,6 +25490,12 @@ var DataForSeoKeywordDiscoveryProvider = class {
24985
25490
  code: "configuration",
24986
25491
  message: "DataForSEO keyword discovery currently supports Google."
24987
25492
  });
25493
+ if (market.location) throw new ProviderError({
25494
+ provider: "dataforseo",
25495
+ operation: "keyword-discovery",
25496
+ code: "configuration",
25497
+ message: "DataForSEO Labs keyword discovery supports country-level markets; omit market.location and use countryCode."
25498
+ });
24988
25499
  const seeds = [...new Set(input.seeds.map(normalizedKeyword$1))].filter(Boolean).sort(compareCodepoints$3);
24989
25500
  if (seeds.length < 1 || seeds.length > MAX_DISCOVERY_SEEDS || seeds.some((seed) => seed.length > 80 || seed.split(/\s+/u).length > 10)) throw new ProviderError({
24990
25501
  provider: "dataforseo",
@@ -25107,7 +25618,7 @@ async function defaultCandidates$1() {
25107
25618
  priority: 10
25108
25619
  }];
25109
25620
  }
25110
- function providerError$1(error) {
25621
+ function providerError$2(error) {
25111
25622
  if (!(error instanceof ProviderError)) throw error;
25112
25623
  if (error.code === "rate-limit") throw new SeoError("RATE_LIMITED", error.message);
25113
25624
  if (error.code === "configuration") throw new SeoError("INVALID_INPUT", error.message);
@@ -25175,7 +25686,7 @@ async function keywordResearchReport(input, dependencies = {}) {
25175
25686
  try {
25176
25687
  candidates = dependencies.candidates ?? await defaultCandidates$1();
25177
25688
  } catch (error) {
25178
- return providerError$1(error);
25689
+ return providerError$2(error);
25179
25690
  }
25180
25691
  const resolution = resolveProvider({
25181
25692
  capability: "keyword-discovery",
@@ -25183,7 +25694,10 @@ async function keywordResearchReport(input, dependencies = {}) {
25183
25694
  candidates,
25184
25695
  provider: validated.provider
25185
25696
  });
25186
- if (resolution.status === "unavailable") throw new SeoError("PROVIDER_UNAVAILABLE", resolution.reason === "provider-not-connected" ? "No connected provider can discover keywords. Run `seo providers dataforseo connect` first." : validated.provider ? `${validated.provider} cannot discover keywords for this market.` : "No configured provider can discover keywords for this market.");
25697
+ if (resolution.status === "unavailable") {
25698
+ const message = resolution.reason === "provider-not-connected" ? "No connected provider can discover keywords. Run `seo providers dataforseo connect` first." : validated.provider ? `${validated.provider} cannot discover keywords for this market.` : "No configured provider can discover keywords for this market.";
25699
+ throw new SeoError(validated.provider && resolution.reason === "market-not-supported" ? "INVALID_INPUT" : "PROVIDER_UNAVAILABLE", validated.provider === "dataforseo" && resolution.reason === "market-not-supported" ? "DataForSEO keyword discovery uses country-level Labs data. Omit location and keep countryCode, then run serp-results separately for a local snapshot." : message);
25700
+ }
25187
25701
  const provider = discoveryProvider(resolution.provider);
25188
25702
  if (!provider) throw new SeoError("PROVIDER_UNAVAILABLE", "The selected provider has no keyword discovery implementation.");
25189
25703
  let evidence;
@@ -25201,7 +25715,7 @@ async function keywordResearchReport(input, dependencies = {}) {
25201
25715
  }
25202
25716
  });
25203
25717
  } catch (error) {
25204
- return providerError$1(error);
25718
+ return providerError$2(error);
25205
25719
  }
25206
25720
  const analysis = evidence.data.map((idea) => ({
25207
25721
  keyword: idea.keyword,
@@ -29747,6 +30261,96 @@ function checkedAt(value, fallback) {
29747
30261
  const timestamp = Date.parse(value);
29748
30262
  return Number.isFinite(timestamp) ? new Date(timestamp).toISOString() : fallback;
29749
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
+ }
29750
30354
  var DataForSeoSerpSnapshotProvider = class {
29751
30355
  provider = "dataforseo";
29752
30356
  capabilitySupport = [{
@@ -29788,7 +30392,7 @@ var DataForSeoSerpSnapshotProvider = class {
29788
30392
  reportRunId: randomUUID()
29789
30393
  };
29790
30394
  const location = locationRequest(market, "serp-snapshot");
29791
- const snapshot = await this.client.serpLive({
30395
+ return mapDataForSeoSerpSnapshot(input, await this.client.serpLive({
29792
30396
  keyword,
29793
30397
  languageCode: market.languageCode.split("-")[0],
29794
30398
  ...location,
@@ -29796,86 +30400,7 @@ var DataForSeoSerpSnapshotProvider = class {
29796
30400
  depth: input.depth,
29797
30401
  refresh: input.refresh,
29798
30402
  context
29799
- });
29800
- const result = snapshot.response.tasks.flatMap((task) => task.result ?? []).find((item) => normalizedKeyword$1(item.keyword) === keyword);
29801
- if (!result) throw new ProviderError({
29802
- provider: "dataforseo",
29803
- operation: "serp-snapshot",
29804
- code: "invalid-response",
29805
- message: "DataForSEO returned no matching SERP result."
29806
- });
29807
- const warnings = [...snapshot.warnings];
29808
- const items = result.items ?? [];
29809
- const organicItems = items.filter((item) => item.type === "organic");
29810
- const mappedOrganicResults = organicItems.flatMap((item) => {
29811
- const mapped = organicResult(item);
29812
- return mapped ? [mapped] : [];
29813
- }).sort((left, right) => left.rankAbsolute - right.rankAbsolute || left.rankGroup - right.rankGroup || compareCodepoints$3(left.url, right.url));
29814
- const organicResults = mappedOrganicResults.slice(0, input.depth);
29815
- const invalidRows = organicItems.length - mappedOrganicResults.length;
29816
- const resultCapped = mappedOrganicResults.length > input.depth;
29817
- if (invalidRows > 0) warnings.push({
29818
- code: "invalid-organic-results",
29819
- field: "organicResults",
29820
- message: `DataForSEO returned ${invalidRows} organic result${invalidRows === 1 ? "" : "s"} without a valid rank, domain, or URL.`
29821
- });
29822
- if (!Number.isFinite(Date.parse(result.datetime))) warnings.push({
29823
- code: "invalid-serp-observation-time",
29824
- field: "checkedAt",
29825
- message: "DataForSEO returned an invalid SERP observation time; the local response time was retained."
29826
- });
29827
- const checkUrl = safeUrl(result.check_url);
29828
- if (result.check_url && !checkUrl) warnings.push({
29829
- code: "invalid-serp-check-url",
29830
- field: "checkUrl",
29831
- message: "DataForSEO returned an invalid SERP check URL."
29832
- });
29833
- const features = [.../* @__PURE__ */ new Set([...result.item_types ?? [], ...items.map((item) => item.type)])].sort(compareCodepoints$3);
29834
- return {
29835
- schemaVersion: 1,
29836
- provider: "dataforseo",
29837
- capability: "serp-snapshot",
29838
- data: {
29839
- keyword,
29840
- effectiveKeyword: normalizedKeyword$1(result.spell?.keyword ?? result.keyword),
29841
- searchEngineDomain: result.se_domain?.trim().toLowerCase() ?? null,
29842
- checkedAt: checkedAt(result.datetime, snapshot.observedAt),
29843
- checkUrl,
29844
- resultCount: result.se_results_count === void 0 ? null : result.se_results_count,
29845
- pagesCount: result.pages_count === void 0 ? null : result.pages_count,
29846
- features,
29847
- organicResults
29848
- },
29849
- observedAt: snapshot.observedAt,
29850
- market: {
29851
- ...market,
29852
- device: market.device ?? "desktop"
29853
- },
29854
- coverage: {
29855
- requestedRows: input.depth,
29856
- returnedRows: snapshot.returnedRows,
29857
- retainedRows: organicResults.length,
29858
- invalidRows,
29859
- providerTotalRows: result.se_results_count ?? null,
29860
- completeness: invalidRows > 0 ? "partial" : resultCapped ? "capped" : "complete",
29861
- nextCursor: null
29862
- },
29863
- cache: snapshot.cache,
29864
- cost: snapshot.cost,
29865
- request: {
29866
- operation: "serp-snapshot",
29867
- endpoint: SERP_ENDPOINT,
29868
- limit: input.depth,
29869
- filters: {
29870
- countryCode: market.countryCode,
29871
- languageCode: market.languageCode,
29872
- device: market.device ?? "desktop",
29873
- ...location.locationCode !== void 0 ? { locationCode: location.locationCode } : { locationName: location.locationName }
29874
- },
29875
- sort: ["rankAbsolute:ascending", "url:codepoint-ascending"]
29876
- },
29877
- warnings
29878
- };
30403
+ }));
29879
30404
  }
29880
30405
  };
29881
30406
  //#endregion
@@ -29894,7 +30419,7 @@ async function defaultCandidates() {
29894
30419
  priority: 10
29895
30420
  }];
29896
30421
  }
29897
- function providerError(error) {
30422
+ function providerError$1(error) {
29898
30423
  if (!(error instanceof ProviderError)) throw error;
29899
30424
  if (error.code === "rate-limit") throw new SeoError("RATE_LIMITED", error.message);
29900
30425
  if (error.code === "configuration") throw new SeoError("INVALID_INPUT", error.message);
@@ -29956,7 +30481,7 @@ async function serpResultsReport(input, dependencies = {}) {
29956
30481
  try {
29957
30482
  candidates = dependencies.candidates ?? await defaultCandidates();
29958
30483
  } catch (error) {
29959
- return providerError(error);
30484
+ return providerError$1(error);
29960
30485
  }
29961
30486
  const resolution = resolveProvider({
29962
30487
  capability: "serp-snapshot",
@@ -29981,7 +30506,7 @@ async function serpResultsReport(input, dependencies = {}) {
29981
30506
  }
29982
30507
  });
29983
30508
  } catch (error) {
29984
- return providerError(error);
30509
+ return providerError$1(error);
29985
30510
  }
29986
30511
  const domains = domainSummaries(evidence.data);
29987
30512
  const dataStatus = reportStatus$1(evidence);
@@ -30231,6 +30756,1110 @@ async function pseoOpportunitiesReport(input, dependencies = {}) {
30231
30756
  };
30232
30757
  }
30233
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
30234
31863
  //#region packages/core/dist/analyze/reports/dates.js
30235
31864
  function rangeDays(range) {
30236
31865
  const start = Date.parse(`${range.startDate}T00:00:00Z`);
@@ -30696,6 +32325,115 @@ function reportPresentation(report) {
30696
32325
  };
30697
32326
  }
30698
32327
  //#endregion
32328
+ //#region packages/core/dist/analyze/saved-keywords.js
32329
+ const DEFAULT_STALE_DAYS = 45;
32330
+ function savedKeywordSetReport(input, dependencies = {}) {
32331
+ const staleDays = input.staleDays ?? DEFAULT_STALE_DAYS;
32332
+ if (!Number.isSafeInteger(staleDays) || staleDays < 1 || staleDays > 365) throw new SeoError("INVALID_INPUT", "Metric freshness must be from 1 to 365 days.");
32333
+ const now = (dependencies.now ?? (() => /* @__PURE__ */ new Date()))();
32334
+ const evidence = (dependencies.getKeywordSet ?? getKeywordSet)(input);
32335
+ const snapshots = evidence.items.filter((item) => item.latestMetric);
32336
+ const observedVolumes = snapshots.filter((item) => item.latestMetric?.metric.monthlySearchVolume.state === "observed");
32337
+ const staleCutoff = now.getTime() - staleDays * 864e5;
32338
+ const staleSnapshots = snapshots.filter((item) => Date.parse(item.latestMetric?.observedAt ?? "") < staleCutoff);
32339
+ const mapped = evidence.items.filter((item) => item.page);
32340
+ const tagGroups = groupedCounts(evidence.items.flatMap((item) => item.tags)).map(([tag, keywordCount]) => ({
32341
+ tag,
32342
+ keywordCount
32343
+ }));
32344
+ const pageMappings = groupedMappings(evidence);
32345
+ const missingMetrics = evidence.items.length - snapshots.length;
32346
+ const unavailableVolumeSnapshots = snapshots.length - observedVolumes.length;
32347
+ const unmapped = evidence.items.length - mapped.length;
32348
+ const partial = evidence.pagination.nextOffset !== null || evidence.pagination.offset > 0 || evidence.pagination.total !== evidence.set.keywordCount;
32349
+ const dataStatus = evidence.items.length === 0 ? "unavailable" : partial ? "partial" : "complete";
32350
+ return {
32351
+ schemaVersion: 1,
32352
+ generatedAt: now.toISOString(),
32353
+ dataStatus,
32354
+ summary: {
32355
+ name: evidence.set.name,
32356
+ totalKeywords: evidence.set.keywordCount,
32357
+ matchedKeywords: evidence.pagination.total,
32358
+ returnedKeywords: evidence.items.length,
32359
+ metricSnapshots: snapshots.length,
32360
+ observedVolumes: observedVolumes.length,
32361
+ observedZeroVolumes: observedVolumes.filter((item) => item.latestMetric?.metric.monthlySearchVolume.value === 0).length,
32362
+ unavailableVolumeSnapshots,
32363
+ staleMetricSnapshots: staleSnapshots.length,
32364
+ mappedKeywords: mapped.length,
32365
+ targetPages: pageMappings.filter((item) => item.kind === "target").length,
32366
+ proposedPages: pageMappings.filter((item) => item.kind === "proposed").length,
32367
+ distinctTags: tagGroups.length,
32368
+ verdict: `${evidence.items.length} saved keywords were returned; search volume is observed for ${observedVolumes.length}, unavailable in ${unavailableVolumeSnapshots} saved snapshot${unavailableVolumeSnapshots === 1 ? "" : "s"}, and ${mapped.length} have page mappings.`
32369
+ },
32370
+ evidence,
32371
+ analysis: {
32372
+ metricFreshnessDays: staleDays,
32373
+ tagGroups,
32374
+ pageMappings
32375
+ },
32376
+ findings: [
32377
+ ...missingMetrics > 0 ? [{
32378
+ code: "missing-metrics",
32379
+ evidenceRef: "evidence.items[*].latestMetric",
32380
+ detail: `${missingMetrics} returned keywords have no saved provider metric snapshot.`,
32381
+ action: "Preview a keyword-set refresh before using provider metrics to prioritize them."
32382
+ }] : [],
32383
+ ...unavailableVolumeSnapshots > 0 ? [{
32384
+ code: "unavailable-volume-evidence",
32385
+ evidenceRef: "evidence.items[*].latestMetric.metric.monthlySearchVolume",
32386
+ detail: `${unavailableVolumeSnapshots} saved metric snapshot${unavailableVolumeSnapshots === 1 ? "" : "s"} ${unavailableVolumeSnapshots === 1 ? "has" : "have"} no observed search volume. This is not an observed zero.`,
32387
+ action: "Keep these terms separate from observed-zero terms and verify them with another source or a later refresh before using demand as a filter."
32388
+ }] : [],
32389
+ ...staleSnapshots.length > 0 ? [{
32390
+ code: "stale-metrics",
32391
+ evidenceRef: "evidence.items[*].latestMetric.observedAt",
32392
+ detail: `${staleSnapshots.length} saved metric snapshots are older than ${staleDays} days.`,
32393
+ action: "Preview and selectively refresh the stale provider evidence."
32394
+ }] : [],
32395
+ ...unmapped > 0 ? [{
32396
+ code: "unmapped-keywords",
32397
+ evidenceRef: "evidence.items[*].page",
32398
+ detail: `${unmapped} returned keywords have no existing or proposed page mapping.`,
32399
+ action: "Review shared intent and current results before mapping terms to an existing page or a proposed page."
32400
+ }] : []
32401
+ ],
32402
+ caveats: [
32403
+ "Tags are user-managed research labels, not proof that keywords share one intent or should use one page.",
32404
+ "Provider metrics are market estimates; missing evidence differs from an observed zero.",
32405
+ "Page mappings record planning state and do not prove that a page ranks, is indexed, or should be created.",
32406
+ ...partial ? ["This view is filtered or paginated and cannot support a complete-set all-clear."] : []
32407
+ ],
32408
+ nextSteps: [
32409
+ "Compare relevant saved terms with Search Console evidence for the project before changing priorities.",
32410
+ "Inspect current results in the same market before turning a tag group into a page or programmatic template.",
32411
+ "Use the refresh preview to check provider cost before updating saved metric snapshots."
32412
+ ]
32413
+ };
32414
+ }
32415
+ function groupedCounts(values) {
32416
+ const counts = /* @__PURE__ */ new Map();
32417
+ for (const value of values) counts.set(value, (counts.get(value) ?? 0) + 1);
32418
+ return [...counts].sort(([left, leftCount], [right, rightCount]) => rightCount - leftCount || (left < right ? -1 : left > right ? 1 : 0)).slice(0, 50);
32419
+ }
32420
+ function groupedMappings(evidence) {
32421
+ const counts = /* @__PURE__ */ new Map();
32422
+ for (const item of evidence.items) {
32423
+ if (!item.page) continue;
32424
+ const key = `${item.page.kind}\u0000${item.page.url}`;
32425
+ const current = counts.get(key);
32426
+ counts.set(key, {
32427
+ ...item.page,
32428
+ count: (current?.count ?? 0) + 1
32429
+ });
32430
+ }
32431
+ return [...counts.values()].sort((left, right) => right.count - left.count || (left.url < right.url ? -1 : left.url > right.url ? 1 : 0)).slice(0, 50).map(({ count, ...mapping }) => ({
32432
+ ...mapping,
32433
+ keywordCount: count
32434
+ }));
32435
+ }
32436
+ //#endregion
30699
32437
  //#region packages/core/dist/analyze/second-page.js
30700
32438
  const MAX_SOURCE_ROWS = 1e5;
30701
32439
  const defaultDependencies = {
@@ -34215,6 +35953,7 @@ const TELEMETRY_REPORTS = [
34215
35953
  "keyword-metrics",
34216
35954
  "keyword-opportunities",
34217
35955
  "keyword-research",
35956
+ "saved-keywords",
34218
35957
  "link-evidence",
34219
35958
  "server-log-analysis",
34220
35959
  "link-recovery",
@@ -34232,6 +35971,7 @@ const TELEMETRY_REPORTS = [
34232
35971
  "pseo-opportunities",
34233
35972
  "query-clusters",
34234
35973
  "quick-wins",
35974
+ "rank-tracking",
34235
35975
  "redirect-trace",
34236
35976
  "narrative-report",
34237
35977
  "second-page",
@@ -35979,7 +37719,63 @@ const keywordOpportunitiesInput = z$1.strictObject({
35979
37719
  message: "External keyword context requires countryCode and languageCode."
35980
37720
  });
35981
37721
  });
37722
+ const savedKeywordsInput = z$1.strictObject({
37723
+ projectId: z$1.string().trim().min(1).max(80),
37724
+ set: z$1.string().trim().min(1).max(80),
37725
+ tag: z$1.string().trim().min(1).max(40).optional(),
37726
+ limit: z$1.number().int().min(1).max(1e3).default(100),
37727
+ offset: z$1.number().int().min(0).max(1e5).default(0),
37728
+ staleDays: z$1.number().int().min(1).max(365).default(45)
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
+ });
35982
37749
  function registerKeywordTools(server, dependencies = {}) {
37750
+ server.registerTool("seo_saved_keywords", {
37751
+ description: "Review one bounded local keyword set with metric freshness, tags, page mappings, and evidence limits",
37752
+ inputSchema: savedKeywordsInput
37753
+ }, async ({ projectId, set, tag, limit, offset, staleDays }) => {
37754
+ try {
37755
+ const report = (dependencies.savedKeywordSetReport ?? savedKeywordSetReport)({
37756
+ projectId,
37757
+ idOrName: set,
37758
+ tag,
37759
+ limit,
37760
+ offset,
37761
+ staleDays
37762
+ });
37763
+ return toolSuccess(report.summary.verdict, compactAgentWorkflowOutput(report));
37764
+ } catch (error) {
37765
+ return toolError(error);
37766
+ }
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
+ });
35983
37779
  server.registerTool("seo_keyword_metrics", {
35984
37780
  description: "Compare bounded third-party keyword demand, cost, competition, difficulty, intent, result-count, and trend evidence",
35985
37781
  inputSchema: {
@@ -37998,6 +39794,39 @@ const REPORT_DEPTH = {
37998
39794
  }
37999
39795
  ]
38000
39796
  },
39797
+ "saved-keywords": {
39798
+ readOrder: [
39799
+ "dataStatus, summary, evidence pagination, and any tag filter",
39800
+ "the saved set market, source, refresh time, and keyword count",
39801
+ "each item metric value state, observation time, tags, and page mapping",
39802
+ "analysis groups, findings, caveats, and nextSteps"
39803
+ ],
39804
+ doNotClaim: [
39805
+ "User-managed tags do not prove shared intent or justify one page or template.",
39806
+ "Provider metrics are estimates rather than first-party rankings or traffic, and a missing value differs from an observed zero.",
39807
+ "A target or proposed page mapping does not prove that the page should exist, ranks, or is indexed.",
39808
+ "A filtered, capped, or paginated view cannot support a conclusion about the complete set."
39809
+ ],
39810
+ verify: "Compare relevant terms with Search Console and inspect current results in the saved market before changing a page or template plan.",
39811
+ related: [
39812
+ {
39813
+ id: "keyword-metrics",
39814
+ reason: "Refreshes a supplied term list."
39815
+ },
39816
+ {
39817
+ id: "keyword-research",
39818
+ reason: "Discovers more candidate terms."
39819
+ },
39820
+ {
39821
+ id: "keyword-opportunities",
39822
+ reason: "Adds first-party opportunity evidence."
39823
+ },
39824
+ {
39825
+ id: "pseo-opportunities",
39826
+ reason: "Reviews repeatable programmatic patterns."
39827
+ }
39828
+ ]
39829
+ },
38001
39830
  "query-clusters": {
38002
39831
  readOrder: [
38003
39832
  "summary cluster and query counts, clicks, impressions, threshold, limit, and brand filtering",
@@ -38196,6 +40025,40 @@ const REPORT_DEPTH = {
38196
40025
  }
38197
40026
  ]
38198
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
+ },
38199
40062
  "segment-impact": {
38200
40063
  readOrder: [
38201
40064
  "before, after, and rangeDays",
@@ -38783,6 +40646,13 @@ const REPORT_GUIDANCE = {
38783
40646
  avoidWhen: ["You need exact current rankings or pages in the live results.", "You plan to turn every discovered term into a page without checking intent or fit."],
38784
40647
  outcome: "A bounded keyword set with discovery sources, provider estimates, evidence states, and research follow-ups."
38785
40648
  },
40649
+ "saved-keywords": {
40650
+ name: "Saved keyword set",
40651
+ description: "Review one local keyword set with metric freshness, tags, page mappings, and explicit view limits.",
40652
+ useWhen: ["You want to continue earlier keyword research from a saved project set.", "You need to review stale, unmapped, or tagged terms before deeper research."],
40653
+ avoidWhen: ["You need independent keyword discovery or a current result snapshot.", "You plan to treat user-managed tags as proof of shared search intent."],
40654
+ outcome: "A bounded local research view with saved evidence, freshness state, planning mappings, and next steps."
40655
+ },
38786
40656
  "query-clusters": {
38787
40657
  name: "Search query clusters",
38788
40658
  description: "Group related Search Console queries so repeated demand themes are easier to review.",
@@ -38832,6 +40702,17 @@ const REPORT_GUIDANCE = {
38832
40702
  avoidWhen: ["You need rank history or scheduled tracking across many keywords.", "You want a domain strength, content quality, or ranking feasibility verdict."],
38833
40703
  outcome: "A bounded result snapshot with exact retained ranks, domain repetition, query corrections, features, cost, and coverage."
38834
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
+ },
38835
40716
  "segment-impact": {
38836
40717
  name: "Search impact by segment",
38837
40718
  description: "Compare page or query groups across equal periods to find which parts of a site moved.",
@@ -39338,6 +41219,8 @@ const reportGroups = [
39338
41219
  "seo_keyword_metrics",
39339
41220
  "seo_keyword_opportunities",
39340
41221
  "seo_keyword_research",
41222
+ "seo_rank_tracking",
41223
+ "seo_saved_keywords",
39341
41224
  "seo_serp_results"
39342
41225
  ]
39343
41226
  },