@adsim/wordpress-mcp-server 3.1.0 → 4.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +564 -176
- package/dxt/manifest.json +93 -9
- package/index.js +3624 -36
- package/package.json +1 -1
- package/src/confirmationToken.js +64 -0
- package/src/contentAnalyzer.js +476 -0
- package/src/htmlParser.js +80 -0
- package/src/linkUtils.js +158 -0
- package/src/pluginDetector.js +158 -0
- package/src/utils/contentCompressor.js +116 -0
- package/src/woocommerceClient.js +88 -0
- package/tests/unit/contentAnalyzer.test.js +397 -0
- package/tests/unit/pluginDetector.test.js +167 -0
- package/tests/unit/tools/analyzeEeatSignals.test.js +192 -0
- package/tests/unit/tools/approval.test.js +251 -0
- package/tests/unit/tools/auditCanonicals.test.js +149 -0
- package/tests/unit/tools/auditHeadingStructure.test.js +150 -0
- package/tests/unit/tools/auditMediaSeo.test.js +123 -0
- package/tests/unit/tools/auditOutboundLinks.test.js +175 -0
- package/tests/unit/tools/auditTaxonomies.test.js +173 -0
- package/tests/unit/tools/contentCompressor.test.js +320 -0
- package/tests/unit/tools/contentIntelligence.test.js +2168 -0
- package/tests/unit/tools/destructive.test.js +246 -0
- package/tests/unit/tools/findBrokenInternalLinks.test.js +222 -0
- package/tests/unit/tools/findKeywordCannibalization.test.js +183 -0
- package/tests/unit/tools/findOrphanPages.test.js +145 -0
- package/tests/unit/tools/findThinContent.test.js +145 -0
- package/tests/unit/tools/internalLinks.test.js +283 -0
- package/tests/unit/tools/perTargetControls.test.js +228 -0
- package/tests/unit/tools/pluginIntelligence.test.js +864 -0
- package/tests/unit/tools/site.test.js +6 -1
- package/tests/unit/tools/woocommerce.test.js +344 -0
- package/tests/unit/tools/woocommerceIntelligence.test.js +341 -0
- package/tests/unit/tools/woocommerceWrite.test.js +323 -0
package/index.js
CHANGED
|
@@ -22,12 +22,19 @@ import fetch from 'node-fetch';
|
|
|
22
22
|
import { readFileSync, existsSync } from 'fs';
|
|
23
23
|
import { resolve } from 'path';
|
|
24
24
|
import { HttpTransportManager } from './src/transport/http.js';
|
|
25
|
+
import { generateToken, validateToken } from './src/confirmationToken.js';
|
|
26
|
+
import { extractInternalLinks, extractExternalLinks, checkLinkStatus, extractFocusKeyword, calculateRelevanceScore, suggestAnchorText } from './src/linkUtils.js';
|
|
27
|
+
import { wcApiCall, getWcCredentials } from './src/woocommerceClient.js';
|
|
28
|
+
import { parseImagesFromHtml, extractHeadings, extractInternalLinks as extractInternalLinksHtml, countWords } from './src/htmlParser.js';
|
|
29
|
+
import { summarizePost, applyContentFormat } from './src/utils/contentCompressor.js';
|
|
30
|
+
import { calculateReadabilityScore, extractHeadingsOutline, detectContentSections, extractTransitionWords, countPassiveSentences, buildTFIDFVectors, computeCosineSimilarity, findDuplicatePairs, extractEntities, computeTextDiff } from './src/contentAnalyzer.js';
|
|
31
|
+
import { detectSeoPlugin, getRenderedHead, parseRenderedHead } from './src/pluginDetector.js';
|
|
25
32
|
|
|
26
33
|
// ============================================================
|
|
27
34
|
// CONFIGURATION
|
|
28
35
|
// ============================================================
|
|
29
36
|
|
|
30
|
-
const VERSION = '
|
|
37
|
+
const VERSION = '3.2.0';
|
|
31
38
|
const VERBOSE = process.env.WP_MCP_VERBOSE === 'true' || process.argv.includes('--verbose');
|
|
32
39
|
const MAX_RETRIES = parseInt(process.env.WP_MCP_MAX_RETRIES || '3', 10);
|
|
33
40
|
const TIMEOUT_MS = parseInt(process.env.WP_MCP_TIMEOUT || '30000', 10);
|
|
@@ -41,6 +48,8 @@ const READ_ONLY = process.env.WP_READ_ONLY === 'true';
|
|
|
41
48
|
const DRAFT_ONLY = process.env.WP_DRAFT_ONLY === 'true';
|
|
42
49
|
const DISABLE_DELETE = process.env.WP_DISABLE_DELETE === 'true';
|
|
43
50
|
const DISABLE_PLUGIN_MANAGEMENT = process.env.WP_DISABLE_PLUGIN_MANAGEMENT === 'true';
|
|
51
|
+
const REQUIRE_APPROVAL = process.env.WP_REQUIRE_APPROVAL === 'true';
|
|
52
|
+
const CONFIRM_DESTRUCTIVE = process.env.WP_CONFIRM_DESTRUCTIVE === 'true';
|
|
44
53
|
const MAX_CALLS_PER_MINUTE = parseInt(process.env.WP_MAX_CALLS_PER_MINUTE || '0', 10); // 0 = unlimited
|
|
45
54
|
const ALLOWED_TYPES = process.env.WP_ALLOWED_TYPES ? process.env.WP_ALLOWED_TYPES.split(',').map(s => s.trim()) : null; // null = all
|
|
46
55
|
const ALLOWED_STATUSES = process.env.WP_ALLOWED_STATUSES ? process.env.WP_ALLOWED_STATUSES.split(',').map(s => s.trim()) : null;
|
|
@@ -50,38 +59,39 @@ const AUDIT_LOG = process.env.WP_AUDIT_LOG !== 'off'; // on by default
|
|
|
50
59
|
const rateLimiter = { calls: [], windowMs: 60000 };
|
|
51
60
|
|
|
52
61
|
function checkRateLimit() {
|
|
53
|
-
|
|
62
|
+
const maxCpm = getActiveControls().max_calls_per_minute;
|
|
63
|
+
if (maxCpm <= 0) return;
|
|
54
64
|
const now = Date.now();
|
|
55
65
|
rateLimiter.calls = rateLimiter.calls.filter(t => now - t < rateLimiter.windowMs);
|
|
56
|
-
if (rateLimiter.calls.length >=
|
|
57
|
-
throw new Error(`Rate limit exceeded: ${
|
|
66
|
+
if (rateLimiter.calls.length >= maxCpm) {
|
|
67
|
+
throw new Error(`Rate limit exceeded: ${maxCpm} calls/minute. Try again in a few seconds.`);
|
|
58
68
|
}
|
|
59
69
|
rateLimiter.calls.push(now);
|
|
60
70
|
}
|
|
61
71
|
|
|
62
72
|
function enforceReadOnly(toolName) {
|
|
63
|
-
const writeTools = ['wp_create_post', 'wp_update_post', 'wp_delete_post', 'wp_create_page', 'wp_update_page', 'wp_upload_media', 'wp_create_comment', 'wp_create_taxonomy_term', 'wp_update_seo_meta', 'wp_activate_plugin', 'wp_deactivate_plugin', 'wp_restore_revision', 'wp_delete_revision'];
|
|
64
|
-
if (
|
|
73
|
+
const writeTools = ['wp_create_post', 'wp_update_post', 'wp_delete_post', 'wp_create_page', 'wp_update_page', 'wp_upload_media', 'wp_create_comment', 'wp_create_taxonomy_term', 'wp_update_seo_meta', 'wp_activate_plugin', 'wp_deactivate_plugin', 'wp_restore_revision', 'wp_delete_revision', 'wp_submit_for_review', 'wp_approve_post', 'wp_reject_post', 'wc_list_products', 'wc_get_product', 'wc_list_orders', 'wc_get_order', 'wc_list_customers', 'wc_inventory_alert', 'wc_order_intelligence', 'wc_seo_product_audit', 'wc_suggest_product_links', 'wc_update_product', 'wc_update_stock', 'wc_update_order_status'];
|
|
74
|
+
if (getActiveControls().read_only && writeTools.includes(toolName)) {
|
|
65
75
|
throw new Error(`Blocked: Server is in READ-ONLY mode (WP_READ_ONLY=true). Tool "${toolName}" is not allowed.`);
|
|
66
76
|
}
|
|
67
77
|
}
|
|
68
78
|
|
|
69
79
|
function enforceDeleteDisabled(toolName) {
|
|
70
80
|
const deleteTools = ['wp_delete_post', 'wp_delete_revision'];
|
|
71
|
-
if (
|
|
81
|
+
if (getActiveControls().disable_delete && deleteTools.includes(toolName)) {
|
|
72
82
|
throw new Error(`Blocked: Destructive actions are disabled (WP_DISABLE_DELETE=true). Tool "${toolName}" is not allowed.`);
|
|
73
83
|
}
|
|
74
84
|
}
|
|
75
85
|
|
|
76
86
|
function enforcePluginManagement(toolName) {
|
|
77
87
|
const pluginWriteTools = ['wp_activate_plugin', 'wp_deactivate_plugin'];
|
|
78
|
-
if (
|
|
88
|
+
if (getActiveControls().disable_plugin_management && pluginWriteTools.includes(toolName)) {
|
|
79
89
|
throw new Error(`Blocked: Plugin management is disabled (WP_DISABLE_PLUGIN_MANAGEMENT=true). Tool "${toolName}" is not allowed.`);
|
|
80
90
|
}
|
|
81
91
|
}
|
|
82
92
|
|
|
83
93
|
function enforceDraftOnly(status) {
|
|
84
|
-
if (
|
|
94
|
+
if (getActiveControls().draft_only && status && status !== 'draft' && status !== 'pending') {
|
|
85
95
|
throw new Error(`Blocked: Server is in DRAFT-ONLY mode (WP_DRAFT_ONLY=true). Only "draft" and "pending" statuses are allowed, got "${status}".`);
|
|
86
96
|
}
|
|
87
97
|
}
|
|
@@ -98,6 +108,55 @@ function enforceAllowedStatuses(status) {
|
|
|
98
108
|
}
|
|
99
109
|
}
|
|
100
110
|
|
|
111
|
+
// ── Per-target controls merge (OR strict) ──
|
|
112
|
+
|
|
113
|
+
function getActiveControls() {
|
|
114
|
+
const tc = currentTarget?.controls || {};
|
|
115
|
+
const globalMaxCpm = parseInt(process.env.WP_MAX_CALLS_PER_MINUTE || '0', 10);
|
|
116
|
+
const targetMaxCpm = tc.max_calls_per_minute || 0;
|
|
117
|
+
let effectiveMaxCpm;
|
|
118
|
+
if (globalMaxCpm > 0 && targetMaxCpm > 0) effectiveMaxCpm = Math.min(globalMaxCpm, targetMaxCpm);
|
|
119
|
+
else if (globalMaxCpm > 0) effectiveMaxCpm = globalMaxCpm;
|
|
120
|
+
else if (targetMaxCpm > 0) effectiveMaxCpm = targetMaxCpm;
|
|
121
|
+
else effectiveMaxCpm = 0;
|
|
122
|
+
|
|
123
|
+
return {
|
|
124
|
+
read_only: process.env.WP_READ_ONLY === 'true' || tc.read_only === true,
|
|
125
|
+
draft_only: process.env.WP_DRAFT_ONLY === 'true' || tc.draft_only === true,
|
|
126
|
+
disable_delete: process.env.WP_DISABLE_DELETE === 'true' || tc.disable_delete === true,
|
|
127
|
+
disable_plugin_management: process.env.WP_DISABLE_PLUGIN_MANAGEMENT === 'true' || tc.disable_plugin_management === true,
|
|
128
|
+
require_approval: process.env.WP_REQUIRE_APPROVAL === 'true' || tc.require_approval === true,
|
|
129
|
+
confirm_destructive: process.env.WP_CONFIRM_DESTRUCTIVE === 'true' || tc.confirm_destructive === true,
|
|
130
|
+
max_calls_per_minute: effectiveMaxCpm,
|
|
131
|
+
};
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function getControlSources() {
|
|
135
|
+
const tc = currentTarget?.controls || {};
|
|
136
|
+
const src = (g, t) => (g && t) ? 'both' : g ? 'global' : t ? 'target' : 'none';
|
|
137
|
+
const globalMaxCpm = parseInt(process.env.WP_MAX_CALLS_PER_MINUTE || '0', 10);
|
|
138
|
+
const targetMaxCpm = tc.max_calls_per_minute || 0;
|
|
139
|
+
return {
|
|
140
|
+
read_only_source: src(process.env.WP_READ_ONLY === 'true', tc.read_only === true),
|
|
141
|
+
draft_only_source: src(process.env.WP_DRAFT_ONLY === 'true', tc.draft_only === true),
|
|
142
|
+
disable_delete_source: src(process.env.WP_DISABLE_DELETE === 'true', tc.disable_delete === true),
|
|
143
|
+
disable_plugin_management_source: src(process.env.WP_DISABLE_PLUGIN_MANAGEMENT === 'true', tc.disable_plugin_management === true),
|
|
144
|
+
require_approval_source: src(process.env.WP_REQUIRE_APPROVAL === 'true', tc.require_approval === true),
|
|
145
|
+
confirm_destructive_source: src(process.env.WP_CONFIRM_DESTRUCTIVE === 'true', tc.confirm_destructive === true),
|
|
146
|
+
max_calls_per_minute_source: (globalMaxCpm > 0 && targetMaxCpm > 0) ? 'both' : (globalMaxCpm > 0 ? 'global' : (targetMaxCpm > 0 ? 'target' : 'none')),
|
|
147
|
+
};
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// Test helper — not part of public API
|
|
151
|
+
function _testSetTarget(name, config) {
|
|
152
|
+
if (name && config) {
|
|
153
|
+
targets[name] = config;
|
|
154
|
+
currentTarget = { name, ...config };
|
|
155
|
+
} else {
|
|
156
|
+
currentTarget = null;
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
101
160
|
// ============================================================
|
|
102
161
|
// LOGGER & AUDIT
|
|
103
162
|
// ============================================================
|
|
@@ -121,7 +180,8 @@ function auditLog(entry) {
|
|
|
121
180
|
latency_ms: entry.latency_ms,
|
|
122
181
|
site: entry.site || currentTarget?.name || 'default',
|
|
123
182
|
params: entry.params || {},
|
|
124
|
-
error: entry.error || null
|
|
183
|
+
error: entry.error || null,
|
|
184
|
+
...(entry.effective_controls ? { effective_controls: entry.effective_controls } : {})
|
|
125
185
|
};
|
|
126
186
|
console.error(`[AUDIT] ${JSON.stringify(record)}`);
|
|
127
187
|
}
|
|
@@ -376,24 +436,32 @@ const ORDERBY = ['date', 'relevance', 'id', 'title', 'slug', 'modified', 'author
|
|
|
376
436
|
const ORDERS = ['asc', 'desc'];
|
|
377
437
|
const MEDIA_TYPES = ['image', 'video', 'audio', 'application'];
|
|
378
438
|
const COMMENT_STATUSES = ['approved', 'hold', 'spam', 'trash'];
|
|
379
|
-
const TOOLS_COUNT =
|
|
439
|
+
const TOOLS_COUNT = 85;
|
|
380
440
|
|
|
381
441
|
function json(data) { return { content: [{ type: 'text', text: JSON.stringify(data, null, 2) }] }; }
|
|
382
442
|
function strip(html) { return (html || '').replace(/<[^>]*>/g, '').trim(); }
|
|
383
443
|
|
|
384
444
|
// ============================================================
|
|
385
|
-
// TOOL DEFINITIONS (
|
|
445
|
+
// TOOL DEFINITIONS (56 tools)
|
|
386
446
|
// ============================================================
|
|
387
447
|
|
|
388
448
|
const TOOLS_DEFINITIONS = [
|
|
389
449
|
// ── POSTS (6) ──
|
|
390
|
-
{ name: 'wp_list_posts', description: 'List posts with filtering and search.', inputSchema: { type: 'object', properties: { per_page: { type: 'number', default: 10 }, page: { type: 'number', default: 1 }, status: { type: 'string', default: 'publish' }, orderby: { type: 'string', default: 'date' }, order: { type: 'string', default: 'desc' }, categories: { type: 'string' }, tags: { type: 'string' }, search: { type: 'string' }, author: { type: 'number' } }}},
|
|
391
|
-
{ name: 'wp_get_post', description: 'Get post by ID
|
|
450
|
+
{ name: 'wp_list_posts', description: 'List posts with filtering and search. Default "full" mode returns all fields including excerpts — use mode:"summary" for listing/inventory workflows (id, title, slug, date, status, link only), mode:"ids_only" when you only need IDs for a subsequent batch operation (e.g. then calling wp_get_post per ID). Use mode:"full" only when you need excerpts or category/tag details.', inputSchema: { type: 'object', properties: { per_page: { type: 'number', default: 10 }, page: { type: 'number', default: 1 }, status: { type: 'string', default: 'publish' }, orderby: { type: 'string', default: 'date' }, order: { type: 'string', default: 'desc' }, categories: { type: 'string' }, tags: { type: 'string' }, search: { type: 'string' }, author: { type: 'number' }, mode: { type: 'string', default: 'full', description: 'full=all fields, summary=id/title/slug/date/status/link only, ids_only=flat ID array' } }}},
|
|
451
|
+
{ name: 'wp_get_post', description: 'Get post by ID. WARNING: default returns full HTML content (50,000-187,000 chars on Elementor/page-builder sites) — always specify content_format to avoid context overflow. Use content_format:"links_only" for internal linking workflows (~800 chars instead of 187,000). Use content_format:"text" for content audit/rewrite workflows (plain text truncated to 15,000 chars). Use content_format:"html" only when you need raw HTML for structure analysis. Combine with fields:["id","title","content"] for rewrite tasks or fields:["id","title","meta"] for SEO-only tasks. Prefer wp_get_seo_meta over wp_get_post when you only need SEO metadata.', inputSchema: { type: 'object', properties: { id: { type: 'number' }, fields: { type: 'array', items: { type: 'string' }, description: 'Return only specified fields (id,title,content,excerpt,slug,status,date,modified,categories,tags,author,featured_media,meta,link). Omit for all.' }, content_format: { type: 'string', default: 'html', description: 'html=raw HTML (truncated at WP_MAX_CONTENT_CHARS), text=plain text, links_only=extract internal links only' } }, required: ['id'] }},
|
|
392
452
|
{ name: 'wp_create_post', description: 'Create a post (default: draft).', inputSchema: { type: 'object', properties: { title: { type: 'string' }, content: { type: 'string' }, status: { type: 'string', default: 'draft' }, excerpt: { type: 'string' }, categories: { type: 'array', items: { type: 'number' } }, tags: { type: 'array', items: { type: 'number' } }, slug: { type: 'string' }, featured_media: { type: 'number' }, meta: { type: 'object' }, author: { type: 'number' } }, required: ['title', 'content'] }},
|
|
393
453
|
{ name: 'wp_update_post', description: 'Update a post.', inputSchema: { type: 'object', properties: { id: { type: 'number' }, title: { type: 'string' }, content: { type: 'string' }, status: { type: 'string' }, excerpt: { type: 'string' }, categories: { type: 'array', items: { type: 'number' } }, tags: { type: 'array', items: { type: 'number' } }, slug: { type: 'string' }, featured_media: { type: 'number' }, meta: { type: 'object' }, author: { type: 'number' } }, required: ['id'] }},
|
|
394
|
-
{ name: 'wp_delete_post', description: 'Delete a post (trash or permanent).', inputSchema: { type: 'object', properties: { id: { type: 'number' }, force: { type: 'boolean', default: false } }, required: ['id'] }},
|
|
454
|
+
{ name: 'wp_delete_post', description: 'Delete a post (trash or permanent). When WP_CONFIRM_DESTRUCTIVE=true, requires a confirmation_token (call once without to get token, then again with token).', inputSchema: { type: 'object', properties: { id: { type: 'number' }, force: { type: 'boolean', default: false }, confirmation_token: { type: 'string', description: 'Confirmation token returned by the first call when WP_CONFIRM_DESTRUCTIVE=true' } }, required: ['id'] }},
|
|
395
455
|
{ name: 'wp_search', description: 'Full-text search across all content.', inputSchema: { type: 'object', properties: { search: { type: 'string' }, per_page: { type: 'number', default: 10 }, type: { type: 'string', default: '' } }, required: ['search'] }},
|
|
396
456
|
|
|
457
|
+
// ── APPROVAL WORKFLOW (3) ──
|
|
458
|
+
{ name: 'wp_submit_for_review', description: 'Submit a draft post for editorial review (draft → pending). Blocked by WP_READ_ONLY.',
|
|
459
|
+
inputSchema: { type: 'object', properties: { id: { type: 'number', description: 'Post ID' }, note: { type: 'string', description: 'Optional review note (stored as post meta _mcp_review_note)' } }, required: ['id'] }},
|
|
460
|
+
{ name: 'wp_approve_post', description: 'Approve a pending post for publication (pending → publish). Blocked by WP_READ_ONLY and WP_DRAFT_ONLY.',
|
|
461
|
+
inputSchema: { type: 'object', properties: { id: { type: 'number', description: 'Post ID' } }, required: ['id'] }},
|
|
462
|
+
{ name: 'wp_reject_post', description: 'Reject a pending post back to draft with a reason (pending → draft). Stores rejection reason and increments rejection count. Blocked by WP_READ_ONLY.',
|
|
463
|
+
inputSchema: { type: 'object', properties: { id: { type: 'number', description: 'Post ID' }, reason: { type: 'string', description: 'Reason for rejection (stored as post meta _mcp_rejection_reason)' } }, required: ['id', 'reason'] }},
|
|
464
|
+
|
|
397
465
|
// ── PAGES (4) ──
|
|
398
466
|
{ name: 'wp_list_pages', description: 'List pages with hierarchy.', inputSchema: { type: 'object', properties: { per_page: { type: 'number', default: 10 }, page: { type: 'number', default: 1 }, status: { type: 'string', default: 'publish' }, parent: { type: 'number' }, orderby: { type: 'string', default: 'menu_order' }, order: { type: 'string', default: 'asc' }, search: { type: 'string' } }}},
|
|
399
467
|
{ name: 'wp_get_page', description: 'Get page by ID with content and template.', inputSchema: { type: 'object', properties: { id: { type: 'number' } }, required: ['id'] }},
|
|
@@ -430,11 +498,11 @@ const TOOLS_DEFINITIONS = [
|
|
|
430
498
|
inputSchema: { type: 'object', properties: {} }},
|
|
431
499
|
|
|
432
500
|
// ── SEO METADATA (3) ──
|
|
433
|
-
{ name: 'wp_get_seo_meta', description: 'Get SEO metadata (title, description, focus keyword, robots, canonical, og) for a post or page. Auto-detects Yoast SEO, RankMath, SEOPress, or All in One SEO.',
|
|
501
|
+
{ name: 'wp_get_seo_meta', description: 'Get SEO metadata (title, description, focus keyword, robots, canonical, og) for a post or page. Auto-detects Yoast SEO, RankMath, SEOPress, or All in One SEO. Preferred over wp_get_post for SEO-only workflows — no HTML content loaded.',
|
|
434
502
|
inputSchema: { type: 'object', properties: { id: { type: 'number', description: 'Post or page ID' }, post_type: { type: 'string', default: 'post', description: 'post or page' } }, required: ['id'] }},
|
|
435
503
|
{ name: 'wp_update_seo_meta', description: 'Update SEO metadata (title, description, focus keyword) for a post or page. Auto-detects installed SEO plugin.',
|
|
436
504
|
inputSchema: { type: 'object', properties: { id: { type: 'number', description: 'Post or page ID' }, post_type: { type: 'string', default: 'post', description: 'post or page' }, title: { type: 'string', description: 'SEO title' }, description: { type: 'string', description: 'Meta description' }, focus_keyword: { type: 'string', description: 'Focus keyword' }, canonical_url: { type: 'string', description: 'Canonical URL' }, robots_noindex: { type: 'boolean', description: 'Set noindex' }, robots_nofollow: { type: 'boolean', description: 'Set nofollow' } }, required: ['id'] }},
|
|
437
|
-
{ name: 'wp_audit_seo', description: 'Audit SEO metadata across multiple posts/pages. Returns
|
|
505
|
+
{ name: 'wp_audit_seo', description: 'Audit SEO metadata across multiple posts/pages. Returns scores and issue flags only — never loads post content, safe for bulk operations.',
|
|
438
506
|
inputSchema: { type: 'object', properties: { post_type: { type: 'string', default: 'post', description: 'post or page' }, per_page: { type: 'number', default: 20, description: 'Number of posts to audit (max 100)' }, status: { type: 'string', default: 'publish' }, orderby: { type: 'string', default: 'date' }, order: { type: 'string', default: 'desc' } }}},
|
|
439
507
|
|
|
440
508
|
// ── PLUGINS (3) ──
|
|
@@ -458,8 +526,130 @@ const TOOLS_DEFINITIONS = [
|
|
|
458
526
|
inputSchema: { type: 'object', properties: { post_id: { type: 'number', description: 'Post or page ID' }, revision_id: { type: 'number', description: 'Revision ID' }, post_type: { type: 'string', enum: ['post', 'page'], default: 'post', description: 'Post type' } }, required: ['post_id', 'revision_id'] }},
|
|
459
527
|
{ name: 'wp_restore_revision', description: 'Restore a post or page to a previous revision. Copies revision content back to the post. Blocked by WP_READ_ONLY.',
|
|
460
528
|
inputSchema: { type: 'object', properties: { post_id: { type: 'number', description: 'Post or page ID' }, revision_id: { type: 'number', description: 'Revision ID to restore' }, post_type: { type: 'string', enum: ['post', 'page'], default: 'post', description: 'Post type' } }, required: ['post_id', 'revision_id'] }},
|
|
461
|
-
{ name: 'wp_delete_revision', description: 'Permanently delete a revision. This action cannot be undone. Blocked by WP_READ_ONLY and WP_DISABLE_DELETE.',
|
|
462
|
-
inputSchema: { type: 'object', properties: { post_id: { type: 'number', description: 'Post or page ID' }, revision_id: { type: 'number', description: 'Revision ID to delete' }, post_type: { type: 'string', enum: ['post', 'page'], default: 'post', description: 'Post type' } }, required: ['post_id', 'revision_id'] }}
|
|
529
|
+
{ name: 'wp_delete_revision', description: 'Permanently delete a revision. This action cannot be undone. Blocked by WP_READ_ONLY and WP_DISABLE_DELETE. When WP_CONFIRM_DESTRUCTIVE=true, requires a confirmation_token.',
|
|
530
|
+
inputSchema: { type: 'object', properties: { post_id: { type: 'number', description: 'Post or page ID' }, revision_id: { type: 'number', description: 'Revision ID to delete' }, post_type: { type: 'string', enum: ['post', 'page'], default: 'post', description: 'Post type' }, confirmation_token: { type: 'string', description: 'Confirmation token returned by the first call when WP_CONFIRM_DESTRUCTIVE=true' } }, required: ['post_id', 'revision_id'] }},
|
|
531
|
+
|
|
532
|
+
// ── LINK ANALYSIS (2) ──
|
|
533
|
+
{ name: 'wp_analyze_links', description: 'Analyze internal and external links in a post. Optionally checks for broken internal links via HEAD requests (read-only).',
|
|
534
|
+
inputSchema: { type: 'object', properties: { post_id: { type: 'number', description: 'Post ID to analyze' }, check_broken: { type: 'boolean', default: true, description: 'Check broken internal links via HEAD request' }, timeout_ms: { type: 'number', default: 5000, description: 'Timeout per HEAD request in ms' } }, required: ['post_id'] }},
|
|
535
|
+
{ name: 'wp_suggest_internal_links', description: 'Suggest internal links for a post based on keyword relevance, shared categories, and content freshness (read-only). Use before wp_get_post with content_format:"links_only" to map existing links first.',
|
|
536
|
+
inputSchema: { type: 'object', properties: { post_id: { type: 'number', description: 'Post ID to get suggestions for' }, max_suggestions: { type: 'number', default: 5, description: 'Number of suggestions (1-10)' }, focus_keywords: { type: 'array', items: { type: 'string' }, description: 'Additional keywords to match against' }, exclude_already_linked: { type: 'boolean', default: true, description: 'Exclude posts already linked from the current post' } }, required: ['post_id'] }},
|
|
537
|
+
|
|
538
|
+
// ── WOOCOMMERCE (6) ──
|
|
539
|
+
{ name: 'wc_list_products', description: 'List WooCommerce products with filtering and search. Requires WC_CONSUMER_KEY and WC_CONSUMER_SECRET. Blocked by WP_READ_ONLY.',
|
|
540
|
+
inputSchema: { type: 'object', properties: { per_page: { type: 'number', default: 10 }, page: { type: 'number', default: 1 }, status: { type: 'string', default: 'any', description: 'any, draft, pending, private, publish' }, search: { type: 'string' }, category: { type: 'number', description: 'Category ID' }, orderby: { type: 'string', default: 'date', description: 'date, id, title, price, popularity' }, order: { type: 'string', default: 'desc', description: 'asc or desc' } }}},
|
|
541
|
+
{ name: 'wc_get_product', description: 'Get a WooCommerce product by ID with full details. Includes variations summary for variable products. Blocked by WP_READ_ONLY.',
|
|
542
|
+
inputSchema: { type: 'object', properties: { id: { type: 'number', description: 'Product ID' } }, required: ['id'] }},
|
|
543
|
+
{ name: 'wc_list_orders', description: 'List WooCommerce orders with filtering. Blocked by WP_READ_ONLY.',
|
|
544
|
+
inputSchema: { type: 'object', properties: { per_page: { type: 'number', default: 10 }, page: { type: 'number', default: 1 }, status: { type: 'string', default: 'any', description: 'any, pending, processing, on-hold, completed, cancelled, refunded, failed' }, customer: { type: 'number', description: 'Customer ID' }, orderby: { type: 'string', default: 'date' }, order: { type: 'string', default: 'desc' } }}},
|
|
545
|
+
{ name: 'wc_get_order', description: 'Get a WooCommerce order by ID with full details including line items, shipping, billing, and payment info. Blocked by WP_READ_ONLY.',
|
|
546
|
+
inputSchema: { type: 'object', properties: { id: { type: 'number', description: 'Order ID' } }, required: ['id'] }},
|
|
547
|
+
{ name: 'wc_list_customers', description: 'List WooCommerce customers with search and filtering. Blocked by WP_READ_ONLY.',
|
|
548
|
+
inputSchema: { type: 'object', properties: { per_page: { type: 'number', default: 10 }, page: { type: 'number', default: 1 }, search: { type: 'string' }, orderby: { type: 'string', default: 'date_created' }, order: { type: 'string', default: 'desc' }, role: { type: 'string', default: 'customer' } }}},
|
|
549
|
+
{ name: 'wc_price_guardrail', description: 'Analyze a product price change without modifying anything. Returns safe/unsafe assessment based on threshold percentage. Always allowed even in WP_READ_ONLY mode.',
|
|
550
|
+
inputSchema: { type: 'object', properties: { product_id: { type: 'number', description: 'Product ID' }, new_price: { type: 'number', description: 'Proposed new price' }, threshold_percent: { type: 'number', default: 20, description: 'Maximum allowed change percentage (default 20)' } }, required: ['product_id', 'new_price'] }},
|
|
551
|
+
|
|
552
|
+
// ── WOOCOMMERCE INTELLIGENCE (4) ──
|
|
553
|
+
{ name: 'wc_inventory_alert', description: 'Identify low-stock and out-of-stock products below a threshold, sorted by urgency. Blocked by WP_READ_ONLY.',
|
|
554
|
+
inputSchema: { type: 'object', properties: { threshold: { type: 'number', default: 5, description: 'Stock quantity threshold (default 5)' }, per_page: { type: 'number', default: 50, description: 'Products to scan (max 100)' }, include_out_of_stock: { type: 'boolean', default: true, description: 'Include out-of-stock products' } }}},
|
|
555
|
+
{ name: 'wc_order_intelligence', description: 'Analyze customer purchase history: lifetime value, average order, favourite products, order frequency, status breakdown. Blocked by WP_READ_ONLY.',
|
|
556
|
+
inputSchema: { type: 'object', properties: { customer_id: { type: 'number', description: 'Customer ID' } }, required: ['customer_id'] }},
|
|
557
|
+
{ name: 'wc_seo_product_audit', description: 'Audit WooCommerce product listings for SEO issues (missing descriptions, images, alt text, generic slugs, missing price). Returns per-product scores. Blocked by WP_READ_ONLY.',
|
|
558
|
+
inputSchema: { type: 'object', properties: { per_page: { type: 'number', default: 20, description: 'Products to audit (max 100)' }, page: { type: 'number', default: 1 } }}},
|
|
559
|
+
{ name: 'wc_suggest_product_links', description: 'Suggest WooCommerce products to link from a WordPress blog post based on SEO keyword relevance. Blocked by WP_READ_ONLY.',
|
|
560
|
+
inputSchema: { type: 'object', properties: { post_id: { type: 'number', description: 'WordPress post ID' }, max_suggestions: { type: 'number', default: 3, description: 'Maximum suggestions (1-5)' } }, required: ['post_id'] }},
|
|
561
|
+
|
|
562
|
+
// ── WOOCOMMERCE WRITE (3) ──
|
|
563
|
+
{ name: 'wc_update_product', description: 'Update a WooCommerce product. Includes automatic price guardrail: price changes >20% require price_guardrail_confirmed=true. Blocked by WP_READ_ONLY.',
|
|
564
|
+
inputSchema: { type: 'object', properties: { id: { type: 'number', description: 'Product ID' }, name: { type: 'string' }, description: { type: 'string' }, short_description: { type: 'string' }, regular_price: { type: 'string', description: 'Format "19.99"' }, sale_price: { type: 'string' }, status: { type: 'string', description: 'publish, draft, or private' }, price_guardrail_confirmed: { type: 'boolean', default: false, description: 'Set true to bypass price guardrail after calling wc_price_guardrail' } }, required: ['id'] }},
|
|
565
|
+
{ name: 'wc_update_stock', description: 'Update stock quantity of a WooCommerce product or variation. Blocked by WP_READ_ONLY.',
|
|
566
|
+
inputSchema: { type: 'object', properties: { id: { type: 'number', description: 'Product ID' }, stock_quantity: { type: 'number', description: 'New stock quantity (>= 0)' }, variation_id: { type: 'number', description: 'Variation ID (for variable products)' } }, required: ['id', 'stock_quantity'] }},
|
|
567
|
+
{ name: 'wc_update_order_status', description: 'Update WooCommerce order status with transition validation. Blocked by WP_READ_ONLY.',
|
|
568
|
+
inputSchema: { type: 'object', properties: { id: { type: 'number', description: 'Order ID' }, status: { type: 'string', description: 'New status (processing, completed, cancelled, refunded, on-hold, failed)' }, note: { type: 'string', description: 'Optional internal note added to the order' } }, required: ['id', 'status'] }},
|
|
569
|
+
|
|
570
|
+
// ── SEO ADVANCED (3) ──
|
|
571
|
+
{ name: 'wp_audit_media_seo', description: 'Audit media library images for SEO issues (missing alt text, filename-as-alt, alt too short). Optionally scans post content for inline images. Read-only.',
|
|
572
|
+
inputSchema: { type: 'object', properties: { per_page: { type: 'number', default: 50, description: 'Media items to audit (max 100)' }, page: { type: 'number', default: 1 }, post_id: { type: 'number', description: 'Optional: also scan inline images from this post' } }}},
|
|
573
|
+
{ name: 'wp_find_orphan_pages', description: 'Find published pages with no internal links pointing to them from other pages. Read-only.',
|
|
574
|
+
inputSchema: { type: 'object', properties: { per_page: { type: 'number', default: 100, description: 'Pages to scan (max 100)' }, exclude_ids: { type: 'array', items: { type: 'number' }, description: 'Page IDs to exclude from orphan check' }, min_words: { type: 'number', default: 0, description: 'Minimum word count to include in results' } }}},
|
|
575
|
+
{ name: 'wp_audit_heading_structure', description: 'Audit heading hierarchy (H1-H6) of a post or page for SEO issues: H1 in content, level skips, empty headings, keyword stuffing. Read-only.',
|
|
576
|
+
inputSchema: { type: 'object', properties: { id: { type: 'number', description: 'Post or page ID' }, post_type: { type: 'string', default: 'post', description: 'post or page' }, focus_keyword: { type: 'string', description: 'Optional keyword to check in H2 headings' } }, required: ['id'] }},
|
|
577
|
+
|
|
578
|
+
// ── SEO ADVANCED v4.1 (3) ──
|
|
579
|
+
{ name: 'wp_find_thin_content', description: 'Find thin/low-quality published posts: too short, outdated, uncategorized. Classifies severity and suggests actions. Read-only.',
|
|
580
|
+
inputSchema: { type: 'object', properties: { limit: { type: 'number', default: 100, description: 'Max posts to scan (1-500)' }, min_words: { type: 'number', default: 300, description: 'Threshold for "too short"' }, critical_words: { type: 'number', default: 150, description: 'Threshold for "very short"' }, max_age_days: { type: 'number', default: 730, description: 'Days since update to flag as outdated' }, include_uncategorized: { type: 'boolean', default: true, description: 'Flag uncategorized posts' }, post_type: { type: 'string', default: 'post', description: 'post or page' } }}},
|
|
581
|
+
{ name: 'wp_audit_canonicals', description: 'Audit canonical URLs for SEO issues: missing, HTTP on HTTPS site, staging URLs, wrong domain, trailing slash mismatch. Auto-detects SEO plugin. Read-only.',
|
|
582
|
+
inputSchema: { type: 'object', properties: { limit: { type: 'number', default: 50, description: 'Max posts to audit (1-200)' }, post_type: { type: 'string', default: 'post', description: 'post, page, or both' }, check_staging_patterns: { type: 'boolean', default: true, description: 'Detect staging/dev URLs' } }}},
|
|
583
|
+
{ name: 'wp_analyze_eeat_signals', description: 'Analyze E-E-A-T (Experience, Expertise, Authoritativeness, Trustworthiness) signals for posts. Scores 0-100 with actionable priority fixes. Read-only.',
|
|
584
|
+
inputSchema: { type: 'object', properties: { post_ids: { type: 'array', items: { type: 'number' }, description: 'Specific post IDs to analyze (if empty, audits latest N)' }, limit: { type: 'number', default: 10, description: 'Number of latest posts to audit (1-50)' }, post_type: { type: 'string', default: 'post', description: 'post or page' }, authoritative_domains: { type: 'array', items: { type: 'string' }, default: ['wikipedia.org', 'gov', 'edu', 'who.int', 'pubmed'], description: 'Domains considered authoritative' } }}},
|
|
585
|
+
|
|
586
|
+
// ── SEO ADVANCED v4.2 (4) ──
|
|
587
|
+
{ name: 'wp_find_broken_internal_links', description: 'Scan published posts, extract all internal links and verify accessibility via HEAD requests. Detects 404s, 301/302 redirects, timeouts and network errors. Configurable batching to avoid overloading WordPress. Read-only.',
|
|
588
|
+
inputSchema: { type: 'object', properties: { limit_posts: { type: 'number', default: 20, description: 'Max posts to scan (1-100)' }, batch_size: { type: 'number', default: 5, description: 'Links per batch (1-10)' }, timeout_ms: { type: 'number', default: 5000, description: 'Timeout per HEAD request (1000-30000)' }, delay_ms: { type: 'number', default: 200, description: 'Delay between batches (0-2000)' }, post_type: { type: 'string', default: 'post', description: 'post, page, or both' }, include_redirects: { type: 'boolean', default: true, description: 'Include 301/302 redirects in results' } }}},
|
|
589
|
+
{ name: 'wp_find_keyword_cannibalization', description: 'Detect articles targeting the same SEO focus keywords, creating internal competition that dilutes authority. Returns cannibalization groups with recommended actions. Read-only.',
|
|
590
|
+
inputSchema: { type: 'object', properties: { limit: { type: 'number', default: 200, description: 'Max posts to analyze (1-500)' }, post_type: { type: 'string', default: 'post', description: 'post, page, or both' }, similarity_mode: { type: 'string', default: 'normalized', description: 'exact or normalized keyword matching' }, min_group_size: { type: 'number', default: 2, description: 'Minimum articles per group (min 2)' } }}},
|
|
591
|
+
{ name: 'wp_audit_taxonomies', description: 'Audit categories and tags for taxonomy bloat: empty tags, single-post tags, near-duplicate terms, categories without SEO description. These archive pages create crawl waste for Googlebot. Read-only.',
|
|
592
|
+
inputSchema: { type: 'object', properties: { check_tags: { type: 'boolean', default: true, description: 'Audit tags' }, check_categories: { type: 'boolean', default: true, description: 'Audit categories' }, min_posts_threshold: { type: 'number', default: 2, description: 'Minimum posts per term' }, detect_duplicates: { type: 'boolean', default: true, description: 'Detect near-duplicate terms via Levenshtein' } }}},
|
|
593
|
+
{ name: 'wp_audit_outbound_links', description: 'Analyze outbound (external) links in published posts. Detects posts without external sources, posts with too many outbound links (dilution), and identifies most-cited domains. Good outbound link profile improves E-E-A-T credibility. Read-only.',
|
|
594
|
+
inputSchema: { type: 'object', properties: { limit: { type: 'number', default: 30, description: 'Max posts to audit (1-100)' }, post_type: { type: 'string', default: 'post', description: 'post or page' }, min_outbound: { type: 'number', default: 1, description: 'Minimum outbound links threshold' }, max_outbound: { type: 'number', default: 15, description: 'Maximum outbound links before dilution warning' }, authoritative_domains: { type: 'array', items: { type: 'string' }, default: ['wikipedia.org', 'gov', 'edu', 'who.int', 'pubmed.ncbi'], description: 'Domains considered authoritative' } }}},
|
|
595
|
+
|
|
596
|
+
// ── CONTENT INTELLIGENCE v4.4 (2) ──
|
|
597
|
+
{ name: 'wp_get_content_brief', description: 'Aggregate a complete editorial/SEO brief for a single post or page in one call: word count, readability score, heading structure, internal/external links, SEO metadata, content sections (intro, conclusion, FAQ, lists, tables, images), categories and tags resolved to names. Read-only.',
|
|
598
|
+
inputSchema: { type: 'object', properties: { id: { type: 'number', description: 'Post or page ID' }, post_type: { type: 'string', default: 'post', description: 'post or page' } }, required: ['id'] }},
|
|
599
|
+
{ name: 'wp_extract_post_outline', description: 'Extract heading structure (H1-H4) from N posts in a category to build a reference outline. Returns per-post outlines with word counts, aggregated stats, and common H2 patterns ranked by frequency. Read-only.',
|
|
600
|
+
inputSchema: { type: 'object', properties: { category_id: { type: 'number', description: 'Category ID to analyze' }, post_type: { type: 'string', default: 'post', description: 'post or page' }, limit: { type: 'number', default: 10, description: 'Max posts to analyze (1-50)' } }, required: ['category_id'] }},
|
|
601
|
+
|
|
602
|
+
// ── CONTENT INTELLIGENCE v4.4 Week 2 (3) ──
|
|
603
|
+
{ name: 'wp_audit_readability', description: 'Audit Flesch-Kincaid FR readability in bulk across N published posts. Returns per-post scores, transition word density, passive voice ratio, issues flagged, and aggregated distribution. Sorted worst-first. Read-only.',
|
|
604
|
+
inputSchema: { type: 'object', properties: { limit: { type: 'number', description: 'Max posts to analyze (1-200, default 50)' }, post_type: { type: 'string', enum: ['post', 'page'], description: 'post or page (default post)' }, category_id: { type: 'number', description: 'Optional: filter by category ID' }, min_words: { type: 'number', description: 'Minimum word count to include (default 100)' } }}},
|
|
605
|
+
{ name: 'wp_audit_update_frequency', description: 'Find posts not modified in X days, cross-referenced with SEO metadata quality to prioritize content refreshes. Sorted by priority (age x SEO gap). Read-only.',
|
|
606
|
+
inputSchema: { type: 'object', properties: { days_threshold: { type: 'number', description: 'Flag posts not modified in X days (default 180)' }, limit: { type: 'number', description: 'Max posts to analyze (1-200, default 50)' }, post_type: { type: 'string', enum: ['post', 'page'], description: 'post or page (default post)' }, include_seo_score: { type: 'boolean', description: 'Cross-reference with SEO metadata quality (default true)' } }}},
|
|
607
|
+
{ name: 'wp_build_link_map', description: 'Build a complete internal linking matrix with simplified PageRank scoring. Identifies orphan posts, inbound/outbound counts, and generates a sparse adjacency matrix. Read-only.',
|
|
608
|
+
inputSchema: { type: 'object', properties: { post_type: { type: 'string', enum: ['post', 'page', 'both'], description: 'post, page, or both (default post)' }, limit: { type: 'number', description: 'Max posts to analyze (1-200, default 50)' }, category_id: { type: 'number', description: 'Optional: filter by category ID' } }}},
|
|
609
|
+
|
|
610
|
+
// ── CONTENT INTELLIGENCE v4.4 Week 3 (3) ──
|
|
611
|
+
{ name: 'wp_audit_anchor_texts', description: 'Audit internal link anchor text diversity and relevance across N posts. Detects generic anchors, over-optimized anchors, image links without text. Sorted by diversity score ASC. Read-only.',
|
|
612
|
+
inputSchema: { type: 'object', properties: { limit: { type: 'number', description: 'Max posts to analyze (1-200, default 50)' }, post_type: { type: 'string', enum: ['post', 'page', 'both'], description: 'post, page, or both (default post)' } }}},
|
|
613
|
+
{ name: 'wp_audit_schema_markup', description: 'Detect and validate JSON-LD schema.org blocks in post HTML. Checks Article, FAQPage, HowTo, LocalBusiness, BreadcrumbList types for required fields. Read-only.',
|
|
614
|
+
inputSchema: { type: 'object', properties: { limit: { type: 'number', description: 'Max posts to analyze (1-200, default 50)' }, post_type: { type: 'string', enum: ['post', 'page', 'both'], description: 'post, page, or both (default post)' } }}},
|
|
615
|
+
{ name: 'wp_audit_content_structure', description: 'Analyze editorial structure: intro/conclusion/FAQ presence, heading density, lists, tables, images, paragraphs. Scores 0-100. Sorted by structure score ASC. Read-only.',
|
|
616
|
+
inputSchema: { type: 'object', properties: { limit: { type: 'number', description: 'Max posts to analyze (1-200, default 50)' }, post_type: { type: 'string', enum: ['post', 'page', 'both'], description: 'post, page, or both (default post)' }, category_id: { type: 'number', description: 'Optional: filter by category ID' } }}},
|
|
617
|
+
|
|
618
|
+
// ── CONTENT INTELLIGENCE v4.4 Batch 4A (4) ──
|
|
619
|
+
{ name: 'wp_find_duplicate_content', description: 'Detect near-duplicate content via TF-IDF cosine similarity. Returns duplicate pairs with severity and clusters. Read-only.',
|
|
620
|
+
inputSchema: { type: 'object', properties: { limit: { type: 'number', description: 'Max posts to analyze (1-100, default 50)' }, post_type: { type: 'string', enum: ['post', 'page'], description: 'post or page (default post)' }, category_id: { type: 'number', description: 'Optional: filter by category ID' }, similarity_threshold: { type: 'number', description: 'Minimum similarity to flag (0.0-1.0, default 0.7)' } }}},
|
|
621
|
+
{ name: 'wp_find_content_gaps', description: 'Identify taxonomy terms (categories/tags) with too few posts — content creation opportunities. Read-only.',
|
|
622
|
+
inputSchema: { type: 'object', properties: { min_posts: { type: 'number', description: 'Minimum posts per term to NOT be flagged (default 3)' }, taxonomy: { type: 'string', enum: ['category', 'post_tag', 'both'], description: 'Taxonomy to analyze (default both)' }, exclude_empty: { type: 'boolean', description: 'Exclude terms with 0 posts (default false)' } }}},
|
|
623
|
+
{ name: 'wp_extract_faq_blocks', description: 'Inventory all FAQ blocks: JSON-LD FAQPage, Gutenberg Yoast/RankMath blocks, HTML Q&A patterns. Read-only.',
|
|
624
|
+
inputSchema: { type: 'object', properties: { limit: { type: 'number', description: 'Max posts to scan (1-200, default 50)' }, post_type: { type: 'string', enum: ['post', 'page', 'both'], description: 'post, page, or both (default post)' } }}},
|
|
625
|
+
{ name: 'wp_audit_cta_presence', description: 'Detect CTA presence per post: contact links, forms, buttons, phone links, quote requests, signup links. Scores 0-100. Read-only.',
|
|
626
|
+
inputSchema: { type: 'object', properties: { limit: { type: 'number', description: 'Max posts to analyze (1-200, default 50)' }, post_type: { type: 'string', enum: ['post', 'page', 'both'], description: 'post, page, or both (default post)' }, category_id: { type: 'number', description: 'Optional: filter by category ID' } }}},
|
|
627
|
+
|
|
628
|
+
// ── CONTENT INTELLIGENCE v4.4 Batch 4B (4) ──
|
|
629
|
+
{ name: 'wp_extract_entities', description: 'Extract named entities (brands, locations, persons, organizations) from posts using regex heuristics. Read-only.',
|
|
630
|
+
inputSchema: { type: 'object', properties: { limit: { type: 'number', description: 'Max posts to analyze (1-100, default 20)' }, post_type: { type: 'string', enum: ['post', 'page'], description: 'post or page (default post)' }, min_occurrences: { type: 'number', description: 'Minimum total occurrences across corpus (default 2)' } }}},
|
|
631
|
+
{ name: 'wp_get_publishing_velocity', description: 'Analyze publishing cadence by author and category over configurable periods. Read-only.',
|
|
632
|
+
inputSchema: { type: 'object', properties: { periods: { type: 'string', description: "Comma-separated day periods (default '30,90,180')" }, post_type: { type: 'string', enum: ['post', 'page'], description: 'post or page (default post)' }, limit: { type: 'number', description: 'Max posts to fetch (1-500, default 200)' } }}},
|
|
633
|
+
{ name: 'wp_compare_revisions_diff', description: 'Diff between two post revisions: lines/words added/removed, headings diff, amplitude score. Read-only.',
|
|
634
|
+
inputSchema: { type: 'object', properties: { post_id: { type: 'number', description: 'Post or page ID' }, 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'], description: 'post or page (default post)' } }, required: ['post_id', 'revision_id_from'] }},
|
|
635
|
+
{ name: 'wp_list_posts_by_word_count', description: 'List posts sorted by word count with automatic length segmentation and distribution stats. Read-only.',
|
|
636
|
+
inputSchema: { type: 'object', properties: { limit: { type: 'number', description: 'Max posts to analyze (1-500, default 100)' }, post_type: { type: 'string', enum: ['post', 'page', 'both'], description: 'post, page, or both (default post)' }, order: { type: 'string', enum: ['asc', 'desc'], description: 'Sort order by word count (default desc)' }, category_id: { type: 'number', description: 'Optional: filter by category ID' } }}},
|
|
637
|
+
|
|
638
|
+
// ── PLUGIN INTELLIGENCE v4.5 (3) ──
|
|
639
|
+
{ name: 'wp_get_rendered_head', description: 'Retrieve the real rendered <head> HTML as seen by Google via RankMath or Yoast headless endpoint. Compares rendered meta with stored meta. Read-only.',
|
|
640
|
+
inputSchema: { type: 'object', properties: { post_id: { type: 'number', description: 'Post or page ID' }, post_type: { type: 'string', enum: ['post', 'page'], description: 'post or page (default post)' } }, required: ['post_id'] }},
|
|
641
|
+
{ name: 'wp_audit_rendered_seo', description: 'Bulk audit: compare rendered <head> vs stored SEO meta for divergences (title mismatch, missing description, canonical issues, missing schema). Requires RankMath or Yoast. Read-only.',
|
|
642
|
+
inputSchema: { type: 'object', properties: { limit: { type: 'number', description: 'Max posts to audit (1-50, default 10)' }, post_type: { type: 'string', enum: ['post', 'page'], description: 'post or page (default post)' } }}},
|
|
643
|
+
{ name: 'wp_get_pillar_content', description: 'Read or set the RankMath pillar/cornerstone content flag. List all pillar posts, read a single post flag, or mark/unmark a post as pillar. Write mode blocked by WP_READ_ONLY.',
|
|
644
|
+
inputSchema: { type: 'object', properties: { post_id: { type: 'number', description: 'Post or page ID (read or write mode)' }, 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'], description: 'post or page (default post)' }, limit: { type: 'number', description: 'Max posts to scan for list_pillars mode (default 100)' } }}},
|
|
645
|
+
|
|
646
|
+
// ── PLUGIN INTELLIGENCE v4.5 batch 2 (3) ──
|
|
647
|
+
{ name: 'wp_audit_schema_plugins', description: 'Audit schema JSON-LD from SEO plugin native fields (rank_math_schema or Yoast head). Validates presence and required fields per @type. Read-only.',
|
|
648
|
+
inputSchema: { type: 'object', properties: { limit: { type: 'number', description: 'Max posts to audit (1-100, default 20)' }, post_type: { type: 'string', enum: ['post', 'page', 'both'], description: 'post, page, or both (default post)' } }}},
|
|
649
|
+
{ name: 'wp_get_seo_score', description: 'Read RankMath native SEO score (0-100) for a single post or bulk-list posts sorted by score with distribution stats. Read-only.',
|
|
650
|
+
inputSchema: { type: 'object', properties: { post_id: { type: 'number', description: 'Single post ID to get score for' }, limit: { type: 'number', description: 'Bulk mode: max posts (1-100, default 20)' }, post_type: { type: 'string', enum: ['post', 'page'], description: 'post or page (default post)' }, order: { type: 'string', enum: ['asc', 'desc'], description: 'Sort by score (default desc)' } }}},
|
|
651
|
+
{ name: 'wp_get_twitter_meta', description: 'Read or write Twitter Card meta (title, description, image) from RankMath, Yoast, or SEOPress. Write mode blocked by WP_READ_ONLY.',
|
|
652
|
+
inputSchema: { type: 'object', properties: { post_id: { type: 'number', description: 'Post or page ID' }, post_type: { type: 'string', enum: ['post', 'page'], description: 'post or page (default post)' }, 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'] }}
|
|
463
653
|
];
|
|
464
654
|
|
|
465
655
|
function registerHandlers(s) {
|
|
@@ -498,21 +688,34 @@ export async function handleToolCall(request) {
|
|
|
498
688
|
// ── POSTS ──
|
|
499
689
|
|
|
500
690
|
case 'wp_list_posts': {
|
|
501
|
-
validateInput(args, { per_page: { type: 'number', min: 1, max: 100 }, page: { type: 'number', min: 1 }, status: { type: 'string', enum: STATUSES }, orderby: { type: 'string', enum: ORDERBY }, order: { type: 'string', enum: ORDERS } });
|
|
502
|
-
const { per_page = 10, page = 1, status = 'publish', orderby = 'date', order = 'desc', categories, tags, search, author } = args;
|
|
691
|
+
validateInput(args, { per_page: { type: 'number', min: 1, max: 100 }, page: { type: 'number', min: 1 }, status: { type: 'string', enum: STATUSES }, orderby: { type: 'string', enum: ORDERBY }, order: { type: 'string', enum: ORDERS }, mode: { type: 'string', enum: ['full', 'summary', 'ids_only'] } });
|
|
692
|
+
const { per_page = 10, page = 1, status = 'publish', orderby = 'date', order = 'desc', categories, tags, search, author, mode = 'full' } = args;
|
|
503
693
|
let ep = `/posts?per_page=${per_page}&page=${page}&status=${status}&orderby=${orderby}&order=${order}`;
|
|
504
694
|
if (categories) ep += `&categories=${categories}`; if (tags) ep += `&tags=${tags}`;
|
|
505
695
|
if (search) ep += `&search=${encodeURIComponent(search)}`; if (author) ep += `&author=${author}`;
|
|
506
696
|
const posts = await wpApiCall(ep);
|
|
507
|
-
|
|
697
|
+
let listResult;
|
|
698
|
+
if (mode === 'ids_only') {
|
|
699
|
+
listResult = { total: posts.length, page, mode: 'ids_only', ids: posts.map(p => p.id) };
|
|
700
|
+
} else if (mode === 'summary') {
|
|
701
|
+
listResult = { total: posts.length, page, mode: 'summary', posts: posts.map(p => ({ id: p.id, title: p.title.rendered, slug: p.slug, date: p.date, status: p.status, link: p.link })) };
|
|
702
|
+
} else {
|
|
703
|
+
listResult = { total: posts.length, page, posts: posts.map(p => ({ id: p.id, title: p.title.rendered, status: p.status, date: p.date, modified: p.modified, link: p.link, author: p.author, categories: p.categories, tags: p.tags, excerpt: strip(p.excerpt.rendered).substring(0, 200) })) };
|
|
704
|
+
}
|
|
705
|
+
result = json(listResult);
|
|
508
706
|
auditLog({ tool: name, action: 'list', status: 'success', latency_ms: Date.now() - t0, params: sanitizeParams(args) });
|
|
509
707
|
break;
|
|
510
708
|
}
|
|
511
709
|
|
|
512
710
|
case 'wp_get_post': {
|
|
513
|
-
validateInput(args, { id: { type: 'number', required: true, min: 1 } });
|
|
711
|
+
validateInput(args, { id: { type: 'number', required: true, min: 1 }, fields: { type: 'array' }, content_format: { type: 'string', enum: ['html', 'text', 'links_only'] } });
|
|
712
|
+
const { content_format = 'html', fields: requestedFields } = args;
|
|
514
713
|
const p = await wpApiCall(`/posts/${args.id}`);
|
|
515
|
-
|
|
714
|
+
let postData = { id: p.id, title: p.title.rendered, content: p.content.rendered, excerpt: p.excerpt.rendered, status: p.status, date: p.date, modified: p.modified, link: p.link, slug: p.slug, categories: p.categories, tags: p.tags, author: p.author, featured_media: p.featured_media, comment_status: p.comment_status, meta: p.meta || {} };
|
|
715
|
+
const { url: siteUrl } = getActiveAuth();
|
|
716
|
+
postData = applyContentFormat(postData, content_format, siteUrl);
|
|
717
|
+
if (requestedFields && requestedFields.length > 0) postData = summarizePost(postData, requestedFields);
|
|
718
|
+
result = json(postData);
|
|
516
719
|
auditLog({ tool: name, target: args.id, target_type: 'post', action: 'read', status: 'success', latency_ms: Date.now() - t0 });
|
|
517
720
|
break;
|
|
518
721
|
}
|
|
@@ -520,6 +723,10 @@ export async function handleToolCall(request) {
|
|
|
520
723
|
case 'wp_create_post': {
|
|
521
724
|
validateInput(args, { title: { type: 'string', required: true }, content: { type: 'string', required: true }, status: { type: 'string', enum: STATUSES.filter(s => s !== 'trash') } });
|
|
522
725
|
enforceDraftOnly(args.status); enforceAllowedStatuses(args.status); enforceAllowedTypes('post');
|
|
726
|
+
if (getActiveControls().require_approval && args.status === 'publish') {
|
|
727
|
+
auditLog({ tool: name, target: null, target_type: 'post', action: 'create', status: 'blocked', latency_ms: Date.now() - t0, params: sanitizeParams(args), error: 'APPROVAL REQUIRED: Use wp_submit_for_review then wp_approve_post' });
|
|
728
|
+
return { content: [{ type: 'text', text: 'Error: APPROVAL REQUIRED: Use wp_submit_for_review then wp_approve_post' }], isError: true };
|
|
729
|
+
}
|
|
523
730
|
const { title, content, status = 'draft', excerpt, categories, tags, slug, featured_media, meta, author } = args;
|
|
524
731
|
const data = { title, content, status };
|
|
525
732
|
if (excerpt) data.excerpt = excerpt; if (categories) data.categories = categories; if (tags) data.tags = tags;
|
|
@@ -533,6 +740,10 @@ export async function handleToolCall(request) {
|
|
|
533
740
|
case 'wp_update_post': {
|
|
534
741
|
validateInput(args, { id: { type: 'number', required: true, min: 1 }, status: { type: 'string', enum: STATUSES } });
|
|
535
742
|
if (args.status) { enforceDraftOnly(args.status); enforceAllowedStatuses(args.status); }
|
|
743
|
+
if (getActiveControls().require_approval && args.status === 'publish') {
|
|
744
|
+
auditLog({ tool: name, target: args.id, target_type: 'post', action: 'update', status: 'blocked', latency_ms: Date.now() - t0, params: sanitizeParams(args), error: 'APPROVAL REQUIRED: Use wp_submit_for_review then wp_approve_post' });
|
|
745
|
+
return { content: [{ type: 'text', text: 'Error: APPROVAL REQUIRED: Use wp_submit_for_review then wp_approve_post' }], isError: true };
|
|
746
|
+
}
|
|
536
747
|
const { id, ...upd } = args;
|
|
537
748
|
const up = await wpApiCall(`/posts/${id}`, { method: 'POST', body: JSON.stringify(upd) });
|
|
538
749
|
result = json({ success: true, message: `Post ${id} updated`, post: { id: up.id, title: up.title.rendered, status: up.status, link: up.link, modified: up.modified } });
|
|
@@ -542,10 +753,31 @@ export async function handleToolCall(request) {
|
|
|
542
753
|
|
|
543
754
|
case 'wp_delete_post': {
|
|
544
755
|
validateInput(args, { id: { type: 'number', required: true, min: 1 } });
|
|
545
|
-
const { id, force = false } = args;
|
|
756
|
+
const { id, force = false, confirmation_token } = args;
|
|
757
|
+
const deleteAction = force ? 'permanent_delete' : 'trash';
|
|
758
|
+
|
|
759
|
+
// Two-step confirmation when WP_CONFIRM_DESTRUCTIVE=true
|
|
760
|
+
if (getActiveControls().confirm_destructive) {
|
|
761
|
+
if (!confirmation_token) {
|
|
762
|
+
// Step 1: return confirmation_required with token
|
|
763
|
+
const p = await wpApiCall(`/posts/${id}`);
|
|
764
|
+
const token = generateToken(id, deleteAction);
|
|
765
|
+
const verb = force ? 'permanently deleted' : 'trashed';
|
|
766
|
+
result = json({ status: 'confirmation_required', post_id: id, post_title: p.title?.rendered || `Post #${id}`, action: deleteAction, confirmation_token: token, expires_in: 60, message: `Post #${id} '${p.title?.rendered || ''}' will be ${verb}. Call again with confirmation_token to confirm.` });
|
|
767
|
+
auditLog({ tool: name, target: id, target_type: 'post', action: 'delete_requested', status: 'pending', latency_ms: Date.now() - t0, params: { id, force } });
|
|
768
|
+
break;
|
|
769
|
+
}
|
|
770
|
+
// Step 2: validate token then execute
|
|
771
|
+
const validation = validateToken(confirmation_token, id, deleteAction);
|
|
772
|
+
if (!validation.valid) {
|
|
773
|
+
auditLog({ tool: name, target: id, target_type: 'post', action: deleteAction, status: 'error', latency_ms: Date.now() - t0, params: { id, force }, error: 'Invalid or expired confirmation token' });
|
|
774
|
+
return { content: [{ type: 'text', text: 'Error: Invalid or expired confirmation token' }], isError: true };
|
|
775
|
+
}
|
|
776
|
+
}
|
|
777
|
+
|
|
546
778
|
const dp = await wpApiCall(`/posts/${id}${force ? '?force=true' : ''}`, { method: 'DELETE' });
|
|
547
779
|
result = json({ success: true, message: force ? `Post ${id} permanently deleted` : `Post ${id} trashed`, post: { id: dp.id, title: dp.title?.rendered || dp.previous?.title?.rendered, status: force ? 'deleted' : 'trash' } });
|
|
548
|
-
auditLog({ tool: name, target: id, target_type: 'post', action:
|
|
780
|
+
auditLog({ tool: name, target: id, target_type: 'post', action: deleteAction, status: 'success', latency_ms: Date.now() - t0 });
|
|
549
781
|
break;
|
|
550
782
|
}
|
|
551
783
|
|
|
@@ -751,8 +983,9 @@ export async function handleToolCall(request) {
|
|
|
751
983
|
const prev = currentTarget?.name || 'default';
|
|
752
984
|
currentTarget = { name: site, ...targets[site] };
|
|
753
985
|
log.info(`Target switched: ${prev} → ${site} (${currentTarget.url})`);
|
|
754
|
-
|
|
755
|
-
|
|
986
|
+
const effectiveControls = getActiveControls();
|
|
987
|
+
result = json({ success: true, message: `Active site: ${site}`, previous: prev, current: { name: site, url: currentTarget.url }, effective_controls: effectiveControls });
|
|
988
|
+
auditLog({ tool: name, action: 'switch_target', status: 'success', latency_ms: Date.now() - t0, params: { from: prev, to: site }, effective_controls: effectiveControls });
|
|
756
989
|
break;
|
|
757
990
|
}
|
|
758
991
|
|
|
@@ -774,12 +1007,17 @@ export async function handleToolCall(request) {
|
|
|
774
1007
|
site: { name: si.name, description: si.description, url: si.url || baseUrl, gmt_offset: si.gmt_offset, timezone_string: si.timezone_string },
|
|
775
1008
|
current_user: u ? { id: u.id, name: u.name, slug: u.slug, roles: u.roles } : null,
|
|
776
1009
|
post_types: postTypes,
|
|
777
|
-
enterprise_controls: {
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
1010
|
+
enterprise_controls: (() => {
|
|
1011
|
+
const c = getActiveControls();
|
|
1012
|
+
const s = getControlSources();
|
|
1013
|
+
return {
|
|
1014
|
+
read_only: c.read_only, draft_only: c.draft_only, delete_disabled: c.disable_delete, plugin_management_disabled: c.disable_plugin_management, require_approval: c.require_approval, confirm_destructive: c.confirm_destructive,
|
|
1015
|
+
rate_limit: c.max_calls_per_minute > 0 ? `${c.max_calls_per_minute}/min` : 'unlimited',
|
|
1016
|
+
allowed_types: ALLOWED_TYPES || 'all', allowed_statuses: ALLOWED_STATUSES || 'all',
|
|
1017
|
+
audit_log: AUDIT_LOG,
|
|
1018
|
+
...s
|
|
1019
|
+
};
|
|
1020
|
+
})(),
|
|
783
1021
|
multi_target: {
|
|
784
1022
|
enabled: isMultiTarget, active_site: currentTarget?.name || 'default',
|
|
785
1023
|
available_sites: Object.keys(targets)
|
|
@@ -1282,8 +1520,24 @@ export async function handleToolCall(request) {
|
|
|
1282
1520
|
revision_id: { type: 'number', required: true, min: 1 },
|
|
1283
1521
|
post_type: { type: 'string', enum: ['post', 'page'] }
|
|
1284
1522
|
});
|
|
1285
|
-
const { post_id, revision_id, post_type = 'post' } = args;
|
|
1523
|
+
const { post_id, revision_id, post_type = 'post', confirmation_token } = args;
|
|
1286
1524
|
const base = post_type === 'page' ? 'pages' : 'posts';
|
|
1525
|
+
|
|
1526
|
+
// Two-step confirmation when WP_CONFIRM_DESTRUCTIVE=true
|
|
1527
|
+
if (getActiveControls().confirm_destructive) {
|
|
1528
|
+
if (!confirmation_token) {
|
|
1529
|
+
const token = generateToken(revision_id, 'delete_revision');
|
|
1530
|
+
result = json({ status: 'confirmation_required', revision_id, post_id, action: 'delete_revision', confirmation_token: token, expires_in: 60, message: `Revision #${revision_id} of post #${post_id} will be permanently deleted. Call again with confirmation_token to confirm.` });
|
|
1531
|
+
auditLog({ tool: name, target: revision_id, target_type: 'revision', action: 'delete_requested', status: 'pending', latency_ms: Date.now() - t0, params: { post_id, revision_id } });
|
|
1532
|
+
break;
|
|
1533
|
+
}
|
|
1534
|
+
const validation = validateToken(confirmation_token, revision_id, 'delete_revision');
|
|
1535
|
+
if (!validation.valid) {
|
|
1536
|
+
auditLog({ tool: name, target: revision_id, target_type: 'revision', action: 'delete_revision', status: 'error', latency_ms: Date.now() - t0, params: { post_id, revision_id }, error: 'Invalid or expired confirmation token' });
|
|
1537
|
+
return { content: [{ type: 'text', text: 'Error: Invalid or expired confirmation token' }], isError: true };
|
|
1538
|
+
}
|
|
1539
|
+
}
|
|
1540
|
+
|
|
1287
1541
|
try {
|
|
1288
1542
|
await wpApiCall(`/${base}/${post_id}/revisions/${revision_id}?force=true`, { method: 'DELETE' });
|
|
1289
1543
|
result = json({ deleted: true, revision_id, post_id, post_type });
|
|
@@ -1299,6 +1553,3340 @@ export async function handleToolCall(request) {
|
|
|
1299
1553
|
break;
|
|
1300
1554
|
}
|
|
1301
1555
|
|
|
1556
|
+
// ── APPROVAL WORKFLOW ──
|
|
1557
|
+
|
|
1558
|
+
case 'wp_submit_for_review': {
|
|
1559
|
+
validateInput(args, { id: { type: 'number', required: true, min: 1 } });
|
|
1560
|
+
const { id, note } = args;
|
|
1561
|
+
const p = await wpApiCall(`/posts/${id}`);
|
|
1562
|
+
if (p.status !== 'draft' && p.status !== 'auto-draft') {
|
|
1563
|
+
throw new Error(`Post ${id} is in "${p.status}" status. Only "draft" or "auto-draft" posts can be submitted for review.`);
|
|
1564
|
+
}
|
|
1565
|
+
const data = { status: 'pending' };
|
|
1566
|
+
if (note) data.meta = { _mcp_review_note: note };
|
|
1567
|
+
const up = await wpApiCall(`/posts/${id}`, { method: 'POST', body: JSON.stringify(data) });
|
|
1568
|
+
result = json({ success: true, message: `Post ${id} submitted for review`, post: { id: up.id, title: up.title.rendered, status: up.status, link: up.link } });
|
|
1569
|
+
auditLog({ tool: name, target: id, target_type: 'post', action: 'submit_for_review', status: 'success', latency_ms: Date.now() - t0, params: { id } });
|
|
1570
|
+
break;
|
|
1571
|
+
}
|
|
1572
|
+
|
|
1573
|
+
case 'wp_approve_post': {
|
|
1574
|
+
validateInput(args, { id: { type: 'number', required: true, min: 1 } });
|
|
1575
|
+
const { id } = args;
|
|
1576
|
+
if (getActiveControls().draft_only) {
|
|
1577
|
+
auditLog({ tool: name, target: id, target_type: 'post', action: 'approve', status: 'blocked', latency_ms: Date.now() - t0, params: { id }, error: 'Blocked: Server is in DRAFT-ONLY mode (WP_DRAFT_ONLY=true). Publishing is not allowed.' });
|
|
1578
|
+
return { content: [{ type: 'text', text: 'Error: Blocked: Server is in DRAFT-ONLY mode (WP_DRAFT_ONLY=true). Publishing is not allowed.' }], isError: true };
|
|
1579
|
+
}
|
|
1580
|
+
const p = await wpApiCall(`/posts/${id}`);
|
|
1581
|
+
if (p.status !== 'pending') {
|
|
1582
|
+
throw new Error(`Post ${id} is in "${p.status}" status. Only "pending" posts can be approved.`);
|
|
1583
|
+
}
|
|
1584
|
+
const up = await wpApiCall(`/posts/${id}`, { method: 'POST', body: JSON.stringify({ status: 'publish' }) });
|
|
1585
|
+
result = json({ success: true, message: `Post ${id} approved and published`, post: { id: up.id, title: up.title.rendered, status: up.status, link: up.link } });
|
|
1586
|
+
auditLog({ tool: name, target: id, target_type: 'post', action: 'approve', status: 'success', latency_ms: Date.now() - t0, params: { id } });
|
|
1587
|
+
break;
|
|
1588
|
+
}
|
|
1589
|
+
|
|
1590
|
+
case 'wp_reject_post': {
|
|
1591
|
+
validateInput(args, { id: { type: 'number', required: true, min: 1 }, reason: { type: 'string', required: true } });
|
|
1592
|
+
const { id, reason } = args;
|
|
1593
|
+
const p = await wpApiCall(`/posts/${id}`);
|
|
1594
|
+
if (p.status !== 'pending') {
|
|
1595
|
+
throw new Error(`Post ${id} is in "${p.status}" status. Only "pending" posts can be rejected.`);
|
|
1596
|
+
}
|
|
1597
|
+
const currentCount = parseInt(p.meta?._mcp_rejection_count || '0', 10);
|
|
1598
|
+
const up = await wpApiCall(`/posts/${id}`, { method: 'POST', body: JSON.stringify({ status: 'draft', meta: { _mcp_rejection_reason: reason, _mcp_rejection_count: currentCount + 1 } }) });
|
|
1599
|
+
result = json({ success: true, message: `Post ${id} rejected and moved to draft`, post: { id: up.id, title: up.title.rendered, status: up.status }, rejection: { reason, count: currentCount + 1 } });
|
|
1600
|
+
auditLog({ tool: name, target: id, target_type: 'post', action: 'reject', status: 'success', latency_ms: Date.now() - t0, params: { id } });
|
|
1601
|
+
break;
|
|
1602
|
+
}
|
|
1603
|
+
|
|
1604
|
+
// ── LINK ANALYSIS ──
|
|
1605
|
+
|
|
1606
|
+
case 'wp_analyze_links': {
|
|
1607
|
+
validateInput(args, { post_id: { type: 'number', required: true, min: 1 }, timeout_ms: { type: 'number', min: 100 } });
|
|
1608
|
+
const { post_id, check_broken = true, timeout_ms = 5000 } = args;
|
|
1609
|
+
const p = await wpApiCall(`/posts/${post_id}`);
|
|
1610
|
+
const content = p.content?.rendered || '';
|
|
1611
|
+
const postTitle = strip(p.title?.rendered || '');
|
|
1612
|
+
const { url: siteUrl } = getActiveAuth();
|
|
1613
|
+
const internal_links = extractInternalLinks(content, siteUrl);
|
|
1614
|
+
const external_links = extractExternalLinks(content, siteUrl);
|
|
1615
|
+
let broken_count = 0, warning_count = 0, unknown_count = 0;
|
|
1616
|
+
if (check_broken) {
|
|
1617
|
+
const toCheck = internal_links.slice(0, 20);
|
|
1618
|
+
for (const link of toCheck) {
|
|
1619
|
+
const fullUrl = link.url.startsWith('/') ? `${siteUrl}${link.url}` : link.url;
|
|
1620
|
+
const { status, http_code } = await checkLinkStatus(fullUrl, timeout_ms);
|
|
1621
|
+
link.status = status;
|
|
1622
|
+
link.http_code = http_code;
|
|
1623
|
+
if (status === 'broken') broken_count++;
|
|
1624
|
+
else if (status === 'warning') warning_count++;
|
|
1625
|
+
else if (status === 'unknown') unknown_count++;
|
|
1626
|
+
}
|
|
1627
|
+
for (let i = 20; i < internal_links.length; i++) {
|
|
1628
|
+
internal_links[i].status = 'unchecked';
|
|
1629
|
+
internal_links[i].http_code = null;
|
|
1630
|
+
}
|
|
1631
|
+
} else {
|
|
1632
|
+
for (const link of internal_links) { link.status = 'unchecked'; link.http_code = null; }
|
|
1633
|
+
}
|
|
1634
|
+
result = json({
|
|
1635
|
+
post_id, post_title: postTitle, internal_links, external_links,
|
|
1636
|
+
summary: { total_internal: internal_links.length, total_external: external_links.length, broken_count, warning_count, unknown_count }
|
|
1637
|
+
});
|
|
1638
|
+
auditLog({ tool: name, target: post_id, target_type: 'post', action: 'analyze_links', status: 'success', latency_ms: Date.now() - t0 });
|
|
1639
|
+
break;
|
|
1640
|
+
}
|
|
1641
|
+
|
|
1642
|
+
case 'wp_suggest_internal_links': {
|
|
1643
|
+
validateInput(args, { post_id: { type: 'number', required: true, min: 1 }, max_suggestions: { type: 'number', min: 1, max: 10 } });
|
|
1644
|
+
const { post_id, max_suggestions = 5, focus_keywords = [], exclude_already_linked = true } = args;
|
|
1645
|
+
const p = await wpApiCall(`/posts/${post_id}`);
|
|
1646
|
+
const content = p.content?.rendered || '';
|
|
1647
|
+
const postTitle = strip(p.title?.rendered || '');
|
|
1648
|
+
const postMeta = p.meta || {};
|
|
1649
|
+
const postCategories = p.categories || [];
|
|
1650
|
+
const { url: siteUrl } = getActiveAuth();
|
|
1651
|
+
|
|
1652
|
+
// Step 1 — Collect keywords
|
|
1653
|
+
const seoKeyword = extractFocusKeyword(postMeta);
|
|
1654
|
+
let keywords = [...(Array.isArray(focus_keywords) ? focus_keywords : [])];
|
|
1655
|
+
if (seoKeyword) keywords.unshift(seoKeyword);
|
|
1656
|
+
keywords = [...new Set(keywords.map(k => k.toLowerCase()))];
|
|
1657
|
+
if (keywords.length === 0) {
|
|
1658
|
+
keywords = postTitle.split(/\s+/).slice(0, 5).filter(w => w.length > 2).map(w => w.toLowerCase());
|
|
1659
|
+
}
|
|
1660
|
+
|
|
1661
|
+
// Already-linked URLs
|
|
1662
|
+
const linkedUrls = siteUrl ? extractInternalLinks(content, siteUrl).map(l => l.url) : [];
|
|
1663
|
+
|
|
1664
|
+
// Step 2 — Search for candidates (max 3 keyword searches)
|
|
1665
|
+
const candidateMap = new Map();
|
|
1666
|
+
for (const kw of keywords.slice(0, 3)) {
|
|
1667
|
+
try {
|
|
1668
|
+
const results = await wpApiCall(`/posts?search=${encodeURIComponent(kw)}&per_page=10&status=publish`);
|
|
1669
|
+
if (Array.isArray(results)) {
|
|
1670
|
+
for (const r of results) {
|
|
1671
|
+
if (r.id !== post_id && !candidateMap.has(r.id)) candidateMap.set(r.id, r);
|
|
1672
|
+
}
|
|
1673
|
+
}
|
|
1674
|
+
} catch { /* search failed */ }
|
|
1675
|
+
}
|
|
1676
|
+
|
|
1677
|
+
// Step 3 — Score each candidate
|
|
1678
|
+
const currentPostData = { id: post_id, categories: postCategories, linkedUrls: exclude_already_linked ? linkedUrls : [] };
|
|
1679
|
+
const scored = [];
|
|
1680
|
+
let excluded_count = 0;
|
|
1681
|
+
|
|
1682
|
+
for (const [, cand] of candidateMap) {
|
|
1683
|
+
const candTitle = typeof cand.title === 'string' ? cand.title : (cand.title?.rendered || '');
|
|
1684
|
+
const { total, breakdown } = calculateRelevanceScore(
|
|
1685
|
+
{ id: cand.id, title: candTitle, date: cand.date, categories: cand.categories || [], meta: cand.meta || {}, link: cand.link || '' },
|
|
1686
|
+
currentPostData, keywords
|
|
1687
|
+
);
|
|
1688
|
+
if (total === -999) { excluded_count++; continue; }
|
|
1689
|
+
scored.push({
|
|
1690
|
+
target_post_id: cand.id, target_title: strip(candTitle), target_url: cand.link || '',
|
|
1691
|
+
anchor_text: suggestAnchorText({ meta: cand.meta || {}, title: candTitle }),
|
|
1692
|
+
relevance_score: total, score_breakdown: breakdown,
|
|
1693
|
+
already_linked: linkedUrls.some(u => u === cand.link)
|
|
1694
|
+
});
|
|
1695
|
+
}
|
|
1696
|
+
|
|
1697
|
+
scored.sort((a, b) => b.relevance_score - a.relevance_score);
|
|
1698
|
+
const suggestions = scored.slice(0, Math.min(max_suggestions, 10));
|
|
1699
|
+
result = json({ post_id, post_title: postTitle, keywords_used: keywords, suggestions, excluded_already_linked: excluded_count });
|
|
1700
|
+
auditLog({ tool: name, target: post_id, target_type: 'post', action: 'suggest_links', status: 'success', latency_ms: Date.now() - t0 });
|
|
1701
|
+
break;
|
|
1702
|
+
}
|
|
1703
|
+
|
|
1704
|
+
// ── WOOCOMMERCE ──
|
|
1705
|
+
|
|
1706
|
+
case 'wc_list_products': {
|
|
1707
|
+
validateInput(args, { per_page: { type: 'number', min: 1, max: 100 }, page: { type: 'number', min: 1 } });
|
|
1708
|
+
const { per_page = 10, page = 1, status = 'any', search, category, orderby = 'date', order = 'desc' } = args;
|
|
1709
|
+
const { url: baseUrl } = getActiveAuth();
|
|
1710
|
+
let ep = `/products?per_page=${per_page}&page=${page}&orderby=${orderby}&order=${order}`;
|
|
1711
|
+
if (status && status !== 'any') ep += `&status=${status}`;
|
|
1712
|
+
if (search) ep += `&search=${encodeURIComponent(search)}`;
|
|
1713
|
+
if (category) ep += `&category=${category}`;
|
|
1714
|
+
const products = await wcApiCall(ep, {}, baseUrl);
|
|
1715
|
+
result = json({ total: products.length, page, products: products.map(p => ({ id: p.id, name: p.name, slug: p.slug, status: p.status, price: p.price, regular_price: p.regular_price, sale_price: p.sale_price, stock_status: p.stock_status, stock_quantity: p.stock_quantity, categories: (p.categories || []).map(c => ({ id: c.id, name: c.name })), image: p.images?.[0]?.src || null, permalink: p.permalink })) });
|
|
1716
|
+
auditLog({ tool: name, action: 'list', target_type: 'product', status: 'success', latency_ms: Date.now() - t0, params: sanitizeParams(args) });
|
|
1717
|
+
break;
|
|
1718
|
+
}
|
|
1719
|
+
|
|
1720
|
+
case 'wc_get_product': {
|
|
1721
|
+
validateInput(args, { id: { type: 'number', required: true, min: 1 } });
|
|
1722
|
+
const { url: baseUrl } = getActiveAuth();
|
|
1723
|
+
const p = await wcApiCall(`/products/${args.id}`, {}, baseUrl);
|
|
1724
|
+
const productData = { ...p };
|
|
1725
|
+
if (p.type === 'variable' && p.variations && p.variations.length > 0) {
|
|
1726
|
+
try {
|
|
1727
|
+
const vars = await wcApiCall(`/products/${args.id}/variations?per_page=100`, {}, baseUrl);
|
|
1728
|
+
productData.variations_detail = vars.map(v => ({ id: v.id, sku: v.sku, price: v.price, regular_price: v.regular_price, sale_price: v.sale_price, stock_status: v.stock_status, stock_quantity: v.stock_quantity, attributes: v.attributes }));
|
|
1729
|
+
} catch { productData.variations_detail = []; }
|
|
1730
|
+
}
|
|
1731
|
+
result = json(productData);
|
|
1732
|
+
auditLog({ tool: name, target: args.id, target_type: 'product', action: 'read', status: 'success', latency_ms: Date.now() - t0 });
|
|
1733
|
+
break;
|
|
1734
|
+
}
|
|
1735
|
+
|
|
1736
|
+
case 'wc_list_orders': {
|
|
1737
|
+
validateInput(args, { per_page: { type: 'number', min: 1, max: 100 }, page: { type: 'number', min: 1 } });
|
|
1738
|
+
const { per_page = 10, page = 1, status = 'any', customer, orderby = 'date', order = 'desc' } = args;
|
|
1739
|
+
const { url: baseUrl } = getActiveAuth();
|
|
1740
|
+
let ep = `/orders?per_page=${per_page}&page=${page}&orderby=${orderby}&order=${order}`;
|
|
1741
|
+
if (status && status !== 'any') ep += `&status=${status}`;
|
|
1742
|
+
if (customer) ep += `&customer=${customer}`;
|
|
1743
|
+
const orders = await wcApiCall(ep, {}, baseUrl);
|
|
1744
|
+
result = json({ total: orders.length, page, orders: orders.map(o => ({ id: o.id, number: o.number, status: o.status, date_created: o.date_created, customer_id: o.customer_id, billing_name: `${o.billing?.first_name || ''} ${o.billing?.last_name || ''}`.trim(), billing_email: o.billing?.email || '', total: o.total, currency: o.currency, line_items: (o.line_items || []).map(li => ({ name: li.name, quantity: li.quantity, total: li.total })) })) });
|
|
1745
|
+
auditLog({ tool: name, action: 'list', target_type: 'order', status: 'success', latency_ms: Date.now() - t0, params: sanitizeParams(args) });
|
|
1746
|
+
break;
|
|
1747
|
+
}
|
|
1748
|
+
|
|
1749
|
+
case 'wc_get_order': {
|
|
1750
|
+
validateInput(args, { id: { type: 'number', required: true, min: 1 } });
|
|
1751
|
+
const { url: baseUrl } = getActiveAuth();
|
|
1752
|
+
const o = await wcApiCall(`/orders/${args.id}`, {}, baseUrl);
|
|
1753
|
+
result = json(o);
|
|
1754
|
+
auditLog({ tool: name, target: args.id, target_type: 'order', action: 'read', status: 'success', latency_ms: Date.now() - t0 });
|
|
1755
|
+
break;
|
|
1756
|
+
}
|
|
1757
|
+
|
|
1758
|
+
case 'wc_list_customers': {
|
|
1759
|
+
validateInput(args, { per_page: { type: 'number', min: 1, max: 100 }, page: { type: 'number', min: 1 } });
|
|
1760
|
+
const { per_page = 10, page = 1, search, orderby = 'date_created', order = 'desc', role = 'customer' } = args;
|
|
1761
|
+
const { url: baseUrl } = getActiveAuth();
|
|
1762
|
+
let ep = `/customers?per_page=${per_page}&page=${page}&orderby=${orderby}&order=${order}&role=${role}`;
|
|
1763
|
+
if (search) ep += `&search=${encodeURIComponent(search)}`;
|
|
1764
|
+
const customers = await wcApiCall(ep, {}, baseUrl);
|
|
1765
|
+
result = json({ total: customers.length, page, customers: customers.map(c => ({ id: c.id, first_name: c.first_name, last_name: c.last_name, email: c.email, date_created: c.date_created, orders_count: c.orders_count, total_spent: c.total_spent, username: c.username })) });
|
|
1766
|
+
auditLog({ tool: name, action: 'list', target_type: 'customer', status: 'success', latency_ms: Date.now() - t0, params: sanitizeParams(args) });
|
|
1767
|
+
break;
|
|
1768
|
+
}
|
|
1769
|
+
|
|
1770
|
+
case 'wc_price_guardrail': {
|
|
1771
|
+
validateInput(args, { product_id: { type: 'number', required: true, min: 1 }, new_price: { type: 'number', required: true }, threshold_percent: { type: 'number', min: 0 } });
|
|
1772
|
+
const { product_id, new_price, threshold_percent = 20 } = args;
|
|
1773
|
+
const { url: baseUrl } = getActiveAuth();
|
|
1774
|
+
const p = await wcApiCall(`/products/${product_id}`, {}, baseUrl);
|
|
1775
|
+
const current_price = parseFloat(p.price);
|
|
1776
|
+
if (isNaN(current_price) || current_price <= 0) {
|
|
1777
|
+
result = json({ safe: false, product_id, product_name: p.name, current_price: p.price, new_price, change_percent: null, message: 'Cannot evaluate: current price is zero or invalid' });
|
|
1778
|
+
auditLog({ tool: name, target: product_id, target_type: 'product', action: 'price_check', status: 'success', latency_ms: Date.now() - t0, params: { new_price, threshold_percent } });
|
|
1779
|
+
break;
|
|
1780
|
+
}
|
|
1781
|
+
const change_percent = Math.round(Math.abs(new_price - current_price) / current_price * 10000) / 100;
|
|
1782
|
+
const safe = change_percent <= threshold_percent;
|
|
1783
|
+
result = json({
|
|
1784
|
+
safe, product_id, product_name: p.name, current_price, new_price, change_percent,
|
|
1785
|
+
...(safe
|
|
1786
|
+
? { message: 'Price change within threshold' }
|
|
1787
|
+
: { requires_confirmation: true, message: `Price change of ${change_percent}% exceeds threshold of ${threshold_percent}%. Use wp_update_product with confirm=true to proceed.` })
|
|
1788
|
+
});
|
|
1789
|
+
auditLog({ tool: name, target: product_id, target_type: 'product', action: 'price_check', status: 'success', latency_ms: Date.now() - t0, params: { current_price, new_price, change_percent, threshold_percent, safe } });
|
|
1790
|
+
break;
|
|
1791
|
+
}
|
|
1792
|
+
|
|
1793
|
+
// ── WOOCOMMERCE INTELLIGENCE ──
|
|
1794
|
+
|
|
1795
|
+
case 'wc_inventory_alert': {
|
|
1796
|
+
validateInput(args, { threshold: { type: 'number', min: 0 }, per_page: { type: 'number', min: 1, max: 100 } });
|
|
1797
|
+
const { threshold = 5, per_page = 50, include_out_of_stock = true } = args;
|
|
1798
|
+
const { url: baseUrl } = getActiveAuth();
|
|
1799
|
+
const fetchSize = Math.min(per_page * 2, 100);
|
|
1800
|
+
let allProducts = [];
|
|
1801
|
+
const instock = await wcApiCall(`/products?stock_status=instock&per_page=${fetchSize}`, {}, baseUrl);
|
|
1802
|
+
allProducts.push(...instock);
|
|
1803
|
+
if (include_out_of_stock) {
|
|
1804
|
+
const oos = await wcApiCall(`/products?stock_status=outofstock&per_page=${fetchSize}`, {}, baseUrl);
|
|
1805
|
+
allProducts.push(...oos);
|
|
1806
|
+
}
|
|
1807
|
+
const alerts = allProducts.filter(p => {
|
|
1808
|
+
if (p.stock_status === 'outofstock') return true;
|
|
1809
|
+
if (p.stock_quantity !== null && p.stock_quantity !== undefined && p.stock_quantity <= threshold) return true;
|
|
1810
|
+
return false;
|
|
1811
|
+
});
|
|
1812
|
+
alerts.sort((a, b) => (a.stock_quantity ?? -1) - (b.stock_quantity ?? -1));
|
|
1813
|
+
const out_of_stock_count = alerts.filter(p => p.stock_status === 'outofstock').length;
|
|
1814
|
+
const low_stock_count = alerts.length - out_of_stock_count;
|
|
1815
|
+
result = json({
|
|
1816
|
+
products: alerts.map(p => ({ id: p.id, name: p.name, sku: p.sku || '', stock_quantity: p.stock_quantity, stock_status: p.stock_status, price: p.price, permalink: p.permalink })),
|
|
1817
|
+
summary: { total_alerts: alerts.length, out_of_stock_count, low_stock_count, threshold_used: threshold }
|
|
1818
|
+
});
|
|
1819
|
+
auditLog({ tool: name, action: 'inventory_alert', target_type: 'product', status: 'success', latency_ms: Date.now() - t0, params: { threshold, include_out_of_stock } });
|
|
1820
|
+
break;
|
|
1821
|
+
}
|
|
1822
|
+
|
|
1823
|
+
case 'wc_order_intelligence': {
|
|
1824
|
+
validateInput(args, { customer_id: { type: 'number', required: true, min: 1 } });
|
|
1825
|
+
const { customer_id } = args;
|
|
1826
|
+
const { url: baseUrl } = getActiveAuth();
|
|
1827
|
+
const orders = await wcApiCall(`/orders?customer=${customer_id}&per_page=100&orderby=date&order=desc`, {}, baseUrl);
|
|
1828
|
+
const total_orders = orders.length;
|
|
1829
|
+
if (total_orders === 0) {
|
|
1830
|
+
result = json({ customer_id, total_orders: 0, total_spent: 0, average_order_value: 0, first_order_date: null, last_order_date: null, order_frequency_days: null, favourite_products: [], statuses_breakdown: {}, recent_orders: [] });
|
|
1831
|
+
auditLog({ tool: name, target: customer_id, target_type: 'customer', action: 'order_intelligence', status: 'success', latency_ms: Date.now() - t0 });
|
|
1832
|
+
break;
|
|
1833
|
+
}
|
|
1834
|
+
const total_spent = Math.round(orders.reduce((sum, o) => sum + parseFloat(o.total || '0'), 0) * 100) / 100;
|
|
1835
|
+
const average_order_value = Math.round(total_spent / total_orders * 100) / 100;
|
|
1836
|
+
const last_order_date = orders[0].date_created;
|
|
1837
|
+
const first_order_date = orders[orders.length - 1].date_created;
|
|
1838
|
+
let order_frequency_days = null;
|
|
1839
|
+
if (total_orders > 1) {
|
|
1840
|
+
const firstMs = new Date(first_order_date).getTime();
|
|
1841
|
+
const lastMs = new Date(last_order_date).getTime();
|
|
1842
|
+
order_frequency_days = Math.round((lastMs - firstMs) / (1000 * 60 * 60 * 24) / total_orders);
|
|
1843
|
+
}
|
|
1844
|
+
const productFreq = {};
|
|
1845
|
+
for (const o of orders) {
|
|
1846
|
+
for (const li of (o.line_items || [])) {
|
|
1847
|
+
productFreq[li.name] = (productFreq[li.name] || 0) + li.quantity;
|
|
1848
|
+
}
|
|
1849
|
+
}
|
|
1850
|
+
const favourite_products = Object.entries(productFreq).sort((a, b) => b[1] - a[1]).slice(0, 3).map(([pname, quantity]) => ({ name: pname, quantity }));
|
|
1851
|
+
const statuses_breakdown = {};
|
|
1852
|
+
for (const o of orders) { statuses_breakdown[o.status] = (statuses_breakdown[o.status] || 0) + 1; }
|
|
1853
|
+
const recent_orders = orders.slice(0, 5).map(o => ({ id: o.id, date: o.date_created, total: o.total, status: o.status }));
|
|
1854
|
+
result = json({ customer_id, total_orders, total_spent, average_order_value, first_order_date, last_order_date, order_frequency_days, favourite_products, statuses_breakdown, recent_orders });
|
|
1855
|
+
auditLog({ tool: name, target: customer_id, target_type: 'customer', action: 'order_intelligence', status: 'success', latency_ms: Date.now() - t0 });
|
|
1856
|
+
break;
|
|
1857
|
+
}
|
|
1858
|
+
|
|
1859
|
+
case 'wc_seo_product_audit': {
|
|
1860
|
+
validateInput(args, { per_page: { type: 'number', min: 1, max: 100 }, page: { type: 'number', min: 1 } });
|
|
1861
|
+
const { per_page = 20, page = 1 } = args;
|
|
1862
|
+
const { url: baseUrl } = getActiveAuth();
|
|
1863
|
+
const products = await wcApiCall(`/products?per_page=${per_page}&page=${page}&status=publish`, {}, baseUrl);
|
|
1864
|
+
let totalScore = 0;
|
|
1865
|
+
const audited = products.map(p => {
|
|
1866
|
+
const issues = [];
|
|
1867
|
+
const descText = strip(p.description || '');
|
|
1868
|
+
if (descText.length === 0) issues.push('missing_description');
|
|
1869
|
+
else if (descText.length < 50) issues.push('description_too_short');
|
|
1870
|
+
if (!p.short_description || strip(p.short_description).length === 0) issues.push('missing_short_description');
|
|
1871
|
+
if (p.slug && p.slug.includes('product-')) issues.push('generic_slug');
|
|
1872
|
+
if (!p.images || p.images.length === 0 || !p.images[0]?.src) issues.push('missing_image');
|
|
1873
|
+
else if (!p.images[0]?.alt || p.images[0].alt.trim() === '') issues.push('missing_image_alt');
|
|
1874
|
+
if (!p.price || p.price === '') issues.push('missing_price');
|
|
1875
|
+
const score = Math.max(0, 100 - issues.length * 15);
|
|
1876
|
+
totalScore += score;
|
|
1877
|
+
return { id: p.id, name: p.name, score, issues, permalink: p.permalink };
|
|
1878
|
+
});
|
|
1879
|
+
const average_score = audited.length > 0 ? Math.round(totalScore / audited.length) : 0;
|
|
1880
|
+
result = json({
|
|
1881
|
+
average_score, total_audited: audited.length, products: audited,
|
|
1882
|
+
summary: { perfect_score_count: audited.filter(p => p.score === 100).length, needs_attention_count: audited.filter(p => p.score < 70).length }
|
|
1883
|
+
});
|
|
1884
|
+
auditLog({ tool: name, action: 'seo_product_audit', target_type: 'product', status: 'success', latency_ms: Date.now() - t0, params: { per_page, page, avg_score: average_score } });
|
|
1885
|
+
break;
|
|
1886
|
+
}
|
|
1887
|
+
|
|
1888
|
+
case 'wc_suggest_product_links': {
|
|
1889
|
+
validateInput(args, { post_id: { type: 'number', required: true, min: 1 }, max_suggestions: { type: 'number', min: 1, max: 5 } });
|
|
1890
|
+
const { post_id, max_suggestions = 3 } = args;
|
|
1891
|
+
const { url: baseUrl } = getActiveAuth();
|
|
1892
|
+
const post = await wpApiCall(`/posts/${post_id}`);
|
|
1893
|
+
const postTitle = strip(post.title?.rendered || '');
|
|
1894
|
+
const postMeta = post.meta || {};
|
|
1895
|
+
const focusKw = extractFocusKeyword(postMeta);
|
|
1896
|
+
const keywords = [];
|
|
1897
|
+
if (focusKw) keywords.push(focusKw);
|
|
1898
|
+
const titleWords = postTitle.split(/\s+/).filter(w => w.length > 3).slice(0, 3);
|
|
1899
|
+
for (const w of titleWords) {
|
|
1900
|
+
if (!keywords.some(k => k.toLowerCase() === w.toLowerCase())) keywords.push(w);
|
|
1901
|
+
}
|
|
1902
|
+
const candidateMap = new Map();
|
|
1903
|
+
for (const kw of keywords.slice(0, 2)) {
|
|
1904
|
+
try {
|
|
1905
|
+
const prods = await wcApiCall(`/products?search=${encodeURIComponent(kw)}&per_page=10&status=publish`, {}, baseUrl);
|
|
1906
|
+
if (Array.isArray(prods)) {
|
|
1907
|
+
for (const p of prods) { if (!candidateMap.has(p.id)) candidateMap.set(p.id, p); }
|
|
1908
|
+
}
|
|
1909
|
+
} catch { /* search failed */ }
|
|
1910
|
+
}
|
|
1911
|
+
const scored = [];
|
|
1912
|
+
for (const [, p] of candidateMap) {
|
|
1913
|
+
let relevance_score = 0;
|
|
1914
|
+
const nameLower = (p.name || '').toLowerCase();
|
|
1915
|
+
const descLower = strip(p.description || '').toLowerCase();
|
|
1916
|
+
for (const kw of keywords) {
|
|
1917
|
+
const kwLower = kw.toLowerCase();
|
|
1918
|
+
if (nameLower.includes(kwLower)) relevance_score += 3;
|
|
1919
|
+
if (descLower.includes(kwLower)) relevance_score += 2;
|
|
1920
|
+
}
|
|
1921
|
+
if (p.stock_status === 'instock') relevance_score += 1;
|
|
1922
|
+
if (p.images && p.images.length > 0 && p.images[0]?.src) relevance_score += 1;
|
|
1923
|
+
scored.push({ product_id: p.id, product_name: p.name, product_url: p.permalink, anchor_text: (p.name || '').substring(0, 50), price: p.price, relevance_score, in_stock: p.stock_status === 'instock' });
|
|
1924
|
+
}
|
|
1925
|
+
scored.sort((a, b) => b.relevance_score - a.relevance_score);
|
|
1926
|
+
const suggestions = scored.slice(0, max_suggestions);
|
|
1927
|
+
result = json({ post_id, post_title: postTitle, keywords_used: keywords, suggestions });
|
|
1928
|
+
auditLog({ tool: name, target: post_id, target_type: 'post', action: 'suggest_product_links', status: 'success', latency_ms: Date.now() - t0, params: { post_id, suggestion_count: suggestions.length } });
|
|
1929
|
+
break;
|
|
1930
|
+
}
|
|
1931
|
+
|
|
1932
|
+
// ── WOOCOMMERCE WRITE ──
|
|
1933
|
+
|
|
1934
|
+
case 'wc_update_product': {
|
|
1935
|
+
validateInput(args, { id: { type: 'number', required: true, min: 1 }, status: { type: 'string', enum: ['publish', 'draft', 'private'] } });
|
|
1936
|
+
const { id, name: prodName, description, short_description, regular_price, sale_price, status: prodStatus, price_guardrail_confirmed = false } = args;
|
|
1937
|
+
const { url: baseUrl } = getActiveAuth();
|
|
1938
|
+
|
|
1939
|
+
// Price guardrail check
|
|
1940
|
+
if (regular_price !== undefined || sale_price !== undefined) {
|
|
1941
|
+
const current = await wcApiCall(`/products/${id}`, {}, baseUrl);
|
|
1942
|
+
const checks = [];
|
|
1943
|
+
if (regular_price !== undefined) checks.push({ label: 'regular_price', current: parseFloat(current.regular_price), proposed: parseFloat(regular_price) });
|
|
1944
|
+
if (sale_price !== undefined && sale_price !== '') checks.push({ label: 'sale_price', current: parseFloat(current.sale_price || '0'), proposed: parseFloat(sale_price) });
|
|
1945
|
+
|
|
1946
|
+
for (const chk of checks) {
|
|
1947
|
+
if (!isNaN(chk.current) && chk.current > 0) {
|
|
1948
|
+
const changePct = Math.round(Math.abs(chk.proposed - chk.current) / chk.current * 10000) / 100;
|
|
1949
|
+
if (changePct > 20 && !price_guardrail_confirmed) {
|
|
1950
|
+
auditLog({ tool: name, target: id, target_type: 'product', action: 'update_product', status: 'blocked', latency_ms: Date.now() - t0, params: sanitizeParams(args), error: 'PRICE_GUARDRAIL_TRIGGERED' });
|
|
1951
|
+
return { content: [{ type: 'text', text: JSON.stringify({ error: 'PRICE_GUARDRAIL_TRIGGERED', message: `Price change of ${changePct}% exceeds 20% threshold. Call wc_price_guardrail first, then retry with price_guardrail_confirmed: true`, current_price: chk.current, new_price: chk.proposed, change_percent: changePct }, null, 2) }], isError: true };
|
|
1952
|
+
}
|
|
1953
|
+
}
|
|
1954
|
+
}
|
|
1955
|
+
}
|
|
1956
|
+
|
|
1957
|
+
const updateData = {};
|
|
1958
|
+
if (prodName !== undefined) updateData.name = prodName;
|
|
1959
|
+
if (description !== undefined) updateData.description = description;
|
|
1960
|
+
if (short_description !== undefined) updateData.short_description = short_description;
|
|
1961
|
+
if (regular_price !== undefined) updateData.regular_price = regular_price;
|
|
1962
|
+
if (sale_price !== undefined) updateData.sale_price = sale_price;
|
|
1963
|
+
if (prodStatus !== undefined) updateData.status = prodStatus;
|
|
1964
|
+
|
|
1965
|
+
const updated = await wcApiCall(`/products/${id}`, { method: 'PUT', body: JSON.stringify(updateData) }, baseUrl);
|
|
1966
|
+
const auditParams = { id, ...updateData };
|
|
1967
|
+
if (regular_price !== undefined || sale_price !== undefined) auditParams.price_change = true;
|
|
1968
|
+
result = json({ success: true, message: `Product ${id} updated`, product: { id: updated.id, name: updated.name, status: updated.status, regular_price: updated.regular_price, sale_price: updated.sale_price, permalink: updated.permalink } });
|
|
1969
|
+
auditLog({ tool: name, target: id, target_type: 'product', action: 'update_product', status: 'success', latency_ms: Date.now() - t0, params: sanitizeParams(auditParams) });
|
|
1970
|
+
break;
|
|
1971
|
+
}
|
|
1972
|
+
|
|
1973
|
+
case 'wc_update_stock': {
|
|
1974
|
+
validateInput(args, { id: { type: 'number', required: true, min: 1 }, stock_quantity: { type: 'number', required: true, min: 0 }, variation_id: { type: 'number', min: 1 } });
|
|
1975
|
+
const { id, stock_quantity, variation_id } = args;
|
|
1976
|
+
const { url: baseUrl } = getActiveAuth();
|
|
1977
|
+
|
|
1978
|
+
// Fetch current product/variation for audit
|
|
1979
|
+
let previousStock;
|
|
1980
|
+
if (variation_id) {
|
|
1981
|
+
const currentVar = await wcApiCall(`/products/${id}/variations/${variation_id}`, {}, baseUrl);
|
|
1982
|
+
previousStock = currentVar.stock_quantity;
|
|
1983
|
+
await wcApiCall(`/products/${id}/variations/${variation_id}`, { method: 'PUT', body: JSON.stringify({ stock_quantity, manage_stock: true }) }, baseUrl);
|
|
1984
|
+
} else {
|
|
1985
|
+
const currentProd = await wcApiCall(`/products/${id}`, {}, baseUrl);
|
|
1986
|
+
previousStock = currentProd.stock_quantity;
|
|
1987
|
+
await wcApiCall(`/products/${id}`, { method: 'PUT', body: JSON.stringify({ stock_quantity, manage_stock: true }) }, baseUrl);
|
|
1988
|
+
}
|
|
1989
|
+
|
|
1990
|
+
result = json({ success: true, message: variation_id ? `Variation ${variation_id} stock updated` : `Product ${id} stock updated`, product_id: id, variation_id: variation_id || null, previous_stock: previousStock, new_stock: stock_quantity });
|
|
1991
|
+
auditLog({ tool: name, target: id, target_type: 'product', action: 'update_stock', status: 'success', latency_ms: Date.now() - t0, params: { id, variation_id: variation_id || null, previous_stock: previousStock, new_stock: stock_quantity } });
|
|
1992
|
+
break;
|
|
1993
|
+
}
|
|
1994
|
+
|
|
1995
|
+
case 'wc_update_order_status': {
|
|
1996
|
+
validateInput(args, { id: { type: 'number', required: true, min: 1 }, status: { type: 'string', required: true } });
|
|
1997
|
+
const { id, status: newStatus, note } = args;
|
|
1998
|
+
const { url: baseUrl } = getActiveAuth();
|
|
1999
|
+
|
|
2000
|
+
const VALID_TRANSITIONS = {
|
|
2001
|
+
'pending': ['processing', 'cancelled', 'on-hold'],
|
|
2002
|
+
'processing': ['completed', 'cancelled', 'refunded', 'on-hold'],
|
|
2003
|
+
'on-hold': ['processing', 'cancelled'],
|
|
2004
|
+
'completed': ['refunded'],
|
|
2005
|
+
'cancelled': [],
|
|
2006
|
+
'refunded': [],
|
|
2007
|
+
'failed': ['processing', 'cancelled']
|
|
2008
|
+
};
|
|
2009
|
+
|
|
2010
|
+
const order = await wcApiCall(`/orders/${id}`, {}, baseUrl);
|
|
2011
|
+
const currentStatus = order.status;
|
|
2012
|
+
const validNext = VALID_TRANSITIONS[currentStatus];
|
|
2013
|
+
|
|
2014
|
+
if (validNext === undefined) {
|
|
2015
|
+
auditLog({ tool: name, target: id, target_type: 'order', action: 'update_order_status', status: 'error', latency_ms: Date.now() - t0, params: { current_status: currentStatus, requested_status: newStatus }, error: 'UNKNOWN_STATUS' });
|
|
2016
|
+
return { content: [{ type: 'text', text: JSON.stringify({ error: 'UNKNOWN_STATUS', current_status: currentStatus, requested_status: newStatus, message: `Unknown current status "${currentStatus}"` }, null, 2) }], isError: true };
|
|
2017
|
+
}
|
|
2018
|
+
|
|
2019
|
+
if (!validNext.includes(newStatus)) {
|
|
2020
|
+
auditLog({ tool: name, target: id, target_type: 'order', action: 'update_order_status', status: 'error', latency_ms: Date.now() - t0, params: { current_status: currentStatus, requested_status: newStatus }, error: 'INVALID_TRANSITION' });
|
|
2021
|
+
return { content: [{ type: 'text', text: JSON.stringify({ error: 'INVALID_TRANSITION', current_status: currentStatus, requested_status: newStatus, valid_transitions: validNext, message: validNext.length === 0 ? `Order status "${currentStatus}" is terminal — no transitions allowed` : `Cannot transition from "${currentStatus}" to "${newStatus}". Valid: ${validNext.join(', ')}` }, null, 2) }], isError: true };
|
|
2022
|
+
}
|
|
2023
|
+
|
|
2024
|
+
await wcApiCall(`/orders/${id}`, { method: 'PUT', body: JSON.stringify({ status: newStatus }) }, baseUrl);
|
|
2025
|
+
|
|
2026
|
+
let noteAdded = false;
|
|
2027
|
+
if (note) {
|
|
2028
|
+
await wcApiCall(`/orders/${id}/notes`, { method: 'POST', body: JSON.stringify({ note, customer_note: false }) }, baseUrl);
|
|
2029
|
+
noteAdded = true;
|
|
2030
|
+
}
|
|
2031
|
+
|
|
2032
|
+
result = json({ success: true, message: `Order ${id} status updated: ${currentStatus} → ${newStatus}`, order_id: id, previous_status: currentStatus, new_status: newStatus, note_added: noteAdded });
|
|
2033
|
+
auditLog({ tool: name, target: id, target_type: 'order', action: 'update_order_status', status: 'success', latency_ms: Date.now() - t0, params: { previous_status: currentStatus, new_status: newStatus, note_added: noteAdded } });
|
|
2034
|
+
break;
|
|
2035
|
+
}
|
|
2036
|
+
|
|
2037
|
+
// ── SEO ADVANCED ──
|
|
2038
|
+
|
|
2039
|
+
case 'wp_audit_media_seo': {
|
|
2040
|
+
validateInput(args, { per_page: { type: 'number', min: 1, max: 100 }, page: { type: 'number', min: 1 }, post_id: { type: 'number', min: 1 } });
|
|
2041
|
+
const { per_page = 50, page = 1, post_id } = args;
|
|
2042
|
+
const mediaItems = await wpApiCall(`/media?per_page=${Math.min(per_page, 100)}&page=${page}&media_type=image`);
|
|
2043
|
+
|
|
2044
|
+
const audit = [];
|
|
2045
|
+
let totalScore = 0;
|
|
2046
|
+
for (const m of mediaItems) {
|
|
2047
|
+
const item = { id: m.id, title: m.title?.rendered || '', source_url: m.source_url || '', alt_text: m.alt_text || '', issues: [], score: 100 };
|
|
2048
|
+
if (!item.alt_text) {
|
|
2049
|
+
item.issues.push('missing_alt'); item.score -= 40;
|
|
2050
|
+
} else {
|
|
2051
|
+
const filename = (m.source_url || '').split('/').pop()?.split('.')[0]?.replace(/[-_]/g, ' ') || '';
|
|
2052
|
+
if (filename && item.alt_text.toLowerCase().replace(/[-_]/g, ' ') === filename.toLowerCase()) {
|
|
2053
|
+
item.issues.push('filename_as_alt'); item.score -= 20;
|
|
2054
|
+
}
|
|
2055
|
+
if (item.alt_text.length < 5) { item.issues.push('alt_too_short'); item.score -= 15; }
|
|
2056
|
+
}
|
|
2057
|
+
if (item.score < 0) item.score = 0;
|
|
2058
|
+
totalScore += item.score;
|
|
2059
|
+
audit.push(item);
|
|
2060
|
+
}
|
|
2061
|
+
|
|
2062
|
+
let inlineImages = [];
|
|
2063
|
+
if (post_id) {
|
|
2064
|
+
const p = await wpApiCall(`/posts/${post_id}`);
|
|
2065
|
+
inlineImages = parseImagesFromHtml(p.content?.rendered || '');
|
|
2066
|
+
}
|
|
2067
|
+
|
|
2068
|
+
const avgScore = audit.length > 0 ? Math.round(totalScore / audit.length) : 0;
|
|
2069
|
+
result = json({
|
|
2070
|
+
summary: {
|
|
2071
|
+
total_audited: audit.length, average_score: avgScore,
|
|
2072
|
+
issues_breakdown: {
|
|
2073
|
+
missing_alt: audit.filter(a => a.issues.includes('missing_alt')).length,
|
|
2074
|
+
filename_as_alt: audit.filter(a => a.issues.includes('filename_as_alt')).length,
|
|
2075
|
+
alt_too_short: audit.filter(a => a.issues.includes('alt_too_short')).length,
|
|
2076
|
+
}
|
|
2077
|
+
},
|
|
2078
|
+
media: audit,
|
|
2079
|
+
inline_images: inlineImages
|
|
2080
|
+
});
|
|
2081
|
+
auditLog({ tool: name, action: 'audit_media_seo', status: 'success', latency_ms: Date.now() - t0, params: { count: audit.length, avg_score: avgScore } });
|
|
2082
|
+
break;
|
|
2083
|
+
}
|
|
2084
|
+
|
|
2085
|
+
case 'wp_find_orphan_pages': {
|
|
2086
|
+
validateInput(args, { per_page: { type: 'number', min: 1, max: 100 }, min_words: { type: 'number', min: 0 } });
|
|
2087
|
+
const { per_page = 100, exclude_ids = [], min_words = 0 } = args;
|
|
2088
|
+
const allPages = await wpApiCall(`/pages?per_page=${Math.min(per_page, 100)}&status=publish`);
|
|
2089
|
+
const { url: siteUrl } = getActiveAuth();
|
|
2090
|
+
|
|
2091
|
+
// Build set of linked page permalinks
|
|
2092
|
+
const linkedPermalinks = new Set();
|
|
2093
|
+
for (const pg of allPages) {
|
|
2094
|
+
const content = pg.content?.rendered || '';
|
|
2095
|
+
const links = extractInternalLinksHtml(content, siteUrl);
|
|
2096
|
+
for (const link of links) {
|
|
2097
|
+
// Normalise trailing slash for comparison
|
|
2098
|
+
linkedPermalinks.add(link.replace(/\/+$/, ''));
|
|
2099
|
+
}
|
|
2100
|
+
}
|
|
2101
|
+
|
|
2102
|
+
// Find orphans: pages whose permalink is NOT in linkedPermalinks
|
|
2103
|
+
const excludeSet = new Set(Array.isArray(exclude_ids) ? exclude_ids : []);
|
|
2104
|
+
const orphans = allPages.filter(pg => {
|
|
2105
|
+
if (excludeSet.has(pg.id)) return false;
|
|
2106
|
+
const normLink = (pg.link || '').replace(/\/+$/, '');
|
|
2107
|
+
if (linkedPermalinks.has(normLink)) return false;
|
|
2108
|
+
const wc = countWords(pg.content?.rendered || '');
|
|
2109
|
+
if (wc < min_words) return false;
|
|
2110
|
+
return true;
|
|
2111
|
+
});
|
|
2112
|
+
|
|
2113
|
+
// Sort by word count descending
|
|
2114
|
+
orphans.sort((a, b) => countWords(b.content?.rendered || '') - countWords(a.content?.rendered || ''));
|
|
2115
|
+
|
|
2116
|
+
result = json({
|
|
2117
|
+
total_pages: allPages.length, total_orphans: orphans.length,
|
|
2118
|
+
orphans: orphans.map(pg => ({
|
|
2119
|
+
id: pg.id, title: strip(pg.title?.rendered || ''), slug: pg.slug, link: pg.link,
|
|
2120
|
+
word_count: countWords(pg.content?.rendered || ''), status: pg.status
|
|
2121
|
+
}))
|
|
2122
|
+
});
|
|
2123
|
+
auditLog({ tool: name, action: 'find_orphan_pages', status: 'success', latency_ms: Date.now() - t0, params: { total: allPages.length, orphans: orphans.length } });
|
|
2124
|
+
break;
|
|
2125
|
+
}
|
|
2126
|
+
|
|
2127
|
+
case 'wp_audit_heading_structure': {
|
|
2128
|
+
validateInput(args, { id: { type: 'number', required: true, min: 1 } });
|
|
2129
|
+
const { id, post_type = 'post', focus_keyword } = args;
|
|
2130
|
+
const ep = post_type === 'page' ? `/pages/${id}` : `/posts/${id}`;
|
|
2131
|
+
const p = await wpApiCall(ep);
|
|
2132
|
+
const content = p.content?.rendered || '';
|
|
2133
|
+
const postTitle = strip(p.title?.rendered || '');
|
|
2134
|
+
|
|
2135
|
+
const headings = extractHeadings(content);
|
|
2136
|
+
const issues = [];
|
|
2137
|
+
let score = 100;
|
|
2138
|
+
|
|
2139
|
+
// H1 in content
|
|
2140
|
+
const h1s = headings.filter(h => h.level === 1);
|
|
2141
|
+
if (h1s.length > 0) {
|
|
2142
|
+
issues.push({ type: 'h1_in_content', count: h1s.length, message: 'H1 tag found in content — the post title already provides the H1' });
|
|
2143
|
+
score -= 20;
|
|
2144
|
+
}
|
|
2145
|
+
|
|
2146
|
+
// Heading level skips
|
|
2147
|
+
for (let i = 1; i < headings.length; i++) {
|
|
2148
|
+
if (headings[i].level > headings[i - 1].level + 1) {
|
|
2149
|
+
issues.push({ type: 'heading_skip', from: `H${headings[i - 1].level}`, to: `H${headings[i].level}`, message: `Heading level skip: H${headings[i - 1].level} → H${headings[i].level}` });
|
|
2150
|
+
score -= 10;
|
|
2151
|
+
}
|
|
2152
|
+
}
|
|
2153
|
+
|
|
2154
|
+
// Empty headings
|
|
2155
|
+
const emptyHeadings = headings.filter(h => !h.text);
|
|
2156
|
+
if (emptyHeadings.length > 0) {
|
|
2157
|
+
issues.push({ type: 'empty_heading', count: emptyHeadings.length, message: `${emptyHeadings.length} empty heading(s) found` });
|
|
2158
|
+
score -= 10 * emptyHeadings.length;
|
|
2159
|
+
}
|
|
2160
|
+
|
|
2161
|
+
// No H2
|
|
2162
|
+
const h2s = headings.filter(h => h.level === 2);
|
|
2163
|
+
if (h2s.length === 0 && headings.length > 0) {
|
|
2164
|
+
issues.push({ type: 'no_h2', message: 'No H2 headings found — content should use H2 for main sections' });
|
|
2165
|
+
score -= 15;
|
|
2166
|
+
}
|
|
2167
|
+
|
|
2168
|
+
// Keyword checks
|
|
2169
|
+
if (focus_keyword && h2s.length > 0) {
|
|
2170
|
+
const kwLower = focus_keyword.toLowerCase();
|
|
2171
|
+
const h2WithKw = h2s.filter(h => h.text.toLowerCase().includes(kwLower));
|
|
2172
|
+
if (h2WithKw.length === 0) {
|
|
2173
|
+
issues.push({ type: 'keyword_absent_h2', message: `Focus keyword "${focus_keyword}" not found in any H2 heading` });
|
|
2174
|
+
score -= 10;
|
|
2175
|
+
}
|
|
2176
|
+
if (h2s.length >= 3 && h2WithKw.length / h2s.length > 0.5) {
|
|
2177
|
+
issues.push({ type: 'keyword_stuffing', count: h2WithKw.length, total_h2: h2s.length, message: `Keyword "${focus_keyword}" appears in ${h2WithKw.length}/${h2s.length} H2 headings (>50%)` });
|
|
2178
|
+
score -= 15;
|
|
2179
|
+
}
|
|
2180
|
+
}
|
|
2181
|
+
|
|
2182
|
+
if (score < 0) score = 0;
|
|
2183
|
+
|
|
2184
|
+
result = json({
|
|
2185
|
+
post_id: id, post_title: postTitle, headings, issues, score,
|
|
2186
|
+
summary: {
|
|
2187
|
+
total_headings: headings.length,
|
|
2188
|
+
h1_count: h1s.length, h2_count: h2s.length,
|
|
2189
|
+
h3_count: headings.filter(h => h.level === 3).length,
|
|
2190
|
+
h4_count: headings.filter(h => h.level === 4).length,
|
|
2191
|
+
h5_count: headings.filter(h => h.level === 5).length,
|
|
2192
|
+
h6_count: headings.filter(h => h.level === 6).length,
|
|
2193
|
+
}
|
|
2194
|
+
});
|
|
2195
|
+
auditLog({ tool: name, target: id, target_type: post_type, action: 'audit_heading_structure', status: 'success', latency_ms: Date.now() - t0, params: { headings_count: headings.length, issues_count: issues.length, score } });
|
|
2196
|
+
break;
|
|
2197
|
+
}
|
|
2198
|
+
|
|
2199
|
+
// ── SEO ADVANCED v4.1 ──
|
|
2200
|
+
|
|
2201
|
+
case 'wp_find_thin_content': {
|
|
2202
|
+
validateInput(args, {
|
|
2203
|
+
limit: { type: 'number', min: 1, max: 500 },
|
|
2204
|
+
min_words: { type: 'number', min: 1 },
|
|
2205
|
+
critical_words: { type: 'number', min: 1 },
|
|
2206
|
+
max_age_days: { type: 'number', min: 1 },
|
|
2207
|
+
include_uncategorized: { type: 'string' },
|
|
2208
|
+
post_type: { type: 'string', enum: ['post', 'page'] }
|
|
2209
|
+
});
|
|
2210
|
+
const { limit = 100, min_words = 300, critical_words = 150, max_age_days = 730, include_uncategorized = true, post_type = 'post' } = args;
|
|
2211
|
+
const endpoint = post_type === 'page' ? '/pages' : '/posts';
|
|
2212
|
+
const items = await wpApiCall(`${endpoint}?per_page=${Math.min(limit, 100)}&status=publish&_fields=id,title,link,content,modified,date,categories`);
|
|
2213
|
+
|
|
2214
|
+
const now = new Date();
|
|
2215
|
+
const articles = [];
|
|
2216
|
+
for (const item of items) {
|
|
2217
|
+
const wc = countWords(item.content?.rendered || '');
|
|
2218
|
+
const modified = new Date(item.modified);
|
|
2219
|
+
const daysSinceUpdate = Math.floor((now - modified) / (1000 * 60 * 60 * 24));
|
|
2220
|
+
|
|
2221
|
+
const signals = [];
|
|
2222
|
+
if (wc < critical_words) signals.push('very_short');
|
|
2223
|
+
else if (wc < min_words) signals.push('too_short');
|
|
2224
|
+
if (daysSinceUpdate > max_age_days) signals.push('outdated');
|
|
2225
|
+
if (include_uncategorized) {
|
|
2226
|
+
const cats = item.categories || [];
|
|
2227
|
+
if (cats.length === 0 || (cats.length === 1 && cats[0] === 1)) signals.push('uncategorized');
|
|
2228
|
+
}
|
|
2229
|
+
|
|
2230
|
+
if (signals.length === 0) continue;
|
|
2231
|
+
|
|
2232
|
+
let severity = 'medium';
|
|
2233
|
+
if (signals.length >= 3) severity = 'critical';
|
|
2234
|
+
else if (signals.length >= 2) severity = 'high';
|
|
2235
|
+
|
|
2236
|
+
let suggested_action = 'review';
|
|
2237
|
+
if (signals.includes('very_short') && signals.includes('outdated')) suggested_action = 'delete';
|
|
2238
|
+
else if (signals.includes('too_short') || signals.includes('very_short')) suggested_action = 'expand';
|
|
2239
|
+
else if (signals.includes('outdated') && !signals.includes('too_short') && !signals.includes('very_short')) suggested_action = 'update_or_merge';
|
|
2240
|
+
|
|
2241
|
+
articles.push({
|
|
2242
|
+
id: item.id, title: strip(item.title?.rendered || ''), link: item.link || '',
|
|
2243
|
+
word_count: wc, days_since_update: daysSinceUpdate,
|
|
2244
|
+
signals, severity, suggested_action
|
|
2245
|
+
});
|
|
2246
|
+
}
|
|
2247
|
+
|
|
2248
|
+
// Sort: severity DESC (critical > high > medium), then word_count ASC
|
|
2249
|
+
const sevOrder = { critical: 0, high: 1, medium: 2 };
|
|
2250
|
+
articles.sort((a, b) => (sevOrder[a.severity] - sevOrder[b.severity]) || (a.word_count - b.word_count));
|
|
2251
|
+
|
|
2252
|
+
const bySev = { critical: 0, high: 0, medium: 0 };
|
|
2253
|
+
for (const a of articles) bySev[a.severity]++;
|
|
2254
|
+
|
|
2255
|
+
result = json({ total_analyzed: items.length, total_thin: articles.length, by_severity: bySev, articles });
|
|
2256
|
+
auditLog({ tool: name, action: 'audit_seo', target_type: post_type, status: 'success', latency_ms: Date.now() - t0, params: { total_analyzed: items.length, total_thin: articles.length } });
|
|
2257
|
+
break;
|
|
2258
|
+
}
|
|
2259
|
+
|
|
2260
|
+
case 'wp_audit_canonicals': {
|
|
2261
|
+
validateInput(args, {
|
|
2262
|
+
limit: { type: 'number', min: 1, max: 200 },
|
|
2263
|
+
post_type: { type: 'string', enum: ['post', 'page', 'both'] },
|
|
2264
|
+
check_staging_patterns: { type: 'string' }
|
|
2265
|
+
});
|
|
2266
|
+
const { limit = 50, post_type = 'post', check_staging_patterns = true } = args;
|
|
2267
|
+
|
|
2268
|
+
// Fetch posts (and/or pages)
|
|
2269
|
+
let allPosts = [];
|
|
2270
|
+
const fieldsList = 'id,title,link,meta';
|
|
2271
|
+
if (post_type === 'both' || post_type === 'post') {
|
|
2272
|
+
const posts = await wpApiCall(`/posts?per_page=${Math.min(limit, 100)}&status=publish&_fields=${fieldsList}`);
|
|
2273
|
+
allPosts = allPosts.concat(posts);
|
|
2274
|
+
}
|
|
2275
|
+
if (post_type === 'both' || post_type === 'page') {
|
|
2276
|
+
const pages = await wpApiCall(`/pages?per_page=${Math.min(limit, 100)}&status=publish&_fields=${fieldsList}`);
|
|
2277
|
+
allPosts = allPosts.concat(pages);
|
|
2278
|
+
}
|
|
2279
|
+
|
|
2280
|
+
// Detect site URL from auth
|
|
2281
|
+
const { url: siteUrl } = getActiveAuth();
|
|
2282
|
+
let siteHost = '';
|
|
2283
|
+
let siteProtocol = 'https:';
|
|
2284
|
+
try {
|
|
2285
|
+
const parsed = new URL(siteUrl);
|
|
2286
|
+
siteHost = parsed.host;
|
|
2287
|
+
siteProtocol = parsed.protocol;
|
|
2288
|
+
} catch { /* fallback */ }
|
|
2289
|
+
|
|
2290
|
+
// Detect SEO plugin from meta keys
|
|
2291
|
+
let seoPluginDetected = 'none';
|
|
2292
|
+
const seoCanonicalKeys = {
|
|
2293
|
+
rank_math_canonical_url: 'RankMath',
|
|
2294
|
+
_yoast_wpseo_canonical: 'Yoast',
|
|
2295
|
+
_seopress_robots_canonical: 'SEOPress',
|
|
2296
|
+
_aioseo_og_title: 'AIOSEO'
|
|
2297
|
+
};
|
|
2298
|
+
|
|
2299
|
+
for (const p of allPosts) {
|
|
2300
|
+
const meta = p.meta || {};
|
|
2301
|
+
for (const [key, plugin] of Object.entries(seoCanonicalKeys)) {
|
|
2302
|
+
if (meta[key] !== undefined && meta[key] !== '' && meta[key] !== null) {
|
|
2303
|
+
seoPluginDetected = plugin;
|
|
2304
|
+
break;
|
|
2305
|
+
}
|
|
2306
|
+
}
|
|
2307
|
+
if (seoPluginDetected !== 'none') break;
|
|
2308
|
+
}
|
|
2309
|
+
|
|
2310
|
+
// Get canonical field name
|
|
2311
|
+
const canonicalKey = Object.keys(seoCanonicalKeys).find(k => {
|
|
2312
|
+
return allPosts.some(p => p.meta && p.meta[k] !== undefined);
|
|
2313
|
+
}) || '';
|
|
2314
|
+
|
|
2315
|
+
const stagingPatterns = ['staging', 'dev', 'local', 'preprod', 'test', 'localhost'];
|
|
2316
|
+
const audits = [];
|
|
2317
|
+
const issuesByType = { missing_canonical: 0, http_on_https_site: 0, staging_url: 0, wrong_domain: 0, trailing_slash_mismatch: 0 };
|
|
2318
|
+
|
|
2319
|
+
for (const p of allPosts) {
|
|
2320
|
+
const meta = p.meta || {};
|
|
2321
|
+
const postUrl = p.link || '';
|
|
2322
|
+
const canonical = (canonicalKey && meta[canonicalKey]) ? String(meta[canonicalKey]) : '';
|
|
2323
|
+
const postIssues = [];
|
|
2324
|
+
|
|
2325
|
+
if (!canonical) {
|
|
2326
|
+
postIssues.push('missing_canonical');
|
|
2327
|
+
issuesByType.missing_canonical++;
|
|
2328
|
+
} else {
|
|
2329
|
+
// HTTP on HTTPS site
|
|
2330
|
+
if (siteProtocol === 'https:' && canonical.startsWith('http://')) {
|
|
2331
|
+
postIssues.push('http_on_https_site');
|
|
2332
|
+
issuesByType.http_on_https_site++;
|
|
2333
|
+
}
|
|
2334
|
+
// Staging URL
|
|
2335
|
+
if (check_staging_patterns) {
|
|
2336
|
+
let canonHostLower = '';
|
|
2337
|
+
try { canonHostLower = new URL(canonical).hostname.toLowerCase(); } catch { /* skip */ }
|
|
2338
|
+
if (canonHostLower && stagingPatterns.some(pat => canonHostLower.includes(pat))) {
|
|
2339
|
+
postIssues.push('staging_url');
|
|
2340
|
+
issuesByType.staging_url++;
|
|
2341
|
+
}
|
|
2342
|
+
}
|
|
2343
|
+
// Wrong domain
|
|
2344
|
+
try {
|
|
2345
|
+
const canonHost = new URL(canonical).host;
|
|
2346
|
+
if (siteHost && canonHost !== siteHost) {
|
|
2347
|
+
postIssues.push('wrong_domain');
|
|
2348
|
+
issuesByType.wrong_domain++;
|
|
2349
|
+
}
|
|
2350
|
+
} catch { /* skip invalid canonical URL */ }
|
|
2351
|
+
// Trailing slash mismatch
|
|
2352
|
+
if (postUrl && canonical) {
|
|
2353
|
+
const postTrailing = postUrl.endsWith('/');
|
|
2354
|
+
const canonTrailing = canonical.endsWith('/');
|
|
2355
|
+
if (postTrailing !== canonTrailing) {
|
|
2356
|
+
postIssues.push('trailing_slash_mismatch');
|
|
2357
|
+
issuesByType.trailing_slash_mismatch++;
|
|
2358
|
+
}
|
|
2359
|
+
}
|
|
2360
|
+
}
|
|
2361
|
+
|
|
2362
|
+
audits.push({
|
|
2363
|
+
id: p.id, title: strip(p.title?.rendered || ''), url: postUrl,
|
|
2364
|
+
canonical: canonical || null, issues: postIssues
|
|
2365
|
+
});
|
|
2366
|
+
}
|
|
2367
|
+
|
|
2368
|
+
const totalIssues = audits.filter(a => a.issues.length > 0).length;
|
|
2369
|
+
result = json({ total_audited: audits.length, total_issues: totalIssues, seo_plugin_detected: seoPluginDetected, issues_by_type: issuesByType, audits });
|
|
2370
|
+
auditLog({ tool: name, action: 'audit_seo', target_type: post_type, status: 'success', latency_ms: Date.now() - t0, params: { total_audited: audits.length, total_issues: totalIssues, seo_plugin: seoPluginDetected } });
|
|
2371
|
+
break;
|
|
2372
|
+
}
|
|
2373
|
+
|
|
2374
|
+
case 'wp_analyze_eeat_signals': {
|
|
2375
|
+
validateInput(args, {
|
|
2376
|
+
post_ids: { type: 'array' },
|
|
2377
|
+
limit: { type: 'number', min: 1, max: 50 },
|
|
2378
|
+
post_type: { type: 'string', enum: ['post', 'page'] },
|
|
2379
|
+
authoritative_domains: { type: 'array' }
|
|
2380
|
+
});
|
|
2381
|
+
const { post_ids, limit = 10, post_type = 'post', authoritative_domains = ['wikipedia.org', 'gov', 'edu', 'who.int', 'pubmed'] } = args;
|
|
2382
|
+
|
|
2383
|
+
// Fetch posts
|
|
2384
|
+
let posts;
|
|
2385
|
+
if (post_ids && post_ids.length > 0) {
|
|
2386
|
+
posts = await wpApiCall(`/${post_type === 'page' ? 'pages' : 'posts'}?include=${post_ids.join(',')}&_fields=id,title,link,content,author,date,modified,meta`);
|
|
2387
|
+
} else {
|
|
2388
|
+
posts = await wpApiCall(`/${post_type === 'page' ? 'pages' : 'posts'}?per_page=${Math.min(limit, 50)}&status=publish&orderby=date&order=desc&_fields=id,title,link,content,author,date,modified,meta`);
|
|
2389
|
+
}
|
|
2390
|
+
|
|
2391
|
+
// Cache authors
|
|
2392
|
+
const authorCache = {};
|
|
2393
|
+
const eeatResults = [];
|
|
2394
|
+
|
|
2395
|
+
for (const p of posts) {
|
|
2396
|
+
const content = p.content?.rendered || '';
|
|
2397
|
+
const wc = countWords(content);
|
|
2398
|
+
const nowDate = new Date();
|
|
2399
|
+
const modified = new Date(p.modified);
|
|
2400
|
+
const daysSinceUpdate = Math.floor((nowDate - modified) / (1000 * 60 * 60 * 24));
|
|
2401
|
+
|
|
2402
|
+
// Fetch author
|
|
2403
|
+
const authorId = p.author;
|
|
2404
|
+
if (authorId && !authorCache[authorId]) {
|
|
2405
|
+
try {
|
|
2406
|
+
authorCache[authorId] = await wpApiCall(`/users/${authorId}`);
|
|
2407
|
+
} catch { authorCache[authorId] = {}; }
|
|
2408
|
+
}
|
|
2409
|
+
const author = authorCache[authorId] || {};
|
|
2410
|
+
|
|
2411
|
+
const signalsPresent = [];
|
|
2412
|
+
const signalsMissing = [];
|
|
2413
|
+
|
|
2414
|
+
// EXPERIENCE (score /25)
|
|
2415
|
+
let experienceScore = 0;
|
|
2416
|
+
const hasBio = !!(author.description && author.description.trim());
|
|
2417
|
+
if (hasBio) { experienceScore += 10; signalsPresent.push('author_has_bio'); } else { signalsMissing.push({ signal: 'author_has_bio', impact: 10, category: 'experience' }); }
|
|
2418
|
+
const hasFirstPerson = /\b(je|nous|notre|mon|ma|j'ai|j'|I|we|my|our|I've)\b/i.test(content);
|
|
2419
|
+
if (hasFirstPerson) { experienceScore += 8; signalsPresent.push('content_has_first_person'); } else { signalsMissing.push({ signal: 'content_has_first_person', impact: 8, category: 'experience' }); }
|
|
2420
|
+
const hasPersonalExp = /\b(expérience|témoignage|cas|exemple|client|projet|experience|testimony|case study|example|project)\b/i.test(content);
|
|
2421
|
+
if (hasPersonalExp) { experienceScore += 7; signalsPresent.push('content_has_personal_experience'); } else { signalsMissing.push({ signal: 'content_has_personal_experience', impact: 7, category: 'experience' }); }
|
|
2422
|
+
|
|
2423
|
+
// EXPERTISE (score /25)
|
|
2424
|
+
let expertiseScore = 0;
|
|
2425
|
+
const hasData = /\d+\s*(%|€|\$|£|kg|km|ml|mg|px|ms|gb|mb|tb)/i.test(content);
|
|
2426
|
+
if (hasData) { expertiseScore += 8; signalsPresent.push('content_has_data'); } else { signalsMissing.push({ signal: 'content_has_data', impact: 8, category: 'expertise' }); }
|
|
2427
|
+
const hasEntities = /[.>]\s+\w+\s+[A-Z][a-z]{2,}/.test(content.replace(/<[^>]*>/g, ''));
|
|
2428
|
+
if (hasEntities) { expertiseScore += 7; signalsPresent.push('content_has_entities'); } else { signalsMissing.push({ signal: 'content_has_entities', impact: 7, category: 'expertise' }); }
|
|
2429
|
+
if (wc > 800) { expertiseScore += 10; signalsPresent.push('content_word_count_expert'); } else { signalsMissing.push({ signal: 'content_word_count_expert', impact: 10, category: 'expertise' }); }
|
|
2430
|
+
|
|
2431
|
+
// AUTHORITATIVENESS (score /25)
|
|
2432
|
+
let authScore = 0;
|
|
2433
|
+
const extLinkRegex = /<a\s[^>]*?href=["'](https?:\/\/[^"']+)["'][^>]*?>/gi;
|
|
2434
|
+
const externalLinks = [];
|
|
2435
|
+
let extMatch;
|
|
2436
|
+
const { url: sUrl } = getActiveAuth();
|
|
2437
|
+
let sHost = '';
|
|
2438
|
+
try { sHost = new URL(sUrl).host; } catch { /* */ }
|
|
2439
|
+
while ((extMatch = extLinkRegex.exec(content)) !== null) {
|
|
2440
|
+
try {
|
|
2441
|
+
const linkHost = new URL(extMatch[1]).host;
|
|
2442
|
+
if (linkHost !== sHost) externalLinks.push(extMatch[1]);
|
|
2443
|
+
} catch { /* skip */ }
|
|
2444
|
+
}
|
|
2445
|
+
if (externalLinks.length >= 2) { authScore += 8; signalsPresent.push('has_outbound_links'); } else { signalsMissing.push({ signal: 'has_outbound_links', impact: 8, category: 'authoritativeness' }); }
|
|
2446
|
+
const hasAuthSources = externalLinks.some(link => {
|
|
2447
|
+
const lowerLink = link.toLowerCase();
|
|
2448
|
+
return authoritative_domains.some(d => lowerLink.includes(d));
|
|
2449
|
+
});
|
|
2450
|
+
if (hasAuthSources) { authScore += 10; signalsPresent.push('has_authoritative_sources'); } else { signalsMissing.push({ signal: 'has_authoritative_sources', impact: 10, category: 'authoritativeness' }); }
|
|
2451
|
+
const hasCitations = /\b(selon|d'après|source|étude|rapport|according to|study|report|research)\b/i.test(content);
|
|
2452
|
+
if (hasCitations) { authScore += 7; signalsPresent.push('content_has_citations'); } else { signalsMissing.push({ signal: 'content_has_citations', impact: 7, category: 'authoritativeness' }); }
|
|
2453
|
+
|
|
2454
|
+
// TRUSTWORTHINESS (score /25)
|
|
2455
|
+
let trustScore = 0;
|
|
2456
|
+
if (daysSinceUpdate <= 365) { trustScore += 10; signalsPresent.push('has_update_date'); } else { signalsMissing.push({ signal: 'has_update_date', impact: 10, category: 'trustworthiness' }); }
|
|
2457
|
+
const hasAvatar = !!(author.avatar_urls && Object.keys(author.avatar_urls).length > 0);
|
|
2458
|
+
if (hasBio && hasAvatar) { trustScore += 8; signalsPresent.push('author_linked'); } else { signalsMissing.push({ signal: 'author_linked', impact: 8, category: 'trustworthiness' }); }
|
|
2459
|
+
const hasStructuredData = content.includes('application/ld+json');
|
|
2460
|
+
if (hasStructuredData) { trustScore += 7; signalsPresent.push('has_structured_data'); } else { signalsMissing.push({ signal: 'has_structured_data', impact: 7, category: 'trustworthiness' }); }
|
|
2461
|
+
|
|
2462
|
+
const total = experienceScore + expertiseScore + authScore + trustScore;
|
|
2463
|
+
|
|
2464
|
+
// Priority fixes: top 3 missing signals by impact
|
|
2465
|
+
const priorityFixes = signalsMissing
|
|
2466
|
+
.sort((a, b) => b.impact - a.impact)
|
|
2467
|
+
.slice(0, 3)
|
|
2468
|
+
.map(s => ({ signal: s.signal, category: s.category, potential_points: s.impact }));
|
|
2469
|
+
|
|
2470
|
+
eeatResults.push({
|
|
2471
|
+
id: p.id, title: strip(p.title?.rendered || ''),
|
|
2472
|
+
scores: { experience: experienceScore, expertise: expertiseScore, authoritativeness: authScore, trustworthiness: trustScore, total },
|
|
2473
|
+
signals_present: signalsPresent,
|
|
2474
|
+
signals_missing: signalsMissing.map(s => s.signal),
|
|
2475
|
+
priority_fixes: priorityFixes
|
|
2476
|
+
});
|
|
2477
|
+
}
|
|
2478
|
+
|
|
2479
|
+
result = json({ total_analyzed: posts.length, analyses: eeatResults });
|
|
2480
|
+
auditLog({ tool: name, action: 'audit_seo', target_type: post_type, status: 'success', latency_ms: Date.now() - t0, params: { total_analyzed: posts.length } });
|
|
2481
|
+
break;
|
|
2482
|
+
}
|
|
2483
|
+
|
|
2484
|
+
// ── SEO ADVANCED v4.2 ──
|
|
2485
|
+
|
|
2486
|
+
case 'wp_find_broken_internal_links': {
|
|
2487
|
+
validateInput(args, {
|
|
2488
|
+
limit_posts: { type: 'number', min: 1, max: 100 },
|
|
2489
|
+
batch_size: { type: 'number', min: 1, max: 10 },
|
|
2490
|
+
timeout_ms: { type: 'number', min: 1000, max: 30000 },
|
|
2491
|
+
delay_ms: { type: 'number', min: 0, max: 2000 },
|
|
2492
|
+
post_type: { type: 'string', enum: ['post', 'page', 'both'] }
|
|
2493
|
+
});
|
|
2494
|
+
const { limit_posts = 20, batch_size = 5, timeout_ms = 5000, delay_ms = 200, post_type = 'post', include_redirects = true } = args;
|
|
2495
|
+
const scanStart = Date.now();
|
|
2496
|
+
|
|
2497
|
+
// Fetch posts
|
|
2498
|
+
const endpoints = post_type === 'both' ? ['/posts', '/pages'] : [post_type === 'page' ? '/pages' : '/posts'];
|
|
2499
|
+
let allPosts = [];
|
|
2500
|
+
for (const ep of endpoints) {
|
|
2501
|
+
const items = await wpApiCall(`${ep}?per_page=${Math.min(limit_posts, 100)}&status=publish&_fields=id,title,link,content`);
|
|
2502
|
+
allPosts = allPosts.concat(items);
|
|
2503
|
+
}
|
|
2504
|
+
allPosts = allPosts.slice(0, limit_posts);
|
|
2505
|
+
|
|
2506
|
+
// Extract internal links per post and deduplicate globally
|
|
2507
|
+
const linkToSources = new Map(); // url → [{post_id, post_title, post_url, html}]
|
|
2508
|
+
for (const p of allPosts) {
|
|
2509
|
+
const html = p.content?.rendered || '';
|
|
2510
|
+
const siteUrl = p.link ? p.link.replace(/\/[^/]*\/?$/, '') : '';
|
|
2511
|
+
const internalUrls = extractInternalLinksHtml(html, siteUrl);
|
|
2512
|
+
const uniqueUrls = [...new Set(internalUrls)];
|
|
2513
|
+
for (const url of uniqueUrls) {
|
|
2514
|
+
if (!linkToSources.has(url)) linkToSources.set(url, []);
|
|
2515
|
+
linkToSources.get(url).push({ post_id: p.id, post_title: strip(p.title?.rendered || ''), post_url: p.link, html });
|
|
2516
|
+
}
|
|
2517
|
+
}
|
|
2518
|
+
|
|
2519
|
+
// Check links in batches
|
|
2520
|
+
const allUrls = [...linkToSources.keys()];
|
|
2521
|
+
const linkResults = new Map(); // url → {status, ok, isTimeout}
|
|
2522
|
+
|
|
2523
|
+
async function checkSingleLink(url, tms) {
|
|
2524
|
+
const controller = new AbortController();
|
|
2525
|
+
const timer = setTimeout(() => controller.abort(), tms);
|
|
2526
|
+
try {
|
|
2527
|
+
const response = await fetch(url, { method: 'HEAD', signal: controller.signal, redirect: 'manual' });
|
|
2528
|
+
clearTimeout(timer);
|
|
2529
|
+
return { url, status: response.status, ok: response.status < 400 };
|
|
2530
|
+
} catch (error) {
|
|
2531
|
+
clearTimeout(timer);
|
|
2532
|
+
return { url, status: null, ok: false, isTimeout: error.name === 'AbortError' };
|
|
2533
|
+
}
|
|
2534
|
+
}
|
|
2535
|
+
|
|
2536
|
+
for (let i = 0; i < allUrls.length; i += batch_size) {
|
|
2537
|
+
const batch = allUrls.slice(i, i + batch_size);
|
|
2538
|
+
const results = await Promise.allSettled(batch.map(u => checkSingleLink(u, timeout_ms)));
|
|
2539
|
+
for (const r of results) {
|
|
2540
|
+
const val = r.status === 'fulfilled' ? r.value : { url: batch[0], status: null, ok: false, isTimeout: false };
|
|
2541
|
+
linkResults.set(val.url, val);
|
|
2542
|
+
}
|
|
2543
|
+
if (i + batch_size < allUrls.length && delay_ms > 0) {
|
|
2544
|
+
await new Promise(r => setTimeout(r, delay_ms));
|
|
2545
|
+
}
|
|
2546
|
+
}
|
|
2547
|
+
|
|
2548
|
+
// Extract anchor text helper
|
|
2549
|
+
function getAnchorText(html, href) {
|
|
2550
|
+
const escaped = href.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
2551
|
+
const m = html.match(new RegExp(`<a\\s[^>]*?href=["']${escaped}["'][^>]*?>(.*?)<\\/a>`, 'i'));
|
|
2552
|
+
return m ? m[1].replace(/<[^>]*>/g, '').trim() : '';
|
|
2553
|
+
}
|
|
2554
|
+
|
|
2555
|
+
// Build results
|
|
2556
|
+
const brokenLinks = [];
|
|
2557
|
+
let totalRedirects = 0;
|
|
2558
|
+
let totalTimeouts = 0;
|
|
2559
|
+
let totalBroken = 0;
|
|
2560
|
+
|
|
2561
|
+
for (const [url, lr] of linkResults) {
|
|
2562
|
+
let issueType = null;
|
|
2563
|
+
if (lr.status === 404) issueType = 'not_found';
|
|
2564
|
+
else if (lr.status === 301 || lr.status === 302) issueType = 'redirect';
|
|
2565
|
+
else if (lr.isTimeout) issueType = 'timeout';
|
|
2566
|
+
else if (!lr.ok && lr.status !== 301 && lr.status !== 302) issueType = 'network_error';
|
|
2567
|
+
|
|
2568
|
+
if (!issueType) continue;
|
|
2569
|
+
if (issueType === 'redirect') { totalRedirects++; if (!include_redirects) continue; }
|
|
2570
|
+
if (issueType === 'timeout') totalTimeouts++;
|
|
2571
|
+
if (issueType === 'not_found' || issueType === 'network_error') totalBroken++;
|
|
2572
|
+
|
|
2573
|
+
const sources = linkToSources.get(url) || [];
|
|
2574
|
+
for (const src of sources) {
|
|
2575
|
+
brokenLinks.push({
|
|
2576
|
+
source_post_id: src.post_id, source_post_title: src.post_title, source_post_url: src.post_url,
|
|
2577
|
+
broken_url: url, anchor_text: getAnchorText(src.html, url),
|
|
2578
|
+
status_code: lr.status, issue_type: issueType
|
|
2579
|
+
});
|
|
2580
|
+
}
|
|
2581
|
+
}
|
|
2582
|
+
|
|
2583
|
+
// Sort: not_found first, then redirect, then timeout, then network_error
|
|
2584
|
+
const typeOrder = { not_found: 0, redirect: 1, timeout: 2, network_error: 3 };
|
|
2585
|
+
brokenLinks.sort((a, b) => (typeOrder[a.issue_type] ?? 9) - (typeOrder[b.issue_type] ?? 9));
|
|
2586
|
+
|
|
2587
|
+
result = json({
|
|
2588
|
+
total_posts_scanned: allPosts.length,
|
|
2589
|
+
total_links_checked: allUrls.length,
|
|
2590
|
+
total_broken: totalBroken,
|
|
2591
|
+
total_redirects: totalRedirects,
|
|
2592
|
+
total_timeouts: totalTimeouts,
|
|
2593
|
+
broken_links: brokenLinks,
|
|
2594
|
+
scan_duration_ms: Date.now() - scanStart
|
|
2595
|
+
});
|
|
2596
|
+
auditLog({ tool: name, action: 'audit_seo', target_type: 'post', status: 'success', latency_ms: Date.now() - t0, params: { limit_posts, total_links_checked: allUrls.length } });
|
|
2597
|
+
break;
|
|
2598
|
+
}
|
|
2599
|
+
|
|
2600
|
+
case 'wp_find_keyword_cannibalization': {
|
|
2601
|
+
validateInput(args, {
|
|
2602
|
+
limit: { type: 'number', min: 1, max: 500 },
|
|
2603
|
+
post_type: { type: 'string', enum: ['post', 'page', 'both'] },
|
|
2604
|
+
similarity_mode: { type: 'string', enum: ['exact', 'normalized'] },
|
|
2605
|
+
min_group_size: { type: 'number', min: 2 }
|
|
2606
|
+
});
|
|
2607
|
+
const { limit = 200, post_type = 'post', similarity_mode = 'normalized', min_group_size = 2 } = args;
|
|
2608
|
+
|
|
2609
|
+
const endpoints = post_type === 'both' ? ['/posts', '/pages'] : [post_type === 'page' ? '/pages' : '/posts'];
|
|
2610
|
+
let allPosts = [];
|
|
2611
|
+
for (const ep of endpoints) {
|
|
2612
|
+
const items = await wpApiCall(`${ep}?per_page=${Math.min(limit, 100)}&status=publish&_fields=id,title,link,date,meta`);
|
|
2613
|
+
allPosts = allPosts.concat(items);
|
|
2614
|
+
}
|
|
2615
|
+
allPosts = allPosts.slice(0, limit);
|
|
2616
|
+
|
|
2617
|
+
// Extract focus keyword
|
|
2618
|
+
function getFocusKeyword(meta) {
|
|
2619
|
+
if (!meta) return null;
|
|
2620
|
+
return meta.rank_math_focus_keyword || meta._yoast_wpseo_focuskw || meta._seopress_analysis_target_kw || meta._aioseo_keywords || null;
|
|
2621
|
+
}
|
|
2622
|
+
|
|
2623
|
+
// Normalize keyword
|
|
2624
|
+
function normalizeKeyword(kw) {
|
|
2625
|
+
return kw
|
|
2626
|
+
.toLowerCase()
|
|
2627
|
+
.normalize('NFD').replace(/[\u0300-\u036f]/g, '')
|
|
2628
|
+
.replace(/\b(le|la|les|un|une|des|de|du|en|et|ou|the|a|an|of|for|in|on|at)\b/g, '')
|
|
2629
|
+
.replace(/[^a-z0-9\s]/g, ' ')
|
|
2630
|
+
.replace(/\s+/g, ' ')
|
|
2631
|
+
.trim();
|
|
2632
|
+
}
|
|
2633
|
+
|
|
2634
|
+
// Group by keyword
|
|
2635
|
+
const kwMap = new Map(); // normalizedKw → [{id, title, url, date, original_keyword}]
|
|
2636
|
+
let postsWithKw = 0;
|
|
2637
|
+
let postsWithoutKw = 0;
|
|
2638
|
+
|
|
2639
|
+
for (const p of allPosts) {
|
|
2640
|
+
const raw = getFocusKeyword(p.meta);
|
|
2641
|
+
if (!raw || !raw.trim()) { postsWithoutKw++; continue; }
|
|
2642
|
+
postsWithKw++;
|
|
2643
|
+
const key = similarity_mode === 'exact' ? raw.trim() : normalizeKeyword(raw);
|
|
2644
|
+
if (!kwMap.has(key)) kwMap.set(key, []);
|
|
2645
|
+
kwMap.get(key).push({
|
|
2646
|
+
id: p.id, title: strip(p.title?.rendered || ''), url: p.link, date: p.date, original_keyword: raw.trim()
|
|
2647
|
+
});
|
|
2648
|
+
}
|
|
2649
|
+
|
|
2650
|
+
// Filter groups by min_group_size
|
|
2651
|
+
const groups = [];
|
|
2652
|
+
for (const [keyword, articles] of kwMap) {
|
|
2653
|
+
if (articles.length < min_group_size) continue;
|
|
2654
|
+
// Recommended action
|
|
2655
|
+
let recommended_action = 'differentiate';
|
|
2656
|
+
if (articles.length >= 3) {
|
|
2657
|
+
recommended_action = 'merge';
|
|
2658
|
+
} else if (articles.length === 2) {
|
|
2659
|
+
const d1 = new Date(articles[0].date);
|
|
2660
|
+
const d2 = new Date(articles[1].date);
|
|
2661
|
+
const diffDays = Math.abs(d1 - d2) / (1000 * 60 * 60 * 24);
|
|
2662
|
+
if (diffDays > 365) recommended_action = 'consolidate_301';
|
|
2663
|
+
else recommended_action = 'differentiate';
|
|
2664
|
+
}
|
|
2665
|
+
groups.push({
|
|
2666
|
+
keyword,
|
|
2667
|
+
variants: [...new Set(articles.map(a => a.original_keyword))],
|
|
2668
|
+
articles_count: articles.length,
|
|
2669
|
+
articles,
|
|
2670
|
+
recommended_action
|
|
2671
|
+
});
|
|
2672
|
+
}
|
|
2673
|
+
|
|
2674
|
+
let articlesAffected = 0;
|
|
2675
|
+
for (const g of groups) articlesAffected += g.articles_count;
|
|
2676
|
+
|
|
2677
|
+
result = json({
|
|
2678
|
+
total_posts_analyzed: allPosts.length,
|
|
2679
|
+
posts_with_keyword: postsWithKw,
|
|
2680
|
+
posts_without_keyword: postsWithoutKw,
|
|
2681
|
+
total_groups: groups.length,
|
|
2682
|
+
articles_affected: articlesAffected,
|
|
2683
|
+
cannibalization_groups: groups
|
|
2684
|
+
});
|
|
2685
|
+
auditLog({ tool: name, action: 'audit_seo', target_type: 'post', status: 'success', latency_ms: Date.now() - t0, params: { total_posts_analyzed: allPosts.length, total_groups: groups.length } });
|
|
2686
|
+
break;
|
|
2687
|
+
}
|
|
2688
|
+
|
|
2689
|
+
case 'wp_audit_taxonomies': {
|
|
2690
|
+
validateInput(args, {
|
|
2691
|
+
min_posts_threshold: { type: 'number', min: 1 }
|
|
2692
|
+
});
|
|
2693
|
+
const { check_tags = true, check_categories = true, min_posts_threshold = 2, detect_duplicates = true } = args;
|
|
2694
|
+
|
|
2695
|
+
// Levenshtein distance
|
|
2696
|
+
function levenshtein(a, b) {
|
|
2697
|
+
const m = a.length, n = b.length;
|
|
2698
|
+
const dp = Array.from({ length: m + 1 }, (_, i) =>
|
|
2699
|
+
Array.from({ length: n + 1 }, (_, j) => i === 0 ? j : j === 0 ? i : 0)
|
|
2700
|
+
);
|
|
2701
|
+
for (let i = 1; i <= m; i++)
|
|
2702
|
+
for (let j = 1; j <= n; j++)
|
|
2703
|
+
dp[i][j] = a[i - 1] === b[j - 1] ? dp[i - 1][j - 1]
|
|
2704
|
+
: 1 + Math.min(dp[i - 1][j], dp[i][j - 1], dp[i - 1][j - 1]);
|
|
2705
|
+
return dp[m][n];
|
|
2706
|
+
}
|
|
2707
|
+
|
|
2708
|
+
function normalizeTermName(name) {
|
|
2709
|
+
return name.toLowerCase()
|
|
2710
|
+
.normalize('NFD').replace(/[\u0300-\u036f]/g, '')
|
|
2711
|
+
.replace(/[^a-z0-9]/g, '');
|
|
2712
|
+
}
|
|
2713
|
+
|
|
2714
|
+
function auditTerms(terms, isCat) {
|
|
2715
|
+
const empty = [];
|
|
2716
|
+
const singlePost = [];
|
|
2717
|
+
const missingDesc = [];
|
|
2718
|
+
for (const t of terms) {
|
|
2719
|
+
const item = { id: t.id, name: t.name, slug: t.slug, count: t.count };
|
|
2720
|
+
if (t.count === 0) { empty.push({ ...item, issue_type: 'empty' }); }
|
|
2721
|
+
else if (t.count < min_posts_threshold) { singlePost.push({ ...item, issue_type: 'single_post' }); }
|
|
2722
|
+
if (isCat && (!t.description || t.description.trim() === '')) {
|
|
2723
|
+
missingDesc.push({ ...item, issue_type: 'missing_description' });
|
|
2724
|
+
}
|
|
2725
|
+
}
|
|
2726
|
+
|
|
2727
|
+
// Duplicate detection
|
|
2728
|
+
const duplicateGroups = [];
|
|
2729
|
+
if (detect_duplicates && terms.length > 1) {
|
|
2730
|
+
const normalized = terms.map(t => ({ ...t, norm: normalizeTermName(t.name) }));
|
|
2731
|
+
const used = new Set();
|
|
2732
|
+
for (let i = 0; i < normalized.length; i++) {
|
|
2733
|
+
if (used.has(i)) continue;
|
|
2734
|
+
const group = [normalized[i]];
|
|
2735
|
+
for (let j = i + 1; j < normalized.length; j++) {
|
|
2736
|
+
if (used.has(j)) continue;
|
|
2737
|
+
const isExact = normalized[i].norm === normalized[j].norm;
|
|
2738
|
+
const isNear = !isExact && normalized[i].norm.length >= 4 && normalized[j].norm.length >= 4 && levenshtein(normalized[i].norm, normalized[j].norm) <= 2;
|
|
2739
|
+
if (isExact || isNear) {
|
|
2740
|
+
group.push(normalized[j]);
|
|
2741
|
+
used.add(j);
|
|
2742
|
+
}
|
|
2743
|
+
}
|
|
2744
|
+
if (group.length >= 2) {
|
|
2745
|
+
used.add(i);
|
|
2746
|
+
const similarity = group.every(g => g.norm === group[0].norm) ? 'exact' : 'near';
|
|
2747
|
+
duplicateGroups.push({
|
|
2748
|
+
terms: group.map(g => ({ id: g.id, name: g.name, slug: g.slug, count: g.count, issue_type: 'duplicate' })),
|
|
2749
|
+
similarity
|
|
2750
|
+
});
|
|
2751
|
+
}
|
|
2752
|
+
}
|
|
2753
|
+
}
|
|
2754
|
+
|
|
2755
|
+
const issues = { empty, single_post: singlePost, duplicate_groups: duplicateGroups };
|
|
2756
|
+
if (isCat) issues.missing_description = missingDesc;
|
|
2757
|
+
return issues;
|
|
2758
|
+
}
|
|
2759
|
+
|
|
2760
|
+
let tagsResult = null;
|
|
2761
|
+
let catsResult = null;
|
|
2762
|
+
let totalIssues = 0;
|
|
2763
|
+
let totalTerms = 0;
|
|
2764
|
+
|
|
2765
|
+
if (check_tags) {
|
|
2766
|
+
const tags = await wpApiCall('/tags?per_page=100');
|
|
2767
|
+
const issues = auditTerms(tags, false);
|
|
2768
|
+
const tagIssueCount = issues.empty.length + issues.single_post.length + issues.duplicate_groups.reduce((s, g) => s + g.terms.length, 0);
|
|
2769
|
+
totalIssues += tagIssueCount;
|
|
2770
|
+
totalTerms += tags.length;
|
|
2771
|
+
tagsResult = { total: tags.length, issues };
|
|
2772
|
+
}
|
|
2773
|
+
|
|
2774
|
+
if (check_categories) {
|
|
2775
|
+
const cats = await wpApiCall('/categories?per_page=100');
|
|
2776
|
+
const issues = auditTerms(cats, true);
|
|
2777
|
+
const catIssueCount = issues.empty.length + issues.single_post.length + (issues.missing_description || []).length + issues.duplicate_groups.reduce((s, g) => s + g.terms.length, 0);
|
|
2778
|
+
totalIssues += catIssueCount;
|
|
2779
|
+
totalTerms += cats.length;
|
|
2780
|
+
catsResult = { total: cats.length, issues };
|
|
2781
|
+
}
|
|
2782
|
+
|
|
2783
|
+
const crawlWasteScore = totalTerms > 0 ? Math.min(100, Math.round((totalIssues / totalTerms) * 100)) : 0;
|
|
2784
|
+
|
|
2785
|
+
const output = { total_issues: totalIssues, crawl_waste_score: crawlWasteScore };
|
|
2786
|
+
if (tagsResult) output.tags = tagsResult;
|
|
2787
|
+
if (catsResult) output.categories = catsResult;
|
|
2788
|
+
|
|
2789
|
+
result = json(output);
|
|
2790
|
+
auditLog({ tool: name, action: 'audit_seo', target_type: 'taxonomy', status: 'success', latency_ms: Date.now() - t0, params: { total_issues: totalIssues, crawl_waste_score: crawlWasteScore } });
|
|
2791
|
+
break;
|
|
2792
|
+
}
|
|
2793
|
+
|
|
2794
|
+
case 'wp_audit_outbound_links': {
|
|
2795
|
+
validateInput(args, {
|
|
2796
|
+
limit: { type: 'number', min: 1, max: 100 },
|
|
2797
|
+
post_type: { type: 'string', enum: ['post', 'page'] },
|
|
2798
|
+
min_outbound: { type: 'number', min: 0 },
|
|
2799
|
+
max_outbound: { type: 'number', min: 1 },
|
|
2800
|
+
authoritative_domains: { type: 'array' }
|
|
2801
|
+
});
|
|
2802
|
+
const { limit = 30, post_type = 'post', min_outbound = 1, max_outbound = 15, authoritative_domains = ['wikipedia.org', 'gov', 'edu', 'who.int', 'pubmed.ncbi'] } = args;
|
|
2803
|
+
const endpoint = post_type === 'page' ? '/pages' : '/posts';
|
|
2804
|
+
const posts = await wpApiCall(`${endpoint}?per_page=${Math.min(limit, 100)}&status=publish&_fields=id,title,link,content`);
|
|
2805
|
+
|
|
2806
|
+
function extractDomain(url) {
|
|
2807
|
+
try { return new URL(url).hostname.replace(/^www\./, ''); }
|
|
2808
|
+
catch { return null; }
|
|
2809
|
+
}
|
|
2810
|
+
|
|
2811
|
+
function isAuthoritative(domain) {
|
|
2812
|
+
return authoritative_domains.some(ad => domain === ad || domain.endsWith('.' + ad));
|
|
2813
|
+
}
|
|
2814
|
+
|
|
2815
|
+
const domainCounts = new Map();
|
|
2816
|
+
const articles = [];
|
|
2817
|
+
let totalAuthoritative = 0;
|
|
2818
|
+
|
|
2819
|
+
for (const p of posts) {
|
|
2820
|
+
const html = p.content?.rendered || '';
|
|
2821
|
+
const wc = countWords(html);
|
|
2822
|
+
const siteHost = p.link ? extractDomain(p.link) : '';
|
|
2823
|
+
|
|
2824
|
+
// Extract all links
|
|
2825
|
+
const allLinks = [];
|
|
2826
|
+
const linkRegex = /<a\s[^>]*?href=["']([^"']+)["'][^>]*?>/gi;
|
|
2827
|
+
let m;
|
|
2828
|
+
while ((m = linkRegex.exec(html)) !== null) {
|
|
2829
|
+
try {
|
|
2830
|
+
const href = m[1];
|
|
2831
|
+
if (href.startsWith('http')) allLinks.push(href);
|
|
2832
|
+
} catch { /* skip */ }
|
|
2833
|
+
}
|
|
2834
|
+
|
|
2835
|
+
// Separate external links
|
|
2836
|
+
const externalLinks = [];
|
|
2837
|
+
const externalDomains = new Set();
|
|
2838
|
+
for (const link of allLinks) {
|
|
2839
|
+
const dom = extractDomain(link);
|
|
2840
|
+
if (!dom || dom === siteHost) continue;
|
|
2841
|
+
externalLinks.push(link);
|
|
2842
|
+
externalDomains.add(dom);
|
|
2843
|
+
domainCounts.set(dom, (domainCounts.get(dom) || 0) + 1);
|
|
2844
|
+
}
|
|
2845
|
+
|
|
2846
|
+
const outboundCount = externalLinks.length;
|
|
2847
|
+
let authCount = 0;
|
|
2848
|
+
for (const dom of externalDomains) {
|
|
2849
|
+
if (isAuthoritative(dom)) authCount++;
|
|
2850
|
+
}
|
|
2851
|
+
if (authCount > 0) totalAuthoritative++;
|
|
2852
|
+
|
|
2853
|
+
let status;
|
|
2854
|
+
if (outboundCount === 0) status = 'no_outbound';
|
|
2855
|
+
else if (outboundCount < min_outbound) status = 'insufficient';
|
|
2856
|
+
else if (outboundCount > max_outbound) status = 'excessive';
|
|
2857
|
+
else status = 'good';
|
|
2858
|
+
|
|
2859
|
+
articles.push({
|
|
2860
|
+
id: p.id, title: strip(p.title?.rendered || ''), url: p.link, word_count: wc,
|
|
2861
|
+
outbound_count: outboundCount,
|
|
2862
|
+
authoritative_count: authCount,
|
|
2863
|
+
outbound_ratio: wc > 0 ? Math.round((outboundCount / (wc / 100)) * 100) / 100 : 0,
|
|
2864
|
+
status,
|
|
2865
|
+
external_domains: [...externalDomains]
|
|
2866
|
+
});
|
|
2867
|
+
}
|
|
2868
|
+
|
|
2869
|
+
// Top cited domains
|
|
2870
|
+
const topDomains = [...domainCounts.entries()]
|
|
2871
|
+
.sort((a, b) => b[1] - a[1])
|
|
2872
|
+
.slice(0, 10)
|
|
2873
|
+
.map(([domain, count]) => ({ domain, count, is_authoritative: isAuthoritative(domain) }));
|
|
2874
|
+
|
|
2875
|
+
const byStatus = { no_outbound: 0, insufficient: 0, good: 0, excessive: 0 };
|
|
2876
|
+
for (const a of articles) byStatus[a.status]++;
|
|
2877
|
+
|
|
2878
|
+
const avgOutbound = articles.length > 0 ? Math.round((articles.reduce((s, a) => s + a.outbound_count, 0) / articles.length) * 100) / 100 : 0;
|
|
2879
|
+
|
|
2880
|
+
result = json({
|
|
2881
|
+
total_analyzed: posts.length,
|
|
2882
|
+
by_status: byStatus,
|
|
2883
|
+
top_cited_domains: topDomains,
|
|
2884
|
+
articles,
|
|
2885
|
+
average_outbound_per_post: avgOutbound,
|
|
2886
|
+
posts_with_authoritative_sources: totalAuthoritative
|
|
2887
|
+
});
|
|
2888
|
+
auditLog({ tool: name, action: 'audit_seo', target_type: 'post', status: 'success', latency_ms: Date.now() - t0, params: { total_analyzed: posts.length } });
|
|
2889
|
+
break;
|
|
2890
|
+
}
|
|
2891
|
+
|
|
2892
|
+
// ── CONTENT INTELLIGENCE v4.4 ──
|
|
2893
|
+
|
|
2894
|
+
case 'wp_get_content_brief': {
|
|
2895
|
+
validateInput(args, {
|
|
2896
|
+
id: { type: 'number', required: true, min: 1 },
|
|
2897
|
+
post_type: { type: 'string', enum: ['post', 'page'] }
|
|
2898
|
+
});
|
|
2899
|
+
const { id, post_type: briefPostType = 'post' } = args;
|
|
2900
|
+
const briefEndpoint = briefPostType === 'page' ? '/pages' : '/posts';
|
|
2901
|
+
const briefPost = await wpApiCall(`${briefEndpoint}/${id}?_fields=id,title,content,excerpt,slug,status,date,modified,link,categories,tags,author,featured_media,meta`);
|
|
2902
|
+
|
|
2903
|
+
const briefContent = briefPost.content?.rendered || '';
|
|
2904
|
+
const { url: briefSiteUrl } = getActiveAuth();
|
|
2905
|
+
|
|
2906
|
+
// Word count & readability
|
|
2907
|
+
const briefWordCount = countWords(briefContent);
|
|
2908
|
+
const readability = calculateReadabilityScore(briefContent);
|
|
2909
|
+
|
|
2910
|
+
// Structure
|
|
2911
|
+
const briefHeadings = extractHeadingsOutline(briefContent);
|
|
2912
|
+
const structure = detectContentSections(briefContent);
|
|
2913
|
+
|
|
2914
|
+
// Links
|
|
2915
|
+
const briefInternalLinks = extractInternalLinks(briefContent, briefSiteUrl);
|
|
2916
|
+
const briefExternalLinks = extractExternalLinks(briefContent, briefSiteUrl);
|
|
2917
|
+
|
|
2918
|
+
// SEO meta
|
|
2919
|
+
const briefMeta = briefPost.meta || {};
|
|
2920
|
+
const briefFocusKw = extractFocusKeyword(briefMeta);
|
|
2921
|
+
const seoTitle = briefMeta._yoast_wpseo_title || briefMeta.rank_math_title || briefMeta._seopress_titles_title || null;
|
|
2922
|
+
const seoDescription = briefMeta._yoast_wpseo_metadesc || briefMeta.rank_math_description || briefMeta._seopress_titles_desc || null;
|
|
2923
|
+
const seoCanonical = briefMeta._yoast_wpseo_canonical || briefMeta.rank_math_canonical_url || briefMeta._seopress_robots_canonical || null;
|
|
2924
|
+
|
|
2925
|
+
// Resolve categories to names
|
|
2926
|
+
const briefCatIds = briefPost.categories || [];
|
|
2927
|
+
let briefCategories = [];
|
|
2928
|
+
if (briefCatIds.length > 0) {
|
|
2929
|
+
try {
|
|
2930
|
+
const cats = await wpApiCall(`/categories?include=${briefCatIds.join(',')}&_fields=id,name`);
|
|
2931
|
+
briefCategories = cats.map(c => ({ id: c.id, name: c.name }));
|
|
2932
|
+
} catch { briefCategories = briefCatIds.map(cid => ({ id: cid, name: null })); }
|
|
2933
|
+
}
|
|
2934
|
+
|
|
2935
|
+
// Resolve tags to names
|
|
2936
|
+
const briefTagIds = briefPost.tags || [];
|
|
2937
|
+
let briefTags = [];
|
|
2938
|
+
if (briefTagIds.length > 0) {
|
|
2939
|
+
try {
|
|
2940
|
+
const tags = await wpApiCall(`/tags?include=${briefTagIds.join(',')}&_fields=id,name`);
|
|
2941
|
+
briefTags = tags.map(t => ({ id: t.id, name: t.name }));
|
|
2942
|
+
} catch { briefTags = briefTagIds.map(tid => ({ id: tid, name: null })); }
|
|
2943
|
+
}
|
|
2944
|
+
|
|
2945
|
+
result = json({
|
|
2946
|
+
id: briefPost.id,
|
|
2947
|
+
title: strip(briefPost.title?.rendered || ''),
|
|
2948
|
+
slug: briefPost.slug,
|
|
2949
|
+
status: briefPost.status,
|
|
2950
|
+
date: briefPost.date,
|
|
2951
|
+
modified: briefPost.modified,
|
|
2952
|
+
link: briefPost.link,
|
|
2953
|
+
author: briefPost.author,
|
|
2954
|
+
word_count: briefWordCount,
|
|
2955
|
+
readability: { score: readability.score, level: readability.level, avg_words_per_sentence: readability.avg_words_per_sentence },
|
|
2956
|
+
seo: {
|
|
2957
|
+
title: seoTitle,
|
|
2958
|
+
description: seoDescription,
|
|
2959
|
+
focus_keyword: briefFocusKw,
|
|
2960
|
+
canonical: seoCanonical
|
|
2961
|
+
},
|
|
2962
|
+
structure: {
|
|
2963
|
+
headings: briefHeadings,
|
|
2964
|
+
has_intro: structure.has_intro,
|
|
2965
|
+
has_conclusion: structure.has_conclusion,
|
|
2966
|
+
has_faq: structure.has_faq,
|
|
2967
|
+
lists_count: structure.lists_count,
|
|
2968
|
+
tables_count: structure.tables_count,
|
|
2969
|
+
images_count: structure.images_count
|
|
2970
|
+
},
|
|
2971
|
+
categories: briefCategories,
|
|
2972
|
+
tags: briefTags,
|
|
2973
|
+
internal_links: { count: briefInternalLinks.length, links: briefInternalLinks },
|
|
2974
|
+
external_links: { count: briefExternalLinks.length, links: briefExternalLinks },
|
|
2975
|
+
featured_media: briefPost.featured_media || null
|
|
2976
|
+
});
|
|
2977
|
+
auditLog({ tool: name, target: id, target_type: briefPostType, action: 'content_brief', status: 'success', latency_ms: Date.now() - t0, params: { id, post_type: briefPostType } });
|
|
2978
|
+
break;
|
|
2979
|
+
}
|
|
2980
|
+
|
|
2981
|
+
case 'wp_extract_post_outline': {
|
|
2982
|
+
validateInput(args, {
|
|
2983
|
+
category_id: { type: 'number', required: true, min: 1 },
|
|
2984
|
+
post_type: { type: 'string', enum: ['post', 'page'] },
|
|
2985
|
+
limit: { type: 'number', min: 1, max: 50 }
|
|
2986
|
+
});
|
|
2987
|
+
const { category_id, post_type: outlinePostType = 'post', limit: outlineLimit = 10 } = args;
|
|
2988
|
+
const outlineEndpoint = outlinePostType === 'page' ? '/pages' : '/posts';
|
|
2989
|
+
let outlineUrl = `${outlineEndpoint}?per_page=${Math.min(outlineLimit, 50)}&status=publish&_fields=id,title,content,slug,link,meta`;
|
|
2990
|
+
if (outlinePostType !== 'page') outlineUrl += `&categories=${category_id}`;
|
|
2991
|
+
const outlinePosts = await wpApiCall(outlineUrl);
|
|
2992
|
+
|
|
2993
|
+
const outlines = [];
|
|
2994
|
+
const h2Counter = new Map();
|
|
2995
|
+
|
|
2996
|
+
for (const p of outlinePosts) {
|
|
2997
|
+
const html = p.content?.rendered || '';
|
|
2998
|
+
const headings = extractHeadingsOutline(html);
|
|
2999
|
+
const wc = countWords(html);
|
|
3000
|
+
|
|
3001
|
+
// Count H2 patterns
|
|
3002
|
+
for (const h of headings) {
|
|
3003
|
+
if (h.level === 2) {
|
|
3004
|
+
const normalized = h.text.toLowerCase().trim();
|
|
3005
|
+
if (normalized) h2Counter.set(normalized, (h2Counter.get(normalized) || 0) + 1);
|
|
3006
|
+
}
|
|
3007
|
+
}
|
|
3008
|
+
|
|
3009
|
+
outlines.push({
|
|
3010
|
+
id: p.id,
|
|
3011
|
+
title: strip(p.title?.rendered || ''),
|
|
3012
|
+
slug: p.slug,
|
|
3013
|
+
link: p.link,
|
|
3014
|
+
word_count: wc,
|
|
3015
|
+
headings_count: headings.length,
|
|
3016
|
+
headings
|
|
3017
|
+
});
|
|
3018
|
+
}
|
|
3019
|
+
|
|
3020
|
+
// Aggregated stats
|
|
3021
|
+
const totalWords = outlines.reduce((s, o) => s + o.word_count, 0);
|
|
3022
|
+
const totalHeadings = outlines.reduce((s, o) => s + o.headings_count, 0);
|
|
3023
|
+
const avgWordCount = outlines.length > 0 ? Math.round(totalWords / outlines.length) : 0;
|
|
3024
|
+
const avgHeadingsCount = outlines.length > 0 ? Math.round(totalHeadings / outlines.length) : 0;
|
|
3025
|
+
|
|
3026
|
+
// Common H2 patterns (top 10)
|
|
3027
|
+
const commonH2 = [...h2Counter.entries()]
|
|
3028
|
+
.sort((a, b) => b[1] - a[1])
|
|
3029
|
+
.slice(0, 10)
|
|
3030
|
+
.map(([text, frequency]) => ({ text, frequency }));
|
|
3031
|
+
|
|
3032
|
+
result = json({
|
|
3033
|
+
category_id,
|
|
3034
|
+
posts_analyzed: outlines.length,
|
|
3035
|
+
avg_word_count: avgWordCount,
|
|
3036
|
+
avg_headings_count: avgHeadingsCount,
|
|
3037
|
+
common_h2_patterns: commonH2,
|
|
3038
|
+
outlines
|
|
3039
|
+
});
|
|
3040
|
+
auditLog({ tool: name, action: 'extract_outline', status: 'success', latency_ms: Date.now() - t0, params: { category_id, post_type: outlinePostType, limit: outlineLimit } });
|
|
3041
|
+
break;
|
|
3042
|
+
}
|
|
3043
|
+
|
|
3044
|
+
// ── wp_audit_readability ──
|
|
3045
|
+
case 'wp_audit_readability': {
|
|
3046
|
+
validateInput(args, {
|
|
3047
|
+
limit: { type: 'number', min: 1, max: 200 },
|
|
3048
|
+
post_type: { type: 'string', enum: ['post', 'page'] },
|
|
3049
|
+
category_id: { type: 'number' },
|
|
3050
|
+
min_words: { type: 'number', min: 0 }
|
|
3051
|
+
});
|
|
3052
|
+
const { limit: arLimit = 50, post_type: arPostType = 'post', category_id: arCatId, min_words: arMinWords = 100 } = args;
|
|
3053
|
+
const arEndpoint = arPostType === 'page' ? '/pages' : '/posts';
|
|
3054
|
+
let arUrl = `${arEndpoint}?per_page=${Math.min(arLimit, 200)}&status=publish&_fields=id,title,content,slug,link,categories,meta`;
|
|
3055
|
+
if (arCatId && arPostType !== 'page') arUrl += `&categories=${arCatId}`;
|
|
3056
|
+
const arPosts = await wpApiCall(arUrl);
|
|
3057
|
+
|
|
3058
|
+
const arResults = [];
|
|
3059
|
+
for (const p of arPosts) {
|
|
3060
|
+
const html = p.content?.rendered || '';
|
|
3061
|
+
const wc = countWords(html);
|
|
3062
|
+
if (wc < arMinWords) continue;
|
|
3063
|
+
const plainText = strip(html);
|
|
3064
|
+
const readabilityResult = calculateReadabilityScore(html);
|
|
3065
|
+
const transitionResult = extractTransitionWords(plainText);
|
|
3066
|
+
const passiveResult = countPassiveSentences(plainText);
|
|
3067
|
+
|
|
3068
|
+
const issues = [];
|
|
3069
|
+
if (readabilityResult.score < 20) issues.push('very_low_readability');
|
|
3070
|
+
else if (readabilityResult.score < 40) issues.push('low_readability');
|
|
3071
|
+
if (transitionResult.count === 0) issues.push('no_transition_words');
|
|
3072
|
+
else if (transitionResult.density < 0.1) issues.push('low_transition_density');
|
|
3073
|
+
if (passiveResult.ratio > 0.3) issues.push('high_passive_ratio');
|
|
3074
|
+
|
|
3075
|
+
arResults.push({
|
|
3076
|
+
id: p.id,
|
|
3077
|
+
title: strip(p.title?.rendered || ''),
|
|
3078
|
+
slug: p.slug,
|
|
3079
|
+
link: p.link,
|
|
3080
|
+
word_count: wc,
|
|
3081
|
+
readability: {
|
|
3082
|
+
score: readabilityResult.score,
|
|
3083
|
+
level: readabilityResult.level,
|
|
3084
|
+
avg_words_per_sentence: readabilityResult.avg_words_per_sentence,
|
|
3085
|
+
avg_syllables_per_word: readabilityResult.avg_syllables_per_word
|
|
3086
|
+
},
|
|
3087
|
+
transition_density: transitionResult.density,
|
|
3088
|
+
passive_ratio: passiveResult.ratio,
|
|
3089
|
+
issues
|
|
3090
|
+
});
|
|
3091
|
+
}
|
|
3092
|
+
|
|
3093
|
+
// Sort by score ASC (worst first)
|
|
3094
|
+
arResults.sort((a, b) => a.readability.score - b.readability.score);
|
|
3095
|
+
|
|
3096
|
+
// Aggregation
|
|
3097
|
+
const arTotal = arResults.length;
|
|
3098
|
+
const arAvgScore = arTotal > 0 ? Math.round(arResults.reduce((s, r) => s + r.readability.score, 0) / arTotal * 10) / 10 : 0;
|
|
3099
|
+
const arAvgTD = arTotal > 0 ? Math.round(arResults.reduce((s, r) => s + r.transition_density, 0) / arTotal * 1000) / 1000 : 0;
|
|
3100
|
+
const arAvgPR = arTotal > 0 ? Math.round(arResults.reduce((s, r) => s + r.passive_ratio, 0) / arTotal * 1000) / 1000 : 0;
|
|
3101
|
+
const distribution = { 'très facile': 0, 'facile': 0, 'standard': 0, 'difficile': 0, 'très difficile': 0 };
|
|
3102
|
+
for (const r of arResults) { distribution[r.readability.level] = (distribution[r.readability.level] || 0) + 1; }
|
|
3103
|
+
|
|
3104
|
+
result = json({
|
|
3105
|
+
total_analyzed: arTotal,
|
|
3106
|
+
avg_readability_score: arAvgScore,
|
|
3107
|
+
distribution,
|
|
3108
|
+
avg_transition_density: arAvgTD,
|
|
3109
|
+
avg_passive_ratio: arAvgPR,
|
|
3110
|
+
posts: arResults
|
|
3111
|
+
});
|
|
3112
|
+
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 } });
|
|
3113
|
+
break;
|
|
3114
|
+
}
|
|
3115
|
+
|
|
3116
|
+
// ── wp_audit_update_frequency ──
|
|
3117
|
+
case 'wp_audit_update_frequency': {
|
|
3118
|
+
validateInput(args, {
|
|
3119
|
+
days_threshold: { type: 'number', min: 1 },
|
|
3120
|
+
limit: { type: 'number', min: 1, max: 200 },
|
|
3121
|
+
post_type: { type: 'string', enum: ['post', 'page'] },
|
|
3122
|
+
include_seo_score: { type: 'boolean' }
|
|
3123
|
+
});
|
|
3124
|
+
const { days_threshold: aufDays = 180, limit: aufLimit = 50, post_type: aufPostType = 'post', include_seo_score: aufIncludeSeo = true } = args;
|
|
3125
|
+
const aufEndpoint = aufPostType === 'page' ? '/pages' : '/posts';
|
|
3126
|
+
const aufUrl = `${aufEndpoint}?per_page=${Math.min(aufLimit, 200)}&status=publish&_fields=id,title,slug,link,date,modified,content,meta,categories`;
|
|
3127
|
+
const aufAllPosts = await wpApiCall(aufUrl);
|
|
3128
|
+
|
|
3129
|
+
const aufNow = Date.now();
|
|
3130
|
+
const aufFiltered = [];
|
|
3131
|
+
|
|
3132
|
+
for (const p of aufAllPosts) {
|
|
3133
|
+
const daysSince = Math.floor((aufNow - new Date(p.modified).getTime()) / 86400000);
|
|
3134
|
+
if (daysSince < aufDays) continue;
|
|
3135
|
+
|
|
3136
|
+
const meta = p.meta || {};
|
|
3137
|
+
const html = p.content?.rendered || '';
|
|
3138
|
+
const wc = countWords(html);
|
|
3139
|
+
|
|
3140
|
+
let seoScore = null;
|
|
3141
|
+
const issues = ['outdated_180d'];
|
|
3142
|
+
if (daysSince >= 365) issues.push('outdated_365d');
|
|
3143
|
+
if (wc < 300) issues.push('thin_content');
|
|
3144
|
+
|
|
3145
|
+
if (aufIncludeSeo) {
|
|
3146
|
+
const seoTitle = meta.rank_math_title || meta._yoast_wpseo_title || meta._seopress_titles_title || meta._aioseo_title || null;
|
|
3147
|
+
const seoDesc = meta.rank_math_description || meta._yoast_wpseo_metadesc || meta._seopress_titles_desc || meta._aioseo_description || null;
|
|
3148
|
+
const focusKw = extractFocusKeyword(meta);
|
|
3149
|
+
seoScore = 100;
|
|
3150
|
+
if (!seoTitle) { seoScore -= 30; issues.push('missing_seo_title'); }
|
|
3151
|
+
if (!seoDesc) { seoScore -= 30; issues.push('missing_meta_description'); }
|
|
3152
|
+
if (!focusKw) seoScore -= 20;
|
|
3153
|
+
if (seoTitle && focusKw && !seoTitle.toLowerCase().includes(focusKw.toLowerCase())) seoScore -= 10;
|
|
3154
|
+
if (seoScore < 70) issues.push('low_seo_score');
|
|
3155
|
+
}
|
|
3156
|
+
|
|
3157
|
+
const priority = daysSince * (aufIncludeSeo && seoScore !== null ? (100 - seoScore) / 100 : 1) * (wc < 300 ? 1.5 : 1);
|
|
3158
|
+
|
|
3159
|
+
aufFiltered.push({
|
|
3160
|
+
id: p.id,
|
|
3161
|
+
title: strip(p.title?.rendered || ''),
|
|
3162
|
+
slug: p.slug,
|
|
3163
|
+
link: p.link,
|
|
3164
|
+
date: p.date,
|
|
3165
|
+
modified: p.modified,
|
|
3166
|
+
days_since_modified: daysSince,
|
|
3167
|
+
word_count: wc,
|
|
3168
|
+
seo_score: seoScore,
|
|
3169
|
+
priority_score: Math.round(priority * 10) / 10,
|
|
3170
|
+
issues
|
|
3171
|
+
});
|
|
3172
|
+
}
|
|
3173
|
+
|
|
3174
|
+
aufFiltered.sort((a, b) => b.priority_score - a.priority_score);
|
|
3175
|
+
|
|
3176
|
+
result = json({
|
|
3177
|
+
days_threshold: aufDays,
|
|
3178
|
+
total_published: aufAllPosts.length,
|
|
3179
|
+
outdated_count: aufFiltered.length,
|
|
3180
|
+
outdated_ratio: aufAllPosts.length > 0 ? Math.round(aufFiltered.length / aufAllPosts.length * 100) / 100 : 0,
|
|
3181
|
+
posts: aufFiltered
|
|
3182
|
+
});
|
|
3183
|
+
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 } });
|
|
3184
|
+
break;
|
|
3185
|
+
}
|
|
3186
|
+
|
|
3187
|
+
// ── wp_build_link_map ──
|
|
3188
|
+
case 'wp_build_link_map': {
|
|
3189
|
+
validateInput(args, {
|
|
3190
|
+
post_type: { type: 'string', enum: ['post', 'page', 'both'] },
|
|
3191
|
+
limit: { type: 'number', min: 1, max: 200 },
|
|
3192
|
+
category_id: { type: 'number' }
|
|
3193
|
+
});
|
|
3194
|
+
const { post_type: lmPostType = 'post', limit: lmLimit = 50, category_id: lmCatId } = args;
|
|
3195
|
+
const { url: lmSiteUrl } = getActiveAuth();
|
|
3196
|
+
const perPage = Math.min(lmLimit, 200);
|
|
3197
|
+
const fields = '_fields=id,title,slug,link,content,categories';
|
|
3198
|
+
|
|
3199
|
+
let lmPosts = [];
|
|
3200
|
+
if (lmPostType === 'both') {
|
|
3201
|
+
let postsUrl = `/posts?per_page=${perPage}&status=publish&${fields}`;
|
|
3202
|
+
if (lmCatId) postsUrl += `&categories=${lmCatId}`;
|
|
3203
|
+
const [posts, pages] = await Promise.all([
|
|
3204
|
+
wpApiCall(postsUrl),
|
|
3205
|
+
wpApiCall(`/pages?per_page=${perPage}&status=publish&${fields}`)
|
|
3206
|
+
]);
|
|
3207
|
+
lmPosts = [...posts, ...pages];
|
|
3208
|
+
} else {
|
|
3209
|
+
const ep = lmPostType === 'page' ? '/pages' : '/posts';
|
|
3210
|
+
let postsUrl = `${ep}?per_page=${perPage}&status=publish&${fields}`;
|
|
3211
|
+
if (lmCatId && lmPostType !== 'page') postsUrl += `&categories=${lmCatId}`;
|
|
3212
|
+
lmPosts = await wpApiCall(postsUrl);
|
|
3213
|
+
}
|
|
3214
|
+
|
|
3215
|
+
// Build lookup maps
|
|
3216
|
+
const postMap = new Map();
|
|
3217
|
+
const slugToId = new Map();
|
|
3218
|
+
for (const p of lmPosts) {
|
|
3219
|
+
postMap.set(p.id, {
|
|
3220
|
+
id: p.id,
|
|
3221
|
+
title: strip(p.title?.rendered || ''),
|
|
3222
|
+
slug: p.slug,
|
|
3223
|
+
link: p.link,
|
|
3224
|
+
outbound_links: [],
|
|
3225
|
+
inbound_count: 0,
|
|
3226
|
+
unresolved_links: 0
|
|
3227
|
+
});
|
|
3228
|
+
slugToId.set(p.slug, p.id);
|
|
3229
|
+
if (p.link) {
|
|
3230
|
+
try { slugToId.set(new URL(p.link).pathname.replace(/\/+$/, ''), p.id); } catch { /* skip */ }
|
|
3231
|
+
}
|
|
3232
|
+
}
|
|
3233
|
+
|
|
3234
|
+
// Extract links and resolve targets
|
|
3235
|
+
let totalLinks = 0;
|
|
3236
|
+
const linkMatrix = {};
|
|
3237
|
+
|
|
3238
|
+
for (const p of lmPosts) {
|
|
3239
|
+
const html = p.content?.rendered || '';
|
|
3240
|
+
const internalLinks = extractInternalLinks(html, lmSiteUrl);
|
|
3241
|
+
const resolvedTargets = [];
|
|
3242
|
+
let unresolvedCount = 0;
|
|
3243
|
+
|
|
3244
|
+
for (const link of internalLinks) {
|
|
3245
|
+
let targetId = null;
|
|
3246
|
+
// Try to match by URL path
|
|
3247
|
+
try {
|
|
3248
|
+
const linkPath = new URL(link.url || link).pathname.replace(/\/+$/, '');
|
|
3249
|
+
targetId = slugToId.get(linkPath) || null;
|
|
3250
|
+
if (!targetId) {
|
|
3251
|
+
// Try matching just the last segment as slug
|
|
3252
|
+
const lastSeg = linkPath.split('/').filter(Boolean).pop();
|
|
3253
|
+
if (lastSeg) targetId = slugToId.get(lastSeg) || null;
|
|
3254
|
+
}
|
|
3255
|
+
} catch { /* skip */ }
|
|
3256
|
+
|
|
3257
|
+
if (targetId && targetId !== p.id && postMap.has(targetId)) {
|
|
3258
|
+
const target = postMap.get(targetId);
|
|
3259
|
+
resolvedTargets.push({
|
|
3260
|
+
target_id: targetId,
|
|
3261
|
+
target_title: target.title,
|
|
3262
|
+
anchor_text: link.anchor_text || link.text || ''
|
|
3263
|
+
});
|
|
3264
|
+
target.inbound_count++;
|
|
3265
|
+
totalLinks++;
|
|
3266
|
+
} else {
|
|
3267
|
+
unresolvedCount++;
|
|
3268
|
+
}
|
|
3269
|
+
}
|
|
3270
|
+
|
|
3271
|
+
const entry = postMap.get(p.id);
|
|
3272
|
+
entry.outbound_links = resolvedTargets;
|
|
3273
|
+
entry.unresolved_links = unresolvedCount;
|
|
3274
|
+
if (resolvedTargets.length > 0) {
|
|
3275
|
+
linkMatrix[p.id] = resolvedTargets.map(l => l.target_id);
|
|
3276
|
+
}
|
|
3277
|
+
}
|
|
3278
|
+
|
|
3279
|
+
// Simplified PageRank (10 iterations, damping 0.85)
|
|
3280
|
+
const N = lmPosts.length;
|
|
3281
|
+
const damping = 0.85;
|
|
3282
|
+
let scores = new Map();
|
|
3283
|
+
for (const p of lmPosts) scores.set(p.id, 1 / N);
|
|
3284
|
+
|
|
3285
|
+
for (let iter = 0; iter < 10; iter++) {
|
|
3286
|
+
const newScores = new Map();
|
|
3287
|
+
for (const p of lmPosts) newScores.set(p.id, (1 - damping) / N);
|
|
3288
|
+
for (const p of lmPosts) {
|
|
3289
|
+
const entry = postMap.get(p.id);
|
|
3290
|
+
const outCount = entry.outbound_links.length;
|
|
3291
|
+
if (outCount > 0) {
|
|
3292
|
+
const share = (damping * scores.get(p.id)) / outCount;
|
|
3293
|
+
for (const link of entry.outbound_links) {
|
|
3294
|
+
newScores.set(link.target_id, (newScores.get(link.target_id) || 0) + share);
|
|
3295
|
+
}
|
|
3296
|
+
}
|
|
3297
|
+
}
|
|
3298
|
+
scores = newScores;
|
|
3299
|
+
}
|
|
3300
|
+
|
|
3301
|
+
// Normalize to 0-100
|
|
3302
|
+
let maxScore = 0;
|
|
3303
|
+
for (const s of scores.values()) { if (s > maxScore) maxScore = s; }
|
|
3304
|
+
const normalizedScores = new Map();
|
|
3305
|
+
for (const [id, s] of scores) {
|
|
3306
|
+
normalizedScores.set(id, maxScore > 0 ? Math.round((s / maxScore) * 100 * 10) / 10 : 0);
|
|
3307
|
+
}
|
|
3308
|
+
|
|
3309
|
+
// Build result array
|
|
3310
|
+
const lmResults = [];
|
|
3311
|
+
for (const [id, entry] of postMap) {
|
|
3312
|
+
lmResults.push({
|
|
3313
|
+
id,
|
|
3314
|
+
title: entry.title,
|
|
3315
|
+
slug: entry.slug,
|
|
3316
|
+
link: entry.link,
|
|
3317
|
+
outbound_count: entry.outbound_links.length,
|
|
3318
|
+
inbound_count: entry.inbound_count,
|
|
3319
|
+
pagerank_score: normalizedScores.get(id) || 0,
|
|
3320
|
+
is_orphan: entry.inbound_count === 0,
|
|
3321
|
+
outbound_links: entry.outbound_links,
|
|
3322
|
+
unresolved_links: entry.unresolved_links
|
|
3323
|
+
});
|
|
3324
|
+
}
|
|
3325
|
+
|
|
3326
|
+
lmResults.sort((a, b) => b.pagerank_score - a.pagerank_score);
|
|
3327
|
+
|
|
3328
|
+
const orphanCount = lmResults.filter(r => r.is_orphan).length;
|
|
3329
|
+
result = json({
|
|
3330
|
+
total_analyzed: lmPosts.length,
|
|
3331
|
+
total_internal_links: totalLinks,
|
|
3332
|
+
avg_outbound_per_post: lmPosts.length > 0 ? Math.round(totalLinks / lmPosts.length * 10) / 10 : 0,
|
|
3333
|
+
orphan_posts: orphanCount,
|
|
3334
|
+
posts: lmResults,
|
|
3335
|
+
link_matrix: linkMatrix
|
|
3336
|
+
});
|
|
3337
|
+
auditLog({ tool: name, action: 'build_link_map', status: 'success', latency_ms: Date.now() - t0, params: { post_type: lmPostType, limit: lmLimit, category_id: lmCatId } });
|
|
3338
|
+
break;
|
|
3339
|
+
}
|
|
3340
|
+
|
|
3341
|
+
// ── wp_audit_anchor_texts ──
|
|
3342
|
+
case 'wp_audit_anchor_texts': {
|
|
3343
|
+
validateInput(args, {
|
|
3344
|
+
limit: { type: 'number', min: 1, max: 200 },
|
|
3345
|
+
post_type: { type: 'string', enum: ['post', 'page', 'both'] }
|
|
3346
|
+
});
|
|
3347
|
+
const { limit: aatLimit = 50, post_type: aatPostType = 'post' } = args;
|
|
3348
|
+
const { url: aatSiteUrl } = getActiveAuth();
|
|
3349
|
+
const aatPerPage = Math.min(aatLimit, 200);
|
|
3350
|
+
const aatFields = '_fields=id,title,slug,link,content';
|
|
3351
|
+
|
|
3352
|
+
let aatPosts = [];
|
|
3353
|
+
if (aatPostType === 'both') {
|
|
3354
|
+
const [posts, pages] = await Promise.all([
|
|
3355
|
+
wpApiCall(`/posts?per_page=${aatPerPage}&status=publish&${aatFields}`),
|
|
3356
|
+
wpApiCall(`/pages?per_page=${aatPerPage}&status=publish&${aatFields}`)
|
|
3357
|
+
]);
|
|
3358
|
+
aatPosts = [...posts, ...pages];
|
|
3359
|
+
} else {
|
|
3360
|
+
const ep = aatPostType === 'page' ? '/pages' : '/posts';
|
|
3361
|
+
aatPosts = await wpApiCall(`${ep}?per_page=${aatPerPage}&status=publish&${aatFields}`);
|
|
3362
|
+
}
|
|
3363
|
+
|
|
3364
|
+
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'];
|
|
3365
|
+
|
|
3366
|
+
// Corpus-wide anchor map: anchor_text_lower → { count, posts: Set, targets: Set }
|
|
3367
|
+
const anchorMap = new Map();
|
|
3368
|
+
const postAnchors = new Map(); // postId → { anchors: [{text,type}], total, unique }
|
|
3369
|
+
|
|
3370
|
+
let totalInternalLinks = 0;
|
|
3371
|
+
|
|
3372
|
+
for (const p of aatPosts) {
|
|
3373
|
+
const html = p.content?.rendered || '';
|
|
3374
|
+
const links = extractInternalLinks(html, aatSiteUrl);
|
|
3375
|
+
const postAnchorList = [];
|
|
3376
|
+
|
|
3377
|
+
for (const link of links) {
|
|
3378
|
+
const text = (link.anchor_text || '').trim();
|
|
3379
|
+
const lower = text.toLowerCase();
|
|
3380
|
+
totalInternalLinks++;
|
|
3381
|
+
|
|
3382
|
+
postAnchorList.push({ text, lower, href: link.url });
|
|
3383
|
+
|
|
3384
|
+
if (!anchorMap.has(lower)) {
|
|
3385
|
+
anchorMap.set(lower, { count: 0, posts: new Set(), targets: new Set() });
|
|
3386
|
+
}
|
|
3387
|
+
const entry = anchorMap.get(lower);
|
|
3388
|
+
entry.count++;
|
|
3389
|
+
entry.posts.add(p.id);
|
|
3390
|
+
entry.targets.add(link.url);
|
|
3391
|
+
}
|
|
3392
|
+
|
|
3393
|
+
postAnchors.set(p.id, postAnchorList);
|
|
3394
|
+
}
|
|
3395
|
+
|
|
3396
|
+
// Classify each anchor
|
|
3397
|
+
const anchorHealth = { healthy: 0, generic: 0, over_optimized: 0, image_link: 0 };
|
|
3398
|
+
const genericAnchorsMap = new Map(); // text → { count, post_ids }
|
|
3399
|
+
const overOptimized = [];
|
|
3400
|
+
|
|
3401
|
+
for (const [lower, entry] of anchorMap) {
|
|
3402
|
+
if (lower === '' || /^\s*$/.test(lower)) {
|
|
3403
|
+
anchorHealth.image_link += entry.count;
|
|
3404
|
+
} else if (GENERIC_ANCHORS.includes(lower)) {
|
|
3405
|
+
anchorHealth.generic += entry.count;
|
|
3406
|
+
genericAnchorsMap.set(lower, { text: lower, count: entry.count, post_ids: [...entry.posts] });
|
|
3407
|
+
} else if (entry.count > 3 && entry.targets.size > 1) {
|
|
3408
|
+
anchorHealth.over_optimized += entry.count;
|
|
3409
|
+
overOptimized.push({ text: lower, count: entry.count, target_count: entry.targets.size, post_ids: [...entry.posts] });
|
|
3410
|
+
} else {
|
|
3411
|
+
anchorHealth.healthy += entry.count;
|
|
3412
|
+
}
|
|
3413
|
+
}
|
|
3414
|
+
|
|
3415
|
+
// Top 10 generic anchors sorted by count DESC
|
|
3416
|
+
const genericAnchors = [...genericAnchorsMap.values()].sort((a, b) => b.count - a.count).slice(0, 10);
|
|
3417
|
+
|
|
3418
|
+
// Per-post results
|
|
3419
|
+
const aatResults = [];
|
|
3420
|
+
for (const p of aatPosts) {
|
|
3421
|
+
const anchors = postAnchors.get(p.id) || [];
|
|
3422
|
+
const total = anchors.length;
|
|
3423
|
+
const uniqueTexts = new Set(anchors.map(a => a.lower));
|
|
3424
|
+
const unique = uniqueTexts.size;
|
|
3425
|
+
const ds = total > 0 ? unique / total : 1;
|
|
3426
|
+
|
|
3427
|
+
const issues = [];
|
|
3428
|
+
const hasGeneric = anchors.some(a => GENERIC_ANCHORS.includes(a.lower));
|
|
3429
|
+
const hasOverOpt = anchors.some(a => {
|
|
3430
|
+
const e = anchorMap.get(a.lower);
|
|
3431
|
+
return e && e.count > 3 && e.targets.size > 1 && a.lower !== '' && !GENERIC_ANCHORS.includes(a.lower);
|
|
3432
|
+
});
|
|
3433
|
+
const hasImageLink = anchors.some(a => a.lower === '' || /^\s*$/.test(a.lower));
|
|
3434
|
+
if (hasGeneric) issues.push('has_generic_anchors');
|
|
3435
|
+
if (hasOverOpt) issues.push('has_over_optimized_anchors');
|
|
3436
|
+
if (hasImageLink) issues.push('has_image_links');
|
|
3437
|
+
if (total > 0 && ds < 0.5) issues.push('low_anchor_diversity');
|
|
3438
|
+
|
|
3439
|
+
aatResults.push({
|
|
3440
|
+
id: p.id,
|
|
3441
|
+
title: strip(p.title?.rendered || ''),
|
|
3442
|
+
slug: p.slug,
|
|
3443
|
+
internal_links_count: total,
|
|
3444
|
+
unique_anchors_count: unique,
|
|
3445
|
+
diversity_score: Math.round(ds * 100) / 100,
|
|
3446
|
+
issues
|
|
3447
|
+
});
|
|
3448
|
+
}
|
|
3449
|
+
|
|
3450
|
+
aatResults.sort((a, b) => a.diversity_score - b.diversity_score);
|
|
3451
|
+
|
|
3452
|
+
result = json({
|
|
3453
|
+
total_analyzed: aatPosts.length,
|
|
3454
|
+
total_internal_links: totalInternalLinks,
|
|
3455
|
+
total_unique_anchors: anchorMap.size,
|
|
3456
|
+
anchor_health: anchorHealth,
|
|
3457
|
+
generic_anchors: genericAnchors,
|
|
3458
|
+
over_optimized_anchors: overOptimized,
|
|
3459
|
+
posts: aatResults
|
|
3460
|
+
});
|
|
3461
|
+
auditLog({ tool: name, action: 'audit_anchor_texts', status: 'success', latency_ms: Date.now() - t0, params: { limit: aatLimit, post_type: aatPostType } });
|
|
3462
|
+
break;
|
|
3463
|
+
}
|
|
3464
|
+
|
|
3465
|
+
// ── wp_audit_schema_markup ──
|
|
3466
|
+
case 'wp_audit_schema_markup': {
|
|
3467
|
+
validateInput(args, {
|
|
3468
|
+
limit: { type: 'number', min: 1, max: 200 },
|
|
3469
|
+
post_type: { type: 'string', enum: ['post', 'page', 'both'] }
|
|
3470
|
+
});
|
|
3471
|
+
const { limit: asmLimit = 50, post_type: asmPostType = 'post' } = args;
|
|
3472
|
+
const asmPerPage = Math.min(asmLimit, 200);
|
|
3473
|
+
const asmFields = '_fields=id,title,slug,link,content';
|
|
3474
|
+
|
|
3475
|
+
let asmPosts = [];
|
|
3476
|
+
if (asmPostType === 'both') {
|
|
3477
|
+
const [posts, pages] = await Promise.all([
|
|
3478
|
+
wpApiCall(`/posts?per_page=${asmPerPage}&status=publish&${asmFields}`),
|
|
3479
|
+
wpApiCall(`/pages?per_page=${asmPerPage}&status=publish&${asmFields}`)
|
|
3480
|
+
]);
|
|
3481
|
+
asmPosts = [...posts, ...pages];
|
|
3482
|
+
} else {
|
|
3483
|
+
const ep = asmPostType === 'page' ? '/pages' : '/posts';
|
|
3484
|
+
asmPosts = await wpApiCall(`${ep}?per_page=${asmPerPage}&status=publish&${asmFields}`);
|
|
3485
|
+
}
|
|
3486
|
+
|
|
3487
|
+
const SCHEMA_REQUIRED_FIELDS = {
|
|
3488
|
+
'Article': ['headline', 'datePublished', 'author'],
|
|
3489
|
+
'NewsArticle': ['headline', 'datePublished', 'author'],
|
|
3490
|
+
'BlogPosting': ['headline', 'datePublished', 'author'],
|
|
3491
|
+
'FAQPage': ['mainEntity'],
|
|
3492
|
+
'HowTo': ['name', 'step'],
|
|
3493
|
+
'LocalBusiness': ['name', 'address'],
|
|
3494
|
+
'BreadcrumbList': ['itemListElement'],
|
|
3495
|
+
'Organization': ['name'],
|
|
3496
|
+
'WebPage': ['name']
|
|
3497
|
+
};
|
|
3498
|
+
const ARTICLE_TYPES = ['Article', 'NewsArticle', 'BlogPosting'];
|
|
3499
|
+
|
|
3500
|
+
const typeDist = {};
|
|
3501
|
+
let totalSchemas = 0;
|
|
3502
|
+
let validCount = 0;
|
|
3503
|
+
let invalidCount = 0;
|
|
3504
|
+
let postsWithSchema = 0;
|
|
3505
|
+
let postsWithout = 0;
|
|
3506
|
+
|
|
3507
|
+
const ldJsonRegex = /<script\s+type=["']application\/ld\+json["'][^>]*>([\s\S]*?)<\/script>/gi;
|
|
3508
|
+
|
|
3509
|
+
const asmResults = [];
|
|
3510
|
+
for (const p of asmPosts) {
|
|
3511
|
+
const html = p.content?.rendered || '';
|
|
3512
|
+
const schemas = [];
|
|
3513
|
+
const issues = [];
|
|
3514
|
+
let hasInvalidJson = false;
|
|
3515
|
+
let hasArticleSchema = false;
|
|
3516
|
+
|
|
3517
|
+
let ldMatch;
|
|
3518
|
+
ldJsonRegex.lastIndex = 0;
|
|
3519
|
+
while ((ldMatch = ldJsonRegex.exec(html)) !== null) {
|
|
3520
|
+
const raw = ldMatch[1].trim();
|
|
3521
|
+
let parsed;
|
|
3522
|
+
try {
|
|
3523
|
+
parsed = JSON.parse(raw);
|
|
3524
|
+
} catch {
|
|
3525
|
+
schemas.push({ type: 'unknown', valid: false, missing_fields: [], raw_type: 'parse_error' });
|
|
3526
|
+
hasInvalidJson = true;
|
|
3527
|
+
invalidCount++;
|
|
3528
|
+
totalSchemas++;
|
|
3529
|
+
continue;
|
|
3530
|
+
}
|
|
3531
|
+
|
|
3532
|
+
const types = Array.isArray(parsed['@type']) ? parsed['@type'] : [parsed['@type'] || 'unknown'];
|
|
3533
|
+
for (const t of types) {
|
|
3534
|
+
const required = SCHEMA_REQUIRED_FIELDS[t];
|
|
3535
|
+
if (ARTICLE_TYPES.includes(t)) hasArticleSchema = true;
|
|
3536
|
+
|
|
3537
|
+
if (required) {
|
|
3538
|
+
const missing = required.filter(f => {
|
|
3539
|
+
const val = parsed[f];
|
|
3540
|
+
if (val === undefined || val === null) return true;
|
|
3541
|
+
if (Array.isArray(val) && val.length === 0) return true;
|
|
3542
|
+
return false;
|
|
3543
|
+
});
|
|
3544
|
+
const isValid = missing.length === 0;
|
|
3545
|
+
schemas.push({ type: t, valid: isValid, missing_fields: missing, raw_type: t });
|
|
3546
|
+
if (isValid) validCount++; else invalidCount++;
|
|
3547
|
+
if (!isValid) issues.push('missing_required_fields');
|
|
3548
|
+
typeDist[t] = (typeDist[t] || 0) + 1;
|
|
3549
|
+
} else {
|
|
3550
|
+
schemas.push({ type: t, valid: true, missing_fields: [], raw_type: t });
|
|
3551
|
+
validCount++;
|
|
3552
|
+
typeDist['other'] = (typeDist['other'] || 0) + 1;
|
|
3553
|
+
}
|
|
3554
|
+
totalSchemas++;
|
|
3555
|
+
}
|
|
3556
|
+
}
|
|
3557
|
+
|
|
3558
|
+
if (schemas.length === 0) {
|
|
3559
|
+
issues.push('no_schema');
|
|
3560
|
+
postsWithout++;
|
|
3561
|
+
} else {
|
|
3562
|
+
postsWithSchema++;
|
|
3563
|
+
}
|
|
3564
|
+
if (hasInvalidJson) issues.push('invalid_json');
|
|
3565
|
+
if (!hasArticleSchema && schemas.length > 0) issues.push('no_article_schema');
|
|
3566
|
+
|
|
3567
|
+
asmResults.push({
|
|
3568
|
+
id: p.id,
|
|
3569
|
+
title: strip(p.title?.rendered || ''),
|
|
3570
|
+
slug: p.slug,
|
|
3571
|
+
link: p.link,
|
|
3572
|
+
schemas_found: schemas.length,
|
|
3573
|
+
schemas,
|
|
3574
|
+
issues
|
|
3575
|
+
});
|
|
3576
|
+
}
|
|
3577
|
+
|
|
3578
|
+
// Sort: posts with issues first, by issue count DESC
|
|
3579
|
+
asmResults.sort((a, b) => b.issues.length - a.issues.length);
|
|
3580
|
+
|
|
3581
|
+
result = json({
|
|
3582
|
+
total_analyzed: asmPosts.length,
|
|
3583
|
+
posts_with_schema: postsWithSchema,
|
|
3584
|
+
posts_without_schema: postsWithout,
|
|
3585
|
+
schema_type_distribution: typeDist,
|
|
3586
|
+
total_schemas_found: totalSchemas,
|
|
3587
|
+
total_valid: validCount,
|
|
3588
|
+
total_invalid: invalidCount,
|
|
3589
|
+
posts: asmResults
|
|
3590
|
+
});
|
|
3591
|
+
auditLog({ tool: name, action: 'audit_schema_markup', status: 'success', latency_ms: Date.now() - t0, params: { limit: asmLimit, post_type: asmPostType } });
|
|
3592
|
+
break;
|
|
3593
|
+
}
|
|
3594
|
+
|
|
3595
|
+
// ── wp_audit_content_structure ──
|
|
3596
|
+
case 'wp_audit_content_structure': {
|
|
3597
|
+
validateInput(args, {
|
|
3598
|
+
limit: { type: 'number', min: 1, max: 200 },
|
|
3599
|
+
post_type: { type: 'string', enum: ['post', 'page', 'both'] },
|
|
3600
|
+
category_id: { type: 'number' }
|
|
3601
|
+
});
|
|
3602
|
+
const { limit: acsLimit = 50, post_type: acsPostType = 'post', category_id: acsCatId } = args;
|
|
3603
|
+
const acsPerPage = Math.min(acsLimit, 200);
|
|
3604
|
+
const acsFields = '_fields=id,title,slug,link,content,categories';
|
|
3605
|
+
|
|
3606
|
+
let acsPosts = [];
|
|
3607
|
+
if (acsPostType === 'both') {
|
|
3608
|
+
let postsUrl = `/posts?per_page=${acsPerPage}&status=publish&${acsFields}`;
|
|
3609
|
+
if (acsCatId) postsUrl += `&categories=${acsCatId}`;
|
|
3610
|
+
const [posts, pages] = await Promise.all([
|
|
3611
|
+
wpApiCall(postsUrl),
|
|
3612
|
+
wpApiCall(`/pages?per_page=${acsPerPage}&status=publish&${acsFields}`)
|
|
3613
|
+
]);
|
|
3614
|
+
acsPosts = [...posts, ...pages];
|
|
3615
|
+
} else {
|
|
3616
|
+
const ep = acsPostType === 'page' ? '/pages' : '/posts';
|
|
3617
|
+
let postsUrl = `${ep}?per_page=${acsPerPage}&status=publish&${acsFields}`;
|
|
3618
|
+
if (acsCatId && acsPostType !== 'page') postsUrl += `&categories=${acsCatId}`;
|
|
3619
|
+
acsPosts = await wpApiCall(postsUrl);
|
|
3620
|
+
}
|
|
3621
|
+
|
|
3622
|
+
let totalScore = 0;
|
|
3623
|
+
const distribution = { excellent: 0, good: 0, average: 0, poor: 0 };
|
|
3624
|
+
const featureCounts = { intro: 0, conclusion: 0, faq: 0, toc: 0, lists: 0, tables: 0, images: 0, blockquotes: 0 };
|
|
3625
|
+
|
|
3626
|
+
const acsResults = [];
|
|
3627
|
+
for (const p of acsPosts) {
|
|
3628
|
+
const html = p.content?.rendered || '';
|
|
3629
|
+
const sections = detectContentSections(html);
|
|
3630
|
+
const wc = countWords(html);
|
|
3631
|
+
|
|
3632
|
+
// Paragraphs count (non-empty <p>)
|
|
3633
|
+
const pMatches = html.match(/<p[^>]*>(?!\s*<\/p>)/gi) || [];
|
|
3634
|
+
const paragraphsCount = pMatches.length || 1;
|
|
3635
|
+
const avgParagraphLength = Math.round(wc / paragraphsCount);
|
|
3636
|
+
|
|
3637
|
+
// TOC detection
|
|
3638
|
+
const hasToc = /<[^>]+(?:id|class)=["'][^"']*(?:toc|table-of-contents|table-des-matieres)[^"']*["'][^>]*>/i.test(html);
|
|
3639
|
+
|
|
3640
|
+
// Blockquotes and code blocks
|
|
3641
|
+
const blockquotesCount = (html.match(/<blockquote\b/gi) || []).length;
|
|
3642
|
+
const codeBlocksCount = (html.match(/<(?:pre|code)\b/gi) || []).length;
|
|
3643
|
+
|
|
3644
|
+
// Heading density: headings per 300 words
|
|
3645
|
+
const headingDensity = wc > 0 ? sections.headings_count / (wc / 300) : 0;
|
|
3646
|
+
|
|
3647
|
+
// Structure score (0-100)
|
|
3648
|
+
let score = 0;
|
|
3649
|
+
if (sections.has_intro) score += 15;
|
|
3650
|
+
if (sections.has_conclusion) score += 10;
|
|
3651
|
+
if (sections.headings_count >= 3) score += 15;
|
|
3652
|
+
if (headingDensity >= 0.5 && headingDensity <= 2.0) score += 10;
|
|
3653
|
+
if (sections.lists_count >= 1) score += 10;
|
|
3654
|
+
if (sections.images_count >= 1) score += 10;
|
|
3655
|
+
if (sections.has_faq) score += 10;
|
|
3656
|
+
if (sections.tables_count >= 1) score += 5;
|
|
3657
|
+
if (hasToc) score += 5;
|
|
3658
|
+
if (blockquotesCount > 0 || codeBlocksCount > 0) score += 5;
|
|
3659
|
+
if (avgParagraphLength < 100) score += 5;
|
|
3660
|
+
|
|
3661
|
+
totalScore += score;
|
|
3662
|
+
|
|
3663
|
+
if (score >= 80) distribution.excellent++;
|
|
3664
|
+
else if (score >= 60) distribution.good++;
|
|
3665
|
+
else if (score >= 40) distribution.average++;
|
|
3666
|
+
else distribution.poor++;
|
|
3667
|
+
|
|
3668
|
+
// Feature coverage tracking
|
|
3669
|
+
if (sections.has_intro) featureCounts.intro++;
|
|
3670
|
+
if (sections.has_conclusion) featureCounts.conclusion++;
|
|
3671
|
+
if (sections.has_faq) featureCounts.faq++;
|
|
3672
|
+
if (hasToc) featureCounts.toc++;
|
|
3673
|
+
if (sections.lists_count >= 1) featureCounts.lists++;
|
|
3674
|
+
if (sections.tables_count >= 1) featureCounts.tables++;
|
|
3675
|
+
if (sections.images_count >= 1) featureCounts.images++;
|
|
3676
|
+
if (blockquotesCount > 0) featureCounts.blockquotes++;
|
|
3677
|
+
|
|
3678
|
+
// Issues
|
|
3679
|
+
const issues = [];
|
|
3680
|
+
if (!sections.has_intro) issues.push('no_intro');
|
|
3681
|
+
if (!sections.has_conclusion) issues.push('no_conclusion');
|
|
3682
|
+
if (sections.headings_count === 0) issues.push('no_headings');
|
|
3683
|
+
if (headingDensity < 0.3 && wc > 100) issues.push('low_heading_density');
|
|
3684
|
+
if (headingDensity > 3.0) issues.push('high_heading_density');
|
|
3685
|
+
if (sections.images_count === 0) issues.push('no_images');
|
|
3686
|
+
if (sections.lists_count === 0) issues.push('no_lists');
|
|
3687
|
+
if (avgParagraphLength > 150) issues.push('long_paragraphs');
|
|
3688
|
+
if (score < 40) issues.push('poor_structure');
|
|
3689
|
+
|
|
3690
|
+
acsResults.push({
|
|
3691
|
+
id: p.id,
|
|
3692
|
+
title: strip(p.title?.rendered || ''),
|
|
3693
|
+
slug: p.slug,
|
|
3694
|
+
link: p.link,
|
|
3695
|
+
word_count: wc,
|
|
3696
|
+
structure_score: score,
|
|
3697
|
+
features: {
|
|
3698
|
+
has_intro: sections.has_intro,
|
|
3699
|
+
has_conclusion: sections.has_conclusion,
|
|
3700
|
+
has_faq: sections.has_faq,
|
|
3701
|
+
has_toc: hasToc,
|
|
3702
|
+
headings_count: sections.headings_count,
|
|
3703
|
+
lists_count: sections.lists_count,
|
|
3704
|
+
tables_count: sections.tables_count,
|
|
3705
|
+
images_count: sections.images_count,
|
|
3706
|
+
paragraphs_count: paragraphsCount,
|
|
3707
|
+
avg_paragraph_length: avgParagraphLength,
|
|
3708
|
+
blockquotes_count: blockquotesCount,
|
|
3709
|
+
code_blocks_count: codeBlocksCount,
|
|
3710
|
+
heading_density: Math.round(headingDensity * 100) / 100
|
|
3711
|
+
},
|
|
3712
|
+
issues
|
|
3713
|
+
});
|
|
3714
|
+
}
|
|
3715
|
+
|
|
3716
|
+
acsResults.sort((a, b) => a.structure_score - b.structure_score);
|
|
3717
|
+
|
|
3718
|
+
const acsTotal = acsPosts.length;
|
|
3719
|
+
const featureCoverage = {};
|
|
3720
|
+
for (const [key, count] of Object.entries(featureCounts)) {
|
|
3721
|
+
featureCoverage[key] = acsTotal > 0 ? Math.round(count / acsTotal * 100) : 0;
|
|
3722
|
+
}
|
|
3723
|
+
|
|
3724
|
+
result = json({
|
|
3725
|
+
total_analyzed: acsTotal,
|
|
3726
|
+
avg_structure_score: acsTotal > 0 ? Math.round(totalScore / acsTotal * 10) / 10 : 0,
|
|
3727
|
+
distribution,
|
|
3728
|
+
feature_coverage: featureCoverage,
|
|
3729
|
+
posts: acsResults
|
|
3730
|
+
});
|
|
3731
|
+
auditLog({ tool: name, action: 'audit_content_structure', status: 'success', latency_ms: Date.now() - t0, params: { limit: acsLimit, post_type: acsPostType, category_id: acsCatId } });
|
|
3732
|
+
break;
|
|
3733
|
+
}
|
|
3734
|
+
|
|
3735
|
+
// ── wp_find_duplicate_content ──
|
|
3736
|
+
case 'wp_find_duplicate_content': {
|
|
3737
|
+
validateInput(args, {
|
|
3738
|
+
limit: { type: 'number', min: 1, max: 100 },
|
|
3739
|
+
post_type: { type: 'string', enum: ['post', 'page'] },
|
|
3740
|
+
category_id: { type: 'number' },
|
|
3741
|
+
similarity_threshold: { type: 'number', min: 0, max: 1 }
|
|
3742
|
+
});
|
|
3743
|
+
const { limit: fdcLimit = 50, post_type: fdcPostType = 'post', category_id: fdcCatId, similarity_threshold: fdcThreshold = 0.7 } = args;
|
|
3744
|
+
const fdcPerPage = Math.min(fdcLimit, 100);
|
|
3745
|
+
const fdcFields = '_fields=id,title,content,slug,link';
|
|
3746
|
+
|
|
3747
|
+
const fdcEp = fdcPostType === 'page' ? '/pages' : '/posts';
|
|
3748
|
+
let fdcUrl = `${fdcEp}?per_page=${fdcPerPage}&status=publish&${fdcFields}`;
|
|
3749
|
+
if (fdcCatId && fdcPostType !== 'page') fdcUrl += `&categories=${fdcCatId}`;
|
|
3750
|
+
|
|
3751
|
+
const fdcPosts = await wpApiCall(fdcUrl);
|
|
3752
|
+
|
|
3753
|
+
const fdcDocs = [];
|
|
3754
|
+
const fdcPostMap = new Map();
|
|
3755
|
+
for (const p of fdcPosts) {
|
|
3756
|
+
const text = strip(p.content?.rendered || '');
|
|
3757
|
+
const wc = countWords(p.content?.rendered || '');
|
|
3758
|
+
fdcPostMap.set(p.id, { id: p.id, title: strip(p.title?.rendered || ''), slug: p.slug, word_count: wc });
|
|
3759
|
+
if (wc >= 50) {
|
|
3760
|
+
fdcDocs.push({ id: p.id, title: strip(p.title?.rendered || ''), text });
|
|
3761
|
+
}
|
|
3762
|
+
}
|
|
3763
|
+
|
|
3764
|
+
const fdcPairs = findDuplicatePairs(fdcDocs, fdcThreshold);
|
|
3765
|
+
|
|
3766
|
+
const formattedPairs = fdcPairs
|
|
3767
|
+
.sort((a, b) => b.similarity - a.similarity)
|
|
3768
|
+
.map(pair => {
|
|
3769
|
+
const p1 = fdcPostMap.get(pair.doc1_id);
|
|
3770
|
+
const p2 = fdcPostMap.get(pair.doc2_id);
|
|
3771
|
+
const sim = Math.round(pair.similarity * 1000) / 1000;
|
|
3772
|
+
return {
|
|
3773
|
+
post1: { id: p1.id, title: p1.title, slug: p1.slug, word_count: p1.word_count },
|
|
3774
|
+
post2: { id: p2.id, title: p2.title, slug: p2.slug, word_count: p2.word_count },
|
|
3775
|
+
similarity: sim,
|
|
3776
|
+
severity: sim >= 0.9 ? 'critical' : sim >= 0.8 ? 'high' : 'medium'
|
|
3777
|
+
};
|
|
3778
|
+
});
|
|
3779
|
+
|
|
3780
|
+
// Union-find clustering
|
|
3781
|
+
const ufParent = new Map();
|
|
3782
|
+
const ufFind = (x) => {
|
|
3783
|
+
if (!ufParent.has(x)) ufParent.set(x, x);
|
|
3784
|
+
if (ufParent.get(x) !== x) ufParent.set(x, ufFind(ufParent.get(x)));
|
|
3785
|
+
return ufParent.get(x);
|
|
3786
|
+
};
|
|
3787
|
+
const ufUnion = (a, b) => { ufParent.set(ufFind(a), ufFind(b)); };
|
|
3788
|
+
|
|
3789
|
+
for (const pair of fdcPairs) ufUnion(pair.doc1_id, pair.doc2_id);
|
|
3790
|
+
|
|
3791
|
+
const clusterMap = new Map();
|
|
3792
|
+
const idsInPairs = new Set();
|
|
3793
|
+
for (const pair of fdcPairs) { idsInPairs.add(pair.doc1_id); idsInPairs.add(pair.doc2_id); }
|
|
3794
|
+
for (const id of idsInPairs) {
|
|
3795
|
+
const root = ufFind(id);
|
|
3796
|
+
if (!clusterMap.has(root)) clusterMap.set(root, { posts: [], maxSim: 0 });
|
|
3797
|
+
const info = fdcPostMap.get(id);
|
|
3798
|
+
clusterMap.get(root).posts.push({ id: info.id, title: info.title, slug: info.slug });
|
|
3799
|
+
}
|
|
3800
|
+
for (const pair of fdcPairs) {
|
|
3801
|
+
const root = ufFind(pair.doc1_id);
|
|
3802
|
+
const cluster = clusterMap.get(root);
|
|
3803
|
+
if (pair.similarity > cluster.maxSim) cluster.maxSim = pair.similarity;
|
|
3804
|
+
}
|
|
3805
|
+
|
|
3806
|
+
let fdcClusterId = 0;
|
|
3807
|
+
const clusters = [...clusterMap.values()].map(c => ({
|
|
3808
|
+
cluster_id: ++fdcClusterId,
|
|
3809
|
+
posts: c.posts,
|
|
3810
|
+
max_similarity: Math.round(c.maxSim * 1000) / 1000
|
|
3811
|
+
}));
|
|
3812
|
+
|
|
3813
|
+
result = json({
|
|
3814
|
+
total_analyzed: fdcPosts.length,
|
|
3815
|
+
similarity_threshold: fdcThreshold,
|
|
3816
|
+
duplicate_pairs_found: formattedPairs.length,
|
|
3817
|
+
duplicate_clusters: clusters.length,
|
|
3818
|
+
pairs: formattedPairs,
|
|
3819
|
+
clusters
|
|
3820
|
+
});
|
|
3821
|
+
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 } });
|
|
3822
|
+
break;
|
|
3823
|
+
}
|
|
3824
|
+
|
|
3825
|
+
// ── wp_find_content_gaps ──
|
|
3826
|
+
case 'wp_find_content_gaps': {
|
|
3827
|
+
validateInput(args, {
|
|
3828
|
+
min_posts: { type: 'number', min: 1, max: 50 },
|
|
3829
|
+
taxonomy: { type: 'string', enum: ['category', 'post_tag', 'both'] },
|
|
3830
|
+
exclude_empty: { type: 'boolean' }
|
|
3831
|
+
});
|
|
3832
|
+
const { min_posts: fcgMinPosts = 3, taxonomy: fcgTaxonomy = 'both', exclude_empty: fcgExcludeEmpty = false } = args;
|
|
3833
|
+
|
|
3834
|
+
let fcgCategories = [];
|
|
3835
|
+
let fcgTags = [];
|
|
3836
|
+
|
|
3837
|
+
if (fcgTaxonomy === 'category' || fcgTaxonomy === 'both') {
|
|
3838
|
+
fcgCategories = await wpApiCall('/categories?per_page=100&_fields=id,name,slug,count,description,parent');
|
|
3839
|
+
}
|
|
3840
|
+
if (fcgTaxonomy === 'post_tag' || fcgTaxonomy === 'both') {
|
|
3841
|
+
fcgTags = await wpApiCall('/tags?per_page=100&_fields=id,name,slug,count');
|
|
3842
|
+
}
|
|
3843
|
+
|
|
3844
|
+
const catMap = new Map();
|
|
3845
|
+
for (const c of fcgCategories) catMap.set(c.id, c);
|
|
3846
|
+
|
|
3847
|
+
const fcgGaps = [];
|
|
3848
|
+
const fcgWellCovered = [];
|
|
3849
|
+
|
|
3850
|
+
for (const c of fcgCategories) {
|
|
3851
|
+
if (fcgExcludeEmpty && c.count === 0) continue;
|
|
3852
|
+
if (c.count < fcgMinPosts) {
|
|
3853
|
+
const parentInfo = c.parent ? catMap.get(c.parent) : null;
|
|
3854
|
+
fcgGaps.push({
|
|
3855
|
+
taxonomy: 'category', id: c.id, name: c.name, slug: c.slug,
|
|
3856
|
+
current_count: c.count, deficit: fcgMinPosts - c.count,
|
|
3857
|
+
parent_name: parentInfo ? parentInfo.name : null,
|
|
3858
|
+
parent_count: parentInfo ? parentInfo.count : null,
|
|
3859
|
+
severity: c.count === 0 ? 'empty' : 'underrepresented',
|
|
3860
|
+
suggestion: c.count === 0
|
|
3861
|
+
? `Create ${fcgMinPosts} posts for "${c.name}"`
|
|
3862
|
+
: `Add ${fcgMinPosts - c.count} more posts for "${c.name}"`
|
|
3863
|
+
});
|
|
3864
|
+
} else {
|
|
3865
|
+
fcgWellCovered.push({ taxonomy: 'category', id: c.id, name: c.name, count: c.count });
|
|
3866
|
+
}
|
|
3867
|
+
}
|
|
3868
|
+
|
|
3869
|
+
for (const t of fcgTags) {
|
|
3870
|
+
if (fcgExcludeEmpty && t.count === 0) continue;
|
|
3871
|
+
if (t.count < fcgMinPosts) {
|
|
3872
|
+
fcgGaps.push({
|
|
3873
|
+
taxonomy: 'post_tag', id: t.id, name: t.name, slug: t.slug,
|
|
3874
|
+
current_count: t.count, deficit: fcgMinPosts - t.count,
|
|
3875
|
+
parent_name: null, parent_count: null,
|
|
3876
|
+
severity: t.count === 0 ? 'empty' : 'underrepresented',
|
|
3877
|
+
suggestion: t.count === 0
|
|
3878
|
+
? `Create ${fcgMinPosts} posts for "${t.name}"`
|
|
3879
|
+
: `Add ${fcgMinPosts - t.count} more posts for "${t.name}"`
|
|
3880
|
+
});
|
|
3881
|
+
} else {
|
|
3882
|
+
fcgWellCovered.push({ taxonomy: 'post_tag', id: t.id, name: t.name, count: t.count });
|
|
3883
|
+
}
|
|
3884
|
+
}
|
|
3885
|
+
|
|
3886
|
+
fcgGaps.sort((a, b) => b.deficit - a.deficit || a.current_count - b.current_count);
|
|
3887
|
+
fcgWellCovered.sort((a, b) => b.count - a.count);
|
|
3888
|
+
|
|
3889
|
+
const catGaps = fcgGaps.filter(g => g.taxonomy === 'category');
|
|
3890
|
+
const tagGaps = fcgGaps.filter(g => g.taxonomy === 'post_tag');
|
|
3891
|
+
|
|
3892
|
+
result = json({
|
|
3893
|
+
min_posts_threshold: fcgMinPosts,
|
|
3894
|
+
total_terms_analyzed: fcgCategories.length + fcgTags.length,
|
|
3895
|
+
gaps_found: fcgGaps.length,
|
|
3896
|
+
gaps_by_taxonomy: { categories: catGaps.length, tags: tagGaps.length },
|
|
3897
|
+
gaps: fcgGaps,
|
|
3898
|
+
well_covered: fcgWellCovered.slice(0, 10)
|
|
3899
|
+
});
|
|
3900
|
+
auditLog({ tool: name, action: 'find_content_gaps', status: 'success', latency_ms: Date.now() - t0, params: { min_posts: fcgMinPosts, taxonomy: fcgTaxonomy, exclude_empty: fcgExcludeEmpty } });
|
|
3901
|
+
break;
|
|
3902
|
+
}
|
|
3903
|
+
|
|
3904
|
+
// ── wp_extract_faq_blocks ──
|
|
3905
|
+
case 'wp_extract_faq_blocks': {
|
|
3906
|
+
validateInput(args, {
|
|
3907
|
+
limit: { type: 'number', min: 1, max: 200 },
|
|
3908
|
+
post_type: { type: 'string', enum: ['post', 'page', 'both'] }
|
|
3909
|
+
});
|
|
3910
|
+
const { limit: efbLimit = 50, post_type: efbPostType = 'post' } = args;
|
|
3911
|
+
const efbPerPage = Math.min(efbLimit, 200);
|
|
3912
|
+
const efbFields = '_fields=id,title,slug,link,content';
|
|
3913
|
+
|
|
3914
|
+
let efbPosts = [];
|
|
3915
|
+
if (efbPostType === 'both') {
|
|
3916
|
+
const [posts, pages] = await Promise.all([
|
|
3917
|
+
wpApiCall(`/posts?per_page=${efbPerPage}&status=publish&${efbFields}`),
|
|
3918
|
+
wpApiCall(`/pages?per_page=${efbPerPage}&status=publish&${efbFields}`)
|
|
3919
|
+
]);
|
|
3920
|
+
efbPosts = [...posts, ...pages];
|
|
3921
|
+
} else {
|
|
3922
|
+
const ep = efbPostType === 'page' ? '/pages' : '/posts';
|
|
3923
|
+
efbPosts = await wpApiCall(`${ep}?per_page=${efbPerPage}&status=publish&${efbFields}`);
|
|
3924
|
+
}
|
|
3925
|
+
|
|
3926
|
+
const efbLdRegex = /<script\s+type=["']application\/ld\+json["'][^>]*>([\s\S]*?)<\/script>/gi;
|
|
3927
|
+
const efbYoastRegex = /<!-- wp:yoast\/faq-block -->([\s\S]*?)<!-- \/wp:yoast\/faq-block -->/gi;
|
|
3928
|
+
const efbRankMathRegex = /<!-- wp:rank-math\/faq-block -->([\s\S]*?)<!-- \/wp:rank-math\/faq-block -->/gi;
|
|
3929
|
+
const efbFaqHeadingRegex = /<h[2-4][^>]*>[^<]*(?:FAQ|Questions fréquentes|Questions courantes|Foire aux questions)[^<]*<\/h[2-4]>/gi;
|
|
3930
|
+
|
|
3931
|
+
let efbTotalQ = 0;
|
|
3932
|
+
let efbPostsWithFaq = 0;
|
|
3933
|
+
const efbBySource = { 'json-ld': 0, 'gutenberg-block': 0, 'html-pattern': 0 };
|
|
3934
|
+
|
|
3935
|
+
const efbResults = [];
|
|
3936
|
+
for (const p of efbPosts) {
|
|
3937
|
+
const html = p.content?.rendered || '';
|
|
3938
|
+
const faqBlocks = [];
|
|
3939
|
+
|
|
3940
|
+
// Type A: JSON-LD FAQPage
|
|
3941
|
+
efbLdRegex.lastIndex = 0;
|
|
3942
|
+
let ldMatch;
|
|
3943
|
+
while ((ldMatch = efbLdRegex.exec(html)) !== null) {
|
|
3944
|
+
try {
|
|
3945
|
+
const parsed = JSON.parse(ldMatch[1].trim());
|
|
3946
|
+
const pType = Array.isArray(parsed['@type']) ? parsed['@type'] : [parsed['@type']];
|
|
3947
|
+
if (pType.includes('FAQPage') && Array.isArray(parsed.mainEntity)) {
|
|
3948
|
+
const questions = parsed.mainEntity
|
|
3949
|
+
.filter(q => q['@type'] === 'Question' && q.name)
|
|
3950
|
+
.map(q => ({ question: q.name, answer: (q.acceptedAnswer?.text || '').slice(0, 200) }));
|
|
3951
|
+
if (questions.length > 0) {
|
|
3952
|
+
faqBlocks.push({ source: 'json-ld', plugin: null, questions_count: questions.length, questions });
|
|
3953
|
+
efbBySource['json-ld'] += questions.length;
|
|
3954
|
+
efbTotalQ += questions.length;
|
|
3955
|
+
}
|
|
3956
|
+
}
|
|
3957
|
+
} catch { /* invalid JSON */ }
|
|
3958
|
+
}
|
|
3959
|
+
|
|
3960
|
+
// Type B: Gutenberg FAQ blocks
|
|
3961
|
+
const processGutenberg = (regex, plugin) => {
|
|
3962
|
+
regex.lastIndex = 0;
|
|
3963
|
+
let gMatch;
|
|
3964
|
+
while ((gMatch = regex.exec(html)) !== null) {
|
|
3965
|
+
const blockHtml = gMatch[1];
|
|
3966
|
+
const questions = [];
|
|
3967
|
+
const qRegex = /<(?:strong|h3)[^>]*class=["'][^"']*faq-question[^"']*["'][^>]*>([\s\S]*?)<\/(?:strong|h3)>/gi;
|
|
3968
|
+
const aRegex = /<(?:p|div)[^>]*class=["'][^"']*faq-answer[^"']*["'][^>]*>([\s\S]*?)<\/(?:p|div)>/gi;
|
|
3969
|
+
const qs = [];
|
|
3970
|
+
const as = [];
|
|
3971
|
+
let qm;
|
|
3972
|
+
while ((qm = qRegex.exec(blockHtml)) !== null) qs.push(strip(qm[1]));
|
|
3973
|
+
let am;
|
|
3974
|
+
while ((am = aRegex.exec(blockHtml)) !== null) as.push(strip(am[1]).slice(0, 200));
|
|
3975
|
+
for (let i = 0; i < qs.length; i++) {
|
|
3976
|
+
questions.push({ question: qs[i], answer: as[i] || '' });
|
|
3977
|
+
}
|
|
3978
|
+
if (questions.length > 0) {
|
|
3979
|
+
faqBlocks.push({ source: 'gutenberg-block', plugin, questions_count: questions.length, questions });
|
|
3980
|
+
efbBySource['gutenberg-block'] += questions.length;
|
|
3981
|
+
efbTotalQ += questions.length;
|
|
3982
|
+
}
|
|
3983
|
+
}
|
|
3984
|
+
};
|
|
3985
|
+
processGutenberg(efbYoastRegex, 'yoast');
|
|
3986
|
+
processGutenberg(efbRankMathRegex, 'rankmath');
|
|
3987
|
+
|
|
3988
|
+
// Type C: HTML pattern FAQ
|
|
3989
|
+
efbFaqHeadingRegex.lastIndex = 0;
|
|
3990
|
+
if (efbFaqHeadingRegex.test(html)) {
|
|
3991
|
+
const questions = [];
|
|
3992
|
+
const h3pRegex = /<h3[^>]*>([\s\S]*?)<\/h3>\s*<p[^>]*>([\s\S]*?)<\/p>/gi;
|
|
3993
|
+
const dtddRegex = /<dt[^>]*>([\s\S]*?)<\/dt>\s*<dd[^>]*>([\s\S]*?)<\/dd>/gi;
|
|
3994
|
+
let hm;
|
|
3995
|
+
while ((hm = h3pRegex.exec(html)) !== null) {
|
|
3996
|
+
const q = strip(hm[1]);
|
|
3997
|
+
if (q) questions.push({ question: q, answer: strip(hm[2]).slice(0, 200) });
|
|
3998
|
+
}
|
|
3999
|
+
if (questions.length === 0) {
|
|
4000
|
+
while ((hm = dtddRegex.exec(html)) !== null) {
|
|
4001
|
+
const q = strip(hm[1]);
|
|
4002
|
+
if (q) questions.push({ question: q, answer: strip(hm[2]).slice(0, 200) });
|
|
4003
|
+
}
|
|
4004
|
+
}
|
|
4005
|
+
if (questions.length > 0) {
|
|
4006
|
+
faqBlocks.push({ source: 'html-pattern', plugin: null, questions_count: questions.length, questions });
|
|
4007
|
+
efbBySource['html-pattern'] += questions.length;
|
|
4008
|
+
efbTotalQ += questions.length;
|
|
4009
|
+
}
|
|
4010
|
+
}
|
|
4011
|
+
|
|
4012
|
+
const hasFaq = faqBlocks.length > 0;
|
|
4013
|
+
if (hasFaq) efbPostsWithFaq++;
|
|
4014
|
+
|
|
4015
|
+
efbResults.push({
|
|
4016
|
+
id: p.id, title: strip(p.title?.rendered || ''), slug: p.slug, link: p.link,
|
|
4017
|
+
has_faq: hasFaq, faq_blocks: faqBlocks
|
|
4018
|
+
});
|
|
4019
|
+
}
|
|
4020
|
+
|
|
4021
|
+
let efbFiltered = efbPosts.length <= 10
|
|
4022
|
+
? efbResults
|
|
4023
|
+
: efbResults.filter(r => r.has_faq);
|
|
4024
|
+
|
|
4025
|
+
efbFiltered.sort((a, b) => {
|
|
4026
|
+
const qA = a.faq_blocks.reduce((s, f) => s + f.questions_count, 0);
|
|
4027
|
+
const qB = b.faq_blocks.reduce((s, f) => s + f.questions_count, 0);
|
|
4028
|
+
return qB - qA;
|
|
4029
|
+
});
|
|
4030
|
+
|
|
4031
|
+
result = json({
|
|
4032
|
+
total_analyzed: efbPosts.length,
|
|
4033
|
+
posts_with_faq: efbPostsWithFaq,
|
|
4034
|
+
total_questions: efbTotalQ,
|
|
4035
|
+
faq_by_source: efbBySource,
|
|
4036
|
+
posts: efbFiltered
|
|
4037
|
+
});
|
|
4038
|
+
auditLog({ tool: name, action: 'extract_faq_blocks', status: 'success', latency_ms: Date.now() - t0, params: { limit: efbLimit, post_type: efbPostType } });
|
|
4039
|
+
break;
|
|
4040
|
+
}
|
|
4041
|
+
|
|
4042
|
+
// ── wp_audit_cta_presence ──
|
|
4043
|
+
case 'wp_audit_cta_presence': {
|
|
4044
|
+
validateInput(args, {
|
|
4045
|
+
limit: { type: 'number', min: 1, max: 200 },
|
|
4046
|
+
post_type: { type: 'string', enum: ['post', 'page', 'both'] },
|
|
4047
|
+
category_id: { type: 'number' }
|
|
4048
|
+
});
|
|
4049
|
+
const { limit: acpLimit = 50, post_type: acpPostType = 'post', category_id: acpCatId } = args;
|
|
4050
|
+
const acpPerPage = Math.min(acpLimit, 200);
|
|
4051
|
+
const acpFields = '_fields=id,title,slug,link,content,categories';
|
|
4052
|
+
|
|
4053
|
+
let acpPosts = [];
|
|
4054
|
+
if (acpPostType === 'both') {
|
|
4055
|
+
let postsUrl = `/posts?per_page=${acpPerPage}&status=publish&${acpFields}`;
|
|
4056
|
+
if (acpCatId) postsUrl += `&categories=${acpCatId}`;
|
|
4057
|
+
const [posts, pages] = await Promise.all([
|
|
4058
|
+
wpApiCall(postsUrl),
|
|
4059
|
+
wpApiCall(`/pages?per_page=${acpPerPage}&status=publish&${acpFields}`)
|
|
4060
|
+
]);
|
|
4061
|
+
acpPosts = [...posts, ...pages];
|
|
4062
|
+
} else {
|
|
4063
|
+
const ep = acpPostType === 'page' ? '/pages' : '/posts';
|
|
4064
|
+
let postsUrl = `${ep}?per_page=${acpPerPage}&status=publish&${acpFields}`;
|
|
4065
|
+
if (acpCatId && acpPostType !== 'page') postsUrl += `&categories=${acpCatId}`;
|
|
4066
|
+
acpPosts = await wpApiCall(postsUrl);
|
|
4067
|
+
}
|
|
4068
|
+
|
|
4069
|
+
const CTA_CONTACT_HREFS = ['/contact', '/nous-contacter', '/contactez-nous', 'mailto:'];
|
|
4070
|
+
const CTA_CONTACT_ANCHORS = ['contactez', 'contact', 'nous contacter'];
|
|
4071
|
+
const CTA_FORM_PATTERNS = ['wpforms', 'cf7', 'gravity', 'elementor-form', 'formulaire', 'form'];
|
|
4072
|
+
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'];
|
|
4073
|
+
const CTA_QUOTE_TEXTS = ['devis', 'demande de devis', 'quote', 'request a quote', 'estimation'];
|
|
4074
|
+
const CTA_SIGNUP_HREFS = ['/inscription', '/register', '/signup', '/trial', '/essai'];
|
|
4075
|
+
|
|
4076
|
+
let acpWithCta = 0;
|
|
4077
|
+
let acpWithoutCta = 0;
|
|
4078
|
+
const ctaTypeDist = { contact_link: 0, form: 0, button_cta: 0, phone_link: 0, quote_request: 0, signup_link: 0 };
|
|
4079
|
+
|
|
4080
|
+
const acpResults = [];
|
|
4081
|
+
for (const p of acpPosts) {
|
|
4082
|
+
const html = p.content?.rendered || '';
|
|
4083
|
+
const lower = html.toLowerCase();
|
|
4084
|
+
const ctas = [];
|
|
4085
|
+
const ctaTypesFound = new Set();
|
|
4086
|
+
|
|
4087
|
+
// Scan all links
|
|
4088
|
+
const linkRegex = /<a\b[^>]*href=["']([^"']*)["'][^>]*>([\s\S]*?)<\/a>/gi;
|
|
4089
|
+
let lm;
|
|
4090
|
+
while ((lm = linkRegex.exec(html)) !== null) {
|
|
4091
|
+
const href = lm[1].toLowerCase();
|
|
4092
|
+
const anchor = strip(lm[2]).toLowerCase();
|
|
4093
|
+
|
|
4094
|
+
if (CTA_CONTACT_HREFS.some(pat => href.includes(pat)) || CTA_CONTACT_ANCHORS.some(txt => anchor.includes(txt))) {
|
|
4095
|
+
ctas.push({ type: 'contact_link', text: strip(lm[2]), href: lm[1] });
|
|
4096
|
+
ctaTypesFound.add('contact_link');
|
|
4097
|
+
}
|
|
4098
|
+
if (href.startsWith('tel:')) {
|
|
4099
|
+
ctas.push({ type: 'phone_link', text: strip(lm[2]), href: lm[1] });
|
|
4100
|
+
ctaTypesFound.add('phone_link');
|
|
4101
|
+
}
|
|
4102
|
+
if (CTA_SIGNUP_HREFS.some(pat => href.includes(pat))) {
|
|
4103
|
+
ctas.push({ type: 'signup_link', text: strip(lm[2]), href: lm[1] });
|
|
4104
|
+
ctaTypesFound.add('signup_link');
|
|
4105
|
+
}
|
|
4106
|
+
if (CTA_QUOTE_TEXTS.some(txt => anchor.includes(txt))) {
|
|
4107
|
+
ctas.push({ type: 'quote_request', text: strip(lm[2]), href: lm[1] });
|
|
4108
|
+
ctaTypesFound.add('quote_request');
|
|
4109
|
+
}
|
|
4110
|
+
}
|
|
4111
|
+
|
|
4112
|
+
// Forms
|
|
4113
|
+
if (/<form\b/i.test(html) || CTA_FORM_PATTERNS.some(pat => lower.includes(pat))) {
|
|
4114
|
+
const formElement = CTA_FORM_PATTERNS.find(pat => lower.includes(pat)) || 'form';
|
|
4115
|
+
ctas.push({ type: 'form', element: formElement });
|
|
4116
|
+
ctaTypesFound.add('form');
|
|
4117
|
+
}
|
|
4118
|
+
|
|
4119
|
+
// Button CTAs (by class)
|
|
4120
|
+
const btnClassRegex = /<(?:button|a)\b[^>]*class=["'][^"']*(?:cta|btn-cta|call-to-action|bouton-action)[^"']*["'][^>]*>([\s\S]*?)<\/(?:button|a)>/gi;
|
|
4121
|
+
let bm;
|
|
4122
|
+
while ((bm = btnClassRegex.exec(html)) !== null) {
|
|
4123
|
+
ctas.push({ type: 'button_cta', text: strip(bm[1]) });
|
|
4124
|
+
ctaTypesFound.add('button_cta');
|
|
4125
|
+
}
|
|
4126
|
+
// Button CTAs (by text)
|
|
4127
|
+
if (!ctaTypesFound.has('button_cta')) {
|
|
4128
|
+
const btnTextRegex = /<button\b[^>]*>([\s\S]*?)<\/button>/gi;
|
|
4129
|
+
let btm;
|
|
4130
|
+
while ((btm = btnTextRegex.exec(html)) !== null) {
|
|
4131
|
+
const btnText = strip(btm[1]).toLowerCase();
|
|
4132
|
+
if (CTA_BUTTON_TEXTS.some(txt => btnText.includes(txt))) {
|
|
4133
|
+
ctas.push({ type: 'button_cta', text: strip(btm[1]) });
|
|
4134
|
+
ctaTypesFound.add('button_cta');
|
|
4135
|
+
break;
|
|
4136
|
+
}
|
|
4137
|
+
}
|
|
4138
|
+
}
|
|
4139
|
+
|
|
4140
|
+
const uniqueTypes = ctaTypesFound.size;
|
|
4141
|
+
let ctaScore = 0;
|
|
4142
|
+
if (uniqueTypes >= 3) ctaScore = 100;
|
|
4143
|
+
else if (uniqueTypes === 2) ctaScore = 70;
|
|
4144
|
+
else if (uniqueTypes === 1) ctaScore = 40;
|
|
4145
|
+
|
|
4146
|
+
const issues = [];
|
|
4147
|
+
if (uniqueTypes === 0) { issues.push('no_cta'); acpWithoutCta++; }
|
|
4148
|
+
else { acpWithCta++; }
|
|
4149
|
+
if (uniqueTypes === 1) issues.push('single_cta_type');
|
|
4150
|
+
|
|
4151
|
+
for (const ct of ctaTypesFound) ctaTypeDist[ct]++;
|
|
4152
|
+
|
|
4153
|
+
acpResults.push({
|
|
4154
|
+
id: p.id, title: strip(p.title?.rendered || ''), slug: p.slug, link: p.link,
|
|
4155
|
+
cta_score: ctaScore, cta_count: ctas.length, cta_types: [...ctaTypesFound],
|
|
4156
|
+
ctas, issues
|
|
4157
|
+
});
|
|
4158
|
+
}
|
|
4159
|
+
|
|
4160
|
+
acpResults.sort((a, b) => a.cta_score - b.cta_score);
|
|
4161
|
+
|
|
4162
|
+
const acpTotal = acpPosts.length;
|
|
4163
|
+
result = json({
|
|
4164
|
+
total_analyzed: acpTotal,
|
|
4165
|
+
posts_with_cta: acpWithCta,
|
|
4166
|
+
posts_without_cta: acpWithoutCta,
|
|
4167
|
+
cta_coverage: acpTotal > 0 ? Math.round(acpWithCta / acpTotal * 100) : 0,
|
|
4168
|
+
cta_type_distribution: ctaTypeDist,
|
|
4169
|
+
posts: acpResults
|
|
4170
|
+
});
|
|
4171
|
+
auditLog({ tool: name, action: 'audit_cta_presence', status: 'success', latency_ms: Date.now() - t0, params: { limit: acpLimit, post_type: acpPostType, category_id: acpCatId } });
|
|
4172
|
+
break;
|
|
4173
|
+
}
|
|
4174
|
+
|
|
4175
|
+
// ── CONTENT INTELLIGENCE v4.4 Batch 4B ──
|
|
4176
|
+
|
|
4177
|
+
case 'wp_extract_entities': {
|
|
4178
|
+
validateInput(args, { limit: { type: 'number', min: 1, max: 100 }, post_type: { type: 'string', enum: ['post', 'page'] }, min_occurrences: { type: 'number', min: 1 } });
|
|
4179
|
+
const eeLimit = args.limit || 20;
|
|
4180
|
+
const eePostType = args.post_type || 'post';
|
|
4181
|
+
const eeMinOcc = args.min_occurrences || 2;
|
|
4182
|
+
|
|
4183
|
+
const eePosts = await wpApiCall(`/${eePostType}s?per_page=${eeLimit}&status=publish&_fields=id,title,content,slug`);
|
|
4184
|
+
|
|
4185
|
+
const globalEntities = new Map(); // name -> { type, count, post_ids, contexts }
|
|
4186
|
+
const postResults = [];
|
|
4187
|
+
|
|
4188
|
+
for (const p of eePosts) {
|
|
4189
|
+
const text = strip(p.content?.rendered || '');
|
|
4190
|
+
const entities = extractEntities(text);
|
|
4191
|
+
const localEntities = [];
|
|
4192
|
+
|
|
4193
|
+
for (const ent of entities) {
|
|
4194
|
+
localEntities.push({ name: ent.name, type: ent.type, count: ent.count });
|
|
4195
|
+
if (!globalEntities.has(ent.name)) {
|
|
4196
|
+
globalEntities.set(ent.name, { type: ent.type, count: 0, post_ids: new Set(), contexts: [] });
|
|
4197
|
+
}
|
|
4198
|
+
const g = globalEntities.get(ent.name);
|
|
4199
|
+
g.count += ent.count;
|
|
4200
|
+
g.post_ids.add(p.id);
|
|
4201
|
+
for (const ctx of ent.contexts) {
|
|
4202
|
+
if (g.contexts.length < 2) g.contexts.push(ctx);
|
|
4203
|
+
}
|
|
4204
|
+
}
|
|
4205
|
+
|
|
4206
|
+
postResults.push({
|
|
4207
|
+
id: p.id, title: strip(p.title?.rendered || ''), slug: p.slug,
|
|
4208
|
+
entities_count: localEntities.length,
|
|
4209
|
+
entities: localEntities
|
|
4210
|
+
});
|
|
4211
|
+
}
|
|
4212
|
+
|
|
4213
|
+
// Filter by min_occurrences and build aggregated results
|
|
4214
|
+
const filteredEntities = [...globalEntities.entries()]
|
|
4215
|
+
.filter(([, v]) => v.count >= eeMinOcc)
|
|
4216
|
+
.sort((a, b) => b[1].count - a[1].count);
|
|
4217
|
+
|
|
4218
|
+
const byType = { brand: 0, location: 0, person: 0, organization: 0, unknown: 0 };
|
|
4219
|
+
for (const [, v] of filteredEntities) {
|
|
4220
|
+
if (byType[v.type] !== undefined) byType[v.type]++;
|
|
4221
|
+
}
|
|
4222
|
+
|
|
4223
|
+
result = json({
|
|
4224
|
+
total_analyzed: eePosts.length,
|
|
4225
|
+
total_entities_found: filteredEntities.length,
|
|
4226
|
+
entities_by_type: byType,
|
|
4227
|
+
top_entities: filteredEntities.slice(0, 20).map(([name, v]) => ({
|
|
4228
|
+
name, type: v.type, total_count: v.count, post_ids: [...v.post_ids], contexts: v.contexts
|
|
4229
|
+
})),
|
|
4230
|
+
posts: postResults
|
|
4231
|
+
});
|
|
4232
|
+
auditLog({ tool: name, action: 'extract_entities', status: 'success', latency_ms: Date.now() - t0, params: { limit: eeLimit, post_type: eePostType, min_occurrences: eeMinOcc } });
|
|
4233
|
+
break;
|
|
4234
|
+
}
|
|
4235
|
+
|
|
4236
|
+
case 'wp_get_publishing_velocity': {
|
|
4237
|
+
validateInput(args, { periods: { type: 'string' }, post_type: { type: 'string', enum: ['post', 'page'] }, limit: { type: 'number', min: 1, max: 500 } });
|
|
4238
|
+
const pvPeriodsStr = args.periods || '30,90,180';
|
|
4239
|
+
const pvPostType = args.post_type || 'post';
|
|
4240
|
+
const pvLimit = args.limit || 200;
|
|
4241
|
+
|
|
4242
|
+
const periodDays = pvPeriodsStr.split(',').map(s => parseInt(s.trim(), 10)).filter(n => n > 0);
|
|
4243
|
+
if (periodDays.length === 0) throw new Error('Invalid periods: must be comma-separated positive integers');
|
|
4244
|
+
|
|
4245
|
+
const pvPosts = await wpApiCall(`/${pvPostType}s?per_page=${pvLimit}&status=publish&orderby=date&order=desc&_fields=id,title,date,author,categories`);
|
|
4246
|
+
const pvAuthors = await wpApiCall('/users?per_page=100&_fields=id,name');
|
|
4247
|
+
const pvCategories = await wpApiCall('/categories?per_page=100&_fields=id,name');
|
|
4248
|
+
|
|
4249
|
+
const authorMap = new Map(pvAuthors.map(a => [a.id, a.name]));
|
|
4250
|
+
const catMap = new Map(pvCategories.map(c => [c.id, c.name]));
|
|
4251
|
+
const now = Date.now();
|
|
4252
|
+
|
|
4253
|
+
const periodsResult = periodDays.map(p => {
|
|
4254
|
+
const cutoff = now - p * 86400000;
|
|
4255
|
+
const inPeriod = pvPosts.filter(post => new Date(post.date).getTime() >= cutoff);
|
|
4256
|
+
const velocity = Math.round(inPeriod.length / (p / 30) * 10) / 10;
|
|
4257
|
+
|
|
4258
|
+
// By author
|
|
4259
|
+
const authorCounts = new Map();
|
|
4260
|
+
for (const post of inPeriod) {
|
|
4261
|
+
authorCounts.set(post.author, (authorCounts.get(post.author) || 0) + 1);
|
|
4262
|
+
}
|
|
4263
|
+
const byAuthor = [...authorCounts.entries()]
|
|
4264
|
+
.map(([id, count]) => ({ id, name: authorMap.get(id) || `Author ${id}`, posts_count: count, velocity_per_month: Math.round(count / (p / 30) * 10) / 10 }))
|
|
4265
|
+
.sort((a, b) => b.velocity_per_month - a.velocity_per_month);
|
|
4266
|
+
|
|
4267
|
+
// By category
|
|
4268
|
+
const catCounts = new Map();
|
|
4269
|
+
for (const post of inPeriod) {
|
|
4270
|
+
for (const catId of (post.categories || [])) {
|
|
4271
|
+
catCounts.set(catId, (catCounts.get(catId) || 0) + 1);
|
|
4272
|
+
}
|
|
4273
|
+
}
|
|
4274
|
+
const byCategory = [...catCounts.entries()]
|
|
4275
|
+
.map(([id, count]) => ({ id, name: catMap.get(id) || `Category ${id}`, posts_count: count, velocity_per_month: Math.round(count / (p / 30) * 10) / 10 }))
|
|
4276
|
+
.sort((a, b) => b.velocity_per_month - a.velocity_per_month);
|
|
4277
|
+
|
|
4278
|
+
return { days: p, posts_count: inPeriod.length, velocity_per_month: velocity, by_author: byAuthor, by_category: byCategory };
|
|
4279
|
+
});
|
|
4280
|
+
|
|
4281
|
+
// Trend calculation
|
|
4282
|
+
const sortedPeriods = [...periodsResult].sort((a, b) => a.days - b.days);
|
|
4283
|
+
const shortV = sortedPeriods[0].velocity_per_month;
|
|
4284
|
+
const longV = sortedPeriods[sortedPeriods.length - 1].velocity_per_month;
|
|
4285
|
+
const changePct = longV > 0 ? Math.round(((shortV - longV) / longV) * 100) : 0;
|
|
4286
|
+
let direction = 'stable';
|
|
4287
|
+
if (changePct > 20) direction = 'accelerating';
|
|
4288
|
+
else if (changePct < -20) direction = 'decelerating';
|
|
4289
|
+
|
|
4290
|
+
// Top authors/categories from shortest period
|
|
4291
|
+
const shortPeriod = sortedPeriods[0];
|
|
4292
|
+
|
|
4293
|
+
result = json({
|
|
4294
|
+
total_posts_fetched: pvPosts.length,
|
|
4295
|
+
post_type: pvPostType,
|
|
4296
|
+
periods: periodsResult,
|
|
4297
|
+
trend: { direction, short_period_velocity: shortV, long_period_velocity: longV, change_percent: changePct },
|
|
4298
|
+
top_authors: shortPeriod.by_author.slice(0, 10),
|
|
4299
|
+
top_categories: shortPeriod.by_category.slice(0, 10)
|
|
4300
|
+
});
|
|
4301
|
+
auditLog({ tool: name, action: 'publishing_velocity', status: 'success', latency_ms: Date.now() - t0, params: { periods: periodDays, post_type: pvPostType, limit: pvLimit } });
|
|
4302
|
+
break;
|
|
4303
|
+
}
|
|
4304
|
+
|
|
4305
|
+
case 'wp_compare_revisions_diff': {
|
|
4306
|
+
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'] } });
|
|
4307
|
+
const crdPostId = args.post_id;
|
|
4308
|
+
const crdRevFrom = args.revision_id_from;
|
|
4309
|
+
const crdRevTo = args.revision_id_to;
|
|
4310
|
+
const crdPostType = args.post_type || 'post';
|
|
4311
|
+
|
|
4312
|
+
const revFrom = await wpApiCall(`/${crdPostType}s/${crdPostId}/revisions/${crdRevFrom}?_fields=id,date,content,title`);
|
|
4313
|
+
let revTo;
|
|
4314
|
+
let revToId;
|
|
4315
|
+
if (crdRevTo) {
|
|
4316
|
+
revTo = await wpApiCall(`/${crdPostType}s/${crdPostId}/revisions/${crdRevTo}?_fields=id,date,content,title`);
|
|
4317
|
+
revToId = crdRevTo;
|
|
4318
|
+
} else {
|
|
4319
|
+
revTo = await wpApiCall(`/${crdPostType}s/${crdPostId}?_fields=id,content,title,modified`);
|
|
4320
|
+
revToId = 'current';
|
|
4321
|
+
}
|
|
4322
|
+
|
|
4323
|
+
const textFrom = strip(revFrom.content?.rendered || revFrom.content || '');
|
|
4324
|
+
const textTo = strip(revTo.content?.rendered || revTo.content || '');
|
|
4325
|
+
const diff = computeTextDiff(textFrom, textTo);
|
|
4326
|
+
|
|
4327
|
+
const wcFrom = countWords(revFrom.content?.rendered || revFrom.content || '');
|
|
4328
|
+
const wcTo = countWords(revTo.content?.rendered || revTo.content || '');
|
|
4329
|
+
|
|
4330
|
+
const headingsFrom = extractHeadingsOutline(revFrom.content?.rendered || revFrom.content || '');
|
|
4331
|
+
const headingsTo = extractHeadingsOutline(revTo.content?.rendered || revTo.content || '');
|
|
4332
|
+
const headingsFromSet = new Set(headingsFrom.map(h => `${h.level}:${h.text}`));
|
|
4333
|
+
const headingsToSet = new Set(headingsTo.map(h => `${h.level}:${h.text}`));
|
|
4334
|
+
const headingsAdded = headingsTo.filter(h => !headingsFromSet.has(`${h.level}:${h.text}`));
|
|
4335
|
+
const headingsRemoved = headingsFrom.filter(h => !headingsToSet.has(`${h.level}:${h.text}`));
|
|
4336
|
+
|
|
4337
|
+
const amplitude = diff.change_ratio >= 0.5 ? 'major' : diff.change_ratio >= 0.2 ? 'moderate' : 'minor';
|
|
4338
|
+
|
|
4339
|
+
result = json({
|
|
4340
|
+
post_id: crdPostId,
|
|
4341
|
+
from: {
|
|
4342
|
+
revision_id: crdRevFrom,
|
|
4343
|
+
date: revFrom.date,
|
|
4344
|
+
title: strip(revFrom.title?.rendered || revFrom.title || ''),
|
|
4345
|
+
word_count: wcFrom
|
|
4346
|
+
},
|
|
4347
|
+
to: {
|
|
4348
|
+
revision_id: revToId,
|
|
4349
|
+
date: revTo.date || revTo.modified,
|
|
4350
|
+
title: strip(revTo.title?.rendered || revTo.title || ''),
|
|
4351
|
+
word_count: wcTo
|
|
4352
|
+
},
|
|
4353
|
+
diff: {
|
|
4354
|
+
lines_added: diff.lines_added,
|
|
4355
|
+
lines_removed: diff.lines_removed,
|
|
4356
|
+
lines_unchanged: diff.lines_unchanged,
|
|
4357
|
+
words_added: diff.words_added,
|
|
4358
|
+
words_removed: diff.words_removed,
|
|
4359
|
+
word_count_change: wcTo - wcFrom,
|
|
4360
|
+
change_ratio: Math.round(diff.change_ratio * 1000) / 1000,
|
|
4361
|
+
amplitude
|
|
4362
|
+
},
|
|
4363
|
+
headings_diff: { added: headingsAdded, removed: headingsRemoved },
|
|
4364
|
+
sample_changes: {
|
|
4365
|
+
added: diff.added_lines.slice(0, 10),
|
|
4366
|
+
removed: diff.removed_lines.slice(0, 10)
|
|
4367
|
+
}
|
|
4368
|
+
});
|
|
4369
|
+
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 } });
|
|
4370
|
+
break;
|
|
4371
|
+
}
|
|
4372
|
+
|
|
4373
|
+
case 'wp_list_posts_by_word_count': {
|
|
4374
|
+
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' } });
|
|
4375
|
+
const wclLimit = args.limit || 100;
|
|
4376
|
+
const wclPostType = args.post_type || 'post';
|
|
4377
|
+
const wclOrder = args.order || 'desc';
|
|
4378
|
+
const wclCatId = args.category_id;
|
|
4379
|
+
|
|
4380
|
+
let wclPosts;
|
|
4381
|
+
if (wclPostType === 'both') {
|
|
4382
|
+
const catParam = wclCatId ? `&categories=${wclCatId}` : '';
|
|
4383
|
+
const [postsArr, pagesArr] = await Promise.all([
|
|
4384
|
+
wpApiCall(`/posts?per_page=${wclLimit}&status=publish&_fields=id,title,slug,link,content,date,modified,categories${catParam}`),
|
|
4385
|
+
wpApiCall(`/pages?per_page=${wclLimit}&status=publish&_fields=id,title,slug,link,content,date,modified,categories`)
|
|
4386
|
+
]);
|
|
4387
|
+
wclPosts = [...postsArr, ...pagesArr];
|
|
4388
|
+
} else {
|
|
4389
|
+
const catParam = wclCatId ? `&categories=${wclCatId}` : '';
|
|
4390
|
+
wclPosts = await wpApiCall(`/${wclPostType}s?per_page=${wclLimit}&status=publish&_fields=id,title,slug,link,content,date,modified,categories${catParam}`);
|
|
4391
|
+
}
|
|
4392
|
+
|
|
4393
|
+
const postsWithWc = wclPosts.map(p => {
|
|
4394
|
+
const wc = countWords(p.content?.rendered || '');
|
|
4395
|
+
let segment;
|
|
4396
|
+
if (wc < 300) segment = 'very_short';
|
|
4397
|
+
else if (wc < 600) segment = 'short';
|
|
4398
|
+
else if (wc < 1000) segment = 'medium';
|
|
4399
|
+
else if (wc < 2000) segment = 'standard';
|
|
4400
|
+
else if (wc < 3000) segment = 'long';
|
|
4401
|
+
else segment = 'very_long';
|
|
4402
|
+
|
|
4403
|
+
return {
|
|
4404
|
+
id: p.id, title: strip(p.title?.rendered || ''), slug: p.slug, link: p.link,
|
|
4405
|
+
word_count: wc, segment,
|
|
4406
|
+
date: p.date, modified: p.modified,
|
|
4407
|
+
categories: p.categories || []
|
|
4408
|
+
};
|
|
4409
|
+
});
|
|
4410
|
+
|
|
4411
|
+
postsWithWc.sort((a, b) => wclOrder === 'asc' ? a.word_count - b.word_count : b.word_count - a.word_count);
|
|
4412
|
+
|
|
4413
|
+
const counts = postsWithWc.map(p => p.word_count);
|
|
4414
|
+
const total = counts.length || 1;
|
|
4415
|
+
const avg = counts.reduce((s, c) => s + c, 0) / total;
|
|
4416
|
+
const sorted = [...counts].sort((a, b) => a - b);
|
|
4417
|
+
const median = sorted.length % 2 === 0 ? (sorted[sorted.length / 2 - 1] + sorted[sorted.length / 2]) / 2 : sorted[Math.floor(sorted.length / 2)];
|
|
4418
|
+
|
|
4419
|
+
const dist = { very_short: 0, short: 0, medium: 0, standard: 0, long: 0, very_long: 0 };
|
|
4420
|
+
for (const p of postsWithWc) dist[p.segment]++;
|
|
4421
|
+
const distribution = {};
|
|
4422
|
+
for (const [seg, count] of Object.entries(dist)) {
|
|
4423
|
+
distribution[seg] = { count, percent: Math.round(count / total * 100) };
|
|
4424
|
+
}
|
|
4425
|
+
|
|
4426
|
+
result = json({
|
|
4427
|
+
total_analyzed: postsWithWc.length,
|
|
4428
|
+
avg_word_count: Math.round(avg),
|
|
4429
|
+
median_word_count: median,
|
|
4430
|
+
min_word_count: sorted[0] || 0,
|
|
4431
|
+
max_word_count: sorted[sorted.length - 1] || 0,
|
|
4432
|
+
distribution,
|
|
4433
|
+
posts: postsWithWc
|
|
4434
|
+
});
|
|
4435
|
+
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 } });
|
|
4436
|
+
break;
|
|
4437
|
+
}
|
|
4438
|
+
|
|
4439
|
+
// ── PLUGIN INTELLIGENCE v4.5 ──
|
|
4440
|
+
|
|
4441
|
+
case 'wp_get_rendered_head': {
|
|
4442
|
+
validateInput(args, { post_id: { type: 'number', required: true }, post_type: { type: 'string', enum: ['post', 'page'] } });
|
|
4443
|
+
const grhPostId = args.post_id;
|
|
4444
|
+
const grhPostType = args.post_type || 'post';
|
|
4445
|
+
const { url: grhBaseUrl, auth: grhAuth } = getActiveAuth();
|
|
4446
|
+
|
|
4447
|
+
const grhPlugin = await detectSeoPlugin(grhBaseUrl, fetch);
|
|
4448
|
+
if (!grhPlugin) throw new Error('No supported SEO plugin detected. wp_get_rendered_head requires RankMath or Yoast.');
|
|
4449
|
+
if (grhPlugin !== 'rankmath' && grhPlugin !== 'yoast') throw new Error(`Rendered head requires RankMath or Yoast (detected: ${grhPlugin})`);
|
|
4450
|
+
|
|
4451
|
+
const grhEp = grhPostType === 'page' ? `/pages/${grhPostId}?_fields=id,title,link,slug,meta` : `/posts/${grhPostId}?_fields=id,title,link,slug,meta`;
|
|
4452
|
+
const grhPost = await wpApiCall(grhEp);
|
|
4453
|
+
|
|
4454
|
+
const grhHeadResult = await getRenderedHead(grhBaseUrl, grhPost.link, grhPlugin, fetch, grhAuth);
|
|
4455
|
+
if (!grhHeadResult.success) throw new Error(grhHeadResult.error);
|
|
4456
|
+
|
|
4457
|
+
const grhParsed = parseRenderedHead(grhHeadResult.head);
|
|
4458
|
+
const grhMeta = grhPost.meta || {};
|
|
4459
|
+
|
|
4460
|
+
// Extract stored SEO meta based on plugin
|
|
4461
|
+
let grhStoredTitle, grhStoredDesc, grhStoredKeyword, grhStoredCanonical, grhStoredRobots;
|
|
4462
|
+
if (grhPlugin === 'rankmath') {
|
|
4463
|
+
grhStoredTitle = grhMeta.rank_math_title || null;
|
|
4464
|
+
grhStoredDesc = grhMeta.rank_math_description || null;
|
|
4465
|
+
grhStoredKeyword = grhMeta.rank_math_focus_keyword || null;
|
|
4466
|
+
grhStoredCanonical = grhMeta.rank_math_canonical_url || null;
|
|
4467
|
+
const rm = grhMeta.rank_math_robots || [];
|
|
4468
|
+
grhStoredRobots = Array.isArray(rm) && rm.length > 0 ? rm.join(', ') : null;
|
|
4469
|
+
} else {
|
|
4470
|
+
grhStoredTitle = grhMeta._yoast_wpseo_title || null;
|
|
4471
|
+
grhStoredDesc = grhMeta._yoast_wpseo_metadesc || null;
|
|
4472
|
+
grhStoredKeyword = grhMeta._yoast_wpseo_focuskw || null;
|
|
4473
|
+
grhStoredCanonical = grhMeta._yoast_wpseo_canonical || null;
|
|
4474
|
+
grhStoredRobots = grhMeta._yoast_wpseo_meta_robots_noindex === '1' ? 'noindex' : null;
|
|
4475
|
+
}
|
|
4476
|
+
|
|
4477
|
+
result = json({
|
|
4478
|
+
post_id: grhPostId,
|
|
4479
|
+
post_url: grhPost.link,
|
|
4480
|
+
seo_plugin: grhPlugin,
|
|
4481
|
+
rendered: grhParsed,
|
|
4482
|
+
stored: {
|
|
4483
|
+
title: grhStoredTitle,
|
|
4484
|
+
description: grhStoredDesc,
|
|
4485
|
+
focus_keyword: grhStoredKeyword,
|
|
4486
|
+
canonical: grhStoredCanonical,
|
|
4487
|
+
robots: grhStoredRobots
|
|
4488
|
+
},
|
|
4489
|
+
raw_head_length: grhHeadResult.head.length,
|
|
4490
|
+
schemas_count: grhParsed.schema_json_ld.length
|
|
4491
|
+
});
|
|
4492
|
+
auditLog({ tool: name, action: 'get_rendered_head', status: 'success', latency_ms: Date.now() - t0, params: { post_id: grhPostId, post_type: grhPostType, plugin: grhPlugin } });
|
|
4493
|
+
break;
|
|
4494
|
+
}
|
|
4495
|
+
|
|
4496
|
+
case 'wp_audit_rendered_seo': {
|
|
4497
|
+
validateInput(args, { limit: { type: 'number', min: 1, max: 50 }, post_type: { type: 'string', enum: ['post', 'page'] } });
|
|
4498
|
+
const arsLimit = args.limit || 10;
|
|
4499
|
+
const arsPostType = args.post_type || 'post';
|
|
4500
|
+
const { url: arsBaseUrl, auth: arsAuth } = getActiveAuth();
|
|
4501
|
+
|
|
4502
|
+
const arsPlugin = await detectSeoPlugin(arsBaseUrl, fetch);
|
|
4503
|
+
if (!arsPlugin) throw new Error('No supported SEO plugin detected. wp_audit_rendered_seo requires RankMath or Yoast.');
|
|
4504
|
+
if (arsPlugin !== 'rankmath' && arsPlugin !== 'yoast') throw new Error(`Rendered SEO audit requires RankMath or Yoast (detected: ${arsPlugin})`);
|
|
4505
|
+
|
|
4506
|
+
const arsEp = `/${arsPostType}s?per_page=${arsLimit}&status=publish&_fields=id,title,link,slug,meta`;
|
|
4507
|
+
const arsPosts = await wpApiCall(arsEp);
|
|
4508
|
+
|
|
4509
|
+
const arsResults = [];
|
|
4510
|
+
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 };
|
|
4511
|
+
|
|
4512
|
+
for (const p of arsPosts) {
|
|
4513
|
+
const headRes = await getRenderedHead(arsBaseUrl, p.link, arsPlugin, fetch, arsAuth);
|
|
4514
|
+
if (!headRes.success) {
|
|
4515
|
+
arsResults.push({ id: p.id, title: strip(p.title?.rendered || ''), url: p.link, score: 0, issues: ['head_fetch_failed'], rendered: null, stored: null });
|
|
4516
|
+
continue;
|
|
4517
|
+
}
|
|
4518
|
+
|
|
4519
|
+
const parsed = parseRenderedHead(headRes.head);
|
|
4520
|
+
const meta = p.meta || {};
|
|
4521
|
+
const issues = [];
|
|
4522
|
+
|
|
4523
|
+
// Extract stored meta
|
|
4524
|
+
let storedTitle, storedDesc, storedCanonical, storedRobots;
|
|
4525
|
+
if (arsPlugin === 'rankmath') {
|
|
4526
|
+
storedTitle = meta.rank_math_title || null;
|
|
4527
|
+
storedDesc = meta.rank_math_description || null;
|
|
4528
|
+
storedCanonical = meta.rank_math_canonical_url || null;
|
|
4529
|
+
const rm = meta.rank_math_robots || [];
|
|
4530
|
+
storedRobots = Array.isArray(rm) && rm.length > 0 ? rm.join(', ') : null;
|
|
4531
|
+
} else {
|
|
4532
|
+
storedTitle = meta._yoast_wpseo_title || null;
|
|
4533
|
+
storedDesc = meta._yoast_wpseo_metadesc || null;
|
|
4534
|
+
storedCanonical = meta._yoast_wpseo_canonical || null;
|
|
4535
|
+
storedRobots = meta._yoast_wpseo_meta_robots_noindex === '1' ? 'noindex' : null;
|
|
4536
|
+
}
|
|
4537
|
+
|
|
4538
|
+
// Compare rendered vs stored
|
|
4539
|
+
if (!parsed.title) { issues.push('missing_rendered_title'); arsSummary.missing_rendered_title++; }
|
|
4540
|
+
else if (storedTitle && !parsed.title.includes(storedTitle)) { issues.push('title_mismatch'); arsSummary.title_mismatch++; }
|
|
4541
|
+
|
|
4542
|
+
if (!parsed.meta_description) { issues.push('missing_rendered_description'); arsSummary.missing_rendered_description++; }
|
|
4543
|
+
else if (storedDesc && parsed.meta_description !== storedDesc) { issues.push('description_mismatch'); arsSummary.description_mismatch++; }
|
|
4544
|
+
|
|
4545
|
+
if (parsed.canonical && parsed.canonical !== p.link && storedCanonical && parsed.canonical !== storedCanonical) { issues.push('canonical_mismatch'); arsSummary.canonical_mismatch++; }
|
|
4546
|
+
|
|
4547
|
+
if (parsed.robots && parsed.robots.includes('noindex') && (!storedRobots || !storedRobots.includes('noindex'))) { issues.push('robots_mismatch'); arsSummary.robots_mismatch++; }
|
|
4548
|
+
|
|
4549
|
+
if (parsed.schema_json_ld.length === 0) { issues.push('schema_missing'); arsSummary.schema_missing++; }
|
|
4550
|
+
|
|
4551
|
+
const score = Math.max(0, 100 - issues.length * 15);
|
|
4552
|
+
|
|
4553
|
+
arsResults.push({
|
|
4554
|
+
id: p.id, title: strip(p.title?.rendered || ''), url: p.link, score, issues,
|
|
4555
|
+
rendered: { title: parsed.title, description: parsed.meta_description, canonical: parsed.canonical, robots: parsed.robots, schemas_count: parsed.schema_json_ld.length },
|
|
4556
|
+
stored: { title: storedTitle, description: storedDesc, canonical: storedCanonical, robots: storedRobots }
|
|
4557
|
+
});
|
|
4558
|
+
}
|
|
4559
|
+
|
|
4560
|
+
const arsAvgScore = arsResults.length > 0 ? arsResults.reduce((s, r) => s + r.score, 0) / arsResults.length : 0;
|
|
4561
|
+
|
|
4562
|
+
result = json({
|
|
4563
|
+
seo_plugin: arsPlugin,
|
|
4564
|
+
total_audited: arsResults.length,
|
|
4565
|
+
avg_score: Math.round(arsAvgScore),
|
|
4566
|
+
issues_summary: arsSummary,
|
|
4567
|
+
posts: arsResults
|
|
4568
|
+
});
|
|
4569
|
+
auditLog({ tool: name, action: 'audit_rendered_seo', status: 'success', latency_ms: Date.now() - t0, params: { limit: arsLimit, post_type: arsPostType, plugin: arsPlugin } });
|
|
4570
|
+
break;
|
|
4571
|
+
}
|
|
4572
|
+
|
|
4573
|
+
case 'wp_get_pillar_content': {
|
|
4574
|
+
validateInput(args, {
|
|
4575
|
+
post_id: { type: 'number' },
|
|
4576
|
+
set_pillar: { type: 'boolean' },
|
|
4577
|
+
list_pillars: { type: 'boolean' },
|
|
4578
|
+
post_type: { type: 'string', enum: ['post', 'page'] },
|
|
4579
|
+
limit: { type: 'number', min: 1, max: 500 }
|
|
4580
|
+
});
|
|
4581
|
+
const pcPostType = args.post_type || 'post';
|
|
4582
|
+
const { url: pcBaseUrl } = getActiveAuth();
|
|
4583
|
+
|
|
4584
|
+
const pcPlugin = await detectSeoPlugin(pcBaseUrl, fetch);
|
|
4585
|
+
if (pcPlugin !== 'rankmath') throw new Error('Pillar content requires RankMath (detected: ' + (pcPlugin || 'none') + ')');
|
|
4586
|
+
|
|
4587
|
+
if (args.list_pillars) {
|
|
4588
|
+
// Mode: list all pillar posts
|
|
4589
|
+
const pcLimit = args.limit || 100;
|
|
4590
|
+
const pcPosts = await wpApiCall(`/${pcPostType}s?per_page=${pcLimit}&status=publish&_fields=id,title,link,slug,meta`);
|
|
4591
|
+
const pillars = pcPosts.filter(p => (p.meta || {}).rank_math_pillar_content === 'on').map(p => ({
|
|
4592
|
+
id: p.id, title: strip(p.title?.rendered || ''), slug: p.slug, link: p.link, post_type: pcPostType
|
|
4593
|
+
}));
|
|
4594
|
+
result = json({ mode: 'list_pillars', seo_plugin: pcPlugin, pillar_count: pillars.length, pillars });
|
|
4595
|
+
auditLog({ tool: name, action: 'read_pillar_content', status: 'success', latency_ms: Date.now() - t0, params: { list_pillars: true, post_type: pcPostType, limit: pcLimit } });
|
|
4596
|
+
} else if (args.post_id !== undefined && args.set_pillar !== undefined) {
|
|
4597
|
+
// Mode: write — set/unset pillar flag
|
|
4598
|
+
if (getActiveControls().read_only) throw new Error('Blocked: READ-ONLY mode. Cannot update pillar content flag.');
|
|
4599
|
+
const pcPost = await wpApiCall(`/${pcPostType}s/${args.post_id}?_fields=id,title,link,meta`);
|
|
4600
|
+
await wpApiCall(`/${pcPostType}s/${args.post_id}`, { method: 'POST', body: JSON.stringify({ meta: { rank_math_pillar_content: args.set_pillar ? 'on' : '' } }) });
|
|
4601
|
+
result = json({
|
|
4602
|
+
mode: 'write', post_id: args.post_id, title: strip(pcPost.title?.rendered || ''),
|
|
4603
|
+
is_pillar: args.set_pillar, action: args.set_pillar ? 'marked_as_pillar' : 'unmarked_as_pillar', seo_plugin: pcPlugin
|
|
4604
|
+
});
|
|
4605
|
+
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 } });
|
|
4606
|
+
} else if (args.post_id !== undefined) {
|
|
4607
|
+
// Mode: read single post
|
|
4608
|
+
const pcPost = await wpApiCall(`/${pcPostType}s/${args.post_id}?_fields=id,title,link,meta`);
|
|
4609
|
+
const isPillar = (pcPost.meta || {}).rank_math_pillar_content === 'on';
|
|
4610
|
+
result = json({ mode: 'read', post_id: args.post_id, title: strip(pcPost.title?.rendered || ''), is_pillar: isPillar, seo_plugin: pcPlugin });
|
|
4611
|
+
auditLog({ tool: name, action: 'read_pillar_content', status: 'success', latency_ms: Date.now() - t0, params: { post_id: args.post_id, post_type: pcPostType } });
|
|
4612
|
+
} else {
|
|
4613
|
+
throw new Error('Provide post_id (read/write) or list_pillars:true');
|
|
4614
|
+
}
|
|
4615
|
+
break;
|
|
4616
|
+
}
|
|
4617
|
+
|
|
4618
|
+
case 'wp_audit_schema_plugins': {
|
|
4619
|
+
validateInput(args, {
|
|
4620
|
+
limit: { type: 'number', min: 1, max: 100 },
|
|
4621
|
+
post_type: { type: 'string', enum: ['post', 'page', 'both'] }
|
|
4622
|
+
});
|
|
4623
|
+
const aspLimit = args.limit || 20;
|
|
4624
|
+
const aspPostType = args.post_type || 'post';
|
|
4625
|
+
const { url: aspBaseUrl, auth: aspAuth } = getActiveAuth();
|
|
4626
|
+
|
|
4627
|
+
const aspPlugin = await detectSeoPlugin(aspBaseUrl, fetch);
|
|
4628
|
+
if (!aspPlugin) throw new Error('No supported SEO plugin detected');
|
|
4629
|
+
if (aspPlugin !== 'rankmath' && aspPlugin !== 'yoast') throw new Error(`Schema plugin audit requires RankMath or Yoast (detected: ${aspPlugin})`);
|
|
4630
|
+
|
|
4631
|
+
const aspRequired = {
|
|
4632
|
+
'Article': ['headline', 'datePublished', 'author'],
|
|
4633
|
+
'BlogPosting': ['headline', 'datePublished', 'author'],
|
|
4634
|
+
'NewsArticle': ['headline', 'datePublished', 'author'],
|
|
4635
|
+
'FAQPage': ['mainEntity'],
|
|
4636
|
+
'HowTo': ['name', 'step'],
|
|
4637
|
+
'LocalBusiness': ['name', 'address'],
|
|
4638
|
+
'BreadcrumbList': ['itemListElement'],
|
|
4639
|
+
'Organization': ['name'],
|
|
4640
|
+
'WebPage': ['name'],
|
|
4641
|
+
'WebSite': ['name', 'url']
|
|
4642
|
+
};
|
|
4643
|
+
|
|
4644
|
+
let aspAllPosts = [];
|
|
4645
|
+
if (aspPostType === 'both') {
|
|
4646
|
+
const aspP = await wpApiCall(`/posts?per_page=${aspLimit}&status=publish&_fields=id,title,link,slug,meta`);
|
|
4647
|
+
const aspG = await wpApiCall(`/pages?per_page=${aspLimit}&status=publish&_fields=id,title,link,slug,meta`);
|
|
4648
|
+
aspAllPosts = [...aspP, ...aspG];
|
|
4649
|
+
} else {
|
|
4650
|
+
aspAllPosts = await wpApiCall(`/${aspPostType}s?per_page=${aspLimit}&status=publish&_fields=id,title,link,slug,meta`);
|
|
4651
|
+
}
|
|
4652
|
+
|
|
4653
|
+
const aspResults = [];
|
|
4654
|
+
const aspTypesCounts = {};
|
|
4655
|
+
const aspIssuesSummary = { no_plugin_schema: 0, invalid_schema_json: 0, missing_required_fields: 0, no_article_schema: 0 };
|
|
4656
|
+
let aspWithSchema = 0;
|
|
4657
|
+
|
|
4658
|
+
for (const p of aspAllPosts) {
|
|
4659
|
+
const postIssues = [];
|
|
4660
|
+
const postSchemas = [];
|
|
4661
|
+
let schemas = [];
|
|
4662
|
+
|
|
4663
|
+
if (aspPlugin === 'rankmath') {
|
|
4664
|
+
const rawSchema = (p.meta || {}).rank_math_schema;
|
|
4665
|
+
if (!rawSchema || rawSchema === '{}') {
|
|
4666
|
+
postIssues.push('no_plugin_schema');
|
|
4667
|
+
aspIssuesSummary.no_plugin_schema++;
|
|
4668
|
+
} else {
|
|
4669
|
+
try {
|
|
4670
|
+
const parsed = typeof rawSchema === 'string' ? JSON.parse(rawSchema) : rawSchema;
|
|
4671
|
+
if (parsed['@type']) {
|
|
4672
|
+
schemas = [parsed];
|
|
4673
|
+
} else if (parsed['@graph']) {
|
|
4674
|
+
schemas = parsed['@graph'];
|
|
4675
|
+
} else {
|
|
4676
|
+
schemas = Object.values(parsed).filter(v => v && typeof v === 'object' && v['@type']);
|
|
4677
|
+
}
|
|
4678
|
+
if (schemas.length === 0) { postIssues.push('no_plugin_schema'); aspIssuesSummary.no_plugin_schema++; }
|
|
4679
|
+
} catch {
|
|
4680
|
+
postIssues.push('invalid_schema_json');
|
|
4681
|
+
aspIssuesSummary.invalid_schema_json++;
|
|
4682
|
+
}
|
|
4683
|
+
}
|
|
4684
|
+
} else {
|
|
4685
|
+
const headRes = await getRenderedHead(aspBaseUrl, p.link, aspPlugin, fetch, aspAuth);
|
|
4686
|
+
if (headRes.success) {
|
|
4687
|
+
const parsed = parseRenderedHead(headRes.head);
|
|
4688
|
+
schemas = parsed.schema_json_ld || [];
|
|
4689
|
+
if (schemas.length === 0) { postIssues.push('no_plugin_schema'); aspIssuesSummary.no_plugin_schema++; }
|
|
4690
|
+
} else {
|
|
4691
|
+
postIssues.push('no_plugin_schema');
|
|
4692
|
+
aspIssuesSummary.no_plugin_schema++;
|
|
4693
|
+
}
|
|
4694
|
+
}
|
|
4695
|
+
|
|
4696
|
+
let hasArticleType = false;
|
|
4697
|
+
for (const schema of schemas) {
|
|
4698
|
+
const schemaType = schema['@type'] || 'Unknown';
|
|
4699
|
+
if (['Article', 'BlogPosting', 'NewsArticle'].includes(schemaType)) hasArticleType = true;
|
|
4700
|
+
aspTypesCounts[schemaType] = (aspTypesCounts[schemaType] || 0) + 1;
|
|
4701
|
+
|
|
4702
|
+
const requiredFields = aspRequired[schemaType];
|
|
4703
|
+
const missingFields = [];
|
|
4704
|
+
if (requiredFields) {
|
|
4705
|
+
for (const field of requiredFields) {
|
|
4706
|
+
if (!schema[field]) missingFields.push(field);
|
|
4707
|
+
}
|
|
4708
|
+
}
|
|
4709
|
+
if (missingFields.length > 0) { postIssues.push('missing_required_fields'); aspIssuesSummary.missing_required_fields++; }
|
|
4710
|
+
postSchemas.push({ type: schemaType, valid: missingFields.length === 0, missing_fields: missingFields });
|
|
4711
|
+
}
|
|
4712
|
+
|
|
4713
|
+
if (schemas.length > 0 && !hasArticleType && aspPostType === 'post') {
|
|
4714
|
+
postIssues.push('no_article_schema');
|
|
4715
|
+
aspIssuesSummary.no_article_schema++;
|
|
4716
|
+
}
|
|
4717
|
+
|
|
4718
|
+
if (schemas.length > 0) aspWithSchema++;
|
|
4719
|
+
const postScore = Math.max(0, 100 - postIssues.length * 15);
|
|
4720
|
+
aspResults.push({ id: p.id, title: strip(p.title?.rendered || ''), slug: p.slug, score: postScore, schemas: postSchemas, issues: postIssues });
|
|
4721
|
+
}
|
|
4722
|
+
|
|
4723
|
+
const aspAvg = aspResults.length > 0 ? aspResults.reduce((s, r) => s + r.score, 0) / aspResults.length : 0;
|
|
4724
|
+
const aspWithout = aspAllPosts.length - aspWithSchema;
|
|
4725
|
+
|
|
4726
|
+
result = json({
|
|
4727
|
+
seo_plugin: aspPlugin,
|
|
4728
|
+
total_audited: aspResults.length,
|
|
4729
|
+
avg_score: Math.round(aspAvg),
|
|
4730
|
+
schema_coverage: {
|
|
4731
|
+
posts_with_schema: aspWithSchema,
|
|
4732
|
+
posts_without_schema: aspWithout,
|
|
4733
|
+
coverage_percent: aspAllPosts.length > 0 ? Math.round(aspWithSchema / aspAllPosts.length * 100) : 0
|
|
4734
|
+
},
|
|
4735
|
+
schema_types_found: aspTypesCounts,
|
|
4736
|
+
issues_summary: aspIssuesSummary,
|
|
4737
|
+
posts: aspResults
|
|
4738
|
+
});
|
|
4739
|
+
auditLog({ tool: name, action: 'audit_schema_plugins', status: 'success', latency_ms: Date.now() - t0, params: { limit: aspLimit, post_type: aspPostType, plugin: aspPlugin } });
|
|
4740
|
+
break;
|
|
4741
|
+
}
|
|
4742
|
+
|
|
4743
|
+
case 'wp_get_seo_score': {
|
|
4744
|
+
validateInput(args, {
|
|
4745
|
+
post_id: { type: 'number' },
|
|
4746
|
+
limit: { type: 'number', min: 1, max: 100 },
|
|
4747
|
+
post_type: { type: 'string', enum: ['post', 'page'] },
|
|
4748
|
+
order: { type: 'string', enum: ['asc', 'desc'] }
|
|
4749
|
+
});
|
|
4750
|
+
const gssPostType = args.post_type || 'post';
|
|
4751
|
+
const { url: gssBaseUrl } = getActiveAuth();
|
|
4752
|
+
|
|
4753
|
+
const gssPlugin = await detectSeoPlugin(gssBaseUrl, fetch);
|
|
4754
|
+
if (gssPlugin !== 'rankmath') throw new Error('SEO score requires RankMath (detected: ' + (gssPlugin || 'none') + ')');
|
|
4755
|
+
|
|
4756
|
+
if (args.post_id !== undefined) {
|
|
4757
|
+
const gssPost = await wpApiCall(`/${gssPostType}s/${args.post_id}?_fields=id,title,link,slug,meta`);
|
|
4758
|
+
const gssMeta = gssPost.meta || {};
|
|
4759
|
+
const gssRaw = gssMeta.rank_math_seo_score;
|
|
4760
|
+
const gssScore = gssRaw !== undefined && gssRaw !== null && gssRaw !== '' ? parseInt(gssRaw, 10) : null;
|
|
4761
|
+
const gssKw = gssMeta.rank_math_focus_keyword || null;
|
|
4762
|
+
const gssRating = gssScore === null || gssScore === 0 ? 'no_score' : gssScore >= 80 ? 'excellent' : gssScore >= 60 ? 'good' : gssScore >= 40 ? 'average' : 'poor';
|
|
4763
|
+
|
|
4764
|
+
result = json({
|
|
4765
|
+
mode: 'single', post_id: args.post_id, title: strip(gssPost.title?.rendered || ''),
|
|
4766
|
+
link: gssPost.link, seo_score: gssScore, focus_keyword: gssKw, rating: gssRating
|
|
4767
|
+
});
|
|
4768
|
+
auditLog({ tool: name, action: 'get_seo_score', status: 'success', latency_ms: Date.now() - t0, params: { post_id: args.post_id, post_type: gssPostType } });
|
|
4769
|
+
} else {
|
|
4770
|
+
const gssLimit = args.limit || 20;
|
|
4771
|
+
const gssSortOrder = args.order || 'desc';
|
|
4772
|
+
const gssPosts = await wpApiCall(`/${gssPostType}s?per_page=${gssLimit}&status=publish&_fields=id,title,link,slug,meta`);
|
|
4773
|
+
|
|
4774
|
+
const gssItems = gssPosts.map(p => {
|
|
4775
|
+
const m = p.meta || {};
|
|
4776
|
+
const raw = m.rank_math_seo_score;
|
|
4777
|
+
const score = raw !== undefined && raw !== null && raw !== '' ? parseInt(raw, 10) : null;
|
|
4778
|
+
const kw = m.rank_math_focus_keyword || null;
|
|
4779
|
+
const rating = score === null || score === 0 ? 'no_score' : score >= 80 ? 'excellent' : score >= 60 ? 'good' : score >= 40 ? 'average' : 'poor';
|
|
4780
|
+
return { id: p.id, title: strip(p.title?.rendered || ''), slug: p.slug, link: p.link, seo_score: score, focus_keyword: kw, rating };
|
|
4781
|
+
});
|
|
4782
|
+
|
|
4783
|
+
gssItems.sort((a, b) => {
|
|
4784
|
+
const sa = a.seo_score === null ? -1 : a.seo_score;
|
|
4785
|
+
const sb = b.seo_score === null ? -1 : b.seo_score;
|
|
4786
|
+
return gssSortOrder === 'asc' ? sa - sb : sb - sa;
|
|
4787
|
+
});
|
|
4788
|
+
|
|
4789
|
+
const gssDist = { excellent: 0, good: 0, average: 0, poor: 0, no_score: 0 };
|
|
4790
|
+
const gssScores = [];
|
|
4791
|
+
for (const item of gssItems) {
|
|
4792
|
+
gssDist[item.rating]++;
|
|
4793
|
+
if (item.seo_score !== null && item.seo_score > 0) gssScores.push(item.seo_score);
|
|
4794
|
+
}
|
|
4795
|
+
const gssTotal = gssItems.length;
|
|
4796
|
+
const gssAvg = gssScores.length > 0 ? gssScores.reduce((a, b) => a + b, 0) / gssScores.length : 0;
|
|
4797
|
+
const gssSorted = [...gssScores].sort((a, b) => a - b);
|
|
4798
|
+
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;
|
|
4799
|
+
|
|
4800
|
+
result = json({
|
|
4801
|
+
mode: 'bulk', total_analyzed: gssItems.length,
|
|
4802
|
+
avg_score: Math.round(gssAvg), median_score: gssMedian,
|
|
4803
|
+
distribution: {
|
|
4804
|
+
excellent: { count: gssDist.excellent, percent: gssTotal > 0 ? Math.round(gssDist.excellent / gssTotal * 100) : 0 },
|
|
4805
|
+
good: { count: gssDist.good, percent: gssTotal > 0 ? Math.round(gssDist.good / gssTotal * 100) : 0 },
|
|
4806
|
+
average: { count: gssDist.average, percent: gssTotal > 0 ? Math.round(gssDist.average / gssTotal * 100) : 0 },
|
|
4807
|
+
poor: { count: gssDist.poor, percent: gssTotal > 0 ? Math.round(gssDist.poor / gssTotal * 100) : 0 },
|
|
4808
|
+
no_score: { count: gssDist.no_score, percent: gssTotal > 0 ? Math.round(gssDist.no_score / gssTotal * 100) : 0 }
|
|
4809
|
+
},
|
|
4810
|
+
posts: gssItems
|
|
4811
|
+
});
|
|
4812
|
+
auditLog({ tool: name, action: 'get_seo_score', status: 'success', latency_ms: Date.now() - t0, params: { limit: gssLimit, post_type: gssPostType, order: gssSortOrder } });
|
|
4813
|
+
}
|
|
4814
|
+
break;
|
|
4815
|
+
}
|
|
4816
|
+
|
|
4817
|
+
case 'wp_get_twitter_meta': {
|
|
4818
|
+
validateInput(args, {
|
|
4819
|
+
post_id: { type: 'number', required: true },
|
|
4820
|
+
post_type: { type: 'string', enum: ['post', 'page'] },
|
|
4821
|
+
twitter_title: { type: 'string' },
|
|
4822
|
+
twitter_description: { type: 'string' },
|
|
4823
|
+
twitter_image: { type: 'string' }
|
|
4824
|
+
});
|
|
4825
|
+
const gtmPostId = args.post_id;
|
|
4826
|
+
const gtmPostType = args.post_type || 'post';
|
|
4827
|
+
const { url: gtmBaseUrl } = getActiveAuth();
|
|
4828
|
+
const gtmPlugin = await detectSeoPlugin(gtmBaseUrl, fetch);
|
|
4829
|
+
if (!gtmPlugin) throw new Error('No supported SEO plugin detected');
|
|
4830
|
+
|
|
4831
|
+
const gtmIsWrite = args.twitter_title !== undefined || args.twitter_description !== undefined || args.twitter_image !== undefined;
|
|
4832
|
+
|
|
4833
|
+
if (gtmIsWrite) {
|
|
4834
|
+
if (getActiveControls().read_only) throw new Error('Blocked: READ-ONLY mode. Cannot update Twitter meta.');
|
|
4835
|
+
if (gtmPlugin !== 'rankmath' && gtmPlugin !== 'yoast') throw new Error('Twitter meta write requires RankMath or Yoast (detected: ' + gtmPlugin + ')');
|
|
4836
|
+
|
|
4837
|
+
const gtmMeta = {};
|
|
4838
|
+
const gtmUpdated = [];
|
|
4839
|
+
if (gtmPlugin === 'rankmath') {
|
|
4840
|
+
if (args.twitter_title !== undefined) { gtmMeta.rank_math_twitter_title = args.twitter_title; gtmUpdated.push('twitter_title'); }
|
|
4841
|
+
if (args.twitter_description !== undefined) { gtmMeta.rank_math_twitter_description = args.twitter_description; gtmUpdated.push('twitter_description'); }
|
|
4842
|
+
if (args.twitter_image !== undefined) { gtmMeta.rank_math_twitter_image = args.twitter_image; gtmUpdated.push('twitter_image'); }
|
|
4843
|
+
} else {
|
|
4844
|
+
if (args.twitter_title !== undefined) { gtmMeta['_yoast_wpseo_twitter-title'] = args.twitter_title; gtmUpdated.push('twitter_title'); }
|
|
4845
|
+
if (args.twitter_description !== undefined) { gtmMeta['_yoast_wpseo_twitter-description'] = args.twitter_description; gtmUpdated.push('twitter_description'); }
|
|
4846
|
+
if (args.twitter_image !== undefined) { gtmMeta['_yoast_wpseo_twitter-image'] = args.twitter_image; gtmUpdated.push('twitter_image'); }
|
|
4847
|
+
}
|
|
4848
|
+
|
|
4849
|
+
const gtmPost = await wpApiCall(`/${gtmPostType}s/${gtmPostId}?_fields=id,title,link,meta`);
|
|
4850
|
+
await wpApiCall(`/${gtmPostType}s/${gtmPostId}`, { method: 'POST', body: JSON.stringify({ meta: gtmMeta }) });
|
|
4851
|
+
|
|
4852
|
+
result = json({
|
|
4853
|
+
mode: 'write', post_id: gtmPostId, title: strip(gtmPost.title?.rendered || ''),
|
|
4854
|
+
seo_plugin: gtmPlugin, updated_fields: gtmUpdated,
|
|
4855
|
+
twitter: { title: args.twitter_title || null, description: args.twitter_description || null, image: args.twitter_image || null }
|
|
4856
|
+
});
|
|
4857
|
+
auditLog({ tool: name, action: 'update_twitter_meta', target: gtmPostId, target_type: gtmPostType, status: 'success', latency_ms: Date.now() - t0, params: { updated_fields: gtmUpdated } });
|
|
4858
|
+
} else {
|
|
4859
|
+
const gtmPost = await wpApiCall(`/${gtmPostType}s/${gtmPostId}?_fields=id,title,link,meta`);
|
|
4860
|
+
const gtmM = gtmPost.meta || {};
|
|
4861
|
+
|
|
4862
|
+
let gtmTitle, gtmDesc, gtmImage, gtmCard;
|
|
4863
|
+
if (gtmPlugin === 'rankmath') {
|
|
4864
|
+
gtmTitle = gtmM.rank_math_twitter_title || null;
|
|
4865
|
+
gtmDesc = gtmM.rank_math_twitter_description || null;
|
|
4866
|
+
gtmImage = gtmM.rank_math_twitter_image || null;
|
|
4867
|
+
gtmCard = gtmM.rank_math_twitter_card_type || null;
|
|
4868
|
+
} else if (gtmPlugin === 'yoast') {
|
|
4869
|
+
gtmTitle = gtmM['_yoast_wpseo_twitter-title'] || null;
|
|
4870
|
+
gtmDesc = gtmM['_yoast_wpseo_twitter-description'] || null;
|
|
4871
|
+
gtmImage = gtmM['_yoast_wpseo_twitter-image'] || null;
|
|
4872
|
+
gtmCard = null;
|
|
4873
|
+
} else {
|
|
4874
|
+
gtmTitle = gtmM._seopress_social_twitter_title || null;
|
|
4875
|
+
gtmDesc = gtmM._seopress_social_twitter_desc || null;
|
|
4876
|
+
gtmImage = gtmM._seopress_social_twitter_img || null;
|
|
4877
|
+
gtmCard = null;
|
|
4878
|
+
}
|
|
4879
|
+
|
|
4880
|
+
result = json({
|
|
4881
|
+
mode: 'read', post_id: gtmPostId, title: strip(gtmPost.title?.rendered || ''),
|
|
4882
|
+
link: gtmPost.link, seo_plugin: gtmPlugin,
|
|
4883
|
+
twitter: { title: gtmTitle, description: gtmDesc, image: gtmImage, card_type: gtmCard }
|
|
4884
|
+
});
|
|
4885
|
+
auditLog({ tool: name, action: 'read_twitter_meta', status: 'success', latency_ms: Date.now() - t0, params: { post_id: gtmPostId, post_type: gtmPostType, plugin: gtmPlugin } });
|
|
4886
|
+
}
|
|
4887
|
+
break;
|
|
4888
|
+
}
|
|
4889
|
+
|
|
1302
4890
|
default:
|
|
1303
4891
|
throw new Error(`Unknown tool: "${name}".`);
|
|
1304
4892
|
}
|
|
@@ -1364,4 +4952,4 @@ if (process.env.NODE_ENV !== 'test') {
|
|
|
1364
4952
|
main().catch((error) => { log.error(`Fatal: ${error.message}`); process.exit(1); });
|
|
1365
4953
|
}
|
|
1366
4954
|
|
|
1367
|
-
export { server };
|
|
4955
|
+
export { server, getActiveControls, getControlSources, _testSetTarget };
|