@adsim/wordpress-mcp-server 4.6.0 → 5.3.1

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 (51) hide show
  1. package/.env.example +18 -0
  2. package/README.md +867 -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/plugins/adapters/acf/acfAdapter.js +55 -3
  9. package/src/shared/api.js +79 -0
  10. package/src/shared/audit.js +39 -0
  11. package/src/shared/context.js +15 -0
  12. package/src/shared/governance.js +98 -0
  13. package/src/shared/utils.js +148 -0
  14. package/src/tools/comments.js +50 -0
  15. package/src/tools/content.js +395 -0
  16. package/src/tools/core.js +114 -0
  17. package/src/tools/editorial.js +634 -0
  18. package/src/tools/fse.js +370 -0
  19. package/src/tools/health.js +160 -0
  20. package/src/tools/index.js +96 -0
  21. package/src/tools/intelligence.js +2082 -0
  22. package/src/tools/links.js +118 -0
  23. package/src/tools/media.js +71 -0
  24. package/src/tools/performance.js +219 -0
  25. package/src/tools/plugins.js +368 -0
  26. package/src/tools/schema.js +417 -0
  27. package/src/tools/security.js +590 -0
  28. package/src/tools/seo.js +1633 -0
  29. package/src/tools/taxonomy.js +115 -0
  30. package/src/tools/users.js +188 -0
  31. package/src/tools/woocommerce.js +1008 -0
  32. package/src/tools/workflow.js +409 -0
  33. package/src/transport/http.js +39 -0
  34. package/tests/unit/helpers/pagination.test.js +43 -0
  35. package/tests/unit/plugins/acf/acfAdapter.test.js +43 -5
  36. package/tests/unit/tools/bulkUpdate.test.js +188 -0
  37. package/tests/unit/tools/diagnostics.test.js +397 -0
  38. package/tests/unit/tools/dynamicFiltering.test.js +100 -8
  39. package/tests/unit/tools/editorialIntelligence.test.js +817 -0
  40. package/tests/unit/tools/fse.test.js +548 -0
  41. package/tests/unit/tools/multilingual.test.js +653 -0
  42. package/tests/unit/tools/performance.test.js +351 -0
  43. package/tests/unit/tools/postMeta.test.js +105 -0
  44. package/tests/unit/tools/runWorkflow.test.js +150 -0
  45. package/tests/unit/tools/schema.test.js +477 -0
  46. package/tests/unit/tools/security.test.js +695 -0
  47. package/tests/unit/tools/site.test.js +1 -1
  48. package/tests/unit/tools/users.crud.test.js +399 -0
  49. package/tests/unit/tools/validateBlocks.test.js +186 -0
  50. package/tests/unit/tools/visualStaging.test.js +271 -0
  51. package/tests/unit/tools/woocommerce.advanced.test.js +679 -0
