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,222 @@
|
|
|
1
|
+
import { createResult } from './types.js';
|
|
2
|
+
export const i18nRules = [
|
|
3
|
+
{
|
|
4
|
+
id: 'i18n-hreflang-exists',
|
|
5
|
+
name: 'Hreflang Tags',
|
|
6
|
+
category: 'technical',
|
|
7
|
+
severity: 'warning',
|
|
8
|
+
description: 'Multi-language sites should have hreflang tags for proper language targeting',
|
|
9
|
+
check: (ctx) => {
|
|
10
|
+
if (!ctx.hreflangTags || ctx.hreflangTags.length === 0) {
|
|
11
|
+
if (ctx.hasLang && ctx.langValue && ctx.langValue !== 'en') {
|
|
12
|
+
return createResult({ id: 'i18n-hreflang-exists', name: 'Hreflang Tags', category: 'technical', severity: 'warning' }, 'info', 'No hreflang tags found (recommended for multi-language sites)', {
|
|
13
|
+
recommendation: 'Add hreflang tags to indicate language/region alternatives',
|
|
14
|
+
evidence: {
|
|
15
|
+
expected: '<link rel="alternate" hreflang="en" href="https://example.com/en/">',
|
|
16
|
+
example: '<link rel="alternate" hreflang="en" href="https://example.com/en/">\n<link rel="alternate" hreflang="es" href="https://example.com/es/">\n<link rel="alternate" hreflang="x-default" href="https://example.com/">',
|
|
17
|
+
impact: 'Without hreflang, search engines may show wrong language version to users in different countries',
|
|
18
|
+
learnMore: 'https://developers.google.com/search/docs/specialty/international/localized-versions',
|
|
19
|
+
},
|
|
20
|
+
});
|
|
21
|
+
}
|
|
22
|
+
return null;
|
|
23
|
+
}
|
|
24
|
+
return createResult({ id: 'i18n-hreflang-exists', name: 'Hreflang Tags', category: 'technical', severity: 'warning' }, 'pass', `${ctx.hreflangTags.length} hreflang tag(s) found`, { value: ctx.hreflangTags.length });
|
|
25
|
+
},
|
|
26
|
+
},
|
|
27
|
+
{
|
|
28
|
+
id: 'i18n-hreflang-self',
|
|
29
|
+
name: 'Hreflang Self-Reference',
|
|
30
|
+
category: 'technical',
|
|
31
|
+
severity: 'warning',
|
|
32
|
+
description: 'Hreflang tags should include a self-referencing tag for the current page',
|
|
33
|
+
check: (ctx) => {
|
|
34
|
+
if (!ctx.hreflangTags || ctx.hreflangTags.length === 0)
|
|
35
|
+
return null;
|
|
36
|
+
if (!ctx.url)
|
|
37
|
+
return null;
|
|
38
|
+
const currentUrl = ctx.url.toLowerCase().replace(/\/$/, '');
|
|
39
|
+
const hasSelfRef = ctx.hreflangTags.some((tag) => {
|
|
40
|
+
const href = tag.href?.toLowerCase().replace(/\/$/, '');
|
|
41
|
+
return href === currentUrl;
|
|
42
|
+
});
|
|
43
|
+
if (!hasSelfRef) {
|
|
44
|
+
return createResult({ id: 'i18n-hreflang-self', name: 'Hreflang Self-Reference', category: 'technical', severity: 'warning' }, 'warn', 'Missing self-referencing hreflang tag', {
|
|
45
|
+
recommendation: 'Add a hreflang tag that points to the current page',
|
|
46
|
+
evidence: {
|
|
47
|
+
found: `Current URL: ${ctx.url}`,
|
|
48
|
+
expected: `<link rel="alternate" hreflang="${ctx.langValue || 'en'}" href="${ctx.url}">`,
|
|
49
|
+
impact: 'Google recommends including a self-referencing hreflang tag for clarity',
|
|
50
|
+
},
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
return null;
|
|
54
|
+
},
|
|
55
|
+
},
|
|
56
|
+
{
|
|
57
|
+
id: 'i18n-hreflang-x-default',
|
|
58
|
+
name: 'Hreflang X-Default',
|
|
59
|
+
category: 'technical',
|
|
60
|
+
severity: 'info',
|
|
61
|
+
description: 'Include x-default hreflang for users outside defined regions',
|
|
62
|
+
check: (ctx) => {
|
|
63
|
+
if (!ctx.hreflangTags || ctx.hreflangTags.length < 2)
|
|
64
|
+
return null;
|
|
65
|
+
const hasXDefault = ctx.hreflangTags.some((tag) => tag.lang === 'x-default');
|
|
66
|
+
if (!hasXDefault) {
|
|
67
|
+
return createResult({ id: 'i18n-hreflang-x-default', name: 'Hreflang X-Default', category: 'technical', severity: 'info' }, 'info', 'No x-default hreflang tag found', {
|
|
68
|
+
recommendation: 'Add hreflang="x-default" to specify the fallback page for unmatched languages',
|
|
69
|
+
evidence: {
|
|
70
|
+
expected: '<link rel="alternate" hreflang="x-default" href="https://example.com/">',
|
|
71
|
+
impact: 'Without x-default, users in unsupported regions may not see the best version',
|
|
72
|
+
},
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
|
+
return createResult({ id: 'i18n-hreflang-x-default', name: 'Hreflang X-Default', category: 'technical', severity: 'info' }, 'pass', 'x-default hreflang tag present');
|
|
76
|
+
},
|
|
77
|
+
},
|
|
78
|
+
{
|
|
79
|
+
id: 'i18n-hreflang-valid-codes',
|
|
80
|
+
name: 'Hreflang Valid Codes',
|
|
81
|
+
category: 'technical',
|
|
82
|
+
severity: 'warning',
|
|
83
|
+
description: 'Hreflang language codes must be valid ISO 639-1 codes',
|
|
84
|
+
check: (ctx) => {
|
|
85
|
+
if (!ctx.hreflangTags || ctx.hreflangTags.length === 0)
|
|
86
|
+
return null;
|
|
87
|
+
const validLanguageCodes = new Set([
|
|
88
|
+
'aa', 'ab', 'af', 'ak', 'sq', 'am', 'ar', 'an', 'hy', 'as', 'av', 'ae', 'ay', 'az',
|
|
89
|
+
'ba', 'bm', 'eu', 'be', 'bn', 'bh', 'bi', 'bo', 'bs', 'br', 'bg', 'my', 'ca', 'cs',
|
|
90
|
+
'ch', 'ce', 'zh', 'cu', 'cv', 'kw', 'co', 'cr', 'cy', 'da', 'de', 'dv', 'nl', 'dz',
|
|
91
|
+
'en', 'eo', 'et', 'ee', 'fo', 'fa', 'fj', 'fi', 'fr', 'fy', 'ff', 'ka', 'el', 'gn',
|
|
92
|
+
'gu', 'ht', 'ha', 'he', 'hz', 'hi', 'ho', 'hr', 'hu', 'ig', 'is', 'io', 'ii', 'iu',
|
|
93
|
+
'ie', 'ia', 'id', 'ik', 'it', 'jv', 'ja', 'kl', 'kn', 'ks', 'kr', 'kk', 'km', 'ki',
|
|
94
|
+
'rw', 'ky', 'kv', 'kg', 'ko', 'kj', 'ku', 'lo', 'la', 'lv', 'li', 'ln', 'lt', 'lb',
|
|
95
|
+
'lu', 'lg', 'mk', 'mh', 'ml', 'mi', 'mr', 'ms', 'mg', 'mt', 'mn', 'na', 'nv', 'nr',
|
|
96
|
+
'nd', 'ng', 'ne', 'nn', 'nb', 'no', 'ny', 'oc', 'oj', 'or', 'om', 'os', 'pa', 'pi',
|
|
97
|
+
'pl', 'pt', 'ps', 'qu', 'rm', 'ro', 'rn', 'ru', 'sg', 'sa', 'si', 'sk', 'sl', 'se',
|
|
98
|
+
'sm', 'sn', 'sd', 'so', 'st', 'es', 'sc', 'sr', 'ss', 'su', 'sw', 'sv', 'ty', 'ta',
|
|
99
|
+
'tt', 'te', 'tg', 'tl', 'th', 'ti', 'to', 'tn', 'ts', 'tk', 'tr', 'tw', 'ug', 'uk',
|
|
100
|
+
'ur', 'uz', 've', 'vi', 'vo', 'wa', 'wo', 'xh', 'yi', 'yo', 'za', 'zu',
|
|
101
|
+
'x-default',
|
|
102
|
+
]);
|
|
103
|
+
const invalidTags = [];
|
|
104
|
+
for (const tag of ctx.hreflangTags) {
|
|
105
|
+
const lang = tag.lang?.toLowerCase().split('-')[0];
|
|
106
|
+
if (lang && !validLanguageCodes.has(lang)) {
|
|
107
|
+
invalidTags.push(tag.lang);
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
if (invalidTags.length > 0) {
|
|
111
|
+
return createResult({ id: 'i18n-hreflang-valid-codes', name: 'Hreflang Valid Codes', category: 'technical', severity: 'warning' }, 'warn', `Invalid hreflang codes: ${invalidTags.join(', ')}`, {
|
|
112
|
+
recommendation: 'Use valid ISO 639-1 language codes',
|
|
113
|
+
evidence: {
|
|
114
|
+
found: invalidTags,
|
|
115
|
+
expected: 'Valid ISO 639-1 codes like: en, es, fr, de, pt-BR, zh-CN',
|
|
116
|
+
learnMore: 'https://en.wikipedia.org/wiki/List_of_ISO_639-1_codes',
|
|
117
|
+
},
|
|
118
|
+
});
|
|
119
|
+
}
|
|
120
|
+
return null;
|
|
121
|
+
},
|
|
122
|
+
},
|
|
123
|
+
{
|
|
124
|
+
id: 'i18n-hreflang-return-links',
|
|
125
|
+
name: 'Hreflang Return Links',
|
|
126
|
+
category: 'technical',
|
|
127
|
+
severity: 'warning',
|
|
128
|
+
description: 'All hreflang URLs should return links back to this page (bidirectional)',
|
|
129
|
+
check: (ctx) => {
|
|
130
|
+
if (!ctx.hreflangTags || ctx.hreflangTags.length < 2)
|
|
131
|
+
return null;
|
|
132
|
+
return createResult({ id: 'i18n-hreflang-return-links', name: 'Hreflang Return Links', category: 'technical', severity: 'warning' }, 'info', 'Hreflang return links cannot be verified from HTML alone', {
|
|
133
|
+
recommendation: 'Ensure all alternate pages link back to this page with matching hreflang tags',
|
|
134
|
+
evidence: {
|
|
135
|
+
impact: 'Missing return links can cause Google to ignore hreflang annotations',
|
|
136
|
+
learnMore: 'https://developers.google.com/search/docs/specialty/international/localized-versions#bidirectional',
|
|
137
|
+
},
|
|
138
|
+
});
|
|
139
|
+
},
|
|
140
|
+
},
|
|
141
|
+
{
|
|
142
|
+
id: 'i18n-content-language',
|
|
143
|
+
name: 'Content-Language Header',
|
|
144
|
+
category: 'technical',
|
|
145
|
+
severity: 'info',
|
|
146
|
+
description: 'Content-Language header can indicate the language of the document',
|
|
147
|
+
check: (ctx) => {
|
|
148
|
+
if (!ctx.responseHeaders)
|
|
149
|
+
return null;
|
|
150
|
+
const contentLang = ctx.responseHeaders['content-language'] || ctx.responseHeaders['Content-Language'];
|
|
151
|
+
if (!contentLang) {
|
|
152
|
+
if (ctx.hasLang) {
|
|
153
|
+
return createResult({ id: 'i18n-content-language', name: 'Content-Language Header', category: 'technical', severity: 'info' }, 'info', 'Content-Language header not set', {
|
|
154
|
+
recommendation: `Consider adding Content-Language: ${ctx.langValue || 'en'} header`,
|
|
155
|
+
evidence: {
|
|
156
|
+
impact: 'While not critical for SEO, it helps with content negotiation',
|
|
157
|
+
},
|
|
158
|
+
});
|
|
159
|
+
}
|
|
160
|
+
return null;
|
|
161
|
+
}
|
|
162
|
+
if (ctx.langValue) {
|
|
163
|
+
const headerLang = Array.isArray(contentLang) ? contentLang[0] : contentLang;
|
|
164
|
+
const headerLangPrimary = headerLang.toLowerCase().split('-')[0].split(',')[0].trim();
|
|
165
|
+
const htmlLangPrimary = ctx.langValue.toLowerCase().split('-')[0];
|
|
166
|
+
if (headerLangPrimary !== htmlLangPrimary) {
|
|
167
|
+
return createResult({ id: 'i18n-content-language', name: 'Content-Language Header', category: 'technical', severity: 'info' }, 'warn', `Content-Language (${headerLang}) doesn't match html lang (${ctx.langValue})`, {
|
|
168
|
+
recommendation: 'Ensure Content-Language header matches the html lang attribute',
|
|
169
|
+
});
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
return createResult({ id: 'i18n-content-language', name: 'Content-Language Header', category: 'technical', severity: 'info' }, 'pass', `Content-Language: ${contentLang}`);
|
|
173
|
+
},
|
|
174
|
+
},
|
|
175
|
+
{
|
|
176
|
+
id: 'i18n-lang-consistency',
|
|
177
|
+
name: 'Language Consistency',
|
|
178
|
+
category: 'technical',
|
|
179
|
+
severity: 'warning',
|
|
180
|
+
description: 'HTML lang attribute should match the og:locale if present',
|
|
181
|
+
check: (ctx) => {
|
|
182
|
+
if (!ctx.hasLang || !ctx.ogLocale)
|
|
183
|
+
return null;
|
|
184
|
+
const htmlLang = ctx.langValue?.toLowerCase().split('-')[0];
|
|
185
|
+
const ogLocaleLang = ctx.ogLocale.toLowerCase().split('_')[0];
|
|
186
|
+
if (htmlLang !== ogLocaleLang) {
|
|
187
|
+
return createResult({ id: 'i18n-lang-consistency', name: 'Language Consistency', category: 'technical', severity: 'warning' }, 'warn', `Language mismatch: html lang="${ctx.langValue}" vs og:locale="${ctx.ogLocale}"`, {
|
|
188
|
+
recommendation: 'Ensure html lang and og:locale represent the same language',
|
|
189
|
+
evidence: {
|
|
190
|
+
found: [`html lang="${ctx.langValue}"`, `og:locale="${ctx.ogLocale}"`],
|
|
191
|
+
impact: 'Inconsistent language signals can confuse search engines and social platforms',
|
|
192
|
+
},
|
|
193
|
+
});
|
|
194
|
+
}
|
|
195
|
+
return null;
|
|
196
|
+
},
|
|
197
|
+
},
|
|
198
|
+
{
|
|
199
|
+
id: 'i18n-lang-region',
|
|
200
|
+
name: 'Language Region Specificity',
|
|
201
|
+
category: 'technical',
|
|
202
|
+
severity: 'info',
|
|
203
|
+
description: 'Consider using region-specific language codes for better targeting',
|
|
204
|
+
check: (ctx) => {
|
|
205
|
+
if (!ctx.hasLang || !ctx.langValue)
|
|
206
|
+
return null;
|
|
207
|
+
const multiRegionalLangs = ['en', 'es', 'pt', 'zh', 'fr', 'de', 'ar'];
|
|
208
|
+
const langPrimary = ctx.langValue.toLowerCase().split('-')[0];
|
|
209
|
+
if (multiRegionalLangs.includes(langPrimary) && !ctx.langValue.includes('-')) {
|
|
210
|
+
return createResult({ id: 'i18n-lang-region', name: 'Language Region Specificity', category: 'technical', severity: 'info' }, 'info', `Consider using region-specific lang code (e.g., ${langPrimary}-US, ${langPrimary}-GB)`, {
|
|
211
|
+
recommendation: 'For multi-regional languages, specify the region for better targeting',
|
|
212
|
+
evidence: {
|
|
213
|
+
found: ctx.langValue,
|
|
214
|
+
expected: `${langPrimary}-XX (e.g., en-US, es-ES, pt-BR, zh-CN)`,
|
|
215
|
+
impact: 'Helps search engines serve the right regional variant',
|
|
216
|
+
},
|
|
217
|
+
});
|
|
218
|
+
}
|
|
219
|
+
return null;
|
|
220
|
+
},
|
|
221
|
+
},
|
|
222
|
+
];
|
|
@@ -2,6 +2,38 @@ 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
|
+
};
|
|
5
37
|
export interface RulesEngineOptions {
|
|
6
38
|
categories?: RuleCategory[];
|
|
7
39
|
excludeCategories?: RuleCategory[];
|
package/dist/seo/rules/index.js
CHANGED
|
@@ -9,6 +9,16 @@ 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
|
+
import { pwaRules } from './pwa.js';
|
|
19
|
+
import { socialRules } from './social.js';
|
|
20
|
+
import { internalLinkingRules } from './internal-linking.js';
|
|
21
|
+
import { bestPracticesRules } from './best-practices.js';
|
|
12
22
|
export * from './types.js';
|
|
13
23
|
export * from './thresholds.js';
|
|
14
24
|
export const ALL_SEO_RULES = [
|
|
@@ -23,7 +33,68 @@ export const ALL_SEO_RULES = [
|
|
|
23
33
|
...schemaRules,
|
|
24
34
|
...accessibilityRules,
|
|
25
35
|
...mobileRules,
|
|
36
|
+
...i18nRules,
|
|
37
|
+
...ecommerceRules,
|
|
38
|
+
...localRules,
|
|
39
|
+
...cwvRules,
|
|
40
|
+
...crawlRules,
|
|
41
|
+
...readabilityRules,
|
|
42
|
+
...pwaRules,
|
|
43
|
+
...socialRules,
|
|
44
|
+
...internalLinkingRules,
|
|
45
|
+
...bestPracticesRules,
|
|
26
46
|
];
|
|
47
|
+
export const SCORING_WEIGHTS = {
|
|
48
|
+
severity: {
|
|
49
|
+
error: { pass: 10, fail: -15, warn: -10, info: 0 },
|
|
50
|
+
warning: { pass: 5, fail: -8, warn: -5, info: 0 },
|
|
51
|
+
info: { pass: 2, fail: -3, warn: -2, info: 0 },
|
|
52
|
+
},
|
|
53
|
+
category: {
|
|
54
|
+
title: 1.5,
|
|
55
|
+
meta: 1.3,
|
|
56
|
+
og: 1.0,
|
|
57
|
+
twitter: 0.8,
|
|
58
|
+
headings: 1.2,
|
|
59
|
+
images: 1.0,
|
|
60
|
+
links: 1.1,
|
|
61
|
+
content: 1.2,
|
|
62
|
+
technical: 1.3,
|
|
63
|
+
security: 0.9,
|
|
64
|
+
mobile: 1.2,
|
|
65
|
+
'structured-data': 1.0,
|
|
66
|
+
performance: 1.4,
|
|
67
|
+
accessibility: 0.8,
|
|
68
|
+
},
|
|
69
|
+
};
|
|
70
|
+
export function calculateWeightedScore(results) {
|
|
71
|
+
const categoryScores = {};
|
|
72
|
+
for (const result of results) {
|
|
73
|
+
const severityWeight = SCORING_WEIGHTS.severity[result.severity];
|
|
74
|
+
const statusScore = severityWeight[result.status] ?? 0;
|
|
75
|
+
if (!categoryScores[result.category]) {
|
|
76
|
+
categoryScores[result.category] = { score: 0, count: 0 };
|
|
77
|
+
}
|
|
78
|
+
categoryScores[result.category].score += statusScore;
|
|
79
|
+
categoryScores[result.category].count++;
|
|
80
|
+
}
|
|
81
|
+
let totalScore = 0;
|
|
82
|
+
let totalWeight = 0;
|
|
83
|
+
const details = [];
|
|
84
|
+
for (const [cat, data] of Object.entries(categoryScores)) {
|
|
85
|
+
const category = cat;
|
|
86
|
+
const categoryWeight = SCORING_WEIGHTS.category[category] ?? 1;
|
|
87
|
+
const normalizedScore = data.count > 0 ? data.score / data.count : 0;
|
|
88
|
+
const weightedScore = normalizedScore * categoryWeight;
|
|
89
|
+
details.push({ category, score: normalizedScore, weight: categoryWeight });
|
|
90
|
+
totalScore += weightedScore;
|
|
91
|
+
totalWeight += categoryWeight;
|
|
92
|
+
}
|
|
93
|
+
const maxPossible = 100;
|
|
94
|
+
const baseScore = 70;
|
|
95
|
+
const adjustedScore = Math.max(0, Math.min(100, baseScore + totalScore));
|
|
96
|
+
return { score: Math.round(adjustedScore), maxPossible, details };
|
|
97
|
+
}
|
|
27
98
|
export class SeoRulesEngine {
|
|
28
99
|
rules;
|
|
29
100
|
constructor(options = {}) {
|