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 +7 -8
- package/README.md +92 -26
- package/dist/analysis/tech-issues.js +6 -10
- package/dist/cli-help.js +5 -7
- package/dist/cli.js +5 -4
- package/dist/clients.js +5 -8
- package/dist/config.js +2 -3
- package/dist/lib/clients.js +12 -6
- package/dist/lib/country.js +0 -11
- package/dist/lib/env-file.js +14 -5
- package/dist/lib/issue-snapshots.js +0 -6
- package/dist/lib/provider-data.js +2 -2
- package/dist/lib/vendor-mcp.js +155 -0
- package/dist/prompts/templates/keyword-research.md +1 -1
- package/dist/providers/ahrefs-shape.js +2 -2
- package/dist/providers/semrush-shape.js +20 -314
- package/dist/providers/semrush.js +73 -480
- package/dist/providers/seo-data.js +13 -1
- package/dist/providers/units-billing.js +1 -1
- package/dist/reports/render/sections.js +2 -1
- package/dist/reports/technical-issues.js +3 -11
- package/dist/reports/templates/run.js +8 -4
- package/dist/reports/templates/schema.js +20 -10
- package/dist/reports/templates/write.js +15 -2
- package/dist/setup/apps.js +1 -1
- package/dist/setup/menu.js +2 -1
- package/dist/setup/status-providers.js +1 -3
- package/dist/setup/status.js +16 -22
- package/dist/setup/vendor-mcp-step.js +339 -0
- package/dist/setup/wizard.js +42 -59
- package/dist/tools/competitors.js +1 -1
- package/dist/tools/raw.js +4 -5
- package/dist/tools/reports.js +6 -6
- package/package.json +1 -1
- package/dist/providers/semrush-site-audit.js +0 -175
|
@@ -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
|
-
|
|
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
|
|
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,
|
|
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 =
|
|
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?.(
|
|
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:
|
|
536
|
+
sources: kept?.length ? kept : undefined,
|
|
534
537
|
minSeverity: s.minSeverity,
|
|
535
538
|
limit: s.limit ?? 25,
|
|
536
539
|
maxAgeDays: s.maxAgeDays,
|
|
537
|
-
|
|
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
|
|
76
|
-
|
|
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
|
|
80
|
-
export const PAID_ISSUE_SOURCES = new Set(["ahrefs_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
|
|
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
|
|
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
|
|
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
|
-
|
|
313
|
-
|
|
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 {
|
package/dist/setup/apps.js
CHANGED
|
@@ -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);
|
package/dist/setup/menu.js
CHANGED
|
@@ -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
|
-
|
|
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
|
}),
|
package/dist/setup/status.js
CHANGED
|
@@ -150,40 +150,34 @@ export function ahrefsRow(a, next) {
|
|
|
150
150
|
};
|
|
151
151
|
}
|
|
152
152
|
/**
|
|
153
|
-
* The `semrush` row
|
|
154
|
-
*
|
|
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 {
|
|
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:
|
|
165
|
-
|
|
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
|
-
|
|
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 (
|
|
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
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
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 ?
|
|
250
|
+
semrush: semrush?.keySet ? "configured" : "missing",
|
|
257
251
|
};
|
|
258
252
|
const row = seoProviderRow(seo, states, { cli, configuredByEnv: home.configuredByEnv });
|
|
259
253
|
seoData = row.data;
|