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