@adsim/wordpress-mcp-server 3.1.0 → 4.5.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 (34) hide show
  1. package/README.md +564 -176
  2. package/dxt/manifest.json +93 -9
  3. package/index.js +3624 -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/pluginDetector.js +158 -0
  10. package/src/utils/contentCompressor.js +116 -0
  11. package/src/woocommerceClient.js +88 -0
  12. package/tests/unit/contentAnalyzer.test.js +397 -0
  13. package/tests/unit/pluginDetector.test.js +167 -0
  14. package/tests/unit/tools/analyzeEeatSignals.test.js +192 -0
  15. package/tests/unit/tools/approval.test.js +251 -0
  16. package/tests/unit/tools/auditCanonicals.test.js +149 -0
  17. package/tests/unit/tools/auditHeadingStructure.test.js +150 -0
  18. package/tests/unit/tools/auditMediaSeo.test.js +123 -0
  19. package/tests/unit/tools/auditOutboundLinks.test.js +175 -0
  20. package/tests/unit/tools/auditTaxonomies.test.js +173 -0
  21. package/tests/unit/tools/contentCompressor.test.js +320 -0
  22. package/tests/unit/tools/contentIntelligence.test.js +2168 -0
  23. package/tests/unit/tools/destructive.test.js +246 -0
  24. package/tests/unit/tools/findBrokenInternalLinks.test.js +222 -0
  25. package/tests/unit/tools/findKeywordCannibalization.test.js +183 -0
  26. package/tests/unit/tools/findOrphanPages.test.js +145 -0
  27. package/tests/unit/tools/findThinContent.test.js +145 -0
  28. package/tests/unit/tools/internalLinks.test.js +283 -0
  29. package/tests/unit/tools/perTargetControls.test.js +228 -0
  30. package/tests/unit/tools/pluginIntelligence.test.js +864 -0
  31. package/tests/unit/tools/site.test.js +6 -1
  32. package/tests/unit/tools/woocommerce.test.js +344 -0
  33. package/tests/unit/tools/woocommerceIntelligence.test.js +341 -0
  34. 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,158 @@
