@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.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
- const isHttps = url.startsWith("https://");
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: url.startsWith("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
- 'Quick fix: Add react-snap to pre-render pages at build time (npm install -D react-snap, add "postbuild": "react-snap" to scripts)'
12218
+ "Quick fix: Use Vike (vike.dev) for SSR/SSG - works with Vite, minimal config needed"
12203
12219
  );
12204
12220
  recommendations.push(
12205
- "Alternative: Use Vike (vite-plugin-ssr) for SSR/SSG without changing frameworks"
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: Add prerender-spa-plugin to pre-render pages at build time"
12225
+ "Quick fix: Use Vike (vike.dev) for SSR/SSG - works with Vite, minimal config needed"
12210
12226
  );
12211
12227
  recommendations.push(
12212
- "Alternative: Use Vike (vite-plugin-ssr) for SSR/SSG without changing frameworks"
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 a pre-rendering tool like react-snap or prerender-spa-plugin"
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" ? '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.' : analysis.frameworkDetected === "Vue" ? "Add prerender-spa-plugin to pre-render pages at build time. Alternatively, use Vike for SSR/SSG." : "Pre-render your pages using react-snap, prerender-spa-plugin, or similar build-time tools.";
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" ? "Quick fix: Add react-snap (npm install -D react-snap) to pre-render at build time. Alternative: Use Vike for SSR/SSG." : "Quick fix: Add prerender-spa-plugin to pre-render at build time. Alternative: Use Vike for SSR/SSG.",
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: "crawlability",
20219
- title: "Failed to fetch page",
20220
- description: `Could not fetch the page: ${fetchResult.error instanceof Error ? fetchResult.error.message : "Unknown error"}`,
20221
- impact: "Cannot perform full audit without page content.",
20222
- howToFix: "Ensure the URL is accessible and the server is responding.",
20223
- affectedUrls: [url]
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,