@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.
- package/dist/index.d.mts +181 -1
- package/dist/index.d.ts +181 -1
- package/dist/index.js +578 -15
- package/dist/index.mjs +575 -15
- package/package.json +2 -1
- package/src/audit/checks/client-rendering.ts +10 -10
- package/src/audit/checks/security-headers.ts +18 -2
- package/src/audit/engine.ts +12 -5
- 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/dist/index.js
CHANGED
|
@@ -3306,9 +3306,12 @@ __export(index_exports, {
|
|
|
3306
3306
|
LOCATION_CODES: () => LOCATION_CODES,
|
|
3307
3307
|
OG_IMAGE_SPECS: () => OG_IMAGE_SPECS,
|
|
3308
3308
|
PRIORITY_WEIGHTS: () => PRIORITY_WEIGHTS,
|
|
3309
|
+
RankTracker: () => RankTracker,
|
|
3309
3310
|
SEO_SCOPES: () => SEO_SCOPES,
|
|
3310
3311
|
SITE_PROFILE_QUESTIONS: () => SITE_PROFILE_QUESTIONS,
|
|
3311
3312
|
Schemas: () => Schemas,
|
|
3313
|
+
SerpClient: () => SerpClient,
|
|
3314
|
+
TIER_LIMITS: () => TIER_LIMITS,
|
|
3312
3315
|
addTrackingResult: () => addTrackingResult,
|
|
3313
3316
|
analyzeAnchorText: () => analyzeAnchorText,
|
|
3314
3317
|
analyzeCanonicalAdvanced: () => analyzeCanonicalAdvanced,
|
|
@@ -8085,7 +8088,13 @@ async function analyzeSecurityHeaders(url) {
|
|
|
8085
8088
|
maxRedirects: 5
|
|
8086
8089
|
});
|
|
8087
8090
|
const headers = response.headers;
|
|
8088
|
-
|
|
8091
|
+
let isHttps = false;
|
|
8092
|
+
try {
|
|
8093
|
+
const parsedUrl = new URL(url);
|
|
8094
|
+
isHttps = parsedUrl.protocol === "https:";
|
|
8095
|
+
} catch {
|
|
8096
|
+
isHttps = url.toLowerCase().startsWith("https://");
|
|
8097
|
+
}
|
|
8089
8098
|
const getHeader = (name) => {
|
|
8090
8099
|
const key = Object.keys(headers).find((k) => k.toLowerCase() === name.toLowerCase());
|
|
8091
8100
|
return key ? headers[key] : null;
|
|
@@ -8157,10 +8166,17 @@ async function analyzeSecurityHeaders(url) {
|
|
|
8157
8166
|
}
|
|
8158
8167
|
};
|
|
8159
8168
|
} catch (error) {
|
|
8169
|
+
let isHttps = false;
|
|
8170
|
+
try {
|
|
8171
|
+
const parsedUrl = new URL(url);
|
|
8172
|
+
isHttps = parsedUrl.protocol === "https:";
|
|
8173
|
+
} catch {
|
|
8174
|
+
isHttps = url.toLowerCase().startsWith("https://");
|
|
8175
|
+
}
|
|
8160
8176
|
return {
|
|
8161
8177
|
issues,
|
|
8162
8178
|
data: {
|
|
8163
|
-
https:
|
|
8179
|
+
https: isHttps,
|
|
8164
8180
|
headers: {
|
|
8165
8181
|
hsts: null,
|
|
8166
8182
|
csp: null,
|
|
@@ -12199,17 +12215,17 @@ function generateRecommendations2(renderingMethod, hasContentInHTML, charCount,
|
|
|
12199
12215
|
);
|
|
12200
12216
|
if (framework === "React") {
|
|
12201
12217
|
recommendations.push(
|
|
12202
|
-
|
|
12218
|
+
"Quick fix: Use Vike (vike.dev) for SSR/SSG - works with Vite, minimal config needed"
|
|
12203
12219
|
);
|
|
12204
12220
|
recommendations.push(
|
|
12205
|
-
"Alternative:
|
|
12221
|
+
"Alternative: Migrate to Next.js or Remix for built-in SSR/SSG support"
|
|
12206
12222
|
);
|
|
12207
12223
|
} else if (framework === "Vue") {
|
|
12208
12224
|
recommendations.push(
|
|
12209
|
-
"Quick fix:
|
|
12225
|
+
"Quick fix: Use Vike (vike.dev) for SSR/SSG - works with Vite, minimal config needed"
|
|
12210
12226
|
);
|
|
12211
12227
|
recommendations.push(
|
|
12212
|
-
"Alternative:
|
|
12228
|
+
"Alternative: Migrate to Nuxt for built-in SSR/SSG support"
|
|
12213
12229
|
);
|
|
12214
12230
|
} else if (framework === "Angular Universal") {
|
|
12215
12231
|
recommendations.push(
|
|
@@ -12217,7 +12233,7 @@ function generateRecommendations2(renderingMethod, hasContentInHTML, charCount,
|
|
|
12217
12233
|
);
|
|
12218
12234
|
} else {
|
|
12219
12235
|
recommendations.push(
|
|
12220
|
-
"Quick fix: Use
|
|
12236
|
+
"Quick fix: Use Vike (vike.dev) for SSR/SSG with any Vite-based framework"
|
|
12221
12237
|
);
|
|
12222
12238
|
}
|
|
12223
12239
|
recommendations.push(
|
|
@@ -12241,7 +12257,7 @@ function analyzeClientRendering(html, url) {
|
|
|
12241
12257
|
const issues = [];
|
|
12242
12258
|
const analysis = analyzeRendering(html);
|
|
12243
12259
|
if (analysis.renderingMethod === "csr" && analysis.confidence !== "low") {
|
|
12244
|
-
const howToFixByFramework = analysis.frameworkDetected === "React" ?
|
|
12260
|
+
const howToFixByFramework = analysis.frameworkDetected === "React" ? "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." : analysis.frameworkDetected === "Vue" ? "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." : "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).";
|
|
12245
12261
|
issues.push({
|
|
12246
12262
|
code: "CLIENT_SIDE_RENDERING",
|
|
12247
12263
|
severity: "error",
|
|
@@ -12282,7 +12298,7 @@ function analyzeClientRendering(html, url) {
|
|
|
12282
12298
|
title: `${analysis.frameworkDetected} detected without SSR markers`,
|
|
12283
12299
|
description: `This appears to be a ${analysis.frameworkDetected} SPA without server-side rendering enabled.`,
|
|
12284
12300
|
impact: "Single Page Applications without SSR have slower time-to-content for search crawlers.",
|
|
12285
|
-
howToFix: analysis.frameworkDetected === "React" ? "
|
|
12301
|
+
howToFix: analysis.frameworkDetected === "React" ? "Add SSR/SSG using Vike (vike.dev): npm install vike vike-react. Alternative: migrate to Next.js or Remix." : "Add SSR/SSG using Vike (vike.dev): npm install vike vike-vue. Alternative: migrate to Nuxt.",
|
|
12286
12302
|
affectedUrls: [url],
|
|
12287
12303
|
details: {
|
|
12288
12304
|
framework: analysis.frameworkDetected,
|
|
@@ -20207,20 +20223,26 @@ async function runFullAudit(options) {
|
|
|
20207
20223
|
timeout: 3e4,
|
|
20208
20224
|
validateStatus: () => true
|
|
20209
20225
|
}).catch((err) => {
|
|
20226
|
+
console.error("Main page fetch failed:", err instanceof Error ? { message: err.message, cause: err.cause } : err);
|
|
20210
20227
|
return { error: err, data: "", headers: {} };
|
|
20211
20228
|
})
|
|
20212
20229
|
]);
|
|
20213
20230
|
allIssues.push(...crawlabilityResult);
|
|
20214
20231
|
if ("error" in fetchResult) {
|
|
20232
|
+
const err = fetchResult.error;
|
|
20233
|
+
const errorMsg = err instanceof Error ? err.message : "Unknown error";
|
|
20234
|
+
const errorCause = err instanceof Error && err.cause ? ` (${String(err.cause)})` : "";
|
|
20215
20235
|
allIssues.push({
|
|
20216
20236
|
code: "FETCH_ERROR",
|
|
20217
20237
|
severity: "error",
|
|
20218
|
-
category: "
|
|
20219
|
-
|
|
20220
|
-
|
|
20221
|
-
|
|
20222
|
-
|
|
20223
|
-
|
|
20238
|
+
category: "indexability",
|
|
20239
|
+
// Not crawlability - robots/sitemap checks may have succeeded
|
|
20240
|
+
title: "Page fetch failed",
|
|
20241
|
+
description: `Could not load page content: ${errorMsg}${errorCause}. Only robots.txt and sitemap checks were performed.`,
|
|
20242
|
+
impact: "Cannot perform full SEO audit without page HTML content.",
|
|
20243
|
+
howToFix: "Verify the URL is accessible, the server is responding, and there are no firewall/geo blocks.",
|
|
20244
|
+
affectedUrls: [url],
|
|
20245
|
+
details: { error: errorMsg, cause: errorCause || void 0 }
|
|
20224
20246
|
});
|
|
20225
20247
|
return createReport(url, domain, allIssues, pages);
|
|
20226
20248
|
}
|
|
@@ -37315,6 +37337,544 @@ function getAIVisibilitySummary(results) {
|
|
|
37315
37337
|
};
|
|
37316
37338
|
}
|
|
37317
37339
|
|
|
37340
|
+
// src/ranking/types.ts
|
|
37341
|
+
var TIER_LIMITS = {
|
|
37342
|
+
free: {
|
|
37343
|
+
maxKeywords: 10,
|
|
37344
|
+
checksPerDay: 1,
|
|
37345
|
+
serpFeatures: false,
|
|
37346
|
+
competitorTracking: false,
|
|
37347
|
+
historyDays: 7
|
|
37348
|
+
},
|
|
37349
|
+
solo: {
|
|
37350
|
+
maxKeywords: 100,
|
|
37351
|
+
checksPerDay: 1,
|
|
37352
|
+
serpFeatures: true,
|
|
37353
|
+
competitorTracking: false,
|
|
37354
|
+
historyDays: 30
|
|
37355
|
+
},
|
|
37356
|
+
pro: {
|
|
37357
|
+
maxKeywords: 500,
|
|
37358
|
+
checksPerDay: 2,
|
|
37359
|
+
serpFeatures: true,
|
|
37360
|
+
competitorTracking: true,
|
|
37361
|
+
historyDays: 90
|
|
37362
|
+
},
|
|
37363
|
+
agency: {
|
|
37364
|
+
maxKeywords: 2e3,
|
|
37365
|
+
checksPerDay: 4,
|
|
37366
|
+
serpFeatures: true,
|
|
37367
|
+
competitorTracking: true,
|
|
37368
|
+
historyDays: 365
|
|
37369
|
+
}
|
|
37370
|
+
};
|
|
37371
|
+
|
|
37372
|
+
// src/ranking/serp-client.ts
|
|
37373
|
+
var SerpClient = class {
|
|
37374
|
+
config;
|
|
37375
|
+
constructor(config) {
|
|
37376
|
+
this.config = config;
|
|
37377
|
+
}
|
|
37378
|
+
/**
|
|
37379
|
+
* Check ranking for a single keyword
|
|
37380
|
+
*/
|
|
37381
|
+
async checkRank(options) {
|
|
37382
|
+
const results = [];
|
|
37383
|
+
for (const keyword of options.keywords) {
|
|
37384
|
+
try {
|
|
37385
|
+
const result = await this.checkSingleKeyword({
|
|
37386
|
+
...options,
|
|
37387
|
+
keyword
|
|
37388
|
+
});
|
|
37389
|
+
results.push(result);
|
|
37390
|
+
} catch (error) {
|
|
37391
|
+
console.error(`Error checking rank for "${keyword}":`, error);
|
|
37392
|
+
results.push({
|
|
37393
|
+
keyword,
|
|
37394
|
+
position: null,
|
|
37395
|
+
url: null,
|
|
37396
|
+
serpFeatures: [],
|
|
37397
|
+
topResults: [],
|
|
37398
|
+
checkedAt: /* @__PURE__ */ new Date()
|
|
37399
|
+
});
|
|
37400
|
+
}
|
|
37401
|
+
}
|
|
37402
|
+
return results;
|
|
37403
|
+
}
|
|
37404
|
+
async checkSingleKeyword(options) {
|
|
37405
|
+
switch (this.config.provider) {
|
|
37406
|
+
case "valueserp":
|
|
37407
|
+
return this.checkViaValueSerp(options);
|
|
37408
|
+
case "serpapi":
|
|
37409
|
+
return this.checkViaSerpApi(options);
|
|
37410
|
+
case "direct":
|
|
37411
|
+
default:
|
|
37412
|
+
return this.checkViaDirect(options);
|
|
37413
|
+
}
|
|
37414
|
+
}
|
|
37415
|
+
/**
|
|
37416
|
+
* ValueSERP API implementation
|
|
37417
|
+
* Docs: https://www.valueserp.com/docs
|
|
37418
|
+
*/
|
|
37419
|
+
async checkViaValueSerp(options) {
|
|
37420
|
+
if (!this.config.apiKey) {
|
|
37421
|
+
throw new Error("ValueSERP API key required");
|
|
37422
|
+
}
|
|
37423
|
+
const params = new URLSearchParams({
|
|
37424
|
+
api_key: this.config.apiKey,
|
|
37425
|
+
q: options.keyword,
|
|
37426
|
+
location: options.country || "United States",
|
|
37427
|
+
google_domain: options.country === "US" ? "google.com" : `google.${options.country?.toLowerCase() || "com"}`,
|
|
37428
|
+
gl: options.country || "us",
|
|
37429
|
+
hl: options.language || "en",
|
|
37430
|
+
device: options.device || "desktop",
|
|
37431
|
+
num: "100"
|
|
37432
|
+
// Get top 100 results
|
|
37433
|
+
});
|
|
37434
|
+
const response = await fetch(`https://api.valueserp.com/search?${params}`);
|
|
37435
|
+
if (!response.ok) {
|
|
37436
|
+
throw new Error(`ValueSERP API error: ${response.status}`);
|
|
37437
|
+
}
|
|
37438
|
+
const data = await response.json();
|
|
37439
|
+
return this.parseValueSerpResponse(data, options.domain, options.keyword);
|
|
37440
|
+
}
|
|
37441
|
+
/**
|
|
37442
|
+
* SerpAPI implementation (alternative)
|
|
37443
|
+
* Docs: https://serpapi.com/search-api
|
|
37444
|
+
*/
|
|
37445
|
+
async checkViaSerpApi(options) {
|
|
37446
|
+
if (!this.config.apiKey) {
|
|
37447
|
+
throw new Error("SerpAPI key required");
|
|
37448
|
+
}
|
|
37449
|
+
const params = new URLSearchParams({
|
|
37450
|
+
api_key: this.config.apiKey,
|
|
37451
|
+
q: options.keyword,
|
|
37452
|
+
location: options.country === "US" ? "United States" : options.country || "United States",
|
|
37453
|
+
gl: options.country?.toLowerCase() || "us",
|
|
37454
|
+
hl: options.language || "en",
|
|
37455
|
+
device: options.device || "desktop",
|
|
37456
|
+
num: "100"
|
|
37457
|
+
});
|
|
37458
|
+
const response = await fetch(`https://serpapi.com/search?${params}`);
|
|
37459
|
+
if (!response.ok) {
|
|
37460
|
+
throw new Error(`SerpAPI error: ${response.status}`);
|
|
37461
|
+
}
|
|
37462
|
+
const data = await response.json();
|
|
37463
|
+
return this.parseSerpApiResponse(data, options.domain, options.keyword);
|
|
37464
|
+
}
|
|
37465
|
+
/**
|
|
37466
|
+
* Direct scraping fallback (rate-limited, use with caution)
|
|
37467
|
+
*/
|
|
37468
|
+
async checkViaDirect(options) {
|
|
37469
|
+
const searchUrl = `https://www.google.com/search?q=${encodeURIComponent(options.keyword)}&num=100`;
|
|
37470
|
+
const headers = {
|
|
37471
|
+
"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",
|
|
37472
|
+
"Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
|
|
37473
|
+
"Accept-Language": options.language || "en-US,en;q=0.9"
|
|
37474
|
+
};
|
|
37475
|
+
const response = await fetch(searchUrl, { headers });
|
|
37476
|
+
if (!response.ok) {
|
|
37477
|
+
throw new Error(`Direct search failed: ${response.status}`);
|
|
37478
|
+
}
|
|
37479
|
+
const html = await response.text();
|
|
37480
|
+
return this.parseDirectSearchResults(html, options.domain, options.keyword);
|
|
37481
|
+
}
|
|
37482
|
+
parseValueSerpResponse(data, domain, keyword) {
|
|
37483
|
+
const topResults = [];
|
|
37484
|
+
const serpFeatures = [];
|
|
37485
|
+
let position = null;
|
|
37486
|
+
let url = null;
|
|
37487
|
+
if (data.organic_results) {
|
|
37488
|
+
for (let i = 0; i < data.organic_results.length; i++) {
|
|
37489
|
+
const result = data.organic_results[i];
|
|
37490
|
+
const resultDomain = this.extractDomain(result.link);
|
|
37491
|
+
topResults.push({
|
|
37492
|
+
position: i + 1,
|
|
37493
|
+
url: result.link,
|
|
37494
|
+
domain: resultDomain,
|
|
37495
|
+
title: result.title
|
|
37496
|
+
});
|
|
37497
|
+
if (this.domainMatches(resultDomain, domain) && position === null) {
|
|
37498
|
+
position = i + 1;
|
|
37499
|
+
url = result.link;
|
|
37500
|
+
}
|
|
37501
|
+
}
|
|
37502
|
+
}
|
|
37503
|
+
if (data.answer_box) {
|
|
37504
|
+
serpFeatures.push({
|
|
37505
|
+
type: "featured_snippet",
|
|
37506
|
+
position: 0,
|
|
37507
|
+
hasOwnSite: this.domainMatches(this.extractDomain(data.answer_box.link || ""), domain)
|
|
37508
|
+
});
|
|
37509
|
+
}
|
|
37510
|
+
if (data.people_also_ask) {
|
|
37511
|
+
serpFeatures.push({
|
|
37512
|
+
type: "people_also_ask",
|
|
37513
|
+
hasOwnSite: data.people_also_ask.some(
|
|
37514
|
+
(paa) => this.domainMatches(this.extractDomain(paa.link || ""), domain)
|
|
37515
|
+
)
|
|
37516
|
+
});
|
|
37517
|
+
}
|
|
37518
|
+
if (data.local_results) {
|
|
37519
|
+
serpFeatures.push({ type: "local_pack" });
|
|
37520
|
+
}
|
|
37521
|
+
if (data.knowledge_graph) {
|
|
37522
|
+
serpFeatures.push({ type: "knowledge_panel" });
|
|
37523
|
+
}
|
|
37524
|
+
return {
|
|
37525
|
+
keyword,
|
|
37526
|
+
position,
|
|
37527
|
+
url,
|
|
37528
|
+
serpFeatures,
|
|
37529
|
+
topResults: topResults.slice(0, 10),
|
|
37530
|
+
checkedAt: /* @__PURE__ */ new Date()
|
|
37531
|
+
};
|
|
37532
|
+
}
|
|
37533
|
+
parseSerpApiResponse(data, domain, keyword) {
|
|
37534
|
+
const topResults = [];
|
|
37535
|
+
const serpFeatures = [];
|
|
37536
|
+
let position = null;
|
|
37537
|
+
let url = null;
|
|
37538
|
+
if (data.organic_results) {
|
|
37539
|
+
for (let i = 0; i < data.organic_results.length; i++) {
|
|
37540
|
+
const result = data.organic_results[i];
|
|
37541
|
+
const resultDomain = this.extractDomain(result.link);
|
|
37542
|
+
topResults.push({
|
|
37543
|
+
position: result.position || i + 1,
|
|
37544
|
+
url: result.link,
|
|
37545
|
+
domain: resultDomain,
|
|
37546
|
+
title: result.title
|
|
37547
|
+
});
|
|
37548
|
+
if (this.domainMatches(resultDomain, domain) && position === null) {
|
|
37549
|
+
position = result.position || i + 1;
|
|
37550
|
+
url = result.link;
|
|
37551
|
+
}
|
|
37552
|
+
}
|
|
37553
|
+
}
|
|
37554
|
+
if (data.answer_box) {
|
|
37555
|
+
serpFeatures.push({
|
|
37556
|
+
type: "featured_snippet",
|
|
37557
|
+
position: 0
|
|
37558
|
+
});
|
|
37559
|
+
}
|
|
37560
|
+
if (data.related_questions) {
|
|
37561
|
+
serpFeatures.push({ type: "people_also_ask" });
|
|
37562
|
+
}
|
|
37563
|
+
return {
|
|
37564
|
+
keyword,
|
|
37565
|
+
position,
|
|
37566
|
+
url,
|
|
37567
|
+
serpFeatures,
|
|
37568
|
+
topResults: topResults.slice(0, 10),
|
|
37569
|
+
checkedAt: /* @__PURE__ */ new Date()
|
|
37570
|
+
};
|
|
37571
|
+
}
|
|
37572
|
+
parseDirectSearchResults(html, domain, keyword) {
|
|
37573
|
+
const topResults = [];
|
|
37574
|
+
let position = null;
|
|
37575
|
+
let url = null;
|
|
37576
|
+
const linkRegex = /<a[^>]+href="\/url\?q=([^"&]+)/g;
|
|
37577
|
+
let match;
|
|
37578
|
+
let index = 0;
|
|
37579
|
+
while ((match = linkRegex.exec(html)) !== null && index < 100) {
|
|
37580
|
+
try {
|
|
37581
|
+
const decodedUrl = decodeURIComponent(match[1]);
|
|
37582
|
+
const resultDomain = this.extractDomain(decodedUrl);
|
|
37583
|
+
if (resultDomain.includes("google.com") || resultDomain.includes("gstatic.com")) {
|
|
37584
|
+
continue;
|
|
37585
|
+
}
|
|
37586
|
+
index++;
|
|
37587
|
+
topResults.push({
|
|
37588
|
+
position: index,
|
|
37589
|
+
url: decodedUrl,
|
|
37590
|
+
domain: resultDomain
|
|
37591
|
+
});
|
|
37592
|
+
if (this.domainMatches(resultDomain, domain) && position === null) {
|
|
37593
|
+
position = index;
|
|
37594
|
+
url = decodedUrl;
|
|
37595
|
+
}
|
|
37596
|
+
} catch {
|
|
37597
|
+
}
|
|
37598
|
+
}
|
|
37599
|
+
return {
|
|
37600
|
+
keyword,
|
|
37601
|
+
position,
|
|
37602
|
+
url,
|
|
37603
|
+
serpFeatures: [],
|
|
37604
|
+
// Direct scraping doesn't easily extract SERP features
|
|
37605
|
+
topResults: topResults.slice(0, 10),
|
|
37606
|
+
checkedAt: /* @__PURE__ */ new Date()
|
|
37607
|
+
};
|
|
37608
|
+
}
|
|
37609
|
+
extractDomain(url) {
|
|
37610
|
+
try {
|
|
37611
|
+
const parsed = new URL(url);
|
|
37612
|
+
return parsed.hostname.replace(/^www\./, "");
|
|
37613
|
+
} catch {
|
|
37614
|
+
return "";
|
|
37615
|
+
}
|
|
37616
|
+
}
|
|
37617
|
+
domainMatches(resultDomain, targetDomain) {
|
|
37618
|
+
const normalizedResult = resultDomain.toLowerCase().replace(/^www\./, "");
|
|
37619
|
+
const normalizedTarget = targetDomain.toLowerCase().replace(/^www\./, "");
|
|
37620
|
+
return normalizedResult === normalizedTarget || normalizedResult.endsWith(`.${normalizedTarget}`);
|
|
37621
|
+
}
|
|
37622
|
+
};
|
|
37623
|
+
|
|
37624
|
+
// src/ranking/tracker.ts
|
|
37625
|
+
var RankTracker = class {
|
|
37626
|
+
supabase;
|
|
37627
|
+
serpClient;
|
|
37628
|
+
constructor(config) {
|
|
37629
|
+
this.supabase = config.supabase;
|
|
37630
|
+
this.serpClient = new SerpClient(config.serpConfig);
|
|
37631
|
+
}
|
|
37632
|
+
/**
|
|
37633
|
+
* Add keywords to track for a project
|
|
37634
|
+
*/
|
|
37635
|
+
async addKeywords(projectId, keywords, options) {
|
|
37636
|
+
const keywordRecords = keywords.map((keyword) => ({
|
|
37637
|
+
project_id: projectId,
|
|
37638
|
+
keyword: keyword.toLowerCase().trim(),
|
|
37639
|
+
search_engine: options?.searchEngine || "google",
|
|
37640
|
+
country: options?.country || "US",
|
|
37641
|
+
language: options?.language || "en",
|
|
37642
|
+
track_url: options?.trackUrl,
|
|
37643
|
+
is_active: true
|
|
37644
|
+
}));
|
|
37645
|
+
const { data, error } = await this.supabase.from("keywords").upsert(keywordRecords, {
|
|
37646
|
+
onConflict: "project_id,keyword",
|
|
37647
|
+
ignoreDuplicates: false
|
|
37648
|
+
}).select();
|
|
37649
|
+
if (error) {
|
|
37650
|
+
throw new Error(`Failed to add keywords: ${error.message}`);
|
|
37651
|
+
}
|
|
37652
|
+
return (data || []).map(this.mapKeyword);
|
|
37653
|
+
}
|
|
37654
|
+
/**
|
|
37655
|
+
* Remove keywords from tracking
|
|
37656
|
+
*/
|
|
37657
|
+
async removeKeywords(projectId, keywords) {
|
|
37658
|
+
const normalizedKeywords = keywords.map((k) => k.toLowerCase().trim());
|
|
37659
|
+
const { error } = await this.supabase.from("keywords").update({ is_active: false }).eq("project_id", projectId).in("keyword", normalizedKeywords);
|
|
37660
|
+
if (error) {
|
|
37661
|
+
throw new Error(`Failed to remove keywords: ${error.message}`);
|
|
37662
|
+
}
|
|
37663
|
+
}
|
|
37664
|
+
/**
|
|
37665
|
+
* Get all tracked keywords for a project
|
|
37666
|
+
*/
|
|
37667
|
+
async getKeywords(projectId, includeInactive = false) {
|
|
37668
|
+
let query = this.supabase.from("keywords").select("*").eq("project_id", projectId);
|
|
37669
|
+
if (!includeInactive) {
|
|
37670
|
+
query = query.eq("is_active", true);
|
|
37671
|
+
}
|
|
37672
|
+
const { data, error } = await query;
|
|
37673
|
+
if (error) {
|
|
37674
|
+
throw new Error(`Failed to get keywords: ${error.message}`);
|
|
37675
|
+
}
|
|
37676
|
+
return (data || []).map(this.mapKeyword);
|
|
37677
|
+
}
|
|
37678
|
+
/**
|
|
37679
|
+
* Check rankings for all active keywords in a project
|
|
37680
|
+
*/
|
|
37681
|
+
async checkRankings(projectId, domain) {
|
|
37682
|
+
const keywords = await this.getKeywords(projectId);
|
|
37683
|
+
if (keywords.length === 0) {
|
|
37684
|
+
return [];
|
|
37685
|
+
}
|
|
37686
|
+
const groups = this.groupKeywords(keywords);
|
|
37687
|
+
const results = [];
|
|
37688
|
+
for (const group of groups) {
|
|
37689
|
+
const checkResults = await this.serpClient.checkRank({
|
|
37690
|
+
keywords: group.keywords.map((k) => k.keyword),
|
|
37691
|
+
domain,
|
|
37692
|
+
searchEngine: group.searchEngine,
|
|
37693
|
+
country: group.country,
|
|
37694
|
+
language: group.language
|
|
37695
|
+
});
|
|
37696
|
+
for (const result of checkResults) {
|
|
37697
|
+
const keyword = group.keywords.find((k) => k.keyword === result.keyword);
|
|
37698
|
+
if (!keyword) continue;
|
|
37699
|
+
await this.saveRanking(keyword.id, result);
|
|
37700
|
+
results.push({
|
|
37701
|
+
keywordId: keyword.id,
|
|
37702
|
+
keyword: result.keyword,
|
|
37703
|
+
position: result.position,
|
|
37704
|
+
url: result.url,
|
|
37705
|
+
serpFeatures: result.serpFeatures,
|
|
37706
|
+
competitorUrls: result.topResults,
|
|
37707
|
+
checkedAt: result.checkedAt
|
|
37708
|
+
});
|
|
37709
|
+
}
|
|
37710
|
+
}
|
|
37711
|
+
await this.supabase.from("projects").update({ last_rank_check_at: (/* @__PURE__ */ new Date()).toISOString() }).eq("id", projectId);
|
|
37712
|
+
return results;
|
|
37713
|
+
}
|
|
37714
|
+
/**
|
|
37715
|
+
* Save a ranking result to the database
|
|
37716
|
+
*/
|
|
37717
|
+
async saveRanking(keywordId, result) {
|
|
37718
|
+
const { data: currentKeyword } = await this.supabase.from("keywords").select("current_position, best_position").eq("id", keywordId).single();
|
|
37719
|
+
const previousPosition = currentKeyword?.current_position;
|
|
37720
|
+
const bestPosition = currentKeyword?.best_position;
|
|
37721
|
+
const newBestPosition = result.position !== null && (bestPosition === null || result.position < bestPosition) ? result.position : bestPosition;
|
|
37722
|
+
const { error: updateError } = await this.supabase.from("keywords").update({
|
|
37723
|
+
previous_position: previousPosition,
|
|
37724
|
+
current_position: result.position,
|
|
37725
|
+
best_position: newBestPosition,
|
|
37726
|
+
last_checked: result.checkedAt.toISOString()
|
|
37727
|
+
}).eq("id", keywordId);
|
|
37728
|
+
if (updateError) {
|
|
37729
|
+
console.error(`Failed to save ranking for ${result.keyword}:`, updateError);
|
|
37730
|
+
}
|
|
37731
|
+
}
|
|
37732
|
+
/**
|
|
37733
|
+
* Get ranking history for a keyword
|
|
37734
|
+
* Note: Full history requires keyword_ranking_history table with keyword_ranking_id
|
|
37735
|
+
* For now, returns current state as single history entry
|
|
37736
|
+
*/
|
|
37737
|
+
async getHistory(keywordId, _days = 30) {
|
|
37738
|
+
const { data, error } = await this.supabase.from("keywords").select("current_position, target_url, last_checked").eq("id", keywordId).single();
|
|
37739
|
+
if (error || !data) {
|
|
37740
|
+
return [];
|
|
37741
|
+
}
|
|
37742
|
+
return [{
|
|
37743
|
+
position: data.current_position,
|
|
37744
|
+
url: data.target_url,
|
|
37745
|
+
serpFeatures: [],
|
|
37746
|
+
recordedAt: data.last_checked ? new Date(data.last_checked) : /* @__PURE__ */ new Date()
|
|
37747
|
+
}];
|
|
37748
|
+
}
|
|
37749
|
+
/**
|
|
37750
|
+
* Get keyword trends for a project
|
|
37751
|
+
*/
|
|
37752
|
+
async getTrends(projectId) {
|
|
37753
|
+
const { data, error } = await this.supabase.from("keyword_rank_trends").select("*").eq("project_id", projectId);
|
|
37754
|
+
if (error) {
|
|
37755
|
+
return this.calculateTrends(projectId);
|
|
37756
|
+
}
|
|
37757
|
+
return (data || []).map((row) => ({
|
|
37758
|
+
keywordId: row.keyword_id,
|
|
37759
|
+
keyword: row.keyword,
|
|
37760
|
+
currentPosition: row.current_position,
|
|
37761
|
+
bestPosition: row.best_position,
|
|
37762
|
+
positionChange: row.position_change || 0,
|
|
37763
|
+
avgPosition: row.avg_position || 0,
|
|
37764
|
+
dataPoints: row.data_points || 0
|
|
37765
|
+
}));
|
|
37766
|
+
}
|
|
37767
|
+
/**
|
|
37768
|
+
* Manual trend calculation fallback
|
|
37769
|
+
*/
|
|
37770
|
+
async calculateTrends(projectId) {
|
|
37771
|
+
const keywords = await this.getKeywords(projectId);
|
|
37772
|
+
const trends = [];
|
|
37773
|
+
for (const keyword of keywords) {
|
|
37774
|
+
const history = await this.getHistory(keyword.id, 30);
|
|
37775
|
+
if (history.length === 0) {
|
|
37776
|
+
trends.push({
|
|
37777
|
+
keywordId: keyword.id,
|
|
37778
|
+
keyword: keyword.keyword,
|
|
37779
|
+
currentPosition: keyword.currentPosition,
|
|
37780
|
+
bestPosition: keyword.bestPosition,
|
|
37781
|
+
positionChange: 0,
|
|
37782
|
+
avgPosition: keyword.currentPosition || 0,
|
|
37783
|
+
dataPoints: 0
|
|
37784
|
+
});
|
|
37785
|
+
continue;
|
|
37786
|
+
}
|
|
37787
|
+
const positions = history.filter((h) => h.position !== null).map((h) => h.position);
|
|
37788
|
+
const avgPosition = positions.length > 0 ? positions.reduce((a, b) => a + b, 0) / positions.length : 0;
|
|
37789
|
+
const positionChange = keyword.previousPosition && keyword.currentPosition ? keyword.previousPosition - keyword.currentPosition : 0;
|
|
37790
|
+
trends.push({
|
|
37791
|
+
keywordId: keyword.id,
|
|
37792
|
+
keyword: keyword.keyword,
|
|
37793
|
+
currentPosition: keyword.currentPosition,
|
|
37794
|
+
bestPosition: keyword.bestPosition,
|
|
37795
|
+
positionChange,
|
|
37796
|
+
avgPosition,
|
|
37797
|
+
dataPoints: history.length
|
|
37798
|
+
});
|
|
37799
|
+
}
|
|
37800
|
+
return trends;
|
|
37801
|
+
}
|
|
37802
|
+
/**
|
|
37803
|
+
* Export ranking data as CSV
|
|
37804
|
+
*/
|
|
37805
|
+
async exportCSV(projectId, days = 30) {
|
|
37806
|
+
const keywords = await this.getKeywords(projectId);
|
|
37807
|
+
const rows = ["Keyword,Current Position,Best Position,Last Checked,Trend"];
|
|
37808
|
+
for (const keyword of keywords) {
|
|
37809
|
+
const history = await this.getHistory(keyword.id, days);
|
|
37810
|
+
const trend = this.calculatePositionTrend(history);
|
|
37811
|
+
rows.push([
|
|
37812
|
+
`"${keyword.keyword}"`,
|
|
37813
|
+
keyword.currentPosition?.toString() || "N/A",
|
|
37814
|
+
keyword.bestPosition?.toString() || "N/A",
|
|
37815
|
+
keyword.lastChecked?.toISOString() || "Never",
|
|
37816
|
+
trend
|
|
37817
|
+
].join(","));
|
|
37818
|
+
}
|
|
37819
|
+
return rows.join("\n");
|
|
37820
|
+
}
|
|
37821
|
+
/**
|
|
37822
|
+
* Calculate position trend from history
|
|
37823
|
+
*/
|
|
37824
|
+
calculatePositionTrend(history) {
|
|
37825
|
+
if (history.length < 2) return "\u2192";
|
|
37826
|
+
const recent = history[0]?.position;
|
|
37827
|
+
const older = history[history.length - 1]?.position;
|
|
37828
|
+
if (recent === null || older === null) return "\u2192";
|
|
37829
|
+
if (recent < older) return "\u2191";
|
|
37830
|
+
if (recent > older) return "\u2193";
|
|
37831
|
+
return "\u2192";
|
|
37832
|
+
}
|
|
37833
|
+
/**
|
|
37834
|
+
* Group keywords by search engine and country
|
|
37835
|
+
*/
|
|
37836
|
+
groupKeywords(keywords) {
|
|
37837
|
+
const groups = /* @__PURE__ */ new Map();
|
|
37838
|
+
for (const keyword of keywords) {
|
|
37839
|
+
const key = `${keyword.searchEngine}:${keyword.country}:${keyword.language}`;
|
|
37840
|
+
if (!groups.has(key)) {
|
|
37841
|
+
groups.set(key, []);
|
|
37842
|
+
}
|
|
37843
|
+
groups.get(key).push(keyword);
|
|
37844
|
+
}
|
|
37845
|
+
return Array.from(groups.entries()).map(([key, keywords2]) => {
|
|
37846
|
+
const [searchEngine, country, language] = key.split(":");
|
|
37847
|
+
return {
|
|
37848
|
+
searchEngine,
|
|
37849
|
+
country,
|
|
37850
|
+
language,
|
|
37851
|
+
keywords: keywords2
|
|
37852
|
+
};
|
|
37853
|
+
});
|
|
37854
|
+
}
|
|
37855
|
+
/**
|
|
37856
|
+
* Map database row to TrackedKeyword
|
|
37857
|
+
*/
|
|
37858
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
37859
|
+
mapKeyword(row) {
|
|
37860
|
+
return {
|
|
37861
|
+
id: row.id,
|
|
37862
|
+
projectId: row.project_id,
|
|
37863
|
+
keyword: row.keyword,
|
|
37864
|
+
searchEngine: row.search_engine || "google",
|
|
37865
|
+
country: row.country || "US",
|
|
37866
|
+
language: row.language || "en",
|
|
37867
|
+
currentPosition: row.current_position,
|
|
37868
|
+
previousPosition: row.previous_position,
|
|
37869
|
+
bestPosition: row.best_position,
|
|
37870
|
+
trackUrl: row.track_url,
|
|
37871
|
+
isActive: row.is_active,
|
|
37872
|
+
lastChecked: row.last_checked ? new Date(row.last_checked) : null,
|
|
37873
|
+
createdAt: new Date(row.created_at)
|
|
37874
|
+
};
|
|
37875
|
+
}
|
|
37876
|
+
};
|
|
37877
|
+
|
|
37318
37878
|
// src/analyzers/index.ts
|
|
37319
37879
|
var analyzers_exports = {};
|
|
37320
37880
|
__export(analyzers_exports, {
|
|
@@ -38506,9 +39066,12 @@ if (typeof globalThis !== "undefined") {
|
|
|
38506
39066
|
LOCATION_CODES,
|
|
38507
39067
|
OG_IMAGE_SPECS,
|
|
38508
39068
|
PRIORITY_WEIGHTS,
|
|
39069
|
+
RankTracker,
|
|
38509
39070
|
SEO_SCOPES,
|
|
38510
39071
|
SITE_PROFILE_QUESTIONS,
|
|
38511
39072
|
Schemas,
|
|
39073
|
+
SerpClient,
|
|
39074
|
+
TIER_LIMITS,
|
|
38512
39075
|
addTrackingResult,
|
|
38513
39076
|
analyzeAnchorText,
|
|
38514
39077
|
analyzeCanonicalAdvanced,
|