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.
package/.env.example CHANGED
@@ -47,14 +47,14 @@ AHREFS_API_KEY=
47
47
  AHREFS_MONTHLY_UNIT_CAP=50000
48
48
  DEFAULT_CLIENT_MONTHLY_AHREFS_UNITS=10000
49
49
 
50
- # Semrush (optional): keyword volumes, rankings, competitors and backlinks from Semrush. Needs an SEO Toolkit Business
51
- # plan plus a separately bought API unit package (the balance is zero right after the upgrade).
52
- # SEMRUSH_API_KEY is the v3 key, under Subscription info > API units; it is used for every v3 report and the free
53
- # balance check. `crawlmark-cli setup --step semrush` checks it for free and saves it.
50
+ # Semrush (optional): keyword volume (v4 Get Keyword Metrics, 20 units per keyword) and backlinks (v4 overview) from Semrush.
51
+ # Semrush's v4 API has nothing else, so rankings, competitors and visibility come from DataForSEO or Ahrefs. The API needs
52
+ # a plan that includes API units (50,000 come with SEO Classic Pro and Guru, and Semrush One Starter and Pro+) or a bought
53
+ # unit package. SEMRUSH_API_KEY is a v4 key, from your profile > API keys; Semrush's older v3 keys aren't supported.
54
+ # Semrush has no free key check, so the key is checked by the first paid call. `crawlmark-cli setup --step semrush` saves it.
55
+ # (SEMRUSH_V4_API_KEY, the name 0.1.0 used for the v4 key, is still read and wins if both are set: in 0.1.0 SEMRUSH_API_KEY
56
+ # held the old v3 key. `setup --step semrush` saves SEMRUSH_API_KEY and removes SEMRUSH_V4_API_KEY.)
54
57
  SEMRUSH_API_KEY=
55
- # Optional v4 key (your Semrush profile > API keys). When set, backlinks use the v4 overview; when unset, the older v3
56
- # report. Semrush has no free check for v4 keys, so it is first checked when backlinks_summary runs.
57
- SEMRUSH_V4_API_KEY=
58
58
  # Semrush API units crawlmark may use per calendar month (UTC), across all clients, and per client by default
59
59
  # (a client's monthlyUnits.semrush in clients.yaml overrides the per-client value).
60
60
  SEMRUSH_MONTHLY_UNIT_CAP=20000
@@ -65,7 +65,6 @@ DEFAULT_CLIENT_MONTHLY_SEMRUSH_UNITS=5000
65
65
  # 127.0.0.1 / localhost / [::1] only; the server and `setup` print a stderr line while one is in use.
66
66
  # AHREFS_BASE_URL=
67
67
  # SEMRUSH_BASE_URL=
68
- # SEMRUSH_BALANCE_URL=
69
68
  # CRUX_BASE_URL=
70
69
  # BING_BASE_URL=
71
70
  # CRAWLER_IPRANGES_BASE_URL=
package/README.md CHANGED
@@ -10,7 +10,8 @@ It combines:
10
10
  | GA4 Data API | Organic sessions, engagement, key events, landing pages | Free |
11
11
  | Google Tag Manager | Live container contents for tag audits | Free |
12
12
  | DataForSEO | Keyword volume, live SERPs, ranked keywords, competitors, backlinks, site crawls | Pay per call |
13
- | Ahrefs or Semrush (beta) | Keyword volume, ranked keywords, competitors and backlinks, with your own API key | Your plan's API units |
13
+ | Ahrefs (beta) | Keyword volume, ranked keywords, competitors and backlinks, with your own API key | Your plan's API units |
14
+ | Semrush (beta) | Keyword volume and backlinks, with your own v4 API key (its v4 API has nothing else) | Your plan's API units |
14
15
  | Lighthouse | Performance, SEO and best-practice scores, lab Core Web Vitals | Free (runs locally) |
15
16
  | PageSpeed Insights | Real-user field data (CrUX) | Free with an API key (optional) |
16
17
  | Chrome UX Report History | Real-user Core Web Vitals week by week: 25 weeks by default, up to 40 | Free with an API key (optional) |
@@ -26,7 +27,7 @@ Install crawlmark
26
27
  npx crawlmark-mcp@latest init
27
28
  ```
28
29
 
29
- That one command sets up crawlmark and adds it to your AI apps: **Claude Code, Claude Desktop, Cursor and Codex**. It needs **Node 22.13 or later** (crawlmark uses the built-in `node:sqlite`). Other ways to install are below; whichever you pick, [set up your credentials](#set-up-credentials) next.
30
+ That one command sets up crawlmark and adds it to your AI apps: **Claude Code, Claude Desktop, Cursor and Codex**. `npm install crawlmark-mcp` on its own only downloads the package and doesn't start setup; run `init` with `npx` as above (or install globally with `npm i -g crawlmark-mcp` and run `crawlmark-mcp init`). It needs **Node 22.13 or later** (crawlmark uses the built-in `node:sqlite`). Other ways to install are below; whichever you pick, [set up your credentials](#set-up-credentials) next.
30
31
 
31
32
  | System or use | Installation method |
32
33
  |---|---|
@@ -53,7 +54,7 @@ Every change is shown before it's made, other servers are listed by name only, a
53
54
 
54
55
  In a terminal you get arrow-key choices, masked secrets, a spinner with the time taken for each live check, and a short tip under each result. Links are clickable in terminals that support it (iTerm2, WezTerm, VS Code, Ghostty, Windows Terminal, kitty and recent GNOME terminals; `FORCE_HYPERLINK=1` or `0` overrides), and the steps that need a web page (DataForSEO API access, Ahrefs and Semrush keys, Google Cloud, PageSpeed) offer to open it with Enter. The summary box ends with three things to try asking Claude, using up to three of your own clients from `clients.yaml` (read locally and only shown in your terminal; `example.com` when you have none). With piped input, `CI=true`, `TERM=dumb`, `NO_COLOR` or `--yes`, the same steps run as plain numbered prompts without any of that. `init` and `setup` are the same command.
55
56
 
56
- Run `init` again once crawlmark is set up (a `.env` in its config home and at least one working data source) and it shows a menu instead of every step: add or change a data provider, change the Google sign-in, re-register your AI apps, refresh clients, set up report branding, check status, run the full setup again, or exit. `setup --step <name>` still goes straight to one step.
57
+ Run `init` again once crawlmark is set up (a `.env` in its config home and at least one working data source) and it shows a menu instead of every step: add or change a data provider, change the Google sign-in, re-register your AI apps, add Semrush's or Ahrefs' own MCP server, refresh clients, set up report branding, check status, run the full setup again, or exit. `setup --step <name>` still goes straight to one step.
57
58
 
58
59
  `init`, `setup`, `--help` and `--version` check npm for a newer crawlmark at most once a day (a plain request for the latest version number, with a 1.5 second timeout and nothing about you or your clients) and print `Update available` when there is one. It never runs in CI, when output isn't a terminal, or with `--json`. Set `CRAWLMARK_NO_UPDATE_CHECK=1` (or the common `NO_UPDATE_NOTIFIER=1`) to turn it off; the MCP server itself never checks.
59
60
 
@@ -65,7 +66,7 @@ crawlmark prefers puppeteer's `chrome-headless-shell` (run `npx puppeteer browse
65
66
 
66
67
  ### Claude Desktop bundle (MCPB)
67
68
 
