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 +1 -1
- package/action.yml +13 -8
- package/package.json +1 -1
- package/src/auto-fix.js +7 -5
- package/src/batch-full-page-audit.js +3 -2
- package/src/citability.js +2 -11
- package/src/citation-check.js +5 -16
- package/src/cli-site-ops-commands.js +128 -3
- package/src/compare.js +6 -6
- package/src/competitor-tracking.js +275 -0
- package/src/config.js +6 -6
- package/src/content-freshness.js +411 -0
- package/src/crawlers.js +16 -13
- package/src/deep-benchmark.js +3 -2
- package/src/eeat.js +2 -11
- package/src/explain.js +268 -0
- package/src/fetch-utils.js +12 -2
- package/src/freshness.js +4 -13
- package/src/full-audit.js +19 -2
- package/src/full-page-audit.js +10 -10
- package/src/heading-structure.js +2 -11
- package/src/index.d.ts +866 -1
- package/src/index.js +14 -1
- package/src/internal-links.js +2 -11
- package/src/link-quality.js +384 -0
- package/src/monitor.js +144 -0
- package/src/optimize-llms.js +583 -0
- package/src/page-audit.js +2 -14
- package/src/page-snapshot.js +1 -1
- package/src/pdf-report.js +9 -3
- package/src/platform-ready.js +2 -11
- package/src/plugins.js +1 -1
- package/src/readability.js +2 -11
- package/src/security.js +3 -18
- package/src/sitemap.js +4 -7
- package/src/social-meta.js +2 -14
- package/src/summary.js +1 -1
- package/src/topics.js +2 -11
- package/src/url-onboarding.js +2 -14
- package/src/validate-llms.js +25 -10
- package/src/validate-schema.js +14 -12
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.
|
|
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 [ "$
|
|
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 "$
|
|
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 "$
|
|
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
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, "&").replace(/"/g, """).replace(/</g, "<").replace(/>/g, ">");
|
|
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
|
|
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
|
|
207
|
+
rawContent = await fetchText(input);
|
|
217
208
|
source = input;
|
|
218
209
|
} else {
|
|
219
210
|
const filePath = path.resolve(input);
|
package/src/citation-check.js
CHANGED
|
@@ -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
|
|
23
|
-
|
|
24
|
-
|
|
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; //
|
|
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
|
-
|
|
64
|
-
|
|
65
|
-
const pB =
|
|
66
|
-
if (
|
|
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 &&
|
|
94
|
-
if (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 &&
|
|
100
|
-
if (config.output?.dataDir &&
|
|
101
|
-
if (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;
|