@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.
- package/.env.example +18 -0
- package/README.md +867 -499
- package/companion/mcp-diagnostics.php +1184 -0
- package/dxt/manifest.json +715 -98
- package/index.js +166 -4786
- package/package.json +14 -6
- package/src/data/plugin-performance-data.json +59 -0
- package/src/plugins/adapters/acf/acfAdapter.js +55 -3
- package/src/shared/api.js +79 -0
- package/src/shared/audit.js +39 -0
- package/src/shared/context.js +15 -0
- package/src/shared/governance.js +98 -0
- package/src/shared/utils.js +148 -0
- package/src/tools/comments.js +50 -0
- package/src/tools/content.js +395 -0
- package/src/tools/core.js +114 -0
- package/src/tools/editorial.js +634 -0
- package/src/tools/fse.js +370 -0
- package/src/tools/health.js +160 -0
- package/src/tools/index.js +96 -0
- package/src/tools/intelligence.js +2082 -0
- package/src/tools/links.js +118 -0
- package/src/tools/media.js +71 -0
- package/src/tools/performance.js +219 -0
- package/src/tools/plugins.js +368 -0
- package/src/tools/schema.js +417 -0
- package/src/tools/security.js +590 -0
- package/src/tools/seo.js +1633 -0
- package/src/tools/taxonomy.js +115 -0
- package/src/tools/users.js +188 -0
- package/src/tools/woocommerce.js +1008 -0
- package/src/tools/workflow.js +409 -0
- package/src/transport/http.js +39 -0
- package/tests/unit/helpers/pagination.test.js +43 -0
- package/tests/unit/plugins/acf/acfAdapter.test.js +43 -5
- package/tests/unit/tools/bulkUpdate.test.js +188 -0
- package/tests/unit/tools/diagnostics.test.js +397 -0
- package/tests/unit/tools/dynamicFiltering.test.js +100 -8
- package/tests/unit/tools/editorialIntelligence.test.js +817 -0
- package/tests/unit/tools/fse.test.js +548 -0
- package/tests/unit/tools/multilingual.test.js +653 -0
- package/tests/unit/tools/performance.test.js +351 -0
- package/tests/unit/tools/postMeta.test.js +105 -0
- package/tests/unit/tools/runWorkflow.test.js +150 -0
- package/tests/unit/tools/schema.test.js +477 -0
- package/tests/unit/tools/security.test.js +695 -0
- package/tests/unit/tools/site.test.js +1 -1
- package/tests/unit/tools/users.crud.test.js +399 -0
- package/tests/unit/tools/validateBlocks.test.js +186 -0
- package/tests/unit/tools/visualStaging.test.js +271 -0
- 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
|
+
};
|