@rankcli/agent-runtime 0.0.15 → 0.0.16
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/dist/index.d.mts +43 -393
- package/dist/index.d.ts +43 -393
- package/dist/index.js +306 -6526
- package/dist/index.mjs +202 -6412
- package/package.json +6 -1
- package/scripts/build-deno.ts +0 -134
- package/src/analyzers/core-web-vitals-analyzer.test.ts +0 -236
- package/src/analyzers/core-web-vitals-analyzer.ts +0 -557
- package/src/analyzers/geo-analyzer.test.ts +0 -310
- package/src/analyzers/geo-analyzer.ts +0 -814
- package/src/analyzers/image-optimization-analyzer.test.ts +0 -145
- package/src/analyzers/image-optimization-analyzer.ts +0 -348
- package/src/analyzers/index.ts +0 -233
- package/src/analyzers/internal-linking-analyzer.test.ts +0 -141
- package/src/analyzers/internal-linking-analyzer.ts +0 -419
- package/src/analyzers/mobile-seo-analyzer.test.ts +0 -140
- package/src/analyzers/mobile-seo-analyzer.ts +0 -455
- package/src/analyzers/security-headers-analyzer.test.ts +0 -115
- package/src/analyzers/security-headers-analyzer.ts +0 -318
- package/src/analyzers/structured-data-analyzer.test.ts +0 -210
- package/src/analyzers/structured-data-analyzer.ts +0 -590
- package/src/audit/ai/analyzer.ts +0 -347
- package/src/audit/ai/index.ts +0 -29
- package/src/audit/ai/prompts/content-analysis.ts +0 -271
- package/src/audit/ai/types.ts +0 -179
- package/src/audit/checks/additional-checks.ts +0 -439
- package/src/audit/checks/agent-experience.ts +0 -108
- package/src/audit/checks/ai-citation-worthiness.ts +0 -399
- package/src/audit/checks/ai-content-structure.ts +0 -325
- package/src/audit/checks/ai-readiness.ts +0 -406
- package/src/audit/checks/anchor-text.ts +0 -179
- package/src/audit/checks/answer-conciseness.ts +0 -322
- package/src/audit/checks/asset-minification.ts +0 -270
- package/src/audit/checks/bing-optimization.ts +0 -206
- package/src/audit/checks/brand-mention-optimization.ts +0 -349
- package/src/audit/checks/caching-headers.ts +0 -305
- package/src/audit/checks/canonical-advanced.ts +0 -150
- package/src/audit/checks/canonical-domain.ts +0 -196
- package/src/audit/checks/citation-quality.ts +0 -358
- package/src/audit/checks/client-rendering.ts +0 -542
- package/src/audit/checks/color-contrast.ts +0 -342
- package/src/audit/checks/content-freshness.ts +0 -170
- package/src/audit/checks/content-science.ts +0 -589
- package/src/audit/checks/conversion-elements.ts +0 -526
- package/src/audit/checks/crawlability.ts +0 -220
- package/src/audit/checks/directory-listing.ts +0 -172
- package/src/audit/checks/dom-analysis.ts +0 -191
- package/src/audit/checks/dom-size.ts +0 -246
- package/src/audit/checks/duplicate-content.ts +0 -194
- package/src/audit/checks/eeat-signals.ts +0 -990
- package/src/audit/checks/entity-seo.ts +0 -396
- package/src/audit/checks/featured-snippet.ts +0 -473
- package/src/audit/checks/freshness-signals.ts +0 -443
- package/src/audit/checks/funnel-intent.ts +0 -463
- package/src/audit/checks/hreflang.ts +0 -174
- package/src/audit/checks/html-compliance.ts +0 -302
- package/src/audit/checks/image-dimensions.ts +0 -167
- package/src/audit/checks/images.ts +0 -160
- package/src/audit/checks/indexnow.ts +0 -275
- package/src/audit/checks/interactive-tools.ts +0 -475
- package/src/audit/checks/internal-link-graph.ts +0 -436
- package/src/audit/checks/keyword-analysis.ts +0 -239
- package/src/audit/checks/keyword-cannibalization.ts +0 -385
- package/src/audit/checks/keyword-placement.ts +0 -471
- package/src/audit/checks/links.ts +0 -273
- package/src/audit/checks/llms-txt.ts +0 -224
- package/src/audit/checks/local-seo.ts +0 -296
- package/src/audit/checks/mobile.ts +0 -167
- package/src/audit/checks/modern-images.ts +0 -226
- package/src/audit/checks/navboost-signals.ts +0 -395
- package/src/audit/checks/on-page.ts +0 -209
- package/src/audit/checks/page-resources.ts +0 -285
- package/src/audit/checks/pagination.ts +0 -180
- package/src/audit/checks/performance.ts +0 -153
- package/src/audit/checks/platform-presence.ts +0 -580
- package/src/audit/checks/rag-chunk-readiness.test.ts +0 -159
- package/src/audit/checks/rag-chunk-readiness.ts +0 -163
- package/src/audit/checks/redirect-analysis.ts +0 -153
- package/src/audit/checks/redirect-chain.ts +0 -389
- package/src/audit/checks/resource-hints.ts +0 -420
- package/src/audit/checks/responsive-css.ts +0 -247
- package/src/audit/checks/responsive-images.ts +0 -396
- package/src/audit/checks/review-ecosystem.ts +0 -415
- package/src/audit/checks/robots-validation.ts +0 -373
- package/src/audit/checks/security-headers.ts +0 -188
- package/src/audit/checks/security.ts +0 -144
- package/src/audit/checks/serp-preview.ts +0 -251
- package/src/audit/checks/site-maturity.ts +0 -453
- package/src/audit/checks/social-meta.test.ts +0 -275
- package/src/audit/checks/social-meta.ts +0 -134
- package/src/audit/checks/soft-404.ts +0 -151
- package/src/audit/checks/structured-data.ts +0 -238
- package/src/audit/checks/tech-detection.ts +0 -496
- package/src/audit/checks/topical-clusters.ts +0 -435
- package/src/audit/checks/tracker-bloat.ts +0 -462
- package/src/audit/checks/tracking-verification.test.ts +0 -371
- package/src/audit/checks/tracking-verification.ts +0 -636
- package/src/audit/checks/url-safety.ts +0 -682
- package/src/audit/deno-entry.ts +0 -66
- package/src/audit/discovery/index.ts +0 -15
- package/src/audit/discovery/link-crawler.ts +0 -233
- package/src/audit/discovery/repo-routes.ts +0 -347
- package/src/audit/engine.ts +0 -633
- package/src/audit/fixes/index.ts +0 -209
- package/src/audit/fixes/social-meta-fixes.test.ts +0 -329
- package/src/audit/fixes/social-meta-fixes.ts +0 -463
- package/src/audit/index.ts +0 -74
- package/src/audit/runner.test.ts +0 -299
- package/src/audit/runner.ts +0 -130
- package/src/audit/types.ts +0 -1987
- package/src/content/featured-snippet.ts +0 -367
- package/src/content/generator.test.ts +0 -534
- package/src/content/generator.ts +0 -501
- package/src/content/headline.ts +0 -317
- package/src/content/index.ts +0 -62
- package/src/content/intent.ts +0 -258
- package/src/content/keyword-density.ts +0 -349
- package/src/content/readability.ts +0 -262
- package/src/executor.ts +0 -336
- package/src/fixer/framework-fixes.test.ts +0 -489
- package/src/fixer/framework-fixes.ts +0 -5287
- package/src/fixer/index.ts +0 -6
- package/src/fixer/schemas.ts +0 -971
- package/src/fixer.ts +0 -642
- package/src/frameworks/detector.test.ts +0 -248
- package/src/frameworks/detector.ts +0 -899
- package/src/frameworks/index.ts +0 -68
- package/src/frameworks/recipes/angular.yaml +0 -171
- package/src/frameworks/recipes/astro.yaml +0 -206
- package/src/frameworks/recipes/django.yaml +0 -180
- package/src/frameworks/recipes/laravel.yaml +0 -137
- package/src/frameworks/recipes/nextjs.yaml +0 -268
- package/src/frameworks/recipes/nuxt.yaml +0 -175
- package/src/frameworks/recipes/rails.yaml +0 -188
- package/src/frameworks/recipes/react.yaml +0 -202
- package/src/frameworks/recipes/sveltekit.yaml +0 -154
- package/src/frameworks/recipes/vue.yaml +0 -137
- package/src/frameworks/recipes/wordpress.yaml +0 -209
- package/src/frameworks/suggestion-engine.ts +0 -357
- package/src/geo/geo-content.test.ts +0 -305
- package/src/geo/geo-content.ts +0 -266
- package/src/geo/geo-history.test.ts +0 -473
- package/src/geo/geo-history.ts +0 -433
- package/src/geo/geo-tracker.test.ts +0 -359
- package/src/geo/geo-tracker.ts +0 -411
- package/src/geo/index.ts +0 -11
- package/src/geo/llm-citation-checker.ts +0 -188
- package/src/git/commit-helper.test.ts +0 -261
- package/src/git/commit-helper.ts +0 -329
- package/src/git/index.ts +0 -12
- package/src/git/pr-helper.test.ts +0 -284
- package/src/git/pr-helper.ts +0 -307
- package/src/index.ts +0 -76
- package/src/keywords/ai-keyword-engine.ts +0 -1062
- package/src/keywords/ai-summarizer.ts +0 -387
- package/src/keywords/ci-mode.ts +0 -555
- package/src/keywords/engine.ts +0 -359
- package/src/keywords/index.ts +0 -151
- package/src/keywords/llm-judge.ts +0 -357
- package/src/keywords/nlp-analysis.ts +0 -706
- package/src/keywords/prioritizer.ts +0 -295
- package/src/keywords/site-crawler.ts +0 -342
- package/src/keywords/sources/autocomplete.ts +0 -139
- package/src/keywords/sources/competitive-search.ts +0 -450
- package/src/keywords/sources/competitor-analysis.ts +0 -374
- package/src/keywords/sources/dataforseo.ts +0 -206
- package/src/keywords/sources/free-sources.ts +0 -294
- package/src/keywords/sources/gsc.ts +0 -123
- package/src/keywords/topic-grouping.ts +0 -327
- package/src/keywords/types.ts +0 -144
- package/src/keywords/wizard.ts +0 -457
- package/src/loader.ts +0 -40
- package/src/ranking/index.ts +0 -5
- package/src/ranking/serp-client.ts +0 -348
- package/src/ranking/tracker.ts +0 -380
- package/src/ranking/types.ts +0 -123
- package/src/reports/index.ts +0 -7
- package/src/reports/report-generator.test.ts +0 -293
- package/src/reports/report-generator.ts +0 -713
- package/src/scheduler/alerts.test.ts +0 -458
- package/src/scheduler/alerts.ts +0 -328
- package/src/scheduler/index.ts +0 -8
- package/src/scheduler/scheduled-audit.test.ts +0 -377
- package/src/scheduler/scheduled-audit.ts +0 -149
- package/src/test/integration-test.ts +0 -325
- package/src/tools/analyzer.ts +0 -373
- package/src/tools/crawl.ts +0 -293
- package/src/tools/files.ts +0 -301
- package/src/tools/h1-fixer.ts +0 -249
- package/src/tools/index.ts +0 -67
- package/src/tracking/github-action.ts +0 -326
- package/src/tracking/google-analytics.ts +0 -265
- package/src/tracking/index.ts +0 -45
- package/src/tracking/report-generator.ts +0 -386
- package/src/tracking/search-console.ts +0 -335
- package/src/types.ts +0 -148
- package/src/utils/http.ts +0 -302
- package/src/wasm-adapter.ts +0 -297
- package/src/wasm-entry.ts +0 -14
- package/tsconfig.json +0 -17
- package/tsup.wasm.config.ts +0 -26
- package/vitest.config.ts +0 -15
|
@@ -1,406 +0,0 @@
|
|
|
1
|
-
// AI/LLM Readiness Checks
|
|
2
|
-
// Checks for llms.txt, AI bot blocking, and JS rendering ratio
|
|
3
|
-
|
|
4
|
-
import { httpGet } from '../../utils/http.js';
|
|
5
|
-
import * as cheerio from 'cheerio';
|
|
6
|
-
import type { AuditIssue } from '../types.js';
|
|
7
|
-
import { ISSUE_DEFINITIONS } from '../types.js';
|
|
8
|
-
import { checkAgentExperience, type AgentExperienceData } from './agent-experience.js';
|
|
9
|
-
|
|
10
|
-
// Known AI bot user agents
|
|
11
|
-
const AI_BOTS = {
|
|
12
|
-
GPTBot: 'GPTBot',
|
|
13
|
-
'ChatGPT-User': 'ChatGPT-User',
|
|
14
|
-
'OAI-SearchBot': 'OAI-SearchBot',
|
|
15
|
-
ClaudeBot: 'ClaudeBot',
|
|
16
|
-
'Claude-Web': 'Claude-Web',
|
|
17
|
-
'anthropic-ai': 'anthropic-ai',
|
|
18
|
-
PerplexityBot: 'PerplexityBot',
|
|
19
|
-
'Google-Extended': 'Google-Extended',
|
|
20
|
-
Bytespider: 'Bytespider',
|
|
21
|
-
CCBot: 'CCBot',
|
|
22
|
-
};
|
|
23
|
-
|
|
24
|
-
export interface LlmsTxtResult {
|
|
25
|
-
exists: boolean;
|
|
26
|
-
content?: string;
|
|
27
|
-
valid: boolean;
|
|
28
|
-
errors: string[];
|
|
29
|
-
}
|
|
30
|
-
|
|
31
|
-
export interface AIBotBlockingResult {
|
|
32
|
-
robotsExists: boolean;
|
|
33
|
-
blockedBots: string[];
|
|
34
|
-
allowedBots: string[];
|
|
35
|
-
allBlocked: boolean;
|
|
36
|
-
}
|
|
37
|
-
|
|
38
|
-
export interface CloudflareAICrawlerGateResult {
|
|
39
|
-
behindCloudflare: boolean;
|
|
40
|
-
hasExplicitAIRules: boolean;
|
|
41
|
-
ambiguous: boolean; // behind Cloudflare with no explicit AI crawler rules
|
|
42
|
-
}
|
|
43
|
-
|
|
44
|
-
export interface AIReadinessData {
|
|
45
|
-
llmsTxt: LlmsTxtResult;
|
|
46
|
-
botBlocking: AIBotBlockingResult;
|
|
47
|
-
jsRenderingRatio: number; // 0-100%
|
|
48
|
-
cloudflareAIGate: CloudflareAICrawlerGateResult;
|
|
49
|
-
agentExperience: AgentExperienceData;
|
|
50
|
-
}
|
|
51
|
-
|
|
52
|
-
/**
|
|
53
|
-
* Check for llms.txt file
|
|
54
|
-
* See: https://llmstxt.org/
|
|
55
|
-
*/
|
|
56
|
-
export async function checkLlmsTxt(baseUrl: string): Promise<{ issues: AuditIssue[]; data: LlmsTxtResult }> {
|
|
57
|
-
const issues: AuditIssue[] = [];
|
|
58
|
-
const url = new URL('/llms.txt', baseUrl).href;
|
|
59
|
-
|
|
60
|
-
try {
|
|
61
|
-
const response = await httpGet<string>(url, {
|
|
62
|
-
timeout: 10000,
|
|
63
|
-
validateStatus: () => true,
|
|
64
|
-
});
|
|
65
|
-
|
|
66
|
-
if (response.status === 404 || response.status >= 400) {
|
|
67
|
-
issues.push({
|
|
68
|
-
...ISSUE_DEFINITIONS.LLMS_TXT_MISSING,
|
|
69
|
-
affectedUrls: [url],
|
|
70
|
-
});
|
|
71
|
-
return {
|
|
72
|
-
issues,
|
|
73
|
-
data: { exists: false, valid: false, errors: ['File not found'] },
|
|
74
|
-
};
|
|
75
|
-
}
|
|
76
|
-
|
|
77
|
-
const content = response.data as string;
|
|
78
|
-
|
|
79
|
-
// Basic validation of llms.txt format
|
|
80
|
-
// Expected format starts with # followed by site name, then markdown content
|
|
81
|
-
const lines = content.split('\n').filter((line) => line.trim());
|
|
82
|
-
const errors: string[] = [];
|
|
83
|
-
let valid = true;
|
|
84
|
-
|
|
85
|
-
// Check if it starts with a title (# Site Name)
|
|
86
|
-
if (lines.length === 0) {
|
|
87
|
-
errors.push('File is empty');
|
|
88
|
-
valid = false;
|
|
89
|
-
} else if (!lines[0].startsWith('#')) {
|
|
90
|
-
errors.push('File should start with a markdown heading (# Site Name)');
|
|
91
|
-
valid = false;
|
|
92
|
-
}
|
|
93
|
-
|
|
94
|
-
// Check for common sections
|
|
95
|
-
const hasDescription = content.toLowerCase().includes('## ') || content.length > 50;
|
|
96
|
-
if (!hasDescription) {
|
|
97
|
-
errors.push('File should contain meaningful content describing your site for AI');
|
|
98
|
-
valid = false;
|
|
99
|
-
}
|
|
100
|
-
|
|
101
|
-
if (!valid) {
|
|
102
|
-
issues.push({
|
|
103
|
-
...ISSUE_DEFINITIONS.LLMS_TXT_INVALID,
|
|
104
|
-
affectedUrls: [url],
|
|
105
|
-
details: { errors },
|
|
106
|
-
});
|
|
107
|
-
}
|
|
108
|
-
|
|
109
|
-
return {
|
|
110
|
-
issues,
|
|
111
|
-
data: { exists: true, content, valid, errors },
|
|
112
|
-
};
|
|
113
|
-
} catch (error) {
|
|
114
|
-
issues.push({
|
|
115
|
-
...ISSUE_DEFINITIONS.LLMS_TXT_MISSING,
|
|
116
|
-
affectedUrls: [url],
|
|
117
|
-
details: { error: error instanceof Error ? error.message : 'Unknown error' },
|
|
118
|
-
});
|
|
119
|
-
return {
|
|
120
|
-
issues,
|
|
121
|
-
data: { exists: false, valid: false, errors: ['Failed to fetch'] },
|
|
122
|
-
};
|
|
123
|
-
}
|
|
124
|
-
}
|
|
125
|
-
|
|
126
|
-
/**
|
|
127
|
-
* Check robots.txt for AI bot blocking
|
|
128
|
-
*/
|
|
129
|
-
export async function checkAIBotBlocking(baseUrl: string): Promise<{ issues: AuditIssue[]; data: AIBotBlockingResult }> {
|
|
130
|
-
const issues: AuditIssue[] = [];
|
|
131
|
-
const url = new URL('/robots.txt', baseUrl).href;
|
|
132
|
-
|
|
133
|
-
const blockedBots: string[] = [];
|
|
134
|
-
const allowedBots: string[] = [];
|
|
135
|
-
|
|
136
|
-
try {
|
|
137
|
-
const response = await httpGet<string>(url, {
|
|
138
|
-
timeout: 10000,
|
|
139
|
-
validateStatus: () => true,
|
|
140
|
-
});
|
|
141
|
-
|
|
142
|
-
if (response.status === 404 || response.status >= 400) {
|
|
143
|
-
// No robots.txt means all bots are allowed
|
|
144
|
-
return {
|
|
145
|
-
issues,
|
|
146
|
-
data: {
|
|
147
|
-
robotsExists: false,
|
|
148
|
-
blockedBots: [],
|
|
149
|
-
allowedBots: Object.keys(AI_BOTS),
|
|
150
|
-
allBlocked: false,
|
|
151
|
-
},
|
|
152
|
-
};
|
|
153
|
-
}
|
|
154
|
-
|
|
155
|
-
const content = response.data as string;
|
|
156
|
-
const lines = content.split('\n');
|
|
157
|
-
|
|
158
|
-
// Parse robots.txt for AI bot rules
|
|
159
|
-
let currentUserAgent = '';
|
|
160
|
-
const botRules: Record<string, { allowed: boolean; disallowAll: boolean }> = {};
|
|
161
|
-
|
|
162
|
-
for (const line of lines) {
|
|
163
|
-
const trimmed = line.trim().toLowerCase();
|
|
164
|
-
|
|
165
|
-
if (trimmed.startsWith('user-agent:')) {
|
|
166
|
-
currentUserAgent = trimmed.split(':')[1].trim();
|
|
167
|
-
} else if (trimmed.startsWith('disallow:')) {
|
|
168
|
-
const path = trimmed.split(':')[1]?.trim() || '';
|
|
169
|
-
|
|
170
|
-
// Check if this user agent matches any AI bot
|
|
171
|
-
for (const [botKey, botName] of Object.entries(AI_BOTS)) {
|
|
172
|
-
if (currentUserAgent === '*' || currentUserAgent === botKey.toLowerCase() || currentUserAgent === botName.toLowerCase()) {
|
|
173
|
-
if (path === '/' || path === '/*') {
|
|
174
|
-
if (!botRules[botKey]) {
|
|
175
|
-
botRules[botKey] = { allowed: false, disallowAll: false };
|
|
176
|
-
}
|
|
177
|
-
if (currentUserAgent !== '*' || (currentUserAgent === '*' && !botRules[botKey].allowed)) {
|
|
178
|
-
botRules[botKey].disallowAll = true;
|
|
179
|
-
}
|
|
180
|
-
}
|
|
181
|
-
}
|
|
182
|
-
}
|
|
183
|
-
} else if (trimmed.startsWith('allow:')) {
|
|
184
|
-
// Explicit allow for a bot
|
|
185
|
-
for (const [botKey] of Object.entries(AI_BOTS)) {
|
|
186
|
-
if (currentUserAgent === botKey.toLowerCase()) {
|
|
187
|
-
if (!botRules[botKey]) {
|
|
188
|
-
botRules[botKey] = { allowed: true, disallowAll: false };
|
|
189
|
-
}
|
|
190
|
-
botRules[botKey].allowed = true;
|
|
191
|
-
}
|
|
192
|
-
}
|
|
193
|
-
}
|
|
194
|
-
}
|
|
195
|
-
|
|
196
|
-
// Determine blocked/allowed bots
|
|
197
|
-
for (const [botKey] of Object.entries(AI_BOTS)) {
|
|
198
|
-
const rules = botRules[botKey];
|
|
199
|
-
if (rules?.disallowAll && !rules?.allowed) {
|
|
200
|
-
blockedBots.push(botKey);
|
|
201
|
-
} else {
|
|
202
|
-
allowedBots.push(botKey);
|
|
203
|
-
}
|
|
204
|
-
}
|
|
205
|
-
|
|
206
|
-
// Generate issues for blocked bots
|
|
207
|
-
if (blockedBots.length === Object.keys(AI_BOTS).length) {
|
|
208
|
-
issues.push({
|
|
209
|
-
...ISSUE_DEFINITIONS.AI_BOT_BLOCKED_ALL,
|
|
210
|
-
affectedUrls: [url],
|
|
211
|
-
details: { blockedBots },
|
|
212
|
-
});
|
|
213
|
-
} else {
|
|
214
|
-
// Individual bot blocking notices
|
|
215
|
-
if (blockedBots.includes('GPTBot') || blockedBots.includes('ChatGPT-User')) {
|
|
216
|
-
issues.push({
|
|
217
|
-
...ISSUE_DEFINITIONS.AI_BOT_GPTBOT_BLOCKED,
|
|
218
|
-
affectedUrls: [url],
|
|
219
|
-
});
|
|
220
|
-
}
|
|
221
|
-
if (blockedBots.includes('ClaudeBot') || blockedBots.includes('Claude-Web') || blockedBots.includes('anthropic-ai')) {
|
|
222
|
-
issues.push({
|
|
223
|
-
...ISSUE_DEFINITIONS.AI_BOT_CLAUDEBOT_BLOCKED,
|
|
224
|
-
affectedUrls: [url],
|
|
225
|
-
});
|
|
226
|
-
}
|
|
227
|
-
if (blockedBots.includes('PerplexityBot')) {
|
|
228
|
-
issues.push({
|
|
229
|
-
...ISSUE_DEFINITIONS.AI_BOT_PERPLEXITY_BLOCKED,
|
|
230
|
-
affectedUrls: [url],
|
|
231
|
-
});
|
|
232
|
-
}
|
|
233
|
-
if (blockedBots.includes('Google-Extended')) {
|
|
234
|
-
issues.push({
|
|
235
|
-
...ISSUE_DEFINITIONS.AI_BOT_GOOGLE_EXTENDED_BLOCKED,
|
|
236
|
-
affectedUrls: [url],
|
|
237
|
-
});
|
|
238
|
-
}
|
|
239
|
-
}
|
|
240
|
-
|
|
241
|
-
return {
|
|
242
|
-
issues,
|
|
243
|
-
data: {
|
|
244
|
-
robotsExists: true,
|
|
245
|
-
blockedBots,
|
|
246
|
-
allowedBots,
|
|
247
|
-
allBlocked: blockedBots.length === Object.keys(AI_BOTS).length,
|
|
248
|
-
},
|
|
249
|
-
};
|
|
250
|
-
} catch (error) {
|
|
251
|
-
return {
|
|
252
|
-
issues,
|
|
253
|
-
data: {
|
|
254
|
-
robotsExists: false,
|
|
255
|
-
blockedBots: [],
|
|
256
|
-
allowedBots: Object.keys(AI_BOTS),
|
|
257
|
-
allBlocked: false,
|
|
258
|
-
},
|
|
259
|
-
};
|
|
260
|
-
}
|
|
261
|
-
}
|
|
262
|
-
|
|
263
|
-
/**
|
|
264
|
-
* Check JavaScript rendering ratio
|
|
265
|
-
* Compares static HTML content size vs rendered content
|
|
266
|
-
*/
|
|
267
|
-
export function checkJSRenderingRatio(
|
|
268
|
-
html: string,
|
|
269
|
-
url: string
|
|
270
|
-
): { issues: AuditIssue[]; data: { ratio: number; staticWordCount: number } } {
|
|
271
|
-
const issues: AuditIssue[] = [];
|
|
272
|
-
const $ = cheerio.load(html);
|
|
273
|
-
|
|
274
|
-
// Get static text content (what AI crawlers would see without JS)
|
|
275
|
-
// Remove script and style tags
|
|
276
|
-
$('script, style, noscript').remove();
|
|
277
|
-
|
|
278
|
-
const staticText = $('body').text().replace(/\s+/g, ' ').trim();
|
|
279
|
-
const staticWordCount = staticText.split(/\s+/).filter((word) => word.length > 0).length;
|
|
280
|
-
|
|
281
|
-
// Check for signs of heavy JS rendering
|
|
282
|
-
const hasReactRoot = $('#root, #app, #__next, [data-reactroot]').length > 0;
|
|
283
|
-
const hasVueApp = $('#app[data-v-app], [data-v-]').length > 0;
|
|
284
|
-
const hasAngularApp = $('[ng-app], [data-ng-app]').length > 0;
|
|
285
|
-
const hasEmptyBody = staticWordCount < 50;
|
|
286
|
-
|
|
287
|
-
// Estimate JS rendering ratio based on signals
|
|
288
|
-
let ratio = 0;
|
|
289
|
-
if (hasEmptyBody && (hasReactRoot || hasVueApp || hasAngularApp)) {
|
|
290
|
-
ratio = 90; // Likely SPA with most content rendered by JS
|
|
291
|
-
} else if (hasReactRoot || hasVueApp || hasAngularApp) {
|
|
292
|
-
ratio = 50; // Has JS framework but some static content
|
|
293
|
-
} else if (staticWordCount < 100) {
|
|
294
|
-
ratio = 60; // Very little static content
|
|
295
|
-
} else {
|
|
296
|
-
ratio = 10; // Mostly static content
|
|
297
|
-
}
|
|
298
|
-
|
|
299
|
-
// Check for noscript fallback
|
|
300
|
-
const hasNoScript = $('noscript').length > 0;
|
|
301
|
-
if (hasNoScript && ratio > 50) {
|
|
302
|
-
ratio -= 20; // Has fallback content
|
|
303
|
-
}
|
|
304
|
-
|
|
305
|
-
if (ratio > 50) {
|
|
306
|
-
issues.push({
|
|
307
|
-
...ISSUE_DEFINITIONS.HIGH_JS_RENDERING_RATIO,
|
|
308
|
-
affectedUrls: [url],
|
|
309
|
-
details: { ratio: `${ratio}%`, staticWordCount },
|
|
310
|
-
});
|
|
311
|
-
}
|
|
312
|
-
|
|
313
|
-
return {
|
|
314
|
-
issues,
|
|
315
|
-
data: { ratio, staticWordCount },
|
|
316
|
-
};
|
|
317
|
-
}
|
|
318
|
-
|
|
319
|
-
/**
|
|
320
|
-
* Check whether a Cloudflare-fronted site has explicit AI crawler rules.
|
|
321
|
-
*
|
|
322
|
-
* Cloudflare blocks "mixed-use" AI crawlers by default on ad-hosting zones
|
|
323
|
-
* starting September 15, 2026, and is expanding Pay Per Crawl into a
|
|
324
|
-
* broader Pay Per Use gating model. A site with no explicit robots.txt
|
|
325
|
-
* stance on AI crawlers is now relying on whatever Cloudflare's
|
|
326
|
-
* account-level bot-management default happens to be — invisible from
|
|
327
|
-
* the codebase, and liable to change without a corresponding commit.
|
|
328
|
-
*/
|
|
329
|
-
export async function checkCloudflareAICrawlerGate(
|
|
330
|
-
baseUrl: string,
|
|
331
|
-
botBlocking: AIBotBlockingResult
|
|
332
|
-
): Promise<{ issues: AuditIssue[]; data: CloudflareAICrawlerGateResult }> {
|
|
333
|
-
const issues: AuditIssue[] = [];
|
|
334
|
-
|
|
335
|
-
let behindCloudflare = false;
|
|
336
|
-
try {
|
|
337
|
-
const response = await httpGet<string>(baseUrl, {
|
|
338
|
-
timeout: 10000,
|
|
339
|
-
validateStatus: () => true,
|
|
340
|
-
});
|
|
341
|
-
const server = response.headers['server'] || '';
|
|
342
|
-
behindCloudflare = server.toLowerCase().includes('cloudflare') || 'cf-ray' in response.headers;
|
|
343
|
-
} catch {
|
|
344
|
-
behindCloudflare = false;
|
|
345
|
-
}
|
|
346
|
-
|
|
347
|
-
// "Explicit" means robots.txt exists and names at least one AI crawler
|
|
348
|
-
// specifically, rather than relying purely on a bare wildcard rule or
|
|
349
|
-
// the absence of a file.
|
|
350
|
-
const hasExplicitAIRules = botBlocking.robotsExists && botBlocking.blockedBots.length > 0;
|
|
351
|
-
|
|
352
|
-
const ambiguous = behindCloudflare && !hasExplicitAIRules;
|
|
353
|
-
|
|
354
|
-
if (ambiguous) {
|
|
355
|
-
issues.push({
|
|
356
|
-
...ISSUE_DEFINITIONS.CLOUDFLARE_AI_CRAWLER_GATE_AMBIGUOUS,
|
|
357
|
-
affectedUrls: [baseUrl],
|
|
358
|
-
});
|
|
359
|
-
}
|
|
360
|
-
|
|
361
|
-
return {
|
|
362
|
-
issues,
|
|
363
|
-
data: { behindCloudflare, hasExplicitAIRules, ambiguous },
|
|
364
|
-
};
|
|
365
|
-
}
|
|
366
|
-
|
|
367
|
-
/**
|
|
368
|
-
* Run all AI readiness checks
|
|
369
|
-
*/
|
|
370
|
-
export async function runAIReadinessChecks(
|
|
371
|
-
baseUrl: string,
|
|
372
|
-
html: string
|
|
373
|
-
): Promise<{ issues: AuditIssue[]; data: AIReadinessData }> {
|
|
374
|
-
const allIssues: AuditIssue[] = [];
|
|
375
|
-
|
|
376
|
-
// Check llms.txt
|
|
377
|
-
const llmsResult = await checkLlmsTxt(baseUrl);
|
|
378
|
-
allIssues.push(...llmsResult.issues);
|
|
379
|
-
|
|
380
|
-
// Check AI bot blocking
|
|
381
|
-
const botResult = await checkAIBotBlocking(baseUrl);
|
|
382
|
-
allIssues.push(...botResult.issues);
|
|
383
|
-
|
|
384
|
-
// Check JS rendering ratio
|
|
385
|
-
const jsResult = checkJSRenderingRatio(html, baseUrl);
|
|
386
|
-
allIssues.push(...jsResult.issues);
|
|
387
|
-
|
|
388
|
-
// Check Cloudflare AI-crawler-access gating posture
|
|
389
|
-
const cfGateResult = await checkCloudflareAICrawlerGate(baseUrl, botResult.data);
|
|
390
|
-
allIssues.push(...cfGateResult.issues);
|
|
391
|
-
|
|
392
|
-
// Check agent-facing discovery surface (llms-full.txt, SKILL.md, MCP, OpenAPI)
|
|
393
|
-
const agentExperienceResult = await checkAgentExperience(baseUrl);
|
|
394
|
-
allIssues.push(...agentExperienceResult.issues);
|
|
395
|
-
|
|
396
|
-
return {
|
|
397
|
-
issues: allIssues,
|
|
398
|
-
data: {
|
|
399
|
-
llmsTxt: llmsResult.data,
|
|
400
|
-
botBlocking: botResult.data,
|
|
401
|
-
jsRenderingRatio: jsResult.data.ratio,
|
|
402
|
-
cloudflareAIGate: cfGateResult.data,
|
|
403
|
-
agentExperience: agentExperienceResult.data,
|
|
404
|
-
},
|
|
405
|
-
};
|
|
406
|
-
}
|
|
@@ -1,179 +0,0 @@
|
|
|
1
|
-
import * as cheerio from 'cheerio';
|
|
2
|
-
import type { AuditIssue } from '../types.js';
|
|
3
|
-
import { ISSUE_DEFINITIONS } from '../types.js';
|
|
4
|
-
|
|
5
|
-
export interface AnchorTextData {
|
|
6
|
-
totalLinks: number;
|
|
7
|
-
internalLinks: number;
|
|
8
|
-
externalLinks: number;
|
|
9
|
-
emptyAnchors: number;
|
|
10
|
-
genericAnchors: number;
|
|
11
|
-
anchorDistribution: Record<string, number>;
|
|
12
|
-
topAnchors: { text: string; count: number }[];
|
|
13
|
-
}
|
|
14
|
-
|
|
15
|
-
// Generic anchor text patterns
|
|
16
|
-
const GENERIC_ANCHORS = new Set([
|
|
17
|
-
'click here',
|
|
18
|
-
'click',
|
|
19
|
-
'here',
|
|
20
|
-
'read more',
|
|
21
|
-
'more',
|
|
22
|
-
'learn more',
|
|
23
|
-
'see more',
|
|
24
|
-
'view more',
|
|
25
|
-
'continue',
|
|
26
|
-
'continue reading',
|
|
27
|
-
'link',
|
|
28
|
-
'this',
|
|
29
|
-
'this link',
|
|
30
|
-
'go',
|
|
31
|
-
'details',
|
|
32
|
-
'more details',
|
|
33
|
-
'more info',
|
|
34
|
-
'info',
|
|
35
|
-
'read',
|
|
36
|
-
'view',
|
|
37
|
-
'see',
|
|
38
|
-
'download',
|
|
39
|
-
'get it',
|
|
40
|
-
'get',
|
|
41
|
-
]);
|
|
42
|
-
|
|
43
|
-
export function analyzeAnchorText(
|
|
44
|
-
html: string,
|
|
45
|
-
url: string
|
|
46
|
-
): { issues: AuditIssue[]; data: AnchorTextData } {
|
|
47
|
-
const issues: AuditIssue[] = [];
|
|
48
|
-
const $ = cheerio.load(html);
|
|
49
|
-
|
|
50
|
-
const baseUrl = new URL(url);
|
|
51
|
-
const anchorCounts: Record<string, number> = {};
|
|
52
|
-
let totalLinks = 0;
|
|
53
|
-
let internalLinks = 0;
|
|
54
|
-
let externalLinks = 0;
|
|
55
|
-
let emptyAnchors = 0;
|
|
56
|
-
let genericAnchors = 0;
|
|
57
|
-
const emptyAnchorUrls: string[] = [];
|
|
58
|
-
const genericAnchorExamples: string[] = [];
|
|
59
|
-
|
|
60
|
-
$('a[href]').each((_, el) => {
|
|
61
|
-
const href = $(el).attr('href') || '';
|
|
62
|
-
const text = $(el).text().trim().toLowerCase();
|
|
63
|
-
const ariaLabel = $(el).attr('aria-label')?.trim();
|
|
64
|
-
const title = $(el).attr('title')?.trim();
|
|
65
|
-
|
|
66
|
-
// Skip non-navigation links
|
|
67
|
-
if (href.startsWith('#') || href.startsWith('javascript:') || href.startsWith('mailto:') || href.startsWith('tel:')) {
|
|
68
|
-
return;
|
|
69
|
-
}
|
|
70
|
-
|
|
71
|
-
totalLinks++;
|
|
72
|
-
|
|
73
|
-
// Determine if internal or external
|
|
74
|
-
try {
|
|
75
|
-
const linkUrl = new URL(href, url);
|
|
76
|
-
if (linkUrl.hostname === baseUrl.hostname) {
|
|
77
|
-
internalLinks++;
|
|
78
|
-
} else {
|
|
79
|
-
externalLinks++;
|
|
80
|
-
}
|
|
81
|
-
} catch {
|
|
82
|
-
internalLinks++; // Relative URLs are internal
|
|
83
|
-
}
|
|
84
|
-
|
|
85
|
-
// Check for empty anchor text
|
|
86
|
-
const hasAccessibleText = text.length > 0 || ariaLabel || title;
|
|
87
|
-
if (!hasAccessibleText) {
|
|
88
|
-
emptyAnchors++;
|
|
89
|
-
if (emptyAnchorUrls.length < 5) {
|
|
90
|
-
emptyAnchorUrls.push(href);
|
|
91
|
-
}
|
|
92
|
-
return;
|
|
93
|
-
}
|
|
94
|
-
|
|
95
|
-
// Check for generic anchor text
|
|
96
|
-
if (GENERIC_ANCHORS.has(text)) {
|
|
97
|
-
genericAnchors++;
|
|
98
|
-
if (genericAnchorExamples.length < 5) {
|
|
99
|
-
genericAnchorExamples.push(text);
|
|
100
|
-
}
|
|
101
|
-
}
|
|
102
|
-
|
|
103
|
-
// Track anchor text distribution
|
|
104
|
-
if (text.length > 0) {
|
|
105
|
-
anchorCounts[text] = (anchorCounts[text] || 0) + 1;
|
|
106
|
-
}
|
|
107
|
-
});
|
|
108
|
-
|
|
109
|
-
// Calculate top anchors
|
|
110
|
-
const topAnchors = Object.entries(anchorCounts)
|
|
111
|
-
.sort((a, b) => b[1] - a[1])
|
|
112
|
-
.slice(0, 10)
|
|
113
|
-
.map(([text, count]) => ({ text, count }));
|
|
114
|
-
|
|
115
|
-
const data: AnchorTextData = {
|
|
116
|
-
totalLinks,
|
|
117
|
-
internalLinks,
|
|
118
|
-
externalLinks,
|
|
119
|
-
emptyAnchors,
|
|
120
|
-
genericAnchors,
|
|
121
|
-
anchorDistribution: anchorCounts,
|
|
122
|
-
topAnchors,
|
|
123
|
-
};
|
|
124
|
-
|
|
125
|
-
// ==================== Issue Detection ====================
|
|
126
|
-
|
|
127
|
-
// Empty anchors
|
|
128
|
-
if (emptyAnchors > 0) {
|
|
129
|
-
issues.push({
|
|
130
|
-
...ISSUE_DEFINITIONS.ANCHOR_TEXT_EMPTY,
|
|
131
|
-
affectedUrls: [url],
|
|
132
|
-
details: {
|
|
133
|
-
count: emptyAnchors,
|
|
134
|
-
examples: emptyAnchorUrls,
|
|
135
|
-
},
|
|
136
|
-
});
|
|
137
|
-
}
|
|
138
|
-
|
|
139
|
-
// Generic anchors (only flag if significant portion)
|
|
140
|
-
const genericRatio = totalLinks > 0 ? genericAnchors / totalLinks : 0;
|
|
141
|
-
if (genericAnchors >= 3 && genericRatio > 0.2) {
|
|
142
|
-
issues.push({
|
|
143
|
-
...ISSUE_DEFINITIONS.ANCHOR_TEXT_GENERIC,
|
|
144
|
-
affectedUrls: [url],
|
|
145
|
-
details: {
|
|
146
|
-
count: genericAnchors,
|
|
147
|
-
ratio: `${(genericRatio * 100).toFixed(1)}%`,
|
|
148
|
-
examples: genericAnchorExamples,
|
|
149
|
-
},
|
|
150
|
-
});
|
|
151
|
-
}
|
|
152
|
-
|
|
153
|
-
// Over-optimized anchor text detection
|
|
154
|
-
// Check if any single anchor text is used too frequently
|
|
155
|
-
const overOptimizedAnchors: string[] = [];
|
|
156
|
-
for (const [text, count] of Object.entries(anchorCounts)) {
|
|
157
|
-
// Exclude very short or very long texts
|
|
158
|
-
if (text.length < 3 || text.length > 50) continue;
|
|
159
|
-
// Exclude common navigation items
|
|
160
|
-
if (['home', 'about', 'contact', 'blog', 'services', 'products'].includes(text)) continue;
|
|
161
|
-
|
|
162
|
-
// Flag if same anchor used more than 5 times and represents >15% of links
|
|
163
|
-
if (count > 5 && count / internalLinks > 0.15) {
|
|
164
|
-
overOptimizedAnchors.push(`"${text}" (${count} times)`);
|
|
165
|
-
}
|
|
166
|
-
}
|
|
167
|
-
|
|
168
|
-
if (overOptimizedAnchors.length > 0) {
|
|
169
|
-
issues.push({
|
|
170
|
-
...ISSUE_DEFINITIONS.ANCHOR_TEXT_OVER_OPTIMIZED,
|
|
171
|
-
affectedUrls: [url],
|
|
172
|
-
details: {
|
|
173
|
-
overOptimized: overOptimizedAnchors,
|
|
174
|
-
},
|
|
175
|
-
});
|
|
176
|
-
}
|
|
177
|
-
|
|
178
|
-
return { issues, data };
|
|
179
|
-
}
|