@adsim/wordpress-mcp-server 4.6.0 → 5.1.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 (48) hide show
  1. package/.env.example +18 -0
  2. package/README.md +851 -499
  3. package/companion/mcp-diagnostics.php +1184 -0
  4. package/dxt/manifest.json +715 -98
  5. package/index.js +166 -4786
  6. package/package.json +14 -6
  7. package/src/data/plugin-performance-data.json +59 -0
  8. package/src/shared/api.js +79 -0
  9. package/src/shared/audit.js +39 -0
  10. package/src/shared/context.js +15 -0
  11. package/src/shared/governance.js +98 -0
  12. package/src/shared/utils.js +148 -0
  13. package/src/tools/comments.js +50 -0
  14. package/src/tools/content.js +353 -0
  15. package/src/tools/core.js +114 -0
  16. package/src/tools/editorial.js +634 -0
  17. package/src/tools/fse.js +370 -0
  18. package/src/tools/health.js +160 -0
  19. package/src/tools/index.js +96 -0
  20. package/src/tools/intelligence.js +2082 -0
  21. package/src/tools/links.js +118 -0
  22. package/src/tools/media.js +71 -0
  23. package/src/tools/performance.js +219 -0
  24. package/src/tools/plugins.js +368 -0
  25. package/src/tools/schema.js +417 -0
  26. package/src/tools/security.js +590 -0
  27. package/src/tools/seo.js +1633 -0
  28. package/src/tools/taxonomy.js +115 -0
  29. package/src/tools/users.js +188 -0
  30. package/src/tools/woocommerce.js +1008 -0
  31. package/src/tools/workflow.js +409 -0
  32. package/src/transport/http.js +39 -0
  33. package/tests/unit/helpers/pagination.test.js +43 -0
  34. package/tests/unit/tools/bulkUpdate.test.js +188 -0
  35. package/tests/unit/tools/diagnostics.test.js +397 -0
  36. package/tests/unit/tools/dynamicFiltering.test.js +100 -8
  37. package/tests/unit/tools/editorialIntelligence.test.js +817 -0
  38. package/tests/unit/tools/fse.test.js +548 -0
  39. package/tests/unit/tools/multilingual.test.js +653 -0
  40. package/tests/unit/tools/performance.test.js +351 -0
  41. package/tests/unit/tools/runWorkflow.test.js +150 -0
  42. package/tests/unit/tools/schema.test.js +477 -0
  43. package/tests/unit/tools/security.test.js +695 -0
  44. package/tests/unit/tools/site.test.js +1 -1
  45. package/tests/unit/tools/users.crud.test.js +399 -0
  46. package/tests/unit/tools/validateBlocks.test.js +186 -0
  47. package/tests/unit/tools/visualStaging.test.js +271 -0
  48. package/tests/unit/tools/woocommerce.advanced.test.js +679 -0
