@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.
- package/dist/index.d.mts +215 -1
- package/dist/index.d.ts +215 -1
- package/dist/index.js +847 -90
- package/dist/index.mjs +813 -60
- package/package.json +2 -1
- package/src/audit/checks/agent-experience.ts +108 -0
- package/src/audit/checks/ai-readiness.ts +67 -0
- package/src/audit/checks/client-rendering.ts +10 -10
- package/src/audit/checks/rag-chunk-readiness.test.ts +159 -0
- package/src/audit/checks/rag-chunk-readiness.ts +163 -0
- package/src/audit/checks/security-headers.ts +18 -2
- package/src/audit/engine.ts +23 -10
- package/src/audit/types.ts +24 -0
- package/src/index.ts +3 -0
- package/src/ranking/index.ts +5 -0
- package/src/ranking/serp-client.ts +348 -0
- package/src/ranking/tracker.ts +380 -0
- package/src/ranking/types.ts +123 -0
package/src/audit/engine.ts
CHANGED
|
@@ -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
|
-
|
|
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.
|
|
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: '
|
|
138
|
-
title: '
|
|
139
|
-
description: `Could not
|
|
140
|
-
impact: 'Cannot perform full audit without page content.',
|
|
141
|
-
howToFix: '
|
|
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.
|
|
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.
|
|
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.
|
|
427
|
+
console.error('\nā
Audit complete!\n');
|
|
415
428
|
|
|
416
429
|
return createReport(url, domain, allIssues, pages);
|
|
417
430
|
}
|
package/src/audit/types.ts
CHANGED
|
@@ -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,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
|
+
}
|