recker 1.0.27 → 1.0.28-next.c61382b

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (58) hide show
  1. package/dist/browser/scrape/extractors.js +2 -1
  2. package/dist/browser/scrape/types.d.ts +2 -1
  3. package/dist/cli/index.js +142 -3
  4. package/dist/cli/tui/shell.d.ts +1 -0
  5. package/dist/cli/tui/shell.js +157 -0
  6. package/dist/index.d.ts +1 -0
  7. package/dist/index.js +1 -0
  8. package/dist/scrape/extractors.js +2 -1
  9. package/dist/scrape/types.d.ts +2 -1
  10. package/dist/seo/analyzer.d.ts +42 -0
  11. package/dist/seo/analyzer.js +727 -0
  12. package/dist/seo/index.d.ts +5 -0
  13. package/dist/seo/index.js +2 -0
  14. package/dist/seo/rules/accessibility.d.ts +2 -0
  15. package/dist/seo/rules/accessibility.js +128 -0
  16. package/dist/seo/rules/content.d.ts +2 -0
  17. package/dist/seo/rules/content.js +236 -0
  18. package/dist/seo/rules/crawl.d.ts +2 -0
  19. package/dist/seo/rules/crawl.js +307 -0
  20. package/dist/seo/rules/cwv.d.ts +2 -0
  21. package/dist/seo/rules/cwv.js +337 -0
  22. package/dist/seo/rules/ecommerce.d.ts +2 -0
  23. package/dist/seo/rules/ecommerce.js +252 -0
  24. package/dist/seo/rules/i18n.d.ts +2 -0
  25. package/dist/seo/rules/i18n.js +222 -0
  26. package/dist/seo/rules/images.d.ts +2 -0
  27. package/dist/seo/rules/images.js +180 -0
  28. package/dist/seo/rules/index.d.ts +52 -0
  29. package/dist/seo/rules/index.js +135 -0
  30. package/dist/seo/rules/links.d.ts +2 -0
  31. package/dist/seo/rules/links.js +150 -0
  32. package/dist/seo/rules/local.d.ts +2 -0
  33. package/dist/seo/rules/local.js +265 -0
  34. package/dist/seo/rules/meta.d.ts +2 -0
  35. package/dist/seo/rules/meta.js +523 -0
  36. package/dist/seo/rules/mobile.d.ts +2 -0
  37. package/dist/seo/rules/mobile.js +71 -0
  38. package/dist/seo/rules/performance.d.ts +2 -0
  39. package/dist/seo/rules/performance.js +246 -0
  40. package/dist/seo/rules/readability.d.ts +2 -0
  41. package/dist/seo/rules/readability.js +255 -0
  42. package/dist/seo/rules/schema.d.ts +2 -0
  43. package/dist/seo/rules/schema.js +54 -0
  44. package/dist/seo/rules/security.d.ts +2 -0
  45. package/dist/seo/rules/security.js +147 -0
  46. package/dist/seo/rules/structural.d.ts +2 -0
  47. package/dist/seo/rules/structural.js +155 -0
  48. package/dist/seo/rules/technical.d.ts +2 -0
  49. package/dist/seo/rules/technical.js +223 -0
  50. package/dist/seo/rules/thresholds.d.ts +196 -0
  51. package/dist/seo/rules/thresholds.js +118 -0
  52. package/dist/seo/rules/types.d.ts +286 -0
  53. package/dist/seo/rules/types.js +11 -0
  54. package/dist/seo/types.d.ts +160 -0
  55. package/dist/seo/types.js +1 -0
  56. package/dist/utils/columns.d.ts +14 -0
  57. package/dist/utils/columns.js +69 -0
  58. package/package.json +1 -1
