recker 1.0.28 → 1.0.29
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/dist/cli/tui/shell.d.ts +1 -0
- package/dist/cli/tui/shell.js +339 -5
- package/dist/scrape/index.d.ts +2 -0
- package/dist/scrape/index.js +1 -0
- package/dist/scrape/spider.d.ts +61 -0
- package/dist/scrape/spider.js +250 -0
- package/dist/seo/analyzer.js +27 -0
- package/dist/seo/index.d.ts +3 -1
- package/dist/seo/index.js +1 -0
- package/dist/seo/rules/accessibility.js +620 -54
- package/dist/seo/rules/best-practices.d.ts +2 -0
- package/dist/seo/rules/best-practices.js +188 -0
- package/dist/seo/rules/crawl.d.ts +2 -0
- package/dist/seo/rules/crawl.js +307 -0
- package/dist/seo/rules/cwv.d.ts +2 -0
- package/dist/seo/rules/cwv.js +337 -0
- package/dist/seo/rules/ecommerce.d.ts +2 -0
- package/dist/seo/rules/ecommerce.js +252 -0
- package/dist/seo/rules/i18n.d.ts +2 -0
- package/dist/seo/rules/i18n.js +222 -0
- package/dist/seo/rules/index.d.ts +32 -0
- package/dist/seo/rules/index.js +71 -0
- package/dist/seo/rules/internal-linking.d.ts +2 -0
- package/dist/seo/rules/internal-linking.js +375 -0
- package/dist/seo/rules/local.d.ts +2 -0
- package/dist/seo/rules/local.js +265 -0
- package/dist/seo/rules/pwa.d.ts +2 -0
- package/dist/seo/rules/pwa.js +302 -0
- package/dist/seo/rules/readability.d.ts +2 -0
- package/dist/seo/rules/readability.js +255 -0
- package/dist/seo/rules/security.js +406 -28
- package/dist/seo/rules/social.d.ts +2 -0
- package/dist/seo/rules/social.js +373 -0
- package/dist/seo/rules/types.d.ts +155 -0
- package/dist/seo/seo-spider.d.ts +47 -0
- package/dist/seo/seo-spider.js +362 -0
- package/dist/seo/types.d.ts +24 -0
- package/package.json +1 -1
|
@@ -0,0 +1,362 @@
|
|
|
1
|
+
import { Spider } from '../scrape/spider.js';
|
|
2
|
+
import { analyzeSeo } from './analyzer.js';
|
|
3
|
+
import { createClient } from '../core/client.js';
|
|
4
|
+
import * as fs from 'fs/promises';
|
|
5
|
+
export class SeoSpider {
|
|
6
|
+
spider;
|
|
7
|
+
options;
|
|
8
|
+
seoResults = new Map();
|
|
9
|
+
constructor(options = {}) {
|
|
10
|
+
this.options = options;
|
|
11
|
+
this.spider = new Spider(options);
|
|
12
|
+
}
|
|
13
|
+
async crawl(startUrl) {
|
|
14
|
+
const result = await this.spider.crawl(startUrl);
|
|
15
|
+
if (!this.options.seo) {
|
|
16
|
+
return {
|
|
17
|
+
...result,
|
|
18
|
+
pages: result.pages,
|
|
19
|
+
siteWideIssues: [],
|
|
20
|
+
summary: {
|
|
21
|
+
totalPages: result.pages.length,
|
|
22
|
+
pagesWithErrors: 0,
|
|
23
|
+
pagesWithWarnings: 0,
|
|
24
|
+
avgScore: 0,
|
|
25
|
+
duplicateTitles: 0,
|
|
26
|
+
duplicateDescriptions: 0,
|
|
27
|
+
duplicateH1s: 0,
|
|
28
|
+
orphanPages: 0,
|
|
29
|
+
},
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
const seoPages = await this.analyzePages(result.pages);
|
|
33
|
+
const siteWideIssues = this.detectSiteWideIssues(seoPages);
|
|
34
|
+
const summary = this.calculateSummary(seoPages, siteWideIssues);
|
|
35
|
+
const seoResult = {
|
|
36
|
+
...result,
|
|
37
|
+
pages: seoPages,
|
|
38
|
+
siteWideIssues,
|
|
39
|
+
summary,
|
|
40
|
+
};
|
|
41
|
+
if (this.options.output) {
|
|
42
|
+
await this.saveReport(seoResult);
|
|
43
|
+
}
|
|
44
|
+
return seoResult;
|
|
45
|
+
}
|
|
46
|
+
async analyzePages(pages) {
|
|
47
|
+
const results = [];
|
|
48
|
+
const client = createClient({
|
|
49
|
+
timeout: this.options.timeout || 10000,
|
|
50
|
+
headers: {
|
|
51
|
+
'User-Agent': this.options.userAgent || 'Recker Spider/1.0',
|
|
52
|
+
},
|
|
53
|
+
});
|
|
54
|
+
for (const page of pages) {
|
|
55
|
+
if (page.error || page.status >= 400) {
|
|
56
|
+
results.push({
|
|
57
|
+
...page,
|
|
58
|
+
seoReport: undefined,
|
|
59
|
+
});
|
|
60
|
+
continue;
|
|
61
|
+
}
|
|
62
|
+
try {
|
|
63
|
+
const response = await client.get(page.url);
|
|
64
|
+
const html = await response.text();
|
|
65
|
+
const seoReport = await analyzeSeo(html, { baseUrl: page.url });
|
|
66
|
+
const seoPage = {
|
|
67
|
+
...page,
|
|
68
|
+
seoReport,
|
|
69
|
+
};
|
|
70
|
+
results.push(seoPage);
|
|
71
|
+
this.seoResults.set(page.url, seoReport);
|
|
72
|
+
this.options.onSeoAnalysis?.(seoPage);
|
|
73
|
+
}
|
|
74
|
+
catch {
|
|
75
|
+
results.push({
|
|
76
|
+
...page,
|
|
77
|
+
seoReport: undefined,
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
return results;
|
|
82
|
+
}
|
|
83
|
+
createReportFromPageData(page) {
|
|
84
|
+
const checks = [];
|
|
85
|
+
if (page.title) {
|
|
86
|
+
const titleLength = page.title.length;
|
|
87
|
+
if (titleLength < 30) {
|
|
88
|
+
checks.push({
|
|
89
|
+
name: 'Title Length',
|
|
90
|
+
status: 'warn',
|
|
91
|
+
message: `Title is too short (${titleLength} chars)`,
|
|
92
|
+
value: titleLength,
|
|
93
|
+
recommendation: 'Title should be 50-60 characters',
|
|
94
|
+
});
|
|
95
|
+
}
|
|
96
|
+
else if (titleLength > 60) {
|
|
97
|
+
checks.push({
|
|
98
|
+
name: 'Title Length',
|
|
99
|
+
status: 'warn',
|
|
100
|
+
message: `Title is too long (${titleLength} chars)`,
|
|
101
|
+
value: titleLength,
|
|
102
|
+
recommendation: 'Title should be 50-60 characters',
|
|
103
|
+
});
|
|
104
|
+
}
|
|
105
|
+
else {
|
|
106
|
+
checks.push({
|
|
107
|
+
name: 'Title Length',
|
|
108
|
+
status: 'pass',
|
|
109
|
+
message: `Good title length (${titleLength} chars)`,
|
|
110
|
+
value: titleLength,
|
|
111
|
+
});
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
else {
|
|
115
|
+
checks.push({
|
|
116
|
+
name: 'Title',
|
|
117
|
+
status: 'fail',
|
|
118
|
+
message: 'Page has no title',
|
|
119
|
+
recommendation: 'Add a descriptive <title> tag',
|
|
120
|
+
});
|
|
121
|
+
}
|
|
122
|
+
const internalLinks = page.links.filter(l => l.type === 'internal').length;
|
|
123
|
+
const externalLinks = page.links.filter(l => l.type === 'external').length;
|
|
124
|
+
if (internalLinks === 0) {
|
|
125
|
+
checks.push({
|
|
126
|
+
name: 'Internal Links',
|
|
127
|
+
status: 'warn',
|
|
128
|
+
message: 'No internal links found',
|
|
129
|
+
recommendation: 'Add internal links to improve site structure',
|
|
130
|
+
});
|
|
131
|
+
}
|
|
132
|
+
else {
|
|
133
|
+
checks.push({
|
|
134
|
+
name: 'Internal Links',
|
|
135
|
+
status: 'pass',
|
|
136
|
+
message: `${internalLinks} internal links found`,
|
|
137
|
+
value: internalLinks,
|
|
138
|
+
});
|
|
139
|
+
}
|
|
140
|
+
const scoreSum = checks.reduce((sum, c) => {
|
|
141
|
+
if (c.status === 'pass')
|
|
142
|
+
return sum + 100;
|
|
143
|
+
if (c.status === 'warn')
|
|
144
|
+
return sum + 50;
|
|
145
|
+
return sum;
|
|
146
|
+
}, 0);
|
|
147
|
+
const score = checks.length > 0 ? Math.round(scoreSum / checks.length) : 0;
|
|
148
|
+
return {
|
|
149
|
+
url: page.url,
|
|
150
|
+
timestamp: new Date(),
|
|
151
|
+
grade: this.scoreToGrade(score),
|
|
152
|
+
score,
|
|
153
|
+
checks,
|
|
154
|
+
title: page.title ? { text: page.title, length: page.title.length } : undefined,
|
|
155
|
+
headings: {
|
|
156
|
+
structure: [],
|
|
157
|
+
h1Count: 0,
|
|
158
|
+
hasProperHierarchy: false,
|
|
159
|
+
issues: [],
|
|
160
|
+
},
|
|
161
|
+
content: {
|
|
162
|
+
wordCount: 0,
|
|
163
|
+
characterCount: 0,
|
|
164
|
+
sentenceCount: 0,
|
|
165
|
+
paragraphCount: 0,
|
|
166
|
+
readingTimeMinutes: 0,
|
|
167
|
+
avgWordsPerSentence: 0,
|
|
168
|
+
avgParagraphLength: 0,
|
|
169
|
+
listCount: 0,
|
|
170
|
+
strongTagCount: 0,
|
|
171
|
+
emTagCount: 0,
|
|
172
|
+
},
|
|
173
|
+
links: {
|
|
174
|
+
total: page.links.length,
|
|
175
|
+
internal: internalLinks,
|
|
176
|
+
external: externalLinks,
|
|
177
|
+
nofollow: 0,
|
|
178
|
+
broken: 0,
|
|
179
|
+
withoutText: page.links.filter(l => !l.text?.trim()).length,
|
|
180
|
+
sponsoredLinks: 0,
|
|
181
|
+
ugcLinks: 0,
|
|
182
|
+
},
|
|
183
|
+
images: {
|
|
184
|
+
total: 0,
|
|
185
|
+
withAlt: 0,
|
|
186
|
+
withoutAlt: 0,
|
|
187
|
+
lazy: 0,
|
|
188
|
+
missingDimensions: 0,
|
|
189
|
+
modernFormats: 0,
|
|
190
|
+
altTextLengths: [],
|
|
191
|
+
imageFilenames: [],
|
|
192
|
+
imagesWithAsyncDecoding: 0,
|
|
193
|
+
},
|
|
194
|
+
social: {
|
|
195
|
+
openGraph: {
|
|
196
|
+
present: false,
|
|
197
|
+
hasTitle: false,
|
|
198
|
+
hasDescription: false,
|
|
199
|
+
hasImage: false,
|
|
200
|
+
hasUrl: false,
|
|
201
|
+
issues: [],
|
|
202
|
+
},
|
|
203
|
+
twitterCard: {
|
|
204
|
+
present: false,
|
|
205
|
+
hasCard: false,
|
|
206
|
+
hasTitle: false,
|
|
207
|
+
hasDescription: false,
|
|
208
|
+
hasImage: false,
|
|
209
|
+
issues: [],
|
|
210
|
+
},
|
|
211
|
+
},
|
|
212
|
+
technical: {
|
|
213
|
+
hasCanonical: false,
|
|
214
|
+
hasRobotsMeta: false,
|
|
215
|
+
hasViewport: false,
|
|
216
|
+
hasCharset: false,
|
|
217
|
+
hasLang: false,
|
|
218
|
+
},
|
|
219
|
+
jsonLd: {
|
|
220
|
+
count: 0,
|
|
221
|
+
types: [],
|
|
222
|
+
},
|
|
223
|
+
};
|
|
224
|
+
}
|
|
225
|
+
detectSiteWideIssues(pages) {
|
|
226
|
+
const issues = [];
|
|
227
|
+
const titleGroups = new Map();
|
|
228
|
+
const descriptionGroups = new Map();
|
|
229
|
+
const h1Groups = new Map();
|
|
230
|
+
for (const page of pages) {
|
|
231
|
+
if (!page.seoReport)
|
|
232
|
+
continue;
|
|
233
|
+
const title = page.seoReport.title?.text?.trim();
|
|
234
|
+
if (title) {
|
|
235
|
+
const urls = titleGroups.get(title) || [];
|
|
236
|
+
urls.push(page.url);
|
|
237
|
+
titleGroups.set(title, urls);
|
|
238
|
+
}
|
|
239
|
+
const desc = page.seoReport.metaDescription?.text?.trim();
|
|
240
|
+
if (desc) {
|
|
241
|
+
const urls = descriptionGroups.get(desc) || [];
|
|
242
|
+
urls.push(page.url);
|
|
243
|
+
descriptionGroups.set(desc, urls);
|
|
244
|
+
}
|
|
245
|
+
const h1 = page.seoReport.headings?.structure?.find(h => h.level === 1)?.text?.trim();
|
|
246
|
+
if (h1) {
|
|
247
|
+
const urls = h1Groups.get(h1) || [];
|
|
248
|
+
urls.push(page.url);
|
|
249
|
+
h1Groups.set(h1, urls);
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
for (const [title, urls] of titleGroups) {
|
|
253
|
+
if (urls.length > 1) {
|
|
254
|
+
issues.push({
|
|
255
|
+
type: 'duplicate-title',
|
|
256
|
+
severity: 'error',
|
|
257
|
+
message: `${urls.length} pages share the same title`,
|
|
258
|
+
affectedUrls: urls,
|
|
259
|
+
value: title,
|
|
260
|
+
});
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
for (const [desc, urls] of descriptionGroups) {
|
|
264
|
+
if (urls.length > 1) {
|
|
265
|
+
issues.push({
|
|
266
|
+
type: 'duplicate-description',
|
|
267
|
+
severity: 'warning',
|
|
268
|
+
message: `${urls.length} pages share the same meta description`,
|
|
269
|
+
affectedUrls: urls,
|
|
270
|
+
value: desc,
|
|
271
|
+
});
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
for (const [h1, urls] of h1Groups) {
|
|
275
|
+
if (urls.length > 1) {
|
|
276
|
+
issues.push({
|
|
277
|
+
type: 'duplicate-h1',
|
|
278
|
+
severity: 'warning',
|
|
279
|
+
message: `${urls.length} pages share the same H1 heading`,
|
|
280
|
+
affectedUrls: urls,
|
|
281
|
+
value: h1,
|
|
282
|
+
});
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
const linkedUrls = new Set();
|
|
286
|
+
for (const page of pages) {
|
|
287
|
+
for (const link of page.links) {
|
|
288
|
+
if (link.type === 'internal' && link.href) {
|
|
289
|
+
linkedUrls.add(link.href);
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
const orphanPages = pages
|
|
294
|
+
.filter(p => !linkedUrls.has(p.url) && p.depth > 0)
|
|
295
|
+
.map(p => p.url);
|
|
296
|
+
if (orphanPages.length > 0) {
|
|
297
|
+
issues.push({
|
|
298
|
+
type: 'orphan-page',
|
|
299
|
+
severity: 'warning',
|
|
300
|
+
message: `${orphanPages.length} page(s) have no internal links pointing to them`,
|
|
301
|
+
affectedUrls: orphanPages,
|
|
302
|
+
});
|
|
303
|
+
}
|
|
304
|
+
return issues;
|
|
305
|
+
}
|
|
306
|
+
calculateSummary(pages, issues) {
|
|
307
|
+
const pagesWithSeo = pages.filter(p => p.seoReport);
|
|
308
|
+
const scores = pagesWithSeo.map(p => p.seoReport.score);
|
|
309
|
+
const avgScore = scores.length > 0
|
|
310
|
+
? Math.round(scores.reduce((a, b) => a + b, 0) / scores.length)
|
|
311
|
+
: 0;
|
|
312
|
+
const pagesWithErrors = pagesWithSeo.filter(p => p.seoReport.checks.some(c => c.status === 'fail')).length;
|
|
313
|
+
const pagesWithWarnings = pagesWithSeo.filter(p => p.seoReport.checks.some(c => c.status === 'warn')).length;
|
|
314
|
+
const duplicateTitles = issues.filter(i => i.type === 'duplicate-title').length;
|
|
315
|
+
const duplicateDescriptions = issues.filter(i => i.type === 'duplicate-description').length;
|
|
316
|
+
const duplicateH1s = issues.filter(i => i.type === 'duplicate-h1').length;
|
|
317
|
+
const orphanPages = issues
|
|
318
|
+
.filter(i => i.type === 'orphan-page')
|
|
319
|
+
.reduce((sum, i) => sum + i.affectedUrls.length, 0);
|
|
320
|
+
return {
|
|
321
|
+
totalPages: pages.length,
|
|
322
|
+
pagesWithErrors,
|
|
323
|
+
pagesWithWarnings,
|
|
324
|
+
avgScore,
|
|
325
|
+
duplicateTitles,
|
|
326
|
+
duplicateDescriptions,
|
|
327
|
+
duplicateH1s,
|
|
328
|
+
orphanPages,
|
|
329
|
+
};
|
|
330
|
+
}
|
|
331
|
+
scoreToGrade(score) {
|
|
332
|
+
if (score >= 90)
|
|
333
|
+
return 'A';
|
|
334
|
+
if (score >= 80)
|
|
335
|
+
return 'B';
|
|
336
|
+
if (score >= 70)
|
|
337
|
+
return 'C';
|
|
338
|
+
if (score >= 60)
|
|
339
|
+
return 'D';
|
|
340
|
+
return 'F';
|
|
341
|
+
}
|
|
342
|
+
async saveReport(result) {
|
|
343
|
+
if (!this.options.output)
|
|
344
|
+
return;
|
|
345
|
+
const reportData = {
|
|
346
|
+
...result,
|
|
347
|
+
visited: Array.from(result.visited),
|
|
348
|
+
generatedAt: new Date().toISOString(),
|
|
349
|
+
};
|
|
350
|
+
await fs.writeFile(this.options.output, JSON.stringify(reportData, null, 2), 'utf-8');
|
|
351
|
+
}
|
|
352
|
+
abort() {
|
|
353
|
+
this.spider.abort();
|
|
354
|
+
}
|
|
355
|
+
isRunning() {
|
|
356
|
+
return this.spider.isRunning();
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
export async function seoSpider(url, options) {
|
|
360
|
+
const spider = new SeoSpider(options);
|
|
361
|
+
return spider.crawl(url);
|
|
362
|
+
}
|
package/dist/seo/types.d.ts
CHANGED
|
@@ -86,11 +86,20 @@ export interface TechnicalSeo {
|
|
|
86
86
|
hasLang: boolean;
|
|
87
87
|
langValue?: string;
|
|
88
88
|
}
|
|
89
|
+
export interface SeoTiming {
|
|
90
|
+
ttfb?: number;
|
|
91
|
+
total?: number;
|
|
92
|
+
dns?: number;
|
|
93
|
+
tcp?: number;
|
|
94
|
+
tls?: number;
|
|
95
|
+
download?: number;
|
|
96
|
+
}
|
|
89
97
|
export interface SeoReport {
|
|
90
98
|
url: string;
|
|
91
99
|
timestamp: Date;
|
|
92
100
|
grade: string;
|
|
93
101
|
score: number;
|
|
102
|
+
timing?: SeoTiming;
|
|
94
103
|
checks: SeoCheckResult[];
|
|
95
104
|
title?: {
|
|
96
105
|
text: string;
|
|
@@ -100,6 +109,21 @@ export interface SeoReport {
|
|
|
100
109
|
text: string;
|
|
101
110
|
length: number;
|
|
102
111
|
};
|
|
112
|
+
openGraph?: {
|
|
113
|
+
title?: string;
|
|
114
|
+
description?: string;
|
|
115
|
+
image?: string;
|
|
116
|
+
url?: string;
|
|
117
|
+
type?: string;
|
|
118
|
+
siteName?: string;
|
|
119
|
+
};
|
|
120
|
+
twitterCard?: {
|
|
121
|
+
card?: string;
|
|
122
|
+
title?: string;
|
|
123
|
+
description?: string;
|
|
124
|
+
image?: string;
|
|
125
|
+
site?: string;
|
|
126
|
+
};
|
|
103
127
|
headings: HeadingAnalysis;
|
|
104
128
|
content: ContentMetrics;
|
|
105
129
|
links: LinkAnalysis;
|