@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.mjs CHANGED
@@ -919,6 +919,24 @@ var ISSUE_DEFINITIONS = {
919
919
  impact: "Your content will not be used for Bard/Gemini AI training (regular search unaffected).",
920
920
  howToFix: 'Remove "User-agent: Google-Extended Disallow: /" if you want Google AI visibility.'
921
921
  },
922
+ CLOUDFLARE_AI_CRAWLER_GATE_AMBIGUOUS: {
923
+ code: "CLOUDFLARE_AI_CRAWLER_GATE_AMBIGUOUS",
924
+ severity: "warning",
925
+ category: "ai-readiness",
926
+ title: "No explicit AI crawler rules on a Cloudflare-fronted site",
927
+ description: '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.',
928
+ impact: "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 \u2014 a silent, invisible-to-git failure mode.",
929
+ howToFix: "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 \u2192 AI Crawl Control. If you'd rather charge than block, Cloudflare's Pay Per Crawl (AI Crawl Control \u2192 Payments tab) lets you set a per-crawl price instead of a flat allow/deny \u2014 worth a look if you get meaningful AI-crawler traffic."
930
+ },
931
+ NO_AGENT_EXPERIENCE_SURFACE: {
932
+ code: "NO_AGENT_EXPERIENCE_SURFACE",
933
+ severity: "notice",
934
+ category: "ai-readiness",
935
+ title: "No agent-facing discovery surface found",
936
+ description: "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 \u2014 most sites have none of these yet \u2014 so this is an opportunity, not a compliance failure.",
937
+ impact: "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.",
938
+ howToFix: "Start with whichever fits your site: llms-full.txt (a single Markdown dump of your key content \u2014 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."
939
+ },
922
940
  HIGH_JS_RENDERING_RATIO: {
923
941
  code: "HIGH_JS_RENDERING_RATIO",
924
942
  severity: "warning",
@@ -3854,6 +3872,61 @@ async function checkInternalRedirects(internalLinks, batchSize = 10) {
3854
3872
 
3855
3873
  // src/audit/checks/ai-readiness.ts
3856
3874
  import * as cheerio13 from "cheerio";
3875
+
3876
+ // src/audit/checks/agent-experience.ts
3877
+ var SIGNAL_PATHS = [
3878
+ { path: "/llms-full.txt", description: "Full-site content dump for AI agents \u2014 the fuller sibling of llms.txt, measured as fetched roughly 2x more often" },
3879
+ { path: "/skill.md", description: "SKILL.md capability manifest (Anthropic Agent Skills convention)" },
3880
+ { path: "/mcp", description: "MCP (Model Context Protocol) server, discoverable at a conventional path" },
3881
+ { path: "/.well-known/mcp.json", description: "MCP server descriptor at the .well-known convention" },
3882
+ { path: "/openapi.json", description: "OpenAPI spec \u2014 lets an agent discover and call your API directly instead of scraping HTML" },
3883
+ { path: "/.well-known/ai-plugin.json", description: "AI plugin manifest (older but still-referenced convention for agent tool discovery)" }
3884
+ ];
3885
+ async function fetchBody(baseUrl, path3) {
3886
+ try {
3887
+ const url = new URL(path3, baseUrl).href;
3888
+ const response = await httpGet(url, {
3889
+ timeout: 8e3,
3890
+ validateStatus: () => true
3891
+ });
3892
+ return { status: response.status, body: String(response.data ?? "") };
3893
+ } catch {
3894
+ return null;
3895
+ }
3896
+ }
3897
+ async function pathExists(baseUrl, path3, fallbackBody) {
3898
+ const result = await fetchBody(baseUrl, path3);
3899
+ if (!result) return false;
3900
+ if (result.status === 404) return false;
3901
+ if (result.status < 200 || result.status >= 400) return false;
3902
+ if (fallbackBody != null && result.body === fallbackBody) return false;
3903
+ return true;
3904
+ }
3905
+ async function checkAgentExperience(baseUrl) {
3906
+ const issues = [];
3907
+ const probePath = `/__rankcli_ax_probe_${Math.random().toString(36).slice(2)}`;
3908
+ const baseline = await fetchBody(baseUrl, probePath);
3909
+ const fallbackBody = baseline && baseline.status >= 200 && baseline.status < 400 ? baseline.body : null;
3910
+ const signals = await Promise.all(
3911
+ SIGNAL_PATHS.map(async ({ path: path3, description }) => ({
3912
+ path: path3,
3913
+ description,
3914
+ present: await pathExists(baseUrl, path3, fallbackBody)
3915
+ }))
3916
+ );
3917
+ const presentCount = signals.filter((s) => s.present).length;
3918
+ const score = Math.round(presentCount / signals.length * 100);
3919
+ if (presentCount === 0) {
3920
+ issues.push({
3921
+ ...ISSUE_DEFINITIONS.NO_AGENT_EXPERIENCE_SURFACE,
3922
+ affectedUrls: [baseUrl],
3923
+ details: { checkedPaths: SIGNAL_PATHS.map((s) => s.path) }
3924
+ });
3925
+ }
3926
+ return { issues, data: { signals, score } };
3927
+ }
3928
+
3929
+ // src/audit/checks/ai-readiness.ts
3857
3930
  var AI_BOTS = {
3858
3931
  GPTBot: "GPTBot",
3859
3932
  "ChatGPT-User": "ChatGPT-User",
@@ -4074,6 +4147,32 @@ function checkJSRenderingRatio(html, url) {
4074
4147
  data: { ratio, staticWordCount }
4075
4148
  };
4076
4149
  }
4150
+ async function checkCloudflareAICrawlerGate(baseUrl, botBlocking) {
4151
+ const issues = [];
4152
+ let behindCloudflare = false;
4153
+ try {
4154
+ const response = await httpGet(baseUrl, {
4155
+ timeout: 1e4,
4156
+ validateStatus: () => true
4157
+ });
4158
+ const server = response.headers["server"] || "";
4159
+ behindCloudflare = server.toLowerCase().includes("cloudflare") || "cf-ray" in response.headers;
4160
+ } catch {
4161
+ behindCloudflare = false;
4162
+ }
4163
+ const hasExplicitAIRules = botBlocking.robotsExists && botBlocking.blockedBots.length > 0;
4164
+ const ambiguous = behindCloudflare && !hasExplicitAIRules;
4165
+ if (ambiguous) {
4166
+ issues.push({
4167
+ ...ISSUE_DEFINITIONS.CLOUDFLARE_AI_CRAWLER_GATE_AMBIGUOUS,
4168
+ affectedUrls: [baseUrl]
4169
+ });
4170
+ }
4171
+ return {
4172
+ issues,
4173
+ data: { behindCloudflare, hasExplicitAIRules, ambiguous }
4174
+ };
4175
+ }
4077
4176
  async function runAIReadinessChecks(baseUrl, html) {
4078
4177
  const allIssues = [];
4079
4178
  const llmsResult = await checkLlmsTxt(baseUrl);
@@ -4082,12 +4181,18 @@ async function runAIReadinessChecks(baseUrl, html) {
4082
4181
  allIssues.push(...botResult.issues);
4083
4182
  const jsResult = checkJSRenderingRatio(html, baseUrl);
4084
4183
  allIssues.push(...jsResult.issues);
4184
+ const cfGateResult = await checkCloudflareAICrawlerGate(baseUrl, botResult.data);
4185
+ allIssues.push(...cfGateResult.issues);
4186
+ const agentExperienceResult = await checkAgentExperience(baseUrl);
4187
+ allIssues.push(...agentExperienceResult.issues);
4085
4188
  return {
4086
4189
  issues: allIssues,
4087
4190
  data: {
4088
4191
  llmsTxt: llmsResult.data,
4089
4192
  botBlocking: botResult.data,
4090
- jsRenderingRatio: jsResult.data.ratio
4193
+ jsRenderingRatio: jsResult.data.ratio,
4194
+ cloudflareAIGate: cfGateResult.data,
4195
+ agentExperience: agentExperienceResult.data
4091
4196
  }
4092
4197
  };
4093
4198
  }
@@ -4552,7 +4657,13 @@ async function analyzeSecurityHeaders2(url) {
4552
4657
  maxRedirects: 5
4553
4658
  });
4554
4659
  const headers = response.headers;
4555
- const isHttps = url.startsWith("https://");
4660
+ let isHttps = false;
4661
+ try {
4662
+ const parsedUrl = new URL(url);
4663
+ isHttps = parsedUrl.protocol === "https:";
4664
+ } catch {
4665
+ isHttps = url.toLowerCase().startsWith("https://");
4666
+ }
4556
4667
  const getHeader = (name) => {
4557
4668
  const key = Object.keys(headers).find((k) => k.toLowerCase() === name.toLowerCase());
4558
4669
  return key ? headers[key] : null;
@@ -4624,10 +4735,17 @@ async function analyzeSecurityHeaders2(url) {
4624
4735
  }
4625
4736
  };
4626
4737
  } catch (error) {
4738
+ let isHttps = false;
4739
+ try {
4740
+ const parsedUrl = new URL(url);
4741
+ isHttps = parsedUrl.protocol === "https:";
4742
+ } catch {
4743
+ isHttps = url.toLowerCase().startsWith("https://");
4744
+ }
4627
4745
  return {
4628
4746
  issues,
4629
4747
  data: {
4630
- https: url.startsWith("https://"),
4748
+ https: isHttps,
4631
4749
  headers: {
4632
4750
  hsts: null,
4633
4751
  csp: null,
@@ -8661,17 +8779,17 @@ function generateRecommendations2(renderingMethod, hasContentInHTML, charCount,
8661
8779
  );
8662
8780
  if (framework === "React") {
8663
8781
  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)'
8782
+ "Quick fix: Use Vike (vike.dev) for SSR/SSG - works with Vite, minimal config needed"
8665
8783
  );
8666
8784
  recommendations.push(
8667
- "Alternative: Use Vike (vite-plugin-ssr) for SSR/SSG without changing frameworks"
8785
+ "Alternative: Migrate to Next.js or Remix for built-in SSR/SSG support"
8668
8786
  );
8669
8787
  } else if (framework === "Vue") {
8670
8788
  recommendations.push(
8671
- "Quick fix: Add prerender-spa-plugin to pre-render pages at build time"
8789
+ "Quick fix: Use Vike (vike.dev) for SSR/SSG - works with Vite, minimal config needed"
8672
8790
  );
8673
8791
  recommendations.push(
8674
- "Alternative: Use Vike (vite-plugin-ssr) for SSR/SSG without changing frameworks"
8792
+ "Alternative: Migrate to Nuxt for built-in SSR/SSG support"
8675
8793
  );
8676
8794
  } else if (framework === "Angular Universal") {
8677
8795
  recommendations.push(
@@ -8679,7 +8797,7 @@ function generateRecommendations2(renderingMethod, hasContentInHTML, charCount,
8679
8797
  );
8680
8798
  } else {
8681
8799
  recommendations.push(
8682
- "Quick fix: Use a pre-rendering tool like react-snap or prerender-spa-plugin"
8800
+ "Quick fix: Use Vike (vike.dev) for SSR/SSG with any Vite-based framework"
8683
8801
  );
8684
8802
  }
8685
8803
  recommendations.push(
@@ -8703,7 +8821,7 @@ function analyzeClientRendering(html, url) {
8703
8821
  const issues = [];
8704
8822
  const analysis = analyzeRendering(html);
8705
8823
  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.";
8824
+ 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
8825
  issues.push({
8708
8826
  code: "CLIENT_SIDE_RENDERING",
8709
8827
  severity: "error",
@@ -8744,7 +8862,7 @@ function analyzeClientRendering(html, url) {
8744
8862
  title: `${analysis.frameworkDetected} detected without SSR markers`,
8745
8863
  description: `This appears to be a ${analysis.frameworkDetected} SPA without server-side rendering enabled.`,
8746
8864
  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.",
8865
+ 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
8866
  affectedUrls: [url],
8749
8867
  details: {
8750
8868
  framework: analysis.frameworkDetected,
@@ -12521,8 +12639,94 @@ function analyzeAIContentStructure(html, url) {
12521
12639
  };
12522
12640
  }
12523
12641
 
12524
- // src/audit/checks/citation-quality.ts
12642
+ // src/audit/checks/rag-chunk-readiness.ts
12525
12643
  import * as cheerio44 from "cheerio";
12644
+ var MIN_GOOD_WORDS = 150;
12645
+ var MAX_GOOD_WORDS = 450;
12646
+ var TOKENS_PER_WORD = 1.33;
12647
+ var DANGLING_OPENERS = /^(this|it|these|that|they|such|those|the former|the latter)\b/i;
12648
+ function classifySize(wordCount) {
12649
+ if (wordCount < MIN_GOOD_WORDS) return "too-short";
12650
+ if (wordCount > MAX_GOOD_WORDS) return "too-long";
12651
+ return "good";
12652
+ }
12653
+ function extractSections($) {
12654
+ const elements = $("h1, h2, h3, p, li, blockquote").toArray();
12655
+ const sections = [];
12656
+ let current = null;
12657
+ for (const el of elements) {
12658
+ const tag = el.tagName?.toLowerCase();
12659
+ if (tag === "h1" || tag === "h2" || tag === "h3") {
12660
+ if (current) sections.push(current);
12661
+ current = { heading: $(el).text().trim(), headingLevel: parseInt(tag.slice(1), 10), text: "" };
12662
+ } else if (current) {
12663
+ current.text += " " + $(el).text();
12664
+ }
12665
+ }
12666
+ if (current) sections.push(current);
12667
+ return sections;
12668
+ }
12669
+ function analyzeRAGChunkReadiness(html, url) {
12670
+ const issues = [];
12671
+ const $ = cheerio44.load(html);
12672
+ $("nav, footer, aside, script, style, noscript, header").remove();
12673
+ const rawSections = extractSections($);
12674
+ const sections = rawSections.map((s) => {
12675
+ const words = s.text.trim().split(/\s+/).filter(Boolean);
12676
+ const wordCount = words.length;
12677
+ const estimatedTokens = Math.round(wordCount * TOKENS_PER_WORD);
12678
+ const firstSentence = s.text.trim().split(/[.!?]/)[0] || "";
12679
+ return {
12680
+ heading: s.heading,
12681
+ headingLevel: s.headingLevel,
12682
+ wordCount,
12683
+ estimatedTokens,
12684
+ sizeQuality: classifySize(wordCount),
12685
+ startsWithDanglingReference: DANGLING_OPENERS.test(firstSentence.trim())
12686
+ };
12687
+ });
12688
+ const totalSections = sections.length;
12689
+ const wellSizedSectionCount = sections.filter((s) => s.sizeQuality === "good").length;
12690
+ const danglingReferenceCount = sections.filter((s) => s.startsWithDanglingReference).length;
12691
+ const chunkReadinessScore = totalSections === 0 ? 0 : Math.round(
12692
+ (wellSizedSectionCount / totalSections * 0.7 + (totalSections - danglingReferenceCount) / totalSections * 0.3) * 100
12693
+ );
12694
+ if (totalSections >= 3 && wellSizedSectionCount / totalSections < 0.5) {
12695
+ const tooLong = sections.filter((s) => s.sizeQuality === "too-long").length;
12696
+ const tooShort = sections.filter((s) => s.sizeQuality === "too-short").length;
12697
+ issues.push({
12698
+ code: "RAG_CHUNK_SIZE_MISMATCH",
12699
+ severity: "notice",
12700
+ category: "ai-readiness",
12701
+ title: "Most sections are poorly sized for AI retrieval chunking",
12702
+ description: `${wellSizedSectionCount} of ${totalSections} sections fall in a well-sized range for RAG retrieval (roughly 150-450 words); ${tooLong} are too long and likely to get split mid-thought, ${tooShort} are too short and likely to get merged with unrelated neighbors.`,
12703
+ impact: "When an AI answer engine retrieves a chunk of this page in response to a query, oversized sections risk being cut off mid-point and undersized ones risk losing standalone context \u2014 both reduce the odds the retrieved chunk reads coherently in an AI-generated answer.",
12704
+ howToFix: "Break up long sections (450+ words) with an additional H2/H3 subheading roughly every 300-400 words. Merge very short sections (under 150 words) into a neighboring section or expand them with enough context to stand alone.",
12705
+ affectedUrls: [url],
12706
+ details: { wellSizedSectionCount, totalSections, tooLong, tooShort }
12707
+ });
12708
+ }
12709
+ if (totalSections >= 3 && danglingReferenceCount / totalSections > 0.3) {
12710
+ issues.push({
12711
+ code: "RAG_CHUNK_DANGLING_REFERENCES",
12712
+ severity: "notice",
12713
+ category: "ai-readiness",
12714
+ title: "Several sections open with a reference to prior context",
12715
+ description: `${danglingReferenceCount} of ${totalSections} sections start with a pronoun or demonstrative ("This...", "It...", "These...") that depends on the previous section to make sense.`,
12716
+ impact: "A RAG system that retrieves one of these sections on its own \u2014 which is exactly how retrieval works, one chunk at a time \u2014 surfaces a sentence whose subject is undefined, reading as broken or confusing in an AI-generated answer.",
12717
+ howToFix: 'Open each section by naming its actual subject instead of referring back to the previous one \u2014 e.g. "This approach reduces cost" becomes "Caching reduces cost."',
12718
+ affectedUrls: [url],
12719
+ details: { danglingReferenceCount, totalSections }
12720
+ });
12721
+ }
12722
+ return {
12723
+ issues,
12724
+ data: { sections, totalSections, wellSizedSectionCount, danglingReferenceCount, chunkReadinessScore }
12725
+ };
12726
+ }
12727
+
12728
+ // src/audit/checks/citation-quality.ts
12729
+ import * as cheerio45 from "cheerio";
12526
12730
  var REPUTABLE_SOURCES = {
12527
12731
  academic: [
12528
12732
  "scholar.google.com",
@@ -12589,7 +12793,7 @@ var REPUTABLE_SOURCES = {
12589
12793
  };
12590
12794
  function analyzeCitationQuality(html, url) {
12591
12795
  const issues = [];
12592
- const $ = cheerio44.load(html);
12796
+ const $ = cheerio45.load(html);
12593
12797
  const parsedUrl = new URL(url);
12594
12798
  const currentDomain = parsedUrl.hostname;
12595
12799
  const $content = $("body").clone();
@@ -12818,12 +13022,12 @@ function analyzeCitationQuality(html, url) {
12818
13022
  }
12819
13023
 
12820
13024
  // src/audit/checks/answer-conciseness.ts
12821
- import * as cheerio45 from "cheerio";
13025
+ import * as cheerio46 from "cheerio";
12822
13026
  var IDEAL_ANSWER_LENGTH = { min: 40, max: 150 };
12823
13027
  var MAX_FIRST_SENTENCE = 200;
12824
13028
  function analyzeAnswerConciseness(html, url) {
12825
13029
  const issues = [];
12826
- const $ = cheerio45.load(html);
13030
+ const $ = cheerio46.load(html);
12827
13031
  $("nav, footer, aside, script, style, noscript, header").remove();
12828
13032
  const headings = $("h1, h2, h3, h4, h5, h6");
12829
13033
  let totalHeadings = 0;
@@ -13014,7 +13218,7 @@ function analyzeAnswerConciseness(html, url) {
13014
13218
  }
13015
13219
 
13016
13220
  // src/audit/checks/brand-mention-optimization.ts
13017
- import * as cheerio46 from "cheerio";
13221
+ import * as cheerio47 from "cheerio";
13018
13222
  var BRAND_DEFINITION_PATTERNS = [
13019
13223
  /(?:we are|we're|is a|is the|is an)\s+(?:leading|premier|top|best|trusted|innovative|professional)/i,
13020
13224
  /(?:our mission|our vision|we help|we provide|we offer|we specialize)/i,
@@ -13052,7 +13256,7 @@ var REVIEW_PLATFORMS = [
13052
13256
  ];
13053
13257
  function analyzeBrandMentionOptimization(html, url) {
13054
13258
  const issues = [];
13055
- const $ = cheerio46.load(html);
13259
+ const $ = cheerio47.load(html);
13056
13260
  const parsedUrl = new URL(url);
13057
13261
  const $content = $("body").clone();
13058
13262
  $content.find("nav, footer, script, style, noscript").remove();
@@ -13244,7 +13448,7 @@ function analyzeBrandMentionOptimization(html, url) {
13244
13448
  }
13245
13449
 
13246
13450
  // src/audit/checks/ai-citation-worthiness.ts
13247
- import * as cheerio47 from "cheerio";
13451
+ import * as cheerio48 from "cheerio";
13248
13452
  var ORIGINAL_DATA_PATTERNS = [
13249
13453
  /(?:our data|our research|our study|our analysis|we found|we discovered)/i,
13250
13454
  /(?:survey of|surveyed|interviewed|analyzed)\s+\d+/i,
@@ -13291,7 +13495,7 @@ var STOCK_IMAGE_DOMAINS = [
13291
13495
  ];
13292
13496
  function analyzeAICitationWorthiness(html, url) {
13293
13497
  const issues = [];
13294
- const $ = cheerio47.load(html);
13498
+ const $ = cheerio48.load(html);
13295
13499
  const $content = $("body").clone();
13296
13500
  $content.find("nav, footer, script, style, noscript, aside").remove();
13297
13501
  const bodyText = $content.text();
@@ -13516,7 +13720,7 @@ function analyzeAICitationWorthiness(html, url) {
13516
13720
  }
13517
13721
 
13518
13722
  // src/audit/checks/review-ecosystem.ts
13519
- import * as cheerio48 from "cheerio";
13723
+ import * as cheerio49 from "cheerio";
13520
13724
  var REVIEW_PLATFORMS2 = {
13521
13725
  general: [
13522
13726
  { name: "Google Business", domain: "google.com/maps", aliases: ["goo.gl/maps", "maps.google"] },
@@ -13576,7 +13780,7 @@ var TRUST_BADGE_PATTERNS2 = [
13576
13780
  ];
13577
13781
  function analyzeReviewEcosystem(html, url) {
13578
13782
  const issues = [];
13579
- const $ = cheerio48.load(html);
13783
+ const $ = cheerio49.load(html);
13580
13784
  const allLinks = $("a[href]").map((_, a) => $(a).attr("href") || "").get();
13581
13785
  const allLinksLower = allLinks.map((l) => l.toLowerCase());
13582
13786
  const linkedPlatforms = [];
@@ -14258,10 +14462,10 @@ function analyzeStructure(content) {
14258
14462
  }
14259
14463
 
14260
14464
  // src/audit/checks/html-compliance.ts
14261
- import * as cheerio49 from "cheerio";
14465
+ import * as cheerio50 from "cheerio";
14262
14466
  async function analyzeHtmlCompliance(html, url, headers) {
14263
14467
  const issues = [];
14264
- const $ = cheerio49.load(html);
14468
+ const $ = cheerio50.load(html);
14265
14469
  const parsedUrl = new URL(url);
14266
14470
  const doctypeMatch = html.match(/<!DOCTYPE\s+([^>]+)>/i);
14267
14471
  const hasDoctype = doctypeMatch !== null;
@@ -14825,7 +15029,7 @@ function getAssetType(url) {
14825
15029
  }
14826
15030
 
14827
15031
  // src/audit/checks/dom-size.ts
14828
- import * as cheerio50 from "cheerio";
15032
+ import * as cheerio51 from "cheerio";
14829
15033
  var THRESHOLDS = {
14830
15034
  totalElements: {
14831
15035
  warning: 1500,
@@ -14842,7 +15046,7 @@ var THRESHOLDS = {
14842
15046
  };
14843
15047
  function analyzeDomSize(html, url) {
14844
15048
  const issues = [];
14845
- const $ = cheerio50.load(html);
15049
+ const $ = cheerio51.load(html);
14846
15050
  const allElements = $("*");
14847
15051
  const totalElements = allElements.length;
14848
15052
  let maxDepth = 0;
@@ -14998,10 +15202,10 @@ function getDomReductionSuggestions(breakdown) {
14998
15202
  }
14999
15203
 
15000
15204
  // src/audit/checks/image-dimensions.ts
15001
- import * as cheerio51 from "cheerio";
15205
+ import * as cheerio52 from "cheerio";
15002
15206
  function analyzeImageDimensions(html, url) {
15003
15207
  const issues = [];
15004
- const $ = cheerio51.load(html);
15208
+ const $ = cheerio52.load(html);
15005
15209
  const images = $("img");
15006
15210
  const totalImages = images.length;
15007
15211
  let withDimensions = 0;
@@ -15112,7 +15316,7 @@ function truncateSrc(src) {
15112
15316
  }
15113
15317
 
15114
15318
  // src/audit/checks/color-contrast.ts
15115
- import * as cheerio52 from "cheerio";
15319
+ import * as cheerio53 from "cheerio";
15116
15320
  var KNOWN_LOW_CONTRAST_PAIRS = [
15117
15321
  { fg: "#999999", bg: "#ffffff", ratio: 2.85 },
15118
15322
  { fg: "#888888", bg: "#ffffff", ratio: 3.54 },
@@ -15147,7 +15351,7 @@ var NAMED_COLORS = {
15147
15351
  };
15148
15352
  function analyzeColorContrast(html, url) {
15149
15353
  const issues = [];
15150
- const $ = cheerio52.load(html);
15354
+ const $ = cheerio53.load(html);
15151
15355
  const potentialIssues = [];
15152
15356
  let elementsAnalyzed = 0;
15153
15357
  let passedChecks = 0;
@@ -15345,7 +15549,7 @@ function calculateContrastRatio(fg, bg) {
15345
15549
  }
15346
15550
 
15347
15551
  // src/audit/checks/asset-minification.ts
15348
- import * as cheerio53 from "cheerio";
15552
+ import * as cheerio54 from "cheerio";
15349
15553
  function isMinified(content, type) {
15350
15554
  const lines = content.split("\n");
15351
15555
  const totalLines = lines.length;
@@ -15373,7 +15577,7 @@ function isMinified(content, type) {
15373
15577
  };
15374
15578
  }
15375
15579
  function extractAssetUrls(html, baseUrl) {
15376
- const $ = cheerio53.load(html);
15580
+ const $ = cheerio54.load(html);
15377
15581
  const base = new URL(baseUrl);
15378
15582
  const css = [];
15379
15583
  const js = [];
@@ -15519,10 +15723,10 @@ async function analyzeAssetMinification(html, url) {
15519
15723
  }
15520
15724
 
15521
15725
  // src/audit/checks/page-resources.ts
15522
- import * as cheerio54 from "cheerio";
15726
+ import * as cheerio55 from "cheerio";
15523
15727
  function analyzePageResources(html, url) {
15524
15728
  const issues = [];
15525
- const $ = cheerio54.load(html);
15729
+ const $ = cheerio55.load(html);
15526
15730
  const baseUrl = new URL(url);
15527
15731
  const baseHostname = baseUrl.hostname;
15528
15732
  const stylesheets = [];
@@ -15722,7 +15926,7 @@ function analyzePageResources(html, url) {
15722
15926
  }
15723
15927
 
15724
15928
  // src/audit/checks/responsive-css.ts
15725
- import * as cheerio55 from "cheerio";
15929
+ import * as cheerio56 from "cheerio";
15726
15930
  function extractMediaQueries(css) {
15727
15931
  const mediaQueries = [];
15728
15932
  const regex = /@media\s*([^{]+)/g;
@@ -15772,7 +15976,7 @@ function classifyBreakpoints(mediaQueries) {
15772
15976
  }
15773
15977
  async function analyzeResponsiveCss(html, url) {
15774
15978
  const issues = [];
15775
- const $ = cheerio55.load(html);
15979
+ const $ = cheerio56.load(html);
15776
15980
  const baseUrl = new URL(url);
15777
15981
  const viewportMeta = $('meta[name="viewport"]').attr("content");
15778
15982
  const hasViewport = !!viewportMeta;
@@ -16509,10 +16713,10 @@ var urlSafetyDatabase = {
16509
16713
  };
16510
16714
 
16511
16715
  // src/audit/checks/tracking-verification.ts
16512
- import * as cheerio56 from "cheerio";
16716
+ import * as cheerio57 from "cheerio";
16513
16717
  function analyzeTrackingVerification(html, url) {
16514
16718
  const issues = [];
16515
- const $ = cheerio56.load(html);
16719
+ const $ = cheerio57.load(html);
16516
16720
  const ga4Match = html.match(/gtag\s*\(\s*['"]config['"]\s*,\s*['"](G-[A-Z0-9]+)['"]/i) || html.match(/googletagmanager\.com\/gtag\/js\?id=(G-[A-Z0-9]+)/i);
16517
16721
  const hasGa4 = !!ga4Match;
16518
16722
  const ga4MeasurementId = ga4Match ? ga4Match[1] : void 0;
@@ -16646,10 +16850,10 @@ async function runFullAudit(options) {
16646
16850
  const runExtendedChecks = tier !== "free";
16647
16851
  const parsedUrl = new URL(url);
16648
16852
  const domain = parsedUrl.hostname;
16649
- console.log(`
16853
+ console.error(`
16650
16854
  \u{1F50D} Running comprehensive SEO audit on ${url}...
16651
16855
  `);
16652
- console.log("\u{1F4CB} Phase 1: Crawlability checks + page fetch (parallel)...");
16856
+ console.error("\u{1F4CB} Phase 1: Crawlability checks + page fetch (parallel)...");
16653
16857
  const [crawlabilityResult, fetchResult] = await Promise.all([
16654
16858
  runCrawlabilityChecks(url).catch((err) => {
16655
16859
  console.error("Crawlability check failed:", err);
@@ -16659,26 +16863,32 @@ async function runFullAudit(options) {
16659
16863
  timeout: 3e4,
16660
16864
  validateStatus: () => true
16661
16865
  }).catch((err) => {
16866
+ console.error("Main page fetch failed:", err instanceof Error ? { message: err.message, cause: err.cause } : err);
16662
16867
  return { error: err, data: "", headers: {} };
16663
16868
  })
16664
16869
  ]);
16665
16870
  allIssues.push(...crawlabilityResult);
16666
16871
  if ("error" in fetchResult) {
16872
+ const err = fetchResult.error;
16873
+ const errorMsg = err instanceof Error ? err.message : "Unknown error";
16874
+ const errorCause = err instanceof Error && err.cause ? ` (${String(err.cause)})` : "";
16667
16875
  allIssues.push({
16668
16876
  code: "FETCH_ERROR",
16669
16877
  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]
16878
+ category: "indexability",
16879
+ // Not crawlability - robots/sitemap checks may have succeeded
16880
+ title: "Page fetch failed",
16881
+ description: `Could not load page content: ${errorMsg}${errorCause}. Only robots.txt and sitemap checks were performed.`,
16882
+ impact: "Cannot perform full SEO audit without page HTML content.",
16883
+ howToFix: "Verify the URL is accessible, the server is responding, and there are no firewall/geo blocks.",
16884
+ affectedUrls: [url],
16885
+ details: { error: errorMsg, cause: errorCause || void 0 }
16676
16886
  });
16677
16887
  return createReport(url, domain, allIssues, pages);
16678
16888
  }
16679
16889
  const html = fetchResult.data;
16680
16890
  const headers = fetchResult.headers;
16681
- console.log(`\u{1F4DD} Phase 2: Running synchronous HTML checks (tier: ${tier}, limit: ${checksLimit})...`);
16891
+ console.error(`\u{1F4DD} Phase 2: Running synchronous HTML checks (tier: ${tier}, limit: ${checksLimit})...`);
16682
16892
  const onPageResult = analyzeOnPage(html, url);
16683
16893
  const structuredDataResult = analyzeStructuredData2(html, url);
16684
16894
  const mobileResult = analyzeMobile(html, url);
@@ -16718,6 +16928,7 @@ async function runFullAudit(options) {
16718
16928
  const entityResult = runPremiumChecks ? analyzeEntitySEO(html, url) : { issues: [], data: {} };
16719
16929
  const qdfFreshnessResult = runPremiumChecks ? analyzeFreshnessSignals(html, url) : { issues: [], data: {} };
16720
16930
  const aiContentStructureResult = runPremiumChecks ? analyzeAIContentStructure(html, url) : { issues: [], data: {} };
16931
+ const ragChunkReadinessResult = runPremiumChecks ? analyzeRAGChunkReadiness(html, url) : { issues: [], data: {} };
16721
16932
  const citationQualityResult = runPremiumChecks ? analyzeCitationQuality(html, url) : { issues: [], data: {} };
16722
16933
  const answerConcisenessResult = runPremiumChecks ? analyzeAnswerConciseness(html, url) : { issues: [], data: {} };
16723
16934
  const brandMentionResult = runPremiumChecks ? analyzeBrandMentionOptimization(html, url) : { issues: [], data: {} };
@@ -16756,6 +16967,7 @@ async function runFullAudit(options) {
16756
16967
  ...entityResult.issues,
16757
16968
  ...qdfFreshnessResult.issues,
16758
16969
  ...aiContentStructureResult.issues,
16970
+ ...ragChunkReadinessResult.issues,
16759
16971
  ...citationQualityResult.issues,
16760
16972
  ...answerConcisenessResult.issues,
16761
16973
  ...brandMentionResult.issues,
@@ -16779,7 +16991,7 @@ async function runFullAudit(options) {
16779
16991
  };
16780
16992
  }
16781
16993
  }
16782
- console.log("\u{1F517} Phase 3: Running async checks (parallel)...");
16994
+ console.error("\u{1F517} Phase 3: Running async checks (parallel)...");
16783
16995
  const safeAsync = async (name, fn, timeoutMs = 1e4) => {
16784
16996
  try {
16785
16997
  const resultPromise = fn();
@@ -16892,7 +17104,7 @@ async function runFullAudit(options) {
16892
17104
  loadTime: perfData.loadTime,
16893
17105
  issues: allIssues.map((i) => i.code)
16894
17106
  });
16895
- console.log("\n\u2705 Audit complete!\n");
17107
+ console.error("\n\u2705 Audit complete!\n");
16896
17108
  return createReport(url, domain, allIssues, pages);
16897
17109
  }
16898
17110
  function createReport(url, domain, issues, pages) {
@@ -17053,10 +17265,10 @@ function groupIssuesByCategory(issues) {
17053
17265
  }
17054
17266
 
17055
17267
  // src/audit/checks/duplicate-content.ts
17056
- import * as cheerio57 from "cheerio";
17268
+ import * as cheerio58 from "cheerio";
17057
17269
  import { createHash } from "crypto";
17058
17270
  function extractContentHash(html, url) {
17059
- const $ = cheerio57.load(html);
17271
+ const $ = cheerio58.load(html);
17060
17272
  $("script, style, nav, header, footer, aside, .nav, .header, .footer, .sidebar").remove();
17061
17273
  const title = $("title").text().trim();
17062
17274
  const bodyText = $("body").text().replace(/\s+/g, " ").trim();
@@ -18284,7 +18496,7 @@ var PRIORITY_WEIGHTS = {
18284
18496
  };
18285
18497
 
18286
18498
  // src/keywords/engine.ts
18287
- import * as cheerio59 from "cheerio";
18499
+ import * as cheerio60 from "cheerio";
18288
18500
 
18289
18501
  // src/keywords/prioritizer.ts
18290
18502
  function prioritizeKeywords(keywords, siteProfile, existingMeta) {
@@ -18578,7 +18790,7 @@ function enrichKeywordsWithEstimates(keywords) {
18578
18790
  }
18579
18791
 
18580
18792
  // src/keywords/sources/free-sources.ts
18581
- import * as cheerio58 from "cheerio";
18793
+ import * as cheerio59 from "cheerio";
18582
18794
  var USER_AGENT = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36";
18583
18795
  async function getPeopleAlsoAsk(query) {
18584
18796
  try {
@@ -18587,7 +18799,7 @@ async function getPeopleAlsoAsk(query) {
18587
18799
  headers: { "User-Agent": USER_AGENT },
18588
18800
  timeout: 1e4
18589
18801
  });
18590
- const $ = cheerio58.load(response.data);
18802
+ const $ = cheerio59.load(response.data);
18591
18803
  const questions = [];
18592
18804
  $("[data-sgrd]").each((_, el) => {
18593
18805
  const text = $(el).text().trim();
@@ -18613,7 +18825,7 @@ async function getRelatedSearches(query) {
18613
18825
  headers: { "User-Agent": USER_AGENT },
18614
18826
  timeout: 1e4
18615
18827
  });
18616
- const $ = cheerio58.load(response.data);
18828
+ const $ = cheerio59.load(response.data);
18617
18829
  const related = [];
18618
18830
  $("div[data-ved] a").each((_, el) => {
18619
18831
  const href = $(el).attr("href");
@@ -18703,7 +18915,7 @@ async function analyzeCompetitorTitles(query) {
18703
18915
  headers: { "User-Agent": USER_AGENT },
18704
18916
  timeout: 1e4
18705
18917
  });
18706
- const $ = cheerio58.load(response.data);
18918
+ const $ = cheerio59.load(response.data);
18707
18919
  const titles = [];
18708
18920
  const keywords = /* @__PURE__ */ new Set();
18709
18921
  $("h3").each((_, el) => {
@@ -19035,7 +19247,7 @@ async function fetchPageMeta(url) {
19035
19247
  const response = await httpGet(url, {
19036
19248
  timeout: 1e4
19037
19249
  });
19038
- const $ = cheerio59.load(response.data);
19250
+ const $ = cheerio60.load(response.data);
19039
19251
  return {
19040
19252
  url,
19041
19253
  title: $("title").text().trim() || void 0,
@@ -19166,7 +19378,7 @@ async function extractSeedKeywords(url) {
19166
19378
  const response = await httpGet(url, {
19167
19379
  timeout: 1e4
19168
19380
  });
19169
- const $ = cheerio59.load(response.data);
19381
+ const $ = cheerio60.load(response.data);
19170
19382
  const seeds = /* @__PURE__ */ new Set();
19171
19383
  const title = $("title").text().toLowerCase();
19172
19384
  const titleWords = title.split(/[\s\-|:]+/).filter((w) => w.length > 3);
@@ -19188,7 +19400,7 @@ async function extractSeedKeywords(url) {
19188
19400
  }
19189
19401
 
19190
19402
  // src/keywords/site-crawler.ts
19191
- import * as cheerio60 from "cheerio";
19403
+ import * as cheerio61 from "cheerio";
19192
19404
  var EXCLUDED_PATHS = [
19193
19405
  "/cdn-cgi/",
19194
19406
  "/wp-admin/",
@@ -19300,7 +19512,7 @@ async function crawlPage(url, timeout) {
19300
19512
  validateStatus: (status) => status === 200
19301
19513
  });
19302
19514
  const html = response.data;
19303
- const $ = cheerio60.load(html);
19515
+ const $ = cheerio61.load(html);
19304
19516
  $('script, style, noscript, iframe, nav, footer, header, aside, [role="navigation"]').remove();
19305
19517
  const title = $("title").text().trim();
19306
19518
  const description = $('meta[name="description"]').attr("content")?.trim() || "";
@@ -21957,7 +22169,7 @@ function getDateRange(days = 28) {
21957
22169
  }
21958
22170
 
21959
22171
  // src/keywords/sources/competitor-analysis.ts
21960
- import * as cheerio61 from "cheerio";
22172
+ import * as cheerio62 from "cheerio";
21961
22173
  var USER_AGENT2 = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36";
21962
22174
  async function discoverCompetitorKeywords(yourDomain, seedKeywords, competitors) {
21963
22175
  const yourKeywords = /* @__PURE__ */ new Set();
@@ -22054,7 +22266,7 @@ async function analyzeSERP(query) {
22054
22266
  headers: { "User-Agent": USER_AGENT2 },
22055
22267
  timeout: 1e4
22056
22268
  });
22057
- const $ = cheerio61.load(response.data);
22269
+ const $ = cheerio62.load(response.data);
22058
22270
  const results = [];
22059
22271
  const relatedSearches = [];
22060
22272
  const peopleAlsoAsk = [];
@@ -33755,6 +33967,544 @@ function getAIVisibilitySummary(results) {
33755
33967
  };
33756
33968
  }
33757
33969
 
33970
+ // src/ranking/types.ts
33971
+ var TIER_LIMITS = {
33972
+ free: {
33973
+ maxKeywords: 10,
33974
+ checksPerDay: 1,
33975
+ serpFeatures: false,
33976
+ competitorTracking: false,
33977
+ historyDays: 7
33978
+ },
33979
+ solo: {
33980
+ maxKeywords: 100,
33981
+ checksPerDay: 1,
33982
+ serpFeatures: true,
33983
+ competitorTracking: false,
33984
+ historyDays: 30
33985
+ },
33986
+ pro: {
33987
+ maxKeywords: 500,
33988
+ checksPerDay: 2,
33989
+ serpFeatures: true,
33990
+ competitorTracking: true,
33991
+ historyDays: 90
33992
+ },
33993
+ agency: {
33994
+ maxKeywords: 2e3,
33995
+ checksPerDay: 4,
33996
+ serpFeatures: true,
33997
+ competitorTracking: true,
33998
+ historyDays: 365
33999
+ }
34000
+ };
34001
+
34002
+ // src/ranking/serp-client.ts
34003
+ var SerpClient = class {
34004
+ config;
34005
+ constructor(config) {
34006
+ this.config = config;
34007
+ }
34008
+ /**
34009
+ * Check ranking for a single keyword
34010
+ */
34011
+ async checkRank(options) {
34012
+ const results = [];
34013
+ for (const keyword of options.keywords) {
34014
+ try {
34015
+ const result = await this.checkSingleKeyword({
34016
+ ...options,
34017
+ keyword
34018
+ });
34019
+ results.push(result);
34020
+ } catch (error) {
34021
+ console.error(`Error checking rank for "${keyword}":`, error);
34022
+ results.push({
34023
+ keyword,
34024
+ position: null,
34025
+ url: null,
34026
+ serpFeatures: [],
34027
+ topResults: [],
34028
+ checkedAt: /* @__PURE__ */ new Date()
34029
+ });
34030
+ }
34031
+ }
34032
+ return results;
34033
+ }
34034
+ async checkSingleKeyword(options) {
34035
+ switch (this.config.provider) {
34036
+ case "valueserp":
34037
+ return this.checkViaValueSerp(options);
34038
+ case "serpapi":
34039
+ return this.checkViaSerpApi(options);
34040
+ case "direct":
34041
+ default:
34042
+ return this.checkViaDirect(options);
34043
+ }
34044
+ }
34045
+ /**
34046
+ * ValueSERP API implementation
34047
+ * Docs: https://www.valueserp.com/docs
34048
+ */
34049
+ async checkViaValueSerp(options) {
34050
+ if (!this.config.apiKey) {
34051
+ throw new Error("ValueSERP API key required");
34052
+ }
34053
+ const params = new URLSearchParams({
34054
+ api_key: this.config.apiKey,
34055
+ q: options.keyword,
34056
+ location: options.country || "United States",
34057
+ google_domain: options.country === "US" ? "google.com" : `google.${options.country?.toLowerCase() || "com"}`,
34058
+ gl: options.country || "us",
34059
+ hl: options.language || "en",
34060
+ device: options.device || "desktop",
34061
+ num: "100"
34062
+ // Get top 100 results
34063
+ });
34064
+ const response = await fetch(`https://api.valueserp.com/search?${params}`);
34065
+ if (!response.ok) {
34066
+ throw new Error(`ValueSERP API error: ${response.status}`);
34067
+ }
34068
+ const data = await response.json();
34069
+ return this.parseValueSerpResponse(data, options.domain, options.keyword);
34070
+ }
34071
+ /**
34072
+ * SerpAPI implementation (alternative)
34073
+ * Docs: https://serpapi.com/search-api
34074
+ */
34075
+ async checkViaSerpApi(options) {
34076
+ if (!this.config.apiKey) {
34077
+ throw new Error("SerpAPI key required");
34078
+ }
34079
+ const params = new URLSearchParams({
34080
+ api_key: this.config.apiKey,
34081
+ q: options.keyword,
34082
+ location: options.country === "US" ? "United States" : options.country || "United States",
34083
+ gl: options.country?.toLowerCase() || "us",
34084
+ hl: options.language || "en",
34085
+ device: options.device || "desktop",
34086
+ num: "100"
34087
+ });
34088
+ const response = await fetch(`https://serpapi.com/search?${params}`);
34089
+ if (!response.ok) {
34090
+ throw new Error(`SerpAPI error: ${response.status}`);
34091
+ }
34092
+ const data = await response.json();
34093
+ return this.parseSerpApiResponse(data, options.domain, options.keyword);
34094
+ }
34095
+ /**
34096
+ * Direct scraping fallback (rate-limited, use with caution)
34097
+ */
34098
+ async checkViaDirect(options) {
34099
+ const searchUrl = `https://www.google.com/search?q=${encodeURIComponent(options.keyword)}&num=100`;
34100
+ const headers = {
34101
+ "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",
34102
+ "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
34103
+ "Accept-Language": options.language || "en-US,en;q=0.9"
34104
+ };
34105
+ const response = await fetch(searchUrl, { headers });
34106
+ if (!response.ok) {
34107
+ throw new Error(`Direct search failed: ${response.status}`);
34108
+ }
34109
+ const html = await response.text();
34110
+ return this.parseDirectSearchResults(html, options.domain, options.keyword);
34111
+ }
34112
+ parseValueSerpResponse(data, domain, keyword) {
34113
+ const topResults = [];
34114
+ const serpFeatures = [];
34115
+ let position = null;
34116
+ let url = null;
34117
+ if (data.organic_results) {
34118
+ for (let i = 0; i < data.organic_results.length; i++) {
34119
+ const result = data.organic_results[i];
34120
+ const resultDomain = this.extractDomain(result.link);
34121
+ topResults.push({
34122
+ position: i + 1,
34123
+ url: result.link,
34124
+ domain: resultDomain,
34125
+ title: result.title
34126
+ });
34127
+ if (this.domainMatches(resultDomain, domain) && position === null) {
34128
+ position = i + 1;
34129
+ url = result.link;
34130
+ }
34131
+ }
34132
+ }
34133
+ if (data.answer_box) {
34134
+ serpFeatures.push({
34135
+ type: "featured_snippet",
34136
+ position: 0,
34137
+ hasOwnSite: this.domainMatches(this.extractDomain(data.answer_box.link || ""), domain)
34138
+ });
34139
+ }
34140
+ if (data.people_also_ask) {
34141
+ serpFeatures.push({
34142
+ type: "people_also_ask",
34143
+ hasOwnSite: data.people_also_ask.some(
34144
+ (paa) => this.domainMatches(this.extractDomain(paa.link || ""), domain)
34145
+ )
34146
+ });
34147
+ }
34148
+ if (data.local_results) {
34149
+ serpFeatures.push({ type: "local_pack" });
34150
+ }
34151
+ if (data.knowledge_graph) {
34152
+ serpFeatures.push({ type: "knowledge_panel" });
34153
+ }
34154
+ return {
34155
+ keyword,
34156
+ position,
34157
+ url,
34158
+ serpFeatures,
34159
+ topResults: topResults.slice(0, 10),
34160
+ checkedAt: /* @__PURE__ */ new Date()
34161
+ };
34162
+ }
34163
+ parseSerpApiResponse(data, domain, keyword) {
34164
+ const topResults = [];
34165
+ const serpFeatures = [];
34166
+ let position = null;
34167
+ let url = null;
34168
+ if (data.organic_results) {
34169
+ for (let i = 0; i < data.organic_results.length; i++) {
34170
+ const result = data.organic_results[i];
34171
+ const resultDomain = this.extractDomain(result.link);
34172
+ topResults.push({
34173
+ position: result.position || i + 1,
34174
+ url: result.link,
34175
+ domain: resultDomain,
34176
+ title: result.title
34177
+ });
34178
+ if (this.domainMatches(resultDomain, domain) && position === null) {
34179
+ position = result.position || i + 1;
34180
+ url = result.link;
34181
+ }
34182
+ }
34183
+ }
34184
+ if (data.answer_box) {
34185
+ serpFeatures.push({
34186
+ type: "featured_snippet",
34187
+ position: 0
34188
+ });
34189
+ }
34190
+ if (data.related_questions) {
34191
+ serpFeatures.push({ type: "people_also_ask" });
34192
+ }
34193
+ return {
34194
+ keyword,
34195
+ position,
34196
+ url,
34197
+ serpFeatures,
34198
+ topResults: topResults.slice(0, 10),
34199
+ checkedAt: /* @__PURE__ */ new Date()
34200
+ };
34201
+ }
34202
+ parseDirectSearchResults(html, domain, keyword) {
34203
+ const topResults = [];
34204
+ let position = null;
34205
+ let url = null;
34206
+ const linkRegex = /<a[^>]+href="\/url\?q=([^"&]+)/g;
34207
+ let match;
34208
+ let index = 0;
34209
+ while ((match = linkRegex.exec(html)) !== null && index < 100) {
34210
+ try {
34211
+ const decodedUrl = decodeURIComponent(match[1]);
34212
+ const resultDomain = this.extractDomain(decodedUrl);
34213
+ if (resultDomain.includes("google.com") || resultDomain.includes("gstatic.com")) {
34214
+ continue;
34215
+ }
34216
+ index++;
34217
+ topResults.push({
34218
+ position: index,
34219
+ url: decodedUrl,
34220
+ domain: resultDomain
34221
+ });
34222
+ if (this.domainMatches(resultDomain, domain) && position === null) {
34223
+ position = index;
34224
+ url = decodedUrl;
34225
+ }
34226
+ } catch {
34227
+ }
34228
+ }
34229
+ return {
34230
+ keyword,
34231
+ position,
34232
+ url,
34233
+ serpFeatures: [],
34234
+ // Direct scraping doesn't easily extract SERP features
34235
+ topResults: topResults.slice(0, 10),
34236
+ checkedAt: /* @__PURE__ */ new Date()
34237
+ };
34238
+ }
34239
+ extractDomain(url) {
34240
+ try {
34241
+ const parsed = new URL(url);
34242
+ return parsed.hostname.replace(/^www\./, "");
34243
+ } catch {
34244
+ return "";
34245
+ }
34246
+ }
34247
+ domainMatches(resultDomain, targetDomain) {
34248
+ const normalizedResult = resultDomain.toLowerCase().replace(/^www\./, "");
34249
+ const normalizedTarget = targetDomain.toLowerCase().replace(/^www\./, "");
34250
+ return normalizedResult === normalizedTarget || normalizedResult.endsWith(`.${normalizedTarget}`);
34251
+ }
34252
+ };
34253
+
34254
+ // src/ranking/tracker.ts
34255
+ var RankTracker = class {
34256
+ supabase;
34257
+ serpClient;
34258
+ constructor(config) {
34259
+ this.supabase = config.supabase;
34260
+ this.serpClient = new SerpClient(config.serpConfig);
34261
+ }
34262
+ /**
34263
+ * Add keywords to track for a project
34264
+ */
34265
+ async addKeywords(projectId, keywords, options) {
34266
+ const keywordRecords = keywords.map((keyword) => ({
34267
+ project_id: projectId,
34268
+ keyword: keyword.toLowerCase().trim(),
34269
+ search_engine: options?.searchEngine || "google",
34270
+ country: options?.country || "US",
34271
+ language: options?.language || "en",
34272
+ track_url: options?.trackUrl,
34273
+ is_active: true
34274
+ }));
34275
+ const { data, error } = await this.supabase.from("keywords").upsert(keywordRecords, {
34276
+ onConflict: "project_id,keyword",
34277
+ ignoreDuplicates: false
34278
+ }).select();
34279
+ if (error) {
34280
+ throw new Error(`Failed to add keywords: ${error.message}`);
34281
+ }
34282
+ return (data || []).map(this.mapKeyword);
34283
+ }
34284
+ /**
34285
+ * Remove keywords from tracking
34286
+ */
34287
+ async removeKeywords(projectId, keywords) {
34288
+ const normalizedKeywords = keywords.map((k) => k.toLowerCase().trim());
34289
+ const { error } = await this.supabase.from("keywords").update({ is_active: false }).eq("project_id", projectId).in("keyword", normalizedKeywords);
34290
+ if (error) {
34291
+ throw new Error(`Failed to remove keywords: ${error.message}`);
34292
+ }
34293
+ }
34294
+ /**
34295
+ * Get all tracked keywords for a project
34296
+ */
34297
+ async getKeywords(projectId, includeInactive = false) {
34298
+ let query = this.supabase.from("keywords").select("*").eq("project_id", projectId);
34299
+ if (!includeInactive) {
34300
+ query = query.eq("is_active", true);
34301
+ }
34302
+ const { data, error } = await query;
34303
+ if (error) {
34304
+ throw new Error(`Failed to get keywords: ${error.message}`);
34305
+ }
34306
+ return (data || []).map(this.mapKeyword);
34307
+ }
34308
+ /**
34309
+ * Check rankings for all active keywords in a project
34310
+ */
34311
+ async checkRankings(projectId, domain) {
34312
+ const keywords = await this.getKeywords(projectId);
34313
+ if (keywords.length === 0) {
34314
+ return [];
34315
+ }
34316
+ const groups = this.groupKeywords(keywords);
34317
+ const results = [];
34318
+ for (const group of groups) {
34319
+ const checkResults = await this.serpClient.checkRank({
34320
+ keywords: group.keywords.map((k) => k.keyword),
34321
+ domain,
34322
+ searchEngine: group.searchEngine,
34323
+ country: group.country,
34324
+ language: group.language
34325
+ });
34326
+ for (const result of checkResults) {
34327
+ const keyword = group.keywords.find((k) => k.keyword === result.keyword);
34328
+ if (!keyword) continue;
34329
+ await this.saveRanking(keyword.id, result);
34330
+ results.push({
34331
+ keywordId: keyword.id,
34332
+ keyword: result.keyword,
34333
+ position: result.position,
34334
+ url: result.url,
34335
+ serpFeatures: result.serpFeatures,
34336
+ competitorUrls: result.topResults,
34337
+ checkedAt: result.checkedAt
34338
+ });
34339
+ }
34340
+ }
34341
+ await this.supabase.from("projects").update({ last_rank_check_at: (/* @__PURE__ */ new Date()).toISOString() }).eq("id", projectId);
34342
+ return results;
34343
+ }
34344
+ /**
34345
+ * Save a ranking result to the database
34346
+ */
34347
+ async saveRanking(keywordId, result) {
34348
+ const { data: currentKeyword } = await this.supabase.from("keywords").select("current_position, best_position").eq("id", keywordId).single();
34349
+ const previousPosition = currentKeyword?.current_position;
34350
+ const bestPosition = currentKeyword?.best_position;
34351
+ const newBestPosition = result.position !== null && (bestPosition === null || result.position < bestPosition) ? result.position : bestPosition;
34352
+ const { error: updateError } = await this.supabase.from("keywords").update({
34353
+ previous_position: previousPosition,
34354
+ current_position: result.position,
34355
+ best_position: newBestPosition,
34356
+ last_checked: result.checkedAt.toISOString()
34357
+ }).eq("id", keywordId);
34358
+ if (updateError) {
34359
+ console.error(`Failed to save ranking for ${result.keyword}:`, updateError);
34360
+ }
34361
+ }
34362
+ /**
34363
+ * Get ranking history for a keyword
34364
+ * Note: Full history requires keyword_ranking_history table with keyword_ranking_id
34365
+ * For now, returns current state as single history entry
34366
+ */
34367
+ async getHistory(keywordId, _days = 30) {
34368
+ const { data, error } = await this.supabase.from("keywords").select("current_position, target_url, last_checked").eq("id", keywordId).single();
34369
+ if (error || !data) {
34370
+ return [];
34371
+ }
34372
+ return [{
34373
+ position: data.current_position,
34374
+ url: data.target_url,
34375
+ serpFeatures: [],
34376
+ recordedAt: data.last_checked ? new Date(data.last_checked) : /* @__PURE__ */ new Date()
34377
+ }];
34378
+ }
34379
+ /**
34380
+ * Get keyword trends for a project
34381
+ */
34382
+ async getTrends(projectId) {
34383
+ const { data, error } = await this.supabase.from("keyword_rank_trends").select("*").eq("project_id", projectId);
34384
+ if (error) {
34385
+ return this.calculateTrends(projectId);
34386
+ }
34387
+ return (data || []).map((row) => ({
34388
+ keywordId: row.keyword_id,
34389
+ keyword: row.keyword,
34390
+ currentPosition: row.current_position,
34391
+ bestPosition: row.best_position,
34392
+ positionChange: row.position_change || 0,
34393
+ avgPosition: row.avg_position || 0,
34394
+ dataPoints: row.data_points || 0
34395
+ }));
34396
+ }
34397
+ /**
34398
+ * Manual trend calculation fallback
34399
+ */
34400
+ async calculateTrends(projectId) {
34401
+ const keywords = await this.getKeywords(projectId);
34402
+ const trends = [];
34403
+ for (const keyword of keywords) {
34404
+ const history = await this.getHistory(keyword.id, 30);
34405
+ if (history.length === 0) {
34406
+ trends.push({
34407
+ keywordId: keyword.id,
34408
+ keyword: keyword.keyword,
34409
+ currentPosition: keyword.currentPosition,
34410
+ bestPosition: keyword.bestPosition,
34411
+ positionChange: 0,
34412
+ avgPosition: keyword.currentPosition || 0,
34413
+ dataPoints: 0
34414
+ });
34415
+ continue;
34416
+ }
34417
+ const positions = history.filter((h) => h.position !== null).map((h) => h.position);
34418
+ const avgPosition = positions.length > 0 ? positions.reduce((a, b) => a + b, 0) / positions.length : 0;
34419
+ const positionChange = keyword.previousPosition && keyword.currentPosition ? keyword.previousPosition - keyword.currentPosition : 0;
34420
+ trends.push({
34421
+ keywordId: keyword.id,
34422
+ keyword: keyword.keyword,
34423
+ currentPosition: keyword.currentPosition,
34424
+ bestPosition: keyword.bestPosition,
34425
+ positionChange,
34426
+ avgPosition,
34427
+ dataPoints: history.length
34428
+ });
34429
+ }
34430
+ return trends;
34431
+ }
34432
+ /**
34433
+ * Export ranking data as CSV
34434
+ */
34435
+ async exportCSV(projectId, days = 30) {
34436
+ const keywords = await this.getKeywords(projectId);
34437
+ const rows = ["Keyword,Current Position,Best Position,Last Checked,Trend"];
34438
+ for (const keyword of keywords) {
34439
+ const history = await this.getHistory(keyword.id, days);
34440
+ const trend = this.calculatePositionTrend(history);
34441
+ rows.push([
34442
+ `"${keyword.keyword}"`,
34443
+ keyword.currentPosition?.toString() || "N/A",
34444
+ keyword.bestPosition?.toString() || "N/A",
34445
+ keyword.lastChecked?.toISOString() || "Never",
34446
+ trend
34447
+ ].join(","));
34448
+ }
34449
+ return rows.join("\n");
34450
+ }
34451
+ /**
34452
+ * Calculate position trend from history
34453
+ */
34454
+ calculatePositionTrend(history) {
34455
+ if (history.length < 2) return "\u2192";
34456
+ const recent = history[0]?.position;
34457
+ const older = history[history.length - 1]?.position;
34458
+ if (recent === null || older === null) return "\u2192";
34459
+ if (recent < older) return "\u2191";
34460
+ if (recent > older) return "\u2193";
34461
+ return "\u2192";
34462
+ }
34463
+ /**
34464
+ * Group keywords by search engine and country
34465
+ */
34466
+ groupKeywords(keywords) {
34467
+ const groups = /* @__PURE__ */ new Map();
34468
+ for (const keyword of keywords) {
34469
+ const key = `${keyword.searchEngine}:${keyword.country}:${keyword.language}`;
34470
+ if (!groups.has(key)) {
34471
+ groups.set(key, []);
34472
+ }
34473
+ groups.get(key).push(keyword);
34474
+ }
34475
+ return Array.from(groups.entries()).map(([key, keywords2]) => {
34476
+ const [searchEngine, country, language] = key.split(":");
34477
+ return {
34478
+ searchEngine,
34479
+ country,
34480
+ language,
34481
+ keywords: keywords2
34482
+ };
34483
+ });
34484
+ }
34485
+ /**
34486
+ * Map database row to TrackedKeyword
34487
+ */
34488
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
34489
+ mapKeyword(row) {
34490
+ return {
34491
+ id: row.id,
34492
+ projectId: row.project_id,
34493
+ keyword: row.keyword,
34494
+ searchEngine: row.search_engine || "google",
34495
+ country: row.country || "US",
34496
+ language: row.language || "en",
34497
+ currentPosition: row.current_position,
34498
+ previousPosition: row.previous_position,
34499
+ bestPosition: row.best_position,
34500
+ trackUrl: row.track_url,
34501
+ isActive: row.is_active,
34502
+ lastChecked: row.last_checked ? new Date(row.last_checked) : null,
34503
+ createdAt: new Date(row.created_at)
34504
+ };
34505
+ }
34506
+ };
34507
+
33758
34508
  // src/analyzers/index.ts
33759
34509
  var analyzers_exports = {};
33760
34510
  __export(analyzers_exports, {
@@ -34937,9 +35687,12 @@ export {
34937
35687
  LOCATION_CODES,
34938
35688
  OG_IMAGE_SPECS,
34939
35689
  PRIORITY_WEIGHTS,
35690
+ RankTracker,
34940
35691
  SEO_SCOPES,
34941
35692
  SITE_PROFILE_QUESTIONS,
34942
35693
  Schemas,
35694
+ SerpClient,
35695
+ TIER_LIMITS,
34943
35696
  addTrackingResult,
34944
35697
  analyzeAnchorText,
34945
35698
  analyzeCanonicalAdvanced,