@rankcli/agent-runtime 0.0.13 → 0.0.15

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.
@@ -56,6 +56,7 @@ import { analyzeFreshnessSignals } from './checks/freshness-signals.js';
56
56
  // AI Search Optimization checks (2026 - ChatGPT, AI Overviews)
57
57
  import { analyzeBingOptimization } from './checks/bing-optimization.js';
58
58
  import { analyzeAIContentStructure } from './checks/ai-content-structure.js';
59
+ import { analyzeRAGChunkReadiness } from './checks/rag-chunk-readiness.js';
59
60
  import { analyzeCitationQuality } from './checks/citation-quality.js';
60
61
  import { analyzeAnswerConciseness } from './checks/answer-conciseness.js';
61
62
  // AI SEO Skills checks (brand narrative, citation worthiness, review ecosystem)
@@ -109,10 +110,13 @@ export async function runFullAudit(options: AuditOptions): Promise<AuditReport>
109
110
  const parsedUrl = new URL(url);
110
111
  const domain = parsedUrl.hostname;
111
112
 
112
- console.log(`\nšŸ” Running comprehensive SEO audit on ${url}...\n`);
113
+ // Progress messages go to stderr, not stdout: stdout is reserved for the
114
+ // audit's actual JSON output (`rankcli audit --output json > file.json`),
115
+ // and printing progress via console.log here corrupts that redirect.
116
+ console.error(`\nšŸ” Running comprehensive SEO audit on ${url}...\n`);
113
117
 
114
118
  // ========== PHASE 1: CRAWLABILITY + FETCH (PARALLEL) ==========
115
- console.log('šŸ“‹ Phase 1: Crawlability checks + page fetch (parallel)...');
119
+ console.error('šŸ“‹ Phase 1: Crawlability checks + page fetch (parallel)...');
116
120
 
117
121
  const [crawlabilityResult, fetchResult] = await Promise.all([
118
122
  runCrawlabilityChecks(url).catch(err => {
@@ -123,6 +127,7 @@ export async function runFullAudit(options: AuditOptions): Promise<AuditReport>
123
127
  timeout: 30000,
124
128
  validateStatus: () => true,
125
129
  }).catch(err => {
130
+ console.error('Main page fetch failed:', err instanceof Error ? { message: err.message, cause: (err as NodeJS.ErrnoException).cause } : err);
126
131
  return { error: err, data: '', headers: {} as Record<string, string> };
127
132
  }),
128
133
  ]);
@@ -131,15 +136,21 @@ export async function runFullAudit(options: AuditOptions): Promise<AuditReport>
131
136
 
132
137
  // Check if fetch failed
133
138
  if ('error' in fetchResult) {
139
+ const err = fetchResult.error;
140
+ const errorMsg = err instanceof Error ? err.message : 'Unknown error';
141
+ const errorCause = err instanceof Error && (err as NodeJS.ErrnoException).cause
142
+ ? ` (${String((err as NodeJS.ErrnoException).cause)})`
143
+ : '';
134
144
  allIssues.push({
135
145
  code: 'FETCH_ERROR',
136
146
  severity: 'error',
137
- category: 'crawlability',
138
- title: 'Failed to fetch page',
139
- description: `Could not fetch the page: ${fetchResult.error instanceof Error ? fetchResult.error.message : 'Unknown error'}`,
140
- impact: 'Cannot perform full audit without page content.',
141
- howToFix: 'Ensure the URL is accessible and the server is responding.',
147
+ category: 'indexability', // Not crawlability - robots/sitemap checks may have succeeded
148
+ title: 'Page fetch failed',
149
+ description: `Could not load page content: ${errorMsg}${errorCause}. Only robots.txt and sitemap checks were performed.`,
150
+ impact: 'Cannot perform full SEO audit without page HTML content.',
151
+ howToFix: 'Verify the URL is accessible, the server is responding, and there are no firewall/geo blocks.',
142
152
  affectedUrls: [url],
153
+ details: { error: errorMsg, cause: errorCause || undefined },
143
154
  });
144
155
  return createReport(url, domain, allIssues, pages);
145
156
  }
