@rankcli/agent-runtime 0.0.1
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/README.md +242 -0
- package/dist/analyzer-2CSWIQGD.mjs +6 -0
- package/dist/chunk-YNZYHEYM.mjs +774 -0
- package/dist/index.d.mts +4012 -0
- package/dist/index.d.ts +4012 -0
- package/dist/index.js +29672 -0
- package/dist/index.mjs +28602 -0
- package/package.json +53 -0
- package/scripts/build-deno.ts +134 -0
- package/src/audit/ai/analyzer.ts +347 -0
- package/src/audit/ai/index.ts +29 -0
- package/src/audit/ai/prompts/content-analysis.ts +271 -0
- package/src/audit/ai/types.ts +179 -0
- package/src/audit/checks/additional-checks.ts +439 -0
- package/src/audit/checks/ai-citation-worthiness.ts +399 -0
- package/src/audit/checks/ai-content-structure.ts +325 -0
- package/src/audit/checks/ai-readiness.ts +339 -0
- package/src/audit/checks/anchor-text.ts +179 -0
- package/src/audit/checks/answer-conciseness.ts +322 -0
- package/src/audit/checks/asset-minification.ts +270 -0
- package/src/audit/checks/bing-optimization.ts +206 -0
- package/src/audit/checks/brand-mention-optimization.ts +349 -0
- package/src/audit/checks/caching-headers.ts +305 -0
- package/src/audit/checks/canonical-advanced.ts +150 -0
- package/src/audit/checks/canonical-domain.ts +196 -0
- package/src/audit/checks/citation-quality.ts +358 -0
- package/src/audit/checks/client-rendering.ts +542 -0
- package/src/audit/checks/color-contrast.ts +342 -0
- package/src/audit/checks/content-freshness.ts +170 -0
- package/src/audit/checks/content-science.ts +589 -0
- package/src/audit/checks/conversion-elements.ts +526 -0
- package/src/audit/checks/crawlability.ts +220 -0
- package/src/audit/checks/directory-listing.ts +172 -0
- package/src/audit/checks/dom-analysis.ts +191 -0
- package/src/audit/checks/dom-size.ts +246 -0
- package/src/audit/checks/duplicate-content.ts +194 -0
- package/src/audit/checks/eeat-signals.ts +990 -0
- package/src/audit/checks/entity-seo.ts +396 -0
- package/src/audit/checks/featured-snippet.ts +473 -0
- package/src/audit/checks/freshness-signals.ts +443 -0
- package/src/audit/checks/funnel-intent.ts +463 -0
- package/src/audit/checks/hreflang.ts +174 -0
- package/src/audit/checks/html-compliance.ts +302 -0
- package/src/audit/checks/image-dimensions.ts +167 -0
- package/src/audit/checks/images.ts +160 -0
- package/src/audit/checks/indexnow.ts +275 -0
- package/src/audit/checks/interactive-tools.ts +475 -0
- package/src/audit/checks/internal-link-graph.ts +436 -0
- package/src/audit/checks/keyword-analysis.ts +239 -0
- package/src/audit/checks/keyword-cannibalization.ts +385 -0
- package/src/audit/checks/keyword-placement.ts +471 -0
- package/src/audit/checks/links.ts +203 -0
- package/src/audit/checks/llms-txt.ts +224 -0
- package/src/audit/checks/local-seo.ts +296 -0
- package/src/audit/checks/mobile.ts +167 -0
- package/src/audit/checks/modern-images.ts +226 -0
- package/src/audit/checks/navboost-signals.ts +395 -0
- package/src/audit/checks/on-page.ts +209 -0
- package/src/audit/checks/page-resources.ts +285 -0
- package/src/audit/checks/pagination.ts +180 -0
- package/src/audit/checks/performance.ts +153 -0
- package/src/audit/checks/platform-presence.ts +580 -0
- package/src/audit/checks/redirect-analysis.ts +153 -0
- package/src/audit/checks/redirect-chain.ts +389 -0
- package/src/audit/checks/resource-hints.ts +420 -0
- package/src/audit/checks/responsive-css.ts +247 -0
- package/src/audit/checks/responsive-images.ts +396 -0
- package/src/audit/checks/review-ecosystem.ts +415 -0
- package/src/audit/checks/robots-validation.ts +373 -0
- package/src/audit/checks/security-headers.ts +172 -0
- package/src/audit/checks/security.ts +144 -0
- package/src/audit/checks/serp-preview.ts +251 -0
- package/src/audit/checks/site-maturity.ts +444 -0
- package/src/audit/checks/social-meta.test.ts +275 -0
- package/src/audit/checks/social-meta.ts +134 -0
- package/src/audit/checks/soft-404.ts +151 -0
- package/src/audit/checks/structured-data.ts +238 -0
- package/src/audit/checks/tech-detection.ts +496 -0
- package/src/audit/checks/topical-clusters.ts +435 -0
- package/src/audit/checks/tracker-bloat.ts +462 -0
- package/src/audit/checks/tracking-verification.test.ts +371 -0
- package/src/audit/checks/tracking-verification.ts +636 -0
- package/src/audit/checks/url-safety.ts +682 -0
- package/src/audit/deno-entry.ts +66 -0
- package/src/audit/discovery/index.ts +15 -0
- package/src/audit/discovery/link-crawler.ts +232 -0
- package/src/audit/discovery/repo-routes.ts +347 -0
- package/src/audit/engine.ts +620 -0
- package/src/audit/fixes/index.ts +209 -0
- package/src/audit/fixes/social-meta-fixes.test.ts +329 -0
- package/src/audit/fixes/social-meta-fixes.ts +463 -0
- package/src/audit/index.ts +74 -0
- package/src/audit/runner.test.ts +299 -0
- package/src/audit/runner.ts +130 -0
- package/src/audit/types.ts +1953 -0
- package/src/content/featured-snippet.ts +367 -0
- package/src/content/generator.test.ts +534 -0
- package/src/content/generator.ts +501 -0
- package/src/content/headline.ts +317 -0
- package/src/content/index.ts +62 -0
- package/src/content/intent.ts +258 -0
- package/src/content/keyword-density.ts +349 -0
- package/src/content/readability.ts +262 -0
- package/src/executor.ts +336 -0
- package/src/fixer.ts +416 -0
- package/src/frameworks/detector.test.ts +248 -0
- package/src/frameworks/detector.ts +371 -0
- package/src/frameworks/index.ts +68 -0
- package/src/frameworks/recipes/angular.yaml +171 -0
- package/src/frameworks/recipes/astro.yaml +206 -0
- package/src/frameworks/recipes/django.yaml +180 -0
- package/src/frameworks/recipes/laravel.yaml +137 -0
- package/src/frameworks/recipes/nextjs.yaml +268 -0
- package/src/frameworks/recipes/nuxt.yaml +175 -0
- package/src/frameworks/recipes/rails.yaml +188 -0
- package/src/frameworks/recipes/react.yaml +202 -0
- package/src/frameworks/recipes/sveltekit.yaml +154 -0
- package/src/frameworks/recipes/vue.yaml +137 -0
- package/src/frameworks/recipes/wordpress.yaml +209 -0
- package/src/frameworks/suggestion-engine.ts +320 -0
- package/src/geo/geo-content.test.ts +305 -0
- package/src/geo/geo-content.ts +266 -0
- package/src/geo/geo-history.test.ts +473 -0
- package/src/geo/geo-history.ts +433 -0
- package/src/geo/geo-tracker.test.ts +359 -0
- package/src/geo/geo-tracker.ts +411 -0
- package/src/geo/index.ts +10 -0
- package/src/git/commit-helper.test.ts +261 -0
- package/src/git/commit-helper.ts +329 -0
- package/src/git/index.ts +12 -0
- package/src/git/pr-helper.test.ts +284 -0
- package/src/git/pr-helper.ts +307 -0
- package/src/index.ts +66 -0
- package/src/keywords/ai-keyword-engine.ts +1062 -0
- package/src/keywords/ai-summarizer.ts +387 -0
- package/src/keywords/ci-mode.ts +555 -0
- package/src/keywords/engine.ts +359 -0
- package/src/keywords/index.ts +151 -0
- package/src/keywords/llm-judge.ts +357 -0
- package/src/keywords/nlp-analysis.ts +706 -0
- package/src/keywords/prioritizer.ts +295 -0
- package/src/keywords/site-crawler.ts +342 -0
- package/src/keywords/sources/autocomplete.ts +139 -0
- package/src/keywords/sources/competitive-search.ts +450 -0
- package/src/keywords/sources/competitor-analysis.ts +374 -0
- package/src/keywords/sources/dataforseo.ts +206 -0
- package/src/keywords/sources/free-sources.ts +294 -0
- package/src/keywords/sources/gsc.ts +123 -0
- package/src/keywords/topic-grouping.ts +327 -0
- package/src/keywords/types.ts +144 -0
- package/src/keywords/wizard.ts +457 -0
- package/src/loader.ts +40 -0
- package/src/reports/index.ts +7 -0
- package/src/reports/report-generator.test.ts +293 -0
- package/src/reports/report-generator.ts +713 -0
- package/src/scheduler/alerts.test.ts +458 -0
- package/src/scheduler/alerts.ts +328 -0
- package/src/scheduler/index.ts +8 -0
- package/src/scheduler/scheduled-audit.test.ts +377 -0
- package/src/scheduler/scheduled-audit.ts +149 -0
- package/src/test/integration-test.ts +325 -0
- package/src/tools/analyzer.ts +373 -0
- package/src/tools/crawl.ts +293 -0
- package/src/tools/files.ts +301 -0
- package/src/tools/h1-fixer.ts +249 -0
- package/src/tools/index.ts +67 -0
- package/src/tracking/github-action.ts +326 -0
- package/src/tracking/google-analytics.ts +265 -0
- package/src/tracking/index.ts +45 -0
- package/src/tracking/report-generator.ts +386 -0
- package/src/tracking/search-console.ts +335 -0
- package/src/types.ts +134 -0
- package/src/utils/http.ts +302 -0
- package/src/wasm-adapter.ts +297 -0
- package/src/wasm-entry.ts +14 -0
- package/tsconfig.json +17 -0
- package/tsup.wasm.config.ts +26 -0
- package/vitest.config.ts +15 -0
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
// Modern Image Format Checks
|
|
2
|
+
// Checks for WebP/AVIF usage and image optimization
|
|
3
|
+
|
|
4
|
+
import * as cheerio from 'cheerio';
|
|
5
|
+
import type { AuditIssue } from '../types.js';
|
|
6
|
+
import { ISSUE_DEFINITIONS } from '../types.js';
|
|
7
|
+
|
|
8
|
+
// File extensions for modern formats
|
|
9
|
+
const MODERN_FORMATS = ['.webp', '.avif'];
|
|
10
|
+
const LEGACY_FORMATS = ['.jpg', '.jpeg', '.png', '.gif', '.bmp'];
|
|
11
|
+
|
|
12
|
+
export interface ImageInfo {
|
|
13
|
+
src: string;
|
|
14
|
+
alt: string | null;
|
|
15
|
+
width: number | null;
|
|
16
|
+
height: number | null;
|
|
17
|
+
loading: string | null;
|
|
18
|
+
format: string;
|
|
19
|
+
isModernFormat: boolean;
|
|
20
|
+
hasDimensions: boolean;
|
|
21
|
+
isLazyLoaded: boolean;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface ModernImagesData {
|
|
25
|
+
totalImages: number;
|
|
26
|
+
modernFormatCount: number;
|
|
27
|
+
legacyFormatCount: number;
|
|
28
|
+
modernFormatPercentage: number;
|
|
29
|
+
images: ImageInfo[];
|
|
30
|
+
hasSourceSet: boolean;
|
|
31
|
+
hasPictureElements: boolean;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Extract format from URL
|
|
36
|
+
*/
|
|
37
|
+
function getImageFormat(url: string): string {
|
|
38
|
+
try {
|
|
39
|
+
const pathname = new URL(url, 'https://example.com').pathname.toLowerCase();
|
|
40
|
+
|
|
41
|
+
// Check for format in query string (common with CDNs)
|
|
42
|
+
const formatMatch = url.match(/[?&](?:format|f)=(\w+)/i);
|
|
43
|
+
if (formatMatch) {
|
|
44
|
+
return '.' + formatMatch[1].toLowerCase();
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// Check file extension
|
|
48
|
+
const extMatch = pathname.match(/\.([a-z0-9]+)$/i);
|
|
49
|
+
if (extMatch) {
|
|
50
|
+
return '.' + extMatch[1].toLowerCase();
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
return 'unknown';
|
|
54
|
+
} catch {
|
|
55
|
+
return 'unknown';
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Check if format is modern
|
|
61
|
+
*/
|
|
62
|
+
function isModernFormat(format: string): boolean {
|
|
63
|
+
return MODERN_FORMATS.includes(format.toLowerCase());
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Extract image information from HTML
|
|
68
|
+
*/
|
|
69
|
+
function extractImages($: cheerio.CheerioAPI, baseUrl: string): ImageInfo[] {
|
|
70
|
+
const images: ImageInfo[] = [];
|
|
71
|
+
|
|
72
|
+
// Process img tags
|
|
73
|
+
$('img').each((_, el) => {
|
|
74
|
+
const src = $(el).attr('src') || $(el).attr('data-src') || '';
|
|
75
|
+
const srcset = $(el).attr('srcset') || '';
|
|
76
|
+
const alt = $(el).attr('alt') || null;
|
|
77
|
+
const width = $(el).attr('width') ? parseInt($(el).attr('width')!, 10) : null;
|
|
78
|
+
const height = $(el).attr('height') ? parseInt($(el).attr('height')!, 10) : null;
|
|
79
|
+
const loading = $(el).attr('loading') || null;
|
|
80
|
+
|
|
81
|
+
// Skip data URIs and empty sources
|
|
82
|
+
if (!src || src.startsWith('data:')) return;
|
|
83
|
+
|
|
84
|
+
// Resolve relative URLs
|
|
85
|
+
let fullSrc = src;
|
|
86
|
+
try {
|
|
87
|
+
fullSrc = new URL(src, baseUrl).href;
|
|
88
|
+
} catch {
|
|
89
|
+
// Keep original if URL parsing fails
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const format = getImageFormat(fullSrc);
|
|
93
|
+
|
|
94
|
+
images.push({
|
|
95
|
+
src: fullSrc,
|
|
96
|
+
alt,
|
|
97
|
+
width,
|
|
98
|
+
height,
|
|
99
|
+
loading,
|
|
100
|
+
format,
|
|
101
|
+
isModernFormat: isModernFormat(format),
|
|
102
|
+
hasDimensions: width !== null && height !== null,
|
|
103
|
+
isLazyLoaded: loading === 'lazy' || $(el).attr('data-src') !== undefined,
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
// Check srcset for modern formats
|
|
107
|
+
if (srcset) {
|
|
108
|
+
const srcsetUrls = srcset.split(',').map((s) => s.trim().split(' ')[0]);
|
|
109
|
+
for (const srcsetUrl of srcsetUrls) {
|
|
110
|
+
const srcsetFormat = getImageFormat(srcsetUrl);
|
|
111
|
+
if (isModernFormat(srcsetFormat) && !images.some((img) => img.src === srcsetUrl)) {
|
|
112
|
+
images.push({
|
|
113
|
+
src: srcsetUrl,
|
|
114
|
+
alt,
|
|
115
|
+
width: null,
|
|
116
|
+
height: null,
|
|
117
|
+
loading,
|
|
118
|
+
format: srcsetFormat,
|
|
119
|
+
isModernFormat: true,
|
|
120
|
+
hasDimensions: false,
|
|
121
|
+
isLazyLoaded: loading === 'lazy',
|
|
122
|
+
});
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
// Check picture elements for modern format sources
|
|
129
|
+
$('picture source').each((_, el) => {
|
|
130
|
+
const srcset = $(el).attr('srcset') || '';
|
|
131
|
+
const type = $(el).attr('type') || '';
|
|
132
|
+
|
|
133
|
+
if (type.includes('webp') || type.includes('avif')) {
|
|
134
|
+
const srcsetUrls = srcset.split(',').map((s) => s.trim().split(' ')[0]);
|
|
135
|
+
for (const srcsetUrl of srcsetUrls) {
|
|
136
|
+
if (!images.some((img) => img.src === srcsetUrl)) {
|
|
137
|
+
images.push({
|
|
138
|
+
src: srcsetUrl,
|
|
139
|
+
alt: null,
|
|
140
|
+
width: null,
|
|
141
|
+
height: null,
|
|
142
|
+
loading: null,
|
|
143
|
+
format: type.includes('webp') ? '.webp' : '.avif',
|
|
144
|
+
isModernFormat: true,
|
|
145
|
+
hasDimensions: false,
|
|
146
|
+
isLazyLoaded: false,
|
|
147
|
+
});
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
return images;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* Analyze image formats and optimization
|
|
158
|
+
*/
|
|
159
|
+
export function analyzeModernImages(html: string, url: string): { issues: AuditIssue[]; data: ModernImagesData } {
|
|
160
|
+
const issues: AuditIssue[] = [];
|
|
161
|
+
const $ = cheerio.load(html);
|
|
162
|
+
|
|
163
|
+
// Extract images
|
|
164
|
+
const images = extractImages($, url);
|
|
165
|
+
|
|
166
|
+
// Filter to unique images by src
|
|
167
|
+
const uniqueImages = images.filter((img, idx, arr) => arr.findIndex((i) => i.src === img.src) === idx);
|
|
168
|
+
|
|
169
|
+
// Count formats
|
|
170
|
+
const modernFormatCount = uniqueImages.filter((img) => img.isModernFormat).length;
|
|
171
|
+
const legacyFormatCount = uniqueImages.filter(
|
|
172
|
+
(img) => !img.isModernFormat && LEGACY_FORMATS.includes(img.format)
|
|
173
|
+
).length;
|
|
174
|
+
|
|
175
|
+
// Check for picture elements and srcset
|
|
176
|
+
const hasPictureElements = $('picture').length > 0;
|
|
177
|
+
const hasSourceSet = $('img[srcset]').length > 0 || $('picture source[srcset]').length > 0;
|
|
178
|
+
|
|
179
|
+
// Calculate percentage
|
|
180
|
+
const totalRelevantImages = modernFormatCount + legacyFormatCount;
|
|
181
|
+
const modernFormatPercentage = totalRelevantImages > 0 ? Math.round((modernFormatCount / totalRelevantImages) * 100) : 100;
|
|
182
|
+
|
|
183
|
+
// Generate issues
|
|
184
|
+
if (legacyFormatCount > 0 && !hasPictureElements && !hasSourceSet) {
|
|
185
|
+
const legacyImages = uniqueImages.filter((img) => !img.isModernFormat && LEGACY_FORMATS.includes(img.format));
|
|
186
|
+
|
|
187
|
+
issues.push({
|
|
188
|
+
...ISSUE_DEFINITIONS.IMAGE_NOT_WEBP_AVIF,
|
|
189
|
+
affectedUrls: legacyImages.slice(0, 10).map((img) => img.src),
|
|
190
|
+
details: {
|
|
191
|
+
legacyImageCount: legacyFormatCount,
|
|
192
|
+
modernImageCount: modernFormatCount,
|
|
193
|
+
modernFormatPercentage,
|
|
194
|
+
recommendation: 'Convert images to WebP or AVIF format for 25-50% smaller file sizes',
|
|
195
|
+
},
|
|
196
|
+
});
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
// Check for images without dimensions (causes CLS)
|
|
200
|
+
const imagesWithoutDimensions = uniqueImages.filter(
|
|
201
|
+
(img) => !img.hasDimensions && !img.isLazyLoaded && LEGACY_FORMATS.includes(img.format)
|
|
202
|
+
);
|
|
203
|
+
|
|
204
|
+
if (imagesWithoutDimensions.length > 0) {
|
|
205
|
+
issues.push({
|
|
206
|
+
...ISSUE_DEFINITIONS.IMG_NO_DIMENSIONS,
|
|
207
|
+
affectedUrls: imagesWithoutDimensions.slice(0, 10).map((img) => img.src),
|
|
208
|
+
details: {
|
|
209
|
+
count: imagesWithoutDimensions.length,
|
|
210
|
+
},
|
|
211
|
+
});
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
return {
|
|
215
|
+
issues,
|
|
216
|
+
data: {
|
|
217
|
+
totalImages: uniqueImages.length,
|
|
218
|
+
modernFormatCount,
|
|
219
|
+
legacyFormatCount,
|
|
220
|
+
modernFormatPercentage,
|
|
221
|
+
images: uniqueImages,
|
|
222
|
+
hasSourceSet,
|
|
223
|
+
hasPictureElements,
|
|
224
|
+
},
|
|
225
|
+
};
|
|
226
|
+
}
|
|
@@ -0,0 +1,395 @@
|
|
|
1
|
+
// NavBoost & User Signal Optimization
|
|
2
|
+
// Reference: Google Algorithm Leak 2024 + DOJ vs Google Antitrust Trial
|
|
3
|
+
// NavBoost uses memorized click data over 13 months
|
|
4
|
+
// "goodClicks", "badClicks", "lastLongestClicks" - confirmed ranking signals
|
|
5
|
+
// Bounce rate >70% + session <30 seconds = quality signal problem
|
|
6
|
+
|
|
7
|
+
import * as cheerio from 'cheerio';
|
|
8
|
+
import type { AuditIssue } from '../types.js';
|
|
9
|
+
|
|
10
|
+
export interface NavBoostSignalsData {
|
|
11
|
+
clickOptimization: {
|
|
12
|
+
hasClearTitle: boolean;
|
|
13
|
+
hasCompellingMetaDesc: boolean;
|
|
14
|
+
titleTruncated: boolean;
|
|
15
|
+
metaDescTruncated: boolean;
|
|
16
|
+
estimatedCTRPotential: 'high' | 'medium' | 'low';
|
|
17
|
+
};
|
|
18
|
+
engagementSignals: {
|
|
19
|
+
hasTableOfContents: boolean;
|
|
20
|
+
hasJumpLinks: boolean;
|
|
21
|
+
estimatedScrollDepth: 'deep' | 'medium' | 'shallow';
|
|
22
|
+
hasProgressIndicator: boolean;
|
|
23
|
+
hasStickyNavigation: boolean;
|
|
24
|
+
contentSections: number;
|
|
25
|
+
};
|
|
26
|
+
bounceRiskFactors: {
|
|
27
|
+
hasAboveFoldContent: boolean;
|
|
28
|
+
hasSlowLoadingIndicators: boolean;
|
|
29
|
+
hasPopupRisk: boolean;
|
|
30
|
+
hasIntrusiveAds: boolean;
|
|
31
|
+
contentMatchesTitle: boolean;
|
|
32
|
+
hasImmediateValue: boolean;
|
|
33
|
+
};
|
|
34
|
+
dwellTimeFactors: {
|
|
35
|
+
estimatedReadTime: number; // minutes
|
|
36
|
+
hasVideo: boolean;
|
|
37
|
+
hasInteractiveElements: boolean;
|
|
38
|
+
contentDepth: 'comprehensive' | 'moderate' | 'thin';
|
|
39
|
+
hasRelatedContent: boolean;
|
|
40
|
+
};
|
|
41
|
+
overallNavBoostScore: number; // 0-100
|
|
42
|
+
recommendations: string[];
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Analyze title and meta description for CTR optimization
|
|
47
|
+
*/
|
|
48
|
+
function analyzeClickOptimization($: cheerio.CheerioAPI): NavBoostSignalsData['clickOptimization'] {
|
|
49
|
+
const title = $('title').text().trim();
|
|
50
|
+
const metaDesc = $('meta[name="description"]').attr('content')?.trim() || '';
|
|
51
|
+
|
|
52
|
+
// Title analysis
|
|
53
|
+
const hasClearTitle = title.length > 10 && title.length <= 60;
|
|
54
|
+
const titleTruncated = title.length > 60;
|
|
55
|
+
|
|
56
|
+
// Meta description analysis
|
|
57
|
+
const hasCompellingMetaDesc = metaDesc.length >= 70 && metaDesc.length <= 160;
|
|
58
|
+
const metaDescTruncated = metaDesc.length > 160;
|
|
59
|
+
|
|
60
|
+
// CTR potential indicators
|
|
61
|
+
const hasPowerWords = /free|best|guide|how to|ultimate|proven|secret|new|exclusive/i.test(title + ' ' + metaDesc);
|
|
62
|
+
const hasNumbers = /\d+/.test(title);
|
|
63
|
+
const hasYear = /202[4-9]|203\d/.test(title + ' ' + metaDesc);
|
|
64
|
+
|
|
65
|
+
let ctrScore = 0;
|
|
66
|
+
if (hasClearTitle) ctrScore += 2;
|
|
67
|
+
if (hasCompellingMetaDesc) ctrScore += 2;
|
|
68
|
+
if (hasPowerWords) ctrScore += 1;
|
|
69
|
+
if (hasNumbers) ctrScore += 1;
|
|
70
|
+
if (hasYear) ctrScore += 1;
|
|
71
|
+
|
|
72
|
+
const estimatedCTRPotential = ctrScore >= 5 ? 'high' : ctrScore >= 3 ? 'medium' : 'low';
|
|
73
|
+
|
|
74
|
+
return {
|
|
75
|
+
hasClearTitle,
|
|
76
|
+
hasCompellingMetaDesc,
|
|
77
|
+
titleTruncated,
|
|
78
|
+
metaDescTruncated,
|
|
79
|
+
estimatedCTRPotential,
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Analyze engagement signals that affect NavBoost
|
|
85
|
+
*/
|
|
86
|
+
function analyzeEngagementSignals($: cheerio.CheerioAPI, html: string): NavBoostSignalsData['engagementSignals'] {
|
|
87
|
+
// Table of contents detection
|
|
88
|
+
const hasTableOfContents =
|
|
89
|
+
$('[class*="toc"], [id*="toc"], [class*="table-of-contents"], nav[aria-label*="content"]').length > 0 ||
|
|
90
|
+
$('a[href^="#"]').filter((_, el) => {
|
|
91
|
+
const text = $(el).text().toLowerCase();
|
|
92
|
+
return /^(\d+\.?\s*)?[a-z]/i.test(text) && text.length > 3;
|
|
93
|
+
}).length >= 3;
|
|
94
|
+
|
|
95
|
+
// Jump links (in-page navigation)
|
|
96
|
+
const hasJumpLinks = $('a[href^="#"]').length >= 3;
|
|
97
|
+
|
|
98
|
+
// Count content sections (H2s as proxy)
|
|
99
|
+
const contentSections = $('h2, h3').length;
|
|
100
|
+
|
|
101
|
+
// Scroll depth estimation based on content length
|
|
102
|
+
const bodyText = $('body').text();
|
|
103
|
+
const wordCount = bodyText.split(/\s+/).filter(w => w.length > 0).length;
|
|
104
|
+
const estimatedScrollDepth = wordCount > 2000 ? 'deep' : wordCount > 800 ? 'medium' : 'shallow';
|
|
105
|
+
|
|
106
|
+
// Progress indicator
|
|
107
|
+
const hasProgressIndicator =
|
|
108
|
+
$('[class*="progress"], [class*="reading-progress"], [role="progressbar"]').length > 0;
|
|
109
|
+
|
|
110
|
+
// Sticky navigation
|
|
111
|
+
const hasStickyNavigation =
|
|
112
|
+
$('[class*="sticky"], [class*="fixed"], [style*="position: sticky"], [style*="position: fixed"]').filter((_, el) => {
|
|
113
|
+
return $(el).find('nav, a').length > 0;
|
|
114
|
+
}).length > 0;
|
|
115
|
+
|
|
116
|
+
return {
|
|
117
|
+
hasTableOfContents,
|
|
118
|
+
hasJumpLinks,
|
|
119
|
+
estimatedScrollDepth,
|
|
120
|
+
hasProgressIndicator,
|
|
121
|
+
hasStickyNavigation,
|
|
122
|
+
contentSections,
|
|
123
|
+
};
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Analyze bounce risk factors
|
|
128
|
+
*/
|
|
129
|
+
function analyzeBounceRiskFactors($: cheerio.CheerioAPI, html: string): NavBoostSignalsData['bounceRiskFactors'] {
|
|
130
|
+
const title = $('title').text().toLowerCase();
|
|
131
|
+
const h1 = $('h1').first().text().toLowerCase();
|
|
132
|
+
const firstParagraph = $('p').first().text().toLowerCase();
|
|
133
|
+
|
|
134
|
+
// Above fold content check
|
|
135
|
+
const hasAboveFoldContent =
|
|
136
|
+
$('h1').length > 0 &&
|
|
137
|
+
($('p').first().text().length > 50 ||
|
|
138
|
+
$('main, article, .content').first().text().length > 100);
|
|
139
|
+
|
|
140
|
+
// Slow loading indicators (large images, heavy scripts)
|
|
141
|
+
const hasSlowLoadingIndicators =
|
|
142
|
+
$('script[src*="bundle"], script[src*="vendor"]').length > 5 ||
|
|
143
|
+
$('img:not([loading="lazy"])').length > 10;
|
|
144
|
+
|
|
145
|
+
// Popup risk
|
|
146
|
+
const hasPopupRisk =
|
|
147
|
+
html.includes('modal') ||
|
|
148
|
+
html.includes('popup') ||
|
|
149
|
+
html.includes('newsletter-overlay') ||
|
|
150
|
+
$('[class*="popup"], [class*="modal"]').length > 0;
|
|
151
|
+
|
|
152
|
+
// Intrusive ads
|
|
153
|
+
const hasIntrusiveAds =
|
|
154
|
+
$('[class*="ad-"], [id*="ad-"], [class*="advertisement"]').length > 3 ||
|
|
155
|
+
html.includes('interstitial');
|
|
156
|
+
|
|
157
|
+
// Content-title match (basic check)
|
|
158
|
+
const titleWords = title.split(/\s+/).filter(w => w.length > 3);
|
|
159
|
+
const matchingWords = titleWords.filter(w =>
|
|
160
|
+
h1.includes(w) || firstParagraph.includes(w)
|
|
161
|
+
);
|
|
162
|
+
const contentMatchesTitle = matchingWords.length >= Math.ceil(titleWords.length * 0.5);
|
|
163
|
+
|
|
164
|
+
// Immediate value (answer in first paragraph)
|
|
165
|
+
const hasImmediateValue =
|
|
166
|
+
firstParagraph.length > 100 ||
|
|
167
|
+
$('main p, article p').first().text().length > 100;
|
|
168
|
+
|
|
169
|
+
return {
|
|
170
|
+
hasAboveFoldContent,
|
|
171
|
+
hasSlowLoadingIndicators,
|
|
172
|
+
hasPopupRisk,
|
|
173
|
+
hasIntrusiveAds,
|
|
174
|
+
contentMatchesTitle,
|
|
175
|
+
hasImmediateValue,
|
|
176
|
+
};
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* Analyze dwell time factors
|
|
181
|
+
*/
|
|
182
|
+
function analyzeDwellTimeFactors($: cheerio.CheerioAPI, html: string): NavBoostSignalsData['dwellTimeFactors'] {
|
|
183
|
+
const bodyText = $('main, article, .content, body').text();
|
|
184
|
+
const wordCount = bodyText.split(/\s+/).filter(w => w.length > 0).length;
|
|
185
|
+
|
|
186
|
+
// Estimated read time (200 words per minute average)
|
|
187
|
+
const estimatedReadTime = Math.ceil(wordCount / 200);
|
|
188
|
+
|
|
189
|
+
// Video presence
|
|
190
|
+
const hasVideo =
|
|
191
|
+
$('video, iframe[src*="youtube"], iframe[src*="vimeo"], [class*="video"]').length > 0;
|
|
192
|
+
|
|
193
|
+
// Interactive elements
|
|
194
|
+
const hasInteractiveElements =
|
|
195
|
+
$('form, [class*="calculator"], [class*="quiz"], [class*="interactive"], input, select').length > 0;
|
|
196
|
+
|
|
197
|
+
// Content depth
|
|
198
|
+
const contentDepth = wordCount > 2000 ? 'comprehensive' : wordCount > 800 ? 'moderate' : 'thin';
|
|
199
|
+
|
|
200
|
+
// Related content
|
|
201
|
+
const hasRelatedContent =
|
|
202
|
+
$('[class*="related"], [class*="recommended"], [class*="similar"]').length > 0 ||
|
|
203
|
+
$('a').filter((_, el) => {
|
|
204
|
+
const text = $(el).text().toLowerCase();
|
|
205
|
+
return text.includes('related') || text.includes('see also') || text.includes('read next');
|
|
206
|
+
}).length > 0;
|
|
207
|
+
|
|
208
|
+
return {
|
|
209
|
+
estimatedReadTime,
|
|
210
|
+
hasVideo,
|
|
211
|
+
hasInteractiveElements,
|
|
212
|
+
contentDepth,
|
|
213
|
+
hasRelatedContent,
|
|
214
|
+
};
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/**
|
|
218
|
+
* Calculate overall NavBoost optimization score
|
|
219
|
+
*/
|
|
220
|
+
function calculateNavBoostScore(
|
|
221
|
+
click: NavBoostSignalsData['clickOptimization'],
|
|
222
|
+
engagement: NavBoostSignalsData['engagementSignals'],
|
|
223
|
+
bounce: NavBoostSignalsData['bounceRiskFactors'],
|
|
224
|
+
dwell: NavBoostSignalsData['dwellTimeFactors']
|
|
225
|
+
): number {
|
|
226
|
+
let score = 50; // Start at baseline
|
|
227
|
+
|
|
228
|
+
// Click optimization (max +20)
|
|
229
|
+
if (click.estimatedCTRPotential === 'high') score += 20;
|
|
230
|
+
else if (click.estimatedCTRPotential === 'medium') score += 10;
|
|
231
|
+
if (click.titleTruncated) score -= 5;
|
|
232
|
+
if (click.metaDescTruncated) score -= 3;
|
|
233
|
+
|
|
234
|
+
// Engagement signals (max +20)
|
|
235
|
+
if (engagement.hasTableOfContents) score += 10;
|
|
236
|
+
if (engagement.hasJumpLinks) score += 5;
|
|
237
|
+
if (engagement.contentSections >= 5) score += 5;
|
|
238
|
+
|
|
239
|
+
// Bounce risk (max -20)
|
|
240
|
+
if (!bounce.hasAboveFoldContent) score -= 10;
|
|
241
|
+
if (bounce.hasPopupRisk) score -= 5;
|
|
242
|
+
if (bounce.hasIntrusiveAds) score -= 10;
|
|
243
|
+
if (!bounce.contentMatchesTitle) score -= 5;
|
|
244
|
+
if (bounce.hasImmediateValue) score += 5;
|
|
245
|
+
|
|
246
|
+
// Dwell time factors (max +20)
|
|
247
|
+
if (dwell.hasVideo) score += 10;
|
|
248
|
+
if (dwell.hasInteractiveElements) score += 5;
|
|
249
|
+
if (dwell.contentDepth === 'comprehensive') score += 5;
|
|
250
|
+
else if (dwell.contentDepth === 'thin') score -= 5;
|
|
251
|
+
if (dwell.hasRelatedContent) score += 5;
|
|
252
|
+
|
|
253
|
+
return Math.max(0, Math.min(100, score));
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
/**
|
|
257
|
+
* Main function: Analyze NavBoost optimization signals
|
|
258
|
+
*/
|
|
259
|
+
export function analyzeNavBoostSignals(
|
|
260
|
+
html: string,
|
|
261
|
+
url: string
|
|
262
|
+
): { issues: AuditIssue[]; data: NavBoostSignalsData } {
|
|
263
|
+
const $ = cheerio.load(html);
|
|
264
|
+
const issues: AuditIssue[] = [];
|
|
265
|
+
|
|
266
|
+
// Run all analyses
|
|
267
|
+
const clickOptimization = analyzeClickOptimization($);
|
|
268
|
+
const engagementSignals = analyzeEngagementSignals($, html);
|
|
269
|
+
const bounceRiskFactors = analyzeBounceRiskFactors($, html);
|
|
270
|
+
const dwellTimeFactors = analyzeDwellTimeFactors($, html);
|
|
271
|
+
|
|
272
|
+
// Calculate overall score
|
|
273
|
+
const overallNavBoostScore = calculateNavBoostScore(
|
|
274
|
+
clickOptimization,
|
|
275
|
+
engagementSignals,
|
|
276
|
+
bounceRiskFactors,
|
|
277
|
+
dwellTimeFactors
|
|
278
|
+
);
|
|
279
|
+
|
|
280
|
+
// Generate recommendations
|
|
281
|
+
const recommendations: string[] = [];
|
|
282
|
+
|
|
283
|
+
if (clickOptimization.estimatedCTRPotential !== 'high') {
|
|
284
|
+
recommendations.push('Optimize title/meta for higher CTR: use power words, numbers, or current year');
|
|
285
|
+
}
|
|
286
|
+
if (!engagementSignals.hasTableOfContents && engagementSignals.contentSections >= 5) {
|
|
287
|
+
recommendations.push('Add table of contents for long-form content to improve engagement');
|
|
288
|
+
}
|
|
289
|
+
if (bounceRiskFactors.hasPopupRisk) {
|
|
290
|
+
recommendations.push('Delay or remove popup modals to reduce bounce rate');
|
|
291
|
+
}
|
|
292
|
+
if (!dwellTimeFactors.hasRelatedContent) {
|
|
293
|
+
recommendations.push('Add related content section to increase session duration');
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
// Generate issues
|
|
297
|
+
|
|
298
|
+
// Low CTR potential
|
|
299
|
+
if (clickOptimization.estimatedCTRPotential === 'low') {
|
|
300
|
+
issues.push({
|
|
301
|
+
code: 'LOW_CTR_POTENTIAL',
|
|
302
|
+
severity: 'warning',
|
|
303
|
+
category: 'on-page',
|
|
304
|
+
title: 'Title and meta description have low CTR potential',
|
|
305
|
+
description: 'The title and meta description lack compelling elements that drive clicks.',
|
|
306
|
+
impact: 'NavBoost uses click data as a ranking signal. Higher CTR improves rankings over time.',
|
|
307
|
+
howToFix: 'Add power words (free, best, ultimate), numbers, or current year to increase CTR.',
|
|
308
|
+
affectedUrls: [url],
|
|
309
|
+
details: {
|
|
310
|
+
ctrPotential: clickOptimization.estimatedCTRPotential,
|
|
311
|
+
titleTruncated: clickOptimization.titleTruncated,
|
|
312
|
+
},
|
|
313
|
+
});
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
// High bounce risk
|
|
317
|
+
if (!bounceRiskFactors.hasAboveFoldContent || !bounceRiskFactors.hasImmediateValue) {
|
|
318
|
+
issues.push({
|
|
319
|
+
code: 'HIGH_BOUNCE_RISK',
|
|
320
|
+
severity: 'warning',
|
|
321
|
+
category: 'content',
|
|
322
|
+
title: 'Page has high bounce risk signals',
|
|
323
|
+
description: 'The page lacks above-fold content or immediate value for visitors.',
|
|
324
|
+
impact: 'NavBoost tracks bounce rate. >70% bounce with <30s sessions hurts rankings.',
|
|
325
|
+
howToFix: 'Add compelling content above the fold that delivers immediate value to visitors.',
|
|
326
|
+
affectedUrls: [url],
|
|
327
|
+
details: {
|
|
328
|
+
hasAboveFoldContent: bounceRiskFactors.hasAboveFoldContent,
|
|
329
|
+
hasImmediateValue: bounceRiskFactors.hasImmediateValue,
|
|
330
|
+
},
|
|
331
|
+
});
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
// Popup/interstitial risk
|
|
335
|
+
if (bounceRiskFactors.hasPopupRisk) {
|
|
336
|
+
issues.push({
|
|
337
|
+
code: 'POPUP_BOUNCE_RISK',
|
|
338
|
+
severity: 'notice',
|
|
339
|
+
category: 'content',
|
|
340
|
+
title: 'Popup/modal detected - potential bounce risk',
|
|
341
|
+
description: 'Popups detected that may cause visitors to leave immediately.',
|
|
342
|
+
impact: 'Intrusive interstitials increase bounce rate and hurt NavBoost signals.',
|
|
343
|
+
howToFix: 'Delay popups by 30+ seconds, or use exit-intent only. Avoid fullscreen overlays.',
|
|
344
|
+
affectedUrls: [url],
|
|
345
|
+
});
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
// Thin content with low dwell time
|
|
349
|
+
if (dwellTimeFactors.contentDepth === 'thin' && dwellTimeFactors.estimatedReadTime < 3) {
|
|
350
|
+
issues.push({
|
|
351
|
+
code: 'LOW_DWELL_TIME_CONTENT',
|
|
352
|
+
severity: 'notice',
|
|
353
|
+
category: 'content',
|
|
354
|
+
title: 'Thin content may result in low dwell time',
|
|
355
|
+
description: `Estimated read time is only ${dwellTimeFactors.estimatedReadTime} minute(s).`,
|
|
356
|
+
impact: 'Google tracks "lastLongestClicks" - longer sessions signal quality content.',
|
|
357
|
+
howToFix: 'Add comprehensive content, videos, or interactive elements to increase time on page.',
|
|
358
|
+
affectedUrls: [url],
|
|
359
|
+
details: {
|
|
360
|
+
estimatedReadTime: dwellTimeFactors.estimatedReadTime,
|
|
361
|
+
contentDepth: dwellTimeFactors.contentDepth,
|
|
362
|
+
},
|
|
363
|
+
});
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
// Long content without navigation aids
|
|
367
|
+
if (engagementSignals.estimatedScrollDepth === 'deep' && !engagementSignals.hasTableOfContents) {
|
|
368
|
+
issues.push({
|
|
369
|
+
code: 'LONG_CONTENT_NO_TOC',
|
|
370
|
+
severity: 'notice',
|
|
371
|
+
category: 'content',
|
|
372
|
+
title: 'Long content lacks table of contents',
|
|
373
|
+
description: 'Long-form content without navigation aids may cause users to abandon the page.',
|
|
374
|
+
impact: 'Table of contents improves engagement signals by helping users find relevant sections.',
|
|
375
|
+
howToFix: 'Add a table of contents with jump links to major sections.',
|
|
376
|
+
affectedUrls: [url],
|
|
377
|
+
details: {
|
|
378
|
+
contentSections: engagementSignals.contentSections,
|
|
379
|
+
scrollDepth: engagementSignals.estimatedScrollDepth,
|
|
380
|
+
},
|
|
381
|
+
});
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
return {
|
|
385
|
+
issues,
|
|
386
|
+
data: {
|
|
387
|
+
clickOptimization,
|
|
388
|
+
engagementSignals,
|
|
389
|
+
bounceRiskFactors,
|
|
390
|
+
dwellTimeFactors,
|
|
391
|
+
overallNavBoostScore,
|
|
392
|
+
recommendations,
|
|
393
|
+
},
|
|
394
|
+
};
|
|
395
|
+
}
|