recker 1.0.28-next.c61382b → 1.0.28
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/seo/analyzer.js +0 -12
- package/dist/seo/rules/index.d.ts +0 -32
- package/dist/seo/rules/index.js +0 -63
- package/dist/seo/rules/types.d.ts +0 -95
- package/package.json +1 -1
- package/dist/seo/rules/crawl.d.ts +0 -2
- package/dist/seo/rules/crawl.js +0 -307
- package/dist/seo/rules/cwv.d.ts +0 -2
- package/dist/seo/rules/cwv.js +0 -337
- package/dist/seo/rules/ecommerce.d.ts +0 -2
- package/dist/seo/rules/ecommerce.js +0 -252
- package/dist/seo/rules/i18n.d.ts +0 -2
- package/dist/seo/rules/i18n.js +0 -222
- package/dist/seo/rules/local.d.ts +0 -2
- package/dist/seo/rules/local.js +0 -265
- package/dist/seo/rules/readability.d.ts +0 -2
- package/dist/seo/rules/readability.js +0 -255
package/dist/seo/analyzer.js
CHANGED
|
@@ -72,16 +72,6 @@ export class SeoAnalyzer {
|
|
|
72
72
|
buildRuleContext(data) {
|
|
73
73
|
const { meta, og, twitter, jsonLd, headings, content, linkAnalysis, imageAnalysis, links } = data;
|
|
74
74
|
const htmlLang = this.$('html').attr('lang');
|
|
75
|
-
const hreflangTags = [];
|
|
76
|
-
this.$('link[rel="alternate"][hreflang]').each((_, el) => {
|
|
77
|
-
const $el = this.$(el);
|
|
78
|
-
const lang = $el.attr('hreflang');
|
|
79
|
-
const href = $el.attr('href');
|
|
80
|
-
if (lang && href) {
|
|
81
|
-
hreflangTags.push({ lang, href });
|
|
82
|
-
}
|
|
83
|
-
});
|
|
84
|
-
const ogLocale = this.$('meta[property="og:locale"]').attr('content');
|
|
85
75
|
const genericTexts = SEO_THRESHOLDS.links.genericTexts;
|
|
86
76
|
const genericTextLinks = links.filter((l) => {
|
|
87
77
|
const text = l.text?.toLowerCase().trim();
|
|
@@ -206,8 +196,6 @@ export class SeoAnalyzer {
|
|
|
206
196
|
titleMatchesH1: meta.title && h1Text ? meta.title.toLowerCase().trim() === h1Text.toLowerCase().trim() : undefined,
|
|
207
197
|
...this.analyzeUrlQuality(),
|
|
208
198
|
...this.analyzeJsRendering(content),
|
|
209
|
-
hreflangTags: hreflangTags.length > 0 ? hreflangTags : undefined,
|
|
210
|
-
ogLocale,
|
|
211
199
|
};
|
|
212
200
|
}
|
|
213
201
|
analyzeUrlQuality() {
|
|
@@ -2,38 +2,6 @@ import { SeoRule, RuleCategory, RuleSeverity, RuleContext, RuleResult } from './
|
|
|
2
2
|
export * from './types.js';
|
|
3
3
|
export * from './thresholds.js';
|
|
4
4
|
export declare const ALL_SEO_RULES: SeoRule[];
|
|
5
|
-
export declare const SCORING_WEIGHTS: {
|
|
6
|
-
readonly severity: {
|
|
7
|
-
readonly error: {
|
|
8
|
-
readonly pass: 10;
|
|
9
|
-
readonly fail: -15;
|
|
10
|
-
readonly warn: -10;
|
|
11
|
-
readonly info: 0;
|
|
12
|
-
};
|
|
13
|
-
readonly warning: {
|
|
14
|
-
readonly pass: 5;
|
|
15
|
-
readonly fail: -8;
|
|
16
|
-
readonly warn: -5;
|
|
17
|
-
readonly info: 0;
|
|
18
|
-
};
|
|
19
|
-
readonly info: {
|
|
20
|
-
readonly pass: 2;
|
|
21
|
-
readonly fail: -3;
|
|
22
|
-
readonly warn: -2;
|
|
23
|
-
readonly info: 0;
|
|
24
|
-
};
|
|
25
|
-
};
|
|
26
|
-
readonly category: Record<RuleCategory, number>;
|
|
27
|
-
};
|
|
28
|
-
export declare function calculateWeightedScore(results: RuleResult[]): {
|
|
29
|
-
score: number;
|
|
30
|
-
maxPossible: number;
|
|
31
|
-
details: {
|
|
32
|
-
category: RuleCategory;
|
|
33
|
-
score: number;
|
|
34
|
-
weight: number;
|
|
35
|
-
}[];
|
|
36
|
-
};
|
|
37
5
|
export interface RulesEngineOptions {
|
|
38
6
|
categories?: RuleCategory[];
|
|
39
7
|
excludeCategories?: RuleCategory[];
|
package/dist/seo/rules/index.js
CHANGED
|
@@ -9,12 +9,6 @@ import { securityRules } from './security.js';
|
|
|
9
9
|
import { schemaRules } from './schema.js';
|
|
10
10
|
import { accessibilityRules } from './accessibility.js';
|
|
11
11
|
import { mobileRules } from './mobile.js';
|
|
12
|
-
import { i18nRules } from './i18n.js';
|
|
13
|
-
import { ecommerceRules } from './ecommerce.js';
|
|
14
|
-
import { localRules } from './local.js';
|
|
15
|
-
import { cwvRules } from './cwv.js';
|
|
16
|
-
import { crawlRules } from './crawl.js';
|
|
17
|
-
import { readabilityRules } from './readability.js';
|
|
18
12
|
export * from './types.js';
|
|
19
13
|
export * from './thresholds.js';
|
|
20
14
|
export const ALL_SEO_RULES = [
|
|
@@ -29,64 +23,7 @@ export const ALL_SEO_RULES = [
|
|
|
29
23
|
...schemaRules,
|
|
30
24
|
...accessibilityRules,
|
|
31
25
|
...mobileRules,
|
|
32
|
-
...i18nRules,
|
|
33
|
-
...ecommerceRules,
|
|
34
|
-
...localRules,
|
|
35
|
-
...cwvRules,
|
|
36
|
-
...crawlRules,
|
|
37
|
-
...readabilityRules,
|
|
38
26
|
];
|
|
39
|
-
export const SCORING_WEIGHTS = {
|
|
40
|
-
severity: {
|
|
41
|
-
error: { pass: 10, fail: -15, warn: -10, info: 0 },
|
|
42
|
-
warning: { pass: 5, fail: -8, warn: -5, info: 0 },
|
|
43
|
-
info: { pass: 2, fail: -3, warn: -2, info: 0 },
|
|
44
|
-
},
|
|
45
|
-
category: {
|
|
46
|
-
title: 1.5,
|
|
47
|
-
meta: 1.3,
|
|
48
|
-
og: 1.0,
|
|
49
|
-
twitter: 0.8,
|
|
50
|
-
headings: 1.2,
|
|
51
|
-
images: 1.0,
|
|
52
|
-
links: 1.1,
|
|
53
|
-
content: 1.2,
|
|
54
|
-
technical: 1.3,
|
|
55
|
-
security: 0.9,
|
|
56
|
-
mobile: 1.2,
|
|
57
|
-
'structured-data': 1.0,
|
|
58
|
-
performance: 1.4,
|
|
59
|
-
accessibility: 0.8,
|
|
60
|
-
},
|
|
61
|
-
};
|
|
62
|
-
export function calculateWeightedScore(results) {
|
|
63
|
-
const categoryScores = {};
|
|
64
|
-
for (const result of results) {
|
|
65
|
-
const severityWeight = SCORING_WEIGHTS.severity[result.severity];
|
|
66
|
-
const statusScore = severityWeight[result.status] ?? 0;
|
|
67
|
-
if (!categoryScores[result.category]) {
|
|
68
|
-
categoryScores[result.category] = { score: 0, count: 0 };
|
|
69
|
-
}
|
|
70
|
-
categoryScores[result.category].score += statusScore;
|
|
71
|
-
categoryScores[result.category].count++;
|
|
72
|
-
}
|
|
73
|
-
let totalScore = 0;
|
|
74
|
-
let totalWeight = 0;
|
|
75
|
-
const details = [];
|
|
76
|
-
for (const [cat, data] of Object.entries(categoryScores)) {
|
|
77
|
-
const category = cat;
|
|
78
|
-
const categoryWeight = SCORING_WEIGHTS.category[category] ?? 1;
|
|
79
|
-
const normalizedScore = data.count > 0 ? data.score / data.count : 0;
|
|
80
|
-
const weightedScore = normalizedScore * categoryWeight;
|
|
81
|
-
details.push({ category, score: normalizedScore, weight: categoryWeight });
|
|
82
|
-
totalScore += weightedScore;
|
|
83
|
-
totalWeight += categoryWeight;
|
|
84
|
-
}
|
|
85
|
-
const maxPossible = 100;
|
|
86
|
-
const baseScore = 70;
|
|
87
|
-
const adjustedScore = Math.max(0, Math.min(100, baseScore + totalScore));
|
|
88
|
-
return { score: Math.round(adjustedScore), maxPossible, details };
|
|
89
|
-
}
|
|
90
27
|
export class SeoRulesEngine {
|
|
91
28
|
rules;
|
|
92
29
|
constructor(options = {}) {
|
|
@@ -134,12 +134,6 @@ export interface RuleContext {
|
|
|
134
134
|
jsonLdTypes?: string[];
|
|
135
135
|
url?: string;
|
|
136
136
|
urlLength?: number;
|
|
137
|
-
hreflangTags?: Array<{
|
|
138
|
-
lang: string;
|
|
139
|
-
href: string;
|
|
140
|
-
}>;
|
|
141
|
-
ogLocale?: string;
|
|
142
|
-
alternateLanguages?: string[];
|
|
143
137
|
titleMatchesH1?: boolean;
|
|
144
138
|
urlHasUppercase?: boolean;
|
|
145
139
|
urlHasSpecialChars?: boolean;
|
|
@@ -159,95 +153,6 @@ export interface RuleContext {
|
|
|
159
153
|
htmlSize?: number;
|
|
160
154
|
compressedSize?: number;
|
|
161
155
|
isCompressed?: boolean;
|
|
162
|
-
isProductPage?: boolean;
|
|
163
|
-
productSchema?: {
|
|
164
|
-
name?: string;
|
|
165
|
-
image?: string | string[];
|
|
166
|
-
offers?: {
|
|
167
|
-
price?: number | string;
|
|
168
|
-
lowPrice?: number | string;
|
|
169
|
-
priceCurrency?: string;
|
|
170
|
-
availability?: string;
|
|
171
|
-
priceValidUntil?: string;
|
|
172
|
-
validFrom?: string;
|
|
173
|
-
validThrough?: string;
|
|
174
|
-
};
|
|
175
|
-
aggregateRating?: {
|
|
176
|
-
ratingValue?: number | string;
|
|
177
|
-
reviewCount?: number;
|
|
178
|
-
ratingCount?: number;
|
|
179
|
-
};
|
|
180
|
-
review?: unknown;
|
|
181
|
-
brand?: string | {
|
|
182
|
-
name?: string;
|
|
183
|
-
};
|
|
184
|
-
sku?: string;
|
|
185
|
-
gtin?: string;
|
|
186
|
-
gtin13?: string;
|
|
187
|
-
gtin14?: string;
|
|
188
|
-
gtin8?: string;
|
|
189
|
-
mpn?: string;
|
|
190
|
-
};
|
|
191
|
-
hasLocalBusinessSignals?: boolean;
|
|
192
|
-
localBusinessSchema?: {
|
|
193
|
-
'@type'?: string;
|
|
194
|
-
name?: string;
|
|
195
|
-
address?: {
|
|
196
|
-
streetAddress?: string;
|
|
197
|
-
addressLocality?: string;
|
|
198
|
-
addressRegion?: string;
|
|
199
|
-
postalCode?: string;
|
|
200
|
-
addressCountry?: string;
|
|
201
|
-
};
|
|
202
|
-
telephone?: string;
|
|
203
|
-
openingHoursSpecification?: unknown;
|
|
204
|
-
openingHours?: string | string[];
|
|
205
|
-
geo?: {
|
|
206
|
-
latitude?: number | string;
|
|
207
|
-
longitude?: number | string;
|
|
208
|
-
};
|
|
209
|
-
areaServed?: unknown;
|
|
210
|
-
priceRange?: string;
|
|
211
|
-
};
|
|
212
|
-
hasPhoneOnPage?: boolean;
|
|
213
|
-
hasAddressOnPage?: boolean;
|
|
214
|
-
lcpCandidate?: {
|
|
215
|
-
element?: string;
|
|
216
|
-
src?: string;
|
|
217
|
-
loading?: string;
|
|
218
|
-
fetchpriority?: string;
|
|
219
|
-
};
|
|
220
|
-
hasLcpPreload?: boolean;
|
|
221
|
-
webFonts?: Array<{
|
|
222
|
-
family?: string;
|
|
223
|
-
hasSwap?: boolean;
|
|
224
|
-
hasOptional?: boolean;
|
|
225
|
-
hasSizeAdjust?: boolean;
|
|
226
|
-
hasAscentOverride?: boolean;
|
|
227
|
-
}>;
|
|
228
|
-
renderBlockingStylesheets?: number;
|
|
229
|
-
renderBlockingScripts?: number;
|
|
230
|
-
hasAspectRatioCss?: boolean;
|
|
231
|
-
hasResponsiveImages?: boolean;
|
|
232
|
-
hasAdsWithoutReservedSpace?: boolean;
|
|
233
|
-
hasBannersWithoutMinHeight?: boolean;
|
|
234
|
-
hasInfiniteScroll?: boolean;
|
|
235
|
-
largeInlineScripts?: number;
|
|
236
|
-
inlineEventHandlers?: number;
|
|
237
|
-
hasHeavyAnimations?: boolean;
|
|
238
|
-
externalOrigins?: number;
|
|
239
|
-
hasCriticalResources?: boolean;
|
|
240
|
-
hasInlineCriticalCss?: boolean;
|
|
241
|
-
hasSitemapLink?: boolean;
|
|
242
|
-
sitemapUrl?: string;
|
|
243
|
-
robotsHasSitemap?: boolean;
|
|
244
|
-
isPaginatedPage?: boolean;
|
|
245
|
-
hasRelPrev?: boolean;
|
|
246
|
-
hasRelNext?: boolean;
|
|
247
|
-
passiveVoicePercentage?: number;
|
|
248
|
-
transitionWordPercentage?: number;
|
|
249
|
-
consecutiveSentenceStarts?: number;
|
|
250
|
-
complexWordPercentage?: number;
|
|
251
156
|
}
|
|
252
157
|
export interface RuleEvidence {
|
|
253
158
|
found?: string | number | string[];
|
package/package.json
CHANGED
package/dist/seo/rules/crawl.js
DELETED
|
@@ -1,307 +0,0 @@
|
|
|
1
|
-
import { createResult } from './types.js';
|
|
2
|
-
export const crawlRules = [
|
|
3
|
-
{
|
|
4
|
-
id: 'crawl-sitemap-reference',
|
|
5
|
-
name: 'Sitemap Reference',
|
|
6
|
-
category: 'technical',
|
|
7
|
-
severity: 'info',
|
|
8
|
-
description: 'Page should reference sitemap location',
|
|
9
|
-
check: (ctx) => {
|
|
10
|
-
if (ctx.hasSitemapLink) {
|
|
11
|
-
return createResult({ id: 'crawl-sitemap-reference', name: 'Sitemap Reference', category: 'technical', severity: 'info' }, 'pass', `Sitemap link found: ${ctx.sitemapUrl || 'referenced'}`);
|
|
12
|
-
}
|
|
13
|
-
if (ctx.robotsHasSitemap) {
|
|
14
|
-
return createResult({ id: 'crawl-sitemap-reference', name: 'Sitemap Reference', category: 'technical', severity: 'info' }, 'pass', 'Sitemap referenced in robots.txt');
|
|
15
|
-
}
|
|
16
|
-
return createResult({ id: 'crawl-sitemap-reference', name: 'Sitemap Reference', category: 'technical', severity: 'info' }, 'info', 'No sitemap reference found', {
|
|
17
|
-
recommendation: 'Add sitemap reference in robots.txt or HTML',
|
|
18
|
-
evidence: {
|
|
19
|
-
expected: 'Sitemap: https://example.com/sitemap.xml in robots.txt',
|
|
20
|
-
example: '<link rel="sitemap" type="application/xml" href="/sitemap.xml">',
|
|
21
|
-
impact: 'Helps search engines discover all pages',
|
|
22
|
-
learnMore: 'https://developers.google.com/search/docs/crawling-indexing/sitemaps/overview',
|
|
23
|
-
},
|
|
24
|
-
});
|
|
25
|
-
},
|
|
26
|
-
},
|
|
27
|
-
{
|
|
28
|
-
id: 'crawl-robots-noindex',
|
|
29
|
-
name: 'Robots Noindex',
|
|
30
|
-
category: 'technical',
|
|
31
|
-
severity: 'error',
|
|
32
|
-
description: 'Check if page is blocked from indexing',
|
|
33
|
-
check: (ctx) => {
|
|
34
|
-
if (!ctx.metaRobots)
|
|
35
|
-
return null;
|
|
36
|
-
const hasNoindex = ctx.metaRobots.some(r => r.toLowerCase().includes('noindex'));
|
|
37
|
-
if (hasNoindex) {
|
|
38
|
-
return createResult({ id: 'crawl-robots-noindex', name: 'Robots Noindex', category: 'technical', severity: 'error' }, 'fail', 'Page is set to noindex', {
|
|
39
|
-
evidence: {
|
|
40
|
-
found: ctx.metaRobots.join(', '),
|
|
41
|
-
issue: 'This page will NOT appear in search results',
|
|
42
|
-
impact: 'Remove noindex if this page should be indexed',
|
|
43
|
-
},
|
|
44
|
-
});
|
|
45
|
-
}
|
|
46
|
-
return null;
|
|
47
|
-
},
|
|
48
|
-
},
|
|
49
|
-
{
|
|
50
|
-
id: 'crawl-robots-nofollow',
|
|
51
|
-
name: 'Robots Nofollow',
|
|
52
|
-
category: 'technical',
|
|
53
|
-
severity: 'warning',
|
|
54
|
-
description: 'Check if page links are blocked from following',
|
|
55
|
-
check: (ctx) => {
|
|
56
|
-
if (!ctx.metaRobots)
|
|
57
|
-
return null;
|
|
58
|
-
const hasNofollow = ctx.metaRobots.some(r => r.toLowerCase().includes('nofollow'));
|
|
59
|
-
if (hasNofollow) {
|
|
60
|
-
return createResult({ id: 'crawl-robots-nofollow', name: 'Robots Nofollow', category: 'technical', severity: 'warning' }, 'warn', 'Page has nofollow directive', {
|
|
61
|
-
evidence: {
|
|
62
|
-
found: ctx.metaRobots.join(', '),
|
|
63
|
-
issue: 'Links on this page will not pass PageRank',
|
|
64
|
-
impact: 'Internal pages should usually not have nofollow',
|
|
65
|
-
},
|
|
66
|
-
});
|
|
67
|
-
}
|
|
68
|
-
return null;
|
|
69
|
-
},
|
|
70
|
-
},
|
|
71
|
-
{
|
|
72
|
-
id: 'crawl-robots-combined',
|
|
73
|
-
name: 'Robots Directives',
|
|
74
|
-
category: 'technical',
|
|
75
|
-
severity: 'info',
|
|
76
|
-
description: 'Review all robots meta directives',
|
|
77
|
-
check: (ctx) => {
|
|
78
|
-
if (!ctx.metaRobots || ctx.metaRobots.length === 0) {
|
|
79
|
-
return createResult({ id: 'crawl-robots-combined', name: 'Robots Directives', category: 'technical', severity: 'info' }, 'info', 'No robots meta tag (defaults to index, follow)', {
|
|
80
|
-
recommendation: 'Explicitly set robots directives if needed',
|
|
81
|
-
evidence: {
|
|
82
|
-
expected: '<meta name="robots" content="index, follow">',
|
|
83
|
-
impact: 'Default behavior allows full indexing and following',
|
|
84
|
-
},
|
|
85
|
-
});
|
|
86
|
-
}
|
|
87
|
-
const directives = ctx.metaRobots.join(', ').toLowerCase();
|
|
88
|
-
const hasIndex = directives.includes('index') && !directives.includes('noindex');
|
|
89
|
-
const hasNoindex = directives.includes('noindex');
|
|
90
|
-
if (hasIndex && hasNoindex) {
|
|
91
|
-
return createResult({ id: 'crawl-robots-combined', name: 'Robots Directives', category: 'technical', severity: 'info' }, 'warn', 'Conflicting robots directives detected', {
|
|
92
|
-
evidence: {
|
|
93
|
-
found: ctx.metaRobots.join(', '),
|
|
94
|
-
issue: 'Both index and noindex specified',
|
|
95
|
-
},
|
|
96
|
-
});
|
|
97
|
-
}
|
|
98
|
-
return createResult({ id: 'crawl-robots-combined', name: 'Robots Directives', category: 'technical', severity: 'info' }, 'pass', `Robots: ${ctx.metaRobots.join(', ')}`);
|
|
99
|
-
},
|
|
100
|
-
},
|
|
101
|
-
{
|
|
102
|
-
id: 'crawl-x-robots-tag',
|
|
103
|
-
name: 'X-Robots-Tag Header',
|
|
104
|
-
category: 'technical',
|
|
105
|
-
severity: 'warning',
|
|
106
|
-
description: 'Check X-Robots-Tag HTTP header for indexing directives',
|
|
107
|
-
check: (ctx) => {
|
|
108
|
-
if (!ctx.responseHeaders)
|
|
109
|
-
return null;
|
|
110
|
-
const xRobotsTag = ctx.responseHeaders['x-robots-tag'] ||
|
|
111
|
-
ctx.responseHeaders['X-Robots-Tag'];
|
|
112
|
-
if (!xRobotsTag)
|
|
113
|
-
return null;
|
|
114
|
-
const tagValue = Array.isArray(xRobotsTag) ? xRobotsTag.join(', ') : xRobotsTag;
|
|
115
|
-
if (tagValue.toLowerCase().includes('noindex')) {
|
|
116
|
-
return createResult({ id: 'crawl-x-robots-tag', name: 'X-Robots-Tag Header', category: 'technical', severity: 'warning' }, 'fail', 'X-Robots-Tag contains noindex', {
|
|
117
|
-
evidence: {
|
|
118
|
-
found: tagValue,
|
|
119
|
-
issue: 'HTTP header is blocking indexing',
|
|
120
|
-
impact: 'Page will not appear in search results',
|
|
121
|
-
},
|
|
122
|
-
});
|
|
123
|
-
}
|
|
124
|
-
return createResult({ id: 'crawl-x-robots-tag', name: 'X-Robots-Tag Header', category: 'technical', severity: 'warning' }, 'info', `X-Robots-Tag: ${tagValue}`);
|
|
125
|
-
},
|
|
126
|
-
},
|
|
127
|
-
{
|
|
128
|
-
id: 'crawl-canonical-present',
|
|
129
|
-
name: 'Canonical URL',
|
|
130
|
-
category: 'technical',
|
|
131
|
-
severity: 'warning',
|
|
132
|
-
description: 'Pages should have a canonical URL to prevent duplicate content',
|
|
133
|
-
check: (ctx) => {
|
|
134
|
-
if (!ctx.hasCanonical) {
|
|
135
|
-
return createResult({ id: 'crawl-canonical-present', name: 'Canonical URL', category: 'technical', severity: 'warning' }, 'warn', 'Missing canonical URL', {
|
|
136
|
-
recommendation: 'Add a canonical link to prevent duplicate content issues',
|
|
137
|
-
evidence: {
|
|
138
|
-
expected: '<link rel="canonical" href="https://example.com/page">',
|
|
139
|
-
impact: 'Without canonical, search engines may index multiple versions',
|
|
140
|
-
learnMore: 'https://developers.google.com/search/docs/crawling-indexing/canonicalization',
|
|
141
|
-
},
|
|
142
|
-
});
|
|
143
|
-
}
|
|
144
|
-
return createResult({ id: 'crawl-canonical-present', name: 'Canonical URL', category: 'technical', severity: 'warning' }, 'pass', `Canonical: ${ctx.canonicalUrl}`);
|
|
145
|
-
},
|
|
146
|
-
},
|
|
147
|
-
{
|
|
148
|
-
id: 'crawl-canonical-self',
|
|
149
|
-
name: 'Canonical Self-Reference',
|
|
150
|
-
category: 'technical',
|
|
151
|
-
severity: 'info',
|
|
152
|
-
description: 'Canonical should point to the current page or explicit alternate',
|
|
153
|
-
check: (ctx) => {
|
|
154
|
-
if (!ctx.hasCanonical || !ctx.canonicalUrl || !ctx.url)
|
|
155
|
-
return null;
|
|
156
|
-
const normalizeUrl = (url) => {
|
|
157
|
-
try {
|
|
158
|
-
const u = new URL(url);
|
|
159
|
-
let normalized = u.origin + u.pathname.replace(/\/$/, '');
|
|
160
|
-
return normalized.toLowerCase();
|
|
161
|
-
}
|
|
162
|
-
catch {
|
|
163
|
-
return url.toLowerCase().replace(/\/$/, '');
|
|
164
|
-
}
|
|
165
|
-
};
|
|
166
|
-
const currentNorm = normalizeUrl(ctx.url);
|
|
167
|
-
const canonicalNorm = normalizeUrl(ctx.canonicalUrl);
|
|
168
|
-
if (currentNorm !== canonicalNorm) {
|
|
169
|
-
return createResult({ id: 'crawl-canonical-self', name: 'Canonical Self-Reference', category: 'technical', severity: 'info' }, 'info', 'Canonical points to different URL', {
|
|
170
|
-
evidence: {
|
|
171
|
-
found: [`Current: ${ctx.url}`, `Canonical: ${ctx.canonicalUrl}`],
|
|
172
|
-
issue: 'This page canonicalizes to a different URL',
|
|
173
|
-
impact: 'Ensure this is intentional (e.g., www vs non-www consolidation)',
|
|
174
|
-
},
|
|
175
|
-
});
|
|
176
|
-
}
|
|
177
|
-
return null;
|
|
178
|
-
},
|
|
179
|
-
},
|
|
180
|
-
{
|
|
181
|
-
id: 'crawl-canonical-absolute',
|
|
182
|
-
name: 'Canonical Absolute URL',
|
|
183
|
-
category: 'technical',
|
|
184
|
-
severity: 'warning',
|
|
185
|
-
description: 'Canonical URL should be absolute, not relative',
|
|
186
|
-
check: (ctx) => {
|
|
187
|
-
if (!ctx.canonicalUrl)
|
|
188
|
-
return null;
|
|
189
|
-
const isAbsolute = ctx.canonicalUrl.startsWith('http://') ||
|
|
190
|
-
ctx.canonicalUrl.startsWith('https://');
|
|
191
|
-
if (!isAbsolute) {
|
|
192
|
-
return createResult({ id: 'crawl-canonical-absolute', name: 'Canonical Absolute URL', category: 'technical', severity: 'warning' }, 'warn', 'Canonical URL is relative', {
|
|
193
|
-
evidence: {
|
|
194
|
-
found: ctx.canonicalUrl,
|
|
195
|
-
expected: 'Absolute URL starting with https://',
|
|
196
|
-
impact: 'Relative canonicals may be misinterpreted',
|
|
197
|
-
},
|
|
198
|
-
});
|
|
199
|
-
}
|
|
200
|
-
return null;
|
|
201
|
-
},
|
|
202
|
-
},
|
|
203
|
-
{
|
|
204
|
-
id: 'crawl-canonical-https',
|
|
205
|
-
name: 'Canonical HTTPS',
|
|
206
|
-
category: 'technical',
|
|
207
|
-
severity: 'warning',
|
|
208
|
-
description: 'Canonical URL should use HTTPS',
|
|
209
|
-
check: (ctx) => {
|
|
210
|
-
if (!ctx.canonicalUrl)
|
|
211
|
-
return null;
|
|
212
|
-
if (ctx.canonicalUrl.startsWith('http://')) {
|
|
213
|
-
return createResult({ id: 'crawl-canonical-https', name: 'Canonical HTTPS', category: 'technical', severity: 'warning' }, 'warn', 'Canonical URL uses HTTP instead of HTTPS', {
|
|
214
|
-
evidence: {
|
|
215
|
-
found: ctx.canonicalUrl,
|
|
216
|
-
expected: 'HTTPS canonical URL',
|
|
217
|
-
impact: 'Google prefers HTTPS URLs for ranking',
|
|
218
|
-
},
|
|
219
|
-
});
|
|
220
|
-
}
|
|
221
|
-
return null;
|
|
222
|
-
},
|
|
223
|
-
},
|
|
224
|
-
{
|
|
225
|
-
id: 'crawl-url-parameters',
|
|
226
|
-
name: 'URL Parameters',
|
|
227
|
-
category: 'technical',
|
|
228
|
-
severity: 'info',
|
|
229
|
-
description: 'URLs with tracking parameters should have proper canonical',
|
|
230
|
-
check: (ctx) => {
|
|
231
|
-
if (!ctx.url)
|
|
232
|
-
return null;
|
|
233
|
-
try {
|
|
234
|
-
const url = new URL(ctx.url);
|
|
235
|
-
const trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'fbclid', 'gclid', 'ref'];
|
|
236
|
-
const hasTracking = trackingParams.some(p => url.searchParams.has(p));
|
|
237
|
-
if (hasTracking && !ctx.hasCanonical) {
|
|
238
|
-
return createResult({ id: 'crawl-url-parameters', name: 'URL Parameters', category: 'technical', severity: 'info' }, 'warn', 'URL has tracking parameters but no canonical', {
|
|
239
|
-
recommendation: 'Add canonical pointing to clean URL without parameters',
|
|
240
|
-
evidence: {
|
|
241
|
-
found: url.search,
|
|
242
|
-
expected: 'Canonical to base URL without tracking params',
|
|
243
|
-
impact: 'Tracking parameters can cause duplicate content',
|
|
244
|
-
},
|
|
245
|
-
});
|
|
246
|
-
}
|
|
247
|
-
}
|
|
248
|
-
catch {
|
|
249
|
-
}
|
|
250
|
-
return null;
|
|
251
|
-
},
|
|
252
|
-
},
|
|
253
|
-
{
|
|
254
|
-
id: 'crawl-pagination-rel',
|
|
255
|
-
name: 'Pagination Links',
|
|
256
|
-
category: 'technical',
|
|
257
|
-
severity: 'info',
|
|
258
|
-
description: 'Paginated content should use proper rel attributes',
|
|
259
|
-
check: (ctx) => {
|
|
260
|
-
if (!ctx.isPaginatedPage)
|
|
261
|
-
return null;
|
|
262
|
-
const hasPrevNext = ctx.hasRelPrev || ctx.hasRelNext;
|
|
263
|
-
if (!hasPrevNext) {
|
|
264
|
-
return createResult({ id: 'crawl-pagination-rel', name: 'Pagination Links', category: 'technical', severity: 'info' }, 'info', 'Paginated page missing rel="prev/next" (deprecated but still useful)', {
|
|
265
|
-
recommendation: 'Consider using rel="prev" and rel="next" for pagination',
|
|
266
|
-
evidence: {
|
|
267
|
-
example: '<link rel="prev" href="/page/1">\n<link rel="next" href="/page/3">',
|
|
268
|
-
impact: 'Helps search engines understand pagination structure',
|
|
269
|
-
learnMore: 'https://developers.google.com/search/docs/specialty/ecommerce/pagination-and-incremental-page-loading',
|
|
270
|
-
},
|
|
271
|
-
});
|
|
272
|
-
}
|
|
273
|
-
return createResult({ id: 'crawl-pagination-rel', name: 'Pagination Links', category: 'technical', severity: 'info' }, 'pass', 'Pagination links present');
|
|
274
|
-
},
|
|
275
|
-
},
|
|
276
|
-
{
|
|
277
|
-
id: 'crawl-noarchive',
|
|
278
|
-
name: 'Cache Directives',
|
|
279
|
-
category: 'technical',
|
|
280
|
-
severity: 'info',
|
|
281
|
-
description: 'Check for noarchive and nocache directives',
|
|
282
|
-
check: (ctx) => {
|
|
283
|
-
if (!ctx.metaRobots)
|
|
284
|
-
return null;
|
|
285
|
-
const directives = ctx.metaRobots.join(', ').toLowerCase();
|
|
286
|
-
const hasNoarchive = directives.includes('noarchive');
|
|
287
|
-
const hasNocache = directives.includes('nocache');
|
|
288
|
-
const hasNosnippet = directives.includes('nosnippet');
|
|
289
|
-
const restrictions = [];
|
|
290
|
-
if (hasNoarchive)
|
|
291
|
-
restrictions.push('noarchive (no cached version)');
|
|
292
|
-
if (hasNocache)
|
|
293
|
-
restrictions.push('nocache (no cache)');
|
|
294
|
-
if (hasNosnippet)
|
|
295
|
-
restrictions.push('nosnippet (no search snippet)');
|
|
296
|
-
if (restrictions.length > 0) {
|
|
297
|
-
return createResult({ id: 'crawl-noarchive', name: 'Cache Directives', category: 'technical', severity: 'info' }, 'info', `Search restrictions: ${restrictions.join(', ')}`, {
|
|
298
|
-
evidence: {
|
|
299
|
-
found: restrictions,
|
|
300
|
-
impact: 'These directives limit how search engines display your page',
|
|
301
|
-
},
|
|
302
|
-
});
|
|
303
|
-
}
|
|
304
|
-
return null;
|
|
305
|
-
},
|
|
306
|
-
},
|
|
307
|
-
];
|
package/dist/seo/rules/cwv.d.ts
DELETED