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.
@@ -11,7 +11,7 @@
11
11
  * - No key, and nothing derived from one, is ever stored: the mark records only the key's source.
12
12
  */
13
13
  import { ENV_FILE_KEYS, env } from "../config.js";
14
- import { pruneSemrushIssueSnapshots, stripSourceFromIssueSnapshots } from "./issue-snapshots.js";
14
+ import { stripSourceFromIssueSnapshots } from "./issue-snapshots.js";
15
15
  import { purgeExpired } from "./cache.js";
16
16
  import { pruneSnapshots } from "./snapshots.js";
17
17
  import { db } from "./store.js";
@@ -20,6 +20,7 @@ let processEntry = "cli";
20
20
  export function setProcessEntry(e) {
21
21
  processEntry = e;
22
22
  }
23
+ /** Stored issue items from a provider's Site Audit, removed with the rest of its data. (Semrush has none now; an older build may have stored some.) */
23
24
  const SITE_AUDIT_SOURCE = { ahrefs: "ahrefs_site_audit", semrush: "semrush_site_audit" };
24
25
  const KEY_VAR = { ahrefs: "AHREFS_API_KEY", semrush: "SEMRUSH_API_KEY" };
25
26
  /** Remember that a cache entry holds this provider's data for this client, so a purge can delete it. */
