@rankcli/agent-runtime 0.0.13 → 0.0.14

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.
@@ -312,17 +312,17 @@ function generateRecommendations(
312
312
 
313
313
  if (framework === 'React') {
314
314
  recommendations.push(
315
- 'Quick fix: Add react-snap to pre-render pages at build time (npm install -D react-snap, add "postbuild": "react-snap" to scripts)'
315
+ 'Quick fix: Use Vike (vike.dev) for SSR/SSG - works with Vite, minimal config needed'
316
316
  );
317
317
  recommendations.push(
318
- 'Alternative: Use Vike (vite-plugin-ssr) for SSR/SSG without changing frameworks'
318
+ 'Alternative: Migrate to Next.js or Remix for built-in SSR/SSG support'
319
319
  );
320
320
  } else if (framework === 'Vue') {
321
321
  recommendations.push(
322
- 'Quick fix: Add prerender-spa-plugin to pre-render pages at build time'
322
+ 'Quick fix: Use Vike (vike.dev) for SSR/SSG - works with Vite, minimal config needed'
323
323
  );
324
324
  recommendations.push(
325
- 'Alternative: Use Vike (vite-plugin-ssr) for SSR/SSG without changing frameworks'
325
+ 'Alternative: Migrate to Nuxt for built-in SSR/SSG support'
326
326
  );
327
327
  } else if (framework === 'Angular Universal') {
328
328
  recommendations.push(
@@ -330,7 +330,7 @@ function generateRecommendations(
330
330
  );
331
331
  } else {
332
332
  recommendations.push(
333
- 'Quick fix: Use a pre-rendering tool like react-snap or prerender-spa-plugin'
333
+ 'Quick fix: Use Vike (vike.dev) for SSR/SSG with any Vite-based framework'
334
334
  );
335
335
  }
336
336
 
@@ -368,10 +368,10 @@ export function analyzeClientRendering(
368
368
  // Critical: Client-side only rendering
369
369
  if (analysis.renderingMethod === 'csr' && analysis.confidence !== 'low') {
370
370
  const howToFixByFramework = analysis.frameworkDetected === 'React'
371
- ? 'Add react-snap to pre-render pages: npm install -D react-snap, then add "postbuild": "react-snap" to package.json scripts. No code changes needed.'
371
+ ? 'Use Vike (vike.dev) to add SSR/SSG to your Vite React app: npm install vike vike-react, then follow the setup guide. Alternatively, migrate to Next.js or Remix.'
372
372
  : analysis.frameworkDetected === 'Vue'
373
- ? 'Add prerender-spa-plugin to pre-render pages at build time. Alternatively, use Vike for SSR/SSG.'
374
- : 'Pre-render your pages using react-snap, prerender-spa-plugin, or similar build-time tools.';
373
+ ? 'Use Vike (vike.dev) to add SSR/SSG to your Vite Vue app: npm install vike vike-vue, then follow the setup guide. Alternatively, migrate to Nuxt.'
374
+ : 'Use Vike (vike.dev) for SSR/SSG with Vite-based frameworks, or use a framework with built-in SSR support (Next.js, Nuxt, Remix, SvelteKit).';
375
375
 
376
376
  issues.push({
377
377
  code: 'CLIENT_SIDE_RENDERING',
@@ -431,8 +431,8 @@ export function analyzeClientRendering(
431
431
  'Single Page Applications without SSR have slower time-to-content for search crawlers.',
432
432
  howToFix:
433
433
  analysis.frameworkDetected === 'React'
434
- ? 'Quick fix: Add react-snap (npm install -D react-snap) to pre-render at build time. Alternative: Use Vike for SSR/SSG.'
435
- : 'Quick fix: Add prerender-spa-plugin to pre-render at build time. Alternative: Use Vike for SSR/SSG.',
434
+ ? 'Add SSR/SSG using Vike (vike.dev): npm install vike vike-react. Alternative: migrate to Next.js or Remix.'
435
+ : 'Add SSR/SSG using Vike (vike.dev): npm install vike vike-vue. Alternative: migrate to Nuxt.',
436
436
  affectedUrls: [url],
437
437
  details: {
438
438
  framework: analysis.frameworkDetected,
@@ -59,7 +59,15 @@ export async function analyzeSecurityHeaders(url: string): Promise<{ issues: Aud
59
59
  });
60
60
 
61
61
  const headers = response.headers;
62
- const isHttps = url.startsWith('https://');
62
+ // Use URL parsing for robust protocol detection (handles case variations like Https://)
63
+ let isHttps = false;
64
+ try {
65
+ const parsedUrl = new URL(url);
66
+ isHttps = parsedUrl.protocol === 'https:';
67
+ } catch {
68
+ // If URL parsing fails, fall back to startsWith check
69
+ isHttps = url.toLowerCase().startsWith('https://');
70
+ }
63
71
 
64
72
  // Extract security headers (case-insensitive)
65
73
  const getHeader = (name: string): string | null => {
@@ -151,10 +159,18 @@ export async function analyzeSecurityHeaders(url: string): Promise<{ issues: Aud
151
159
  },
152
160
  };
153
161
  } catch (error) {
162
+ // Use URL parsing for robust protocol detection in error case too
163
+ let isHttps = false;
164
+ try {
165
+ const parsedUrl = new URL(url);
166
+ isHttps = parsedUrl.protocol === 'https:';
167
+ } catch {
168
+ isHttps = url.toLowerCase().startsWith('https://');
169
+ }
154
170
  return {
155
171
  issues,
156
172
  data: {
157
- https: url.startsWith('https://'),
173
+ https: isHttps,
158
174
  headers: {
159
175
  hsts: null,
160
176
  csp: null,
@@ -123,6 +123,7 @@ export async function runFullAudit(options: AuditOptions): Promise<AuditReport>
123
123
  timeout: 30000,
124
124
  validateStatus: () => true,
125
125
  }).catch(err => {
126
+ console.error('Main page fetch failed:', err instanceof Error ? { message: err.message, cause: (err as NodeJS.ErrnoException).cause } : err);
126
127
  return { error: err, data: '', headers: {} as Record<string, string> };
127
128
  }),
128
129
  ]);
@@ -131,15 +132,21 @@ export async function runFullAudit(options: AuditOptions): Promise<AuditReport>
131
132
 
132
133
  // Check if fetch failed
133
134
  if ('error' in fetchResult) {
135
+ const err = fetchResult.error;
136
+ const errorMsg = err instanceof Error ? err.message : 'Unknown error';
137
+ const errorCause = err instanceof Error && (err as NodeJS.ErrnoException).cause
138
+ ? ` (${String((err as NodeJS.ErrnoException).cause)})`
139
+ : '';
134
140
  allIssues.push({
135
141
  code: 'FETCH_ERROR',
136
142
  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.',
143
+ category: 'indexability', // Not crawlability - robots/sitemap checks may have succeeded
144
+ title: 'Page fetch failed',
145
+ description: `Could not load page content: ${errorMsg}${errorCause}. Only robots.txt and sitemap checks were performed.`,
146
+ impact: 'Cannot perform full SEO audit without page HTML content.',
147
+ howToFix: 'Verify the URL is accessible, the server is responding, and there are no firewall/geo blocks.',
142
148
  affectedUrls: [url],
149
+ details: { error: errorMsg, cause: errorCause || undefined },
143
150
  });
144
151
  return createReport(url, domain, allIssues, pages);
145
152
  }
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
+ }