recker 1.0.27 → 1.0.28-next.32fe8ef
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/browser/scrape/extractors.js +2 -1
- package/dist/browser/scrape/types.d.ts +2 -1
- package/dist/cli/index.js +142 -3
- package/dist/cli/tui/shell.d.ts +1 -0
- package/dist/cli/tui/shell.js +157 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/scrape/extractors.js +2 -1
- package/dist/scrape/types.d.ts +2 -1
- package/dist/seo/analyzer.d.ts +42 -0
- package/dist/seo/analyzer.js +715 -0
- package/dist/seo/index.d.ts +5 -0
- package/dist/seo/index.js +2 -0
- package/dist/seo/rules/accessibility.d.ts +2 -0
- package/dist/seo/rules/accessibility.js +128 -0
- package/dist/seo/rules/content.d.ts +2 -0
- package/dist/seo/rules/content.js +236 -0
- package/dist/seo/rules/images.d.ts +2 -0
- package/dist/seo/rules/images.js +180 -0
- package/dist/seo/rules/index.d.ts +20 -0
- package/dist/seo/rules/index.js +72 -0
- package/dist/seo/rules/links.d.ts +2 -0
- package/dist/seo/rules/links.js +150 -0
- package/dist/seo/rules/meta.d.ts +2 -0
- package/dist/seo/rules/meta.js +523 -0
- package/dist/seo/rules/mobile.d.ts +2 -0
- package/dist/seo/rules/mobile.js +71 -0
- package/dist/seo/rules/performance.d.ts +2 -0
- package/dist/seo/rules/performance.js +246 -0
- package/dist/seo/rules/schema.d.ts +2 -0
- package/dist/seo/rules/schema.js +54 -0
- package/dist/seo/rules/security.d.ts +2 -0
- package/dist/seo/rules/security.js +147 -0
- package/dist/seo/rules/structural.d.ts +2 -0
- package/dist/seo/rules/structural.js +155 -0
- package/dist/seo/rules/technical.d.ts +2 -0
- package/dist/seo/rules/technical.js +223 -0
- package/dist/seo/rules/thresholds.d.ts +196 -0
- package/dist/seo/rules/thresholds.js +118 -0
- package/dist/seo/rules/types.d.ts +191 -0
- package/dist/seo/rules/types.js +11 -0
- package/dist/seo/types.d.ts +160 -0
- package/dist/seo/types.js +1 -0
- package/dist/utils/columns.d.ts +14 -0
- package/dist/utils/columns.js +69 -0
- package/package.json +1 -1
|
@@ -0,0 +1,715 @@
|
|
|
1
|
+
import { extractMeta, extractOpenGraph, extractTwitterCard, extractJsonLd, extractLinks, extractImages, } from '../scrape/extractors.js';
|
|
2
|
+
import { requireOptional } from '../utils/optional-require.js';
|
|
3
|
+
import { createRulesEngine, SEO_THRESHOLDS, } from './rules/index.js';
|
|
4
|
+
let cheerioModule = null;
|
|
5
|
+
async function loadCheerio() {
|
|
6
|
+
if (cheerioModule)
|
|
7
|
+
return cheerioModule;
|
|
8
|
+
cheerioModule = await requireOptional('cheerio', 'recker/seo');
|
|
9
|
+
return cheerioModule;
|
|
10
|
+
}
|
|
11
|
+
export class SeoAnalyzer {
|
|
12
|
+
$;
|
|
13
|
+
options;
|
|
14
|
+
rulesEngine;
|
|
15
|
+
constructor($, options = {}) {
|
|
16
|
+
this.$ = $;
|
|
17
|
+
this.options = options;
|
|
18
|
+
this.rulesEngine = createRulesEngine(options.rules);
|
|
19
|
+
}
|
|
20
|
+
static async fromHtml(html, options = {}) {
|
|
21
|
+
const { load } = await loadCheerio();
|
|
22
|
+
return new SeoAnalyzer(load(html), options);
|
|
23
|
+
}
|
|
24
|
+
analyze() {
|
|
25
|
+
const url = this.options.baseUrl || '';
|
|
26
|
+
const meta = extractMeta(this.$);
|
|
27
|
+
const og = extractOpenGraph(this.$);
|
|
28
|
+
const twitter = extractTwitterCard(this.$);
|
|
29
|
+
const jsonLd = extractJsonLd(this.$);
|
|
30
|
+
const links = extractLinks(this.$, { baseUrl: this.options.baseUrl });
|
|
31
|
+
const images = extractImages(this.$, { baseUrl: this.options.baseUrl });
|
|
32
|
+
const headings = this.analyzeHeadings();
|
|
33
|
+
const content = this.analyzeContent(headings);
|
|
34
|
+
const linkAnalysis = this.buildLinkAnalysis(links);
|
|
35
|
+
const imageAnalysis = this.buildImageAnalysis(images);
|
|
36
|
+
const social = this.buildSocialAnalysis(og, twitter);
|
|
37
|
+
const technical = this.buildTechnicalAnalysis(meta);
|
|
38
|
+
const context = this.buildRuleContext({
|
|
39
|
+
meta,
|
|
40
|
+
og,
|
|
41
|
+
twitter,
|
|
42
|
+
jsonLd,
|
|
43
|
+
headings,
|
|
44
|
+
content,
|
|
45
|
+
linkAnalysis,
|
|
46
|
+
imageAnalysis,
|
|
47
|
+
links,
|
|
48
|
+
});
|
|
49
|
+
const ruleResults = this.rulesEngine.evaluate(context);
|
|
50
|
+
const checks = this.convertToCheckResults(ruleResults);
|
|
51
|
+
const { score, grade } = this.calculateScore(checks);
|
|
52
|
+
return {
|
|
53
|
+
url,
|
|
54
|
+
timestamp: new Date(),
|
|
55
|
+
grade,
|
|
56
|
+
score,
|
|
57
|
+
checks,
|
|
58
|
+
title: meta.title ? { text: meta.title, length: meta.title.length } : undefined,
|
|
59
|
+
metaDescription: meta.description ? { text: meta.description, length: meta.description.length } : undefined,
|
|
60
|
+
headings: headings,
|
|
61
|
+
content,
|
|
62
|
+
links: linkAnalysis,
|
|
63
|
+
images: imageAnalysis,
|
|
64
|
+
social,
|
|
65
|
+
technical,
|
|
66
|
+
jsonLd: {
|
|
67
|
+
count: jsonLd.length,
|
|
68
|
+
types: jsonLd.map((j) => j['@type']).filter(Boolean),
|
|
69
|
+
},
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
buildRuleContext(data) {
|
|
73
|
+
const { meta, og, twitter, jsonLd, headings, content, linkAnalysis, imageAnalysis, links } = data;
|
|
74
|
+
const htmlLang = this.$('html').attr('lang');
|
|
75
|
+
const genericTexts = SEO_THRESHOLDS.links.genericTexts;
|
|
76
|
+
const genericTextLinks = links.filter((l) => {
|
|
77
|
+
const text = l.text?.toLowerCase().trim();
|
|
78
|
+
return text && genericTexts.some((g) => text === g || text.includes(g));
|
|
79
|
+
});
|
|
80
|
+
const linksWithGenericText = genericTextLinks.length;
|
|
81
|
+
const linksWithoutTextArray = links.filter((l) => !l.text || l.text.trim() === '');
|
|
82
|
+
const externalBlankLinks = links.filter((l) => l.type === 'external' && l.target === '_blank');
|
|
83
|
+
const missingNoopenerLinks = externalBlankLinks.filter((l) => !l.rel?.includes('noopener'));
|
|
84
|
+
const missingNoreferrerLinks = externalBlankLinks.filter((l) => !l.rel?.includes('noreferrer'));
|
|
85
|
+
const problematicLinks = {
|
|
86
|
+
withoutText: linksWithoutTextArray,
|
|
87
|
+
genericText: genericTextLinks,
|
|
88
|
+
missingNoopener: missingNoopenerLinks,
|
|
89
|
+
missingNoreferrer: missingNoreferrerLinks,
|
|
90
|
+
};
|
|
91
|
+
const hasMixedContent = this.checkMixedContent();
|
|
92
|
+
const h1Elements = this.$('h1');
|
|
93
|
+
const h1Text = h1Elements.first().text().trim();
|
|
94
|
+
const viewportContent = this.$('meta[name="viewport"]').attr('content');
|
|
95
|
+
const a11yMetrics = this.analyzeAccessibility();
|
|
96
|
+
const imagesWithEmptyAlt = this.$('img[alt=""]').length;
|
|
97
|
+
const linkSecurityMetrics = this.analyzeLinkSecurity();
|
|
98
|
+
const faviconInfo = this.detectFavicon();
|
|
99
|
+
const perfHints = this.analyzePerformanceHints();
|
|
100
|
+
const cwvHints = this.analyzeCWVHints();
|
|
101
|
+
const structuralHtml = this.analyzeStructuralHtml();
|
|
102
|
+
const breadcrumbs = this.analyzeBreadcrumbs(jsonLd.map((j) => j['@type']).filter(Boolean));
|
|
103
|
+
const multimedia = this.analyzeMultimedia();
|
|
104
|
+
const trustSignals = this.analyzeTrustSignals(links);
|
|
105
|
+
const totalSubheadings = (headings.structure.filter((h) => h.level === 2).length || 0) + (headings.structure.filter((h) => h.level === 3).length || 0);
|
|
106
|
+
const subheadingFrequency = content.wordCount > 0 ? (totalSubheadings / content.wordCount) * 100 : 0;
|
|
107
|
+
const textHtmlRatio = this.calculateTextHtmlRatio(content.characterCount);
|
|
108
|
+
return {
|
|
109
|
+
title: meta.title,
|
|
110
|
+
titleLength: meta.title?.length,
|
|
111
|
+
metaDescription: meta.description,
|
|
112
|
+
metaDescriptionLength: meta.description?.length,
|
|
113
|
+
metaKeywords: meta.keywords,
|
|
114
|
+
metaRobots: meta.robots,
|
|
115
|
+
ogTitle: og.title,
|
|
116
|
+
ogDescription: og.description,
|
|
117
|
+
ogImage: Array.isArray(og.image) ? og.image[0] : og.image,
|
|
118
|
+
ogUrl: og.url,
|
|
119
|
+
ogType: og.type,
|
|
120
|
+
ogSiteName: og.siteName,
|
|
121
|
+
twitterCard: twitter.card,
|
|
122
|
+
twitterTitle: twitter.title,
|
|
123
|
+
twitterDescription: twitter.description,
|
|
124
|
+
twitterImage: Array.isArray(twitter.image) ? twitter.image[0] : twitter.image,
|
|
125
|
+
twitterSite: twitter.site,
|
|
126
|
+
h1Count: headings.h1Count,
|
|
127
|
+
h1Text: h1Text || undefined,
|
|
128
|
+
h1Length: h1Text?.length,
|
|
129
|
+
h2Count: headings.structure.filter((h) => h.level === 2).length,
|
|
130
|
+
headingHierarchyValid: headings.hasProperHierarchy,
|
|
131
|
+
headingSkippedLevels: headings.issues.filter((i) => i.includes('Skipped')),
|
|
132
|
+
sectionWordCounts: headings.sectionWordCounts,
|
|
133
|
+
totalImages: imageAnalysis.total,
|
|
134
|
+
imagesWithAlt: imageAnalysis.withAlt,
|
|
135
|
+
imagesWithoutAlt: imageAnalysis.withoutAlt,
|
|
136
|
+
imagesWithLazyLoad: imageAnalysis.lazy,
|
|
137
|
+
imagesMissingDimensions: imageAnalysis.missingDimensions,
|
|
138
|
+
imagesWithEmptyAlt,
|
|
139
|
+
imagesUsingModernFormats: imageAnalysis.modernFormats,
|
|
140
|
+
altTextLengths: imageAnalysis.altTextLengths,
|
|
141
|
+
imageFilenames: imageAnalysis.imageFilenames,
|
|
142
|
+
imagesWithAsyncDecoding: imageAnalysis.imagesWithAsyncDecoding,
|
|
143
|
+
...a11yMetrics,
|
|
144
|
+
allLinks: links,
|
|
145
|
+
totalLinks: linkAnalysis.total,
|
|
146
|
+
internalLinks: linkAnalysis.internal,
|
|
147
|
+
externalLinks: linkAnalysis.external,
|
|
148
|
+
linksWithoutText: linkAnalysis.withoutText,
|
|
149
|
+
nofollowLinks: linkAnalysis.nofollow,
|
|
150
|
+
sponsoredLinks: linkAnalysis.sponsoredLinks,
|
|
151
|
+
ugcLinks: linkAnalysis.ugcLinks,
|
|
152
|
+
linksWithGenericText,
|
|
153
|
+
...linkSecurityMetrics,
|
|
154
|
+
problematicLinks,
|
|
155
|
+
wordCount: content.wordCount,
|
|
156
|
+
characterCount: content.characterCount,
|
|
157
|
+
sentenceCount: content.sentenceCount,
|
|
158
|
+
paragraphCount: content.paragraphCount,
|
|
159
|
+
avgWordsPerSentence: content.avgWordsPerSentence,
|
|
160
|
+
avgParagraphLength: content.avgParagraphLength,
|
|
161
|
+
listCount: content.listCount,
|
|
162
|
+
strongTagCount: content.strongTagCount,
|
|
163
|
+
emTagCount: content.emTagCount,
|
|
164
|
+
subheadingFrequency,
|
|
165
|
+
paragraphWordCounts: content.paragraphWordCounts,
|
|
166
|
+
avgSentenceLength: content.avgSentenceLength,
|
|
167
|
+
faqCount: content.faqCount,
|
|
168
|
+
imagePerWordRatio: content.imagePerWordRatio,
|
|
169
|
+
keywordDensity: content.keywordDensity,
|
|
170
|
+
fleschReadingEase: content.fleschReadingEase,
|
|
171
|
+
hasQuestionHeadings: content.hasQuestionHeadings,
|
|
172
|
+
...structuralHtml,
|
|
173
|
+
...trustSignals,
|
|
174
|
+
...breadcrumbs,
|
|
175
|
+
...multimedia,
|
|
176
|
+
hasCanonical: !!meta.canonical,
|
|
177
|
+
canonicalUrl: meta.canonical,
|
|
178
|
+
hasViewport: !!meta.viewport,
|
|
179
|
+
viewportContent,
|
|
180
|
+
hasCharset: !!meta.charset,
|
|
181
|
+
charset: meta.charset,
|
|
182
|
+
hasLang: !!htmlLang,
|
|
183
|
+
langValue: htmlLang,
|
|
184
|
+
isHttps: this.options.baseUrl?.startsWith('https://'),
|
|
185
|
+
hasMixedContent,
|
|
186
|
+
responseHeaders: this.options.responseHeaders,
|
|
187
|
+
textHtmlRatio,
|
|
188
|
+
...faviconInfo,
|
|
189
|
+
...perfHints,
|
|
190
|
+
lcpHints: cwvHints.lcpHints,
|
|
191
|
+
clsHints: cwvHints.clsHints,
|
|
192
|
+
jsonLdCount: jsonLd.length,
|
|
193
|
+
jsonLdTypes: jsonLd.map((j) => j['@type']).filter(Boolean),
|
|
194
|
+
url: this.options.baseUrl,
|
|
195
|
+
urlLength: this.options.baseUrl?.length,
|
|
196
|
+
titleMatchesH1: meta.title && h1Text ? meta.title.toLowerCase().trim() === h1Text.toLowerCase().trim() : undefined,
|
|
197
|
+
...this.analyzeUrlQuality(),
|
|
198
|
+
...this.analyzeJsRendering(content),
|
|
199
|
+
};
|
|
200
|
+
}
|
|
201
|
+
analyzeUrlQuality() {
|
|
202
|
+
if (!this.options.baseUrl) {
|
|
203
|
+
return {
|
|
204
|
+
urlHasUppercase: false,
|
|
205
|
+
urlHasSpecialChars: false,
|
|
206
|
+
urlHasAccents: false,
|
|
207
|
+
};
|
|
208
|
+
}
|
|
209
|
+
try {
|
|
210
|
+
const url = new URL(this.options.baseUrl);
|
|
211
|
+
const path = url.pathname + url.search;
|
|
212
|
+
const urlHasUppercase = /[A-Z]/.test(path);
|
|
213
|
+
const urlHasAccents = /[àáâãäåæçèéêëìíîïñòóôõöùúûüýÿ]/i.test(path);
|
|
214
|
+
const urlHasSpecialChars = /[<>{}|\\^`\[\]]/.test(path) || /%[0-9A-F]{2}/i.test(path);
|
|
215
|
+
return { urlHasUppercase, urlHasSpecialChars, urlHasAccents };
|
|
216
|
+
}
|
|
217
|
+
catch {
|
|
218
|
+
return {
|
|
219
|
+
urlHasUppercase: false,
|
|
220
|
+
urlHasSpecialChars: false,
|
|
221
|
+
urlHasAccents: false,
|
|
222
|
+
};
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
analyzeJsRendering(content) {
|
|
226
|
+
const bodyTextLength = content.characterCount;
|
|
227
|
+
const scriptCount = this.$('script').length;
|
|
228
|
+
const noscriptContent = this.$('noscript').text().trim();
|
|
229
|
+
const hasNoscriptContent = noscriptContent.length > 50;
|
|
230
|
+
return { bodyTextLength, scriptCount, hasNoscriptContent };
|
|
231
|
+
}
|
|
232
|
+
checkMixedContent() {
|
|
233
|
+
let hasMixed = false;
|
|
234
|
+
this.$('img[src^="http://"]').each(() => {
|
|
235
|
+
hasMixed = true;
|
|
236
|
+
});
|
|
237
|
+
this.$('script[src^="http://"]').each(() => {
|
|
238
|
+
hasMixed = true;
|
|
239
|
+
});
|
|
240
|
+
this.$('link[href^="http://"]').each(() => {
|
|
241
|
+
hasMixed = true;
|
|
242
|
+
});
|
|
243
|
+
return hasMixed;
|
|
244
|
+
}
|
|
245
|
+
analyzeLinkSecurity() {
|
|
246
|
+
let withoutNoopener = 0;
|
|
247
|
+
let withoutNoreferrer = 0;
|
|
248
|
+
this.$('a[href^="http"][target="_blank"]').each((_, el) => {
|
|
249
|
+
const $el = this.$(el);
|
|
250
|
+
const href = $el.attr('href') || '';
|
|
251
|
+
const rel = ($el.attr('rel') || '').toLowerCase();
|
|
252
|
+
if (this.options.baseUrl && href.startsWith(this.options.baseUrl)) {
|
|
253
|
+
return;
|
|
254
|
+
}
|
|
255
|
+
if (!rel.includes('noopener')) {
|
|
256
|
+
withoutNoopener++;
|
|
257
|
+
}
|
|
258
|
+
if (!rel.includes('noreferrer')) {
|
|
259
|
+
withoutNoreferrer++;
|
|
260
|
+
}
|
|
261
|
+
});
|
|
262
|
+
return {
|
|
263
|
+
externalLinksWithoutNoopener: withoutNoopener,
|
|
264
|
+
externalLinksWithoutNoreferrer: withoutNoreferrer,
|
|
265
|
+
};
|
|
266
|
+
}
|
|
267
|
+
detectFavicon() {
|
|
268
|
+
const faviconSelectors = [
|
|
269
|
+
'link[rel="icon"]',
|
|
270
|
+
'link[rel="shortcut icon"]',
|
|
271
|
+
'link[rel="apple-touch-icon"]',
|
|
272
|
+
'link[rel="apple-touch-icon-precomposed"]',
|
|
273
|
+
];
|
|
274
|
+
for (const selector of faviconSelectors) {
|
|
275
|
+
const favicon = this.$(selector).first();
|
|
276
|
+
if (favicon.length > 0) {
|
|
277
|
+
return {
|
|
278
|
+
hasFavicon: true,
|
|
279
|
+
faviconUrl: favicon.attr('href'),
|
|
280
|
+
};
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
return { hasFavicon: false };
|
|
284
|
+
}
|
|
285
|
+
analyzePerformanceHints() {
|
|
286
|
+
const preconnectLinks = this.$('link[rel="preconnect"]');
|
|
287
|
+
const preconnectCount = preconnectLinks.length;
|
|
288
|
+
const dnsPrefetchLinks = this.$('link[rel="dns-prefetch"]');
|
|
289
|
+
const dnsPrefetchCount = dnsPrefetchLinks.length;
|
|
290
|
+
const preloadLinks = this.$('link[rel="preload"]');
|
|
291
|
+
const preloadCount = preloadLinks.length;
|
|
292
|
+
let renderBlockingResources = 0;
|
|
293
|
+
this.$('head script[src]:not([async]):not([defer])').each(() => {
|
|
294
|
+
renderBlockingResources++;
|
|
295
|
+
});
|
|
296
|
+
this.$('head link[rel="stylesheet"]').each((_, el) => {
|
|
297
|
+
const $el = this.$(el);
|
|
298
|
+
const media = $el.attr('media');
|
|
299
|
+
if (!media || media === 'all' || media === 'screen') {
|
|
300
|
+
renderBlockingResources++;
|
|
301
|
+
}
|
|
302
|
+
});
|
|
303
|
+
const inlineScriptsCount = this.$('script:not([src])').filter((_, el) => {
|
|
304
|
+
const content = this.$(el).html() || '';
|
|
305
|
+
return content.trim().length > 0;
|
|
306
|
+
}).length;
|
|
307
|
+
const inlineStylesCount = this.$('style').length;
|
|
308
|
+
return {
|
|
309
|
+
hasPreconnect: preconnectCount > 0,
|
|
310
|
+
preconnectCount,
|
|
311
|
+
hasDnsPrefetch: dnsPrefetchCount > 0,
|
|
312
|
+
dnsPrefetchCount,
|
|
313
|
+
hasPreload: preloadCount > 0,
|
|
314
|
+
preloadCount,
|
|
315
|
+
renderBlockingResources,
|
|
316
|
+
inlineScriptsCount,
|
|
317
|
+
inlineStylesCount,
|
|
318
|
+
};
|
|
319
|
+
}
|
|
320
|
+
analyzeCWVHints() {
|
|
321
|
+
const images = this.$('img');
|
|
322
|
+
let hasLargeImages = false;
|
|
323
|
+
let hasLazyLcp = false;
|
|
324
|
+
let hasPriorityHints = false;
|
|
325
|
+
images.slice(0, 3).each((index, el) => {
|
|
326
|
+
const $el = this.$(el);
|
|
327
|
+
const width = parseInt($el.attr('width') || '0', 10);
|
|
328
|
+
const height = parseInt($el.attr('height') || '0', 10);
|
|
329
|
+
const loading = $el.attr('loading');
|
|
330
|
+
const fetchPriority = $el.attr('fetchpriority');
|
|
331
|
+
if (width >= 400 || height >= 300) {
|
|
332
|
+
hasLargeImages = true;
|
|
333
|
+
}
|
|
334
|
+
if (index === 0 && loading === 'lazy') {
|
|
335
|
+
hasLazyLcp = true;
|
|
336
|
+
}
|
|
337
|
+
if (fetchPriority === 'high') {
|
|
338
|
+
hasPriorityHints = true;
|
|
339
|
+
}
|
|
340
|
+
});
|
|
341
|
+
this.$('[style*="background-image"]').slice(0, 3).each(() => {
|
|
342
|
+
hasLargeImages = true;
|
|
343
|
+
});
|
|
344
|
+
const imagesWithoutDimensions = this.$('img:not([width]):not([height])').length + this.$('img[width="auto"], img[height="auto"]').length;
|
|
345
|
+
return {
|
|
346
|
+
lcpHints: {
|
|
347
|
+
hasLargeImages,
|
|
348
|
+
hasLazyLcp,
|
|
349
|
+
hasPriorityHints,
|
|
350
|
+
},
|
|
351
|
+
clsHints: {
|
|
352
|
+
imagesWithoutDimensions,
|
|
353
|
+
},
|
|
354
|
+
};
|
|
355
|
+
}
|
|
356
|
+
analyzeAccessibility() {
|
|
357
|
+
let buttonsWithoutAriaLabel = 0;
|
|
358
|
+
this.$('button').each((_, el) => {
|
|
359
|
+
const $el = this.$(el);
|
|
360
|
+
const text = $el.text().trim();
|
|
361
|
+
const ariaLabel = $el.attr('aria-label');
|
|
362
|
+
const ariaLabelledBy = $el.attr('aria-labelledby');
|
|
363
|
+
const title = $el.attr('title');
|
|
364
|
+
if (!text && !ariaLabel && !ariaLabelledBy && !title) {
|
|
365
|
+
buttonsWithoutAriaLabel++;
|
|
366
|
+
}
|
|
367
|
+
});
|
|
368
|
+
let linksWithoutAriaLabel = 0;
|
|
369
|
+
this.$('a[href]').each((_, el) => {
|
|
370
|
+
const $el = this.$(el);
|
|
371
|
+
const text = $el.text().trim();
|
|
372
|
+
const ariaLabel = $el.attr('aria-label');
|
|
373
|
+
const ariaLabelledBy = $el.attr('aria-labelledby');
|
|
374
|
+
const title = $el.attr('title');
|
|
375
|
+
const hasOnlyImage = $el.find('img, svg').length > 0 && !text;
|
|
376
|
+
if (hasOnlyImage && !ariaLabel && !ariaLabelledBy && !title) {
|
|
377
|
+
linksWithoutAriaLabel++;
|
|
378
|
+
}
|
|
379
|
+
});
|
|
380
|
+
let inputsWithoutLabel = 0;
|
|
381
|
+
this.$('input:not([type="hidden"]):not([type="submit"]):not([type="button"]):not([type="reset"]), select, textarea').each((_, el) => {
|
|
382
|
+
const $el = this.$(el);
|
|
383
|
+
const id = $el.attr('id');
|
|
384
|
+
const ariaLabel = $el.attr('aria-label');
|
|
385
|
+
const ariaLabelledBy = $el.attr('aria-labelledby');
|
|
386
|
+
const placeholder = $el.attr('placeholder');
|
|
387
|
+
const title = $el.attr('title');
|
|
388
|
+
const hasLabel = id ? this.$(`label[for="${id}"]`).length > 0 : false;
|
|
389
|
+
const wrappedInLabel = $el.closest('label').length > 0;
|
|
390
|
+
if (!hasLabel && !wrappedInLabel && !ariaLabel && !ariaLabelledBy && !title && !placeholder) {
|
|
391
|
+
inputsWithoutLabel++;
|
|
392
|
+
}
|
|
393
|
+
});
|
|
394
|
+
const iframesWithoutTitle = this.$('iframe:not([title])').length;
|
|
395
|
+
let tablesWithoutCaption = 0;
|
|
396
|
+
this.$('table').each((_, el) => {
|
|
397
|
+
const $el = this.$(el);
|
|
398
|
+
const hasCaption = $el.find('caption').length > 0;
|
|
399
|
+
const ariaLabel = $el.attr('aria-label');
|
|
400
|
+
const ariaLabelledBy = $el.attr('aria-labelledby');
|
|
401
|
+
const role = $el.attr('role');
|
|
402
|
+
if (role === 'presentation' || role === 'none')
|
|
403
|
+
return;
|
|
404
|
+
const hasHeaders = $el.find('th').length > 0;
|
|
405
|
+
if (hasHeaders && !hasCaption && !ariaLabel && !ariaLabelledBy) {
|
|
406
|
+
tablesWithoutCaption++;
|
|
407
|
+
}
|
|
408
|
+
});
|
|
409
|
+
let svgsWithoutTitle = 0;
|
|
410
|
+
this.$('svg').each((_, el) => {
|
|
411
|
+
const $el = this.$(el);
|
|
412
|
+
const hasTitle = $el.find('title').length > 0;
|
|
413
|
+
const ariaLabel = $el.attr('aria-label');
|
|
414
|
+
const ariaLabelledBy = $el.attr('aria-labelledby');
|
|
415
|
+
const ariaHidden = $el.attr('aria-hidden');
|
|
416
|
+
const role = $el.attr('role');
|
|
417
|
+
if (ariaHidden === 'true' || role === 'presentation' || role === 'none')
|
|
418
|
+
return;
|
|
419
|
+
if (!hasTitle && !ariaLabel && !ariaLabelledBy) {
|
|
420
|
+
svgsWithoutTitle++;
|
|
421
|
+
}
|
|
422
|
+
});
|
|
423
|
+
return {
|
|
424
|
+
buttonsWithoutAriaLabel,
|
|
425
|
+
linksWithoutAriaLabel,
|
|
426
|
+
inputsWithoutLabel,
|
|
427
|
+
iframesWithoutTitle,
|
|
428
|
+
tablesWithoutCaption,
|
|
429
|
+
svgsWithoutTitle,
|
|
430
|
+
};
|
|
431
|
+
}
|
|
432
|
+
analyzeStructuralHtml() {
|
|
433
|
+
return {
|
|
434
|
+
hasHeader: this.$('header').length > 0,
|
|
435
|
+
hasNav: this.$('nav').length > 0,
|
|
436
|
+
hasMain: this.$('main').length > 0,
|
|
437
|
+
hasArticle: this.$('article').length > 0,
|
|
438
|
+
hasSection: this.$('section').length > 0,
|
|
439
|
+
hasFooter: this.$('footer').length > 0,
|
|
440
|
+
};
|
|
441
|
+
}
|
|
442
|
+
analyzeBreadcrumbs(jsonLdTypes) {
|
|
443
|
+
const hasBreadcrumbsHtml = this.$('nav[aria-label="breadcrumb"], .breadcrumb, .breadcrumbs').length > 0;
|
|
444
|
+
const hasBreadcrumbsSchema = jsonLdTypes.includes('BreadcrumbList');
|
|
445
|
+
return { hasBreadcrumbsHtml, hasBreadcrumbsSchema };
|
|
446
|
+
}
|
|
447
|
+
analyzeMultimedia() {
|
|
448
|
+
return {
|
|
449
|
+
videoCount: this.$('video').length,
|
|
450
|
+
audioCount: this.$('audio').length,
|
|
451
|
+
};
|
|
452
|
+
}
|
|
453
|
+
analyzeTrustSignals(links) {
|
|
454
|
+
const linkHrefs = links.map(l => l.href.toLowerCase());
|
|
455
|
+
return {
|
|
456
|
+
hasAboutPageLink: linkHrefs.some(href => href.includes('about') || href.includes('quem-somos')),
|
|
457
|
+
hasContactPageLink: linkHrefs.some(href => href.includes('contact') || href.includes('contato')),
|
|
458
|
+
hasPrivacyPolicyLink: linkHrefs.some(href => href.includes('privacy') || href.includes('privacidade')),
|
|
459
|
+
hasTermsOfServiceLink: linkHrefs.some(href => href.includes('terms') || href.includes('termos-de-uso')),
|
|
460
|
+
};
|
|
461
|
+
}
|
|
462
|
+
calculateTextHtmlRatio(bodyTextLength) {
|
|
463
|
+
const htmlSize = this.$('html').html()?.length;
|
|
464
|
+
if (htmlSize && htmlSize > 0) {
|
|
465
|
+
return (bodyTextLength / htmlSize) * 100;
|
|
466
|
+
}
|
|
467
|
+
return undefined;
|
|
468
|
+
}
|
|
469
|
+
convertToCheckResults(results) {
|
|
470
|
+
return results.map((r) => ({
|
|
471
|
+
name: r.name,
|
|
472
|
+
status: r.status,
|
|
473
|
+
message: r.message,
|
|
474
|
+
value: r.value,
|
|
475
|
+
recommendation: r.recommendation,
|
|
476
|
+
evidence: r.evidence,
|
|
477
|
+
}));
|
|
478
|
+
}
|
|
479
|
+
analyzeHeadings() {
|
|
480
|
+
const issues = [];
|
|
481
|
+
const structure = [];
|
|
482
|
+
const sectionWordCounts = [];
|
|
483
|
+
const counts = { 1: 0, 2: 0, 3: 0, 4: 0, 5: 0, 6: 0 };
|
|
484
|
+
let currentSectionWordCount = 0;
|
|
485
|
+
let inSection = false;
|
|
486
|
+
this.$('body').find('*').each((_, el) => {
|
|
487
|
+
const tagName = el.tagName.toLowerCase();
|
|
488
|
+
if (tagName.match(/^h[1-6]$/)) {
|
|
489
|
+
const level = parseInt(tagName.substring(1), 10);
|
|
490
|
+
const text = this.$(el).text().trim();
|
|
491
|
+
counts[level] = (counts[level] || 0) + 1;
|
|
492
|
+
structure.push({ level, text: text.slice(0, 80), count: 1 });
|
|
493
|
+
if (level === 2) {
|
|
494
|
+
if (inSection) {
|
|
495
|
+
sectionWordCounts.push(currentSectionWordCount);
|
|
496
|
+
}
|
|
497
|
+
currentSectionWordCount = 0;
|
|
498
|
+
inSection = true;
|
|
499
|
+
}
|
|
500
|
+
}
|
|
501
|
+
else if (inSection && ['p', 'ul', 'ol', 'div', 'article', 'section'].includes(tagName)) {
|
|
502
|
+
const text = this.$(el).clone().children().remove().end().text().trim();
|
|
503
|
+
if (text.length > 0) {
|
|
504
|
+
currentSectionWordCount += text.split(/\s+/).length;
|
|
505
|
+
}
|
|
506
|
+
}
|
|
507
|
+
});
|
|
508
|
+
if (inSection) {
|
|
509
|
+
sectionWordCounts.push(currentSectionWordCount);
|
|
510
|
+
}
|
|
511
|
+
let hasProperHierarchy = true;
|
|
512
|
+
let prevLevel = 0;
|
|
513
|
+
for (const heading of structure) {
|
|
514
|
+
if (heading.level > prevLevel + 1 && prevLevel !== 0) {
|
|
515
|
+
hasProperHierarchy = false;
|
|
516
|
+
issues.push(`Skipped heading level: H${prevLevel} to H${heading.level}`);
|
|
517
|
+
}
|
|
518
|
+
prevLevel = heading.level;
|
|
519
|
+
}
|
|
520
|
+
if (counts[1] === 0) {
|
|
521
|
+
issues.push('No H1 tag found');
|
|
522
|
+
}
|
|
523
|
+
else if (counts[1] > 1) {
|
|
524
|
+
issues.push('Multiple H1 tags');
|
|
525
|
+
}
|
|
526
|
+
return {
|
|
527
|
+
structure,
|
|
528
|
+
h1Count: counts[1],
|
|
529
|
+
hasProperHierarchy,
|
|
530
|
+
issues,
|
|
531
|
+
sectionWordCounts,
|
|
532
|
+
};
|
|
533
|
+
}
|
|
534
|
+
analyzeContent(headings) {
|
|
535
|
+
const $body = this.$('body').clone();
|
|
536
|
+
$body.find('script, style, noscript, svg, header, footer, nav').remove();
|
|
537
|
+
const bodyText = $body.text().replace(/\s+/g, ' ').trim();
|
|
538
|
+
const words = bodyText.split(/\s+/).filter((w) => w.length > 0);
|
|
539
|
+
const sentences = bodyText.split(/[.!?]+/).filter((s) => s.trim().length > 0);
|
|
540
|
+
const paragraphs = this.$('p');
|
|
541
|
+
let totalParagraphLength = 0;
|
|
542
|
+
const paragraphWordCounts = [];
|
|
543
|
+
paragraphs.each((_, el) => {
|
|
544
|
+
const text = this.$(el).text().trim();
|
|
545
|
+
totalParagraphLength += text.length;
|
|
546
|
+
const pWords = text.split(/\s+/).filter(w => w.length > 0).length;
|
|
547
|
+
if (pWords > 0)
|
|
548
|
+
paragraphWordCounts.push(pWords);
|
|
549
|
+
});
|
|
550
|
+
const wordCount = words.length;
|
|
551
|
+
const readingTimeMinutes = Math.ceil(wordCount / 200);
|
|
552
|
+
const avgWordsPerSentence = sentences.length > 0 ? Math.round(wordCount / sentences.length) : 0;
|
|
553
|
+
let faqCount = 0;
|
|
554
|
+
headings.structure.forEach(h => {
|
|
555
|
+
if (h.level === 3 && /^(what|how|why|when|where|who|can|do|is|are)\b/i.test(h.text)) {
|
|
556
|
+
faqCount++;
|
|
557
|
+
}
|
|
558
|
+
});
|
|
559
|
+
const imageCount = this.$('img').length;
|
|
560
|
+
const imagePerWordRatio = wordCount > 0 ? imageCount / wordCount : 0;
|
|
561
|
+
const fleschReadingEase = undefined;
|
|
562
|
+
const hasQuestionHeadings = headings.structure.some((h) => (h.level === 2 || h.level === 3) && /^(what|how|why|when|where|who|can|do|is|are)\b/i.test(h.text));
|
|
563
|
+
return {
|
|
564
|
+
wordCount,
|
|
565
|
+
characterCount: bodyText.length,
|
|
566
|
+
sentenceCount: sentences.length,
|
|
567
|
+
paragraphCount: paragraphs.length,
|
|
568
|
+
readingTimeMinutes,
|
|
569
|
+
avgWordsPerSentence,
|
|
570
|
+
avgParagraphLength: paragraphs.length > 0 ? Math.round(totalParagraphLength / paragraphs.length) : 0,
|
|
571
|
+
listCount: this.$('ul, ol').length,
|
|
572
|
+
strongTagCount: this.$('strong').length,
|
|
573
|
+
emTagCount: this.$('em').length,
|
|
574
|
+
paragraphWordCounts,
|
|
575
|
+
avgSentenceLength: avgWordsPerSentence,
|
|
576
|
+
faqCount,
|
|
577
|
+
imagePerWordRatio,
|
|
578
|
+
fleschReadingEase,
|
|
579
|
+
hasQuestionHeadings,
|
|
580
|
+
};
|
|
581
|
+
}
|
|
582
|
+
buildLinkAnalysis(links) {
|
|
583
|
+
return {
|
|
584
|
+
total: links.length,
|
|
585
|
+
internal: links.filter((l) => l.type === 'internal').length,
|
|
586
|
+
external: links.filter((l) => l.type === 'external').length,
|
|
587
|
+
nofollow: links.filter((l) => l.rel?.includes('nofollow')).length,
|
|
588
|
+
sponsoredLinks: links.filter((l) => l.rel?.includes('sponsored')).length,
|
|
589
|
+
ugcLinks: links.filter((l) => l.rel?.includes('ugc')).length,
|
|
590
|
+
broken: 0,
|
|
591
|
+
withoutText: links.filter((l) => !l.text?.trim()).length,
|
|
592
|
+
};
|
|
593
|
+
}
|
|
594
|
+
buildImageAnalysis(images) {
|
|
595
|
+
return {
|
|
596
|
+
total: images.length,
|
|
597
|
+
withAlt: images.filter((i) => i.alt && i.alt.trim().length > 0).length,
|
|
598
|
+
withoutAlt: images.filter((i) => !i.alt || i.alt.trim().length === 0).length,
|
|
599
|
+
lazy: images.filter((i) => i.loading === 'lazy').length,
|
|
600
|
+
missingDimensions: images.filter((i) => !i.width || !i.height).length,
|
|
601
|
+
modernFormats: images.filter((i) => /\.(webp|avif)$/i.test(i.src)).length,
|
|
602
|
+
altTextLengths: images.filter(i => i.alt).map(i => i.alt.length),
|
|
603
|
+
imageFilenames: images.map(i => {
|
|
604
|
+
try {
|
|
605
|
+
const url = new URL(i.src);
|
|
606
|
+
return url.pathname.split('/').pop() || '';
|
|
607
|
+
}
|
|
608
|
+
catch {
|
|
609
|
+
return '';
|
|
610
|
+
}
|
|
611
|
+
}).filter(Boolean),
|
|
612
|
+
imagesWithAsyncDecoding: images.filter(i => i.decoding === 'async').length,
|
|
613
|
+
};
|
|
614
|
+
}
|
|
615
|
+
buildSocialAnalysis(og, twitter) {
|
|
616
|
+
const ogIssues = [];
|
|
617
|
+
const twitterIssues = [];
|
|
618
|
+
const hasOg = !!(og.title || og.description || og.image);
|
|
619
|
+
if (!hasOg) {
|
|
620
|
+
ogIssues.push('No OpenGraph meta tags found');
|
|
621
|
+
}
|
|
622
|
+
else {
|
|
623
|
+
if (!og.title)
|
|
624
|
+
ogIssues.push('Missing og:title');
|
|
625
|
+
if (!og.description)
|
|
626
|
+
ogIssues.push('Missing og:description');
|
|
627
|
+
if (!og.image)
|
|
628
|
+
ogIssues.push('Missing og:image');
|
|
629
|
+
if (!og.url)
|
|
630
|
+
ogIssues.push('Missing og:url');
|
|
631
|
+
}
|
|
632
|
+
const hasTwitter = !!(twitter.card || twitter.title || twitter.description);
|
|
633
|
+
if (!hasTwitter) {
|
|
634
|
+
twitterIssues.push('No Twitter Card meta tags found');
|
|
635
|
+
}
|
|
636
|
+
else {
|
|
637
|
+
if (!twitter.card)
|
|
638
|
+
twitterIssues.push('Missing twitter:card');
|
|
639
|
+
if (!twitter.title)
|
|
640
|
+
twitterIssues.push('Missing twitter:title');
|
|
641
|
+
if (!twitter.description)
|
|
642
|
+
twitterIssues.push('Missing twitter:description');
|
|
643
|
+
}
|
|
644
|
+
return {
|
|
645
|
+
openGraph: {
|
|
646
|
+
present: hasOg,
|
|
647
|
+
hasTitle: !!og.title,
|
|
648
|
+
hasDescription: !!og.description,
|
|
649
|
+
hasImage: !!og.image,
|
|
650
|
+
hasUrl: !!og.url,
|
|
651
|
+
issues: ogIssues,
|
|
652
|
+
},
|
|
653
|
+
twitterCard: {
|
|
654
|
+
present: hasTwitter,
|
|
655
|
+
hasCard: !!twitter.card,
|
|
656
|
+
hasTitle: !!twitter.title,
|
|
657
|
+
hasDescription: !!twitter.description,
|
|
658
|
+
hasImage: !!twitter.image,
|
|
659
|
+
issues: twitterIssues,
|
|
660
|
+
},
|
|
661
|
+
};
|
|
662
|
+
}
|
|
663
|
+
buildTechnicalAnalysis(meta) {
|
|
664
|
+
const htmlLang = this.$('html').attr('lang');
|
|
665
|
+
return {
|
|
666
|
+
hasCanonical: !!meta.canonical,
|
|
667
|
+
canonicalUrl: meta.canonical,
|
|
668
|
+
hasRobotsMeta: !!meta.robots,
|
|
669
|
+
robotsContent: meta.robots,
|
|
670
|
+
hasViewport: !!meta.viewport,
|
|
671
|
+
hasCharset: !!meta.charset,
|
|
672
|
+
hasLang: !!htmlLang,
|
|
673
|
+
langValue: htmlLang,
|
|
674
|
+
};
|
|
675
|
+
}
|
|
676
|
+
calculateScore(checks) {
|
|
677
|
+
const weights = {
|
|
678
|
+
pass: 100,
|
|
679
|
+
warn: 50,
|
|
680
|
+
fail: 0,
|
|
681
|
+
info: 100,
|
|
682
|
+
};
|
|
683
|
+
const scoringChecks = checks.filter((c) => c.status !== 'info');
|
|
684
|
+
if (scoringChecks.length === 0)
|
|
685
|
+
return { score: 100, grade: 'A' };
|
|
686
|
+
const totalWeight = scoringChecks.reduce((sum, check) => sum + weights[check.status], 0);
|
|
687
|
+
const score = Math.round(totalWeight / scoringChecks.length);
|
|
688
|
+
let grade;
|
|
689
|
+
if (score >= 90)
|
|
690
|
+
grade = 'A';
|
|
691
|
+
else if (score >= 80)
|
|
692
|
+
grade = 'B';
|
|
693
|
+
else if (score >= 70)
|
|
694
|
+
grade = 'C';
|
|
695
|
+
else if (score >= 60)
|
|
696
|
+
grade = 'D';
|
|
697
|
+
else
|
|
698
|
+
grade = 'F';
|
|
699
|
+
return { score, grade };
|
|
700
|
+
}
|
|
701
|
+
getRules() {
|
|
702
|
+
return this.rulesEngine.getRules();
|
|
703
|
+
}
|
|
704
|
+
getRulesByCategory(category) {
|
|
705
|
+
return this.rulesEngine.getRulesByCategory(category);
|
|
706
|
+
}
|
|
707
|
+
getCategories() {
|
|
708
|
+
return this.rulesEngine.getCategories();
|
|
709
|
+
}
|
|
710
|
+
}
|
|
711
|
+
export async function analyzeSeo(html, options = {}) {
|
|
712
|
+
const analyzer = await SeoAnalyzer.fromHtml(html, options);
|
|
713
|
+
return analyzer.analyze();
|
|
714
|
+
}
|
|
715
|
+
export { SEO_THRESHOLDS, createRulesEngine, SeoRulesEngine } from './rules/index.js';
|