@adsim/wordpress-mcp-server 3.1.0 → 4.4.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/README.md +543 -176
- package/dxt/manifest.json +86 -9
- package/index.js +3156 -36
- package/package.json +1 -1
- package/src/confirmationToken.js +64 -0
- package/src/contentAnalyzer.js +476 -0
- package/src/htmlParser.js +80 -0
- package/src/linkUtils.js +158 -0
- package/src/utils/contentCompressor.js +116 -0
- package/src/woocommerceClient.js +88 -0
- package/tests/unit/contentAnalyzer.test.js +397 -0
- package/tests/unit/tools/analyzeEeatSignals.test.js +192 -0
- package/tests/unit/tools/approval.test.js +251 -0
- package/tests/unit/tools/auditCanonicals.test.js +149 -0
- package/tests/unit/tools/auditHeadingStructure.test.js +150 -0
- package/tests/unit/tools/auditMediaSeo.test.js +123 -0
- package/tests/unit/tools/auditOutboundLinks.test.js +175 -0
- package/tests/unit/tools/auditTaxonomies.test.js +173 -0
- package/tests/unit/tools/contentCompressor.test.js +320 -0
- package/tests/unit/tools/contentIntelligence.test.js +2168 -0
- package/tests/unit/tools/destructive.test.js +246 -0
- package/tests/unit/tools/findBrokenInternalLinks.test.js +222 -0
- package/tests/unit/tools/findKeywordCannibalization.test.js +183 -0
- package/tests/unit/tools/findOrphanPages.test.js +145 -0
- package/tests/unit/tools/findThinContent.test.js +145 -0
- package/tests/unit/tools/internalLinks.test.js +283 -0
- package/tests/unit/tools/perTargetControls.test.js +228 -0
- package/tests/unit/tools/site.test.js +6 -1
- package/tests/unit/tools/woocommerce.test.js +344 -0
- package/tests/unit/tools/woocommerceIntelligence.test.js +341 -0
- package/tests/unit/tools/woocommerceWrite.test.js +323 -0
package/src/linkUtils.js
ADDED
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Link analysis and suggestion utilities for WordPress MCP Server.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import fetch from 'node-fetch';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Extract internal links from HTML content.
|
|
9
|
+
* @param {string} htmlContent
|
|
10
|
+
* @param {string} siteUrl - e.g. https://example.com
|
|
11
|
+
* @returns {{ url: string, anchor_text: string }[]}
|
|
12
|
+
*/
|
|
13
|
+
export function extractInternalLinks(htmlContent, siteUrl) {
|
|
14
|
+
const links = [];
|
|
15
|
+
if (!htmlContent || !siteUrl) return links;
|
|
16
|
+
let siteHost;
|
|
17
|
+
try { siteHost = new URL(siteUrl).host; } catch { return links; }
|
|
18
|
+
const regex = /<a\s[^>]*?href=["']([^"']+)["'][^>]*?>([\s\S]*?)<\/a>/gi;
|
|
19
|
+
let match;
|
|
20
|
+
while ((match = regex.exec(htmlContent)) !== null) {
|
|
21
|
+
const href = match[1];
|
|
22
|
+
const anchorText = match[2].replace(/<[^>]*>/g, '').trim();
|
|
23
|
+
try {
|
|
24
|
+
if (href.startsWith('/') && !href.startsWith('//')) {
|
|
25
|
+
links.push({ url: href, anchor_text: anchorText });
|
|
26
|
+
} else if (href.startsWith('http')) {
|
|
27
|
+
if (new URL(href).host === siteHost) {
|
|
28
|
+
links.push({ url: href, anchor_text: anchorText });
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
} catch { /* invalid URL, skip */ }
|
|
32
|
+
}
|
|
33
|
+
return links;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Extract external links from HTML content.
|
|
38
|
+
* @param {string} htmlContent
|
|
39
|
+
* @param {string} siteUrl
|
|
40
|
+
* @returns {{ url: string, anchor_text: string }[]}
|
|
41
|
+
*/
|
|
42
|
+
export function extractExternalLinks(htmlContent, siteUrl) {
|
|
43
|
+
const links = [];
|
|
44
|
+
if (!htmlContent || !siteUrl) return links;
|
|
45
|
+
let siteHost;
|
|
46
|
+
try { siteHost = new URL(siteUrl).host; } catch { return links; }
|
|
47
|
+
const regex = /<a\s[^>]*?href=["']([^"']+)["'][^>]*?>([\s\S]*?)<\/a>/gi;
|
|
48
|
+
let match;
|
|
49
|
+
while ((match = regex.exec(htmlContent)) !== null) {
|
|
50
|
+
const href = match[1];
|
|
51
|
+
const anchorText = match[2].replace(/<[^>]*>/g, '').trim();
|
|
52
|
+
try {
|
|
53
|
+
if (href.startsWith('http') && new URL(href).host !== siteHost) {
|
|
54
|
+
links.push({ url: href, anchor_text: anchorText });
|
|
55
|
+
}
|
|
56
|
+
} catch { /* invalid URL, skip */ }
|
|
57
|
+
}
|
|
58
|
+
return links;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Check status of a URL via HEAD request.
|
|
63
|
+
* @param {string} url
|
|
64
|
+
* @param {number} timeoutMs
|
|
65
|
+
* @returns {Promise<{ status: string, http_code: number|null }>}
|
|
66
|
+
*/
|
|
67
|
+
export async function checkLinkStatus(url, timeoutMs) {
|
|
68
|
+
try {
|
|
69
|
+
const controller = new AbortController();
|
|
70
|
+
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
71
|
+
const response = await fetch(url, { method: 'HEAD', signal: controller.signal, redirect: 'follow' });
|
|
72
|
+
clearTimeout(timer);
|
|
73
|
+
const code = response.status;
|
|
74
|
+
if (code >= 200 && code <= 399) return { status: 'ok', http_code: code };
|
|
75
|
+
if (code === 404) return { status: 'broken', http_code: code };
|
|
76
|
+
return { status: 'warning', http_code: code };
|
|
77
|
+
} catch {
|
|
78
|
+
return { status: 'unknown', http_code: null };
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Extract focus keyword from post meta (auto-detect SEO plugin).
|
|
84
|
+
* @param {object} meta
|
|
85
|
+
* @returns {string|null}
|
|
86
|
+
*/
|
|
87
|
+
export function extractFocusKeyword(meta) {
|
|
88
|
+
if (!meta) return null;
|
|
89
|
+
if (meta.rank_math_focus_keyword) return meta.rank_math_focus_keyword;
|
|
90
|
+
if (meta._yoast_wpseo_focuskw) return meta._yoast_wpseo_focuskw;
|
|
91
|
+
if (meta._seopress_analysis_target_kw) return meta._seopress_analysis_target_kw;
|
|
92
|
+
if (meta._aioseo_keywords) return meta._aioseo_keywords;
|
|
93
|
+
return null;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Calculate relevance score for a candidate post.
|
|
98
|
+
* @param {object} candidate - { id, title, date, categories, meta, link }
|
|
99
|
+
* @param {object} currentPost - { id, categories, linkedUrls }
|
|
100
|
+
* @param {string[]} keywords
|
|
101
|
+
* @returns {{ total: number, breakdown: { category_match, freshness, keyword_match, title_match } }}
|
|
102
|
+
*/
|
|
103
|
+
export function calculateRelevanceScore(candidate, currentPost, keywords) {
|
|
104
|
+
let category_match = 0;
|
|
105
|
+
let freshness = 0;
|
|
106
|
+
let keyword_match = 0;
|
|
107
|
+
let title_match = 0;
|
|
108
|
+
|
|
109
|
+
// Already linked → excluded
|
|
110
|
+
if (currentPost.linkedUrls && currentPost.linkedUrls.length > 0 && candidate.link) {
|
|
111
|
+
if (currentPost.linkedUrls.some(u => u === candidate.link)) {
|
|
112
|
+
return { total: -999, breakdown: { category_match, freshness, keyword_match, title_match } };
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// +3 per common category
|
|
117
|
+
if (candidate.categories && currentPost.categories) {
|
|
118
|
+
const common = candidate.categories.filter(c => currentPost.categories.includes(c));
|
|
119
|
+
category_match = common.length * 3;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// Freshness
|
|
123
|
+
if (candidate.date) {
|
|
124
|
+
const monthsAgo = (Date.now() - new Date(candidate.date).getTime()) / (1000 * 60 * 60 * 24 * 30);
|
|
125
|
+
if (monthsAgo < 3) freshness = 3;
|
|
126
|
+
else if (monthsAgo < 6) freshness = 2;
|
|
127
|
+
else if (monthsAgo < 12) freshness = 1;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// Focus keyword of candidate contains a search keyword → +2
|
|
131
|
+
const candidateKw = extractFocusKeyword(candidate.meta);
|
|
132
|
+
if (candidateKw && keywords && keywords.length > 0) {
|
|
133
|
+
const lower = candidateKw.toLowerCase();
|
|
134
|
+
if (keywords.some(k => lower.includes(k.toLowerCase()))) keyword_match = 2;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// Keyword present in candidate title → +2
|
|
138
|
+
if (candidate.title && keywords && keywords.length > 0) {
|
|
139
|
+
const titleLower = (typeof candidate.title === 'string' ? candidate.title : '').toLowerCase();
|
|
140
|
+
if (keywords.some(k => titleLower.includes(k.toLowerCase()))) title_match = 2;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
return { total: category_match + freshness + keyword_match + title_match, breakdown: { category_match, freshness, keyword_match, title_match } };
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Suggest anchor text for a candidate post.
|
|
148
|
+
* @param {object} candidate - { meta, title }
|
|
149
|
+
* @returns {string}
|
|
150
|
+
*/
|
|
151
|
+
export function suggestAnchorText(candidate) {
|
|
152
|
+
const focusKw = extractFocusKeyword(candidate.meta);
|
|
153
|
+
if (focusKw) return focusKw.replace(/<[^>]*>/g, '').trim();
|
|
154
|
+
let title = typeof candidate.title === 'string' ? candidate.title : (candidate.title?.rendered || '');
|
|
155
|
+
title = title.replace(/<[^>]*>/g, '').trim();
|
|
156
|
+
if (title.length > 60) title = title.substring(0, 60).trim();
|
|
157
|
+
return title;
|
|
158
|
+
}
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Content compression utilities for LLM context optimization.
|
|
3
|
+
* Reduces token consumption on large WordPress sites.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
const DEFAULT_MAX_CHARS = parseInt(process.env.WP_MAX_CONTENT_CHARS || '15000', 10);
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Strip HTML tags and decode common entities.
|
|
10
|
+
* @param {string} html
|
|
11
|
+
* @returns {string}
|
|
12
|
+
*/
|
|
13
|
+
export function stripHtml(html) {
|
|
14
|
+
if (!html) return '';
|
|
15
|
+
return html
|
|
16
|
+
.replace(/<script\b[^<]*(?:(?!<\/script>)<[^<]*)*<\/script>/gi, '')
|
|
17
|
+
.replace(/<style\b[^<]*(?:(?!<\/style>)<[^<]*)*<\/style>/gi, '')
|
|
18
|
+
.replace(/<\/?(p|div|h[1-6]|li|br|tr|blockquote|pre)[^>]*>/gi, '\n')
|
|
19
|
+
.replace(/<[^>]+>/g, '')
|
|
20
|
+
.replace(/&/g, '&')
|
|
21
|
+
.replace(/</g, '<')
|
|
22
|
+
.replace(/>/g, '>')
|
|
23
|
+
.replace(/"/g, '"')
|
|
24
|
+
.replace(/'/g, "'")
|
|
25
|
+
.replace(/ /g, ' ')
|
|
26
|
+
.replace(/\n{3,}/g, '\n\n')
|
|
27
|
+
.replace(/[ \t]+/g, ' ')
|
|
28
|
+
.trim();
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Extract internal links from HTML content.
|
|
33
|
+
* Returns only links pointing to the same site.
|
|
34
|
+
* @param {string} html
|
|
35
|
+
* @param {string} siteUrl e.g. https://example.com
|
|
36
|
+
* @returns {{ text: string, href: string }[]}
|
|
37
|
+
*/
|
|
38
|
+
export function extractLinksOnly(html, siteUrl) {
|
|
39
|
+
if (!html || !siteUrl) return [];
|
|
40
|
+
const links = [];
|
|
41
|
+
const base = siteUrl.replace(/\/$/, '');
|
|
42
|
+
const regex = /<a\s+[^>]*href=["']([^"']+)["'][^>]*>(.*?)<\/a>/gi;
|
|
43
|
+
let match;
|
|
44
|
+
while ((match = regex.exec(html)) !== null) {
|
|
45
|
+
const href = match[1];
|
|
46
|
+
const text = stripHtml(match[2]).trim();
|
|
47
|
+
if (href.startsWith(base) || href.startsWith('/') || href.startsWith('./')) {
|
|
48
|
+
links.push({
|
|
49
|
+
text: text || '[no anchor text]',
|
|
50
|
+
href: href.startsWith('/') ? `${base}${href}` : href
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
return links;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Truncate content to maxChars with informative suffix.
|
|
59
|
+
* @param {string} text
|
|
60
|
+
* @param {number} maxChars 0 = no limit
|
|
61
|
+
* @returns {string}
|
|
62
|
+
*/
|
|
63
|
+
export function truncateContent(text, maxChars = DEFAULT_MAX_CHARS) {
|
|
64
|
+
if (!maxChars || maxChars === 0 || !text || text.length <= maxChars) {
|
|
65
|
+
return text || '';
|
|
66
|
+
}
|
|
67
|
+
const truncated = text.substring(0, maxChars).replace(/\s+\S*$/, '');
|
|
68
|
+
const remaining = text.length - truncated.length;
|
|
69
|
+
return `${truncated}\n\n[truncated: ${remaining} chars remaining]`;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Filter post fields based on requested fields array.
|
|
74
|
+
* @param {object} post
|
|
75
|
+
* @param {string[]} fields
|
|
76
|
+
* @returns {object}
|
|
77
|
+
*/
|
|
78
|
+
export function summarizePost(post, fields) {
|
|
79
|
+
if (!fields || fields.length === 0) return post;
|
|
80
|
+
const allowed = new Set(fields);
|
|
81
|
+
const result = {};
|
|
82
|
+
for (const key of allowed) {
|
|
83
|
+
if (key in post) result[key] = post[key];
|
|
84
|
+
}
|
|
85
|
+
return result;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Apply content_format transformation to a post object.
|
|
90
|
+
* @param {object} post Post with .content field
|
|
91
|
+
* @param {string} contentFormat 'html' | 'text' | 'links_only'
|
|
92
|
+
* @param {string} siteUrl Site base URL
|
|
93
|
+
* @param {number} maxChars Truncation limit (0 = unlimited)
|
|
94
|
+
* @returns {object}
|
|
95
|
+
*/
|
|
96
|
+
export function applyContentFormat(post, contentFormat, siteUrl, maxChars = DEFAULT_MAX_CHARS) {
|
|
97
|
+
if (!post || !post.content) return post;
|
|
98
|
+
const processed = { ...post };
|
|
99
|
+
switch (contentFormat) {
|
|
100
|
+
case 'text':
|
|
101
|
+
processed.content = truncateContent(stripHtml(post.content), maxChars);
|
|
102
|
+
processed._content_format = 'text';
|
|
103
|
+
break;
|
|
104
|
+
case 'links_only':
|
|
105
|
+
processed.content = null;
|
|
106
|
+
processed.internal_links = extractLinksOnly(post.content, siteUrl);
|
|
107
|
+
processed._content_format = 'links_only';
|
|
108
|
+
break;
|
|
109
|
+
case 'html':
|
|
110
|
+
default:
|
|
111
|
+
processed.content = truncateContent(post.content, maxChars);
|
|
112
|
+
processed._content_format = 'html';
|
|
113
|
+
break;
|
|
114
|
+
}
|
|
115
|
+
return processed;
|
|
116
|
+
}
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* WooCommerce REST API v3 client for WordPress MCP Server.
|
|
3
|
+
*
|
|
4
|
+
* Auth: Basic Auth with WC_CONSUMER_KEY + WC_CONSUMER_SECRET
|
|
5
|
+
* Base URL: WP_API_URL + /wp-json/wc/v3/
|
|
6
|
+
* Retry + timeout identical to the WordPress client in index.js
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import fetch from 'node-fetch';
|
|
10
|
+
|
|
11
|
+
const MAX_RETRIES = parseInt(process.env.WP_MCP_MAX_RETRIES || '3', 10);
|
|
12
|
+
const TIMEOUT_MS = parseInt(process.env.WP_MCP_TIMEOUT || '30000', 10);
|
|
13
|
+
const RETRY_BASE_DELAY_MS = 1000;
|
|
14
|
+
const VERBOSE = process.env.WP_MCP_VERBOSE === 'true';
|
|
15
|
+
|
|
16
|
+
function sleep(ms) { return new Promise(r => setTimeout(r, ms)); }
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Check that WooCommerce credentials are configured.
|
|
20
|
+
* @returns {{ key: string, secret: string }}
|
|
21
|
+
* @throws {Error} if credentials are missing
|
|
22
|
+
*/
|
|
23
|
+
export function getWcCredentials() {
|
|
24
|
+
const key = process.env.WC_CONSUMER_KEY || '';
|
|
25
|
+
const secret = process.env.WC_CONSUMER_SECRET || '';
|
|
26
|
+
if (!key || !secret) {
|
|
27
|
+
throw new Error('WooCommerce credentials not configured (WC_CONSUMER_KEY, WC_CONSUMER_SECRET)');
|
|
28
|
+
}
|
|
29
|
+
return { key, secret };
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Make an authenticated call to WooCommerce REST API v3.
|
|
34
|
+
*
|
|
35
|
+
* @param {string} endpoint - Path after /wc/v3/, e.g. "/products"
|
|
36
|
+
* @param {object} options
|
|
37
|
+
* @param {string} options.method - HTTP method (default GET)
|
|
38
|
+
* @param {string} options.body - JSON body for POST/PUT
|
|
39
|
+
* @param {string} baseUrl - WordPress base URL
|
|
40
|
+
* @returns {Promise<object>}
|
|
41
|
+
*/
|
|
42
|
+
export async function wcApiCall(endpoint, options = {}, baseUrl) {
|
|
43
|
+
const { key, secret } = getWcCredentials();
|
|
44
|
+
const url = `${baseUrl}/wp-json/wc/v3${endpoint}`;
|
|
45
|
+
const method = options.method || 'GET';
|
|
46
|
+
const auth = Buffer.from(`${key}:${secret}`).toString('base64');
|
|
47
|
+
const headers = {
|
|
48
|
+
'Authorization': `Basic ${auth}`,
|
|
49
|
+
'Content-Type': 'application/json',
|
|
50
|
+
'User-Agent': 'WordPress-MCP-Server/WooCommerce',
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
let lastError;
|
|
54
|
+
for (let attempt = 1; attempt <= MAX_RETRIES; attempt++) {
|
|
55
|
+
const controller = new AbortController();
|
|
56
|
+
const tid = setTimeout(() => controller.abort(), TIMEOUT_MS);
|
|
57
|
+
try {
|
|
58
|
+
if (VERBOSE) console.error(`[DEBUG] WC ${method} ${endpoint} (${attempt}/${MAX_RETRIES})`);
|
|
59
|
+
const t0 = Date.now();
|
|
60
|
+
const fetchOpts = { method, headers, signal: controller.signal };
|
|
61
|
+
if (options.body) fetchOpts.body = options.body;
|
|
62
|
+
const response = await fetch(url, fetchOpts);
|
|
63
|
+
clearTimeout(tid);
|
|
64
|
+
if (VERBOSE) console.error(`[DEBUG] WC ${response.status} in ${Date.now() - t0}ms`);
|
|
65
|
+
|
|
66
|
+
if (!response.ok) {
|
|
67
|
+
const errorText = await response.text();
|
|
68
|
+
const sc = response.status;
|
|
69
|
+
if (sc >= 400 && sc < 500 && sc !== 429) {
|
|
70
|
+
const hints = { 400: 'Bad Request', 401: 'Unauthorized', 403: 'Forbidden', 404: 'Not Found', 405: 'Method Not Allowed' };
|
|
71
|
+
throw new Error(`WC API ${sc}: ${hints[sc] || ''}\n${errorText}`);
|
|
72
|
+
}
|
|
73
|
+
lastError = new Error(`WC API ${sc}: ${errorText}`);
|
|
74
|
+
if (sc === 429) { await sleep(parseInt(response.headers.get('retry-after') || '5', 10) * 1000); continue; }
|
|
75
|
+
if (attempt < MAX_RETRIES) { await sleep(RETRY_BASE_DELAY_MS * Math.pow(2, attempt - 1)); continue; }
|
|
76
|
+
throw lastError;
|
|
77
|
+
}
|
|
78
|
+
return await response.json();
|
|
79
|
+
} catch (error) {
|
|
80
|
+
clearTimeout(tid);
|
|
81
|
+
if (error.name === 'AbortError') lastError = new Error(`WC timeout ${TIMEOUT_MS}ms`);
|
|
82
|
+
else if (error.message.includes('WC API 4')) throw error;
|
|
83
|
+
else lastError = error;
|
|
84
|
+
if (attempt < MAX_RETRIES) await sleep(RETRY_BASE_DELAY_MS * Math.pow(2, attempt - 1));
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
throw lastError || new Error(`WC call failed after ${MAX_RETRIES} attempts`);
|
|
88
|
+
}
|