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