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.
@@ -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 and semrush_site_audit sources read the client's existing Site Audit project (clients.yaml ahrefsSiteAuditProject / semrushProject, else matched by domain) and spend units " +
265
- "(Ahrefs: 50 for the issues, plus 50 per issueUrls call; Semrush: about 100 per call, up to 400 with the project list and titles): they need allowPaid: true, read only (never start a crawl or create a project). " +
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 a provider Site Audit source (paid: one call each). Default 0."),
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 / Semrush Site Audit sources (they spend provider units); set only after the user agreed to the estimate."),
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 an ahrefs_site_audit or semrush_site_audit source.");
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.filter((p) => p.provider === "ahrefs").reduce((a, p) => a + p.units, 0), semrush: paid.filter((p) => p.provider === "semrush").reduce((a, p) => a + p.units, 0) };
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,6 +1,6 @@
1
1
  {
2
2
  "name": "crawlmark-mcp",
3
- "version": "0.1.0",
3
+ "version": "0.1.2",
4
4
  "description": "MCP server for automated SEO audits and reports: Google Search Console, GA4, Tag Manager, DataForSEO, Lighthouse and axe-core",
5
5
  "keywords": [
6
6
  "mcp",
@@ -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