crawlmark-mcp 0.1.0 → 0.1.2
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/.env.example +7 -8
- package/README.md +92 -26
- package/dist/analysis/tech-issues.js +6 -10
- package/dist/cli-help.js +5 -7
- package/dist/cli.js +5 -4
- package/dist/clients.js +5 -8
- package/dist/config.js +2 -3
- package/dist/lib/clients.js +12 -6
- package/dist/lib/country.js +0 -11
- package/dist/lib/env-file.js +14 -5
- package/dist/lib/issue-snapshots.js +0 -6
- package/dist/lib/provider-data.js +2 -2
- package/dist/lib/vendor-mcp.js +155 -0
- package/dist/prompts/templates/keyword-research.md +1 -1
- package/dist/providers/ahrefs-shape.js +2 -2
- package/dist/providers/semrush-shape.js +20 -314
- package/dist/providers/semrush.js +73 -480
- package/dist/providers/seo-data.js +13 -1
- package/dist/providers/units-billing.js +1 -1
- package/dist/reports/render/sections.js +2 -1
- package/dist/reports/technical-issues.js +3 -11
- package/dist/reports/templates/run.js +8 -4
- package/dist/reports/templates/schema.js +20 -10
- package/dist/reports/templates/write.js +15 -2
- package/dist/setup/apps.js +1 -1
- package/dist/setup/menu.js +2 -1
- package/dist/setup/status-providers.js +1 -3
- package/dist/setup/status.js +16 -22
- package/dist/setup/vendor-mcp-step.js +339 -0
- package/dist/setup/wizard.js +46 -60
- package/dist/tools/competitors.js +1 -1
- package/dist/tools/raw.js +4 -5
- package/dist/tools/reports.js +6 -6
- package/package.json +1 -1
- package/dist/providers/semrush-site-audit.js +0 -175
|
@@ -1,33 +1,27 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Semrush client
|
|
3
|
-
* domain_organic_organic), backlinks via the v4 overview when SEMRUSH_V4_API_KEY is set
|
|
4
|
-
* and the v3 backlinks_overview (at /analytics/v1/) otherwise, and the free balance
|
|
5
|
-
* check. Endpoints, parameters, prices and errors follow docs/research/semrush-api.md
|
|
6
|
-
* "Endpoints" (accessed 2026-10-13); the pure parts live in ./semrush-shape.ts.
|
|
2
|
+
* Semrush client: v4 API only. Semrush's older v3 keys and reports aren't supported.
|
|
7
3
|
*
|
|
8
|
-
* -
|
|
9
|
-
*
|
|
10
|
-
* -
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
4
|
+
* - keyword volume: v4 Get Keyword Metrics (`/apis/v4/keywords/v1/metrics`), one request per keyword, 20 units each
|
|
5
|
+
* (Early Access per Semrush);
|
|
6
|
+
* - backlinks summary: the v4 Backlinks overview (`/apis/v4/backlinks/v1/overview`), 45 units.
|
|
7
|
+
*
|
|
8
|
+
* Everything else (rankings, competitors, visibility, keyword ideas and gaps, Site Audit) isn't in Semrush's v4 API, so
|
|
9
|
+
* the provider doesn't claim those capabilities and routing sends them to DataForSEO or says why not (seo-data.ts).
|
|
10
|
+
*
|
|
11
|
+
* The key goes in `Authorization: Apikey`, never in a URL. There is no documented free key check: the key is first
|
|
12
|
+
* checked by the first paid call. At most 4 requests in flight and 100 ms between starts (10/s, 10 at once). Errors are
|
|
13
|
+
* never retried, except one retry of a 429. Units are recorded per request; a timeout records the estimate. The pure
|
|
14
|
+
* parts live in ./semrush-shape.ts.
|
|
18
15
|
*/
|
|
19
16
|
import { env } from "../config.js";
|
|
20
17
|
import { globalUnitCap, recordUnits, unitsThisMonth } from "../lib/budget.js";
|
|
21
18
|
import { TTL } from "../lib/cache.js";
|
|
22
|
-
import { noCountryMessage
|
|
19
|
+
import { noCountryMessage } from "../lib/country.js";
|
|
23
20
|
import { ProviderUnavailableError } from "../lib/provider-ids.js";
|
|
24
21
|
import { createLimiter } from "../lib/rate.js";
|
|
25
22
|
import { TIMEOUTS } from "../lib/timeout.js";
|
|
26
|
-
import {
|
|
27
|
-
import { domainVsDomain, mapSemrushGapRows, mapSemrushIdeas, positionHeaderFor, classifySemrushError, classifySemrushV4Error, estimateSemrushUnits, mapSemrushBacklinksV3, mapSemrushBacklinksV4, mapSemrushCompetitors, mapSemrushKeywordVolumes, mapSemrushRankedKeywords, parseSemrushCsv, parseSemrushError, parseSemrushV4Error, normalizeKeywordList, parseUnitsBalance, partialLinesNote, SEMRUSH_COLUMNS, SEMRUSH_KEYWORD_BATCH, SEMRUSH_UNITS, SEMRUSH_V4_BACKLINK_FIELDS, SemrushFormatError, } from "./semrush-shape.js";
|
|
28
|
-
import { mapSemrushCompetitorsFull, mapSemrushVisibility, mapSemrushVisibilityHistory } from "./semrush-shape.js";
|
|
23
|
+
import { classifySemrushV4Error, mapSemrushBacklinksV4, mapSemrushKeywordMetricsV4, normalizeKeywordList, parseSemrushV4Error, SEMRUSH_UNITS, SEMRUSH_V4_BACKLINK_FIELDS, SEMRUSH_V4_METRICS_NOTE, } from "./semrush-shape.js";
|
|
29
24
|
import { isTimeoutOrAbort, redact, retryAfterMs, unitsCall } from "./units-billing.js";
|
|
30
|
-
import { assertSemrushAllowed } from "./semrush-site-audit.js";
|
|
31
25
|
export class SemrushApiError extends Error {
|
|
32
26
|
code;
|
|
33
27
|
kind;
|
|
@@ -43,14 +37,16 @@ const realSleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
|
43
37
|
export const semrushTiming = { sleep: realSleep, spacing: realSleep };
|
|
44
38
|
/** Shared by every Semrush call in this process: 4 in flight, 100 ms between starts. */
|
|
45
39
|
export const semrushLimiter = createLimiter({ maxInFlight: 4, minSpacingMs: 100, sleep: (ms) => semrushTiming.spacing(ms) });
|
|
46
|
-
const secrets = () => [env.SEMRUSH_API_KEY
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
40
|
+
const secrets = () => [env.SEMRUSH_API_KEY];
|
|
41
|
+
function apiKey(capability) {
|
|
42
|
+
if (!env.SEMRUSH_API_KEY)
|
|
43
|
+
throw new ProviderUnavailableError(capability, "semrush", "SEMRUSH_API_KEY is not set. Run setup --step semrush.");
|
|
44
|
+
return env.SEMRUSH_API_KEY;
|
|
45
|
+
}
|
|
46
|
+
/** One request through the limiter; a 429 is retried once. Returns the body text and status. */
|
|
47
|
+
async function send(req, key, signal) {
|
|
48
|
+
const url = `${env.SEMRUSH_BASE_URL}${req.path}?${new URLSearchParams(req.params).toString()}`;
|
|
49
|
+
const headers = { Authorization: `Apikey ${key}`, Accept: "application/json" };
|
|
54
50
|
for (let attempt = 0;; attempt++) {
|
|
55
51
|
const res = await semrushLimiter.run(async () => {
|
|
56
52
|
try {
|
|
@@ -63,55 +59,25 @@ async function send(req, signal) {
|
|
|
63
59
|
catch (err) {
|
|
64
60
|
if (isTimeoutOrAbort(err))
|
|
65
61
|
throw err;
|
|
66
|
-
// Never the URL: it carries the v3 key.
|
|
67
62
|
throw new Error(`Couldn't reach Semrush (${req.label}): ${redact(err instanceof Error ? err.message : String(err), secrets())}`);
|
|
68
63
|
}
|
|
69
64
|
});
|
|
70
65
|
const text = await res.text();
|
|
71
|
-
|
|
72
|
-
if (throttled && attempt === 0) {
|
|
66
|
+
if (res.status === 429 && attempt === 0) {
|
|
73
67
|
await semrushTiming.sleep(retryAfterMs(res.headers.get("retry-after")));
|
|
74
68
|
continue;
|
|
75
69
|
}
|
|
76
70
|
return { status: res.status, text };
|
|
77
71
|
}
|
|
78
72
|
}
|
|
79
|
-
function v3Key(capability) {
|
|
80
|
-
if (!env.SEMRUSH_API_KEY)
|
|
81
|
-
throw new ProviderUnavailableError(capability, "semrush", "SEMRUSH_API_KEY is not set. Run setup --step semrush.");
|
|
82
|
-
return env.SEMRUSH_API_KEY;
|
|
83
|
-
}
|
|
84
|
-
/**
|
|
85
|
-
* Phase 13 B: one Semrush Projects API GET (JSON) for the Site Audit reader (semrush-site-audit.ts), which checks its
|
|
86
|
-
* own path allowlist first. The v3 key goes in the query string (never shown); `ledgerLabel` is what the ledger
|
|
87
|
-
* records. Units are recorded by the caller once the body is read. Errors carry redacted vendor text only.
|
|
88
|
-
*/
|
|
89
|
-
export async function semrushProjectsGet(path, ledgerLabel, params) {
|
|
90
|
-
// Defence in depth (SEC-6): the Site Audit allowlist is checked here too.
|
|
91
|
-
assertSemrushAllowed("GET", path, params);
|
|
92
|
-
const key = v3Key("siteAudit");
|
|
93
|
-
return send({ path, label: ledgerLabel, auth: { v3: key }, params });
|
|
94
|
-
}
|
|
95
|
-
/** Vendor text made safe to show (keys redacted, control characters stripped, capped). */
|
|
96
|
-
export const semrushRedact = (text) => redact(text, secrets());
|
|
97
|
-
/** The client's Semrush database, or unavailable (no country, or one Semrush has no documented database for). */
|
|
98
|
-
function database(client, capability) {
|
|
99
|
-
if (!client.country)
|
|
100
|
-
throw new ProviderUnavailableError(capability, "semrush", noCountryMessage(client));
|
|
101
|
-
const db = semrushDatabase(client.country);
|
|
102
|
-
if (!db)
|
|
103
|
-
throw new ProviderUnavailableError(capability, "semrush", `Semrush has no database for country ${client.country} (${client.domain}); set another country or use a different dataProvider for this client.`);
|
|
104
|
-
return db;
|
|
105
|
-
}
|
|
106
73
|
/**
|
|
107
|
-
*
|
|
108
|
-
*
|
|
109
|
-
* documented price x lines returned. A timeout records `estimate` and rethrows.
|
|
74
|
+
* One billed v4 GET (JSON). A timeout records `estimate` and rethrows; an error body is classified; a success records
|
|
75
|
+
* `estimate` against the client's ledger. Returns the parsed body.
|
|
110
76
|
*/
|
|
111
|
-
async function
|
|
77
|
+
async function v4Get(client, req, key, estimate) {
|
|
112
78
|
let res;
|
|
113
79
|
try {
|
|
114
|
-
res = await send(req);
|
|
80
|
+
res = await send(req, key);
|
|
115
81
|
}
|
|
116
82
|
catch (err) {
|
|
117
83
|
if (!isTimeoutOrAbort(err))
|
|
@@ -119,470 +85,97 @@ async function v3Report(client, req, columns, type, estimate, db, accept = {}) {
|
|
|
119
85
|
recordUnits(client.slug, "semrush", `${req.label} (timeout, estimated)`, estimate);
|
|
120
86
|
throw new Error(`Semrush ${req.label} timed out after ${TIMEOUTS.networkMs / 1000}s; Semrush may still have billed it. The estimated ${estimate} units were recorded against the cap.`);
|
|
121
87
|
}
|
|
122
|
-
|
|
123
|
-
if (err?.code === 50) {
|
|
124
|
-
recordUnits(client.slug, "semrush", req.label, 0);
|
|
125
|
-
return { rows: [], units: 0, nothingFound: true };
|
|
126
|
-
}
|
|
127
|
-
if (err) {
|
|
128
|
-
const info = classifySemrushError({ code: err.code, text: redact(err.text, secrets()) }, db);
|
|
129
|
-
throw new SemrushApiError(err.code, info.kind, info.message);
|
|
130
|
-
}
|
|
131
|
-
if (res.status === 429) {
|
|
132
|
-
const info = classifySemrushError({ code: 429, text: "Too Many Requests" }, db);
|
|
133
|
-
throw new SemrushApiError(429, info.kind, info.message);
|
|
134
|
-
}
|
|
135
|
-
if (res.status >= 400)
|
|
136
|
-
throw new SemrushApiError(res.status, "server", `Semrush ${req.label} returned HTTP ${res.status}: ${redact(res.text, secrets())}`);
|
|
137
|
-
let rows;
|
|
88
|
+
let json;
|
|
138
89
|
try {
|
|
139
|
-
|
|
90
|
+
json = JSON.parse(res.text);
|
|
140
91
|
}
|
|
141
|
-
catch
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
const
|
|
146
|
-
|
|
147
|
-
throw
|
|
92
|
+
catch {
|
|
93
|
+
json = null;
|
|
94
|
+
}
|
|
95
|
+
if (res.status >= 400 || json?.meta?.success === false) {
|
|
96
|
+
const e = parseSemrushV4Error(json);
|
|
97
|
+
const info = classifySemrushV4Error(res.status, e && { code: e.code, message: redact(e.message, secrets()) });
|
|
98
|
+
throw new SemrushApiError(e?.code ?? res.status, info.kind, info.message);
|
|
148
99
|
}
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
return { rows, units, nothingFound: false };
|
|
100
|
+
recordUnits(client.slug, "semrush", req.label, estimate);
|
|
101
|
+
return json;
|
|
152
102
|
}
|
|
153
103
|
const meta = (r) => ({ provider: "semrush", costUsd: 0, units: r.units, fromCache: r.fromCache });
|
|
154
|
-
const withNotes = (notes) => (notes.length ? { notes } : {});
|
|
155
104
|
// ------------------------------------------------------------------ capabilities
|
|
156
|
-
/** Keyword volume via
|
|
105
|
+
/** Keyword volume via v4 Get Keyword Metrics: one request (20 units) per keyword, one after another. */
|
|
157
106
|
export async function semrushKeywordVolume(client, input, opts = {}) {
|
|
158
|
-
// Defensive: the routed searchVolume already normalises, but `;` inside an entry would bill extra
|
|
107
|
+
// Defensive: the routed searchVolume already normalises, but `;` inside an entry would bill extra requests.
|
|
159
108
|
const keywords = normalizeKeywordList(input);
|
|
160
|
-
const
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
109
|
+
const key = apiKey("keywordVolume");
|
|
110
|
+
if (!client.country)
|
|
111
|
+
throw new ProviderUnavailableError("keywordVolume", "semrush", noCountryMessage(client));
|
|
112
|
+
const country = client.country.toUpperCase();
|
|
113
|
+
const perKeyword = SEMRUSH_UNITS.keyword_metrics_v4;
|
|
165
114
|
const r = await unitsCall({
|
|
166
115
|
client,
|
|
167
116
|
provider: "semrush",
|
|
168
117
|
capability: "keywordVolume",
|
|
169
118
|
keyParts: { country: client.country, keywords },
|
|
170
119
|
ttl: TTL.month,
|
|
171
|
-
estimate:
|
|
120
|
+
estimate: perKeyword * keywords.length,
|
|
172
121
|
refresh: opts.refresh,
|
|
173
122
|
run: async () => {
|
|
174
123
|
const result = [];
|
|
175
|
-
const notes = [];
|
|
176
124
|
let units = 0;
|
|
177
|
-
for (const
|
|
178
|
-
const
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
units += out.units;
|
|
182
|
-
result.push(...mapSemrushKeywordVolumes(out.rows, batch));
|
|
183
|
-
const note = out.nothingFound ? undefined : partialLinesNote(out.rows.length, batch.length);
|
|
184
|
-
if (note)
|
|
185
|
-
notes.push(note);
|
|
125
|
+
for (const keyword of keywords) {
|
|
126
|
+
const req = { path: "/apis/v4/keywords/v1/metrics", label: "keywords/v1/metrics", params: { keyword, country } };
|
|
127
|
+
result.push(mapSemrushKeywordMetricsV4(await v4Get(client, req, key, perKeyword), keyword));
|
|
128
|
+
units += perKeyword;
|
|
186
129
|
}
|
|
187
|
-
return { value: { result,
|
|
130
|
+
return { value: { result, notes: [SEMRUSH_V4_METRICS_NOTE] }, units };
|
|
188
131
|
},
|
|
189
132
|
});
|
|
190
133
|
return { ...r.value, ...meta(r) };
|
|
191
134
|
}
|
|
192
|
-
/**
|
|
193
|
-
export async function semrushRankedKeywords(client, target, limit, opts = {}) {
|
|
194
|
-
const db = database(client, "rankedKeywords");
|
|
195
|
-
const key = v3Key("rankedKeywords");
|
|
196
|
-
const estimate = estimateSemrushUnits("domain_organic", limit);
|
|
197
|
-
const r = await unitsCall({
|
|
198
|
-
client,
|
|
199
|
-
provider: "semrush",
|
|
200
|
-
capability: "rankedKeywords",
|
|
201
|
-
keyParts: { target, country: client.country, limit },
|
|
202
|
-
ttl: TTL.week,
|
|
203
|
-
estimate,
|
|
204
|
-
refresh: opts.refresh,
|
|
205
|
-
run: async () => {
|
|
206
|
-
const columns = SEMRUSH_COLUMNS.domain_organic;
|
|
207
|
-
const req = {
|
|
208
|
-
path: "/",
|
|
209
|
-
label: "domain_organic",
|
|
210
|
-
auth: { v3: key },
|
|
211
|
-
params: { type: "domain_organic", domain: target, database: db, export_columns: columns.join(","), display_sort: "nq_desc", display_limit: String(limit) },
|
|
212
|
-
};
|
|
213
|
-
const out = await v3Report(client, req, columns, "domain_organic", estimate, db);
|
|
214
|
-
const note = out.nothingFound ? undefined : partialLinesNote(out.rows.length, limit);
|
|
215
|
-
return { value: { ...mapSemrushRankedKeywords(out.rows, target), ...withNotes(note ? [note] : []) }, units: out.units };
|
|
216
|
-
},
|
|
217
|
-
});
|
|
218
|
-
return { ...r.value, ...meta(r) };
|
|
219
|
-
}
|
|
220
|
-
/** Organic competitors via v3 domain_organic_organic, sorted by common keywords. */
|
|
221
|
-
export async function semrushCompetitorDomains(client, limit, opts = {}) {
|
|
222
|
-
const db = database(client, "competitorDomains");
|
|
223
|
-
const key = v3Key("competitorDomains");
|
|
224
|
-
const target = client.domain;
|
|
225
|
-
const estimate = estimateSemrushUnits("domain_organic_organic", limit);
|
|
226
|
-
const r = await unitsCall({
|
|
227
|
-
client,
|
|
228
|
-
provider: "semrush",
|
|
229
|
-
capability: "competitorDomains",
|
|
230
|
-
keyParts: { target, country: client.country, limit },
|
|
231
|
-
ttl: TTL.month,
|
|
232
|
-
estimate,
|
|
233
|
-
refresh: opts.refresh,
|
|
234
|
-
run: async () => {
|
|
235
|
-
const columns = SEMRUSH_COLUMNS.domain_organic_organic;
|
|
236
|
-
const req = {
|
|
237
|
-
path: "/",
|
|
238
|
-
label: "domain_organic_organic",
|
|
239
|
-
auth: { v3: key },
|
|
240
|
-
params: { type: "domain_organic_organic", domain: target, database: db, export_columns: columns.join(","), display_sort: "np_desc", display_limit: String(limit) },
|
|
241
|
-
};
|
|
242
|
-
const out = await v3Report(client, req, columns, "domain_organic_organic", estimate, db);
|
|
243
|
-
const note = out.nothingFound ? undefined : partialLinesNote(out.rows.length, limit);
|
|
244
|
-
return { value: { ...mapSemrushCompetitors(out.rows, target), ...withNotes(note ? [note] : []) }, units: out.units };
|
|
245
|
-
},
|
|
246
|
-
});
|
|
247
|
-
return { ...r.value, ...meta(r) };
|
|
248
|
-
}
|
|
249
|
-
/** Backlinks: the v4 overview with SEMRUSH_V4_API_KEY (45 units), else the v3 backlinks_overview (40 units) (D3). */
|
|
135
|
+
/** Backlinks via the v4 overview (45 units). */
|
|
250
136
|
export async function semrushBacklinksSummary(client, target, opts = {}) {
|
|
251
|
-
const
|
|
252
|
-
const
|
|
253
|
-
const type = v4 ? "backlinks_overview_v4" : "backlinks_overview_v3";
|
|
254
|
-
const estimate = SEMRUSH_UNITS[type];
|
|
137
|
+
const key = apiKey("backlinksSummary");
|
|
138
|
+
const estimate = SEMRUSH_UNITS.backlinks_overview_v4;
|
|
255
139
|
const r = await unitsCall({
|
|
256
140
|
client,
|
|
257
141
|
provider: "semrush",
|
|
258
142
|
capability: "backlinksSummary",
|
|
259
|
-
keyParts: { target
|
|
143
|
+
keyParts: { target },
|
|
260
144
|
ttl: TTL.week,
|
|
261
145
|
estimate,
|
|
262
146
|
refresh: opts.refresh,
|
|
263
147
|
run: async () => {
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
try {
|
|
268
|
-
res = await send(req);
|
|
269
|
-
}
|
|
270
|
-
catch (err) {
|
|
271
|
-
if (!isTimeoutOrAbort(err))
|
|
272
|
-
throw err;
|
|
273
|
-
recordUnits(client.slug, "semrush", `${req.label} (timeout, estimated)`, estimate);
|
|
274
|
-
throw new Error(`Semrush ${req.label} timed out after ${TIMEOUTS.networkMs / 1000}s; Semrush may still have billed it. The estimated ${estimate} units were recorded against the cap.`);
|
|
275
|
-
}
|
|
276
|
-
let json;
|
|
277
|
-
try {
|
|
278
|
-
json = JSON.parse(res.text);
|
|
279
|
-
}
|
|
280
|
-
catch {
|
|
281
|
-
json = null;
|
|
282
|
-
}
|
|
283
|
-
if (res.status >= 400 || json?.meta?.success === false) {
|
|
284
|
-
const e = parseSemrushV4Error(json);
|
|
285
|
-
const info = classifySemrushV4Error(res.status, e && { code: e.code, message: redact(e.message, secrets()) });
|
|
286
|
-
throw new SemrushApiError(e?.code ?? res.status, info.kind, info.message);
|
|
287
|
-
}
|
|
288
|
-
const units = estimateSemrushUnits(type, 1);
|
|
289
|
-
recordUnits(client.slug, "semrush", req.label, units);
|
|
290
|
-
return { value: { summary: mapSemrushBacklinksV4(json, target) }, units };
|
|
291
|
-
}
|
|
292
|
-
const columns = SEMRUSH_COLUMNS.backlinks_overview;
|
|
293
|
-
const req = {
|
|
294
|
-
path: "/analytics/v1/",
|
|
295
|
-
label: "backlinks_overview",
|
|
296
|
-
auth: { v3: v3 },
|
|
297
|
-
params: { type: "backlinks_overview", target, target_type: "root_domain", export_columns: columns.join(",") },
|
|
298
|
-
};
|
|
299
|
-
// Priced per request: estimateSemrushUnits gives 40 for a returned row and 0 when nothing came back.
|
|
300
|
-
const out = await v3Report(client, req, columns, type, estimate, null);
|
|
301
|
-
return { value: { summary: mapSemrushBacklinksV3(out.rows, target) }, units: out.units };
|
|
302
|
-
},
|
|
303
|
-
});
|
|
304
|
-
return { ...r.value, ...meta(r) };
|
|
305
|
-
}
|
|
306
|
-
// ------------------------------------------------------------------ keyword ideas and gap (Phase 12 B)
|
|
307
|
-
const IDEAS_TYPE = { related: "phrase_related", matching: "phrase_fullsearch" };
|
|
308
|
-
const perSeed = (limit, seeds) => Math.max(1, Math.ceil(limit / Math.max(1, seeds)));
|
|
309
|
-
/** Semrush wants bare domains in `domains`. */
|
|
310
|
-
const bare = (d) => d.replace(/^www\./, "");
|
|
311
|
-
export function planSemrushKeywordIdeas(client, req) {
|
|
312
|
-
const db = database(client, "keywordIdeas");
|
|
313
|
-
const type = IDEAS_TYPE[req.mode];
|
|
314
|
-
const n = req.seeds.length;
|
|
315
|
-
return {
|
|
316
|
-
keyParts: { database: db, mode: req.mode, seeds: req.seeds, limit: req.limit },
|
|
317
|
-
unit: "units",
|
|
318
|
-
requests: n,
|
|
319
|
-
cost: (limit) => n * estimateSemrushUnits(type, perSeed(limit, n)),
|
|
320
|
-
breakdown: (limit) => `${n} ${type} request(s) × ${perSeed(limit, n)} lines × ${SEMRUSH_UNITS[type]} units`,
|
|
321
|
-
};
|
|
322
|
-
}
|
|
323
|
-
/** Keyword ideas: one phrase_related (`related`) or phrase_fullsearch (`matching`) request per seed, display_limit = ceil(limit / seeds), always sent. */
|
|
324
|
-
export async function semrushKeywordIdeas(client, req, opts = {}) {
|
|
325
|
-
const plan = planSemrushKeywordIdeas(client, req);
|
|
326
|
-
const db = plan.keyParts.database;
|
|
327
|
-
const key = v3Key("keywordIdeas");
|
|
328
|
-
const type = IDEAS_TYPE[req.mode];
|
|
329
|
-
const per = perSeed(req.limit, req.seeds.length);
|
|
330
|
-
const r = await unitsCall({
|
|
331
|
-
client,
|
|
332
|
-
provider: "semrush",
|
|
333
|
-
capability: "keywordIdeas",
|
|
334
|
-
keyParts: plan.keyParts,
|
|
335
|
-
ttl: TTL.week,
|
|
336
|
-
estimate: plan.cost(req.limit),
|
|
337
|
-
refresh: opts.refresh,
|
|
338
|
-
run: async () => {
|
|
339
|
-
const lists = [];
|
|
340
|
-
const notes = [];
|
|
341
|
-
let units = 0;
|
|
342
|
-
for (const seed of req.seeds) {
|
|
343
|
-
const columns = SEMRUSH_COLUMNS[type];
|
|
344
|
-
const q = { path: "/", label: type, auth: { v3: key }, params: { type, phrase: seed, database: db, export_columns: columns.join(","), display_limit: String(per) } };
|
|
345
|
-
const out = await v3Report(client, q, columns, type, estimateSemrushUnits(type, per), db);
|
|
346
|
-
units += out.units;
|
|
347
|
-
lists.push(mapSemrushIdeas(out.rows, seed));
|
|
348
|
-
const note = out.nothingFound ? undefined : partialLinesNote(out.rows.length, per);
|
|
349
|
-
if (note)
|
|
350
|
-
notes.push(`${seed}: ${note}`);
|
|
351
|
-
}
|
|
352
|
-
const seen = new Set();
|
|
353
|
-
const ideas = lists.flat().filter((i) => {
|
|
354
|
-
const k = i.keyword.toLowerCase();
|
|
355
|
-
if (seen.has(k))
|
|
356
|
-
return false;
|
|
357
|
-
seen.add(k);
|
|
358
|
-
return true;
|
|
359
|
-
});
|
|
360
|
-
return { value: { ideas: ideas.slice(0, req.limit), ...withNotes(notes) }, units };
|
|
148
|
+
const req = { path: "/apis/v4/backlinks/v1/overview", label: "backlinks/v1/overview", params: { url: target, scope: "ROOT_DOMAIN", fields: SEMRUSH_V4_BACKLINK_FIELDS.join(",") } };
|
|
149
|
+
const json = await v4Get(client, req, key, estimate);
|
|
150
|
+
return { value: { summary: mapSemrushBacklinksV4(json, target) }, units: estimate };
|
|
361
151
|
},
|
|
362
152
|
});
|
|
363
153
|
return { ...r.value, ...meta(r) };
|
|
364
154
|
}
|
|
365
|
-
export function planSemrushKeywordGap(client, req) {
|
|
366
|
-
const db = database(client, "keywordGap");
|
|
367
|
-
const n = req.competitors.length;
|
|
368
|
-
return {
|
|
369
|
-
keyParts: { database: db, target: bare(client.domain), competitors: req.competitors, limit: req.limit },
|
|
370
|
-
unit: "units",
|
|
371
|
-
requests: n,
|
|
372
|
-
cost: (limit) => n * estimateSemrushUnits("domain_domains", limit),
|
|
373
|
-
breakdown: (limit) => `${n} competitor(s) × domain_domains ${limit} lines × ${SEMRUSH_UNITS.domain_domains} units`,
|
|
374
|
-
};
|
|
375
|
-
}
|
|
376
|
-
/** Keyword gap: one domain_domains (Domain vs Domain) per competitor, `domains = *|or|<competitor>|-|or|<client>`, display_limit = limit. */
|
|
377
|
-
export async function semrushKeywordGap(client, req, opts = {}) {
|
|
378
|
-
const plan = planSemrushKeywordGap(client, req);
|
|
379
|
-
const db = plan.keyParts.database;
|
|
380
|
-
const key = v3Key("keywordGap");
|
|
381
|
-
const target = bare(client.domain);
|
|
382
|
-
const r = await unitsCall({
|
|
383
|
-
client,
|
|
384
|
-
provider: "semrush",
|
|
385
|
-
capability: "keywordGap",
|
|
386
|
-
keyParts: plan.keyParts,
|
|
387
|
-
ttl: TTL.week,
|
|
388
|
-
estimate: plan.cost(req.limit),
|
|
389
|
-
refresh: opts.refresh,
|
|
390
|
-
run: async () => {
|
|
391
|
-
const per = [];
|
|
392
|
-
const notes = [];
|
|
393
|
-
let units = 0;
|
|
394
|
-
for (const competitor of req.competitors) {
|
|
395
|
-
const columns = SEMRUSH_COLUMNS.domain_domains;
|
|
396
|
-
const q = {
|
|
397
|
-
path: "/",
|
|
398
|
-
label: "domain_domains",
|
|
399
|
-
auth: { v3: key },
|
|
400
|
-
params: { type: "domain_domains", domains: domainVsDomain(bare(competitor), target), database: db, export_columns: columns.join(","), display_sort: "nq_desc", display_limit: String(req.limit) },
|
|
401
|
-
};
|
|
402
|
-
const out = await v3Report(client, q, columns, "domain_domains", estimateSemrushUnits("domain_domains", req.limit), db, {
|
|
403
|
-
P0: positionHeaderFor(bare(competitor), "P0"),
|
|
404
|
-
P1: positionHeaderFor(target, "P1"),
|
|
405
|
-
});
|
|
406
|
-
units += out.units;
|
|
407
|
-
per.push({ domain: competitor, rows: mapSemrushGapRows(out.rows) });
|
|
408
|
-
// B1: fewer lines than display_limit may be a cut-short answer (low balance), or simply all there is.
|
|
409
|
-
const note = out.nothingFound ? undefined : partialLinesNote(out.rows.length, req.limit);
|
|
410
|
-
if (note)
|
|
411
|
-
notes.push(`${competitor}: ${note}`);
|
|
412
|
-
}
|
|
413
|
-
return { value: { per, notes }, units };
|
|
414
|
-
},
|
|
415
|
-
});
|
|
416
|
-
const merged = mergeGaps(r.value.per, { metric: "semrush_kd", clientCheck: "provider", minVolume: req.minVolume });
|
|
417
|
-
return { gaps: merged.gaps, perCompetitor: merged.perCompetitor, ...withNotes(r.value.notes), ...meta(r) };
|
|
418
|
-
}
|
|
419
|
-
// ------------------------------------------------------------------ Phase 13 B: visibility, history, competitors
|
|
420
|
-
const bareT = (t) => t.replace(/^www\./, "").toLowerCase();
|
|
421
155
|
export function planSemrushBacklinksSummary(_client, target) {
|
|
422
|
-
const
|
|
423
|
-
|
|
424
|
-
return { keyParts: { target, semrushApi: v4 ? "v4" : "v3" }, unit: "units", requests: 1, cost: () => units, breakdown: () => `backlinks overview (${units} units)` };
|
|
425
|
-
}
|
|
426
|
-
export function planSemrushOrganicOverview(client, target) {
|
|
427
|
-
return { keyParts: { target: bareT(target), database: database(client, "organicOverview") }, unit: "units", requests: 1, cost: () => SEMRUSH_UNITS.domain_rank, breakdown: () => `domain_rank 1 line × ${SEMRUSH_UNITS.domain_rank} units` };
|
|
428
|
-
}
|
|
429
|
-
/** One v3 report line set, cached and unit-capped (shared by the Phase 13 B calls). */
|
|
430
|
-
async function v3Lines(client, o) {
|
|
431
|
-
const key = v3Key(o.capability);
|
|
432
|
-
return unitsCall({
|
|
433
|
-
client,
|
|
434
|
-
provider: "semrush",
|
|
435
|
-
capability: o.capability,
|
|
436
|
-
keyParts: o.keyParts,
|
|
437
|
-
// Semrush terms: nothing kept longer than a month (research S5); a week here.
|
|
438
|
-
ttl: TTL.week,
|
|
439
|
-
estimate: o.estimate,
|
|
440
|
-
refresh: o.refresh,
|
|
441
|
-
run: async () => {
|
|
442
|
-
const req = { path: "/", label: o.params.type, auth: { v3: key }, params: { ...o.params, database: o.db, export_columns: o.columns.join(",") } };
|
|
443
|
-
const out = await v3Report(client, req, o.columns, o.type, o.estimate, o.db);
|
|
444
|
-
return { value: out.rows, units: out.units };
|
|
445
|
-
},
|
|
446
|
-
});
|
|
447
|
-
}
|
|
448
|
-
/** Visibility now: domain_rank (Or, Ot). Semrush has no position bands (D6). */
|
|
449
|
-
export async function semrushOrganicOverview(client, target, opts = {}) {
|
|
450
|
-
const plan = planSemrushOrganicOverview(client, target);
|
|
451
|
-
const db = plan.keyParts.database;
|
|
452
|
-
const r = await v3Lines(client, { capability: "organicOverview", keyParts: plan.keyParts, estimate: plan.cost(1), refresh: opts.refresh, type: "domain_rank", columns: SEMRUSH_COLUMNS.domain_rank, params: { type: "domain_rank", domain: plan.keyParts.target }, db });
|
|
453
|
-
return { figures: mapSemrushVisibility(r.value), market: db, ...meta(r) };
|
|
454
|
-
}
|
|
455
|
-
export function planSemrushOrganicHistory(client, target, months) {
|
|
456
|
-
const units = estimateSemrushUnits("domain_rank_history", months);
|
|
457
|
-
return { keyParts: { target: bareT(target), database: database(client, "organicOverview"), months }, unit: "units", requests: 1, cost: () => units, breakdown: () => `domain_rank_history ${months} lines × ${SEMRUSH_UNITS.domain_rank_history} units` };
|
|
458
|
-
}
|
|
459
|
-
/** Monthly visibility history: domain_rank_history, display_limit = months (always sent), newest first. */
|
|
460
|
-
export async function semrushOrganicHistory(client, target, months, opts = {}) {
|
|
461
|
-
const plan = planSemrushOrganicHistory(client, target, months);
|
|
462
|
-
const db = plan.keyParts.database;
|
|
463
|
-
const r = await v3Lines(client, {
|
|
464
|
-
capability: "organicHistory",
|
|
465
|
-
keyParts: plan.keyParts,
|
|
466
|
-
estimate: plan.cost(1),
|
|
467
|
-
refresh: opts.refresh,
|
|
468
|
-
type: "domain_rank_history",
|
|
469
|
-
columns: SEMRUSH_COLUMNS.domain_rank_history,
|
|
470
|
-
params: { type: "domain_rank_history", domain: plan.keyParts.target, display_limit: String(months), display_sort: "dt_desc" },
|
|
471
|
-
db,
|
|
472
|
-
});
|
|
473
|
-
return { points: mapSemrushVisibilityHistory(r.value), market: db, ...meta(r) };
|
|
474
|
-
}
|
|
475
|
-
export const semrushCompetitorLines = (limit) => Math.max(limit, 10);
|
|
476
|
-
export function planSemrushCompetitorOverview(client, req) {
|
|
477
|
-
const lines = semrushCompetitorLines(req.limit);
|
|
478
|
-
const units = estimateSemrushUnits("domain_organic_organic", lines) + req.saved.length * SEMRUSH_UNITS.domain_rank;
|
|
479
|
-
return {
|
|
480
|
-
keyParts: { target: bareT(client.domain), database: database(client, "competitorOverview"), lines, saved: req.saved },
|
|
481
|
-
unit: "units",
|
|
482
|
-
requests: 1 + req.saved.length,
|
|
483
|
-
cost: () => units,
|
|
484
|
-
breakdown: () => `domain_organic_organic ${lines} lines × 40${req.saved.length ? ` + up to ${req.saved.length} domain_rank call(s) (10 each) for saved competitors the list misses` : ""} = ${units} units`,
|
|
485
|
-
};
|
|
486
|
-
}
|
|
487
|
-
/** Competitors: domain_organic_organic (Dn, Np, Or, Ot) and a domain_rank per saved competitor the list misses (D7). */
|
|
488
|
-
export async function semrushCompetitorOverview(client, req, opts = {}) {
|
|
489
|
-
const plan = planSemrushCompetitorOverview(client, req);
|
|
490
|
-
const db = plan.keyParts.database;
|
|
491
|
-
const key = v3Key("competitorOverview");
|
|
492
|
-
const target = plan.keyParts.target;
|
|
493
|
-
const lines = plan.keyParts.lines;
|
|
494
|
-
const r = await unitsCall({
|
|
495
|
-
client,
|
|
496
|
-
provider: "semrush",
|
|
497
|
-
capability: "competitorOverview",
|
|
498
|
-
keyParts: plan.keyParts,
|
|
499
|
-
ttl: TTL.week,
|
|
500
|
-
estimate: plan.cost(1),
|
|
501
|
-
refresh: opts.refresh,
|
|
502
|
-
run: async () => {
|
|
503
|
-
const columns = SEMRUSH_COLUMNS.domain_organic_organic_full;
|
|
504
|
-
const q = { path: "/", label: "domain_organic_organic", auth: { v3: key }, params: { type: "domain_organic_organic", domain: target, database: db, export_columns: columns.join(","), display_sort: "np_desc", display_limit: String(lines) } };
|
|
505
|
-
const out = await v3Report(client, q, columns, "domain_organic_organic", estimateSemrushUnits("domain_organic_organic", lines), db);
|
|
506
|
-
let units = out.units;
|
|
507
|
-
const rows = mapSemrushCompetitorsFull(out.rows, target);
|
|
508
|
-
const size = rows.length;
|
|
509
|
-
for (const saved of req.saved) {
|
|
510
|
-
if (rows.some((x) => x.domain === saved))
|
|
511
|
-
continue;
|
|
512
|
-
const dc = SEMRUSH_COLUMNS.domain_rank;
|
|
513
|
-
const one = await v3Report(client, { path: "/", label: "domain_rank", auth: { v3: key }, params: { type: "domain_rank", domain: saved, database: db, export_columns: dc.join(",") } }, dc, "domain_rank", SEMRUSH_UNITS.domain_rank, db);
|
|
514
|
-
units += one.units;
|
|
515
|
-
const f = mapSemrushVisibility(one.rows);
|
|
516
|
-
rows.push({ domain: saved, commonKeywords: null, keywords: f.keywords, traffic: f.traffic, inProviderList: false });
|
|
517
|
-
}
|
|
518
|
-
return { value: { rows, providerListSize: size }, units };
|
|
519
|
-
},
|
|
520
|
-
});
|
|
521
|
-
return { ...r.value, market: db, ...meta(r) };
|
|
156
|
+
const units = SEMRUSH_UNITS.backlinks_overview_v4;
|
|
157
|
+
return { keyParts: { target }, unit: "units", requests: 1, cost: () => units, breakdown: () => `backlinks overview (${units} units)` };
|
|
522
158
|
}
|
|
523
159
|
export const semrushProvider = {
|
|
524
160
|
id: "semrush",
|
|
525
|
-
capabilities: new Set(["keywordVolume", "
|
|
526
|
-
planBacklinksSummary: (c, t) => planSemrushBacklinksSummary(c, t),
|
|
527
|
-
planOrganicOverview: (c, t) => planSemrushOrganicOverview(c, t),
|
|
528
|
-
organicOverview: (c, t, o) => semrushOrganicOverview(c, t, o),
|
|
529
|
-
planOrganicHistory: (c, t, m) => planSemrushOrganicHistory(c, t, m),
|
|
530
|
-
organicHistory: (c, t, m, o) => semrushOrganicHistory(c, t, m, o),
|
|
531
|
-
planCompetitorOverview: (c, r) => planSemrushCompetitorOverview(c, r),
|
|
532
|
-
competitorOverview: (c, r, o) => semrushCompetitorOverview(c, r, o),
|
|
533
|
-
market: (c, cap) => database(c, cap),
|
|
161
|
+
capabilities: new Set(["keywordVolume", "backlinksSummary"]),
|
|
534
162
|
configured: () => Boolean(env.SEMRUSH_API_KEY),
|
|
535
|
-
planKeywordIdeas: (c, r) => planSemrushKeywordIdeas(c, r),
|
|
536
|
-
keywordIdeas: (c, r, o) => semrushKeywordIdeas(c, r, o),
|
|
537
|
-
planKeywordGap: (c, r) => planSemrushKeywordGap(c, r),
|
|
538
|
-
keywordGap: (c, r, _ctx, o) => semrushKeywordGap(c, r, o),
|
|
539
163
|
keywordVolume: (c, k, o) => semrushKeywordVolume(c, k, o),
|
|
540
|
-
rankedKeywords: (c, t, l, o) => semrushRankedKeywords(c, t, l, o),
|
|
541
|
-
competitorDomains: (c, l, o) => semrushCompetitorDomains(c, l, o),
|
|
542
164
|
backlinksSummary: (c, t, o) => semrushBacklinksSummary(c, t, o),
|
|
165
|
+
planBacklinksSummary: (c, t) => planSemrushBacklinksSummary(c, t),
|
|
543
166
|
};
|
|
544
167
|
/**
|
|
545
|
-
* The
|
|
546
|
-
* free
|
|
547
|
-
* the v3 key exists and shows the balance, not plan access (D4). Never throws; errors
|
|
548
|
-
* never contain the key or the URL.
|
|
168
|
+
* The `semrush_status` tool: whether the key is set, what it serves, and this month's ledger units. There is no
|
|
169
|
+
* documented free check for a Semrush v4 key and no balance call, so the key is first checked by a paid call. Never a key.
|
|
549
170
|
*/
|
|
550
|
-
export
|
|
551
|
-
|
|
552
|
-
return { configured: false, works: false, reachable: true, error: "SEMRUSH_API_KEY is not set.", costUsd: 0 };
|
|
553
|
-
const base = env.SEMRUSH_BALANCE_URL;
|
|
554
|
-
let res;
|
|
555
|
-
let text;
|
|
556
|
-
try {
|
|
557
|
-
res = await fetchImpl(`${base}?${new URLSearchParams({ key }).toString()}`, { method: "GET", signal: AbortSignal.timeout(TIMEOUTS.networkMs) });
|
|
558
|
-
text = await res.text();
|
|
559
|
-
}
|
|
560
|
-
catch (err) {
|
|
561
|
-
return { configured: true, works: null, reachable: false, error: `Couldn't reach Semrush's balance check: ${redact(err instanceof Error ? err.message : String(err), [key])}`, costUsd: 0 };
|
|
562
|
-
}
|
|
563
|
-
const e = parseSemrushError(text);
|
|
564
|
-
if (e) {
|
|
565
|
-
const info = classifySemrushError({ code: e.code, text: redact(e.text, [key]) }, null);
|
|
566
|
-
if (info.kind === "auth")
|
|
567
|
-
return { configured: true, works: false, reachable: true, errorCode: e.code, error: info.message, costUsd: 0 };
|
|
568
|
-
return { configured: true, works: null, reachable: info.kind !== "rate", errorCode: e.code, error: info.message, costUsd: 0 };
|
|
569
|
-
}
|
|
570
|
-
if (res.status === 401 || res.status === 403)
|
|
571
|
-
return { configured: true, works: false, reachable: true, error: "Semrush rejected the API key (SEMRUSH_API_KEY).", costUsd: 0 };
|
|
572
|
-
if (!res.ok)
|
|
573
|
-
return { configured: true, works: null, reachable: false, error: `Semrush's balance check returned HTTP ${res.status}.`, costUsd: 0 };
|
|
574
|
-
const units = parseUnitsBalance(text);
|
|
575
|
-
if (units === null)
|
|
576
|
-
return { configured: true, works: null, reachable: true, error: `Semrush's balance check answered with an unexpected body: ${redact(text, [key], 80)}`, costUsd: 0 };
|
|
577
|
-
return { configured: true, works: true, reachable: true, unitsLeft: units, error: null, costUsd: 0 };
|
|
578
|
-
}
|
|
579
|
-
/** The `semrush_status` tool: the balance check with the configured v3 key, whether a v4 key is set, and this month's ledger units. Never a key. */
|
|
580
|
-
export async function semrushStatus() {
|
|
581
|
-
const check = await checkSemrushKey(env.SEMRUSH_API_KEY);
|
|
171
|
+
export function semrushStatus() {
|
|
172
|
+
const keySet = Boolean(env.SEMRUSH_API_KEY);
|
|
582
173
|
return {
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
174
|
+
keySet,
|
|
175
|
+
keyCheck: "none",
|
|
176
|
+
keyNote: "Semrush has no free key check; the key is checked by the first paid call (keyword volume or backlinks).",
|
|
177
|
+
capabilities: { keywordVolume: keySet, backlinksSummary: keySet },
|
|
178
|
+
notServed: "Rankings, competitors, visibility, keyword ideas, keyword gaps and Site Audit aren't in Semrush's v4 API; DataForSEO or Ahrefs serves them.",
|
|
586
179
|
unitsUsedThisMonth: unitsThisMonth(undefined, "semrush"),
|
|
587
180
|
monthlyUnitCap: globalUnitCap("semrush"),
|
|
588
181
|
defaultClientMonthlyUnits: env.DEFAULT_CLIENT_MONTHLY_SEMRUSH_UNITS,
|