@@ -148,7 +159,7 @@ export async function runFullAudit(options: AuditOptions): Promise<AuditReport>
148
159
  const headers = fetchResult.headers;
149
160
 
150
161
  // ========== PHASE 2: SYNCHRONOUS HTML CHECKS (PARALLEL) ==========
151
- console.log(`šŸ“ Phase 2: Running synchronous HTML checks (tier: ${tier}, limit: ${checksLimit})...`);
162
+ console.error(`šŸ“ Phase 2: Running synchronous HTML checks (tier: ${tier}, limit: ${checksLimit})...`);
152
163
 
153
164
  // ===== CORE CHECKS (all tiers) =====
154
165
  const onPageResult = analyzeOnPage(html, url);
@@ -199,6 +210,7 @@ export async function runFullAudit(options: AuditOptions): Promise<AuditReport>
199
210
  const qdfFreshnessResult = runPremiumChecks ? analyzeFreshnessSignals(html, url) : { issues: [], data: {} };
200
211
  // AI Search Optimization (2026) - Premium only
201
212
  const aiContentStructureResult = runPremiumChecks ? analyzeAIContentStructure(html, url) : { issues: [], data: {} };
213
+ const ragChunkReadinessResult = runPremiumChecks ? analyzeRAGChunkReadiness(html, url) : { issues: [], data: {} };
202
214
  const citationQualityResult = runPremiumChecks ? analyzeCitationQuality(html, url) : { issues: [], data: {} };
203
215
  const answerConcisenessResult = runPremiumChecks ? analyzeAnswerConciseness(html, url) : { issues: [], data: {} };
204
216
  // AI SEO Skills - Premium only
@@ -240,6 +252,7 @@ export async function runFullAudit(options: AuditOptions): Promise<AuditReport>
240
252
  ...entityResult.issues,
241
253
  ...qdfFreshnessResult.issues,
242
254
  ...aiContentStructureResult.issues,
255
+ ...ragChunkReadinessResult.issues,
243
256
  ...citationQualityResult.issues,
244
257
  ...answerConcisenessResult.issues,
245
258
  ...brandMentionResult.issues,
@@ -267,7 +280,7 @@ export async function runFullAudit(options: AuditOptions): Promise<AuditReport>
267
280
  }
268
281
 
269
282
  // ========== PHASE 3: ASYNC CHECKS (PARALLEL) ==========
270
- console.log('šŸ”— Phase 3: Running async checks (parallel)...');
283
+ console.error('šŸ”— Phase 3: Running async checks (parallel)...');
271
284
 
272
285
  // Helper to safely run async checks with error handling and timeout
273
286
  const safeAsync = async <T>(
@@ -411,7 +424,7 @@ export async function runFullAudit(options: AuditOptions): Promise<AuditReport>
411
424
  issues: allIssues.map(i => i.code),
412
425
  });
413
426
 
414
- console.log('\nāœ… Audit complete!\n');
427
+ console.error('\nāœ… Audit complete!\n');
415
428
 
416
429
  return createReport(url, domain, allIssues, pages);
417
430
  }
@@ -994,6 +994,30 @@ export const ISSUE_DEFINITIONS: Record<string, IssueDefinition> = {
994
994
  impact: 'Your content will not be used for Bard/Gemini AI training (regular search unaffected).',
995
995
  howToFix: 'Remove "User-agent: Google-Extended Disallow: /" if you want Google AI visibility.',
996
996
  },
