@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.
Files changed (178) hide show
  1. package/README.md +242 -0
  2. package/dist/analyzer-2CSWIQGD.mjs +6 -0
  3. package/dist/chunk-YNZYHEYM.mjs +774 -0
  4. package/dist/index.d.mts +4012 -0
  5. package/dist/index.d.ts +4012 -0
  6. package/dist/index.js +29672 -0
  7. package/dist/index.mjs +28602 -0
  8. package/package.json +53 -0
  9. package/scripts/build-deno.ts +134 -0
  10. package/src/audit/ai/analyzer.ts +347 -0
  11. package/src/audit/ai/index.ts +29 -0
  12. package/src/audit/ai/prompts/content-analysis.ts +271 -0
  13. package/src/audit/ai/types.ts +179 -0
  14. package/src/audit/checks/additional-checks.ts +439 -0
  15. package/src/audit/checks/ai-citation-worthiness.ts +399 -0
  16. package/src/audit/checks/ai-content-structure.ts +325 -0
  17. package/src/audit/checks/ai-readiness.ts +339 -0
  18. package/src/audit/checks/anchor-text.ts +179 -0
  19. package/src/audit/checks/answer-conciseness.ts +322 -0
  20. package/src/audit/checks/asset-minification.ts +270 -0
  21. package/src/audit/checks/bing-optimization.ts +206 -0
  22. package/src/audit/checks/brand-mention-optimization.ts +349 -0
  23. package/src/audit/checks/caching-headers.ts +305 -0
  24. package/src/audit/checks/canonical-advanced.ts +150 -0
  25. package/src/audit/checks/canonical-domain.ts +196 -0
  26. package/src/audit/checks/citation-quality.ts +358 -0
  27. package/src/audit/checks/client-rendering.ts +542 -0
  28. package/src/audit/checks/color-contrast.ts +342 -0
  29. package/src/audit/checks/content-freshness.ts +170 -0
  30. package/src/audit/checks/content-science.ts +589 -0
  31. package/src/audit/checks/conversion-elements.ts +526 -0
  32. package/src/audit/checks/crawlability.ts +220 -0
  33. package/src/audit/checks/directory-listing.ts +172 -0
  34. package/src/audit/checks/dom-analysis.ts +191 -0
  35. package/src/audit/checks/dom-size.ts +246 -0
  36. package/src/audit/checks/duplicate-content.ts +194 -0
  37. package/src/audit/checks/eeat-signals.ts +990 -0
  38. package/src/audit/checks/entity-seo.ts +396 -0
  39. package/src/audit/checks/featured-snippet.ts +473 -0
  40. package/src/audit/checks/freshness-signals.ts +443 -0
  41. package/src/audit/checks/funnel-intent.ts +463 -0
  42. package/src/audit/checks/hreflang.ts +174 -0
  43. package/src/audit/checks/html-compliance.ts +302 -0
  44. package/src/audit/checks/image-dimensions.ts +167 -0
  45. package/src/audit/checks/images.ts +160 -0
  46. package/src/audit/checks/indexnow.ts +275 -0
  47. package/src/audit/checks/interactive-tools.ts +475 -0
  48. package/src/audit/checks/internal-link-graph.ts +436 -0
  49. package/src/audit/checks/keyword-analysis.ts +239 -0
  50. package/src/audit/checks/keyword-cannibalization.ts +385 -0
  51. package/src/audit/checks/keyword-placement.ts +471 -0
  52. package/src/audit/checks/links.ts +203 -0
  53. package/src/audit/checks/llms-txt.ts +224 -0
  54. package/src/audit/checks/local-seo.ts +296 -0
  55. package/src/audit/checks/mobile.ts +167 -0
  56. package/src/audit/checks/modern-images.ts +226 -0
  57. package/src/audit/checks/navboost-signals.ts +395 -0
  58. package/src/audit/checks/on-page.ts +209 -0
  59. package/src/audit/checks/page-resources.ts +285 -0
  60. package/src/audit/checks/pagination.ts +180 -0
  61. package/src/audit/checks/performance.ts +153 -0
  62. package/src/audit/checks/platform-presence.ts +580 -0
  63. package/src/audit/checks/redirect-analysis.ts +153 -0
  64. package/src/audit/checks/redirect-chain.ts +389 -0
  65. package/src/audit/checks/resource-hints.ts +420 -0
  66. package/src/audit/checks/responsive-css.ts +247 -0
  67. package/src/audit/checks/responsive-images.ts +396 -0
  68. package/src/audit/checks/review-ecosystem.ts +415 -0
  69. package/src/audit/checks/robots-validation.ts +373 -0
  70. package/src/audit/checks/security-headers.ts +172 -0
  71. package/src/audit/checks/security.ts +144 -0
  72. package/src/audit/checks/serp-preview.ts +251 -0
  73. package/src/audit/checks/site-maturity.ts +444 -0
  74. package/src/audit/checks/social-meta.test.ts +275 -0
  75. package/src/audit/checks/social-meta.ts +134 -0
  76. package/src/audit/checks/soft-404.ts +151 -0
  77. package/src/audit/checks/structured-data.ts +238 -0
  78. package/src/audit/checks/tech-detection.ts +496 -0
  79. package/src/audit/checks/topical-clusters.ts +435 -0
  80. package/src/audit/checks/tracker-bloat.ts +462 -0
  81. package/src/audit/checks/tracking-verification.test.ts +371 -0
  82. package/src/audit/checks/tracking-verification.ts +636 -0
  83. package/src/audit/checks/url-safety.ts +682 -0
  84. package/src/audit/deno-entry.ts +66 -0
  85. package/src/audit/discovery/index.ts +15 -0
  86. package/src/audit/discovery/link-crawler.ts +232 -0
  87. package/src/audit/discovery/repo-routes.ts +347 -0
  88. package/src/audit/engine.ts +620 -0
  89. package/src/audit/fixes/index.ts +209 -0
  90. package/src/audit/fixes/social-meta-fixes.test.ts +329 -0
  91. package/src/audit/fixes/social-meta-fixes.ts +463 -0
  92. package/src/audit/index.ts +74 -0
  93. package/src/audit/runner.test.ts +299 -0
  94. package/src/audit/runner.ts +130 -0
  95. package/src/audit/types.ts +1953 -0
  96. package/src/content/featured-snippet.ts +367 -0
  97. package/src/content/generator.test.ts +534 -0
  98. package/src/content/generator.ts +501 -0
  99. package/src/content/headline.ts +317 -0
  100. package/src/content/index.ts +62 -0
  101. package/src/content/intent.ts +258 -0
  102. package/src/content/keyword-density.ts +349 -0
  103. package/src/content/readability.ts +262 -0
  104. package/src/executor.ts +336 -0
  105. package/src/fixer.ts +416 -0
  106. package/src/frameworks/detector.test.ts +248 -0
  107. package/src/frameworks/detector.ts +371 -0
  108. package/src/frameworks/index.ts +68 -0
  109. package/src/frameworks/recipes/angular.yaml +171 -0
  110. package/src/frameworks/recipes/astro.yaml +206 -0
  111. package/src/frameworks/recipes/django.yaml +180 -0
  112. package/src/frameworks/recipes/laravel.yaml +137 -0
  113. package/src/frameworks/recipes/nextjs.yaml +268 -0
  114. package/src/frameworks/recipes/nuxt.yaml +175 -0
  115. package/src/frameworks/recipes/rails.yaml +188 -0
  116. package/src/frameworks/recipes/react.yaml +202 -0
  117. package/src/frameworks/recipes/sveltekit.yaml +154 -0
  118. package/src/frameworks/recipes/vue.yaml +137 -0
  119. package/src/frameworks/recipes/wordpress.yaml +209 -0
  120. package/src/frameworks/suggestion-engine.ts +320 -0
  121. package/src/geo/geo-content.test.ts +305 -0
  122. package/src/geo/geo-content.ts +266 -0
  123. package/src/geo/geo-history.test.ts +473 -0
  124. package/src/geo/geo-history.ts +433 -0
  125. package/src/geo/geo-tracker.test.ts +359 -0
  126. package/src/geo/geo-tracker.ts +411 -0
  127. package/src/geo/index.ts +10 -0
  128. package/src/git/commit-helper.test.ts +261 -0
  129. package/src/git/commit-helper.ts +329 -0
  130. package/src/git/index.ts +12 -0
  131. package/src/git/pr-helper.test.ts +284 -0
  132. package/src/git/pr-helper.ts +307 -0
  133. package/src/index.ts +66 -0
  134. package/src/keywords/ai-keyword-engine.ts +1062 -0
  135. package/src/keywords/ai-summarizer.ts +387 -0
  136. package/src/keywords/ci-mode.ts +555 -0
  137. package/src/keywords/engine.ts +359 -0
  138. package/src/keywords/index.ts +151 -0
  139. package/src/keywords/llm-judge.ts +357 -0
  140. package/src/keywords/nlp-analysis.ts +706 -0
  141. package/src/keywords/prioritizer.ts +295 -0
  142. package/src/keywords/site-crawler.ts +342 -0
  143. package/src/keywords/sources/autocomplete.ts +139 -0
  144. package/src/keywords/sources/competitive-search.ts +450 -0
  145. package/src/keywords/sources/competitor-analysis.ts +374 -0
  146. package/src/keywords/sources/dataforseo.ts +206 -0
  147. package/src/keywords/sources/free-sources.ts +294 -0
  148. package/src/keywords/sources/gsc.ts +123 -0
  149. package/src/keywords/topic-grouping.ts +327 -0
  150. package/src/keywords/types.ts +144 -0
  151. package/src/keywords/wizard.ts +457 -0
  152. package/src/loader.ts +40 -0
  153. package/src/reports/index.ts +7 -0
  154. package/src/reports/report-generator.test.ts +293 -0
  155. package/src/reports/report-generator.ts +713 -0
  156. package/src/scheduler/alerts.test.ts +458 -0
  157. package/src/scheduler/alerts.ts +328 -0
  158. package/src/scheduler/index.ts +8 -0
  159. package/src/scheduler/scheduled-audit.test.ts +377 -0
  160. package/src/scheduler/scheduled-audit.ts +149 -0
  161. package/src/test/integration-test.ts +325 -0
  162. package/src/tools/analyzer.ts +373 -0
  163. package/src/tools/crawl.ts +293 -0
  164. package/src/tools/files.ts +301 -0
  165. package/src/tools/h1-fixer.ts +249 -0
  166. package/src/tools/index.ts +67 -0
  167. package/src/tracking/github-action.ts +326 -0
  168. package/src/tracking/google-analytics.ts +265 -0
  169. package/src/tracking/index.ts +45 -0
  170. package/src/tracking/report-generator.ts +386 -0
  171. package/src/tracking/search-console.ts +335 -0
  172. package/src/types.ts +134 -0
  173. package/src/utils/http.ts +302 -0
  174. package/src/wasm-adapter.ts +297 -0
  175. package/src/wasm-entry.ts +14 -0
  176. package/tsconfig.json +17 -0
  177. package/tsup.wasm.config.ts +26 -0
  178. 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
+ }