@@ -0,0 +1,2082 @@
1
+ // src/tools/intelligence.js — intelligence tools (22)
2
+ // Definitions + handlers (v5.0.0 refactor Step B+C)
3
+
4
+ import { json, strip } from '../shared/utils.js';
5
+ import { validateInput } from '../shared/governance.js';
6
+ import { rt } from '../shared/context.js';
7
+
8
+ export const definitions = [
9
+ { name: 'wp_get_content_brief', _category: 'intelligence', description: 'Use to get a compact content brief in 1 call: title, SEO meta, headings, word count, links, categories. Read-only. Hint: start here before writing or rewriting content.',
10
+ inputSchema: { type: 'object', properties: { id: { type: 'number' }, post_type: { type: 'string', default: 'post', description: 'post or page' } }, required: ['id'] }},
11
+ { name: 'wp_extract_post_outline', _category: 'intelligence', description: 'Use to extract H1-H4 outline from N posts in a category as a reference template for new content. Read-only.',
12
+ inputSchema: { type: 'object', properties: { category_id: { type: 'number' }, post_type: { type: 'string', default: 'post', description: 'post or page' }, limit: { type: 'number', default: 10 } }, required: ['category_id'] }},
13
+ { name: 'wp_audit_readability', _category: 'intelligence', description: 'Use to score text readability (Flesch-Kincaid adapted). Returns transition density and passive ratio. Read-only.',
14
+ inputSchema: { type: 'object', properties: { limit: { type: 'number' }, post_type: { type: 'string', enum: ['post', 'page'] }, category_id: { type: 'number' }, min_words: { type: 'number', description: 'default 100' } }}},
15
+ { name: 'wp_audit_update_frequency', _category: 'intelligence', description: 'Use to find stale posts not updated since N days, cross-referenced with SEO score. Read-only.',
16
+ inputSchema: { type: 'object', properties: { days_threshold: { type: 'number', description: 'default 180' }, limit: { type: 'number' }, post_type: { type: 'string', enum: ['post', 'page'] }, include_seo_score: { type: 'boolean', description: 'default true' } }}},
17
+ { name: 'wp_build_link_map', _category: 'intelligence', description: 'Use to generate full internal link matrix with simplified PageRank scores per post. Read-only.',
18
+ inputSchema: { type: 'object', properties: { post_type: { type: 'string', enum: ['post', 'page', 'both'] }, limit: { type: 'number' }, category_id: { type: 'number' } }}},
19
+ { name: 'wp_audit_anchor_texts', _category: 'intelligence', description: 'Use to check internal link anchor diversity. Detects generic (\'click here\') and over-optimized anchors. Read-only.',
20
+ inputSchema: { type: 'object', properties: { limit: { type: 'number' }, post_type: { type: 'string', enum: ['post', 'page', 'both'] } }}},
21
+ { name: 'wp_audit_schema_markup', _category: 'intelligence', description: 'Use to detect and validate JSON-LD in post HTML content (Article, FAQ, HowTo, LocalBusiness). Read-only. Hint: use wp_audit_schema_plugins for plugin-native schema instead.',
22
+ inputSchema: { type: 'object', properties: { limit: { type: 'number' }, post_type: { type: 'string', enum: ['post', 'page', 'both'] } }}},
23
+ { name: 'wp_audit_content_structure', _category: 'intelligence', description: 'Use to analyze post structure: intro/body/conclusion ratio, FAQ presence, TOC, lists, tables. Read-only.',
24
+ inputSchema: { type: 'object', properties: { limit: { type: 'number' }, post_type: { type: 'string', enum: ['post', 'page', 'both'] }, category_id: { type: 'number' } }}},
25
+ { name: 'wp_find_duplicate_content', _category: 'intelligence', description: 'Use to detect near-duplicate posts via TF-IDF cosine similarity. Read-only.',
26
+ inputSchema: { type: 'object', properties: { limit: { type: 'number' }, post_type: { type: 'string', enum: ['post', 'page'] }, category_id: { type: 'number' }, similarity_threshold: { type: 'number', description: 'default 0.7 (0-1)' } }}},
27
+ { name: 'wp_find_content_gaps', _category: 'intelligence', description: 'Use to find under-represented taxonomy terms (< N posts) as content creation opportunities. Read-only.',
28
+ inputSchema: { type: 'object', properties: { min_posts: { type: 'number', description: 'default 3' }, taxonomy: { type: 'string', enum: ['category', 'post_tag', 'both'] }, exclude_empty: { type: 'boolean', description: 'default false' } }}},
29
+ { name: 'wp_extract_faq_blocks', _category: 'intelligence', description: 'Use to inventory all FAQ blocks (Gutenberg + schema JSON-LD) across the corpus. Read-only.',
30
+ inputSchema: { type: 'object', properties: { limit: { type: 'number' }, post_type: { type: 'string', enum: ['post', 'page', 'both'] } }}},
31
+ { name: 'wp_audit_cta_presence', _category: 'intelligence', description: 'Use to detect presence/absence of CTAs (contact links, forms, buttons) per post. Read-only.',
32
+ inputSchema: { type: 'object', properties: { limit: { type: 'number' }, post_type: { type: 'string', enum: ['post', 'page', 'both'] }, category_id: { type: 'number' } }}},
33
+ { name: 'wp_extract_entities', _category: 'intelligence', description: 'Use to extract named entities (brands, places, people, organizations) from post content. Read-only.',
34
+ inputSchema: { type: 'object', properties: { limit: { type: 'number' }, post_type: { type: 'string', enum: ['post', 'page'] }, min_occurrences: { type: 'number', description: 'default 2' } }}},
35
+ { name: 'wp_get_publishing_velocity', _category: 'intelligence', description: 'Use to measure publication cadence per author and category over 30/90/180 days. Read-only.',
36
+ inputSchema: { type: 'object', properties: { periods: { type: 'string', description: "Comma-separated day periods (default '30,90,180')" }, post_type: { type: 'string', enum: ['post', 'page'] }, limit: { type: 'number' } }}},
37
+ { name: 'wp_compare_revisions_diff', _category: 'intelligence', description: 'Use to diff two revisions and measure update amplitude. Read-only.',
38
+ inputSchema: { type: 'object', properties: { post_id: { type: 'number' }, revision_id_from: { type: 'number', description: 'Older revision ID (baseline)' }, revision_id_to: { type: 'number', description: 'Newer revision ID (omit for current post)' }, post_type: { type: 'string', enum: ['post', 'page'] } }, required: ['post_id', 'revision_id_from'] }},
39
+ { name: 'wp_list_posts_by_word_count', _category: 'intelligence', description: 'Use to rank posts by length with auto-segmentation (<500, 500-1k, 1k-2k, 2k+). Read-only.',
40
+ inputSchema: { type: 'object', properties: { limit: { type: 'number' }, post_type: { type: 'string', enum: ['post', 'page', 'both'] }, order: { type: 'string', enum: ['asc', 'desc'] }, category_id: { type: 'number' } }}},
41
+ { name: 'wp_get_rendered_head', _category: 'intelligence', description: 'Use to fetch the real <head> HTML Google sees via RankMath/Yoast headless endpoint. Compares rendered vs stored meta. Read-only. Requires WP_ENABLE_PLUGIN_INTELLIGENCE=true.',
42
+ inputSchema: { type: 'object', properties: { post_id: { type: 'number' }, post_type: { type: 'string', enum: ['post', 'page'] } }, required: ['post_id'] }},
43
+ { name: 'wp_audit_rendered_seo', _category: 'intelligence', description: 'Use for bulk rendered-vs-stored SEO divergence detection with per-post scoring. Read-only. Requires WP_ENABLE_PLUGIN_INTELLIGENCE=true.',
44
+ inputSchema: { type: 'object', properties: { limit: { type: 'number' }, post_type: { type: 'string', enum: ['post', 'page'] } }}},
45
+ { name: 'wp_get_pillar_content', _category: 'intelligence', description: 'Use to read or set RankMath cornerstone/pillar flag. Read always allowed. Write — blocked by WP_READ_ONLY. Requires WP_ENABLE_PLUGIN_INTELLIGENCE=true.',
46
+ inputSchema: { type: 'object', properties: { post_id: { type: 'number' }, set_pillar: { type: 'boolean', description: 'Set pillar flag (true=pillar, false=not). Requires write access.' }, list_pillars: { type: 'boolean', description: 'List all pillar content posts (ignores post_id)' }, post_type: { type: 'string', enum: ['post', 'page'] }, limit: { type: 'number' } }}},
47
+ { name: 'wp_audit_schema_plugins', _category: 'intelligence', description: 'Use to validate JSON-LD from SEO plugin native fields (rank_math_schema or yoast_head_json). Read-only. Requires WP_ENABLE_PLUGIN_INTELLIGENCE=true.',
48
+ inputSchema: { type: 'object', properties: { limit: { type: 'number' }, post_type: { type: 'string', enum: ['post', 'page', 'both'] } }}},
49
+ { name: 'wp_get_seo_score', _category: 'intelligence', description: 'Use to read RankMath native SEO score (0-100). Bulk mode shows distribution stats. Read-only. Requires WP_ENABLE_PLUGIN_INTELLIGENCE=true.',
50
+ inputSchema: { type: 'object', properties: { post_id: { type: 'number' }, limit: { type: 'number', description: 'default 20, max 100' }, post_type: { type: 'string', enum: ['post', 'page'] }, order: { type: 'string', enum: ['asc', 'desc'] } }}},
51
+ { name: 'wp_get_twitter_meta', _category: 'intelligence', description: 'Use to read or update Twitter Card meta (title, description, image) for RankMath/Yoast/SEOPress. Write — blocked by WP_READ_ONLY. Requires WP_ENABLE_PLUGIN_INTELLIGENCE=true.',
52
+ inputSchema: { type: 'object', properties: { post_id: { type: 'number' }, post_type: { type: 'string', enum: ['post', 'page'] }, twitter_title: { type: 'string', description: 'Set Twitter title (write mode)' }, twitter_description: { type: 'string', description: 'Set Twitter description (write mode)' }, twitter_image: { type: 'string', description: 'Set Twitter image URL (write mode)' } }, required: ['post_id'] }}
53
+ ];
54
+
55
+ export const handlers = {};
56
+
57
+ handlers['wp_get_content_brief'] = async (args) => {
58
+ const t0 = Date.now();
59
+ let result;
60
+ const { wpApiCall, getActiveAuth, auditLog, name, calculateReadabilityScore, extractHeadingsOutline, detectContentSections, countWords, extractInternalLinks, extractExternalLinks, extractFocusKeyword } = rt;
61
+ validateInput(args, {
62
+ id: { type: 'number', required: true, min: 1 },
63
+ post_type: { type: 'string', enum: ['post', 'page'] }
64
+ });
65
+ const { id, post_type: briefPostType = 'post' } = args;
66
+ const briefEndpoint = briefPostType === 'page' ? '/pages' : '/posts';
67
+ const briefPost = await wpApiCall(`${briefEndpoint}/${id}?_fields=id,title,content,excerpt,slug,status,date,modified,link,categories,tags,author,featured_media,meta`);
68
+
69
+ const briefContent = briefPost.content?.rendered || '';
70
+ const { url: briefSiteUrl } = getActiveAuth();
71
+
72
+ // Word count & readability
73
+ const briefWordCount = countWords(briefContent);
74
+ const readability = calculateReadabilityScore(briefContent);
75
+
76
+ // Structure
77
+ const briefHeadings = extractHeadingsOutline(briefContent);
78
+ const structure = detectContentSections(briefContent);
79
+
80
+ // Links
81
+ const briefInternalLinks = extractInternalLinks(briefContent, briefSiteUrl);
82
+ const briefExternalLinks = extractExternalLinks(briefContent, briefSiteUrl);
83
+
84
+ // SEO meta
85
+ const briefMeta = briefPost.meta || {};
86
+ const briefFocusKw = extractFocusKeyword(briefMeta);
87
+ const seoTitle = briefMeta._yoast_wpseo_title || briefMeta.rank_math_title || briefMeta._seopress_titles_title || null;
88
+ const seoDescription = briefMeta._yoast_wpseo_metadesc || briefMeta.rank_math_description || briefMeta._seopress_titles_desc || null;
89
+ const seoCanonical = briefMeta._yoast_wpseo_canonical || briefMeta.rank_math_canonical_url || briefMeta._seopress_robots_canonical || null;
90
+
91
+ // Resolve categories to names
92
+ const briefCatIds = briefPost.categories || [];
93
+ let briefCategories = [];
94
+ if (briefCatIds.length > 0) {
95
+ try {
96
+ const cats = await wpApiCall(`/categories?include=${briefCatIds.join(',')}&_fields=id,name`);
97
+ briefCategories = cats.map(c => ({ id: c.id, name: c.name }));
98
+ } catch { briefCategories = briefCatIds.map(cid => ({ id: cid, name: null })); }
99
+ }
100
+
101
+ // Resolve tags to names
102
+ const briefTagIds = briefPost.tags || [];
103
+ let briefTags = [];
104
+ if (briefTagIds.length > 0) {
105
+ try {
106
+ const tags = await wpApiCall(`/tags?include=${briefTagIds.join(',')}&_fields=id,name`);
107
+ briefTags = tags.map(t => ({ id: t.id, name: t.name }));
108
+ } catch { briefTags = briefTagIds.map(tid => ({ id: tid, name: null })); }
109
+ }
110
+
111
+ result = json({
112
+ id: briefPost.id,
113
+ title: strip(briefPost.title?.rendered || ''),
114
+ slug: briefPost.slug,
115
+ status: briefPost.status,
116
+ date: briefPost.date,
117
+ modified: briefPost.modified,
118
+ link: briefPost.link,
119
+ author: briefPost.author,
120
+ word_count: briefWordCount,
121
+ readability: { score: readability.score, level: readability.level, avg_words_per_sentence: readability.avg_words_per_sentence },
122
+ seo: {
123
+ title: seoTitle,
124
+ description: seoDescription,
125
+ focus_keyword: briefFocusKw,
126
+ canonical: seoCanonical
127
+ },
128
+ structure: {
129
+ headings: briefHeadings,
130
+ has_intro: structure.has_intro,
131
+ has_conclusion: structure.has_conclusion,
132
+ has_faq: structure.has_faq,
133
+ lists_count: structure.lists_count,
134
+ tables_count: structure.tables_count,
135
+ images_count: structure.images_count
136
+ },
137
+ categories: briefCategories,
138
+ tags: briefTags,
139
+ internal_links: { count: briefInternalLinks.length, links: briefInternalLinks },
140
+ external_links: { count: briefExternalLinks.length, links: briefExternalLinks },
141
+ featured_media: briefPost.featured_media || null
142
+ });
143
+ auditLog({ tool: name, target: id, target_type: briefPostType, action: 'content_brief', status: 'success', latency_ms: Date.now() - t0, params: { id, post_type: briefPostType } });
144
+ return result;
145
+ };
146
+ handlers['wp_extract_post_outline'] = async (args) => {
147
+ const t0 = Date.now();
148
+ let result;
149
+ const { wpApiCall, auditLog, name, extractHeadingsOutline, countWords } = rt;
150
+ validateInput(args, {
151
+ category_id: { type: 'number', required: true, min: 1 },
152
+ post_type: { type: 'string', enum: ['post', 'page'] },
153
+ limit: { type: 'number', min: 1, max: 50 }
154
+ });
155
+ const { category_id, post_type: outlinePostType = 'post', limit: outlineLimit = 10 } = args;
156
+ const outlineEndpoint = outlinePostType === 'page' ? '/pages' : '/posts';
157
+ let outlineUrl = `${outlineEndpoint}?per_page=${Math.min(outlineLimit, 50)}&status=publish&_fields=id,title,content,slug,link,meta`;
158
+ if (outlinePostType !== 'page') outlineUrl += `&categories=${category_id}`;
159
+ const outlinePosts = await wpApiCall(outlineUrl);
160
+
161
+ const outlines = [];
162
+ const h2Counter = new Map();
163
+
164
+ for (const p of outlinePosts) {
165
+ const html = p.content?.rendered || '';
166
+ const headings = extractHeadingsOutline(html);
167
+ const wc = countWords(html);
168
+
169
+ // Count H2 patterns
170
+ for (const h of headings) {
171
+ if (h.level === 2) {
172
+ const normalized = h.text.toLowerCase().trim();
173
+ if (normalized) h2Counter.set(normalized, (h2Counter.get(normalized) || 0) + 1);
174
+ }
175
+ }
176
+
177
+ outlines.push({
178
+ id: p.id,
179
+ title: strip(p.title?.rendered || ''),
180
+ slug: p.slug,
181
+ link: p.link,
182
+ word_count: wc,
183
+ headings_count: headings.length,
184
+ headings
185
+ });
186
+ }
187
+
188
+ // Aggregated stats
189
+ const totalWords = outlines.reduce((s, o) => s + o.word_count, 0);
190
+ const totalHeadings = outlines.reduce((s, o) => s + o.headings_count, 0);
191
+ const avgWordCount = outlines.length > 0 ? Math.round(totalWords / outlines.length) : 0;
192
+ const avgHeadingsCount = outlines.length > 0 ? Math.round(totalHeadings / outlines.length) : 0;
193
+
194
+ // Common H2 patterns (top 10)
195
+ const commonH2 = [...h2Counter.entries()]
196
+ .sort((a, b) => b[1] - a[1])
197
+ .slice(0, 10)
198
+ .map(([text, frequency]) => ({ text, frequency }));
199
+
200
+ result = json({
201
+ category_id,
202
+ posts_analyzed: outlines.length,
203
+ avg_word_count: avgWordCount,
204
+ avg_headings_count: avgHeadingsCount,
205
+ common_h2_patterns: commonH2,
206
+ outlines
207
+ });
208
+ auditLog({ tool: name, action: 'extract_outline', status: 'success', latency_ms: Date.now() - t0, params: { category_id, post_type: outlinePostType, limit: outlineLimit } });
209
+ return result;
210
+ };
211
+ handlers['wp_audit_readability'] = async (args) => {
212
+ const t0 = Date.now();
213
+ let result;
214
+ const { wpApiCall, auditLog, name, calculateReadabilityScore, extractTransitionWords, countPassiveSentences, countWords } = rt;
215
+ validateInput(args, {
216
+ limit: { type: 'number', min: 1, max: 200 },
217
+ post_type: { type: 'string', enum: ['post', 'page'] },
218
+ category_id: { type: 'number' },
219
+ min_words: { type: 'number', min: 0 }
220
+ });
221
+ const { limit: arLimit = 50, post_type: arPostType = 'post', category_id: arCatId, min_words: arMinWords = 100 } = args;
222
+ const arEndpoint = arPostType === 'page' ? '/pages' : '/posts';
223
+ let arUrl = `${arEndpoint}?per_page=${Math.min(arLimit, 200)}&status=publish&_fields=id,title,content,slug,link,categories,meta`;
224
+ if (arCatId && arPostType !== 'page') arUrl += `&categories=${arCatId}`;
225
+ const arPosts = await wpApiCall(arUrl);
226
+
227
+ const arResults = [];
228
+ for (const p of arPosts) {
229
+ const html = p.content?.rendered || '';
230
+ const wc = countWords(html);
231
+ if (wc < arMinWords) continue;
232
+ const plainText = strip(html);
233
+ const readabilityResult = calculateReadabilityScore(html);
234
+ const transitionResult = extractTransitionWords(plainText);
235
+ const passiveResult = countPassiveSentences(plainText);
236
+
237
+ const issues = [];
238
+ if (readabilityResult.score < 20) issues.push('very_low_readability');
239
+ else if (readabilityResult.score < 40) issues.push('low_readability');
240
+ if (transitionResult.count === 0) issues.push('no_transition_words');
241
+ else if (transitionResult.density < 0.1) issues.push('low_transition_density');
242
+ if (passiveResult.ratio > 0.3) issues.push('high_passive_ratio');
243
+
244
+ arResults.push({
245
+ id: p.id,
246
+ title: strip(p.title?.rendered || ''),
247
+ slug: p.slug,
248
+ link: p.link,
249
+ word_count: wc,
250
+ readability: {
251
+ score: readabilityResult.score,
252
+ level: readabilityResult.level,
253
+ avg_words_per_sentence: readabilityResult.avg_words_per_sentence,
254
+ avg_syllables_per_word: readabilityResult.avg_syllables_per_word
255
+ },
256
+ transition_density: transitionResult.density,
257
+ passive_ratio: passiveResult.ratio,
258
+ issues
259
+ });
260
+ }
261
+
262
+ // Sort by score ASC (worst first)
263
+ arResults.sort((a, b) => a.readability.score - b.readability.score);
264
+
265
+ // Aggregation
266
+ const arTotal = arResults.length;
267
+ const arAvgScore = arTotal > 0 ? Math.round(arResults.reduce((s, r) => s + r.readability.score, 0) / arTotal * 10) / 10 : 0;
268
+ const arAvgTD = arTotal > 0 ? Math.round(arResults.reduce((s, r) => s + r.transition_density, 0) / arTotal * 1000) / 1000 : 0;
269
+ const arAvgPR = arTotal > 0 ? Math.round(arResults.reduce((s, r) => s + r.passive_ratio, 0) / arTotal * 1000) / 1000 : 0;
270
+ const distribution = { 'très facile': 0, 'facile': 0, 'standard': 0, 'difficile': 0, 'très difficile': 0 };
271
+ for (const r of arResults) { distribution[r.readability.level] = (distribution[r.readability.level] || 0) + 1; }
272
+
273
+ result = json({
274
+ total_analyzed: arTotal,
275
+ avg_readability_score: arAvgScore,
276
+ distribution,
277
+ avg_transition_density: arAvgTD,
278
+ avg_passive_ratio: arAvgPR,
279
+ posts: arResults
280
+ });
281
+ auditLog({ tool: name, action: 'audit_readability', status: 'success', latency_ms: Date.now() - t0, params: { limit: arLimit, post_type: arPostType, category_id: arCatId, min_words: arMinWords } });
282
+ return result;
283
+ };
284
+ handlers['wp_audit_update_frequency'] = async (args) => {
285
+ const t0 = Date.now();
286
+ let result;
287
+ const { wpApiCall, auditLog, name, countWords, extractFocusKeyword } = rt;
288
+ validateInput(args, {
289
+ days_threshold: { type: 'number', min: 1 },
290
+ limit: { type: 'number', min: 1, max: 200 },
291
+ post_type: { type: 'string', enum: ['post', 'page'] },
292
+ include_seo_score: { type: 'boolean' }
293
+ });
294
+ const { days_threshold: aufDays = 180, limit: aufLimit = 50, post_type: aufPostType = 'post', include_seo_score: aufIncludeSeo = true } = args;
295
+ const aufEndpoint = aufPostType === 'page' ? '/pages' : '/posts';
296
+ const aufUrl = `${aufEndpoint}?per_page=${Math.min(aufLimit, 200)}&status=publish&_fields=id,title,slug,link,date,modified,content,meta,categories`;
297
+ const aufAllPosts = await wpApiCall(aufUrl);
298
+
299
+ const aufNow = Date.now();
300
+ const aufFiltered = [];
301
+
302
+ for (const p of aufAllPosts) {
303
+ const daysSince = Math.floor((aufNow - new Date(p.modified).getTime()) / 86400000);
304
+ if (daysSince < aufDays) continue;
305
+
306
+ const meta = p.meta || {};
307
+ const html = p.content?.rendered || '';
308
+ const wc = countWords(html);
309
+
310
+ let seoScore = null;
311
+ const issues = ['outdated_180d'];
312
+ if (daysSince >= 365) issues.push('outdated_365d');
313
+ if (wc < 300) issues.push('thin_content');
314
+
315
+ if (aufIncludeSeo) {
316
+ const seoTitle = meta.rank_math_title || meta._yoast_wpseo_title || meta._seopress_titles_title || meta._aioseo_title || null;
317
+ const seoDesc = meta.rank_math_description || meta._yoast_wpseo_metadesc || meta._seopress_titles_desc || meta._aioseo_description || null;
318
+ const focusKw = extractFocusKeyword(meta);
319
+ seoScore = 100;
320
+ if (!seoTitle) { seoScore -= 30; issues.push('missing_seo_title'); }
321
+ if (!seoDesc) { seoScore -= 30; issues.push('missing_meta_description'); }
322
+ if (!focusKw) seoScore -= 20;
323
+ if (seoTitle && focusKw && !seoTitle.toLowerCase().includes(focusKw.toLowerCase())) seoScore -= 10;
324
+ if (seoScore < 70) issues.push('low_seo_score');
325
+ }
326
+
327
+ const priority = daysSince * (aufIncludeSeo && seoScore !== null ? (100 - seoScore) / 100 : 1) * (wc < 300 ? 1.5 : 1);
328
+
329
+ aufFiltered.push({
330
+ id: p.id,
331
+ title: strip(p.title?.rendered || ''),
332
+ slug: p.slug,
333
+ link: p.link,
334
+ date: p.date,
335
+ modified: p.modified,
336
+ days_since_modified: daysSince,
337
+ word_count: wc,
338
+ seo_score: seoScore,
339
+ priority_score: Math.round(priority * 10) / 10,
340
+ issues
341
+ });
342
+ }
343
+
344
+ aufFiltered.sort((a, b) => b.priority_score - a.priority_score);
345
+
346
+ result = json({
347
+ days_threshold: aufDays,
348
+ total_published: aufAllPosts.length,
349
+ outdated_count: aufFiltered.length,
350
+ outdated_ratio: aufAllPosts.length > 0 ? Math.round(aufFiltered.length / aufAllPosts.length * 100) / 100 : 0,
351
+ posts: aufFiltered
352
+ });
353
+ auditLog({ tool: name, action: 'audit_update_frequency', status: 'success', latency_ms: Date.now() - t0, params: { days_threshold: aufDays, limit: aufLimit, post_type: aufPostType, include_seo_score: aufIncludeSeo } });
354
+ return result;
355
+ };
356
+ handlers['wp_build_link_map'] = async (args) => {
357
+ const t0 = Date.now();
358
+ let result;
359
+ const { wpApiCall, getActiveAuth, targets, auditLog, name, extractInternalLinks } = rt;
360
+ validateInput(args, {
361
+ post_type: { type: 'string', enum: ['post', 'page', 'both'] },
362
+ limit: { type: 'number', min: 1, max: 200 },
363
+ category_id: { type: 'number' }
364
+ });
365
+ const { post_type: lmPostType = 'post', limit: lmLimit = 50, category_id: lmCatId } = args;
366
+ const { url: lmSiteUrl } = getActiveAuth();
367
+ const perPage = Math.min(lmLimit, 200);
368
+ const fields = '_fields=id,title,slug,link,content,categories';
369
+
370
+ let lmPosts = [];
371
+ if (lmPostType === 'both') {
372
+ let postsUrl = `/posts?per_page=${perPage}&status=publish&${fields}`;
373
+ if (lmCatId) postsUrl += `&categories=${lmCatId}`;
374
+ const [posts, pages] = await Promise.all([
375
+ wpApiCall(postsUrl),
376
+ wpApiCall(`/pages?per_page=${perPage}&status=publish&${fields}`)
377
+ ]);
378
+ lmPosts = [...posts, ...pages];
379
+ } else {
380
+ const ep = lmPostType === 'page' ? '/pages' : '/posts';
381
+ let postsUrl = `${ep}?per_page=${perPage}&status=publish&${fields}`;
382
+ if (lmCatId && lmPostType !== 'page') postsUrl += `&categories=${lmCatId}`;
383
+ lmPosts = await wpApiCall(postsUrl);
384
+ }
385
+
386
+ // Build lookup maps
387
+ const postMap = new Map();
388
+ const slugToId = new Map();
389
+ for (const p of lmPosts) {
390
+ postMap.set(p.id, {
391
+ id: p.id,
392
+ title: strip(p.title?.rendered || ''),
393
+ slug: p.slug,
394
+ link: p.link,
395
+ outbound_links: [],
396
+ inbound_count: 0,
397
+ unresolved_links: 0
398
+ });
399
+ slugToId.set(p.slug, p.id);
400
+ if (p.link) {
401
+ try { slugToId.set(new URL(p.link).pathname.replace(/\/+$/, ''), p.id); } catch { /* skip */ }
402
+ }
403
+ }
404
+
405
+ // Extract links and resolve targets
406
+ let totalLinks = 0;
407
+ const linkMatrix = {};
408
+
409
+ for (const p of lmPosts) {
410
+ const html = p.content?.rendered || '';
411
+ const internalLinks = extractInternalLinks(html, lmSiteUrl);
412
+ const resolvedTargets = [];
413
+ let unresolvedCount = 0;
414
+
415
+ for (const link of internalLinks) {
416
+ let targetId = null;
417
+ // Try to match by URL path
418
+ try {
419
+ const linkPath = new URL(link.url || link).pathname.replace(/\/+$/, '');
420
+ targetId = slugToId.get(linkPath) || null;
421
+ if (!targetId) {
422
+ // Try matching just the last segment as slug
423
+ const lastSeg = linkPath.split('/').filter(Boolean).pop();
424
+ if (lastSeg) targetId = slugToId.get(lastSeg) || null;
425
+ }
426
+ } catch { /* skip */ }
427
+
428
+ if (targetId && targetId !== p.id && postMap.has(targetId)) {
429
+ const target = postMap.get(targetId);
430
+ resolvedTargets.push({
431
+ target_id: targetId,
432
+ target_title: target.title,
433
+ anchor_text: link.anchor_text || link.text || ''
434
+ });
435
+ target.inbound_count++;
436
+ totalLinks++;
437
+ } else {
438
+ unresolvedCount++;
439
+ }
440
+ }
441
+
442
+ const entry = postMap.get(p.id);
443
+ entry.outbound_links = resolvedTargets;
444
+ entry.unresolved_links = unresolvedCount;
445
+ if (resolvedTargets.length > 0) {
446
+ linkMatrix[p.id] = resolvedTargets.map(l => l.target_id);
447
+ }
448
+ }
449
+
450
+ // Simplified PageRank (10 iterations, damping 0.85)
451
+ const N = lmPosts.length;
452
+ const damping = 0.85;
453
+ let scores = new Map();
454
+ for (const p of lmPosts) scores.set(p.id, 1 / N);
455
+
456
+ for (let iter = 0; iter < 10; iter++) {
457
+ const newScores = new Map();
458
+ for (const p of lmPosts) newScores.set(p.id, (1 - damping) / N);
459
+ for (const p of lmPosts) {
460
+ const entry = postMap.get(p.id);
461
+ const outCount = entry.outbound_links.length;
462
+ if (outCount > 0) {
463
+ const share = (damping * scores.get(p.id)) / outCount;
464
+ for (const link of entry.outbound_links) {
465
+ newScores.set(link.target_id, (newScores.get(link.target_id) || 0) + share);
466
+ }
467
+ }
468
+ }
469
+ scores = newScores;
470
+ }
471
+
472
+ // Normalize to 0-100
473
+ let maxScore = 0;
474
+ for (const s of scores.values()) { if (s > maxScore) maxScore = s; }
475
+ const normalizedScores = new Map();
476
+ for (const [id, s] of scores) {
477
+ normalizedScores.set(id, maxScore > 0 ? Math.round((s / maxScore) * 100 * 10) / 10 : 0);
478
+ }
479
+
480
+ // Build result array
481
+ const lmResults = [];
482
+ for (const [id, entry] of postMap) {
483
+ lmResults.push({
484
+ id,
485
+ title: entry.title,
486
+ slug: entry.slug,
487
+ link: entry.link,
488
+ outbound_count: entry.outbound_links.length,
489
+ inbound_count: entry.inbound_count,
490
+ pagerank_score: normalizedScores.get(id) || 0,
491
+ is_orphan: entry.inbound_count === 0,
492
+ outbound_links: entry.outbound_links,
493
+ unresolved_links: entry.unresolved_links
494
+ });
495
+ }
496
+
497
+ lmResults.sort((a, b) => b.pagerank_score - a.pagerank_score);
498
+
499
+ const orphanCount = lmResults.filter(r => r.is_orphan).length;
500
+ result = json({
501
+ total_analyzed: lmPosts.length,
502
+ total_internal_links: totalLinks,
503
+ avg_outbound_per_post: lmPosts.length > 0 ? Math.round(totalLinks / lmPosts.length * 10) / 10 : 0,
504
+ orphan_posts: orphanCount,
505
+ posts: lmResults,
506
+ link_matrix: linkMatrix
507
+ });
508
+ auditLog({ tool: name, action: 'build_link_map', status: 'success', latency_ms: Date.now() - t0, params: { post_type: lmPostType, limit: lmLimit, category_id: lmCatId } });
509
+ return result;
510
+ };
511
+ handlers['wp_audit_anchor_texts'] = async (args) => {
512
+ const t0 = Date.now();
513
+ let result;
514
+ const { wpApiCall, getActiveAuth, targets, auditLog, name, extractInternalLinks } = rt;
515
+ validateInput(args, {
516
+ limit: { type: 'number', min: 1, max: 200 },
517
+ post_type: { type: 'string', enum: ['post', 'page', 'both'] }
518
+ });
519
+ const { limit: aatLimit = 50, post_type: aatPostType = 'post' } = args;
520
+ const { url: aatSiteUrl } = getActiveAuth();
521
+ const aatPerPage = Math.min(aatLimit, 200);
522
+ const aatFields = '_fields=id,title,slug,link,content';
523
+
524
+ let aatPosts = [];
525
+ if (aatPostType === 'both') {
526
+ const [posts, pages] = await Promise.all([
527
+ wpApiCall(`/posts?per_page=${aatPerPage}&status=publish&${aatFields}`),
528
+ wpApiCall(`/pages?per_page=${aatPerPage}&status=publish&${aatFields}`)
529
+ ]);
530
+ aatPosts = [...posts, ...pages];
531
+ } else {
532
+ const ep = aatPostType === 'page' ? '/pages' : '/posts';
533
+ aatPosts = await wpApiCall(`${ep}?per_page=${aatPerPage}&status=publish&${aatFields}`);
534
+ }
535
+
536
+ const GENERIC_ANCHORS = ['cliquez ici', 'click here', 'lire la suite', 'read more', 'en savoir plus', 'learn more', 'ici', 'here', 'lien', 'link', 'voir', 'see', 'plus', 'more', 'article', 'page', 'ce lien', 'this link', 'cet article', 'this article'];
537
+
538
+ // Corpus-wide anchor map: anchor_text_lower → { count, posts: Set, targets: Set }
539
+ const anchorMap = new Map();
540
+ const postAnchors = new Map(); // postId → { anchors: [{text,type}], total, unique }
541
+
542
+ let totalInternalLinks = 0;
543
+
544
+ for (const p of aatPosts) {
545
+ const html = p.content?.rendered || '';
546
+ const links = extractInternalLinks(html, aatSiteUrl);
547
+ const postAnchorList = [];
548
+
549
+ for (const link of links) {
550
+ const text = (link.anchor_text || '').trim();
551
+ const lower = text.toLowerCase();
552
+ totalInternalLinks++;
553
+
554
+ postAnchorList.push({ text, lower, href: link.url });
555
+
556
+ if (!anchorMap.has(lower)) {
557
+ anchorMap.set(lower, { count: 0, posts: new Set(), targets: new Set() });
558
+ }
559
+ const entry = anchorMap.get(lower);
560
+ entry.count++;
561
+ entry.posts.add(p.id);
562
+ entry.targets.add(link.url);
563
+ }
564
+
565
+ postAnchors.set(p.id, postAnchorList);
566
+ }
567
+
568
+ // Classify each anchor
569
+ const anchorHealth = { healthy: 0, generic: 0, over_optimized: 0, image_link: 0 };
570
+ const genericAnchorsMap = new Map(); // text → { count, post_ids }
571
+ const overOptimized = [];
572
+
573
+ for (const [lower, entry] of anchorMap) {
574
+ if (lower === '' || /^\s*$/.test(lower)) {
575
+ anchorHealth.image_link += entry.count;
576
+ } else if (GENERIC_ANCHORS.includes(lower)) {
577
+ anchorHealth.generic += entry.count;
578
+ genericAnchorsMap.set(lower, { text: lower, count: entry.count, post_ids: [...entry.posts] });
579
+ } else if (entry.count > 3 && entry.targets.size > 1) {
580
+ anchorHealth.over_optimized += entry.count;
581
+ overOptimized.push({ text: lower, count: entry.count, target_count: entry.targets.size, post_ids: [...entry.posts] });
582
+ } else {
583
+ anchorHealth.healthy += entry.count;
584
+ }
585
+ }
586
+
587
+ // Top 10 generic anchors sorted by count DESC
588
+ const genericAnchors = [...genericAnchorsMap.values()].sort((a, b) => b.count - a.count).slice(0, 10);
589
+
590
+ // Per-post results
591
+ const aatResults = [];
592
+ for (const p of aatPosts) {
593
+ const anchors = postAnchors.get(p.id) || [];
594
+ const total = anchors.length;
595
+ const uniqueTexts = new Set(anchors.map(a => a.lower));
596
+ const unique = uniqueTexts.size;
597
+ const ds = total > 0 ? unique / total : 1;
598
+
599
+ const issues = [];
600
+ const hasGeneric = anchors.some(a => GENERIC_ANCHORS.includes(a.lower));
601
+ const hasOverOpt = anchors.some(a => {
602
+ const e = anchorMap.get(a.lower);
603
+ return e && e.count > 3 && e.targets.size > 1 && a.lower !== '' && !GENERIC_ANCHORS.includes(a.lower);
604
+ });
605
+ const hasImageLink = anchors.some(a => a.lower === '' || /^\s*$/.test(a.lower));
606
+ if (hasGeneric) issues.push('has_generic_anchors');
607
+ if (hasOverOpt) issues.push('has_over_optimized_anchors');
608
+ if (hasImageLink) issues.push('has_image_links');
609
+ if (total > 0 && ds < 0.5) issues.push('low_anchor_diversity');
610
+
611
+ aatResults.push({
612
+ id: p.id,
613
+ title: strip(p.title?.rendered || ''),
614
+ slug: p.slug,
615
+ internal_links_count: total,
616
+ unique_anchors_count: unique,
617
+ diversity_score: Math.round(ds * 100) / 100,
618
+ issues
619
+ });
620
+ }
621
+
622
+ aatResults.sort((a, b) => a.diversity_score - b.diversity_score);
623
+
624
+ result = json({
625
+ total_analyzed: aatPosts.length,
626
+ total_internal_links: totalInternalLinks,
627
+ total_unique_anchors: anchorMap.size,
628
+ anchor_health: anchorHealth,
629
+ generic_anchors: genericAnchors,
630
+ over_optimized_anchors: overOptimized,
631
+ posts: aatResults
632
+ });
633
+ auditLog({ tool: name, action: 'audit_anchor_texts', status: 'success', latency_ms: Date.now() - t0, params: { limit: aatLimit, post_type: aatPostType } });
634
+ return result;
635
+ };
636
+ handlers['wp_audit_schema_markup'] = async (args) => {
637
+ const t0 = Date.now();
638
+ let result;
639
+ const { wpApiCall, auditLog, name } = rt;
640
+ validateInput(args, {
641
+ limit: { type: 'number', min: 1, max: 200 },
642
+ post_type: { type: 'string', enum: ['post', 'page', 'both'] }
643
+ });
644
+ const { limit: asmLimit = 50, post_type: asmPostType = 'post' } = args;
645
+ const asmPerPage = Math.min(asmLimit, 200);
646
+ const asmFields = '_fields=id,title,slug,link,content';
647
+
648
+ let asmPosts = [];
649
+ if (asmPostType === 'both') {
650
+ const [posts, pages] = await Promise.all([
651
+ wpApiCall(`/posts?per_page=${asmPerPage}&status=publish&${asmFields}`),
652
+ wpApiCall(`/pages?per_page=${asmPerPage}&status=publish&${asmFields}`)
653
+ ]);
654
+ asmPosts = [...posts, ...pages];
655
+ } else {
656
+ const ep = asmPostType === 'page' ? '/pages' : '/posts';
657
+ asmPosts = await wpApiCall(`${ep}?per_page=${asmPerPage}&status=publish&${asmFields}`);
658
+ }
659
+
660
+ const SCHEMA_REQUIRED_FIELDS = {
661
+ 'Article': ['headline', 'datePublished', 'author'],
662
+ 'NewsArticle': ['headline', 'datePublished', 'author'],
663
+ 'BlogPosting': ['headline', 'datePublished', 'author'],
664
+ 'FAQPage': ['mainEntity'],
665
+ 'HowTo': ['name', 'step'],
666
+ 'LocalBusiness': ['name', 'address'],
667
+ 'BreadcrumbList': ['itemListElement'],
668
+ 'Organization': ['name'],
669
+ 'WebPage': ['name']
670
+ };
671
+ const ARTICLE_TYPES = ['Article', 'NewsArticle', 'BlogPosting'];
672
+
673
+ const typeDist = {};
674
+ let totalSchemas = 0;
675
+ let validCount = 0;
676
+ let invalidCount = 0;
677
+ let postsWithSchema = 0;
678
+ let postsWithout = 0;
679
+
680
+ const ldJsonRegex = /<script\s+type=["']application\/ld\+json["'][^>]*>([\s\S]*?)<\/script>/gi;
681
+
682
+ const asmResults = [];
683
+ for (const p of asmPosts) {
684
+ const html = p.content?.rendered || '';
685
+ const schemas = [];
686
+ const issues = [];
687
+ let hasInvalidJson = false;
688
+ let hasArticleSchema = false;
689
+
690
+ let ldMatch;
691
+ ldJsonRegex.lastIndex = 0;
692
+ while ((ldMatch = ldJsonRegex.exec(html)) !== null) {
693
+ const raw = ldMatch[1].trim();
694
+ let parsed;
695
+ try {
696
+ parsed = JSON.parse(raw);
697
+ } catch {
698
+ schemas.push({ type: 'unknown', valid: false, missing_fields: [], raw_type: 'parse_error' });
699
+ hasInvalidJson = true;
700
+ invalidCount++;
701
+ totalSchemas++;
702
+ continue;
703
+ }
704
+
705
+ const types = Array.isArray(parsed['@type']) ? parsed['@type'] : [parsed['@type'] || 'unknown'];
706
+ for (const t of types) {
707
+ const required = SCHEMA_REQUIRED_FIELDS[t];
708
+ if (ARTICLE_TYPES.includes(t)) hasArticleSchema = true;
709
+
710
+ if (required) {
711
+ const missing = required.filter(f => {
712
+ const val = parsed[f];
713
+ if (val === undefined || val === null) return true;
714
+ if (Array.isArray(val) && val.length === 0) return true;
715
+ return false;
716
+ });
717
+ const isValid = missing.length === 0;
718
+ schemas.push({ type: t, valid: isValid, missing_fields: missing, raw_type: t });
719
+ if (isValid) validCount++; else invalidCount++;
720
+ if (!isValid) issues.push('missing_required_fields');
721
+ typeDist[t] = (typeDist[t] || 0) + 1;
722
+ } else {
723
+ schemas.push({ type: t, valid: true, missing_fields: [], raw_type: t });
724
+ validCount++;
725
+ typeDist['other'] = (typeDist['other'] || 0) + 1;
726
+ }
727
+ totalSchemas++;
728
+ }
729
+ }
730
+
731
+ if (schemas.length === 0) {
732
+ issues.push('no_schema');
733
+ postsWithout++;
734
+ } else {
735
+ postsWithSchema++;
736
+ }
737
+ if (hasInvalidJson) issues.push('invalid_json');
738
+ if (!hasArticleSchema && schemas.length > 0) issues.push('no_article_schema');
739
+
740
+ asmResults.push({
741
+ id: p.id,
742
+ title: strip(p.title?.rendered || ''),
743
+ slug: p.slug,
744
+ link: p.link,
745
+ schemas_found: schemas.length,
746
+ schemas,
747
+ issues
748
+ });
749
+ }
750
+
751
+ // Sort: posts with issues first, by issue count DESC
752
+ asmResults.sort((a, b) => b.issues.length - a.issues.length);
753
+
754
+ result = json({
755
+ total_analyzed: asmPosts.length,
756
+ posts_with_schema: postsWithSchema,
757
+ posts_without_schema: postsWithout,
758
+ schema_type_distribution: typeDist,
759
+ total_schemas_found: totalSchemas,
760
+ total_valid: validCount,
761
+ total_invalid: invalidCount,
762
+ posts: asmResults
763
+ });
764
+ auditLog({ tool: name, action: 'audit_schema_markup', status: 'success', latency_ms: Date.now() - t0, params: { limit: asmLimit, post_type: asmPostType } });
765
+ return result;
766
+ };
767
+ handlers['wp_audit_content_structure'] = async (args) => {
768
+ const t0 = Date.now();
769
+ let result;
770
+ const { wpApiCall, auditLog, name, detectContentSections, countWords } = rt;
771
+ validateInput(args, {
772
+ limit: { type: 'number', min: 1, max: 200 },
773
+ post_type: { type: 'string', enum: ['post', 'page', 'both'] },
774
+ category_id: { type: 'number' }
775
+ });
776
+ const { limit: acsLimit = 50, post_type: acsPostType = 'post', category_id: acsCatId } = args;
777
+ const acsPerPage = Math.min(acsLimit, 200);
778
+ const acsFields = '_fields=id,title,slug,link,content,categories';
779
+
780
+ let acsPosts = [];
781
+ if (acsPostType === 'both') {
782
+ let postsUrl = `/posts?per_page=${acsPerPage}&status=publish&${acsFields}`;
783
+ if (acsCatId) postsUrl += `&categories=${acsCatId}`;
784
+ const [posts, pages] = await Promise.all([
785
+ wpApiCall(postsUrl),
786
+ wpApiCall(`/pages?per_page=${acsPerPage}&status=publish&${acsFields}`)
787
+ ]);
788
+ acsPosts = [...posts, ...pages];
789
+ } else {
790
+ const ep = acsPostType === 'page' ? '/pages' : '/posts';
791
+ let postsUrl = `${ep}?per_page=${acsPerPage}&status=publish&${acsFields}`;
792
+ if (acsCatId && acsPostType !== 'page') postsUrl += `&categories=${acsCatId}`;
793
+ acsPosts = await wpApiCall(postsUrl);
794
+ }
795
+
796
+ let totalScore = 0;
797
+ const distribution = { excellent: 0, good: 0, average: 0, poor: 0 };
798
+ const featureCounts = { intro: 0, conclusion: 0, faq: 0, toc: 0, lists: 0, tables: 0, images: 0, blockquotes: 0 };
799
+
800
+ const acsResults = [];
801
+ for (const p of acsPosts) {
802
+ const html = p.content?.rendered || '';
803
+ const sections = detectContentSections(html);
804
+ const wc = countWords(html);
805
+
806
+ // Paragraphs count (non-empty <p>)
807
+ const pMatches = html.match(/<p[^>]*>(?!\s*<\/p>)/gi) || [];
808
+ const paragraphsCount = pMatches.length || 1;
809
+ const avgParagraphLength = Math.round(wc / paragraphsCount);
810
+
811
+ // TOC detection
812
+ const hasToc = /<[^>]+(?:id|class)=["'][^"']*(?:toc|table-of-contents|table-des-matieres)[^"']*["'][^>]*>/i.test(html);
813
+
814
+ // Blockquotes and code blocks
815
+ const blockquotesCount = (html.match(/<blockquote\b/gi) || []).length;
816
+ const codeBlocksCount = (html.match(/<(?:pre|code)\b/gi) || []).length;
817
+
818
+ // Heading density: headings per 300 words
819
+ const headingDensity = wc > 0 ? sections.headings_count / (wc / 300) : 0;
820
+
821
+ // Structure score (0-100)
822
+ let score = 0;
823
+ if (sections.has_intro) score += 15;
824
+ if (sections.has_conclusion) score += 10;
825
+ if (sections.headings_count >= 3) score += 15;
826
+ if (headingDensity >= 0.5 && headingDensity <= 2.0) score += 10;
827
+ if (sections.lists_count >= 1) score += 10;
828
+ if (sections.images_count >= 1) score += 10;
829
+ if (sections.has_faq) score += 10;
830
+ if (sections.tables_count >= 1) score += 5;
831
+ if (hasToc) score += 5;
832
+ if (blockquotesCount > 0 || codeBlocksCount > 0) score += 5;
833
+ if (avgParagraphLength < 100) score += 5;
834
+
835
+ totalScore += score;
836
+
837
+ if (score >= 80) distribution.excellent++;
838
+ else if (score >= 60) distribution.good++;
839
+ else if (score >= 40) distribution.average++;
840
+ else distribution.poor++;
841
+
842
+ // Feature coverage tracking
843
+ if (sections.has_intro) featureCounts.intro++;
844
+ if (sections.has_conclusion) featureCounts.conclusion++;
845
+ if (sections.has_faq) featureCounts.faq++;
846
+ if (hasToc) featureCounts.toc++;
847
+ if (sections.lists_count >= 1) featureCounts.lists++;
848
+ if (sections.tables_count >= 1) featureCounts.tables++;
849
+ if (sections.images_count >= 1) featureCounts.images++;
850
+ if (blockquotesCount > 0) featureCounts.blockquotes++;
851
+
852
+ // Issues
853
+ const issues = [];
854
+ if (!sections.has_intro) issues.push('no_intro');
855
+ if (!sections.has_conclusion) issues.push('no_conclusion');
856
+ if (sections.headings_count === 0) issues.push('no_headings');
857
+ if (headingDensity < 0.3 && wc > 100) issues.push('low_heading_density');
858
+ if (headingDensity > 3.0) issues.push('high_heading_density');
859
+ if (sections.images_count === 0) issues.push('no_images');
860
+ if (sections.lists_count === 0) issues.push('no_lists');
861
+ if (avgParagraphLength > 150) issues.push('long_paragraphs');
862
+ if (score < 40) issues.push('poor_structure');
863
+
864
+ acsResults.push({
865
+ id: p.id,
866
+ title: strip(p.title?.rendered || ''),
867
+ slug: p.slug,
868
+ link: p.link,
869
+ word_count: wc,
870
+ structure_score: score,
871
+ features: {
872
+ has_intro: sections.has_intro,
873
+ has_conclusion: sections.has_conclusion,
874
+ has_faq: sections.has_faq,
875
+ has_toc: hasToc,
876
+ headings_count: sections.headings_count,
877
+ lists_count: sections.lists_count,
878
+ tables_count: sections.tables_count,
879
+ images_count: sections.images_count,
880
+ paragraphs_count: paragraphsCount,
881
+ avg_paragraph_length: avgParagraphLength,
882
+ blockquotes_count: blockquotesCount,
883
+ code_blocks_count: codeBlocksCount,
884
+ heading_density: Math.round(headingDensity * 100) / 100
885
+ },
886
+ issues
887
+ });
888
+ }
889
+
890
+ acsResults.sort((a, b) => a.structure_score - b.structure_score);
891
+
892
+ const acsTotal = acsPosts.length;
893
+ const featureCoverage = {};
894
+ for (const [key, count] of Object.entries(featureCounts)) {
895
+ featureCoverage[key] = acsTotal > 0 ? Math.round(count / acsTotal * 100) : 0;
896
+ }
897
+
898
+ result = json({
899
+ total_analyzed: acsTotal,
900
+ avg_structure_score: acsTotal > 0 ? Math.round(totalScore / acsTotal * 10) / 10 : 0,
901
+ distribution,
902
+ feature_coverage: featureCoverage,
903
+ posts: acsResults
904
+ });
905
+ auditLog({ tool: name, action: 'audit_content_structure', status: 'success', latency_ms: Date.now() - t0, params: { limit: acsLimit, post_type: acsPostType, category_id: acsCatId } });
906
+ return result;
907
+ };
908
+ handlers['wp_find_duplicate_content'] = async (args) => {
909
+ const t0 = Date.now();
910
+ let result;
911
+ const { wpApiCall, auditLog, name, findDuplicatePairs, countWords } = rt;
912
+ validateInput(args, {
913
+ limit: { type: 'number', min: 1, max: 100 },
914
+ post_type: { type: 'string', enum: ['post', 'page'] },
915
+ category_id: { type: 'number' },
916
+ similarity_threshold: { type: 'number', min: 0, max: 1 }
917
+ });
918
+ const { limit: fdcLimit = 50, post_type: fdcPostType = 'post', category_id: fdcCatId, similarity_threshold: fdcThreshold = 0.7 } = args;
919
+ const fdcPerPage = Math.min(fdcLimit, 100);
920
+ const fdcFields = '_fields=id,title,content,slug,link';
921
+
922
+ const fdcEp = fdcPostType === 'page' ? '/pages' : '/posts';
923
+ let fdcUrl = `${fdcEp}?per_page=${fdcPerPage}&status=publish&${fdcFields}`;
924
+ if (fdcCatId && fdcPostType !== 'page') fdcUrl += `&categories=${fdcCatId}`;
925
+
926
+ const fdcPosts = await wpApiCall(fdcUrl);
927
+
928
+ const fdcDocs = [];
929
+ const fdcPostMap = new Map();
930
+ for (const p of fdcPosts) {
931
+ const text = strip(p.content?.rendered || '');
932
+ const wc = countWords(p.content?.rendered || '');
933
+ fdcPostMap.set(p.id, { id: p.id, title: strip(p.title?.rendered || ''), slug: p.slug, word_count: wc });
934
+ if (wc >= 50) {
935
+ fdcDocs.push({ id: p.id, title: strip(p.title?.rendered || ''), text });
936
+ }
937
+ }
938
+
939
+ const fdcPairs = findDuplicatePairs(fdcDocs, fdcThreshold);
940
+
941
+ const formattedPairs = fdcPairs
942
+ .sort((a, b) => b.similarity - a.similarity)
943
+ .map(pair => {
944
+ const p1 = fdcPostMap.get(pair.doc1_id);
945
+ const p2 = fdcPostMap.get(pair.doc2_id);
946
+ const sim = Math.round(pair.similarity * 1000) / 1000;
947
+ return {
948
+ post1: { id: p1.id, title: p1.title, slug: p1.slug, word_count: p1.word_count },
949
+ post2: { id: p2.id, title: p2.title, slug: p2.slug, word_count: p2.word_count },
950
+ similarity: sim,
951
+ severity: sim >= 0.9 ? 'critical' : sim >= 0.8 ? 'high' : 'medium'
952
+ };
953
+ });
954
+
955
+ // Union-find clustering
956
+ const ufParent = new Map();
957
+ const ufFind = (x) => {
958
+ if (!ufParent.has(x)) ufParent.set(x, x);
959
+ if (ufParent.get(x) !== x) ufParent.set(x, ufFind(ufParent.get(x)));
960
+ return ufParent.get(x);
961
+ };
962
+ const ufUnion = (a, b) => { ufParent.set(ufFind(a), ufFind(b)); };
963
+
964
+ for (const pair of fdcPairs) ufUnion(pair.doc1_id, pair.doc2_id);
965
+
966
+ const clusterMap = new Map();
967
+ const idsInPairs = new Set();
968
+ for (const pair of fdcPairs) { idsInPairs.add(pair.doc1_id); idsInPairs.add(pair.doc2_id); }
969
+ for (const id of idsInPairs) {
970
+ const root = ufFind(id);
971
+ if (!clusterMap.has(root)) clusterMap.set(root, { posts: [], maxSim: 0 });
972
+ const info = fdcPostMap.get(id);
973
+ clusterMap.get(root).posts.push({ id: info.id, title: info.title, slug: info.slug });
974
+ }
975
+ for (const pair of fdcPairs) {
976
+ const root = ufFind(pair.doc1_id);
977
+ const cluster = clusterMap.get(root);
978
+ if (pair.similarity > cluster.maxSim) cluster.maxSim = pair.similarity;
979
+ }
980
+
981
+ let fdcClusterId = 0;
982
+ const clusters = [...clusterMap.values()].map(c => ({
983
+ cluster_id: ++fdcClusterId,
984
+ posts: c.posts,
985
+ max_similarity: Math.round(c.maxSim * 1000) / 1000
986
+ }));
987
+
988
+ result = json({
989
+ total_analyzed: fdcPosts.length,
990
+ similarity_threshold: fdcThreshold,
991
+ duplicate_pairs_found: formattedPairs.length,
992
+ duplicate_clusters: clusters.length,
993
+ pairs: formattedPairs,
994
+ clusters
995
+ });
996
+ auditLog({ tool: name, action: 'find_duplicate_content', status: 'success', latency_ms: Date.now() - t0, params: { limit: fdcLimit, post_type: fdcPostType, category_id: fdcCatId, similarity_threshold: fdcThreshold } });
997
+ return result;
998
+ };
999
+ handlers['wp_find_content_gaps'] = async (args) => {
1000
+ const t0 = Date.now();
1001
+ let result;
1002
+ const { wpApiCall, auditLog, name } = rt;
1003
+ validateInput(args, {
1004
+ min_posts: { type: 'number', min: 1, max: 50 },
1005
+ taxonomy: { type: 'string', enum: ['category', 'post_tag', 'both'] },
1006
+ exclude_empty: { type: 'boolean' }
1007
+ });
1008
+ const { min_posts: fcgMinPosts = 3, taxonomy: fcgTaxonomy = 'both', exclude_empty: fcgExcludeEmpty = false } = args;
1009
+
1010
+ let fcgCategories = [];
1011
+ let fcgTags = [];
1012
+
1013
+ if (fcgTaxonomy === 'category' || fcgTaxonomy === 'both') {
1014
+ fcgCategories = await wpApiCall('/categories?per_page=100&_fields=id,name,slug,count,description,parent');
1015
+ }
1016
+ if (fcgTaxonomy === 'post_tag' || fcgTaxonomy === 'both') {
1017
+ fcgTags = await wpApiCall('/tags?per_page=100&_fields=id,name,slug,count');
1018
+ }
1019
+
1020
+ const catMap = new Map();
1021
+ for (const c of fcgCategories) catMap.set(c.id, c);
1022
+
1023
+ const fcgGaps = [];
1024
+ const fcgWellCovered = [];
1025
+
1026
+ for (const c of fcgCategories) {
1027
+ if (fcgExcludeEmpty && c.count === 0) continue;
1028
+ if (c.count < fcgMinPosts) {
1029
+ const parentInfo = c.parent ? catMap.get(c.parent) : null;
1030
+ fcgGaps.push({
1031
+ taxonomy: 'category', id: c.id, name: c.name, slug: c.slug,
1032
+ current_count: c.count, deficit: fcgMinPosts - c.count,
1033
+ parent_name: parentInfo ? parentInfo.name : null,
1034
+ parent_count: parentInfo ? parentInfo.count : null,
1035
+ severity: c.count === 0 ? 'empty' : 'underrepresented',
1036
+ suggestion: c.count === 0
1037
+ ? `Create ${fcgMinPosts} posts for "${c.name}"`
1038
+ : `Add ${fcgMinPosts - c.count} more posts for "${c.name}"`
1039
+ });
1040
+ } else {
1041
+ fcgWellCovered.push({ taxonomy: 'category', id: c.id, name: c.name, count: c.count });
1042
+ }
1043
+ }
1044
+
1045
+ for (const t of fcgTags) {
1046
+ if (fcgExcludeEmpty && t.count === 0) continue;
1047
+ if (t.count < fcgMinPosts) {
1048
+ fcgGaps.push({
1049
+ taxonomy: 'post_tag', id: t.id, name: t.name, slug: t.slug,
1050
+ current_count: t.count, deficit: fcgMinPosts - t.count,
1051
+ parent_name: null, parent_count: null,
1052
+ severity: t.count === 0 ? 'empty' : 'underrepresented',
1053
+ suggestion: t.count === 0
1054
+ ? `Create ${fcgMinPosts} posts for "${t.name}"`
1055
+ : `Add ${fcgMinPosts - t.count} more posts for "${t.name}"`
1056
+ });
1057
+ } else {
1058
+ fcgWellCovered.push({ taxonomy: 'post_tag', id: t.id, name: t.name, count: t.count });
1059
+ }
1060
+ }
1061
+
1062
+ fcgGaps.sort((a, b) => b.deficit - a.deficit || a.current_count - b.current_count);
1063
+ fcgWellCovered.sort((a, b) => b.count - a.count);
1064
+
1065
+ const catGaps = fcgGaps.filter(g => g.taxonomy === 'category');
1066
+ const tagGaps = fcgGaps.filter(g => g.taxonomy === 'post_tag');
1067
+
1068
+ result = json({
1069
+ min_posts_threshold: fcgMinPosts,
1070
+ total_terms_analyzed: fcgCategories.length + fcgTags.length,
1071
+ gaps_found: fcgGaps.length,
1072
+ gaps_by_taxonomy: { categories: catGaps.length, tags: tagGaps.length },
1073
+ gaps: fcgGaps,
1074
+ well_covered: fcgWellCovered.slice(0, 10)
1075
+ });
1076
+ auditLog({ tool: name, action: 'find_content_gaps', status: 'success', latency_ms: Date.now() - t0, params: { min_posts: fcgMinPosts, taxonomy: fcgTaxonomy, exclude_empty: fcgExcludeEmpty } });
1077
+ return result;
1078
+ };
1079
+ handlers['wp_extract_faq_blocks'] = async (args) => {
1080
+ const t0 = Date.now();
1081
+ let result;
1082
+ const { wpApiCall, auditLog, name } = rt;
1083
+ validateInput(args, {
1084
+ limit: { type: 'number', min: 1, max: 200 },
1085
+ post_type: { type: 'string', enum: ['post', 'page', 'both'] }
1086
+ });
1087
+ const { limit: efbLimit = 50, post_type: efbPostType = 'post' } = args;
1088
+ const efbPerPage = Math.min(efbLimit, 200);
1089
+ const efbFields = '_fields=id,title,slug,link,content';
1090
+
1091
+ let efbPosts = [];
1092
+ if (efbPostType === 'both') {
1093
+ const [posts, pages] = await Promise.all([
1094
+ wpApiCall(`/posts?per_page=${efbPerPage}&status=publish&${efbFields}`),
1095
+ wpApiCall(`/pages?per_page=${efbPerPage}&status=publish&${efbFields}`)
1096
+ ]);
1097
+ efbPosts = [...posts, ...pages];
1098
+ } else {
1099
+ const ep = efbPostType === 'page' ? '/pages' : '/posts';
1100
+ efbPosts = await wpApiCall(`${ep}?per_page=${efbPerPage}&status=publish&${efbFields}`);
1101
+ }
1102
+
1103
+ const efbLdRegex = /<script\s+type=["']application\/ld\+json["'][^>]*>([\s\S]*?)<\/script>/gi;
1104
+ const efbYoastRegex = /<!-- wp:yoast\/faq-block -->([\s\S]*?)<!-- \/wp:yoast\/faq-block -->/gi;
1105
+ const efbRankMathRegex = /<!-- wp:rank-math\/faq-block -->([\s\S]*?)<!-- \/wp:rank-math\/faq-block -->/gi;
1106
+ const efbFaqHeadingRegex = /<h[2-4][^>]*>[^<]*(?:FAQ|Questions fréquentes|Questions courantes|Foire aux questions)[^<]*<\/h[2-4]>/gi;
1107
+
1108
+ let efbTotalQ = 0;
1109
+ let efbPostsWithFaq = 0;
1110
+ const efbBySource = { 'json-ld': 0, 'gutenberg-block': 0, 'html-pattern': 0 };
1111
+
1112
+ const efbResults = [];
1113
+ for (const p of efbPosts) {
1114
+ const html = p.content?.rendered || '';
1115
+ const faqBlocks = [];
1116
+
1117
+ // Type A: JSON-LD FAQPage
1118
+ efbLdRegex.lastIndex = 0;
1119
+ let ldMatch;
1120
+ while ((ldMatch = efbLdRegex.exec(html)) !== null) {
1121
+ try {
1122
+ const parsed = JSON.parse(ldMatch[1].trim());
1123
+ const pType = Array.isArray(parsed['@type']) ? parsed['@type'] : [parsed['@type']];
1124
+ if (pType.includes('FAQPage') && Array.isArray(parsed.mainEntity)) {
1125
+ const questions = parsed.mainEntity
1126
+ .filter(q => q['@type'] === 'Question' && q.name)
1127
+ .map(q => ({ question: q.name, answer: (q.acceptedAnswer?.text || '').slice(0, 200) }));
1128
+ if (questions.length > 0) {
1129
+ faqBlocks.push({ source: 'json-ld', plugin: null, questions_count: questions.length, questions });
1130
+ efbBySource['json-ld'] += questions.length;
1131
+ efbTotalQ += questions.length;
1132
+ }
1133
+ }
1134
+ } catch { /* invalid JSON */ }
1135
+ }
1136
+
1137
+ // Type B: Gutenberg FAQ blocks
1138
+ const processGutenberg = (regex, plugin) => {
1139
+ regex.lastIndex = 0;
1140
+ let gMatch;
1141
+ while ((gMatch = regex.exec(html)) !== null) {
1142
+ const blockHtml = gMatch[1];
1143
+ const questions = [];
1144
+ const qRegex = /<(?:strong|h3)[^>]*class=["'][^"']*faq-question[^"']*["'][^>]*>([\s\S]*?)<\/(?:strong|h3)>/gi;
1145
+ const aRegex = /<(?:p|div)[^>]*class=["'][^"']*faq-answer[^"']*["'][^>]*>([\s\S]*?)<\/(?:p|div)>/gi;
1146
+ const qs = [];
1147
+ const as = [];
1148
+ let qm;
1149
+ while ((qm = qRegex.exec(blockHtml)) !== null) qs.push(strip(qm[1]));
1150
+ let am;
1151
+ while ((am = aRegex.exec(blockHtml)) !== null) as.push(strip(am[1]).slice(0, 200));
1152
+ for (let i = 0; i < qs.length; i++) {
1153
+ questions.push({ question: qs[i], answer: as[i] || '' });
1154
+ }
1155
+ if (questions.length > 0) {
1156
+ faqBlocks.push({ source: 'gutenberg-block', plugin, questions_count: questions.length, questions });
1157
+ efbBySource['gutenberg-block'] += questions.length;
1158
+ efbTotalQ += questions.length;
1159
+ }
1160
+ }
1161
+ };
1162
+ processGutenberg(efbYoastRegex, 'yoast');
1163
+ processGutenberg(efbRankMathRegex, 'rankmath');
1164
+
1165
+ // Type C: HTML pattern FAQ
1166
+ efbFaqHeadingRegex.lastIndex = 0;
1167
+ if (efbFaqHeadingRegex.test(html)) {
1168
+ const questions = [];
1169
+ const h3pRegex = /<h3[^>]*>([\s\S]*?)<\/h3>\s*<p[^>]*>([\s\S]*?)<\/p>/gi;
1170
+ const dtddRegex = /<dt[^>]*>([\s\S]*?)<\/dt>\s*<dd[^>]*>([\s\S]*?)<\/dd>/gi;
1171
+ let hm;
1172
+ while ((hm = h3pRegex.exec(html)) !== null) {
1173
+ const q = strip(hm[1]);
1174
+ if (q) questions.push({ question: q, answer: strip(hm[2]).slice(0, 200) });
1175
+ }
1176
+ if (questions.length === 0) {
1177
+ while ((hm = dtddRegex.exec(html)) !== null) {
1178
+ const q = strip(hm[1]);
1179
+ if (q) questions.push({ question: q, answer: strip(hm[2]).slice(0, 200) });
1180
+ }
1181
+ }
1182
+ if (questions.length > 0) {
1183
+ faqBlocks.push({ source: 'html-pattern', plugin: null, questions_count: questions.length, questions });
1184
+ efbBySource['html-pattern'] += questions.length;
1185
+ efbTotalQ += questions.length;
1186
+ }
1187
+ }
1188
+
1189
+ const hasFaq = faqBlocks.length > 0;
1190
+ if (hasFaq) efbPostsWithFaq++;
1191
+
1192
+ efbResults.push({
1193
+ id: p.id, title: strip(p.title?.rendered || ''), slug: p.slug, link: p.link,
1194
+ has_faq: hasFaq, faq_blocks: faqBlocks
1195
+ });
1196
+ }
1197
+
1198
+ let efbFiltered = efbPosts.length <= 10
1199
+ ? efbResults
1200
+ : efbResults.filter(r => r.has_faq);
1201
+
1202
+ efbFiltered.sort((a, b) => {
1203
+ const qA = a.faq_blocks.reduce((s, f) => s + f.questions_count, 0);
1204
+ const qB = b.faq_blocks.reduce((s, f) => s + f.questions_count, 0);
1205
+ return qB - qA;
1206
+ });
1207
+
1208
+ result = json({
1209
+ total_analyzed: efbPosts.length,
1210
+ posts_with_faq: efbPostsWithFaq,
1211
+ total_questions: efbTotalQ,
1212
+ faq_by_source: efbBySource,
1213
+ posts: efbFiltered
1214
+ });
1215
+ auditLog({ tool: name, action: 'extract_faq_blocks', status: 'success', latency_ms: Date.now() - t0, params: { limit: efbLimit, post_type: efbPostType } });
1216
+ return result;
1217
+ };
1218
+ handlers['wp_audit_cta_presence'] = async (args) => {
1219
+ const t0 = Date.now();
1220
+ let result;
1221
+ const { wpApiCall, auditLog, name } = rt;
1222
+ validateInput(args, {
1223
+ limit: { type: 'number', min: 1, max: 200 },
1224
+ post_type: { type: 'string', enum: ['post', 'page', 'both'] },
1225
+ category_id: { type: 'number' }
1226
+ });
1227
+ const { limit: acpLimit = 50, post_type: acpPostType = 'post', category_id: acpCatId } = args;
1228
+ const acpPerPage = Math.min(acpLimit, 200);
1229
+ const acpFields = '_fields=id,title,slug,link,content,categories';
1230
+
1231
+ let acpPosts = [];
1232
+ if (acpPostType === 'both') {
1233
+ let postsUrl = `/posts?per_page=${acpPerPage}&status=publish&${acpFields}`;
1234
+ if (acpCatId) postsUrl += `&categories=${acpCatId}`;
1235
+ const [posts, pages] = await Promise.all([
1236
+ wpApiCall(postsUrl),
1237
+ wpApiCall(`/pages?per_page=${acpPerPage}&status=publish&${acpFields}`)
1238
+ ]);
1239
+ acpPosts = [...posts, ...pages];
1240
+ } else {
1241
+ const ep = acpPostType === 'page' ? '/pages' : '/posts';
1242
+ let postsUrl = `${ep}?per_page=${acpPerPage}&status=publish&${acpFields}`;
1243
+ if (acpCatId && acpPostType !== 'page') postsUrl += `&categories=${acpCatId}`;
1244
+ acpPosts = await wpApiCall(postsUrl);
1245
+ }
1246
+
1247
+ const CTA_CONTACT_HREFS = ['/contact', '/nous-contacter', '/contactez-nous', 'mailto:'];
1248
+ const CTA_CONTACT_ANCHORS = ['contactez', 'contact', 'nous contacter'];
1249
+ const CTA_FORM_PATTERNS = ['wpforms', 'cf7', 'gravity', 'elementor-form', 'formulaire', 'form'];
1250
+ const CTA_BUTTON_TEXTS = ['devis', 'essai', 'commencer', 'inscription', "s'inscrire", 'acheter', 'commander', 'réserver', 'télécharger', 'download', 'get started', 'sign up', 'buy', 'order', 'book', 'subscribe'];
1251
+ const CTA_QUOTE_TEXTS = ['devis', 'demande de devis', 'quote', 'request a quote', 'estimation'];
1252
+ const CTA_SIGNUP_HREFS = ['/inscription', '/register', '/signup', '/trial', '/essai'];
1253
+
1254
+ let acpWithCta = 0;
1255
+ let acpWithoutCta = 0;
1256
+ const ctaTypeDist = { contact_link: 0, form: 0, button_cta: 0, phone_link: 0, quote_request: 0, signup_link: 0 };
1257
+
1258
+ const acpResults = [];
1259
+ for (const p of acpPosts) {
1260
+ const html = p.content?.rendered || '';
1261
+ const lower = html.toLowerCase();
1262
+ const ctas = [];
1263
+ const ctaTypesFound = new Set();
1264
+
1265
+ // Scan all links
1266
+ const linkRegex = /<a\b[^>]*href=["']([^"']*)["'][^>]*>([\s\S]*?)<\/a>/gi;
1267
+ let lm;
1268
+ while ((lm = linkRegex.exec(html)) !== null) {
1269
+ const href = lm[1].toLowerCase();
1270
+ const anchor = strip(lm[2]).toLowerCase();
1271
+
1272
+ if (CTA_CONTACT_HREFS.some(pat => href.includes(pat)) || CTA_CONTACT_ANCHORS.some(txt => anchor.includes(txt))) {
1273
+ ctas.push({ type: 'contact_link', text: strip(lm[2]), href: lm[1] });
1274
+ ctaTypesFound.add('contact_link');
1275
+ }
1276
+ if (href.startsWith('tel:')) {
1277
+ ctas.push({ type: 'phone_link', text: strip(lm[2]), href: lm[1] });
1278
+ ctaTypesFound.add('phone_link');
1279
+ }
1280
+ if (CTA_SIGNUP_HREFS.some(pat => href.includes(pat))) {
1281
+ ctas.push({ type: 'signup_link', text: strip(lm[2]), href: lm[1] });
1282
+ ctaTypesFound.add('signup_link');
1283
+ }
1284
+ if (CTA_QUOTE_TEXTS.some(txt => anchor.includes(txt))) {
1285
+ ctas.push({ type: 'quote_request', text: strip(lm[2]), href: lm[1] });
1286
+ ctaTypesFound.add('quote_request');
1287
+ }
1288
+ }
1289
+
1290
+ // Forms
1291
+ if (/<form\b/i.test(html) || CTA_FORM_PATTERNS.some(pat => lower.includes(pat))) {
1292
+ const formElement = CTA_FORM_PATTERNS.find(pat => lower.includes(pat)) || 'form';
1293
+ ctas.push({ type: 'form', element: formElement });
1294
+ ctaTypesFound.add('form');
1295
+ }
1296
+
1297
+ // Button CTAs (by class)
1298
+ const btnClassRegex = /<(?:button|a)\b[^>]*class=["'][^"']*(?:cta|btn-cta|call-to-action|bouton-action)[^"']*["'][^>]*>([\s\S]*?)<\/(?:button|a)>/gi;
1299
+ let bm;
1300
+ while ((bm = btnClassRegex.exec(html)) !== null) {
1301
+ ctas.push({ type: 'button_cta', text: strip(bm[1]) });
1302
+ ctaTypesFound.add('button_cta');
1303
+ }
1304
+ // Button CTAs (by text)
1305
+ if (!ctaTypesFound.has('button_cta')) {
1306
+ const btnTextRegex = /<button\b[^>]*>([\s\S]*?)<\/button>/gi;
1307
+ let btm;
1308
+ while ((btm = btnTextRegex.exec(html)) !== null) {
1309
+ const btnText = strip(btm[1]).toLowerCase();
1310
+ if (CTA_BUTTON_TEXTS.some(txt => btnText.includes(txt))) {
1311
+ ctas.push({ type: 'button_cta', text: strip(btm[1]) });
1312
+ ctaTypesFound.add('button_cta');
1313
+ break;
1314
+ }
1315
+ }
1316
+ }
1317
+
1318
+ const uniqueTypes = ctaTypesFound.size;
1319
+ let ctaScore = 0;
1320
+ if (uniqueTypes >= 3) ctaScore = 100;
1321
+ else if (uniqueTypes === 2) ctaScore = 70;
1322
+ else if (uniqueTypes === 1) ctaScore = 40;
1323
+
1324
+ const issues = [];
1325
+ if (uniqueTypes === 0) { issues.push('no_cta'); acpWithoutCta++; }
1326
+ else { acpWithCta++; }
1327
+ if (uniqueTypes === 1) issues.push('single_cta_type');
1328
+
1329
+ for (const ct of ctaTypesFound) ctaTypeDist[ct]++;
1330
+
1331
+ acpResults.push({
1332
+ id: p.id, title: strip(p.title?.rendered || ''), slug: p.slug, link: p.link,
1333
+ cta_score: ctaScore, cta_count: ctas.length, cta_types: [...ctaTypesFound],
1334
+ ctas, issues
1335
+ });
1336
+ }
1337
+
1338
+ acpResults.sort((a, b) => a.cta_score - b.cta_score);
1339
+
1340
+ const acpTotal = acpPosts.length;
1341
+ result = json({
1342
+ total_analyzed: acpTotal,
1343
+ posts_with_cta: acpWithCta,
1344
+ posts_without_cta: acpWithoutCta,
1345
+ cta_coverage: acpTotal > 0 ? Math.round(acpWithCta / acpTotal * 100) : 0,
1346
+ cta_type_distribution: ctaTypeDist,
1347
+ posts: acpResults
1348
+ });
1349
+ auditLog({ tool: name, action: 'audit_cta_presence', status: 'success', latency_ms: Date.now() - t0, params: { limit: acpLimit, post_type: acpPostType, category_id: acpCatId } });
1350
+ return result;
1351
+ };
1352
+ handlers['wp_extract_entities'] = async (args) => {
1353
+ const t0 = Date.now();
1354
+ let result;
1355
+ const { wpApiCall, auditLog, name, extractEntities } = rt;
1356
+ validateInput(args, { limit: { type: 'number', min: 1, max: 100 }, post_type: { type: 'string', enum: ['post', 'page'] }, min_occurrences: { type: 'number', min: 1 } });
1357
+ const eeLimit = args.limit || 20;
1358
+ const eePostType = args.post_type || 'post';
1359
+ const eeMinOcc = args.min_occurrences || 2;
1360
+
1361
+ const eePosts = await wpApiCall(`/${eePostType}s?per_page=${eeLimit}&status=publish&_fields=id,title,content,slug`);
1362
+
1363
+ const globalEntities = new Map(); // name -> { type, count, post_ids, contexts }
1364
+ const postResults = [];
1365
+
1366
+ for (const p of eePosts) {
1367
+ const text = strip(p.content?.rendered || '');
1368
+ const entities = extractEntities(text);
1369
+ const localEntities = [];
1370
+
1371
+ for (const ent of entities) {
1372
+ localEntities.push({ name: ent.name, type: ent.type, count: ent.count });
1373
+ if (!globalEntities.has(ent.name)) {
1374
+ globalEntities.set(ent.name, { type: ent.type, count: 0, post_ids: new Set(), contexts: [] });
1375
+ }
1376
+ const g = globalEntities.get(ent.name);
1377
+ g.count += ent.count;
1378
+ g.post_ids.add(p.id);
1379
+ for (const ctx of ent.contexts) {
1380
+ if (g.contexts.length < 2) g.contexts.push(ctx);
1381
+ }
1382
+ }
1383
+
1384
+ postResults.push({
1385
+ id: p.id, title: strip(p.title?.rendered || ''), slug: p.slug,
1386
+ entities_count: localEntities.length,
1387
+ entities: localEntities
1388
+ });
1389
+ }
1390
+
1391
+ // Filter by min_occurrences and build aggregated results
1392
+ const filteredEntities = [...globalEntities.entries()]
1393
+ .filter(([, v]) => v.count >= eeMinOcc)
1394
+ .sort((a, b) => b[1].count - a[1].count);
1395
+
1396
+ const byType = { brand: 0, location: 0, person: 0, organization: 0, unknown: 0 };
1397
+ for (const [, v] of filteredEntities) {
1398
+ if (byType[v.type] !== undefined) byType[v.type]++;
1399
+ }
1400
+
1401
+ result = json({
1402
+ total_analyzed: eePosts.length,
1403
+ total_entities_found: filteredEntities.length,
1404
+ entities_by_type: byType,
1405
+ top_entities: filteredEntities.slice(0, 20).map(([name, v]) => ({
1406
+ name, type: v.type, total_count: v.count, post_ids: [...v.post_ids], contexts: v.contexts
1407
+ })),
1408
+ posts: postResults
1409
+ });
1410
+ auditLog({ tool: name, action: 'extract_entities', status: 'success', latency_ms: Date.now() - t0, params: { limit: eeLimit, post_type: eePostType, min_occurrences: eeMinOcc } });
1411
+ return result;
1412
+ };
1413
+ handlers['wp_get_publishing_velocity'] = async (args) => {
1414
+ const t0 = Date.now();
1415
+ let result;
1416
+ const { wpApiCall, auditLog, name } = rt;
1417
+ validateInput(args, { periods: { type: 'string' }, post_type: { type: 'string', enum: ['post', 'page'] }, limit: { type: 'number', min: 1, max: 500 } });
1418
+ const pvPeriodsStr = args.periods || '30,90,180';
1419
+ const pvPostType = args.post_type || 'post';
1420
+ const pvLimit = args.limit || 200;
1421
+
1422
+ const periodDays = pvPeriodsStr.split(',').map(s => parseInt(s.trim(), 10)).filter(n => n > 0);
1423
+ if (periodDays.length === 0) throw new Error('Invalid periods: must be comma-separated positive integers');
1424
+
1425
+ const pvPosts = await wpApiCall(`/${pvPostType}s?per_page=${pvLimit}&status=publish&orderby=date&order=desc&_fields=id,title,date,author,categories`);
1426
+ const pvAuthors = await wpApiCall('/users?per_page=100&_fields=id,name');
1427
+ const pvCategories = await wpApiCall('/categories?per_page=100&_fields=id,name');
1428
+
1429
+ const authorMap = new Map(pvAuthors.map(a => [a.id, a.name]));
1430
+ const catMap = new Map(pvCategories.map(c => [c.id, c.name]));
1431
+ const now = Date.now();
1432
+
1433
+ const periodsResult = periodDays.map(p => {
1434
+ const cutoff = now - p * 86400000;
1435
+ const inPeriod = pvPosts.filter(post => new Date(post.date).getTime() >= cutoff);
1436
+ const velocity = Math.round(inPeriod.length / (p / 30) * 10) / 10;
1437
+
1438
+ // By author
1439
+ const authorCounts = new Map();
1440
+ for (const post of inPeriod) {
1441
+ authorCounts.set(post.author, (authorCounts.get(post.author) || 0) + 1);
1442
+ }
1443
+ const byAuthor = [...authorCounts.entries()]
1444
+ .map(([id, count]) => ({ id, name: authorMap.get(id) || `Author ${id}`, posts_count: count, velocity_per_month: Math.round(count / (p / 30) * 10) / 10 }))
1445
+ .sort((a, b) => b.velocity_per_month - a.velocity_per_month);
1446
+
1447
+ // By category
1448
+ const catCounts = new Map();
1449
+ for (const post of inPeriod) {
1450
+ for (const catId of (post.categories || [])) {
1451
+ catCounts.set(catId, (catCounts.get(catId) || 0) + 1);
1452
+ }
1453
+ }
1454
+ const byCategory = [...catCounts.entries()]
1455
+ .map(([id, count]) => ({ id, name: catMap.get(id) || `Category ${id}`, posts_count: count, velocity_per_month: Math.round(count / (p / 30) * 10) / 10 }))
1456
+ .sort((a, b) => b.velocity_per_month - a.velocity_per_month);
1457
+
1458
+ return { days: p, posts_count: inPeriod.length, velocity_per_month: velocity, by_author: byAuthor, by_category: byCategory };
1459
+ });
1460
+
1461
+ // Trend calculation
1462
+ const sortedPeriods = [...periodsResult].sort((a, b) => a.days - b.days);
1463
+ const shortV = sortedPeriods[0].velocity_per_month;
1464
+ const longV = sortedPeriods[sortedPeriods.length - 1].velocity_per_month;
1465
+ const changePct = longV > 0 ? Math.round(((shortV - longV) / longV) * 100) : 0;
1466
+ let direction = 'stable';
1467
+ if (changePct > 20) direction = 'accelerating';
1468
+ else if (changePct < -20) direction = 'decelerating';
1469
+
1470
+ // Top authors/categories from shortest period
1471
+ const shortPeriod = sortedPeriods[0];
1472
+
1473
+ result = json({
1474
+ total_posts_fetched: pvPosts.length,
1475
+ post_type: pvPostType,
1476
+ periods: periodsResult,
1477
+ trend: { direction, short_period_velocity: shortV, long_period_velocity: longV, change_percent: changePct },
1478
+ top_authors: shortPeriod.by_author.slice(0, 10),
1479
+ top_categories: shortPeriod.by_category.slice(0, 10)
1480
+ });
1481
+ auditLog({ tool: name, action: 'publishing_velocity', status: 'success', latency_ms: Date.now() - t0, params: { periods: periodDays, post_type: pvPostType, limit: pvLimit } });
1482
+ return result;
1483
+ };
1484
+ handlers['wp_compare_revisions_diff'] = async (args) => {
1485
+ const t0 = Date.now();
1486
+ let result;
1487
+ const { wpApiCall, auditLog, name, extractHeadingsOutline, computeTextDiff, countWords } = rt;
1488
+ validateInput(args, { post_id: { type: 'number', required: true }, revision_id_from: { type: 'number', required: true }, revision_id_to: { type: 'number' }, post_type: { type: 'string', enum: ['post', 'page'] } });
1489
+ const crdPostId = args.post_id;
1490
+ const crdRevFrom = args.revision_id_from;
1491
+ const crdRevTo = args.revision_id_to;
1492
+ const crdPostType = args.post_type || 'post';
1493
+
1494
+ const revFrom = await wpApiCall(`/${crdPostType}s/${crdPostId}/revisions/${crdRevFrom}?_fields=id,date,content,title`);
1495
+ let revTo;
1496
+ let revToId;
1497
+ if (crdRevTo) {
1498
+ revTo = await wpApiCall(`/${crdPostType}s/${crdPostId}/revisions/${crdRevTo}?_fields=id,date,content,title`);
1499
+ revToId = crdRevTo;
1500
+ } else {
1501
+ revTo = await wpApiCall(`/${crdPostType}s/${crdPostId}?_fields=id,content,title,modified`);
1502
+ revToId = 'current';
1503
+ }
1504
+
1505
+ const textFrom = strip(revFrom.content?.rendered || revFrom.content || '');
1506
+ const textTo = strip(revTo.content?.rendered || revTo.content || '');
1507
+ const diff = computeTextDiff(textFrom, textTo);
1508
+
1509
+ const wcFrom = countWords(revFrom.content?.rendered || revFrom.content || '');
1510
+ const wcTo = countWords(revTo.content?.rendered || revTo.content || '');
1511
+
1512
+ const headingsFrom = extractHeadingsOutline(revFrom.content?.rendered || revFrom.content || '');
1513
+ const headingsTo = extractHeadingsOutline(revTo.content?.rendered || revTo.content || '');
1514
+ const headingsFromSet = new Set(headingsFrom.map(h => `${h.level}:${h.text}`));
1515
+ const headingsToSet = new Set(headingsTo.map(h => `${h.level}:${h.text}`));
1516
+ const headingsAdded = headingsTo.filter(h => !headingsFromSet.has(`${h.level}:${h.text}`));
1517
+ const headingsRemoved = headingsFrom.filter(h => !headingsToSet.has(`${h.level}:${h.text}`));
1518
+
1519
+ const amplitude = diff.change_ratio >= 0.5 ? 'major' : diff.change_ratio >= 0.2 ? 'moderate' : 'minor';
1520
+
1521
+ result = json({
1522
+ post_id: crdPostId,
1523
+ from: {
1524
+ revision_id: crdRevFrom,
1525
+ date: revFrom.date,
1526
+ title: strip(revFrom.title?.rendered || revFrom.title || ''),
1527
+ word_count: wcFrom
1528
+ },
1529
+ to: {
1530
+ revision_id: revToId,
1531
+ date: revTo.date || revTo.modified,
1532
+ title: strip(revTo.title?.rendered || revTo.title || ''),
1533
+ word_count: wcTo
1534
+ },
1535
+ diff: {
1536
+ lines_added: diff.lines_added,
1537
+ lines_removed: diff.lines_removed,
1538
+ lines_unchanged: diff.lines_unchanged,
1539
+ words_added: diff.words_added,
1540
+ words_removed: diff.words_removed,
1541
+ word_count_change: wcTo - wcFrom,
1542
+ change_ratio: Math.round(diff.change_ratio * 1000) / 1000,
1543
+ amplitude
1544
+ },
1545
+ headings_diff: { added: headingsAdded, removed: headingsRemoved },
1546
+ sample_changes: {
1547
+ added: diff.added_lines.slice(0, 10),
1548
+ removed: diff.removed_lines.slice(0, 10)
1549
+ }
1550
+ });
1551
+ auditLog({ tool: name, action: 'compare_revisions_diff', status: 'success', latency_ms: Date.now() - t0, params: { post_id: crdPostId, revision_id_from: crdRevFrom, revision_id_to: crdRevTo } });
1552
+ return result;
1553
+ };
1554
+ handlers['wp_list_posts_by_word_count'] = async (args) => {
1555
+ const t0 = Date.now();
1556
+ let result;
1557
+ const { wpApiCall, auditLog, name, countWords } = rt;
1558
+ validateInput(args, { limit: { type: 'number', min: 1, max: 500 }, post_type: { type: 'string', enum: ['post', 'page', 'both'] }, order: { type: 'string', enum: ['asc', 'desc'] }, category_id: { type: 'number' } });
1559
+ const wclLimit = args.limit || 100;
1560
+ const wclPostType = args.post_type || 'post';
1561
+ const wclOrder = args.order || 'desc';
1562
+ const wclCatId = args.category_id;
1563
+
1564
+ let wclPosts;
1565
+ if (wclPostType === 'both') {
1566
+ const catParam = wclCatId ? `&categories=${wclCatId}` : '';
1567
+ const [postsArr, pagesArr] = await Promise.all([
1568
+ wpApiCall(`/posts?per_page=${wclLimit}&status=publish&_fields=id,title,slug,link,content,date,modified,categories${catParam}`),
1569
+ wpApiCall(`/pages?per_page=${wclLimit}&status=publish&_fields=id,title,slug,link,content,date,modified,categories`)
1570
+ ]);
1571
+ wclPosts = [...postsArr, ...pagesArr];
1572
+ } else {
1573
+ const catParam = wclCatId ? `&categories=${wclCatId}` : '';
1574
+ wclPosts = await wpApiCall(`/${wclPostType}s?per_page=${wclLimit}&status=publish&_fields=id,title,slug,link,content,date,modified,categories${catParam}`);
1575
+ }
1576
+
1577
+ const postsWithWc = wclPosts.map(p => {
1578
+ const wc = countWords(p.content?.rendered || '');
1579
+ let segment;
1580
+ if (wc < 300) segment = 'very_short';
1581
+ else if (wc < 600) segment = 'short';
1582
+ else if (wc < 1000) segment = 'medium';
1583
+ else if (wc < 2000) segment = 'standard';
1584
+ else if (wc < 3000) segment = 'long';
1585
+ else segment = 'very_long';
1586
+
1587
+ return {
1588
+ id: p.id, title: strip(p.title?.rendered || ''), slug: p.slug, link: p.link,
1589
+ word_count: wc, segment,
1590
+ date: p.date, modified: p.modified,
1591
+ categories: p.categories || []
1592
+ };
1593
+ });
1594
+
1595
+ postsWithWc.sort((a, b) => wclOrder === 'asc' ? a.word_count - b.word_count : b.word_count - a.word_count);
1596
+
1597
+ const counts = postsWithWc.map(p => p.word_count);
1598
+ const total = counts.length || 1;
1599
+ const avg = counts.reduce((s, c) => s + c, 0) / total;
1600
+ const sorted = [...counts].sort((a, b) => a - b);
1601
+ const median = sorted.length % 2 === 0 ? (sorted[sorted.length / 2 - 1] + sorted[sorted.length / 2]) / 2 : sorted[Math.floor(sorted.length / 2)];
1602
+
1603
+ const dist = { very_short: 0, short: 0, medium: 0, standard: 0, long: 0, very_long: 0 };
1604
+ for (const p of postsWithWc) dist[p.segment]++;
1605
+ const distribution = {};
1606
+ for (const [seg, count] of Object.entries(dist)) {
1607
+ distribution[seg] = { count, percent: Math.round(count / total * 100) };
1608
+ }
1609
+
1610
+ result = json({
1611
+ total_analyzed: postsWithWc.length,
1612
+ avg_word_count: Math.round(avg),
1613
+ median_word_count: median,
1614
+ min_word_count: sorted[0] || 0,
1615
+ max_word_count: sorted[sorted.length - 1] || 0,
1616
+ distribution,
1617
+ posts: postsWithWc
1618
+ });
1619
+ auditLog({ tool: name, action: 'list_by_word_count', status: 'success', latency_ms: Date.now() - t0, params: { limit: wclLimit, post_type: wclPostType, order: wclOrder, category_id: wclCatId } });
1620
+ return result;
1621
+ };
1622
+ handlers['wp_get_rendered_head'] = async (args) => {
1623
+ const t0 = Date.now();
1624
+ let result;
1625
+ const { wpApiCall, getActiveAuth, fetch, auditLog, name, detectSeoPlugin, getRenderedHead, parseRenderedHead } = rt;
1626
+ validateInput(args, { post_id: { type: 'number', required: true }, post_type: { type: 'string', enum: ['post', 'page'] } });
1627
+ const grhPostId = args.post_id;
1628
+ const grhPostType = args.post_type || 'post';
1629
+ const { url: grhBaseUrl, auth: grhAuth } = getActiveAuth();
1630
+
1631
+ const grhPlugin = await detectSeoPlugin(grhBaseUrl, fetch);
1632
+ if (!grhPlugin) throw new Error('No supported SEO plugin detected. wp_get_rendered_head requires RankMath or Yoast.');
1633
+ if (grhPlugin !== 'rankmath' && grhPlugin !== 'yoast') throw new Error(`Rendered head requires RankMath or Yoast (detected: ${grhPlugin})`);
1634
+
1635
+ const grhEp = grhPostType === 'page' ? `/pages/${grhPostId}?_fields=id,title,link,slug,meta` : `/posts/${grhPostId}?_fields=id,title,link,slug,meta`;
1636
+ const grhPost = await wpApiCall(grhEp);
1637
+
1638
+ const grhHeadResult = await getRenderedHead(grhBaseUrl, grhPost.link, grhPlugin, fetch, grhAuth);
1639
+ if (!grhHeadResult.success) throw new Error(grhHeadResult.error);
1640
+
1641
+ const grhParsed = parseRenderedHead(grhHeadResult.head);
1642
+ const grhMeta = grhPost.meta || {};
1643
+
1644
+ // Extract stored SEO meta based on plugin
1645
+ let grhStoredTitle, grhStoredDesc, grhStoredKeyword, grhStoredCanonical, grhStoredRobots;
1646
+ if (grhPlugin === 'rankmath') {
1647
+ grhStoredTitle = grhMeta.rank_math_title || null;
1648
+ grhStoredDesc = grhMeta.rank_math_description || null;
1649
+ grhStoredKeyword = grhMeta.rank_math_focus_keyword || null;
1650
+ grhStoredCanonical = grhMeta.rank_math_canonical_url || null;
1651
+ const rm = grhMeta.rank_math_robots || [];
1652
+ grhStoredRobots = Array.isArray(rm) && rm.length > 0 ? rm.join(', ') : null;
1653
+ } else {
1654
+ grhStoredTitle = grhMeta._yoast_wpseo_title || null;
1655
+ grhStoredDesc = grhMeta._yoast_wpseo_metadesc || null;
1656
+ grhStoredKeyword = grhMeta._yoast_wpseo_focuskw || null;
1657
+ grhStoredCanonical = grhMeta._yoast_wpseo_canonical || null;
1658
+ grhStoredRobots = grhMeta._yoast_wpseo_meta_robots_noindex === '1' ? 'noindex' : null;
1659
+ }
1660
+
1661
+ result = json({
1662
+ post_id: grhPostId,
1663
+ post_url: grhPost.link,
1664
+ seo_plugin: grhPlugin,
1665
+ rendered: grhParsed,
1666
+ stored: {
1667
+ title: grhStoredTitle,
1668
+ description: grhStoredDesc,
1669
+ focus_keyword: grhStoredKeyword,
1670
+ canonical: grhStoredCanonical,
1671
+ robots: grhStoredRobots
1672
+ },
1673
+ raw_head_length: grhHeadResult.head.length,
1674
+ schemas_count: grhParsed.schema_json_ld.length
1675
+ });
1676
+ auditLog({ tool: name, action: 'get_rendered_head', status: 'success', latency_ms: Date.now() - t0, params: { post_id: grhPostId, post_type: grhPostType, plugin: grhPlugin } });
1677
+ return result;
1678
+ };
1679
+ handlers['wp_audit_rendered_seo'] = async (args) => {
1680
+ const t0 = Date.now();
1681
+ let result;
1682
+ const { wpApiCall, getActiveAuth, fetch, auditLog, name, detectSeoPlugin, getRenderedHead, parseRenderedHead } = rt;
1683
+ validateInput(args, { limit: { type: 'number', min: 1, max: 50 }, post_type: { type: 'string', enum: ['post', 'page'] } });
1684
+ const arsLimit = args.limit || 10;
1685
+ const arsPostType = args.post_type || 'post';
1686
+ const { url: arsBaseUrl, auth: arsAuth } = getActiveAuth();
1687
+
1688
+ const arsPlugin = await detectSeoPlugin(arsBaseUrl, fetch);
1689
+ if (!arsPlugin) throw new Error('No supported SEO plugin detected. wp_audit_rendered_seo requires RankMath or Yoast.');
1690
+ if (arsPlugin !== 'rankmath' && arsPlugin !== 'yoast') throw new Error(`Rendered SEO audit requires RankMath or Yoast (detected: ${arsPlugin})`);
1691
+
1692
+ const arsEp = `/${arsPostType}s?per_page=${arsLimit}&status=publish&_fields=id,title,link,slug,meta`;
1693
+ const arsPosts = await wpApiCall(arsEp);
1694
+
1695
+ const arsResults = [];
1696
+ const arsSummary = { title_mismatch: 0, description_mismatch: 0, canonical_mismatch: 0, missing_rendered_title: 0, missing_rendered_description: 0, robots_mismatch: 0, schema_missing: 0 };
1697
+
1698
+ for (const p of arsPosts) {
1699
+ const headRes = await getRenderedHead(arsBaseUrl, p.link, arsPlugin, fetch, arsAuth);
1700
+ if (!headRes.success) {
1701
+ arsResults.push({ id: p.id, title: strip(p.title?.rendered || ''), url: p.link, score: 0, issues: ['head_fetch_failed'], rendered: null, stored: null });
1702
+ continue;
1703
+ }
1704
+
1705
+ const parsed = parseRenderedHead(headRes.head);
1706
+ const meta = p.meta || {};
1707
+ const issues = [];
1708
+
1709
+ // Extract stored meta
1710
+ let storedTitle, storedDesc, storedCanonical, storedRobots;
1711
+ if (arsPlugin === 'rankmath') {
1712
+ storedTitle = meta.rank_math_title || null;
1713
+ storedDesc = meta.rank_math_description || null;
1714
+ storedCanonical = meta.rank_math_canonical_url || null;
1715
+ const rm = meta.rank_math_robots || [];
1716
+ storedRobots = Array.isArray(rm) && rm.length > 0 ? rm.join(', ') : null;
1717
+ } else {
1718
+ storedTitle = meta._yoast_wpseo_title || null;
1719
+ storedDesc = meta._yoast_wpseo_metadesc || null;
1720
+ storedCanonical = meta._yoast_wpseo_canonical || null;
1721
+ storedRobots = meta._yoast_wpseo_meta_robots_noindex === '1' ? 'noindex' : null;
1722
+ }
1723
+
1724
+ // Compare rendered vs stored
1725
+ if (!parsed.title) { issues.push('missing_rendered_title'); arsSummary.missing_rendered_title++; }
1726
+ else if (storedTitle && !parsed.title.includes(storedTitle)) { issues.push('title_mismatch'); arsSummary.title_mismatch++; }
1727
+
1728
+ if (!parsed.meta_description) { issues.push('missing_rendered_description'); arsSummary.missing_rendered_description++; }
1729
+ else if (storedDesc && parsed.meta_description !== storedDesc) { issues.push('description_mismatch'); arsSummary.description_mismatch++; }
1730
+
1731
+ if (parsed.canonical && parsed.canonical !== p.link && storedCanonical && parsed.canonical !== storedCanonical) { issues.push('canonical_mismatch'); arsSummary.canonical_mismatch++; }
1732
+
1733
+ if (parsed.robots && parsed.robots.includes('noindex') && (!storedRobots || !storedRobots.includes('noindex'))) { issues.push('robots_mismatch'); arsSummary.robots_mismatch++; }
1734
+
1735
+ if (parsed.schema_json_ld.length === 0) { issues.push('schema_missing'); arsSummary.schema_missing++; }
1736
+
1737
+ const score = Math.max(0, 100 - issues.length * 15);
1738
+
1739
+ arsResults.push({
1740
+ id: p.id, title: strip(p.title?.rendered || ''), url: p.link, score, issues,
1741
+ rendered: { title: parsed.title, description: parsed.meta_description, canonical: parsed.canonical, robots: parsed.robots, schemas_count: parsed.schema_json_ld.length },
1742
+ stored: { title: storedTitle, description: storedDesc, canonical: storedCanonical, robots: storedRobots }
1743
+ });
1744
+ }
1745
+
1746
+ const arsAvgScore = arsResults.length > 0 ? arsResults.reduce((s, r) => s + r.score, 0) / arsResults.length : 0;
1747
+
1748
+ result = json({
1749
+ seo_plugin: arsPlugin,
1750
+ total_audited: arsResults.length,
1751
+ avg_score: Math.round(arsAvgScore),
1752
+ issues_summary: arsSummary,
1753
+ posts: arsResults
1754
+ });
1755
+ auditLog({ tool: name, action: 'audit_rendered_seo', status: 'success', latency_ms: Date.now() - t0, params: { limit: arsLimit, post_type: arsPostType, plugin: arsPlugin } });
1756
+ return result;
1757
+ };
1758
+ handlers['wp_get_pillar_content'] = async (args) => {
1759
+ const t0 = Date.now();
1760
+ let result;
1761
+ const { wpApiCall, getActiveAuth, getActiveControls, fetch, auditLog, name, detectSeoPlugin } = rt;
1762
+ validateInput(args, {
1763
+ post_id: { type: 'number' },
1764
+ set_pillar: { type: 'boolean' },
1765
+ list_pillars: { type: 'boolean' },
1766
+ post_type: { type: 'string', enum: ['post', 'page'] },
1767
+ limit: { type: 'number', min: 1, max: 500 }
1768
+ });
1769
+ const pcPostType = args.post_type || 'post';
1770
+ const { url: pcBaseUrl } = getActiveAuth();
1771
+
1772
+ const pcPlugin = await detectSeoPlugin(pcBaseUrl, fetch);
1773
+ if (pcPlugin !== 'rankmath') throw new Error('Pillar content requires RankMath (detected: ' + (pcPlugin || 'none') + ')');
1774
+
1775
+ if (args.list_pillars) {
1776
+ // Mode: list all pillar posts
1777
+ const pcLimit = args.limit || 100;
1778
+ const pcPosts = await wpApiCall(`/${pcPostType}s?per_page=${pcLimit}&status=publish&_fields=id,title,link,slug,meta`);
1779
+ const pillars = pcPosts.filter(p => (p.meta || {}).rank_math_pillar_content === 'on').map(p => ({
1780
+ id: p.id, title: strip(p.title?.rendered || ''), slug: p.slug, link: p.link, post_type: pcPostType
1781
+ }));
1782
+ result = json({ mode: 'list_pillars', seo_plugin: pcPlugin, pillar_count: pillars.length, pillars });
1783
+ auditLog({ tool: name, action: 'read_pillar_content', status: 'success', latency_ms: Date.now() - t0, params: { list_pillars: true, post_type: pcPostType, limit: pcLimit } });
1784
+ } else if (args.post_id !== undefined && args.set_pillar !== undefined) {
1785
+ // Mode: write — set/unset pillar flag
1786
+ if (getActiveControls().read_only) throw new Error('Blocked: READ-ONLY mode. Cannot update pillar content flag.');
1787
+ const pcPost = await wpApiCall(`/${pcPostType}s/${args.post_id}?_fields=id,title,link,meta`);
1788
+ await wpApiCall(`/${pcPostType}s/${args.post_id}`, { method: 'POST', body: JSON.stringify({ meta: { rank_math_pillar_content: args.set_pillar ? 'on' : '' } }) });
1789
+ result = json({
1790
+ mode: 'write', post_id: args.post_id, title: strip(pcPost.title?.rendered || ''),
1791
+ is_pillar: args.set_pillar, action: args.set_pillar ? 'marked_as_pillar' : 'unmarked_as_pillar', seo_plugin: pcPlugin
1792
+ });
1793
+ auditLog({ tool: name, action: 'update_pillar_content', target: args.post_id, target_type: pcPostType, status: 'success', latency_ms: Date.now() - t0, params: { set_pillar: args.set_pillar } });
1794
+ } else if (args.post_id !== undefined) {
1795
+ // Mode: read single post
1796
+ const pcPost = await wpApiCall(`/${pcPostType}s/${args.post_id}?_fields=id,title,link,meta`);
1797
+ const isPillar = (pcPost.meta || {}).rank_math_pillar_content === 'on';
1798
+ result = json({ mode: 'read', post_id: args.post_id, title: strip(pcPost.title?.rendered || ''), is_pillar: isPillar, seo_plugin: pcPlugin });
1799
+ auditLog({ tool: name, action: 'read_pillar_content', status: 'success', latency_ms: Date.now() - t0, params: { post_id: args.post_id, post_type: pcPostType } });
1800
+ } else {
1801
+ throw new Error('Provide post_id (read/write) or list_pillars:true');
1802
+ }
1803
+ return result;
1804
+ };
1805
+ handlers['wp_audit_schema_plugins'] = async (args) => {
1806
+ const t0 = Date.now();
1807
+ let result;
1808
+ const { wpApiCall, getActiveAuth, fetch, auditLog, name, detectSeoPlugin, getRenderedHead, parseRenderedHead } = rt;
1809
+ validateInput(args, {
1810
+ limit: { type: 'number', min: 1, max: 100 },
1811
+ post_type: { type: 'string', enum: ['post', 'page', 'both'] }
1812
+ });
1813
+ const aspLimit = args.limit || 20;
1814
+ const aspPostType = args.post_type || 'post';
1815
+ const { url: aspBaseUrl, auth: aspAuth } = getActiveAuth();
1816
+
1817
+ const aspPlugin = await detectSeoPlugin(aspBaseUrl, fetch);
1818
+ if (!aspPlugin) throw new Error('No supported SEO plugin detected');
1819
+ if (aspPlugin !== 'rankmath' && aspPlugin !== 'yoast') throw new Error(`Schema plugin audit requires RankMath or Yoast (detected: ${aspPlugin})`);
1820
+
1821
+ const aspRequired = {
1822
+ 'Article': ['headline', 'datePublished', 'author'],
1823
+ 'BlogPosting': ['headline', 'datePublished', 'author'],
1824
+ 'NewsArticle': ['headline', 'datePublished', 'author'],
1825
+ 'FAQPage': ['mainEntity'],
1826
+ 'HowTo': ['name', 'step'],
1827
+ 'LocalBusiness': ['name', 'address'],
1828
+ 'BreadcrumbList': ['itemListElement'],
1829
+ 'Organization': ['name'],
1830
+ 'WebPage': ['name'],
1831
+ 'WebSite': ['name', 'url']
1832
+ };
1833
+
1834
+ let aspAllPosts = [];
1835
+ if (aspPostType === 'both') {
1836
+ const aspP = await wpApiCall(`/posts?per_page=${aspLimit}&status=publish&_fields=id,title,link,slug,meta`);
1837
+ const aspG = await wpApiCall(`/pages?per_page=${aspLimit}&status=publish&_fields=id,title,link,slug,meta`);
1838
+ aspAllPosts = [...aspP, ...aspG];
1839
+ } else {
1840
+ aspAllPosts = await wpApiCall(`/${aspPostType}s?per_page=${aspLimit}&status=publish&_fields=id,title,link,slug,meta`);
1841
+ }
1842
+
1843
+ const aspResults = [];
1844
+ const aspTypesCounts = {};
1845
+ const aspIssuesSummary = { no_plugin_schema: 0, invalid_schema_json: 0, missing_required_fields: 0, no_article_schema: 0 };
1846
+ let aspWithSchema = 0;
1847
+
1848
+ for (const p of aspAllPosts) {
1849
+ const postIssues = [];
1850
+ const postSchemas = [];
1851
+ let schemas = [];
1852
+
1853
+ if (aspPlugin === 'rankmath') {
1854
+ const rawSchema = (p.meta || {}).rank_math_schema;
1855
+ if (!rawSchema || rawSchema === '{}') {
1856
+ postIssues.push('no_plugin_schema');
1857
+ aspIssuesSummary.no_plugin_schema++;
1858
+ } else {
1859
+ try {
1860
+ const parsed = typeof rawSchema === 'string' ? JSON.parse(rawSchema) : rawSchema;
1861
+ if (parsed['@type']) {
1862
+ schemas = [parsed];
1863
+ } else if (parsed['@graph']) {
1864
+ schemas = parsed['@graph'];
1865
+ } else {
1866
+ schemas = Object.values(parsed).filter(v => v && typeof v === 'object' && v['@type']);
1867
+ }
1868
+ if (schemas.length === 0) { postIssues.push('no_plugin_schema'); aspIssuesSummary.no_plugin_schema++; }
1869
+ } catch {
1870
+ postIssues.push('invalid_schema_json');
1871
+ aspIssuesSummary.invalid_schema_json++;
1872
+ }
1873
+ }
1874
+ } else {
1875
+ const headRes = await getRenderedHead(aspBaseUrl, p.link, aspPlugin, fetch, aspAuth);
1876
+ if (headRes.success) {
1877
+ const parsed = parseRenderedHead(headRes.head);
1878
+ schemas = parsed.schema_json_ld || [];
1879
+ if (schemas.length === 0) { postIssues.push('no_plugin_schema'); aspIssuesSummary.no_plugin_schema++; }
1880
+ } else {
1881
+ postIssues.push('no_plugin_schema');
1882
+ aspIssuesSummary.no_plugin_schema++;
1883
+ }
1884
+ }
1885
+
1886
+ let hasArticleType = false;
1887
+ for (const schema of schemas) {
1888
+ const schemaType = schema['@type'] || 'Unknown';
1889
+ if (['Article', 'BlogPosting', 'NewsArticle'].includes(schemaType)) hasArticleType = true;
1890
+ aspTypesCounts[schemaType] = (aspTypesCounts[schemaType] || 0) + 1;
1891
+
1892
+ const requiredFields = aspRequired[schemaType];
1893
+ const missingFields = [];
1894
+ if (requiredFields) {
1895
+ for (const field of requiredFields) {
1896
+ if (!schema[field]) missingFields.push(field);
1897
+ }
1898
+ }
1899
+ if (missingFields.length > 0) { postIssues.push('missing_required_fields'); aspIssuesSummary.missing_required_fields++; }
1900
+ postSchemas.push({ type: schemaType, valid: missingFields.length === 0, missing_fields: missingFields });
1901
+ }
1902
+
1903
+ if (schemas.length > 0 && !hasArticleType && aspPostType === 'post') {
1904
+ postIssues.push('no_article_schema');
1905
+ aspIssuesSummary.no_article_schema++;
1906
+ }
1907
+
1908
+ if (schemas.length > 0) aspWithSchema++;
1909
+ const postScore = Math.max(0, 100 - postIssues.length * 15);
1910
+ aspResults.push({ id: p.id, title: strip(p.title?.rendered || ''), slug: p.slug, score: postScore, schemas: postSchemas, issues: postIssues });
1911
+ }
1912
+
1913
+ const aspAvg = aspResults.length > 0 ? aspResults.reduce((s, r) => s + r.score, 0) / aspResults.length : 0;
1914
+ const aspWithout = aspAllPosts.length - aspWithSchema;
1915
+
1916
+ result = json({
1917
+ seo_plugin: aspPlugin,
1918
+ total_audited: aspResults.length,
1919
+ avg_score: Math.round(aspAvg),
1920
+ schema_coverage: {
1921
+ posts_with_schema: aspWithSchema,
1922
+ posts_without_schema: aspWithout,
1923
+ coverage_percent: aspAllPosts.length > 0 ? Math.round(aspWithSchema / aspAllPosts.length * 100) : 0
1924
+ },
1925
+ schema_types_found: aspTypesCounts,
1926
+ issues_summary: aspIssuesSummary,
1927
+ posts: aspResults
1928
+ });
1929
+ auditLog({ tool: name, action: 'audit_schema_plugins', status: 'success', latency_ms: Date.now() - t0, params: { limit: aspLimit, post_type: aspPostType, plugin: aspPlugin } });
1930
+ return result;
1931
+ };
1932
+ handlers['wp_get_seo_score'] = async (args) => {
1933
+ const t0 = Date.now();
1934
+ let result;
1935
+ const { wpApiCall, getActiveAuth, fetch, auditLog, name, detectSeoPlugin } = rt;
1936
+ validateInput(args, {
1937
+ post_id: { type: 'number' },
1938
+ limit: { type: 'number', min: 1, max: 100 },
1939
+ post_type: { type: 'string', enum: ['post', 'page'] },
1940
+ order: { type: 'string', enum: ['asc', 'desc'] }
1941
+ });
1942
+ const gssPostType = args.post_type || 'post';
1943
+ const { url: gssBaseUrl } = getActiveAuth();
1944
+
1945
+ const gssPlugin = await detectSeoPlugin(gssBaseUrl, fetch);
1946
+ if (gssPlugin !== 'rankmath') throw new Error('SEO score requires RankMath (detected: ' + (gssPlugin || 'none') + ')');
1947
+
1948
+ if (args.post_id !== undefined) {
1949
+ const gssPost = await wpApiCall(`/${gssPostType}s/${args.post_id}?_fields=id,title,link,slug,meta`);
1950
+ const gssMeta = gssPost.meta || {};
1951
+ const gssRaw = gssMeta.rank_math_seo_score;
1952
+ const gssScore = gssRaw !== undefined && gssRaw !== null && gssRaw !== '' ? parseInt(gssRaw, 10) : null;
1953
+ const gssKw = gssMeta.rank_math_focus_keyword || null;
1954
+ const gssRating = gssScore === null || gssScore === 0 ? 'no_score' : gssScore >= 80 ? 'excellent' : gssScore >= 60 ? 'good' : gssScore >= 40 ? 'average' : 'poor';
1955
+
1956
+ result = json({
1957
+ mode: 'single', post_id: args.post_id, title: strip(gssPost.title?.rendered || ''),
1958
+ link: gssPost.link, seo_score: gssScore, focus_keyword: gssKw, rating: gssRating
1959
+ });
1960
+ auditLog({ tool: name, action: 'get_seo_score', status: 'success', latency_ms: Date.now() - t0, params: { post_id: args.post_id, post_type: gssPostType } });
1961
+ } else {
1962
+ const gssLimit = args.limit || 20;
1963
+ const gssSortOrder = args.order || 'desc';
1964
+ const gssPosts = await wpApiCall(`/${gssPostType}s?per_page=${gssLimit}&status=publish&_fields=id,title,link,slug,meta`);
1965
+
1966
+ const gssItems = gssPosts.map(p => {
1967
+ const m = p.meta || {};
1968
+ const raw = m.rank_math_seo_score;
1969
+ const score = raw !== undefined && raw !== null && raw !== '' ? parseInt(raw, 10) : null;
1970
+ const kw = m.rank_math_focus_keyword || null;
1971
+ const rating = score === null || score === 0 ? 'no_score' : score >= 80 ? 'excellent' : score >= 60 ? 'good' : score >= 40 ? 'average' : 'poor';
1972
+ return { id: p.id, title: strip(p.title?.rendered || ''), slug: p.slug, link: p.link, seo_score: score, focus_keyword: kw, rating };
1973
+ });
1974
+
1975
+ gssItems.sort((a, b) => {
1976
+ const sa = a.seo_score === null ? -1 : a.seo_score;
1977
+ const sb = b.seo_score === null ? -1 : b.seo_score;
1978
+ return gssSortOrder === 'asc' ? sa - sb : sb - sa;
1979
+ });
1980
+
1981
+ const gssDist = { excellent: 0, good: 0, average: 0, poor: 0, no_score: 0 };
1982
+ const gssScores = [];
1983
+ for (const item of gssItems) {
1984
+ gssDist[item.rating]++;
1985
+ if (item.seo_score !== null && item.seo_score > 0) gssScores.push(item.seo_score);
1986
+ }
1987
+ const gssTotal = gssItems.length;
1988
+ const gssAvg = gssScores.length > 0 ? gssScores.reduce((a, b) => a + b, 0) / gssScores.length : 0;
1989
+ const gssSorted = [...gssScores].sort((a, b) => a - b);
1990
+ const gssMedian = gssSorted.length > 0 ? (gssSorted.length % 2 === 0 ? (gssSorted[gssSorted.length / 2 - 1] + gssSorted[gssSorted.length / 2]) / 2 : gssSorted[Math.floor(gssSorted.length / 2)]) : 0;
1991
+
1992
+ result = json({
1993
+ mode: 'bulk', total_analyzed: gssItems.length,
1994
+ avg_score: Math.round(gssAvg), median_score: gssMedian,
1995
+ distribution: {
1996
+ excellent: { count: gssDist.excellent, percent: gssTotal > 0 ? Math.round(gssDist.excellent / gssTotal * 100) : 0 },
1997
+ good: { count: gssDist.good, percent: gssTotal > 0 ? Math.round(gssDist.good / gssTotal * 100) : 0 },
1998
+ average: { count: gssDist.average, percent: gssTotal > 0 ? Math.round(gssDist.average / gssTotal * 100) : 0 },
1999
+ poor: { count: gssDist.poor, percent: gssTotal > 0 ? Math.round(gssDist.poor / gssTotal * 100) : 0 },
2000
+ no_score: { count: gssDist.no_score, percent: gssTotal > 0 ? Math.round(gssDist.no_score / gssTotal * 100) : 0 }
2001
+ },
2002
+ posts: gssItems
2003
+ });
2004
+ auditLog({ tool: name, action: 'get_seo_score', status: 'success', latency_ms: Date.now() - t0, params: { limit: gssLimit, post_type: gssPostType, order: gssSortOrder } });
2005
+ }
2006
+ return result;
2007
+ };
2008
+ handlers['wp_get_twitter_meta'] = async (args) => {
2009
+ const t0 = Date.now();
2010
+ let result;
2011
+ const { wpApiCall, getActiveAuth, getActiveControls, fetch, auditLog, name, detectSeoPlugin } = rt;
2012
+ validateInput(args, {
2013
+ post_id: { type: 'number', required: true },
2014
+ post_type: { type: 'string', enum: ['post', 'page'] },
2015
+ twitter_title: { type: 'string' },
2016
+ twitter_description: { type: 'string' },
2017
+ twitter_image: { type: 'string' }
2018
+ });
2019
+ const gtmPostId = args.post_id;
2020
+ const gtmPostType = args.post_type || 'post';
2021
+ const { url: gtmBaseUrl } = getActiveAuth();
2022
+ const gtmPlugin = await detectSeoPlugin(gtmBaseUrl, fetch);
2023
+ if (!gtmPlugin) throw new Error('No supported SEO plugin detected');
2024
+
2025
+ const gtmIsWrite = args.twitter_title !== undefined || args.twitter_description !== undefined || args.twitter_image !== undefined;
2026
+
2027
+ if (gtmIsWrite) {
2028
+ if (getActiveControls().read_only) throw new Error('Blocked: READ-ONLY mode. Cannot update Twitter meta.');
2029
+ if (gtmPlugin !== 'rankmath' && gtmPlugin !== 'yoast') throw new Error('Twitter meta write requires RankMath or Yoast (detected: ' + gtmPlugin + ')');
2030
+
2031
+ const gtmMeta = {};
2032
+ const gtmUpdated = [];
2033
+ if (gtmPlugin === 'rankmath') {
2034
+ if (args.twitter_title !== undefined) { gtmMeta.rank_math_twitter_title = args.twitter_title; gtmUpdated.push('twitter_title'); }
2035
+ if (args.twitter_description !== undefined) { gtmMeta.rank_math_twitter_description = args.twitter_description; gtmUpdated.push('twitter_description'); }
2036
+ if (args.twitter_image !== undefined) { gtmMeta.rank_math_twitter_image = args.twitter_image; gtmUpdated.push('twitter_image'); }
2037
+ } else {
2038
+ if (args.twitter_title !== undefined) { gtmMeta['_yoast_wpseo_twitter-title'] = args.twitter_title; gtmUpdated.push('twitter_title'); }
2039
+ if (args.twitter_description !== undefined) { gtmMeta['_yoast_wpseo_twitter-description'] = args.twitter_description; gtmUpdated.push('twitter_description'); }
2040
+ if (args.twitter_image !== undefined) { gtmMeta['_yoast_wpseo_twitter-image'] = args.twitter_image; gtmUpdated.push('twitter_image'); }
2041
+ }
2042
+
2043
+ const gtmPost = await wpApiCall(`/${gtmPostType}s/${gtmPostId}?_fields=id,title,link,meta`);
2044
+ await wpApiCall(`/${gtmPostType}s/${gtmPostId}`, { method: 'POST', body: JSON.stringify({ meta: gtmMeta }) });
2045
+
2046
+ result = json({
2047
+ mode: 'write', post_id: gtmPostId, title: strip(gtmPost.title?.rendered || ''),
2048
+ seo_plugin: gtmPlugin, updated_fields: gtmUpdated,
2049
+ twitter: { title: args.twitter_title || null, description: args.twitter_description || null, image: args.twitter_image || null }
2050
+ });
2051
+ auditLog({ tool: name, action: 'update_twitter_meta', target: gtmPostId, target_type: gtmPostType, status: 'success', latency_ms: Date.now() - t0, params: { updated_fields: gtmUpdated } });
2052
+ } else {
2053
+ const gtmPost = await wpApiCall(`/${gtmPostType}s/${gtmPostId}?_fields=id,title,link,meta`);
2054
+ const gtmM = gtmPost.meta || {};
2055
+
2056
+ let gtmTitle, gtmDesc, gtmImage, gtmCard;
2057
+ if (gtmPlugin === 'rankmath') {
2058
+ gtmTitle = gtmM.rank_math_twitter_title || null;
2059
+ gtmDesc = gtmM.rank_math_twitter_description || null;
2060
+ gtmImage = gtmM.rank_math_twitter_image || null;
2061
+ gtmCard = gtmM.rank_math_twitter_card_type || null;
2062
+ } else if (gtmPlugin === 'yoast') {
2063
+ gtmTitle = gtmM['_yoast_wpseo_twitter-title'] || null;
2064
+ gtmDesc = gtmM['_yoast_wpseo_twitter-description'] || null;
2065
+ gtmImage = gtmM['_yoast_wpseo_twitter-image'] || null;
2066
+ gtmCard = null;
2067
+ } else {
2068
+ gtmTitle = gtmM._seopress_social_twitter_title || null;
2069
+ gtmDesc = gtmM._seopress_social_twitter_desc || null;
2070
+ gtmImage = gtmM._seopress_social_twitter_img || null;
2071
+ gtmCard = null;
2072
+ }
2073
+
2074
+ result = json({
2075
+ mode: 'read', post_id: gtmPostId, title: strip(gtmPost.title?.rendered || ''),
2076
+ link: gtmPost.link, seo_plugin: gtmPlugin,
2077
+ twitter: { title: gtmTitle, description: gtmDesc, image: gtmImage, card_type: gtmCard }
2078
+ });
2079
+ auditLog({ tool: name, action: 'read_twitter_meta', status: 'success', latency_ms: Date.now() - t0, params: { post_id: gtmPostId, post_type: gtmPostType, plugin: gtmPlugin } });
2080
+ }
2081
+ return result;
2082
+ };