@adsim/wordpress-mcp-server 4.4.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.
@@ -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,167 @@
1
+ import { describe, it, expect, beforeEach } from 'vitest';
2
+ import {
3
+ detectSeoPlugin,
4
+ _clearPluginCache,
5
+ getRenderedHead,
6
+ parseRenderedHead
7
+ } from '../../src/pluginDetector.js';
8
+
9
+ // =========================================================================
10
+ // detectSeoPlugin
11
+ // =========================================================================
12
+
13
+ describe('detectSeoPlugin', () => {
14
+ beforeEach(() => { _clearPluginCache(); });
15
+
16
+ function mockFetch(namespaces, ok = true) {
17
+ let callCount = 0;
18
+ return (..._args) => {
19
+ callCount++;
20
+ return Promise.resolve({
21
+ ok,
22
+ status: ok ? 200 : 500,
23
+ json: () => Promise.resolve({ namespaces })
24
+ });
25
+ };
26
+ }
27
+
28
+ it('detects RankMath from namespaces', async () => {
29
+ const fn = mockFetch(['wp/v2', 'rankmath/v1', 'oembed/1.0']);
30
+ expect(await detectSeoPlugin('https://site.com', fn)).toBe('rankmath');
31
+ });
32
+
33
+ it('detects Yoast from namespaces', async () => {
34
+ const fn = mockFetch(['wp/v2', 'yoast/v1']);
35
+ expect(await detectSeoPlugin('https://site.com', fn)).toBe('yoast');
36
+ });
37
+
38
+ it('detects SEOPress from namespaces', async () => {
39
+ const fn = mockFetch(['wp/v2', 'seopress/v1']);
40
+ expect(await detectSeoPlugin('https://site.com', fn)).toBe('seopress');
41
+ });
42
+
43
+ it('returns null when no SEO plugin namespace found', async () => {
44
+ const fn = mockFetch(['wp/v2', 'oembed/1.0']);
45
+ expect(await detectSeoPlugin('https://site.com', fn)).toBeNull();
46
+ });
47
+
48
+ it('uses cache on second call (no extra fetch)', async () => {
49
+ let calls = 0;
50
+ const fn = () => { calls++; return Promise.resolve({ ok: true, json: () => Promise.resolve({ namespaces: ['rankmath/v1'] }) }); };
51
+ await detectSeoPlugin('https://cached.com', fn);
52
+ await detectSeoPlugin('https://cached.com', fn);
53
+ expect(calls).toBe(1);
54
+ });
55
+
56
+ it('returns null on fetch error', async () => {
57
+ const fn = () => Promise.reject(new Error('Network error'));
58
+ expect(await detectSeoPlugin('https://fail.com', fn)).toBeNull();
59
+ });
60
+ });
61
+
62
+ // =========================================================================
63
+ // getRenderedHead
64
+ // =========================================================================
65
+
66
+ describe('getRenderedHead', () => {
67
+ it('calls RankMath getHead endpoint', async () => {
68
+ let calledUrl;
69
+ const fn = (url) => {
70
+ calledUrl = url;
71
+ return Promise.resolve({
72
+ ok: true,
73
+ json: () => Promise.resolve({ success: true, head: '<title>Test</title>' })
74
+ });
75
+ };
76
+ const res = await getRenderedHead('https://site.com', 'https://site.com/hello/', 'rankmath', fn, 'dTpw');
77
+ expect(calledUrl).toContain('rankmath/v1/getHead');
78
+ expect(res.success).toBe(true);
79
+ expect(res.head).toBe('<title>Test</title>');
80
+ expect(res.plugin).toBe('rankmath');
81
+ });
82
+
83
+ it('calls Yoast get_head endpoint', async () => {
84
+ let calledUrl;
85
+ const fn = (url) => {
86
+ calledUrl = url;
87
+ return Promise.resolve({
88
+ ok: true,
89
+ json: () => Promise.resolve({ html: '<title>Yoast</title>', json: { title: 'Yoast' } })
90
+ });
91
+ };
92
+ const res = await getRenderedHead('https://site.com', 'https://site.com/hello/', 'yoast', fn, 'dTpw');
93
+ expect(calledUrl).toContain('yoast/v1/get_head');
94
+ expect(res.success).toBe(true);
95
+ expect(res.head).toBe('<title>Yoast</title>');
96
+ expect(res.plugin).toBe('yoast');
97
+ });
98
+
99
+ it('returns error for unsupported plugin', async () => {
100
+ const fn = () => Promise.resolve({ ok: true, json: () => Promise.resolve({}) });
101
+ const res = await getRenderedHead('https://site.com', 'https://site.com/hello/', 'seopress', fn, 'dTpw');
102
+ expect(res.success).toBe(false);
103
+ expect(res.error).toContain('does not support');
104
+ });
105
+ });
106
+
107
+ // =========================================================================
108
+ // parseRenderedHead
109
+ // =========================================================================
110
+
111
+ describe('parseRenderedHead', () => {
112
+ const fullHead = [
113
+ '<title>Mon Article Test | MonSite</title>',
114
+ '<meta name="description" content="Description rendue par RankMath" />',
115
+ '<link rel="canonical" href="https://example.com/mon-article-test" />',
116
+ '<meta name="robots" content="index, follow" />',
117
+ '<meta property="og:title" content="Mon Article Test" />',
118
+ '<meta property="og:description" content="OG Description" />',
119
+ '<meta property="og:image" content="https://example.com/image.jpg" />',
120
+ '<meta property="og:type" content="article" />',
121
+ '<meta name="twitter:card" content="summary_large_image" />',
122
+ '<meta name="twitter:title" content="Mon Article Test" />',
123
+ '<meta name="twitter:description" content="Twitter Desc" />',
124
+ '<meta name="twitter:image" content="https://example.com/tw.jpg" />',
125
+ '<script type="application/ld+json">{"@type":"Article","headline":"Mon Article Test"}</script>'
126
+ ].join('\n');
127
+
128
+ it('extracts all fields from a complete head', () => {
129
+ const r = parseRenderedHead(fullHead);
130
+ expect(r.title).toBe('Mon Article Test | MonSite');
131
+ expect(r.meta_description).toBe('Description rendue par RankMath');
132
+ expect(r.canonical).toBe('https://example.com/mon-article-test');
133
+ expect(r.robots).toBe('index, follow');
134
+ expect(r.og_title).toBe('Mon Article Test');
135
+ expect(r.og_description).toBe('OG Description');
136
+ expect(r.og_image).toBe('https://example.com/image.jpg');
137
+ expect(r.og_type).toBe('article');
138
+ expect(r.twitter_card).toBe('summary_large_image');
139
+ expect(r.twitter_title).toBe('Mon Article Test');
140
+ expect(r.twitter_description).toBe('Twitter Desc');
141
+ expect(r.twitter_image).toBe('https://example.com/tw.jpg');
142
+ expect(r.schema_json_ld).toHaveLength(1);
143
+ expect(r.schema_json_ld[0]['@type']).toBe('Article');
144
+ });
145
+
146
+ it('handles minimal head (title only)', () => {
147
+ const r = parseRenderedHead('<title>Simple</title>');
148
+ expect(r.title).toBe('Simple');
149
+ expect(r.meta_description).toBeNull();
150
+ expect(r.canonical).toBeNull();
151
+ expect(r.schema_json_ld).toHaveLength(0);
152
+ });
153
+
154
+ it('returns all nulls for empty string', () => {
155
+ const r = parseRenderedHead('');
156
+ expect(r.title).toBeNull();
157
+ expect(r.meta_description).toBeNull();
158
+ expect(r.canonical).toBeNull();
159
+ expect(r.schema_json_ld).toHaveLength(0);
160
+ });
161
+
162
+ it('ignores malformed JSON-LD', () => {
163
+ const html = '<script type="application/ld+json">{invalid json}</script>';
164
+ const r = parseRenderedHead(html);
165
+ expect(r.schema_json_ld).toHaveLength(0);
166
+ });
167
+ });