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