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,375 @@
|
|
|
1
|
+
import { createResult } from './types.js';
|
|
2
|
+
export const internalLinkingRules = [
|
|
3
|
+
{
|
|
4
|
+
id: 'linking-internal-count',
|
|
5
|
+
name: 'Internal Link Count',
|
|
6
|
+
category: 'links',
|
|
7
|
+
severity: 'warning',
|
|
8
|
+
description: 'Pages should have a healthy number of internal links',
|
|
9
|
+
check: (ctx) => {
|
|
10
|
+
if (ctx.internalLinks === undefined)
|
|
11
|
+
return null;
|
|
12
|
+
const count = ctx.internalLinks;
|
|
13
|
+
if (count === 0) {
|
|
14
|
+
return createResult({ id: 'linking-internal-count', name: 'Internal Link Count', category: 'links', severity: 'warning' }, 'warn', 'No internal links found', {
|
|
15
|
+
recommendation: 'Add internal links to improve navigation and crawlability',
|
|
16
|
+
evidence: {
|
|
17
|
+
found: 0,
|
|
18
|
+
expected: 'At least 3-5 internal links per page',
|
|
19
|
+
impact: 'Pages without internal links are harder to discover and may not pass link equity',
|
|
20
|
+
},
|
|
21
|
+
});
|
|
22
|
+
}
|
|
23
|
+
if (count < 3) {
|
|
24
|
+
return createResult({ id: 'linking-internal-count', name: 'Internal Link Count', category: 'links', severity: 'warning' }, 'info', `Only ${count} internal link(s)`, {
|
|
25
|
+
recommendation: 'Consider adding more internal links',
|
|
26
|
+
evidence: {
|
|
27
|
+
found: count,
|
|
28
|
+
expected: 'At least 3-5 internal links',
|
|
29
|
+
},
|
|
30
|
+
});
|
|
31
|
+
}
|
|
32
|
+
return createResult({ id: 'linking-internal-count', name: 'Internal Link Count', category: 'links', severity: 'warning' }, 'pass', `${count} internal links`);
|
|
33
|
+
},
|
|
34
|
+
},
|
|
35
|
+
{
|
|
36
|
+
id: 'linking-internal-ratio',
|
|
37
|
+
name: 'Internal/External Link Ratio',
|
|
38
|
+
category: 'links',
|
|
39
|
+
severity: 'info',
|
|
40
|
+
description: 'Pages should have more internal than external links',
|
|
41
|
+
check: (ctx) => {
|
|
42
|
+
if (ctx.internalLinks === undefined || ctx.externalLinks === undefined)
|
|
43
|
+
return null;
|
|
44
|
+
if (ctx.totalLinks === undefined || ctx.totalLinks === 0)
|
|
45
|
+
return null;
|
|
46
|
+
const internal = ctx.internalLinks;
|
|
47
|
+
const external = ctx.externalLinks;
|
|
48
|
+
const ratio = internal / (external || 1);
|
|
49
|
+
if (external > internal && external > 5) {
|
|
50
|
+
return createResult({ id: 'linking-internal-ratio', name: 'Internal/External Link Ratio', category: 'links', severity: 'info' }, 'info', `More external (${external}) than internal (${internal}) links`, {
|
|
51
|
+
recommendation: 'Consider adding more internal links for better link equity',
|
|
52
|
+
evidence: {
|
|
53
|
+
found: `${internal} internal, ${external} external`,
|
|
54
|
+
expected: 'Internal links should exceed external links',
|
|
55
|
+
impact: 'External links pass PageRank to other sites',
|
|
56
|
+
},
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
return createResult({ id: 'linking-internal-ratio', name: 'Internal/External Link Ratio', category: 'links', severity: 'info' }, 'pass', `Ratio: ${ratio.toFixed(1)}:1 (internal:external)`);
|
|
60
|
+
},
|
|
61
|
+
},
|
|
62
|
+
{
|
|
63
|
+
id: 'linking-anchor-diversity',
|
|
64
|
+
name: 'Anchor Text Diversity',
|
|
65
|
+
category: 'links',
|
|
66
|
+
severity: 'info',
|
|
67
|
+
description: 'Internal links should use diverse, descriptive anchor text',
|
|
68
|
+
check: (ctx) => {
|
|
69
|
+
if (!ctx.allLinks || ctx.allLinks.length === 0)
|
|
70
|
+
return null;
|
|
71
|
+
const internalLinks = ctx.allLinks.filter(l => l.type === 'internal');
|
|
72
|
+
if (internalLinks.length < 3)
|
|
73
|
+
return null;
|
|
74
|
+
const anchorCounts = {};
|
|
75
|
+
for (const link of internalLinks) {
|
|
76
|
+
const anchor = (link.text || '').toLowerCase().trim();
|
|
77
|
+
if (anchor && anchor.length > 2) {
|
|
78
|
+
anchorCounts[anchor] = (anchorCounts[anchor] || 0) + 1;
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
const overused = Object.entries(anchorCounts)
|
|
82
|
+
.filter(([_, count]) => count >= 3)
|
|
83
|
+
.map(([anchor, count]) => `"${anchor}" (${count}x)`);
|
|
84
|
+
if (overused.length > 0) {
|
|
85
|
+
return createResult({ id: 'linking-anchor-diversity', name: 'Anchor Text Diversity', category: 'links', severity: 'info' }, 'info', `Some anchor texts overused`, {
|
|
86
|
+
recommendation: 'Vary anchor text for better SEO signal distribution',
|
|
87
|
+
evidence: {
|
|
88
|
+
found: overused.slice(0, 3),
|
|
89
|
+
impact: 'Repetitive anchors may look spammy to search engines',
|
|
90
|
+
},
|
|
91
|
+
});
|
|
92
|
+
}
|
|
93
|
+
return createResult({ id: 'linking-anchor-diversity', name: 'Anchor Text Diversity', category: 'links', severity: 'info' }, 'pass', 'Good anchor text diversity');
|
|
94
|
+
},
|
|
95
|
+
},
|
|
96
|
+
{
|
|
97
|
+
id: 'linking-deep-links',
|
|
98
|
+
name: 'Deep Linking',
|
|
99
|
+
category: 'links',
|
|
100
|
+
severity: 'info',
|
|
101
|
+
description: 'Pages should link to deep content, not just homepage',
|
|
102
|
+
check: (ctx) => {
|
|
103
|
+
if (!ctx.allLinks || ctx.allLinks.length === 0)
|
|
104
|
+
return null;
|
|
105
|
+
const internalLinks = ctx.allLinks.filter(l => l.type === 'internal');
|
|
106
|
+
if (internalLinks.length === 0)
|
|
107
|
+
return null;
|
|
108
|
+
let rootLinks = 0;
|
|
109
|
+
let deepLinks = 0;
|
|
110
|
+
for (const link of internalLinks) {
|
|
111
|
+
try {
|
|
112
|
+
const url = new URL(link.href, ctx.url);
|
|
113
|
+
if (url.pathname === '/' || url.pathname === '') {
|
|
114
|
+
rootLinks++;
|
|
115
|
+
}
|
|
116
|
+
else {
|
|
117
|
+
deepLinks++;
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
catch {
|
|
121
|
+
continue;
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
const deepRatio = deepLinks / internalLinks.length;
|
|
125
|
+
if (rootLinks > deepLinks && internalLinks.length > 5) {
|
|
126
|
+
return createResult({ id: 'linking-deep-links', name: 'Deep Linking', category: 'links', severity: 'info' }, 'info', `Most internal links go to homepage`, {
|
|
127
|
+
recommendation: 'Add more links to inner pages',
|
|
128
|
+
evidence: {
|
|
129
|
+
found: `${rootLinks} homepage links, ${deepLinks} deep links`,
|
|
130
|
+
expected: 'More deep links than homepage links',
|
|
131
|
+
impact: 'Deep linking improves crawlability of inner pages',
|
|
132
|
+
},
|
|
133
|
+
});
|
|
134
|
+
}
|
|
135
|
+
return createResult({ id: 'linking-deep-links', name: 'Deep Linking', category: 'links', severity: 'info' }, 'pass', `${Math.round(deepRatio * 100)}% deep links`);
|
|
136
|
+
},
|
|
137
|
+
},
|
|
138
|
+
{
|
|
139
|
+
id: 'linking-nav-links',
|
|
140
|
+
name: 'Navigation Links',
|
|
141
|
+
category: 'links',
|
|
142
|
+
severity: 'info',
|
|
143
|
+
description: 'Check for proper navigation link structure',
|
|
144
|
+
check: (ctx) => {
|
|
145
|
+
if (!ctx.hasNav)
|
|
146
|
+
return null;
|
|
147
|
+
if (ctx.navLinkCount === undefined)
|
|
148
|
+
return null;
|
|
149
|
+
if (ctx.navLinkCount === 0) {
|
|
150
|
+
return createResult({ id: 'linking-nav-links', name: 'Navigation Links', category: 'links', severity: 'info' }, 'warn', 'Navigation element has no links', {
|
|
151
|
+
recommendation: 'Add links to navigation for user experience and SEO',
|
|
152
|
+
evidence: {
|
|
153
|
+
found: '<nav> element with no links',
|
|
154
|
+
expected: 'Navigation should contain meaningful links',
|
|
155
|
+
},
|
|
156
|
+
});
|
|
157
|
+
}
|
|
158
|
+
if (ctx.navLinkCount > 20) {
|
|
159
|
+
return createResult({ id: 'linking-nav-links', name: 'Navigation Links', category: 'links', severity: 'info' }, 'info', `Navigation has ${ctx.navLinkCount} links (high)`, {
|
|
160
|
+
recommendation: 'Consider simplifying navigation',
|
|
161
|
+
evidence: {
|
|
162
|
+
found: ctx.navLinkCount,
|
|
163
|
+
expected: 'Under 20 links for optimal UX',
|
|
164
|
+
impact: 'Too many nav links may dilute link equity',
|
|
165
|
+
},
|
|
166
|
+
});
|
|
167
|
+
}
|
|
168
|
+
return createResult({ id: 'linking-nav-links', name: 'Navigation Links', category: 'links', severity: 'info' }, 'pass', `${ctx.navLinkCount} navigation links`);
|
|
169
|
+
},
|
|
170
|
+
},
|
|
171
|
+
{
|
|
172
|
+
id: 'linking-footer-links',
|
|
173
|
+
name: 'Footer Links',
|
|
174
|
+
category: 'links',
|
|
175
|
+
severity: 'info',
|
|
176
|
+
description: 'Footer should contain important site-wide links',
|
|
177
|
+
check: (ctx) => {
|
|
178
|
+
if (!ctx.hasFooter)
|
|
179
|
+
return null;
|
|
180
|
+
if (ctx.footerLinkCount === undefined)
|
|
181
|
+
return null;
|
|
182
|
+
if (ctx.footerLinkCount === 0) {
|
|
183
|
+
return createResult({ id: 'linking-footer-links', name: 'Footer Links', category: 'links', severity: 'info' }, 'info', 'Footer has no links', {
|
|
184
|
+
recommendation: 'Add important links to footer',
|
|
185
|
+
evidence: {
|
|
186
|
+
expected: 'Links to privacy policy, terms, contact, sitemap',
|
|
187
|
+
},
|
|
188
|
+
});
|
|
189
|
+
}
|
|
190
|
+
if (ctx.footerLinkCount > 50) {
|
|
191
|
+
return createResult({ id: 'linking-footer-links', name: 'Footer Links', category: 'links', severity: 'info' }, 'info', `Footer has ${ctx.footerLinkCount} links (excessive)`, {
|
|
192
|
+
recommendation: 'Reduce footer links to essential pages',
|
|
193
|
+
evidence: {
|
|
194
|
+
found: ctx.footerLinkCount,
|
|
195
|
+
impact: 'Excessive footer links may be seen as link spam',
|
|
196
|
+
},
|
|
197
|
+
});
|
|
198
|
+
}
|
|
199
|
+
return createResult({ id: 'linking-footer-links', name: 'Footer Links', category: 'links', severity: 'info' }, 'pass', `${ctx.footerLinkCount} footer links`);
|
|
200
|
+
},
|
|
201
|
+
},
|
|
202
|
+
{
|
|
203
|
+
id: 'linking-contextual',
|
|
204
|
+
name: 'Contextual Links',
|
|
205
|
+
category: 'links',
|
|
206
|
+
severity: 'info',
|
|
207
|
+
description: 'Check for in-content contextual links',
|
|
208
|
+
check: (ctx) => {
|
|
209
|
+
if (ctx.contextualLinkCount === undefined)
|
|
210
|
+
return null;
|
|
211
|
+
if (!ctx.wordCount || ctx.wordCount < 300)
|
|
212
|
+
return null;
|
|
213
|
+
const count = ctx.contextualLinkCount;
|
|
214
|
+
if (count === 0) {
|
|
215
|
+
return createResult({ id: 'linking-contextual', name: 'Contextual Links', category: 'links', severity: 'info' }, 'info', 'No contextual links in content', {
|
|
216
|
+
recommendation: 'Add links within body content to related pages',
|
|
217
|
+
evidence: {
|
|
218
|
+
expected: 'At least 2-3 contextual links per 500 words',
|
|
219
|
+
impact: 'Contextual links pass more link equity than navigation links',
|
|
220
|
+
},
|
|
221
|
+
});
|
|
222
|
+
}
|
|
223
|
+
const linksPerWords = (count / ctx.wordCount) * 500;
|
|
224
|
+
if (linksPerWords < 1 && ctx.wordCount > 500) {
|
|
225
|
+
return createResult({ id: 'linking-contextual', name: 'Contextual Links', category: 'links', severity: 'info' }, 'info', `Only ${count} contextual link(s) in ${ctx.wordCount} words`, {
|
|
226
|
+
recommendation: 'Add more in-content links',
|
|
227
|
+
evidence: {
|
|
228
|
+
found: `${linksPerWords.toFixed(1)} links per 500 words`,
|
|
229
|
+
expected: '2-3 links per 500 words',
|
|
230
|
+
},
|
|
231
|
+
});
|
|
232
|
+
}
|
|
233
|
+
return createResult({ id: 'linking-contextual', name: 'Contextual Links', category: 'links', severity: 'info' }, 'pass', `${count} contextual links`);
|
|
234
|
+
},
|
|
235
|
+
},
|
|
236
|
+
{
|
|
237
|
+
id: 'linking-orphan-page',
|
|
238
|
+
name: 'Orphan Page Detection',
|
|
239
|
+
category: 'links',
|
|
240
|
+
severity: 'warning',
|
|
241
|
+
description: 'Pages should be linked from other pages on the site',
|
|
242
|
+
check: (ctx) => {
|
|
243
|
+
if (ctx.incomingInternalLinks === undefined)
|
|
244
|
+
return null;
|
|
245
|
+
if (ctx.incomingInternalLinks === 0) {
|
|
246
|
+
return createResult({ id: 'linking-orphan-page', name: 'Orphan Page Detection', category: 'links', severity: 'warning' }, 'warn', 'Page may be an orphan (no incoming internal links)', {
|
|
247
|
+
recommendation: 'Link to this page from other pages on your site',
|
|
248
|
+
evidence: {
|
|
249
|
+
found: '0 incoming internal links detected',
|
|
250
|
+
impact: 'Orphan pages are harder for search engines to discover',
|
|
251
|
+
learnMore: 'https://ahrefs.com/blog/orphan-pages/',
|
|
252
|
+
},
|
|
253
|
+
});
|
|
254
|
+
}
|
|
255
|
+
return createResult({ id: 'linking-orphan-page', name: 'Orphan Page Detection', category: 'links', severity: 'warning' }, 'pass', `${ctx.incomingInternalLinks} incoming internal link(s)`);
|
|
256
|
+
},
|
|
257
|
+
},
|
|
258
|
+
{
|
|
259
|
+
id: 'linking-self-referencing',
|
|
260
|
+
name: 'Self-Referencing Links',
|
|
261
|
+
category: 'links',
|
|
262
|
+
severity: 'info',
|
|
263
|
+
description: 'Avoid excessive self-referencing links',
|
|
264
|
+
check: (ctx) => {
|
|
265
|
+
if (ctx.selfReferencingLinks === undefined)
|
|
266
|
+
return null;
|
|
267
|
+
if (ctx.selfReferencingLinks > 3) {
|
|
268
|
+
return createResult({ id: 'linking-self-referencing', name: 'Self-Referencing Links', category: 'links', severity: 'info' }, 'info', `${ctx.selfReferencingLinks} self-referencing links`, {
|
|
269
|
+
recommendation: 'Reduce links that point to the current page',
|
|
270
|
+
evidence: {
|
|
271
|
+
found: ctx.selfReferencingLinks,
|
|
272
|
+
expected: '0-1 self-referencing links (e.g., canonical only)',
|
|
273
|
+
impact: 'Self-links waste crawl budget and confuse users',
|
|
274
|
+
},
|
|
275
|
+
});
|
|
276
|
+
}
|
|
277
|
+
return null;
|
|
278
|
+
},
|
|
279
|
+
},
|
|
280
|
+
{
|
|
281
|
+
id: 'linking-broken-internal',
|
|
282
|
+
name: 'Broken Internal Links',
|
|
283
|
+
category: 'links',
|
|
284
|
+
severity: 'error',
|
|
285
|
+
description: 'Internal links should not be broken',
|
|
286
|
+
check: (ctx) => {
|
|
287
|
+
if (ctx.brokenInternalLinks === undefined)
|
|
288
|
+
return null;
|
|
289
|
+
if (ctx.brokenInternalLinks > 0) {
|
|
290
|
+
return createResult({ id: 'linking-broken-internal', name: 'Broken Internal Links', category: 'links', severity: 'error' }, 'fail', `${ctx.brokenInternalLinks} broken internal link(s)`, {
|
|
291
|
+
recommendation: 'Fix or remove broken internal links',
|
|
292
|
+
evidence: {
|
|
293
|
+
found: ctx.brokenInternalLinks,
|
|
294
|
+
expected: '0 broken links',
|
|
295
|
+
impact: 'Broken links waste crawl budget and harm user experience',
|
|
296
|
+
},
|
|
297
|
+
});
|
|
298
|
+
}
|
|
299
|
+
return createResult({ id: 'linking-broken-internal', name: 'Broken Internal Links', category: 'links', severity: 'error' }, 'pass', 'No broken internal links');
|
|
300
|
+
},
|
|
301
|
+
},
|
|
302
|
+
{
|
|
303
|
+
id: 'linking-redirect-chains',
|
|
304
|
+
name: 'Redirect Chains',
|
|
305
|
+
category: 'links',
|
|
306
|
+
severity: 'warning',
|
|
307
|
+
description: 'Internal links should not go through redirect chains',
|
|
308
|
+
check: (ctx) => {
|
|
309
|
+
if (ctx.redirectChainLinks === undefined)
|
|
310
|
+
return null;
|
|
311
|
+
if (ctx.redirectChainLinks > 0) {
|
|
312
|
+
return createResult({ id: 'linking-redirect-chains', name: 'Redirect Chains', category: 'links', severity: 'warning' }, 'warn', `${ctx.redirectChainLinks} link(s) go through redirects`, {
|
|
313
|
+
recommendation: 'Update links to point to final destination URLs',
|
|
314
|
+
evidence: {
|
|
315
|
+
found: ctx.redirectChainLinks,
|
|
316
|
+
expected: '0 redirect chain links',
|
|
317
|
+
impact: 'Redirect chains slow down crawling and lose link equity',
|
|
318
|
+
},
|
|
319
|
+
});
|
|
320
|
+
}
|
|
321
|
+
return createResult({ id: 'linking-redirect-chains', name: 'Redirect Chains', category: 'links', severity: 'warning' }, 'pass', 'No redirect chain links');
|
|
322
|
+
},
|
|
323
|
+
},
|
|
324
|
+
{
|
|
325
|
+
id: 'linking-nofollow-internal',
|
|
326
|
+
name: 'Nofollow Internal Links',
|
|
327
|
+
category: 'links',
|
|
328
|
+
severity: 'warning',
|
|
329
|
+
description: 'Internal links should not use nofollow',
|
|
330
|
+
check: (ctx) => {
|
|
331
|
+
if (!ctx.allLinks)
|
|
332
|
+
return null;
|
|
333
|
+
const nofollowInternal = ctx.allLinks.filter(l => l.type === 'internal' && l.rel?.includes('nofollow'));
|
|
334
|
+
if (nofollowInternal.length > 0) {
|
|
335
|
+
return createResult({ id: 'linking-nofollow-internal', name: 'Nofollow Internal Links', category: 'links', severity: 'warning' }, 'warn', `${nofollowInternal.length} internal link(s) have nofollow`, {
|
|
336
|
+
recommendation: 'Remove nofollow from internal links',
|
|
337
|
+
evidence: {
|
|
338
|
+
found: nofollowInternal.slice(0, 3).map(l => l.href),
|
|
339
|
+
impact: 'Nofollow on internal links wastes PageRank',
|
|
340
|
+
learnMore: 'https://developers.google.com/search/docs/crawling-indexing/qualify-outbound-links',
|
|
341
|
+
},
|
|
342
|
+
});
|
|
343
|
+
}
|
|
344
|
+
return createResult({ id: 'linking-nofollow-internal', name: 'Nofollow Internal Links', category: 'links', severity: 'warning' }, 'pass', 'No nofollow on internal links');
|
|
345
|
+
},
|
|
346
|
+
},
|
|
347
|
+
{
|
|
348
|
+
id: 'linking-click-depth',
|
|
349
|
+
name: 'Click Depth',
|
|
350
|
+
category: 'links',
|
|
351
|
+
severity: 'info',
|
|
352
|
+
description: 'Important pages should be reachable in few clicks',
|
|
353
|
+
check: (ctx) => {
|
|
354
|
+
if (ctx.pageClickDepth === undefined)
|
|
355
|
+
return null;
|
|
356
|
+
const depth = ctx.pageClickDepth;
|
|
357
|
+
if (depth > 4) {
|
|
358
|
+
return createResult({ id: 'linking-click-depth', name: 'Click Depth', category: 'links', severity: 'info' }, 'warn', `Page is ${depth} clicks from homepage`, {
|
|
359
|
+
recommendation: 'Improve site architecture for better accessibility',
|
|
360
|
+
evidence: {
|
|
361
|
+
found: `${depth} clicks deep`,
|
|
362
|
+
expected: 'Under 4 clicks from homepage',
|
|
363
|
+
impact: 'Deep pages receive less crawl priority and link equity',
|
|
364
|
+
},
|
|
365
|
+
});
|
|
366
|
+
}
|
|
367
|
+
if (depth > 3) {
|
|
368
|
+
return createResult({ id: 'linking-click-depth', name: 'Click Depth', category: 'links', severity: 'info' }, 'info', `Page is ${depth} clicks from homepage`, {
|
|
369
|
+
recommendation: 'Consider adding shortcuts to this page',
|
|
370
|
+
});
|
|
371
|
+
}
|
|
372
|
+
return createResult({ id: 'linking-click-depth', name: 'Click Depth', category: 'links', severity: 'info' }, 'pass', `${depth} click(s) from homepage`);
|
|
373
|
+
},
|
|
374
|
+
},
|
|
375
|
+
];
|
|
@@ -0,0 +1,265 @@
|
|
|
1
|
+
import { createResult } from './types.js';
|
|
2
|
+
export const localRules = [
|
|
3
|
+
{
|
|
4
|
+
id: 'local-business-schema',
|
|
5
|
+
name: 'LocalBusiness Schema',
|
|
6
|
+
category: 'structured-data',
|
|
7
|
+
severity: 'info',
|
|
8
|
+
description: 'Local businesses should have LocalBusiness schema',
|
|
9
|
+
check: (ctx) => {
|
|
10
|
+
if (!ctx.jsonLdTypes)
|
|
11
|
+
return null;
|
|
12
|
+
const localBusinessTypes = [
|
|
13
|
+
'LocalBusiness', 'Restaurant', 'Store', 'MedicalBusiness',
|
|
14
|
+
'FinancialService', 'FoodEstablishment', 'HealthAndBeautyBusiness',
|
|
15
|
+
'HomeAndConstructionBusiness', 'LegalService', 'RealEstateAgent',
|
|
16
|
+
'SportingGoodsStore', 'AutoDealer', 'AutoRepair', 'Bakery',
|
|
17
|
+
'BarOrPub', 'BeautySalon', 'CafeOrCoffeeShop', 'Dentist',
|
|
18
|
+
'DryCleaningOrLaundry', 'Florist', 'GasStation', 'GroceryStore',
|
|
19
|
+
'HairSalon', 'HardwareStore', 'Hospital', 'Hotel', 'InsuranceAgency',
|
|
20
|
+
'LodgingBusiness', 'MovingCompany', 'Pharmacy', 'Physician',
|
|
21
|
+
'PlaceOfWorship', 'Plumber', 'RealEstateAgent', 'ShoppingCenter',
|
|
22
|
+
];
|
|
23
|
+
const hasLocalBusiness = ctx.jsonLdTypes.some(t => localBusinessTypes.includes(t));
|
|
24
|
+
if (!hasLocalBusiness && ctx.hasLocalBusinessSignals) {
|
|
25
|
+
return createResult({ id: 'local-business-schema', name: 'LocalBusiness Schema', category: 'structured-data', severity: 'info' }, 'info', 'Page may benefit from LocalBusiness schema', {
|
|
26
|
+
recommendation: 'Add LocalBusiness structured data for local search visibility',
|
|
27
|
+
evidence: {
|
|
28
|
+
expected: 'LocalBusiness schema with name, address, phone, hours',
|
|
29
|
+
example: `{
|
|
30
|
+
"@type": "LocalBusiness",
|
|
31
|
+
"name": "Business Name",
|
|
32
|
+
"address": {
|
|
33
|
+
"@type": "PostalAddress",
|
|
34
|
+
"streetAddress": "123 Main St",
|
|
35
|
+
"addressLocality": "City",
|
|
36
|
+
"addressRegion": "State",
|
|
37
|
+
"postalCode": "12345"
|
|
38
|
+
},
|
|
39
|
+
"telephone": "+1-234-567-8900"
|
|
40
|
+
}`,
|
|
41
|
+
learnMore: 'https://developers.google.com/search/docs/appearance/structured-data/local-business',
|
|
42
|
+
},
|
|
43
|
+
});
|
|
44
|
+
}
|
|
45
|
+
if (hasLocalBusiness) {
|
|
46
|
+
const foundTypes = ctx.jsonLdTypes.filter(t => localBusinessTypes.includes(t));
|
|
47
|
+
return createResult({ id: 'local-business-schema', name: 'LocalBusiness Schema', category: 'structured-data', severity: 'info' }, 'pass', `LocalBusiness schema found: ${foundTypes.join(', ')}`);
|
|
48
|
+
}
|
|
49
|
+
return null;
|
|
50
|
+
},
|
|
51
|
+
},
|
|
52
|
+
{
|
|
53
|
+
id: 'local-business-address',
|
|
54
|
+
name: 'Business Address',
|
|
55
|
+
category: 'structured-data',
|
|
56
|
+
severity: 'warning',
|
|
57
|
+
description: 'LocalBusiness schema should include complete address',
|
|
58
|
+
check: (ctx) => {
|
|
59
|
+
if (!ctx.localBusinessSchema)
|
|
60
|
+
return null;
|
|
61
|
+
const address = ctx.localBusinessSchema.address;
|
|
62
|
+
if (!address) {
|
|
63
|
+
return createResult({ id: 'local-business-address', name: 'Business Address', category: 'structured-data', severity: 'warning' }, 'warn', 'LocalBusiness schema missing address', {
|
|
64
|
+
recommendation: 'Add PostalAddress for Google Maps and local search',
|
|
65
|
+
evidence: {
|
|
66
|
+
expected: 'address with streetAddress, addressLocality, addressRegion, postalCode',
|
|
67
|
+
impact: 'Businesses without address may not appear in local pack results',
|
|
68
|
+
},
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
const missingFields = [];
|
|
72
|
+
if (!address.streetAddress)
|
|
73
|
+
missingFields.push('streetAddress');
|
|
74
|
+
if (!address.addressLocality)
|
|
75
|
+
missingFields.push('addressLocality (city)');
|
|
76
|
+
if (!address.addressRegion)
|
|
77
|
+
missingFields.push('addressRegion (state)');
|
|
78
|
+
if (!address.postalCode)
|
|
79
|
+
missingFields.push('postalCode');
|
|
80
|
+
if (missingFields.length > 0) {
|
|
81
|
+
return createResult({ id: 'local-business-address', name: 'Business Address', category: 'structured-data', severity: 'warning' }, 'warn', `Address incomplete: missing ${missingFields.join(', ')}`, {
|
|
82
|
+
recommendation: 'Complete address for better local SEO',
|
|
83
|
+
evidence: {
|
|
84
|
+
found: Object.keys(address).join(', '),
|
|
85
|
+
expected: 'streetAddress, addressLocality, addressRegion, postalCode, addressCountry',
|
|
86
|
+
},
|
|
87
|
+
});
|
|
88
|
+
}
|
|
89
|
+
return createResult({ id: 'local-business-address', name: 'Business Address', category: 'structured-data', severity: 'warning' }, 'pass', 'Complete address in LocalBusiness schema');
|
|
90
|
+
},
|
|
91
|
+
},
|
|
92
|
+
{
|
|
93
|
+
id: 'local-business-phone',
|
|
94
|
+
name: 'Business Phone',
|
|
95
|
+
category: 'structured-data',
|
|
96
|
+
severity: 'warning',
|
|
97
|
+
description: 'LocalBusiness schema should include phone number',
|
|
98
|
+
check: (ctx) => {
|
|
99
|
+
if (!ctx.localBusinessSchema)
|
|
100
|
+
return null;
|
|
101
|
+
const phone = ctx.localBusinessSchema.telephone;
|
|
102
|
+
if (!phone) {
|
|
103
|
+
return createResult({ id: 'local-business-phone', name: 'Business Phone', category: 'structured-data', severity: 'warning' }, 'warn', 'LocalBusiness schema missing phone number', {
|
|
104
|
+
recommendation: 'Add telephone for click-to-call functionality',
|
|
105
|
+
evidence: {
|
|
106
|
+
expected: 'telephone in E.164 format: +1-234-567-8900',
|
|
107
|
+
impact: 'Phone number is crucial for local search and mobile users',
|
|
108
|
+
},
|
|
109
|
+
});
|
|
110
|
+
}
|
|
111
|
+
const phoneStr = String(phone);
|
|
112
|
+
const hasCountryCode = phoneStr.startsWith('+');
|
|
113
|
+
if (!hasCountryCode) {
|
|
114
|
+
return createResult({ id: 'local-business-phone', name: 'Business Phone', category: 'structured-data', severity: 'warning' }, 'info', 'Phone number should include country code', {
|
|
115
|
+
evidence: {
|
|
116
|
+
found: phoneStr,
|
|
117
|
+
expected: 'E.164 format with country code: +1-234-567-8900',
|
|
118
|
+
},
|
|
119
|
+
});
|
|
120
|
+
}
|
|
121
|
+
return createResult({ id: 'local-business-phone', name: 'Business Phone', category: 'structured-data', severity: 'warning' }, 'pass', `Phone: ${phoneStr}`);
|
|
122
|
+
},
|
|
123
|
+
},
|
|
124
|
+
{
|
|
125
|
+
id: 'local-business-hours',
|
|
126
|
+
name: 'Business Hours',
|
|
127
|
+
category: 'structured-data',
|
|
128
|
+
severity: 'info',
|
|
129
|
+
description: 'LocalBusiness schema should include opening hours',
|
|
130
|
+
check: (ctx) => {
|
|
131
|
+
if (!ctx.localBusinessSchema)
|
|
132
|
+
return null;
|
|
133
|
+
const hours = ctx.localBusinessSchema.openingHoursSpecification ||
|
|
134
|
+
ctx.localBusinessSchema.openingHours;
|
|
135
|
+
if (!hours) {
|
|
136
|
+
return createResult({ id: 'local-business-hours', name: 'Business Hours', category: 'structured-data', severity: 'info' }, 'info', 'LocalBusiness schema missing opening hours', {
|
|
137
|
+
recommendation: 'Add openingHoursSpecification for "open now" filters',
|
|
138
|
+
evidence: {
|
|
139
|
+
example: `"openingHoursSpecification": [{
|
|
140
|
+
"@type": "OpeningHoursSpecification",
|
|
141
|
+
"dayOfWeek": ["Monday", "Tuesday", "Wednesday", "Thursday", "Friday"],
|
|
142
|
+
"opens": "09:00",
|
|
143
|
+
"closes": "17:00"
|
|
144
|
+
}]`,
|
|
145
|
+
impact: 'Opening hours help users find open businesses',
|
|
146
|
+
},
|
|
147
|
+
});
|
|
148
|
+
}
|
|
149
|
+
return createResult({ id: 'local-business-hours', name: 'Business Hours', category: 'structured-data', severity: 'info' }, 'pass', 'Opening hours specified');
|
|
150
|
+
},
|
|
151
|
+
},
|
|
152
|
+
{
|
|
153
|
+
id: 'local-business-geo',
|
|
154
|
+
name: 'Business Coordinates',
|
|
155
|
+
category: 'structured-data',
|
|
156
|
+
severity: 'info',
|
|
157
|
+
description: 'LocalBusiness schema should include geo coordinates',
|
|
158
|
+
check: (ctx) => {
|
|
159
|
+
if (!ctx.localBusinessSchema)
|
|
160
|
+
return null;
|
|
161
|
+
const geo = ctx.localBusinessSchema.geo;
|
|
162
|
+
if (!geo) {
|
|
163
|
+
return createResult({ id: 'local-business-geo', name: 'Business Coordinates', category: 'structured-data', severity: 'info' }, 'info', 'LocalBusiness schema missing geo coordinates', {
|
|
164
|
+
recommendation: 'Add latitude/longitude for precise map placement',
|
|
165
|
+
evidence: {
|
|
166
|
+
example: `"geo": {
|
|
167
|
+
"@type": "GeoCoordinates",
|
|
168
|
+
"latitude": "40.7128",
|
|
169
|
+
"longitude": "-74.0060"
|
|
170
|
+
}`,
|
|
171
|
+
impact: 'Coordinates ensure accurate Google Maps integration',
|
|
172
|
+
},
|
|
173
|
+
});
|
|
174
|
+
}
|
|
175
|
+
const lat = geo.latitude;
|
|
176
|
+
const lng = geo.longitude;
|
|
177
|
+
if (!lat || !lng) {
|
|
178
|
+
return createResult({ id: 'local-business-geo', name: 'Business Coordinates', category: 'structured-data', severity: 'info' }, 'warn', 'Geo coordinates incomplete', {
|
|
179
|
+
evidence: {
|
|
180
|
+
found: `lat: ${lat}, lng: ${lng}`,
|
|
181
|
+
expected: 'Both latitude and longitude required',
|
|
182
|
+
},
|
|
183
|
+
});
|
|
184
|
+
}
|
|
185
|
+
return createResult({ id: 'local-business-geo', name: 'Business Coordinates', category: 'structured-data', severity: 'info' }, 'pass', `Geo: ${lat}, ${lng}`);
|
|
186
|
+
},
|
|
187
|
+
},
|
|
188
|
+
{
|
|
189
|
+
id: 'local-nap-presence',
|
|
190
|
+
name: 'NAP Presence',
|
|
191
|
+
category: 'content',
|
|
192
|
+
severity: 'info',
|
|
193
|
+
description: 'Local business pages should display Name, Address, Phone (NAP)',
|
|
194
|
+
check: (ctx) => {
|
|
195
|
+
if (!ctx.hasLocalBusinessSignals)
|
|
196
|
+
return null;
|
|
197
|
+
const missing = [];
|
|
198
|
+
if (!ctx.hasPhoneOnPage)
|
|
199
|
+
missing.push('Phone');
|
|
200
|
+
if (!ctx.hasAddressOnPage)
|
|
201
|
+
missing.push('Address');
|
|
202
|
+
if (missing.length > 0) {
|
|
203
|
+
return createResult({ id: 'local-nap-presence', name: 'NAP Presence', category: 'content', severity: 'info' }, 'info', `Missing visible NAP elements: ${missing.join(', ')}`, {
|
|
204
|
+
recommendation: 'Display business name, address, and phone prominently on page',
|
|
205
|
+
evidence: {
|
|
206
|
+
issue: 'NAP should be visible to users, not just in schema',
|
|
207
|
+
impact: 'Visible NAP improves trust and local SEO consistency',
|
|
208
|
+
},
|
|
209
|
+
});
|
|
210
|
+
}
|
|
211
|
+
return createResult({ id: 'local-nap-presence', name: 'NAP Presence', category: 'content', severity: 'info' }, 'pass', 'NAP information visible on page');
|
|
212
|
+
},
|
|
213
|
+
},
|
|
214
|
+
{
|
|
215
|
+
id: 'local-service-area',
|
|
216
|
+
name: 'Service Area',
|
|
217
|
+
category: 'structured-data',
|
|
218
|
+
severity: 'info',
|
|
219
|
+
description: 'Service area businesses should specify their coverage',
|
|
220
|
+
check: (ctx) => {
|
|
221
|
+
if (!ctx.localBusinessSchema)
|
|
222
|
+
return null;
|
|
223
|
+
const isServiceArea = ctx.localBusinessSchema['@type'] === 'ServiceAreaBusiness' ||
|
|
224
|
+
ctx.localBusinessSchema.areaServed !== undefined;
|
|
225
|
+
if (!isServiceArea)
|
|
226
|
+
return null;
|
|
227
|
+
const areaServed = ctx.localBusinessSchema.areaServed;
|
|
228
|
+
if (!areaServed) {
|
|
229
|
+
return createResult({ id: 'local-service-area', name: 'Service Area', category: 'structured-data', severity: 'info' }, 'info', 'Service area business missing areaServed', {
|
|
230
|
+
recommendation: 'Define the geographic area you serve',
|
|
231
|
+
evidence: {
|
|
232
|
+
example: `"areaServed": {
|
|
233
|
+
"@type": "GeoCircle",
|
|
234
|
+
"geoMidpoint": { "latitude": "40.7128", "longitude": "-74.0060" },
|
|
235
|
+
"geoRadius": "50 mi"
|
|
236
|
+
}`,
|
|
237
|
+
},
|
|
238
|
+
});
|
|
239
|
+
}
|
|
240
|
+
return createResult({ id: 'local-service-area', name: 'Service Area', category: 'structured-data', severity: 'info' }, 'pass', 'Service area defined');
|
|
241
|
+
},
|
|
242
|
+
},
|
|
243
|
+
{
|
|
244
|
+
id: 'local-pricerange',
|
|
245
|
+
name: 'Price Range',
|
|
246
|
+
category: 'structured-data',
|
|
247
|
+
severity: 'info',
|
|
248
|
+
description: 'LocalBusiness can include price range for user expectations',
|
|
249
|
+
check: (ctx) => {
|
|
250
|
+
if (!ctx.localBusinessSchema)
|
|
251
|
+
return null;
|
|
252
|
+
const priceRange = ctx.localBusinessSchema.priceRange;
|
|
253
|
+
if (!priceRange) {
|
|
254
|
+
return createResult({ id: 'local-pricerange', name: 'Price Range', category: 'structured-data', severity: 'info' }, 'info', 'LocalBusiness missing price range', {
|
|
255
|
+
recommendation: 'Add priceRange (e.g., "$$", "$$$") for user expectations',
|
|
256
|
+
evidence: {
|
|
257
|
+
example: '"priceRange": "$$" (uses $ symbols: $, $$, $$$, $$$$)',
|
|
258
|
+
impact: 'Helps users filter by budget in search results',
|
|
259
|
+
},
|
|
260
|
+
});
|
|
261
|
+
}
|
|
262
|
+
return createResult({ id: 'local-pricerange', name: 'Price Range', category: 'structured-data', severity: 'info' }, 'pass', `Price range: ${priceRange}`);
|
|
263
|
+
},
|
|
264
|
+
},
|
|
265
|
+
];
|