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
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
|