@rankcli/agent-runtime 0.0.9 → 0.0.11
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 +90 -196
- package/dist/analyzer-GMURJADU.mjs +7 -0
- package/dist/chunk-2JADKV3Z.mjs +244 -0
- package/dist/chunk-3ZSCLNTW.mjs +557 -0
- package/dist/chunk-4E4MQOSP.mjs +374 -0
- package/dist/chunk-6BWS3CLP.mjs +16 -0
- package/dist/chunk-AK2IC22C.mjs +206 -0
- package/dist/chunk-K6VSXDD6.mjs +293 -0
- package/dist/chunk-M27NQCWW.mjs +303 -0
- package/dist/{chunk-YNZYHEYM.mjs → chunk-PJLNXOLN.mjs} +0 -14
- package/dist/chunk-VSQD74I7.mjs +474 -0
- package/dist/core-web-vitals-analyzer-TE6LQJMS.mjs +7 -0
- package/dist/geo-analyzer-D47LTMMA.mjs +25 -0
- package/dist/image-optimization-analyzer-XP4OQGRP.mjs +9 -0
- package/dist/index.d.mts +612 -17
- package/dist/index.d.ts +612 -17
- package/dist/index.js +9020 -2686
- package/dist/index.mjs +4177 -328
- package/dist/internal-linking-analyzer-MRMBV7NM.mjs +9 -0
- package/dist/mobile-seo-analyzer-67HNQ7IO.mjs +7 -0
- package/dist/security-headers-analyzer-3ZUQARS5.mjs +9 -0
- package/dist/structured-data-analyzer-2I4NQAUP.mjs +9 -0
- package/package.json +2 -2
- package/src/analyzers/core-web-vitals-analyzer.test.ts +236 -0
- package/src/analyzers/core-web-vitals-analyzer.ts +557 -0
- package/src/analyzers/geo-analyzer.test.ts +310 -0
- package/src/analyzers/geo-analyzer.ts +814 -0
- package/src/analyzers/image-optimization-analyzer.test.ts +145 -0
- package/src/analyzers/image-optimization-analyzer.ts +348 -0
- package/src/analyzers/index.ts +233 -0
- package/src/analyzers/internal-linking-analyzer.test.ts +141 -0
- package/src/analyzers/internal-linking-analyzer.ts +419 -0
- package/src/analyzers/mobile-seo-analyzer.test.ts +140 -0
- package/src/analyzers/mobile-seo-analyzer.ts +455 -0
- package/src/analyzers/security-headers-analyzer.test.ts +115 -0
- package/src/analyzers/security-headers-analyzer.ts +318 -0
- package/src/analyzers/structured-data-analyzer.test.ts +210 -0
- package/src/analyzers/structured-data-analyzer.ts +590 -0
- package/src/audit/engine.ts +3 -3
- package/src/audit/types.ts +3 -2
- package/src/fixer/framework-fixes.test.ts +489 -0
- package/src/fixer/framework-fixes.ts +3418 -0
- package/src/frameworks/detector.ts +642 -114
- package/src/frameworks/suggestion-engine.ts +38 -1
- package/src/index.ts +3 -0
- package/src/types.ts +15 -1
- package/dist/analyzer-2CSWIQGD.mjs +0 -6
|
@@ -0,0 +1,293 @@
|
|
|
1
|
+
// src/analyzers/internal-linking-analyzer.ts
|
|
2
|
+
import * as cheerio from "cheerio";
|
|
3
|
+
var GENERIC_ANCHOR_TEXTS = [
|
|
4
|
+
"click here",
|
|
5
|
+
"read more",
|
|
6
|
+
"learn more",
|
|
7
|
+
"here",
|
|
8
|
+
"link",
|
|
9
|
+
"this",
|
|
10
|
+
"more",
|
|
11
|
+
"continue",
|
|
12
|
+
"see more",
|
|
13
|
+
"view more",
|
|
14
|
+
"details",
|
|
15
|
+
"go",
|
|
16
|
+
"continue reading",
|
|
17
|
+
">>",
|
|
18
|
+
"\u2192",
|
|
19
|
+
"next",
|
|
20
|
+
"previous"
|
|
21
|
+
];
|
|
22
|
+
function isInternalUrl(href, baseUrl) {
|
|
23
|
+
if (!href || href.startsWith("#") || href.startsWith("javascript:") || href.startsWith("mailto:") || href.startsWith("tel:")) {
|
|
24
|
+
return false;
|
|
25
|
+
}
|
|
26
|
+
if (href.startsWith("/") && !href.startsWith("//")) {
|
|
27
|
+
return true;
|
|
28
|
+
}
|
|
29
|
+
try {
|
|
30
|
+
const base = new URL(baseUrl);
|
|
31
|
+
const link = new URL(href, baseUrl);
|
|
32
|
+
return link.hostname === base.hostname;
|
|
33
|
+
} catch {
|
|
34
|
+
return false;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
function isNavigationLink($el, $) {
|
|
38
|
+
const parent = $el.parents('nav, header, footer, [role="navigation"], .nav, .navigation, .menu, .sidebar').first();
|
|
39
|
+
return parent.length > 0;
|
|
40
|
+
}
|
|
41
|
+
function analyzeAnchorText(text) {
|
|
42
|
+
const normalized = text.toLowerCase().trim();
|
|
43
|
+
return {
|
|
44
|
+
isEmpty: normalized.length === 0,
|
|
45
|
+
isGeneric: GENERIC_ANCHOR_TEXTS.some((g) => normalized === g || normalized.includes(g)),
|
|
46
|
+
isTooLong: normalized.length > 100
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
function analyzeInternalLinking(html, url) {
|
|
50
|
+
const $ = cheerio.load(html);
|
|
51
|
+
const issues = [];
|
|
52
|
+
const recommendations = [];
|
|
53
|
+
let score = 100;
|
|
54
|
+
const linkDetails = [];
|
|
55
|
+
const anchorTextCounts = {};
|
|
56
|
+
let totalLinks = 0;
|
|
57
|
+
let internalLinks = 0;
|
|
58
|
+
let externalLinks = 0;
|
|
59
|
+
let navigationLinks = 0;
|
|
60
|
+
let contentLinks = 0;
|
|
61
|
+
let linksWithGenericText = 0;
|
|
62
|
+
let linksWithKeywords = 0;
|
|
63
|
+
let emptyAnchorLinks = 0;
|
|
64
|
+
let tooLongAnchorLinks = 0;
|
|
65
|
+
let imageLinks = 0;
|
|
66
|
+
const internalTargets = /* @__PURE__ */ new Set();
|
|
67
|
+
$("a[href]").each((_, el) => {
|
|
68
|
+
const $link = $(el);
|
|
69
|
+
const href = $link.attr("href") || "";
|
|
70
|
+
const text = $link.text().trim();
|
|
71
|
+
const rel = $link.attr("rel");
|
|
72
|
+
const title = $link.attr("title");
|
|
73
|
+
const hasImage = $link.find("img").length > 0;
|
|
74
|
+
if (href.startsWith("mailto:") || href.startsWith("tel:") || href.startsWith("javascript:")) {
|
|
75
|
+
return;
|
|
76
|
+
}
|
|
77
|
+
totalLinks++;
|
|
78
|
+
const linkIssues = [];
|
|
79
|
+
const isInternal = isInternalUrl(href, url);
|
|
80
|
+
const isNav = isNavigationLink($link, $);
|
|
81
|
+
if (isInternal) {
|
|
82
|
+
internalLinks++;
|
|
83
|
+
try {
|
|
84
|
+
const targetUrl = new URL(href, url).pathname;
|
|
85
|
+
internalTargets.add(targetUrl);
|
|
86
|
+
} catch {
|
|
87
|
+
}
|
|
88
|
+
} else if (href.startsWith("http")) {
|
|
89
|
+
externalLinks++;
|
|
90
|
+
}
|
|
91
|
+
if (isNav) {
|
|
92
|
+
navigationLinks++;
|
|
93
|
+
} else {
|
|
94
|
+
contentLinks++;
|
|
95
|
+
}
|
|
96
|
+
const anchorAnalysis = analyzeAnchorText(text);
|
|
97
|
+
if (hasImage && !text) {
|
|
98
|
+
imageLinks++;
|
|
99
|
+
const imgAlt = $link.find("img").attr("alt");
|
|
100
|
+
if (!imgAlt) {
|
|
101
|
+
linkIssues.push("Image link without alt text");
|
|
102
|
+
}
|
|
103
|
+
} else if (anchorAnalysis.isEmpty) {
|
|
104
|
+
emptyAnchorLinks++;
|
|
105
|
+
linkIssues.push("Empty anchor text");
|
|
106
|
+
} else if (anchorAnalysis.isGeneric) {
|
|
107
|
+
linksWithGenericText++;
|
|
108
|
+
linkIssues.push("Generic anchor text");
|
|
109
|
+
} else {
|
|
110
|
+
linksWithKeywords++;
|
|
111
|
+
}
|
|
112
|
+
if (anchorAnalysis.isTooLong) {
|
|
113
|
+
tooLongAnchorLinks++;
|
|
114
|
+
linkIssues.push("Anchor text too long");
|
|
115
|
+
}
|
|
116
|
+
if (text) {
|
|
117
|
+
const normalizedText = text.toLowerCase().substring(0, 50);
|
|
118
|
+
anchorTextCounts[normalizedText] = (anchorTextCounts[normalizedText] || 0) + 1;
|
|
119
|
+
}
|
|
120
|
+
if (isInternal && rel?.includes("nofollow")) {
|
|
121
|
+
linkIssues.push("nofollow on internal link");
|
|
122
|
+
}
|
|
123
|
+
if (isInternal && (rel?.includes("sponsored") || rel?.includes("ugc"))) {
|
|
124
|
+
linkIssues.push("sponsored/ugc on internal link");
|
|
125
|
+
}
|
|
126
|
+
linkDetails.push({
|
|
127
|
+
href: href.substring(0, 200),
|
|
128
|
+
text: text.substring(0, 100),
|
|
129
|
+
isInternal,
|
|
130
|
+
isNavigation: isNav,
|
|
131
|
+
rel,
|
|
132
|
+
title,
|
|
133
|
+
issues: linkIssues
|
|
134
|
+
});
|
|
135
|
+
});
|
|
136
|
+
const anchorPatterns = Object.entries(anchorTextCounts).sort((a, b) => b[1] - a[1]).slice(0, 10).map(([text, count]) => ({ text, count }));
|
|
137
|
+
const uniqueInternalTargets = internalTargets.size;
|
|
138
|
+
const orphanRisk = internalLinks < 3 && contentLinks < 2;
|
|
139
|
+
if (internalLinks === 0) {
|
|
140
|
+
issues.push({
|
|
141
|
+
code: "LINK_NO_INTERNAL",
|
|
142
|
+
severity: "critical",
|
|
143
|
+
category: "content",
|
|
144
|
+
title: "No internal links found",
|
|
145
|
+
description: "This page has no internal links to other pages on your site.",
|
|
146
|
+
impact: "Poor link equity distribution, crawlability issues",
|
|
147
|
+
howToFix: "Add relevant internal links to other pages on your site.",
|
|
148
|
+
affectedUrls: [url]
|
|
149
|
+
});
|
|
150
|
+
score -= 25;
|
|
151
|
+
} else if (contentLinks === 0 && internalLinks < 5) {
|
|
152
|
+
issues.push({
|
|
153
|
+
code: "LINK_LOW_INTERNAL",
|
|
154
|
+
severity: "warning",
|
|
155
|
+
category: "content",
|
|
156
|
+
title: "Very few internal links in content",
|
|
157
|
+
description: `Only ${internalLinks} internal links found, all in navigation.`,
|
|
158
|
+
impact: "Limited topic relevance signals",
|
|
159
|
+
howToFix: "Add contextual internal links within your content.",
|
|
160
|
+
affectedUrls: [url]
|
|
161
|
+
});
|
|
162
|
+
score -= 15;
|
|
163
|
+
}
|
|
164
|
+
if (linksWithGenericText > 3) {
|
|
165
|
+
const percentage = Math.round(linksWithGenericText / totalLinks * 100);
|
|
166
|
+
issues.push({
|
|
167
|
+
code: "LINK_GENERIC_ANCHOR",
|
|
168
|
+
severity: "warning",
|
|
169
|
+
category: "content",
|
|
170
|
+
title: `${linksWithGenericText} links with generic anchor text`,
|
|
171
|
+
description: `${percentage}% of links use non-descriptive text like "click here" or "read more".`,
|
|
172
|
+
impact: "Missed keyword relevance signals",
|
|
173
|
+
howToFix: "Replace generic anchors with descriptive, keyword-rich text.",
|
|
174
|
+
affectedUrls: [url]
|
|
175
|
+
});
|
|
176
|
+
score -= Math.min(linksWithGenericText * 2, 15);
|
|
177
|
+
}
|
|
178
|
+
if (emptyAnchorLinks > 0) {
|
|
179
|
+
issues.push({
|
|
180
|
+
code: "LINK_EMPTY_ANCHOR",
|
|
181
|
+
severity: "warning",
|
|
182
|
+
category: "content",
|
|
183
|
+
title: `${emptyAnchorLinks} links with empty anchor text`,
|
|
184
|
+
description: "Links without text or alt text are inaccessible and provide no SEO value.",
|
|
185
|
+
impact: "Accessibility issues, wasted link equity",
|
|
186
|
+
howToFix: "Add descriptive text or ensure image links have alt text.",
|
|
187
|
+
affectedUrls: [url]
|
|
188
|
+
});
|
|
189
|
+
score -= Math.min(emptyAnchorLinks * 3, 15);
|
|
190
|
+
}
|
|
191
|
+
if (orphanRisk) {
|
|
192
|
+
issues.push({
|
|
193
|
+
code: "LINK_ORPHAN_RISK",
|
|
194
|
+
severity: "info",
|
|
195
|
+
category: "content",
|
|
196
|
+
title: "Page may be an orphan",
|
|
197
|
+
description: "This page has very few internal links, which may indicate it's poorly connected in your site structure.",
|
|
198
|
+
impact: "Difficult for users and crawlers to find",
|
|
199
|
+
howToFix: "Link to this page from related content and relevant hub pages.",
|
|
200
|
+
affectedUrls: [url]
|
|
201
|
+
});
|
|
202
|
+
score -= 10;
|
|
203
|
+
}
|
|
204
|
+
const nofollowInternalLinks = linkDetails.filter((l) => l.isInternal && l.rel?.includes("nofollow"));
|
|
205
|
+
if (nofollowInternalLinks.length > 0) {
|
|
206
|
+
issues.push({
|
|
207
|
+
code: "LINK_NOFOLLOW_INTERNAL",
|
|
208
|
+
severity: "warning",
|
|
209
|
+
category: "technical",
|
|
210
|
+
title: `${nofollowInternalLinks.length} internal links with nofollow`,
|
|
211
|
+
description: "Internal links should not have nofollow - it wastes PageRank.",
|
|
212
|
+
impact: "Reduced internal link equity flow",
|
|
213
|
+
howToFix: "Remove nofollow from internal links.",
|
|
214
|
+
affectedUrls: [url]
|
|
215
|
+
});
|
|
216
|
+
score -= nofollowInternalLinks.length * 3;
|
|
217
|
+
}
|
|
218
|
+
if (externalLinks > internalLinks && externalLinks > 10) {
|
|
219
|
+
issues.push({
|
|
220
|
+
code: "LINK_EXTERNAL_HEAVY",
|
|
221
|
+
severity: "info",
|
|
222
|
+
category: "content",
|
|
223
|
+
title: "More external links than internal",
|
|
224
|
+
description: `${externalLinks} external vs ${internalLinks} internal links.`,
|
|
225
|
+
impact: "May leak PageRank to external sites",
|
|
226
|
+
howToFix: "Consider adding more internal links or using nofollow/sponsored for some external links.",
|
|
227
|
+
affectedUrls: [url]
|
|
228
|
+
});
|
|
229
|
+
}
|
|
230
|
+
if (contentLinks < 3 && internalLinks > 0) {
|
|
231
|
+
recommendations.push("Add 2-3 contextual internal links within your content");
|
|
232
|
+
}
|
|
233
|
+
if (linksWithKeywords / Math.max(totalLinks, 1) > 0.7) {
|
|
234
|
+
recommendations.push("\u2713 Good use of descriptive anchor text");
|
|
235
|
+
}
|
|
236
|
+
if (uniqueInternalTargets > 5) {
|
|
237
|
+
recommendations.push("\u2713 Good diversity of internal link targets");
|
|
238
|
+
}
|
|
239
|
+
if (internalLinks > 10) {
|
|
240
|
+
recommendations.push("Consider creating a hub/pillar page to consolidate topic authority");
|
|
241
|
+
}
|
|
242
|
+
return {
|
|
243
|
+
score: Math.max(0, Math.min(100, score)),
|
|
244
|
+
totalLinks,
|
|
245
|
+
internalLinks,
|
|
246
|
+
externalLinks,
|
|
247
|
+
navigationLinks,
|
|
248
|
+
contentLinks,
|
|
249
|
+
uniqueInternalTargets,
|
|
250
|
+
linksWithKeywords,
|
|
251
|
+
linksWithGenericText,
|
|
252
|
+
orphanRisk,
|
|
253
|
+
linkDetails,
|
|
254
|
+
anchorTextAnalysis: {
|
|
255
|
+
total: totalLinks,
|
|
256
|
+
descriptive: linksWithKeywords,
|
|
257
|
+
generic: linksWithGenericText,
|
|
258
|
+
empty: emptyAnchorLinks,
|
|
259
|
+
tooLong: tooLongAnchorLinks,
|
|
260
|
+
imageLinks,
|
|
261
|
+
patterns: anchorPatterns
|
|
262
|
+
},
|
|
263
|
+
issues,
|
|
264
|
+
recommendations
|
|
265
|
+
};
|
|
266
|
+
}
|
|
267
|
+
function suggestInternalLinks(content, availablePages) {
|
|
268
|
+
const suggestions = [];
|
|
269
|
+
const contentLower = content.toLowerCase();
|
|
270
|
+
for (const page of availablePages) {
|
|
271
|
+
for (const keyword of page.keywords) {
|
|
272
|
+
if (contentLower.includes(keyword.toLowerCase())) {
|
|
273
|
+
const alreadyLinked = content.includes(`href="${page.url}"`) || content.includes(`href='${page.url}'`);
|
|
274
|
+
if (!alreadyLinked) {
|
|
275
|
+
const position = contentLower.indexOf(keyword.toLowerCase());
|
|
276
|
+
const relevance = keyword.length * 2 + (1e3 - Math.min(position, 1e3)) / 100;
|
|
277
|
+
suggestions.push({
|
|
278
|
+
keyword,
|
|
279
|
+
suggestedUrl: page.url,
|
|
280
|
+
suggestedTitle: page.title,
|
|
281
|
+
relevance
|
|
282
|
+
});
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
return suggestions.sort((a, b) => b.relevance - a.relevance).filter((s, i, arr) => arr.findIndex((x) => x.suggestedUrl === s.suggestedUrl) === i).slice(0, 10);
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
export {
|
|
291
|
+
analyzeInternalLinking,
|
|
292
|
+
suggestInternalLinks
|
|
293
|
+
};
|
|
@@ -0,0 +1,303 @@
|
|
|
1
|
+
// src/analyzers/mobile-seo-analyzer.ts
|
|
2
|
+
import * as cheerio from "cheerio";
|
|
3
|
+
function analyzeViewport($) {
|
|
4
|
+
const viewport = $('meta[name="viewport"]');
|
|
5
|
+
const issues = [];
|
|
6
|
+
if (viewport.length === 0) {
|
|
7
|
+
return {
|
|
8
|
+
hasViewport: false,
|
|
9
|
+
isResponsive: false,
|
|
10
|
+
issues: ["No viewport meta tag"]
|
|
11
|
+
};
|
|
12
|
+
}
|
|
13
|
+
const content = viewport.attr("content") || "";
|
|
14
|
+
const hasWidth = content.includes("width=");
|
|
15
|
+
const hasDeviceWidth = content.includes("width=device-width");
|
|
16
|
+
const hasInitialScale = content.includes("initial-scale=");
|
|
17
|
+
const hasMaximumScale = content.includes("maximum-scale=1");
|
|
18
|
+
const hasUserScalable = content.includes("user-scalable=no") || content.includes("user-scalable=0");
|
|
19
|
+
let isResponsive = hasDeviceWidth;
|
|
20
|
+
if (!hasDeviceWidth) {
|
|
21
|
+
if (content.includes("width=") && !content.includes("width=device-width")) {
|
|
22
|
+
issues.push("Fixed viewport width instead of device-width");
|
|
23
|
+
isResponsive = false;
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
if (hasUserScalable || hasMaximumScale) {
|
|
27
|
+
issues.push("Viewport disables zooming - accessibility issue");
|
|
28
|
+
}
|
|
29
|
+
if (!hasInitialScale) {
|
|
30
|
+
issues.push("Missing initial-scale=1");
|
|
31
|
+
}
|
|
32
|
+
return {
|
|
33
|
+
hasViewport: true,
|
|
34
|
+
isResponsive,
|
|
35
|
+
viewportContent: content,
|
|
36
|
+
issues
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
function analyzeTouchTargets($) {
|
|
40
|
+
const issues = [];
|
|
41
|
+
let smallTargets = 0;
|
|
42
|
+
let properTargets = 0;
|
|
43
|
+
const interactiveElements = $('a, button, input, select, textarea, [role="button"], [onclick]');
|
|
44
|
+
interactiveElements.each((_, el) => {
|
|
45
|
+
const $el = $(el);
|
|
46
|
+
const style = $el.attr("style") || "";
|
|
47
|
+
const className = $el.attr("class") || "";
|
|
48
|
+
const hasSmallClass = /\b(xs|tiny|small|mini)\b/i.test(className);
|
|
49
|
+
const hasSmallInlineStyle = /(?:width|height)\s*:\s*(?:\d{1,2}(?:px|rem|em)|1\d{2}px)/i.test(style);
|
|
50
|
+
const hasPadding = /padding/i.test(style) || /\bp-\d|\bpy-\d|\bpx-\d/i.test(className);
|
|
51
|
+
if (hasSmallClass || hasSmallInlineStyle) {
|
|
52
|
+
smallTargets++;
|
|
53
|
+
} else if (hasPadding) {
|
|
54
|
+
properTargets++;
|
|
55
|
+
} else {
|
|
56
|
+
properTargets++;
|
|
57
|
+
}
|
|
58
|
+
});
|
|
59
|
+
if (smallTargets > 5) {
|
|
60
|
+
issues.push(`${smallTargets} potentially small touch targets`);
|
|
61
|
+
}
|
|
62
|
+
return {
|
|
63
|
+
smallTargets,
|
|
64
|
+
properTargets,
|
|
65
|
+
issues
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
function analyzeFontSizes($, html) {
|
|
69
|
+
const issues = [];
|
|
70
|
+
let smallFontIndicators = 0;
|
|
71
|
+
const hasResponsiveFonts = html.includes("@media") && html.includes("font-size") || html.includes("clamp(") || html.includes("vw") || /text-(?:xs|sm|base|lg|xl)/i.test(html);
|
|
72
|
+
$('[style*="font-size"]').each((_, el) => {
|
|
73
|
+
const style = $(el).attr("style") || "";
|
|
74
|
+
const match = style.match(/font-size\s*:\s*(\d+)(px|pt)?/i);
|
|
75
|
+
if (match) {
|
|
76
|
+
const size = parseInt(match[1]);
|
|
77
|
+
const unit = match[2]?.toLowerCase() || "px";
|
|
78
|
+
if (unit === "px" && size < 12 || unit === "pt" && size < 9) {
|
|
79
|
+
smallFontIndicators++;
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
});
|
|
83
|
+
const smallFontPatterns = /font-size\s*:\s*(?:[0-9]|1[01])px/gi;
|
|
84
|
+
const matches = html.match(smallFontPatterns);
|
|
85
|
+
if (matches) {
|
|
86
|
+
smallFontIndicators += matches.length;
|
|
87
|
+
}
|
|
88
|
+
if (smallFontIndicators > 3) {
|
|
89
|
+
issues.push(`${smallFontIndicators} instances of small font sizes (<12px)`);
|
|
90
|
+
}
|
|
91
|
+
return {
|
|
92
|
+
hasResponsiveFonts,
|
|
93
|
+
smallFontIndicators,
|
|
94
|
+
issues
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
function analyzeContentWidth($, html) {
|
|
98
|
+
const issues = [];
|
|
99
|
+
let fixedWidthElements = 0;
|
|
100
|
+
const hasOverflowHidden = html.includes("overflow-x: hidden") || html.includes("overflow-x:hidden");
|
|
101
|
+
$('[style*="width"]').each((_, el) => {
|
|
102
|
+
const style = $(el).attr("style") || "";
|
|
103
|
+
const widthMatch = style.match(/width\s*:\s*(\d+)(px)?/i);
|
|
104
|
+
if (widthMatch) {
|
|
105
|
+
const width = parseInt(widthMatch[1]);
|
|
106
|
+
if (width > 500 && !style.includes("max-width")) {
|
|
107
|
+
fixedWidthElements++;
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
});
|
|
111
|
+
const tables = $("table");
|
|
112
|
+
const responsiveTables = $("table").filter((_, el) => {
|
|
113
|
+
const parent = $(el).parent();
|
|
114
|
+
const parentClass = parent.attr("class") || "";
|
|
115
|
+
const parentStyle = parent.attr("style") || "";
|
|
116
|
+
return parentClass.includes("overflow") || parentStyle.includes("overflow") || parentClass.includes("responsive");
|
|
117
|
+
});
|
|
118
|
+
if (tables.length > responsiveTables.length) {
|
|
119
|
+
issues.push(`${tables.length - responsiveTables.length} tables without responsive wrapper`);
|
|
120
|
+
fixedWidthElements += tables.length - responsiveTables.length;
|
|
121
|
+
}
|
|
122
|
+
const iframes = $('iframe:not([style*="max-width"])');
|
|
123
|
+
if (iframes.length > 0) {
|
|
124
|
+
issues.push(`${iframes.length} iframes may cause horizontal scroll`);
|
|
125
|
+
fixedWidthElements += iframes.length;
|
|
126
|
+
}
|
|
127
|
+
return {
|
|
128
|
+
hasHorizontalScroll: !hasOverflowHidden && fixedWidthElements > 0,
|
|
129
|
+
fixedWidthElements,
|
|
130
|
+
issues
|
|
131
|
+
};
|
|
132
|
+
}
|
|
133
|
+
function analyzeMobileSpecific($) {
|
|
134
|
+
const issues = [];
|
|
135
|
+
const hasAppleTouchIcon = $('link[rel="apple-touch-icon"], link[rel="apple-touch-icon-precomposed"]').length > 0;
|
|
136
|
+
if (!hasAppleTouchIcon) {
|
|
137
|
+
issues.push("Missing apple-touch-icon");
|
|
138
|
+
}
|
|
139
|
+
const hasThemeColor = $('meta[name="theme-color"]').length > 0;
|
|
140
|
+
if (!hasThemeColor) {
|
|
141
|
+
issues.push("Missing theme-color meta tag");
|
|
142
|
+
}
|
|
143
|
+
const hasManifest = $('link[rel="manifest"]').length > 0;
|
|
144
|
+
if (!hasManifest) {
|
|
145
|
+
issues.push("Missing web app manifest");
|
|
146
|
+
}
|
|
147
|
+
const responsiveImages = $("img[srcset], picture source[srcset]").length;
|
|
148
|
+
const totalImages = $("img").length;
|
|
149
|
+
const hasMobileOptimizedImages = responsiveImages > 0 || totalImages === 0;
|
|
150
|
+
if (totalImages > 0 && responsiveImages === 0) {
|
|
151
|
+
issues.push("No responsive images (srcset)");
|
|
152
|
+
}
|
|
153
|
+
const popupIndicators = $('[class*="popup"], [class*="modal"], [class*="overlay"], [id*="popup"], [id*="modal"]');
|
|
154
|
+
const hasPopupTriggers = $("[data-popup], [data-modal], .popup-trigger, .modal-trigger").length > 0;
|
|
155
|
+
const avoidsMobilePopups = popupIndicators.filter((_, el) => {
|
|
156
|
+
const style = $(el).attr("style") || "";
|
|
157
|
+
return style.includes("display: block") || style.includes("display:block");
|
|
158
|
+
}).length === 0;
|
|
159
|
+
return {
|
|
160
|
+
hasAppleTouchIcon,
|
|
161
|
+
hasThemeColor,
|
|
162
|
+
hasManifest,
|
|
163
|
+
hasMobileOptimizedImages,
|
|
164
|
+
avoidsMobilePopups,
|
|
165
|
+
issues
|
|
166
|
+
};
|
|
167
|
+
}
|
|
168
|
+
function analyzeMobileSEO(html, url) {
|
|
169
|
+
const $ = cheerio.load(html);
|
|
170
|
+
const issues = [];
|
|
171
|
+
const recommendations = [];
|
|
172
|
+
let score = 100;
|
|
173
|
+
const viewport = analyzeViewport($);
|
|
174
|
+
const touchTargets = analyzeTouchTargets($);
|
|
175
|
+
const fontSizes = analyzeFontSizes($, html);
|
|
176
|
+
const contentWidth = analyzeContentWidth($, html);
|
|
177
|
+
const mobileSpecific = analyzeMobileSpecific($);
|
|
178
|
+
if (!viewport.hasViewport) {
|
|
179
|
+
issues.push({
|
|
180
|
+
code: "MOBILE_NO_VIEWPORT",
|
|
181
|
+
severity: "critical",
|
|
182
|
+
category: "technical",
|
|
183
|
+
title: "Missing viewport meta tag",
|
|
184
|
+
description: "The page lacks a viewport meta tag, critical for mobile rendering.",
|
|
185
|
+
impact: "Page will not render properly on mobile devices",
|
|
186
|
+
howToFix: 'Add <meta name="viewport" content="width=device-width, initial-scale=1">',
|
|
187
|
+
affectedUrls: [url]
|
|
188
|
+
});
|
|
189
|
+
score -= 30;
|
|
190
|
+
} else if (!viewport.isResponsive) {
|
|
191
|
+
issues.push({
|
|
192
|
+
code: "MOBILE_FIXED_VIEWPORT",
|
|
193
|
+
severity: "warning",
|
|
194
|
+
category: "technical",
|
|
195
|
+
title: "Non-responsive viewport",
|
|
196
|
+
description: viewport.issues.join(". "),
|
|
197
|
+
impact: "Page may not scale properly on all devices",
|
|
198
|
+
howToFix: "Use width=device-width instead of fixed width",
|
|
199
|
+
affectedUrls: [url]
|
|
200
|
+
});
|
|
201
|
+
score -= 15;
|
|
202
|
+
}
|
|
203
|
+
if (viewport.issues.includes("Viewport disables zooming - accessibility issue")) {
|
|
204
|
+
issues.push({
|
|
205
|
+
code: "MOBILE_ZOOM_DISABLED",
|
|
206
|
+
severity: "warning",
|
|
207
|
+
category: "technical",
|
|
208
|
+
title: "Pinch-to-zoom disabled",
|
|
209
|
+
description: "The viewport prevents users from zooming, which is an accessibility violation.",
|
|
210
|
+
impact: "Fails WCAG accessibility guidelines",
|
|
211
|
+
howToFix: "Remove maximum-scale=1 and user-scalable=no from viewport",
|
|
212
|
+
affectedUrls: [url]
|
|
213
|
+
});
|
|
214
|
+
score -= 10;
|
|
215
|
+
}
|
|
216
|
+
if (touchTargets.smallTargets > 5) {
|
|
217
|
+
issues.push({
|
|
218
|
+
code: "MOBILE_SMALL_TOUCH_TARGETS",
|
|
219
|
+
severity: "warning",
|
|
220
|
+
category: "technical",
|
|
221
|
+
title: `${touchTargets.smallTargets} small touch targets`,
|
|
222
|
+
description: "Interactive elements may be too small to tap easily on mobile.",
|
|
223
|
+
impact: "Poor mobile usability, user frustration",
|
|
224
|
+
howToFix: "Ensure touch targets are at least 48x48px with adequate spacing",
|
|
225
|
+
affectedUrls: [url]
|
|
226
|
+
});
|
|
227
|
+
score -= Math.min(touchTargets.smallTargets * 2, 15);
|
|
228
|
+
}
|
|
229
|
+
if (fontSizes.smallFontIndicators > 3) {
|
|
230
|
+
issues.push({
|
|
231
|
+
code: "MOBILE_SMALL_FONTS",
|
|
232
|
+
severity: "warning",
|
|
233
|
+
category: "technical",
|
|
234
|
+
title: "Small font sizes detected",
|
|
235
|
+
description: `${fontSizes.smallFontIndicators} instances of fonts smaller than 12px.`,
|
|
236
|
+
impact: "Text difficult to read on mobile without zooming",
|
|
237
|
+
howToFix: "Use minimum 16px for body text, 12px absolute minimum",
|
|
238
|
+
affectedUrls: [url]
|
|
239
|
+
});
|
|
240
|
+
score -= 10;
|
|
241
|
+
}
|
|
242
|
+
if (contentWidth.fixedWidthElements > 2) {
|
|
243
|
+
issues.push({
|
|
244
|
+
code: "MOBILE_HORIZONTAL_SCROLL",
|
|
245
|
+
severity: "warning",
|
|
246
|
+
category: "technical",
|
|
247
|
+
title: "Content wider than screen",
|
|
248
|
+
description: `${contentWidth.fixedWidthElements} elements may cause horizontal scrolling.`,
|
|
249
|
+
impact: "Horizontal scrolling is frustrating on mobile",
|
|
250
|
+
howToFix: "Use max-width: 100% and avoid fixed widths >320px",
|
|
251
|
+
affectedUrls: [url]
|
|
252
|
+
});
|
|
253
|
+
score -= Math.min(contentWidth.fixedWidthElements * 3, 15);
|
|
254
|
+
}
|
|
255
|
+
if (!mobileSpecific.hasManifest) {
|
|
256
|
+
issues.push({
|
|
257
|
+
code: "MOBILE_NO_MANIFEST",
|
|
258
|
+
severity: "info",
|
|
259
|
+
category: "technical",
|
|
260
|
+
title: "Missing web app manifest",
|
|
261
|
+
description: "No manifest.json linked for PWA capabilities.",
|
|
262
|
+
impact: "Cannot be installed as PWA",
|
|
263
|
+
howToFix: 'Add <link rel="manifest" href="/manifest.json">',
|
|
264
|
+
affectedUrls: [url]
|
|
265
|
+
});
|
|
266
|
+
score -= 5;
|
|
267
|
+
}
|
|
268
|
+
if (!mobileSpecific.hasThemeColor) {
|
|
269
|
+
recommendations.push("Add theme-color meta tag for mobile browser UI customization");
|
|
270
|
+
score -= 2;
|
|
271
|
+
}
|
|
272
|
+
if (!mobileSpecific.hasAppleTouchIcon) {
|
|
273
|
+
recommendations.push("Add apple-touch-icon for iOS home screen");
|
|
274
|
+
score -= 2;
|
|
275
|
+
}
|
|
276
|
+
if (!mobileSpecific.hasMobileOptimizedImages) {
|
|
277
|
+
recommendations.push("Use srcset for responsive images");
|
|
278
|
+
score -= 5;
|
|
279
|
+
}
|
|
280
|
+
if (viewport.hasViewport && viewport.isResponsive && viewport.issues.length === 0) {
|
|
281
|
+
recommendations.push("\u2713 Proper responsive viewport configuration");
|
|
282
|
+
}
|
|
283
|
+
if (fontSizes.hasResponsiveFonts) {
|
|
284
|
+
recommendations.push("\u2713 Responsive font sizing detected");
|
|
285
|
+
}
|
|
286
|
+
if (mobileSpecific.hasManifest && mobileSpecific.hasAppleTouchIcon && mobileSpecific.hasThemeColor) {
|
|
287
|
+
recommendations.push("\u2713 Good PWA/mobile app configuration");
|
|
288
|
+
}
|
|
289
|
+
return {
|
|
290
|
+
score: Math.max(0, Math.min(100, score)),
|
|
291
|
+
viewport,
|
|
292
|
+
touchTargets,
|
|
293
|
+
fontSizes,
|
|
294
|
+
contentWidth,
|
|
295
|
+
mobileSpecific,
|
|
296
|
+
issues,
|
|
297
|
+
recommendations
|
|
298
|
+
};
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
export {
|
|
302
|
+
analyzeMobileSEO
|
|
303
|
+
};
|
|
@@ -1,15 +1,3 @@
|
|
|
1
|
-
var __defProp = Object.defineProperty;
|
|
2
|
-
var __require = /* @__PURE__ */ ((x) => typeof require !== "undefined" ? require : typeof Proxy !== "undefined" ? new Proxy(x, {
|
|
3
|
-
get: (a, b) => (typeof require !== "undefined" ? require : a)[b]
|
|
4
|
-
}) : x)(function(x) {
|
|
5
|
-
if (typeof require !== "undefined") return require.apply(this, arguments);
|
|
6
|
-
throw Error('Dynamic require of "' + x + '" is not supported');
|
|
7
|
-
});
|
|
8
|
-
var __export = (target, all) => {
|
|
9
|
-
for (var name in all)
|
|
10
|
-
__defProp(target, name, { get: all[name], enumerable: true });
|
|
11
|
-
};
|
|
12
|
-
|
|
13
1
|
// src/utils/http.ts
|
|
14
2
|
var DEFAULT_TIMEOUT = 3e4;
|
|
15
3
|
var DEFAULT_USER_AGENT = "RankCLI/1.0 (+https://rankcli.dev)";
|
|
@@ -757,8 +745,6 @@ function generateRecommendations(issues) {
|
|
|
757
745
|
}
|
|
758
746
|
|
|
759
747
|
export {
|
|
760
|
-
__require,
|
|
761
|
-
__export,
|
|
762
748
|
httpGet,
|
|
763
749
|
httpHead,
|
|
764
750
|
httpPost,
|