@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,153 @@
|
|
|
1
|
+
import { httpHead } from '../../utils/http.js';
|
|
2
|
+
import type { AuditIssue } from '../types.js';
|
|
3
|
+
import { ISSUE_DEFINITIONS } from '../types.js';
|
|
4
|
+
|
|
5
|
+
export interface RedirectData {
|
|
6
|
+
hasRedirect: boolean;
|
|
7
|
+
redirectChain: RedirectHop[];
|
|
8
|
+
isLoop: boolean;
|
|
9
|
+
finalUrl?: string;
|
|
10
|
+
totalHops: number;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export interface RedirectHop {
|
|
14
|
+
url: string;
|
|
15
|
+
statusCode: number;
|
|
16
|
+
location?: string;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export async function analyzeRedirects(
|
|
20
|
+
url: string,
|
|
21
|
+
maxHops: number = 10
|
|
22
|
+
): Promise<{ issues: AuditIssue[]; data: RedirectData }> {
|
|
23
|
+
const issues: AuditIssue[] = [];
|
|
24
|
+
const chain: RedirectHop[] = [];
|
|
25
|
+
const visitedUrls = new Set<string>();
|
|
26
|
+
let currentUrl = url;
|
|
27
|
+
let isLoop = false;
|
|
28
|
+
|
|
29
|
+
while (chain.length < maxHops) {
|
|
30
|
+
// Check for loop
|
|
31
|
+
if (visitedUrls.has(currentUrl)) {
|
|
32
|
+
isLoop = true;
|
|
33
|
+
break;
|
|
34
|
+
}
|
|
35
|
+
visitedUrls.add(currentUrl);
|
|
36
|
+
|
|
37
|
+
try {
|
|
38
|
+
const response = await httpHead(currentUrl, {
|
|
39
|
+
timeout: 5000,
|
|
40
|
+
maxRedirects: 0,
|
|
41
|
+
validateStatus: () => true,
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
const hop: RedirectHop = {
|
|
45
|
+
url: currentUrl,
|
|
46
|
+
statusCode: response.status,
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
if (response.status >= 300 && response.status < 400) {
|
|
50
|
+
const location = response.headers['location'];
|
|
51
|
+
if (location) {
|
|
52
|
+
hop.location = location;
|
|
53
|
+
chain.push(hop);
|
|
54
|
+
currentUrl = new URL(location, currentUrl).href;
|
|
55
|
+
continue;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
chain.push(hop);
|
|
60
|
+
break;
|
|
61
|
+
} catch (error) {
|
|
62
|
+
chain.push({
|
|
63
|
+
url: currentUrl,
|
|
64
|
+
statusCode: 0,
|
|
65
|
+
});
|
|
66
|
+
break;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const hasRedirect = chain.length > 1;
|
|
71
|
+
const finalUrl = chain.length > 0 ? chain[chain.length - 1].url : url;
|
|
72
|
+
|
|
73
|
+
const data: RedirectData = {
|
|
74
|
+
hasRedirect,
|
|
75
|
+
redirectChain: chain,
|
|
76
|
+
isLoop,
|
|
77
|
+
finalUrl,
|
|
78
|
+
totalHops: chain.length - 1,
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
// ==================== Issue Detection ====================
|
|
82
|
+
|
|
83
|
+
// Redirect loop
|
|
84
|
+
if (isLoop) {
|
|
85
|
+
issues.push({
|
|
86
|
+
...ISSUE_DEFINITIONS.REDIRECT_LOOP,
|
|
87
|
+
affectedUrls: [url],
|
|
88
|
+
details: {
|
|
89
|
+
chain: chain.map(h => `${h.url} (${h.statusCode})`),
|
|
90
|
+
},
|
|
91
|
+
});
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// Redirect chain (more than one redirect)
|
|
95
|
+
if (!isLoop && chain.length > 2) {
|
|
96
|
+
issues.push({
|
|
97
|
+
...ISSUE_DEFINITIONS.REDIRECT_CHAIN,
|
|
98
|
+
affectedUrls: [url],
|
|
99
|
+
details: {
|
|
100
|
+
chain: chain.map(h => `${h.url} (${h.statusCode})`),
|
|
101
|
+
hops: chain.length - 1,
|
|
102
|
+
finalUrl,
|
|
103
|
+
},
|
|
104
|
+
});
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
return { issues, data };
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// Check internal links for redirect targets
|
|
111
|
+
export async function checkInternalRedirects(
|
|
112
|
+
internalLinks: string[],
|
|
113
|
+
batchSize: number = 10
|
|
114
|
+
): Promise<{ redirectLinks: Array<{ source: string; target: string; statusCode: number }> }> {
|
|
115
|
+
const redirectLinks: Array<{ source: string; target: string; statusCode: number }> = [];
|
|
116
|
+
|
|
117
|
+
// Process in batches to avoid overwhelming the server
|
|
118
|
+
for (let i = 0; i < internalLinks.length; i += batchSize) {
|
|
119
|
+
const batch = internalLinks.slice(i, i + batchSize);
|
|
120
|
+
const promises = batch.map(async (link) => {
|
|
121
|
+
try {
|
|
122
|
+
const response = await httpHead(link, {
|
|
123
|
+
timeout: 5000,
|
|
124
|
+
maxRedirects: 0,
|
|
125
|
+
validateStatus: () => true,
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
if (response.status >= 300 && response.status < 400) {
|
|
129
|
+
const location = response.headers['location'];
|
|
130
|
+
if (location) {
|
|
131
|
+
return {
|
|
132
|
+
source: link,
|
|
133
|
+
target: new URL(location, link).href,
|
|
134
|
+
statusCode: response.status,
|
|
135
|
+
};
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
return null;
|
|
139
|
+
} catch {
|
|
140
|
+
return null;
|
|
141
|
+
}
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
const results = await Promise.all(promises);
|
|
145
|
+
for (const result of results) {
|
|
146
|
+
if (result) {
|
|
147
|
+
redirectLinks.push(result);
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
return { redirectLinks };
|
|
153
|
+
}
|
|
@@ -0,0 +1,389 @@
|
|
|
1
|
+
// Redirect Chain Detection - Multiple redirects hurt SEO
|
|
2
|
+
// Reference: "Technical SEO for Developers - 17 Tips to Rank Higher"
|
|
3
|
+
// "Managing redirects... Broken links can absolutely tank your SEO numbers"
|
|
4
|
+
|
|
5
|
+
import { httpGet } from '../../utils/http.js';
|
|
6
|
+
import type { AuditIssue } from '../types.js';
|
|
7
|
+
|
|
8
|
+
export interface RedirectHop {
|
|
9
|
+
url: string;
|
|
10
|
+
statusCode: number;
|
|
11
|
+
statusText: string;
|
|
12
|
+
redirectTo: string | null;
|
|
13
|
+
responseTime: number;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export interface RedirectChainResult {
|
|
17
|
+
originalUrl: string;
|
|
18
|
+
finalUrl: string;
|
|
19
|
+
hops: RedirectHop[];
|
|
20
|
+
chainLength: number;
|
|
21
|
+
totalTime: number;
|
|
22
|
+
hasHttpsUpgrade: boolean;
|
|
23
|
+
hasWwwNormalization: boolean;
|
|
24
|
+
hasTrailingSlashIssue: boolean;
|
|
25
|
+
isInternalLoop: boolean;
|
|
26
|
+
issues: string[];
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// Maximum redirects to follow before giving up
|
|
30
|
+
const MAX_REDIRECTS = 10;
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Follow redirects and build the chain
|
|
34
|
+
*/
|
|
35
|
+
export async function traceRedirectChain(
|
|
36
|
+
url: string,
|
|
37
|
+
options: { timeout?: number } = {}
|
|
38
|
+
): Promise<RedirectChainResult> {
|
|
39
|
+
const hops: RedirectHop[] = [];
|
|
40
|
+
let currentUrl = url;
|
|
41
|
+
let chainLength = 0;
|
|
42
|
+
let totalTime = 0;
|
|
43
|
+
const visitedUrls = new Set<string>();
|
|
44
|
+
|
|
45
|
+
const timeout = options.timeout || 10000;
|
|
46
|
+
|
|
47
|
+
while (chainLength < MAX_REDIRECTS) {
|
|
48
|
+
// Detect loops
|
|
49
|
+
if (visitedUrls.has(currentUrl)) {
|
|
50
|
+
break;
|
|
51
|
+
}
|
|
52
|
+
visitedUrls.add(currentUrl);
|
|
53
|
+
|
|
54
|
+
try {
|
|
55
|
+
const startTime = Date.now();
|
|
56
|
+
|
|
57
|
+
const response = await httpGet<string>(currentUrl, {
|
|
58
|
+
|
|
59
|
+
timeout,
|
|
60
|
+
maxRedirects: 0, // Don't auto-follow, we want to trace manually
|
|
61
|
+
validateStatus: () => true, // Accept all status codes
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
const responseTime = Date.now() - startTime;
|
|
65
|
+
totalTime += responseTime;
|
|
66
|
+
|
|
67
|
+
const statusCode = response.status;
|
|
68
|
+
const locationHeader = response.headers.location;
|
|
69
|
+
|
|
70
|
+
// Normalize the redirect location
|
|
71
|
+
let redirectTo: string | null = null;
|
|
72
|
+
if (locationHeader) {
|
|
73
|
+
try {
|
|
74
|
+
// Handle relative URLs
|
|
75
|
+
redirectTo = new URL(locationHeader, currentUrl).toString();
|
|
76
|
+
} catch {
|
|
77
|
+
redirectTo = locationHeader;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
hops.push({
|
|
82
|
+
url: currentUrl,
|
|
83
|
+
statusCode,
|
|
84
|
+
statusText: response.statusText || getStatusText(statusCode),
|
|
85
|
+
redirectTo,
|
|
86
|
+
responseTime,
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
// Check if this is a redirect
|
|
90
|
+
if (statusCode >= 300 && statusCode < 400 && redirectTo) {
|
|
91
|
+
chainLength++;
|
|
92
|
+
currentUrl = redirectTo;
|
|
93
|
+
} else {
|
|
94
|
+
// Final destination reached
|
|
95
|
+
break;
|
|
96
|
+
}
|
|
97
|
+
} catch (error) {
|
|
98
|
+
// Network error or timeout
|
|
99
|
+
hops.push({
|
|
100
|
+
url: currentUrl,
|
|
101
|
+
statusCode: 0,
|
|
102
|
+
statusText: error instanceof Error ? error.message : 'Network error',
|
|
103
|
+
redirectTo: null,
|
|
104
|
+
responseTime: 0,
|
|
105
|
+
});
|
|
106
|
+
break;
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
const finalUrl = hops.length > 0 ? hops[hops.length - 1].url : url;
|
|
111
|
+
|
|
112
|
+
// Analyze the chain for common issues
|
|
113
|
+
const issues = analyzeChainIssues(url, hops);
|
|
114
|
+
|
|
115
|
+
return {
|
|
116
|
+
originalUrl: url,
|
|
117
|
+
finalUrl,
|
|
118
|
+
hops,
|
|
119
|
+
chainLength: hops.length - 1, // Number of redirects (not including final destination)
|
|
120
|
+
totalTime,
|
|
121
|
+
hasHttpsUpgrade: detectHttpsUpgrade(hops),
|
|
122
|
+
hasWwwNormalization: detectWwwNormalization(hops),
|
|
123
|
+
hasTrailingSlashIssue: detectTrailingSlashIssue(hops),
|
|
124
|
+
isInternalLoop: visitedUrls.size !== hops.length,
|
|
125
|
+
issues,
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function getStatusText(code: number): string {
|
|
130
|
+
const statusTexts: Record<number, string> = {
|
|
131
|
+
200: 'OK',
|
|
132
|
+
301: 'Moved Permanently',
|
|
133
|
+
302: 'Found',
|
|
134
|
+
303: 'See Other',
|
|
135
|
+
307: 'Temporary Redirect',
|
|
136
|
+
308: 'Permanent Redirect',
|
|
137
|
+
404: 'Not Found',
|
|
138
|
+
500: 'Internal Server Error',
|
|
139
|
+
};
|
|
140
|
+
return statusTexts[code] || 'Unknown';
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function detectHttpsUpgrade(hops: RedirectHop[]): boolean {
|
|
144
|
+
for (let i = 0; i < hops.length - 1; i++) {
|
|
145
|
+
const current = hops[i].url;
|
|
146
|
+
const next = hops[i].redirectTo;
|
|
147
|
+
if (current.startsWith('http://') && next?.startsWith('https://')) {
|
|
148
|
+
return true;
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
return false;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function detectWwwNormalization(hops: RedirectHop[]): boolean {
|
|
155
|
+
for (let i = 0; i < hops.length - 1; i++) {
|
|
156
|
+
const currentHost = extractHost(hops[i].url);
|
|
157
|
+
const nextHost = hops[i].redirectTo ? extractHost(hops[i].redirectTo!) : null;
|
|
158
|
+
|
|
159
|
+
if (currentHost && nextHost) {
|
|
160
|
+
const hasWwwChange =
|
|
161
|
+
(currentHost.startsWith('www.') && !nextHost.startsWith('www.')) ||
|
|
162
|
+
(!currentHost.startsWith('www.') && nextHost.startsWith('www.'));
|
|
163
|
+
if (hasWwwChange) {
|
|
164
|
+
return true;
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
return false;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
function detectTrailingSlashIssue(hops: RedirectHop[]): boolean {
|
|
172
|
+
for (let i = 0; i < hops.length - 1; i++) {
|
|
173
|
+
const current = new URL(hops[i].url);
|
|
174
|
+
const next = hops[i].redirectTo ? new URL(hops[i].redirectTo!) : null;
|
|
175
|
+
|
|
176
|
+
if (next && current.pathname !== next.pathname) {
|
|
177
|
+
const pathDiff =
|
|
178
|
+
(current.pathname.endsWith('/') && !next.pathname.endsWith('/')) ||
|
|
179
|
+
(!current.pathname.endsWith('/') && next.pathname.endsWith('/'));
|
|
180
|
+
|
|
181
|
+
// Check if only difference is trailing slash
|
|
182
|
+
const normalizedCurrent = current.pathname.replace(/\/$/, '');
|
|
183
|
+
const normalizedNext = next.pathname.replace(/\/$/, '');
|
|
184
|
+
|
|
185
|
+
if (pathDiff && normalizedCurrent === normalizedNext) {
|
|
186
|
+
return true;
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
return false;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
function extractHost(url: string): string | null {
|
|
194
|
+
try {
|
|
195
|
+
return new URL(url).hostname;
|
|
196
|
+
} catch {
|
|
197
|
+
return null;
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
function analyzeChainIssues(originalUrl: string, hops: RedirectHop[]): string[] {
|
|
202
|
+
const issues: string[] = [];
|
|
203
|
+
|
|
204
|
+
// Check for redirect chain
|
|
205
|
+
const redirectCount = hops.filter(h => h.statusCode >= 300 && h.statusCode < 400).length;
|
|
206
|
+
if (redirectCount > 1) {
|
|
207
|
+
issues.push(`Redirect chain detected: ${redirectCount} hops`);
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
// Check for mixed 301/302
|
|
211
|
+
const has301 = hops.some(h => h.statusCode === 301 || h.statusCode === 308);
|
|
212
|
+
const has302 = hops.some(h => h.statusCode === 302 || h.statusCode === 307);
|
|
213
|
+
if (has301 && has302) {
|
|
214
|
+
issues.push('Mixed permanent and temporary redirects');
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
// Check for 302 being used where 301 should be
|
|
218
|
+
const uses302 = hops.some(h => h.statusCode === 302);
|
|
219
|
+
if (uses302 && redirectCount === 1) {
|
|
220
|
+
issues.push('Consider using 301 (permanent) instead of 302 (temporary)');
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
// Check for slow redirects
|
|
224
|
+
for (const hop of hops) {
|
|
225
|
+
if (hop.responseTime > 500) {
|
|
226
|
+
issues.push(`Slow redirect: ${hop.url} took ${hop.responseTime}ms`);
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
// Check for HTTP to HTTPS upgrade in chain
|
|
231
|
+
if (originalUrl.startsWith('http://')) {
|
|
232
|
+
const finalHop = hops[hops.length - 1];
|
|
233
|
+
if (finalHop && !finalHop.url.startsWith('https://')) {
|
|
234
|
+
issues.push('HTTP URL does not redirect to HTTPS');
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
// Check for unnecessary www/non-www redirect
|
|
239
|
+
const httpAndWwwRedirects = hops.filter(h =>
|
|
240
|
+
h.statusCode >= 300 && h.statusCode < 400
|
|
241
|
+
);
|
|
242
|
+
if (httpAndWwwRedirects.length > 1) {
|
|
243
|
+
issues.push('Multiple normalizations (consider combining HTTPS + www redirects)');
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
return issues;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
/**
|
|
250
|
+
* Main function: Analyze redirects for SEO issues
|
|
251
|
+
*/
|
|
252
|
+
export async function analyzeRedirectChain(
|
|
253
|
+
url: string
|
|
254
|
+
): Promise<{ issues: AuditIssue[]; data: RedirectChainResult }> {
|
|
255
|
+
const issues: AuditIssue[] = [];
|
|
256
|
+
const result = await traceRedirectChain(url);
|
|
257
|
+
|
|
258
|
+
// Critical: Redirect loop detected
|
|
259
|
+
if (result.isInternalLoop) {
|
|
260
|
+
issues.push({
|
|
261
|
+
code: 'REDIRECT_LOOP',
|
|
262
|
+
severity: 'error',
|
|
263
|
+
category: 'crawlability',
|
|
264
|
+
title: 'Redirect loop detected',
|
|
265
|
+
description: 'The URL redirects to itself or creates a circular redirect chain.',
|
|
266
|
+
impact: 'Search engines cannot index pages with redirect loops. Users will see an error.',
|
|
267
|
+
howToFix: 'Review redirect rules and ensure each URL points to a single final destination.',
|
|
268
|
+
affectedUrls: [url],
|
|
269
|
+
details: {
|
|
270
|
+
chain: result.hops.map(h => h.url),
|
|
271
|
+
},
|
|
272
|
+
});
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
// Error: Long redirect chain (3+ hops)
|
|
276
|
+
if (result.chainLength >= 3) {
|
|
277
|
+
issues.push({
|
|
278
|
+
code: 'LONG_REDIRECT_CHAIN',
|
|
279
|
+
severity: 'error',
|
|
280
|
+
category: 'crawlability',
|
|
281
|
+
title: `Long redirect chain (${result.chainLength} hops)`,
|
|
282
|
+
description: `URL requires ${result.chainLength} redirects before reaching the final destination.`,
|
|
283
|
+
impact: 'Long redirect chains slow page load, waste crawl budget, and may lose link equity.',
|
|
284
|
+
howToFix: 'Update internal links to point directly to the final URL. Combine redirect rules.',
|
|
285
|
+
affectedUrls: [url],
|
|
286
|
+
details: {
|
|
287
|
+
chain: result.hops.map(h => ({ url: h.url, status: h.statusCode })),
|
|
288
|
+
totalTime: result.totalTime,
|
|
289
|
+
},
|
|
290
|
+
});
|
|
291
|
+
}
|
|
292
|
+
// Warning: Redirect chain (2 hops)
|
|
293
|
+
else if (result.chainLength === 2) {
|
|
294
|
+
issues.push({
|
|
295
|
+
code: 'REDIRECT_CHAIN',
|
|
296
|
+
severity: 'warning',
|
|
297
|
+
category: 'crawlability',
|
|
298
|
+
title: 'Redirect chain detected (2 hops)',
|
|
299
|
+
description: 'URL has an intermediate redirect before reaching the final destination.',
|
|
300
|
+
impact: 'Each redirect adds latency and may dilute link equity slightly.',
|
|
301
|
+
howToFix: 'Combine redirects into a single hop from source to final destination.',
|
|
302
|
+
affectedUrls: [url],
|
|
303
|
+
details: {
|
|
304
|
+
chain: result.hops.map(h => ({ url: h.url, status: h.statusCode })),
|
|
305
|
+
},
|
|
306
|
+
});
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
// Warning: Temporary redirect (302) used
|
|
310
|
+
const uses302 = result.hops.some(h => h.statusCode === 302 || h.statusCode === 307);
|
|
311
|
+
if (uses302 && result.chainLength > 0) {
|
|
312
|
+
issues.push({
|
|
313
|
+
code: 'TEMPORARY_REDIRECT',
|
|
314
|
+
severity: 'warning',
|
|
315
|
+
category: 'crawlability',
|
|
316
|
+
title: 'Temporary redirect (302/307) used',
|
|
317
|
+
description: 'URL uses a temporary redirect instead of a permanent redirect.',
|
|
318
|
+
impact: 'Temporary redirects may not pass full link equity. Use 301/308 for permanent moves.',
|
|
319
|
+
howToFix: 'Change 302/307 redirects to 301/308 if the redirect is permanent.',
|
|
320
|
+
affectedUrls: [url],
|
|
321
|
+
details: {
|
|
322
|
+
redirectType: result.hops.find(h => h.statusCode === 302 || h.statusCode === 307)?.statusCode,
|
|
323
|
+
},
|
|
324
|
+
});
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
// Notice: HTTPS upgrade happening
|
|
328
|
+
if (result.hasHttpsUpgrade && result.chainLength === 1) {
|
|
329
|
+
// This is expected and not an issue, but we note it
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
// Notice: Inefficient www normalization
|
|
333
|
+
if (result.hasWwwNormalization && result.hasHttpsUpgrade && result.chainLength > 1) {
|
|
334
|
+
issues.push({
|
|
335
|
+
code: 'INEFFICIENT_NORMALIZATION',
|
|
336
|
+
severity: 'notice',
|
|
337
|
+
category: 'crawlability',
|
|
338
|
+
title: 'Inefficient HTTP/www normalization',
|
|
339
|
+
description: 'Multiple redirects for HTTPS upgrade and www normalization that could be combined.',
|
|
340
|
+
impact: 'Extra redirect adds unnecessary latency and server load.',
|
|
341
|
+
howToFix: 'Configure server to redirect directly from http://domain to https://www.domain (or vice versa) in one hop.',
|
|
342
|
+
affectedUrls: [url],
|
|
343
|
+
});
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
// Notice: Slow redirect chain
|
|
347
|
+
if (result.totalTime > 1000 && result.chainLength > 0) {
|
|
348
|
+
issues.push({
|
|
349
|
+
code: 'SLOW_REDIRECTS',
|
|
350
|
+
severity: 'notice',
|
|
351
|
+
category: 'performance',
|
|
352
|
+
title: `Slow redirect chain (${result.totalTime}ms total)`,
|
|
353
|
+
description: `Redirect chain took ${result.totalTime}ms to complete.`,
|
|
354
|
+
impact: 'Slow redirects negatively impact Time to First Byte and user experience.',
|
|
355
|
+
howToFix: 'Optimize server response times and reduce redirect chain length.',
|
|
356
|
+
affectedUrls: [url],
|
|
357
|
+
details: {
|
|
358
|
+
totalTime: result.totalTime,
|
|
359
|
+
hops: result.hops.map(h => ({ url: h.url, time: h.responseTime })),
|
|
360
|
+
},
|
|
361
|
+
});
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
return { issues, data: result };
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
/**
|
|
368
|
+
* Batch check multiple URLs for redirect chains
|
|
369
|
+
*/
|
|
370
|
+
export async function batchAnalyzeRedirects(
|
|
371
|
+
urls: string[]
|
|
372
|
+
): Promise<Map<string, RedirectChainResult>> {
|
|
373
|
+
const results = new Map<string, RedirectChainResult>();
|
|
374
|
+
|
|
375
|
+
// Process in parallel with concurrency limit
|
|
376
|
+
const concurrency = 5;
|
|
377
|
+
for (let i = 0; i < urls.length; i += concurrency) {
|
|
378
|
+
const batch = urls.slice(i, i + concurrency);
|
|
379
|
+
const batchResults = await Promise.all(
|
|
380
|
+
batch.map(url => traceRedirectChain(url).then(result => ({ url, result })))
|
|
381
|
+
);
|
|
382
|
+
|
|
383
|
+
for (const { url, result } of batchResults) {
|
|
384
|
+
results.set(url, result);
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
return results;
|
|
389
|
+
}
|