@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,435 @@
|
|
|
1
|
+
// Topical Cluster Analysis - Internal Linking for Topic Authority
|
|
2
|
+
// Reference: "4 Steps to Rank #1 in Google (2026 SEO Plan)" by Nathan Gotch
|
|
3
|
+
// "Create many supporting assets to train the algorithm to believe we're experts"
|
|
4
|
+
// "Cross-link these informational assets together to form a strong cluster"
|
|
5
|
+
// "Internally link back out to your commercial page from all these informational assets"
|
|
6
|
+
|
|
7
|
+
import * as cheerio from 'cheerio';
|
|
8
|
+
import type { AuditIssue } from '../types.js';
|
|
9
|
+
|
|
10
|
+
export interface InternalLink {
|
|
11
|
+
href: string;
|
|
12
|
+
anchorText: string;
|
|
13
|
+
context: 'navigation' | 'content' | 'footer' | 'sidebar';
|
|
14
|
+
isDoFollow: boolean;
|
|
15
|
+
destination: 'internal' | 'external' | 'anchor';
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export interface TopicalClusterData {
|
|
19
|
+
currentUrl: string;
|
|
20
|
+
internalLinks: InternalLink[];
|
|
21
|
+
externalLinks: InternalLink[];
|
|
22
|
+
anchorLinks: InternalLink[];
|
|
23
|
+
linkMetrics: {
|
|
24
|
+
totalLinks: number;
|
|
25
|
+
internalLinkCount: number;
|
|
26
|
+
externalLinkCount: number;
|
|
27
|
+
contentLinks: number; // Links within main content (not nav/footer)
|
|
28
|
+
uniqueInternalDestinations: number;
|
|
29
|
+
avgAnchorTextLength: number;
|
|
30
|
+
hasExactMatchAnchors: boolean;
|
|
31
|
+
};
|
|
32
|
+
clusterSignals: {
|
|
33
|
+
hasHubStructure: boolean; // Links to many related pages
|
|
34
|
+
hasSpokeLinks: boolean; // Content links back to main topics
|
|
35
|
+
hasBreadcrumbs: boolean;
|
|
36
|
+
hasRelatedPosts: boolean;
|
|
37
|
+
hasCategoryLinks: boolean;
|
|
38
|
+
internalToExternalRatio: number;
|
|
39
|
+
};
|
|
40
|
+
orphanPageRisk: 'low' | 'medium' | 'high';
|
|
41
|
+
recommendations: string[];
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Determine link context (nav, content, footer, sidebar)
|
|
46
|
+
*/
|
|
47
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
48
|
+
function getLinkContext($: cheerio.CheerioAPI, link: cheerio.Cheerio<any>): InternalLink['context'] {
|
|
49
|
+
const parents = link.parents().toArray();
|
|
50
|
+
|
|
51
|
+
for (const parent of parents) {
|
|
52
|
+
const tagName = parent.tagName?.toLowerCase();
|
|
53
|
+
const className = ($(parent).attr('class') || '').toLowerCase();
|
|
54
|
+
const id = ($(parent).attr('id') || '').toLowerCase();
|
|
55
|
+
|
|
56
|
+
// Navigation detection
|
|
57
|
+
if (tagName === 'nav' ||
|
|
58
|
+
className.includes('nav') ||
|
|
59
|
+
className.includes('menu') ||
|
|
60
|
+
id.includes('nav') ||
|
|
61
|
+
id.includes('menu')) {
|
|
62
|
+
return 'navigation';
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// Footer detection
|
|
66
|
+
if (tagName === 'footer' ||
|
|
67
|
+
className.includes('footer') ||
|
|
68
|
+
id.includes('footer')) {
|
|
69
|
+
return 'footer';
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// Sidebar detection
|
|
73
|
+
if (tagName === 'aside' ||
|
|
74
|
+
className.includes('sidebar') ||
|
|
75
|
+
className.includes('widget') ||
|
|
76
|
+
id.includes('sidebar')) {
|
|
77
|
+
return 'sidebar';
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// Main content detection
|
|
81
|
+
if (tagName === 'main' ||
|
|
82
|
+
tagName === 'article' ||
|
|
83
|
+
className.includes('content') ||
|
|
84
|
+
className.includes('post') ||
|
|
85
|
+
className.includes('entry') ||
|
|
86
|
+
id.includes('content')) {
|
|
87
|
+
return 'content';
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
return 'content'; // Default to content
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Determine if link is internal, external, or anchor
|
|
96
|
+
*/
|
|
97
|
+
function getLinkDestination(href: string, baseUrl: string): InternalLink['destination'] {
|
|
98
|
+
if (!href || href.startsWith('#')) {
|
|
99
|
+
return 'anchor';
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
try {
|
|
103
|
+
const linkUrl = new URL(href, baseUrl);
|
|
104
|
+
const currentUrl = new URL(baseUrl);
|
|
105
|
+
|
|
106
|
+
if (linkUrl.hostname === currentUrl.hostname) {
|
|
107
|
+
return 'internal';
|
|
108
|
+
}
|
|
109
|
+
return 'external';
|
|
110
|
+
} catch {
|
|
111
|
+
// Relative URL - internal
|
|
112
|
+
if (href.startsWith('/') || href.startsWith('./') || href.startsWith('../')) {
|
|
113
|
+
return 'internal';
|
|
114
|
+
}
|
|
115
|
+
return 'external';
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Check if link is dofollow (no rel="nofollow")
|
|
121
|
+
*/
|
|
122
|
+
function isDoFollow(rel: string | undefined): boolean {
|
|
123
|
+
if (!rel) return true;
|
|
124
|
+
const relValues = rel.toLowerCase().split(/\s+/);
|
|
125
|
+
return !relValues.includes('nofollow') && !relValues.includes('sponsored') && !relValues.includes('ugc');
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Extract all links from the page
|
|
130
|
+
*/
|
|
131
|
+
export function extractLinks(html: string, url: string): {
|
|
132
|
+
internalLinks: InternalLink[];
|
|
133
|
+
externalLinks: InternalLink[];
|
|
134
|
+
anchorLinks: InternalLink[];
|
|
135
|
+
} {
|
|
136
|
+
const $ = cheerio.load(html);
|
|
137
|
+
const internalLinks: InternalLink[] = [];
|
|
138
|
+
const externalLinks: InternalLink[] = [];
|
|
139
|
+
const anchorLinks: InternalLink[] = [];
|
|
140
|
+
|
|
141
|
+
$('a[href]').each((_, element) => {
|
|
142
|
+
const $link = $(element);
|
|
143
|
+
const href = $link.attr('href') || '';
|
|
144
|
+
const anchorText = $link.text().trim();
|
|
145
|
+
const rel = $link.attr('rel');
|
|
146
|
+
|
|
147
|
+
const destination = getLinkDestination(href, url);
|
|
148
|
+
const context = getLinkContext($, $link);
|
|
149
|
+
const doFollow = isDoFollow(rel);
|
|
150
|
+
|
|
151
|
+
const link: InternalLink = {
|
|
152
|
+
href: href,
|
|
153
|
+
anchorText: anchorText,
|
|
154
|
+
context,
|
|
155
|
+
isDoFollow: doFollow,
|
|
156
|
+
destination,
|
|
157
|
+
};
|
|
158
|
+
|
|
159
|
+
if (destination === 'internal') {
|
|
160
|
+
internalLinks.push(link);
|
|
161
|
+
} else if (destination === 'external') {
|
|
162
|
+
externalLinks.push(link);
|
|
163
|
+
} else {
|
|
164
|
+
anchorLinks.push(link);
|
|
165
|
+
}
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
return { internalLinks, externalLinks, anchorLinks };
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* Detect cluster-related patterns
|
|
173
|
+
*/
|
|
174
|
+
export function detectClusterSignals($: cheerio.CheerioAPI): TopicalClusterData['clusterSignals'] {
|
|
175
|
+
// Breadcrumbs detection
|
|
176
|
+
const hasBreadcrumbs = $(
|
|
177
|
+
'[class*="breadcrumb"], [id*="breadcrumb"], nav[aria-label*="breadcrumb"], ' +
|
|
178
|
+
'[itemtype*="BreadcrumbList"], .breadcrumbs'
|
|
179
|
+
).length > 0;
|
|
180
|
+
|
|
181
|
+
// Related posts section
|
|
182
|
+
const hasRelatedPosts = $(
|
|
183
|
+
'[class*="related"], [class*="similar"], [class*="recommended"], ' +
|
|
184
|
+
'[id*="related"], h2:contains("Related"), h3:contains("Related"), ' +
|
|
185
|
+
'h2:contains("You might also"), h2:contains("Similar")'
|
|
186
|
+
).length > 0;
|
|
187
|
+
|
|
188
|
+
// Category/tag links
|
|
189
|
+
const hasCategoryLinks = $(
|
|
190
|
+
'[class*="categor"], [class*="tag-"], [rel="tag"], ' +
|
|
191
|
+
'a[href*="/category/"], a[href*="/tag/"], a[href*="/topic/"]'
|
|
192
|
+
).length > 0;
|
|
193
|
+
|
|
194
|
+
return {
|
|
195
|
+
hasHubStructure: false, // Calculated later
|
|
196
|
+
hasSpokeLinks: false, // Calculated later
|
|
197
|
+
hasBreadcrumbs,
|
|
198
|
+
hasRelatedPosts,
|
|
199
|
+
hasCategoryLinks,
|
|
200
|
+
internalToExternalRatio: 0, // Calculated later
|
|
201
|
+
};
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/**
|
|
205
|
+
* Calculate orphan page risk based on internal linking
|
|
206
|
+
*/
|
|
207
|
+
function calculateOrphanRisk(
|
|
208
|
+
contentLinks: number,
|
|
209
|
+
uniqueDestinations: number,
|
|
210
|
+
clusterSignals: TopicalClusterData['clusterSignals']
|
|
211
|
+
): TopicalClusterData['orphanPageRisk'] {
|
|
212
|
+
let score = 0;
|
|
213
|
+
|
|
214
|
+
// Content links to other pages
|
|
215
|
+
if (contentLinks >= 5) score += 3;
|
|
216
|
+
else if (contentLinks >= 2) score += 2;
|
|
217
|
+
else if (contentLinks >= 1) score += 1;
|
|
218
|
+
|
|
219
|
+
// Unique destinations (not just linking to homepage)
|
|
220
|
+
if (uniqueDestinations >= 5) score += 2;
|
|
221
|
+
else if (uniqueDestinations >= 2) score += 1;
|
|
222
|
+
|
|
223
|
+
// Cluster signals
|
|
224
|
+
if (clusterSignals.hasBreadcrumbs) score += 1;
|
|
225
|
+
if (clusterSignals.hasRelatedPosts) score += 2;
|
|
226
|
+
if (clusterSignals.hasCategoryLinks) score += 1;
|
|
227
|
+
|
|
228
|
+
if (score >= 6) return 'low';
|
|
229
|
+
if (score >= 3) return 'medium';
|
|
230
|
+
return 'high';
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/**
|
|
234
|
+
* Main function: Analyze topical cluster structure
|
|
235
|
+
*/
|
|
236
|
+
export function analyzeTopicalClusters(
|
|
237
|
+
html: string,
|
|
238
|
+
url: string
|
|
239
|
+
): { issues: AuditIssue[]; data: TopicalClusterData } {
|
|
240
|
+
const $ = cheerio.load(html);
|
|
241
|
+
const issues: AuditIssue[] = [];
|
|
242
|
+
|
|
243
|
+
// Extract all links
|
|
244
|
+
const { internalLinks, externalLinks, anchorLinks } = extractLinks(html, url);
|
|
245
|
+
|
|
246
|
+
// Content-only links (not in nav/footer)
|
|
247
|
+
const contentInternalLinks = internalLinks.filter(l => l.context === 'content');
|
|
248
|
+
const contentExternalLinks = externalLinks.filter(l => l.context === 'content');
|
|
249
|
+
|
|
250
|
+
// Calculate unique internal destinations
|
|
251
|
+
const uniqueInternalHrefs = new Set(
|
|
252
|
+
internalLinks.map(l => {
|
|
253
|
+
try {
|
|
254
|
+
return new URL(l.href, url).pathname;
|
|
255
|
+
} catch {
|
|
256
|
+
return l.href;
|
|
257
|
+
}
|
|
258
|
+
})
|
|
259
|
+
);
|
|
260
|
+
|
|
261
|
+
// Calculate average anchor text length
|
|
262
|
+
const anchorTexts = internalLinks.filter(l => l.anchorText.length > 0);
|
|
263
|
+
const avgAnchorTextLength = anchorTexts.length > 0
|
|
264
|
+
? anchorTexts.reduce((sum, l) => sum + l.anchorText.length, 0) / anchorTexts.length
|
|
265
|
+
: 0;
|
|
266
|
+
|
|
267
|
+
// Check for exact match anchor text patterns (over-optimization)
|
|
268
|
+
const hasExactMatchAnchors = internalLinks.some(l => {
|
|
269
|
+
const text = l.anchorText.toLowerCase();
|
|
270
|
+
return text.split(' ').length >= 3 && l.context === 'content';
|
|
271
|
+
});
|
|
272
|
+
|
|
273
|
+
// Detect cluster signals
|
|
274
|
+
const clusterSignals = detectClusterSignals($);
|
|
275
|
+
|
|
276
|
+
// Calculate internal to external ratio
|
|
277
|
+
const totalContentLinks = contentInternalLinks.length + contentExternalLinks.length;
|
|
278
|
+
clusterSignals.internalToExternalRatio = totalContentLinks > 0
|
|
279
|
+
? contentInternalLinks.length / totalContentLinks
|
|
280
|
+
: 1;
|
|
281
|
+
|
|
282
|
+
// Hub structure: many internal links from content
|
|
283
|
+
clusterSignals.hasHubStructure = contentInternalLinks.length >= 5;
|
|
284
|
+
|
|
285
|
+
// Spoke links: has related posts or category links
|
|
286
|
+
clusterSignals.hasSpokeLinks = clusterSignals.hasRelatedPosts || clusterSignals.hasCategoryLinks;
|
|
287
|
+
|
|
288
|
+
// Calculate orphan risk
|
|
289
|
+
const orphanPageRisk = calculateOrphanRisk(
|
|
290
|
+
contentInternalLinks.length,
|
|
291
|
+
uniqueInternalHrefs.size,
|
|
292
|
+
clusterSignals
|
|
293
|
+
);
|
|
294
|
+
|
|
295
|
+
// Generate recommendations
|
|
296
|
+
const recommendations: string[] = [];
|
|
297
|
+
|
|
298
|
+
if (contentInternalLinks.length < 3) {
|
|
299
|
+
recommendations.push('Add more contextual internal links within your content');
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
if (!clusterSignals.hasBreadcrumbs) {
|
|
303
|
+
recommendations.push('Add breadcrumb navigation for better site structure');
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
if (!clusterSignals.hasRelatedPosts) {
|
|
307
|
+
recommendations.push('Add a "Related Posts" or "Similar Articles" section');
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
if (!clusterSignals.hasCategoryLinks) {
|
|
311
|
+
recommendations.push('Link to category or topic pages to strengthen clusters');
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
if (clusterSignals.internalToExternalRatio < 0.5) {
|
|
315
|
+
recommendations.push('Balance your link profile - add more internal links');
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
// Generate issues
|
|
319
|
+
|
|
320
|
+
// No internal links in content
|
|
321
|
+
if (contentInternalLinks.length === 0) {
|
|
322
|
+
issues.push({
|
|
323
|
+
code: 'NO_CONTENT_INTERNAL_LINKS',
|
|
324
|
+
severity: 'warning',
|
|
325
|
+
category: 'on-page',
|
|
326
|
+
title: 'No internal links in page content',
|
|
327
|
+
description: 'The main content area has no links to other pages on your site.',
|
|
328
|
+
impact: 'Internal links help search engines discover pages and understand site structure. They also pass PageRank.',
|
|
329
|
+
howToFix: 'Add 3-5 contextual internal links within your content to related pages.',
|
|
330
|
+
affectedUrls: [url],
|
|
331
|
+
});
|
|
332
|
+
} else if (contentInternalLinks.length < 3) {
|
|
333
|
+
issues.push({
|
|
334
|
+
code: 'FEW_CONTENT_INTERNAL_LINKS',
|
|
335
|
+
severity: 'notice',
|
|
336
|
+
category: 'on-page',
|
|
337
|
+
title: 'Few internal links in content',
|
|
338
|
+
description: `Only ${contentInternalLinks.length} internal link(s) in the main content area.`,
|
|
339
|
+
impact: 'More contextual internal links strengthen topical clusters and help users discover content.',
|
|
340
|
+
howToFix: 'Add relevant internal links where they naturally fit in your content.',
|
|
341
|
+
affectedUrls: [url],
|
|
342
|
+
details: {
|
|
343
|
+
currentCount: contentInternalLinks.length,
|
|
344
|
+
recommended: '3-5 minimum',
|
|
345
|
+
},
|
|
346
|
+
});
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
// No breadcrumbs
|
|
350
|
+
if (!clusterSignals.hasBreadcrumbs) {
|
|
351
|
+
issues.push({
|
|
352
|
+
code: 'NO_BREADCRUMBS',
|
|
353
|
+
severity: 'notice',
|
|
354
|
+
category: 'on-page',
|
|
355
|
+
title: 'No breadcrumb navigation detected',
|
|
356
|
+
description: 'Page lacks breadcrumb navigation for hierarchical context.',
|
|
357
|
+
impact: 'Breadcrumbs improve UX, help search engines understand site structure, and can appear in SERPs.',
|
|
358
|
+
howToFix: 'Add breadcrumb navigation with Schema.org BreadcrumbList markup.',
|
|
359
|
+
affectedUrls: [url],
|
|
360
|
+
});
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
// No related content section
|
|
364
|
+
if (!clusterSignals.hasRelatedPosts && !clusterSignals.hasCategoryLinks) {
|
|
365
|
+
issues.push({
|
|
366
|
+
code: 'NO_RELATED_CONTENT',
|
|
367
|
+
severity: 'notice',
|
|
368
|
+
category: 'on-page',
|
|
369
|
+
title: 'No related content section',
|
|
370
|
+
description: 'Page has no "Related Posts" or similar content discovery section.',
|
|
371
|
+
impact: 'Related content sections reduce bounce rate, increase pageviews, and strengthen topic clusters.',
|
|
372
|
+
howToFix: 'Add a section linking to related articles, products, or category pages.',
|
|
373
|
+
affectedUrls: [url],
|
|
374
|
+
});
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
// High orphan page risk
|
|
378
|
+
if (orphanPageRisk === 'high') {
|
|
379
|
+
issues.push({
|
|
380
|
+
code: 'HIGH_ORPHAN_RISK',
|
|
381
|
+
severity: 'warning',
|
|
382
|
+
category: 'on-page',
|
|
383
|
+
title: 'Page may be an orphan page',
|
|
384
|
+
description: 'This page has very few internal links, making it hard to discover.',
|
|
385
|
+
impact: 'Orphan pages are difficult for search engines to find and may not get indexed.',
|
|
386
|
+
howToFix: 'Ensure this page is linked from navigation, category pages, or related content.',
|
|
387
|
+
affectedUrls: [url],
|
|
388
|
+
details: {
|
|
389
|
+
contentLinks: contentInternalLinks.length,
|
|
390
|
+
uniqueDestinations: uniqueInternalHrefs.size,
|
|
391
|
+
},
|
|
392
|
+
});
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
// Too many external links relative to internal
|
|
396
|
+
if (contentExternalLinks.length > contentInternalLinks.length * 2 && contentExternalLinks.length >= 5) {
|
|
397
|
+
issues.push({
|
|
398
|
+
code: 'EXTERNAL_LINK_HEAVY',
|
|
399
|
+
severity: 'notice',
|
|
400
|
+
category: 'on-page',
|
|
401
|
+
title: 'More external links than internal links',
|
|
402
|
+
description: `${contentExternalLinks.length} external links vs ${contentInternalLinks.length} internal links in content.`,
|
|
403
|
+
impact: 'While citing sources is good, heavy external linking may dilute PageRank passed to your own pages.',
|
|
404
|
+
howToFix: 'Balance external citations with internal links to your own related content.',
|
|
405
|
+
affectedUrls: [url],
|
|
406
|
+
details: {
|
|
407
|
+
externalLinks: contentExternalLinks.length,
|
|
408
|
+
internalLinks: contentInternalLinks.length,
|
|
409
|
+
ratio: clusterSignals.internalToExternalRatio.toFixed(2),
|
|
410
|
+
},
|
|
411
|
+
});
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
return {
|
|
415
|
+
issues,
|
|
416
|
+
data: {
|
|
417
|
+
currentUrl: url,
|
|
418
|
+
internalLinks,
|
|
419
|
+
externalLinks,
|
|
420
|
+
anchorLinks,
|
|
421
|
+
linkMetrics: {
|
|
422
|
+
totalLinks: internalLinks.length + externalLinks.length + anchorLinks.length,
|
|
423
|
+
internalLinkCount: internalLinks.length,
|
|
424
|
+
externalLinkCount: externalLinks.length,
|
|
425
|
+
contentLinks: contentInternalLinks.length,
|
|
426
|
+
uniqueInternalDestinations: uniqueInternalHrefs.size,
|
|
427
|
+
avgAnchorTextLength: Math.round(avgAnchorTextLength),
|
|
428
|
+
hasExactMatchAnchors,
|
|
429
|
+
},
|
|
430
|
+
clusterSignals,
|
|
431
|
+
orphanPageRisk,
|
|
432
|
+
recommendations,
|
|
433
|
+
},
|
|
434
|
+
};
|
|
435
|
+
}
|