68
- Download `crawlmark.mcpb` from the release page and double-click it, or drag it onto Claude Desktop (**Settings > Extensions**). Claude Desktop shows a form. Every field is optional, so fill in only what you use:
69
+ Download `crawlmark.mcpb` from the [latest release](https://github.com/idrewlong/crawlmark-mcp/releases/latest) and double-click it, or drag it onto Claude Desktop (**Settings > Extensions**). Claude Desktop shows a form. Every field is optional, so fill in only what you use:
69
70
 
70
71
  | Field | What it is |
71
72
  |---|---|
@@ -73,7 +74,7 @@ Download `crawlmark.mcpb` from the release page and double-click it, or drag it
73
74
  | Use the live DataForSEO API | Off (default) uses the free sandbox with sample data. On uses real data, billed to your balance |
74
75
  | Default SEO data provider | `dataforseo`, `ahrefs` or `semrush`. Blank uses the value in your crawlmark `.env` (from `setup --step provider`), else `dataforseo`. Any other value is reported by `setup_status` |
75
76
  | Ahrefs API key | API v3 key ([Ahrefs](#ahrefs)) |
76
- | Semrush API key (v3), Semrush API key (v4, optional) | [Semrush](#semrush) keys; the v4 key is only for the newer backlinks report |
77
+ | Semrush API key (v4) | The [Semrush](#semrush) key, from your profile > API keys |
77
78
  | Ahrefs / Semrush monthly unit cap | API units crawlmark may use per month across all clients. Defaults 50,000 and 20,000 |
78
79
  | Google service-account key file | The JSON key from [option C](#option-c-service-account) |
79
80
  | Google OAuth client ID / secret | Only for signing in with your own Google account ([options A and B](#options-a-and-b-oauth)). After installing, sign in once from a terminal: `CRAWLMARK_HOME=<data folder> npx crawlmark-mcp setup --step google` |
@@ -105,7 +106,7 @@ The setup wizard covers everything here. Each step shows its current status firs
105
106
  | Command | Does |
106
107
  |---|---|
107
108
  | `crawlmark-cli init` (or `setup`) | The whole wizard |
108
- | `crawlmark-cli setup --step <node\|provider\|dataforseo\|ahrefs\|semrush\|google\|psi\|discovery\|register>` | One step. `provider` picks your SEO data providers and the default one |
109
+ | `crawlmark-cli setup --step <node\|provider\|dataforseo\|ahrefs\|semrush\|google\|psi\|discovery\|register\|vendor-mcp>` | One step. `provider` picks your SEO data providers and the default one; `vendor-mcp` adds Semrush's or Ahrefs' own MCP server to your AI apps (see [below](#semrushs-and-ahrefs-own-mcp-servers-optional)) |
109
110
  | `crawlmark-cli setup --step crux` | A key for Core Web Vitals history (CrUX). Only run this way; it isn't part of the default wizard |
110
111
  | `crawlmark-cli setup --step bing` | A Bing Webmaster Tools API key. Only run this way; it isn't part of the default wizard |
111
112
  | `crawlmark-cli setup --step branding [--client <domain>]` | Report branding: agency name, logo, colours, footer, white label, paper size and default formats, with a one-page preview before saving. Only run this way (or from the setup menu) |
@@ -164,38 +165,47 @@ By hand, set `AHREFS_API_KEY` (and `SEO_DATA_PROVIDER=ahrefs` to make it the def
164
165
 
165
166
  > **Beta.** The Semrush integration is built to the official API documentation and tested against the documented example responses, but it hasn't been verified against a live account yet. Cost caps and key handling are fully tested. If anything looks wrong (an error you don't expect, a missing field, units that don't match your dashboard), please [open an issue](https://github.com/idrewlong/crawlmark-mcp/issues).
166
167
 
167
- Semrush is optional. Its API needs an **SEO Toolkit Business** plan **plus** a separately bought **API unit package**; the unit balance is zero until you buy one.
168
+ Semrush is optional. crawlmark uses Semrush's **v4 API** with a v4 key; Semrush's older v3 keys aren't supported. Semrush's v4 SEO API has only two things crawlmark uses:
168
169
 
169
- 1. Find your **v3 API key** under **Subscription info > API units**. crawlmark uses it for keyword volume, ranked keywords and competitors.
170
- 2. Optionally create a **v4 key** in your profile under **API keys**. With it, backlinks use Semrush's newer v4 overview (45 units); without it, the older v3 report (40 units).
171
- 3. Run `crawlmark-cli setup --step semrush`. It checks the v3 key with Semrush's free balance call and shows the units left ("key accepted": the check proves the key exists, not that your plan includes the API). A v4 key has no free check, so it's saved untested and checked the first time `backlinks_summary` runs.
170
+ | What crawlmark does | From Semrush |
171
+ |---|---|
172
+ | `keyword_volume` | v4 Get Keyword Metrics, 20 units per keyword (Early Access per Semrush, so it may change) |
173
+ | `backlinks_summary` and the `backlinks` report section | v4 backlinks overview, 45 units; no history (stored figures only) |
174
+ | Everything else: `ranked_keywords`, `competitor_domains`, `organic_visibility`, `competitor_overview`, `keyword_ideas`, `keyword_gap`, Site Audit in `technical_issues` | Not in Semrush's v4 API. DataForSEO serves them when it is set up (the result says `fallbackFrom: semrush`); otherwise the call says to use DataForSEO or Ahrefs |
175
+
176
+ API units come with the plan: Semrush says 50,000 units are included with SEO Classic Pro and Guru and with Semrush One Starter and Pro+, and SEO Classic Business or Semrush One Advanced can add unit packages.
177
+
178
+ 1. Create a **v4 key** in your Semrush profile under **API keys** (up to 100, revocable, with an expiry).
179
+ 2. Run `crawlmark-cli setup --step semrush` and paste it. Semrush has no free key check, so the key is saved as it is and checked by the first paid call (keyword volume or backlinks). A Semrush rejection on that call says to create a v4 key and re-run the step.
172
180
 
173
- By hand, set `SEMRUSH_API_KEY` (and `SEMRUSH_V4_API_KEY`, and `SEO_DATA_PROVIDER=semrush` to make it the default). The `semrush_status` tool shows the balance.
181
+ By hand, set `SEMRUSH_API_KEY` (and `SEO_DATA_PROVIDER=semrush` to make it the default). If you used 0.1.0, a key saved as `SEMRUSH_V4_API_KEY` is still read, and if both are set the `SEMRUSH_V4_API_KEY` one wins, because in 0.1.0 `SEMRUSH_API_KEY` held the old v3 key. Re-running the step saves the key as `SEMRUSH_API_KEY` and removes `SEMRUSH_V4_API_KEY` from `.env`. The `semrush_status` tool and `setup status` show whether the key is set and what it serves.
182
+
183
+ In v4 keyword metrics the CPC unit and the competition scale aren't documented, so CPC is passed through as Semrush returns it and the result carries a note saying so.
174
184
 
175
185
  Semrush's API terms don't allow keeping its data longer than a month, so crawlmark deletes stored Semrush figures 31 days after fetching them.
176
186
 
177
187
  ### Data providers
178
188
 
179
- Keyword volume, rankings, competitors, backlinks, keyword ideas and the competitor keyword gap can come from any of the three providers. DataForSEO is the default and is verified against its live API; Ahrefs and Semrush are in beta (see above). Live SERPs, on-page checks and crawls come from DataForSEO only.
189
+ Keyword volume and backlinks can come from any of the three providers; rankings, competitors, visibility, keyword ideas and the competitor keyword gap from DataForSEO or Ahrefs (Semrush's v4 API doesn't have them, so a Semrush client is served by DataForSEO for those when it is set up). DataForSEO is the default and is verified against its live API; Ahrefs and Semrush are in beta (see above). Live SERPs, on-page checks and crawls come from DataForSEO only.
180
190
 
181
191
  | Capability | DataForSEO | Ahrefs | Semrush |
182
192
  |---|---|---|---|
183
- | `keyword_volume` | Google Ads volume | Keywords Explorer (12-month average) | Batch keyword overview (12-month average) |
184
- | `ranked_keywords` | Labs ranked keywords | Site Explorer organic keywords | Domain organic search keywords |
185
- | `competitor_domains` | Labs competitors | Site Explorer organic competitors | Competitors in organic search |
186
- | `backlinks_summary` | Backlinks summary (rank, spam score) | Backlinks stats plus Domain Rating | Backlinks overview (v4 with a v4 key, else v3) plus authority score |
187
- | `organic_visibility`, `organic_visibility` report section | Labs domain rank overview (position bands, estimated traffic); history: historical rank overview | Metrics plus keywords history (position bands, organic traffic); history: keywords and metrics history | Domain overview (keywords, traffic; no position bands); history: domain overview history |
188
- | `backlinks` report section | Backlinks summary; history: backlinks history (monthly, with new and lost referring domains) | Backlinks stats plus Domain Rating; history: referring domains and Domain Rating history | Backlinks overview; no history (stored figures only) |
189
- | `competitor_overview`, `competitor_overview` report section | Labs competitors with each competitor's whole footprint | Organic competitors | Competitors in organic search |
190
- | `technical_issues` sources (paid, opt-in) | A stored full crawl's summary (free) | Site Audit issues of your existing project | Site Audit of your existing project |
191
- | `keyword_ideas` | Labs keyword ideas (`related`) or keyword suggestions (`matching`) | Keywords Explorer related terms or matching terms | Related keywords or broad match |
192
- | `keyword_gap` | Labs domain intersection | Each competitor's organic keywords, minus what the client already gets Search Console impressions for | Domain vs Domain |
193
+ | `keyword_volume` | Google Ads volume | Keywords Explorer (12-month average) | Get Keyword Metrics (v4, 20 units per keyword) |
194
+ | `ranked_keywords` | Labs ranked keywords | Site Explorer organic keywords | Not available (DataForSEO or Ahrefs) |
195
+ | `competitor_domains` | Labs competitors | Site Explorer organic competitors | Not available (DataForSEO or Ahrefs) |
196
+ | `backlinks_summary` | Backlinks summary (rank, spam score) | Backlinks stats plus Domain Rating | Backlinks overview (v4) plus authority score |
197
+ | `organic_visibility`, `organic_visibility` report section | Labs domain rank overview (position bands, estimated traffic); history: historical rank overview | Metrics plus keywords history (position bands, organic traffic); history: keywords and metrics history | Not available (DataForSEO or Ahrefs) |
198
+ | `backlinks` report section | Backlinks summary; history: backlinks history (monthly, with new and lost referring domains) | Backlinks stats plus Domain Rating; history: referring domains and Domain Rating history | Backlinks overview (v4); no history (stored figures only) |
199
+ | `competitor_overview`, `competitor_overview` report section | Labs competitors with each competitor's whole footprint | Organic competitors | Not available (DataForSEO or Ahrefs) |
200
+ | `technical_issues` sources (paid, opt-in) | A stored full crawl's summary (free) | Site Audit issues of your existing project | Not available |
201
+ | `keyword_ideas` | Labs keyword ideas (`related`) or keyword suggestions (`matching`) | Keywords Explorer related terms or matching terms | Not available (DataForSEO or Ahrefs) |
202
+ | `keyword_gap` | Labs domain intersection | Each competitor's organic keywords, minus what the client already gets Search Console impressions for | Not available (DataForSEO or Ahrefs) |
193
203
  | `serp_check`, `check_ranks` with `live`, on-page checks, crawls | Yes | No (DataForSEO is used) | No (DataForSEO is used) |
194
204
  | Account check | `dataforseo_status` | `ahrefs_status` | `semrush_status` |
195
205
 
196
206
  - **The default.** `SEO_DATA_PROVIDER` (`dataforseo`, `ahrefs` or `semrush`; blank means `dataforseo`) picks the default. `crawlmark-cli setup --step provider` sets it, and so does the bundle's "Default SEO data provider" field. An unknown value doesn't stop the server: `setup_status` and every paid call say what's wrong, and free tools keep working.
197
207
  - **Per client.** A client's `dataProvider` in clients.yaml overrides the default: `crawlmark-cli clients set example.com --provider ahrefs` (or `none` to go back to the default), or `set_client_profile`. Ahrefs and Semrush need the client's market as a country: set `country` (`--country GB`), or it comes from a country-level `locationCode`.
198
- - **No silent fallback.** If a client's provider has no key, its paid calls fail with the command to fix it; crawlmark never spends on another account instead. If the provider lacks a capability (live SERPs, on-page checks, crawls), DataForSEO serves it when it's set up, and the result says `fallbackFrom`.
208
+ - **No silent fallback.** If a client's provider has no key, its paid calls fail with the command to fix it; crawlmark never spends on another account instead. If the provider lacks a capability (live SERPs, on-page checks, crawls, or for Semrush anything but keyword volume and backlinks), DataForSEO serves it when it's set up, and the result says `fallbackFrom`.
199
209
  - **Units, not dollars.** DataForSEO is capped in USD. Ahrefs and Semrush are capped in their own API units, per month (UTC): `AHREFS_MONTHLY_UNIT_CAP` (default 50,000) and `SEMRUSH_MONTHLY_UNIT_CAP` (20,000) across all clients, and `DEFAULT_CLIENT_MONTHLY_AHREFS_UNITS` (10,000) and `DEFAULT_CLIENT_MONTHLY_SEMRUSH_UNITS` (5,000) per client, or a client's `monthlyUnits`. Each call checks its estimate first; units are never converted to dollars.
200
210
  - **Metrics differ.** Every result carries `provider`, and `backlinks_summary` labels the domain-strength number in `authority.metric` (`dataforseo_rank`, `domain_rating` or `semrush_score`). They aren't comparable across providers.
201
211
 
@@ -331,6 +341,62 @@ args = ["-y", "crawlmark-mcp"]
331
341
 
332
342
  For a source checkout, use `"command": "node"` and `"args": ["--disable-warning=ExperimentalWarning", "/absolute/path/to/crawlmark-mcp/dist/index.js"]`. Claude Desktop, Cursor and Codex may not load your shell's `PATH`, so if one can't find `node` or `npx`, use the absolute path (`which node`). Credentials come from `.env` in the config home, so no `env` block is needed unless your settings live somewhere other than the default; then add `"env": { "CRAWLMARK_HOME": "/path/to/home" }`.
333
343
 
344
+ ### Semrush's and Ahrefs' own MCP servers (optional)
345
+
346
+ Semrush and Ahrefs each host their own MCP server. You can add either to your AI app next to crawlmark, to ask the vendor's tools ad-hoc questions. This is separate from crawlmark's own Semrush and Ahrefs support:
347
+
348
+ | | crawlmark's Semrush / Ahrefs | The vendor's own MCP server |
349
+ |---|---|---|
350
+ | Sign-in | An API key in your crawlmark `.env` | OAuth, in your AI app |
351
+ | Good for | Reports, cost caps, caching and history | Ad-hoc questions in chat |
352
+ | Units | Counted against crawlmark's monthly caps | Spent directly from your account; crawlmark's caps, cache and reports don't cover them |
353
+
354
+ crawlmark itself never calls these servers; it only adds them to your AI apps. For Ahrefs, that is what its terms ask for: per [Ahrefs' docs](https://docs.ahrefs.com/docs/mcp/introduction), its MCP endpoint is for interactive use in MCP-compatible clients, and scripts or other programmatic access must use the public API, which is what crawlmark's own Ahrefs features use (REST API v3). Ahrefs' MCP is available on paid plans from Lite and draws from the same monthly API unit pool as the REST API; connecting through OAuth creates an MCP-tagged key with a default limit of 200,000 units a month, which workspace admins can change in the API keys report. Semrush says its MCP calls use the same API units as its API, with no extra fee ([Semrush MCP docs](https://developer.semrush.com/api/basics/semrush-mcp/)).
355
+
356
+ ```bash
357
+ crawlmark-cli setup --step vendor-mcp
358
+ ```
359
+
360
+ The step asks which servers to add (Semrush and Ahrefs, pre-ticked if you picked them as data providers), then which of your detected apps. Each change is previewed and asked about first, the config file is kept as a `.bak`, other servers are never touched, and an entry that is already there is left alone. If an entry named `semrush` or `ahrefs` already exists with a different setting, it is replaced only if you say yes (the default is no). No API key is ever written to an app's config. In a normal `init`, if you picked Semrush or Ahrefs as a provider, it asks once after the register step whether to also add them (default no; never asked under `--yes` or in plain mode).
361
+
362
+ To add them by hand (replace the name and URL for Ahrefs: `ahrefs`, `https://api.ahrefs.com/mcp/mcp`):
363
+
364
+ **Claude Code:**
365
+
366
+ ```bash
367
+ claude mcp add --transport http -s user semrush https://mcp.semrush.com/v2/mcp
368
+ ```
369
+
370
+ Then run `/mcp` in Claude Code and sign in.
371
+
372
+ **Cursor** (`~/.cursor/mcp.json`):
373
+
374
+ ```json
375
+ {
376
+ "mcpServers": {
377
+ "semrush": { "url": "https://mcp.semrush.com/v2/mcp" }
378
+ }
379
+ }
380
+ ```
381
+
382
+ **Codex:**
383
+
384
+ ```bash
385
+ codex mcp add semrush --url https://mcp.semrush.com/v2/mcp
386
+ codex mcp login semrush
387
+ ```
388
+
389
+ or in `~/.codex/config.toml`:
390
+
391
+ ```toml
392
+ [mcp_servers.semrush]
393
+ url = "https://mcp.semrush.com/v2/mcp"
394
+ ```
395
+
396
+ **Claude Desktop** takes remote servers in its settings, not in `claude_desktop_config.json`: open **Settings > Connectors > Add custom connector**, enter a name (Semrush) and the URL, save, and sign in when asked.
397
+
398
+ If a client can't do OAuth, Semrush also accepts the header `Authorization: Apikey <key>`; the wizard never writes that for you, so add it yourself if you need it.
399
+
334
400
  Once connected, run the **`getting-started`** prompt. It checks what's set up, explains what crawlmark can do with it, and offers a first report for your busiest client.
335
401
 
336
402
  Tools
@@ -384,7 +450,7 @@ Branded queries are excluded by default. See [docs/audits.md](docs/audits.md#opp
384
450
  | `site_hygiene` | robots.txt, sitemaps, structured data, hreflang (page tags, sitemap entries and `Link` headers), orphan pages (from the newest complete local or DataForSEO crawl), AI crawler access in robots.txt (AI search vs training-only crawlers) and llms.txt (informational: Google says it doesn't use it). Free |
385
451
  | `local_crawl` | Crawls the client's own site from your machine (free): status codes, redirects and chains, titles, meta descriptions, canonicals, H1s, internal links, depth, broken links, noindex pages and orphans against the sitemaps. Obeys robots.txt and Crawl-delay; same host only by default; up to 2,000 pages. Saved for `local_crawl_pages`, `site_hygiene` and audits |
386
452
  | `local_crawl_pages` | Pages from a saved local crawl, filtered by issue or status code. Free, local |
387
- | `technical_issues` | One deduplicated, severity-grouped list of technical problems from the saved audit, its site checks, the newest local crawl, a stored DataForSEO crawl and stored URL Inspection results, with the sources per issue and what's new, resolved or not re-checked since the last snapshot. Never starts a crawl. Free; your existing Ahrefs or Semrush Site Audit project can be added as a paid source with `allowPaid` (read-only) ([details](docs/audits.md#technical-issues)) |
453
+ | `technical_issues` | One deduplicated, severity-grouped list of technical problems from the saved audit, its site checks, the newest local crawl, a stored DataForSEO crawl and stored URL Inspection results, with the sources per issue and what's new, resolved or not re-checked since the last snapshot. Never starts a crawl. Free; your existing Ahrefs Site Audit project can be added as a paid source with `allowPaid` (read-only) ([details](docs/audits.md#technical-issues)) |
388
454
  | `index_coverage` | Google's indexing of a sample of the client's URLs from stored URL Inspection results: indexed share, coverage states, the worst URLs, canonical mismatches, sitemap errors, the monthly trend and how many sitemap URLs were checked recently (or that all were). Free, local ([details](docs/audits.md#indexing-coverage)) |
389
455
  | `import_index_report` | Imports an export of Search Console's Pages report (Indexing > Pages > Export: the zip of CSVs, an Excel workbook or one CSV) so `index_coverage` and the indexing report section show Google's complete counts per reason, with example URLs from a reason's own export. Reads only `IMPORT_DIRS` or `<home>/imports`; parsed on your machine. Free ([details](docs/audits.md#importing-search-consoles-pages-report)) |
390
456
  | `inspect_index_coverage` | Inspects key pages, top Search Console pages and a weekly-rotating sample of sitemap URLs with URL Inspection (free, read-only) within a daily per-property budget, and stores the results. `all: true` checks every page when today's budget allows, else says how many daily runs a full pass needs |
@@ -452,7 +518,7 @@ All three take `estimateOnly: true` (the estimate and what's left of the caps, w
452
518
  | `backlinks_summary` | **Paid** (the client's data provider). Backlink totals for a domain |
453
519
  | `dataforseo_status` | Credentials check, sandbox or live mode, and balance. Free |
454
520
  | `ahrefs_status` | Ahrefs key check, plan, units used and reset date. Free |
455
- | `semrush_status` | Semrush key check, units left, and whether a v4 key is set. Free |
521
+ | `semrush_status` | Whether the Semrush v4 key is set, what Semrush serves in crawlmark, and units used this month (no free key check exists). Free |
456
522
 
457
523
  Paid results carry `provider` (whose data it is) and `units` (Ahrefs or Semrush API units used; `null` for DataForSEO, which reports `costUsd`).
458
524
 
@@ -579,7 +645,7 @@ The config home holds `.env`, `config/clients.yaml`, `config/report-templates.ya
579
645
  | `DATAFORSEO_LIVE` | `false` | `true` selects the live API when no base URL is set |
580
646
  | `SEO_DATA_PROVIDER` | `dataforseo` | Default provider for keyword, ranking, competitor and backlink data: `dataforseo`, `ahrefs` or `semrush` |
581
647
  | `AHREFS_API_KEY` | | Ahrefs API v3 key |
582
- | `SEMRUSH_API_KEY`, `SEMRUSH_V4_API_KEY` | | Semrush v3 key, and the optional v4 key for backlinks |
648
+ | `SEMRUSH_API_KEY` | | Semrush v4 key (profile > API keys). `SEMRUSH_V4_API_KEY`, the 0.1.0 name for the v4 key, is still read and wins if both are set (in 0.1.0 this variable held the old v3 key) |
583
649
  | `AHREFS_MONTHLY_UNIT_CAP`, `SEMRUSH_MONTHLY_UNIT_CAP` | `50000`, `20000` | Monthly API units across all clients |
584
650
  | `DEFAULT_CLIENT_MONTHLY_AHREFS_UNITS`, `DEFAULT_CLIENT_MONTHLY_SEMRUSH_UNITS` | `10000`, `5000` | Monthly API units per client (clients.yaml `monthlyUnits` overrides) |
585
651
  | `PSI_API_KEY` | | PageSpeed Insights key |
@@ -3,16 +3,16 @@ const SEVERITIES = ["critical", "high", "medium", "low"];
3
3
  /**
4
4
  * The starter mapping (spec 13.5.3). crawlmark sources: `audit:<finding source>:<rule>` for the saved audit's
5
5
  * findings, `local_crawl:<rule>`, `site_hygiene:<rule>`, `gsc_inspection:<rule>`; DataForSEO crawl checks:
6
- * `dataforseo_crawl:<check>`. Ahrefs names and Semrush IDs are filled in by Stage B (research §1-2).
6
+ * `dataforseo_crawl:<check>`. Ahrefs names are filled in by Stage B (research §1).
7
7
  */
8
8
  export const CATALOGUE = [
9
- { key: "broken-internal-link", title: "Internal link to a broken page", category: "links", match: ["local_crawl:broken-internal-link", "dataforseo_crawl:is_broken"], ahrefs: ["Page has links to broken page"], semrush: [8] },
10
- { key: "server-error", title: "Page returns a server error (5xx)", category: "crawlability", match: ["audit:onpage:is_5xx_code", "dataforseo_crawl:is_5xx_code", "site_hygiene:sitemap-non200:5xx"], ahrefs: ["5XX page"], semrush: [1] },
11
- { key: "client-error-page", title: "Page returns a client error (4xx)", category: "crawlability", match: ["audit:onpage:is_4xx_code", "dataforseo_crawl:is_4xx_code", "site_hygiene:sitemap-non200:4xx"], ahrefs: ["4XX page"], semrush: [2] },
9
+ { key: "broken-internal-link", title: "Internal link to a broken page", category: "links", match: ["local_crawl:broken-internal-link", "dataforseo_crawl:is_broken"], ahrefs: ["Page has links to broken page"] },
10
+ { key: "server-error", title: "Page returns a server error (5xx)", category: "crawlability", match: ["audit:onpage:is_5xx_code", "dataforseo_crawl:is_5xx_code", "site_hygiene:sitemap-non200:5xx"], ahrefs: ["5XX page"] },
11
+ { key: "client-error-page", title: "Page returns a client error (4xx)", category: "crawlability", match: ["audit:onpage:is_4xx_code", "dataforseo_crawl:is_4xx_code", "site_hygiene:sitemap-non200:4xx"], ahrefs: ["4XX page"] },
12
12
  { key: "missing-title", title: "Page has no title", category: "content", match: ["local_crawl:missing-title", "audit:onpage:no_title", "dataforseo_crawl:no_title"], ahrefs: ["Title tag missing or empty"] },
13
13
  { key: "duplicate-title", title: "Title is used by another page", category: "duplicates", match: ["local_crawl:duplicate-title", "audit:onpage:duplicate_title_tag", "dataforseo_crawl:duplicate_title_tag"], ahrefs: ["Multiple pages have same title"] },
14
14
  { key: "missing-description", title: "Page has no meta description", category: "content", match: ["local_crawl:missing-description", "audit:onpage:no_description", "dataforseo_crawl:no_description"], ahrefs: ["Meta description tag missing or empty"] },
15
- { key: "missing-h1", title: "Page has no H1 heading", category: "content", match: ["audit:onpage:no_h1_tag", "dataforseo_crawl:no_h1_tag"], ahrefs: ["H1 tag missing or empty"], semrush: [103] },
15
+ { key: "missing-h1", title: "Page has no H1 heading", category: "content", match: ["audit:onpage:no_h1_tag", "dataforseo_crawl:no_h1_tag"], ahrefs: ["H1 tag missing or empty"] },
16
16
  { key: "redirect-chain", title: "Redirect chain (two or more hops)", category: "redirects", match: ["local_crawl:redirect-chain", "dataforseo_crawl:redirect_chain"], ahrefs: ["Redirect chain"] },
17
17
  { key: "noindex-in-sitemap", title: "Sitemap lists a noindex page", category: "indexability", match: ["site_hygiene:sitemap-noindex"], ahrefs: ["Noindex page in sitemap"] },
18
18
  { key: "canonical-mismatch", title: "Google chose a different canonical than the page declares", category: "indexability", match: ["audit:indexing:canonical-mismatch", "gsc_inspection:canonical-mismatch"] },
@@ -37,17 +37,13 @@ function matchKey(r) {
37
37
  }
38
38
  /**
39
39
  * Stage B: an Ahrefs Site Audit issue matches a row by its name (case-insensitive prefix: Ahrefs doesn't document its
40
- * issue IDs, research §1); a Semrush Site Audit issue by its documented numeric ID (S2). Unmatched ones keep their own key.
40
+ * issue IDs, research §1). Unmatched ones keep their own key.
41
41
  */
42
42
  function providerRow(r) {
43
43
  if (r.source === "ahrefs_site_audit") {
44
44
  const name = r.rule.toLowerCase();
45
45
  return CATALOGUE.find((row) => row.ahrefs?.some((a) => name.startsWith(a.toLowerCase())));
46
46
  }
47
- if (r.source === "semrush_site_audit") {
48
- const id = Number(r.rule);
49
- return Number.isInteger(id) ? CATALOGUE.find((row) => row.semrush?.includes(id)) : undefined;
50
- }
51
47
  return undefined;
52
48
  }
53
49
  /** Canonical key, title and category for a raw issue: its catalogue row, axe rules per rule, else `<source>:<rule>`. */
package/dist/cli-help.js CHANGED
@@ -26,7 +26,7 @@ export function topHelp(o) {
26
26
  heading("Get started", o),
27
27
  " init Set up credentials and clients, register your AI apps",
28
28
  " setup --step <name> Re-run one step: node, provider, dataforseo, ahrefs,",
29
- " semrush, google, psi, discovery or register",
29
+ " semrush, google, psi, discovery, register or vendor-mcp",
30
30
  " setup status What's configured and what to do next",
31
31
  " setup migrate --to Copy your settings to another folder",
32
32
  heading("Google", o),
@@ -66,8 +66,10 @@ const GROUP_TEXT = {
66
66
  " Claude Desktop, Cursor and Codex. Re-runnable; each step shows its status",
67
67
  " first and can be skipped. init and setup are the same command.",
68
68
  " --step <name> Run one step: node, provider, dataforseo, ahrefs, semrush,",
69
- " google, psi, discovery or register. provider picks your SEO",
70
- " data providers and the default one. crux (a key for Core",
69
+ " google, psi, discovery, register or vendor-mcp. provider",
70
+ " picks your SEO data providers and the default one.",
71
+ " vendor-mcp adds Semrush's or Ahrefs' own MCP server to",
72
+ " your AI apps (OAuth, optional). crux (a key for Core",
71
73
  " Web Vitals history), bing (a Bing Webmaster Tools key) and",
72
74
  " branding (report logo, colours, footer, white label and",
73
75
  " default formats, with a one-page preview; --client <domain>",
@@ -111,7 +113,6 @@ const GROUP_TEXT = {
111
113
  " [--country <XX|none>] [--bing-site <url|none>]",
112
114
  " [--place-id <id|none>]",
113
115
  " [--ahrefs-site-audit-project <id|none>]",
114
- " [--semrush-project <id|none>]",
115
116
  "",
116
117
  "clients list",
117
118
  " Show every client. When Google is set up, newly discovered Search Console",
@@ -150,9 +151,6 @@ const GROUP_TEXT = {
150
151
  " The client's existing Ahrefs Site Audit project",
151
152
  " (app.ahrefs.com/site-audit/<id>), read by",
152
153
  " technical_issues; none matches by domain",
153
- " --semrush-project <id>",
154
- " The client's existing Semrush project with Site",
155
- " Audit; none matches by domain",
156
154
  ],
157
155
  competitors: (p) => [
158
156
  `Usage: ${p} competitors list <client>`,
package/dist/cli.js CHANGED
@@ -399,8 +399,10 @@ const noneToEmpty = (v) => (v !== undefined && v.trim().toLowerCase() === "none"
399
399
  async function clientsSet(args, values) {
400
400
  if (args.length !== 1)
401
401
  return usageError("clients set takes exactly one client.");
402
- if (Object.values({ a: values.area, n: values.niche, m: values.name, b: values["brand-terms"], p: values.provider, c: values.country, s: values["bing-site"], g: values["place-id"], ah: values["ahrefs-site-audit-project"], sp: values["semrush-project"] }).every((v) => v === undefined)) {
403
- return usageError("clients set needs --area, --niche, --name, --brand-terms, --provider, --country, --bing-site, --place-id, --ahrefs-site-audit-project or --semrush-project.");
402
+ if (values["semrush-project"] !== undefined)
403
+ return usageError("--semrush-project is no longer supported: Semrush Site Audit needs Semrush's v3 API, which crawlmark no longer supports.");
404
+ if (Object.values({ a: values.area, n: values.niche, m: values.name, b: values["brand-terms"], p: values.provider, c: values.country, s: values["bing-site"], g: values["place-id"], ah: values["ahrefs-site-audit-project"] }).every((v) => v === undefined)) {
405
+ return usageError("clients set needs --area, --niche, --name, --brand-terms, --provider, --country, --bing-site, --place-id, or --ahrefs-site-audit-project.");
404
406
  }
405
407
  const provider = noneToEmpty(values.provider)?.trim().toLowerCase();
406
408
  if (provider !== undefined && provider !== "" && !PROVIDER_IDS.includes(provider)) {
@@ -411,7 +413,7 @@ async function clientsSet(args, values) {
411
413
  return usageError(`--country must be a two-letter ISO code such as US or GB, or none (got "${values.country}").`);
412
414
  }
413
415
  const brandTerms = values["brand-terms"] === undefined ? undefined : values["brand-terms"].split(",");
414
- const r = await setClientProfile(args[0], { area: values.area, niche: values.niche, name: values.name, brandTerms, dataProvider: provider, country, bingSite: noneToEmpty(values["bing-site"]), gbpPlaceId: noneToEmpty(values["place-id"]), ahrefsSiteAuditProject: noneToEmpty(values["ahrefs-site-audit-project"]), semrushProject: noneToEmpty(values["semrush-project"]) }, { trustedPlaceId: true });
416
+ const r = await setClientProfile(args[0], { area: values.area, niche: values.niche, name: values.name, brandTerms, dataProvider: provider, country, bingSite: noneToEmpty(values["bing-site"]), gbpPlaceId: noneToEmpty(values["place-id"]), ahrefsSiteAuditProject: noneToEmpty(values["ahrefs-site-audit-project"]) }, { trustedPlaceId: true });
415
417
  for (const [k, v] of Object.entries(r.set))
416
418
  out(`Set ${k}: ${v}`);
417
419
  for (const k of r.cleared)
@@ -421,7 +423,6 @@ async function clientsSet(args, values) {
421
423
  (r.profile.bingSite !== undefined ? `Bing site ${r.profile.bingSite ?? "(matched by domain)"}. ` : "") +
422
424
  (r.profile.gbpPlaceId !== undefined ? `Google Business Profile place ID ${r.profile.gbpPlaceId ?? "(not set)"}. ` : "") +
423
425
  (r.profile.ahrefsSiteAuditProject !== undefined ? `Ahrefs Site Audit project ${r.profile.ahrefsSiteAuditProject ?? "(matched by domain)"}. ` : "") +
424
- (r.profile.semrushProject !== undefined ? `Semrush project ${r.profile.semrushProject ?? "(matched by domain)"}. ` : "") +
425
426
  `File: ${r.clientsFile}`);
426
427
  return 0;
427
428
  }
package/dist/clients.js CHANGED
@@ -479,13 +479,11 @@ opts = {}) {
479
479
  throw new Error(`"${stripControl(update.gbpPlaceId).slice(0, 100)}" is not a Google place ID (letters, digits, - and _, as local_profile returns it in matched.placeId).`);
480
480
  fields.gbpPlaceId = v;
481
481
  }
482
- for (const k of ["ahrefsSiteAuditProject", "semrushProject"]) {
483
- if (update[k] === undefined)
484
- continue;
485
- const v = update[k].trim();
482
+ if (update.ahrefsSiteAuditProject !== undefined) {
483
+ const v = update.ahrefsSiteAuditProject.trim();
486
484
  if (v !== "" && !PROJECT_ID_RE.test(v))
487
- throw new Error(`"${stripControl(update[k]).slice(0, 60)}" isn't ${k === "semrushProject" ? "a Semrush project" : "an Ahrefs Site Audit project"} ID (digits, from the project's URL).`);
488
- fields[k] = v;
485
+ throw new Error(`"${stripControl(update.ahrefsSiteAuditProject).slice(0, 60)}" isn't an Ahrefs Site Audit project ID (digits, from the project's URL).`);
486
+ fields.ahrefsSiteAuditProject = v;
489
487
  }
490
488
  let brandTerms;
491
489
  if (update.brandTerms !== undefined) {
@@ -495,7 +493,7 @@ opts = {}) {
495
493
  throw new Error(`Not a usable brand term: ${bad.map((t) => `"${t}"`).join(", ")}. Terms need a letter or digit and at most ${MAX_BRAND_TERM_LENGTH} characters.`);
496
494
  }
497
495
  if (Object.keys(fields).length === 0 && brandTerms === undefined)
498
- throw new Error("Nothing to set: pass area, niche, name, brandTerms, dataProvider, country, bingSite, gbpPlaceId, ahrefsSiteAuditProject or semrushProject.");
496
+ throw new Error("Nothing to set: pass area, niche, name, brandTerms, dataProvider, country, bingSite, gbpPlaceId or ahrefsSiteAuditProject.");
499
497
  const client = await resolveClient(idOrDomain);
500
498
  if (fields.bingSite && !bingSiteOnDomain(fields.bingSite, client.domain)) {
501
499
  throw new Error(`bingSite ${fields.bingSite.slice(0, 200)} isn't on ${client.domain}: it would show another site's Bing data in this client's reports. Use a Bing site on ${client.domain} or a subdomain of it.`);
@@ -528,7 +526,6 @@ opts = {}) {
528
526
  ...(update.bingSite !== undefined ? { bingSite: updated.bingSite ?? null } : {}),
529
527
  ...(update.gbpPlaceId !== undefined ? { gbpPlaceId: updated.gbpPlaceId ?? null } : {}),
530
528
  ...(update.ahrefsSiteAuditProject !== undefined ? { ahrefsSiteAuditProject: updated.ahrefsSiteAuditProject ?? null } : {}),
531
- ...(update.semrushProject !== undefined ? { semrushProject: updated.semrushProject ?? null } : {}),
532
529
  },
533
530
  clientsFile: env.CLIENTS_FILE,
534
531
  };
package/dist/config.js CHANGED
@@ -71,6 +71,7 @@ const EnvSchema = z.object({
71
71
  // Phase 9: Ahrefs (API v3) and Semrush keys, and monthly unit caps. SEO_DATA_PROVIDER is read
72
72
  // leniently below (not here), so an unknown value never stops the server.
73
73
  AHREFS_API_KEY: z.string().optional(),
74
+ // Semrush's v4 API key (profile > API keys). SEMRUSH_V4_API_KEY is the name 0.1.0 used for the v4 key (there SEMRUSH_API_KEY was the old v3 key), so it wins when both are set.
74
75
  SEMRUSH_API_KEY: z.string().optional(),
75
76
  SEMRUSH_V4_API_KEY: z.string().optional(),
76
77
  AHREFS_MONTHLY_UNIT_CAP: z.coerce.number().int().positive().default(50_000),
@@ -95,7 +96,6 @@ const EnvSchema = z.object({
95
96
  const parsedEnv = EnvSchema.parse(stripEmptyValues(process.env));
96
97
  export const AHREFS_DEFAULT_BASE_URL = "https://api.ahrefs.com/v3";
97
98
  export const SEMRUSH_DEFAULT_BASE_URL = "https://api.semrush.com";
98
- export const SEMRUSH_DEFAULT_BALANCE_URL = "https://www.semrush.com/users/countapiunits.html";
99
99
  export const CRUX_DEFAULT_BASE_URL = "https://chromeuxreport.googleapis.com/v1";
100
100
  /** Bing Webmaster Tools JSON endpoint; methods are appended as /<Method>. */
101
101
  export const BING_DEFAULT_BASE_URL = "https://ssl.bing.com/webmaster/api.svc/json";
@@ -129,9 +129,9 @@ export const env = {
129
129
  GOOGLE_APPLICATION_CREDENTIALS: parsedEnv.GOOGLE_APPLICATION_CREDENTIALS
130
130
  ? resolveFromRoot(HOME.dir, parsedEnv.GOOGLE_APPLICATION_CREDENTIALS)
131
131
  : undefined,
132
+ SEMRUSH_API_KEY: parsedEnv.SEMRUSH_V4_API_KEY || parsedEnv.SEMRUSH_API_KEY || undefined,
132
133
  AHREFS_BASE_URL: urlOverride("AHREFS_BASE_URL", AHREFS_DEFAULT_BASE_URL),
133
134
  SEMRUSH_BASE_URL: urlOverride("SEMRUSH_BASE_URL", SEMRUSH_DEFAULT_BASE_URL),
134
- SEMRUSH_BALANCE_URL: urlOverride("SEMRUSH_BALANCE_URL", SEMRUSH_DEFAULT_BALANCE_URL),
135
135
  CRUX_BASE_URL: urlOverride("CRUX_BASE_URL", CRUX_DEFAULT_BASE_URL),
136
136
  BING_BASE_URL: urlOverride("BING_BASE_URL", BING_DEFAULT_BASE_URL),
137
137
  CRAWLER_IPRANGES_BASE_URL: urlOverride("CRAWLER_IPRANGES_BASE_URL", CRAWLER_IPRANGES_DEFAULT_BASE_URL),
@@ -149,7 +149,6 @@ export const clientDefaults = {
149
149
  export const providerUrlNotice = urlOverrideNotice([
150
150
  { name: "AHREFS_BASE_URL", value: env.AHREFS_BASE_URL, fallback: AHREFS_DEFAULT_BASE_URL },
151
151
  { name: "SEMRUSH_BASE_URL", value: env.SEMRUSH_BASE_URL, fallback: SEMRUSH_DEFAULT_BASE_URL },
152
- { name: "SEMRUSH_BALANCE_URL", value: env.SEMRUSH_BALANCE_URL, fallback: SEMRUSH_DEFAULT_BALANCE_URL },
153
152
  { name: "CRUX_BASE_URL", value: env.CRUX_BASE_URL, fallback: CRUX_DEFAULT_BASE_URL },
154
153
  { name: "BING_BASE_URL", value: env.BING_BASE_URL, fallback: BING_DEFAULT_BASE_URL },
155
154
  { name: "CRAWLER_IPRANGES_BASE_URL", value: env.CRAWLER_IPRANGES_BASE_URL, fallback: CRAWLER_IPRANGES_DEFAULT_BASE_URL },
@@ -288,7 +288,7 @@ export function normalizeArea(input) {
288
288
  // ---------------------------------------------------------------- clients.yaml overrides
289
289
  /** A Google place ID (e.g. ChIJexamplePlaceId000001): letters, digits, - and _ only. Phase 12 C4. */
290
290
  export const PLACE_ID_RE = /^[A-Za-z0-9_-]{10,300}$/;
291
- /** Phase 13 B: Ahrefs and Semrush project IDs are digits. */
291
+ /** Phase 13 B: Ahrefs project IDs are digits. */
292
292
  export const PROJECT_ID_RE = /^\d{1,20}$/;
293
293
  const OverrideSchema = z.object({
294
294
  domain: z.string().min(1),
@@ -334,8 +334,8 @@ const OverrideSchema = z.object({
334
334
  gbpPlaceId: z.string().regex(PLACE_ID_RE, { error: "must be a Google place ID (letters, digits, - and _), e.g. ChIJexamplePlaceId000001" }).optional(),
335
335
  /** Phase 13 B: the client's existing Ahrefs Site Audit project (its ID from app.ahrefs.com/site-audit/<id>); wins over matching by domain. */
336
336
  ahrefsSiteAuditProject: z.coerce.string().regex(PROJECT_ID_RE, { error: "must be an Ahrefs Site Audit project ID (digits)" }).optional(),
337
- /** Phase 13 B: the client's existing Semrush project with Site Audit (its ID); wins over matching by domain. */
338
- semrushProject: z.coerce.string().regex(PROJECT_ID_RE, { error: "must be a Semrush project ID (digits)" }).optional(),
337
+ /** Deprecated and ignored: Semrush Site Audit needed Semrush's v3 API, which crawlmark no longer supports. Accepted so an existing file still loads. */
338
+ semrushProject: z.coerce.string().optional(),
339
339
  /** Hide this domain: it isn't a client, and discovery never re-adds it. */
340
340
  ignore: z.boolean().optional(),
341
341
  });
@@ -346,6 +346,8 @@ const OverridesFileSchema = z
346
346
  * Validate parsed clients.yaml content. An empty file or empty `clients:` is
347
347
  * fine. Throws one readable error naming the bad entries.
348
348
  */
349
+ let semrushProjectNoted = false;
350
+ export const SEMRUSH_PROJECT_NOTE = "crawlmark: clients.yaml `semrushProject` is no longer used (Semrush Site Audit needs Semrush's v3 API, which crawlmark no longer supports); you can remove it.";
349
351
  export function parseOverrides(raw, source = "clients.yaml") {
350
352
  const parsed = OverridesFileSchema.safeParse(raw);
351
353
  if (!parsed.success) {
@@ -380,7 +382,13 @@ export function parseOverrides(raw, source = "clients.yaml") {
380
382
  if (!normalizeBrandTerm(brandTermText(t)))
381
383
  throw new Error(`Invalid ${source}: clients.${slug}.brandTerms[${i}] must contain a letter or digit and be at most 100 characters.`);
382
384
  });
383
- return { ...entry, domain: host, slug };
385
+ // The deprecated key is accepted and dropped; one stderr note per process says so.
386
+ const { semrushProject, ...rest } = entry;
387
+ if (semrushProject !== undefined && !semrushProjectNoted) {
388
+ semrushProjectNoted = true;
389
+ process.stderr.write(`${SEMRUSH_PROJECT_NOTE}\n`);
390
+ }
391
+ return { ...rest, domain: host, slug };
384
392
  });
385
393
  }
386
394
  /** DEFAULT_CLIENT_MONTHLY_AHREFS_UNITS / _SEMRUSH_UNITS defaults (D1). */
@@ -460,8 +468,6 @@ export function mergeClients(discovered, overrides, defaults, gtm = new Map()) {
460
468
  client.gbpPlaceId = y.gbpPlaceId;
461
469
  if (y?.ahrefsSiteAuditProject)
462
470
  client.ahrefsSiteAuditProject = y.ahrefsSiteAuditProject;
463
- if (y?.semrushProject)
464
- client.semrushProject = y.semrushProject;
465
471
  if (d?.ga4DisplayName && d.ga4DisplayName !== client.name)
466
472
  client.ga4Name = d.ga4DisplayName;
467
473
  return client;
@@ -49,17 +49,6 @@ export function resolveCountry(country, locationCode) {
49
49
  export function ahrefsCountry(cc) {
50
50
  return cc.trim().toLowerCase();
51
51
  }
52
- /**
53
- * The documented Semrush desktop databases, copied from docs/research/semrush-api.md
54
- * "Databases" (lowercase; the United Kingdom is `uk`). Mobile and `-ext` databases are never used.
55
- */
56
- export const SEMRUSH_DATABASES = new Set(("us uk ca ru de fr es it br au ar be ch dk fi hk ie il mx nl no pl se sg tr jp in hu af al dz ao am at az bh bd by bz bo ba bw bn bg cv kh cm cl co cr hr cy cz cd do ec eg sv ee et ge gh gr gt gy ht hn is id jm jo kz kw lv lb lt lu mg my mt mu md mn me ma mz na np nz ni ng om py pe ph pt ro sa sn rs sk si za kr lk th bs tt tn ua ae uy ve vn zm zw ly pa pk tw qa").split(" "));
57
- /** Semrush `database` for a country: lowercase, GB -> `uk`; null when it isn't a documented desktop database. */
58
- export function semrushDatabase(cc) {
59
- const lower = cc.trim().toLowerCase();
60
- const db = lower === "gb" ? "uk" : lower;
61
- return SEMRUSH_DATABASES.has(db) ? db : null;
62
- }
63
52
  /** The message when a client has no country Ahrefs/Semrush can use (spec 9.1). */
64
53
  export function noCountryMessage(client) {
65
54
  return (`Set country: XX for ${client.domain} in clients.yaml (or clients set ${client.domain} --country XX); ` +
@@ -54,7 +54,7 @@ export function formatEnvValue(value) {
54
54
  * line it appears on (keeping `export` and spacing), so it never ends up with two
55
55
  * different values; a new key is appended. Returns the new text and what changed.
56
56
  */
57
- export function setEnvValues(text, updates) {
57
+ export function setEnvValues(text, updates, remove = []) {
58
58
  for (const k of Object.keys(updates))
59
59
  if (!KEY_RE.test(k))
60
60
  throw new Error(`Invalid env key: ${k}`);
@@ -64,6 +64,15 @@ export function setEnvValues(text, updates) {
64
64
  const trailing = lines.length > 0 && lines[lines.length - 1] === "";
65
65
  if (trailing)
66
66
  lines.pop();
67
+ // Keys to delete (every line they appear on), e.g. a name an older version wrote.
68
+ const removed = new Set();
69
+ for (let i = lines.length - 1; i >= 0 && remove.length; i--) {
70
+ const m = LINE_RE.exec(lines[i]);
71
+ if (m && remove.includes(m[2])) {
72
+ removed.add(m[2]);
73
+ lines.splice(i, 1);
74
+ }
75
+ }
67
76
  const seen = new Set();
68
77
  const updated = new Set();
69
78
  for (let i = 0; i < lines.length; i++) {
@@ -87,7 +96,7 @@ export function setEnvValues(text, updates) {
87
96
  lines.push(`${key}=${formatEnvValue(value)}`);
88
97
  added.push(key);
89
98
  }
90
- return { text: lines.length ? lines.join(eol) + eol : "", updated: [...updated], added };
99
+ return { text: lines.length ? lines.join(eol) + eol : "", updated: [...updated], added, removed: [...removed] };
91
100
  }
92
101
  export function readEnvFile(path) {
93
102
  return existsSync(path) ? parseEnvText(readFileSync(path, "utf8")) : {};
@@ -96,13 +105,13 @@ export function readEnvFile(path) {
96
105
  * Update keys in a `.env` file: atomic (temp file, then rename), mode 0600,
97
106
  * parent directory created with 0700, symlinks followed. Returns which keys were updated or added.
98
107
  */
99
- export function writeEnvFile(file, updates) {
108
+ export function writeEnvFile(file, updates, remove = []) {
100
109
  // Follow a symlinked .env so the link survives the rename.
101
110
  const path = existsSync(file) ? realpathSync(file) : file;
102
111
  const before = existsSync(path) ? readFileSync(path, "utf8") : "";
103
- const r = setEnvValues(before, updates);
112
+ const r = setEnvValues(before, updates, remove);
104
113
  mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
105
114
  writeFileAtomic(path, r.text, 0o600);
106
- return { updated: r.updated, added: r.added };
115
+ return { updated: r.updated, added: r.added, removed: r.removed };
107
116
  }
108
117
  //# sourceMappingURL=env-file.js.map
@@ -1,7 +1,5 @@
1
1
  import { db } from "./store.js";
2
2
  export const ISSUE_SNAPSHOTS_KEPT = 24;
3
- /** Semrush's API terms: nothing from Semrush is kept longer than this (the Semrush Site Audit items in a snapshot). */
4
- export const SEMRUSH_ISSUE_RETENTION_DAYS = 31;
5
3
  const DAY_MS = 86_400_000;
6
4
  /**
7
5
  * Remove one source's items and source runs from stored issue snapshots (all of them, one client's, or only those
@@ -31,8 +29,6 @@ export function stripSourceFromIssueSnapshots(source, o = {}) {
31
29
  }
32
30
  return changed;
33
31
  }
34
- /** Semrush Site Audit items older than 31 days are removed (on every save and every read). */
35
- export const pruneSemrushIssueSnapshots = (now) => stripSourceFromIssueSnapshots("semrush_site_audit", { beforeMs: now - SEMRUSH_ISSUE_RETENTION_DAYS * DAY_MS });
36
32
  /** The newest snapshot of each calendar month is also kept for this many months. */
37
33
  export const MONTHLY_KEPT = 25;
38
34
  /** The first millisecond of the month `back` months before the month of `ms` (UTC). */
@@ -51,7 +47,6 @@ const fromRow = (r) => ({
51
47
  export function saveIssueSnapshot(s, now = Math.max(Date.now(), s.capturedAt)) {
52
48
  db.exec("BEGIN");
53
49
  try {
54
- pruneSemrushIssueSnapshots(now);
55
50
  db.prepare("INSERT INTO issue_snapshots (id, client, captured_at, items, checked, settings) VALUES (?, ?, ?, ?, ?, ?)").run(s.id, s.client, s.capturedAt, JSON.stringify(s.items), JSON.stringify(s.checked), JSON.stringify(s.settings));
56
51
  // Kept: the newest ISSUE_SNAPSHOTS_KEPT, plus the newest of each calendar month (UTC) for MONTHLY_KEPT months, so a
57
52
  // report's base (the snapshot from the previous report) can't be pruned by frequent tool calls.
@@ -74,7 +69,6 @@ export function saveIssueSnapshot(s, now = Math.max(Date.now(), s.capturedAt)) {
74
69
  }
75
70
  /** The client's newest snapshot captured strictly before `beforeMs`, leaving out `excludeIds` (the current run's own). */
76
71
  export function latestIssueSnapshotBefore(client, beforeMs, excludeIds = [], now = Date.now()) {
77
- pruneSemrushIssueSnapshots(now);
78
72
  const ex = excludeIds.length ? ` AND id NOT IN (${excludeIds.map(() => "?").join(", ")})` : "";
79
73
  const row = db.prepare(`SELECT * FROM issue_snapshots WHERE client = ? AND captured_at < ?${ex} ORDER BY captured_at DESC, id DESC LIMIT 1`).get(client, beforeMs, ...excludeIds);
80
74
  return row ? fromRow(row) : undefined;