geo-ai-search-optimization 2.4.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 +95 -4
- 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 +1 -1
- package/src/fetch-utils.js +12 -2
- package/src/freshness.js +4 -13
- package/src/full-audit.js +1 -0
- 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 +12 -1
- package/src/internal-links.js +2 -11
- package/src/link-quality.js +384 -0
- package/src/monitor.js +3 -3
- 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) {
|
|
@@ -59,6 +59,10 @@ import { quickSummary, renderSummaryMarkdown } from "./summary.js";
|
|
|
59
59
|
import { fullPageAuditToCsv, batchFullPageAuditToCsv, deepBenchmarkToCsv, compareToCsv } from "./csv-export.js";
|
|
60
60
|
import { explain, renderExplainMarkdown, writeExplainOutput } from "./explain.js";
|
|
61
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";
|
|
62
66
|
|
|
63
67
|
export const SITE_OPS_HELP_LINES = [
|
|
64
68
|
" geo-ai-search-optimization doctor [--json]",
|
|
@@ -106,9 +110,14 @@ export const SITE_OPS_HELP_LINES = [
|
|
|
106
110
|
" geo-ai-search-optimization diagnose <url-or-dir-or-file> [--json] [--out <file>]",
|
|
107
111
|
" geo-ai-search-optimization compare <url-or-file-A> <url-or-file-B> [--json] [--csv] [--out <file>]",
|
|
108
112
|
" geo-ai-search-optimization deep-benchmark <url> --competitors <url1,url2,...> [--concurrency <n>] [--json] [--csv] [--out <file>]",
|
|
109
|
-
" geo-ai-search-optimization summary <url-or-dir-or-file>",
|
|
113
|
+
" geo-ai-search-optimization summary <url-or-dir-or-file> [--json]",
|
|
110
114
|
" geo-ai-search-optimization explain <url-or-file> [--dimension <dim>] [--json] [--out <file>]",
|
|
111
|
-
" geo-ai-search-optimization monitor <url-or-file> [--threshold <n>] [--data-dir <dir>] [--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>]"
|
|
112
121
|
];
|
|
113
122
|
|
|
114
123
|
const passthroughWriteOutput = async (outputPath) => outputPath;
|
|
@@ -798,7 +807,7 @@ const handleCompare = createStructuredOutputCommandHandler({
|
|
|
798
807
|
renderMarkdown: (report) => renderCompareMarkdown(report),
|
|
799
808
|
writeOutput: writeCompareOutput,
|
|
800
809
|
getOutputJson: (args) => {
|
|
801
|
-
if (hasFlag(args, "--csv")) return false; //
|
|
810
|
+
if (hasFlag(args, "--csv")) return false; // CSV handled by handleCompareCsv wrapper
|
|
802
811
|
return hasFlag(args, "--json");
|
|
803
812
|
}
|
|
804
813
|
});
|
|
@@ -901,6 +910,83 @@ const handleFullAudit = createStructuredOutputCommandHandler({
|
|
|
901
910
|
getOutputJson: (args) => hasFlag(args, "--json")
|
|
902
911
|
});
|
|
903
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
|
+
|
|
904
990
|
export const SITE_OPS_COMMAND_HANDLERS = {
|
|
905
991
|
doctor: handleDoctor,
|
|
906
992
|
"quick-start": handleQuickStart,
|
|
@@ -949,5 +1035,10 @@ export const SITE_OPS_COMMAND_HANDLERS = {
|
|
|
949
1035
|
"deep-benchmark": handleDeepBenchmark,
|
|
950
1036
|
summary: handleSummary,
|
|
951
1037
|
explain: handleExplain,
|
|
952
|
-
monitor: handleMonitor
|
|
1038
|
+
monitor: handleMonitor,
|
|
1039
|
+
"link-quality": handleLinkQuality,
|
|
1040
|
+
"content-freshness": handleContentFreshness,
|
|
1041
|
+
"track-competitors": handleTrackCompetitors,
|
|
1042
|
+
"generate-llms": handleGenerateLlms,
|
|
1043
|
+
"optimize-llms": handleOptimizeLlms
|
|
953
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;
|