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.
@@ -29,7 +29,8 @@ const DFS_ONLY = ["serpLive", "onPageInstant", "siteCrawl", "localProfile", "loc
29
29
  export const CAPABILITIES = {
30
30
  dataforseo: new Set([...ROUTED_CAPABILITIES, ...DFS_ONLY]),
31
31
  ahrefs: new Set(ROUTED_CAPABILITIES),
32
- semrush: new Set(ROUTED_CAPABILITIES),
32
+ /** Semrush's v4 API serves keyword volume and backlinks only; the rest routes to DataForSEO or is unavailable. */
33
+ semrush: new Set(["keywordVolume", "backlinksSummary"]),
33
34
  };
34
35
  const ONLY_LABEL = {
35
36
  serpLive: "Live SERP checks",
@@ -40,6 +41,15 @@ const ONLY_LABEL = {
40
41
  localReviews: "Google reviews",
41
42
  aiVisibility: "AI-search visibility checks",
42
43
  };
44
+ /** What a Semrush-routed client is told is missing, in plain words. */
45
+ const SEMRUSH_LACKS = {
46
+ rankedKeywords: "ranked keywords",
47
+ competitorDomains: "competitor domains",
48
+ keywordIdeas: "keyword ideas",
49
+ keywordGap: "keyword gaps",
50
+ organicOverview: "organic visibility",
51
+ competitorOverview: "the competitor overview",
52
+ };
43
53
  const KEY_VAR = { dataforseo: "DATAFORSEO_LOGIN / DATAFORSEO_PASSWORD", ahrefs: "AHREFS_API_KEY", semrush: "SEMRUSH_API_KEY" };
44
54
  /** Today's wording when DataForSEO has no login (rule 6). */
45
55
  export const DFS_NOT_SET = "DATAFORSEO_LOGIN / DATAFORSEO_PASSWORD are not set. See .env.example.";
@@ -69,6 +79,8 @@ export function resolveProvider(client, capability, e) {
69
79
  if (!CAPABILITIES[chosen].has(capability)) {
70
80
  if (e.configured.dataforseo)
71
81
  return { provider: "dataforseo", fallbackFrom: chosen };
82
+ if (chosen === "semrush" && !isDfsOnly(capability))
83
+ return { unavailable: `Semrush's API (v4) doesn't include ${SEMRUSH_LACKS[capability] ?? capability}. Use DataForSEO or Ahrefs for it.` };
72
84
  const what = isDfsOnly(capability) ? ONLY_LABEL[capability] : capability;
73
85
  return { unavailable: `${what} need DataForSEO; ${chosen} (the provider for ${client.slug}) doesn't offer them. Run setup --step dataforseo.` };
74
86
  }
@@ -11,7 +11,7 @@ import { spendKeys } from "../lib/spend.js";
11
11
  import { stripControl } from "../lib/terminal-text.js";
12
12
  /**
13
13
  * Untrusted vendor text made safe to show or throw: every secret (and any `key=...`
14
- * query parameter, since Semrush keys travel in the URL) replaced, control characters
14
+ * query parameter, since Ahrefs and older Semrush keys can travel in a URL) replaced, control characters
15
15
  * stripped, whitespace collapsed and the length capped.
16
16
  */
17
17
  export function redact(text, secrets, max = 300) {
@@ -176,7 +176,6 @@ export const ISSUE_SOURCE_NAMES = {
176
176
  dataforseo_crawl: "DataForSEO crawl",
177
177
  gsc_inspection: "Google URL Inspection",
178
178
  ahrefs_site_audit: "Ahrefs Site Audit",
179
- semrush_site_audit: "Semrush Site Audit",
180
179
  };
181
180
  const SEVERITY_TITLE = { critical: "Critical", high: "High", medium: "Medium", low: "Low" };
182
181
  const CHANGE_TAG = {
@@ -941,6 +940,8 @@ export const SECTION_MAPPERS = {
941
940
  if (data.resolved.length) {
942
941
  blocks.push({ kind: "table", title: "Resolved since the last report", header: ["Issue", "Example", "Found by"], rows: data.resolved.map((i) => [d(i.title), issueExample(i, ctx.domain), foundBy(i)]) });
943
942
  }
943
+ for (const n of data.notes ?? [])
944
+ blocks.push({ kind: "note", text: [t(n)] });
944
945
  if (data.noLongerChecked)
945
946
  ctx.note("Technical SEO issues: some issues from earlier reports are no longer checked by any current source, so they are left out (not counted as fixed).");
946
947
  const from = data.sources.map((x) => `${ISSUE_SOURCE_NAMES[x.source]} (${fullDay(x.ranAt)}${x.stale ? ", older data" : ""})`).join(", ");
@@ -45,11 +45,7 @@ export async function loadTechIssuesDeps() {
45
45
  },
46
46
  latestIssueSnapshotBefore: snaps.latestIssueSnapshotBefore,
47
47
  saveIssueSnapshot: snaps.saveIssueSnapshot,
48
- siteAudit: async (c, source, o) => {
49
- if (source === "ahrefs_site_audit")
50
- return (await import("../providers/ahrefs-site-audit.js")).readAhrefsSiteAudit(c, o);
51
- return (await import("../providers/semrush-site-audit.js")).readSemrushSiteAudit(c, o);
52
- },
48
+ siteAudit: async (c, _source, o) => (await import("../providers/ahrefs-site-audit.js")).readAhrefsSiteAudit(c, o),
53
49
  markProviderData: (p) => {
54
50
  void import("../lib/provider-data.js").then((m) => m.markProviderData(p));
55
51
  },
@@ -175,7 +171,7 @@ export async function technicalIssues(client, opts, deps) {
175
171
  }
176
172
  }
177
173
  for (const s of sources.filter((x) => PAID_ISSUE_SOURCES.has(x))) {
178
- const label = s === "ahrefs_site_audit" ? "Ahrefs" : "Semrush";
174
+ const label = "Ahrefs";
179
175
  if (!opts.allowPaid) {
180
176
  unavailable.push({ source: s, reason: "paid source: needs allowPaid (it spends provider units)" });
181
177
  continue;
@@ -188,7 +184,7 @@ export async function technicalIssues(client, opts, deps) {
188
184
  const r = await deps.siteAudit(client, s, { issueUrls: opts.issueUrls ?? 0 });
189
185
  raws.push(...r.issues);
190
186
  runs.push({ ...r.run, stale: stale(Date.parse(r.run.ranAt)) });
191
- deps.markProviderData?.(s === "ahrefs_site_audit" ? "ahrefs" : "semrush");
187
+ deps.markProviderData?.("ahrefs");
192
188
  }
193
189
  catch (err) {
194
190
  // crawlmark's own wording only (a missing key or project names the setting); never the provider's error text.
@@ -230,10 +226,6 @@ export async function planPaidIssueSources(client, sources, issueUrls) {
230
226
  const { planAhrefsSiteAudit } = await import("../providers/ahrefs-site-audit.js");
231
227
  out.push({ source: "ahrefs_site_audit", provider: "ahrefs", ...planAhrefsSiteAudit(issueUrls) });
232
228
  }
233
- if (sources.includes("semrush_site_audit")) {
234
- const { estimateSemrushSiteAudit } = await import("../providers/semrush-site-audit.js");
235
- out.push({ source: "semrush_site_audit", provider: "semrush", ...(await estimateSemrushSiteAudit(client, issueUrls)) });
236
- }
237
229
  return out;
238
230
  }
239
231
  //# sourceMappingURL=technical-issues.js.map
@@ -12,7 +12,7 @@ import { brandedSplitFor, compare, liveRanksAvailability, movers, organicTraffic
12
12
  import { aiReadinessFrom } from "../ai-readiness.js";
13
13
  import { loadTemplates, resolveTemplate, templateFileWarnings } from "./load.js";
14
14
  import { resolvePeriod } from "./period.js";
15
- import { isPaidSection, SECTION_TITLES } from "./schema.js";
15
+ import { isPaidSection, PAID_ISSUE_SOURCES, REMOVED_ISSUE_SOURCE, REMOVED_ISSUE_SOURCE_NOTE, SECTION_TITLES } from "./schema.js";
16
16
  import { analyzeCrawl, topFindings } from "../../analysis/crawl.js";
17
17
  import { wholeQuarter } from "./period.js";
18
18
  /** At most this many stored points per keyword and source go into a report (evenly thinned). */
@@ -529,12 +529,16 @@ export async function runTemplate(client, opts, deps) {
529
529
  // Phase 13 A: fetchers land in A-4 (technical_issues) and A-5 (indexing_issues).
530
530
  // Phase 13 A: stored sources only (never starts a crawl).
531
531
  technical_issues: async (s) => {
532
+ // A source removed since 0.1.0 is dropped, with a note in the section.
533
+ const removed = s.sources?.includes(REMOVED_ISSUE_SOURCE) === true;
534
+ const kept = s.sources?.filter((x) => x !== REMOVED_ISSUE_SOURCE);
532
535
  const r = await deps.technicalIssues(client, {
533
- sources: s.sources,
536
+ sources: kept?.length ? kept : undefined,
534
537
  minSeverity: s.minSeverity,
535
538
  limit: s.limit ?? 25,
536
539
  maxAgeDays: s.maxAgeDays,
537
- issueUrls: s.issueUrls,
540
+ // No paid source left after dropping the removed one: nothing to fetch example URLs from.
541
+ issueUrls: kept?.some((x) => PAID_ISSUE_SOURCES.has(x)) ? s.issueUrls : removed ? 0 : s.issueUrls,
538
542
  allowPaid,
539
543
  // The base is the newest snapshot from before the period ended (for a September report run on 2 October, the
540
544
  // one the August report took about 2 September), never one this run made.
@@ -546,7 +550,7 @@ export async function runTemplate(client, opts, deps) {
546
550
  if (!r.sources.length)
547
551
  throw new SectionUnavailableError(`no saved audit, crawl or URL Inspection results for ${client.domain} yet (run run_audit or crawlmark-cli crawl, free)`);
548
552
  const { snapshotId: _id, ...data } = r;
549
- return data;
553
+ return removed ? { ...data, notes: [REMOVED_ISSUE_SOURCE_NOTE] } : data;
550
554
  },
551
555
  // Phase 13 A: a sample, labelled as one in the file; never Search Console's full Page indexing report.
552
556
  indexing_issues: async (s) => {
@@ -72,12 +72,15 @@ const common = {
72
72
  paid: z.boolean().optional(),
73
73
  };
74
74
  const limit = (max) => z.number().int().min(1).max(max).optional();
75
- /** technical_issues sources (spec 13.6.1). The two provider site audits are paid and arrive in Stage B. */
76
- export const ISSUE_SOURCES = ["audit", "local_crawl", "site_hygiene", "dataforseo_crawl", "gsc_inspection", "ahrefs_site_audit", "semrush_site_audit"];
75
+ /** technical_issues sources (spec 13.6.1). The Ahrefs Site Audit is paid. */
76
+ /** A source 0.1.0 templates may still name. It validates (with a warning) and is dropped when the section runs. */
77
+ export const REMOVED_ISSUE_SOURCE = "semrush_site_audit";
78
+ export const REMOVED_ISSUE_SOURCE_NOTE = "Semrush Site Audit is no longer available (Semrush's v4 API doesn't include it).";
79
+ export const ISSUE_SOURCES = ["audit", "local_crawl", "site_hygiene", "dataforseo_crawl", "gsc_inspection", "ahrefs_site_audit"];
77
80
  /** The free sources: the default when a technical_issues section names none. */
78
81
  export const FREE_ISSUE_SOURCES = ["audit", "local_crawl", "site_hygiene", "dataforseo_crawl", "gsc_inspection"];
79
- /** Sources that spend Ahrefs or Semrush units. */
80
- export const PAID_ISSUE_SOURCES = new Set(["ahrefs_site_audit", "semrush_site_audit"]);
82
+ /** Sources that spend Ahrefs units. */
83
+ export const PAID_ISSUE_SOURCES = new Set(["ahrefs_site_audit"]);
81
84
  const sectionSchemas = [
82
85
  z.strictObject({ ...common, type: z.literal("search_overview"), metrics: z.array(z.enum(["clicks", "impressions", "ctr", "position"])).min(1).optional() }),
83
86
  z.strictObject({ ...common, type: z.literal("branded_split") }),
@@ -119,13 +122,13 @@ const sectionSchemas = [
119
122
  z.strictObject({ ...common, type: z.literal("ai_visibility"), mode: z.enum(["mentions", "metrics"]).optional(), platform: z.enum(["google", "chat_gpt"]).optional(), limit: limit(50) }),
120
123
  // llms.txt and AI crawler access from the newest saved audit (free, local; never fetches).
121
124
  z.strictObject({ ...common, type: z.literal("ai_readiness") }),
122
- // Phase 13 A: one deduplicated technical-issues list from stored results (free sources; Ahrefs/Semrush Site Audit are paid, Stage B).
125
+ // Phase 13 A: one deduplicated technical-issues list from stored results (free sources; the Ahrefs Site Audit is paid, Stage B).
123
126
  z.strictObject({
124
127
  ...common,
125
128
  type: z.literal("technical_issues"),
126
129
  limit: limit(200),
127
130
  minSeverity: z.enum(["critical", "high", "medium", "low"]).optional(),
128
- sources: z.array(z.enum(ISSUE_SOURCES)).min(1).max(ISSUE_SOURCES.length).optional(),
131
+ sources: z.array(z.enum([...ISSUE_SOURCES, REMOVED_ISSUE_SOURCE])).min(1).max(ISSUE_SOURCES.length + 1).optional(),
129
132
  issueUrls: z.number().int().min(0).max(5).optional(),
130
133
  maxAgeDays: z.number().int().min(7).max(120).optional(),
131
134
  }),
@@ -203,7 +206,7 @@ export const SECTION_CATALOGUE = {
203
206
  local_profile: { group: "local", cost: "paid", needs: "DataForSEO and a confirmed Google Business Profile", summary: "The Google Business Profile and map-pack positions." },
204
207
  ai_visibility: { group: "ai", cost: "paid", needs: "DataForSEO with AI_VISIBILITY_ENABLED=true", summary: "AI answers (Google AI Overviews, ChatGPT) that cite the site." },
205
208
  ai_readiness: { group: "ai", cost: "free", needs: "a saved audit", summary: "llms.txt and which AI crawlers robots.txt lets in." },
206
- technical_issues: { group: "site", cost: "paid_optional", needs: "a saved audit or crawl (Ahrefs / Semrush Site Audit sources are paid)", summary: "One deduplicated, severity-grouped list of technical problems from every stored source, with what changed since the last report." },
209
+ technical_issues: { group: "site", cost: "paid_optional", needs: "a saved audit or crawl (the Ahrefs Site Audit source is paid)", summary: "One deduplicated, severity-grouped list of technical problems from every stored source, with what changed since the last report." },
207
210
  indexing_issues: { group: "site", cost: "free", needs: "Google Search Console (uses the daily URL Inspection quota)", summary: "Google's verdict for key pages, top pages and a rotating sample of sitemap URLs, plus sitemap errors." },
208
211
  backlinks: { group: "seo_data", cost: "paid", needs: "the client's SEO data provider (DataForSEO, Ahrefs or Semrush)", summary: "Referring domains, backlinks and the provider's authority metric, with the change since last month from stored snapshots." },
209
212
  organic_visibility: { group: "seo_data", cost: "paid", needs: "the client's SEO data provider (DataForSEO, Ahrefs or Semrush)", summary: "Ranking keywords by position band and estimated organic traffic, with the trend from stored snapshots." },
@@ -303,14 +306,16 @@ export function semanticIssues(file, today) {
303
306
  push([...at, "paid"], s.type === "tracked_keywords"
304
307
  ? "liveRanks: true is a paid live SERP check, so the section must set paid: true"
305
308
  : s.type === "technical_issues"
306
- ? "the ahrefs_site_audit and semrush_site_audit sources spend provider units, so the section must set paid: true"
309
+ ? "the ahrefs_site_audit source spends Ahrefs units, so the section must set paid: true"
307
310
  : `${s.type} is paid, so the section must set paid: true`);
308
311
  }
309
312
  if (s.type === "technical_issues") {
310
313
  if (s.sources && new Set(s.sources).size !== s.sources.length)
311
314
  push([...at, "sources"], "entries must be unique");
312
- if ((s.issueUrls ?? 0) > 0 && !(s.sources ?? []).some((x) => PAID_ISSUE_SOURCES.has(x)))
313
- push([...at, "issueUrls"], "issueUrls needs an ahrefs_site_audit or semrush_site_audit source (it fetches example URLs from the provider)");
315
+ // A 0.1.0 template pairing issueUrls with the removed semrush_site_audit source still loads (warned about in validateTemplateText).
316
+ const hasRemoved = s.sources?.includes(REMOVED_ISSUE_SOURCE) === true;
317
+ if ((s.issueUrls ?? 0) > 0 && !hasRemoved && !(s.sources ?? []).some((x) => PAID_ISSUE_SOURCES.has(x)))
318
+ push([...at, "issueUrls"], "issueUrls needs the ahrefs_site_audit source (it fetches example URLs from the provider)");
314
319
  }
315
320
  if (s.type === "core_web_vitals_history") {
316
321
  if (s.scope === "url" && !s.url)
@@ -379,6 +384,11 @@ export function validateTemplateText(text, opts = {}) {
379
384
  if (logo && opts.logoExists && !opts.logoExists(logo)) {
380
385
  warnings.push({ path: "branding.logo", message: `${logo} doesn't exist; reports are rendered without a logo until it does` });
381
386
  }
387
+ (parsed.data.templates ? Object.entries(parsed.data.templates) : []).forEach(([id, t]) => t.sections.forEach((s, i) => {
388
+ if (s.type === "technical_issues" && s.sources?.includes(REMOVED_ISSUE_SOURCE)) {
389
+ warnings.push({ path: `templates.${id}.sections[${i}].sources`, message: `${REMOVED_ISSUE_SOURCE}: ${REMOVED_ISSUE_SOURCE_NOTE} It is skipped when the report runs; you can remove it.${(s.issueUrls ?? 0) > 0 && !(s.sources ?? []).some((x) => PAID_ISSUE_SOURCES.has(x)) ? " issueUrls has no source left to fetch example URLs from, so it is ignored." : ""}` });
390
+ }
391
+ }));
382
392
  return errors.length ? { valid: false, errors, warnings } : { valid: true, errors: [], warnings, value: parsed.data };
383
393
  }
384
394
  //# sourceMappingURL=schema.js.map
@@ -12,13 +12,13 @@ import { createHash } from "node:crypto";
12
12
  import { isDeepStrictEqual } from "node:util";
13
13
  import { chmodSync, existsSync, lstatSync, mkdirSync } from "node:fs";
14
14
  import { dirname, join, relative, resolve, sep, isAbsolute } from "node:path";
15
- import { Document, isMap, isScalar, isSeq, parseDocument, YAMLMap } from "yaml";
15
+ import { Document, isMap, isNode, isScalar, isSeq, parseDocument, YAMLMap } from "yaml";
16
16
  import { RegularFileError, readRegularFileCapped } from "../../lib/read-regular-file.js";
17
17
  import { writeFileAtomic } from "../../lib/atomic-file.js";
18
18
  import { clientTemplatesFile, globalTemplatesFile } from "../paths.js";
19
19
  import { BUILTIN_TEMPLATES } from "./builtin.js";
20
20
  import { readTemplateFile, resolveLogo } from "./load.js";
21
- import { ID_RE, isPaidSection, MAX_TEMPLATE_BYTES, SectionSchema, validateTemplateText, } from "./schema.js";
21
+ import { ID_RE, isPaidSection, MAX_TEMPLATE_BYTES, REMOVED_ISSUE_SOURCE, REMOVED_ISSUE_SOURCE_NOTE, SectionSchema, validateTemplateText, } from "./schema.js";
22
22
  /** The proposal is invalid: nothing can be written. `errors` carry YAML paths in the target file. */
23
23
  export class TemplateWriteError extends Error {
24
24
  errors;
@@ -152,6 +152,14 @@ export function planTemplateWrite(req) {
152
152
  const templates = templatesMap(doc);
153
153
  const existing = templates.get(req.id, true);
154
154
  const inFile = existing !== undefined;
155
+ // Sources the saved template already names (by section id), so an update that leaves one alone isn't refused.
156
+ const priorRemoved = new Set();
157
+ if (isNode(existing)) {
158
+ const prior = existing.toJSON();
159
+ for (const s of prior?.sections ?? [])
160
+ if (s?.id && s.sources?.includes(REMOVED_ISSUE_SOURCE))
161
+ priorRemoved.add(s.id);
162
+ }
155
163
  const isBuiltin = Object.hasOwn(BUILTIN_TEMPLATES, req.id);
156
164
  // A client template with the same id fully replaces the global one for that client (load.ts), so say so.
157
165
  const globalIds = req.file === "client" ? globalTemplateIds(req.homeDir, req.today) : { ids: [] };
@@ -209,6 +217,11 @@ export function planTemplateWrite(req) {
209
217
  if (!result.valid)
210
218
  throw new TemplateWriteError(`The template isn't valid: ${result.errors.map((e) => `${e.path || "(file)"}: ${e.message}`).join("; ")}`, result.errors);
211
219
  const template = result.value.templates[req.id];
220
+ for (const [i, s] of template.sections.entries()) {
221
+ if (s.type === "technical_issues" && s.sources?.includes(REMOVED_ISSUE_SOURCE) && !priorRemoved.has(s.id)) {
222
+ throw new TemplateWriteError(`${REMOVED_ISSUE_SOURCE_NOTE} It can't be added to a template.`, [{ path: `templates.${req.id}.sections[${i}].sources`, message: `${REMOVED_ISSUE_SOURCE}: ${REMOVED_ISSUE_SOURCE_NOTE}` }]);
223
+ }
224
+ }
212
225
  const block = new Document({ [req.id]: template });
213
226
  styleTemplateNode(block.contents.get(req.id, true));
214
227
  return {
@@ -108,7 +108,7 @@ export async function detectApps(ctx) {
108
108
  * Run an app's CLI to change its registration. Plain mode gives it the terminal,
109
109
  * as before; fancy mode captures its output and prints it inside the frame.
110
110
  */
111
- async function runApp(ctx, cmd, args) {
111
+ export async function runApp(ctx, cmd, args) {
112
112
  if (!uiFor(ctx).fancy)
113
113
  return ctx.exec(cmd, args, { inherit: true });
114
114
  const r = await ctx.exec(cmd, args);
@@ -12,6 +12,7 @@ export const MENU_CHOICES = [
12
12
  { value: "provider", label: "Add or change a data provider" },
13
13
  { value: "google", label: "Change Google sign-in" },
14
14
  { value: "register", label: "Re-register AI apps" },
15
+ { value: "vendor-mcp", label: "Add Semrush's or Ahrefs' own MCP server" },
15
16
  { value: "discovery", label: "Refresh clients" },
16
17
  { value: "branding", label: "Set up report branding" },
17
18
  { value: "status", label: "Check status" },
@@ -20,7 +21,7 @@ export const MENU_CHOICES = [
20
21
  ];
21
22
  /** The --step a menu action runs, if it is one. */
22
23
  export function menuStep(action) {
23
- return action === "provider" || action === "google" || action === "register" || action === "discovery" || action === "branding" ? action : undefined;
24
+ return action === "provider" || action === "google" || action === "register" || action === "vendor-mcp" || action === "discovery" || action === "branding" ? action : undefined;
24
25
  }
25
26
  /** What the menu's status read asks for. */
26
27
  export const MENU_STATUS_IDS = ["dataforseo", "ahrefs", "semrush", "google", "clients"];
@@ -5,7 +5,6 @@ import { loadOverrides } from "../clients.js";
5
5
  import { ENV_FILE, ENV_FILE_KEYS, env, HOME, PROJECT_ROOT, seoDefaultProvider } from "../config.js";
6
6
  import { globalUnitCap, unitsThisMonth } from "../lib/budget.js";
7
7
  import { checkAhrefsKey } from "../providers/ahrefs.js";
8
- import { checkSemrushKey } from "../providers/semrush.js";
9
8
  import { checkCruxKey, cruxKey } from "../providers/crux.js";
10
9
  import { bingKey, checkBingKey } from "../providers/bing.js";
11
10
  import { remainingAiUsd } from "../lib/budget.js";
@@ -121,9 +120,8 @@ export const realStatusProviders = {
121
120
  : { default: null, source: ENV_FILE_KEYS.has("SEO_DATA_PROVIDER") ? "envFile" : "env", invalidValue: d.invalidValue, perClientOverrides };
122
121
  },
123
122
  semrush: async () => ({
124
- check: await checkSemrushKey(env.SEMRUSH_API_KEY),
123
+ keySet: Boolean(env.SEMRUSH_API_KEY),
125
124
  chosen: providerChoice("semrush"),
126
- v4KeySet: Boolean(env.SEMRUSH_V4_API_KEY),
127
125
  unitsUsedThisMonth: unitsThisMonth(undefined, "semrush"),
128
126
  cap: globalUnitCap("semrush"),
129
127
  }),
@@ -150,40 +150,34 @@ export function ahrefsRow(a, next) {
150
150
  };
151
151
  }
152
152
  /**
153
- * The `semrush` row (spec 9.6): missing (no key, nobody uses it), error (used but no key,
154
- * or rejected), configured (the balance check couldn't be reached, the balance is 0, or a
155
- * v4 key is set but untested: it has no free check), working. Never a key.
153
+ * The `semrush` row: missing (no key, nobody uses it), error (used but no key), configured (key set; Semrush has no
154
+ * free key check, so it is untested until the first paid call). Never a key, never a request.
156
155
  */
157
156
  export function semrushRow(s, next) {
158
- const { check, chosen } = s;
157
+ const { chosen } = s;
159
158
  const used = chosen.byDefault || chosen.clients > 0;
160
159
  const base = { id: "semrush", label: "Semrush" };
161
160
  const data = {
162
161
  default: chosen.byDefault,
163
162
  clients: chosen.clients,
164
- keySet: check.configured,
165
- v4KeySet: s.v4KeySet,
163
+ keySet: s.keySet,
164
+ keyCheck: "none",
165
+ capabilities: { keywordVolume: s.keySet, backlinksSummary: s.keySet },
166
166
  unitsUsedThisMonth: s.unitsUsedThisMonth,
167
167
  cap: s.cap,
168
- ...(check.works === true ? { unitsLeft: check.unitsLeft } : {}),
169
168
  };
170
- const usage = `crawlmark used ${fmtUnits(s.unitsUsedThisMonth)} of its ${fmtUnits(s.cap)}-unit monthly cap this month`;
171
- if (!check.configured) {
169
+ if (!s.keySet) {
172
170
  return used
173
171
  ? { ...base, state: "error", detail: `Semrush is ${choiceText(chosen)}, but SEMRUSH_API_KEY is not set, so its paid calls fail.`, next, data }
174
- : { ...base, state: "missing", detail: "Optional. A Semrush API key (SEO Business plan plus an API unit package) can supply keyword, ranking, competitor and backlink data.", next, data };
175
- }
176
- if (check.works === false)
177
- return { ...base, state: "error", detail: check.error, next, data };
178
- if (check.works === null) {
179
- return { ...base, state: "configured", detail: `SEMRUSH_API_KEY is set but Semrush's balance check couldn't be reached, so it's untested: ${check.error}`, next, data };
172
+ : { ...base, state: "missing", detail: "Optional. A Semrush v4 API key (profile > API keys) supplies keyword volume and backlinks; rankings, competitors and visibility come from DataForSEO or Ahrefs.", next, data };
180
173
  }
181
- const left = check.unitsLeft;
182
- const v4 = s.v4KeySet ? " The v4 key (backlinks) has no free check; it's checked the first time backlinks_summary runs." : " Backlinks use the v3 report (no v4 key set).";
183
- const head = `Key accepted, ${fmtUnits(left)} API units left; ${usage}.${used ? ` Used as ${choiceText(chosen)}.` : ""}`;
184
- if (left === 0)
185
- return { ...base, state: "configured", detail: `Key accepted but the balance is 0 units; Semrush API needs an SEO Business plan and a unit package.${v4}`, next, data };
186
- return { ...base, state: s.v4KeySet ? "configured" : "working", detail: `${head}${v4}`, next: null, data };
174
+ return {
175
+ ...base,
176
+ state: "configured",
177
+ detail: `Key set (v4). It serves keyword volume (20 units per keyword, Early Access) and backlinks (45 units); Semrush has no free key check, so it's checked by the first paid call. crawlmark used ${fmtUnits(s.unitsUsedThisMonth)} of its ${fmtUnits(s.cap)}-unit monthly cap this month.${used ? ` Used as ${choiceText(chosen)}.` : ""}`,
178
+ next: null,
179
+ data,
180
+ };
187
181
  }
188
182
  async function attempt(fn) {
189
183
  try {
@@ -253,7 +247,7 @@ export async function collectSetupStatus(p, opts = {}) {
253
247
  const states = {
254
248
  dataforseo: dfs ? dfsState(dfs) : "missing",
255
249
  ahrefs: ahrefs ? keyState(ahrefs.check) : "missing",
256
- semrush: semrush ? keyState(semrush.check) : "missing",
250
+ semrush: semrush?.keySet ? "configured" : "missing",
257
251
  };
258
252
  const row = seoProviderRow(seo, states, { cli, configuredByEnv: home.configuredByEnv });
259
253
  seoData = row.data;