997
+ CLOUDFLARE_AI_CRAWLER_GATE_AMBIGUOUS: {
998
+ code: 'CLOUDFLARE_AI_CRAWLER_GATE_AMBIGUOUS',
999
+ severity: 'warning',
1000
+ category: 'ai-readiness',
1001
+ title: 'No explicit AI crawler rules on a Cloudflare-fronted site',
1002
+ description:
1003
+ 'This site appears to be served through Cloudflare but robots.txt has no explicit Allow/Disallow rules for AI crawlers. Cloudflare blocks "mixed-use" AI crawlers by default on ad-hosting zones starting September 15, 2026, and is rolling out Pay Per Crawl / Pay Per Use gating beyond that.',
1004
+ impact:
1005
+ 'Without an explicit rule, whether AI crawlers (and future citation opportunities in ChatGPT, Claude, Perplexity, etc.) can reach this site now depends on Cloudflare account-level bot-management defaults, not on this codebase — a silent, invisible-to-git failure mode.',
1006
+ howToFix:
1007
+ 'Add explicit User-agent rules for GPTBot, Claude-Web/ClaudeBot, PerplexityBot, and Google-Extended in robots.txt, and confirm the matching allow/block posture in the Cloudflare dashboard under Bot Management → AI Crawl Control. If you\'d rather charge than block, Cloudflare\'s Pay Per Crawl (AI Crawl Control → Payments tab) lets you set a per-crawl price instead of a flat allow/deny — worth a look if you get meaningful AI-crawler traffic.',
1008
+ },
1009
+ NO_AGENT_EXPERIENCE_SURFACE: {
1010
+ code: 'NO_AGENT_EXPERIENCE_SURFACE',
1011
+ severity: 'notice',
1012
+ category: 'ai-readiness',
1013
+ title: 'No agent-facing discovery surface found',
1014
+ description:
1015
+ 'None of the emerging AI-agent discovery conventions were found: llms-full.txt, SKILL.md, a discoverable MCP server, or an OpenAPI spec. This is a genuinely new, low-adoption category as of 2026 — most sites have none of these yet — so this is an opportunity, not a compliance failure.',
1016
+ impact:
1017
+ 'AI agents (not just chatbots answering questions, but agents that browse and act on a user\'s behalf) increasingly prefer sites that expose machine-readable capabilities over ones that require scraping and guessing at HTML structure. Being an early, discoverable site in this space is a low-competition differentiator right now.',
1018
+ howToFix:
1019
+ 'Start with whichever fits your site: llms-full.txt (a single Markdown dump of your key content — measured as fetched roughly 2x more than plain llms.txt), a SKILL.md capability manifest, or an OpenAPI spec at /openapi.json if you already have an API. A discoverable MCP server is the highest-effort, highest-payoff option if your product has one to expose.',
1020
+ },
997
1021
  HIGH_JS_RENDERING_RATIO: {
998
1022
  code: 'HIGH_JS_RENDERING_RATIO',
999
1023
  severity: 'warning',
package/src/index.ts CHANGED
@@ -62,6 +62,9 @@ export * from './reports/index.js';
62
62
  // GEO (Generative Engine Optimization) tracking
63
63
  export * from './geo/index.js';
64
64
 
65
+ // Keyword Rank Tracking
66
+ export * from './ranking/index.js';
67
+
65
68
  // Advanced Analyzers (GEO, CWV, Security, Schema, Images, Links, Mobile)
66
69
  export * as analyzers from './analyzers/index.js';
67
70
 
@@ -0,0 +1,5 @@
1
+ // Ranking Module - Keyword position tracking and SERP analysis
2
+
3
+ export * from './types.js';
4
+ export * from './serp-client.js';
5
+ export * from './tracker.js';
@@ -0,0 +1,348 @@
1
+ // SERP Client - Check keyword rankings via API or scraping
2
+
3
+ import type { RankCheckOptions, RankCheckResult, SerpFeature, CompetitorUrl } from './types.js';
4
+
5
+ export interface SerpClientConfig {
6
+ provider: 'valueserp' | 'serpapi' | 'direct';
7
+ apiKey?: string;
8
+ proxyUrl?: string;
9
+ }
10
+
11
+ export class SerpClient {
12
+ private config: SerpClientConfig;
13
+
14
+ constructor(config: SerpClientConfig) {
15
+ this.config = config;
16
+ }
17
+
18
+ /**
19
+ * Check ranking for a single keyword
20
+ */
21
+ async checkRank(options: RankCheckOptions): Promise<RankCheckResult[]> {
22
+ const results: RankCheckResult[] = [];
23
+
24
+ for (const keyword of options.keywords) {
25
+ try {
26
+ const result = await this.checkSingleKeyword({
27
+ ...options,
28
+ keyword,
29
+ });
30
+ results.push(result);
31
+ } catch (error) {
32
+ // Log error but continue with other keywords
33
+ console.error(`Error checking rank for "${keyword}":`, error);
34
+ results.push({
35
+ keyword,
36
+ position: null,
37
+ url: null,
38
+ serpFeatures: [],
39
+ topResults: [],
40
+ checkedAt: new Date(),
41
+ });
42
+ }
43
+ }
44
+
45
+ return results;
46
+ }
47
+
48
+ private async checkSingleKeyword(options: RankCheckOptions & { keyword: string }): Promise<RankCheckResult> {
49
+ switch (this.config.provider) {
50
+ case 'valueserp':
51
+ return this.checkViaValueSerp(options);
52
+ case 'serpapi':
53
+ return this.checkViaSerpApi(options);
54
+ case 'direct':
55
+ default:
56
+ return this.checkViaDirect(options);
57
+ }
58
+ }
59
+
60
+ /**
61
+ * ValueSERP API implementation
62
+ * Docs: https://www.valueserp.com/docs
63
+ */
64
+ private async checkViaValueSerp(options: RankCheckOptions & { keyword: string }): Promise<RankCheckResult> {
65
+ if (!this.config.apiKey) {
66
+ throw new Error('ValueSERP API key required');
67
+ }
68
+
69
+ const params = new URLSearchParams({
70
+ api_key: this.config.apiKey,
71
+ q: options.keyword,
72
+ location: options.country || 'United States',
73
+ google_domain: options.country === 'US' ? 'google.com' : `google.${options.country?.toLowerCase() || 'com'}`,
74
+ gl: options.country || 'us',
75
+ hl: options.language || 'en',
76
+ device: options.device || 'desktop',
77
+ num: '100', // Get top 100 results
78
+ });
79
+
80
+ const response = await fetch(`https://api.valueserp.com/search?${params}`);
81
+
82
+ if (!response.ok) {
83
+ throw new Error(`ValueSERP API error: ${response.status}`);
84
+ }
85
+
86
+ const data = await response.json() as ValueSerpResponse;
87
+
88
+ return this.parseValueSerpResponse(data, options.domain, options.keyword);
89
+ }
90
+
91
+ /**
92
+ * SerpAPI implementation (alternative)
93
+ * Docs: https://serpapi.com/search-api
94
+ */
95
+ private async checkViaSerpApi(options: RankCheckOptions & { keyword: string }): Promise<RankCheckResult> {
96
+ if (!this.config.apiKey) {
97
+ throw new Error('SerpAPI key required');
98
+ }
99
+
100
+ const params = new URLSearchParams({
101
+ api_key: this.config.apiKey,
102
+ q: options.keyword,
103
+ location: options.country === 'US' ? 'United States' : options.country || 'United States',
104
+ gl: options.country?.toLowerCase() || 'us',
105
+ hl: options.language || 'en',
106
+ device: options.device || 'desktop',
107
+ num: '100',
108
+ });
109
+
110
+ const response = await fetch(`https://serpapi.com/search?${params}`);
111
+
112
+ if (!response.ok) {
113
+ throw new Error(`SerpAPI error: ${response.status}`);
114
+ }
115
+
116
+ const data = await response.json() as SerpApiResponse;
117
+
118
+ return this.parseSerpApiResponse(data, options.domain, options.keyword);
119
+ }
120
+
121
+ /**
122
+ * Direct scraping fallback (rate-limited, use with caution)
123
+ */
124
+ private async checkViaDirect(options: RankCheckOptions & { keyword: string }): Promise<RankCheckResult> {
125
+ const searchUrl = `https://www.google.com/search?q=${encodeURIComponent(options.keyword)}&num=100`;
126
+
127
+ const headers: Record<string, string> = {
128
+ 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
129
+ 'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8',
130
+ 'Accept-Language': options.language || 'en-US,en;q=0.9',
131
+ };
132
+
133
+ const response = await fetch(searchUrl, { headers });
134
+
135
+ if (!response.ok) {
136
+ throw new Error(`Direct search failed: ${response.status}`);
137
+ }
138
+
139
+ const html = await response.text();
140
+ return this.parseDirectSearchResults(html, options.domain, options.keyword);
141
+ }
142
+
143
+ private parseValueSerpResponse(data: ValueSerpResponse, domain: string, keyword: string): RankCheckResult {
144
+ const topResults: CompetitorUrl[] = [];
145
+ const serpFeatures: SerpFeature[] = [];
146
+ let position: number | null = null;
147
+ let url: string | null = null;
148
+
149
+ // Parse organic results
150
+ if (data.organic_results) {
151
+ for (let i = 0; i < data.organic_results.length; i++) {
152
+ const result = data.organic_results[i];
153
+ const resultDomain = this.extractDomain(result.link);
154
+
155
+ topResults.push({
156
+ position: i + 1,
157
+ url: result.link,
158
+ domain: resultDomain,
159
+ title: result.title,
160
+ });
161
+
162
+ // Check if this is our domain
163
+ if (this.domainMatches(resultDomain, domain) && position === null) {
164
+ position = i + 1;
165
+ url = result.link;
166
+ }
167
+ }
168
+ }
169
+
170
+ // Parse SERP features
171
+ if (data.answer_box) {
172
+ serpFeatures.push({
173
+ type: 'featured_snippet',
174
+ position: 0,
175
+ hasOwnSite: this.domainMatches(this.extractDomain(data.answer_box.link || ''), domain),
176
+ });
177
+ }
178
+
179
+ if (data.people_also_ask) {
180
+ serpFeatures.push({
181
+ type: 'people_also_ask',
182
+ hasOwnSite: data.people_also_ask.some(
183
+ (paa: { link?: string }) => this.domainMatches(this.extractDomain(paa.link || ''), domain)
184
+ ),
185
+ });
186
+ }
187
+
188
+ if (data.local_results) {
189
+ serpFeatures.push({ type: 'local_pack' });
190
+ }
191
+
192
+ if (data.knowledge_graph) {
193
+ serpFeatures.push({ type: 'knowledge_panel' });
194
+ }
195
+
196
+ return {
197
+ keyword,
198
+ position,
199
+ url,
200
+ serpFeatures,
201
+ topResults: topResults.slice(0, 10),
202
+ checkedAt: new Date(),
203
+ };
204
+ }
205
+
206
+ private parseSerpApiResponse(data: SerpApiResponse, domain: string, keyword: string): RankCheckResult {
207
+ const topResults: CompetitorUrl[] = [];
208
+ const serpFeatures: SerpFeature[] = [];
209
+ let position: number | null = null;
210
+ let url: string | null = null;
211
+
212
+ // Parse organic results
213
+ if (data.organic_results) {
214
+ for (let i = 0; i < data.organic_results.length; i++) {
215
+ const result = data.organic_results[i];
216
+ const resultDomain = this.extractDomain(result.link);
217
+
218
+ topResults.push({
219
+ position: result.position || i + 1,
220
+ url: result.link,
221
+ domain: resultDomain,
222
+ title: result.title,
223
+ });
224
+
225
+ if (this.domainMatches(resultDomain, domain) && position === null) {
226
+ position = result.position || i + 1;
227
+ url = result.link;
228
+ }
229
+ }
230
+ }
231
+
232
+ // Parse SERP features
233
+ if (data.answer_box) {
234
+ serpFeatures.push({
235
+ type: 'featured_snippet',
236
+ position: 0,
237
+ });
238
+ }
239
+
240
+ if (data.related_questions) {
241
+ serpFeatures.push({ type: 'people_also_ask' });
242
+ }
243
+
244
+ return {
245
+ keyword,
246
+ position,
247
+ url,
248
+ serpFeatures,
249
+ topResults: topResults.slice(0, 10),
250
+ checkedAt: new Date(),
251
+ };
252
+ }
253
+
254
+ private parseDirectSearchResults(html: string, domain: string, keyword: string): RankCheckResult {
255
+ const topResults: CompetitorUrl[] = [];
256
+ let position: number | null = null;
257
+ let url: string | null = null;
258
+
259
+ // Simple regex-based parsing (not perfect but works for basic cases)
260
+ const linkRegex = /<a[^>]+href="\/url\?q=([^"&]+)/g;
261
+ let match;
262
+ let index = 0;
263
+
264
+ while ((match = linkRegex.exec(html)) !== null && index < 100) {
265
+ try {
266
+ const decodedUrl = decodeURIComponent(match[1]);
267
+ const resultDomain = this.extractDomain(decodedUrl);
268
+
269
+ // Skip Google's own URLs
270
+ if (resultDomain.includes('google.com') || resultDomain.includes('gstatic.com')) {
271
+ continue;
272
+ }
273
+
274
+ index++;
275
+ topResults.push({
276
+ position: index,
277
+ url: decodedUrl,
278
+ domain: resultDomain,
279
+ });
280
+
281
+ if (this.domainMatches(resultDomain, domain) && position === null) {
282
+ position = index;
283
+ url = decodedUrl;
284
+ }
285
+ } catch {
286
+ // Skip malformed URLs
287
+ }
288
+ }
289
+
290
+ return {
291
+ keyword,
292
+ position,
293
+ url,
294
+ serpFeatures: [], // Direct scraping doesn't easily extract SERP features
295
+ topResults: topResults.slice(0, 10),
296
+ checkedAt: new Date(),
297
+ };
298
+ }
299
+
300
+ private extractDomain(url: string): string {
301
+ try {
302
+ const parsed = new URL(url);
303
+ return parsed.hostname.replace(/^www\./, '');
304
+ } catch {
305
+ return '';
306
+ }
307
+ }
308
+
309
+ private domainMatches(resultDomain: string, targetDomain: string): boolean {
310
+ const normalizedResult = resultDomain.toLowerCase().replace(/^www\./, '');
311
+ const normalizedTarget = targetDomain.toLowerCase().replace(/^www\./, '');
312
+
313
+ // Exact match or subdomain match
314
+ return normalizedResult === normalizedTarget ||
315
+ normalizedResult.endsWith(`.${normalizedTarget}`);
316
+ }
317
+ }
318
+
319
+ // ValueSERP response types
320
+ interface ValueSerpResponse {
321
+ organic_results?: Array<{
322
+ link: string;
323
+ title: string;
324
+ snippet?: string;
325
+ }>;
326
+ answer_box?: {
327
+ link?: string;
328
+ title?: string;
329
+ };
330
+ people_also_ask?: Array<{
331
+ question: string;
332
+ link?: string;
333
+ }>;
334
+ local_results?: Array<unknown>;
335
+ knowledge_graph?: unknown;
336
+ }
337
+
338
+ // SerpAPI response types
339
+ interface SerpApiResponse {
340
+ organic_results?: Array<{
341
+ position: number;
342
+ link: string;
343
+ title: string;
344
+ snippet?: string;
345
+ }>;
346
+ answer_box?: unknown;
347
+ related_questions?: Array<unknown>;
348
+ }