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.
Files changed (38) hide show
  1. package/dist/cli/tui/shell.d.ts +1 -0
  2. package/dist/cli/tui/shell.js +339 -5
  3. package/dist/scrape/index.d.ts +2 -0
  4. package/dist/scrape/index.js +1 -0
  5. package/dist/scrape/spider.d.ts +61 -0
  6. package/dist/scrape/spider.js +250 -0
  7. package/dist/seo/analyzer.js +27 -0
  8. package/dist/seo/index.d.ts +3 -1
  9. package/dist/seo/index.js +1 -0
  10. package/dist/seo/rules/accessibility.js +620 -54
  11. package/dist/seo/rules/best-practices.d.ts +2 -0
  12. package/dist/seo/rules/best-practices.js +188 -0
  13. package/dist/seo/rules/crawl.d.ts +2 -0
  14. package/dist/seo/rules/crawl.js +307 -0
  15. package/dist/seo/rules/cwv.d.ts +2 -0
  16. package/dist/seo/rules/cwv.js +337 -0
  17. package/dist/seo/rules/ecommerce.d.ts +2 -0
  18. package/dist/seo/rules/ecommerce.js +252 -0
  19. package/dist/seo/rules/i18n.d.ts +2 -0
  20. package/dist/seo/rules/i18n.js +222 -0
  21. package/dist/seo/rules/index.d.ts +32 -0
  22. package/dist/seo/rules/index.js +71 -0
  23. package/dist/seo/rules/internal-linking.d.ts +2 -0
  24. package/dist/seo/rules/internal-linking.js +375 -0
  25. package/dist/seo/rules/local.d.ts +2 -0
  26. package/dist/seo/rules/local.js +265 -0
  27. package/dist/seo/rules/pwa.d.ts +2 -0
  28. package/dist/seo/rules/pwa.js +302 -0
  29. package/dist/seo/rules/readability.d.ts +2 -0
  30. package/dist/seo/rules/readability.js +255 -0
  31. package/dist/seo/rules/security.js +406 -28
  32. package/dist/seo/rules/social.d.ts +2 -0
  33. package/dist/seo/rules/social.js +373 -0
  34. package/dist/seo/rules/types.d.ts +155 -0
  35. package/dist/seo/seo-spider.d.ts +47 -0
  36. package/dist/seo/seo-spider.js +362 -0
  37. package/dist/seo/types.d.ts +24 -0
  38. 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
+ }
@@ -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;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "recker",
3
- "version": "1.0.28",
3
+ "version": "1.0.29",
4
4
  "description": "AI & DevX focused HTTP client for Node.js 18+",
5
5
  "main": "./dist/index.js",
6
6
  "types": "./dist/index.d.ts",