geo-ai-search-optimization 2.3.0 → 2.5.0

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/README.md CHANGED
@@ -193,7 +193,7 @@ Full TypeScript declarations included (`index.d.ts`) — 230+ exports with IDE a
193
193
  ## GitHub Action
194
194
 
195
195
  ```yaml
196
- - uses: redredchen01/geo-ai-search-optimization@v2.2.0
196
+ - uses: redredchen01/geo-ai-search-optimization@v2.5.0
197
197
  with:
198
198
  project-path: ./your-project
199
199
  min-score: 60
package/action.yml CHANGED
@@ -51,23 +51,24 @@ runs:
51
51
 
52
52
  - name: Install geo-ai-search-optimization
53
53
  shell: bash
54
- run: npm install -g geo-ai-search-optimization
54
+ run: npm install -g geo-ai-search-optimization@2.5.0
55
55
 
56
56
  - name: Run GEO Audit
57
57
  id: audit
58
58
  shell: bash
59
+ env:
60
+ PROJ_PATH: ${{ inputs.project-path }}
61
+ BASELINE: ${{ inputs.baseline }}
62
+ MIN_SCORE: ${{ inputs.min-score }}
63
+ FAIL_ON_REGRESSION: ${{ inputs.fail-on-regression }}
59
64
  run: |
60
- PROJ_PATH="${{ inputs.project-path }}"
61
- BASELINE="${{ inputs.baseline }}"
62
- MIN_SCORE="${{ inputs.min-score }}"
63
-
64
65
  ARGS=("$PROJ_PATH" "--json")
65
66
 
66
67
  if [ -n "$BASELINE" ]; then
67
68
  ARGS+=("--baseline" "$BASELINE")
68
69
  fi
69
70
 
70
- if [ "${{ inputs.fail-on-regression }}" = "true" ]; then
71
+ if [ "$FAIL_ON_REGRESSION" = "true" ]; then
71
72
  ARGS+=("--fail-on-regression")
72
73
  fi
73
74
 
@@ -99,14 +100,18 @@ runs:
99
100
  - name: Save Snapshot
100
101
  if: inputs.save-snapshot == 'true'
101
102
  shell: bash
103
+ env:
104
+ PROJ_PATH: ${{ inputs.project-path }}
102
105
  run: |
103
- geo-ai-search-optimization audit "${{ inputs.project-path }}" --save --json > /dev/null 2>&1 || true
106
+ geo-ai-search-optimization audit "$PROJ_PATH" --save --json > /dev/null 2>&1 || true
104
107
 
105
108
  - name: Generate Markdown Report
106
109
  if: inputs.output-format == 'markdown'
107
110
  shell: bash
111
+ env:
112
+ PROJ_PATH: ${{ inputs.project-path }}
108
113
  run: |
109
- geo-ai-search-optimization audit "${{ inputs.project-path }}" --out geo-audit-report.md || true
114
+ geo-ai-search-optimization audit "$PROJ_PATH" --out geo-audit-report.md || true
110
115
 
111
116
  - name: Post Summary
112
117
  if: always()
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "geo-ai-search-optimization",
3
- "version": "2.3.0",
3
+ "version": "2.5.0",
4
4
  "description": "Install and run a Generative Engine Optimization (GEO)-first, SEO-supported Codex skill for website optimization.",
5
5
  "type": "module",
6
6
  "bin": {
package/src/auto-fix.js CHANGED
@@ -1,5 +1,3 @@
1
- import fs from "node:fs/promises";
2
- import path from "node:path";
3
1
  import { writeScanOutput } from "./scan.js";
4
2
  import { auditPage } from "./page-audit.js";
5
3
 
@@ -50,10 +48,14 @@ function generateMetaTags(metadata, signals) {
50
48
  return tags;
51
49
  }
52
50
 
51
+ function escapeHtmlAttr(str) {
52
+ return str.replace(/&/g, "&amp;").replace(/"/g, "&quot;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
53
+ }
54
+
53
55
  function generateOgTags(metadata) {
54
- const title = metadata.title || "Your Page Title";
55
- const description = metadata.metaDescription || "A concise description of your page.";
56
- const url = metadata.canonical || "https://yoursite.com/this-page";
56
+ const title = escapeHtmlAttr(metadata.title || "Your Page Title");
57
+ const description = escapeHtmlAttr(metadata.metaDescription || "A concise description of your page.");
58
+ const url = escapeHtmlAttr(metadata.canonical || "https://yoursite.com/this-page");
57
59
 
58
60
  return [
59
61
  { tag: "og:title", html: `<meta property="og:title" content="${title}">` },
@@ -66,7 +66,7 @@ export async function batchFullPageAudit(urls, options = {}) {
66
66
  const commonIssues = Object.entries(issueCounts)
67
67
  .sort((a, b) => b[1] - a[1])
68
68
  .slice(0, 10)
69
- .map(([issue, count]) => ({ issue, count, percentage: Math.round((count / results.length) * 100) }));
69
+ .map(([issue, count]) => ({ issue, count, percentage: results.length > 0 ? Math.round((count / results.length) * 100) : 0 }));
70
70
 
71
71
  return {
72
72
  kind: "geo-batch-full-page-audit",
@@ -116,7 +116,8 @@ export function renderBatchFullPageAuditMarkdown(report) {
116
116
 
117
117
  lines.push("", "## Page Results", "", "| Page | Composite | Label |", "|------|-----------|-------|");
118
118
  for (const result of report.results) {
119
- const short = result.input.length > 50 ? `...${result.input.slice(-47)}` : result.input;
119
+ const inputStr = result.input || "";
120
+ const short = inputStr.length > 50 ? `...${inputStr.slice(-47)}` : inputStr;
120
121
  const icon = result.compositeScore >= 70 ? "🟢" : result.compositeScore >= 40 ? "🟡" : "🔴";
121
122
  lines.push(`| ${short} | ${icon} ${result.compositeScore}/100 | ${result.compositeLabel} |`);
122
123
  }
package/src/citability.js CHANGED
@@ -1,5 +1,6 @@
1
1
  import fs from "node:fs/promises";
2
2
  import path from "node:path";
3
+ import { fetchText } from "./fetch-utils.js";
3
4
  import { writeScanOutput } from "./scan.js";
4
5
 
5
6
  function stripHtml(text) {
@@ -198,22 +199,12 @@ function buildRecommendations(claims, entities, quotable, structure, wordCount)
198
199
  return recs;
199
200
  }
200
201
 
201
- async function fetchContent(url) {
202
- const response = await fetch(url, {
203
- redirect: "follow",
204
- headers: { "user-agent": "geo-ai-search-optimization/2.2.0" },
205
- signal: AbortSignal.timeout(10_000)
206
- });
207
- if (!response.ok) throw new Error(`Failed to fetch: ${url} (status ${response.status})`);
208
- return response.text();
209
- }
210
-
211
202
  export async function analyzeCitability(input, options = {}) {
212
203
  let rawContent;
213
204
  let source;
214
205
 
215
206
  if (/^https?:\/\//i.test(input)) {
216
- rawContent = await fetchContent(input);
207
+ rawContent = await fetchText(input);
217
208
  source = input;
218
209
  } else {
219
210
  const filePath = path.resolve(input);
@@ -1,3 +1,4 @@
1
+ import { fetchTextSafe } from "./fetch-utils.js";
1
2
  import { writeScanOutput } from "./scan.js";
2
3
 
3
4
  const DEFAULT_ENGINES = ["perplexity", "google"];
@@ -19,23 +20,11 @@ function extractDomain(siteUrl) {
19
20
  }
20
21
 
21
22
  async function fetchWithTimeout(url, timeoutMs = 10000) {
22
- const controller = new AbortController();
23
- const timer = setTimeout(() => controller.abort(), timeoutMs);
24
- try {
25
- const response = await fetch(url, {
26
- signal: controller.signal,
27
- headers: {
28
- "user-agent": "geo-ai-search-optimization/2.2.0"
29
- },
30
- redirect: "follow"
31
- });
32
- const text = await response.text();
33
- return { ok: response.ok, status: response.status, text };
34
- } catch (error) {
35
- return { ok: false, status: 0, text: "", error: error.message };
36
- } finally {
37
- clearTimeout(timer);
23
+ const result = await fetchTextSafe(url, { timeout: timeoutMs });
24
+ if (result.ok) {
25
+ return { ok: true, status: 200, text: result.text };
38
26
  }
27
+ return { ok: false, status: 0, text: "", error: result.error };
39
28
  }
40
29
 
41
30
  function checkTextForCitation(text, domain) {
@@ -57,6 +57,12 @@ import { comparePages, renderCompareMarkdown, writeCompareOutput } from "./compa
57
57
  import { deepBenchmark, renderDeepBenchmarkMarkdown, writeDeepBenchmarkOutput } from "./deep-benchmark.js";
58
58
  import { quickSummary, renderSummaryMarkdown } from "./summary.js";
59
59
  import { fullPageAuditToCsv, batchFullPageAuditToCsv, deepBenchmarkToCsv, compareToCsv } from "./csv-export.js";
60
+ import { explain, renderExplainMarkdown, writeExplainOutput } from "./explain.js";
61
+ import { monitorPage, renderMonitorMarkdown, writeMonitorOutput } from "./monitor.js";
62
+ import { analyzeLinkQuality, renderLinkQualityMarkdown, writeLinkQualityOutput } from "./link-quality.js";
63
+ import { analyzeContentFreshness, renderContentFreshnessMarkdown, writeContentFreshnessOutput } from "./content-freshness.js";
64
+ import { trackCompetitors, renderCompetitorTrackingMarkdown, writeCompetitorTrackingOutput } from "./competitor-tracking.js";
65
+ import { generateLlmsTxt, optimizeLlmsTxt, renderGenerateLlmsMarkdown, renderOptimizeLlmsMarkdown, writeGenerateLlmsOutput, writeOptimizeLlmsOutput } from "./optimize-llms.js";
60
66
 
61
67
  export const SITE_OPS_HELP_LINES = [
62
68
  " geo-ai-search-optimization doctor [--json]",
@@ -104,7 +110,14 @@ export const SITE_OPS_HELP_LINES = [
104
110
  " geo-ai-search-optimization diagnose <url-or-dir-or-file> [--json] [--out <file>]",
105
111
  " geo-ai-search-optimization compare <url-or-file-A> <url-or-file-B> [--json] [--csv] [--out <file>]",
106
112
  " geo-ai-search-optimization deep-benchmark <url> --competitors <url1,url2,...> [--concurrency <n>] [--json] [--csv] [--out <file>]",
107
- " geo-ai-search-optimization summary <url-or-dir-or-file>"
113
+ " geo-ai-search-optimization summary <url-or-dir-or-file> [--json]",
114
+ " geo-ai-search-optimization explain <url-or-file> [--dimension <dim>] [--json] [--out <file>]",
115
+ " geo-ai-search-optimization monitor <url-or-file> [--threshold <n>] [--data-dir <dir>] [--json] [--out <file>]",
116
+ " geo-ai-search-optimization link-quality <url-or-file> [--base-url <url>] [--skip-broken-check] [--json] [--out <file>]",
117
+ " geo-ai-search-optimization content-freshness <url-or-file> [--json] [--out <file>]",
118
+ " geo-ai-search-optimization track-competitors <url> --competitors <url1,url2,...> [--concurrency <n>] [--data-dir <dir>] [--alert-threshold <n>] [--json] [--out <file>]",
119
+ " geo-ai-search-optimization generate-llms <url-or-file> [--site-name <name>] [--site-url <url>] [--site-type <type>] [--json] [--out <file>]",
120
+ " geo-ai-search-optimization optimize-llms <url-or-file> [--json] [--out <file>]"
108
121
  ];
109
122
 
110
123
  const passthroughWriteOutput = async (outputPath) => outputPath;
@@ -710,6 +723,34 @@ const handleBatchFullPageAudit = createStructuredOutputCommandHandler({
710
723
  getOutputJson: (args) => hasFlag(args, "--json")
711
724
  });
712
725
 
726
+ const handleExplain = createStructuredOutputCommandHandler({
727
+ commandLabel: "explain",
728
+ execute: async (args) => {
729
+ const input = args.find((value) => !value.startsWith("-"));
730
+ if (!input) throw new Error("explain requires a URL or file path");
731
+ return explain(input, { dimension: getFlagValue(args, "--dimension") || undefined });
732
+ },
733
+ renderMarkdown: renderExplainMarkdown,
734
+ writeOutput: writeExplainOutput,
735
+ getOutputJson: (args) => hasFlag(args, "--json")
736
+ });
737
+
738
+ const handleMonitor = createStructuredOutputCommandHandler({
739
+ commandLabel: "monitor",
740
+ execute: async (args) => {
741
+ const input = args.find((value) => !value.startsWith("-"));
742
+ if (!input) throw new Error("monitor requires a URL or file path");
743
+ const thresholdValue = getFlagValue(args, "--threshold");
744
+ return monitorPage(input, {
745
+ threshold: thresholdValue ? parsePositiveInteger(thresholdValue, "--threshold") : 5,
746
+ dataDir: getFlagValue(args, "--data-dir") || undefined
747
+ });
748
+ },
749
+ renderMarkdown: renderMonitorMarkdown,
750
+ writeOutput: writeMonitorOutput,
751
+ getOutputJson: (args) => hasFlag(args, "--json")
752
+ });
753
+
713
754
  const handleDeepBenchmark = createStructuredOutputCommandHandler({
714
755
  commandLabel: "deep benchmark",
715
756
  execute: async (args) => {
@@ -766,7 +807,7 @@ const handleCompare = createStructuredOutputCommandHandler({
766
807
  renderMarkdown: (report) => renderCompareMarkdown(report),
767
808
  writeOutput: writeCompareOutput,
768
809
  getOutputJson: (args) => {
769
- if (hasFlag(args, "--csv")) return false; // Let renderMarkdown handle CSV
810
+ if (hasFlag(args, "--csv")) return false; // CSV handled by handleCompareCsv wrapper
770
811
  return hasFlag(args, "--json");
771
812
  }
772
813
  });
@@ -869,6 +910,83 @@ const handleFullAudit = createStructuredOutputCommandHandler({
869
910
  getOutputJson: (args) => hasFlag(args, "--json")
870
911
  });
871
912
 
913
+ const handleLinkQuality = createStructuredOutputCommandHandler({
914
+ commandLabel: "link quality analysis",
915
+ execute: async (args) => {
916
+ const input = args.find((value) => !value.startsWith("-"));
917
+ if (!input) throw new Error("link-quality requires a URL or file path");
918
+ return analyzeLinkQuality(input, {
919
+ baseUrl: getFlagValue(args, "--base-url"),
920
+ skipBrokenCheck: hasFlag(args, "--skip-broken-check")
921
+ });
922
+ },
923
+ renderMarkdown: renderLinkQualityMarkdown,
924
+ writeOutput: writeLinkQualityOutput,
925
+ getOutputJson: (args) => hasFlag(args, "--json")
926
+ });
927
+
928
+ const handleContentFreshness = createStructuredOutputCommandHandler({
929
+ commandLabel: "content freshness analysis",
930
+ execute: async (args) => {
931
+ const input = args.find((value) => !value.startsWith("-"));
932
+ if (!input) throw new Error("content-freshness requires a URL or file path");
933
+ return analyzeContentFreshness(input);
934
+ },
935
+ renderMarkdown: renderContentFreshnessMarkdown,
936
+ writeOutput: writeContentFreshnessOutput,
937
+ getOutputJson: (args) => hasFlag(args, "--json")
938
+ });
939
+
940
+ const handleTrackCompetitors = createStructuredOutputCommandHandler({
941
+ commandLabel: "competitor tracking",
942
+ execute: async (args) => {
943
+ const ownUrl = args.find((value) => !value.startsWith("-"));
944
+ if (!ownUrl) throw new Error("track-competitors requires your URL as the first argument");
945
+ const competitorsRaw = getFlagValue(args, "--competitors");
946
+ if (!competitorsRaw) throw new Error("track-competitors requires --competitors <url1,url2,...>");
947
+ const competitorUrls = competitorsRaw.split(",").map((u) => u.trim()).filter(Boolean);
948
+ const concurrencyValue = getFlagValue(args, "--concurrency");
949
+ const alertThresholdValue = getFlagValue(args, "--alert-threshold");
950
+ return trackCompetitors(ownUrl, competitorUrls, {
951
+ concurrency: concurrencyValue ? parsePositiveInteger(concurrencyValue, "--concurrency") : 2,
952
+ dataDir: getFlagValue(args, "--data-dir"),
953
+ alertThreshold: alertThresholdValue ? parsePositiveInteger(alertThresholdValue, "--alert-threshold") : 10
954
+ });
955
+ },
956
+ renderMarkdown: renderCompetitorTrackingMarkdown,
957
+ writeOutput: writeCompetitorTrackingOutput,
958
+ getOutputJson: (args) => hasFlag(args, "--json")
959
+ });
960
+
961
+ const handleGenerateLlms = createStructuredOutputCommandHandler({
962
+ commandLabel: "generate-llms",
963
+ execute: async (args) => {
964
+ const input = args.find((value) => !value.startsWith("-"));
965
+ if (!input) throw new Error("generate-llms requires a URL or file path");
966
+ return generateLlmsTxt(input, {
967
+ siteName: getFlagValue(args, "--site-name"),
968
+ siteUrl: getFlagValue(args, "--site-url"),
969
+ siteType: getFlagValue(args, "--site-type"),
970
+ outputPath: getFlagValue(args, "--out")
971
+ });
972
+ },
973
+ renderMarkdown: renderGenerateLlmsMarkdown,
974
+ writeOutput: writeGenerateLlmsOutput,
975
+ getOutputJson: (args) => hasFlag(args, "--json")
976
+ });
977
+
978
+ const handleOptimizeLlms = createStructuredOutputCommandHandler({
979
+ commandLabel: "optimize-llms",
980
+ execute: async (args) => {
981
+ const input = args.find((value) => !value.startsWith("-"));
982
+ if (!input) throw new Error("optimize-llms requires a URL or file path");
983
+ return optimizeLlmsTxt(input);
984
+ },
985
+ renderMarkdown: renderOptimizeLlmsMarkdown,
986
+ writeOutput: writeOptimizeLlmsOutput,
987
+ getOutputJson: (args) => hasFlag(args, "--json")
988
+ });
989
+
872
990
  export const SITE_OPS_COMMAND_HANDLERS = {
873
991
  doctor: handleDoctor,
874
992
  "quick-start": handleQuickStart,
@@ -915,5 +1033,12 @@ export const SITE_OPS_COMMAND_HANDLERS = {
915
1033
  diagnose: handleDiagnose,
916
1034
  compare: handleCompareCsv,
917
1035
  "deep-benchmark": handleDeepBenchmark,
918
- summary: handleSummary
1036
+ summary: handleSummary,
1037
+ explain: handleExplain,
1038
+ monitor: handleMonitor,
1039
+ "link-quality": handleLinkQuality,
1040
+ "content-freshness": handleContentFreshness,
1041
+ "track-competitors": handleTrackCompetitors,
1042
+ "generate-llms": handleGenerateLlms,
1043
+ "optimize-llms": handleOptimizeLlms
919
1044
  };
package/src/compare.js CHANGED
@@ -42,8 +42,8 @@ export async function comparePages(inputA, inputB, options = {}) {
42
42
  };
43
43
  }
44
44
 
45
- const compositeA = resultA.compositeScore;
46
- const compositeB = resultB.compositeScore;
45
+ const compositeA = resultA.compositeScore ?? 0;
46
+ const compositeB = resultB.compositeScore ?? 0;
47
47
  const compositeDelta = compositeA - compositeB;
48
48
 
49
49
  // Identify where each page is stronger
@@ -60,10 +60,10 @@ export async function comparePages(inputA, inputB, options = {}) {
60
60
  // Platform comparison
61
61
  const platformComparison = [];
62
62
  if (resultA.platformSummary && resultB.platformSummary) {
63
- for (let i = 0; i < resultA.platformSummary.length; i++) {
64
- const pA = resultA.platformSummary[i];
65
- const pB = resultB.platformSummary[i];
66
- if (pA && pB) {
63
+ const platformMapB = new Map(resultB.platformSummary.map((p) => [p.platform, p]));
64
+ for (const pA of resultA.platformSummary) {
65
+ const pB = platformMapB.get(pA.platform);
66
+ if (pB) {
67
67
  platformComparison.push({
68
68
  platform: pA.platform,
69
69
  scoreA: pA.score,
@@ -0,0 +1,275 @@
1
+ import fs from "node:fs/promises";
2
+ import path from "node:path";
3
+ import { fullPageAudit } from "./full-page-audit.js";
4
+ import { writeScanOutput } from "./scan.js";
5
+
6
+ const DEFAULT_DATA_DIR = ".geo-data/competitor-tracking";
7
+
8
+ function sanitizeKey(input) {
9
+ return input
10
+ .replace(/^https?:\/\//, "")
11
+ .replace(/[^a-zA-Z0-9.-]/g, "_")
12
+ .replace(/_+/g, "_")
13
+ .slice(0, 120);
14
+ }
15
+
16
+ function compositeLabel(score) {
17
+ if (score >= 80) return "Strong";
18
+ if (score >= 60) return "Moderate";
19
+ if (score >= 40) return "Weak";
20
+ return "Very weak";
21
+ }
22
+
23
+ async function runAuditsWithConcurrency(urls, concurrency, options) {
24
+ const results = new Map();
25
+ const queue = [...urls];
26
+ const running = [];
27
+
28
+ async function next() {
29
+ if (queue.length === 0) return;
30
+ const url = queue.shift();
31
+ try {
32
+ const audit = await fullPageAudit(url, options);
33
+ results.set(url, { ok: true, audit });
34
+ } catch (err) {
35
+ results.set(url, { ok: false, error: err.message });
36
+ }
37
+ await next();
38
+ }
39
+
40
+ for (let i = 0; i < Math.min(concurrency, queue.length); i++) {
41
+ running.push(next());
42
+ }
43
+ await Promise.all(running);
44
+ return results;
45
+ }
46
+
47
+ async function loadSnapshots(dataDir, ownKey) {
48
+ const dir = path.join(dataDir, ownKey);
49
+ let files;
50
+ try {
51
+ files = await fs.readdir(dir);
52
+ } catch {
53
+ return [];
54
+ }
55
+ const jsonFiles = files.filter((f) => f.endsWith(".json")).sort();
56
+ const snapshots = [];
57
+ for (const file of jsonFiles) {
58
+ try {
59
+ const content = await fs.readFile(path.join(dir, file), "utf8");
60
+ snapshots.push(JSON.parse(content));
61
+ } catch {
62
+ // skip corrupt
63
+ }
64
+ }
65
+ return snapshots;
66
+ }
67
+
68
+ async function saveSnapshot(dataDir, ownKey, snapshot) {
69
+ const dir = path.join(dataDir, ownKey);
70
+ await fs.mkdir(dir, { recursive: true });
71
+ const ts = new Date().toISOString().replace(/[:.]/g, "-");
72
+ const filePath = path.join(dir, `${ts}.json`);
73
+ await fs.writeFile(filePath, `${JSON.stringify(snapshot, null, 2)}\n`, "utf8");
74
+ return filePath;
75
+ }
76
+
77
+ export async function trackCompetitors(ownUrl, competitorUrls, options = {}) {
78
+ const dataDir = path.resolve(options.dataDir || DEFAULT_DATA_DIR);
79
+ const concurrency = options.concurrency || 2;
80
+ const alertThreshold = options.alertThreshold || 10;
81
+
82
+ const allUrls = [ownUrl, ...competitorUrls];
83
+ const auditResults = await runAuditsWithConcurrency(allUrls, concurrency, options);
84
+
85
+ const timestamp = new Date().toISOString();
86
+
87
+ // Own score
88
+ const ownResult = auditResults.get(ownUrl);
89
+ const ownScore = ownResult?.ok ? ownResult.audit.compositeScore : 0;
90
+
91
+ // Competitor scores
92
+ const competitorScores = competitorUrls.map((url) => {
93
+ const res = auditResults.get(url);
94
+ const score = res?.ok ? res.audit.compositeScore : 0;
95
+ return { url, score, label: compositeLabel(score) };
96
+ });
97
+
98
+ // Averages and ranking
99
+ const validCompScores = competitorScores.filter((c) => c.score > 0).map((c) => c.score);
100
+ const avgCompetitorScore = validCompScores.length > 0
101
+ ? Math.round(validCompScores.reduce((a, b) => a + b, 0) / validCompScores.length)
102
+ : 0;
103
+ const scoreDelta = ownScore - avgCompetitorScore;
104
+
105
+ // Rank: 1 = best
106
+ const allScores = [{ url: ownUrl, score: ownScore }, ...competitorScores];
107
+ allScores.sort((a, b) => b.score - a.score);
108
+ const ownRank = allScores.findIndex((s) => s.url === ownUrl) + 1;
109
+
110
+ // Load history
111
+ const ownKey = sanitizeKey(ownUrl);
112
+ const previousSnapshots = await loadSnapshots(dataDir, ownKey);
113
+
114
+ // Build snapshot data to save
115
+ const snapshotData = {
116
+ timestamp,
117
+ ownUrl,
118
+ ownScore,
119
+ competitorScores: competitorScores.map((c) => ({ url: c.url, score: c.score }))
120
+ };
121
+
122
+ const snapshotPath = await saveSnapshot(dataDir, ownKey, snapshotData);
123
+
124
+ // All snapshots including current
125
+ const allSnapshots = [...previousSnapshots, snapshotData];
126
+
127
+ // History
128
+ let history;
129
+ if (allSnapshots.length <= 1) {
130
+ history = {
131
+ trackingCount: allSnapshots.length,
132
+ firstTracked: timestamp,
133
+ latestTracked: timestamp,
134
+ ownTrend: [{ date: timestamp, score: ownScore }],
135
+ competitorTrends: {}
136
+ };
137
+ } else {
138
+ const ownTrend = allSnapshots.map((s) => ({ date: s.timestamp, score: s.ownScore }));
139
+ const competitorTrends = {};
140
+ for (const comp of competitorUrls) {
141
+ competitorTrends[comp] = allSnapshots
142
+ .map((s) => {
143
+ const found = s.competitorScores?.find((c) => c.url === comp);
144
+ return found ? { date: s.timestamp, score: found.score } : null;
145
+ })
146
+ .filter(Boolean);
147
+ }
148
+ history = {
149
+ trackingCount: allSnapshots.length,
150
+ firstTracked: allSnapshots[0].timestamp,
151
+ latestTracked: timestamp,
152
+ ownTrend,
153
+ competitorTrends
154
+ };
155
+ }
156
+
157
+ // Alerts
158
+ const alerts = [];
159
+ if (previousSnapshots.length > 0) {
160
+ const lastSnapshot = previousSnapshots[previousSnapshots.length - 1];
161
+ const lastOwnScore = lastSnapshot.ownScore;
162
+
163
+ // Own score dropped
164
+ if (lastOwnScore - ownScore >= alertThreshold) {
165
+ alerts.push({
166
+ type: "score-drop",
167
+ message: `Own score dropped from ${lastOwnScore} to ${ownScore} (delta: ${ownScore - lastOwnScore})`,
168
+ url: ownUrl,
169
+ delta: ownScore - lastOwnScore
170
+ });
171
+ }
172
+
173
+ // Own score improved
174
+ if (ownScore - lastOwnScore >= alertThreshold) {
175
+ alerts.push({
176
+ type: "improvement",
177
+ message: `Own score improved from ${lastOwnScore} to ${ownScore} (delta: +${ownScore - lastOwnScore})`,
178
+ url: ownUrl,
179
+ delta: ownScore - lastOwnScore
180
+ });
181
+ }
182
+ }
183
+
184
+ // Competitor overtook own score
185
+ for (const comp of competitorScores) {
186
+ if (comp.score > ownScore) {
187
+ alerts.push({
188
+ type: "overtaken",
189
+ message: `${comp.url} (${comp.score}) has overtaken own score (${ownScore})`,
190
+ url: comp.url,
191
+ delta: comp.score - ownScore
192
+ });
193
+ }
194
+ }
195
+
196
+ const summary = `Own: ${ownScore}/100 (${compositeLabel(ownScore)}), Rank: ${ownRank}/${allScores.length}, vs avg competitor: ${avgCompetitorScore} (delta: ${scoreDelta >= 0 ? "+" : ""}${scoreDelta}). ${alerts.length} alert(s).`;
197
+
198
+ return {
199
+ kind: "geo-competitor-tracking",
200
+ ownUrl,
201
+ competitorUrls,
202
+ timestamp,
203
+ ownScore,
204
+ competitorScores,
205
+ avgCompetitorScore,
206
+ scoreDelta,
207
+ ownRank,
208
+ history,
209
+ alerts,
210
+ snapshotPath,
211
+ summary
212
+ };
213
+ }
214
+
215
+ export function renderCompetitorTrackingMarkdown(report) {
216
+ const lines = [
217
+ "# GEO Competitor Tracking",
218
+ "",
219
+ `- Own URL: \`${report.ownUrl}\``,
220
+ `- Competitors: ${report.competitorUrls.length}`,
221
+ `- Timestamp: ${report.timestamp}`,
222
+ `- **Own Score: ${report.ownScore}/100 (${compositeLabel(report.ownScore)})**`,
223
+ `- Avg Competitor Score: ${report.avgCompetitorScore}`,
224
+ `- Score Delta (own - avg): ${report.scoreDelta >= 0 ? "+" : ""}${report.scoreDelta}`,
225
+ `- Rank: ${report.ownRank} / ${report.competitorScores.length + 1}`,
226
+ "",
227
+ "## Competitor Scores",
228
+ "",
229
+ "| URL | Score | Label |",
230
+ "|-----|-------|-------|"
231
+ ];
232
+
233
+ for (const comp of report.competitorScores) {
234
+ lines.push(`| ${comp.url} | ${comp.score}/100 | ${comp.label} |`);
235
+ }
236
+
237
+ if (report.alerts.length > 0) {
238
+ lines.push("", "## Alerts", "");
239
+ for (const alert of report.alerts) {
240
+ const icon = alert.type === "score-drop" ? "🔴" : alert.type === "improvement" ? "🟢" : "🟡";
241
+ lines.push(`- ${icon} **${alert.type}**: ${alert.message}`);
242
+ }
243
+ }
244
+
245
+ if (report.history) {
246
+ lines.push("", "## History", "");
247
+ lines.push(`- Tracking count: ${report.history.trackingCount}`);
248
+ lines.push(`- First tracked: ${report.history.firstTracked}`);
249
+ lines.push(`- Latest tracked: ${report.history.latestTracked}`);
250
+
251
+ if (report.history.ownTrend && report.history.ownTrend.length > 0) {
252
+ lines.push("", "### Own Score Trend", "");
253
+ for (const point of report.history.ownTrend) {
254
+ lines.push(`- \`${point.date.slice(0, 19)}\` — Score: **${point.score}**`);
255
+ }
256
+ }
257
+
258
+ if (report.history.competitorTrends && Object.keys(report.history.competitorTrends).length > 0) {
259
+ lines.push("", "### Competitor Trends", "");
260
+ for (const [url, trend] of Object.entries(report.history.competitorTrends)) {
261
+ lines.push(`**${url}**:`);
262
+ for (const point of trend) {
263
+ lines.push(`- \`${point.date.slice(0, 19)}\` — Score: **${point.score}**`);
264
+ }
265
+ }
266
+ }
267
+ }
268
+
269
+ lines.push("", `---`, "", `Summary: ${report.summary}`, "");
270
+ return lines.join("\n");
271
+ }
272
+
273
+ export async function writeCompetitorTrackingOutput(outputPath, content) {
274
+ return writeScanOutput(outputPath, content);
275
+ }
package/src/config.js CHANGED
@@ -90,15 +90,15 @@ export function mergeConfigWithOptions(config, cliOptions) {
90
90
  const merged = { ...cliOptions };
91
91
 
92
92
  if (config._found) {
93
- if (config.site?.url && !merged.siteUrl) merged.siteUrl = config.site.url;
94
- if (config.site?.name && !merged.siteName) merged.siteName = config.site.name;
93
+ if (config.site?.url && merged.siteUrl === undefined) merged.siteUrl = config.site.url;
94
+ if (config.site?.name && merged.siteName === undefined) merged.siteName = config.site.name;
95
95
  if (config.audit?.minScore !== undefined && merged.minScore === undefined) merged.minScore = config.audit.minScore;
96
96
  if (config.audit?.maxFileSize !== undefined && merged.maxFileSize === undefined) merged.maxFileSize = config.audit.maxFileSize;
97
97
  if (config.audit?.maxExamples !== undefined && merged.maxExamples === undefined) merged.maxExamples = config.audit.maxExamples;
98
- if (config.ci?.failOnRegression && merged.failOnRegression === undefined) merged.failOnRegression = config.ci.failOnRegression;
99
- if (config.output?.format && !merged.format) merged.format = config.output.format;
100
- if (config.output?.dataDir && !merged.dataDir) merged.dataDir = config.output.dataDir;
101
- if (config.crawlers?.strategy && !merged.strategy) merged.strategy = config.crawlers.strategy;
98
+ if (config.ci?.failOnRegression !== undefined && merged.failOnRegression === undefined) merged.failOnRegression = config.ci.failOnRegression;
99
+ if (config.output?.format && merged.format === undefined) merged.format = config.output.format;
100
+ if (config.output?.dataDir && merged.dataDir === undefined) merged.dataDir = config.output.dataDir;
101
+ if (config.crawlers?.strategy && merged.strategy === undefined) merged.strategy = config.crawlers.strategy;
102
102
  }
103
103
 
104
104
  return merged;