@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.
Files changed (31) hide show
  1. package/README.md +543 -176
  2. package/dxt/manifest.json +86 -9
  3. package/index.js +3156 -36
  4. package/package.json +1 -1
  5. package/src/confirmationToken.js +64 -0
  6. package/src/contentAnalyzer.js +476 -0
  7. package/src/htmlParser.js +80 -0
  8. package/src/linkUtils.js +158 -0
  9. package/src/utils/contentCompressor.js +116 -0
  10. package/src/woocommerceClient.js +88 -0
  11. package/tests/unit/contentAnalyzer.test.js +397 -0
  12. package/tests/unit/tools/analyzeEeatSignals.test.js +192 -0
  13. package/tests/unit/tools/approval.test.js +251 -0
  14. package/tests/unit/tools/auditCanonicals.test.js +149 -0
  15. package/tests/unit/tools/auditHeadingStructure.test.js +150 -0
  16. package/tests/unit/tools/auditMediaSeo.test.js +123 -0
  17. package/tests/unit/tools/auditOutboundLinks.test.js +175 -0
  18. package/tests/unit/tools/auditTaxonomies.test.js +173 -0
  19. package/tests/unit/tools/contentCompressor.test.js +320 -0
  20. package/tests/unit/tools/contentIntelligence.test.js +2168 -0
  21. package/tests/unit/tools/destructive.test.js +246 -0
  22. package/tests/unit/tools/findBrokenInternalLinks.test.js +222 -0
  23. package/tests/unit/tools/findKeywordCannibalization.test.js +183 -0
  24. package/tests/unit/tools/findOrphanPages.test.js +145 -0
  25. package/tests/unit/tools/findThinContent.test.js +145 -0
  26. package/tests/unit/tools/internalLinks.test.js +283 -0
  27. package/tests/unit/tools/perTargetControls.test.js +228 -0
  28. package/tests/unit/tools/site.test.js +6 -1
  29. package/tests/unit/tools/woocommerce.test.js +344 -0
  30. package/tests/unit/tools/woocommerceIntelligence.test.js +341 -0
  31. package/tests/unit/tools/woocommerceWrite.test.js +323 -0
@@ -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(/&amp;/g, '&')
21
+ .replace(/&lt;/g, '<')
22
+ .replace(/&gt;/g, '>')
23
+ .replace(/&quot;/g, '"')
24
+ .replace(/&#039;/g, "'")
25
+ .replace(/&nbsp;/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
+ }