seo 0.2.15 → 0.2.17
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cli.js +114 -110
- package/dist/cli.js.map +1 -1
- package/dist/{dist-DIqNaMQf.js → dist-B36ZRY2E.js} +338 -77
- package/dist/dist-B36ZRY2E.js.map +1 -0
- package/dist/{dist-C56VwYuO.js → dist-B9Clu4T_.js} +1 -1
- package/dist/index.d.ts +637 -8
- package/dist/index.js +2335 -190
- package/dist/index.js.map +1 -1
- package/dist/mcp.js +2066 -183
- package/dist/mcp.js.map +1 -1
- package/dist/{package-Cuxp-LBy.js → package-C-Oa9Mgz.js} +2 -2
- package/dist/{package-Cuxp-LBy.js.map → package-C-Oa9Mgz.js.map} +1 -1
- package/evals/rank-tracking.json +40 -0
- package/evals/saved-keywords.json +28 -0
- package/package.json +2 -1
- package/skills/seo/SKILL.md +1 -0
- package/dist/dist-DIqNaMQf.js.map +0 -1
package/dist/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-C-Oa9Mgz.js";
|
|
3
3
|
import { Headers, fetch as fetch$1 } from "undici";
|
|
4
4
|
import { chmodSync, createReadStream, existsSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs";
|
|
5
5
|
import { arch, homedir, platform, totalmem } from "node:os";
|
|
@@ -787,6 +787,157 @@ async function createGoogleAccessTokenClient() {
|
|
|
787
787
|
};
|
|
788
788
|
}
|
|
789
789
|
//#endregion
|
|
790
|
+
//#region packages/core/dist/keyword-sets/schema.js
|
|
791
|
+
const KEYWORD_SET_SCHEMA_SQL = `
|
|
792
|
+
CREATE TABLE IF NOT EXISTS keyword_sets (
|
|
793
|
+
id TEXT PRIMARY KEY,
|
|
794
|
+
project_id TEXT NOT NULL,
|
|
795
|
+
name TEXT NOT NULL,
|
|
796
|
+
market_json TEXT NOT NULL,
|
|
797
|
+
provider TEXT CHECK(provider IS NULL OR provider IN ('dataforseo', 'semrush', 'ahrefs')),
|
|
798
|
+
source_report TEXT,
|
|
799
|
+
created_at INTEGER NOT NULL,
|
|
800
|
+
updated_at INTEGER NOT NULL,
|
|
801
|
+
last_refreshed_at INTEGER,
|
|
802
|
+
UNIQUE(project_id, name COLLATE NOCASE)
|
|
803
|
+
);
|
|
804
|
+
CREATE INDEX IF NOT EXISTS idx_keyword_sets_project
|
|
805
|
+
ON keyword_sets(project_id, updated_at DESC, id);
|
|
806
|
+
|
|
807
|
+
CREATE TABLE IF NOT EXISTS keyword_set_items (
|
|
808
|
+
set_id TEXT NOT NULL,
|
|
809
|
+
normalized_keyword TEXT NOT NULL,
|
|
810
|
+
display_keyword TEXT NOT NULL,
|
|
811
|
+
metric_json TEXT,
|
|
812
|
+
metric_provider TEXT CHECK(metric_provider IS NULL OR metric_provider IN ('dataforseo', 'semrush', 'ahrefs')),
|
|
813
|
+
metric_observed_at TEXT,
|
|
814
|
+
page_kind TEXT CHECK(page_kind IS NULL OR page_kind IN ('target', 'proposed')),
|
|
815
|
+
page_url TEXT,
|
|
816
|
+
created_at INTEGER NOT NULL,
|
|
817
|
+
updated_at INTEGER NOT NULL,
|
|
818
|
+
PRIMARY KEY(set_id, normalized_keyword),
|
|
819
|
+
FOREIGN KEY(set_id) REFERENCES keyword_sets(id) ON DELETE CASCADE
|
|
820
|
+
) WITHOUT ROWID;
|
|
821
|
+
CREATE INDEX IF NOT EXISTS idx_keyword_set_items_set
|
|
822
|
+
ON keyword_set_items(set_id, normalized_keyword);
|
|
823
|
+
|
|
824
|
+
CREATE TABLE IF NOT EXISTS keyword_set_tags (
|
|
825
|
+
set_id TEXT NOT NULL,
|
|
826
|
+
normalized_keyword TEXT NOT NULL,
|
|
827
|
+
tag TEXT NOT NULL,
|
|
828
|
+
PRIMARY KEY(set_id, normalized_keyword, tag),
|
|
829
|
+
FOREIGN KEY(set_id, normalized_keyword)
|
|
830
|
+
REFERENCES keyword_set_items(set_id, normalized_keyword) ON DELETE CASCADE
|
|
831
|
+
) WITHOUT ROWID;
|
|
832
|
+
CREATE INDEX IF NOT EXISTS idx_keyword_set_tags_lookup
|
|
833
|
+
ON keyword_set_tags(set_id, tag, normalized_keyword);
|
|
834
|
+
`;
|
|
835
|
+
//#endregion
|
|
836
|
+
//#region packages/core/dist/rank-tracking/schema.js
|
|
837
|
+
const RANK_TRACKING_SCHEMA_SQL = `
|
|
838
|
+
CREATE TABLE IF NOT EXISTS rank_tracking_configs (
|
|
839
|
+
id TEXT PRIMARY KEY,
|
|
840
|
+
config_key TEXT NOT NULL UNIQUE,
|
|
841
|
+
project_id TEXT NOT NULL,
|
|
842
|
+
keyword_set_id TEXT NOT NULL,
|
|
843
|
+
target_domain TEXT NOT NULL,
|
|
844
|
+
tag TEXT,
|
|
845
|
+
market_json TEXT NOT NULL,
|
|
846
|
+
devices_json TEXT NOT NULL,
|
|
847
|
+
provider TEXT NOT NULL CHECK(provider IN ('dataforseo', 'semrush', 'ahrefs')),
|
|
848
|
+
collection_method TEXT NOT NULL CHECK(collection_method IN ('live', 'queued')),
|
|
849
|
+
cadence TEXT NOT NULL CHECK(cadence IN ('manual', 'daily', 'weekly', 'monthly')),
|
|
850
|
+
depth INTEGER NOT NULL CHECK(depth BETWEEN 1 AND 100),
|
|
851
|
+
keyword_limit INTEGER NOT NULL CHECK(keyword_limit BETWEEN 1 AND 1000),
|
|
852
|
+
next_run_at INTEGER,
|
|
853
|
+
created_at INTEGER NOT NULL,
|
|
854
|
+
updated_at INTEGER NOT NULL,
|
|
855
|
+
FOREIGN KEY(keyword_set_id) REFERENCES keyword_sets(id) ON DELETE CASCADE
|
|
856
|
+
);
|
|
857
|
+
CREATE INDEX IF NOT EXISTS idx_rank_tracking_configs_project
|
|
858
|
+
ON rank_tracking_configs(project_id, updated_at DESC, id);
|
|
859
|
+
CREATE INDEX IF NOT EXISTS idx_rank_tracking_configs_due
|
|
860
|
+
ON rank_tracking_configs(cadence, next_run_at, id);
|
|
861
|
+
|
|
862
|
+
CREATE TABLE IF NOT EXISTS rank_tracking_runs (
|
|
863
|
+
id TEXT PRIMARY KEY,
|
|
864
|
+
config_id TEXT NOT NULL,
|
|
865
|
+
idempotency_key TEXT NOT NULL UNIQUE,
|
|
866
|
+
state TEXT NOT NULL CHECK(state IN ('pending', 'partial', 'failed', 'complete')),
|
|
867
|
+
collection_method TEXT NOT NULL CHECK(collection_method IN ('live', 'queued')),
|
|
868
|
+
scheduled_for INTEGER NOT NULL,
|
|
869
|
+
started_at INTEGER NOT NULL,
|
|
870
|
+
completed_at INTEGER,
|
|
871
|
+
keyword_count INTEGER NOT NULL,
|
|
872
|
+
task_count INTEGER NOT NULL,
|
|
873
|
+
snapshot_count INTEGER NOT NULL DEFAULT 0,
|
|
874
|
+
pending_count INTEGER NOT NULL,
|
|
875
|
+
failed_count INTEGER NOT NULL DEFAULT 0,
|
|
876
|
+
estimated_cost_micros INTEGER,
|
|
877
|
+
actual_cost_micros INTEGER,
|
|
878
|
+
config_snapshot_json TEXT NOT NULL,
|
|
879
|
+
error_summary TEXT,
|
|
880
|
+
FOREIGN KEY(config_id) REFERENCES rank_tracking_configs(id) ON DELETE CASCADE
|
|
881
|
+
);
|
|
882
|
+
CREATE INDEX IF NOT EXISTS idx_rank_tracking_runs_config
|
|
883
|
+
ON rank_tracking_runs(config_id, started_at DESC, id DESC);
|
|
884
|
+
DROP INDEX IF EXISTS idx_rank_tracking_active_run;
|
|
885
|
+
CREATE UNIQUE INDEX idx_rank_tracking_active_run
|
|
886
|
+
ON rank_tracking_runs(config_id)
|
|
887
|
+
WHERE completed_at IS NULL;
|
|
888
|
+
|
|
889
|
+
CREATE TABLE IF NOT EXISTS rank_tracking_tasks (
|
|
890
|
+
id TEXT PRIMARY KEY,
|
|
891
|
+
run_id TEXT NOT NULL,
|
|
892
|
+
normalized_keyword TEXT NOT NULL,
|
|
893
|
+
display_keyword TEXT NOT NULL,
|
|
894
|
+
device TEXT NOT NULL CHECK(device IN ('desktop', 'mobile')),
|
|
895
|
+
state TEXT NOT NULL CHECK(state IN ('pending', 'posting', 'posted', 'complete', 'failed')),
|
|
896
|
+
provider_task_id TEXT,
|
|
897
|
+
attempt_count INTEGER NOT NULL DEFAULT 0,
|
|
898
|
+
posted_at INTEGER,
|
|
899
|
+
collected_at INTEGER,
|
|
900
|
+
error_code TEXT,
|
|
901
|
+
error_message TEXT,
|
|
902
|
+
UNIQUE(run_id, normalized_keyword, device),
|
|
903
|
+
UNIQUE(provider_task_id),
|
|
904
|
+
FOREIGN KEY(run_id) REFERENCES rank_tracking_runs(id) ON DELETE CASCADE
|
|
905
|
+
);
|
|
906
|
+
CREATE INDEX IF NOT EXISTS idx_rank_tracking_tasks_run
|
|
907
|
+
ON rank_tracking_tasks(run_id, state, normalized_keyword, device);
|
|
908
|
+
CREATE INDEX IF NOT EXISTS idx_rank_tracking_tasks_provider
|
|
909
|
+
ON rank_tracking_tasks(provider_task_id, state);
|
|
910
|
+
|
|
911
|
+
CREATE TABLE IF NOT EXISTS rank_tracking_snapshots (
|
|
912
|
+
task_id TEXT PRIMARY KEY,
|
|
913
|
+
run_id TEXT NOT NULL,
|
|
914
|
+
normalized_keyword TEXT NOT NULL,
|
|
915
|
+
display_keyword TEXT NOT NULL,
|
|
916
|
+
device TEXT NOT NULL CHECK(device IN ('desktop', 'mobile')),
|
|
917
|
+
observation_state TEXT NOT NULL CHECK(observation_state IN ('observed', 'not_observed_within_depth')),
|
|
918
|
+
organic_position INTEGER,
|
|
919
|
+
absolute_position INTEGER,
|
|
920
|
+
ranking_url TEXT,
|
|
921
|
+
observed_features_json TEXT NOT NULL,
|
|
922
|
+
checked_at TEXT NOT NULL,
|
|
923
|
+
provider TEXT NOT NULL CHECK(provider IN ('dataforseo', 'semrush', 'ahrefs')),
|
|
924
|
+
provider_task_id TEXT,
|
|
925
|
+
requested_depth INTEGER NOT NULL,
|
|
926
|
+
returned_rows INTEGER,
|
|
927
|
+
retained_rows INTEGER,
|
|
928
|
+
invalid_rows INTEGER NOT NULL,
|
|
929
|
+
completeness TEXT NOT NULL,
|
|
930
|
+
estimated_cost_micros INTEGER,
|
|
931
|
+
actual_cost_micros INTEGER,
|
|
932
|
+
warnings_json TEXT NOT NULL,
|
|
933
|
+
created_at INTEGER NOT NULL,
|
|
934
|
+
FOREIGN KEY(task_id) REFERENCES rank_tracking_tasks(id) ON DELETE CASCADE,
|
|
935
|
+
FOREIGN KEY(run_id) REFERENCES rank_tracking_runs(id) ON DELETE CASCADE
|
|
936
|
+
);
|
|
937
|
+
CREATE INDEX IF NOT EXISTS idx_rank_tracking_snapshots_run
|
|
938
|
+
ON rank_tracking_snapshots(run_id, normalized_keyword, device);
|
|
939
|
+
`;
|
|
940
|
+
//#endregion
|
|
790
941
|
//#region packages/core/dist/storage/cache-maintenance.js
|
|
791
942
|
const MEBIBYTE$2 = 1024 * 1024;
|
|
792
943
|
const CACHE_MAX_AGE_MS = 720 * 60 * 60 * 1e3;
|
|
@@ -1341,6 +1492,8 @@ function initDb(database, isNewDatabase) {
|
|
|
1341
1492
|
if (isNewDatabase) database.pragma("auto_vacuum = INCREMENTAL");
|
|
1342
1493
|
database.transaction(() => {
|
|
1343
1494
|
database.exec(CREATE_SQL);
|
|
1495
|
+
database.exec(KEYWORD_SET_SCHEMA_SQL);
|
|
1496
|
+
database.exec(RANK_TRACKING_SCHEMA_SQL);
|
|
1344
1497
|
database.exec(PROVIDER_SPEND_SCHEMA_SQL);
|
|
1345
1498
|
database.exec(`DELETE FROM semrush_cache WHERE request_json LIKE '%"key"%'`);
|
|
1346
1499
|
ensureColumn(database, "crawl_pages", "snapshot_json", "TEXT");
|
|
@@ -4486,7 +4639,7 @@ async function fetchPage(url, opts = {}, dependencies = {}) {
|
|
|
4486
4639
|
}
|
|
4487
4640
|
//#endregion
|
|
4488
4641
|
//#region packages/core/dist/gsc/property-url.js
|
|
4489
|
-
function invalid(message) {
|
|
4642
|
+
function invalid$2(message) {
|
|
4490
4643
|
throw new SeoError("INVALID_INPUT", message);
|
|
4491
4644
|
}
|
|
4492
4645
|
function normalizeHttpUrl(value) {
|
|
@@ -4494,10 +4647,10 @@ function normalizeHttpUrl(value) {
|
|
|
4494
4647
|
try {
|
|
4495
4648
|
url = new URL(value.trim());
|
|
4496
4649
|
} catch {
|
|
4497
|
-
return invalid(`Invalid URL: ${value}`);
|
|
4650
|
+
return invalid$2(`Invalid URL: ${value}`);
|
|
4498
4651
|
}
|
|
4499
|
-
if (url.protocol !== "http:" && url.protocol !== "https:") return invalid("The report URL must use http:// or https://.");
|
|
4500
|
-
if (url.username || url.password) return invalid("The report URL must not contain embedded credentials.");
|
|
4652
|
+
if (url.protocol !== "http:" && url.protocol !== "https:") return invalid$2("The report URL must use http:// or https://.");
|
|
4653
|
+
if (url.username || url.password) return invalid$2("The report URL must not contain embedded credentials.");
|
|
4501
4654
|
url.hash = "";
|
|
4502
4655
|
return url.toString();
|
|
4503
4656
|
}
|
|
@@ -4506,13 +4659,13 @@ function assertUrlMatchesGscProperty(property, value) {
|
|
|
4506
4659
|
const parsed = new URL(url);
|
|
4507
4660
|
if (property.startsWith("sc-domain:")) {
|
|
4508
4661
|
const domain = property.slice(10).trim().toLowerCase();
|
|
4509
|
-
if (!domain || /[/:]/.test(domain)) return invalid(`Invalid Search Console domain property: ${property}`);
|
|
4662
|
+
if (!domain || /[/:]/.test(domain)) return invalid$2(`Invalid Search Console domain property: ${property}`);
|
|
4510
4663
|
const hostname = parsed.hostname.toLowerCase();
|
|
4511
|
-
if (hostname !== domain && !hostname.endsWith(`.${domain}`)) return invalid(`${url} is outside the Search Console property ${property}.`);
|
|
4664
|
+
if (hostname !== domain && !hostname.endsWith(`.${domain}`)) return invalid$2(`${url} is outside the Search Console property ${property}.`);
|
|
4512
4665
|
return url;
|
|
4513
4666
|
}
|
|
4514
4667
|
const prefix = normalizeHttpUrl(property);
|
|
4515
|
-
if (!url.startsWith(prefix)) return invalid(`${url} is outside the Search Console property ${property}.`);
|
|
4668
|
+
if (!url.startsWith(prefix)) return invalid$2(`${url} is outside the Search Console property ${property}.`);
|
|
4516
4669
|
return url;
|
|
4517
4670
|
}
|
|
4518
4671
|
//#endregion
|
|
@@ -6596,7 +6749,7 @@ function selectBenchmarkRows$1(input) {
|
|
|
6596
6749
|
function compareText$15(left, right) {
|
|
6597
6750
|
return left < right ? -1 : left > right ? 1 : 0;
|
|
6598
6751
|
}
|
|
6599
|
-
function compareItems$
|
|
6752
|
+
function compareItems$2(left, right) {
|
|
6600
6753
|
return (right.estimatedCtrClickShortfall ?? -1) - (left.estimatedCtrClickShortfall ?? -1) || right.impressions - left.impressions || left.position - right.position || compareText$15(left.query, right.query) || compareText$15(left.url, right.url);
|
|
6601
6754
|
}
|
|
6602
6755
|
function analyzeRow(input) {
|
|
@@ -6661,7 +6814,7 @@ function analyzePageOpportunitiesFromRows(input) {
|
|
|
6661
6814
|
reportInput: input,
|
|
6662
6815
|
benchmarkContext,
|
|
6663
6816
|
excludedTargetRows
|
|
6664
|
-
})).sort(compareItems$
|
|
6817
|
+
})).sort(compareItems$2).slice(0, limit);
|
|
6665
6818
|
const estimatedCtrClickShortfall = Number(items.reduce((sum, item) => sum + (item.estimatedCtrClickShortfall ?? 0), 0).toFixed(2));
|
|
6666
6819
|
return {
|
|
6667
6820
|
site: input.site,
|
|
@@ -16738,7 +16891,7 @@ function compareRetainedSegmentRows(input) {
|
|
|
16738
16891
|
}
|
|
16739
16892
|
//#endregion
|
|
16740
16893
|
//#region packages/core/dist/analyze/segment-impact-analysis.js
|
|
16741
|
-
function verdict$
|
|
16894
|
+
function verdict$5(input) {
|
|
16742
16895
|
if (input.status === "empty") return "Search Console returned no retained segment rows for either window.";
|
|
16743
16896
|
if (!input.matchedRows) return "No segment was retained in both windows, so no movement delta was inferred.";
|
|
16744
16897
|
const suffix = input.unmatchedRows ? ` ${input.unmatchedRows} one-window rows were kept as unmatched evidence and not converted to zero.` : "";
|
|
@@ -16831,7 +16984,7 @@ function analyzeSegmentRows(input) {
|
|
|
16831
16984
|
matchedRows: matched.length,
|
|
16832
16985
|
returnedRows: selection.returnedRows,
|
|
16833
16986
|
unmatchedRows: unmatched.length,
|
|
16834
|
-
verdict: verdict$
|
|
16987
|
+
verdict: verdict$5({
|
|
16835
16988
|
status: dataStatus,
|
|
16836
16989
|
matchedRows: matched.length,
|
|
16837
16990
|
returnedRows: selection.returnedRows,
|
|
@@ -17595,7 +17748,7 @@ const defaultDependencies$10 = {
|
|
|
17595
17748
|
searchAnalytics: querySearchAnalytics,
|
|
17596
17749
|
now: () => /* @__PURE__ */ new Date()
|
|
17597
17750
|
};
|
|
17598
|
-
function verdict$
|
|
17751
|
+
function verdict$4(input) {
|
|
17599
17752
|
if (input.status === "empty") return "Search Console returned no retained query/page rows for this window.";
|
|
17600
17753
|
if (!input.eligible) {
|
|
17601
17754
|
if (input.status === "partial") return "No material multi-URL query candidates remained in the validated retained rows, but partial evidence prevents an all-clear.";
|
|
@@ -17722,7 +17875,7 @@ async function cannibalReport(input, dependencies = defaultDependencies$10) {
|
|
|
17722
17875
|
returnedClusters: analysis.selection.returnedClusters,
|
|
17723
17876
|
suppressedQueries: analysis.selection.suppressedQueries,
|
|
17724
17877
|
brandFiltering: input.includeBrand ? "included" : "excluded",
|
|
17725
|
-
verdict: verdict$
|
|
17878
|
+
verdict: verdict$4({
|
|
17726
17879
|
status: dataStatus,
|
|
17727
17880
|
eligible: analysis.selection.eligibleClusters,
|
|
17728
17881
|
returned: analysis.selection.returnedClusters
|
|
@@ -18008,7 +18161,7 @@ const defaultDependencies$9 = {
|
|
|
18008
18161
|
searchAnalytics: querySearchAnalytics,
|
|
18009
18162
|
now: () => /* @__PURE__ */ new Date()
|
|
18010
18163
|
};
|
|
18011
|
-
function verdict$
|
|
18164
|
+
function verdict$3(report) {
|
|
18012
18165
|
if (report.dataStatus === "empty") return "Search Console returned no retained query/page rows for either comparison window.";
|
|
18013
18166
|
if (report.dataStatus === "partial" && report.selection.currentAggregatedRows === 0) return "The current retained-row window is empty, so no query/page losses were inferred.";
|
|
18014
18167
|
if (report.dataStatus === "partial" && report.selection.previousAggregatedRows === 0) return "The comparison retained-row window is empty, so query/page decay could not be measured.";
|
|
@@ -18153,7 +18306,7 @@ async function decayingReport(input, dependencies = defaultDependencies$9) {
|
|
|
18153
18306
|
observedRetainedQueryClickLoss: analysis.totals.eligibleObservedRetainedQueryClickLoss,
|
|
18154
18307
|
returnedObservedRetainedQueryClickLoss: analysis.totals.returnedObservedRetainedQueryClickLoss,
|
|
18155
18308
|
brandFiltering: input.includeBrand ? "included" : "excluded",
|
|
18156
|
-
verdict: verdict$
|
|
18309
|
+
verdict: verdict$3(reportBase)
|
|
18157
18310
|
},
|
|
18158
18311
|
caveats: [
|
|
18159
18312
|
`Current window: ${currentRange.startDate} to ${currentRange.endDate}; comparison: ${previousRange.startDate} to ${previousRange.endDate}.`,
|
|
@@ -18947,7 +19100,7 @@ function templatesFromGroups(groups) {
|
|
|
18947
19100
|
sampleUrls: group.sampleUrls
|
|
18948
19101
|
}));
|
|
18949
19102
|
}
|
|
18950
|
-
function verdict$
|
|
19103
|
+
function verdict$2(analysis) {
|
|
18951
19104
|
if (analysis.dataStatus === "empty") return "Search Console returned no retained query/page rows for this date window.";
|
|
18952
19105
|
if (analysis.dataStatus === "filtered") return "No retained query/page rows met the report filters.";
|
|
18953
19106
|
return `${countLabel(analysis.summary.eligibleRows, "eligible query/page row")} found; ${countLabel(analysis.summary.returnedRows, "row")} returned for review.`;
|
|
@@ -19031,7 +19184,7 @@ async function strikingDistance(input, dependencies = defaultDependencies$7) {
|
|
|
19031
19184
|
summary: {
|
|
19032
19185
|
...analysis.summary,
|
|
19033
19186
|
brandFiltering: input.includeBrand ? "included" : "excluded",
|
|
19034
|
-
verdict: verdict$
|
|
19187
|
+
verdict: verdict$2(analysis)
|
|
19035
19188
|
},
|
|
19036
19189
|
caveats: [
|
|
19037
19190
|
`Date window: ${range.startDate} to ${range.endDate}, using final GSC data where available.`,
|
|
@@ -21745,7 +21898,7 @@ const MAX_LEDGER_ROWS = 5e4;
|
|
|
21745
21898
|
const MAX_LEDGER_BYTES = 32 * 1024 * 1024;
|
|
21746
21899
|
const MAX_TASK_IDS = 20;
|
|
21747
21900
|
const MAX_ACTIVE_RESERVATIONS = 100;
|
|
21748
|
-
function boundedText(provider, value, label, max) {
|
|
21901
|
+
function boundedText$1(provider, value, label, max) {
|
|
21749
21902
|
const normalized = value.trim();
|
|
21750
21903
|
if (!normalized || normalized.length > max) throw new ProviderError({
|
|
21751
21904
|
provider,
|
|
@@ -21839,10 +21992,10 @@ function reserveProviderSpend(input, options = {}) {
|
|
|
21839
21992
|
const now = options.now ?? Date.now();
|
|
21840
21993
|
const normalized = {
|
|
21841
21994
|
...input,
|
|
21842
|
-
endpoint: boundedText(input.provider, input.endpoint, "Provider endpoint", 500),
|
|
21843
|
-
projectId: input.projectId ? boundedText(input.provider, input.projectId, "Project id", 200) : void 0,
|
|
21844
|
-
reportId: boundedText(input.provider, input.reportId, "Report id", 100),
|
|
21845
|
-
reportRunId: boundedText(input.provider, input.reportRunId, "Report run id", 100),
|
|
21995
|
+
endpoint: boundedText$1(input.provider, input.endpoint, "Provider endpoint", 500),
|
|
21996
|
+
projectId: input.projectId ? boundedText$1(input.provider, input.projectId, "Project id", 200) : void 0,
|
|
21997
|
+
reportId: boundedText$1(input.provider, input.reportId, "Report id", 100),
|
|
21998
|
+
reportRunId: boundedText$1(input.provider, input.reportRunId, "Report run id", 100),
|
|
21846
21999
|
requestedRows: nonnegativeInteger(input.provider, input.requestedRows, "Requested rows"),
|
|
21847
22000
|
estimatedCostMicros: nonnegativeInteger(input.provider, input.estimatedCostMicros, "Estimated cost")
|
|
21848
22001
|
};
|
|
@@ -21879,7 +22032,7 @@ function reserveProviderSpend(input, options = {}) {
|
|
|
21879
22032
|
function finalizeProviderSpend(reservationId, result, options = {}) {
|
|
21880
22033
|
const database = options.database ?? getDb();
|
|
21881
22034
|
const now = options.now ?? Date.now();
|
|
21882
|
-
const taskIds = [...new Set(result.taskIds)].sort((left, right) => left < right ? -1 : left > right ? 1 : 0).slice(0, MAX_TASK_IDS).map((id) => boundedText(result.provider, id, "Provider task id", 100));
|
|
22035
|
+
const taskIds = [...new Set(result.taskIds)].sort((left, right) => left < right ? -1 : left > right ? 1 : 0).slice(0, MAX_TASK_IDS).map((id) => boundedText$1(result.provider, id, "Provider task id", 100));
|
|
21883
22036
|
const actualCostMicros = result.actualCostMicros === null ? null : nonnegativeInteger(result.provider, result.actualCostMicros, "Actual cost");
|
|
21884
22037
|
const returnedRows = result.returnedRows === null ? null : nonnegativeInteger(result.provider, result.returnedRows, "Returned rows");
|
|
21885
22038
|
return database.transaction(() => {
|
|
@@ -22131,7 +22284,10 @@ const userDataPriceSchema = z.object({
|
|
|
22131
22284
|
keyword_suggestions: livePriceSchema.optional(),
|
|
22132
22285
|
related_keywords: livePriceSchema.optional()
|
|
22133
22286
|
}).passthrough().optional(),
|
|
22134
|
-
serp: z.object({
|
|
22287
|
+
serp: z.object({
|
|
22288
|
+
task_post: normalPriorityPriceSchema.optional(),
|
|
22289
|
+
live: z.object({ advanced: normalPriorityPriceSchema.optional() }).passthrough().optional()
|
|
22290
|
+
}).passthrough().optional()
|
|
22135
22291
|
}).passthrough();
|
|
22136
22292
|
const userDataResultSchema = z.object({
|
|
22137
22293
|
login: z.string().min(1).max(320),
|
|
@@ -22163,7 +22319,7 @@ const dataForSeoUserDataResponseSchema = z.object({
|
|
|
22163
22319
|
const MAX_PROVIDER_MONTHLY_SEARCH_ROWS = 120;
|
|
22164
22320
|
const nullableNumber = z.number().finite().nullable().optional();
|
|
22165
22321
|
const nullableCount = z.union([z.number().finite(), z.string().trim().min(1)]).nullable().optional();
|
|
22166
|
-
const monthlySearchSchema = z.object({
|
|
22322
|
+
const monthlySearchSchema$1 = z.object({
|
|
22167
22323
|
year: z.number().int(),
|
|
22168
22324
|
month: z.number().int(),
|
|
22169
22325
|
search_volume: z.number().finite().nullable().optional()
|
|
@@ -22174,7 +22330,7 @@ const keywordOverviewItemSchema = z.object({
|
|
|
22174
22330
|
search_volume: nullableNumber,
|
|
22175
22331
|
cpc: nullableNumber,
|
|
22176
22332
|
competition: nullableNumber,
|
|
22177
|
-
monthly_searches: z.array(monthlySearchSchema).max(MAX_PROVIDER_MONTHLY_SEARCH_ROWS).nullish(),
|
|
22333
|
+
monthly_searches: z.array(monthlySearchSchema$1).max(MAX_PROVIDER_MONTHLY_SEARCH_ROWS).nullish(),
|
|
22178
22334
|
last_updated_time: z.string().trim().min(1).nullable().optional()
|
|
22179
22335
|
}).nullish(),
|
|
22180
22336
|
keyword_properties: z.object({ keyword_difficulty: nullableNumber }).nullish(),
|
|
@@ -22188,7 +22344,7 @@ const keywordOverviewItemSchema = z.object({
|
|
|
22188
22344
|
}).passthrough();
|
|
22189
22345
|
const keywordOverviewResultSchema = z.object({
|
|
22190
22346
|
items_count: z.number().int().nonnegative().optional(),
|
|
22191
|
-
items: z.array(keywordOverviewItemSchema).optional()
|
|
22347
|
+
items: z.array(keywordOverviewItemSchema).nullable().optional()
|
|
22192
22348
|
}).passthrough();
|
|
22193
22349
|
const keywordOverviewTaskSchema = z.object({
|
|
22194
22350
|
id: z.string().trim().min(1).optional(),
|
|
@@ -22249,19 +22405,19 @@ const dataForSeoDiscoveryResponseSchema = z.object({
|
|
|
22249
22405
|
}).passthrough();
|
|
22250
22406
|
//#endregion
|
|
22251
22407
|
//#region packages/core/dist/providers/dataforseo/paid-request.js
|
|
22252
|
-
function usdToMicros
|
|
22408
|
+
function usdToMicros(value) {
|
|
22253
22409
|
if (value === void 0) return null;
|
|
22254
22410
|
return Math.round(value * 1e6);
|
|
22255
22411
|
}
|
|
22256
22412
|
function responseTaskIds$1(response) {
|
|
22257
22413
|
return [...new Set(response.tasks.flatMap((task) => task.id ? [task.id] : []))].sort((left, right) => left < right ? -1 : left > right ? 1 : 0);
|
|
22258
22414
|
}
|
|
22259
|
-
function responseCostMicros
|
|
22260
|
-
if (response.cost !== void 0) return usdToMicros
|
|
22415
|
+
function responseCostMicros(response) {
|
|
22416
|
+
if (response.cost !== void 0) return usdToMicros(response.cost);
|
|
22261
22417
|
if (response.tasks.some((task) => task.cost === void 0)) return null;
|
|
22262
|
-
return response.tasks.reduce((sum, task) => sum + (usdToMicros
|
|
22418
|
+
return response.tasks.reduce((sum, task) => sum + (usdToMicros(task.cost) ?? 0), 0);
|
|
22263
22419
|
}
|
|
22264
|
-
function taskErrorCode
|
|
22420
|
+
function taskErrorCode(statusCode) {
|
|
22265
22421
|
if (statusCode >= 40100 && statusCode < 40200) return "authentication";
|
|
22266
22422
|
if (statusCode === 40202) return "rate-limit";
|
|
22267
22423
|
return "remote-error";
|
|
@@ -22351,7 +22507,7 @@ async function dataForSeoPaidPost(input) {
|
|
|
22351
22507
|
});
|
|
22352
22508
|
throw error;
|
|
22353
22509
|
}
|
|
22354
|
-
const actualCostMicros = responseCostMicros
|
|
22510
|
+
const actualCostMicros = responseCostMicros(response);
|
|
22355
22511
|
const returnedRows = input.rowCount(response);
|
|
22356
22512
|
const taskIds = responseTaskIds$1(response);
|
|
22357
22513
|
const failedTasks = response.tasks.filter((task) => task.status_code !== 2e4);
|
|
@@ -22373,7 +22529,7 @@ async function dataForSeoPaidPost(input) {
|
|
|
22373
22529
|
throw new ProviderError({
|
|
22374
22530
|
provider: "dataforseo",
|
|
22375
22531
|
operation: input.operation,
|
|
22376
|
-
code: taskErrorCode
|
|
22532
|
+
code: taskErrorCode(statusCode),
|
|
22377
22533
|
message: `DataForSEO could not complete ${input.operation} (${statusCode}).`,
|
|
22378
22534
|
retryable: statusCode === 40202
|
|
22379
22535
|
});
|
|
@@ -22416,6 +22572,22 @@ async function dataForSeoPaidPost(input) {
|
|
|
22416
22572
|
};
|
|
22417
22573
|
}
|
|
22418
22574
|
//#endregion
|
|
22575
|
+
//#region packages/core/dist/providers/dataforseo/response.js
|
|
22576
|
+
function dataForSeoUsdToMicros(value) {
|
|
22577
|
+
if (value === void 0) return null;
|
|
22578
|
+
return Math.round(value * 1e6);
|
|
22579
|
+
}
|
|
22580
|
+
function dataForSeoTaskErrorCode(statusCode) {
|
|
22581
|
+
if (statusCode >= 40100 && statusCode < 40200) return "authentication";
|
|
22582
|
+
if (statusCode === 40202) return "rate-limit";
|
|
22583
|
+
return "remote-error";
|
|
22584
|
+
}
|
|
22585
|
+
function dataForSeoResponseCostMicros(response) {
|
|
22586
|
+
if (response.cost !== void 0) return dataForSeoUsdToMicros(response.cost);
|
|
22587
|
+
if (response.tasks.some((task) => task.cost === void 0)) return null;
|
|
22588
|
+
return response.tasks.reduce((sum, task) => sum + (dataForSeoUsdToMicros(task.cost) ?? 0), 0);
|
|
22589
|
+
}
|
|
22590
|
+
//#endregion
|
|
22419
22591
|
//#region packages/core/dist/providers/dataforseo/serp-schema.js
|
|
22420
22592
|
const serpItemSchema = z.object({
|
|
22421
22593
|
type: z.string().trim().min(1).max(100),
|
|
@@ -22460,6 +22632,237 @@ const dataForSeoSerpResponseSchema = z.object({
|
|
|
22460
22632
|
tasks: z.array(serpTaskSchema).max(10)
|
|
22461
22633
|
}).passthrough();
|
|
22462
22634
|
//#endregion
|
|
22635
|
+
//#region packages/core/dist/providers/dataforseo/serp-task-schema.js
|
|
22636
|
+
const taskPostItemSchema = z.object({
|
|
22637
|
+
id: z.string().trim().min(1).max(100).optional(),
|
|
22638
|
+
status_code: z.number().int(),
|
|
22639
|
+
status_message: z.string().max(1e3),
|
|
22640
|
+
cost: z.number().finite().nonnegative().optional(),
|
|
22641
|
+
result_count: z.number().int().nonnegative().optional(),
|
|
22642
|
+
data: z.object({ tag: z.string().trim().min(1).max(255).optional() }).passthrough().optional()
|
|
22643
|
+
}).passthrough();
|
|
22644
|
+
const dataForSeoSerpTaskPostResponseSchema = z.object({
|
|
22645
|
+
status_code: z.number().int(),
|
|
22646
|
+
status_message: z.string().max(1e3),
|
|
22647
|
+
cost: z.number().finite().nonnegative().optional(),
|
|
22648
|
+
tasks_count: z.number().int().nonnegative(),
|
|
22649
|
+
tasks_error: z.number().int().nonnegative(),
|
|
22650
|
+
tasks: z.array(taskPostItemSchema).max(100)
|
|
22651
|
+
}).passthrough();
|
|
22652
|
+
const readyResultSchema = z.object({
|
|
22653
|
+
id: z.string().trim().min(1).max(100),
|
|
22654
|
+
tag: z.string().trim().max(255).nullable().optional(),
|
|
22655
|
+
se: z.string().trim().max(100).optional(),
|
|
22656
|
+
se_type: z.string().trim().max(100).optional(),
|
|
22657
|
+
endpoint_advanced: z.string().trim().max(2048).nullable().optional()
|
|
22658
|
+
}).passthrough();
|
|
22659
|
+
const readyTaskSchema = z.object({
|
|
22660
|
+
id: z.string().trim().min(1).max(100).optional(),
|
|
22661
|
+
status_code: z.number().int(),
|
|
22662
|
+
status_message: z.string().max(1e3),
|
|
22663
|
+
cost: z.number().finite().nonnegative().optional(),
|
|
22664
|
+
result: z.array(readyResultSchema).max(1e3).nullable().optional()
|
|
22665
|
+
}).passthrough();
|
|
22666
|
+
const dataForSeoSerpTasksReadyResponseSchema = z.object({
|
|
22667
|
+
status_code: z.number().int(),
|
|
22668
|
+
status_message: z.string().max(1e3),
|
|
22669
|
+
cost: z.number().finite().nonnegative().optional(),
|
|
22670
|
+
tasks_count: z.number().int().nonnegative(),
|
|
22671
|
+
tasks_error: z.number().int().nonnegative(),
|
|
22672
|
+
tasks: z.array(readyTaskSchema).max(10)
|
|
22673
|
+
}).passthrough();
|
|
22674
|
+
//#endregion
|
|
22675
|
+
//#region packages/core/dist/providers/dataforseo/serp-tasks.js
|
|
22676
|
+
const SERP_TASK_POST_PATH = "v3/serp/google/organic/task_post";
|
|
22677
|
+
const SERP_TASKS_READY_PATH = "v3/serp/google/organic/tasks_ready";
|
|
22678
|
+
const SERP_TASK_GET_ADVANCED_PATH = "v3/serp/google/organic/task_get/advanced/";
|
|
22679
|
+
function dataForSeoSerpRows(response) {
|
|
22680
|
+
return response.tasks.reduce((taskTotal, task) => taskTotal + (task.result ?? []).reduce((resultTotal, result) => resultTotal + (result.items?.length ?? 0), 0), 0);
|
|
22681
|
+
}
|
|
22682
|
+
function validateDataForSeoSerpTaskId(providerTaskId) {
|
|
22683
|
+
if (!/^[a-zA-Z0-9-]{1,100}$/u.test(providerTaskId)) throw new ProviderError({
|
|
22684
|
+
provider: "dataforseo",
|
|
22685
|
+
operation: "serp-task-get",
|
|
22686
|
+
code: "configuration",
|
|
22687
|
+
message: "Use a valid queued SERP task id."
|
|
22688
|
+
});
|
|
22689
|
+
}
|
|
22690
|
+
async function postDataForSeoSerpTasks(input, transport) {
|
|
22691
|
+
const { price, requests } = input;
|
|
22692
|
+
if (price.perRequestMicros === null || price.perResultMicros === null) throw new ProviderError({
|
|
22693
|
+
provider: "dataforseo",
|
|
22694
|
+
operation: "serp-task-post",
|
|
22695
|
+
code: "invalid-response",
|
|
22696
|
+
message: "DataForSEO Standard SERP pricing is unavailable, so queued tasks were not posted."
|
|
22697
|
+
});
|
|
22698
|
+
const requestUnits = requests.reduce((total, request) => total + Math.ceil(request.depth / 10), 0);
|
|
22699
|
+
const estimatedCostMicros = price.perRequestMicros * requestUnits + price.perResultMicros * requests.length;
|
|
22700
|
+
const reservation = reserveProviderSpend({
|
|
22701
|
+
provider: "dataforseo",
|
|
22702
|
+
capability: "serp-snapshot",
|
|
22703
|
+
endpoint: SERP_TASK_POST_PATH,
|
|
22704
|
+
projectId: input.context.projectId,
|
|
22705
|
+
reportId: input.context.reportId,
|
|
22706
|
+
reportRunId: input.context.reportRunId,
|
|
22707
|
+
requestedRows: requests.length,
|
|
22708
|
+
estimatedCostMicros
|
|
22709
|
+
}, {
|
|
22710
|
+
database: transport.database,
|
|
22711
|
+
limits: transport.spendLimits,
|
|
22712
|
+
now: transport.now().getTime()
|
|
22713
|
+
});
|
|
22714
|
+
let response;
|
|
22715
|
+
try {
|
|
22716
|
+
response = await providerRequestJson({
|
|
22717
|
+
provider: "dataforseo",
|
|
22718
|
+
operation: "serp-task-post",
|
|
22719
|
+
url: new URL(SERP_TASK_POST_PATH, transport.baseUrl),
|
|
22720
|
+
fetch: transport.fetch,
|
|
22721
|
+
maxResponseBytes: transport.maxResponseBytes,
|
|
22722
|
+
timeoutMs: transport.timeoutMs,
|
|
22723
|
+
retry: "never",
|
|
22724
|
+
schema: dataForSeoSerpTaskPostResponseSchema,
|
|
22725
|
+
init: {
|
|
22726
|
+
method: "POST",
|
|
22727
|
+
headers: {
|
|
22728
|
+
authorization: transport.authorization,
|
|
22729
|
+
"content-type": "application/json"
|
|
22730
|
+
},
|
|
22731
|
+
body: JSON.stringify(requests)
|
|
22732
|
+
}
|
|
22733
|
+
});
|
|
22734
|
+
} catch (error) {
|
|
22735
|
+
finalizeProviderSpend(reservation.id, {
|
|
22736
|
+
provider: "dataforseo",
|
|
22737
|
+
state: "failed",
|
|
22738
|
+
actualCostMicros: null,
|
|
22739
|
+
returnedRows: null,
|
|
22740
|
+
taskIds: []
|
|
22741
|
+
}, {
|
|
22742
|
+
database: transport.database,
|
|
22743
|
+
limits: transport.spendLimits,
|
|
22744
|
+
now: transport.now().getTime()
|
|
22745
|
+
});
|
|
22746
|
+
throw error;
|
|
22747
|
+
}
|
|
22748
|
+
const taskReceipts = response.tasks.flatMap((task) => task.id && task.data?.tag ? [{
|
|
22749
|
+
providerTaskId: task.id,
|
|
22750
|
+
tag: task.data.tag
|
|
22751
|
+
}] : []);
|
|
22752
|
+
const taskIds = taskReceipts.map((receipt) => receipt.providerTaskId);
|
|
22753
|
+
const expectedTags = new Set(requests.map((request) => request.tag));
|
|
22754
|
+
const returnedTags = new Set(taskReceipts.map((receipt) => receipt.tag));
|
|
22755
|
+
const receiptsMatch = expectedTags.size === requests.length && returnedTags.size === expectedTags.size && [...expectedTags].every((tag) => returnedTags.has(tag));
|
|
22756
|
+
const failedTasks = response.tasks.filter((task) => task.status_code !== 20100 || !task.id || !task.data?.tag);
|
|
22757
|
+
const actualCostMicros = dataForSeoResponseCostMicros(response);
|
|
22758
|
+
const state = failedTasks.length === 0 && response.tasks_error === 0 ? "succeeded" : failedTasks.length < response.tasks.length ? "partial" : "failed";
|
|
22759
|
+
const spendNotice = finalizeProviderSpend(reservation.id, {
|
|
22760
|
+
provider: "dataforseo",
|
|
22761
|
+
state,
|
|
22762
|
+
actualCostMicros,
|
|
22763
|
+
returnedRows: taskIds.length,
|
|
22764
|
+
taskIds
|
|
22765
|
+
}, {
|
|
22766
|
+
database: transport.database,
|
|
22767
|
+
limits: transport.spendLimits,
|
|
22768
|
+
now: transport.now().getTime()
|
|
22769
|
+
});
|
|
22770
|
+
if (response.status_code !== 2e4 || failedTasks.length > 0 || taskIds.length !== requests.length || !receiptsMatch) {
|
|
22771
|
+
const statusCode = failedTasks[0]?.status_code ?? response.status_code;
|
|
22772
|
+
throw new ProviderError({
|
|
22773
|
+
provider: "dataforseo",
|
|
22774
|
+
operation: "serp-task-post",
|
|
22775
|
+
code: dataForSeoTaskErrorCode(statusCode),
|
|
22776
|
+
message: `DataForSEO accepted ${taskIds.length} of ${requests.length} queued SERP tasks (${statusCode}).`
|
|
22777
|
+
});
|
|
22778
|
+
}
|
|
22779
|
+
return {
|
|
22780
|
+
taskIds,
|
|
22781
|
+
taskReceipts,
|
|
22782
|
+
estimatedCostMicros,
|
|
22783
|
+
actualCostMicros,
|
|
22784
|
+
spendNotice,
|
|
22785
|
+
warnings: [],
|
|
22786
|
+
observedAt: transport.now().toISOString()
|
|
22787
|
+
};
|
|
22788
|
+
}
|
|
22789
|
+
async function listReadyDataForSeoSerpTasks(transport) {
|
|
22790
|
+
const response = await providerRequestJson({
|
|
22791
|
+
provider: "dataforseo",
|
|
22792
|
+
operation: "serp-tasks-ready",
|
|
22793
|
+
url: new URL(SERP_TASKS_READY_PATH, transport.baseUrl),
|
|
22794
|
+
fetch: transport.fetch,
|
|
22795
|
+
maxResponseBytes: transport.maxResponseBytes,
|
|
22796
|
+
timeoutMs: transport.timeoutMs,
|
|
22797
|
+
retry: "safe",
|
|
22798
|
+
schema: dataForSeoSerpTasksReadyResponseSchema,
|
|
22799
|
+
init: {
|
|
22800
|
+
method: "GET",
|
|
22801
|
+
headers: { authorization: transport.authorization }
|
|
22802
|
+
}
|
|
22803
|
+
});
|
|
22804
|
+
const failedTask = response.tasks.find((task) => task.status_code !== 2e4);
|
|
22805
|
+
if (response.status_code !== 2e4 || failedTask) {
|
|
22806
|
+
const statusCode = failedTask?.status_code ?? response.status_code;
|
|
22807
|
+
throw new ProviderError({
|
|
22808
|
+
provider: "dataforseo",
|
|
22809
|
+
operation: "serp-tasks-ready",
|
|
22810
|
+
code: dataForSeoTaskErrorCode(statusCode),
|
|
22811
|
+
message: `DataForSEO could not list ready SERP tasks (${statusCode}).`,
|
|
22812
|
+
retryable: statusCode === 40202
|
|
22813
|
+
});
|
|
22814
|
+
}
|
|
22815
|
+
return response.tasks.flatMap((task) => (task.result ?? []).map((result) => ({
|
|
22816
|
+
providerTaskId: result.id,
|
|
22817
|
+
tag: result.tag ?? null
|
|
22818
|
+
})));
|
|
22819
|
+
}
|
|
22820
|
+
async function getDataForSeoSerpTask(providerTaskId, transport) {
|
|
22821
|
+
const response = await providerRequestJson({
|
|
22822
|
+
provider: "dataforseo",
|
|
22823
|
+
operation: "serp-task-get",
|
|
22824
|
+
url: new URL(`${SERP_TASK_GET_ADVANCED_PATH}${providerTaskId}`, transport.baseUrl),
|
|
22825
|
+
fetch: transport.fetch,
|
|
22826
|
+
maxResponseBytes: transport.maxResponseBytes,
|
|
22827
|
+
timeoutMs: transport.timeoutMs,
|
|
22828
|
+
retry: "safe",
|
|
22829
|
+
schema: dataForSeoSerpResponseSchema,
|
|
22830
|
+
init: {
|
|
22831
|
+
method: "GET",
|
|
22832
|
+
headers: { authorization: transport.authorization }
|
|
22833
|
+
}
|
|
22834
|
+
});
|
|
22835
|
+
const failedTask = response.tasks.find((task) => task.status_code !== 2e4);
|
|
22836
|
+
if (response.status_code !== 2e4 || failedTask) {
|
|
22837
|
+
const statusCode = failedTask?.status_code ?? response.status_code;
|
|
22838
|
+
throw new ProviderError({
|
|
22839
|
+
provider: "dataforseo",
|
|
22840
|
+
operation: "serp-task-get",
|
|
22841
|
+
code: dataForSeoTaskErrorCode(statusCode),
|
|
22842
|
+
message: `DataForSEO could not collect queued SERP task ${providerTaskId} (${statusCode}).`,
|
|
22843
|
+
retryable: statusCode === 40202
|
|
22844
|
+
});
|
|
22845
|
+
}
|
|
22846
|
+
return {
|
|
22847
|
+
response,
|
|
22848
|
+
observedAt: transport.now().toISOString(),
|
|
22849
|
+
returnedRows: dataForSeoSerpRows(response),
|
|
22850
|
+
cache: {
|
|
22851
|
+
status: "bypass",
|
|
22852
|
+
storedAt: null,
|
|
22853
|
+
expiresAt: null
|
|
22854
|
+
},
|
|
22855
|
+
cost: {
|
|
22856
|
+
currency: "USD",
|
|
22857
|
+
estimatedMicros: 0,
|
|
22858
|
+
actualMicros: dataForSeoResponseCostMicros(response) ?? 0,
|
|
22859
|
+
taskIds: [providerTaskId]
|
|
22860
|
+
},
|
|
22861
|
+
spendNotice: null,
|
|
22862
|
+
warnings: []
|
|
22863
|
+
};
|
|
22864
|
+
}
|
|
22865
|
+
//#endregion
|
|
22463
22866
|
//#region packages/core/dist/providers/dataforseo/client.js
|
|
22464
22867
|
const DEFAULT_BASE_URL$1 = "https://api.dataforseo.com/";
|
|
22465
22868
|
const USER_DATA_PATH = "v3/appendix/user_data";
|
|
@@ -22482,9 +22885,43 @@ const MAX_KEYWORD_WORDS = 10;
|
|
|
22482
22885
|
const MAX_DISCOVERY_SEEDS$1 = 5;
|
|
22483
22886
|
const MAX_DISCOVERY_ROWS$1 = 100;
|
|
22484
22887
|
const MAX_SERP_DEPTH$1 = 100;
|
|
22485
|
-
function
|
|
22486
|
-
|
|
22487
|
-
|
|
22888
|
+
function validateSerpInput(input) {
|
|
22889
|
+
const keyword = input.keyword.trim().replace(/\s+/gu, " ");
|
|
22890
|
+
if (keyword.length < 1 || keyword.length > MAX_KEYWORD_CHARACTERS || keyword.split(/\s+/u).length > MAX_KEYWORD_WORDS) throw new ProviderError({
|
|
22891
|
+
provider: "dataforseo",
|
|
22892
|
+
operation: "serp-snapshot",
|
|
22893
|
+
code: "configuration",
|
|
22894
|
+
message: `SERP snapshots require a keyword of at most ${MAX_KEYWORD_CHARACTERS} characters and ${MAX_KEYWORD_WORDS} words.`
|
|
22895
|
+
});
|
|
22896
|
+
if (/(?:^|\s)(?:allinanchor|allintext|allintitle|allinurl|cache|define|definition|filetype|id|inanchor|info|intext|intitle|inurl|link|site):/iu.test(keyword)) throw new ProviderError({
|
|
22897
|
+
provider: "dataforseo",
|
|
22898
|
+
operation: "serp-snapshot",
|
|
22899
|
+
code: "configuration",
|
|
22900
|
+
message: "SERP snapshot keywords cannot contain search operators with multiplied provider pricing."
|
|
22901
|
+
});
|
|
22902
|
+
if (!Number.isSafeInteger(input.depth) || input.depth < 1 || input.depth > MAX_SERP_DEPTH$1) throw new ProviderError({
|
|
22903
|
+
provider: "dataforseo",
|
|
22904
|
+
operation: "serp-snapshot",
|
|
22905
|
+
code: "configuration",
|
|
22906
|
+
message: `SERP depth must be from 1 to ${MAX_SERP_DEPTH$1}.`
|
|
22907
|
+
});
|
|
22908
|
+
if (!/^[a-z]{2}$/.test(input.languageCode)) throw new ProviderError({
|
|
22909
|
+
provider: "dataforseo",
|
|
22910
|
+
operation: "serp-snapshot",
|
|
22911
|
+
code: "configuration",
|
|
22912
|
+
message: "DataForSEO language code must contain two lowercase letters."
|
|
22913
|
+
});
|
|
22914
|
+
const locationName = input.locationName?.trim();
|
|
22915
|
+
if (input.locationCode !== void 0 === Boolean(locationName)) throw new ProviderError({
|
|
22916
|
+
provider: "dataforseo",
|
|
22917
|
+
operation: "serp-snapshot",
|
|
22918
|
+
code: "configuration",
|
|
22919
|
+
message: "DataForSEO SERP snapshots require exactly one location code or location name."
|
|
22920
|
+
});
|
|
22921
|
+
return {
|
|
22922
|
+
keyword,
|
|
22923
|
+
locationName
|
|
22924
|
+
};
|
|
22488
22925
|
}
|
|
22489
22926
|
function unitPrice(components) {
|
|
22490
22927
|
const prices = components ?? [];
|
|
@@ -22510,19 +22947,12 @@ function keywordDiscoveryPrices(account) {
|
|
|
22510
22947
|
function serpLiveAdvancedPrice(account) {
|
|
22511
22948
|
return unitPrice(account.price?.serp?.live?.advanced?.priority_normal);
|
|
22512
22949
|
}
|
|
22513
|
-
function
|
|
22514
|
-
|
|
22515
|
-
if (statusCode === 40202) return "rate-limit";
|
|
22516
|
-
return "remote-error";
|
|
22950
|
+
function serpTaskPostPrice(account) {
|
|
22951
|
+
return unitPrice(account.price?.serp?.task_post?.priority_normal);
|
|
22517
22952
|
}
|
|
22518
22953
|
function responseTaskIds(response) {
|
|
22519
22954
|
return [...new Set(response.tasks.flatMap((task) => task.id ? [task.id] : []))].sort((left, right) => left < right ? -1 : left > right ? 1 : 0);
|
|
22520
22955
|
}
|
|
22521
|
-
function responseCostMicros(response) {
|
|
22522
|
-
if (response.cost !== void 0) return usdToMicros(response.cost);
|
|
22523
|
-
if (response.tasks.some((task) => task.cost === void 0)) return null;
|
|
22524
|
-
return response.tasks.reduce((sum, task) => sum + (usdToMicros(task.cost) ?? 0), 0);
|
|
22525
|
-
}
|
|
22526
22956
|
function responseRows$1(response) {
|
|
22527
22957
|
return response.tasks.reduce((taskTotal, task) => taskTotal + (task.result ?? []).reduce((resultTotal, result) => resultTotal + (result.items?.length ?? 0), 0), 0);
|
|
22528
22958
|
}
|
|
@@ -22537,9 +22967,6 @@ function discoveryNextCursor(response) {
|
|
|
22537
22967
|
const cursors = response.tasks.flatMap((task) => (task.result ?? []).flatMap((result) => result.offset_token ? [result.offset_token] : []));
|
|
22538
22968
|
return cursors.length === 1 ? cursors[0] ?? null : null;
|
|
22539
22969
|
}
|
|
22540
|
-
function serpRows(response) {
|
|
22541
|
-
return response.tasks.reduce((taskTotal, task) => taskTotal + (task.result ?? []).reduce((resultTotal, result) => resultTotal + (result.items?.length ?? 0), 0), 0);
|
|
22542
|
-
}
|
|
22543
22970
|
var DataForSeoClient = class {
|
|
22544
22971
|
fetch;
|
|
22545
22972
|
credentials;
|
|
@@ -22582,6 +23009,18 @@ var DataForSeoClient = class {
|
|
|
22582
23009
|
authorization(credentials) {
|
|
22583
23010
|
return `Basic ${Buffer.from(`${credentials.login}:${credentials.password}`).toString("base64")}`;
|
|
22584
23011
|
}
|
|
23012
|
+
serpTaskTransport(credentials) {
|
|
23013
|
+
return {
|
|
23014
|
+
authorization: this.authorization(credentials),
|
|
23015
|
+
baseUrl: this.baseUrl,
|
|
23016
|
+
fetch: this.fetch,
|
|
23017
|
+
maxResponseBytes: this.maxResponseBytes,
|
|
23018
|
+
timeoutMs: this.timeoutMs,
|
|
23019
|
+
now: this.now,
|
|
23020
|
+
database: this.database,
|
|
23021
|
+
spendLimits: this.spendLimits
|
|
23022
|
+
};
|
|
23023
|
+
}
|
|
22585
23024
|
async userDataForCredentials(credentials) {
|
|
22586
23025
|
let response;
|
|
22587
23026
|
try {
|
|
@@ -22616,7 +23055,7 @@ var DataForSeoClient = class {
|
|
|
22616
23055
|
throw new ProviderError({
|
|
22617
23056
|
provider: "dataforseo",
|
|
22618
23057
|
operation: "account-status",
|
|
22619
|
-
code:
|
|
23058
|
+
code: dataForSeoTaskErrorCode(statusCode),
|
|
22620
23059
|
message: `DataForSEO could not return account status (${statusCode}).`,
|
|
22621
23060
|
retryable: statusCode === 40202
|
|
22622
23061
|
});
|
|
@@ -22632,18 +23071,19 @@ var DataForSeoClient = class {
|
|
|
22632
23071
|
provider: "dataforseo",
|
|
22633
23072
|
login: account.login,
|
|
22634
23073
|
timezone: account.timezone ?? null,
|
|
22635
|
-
balanceMicros:
|
|
22636
|
-
depositedMicros:
|
|
22637
|
-
accountDailySpendMicros:
|
|
23074
|
+
balanceMicros: dataForSeoUsdToMicros(account.money?.balance),
|
|
23075
|
+
depositedMicros: dataForSeoUsdToMicros(account.money?.total),
|
|
23076
|
+
accountDailySpendMicros: dataForSeoUsdToMicros(account.money?.statistics?.day?.total),
|
|
22638
23077
|
accountDailySpendPeriod: account.money?.statistics?.day?.value ?? null,
|
|
22639
|
-
accountDailyLimitMicros:
|
|
23078
|
+
accountDailyLimitMicros: dataForSeoUsdToMicros(account.money?.limits?.day?.total),
|
|
22640
23079
|
keywordOverviewPrice: keywordOverviewPrice(account),
|
|
22641
23080
|
keywordDiscoveryPrices: keywordDiscoveryPrices(account),
|
|
22642
23081
|
serpLiveAdvancedPrice: serpLiveAdvancedPrice(account),
|
|
23082
|
+
serpTaskPostPrice: serpTaskPostPrice(account),
|
|
22643
23083
|
backlinksSubscriptionExpiresAt: account.backlinks_subscription_expiry_date ?? null,
|
|
22644
23084
|
aiMentionsSubscriptionExpiresAt: account.llm_mentions_subscription_expiry_date ?? null,
|
|
22645
23085
|
apiVersion: response.version ?? null,
|
|
22646
|
-
requestCostMicros:
|
|
23086
|
+
requestCostMicros: dataForSeoUsdToMicros(response.cost) ?? 0,
|
|
22647
23087
|
taskIds: response.tasks.flatMap((task) => task.id ? [task.id] : []),
|
|
22648
23088
|
observedAt: this.now().toISOString()
|
|
22649
23089
|
};
|
|
@@ -22819,7 +23259,7 @@ var DataForSeoClient = class {
|
|
|
22819
23259
|
});
|
|
22820
23260
|
throw error;
|
|
22821
23261
|
}
|
|
22822
|
-
const actualCostMicros =
|
|
23262
|
+
const actualCostMicros = dataForSeoResponseCostMicros(response);
|
|
22823
23263
|
const returnedRows = responseRows$1(response);
|
|
22824
23264
|
const taskIds = responseTaskIds(response);
|
|
22825
23265
|
const failedTasks = response.tasks.filter((task) => task.status_code !== 2e4);
|
|
@@ -22841,7 +23281,7 @@ var DataForSeoClient = class {
|
|
|
22841
23281
|
throw new ProviderError({
|
|
22842
23282
|
provider: "dataforseo",
|
|
22843
23283
|
operation: "keyword-metrics",
|
|
22844
|
-
code:
|
|
23284
|
+
code: dataForSeoTaskErrorCode(statusCode),
|
|
22845
23285
|
message: `DataForSEO could not complete the keyword metrics task (${statusCode}).`,
|
|
22846
23286
|
retryable: statusCode === 40202
|
|
22847
23287
|
});
|
|
@@ -22953,38 +23393,7 @@ var DataForSeoClient = class {
|
|
|
22953
23393
|
};
|
|
22954
23394
|
}
|
|
22955
23395
|
async serpLive(input) {
|
|
22956
|
-
const keyword = input
|
|
22957
|
-
if (keyword.length < 1 || keyword.length > MAX_KEYWORD_CHARACTERS || keyword.split(/\s+/u).length > MAX_KEYWORD_WORDS) throw new ProviderError({
|
|
22958
|
-
provider: "dataforseo",
|
|
22959
|
-
operation: "serp-snapshot",
|
|
22960
|
-
code: "configuration",
|
|
22961
|
-
message: `SERP snapshots require a keyword of at most ${MAX_KEYWORD_CHARACTERS} characters and ${MAX_KEYWORD_WORDS} words.`
|
|
22962
|
-
});
|
|
22963
|
-
if (/(?:^|\s)(?:allinanchor|allintext|allintitle|allinurl|cache|define|definition|filetype|id|inanchor|info|intext|intitle|inurl|link|site):/iu.test(keyword)) throw new ProviderError({
|
|
22964
|
-
provider: "dataforseo",
|
|
22965
|
-
operation: "serp-snapshot",
|
|
22966
|
-
code: "configuration",
|
|
22967
|
-
message: "SERP snapshot keywords cannot contain search operators with multiplied provider pricing."
|
|
22968
|
-
});
|
|
22969
|
-
if (!Number.isSafeInteger(input.depth) || input.depth < 1 || input.depth > MAX_SERP_DEPTH$1) throw new ProviderError({
|
|
22970
|
-
provider: "dataforseo",
|
|
22971
|
-
operation: "serp-snapshot",
|
|
22972
|
-
code: "configuration",
|
|
22973
|
-
message: `SERP depth must be from 1 to ${MAX_SERP_DEPTH$1}.`
|
|
22974
|
-
});
|
|
22975
|
-
if (!/^[a-z]{2}$/.test(input.languageCode)) throw new ProviderError({
|
|
22976
|
-
provider: "dataforseo",
|
|
22977
|
-
operation: "serp-snapshot",
|
|
22978
|
-
code: "configuration",
|
|
22979
|
-
message: "DataForSEO language code must contain two lowercase letters."
|
|
22980
|
-
});
|
|
22981
|
-
const locationName = input.locationName?.trim();
|
|
22982
|
-
if (input.locationCode !== void 0 === Boolean(locationName)) throw new ProviderError({
|
|
22983
|
-
provider: "dataforseo",
|
|
22984
|
-
operation: "serp-snapshot",
|
|
22985
|
-
code: "configuration",
|
|
22986
|
-
message: "DataForSEO SERP snapshots require exactly one location code or location name."
|
|
22987
|
-
});
|
|
23396
|
+
const { keyword, locationName } = validateSerpInput(input);
|
|
22988
23397
|
const request = {
|
|
22989
23398
|
keyword,
|
|
22990
23399
|
language_code: input.languageCode,
|
|
@@ -23005,9 +23414,56 @@ var DataForSeoClient = class {
|
|
|
23005
23414
|
context: input.context,
|
|
23006
23415
|
ttlMs: this.serpTtlMs,
|
|
23007
23416
|
refresh: input.refresh,
|
|
23008
|
-
rowCount:
|
|
23417
|
+
rowCount: dataForSeoSerpRows
|
|
23009
23418
|
});
|
|
23010
23419
|
}
|
|
23420
|
+
async serpTaskPost(input) {
|
|
23421
|
+
if (input.tasks.length < 1 || input.tasks.length > 100) throw new ProviderError({
|
|
23422
|
+
provider: "dataforseo",
|
|
23423
|
+
operation: "serp-task-post",
|
|
23424
|
+
code: "configuration",
|
|
23425
|
+
message: `A queued SERP post requires 1 to 100 tasks.`
|
|
23426
|
+
});
|
|
23427
|
+
const requests = input.tasks.map((task) => {
|
|
23428
|
+
const validated = validateSerpInput(task);
|
|
23429
|
+
return {
|
|
23430
|
+
keyword: validated.keyword,
|
|
23431
|
+
language_code: task.languageCode,
|
|
23432
|
+
...task.locationCode !== void 0 ? { location_code: task.locationCode } : { location_name: validated.locationName },
|
|
23433
|
+
device: task.device,
|
|
23434
|
+
depth: task.depth,
|
|
23435
|
+
tag: task.tag,
|
|
23436
|
+
priority: 1,
|
|
23437
|
+
remove_from_url: ["srsltid"]
|
|
23438
|
+
};
|
|
23439
|
+
});
|
|
23440
|
+
const credentials = await this.getCredentials("serp-task-post");
|
|
23441
|
+
const credentialScope = providerCredentialScope("dataforseo", credentials.login);
|
|
23442
|
+
const price = (await this.pricingForCredentials(credentials, credentialScope)).serpTaskPostPrice;
|
|
23443
|
+
return postDataForSeoSerpTasks({
|
|
23444
|
+
requests,
|
|
23445
|
+
context: input.context,
|
|
23446
|
+
price
|
|
23447
|
+
}, {
|
|
23448
|
+
authorization: this.authorization(credentials),
|
|
23449
|
+
baseUrl: this.baseUrl,
|
|
23450
|
+
fetch: this.fetch,
|
|
23451
|
+
maxResponseBytes: this.maxResponseBytes,
|
|
23452
|
+
timeoutMs: this.timeoutMs,
|
|
23453
|
+
now: this.now,
|
|
23454
|
+
database: this.database,
|
|
23455
|
+
spendLimits: this.spendLimits
|
|
23456
|
+
});
|
|
23457
|
+
}
|
|
23458
|
+
async serpTasksReady() {
|
|
23459
|
+
const credentials = await this.getCredentials("serp-tasks-ready");
|
|
23460
|
+
return listReadyDataForSeoSerpTasks(this.serpTaskTransport(credentials));
|
|
23461
|
+
}
|
|
23462
|
+
async serpTaskGet(providerTaskId) {
|
|
23463
|
+
validateDataForSeoSerpTaskId(providerTaskId);
|
|
23464
|
+
const credentials = await this.getCredentials("serp-task-get");
|
|
23465
|
+
return getDataForSeoSerpTask(providerTaskId, this.serpTaskTransport(credentials));
|
|
23466
|
+
}
|
|
23011
23467
|
};
|
|
23012
23468
|
//#endregion
|
|
23013
23469
|
//#region packages/core/dist/providers/dataforseo/keyword-mapping.js
|
|
@@ -23139,13 +23595,55 @@ var DataForSeoKeywordMetricsProvider = class {
|
|
|
23139
23595
|
status: "available",
|
|
23140
23596
|
markets: [{
|
|
23141
23597
|
searchEngines: ["google"],
|
|
23142
|
-
location: "
|
|
23598
|
+
location: "country-only"
|
|
23143
23599
|
}]
|
|
23144
23600
|
}];
|
|
23145
23601
|
client;
|
|
23146
23602
|
constructor(options = {}) {
|
|
23147
23603
|
this.client = options.client ?? new DataForSeoClient(options);
|
|
23148
23604
|
}
|
|
23605
|
+
async estimateKeywordMetricsCost(input) {
|
|
23606
|
+
if (!Number.isSafeInteger(input.requestedRows) || input.requestedRows < 1 || input.requestedRows > 1e4) throw new ProviderError({
|
|
23607
|
+
provider: "dataforseo",
|
|
23608
|
+
operation: "keyword-metrics-cost",
|
|
23609
|
+
code: "configuration",
|
|
23610
|
+
message: "Keyword metric cost estimates require 1 to 10000 rows."
|
|
23611
|
+
});
|
|
23612
|
+
if (!searchMarketSchema.safeParse(input.market).success) throw new ProviderError({
|
|
23613
|
+
provider: "dataforseo",
|
|
23614
|
+
operation: "keyword-metrics-cost",
|
|
23615
|
+
code: "configuration",
|
|
23616
|
+
message: "Keyword metric cost estimates require a valid market."
|
|
23617
|
+
});
|
|
23618
|
+
if (input.market.location) throw new ProviderError({
|
|
23619
|
+
provider: "dataforseo",
|
|
23620
|
+
operation: "keyword-metrics-cost",
|
|
23621
|
+
code: "configuration",
|
|
23622
|
+
message: "DataForSEO Labs keyword metrics supports country-level markets; omit market.location and use countryCode."
|
|
23623
|
+
});
|
|
23624
|
+
if (!this.client.userData) throw new ProviderError({
|
|
23625
|
+
provider: "dataforseo",
|
|
23626
|
+
operation: "keyword-metrics-cost",
|
|
23627
|
+
code: "configuration",
|
|
23628
|
+
message: "Keyword metric cost estimation is unavailable."
|
|
23629
|
+
});
|
|
23630
|
+
const account = await this.client.userData();
|
|
23631
|
+
const { perRequestMicros, perResultMicros } = account.keywordOverviewPrice;
|
|
23632
|
+
const requestCount = Math.ceil(input.requestedRows / 50);
|
|
23633
|
+
const complete = perRequestMicros !== null && perResultMicros !== null;
|
|
23634
|
+
return {
|
|
23635
|
+
schemaVersion: 1,
|
|
23636
|
+
provider: "dataforseo",
|
|
23637
|
+
capability: "keyword-metrics",
|
|
23638
|
+
currency: "USD",
|
|
23639
|
+
requestedRows: input.requestedRows,
|
|
23640
|
+
requestCount,
|
|
23641
|
+
estimatedMicros: complete ? requestCount * perRequestMicros + input.requestedRows * perResultMicros : null,
|
|
23642
|
+
observedAt: account.observedAt,
|
|
23643
|
+
completeness: complete ? "complete" : "unavailable",
|
|
23644
|
+
basis: complete ? "Current account pricing for normal-priority live keyword overview requests." : "The provider account response did not include complete keyword overview pricing."
|
|
23645
|
+
};
|
|
23646
|
+
}
|
|
23149
23647
|
async keywordMetrics(input) {
|
|
23150
23648
|
const market = searchMarketSchema.parse(input.market);
|
|
23151
23649
|
if (market.searchEngine !== "google") throw new ProviderError({
|
|
@@ -23154,6 +23652,12 @@ var DataForSeoKeywordMetricsProvider = class {
|
|
|
23154
23652
|
code: "configuration",
|
|
23155
23653
|
message: "DataForSEO keyword metrics currently supports Google markets."
|
|
23156
23654
|
});
|
|
23655
|
+
if (market.location) throw new ProviderError({
|
|
23656
|
+
provider: "dataforseo",
|
|
23657
|
+
operation: "keyword-metrics",
|
|
23658
|
+
code: "configuration",
|
|
23659
|
+
message: "DataForSEO Labs keyword metrics supports country-level markets; omit market.location and use countryCode."
|
|
23660
|
+
});
|
|
23157
23661
|
if (input.keywords.length < 1 || input.keywords.length > MAX_KEYWORDS_PER_REPORT || input.keywords.some((keyword) => {
|
|
23158
23662
|
const normalized = keyword.trim();
|
|
23159
23663
|
return normalized.length < 1 || normalized.length > 80 || normalized.split(/\s+/u).length > 10;
|
|
@@ -23369,6 +23873,7 @@ async function defaultCandidates$2() {
|
|
|
23369
23873
|
}
|
|
23370
23874
|
function providerResolutionError(input) {
|
|
23371
23875
|
if (input.reason === "provider-not-connected") return new SeoError("PROVIDER_UNAVAILABLE", "No connected provider can supply keyword metrics. Run `seo providers dataforseo connect` first.");
|
|
23876
|
+
if (input.provider && input.reason === "market-not-supported") return new SeoError("INVALID_INPUT", input.provider === "dataforseo" ? "DataForSEO keyword metrics uses country-level Labs data. Omit location and keep countryCode, then run serp-results separately for a local snapshot." : `${input.provider} cannot supply keyword metrics for this market.`);
|
|
23372
23877
|
if (input.provider) return new SeoError("PROVIDER_UNAVAILABLE", `${input.provider} cannot supply keyword metrics for this market.`);
|
|
23373
23878
|
return new SeoError("PROVIDER_UNAVAILABLE", "No configured provider can supply keyword metrics for this market.");
|
|
23374
23879
|
}
|
|
@@ -24242,7 +24747,7 @@ function pageItem(url, rows) {
|
|
|
24242
24747
|
recommendation: nextRecommendation
|
|
24243
24748
|
};
|
|
24244
24749
|
}
|
|
24245
|
-
function compareItems(left, right) {
|
|
24750
|
+
function compareItems$1(left, right) {
|
|
24246
24751
|
return right.priority.score - left.priority.score || right.impressions - left.impressions || left.position - right.position || compareText$4(left.url, right.url);
|
|
24247
24752
|
}
|
|
24248
24753
|
function analyzeSecondPageRows(input) {
|
|
@@ -24287,7 +24792,7 @@ function analyzeSecondPageRows(input) {
|
|
|
24287
24792
|
if (item.impressions >= minImpressions) return true;
|
|
24288
24793
|
selection.belowMinimumPages++;
|
|
24289
24794
|
return false;
|
|
24290
|
-
}).sort(compareItems);
|
|
24795
|
+
}).sort(compareItems$1);
|
|
24291
24796
|
const items = eligibleItems.slice(0, limit);
|
|
24292
24797
|
selection.eligiblePages = eligibleItems.length;
|
|
24293
24798
|
selection.returnedPages = items.length;
|
|
@@ -24970,7 +25475,7 @@ var DataForSeoKeywordDiscoveryProvider = class {
|
|
|
24970
25475
|
status: "available",
|
|
24971
25476
|
markets: [{
|
|
24972
25477
|
searchEngines: ["google"],
|
|
24973
|
-
location: "
|
|
25478
|
+
location: "country-only"
|
|
24974
25479
|
}]
|
|
24975
25480
|
}];
|
|
24976
25481
|
client;
|
|
@@ -24985,6 +25490,12 @@ var DataForSeoKeywordDiscoveryProvider = class {
|
|
|
24985
25490
|
code: "configuration",
|
|
24986
25491
|
message: "DataForSEO keyword discovery currently supports Google."
|
|
24987
25492
|
});
|
|
25493
|
+
if (market.location) throw new ProviderError({
|
|
25494
|
+
provider: "dataforseo",
|
|
25495
|
+
operation: "keyword-discovery",
|
|
25496
|
+
code: "configuration",
|
|
25497
|
+
message: "DataForSEO Labs keyword discovery supports country-level markets; omit market.location and use countryCode."
|
|
25498
|
+
});
|
|
24988
25499
|
const seeds = [...new Set(input.seeds.map(normalizedKeyword$1))].filter(Boolean).sort(compareCodepoints$3);
|
|
24989
25500
|
if (seeds.length < 1 || seeds.length > MAX_DISCOVERY_SEEDS || seeds.some((seed) => seed.length > 80 || seed.split(/\s+/u).length > 10)) throw new ProviderError({
|
|
24990
25501
|
provider: "dataforseo",
|
|
@@ -25107,7 +25618,7 @@ async function defaultCandidates$1() {
|
|
|
25107
25618
|
priority: 10
|
|
25108
25619
|
}];
|
|
25109
25620
|
}
|
|
25110
|
-
function providerError$
|
|
25621
|
+
function providerError$2(error) {
|
|
25111
25622
|
if (!(error instanceof ProviderError)) throw error;
|
|
25112
25623
|
if (error.code === "rate-limit") throw new SeoError("RATE_LIMITED", error.message);
|
|
25113
25624
|
if (error.code === "configuration") throw new SeoError("INVALID_INPUT", error.message);
|
|
@@ -25175,7 +25686,7 @@ async function keywordResearchReport(input, dependencies = {}) {
|
|
|
25175
25686
|
try {
|
|
25176
25687
|
candidates = dependencies.candidates ?? await defaultCandidates$1();
|
|
25177
25688
|
} catch (error) {
|
|
25178
|
-
return providerError$
|
|
25689
|
+
return providerError$2(error);
|
|
25179
25690
|
}
|
|
25180
25691
|
const resolution = resolveProvider({
|
|
25181
25692
|
capability: "keyword-discovery",
|
|
@@ -25183,7 +25694,10 @@ async function keywordResearchReport(input, dependencies = {}) {
|
|
|
25183
25694
|
candidates,
|
|
25184
25695
|
provider: validated.provider
|
|
25185
25696
|
});
|
|
25186
|
-
if (resolution.status === "unavailable")
|
|
25697
|
+
if (resolution.status === "unavailable") {
|
|
25698
|
+
const message = resolution.reason === "provider-not-connected" ? "No connected provider can discover keywords. Run `seo providers dataforseo connect` first." : validated.provider ? `${validated.provider} cannot discover keywords for this market.` : "No configured provider can discover keywords for this market.";
|
|
25699
|
+
throw new SeoError(validated.provider && resolution.reason === "market-not-supported" ? "INVALID_INPUT" : "PROVIDER_UNAVAILABLE", validated.provider === "dataforseo" && resolution.reason === "market-not-supported" ? "DataForSEO keyword discovery uses country-level Labs data. Omit location and keep countryCode, then run serp-results separately for a local snapshot." : message);
|
|
25700
|
+
}
|
|
25187
25701
|
const provider = discoveryProvider(resolution.provider);
|
|
25188
25702
|
if (!provider) throw new SeoError("PROVIDER_UNAVAILABLE", "The selected provider has no keyword discovery implementation.");
|
|
25189
25703
|
let evidence;
|
|
@@ -25201,7 +25715,7 @@ async function keywordResearchReport(input, dependencies = {}) {
|
|
|
25201
25715
|
}
|
|
25202
25716
|
});
|
|
25203
25717
|
} catch (error) {
|
|
25204
|
-
return providerError$
|
|
25718
|
+
return providerError$2(error);
|
|
25205
25719
|
}
|
|
25206
25720
|
const analysis = evidence.data.map((idea) => ({
|
|
25207
25721
|
keyword: idea.keyword,
|
|
@@ -29747,6 +30261,96 @@ function checkedAt(value, fallback) {
|
|
|
29747
30261
|
const timestamp = Date.parse(value);
|
|
29748
30262
|
return Number.isFinite(timestamp) ? new Date(timestamp).toISOString() : fallback;
|
|
29749
30263
|
}
|
|
30264
|
+
function mapDataForSeoSerpSnapshot(input, snapshot, endpoint = SERP_ENDPOINT) {
|
|
30265
|
+
const market = searchMarketSchema.parse(input.market);
|
|
30266
|
+
const keyword = normalizedKeyword$1(input.keyword);
|
|
30267
|
+
const location = locationRequest(market, "serp-snapshot");
|
|
30268
|
+
const result = snapshot.response.tasks.flatMap((task) => task.result ?? []).find((item) => normalizedKeyword$1(item.keyword) === keyword);
|
|
30269
|
+
if (!result) throw new ProviderError({
|
|
30270
|
+
provider: "dataforseo",
|
|
30271
|
+
operation: "serp-snapshot",
|
|
30272
|
+
code: "invalid-response",
|
|
30273
|
+
message: "DataForSEO returned no matching SERP result."
|
|
30274
|
+
});
|
|
30275
|
+
const warnings = [...snapshot.warnings];
|
|
30276
|
+
const items = result.items ?? [];
|
|
30277
|
+
const organicItems = items.filter((item) => item.type === "organic");
|
|
30278
|
+
const mappedOrganicResults = organicItems.flatMap((item) => {
|
|
30279
|
+
const mapped = organicResult(item);
|
|
30280
|
+
return mapped ? [mapped] : [];
|
|
30281
|
+
}).sort((left, right) => left.rankAbsolute - right.rankAbsolute || left.rankGroup - right.rankGroup || compareCodepoints$3(left.url, right.url));
|
|
30282
|
+
const organicResults = mappedOrganicResults.slice(0, input.depth);
|
|
30283
|
+
const invalidRows = organicItems.length - mappedOrganicResults.length;
|
|
30284
|
+
const resultCapped = mappedOrganicResults.length > input.depth;
|
|
30285
|
+
const invalidResultCount = result.se_results_count !== null && result.se_results_count !== void 0 && result.se_results_count < organicResults.length;
|
|
30286
|
+
if (invalidRows > 0) warnings.push({
|
|
30287
|
+
code: "invalid-organic-results",
|
|
30288
|
+
field: "organicResults",
|
|
30289
|
+
message: `DataForSEO returned ${invalidRows} organic result${invalidRows === 1 ? "" : "s"} without a valid rank, domain, or URL.`
|
|
30290
|
+
});
|
|
30291
|
+
if (!Number.isFinite(Date.parse(result.datetime))) warnings.push({
|
|
30292
|
+
code: "invalid-serp-observation-time",
|
|
30293
|
+
field: "checkedAt",
|
|
30294
|
+
message: "DataForSEO returned an invalid SERP observation time; the local response time was retained."
|
|
30295
|
+
});
|
|
30296
|
+
const checkUrl = safeUrl(result.check_url);
|
|
30297
|
+
if (result.check_url && !checkUrl) warnings.push({
|
|
30298
|
+
code: "invalid-serp-check-url",
|
|
30299
|
+
field: "checkUrl",
|
|
30300
|
+
message: "DataForSEO returned an invalid SERP check URL."
|
|
30301
|
+
});
|
|
30302
|
+
if (invalidResultCount) warnings.push({
|
|
30303
|
+
code: "invalid-serp-result-count",
|
|
30304
|
+
field: "resultCount",
|
|
30305
|
+
message: "DataForSEO returned an estimated result count below the retained organic result count; the estimate was discarded."
|
|
30306
|
+
});
|
|
30307
|
+
const features = [.../* @__PURE__ */ new Set([...result.item_types ?? [], ...items.map((item) => item.type)])].sort(compareCodepoints$3);
|
|
30308
|
+
return {
|
|
30309
|
+
schemaVersion: 1,
|
|
30310
|
+
provider: "dataforseo",
|
|
30311
|
+
capability: "serp-snapshot",
|
|
30312
|
+
data: {
|
|
30313
|
+
keyword,
|
|
30314
|
+
effectiveKeyword: normalizedKeyword$1(result.spell?.keyword ?? result.keyword),
|
|
30315
|
+
searchEngineDomain: result.se_domain?.trim().toLowerCase() ?? null,
|
|
30316
|
+
checkedAt: checkedAt(result.datetime, snapshot.observedAt),
|
|
30317
|
+
checkUrl,
|
|
30318
|
+
resultCount: invalidResultCount ? null : result.se_results_count ?? null,
|
|
30319
|
+
pagesCount: result.pages_count === void 0 ? null : result.pages_count,
|
|
30320
|
+
features,
|
|
30321
|
+
organicResults
|
|
30322
|
+
},
|
|
30323
|
+
observedAt: snapshot.observedAt,
|
|
30324
|
+
market: {
|
|
30325
|
+
...market,
|
|
30326
|
+
device: market.device ?? "desktop"
|
|
30327
|
+
},
|
|
30328
|
+
coverage: {
|
|
30329
|
+
requestedRows: input.depth,
|
|
30330
|
+
returnedRows: snapshot.returnedRows,
|
|
30331
|
+
retainedRows: organicResults.length,
|
|
30332
|
+
invalidRows,
|
|
30333
|
+
providerTotalRows: invalidResultCount ? null : result.se_results_count ?? null,
|
|
30334
|
+
completeness: invalidRows > 0 || invalidResultCount ? "partial" : resultCapped ? "capped" : "complete",
|
|
30335
|
+
nextCursor: null
|
|
30336
|
+
},
|
|
30337
|
+
cache: snapshot.cache,
|
|
30338
|
+
cost: snapshot.cost,
|
|
30339
|
+
request: {
|
|
30340
|
+
operation: "serp-snapshot",
|
|
30341
|
+
endpoint,
|
|
30342
|
+
limit: input.depth,
|
|
30343
|
+
filters: {
|
|
30344
|
+
countryCode: market.countryCode,
|
|
30345
|
+
languageCode: market.languageCode,
|
|
30346
|
+
device: market.device ?? "desktop",
|
|
30347
|
+
...location.locationCode !== void 0 ? { locationCode: location.locationCode } : { locationName: location.locationName }
|
|
30348
|
+
},
|
|
30349
|
+
sort: ["rankAbsolute:ascending", "url:codepoint-ascending"]
|
|
30350
|
+
},
|
|
30351
|
+
warnings
|
|
30352
|
+
};
|
|
30353
|
+
}
|
|
29750
30354
|
var DataForSeoSerpSnapshotProvider = class {
|
|
29751
30355
|
provider = "dataforseo";
|
|
29752
30356
|
capabilitySupport = [{
|
|
@@ -29788,7 +30392,7 @@ var DataForSeoSerpSnapshotProvider = class {
|
|
|
29788
30392
|
reportRunId: randomUUID()
|
|
29789
30393
|
};
|
|
29790
30394
|
const location = locationRequest(market, "serp-snapshot");
|
|
29791
|
-
|
|
30395
|
+
return mapDataForSeoSerpSnapshot(input, await this.client.serpLive({
|
|
29792
30396
|
keyword,
|
|
29793
30397
|
languageCode: market.languageCode.split("-")[0],
|
|
29794
30398
|
...location,
|
|
@@ -29796,86 +30400,7 @@ var DataForSeoSerpSnapshotProvider = class {
|
|
|
29796
30400
|
depth: input.depth,
|
|
29797
30401
|
refresh: input.refresh,
|
|
29798
30402
|
context
|
|
29799
|
-
});
|
|
29800
|
-
const result = snapshot.response.tasks.flatMap((task) => task.result ?? []).find((item) => normalizedKeyword$1(item.keyword) === keyword);
|
|
29801
|
-
if (!result) throw new ProviderError({
|
|
29802
|
-
provider: "dataforseo",
|
|
29803
|
-
operation: "serp-snapshot",
|
|
29804
|
-
code: "invalid-response",
|
|
29805
|
-
message: "DataForSEO returned no matching SERP result."
|
|
29806
|
-
});
|
|
29807
|
-
const warnings = [...snapshot.warnings];
|
|
29808
|
-
const items = result.items ?? [];
|
|
29809
|
-
const organicItems = items.filter((item) => item.type === "organic");
|
|
29810
|
-
const mappedOrganicResults = organicItems.flatMap((item) => {
|
|
29811
|
-
const mapped = organicResult(item);
|
|
29812
|
-
return mapped ? [mapped] : [];
|
|
29813
|
-
}).sort((left, right) => left.rankAbsolute - right.rankAbsolute || left.rankGroup - right.rankGroup || compareCodepoints$3(left.url, right.url));
|
|
29814
|
-
const organicResults = mappedOrganicResults.slice(0, input.depth);
|
|
29815
|
-
const invalidRows = organicItems.length - mappedOrganicResults.length;
|
|
29816
|
-
const resultCapped = mappedOrganicResults.length > input.depth;
|
|
29817
|
-
if (invalidRows > 0) warnings.push({
|
|
29818
|
-
code: "invalid-organic-results",
|
|
29819
|
-
field: "organicResults",
|
|
29820
|
-
message: `DataForSEO returned ${invalidRows} organic result${invalidRows === 1 ? "" : "s"} without a valid rank, domain, or URL.`
|
|
29821
|
-
});
|
|
29822
|
-
if (!Number.isFinite(Date.parse(result.datetime))) warnings.push({
|
|
29823
|
-
code: "invalid-serp-observation-time",
|
|
29824
|
-
field: "checkedAt",
|
|
29825
|
-
message: "DataForSEO returned an invalid SERP observation time; the local response time was retained."
|
|
29826
|
-
});
|
|
29827
|
-
const checkUrl = safeUrl(result.check_url);
|
|
29828
|
-
if (result.check_url && !checkUrl) warnings.push({
|
|
29829
|
-
code: "invalid-serp-check-url",
|
|
29830
|
-
field: "checkUrl",
|
|
29831
|
-
message: "DataForSEO returned an invalid SERP check URL."
|
|
29832
|
-
});
|
|
29833
|
-
const features = [.../* @__PURE__ */ new Set([...result.item_types ?? [], ...items.map((item) => item.type)])].sort(compareCodepoints$3);
|
|
29834
|
-
return {
|
|
29835
|
-
schemaVersion: 1,
|
|
29836
|
-
provider: "dataforseo",
|
|
29837
|
-
capability: "serp-snapshot",
|
|
29838
|
-
data: {
|
|
29839
|
-
keyword,
|
|
29840
|
-
effectiveKeyword: normalizedKeyword$1(result.spell?.keyword ?? result.keyword),
|
|
29841
|
-
searchEngineDomain: result.se_domain?.trim().toLowerCase() ?? null,
|
|
29842
|
-
checkedAt: checkedAt(result.datetime, snapshot.observedAt),
|
|
29843
|
-
checkUrl,
|
|
29844
|
-
resultCount: result.se_results_count === void 0 ? null : result.se_results_count,
|
|
29845
|
-
pagesCount: result.pages_count === void 0 ? null : result.pages_count,
|
|
29846
|
-
features,
|
|
29847
|
-
organicResults
|
|
29848
|
-
},
|
|
29849
|
-
observedAt: snapshot.observedAt,
|
|
29850
|
-
market: {
|
|
29851
|
-
...market,
|
|
29852
|
-
device: market.device ?? "desktop"
|
|
29853
|
-
},
|
|
29854
|
-
coverage: {
|
|
29855
|
-
requestedRows: input.depth,
|
|
29856
|
-
returnedRows: snapshot.returnedRows,
|
|
29857
|
-
retainedRows: organicResults.length,
|
|
29858
|
-
invalidRows,
|
|
29859
|
-
providerTotalRows: result.se_results_count ?? null,
|
|
29860
|
-
completeness: invalidRows > 0 ? "partial" : resultCapped ? "capped" : "complete",
|
|
29861
|
-
nextCursor: null
|
|
29862
|
-
},
|
|
29863
|
-
cache: snapshot.cache,
|
|
29864
|
-
cost: snapshot.cost,
|
|
29865
|
-
request: {
|
|
29866
|
-
operation: "serp-snapshot",
|
|
29867
|
-
endpoint: SERP_ENDPOINT,
|
|
29868
|
-
limit: input.depth,
|
|
29869
|
-
filters: {
|
|
29870
|
-
countryCode: market.countryCode,
|
|
29871
|
-
languageCode: market.languageCode,
|
|
29872
|
-
device: market.device ?? "desktop",
|
|
29873
|
-
...location.locationCode !== void 0 ? { locationCode: location.locationCode } : { locationName: location.locationName }
|
|
29874
|
-
},
|
|
29875
|
-
sort: ["rankAbsolute:ascending", "url:codepoint-ascending"]
|
|
29876
|
-
},
|
|
29877
|
-
warnings
|
|
29878
|
-
};
|
|
30403
|
+
}));
|
|
29879
30404
|
}
|
|
29880
30405
|
};
|
|
29881
30406
|
//#endregion
|
|
@@ -29894,7 +30419,7 @@ async function defaultCandidates() {
|
|
|
29894
30419
|
priority: 10
|
|
29895
30420
|
}];
|
|
29896
30421
|
}
|
|
29897
|
-
function providerError(error) {
|
|
30422
|
+
function providerError$1(error) {
|
|
29898
30423
|
if (!(error instanceof ProviderError)) throw error;
|
|
29899
30424
|
if (error.code === "rate-limit") throw new SeoError("RATE_LIMITED", error.message);
|
|
29900
30425
|
if (error.code === "configuration") throw new SeoError("INVALID_INPUT", error.message);
|
|
@@ -29956,7 +30481,7 @@ async function serpResultsReport(input, dependencies = {}) {
|
|
|
29956
30481
|
try {
|
|
29957
30482
|
candidates = dependencies.candidates ?? await defaultCandidates();
|
|
29958
30483
|
} catch (error) {
|
|
29959
|
-
return providerError(error);
|
|
30484
|
+
return providerError$1(error);
|
|
29960
30485
|
}
|
|
29961
30486
|
const resolution = resolveProvider({
|
|
29962
30487
|
capability: "serp-snapshot",
|
|
@@ -29981,7 +30506,7 @@ async function serpResultsReport(input, dependencies = {}) {
|
|
|
29981
30506
|
}
|
|
29982
30507
|
});
|
|
29983
30508
|
} catch (error) {
|
|
29984
|
-
return providerError(error);
|
|
30509
|
+
return providerError$1(error);
|
|
29985
30510
|
}
|
|
29986
30511
|
const domains = domainSummaries(evidence.data);
|
|
29987
30512
|
const dataStatus = reportStatus$1(evidence);
|
|
@@ -30231,6 +30756,1110 @@ async function pseoOpportunitiesReport(input, dependencies = {}) {
|
|
|
30231
30756
|
};
|
|
30232
30757
|
}
|
|
30233
30758
|
//#endregion
|
|
30759
|
+
//#region packages/core/dist/keyword-sets/limits.js
|
|
30760
|
+
const KEYWORD_SET_LIMITS = {
|
|
30761
|
+
setsPerProject: 25,
|
|
30762
|
+
totalSets: 100,
|
|
30763
|
+
keywordsPerSet: 1e4,
|
|
30764
|
+
totalKeywords: 1e5,
|
|
30765
|
+
mutationKeywords: 1e3,
|
|
30766
|
+
tagsPerKeyword: 20,
|
|
30767
|
+
outputRows: 1e3,
|
|
30768
|
+
logicalBytes: 64 * 1024 * 1024
|
|
30769
|
+
};
|
|
30770
|
+
const KEYWORD_SET_FIELD_LIMITS = {
|
|
30771
|
+
name: 80,
|
|
30772
|
+
projectId: 80,
|
|
30773
|
+
sourceReport: 100,
|
|
30774
|
+
keyword: 80,
|
|
30775
|
+
keywordWords: 10,
|
|
30776
|
+
tag: 40,
|
|
30777
|
+
url: 2048,
|
|
30778
|
+
metricJsonBytes: 16 * 1024
|
|
30779
|
+
};
|
|
30780
|
+
//#endregion
|
|
30781
|
+
//#region packages/core/dist/keyword-sets/validation.js
|
|
30782
|
+
const unavailableValueSchema = z.strictObject({
|
|
30783
|
+
state: z.enum([
|
|
30784
|
+
"missing",
|
|
30785
|
+
"unavailable",
|
|
30786
|
+
"invalid"
|
|
30787
|
+
]),
|
|
30788
|
+
value: z.null(),
|
|
30789
|
+
reason: z.string().min(1).max(500)
|
|
30790
|
+
});
|
|
30791
|
+
const providerValueNumberSchema = z.discriminatedUnion("state", [z.strictObject({
|
|
30792
|
+
state: z.literal("observed"),
|
|
30793
|
+
value: z.number().finite()
|
|
30794
|
+
}), unavailableValueSchema]);
|
|
30795
|
+
const providerValueStringSchema = z.discriminatedUnion("state", [z.strictObject({
|
|
30796
|
+
state: z.literal("observed"),
|
|
30797
|
+
value: z.string().max(500)
|
|
30798
|
+
}), unavailableValueSchema]);
|
|
30799
|
+
const monthlySearchSchema = z.strictObject({
|
|
30800
|
+
year: z.number().int().min(2e3).max(2200),
|
|
30801
|
+
month: z.number().int().min(1).max(12),
|
|
30802
|
+
searchVolume: z.number().finite().nonnegative()
|
|
30803
|
+
});
|
|
30804
|
+
const providerValueMonthlySearchesSchema = z.discriminatedUnion("state", [z.strictObject({
|
|
30805
|
+
state: z.literal("observed"),
|
|
30806
|
+
value: z.array(monthlySearchSchema).max(24)
|
|
30807
|
+
}), unavailableValueSchema]);
|
|
30808
|
+
const keywordMetricSchema = z.strictObject({
|
|
30809
|
+
keyword: z.string().min(1).max(KEYWORD_SET_FIELD_LIMITS.keyword),
|
|
30810
|
+
monthlySearchVolume: providerValueNumberSchema,
|
|
30811
|
+
monthlySearches: providerValueMonthlySearchesSchema,
|
|
30812
|
+
searchVolumeUpdatedAt: providerValueStringSchema,
|
|
30813
|
+
cpcUsd: providerValueNumberSchema,
|
|
30814
|
+
paidCompetition: providerValueNumberSchema,
|
|
30815
|
+
keywordDifficulty: providerValueNumberSchema,
|
|
30816
|
+
intent: providerValueStringSchema,
|
|
30817
|
+
resultCount: providerValueNumberSchema
|
|
30818
|
+
});
|
|
30819
|
+
const savedMetricSchema = z.strictObject({
|
|
30820
|
+
schemaVersion: z.literal(1),
|
|
30821
|
+
provider: providerIdSchema,
|
|
30822
|
+
observedAt: z.iso.datetime(),
|
|
30823
|
+
metric: keywordMetricSchema
|
|
30824
|
+
});
|
|
30825
|
+
function invalid$1(message) {
|
|
30826
|
+
throw new SeoError("INVALID_INPUT", message);
|
|
30827
|
+
}
|
|
30828
|
+
function boundedText(value, label, max) {
|
|
30829
|
+
const normalized = value.trim();
|
|
30830
|
+
if (!normalized || normalized.length > max) invalid$1(`${label} must contain 1 to ${max} characters.`);
|
|
30831
|
+
return normalized;
|
|
30832
|
+
}
|
|
30833
|
+
function normalizeSavedKeyword(value) {
|
|
30834
|
+
const normalized = value.trim().replace(/\s+/gu, " ").toLowerCase();
|
|
30835
|
+
if (!normalized || normalized.length > KEYWORD_SET_FIELD_LIMITS.keyword || normalized.split(" ").length > KEYWORD_SET_FIELD_LIMITS.keywordWords) invalid$1(`Keywords must contain 1 to ${KEYWORD_SET_FIELD_LIMITS.keyword} characters and at most ${KEYWORD_SET_FIELD_LIMITS.keywordWords} words.`);
|
|
30836
|
+
return normalized;
|
|
30837
|
+
}
|
|
30838
|
+
function parseStoredJson(value, schema, label) {
|
|
30839
|
+
let decoded;
|
|
30840
|
+
try {
|
|
30841
|
+
decoded = JSON.parse(value);
|
|
30842
|
+
} catch {
|
|
30843
|
+
throw new SeoError("INTERNAL_ERROR", `${label} contains invalid JSON.`);
|
|
30844
|
+
}
|
|
30845
|
+
const parsed = schema.safeParse(decoded);
|
|
30846
|
+
if (!parsed.success) throw new SeoError("INTERNAL_ERROR", `${label} has an invalid shape.`);
|
|
30847
|
+
return parsed.data;
|
|
30848
|
+
}
|
|
30849
|
+
//#endregion
|
|
30850
|
+
//#region packages/core/dist/keyword-sets/rows.js
|
|
30851
|
+
const SET_SELECT = `
|
|
30852
|
+
SELECT sets.*,
|
|
30853
|
+
(SELECT COUNT(*) FROM keyword_set_items items WHERE items.set_id = sets.id) AS keyword_count,
|
|
30854
|
+
(SELECT COUNT(DISTINCT tags.tag) FROM keyword_set_tags tags WHERE tags.set_id = sets.id) AS tag_count
|
|
30855
|
+
FROM keyword_sets sets`;
|
|
30856
|
+
function storeDatabase(options) {
|
|
30857
|
+
return options.database ?? getDb();
|
|
30858
|
+
}
|
|
30859
|
+
function rowToSet(row) {
|
|
30860
|
+
const provider = row.provider ? providerIdSchema.safeParse(row.provider) : void 0;
|
|
30861
|
+
if (provider && !provider.success) throw new SeoError("INTERNAL_ERROR", "Saved keyword set provider is invalid.");
|
|
30862
|
+
return {
|
|
30863
|
+
schemaVersion: 1,
|
|
30864
|
+
id: row.id,
|
|
30865
|
+
projectId: row.project_id,
|
|
30866
|
+
name: row.name,
|
|
30867
|
+
market: parseStoredJson(row.market_json, searchMarketSchema, "Saved search market"),
|
|
30868
|
+
provider: provider?.data ?? null,
|
|
30869
|
+
sourceReport: row.source_report,
|
|
30870
|
+
keywordCount: row.keyword_count,
|
|
30871
|
+
tagCount: row.tag_count,
|
|
30872
|
+
createdAt: new Date(row.created_at).toISOString(),
|
|
30873
|
+
updatedAt: new Date(row.updated_at).toISOString(),
|
|
30874
|
+
lastRefreshedAt: row.last_refreshed_at === null ? null : new Date(row.last_refreshed_at).toISOString()
|
|
30875
|
+
};
|
|
30876
|
+
}
|
|
30877
|
+
function findSetRow(db, projectId, idOrName) {
|
|
30878
|
+
return db.prepare(`${SET_SELECT}
|
|
30879
|
+
WHERE sets.project_id = ?
|
|
30880
|
+
AND (sets.id = ? OR sets.name = ? COLLATE NOCASE)
|
|
30881
|
+
ORDER BY CASE WHEN sets.id = ? THEN 0 ELSE 1 END
|
|
30882
|
+
LIMIT 1`).get(projectId, idOrName, idOrName, idOrName);
|
|
30883
|
+
}
|
|
30884
|
+
function requireSetRow(db, projectId, idOrName) {
|
|
30885
|
+
const row = findSetRow(db, projectId, idOrName);
|
|
30886
|
+
if (!row) invalid$1(`Keyword set not found: ${idOrName}`);
|
|
30887
|
+
return row;
|
|
30888
|
+
}
|
|
30889
|
+
function rowCount(db, sql, ...params) {
|
|
30890
|
+
return db.prepare(sql).get(...params).count;
|
|
30891
|
+
}
|
|
30892
|
+
//#endregion
|
|
30893
|
+
//#region packages/core/dist/keyword-sets/store.js
|
|
30894
|
+
function getKeywordSet(input, options = {}) {
|
|
30895
|
+
const projectId = boundedText(input.projectId, "Project id", KEYWORD_SET_FIELD_LIMITS.projectId);
|
|
30896
|
+
const idOrName = boundedText(input.idOrName, "Keyword set id or name", KEYWORD_SET_FIELD_LIMITS.name);
|
|
30897
|
+
const tag = input.tag ? boundedText(input.tag, "Tag", KEYWORD_SET_FIELD_LIMITS.tag).toLowerCase() : null;
|
|
30898
|
+
const limit = input.limit ?? 100;
|
|
30899
|
+
const offset = input.offset ?? 0;
|
|
30900
|
+
if (!Number.isSafeInteger(limit) || limit < 1 || limit > KEYWORD_SET_LIMITS.outputRows) invalid$1(`Keyword set output limit must be from 1 to ${KEYWORD_SET_LIMITS.outputRows}.`);
|
|
30901
|
+
if (!Number.isSafeInteger(offset) || offset < 0) invalid$1("Keyword set output offset must be zero or greater.");
|
|
30902
|
+
const db = storeDatabase(options);
|
|
30903
|
+
const setRow = requireSetRow(db, projectId, idOrName);
|
|
30904
|
+
const filterSql = tag ? `AND EXISTS (
|
|
30905
|
+
SELECT 1 FROM keyword_set_tags filter_tags
|
|
30906
|
+
WHERE filter_tags.set_id = items.set_id
|
|
30907
|
+
AND filter_tags.normalized_keyword = items.normalized_keyword
|
|
30908
|
+
AND filter_tags.tag = ?
|
|
30909
|
+
)` : "";
|
|
30910
|
+
const params = tag ? [setRow.id, tag] : [setRow.id];
|
|
30911
|
+
const total = rowCount(db, `SELECT COUNT(*) AS count FROM keyword_set_items items
|
|
30912
|
+
WHERE items.set_id = ? ${filterSql}`, ...params);
|
|
30913
|
+
const rows = db.prepare(`SELECT items.* FROM keyword_set_items items
|
|
30914
|
+
WHERE items.set_id = ? ${filterSql}
|
|
30915
|
+
ORDER BY items.normalized_keyword
|
|
30916
|
+
LIMIT ? OFFSET ?`).all(...params, limit, offset);
|
|
30917
|
+
const tagsByKeyword = loadTags(db, setRow.id, rows);
|
|
30918
|
+
const items = rows.map((row) => itemFromRow(row, tagsByKeyword.get(row.normalized_keyword) ?? []));
|
|
30919
|
+
return {
|
|
30920
|
+
schemaVersion: 1,
|
|
30921
|
+
set: rowToSet(setRow),
|
|
30922
|
+
items,
|
|
30923
|
+
pagination: {
|
|
30924
|
+
offset,
|
|
30925
|
+
limit,
|
|
30926
|
+
returned: items.length,
|
|
30927
|
+
total,
|
|
30928
|
+
nextOffset: offset + items.length < total ? offset + items.length : null
|
|
30929
|
+
},
|
|
30930
|
+
filter: { tag }
|
|
30931
|
+
};
|
|
30932
|
+
}
|
|
30933
|
+
function loadTags(db, setId, rows) {
|
|
30934
|
+
if (rows.length === 0) return /* @__PURE__ */ new Map();
|
|
30935
|
+
const tagRows = db.prepare(`SELECT normalized_keyword, tag FROM keyword_set_tags
|
|
30936
|
+
WHERE set_id = ? AND normalized_keyword IN (${rows.map(() => "?").join(", ")})
|
|
30937
|
+
ORDER BY normalized_keyword, tag`).all(setId, ...rows.map((row) => row.normalized_keyword));
|
|
30938
|
+
const tagsByKeyword = /* @__PURE__ */ new Map();
|
|
30939
|
+
for (const row of tagRows) {
|
|
30940
|
+
const tags = tagsByKeyword.get(row.normalized_keyword) ?? [];
|
|
30941
|
+
tags.push(row.tag);
|
|
30942
|
+
tagsByKeyword.set(row.normalized_keyword, tags);
|
|
30943
|
+
}
|
|
30944
|
+
return tagsByKeyword;
|
|
30945
|
+
}
|
|
30946
|
+
function itemFromRow(row, tags) {
|
|
30947
|
+
const latestMetric = row.metric_json ? parseStoredJson(row.metric_json, savedMetricSchema, "Saved keyword metric") : null;
|
|
30948
|
+
if (latestMetric && (latestMetric.provider !== row.metric_provider || latestMetric.observedAt !== row.metric_observed_at || normalizeSavedKeyword(latestMetric.metric.keyword) !== row.normalized_keyword)) throw new SeoError("INTERNAL_ERROR", "Saved keyword metric metadata is inconsistent.");
|
|
30949
|
+
const page = row.page_kind && row.page_url ? {
|
|
30950
|
+
kind: row.page_kind,
|
|
30951
|
+
url: row.page_url
|
|
30952
|
+
} : null;
|
|
30953
|
+
return {
|
|
30954
|
+
keyword: row.display_keyword,
|
|
30955
|
+
normalizedKeyword: row.normalized_keyword,
|
|
30956
|
+
tags,
|
|
30957
|
+
page,
|
|
30958
|
+
latestMetric,
|
|
30959
|
+
createdAt: new Date(row.created_at).toISOString(),
|
|
30960
|
+
updatedAt: new Date(row.updated_at).toISOString()
|
|
30961
|
+
};
|
|
30962
|
+
}
|
|
30963
|
+
//#endregion
|
|
30964
|
+
//#region packages/core/dist/rank-tracking/dataforseo-collector.js
|
|
30965
|
+
const QUEUED_RESULT_ENDPOINT = "v3/serp/google/organic/task_get/advanced/{taskId}";
|
|
30966
|
+
var DataForSeoRankTrackingCollector = class {
|
|
30967
|
+
provider = "dataforseo";
|
|
30968
|
+
client;
|
|
30969
|
+
liveProvider;
|
|
30970
|
+
constructor(options = {}) {
|
|
30971
|
+
this.client = new DataForSeoClient(options);
|
|
30972
|
+
this.liveProvider = new DataForSeoSerpSnapshotProvider({ client: this.client });
|
|
30973
|
+
}
|
|
30974
|
+
live(input) {
|
|
30975
|
+
return this.liveProvider.serpSnapshot(input);
|
|
30976
|
+
}
|
|
30977
|
+
async post(input) {
|
|
30978
|
+
const snapshot = await this.client.serpTaskPost({
|
|
30979
|
+
tasks: input.tasks.map(({ taskKey, request }) => {
|
|
30980
|
+
const market = request.market;
|
|
30981
|
+
return {
|
|
30982
|
+
tag: taskKey,
|
|
30983
|
+
keyword: normalizedKeyword$1(request.keyword),
|
|
30984
|
+
languageCode: market.languageCode.split("-")[0],
|
|
30985
|
+
...locationRequest(market, "serp-snapshot"),
|
|
30986
|
+
device: market.device ?? "desktop",
|
|
30987
|
+
depth: request.depth
|
|
30988
|
+
};
|
|
30989
|
+
}),
|
|
30990
|
+
context: input.context
|
|
30991
|
+
});
|
|
30992
|
+
return {
|
|
30993
|
+
provider: this.provider,
|
|
30994
|
+
receipts: snapshot.taskReceipts.map((receipt) => ({
|
|
30995
|
+
taskKey: receipt.tag,
|
|
30996
|
+
providerTaskId: receipt.providerTaskId
|
|
30997
|
+
})),
|
|
30998
|
+
estimatedCostMicros: snapshot.estimatedCostMicros,
|
|
30999
|
+
actualCostMicros: snapshot.actualCostMicros,
|
|
31000
|
+
warnings: snapshot.warnings
|
|
31001
|
+
};
|
|
31002
|
+
}
|
|
31003
|
+
ready() {
|
|
31004
|
+
return this.client.serpTasksReady().then((tasks) => tasks.map((task) => ({
|
|
31005
|
+
providerTaskId: task.providerTaskId,
|
|
31006
|
+
taskKey: task.tag
|
|
31007
|
+
})));
|
|
31008
|
+
}
|
|
31009
|
+
async collect(input) {
|
|
31010
|
+
const snapshot = await this.client.serpTaskGet(input.providerTaskId);
|
|
31011
|
+
return mapDataForSeoSerpSnapshot(input.request, snapshot, QUEUED_RESULT_ENDPOINT);
|
|
31012
|
+
}
|
|
31013
|
+
};
|
|
31014
|
+
//#endregion
|
|
31015
|
+
//#region packages/core/dist/rank-tracking/limits.js
|
|
31016
|
+
const RANK_TRACKING_LIMITS = {
|
|
31017
|
+
configurationsPerProject: 25,
|
|
31018
|
+
totalConfigurations: 100,
|
|
31019
|
+
liveKeywordsPerRun: 50,
|
|
31020
|
+
queuedKeywordsPerRun: 1e3,
|
|
31021
|
+
devicesPerRun: 2,
|
|
31022
|
+
tasksPerProviderPost: 100,
|
|
31023
|
+
retainedRunsPerConfiguration: 90,
|
|
31024
|
+
outputRows: 250,
|
|
31025
|
+
logicalBytes: 256 * 1024 * 1024
|
|
31026
|
+
};
|
|
31027
|
+
//#endregion
|
|
31028
|
+
//#region packages/core/dist/rank-tracking/configuration-store.js
|
|
31029
|
+
function invalid(message) {
|
|
31030
|
+
throw new SeoError("INVALID_INPUT", message);
|
|
31031
|
+
}
|
|
31032
|
+
function database(options) {
|
|
31033
|
+
return options.database ?? getDb();
|
|
31034
|
+
}
|
|
31035
|
+
function parseJson(value, label) {
|
|
31036
|
+
try {
|
|
31037
|
+
return JSON.parse(value);
|
|
31038
|
+
} catch {
|
|
31039
|
+
throw new SeoError("INTERNAL_ERROR", `${label} is corrupt.`);
|
|
31040
|
+
}
|
|
31041
|
+
}
|
|
31042
|
+
function normalizeDomain(value) {
|
|
31043
|
+
const raw = value.trim().toLowerCase();
|
|
31044
|
+
if (!raw || raw.length > 253) invalid("Use a valid target domain.");
|
|
31045
|
+
let hostname;
|
|
31046
|
+
try {
|
|
31047
|
+
hostname = new URL(raw.includes("://") ? raw : `https://${raw}`).hostname;
|
|
31048
|
+
} catch {
|
|
31049
|
+
invalid("Use a valid target domain.");
|
|
31050
|
+
}
|
|
31051
|
+
if (!hostname || hostname.length > 253 || hostname.includes("..") || !/^[a-z0-9.-]+$/u.test(hostname)) invalid("Use a valid target domain.");
|
|
31052
|
+
return hostname.replace(/\.$/u, "");
|
|
31053
|
+
}
|
|
31054
|
+
function targetMatchesDomain(targetDomain, candidate) {
|
|
31055
|
+
const target = normalizeDomain(targetDomain);
|
|
31056
|
+
const host = normalizeDomain(candidate);
|
|
31057
|
+
return host === target || host.endsWith(`.${target}`);
|
|
31058
|
+
}
|
|
31059
|
+
function configFromRow(row) {
|
|
31060
|
+
const market = searchMarketSchema.safeParse(parseJson(row.market_json, "Saved rank market"));
|
|
31061
|
+
const devices = parseJson(row.devices_json, "Saved rank devices");
|
|
31062
|
+
const provider = providerIdSchema.safeParse(row.provider);
|
|
31063
|
+
if (!market.success || market.data.device !== void 0 || !Array.isArray(devices) || devices.length < 1 || devices.length > 2 || devices.some((item) => item !== "desktop" && item !== "mobile") || !provider.success) throw new SeoError("INTERNAL_ERROR", "Saved rank tracking configuration is invalid.");
|
|
31064
|
+
return {
|
|
31065
|
+
schemaVersion: 1,
|
|
31066
|
+
id: row.id,
|
|
31067
|
+
projectId: row.project_id,
|
|
31068
|
+
keywordSetId: row.keyword_set_id,
|
|
31069
|
+
targetDomain: row.target_domain,
|
|
31070
|
+
tag: row.tag,
|
|
31071
|
+
market: market.data,
|
|
31072
|
+
devices,
|
|
31073
|
+
provider: provider.data,
|
|
31074
|
+
collectionMethod: row.collection_method,
|
|
31075
|
+
cadence: row.cadence,
|
|
31076
|
+
depth: row.depth,
|
|
31077
|
+
keywordLimit: row.keyword_limit,
|
|
31078
|
+
nextRunAt: row.next_run_at === null ? null : new Date(row.next_run_at).toISOString(),
|
|
31079
|
+
createdAt: new Date(row.created_at).toISOString(),
|
|
31080
|
+
updatedAt: new Date(row.updated_at).toISOString()
|
|
31081
|
+
};
|
|
31082
|
+
}
|
|
31083
|
+
function runFromRow(row) {
|
|
31084
|
+
return {
|
|
31085
|
+
schemaVersion: 1,
|
|
31086
|
+
id: row.id,
|
|
31087
|
+
configId: row.config_id,
|
|
31088
|
+
state: row.state,
|
|
31089
|
+
collectionMethod: row.collection_method,
|
|
31090
|
+
scheduledFor: new Date(row.scheduled_for).toISOString(),
|
|
31091
|
+
startedAt: new Date(row.started_at).toISOString(),
|
|
31092
|
+
completedAt: row.completed_at === null ? null : new Date(row.completed_at).toISOString(),
|
|
31093
|
+
keywordCount: row.keyword_count,
|
|
31094
|
+
taskCount: row.task_count,
|
|
31095
|
+
snapshotCount: row.snapshot_count,
|
|
31096
|
+
pendingCount: row.pending_count,
|
|
31097
|
+
failedCount: row.failed_count,
|
|
31098
|
+
estimatedCostMicros: row.estimated_cost_micros,
|
|
31099
|
+
actualCostMicros: row.actual_cost_micros,
|
|
31100
|
+
errorSummary: row.error_summary
|
|
31101
|
+
};
|
|
31102
|
+
}
|
|
31103
|
+
function taskFromRow(row) {
|
|
31104
|
+
return {
|
|
31105
|
+
id: row.id,
|
|
31106
|
+
runId: row.run_id,
|
|
31107
|
+
normalizedKeyword: row.normalized_keyword,
|
|
31108
|
+
displayKeyword: row.display_keyword,
|
|
31109
|
+
device: row.device,
|
|
31110
|
+
state: row.state,
|
|
31111
|
+
providerTaskId: row.provider_task_id,
|
|
31112
|
+
attemptCount: row.attempt_count,
|
|
31113
|
+
errorCode: row.error_code,
|
|
31114
|
+
errorMessage: row.error_message
|
|
31115
|
+
};
|
|
31116
|
+
}
|
|
31117
|
+
function observationFromRow(row) {
|
|
31118
|
+
const provider = providerIdSchema.safeParse(row.provider);
|
|
31119
|
+
if (!provider.success) throw new SeoError("INTERNAL_ERROR", "Saved rank provider is invalid.");
|
|
31120
|
+
return {
|
|
31121
|
+
taskId: row.task_id,
|
|
31122
|
+
runId: row.run_id,
|
|
31123
|
+
keyword: row.display_keyword,
|
|
31124
|
+
normalizedKeyword: row.normalized_keyword,
|
|
31125
|
+
device: row.device,
|
|
31126
|
+
state: row.observation_state,
|
|
31127
|
+
organicPosition: row.organic_position,
|
|
31128
|
+
absolutePosition: row.absolute_position,
|
|
31129
|
+
rankingUrl: row.ranking_url,
|
|
31130
|
+
observedFeatures: parseJson(row.observed_features_json, "Saved rank features"),
|
|
31131
|
+
checkedAt: row.checked_at,
|
|
31132
|
+
provider: provider.data,
|
|
31133
|
+
providerTaskId: row.provider_task_id,
|
|
31134
|
+
requestedDepth: row.requested_depth,
|
|
31135
|
+
returnedRows: row.returned_rows,
|
|
31136
|
+
retainedRows: row.retained_rows,
|
|
31137
|
+
invalidRows: row.invalid_rows,
|
|
31138
|
+
completeness: row.completeness,
|
|
31139
|
+
estimatedCostMicros: row.estimated_cost_micros,
|
|
31140
|
+
actualCostMicros: row.actual_cost_micros,
|
|
31141
|
+
warnings: parseJson(row.warnings_json, "Saved rank warnings")
|
|
31142
|
+
};
|
|
31143
|
+
}
|
|
31144
|
+
function stableConfigKey(value) {
|
|
31145
|
+
return createHash("sha256").update(JSON.stringify(value)).digest("hex");
|
|
31146
|
+
}
|
|
31147
|
+
function nextRunAt(cadence, scheduledFor) {
|
|
31148
|
+
if (cadence === "manual") return null;
|
|
31149
|
+
const next = new Date(scheduledFor);
|
|
31150
|
+
if (cadence === "daily") next.setUTCDate(next.getUTCDate() + 1);
|
|
31151
|
+
if (cadence === "weekly") next.setUTCDate(next.getUTCDate() + 7);
|
|
31152
|
+
if (cadence === "monthly") {
|
|
31153
|
+
const desiredDay = next.getUTCDate();
|
|
31154
|
+
next.setUTCDate(1);
|
|
31155
|
+
next.setUTCMonth(next.getUTCMonth() + 1);
|
|
31156
|
+
const lastDay = new Date(Date.UTC(next.getUTCFullYear(), next.getUTCMonth() + 1, 0)).getUTCDate();
|
|
31157
|
+
next.setUTCDate(Math.min(desiredDay, lastDay));
|
|
31158
|
+
}
|
|
31159
|
+
return next.getTime();
|
|
31160
|
+
}
|
|
31161
|
+
function getOrCreateRankTrackingConfiguration(input, options = {}) {
|
|
31162
|
+
const projectId = input.projectId.trim();
|
|
31163
|
+
const keywordSetId = input.keywordSetId.trim();
|
|
31164
|
+
const tag = input.tag?.trim().toLowerCase() || null;
|
|
31165
|
+
if (!projectId || projectId.length > 80) invalid("Use a valid project id.");
|
|
31166
|
+
if (!keywordSetId || keywordSetId.length > 80) invalid("Use a valid keyword set id.");
|
|
31167
|
+
if (tag && tag.length > 40) invalid("Rank tracking tag is too long.");
|
|
31168
|
+
const market = searchMarketSchema.safeParse(input.market);
|
|
31169
|
+
if (!market.success) invalid("Use a valid rank tracking market.");
|
|
31170
|
+
const { device: _ignoredDevice, ...marketWithoutDevice } = market.data;
|
|
31171
|
+
const devices = [...new Set(input.devices)].sort();
|
|
31172
|
+
if (devices.length < 1 || devices.length > 2 || devices.some((item) => item !== "desktop" && item !== "mobile")) invalid("Track desktop, mobile, or both devices.");
|
|
31173
|
+
const provider = providerIdSchema.safeParse(input.provider);
|
|
31174
|
+
if (!provider.success) invalid("Use a supported rank tracking provider.");
|
|
31175
|
+
if (!["live", "queued"].includes(input.collectionMethod)) invalid("Use live or queued rank collection.");
|
|
31176
|
+
if (![
|
|
31177
|
+
"manual",
|
|
31178
|
+
"daily",
|
|
31179
|
+
"weekly",
|
|
31180
|
+
"monthly"
|
|
31181
|
+
].includes(input.cadence)) invalid("Use a supported rank tracking cadence.");
|
|
31182
|
+
if (!Number.isSafeInteger(input.depth) || input.depth < 1 || input.depth > 100) invalid("Rank tracking depth must be from 1 to 100.");
|
|
31183
|
+
const maxKeywords = input.collectionMethod === "live" ? RANK_TRACKING_LIMITS.liveKeywordsPerRun : RANK_TRACKING_LIMITS.queuedKeywordsPerRun;
|
|
31184
|
+
if (!Number.isSafeInteger(input.keywordLimit) || input.keywordLimit < 1 || input.keywordLimit > maxKeywords) invalid(`${input.collectionMethod === "live" ? "Live" : "Queued"} rank tracking can collect 1 to ${maxKeywords} keywords per run.`);
|
|
31185
|
+
const targetDomain = normalizeDomain(input.targetDomain);
|
|
31186
|
+
const db = database(options);
|
|
31187
|
+
const set = getKeywordSet({
|
|
31188
|
+
projectId,
|
|
31189
|
+
idOrName: keywordSetId,
|
|
31190
|
+
limit: 1
|
|
31191
|
+
}, { database: db }).set;
|
|
31192
|
+
if (set.market.searchEngine !== marketWithoutDevice.searchEngine) invalid("The rank tracking search engine must match the keyword set.");
|
|
31193
|
+
const configKey = stableConfigKey({
|
|
31194
|
+
projectId,
|
|
31195
|
+
keywordSetId: set.id,
|
|
31196
|
+
targetDomain,
|
|
31197
|
+
tag,
|
|
31198
|
+
market: marketWithoutDevice,
|
|
31199
|
+
devices,
|
|
31200
|
+
provider: provider.data,
|
|
31201
|
+
collectionMethod: input.collectionMethod,
|
|
31202
|
+
cadence: input.cadence,
|
|
31203
|
+
depth: input.depth,
|
|
31204
|
+
keywordLimit: input.keywordLimit
|
|
31205
|
+
});
|
|
31206
|
+
const existing = db.prepare("SELECT * FROM rank_tracking_configs WHERE config_key = ?").get(configKey);
|
|
31207
|
+
if (existing) return configFromRow(existing);
|
|
31208
|
+
const total = db.prepare("SELECT COUNT(*) AS count FROM rank_tracking_configs").get().count;
|
|
31209
|
+
const projectTotal = db.prepare("SELECT COUNT(*) AS count FROM rank_tracking_configs WHERE project_id = ?").get(projectId).count;
|
|
31210
|
+
if (total >= RANK_TRACKING_LIMITS.totalConfigurations) invalid("Remove an unused rank tracking configuration before adding more.");
|
|
31211
|
+
if (projectTotal >= RANK_TRACKING_LIMITS.configurationsPerProject) invalid("This project already has the maximum rank tracking configurations.");
|
|
31212
|
+
const now = (options.now ?? (() => /* @__PURE__ */ new Date()))().getTime();
|
|
31213
|
+
const id = (options.id ?? randomUUID)();
|
|
31214
|
+
db.prepare(`INSERT INTO rank_tracking_configs
|
|
31215
|
+
(id, config_key, project_id, keyword_set_id, target_domain, tag,
|
|
31216
|
+
market_json, devices_json, provider, collection_method, cadence, depth,
|
|
31217
|
+
keyword_limit, next_run_at, created_at, updated_at)
|
|
31218
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`).run(id, configKey, projectId, set.id, targetDomain, tag, JSON.stringify(marketWithoutDevice), JSON.stringify(devices), provider.data, input.collectionMethod, input.cadence, input.depth, input.keywordLimit, input.cadence === "manual" ? null : now, now, now);
|
|
31219
|
+
return configFromRow(db.prepare("SELECT * FROM rank_tracking_configs WHERE id = ?").get(id));
|
|
31220
|
+
}
|
|
31221
|
+
//#endregion
|
|
31222
|
+
//#region packages/core/dist/rank-tracking/store.js
|
|
31223
|
+
function activeRankTrackingRun(configId, options = {}) {
|
|
31224
|
+
const row = database(options).prepare(`SELECT * FROM rank_tracking_runs
|
|
31225
|
+
WHERE config_id = ? AND completed_at IS NULL
|
|
31226
|
+
ORDER BY scheduled_for DESC, id DESC LIMIT 1`).get(configId);
|
|
31227
|
+
return row ? runFromRow(row) : null;
|
|
31228
|
+
}
|
|
31229
|
+
function startRankTrackingRun(input, options = {}) {
|
|
31230
|
+
const db = database(options);
|
|
31231
|
+
const active = activeRankTrackingRun(input.configuration.id, { database: db });
|
|
31232
|
+
if (active) return active;
|
|
31233
|
+
if (input.keywords.length < 1) invalid("The selected keyword set view is empty.");
|
|
31234
|
+
const now = (options.now ?? (() => /* @__PURE__ */ new Date()))().getTime();
|
|
31235
|
+
const scheduledFor = input.scheduledFor?.getTime() ?? now;
|
|
31236
|
+
const runId = (options.id ?? randomUUID)();
|
|
31237
|
+
const idempotencyKey = `${input.configuration.id}:${scheduledFor}`;
|
|
31238
|
+
const taskCount = input.keywords.length * input.configuration.devices.length;
|
|
31239
|
+
const create = db.transaction(() => {
|
|
31240
|
+
db.prepare(`INSERT INTO rank_tracking_runs
|
|
31241
|
+
(id, config_id, idempotency_key, state, collection_method, scheduled_for,
|
|
31242
|
+
started_at, keyword_count, task_count, pending_count, config_snapshot_json)
|
|
31243
|
+
VALUES (?, ?, ?, 'pending', ?, ?, ?, ?, ?, ?, ?)`).run(runId, input.configuration.id, idempotencyKey, input.configuration.collectionMethod, scheduledFor, now, input.keywords.length, taskCount, taskCount, JSON.stringify(input.configuration));
|
|
31244
|
+
const insertTask = db.prepare(`INSERT INTO rank_tracking_tasks
|
|
31245
|
+
(id, run_id, normalized_keyword, display_keyword, device, state)
|
|
31246
|
+
VALUES (?, ?, ?, ?, ?, 'pending')`);
|
|
31247
|
+
for (const keyword of input.keywords) for (const device of input.configuration.devices) insertTask.run((options.id ?? randomUUID)(), runId, keyword.normalizedKeyword, keyword.keyword, device);
|
|
31248
|
+
db.prepare(`UPDATE rank_tracking_configs
|
|
31249
|
+
SET next_run_at = ?, updated_at = ? WHERE id = ?`).run(nextRunAt(input.configuration.cadence, scheduledFor), now, input.configuration.id);
|
|
31250
|
+
});
|
|
31251
|
+
try {
|
|
31252
|
+
create.immediate();
|
|
31253
|
+
} catch (error) {
|
|
31254
|
+
if (/unique constraint/i.test(String(error))) {
|
|
31255
|
+
const recovered = activeRankTrackingRun(input.configuration.id, { database: db });
|
|
31256
|
+
if (recovered) return recovered;
|
|
31257
|
+
}
|
|
31258
|
+
throw error;
|
|
31259
|
+
}
|
|
31260
|
+
return getRankTrackingRun(runId, { database: db });
|
|
31261
|
+
}
|
|
31262
|
+
function getRankTrackingRun(runId, options = {}) {
|
|
31263
|
+
const row = database(options).prepare("SELECT * FROM rank_tracking_runs WHERE id = ?").get(runId);
|
|
31264
|
+
if (!row) throw new SeoError("INTERNAL_ERROR", "Rank tracking run is missing.");
|
|
31265
|
+
return runFromRow(row);
|
|
31266
|
+
}
|
|
31267
|
+
function rankTrackingTasks(runId, states, options = {}) {
|
|
31268
|
+
const filter = states?.length ? ` AND state IN (${states.map(() => "?").join(", ")})` : "";
|
|
31269
|
+
return database(options).prepare(`SELECT * FROM rank_tracking_tasks WHERE run_id = ?${filter}
|
|
31270
|
+
ORDER BY normalized_keyword, device, id`).all(runId, ...states ?? []).map(taskFromRow);
|
|
31271
|
+
}
|
|
31272
|
+
function markRankTasksPosted(runId, receipts, cost, options = {}) {
|
|
31273
|
+
const db = database(options);
|
|
31274
|
+
const now = (options.now ?? (() => /* @__PURE__ */ new Date()))().getTime();
|
|
31275
|
+
db.transaction(() => {
|
|
31276
|
+
const statement = db.prepare(`UPDATE rank_tracking_tasks
|
|
31277
|
+
SET state = 'posted', provider_task_id = ?, posted_at = ?,
|
|
31278
|
+
attempt_count = attempt_count + 1, error_code = NULL,
|
|
31279
|
+
error_message = NULL
|
|
31280
|
+
WHERE id = ? AND run_id = ? AND state IN ('pending', 'posting')`);
|
|
31281
|
+
for (const receipt of receipts) if (statement.run(receipt.providerTaskId, now, receipt.taskId, runId).changes !== 1) throw new SeoError("INTERNAL_ERROR", "A queued rank task changed before its receipt was saved.");
|
|
31282
|
+
db.prepare(`UPDATE rank_tracking_runs
|
|
31283
|
+
SET estimated_cost_micros = CASE
|
|
31284
|
+
WHEN ? IS NULL THEN estimated_cost_micros
|
|
31285
|
+
ELSE COALESCE(estimated_cost_micros, 0) + ? END,
|
|
31286
|
+
actual_cost_micros = CASE
|
|
31287
|
+
WHEN ? IS NULL THEN actual_cost_micros
|
|
31288
|
+
ELSE COALESCE(actual_cost_micros, 0) + ? END
|
|
31289
|
+
WHERE id = ?`).run(cost.estimatedMicros, cost.estimatedMicros, cost.actualMicros, cost.actualMicros, runId);
|
|
31290
|
+
}).immediate();
|
|
31291
|
+
}
|
|
31292
|
+
function markRankTasksPosting(runId, taskIds, options = {}) {
|
|
31293
|
+
if (taskIds.length === 0) return;
|
|
31294
|
+
const db = database(options);
|
|
31295
|
+
db.transaction(() => {
|
|
31296
|
+
const statement = db.prepare(`UPDATE rank_tracking_tasks
|
|
31297
|
+
SET state = 'posting', attempt_count = attempt_count + 1
|
|
31298
|
+
WHERE id = ? AND run_id = ? AND state = 'pending'`);
|
|
31299
|
+
for (const taskId of taskIds) if (statement.run(taskId, runId).changes !== 1) throw new SeoError("INTERNAL_ERROR", "A queued rank task changed before posting started.");
|
|
31300
|
+
}).immediate();
|
|
31301
|
+
}
|
|
31302
|
+
function recoverRankTaskReceipt(input, options = {}) {
|
|
31303
|
+
const now = (options.now ?? (() => /* @__PURE__ */ new Date()))().getTime();
|
|
31304
|
+
database(options).prepare(`UPDATE rank_tracking_tasks
|
|
31305
|
+
SET state = 'posted', provider_task_id = ?, posted_at = ?
|
|
31306
|
+
WHERE id = ? AND state = 'posting'`).run(input.providerTaskId, now, input.taskId);
|
|
31307
|
+
}
|
|
31308
|
+
function saveRankObservations(observations, options = {}) {
|
|
31309
|
+
if (observations.length === 0) return;
|
|
31310
|
+
const runId = observations[0]?.runId;
|
|
31311
|
+
if (!runId || observations.some((observation) => observation.runId !== runId)) throw new SeoError("INTERNAL_ERROR", "A rank observation batch must belong to one run.");
|
|
31312
|
+
const db = database(options);
|
|
31313
|
+
const now = (options.now ?? (() => /* @__PURE__ */ new Date()))().getTime();
|
|
31314
|
+
db.transaction(() => {
|
|
31315
|
+
const insertSnapshot = db.prepare(`INSERT OR IGNORE INTO rank_tracking_snapshots
|
|
31316
|
+
(task_id, run_id, normalized_keyword, display_keyword, device,
|
|
31317
|
+
observation_state, organic_position, absolute_position, ranking_url,
|
|
31318
|
+
observed_features_json, checked_at, provider, provider_task_id,
|
|
31319
|
+
requested_depth, returned_rows, retained_rows, invalid_rows,
|
|
31320
|
+
completeness, estimated_cost_micros, actual_cost_micros, warnings_json,
|
|
31321
|
+
created_at)
|
|
31322
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`);
|
|
31323
|
+
const completeTask = db.prepare(`UPDATE rank_tracking_tasks
|
|
31324
|
+
SET state = 'complete', collected_at = ?, error_code = NULL,
|
|
31325
|
+
error_message = NULL WHERE id = ?`);
|
|
31326
|
+
const addLiveCost = db.prepare(`UPDATE rank_tracking_runs
|
|
31327
|
+
SET estimated_cost_micros = CASE
|
|
31328
|
+
WHEN ? IS NULL THEN estimated_cost_micros
|
|
31329
|
+
ELSE COALESCE(estimated_cost_micros, 0) + ? END,
|
|
31330
|
+
actual_cost_micros = CASE
|
|
31331
|
+
WHEN ? IS NULL THEN actual_cost_micros
|
|
31332
|
+
ELSE COALESCE(actual_cost_micros, 0) + ? END
|
|
31333
|
+
WHERE id = ? AND collection_method = 'live'`);
|
|
31334
|
+
for (const observation of observations) {
|
|
31335
|
+
const inserted = insertSnapshot.run(observation.taskId, observation.runId, observation.normalizedKeyword, observation.keyword, observation.device, observation.state, observation.organicPosition, observation.absolutePosition, observation.rankingUrl, JSON.stringify(observation.observedFeatures), observation.checkedAt, observation.provider, observation.providerTaskId, observation.requestedDepth, observation.returnedRows, observation.retainedRows, observation.invalidRows, observation.completeness, observation.estimatedCostMicros, observation.actualCostMicros, JSON.stringify(observation.warnings), now);
|
|
31336
|
+
completeTask.run(now, observation.taskId);
|
|
31337
|
+
if (inserted.changes === 1) addLiveCost.run(observation.estimatedCostMicros, observation.estimatedCostMicros, observation.actualCostMicros, observation.actualCostMicros, observation.runId);
|
|
31338
|
+
}
|
|
31339
|
+
}).immediate();
|
|
31340
|
+
refreshRankTrackingRun(runId, {
|
|
31341
|
+
database: db,
|
|
31342
|
+
now: options.now
|
|
31343
|
+
});
|
|
31344
|
+
}
|
|
31345
|
+
function failRankTrackingTask(input, options = {}) {
|
|
31346
|
+
const db = database(options);
|
|
31347
|
+
const row = db.prepare("SELECT run_id FROM rank_tracking_tasks WHERE id = ?").get(input.taskId);
|
|
31348
|
+
if (!row) return;
|
|
31349
|
+
db.prepare(`UPDATE rank_tracking_tasks
|
|
31350
|
+
SET state = 'failed', attempt_count = attempt_count + 1,
|
|
31351
|
+
error_code = ?, error_message = ? WHERE id = ?`).run(input.code.slice(0, 100), input.message.slice(0, 1e3), input.taskId);
|
|
31352
|
+
refreshRankTrackingRun(row.run_id, {
|
|
31353
|
+
database: db,
|
|
31354
|
+
now: options.now
|
|
31355
|
+
});
|
|
31356
|
+
}
|
|
31357
|
+
function refreshRankTrackingRun(runId, options = {}) {
|
|
31358
|
+
const db = database(options);
|
|
31359
|
+
const counts = db.prepare(`SELECT
|
|
31360
|
+
SUM(CASE WHEN state = 'complete' THEN 1 ELSE 0 END) AS snapshots,
|
|
31361
|
+
SUM(CASE WHEN state IN ('pending', 'posting', 'posted') THEN 1 ELSE 0 END) AS pending,
|
|
31362
|
+
SUM(CASE WHEN state = 'failed' THEN 1 ELSE 0 END) AS failed
|
|
31363
|
+
FROM rank_tracking_tasks WHERE run_id = ?`).get(runId);
|
|
31364
|
+
const state = counts.pending > 0 ? counts.snapshots > 0 || counts.failed > 0 ? "partial" : "pending" : counts.failed > 0 ? counts.snapshots > 0 ? "partial" : "failed" : "complete";
|
|
31365
|
+
const now = (options.now ?? (() => /* @__PURE__ */ new Date()))().getTime();
|
|
31366
|
+
db.prepare(`UPDATE rank_tracking_runs
|
|
31367
|
+
SET state = ?, snapshot_count = ?, pending_count = ?, failed_count = ?,
|
|
31368
|
+
completed_at = CASE WHEN ? = 0 THEN ? ELSE NULL END,
|
|
31369
|
+
error_summary = CASE WHEN ? > 0 THEN ? ELSE NULL END
|
|
31370
|
+
WHERE id = ?`).run(state, counts.snapshots, counts.pending, counts.failed, counts.pending, now, counts.failed, `${counts.failed} rank task${counts.failed === 1 ? "" : "s"} failed.`, runId);
|
|
31371
|
+
if (counts.pending === 0) pruneRankTrackingRuns(runId, {
|
|
31372
|
+
...options,
|
|
31373
|
+
database: db
|
|
31374
|
+
});
|
|
31375
|
+
return getRankTrackingRun(runId, { database: db });
|
|
31376
|
+
}
|
|
31377
|
+
function pruneRankTrackingRuns(runId, options = {}) {
|
|
31378
|
+
const db = database(options);
|
|
31379
|
+
const row = db.prepare("SELECT config_id FROM rank_tracking_runs WHERE id = ?").get(runId);
|
|
31380
|
+
if (!row) return;
|
|
31381
|
+
db.prepare(`DELETE FROM rank_tracking_runs
|
|
31382
|
+
WHERE config_id = ? AND id IN (
|
|
31383
|
+
SELECT id FROM rank_tracking_runs
|
|
31384
|
+
WHERE config_id = ? AND completed_at IS NOT NULL
|
|
31385
|
+
ORDER BY scheduled_for DESC, id DESC LIMIT -1 OFFSET ?
|
|
31386
|
+
)`).run(row.config_id, row.config_id, RANK_TRACKING_LIMITS.retainedRunsPerConfiguration);
|
|
31387
|
+
const maxBytes = options.maxLogicalBytes ?? RANK_TRACKING_LIMITS.logicalBytes;
|
|
31388
|
+
while (rankTrackingLogicalBytes(db) > maxBytes) {
|
|
31389
|
+
const oldest = db.prepare(`SELECT id FROM rank_tracking_runs
|
|
31390
|
+
WHERE config_id = ? AND id <> ?
|
|
31391
|
+
AND completed_at IS NOT NULL
|
|
31392
|
+
ORDER BY scheduled_for, id LIMIT 1`).get(row.config_id, runId);
|
|
31393
|
+
if (!oldest) break;
|
|
31394
|
+
db.prepare("DELETE FROM rank_tracking_runs WHERE id = ?").run(oldest.id);
|
|
31395
|
+
}
|
|
31396
|
+
enforceRankTrackingLogicalBytes(db, maxBytes);
|
|
31397
|
+
}
|
|
31398
|
+
function latestRankTrackingRun(configId, options = {}) {
|
|
31399
|
+
const row = database(options).prepare(`SELECT * FROM rank_tracking_runs WHERE config_id = ?
|
|
31400
|
+
ORDER BY scheduled_for DESC, id DESC LIMIT 1`).get(configId);
|
|
31401
|
+
return row ? runFromRow(row) : null;
|
|
31402
|
+
}
|
|
31403
|
+
function priorComparableRankTrackingRun(configId, currentRunId, options = {}) {
|
|
31404
|
+
const row = database(options).prepare(`SELECT previous.* FROM rank_tracking_runs previous
|
|
31405
|
+
JOIN rank_tracking_runs current ON current.id = ?
|
|
31406
|
+
WHERE previous.config_id = ? AND previous.id <> current.id
|
|
31407
|
+
AND previous.scheduled_for < current.scheduled_for
|
|
31408
|
+
AND previous.snapshot_count > 0
|
|
31409
|
+
ORDER BY previous.scheduled_for DESC, previous.id DESC LIMIT 1`).get(currentRunId, configId);
|
|
31410
|
+
return row ? runFromRow(row) : null;
|
|
31411
|
+
}
|
|
31412
|
+
function rankObservations(runId, options = {}) {
|
|
31413
|
+
return database(options).prepare(`SELECT * FROM rank_tracking_snapshots WHERE run_id = ?
|
|
31414
|
+
ORDER BY normalized_keyword, device, task_id`).all(runId).map(observationFromRow);
|
|
31415
|
+
}
|
|
31416
|
+
function rankTrackingLogicalBytes(db = getDb()) {
|
|
31417
|
+
return db.prepare(`SELECT
|
|
31418
|
+
COALESCE((SELECT SUM(length(id) + length(config_key) + length(project_id) + length(keyword_set_id) + length(target_domain) + COALESCE(length(tag), 0) + length(market_json) + length(devices_json) + length(provider) + length(collection_method) + length(cadence)) FROM rank_tracking_configs), 0) +
|
|
31419
|
+
COALESCE((SELECT SUM(length(id) + length(config_id) + length(idempotency_key) + length(state) + length(collection_method) + length(config_snapshot_json) + COALESCE(length(error_summary), 0)) FROM rank_tracking_runs), 0) +
|
|
31420
|
+
COALESCE((SELECT SUM(length(id) + length(run_id) + length(normalized_keyword) + length(display_keyword) + length(device) + length(state) + COALESCE(length(provider_task_id), 0) + COALESCE(length(error_code), 0) + COALESCE(length(error_message), 0)) FROM rank_tracking_tasks), 0) +
|
|
31421
|
+
COALESCE((SELECT SUM(length(task_id) + length(run_id) + length(normalized_keyword) + length(display_keyword) + length(device) + length(observation_state) + COALESCE(length(ranking_url), 0) + length(observed_features_json) + length(checked_at) + length(provider) + COALESCE(length(provider_task_id), 0) + length(completeness) + length(warnings_json)) FROM rank_tracking_snapshots), 0)
|
|
31422
|
+
AS bytes`).get().bytes;
|
|
31423
|
+
}
|
|
31424
|
+
function enforceRankTrackingLogicalBytes(db, maxBytes = RANK_TRACKING_LIMITS.logicalBytes) {
|
|
31425
|
+
if (rankTrackingLogicalBytes(db) > maxBytes) throw new SeoError("INVALID_INPUT", "Saved rank history reached its local storage limit. Remove an unused tracking configuration before collecting more.");
|
|
31426
|
+
}
|
|
31427
|
+
//#endregion
|
|
31428
|
+
//#region packages/core/dist/rank-tracking/run.js
|
|
31429
|
+
function providerError(error) {
|
|
31430
|
+
if (!(error instanceof ProviderError)) return error instanceof SeoError ? error : new SeoError("INTERNAL_ERROR", "Rank collection failed unexpectedly.");
|
|
31431
|
+
if (error.code === "rate-limit") return new SeoError("RATE_LIMITED", error.message);
|
|
31432
|
+
if (error.code === "configuration") return new SeoError("INVALID_INPUT", error.message);
|
|
31433
|
+
return new SeoError("PROVIDER_UNAVAILABLE", error.message);
|
|
31434
|
+
}
|
|
31435
|
+
async function defaultCollector(provider) {
|
|
31436
|
+
if (provider !== "dataforseo") throw new SeoError("PROVIDER_UNAVAILABLE", `${provider} does not yet implement exact rank collection. Use DataForSEO for this report.`);
|
|
31437
|
+
if (!await readDataForSeoCredentials()) throw new SeoError("PROVIDER_UNAVAILABLE", "DataForSEO is not connected. Run `seo providers dataforseo connect` first.");
|
|
31438
|
+
return new DataForSeoRankTrackingCollector();
|
|
31439
|
+
}
|
|
31440
|
+
function requestForTask(configuration, task) {
|
|
31441
|
+
return {
|
|
31442
|
+
keyword: task.displayKeyword,
|
|
31443
|
+
market: {
|
|
31444
|
+
...configuration.market,
|
|
31445
|
+
device: task.device
|
|
31446
|
+
},
|
|
31447
|
+
depth: configuration.depth,
|
|
31448
|
+
refresh: true,
|
|
31449
|
+
context: {
|
|
31450
|
+
projectId: configuration.projectId,
|
|
31451
|
+
reportId: "rank-tracking",
|
|
31452
|
+
reportRunId: task.runId
|
|
31453
|
+
}
|
|
31454
|
+
};
|
|
31455
|
+
}
|
|
31456
|
+
function observationFromEvidence(configuration, task, evidence) {
|
|
31457
|
+
const match = evidence.data.organicResults.find((result) => {
|
|
31458
|
+
try {
|
|
31459
|
+
return targetMatchesDomain(configuration.targetDomain, result.domain);
|
|
31460
|
+
} catch {
|
|
31461
|
+
return false;
|
|
31462
|
+
}
|
|
31463
|
+
});
|
|
31464
|
+
return {
|
|
31465
|
+
taskId: task.id,
|
|
31466
|
+
runId: task.runId,
|
|
31467
|
+
keyword: task.displayKeyword,
|
|
31468
|
+
normalizedKeyword: task.normalizedKeyword,
|
|
31469
|
+
device: task.device,
|
|
31470
|
+
state: match ? "observed" : "not_observed_within_depth",
|
|
31471
|
+
organicPosition: match?.rankGroup ?? null,
|
|
31472
|
+
absolutePosition: match?.rankAbsolute ?? null,
|
|
31473
|
+
rankingUrl: match?.url ?? null,
|
|
31474
|
+
observedFeatures: evidence.data.features,
|
|
31475
|
+
checkedAt: evidence.data.checkedAt,
|
|
31476
|
+
provider: evidence.provider,
|
|
31477
|
+
providerTaskId: task.providerTaskId,
|
|
31478
|
+
requestedDepth: configuration.depth,
|
|
31479
|
+
returnedRows: evidence.coverage.returnedRows,
|
|
31480
|
+
retainedRows: evidence.coverage.retainedRows,
|
|
31481
|
+
invalidRows: evidence.coverage.invalidRows,
|
|
31482
|
+
completeness: evidence.coverage.completeness,
|
|
31483
|
+
estimatedCostMicros: evidence.cost.estimatedMicros,
|
|
31484
|
+
actualCostMicros: evidence.cost.actualMicros,
|
|
31485
|
+
warnings: evidence.warnings
|
|
31486
|
+
};
|
|
31487
|
+
}
|
|
31488
|
+
async function inBoundedParallel(items, concurrency, operation) {
|
|
31489
|
+
let next = 0;
|
|
31490
|
+
const workers = Array.from({ length: Math.min(concurrency, items.length) }, async () => {
|
|
31491
|
+
while (next < items.length) {
|
|
31492
|
+
const index = next;
|
|
31493
|
+
next += 1;
|
|
31494
|
+
const item = items[index];
|
|
31495
|
+
if (item) await operation(item);
|
|
31496
|
+
}
|
|
31497
|
+
});
|
|
31498
|
+
await Promise.all(workers);
|
|
31499
|
+
}
|
|
31500
|
+
async function collectLive(configuration, run, collector, dependencies) {
|
|
31501
|
+
const tasks = rankTrackingTasks(run.id, ["pending"], dependencies);
|
|
31502
|
+
const observations = [];
|
|
31503
|
+
const warnings = [];
|
|
31504
|
+
await inBoundedParallel(tasks, 3, async (task) => {
|
|
31505
|
+
try {
|
|
31506
|
+
const evidence = await collector.live(requestForTask(configuration, task));
|
|
31507
|
+
observations.push(observationFromEvidence(configuration, task, evidence));
|
|
31508
|
+
} catch (error) {
|
|
31509
|
+
const mapped = providerError(error);
|
|
31510
|
+
failRankTrackingTask({
|
|
31511
|
+
taskId: task.id,
|
|
31512
|
+
code: mapped.code,
|
|
31513
|
+
message: mapped.message
|
|
31514
|
+
}, dependencies);
|
|
31515
|
+
warnings.push(`${task.displayKeyword} (${task.device}): ${mapped.message}`);
|
|
31516
|
+
}
|
|
31517
|
+
});
|
|
31518
|
+
saveRankObservations(observations, dependencies);
|
|
31519
|
+
return {
|
|
31520
|
+
collected: observations.length,
|
|
31521
|
+
warnings
|
|
31522
|
+
};
|
|
31523
|
+
}
|
|
31524
|
+
async function collectQueued(configuration, run, collector, dependencies) {
|
|
31525
|
+
if (!collector.post || !collector.ready || !collector.collect) throw new SeoError("PROVIDER_UNAVAILABLE", `${collector.provider} does not support queued rank collection. Use live collection or another provider.`);
|
|
31526
|
+
const warnings = [];
|
|
31527
|
+
const observations = [];
|
|
31528
|
+
let posted = 0;
|
|
31529
|
+
let ready = [];
|
|
31530
|
+
try {
|
|
31531
|
+
ready = await collector.ready();
|
|
31532
|
+
} catch (error) {
|
|
31533
|
+
const mapped = providerError(error);
|
|
31534
|
+
warnings.push(`The provider ready list was unavailable; saved task IDs will still be checked: ${mapped.message}`);
|
|
31535
|
+
}
|
|
31536
|
+
const byTaskKey = new Map(ready.flatMap((item) => item.taskKey ? [[item.taskKey, item.providerTaskId]] : []));
|
|
31537
|
+
for (const task of rankTrackingTasks(run.id, ["posting"], dependencies)) {
|
|
31538
|
+
const providerTaskId = byTaskKey.get(task.id);
|
|
31539
|
+
if (providerTaskId) recoverRankTaskReceipt({
|
|
31540
|
+
taskId: task.id,
|
|
31541
|
+
providerTaskId
|
|
31542
|
+
}, dependencies);
|
|
31543
|
+
}
|
|
31544
|
+
await inBoundedParallel(rankTrackingTasks(run.id, ["posted"], dependencies).filter((task) => task.providerTaskId), 5, async (task) => {
|
|
31545
|
+
try {
|
|
31546
|
+
const evidence = await collector.collect?.({
|
|
31547
|
+
providerTaskId: task.providerTaskId,
|
|
31548
|
+
request: requestForTask(configuration, task)
|
|
31549
|
+
});
|
|
31550
|
+
if (!evidence) return;
|
|
31551
|
+
observations.push(observationFromEvidence(configuration, task, evidence));
|
|
31552
|
+
} catch (error) {
|
|
31553
|
+
const mapped = providerError(error);
|
|
31554
|
+
warnings.push(`${task.displayKeyword} (${task.device}): ${mapped.message}`);
|
|
31555
|
+
}
|
|
31556
|
+
});
|
|
31557
|
+
saveRankObservations(observations, dependencies);
|
|
31558
|
+
const pending = rankTrackingTasks(run.id, ["pending"], dependencies);
|
|
31559
|
+
for (let offset = 0; offset < pending.length; offset += RANK_TRACKING_LIMITS.tasksPerProviderPost) {
|
|
31560
|
+
const batch = pending.slice(offset, offset + RANK_TRACKING_LIMITS.tasksPerProviderPost);
|
|
31561
|
+
markRankTasksPosting(run.id, batch.map((task) => task.id), dependencies);
|
|
31562
|
+
try {
|
|
31563
|
+
const result = await collector.post({
|
|
31564
|
+
tasks: batch.map((task) => ({
|
|
31565
|
+
taskKey: task.id,
|
|
31566
|
+
request: requestForTask(configuration, task)
|
|
31567
|
+
})),
|
|
31568
|
+
context: {
|
|
31569
|
+
projectId: configuration.projectId,
|
|
31570
|
+
reportId: "rank-tracking",
|
|
31571
|
+
reportRunId: run.id
|
|
31572
|
+
}
|
|
31573
|
+
});
|
|
31574
|
+
const receiptByKey = new Map(result.receipts.map((receipt) => [receipt.taskKey, receipt]));
|
|
31575
|
+
if (receiptByKey.size !== batch.length) throw new SeoError("PROVIDER_UNAVAILABLE", "The provider did not return one receipt for every queued rank task.");
|
|
31576
|
+
markRankTasksPosted(run.id, batch.map((task) => ({
|
|
31577
|
+
taskId: task.id,
|
|
31578
|
+
providerTaskId: receiptByKey.get(task.id)?.providerTaskId ?? ""
|
|
31579
|
+
})), {
|
|
31580
|
+
estimatedMicros: result.estimatedCostMicros,
|
|
31581
|
+
actualMicros: result.actualCostMicros
|
|
31582
|
+
}, dependencies);
|
|
31583
|
+
posted += batch.length;
|
|
31584
|
+
warnings.push(...result.warnings.map((warning) => warning.message));
|
|
31585
|
+
} catch (error) {
|
|
31586
|
+
const mapped = providerError(error);
|
|
31587
|
+
warnings.push(`${batch.length} queued task${batch.length === 1 ? "" : "s"} remain in recovery after posting was interrupted: ${mapped.message}`);
|
|
31588
|
+
break;
|
|
31589
|
+
}
|
|
31590
|
+
}
|
|
31591
|
+
return {
|
|
31592
|
+
collected: observations.length,
|
|
31593
|
+
posted,
|
|
31594
|
+
warnings
|
|
31595
|
+
};
|
|
31596
|
+
}
|
|
31597
|
+
function due(configuration, now) {
|
|
31598
|
+
return configuration.cadence === "manual" || configuration.nextRunAt === null || Date.parse(configuration.nextRunAt) <= now.getTime();
|
|
31599
|
+
}
|
|
31600
|
+
async function executeRankTracking(input, dependencies = {}) {
|
|
31601
|
+
const cadence = input.cadence ?? "manual";
|
|
31602
|
+
const collectionMethod = input.collectionMethod ?? (cadence === "manual" ? "live" : "queued");
|
|
31603
|
+
const keywordLimit = input.keywordLimit ?? (collectionMethod === "live" ? 25 : 100);
|
|
31604
|
+
const set = getKeywordSet({
|
|
31605
|
+
projectId: input.projectId,
|
|
31606
|
+
idOrName: input.set,
|
|
31607
|
+
tag: input.tag,
|
|
31608
|
+
limit: keywordLimit
|
|
31609
|
+
}, dependencies);
|
|
31610
|
+
const provider = input.provider ?? set.set.provider ?? "dataforseo";
|
|
31611
|
+
const defaultDevice = set.set.market.device ?? "desktop";
|
|
31612
|
+
const configuration = getOrCreateRankTrackingConfiguration({
|
|
31613
|
+
projectId: input.projectId,
|
|
31614
|
+
keywordSetId: set.set.id,
|
|
31615
|
+
targetDomain: input.targetDomain,
|
|
31616
|
+
tag: input.tag,
|
|
31617
|
+
market: set.set.market,
|
|
31618
|
+
devices: input.devices ?? [defaultDevice],
|
|
31619
|
+
provider,
|
|
31620
|
+
collectionMethod,
|
|
31621
|
+
cadence,
|
|
31622
|
+
depth: input.depth ?? 100,
|
|
31623
|
+
keywordLimit
|
|
31624
|
+
}, dependencies);
|
|
31625
|
+
const warnings = [];
|
|
31626
|
+
let run = activeRankTrackingRun(configuration.id, dependencies);
|
|
31627
|
+
let startedRun = false;
|
|
31628
|
+
let collectedTasks = 0;
|
|
31629
|
+
let postedTasks = 0;
|
|
31630
|
+
if (!run && (input.start ?? true) && due(configuration, dependencies.now?.() ?? /* @__PURE__ */ new Date())) {
|
|
31631
|
+
run = startRankTrackingRun({
|
|
31632
|
+
configuration,
|
|
31633
|
+
keywords: set.items.map((item) => ({
|
|
31634
|
+
keyword: item.keyword,
|
|
31635
|
+
normalizedKeyword: item.normalizedKeyword
|
|
31636
|
+
}))
|
|
31637
|
+
}, {
|
|
31638
|
+
...dependencies,
|
|
31639
|
+
id: dependencies.id ?? randomUUID
|
|
31640
|
+
});
|
|
31641
|
+
startedRun = true;
|
|
31642
|
+
}
|
|
31643
|
+
if (run) {
|
|
31644
|
+
const collector = dependencies.collector ?? await (dependencies.collectorFactory ?? defaultCollector)(provider);
|
|
31645
|
+
if (collector.provider !== provider) throw new SeoError("INTERNAL_ERROR", "The selected rank collector does not match the saved provider.");
|
|
31646
|
+
if (configuration.collectionMethod === "live") {
|
|
31647
|
+
const result = await collectLive(configuration, run, collector, dependencies);
|
|
31648
|
+
collectedTasks += result.collected;
|
|
31649
|
+
warnings.push(...result.warnings);
|
|
31650
|
+
} else {
|
|
31651
|
+
const result = await collectQueued(configuration, run, collector, dependencies);
|
|
31652
|
+
collectedTasks += result.collected;
|
|
31653
|
+
postedTasks += result.posted;
|
|
31654
|
+
warnings.push(...result.warnings);
|
|
31655
|
+
}
|
|
31656
|
+
run = latestRankTrackingRun(configuration.id, dependencies);
|
|
31657
|
+
} else run = latestRankTrackingRun(configuration.id, dependencies);
|
|
31658
|
+
return {
|
|
31659
|
+
configuration,
|
|
31660
|
+
run,
|
|
31661
|
+
startedRun,
|
|
31662
|
+
collectedTasks,
|
|
31663
|
+
postedTasks,
|
|
31664
|
+
operationalWarnings: warnings.slice(0, 20)
|
|
31665
|
+
};
|
|
31666
|
+
}
|
|
31667
|
+
//#endregion
|
|
31668
|
+
//#region packages/core/dist/analyze/rank-tracking.js
|
|
31669
|
+
function key$1(item) {
|
|
31670
|
+
return `${item.normalizedKeyword}\u0000${item.device}`;
|
|
31671
|
+
}
|
|
31672
|
+
function changeFor(current, previous) {
|
|
31673
|
+
if (!previous) return {
|
|
31674
|
+
change: "no-history",
|
|
31675
|
+
organicPositionDelta: null,
|
|
31676
|
+
rankingUrlChanged: false
|
|
31677
|
+
};
|
|
31678
|
+
const rankingUrlChanged = current.state === "observed" && previous.state === "observed" && current.rankingUrl !== previous.rankingUrl;
|
|
31679
|
+
if (current.state === "observed" && previous.state === "not_observed_within_depth") return {
|
|
31680
|
+
change: "new",
|
|
31681
|
+
organicPositionDelta: null,
|
|
31682
|
+
rankingUrlChanged
|
|
31683
|
+
};
|
|
31684
|
+
if (current.state === "not_observed_within_depth" && previous.state === "observed") return {
|
|
31685
|
+
change: "lost",
|
|
31686
|
+
organicPositionDelta: null,
|
|
31687
|
+
rankingUrlChanged
|
|
31688
|
+
};
|
|
31689
|
+
if (current.organicPosition !== null && previous.organicPosition !== null) {
|
|
31690
|
+
const delta = previous.organicPosition - current.organicPosition;
|
|
31691
|
+
return {
|
|
31692
|
+
change: delta > 0 ? "improved" : delta < 0 ? "declined" : "unchanged",
|
|
31693
|
+
organicPositionDelta: delta,
|
|
31694
|
+
rankingUrlChanged
|
|
31695
|
+
};
|
|
31696
|
+
}
|
|
31697
|
+
return {
|
|
31698
|
+
change: "unchanged",
|
|
31699
|
+
organicPositionDelta: null,
|
|
31700
|
+
rankingUrlChanged
|
|
31701
|
+
};
|
|
31702
|
+
}
|
|
31703
|
+
const CHANGE_ORDER = {
|
|
31704
|
+
lost: 0,
|
|
31705
|
+
declined: 1,
|
|
31706
|
+
new: 2,
|
|
31707
|
+
improved: 3,
|
|
31708
|
+
unchanged: 4,
|
|
31709
|
+
"no-history": 5
|
|
31710
|
+
};
|
|
31711
|
+
function compareItems(left, right) {
|
|
31712
|
+
return CHANGE_ORDER[left.change] - CHANGE_ORDER[right.change] || Math.abs(right.organicPositionDelta ?? 0) - Math.abs(left.organicPositionDelta ?? 0) || (left.normalizedKeyword < right.normalizedKeyword ? -1 : left.normalizedKeyword > right.normalizedKeyword ? 1 : left.device < right.device ? -1 : left.device > right.device ? 1 : 0);
|
|
31713
|
+
}
|
|
31714
|
+
function reportItems(current, previous, pending) {
|
|
31715
|
+
const previousByKey = new Map(previous.map((item) => [key$1(item), item]));
|
|
31716
|
+
const items = current.map((item) => {
|
|
31717
|
+
const prior = previousByKey.get(key$1(item));
|
|
31718
|
+
const change = changeFor(item, prior);
|
|
31719
|
+
return {
|
|
31720
|
+
keyword: item.keyword,
|
|
31721
|
+
normalizedKeyword: item.normalizedKeyword,
|
|
31722
|
+
device: item.device,
|
|
31723
|
+
state: item.state,
|
|
31724
|
+
organicPosition: item.organicPosition,
|
|
31725
|
+
absolutePosition: item.absolutePosition,
|
|
31726
|
+
rankingUrl: item.rankingUrl,
|
|
31727
|
+
checkedAt: item.checkedAt,
|
|
31728
|
+
previous: prior ? {
|
|
31729
|
+
state: prior.state,
|
|
31730
|
+
organicPosition: prior.organicPosition,
|
|
31731
|
+
absolutePosition: prior.absolutePosition,
|
|
31732
|
+
rankingUrl: prior.rankingUrl,
|
|
31733
|
+
checkedAt: prior.checkedAt
|
|
31734
|
+
} : null,
|
|
31735
|
+
...change,
|
|
31736
|
+
error: null
|
|
31737
|
+
};
|
|
31738
|
+
});
|
|
31739
|
+
const observedKeys = new Set(current.map(key$1));
|
|
31740
|
+
for (const task of pending) {
|
|
31741
|
+
if (observedKeys.has(key$1(task))) continue;
|
|
31742
|
+
const prior = previousByKey.get(key$1(task));
|
|
31743
|
+
items.push({
|
|
31744
|
+
keyword: task.displayKeyword,
|
|
31745
|
+
normalizedKeyword: task.normalizedKeyword,
|
|
31746
|
+
device: task.device,
|
|
31747
|
+
state: task.state === "failed" ? "failed" : "pending",
|
|
31748
|
+
organicPosition: null,
|
|
31749
|
+
absolutePosition: null,
|
|
31750
|
+
rankingUrl: null,
|
|
31751
|
+
checkedAt: null,
|
|
31752
|
+
previous: prior ? {
|
|
31753
|
+
state: prior.state,
|
|
31754
|
+
organicPosition: prior.organicPosition,
|
|
31755
|
+
absolutePosition: prior.absolutePosition,
|
|
31756
|
+
rankingUrl: prior.rankingUrl,
|
|
31757
|
+
checkedAt: prior.checkedAt
|
|
31758
|
+
} : null,
|
|
31759
|
+
change: "no-history",
|
|
31760
|
+
organicPositionDelta: null,
|
|
31761
|
+
rankingUrlChanged: false,
|
|
31762
|
+
error: task.state === "failed" ? {
|
|
31763
|
+
code: task.errorCode,
|
|
31764
|
+
message: task.errorMessage
|
|
31765
|
+
} : null
|
|
31766
|
+
});
|
|
31767
|
+
}
|
|
31768
|
+
return items.sort(compareItems);
|
|
31769
|
+
}
|
|
31770
|
+
function verdict$1(run, counts) {
|
|
31771
|
+
if (!run) return "No rank tracking run has started for this configuration.";
|
|
31772
|
+
if (run.pendingCount > 0) return `${run.snapshotCount} of ${run.taskCount} market and device-specific snapshots are collected; ${run.pendingCount} remain pending.`;
|
|
31773
|
+
if (run.failedCount > 0) return `${run.snapshotCount} snapshots were collected and ${run.failedCount} failed; use the completed rows as partial evidence.`;
|
|
31774
|
+
if (counts.improved + counts.declined + counts.newCount + counts.lost > 0) return `${counts.improved} improved, ${counts.declined} declined, ${counts.newCount} became observed, and ${counts.lost} were no longer observed within the tracked depth.`;
|
|
31775
|
+
return `${counts.observed} rankings were observed and ${counts.notObserved} were not observed within the tracked depth.`;
|
|
31776
|
+
}
|
|
31777
|
+
async function rankTrackingReport(input, dependencies = {}) {
|
|
31778
|
+
const outputLimit = input.outputLimit ?? 100;
|
|
31779
|
+
if (!Number.isSafeInteger(outputLimit) || outputLimit < 1 || outputLimit > 250) throw new SeoError("INVALID_INPUT", "Rank tracking output limit must be from 1 to 250.");
|
|
31780
|
+
const execution = await executeRankTracking(input, dependencies);
|
|
31781
|
+
const { configuration, run } = execution;
|
|
31782
|
+
const current = run ? rankObservations(run.id, dependencies) : [];
|
|
31783
|
+
const priorRun = run ? priorComparableRankTrackingRun(configuration.id, run.id, dependencies) : null;
|
|
31784
|
+
const allItems = reportItems(current, priorRun ? rankObservations(priorRun.id, dependencies) : [], run ? rankTrackingTasks(run.id, void 0, dependencies) : []);
|
|
31785
|
+
const count = (change) => allItems.filter((item) => item.change === change).length;
|
|
31786
|
+
const observed = current.filter((item) => item.state === "observed").length;
|
|
31787
|
+
const notObserved = current.length - observed;
|
|
31788
|
+
const improved = count("improved");
|
|
31789
|
+
const declined = count("declined");
|
|
31790
|
+
const newCount = count("new");
|
|
31791
|
+
const lost = count("lost");
|
|
31792
|
+
const unchanged = count("unchanged");
|
|
31793
|
+
const rankingUrlChanges = allItems.filter((item) => item.rankingUrlChanged).length;
|
|
31794
|
+
const items = allItems.slice(0, outputLimit);
|
|
31795
|
+
const dataStatus = !run ? "unavailable" : run.state === "complete" ? "complete" : run.state === "pending" ? "pending" : "partial";
|
|
31796
|
+
return {
|
|
31797
|
+
schemaVersion: 1,
|
|
31798
|
+
generatedAt: (dependencies.now ?? (() => /* @__PURE__ */ new Date()))().toISOString(),
|
|
31799
|
+
dataStatus,
|
|
31800
|
+
configuration,
|
|
31801
|
+
run,
|
|
31802
|
+
comparison: {
|
|
31803
|
+
priorRunId: priorRun?.id ?? null,
|
|
31804
|
+
priorStartedAt: priorRun?.startedAt ?? null,
|
|
31805
|
+
comparableItems: allItems.filter((item) => item.previous !== null).length
|
|
31806
|
+
},
|
|
31807
|
+
summary: {
|
|
31808
|
+
trackedKeywords: run?.keywordCount ?? 0,
|
|
31809
|
+
trackedDevices: configuration.devices.length,
|
|
31810
|
+
expectedSnapshots: run?.taskCount ?? 0,
|
|
31811
|
+
completedSnapshots: run?.snapshotCount ?? 0,
|
|
31812
|
+
pendingSnapshots: run?.pendingCount ?? 0,
|
|
31813
|
+
failedSnapshots: run?.failedCount ?? 0,
|
|
31814
|
+
observed,
|
|
31815
|
+
notObservedWithinDepth: notObserved,
|
|
31816
|
+
new: newCount,
|
|
31817
|
+
lost,
|
|
31818
|
+
improved,
|
|
31819
|
+
declined,
|
|
31820
|
+
unchanged,
|
|
31821
|
+
rankingUrlChanges,
|
|
31822
|
+
verdict: verdict$1(run, {
|
|
31823
|
+
observed,
|
|
31824
|
+
notObserved,
|
|
31825
|
+
improved,
|
|
31826
|
+
declined,
|
|
31827
|
+
newCount,
|
|
31828
|
+
lost
|
|
31829
|
+
})
|
|
31830
|
+
},
|
|
31831
|
+
items,
|
|
31832
|
+
coverage: {
|
|
31833
|
+
totalItems: allItems.length,
|
|
31834
|
+
returnedItems: items.length,
|
|
31835
|
+
outputLimit,
|
|
31836
|
+
omittedItems: allItems.length - items.length,
|
|
31837
|
+
requestedDepth: configuration.depth,
|
|
31838
|
+
collectionMethod: configuration.collectionMethod,
|
|
31839
|
+
runState: run?.state ?? "not-started"
|
|
31840
|
+
},
|
|
31841
|
+
cost: {
|
|
31842
|
+
currency: "USD",
|
|
31843
|
+
estimatedMicros: run?.estimatedCostMicros ?? null,
|
|
31844
|
+
actualMicros: run?.actualCostMicros ?? null
|
|
31845
|
+
},
|
|
31846
|
+
operationalWarnings: execution.operationalWarnings,
|
|
31847
|
+
caveats: [
|
|
31848
|
+
`Not observed means the target domain was not retained within the first ${configuration.depth} organic results for that exact market and device. It is not proof that the domain does not rank elsewhere.`,
|
|
31849
|
+
"Exact snapshots and Search Console average position answer different questions and should not be substituted for one another.",
|
|
31850
|
+
"Local, national, desktop, and mobile snapshots remain separate configurations and are never blended into one position.",
|
|
31851
|
+
"Search results can change between checks and may differ from signed-in or personalized results.",
|
|
31852
|
+
...run?.state === "partial" || run?.state === "failed" ? ["Failed or pending tasks make this run partial; do not treat missing rows as losses."] : []
|
|
31853
|
+
],
|
|
31854
|
+
nextSteps: [
|
|
31855
|
+
...run?.pendingCount ? ["Run the same report parameters again after queued tasks are ready; collection is free and resumes from saved task IDs."] : [],
|
|
31856
|
+
"Use Search Console opportunity reports to add clicks, impressions, and average-position context before prioritising a ranking change.",
|
|
31857
|
+
"Inspect ranking URL changes for intent drift or competing templates before changing content.",
|
|
31858
|
+
configuration.cadence === "manual" ? "Use queued collection with a daily, weekly, or monthly cadence when this set needs recurring local tracking." : "Run this report from local cron at the configured cadence; overlapping runs are refused and schedule advancement is deterministic."
|
|
31859
|
+
]
|
|
31860
|
+
};
|
|
31861
|
+
}
|
|
31862
|
+
//#endregion
|
|
30234
31863
|
//#region packages/core/dist/analyze/reports/dates.js
|
|
30235
31864
|
function rangeDays(range) {
|
|
30236
31865
|
const start = Date.parse(`${range.startDate}T00:00:00Z`);
|
|
@@ -30696,6 +32325,115 @@ function reportPresentation(report) {
|
|
|
30696
32325
|
};
|
|
30697
32326
|
}
|
|
30698
32327
|
//#endregion
|
|
32328
|
+
//#region packages/core/dist/analyze/saved-keywords.js
|
|
32329
|
+
const DEFAULT_STALE_DAYS = 45;
|
|
32330
|
+
function savedKeywordSetReport(input, dependencies = {}) {
|
|
32331
|
+
const staleDays = input.staleDays ?? DEFAULT_STALE_DAYS;
|
|
32332
|
+
if (!Number.isSafeInteger(staleDays) || staleDays < 1 || staleDays > 365) throw new SeoError("INVALID_INPUT", "Metric freshness must be from 1 to 365 days.");
|
|
32333
|
+
const now = (dependencies.now ?? (() => /* @__PURE__ */ new Date()))();
|
|
32334
|
+
const evidence = (dependencies.getKeywordSet ?? getKeywordSet)(input);
|
|
32335
|
+
const snapshots = evidence.items.filter((item) => item.latestMetric);
|
|
32336
|
+
const observedVolumes = snapshots.filter((item) => item.latestMetric?.metric.monthlySearchVolume.state === "observed");
|
|
32337
|
+
const staleCutoff = now.getTime() - staleDays * 864e5;
|
|
32338
|
+
const staleSnapshots = snapshots.filter((item) => Date.parse(item.latestMetric?.observedAt ?? "") < staleCutoff);
|
|
32339
|
+
const mapped = evidence.items.filter((item) => item.page);
|
|
32340
|
+
const tagGroups = groupedCounts(evidence.items.flatMap((item) => item.tags)).map(([tag, keywordCount]) => ({
|
|
32341
|
+
tag,
|
|
32342
|
+
keywordCount
|
|
32343
|
+
}));
|
|
32344
|
+
const pageMappings = groupedMappings(evidence);
|
|
32345
|
+
const missingMetrics = evidence.items.length - snapshots.length;
|
|
32346
|
+
const unavailableVolumeSnapshots = snapshots.length - observedVolumes.length;
|
|
32347
|
+
const unmapped = evidence.items.length - mapped.length;
|
|
32348
|
+
const partial = evidence.pagination.nextOffset !== null || evidence.pagination.offset > 0 || evidence.pagination.total !== evidence.set.keywordCount;
|
|
32349
|
+
const dataStatus = evidence.items.length === 0 ? "unavailable" : partial ? "partial" : "complete";
|
|
32350
|
+
return {
|
|
32351
|
+
schemaVersion: 1,
|
|
32352
|
+
generatedAt: now.toISOString(),
|
|
32353
|
+
dataStatus,
|
|
32354
|
+
summary: {
|
|
32355
|
+
name: evidence.set.name,
|
|
32356
|
+
totalKeywords: evidence.set.keywordCount,
|
|
32357
|
+
matchedKeywords: evidence.pagination.total,
|
|
32358
|
+
returnedKeywords: evidence.items.length,
|
|
32359
|
+
metricSnapshots: snapshots.length,
|
|
32360
|
+
observedVolumes: observedVolumes.length,
|
|
32361
|
+
observedZeroVolumes: observedVolumes.filter((item) => item.latestMetric?.metric.monthlySearchVolume.value === 0).length,
|
|
32362
|
+
unavailableVolumeSnapshots,
|
|
32363
|
+
staleMetricSnapshots: staleSnapshots.length,
|
|
32364
|
+
mappedKeywords: mapped.length,
|
|
32365
|
+
targetPages: pageMappings.filter((item) => item.kind === "target").length,
|
|
32366
|
+
proposedPages: pageMappings.filter((item) => item.kind === "proposed").length,
|
|
32367
|
+
distinctTags: tagGroups.length,
|
|
32368
|
+
verdict: `${evidence.items.length} saved keywords were returned; search volume is observed for ${observedVolumes.length}, unavailable in ${unavailableVolumeSnapshots} saved snapshot${unavailableVolumeSnapshots === 1 ? "" : "s"}, and ${mapped.length} have page mappings.`
|
|
32369
|
+
},
|
|
32370
|
+
evidence,
|
|
32371
|
+
analysis: {
|
|
32372
|
+
metricFreshnessDays: staleDays,
|
|
32373
|
+
tagGroups,
|
|
32374
|
+
pageMappings
|
|
32375
|
+
},
|
|
32376
|
+
findings: [
|
|
32377
|
+
...missingMetrics > 0 ? [{
|
|
32378
|
+
code: "missing-metrics",
|
|
32379
|
+
evidenceRef: "evidence.items[*].latestMetric",
|
|
32380
|
+
detail: `${missingMetrics} returned keywords have no saved provider metric snapshot.`,
|
|
32381
|
+
action: "Preview a keyword-set refresh before using provider metrics to prioritize them."
|
|
32382
|
+
}] : [],
|
|
32383
|
+
...unavailableVolumeSnapshots > 0 ? [{
|
|
32384
|
+
code: "unavailable-volume-evidence",
|
|
32385
|
+
evidenceRef: "evidence.items[*].latestMetric.metric.monthlySearchVolume",
|
|
32386
|
+
detail: `${unavailableVolumeSnapshots} saved metric snapshot${unavailableVolumeSnapshots === 1 ? "" : "s"} ${unavailableVolumeSnapshots === 1 ? "has" : "have"} no observed search volume. This is not an observed zero.`,
|
|
32387
|
+
action: "Keep these terms separate from observed-zero terms and verify them with another source or a later refresh before using demand as a filter."
|
|
32388
|
+
}] : [],
|
|
32389
|
+
...staleSnapshots.length > 0 ? [{
|
|
32390
|
+
code: "stale-metrics",
|
|
32391
|
+
evidenceRef: "evidence.items[*].latestMetric.observedAt",
|
|
32392
|
+
detail: `${staleSnapshots.length} saved metric snapshots are older than ${staleDays} days.`,
|
|
32393
|
+
action: "Preview and selectively refresh the stale provider evidence."
|
|
32394
|
+
}] : [],
|
|
32395
|
+
...unmapped > 0 ? [{
|
|
32396
|
+
code: "unmapped-keywords",
|
|
32397
|
+
evidenceRef: "evidence.items[*].page",
|
|
32398
|
+
detail: `${unmapped} returned keywords have no existing or proposed page mapping.`,
|
|
32399
|
+
action: "Review shared intent and current results before mapping terms to an existing page or a proposed page."
|
|
32400
|
+
}] : []
|
|
32401
|
+
],
|
|
32402
|
+
caveats: [
|
|
32403
|
+
"Tags are user-managed research labels, not proof that keywords share one intent or should use one page.",
|
|
32404
|
+
"Provider metrics are market estimates; missing evidence differs from an observed zero.",
|
|
32405
|
+
"Page mappings record planning state and do not prove that a page ranks, is indexed, or should be created.",
|
|
32406
|
+
...partial ? ["This view is filtered or paginated and cannot support a complete-set all-clear."] : []
|
|
32407
|
+
],
|
|
32408
|
+
nextSteps: [
|
|
32409
|
+
"Compare relevant saved terms with Search Console evidence for the project before changing priorities.",
|
|
32410
|
+
"Inspect current results in the same market before turning a tag group into a page or programmatic template.",
|
|
32411
|
+
"Use the refresh preview to check provider cost before updating saved metric snapshots."
|
|
32412
|
+
]
|
|
32413
|
+
};
|
|
32414
|
+
}
|
|
32415
|
+
function groupedCounts(values) {
|
|
32416
|
+
const counts = /* @__PURE__ */ new Map();
|
|
32417
|
+
for (const value of values) counts.set(value, (counts.get(value) ?? 0) + 1);
|
|
32418
|
+
return [...counts].sort(([left, leftCount], [right, rightCount]) => rightCount - leftCount || (left < right ? -1 : left > right ? 1 : 0)).slice(0, 50);
|
|
32419
|
+
}
|
|
32420
|
+
function groupedMappings(evidence) {
|
|
32421
|
+
const counts = /* @__PURE__ */ new Map();
|
|
32422
|
+
for (const item of evidence.items) {
|
|
32423
|
+
if (!item.page) continue;
|
|
32424
|
+
const key = `${item.page.kind}\u0000${item.page.url}`;
|
|
32425
|
+
const current = counts.get(key);
|
|
32426
|
+
counts.set(key, {
|
|
32427
|
+
...item.page,
|
|
32428
|
+
count: (current?.count ?? 0) + 1
|
|
32429
|
+
});
|
|
32430
|
+
}
|
|
32431
|
+
return [...counts.values()].sort((left, right) => right.count - left.count || (left.url < right.url ? -1 : left.url > right.url ? 1 : 0)).slice(0, 50).map(({ count, ...mapping }) => ({
|
|
32432
|
+
...mapping,
|
|
32433
|
+
keywordCount: count
|
|
32434
|
+
}));
|
|
32435
|
+
}
|
|
32436
|
+
//#endregion
|
|
30699
32437
|
//#region packages/core/dist/analyze/second-page.js
|
|
30700
32438
|
const MAX_SOURCE_ROWS = 1e5;
|
|
30701
32439
|
const defaultDependencies = {
|
|
@@ -34215,6 +35953,7 @@ const TELEMETRY_REPORTS = [
|
|
|
34215
35953
|
"keyword-metrics",
|
|
34216
35954
|
"keyword-opportunities",
|
|
34217
35955
|
"keyword-research",
|
|
35956
|
+
"saved-keywords",
|
|
34218
35957
|
"link-evidence",
|
|
34219
35958
|
"server-log-analysis",
|
|
34220
35959
|
"link-recovery",
|
|
@@ -34232,6 +35971,7 @@ const TELEMETRY_REPORTS = [
|
|
|
34232
35971
|
"pseo-opportunities",
|
|
34233
35972
|
"query-clusters",
|
|
34234
35973
|
"quick-wins",
|
|
35974
|
+
"rank-tracking",
|
|
34235
35975
|
"redirect-trace",
|
|
34236
35976
|
"narrative-report",
|
|
34237
35977
|
"second-page",
|
|
@@ -35979,7 +37719,63 @@ const keywordOpportunitiesInput = z$1.strictObject({
|
|
|
35979
37719
|
message: "External keyword context requires countryCode and languageCode."
|
|
35980
37720
|
});
|
|
35981
37721
|
});
|
|
37722
|
+
const savedKeywordsInput = z$1.strictObject({
|
|
37723
|
+
projectId: z$1.string().trim().min(1).max(80),
|
|
37724
|
+
set: z$1.string().trim().min(1).max(80),
|
|
37725
|
+
tag: z$1.string().trim().min(1).max(40).optional(),
|
|
37726
|
+
limit: z$1.number().int().min(1).max(1e3).default(100),
|
|
37727
|
+
offset: z$1.number().int().min(0).max(1e5).default(0),
|
|
37728
|
+
staleDays: z$1.number().int().min(1).max(365).default(45)
|
|
37729
|
+
});
|
|
37730
|
+
const rankTrackingInput = z$1.strictObject({
|
|
37731
|
+
projectId: z$1.string().trim().min(1).max(80),
|
|
37732
|
+
set: z$1.string().trim().min(1).max(80),
|
|
37733
|
+
targetDomain: z$1.string().trim().min(1).max(253),
|
|
37734
|
+
tag: z$1.string().trim().min(1).max(40).optional(),
|
|
37735
|
+
devices: z$1.array(z$1.enum(["desktop", "mobile"])).min(1).max(2).refine((items) => new Set(items).size === items.length, { message: "Choose each device once." }).optional(),
|
|
37736
|
+
provider: providerIdInput.optional(),
|
|
37737
|
+
collectionMethod: z$1.enum(["live", "queued"]).optional(),
|
|
37738
|
+
cadence: z$1.enum([
|
|
37739
|
+
"manual",
|
|
37740
|
+
"daily",
|
|
37741
|
+
"weekly",
|
|
37742
|
+
"monthly"
|
|
37743
|
+
]).default("manual"),
|
|
37744
|
+
depth: z$1.number().int().min(1).max(100).default(100),
|
|
37745
|
+
keywordLimit: z$1.number().int().min(1).max(1e3).optional(),
|
|
37746
|
+
start: z$1.boolean().default(true),
|
|
37747
|
+
outputLimit: z$1.number().int().min(1).max(250).default(100)
|
|
37748
|
+
});
|
|
35982
37749
|
function registerKeywordTools(server, dependencies = {}) {
|
|
37750
|
+
server.registerTool("seo_saved_keywords", {
|
|
37751
|
+
description: "Review one bounded local keyword set with metric freshness, tags, page mappings, and evidence limits",
|
|
37752
|
+
inputSchema: savedKeywordsInput
|
|
37753
|
+
}, async ({ projectId, set, tag, limit, offset, staleDays }) => {
|
|
37754
|
+
try {
|
|
37755
|
+
const report = (dependencies.savedKeywordSetReport ?? savedKeywordSetReport)({
|
|
37756
|
+
projectId,
|
|
37757
|
+
idOrName: set,
|
|
37758
|
+
tag,
|
|
37759
|
+
limit,
|
|
37760
|
+
offset,
|
|
37761
|
+
staleDays
|
|
37762
|
+
});
|
|
37763
|
+
return toolSuccess(report.summary.verdict, compactAgentWorkflowOutput(report));
|
|
37764
|
+
} catch (error) {
|
|
37765
|
+
return toolError(error);
|
|
37766
|
+
}
|
|
37767
|
+
});
|
|
37768
|
+
server.registerTool("seo_rank_tracking", {
|
|
37769
|
+
description: "Collect and compare exact market and device-specific ranks for one bounded saved keyword set, with local history, queued recovery, coverage, and cost evidence",
|
|
37770
|
+
inputSchema: rankTrackingInput
|
|
37771
|
+
}, async (input) => {
|
|
37772
|
+
try {
|
|
37773
|
+
const report = await (dependencies.rankTrackingReport ?? rankTrackingReport)(input);
|
|
37774
|
+
return toolSuccess(report.summary.verdict, compactAgentWorkflowOutput(report));
|
|
37775
|
+
} catch (error) {
|
|
37776
|
+
return toolError(error);
|
|
37777
|
+
}
|
|
37778
|
+
});
|
|
35983
37779
|
server.registerTool("seo_keyword_metrics", {
|
|
35984
37780
|
description: "Compare bounded third-party keyword demand, cost, competition, difficulty, intent, result-count, and trend evidence",
|
|
35985
37781
|
inputSchema: {
|
|
@@ -37998,6 +39794,39 @@ const REPORT_DEPTH = {
|
|
|
37998
39794
|
}
|
|
37999
39795
|
]
|
|
38000
39796
|
},
|
|
39797
|
+
"saved-keywords": {
|
|
39798
|
+
readOrder: [
|
|
39799
|
+
"dataStatus, summary, evidence pagination, and any tag filter",
|
|
39800
|
+
"the saved set market, source, refresh time, and keyword count",
|
|
39801
|
+
"each item metric value state, observation time, tags, and page mapping",
|
|
39802
|
+
"analysis groups, findings, caveats, and nextSteps"
|
|
39803
|
+
],
|
|
39804
|
+
doNotClaim: [
|
|
39805
|
+
"User-managed tags do not prove shared intent or justify one page or template.",
|
|
39806
|
+
"Provider metrics are estimates rather than first-party rankings or traffic, and a missing value differs from an observed zero.",
|
|
39807
|
+
"A target or proposed page mapping does not prove that the page should exist, ranks, or is indexed.",
|
|
39808
|
+
"A filtered, capped, or paginated view cannot support a conclusion about the complete set."
|
|
39809
|
+
],
|
|
39810
|
+
verify: "Compare relevant terms with Search Console and inspect current results in the saved market before changing a page or template plan.",
|
|
39811
|
+
related: [
|
|
39812
|
+
{
|
|
39813
|
+
id: "keyword-metrics",
|
|
39814
|
+
reason: "Refreshes a supplied term list."
|
|
39815
|
+
},
|
|
39816
|
+
{
|
|
39817
|
+
id: "keyword-research",
|
|
39818
|
+
reason: "Discovers more candidate terms."
|
|
39819
|
+
},
|
|
39820
|
+
{
|
|
39821
|
+
id: "keyword-opportunities",
|
|
39822
|
+
reason: "Adds first-party opportunity evidence."
|
|
39823
|
+
},
|
|
39824
|
+
{
|
|
39825
|
+
id: "pseo-opportunities",
|
|
39826
|
+
reason: "Reviews repeatable programmatic patterns."
|
|
39827
|
+
}
|
|
39828
|
+
]
|
|
39829
|
+
},
|
|
38001
39830
|
"query-clusters": {
|
|
38002
39831
|
readOrder: [
|
|
38003
39832
|
"summary cluster and query counts, clicks, impressions, threshold, limit, and brand filtering",
|
|
@@ -38196,6 +40025,40 @@ const REPORT_DEPTH = {
|
|
|
38196
40025
|
}
|
|
38197
40026
|
]
|
|
38198
40027
|
},
|
|
40028
|
+
"rank-tracking": {
|
|
40029
|
+
readOrder: [
|
|
40030
|
+
"dataStatus, configuration market, devices, targetDomain, depth, collection method, cadence, and run state",
|
|
40031
|
+
"coverage, pending and failed counts, provider cost, warnings, and caveats",
|
|
40032
|
+
"comparison prior run and per-item current and previous observation states",
|
|
40033
|
+
"organic position change, absolute position, ranking URL changes, and checkedAt",
|
|
40034
|
+
"nextSteps for first-party context, template review, or queued collection"
|
|
40035
|
+
],
|
|
40036
|
+
doNotClaim: [
|
|
40037
|
+
"Not observed within the configured depth is not proof that the domain does not rank.",
|
|
40038
|
+
"Exact snapshots must not be substituted for Search Console average position or blended across markets and devices.",
|
|
40039
|
+
"A ranking change alone does not establish causation, traffic impact, or future performance.",
|
|
40040
|
+
"Pending or failed tasks cannot be treated as losses or zero rankings."
|
|
40041
|
+
],
|
|
40042
|
+
verify: "Confirm the same market, device, depth, and target-domain scope, then inspect the ranking URL and Search Console evidence before acting.",
|
|
40043
|
+
related: [
|
|
40044
|
+
{
|
|
40045
|
+
id: "serp-results",
|
|
40046
|
+
reason: "Inspects the current result page."
|
|
40047
|
+
},
|
|
40048
|
+
{
|
|
40049
|
+
id: "saved-keywords",
|
|
40050
|
+
reason: "Reviews the tracked keyword set."
|
|
40051
|
+
},
|
|
40052
|
+
{
|
|
40053
|
+
id: "quick-wins",
|
|
40054
|
+
reason: "Adds first-party opportunity evidence."
|
|
40055
|
+
},
|
|
40056
|
+
{
|
|
40057
|
+
id: "striking-distance",
|
|
40058
|
+
reason: "Adds first-party average-position evidence."
|
|
40059
|
+
}
|
|
40060
|
+
]
|
|
40061
|
+
},
|
|
38199
40062
|
"segment-impact": {
|
|
38200
40063
|
readOrder: [
|
|
38201
40064
|
"before, after, and rangeDays",
|
|
@@ -38783,6 +40646,13 @@ const REPORT_GUIDANCE = {
|
|
|
38783
40646
|
avoidWhen: ["You need exact current rankings or pages in the live results.", "You plan to turn every discovered term into a page without checking intent or fit."],
|
|
38784
40647
|
outcome: "A bounded keyword set with discovery sources, provider estimates, evidence states, and research follow-ups."
|
|
38785
40648
|
},
|
|
40649
|
+
"saved-keywords": {
|
|
40650
|
+
name: "Saved keyword set",
|
|
40651
|
+
description: "Review one local keyword set with metric freshness, tags, page mappings, and explicit view limits.",
|
|
40652
|
+
useWhen: ["You want to continue earlier keyword research from a saved project set.", "You need to review stale, unmapped, or tagged terms before deeper research."],
|
|
40653
|
+
avoidWhen: ["You need independent keyword discovery or a current result snapshot.", "You plan to treat user-managed tags as proof of shared search intent."],
|
|
40654
|
+
outcome: "A bounded local research view with saved evidence, freshness state, planning mappings, and next steps."
|
|
40655
|
+
},
|
|
38786
40656
|
"query-clusters": {
|
|
38787
40657
|
name: "Search query clusters",
|
|
38788
40658
|
description: "Group related Search Console queries so repeated demand themes are easier to review.",
|
|
@@ -38832,6 +40702,17 @@ const REPORT_GUIDANCE = {
|
|
|
38832
40702
|
avoidWhen: ["You need rank history or scheduled tracking across many keywords.", "You want a domain strength, content quality, or ranking feasibility verdict."],
|
|
38833
40703
|
outcome: "A bounded result snapshot with exact retained ranks, domain repetition, query corrections, features, cost, and coverage."
|
|
38834
40704
|
},
|
|
40705
|
+
"rank-tracking": {
|
|
40706
|
+
name: "Exact rank tracking",
|
|
40707
|
+
description: "Collect and compare exact organic positions for a saved keyword set without mixing markets, devices, or Search Console averages.",
|
|
40708
|
+
useWhen: [
|
|
40709
|
+
"You need repeatable exact-rank snapshots for a fixed market and device.",
|
|
40710
|
+
"You want new, lost, improved, declined, or ranking URL change evidence over time.",
|
|
40711
|
+
"A recurring set should use cheaper queued collection and local history."
|
|
40712
|
+
],
|
|
40713
|
+
avoidWhen: ["You need first-party clicks, impressions, or average position; use a Search Console report.", "One current query needs result-page inspection but no history; use live search results."],
|
|
40714
|
+
outcome: "A bounded exact-rank comparison with explicit depth, pending and failed states, provider cost, and local retention evidence."
|
|
40715
|
+
},
|
|
38835
40716
|
"segment-impact": {
|
|
38836
40717
|
name: "Search impact by segment",
|
|
38837
40718
|
description: "Compare page or query groups across equal periods to find which parts of a site moved.",
|
|
@@ -39338,6 +41219,8 @@ const reportGroups = [
|
|
|
39338
41219
|
"seo_keyword_metrics",
|
|
39339
41220
|
"seo_keyword_opportunities",
|
|
39340
41221
|
"seo_keyword_research",
|
|
41222
|
+
"seo_rank_tracking",
|
|
41223
|
+
"seo_saved_keywords",
|
|
39341
41224
|
"seo_serp_results"
|
|
39342
41225
|
]
|
|
39343
41226
|
},
|