1
+ /**
2
+ * SEO Plugin Detection & Rendered Head Utilities
3
+ *
4
+ * Detects active SEO plugins (RankMath, Yoast, SEOPress) via the WP REST API
5
+ * discovery endpoint, retrieves the rendered <head> from plugin-specific
6
+ * headless endpoints, and parses the resulting HTML for SEO meta tags.
7
+ */
8
+
9
+ // In-process cache (Map<siteUrl, string|null>)
10
+ const pluginCache = new Map();
11
+
12
+ /**
13
+ * Detect which SEO plugin is active on a WordPress site by inspecting
14
+ * the REST API namespaces exposed at /wp-json/.
15
+ *
16
+ * @param {string} siteUrl Base site URL (no trailing slash)
17
+ * @param {Function} fetchFn node-fetch (or compatible)
18
+ * @returns {Promise<string|null>} 'rankmath' | 'yoast' | 'seopress' | null
19
+ */
20
+ export async function detectSeoPlugin(siteUrl, fetchFn) {
21
+ if (pluginCache.has(siteUrl)) return pluginCache.get(siteUrl);
22
+
23
+ try {
24
+ const resp = await fetchFn(`${siteUrl}/wp-json/`, {
25
+ headers: { 'User-Agent': 'WordPress-MCP-Server' }
26
+ });
27
+ if (!resp.ok) { pluginCache.set(siteUrl, null); return null; }
28
+ const data = await resp.json();
29
+ const ns = data.namespaces || [];
30
+
31
+ let plugin = null;
32
+ if (ns.includes('rankmath/v1')) plugin = 'rankmath';
33
+ else if (ns.includes('yoast/v1')) plugin = 'yoast';
34
+ else if (ns.includes('seopress/v1')) plugin = 'seopress';
35
+
36
+ pluginCache.set(siteUrl, plugin);
37
+ return plugin;
38
+ } catch {
39
+ pluginCache.set(siteUrl, null);
40
+ return null;
41
+ }
42
+ }
43
+
44
+ /** Clear the detection cache (for tests). */
45
+ export function _clearPluginCache() {
46
+ pluginCache.clear();
47
+ }
48
+
49
+ /**
50
+ * Retrieve the rendered <head> HTML from a plugin's headless endpoint.
51
+ *
52
+ * @param {string} siteUrl Base site URL
53
+ * @param {string} postUrl Full permalink of the post/page
54
+ * @param {string} plugin 'rankmath' | 'yoast'
55
+ * @param {Function} fetchFn node-fetch
56
+ * @param {string} authBase64 Base64-encoded "user:pass"
57
+ * @returns {Promise<{success:boolean, head?:string, json?:object, plugin?:string, error?:string}>}
58
+ */
59
+ export async function getRenderedHead(siteUrl, postUrl, plugin, fetchFn, authBase64) {
60
+ const authHeader = `Basic ${authBase64}`;
61
+
62
+ if (plugin === 'rankmath') {
63
+ const resp = await fetchFn(`${siteUrl}/wp-json/rankmath/v1/getHead?url=${encodeURIComponent(postUrl)}`, {
64
+ headers: { 'Authorization': authHeader, 'User-Agent': 'WordPress-MCP-Server' }
65
+ });
66
+ if (!resp.ok) return { success: false, error: `RankMath API: ${resp.status}` };
67
+ const data = await resp.json();
68
+ return { success: data.success !== false, head: data.head || '', plugin: 'rankmath' };
69
+ }
70
+
71
+ if (plugin === 'yoast') {
72
+ const resp = await fetchFn(`${siteUrl}/wp-json/yoast/v1/get_head?url=${encodeURIComponent(postUrl)}`, {
73
+ headers: { 'Authorization': authHeader, 'User-Agent': 'WordPress-MCP-Server' }
74
+ });
75
+ if (!resp.ok) return { success: false, error: `Yoast API: ${resp.status}` };
76
+ const data = await resp.json();
77
+ return { success: true, head: data.html || '', json: data.json || {}, plugin: 'yoast' };
78
+ }
79
+
80
+ return { success: false, error: `Plugin ${plugin} does not support rendered head` };
81
+ }
82
+
83
+ /**
84
+ * Parse a rendered <head> HTML string into structured SEO metadata.
85
+ *
86
+ * @param {string} headHtml Raw HTML from getRenderedHead().head
87
+ * @returns {object} Structured metadata
88
+ */
89
+ export function parseRenderedHead(headHtml) {
90
+ const result = {
91
+ title: null,
92
+ meta_description: null,
93
+ canonical: null,
94
+ og_title: null,
95
+ og_description: null,
96
+ og_image: null,
97
+ og_type: null,
98
+ twitter_card: null,
99
+ twitter_title: null,
100
+ twitter_description: null,
101
+ twitter_image: null,
102
+ robots: null,
103
+ schema_json_ld: []
104
+ };
105
+
106
+ if (!headHtml) return result;
107
+
108
+ // <title>...</title>
109
+ const titleMatch = headHtml.match(/<title>([^<]*)<\/title>/i);
110
+ if (titleMatch) result.title = titleMatch[1].trim();
111
+
112
+ // <meta name="description" content="..." />
113
+ const descMatch = headHtml.match(/<meta\s+name=["']description["']\s+content=["']([^"']*)["']/i);
114
+ if (descMatch) result.meta_description = descMatch[1];
115
+
116
+ // <link rel="canonical" href="..." />
117
+ const canonMatch = headHtml.match(/<link\s+rel=["']canonical["']\s+href=["']([^"']*)["']/i);
118
+ if (canonMatch) result.canonical = canonMatch[1];
119
+
120
+ // <meta name="robots" content="..." />
121
+ const robotsMatch = headHtml.match(/<meta\s+name=["']robots["']\s+content=["']([^"']*)["']/i);
122
+ if (robotsMatch) result.robots = robotsMatch[1];
123
+
124
+ // OpenGraph metas
125
+ const ogPatterns = {
126
+ og_title: /property=["']og:title["']\s+content=["']([^"']*?)["']/i,
127
+ og_description: /property=["']og:description["']\s+content=["']([^"']*?)["']/i,
128
+ og_image: /property=["']og:image["']\s+content=["']([^"']*?)["']/i,
129
+ og_type: /property=["']og:type["']\s+content=["']([^"']*?)["']/i,
130
+ };
131
+ for (const [key, regex] of Object.entries(ogPatterns)) {
132
+ const m = headHtml.match(regex);
133
+ if (m) result[key] = m[1];
134
+ }
135
+
136
+ // Twitter metas
137
+ const twPatterns = {
138
+ twitter_card: /name=["']twitter:card["']\s+content=["']([^"']*?)["']/i,
139
+ twitter_title: /name=["']twitter:title["']\s+content=["']([^"']*?)["']/i,
140
+ twitter_description: /name=["']twitter:description["']\s+content=["']([^"']*?)["']/i,
141
+ twitter_image: /name=["']twitter:image["']\s+content=["']([^"']*?)["']/i,
142
+ };
143
+ for (const [key, regex] of Object.entries(twPatterns)) {
144
+ const m = headHtml.match(regex);
145
+ if (m) result[key] = m[1];
146
+ }
147
+
148
+ // JSON-LD schemas
149
+ const schemaRegex = /<script\s+type=["']application\/ld\+json["'][^>]*>([\s\S]*?)<\/script>/gi;
150
+ let schemaMatch;
151
+ while ((schemaMatch = schemaRegex.exec(headHtml)) !== null) {
152
+ try {
153
+ result.schema_json_ld.push(JSON.parse(schemaMatch[1]));
154
+ } catch { /* ignore malformed JSON-LD */ }
155
+ }
156
+
157
+ return result;
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
+ }