@rankcli/agent-runtime 0.0.12 → 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.mjs CHANGED
@@ -4552,7 +4552,13 @@ async function analyzeSecurityHeaders2(url) {
4552
4552
  maxRedirects: 5
4553
4553
  });
4554
4554
  const headers = response.headers;
4555
- const isHttps = url.startsWith("https://");
4555
+ let isHttps = false;
4556
+ try {
4557
+ const parsedUrl = new URL(url);
4558
+ isHttps = parsedUrl.protocol === "https:";
4559
+ } catch {
4560
+ isHttps = url.toLowerCase().startsWith("https://");
4561
+ }
4556
4562
  const getHeader = (name) => {
4557
4563
  const key = Object.keys(headers).find((k) => k.toLowerCase() === name.toLowerCase());
4558
4564
  return key ? headers[key] : null;
@@ -4624,10 +4630,17 @@ async function analyzeSecurityHeaders2(url) {
4624
4630
  }
4625
4631
  };
4626
4632
  } catch (error) {
4633
+ let isHttps = false;
4634
+ try {
4635
+ const parsedUrl = new URL(url);
4636
+ isHttps = parsedUrl.protocol === "https:";
4637
+ } catch {
4638
+ isHttps = url.toLowerCase().startsWith("https://");
4639
+ }
4627
4640
  return {
4628
4641
  issues,
4629
4642
  data: {
4630
- https: url.startsWith("https://"),
4643
+ https: isHttps,
4631
4644
  headers: {
4632
4645
  hsts: null,
4633
4646
  csp: null,
@@ -8661,17 +8674,17 @@ function generateRecommendations2(renderingMethod, hasContentInHTML, charCount,
8661
8674
  );
8662
8675
  if (framework === "React") {
8663
8676
  recommendations.push(
8664
- 'Quick fix: Add react-snap to pre-render pages at build time (npm install -D react-snap, add "postbuild": "react-snap" to scripts)'
8677
+ "Quick fix: Use Vike (vike.dev) for SSR/SSG - works with Vite, minimal config needed"
8665
8678
  );
8666
8679
  recommendations.push(
8667
- "Alternative: Use Vike (vite-plugin-ssr) for SSR/SSG without changing frameworks"
8680
+ "Alternative: Migrate to Next.js or Remix for built-in SSR/SSG support"
8668
8681
  );
8669
8682
  } else if (framework === "Vue") {
8670
8683
  recommendations.push(
8671
- "Quick fix: Add prerender-spa-plugin to pre-render pages at build time"
8684
+ "Quick fix: Use Vike (vike.dev) for SSR/SSG - works with Vite, minimal config needed"
8672
8685
  );
8673
8686
  recommendations.push(
8674
- "Alternative: Use Vike (vite-plugin-ssr) for SSR/SSG without changing frameworks"
8687
+ "Alternative: Migrate to Nuxt for built-in SSR/SSG support"
8675
8688
  );
8676
8689
  } else if (framework === "Angular Universal") {
8677
8690
  recommendations.push(
@@ -8679,7 +8692,7 @@ function generateRecommendations2(renderingMethod, hasContentInHTML, charCount,
8679
8692
  );
8680
8693
  } else {
8681
8694
  recommendations.push(
8682
- "Quick fix: Use a pre-rendering tool like react-snap or prerender-spa-plugin"
8695
+ "Quick fix: Use Vike (vike.dev) for SSR/SSG with any Vite-based framework"
8683
8696
  );
8684
8697
  }
8685
8698
  recommendations.push(
@@ -8703,7 +8716,7 @@ function analyzeClientRendering(html, url) {
8703
8716
  const issues = [];
8704
8717
  const analysis = analyzeRendering(html);
8705
8718
  if (analysis.renderingMethod === "csr" && analysis.confidence !== "low") {
8706
- 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.";
8719
+ 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).";
8707
8720
  issues.push({
8708
8721
  code: "CLIENT_SIDE_RENDERING",
8709
8722
  severity: "error",
@@ -8744,7 +8757,7 @@ function analyzeClientRendering(html, url) {
8744
8757
  title: `${analysis.frameworkDetected} detected without SSR markers`,
8745
8758
  description: `This appears to be a ${analysis.frameworkDetected} SPA without server-side rendering enabled.`,
8746
8759
  impact: "Single Page Applications without SSR have slower time-to-content for search crawlers.",
8747
- 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.",
8760
+ 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.",
8748
8761
  affectedUrls: [url],
8749
8762
  details: {
8750
8763
  framework: analysis.frameworkDetected,
@@ -16659,20 +16672,26 @@ async function runFullAudit(options) {
16659
16672
  timeout: 3e4,
16660
16673
  validateStatus: () => true
16661
16674
  }).catch((err) => {
16675
+ console.error("Main page fetch failed:", err instanceof Error ? { message: err.message, cause: err.cause } : err);
16662
16676
  return { error: err, data: "", headers: {} };
16663
16677
  })
16664
16678
  ]);
16665
16679
  allIssues.push(...crawlabilityResult);
16666
16680
  if ("error" in fetchResult) {
16681
+ const err = fetchResult.error;
16682
+ const errorMsg = err instanceof Error ? err.message : "Unknown error";
16683
+ const errorCause = err instanceof Error && err.cause ? ` (${String(err.cause)})` : "";
16667
16684
  allIssues.push({
16668
16685
  code: "FETCH_ERROR",
16669
16686
  severity: "error",
16670
- category: "crawlability",
16671
- title: "Failed to fetch page",
16672
- description: `Could not fetch the page: ${fetchResult.error instanceof Error ? fetchResult.error.message : "Unknown error"}`,
16673
- impact: "Cannot perform full audit without page content.",
16674
- howToFix: "Ensure the URL is accessible and the server is responding.",
16675
- affectedUrls: [url]
16687
+ category: "indexability",
16688
+ // Not crawlability - robots/sitemap checks may have succeeded
16689
+ title: "Page fetch failed",
16690
+ description: `Could not load page content: ${errorMsg}${errorCause}. Only robots.txt and sitemap checks were performed.`,
16691
+ impact: "Cannot perform full SEO audit without page HTML content.",
16692
+ howToFix: "Verify the URL is accessible, the server is responding, and there are no firewall/geo blocks.",
16693
+ affectedUrls: [url],
16694
+ details: { error: errorMsg, cause: errorCause || void 0 }
16676
16695
  });
16677
16696
  return createReport(url, domain, allIssues, pages);
16678
16697
  }
@@ -33755,6 +33774,544 @@ function getAIVisibilitySummary(results) {
33755
33774
  };
33756
33775
  }
33757
33776
 
33777
+ // src/ranking/types.ts
33778
+ var TIER_LIMITS = {
33779
+ free: {
33780
+ maxKeywords: 10,
33781
+ checksPerDay: 1,
33782
+ serpFeatures: false,
33783
+ competitorTracking: false,
33784
+ historyDays: 7
33785
+ },
33786
+ solo: {
33787
+ maxKeywords: 100,
33788
+ checksPerDay: 1,
33789
+ serpFeatures: true,
33790
+ competitorTracking: false,
33791
+ historyDays: 30
33792
+ },
33793
+ pro: {
33794
+ maxKeywords: 500,
33795
+ checksPerDay: 2,
33796
+ serpFeatures: true,
33797
+ competitorTracking: true,
33798
+ historyDays: 90
33799
+ },
33800
+ agency: {
33801
+ maxKeywords: 2e3,
33802
+ checksPerDay: 4,
33803
+ serpFeatures: true,
33804
+ competitorTracking: true,
33805
+ historyDays: 365
33806
+ }
33807
+ };
33808
+
33809
+ // src/ranking/serp-client.ts
33810
+ var SerpClient = class {
33811
+ config;
33812
+ constructor(config) {
33813
+ this.config = config;
33814
+ }
33815
+ /**
33816
+ * Check ranking for a single keyword
33817
+ */
33818
+ async checkRank(options) {
33819
+ const results = [];
33820
+ for (const keyword of options.keywords) {
33821
+ try {
33822
+ const result = await this.checkSingleKeyword({
33823
+ ...options,
33824
+ keyword
33825
+ });
33826
+ results.push(result);
33827
+ } catch (error) {
33828
+ console.error(`Error checking rank for "${keyword}":`, error);
33829
+ results.push({
33830
+ keyword,
33831
+ position: null,
33832
+ url: null,
33833
+ serpFeatures: [],
33834
+ topResults: [],
33835
+ checkedAt: /* @__PURE__ */ new Date()
33836
+ });
33837
+ }
33838
+ }
33839
+ return results;
33840
+ }
33841
+ async checkSingleKeyword(options) {
33842
+ switch (this.config.provider) {
33843
+ case "valueserp":
33844
+ return this.checkViaValueSerp(options);
33845
+ case "serpapi":
33846
+ return this.checkViaSerpApi(options);
33847
+ case "direct":
33848
+ default:
33849
+ return this.checkViaDirect(options);
33850
+ }
33851
+ }
33852
+ /**
33853
+ * ValueSERP API implementation
33854
+ * Docs: https://www.valueserp.com/docs
33855
+ */
33856
+ async checkViaValueSerp(options) {
33857
+ if (!this.config.apiKey) {
33858
+ throw new Error("ValueSERP API key required");
33859
+ }
33860
+ const params = new URLSearchParams({
33861
+ api_key: this.config.apiKey,
33862
+ q: options.keyword,
33863
+ location: options.country || "United States",
33864
+ google_domain: options.country === "US" ? "google.com" : `google.${options.country?.toLowerCase() || "com"}`,
33865
+ gl: options.country || "us",
33866
+ hl: options.language || "en",
33867
+ device: options.device || "desktop",
33868
+ num: "100"
33869
+ // Get top 100 results
33870
+ });
33871
+ const response = await fetch(`https://api.valueserp.com/search?${params}`);
33872
+ if (!response.ok) {
33873
+ throw new Error(`ValueSERP API error: ${response.status}`);
33874
+ }
33875
+ const data = await response.json();
33876
+ return this.parseValueSerpResponse(data, options.domain, options.keyword);
33877
+ }
33878
+ /**
33879
+ * SerpAPI implementation (alternative)
33880
+ * Docs: https://serpapi.com/search-api
33881
+ */
33882
+ async checkViaSerpApi(options) {
33883
+ if (!this.config.apiKey) {
33884
+ throw new Error("SerpAPI key required");
33885
+ }
33886
+ const params = new URLSearchParams({
33887
+ api_key: this.config.apiKey,
33888
+ q: options.keyword,
33889
+ location: options.country === "US" ? "United States" : options.country || "United States",
33890
+ gl: options.country?.toLowerCase() || "us",
33891
+ hl: options.language || "en",
33892
+ device: options.device || "desktop",
33893
+ num: "100"
33894
+ });
33895
+ const response = await fetch(`https://serpapi.com/search?${params}`);
33896
+ if (!response.ok) {
33897
+ throw new Error(`SerpAPI error: ${response.status}`);
33898
+ }
33899
+ const data = await response.json();
33900
+ return this.parseSerpApiResponse(data, options.domain, options.keyword);
33901
+ }
33902
+ /**
33903
+ * Direct scraping fallback (rate-limited, use with caution)
33904
+ */
33905
+ async checkViaDirect(options) {
33906
+ const searchUrl = `https://www.google.com/search?q=${encodeURIComponent(options.keyword)}&num=100`;
33907
+ const headers = {
33908
+ "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",
33909
+ "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
33910
+ "Accept-Language": options.language || "en-US,en;q=0.9"
33911
+ };
33912
+ const response = await fetch(searchUrl, { headers });
33913
+ if (!response.ok) {
33914
+ throw new Error(`Direct search failed: ${response.status}`);
33915
+ }
33916
+ const html = await response.text();
33917
+ return this.parseDirectSearchResults(html, options.domain, options.keyword);
33918
+ }
33919
+ parseValueSerpResponse(data, domain, keyword) {
33920
+ const topResults = [];
33921
+ const serpFeatures = [];
33922
+ let position = null;
33923
+ let url = null;
33924
+ if (data.organic_results) {
33925
+ for (let i = 0; i < data.organic_results.length; i++) {
33926
+ const result = data.organic_results[i];
33927
+ const resultDomain = this.extractDomain(result.link);
33928
+ topResults.push({
33929
+ position: i + 1,
33930
+ url: result.link,
33931
+ domain: resultDomain,
33932
+ title: result.title
33933
+ });
33934
+ if (this.domainMatches(resultDomain, domain) && position === null) {
33935
+ position = i + 1;
33936
+ url = result.link;
33937
+ }
33938
+ }
33939
+ }
33940
+ if (data.answer_box) {
33941
+ serpFeatures.push({
33942
+ type: "featured_snippet",
33943
+ position: 0,
33944
+ hasOwnSite: this.domainMatches(this.extractDomain(data.answer_box.link || ""), domain)
33945
+ });
33946
+ }
33947
+ if (data.people_also_ask) {
33948
+ serpFeatures.push({
33949
+ type: "people_also_ask",
33950
+ hasOwnSite: data.people_also_ask.some(
33951
+ (paa) => this.domainMatches(this.extractDomain(paa.link || ""), domain)
33952
+ )
33953
+ });
33954
+ }
33955
+ if (data.local_results) {
33956
+ serpFeatures.push({ type: "local_pack" });
33957
+ }
33958
+ if (data.knowledge_graph) {
33959
+ serpFeatures.push({ type: "knowledge_panel" });
33960
+ }
33961
+ return {
33962
+ keyword,
33963
+ position,
33964
+ url,
33965
+ serpFeatures,
33966
+ topResults: topResults.slice(0, 10),
33967
+ checkedAt: /* @__PURE__ */ new Date()
33968
+ };
33969
+ }
33970
+ parseSerpApiResponse(data, domain, keyword) {
33971
+ const topResults = [];
33972
+ const serpFeatures = [];
33973
+ let position = null;
33974
+ let url = null;
33975
+ if (data.organic_results) {
33976
+ for (let i = 0; i < data.organic_results.length; i++) {
33977
+ const result = data.organic_results[i];
33978
+ const resultDomain = this.extractDomain(result.link);
33979
+ topResults.push({
33980
+ position: result.position || i + 1,
33981
+ url: result.link,
33982
+ domain: resultDomain,
33983
+ title: result.title
33984
+ });
33985
+ if (this.domainMatches(resultDomain, domain) && position === null) {
33986
+ position = result.position || i + 1;
33987
+ url = result.link;
33988
+ }
33989
+ }
33990
+ }
33991
+ if (data.answer_box) {
33992
+ serpFeatures.push({
33993
+ type: "featured_snippet",
33994
+ position: 0
33995
+ });
33996
+ }
33997
+ if (data.related_questions) {
33998
+ serpFeatures.push({ type: "people_also_ask" });
33999
+ }
34000
+ return {
34001
+ keyword,
34002
+ position,
34003
+ url,
34004
+ serpFeatures,
34005
+ topResults: topResults.slice(0, 10),
34006
+ checkedAt: /* @__PURE__ */ new Date()
34007
+ };
34008
+ }
34009
+ parseDirectSearchResults(html, domain, keyword) {
34010
+ const topResults = [];
34011
+ let position = null;
34012
+ let url = null;
34013
+ const linkRegex = /<a[^>]+href="\/url\?q=([^"&]+)/g;
34014
+ let match;
34015
+ let index = 0;
34016
+ while ((match = linkRegex.exec(html)) !== null && index < 100) {
34017
+ try {
34018
+ const decodedUrl = decodeURIComponent(match[1]);
34019
+ const resultDomain = this.extractDomain(decodedUrl);
34020
+ if (resultDomain.includes("google.com") || resultDomain.includes("gstatic.com")) {
34021
+ continue;
34022
+ }
34023
+ index++;
34024
+ topResults.push({
34025
+ position: index,
34026
+ url: decodedUrl,
34027
+ domain: resultDomain
34028
+ });
34029
+ if (this.domainMatches(resultDomain, domain) && position === null) {
34030
+ position = index;
34031
+ url = decodedUrl;
34032
+ }
34033
+ } catch {
34034
+ }
34035
+ }
34036
+ return {
34037
+ keyword,
34038
+ position,
34039
+ url,
34040
+ serpFeatures: [],
34041
+ // Direct scraping doesn't easily extract SERP features
34042
+ topResults: topResults.slice(0, 10),
34043
+ checkedAt: /* @__PURE__ */ new Date()
34044
+ };
34045
+ }
34046
+ extractDomain(url) {
34047
+ try {
34048
+ const parsed = new URL(url);
34049
+ return parsed.hostname.replace(/^www\./, "");
34050
+ } catch {
34051
+ return "";
34052
+ }
34053
+ }
34054
+ domainMatches(resultDomain, targetDomain) {
34055
+ const normalizedResult = resultDomain.toLowerCase().replace(/^www\./, "");
34056
+ const normalizedTarget = targetDomain.toLowerCase().replace(/^www\./, "");
34057
+ return normalizedResult === normalizedTarget || normalizedResult.endsWith(`.${normalizedTarget}`);
34058
+ }
34059
+ };
34060
+
34061
+ // src/ranking/tracker.ts
34062
+ var RankTracker = class {
34063
+ supabase;
34064
+ serpClient;
34065
+ constructor(config) {
34066
+ this.supabase = config.supabase;
34067
+ this.serpClient = new SerpClient(config.serpConfig);
34068
+ }
34069
+ /**
34070
+ * Add keywords to track for a project
34071
+ */
34072
+ async addKeywords(projectId, keywords, options) {
34073
+ const keywordRecords = keywords.map((keyword) => ({
34074
+ project_id: projectId,
34075
+ keyword: keyword.toLowerCase().trim(),
34076
+ search_engine: options?.searchEngine || "google",
34077
+ country: options?.country || "US",
34078
+ language: options?.language || "en",
34079
+ track_url: options?.trackUrl,
34080
+ is_active: true
34081
+ }));
34082
+ const { data, error } = await this.supabase.from("keywords").upsert(keywordRecords, {
34083
+ onConflict: "project_id,keyword",
34084
+ ignoreDuplicates: false
34085
+ }).select();
34086
+ if (error) {
34087
+ throw new Error(`Failed to add keywords: ${error.message}`);
34088
+ }
34089
+ return (data || []).map(this.mapKeyword);
34090
+ }
34091
+ /**
34092
+ * Remove keywords from tracking
34093
+ */
34094
+ async removeKeywords(projectId, keywords) {
34095
+ const normalizedKeywords = keywords.map((k) => k.toLowerCase().trim());
34096
+ const { error } = await this.supabase.from("keywords").update({ is_active: false }).eq("project_id", projectId).in("keyword", normalizedKeywords);
34097
+ if (error) {
34098
+ throw new Error(`Failed to remove keywords: ${error.message}`);
34099
+ }
34100
+ }
34101
+ /**
34102
+ * Get all tracked keywords for a project
34103
+ */
34104
+ async getKeywords(projectId, includeInactive = false) {
34105
+ let query = this.supabase.from("keywords").select("*").eq("project_id", projectId);
34106
+ if (!includeInactive) {
34107
+ query = query.eq("is_active", true);
34108
+ }
34109
+ const { data, error } = await query;
34110
+ if (error) {
34111
+ throw new Error(`Failed to get keywords: ${error.message}`);
34112
+ }
34113
+ return (data || []).map(this.mapKeyword);
34114
+ }
34115
+ /**
34116
+ * Check rankings for all active keywords in a project
34117
+ */
34118
+ async checkRankings(projectId, domain) {
34119
+ const keywords = await this.getKeywords(projectId);
34120
+ if (keywords.length === 0) {
34121
+ return [];
34122
+ }
34123
+ const groups = this.groupKeywords(keywords);
34124
+ const results = [];
34125
+ for (const group of groups) {
34126
+ const checkResults = await this.serpClient.checkRank({
34127
+ keywords: group.keywords.map((k) => k.keyword),
34128
+ domain,
34129
+ searchEngine: group.searchEngine,
34130
+ country: group.country,
34131
+ language: group.language
34132
+ });
34133
+ for (const result of checkResults) {
34134
+ const keyword = group.keywords.find((k) => k.keyword === result.keyword);
34135
+ if (!keyword) continue;
34136
+ await this.saveRanking(keyword.id, result);
34137
+ results.push({
34138
+ keywordId: keyword.id,
34139
+ keyword: result.keyword,
34140
+ position: result.position,
34141
+ url: result.url,
34142
+ serpFeatures: result.serpFeatures,
34143
+ competitorUrls: result.topResults,
34144
+ checkedAt: result.checkedAt
34145
+ });
34146
+ }
34147
+ }
34148
+ await this.supabase.from("projects").update({ last_rank_check_at: (/* @__PURE__ */ new Date()).toISOString() }).eq("id", projectId);
34149
+ return results;
34150
+ }
34151
+ /**
34152
+ * Save a ranking result to the database
34153
+ */
34154
+ async saveRanking(keywordId, result) {
34155
+ const { data: currentKeyword } = await this.supabase.from("keywords").select("current_position, best_position").eq("id", keywordId).single();
34156
+ const previousPosition = currentKeyword?.current_position;
34157
+ const bestPosition = currentKeyword?.best_position;
34158
+ const newBestPosition = result.position !== null && (bestPosition === null || result.position < bestPosition) ? result.position : bestPosition;
34159
+ const { error: updateError } = await this.supabase.from("keywords").update({
34160
+ previous_position: previousPosition,
34161
+ current_position: result.position,
34162
+ best_position: newBestPosition,
34163
+ last_checked: result.checkedAt.toISOString()
34164
+ }).eq("id", keywordId);
34165
+ if (updateError) {
34166
+ console.error(`Failed to save ranking for ${result.keyword}:`, updateError);
34167
+ }
34168
+ }
34169
+ /**
34170
+ * Get ranking history for a keyword
34171
+ * Note: Full history requires keyword_ranking_history table with keyword_ranking_id
34172
+ * For now, returns current state as single history entry
34173
+ */
34174
+ async getHistory(keywordId, _days = 30) {
34175
+ const { data, error } = await this.supabase.from("keywords").select("current_position, target_url, last_checked").eq("id", keywordId).single();
34176
+ if (error || !data) {
34177
+ return [];
34178
+ }
34179
+ return [{
34180
+ position: data.current_position,
34181
+ url: data.target_url,
34182
+ serpFeatures: [],
34183
+ recordedAt: data.last_checked ? new Date(data.last_checked) : /* @__PURE__ */ new Date()
34184
+ }];
34185
+ }
34186
+ /**
34187
+ * Get keyword trends for a project
34188
+ */
34189
+ async getTrends(projectId) {
34190
+ const { data, error } = await this.supabase.from("keyword_rank_trends").select("*").eq("project_id", projectId);
34191
+ if (error) {
34192
+ return this.calculateTrends(projectId);
34193
+ }
34194
+ return (data || []).map((row) => ({
34195
+ keywordId: row.keyword_id,
34196
+ keyword: row.keyword,
34197
+ currentPosition: row.current_position,
34198
+ bestPosition: row.best_position,
34199
+ positionChange: row.position_change || 0,
34200
+ avgPosition: row.avg_position || 0,
34201
+ dataPoints: row.data_points || 0
34202
+ }));
34203
+ }
34204
+ /**
34205
+ * Manual trend calculation fallback
34206
+ */
34207
+ async calculateTrends(projectId) {
34208
+ const keywords = await this.getKeywords(projectId);
34209
+ const trends = [];
34210
+ for (const keyword of keywords) {
34211
+ const history = await this.getHistory(keyword.id, 30);
34212
+ if (history.length === 0) {
34213
+ trends.push({
34214
+ keywordId: keyword.id,
34215
+ keyword: keyword.keyword,
34216
+ currentPosition: keyword.currentPosition,
34217
+ bestPosition: keyword.bestPosition,
34218
+ positionChange: 0,
34219
+ avgPosition: keyword.currentPosition || 0,
34220
+ dataPoints: 0
34221
+ });
34222
+ continue;
34223
+ }
34224
+ const positions = history.filter((h) => h.position !== null).map((h) => h.position);
34225
+ const avgPosition = positions.length > 0 ? positions.reduce((a, b) => a + b, 0) / positions.length : 0;
34226
+ const positionChange = keyword.previousPosition && keyword.currentPosition ? keyword.previousPosition - keyword.currentPosition : 0;
34227
+ trends.push({
34228
+ keywordId: keyword.id,
34229
+ keyword: keyword.keyword,
34230
+ currentPosition: keyword.currentPosition,
34231
+ bestPosition: keyword.bestPosition,
34232
+ positionChange,
34233
+ avgPosition,
34234
+ dataPoints: history.length
34235
+ });
34236
+ }
34237
+ return trends;
34238
+ }
34239
+ /**
34240
+ * Export ranking data as CSV
34241
+ */
34242
+ async exportCSV(projectId, days = 30) {
34243
+ const keywords = await this.getKeywords(projectId);
34244
+ const rows = ["Keyword,Current Position,Best Position,Last Checked,Trend"];
34245
+ for (const keyword of keywords) {
34246
+ const history = await this.getHistory(keyword.id, days);
34247
+ const trend = this.calculatePositionTrend(history);
34248
+ rows.push([
34249
+ `"${keyword.keyword}"`,
34250
+ keyword.currentPosition?.toString() || "N/A",
34251
+ keyword.bestPosition?.toString() || "N/A",
34252
+ keyword.lastChecked?.toISOString() || "Never",
34253
+ trend
34254
+ ].join(","));
34255
+ }
34256
+ return rows.join("\n");
34257
+ }
34258
+ /**
34259
+ * Calculate position trend from history
34260
+ */
34261
+ calculatePositionTrend(history) {
34262
+ if (history.length < 2) return "\u2192";
34263
+ const recent = history[0]?.position;
34264
+ const older = history[history.length - 1]?.position;
34265
+ if (recent === null || older === null) return "\u2192";
34266
+ if (recent < older) return "\u2191";
34267
+ if (recent > older) return "\u2193";
34268
+ return "\u2192";
34269
+ }
34270
+ /**
34271
+ * Group keywords by search engine and country
34272
+ */
34273
+ groupKeywords(keywords) {
34274
+ const groups = /* @__PURE__ */ new Map();
34275
+ for (const keyword of keywords) {
34276
+ const key = `${keyword.searchEngine}:${keyword.country}:${keyword.language}`;
34277
+ if (!groups.has(key)) {
34278
+ groups.set(key, []);
34279
+ }
34280
+ groups.get(key).push(keyword);
34281
+ }
34282
+ return Array.from(groups.entries()).map(([key, keywords2]) => {
34283
+ const [searchEngine, country, language] = key.split(":");
34284
+ return {
34285
+ searchEngine,
34286
+ country,
34287
+ language,
34288
+ keywords: keywords2
34289
+ };
34290
+ });
34291
+ }
34292
+ /**
34293
+ * Map database row to TrackedKeyword
34294
+ */
34295
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
34296
+ mapKeyword(row) {
34297
+ return {
34298
+ id: row.id,
34299
+ projectId: row.project_id,
34300
+ keyword: row.keyword,
34301
+ searchEngine: row.search_engine || "google",
34302
+ country: row.country || "US",
34303
+ language: row.language || "en",
34304
+ currentPosition: row.current_position,
34305
+ previousPosition: row.previous_position,
34306
+ bestPosition: row.best_position,
34307
+ trackUrl: row.track_url,
34308
+ isActive: row.is_active,
34309
+ lastChecked: row.last_checked ? new Date(row.last_checked) : null,
34310
+ createdAt: new Date(row.created_at)
34311
+ };
34312
+ }
34313
+ };
34314
+
33758
34315
  // src/analyzers/index.ts
33759
34316
  var analyzers_exports = {};
33760
34317
  __export(analyzers_exports, {
@@ -34937,9 +35494,12 @@ export {
34937
35494
  LOCATION_CODES,
34938
35495
  OG_IMAGE_SPECS,
34939
35496
  PRIORITY_WEIGHTS,
35497
+ RankTracker,
34940
35498
  SEO_SCOPES,
34941
35499
  SITE_PROFILE_QUESTIONS,
34942
35500
  Schemas,
35501
+ SerpClient,
35502
+ TIER_LIMITS,
34943
35503
  addTrackingResult,
34944
35504
  analyzeAnchorText,
34945
35505
  analyzeCanonicalAdvanced,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rankcli/agent-runtime",
3
- "version": "0.0.12",
3
+ "version": "0.0.14",
4
4
  "description": "RankCLI agent runtime - executes SEO audits and fixes with AI",
5
5
  "homepage": "https://rankcli.dev",
6
6
  "main": "dist/index.js",
@@ -27,7 +27,8 @@
27
27
  },
28
28
  "dependencies": {
29
29
  "@anthropic-ai/sdk": "^0.52.0",
30
- "cheerio": "1.1.0",
30
+ "@supabase/supabase-js": "^2.98.0",
31
+ "cheerio": "1.0.0-rc.12",
31
32
  "openai": "^6.16.0",
32
33
  "yaml": "^2.3.0"
33
34
  },