@adsim/wordpress-mcp-server 3.1.0 → 4.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +543 -176
- package/dxt/manifest.json +86 -9
- package/index.js +3156 -36
- package/package.json +1 -1
- package/src/confirmationToken.js +64 -0
- package/src/contentAnalyzer.js +476 -0
- package/src/htmlParser.js +80 -0
- package/src/linkUtils.js +158 -0
- package/src/utils/contentCompressor.js +116 -0
- package/src/woocommerceClient.js +88 -0
- package/tests/unit/contentAnalyzer.test.js +397 -0
- package/tests/unit/tools/analyzeEeatSignals.test.js +192 -0
- package/tests/unit/tools/approval.test.js +251 -0
- package/tests/unit/tools/auditCanonicals.test.js +149 -0
- package/tests/unit/tools/auditHeadingStructure.test.js +150 -0
- package/tests/unit/tools/auditMediaSeo.test.js +123 -0
- package/tests/unit/tools/auditOutboundLinks.test.js +175 -0
- package/tests/unit/tools/auditTaxonomies.test.js +173 -0
- package/tests/unit/tools/contentCompressor.test.js +320 -0
- package/tests/unit/tools/contentIntelligence.test.js +2168 -0
- package/tests/unit/tools/destructive.test.js +246 -0
- package/tests/unit/tools/findBrokenInternalLinks.test.js +222 -0
- package/tests/unit/tools/findKeywordCannibalization.test.js +183 -0
- package/tests/unit/tools/findOrphanPages.test.js +145 -0
- package/tests/unit/tools/findThinContent.test.js +145 -0
- package/tests/unit/tools/internalLinks.test.js +283 -0
- package/tests/unit/tools/perTargetControls.test.js +228 -0
- package/tests/unit/tools/site.test.js +6 -1
- package/tests/unit/tools/woocommerce.test.js +344 -0
- package/tests/unit/tools/woocommerceIntelligence.test.js +341 -0
- package/tests/unit/tools/woocommerceWrite.test.js +323 -0
|
@@ -0,0 +1,397 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import {
|
|
3
|
+
calculateReadabilityScore,
|
|
4
|
+
countSyllablesFr,
|
|
5
|
+
extractTransitionWords,
|
|
6
|
+
countPassiveSentences,
|
|
7
|
+
extractHeadingsOutline,
|
|
8
|
+
detectContentSections
|
|
9
|
+
} from '../../src/contentAnalyzer.js';
|
|
10
|
+
|
|
11
|
+
// =========================================================================
|
|
12
|
+
// countSyllablesFr
|
|
13
|
+
// =========================================================================
|
|
14
|
+
|
|
15
|
+
describe('countSyllablesFr', () => {
|
|
16
|
+
it('counts syllables for common French words', () => {
|
|
17
|
+
expect(countSyllablesFr('maison')).toBe(2);
|
|
18
|
+
expect(countSyllablesFr('a')).toBe(1);
|
|
19
|
+
expect(countSyllablesFr('le')).toBe(1);
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
it('handles accented vowels', () => {
|
|
23
|
+
expect(countSyllablesFr('éléphant')).toBe(3);
|
|
24
|
+
expect(countSyllablesFr('château')).toBe(2);
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it('returns 1 for single-syllable words', () => {
|
|
28
|
+
expect(countSyllablesFr('chat')).toBe(1);
|
|
29
|
+
expect(countSyllablesFr('bras')).toBe(1);
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it('returns 0 for empty/null input', () => {
|
|
33
|
+
expect(countSyllablesFr('')).toBe(0);
|
|
34
|
+
expect(countSyllablesFr(null)).toBe(0);
|
|
35
|
+
expect(countSyllablesFr(undefined)).toBe(0);
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it('minimum 1 syllable for any non-empty word', () => {
|
|
39
|
+
expect(countSyllablesFr('x')).toBe(1);
|
|
40
|
+
expect(countSyllablesFr('bcd')).toBe(1);
|
|
41
|
+
});
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
// =========================================================================
|
|
45
|
+
// calculateReadabilityScore
|
|
46
|
+
// =========================================================================
|
|
47
|
+
|
|
48
|
+
describe('calculateReadabilityScore', () => {
|
|
49
|
+
it('returns high score for simple short sentences', () => {
|
|
50
|
+
const html = '<p>Le chat est petit. Il dort bien. Il mange.</p>';
|
|
51
|
+
const result = calculateReadabilityScore(html);
|
|
52
|
+
expect(result.score).toBeGreaterThan(60);
|
|
53
|
+
expect(result.level).toMatch(/facile/);
|
|
54
|
+
expect(result.words).toBeGreaterThan(0);
|
|
55
|
+
expect(result.sentences).toBeGreaterThan(0);
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it('returns lower score for complex long sentences', () => {
|
|
59
|
+
const complex = '<p>La problématique fondamentale de la conceptualisation épistémologique contemporaine réside dans la difficulté intrinsèque de réconcilier les paradigmes herméneutiques avec les méthodologies quantitatives traditionnellement utilisées dans les sciences sociales et les humanités numériques.</p>';
|
|
60
|
+
const result = calculateReadabilityScore(complex);
|
|
61
|
+
expect(result.score).toBeLessThan(50);
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it('returns all required fields', () => {
|
|
65
|
+
const result = calculateReadabilityScore('<p>Un texte simple.</p>');
|
|
66
|
+
expect(result).toHaveProperty('score');
|
|
67
|
+
expect(result).toHaveProperty('sentences');
|
|
68
|
+
expect(result).toHaveProperty('words');
|
|
69
|
+
expect(result).toHaveProperty('syllables');
|
|
70
|
+
expect(result).toHaveProperty('avg_words_per_sentence');
|
|
71
|
+
expect(result).toHaveProperty('avg_syllables_per_word');
|
|
72
|
+
expect(result).toHaveProperty('level');
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it('score clamped between 0 and 100', () => {
|
|
76
|
+
const result = calculateReadabilityScore('<p>Mot.</p>');
|
|
77
|
+
expect(result.score).toBeGreaterThanOrEqual(0);
|
|
78
|
+
expect(result.score).toBeLessThanOrEqual(100);
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it('handles empty HTML', () => {
|
|
82
|
+
const result = calculateReadabilityScore('');
|
|
83
|
+
expect(result.words).toBeLessThanOrEqual(1);
|
|
84
|
+
});
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
// =========================================================================
|
|
88
|
+
// extractTransitionWords
|
|
89
|
+
// =========================================================================
|
|
90
|
+
|
|
91
|
+
describe('extractTransitionWords', () => {
|
|
92
|
+
it('finds transition words in French text', () => {
|
|
93
|
+
const text = 'Le produit est bon. Cependant, il est cher. De plus, la livraison est lente. En effet, le délai est trop long.';
|
|
94
|
+
const result = extractTransitionWords(text);
|
|
95
|
+
expect(result.count).toBeGreaterThan(0);
|
|
96
|
+
expect(result.words_found).toContain('cependant');
|
|
97
|
+
expect(result.words_found).toContain('de plus');
|
|
98
|
+
expect(result.words_found).toContain('en effet');
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
it('returns density relative to sentence count', () => {
|
|
102
|
+
const text = 'Premièrement, on analyse. Ensuite, on conclut.';
|
|
103
|
+
const result = extractTransitionWords(text);
|
|
104
|
+
expect(result.density).toBeGreaterThan(0);
|
|
105
|
+
expect(typeof result.density).toBe('number');
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
it('returns empty for text without transitions', () => {
|
|
109
|
+
const text = 'Le chat dort. Il mange. Il joue.';
|
|
110
|
+
const result = extractTransitionWords(text);
|
|
111
|
+
expect(result.count).toBe(0);
|
|
112
|
+
expect(result.words_found).toHaveLength(0);
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
it('returns empty for null/empty input', () => {
|
|
116
|
+
expect(extractTransitionWords('')).toEqual({ count: 0, density: 0, words_found: [] });
|
|
117
|
+
expect(extractTransitionWords(null)).toEqual({ count: 0, density: 0, words_found: [] });
|
|
118
|
+
});
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
// =========================================================================
|
|
122
|
+
// countPassiveSentences
|
|
123
|
+
// =========================================================================
|
|
124
|
+
|
|
125
|
+
describe('countPassiveSentences', () => {
|
|
126
|
+
it('detects French passive voice', () => {
|
|
127
|
+
const text = 'Le rapport est rédigé par le comité. La décision a été prise hier.';
|
|
128
|
+
const result = countPassiveSentences(text);
|
|
129
|
+
expect(result.count).toBeGreaterThan(0);
|
|
130
|
+
expect(result.ratio).toBeGreaterThan(0);
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
it('returns zero for active voice text', () => {
|
|
134
|
+
const text = 'Le chat mange la souris. Le chien court vite.';
|
|
135
|
+
const result = countPassiveSentences(text);
|
|
136
|
+
expect(result.count).toBe(0);
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
it('returns all required fields', () => {
|
|
140
|
+
const result = countPassiveSentences('Une phrase active.');
|
|
141
|
+
expect(result).toHaveProperty('count');
|
|
142
|
+
expect(result).toHaveProperty('total_sentences');
|
|
143
|
+
expect(result).toHaveProperty('ratio');
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
it('handles empty input', () => {
|
|
147
|
+
const result = countPassiveSentences('');
|
|
148
|
+
expect(result.count).toBe(0);
|
|
149
|
+
expect(result.total_sentences).toBe(0);
|
|
150
|
+
});
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
// =========================================================================
|
|
154
|
+
// extractHeadingsOutline
|
|
155
|
+
// =========================================================================
|
|
156
|
+
|
|
157
|
+
describe('extractHeadingsOutline', () => {
|
|
158
|
+
it('extracts H1-H6 headings', () => {
|
|
159
|
+
const html = '<h1>Title</h1><h2>Section A</h2><h3>Sub A.1</h3><h2>Section B</h2>';
|
|
160
|
+
const result = extractHeadingsOutline(html);
|
|
161
|
+
expect(result).toHaveLength(4);
|
|
162
|
+
expect(result[0]).toEqual({ level: 1, text: 'Title' });
|
|
163
|
+
expect(result[1]).toEqual({ level: 2, text: 'Section A' });
|
|
164
|
+
expect(result[2]).toEqual({ level: 3, text: 'Sub A.1' });
|
|
165
|
+
expect(result[3]).toEqual({ level: 2, text: 'Section B' });
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
it('returns empty array for no headings', () => {
|
|
169
|
+
expect(extractHeadingsOutline('<p>Just text</p>')).toEqual([]);
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
it('handles empty/null input', () => {
|
|
173
|
+
expect(extractHeadingsOutline('')).toEqual([]);
|
|
174
|
+
expect(extractHeadingsOutline(null)).toEqual([]);
|
|
175
|
+
});
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
// =========================================================================
|
|
179
|
+
// detectContentSections
|
|
180
|
+
// =========================================================================
|
|
181
|
+
|
|
182
|
+
describe('detectContentSections', () => {
|
|
183
|
+
it('detects intro before first H2', () => {
|
|
184
|
+
const html = '<p>This is a long enough introduction paragraph with enough content.</p><h2>First Section</h2><p>Body.</p>';
|
|
185
|
+
const result = detectContentSections(html);
|
|
186
|
+
expect(result.has_intro).toBe(true);
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
it('detects conclusion heading', () => {
|
|
190
|
+
const html = '<h2>Introduction</h2><p>Text.</p><h2>Conclusion</h2><p>Final.</p>';
|
|
191
|
+
const result = detectContentSections(html);
|
|
192
|
+
expect(result.has_conclusion).toBe(true);
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
it('detects FAQ section', () => {
|
|
196
|
+
const html = '<h2>FAQ</h2><p>Question?</p>';
|
|
197
|
+
const result = detectContentSections(html);
|
|
198
|
+
expect(result.has_faq).toBe(true);
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
it('counts lists, tables, images', () => {
|
|
202
|
+
const html = '<ul><li>A</li></ul><ol><li>B</li></ol><table><tr><td>C</td></tr></table><img src="x.jpg"><img src="y.jpg">';
|
|
203
|
+
const result = detectContentSections(html);
|
|
204
|
+
expect(result.lists_count).toBe(2);
|
|
205
|
+
expect(result.tables_count).toBe(1);
|
|
206
|
+
expect(result.images_count).toBe(2);
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
it('returns correct headings_count', () => {
|
|
210
|
+
const html = '<h2>A</h2><h3>B</h3><h2>C</h2>';
|
|
211
|
+
const result = detectContentSections(html);
|
|
212
|
+
expect(result.headings_count).toBe(3);
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
it('handles empty input', () => {
|
|
216
|
+
const result = detectContentSections('');
|
|
217
|
+
expect(result.has_intro).toBe(false);
|
|
218
|
+
expect(result.has_conclusion).toBe(false);
|
|
219
|
+
expect(result.has_faq).toBe(false);
|
|
220
|
+
expect(result.lists_count).toBe(0);
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
it('no intro when H2 is at the start', () => {
|
|
224
|
+
const html = '<h2>Section</h2><p>Body text here.</p>';
|
|
225
|
+
const result = detectContentSections(html);
|
|
226
|
+
expect(result.has_intro).toBe(false);
|
|
227
|
+
});
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
// =========================================================================
|
|
231
|
+
// TF-IDF: buildTFIDFVectors, computeCosineSimilarity, findDuplicatePairs
|
|
232
|
+
// =========================================================================
|
|
233
|
+
|
|
234
|
+
import {
|
|
235
|
+
buildTFIDFVectors,
|
|
236
|
+
computeCosineSimilarity,
|
|
237
|
+
findDuplicatePairs,
|
|
238
|
+
extractEntities,
|
|
239
|
+
computeTextDiff
|
|
240
|
+
} from '../../src/contentAnalyzer.js';
|
|
241
|
+
|
|
242
|
+
describe('buildTFIDFVectors', () => {
|
|
243
|
+
it('builds vectors with correct terms from 2 documents', () => {
|
|
244
|
+
const docs = [
|
|
245
|
+
{ id: 1, text: 'marketing digital stratégie entreprise marketing' },
|
|
246
|
+
{ id: 2, text: 'cuisine française recette traditionnelle cuisine' }
|
|
247
|
+
];
|
|
248
|
+
const { vectors, terms } = buildTFIDFVectors(docs);
|
|
249
|
+
expect(vectors.size).toBe(2);
|
|
250
|
+
expect(terms.size).toBeGreaterThan(0);
|
|
251
|
+
expect(vectors.get(1).has('marketing')).toBe(true);
|
|
252
|
+
expect(vectors.get(2).has('cuisine')).toBe(true);
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
it('filters French stop words', () => {
|
|
256
|
+
const docs = [{ id: 1, text: 'les des une pour dans par sur avec marketing' }];
|
|
257
|
+
const { vectors } = buildTFIDFVectors(docs);
|
|
258
|
+
const vec = vectors.get(1);
|
|
259
|
+
expect(vec.has('les')).toBe(false);
|
|
260
|
+
expect(vec.has('des')).toBe(false);
|
|
261
|
+
expect(vec.has('marketing')).toBe(true);
|
|
262
|
+
});
|
|
263
|
+
|
|
264
|
+
it('filters tokens shorter than 3 characters', () => {
|
|
265
|
+
const docs = [{ id: 1, text: 'le un marketing de la stratégie' }];
|
|
266
|
+
const { vectors } = buildTFIDFVectors(docs);
|
|
267
|
+
const vec = vectors.get(1);
|
|
268
|
+
expect(vec.has('le')).toBe(false);
|
|
269
|
+
expect(vec.has('un')).toBe(false);
|
|
270
|
+
expect(vec.has('marketing')).toBe(true);
|
|
271
|
+
});
|
|
272
|
+
});
|
|
273
|
+
|
|
274
|
+
describe('computeCosineSimilarity', () => {
|
|
275
|
+
it('returns 1.0 for identical vectors', () => {
|
|
276
|
+
const vec = new Map([['marketing', 0.5], ['digital', 0.3]]);
|
|
277
|
+
const sim = computeCosineSimilarity(vec, vec);
|
|
278
|
+
expect(sim).toBeCloseTo(1.0, 5);
|
|
279
|
+
});
|
|
280
|
+
|
|
281
|
+
it('returns 0.0 for orthogonal vectors', () => {
|
|
282
|
+
const vec1 = new Map([['marketing', 0.5]]);
|
|
283
|
+
const vec2 = new Map([['cuisine', 0.5]]);
|
|
284
|
+
const sim = computeCosineSimilarity(vec1, vec2);
|
|
285
|
+
expect(sim).toBe(0);
|
|
286
|
+
});
|
|
287
|
+
|
|
288
|
+
it('returns 0.0 for empty vector', () => {
|
|
289
|
+
const vec1 = new Map();
|
|
290
|
+
const vec2 = new Map([['marketing', 0.5]]);
|
|
291
|
+
expect(computeCosineSimilarity(vec1, vec2)).toBe(0);
|
|
292
|
+
expect(computeCosineSimilarity(null, vec2)).toBe(0);
|
|
293
|
+
});
|
|
294
|
+
});
|
|
295
|
+
|
|
296
|
+
describe('findDuplicatePairs', () => {
|
|
297
|
+
it('finds pair when two texts are similar', () => {
|
|
298
|
+
const docs = [
|
|
299
|
+
{ id: 1, title: 'A', text: 'marketing digital stratégie entreprise contenu référencement naturel visibilité clients campagne publicitaire conversion' },
|
|
300
|
+
{ id: 2, title: 'B', text: 'marketing digital stratégie société contenu référencement naturel visibilité clients campagne publicitaire résultats' },
|
|
301
|
+
{ id: 3, title: 'C', text: 'cuisine française recette traditionnelle plats gastronomie ingrédients frais terroir culinaire desserts pâtisserie' }
|
|
302
|
+
];
|
|
303
|
+
const pairs = findDuplicatePairs(docs, 0.5);
|
|
304
|
+
expect(pairs.length).toBeGreaterThanOrEqual(1);
|
|
305
|
+
const hasPair12 = pairs.some(p => (p.doc1_id === 1 && p.doc2_id === 2) || (p.doc1_id === 2 && p.doc2_id === 1));
|
|
306
|
+
expect(hasPair12).toBe(true);
|
|
307
|
+
});
|
|
308
|
+
|
|
309
|
+
it('returns 0 pairs for completely different texts', () => {
|
|
310
|
+
const docs = [
|
|
311
|
+
{ id: 1, title: 'A', text: 'marketing digital stratégie entreprise contenu référencement' },
|
|
312
|
+
{ id: 2, title: 'B', text: 'cuisine française recette traditionnelle plats gastronomie' }
|
|
313
|
+
];
|
|
314
|
+
const pairs = findDuplicatePairs(docs, 0.7);
|
|
315
|
+
expect(pairs).toHaveLength(0);
|
|
316
|
+
});
|
|
317
|
+
});
|
|
318
|
+
|
|
319
|
+
// =========================================================================
|
|
320
|
+
// extractEntities
|
|
321
|
+
// =========================================================================
|
|
322
|
+
|
|
323
|
+
describe('extractEntities', () => {
|
|
324
|
+
it('detects named entities from text', () => {
|
|
325
|
+
const text = 'Le site utilise Google Analytics pour le suivi. À Bruxelles, Marie Martin gère le projet.';
|
|
326
|
+
const entities = extractEntities(text);
|
|
327
|
+
expect(entities.length).toBeGreaterThan(0);
|
|
328
|
+
const names = entities.map(e => e.name);
|
|
329
|
+
expect(names).toContain('Google Analytics');
|
|
330
|
+
});
|
|
331
|
+
|
|
332
|
+
it('filters sentence-start common words from exclusion list', () => {
|
|
333
|
+
const text = 'Le chat dort. Cette maison est belle. Pour les clients, WordPress est important.';
|
|
334
|
+
const entities = extractEntities(text);
|
|
335
|
+
const names = entities.map(e => e.name);
|
|
336
|
+
expect(names).not.toContain('Le');
|
|
337
|
+
expect(names).not.toContain('Cette');
|
|
338
|
+
expect(names).not.toContain('Pour');
|
|
339
|
+
});
|
|
340
|
+
|
|
341
|
+
it('groups consecutive capitalized words', () => {
|
|
342
|
+
const text = 'Nous utilisons Google Cloud Platform pour héberger le site.';
|
|
343
|
+
const entities = extractEntities(text);
|
|
344
|
+
const names = entities.map(e => e.name);
|
|
345
|
+
expect(names).toContain('Google Cloud Platform');
|
|
346
|
+
});
|
|
347
|
+
|
|
348
|
+
it('classifies brand, location, person correctly', () => {
|
|
349
|
+
const text = 'Le site WordPress est hébergé en Belgique. Marie Martin gère le contenu.';
|
|
350
|
+
const entities = extractEntities(text);
|
|
351
|
+
const wp = entities.find(e => e.name === 'WordPress');
|
|
352
|
+
expect(wp).toBeDefined();
|
|
353
|
+
expect(wp.type).toBe('brand');
|
|
354
|
+
const be = entities.find(e => e.name === 'Belgique');
|
|
355
|
+
expect(be).toBeDefined();
|
|
356
|
+
expect(be.type).toBe('location');
|
|
357
|
+
const mm = entities.find(e => e.name === 'Marie Martin');
|
|
358
|
+
expect(mm).toBeDefined();
|
|
359
|
+
expect(mm.type).toBe('person');
|
|
360
|
+
});
|
|
361
|
+
|
|
362
|
+
it('returns empty array for text without entities', () => {
|
|
363
|
+
const text = 'le chat dort sur le canapé.';
|
|
364
|
+
const entities = extractEntities(text);
|
|
365
|
+
expect(entities).toHaveLength(0);
|
|
366
|
+
});
|
|
367
|
+
});
|
|
368
|
+
|
|
369
|
+
// =========================================================================
|
|
370
|
+
// computeTextDiff
|
|
371
|
+
// =========================================================================
|
|
372
|
+
|
|
373
|
+
describe('computeTextDiff', () => {
|
|
374
|
+
it('returns zero changes for identical texts', () => {
|
|
375
|
+
const text = 'Ligne un\nLigne deux\nLigne trois';
|
|
376
|
+
const diff = computeTextDiff(text, text);
|
|
377
|
+
expect(diff.change_ratio).toBe(0);
|
|
378
|
+
expect(diff.lines_added).toBe(0);
|
|
379
|
+
expect(diff.lines_removed).toBe(0);
|
|
380
|
+
});
|
|
381
|
+
|
|
382
|
+
it('returns high change_ratio for completely different texts', () => {
|
|
383
|
+
const a = 'Première ligne\nDeuxième ligne';
|
|
384
|
+
const b = 'Tout autre contenu\nRien en commun';
|
|
385
|
+
const diff = computeTextDiff(a, b);
|
|
386
|
+
expect(diff.change_ratio).toBeGreaterThan(0.9);
|
|
387
|
+
});
|
|
388
|
+
|
|
389
|
+
it('detects added lines correctly', () => {
|
|
390
|
+
const a = 'Ligne un\nLigne deux';
|
|
391
|
+
const b = 'Ligne un\nLigne deux\nLigne trois\nLigne quatre';
|
|
392
|
+
const diff = computeTextDiff(a, b);
|
|
393
|
+
expect(diff.lines_added).toBe(2);
|
|
394
|
+
expect(diff.lines_removed).toBe(0);
|
|
395
|
+
expect(diff.lines_unchanged).toBe(2);
|
|
396
|
+
});
|
|
397
|
+
});
|
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
import { vi, describe, it, expect, beforeEach, afterEach } from 'vitest';
|
|
2
|
+
|
|
3
|
+
vi.mock('node-fetch', () => ({ default: vi.fn() }));
|
|
4
|
+
|
|
5
|
+
import fetch from 'node-fetch';
|
|
6
|
+
import { handleToolCall, _testSetTarget } from '../../../index.js';
|
|
7
|
+
import { mockSuccess, mockError, getAuditLogs, makeRequest, parseResult } from '../../helpers/mockWpRequest.js';
|
|
8
|
+
|
|
9
|
+
function call(name, args = {}) {
|
|
10
|
+
return handleToolCall(makeRequest(name, args));
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
let consoleSpy;
|
|
14
|
+
beforeEach(() => { fetch.mockReset(); consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); });
|
|
15
|
+
afterEach(() => { consoleSpy.mockRestore(); _testSetTarget(null); });
|
|
16
|
+
|
|
17
|
+
// =========================================================================
|
|
18
|
+
// Helpers
|
|
19
|
+
// =========================================================================
|
|
20
|
+
|
|
21
|
+
const recentDate = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000).toISOString();
|
|
22
|
+
const oldDate = new Date(Date.now() - 400 * 24 * 60 * 60 * 1000).toISOString();
|
|
23
|
+
|
|
24
|
+
function makeAuthor(id, { bio = '', avatarUrls = {} } = {}) {
|
|
25
|
+
return { id, name: `Author ${id}`, slug: `author-${id}`, description: bio, avatar_urls: avatarUrls };
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function makePostWithContent(id, content, { author = 1, modified = recentDate } = {}) {
|
|
29
|
+
return {
|
|
30
|
+
id, title: { rendered: `Post ${id}` }, link: `https://test.example.com/post-${id}/`,
|
|
31
|
+
content: { rendered: content }, author, date: modified, modified, meta: {}
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// Perfect post: all E-E-A-T signals present (must exceed 800 words for content_word_count_expert)
|
|
36
|
+
const fillerSentences = Array(160).fill('This detailed analysis covers important aspects of the topic thoroughly.').join(' ');
|
|
37
|
+
const perfectContent = `
|
|
38
|
+
<p>I've been working on this project for years and my experience with client projects has taught me a lot.</p>
|
|
39
|
+
<p>According to a recent study, 75% of websites need improvement. Source: research shows this clearly.</p>
|
|
40
|
+
<p>Visit <a href="https://other-site.com/page1">External 1</a> and <a href="https://other-site.com/page2">External 2</a>
|
|
41
|
+
and <a href="https://wikipedia.org/wiki/SEO">Wikipedia on SEO</a>.</p>
|
|
42
|
+
<p>John Smith from Google confirmed these findings in his rapport.</p>
|
|
43
|
+
<p>${fillerSentences}</p>
|
|
44
|
+
<script type="application/ld+json">{"@type":"Article"}</script>
|
|
45
|
+
`;
|
|
46
|
+
|
|
47
|
+
const emptyContent = '<p>Short.</p>';
|
|
48
|
+
|
|
49
|
+
// =========================================================================
|
|
50
|
+
// wp_analyze_eeat_signals
|
|
51
|
+
// =========================================================================
|
|
52
|
+
|
|
53
|
+
describe('wp_analyze_eeat_signals', () => {
|
|
54
|
+
it('SUCCESS — perfect score (all signals present)', async () => {
|
|
55
|
+
_testSetTarget('test', { url: 'https://test.example.com', username: 'u', password: 'p' });
|
|
56
|
+
// 1. Fetch posts
|
|
57
|
+
mockSuccess([makePostWithContent(1, perfectContent)]);
|
|
58
|
+
// 2. Fetch author
|
|
59
|
+
mockSuccess(makeAuthor(1, { bio: 'Expert in SEO with 10 years of experience', avatarUrls: { '96': 'https://test.example.com/avatar.jpg' } }));
|
|
60
|
+
|
|
61
|
+
const res = await call('wp_analyze_eeat_signals');
|
|
62
|
+
const data = parseResult(res);
|
|
63
|
+
|
|
64
|
+
expect(data.analyses[0].scores.total).toBe(100);
|
|
65
|
+
expect(data.analyses[0].scores.experience).toBe(25);
|
|
66
|
+
expect(data.analyses[0].scores.expertise).toBe(25);
|
|
67
|
+
expect(data.analyses[0].scores.authoritativeness).toBe(25);
|
|
68
|
+
expect(data.analyses[0].scores.trustworthiness).toBe(25);
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it('SUCCESS — zero score (no signals)', async () => {
|
|
72
|
+
_testSetTarget('test', { url: 'https://test.example.com', username: 'u', password: 'p' });
|
|
73
|
+
mockSuccess([makePostWithContent(1, emptyContent, { modified: oldDate })]);
|
|
74
|
+
mockSuccess(makeAuthor(1));
|
|
75
|
+
|
|
76
|
+
const res = await call('wp_analyze_eeat_signals');
|
|
77
|
+
const data = parseResult(res);
|
|
78
|
+
|
|
79
|
+
expect(data.analyses[0].scores.total).toBe(0);
|
|
80
|
+
expect(data.analyses[0].signals_present).toHaveLength(0);
|
|
81
|
+
expect(data.analyses[0].signals_missing.length).toBeGreaterThan(0);
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it('SIGNAL — author_has_bio detected', async () => {
|
|
85
|
+
_testSetTarget('test', { url: 'https://test.example.com', username: 'u', password: 'p' });
|
|
86
|
+
mockSuccess([makePostWithContent(1, emptyContent)]);
|
|
87
|
+
mockSuccess(makeAuthor(1, { bio: 'I am an expert in WordPress development.' }));
|
|
88
|
+
|
|
89
|
+
const res = await call('wp_analyze_eeat_signals');
|
|
90
|
+
const data = parseResult(res);
|
|
91
|
+
|
|
92
|
+
expect(data.analyses[0].signals_present).toContain('author_has_bio');
|
|
93
|
+
expect(data.analyses[0].scores.experience).toBeGreaterThanOrEqual(10);
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
it('SIGNAL — has_authoritative_sources detected', async () => {
|
|
97
|
+
_testSetTarget('test', { url: 'https://test.example.com', username: 'u', password: 'p' });
|
|
98
|
+
const content = '<p>See <a href="https://external.com/a">link1</a> and <a href="https://external.com/b">link2</a> and <a href="https://wikipedia.org/wiki/Test">Wikipedia</a>.</p>';
|
|
99
|
+
mockSuccess([makePostWithContent(1, content)]);
|
|
100
|
+
mockSuccess(makeAuthor(1));
|
|
101
|
+
|
|
102
|
+
const res = await call('wp_analyze_eeat_signals');
|
|
103
|
+
const data = parseResult(res);
|
|
104
|
+
|
|
105
|
+
expect(data.analyses[0].signals_present).toContain('has_authoritative_sources');
|
|
106
|
+
expect(data.analyses[0].signals_present).toContain('has_outbound_links');
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
it('SIGNAL — content_has_data detected', async () => {
|
|
110
|
+
_testSetTarget('test', { url: 'https://test.example.com', username: 'u', password: 'p' });
|
|
111
|
+
const content = '<p>Our conversion rate improved by 45% after the optimization.</p>';
|
|
112
|
+
mockSuccess([makePostWithContent(1, content)]);
|
|
113
|
+
mockSuccess(makeAuthor(1));
|
|
114
|
+
|
|
115
|
+
const res = await call('wp_analyze_eeat_signals');
|
|
116
|
+
const data = parseResult(res);
|
|
117
|
+
|
|
118
|
+
expect(data.analyses[0].signals_present).toContain('content_has_data');
|
|
119
|
+
expect(data.analyses[0].scores.expertise).toBeGreaterThanOrEqual(8);
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
it('SIGNAL — has_structured_data detected', async () => {
|
|
123
|
+
_testSetTarget('test', { url: 'https://test.example.com', username: 'u', password: 'p' });
|
|
124
|
+
const content = '<p>Text.</p><script type="application/ld+json">{"@type":"Article"}</script>';
|
|
125
|
+
mockSuccess([makePostWithContent(1, content)]);
|
|
126
|
+
mockSuccess(makeAuthor(1));
|
|
127
|
+
|
|
128
|
+
const res = await call('wp_analyze_eeat_signals');
|
|
129
|
+
const data = parseResult(res);
|
|
130
|
+
|
|
131
|
+
expect(data.analyses[0].signals_present).toContain('has_structured_data');
|
|
132
|
+
expect(data.analyses[0].scores.trustworthiness).toBeGreaterThanOrEqual(7);
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
it('SUCCESS — priority_fixes returns max 3 items', async () => {
|
|
136
|
+
_testSetTarget('test', { url: 'https://test.example.com', username: 'u', password: 'p' });
|
|
137
|
+
mockSuccess([makePostWithContent(1, emptyContent, { modified: oldDate })]);
|
|
138
|
+
mockSuccess(makeAuthor(1));
|
|
139
|
+
|
|
140
|
+
const res = await call('wp_analyze_eeat_signals');
|
|
141
|
+
const data = parseResult(res);
|
|
142
|
+
|
|
143
|
+
expect(data.analyses[0].priority_fixes.length).toBeLessThanOrEqual(3);
|
|
144
|
+
expect(data.analyses[0].priority_fixes.length).toBeGreaterThan(0);
|
|
145
|
+
// Should be sorted by impact descending
|
|
146
|
+
if (data.analyses[0].priority_fixes.length >= 2) {
|
|
147
|
+
expect(data.analyses[0].priority_fixes[0].potential_points).toBeGreaterThanOrEqual(data.analyses[0].priority_fixes[1].potential_points);
|
|
148
|
+
}
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
it('SUCCESS — post_ids parameter respected', async () => {
|
|
152
|
+
_testSetTarget('test', { url: 'https://test.example.com', username: 'u', password: 'p' });
|
|
153
|
+
mockSuccess([makePostWithContent(42, emptyContent)]);
|
|
154
|
+
mockSuccess(makeAuthor(1));
|
|
155
|
+
|
|
156
|
+
const res = await call('wp_analyze_eeat_signals', { post_ids: [42] });
|
|
157
|
+
const data = parseResult(res);
|
|
158
|
+
|
|
159
|
+
expect(data.analyses[0].id).toBe(42);
|
|
160
|
+
expect(data.total_analyzed).toBe(1);
|
|
161
|
+
// Verify fetch was called with include parameter
|
|
162
|
+
const fetchUrl = fetch.mock.calls[0][0];
|
|
163
|
+
expect(fetchUrl).toContain('include=42');
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
it('AUDIT — logs success entry', async () => {
|
|
167
|
+
_testSetTarget('test', { url: 'https://test.example.com', username: 'u', password: 'p' });
|
|
168
|
+
mockSuccess([makePostWithContent(1, emptyContent)]);
|
|
169
|
+
mockSuccess(makeAuthor(1));
|
|
170
|
+
|
|
171
|
+
await call('wp_analyze_eeat_signals');
|
|
172
|
+
|
|
173
|
+
const logs = getAuditLogs();
|
|
174
|
+
const entry = logs.find(l => l.tool === 'wp_analyze_eeat_signals');
|
|
175
|
+
expect(entry).toBeDefined();
|
|
176
|
+
expect(entry.status).toBe('success');
|
|
177
|
+
expect(entry.action).toBe('audit_seo');
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
it('ERROR — logs error on API failure', async () => {
|
|
181
|
+
_testSetTarget('test', { url: 'https://test.example.com', username: 'u', password: 'p' });
|
|
182
|
+
mockError(403, '{"code":"rest_forbidden","message":"Forbidden"}');
|
|
183
|
+
|
|
184
|
+
const res = await call('wp_analyze_eeat_signals');
|
|
185
|
+
expect(res.isError).toBe(true);
|
|
186
|
+
|
|
187
|
+
const logs = getAuditLogs();
|
|
188
|
+
const entry = logs.find(l => l.tool === 'wp_analyze_eeat_signals');
|
|
189
|
+
expect(entry).toBeDefined();
|
|
190
|
+
expect(entry.status).toBe('error');
|
|
191
|
+
});
|
|
192
|
+
});
|