@adsim/wordpress-mcp-server 4.6.0 → 5.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.env.example +18 -0
- package/README.md +851 -499
- package/companion/mcp-diagnostics.php +1184 -0
- package/dxt/manifest.json +715 -98
- package/index.js +166 -4786
- package/package.json +14 -6
- package/src/data/plugin-performance-data.json +59 -0
- package/src/shared/api.js +79 -0
- package/src/shared/audit.js +39 -0
- package/src/shared/context.js +15 -0
- package/src/shared/governance.js +98 -0
- package/src/shared/utils.js +148 -0
- package/src/tools/comments.js +50 -0
- package/src/tools/content.js +353 -0
- package/src/tools/core.js +114 -0
- package/src/tools/editorial.js +634 -0
- package/src/tools/fse.js +370 -0
- package/src/tools/health.js +160 -0
- package/src/tools/index.js +96 -0
- package/src/tools/intelligence.js +2082 -0
- package/src/tools/links.js +118 -0
- package/src/tools/media.js +71 -0
- package/src/tools/performance.js +219 -0
- package/src/tools/plugins.js +368 -0
- package/src/tools/schema.js +417 -0
- package/src/tools/security.js +590 -0
- package/src/tools/seo.js +1633 -0
- package/src/tools/taxonomy.js +115 -0
- package/src/tools/users.js +188 -0
- package/src/tools/woocommerce.js +1008 -0
- package/src/tools/workflow.js +409 -0
- package/src/transport/http.js +39 -0
- package/tests/unit/helpers/pagination.test.js +43 -0
- package/tests/unit/tools/bulkUpdate.test.js +188 -0
- package/tests/unit/tools/diagnostics.test.js +397 -0
- package/tests/unit/tools/dynamicFiltering.test.js +100 -8
- package/tests/unit/tools/editorialIntelligence.test.js +817 -0
- package/tests/unit/tools/fse.test.js +548 -0
- package/tests/unit/tools/multilingual.test.js +653 -0
- package/tests/unit/tools/performance.test.js +351 -0
- package/tests/unit/tools/runWorkflow.test.js +150 -0
- package/tests/unit/tools/schema.test.js +477 -0
- package/tests/unit/tools/security.test.js +695 -0
- package/tests/unit/tools/site.test.js +1 -1
- package/tests/unit/tools/users.crud.test.js +399 -0
- package/tests/unit/tools/validateBlocks.test.js +186 -0
- package/tests/unit/tools/visualStaging.test.js +271 -0
- package/tests/unit/tools/woocommerce.advanced.test.js +679 -0
package/src/tools/seo.js
ADDED
|
@@ -0,0 +1,1633 @@
|
|
|
1
|
+
// src/tools/seo.js — seo tools (19)
|
|
2
|
+
// Definitions + handlers (v5.0.0 refactor Step B+C)
|
|
3
|
+
|
|
4
|
+
import { json, strip } from '../shared/utils.js';
|
|
5
|
+
import { validateInput } from '../shared/governance.js';
|
|
6
|
+
import { rt } from '../shared/context.js';
|
|
7
|
+
|
|
8
|
+
export const definitions = [
|
|
9
|
+
{ name: 'wp_get_seo_meta', _category: 'seo', description: 'Use to read SEO title, description, focus keyword, canonical, robots, OG for one post. Auto-detects Yoast/RankMath/SEOPress/AIOSEO. Read-only. Hint: prefer this over wp_get_post for SEO-only workflows.',
|
|
10
|
+
inputSchema: { type: 'object', properties: { id: { type: 'number' }, post_type: { type: 'string', default: 'post', description: 'post or page' } }, required: ['id'] }},
|
|
11
|
+
{ name: 'wp_update_seo_meta', _category: 'seo', description: 'Use to update SEO title, description, focus keyword, canonical, or robots. Auto-detects SEO plugin. Write — blocked by WP_READ_ONLY.',
|
|
12
|
+
inputSchema: { type: 'object', properties: { id: { type: 'number' }, 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' }, robots_noindex: { type: 'boolean' }, robots_nofollow: { type: 'boolean' } }, required: ['id'] }},
|
|
13
|
+
{ name: 'wp_audit_seo', _category: 'seo', description: 'Use for quick bulk SEO scoring (0-100) across posts or pages. Checks missing titles, descriptions, keywords, and length issues. Read-only. Hint: use wp_audit_rendered_seo for rendered-vs-stored comparison.',
|
|
14
|
+
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' } }}},
|
|
15
|
+
{ name: 'wp_audit_media_seo', _category: 'seo', description: 'Use when checking image SEO. Scans media library for missing/short alt text and bad filenames. Returns per-image scores + fix list. Read-only.',
|
|
16
|
+
inputSchema: { type: 'object', properties: { per_page: { type: 'number', default: 50 }, page: { type: 'number', default: 1 }, post_id: { type: 'number', description: 'Also scan inline images from this post' } }}},
|
|
17
|
+
{ name: 'wp_find_orphan_pages', _category: 'seo', description: 'Use to find posts with zero inbound internal links, sorted by word count. Read-only. Hint: combine with wp_suggest_internal_links to fix orphans.',
|
|
18
|
+
inputSchema: { type: 'object', properties: { per_page: { type: 'number', default: 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' } }}},
|
|
19
|
+
{ name: 'wp_audit_heading_structure', _category: 'seo', description: 'Use to check H1-H6 hierarchy in a single post. Detects H1 in body, level skips, empty headings. Read-only.',
|
|
20
|
+
inputSchema: { type: 'object', properties: { id: { type: 'number' }, post_type: { type: 'string', default: 'post', description: 'post or page' }, focus_keyword: { type: 'string', description: 'Keyword to check in H2 headings' } }, required: ['id'] }},
|
|
21
|
+
{ name: 'wp_find_thin_content', _category: 'seo', description: 'Use to surface short/low-quality posts below a word count threshold. Classifies severity. Read-only.',
|
|
22
|
+
inputSchema: { type: 'object', properties: { limit: { type: 'number', default: 100 }, 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' } }}},
|
|
23
|
+
{ name: 'wp_audit_canonicals', _category: 'seo', description: 'Use to validate canonical URLs across posts/pages. Detects missing, mismatched, or staging URLs. Auto-detects SEO plugin. Read-only.',
|
|
24
|
+
inputSchema: { type: 'object', properties: { limit: { type: 'number', default: 50 }, post_type: { type: 'string', default: 'post', description: 'post, page, or both' }, check_staging_patterns: { type: 'boolean', default: true, description: 'Detect staging/dev URLs' } }}},
|
|
25
|
+
{ name: 'wp_analyze_eeat_signals', _category: 'seo', description: 'Use to score E-E-A-T per post (0-100): author bio, dates, citations, word count, structured data. Read-only.',
|
|
26
|
+
inputSchema: { type: 'object', properties: { post_ids: { type: 'array', items: { type: 'number' }, description: 'Specific post IDs (if empty, audits latest N)' }, limit: { type: 'number', default: 10 }, 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' } }}},
|
|
27
|
+
{ name: 'wp_find_broken_internal_links', _category: 'seo', description: 'Use to check internal links via HEAD requests. Returns broken (4xx), redirected (3xx), and slow links. Read-only.',
|
|
28
|
+
inputSchema: { type: 'object', properties: { limit_posts: { type: 'number', default: 20 }, 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' } }}},
|
|
29
|
+
{ name: 'wp_find_keyword_cannibalization', _category: 'seo', description: 'Use to detect posts competing on the same focus keyword. Groups conflicts, flags weakest post. Read-only.',
|
|
30
|
+
inputSchema: { type: 'object', properties: { limit: { type: 'number', default: 200 }, 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)' } }}},
|
|
31
|
+
{ name: 'wp_audit_taxonomies', _category: 'seo', description: 'Use to detect taxonomy bloat: empty/single-post terms, near-duplicates, missing descriptions. Read-only.',
|
|
32
|
+
inputSchema: { type: 'object', properties: { check_tags: { type: 'boolean', default: true }, check_categories: { type: 'boolean', default: true }, 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' } }}},
|
|
33
|
+
{ name: 'wp_audit_outbound_links', _category: 'seo', description: 'Use to analyze external link profile per post. Detects over-linking, missing nofollow, broken external URLs. Read-only.',
|
|
34
|
+
inputSchema: { type: 'object', properties: { limit: { type: 'number', default: 30 }, 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' } }}},
|
|
35
|
+
{ name: 'wp_detect_multilingual_plugin', _category: 'seo', description: 'Use to detect active multilingual plugin: WPML, Polylang Pro, Polylang Free (hreflang fallback), TranslatePress, or none. Returns languages, default language, and API availability. Read-only.',
|
|
36
|
+
inputSchema: { type: 'object', properties: {} }},
|
|
37
|
+
{ name: 'wp_list_languages', _category: 'seo', description: 'Use to list all configured site languages with code, name, native name, locale, URL prefix, and optional post counts. Supports WPML, Polylang (Pro+Free), TranslatePress. Read-only.',
|
|
38
|
+
inputSchema: { type: 'object', properties: { include_post_count: { type: 'boolean', description: 'default false (expensive)' } }}},
|
|
39
|
+
{ name: 'wp_get_post_translations', _category: 'seo', description: 'Use to get all translations of a post. Returns translation post IDs, titles, URLs, statuses, and SEO meta presence per language. Supports WPML, Polylang (Pro+Free), TranslatePress. Read-only.',
|
|
40
|
+
inputSchema: { type: 'object', properties: { post_id: { type: 'number', description: 'Source post ID' }, post_type: { type: 'string', description: 'Post type (default "post")' } }, required: ['post_id'] }},
|
|
41
|
+
{ name: 'wp_audit_translation_coverage', _category: 'seo', description: 'Use to audit translation completeness across all languages and post types. Returns coverage percentages and top untranslated posts by word count. Read-only.',
|
|
42
|
+
inputSchema: { type: 'object', properties: { post_types: { type: 'array', items: { type: 'string' }, description: 'Post types to audit (default ["post","page"])' }, min_word_count: { type: 'number', description: 'default 0' } }}},
|
|
43
|
+
{ name: 'wp_find_missing_seo_translations', _category: 'seo', description: 'Use to find translated posts missing SEO metadata (title, description, OG). Cross-references source SEO with translations. Read-only.',
|
|
44
|
+
inputSchema: { type: 'object', properties: { source_lang: { type: 'string', description: 'Source language code (default: site default)' }, post_types: { type: 'array', items: { type: 'string' }, description: 'Post types (default ["post","page"])' }, fields_to_check: { type: 'array', items: { type: 'string' }, description: 'SEO fields to check (default ["title","description","og_title","og_description"])' }, limit: { type: 'number', description: 'default 50' } }}},
|
|
45
|
+
{ name: 'wp_sync_seo_meta_translations', _category: 'seo', description: 'Use to copy SEO metadata from a source post to its translations. Dry run by default for safety. Write — blocked by WP_READ_ONLY (except dry_run=true).',
|
|
46
|
+
inputSchema: { type: 'object', properties: { post_id: { type: 'number', description: 'Source post ID' }, source_lang: { type: 'string', description: 'Source language code' }, target_langs: { type: 'array', items: { type: 'string' }, description: 'Target language codes' }, fields: { type: 'array', items: { type: 'string', enum: ['title', 'description', 'og_title', 'og_description', 'og_image', 'twitter_title', 'twitter_description', 'all'] }, description: 'Fields to sync (default ["all"])' }, dry_run: { type: 'boolean', description: 'default true (preview only)' }, overwrite_existing: { type: 'boolean', description: 'default false' } }, required: ['post_id', 'source_lang', 'target_langs'] }}
|
|
47
|
+
];
|
|
48
|
+
|
|
49
|
+
export const handlers = {};
|
|
50
|
+
|
|
51
|
+
handlers['wp_get_seo_meta'] = async (args) => {
|
|
52
|
+
const t0 = Date.now();
|
|
53
|
+
let result;
|
|
54
|
+
const { wpApiCall, auditLog, name } = rt;
|
|
55
|
+
validateInput(args, { id: { type: 'number', required: true, min: 1 } });
|
|
56
|
+
const { id, post_type = 'post' } = args;
|
|
57
|
+
const ep = post_type === 'page' ? `/pages/${id}` : `/posts/${id}`;
|
|
58
|
+
const p = await wpApiCall(ep);
|
|
59
|
+
const meta = p.meta || {};
|
|
60
|
+
const yoastHead = p.yoast_head_json || null;
|
|
61
|
+
|
|
62
|
+
// Auto-detect SEO plugin and extract metadata
|
|
63
|
+
const seo = { plugin: 'none', title: null, description: null, focus_keyword: null, canonical: null, robots: { noindex: false, nofollow: false }, og_title: null, og_description: null, og_image: null, schema: null };
|
|
64
|
+
|
|
65
|
+
// Yoast SEO (most common)
|
|
66
|
+
if (meta._yoast_wpseo_title || meta._yoast_wpseo_metadesc || yoastHead) {
|
|
67
|
+
seo.plugin = 'yoast';
|
|
68
|
+
seo.title = meta._yoast_wpseo_title || yoastHead?.title || null;
|
|
69
|
+
seo.description = meta._yoast_wpseo_metadesc || yoastHead?.description || null;
|
|
70
|
+
seo.focus_keyword = meta._yoast_wpseo_focuskw || null;
|
|
71
|
+
seo.canonical = meta._yoast_wpseo_canonical || yoastHead?.canonical || null;
|
|
72
|
+
seo.robots.noindex = meta._yoast_wpseo_meta_robots_noindex === '1' || yoastHead?.robots?.index === 'noindex';
|
|
73
|
+
seo.robots.nofollow = meta._yoast_wpseo_meta_robots_nofollow === '1' || yoastHead?.robots?.follow === 'nofollow';
|
|
74
|
+
if (yoastHead?.og_title) seo.og_title = yoastHead.og_title;
|
|
75
|
+
if (yoastHead?.og_description) seo.og_description = yoastHead.og_description;
|
|
76
|
+
if (yoastHead?.og_image?.[0]?.url) seo.og_image = yoastHead.og_image[0].url;
|
|
77
|
+
if (yoastHead?.schema) seo.schema = yoastHead.schema;
|
|
78
|
+
}
|
|
79
|
+
// RankMath
|
|
80
|
+
else if (meta.rank_math_title || meta.rank_math_description) {
|
|
81
|
+
seo.plugin = 'rankmath';
|
|
82
|
+
seo.title = meta.rank_math_title || null;
|
|
83
|
+
seo.description = meta.rank_math_description || null;
|
|
84
|
+
seo.focus_keyword = meta.rank_math_focus_keyword || null;
|
|
85
|
+
seo.canonical = meta.rank_math_canonical_url || null;
|
|
86
|
+
const robots = meta.rank_math_robots || [];
|
|
87
|
+
seo.robots.noindex = Array.isArray(robots) ? robots.includes('noindex') : false;
|
|
88
|
+
seo.robots.nofollow = Array.isArray(robots) ? robots.includes('nofollow') : false;
|
|
89
|
+
seo.og_title = meta.rank_math_facebook_title || null;
|
|
90
|
+
seo.og_description = meta.rank_math_facebook_description || null;
|
|
91
|
+
seo.og_image = meta.rank_math_facebook_image || null;
|
|
92
|
+
}
|
|
93
|
+
// SEOPress
|
|
94
|
+
else if (meta._seopress_titles_title || meta._seopress_titles_desc) {
|
|
95
|
+
seo.plugin = 'seopress';
|
|
96
|
+
seo.title = meta._seopress_titles_title || null;
|
|
97
|
+
seo.description = meta._seopress_titles_desc || null;
|
|
98
|
+
seo.focus_keyword = meta._seopress_analysis_target_kw || null;
|
|
99
|
+
seo.canonical = meta._seopress_robots_canonical || null;
|
|
100
|
+
seo.robots.noindex = meta._seopress_robots_index === 'yes';
|
|
101
|
+
seo.robots.nofollow = meta._seopress_robots_follow === 'yes';
|
|
102
|
+
seo.og_title = meta._seopress_social_fb_title || null;
|
|
103
|
+
seo.og_description = meta._seopress_social_fb_desc || null;
|
|
104
|
+
seo.og_image = meta._seopress_social_fb_img || null;
|
|
105
|
+
}
|
|
106
|
+
// All in One SEO
|
|
107
|
+
else if (meta._aioseo_title || meta._aioseo_description) {
|
|
108
|
+
seo.plugin = 'aioseo';
|
|
109
|
+
seo.title = meta._aioseo_title || null;
|
|
110
|
+
seo.description = meta._aioseo_description || null;
|
|
111
|
+
seo.focus_keyword = meta._aioseo_keywords || null;
|
|
112
|
+
seo.canonical = meta._aioseo_canonical_url || null;
|
|
113
|
+
seo.robots.noindex = meta._aioseo_noindex === '1';
|
|
114
|
+
seo.robots.nofollow = meta._aioseo_nofollow === '1';
|
|
115
|
+
seo.og_title = meta._aioseo_og_title || null;
|
|
116
|
+
seo.og_description = meta._aioseo_og_description || null;
|
|
117
|
+
seo.og_image = meta._aioseo_og_image || null;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// Fallback: check raw meta keys for any SEO data
|
|
121
|
+
if (seo.plugin === 'none') {
|
|
122
|
+
const seoKeys = Object.keys(meta).filter(k => k.includes('seo') || k.includes('yoast') || k.includes('rank_math') || k.includes('aioseo') || k.includes('seopress'));
|
|
123
|
+
if (seoKeys.length > 0) {
|
|
124
|
+
seo.plugin = 'unknown';
|
|
125
|
+
seo.raw_keys = seoKeys;
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
result = json({
|
|
130
|
+
id: p.id, title: strip(p.title.rendered), slug: p.slug, link: p.link, status: p.status,
|
|
131
|
+
seo, all_meta_keys: Object.keys(meta)
|
|
132
|
+
});
|
|
133
|
+
auditLog({ tool: name, target: id, target_type: post_type, action: 'read_seo', status: 'success', latency_ms: Date.now() - t0 });
|
|
134
|
+
return result;
|
|
135
|
+
};
|
|
136
|
+
handlers['wp_update_seo_meta'] = async (args) => {
|
|
137
|
+
const t0 = Date.now();
|
|
138
|
+
let result;
|
|
139
|
+
const { wpApiCall, auditLog, name } = rt;
|
|
140
|
+
validateInput(args, { id: { type: 'number', required: true, min: 1 } });
|
|
141
|
+
const { id, post_type = 'post', title, description, focus_keyword, canonical_url, robots_noindex, robots_nofollow } = args;
|
|
142
|
+
|
|
143
|
+
// First, detect which SEO plugin is installed by reading current meta
|
|
144
|
+
const readEp = post_type === 'page' ? `/pages/${id}` : `/posts/${id}`;
|
|
145
|
+
const current = await wpApiCall(readEp);
|
|
146
|
+
const currentMeta = current.meta || {};
|
|
147
|
+
const yh = current.yoast_head_json || null;
|
|
148
|
+
|
|
149
|
+
let plugin = 'none';
|
|
150
|
+
if (currentMeta._yoast_wpseo_title !== undefined || currentMeta._yoast_wpseo_metadesc !== undefined || yh) plugin = 'yoast';
|
|
151
|
+
else if (currentMeta.rank_math_title !== undefined || currentMeta.rank_math_description !== undefined) plugin = 'rankmath';
|
|
152
|
+
else if (currentMeta._seopress_titles_title !== undefined) plugin = 'seopress';
|
|
153
|
+
else if (currentMeta._aioseo_title !== undefined) plugin = 'aioseo';
|
|
154
|
+
|
|
155
|
+
if (plugin === 'none') {
|
|
156
|
+
// Try to detect by checking if Yoast REST fields exist
|
|
157
|
+
if (yh) plugin = 'yoast';
|
|
158
|
+
else throw new Error('No SEO plugin detected. Install Yoast SEO, RankMath, SEOPress, or All in One SEO to manage SEO metadata.');
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
const metaUpdate = {};
|
|
162
|
+
const updated = [];
|
|
163
|
+
|
|
164
|
+
if (plugin === 'yoast') {
|
|
165
|
+
if (title !== undefined) { metaUpdate._yoast_wpseo_title = title; updated.push('title'); }
|
|
166
|
+
if (description !== undefined) { metaUpdate._yoast_wpseo_metadesc = description; updated.push('description'); }
|
|
167
|
+
if (focus_keyword !== undefined) { metaUpdate._yoast_wpseo_focuskw = focus_keyword; updated.push('focus_keyword'); }
|
|
168
|
+
if (canonical_url !== undefined) { metaUpdate._yoast_wpseo_canonical = canonical_url; updated.push('canonical'); }
|
|
169
|
+
if (robots_noindex !== undefined) { metaUpdate._yoast_wpseo_meta_robots_noindex = robots_noindex ? '1' : '0'; updated.push('noindex'); }
|
|
170
|
+
if (robots_nofollow !== undefined) { metaUpdate._yoast_wpseo_meta_robots_nofollow = robots_nofollow ? '1' : '0'; updated.push('nofollow'); }
|
|
171
|
+
} else if (plugin === 'rankmath') {
|
|
172
|
+
if (title !== undefined) { metaUpdate.rank_math_title = title; updated.push('title'); }
|
|
173
|
+
if (description !== undefined) { metaUpdate.rank_math_description = description; updated.push('description'); }
|
|
174
|
+
if (focus_keyword !== undefined) { metaUpdate.rank_math_focus_keyword = focus_keyword; updated.push('focus_keyword'); }
|
|
175
|
+
if (canonical_url !== undefined) { metaUpdate.rank_math_canonical_url = canonical_url; updated.push('canonical'); }
|
|
176
|
+
} else if (plugin === 'seopress') {
|
|
177
|
+
if (title !== undefined) { metaUpdate._seopress_titles_title = title; updated.push('title'); }
|
|
178
|
+
if (description !== undefined) { metaUpdate._seopress_titles_desc = description; updated.push('description'); }
|
|
179
|
+
if (focus_keyword !== undefined) { metaUpdate._seopress_analysis_target_kw = focus_keyword; updated.push('focus_keyword'); }
|
|
180
|
+
if (canonical_url !== undefined) { metaUpdate._seopress_robots_canonical = canonical_url; updated.push('canonical'); }
|
|
181
|
+
if (robots_noindex !== undefined) { metaUpdate._seopress_robots_index = robots_noindex ? 'yes' : ''; updated.push('noindex'); }
|
|
182
|
+
if (robots_nofollow !== undefined) { metaUpdate._seopress_robots_follow = robots_nofollow ? 'yes' : ''; updated.push('nofollow'); }
|
|
183
|
+
} else if (plugin === 'aioseo') {
|
|
184
|
+
if (title !== undefined) { metaUpdate._aioseo_title = title; updated.push('title'); }
|
|
185
|
+
if (description !== undefined) { metaUpdate._aioseo_description = description; updated.push('description'); }
|
|
186
|
+
if (focus_keyword !== undefined) { metaUpdate._aioseo_keywords = focus_keyword; updated.push('focus_keyword'); }
|
|
187
|
+
if (canonical_url !== undefined) { metaUpdate._aioseo_canonical_url = canonical_url; updated.push('canonical'); }
|
|
188
|
+
if (robots_noindex !== undefined) { metaUpdate._aioseo_noindex = robots_noindex ? '1' : '0'; updated.push('noindex'); }
|
|
189
|
+
if (robots_nofollow !== undefined) { metaUpdate._aioseo_nofollow = robots_nofollow ? '1' : '0'; updated.push('nofollow'); }
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
if (updated.length === 0) throw new Error('No SEO fields provided. Specify at least one of: title, description, focus_keyword, canonical_url, robots_noindex, robots_nofollow.');
|
|
193
|
+
|
|
194
|
+
const writeEp = post_type === 'page' ? `/pages/${id}` : `/posts/${id}`;
|
|
195
|
+
await wpApiCall(writeEp, { method: 'POST', body: JSON.stringify({ meta: metaUpdate }) });
|
|
196
|
+
|
|
197
|
+
result = json({ success: true, message: `SEO meta updated for ${post_type} ${id}`, plugin, fields_updated: updated, meta_written: metaUpdate });
|
|
198
|
+
auditLog({ tool: name, target: id, target_type: post_type, action: 'update_seo', status: 'success', latency_ms: Date.now() - t0, params: { plugin, fields: updated } });
|
|
199
|
+
return result;
|
|
200
|
+
};
|
|
201
|
+
handlers['wp_audit_seo'] = async (args) => {
|
|
202
|
+
const t0 = Date.now();
|
|
203
|
+
let result;
|
|
204
|
+
const { wpApiCall, auditLog, name } = rt;
|
|
205
|
+
const { post_type = 'post', per_page = 20, status = 'publish', orderby = 'date', order = 'desc' } = args;
|
|
206
|
+
const ep_base = post_type === 'page' ? '/pages' : '/posts';
|
|
207
|
+
const posts = await wpApiCall(`${ep_base}?per_page=${Math.min(per_page, 100)}&status=${status}&orderby=${orderby}&order=${order}`);
|
|
208
|
+
|
|
209
|
+
let seoPlugin = 'none';
|
|
210
|
+
const audit = [];
|
|
211
|
+
let totalScore = 0;
|
|
212
|
+
|
|
213
|
+
for (const p of posts) {
|
|
214
|
+
const meta = p.meta || {};
|
|
215
|
+
const yh = p.yoast_head_json || null;
|
|
216
|
+
const postTitle = strip(p.title?.rendered || '');
|
|
217
|
+
const item = { id: p.id, title: postTitle, slug: p.slug, link: p.link, seo_title: null, seo_description: null, focus_keyword: null, issues: [], score: 100 };
|
|
218
|
+
|
|
219
|
+
// Detect plugin on first post
|
|
220
|
+
if (seoPlugin === 'none') {
|
|
221
|
+
if (meta._yoast_wpseo_title !== undefined || meta._yoast_wpseo_metadesc !== undefined || yh) seoPlugin = 'yoast';
|
|
222
|
+
else if (meta.rank_math_title !== undefined) seoPlugin = 'rankmath';
|
|
223
|
+
else if (meta._seopress_titles_title !== undefined) seoPlugin = 'seopress';
|
|
224
|
+
else if (meta._aioseo_title !== undefined) seoPlugin = 'aioseo';
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
// Extract SEO fields based on detected plugin
|
|
228
|
+
if (seoPlugin === 'yoast') {
|
|
229
|
+
item.seo_title = meta._yoast_wpseo_title || yh?.title || null;
|
|
230
|
+
item.seo_description = meta._yoast_wpseo_metadesc || yh?.description || null;
|
|
231
|
+
item.focus_keyword = meta._yoast_wpseo_focuskw || null;
|
|
232
|
+
} else if (seoPlugin === 'rankmath') {
|
|
233
|
+
item.seo_title = meta.rank_math_title || null;
|
|
234
|
+
item.seo_description = meta.rank_math_description || null;
|
|
235
|
+
item.focus_keyword = meta.rank_math_focus_keyword || null;
|
|
236
|
+
} else if (seoPlugin === 'seopress') {
|
|
237
|
+
item.seo_title = meta._seopress_titles_title || null;
|
|
238
|
+
item.seo_description = meta._seopress_titles_desc || null;
|
|
239
|
+
item.focus_keyword = meta._seopress_analysis_target_kw || null;
|
|
240
|
+
} else if (seoPlugin === 'aioseo') {
|
|
241
|
+
item.seo_title = meta._aioseo_title || null;
|
|
242
|
+
item.seo_description = meta._aioseo_description || null;
|
|
243
|
+
item.focus_keyword = meta._aioseo_keywords || null;
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
// Quality checks
|
|
247
|
+
if (!item.seo_title) { item.issues.push('missing_seo_title'); item.score -= 30; }
|
|
248
|
+
else if (item.seo_title.length < 30) { item.issues.push('seo_title_too_short'); item.score -= 10; }
|
|
249
|
+
else if (item.seo_title.length > 60) { item.issues.push('seo_title_too_long'); item.score -= 10; }
|
|
250
|
+
|
|
251
|
+
if (!item.seo_description) { item.issues.push('missing_meta_description'); item.score -= 30; }
|
|
252
|
+
else if (item.seo_description.length < 120) { item.issues.push('meta_description_too_short'); item.score -= 10; }
|
|
253
|
+
else if (item.seo_description.length > 160) { item.issues.push('meta_description_too_long'); item.score -= 10; }
|
|
254
|
+
|
|
255
|
+
if (!item.focus_keyword) { item.issues.push('missing_focus_keyword'); item.score -= 20; }
|
|
256
|
+
|
|
257
|
+
if (item.seo_title && item.focus_keyword && !item.seo_title.toLowerCase().includes(item.focus_keyword.toLowerCase())) {
|
|
258
|
+
item.issues.push('keyword_not_in_title'); item.score -= 10;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
if (item.score < 0) item.score = 0;
|
|
262
|
+
totalScore += item.score;
|
|
263
|
+
audit.push(item);
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
const avgScore = audit.length > 0 ? Math.round(totalScore / audit.length) : 0;
|
|
267
|
+
const summary = {
|
|
268
|
+
total_audited: audit.length,
|
|
269
|
+
seo_plugin: seoPlugin,
|
|
270
|
+
average_score: avgScore,
|
|
271
|
+
issues_breakdown: {
|
|
272
|
+
missing_seo_title: audit.filter(a => a.issues.includes('missing_seo_title')).length,
|
|
273
|
+
missing_meta_description: audit.filter(a => a.issues.includes('missing_meta_description')).length,
|
|
274
|
+
missing_focus_keyword: audit.filter(a => a.issues.includes('missing_focus_keyword')).length,
|
|
275
|
+
title_length_issues: audit.filter(a => a.issues.includes('seo_title_too_short') || a.issues.includes('seo_title_too_long')).length,
|
|
276
|
+
description_length_issues: audit.filter(a => a.issues.includes('meta_description_too_short') || a.issues.includes('meta_description_too_long')).length,
|
|
277
|
+
keyword_not_in_title: audit.filter(a => a.issues.includes('keyword_not_in_title')).length
|
|
278
|
+
}
|
|
279
|
+
};
|
|
280
|
+
|
|
281
|
+
result = json({ summary, posts: audit });
|
|
282
|
+
auditLog({ tool: name, action: 'audit_seo', status: 'success', latency_ms: Date.now() - t0, params: { post_type, count: audit.length, avg_score: avgScore } });
|
|
283
|
+
return result;
|
|
284
|
+
};
|
|
285
|
+
handlers['wp_audit_media_seo'] = async (args) => {
|
|
286
|
+
const t0 = Date.now();
|
|
287
|
+
let result;
|
|
288
|
+
const { wpApiCall, auditLog, name, parseImagesFromHtml } = rt;
|
|
289
|
+
validateInput(args, { per_page: { type: 'number', min: 1, max: 100 }, page: { type: 'number', min: 1 }, post_id: { type: 'number', min: 1 } });
|
|
290
|
+
const { per_page = 50, page = 1, post_id } = args;
|
|
291
|
+
const mediaItems = await wpApiCall(`/media?per_page=${Math.min(per_page, 100)}&page=${page}&media_type=image`);
|
|
292
|
+
|
|
293
|
+
const audit = [];
|
|
294
|
+
let totalScore = 0;
|
|
295
|
+
for (const m of mediaItems) {
|
|
296
|
+
const item = { id: m.id, title: m.title?.rendered || '', source_url: m.source_url || '', alt_text: m.alt_text || '', issues: [], score: 100 };
|
|
297
|
+
if (!item.alt_text) {
|
|
298
|
+
item.issues.push('missing_alt'); item.score -= 40;
|
|
299
|
+
} else {
|
|
300
|
+
const filename = (m.source_url || '').split('/').pop()?.split('.')[0]?.replace(/[-_]/g, ' ') || '';
|
|
301
|
+
if (filename && item.alt_text.toLowerCase().replace(/[-_]/g, ' ') === filename.toLowerCase()) {
|
|
302
|
+
item.issues.push('filename_as_alt'); item.score -= 20;
|
|
303
|
+
}
|
|
304
|
+
if (item.alt_text.length < 5) { item.issues.push('alt_too_short'); item.score -= 15; }
|
|
305
|
+
}
|
|
306
|
+
if (item.score < 0) item.score = 0;
|
|
307
|
+
totalScore += item.score;
|
|
308
|
+
audit.push(item);
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
let inlineImages = [];
|
|
312
|
+
if (post_id) {
|
|
313
|
+
const p = await wpApiCall(`/posts/${post_id}`);
|
|
314
|
+
inlineImages = parseImagesFromHtml(p.content?.rendered || '');
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
const avgScore = audit.length > 0 ? Math.round(totalScore / audit.length) : 0;
|
|
318
|
+
result = json({
|
|
319
|
+
summary: {
|
|
320
|
+
total_audited: audit.length, average_score: avgScore,
|
|
321
|
+
issues_breakdown: {
|
|
322
|
+
missing_alt: audit.filter(a => a.issues.includes('missing_alt')).length,
|
|
323
|
+
filename_as_alt: audit.filter(a => a.issues.includes('filename_as_alt')).length,
|
|
324
|
+
alt_too_short: audit.filter(a => a.issues.includes('alt_too_short')).length,
|
|
325
|
+
}
|
|
326
|
+
},
|
|
327
|
+
media: audit,
|
|
328
|
+
inline_images: inlineImages
|
|
329
|
+
});
|
|
330
|
+
auditLog({ tool: name, action: 'audit_media_seo', status: 'success', latency_ms: Date.now() - t0, params: { count: audit.length, avg_score: avgScore } });
|
|
331
|
+
return result;
|
|
332
|
+
};
|
|
333
|
+
handlers['wp_find_orphan_pages'] = async (args) => {
|
|
334
|
+
const t0 = Date.now();
|
|
335
|
+
let result;
|
|
336
|
+
const { wpApiCall, getActiveAuth, auditLog, name, extractInternalLinksHtml, countWords } = rt;
|
|
337
|
+
validateInput(args, { per_page: { type: 'number', min: 1, max: 100 }, min_words: { type: 'number', min: 0 } });
|
|
338
|
+
const { per_page = 100, exclude_ids = [], min_words = 0 } = args;
|
|
339
|
+
const allPages = await wpApiCall(`/pages?per_page=${Math.min(per_page, 100)}&status=publish`);
|
|
340
|
+
const { url: siteUrl } = getActiveAuth();
|
|
341
|
+
|
|
342
|
+
// Build set of linked page permalinks
|
|
343
|
+
const linkedPermalinks = new Set();
|
|
344
|
+
for (const pg of allPages) {
|
|
345
|
+
const content = pg.content?.rendered || '';
|
|
346
|
+
const links = extractInternalLinksHtml(content, siteUrl);
|
|
347
|
+
for (const link of links) {
|
|
348
|
+
// Normalise trailing slash for comparison
|
|
349
|
+
linkedPermalinks.add(link.replace(/\/+$/, ''));
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
// Find orphans: pages whose permalink is NOT in linkedPermalinks
|
|
354
|
+
const excludeSet = new Set(Array.isArray(exclude_ids) ? exclude_ids : []);
|
|
355
|
+
const orphans = allPages.filter(pg => {
|
|
356
|
+
if (excludeSet.has(pg.id)) return false;
|
|
357
|
+
const normLink = (pg.link || '').replace(/\/+$/, '');
|
|
358
|
+
if (linkedPermalinks.has(normLink)) return false;
|
|
359
|
+
const wc = countWords(pg.content?.rendered || '');
|
|
360
|
+
if (wc < min_words) return false;
|
|
361
|
+
return true;
|
|
362
|
+
});
|
|
363
|
+
|
|
364
|
+
// Sort by word count descending
|
|
365
|
+
orphans.sort((a, b) => countWords(b.content?.rendered || '') - countWords(a.content?.rendered || ''));
|
|
366
|
+
|
|
367
|
+
result = json({
|
|
368
|
+
total_pages: allPages.length, total_orphans: orphans.length,
|
|
369
|
+
orphans: orphans.map(pg => ({
|
|
370
|
+
id: pg.id, title: strip(pg.title?.rendered || ''), slug: pg.slug, link: pg.link,
|
|
371
|
+
word_count: countWords(pg.content?.rendered || ''), status: pg.status
|
|
372
|
+
}))
|
|
373
|
+
});
|
|
374
|
+
auditLog({ tool: name, action: 'find_orphan_pages', status: 'success', latency_ms: Date.now() - t0, params: { total: allPages.length, orphans: orphans.length } });
|
|
375
|
+
return result;
|
|
376
|
+
};
|
|
377
|
+
handlers['wp_audit_heading_structure'] = async (args) => {
|
|
378
|
+
const t0 = Date.now();
|
|
379
|
+
let result;
|
|
380
|
+
const { wpApiCall, auditLog, name, extractHeadings } = rt;
|
|
381
|
+
validateInput(args, { id: { type: 'number', required: true, min: 1 } });
|
|
382
|
+
const { id, post_type = 'post', focus_keyword } = args;
|
|
383
|
+
const ep = post_type === 'page' ? `/pages/${id}` : `/posts/${id}`;
|
|
384
|
+
const p = await wpApiCall(ep);
|
|
385
|
+
const content = p.content?.rendered || '';
|
|
386
|
+
const postTitle = strip(p.title?.rendered || '');
|
|
387
|
+
|
|
388
|
+
const headings = extractHeadings(content);
|
|
389
|
+
const issues = [];
|
|
390
|
+
let score = 100;
|
|
391
|
+
|
|
392
|
+
// H1 in content
|
|
393
|
+
const h1s = headings.filter(h => h.level === 1);
|
|
394
|
+
if (h1s.length > 0) {
|
|
395
|
+
issues.push({ type: 'h1_in_content', count: h1s.length, message: 'H1 tag found in content — the post title already provides the H1' });
|
|
396
|
+
score -= 20;
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
// Heading level skips
|
|
400
|
+
for (let i = 1; i < headings.length; i++) {
|
|
401
|
+
if (headings[i].level > headings[i - 1].level + 1) {
|
|
402
|
+
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}` });
|
|
403
|
+
score -= 10;
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
// Empty headings
|
|
408
|
+
const emptyHeadings = headings.filter(h => !h.text);
|
|
409
|
+
if (emptyHeadings.length > 0) {
|
|
410
|
+
issues.push({ type: 'empty_heading', count: emptyHeadings.length, message: `${emptyHeadings.length} empty heading(s) found` });
|
|
411
|
+
score -= 10 * emptyHeadings.length;
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
// No H2
|
|
415
|
+
const h2s = headings.filter(h => h.level === 2);
|
|
416
|
+
if (h2s.length === 0 && headings.length > 0) {
|
|
417
|
+
issues.push({ type: 'no_h2', message: 'No H2 headings found — content should use H2 for main sections' });
|
|
418
|
+
score -= 15;
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
// Keyword checks
|
|
422
|
+
if (focus_keyword && h2s.length > 0) {
|
|
423
|
+
const kwLower = focus_keyword.toLowerCase();
|
|
424
|
+
const h2WithKw = h2s.filter(h => h.text.toLowerCase().includes(kwLower));
|
|
425
|
+
if (h2WithKw.length === 0) {
|
|
426
|
+
issues.push({ type: 'keyword_absent_h2', message: `Focus keyword "${focus_keyword}" not found in any H2 heading` });
|
|
427
|
+
score -= 10;
|
|
428
|
+
}
|
|
429
|
+
if (h2s.length >= 3 && h2WithKw.length / h2s.length > 0.5) {
|
|
430
|
+
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%)` });
|
|
431
|
+
score -= 15;
|
|
432
|
+
}
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
if (score < 0) score = 0;
|
|
436
|
+
|
|
437
|
+
result = json({
|
|
438
|
+
post_id: id, post_title: postTitle, headings, issues, score,
|
|
439
|
+
summary: {
|
|
440
|
+
total_headings: headings.length,
|
|
441
|
+
h1_count: h1s.length, h2_count: h2s.length,
|
|
442
|
+
h3_count: headings.filter(h => h.level === 3).length,
|
|
443
|
+
h4_count: headings.filter(h => h.level === 4).length,
|
|
444
|
+
h5_count: headings.filter(h => h.level === 5).length,
|
|
445
|
+
h6_count: headings.filter(h => h.level === 6).length,
|
|
446
|
+
}
|
|
447
|
+
});
|
|
448
|
+
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 } });
|
|
449
|
+
return result;
|
|
450
|
+
};
|
|
451
|
+
handlers['wp_find_thin_content'] = async (args) => {
|
|
452
|
+
const t0 = Date.now();
|
|
453
|
+
let result;
|
|
454
|
+
const { wpApiCall, auditLog, name, countWords } = rt;
|
|
455
|
+
validateInput(args, {
|
|
456
|
+
limit: { type: 'number', min: 1, max: 500 },
|
|
457
|
+
min_words: { type: 'number', min: 1 },
|
|
458
|
+
critical_words: { type: 'number', min: 1 },
|
|
459
|
+
max_age_days: { type: 'number', min: 1 },
|
|
460
|
+
include_uncategorized: { type: 'string' },
|
|
461
|
+
post_type: { type: 'string', enum: ['post', 'page'] }
|
|
462
|
+
});
|
|
463
|
+
const { limit = 100, min_words = 300, critical_words = 150, max_age_days = 730, include_uncategorized = true, post_type = 'post' } = args;
|
|
464
|
+
const endpoint = post_type === 'page' ? '/pages' : '/posts';
|
|
465
|
+
const items = await wpApiCall(`${endpoint}?per_page=${Math.min(limit, 100)}&status=publish&_fields=id,title,link,content,modified,date,categories`);
|
|
466
|
+
|
|
467
|
+
const now = new Date();
|
|
468
|
+
const articles = [];
|
|
469
|
+
for (const item of items) {
|
|
470
|
+
const wc = countWords(item.content?.rendered || '');
|
|
471
|
+
const modified = new Date(item.modified);
|
|
472
|
+
const daysSinceUpdate = Math.floor((now - modified) / (1000 * 60 * 60 * 24));
|
|
473
|
+
|
|
474
|
+
const signals = [];
|
|
475
|
+
if (wc < critical_words) signals.push('very_short');
|
|
476
|
+
else if (wc < min_words) signals.push('too_short');
|
|
477
|
+
if (daysSinceUpdate > max_age_days) signals.push('outdated');
|
|
478
|
+
if (include_uncategorized) {
|
|
479
|
+
const cats = item.categories || [];
|
|
480
|
+
if (cats.length === 0 || (cats.length === 1 && cats[0] === 1)) signals.push('uncategorized');
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
if (signals.length === 0) continue;
|
|
484
|
+
|
|
485
|
+
let severity = 'medium';
|
|
486
|
+
if (signals.length >= 3) severity = 'critical';
|
|
487
|
+
else if (signals.length >= 2) severity = 'high';
|
|
488
|
+
|
|
489
|
+
let suggested_action = 'review';
|
|
490
|
+
if (signals.includes('very_short') && signals.includes('outdated')) suggested_action = 'delete';
|
|
491
|
+
else if (signals.includes('too_short') || signals.includes('very_short')) suggested_action = 'expand';
|
|
492
|
+
else if (signals.includes('outdated') && !signals.includes('too_short') && !signals.includes('very_short')) suggested_action = 'update_or_merge';
|
|
493
|
+
|
|
494
|
+
articles.push({
|
|
495
|
+
id: item.id, title: strip(item.title?.rendered || ''), link: item.link || '',
|
|
496
|
+
word_count: wc, days_since_update: daysSinceUpdate,
|
|
497
|
+
signals, severity, suggested_action
|
|
498
|
+
});
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
// Sort: severity DESC (critical > high > medium), then word_count ASC
|
|
502
|
+
const sevOrder = { critical: 0, high: 1, medium: 2 };
|
|
503
|
+
articles.sort((a, b) => (sevOrder[a.severity] - sevOrder[b.severity]) || (a.word_count - b.word_count));
|
|
504
|
+
|
|
505
|
+
const bySev = { critical: 0, high: 0, medium: 0 };
|
|
506
|
+
for (const a of articles) bySev[a.severity]++;
|
|
507
|
+
|
|
508
|
+
result = json({ total_analyzed: items.length, total_thin: articles.length, by_severity: bySev, articles });
|
|
509
|
+
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 } });
|
|
510
|
+
return result;
|
|
511
|
+
};
|
|
512
|
+
handlers['wp_audit_canonicals'] = async (args) => {
|
|
513
|
+
const t0 = Date.now();
|
|
514
|
+
let result;
|
|
515
|
+
const { wpApiCall, getActiveAuth, auditLog, name } = rt;
|
|
516
|
+
validateInput(args, {
|
|
517
|
+
limit: { type: 'number', min: 1, max: 200 },
|
|
518
|
+
post_type: { type: 'string', enum: ['post', 'page', 'both'] },
|
|
519
|
+
check_staging_patterns: { type: 'string' }
|
|
520
|
+
});
|
|
521
|
+
const { limit = 50, post_type = 'post', check_staging_patterns = true } = args;
|
|
522
|
+
|
|
523
|
+
// Fetch posts (and/or pages)
|
|
524
|
+
let allPosts = [];
|
|
525
|
+
const fieldsList = 'id,title,link,meta';
|
|
526
|
+
if (post_type === 'both' || post_type === 'post') {
|
|
527
|
+
const posts = await wpApiCall(`/posts?per_page=${Math.min(limit, 100)}&status=publish&_fields=${fieldsList}`);
|
|
528
|
+
allPosts = allPosts.concat(posts);
|
|
529
|
+
}
|
|
530
|
+
if (post_type === 'both' || post_type === 'page') {
|
|
531
|
+
const pages = await wpApiCall(`/pages?per_page=${Math.min(limit, 100)}&status=publish&_fields=${fieldsList}`);
|
|
532
|
+
allPosts = allPosts.concat(pages);
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
// Detect site URL from auth
|
|
536
|
+
const { url: siteUrl } = getActiveAuth();
|
|
537
|
+
let siteHost = '';
|
|
538
|
+
let siteProtocol = 'https:';
|
|
539
|
+
try {
|
|
540
|
+
const parsed = new URL(siteUrl);
|
|
541
|
+
siteHost = parsed.host;
|
|
542
|
+
siteProtocol = parsed.protocol;
|
|
543
|
+
} catch { /* fallback */ }
|
|
544
|
+
|
|
545
|
+
// Detect SEO plugin from meta keys
|
|
546
|
+
let seoPluginDetected = 'none';
|
|
547
|
+
const seoCanonicalKeys = {
|
|
548
|
+
rank_math_canonical_url: 'RankMath',
|
|
549
|
+
_yoast_wpseo_canonical: 'Yoast',
|
|
550
|
+
_seopress_robots_canonical: 'SEOPress',
|
|
551
|
+
_aioseo_og_title: 'AIOSEO'
|
|
552
|
+
};
|
|
553
|
+
|
|
554
|
+
for (const p of allPosts) {
|
|
555
|
+
const meta = p.meta || {};
|
|
556
|
+
for (const [key, plugin] of Object.entries(seoCanonicalKeys)) {
|
|
557
|
+
if (meta[key] !== undefined && meta[key] !== '' && meta[key] !== null) {
|
|
558
|
+
seoPluginDetected = plugin;
|
|
559
|
+
break;
|
|
560
|
+
}
|
|
561
|
+
}
|
|
562
|
+
if (seoPluginDetected !== 'none') break;
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
// Get canonical field name
|
|
566
|
+
const canonicalKey = Object.keys(seoCanonicalKeys).find(k => {
|
|
567
|
+
return allPosts.some(p => p.meta && p.meta[k] !== undefined);
|
|
568
|
+
}) || '';
|
|
569
|
+
|
|
570
|
+
const stagingPatterns = ['staging', 'dev', 'local', 'preprod', 'test', 'localhost'];
|
|
571
|
+
const audits = [];
|
|
572
|
+
const issuesByType = { missing_canonical: 0, http_on_https_site: 0, staging_url: 0, wrong_domain: 0, trailing_slash_mismatch: 0 };
|
|
573
|
+
|
|
574
|
+
for (const p of allPosts) {
|
|
575
|
+
const meta = p.meta || {};
|
|
576
|
+
const postUrl = p.link || '';
|
|
577
|
+
const canonical = (canonicalKey && meta[canonicalKey]) ? String(meta[canonicalKey]) : '';
|
|
578
|
+
const postIssues = [];
|
|
579
|
+
|
|
580
|
+
if (!canonical) {
|
|
581
|
+
postIssues.push('missing_canonical');
|
|
582
|
+
issuesByType.missing_canonical++;
|
|
583
|
+
} else {
|
|
584
|
+
// HTTP on HTTPS site
|
|
585
|
+
if (siteProtocol === 'https:' && canonical.startsWith('http://')) {
|
|
586
|
+
postIssues.push('http_on_https_site');
|
|
587
|
+
issuesByType.http_on_https_site++;
|
|
588
|
+
}
|
|
589
|
+
// Staging URL
|
|
590
|
+
if (check_staging_patterns) {
|
|
591
|
+
let canonHostLower = '';
|
|
592
|
+
try { canonHostLower = new URL(canonical).hostname.toLowerCase(); } catch { /* skip */ }
|
|
593
|
+
if (canonHostLower && stagingPatterns.some(pat => canonHostLower.includes(pat))) {
|
|
594
|
+
postIssues.push('staging_url');
|
|
595
|
+
issuesByType.staging_url++;
|
|
596
|
+
}
|
|
597
|
+
}
|
|
598
|
+
// Wrong domain
|
|
599
|
+
try {
|
|
600
|
+
const canonHost = new URL(canonical).host;
|
|
601
|
+
if (siteHost && canonHost !== siteHost) {
|
|
602
|
+
postIssues.push('wrong_domain');
|
|
603
|
+
issuesByType.wrong_domain++;
|
|
604
|
+
}
|
|
605
|
+
} catch { /* skip invalid canonical URL */ }
|
|
606
|
+
// Trailing slash mismatch
|
|
607
|
+
if (postUrl && canonical) {
|
|
608
|
+
const postTrailing = postUrl.endsWith('/');
|
|
609
|
+
const canonTrailing = canonical.endsWith('/');
|
|
610
|
+
if (postTrailing !== canonTrailing) {
|
|
611
|
+
postIssues.push('trailing_slash_mismatch');
|
|
612
|
+
issuesByType.trailing_slash_mismatch++;
|
|
613
|
+
}
|
|
614
|
+
}
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
audits.push({
|
|
618
|
+
id: p.id, title: strip(p.title?.rendered || ''), url: postUrl,
|
|
619
|
+
canonical: canonical || null, issues: postIssues
|
|
620
|
+
});
|
|
621
|
+
}
|
|
622
|
+
|
|
623
|
+
const totalIssues = audits.filter(a => a.issues.length > 0).length;
|
|
624
|
+
result = json({ total_audited: audits.length, total_issues: totalIssues, seo_plugin_detected: seoPluginDetected, issues_by_type: issuesByType, audits });
|
|
625
|
+
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 } });
|
|
626
|
+
return result;
|
|
627
|
+
};
|
|
628
|
+
handlers['wp_analyze_eeat_signals'] = async (args) => {
|
|
629
|
+
const t0 = Date.now();
|
|
630
|
+
let result;
|
|
631
|
+
const { wpApiCall, getActiveAuth, auditLog, name, countWords } = rt;
|
|
632
|
+
validateInput(args, {
|
|
633
|
+
post_ids: { type: 'array' },
|
|
634
|
+
limit: { type: 'number', min: 1, max: 50 },
|
|
635
|
+
post_type: { type: 'string', enum: ['post', 'page'] },
|
|
636
|
+
authoritative_domains: { type: 'array' }
|
|
637
|
+
});
|
|
638
|
+
const { post_ids, limit = 10, post_type = 'post', authoritative_domains = ['wikipedia.org', 'gov', 'edu', 'who.int', 'pubmed'] } = args;
|
|
639
|
+
|
|
640
|
+
// Fetch posts
|
|
641
|
+
let posts;
|
|
642
|
+
if (post_ids && post_ids.length > 0) {
|
|
643
|
+
posts = await wpApiCall(`/${post_type === 'page' ? 'pages' : 'posts'}?include=${post_ids.join(',')}&_fields=id,title,link,content,author,date,modified,meta`);
|
|
644
|
+
} else {
|
|
645
|
+
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`);
|
|
646
|
+
}
|
|
647
|
+
|
|
648
|
+
// Cache authors
|
|
649
|
+
const authorCache = {};
|
|
650
|
+
const eeatResults = [];
|
|
651
|
+
|
|
652
|
+
for (const p of posts) {
|
|
653
|
+
const content = p.content?.rendered || '';
|
|
654
|
+
const wc = countWords(content);
|
|
655
|
+
const nowDate = new Date();
|
|
656
|
+
const modified = new Date(p.modified);
|
|
657
|
+
const daysSinceUpdate = Math.floor((nowDate - modified) / (1000 * 60 * 60 * 24));
|
|
658
|
+
|
|
659
|
+
// Fetch author
|
|
660
|
+
const authorId = p.author;
|
|
661
|
+
if (authorId && !authorCache[authorId]) {
|
|
662
|
+
try {
|
|
663
|
+
authorCache[authorId] = await wpApiCall(`/users/${authorId}`);
|
|
664
|
+
} catch { authorCache[authorId] = {}; }
|
|
665
|
+
}
|
|
666
|
+
const author = authorCache[authorId] || {};
|
|
667
|
+
|
|
668
|
+
const signalsPresent = [];
|
|
669
|
+
const signalsMissing = [];
|
|
670
|
+
|
|
671
|
+
// EXPERIENCE (score /25)
|
|
672
|
+
let experienceScore = 0;
|
|
673
|
+
const hasBio = !!(author.description && author.description.trim());
|
|
674
|
+
if (hasBio) { experienceScore += 10; signalsPresent.push('author_has_bio'); } else { signalsMissing.push({ signal: 'author_has_bio', impact: 10, category: 'experience' }); }
|
|
675
|
+
const hasFirstPerson = /\b(je|nous|notre|mon|ma|j'ai|j'|I|we|my|our|I've)\b/i.test(content);
|
|
676
|
+
if (hasFirstPerson) { experienceScore += 8; signalsPresent.push('content_has_first_person'); } else { signalsMissing.push({ signal: 'content_has_first_person', impact: 8, category: 'experience' }); }
|
|
677
|
+
const hasPersonalExp = /\b(expérience|témoignage|cas|exemple|client|projet|experience|testimony|case study|example|project)\b/i.test(content);
|
|
678
|
+
if (hasPersonalExp) { experienceScore += 7; signalsPresent.push('content_has_personal_experience'); } else { signalsMissing.push({ signal: 'content_has_personal_experience', impact: 7, category: 'experience' }); }
|
|
679
|
+
|
|
680
|
+
// EXPERTISE (score /25)
|
|
681
|
+
let expertiseScore = 0;
|
|
682
|
+
const hasData = /\d+\s*(%|€|\$|£|kg|km|ml|mg|px|ms|gb|mb|tb)/i.test(content);
|
|
683
|
+
if (hasData) { expertiseScore += 8; signalsPresent.push('content_has_data'); } else { signalsMissing.push({ signal: 'content_has_data', impact: 8, category: 'expertise' }); }
|
|
684
|
+
const hasEntities = /[.>]\s+\w+\s+[A-Z][a-z]{2,}/.test(content.replace(/<[^>]*>/g, ''));
|
|
685
|
+
if (hasEntities) { expertiseScore += 7; signalsPresent.push('content_has_entities'); } else { signalsMissing.push({ signal: 'content_has_entities', impact: 7, category: 'expertise' }); }
|
|
686
|
+
if (wc > 800) { expertiseScore += 10; signalsPresent.push('content_word_count_expert'); } else { signalsMissing.push({ signal: 'content_word_count_expert', impact: 10, category: 'expertise' }); }
|
|
687
|
+
|
|
688
|
+
// AUTHORITATIVENESS (score /25)
|
|
689
|
+
let authScore = 0;
|
|
690
|
+
const extLinkRegex = /<a\s[^>]*?href=["'](https?:\/\/[^"']+)["'][^>]*?>/gi;
|
|
691
|
+
const externalLinks = [];
|
|
692
|
+
let extMatch;
|
|
693
|
+
const { url: sUrl } = getActiveAuth();
|
|
694
|
+
let sHost = '';
|
|
695
|
+
try { sHost = new URL(sUrl).host; } catch { /* */ }
|
|
696
|
+
while ((extMatch = extLinkRegex.exec(content)) !== null) {
|
|
697
|
+
try {
|
|
698
|
+
const linkHost = new URL(extMatch[1]).host;
|
|
699
|
+
if (linkHost !== sHost) externalLinks.push(extMatch[1]);
|
|
700
|
+
} catch { /* skip */ }
|
|
701
|
+
}
|
|
702
|
+
if (externalLinks.length >= 2) { authScore += 8; signalsPresent.push('has_outbound_links'); } else { signalsMissing.push({ signal: 'has_outbound_links', impact: 8, category: 'authoritativeness' }); }
|
|
703
|
+
const hasAuthSources = externalLinks.some(link => {
|
|
704
|
+
const lowerLink = link.toLowerCase();
|
|
705
|
+
return authoritative_domains.some(d => lowerLink.includes(d));
|
|
706
|
+
});
|
|
707
|
+
if (hasAuthSources) { authScore += 10; signalsPresent.push('has_authoritative_sources'); } else { signalsMissing.push({ signal: 'has_authoritative_sources', impact: 10, category: 'authoritativeness' }); }
|
|
708
|
+
const hasCitations = /\b(selon|d'après|source|étude|rapport|according to|study|report|research)\b/i.test(content);
|
|
709
|
+
if (hasCitations) { authScore += 7; signalsPresent.push('content_has_citations'); } else { signalsMissing.push({ signal: 'content_has_citations', impact: 7, category: 'authoritativeness' }); }
|
|
710
|
+
|
|
711
|
+
// TRUSTWORTHINESS (score /25)
|
|
712
|
+
let trustScore = 0;
|
|
713
|
+
if (daysSinceUpdate <= 365) { trustScore += 10; signalsPresent.push('has_update_date'); } else { signalsMissing.push({ signal: 'has_update_date', impact: 10, category: 'trustworthiness' }); }
|
|
714
|
+
const hasAvatar = !!(author.avatar_urls && Object.keys(author.avatar_urls).length > 0);
|
|
715
|
+
if (hasBio && hasAvatar) { trustScore += 8; signalsPresent.push('author_linked'); } else { signalsMissing.push({ signal: 'author_linked', impact: 8, category: 'trustworthiness' }); }
|
|
716
|
+
const hasStructuredData = content.includes('application/ld+json');
|
|
717
|
+
if (hasStructuredData) { trustScore += 7; signalsPresent.push('has_structured_data'); } else { signalsMissing.push({ signal: 'has_structured_data', impact: 7, category: 'trustworthiness' }); }
|
|
718
|
+
|
|
719
|
+
const total = experienceScore + expertiseScore + authScore + trustScore;
|
|
720
|
+
|
|
721
|
+
// Priority fixes: top 3 missing signals by impact
|
|
722
|
+
const priorityFixes = signalsMissing
|
|
723
|
+
.sort((a, b) => b.impact - a.impact)
|
|
724
|
+
.slice(0, 3)
|
|
725
|
+
.map(s => ({ signal: s.signal, category: s.category, potential_points: s.impact }));
|
|
726
|
+
|
|
727
|
+
eeatResults.push({
|
|
728
|
+
id: p.id, title: strip(p.title?.rendered || ''),
|
|
729
|
+
scores: { experience: experienceScore, expertise: expertiseScore, authoritativeness: authScore, trustworthiness: trustScore, total },
|
|
730
|
+
signals_present: signalsPresent,
|
|
731
|
+
signals_missing: signalsMissing.map(s => s.signal),
|
|
732
|
+
priority_fixes: priorityFixes
|
|
733
|
+
});
|
|
734
|
+
}
|
|
735
|
+
|
|
736
|
+
result = json({ total_analyzed: posts.length, analyses: eeatResults });
|
|
737
|
+
auditLog({ tool: name, action: 'audit_seo', target_type: post_type, status: 'success', latency_ms: Date.now() - t0, params: { total_analyzed: posts.length } });
|
|
738
|
+
return result;
|
|
739
|
+
};
|
|
740
|
+
handlers['wp_find_broken_internal_links'] = async (args) => {
|
|
741
|
+
const t0 = Date.now();
|
|
742
|
+
let result;
|
|
743
|
+
const { wpApiCall, fetch, auditLog, name, extractInternalLinksHtml } = rt;
|
|
744
|
+
validateInput(args, {
|
|
745
|
+
limit_posts: { type: 'number', min: 1, max: 100 },
|
|
746
|
+
batch_size: { type: 'number', min: 1, max: 10 },
|
|
747
|
+
timeout_ms: { type: 'number', min: 1000, max: 30000 },
|
|
748
|
+
delay_ms: { type: 'number', min: 0, max: 2000 },
|
|
749
|
+
post_type: { type: 'string', enum: ['post', 'page', 'both'] }
|
|
750
|
+
});
|
|
751
|
+
const { limit_posts = 20, batch_size = 5, timeout_ms = 5000, delay_ms = 200, post_type = 'post', include_redirects = true } = args;
|
|
752
|
+
const scanStart = Date.now();
|
|
753
|
+
|
|
754
|
+
// Fetch posts
|
|
755
|
+
const endpoints = post_type === 'both' ? ['/posts', '/pages'] : [post_type === 'page' ? '/pages' : '/posts'];
|
|
756
|
+
let allPosts = [];
|
|
757
|
+
for (const ep of endpoints) {
|
|
758
|
+
const items = await wpApiCall(`${ep}?per_page=${Math.min(limit_posts, 100)}&status=publish&_fields=id,title,link,content`);
|
|
759
|
+
allPosts = allPosts.concat(items);
|
|
760
|
+
}
|
|
761
|
+
allPosts = allPosts.slice(0, limit_posts);
|
|
762
|
+
|
|
763
|
+
// Extract internal links per post and deduplicate globally
|
|
764
|
+
const linkToSources = new Map(); // url → [{post_id, post_title, post_url, html}]
|
|
765
|
+
for (const p of allPosts) {
|
|
766
|
+
const html = p.content?.rendered || '';
|
|
767
|
+
const siteUrl = p.link ? p.link.replace(/\/[^/]*\/?$/, '') : '';
|
|
768
|
+
const internalUrls = extractInternalLinksHtml(html, siteUrl);
|
|
769
|
+
const uniqueUrls = [...new Set(internalUrls)];
|
|
770
|
+
for (const url of uniqueUrls) {
|
|
771
|
+
if (!linkToSources.has(url)) linkToSources.set(url, []);
|
|
772
|
+
linkToSources.get(url).push({ post_id: p.id, post_title: strip(p.title?.rendered || ''), post_url: p.link, html });
|
|
773
|
+
}
|
|
774
|
+
}
|
|
775
|
+
|
|
776
|
+
// Check links in batches
|
|
777
|
+
const allUrls = [...linkToSources.keys()];
|
|
778
|
+
const linkResults = new Map(); // url → {status, ok, isTimeout}
|
|
779
|
+
|
|
780
|
+
async function checkSingleLink(url, tms) {
|
|
781
|
+
const controller = new AbortController();
|
|
782
|
+
const timer = setTimeout(() => controller.abort(), tms);
|
|
783
|
+
try {
|
|
784
|
+
const response = await fetch(url, { method: 'HEAD', signal: controller.signal, redirect: 'manual' });
|
|
785
|
+
clearTimeout(timer);
|
|
786
|
+
return { url, status: response.status, ok: response.status < 400 };
|
|
787
|
+
} catch (error) {
|
|
788
|
+
clearTimeout(timer);
|
|
789
|
+
return { url, status: null, ok: false, isTimeout: error.name === 'AbortError' };
|
|
790
|
+
}
|
|
791
|
+
}
|
|
792
|
+
|
|
793
|
+
for (let i = 0; i < allUrls.length; i += batch_size) {
|
|
794
|
+
const batch = allUrls.slice(i, i + batch_size);
|
|
795
|
+
const results = await Promise.allSettled(batch.map(u => checkSingleLink(u, timeout_ms)));
|
|
796
|
+
for (const r of results) {
|
|
797
|
+
const val = r.status === 'fulfilled' ? r.value : { url: batch[0], status: null, ok: false, isTimeout: false };
|
|
798
|
+
linkResults.set(val.url, val);
|
|
799
|
+
}
|
|
800
|
+
if (i + batch_size < allUrls.length && delay_ms > 0) {
|
|
801
|
+
await new Promise(r => setTimeout(r, delay_ms));
|
|
802
|
+
}
|
|
803
|
+
}
|
|
804
|
+
|
|
805
|
+
// Extract anchor text helper
|
|
806
|
+
function getAnchorText(html, href) {
|
|
807
|
+
const escaped = href.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
808
|
+
const m = html.match(new RegExp(`<a\\s[^>]*?href=["']${escaped}["'][^>]*?>(.*?)<\\/a>`, 'i'));
|
|
809
|
+
return m ? m[1].replace(/<[^>]*>/g, '').trim() : '';
|
|
810
|
+
}
|
|
811
|
+
|
|
812
|
+
// Build results
|
|
813
|
+
const brokenLinks = [];
|
|
814
|
+
let totalRedirects = 0;
|
|
815
|
+
let totalTimeouts = 0;
|
|
816
|
+
let totalBroken = 0;
|
|
817
|
+
|
|
818
|
+
for (const [url, lr] of linkResults) {
|
|
819
|
+
let issueType = null;
|
|
820
|
+
if (lr.status === 404) issueType = 'not_found';
|
|
821
|
+
else if (lr.status === 301 || lr.status === 302) issueType = 'redirect';
|
|
822
|
+
else if (lr.isTimeout) issueType = 'timeout';
|
|
823
|
+
else if (!lr.ok && lr.status !== 301 && lr.status !== 302) issueType = 'network_error';
|
|
824
|
+
|
|
825
|
+
if (!issueType) continue;
|
|
826
|
+
if (issueType === 'redirect') { totalRedirects++; if (!include_redirects) continue; }
|
|
827
|
+
if (issueType === 'timeout') totalTimeouts++;
|
|
828
|
+
if (issueType === 'not_found' || issueType === 'network_error') totalBroken++;
|
|
829
|
+
|
|
830
|
+
const sources = linkToSources.get(url) || [];
|
|
831
|
+
for (const src of sources) {
|
|
832
|
+
brokenLinks.push({
|
|
833
|
+
source_post_id: src.post_id, source_post_title: src.post_title, source_post_url: src.post_url,
|
|
834
|
+
broken_url: url, anchor_text: getAnchorText(src.html, url),
|
|
835
|
+
status_code: lr.status, issue_type: issueType
|
|
836
|
+
});
|
|
837
|
+
}
|
|
838
|
+
}
|
|
839
|
+
|
|
840
|
+
// Sort: not_found first, then redirect, then timeout, then network_error
|
|
841
|
+
const typeOrder = { not_found: 0, redirect: 1, timeout: 2, network_error: 3 };
|
|
842
|
+
brokenLinks.sort((a, b) => (typeOrder[a.issue_type] ?? 9) - (typeOrder[b.issue_type] ?? 9));
|
|
843
|
+
|
|
844
|
+
result = json({
|
|
845
|
+
total_posts_scanned: allPosts.length,
|
|
846
|
+
total_links_checked: allUrls.length,
|
|
847
|
+
total_broken: totalBroken,
|
|
848
|
+
total_redirects: totalRedirects,
|
|
849
|
+
total_timeouts: totalTimeouts,
|
|
850
|
+
broken_links: brokenLinks,
|
|
851
|
+
scan_duration_ms: Date.now() - scanStart
|
|
852
|
+
});
|
|
853
|
+
auditLog({ tool: name, action: 'audit_seo', target_type: 'post', status: 'success', latency_ms: Date.now() - t0, params: { limit_posts, total_links_checked: allUrls.length } });
|
|
854
|
+
return result;
|
|
855
|
+
};
|
|
856
|
+
handlers['wp_find_keyword_cannibalization'] = async (args) => {
|
|
857
|
+
const t0 = Date.now();
|
|
858
|
+
let result;
|
|
859
|
+
const { wpApiCall, auditLog, name } = rt;
|
|
860
|
+
validateInput(args, {
|
|
861
|
+
limit: { type: 'number', min: 1, max: 500 },
|
|
862
|
+
post_type: { type: 'string', enum: ['post', 'page', 'both'] },
|
|
863
|
+
similarity_mode: { type: 'string', enum: ['exact', 'normalized'] },
|
|
864
|
+
min_group_size: { type: 'number', min: 2 }
|
|
865
|
+
});
|
|
866
|
+
const { limit = 200, post_type = 'post', similarity_mode = 'normalized', min_group_size = 2 } = args;
|
|
867
|
+
|
|
868
|
+
const endpoints = post_type === 'both' ? ['/posts', '/pages'] : [post_type === 'page' ? '/pages' : '/posts'];
|
|
869
|
+
let allPosts = [];
|
|
870
|
+
for (const ep of endpoints) {
|
|
871
|
+
const items = await wpApiCall(`${ep}?per_page=${Math.min(limit, 100)}&status=publish&_fields=id,title,link,date,meta`);
|
|
872
|
+
allPosts = allPosts.concat(items);
|
|
873
|
+
}
|
|
874
|
+
allPosts = allPosts.slice(0, limit);
|
|
875
|
+
|
|
876
|
+
// Extract focus keyword
|
|
877
|
+
function getFocusKeyword(meta) {
|
|
878
|
+
if (!meta) return null;
|
|
879
|
+
return meta.rank_math_focus_keyword || meta._yoast_wpseo_focuskw || meta._seopress_analysis_target_kw || meta._aioseo_keywords || null;
|
|
880
|
+
}
|
|
881
|
+
|
|
882
|
+
// Normalize keyword
|
|
883
|
+
function normalizeKeyword(kw) {
|
|
884
|
+
return kw
|
|
885
|
+
.toLowerCase()
|
|
886
|
+
.normalize('NFD').replace(/[\u0300-\u036f]/g, '')
|
|
887
|
+
.replace(/\b(le|la|les|un|une|des|de|du|en|et|ou|the|a|an|of|for|in|on|at)\b/g, '')
|
|
888
|
+
.replace(/[^a-z0-9\s]/g, ' ')
|
|
889
|
+
.replace(/\s+/g, ' ')
|
|
890
|
+
.trim();
|
|
891
|
+
}
|
|
892
|
+
|
|
893
|
+
// Group by keyword
|
|
894
|
+
const kwMap = new Map(); // normalizedKw → [{id, title, url, date, original_keyword}]
|
|
895
|
+
let postsWithKw = 0;
|
|
896
|
+
let postsWithoutKw = 0;
|
|
897
|
+
|
|
898
|
+
for (const p of allPosts) {
|
|
899
|
+
const raw = getFocusKeyword(p.meta);
|
|
900
|
+
if (!raw || !raw.trim()) { postsWithoutKw++; continue; }
|
|
901
|
+
postsWithKw++;
|
|
902
|
+
const key = similarity_mode === 'exact' ? raw.trim() : normalizeKeyword(raw);
|
|
903
|
+
if (!kwMap.has(key)) kwMap.set(key, []);
|
|
904
|
+
kwMap.get(key).push({
|
|
905
|
+
id: p.id, title: strip(p.title?.rendered || ''), url: p.link, date: p.date, original_keyword: raw.trim()
|
|
906
|
+
});
|
|
907
|
+
}
|
|
908
|
+
|
|
909
|
+
// Filter groups by min_group_size
|
|
910
|
+
const groups = [];
|
|
911
|
+
for (const [keyword, articles] of kwMap) {
|
|
912
|
+
if (articles.length < min_group_size) continue;
|
|
913
|
+
// Recommended action
|
|
914
|
+
let recommended_action = 'differentiate';
|
|
915
|
+
if (articles.length >= 3) {
|
|
916
|
+
recommended_action = 'merge';
|
|
917
|
+
} else if (articles.length === 2) {
|
|
918
|
+
const d1 = new Date(articles[0].date);
|
|
919
|
+
const d2 = new Date(articles[1].date);
|
|
920
|
+
const diffDays = Math.abs(d1 - d2) / (1000 * 60 * 60 * 24);
|
|
921
|
+
if (diffDays > 365) recommended_action = 'consolidate_301';
|
|
922
|
+
else recommended_action = 'differentiate';
|
|
923
|
+
}
|
|
924
|
+
groups.push({
|
|
925
|
+
keyword,
|
|
926
|
+
variants: [...new Set(articles.map(a => a.original_keyword))],
|
|
927
|
+
articles_count: articles.length,
|
|
928
|
+
articles,
|
|
929
|
+
recommended_action
|
|
930
|
+
});
|
|
931
|
+
}
|
|
932
|
+
|
|
933
|
+
let articlesAffected = 0;
|
|
934
|
+
for (const g of groups) articlesAffected += g.articles_count;
|
|
935
|
+
|
|
936
|
+
result = json({
|
|
937
|
+
total_posts_analyzed: allPosts.length,
|
|
938
|
+
posts_with_keyword: postsWithKw,
|
|
939
|
+
posts_without_keyword: postsWithoutKw,
|
|
940
|
+
total_groups: groups.length,
|
|
941
|
+
articles_affected: articlesAffected,
|
|
942
|
+
cannibalization_groups: groups
|
|
943
|
+
});
|
|
944
|
+
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 } });
|
|
945
|
+
return result;
|
|
946
|
+
};
|
|
947
|
+
handlers['wp_audit_taxonomies'] = async (args) => {
|
|
948
|
+
const t0 = Date.now();
|
|
949
|
+
let result;
|
|
950
|
+
const { wpApiCall, auditLog, name } = rt;
|
|
951
|
+
validateInput(args, {
|
|
952
|
+
min_posts_threshold: { type: 'number', min: 1 }
|
|
953
|
+
});
|
|
954
|
+
const { check_tags = true, check_categories = true, min_posts_threshold = 2, detect_duplicates = true } = args;
|
|
955
|
+
|
|
956
|
+
// Levenshtein distance
|
|
957
|
+
function levenshtein(a, b) {
|
|
958
|
+
const m = a.length, n = b.length;
|
|
959
|
+
const dp = Array.from({ length: m + 1 }, (_, i) =>
|
|
960
|
+
Array.from({ length: n + 1 }, (_, j) => i === 0 ? j : j === 0 ? i : 0)
|
|
961
|
+
);
|
|
962
|
+
for (let i = 1; i <= m; i++)
|
|
963
|
+
for (let j = 1; j <= n; j++)
|
|
964
|
+
dp[i][j] = a[i - 1] === b[j - 1] ? dp[i - 1][j - 1]
|
|
965
|
+
: 1 + Math.min(dp[i - 1][j], dp[i][j - 1], dp[i - 1][j - 1]);
|
|
966
|
+
return dp[m][n];
|
|
967
|
+
}
|
|
968
|
+
|
|
969
|
+
function normalizeTermName(name) {
|
|
970
|
+
return name.toLowerCase()
|
|
971
|
+
.normalize('NFD').replace(/[\u0300-\u036f]/g, '')
|
|
972
|
+
.replace(/[^a-z0-9]/g, '');
|
|
973
|
+
}
|
|
974
|
+
|
|
975
|
+
function auditTerms(terms, isCat) {
|
|
976
|
+
const empty = [];
|
|
977
|
+
const singlePost = [];
|
|
978
|
+
const missingDesc = [];
|
|
979
|
+
for (const t of terms) {
|
|
980
|
+
const item = { id: t.id, name: t.name, slug: t.slug, count: t.count };
|
|
981
|
+
if (t.count === 0) { empty.push({ ...item, issue_type: 'empty' }); }
|
|
982
|
+
else if (t.count < min_posts_threshold) { singlePost.push({ ...item, issue_type: 'single_post' }); }
|
|
983
|
+
if (isCat && (!t.description || t.description.trim() === '')) {
|
|
984
|
+
missingDesc.push({ ...item, issue_type: 'missing_description' });
|
|
985
|
+
}
|
|
986
|
+
}
|
|
987
|
+
|
|
988
|
+
// Duplicate detection
|
|
989
|
+
const duplicateGroups = [];
|
|
990
|
+
if (detect_duplicates && terms.length > 1) {
|
|
991
|
+
const normalized = terms.map(t => ({ ...t, norm: normalizeTermName(t.name) }));
|
|
992
|
+
const used = new Set();
|
|
993
|
+
for (let i = 0; i < normalized.length; i++) {
|
|
994
|
+
if (used.has(i)) continue;
|
|
995
|
+
const group = [normalized[i]];
|
|
996
|
+
for (let j = i + 1; j < normalized.length; j++) {
|
|
997
|
+
if (used.has(j)) continue;
|
|
998
|
+
const isExact = normalized[i].norm === normalized[j].norm;
|
|
999
|
+
const isNear = !isExact && normalized[i].norm.length >= 4 && normalized[j].norm.length >= 4 && levenshtein(normalized[i].norm, normalized[j].norm) <= 2;
|
|
1000
|
+
if (isExact || isNear) {
|
|
1001
|
+
group.push(normalized[j]);
|
|
1002
|
+
used.add(j);
|
|
1003
|
+
}
|
|
1004
|
+
}
|
|
1005
|
+
if (group.length >= 2) {
|
|
1006
|
+
used.add(i);
|
|
1007
|
+
const similarity = group.every(g => g.norm === group[0].norm) ? 'exact' : 'near';
|
|
1008
|
+
duplicateGroups.push({
|
|
1009
|
+
terms: group.map(g => ({ id: g.id, name: g.name, slug: g.slug, count: g.count, issue_type: 'duplicate' })),
|
|
1010
|
+
similarity
|
|
1011
|
+
});
|
|
1012
|
+
}
|
|
1013
|
+
}
|
|
1014
|
+
}
|
|
1015
|
+
|
|
1016
|
+
const issues = { empty, single_post: singlePost, duplicate_groups: duplicateGroups };
|
|
1017
|
+
if (isCat) issues.missing_description = missingDesc;
|
|
1018
|
+
return issues;
|
|
1019
|
+
}
|
|
1020
|
+
|
|
1021
|
+
let tagsResult = null;
|
|
1022
|
+
let catsResult = null;
|
|
1023
|
+
let totalIssues = 0;
|
|
1024
|
+
let totalTerms = 0;
|
|
1025
|
+
|
|
1026
|
+
if (check_tags) {
|
|
1027
|
+
const tags = await wpApiCall('/tags?per_page=100');
|
|
1028
|
+
const issues = auditTerms(tags, false);
|
|
1029
|
+
const tagIssueCount = issues.empty.length + issues.single_post.length + issues.duplicate_groups.reduce((s, g) => s + g.terms.length, 0);
|
|
1030
|
+
totalIssues += tagIssueCount;
|
|
1031
|
+
totalTerms += tags.length;
|
|
1032
|
+
tagsResult = { total: tags.length, issues };
|
|
1033
|
+
}
|
|
1034
|
+
|
|
1035
|
+
if (check_categories) {
|
|
1036
|
+
const cats = await wpApiCall('/categories?per_page=100');
|
|
1037
|
+
const issues = auditTerms(cats, true);
|
|
1038
|
+
const catIssueCount = issues.empty.length + issues.single_post.length + (issues.missing_description || []).length + issues.duplicate_groups.reduce((s, g) => s + g.terms.length, 0);
|
|
1039
|
+
totalIssues += catIssueCount;
|
|
1040
|
+
totalTerms += cats.length;
|
|
1041
|
+
catsResult = { total: cats.length, issues };
|
|
1042
|
+
}
|
|
1043
|
+
|
|
1044
|
+
const crawlWasteScore = totalTerms > 0 ? Math.min(100, Math.round((totalIssues / totalTerms) * 100)) : 0;
|
|
1045
|
+
|
|
1046
|
+
const output = { total_issues: totalIssues, crawl_waste_score: crawlWasteScore };
|
|
1047
|
+
if (tagsResult) output.tags = tagsResult;
|
|
1048
|
+
if (catsResult) output.categories = catsResult;
|
|
1049
|
+
|
|
1050
|
+
result = json(output);
|
|
1051
|
+
auditLog({ tool: name, action: 'audit_seo', target_type: 'taxonomy', status: 'success', latency_ms: Date.now() - t0, params: { total_issues: totalIssues, crawl_waste_score: crawlWasteScore } });
|
|
1052
|
+
return result;
|
|
1053
|
+
};
|
|
1054
|
+
handlers['wp_audit_outbound_links'] = async (args) => {
|
|
1055
|
+
const t0 = Date.now();
|
|
1056
|
+
let result;
|
|
1057
|
+
const { wpApiCall, auditLog, name, countWords } = rt;
|
|
1058
|
+
validateInput(args, {
|
|
1059
|
+
limit: { type: 'number', min: 1, max: 100 },
|
|
1060
|
+
post_type: { type: 'string', enum: ['post', 'page'] },
|
|
1061
|
+
min_outbound: { type: 'number', min: 0 },
|
|
1062
|
+
max_outbound: { type: 'number', min: 1 },
|
|
1063
|
+
authoritative_domains: { type: 'array' }
|
|
1064
|
+
});
|
|
1065
|
+
const { limit = 30, post_type = 'post', min_outbound = 1, max_outbound = 15, authoritative_domains = ['wikipedia.org', 'gov', 'edu', 'who.int', 'pubmed.ncbi'] } = args;
|
|
1066
|
+
const endpoint = post_type === 'page' ? '/pages' : '/posts';
|
|
1067
|
+
const posts = await wpApiCall(`${endpoint}?per_page=${Math.min(limit, 100)}&status=publish&_fields=id,title,link,content`);
|
|
1068
|
+
|
|
1069
|
+
function extractDomain(url) {
|
|
1070
|
+
try { return new URL(url).hostname.replace(/^www\./, ''); }
|
|
1071
|
+
catch { return null; }
|
|
1072
|
+
}
|
|
1073
|
+
|
|
1074
|
+
function isAuthoritative(domain) {
|
|
1075
|
+
return authoritative_domains.some(ad => domain === ad || domain.endsWith('.' + ad));
|
|
1076
|
+
}
|
|
1077
|
+
|
|
1078
|
+
const domainCounts = new Map();
|
|
1079
|
+
const articles = [];
|
|
1080
|
+
let totalAuthoritative = 0;
|
|
1081
|
+
|
|
1082
|
+
for (const p of posts) {
|
|
1083
|
+
const html = p.content?.rendered || '';
|
|
1084
|
+
const wc = countWords(html);
|
|
1085
|
+
const siteHost = p.link ? extractDomain(p.link) : '';
|
|
1086
|
+
|
|
1087
|
+
// Extract all links
|
|
1088
|
+
const allLinks = [];
|
|
1089
|
+
const linkRegex = /<a\s[^>]*?href=["']([^"']+)["'][^>]*?>/gi;
|
|
1090
|
+
let m;
|
|
1091
|
+
while ((m = linkRegex.exec(html)) !== null) {
|
|
1092
|
+
try {
|
|
1093
|
+
const href = m[1];
|
|
1094
|
+
if (href.startsWith('http')) allLinks.push(href);
|
|
1095
|
+
} catch { /* skip */ }
|
|
1096
|
+
}
|
|
1097
|
+
|
|
1098
|
+
// Separate external links
|
|
1099
|
+
const externalLinks = [];
|
|
1100
|
+
const externalDomains = new Set();
|
|
1101
|
+
for (const link of allLinks) {
|
|
1102
|
+
const dom = extractDomain(link);
|
|
1103
|
+
if (!dom || dom === siteHost) continue;
|
|
1104
|
+
externalLinks.push(link);
|
|
1105
|
+
externalDomains.add(dom);
|
|
1106
|
+
domainCounts.set(dom, (domainCounts.get(dom) || 0) + 1);
|
|
1107
|
+
}
|
|
1108
|
+
|
|
1109
|
+
const outboundCount = externalLinks.length;
|
|
1110
|
+
let authCount = 0;
|
|
1111
|
+
for (const dom of externalDomains) {
|
|
1112
|
+
if (isAuthoritative(dom)) authCount++;
|
|
1113
|
+
}
|
|
1114
|
+
if (authCount > 0) totalAuthoritative++;
|
|
1115
|
+
|
|
1116
|
+
let status;
|
|
1117
|
+
if (outboundCount === 0) status = 'no_outbound';
|
|
1118
|
+
else if (outboundCount < min_outbound) status = 'insufficient';
|
|
1119
|
+
else if (outboundCount > max_outbound) status = 'excessive';
|
|
1120
|
+
else status = 'good';
|
|
1121
|
+
|
|
1122
|
+
articles.push({
|
|
1123
|
+
id: p.id, title: strip(p.title?.rendered || ''), url: p.link, word_count: wc,
|
|
1124
|
+
outbound_count: outboundCount,
|
|
1125
|
+
authoritative_count: authCount,
|
|
1126
|
+
outbound_ratio: wc > 0 ? Math.round((outboundCount / (wc / 100)) * 100) / 100 : 0,
|
|
1127
|
+
status,
|
|
1128
|
+
external_domains: [...externalDomains]
|
|
1129
|
+
});
|
|
1130
|
+
}
|
|
1131
|
+
|
|
1132
|
+
// Top cited domains
|
|
1133
|
+
const topDomains = [...domainCounts.entries()]
|
|
1134
|
+
.sort((a, b) => b[1] - a[1])
|
|
1135
|
+
.slice(0, 10)
|
|
1136
|
+
.map(([domain, count]) => ({ domain, count, is_authoritative: isAuthoritative(domain) }));
|
|
1137
|
+
|
|
1138
|
+
const byStatus = { no_outbound: 0, insufficient: 0, good: 0, excessive: 0 };
|
|
1139
|
+
for (const a of articles) byStatus[a.status]++;
|
|
1140
|
+
|
|
1141
|
+
const avgOutbound = articles.length > 0 ? Math.round((articles.reduce((s, a) => s + a.outbound_count, 0) / articles.length) * 100) / 100 : 0;
|
|
1142
|
+
|
|
1143
|
+
result = json({
|
|
1144
|
+
total_analyzed: posts.length,
|
|
1145
|
+
by_status: byStatus,
|
|
1146
|
+
top_cited_domains: topDomains,
|
|
1147
|
+
articles,
|
|
1148
|
+
average_outbound_per_post: avgOutbound,
|
|
1149
|
+
posts_with_authoritative_sources: totalAuthoritative
|
|
1150
|
+
});
|
|
1151
|
+
auditLog({ tool: name, action: 'audit_seo', target_type: 'post', status: 'success', latency_ms: Date.now() - t0, params: { total_analyzed: posts.length } });
|
|
1152
|
+
return result;
|
|
1153
|
+
};
|
|
1154
|
+
handlers['wp_detect_multilingual_plugin'] = async (args) => {
|
|
1155
|
+
const t0 = Date.now();
|
|
1156
|
+
let result;
|
|
1157
|
+
const { wpApiCall, getActiveAuth, fetch, auditLog, name } = rt;
|
|
1158
|
+
let mlPlugin = null, mlVersion = null, mlLangs = [], mlDefault = null, mlApiAvailable = false, mlMethod = 'none';
|
|
1159
|
+
// 1. Try WPML
|
|
1160
|
+
try {
|
|
1161
|
+
const wpmlLangs = await wpApiCall('/languages', { basePath: '/wp-json/wpml/v1' });
|
|
1162
|
+
if (wpmlLangs && (Array.isArray(wpmlLangs) || typeof wpmlLangs === 'object')) {
|
|
1163
|
+
mlPlugin = 'wpml'; mlApiAvailable = true; mlMethod = 'rest_api';
|
|
1164
|
+
const langArr = Array.isArray(wpmlLangs) ? wpmlLangs : Object.values(wpmlLangs);
|
|
1165
|
+
mlLangs = langArr.map(l => ({ code: l.code || l.language_code, name: l.english_name || l.translated_name || l.name, default: !!(l.default_locale || l.is_default) }));
|
|
1166
|
+
mlDefault = mlLangs.find(l => l.default)?.code || mlLangs[0]?.code;
|
|
1167
|
+
}
|
|
1168
|
+
} catch (_e) { /* WPML not available */ }
|
|
1169
|
+
// 2. Try Polylang Pro
|
|
1170
|
+
if (!mlPlugin) {
|
|
1171
|
+
try {
|
|
1172
|
+
const pllLangs = await wpApiCall('/languages', { basePath: '/wp-json/pll/v1' });
|
|
1173
|
+
if (pllLangs && Array.isArray(pllLangs) && pllLangs.length > 0) {
|
|
1174
|
+
mlPlugin = 'polylang_pro'; mlApiAvailable = true; mlMethod = 'rest_api';
|
|
1175
|
+
mlLangs = pllLangs.map(l => ({ code: l.slug || l.locale, name: l.name, default: !!l.is_default }));
|
|
1176
|
+
mlDefault = mlLangs.find(l => l.default)?.code || mlLangs[0]?.code;
|
|
1177
|
+
}
|
|
1178
|
+
} catch (_e) { /* Polylang Pro not available */ }
|
|
1179
|
+
}
|
|
1180
|
+
// 3. Try mu-plugin Polylang endpoint (covers Polylang Free with mu-plugin)
|
|
1181
|
+
if (!mlPlugin) {
|
|
1182
|
+
try {
|
|
1183
|
+
const pllMu = await wpApiCall('/polylang/languages', { basePath: '/wp-json/mcp-diagnostics/v1' });
|
|
1184
|
+
if (pllMu && pllMu.languages && pllMu.languages.length > 0) {
|
|
1185
|
+
mlPlugin = 'polylang_free'; mlApiAvailable = true; mlMethod = 'rest_api';
|
|
1186
|
+
mlLangs = pllMu.languages.map(l => ({ code: l.slug || l.locale, name: l.name, default: !!l.is_default }));
|
|
1187
|
+
mlDefault = mlLangs.find(l => l.default)?.code || mlLangs[0]?.code;
|
|
1188
|
+
}
|
|
1189
|
+
} catch (_e) { /* mu-plugin not available */ }
|
|
1190
|
+
}
|
|
1191
|
+
// 4. Try TranslatePress
|
|
1192
|
+
if (!mlPlugin) {
|
|
1193
|
+
try {
|
|
1194
|
+
const tpLangs = await wpApiCall('/languages', { basePath: '/wp-json/translatepress/v1' });
|
|
1195
|
+
if (tpLangs && (Array.isArray(tpLangs) || typeof tpLangs === 'object')) {
|
|
1196
|
+
mlPlugin = 'translatepress'; mlApiAvailable = true; mlMethod = 'rest_api';
|
|
1197
|
+
const tpArr = Array.isArray(tpLangs) ? tpLangs : Object.values(tpLangs);
|
|
1198
|
+
mlLangs = tpArr.map(l => ({ code: l.code || l.language_code || l.short_language, name: l.english_name || l.name, default: !!l.is_default }));
|
|
1199
|
+
mlDefault = mlLangs.find(l => l.default)?.code || mlLangs[0]?.code;
|
|
1200
|
+
}
|
|
1201
|
+
} catch (_e) { /* TranslatePress not available */ }
|
|
1202
|
+
}
|
|
1203
|
+
// 5. Fallback: hreflang parsing from homepage (Polylang Free without mu-plugin)
|
|
1204
|
+
if (!mlPlugin) {
|
|
1205
|
+
try {
|
|
1206
|
+
const { url: siteUrl } = getActiveAuth();
|
|
1207
|
+
const hlResp = await fetch(siteUrl, { headers: { 'User-Agent': 'WordPress-MCP-Server' }, redirect: 'follow' });
|
|
1208
|
+
if (hlResp.ok) {
|
|
1209
|
+
const hlHtml = await hlResp.text();
|
|
1210
|
+
const hlRe = /<link[^>]*rel=["']alternate["'][^>]*hreflang=["']([^"']+)["'][^>]*href=["']([^"']+)["'][^>]*\/?>/gi;
|
|
1211
|
+
let hlM;
|
|
1212
|
+
const hlLangs = [];
|
|
1213
|
+
while ((hlM = hlRe.exec(hlHtml)) !== null) {
|
|
1214
|
+
if (hlM[1] !== 'x-default') hlLangs.push({ code: hlM[1], url: hlM[2] });
|
|
1215
|
+
}
|
|
1216
|
+
if (hlLangs.length > 0) {
|
|
1217
|
+
mlPlugin = 'polylang_free'; mlMethod = 'hreflang_parsing'; mlApiAvailable = false;
|
|
1218
|
+
mlLangs = hlLangs.map((l, i) => ({ code: l.code, name: l.code, default: i === 0 }));
|
|
1219
|
+
mlDefault = mlLangs[0]?.code;
|
|
1220
|
+
}
|
|
1221
|
+
}
|
|
1222
|
+
} catch (_e) { /* homepage not reachable */ }
|
|
1223
|
+
}
|
|
1224
|
+
// Try to get version from plugins list
|
|
1225
|
+
if (mlPlugin) {
|
|
1226
|
+
try {
|
|
1227
|
+
const pluginsList = await wpApiCall('/plugins', { basePath: '/wp-json/wp/v2' });
|
|
1228
|
+
const pluginMap = { wpml: 'sitepress-multilingual-cms', polylang_pro: 'polylang-pro', polylang_free: 'polylang', translatepress: 'translatepress-multilingual' };
|
|
1229
|
+
const slug = pluginMap[mlPlugin];
|
|
1230
|
+
const found = Array.isArray(pluginsList) ? pluginsList.find(p => (p.plugin || '').includes(slug)) : null;
|
|
1231
|
+
if (found) mlVersion = found.version || null;
|
|
1232
|
+
} catch (_e) { /* plugins list not accessible */ }
|
|
1233
|
+
}
|
|
1234
|
+
result = json({ plugin: mlPlugin, version: mlVersion, languages: mlLangs, default_language: mlDefault, api_available: mlApiAvailable, detection_method: mlMethod });
|
|
1235
|
+
auditLog({ tool: name, action: 'detect_multilingual', status: 'success', latency_ms: Date.now() - t0 });
|
|
1236
|
+
return result;
|
|
1237
|
+
};
|
|
1238
|
+
handlers['wp_list_languages'] = async (args) => {
|
|
1239
|
+
const t0 = Date.now();
|
|
1240
|
+
let result;
|
|
1241
|
+
const { wpApiCall, auditLog, name, handleToolCall } = rt;
|
|
1242
|
+
const { include_post_count = false } = args;
|
|
1243
|
+
// Detect plugin first
|
|
1244
|
+
const mlDetectResult = await handleToolCall({ params: { name: 'wp_detect_multilingual_plugin', arguments: {} } });
|
|
1245
|
+
const mlDetect = JSON.parse(mlDetectResult.content[0].text);
|
|
1246
|
+
if (!mlDetect.plugin) {
|
|
1247
|
+
result = json({ languages: [{ code: 'default', name: 'Site language', native_name: 'Site language', locale: null, url_prefix: null, flag_url: null, is_default: true, post_count: null }], plugin: null });
|
|
1248
|
+
auditLog({ tool: name, action: 'list_languages', status: 'success', latency_ms: Date.now() - t0 });
|
|
1249
|
+
return result;
|
|
1250
|
+
}
|
|
1251
|
+
let llLangs = [];
|
|
1252
|
+
if (mlDetect.plugin === 'wpml') {
|
|
1253
|
+
try {
|
|
1254
|
+
const wpmlL = await wpApiCall('/languages', { basePath: '/wp-json/wpml/v1' });
|
|
1255
|
+
const arr = Array.isArray(wpmlL) ? wpmlL : Object.values(wpmlL);
|
|
1256
|
+
llLangs = arr.map(l => ({ code: l.code || l.language_code, name: l.english_name || l.name, native_name: l.native_name || l.translated_name || l.name, locale: l.default_locale || l.locale, url_prefix: l.url || null, flag_url: l.country_flag_url || l.flag_url || null, is_default: !!(l.default_locale || l.is_default), post_count: null }));
|
|
1257
|
+
} catch (_e) { llLangs = mlDetect.languages.map(l => ({ ...l, native_name: l.name, locale: null, url_prefix: null, flag_url: null, is_default: l.default, post_count: null })); }
|
|
1258
|
+
} else if (mlDetect.plugin === 'polylang_pro') {
|
|
1259
|
+
try {
|
|
1260
|
+
const pllL = await wpApiCall('/languages', { basePath: '/wp-json/pll/v1' });
|
|
1261
|
+
llLangs = pllL.map(l => ({ code: l.slug || l.locale, name: l.name, native_name: l.native_name || l.name, locale: l.locale || null, url_prefix: l.home_url || null, flag_url: l.flag || null, is_default: !!l.is_default, post_count: null }));
|
|
1262
|
+
} catch (_e) { llLangs = mlDetect.languages.map(l => ({ ...l, native_name: l.name, locale: null, url_prefix: null, flag_url: null, is_default: l.default, post_count: null })); }
|
|
1263
|
+
} else if (mlDetect.plugin === 'polylang_free') {
|
|
1264
|
+
if (mlDetect.api_available) {
|
|
1265
|
+
try {
|
|
1266
|
+
const pllMuL = await wpApiCall('/polylang/languages', { basePath: '/wp-json/mcp-diagnostics/v1' });
|
|
1267
|
+
llLangs = pllMuL.languages.map(l => ({ code: l.slug || l.locale, name: l.name, native_name: l.native_name || l.name, locale: l.locale || null, url_prefix: l.home_url || null, flag_url: l.flag || null, is_default: !!l.is_default, post_count: null }));
|
|
1268
|
+
} catch (_e) { llLangs = mlDetect.languages.map(l => ({ ...l, native_name: l.name, locale: null, url_prefix: null, flag_url: null, is_default: l.default, post_count: null })); }
|
|
1269
|
+
} else {
|
|
1270
|
+
llLangs = mlDetect.languages.map(l => ({ code: l.code, name: l.code, native_name: l.code, locale: null, url_prefix: null, flag_url: null, is_default: l.default, post_count: null }));
|
|
1271
|
+
}
|
|
1272
|
+
} else if (mlDetect.plugin === 'translatepress') {
|
|
1273
|
+
try {
|
|
1274
|
+
const tpL = await wpApiCall('/languages', { basePath: '/wp-json/translatepress/v1' });
|
|
1275
|
+
const tpArr = Array.isArray(tpL) ? tpL : Object.values(tpL);
|
|
1276
|
+
llLangs = tpArr.map(l => ({ code: l.code || l.short_language, name: l.english_name || l.name, native_name: l.native_name || l.name, locale: l.locale || null, url_prefix: l.url || null, flag_url: l.flag_url || null, is_default: !!l.is_default, post_count: null }));
|
|
1277
|
+
} catch (_e) { llLangs = mlDetect.languages.map(l => ({ ...l, native_name: l.name, locale: null, url_prefix: null, flag_url: null, is_default: l.default, post_count: null })); }
|
|
1278
|
+
}
|
|
1279
|
+
// Post counts if requested
|
|
1280
|
+
if (include_post_count && llLangs.length > 0) {
|
|
1281
|
+
for (const lang of llLangs) {
|
|
1282
|
+
try {
|
|
1283
|
+
let ep = '/posts?per_page=1&status=publish';
|
|
1284
|
+
if (mlDetect.plugin === 'wpml') ep += `&wpml_language=${lang.code}`;
|
|
1285
|
+
else if (mlDetect.plugin.startsWith('polylang')) ep += `&lang=${lang.code}`;
|
|
1286
|
+
else if (mlDetect.plugin === 'translatepress') ep += `&trp_language=${lang.code}`;
|
|
1287
|
+
const countResp = await wpApiCall(ep);
|
|
1288
|
+
lang.post_count = Array.isArray(countResp) ? countResp.length : 0;
|
|
1289
|
+
} catch (_e) { lang.post_count = null; }
|
|
1290
|
+
}
|
|
1291
|
+
}
|
|
1292
|
+
result = json({ languages: llLangs, plugin: mlDetect.plugin, total: llLangs.length });
|
|
1293
|
+
auditLog({ tool: name, action: 'list_languages', status: 'success', latency_ms: Date.now() - t0 });
|
|
1294
|
+
return result;
|
|
1295
|
+
};
|
|
1296
|
+
handlers['wp_get_post_translations'] = async (args) => {
|
|
1297
|
+
const t0 = Date.now();
|
|
1298
|
+
let result;
|
|
1299
|
+
const { wpApiCall, fetch, auditLog, name, handleToolCall } = rt;
|
|
1300
|
+
validateInput(args, { post_id: { type: 'number', required: true, min: 1 } });
|
|
1301
|
+
const { post_id: trPostId, post_type: trPostType = 'post' } = args;
|
|
1302
|
+
const mlDet = JSON.parse((await handleToolCall({ params: { name: 'wp_detect_multilingual_plugin', arguments: {} } })).content[0].text);
|
|
1303
|
+
if (!mlDet.plugin) {
|
|
1304
|
+
result = json({ source_post_id: trPostId, source_lang: null, multilingual: false, translations: {}, message: 'No multilingual plugin detected.' });
|
|
1305
|
+
auditLog({ tool: name, action: 'get_translations', status: 'success', latency_ms: Date.now() - t0, params: { post_id: trPostId } });
|
|
1306
|
+
return result;
|
|
1307
|
+
}
|
|
1308
|
+
let trSourceLang = mlDet.default_language;
|
|
1309
|
+
const translations = {};
|
|
1310
|
+
const trEp = trPostType === 'page' ? 'pages' : 'posts';
|
|
1311
|
+
if (mlDet.plugin === 'wpml') {
|
|
1312
|
+
try {
|
|
1313
|
+
const wpmlTr = await wpApiCall(`/translations?post_id=${trPostId}`, { basePath: '/wp-json/wpml/v1' });
|
|
1314
|
+
if (wpmlTr.source_language) trSourceLang = wpmlTr.source_language;
|
|
1315
|
+
const trMap = wpmlTr.translations || wpmlTr;
|
|
1316
|
+
for (const [lang, tr] of Object.entries(trMap)) {
|
|
1317
|
+
if (lang === 'source_language') continue;
|
|
1318
|
+
const trId = tr.post_id || tr.id || tr;
|
|
1319
|
+
if (typeof trId === 'number' || typeof trId === 'string') {
|
|
1320
|
+
try {
|
|
1321
|
+
const trPost = await wpApiCall(`/${trEp}/${trId}`);
|
|
1322
|
+
const trMeta = trPost.meta || {};
|
|
1323
|
+
translations[lang] = { post_id: trPost.id, title: trPost.title?.rendered || '', url: trPost.link, status: trPost.status, last_modified: trPost.modified, has_seo_meta: !!(trMeta._yoast_wpseo_title || trMeta.rank_math_title || trMeta._seopress_titles_title) };
|
|
1324
|
+
} catch (_e) { translations[lang] = { post_id: trId, title: null, url: null, status: 'unknown', last_modified: null, has_seo_meta: false }; }
|
|
1325
|
+
}
|
|
1326
|
+
}
|
|
1327
|
+
} catch (_e) { /* WPML translations endpoint failed */ }
|
|
1328
|
+
} else if (mlDet.plugin === 'polylang_pro') {
|
|
1329
|
+
try {
|
|
1330
|
+
const pllTr = await wpApiCall(`/posts/${trPostId}/translations`, { basePath: '/wp-json/pll/v1' });
|
|
1331
|
+
for (const [lang, trId] of Object.entries(pllTr)) {
|
|
1332
|
+
if (trId === trPostId) { trSourceLang = lang; continue; }
|
|
1333
|
+
try {
|
|
1334
|
+
const trPost = await wpApiCall(`/${trEp}/${trId}`);
|
|
1335
|
+
const trMeta = trPost.meta || {};
|
|
1336
|
+
translations[lang] = { post_id: trPost.id, title: trPost.title?.rendered || '', url: trPost.link, status: trPost.status, last_modified: trPost.modified, has_seo_meta: !!(trMeta._yoast_wpseo_title || trMeta.rank_math_title || trMeta._seopress_titles_title) };
|
|
1337
|
+
} catch (_e) { translations[lang] = { post_id: trId, title: null, url: null, status: 'unknown', last_modified: null, has_seo_meta: false }; }
|
|
1338
|
+
}
|
|
1339
|
+
} catch (_e) { /* PLL translations failed */ }
|
|
1340
|
+
} else if (mlDet.plugin === 'polylang_free') {
|
|
1341
|
+
// Try mu-plugin endpoint first
|
|
1342
|
+
try {
|
|
1343
|
+
const pllMuTr = await wpApiCall(`/polylang/translations/${trPostId}`, { basePath: '/wp-json/mcp-diagnostics/v1' });
|
|
1344
|
+
if (pllMuTr && pllMuTr.translations) {
|
|
1345
|
+
for (const [lang, trId] of Object.entries(pllMuTr.translations)) {
|
|
1346
|
+
if (trId === trPostId) { trSourceLang = lang; continue; }
|
|
1347
|
+
try {
|
|
1348
|
+
const trPost = await wpApiCall(`/${trEp}/${trId}`);
|
|
1349
|
+
const trMeta = trPost.meta || {};
|
|
1350
|
+
translations[lang] = { post_id: trPost.id, title: trPost.title?.rendered || '', url: trPost.link, status: trPost.status, last_modified: trPost.modified, has_seo_meta: !!(trMeta._yoast_wpseo_title || trMeta.rank_math_title || trMeta._seopress_titles_title) };
|
|
1351
|
+
} catch (_e) { translations[lang] = { post_id: trId, title: null, url: null, status: 'unknown', last_modified: null, has_seo_meta: false }; }
|
|
1352
|
+
}
|
|
1353
|
+
}
|
|
1354
|
+
} catch (_e) {
|
|
1355
|
+
// Fallback: fetch post HTML, parse hreflang
|
|
1356
|
+
try {
|
|
1357
|
+
const srcPost = await wpApiCall(`/${trEp}/${trPostId}`);
|
|
1358
|
+
const srcUrl = srcPost.link;
|
|
1359
|
+
if (srcUrl) {
|
|
1360
|
+
const hlResp = await fetch(srcUrl, { headers: { 'User-Agent': 'WordPress-MCP-Server' }, redirect: 'follow' });
|
|
1361
|
+
if (hlResp.ok) {
|
|
1362
|
+
const hlHtml = await hlResp.text();
|
|
1363
|
+
const hlRe = /<link[^>]*rel=["']alternate["'][^>]*hreflang=["']([^"']+)["'][^>]*href=["']([^"']+)["'][^>]*\/?>/gi;
|
|
1364
|
+
let hlM;
|
|
1365
|
+
while ((hlM = hlRe.exec(hlHtml)) !== null) {
|
|
1366
|
+
if (hlM[1] === 'x-default') continue;
|
|
1367
|
+
const hlLang = hlM[1]; const hlUrl = hlM[2];
|
|
1368
|
+
if (hlUrl === srcUrl) { trSourceLang = hlLang; continue; }
|
|
1369
|
+
translations[hlLang] = { post_id: null, title: null, url: hlUrl, status: 'unknown', last_modified: null, has_seo_meta: false };
|
|
1370
|
+
}
|
|
1371
|
+
}
|
|
1372
|
+
}
|
|
1373
|
+
} catch (_e2) { /* hreflang fallback failed */ }
|
|
1374
|
+
}
|
|
1375
|
+
} else if (mlDet.plugin === 'translatepress') {
|
|
1376
|
+
try {
|
|
1377
|
+
const srcPost = await wpApiCall(`/${trEp}/${trPostId}`);
|
|
1378
|
+
const tpMeta = srcPost.meta || {};
|
|
1379
|
+
const origId = tpMeta._tp_original_post || trPostId;
|
|
1380
|
+
for (const lang of mlDet.languages) {
|
|
1381
|
+
if (lang.code === trSourceLang) continue;
|
|
1382
|
+
try {
|
|
1383
|
+
const tpResp = await wpApiCall(`/${trEp}?meta_key=_tp_original_post&meta_value=${origId}&lang=${lang.code}&per_page=1`);
|
|
1384
|
+
if (Array.isArray(tpResp) && tpResp.length > 0) {
|
|
1385
|
+
const trPost = tpResp[0];
|
|
1386
|
+
const trMeta = trPost.meta || {};
|
|
1387
|
+
translations[lang.code] = { post_id: trPost.id, title: trPost.title?.rendered || '', url: trPost.link, status: trPost.status, last_modified: trPost.modified, has_seo_meta: !!(trMeta._yoast_wpseo_title || trMeta.rank_math_title || trMeta._seopress_titles_title) };
|
|
1388
|
+
}
|
|
1389
|
+
} catch (_e) { /* translation not found for this lang */ }
|
|
1390
|
+
}
|
|
1391
|
+
} catch (_e) { /* TranslatePress translations failed */ }
|
|
1392
|
+
}
|
|
1393
|
+
result = json({ source_post_id: trPostId, source_lang: trSourceLang, translations, translation_count: Object.keys(translations).length });
|
|
1394
|
+
auditLog({ tool: name, action: 'get_translations', status: 'success', latency_ms: Date.now() - t0, params: { post_id: trPostId } });
|
|
1395
|
+
return result;
|
|
1396
|
+
};
|
|
1397
|
+
handlers['wp_audit_translation_coverage'] = async (args) => {
|
|
1398
|
+
const t0 = Date.now();
|
|
1399
|
+
let result;
|
|
1400
|
+
const { wpApiCall, auditLog, name, handleToolCall } = rt;
|
|
1401
|
+
const { post_types: covTypes = ['post', 'page'], min_word_count = 0 } = args;
|
|
1402
|
+
const covLangsResult = JSON.parse((await handleToolCall({ params: { name: 'wp_list_languages', arguments: { include_post_count: false } } })).content[0].text);
|
|
1403
|
+
if (!covLangsResult.plugin || covLangsResult.languages.length <= 1) {
|
|
1404
|
+
result = json({ multilingual: false, message: 'No multilingual plugin detected or only one language configured.' });
|
|
1405
|
+
auditLog({ tool: name, action: 'audit_translation_coverage', status: 'success', latency_ms: Date.now() - t0 });
|
|
1406
|
+
return result;
|
|
1407
|
+
}
|
|
1408
|
+
const covLangs = covLangsResult.languages;
|
|
1409
|
+
const defaultLang = covLangs.find(l => l.is_default)?.code || covLangs[0]?.code;
|
|
1410
|
+
const coverage_by_language = [];
|
|
1411
|
+
const untranslatedPosts = [];
|
|
1412
|
+
for (const lang of covLangs) {
|
|
1413
|
+
const typeCoverage = [];
|
|
1414
|
+
for (const pt of covTypes) {
|
|
1415
|
+
const ep = pt === 'page' ? 'pages' : 'posts';
|
|
1416
|
+
try {
|
|
1417
|
+
let qp = `/${ep}?per_page=100&status=publish`;
|
|
1418
|
+
if (covLangsResult.plugin === 'wpml') qp += `&wpml_language=${lang.code}`;
|
|
1419
|
+
else if (covLangsResult.plugin.startsWith('polylang')) qp += `&lang=${lang.code}`;
|
|
1420
|
+
else if (covLangsResult.plugin === 'translatepress') qp += `&trp_language=${lang.code}`;
|
|
1421
|
+
const posts = await wpApiCall(qp);
|
|
1422
|
+
const count = Array.isArray(posts) ? posts.length : 0;
|
|
1423
|
+
// Get default lang total for comparison
|
|
1424
|
+
let defaultTotal = count;
|
|
1425
|
+
if (lang.code !== defaultLang) {
|
|
1426
|
+
try {
|
|
1427
|
+
let dqp = `/${ep}?per_page=100&status=publish`;
|
|
1428
|
+
if (covLangsResult.plugin === 'wpml') dqp += `&wpml_language=${defaultLang}`;
|
|
1429
|
+
else if (covLangsResult.plugin.startsWith('polylang')) dqp += `&lang=${defaultLang}`;
|
|
1430
|
+
else if (covLangsResult.plugin === 'translatepress') dqp += `&trp_language=${defaultLang}`;
|
|
1431
|
+
const defaultPosts = await wpApiCall(dqp);
|
|
1432
|
+
defaultTotal = Array.isArray(defaultPosts) ? defaultPosts.length : 0;
|
|
1433
|
+
} catch (_e) { defaultTotal = count; }
|
|
1434
|
+
}
|
|
1435
|
+
const pct = defaultTotal > 0 ? Math.round((count / defaultTotal) * 100) : 100;
|
|
1436
|
+
typeCoverage.push({ post_type: pt, total: defaultTotal, translated: count, percentage: pct, missing_count: Math.max(0, defaultTotal - count) });
|
|
1437
|
+
// Collect untranslated for priority list (only from default lang)
|
|
1438
|
+
if (lang.code === defaultLang && min_word_count >= 0) {
|
|
1439
|
+
const postsArr = Array.isArray(posts) ? posts : [];
|
|
1440
|
+
for (const p of postsArr) {
|
|
1441
|
+
const wc = (strip(p.content?.rendered || '').split(/\s+/).length) || 0;
|
|
1442
|
+
if (wc >= min_word_count) untranslatedPosts.push({ post_id: p.id, title: strip(p.title?.rendered || ''), post_type: pt, word_count: wc, url: p.link });
|
|
1443
|
+
}
|
|
1444
|
+
}
|
|
1445
|
+
} catch (_e) { typeCoverage.push({ post_type: pt, total: 0, translated: 0, percentage: 0, missing_count: 0 }); }
|
|
1446
|
+
}
|
|
1447
|
+
const overallPct = typeCoverage.length > 0 ? Math.round(typeCoverage.reduce((s, t) => s + t.percentage, 0) / typeCoverage.length) : 0;
|
|
1448
|
+
coverage_by_language.push({ lang_code: lang.code, lang_name: lang.name, post_type_coverage: typeCoverage, overall_percentage: overallPct });
|
|
1449
|
+
}
|
|
1450
|
+
untranslatedPosts.sort((a, b) => b.word_count - a.word_count);
|
|
1451
|
+
result = json({ coverage_by_language, priority_untranslated: untranslatedPosts.slice(0, 10), ga4_note: 'Connect GA4 for traffic-based prioritization' });
|
|
1452
|
+
auditLog({ tool: name, action: 'audit_translation_coverage', status: 'success', latency_ms: Date.now() - t0, params: { post_types: covTypes } });
|
|
1453
|
+
return result;
|
|
1454
|
+
};
|
|
1455
|
+
handlers['wp_find_missing_seo_translations'] = async (args) => {
|
|
1456
|
+
const t0 = Date.now();
|
|
1457
|
+
let result;
|
|
1458
|
+
const { wpApiCall, auditLog, name, handleToolCall } = rt;
|
|
1459
|
+
const { source_lang: fmsLang, post_types: fmsTypes = ['post', 'page'], fields_to_check: fmsFields = ['title', 'description', 'og_title', 'og_description'], limit: fmsLimit = 50 } = args;
|
|
1460
|
+
const fmsDet = JSON.parse((await handleToolCall({ params: { name: 'wp_detect_multilingual_plugin', arguments: {} } })).content[0].text);
|
|
1461
|
+
const fmsSrcLang = fmsLang || fmsDet.default_language;
|
|
1462
|
+
if (!fmsDet.plugin) {
|
|
1463
|
+
result = json({ multilingual: false, message: 'No multilingual plugin detected.' });
|
|
1464
|
+
auditLog({ tool: name, action: 'find_missing_seo_translations', status: 'success', latency_ms: Date.now() - t0 });
|
|
1465
|
+
return result;
|
|
1466
|
+
}
|
|
1467
|
+
const fmsResults = [];
|
|
1468
|
+
for (const pt of fmsTypes) {
|
|
1469
|
+
const ep = pt === 'page' ? 'pages' : 'posts';
|
|
1470
|
+
let qp = `/${ep}?per_page=${Math.min(fmsLimit, 100)}&status=publish`;
|
|
1471
|
+
if (fmsDet.plugin === 'wpml') qp += `&wpml_language=${fmsSrcLang}`;
|
|
1472
|
+
else if (fmsDet.plugin.startsWith('polylang')) qp += `&lang=${fmsSrcLang}`;
|
|
1473
|
+
let srcPosts = [];
|
|
1474
|
+
try { srcPosts = await wpApiCall(qp); } catch (_e) { continue; }
|
|
1475
|
+
if (!Array.isArray(srcPosts)) continue;
|
|
1476
|
+
for (const sp of srcPosts.slice(0, fmsLimit)) {
|
|
1477
|
+
const meta = sp.meta || {};
|
|
1478
|
+
const yoastHead = sp.yoast_head_json || null;
|
|
1479
|
+
// Detect SEO plugin and extract source values
|
|
1480
|
+
const srcSeo = {};
|
|
1481
|
+
if (meta._yoast_wpseo_title || meta._yoast_wpseo_metadesc || yoastHead) {
|
|
1482
|
+
srcSeo.title = meta._yoast_wpseo_title || yoastHead?.title || null;
|
|
1483
|
+
srcSeo.description = meta._yoast_wpseo_metadesc || yoastHead?.description || null;
|
|
1484
|
+
srcSeo.og_title = yoastHead?.og_title || meta._yoast_wpseo_opengraph_title || null;
|
|
1485
|
+
srcSeo.og_description = yoastHead?.og_description || meta._yoast_wpseo_opengraph_description || null;
|
|
1486
|
+
} else if (meta.rank_math_title || meta.rank_math_description) {
|
|
1487
|
+
srcSeo.title = meta.rank_math_title || null;
|
|
1488
|
+
srcSeo.description = meta.rank_math_description || null;
|
|
1489
|
+
srcSeo.og_title = meta.rank_math_facebook_title || null;
|
|
1490
|
+
srcSeo.og_description = meta.rank_math_facebook_description || null;
|
|
1491
|
+
} else if (meta._seopress_titles_title || meta._seopress_titles_desc) {
|
|
1492
|
+
srcSeo.title = meta._seopress_titles_title || null;
|
|
1493
|
+
srcSeo.description = meta._seopress_titles_desc || null;
|
|
1494
|
+
srcSeo.og_title = meta._seopress_social_fb_title || null;
|
|
1495
|
+
srcSeo.og_description = meta._seopress_social_fb_desc || null;
|
|
1496
|
+
}
|
|
1497
|
+
// Skip posts with no SEO meta at all
|
|
1498
|
+
const hasSeo = fmsFields.some(f => srcSeo[f]);
|
|
1499
|
+
if (!hasSeo) continue;
|
|
1500
|
+
// Get translations
|
|
1501
|
+
const trResult = JSON.parse((await handleToolCall({ params: { name: 'wp_get_post_translations', arguments: { post_id: sp.id, post_type: pt } } })).content[0].text);
|
|
1502
|
+
for (const [tLang, tr] of Object.entries(trResult.translations)) {
|
|
1503
|
+
if (!tr.post_id) continue;
|
|
1504
|
+
try {
|
|
1505
|
+
const trPost = await wpApiCall(`/${ep}/${tr.post_id}`);
|
|
1506
|
+
const trMeta = trPost.meta || {};
|
|
1507
|
+
const trYoast = trPost.yoast_head_json || null;
|
|
1508
|
+
const trSeo = {};
|
|
1509
|
+
if (trMeta._yoast_wpseo_title || trMeta._yoast_wpseo_metadesc || trYoast) {
|
|
1510
|
+
trSeo.title = trMeta._yoast_wpseo_title || trYoast?.title || null;
|
|
1511
|
+
trSeo.description = trMeta._yoast_wpseo_metadesc || trYoast?.description || null;
|
|
1512
|
+
trSeo.og_title = trYoast?.og_title || trMeta._yoast_wpseo_opengraph_title || null;
|
|
1513
|
+
trSeo.og_description = trYoast?.og_description || trMeta._yoast_wpseo_opengraph_description || null;
|
|
1514
|
+
} else if (trMeta.rank_math_title || trMeta.rank_math_description) {
|
|
1515
|
+
trSeo.title = trMeta.rank_math_title || null;
|
|
1516
|
+
trSeo.description = trMeta.rank_math_description || null;
|
|
1517
|
+
trSeo.og_title = trMeta.rank_math_facebook_title || null;
|
|
1518
|
+
trSeo.og_description = trMeta.rank_math_facebook_description || null;
|
|
1519
|
+
} else if (trMeta._seopress_titles_title || trMeta._seopress_titles_desc) {
|
|
1520
|
+
trSeo.title = trMeta._seopress_titles_title || null;
|
|
1521
|
+
trSeo.description = trMeta._seopress_titles_desc || null;
|
|
1522
|
+
trSeo.og_title = trMeta._seopress_social_fb_title || null;
|
|
1523
|
+
trSeo.og_description = trMeta._seopress_social_fb_desc || null;
|
|
1524
|
+
}
|
|
1525
|
+
const missing = fmsFields.filter(f => srcSeo[f] && !trSeo[f]);
|
|
1526
|
+
if (missing.length > 0) {
|
|
1527
|
+
fmsResults.push({ source_post_id: sp.id, source_title: strip(sp.title?.rendered || ''), source_lang: fmsSrcLang, translation_post_id: tr.post_id, translation_lang: tLang, translation_title: strip(trPost.title?.rendered || ''), missing_fields: missing, source_values: Object.fromEntries(missing.map(f => [f, srcSeo[f]])) });
|
|
1528
|
+
}
|
|
1529
|
+
} catch (_e) { /* translation post not accessible */ }
|
|
1530
|
+
}
|
|
1531
|
+
}
|
|
1532
|
+
}
|
|
1533
|
+
result = json({ total_missing: fmsResults.length, results: fmsResults });
|
|
1534
|
+
auditLog({ tool: name, action: 'find_missing_seo_translations', status: 'success', latency_ms: Date.now() - t0, params: { source_lang: fmsSrcLang } });
|
|
1535
|
+
return result;
|
|
1536
|
+
};
|
|
1537
|
+
handlers['wp_sync_seo_meta_translations'] = async (args) => {
|
|
1538
|
+
const t0 = Date.now();
|
|
1539
|
+
let result;
|
|
1540
|
+
const { wpApiCall, getActiveControls, targets, auditLog, name, handleToolCall } = rt;
|
|
1541
|
+
validateInput(args, { post_id: { type: 'number', required: true, min: 1 }, source_lang: { type: 'string', required: true }, target_langs: { type: 'array', required: true } });
|
|
1542
|
+
const { post_id: syncPostId, source_lang: syncSrcLang, target_langs: syncTargets, fields: syncFieldsArg = ['all'], dry_run: syncDryRun = true, overwrite_existing: syncOverwrite = false } = args;
|
|
1543
|
+
// Enforce read-only UNLESS dry_run
|
|
1544
|
+
if (!syncDryRun && getActiveControls().read_only) {
|
|
1545
|
+
throw new Error('Blocked: Server is in READ-ONLY mode (WP_READ_ONLY=true). Tool "wp_sync_seo_meta_translations" is not allowed. Use dry_run=true to preview.');
|
|
1546
|
+
}
|
|
1547
|
+
const allSeoFields = ['title', 'description', 'og_title', 'og_description', 'og_image', 'twitter_title', 'twitter_description'];
|
|
1548
|
+
const syncFields = syncFieldsArg.includes('all') ? allSeoFields : syncFieldsArg;
|
|
1549
|
+
// Get source post SEO meta
|
|
1550
|
+
const srcPost = await wpApiCall(`/posts/${syncPostId}`);
|
|
1551
|
+
const srcMeta = srcPost.meta || {};
|
|
1552
|
+
const srcYoast = srcPost.yoast_head_json || null;
|
|
1553
|
+
const srcSeo = {};
|
|
1554
|
+
let seoPlugin = 'none';
|
|
1555
|
+
if (srcMeta._yoast_wpseo_title || srcMeta._yoast_wpseo_metadesc || srcYoast) {
|
|
1556
|
+
seoPlugin = 'yoast';
|
|
1557
|
+
srcSeo.title = srcMeta._yoast_wpseo_title || srcYoast?.title || null;
|
|
1558
|
+
srcSeo.description = srcMeta._yoast_wpseo_metadesc || srcYoast?.description || null;
|
|
1559
|
+
srcSeo.og_title = srcYoast?.og_title || null;
|
|
1560
|
+
srcSeo.og_description = srcYoast?.og_description || null;
|
|
1561
|
+
srcSeo.og_image = srcYoast?.og_image?.[0]?.url || null;
|
|
1562
|
+
srcSeo.twitter_title = srcYoast?.twitter_misc?.title || srcMeta._yoast_wpseo_twitter_title || null;
|
|
1563
|
+
srcSeo.twitter_description = srcYoast?.twitter_misc?.description || srcMeta._yoast_wpseo_twitter_description || null;
|
|
1564
|
+
} else if (srcMeta.rank_math_title || srcMeta.rank_math_description) {
|
|
1565
|
+
seoPlugin = 'rankmath';
|
|
1566
|
+
srcSeo.title = srcMeta.rank_math_title || null;
|
|
1567
|
+
srcSeo.description = srcMeta.rank_math_description || null;
|
|
1568
|
+
srcSeo.og_title = srcMeta.rank_math_facebook_title || null;
|
|
1569
|
+
srcSeo.og_description = srcMeta.rank_math_facebook_description || null;
|
|
1570
|
+
srcSeo.og_image = srcMeta.rank_math_facebook_image || null;
|
|
1571
|
+
srcSeo.twitter_title = srcMeta.rank_math_twitter_title || null;
|
|
1572
|
+
srcSeo.twitter_description = srcMeta.rank_math_twitter_description || null;
|
|
1573
|
+
} else if (srcMeta._seopress_titles_title || srcMeta._seopress_titles_desc) {
|
|
1574
|
+
seoPlugin = 'seopress';
|
|
1575
|
+
srcSeo.title = srcMeta._seopress_titles_title || null;
|
|
1576
|
+
srcSeo.description = srcMeta._seopress_titles_desc || null;
|
|
1577
|
+
srcSeo.og_title = srcMeta._seopress_social_fb_title || null;
|
|
1578
|
+
srcSeo.og_description = srcMeta._seopress_social_fb_desc || null;
|
|
1579
|
+
srcSeo.og_image = srcMeta._seopress_social_fb_img || null;
|
|
1580
|
+
srcSeo.twitter_title = srcMeta._seopress_social_twitter_title || null;
|
|
1581
|
+
srcSeo.twitter_description = srcMeta._seopress_social_twitter_desc || null;
|
|
1582
|
+
}
|
|
1583
|
+
// Get translations
|
|
1584
|
+
const syncTrResult = JSON.parse((await handleToolCall({ params: { name: 'wp_get_post_translations', arguments: { post_id: syncPostId } } })).content[0].text);
|
|
1585
|
+
const syncTargetResults = [];
|
|
1586
|
+
// Map SEO field names to plugin-specific meta keys
|
|
1587
|
+
const metaKeyMap = {
|
|
1588
|
+
yoast: { title: '_yoast_wpseo_title', description: '_yoast_wpseo_metadesc', og_title: '_yoast_wpseo_opengraph_title', og_description: '_yoast_wpseo_opengraph_description', og_image: '_yoast_wpseo_opengraph_image', twitter_title: '_yoast_wpseo_twitter_title', twitter_description: '_yoast_wpseo_twitter_description' },
|
|
1589
|
+
rankmath: { title: 'rank_math_title', description: 'rank_math_description', og_title: 'rank_math_facebook_title', og_description: 'rank_math_facebook_description', og_image: 'rank_math_facebook_image', twitter_title: 'rank_math_twitter_title', twitter_description: 'rank_math_twitter_description' },
|
|
1590
|
+
seopress: { title: '_seopress_titles_title', description: '_seopress_titles_desc', og_title: '_seopress_social_fb_title', og_description: '_seopress_social_fb_desc', og_image: '_seopress_social_fb_img', twitter_title: '_seopress_social_twitter_title', twitter_description: '_seopress_social_twitter_desc' },
|
|
1591
|
+
};
|
|
1592
|
+
for (const tLang of syncTargets) {
|
|
1593
|
+
const tr = syncTrResult.translations[tLang];
|
|
1594
|
+
if (!tr || !tr.post_id) {
|
|
1595
|
+
syncTargetResults.push({ lang: tLang, post_id: null, fields_synced: [], fields_skipped: [], status: 'error', error: 'No translation found for this language' });
|
|
1596
|
+
continue;
|
|
1597
|
+
}
|
|
1598
|
+
// Get existing target meta
|
|
1599
|
+
let trPost;
|
|
1600
|
+
try { trPost = await wpApiCall(`/posts/${tr.post_id}`); } catch (_e) {
|
|
1601
|
+
syncTargetResults.push({ lang: tLang, post_id: tr.post_id, fields_synced: [], fields_skipped: [], status: 'error', error: 'Could not read target post' });
|
|
1602
|
+
continue;
|
|
1603
|
+
}
|
|
1604
|
+
const trMeta = trPost.meta || {};
|
|
1605
|
+
const fieldsSynced = [];
|
|
1606
|
+
const fieldsSkipped = [];
|
|
1607
|
+
const metaUpdate = {};
|
|
1608
|
+
const keyMap = metaKeyMap[seoPlugin] || {};
|
|
1609
|
+
for (const field of syncFields) {
|
|
1610
|
+
if (!srcSeo[field]) { fieldsSkipped.push(field); continue; }
|
|
1611
|
+
const metaKey = keyMap[field];
|
|
1612
|
+
if (!metaKey) { fieldsSkipped.push(field); continue; }
|
|
1613
|
+
if (!syncOverwrite && trMeta[metaKey]) { fieldsSkipped.push(field); continue; }
|
|
1614
|
+
metaUpdate[metaKey] = srcSeo[field];
|
|
1615
|
+
fieldsSynced.push(field);
|
|
1616
|
+
}
|
|
1617
|
+
if (syncDryRun) {
|
|
1618
|
+
syncTargetResults.push({ lang: tLang, post_id: tr.post_id, fields_synced: fieldsSynced, fields_skipped: fieldsSkipped, status: 'preview' });
|
|
1619
|
+
} else if (Object.keys(metaUpdate).length > 0) {
|
|
1620
|
+
try {
|
|
1621
|
+
await wpApiCall(`/posts/${tr.post_id}`, { method: 'POST', body: JSON.stringify({ meta: metaUpdate }) });
|
|
1622
|
+
syncTargetResults.push({ lang: tLang, post_id: tr.post_id, fields_synced: fieldsSynced, fields_skipped: fieldsSkipped, status: 'synced' });
|
|
1623
|
+
} catch (_e) {
|
|
1624
|
+
syncTargetResults.push({ lang: tLang, post_id: tr.post_id, fields_synced: [], fields_skipped: syncFields, status: 'error', error: _e.message });
|
|
1625
|
+
}
|
|
1626
|
+
} else {
|
|
1627
|
+
syncTargetResults.push({ lang: tLang, post_id: tr.post_id, fields_synced: [], fields_skipped: fieldsSkipped, status: 'skipped' });
|
|
1628
|
+
}
|
|
1629
|
+
}
|
|
1630
|
+
result = json({ source: { post_id: syncPostId, lang: syncSrcLang, seo_plugin: seoPlugin, fields_available: Object.keys(srcSeo).filter(k => srcSeo[k]) }, targets: syncTargetResults, dry_run: syncDryRun });
|
|
1631
|
+
auditLog({ tool: name, action: 'sync_seo_meta_translations', status: 'success', latency_ms: Date.now() - t0, params: { post_id: syncPostId, target_langs: syncTargets, dry_run: syncDryRun } });
|
|
1632
|
+
return result;
|
|
1633
|
+
};
|