@@ -0,0 +1,5 @@
1
+ export { SeoAnalyzer, analyzeSeo } from './analyzer.js';
2
+ export { SeoRulesEngine, createRulesEngine, SEO_THRESHOLDS, ALL_SEO_RULES, } from './rules/index.js';
3
+ export type { SeoReport, SeoCheckResult, SeoStatus, HeadingAnalysis, HeadingInfo, ContentMetrics, LinkAnalysis, ImageAnalysis, SocialMetaAnalysis, TechnicalSeo, SeoAnalyzerOptions, } from './types.js';
4
+ export type { SeoRule, RuleContext, RuleResult, RuleEvidence, RuleCategory, RuleSeverity, RulesEngineOptions, } from './rules/index.js';
5
+ export type { SeoAnalyzerFullOptions } from './analyzer.js';
@@ -0,0 +1,2 @@
1
+ export { SeoAnalyzer, analyzeSeo } from './analyzer.js';
2
+ export { SeoRulesEngine, createRulesEngine, SEO_THRESHOLDS, ALL_SEO_RULES, } from './rules/index.js';
@@ -0,0 +1,2 @@
1
+ import { SeoRule } from './types.js';
2
+ export declare const accessibilityRules: SeoRule[];
@@ -0,0 +1,128 @@
1
+ import { createResult } from './types.js';
2
+ export const accessibilityRules = [
3
+ {
4
+ id: 'a11y-buttons-aria',
5
+ name: 'Button Accessibility',
6
+ category: 'accessibility',
7
+ severity: 'warning',
8
+ description: 'Buttons without visible text must have aria-label',
9
+ check: (ctx) => {
10
+ const count = ctx.buttonsWithoutAriaLabel ?? 0;
11
+ if (count > 0) {
12
+ return createResult({ id: 'a11y-buttons-aria', name: 'Button Accessibility', category: 'accessibility', severity: 'warning' }, 'warn', `${count} button(s) without accessible text/aria-label`, { value: count, recommendation: 'Add aria-label to icon-only buttons' });
13
+ }
14
+ return null;
15
+ },
16
+ },
17
+ {
18
+ id: 'a11y-links-aria',
19
+ name: 'Link Accessibility',
20
+ category: 'accessibility',
21
+ severity: 'warning',
22
+ description: 'Links without visible text must have aria-label',
23
+ check: (ctx) => {
24
+ const count = ctx.linksWithoutAriaLabel ?? 0;
25
+ if (count > 0) {
26
+ return createResult({ id: 'a11y-links-aria', name: 'Link Accessibility', category: 'accessibility', severity: 'warning' }, 'warn', `${count} link(s) without accessible text/aria-label`, { value: count, recommendation: 'Add aria-label to icon-only links' });
27
+ }
28
+ return null;
29
+ },
30
+ },
31
+ {
32
+ id: 'a11y-inputs-label',
33
+ name: 'Input Labels',
34
+ category: 'accessibility',
35
+ severity: 'error',
36
+ description: 'Form inputs must have associated labels',
37
+ check: (ctx) => {
38
+ const count = ctx.inputsWithoutLabel ?? 0;
39
+ if (count > 0) {
40
+ return createResult({ id: 'a11y-inputs-label', name: 'Input Labels', category: 'accessibility', severity: 'error' }, 'fail', `${count} input(s) without associated label`, { value: count, recommendation: 'Add <label for="id"> or aria-label to all form inputs' });
41
+ }
42
+ return null;
43
+ },
44
+ },
45
+ {
46
+ id: 'a11y-iframes-title',
47
+ name: 'Iframe Titles',
48
+ category: 'accessibility',
49
+ severity: 'warning',
50
+ description: 'Iframes must have title attribute',
51
+ check: (ctx) => {
52
+ const count = ctx.iframesWithoutTitle ?? 0;
53
+ if (count > 0) {
54
+ return createResult({ id: 'a11y-iframes-title', name: 'Iframe Titles', category: 'accessibility', severity: 'warning' }, 'warn', `${count} iframe(s) without title attribute`, { value: count, recommendation: 'Add title attribute to describe iframe content' });
55
+ }
56
+ return null;
57
+ },
58
+ },
59
+ {
60
+ id: 'a11y-tables-caption',
61
+ name: 'Table Captions',
62
+ category: 'accessibility',
63
+ severity: 'info',
64
+ description: 'Data tables should have caption or aria-label',
65
+ check: (ctx) => {
66
+ const count = ctx.tablesWithoutCaption ?? 0;
67
+ if (count > 0) {
68
+ return createResult({ id: 'a11y-tables-caption', name: 'Table Captions', category: 'accessibility', severity: 'info' }, 'info', `${count} table(s) without caption/aria-label`, { value: count, recommendation: 'Add <caption> or aria-label to data tables' });
69
+ }
70
+ return null;
71
+ },
72
+ },
73
+ {
74
+ id: 'a11y-svg-title',
75
+ name: 'SVG Accessibility',
76
+ category: 'accessibility',
77
+ severity: 'warning',
78
+ description: 'SVGs should have <title> or aria-label for accessibility',
79
+ check: (ctx) => {
80
+ const count = ctx.svgsWithoutTitle ?? 0;
81
+ if (count > 0) {
82
+ return createResult({ id: 'a11y-svg-title', name: 'SVG Accessibility', category: 'accessibility', severity: 'warning' }, 'warn', `${count} SVG(s) without accessible title`, { value: count, recommendation: 'Add <title> inside SVG or aria-label for decorative SVGs' });
83
+ }
84
+ return null;
85
+ },
86
+ },
87
+ {
88
+ id: 'a11y-images-decorative',
89
+ name: 'Decorative Images',
90
+ category: 'accessibility',
91
+ severity: 'info',
92
+ description: 'Decorative images should have empty alt=""',
93
+ check: (ctx) => {
94
+ const decorative = ctx.imagesDecorativeCount ?? 0;
95
+ const emptyAlt = ctx.imagesWithEmptyAlt ?? 0;
96
+ if (decorative > 0 && emptyAlt === 0) {
97
+ return createResult({ id: 'a11y-images-decorative', name: 'Decorative Images', category: 'accessibility', severity: 'info' }, 'info', 'Some images may be decorative - use alt="" for decorative images', { recommendation: 'For decorative images, use alt="" (empty string) not missing alt' });
98
+ }
99
+ return null;
100
+ },
101
+ },
102
+ {
103
+ id: 'a11y-buttons-aria-label',
104
+ name: 'Buttons with Accessible Name',
105
+ category: 'accessibility',
106
+ severity: 'error',
107
+ description: 'Buttons without text content must have an aria-label or title',
108
+ check: (ctx) => {
109
+ if (ctx.buttonsWithoutAriaLabel && ctx.buttonsWithoutAriaLabel > 0) {
110
+ return createResult({ id: 'a11y-buttons-aria-label', name: 'Buttons Accessible Name', category: 'accessibility', severity: 'error' }, 'fail', `${ctx.buttonsWithoutAriaLabel} button(s) without accessible name found`, { recommendation: 'Add descriptive text, aria-label, aria-labelledby, or title to buttons' });
111
+ }
112
+ return null;
113
+ },
114
+ },
115
+ {
116
+ id: 'a11y-links-aria-label',
117
+ name: 'Links with Accessible Name',
118
+ category: 'accessibility',
119
+ severity: 'error',
120
+ description: 'Icon-only links must have an aria-label or title',
121
+ check: (ctx) => {
122
+ if (ctx.linksWithoutAriaLabel && ctx.linksWithoutAriaLabel > 0) {
123
+ return createResult({ id: 'a11y-links-aria-label', name: 'Links Accessible Name', category: 'accessibility', severity: 'error' }, 'fail', `${ctx.linksWithoutAriaLabel} icon-only link(s) without accessible name found`, { recommendation: 'Add descriptive text, aria-label, aria-labelledby, or title to icon-only links' });
124
+ }
125
+ return null;
126
+ },
127
+ },
128
+ ];
@@ -0,0 +1,2 @@
1
+ import { SeoRule } from './types.js';
2
+ export declare const contentRules: SeoRule[];
@@ -0,0 +1,236 @@
1
+ import { createResult } from './types.js';
2
+ import { SEO_THRESHOLDS } from './thresholds.js';
3
+ export const contentRules = [
4
+ {
5
+ id: 'content-depth-word-count',
6
+ name: 'Content Depth (Word Count)',
7
+ category: 'content',
8
+ severity: 'warning',
9
+ description: 'Page should meet minimum word count for its purpose.',
10
+ check: (ctx) => {
11
+ if (ctx.wordCount === undefined)
12
+ return null;
13
+ const { minWordsSimple, minWordsRanking, minWordsAuthority } = SEO_THRESHOLDS.content;
14
+ const veryThinWords = SEO_THRESHOLDS.thinContent.veryThinWords;
15
+ if (ctx.wordCount < veryThinWords) {
16
+ return createResult({ id: 'content-depth-word-count', name: 'Content Depth', category: 'content', severity: 'error' }, 'fail', `Very thin content (${ctx.wordCount} words, min: ${veryThinWords})`, { recommendation: 'Add more substantial content (at least 150-300 words).' });
17
+ }
18
+ if (ctx.wordCount < minWordsSimple) {
19
+ return createResult({ id: 'content-depth-word-count', name: 'Content Depth', category: 'content', severity: 'warning' }, 'warn', `Thin content (${ctx.wordCount} words, min for simple: ${minWordsSimple})`, { recommendation: `Consider expanding to at least ${minWordsSimple} words for simple pages, or more for ranking.` });
20
+ }
21
+ if (ctx.wordCount < minWordsRanking) {
22
+ return createResult({ id: 'content-depth-word-count', name: 'Content Depth', category: 'content', severity: 'info' }, 'warn', `Content may be too short to rank (${ctx.wordCount} words, min for ranking: ${minWordsRanking})`, { recommendation: `Expand content to at least ${minWordsRanking} words for competitive keywords.` });
23
+ }
24
+ if (ctx.wordCount < minWordsAuthority) {
25
+ return createResult({ id: 'content-depth-word-count', name: 'Content Depth', category: 'content', severity: 'info' }, 'info', `Content is not authority-level (${ctx.wordCount} words, min for authority: ${minWordsAuthority})`, { recommendation: `For authority content, aim for ${minWordsAuthority} words or more.` });
26
+ }
27
+ return createResult({ id: 'content-depth-word-count', name: 'Content Depth', category: 'content', severity: 'info' }, 'pass', `Good content depth (${ctx.wordCount} words)`, { value: ctx.wordCount });
28
+ },
29
+ },
30
+ {
31
+ id: 'content-readability-sentence-length',
32
+ name: 'Readability (Sentence Length)',
33
+ category: 'content',
34
+ severity: 'info',
35
+ description: 'Sentences should average under 25 words for better readability.',
36
+ check: (ctx) => {
37
+ if (ctx.avgWordsPerSentence === undefined)
38
+ return null;
39
+ const max = SEO_THRESHOLDS.content.maxWordsPerSentence;
40
+ if (ctx.avgWordsPerSentence > max) {
41
+ return createResult({ id: 'content-readability-sentence-length', name: 'Readability', category: 'content', severity: 'info' }, 'warn', `Long sentences (avg ${ctx.avgWordsPerSentence} words/sentence)`, { value: ctx.avgWordsPerSentence, recommendation: `Aim for under ${max} words per sentence for better readability.` });
42
+ }
43
+ return createResult({ id: 'content-readability-sentence-length', name: 'Readability', category: 'content', severity: 'info' }, 'pass', `Good sentence length (avg ${ctx.avgWordsPerSentence} words)`, { value: ctx.avgWordsPerSentence });
44
+ },
45
+ },
46
+ {
47
+ id: 'content-paragraph-length',
48
+ name: 'Paragraph Length',
49
+ category: 'content',
50
+ severity: 'info',
51
+ description: 'Paragraphs should be concise (ideal 40-90 words) for mobile readability.',
52
+ check: (ctx) => {
53
+ if (!ctx.paragraphWordCounts || ctx.paragraphWordCounts.length === 0)
54
+ return null;
55
+ const { minWordsPerParagraph, maxWordsPerParagraph } = SEO_THRESHOLDS.content;
56
+ let tooShort = 0;
57
+ let tooLong = 0;
58
+ ctx.paragraphWordCounts.forEach(count => {
59
+ if (count < minWordsPerParagraph)
60
+ tooShort++;
61
+ if (count > maxWordsPerParagraph)
62
+ tooLong++;
63
+ });
64
+ if (tooShort > 0 || tooLong > 0) {
65
+ let message = '';
66
+ const recs = [];
67
+ if (tooShort > 0) {
68
+ message += `${tooShort} paragraph(s) are too short (min: ${minWordsPerParagraph} words). `;
69
+ recs.push('Expand short paragraphs.');
70
+ }
71
+ if (tooLong > 0) {
72
+ message += `${tooLong} paragraph(s) are too long (max: ${maxWordsPerParagraph} words).`;
73
+ recs.push('Break long paragraphs into smaller ones.');
74
+ }
75
+ return createResult({ id: 'content-paragraph-length', name: 'Paragraph Length', category: 'content', severity: 'warning' }, 'warn', message.trim(), { recommendation: `Aim for paragraphs between ${minWordsPerParagraph}-${maxWordsPerParagraph} words. ${recs.join(' ')}` });
76
+ }
77
+ return null;
78
+ },
79
+ },
80
+ {
81
+ id: 'content-lists-presence',
82
+ name: 'Lists Usage',
83
+ category: 'content',
84
+ severity: 'info',
85
+ description: 'Use lists (ul/ol) to improve readability and SGE compatibility.',
86
+ check: (ctx) => {
87
+ if (ctx.listCount === undefined || ctx.listCount === 0) {
88
+ return createResult({ id: 'content-lists-presence', name: 'Lists Usage', category: 'content', severity: 'info' }, 'info', 'No lists (ul/ol) found', { recommendation: 'Consider using bullet points or numbered lists for better scannability and AI summarization.' });
89
+ }
90
+ return null;
91
+ },
92
+ },
93
+ {
94
+ id: 'content-subheading-frequency',
95
+ name: 'Subheading Frequency',
96
+ category: 'content',
97
+ severity: 'info',
98
+ description: 'Subheadings (H2/H3) should be used frequently to break up content.',
99
+ check: (ctx) => {
100
+ const idealFrequencyPer100Words = 0.5;
101
+ if (ctx.wordCount && ctx.wordCount > 300 && (ctx.subheadingFrequency ?? 0) < idealFrequencyPer100Words) {
102
+ return createResult({ id: 'content-subheading-frequency', name: 'Subheading Frequency', category: 'content', severity: 'info' }, 'warn', `Low subheading frequency (${(ctx.subheadingFrequency ?? 0).toFixed(2)} per 100 words)`, { recommendation: `Add more subheadings (H2/H3) to break up long text blocks.` });
103
+ }
104
+ return null;
105
+ },
106
+ },
107
+ {
108
+ id: 'content-emphasis-tags',
109
+ name: 'Emphasis Tags Usage',
110
+ category: 'content',
111
+ severity: 'info',
112
+ description: 'Use strong/em tags moderately for emphasis, avoid keyword stuffing.',
113
+ check: (ctx) => {
114
+ const totalEmphasisTags = (ctx.strongTagCount ?? 0) + (ctx.emTagCount ?? 0);
115
+ if (ctx.wordCount && ctx.wordCount > 200 && totalEmphasisTags === 0) {
116
+ return createResult({ id: 'content-emphasis-tags', name: 'Emphasis Tags Usage', category: 'content', severity: 'info' }, 'info', 'No strong/em tags found in substantial content', { recommendation: 'Use <strong> or <em> tags to highlight important keywords or phrases.' });
117
+ }
118
+ const emphasisRatio = totalEmphasisTags / (ctx.wordCount || 1);
119
+ const maxEmphasisRatio = 0.05;
120
+ if (ctx.wordCount && ctx.wordCount > 100 && emphasisRatio > maxEmphasisRatio) {
121
+ return createResult({ id: 'content-emphasis-tags', name: 'Emphasis Tags Usage', category: 'content', severity: 'warning' }, 'warn', `Potentially excessive emphasis tags (${totalEmphasisTags} tags for ${ctx.wordCount} words)`, { recommendation: 'Moderate the use of <strong> and <em> tags to avoid over-optimization.' });
122
+ }
123
+ return null;
124
+ },
125
+ },
126
+ {
127
+ id: 'multimedia-video-audio',
128
+ name: 'Multimedia Content',
129
+ category: 'content',
130
+ severity: 'info',
131
+ description: 'Rich content with videos and audio can enhance user experience.',
132
+ check: (ctx) => {
133
+ const totalMultimedia = (ctx.videoCount ?? 0) + (ctx.audioCount ?? 0);
134
+ if (totalMultimedia === 0 && ctx.wordCount && ctx.wordCount > 500) {
135
+ return createResult({ id: 'multimedia-video-audio', name: 'Multimedia Content', category: 'content', severity: 'info' }, 'info', 'No video or audio elements found for substantial content', { recommendation: 'Consider adding relevant videos, audio, or other rich media to engage users.' });
136
+ }
137
+ return null;
138
+ },
139
+ },
140
+ {
141
+ id: 'content-sge-optimization',
142
+ name: 'AI Overview (SGE) Optimization',
143
+ category: 'content',
144
+ severity: 'info',
145
+ description: 'Optimize content for Google AI Overviews by using question-based headings and clear lists.',
146
+ check: (ctx) => {
147
+ if (ctx.wordCount && ctx.wordCount > 300) {
148
+ let messages = [];
149
+ let recommendation = '';
150
+ if (!ctx.hasQuestionHeadings) {
151
+ messages.push('No question-based headings (H2/H3) found.');
152
+ recommendation += 'Use H2/H3 headings that phrase common questions (e.g., "What is X?", "How to do Y?"). ';
153
+ }
154
+ if (ctx.listCount === 0) {
155
+ messages.push('No lists (ul/ol) found.');
156
+ recommendation += 'Incorporate clear numbered or bulleted lists for steps, features, or summaries.';
157
+ }
158
+ if (messages.length > 0) {
159
+ return createResult({ id: 'content-sge-optimization', name: 'AI Overview Optimization', category: 'content', severity: 'info' }, 'info', `Content could be better optimized for AI Overviews: ${messages.join(' ')}`, { recommendation: recommendation.trim() });
160
+ }
161
+ }
162
+ return null;
163
+ },
164
+ },
165
+ {
166
+ id: 'content-flesch-readability',
167
+ name: 'Flesch Reading Ease Score',
168
+ category: 'content',
169
+ severity: 'info',
170
+ description: 'Measures readability, aiming for a Flesch score above 60 for broad audiences.',
171
+ check: (ctx) => {
172
+ if (ctx.fleschReadingEase === undefined) {
173
+ return createResult({ id: 'content-flesch-readability', name: 'Flesch Reading Ease', category: 'content', severity: 'info' }, 'info', 'Flesch Reading Ease score could not be calculated.', { recommendation: 'To enable Flesch score analysis, ensure the analyzer has capabilities to count syllables per word.' });
174
+ }
175
+ const score = ctx.fleschReadingEase;
176
+ if (score < 60) {
177
+ return createResult({ id: 'content-flesch-readability', name: 'Flesch Reading Ease', category: 'content', severity: 'warning' }, 'warn', `Low Flesch Reading Ease score: ${score.toFixed(2)} (target > 60)`, { value: score, recommendation: 'Simplify sentence structure and vocabulary to improve readability for a broader audience.' });
178
+ }
179
+ return createResult({ id: 'content-flesch-readability', name: 'Flesch Reading Ease', category: 'content', severity: 'info' }, 'pass', `Good Flesch Reading Ease score: ${score.toFixed(2)}`, { value: score });
180
+ },
181
+ },
182
+ {
183
+ id: 'content-faq-mandatory',
184
+ name: 'Mandatory FAQ Section',
185
+ category: 'content',
186
+ severity: 'info',
187
+ description: 'For comprehensive content, an FAQ section is recommended.',
188
+ check: (ctx) => {
189
+ if (ctx.wordCount && ctx.wordCount > SEO_THRESHOLDS.content.minWordsAuthority && (ctx.faqCount ?? 0) < 3) {
190
+ return createResult({ id: 'content-faq-mandatory', name: 'FAQ Section', category: 'content', severity: 'info' }, 'warn', 'Consider adding a dedicated FAQ section for comprehensive content', { recommendation: 'Include 3-7 common questions as H3s and optionally use Schema.org FAQPage markup for rich results.' });
191
+ }
192
+ return null;
193
+ },
194
+ },
195
+ {
196
+ id: 'content-image-text-proportion',
197
+ name: 'Image-Text Proportion',
198
+ category: 'content',
199
+ severity: 'info',
200
+ description: 'Maintain a healthy image-to-text ratio for engaging content.',
201
+ check: (ctx) => {
202
+ if (ctx.wordCount === undefined || ctx.wordCount < 200 || ctx.totalImages === undefined || ctx.totalImages === 0)
203
+ return null;
204
+ const { min: minWords, max: maxWords } = SEO_THRESHOLDS.content.imageWordRatio;
205
+ const actualWordsPerImage = ctx.wordCount / ctx.totalImages;
206
+ if (actualWordsPerImage > maxWords) {
207
+ return createResult({ id: 'content-image-text-proportion', name: 'Image-Text Proportion', category: 'content', severity: 'info' }, 'warn', `Low image density (1 image per ${actualWordsPerImage.toFixed(0)} words, ideal: 1 per ${minWords}-${maxWords})`, { recommendation: 'Add more relevant images to break up text and improve engagement.' });
208
+ }
209
+ if (actualWordsPerImage < minWords) {
210
+ return createResult({ id: 'content-image-text-proportion', name: 'Image-Text Proportion', category: 'content', severity: 'info' }, 'warn', `High image density (1 image per ${actualWordsPerImage.toFixed(0)} words, ideal: 1 per ${minWords}-${maxWords})`, { recommendation: 'Ensure images are relevant and not excessive, as too many can be distracting.' });
211
+ }
212
+ return null;
213
+ },
214
+ },
215
+ {
216
+ id: 'content-main-keyword-redundancy',
217
+ name: 'Main Keyword Redundancy',
218
+ category: 'content',
219
+ severity: 'info',
220
+ description: 'Avoid keyword stuffing in key areas (title, H1, first paragraph, image alt).',
221
+ check: (ctx) => {
222
+ if (!ctx.mainKeyword || !ctx.title || !ctx.h1Text || !ctx.metaDescription || !ctx.paragraphWordCounts || ctx.paragraphWordCounts.length === 0)
223
+ return null;
224
+ const keyword = ctx.mainKeyword.toLowerCase();
225
+ let redundancyCount = 0;
226
+ if (ctx.title.toLowerCase().includes(keyword))
227
+ redundancyCount++;
228
+ if (ctx.h1Text.toLowerCase().includes(keyword))
229
+ redundancyCount++;
230
+ if (redundancyCount > SEO_THRESHOLDS.content.redundancyTolerance) {
231
+ return createResult({ id: 'content-main-keyword-redundancy', name: 'Main Keyword Redundancy', category: 'content', severity: 'warning' }, 'warn', `Main keyword "${ctx.mainKeyword}" appears too often in key SEO elements.`, { recommendation: 'Ensure natural language use of keywords. Avoid over-optimization (keyword stuffing) in title, H1, meta description, and alt texts.' });
232
+ }
233
+ return null;
234
+ },
235
+ },
236
+ ];
@@ -0,0 +1,2 @@
1
+ import { SeoRule } from './types.js';
2
+ export declare const crawlRules: SeoRule[];