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.
- 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 +46 -60
- 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
package/dist/tools/reports.js
CHANGED
|
@@ -261,17 +261,17 @@ export function registerReportTools(server) {
|
|
|
261
261
|
"The same problem found by two sources is one item, listing both sources with their own severity; `affected` is the largest page count any one source reports. " +
|
|
262
262
|
"`changes` compares with the newest snapshot older than 24 hours: new, persistent, resolved (only when the source that reported it ran again) and unverified (not re-checked, never counted as fixed); null on the first run. " +
|
|
263
263
|
"Each call saves a snapshot (local). Never starts a crawl or audit; stale sources (older than maxAgeDays) are shown with their date. " +
|
|
264
|
-
"Free by default. The ahrefs_site_audit
|
|
265
|
-
"(
|
|
264
|
+
"Free by default. The ahrefs_site_audit source reads the client's existing Ahrefs Site Audit project (clients.yaml ahrefsSiteAuditProject, else matched by domain) and spends units " +
|
|
265
|
+
"(50 for the issues, plus 50 per issueUrls call): it needs allowPaid: true and is read only (never starts a crawl or creates a project). Semrush's API has no Site Audit. " +
|
|
266
266
|
"Call with estimateOnly: true first, state the units and ask before passing allowPaid.",
|
|
267
267
|
inputSchema: {
|
|
268
268
|
client: clientArg,
|
|
269
269
|
sources: z.array(z.enum(ISSUE_SOURCES)).min(1).max(ISSUE_SOURCES.length).optional().describe("Default: every free source (audit, local_crawl, site_hygiene, dataforseo_crawl, gsc_inspection)."),
|
|
270
270
|
minSeverity: z.enum(["critical", "high", "medium", "low"]).optional().describe("Leave out issues below this severity. Default low (everything)."),
|
|
271
271
|
limit: z.number().int().min(1).max(200).optional().describe("Issues listed (counts cover all). Default 50."),
|
|
272
|
-
issueUrls: z.number().int().min(0).max(5).optional().describe("Top issues to fetch up to 3 example URLs for from
|
|
272
|
+
issueUrls: z.number().int().min(0).max(5).optional().describe("Top issues to fetch up to 3 example URLs for from the Ahrefs Site Audit source (paid: one call each). Default 0."),
|
|
273
273
|
maxAgeDays: z.number().int().min(7).max(120).optional().describe("Sources older than this are flagged stale. Default 45."),
|
|
274
|
-
allowPaid: z.boolean().optional().describe("Needed for the Ahrefs
|
|
274
|
+
allowPaid: z.boolean().optional().describe("Needed for the Ahrefs Site Audit source (it spends Ahrefs units); set only after the user agreed to the estimate."),
|
|
275
275
|
estimateOnly: z.boolean().optional().describe("Return the provider units this would spend, read nothing and save nothing."),
|
|
276
276
|
},
|
|
277
277
|
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: true },
|
|
@@ -279,11 +279,11 @@ export function registerReportTools(server) {
|
|
|
279
279
|
const c = await resolveClient(client);
|
|
280
280
|
const sources = o.sources ?? [...FREE_ISSUE_SOURCES];
|
|
281
281
|
if ((o.issueUrls ?? 0) > 0 && !sources.some((x) => PAID_ISSUE_SOURCES.has(x)))
|
|
282
|
-
throw new Error("issueUrls needs
|
|
282
|
+
throw new Error("issueUrls needs the ahrefs_site_audit source.");
|
|
283
283
|
const ti = await import("../reports/technical-issues.js");
|
|
284
284
|
if (estimateOnly) {
|
|
285
285
|
const paid = await ti.planPaidIssueSources(c, sources, o.issueUrls ?? 0);
|
|
286
|
-
const units = { ahrefs: paid.
|
|
286
|
+
const units = { ahrefs: paid.reduce((a, p) => a + p.units, 0), semrush: 0 };
|
|
287
287
|
return {
|
|
288
288
|
estimateOnly: true,
|
|
289
289
|
sources,
|
package/package.json
CHANGED
|
@@ -1,175 +0,0 @@
|
|
|
1
|
-
import { recordUnits } from "../lib/budget.js";
|
|
2
|
-
import { TTL } from "../lib/cache.js";
|
|
3
|
-
import { cleanText, cleanUrl } from "../lib/inspections.js";
|
|
4
|
-
import { ProviderUnavailableError } from "../lib/provider-ids.js";
|
|
5
|
-
import { isTimeoutOrAbort, unitsCall, withUnitsReservation } from "./units-billing.js";
|
|
6
|
-
const ID = "\\d{1,20}";
|
|
7
|
-
const SNAP = "[A-Za-z0-9_-]{1,64}";
|
|
8
|
-
/** The only Semrush calls this module can make (D12). Paths are matched whole; no query string may name a snapshot. */
|
|
9
|
-
export const ALLOWED = [
|
|
10
|
-
{ method: "GET", pattern: /^\/management\/v1\/projects$/, units: 100, label: "projects list" },
|
|
11
|
-
{ method: "GET", pattern: new RegExp(`^/reports/v1/projects/${ID}/siteaudit/info$`), units: 100, label: "siteaudit/info" },
|
|
12
|
-
{ method: "GET", pattern: new RegExp(`^/reports/v1/projects/${ID}/siteaudit/meta/issues$`), units: 100, label: "siteaudit/meta/issues" },
|
|
13
|
-
{ method: "GET", pattern: new RegExp(`^/reports/v1/projects/${ID}/siteaudit/snapshot/${SNAP}/issue/${ID}$`), units: 100, label: "siteaudit/issue" },
|
|
14
|
-
];
|
|
15
|
-
export const CALL_UNITS = 100;
|
|
16
|
-
export class SemrushAuditRefused extends Error {
|
|
17
|
-
name = "SemrushAuditRefused";
|
|
18
|
-
}
|
|
19
|
-
/** Throws unless (method, path) is allowed and no parameter names a snapshot (the 10,000-unit snapshot report). */
|
|
20
|
-
export function assertSemrushAllowed(method, path, params = {}) {
|
|
21
|
-
const hit = ALLOWED.find((a) => a.method === method && a.pattern.test(path));
|
|
22
|
-
if (!hit || "snapshot_id" in params || /[?#]/.test(path))
|
|
23
|
-
throw new SemrushAuditRefused(`Refused: ${method} ${path.slice(0, 120)} isn't an allowed read-only Semrush Site Audit call.`);
|
|
24
|
-
return { label: hit.label, units: hit.units };
|
|
25
|
-
}
|
|
26
|
-
/** The real transport: allowlist, the shared Semrush limiter and key handling, units recorded on success. */
|
|
27
|
-
export function semrushAuditRequest(method, path, params = {}) {
|
|
28
|
-
const { label, units } = assertSemrushAllowed(method, path, params);
|
|
29
|
-
return async (client) => {
|
|
30
|
-
const { semrushProjectsGet, semrushRedact } = await import("./semrush.js");
|
|
31
|
-
let res;
|
|
32
|
-
try {
|
|
33
|
-
res = await semrushProjectsGet(path, label, params);
|
|
34
|
-
}
|
|
35
|
-
catch (err) {
|
|
36
|
-
if (!isTimeoutOrAbort(err))
|
|
37
|
-
throw err;
|
|
38
|
-
// Semrush may still have billed it: the estimate is recorded against the cap (as v3Report does).
|
|
39
|
-
recordUnits(client.slug, "semrush", `${label} (timeout, estimated)`, units);
|
|
40
|
-
throw new Error(`Semrush ${label} timed out; Semrush may still have billed it. The estimated ${units} units were recorded against the cap.`);
|
|
41
|
-
}
|
|
42
|
-
let json;
|
|
43
|
-
try {
|
|
44
|
-
json = JSON.parse(res.text);
|
|
45
|
-
}
|
|
46
|
-
catch {
|
|
47
|
-
json = undefined;
|
|
48
|
-
}
|
|
49
|
-
if (res.status >= 400 || json === undefined || /^\s*ERROR \d+/.test(res.text)) {
|
|
50
|
-
throw new Error(`Semrush ${label} failed (HTTP ${res.status}): ${semrushRedact(res.text).slice(0, 200)}`);
|
|
51
|
-
}
|
|
52
|
-
recordUnits(client.slug, "semrush", label, units);
|
|
53
|
-
return json;
|
|
54
|
-
};
|
|
55
|
-
}
|
|
56
|
-
const realTransport = { get: (client, path, params) => semrushAuditRequest("GET", path, params)(client) };
|
|
57
|
-
/** True when a project's host is the client's domain or one of its subdomains (SEC-1). */
|
|
58
|
-
export const onClientDomain = (host, domain) => {
|
|
59
|
-
const own = bareHost(domain);
|
|
60
|
-
return Boolean(host) && (host === own || host.endsWith(`.${own}`));
|
|
61
|
-
};
|
|
62
|
-
const bareHost = (u) => {
|
|
63
|
-
try {
|
|
64
|
-
return new URL(/^https?:\/\//i.test(u) ? u : `https://${u}`).hostname.toLowerCase().replace(/^www\./, "");
|
|
65
|
-
}
|
|
66
|
-
catch {
|
|
67
|
-
return "";
|
|
68
|
-
}
|
|
69
|
-
};
|
|
70
|
-
/** D9: Semrush errors (IDs 1-99) -> high, warnings (101-199) -> medium, notices (201+) -> low. */
|
|
71
|
-
export function semrushSeverity(id) {
|
|
72
|
-
return id < 100 ? "high" : id < 200 ? "medium" : "low";
|
|
73
|
-
}
|
|
74
|
-
/** Not confirmed (live check, research Not confirmed 4): the tool name Semrush uses for Site Audit in the project list. */
|
|
75
|
-
export const SITE_AUDIT_TOOL = "siteaudit";
|
|
76
|
-
/** Pure apart from the cache peek: list (unless a project is saved or the list is cached) + info + meta (unless cached) + issue URLs. */
|
|
77
|
-
export function planSemrushSiteAudit(o) {
|
|
78
|
-
const n = Math.max(0, Math.min(5, o.issueUrls));
|
|
79
|
-
const list = o.savedProject || o.listCached ? 0 : 1;
|
|
80
|
-
const meta = o.metaCached ? 0 : 1;
|
|
81
|
-
const calls = list + 1 + meta + n;
|
|
82
|
-
return { units: calls * CALL_UNITS, requests: calls, breakdown: `Semrush Site Audit: ${list ? "project list (100) + " : ""}info (100)${meta ? " + issue titles (100)" : ""}${n ? ` + ${n} issue URL call(s) (100 each)` : ""}` };
|
|
83
|
-
}
|
|
84
|
-
export async function matchSemrushProject(client, t = realTransport) {
|
|
85
|
-
if (client.semrushProject)
|
|
86
|
-
return client.semrushProject;
|
|
87
|
-
const r = await unitsCall({
|
|
88
|
-
client,
|
|
89
|
-
provider: "semrush",
|
|
90
|
-
capability: "siteAuditProjects",
|
|
91
|
-
keyParts: { list: "all" },
|
|
92
|
-
ttl: TTL.week,
|
|
93
|
-
estimate: CALL_UNITS,
|
|
94
|
-
run: async () => {
|
|
95
|
-
const json = await t.get(client, "/management/v1/projects", {});
|
|
96
|
-
const list = Array.isArray(json) ? json : (json.projects ?? []);
|
|
97
|
-
// Kept: project IDs, hosts and tool names only.
|
|
98
|
-
return { value: list.map((p) => ({ id: p.project_id === null || p.project_id === undefined ? "" : String(p.project_id), host: bareHost(p.url ?? ""), tools: (p.tools ?? []).map((x) => String(x.tool ?? "")) })), units: CALL_UNITS };
|
|
99
|
-
},
|
|
100
|
-
});
|
|
101
|
-
const own = bareHost(client.domain);
|
|
102
|
-
const matches = r.value.filter((p) => onClientDomain(p.host, client.domain) && p.tools.includes(SITE_AUDIT_TOOL) && /^\d+$/.test(p.id));
|
|
103
|
-
if (matches.length !== 1) {
|
|
104
|
-
throw new ProviderUnavailableError("siteAudit", "semrush", matches.length
|
|
105
|
-
? `${matches.length} Semrush projects with Site Audit match ${own}; set semrushProject (crawlmark-cli clients set ${client.domain} --semrush-project <id>)`
|
|
106
|
-
: `no Semrush project with Site Audit for ${own}; set one up in Semrush, or set semrushProject`);
|
|
107
|
-
}
|
|
108
|
-
return matches[0].id;
|
|
109
|
-
}
|
|
110
|
-
const auditTime = (v) => {
|
|
111
|
-
if (typeof v === "number" && Number.isFinite(v))
|
|
112
|
-
return new Date(v > 1e12 ? v : v * 1000).toISOString();
|
|
113
|
-
if (typeof v === "string" && !Number.isNaN(Date.parse(v)))
|
|
114
|
-
return new Date(v).toISOString();
|
|
115
|
-
return null;
|
|
116
|
-
};
|
|
117
|
-
/**
|
|
118
|
-
* Read the latest audit: info (counts per issue ID), titles from meta/issues (cached 30 days) and, with issueUrls,
|
|
119
|
-
* example URLs for the top issues. The whole read's units are checked and reserved first.
|
|
120
|
-
*/
|
|
121
|
-
export async function readSemrushSiteAudit(client, opts, t = realTransport) {
|
|
122
|
-
const project = await matchSemrushProject(client, t);
|
|
123
|
-
const n = Math.max(0, Math.min(5, opts.issueUrls ?? 0));
|
|
124
|
-
const plan = planSemrushSiteAudit({ savedProject: true, listCached: true, metaCached: opts.metaCached === true, issueUrls: n });
|
|
125
|
-
return withUnitsReservation(client, "semrush", plan.units, async () => {
|
|
126
|
-
const base = `/reports/v1/projects/${project}/siteaudit`;
|
|
127
|
-
const info = await t.get(client, `${base}/info`, {});
|
|
128
|
-
// SEC-1: a project (pinned or matched) whose site isn't this client's never reaches its report.
|
|
129
|
-
const infoHost = bareHost(typeof info.url === "string" ? info.url : "");
|
|
130
|
-
if (!onClientDomain(infoHost, client.domain)) {
|
|
131
|
-
throw new ProviderUnavailableError("siteAudit", "semrush", `Semrush project ${project} is for ${infoHost || "an unknown site"}, not ${bareHost(client.domain)}; fix semrushProject (crawlmark-cli clients set ${client.domain} --semrush-project <id>)`);
|
|
132
|
-
}
|
|
133
|
-
const meta = await unitsCall({
|
|
134
|
-
client,
|
|
135
|
-
provider: "semrush",
|
|
136
|
-
capability: "siteAuditMeta",
|
|
137
|
-
keyParts: { project },
|
|
138
|
-
ttl: TTL.month,
|
|
139
|
-
estimate: CALL_UNITS,
|
|
140
|
-
run: async () => {
|
|
141
|
-
const json = await t.get(client, `${base}/meta/issues`, {});
|
|
142
|
-
return { value: (json.issues ?? []).flatMap((i) => (typeof i.id === "number" ? [{ id: i.id, title: cleanText(i.title, 200) }] : [])), units: CALL_UNITS };
|
|
143
|
-
},
|
|
144
|
-
});
|
|
145
|
-
const titles = new Map(meta.value.map((m) => [m.id, m.title]));
|
|
146
|
-
const issues = [];
|
|
147
|
-
for (const [k, v] of Object.entries(info.defects ?? {})) {
|
|
148
|
-
const id = Number(k);
|
|
149
|
-
if (!Number.isInteger(id) || id <= 0 || typeof v !== "number" || v <= 0)
|
|
150
|
-
continue;
|
|
151
|
-
const severity = semrushSeverity(id);
|
|
152
|
-
const title = titles.get(id) ?? `Semrush Site Audit issue ${id}`;
|
|
153
|
-
issues.push({ id, source: "semrush_site_audit", rule: String(id), url: null, severity, nativeSeverity: severity === "high" ? "error" : severity === "medium" ? "warning" : "notice", title, count: Math.round(v) });
|
|
154
|
-
}
|
|
155
|
-
const snapshotId = typeof info.current_snapshot?.snapshot_id === "string" && /^[A-Za-z0-9_-]{1,64}$/.test(info.current_snapshot.snapshot_id) ? info.current_snapshot.snapshot_id : null;
|
|
156
|
-
if (n > 0 && snapshotId) {
|
|
157
|
-
const order = ["critical", "high", "medium", "low"];
|
|
158
|
-
for (const i of [...issues].sort((a, b) => order.indexOf(a.severity) - order.indexOf(b.severity) || (b.count ?? 0) - (a.count ?? 0)).slice(0, n)) {
|
|
159
|
-
const json = await t.get(client, `${base}/snapshot/${snapshotId}/issue/${i.id}`, { limit: "3" });
|
|
160
|
-
// Not confirmed (live check): the URL field's name in this response.
|
|
161
|
-
i.examples = (json.data ?? []).map((row) => [row.source_url, row.url, row.page_url].find((x) => typeof x === "string")).map((u) => (u ? cleanUrl(u) : null)).filter((u) => Boolean(u)).slice(0, 3);
|
|
162
|
-
}
|
|
163
|
-
}
|
|
164
|
-
const ranAt = auditTime(info.last_audit) ?? new Date().toISOString();
|
|
165
|
-
return { run: { source: "semrush_site_audit", runId: `semrush:${project}@${ranAt}`, ranAt, stale: false }, issues: issues.map(({ id: _id, ...r }) => r) };
|
|
166
|
-
});
|
|
167
|
-
}
|
|
168
|
-
/** The pre-flight for one read, honouring the cached project list and issue titles (no request). */
|
|
169
|
-
export async function estimateSemrushSiteAudit(client, issueUrls) {
|
|
170
|
-
const { cacheKey, peekCache } = await import("../lib/cache.js");
|
|
171
|
-
const listCached = peekCache(cacheKey("seo", "semrush", "siteAuditProjects", { list: "all" })) !== undefined;
|
|
172
|
-
const metaCached = client.semrushProject ? peekCache(cacheKey("seo", "semrush", "siteAuditMeta", { project: client.semrushProject })) !== undefined : false;
|
|
173
|
-
return planSemrushSiteAudit({ savedProject: Boolean(client.semrushProject), listCached, metaCached, issueUrls });
|
|
174
|
-
}
|
|
175
|
-
//# sourceMappingURL=semrush-site-audit.js.map
|