@@ -0,0 +1,634 @@
1
+ // src/tools/editorial.js — editorial tools (6)
2
+ // Definitions + handlers (v5.0.0 refactor Step B+C)
3
+
4
+ import { json, strip } from '../shared/utils.js';
5
+ import { rt } from '../shared/context.js';
6
+
7
+ export const definitions = [
8
+ { name: 'wp_suggest_content_updates', _category: 'editorial', description: 'Use to find stale posts that need updating. Prioritizes by age, date mentions in content (e.g. "en 2023"), and low word count. Returns actionable update recommendations. Read-only.',
9
+ inputSchema: { type: 'object', properties: { months: { type: 'number', description: 'default 6' }, post_types: { type: 'array', items: { type: 'string' }, description: 'Post types to scan (default ["post"])' }, per_page: { type: 'number', description: 'default 50' }, min_word_count: { type: 'number', description: 'default 100' } }}},
10
+ { name: 'wp_audit_author_consistency', _category: 'editorial', description: 'Use to profile each active author: post count, avg word count, publication frequency, readability score (Flesch-Kincaid), media usage. Returns per-author profiles and deviations vs site average. Read-only.',
11
+ inputSchema: { type: 'object', properties: { post_types: { type: 'array', items: { type: 'string' }, description: 'Post types (default ["post"])' }, min_posts: { type: 'number', description: 'default 3' }, per_page: { type: 'number', description: 'default 100' } }}},
12
+ { name: 'wp_build_editorial_calendar', _category: 'editorial', description: 'Use to generate a recommended editorial calendar. Analyzes 12 months of publication history for seasonality, crosses with scheduled posts, and suggests optimal publishing slots. Read-only.',
13
+ inputSchema: { type: 'object', properties: { months_ahead: { type: 'number', description: 'default 3' }, post_types: { type: 'array', items: { type: 'string' }, description: 'Post types (default ["post"])' } }}},
14
+ { name: 'wp_find_pillar_content_gaps', _category: 'editorial', description: 'Use to identify topics covered by 3+ secondary posts without a dedicated pillar page. Analyzes categories and tags to detect content cluster opportunities. Read-only.',
15
+ inputSchema: { type: 'object', properties: { min_cluster_size: { type: 'number', description: 'Min posts per topic to qualify (default 3)' }, min_word_count_pillar: { type: 'number', description: 'Min word count to consider as pillar (default 1500)' }, post_types: { type: 'array', items: { type: 'string' }, description: 'Post types (default ["post","page"])' } }}},
16
+ { name: 'wp_audit_internal_link_equity', _category: 'editorial', description: 'Use to build the internal link graph and identify orphan pages (0 inbound), over-linked pages (>20 inbound), and important content with low link equity. Batch-optimized for large sites. Read-only.',
17
+ inputSchema: { type: 'object', properties: { post_types: { type: 'array', items: { type: 'string' }, description: 'Post types (default ["post","page"])' }, batch_size: { type: 'number', description: 'default 50, max 100' }, max_posts: { type: 'number', description: 'default 500' } }}},
18
+ { name: 'wp_suggest_content_cluster', _category: 'editorial', description: 'Use to cluster existing content by semantic similarity using TF-IDF + cosine similarity. Accepts a topic keyword or post_id as seed, returns related clusters with recommended pillar posts. Batch-optimized. Read-only.',
19
+ inputSchema: { type: 'object', properties: { topic: { type: 'string', description: 'Keyword to seed the cluster' }, post_id: { type: 'number', description: 'Post ID to use as cluster seed (alternative to topic)' }, similarity_threshold: { type: 'number', description: 'default 0.15' }, post_types: { type: 'array', items: { type: 'string' }, description: 'Post types (default ["post","page"])' }, max_posts: { type: 'number', description: 'default 200' } }}}
20
+ ];
21
+
22
+ export const handlers = {};
23
+
24
+ handlers['wp_suggest_content_updates'] = async (args) => {
25
+ const t0 = Date.now();
26
+ let result;
27
+ const { wpApiCall, auditLog, sanitizeParams, name, countWords } = rt;
28
+ const staleMonths = args.months || 6;
29
+ const staleTypes = args.post_types || ['post'];
30
+ const stalePerPage = Math.min(args.per_page || 50, 100);
31
+ const staleMinWords = args.min_word_count || 100;
32
+ const cutoffDate = new Date();
33
+ cutoffDate.setMonth(cutoffDate.getMonth() - staleMonths);
34
+ const cutoffISO = cutoffDate.toISOString();
35
+
36
+ let allPosts = [];
37
+ for (const pt of staleTypes) {
38
+ try {
39
+ const posts = await wpApiCall(`/${pt === 'post' ? 'posts' : pt === 'page' ? 'pages' : pt}?per_page=${stalePerPage}&status=publish&before=${cutoffISO}&orderby=modified&order=asc`);
40
+ if (Array.isArray(posts)) allPosts.push(...posts);
41
+ } catch (_e) { /* skip inaccessible type */ }
42
+ }
43
+
44
+ // Score and prioritize
45
+ const yearPattern = /\b(20[0-2]\d)\b/g;
46
+ const datePatterns = /\b(en|depuis|avant|après|janvier|février|mars|avril|mai|juin|juillet|août|septembre|octobre|novembre|décembre|january|february|march|april|may|june|july|august|september|october|november|december)\s+(20[0-2]\d)\b/gi;
47
+
48
+ const suggestions = allPosts.map(p => {
49
+ const content = p.content?.rendered || '';
50
+ const title = strip(p.title?.rendered || '');
51
+ const wordCount = countWords(content);
52
+ if (wordCount < staleMinWords) return null;
53
+
54
+ const modified = new Date(p.modified || p.date);
55
+ const daysSinceUpdate = Math.floor((Date.now() - modified.getTime()) / 86400000);
56
+ const reasons = [];
57
+ let priority = 0;
58
+
59
+ // Age factor
60
+ priority += Math.min(daysSinceUpdate / 30, 24); // max 24 pts for 2+ years
61
+ reasons.push(`Last updated ${daysSinceUpdate} days ago`);
62
+
63
+ // Date mentions in content
64
+ const plainText = strip(content);
65
+ const yearMatches = [...plainText.matchAll(yearPattern)];
66
+ const currentYear = new Date().getFullYear();
67
+ const outdatedYears = yearMatches.filter(m => parseInt(m[1]) < currentYear - 1);
68
+ if (outdatedYears.length > 0) {
69
+ const years = [...new Set(outdatedYears.map(m => m[1]))].sort();
70
+ priority += outdatedYears.length * 3;
71
+ reasons.push(`Contains outdated date references: ${years.join(', ')}`);
72
+ }
73
+
74
+ // Date expressions
75
+ const dateExprs = [...plainText.matchAll(datePatterns)];
76
+ const outdatedExprs = dateExprs.filter(m => parseInt(m[2]) < currentYear - 1);
77
+ if (outdatedExprs.length > 0 && outdatedYears.length === 0) {
78
+ priority += outdatedExprs.length * 2;
79
+ reasons.push(`Contains dated expressions (e.g. "${outdatedExprs[0][0]}")`);
80
+ }
81
+
82
+ // Short content bonus
83
+ if (wordCount < 300) {
84
+ priority += 5;
85
+ reasons.push(`Thin content (${wordCount} words)`);
86
+ }
87
+
88
+ return {
89
+ post_id: p.id,
90
+ title,
91
+ slug: p.slug,
92
+ link: p.link,
93
+ word_count: wordCount,
94
+ last_modified: p.modified,
95
+ days_since_update: daysSinceUpdate,
96
+ priority_score: Math.round(priority * 10) / 10,
97
+ reasons
98
+ };
99
+ }).filter(Boolean).sort((a, b) => b.priority_score - a.priority_score);
100
+
101
+ result = json({ total_analyzed: allPosts.length, stale_threshold_months: staleMonths, suggestions });
102
+ auditLog({ tool: name, action: 'suggest_content_updates', status: 'success', latency_ms: Date.now() - t0, params: sanitizeParams(args) });
103
+ return result;
104
+ };
105
+ handlers['wp_audit_author_consistency'] = async (args) => {
106
+ const t0 = Date.now();
107
+ let result;
108
+ const { wpApiCall, auditLog, sanitizeParams, name, calculateReadabilityScore, countWords } = rt;
109
+ const acTypes = args.post_types || ['post'];
110
+ const acMinPosts = args.min_posts || 3;
111
+ const acPerPage = Math.min(args.per_page || 100, 100);
112
+
113
+ // Fetch users (authors)
114
+ let users = [];
115
+ try {
116
+ users = await wpApiCall('/users?per_page=100&who=authors');
117
+ } catch (_e) {
118
+ try { users = await wpApiCall('/users?per_page=100'); } catch (_e2) { /* no users */ }
119
+ }
120
+ if (!Array.isArray(users)) users = [];
121
+
122
+ const authorProfiles = [];
123
+ const siteStats = { total_words: 0, total_posts: 0, total_media: 0, readability_sum: 0 };
124
+
125
+ for (const user of users) {
126
+ const endpoint = acTypes[0] === 'post' ? 'posts' : acTypes[0] === 'page' ? 'pages' : acTypes[0];
127
+ let posts = [];
128
+ try {
129
+ posts = await wpApiCall(`/${endpoint}?author=${user.id}&per_page=${acPerPage}&status=publish&orderby=date&order=desc`);
130
+ if (!Array.isArray(posts)) posts = [];
131
+ } catch (_e) { continue; }
132
+
133
+ if (posts.length < acMinPosts) continue;
134
+
135
+ let totalWords = 0, totalMediaCount = 0, readabilitySum = 0;
136
+ const dates = [];
137
+
138
+ for (const p of posts) {
139
+ const content = p.content?.rendered || '';
140
+ const wc = countWords(content);
141
+ totalWords += wc;
142
+
143
+ // Media count: images in content
144
+ const imgMatches = content.match(/<img\b/gi);
145
+ totalMediaCount += imgMatches ? imgMatches.length : 0;
146
+
147
+ // Readability
148
+ if (wc > 50) {
149
+ const readScore = calculateReadabilityScore(content);
150
+ readabilitySum += readScore.score;
151
+ }
152
+
153
+ dates.push(new Date(p.date));
154
+ }
155
+
156
+ const avgWords = Math.round(totalWords / posts.length);
157
+ const avgMedia = Math.round((totalMediaCount / posts.length) * 10) / 10;
158
+ const avgReadability = posts.filter(p => countWords(p.content?.rendered || '') > 50).length > 0
159
+ ? Math.round(readabilitySum / posts.filter(p => countWords(p.content?.rendered || '') > 50).length * 10) / 10
160
+ : null;
161
+
162
+ // Publication frequency: posts per month
163
+ dates.sort((a, b) => a - b);
164
+ const spanDays = dates.length > 1 ? (dates[dates.length - 1] - dates[0]) / 86400000 : 30;
165
+ const postsPerMonth = Math.round((posts.length / Math.max(spanDays / 30, 1)) * 10) / 10;
166
+
167
+ authorProfiles.push({
168
+ author_id: user.id,
169
+ name: user.name,
170
+ post_count: posts.length,
171
+ avg_word_count: avgWords,
172
+ posts_per_month: postsPerMonth,
173
+ avg_readability_score: avgReadability,
174
+ avg_media_per_post: avgMedia,
175
+ last_published: dates[dates.length - 1]?.toISOString() || null
176
+ });
177
+
178
+ siteStats.total_words += totalWords;
179
+ siteStats.total_posts += posts.length;
180
+ siteStats.total_media += totalMediaCount;
181
+ siteStats.readability_sum += readabilitySum;
182
+ }
183
+
184
+ // Site averages
185
+ const siteAvg = {
186
+ avg_word_count: siteStats.total_posts > 0 ? Math.round(siteStats.total_words / siteStats.total_posts) : 0,
187
+ avg_media_per_post: siteStats.total_posts > 0 ? Math.round((siteStats.total_media / siteStats.total_posts) * 10) / 10 : 0,
188
+ avg_readability: siteStats.total_posts > 0 && siteStats.readability_sum > 0
189
+ ? Math.round(siteStats.readability_sum / siteStats.total_posts * 10) / 10 : null
190
+ };
191
+
192
+ // Deviation per author
193
+ for (const ap of authorProfiles) {
194
+ ap.deviation = {
195
+ word_count_vs_avg: siteAvg.avg_word_count > 0 ? Math.round(((ap.avg_word_count - siteAvg.avg_word_count) / siteAvg.avg_word_count) * 100) : 0,
196
+ media_vs_avg: siteAvg.avg_media_per_post > 0 ? Math.round(((ap.avg_media_per_post - siteAvg.avg_media_per_post) / siteAvg.avg_media_per_post) * 100) : 0
197
+ };
198
+ }
199
+
200
+ result = json({ authors: authorProfiles, site_average: siteAvg, total_authors_analyzed: authorProfiles.length });
201
+ auditLog({ tool: name, action: 'audit_author_consistency', status: 'success', latency_ms: Date.now() - t0, params: sanitizeParams(args) });
202
+ return result;
203
+ };
204
+ handlers['wp_build_editorial_calendar'] = async (args) => {
205
+ const t0 = Date.now();
206
+ let result;
207
+ const { wpApiCall, auditLog, sanitizeParams, name } = rt;
208
+ const calMonthsAhead = args.months_ahead || 3;
209
+ const calTypes = args.post_types || ['post'];
210
+
211
+ // Fetch last 12 months of posts
212
+ const oneYearAgo = new Date();
213
+ oneYearAgo.setFullYear(oneYearAgo.getFullYear() - 1);
214
+ const afterISO = oneYearAgo.toISOString();
215
+
216
+ let allPosts = [];
217
+ for (const pt of calTypes) {
218
+ const endpoint = pt === 'post' ? 'posts' : pt === 'page' ? 'pages' : pt;
219
+ let page = 1;
220
+ while (page <= 10) {
221
+ try {
222
+ const batch = await wpApiCall(`/${endpoint}?per_page=100&page=${page}&status=publish&after=${afterISO}&orderby=date&order=asc`);
223
+ if (!Array.isArray(batch) || batch.length === 0) break;
224
+ allPosts.push(...batch);
225
+ if (batch.length < 100) break;
226
+ page++;
227
+ } catch (_e) { break; }
228
+ }
229
+ }
230
+
231
+ // Analyze by month and weekday
232
+ const monthCounts = {};
233
+ const weekdayCounts = [0, 0, 0, 0, 0, 0, 0]; // Sun-Sat
234
+ const categoryCount = {};
235
+
236
+ for (const p of allPosts) {
237
+ const d = new Date(p.date);
238
+ const monthKey = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}`;
239
+ monthCounts[monthKey] = (monthCounts[monthKey] || 0) + 1;
240
+ weekdayCounts[d.getDay()]++;
241
+
242
+ // Track categories for topic suggestions
243
+ if (Array.isArray(p.categories)) {
244
+ for (const catId of p.categories) {
245
+ categoryCount[catId] = (categoryCount[catId] || 0) + 1;
246
+ }
247
+ }
248
+ }
249
+
250
+ // Average posts per month
251
+ const monthKeys = Object.keys(monthCounts).sort();
252
+ const avgPerMonth = monthKeys.length > 0 ? Math.round((allPosts.length / monthKeys.length) * 10) / 10 : 0;
253
+
254
+ // Best publishing days (top 3)
255
+ const dayNames = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday'];
256
+ const bestDays = weekdayCounts
257
+ .map((c, i) => ({ day: dayNames[i], count: c }))
258
+ .sort((a, b) => b.count - a.count)
259
+ .slice(0, 3)
260
+ .map(d => d.day);
261
+
262
+ // Seasonal pattern: months with above-average output
263
+ const peakMonths = monthKeys.filter(k => monthCounts[k] > avgPerMonth).map(k => {
264
+ const [y, m] = k.split('-');
265
+ return parseInt(m);
266
+ });
267
+ const peakMonthNames = [...new Set(peakMonths)].sort((a, b) => a - b);
268
+
269
+ // Scheduled posts
270
+ let scheduledPosts = [];
271
+ for (const pt of calTypes) {
272
+ const endpoint = pt === 'post' ? 'posts' : pt === 'page' ? 'pages' : pt;
273
+ try {
274
+ const scheduled = await wpApiCall(`/${endpoint}?per_page=100&status=future&orderby=date&order=asc`);
275
+ if (Array.isArray(scheduled)) scheduledPosts.push(...scheduled);
276
+ } catch (_e) { /* no access */ }
277
+ }
278
+
279
+ // Build recommended calendar
280
+ const calendar = [];
281
+ const now = new Date();
282
+ for (let m = 0; m < calMonthsAhead; m++) {
283
+ const targetDate = new Date(now.getFullYear(), now.getMonth() + m + 1, 1);
284
+ const monthNum = targetDate.getMonth() + 1;
285
+ const monthLabel = `${targetDate.getFullYear()}-${String(monthNum).padStart(2, '0')}`;
286
+
287
+ // Recommended post count based on historical average
288
+ const recommendedCount = Math.max(Math.round(avgPerMonth), 1);
289
+ const scheduledInMonth = scheduledPosts.filter(p => {
290
+ const sd = new Date(p.date);
291
+ return sd.getFullYear() === targetDate.getFullYear() && sd.getMonth() + 1 === monthNum;
292
+ });
293
+
294
+ const gap = Math.max(0, recommendedCount - scheduledInMonth.length);
295
+
296
+ calendar.push({
297
+ month: monthLabel,
298
+ recommended_posts: recommendedCount,
299
+ already_scheduled: scheduledInMonth.length,
300
+ gap,
301
+ best_days: bestDays,
302
+ scheduled_titles: scheduledInMonth.map(p => ({ id: p.id, title: strip(p.title?.rendered || ''), date: p.date }))
303
+ });
304
+ }
305
+
306
+ // Top categories for topic suggestions
307
+ const topCategories = Object.entries(categoryCount)
308
+ .sort(([, a], [, b]) => b - a)
309
+ .slice(0, 10)
310
+ .map(([catId, count]) => ({ category_id: parseInt(catId), post_count: count }));
311
+
312
+ result = json({
313
+ analysis_period: { from: afterISO, posts_analyzed: allPosts.length },
314
+ publishing_pattern: { avg_posts_per_month: avgPerMonth, best_days: bestDays, peak_months: peakMonthNames, monthly_breakdown: monthCounts },
315
+ calendar,
316
+ top_categories: topCategories
317
+ });
318
+ auditLog({ tool: name, action: 'build_editorial_calendar', status: 'success', latency_ms: Date.now() - t0, params: sanitizeParams(args) });
319
+ return result;
320
+ };
321
+ handlers['wp_find_pillar_content_gaps'] = async (args) => {
322
+ const t0 = Date.now();
323
+ let result;
324
+ const { wpApiCall, auditLog, sanitizeParams, name, countWords } = rt;
325
+ const gapMinCluster = args.min_cluster_size || 3;
326
+ const gapMinPillarWords = args.min_word_count_pillar || 1500;
327
+ const gapTypes = args.post_types || ['post', 'page'];
328
+
329
+ // Fetch categories with post counts
330
+ let categories = [];
331
+ try {
332
+ categories = await wpApiCall('/categories?per_page=100&hide_empty=true');
333
+ if (!Array.isArray(categories)) categories = [];
334
+ } catch (_e) { /* no access */ }
335
+
336
+ // Fetch tags with post counts
337
+ let tags = [];
338
+ try {
339
+ tags = await wpApiCall('/tags?per_page=100&hide_empty=true&orderby=count&order=desc');
340
+ if (!Array.isArray(tags)) tags = [];
341
+ } catch (_e) { /* no access */ }
342
+
343
+ const gaps = [];
344
+
345
+ // Analyze categories
346
+ for (const cat of categories) {
347
+ if ((cat.count || 0) < gapMinCluster) continue;
348
+
349
+ // Fetch posts in this category
350
+ let catPosts = [];
351
+ try {
352
+ catPosts = await wpApiCall(`/posts?categories=${cat.id}&per_page=100&status=publish`);
353
+ if (!Array.isArray(catPosts)) catPosts = [];
354
+ } catch (_e) { continue; }
355
+
356
+ // Check if any post qualifies as pillar (long-form)
357
+ const hasPillar = catPosts.some(p => countWords(p.content?.rendered || '') >= gapMinPillarWords);
358
+
359
+ if (!hasPillar) {
360
+ const satellites = catPosts.map(p => ({
361
+ post_id: p.id,
362
+ title: strip(p.title?.rendered || ''),
363
+ word_count: countWords(p.content?.rendered || ''),
364
+ link: p.link
365
+ }));
366
+
367
+ gaps.push({
368
+ type: 'category',
369
+ taxonomy_id: cat.id,
370
+ name: cat.name,
371
+ slug: cat.slug,
372
+ post_count: catPosts.length,
373
+ description: cat.description || null,
374
+ satellite_posts: satellites,
375
+ recommendation: `Create a comprehensive pillar page (${gapMinPillarWords}+ words) for "${cat.name}" to anchor ${catPosts.length} existing posts.`
376
+ });
377
+ }
378
+ }
379
+
380
+ // Analyze tags (top tags only)
381
+ for (const tag of tags.slice(0, 30)) {
382
+ if ((tag.count || 0) < gapMinCluster) continue;
383
+
384
+ let tagPosts = [];
385
+ try {
386
+ tagPosts = await wpApiCall(`/posts?tags=${tag.id}&per_page=100&status=publish`);
387
+ if (!Array.isArray(tagPosts)) tagPosts = [];
388
+ } catch (_e) { continue; }
389
+
390
+ const hasPillar = tagPosts.some(p => countWords(p.content?.rendered || '') >= gapMinPillarWords);
391
+
392
+ if (!hasPillar) {
393
+ // Don't duplicate if same posts already covered by a category gap
394
+ const existingPostIds = new Set(gaps.flatMap(g => g.satellite_posts.map(s => s.post_id)));
395
+ const uniquePosts = tagPosts.filter(p => !existingPostIds.has(p.id));
396
+ if (uniquePosts.length < gapMinCluster) continue;
397
+
398
+ gaps.push({
399
+ type: 'tag',
400
+ taxonomy_id: tag.id,
401
+ name: tag.name,
402
+ slug: tag.slug,
403
+ post_count: tagPosts.length,
404
+ satellite_posts: tagPosts.map(p => ({
405
+ post_id: p.id,
406
+ title: strip(p.title?.rendered || ''),
407
+ word_count: countWords(p.content?.rendered || ''),
408
+ link: p.link
409
+ })),
410
+ recommendation: `Create a pillar page for topic "${tag.name}" linking ${tagPosts.length} related posts.`
411
+ });
412
+ }
413
+ }
414
+
415
+ // Sort by post count (biggest opportunity first)
416
+ gaps.sort((a, b) => b.post_count - a.post_count);
417
+
418
+ result = json({ total_gaps: gaps.length, min_cluster_size: gapMinCluster, min_pillar_words: gapMinPillarWords, gaps });
419
+ auditLog({ tool: name, action: 'find_pillar_content_gaps', status: 'success', latency_ms: Date.now() - t0, params: sanitizeParams(args) });
420
+ return result;
421
+ };
422
+ handlers['wp_audit_internal_link_equity'] = async (args) => {
423
+ const t0 = Date.now();
424
+ let result;
425
+ const { wpApiCall, getActiveAuth, auditLog, sanitizeParams, name, extractInternalLinksHtml, countWords } = rt;
426
+ const linkTypes = args.post_types || ['post', 'page'];
427
+ const linkBatchSize = Math.min(args.batch_size || 50, 100);
428
+ const linkMaxPosts = args.max_posts || 500;
429
+
430
+ // Fetch all published posts in batches
431
+ const allContent = [];
432
+ for (const pt of linkTypes) {
433
+ const endpoint = pt === 'post' ? 'posts' : pt === 'page' ? 'pages' : pt;
434
+ let page = 1;
435
+ while (allContent.length < linkMaxPosts && page <= Math.ceil(linkMaxPosts / linkBatchSize)) {
436
+ try {
437
+ const batch = await wpApiCall(`/${endpoint}?per_page=${linkBatchSize}&page=${page}&status=publish`);
438
+ if (!Array.isArray(batch) || batch.length === 0) break;
439
+ allContent.push(...batch);
440
+ if (batch.length < linkBatchSize) break;
441
+ page++;
442
+ } catch (_e) { break; }
443
+ }
444
+ }
445
+
446
+ // Build link graph
447
+ const { url: siteUrl } = getActiveAuth();
448
+ const inbound = {}; // url -> Set of source post IDs
449
+ const outbound = {}; // post ID -> urls[]
450
+ const postMap = {}; // url -> { id, title, link, word_count }
451
+
452
+ for (const p of allContent) {
453
+ const pLink = p.link || '';
454
+ const pId = p.id;
455
+ const content = p.content?.rendered || '';
456
+ const title = strip(p.title?.rendered || '');
457
+ const wc = countWords(content);
458
+
459
+ postMap[pLink] = { id: pId, title, link: pLink, word_count: wc };
460
+ if (!inbound[pLink]) inbound[pLink] = new Set();
461
+
462
+ // Extract internal links from this post's content
463
+ const links = extractInternalLinksHtml(content, siteUrl);
464
+ outbound[pId] = links;
465
+
466
+ for (const target of links) {
467
+ if (!inbound[target]) inbound[target] = new Set();
468
+ inbound[target].add(pId);
469
+ }
470
+ }
471
+
472
+ // Analyze: orphans, over-linked, under-linked important pages
473
+ const orphans = [];
474
+ const overLinked = [];
475
+ const underLinked = [];
476
+
477
+ for (const [url, meta] of Object.entries(postMap)) {
478
+ const inCount = inbound[url] ? inbound[url].size : 0;
479
+ const outCount = outbound[meta.id] ? outbound[meta.id].length : 0;
480
+
481
+ if (inCount === 0) {
482
+ orphans.push({ ...meta, outbound_links: outCount });
483
+ }
484
+ if (inCount > 20) {
485
+ overLinked.push({ ...meta, inbound_links: inCount });
486
+ }
487
+ // Important but under-linked: >800 words, <3 inbound links
488
+ if (meta.word_count > 800 && inCount < 3) {
489
+ underLinked.push({ ...meta, inbound_links: inCount, outbound_links: outCount });
490
+ }
491
+ }
492
+
493
+ orphans.sort((a, b) => b.word_count - a.word_count);
494
+ overLinked.sort((a, b) => b.inbound_links - a.inbound_links);
495
+ underLinked.sort((a, b) => b.word_count - a.word_count);
496
+
497
+ // Distribution score: 0-100
498
+ const totalPages = Object.keys(postMap).length;
499
+ const orphanRatio = totalPages > 0 ? orphans.length / totalPages : 0;
500
+ const distributionScore = Math.round(Math.max(0, (1 - orphanRatio) * 100));
501
+
502
+ result = json({
503
+ total_pages_analyzed: totalPages,
504
+ distribution_score: distributionScore,
505
+ orphan_pages: { count: orphans.length, pages: orphans.slice(0, 20) },
506
+ over_linked_pages: { count: overLinked.length, pages: overLinked.slice(0, 10) },
507
+ under_linked_important: { count: underLinked.length, pages: underLinked.slice(0, 20) },
508
+ recommendations: [
509
+ orphans.length > 0 ? `${orphans.length} orphan page(s) with zero inbound links — add internal links from related content.` : null,
510
+ overLinked.length > 0 ? `${overLinked.length} page(s) with >20 inbound links — consider distributing link equity more evenly.` : null,
511
+ underLinked.length > 0 ? `${underLinked.length} important page(s) (>800 words) have fewer than 3 inbound links.` : null
512
+ ].filter(Boolean)
513
+ });
514
+ auditLog({ tool: name, action: 'audit_internal_link_equity', status: 'success', latency_ms: Date.now() - t0, params: sanitizeParams(args) });
515
+ return result;
516
+ };
517
+ handlers['wp_suggest_content_cluster'] = async (args) => {
518
+ const t0 = Date.now();
519
+ let result;
520
+ const { wpApiCall, fetch, auditLog, sanitizeParams, name, buildTFIDFVectors, computeCosineSimilarity, countWords } = rt;
521
+ const clusterTopic = args.topic || null;
522
+ const clusterPostId = args.post_id || null;
523
+ const clusterThreshold = args.similarity_threshold || 0.15;
524
+ const clusterTypes = args.post_types || ['post', 'page'];
525
+ const clusterMaxPosts = args.max_posts || 200;
526
+
527
+ if (!clusterTopic && !clusterPostId) {
528
+ throw new Error('Either "topic" or "post_id" parameter is required.');
529
+ }
530
+
531
+ // Fetch posts in batches
532
+ const allPosts = [];
533
+ for (const pt of clusterTypes) {
534
+ const endpoint = pt === 'post' ? 'posts' : pt === 'page' ? 'pages' : pt;
535
+ let page = 1;
536
+ while (allPosts.length < clusterMaxPosts && page <= Math.ceil(clusterMaxPosts / 50)) {
537
+ try {
538
+ const batch = await wpApiCall(`/${endpoint}?per_page=50&page=${page}&status=publish`);
539
+ if (!Array.isArray(batch) || batch.length === 0) break;
540
+ allPosts.push(...batch);
541
+ if (batch.length < 50) break;
542
+ page++;
543
+ } catch (_e) { break; }
544
+ }
545
+ }
546
+
547
+ // Build document corpus for TF-IDF
548
+ const documents = allPosts.map(p => {
549
+ const title = strip(p.title?.rendered || '');
550
+ const excerpt = strip(p.excerpt?.rendered || '');
551
+ const tags = (p.tag_names || []).join(' ');
552
+ return { id: p.id, text: `${title} ${title} ${excerpt} ${tags}` }; // title weighted 2x
553
+ });
554
+
555
+ const { vectors } = buildTFIDFVectors(documents);
556
+
557
+ // Find seed vector
558
+ let seedVector = null;
559
+ let seedId = null;
560
+
561
+ if (clusterPostId) {
562
+ seedVector = vectors.get(clusterPostId);
563
+ seedId = clusterPostId;
564
+ if (!seedVector) {
565
+ // Try to fetch the specific post
566
+ try {
567
+ const seedPost = await wpApiCall(`/posts/${clusterPostId}`);
568
+ const seedTitle = strip(seedPost.title?.rendered || '');
569
+ const seedExcerpt = strip(seedPost.excerpt?.rendered || '');
570
+ const seedDoc = [{ id: clusterPostId, text: `${seedTitle} ${seedTitle} ${seedExcerpt}` }, ...documents];
571
+ const recomputed = buildTFIDFVectors(seedDoc);
572
+ seedVector = recomputed.vectors.get(clusterPostId);
573
+ } catch (_e) {
574
+ throw new Error(`Post ${clusterPostId} not found.`);
575
+ }
576
+ }
577
+ } else {
578
+ // Create a synthetic document from the topic keyword
579
+ seedId = '__topic__';
580
+ const syntheticDocs = [{ id: seedId, text: `${clusterTopic} ${clusterTopic} ${clusterTopic}` }, ...documents];
581
+ const recomputed = buildTFIDFVectors(syntheticDocs);
582
+ seedVector = recomputed.vectors.get(seedId);
583
+ // Re-extract all vectors from recomputed
584
+ for (const [id, vec] of recomputed.vectors) {
585
+ if (id !== seedId) vectors.set(id, vec);
586
+ }
587
+ }
588
+
589
+ if (!seedVector) {
590
+ result = json({ clusters: [], message: 'Could not build seed vector.' });
591
+ return result;
592
+ }
593
+
594
+ // Compute similarity of all posts to seed
595
+ const similarities = [];
596
+ for (const [id, vec] of vectors) {
597
+ if (id === seedId) continue;
598
+ const sim = computeCosineSimilarity(seedVector, vec);
599
+ if (sim >= clusterThreshold) {
600
+ const post = allPosts.find(p => p.id === id);
601
+ similarities.push({
602
+ post_id: id,
603
+ title: post ? strip(post.title?.rendered || '') : `Post #${id}`,
604
+ link: post?.link || null,
605
+ word_count: post ? countWords(post.content?.rendered || '') : 0,
606
+ similarity: Math.round(sim * 1000) / 1000
607
+ });
608
+ }
609
+ }
610
+
611
+ similarities.sort((a, b) => b.similarity - a.similarity);
612
+
613
+ // Suggest pillar: highest word count in cluster
614
+ const pillarCandidate = [...similarities].sort((a, b) => b.word_count - a.word_count)[0] || null;
615
+
616
+ // Sub-cluster by similarity bands
617
+ const highSim = similarities.filter(s => s.similarity >= 0.4);
618
+ const medSim = similarities.filter(s => s.similarity >= 0.25 && s.similarity < 0.4);
619
+ const lowSim = similarities.filter(s => s.similarity < 0.25);
620
+
621
+ result = json({
622
+ seed: clusterTopic ? { type: 'keyword', topic: clusterTopic } : { type: 'post', post_id: clusterPostId },
623
+ cluster_size: similarities.length,
624
+ suggested_pillar: pillarCandidate,
625
+ related_posts: similarities.slice(0, 30),
626
+ similarity_bands: {
627
+ high: { threshold: '>=0.4', count: highSim.length },
628
+ medium: { threshold: '0.25-0.4', count: medSim.length },
629
+ low: { threshold: `${clusterThreshold}-0.25`, count: lowSim.length }
630
+ }
631
+ });
632
+ auditLog({ tool: name, action: 'suggest_content_cluster', status: 'success', latency_ms: Date.now() - t0, params: sanitizeParams(args) });
633
+ return result;
634
+ };