@@ -94,7 +95,6 @@ export function reconcileRemovedKeys(entry = processEntry) {
94
95
  export function retentionSweep(now = Date.now()) {
95
96
  purgeExpired();
96
97
  pruneSnapshots(now);
97
- pruneSemrushIssueSnapshots(now);
98
98
  }
99
99
  let reconciled = false;
100
100
  /** reconcileRemovedKeys once per process; a purge is reported on stderr (never stdout: the MCP channel). */
@@ -0,0 +1,155 @@
1
+ import { parse as parseToml } from "smol-toml";
2
+ import { tomlEqual } from "./codex-config.js";
3
+ /**
4
+ * Semrush's and Ahrefs' own remote MCP servers, which the setup wizard can add to a user's AI apps for ad-hoc
5
+ * questions. crawlmark itself never calls them (its Semrush and Ahrefs integrations use API keys). Entries are
6
+ * URL-only: sign-in is OAuth in the app, so no key or header is ever written to an app config.
7
+ */
8
+ export const VENDOR_IDS = ["semrush", "ahrefs"];
9
+ export const VENDORS = {
10
+ semrush: {
11
+ id: "semrush",
12
+ label: "Semrush",
13
+ name: "semrush",
14
+ url: "https://mcp.semrush.com/v2/mcp",
15
+ notice: "This adds Semrush's own MCP server to your AI app. You sign in to Semrush in the app (OAuth). Its calls spend your Semrush API units directly; crawlmark's caps, cache and reports don't cover them.",
16
+ },
17
+ ahrefs: {
18
+ id: "ahrefs",
19
+ label: "Ahrefs",
20
+ name: "ahrefs",
21
+ url: "https://api.ahrefs.com/mcp/mcp",
22
+ notice: "This adds Ahrefs' own MCP server to your AI app. You sign in to Ahrefs in the app (OAuth). Its calls spend your Ahrefs API units directly (per Ahrefs' docs, it needs a paid plan from Lite, and OAuth creates a key limited to 200,000 units a month by default); crawlmark's caps, cache and reports don't cover them.",
23
+ },
24
+ };
25
+ const isRecord = (v) => Boolean(v) && typeof v === "object" && !Array.isArray(v);
26
+ /** Claude Code: `claude mcp add --transport http -s user <name> <url>` (argv after `claude`). */
27
+ export function claudeCodeVendorArgs(v) {
28
+ return ["mcp", "add", "--transport", "http", "-s", "user", v.name, v.url];
29
+ }
30
+ /** Codex: `codex mcp add <name> --url <url>` (argv after `codex`). */
31
+ export function codexVendorArgs(v) {
32
+ return ["mcp", "add", v.name, "--url", v.url];
33
+ }
34
+ /** Cursor's `mcpServers` entry. */
35
+ export function cursorVendorEntry(v) {
36
+ return { url: v.url };
37
+ }
38
+ /** Parse `claude mcp get <name>`: its URL (HTTP/SSE servers) or Command (stdio), and scope. */
39
+ export function parseClaudeVendorGet(stdout) {
40
+ const text = stdout.replace(/\u001b\[[0-9;]*m/g, "");
41
+ const url = /^\s*URL:\s*(\S+)/im.exec(text)?.[1];
42
+ const command = /^\s*Command:\s*(.+)$/im.exec(text)?.[1]?.trim();
43
+ const scopeText = /^\s*Scope:\s*(\w+)/im.exec(text)?.[1]?.toLowerCase();
44
+ const scope = scopeText?.startsWith("user") ? "user" : scopeText?.startsWith("local") ? "local" : scopeText?.startsWith("project") ? "project" : undefined;
45
+ if (!url && !command)
46
+ return undefined;
47
+ return { ...(url ? { url } : {}), ...(command ? { command } : {}), ...(scope ? { scope } : {}) };
48
+ }
49
+ /** Parse `codex mcp get <name> --json`: a streamable-HTTP server's URL, or a stdio server's command. */
50
+ export function parseCodexVendorGet(stdout) {
51
+ let data;
52
+ try {
53
+ data = JSON.parse(stdout);
54
+ }
55
+ catch {
56
+ return undefined;
57
+ }
58
+ if (!isRecord(data))
59
+ return undefined;
60
+ const t = isRecord(data.transport) ? data.transport : data;
61
+ if (typeof t.url === "string" && t.url)
62
+ return { url: t.url };
63
+ if (typeof t.command === "string" && t.command)
64
+ return { command: t.command };
65
+ return undefined;
66
+ }
67
+ /** `mcp_servers.<name>` in config.toml text (read-only). */
68
+ export function readCodexVendorToml(text, name) {
69
+ if (text === undefined || text.trim() === "")
70
+ return { kind: "absent" };
71
+ let parsed;
72
+ try {
73
+ parsed = parseToml(text);
74
+ }
75
+ catch {
76
+ return { kind: "unreadable" };
77
+ }
78
+ const servers = isRecord(parsed) ? parsed.mcp_servers : undefined;
79
+ if (servers === undefined)
80
+ return { kind: "absent" };
81
+ if (!isRecord(servers))
82
+ return { kind: "unreadable" };
83
+ const e = servers[name];
84
+ if (e === undefined)
85
+ return { kind: "absent" };
86
+ if (!isRecord(e))
87
+ return { kind: "unreadable" };
88
+ if (typeof e.url === "string")
89
+ return { kind: "entry", url: e.url };
90
+ if (typeof e.command === "string")
91
+ return { kind: "entry", command: e.command };
92
+ return { kind: "unreadable" };
93
+ }
94
+ /** The block a user can paste into config.toml. */
95
+ export function codexVendorSnippet(v) {
96
+ return `[mcp_servers.${v.name}]\nurl = ${JSON.stringify(v.url)}`;
97
+ }
98
+ /**
99
+ * Add `[mcp_servers.<name>] url = "..."` to config.toml text (pure). Only an absent entry is added (appended after a
100
+ * blank line, every other byte untouched, then re-parsed: other servers and keys must be deep-equal and the entry must
101
+ * be the intended one). The same URL already there is "no change". A different entry is refused: with no CLI the wizard
102
+ * never rewrites a table it didn't write.
103
+ */
104
+ export function planCodexVendorTomlEdit(existing, v) {
105
+ const source = existing ?? "";
106
+ let before;
107
+ try {
108
+ before = parseToml(source);
109
+ }
110
+ catch (err) {
111
+ return { ok: false, reason: `The Codex config isn't valid TOML (${err.message.split("\n")[0]}); fix it by hand first.` };
112
+ }
113
+ const servers = before.mcp_servers;
114
+ if (servers !== undefined && !isRecord(servers))
115
+ return { ok: false, reason: "The Codex config's mcp_servers isn't a table; fix it by hand first." };
116
+ const current = (servers ?? {});
117
+ const otherServers = Object.keys(current).filter((k) => k !== v.name);
118
+ const intended = { url: v.url };
119
+ if (current[v.name] !== undefined) {
120
+ if (tomlEqual(current[v.name], intended))
121
+ return { ok: true, text: source, changed: false, otherServers };
122
+ return { ok: false, reason: `The Codex config already has a different ${v.name} entry; change it by hand.` };
123
+ }
124
+ const eol = source.includes("\r\n") ? "\r\n" : "\n";
125
+ const block = codexVendorSnippet(v).split("\n").map((l) => l + eol).join("");
126
+ const endsWithNewline = /\n$/.test(source);
127
+ const endsWithBlankLine = /\n[ \t]*\r?\n$/.test(source);
128
+ const lead = source === "" ? "" : (endsWithNewline ? "" : eol) + (endsWithBlankLine ? "" : eol);
129
+ const text = source + lead + block;
130
+ let after;
131
+ try {
132
+ after = parseToml(text);
133
+ }
134
+ catch {
135
+ return { ok: false, reason: "The edited Codex config wouldn't parse, so nothing was changed. Add the entry by hand." };
136
+ }
137
+ const afterServers = isRecord(after.mcp_servers) ? after.mcp_servers : {};
138
+ const ok = [...new Set([...Object.keys(before), ...Object.keys(after)])].every((k) => k === "mcp_servers" || tomlEqual(before[k], after[k])) &&
139
+ Object.keys(current).every((k) => tomlEqual(current[k], afterServers[k])) &&
140
+ tomlEqual(afterServers[v.name], intended);
141
+ if (!ok)
142
+ return { ok: false, reason: "The edit would have changed more than the new entry, so nothing was changed. Add the entry by hand." };
143
+ return { ok: true, text, changed: true, otherServers };
144
+ }
145
+ /** Claude Desktop can't take remote servers in its JSON config: the exact steps for the app's own UI. */
146
+ export function desktopConnectorSteps(v) {
147
+ return [
148
+ "1. Open Claude Desktop, then Settings > Connectors.",
149
+ '2. Choose "Add custom connector".',
150
+ `3. Name: ${v.label}`,
151
+ `4. URL: ${v.url}`,
152
+ `5. Save, then sign in to ${v.label} when Claude Desktop asks (OAuth).`,
153
+ ];
154
+ }
155
+ //# sourceMappingURL=vendor-mcp.js.map
@@ -8,7 +8,7 @@ Keyword ideas and competitor gaps are **paid** through the client's SEO data pro
8
8
  - If `wouldExceed` is true, read me the `message`: it names a smaller `limit` that fits (`fits`), or the cap to raise. Offer the smaller limit; never raise a cap yourself.
9
9
  4. **Ask.** Ask whether to run it. Don't call the tool without `estimateOnly` until I say yes. After a yes, call it with exactly the arguments I agreed to.
10
10
  5. **Present.** Group the results by search intent: use `intent` when the provider gives it (DataForSEO), otherwise infer it from the wording (informational: how, what, why, cost, ideas; commercial: best, vs, reviews, near me; transactional: buy, hire, quote, service, price; navigational: brand names). For each group, show a short table: keyword, monthly volume, difficulty (name the scale from `difficulty.metric`: "dataforseo_kd", "ahrefs_kd" and "semrush_kd" are different 0-100 scales, never compare them) and, for a gap, which competitors rank and where. Then suggest which existing page from step 1 should target each group, or say a new page is needed when none fits. Quote volumes as numbers, not adjectives.
11
- - Mention any `notes` (for example Semrush returning fewer lines than asked, which can mean a low unit balance, or Ahrefs using the client's Search Console to drop keywords it already ranks for), the actual cost (`costUsd` or `units`), and `fromCache`.
11
+ - Mention any `notes` (for example Ahrefs using the client's Search Console to drop keywords it already ranks for), the actual cost (`costUsd` or `units`), and `fromCache`.
12
12
  6. **Track (optional).** Ask whether to track any of the keywords. Only after I name them, call `add_keywords` for `{{client}}` with those keywords and `source: "suggested"`. Tracked keywords show up in `check_ranks`, `rank_history` and monthly reports.
13
13
 
14
14
  Prefer the free tools (Search Console, the saved lists) where they answer the question. If a tool returns an error or the data is unavailable (no key for the client's provider, no country for Ahrefs or Semrush, no competitors saved), give me the error and its fix in plain words instead of guessing or switching to another provider. Keep the conversation brief.
@@ -5,8 +5,8 @@
5
5
  *
6
6
  * Source of truth: docs/research/ahrefs-api-v3.md (Ahrefs' OpenAPI spec and guides,
7
7
  * accessed 2026-10-13). Parameter names, enums, field costs and headers come from
8
- * there; anything listed under its "Not confirmed" takes the spec's safe reading
9
- * (specs/2026-10-13-1500-phase-9-seo-data-providers.md 9.11 N1-N6), marked below.
8
+ * there; anything listed under its "Not confirmed" takes the spec's safe reading,
9
+ * marked below.
10
10
  */
11
11
  import { stripControl } from "../lib/terminal-text.js";
12
12
  const num = (v) => (typeof v === "number" && Number.isFinite(v) ? v : null);
@@ -1,94 +1,13 @@
1
- /**
2
- * Pure Semrush helpers (Phase 9): the v3 CSV parser with header checking, error
3
- * parsing and classification (v3 `ERROR n :: text` bodies and v4 JSON errors), the
4
- * balance parser, unit prices and estimates, and mappers to crawlmark's types. No I/O.
5
- *
6
- * Source of truth: docs/research/semrush-api.md (Semrush developer docs, accessed
7
- * 2026-10-13). Column codes and header texts, report prices, error codes and the
8
- * database list come from there; items under its "Not confirmed" take the spec's safe
9
- * reading (specs/2026-10-13-1500-phase-9-seo-data-providers.md 9.11 N7-N15), marked below.
10
- */
11
- import { stripControl } from "../lib/terminal-text.js";
12
- // ------------------------------------------------------------------ columns and prices
13
- /** v3 column code -> the display-name header Semrush returns for it ("Column names can't be changed"). */
14
- export const SEMRUSH_HEADER_TEXT = {
15
- Ph: "Keyword",
16
- Nq: "Search Volume",
17
- Cp: "CPC",
18
- Co: "Competition",
19
- Nr: "Number of Results",
20
- Td: "Trends",
21
- Po: "Position",
22
- Pp: "Previous Position",
23
- Pd: "Position Difference",
24
- Ur: "Url",
25
- Tr: "Traffic (%)",
26
- Tg: "Traffic",
27
- Tc: "Traffic Cost (%)",
28
- Dn: "Domain",
29
- Cr: "Competitor Relevance",
30
- Np: "Common Keywords",
31
- Or: "Organic Keywords",
32
- Ot: "Organic Traffic",
33
- Oc: "Organic Cost",
34
- Ad: "Adwords Keywords",
35
- // Phase 12 B (keyword reports and domain_domains). Not confirmed: the Kd header text (see SEMRUSH_HEADER_ALTERNATES).
36
- Kd: "Keyword Difficulty",
37
- // v3 backlinks_overview returns its export_columns names as the header.
38
- ascore: "ascore",
39
- total: "total",
40
- domains_num: "domains_num",
41
- // Phase 13 B: domain_rank_history's date column.
42
- Dt: "Date",
43
- };
44
- /**
45
- * Other header texts accepted for a column whose exact header isn't documented (Phase 12 B, to confirm in the
46
- * live check): Kd has appeared as "Keyword Difficulty Index" in older Semrush output.
47
- */
48
- export const SEMRUSH_HEADER_ALTERNATES = {
49
- Kd: ["Keyword Difficulty Index"],
50
- };
51
- export const SEMRUSH_COLUMNS = {
52
- phrase_these: ["Ph", "Nq", "Cp", "Co"],
53
- /** Tg is estimated traffic (visits); Tr is a percentage share and is never requested. */
54
- domain_organic: ["Ph", "Po", "Nq", "Ur", "Tg"],
55
- domain_organic_organic: ["Dn", "Np", "Ot"],
56
- backlinks_overview: ["ascore", "total", "domains_num"],
57
- /** Phase 12 B keyword ideas: phrase_related (40/line) and phrase_fullsearch (20/line). */
58
- phrase_related: ["Ph", "Nq", "Cp", "Co", "Kd"],
59
- phrase_fullsearch: ["Ph", "Nq", "Cp", "Co", "Kd"],
60
- /** Phase 12 B keyword gap (Domain vs Domain): P0 is the first domain's position (the competitor), P1 the client's. */
61
- domain_domains: ["Ph", "P0", "P1", "Nq", "Kd"],
62
- /** Phase 13 B: organic visibility now (domain_rank, 10/line), monthly history (domain_rank_history, 10/line) and competitors with their keyword counts. */
63
- domain_rank: ["Dn", "Or", "Ot"],
64
- domain_rank_history: ["Dt", "Or", "Ot"],
65
- domain_organic_organic_full: ["Dn", "Np", "Or", "Ot"],
66
- };
67
1
  export const SEMRUSH_V4_BACKLINK_FIELDS = ["backlinks_count", "domains_count", "new_count", "lost_count", "score"];
68
- /** Documented prices: per line returned for the reports, per request for the backlinks overviews. */
2
+ /** Documented prices per request: the v4 backlinks overview and v4 Get Keyword Metrics (one keyword each). */
69
3
  export const SEMRUSH_UNITS = {
70
- phrase_these: 10,
71
- domain_organic: 10,
72
- domain_organic_organic: 40,
73
4
  backlinks_overview_v4: 45,
74
- backlinks_overview_v3: 40,
75
- phrase_related: 40,
76
- phrase_fullsearch: 20,
77
- domain_domains: 80,
78
- domain_rank: 10,
79
- domain_rank_history: 10,
5
+ keyword_metrics_v4: 20,
80
6
  };
81
- const PER_REQUEST = new Set(["backlinks_overview_v4", "backlinks_overview_v3"]);
82
- /** Units for `lines` returned (per-line reports) or one request (backlinks). Empty responses cost nothing. */
83
- export function estimateSemrushUnits(type, lines) {
84
- if (PER_REQUEST.has(type))
85
- return lines > 0 ? SEMRUSH_UNITS[type] : 0;
86
- return SEMRUSH_UNITS[type] * Math.max(0, lines);
87
- }
88
7
  /**
89
- * A keyword list made safe to price and send: entries split on `;` (Semrush's phrase separator)
90
- * and line breaks, trimmed, empties dropped and exact duplicates removed. Estimates, batches and
91
- * cache keys all use this list, so one entry can't smuggle in extra billed keywords.
8
+ * A keyword list made safe to price and send: entries split on `;` and line breaks, trimmed, empties dropped and exact
9
+ * duplicates removed. Estimates, requests and cache keys all use this list, so one entry can't smuggle in extra billed
10
+ * keywords. (Shared with Ahrefs.)
92
11
  */
93
12
  export function normalizeKeywordList(keywords) {
94
13
  const out = [];
@@ -104,79 +23,7 @@ export function normalizeKeywordList(keywords) {
104
23
  }
105
24
  return out;
106
25
  }
107
- /** Keywords per phrase_these request ("up to 100 keywords"). */
108
- export const SEMRUSH_KEYWORD_BATCH = 100;
109
- // ------------------------------------------------------------------ CSV
110
- export class SemrushFormatError extends Error {
111
- name = "SemrushFormatError";
112
- }
113
- /** Split one CSV line on `;`, honouring double-quoted fields (export_escape=1, with "" as an escaped quote). */
114
- function splitLine(line) {
115
- const out = [];
116
- let cur = "";
117
- let quoted = false;
118
- for (let i = 0; i < line.length; i++) {
119
- const ch = line[i];
120
- if (quoted) {
121
- if (ch === '"' && line[i + 1] === '"') {
122
- cur += '"';
123
- i++;
124
- }
125
- else if (ch === '"')
126
- quoted = false;
127
- else
128
- cur += ch;
129
- }
130
- else if (ch === '"' && cur === "")
131
- quoted = true;
132
- else if (ch === ";") {
133
- out.push(cur);
134
- cur = "";
135
- }
136
- else
137
- cur += ch;
138
- }
139
- out.push(cur);
140
- return out;
141
- }
142
- /**
143
- * Parse a v3 report: semicolon-separated, header row first (display names, not codes).
144
- * Columns are mapped by the position of the `exportColumns` crawlmark sent, and each
145
- * header cell is checked against SEMRUSH_HEADER_TEXT, so a changed format is a
146
- * SemrushFormatError, never a silent mis-map. Commas inside a field (the Trends
147
- * value), CRLF, a trailing newline and quoted fields are fine. Extra trailing columns are ignored.
148
- */
149
- export function parseSemrushCsv(text, exportColumns,
150
- /** Extra header checks for columns whose header isn't a fixed text (domain_domains' P0/P1 name the domains). */
151
- accept = {}) {
152
- const lines = text.replace(/^\uFEFF/, "").split(/\r?\n/).filter((l) => l.trim() !== "");
153
- if (lines.length === 0)
154
- return [];
155
- const header = splitLine(lines[0]).map((h) => h.trim());
156
- exportColumns.forEach((code, i) => {
157
- const custom = accept[code];
158
- if (custom) {
159
- if (!custom(header[i] ?? ""))
160
- throw new SemrushFormatError(`Semrush returned an unexpected header: column ${i + 1} is ${JSON.stringify(header[i] ?? "(missing)")} for ${code}.`);
161
- return;
162
- }
163
- const want = SEMRUSH_HEADER_TEXT[code];
164
- if (want === undefined)
165
- throw new SemrushFormatError(`Unknown Semrush column code ${code}.`);
166
- if (header[i] !== want && !(SEMRUSH_HEADER_ALTERNATES[code] ?? []).includes(header[i] ?? "")) {
167
- throw new SemrushFormatError(`Semrush returned an unexpected header: column ${i + 1} is ${JSON.stringify(header[i] ?? "(missing)")}, expected ${JSON.stringify(want)} for ${code}.`);
168
- }
169
- });
170
- return lines.slice(1).map((line) => {
171
- const cells = splitLine(line);
172
- return Object.fromEntries(exportColumns.map((code, i) => [code, (cells[i] ?? "").trim()]));
173
- });
174
- }
175
- /** A v3 error body, `ERROR <n> :: <text>` (whatever the HTTP status: N10), else null. */
176
- export function parseSemrushError(text) {
177
- const m = /^\s*ERROR (\d+) :: (.*)/s.exec(text);
178
- return m ? { code: Number(m[1]), text: m[2].trim() } : null;
179
- }
26
+ // ------------------------------------------------------------------ errors
180
27
  /** A v4 JSON error body: `{ meta, error: { code, message, retryable } }`, else null. */
181
28
  export function parseSemrushV4Error(json) {
182
29
  const j = json;
@@ -190,42 +37,20 @@ export function parseSemrushV4Error(json) {
190
37
  status: typeof j.meta?.status_code === "number" ? j.meta.status_code : null,
191
38
  };
192
39
  }
193
- const KEY_REJECTED = "Semrush rejected the API key (SEMRUSH_API_KEY, or SEMRUSH_V4_API_KEY for v4 calls).";
194
- const DISABLED = "Semrush API is not enabled for this account or report (needs an SEO Business plan plus an API unit package).";
195
- const ZERO_BALANCE = "Semrush API units balance is zero; buy units under Subscription info > API units.";
40
+ export const KEY_REJECTED = "Semrush rejected the API key. crawlmark uses Semrush's v4 API: create a key under your profile > API keys and run setup --step semrush.";
41
+ const ZERO_BALANCE = "Semrush API units balance is zero; check your units under Subscription info > API units.";
196
42
  const RATE = "Semrush request limit hit (10 requests/s, 10 at once); try again shortly.";
197
- /** The user-facing message for a v3 error code (spec 9.2). `text` is already redacted. */
198
- export function classifySemrushError(e, database) {
199
- const c = e.code;
200
- if (c === 110 || c === 120 || c === 402)
201
- return { kind: "auth", message: KEY_REJECTED };
202
- if (c === 130 || c === 135)
203
- return { kind: "disabled", message: DISABLED };
204
- if (c === 132)
205
- return { kind: "balance", message: ZERO_BALANCE };
206
- if (c === 131 || c === 134 || c === 429)
207
- return { kind: "rate", message: RATE };
208
- if (c === 133)
209
- return { kind: "database", message: `Semrush denied access to database ${database ?? "(unknown)"} on this plan.` };
210
- if ((c >= 40 && c <= 46) || (c >= 10000 && c <= 10040) || c === 605 || c === 613) {
211
- return { kind: "request", message: `Semrush rejected the request (${c}: ${e.text}). This is likely a crawlmark bug; please report it.` };
212
- }
213
- return { kind: "server", message: `Semrush error ${c}: ${e.text}` };
214
- }
215
43
  /**
216
- * A v4 error (HTTP status plus the JSON body). N11: how ERROR 132 shows up on v4 isn't
217
- * documented, so error.code 132, or a 403 whose message mentions units, is the zero balance;
218
- * any other 403 is "not enabled".
44
+ * A v4 error (HTTP status plus the JSON body). How a zero balance shows up on v4 isn't documented, so error.code 132,
45
+ * or a 403 whose message mentions units, is the zero balance; 401 and any other 403 are a rejected key.
219
46
  */
220
47
  export function classifySemrushV4Error(status, body) {
221
48
  const code = body?.code ?? status;
222
49
  const message = body?.message ?? "";
223
50
  if (code === 132 || (status === 403 && /unit/i.test(message)))
224
51
  return { kind: "balance", message: ZERO_BALANCE };
225
- if (status === 401 || code === 401)
52
+ if (status === 401 || status === 403 || code === 401 || code === 403)
226
53
  return { kind: "auth", message: KEY_REJECTED };
227
- if (status === 403 || code === 403)
228
- return { kind: "disabled", message: DISABLED };
229
54
  if (status === 429 || code === 429)
230
55
  return { kind: "rate", message: RATE };
231
56
  if (status === 400 || status === 404 || status === 409) {
@@ -233,54 +58,22 @@ export function classifySemrushV4Error(status, body) {
233
58
  }
234
59
  return { kind: "server", message: `Semrush v4 error ${code}: ${message || `HTTP ${status}`}` };
235
60
  }
236
- /** The countapiunits body: a number with thousands separators and whitespace (N9), e.g. `1,000\n` -> 1000. Null if it isn't one. */
237
- export function parseUnitsBalance(text) {
238
- const t = text.replace(/[\s,]/g, "");
239
- return /^\d+$/.test(t) ? Number(t) : null;
240
- }
241
61
  // ------------------------------------------------------------------ mappers
242
- const num = (v) => {
243
- if (v === undefined || v.trim() === "")
244
- return null;
245
- const n = Number(v);
246
- return Number.isFinite(n) ? n : null;
247
- };
248
- const str = (v) => (v && v.trim() !== "" ? v.trim() : null);
249
- /** Co (0-1 competitive density) -> LOW (< 0.34), MEDIUM (< 0.67), HIGH. */
62
+ /** Co / competitive density (0-1) -> LOW (< 0.34), MEDIUM (< 0.67), HIGH. */
250
63
  export function competitionLevel(co) {
251
64
  if (co === null)
252
65
  return null;
253
66
  return co < 0.34 ? "LOW" : co < 0.67 ? "MEDIUM" : "HIGH";
254
67
  }
255
- /** phrase_these rows -> KeywordVolume, in request order; a keyword with no line comes back with nulls (N13). Cp is already USD. */
256
- export function mapSemrushKeywordVolumes(rows, requested) {
257
- const byKey = new Map();
258
- for (const r of rows) {
259
- const keyword = str(r.Ph);
260
- if (!keyword)
261
- continue;
262
- byKey.set(keyword.toLowerCase(), { keyword, search_volume: num(r.Nq), competition: competitionLevel(num(r.Co)), cpc: num(r.Cp) });
263
- }
264
- return requested.map((k) => byKey.get(k.toLowerCase()) ?? { keyword: k, search_volume: null, competition: null, cpc: null });
265
- }
266
- /** domain_organic rows -> RankedKeywordsResult. Position 0 means not ranked (null). Tg is the traffic estimate. */
267
- export function mapSemrushRankedKeywords(rows, target) {
268
- const items = rows.flatMap((r) => {
269
- const keyword = str(r.Ph);
270
- const pos = num(r.Po);
271
- return keyword ? [{ keyword, position: pos === 0 ? null : pos, searchVolume: num(r.Nq), url: str(r.Ur), estimatedTraffic: num(r.Tg) }] : [];
272
- });
273
- return { target, totalCount: items.length, items };
274
- }
275
- /** domain_organic_organic rows -> CompetitorsResult (avgPosition null). */
276
- export function mapSemrushCompetitors(rows, target) {
277
- const items = rows.flatMap((r) => {
278
- const domain = str(r.Dn);
279
- return domain ? [{ domain, intersectingKeywords: num(r.Np), avgPosition: null, estimatedTraffic: num(r.Ot) }] : [];
280
- });
281
- return { target, totalCount: items.length, items };
282
- }
283
68
  const jnum = (v) => (typeof v === "number" && Number.isFinite(v) ? v : typeof v === "string" && v.trim() !== "" && Number.isFinite(Number(v)) ? Number(v) : null);
69
+ /** Sent with v4 keyword metrics: cpc's unit and competitive_density's scale aren't documented. */
70
+ export const SEMRUSH_V4_METRICS_NOTE = "Semrush v4 keyword metrics are Early Access. cpc is passed through as Semrush returns it (its unit isn't documented; the sample value 1245 may be cents), and competition is derived from competitive_density read as 0-100 (also undocumented).";
71
+ /** v4 Get Keyword Metrics response -> KeywordVolume (search_volume and cpc arrive as strings). */
72
+ export function mapSemrushKeywordMetricsV4(json, keyword) {
73
+ const d = (json?.data ?? {});
74
+ const density = jnum(d.competitive_density);
75
+ return { keyword, search_volume: jnum(d.search_volume), competition: competitionLevel(density === null ? null : density / 100), cpc: jnum(d.cpc) };
76
+ }
284
77
  /** v4 backlinks overview `data` -> BacklinksSummary (new/lost from new_count/lost_count; `score` as semrush_score). */
285
78
  export function mapSemrushBacklinksV4(json, target) {
286
79
  const d = (json?.data ?? {});
@@ -295,91 +88,4 @@ export function mapSemrushBacklinksV4(json, target) {
295
88
  authority: { metric: "semrush_score", value: jnum(d.score) },
296
89
  };
297
90
  }
298
- /** v3 backlinks_overview row -> BacklinksSummary (no new/lost in v3; `ascore` as semrush_score). Null when there is no row. */
299
- export function mapSemrushBacklinksV3(rows, target) {
300
- const r = rows[0];
301
- if (!r)
302
- return null;
303
- return {
304
- target,
305
- backlinks: num(r.total),
306
- referringDomains: num(r.domains_num),
307
- rank: null,
308
- spamScore: null,
309
- newBacklinks: null,
310
- lostBacklinks: null,
311
- authority: { metric: "semrush_score", value: num(r.ascore) },
312
- };
313
- }
314
- /** D13: a per-line report shorter than requested may have been cut short by a low balance. */
315
- export function partialLinesNote(returned, requested) {
316
- return returned < requested
317
- ? `Semrush returned ${returned} of ${requested} requested lines; if your unit balance is low, results may be cut short (semrush_status shows the balance).`
318
- : undefined;
319
- }
320
- // ------------------------------------------------------------------ keyword ideas and gap (Phase 12 B)
321
- /** phrase_related / phrase_fullsearch rows -> ideas (Cp is USD; Co is 0-1; Kd is Semrush's 0-100 difficulty). */
322
- export function mapSemrushIdeas(rows, seed) {
323
- return rows.flatMap((r) => {
324
- const keyword = str(r.Ph)?.replace(/\s+/g, " ");
325
- return keyword ? [{ keyword, volume: num(r.Nq), cpc: num(r.Cp), competition: num(r.Co), difficulty: { metric: "semrush_kd", value: num(r.Kd) }, intent: null, seed }] : [];
326
- });
327
- }
328
- /** `*|or|<competitor>|-|or|<client>`: keywords the competitor has and the client lacks (research S2). */
329
- export const domainVsDomain = (competitor, client) => `*|or|${competitor}|-|or|${client}`;
330
- /**
331
- * domain_domains rows -> gap rows. P0 is the competitor's position (0 means not ranked: null); Semrush
332
- * gives no URL in this report (null).
333
- */
334
- export function mapSemrushGapRows(rows) {
335
- return rows.flatMap((r) => {
336
- const keyword = str(r.Ph)?.replace(/\s+/g, " ");
337
- const pos = num(r.P0);
338
- return keyword ? [{ keyword, volume: num(r.Nq), difficulty: num(r.Kd), position: pos === 0 ? null : pos, url: null }] : [];
339
- });
340
- }
341
- /**
342
- * Not confirmed (live check): the header text of domain_domains' position columns. Accepted: the domain
343
- * itself (any case, with or without www.), the column code, or a text naming a position.
344
- */
345
- export function positionHeaderFor(domain, code) {
346
- const d = domain.toLowerCase().replace(/^www\./, "");
347
- return (h) => {
348
- const t = h.trim().toLowerCase().replace(/^www\./, "");
349
- return t === d || t === code.toLowerCase() || /position/.test(t);
350
- };
351
- }
352
- // ------------------------------------------------------------------ Phase 13 B: visibility, history, competitors
353
- export const SEMRUSH_TRAFFIC_LABEL = "Semrush organic traffic";
354
- /** D6: Semrush's API reports no keywords by position band, so the bands are null. */
355
- const NO_BANDS = { top3: null, pos4_10: null, pos11_20: null, pos21_100: null };
356
- /** domain_rank's one line -> visibility figures (Or keywords, Ot traffic; bands null). Null figures when there is no line. */
357
- export function mapSemrushVisibility(rows) {
358
- const r = rows[0];
359
- return { keywords: num(r?.Or), ...NO_BANDS, traffic: num(r?.Ot), trafficLabel: SEMRUSH_TRAFFIC_LABEL };
360
- }
361
- /** domain_rank_history lines (Dt is YYYYMMDD; Semrush dates monthly points mid-month) -> points, oldest first. */
362
- export function mapSemrushVisibilityHistory(rows) {
363
- const out = [];
364
- for (const r of rows) {
365
- const m = /^(\d{4})(\d{2})(\d{2})$/.exec((r.Dt ?? "").trim());
366
- if (!m)
367
- continue;
368
- out.push({ day: `${m[1]}-${m[2]}-${m[3]}`, keywords: num(r.Or), ...NO_BANDS, traffic: num(r.Ot), trafficLabel: SEMRUSH_TRAFFIC_LABEL });
369
- }
370
- return out.sort((a, b) => a.day.localeCompare(b.day));
371
- }
372
- /** domain_organic_organic (Dn, Np, Or, Ot) -> competitors; the target and empty domains dropped; domains stripControl'd. */
373
- export function mapSemrushCompetitorsFull(rows, target) {
374
- const own = target.toLowerCase().replace(/^www\./, "");
375
- const out = [];
376
- for (const r of rows) {
377
- const raw = str(r.Dn);
378
- const domain = raw ? stripControl(raw).trim().toLowerCase().replace(/^www\./, "") : null;
379
- if (!domain || domain === own || out.some((o) => o.domain === domain))
380
- continue;
381
- out.push({ domain, commonKeywords: num(r.Np), keywords: num(r.Or), traffic: num(r.Ot), inProviderList: true });
382
- }
383
- return out;
384
- }
385
91
  //# sourceMappingURL=semrush-shape.js.map