crawlmark-mcp 0.1.0 → 0.1.1

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.
@@ -1,33 +1,27 @@
1
1
  /**
2
- * Semrush client (Phase 9): v3 reports (phrase_these, domain_organic,
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
- * - v3 keys travel in the query string (`key=`), so a request URL is never shown, logged
9
- * or put in an error; v4 keys go in `Authorization: Apikey`, never in a URL.
10
- * - `display_limit` is always sent on domain_organic and domain_organic_organic (unset means
11
- * 10,000 lines; 100,000 units for domain_organic). phrase_these has no display_limit: it returns
12
- * at most one line per keyword sent, so its keyword list is normalised (split on `;` and line
13
- * breaks, deduped) before the estimate, the batches and the cache key.
14
- * - Units are the documented price x lines returned (flat per request for backlinks);
15
- * ERROR 50 (nothing found) and empty responses cost 0. A timeout records the estimate.
16
- * - At most 4 requests in flight and 100 ms between starts (10/s, 10 at once). Errors are
17
- * never retried, except one retry of a 429.
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, semrushDatabase } from "../lib/country.js";
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 { mergeGaps } from "../analysis/keyword-gap.js";
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, env.SEMRUSH_V4_API_KEY];
47
- /** One request through the limiter; a 429 (HTTP or `ERROR 429`) is retried once. Returns the body text and status. */
48
- async function send(req, signal) {
49
- const params = new URLSearchParams(req.params);
50
- if ("v3" in req.auth)
51
- params.set("key", req.auth.v3);
52
- const url = `${env.SEMRUSH_BASE_URL}${req.path}?${params.toString()}`;
53
- const headers = "v4" in req.auth ? { Authorization: `Apikey ${req.auth.v4}`, Accept: "application/json" } : {};
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
- const throttled = res.status === 429 || parseSemrushError(text)?.code === 429;
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
- * A billed v3 report. ERROR 50 (nothing found) is an empty result costing 0 units; other
108
- * ERROR bodies are classified by text whatever the HTTP status (N10). Units are the
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 v3Report(client, req, columns, type, estimate, db, accept = {}) {
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
- const err = parseSemrushError(res.text);
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
- rows = parseSemrushCsv(res.text, columns, accept);
90
+ json = JSON.parse(res.text);
140
91
  }
141
- catch (err) {
142
- if (!(err instanceof SemrushFormatError))
143
- throw err;
144
- // R-3: a 2xx answer we can't read was probably billed: the lines it has (minus the header) x the price, else the estimate.
145
- const lines = res.text.split(/\r?\n/).filter((l) => l.trim() !== "").length - 1;
146
- recordUnits(client.slug, "semrush", `${req.label} (unreadable body, estimated)`, lines > 0 ? estimateSemrushUnits(type, lines) : estimate);
147
- throw err;
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
- const units = estimateSemrushUnits(type, rows.length);
150
- recordUnits(client.slug, "semrush", req.label, units);
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 v3 phrase_these (deprecated, D8), in batches of 100 run one after another. */
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 lines.
107
+ // Defensive: the routed searchVolume already normalises, but `;` inside an entry would bill extra requests.
159
108
  const keywords = normalizeKeywordList(input);
160
- const db = database(client, "keywordVolume");
161
- const key = v3Key("keywordVolume");
162
- const batches = [];
163
- for (let i = 0; i < keywords.length; i += SEMRUSH_KEYWORD_BATCH)
164
- batches.push(keywords.slice(i, i + SEMRUSH_KEYWORD_BATCH));
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: estimateSemrushUnits("phrase_these", keywords.length),
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 batch of batches) {
178
- const columns = SEMRUSH_COLUMNS.phrase_these;
179
- const req = { path: "/", label: "phrase_these", auth: { v3: key }, params: { type: "phrase_these", phrase: batch.join(";"), database: db, export_columns: columns.join(",") } };
180
- const out = await v3Report(client, req, columns, "phrase_these", estimateSemrushUnits("phrase_these", batch.length), db);
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, ...withNotes(notes) }, units };
130
+ return { value: { result, notes: [SEMRUSH_V4_METRICS_NOTE] }, units };
188
131
  },
189
132
  });
190
133
  return { ...r.value, ...meta(r) };
191
134
  }
192
- /** Ranked keywords via v3 domain_organic, sorted by volume, `display_limit` always set. */
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 v4 = env.SEMRUSH_V4_API_KEY;
252
- const v3 = v4 ? undefined : v3Key("backlinksSummary");
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, semrushApi: v4 ? "v4" : "v3" },
143
+ keyParts: { target },
260
144
  ttl: TTL.week,
261
145
  estimate,
262
146
  refresh: opts.refresh,
263
147
  run: async () => {
264
- if (v4) {
265
- const req = { path: "/apis/v4/backlinks/v1/overview", label: "backlinks/v1/overview", auth: { v4 }, params: { url: target, scope: "ROOT_DOMAIN", fields: SEMRUSH_V4_BACKLINK_FIELDS.join(",") } };
266
- let res;
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 v4 = Boolean(env.SEMRUSH_V4_API_KEY);
423
- const units = SEMRUSH_UNITS[v4 ? "backlinks_overview_v4" : "backlinks_overview_v3"];
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", "rankedKeywords", "competitorDomains", "backlinksSummary", "keywordIdeas", "keywordGap", "organicOverview", "competitorOverview"]),
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 free balance check, `GET SEMRUSH_BALANCE_URL?key=` (countapiunits; documented as
546
- * free). https only (N8; the docs show http and a key is never sent over it). It proves
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 async function checkSemrushKey(key, fetchImpl = fetch) {
551
- if (!key)
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
- ...check,
584
- v4KeySet: Boolean(env.SEMRUSH_V4_API_KEY),
585
- v4KeyNote: env.SEMRUSH_V4_API_KEY ? "The v4 key (backlinks) has no free check; it is checked the first time backlinks_summary runs." : undefined,
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,