wpfleet 1.0.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/CHANGELOG.md +30 -0
- package/README.md +143 -0
- package/bin/wpfleet.js +2 -0
- package/package.json +45 -0
- package/src/ai/client.js +149 -0
- package/src/ai/seo-prompts.js +117 -0
- package/src/commands/ai-optimize.js +242 -0
- package/src/commands/ai-write.js +119 -0
- package/src/commands/health.js +100 -0
- package/src/commands/interlink.js +208 -0
- package/src/commands/publish.js +136 -0
- package/src/commands/report.js +108 -0
- package/src/commands/security.js +158 -0
- package/src/commands/sites.js +98 -0
- package/src/commands/stats.js +48 -0
- package/src/commands/traffic.js +87 -0
- package/src/commands/update.js +112 -0
- package/src/config.js +51 -0
- package/src/index.js +231 -0
- package/src/utils/display.js +41 -0
- package/src/utils/http.js +40 -0
- package/src/utils/wp-api.js +75 -0
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
import chalk from 'chalk';
|
|
2
|
+
import axios from 'axios';
|
|
3
|
+
import { getSite, getSites } from '../config.js';
|
|
4
|
+
import { getAiConfig, aiComplete } from '../ai/client.js';
|
|
5
|
+
import { metaOptimizationPrompt, batchScoringPrompt } from '../ai/seo-prompts.js';
|
|
6
|
+
import { makeTable } from '../utils/display.js';
|
|
7
|
+
|
|
8
|
+
function makeWpClient(site) {
|
|
9
|
+
const baseURL = site.url.replace(/\/$/, '') + '/wp-json';
|
|
10
|
+
const token = Buffer.from(`${site.wp_user}:${site.wp_app_password}`).toString('base64');
|
|
11
|
+
return axios.create({
|
|
12
|
+
baseURL,
|
|
13
|
+
timeout: 15000,
|
|
14
|
+
validateStatus: () => true,
|
|
15
|
+
headers: { Authorization: `Basic ${token}` },
|
|
16
|
+
});
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function stripHtml(html) {
|
|
20
|
+
return (html || '').replace(/<[^>]+>/g, ' ').replace(/\s+/g, ' ').trim();
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function wordCount(html) {
|
|
24
|
+
return stripHtml(html).split(/\s+/).filter(Boolean).length;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function parseJson(text) {
|
|
28
|
+
// Strip markdown code fences if present
|
|
29
|
+
let cleaned = text.replace(/```(?:json)?\s*/g, '').replace(/```\s*/g, '').trim();
|
|
30
|
+
// Extract JSON from potential surrounding text
|
|
31
|
+
const match = cleaned.match(/(\[[\s\S]*\]|\{[\s\S]*\})/);
|
|
32
|
+
if (!match) throw new Error('No JSON found in AI response');
|
|
33
|
+
return JSON.parse(match[1]);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function scoreColor(score) {
|
|
37
|
+
if (score >= 70) return chalk.green(String(score));
|
|
38
|
+
if (score >= 40) return chalk.yellow(String(score));
|
|
39
|
+
return chalk.red(String(score));
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function priorityColor(priority) {
|
|
43
|
+
if (priority === 'high') return chalk.red(priority);
|
|
44
|
+
if (priority === 'medium') return chalk.yellow(priority);
|
|
45
|
+
return chalk.green(priority);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
async function fetchPosts(client, perPage = 50) {
|
|
49
|
+
const res = await client.get('/wp/v2/posts', {
|
|
50
|
+
params: { per_page: perPage, orderby: 'date', order: 'desc', _fields: 'id,title,link,excerpt,content,slug' },
|
|
51
|
+
});
|
|
52
|
+
if (res.status !== 200 || !Array.isArray(res.data)) {
|
|
53
|
+
throw new Error(`Failed to fetch posts: HTTP ${res.status}`);
|
|
54
|
+
}
|
|
55
|
+
return res.data;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
async function optimizeSinglePost(site, postId, apply) {
|
|
59
|
+
const client = makeWpClient(site);
|
|
60
|
+
const res = await client.get(`/wp/v2/posts/${postId}`, {
|
|
61
|
+
params: { _fields: 'id,title,link,excerpt,content,slug' },
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
if (res.status !== 200 || !res.data) {
|
|
65
|
+
throw new Error(`Post ${postId} not found: HTTP ${res.status}`);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const post = res.data;
|
|
69
|
+
const title = post.title?.rendered || '';
|
|
70
|
+
const content = post.content?.rendered || '';
|
|
71
|
+
const currentMeta = stripHtml(post.excerpt?.rendered || '');
|
|
72
|
+
const url = post.link || '';
|
|
73
|
+
|
|
74
|
+
console.log(chalk.bold(`\nAnalyzing: "${title}"`));
|
|
75
|
+
console.log(chalk.dim(`URL: ${url}`));
|
|
76
|
+
|
|
77
|
+
const { system, user } = metaOptimizationPrompt({ title, content, currentMeta, url });
|
|
78
|
+
|
|
79
|
+
let raw;
|
|
80
|
+
try {
|
|
81
|
+
raw = await aiComplete({ systemPrompt: system, userPrompt: user, maxTokens: 600 });
|
|
82
|
+
} catch (err) {
|
|
83
|
+
throw new Error(`AI error: ${err.message}`);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
let analysis;
|
|
87
|
+
try {
|
|
88
|
+
analysis = parseJson(raw);
|
|
89
|
+
} catch {
|
|
90
|
+
throw new Error('Could not parse AI response as JSON');
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
console.log(`\nSEO Score: ${scoreColor(analysis.score)}/100`);
|
|
94
|
+
|
|
95
|
+
if (analysis.issues && analysis.issues.length > 0) {
|
|
96
|
+
console.log(chalk.bold('\nIssues:'));
|
|
97
|
+
analysis.issues.forEach(issue => console.log(` • ${issue}`));
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
console.log(chalk.bold('\nOptimized Title:'));
|
|
101
|
+
console.log(` ${analysis.optimized_title}`);
|
|
102
|
+
|
|
103
|
+
console.log(chalk.bold('\nOptimized Meta Description:'));
|
|
104
|
+
console.log(` ${analysis.optimized_meta}`);
|
|
105
|
+
|
|
106
|
+
if (analysis.recommendations && analysis.recommendations.length > 0) {
|
|
107
|
+
console.log(chalk.bold('\nRecommendations:'));
|
|
108
|
+
analysis.recommendations.forEach(rec => console.log(` • ${rec}`));
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
if (apply) {
|
|
112
|
+
console.log(chalk.dim('\nApplying changes...'));
|
|
113
|
+
const updateRes = await client.post(`/wp/v2/posts/${postId}`, {
|
|
114
|
+
title: analysis.optimized_title,
|
|
115
|
+
excerpt: analysis.optimized_meta,
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
if (updateRes.status !== 200) {
|
|
119
|
+
throw new Error(`Update failed ${updateRes.status}: ${JSON.stringify(updateRes.data?.message || updateRes.data)}`);
|
|
120
|
+
}
|
|
121
|
+
console.log(chalk.green('Applied: title and meta description updated'));
|
|
122
|
+
} else {
|
|
123
|
+
console.log(chalk.dim('\nRun with --apply to push these changes to WordPress'));
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
async function scoreAllPosts(site) {
|
|
128
|
+
const client = makeWpClient(site);
|
|
129
|
+
console.log(chalk.dim(`Fetching posts from ${site.name}...`));
|
|
130
|
+
|
|
131
|
+
let posts;
|
|
132
|
+
try {
|
|
133
|
+
posts = await fetchPosts(client, 50);
|
|
134
|
+
} catch (err) {
|
|
135
|
+
throw new Error(`Could not fetch posts: ${err.message}`);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
if (posts.length === 0) {
|
|
139
|
+
console.log(chalk.yellow('No posts found'));
|
|
140
|
+
return;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
const postData = posts.map(p => ({
|
|
144
|
+
id: p.id,
|
|
145
|
+
title: p.title?.rendered || '',
|
|
146
|
+
wordCount: wordCount(p.content?.rendered || ''),
|
|
147
|
+
hasMeta: !!stripHtml(p.excerpt?.rendered || ''),
|
|
148
|
+
link: p.link || '',
|
|
149
|
+
}));
|
|
150
|
+
|
|
151
|
+
// Batch scoring in chunks of 15 to avoid truncation
|
|
152
|
+
const BATCH_SIZE = 15;
|
|
153
|
+
let scores = [];
|
|
154
|
+
const totalBatches = Math.ceil(postData.length / BATCH_SIZE);
|
|
155
|
+
console.log(chalk.dim(`Scoring ${postData.length} posts with AI (${totalBatches} batch${totalBatches > 1 ? 'es' : ''})...`));
|
|
156
|
+
|
|
157
|
+
for (let i = 0; i < postData.length; i += BATCH_SIZE) {
|
|
158
|
+
const batch = postData.slice(i, i + BATCH_SIZE);
|
|
159
|
+
const batchNum = Math.floor(i / BATCH_SIZE) + 1;
|
|
160
|
+
if (totalBatches > 1) console.log(chalk.dim(` Batch ${batchNum}/${totalBatches}...`));
|
|
161
|
+
|
|
162
|
+
const { system, user } = batchScoringPrompt({ posts: batch });
|
|
163
|
+
|
|
164
|
+
let raw;
|
|
165
|
+
try {
|
|
166
|
+
raw = await aiComplete({ systemPrompt: system, userPrompt: user, maxTokens: 800 });
|
|
167
|
+
} catch (err) {
|
|
168
|
+
console.error(chalk.yellow(` Batch ${batchNum} AI error: ${err.message}`));
|
|
169
|
+
continue;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
try {
|
|
173
|
+
const batchScores = parseJson(raw);
|
|
174
|
+
if (Array.isArray(batchScores)) scores.push(...batchScores);
|
|
175
|
+
} catch {
|
|
176
|
+
console.error(chalk.yellow(` Batch ${batchNum}: could not parse AI response`));
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
if (scores.length === 0) {
|
|
181
|
+
throw new Error('AI scoring failed for all batches');
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
// Build lookup map
|
|
185
|
+
const scoreMap = {};
|
|
186
|
+
for (const s of scores) scoreMap[s.id] = s;
|
|
187
|
+
|
|
188
|
+
// Sort by score ascending (worst first = fix first)
|
|
189
|
+
const sorted = postData
|
|
190
|
+
.map(p => ({ ...p, ...(scoreMap[p.id] || { score: 50, priority: 'medium', top_issue: '?' }) }))
|
|
191
|
+
.sort((a, b) => a.score - b.score);
|
|
192
|
+
|
|
193
|
+
const table = makeTable(['ID', 'Title', 'Score', 'Priority', 'Top Issue'], [6, 35, 7, 8, 30]);
|
|
194
|
+
for (const p of sorted) {
|
|
195
|
+
const shortTitle = p.title.length > 33 ? p.title.slice(0, 32) + '…' : p.title;
|
|
196
|
+
const shortIssue = (p.top_issue || '').slice(0, 28);
|
|
197
|
+
table.push([String(p.id), shortTitle, scoreColor(p.score), priorityColor(p.priority), shortIssue]);
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
console.log(`\n${chalk.bold(`SEO Optimization Report — ${site.name}`)}`);
|
|
201
|
+
console.log(table.toString());
|
|
202
|
+
console.log(chalk.dim(`\nRun: wpfleet ai-optimize --site ${site.name} --post <id> [--apply]`));
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
export async function aiOptimizeCommand(options) {
|
|
206
|
+
if (!options.site) {
|
|
207
|
+
console.error(chalk.red('Error: --site <name> is required'));
|
|
208
|
+
process.exit(1);
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
if (!getAiConfig()) {
|
|
212
|
+
console.error(chalk.red('No AI API key configured. Set OPENAI_API_KEY or ANTHROPIC_API_KEY'));
|
|
213
|
+
console.error(chalk.dim('Or add ai.api_key to wpfleet.yml'));
|
|
214
|
+
process.exit(1);
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
const site = getSite(options.site);
|
|
218
|
+
if (!site) {
|
|
219
|
+
console.error(chalk.red(`Site not found: ${options.site}`));
|
|
220
|
+
process.exit(1);
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
if (!site.wp_user || !site.wp_app_password) {
|
|
224
|
+
console.error(chalk.red(`Site "${site.name}" requires wp_user and wp_app_password`));
|
|
225
|
+
process.exit(1);
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
try {
|
|
229
|
+
if (options.post) {
|
|
230
|
+
await optimizeSinglePost(site, options.post, options.apply);
|
|
231
|
+
} else {
|
|
232
|
+
if (options.apply) {
|
|
233
|
+
console.error(chalk.red('--apply requires --post <id>'));
|
|
234
|
+
process.exit(1);
|
|
235
|
+
}
|
|
236
|
+
await scoreAllPosts(site);
|
|
237
|
+
}
|
|
238
|
+
} catch (err) {
|
|
239
|
+
console.error(chalk.red(`Error: ${err.message}`));
|
|
240
|
+
process.exit(1);
|
|
241
|
+
}
|
|
242
|
+
}
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
import fs from 'fs';
|
|
2
|
+
import chalk from 'chalk';
|
|
3
|
+
import axios from 'axios';
|
|
4
|
+
import { marked } from 'marked';
|
|
5
|
+
import matter from 'gray-matter';
|
|
6
|
+
import { getSite } from '../config.js';
|
|
7
|
+
import { getAiConfig, aiComplete } from '../ai/client.js';
|
|
8
|
+
import { articleGenerationPrompt } from '../ai/seo-prompts.js';
|
|
9
|
+
|
|
10
|
+
function makeWpClient(site) {
|
|
11
|
+
const baseURL = site.url.replace(/\/$/, '') + '/wp-json';
|
|
12
|
+
const token = Buffer.from(`${site.wp_user}:${site.wp_app_password}`).toString('base64');
|
|
13
|
+
return axios.create({
|
|
14
|
+
baseURL,
|
|
15
|
+
timeout: 15000,
|
|
16
|
+
validateStatus: () => true,
|
|
17
|
+
headers: { Authorization: `Basic ${token}` },
|
|
18
|
+
});
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
async function getSiteContext(site) {
|
|
22
|
+
try {
|
|
23
|
+
const client = makeWpClient(site);
|
|
24
|
+
const res = await client.get('/wp/v2/posts', {
|
|
25
|
+
params: { per_page: 15, orderby: 'date', order: 'desc', _fields: 'title,link,slug' },
|
|
26
|
+
});
|
|
27
|
+
if (res.status !== 200 || !Array.isArray(res.data)) return '';
|
|
28
|
+
return res.data.map(p => `- ${p.title.rendered}: ${p.link}`).join('\n');
|
|
29
|
+
} catch {
|
|
30
|
+
return '';
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export async function aiWriteCommand(options) {
|
|
35
|
+
if (!options.topic) {
|
|
36
|
+
console.error(chalk.red('Error: --topic <topic> is required'));
|
|
37
|
+
process.exit(1);
|
|
38
|
+
}
|
|
39
|
+
if (!options.site) {
|
|
40
|
+
console.error(chalk.red('Error: --site <name> is required'));
|
|
41
|
+
process.exit(1);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
if (!getAiConfig()) {
|
|
45
|
+
console.error(chalk.red('No AI API key configured. Set OPENAI_API_KEY or ANTHROPIC_API_KEY'));
|
|
46
|
+
console.error(chalk.dim('Or add ai.api_key to wpfleet.yml'));
|
|
47
|
+
process.exit(1);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const site = getSite(options.site);
|
|
51
|
+
if (!site) {
|
|
52
|
+
console.error(chalk.red(`Site not found: ${options.site}`));
|
|
53
|
+
process.exit(1);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const words = parseInt(options.words || '1500', 10);
|
|
57
|
+
const lang = options.lang || 'en';
|
|
58
|
+
const keywords = options.keywords ? options.keywords.split(',').map(k => k.trim()).filter(Boolean) : [];
|
|
59
|
+
|
|
60
|
+
console.log(chalk.bold(`Generating: "${options.topic}"`));
|
|
61
|
+
console.log(chalk.dim(`${words} words | lang: ${lang} | site: ${site.name}`));
|
|
62
|
+
|
|
63
|
+
console.log(chalk.dim('Fetching site context...'));
|
|
64
|
+
const siteContext = await getSiteContext(site);
|
|
65
|
+
|
|
66
|
+
const { system, user } = articleGenerationPrompt({ topic: options.topic, keywords, words, lang, siteContext });
|
|
67
|
+
const maxTokens = Math.min(Math.ceil(words * 1.8) + 600, 8000);
|
|
68
|
+
|
|
69
|
+
let markdown;
|
|
70
|
+
try {
|
|
71
|
+
markdown = await aiComplete({ systemPrompt: system, userPrompt: user, maxTokens });
|
|
72
|
+
} catch (err) {
|
|
73
|
+
console.error(chalk.red(`AI error: ${err.message}`));
|
|
74
|
+
process.exit(1);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// Strip code fences if the model wrapped the output
|
|
78
|
+
markdown = markdown.replace(/^```(?:markdown)?\n?/m, '').replace(/\n?```\s*$/m, '').trim();
|
|
79
|
+
|
|
80
|
+
const { data: fm, content } = matter(markdown);
|
|
81
|
+
const title = fm.title || options.topic;
|
|
82
|
+
|
|
83
|
+
console.log(chalk.green(`\nTitle: ${title}`));
|
|
84
|
+
if (fm.meta_description) console.log(chalk.dim(`Meta: ${fm.meta_description}`));
|
|
85
|
+
|
|
86
|
+
if (options.draft) {
|
|
87
|
+
// Save markdown locally; don't publish
|
|
88
|
+
const slug = fm.slug || options.topic.toLowerCase().replace(/\s+/g, '-').replace(/[^a-z0-9-]/g, '');
|
|
89
|
+
const fileName = `${slug}.md`;
|
|
90
|
+
fs.writeFileSync(fileName, markdown, 'utf8');
|
|
91
|
+
console.log(chalk.cyan(`Draft saved: ${fileName}`));
|
|
92
|
+
return;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// Publish to WordPress
|
|
96
|
+
if (!site.wp_user || !site.wp_app_password) {
|
|
97
|
+
console.error(chalk.red(`Site "${site.name}" requires wp_user and wp_app_password`));
|
|
98
|
+
console.error(chalk.dim('Use --draft to save the article locally instead'));
|
|
99
|
+
process.exit(1);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
console.log(chalk.dim(`Publishing to ${site.name}...`));
|
|
103
|
+
|
|
104
|
+
const html = marked(content) + '\n<!-- AI-generated content -->';
|
|
105
|
+
const client = makeWpClient(site);
|
|
106
|
+
const res = await client.post('/wp/v2/posts', {
|
|
107
|
+
title,
|
|
108
|
+
content: html,
|
|
109
|
+
status: 'publish',
|
|
110
|
+
excerpt: fm.meta_description || '',
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
if (res.status !== 201) {
|
|
114
|
+
console.error(chalk.red(`Publish failed ${res.status}: ${JSON.stringify(res.data?.message || res.data)}`));
|
|
115
|
+
process.exit(1);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
console.log(chalk.green(`Published: ${res.data.link}`));
|
|
119
|
+
}
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import { URL } from 'url';
|
|
2
|
+
import chalk from 'chalk';
|
|
3
|
+
import { getSites, getSite } from '../config.js';
|
|
4
|
+
import { getResponseTime, getSslExpiry } from '../utils/http.js';
|
|
5
|
+
import { getWpInfo, getPlugins } from '../utils/wp-api.js';
|
|
6
|
+
import { makeTable, colorByResponseTime, na } from '../utils/display.js';
|
|
7
|
+
|
|
8
|
+
async function fetchHealth(site) {
|
|
9
|
+
const { hostname } = new URL(site.url);
|
|
10
|
+
const [timing, sslExpiry, wpInfo, plugins] = await Promise.all([
|
|
11
|
+
getResponseTime(site.url),
|
|
12
|
+
getSslExpiry(hostname),
|
|
13
|
+
getWpInfo(site),
|
|
14
|
+
getPlugins(site),
|
|
15
|
+
]);
|
|
16
|
+
|
|
17
|
+
const wpVersion = wpInfo ? (wpInfo.generator ? wpInfo.generator.replace('https://wordpress.org/?v=', '') : (wpInfo.description ? null : null)) : null;
|
|
18
|
+
// WP REST API root has a 'generator' field like "https://wordpress.org/?v=6.4.2"
|
|
19
|
+
let version = null;
|
|
20
|
+
if (wpInfo && wpInfo.generator) {
|
|
21
|
+
const match = wpInfo.generator.match(/v=([\d.]+)/);
|
|
22
|
+
if (match) version = match[1];
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
let daysUntilSslExpiry = null;
|
|
26
|
+
if (sslExpiry) {
|
|
27
|
+
daysUntilSslExpiry = Math.floor((sslExpiry - Date.now()) / (1000 * 60 * 60 * 24));
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
let pluginCount = null;
|
|
31
|
+
let pluginUpdates = null;
|
|
32
|
+
if (plugins) {
|
|
33
|
+
pluginCount = plugins.length;
|
|
34
|
+
pluginUpdates = plugins.filter(p => p.update && p.update !== 'none').length;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
return {
|
|
38
|
+
responseTime: timing.ms,
|
|
39
|
+
daysUntilSslExpiry,
|
|
40
|
+
wpVersion: version,
|
|
41
|
+
pluginCount,
|
|
42
|
+
pluginUpdates,
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function colorSsl(days) {
|
|
47
|
+
if (days === null) return na();
|
|
48
|
+
const str = `${days}d`;
|
|
49
|
+
if (days <= 14) return chalk.red(str);
|
|
50
|
+
if (days <= 30) return chalk.yellow(str);
|
|
51
|
+
return chalk.green(str);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function colorPluginUpdates(count) {
|
|
55
|
+
if (count === null) return na();
|
|
56
|
+
if (count > 0) return chalk.yellow(String(count));
|
|
57
|
+
return chalk.green('0');
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function renderRow(site, h) {
|
|
61
|
+
return [
|
|
62
|
+
site.name,
|
|
63
|
+
colorByResponseTime(h.responseTime),
|
|
64
|
+
colorSsl(h.daysUntilSslExpiry),
|
|
65
|
+
h.wpVersion ? chalk.cyan(h.wpVersion) : na(),
|
|
66
|
+
h.pluginCount !== null ? String(h.pluginCount) : na(),
|
|
67
|
+
colorPluginUpdates(h.pluginUpdates),
|
|
68
|
+
];
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export async function healthCommand(siteName) {
|
|
72
|
+
const table = makeTable(
|
|
73
|
+
['Name', 'Response', 'SSL Expiry', 'WP Version', 'Plugins', 'Updates'],
|
|
74
|
+
[18, 12, 12, 12, 10, 10]
|
|
75
|
+
);
|
|
76
|
+
|
|
77
|
+
if (siteName) {
|
|
78
|
+
const site = getSite(siteName);
|
|
79
|
+
if (!site) {
|
|
80
|
+
console.error(chalk.red(`Site not found: ${siteName}`));
|
|
81
|
+
process.exit(1);
|
|
82
|
+
}
|
|
83
|
+
console.log(chalk.dim(`Checking health for ${site.name}...`));
|
|
84
|
+
const h = await fetchHealth(site);
|
|
85
|
+
table.push(renderRow(site, h));
|
|
86
|
+
} else {
|
|
87
|
+
const sites = getSites();
|
|
88
|
+
if (sites.length === 0) {
|
|
89
|
+
console.log(chalk.yellow('No sites configured. Run: wpfleet sites add'));
|
|
90
|
+
return;
|
|
91
|
+
}
|
|
92
|
+
console.log(chalk.dim('Checking health for all sites...'));
|
|
93
|
+
const allHealth = await Promise.all(sites.map(fetchHealth));
|
|
94
|
+
for (let i = 0; i < sites.length; i++) {
|
|
95
|
+
table.push(renderRow(sites[i], allHealth[i]));
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
console.log(table.toString());
|
|
100
|
+
}
|
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
import chalk from 'chalk';
|
|
2
|
+
import axios from 'axios';
|
|
3
|
+
import { getSite, getSites } from '../config.js';
|
|
4
|
+
import { makeTable } from '../utils/display.js';
|
|
5
|
+
|
|
6
|
+
function makeClient(site) {
|
|
7
|
+
const baseURL = site.url.replace(/\/$/, '') + '/wp-json';
|
|
8
|
+
const config = { baseURL, timeout: 15000, validateStatus: () => true };
|
|
9
|
+
if (site.wp_user && site.wp_app_password) {
|
|
10
|
+
const token = Buffer.from(`${site.wp_user}:${site.wp_app_password}`).toString('base64');
|
|
11
|
+
config.headers = { Authorization: `Basic ${token}` };
|
|
12
|
+
}
|
|
13
|
+
return axios.create(config);
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
async function fetchAllPosts(site) {
|
|
17
|
+
const client = makeClient(site);
|
|
18
|
+
let page = 1;
|
|
19
|
+
let posts = [];
|
|
20
|
+
while (true) {
|
|
21
|
+
const res = await client.get('/wp/v2/posts', {
|
|
22
|
+
params: { per_page: 100, page, _fields: 'id,title,link,content,slug' },
|
|
23
|
+
});
|
|
24
|
+
if (res.status !== 200 || !Array.isArray(res.data) || res.data.length === 0) break;
|
|
25
|
+
posts = posts.concat(res.data);
|
|
26
|
+
const totalPages = parseInt(res.headers['x-wp-totalpages'] || '1', 10);
|
|
27
|
+
if (page >= totalPages) break;
|
|
28
|
+
page++;
|
|
29
|
+
}
|
|
30
|
+
return posts;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const STOP_WORDS = new Set([
|
|
34
|
+
'that', 'this', 'with', 'have', 'from', 'they', 'will', 'been', 'were',
|
|
35
|
+
'said', 'each', 'which', 'their', 'into', 'there', 'your', 'what', 'more',
|
|
36
|
+
'also', 'then', 'than', 'when', 'where', 'some', 'these', 'those', 'about',
|
|
37
|
+
'most', 'other', 'like', 'just', 'time', 'very', 'only', 'even', 'such',
|
|
38
|
+
]);
|
|
39
|
+
|
|
40
|
+
export function extractKeywords(text) {
|
|
41
|
+
const clean = (text || '').replace(/<[^>]+>/g, ' ').toLowerCase();
|
|
42
|
+
const words = clean.match(/\b[a-z]{4,}\b/g) || [];
|
|
43
|
+
const freq = {};
|
|
44
|
+
for (const w of words) {
|
|
45
|
+
if (!STOP_WORDS.has(w)) freq[w] = (freq[w] || 0) + 1;
|
|
46
|
+
}
|
|
47
|
+
return freq;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function relevanceScore(freqA, freqB) {
|
|
51
|
+
const totalA = Object.values(freqA).reduce((a, b) => a + b, 0);
|
|
52
|
+
const totalB = Object.values(freqB).reduce((a, b) => a + b, 0);
|
|
53
|
+
if (!totalA || !totalB) return 0;
|
|
54
|
+
const keysB = new Set(Object.keys(freqB));
|
|
55
|
+
let shared = 0;
|
|
56
|
+
for (const k of Object.keys(freqA)) {
|
|
57
|
+
if (keysB.has(k)) shared += Math.min(freqA[k], freqB[k]);
|
|
58
|
+
}
|
|
59
|
+
return Math.round((shared / Math.sqrt(totalA * totalB)) * 100);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function bestAnchor(sourceFreq, targetFreq) {
|
|
63
|
+
const shared = Object.keys(targetFreq)
|
|
64
|
+
.filter(k => sourceFreq[k])
|
|
65
|
+
.sort((a, b) => targetFreq[b] - targetFreq[a]);
|
|
66
|
+
return shared[0] || 'related article';
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function hasLinkTo(content, targetUrl) {
|
|
70
|
+
return (content || '').includes(targetUrl.replace(/\/$/, ''));
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function scoreColor(score) {
|
|
74
|
+
if (score >= 70) return chalk.green(String(score));
|
|
75
|
+
if (score >= 50) return chalk.yellow(String(score));
|
|
76
|
+
return chalk.gray(String(score));
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export async function interlinkCommand(options) {
|
|
80
|
+
const siteName = options.site;
|
|
81
|
+
const site = siteName ? getSite(siteName) : getSites()[0];
|
|
82
|
+
if (!site) {
|
|
83
|
+
console.error(chalk.red(siteName ? `Site not found: ${siteName}` : 'No sites configured'));
|
|
84
|
+
process.exit(1);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const minRelevance = parseInt(options.minRelevance || '50', 10);
|
|
88
|
+
console.log(chalk.dim(`Fetching posts from ${site.name}...`));
|
|
89
|
+
const posts = await fetchAllPosts(site);
|
|
90
|
+
|
|
91
|
+
if (posts.length === 0) {
|
|
92
|
+
console.log(chalk.yellow('No posts found'));
|
|
93
|
+
return;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
console.log(chalk.dim(`Analyzing ${posts.length} posts...`));
|
|
97
|
+
|
|
98
|
+
const postData = posts.map(p => ({
|
|
99
|
+
id: p.id,
|
|
100
|
+
title: p.title?.rendered || p.slug,
|
|
101
|
+
link: p.link,
|
|
102
|
+
content: p.content?.rendered || '',
|
|
103
|
+
keywords: extractKeywords((p.title?.rendered || '') + ' ' + (p.content?.rendered || '')),
|
|
104
|
+
}));
|
|
105
|
+
|
|
106
|
+
const suggestions = [];
|
|
107
|
+
for (let i = 0; i < postData.length; i++) {
|
|
108
|
+
for (let j = 0; j < postData.length; j++) {
|
|
109
|
+
if (i === j) continue;
|
|
110
|
+
const score = relevanceScore(postData[i].keywords, postData[j].keywords);
|
|
111
|
+
if (score < minRelevance) continue;
|
|
112
|
+
if (hasLinkTo(postData[i].content, postData[j].link)) continue;
|
|
113
|
+
suggestions.push({
|
|
114
|
+
sourceId: postData[i].id,
|
|
115
|
+
sourceTitle: postData[i].title,
|
|
116
|
+
sourceContent: postData[i].content,
|
|
117
|
+
targetTitle: postData[j].title,
|
|
118
|
+
targetLink: postData[j].link,
|
|
119
|
+
anchor: bestAnchor(postData[i].keywords, postData[j].keywords),
|
|
120
|
+
score,
|
|
121
|
+
});
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
suggestions.sort((a, b) => b.score - a.score);
|
|
126
|
+
|
|
127
|
+
if (suggestions.length === 0) {
|
|
128
|
+
console.log(chalk.yellow(`No suggestions found with relevance >= ${minRelevance}`));
|
|
129
|
+
return;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
if (options.auto) {
|
|
133
|
+
console.log(chalk.dim(`Applying top suggestions automatically...`));
|
|
134
|
+
const client = makeClient(site);
|
|
135
|
+
const applied = suggestions.slice(0, 20);
|
|
136
|
+
for (const s of applied) {
|
|
137
|
+
const seeAlso = `\n<p>See also: <a href="${s.targetLink}">${s.anchor}</a></p>`;
|
|
138
|
+
await client.post(`/wp/v2/posts/${s.sourceId}`, { content: s.sourceContent + seeAlso });
|
|
139
|
+
console.log(chalk.green(` Linked "${s.sourceTitle}" → "${s.targetTitle}"`));
|
|
140
|
+
}
|
|
141
|
+
return;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// Detect orphan posts (no suggestions pointing TO them)
|
|
145
|
+
const linkedTargets = new Set(suggestions.map(s => s.targetTitle));
|
|
146
|
+
const orphans = postData.filter(p => !linkedTargets.has(p.title));
|
|
147
|
+
if (orphans.length > 0) {
|
|
148
|
+
console.log(chalk.yellow(`\nOrphan posts (no incoming internal link suggestions): ${orphans.length}`));
|
|
149
|
+
for (const o of orphans.slice(0, 5)) {
|
|
150
|
+
console.log(chalk.dim(` - ${o.title}`));
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
console.log('');
|
|
155
|
+
const table = makeTable(['Source Post', 'Target Post', 'Anchor Text', 'Score'], [32, 32, 22, 8]);
|
|
156
|
+
for (const s of suggestions.slice(0, 30)) {
|
|
157
|
+
table.push([
|
|
158
|
+
s.sourceTitle.slice(0, 30),
|
|
159
|
+
s.targetTitle.slice(0, 30),
|
|
160
|
+
s.anchor.slice(0, 20),
|
|
161
|
+
scoreColor(s.score),
|
|
162
|
+
]);
|
|
163
|
+
}
|
|
164
|
+
console.log(table.toString());
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
export async function crosslinkCommand(siteNameA, siteNameB) {
|
|
168
|
+
const siteA = getSite(siteNameA);
|
|
169
|
+
const siteB = getSite(siteNameB);
|
|
170
|
+
if (!siteA) { console.error(chalk.red(`Site not found: ${siteNameA}`)); process.exit(1); }
|
|
171
|
+
if (!siteB) { console.error(chalk.red(`Site not found: ${siteNameB}`)); process.exit(1); }
|
|
172
|
+
|
|
173
|
+
console.log(chalk.dim(`Fetching posts from ${siteA.name} and ${siteB.name}...`));
|
|
174
|
+
const [postsA, postsB] = await Promise.all([fetchAllPosts(siteA), fetchAllPosts(siteB)]);
|
|
175
|
+
|
|
176
|
+
const dataA = postsA.map(p => ({
|
|
177
|
+
title: p.title?.rendered || p.slug,
|
|
178
|
+
link: p.link,
|
|
179
|
+
keywords: extractKeywords((p.title?.rendered || '') + ' ' + (p.content?.rendered || '')),
|
|
180
|
+
}));
|
|
181
|
+
|
|
182
|
+
const dataB = postsB.map(p => ({
|
|
183
|
+
title: p.title?.rendered || p.slug,
|
|
184
|
+
link: p.link,
|
|
185
|
+
keywords: extractKeywords((p.title?.rendered || '') + ' ' + (p.content?.rendered || '')),
|
|
186
|
+
}));
|
|
187
|
+
|
|
188
|
+
const pairs = [];
|
|
189
|
+
for (const a of dataA) {
|
|
190
|
+
for (const b of dataB) {
|
|
191
|
+
const score = relevanceScore(a.keywords, b.keywords);
|
|
192
|
+
if (score >= 30) pairs.push({ titleA: a.title, titleB: b.title, score });
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
pairs.sort((a, b) => b.score - a.score);
|
|
197
|
+
|
|
198
|
+
if (pairs.length === 0) {
|
|
199
|
+
console.log(chalk.yellow('No cross-linking opportunities found'));
|
|
200
|
+
return;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
const table = makeTable([`${siteA.name} Post`, `${siteB.name} Post`, 'Relevance'], [35, 35, 12]);
|
|
204
|
+
for (const p of pairs.slice(0, 20)) {
|
|
205
|
+
table.push([p.titleA.slice(0, 33), p.titleB.slice(0, 33), scoreColor(p.score)]);
|
|
206
|
+
}
|
|
207
|
+
console.log(table.toString());
|
|
208
|
+
}
|