nodebench-mcp 2.13.0 → 2.14.0
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/dist/__tests__/evalHarness.test.js +1 -1
- package/dist/__tests__/gaiaCapabilityAudioEval.test.js +9 -14
- package/dist/__tests__/gaiaCapabilityAudioEval.test.js.map +1 -1
- package/dist/__tests__/gaiaCapabilityEval.test.js +88 -14
- package/dist/__tests__/gaiaCapabilityEval.test.js.map +1 -1
- package/dist/__tests__/gaiaCapabilityFilesEval.test.js +9 -5
- package/dist/__tests__/gaiaCapabilityFilesEval.test.js.map +1 -1
- package/dist/__tests__/gaiaCapabilityMediaEval.test.js +16 -16
- package/dist/__tests__/gaiaCapabilityMediaEval.test.js.map +1 -1
- package/dist/__tests__/helpers/answerMatch.d.ts +36 -7
- package/dist/__tests__/helpers/answerMatch.js +224 -35
- package/dist/__tests__/helpers/answerMatch.js.map +1 -1
- package/dist/__tests__/presetRealWorldBench.test.js +11 -0
- package/dist/__tests__/presetRealWorldBench.test.js.map +1 -1
- package/dist/__tests__/tools.test.js +16 -6
- package/dist/__tests__/tools.test.js.map +1 -1
- package/dist/__tests__/toolsetGatingEval.test.js +11 -1
- package/dist/__tests__/toolsetGatingEval.test.js.map +1 -1
- package/dist/db.js +21 -0
- package/dist/db.js.map +1 -1
- package/dist/index.js +31 -4
- package/dist/index.js.map +1 -1
- package/dist/tools/gitWorkflowTools.d.ts +11 -0
- package/dist/tools/gitWorkflowTools.js +580 -0
- package/dist/tools/gitWorkflowTools.js.map +1 -0
- package/dist/tools/localFileTools.js +2 -2
- package/dist/tools/localFileTools.js.map +1 -1
- package/dist/tools/metaTools.js +82 -0
- package/dist/tools/metaTools.js.map +1 -1
- package/dist/tools/parallelAgentTools.js +228 -0
- package/dist/tools/parallelAgentTools.js.map +1 -1
- package/dist/tools/patternTools.d.ts +13 -0
- package/dist/tools/patternTools.js +456 -0
- package/dist/tools/patternTools.js.map +1 -0
- package/dist/tools/seoTools.d.ts +16 -0
- package/dist/tools/seoTools.js +866 -0
- package/dist/tools/seoTools.js.map +1 -0
- package/dist/tools/toolRegistry.js +260 -0
- package/dist/tools/toolRegistry.js.map +1 -1
- package/dist/tools/toonTools.d.ts +15 -0
- package/dist/tools/toonTools.js +94 -0
- package/dist/tools/toonTools.js.map +1 -0
- package/dist/tools/voiceBridgeTools.d.ts +15 -0
- package/dist/tools/voiceBridgeTools.js +1427 -0
- package/dist/tools/voiceBridgeTools.js.map +1 -0
- package/package.json +3 -2
|
@@ -0,0 +1,866 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SEO Tools — Website SEO auditing, performance checks, content analysis,
|
|
3
|
+
* and WordPress security assessment.
|
|
4
|
+
*
|
|
5
|
+
* 5 tools:
|
|
6
|
+
* - seo_audit_url: Fetch URL and analyze SEO elements (title, meta, headings, images, etc.)
|
|
7
|
+
* - check_page_performance: Lightweight performance checks via fetch (response time, compression, caching)
|
|
8
|
+
* - analyze_seo_content: Content analysis for SEO (readability, keyword density, link ratio)
|
|
9
|
+
* - check_wordpress_site: Detect WordPress and assess security posture
|
|
10
|
+
* - scan_wordpress_updates: Check WordPress plugins/themes for known versions and vulnerabilities
|
|
11
|
+
*
|
|
12
|
+
* All tools use HTTP fetch + basic HTML parsing. No browser dependencies.
|
|
13
|
+
* No external npm dependencies — uses Node's built-in fetch (Node 18+).
|
|
14
|
+
*/
|
|
15
|
+
// ─── HTTP helpers ─────────────────────────────────────────────────────────────
|
|
16
|
+
const DEFAULT_TIMEOUT_MS = 10_000;
|
|
17
|
+
function createAbortSignal(timeoutMs) {
|
|
18
|
+
const controller = new AbortController();
|
|
19
|
+
setTimeout(() => controller.abort(), timeoutMs);
|
|
20
|
+
return controller.signal;
|
|
21
|
+
}
|
|
22
|
+
async function safeFetch(url, timeoutMs = DEFAULT_TIMEOUT_MS, options = {}) {
|
|
23
|
+
try {
|
|
24
|
+
const res = await fetch(url, {
|
|
25
|
+
signal: createAbortSignal(timeoutMs),
|
|
26
|
+
headers: {
|
|
27
|
+
"User-Agent": "NodeBench-SEO-Auditor/1.0",
|
|
28
|
+
Accept: "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
|
|
29
|
+
},
|
|
30
|
+
redirect: "follow",
|
|
31
|
+
...options,
|
|
32
|
+
});
|
|
33
|
+
const text = await res.text();
|
|
34
|
+
return { ok: res.ok, status: res.status, headers: res.headers, text };
|
|
35
|
+
}
|
|
36
|
+
catch (e) {
|
|
37
|
+
return {
|
|
38
|
+
ok: false,
|
|
39
|
+
status: 0,
|
|
40
|
+
headers: new Headers(),
|
|
41
|
+
text: "",
|
|
42
|
+
error: e.name === "AbortError" ? `Request timed out after ${timeoutMs}ms` : e.message,
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
// ─── HTML parsing helpers ─────────────────────────────────────────────────────
|
|
47
|
+
function extractTag(html, tag) {
|
|
48
|
+
const regex = new RegExp(`<${tag}[^>]*>([\\s\\S]*?)</${tag}>`, "i");
|
|
49
|
+
const match = html.match(regex);
|
|
50
|
+
return match ? match[1].trim() : null;
|
|
51
|
+
}
|
|
52
|
+
function extractMeta(html, name) {
|
|
53
|
+
// Match both name="..." and property="..."
|
|
54
|
+
const nameRegex = new RegExp(`<meta\\s+(?:[^>]*?)?(?:name|property)\\s*=\\s*["']${escapeRegex(name)}["'][^>]*?content\\s*=\\s*["']([^"']*)["'][^>]*?>`, "i");
|
|
55
|
+
const match = html.match(nameRegex);
|
|
56
|
+
if (match)
|
|
57
|
+
return match[1];
|
|
58
|
+
// Try reversed attribute order: content before name/property
|
|
59
|
+
const reversedRegex = new RegExp(`<meta\\s+(?:[^>]*?)?content\\s*=\\s*["']([^"']*)["'][^>]*?(?:name|property)\\s*=\\s*["']${escapeRegex(name)}["'][^>]*?>`, "i");
|
|
60
|
+
const reversedMatch = html.match(reversedRegex);
|
|
61
|
+
return reversedMatch ? reversedMatch[1] : null;
|
|
62
|
+
}
|
|
63
|
+
function extractLinkRel(html, rel) {
|
|
64
|
+
const regex = new RegExp(`<link\\s+(?:[^>]*?)?rel\\s*=\\s*["']${escapeRegex(rel)}["'][^>]*?href\\s*=\\s*["']([^"']*)["'][^>]*?>`, "i");
|
|
65
|
+
const match = html.match(regex);
|
|
66
|
+
if (match)
|
|
67
|
+
return match[1];
|
|
68
|
+
// Reversed attribute order
|
|
69
|
+
const reversedRegex = new RegExp(`<link\\s+(?:[^>]*?)?href\\s*=\\s*["']([^"']*)["'][^>]*?rel\\s*=\\s*["']${escapeRegex(rel)}["'][^>]*?>`, "i");
|
|
70
|
+
const reversedMatch = html.match(reversedRegex);
|
|
71
|
+
return reversedMatch ? reversedMatch[1] : null;
|
|
72
|
+
}
|
|
73
|
+
function escapeRegex(str) {
|
|
74
|
+
return str.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
75
|
+
}
|
|
76
|
+
function countHeadings(html) {
|
|
77
|
+
const counts = { h1: 0, h2: 0, h3: 0, h4: 0, h5: 0, h6: 0 };
|
|
78
|
+
for (const level of ["h1", "h2", "h3", "h4", "h5", "h6"]) {
|
|
79
|
+
const regex = new RegExp(`<${level}[\\s>]`, "gi");
|
|
80
|
+
const matches = html.match(regex);
|
|
81
|
+
counts[level] = matches ? matches.length : 0;
|
|
82
|
+
}
|
|
83
|
+
return counts;
|
|
84
|
+
}
|
|
85
|
+
function countImages(html) {
|
|
86
|
+
const imgRegex = /<img\s[^>]*?>/gi;
|
|
87
|
+
const imgs = html.match(imgRegex) || [];
|
|
88
|
+
const total = imgs.length;
|
|
89
|
+
let withoutAlt = 0;
|
|
90
|
+
for (const img of imgs) {
|
|
91
|
+
// Check if alt attribute exists and is non-empty
|
|
92
|
+
const altMatch = img.match(/\salt\s*=\s*["']([^"']*)["']/i);
|
|
93
|
+
if (!altMatch || altMatch[1].trim() === "") {
|
|
94
|
+
withoutAlt++;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
return { total, withoutAlt };
|
|
98
|
+
}
|
|
99
|
+
// ─── Content analysis helpers ─────────────────────────────────────────────────
|
|
100
|
+
function stripHtml(html) {
|
|
101
|
+
return html
|
|
102
|
+
.replace(/<script[^>]*>[\s\S]*?<\/script>/gi, "")
|
|
103
|
+
.replace(/<style[^>]*>[\s\S]*?<\/style>/gi, "")
|
|
104
|
+
.replace(/<[^>]+>/g, " ")
|
|
105
|
+
.replace(/&[a-z]+;/gi, " ")
|
|
106
|
+
.replace(/\s+/g, " ")
|
|
107
|
+
.trim();
|
|
108
|
+
}
|
|
109
|
+
function countSyllables(word) {
|
|
110
|
+
const w = word.toLowerCase().replace(/[^a-z]/g, "");
|
|
111
|
+
if (w.length <= 2)
|
|
112
|
+
return 1;
|
|
113
|
+
// Count vowel groups
|
|
114
|
+
const vowelGroups = w.match(/[aeiouy]+/g);
|
|
115
|
+
let count = vowelGroups ? vowelGroups.length : 1;
|
|
116
|
+
// Silent e at end
|
|
117
|
+
if (w.endsWith("e") && count > 1)
|
|
118
|
+
count--;
|
|
119
|
+
return Math.max(1, count);
|
|
120
|
+
}
|
|
121
|
+
function fleschKincaidScore(words, sentences, syllables) {
|
|
122
|
+
if (sentences === 0 || words === 0)
|
|
123
|
+
return 0;
|
|
124
|
+
return 206.835 - 1.015 * (words / sentences) - 84.6 * (syllables / words);
|
|
125
|
+
}
|
|
126
|
+
function readabilityLevel(score) {
|
|
127
|
+
if (score >= 90)
|
|
128
|
+
return "Very Easy (5th grade)";
|
|
129
|
+
if (score >= 80)
|
|
130
|
+
return "Easy (6th grade)";
|
|
131
|
+
if (score >= 70)
|
|
132
|
+
return "Fairly Easy (7th grade)";
|
|
133
|
+
if (score >= 60)
|
|
134
|
+
return "Standard (8th-9th grade)";
|
|
135
|
+
if (score >= 50)
|
|
136
|
+
return "Fairly Difficult (10th-12th grade)";
|
|
137
|
+
if (score >= 30)
|
|
138
|
+
return "Difficult (College)";
|
|
139
|
+
return "Very Difficult (College Graduate)";
|
|
140
|
+
}
|
|
141
|
+
// ─── Tools ────────────────────────────────────────────────────────────────────
|
|
142
|
+
export const seoTools = [
|
|
143
|
+
{
|
|
144
|
+
name: "seo_audit_url",
|
|
145
|
+
description: "Fetch a URL and analyze its SEO elements: title tag, meta description, Open Graph tags (og:title, og:description, og:image), heading hierarchy (h1-h6 counts), images without alt text, canonical URL, robots meta, and structured data (JSON-LD). Scores each element 0-100 and returns a total score with actionable recommendations. Uses HTTP fetch with regex-based HTML parsing — no browser needed.",
|
|
146
|
+
inputSchema: {
|
|
147
|
+
type: "object",
|
|
148
|
+
properties: {
|
|
149
|
+
url: { type: "string", description: "The URL to audit" },
|
|
150
|
+
timeout: {
|
|
151
|
+
type: "number",
|
|
152
|
+
description: "Request timeout in milliseconds (default: 10000)",
|
|
153
|
+
},
|
|
154
|
+
},
|
|
155
|
+
required: ["url"],
|
|
156
|
+
},
|
|
157
|
+
handler: async (args) => {
|
|
158
|
+
const url = args.url;
|
|
159
|
+
const timeoutMs = args.timeout ?? DEFAULT_TIMEOUT_MS;
|
|
160
|
+
const res = await safeFetch(url, timeoutMs);
|
|
161
|
+
if (!res.ok) {
|
|
162
|
+
return {
|
|
163
|
+
error: true,
|
|
164
|
+
url,
|
|
165
|
+
status: res.status,
|
|
166
|
+
message: res.error || `HTTP ${res.status}`,
|
|
167
|
+
};
|
|
168
|
+
}
|
|
169
|
+
const html = res.text;
|
|
170
|
+
const recommendations = [];
|
|
171
|
+
// ── Title ──
|
|
172
|
+
const title = extractTag(html, "title");
|
|
173
|
+
const titleLength = title ? title.length : 0;
|
|
174
|
+
let titleScore = 0;
|
|
175
|
+
const titleIssues = [];
|
|
176
|
+
if (!title) {
|
|
177
|
+
titleIssues.push("Missing title tag");
|
|
178
|
+
recommendations.push("Add a <title> tag — it is the most important on-page SEO element.");
|
|
179
|
+
}
|
|
180
|
+
else {
|
|
181
|
+
if (titleLength < 30) {
|
|
182
|
+
titleIssues.push("Title too short (< 30 chars)");
|
|
183
|
+
recommendations.push("Lengthen the title to at least 30 characters for better CTR.");
|
|
184
|
+
}
|
|
185
|
+
else if (titleLength > 60) {
|
|
186
|
+
titleIssues.push("Title too long (> 60 chars) — may be truncated in SERPs");
|
|
187
|
+
recommendations.push("Shorten the title to 60 characters or fewer to avoid truncation.");
|
|
188
|
+
}
|
|
189
|
+
titleScore = titleLength >= 30 && titleLength <= 60 ? 100 : titleLength > 0 ? 60 : 0;
|
|
190
|
+
}
|
|
191
|
+
// ── Meta description ──
|
|
192
|
+
const metaDesc = extractMeta(html, "description");
|
|
193
|
+
const metaDescLength = metaDesc ? metaDesc.length : 0;
|
|
194
|
+
let metaDescScore = 0;
|
|
195
|
+
const metaDescIssues = [];
|
|
196
|
+
if (!metaDesc) {
|
|
197
|
+
metaDescIssues.push("Missing meta description");
|
|
198
|
+
recommendations.push("Add a meta description — it directly affects click-through rates.");
|
|
199
|
+
}
|
|
200
|
+
else {
|
|
201
|
+
if (metaDescLength < 70) {
|
|
202
|
+
metaDescIssues.push("Meta description too short (< 70 chars)");
|
|
203
|
+
recommendations.push("Expand the meta description to at least 70 characters.");
|
|
204
|
+
}
|
|
205
|
+
else if (metaDescLength > 160) {
|
|
206
|
+
metaDescIssues.push("Meta description too long (> 160 chars)");
|
|
207
|
+
recommendations.push("Trim the meta description to 160 characters or fewer.");
|
|
208
|
+
}
|
|
209
|
+
metaDescScore = metaDescLength >= 70 && metaDescLength <= 160 ? 100 : metaDescLength > 0 ? 60 : 0;
|
|
210
|
+
}
|
|
211
|
+
// ── Open Graph ──
|
|
212
|
+
const ogTitle = extractMeta(html, "og:title");
|
|
213
|
+
const ogDescription = extractMeta(html, "og:description");
|
|
214
|
+
const ogImage = extractMeta(html, "og:image");
|
|
215
|
+
let ogScore = 0;
|
|
216
|
+
const ogIssues = [];
|
|
217
|
+
if (!ogTitle)
|
|
218
|
+
ogIssues.push("Missing og:title");
|
|
219
|
+
if (!ogDescription)
|
|
220
|
+
ogIssues.push("Missing og:description");
|
|
221
|
+
if (!ogImage)
|
|
222
|
+
ogIssues.push("Missing og:image");
|
|
223
|
+
ogScore = Math.round(([ogTitle, ogDescription, ogImage].filter(Boolean).length / 3) * 100);
|
|
224
|
+
if (ogScore < 100) {
|
|
225
|
+
recommendations.push(`Add missing Open Graph tags: ${ogIssues.join(", ")}.`);
|
|
226
|
+
}
|
|
227
|
+
// ── Headings ──
|
|
228
|
+
const headings = countHeadings(html);
|
|
229
|
+
let headingScore = 100;
|
|
230
|
+
const headingIssues = [];
|
|
231
|
+
if (headings.h1 === 0) {
|
|
232
|
+
headingIssues.push("No H1 tag found");
|
|
233
|
+
headingScore -= 40;
|
|
234
|
+
recommendations.push("Add exactly one H1 tag — it signals the main topic to search engines.");
|
|
235
|
+
}
|
|
236
|
+
else if (headings.h1 > 1) {
|
|
237
|
+
headingIssues.push(`Multiple H1 tags (${headings.h1}) — use exactly one`);
|
|
238
|
+
headingScore -= 20;
|
|
239
|
+
recommendations.push("Reduce to a single H1 tag for clearer content hierarchy.");
|
|
240
|
+
}
|
|
241
|
+
if (headings.h2 === 0) {
|
|
242
|
+
headingIssues.push("No H2 tags — add subheadings for structure");
|
|
243
|
+
headingScore -= 20;
|
|
244
|
+
recommendations.push("Add H2 subheadings to improve content structure and scannability.");
|
|
245
|
+
}
|
|
246
|
+
headingScore = Math.max(0, headingScore);
|
|
247
|
+
// ── Images ──
|
|
248
|
+
const images = countImages(html);
|
|
249
|
+
let imageScore = 100;
|
|
250
|
+
const imageIssues = [];
|
|
251
|
+
if (images.total > 0 && images.withoutAlt > 0) {
|
|
252
|
+
const pct = Math.round((images.withoutAlt / images.total) * 100);
|
|
253
|
+
imageIssues.push(`${images.withoutAlt}/${images.total} images missing alt text (${pct}%)`);
|
|
254
|
+
imageScore = Math.max(0, Math.round(100 - pct));
|
|
255
|
+
recommendations.push(`Add alt text to ${images.withoutAlt} image(s) for accessibility and image SEO.`);
|
|
256
|
+
}
|
|
257
|
+
// ── Canonical ──
|
|
258
|
+
const canonical = extractLinkRel(html, "canonical");
|
|
259
|
+
let canonicalScore = canonical ? 100 : 0;
|
|
260
|
+
const canonicalIssues = [];
|
|
261
|
+
if (!canonical) {
|
|
262
|
+
canonicalIssues.push("Missing canonical URL");
|
|
263
|
+
canonicalScore = 0;
|
|
264
|
+
recommendations.push("Add a <link rel=\"canonical\"> tag to prevent duplicate content issues.");
|
|
265
|
+
}
|
|
266
|
+
// ── Robots meta ──
|
|
267
|
+
const robotsMeta = extractMeta(html, "robots");
|
|
268
|
+
let robotsScore = 100;
|
|
269
|
+
const robotsIssues = [];
|
|
270
|
+
if (!robotsMeta) {
|
|
271
|
+
robotsIssues.push("No robots meta tag (defaults to index, follow)");
|
|
272
|
+
robotsScore = 80; // Not strictly required
|
|
273
|
+
}
|
|
274
|
+
else if (robotsMeta.toLowerCase().includes("noindex")) {
|
|
275
|
+
robotsIssues.push("Page is set to noindex — will not appear in search results");
|
|
276
|
+
robotsScore = 20;
|
|
277
|
+
recommendations.push("Remove noindex directive if this page should be indexed.");
|
|
278
|
+
}
|
|
279
|
+
// ── Structured data ──
|
|
280
|
+
const hasJsonLd = /<script\s+type\s*=\s*["']application\/ld\+json["'][^>]*>/i.test(html);
|
|
281
|
+
let structuredDataScore = hasJsonLd ? 100 : 0;
|
|
282
|
+
const structuredDataIssues = [];
|
|
283
|
+
if (!hasJsonLd) {
|
|
284
|
+
structuredDataIssues.push("No JSON-LD structured data found");
|
|
285
|
+
structuredDataScore = 0;
|
|
286
|
+
recommendations.push("Add JSON-LD structured data for rich snippets in search results.");
|
|
287
|
+
}
|
|
288
|
+
// ── Total score ──
|
|
289
|
+
const weights = {
|
|
290
|
+
title: 20,
|
|
291
|
+
metaDescription: 15,
|
|
292
|
+
openGraph: 10,
|
|
293
|
+
headings: 15,
|
|
294
|
+
images: 10,
|
|
295
|
+
canonical: 10,
|
|
296
|
+
robots: 10,
|
|
297
|
+
structuredData: 10,
|
|
298
|
+
};
|
|
299
|
+
const totalScore = Math.round((titleScore * weights.title +
|
|
300
|
+
metaDescScore * weights.metaDescription +
|
|
301
|
+
ogScore * weights.openGraph +
|
|
302
|
+
headingScore * weights.headings +
|
|
303
|
+
imageScore * weights.images +
|
|
304
|
+
canonicalScore * weights.canonical +
|
|
305
|
+
robotsScore * weights.robots +
|
|
306
|
+
structuredDataScore * weights.structuredData) /
|
|
307
|
+
Object.values(weights).reduce((a, b) => a + b, 0));
|
|
308
|
+
return {
|
|
309
|
+
url,
|
|
310
|
+
score: totalScore,
|
|
311
|
+
elements: {
|
|
312
|
+
title: { content: title, length: titleLength, score: titleScore, issues: titleIssues },
|
|
313
|
+
metaDescription: { content: metaDesc, length: metaDescLength, score: metaDescScore, issues: metaDescIssues },
|
|
314
|
+
openGraph: {
|
|
315
|
+
ogTitle,
|
|
316
|
+
ogDescription,
|
|
317
|
+
ogImage,
|
|
318
|
+
score: ogScore,
|
|
319
|
+
issues: ogIssues,
|
|
320
|
+
},
|
|
321
|
+
headings: { counts: headings, score: headingScore, issues: headingIssues },
|
|
322
|
+
images: { total: images.total, withoutAlt: images.withoutAlt, score: imageScore, issues: imageIssues },
|
|
323
|
+
canonical: { url: canonical, score: canonicalScore, issues: canonicalIssues },
|
|
324
|
+
robots: { content: robotsMeta, score: robotsScore, issues: robotsIssues },
|
|
325
|
+
structuredData: { hasJsonLd, score: structuredDataScore, issues: structuredDataIssues },
|
|
326
|
+
},
|
|
327
|
+
recommendations,
|
|
328
|
+
};
|
|
329
|
+
},
|
|
330
|
+
},
|
|
331
|
+
{
|
|
332
|
+
name: "check_page_performance",
|
|
333
|
+
description: "Lightweight page performance check via HTTP fetch (no browser). Measures: response time, content size, compression (content-encoding header), cache headers (cache-control, etag, last-modified), and HTTP status. Returns a performance score with recommendations for improvement.",
|
|
334
|
+
inputSchema: {
|
|
335
|
+
type: "object",
|
|
336
|
+
properties: {
|
|
337
|
+
url: { type: "string", description: "The URL to check" },
|
|
338
|
+
},
|
|
339
|
+
required: ["url"],
|
|
340
|
+
},
|
|
341
|
+
handler: async (args) => {
|
|
342
|
+
const url = args.url;
|
|
343
|
+
const recommendations = [];
|
|
344
|
+
const startTime = Date.now();
|
|
345
|
+
const res = await safeFetch(url);
|
|
346
|
+
const responseTimeMs = Date.now() - startTime;
|
|
347
|
+
if (!res.ok) {
|
|
348
|
+
return {
|
|
349
|
+
error: true,
|
|
350
|
+
url,
|
|
351
|
+
status: res.status,
|
|
352
|
+
responseTimeMs,
|
|
353
|
+
message: res.error || `HTTP ${res.status}`,
|
|
354
|
+
};
|
|
355
|
+
}
|
|
356
|
+
const contentSizeBytes = new TextEncoder().encode(res.text).length;
|
|
357
|
+
const contentEncoding = res.headers.get("content-encoding");
|
|
358
|
+
const compressed = !!contentEncoding;
|
|
359
|
+
const cacheControl = res.headers.get("cache-control");
|
|
360
|
+
const etag = res.headers.get("etag");
|
|
361
|
+
const lastModified = res.headers.get("last-modified");
|
|
362
|
+
// ── Scoring ──
|
|
363
|
+
let score = 100;
|
|
364
|
+
// Response time scoring
|
|
365
|
+
if (responseTimeMs > 3000) {
|
|
366
|
+
score -= 30;
|
|
367
|
+
recommendations.push(`Response time is ${responseTimeMs}ms — aim for under 1000ms. Consider CDN, caching, or server optimization.`);
|
|
368
|
+
}
|
|
369
|
+
else if (responseTimeMs > 1500) {
|
|
370
|
+
score -= 15;
|
|
371
|
+
recommendations.push(`Response time is ${responseTimeMs}ms — aim for under 1000ms.`);
|
|
372
|
+
}
|
|
373
|
+
else if (responseTimeMs > 1000) {
|
|
374
|
+
score -= 5;
|
|
375
|
+
}
|
|
376
|
+
// Content size scoring
|
|
377
|
+
if (contentSizeBytes > 500_000) {
|
|
378
|
+
score -= 20;
|
|
379
|
+
recommendations.push(`Page size is ${Math.round(contentSizeBytes / 1024)}KB — consider reducing HTML, inlining less CSS/JS, or lazy-loading assets.`);
|
|
380
|
+
}
|
|
381
|
+
else if (contentSizeBytes > 200_000) {
|
|
382
|
+
score -= 10;
|
|
383
|
+
recommendations.push(`Page size is ${Math.round(contentSizeBytes / 1024)}KB — could be smaller.`);
|
|
384
|
+
}
|
|
385
|
+
// Compression scoring
|
|
386
|
+
if (!compressed) {
|
|
387
|
+
score -= 15;
|
|
388
|
+
recommendations.push("Enable gzip or brotli compression — the response has no content-encoding header.");
|
|
389
|
+
}
|
|
390
|
+
// Cache scoring
|
|
391
|
+
if (!cacheControl) {
|
|
392
|
+
score -= 10;
|
|
393
|
+
recommendations.push("Add Cache-Control headers to improve repeat visit performance.");
|
|
394
|
+
}
|
|
395
|
+
else if (cacheControl.includes("no-cache") || cacheControl.includes("no-store")) {
|
|
396
|
+
score -= 5;
|
|
397
|
+
recommendations.push("Cache-Control disables caching. Consider allowing caching for static assets.");
|
|
398
|
+
}
|
|
399
|
+
if (!etag && !lastModified) {
|
|
400
|
+
score -= 5;
|
|
401
|
+
recommendations.push("Add ETag or Last-Modified headers to enable conditional requests (304 responses).");
|
|
402
|
+
}
|
|
403
|
+
score = Math.max(0, score);
|
|
404
|
+
return {
|
|
405
|
+
url,
|
|
406
|
+
responseTimeMs,
|
|
407
|
+
contentSizeBytes,
|
|
408
|
+
contentSizeKB: Math.round(contentSizeBytes / 1024),
|
|
409
|
+
compressed,
|
|
410
|
+
contentEncoding: contentEncoding || "none",
|
|
411
|
+
cacheHeaders: {
|
|
412
|
+
cacheControl: cacheControl || null,
|
|
413
|
+
etag: etag || null,
|
|
414
|
+
lastModified: lastModified || null,
|
|
415
|
+
},
|
|
416
|
+
httpStatus: res.status,
|
|
417
|
+
score,
|
|
418
|
+
recommendations,
|
|
419
|
+
};
|
|
420
|
+
},
|
|
421
|
+
},
|
|
422
|
+
{
|
|
423
|
+
name: "analyze_seo_content",
|
|
424
|
+
description: "Analyze HTML or text content for SEO quality: word count, sentence count, paragraph count, Flesch-Kincaid readability score, heading structure, keyword density (if targetKeyword provided), and internal vs external link ratio. Works on raw HTML or plain text. No network requests — pure content analysis.",
|
|
425
|
+
inputSchema: {
|
|
426
|
+
type: "object",
|
|
427
|
+
properties: {
|
|
428
|
+
content: { type: "string", description: "HTML or plain text content to analyze" },
|
|
429
|
+
targetKeyword: {
|
|
430
|
+
type: "string",
|
|
431
|
+
description: "Target keyword/phrase to measure density for (optional)",
|
|
432
|
+
},
|
|
433
|
+
},
|
|
434
|
+
required: ["content"],
|
|
435
|
+
},
|
|
436
|
+
handler: async (args) => {
|
|
437
|
+
const content = args.content;
|
|
438
|
+
const targetKeyword = args.targetKeyword;
|
|
439
|
+
const recommendations = [];
|
|
440
|
+
// Strip HTML for text analysis
|
|
441
|
+
const plainText = stripHtml(content);
|
|
442
|
+
const words = plainText.split(/\s+/).filter((w) => w.length > 0);
|
|
443
|
+
const wordCount = words.length;
|
|
444
|
+
// Sentence count — split on sentence-ending punctuation
|
|
445
|
+
const sentences = plainText.split(/[.!?]+/).filter((s) => s.trim().length > 0);
|
|
446
|
+
const sentenceCount = sentences.length;
|
|
447
|
+
// Paragraph count — split on double newlines or <p> tags
|
|
448
|
+
const isHtml = /<[a-z][\s\S]*>/i.test(content);
|
|
449
|
+
let paragraphCount;
|
|
450
|
+
if (isHtml) {
|
|
451
|
+
const pTags = content.match(/<p[\s>]/gi);
|
|
452
|
+
paragraphCount = pTags ? pTags.length : 1;
|
|
453
|
+
}
|
|
454
|
+
else {
|
|
455
|
+
const paras = content.split(/\n\s*\n/).filter((p) => p.trim().length > 0);
|
|
456
|
+
paragraphCount = paras.length;
|
|
457
|
+
}
|
|
458
|
+
// Syllable count and readability
|
|
459
|
+
const totalSyllables = words.reduce((sum, w) => sum + countSyllables(w), 0);
|
|
460
|
+
const rawReadability = fleschKincaidScore(wordCount, sentenceCount, totalSyllables);
|
|
461
|
+
const readabilityScore = Math.round(Math.max(0, Math.min(100, rawReadability)));
|
|
462
|
+
const level = readabilityLevel(readabilityScore);
|
|
463
|
+
// Heading structure
|
|
464
|
+
const headingStructure = { h1: 0, h2: 0, h3: 0, h4: 0, h5: 0, h6: 0 };
|
|
465
|
+
if (isHtml) {
|
|
466
|
+
const htmlHeadings = countHeadings(content);
|
|
467
|
+
Object.assign(headingStructure, htmlHeadings);
|
|
468
|
+
}
|
|
469
|
+
else {
|
|
470
|
+
// Markdown headings
|
|
471
|
+
const lines = content.split("\n");
|
|
472
|
+
for (const line of lines) {
|
|
473
|
+
const match = line.match(/^(#{1,6})\s/);
|
|
474
|
+
if (match) {
|
|
475
|
+
const level = `h${match[1].length}`;
|
|
476
|
+
headingStructure[level] = (headingStructure[level] || 0) + 1;
|
|
477
|
+
}
|
|
478
|
+
}
|
|
479
|
+
}
|
|
480
|
+
// Keyword density
|
|
481
|
+
let keywordDensity;
|
|
482
|
+
if (targetKeyword && wordCount > 0) {
|
|
483
|
+
const kwLower = targetKeyword.toLowerCase();
|
|
484
|
+
const textLower = plainText.toLowerCase();
|
|
485
|
+
let kwCount = 0;
|
|
486
|
+
let searchPos = 0;
|
|
487
|
+
while (true) {
|
|
488
|
+
const idx = textLower.indexOf(kwLower, searchPos);
|
|
489
|
+
if (idx === -1)
|
|
490
|
+
break;
|
|
491
|
+
kwCount++;
|
|
492
|
+
searchPos = idx + 1;
|
|
493
|
+
}
|
|
494
|
+
const density = Math.round((kwCount / wordCount) * 10000) / 100; // percentage with 2 decimals
|
|
495
|
+
let kwRec = "";
|
|
496
|
+
if (density === 0) {
|
|
497
|
+
kwRec = `Target keyword "${targetKeyword}" not found — include it naturally in the content.`;
|
|
498
|
+
recommendations.push(kwRec);
|
|
499
|
+
}
|
|
500
|
+
else if (density < 0.5) {
|
|
501
|
+
kwRec = `Keyword density is low (${density}%) — consider adding more natural mentions.`;
|
|
502
|
+
recommendations.push(kwRec);
|
|
503
|
+
}
|
|
504
|
+
else if (density > 3) {
|
|
505
|
+
kwRec = `Keyword density is high (${density}%) — risk of keyword stuffing. Reduce mentions.`;
|
|
506
|
+
recommendations.push(kwRec);
|
|
507
|
+
}
|
|
508
|
+
else {
|
|
509
|
+
kwRec = `Keyword density of ${density}% is in the optimal range (0.5-3%).`;
|
|
510
|
+
}
|
|
511
|
+
keywordDensity = { keyword: targetKeyword, count: kwCount, density, recommendation: kwRec };
|
|
512
|
+
}
|
|
513
|
+
// Link analysis
|
|
514
|
+
const linkRegex = /<a\s[^>]*href\s*=\s*["']([^"']*)["'][^>]*>/gi;
|
|
515
|
+
let linkMatch;
|
|
516
|
+
let internalLinks = 0;
|
|
517
|
+
let externalLinks = 0;
|
|
518
|
+
while ((linkMatch = linkRegex.exec(content)) !== null) {
|
|
519
|
+
const href = linkMatch[1];
|
|
520
|
+
if (href.startsWith("http://") || href.startsWith("https://") || href.startsWith("//")) {
|
|
521
|
+
externalLinks++;
|
|
522
|
+
}
|
|
523
|
+
else if (href.startsWith("#") || href.startsWith("/") || href.startsWith("./") || href.startsWith("../")) {
|
|
524
|
+
internalLinks++;
|
|
525
|
+
}
|
|
526
|
+
else {
|
|
527
|
+
// Relative link without prefix — treat as internal
|
|
528
|
+
internalLinks++;
|
|
529
|
+
}
|
|
530
|
+
}
|
|
531
|
+
const linkRatio = externalLinks > 0 ? Math.round((internalLinks / externalLinks) * 100) / 100 : internalLinks > 0 ? Infinity : 0;
|
|
532
|
+
// Content-level recommendations
|
|
533
|
+
if (wordCount < 300) {
|
|
534
|
+
recommendations.push("Content is thin (< 300 words). Aim for 800+ words for competitive SEO.");
|
|
535
|
+
}
|
|
536
|
+
else if (wordCount < 800) {
|
|
537
|
+
recommendations.push("Content length is moderate. Consider expanding to 1000+ words for long-tail keyword coverage.");
|
|
538
|
+
}
|
|
539
|
+
if (readabilityScore < 50) {
|
|
540
|
+
recommendations.push("Content is difficult to read. Simplify sentences and use shorter words.");
|
|
541
|
+
}
|
|
542
|
+
if (headingStructure.h1 === 0 && isHtml) {
|
|
543
|
+
recommendations.push("Add an H1 heading to establish the main topic.");
|
|
544
|
+
}
|
|
545
|
+
if (sentenceCount > 0 && wordCount / sentenceCount > 25) {
|
|
546
|
+
recommendations.push("Average sentence length is high. Break long sentences for better readability.");
|
|
547
|
+
}
|
|
548
|
+
return {
|
|
549
|
+
wordCount,
|
|
550
|
+
sentenceCount,
|
|
551
|
+
paragraphCount,
|
|
552
|
+
avgWordsPerSentence: sentenceCount > 0 ? Math.round((wordCount / sentenceCount) * 10) / 10 : 0,
|
|
553
|
+
totalSyllables,
|
|
554
|
+
readabilityScore,
|
|
555
|
+
readabilityLevel: level,
|
|
556
|
+
headingStructure,
|
|
557
|
+
keywordDensity,
|
|
558
|
+
linkAnalysis: {
|
|
559
|
+
internal: internalLinks,
|
|
560
|
+
external: externalLinks,
|
|
561
|
+
ratio: linkRatio,
|
|
562
|
+
},
|
|
563
|
+
recommendations,
|
|
564
|
+
};
|
|
565
|
+
},
|
|
566
|
+
},
|
|
567
|
+
{
|
|
568
|
+
name: "check_wordpress_site",
|
|
569
|
+
description: "Detect whether a site runs WordPress and assess its security posture. Checks: WP generator meta tag, wp-content/wp-includes paths, WP REST API (/wp-json/), login page (/wp-login.php), XML-RPC (/xmlrpc.php), active theme, and visible plugins. Returns WordPress detection confidence, theme info, plugin list, and security score with risk assessments.",
|
|
570
|
+
inputSchema: {
|
|
571
|
+
type: "object",
|
|
572
|
+
properties: {
|
|
573
|
+
url: { type: "string", description: "The URL of the site to check" },
|
|
574
|
+
timeout: {
|
|
575
|
+
type: "number",
|
|
576
|
+
description: "Request timeout in milliseconds (default: 10000)",
|
|
577
|
+
},
|
|
578
|
+
},
|
|
579
|
+
required: ["url"],
|
|
580
|
+
},
|
|
581
|
+
handler: async (args) => {
|
|
582
|
+
const rawUrl = args.url.replace(/\/$/, "");
|
|
583
|
+
const timeoutMs = args.timeout ?? DEFAULT_TIMEOUT_MS;
|
|
584
|
+
const recommendations = [];
|
|
585
|
+
// Fetch main page
|
|
586
|
+
const mainRes = await safeFetch(rawUrl, timeoutMs);
|
|
587
|
+
if (!mainRes.ok) {
|
|
588
|
+
return {
|
|
589
|
+
error: true,
|
|
590
|
+
url: rawUrl,
|
|
591
|
+
status: mainRes.status,
|
|
592
|
+
message: mainRes.error || `HTTP ${mainRes.status}`,
|
|
593
|
+
};
|
|
594
|
+
}
|
|
595
|
+
const html = mainRes.text;
|
|
596
|
+
// ── WordPress detection signals ──
|
|
597
|
+
const signals = [];
|
|
598
|
+
// Generator meta tag
|
|
599
|
+
const generator = extractMeta(html, "generator");
|
|
600
|
+
let version = null;
|
|
601
|
+
if (generator && /wordpress/i.test(generator)) {
|
|
602
|
+
signals.push("generator meta tag");
|
|
603
|
+
const versionMatch = generator.match(/WordPress\s+([\d.]+)/i);
|
|
604
|
+
if (versionMatch)
|
|
605
|
+
version = versionMatch[1];
|
|
606
|
+
}
|
|
607
|
+
// wp-content / wp-includes in HTML
|
|
608
|
+
if (/\/wp-content\//i.test(html))
|
|
609
|
+
signals.push("wp-content path in HTML");
|
|
610
|
+
if (/\/wp-includes\//i.test(html))
|
|
611
|
+
signals.push("wp-includes path in HTML");
|
|
612
|
+
const isWordPress = signals.length > 0;
|
|
613
|
+
// ── Theme detection ──
|
|
614
|
+
let theme = null;
|
|
615
|
+
const themeMatch = html.match(/\/wp-content\/themes\/([a-zA-Z0-9_-]+)\//i);
|
|
616
|
+
if (themeMatch)
|
|
617
|
+
theme = themeMatch[1];
|
|
618
|
+
// ── Plugin detection from HTML ──
|
|
619
|
+
const pluginSet = new Set();
|
|
620
|
+
const pluginRegex = /\/wp-content\/plugins\/([a-zA-Z0-9_-]+)\//gi;
|
|
621
|
+
let pluginMatch;
|
|
622
|
+
while ((pluginMatch = pluginRegex.exec(html)) !== null) {
|
|
623
|
+
pluginSet.add(pluginMatch[1]);
|
|
624
|
+
}
|
|
625
|
+
const plugins = Array.from(pluginSet);
|
|
626
|
+
// ── Security checks (only if WordPress detected) ──
|
|
627
|
+
let wpJsonExposed = false;
|
|
628
|
+
let loginExposed = false;
|
|
629
|
+
let xmlrpcExposed = false;
|
|
630
|
+
const risks = [];
|
|
631
|
+
if (isWordPress) {
|
|
632
|
+
// Check /wp-json/
|
|
633
|
+
try {
|
|
634
|
+
const wpJsonRes = await safeFetch(`${rawUrl}/wp-json/`, timeoutMs);
|
|
635
|
+
if (wpJsonRes.ok) {
|
|
636
|
+
wpJsonExposed = true;
|
|
637
|
+
risks.push("WP REST API is publicly accessible — may expose user data and site structure.");
|
|
638
|
+
recommendations.push("Restrict WP REST API access to authenticated users or disable unused endpoints.");
|
|
639
|
+
}
|
|
640
|
+
}
|
|
641
|
+
catch { /* ignore */ }
|
|
642
|
+
// Check /wp-login.php
|
|
643
|
+
try {
|
|
644
|
+
const loginRes = await safeFetch(`${rawUrl}/wp-login.php`, timeoutMs);
|
|
645
|
+
if (loginRes.ok || loginRes.status === 200) {
|
|
646
|
+
loginExposed = true;
|
|
647
|
+
risks.push("Login page is publicly accessible — susceptible to brute force attacks.");
|
|
648
|
+
recommendations.push("Move or protect wp-login.php with IP allowlisting, 2FA, or a security plugin.");
|
|
649
|
+
}
|
|
650
|
+
}
|
|
651
|
+
catch { /* ignore */ }
|
|
652
|
+
// Check /xmlrpc.php
|
|
653
|
+
try {
|
|
654
|
+
const xmlrpcRes = await safeFetch(`${rawUrl}/xmlrpc.php`, timeoutMs);
|
|
655
|
+
if (xmlrpcRes.ok || xmlrpcRes.status === 405) {
|
|
656
|
+
xmlrpcExposed = true;
|
|
657
|
+
risks.push("XML-RPC is exposed — enables brute force amplification and DDoS pingback attacks.");
|
|
658
|
+
recommendations.push("Disable XML-RPC if not needed (block via .htaccess or security plugin).");
|
|
659
|
+
}
|
|
660
|
+
}
|
|
661
|
+
catch { /* ignore */ }
|
|
662
|
+
// Version-based recommendation
|
|
663
|
+
if (version) {
|
|
664
|
+
recommendations.push(`WordPress version ${version} detected in source. Remove the generator meta tag to hide version info.`);
|
|
665
|
+
}
|
|
666
|
+
if (plugins.length > 10) {
|
|
667
|
+
recommendations.push(`${plugins.length} plugins detected — each adds attack surface. Audit and remove unused plugins.`);
|
|
668
|
+
}
|
|
669
|
+
}
|
|
670
|
+
// Security score
|
|
671
|
+
let securityScore = 100;
|
|
672
|
+
if (wpJsonExposed)
|
|
673
|
+
securityScore -= 20;
|
|
674
|
+
if (loginExposed)
|
|
675
|
+
securityScore -= 25;
|
|
676
|
+
if (xmlrpcExposed)
|
|
677
|
+
securityScore -= 30;
|
|
678
|
+
if (version)
|
|
679
|
+
securityScore -= 10; // Version leak
|
|
680
|
+
securityScore = Math.max(0, securityScore);
|
|
681
|
+
return {
|
|
682
|
+
url: rawUrl,
|
|
683
|
+
isWordPress,
|
|
684
|
+
detectionSignals: signals,
|
|
685
|
+
version,
|
|
686
|
+
theme,
|
|
687
|
+
plugins,
|
|
688
|
+
pluginCount: plugins.length,
|
|
689
|
+
security: {
|
|
690
|
+
wpJsonExposed,
|
|
691
|
+
loginExposed,
|
|
692
|
+
xmlrpcExposed,
|
|
693
|
+
score: securityScore,
|
|
694
|
+
risks,
|
|
695
|
+
},
|
|
696
|
+
recommendations: isWordPress
|
|
697
|
+
? recommendations
|
|
698
|
+
: ["Site does not appear to be WordPress — no WP-specific recommendations."],
|
|
699
|
+
};
|
|
700
|
+
},
|
|
701
|
+
},
|
|
702
|
+
{
|
|
703
|
+
name: "scan_wordpress_updates",
|
|
704
|
+
description: "Scan a WordPress site for plugin and theme versions, and optionally check for known vulnerabilities via the WPScan API. Detects plugins/themes from page source and extracts versions from ?ver= query params. If wpscanApiToken is provided, queries the WPScan vulnerability database for each detected plugin.",
|
|
705
|
+
inputSchema: {
|
|
706
|
+
type: "object",
|
|
707
|
+
properties: {
|
|
708
|
+
url: { type: "string", description: "The WordPress site URL to scan" },
|
|
709
|
+
wpscanApiToken: {
|
|
710
|
+
type: "string",
|
|
711
|
+
description: "WPScan API token for vulnerability lookups (optional — get one free at https://wpscan.com)",
|
|
712
|
+
},
|
|
713
|
+
},
|
|
714
|
+
required: ["url"],
|
|
715
|
+
},
|
|
716
|
+
handler: async (args) => {
|
|
717
|
+
const rawUrl = args.url.replace(/\/$/, "");
|
|
718
|
+
const wpscanApiToken = args.wpscanApiToken;
|
|
719
|
+
const recommendations = [];
|
|
720
|
+
// Fetch main page
|
|
721
|
+
const mainRes = await safeFetch(rawUrl);
|
|
722
|
+
if (!mainRes.ok) {
|
|
723
|
+
return {
|
|
724
|
+
error: true,
|
|
725
|
+
url: rawUrl,
|
|
726
|
+
status: mainRes.status,
|
|
727
|
+
message: mainRes.error || `HTTP ${mainRes.status}`,
|
|
728
|
+
};
|
|
729
|
+
}
|
|
730
|
+
const html = mainRes.text;
|
|
731
|
+
// ── Detect plugins with versions ──
|
|
732
|
+
const pluginMap = new Map();
|
|
733
|
+
const pluginRegex = /\/wp-content\/plugins\/([a-zA-Z0-9_-]+)\/[^"']*?(?:\?ver=([0-9.]+))?/gi;
|
|
734
|
+
let match;
|
|
735
|
+
while ((match = pluginRegex.exec(html)) !== null) {
|
|
736
|
+
const name = match[1];
|
|
737
|
+
const ver = match[2] || null;
|
|
738
|
+
// Keep the version if we found one (don't overwrite version with null)
|
|
739
|
+
if (!pluginMap.has(name) || (ver && !pluginMap.get(name))) {
|
|
740
|
+
pluginMap.set(name, ver);
|
|
741
|
+
}
|
|
742
|
+
}
|
|
743
|
+
// Also try to extract versions from generic script/style ver params
|
|
744
|
+
const verRegex = /\/wp-content\/plugins\/([a-zA-Z0-9_-]+)\/[^"'\s]*["']/gi;
|
|
745
|
+
while ((match = verRegex.exec(html)) !== null) {
|
|
746
|
+
const name = match[1];
|
|
747
|
+
if (!pluginMap.has(name)) {
|
|
748
|
+
pluginMap.set(name, null);
|
|
749
|
+
}
|
|
750
|
+
}
|
|
751
|
+
// ── Detect themes with versions ──
|
|
752
|
+
const themeMap = new Map();
|
|
753
|
+
const themeRegex = /\/wp-content\/themes\/([a-zA-Z0-9_-]+)\/[^"']*?(?:\?ver=([0-9.]+))?/gi;
|
|
754
|
+
while ((match = themeRegex.exec(html)) !== null) {
|
|
755
|
+
const name = match[1];
|
|
756
|
+
const ver = match[2] || null;
|
|
757
|
+
if (!themeMap.has(name) || (ver && !themeMap.get(name))) {
|
|
758
|
+
themeMap.set(name, ver);
|
|
759
|
+
}
|
|
760
|
+
}
|
|
761
|
+
// ── Try WP REST API for plugin info (usually requires auth) ──
|
|
762
|
+
try {
|
|
763
|
+
const wpPluginsRes = await safeFetch(`${rawUrl}/wp-json/wp/v2/plugins`);
|
|
764
|
+
if (wpPluginsRes.ok) {
|
|
765
|
+
try {
|
|
766
|
+
const pluginsData = JSON.parse(wpPluginsRes.text);
|
|
767
|
+
if (Array.isArray(pluginsData)) {
|
|
768
|
+
for (const p of pluginsData) {
|
|
769
|
+
const slug = p.plugin?.split("/")?.[0] || p.textdomain;
|
|
770
|
+
if (slug) {
|
|
771
|
+
pluginMap.set(slug, p.version || pluginMap.get(slug) || null);
|
|
772
|
+
}
|
|
773
|
+
}
|
|
774
|
+
}
|
|
775
|
+
}
|
|
776
|
+
catch { /* JSON parse failed */ }
|
|
777
|
+
}
|
|
778
|
+
}
|
|
779
|
+
catch { /* ignore */ }
|
|
780
|
+
// ── WPScan vulnerability lookup ──
|
|
781
|
+
const pluginsResult = [];
|
|
782
|
+
let totalVulnerabilities = 0;
|
|
783
|
+
const pluginEntries = Array.from(pluginMap.entries());
|
|
784
|
+
for (let pi = 0; pi < pluginEntries.length; pi++) {
|
|
785
|
+
const name = pluginEntries[pi][0];
|
|
786
|
+
const version = pluginEntries[pi][1];
|
|
787
|
+
const entry = { name, version };
|
|
788
|
+
if (wpscanApiToken) {
|
|
789
|
+
try {
|
|
790
|
+
const wpscanRes = await safeFetch(`https://wpscan.com/api/v3/plugins/${name}`, DEFAULT_TIMEOUT_MS, { headers: { Authorization: `Token token=${wpscanApiToken}` } });
|
|
791
|
+
if (wpscanRes.ok) {
|
|
792
|
+
try {
|
|
793
|
+
const data = JSON.parse(wpscanRes.text);
|
|
794
|
+
const pluginData = data[name];
|
|
795
|
+
if (pluginData?.vulnerabilities) {
|
|
796
|
+
entry.vulnerabilities = pluginData.vulnerabilities.map((v) => ({
|
|
797
|
+
title: v.title || "Unknown vulnerability",
|
|
798
|
+
fixedIn: v.fixed_in || null,
|
|
799
|
+
severity: v.cvss?.severity || null,
|
|
800
|
+
}));
|
|
801
|
+
// Filter to only show vulns affecting the detected version
|
|
802
|
+
if (version && entry.vulnerabilities) {
|
|
803
|
+
const relevant = entry.vulnerabilities.filter((v) => !v.fixedIn || compareVersions(version, v.fixedIn) < 0);
|
|
804
|
+
totalVulnerabilities += relevant.length;
|
|
805
|
+
entry.vulnerabilities = relevant;
|
|
806
|
+
}
|
|
807
|
+
else {
|
|
808
|
+
totalVulnerabilities += entry.vulnerabilities?.length ?? 0;
|
|
809
|
+
}
|
|
810
|
+
}
|
|
811
|
+
}
|
|
812
|
+
catch { /* JSON parse failed */ }
|
|
813
|
+
}
|
|
814
|
+
}
|
|
815
|
+
catch { /* ignore */ }
|
|
816
|
+
}
|
|
817
|
+
pluginsResult.push(entry);
|
|
818
|
+
}
|
|
819
|
+
const themesResult = [];
|
|
820
|
+
const themeEntries = Array.from(themeMap.entries());
|
|
821
|
+
for (let ti = 0; ti < themeEntries.length; ti++) {
|
|
822
|
+
themesResult.push({ name: themeEntries[ti][0], version: themeEntries[ti][1] });
|
|
823
|
+
}
|
|
824
|
+
// ── Recommendations ──
|
|
825
|
+
const noVersionPlugins = pluginsResult.filter((p) => !p.version);
|
|
826
|
+
if (noVersionPlugins.length > 0) {
|
|
827
|
+
recommendations.push(`${noVersionPlugins.length} plugin(s) have unknown versions — cannot verify if they are up to date.`);
|
|
828
|
+
}
|
|
829
|
+
if (totalVulnerabilities > 0) {
|
|
830
|
+
recommendations.push(`${totalVulnerabilities} known vulnerabilities found. Update affected plugins immediately.`);
|
|
831
|
+
}
|
|
832
|
+
if (!wpscanApiToken) {
|
|
833
|
+
recommendations.push("Provide a wpscanApiToken to check for known vulnerabilities (free at https://wpscan.com).");
|
|
834
|
+
}
|
|
835
|
+
if (pluginsResult.length === 0 && themeMap.size === 0) {
|
|
836
|
+
recommendations.push("No plugins or themes detected in page source — site may not be WordPress.");
|
|
837
|
+
}
|
|
838
|
+
return {
|
|
839
|
+
url: rawUrl,
|
|
840
|
+
plugins: pluginsResult,
|
|
841
|
+
pluginCount: pluginsResult.length,
|
|
842
|
+
themes: themesResult,
|
|
843
|
+
themeCount: themesResult.length,
|
|
844
|
+
totalVulnerabilities,
|
|
845
|
+
wpscanApiUsed: !!wpscanApiToken,
|
|
846
|
+
recommendations,
|
|
847
|
+
};
|
|
848
|
+
},
|
|
849
|
+
},
|
|
850
|
+
];
|
|
851
|
+
// ─── Version comparison helper ────────────────────────────────────────────────
|
|
852
|
+
function compareVersions(a, b) {
|
|
853
|
+
const partsA = a.split(".").map(Number);
|
|
854
|
+
const partsB = b.split(".").map(Number);
|
|
855
|
+
const len = Math.max(partsA.length, partsB.length);
|
|
856
|
+
for (let i = 0; i < len; i++) {
|
|
857
|
+
const va = partsA[i] || 0;
|
|
858
|
+
const vb = partsB[i] || 0;
|
|
859
|
+
if (va < vb)
|
|
860
|
+
return -1;
|
|
861
|
+
if (va > vb)
|
|
862
|
+
return 1;
|
|
863
|
+
}
|
|
864
|
+
return 0;
|
|
865
|
+
}
|
|
866
|
+
//# sourceMappingURL=seoTools.js.map
|