geo-ai-search-optimization 2.2.1 → 2.4.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/package.json +1 -1
- package/src/cli-site-ops-commands.js +102 -4
- package/src/csv-export.js +112 -0
- package/src/deep-benchmark.js +231 -0
- package/src/explain.js +268 -0
- package/src/fetch-utils.js +76 -0
- package/src/full-audit.js +18 -2
- package/src/index.js +6 -0
- package/src/monitor.js +144 -0
- package/src/summary.js +28 -0
package/package.json
CHANGED
|
@@ -54,6 +54,11 @@ import { generateAutoFix, renderAutoFixMarkdown, writeAutoFixOutput } from "./au
|
|
|
54
54
|
import { savePageSnapshot, buildPageTrend, renderPageTrendMarkdown, writePageTrendOutput } from "./page-snapshot.js";
|
|
55
55
|
import { diagnose, renderDiagnoseMarkdown, writeDiagnoseOutput } from "./diagnose.js";
|
|
56
56
|
import { comparePages, renderCompareMarkdown, writeCompareOutput } from "./compare.js";
|
|
57
|
+
import { deepBenchmark, renderDeepBenchmarkMarkdown, writeDeepBenchmarkOutput } from "./deep-benchmark.js";
|
|
58
|
+
import { quickSummary, renderSummaryMarkdown } from "./summary.js";
|
|
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";
|
|
57
62
|
|
|
58
63
|
export const SITE_OPS_HELP_LINES = [
|
|
59
64
|
" geo-ai-search-optimization doctor [--json]",
|
|
@@ -99,7 +104,11 @@ export const SITE_OPS_HELP_LINES = [
|
|
|
99
104
|
" geo-ai-search-optimization auto-fix <url-or-file> [--json] [--out <file>]",
|
|
100
105
|
" geo-ai-search-optimization page-trend <url-or-file> [--data-dir <dir>] [--last <n>] [--json] [--out <file>]",
|
|
101
106
|
" geo-ai-search-optimization diagnose <url-or-dir-or-file> [--json] [--out <file>]",
|
|
102
|
-
" geo-ai-search-optimization compare <url-or-file-A> <url-or-file-B> [--json] [--out <file>]"
|
|
107
|
+
" geo-ai-search-optimization compare <url-or-file-A> <url-or-file-B> [--json] [--csv] [--out <file>]",
|
|
108
|
+
" 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>",
|
|
110
|
+
" 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>]"
|
|
103
112
|
];
|
|
104
113
|
|
|
105
114
|
const passthroughWriteOutput = async (outputPath) => outputPath;
|
|
@@ -705,6 +714,68 @@ const handleBatchFullPageAudit = createStructuredOutputCommandHandler({
|
|
|
705
714
|
getOutputJson: (args) => hasFlag(args, "--json")
|
|
706
715
|
});
|
|
707
716
|
|
|
717
|
+
const handleExplain = createStructuredOutputCommandHandler({
|
|
718
|
+
commandLabel: "explain",
|
|
719
|
+
execute: async (args) => {
|
|
720
|
+
const input = args.find((value) => !value.startsWith("-"));
|
|
721
|
+
if (!input) throw new Error("explain requires a URL or file path");
|
|
722
|
+
return explain(input, { dimension: getFlagValue(args, "--dimension") || undefined });
|
|
723
|
+
},
|
|
724
|
+
renderMarkdown: renderExplainMarkdown,
|
|
725
|
+
writeOutput: writeExplainOutput,
|
|
726
|
+
getOutputJson: (args) => hasFlag(args, "--json")
|
|
727
|
+
});
|
|
728
|
+
|
|
729
|
+
const handleMonitor = createStructuredOutputCommandHandler({
|
|
730
|
+
commandLabel: "monitor",
|
|
731
|
+
execute: async (args) => {
|
|
732
|
+
const input = args.find((value) => !value.startsWith("-"));
|
|
733
|
+
if (!input) throw new Error("monitor requires a URL or file path");
|
|
734
|
+
const thresholdValue = getFlagValue(args, "--threshold");
|
|
735
|
+
return monitorPage(input, {
|
|
736
|
+
threshold: thresholdValue ? parsePositiveInteger(thresholdValue, "--threshold") : 5,
|
|
737
|
+
dataDir: getFlagValue(args, "--data-dir") || undefined
|
|
738
|
+
});
|
|
739
|
+
},
|
|
740
|
+
renderMarkdown: renderMonitorMarkdown,
|
|
741
|
+
writeOutput: writeMonitorOutput,
|
|
742
|
+
getOutputJson: (args) => hasFlag(args, "--json")
|
|
743
|
+
});
|
|
744
|
+
|
|
745
|
+
const handleDeepBenchmark = createStructuredOutputCommandHandler({
|
|
746
|
+
commandLabel: "deep benchmark",
|
|
747
|
+
execute: async (args) => {
|
|
748
|
+
const ownUrl = args.find((value) => !value.startsWith("-"));
|
|
749
|
+
if (!ownUrl) throw new Error("deep-benchmark requires your URL as the first argument");
|
|
750
|
+
const competitorsRaw = getFlagValue(args, "--competitors");
|
|
751
|
+
if (!competitorsRaw) throw new Error("deep-benchmark requires --competitors <url1,url2,...>");
|
|
752
|
+
const competitorUrls = competitorsRaw.split(",").map((u) => u.trim()).filter(Boolean);
|
|
753
|
+
const concurrencyValue = getFlagValue(args, "--concurrency");
|
|
754
|
+
return deepBenchmark(ownUrl, competitorUrls, {
|
|
755
|
+
concurrency: concurrencyValue ? parsePositiveInteger(concurrencyValue, "--concurrency") : 2
|
|
756
|
+
});
|
|
757
|
+
},
|
|
758
|
+
renderMarkdown: (report, args) => {
|
|
759
|
+
if (args && hasFlag(args, "--csv")) return deepBenchmarkToCsv(report);
|
|
760
|
+
return renderDeepBenchmarkMarkdown(report);
|
|
761
|
+
},
|
|
762
|
+
writeOutput: writeDeepBenchmarkOutput,
|
|
763
|
+
getOutputJson: (args) => hasFlag(args, "--json")
|
|
764
|
+
});
|
|
765
|
+
|
|
766
|
+
const handleSummary = async (args) => {
|
|
767
|
+
const input = args.find((value) => !value.startsWith("-"));
|
|
768
|
+
if (!input) throw new Error("summary requires a URL, file path, or directory");
|
|
769
|
+
const result = await quickSummary(input);
|
|
770
|
+
|
|
771
|
+
if (hasFlag(args, "--json")) {
|
|
772
|
+
process.stdout.write(`${JSON.stringify(result, null, 2)}\n`);
|
|
773
|
+
return;
|
|
774
|
+
}
|
|
775
|
+
|
|
776
|
+
process.stdout.write(renderSummaryMarkdown(result));
|
|
777
|
+
};
|
|
778
|
+
|
|
708
779
|
const handleDiagnose = createStructuredOutputCommandHandler({
|
|
709
780
|
commandLabel: "diagnose",
|
|
710
781
|
execute: async (args) => {
|
|
@@ -724,11 +795,34 @@ const handleCompare = createStructuredOutputCommandHandler({
|
|
|
724
795
|
if (nonFlags.length < 2) throw new Error("compare requires two URLs or file paths: <A> <B>");
|
|
725
796
|
return comparePages(nonFlags[0], nonFlags[1]);
|
|
726
797
|
},
|
|
727
|
-
renderMarkdown: renderCompareMarkdown,
|
|
798
|
+
renderMarkdown: (report) => renderCompareMarkdown(report),
|
|
728
799
|
writeOutput: writeCompareOutput,
|
|
729
|
-
getOutputJson: (args) =>
|
|
800
|
+
getOutputJson: (args) => {
|
|
801
|
+
if (hasFlag(args, "--csv")) return false; // Let renderMarkdown handle CSV
|
|
802
|
+
return hasFlag(args, "--json");
|
|
803
|
+
}
|
|
730
804
|
});
|
|
731
805
|
|
|
806
|
+
// Override compare to support --csv
|
|
807
|
+
const _originalCompare = handleCompare;
|
|
808
|
+
const handleCompareCsv = async (args) => {
|
|
809
|
+
if (hasFlag(args, "--csv")) {
|
|
810
|
+
const nonFlags = args.filter((value) => !value.startsWith("-"));
|
|
811
|
+
if (nonFlags.length < 2) throw new Error("compare requires two URLs or file paths: <A> <B>");
|
|
812
|
+
const result = await comparePages(nonFlags[0], nonFlags[1]);
|
|
813
|
+
const csv = compareToCsv(result);
|
|
814
|
+
const outputPath = getFlagValue(args, "--out");
|
|
815
|
+
if (outputPath) {
|
|
816
|
+
const resolved = await writeCompareOutput(outputPath, csv);
|
|
817
|
+
process.stdout.write(`已保存 compare CSV:${resolved}\n`);
|
|
818
|
+
} else {
|
|
819
|
+
process.stdout.write(csv + "\n");
|
|
820
|
+
}
|
|
821
|
+
return;
|
|
822
|
+
}
|
|
823
|
+
return _originalCompare(args);
|
|
824
|
+
};
|
|
825
|
+
|
|
732
826
|
const handleAutoFix = createStructuredOutputCommandHandler({
|
|
733
827
|
commandLabel: "auto-fix",
|
|
734
828
|
execute: async (args) => {
|
|
@@ -851,5 +945,9 @@ export const SITE_OPS_COMMAND_HANDLERS = {
|
|
|
851
945
|
"auto-fix": handleAutoFix,
|
|
852
946
|
"page-trend": handlePageTrend,
|
|
853
947
|
diagnose: handleDiagnose,
|
|
854
|
-
compare:
|
|
948
|
+
compare: handleCompareCsv,
|
|
949
|
+
"deep-benchmark": handleDeepBenchmark,
|
|
950
|
+
summary: handleSummary,
|
|
951
|
+
explain: handleExplain,
|
|
952
|
+
monitor: handleMonitor
|
|
855
953
|
};
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CSV export utility for structured audit data.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
function escCsv(value) {
|
|
6
|
+
if (value === null || value === undefined) return "";
|
|
7
|
+
const str = String(value);
|
|
8
|
+
if (str.includes(",") || str.includes('"') || str.includes("\n")) {
|
|
9
|
+
return `"${str.replace(/"/g, '""')}"`;
|
|
10
|
+
}
|
|
11
|
+
return str;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function toCsvRow(values) {
|
|
15
|
+
return values.map(escCsv).join(",");
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Convert a full-page-audit result to CSV.
|
|
20
|
+
*/
|
|
21
|
+
export function fullPageAuditToCsv(report) {
|
|
22
|
+
const rows = [toCsvRow(["Dimension", "Score", "Label", "Weight"])];
|
|
23
|
+
|
|
24
|
+
const weights = {
|
|
25
|
+
base: 15, citability: 12, eeat: 12, readability: 8, headingStructure: 6,
|
|
26
|
+
internalLinks: 5, socialMeta: 5, platformReady: 8, schema: 7,
|
|
27
|
+
freshness: 8, security: 7, topics: 7
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
for (const [dim, data] of Object.entries(report.dimensions || {})) {
|
|
31
|
+
rows.push(toCsvRow([dim, data.score, data.label, `${weights[dim] || 0}%`]));
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
rows.push(toCsvRow(["COMPOSITE", report.compositeScore, report.compositeLabel, "100%"]));
|
|
35
|
+
return rows.join("\n");
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Convert a batch-full-page-audit result to CSV.
|
|
40
|
+
*/
|
|
41
|
+
export function batchFullPageAuditToCsv(report) {
|
|
42
|
+
const dimKeys = Object.keys(report.dimensionAverages || {});
|
|
43
|
+
const header = ["URL", "Composite", "Label", ...dimKeys];
|
|
44
|
+
const rows = [toCsvRow(header)];
|
|
45
|
+
|
|
46
|
+
for (const result of report.results || []) {
|
|
47
|
+
const dimScores = dimKeys.map((k) => result.dimensions?.[k]?.score ?? "");
|
|
48
|
+
rows.push(toCsvRow([result.input, result.compositeScore, result.compositeLabel, ...dimScores]));
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// Averages row
|
|
52
|
+
const avgScores = dimKeys.map((k) => report.dimensionAverages[k] ?? "");
|
|
53
|
+
rows.push(toCsvRow(["AVERAGE", report.avgScore, "", ...avgScores]));
|
|
54
|
+
|
|
55
|
+
return rows.join("\n");
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Convert a deep-benchmark result to CSV.
|
|
60
|
+
*/
|
|
61
|
+
export function deepBenchmarkToCsv(report) {
|
|
62
|
+
// Score board
|
|
63
|
+
const sections = [];
|
|
64
|
+
|
|
65
|
+
sections.push("# Score Board");
|
|
66
|
+
const boardRows = [toCsvRow(["Rank", "URL", "Composite", "Label", "IsOwn"])];
|
|
67
|
+
report.scoreBoard.forEach((entry, i) => {
|
|
68
|
+
boardRows.push(toCsvRow([i + 1, entry.url, entry.composite, entry.label, entry.isOwn]));
|
|
69
|
+
});
|
|
70
|
+
sections.push(boardRows.join("\n"));
|
|
71
|
+
|
|
72
|
+
// Dimension comparison
|
|
73
|
+
sections.push("\n# Dimension Comparison");
|
|
74
|
+
const dimHeader = ["Dimension", "You", "CompetitorAvg", "Delta", "Rank"];
|
|
75
|
+
const dimRows = [toCsvRow(dimHeader)];
|
|
76
|
+
for (const [dim, data] of Object.entries(report.dimensionComparison || {})) {
|
|
77
|
+
dimRows.push(toCsvRow([dim, data.own, data.competitorAvg, data.delta, data.rank]));
|
|
78
|
+
}
|
|
79
|
+
sections.push(dimRows.join("\n"));
|
|
80
|
+
|
|
81
|
+
return sections.join("\n\n");
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Convert a compare result to CSV.
|
|
86
|
+
*/
|
|
87
|
+
export function compareToCsv(report) {
|
|
88
|
+
const rows = [toCsvRow(["Dimension", "Page A", "Page B", "Delta", "Winner"])];
|
|
89
|
+
|
|
90
|
+
for (const [dim, data] of Object.entries(report.dimensions || {})) {
|
|
91
|
+
rows.push(toCsvRow([dim, data.scoreA, data.scoreB, data.delta, data.winner]));
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
rows.push(toCsvRow(["COMPOSITE", report.compositeA, report.compositeB, report.compositeDelta, report.overallWinner]));
|
|
95
|
+
return rows.join("\n");
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Generic object-array to CSV converter.
|
|
100
|
+
*/
|
|
101
|
+
export function arrayToCsv(items, columns) {
|
|
102
|
+
if (!items || items.length === 0) return "";
|
|
103
|
+
|
|
104
|
+
const keys = columns || Object.keys(items[0]);
|
|
105
|
+
const rows = [toCsvRow(keys)];
|
|
106
|
+
|
|
107
|
+
for (const item of items) {
|
|
108
|
+
rows.push(toCsvRow(keys.map((k) => item[k])));
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
return rows.join("\n");
|
|
112
|
+
}
|
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
import { writeScanOutput } from "./scan.js";
|
|
2
|
+
import { fullPageAudit } from "./full-page-audit.js";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Deep benchmark: full 12-dimension comparison against N competitors.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
async function auditSafe(url, options) {
|
|
9
|
+
try {
|
|
10
|
+
const result = await fullPageAudit(url, options);
|
|
11
|
+
return { ok: true, url, data: result };
|
|
12
|
+
} catch (err) {
|
|
13
|
+
return { ok: false, url, error: err.message };
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export async function deepBenchmark(ownUrl, competitorUrls, options = {}) {
|
|
18
|
+
const concurrency = options.concurrency || 2;
|
|
19
|
+
const allUrls = [ownUrl, ...competitorUrls];
|
|
20
|
+
|
|
21
|
+
// Audit all URLs with concurrency
|
|
22
|
+
const results = [];
|
|
23
|
+
const chunks = [];
|
|
24
|
+
for (let i = 0; i < allUrls.length; i += concurrency) {
|
|
25
|
+
chunks.push(allUrls.slice(i, i + concurrency));
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
for (const chunk of chunks) {
|
|
29
|
+
const chunkResults = await Promise.all(chunk.map((url) => auditSafe(url, options)));
|
|
30
|
+
results.push(...chunkResults);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const ownResult = results[0];
|
|
34
|
+
const competitorResults = results.slice(1);
|
|
35
|
+
const successful = results.filter((r) => r.ok);
|
|
36
|
+
|
|
37
|
+
// Dimension comparison
|
|
38
|
+
const allDimKeys = new Set();
|
|
39
|
+
for (const r of successful) {
|
|
40
|
+
for (const key of Object.keys(r.data.dimensions)) {
|
|
41
|
+
allDimKeys.add(key);
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const dimensionComparison = {};
|
|
46
|
+
for (const dim of allDimKeys) {
|
|
47
|
+
const ownScore = ownResult.ok ? (ownResult.data.dimensions[dim]?.score ?? 0) : null;
|
|
48
|
+
const compScores = competitorResults.map((r) =>
|
|
49
|
+
r.ok ? (r.data.dimensions[dim]?.score ?? 0) : null
|
|
50
|
+
);
|
|
51
|
+
const validCompScores = compScores.filter((s) => s !== null);
|
|
52
|
+
const avgComp = validCompScores.length > 0
|
|
53
|
+
? Math.round(validCompScores.reduce((a, b) => a + b, 0) / validCompScores.length)
|
|
54
|
+
: null;
|
|
55
|
+
|
|
56
|
+
dimensionComparison[dim] = {
|
|
57
|
+
own: ownScore,
|
|
58
|
+
competitors: compScores,
|
|
59
|
+
competitorAvg: avgComp,
|
|
60
|
+
delta: ownScore !== null && avgComp !== null ? ownScore - avgComp : null,
|
|
61
|
+
rank: null // Computed below
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// Compute ranks per dimension
|
|
66
|
+
for (const dim of allDimKeys) {
|
|
67
|
+
const scores = results
|
|
68
|
+
.filter((r) => r.ok)
|
|
69
|
+
.map((r) => ({ url: r.url, score: r.data.dimensions[dim]?.score ?? 0 }))
|
|
70
|
+
.sort((a, b) => b.score - a.score);
|
|
71
|
+
|
|
72
|
+
const ownRank = scores.findIndex((s) => s.url === ownUrl) + 1;
|
|
73
|
+
dimensionComparison[dim].rank = ownRank || null;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// Overall scores
|
|
77
|
+
const scoreBoard = results.map((r) => ({
|
|
78
|
+
url: r.url,
|
|
79
|
+
isOwn: r.url === ownUrl,
|
|
80
|
+
composite: r.ok ? r.data.compositeScore : null,
|
|
81
|
+
label: r.ok ? r.data.compositeLabel : "Failed",
|
|
82
|
+
error: r.ok ? null : r.error
|
|
83
|
+
})).sort((a, b) => (b.composite ?? -1) - (a.composite ?? -1));
|
|
84
|
+
|
|
85
|
+
const ownRank = scoreBoard.findIndex((s) => s.isOwn) + 1;
|
|
86
|
+
|
|
87
|
+
// Strengths and weaknesses vs competitor average
|
|
88
|
+
const strengths = [];
|
|
89
|
+
const weaknesses = [];
|
|
90
|
+
|
|
91
|
+
for (const [dim, data] of Object.entries(dimensionComparison)) {
|
|
92
|
+
if (data.delta === null) continue;
|
|
93
|
+
if (data.delta >= 10) strengths.push({ dimension: dim, delta: data.delta, rank: data.rank });
|
|
94
|
+
if (data.delta <= -10) weaknesses.push({ dimension: dim, delta: data.delta, rank: data.rank });
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
strengths.sort((a, b) => b.delta - a.delta);
|
|
98
|
+
weaknesses.sort((a, b) => a.delta - b.delta);
|
|
99
|
+
|
|
100
|
+
// Platform comparison
|
|
101
|
+
const platformComparison = [];
|
|
102
|
+
if (ownResult.ok && ownResult.data.platformSummary) {
|
|
103
|
+
for (const ownPlatform of ownResult.data.platformSummary) {
|
|
104
|
+
const compPlatformScores = competitorResults
|
|
105
|
+
.filter((r) => r.ok && r.data.platformSummary)
|
|
106
|
+
.map((r) => r.data.platformSummary.find((p) => p.platform === ownPlatform.platform)?.score ?? 0);
|
|
107
|
+
const avgCompPlatform = compPlatformScores.length > 0
|
|
108
|
+
? Math.round(compPlatformScores.reduce((a, b) => a + b, 0) / compPlatformScores.length)
|
|
109
|
+
: 0;
|
|
110
|
+
|
|
111
|
+
platformComparison.push({
|
|
112
|
+
platform: ownPlatform.platform,
|
|
113
|
+
ownScore: ownPlatform.score,
|
|
114
|
+
competitorAvg: avgCompPlatform,
|
|
115
|
+
delta: ownPlatform.score - avgCompPlatform
|
|
116
|
+
});
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// Actionable gaps
|
|
121
|
+
const gaps = weaknesses.map((w) => ({
|
|
122
|
+
dimension: w.dimension,
|
|
123
|
+
gap: Math.abs(w.delta),
|
|
124
|
+
action: `Improve ${w.dimension} (${Math.abs(w.delta)} points behind competitor average)`
|
|
125
|
+
}));
|
|
126
|
+
|
|
127
|
+
const ownComposite = ownResult.ok ? ownResult.data.compositeScore : 0;
|
|
128
|
+
const avgCompComposite = successful.length > 1
|
|
129
|
+
? Math.round(successful.filter((r) => r.url !== ownUrl).reduce((sum, r) => sum + r.data.compositeScore, 0) / (successful.length - 1))
|
|
130
|
+
: null;
|
|
131
|
+
|
|
132
|
+
return {
|
|
133
|
+
kind: "geo-deep-benchmark",
|
|
134
|
+
ownUrl,
|
|
135
|
+
competitorUrls,
|
|
136
|
+
ownComposite,
|
|
137
|
+
competitorAvgComposite: avgCompComposite,
|
|
138
|
+
compositeDelta: avgCompComposite !== null ? ownComposite - avgCompComposite : null,
|
|
139
|
+
ownRank,
|
|
140
|
+
totalCompetitors: competitorUrls.length,
|
|
141
|
+
scoreBoard,
|
|
142
|
+
dimensionComparison,
|
|
143
|
+
strengths,
|
|
144
|
+
weaknesses,
|
|
145
|
+
gaps,
|
|
146
|
+
platformComparison,
|
|
147
|
+
successful: successful.length,
|
|
148
|
+
failed: results.length - successful.length,
|
|
149
|
+
summary: `Deep benchmark: You rank #${ownRank}/${results.length}. Composite: ${ownComposite} vs avg ${avgCompComposite ?? "—"}. ${strengths.length} strength(s), ${weaknesses.length} weakness(es).`
|
|
150
|
+
};
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
const DIM_LABELS = {
|
|
154
|
+
base: "Base", citability: "Citability", eeat: "E-E-A-T", readability: "Readability",
|
|
155
|
+
headingStructure: "Headings", internalLinks: "Links", socialMeta: "Social",
|
|
156
|
+
platformReady: "Platforms", schema: "Schema", freshness: "Freshness",
|
|
157
|
+
security: "Security", topics: "Topics"
|
|
158
|
+
};
|
|
159
|
+
|
|
160
|
+
export function renderDeepBenchmarkMarkdown(report) {
|
|
161
|
+
const lines = [
|
|
162
|
+
"# GEO Deep Benchmark",
|
|
163
|
+
"",
|
|
164
|
+
`- Your URL: \`${report.ownUrl}\``,
|
|
165
|
+
`- Competitors: \`${report.totalCompetitors}\``,
|
|
166
|
+
`- **Your rank: #${report.ownRank}/${report.scoreBoard.length}**`,
|
|
167
|
+
`- Your composite: \`${report.ownComposite}/100\` vs competitor avg \`${report.competitorAvgComposite ?? "—"}/100\``,
|
|
168
|
+
`- Summary: ${report.summary}`,
|
|
169
|
+
"",
|
|
170
|
+
"## Score Board",
|
|
171
|
+
"",
|
|
172
|
+
"| Rank | URL | Composite | Label |",
|
|
173
|
+
"|------|-----|-----------|-------|"
|
|
174
|
+
];
|
|
175
|
+
|
|
176
|
+
report.scoreBoard.forEach((entry, i) => {
|
|
177
|
+
const marker = entry.isOwn ? " **← You**" : "";
|
|
178
|
+
lines.push(`| ${i + 1} | ${entry.url.slice(0, 45)} | ${entry.composite ?? "—"} | ${entry.label}${marker} |`);
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
lines.push(
|
|
182
|
+
"",
|
|
183
|
+
"## Dimension Comparison (You vs Competitor Avg)",
|
|
184
|
+
"",
|
|
185
|
+
"| Dimension | You | Comp Avg | Δ | Rank |",
|
|
186
|
+
"|-----------|-----|----------|---|------|"
|
|
187
|
+
);
|
|
188
|
+
|
|
189
|
+
const sorted = Object.entries(report.dimensionComparison)
|
|
190
|
+
.sort((a, b) => (a[1].delta ?? 0) - (b[1].delta ?? 0));
|
|
191
|
+
|
|
192
|
+
for (const [dim, data] of sorted) {
|
|
193
|
+
const icon = (data.delta ?? 0) >= 5 ? "🟢" : (data.delta ?? 0) <= -5 ? "🔴" : "🟡";
|
|
194
|
+
lines.push(`| ${icon} ${DIM_LABELS[dim] || dim} | ${data.own ?? "—"} | ${data.competitorAvg ?? "—"} | ${data.delta !== null ? (data.delta >= 0 ? "+" : "") + data.delta : "—"} | #${data.rank ?? "—"} |`);
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
if (report.strengths.length > 0) {
|
|
198
|
+
lines.push("", "## Your Strengths (vs competitor avg)", "");
|
|
199
|
+
for (const s of report.strengths) {
|
|
200
|
+
lines.push(`- 🟢 **${DIM_LABELS[s.dimension] || s.dimension}**: +${s.delta} ahead (rank #${s.rank})`);
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
if (report.weaknesses.length > 0) {
|
|
205
|
+
lines.push("", "## Your Weaknesses (vs competitor avg)", "");
|
|
206
|
+
for (const w of report.weaknesses) {
|
|
207
|
+
lines.push(`- 🔴 **${DIM_LABELS[w.dimension] || w.dimension}**: ${w.delta} behind (rank #${w.rank})`);
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
if (report.platformComparison.length > 0) {
|
|
212
|
+
lines.push("", "## Platform Readiness vs Competitors", "", "| Platform | You | Comp Avg | Δ |", "|----------|-----|----------|---|");
|
|
213
|
+
for (const p of report.platformComparison) {
|
|
214
|
+
lines.push(`| ${p.platform} | ${p.ownScore} | ${p.competitorAvg} | ${p.delta >= 0 ? "+" : ""}${p.delta} |`);
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
if (report.gaps.length > 0) {
|
|
219
|
+
lines.push("", "## Priority Gaps to Close", "");
|
|
220
|
+
for (const g of report.gaps) {
|
|
221
|
+
lines.push(`- **${DIM_LABELS[g.dimension] || g.dimension}**: ${g.gap} points behind → ${g.action}`);
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
lines.push("");
|
|
226
|
+
return lines.join("\n");
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
export async function writeDeepBenchmarkOutput(outputPath, content) {
|
|
230
|
+
return writeScanOutput(outputPath, content);
|
|
231
|
+
}
|
package/src/explain.js
ADDED
|
@@ -0,0 +1,268 @@
|
|
|
1
|
+
import { writeScanOutput } from "./scan.js";
|
|
2
|
+
import { fullPageAudit } from "./full-page-audit.js";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Explain: deep-dive into why a specific dimension scored low.
|
|
6
|
+
* Shows concrete evidence from the page content.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
const DIMENSION_EXPLAINERS = {
|
|
10
|
+
base: {
|
|
11
|
+
label: "Base Audit",
|
|
12
|
+
explain: (details) => {
|
|
13
|
+
if (!details) return { factors: [], evidence: [] };
|
|
14
|
+
const factors = [];
|
|
15
|
+
const evidence = [];
|
|
16
|
+
const checks = details.score?.checks || [];
|
|
17
|
+
for (const check of checks) {
|
|
18
|
+
if (!check.passed) {
|
|
19
|
+
factors.push({ factor: check.label, impact: `-${check.maxPoints} points`, status: "missing" });
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
if (details.metadata?.title) evidence.push({ type: "title", value: details.metadata.title });
|
|
23
|
+
if (details.metadata?.metaDescription) evidence.push({ type: "metaDescription", value: details.metadata.metaDescription.slice(0, 160) });
|
|
24
|
+
if (details.directAnswerCandidate) evidence.push({ type: "firstParagraph", value: details.directAnswerCandidate.slice(0, 200) });
|
|
25
|
+
return { factors, evidence };
|
|
26
|
+
}
|
|
27
|
+
},
|
|
28
|
+
citability: {
|
|
29
|
+
label: "Citability",
|
|
30
|
+
explain: (details) => {
|
|
31
|
+
if (!details) return { factors: [], evidence: [] };
|
|
32
|
+
const factors = [];
|
|
33
|
+
if (details.claims?.claimDensity < 15) factors.push({ factor: `Claim density: ${details.claims.claimDensity}% (target: 15%+)`, impact: "Low factual content", status: "weak" });
|
|
34
|
+
if (details.claims?.statDensity < 5) factors.push({ factor: `Stat density: ${details.claims.statDensity}% (target: 5%+)`, impact: "Few statistics", status: "weak" });
|
|
35
|
+
if (details.entities?.entityDensity < 1) factors.push({ factor: `Entity density: ${details.entities.entityDensity}% (target: 1%+)`, impact: "Few named entities", status: "weak" });
|
|
36
|
+
if ((details.quotableSentences?.length || 0) < 3) factors.push({ factor: `Quotable sentences: ${details.quotableSentences?.length || 0} (target: 3+)`, impact: "Few citable passages", status: "weak" });
|
|
37
|
+
if (!details.structure?.hasList) factors.push({ factor: "No lists found", impact: "Content hard to scan", status: "missing" });
|
|
38
|
+
if (!details.structure?.hasTable) factors.push({ factor: "No tables found", impact: "No structured comparisons", status: "missing" });
|
|
39
|
+
const evidence = (details.quotableSentences || []).slice(0, 3).map((q) => ({ type: "quotable", value: q.sentence, score: q.score }));
|
|
40
|
+
return { factors, evidence };
|
|
41
|
+
}
|
|
42
|
+
},
|
|
43
|
+
eeat: {
|
|
44
|
+
label: "E-E-A-T",
|
|
45
|
+
explain: (details) => {
|
|
46
|
+
if (!details) return { factors: [], evidence: [] };
|
|
47
|
+
const factors = [];
|
|
48
|
+
const dims = details.dimensions || {};
|
|
49
|
+
for (const [key, data] of Object.entries(dims)) {
|
|
50
|
+
if (data.score < 30) {
|
|
51
|
+
factors.push({ factor: `${key}: ${data.score}/100`, impact: `No ${key} signals detected`, status: "critical" });
|
|
52
|
+
} else if (data.score < 60) {
|
|
53
|
+
factors.push({ factor: `${key}: ${data.score}/100`, impact: `Weak ${key} signals`, status: "weak" });
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
if ((details.authorPresence?.length || 0) === 0) {
|
|
57
|
+
factors.push({ factor: "No author attribution", impact: "AI can't identify who wrote this", status: "missing" });
|
|
58
|
+
}
|
|
59
|
+
const evidence = (details.authorPresence || []).map((s) => ({ type: "authorSignal", value: s }));
|
|
60
|
+
for (const [key, data] of Object.entries(dims)) {
|
|
61
|
+
for (const signal of (data.signals || []).slice(0, 2)) {
|
|
62
|
+
evidence.push({ type: key, value: `${signal.label}: ${signal.count}x — e.g. "${signal.examples[0] || ""}"` });
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
return { factors, evidence };
|
|
66
|
+
}
|
|
67
|
+
},
|
|
68
|
+
readability: {
|
|
69
|
+
label: "Readability",
|
|
70
|
+
explain: (details) => {
|
|
71
|
+
if (!details) return { factors: [], evidence: [] };
|
|
72
|
+
const factors = [];
|
|
73
|
+
if (details.fleschKincaidGrade > 12) factors.push({ factor: `Grade level: ${details.fleschKincaidGrade} (target: 8-10)`, impact: "Too complex for broad audience", status: "weak" });
|
|
74
|
+
if (details.sentenceLength?.avg > 25) factors.push({ factor: `Avg sentence: ${details.sentenceLength.avg} words (target: 15-20)`, impact: "Sentences too long", status: "weak" });
|
|
75
|
+
if (details.passiveVoice?.passiveRatio > 20) factors.push({ factor: `Passive voice: ${details.passiveVoice.passiveRatio}% (target: <15%)`, impact: "Unclear agency", status: "weak" });
|
|
76
|
+
if (details.sentenceLength?.distribution?.veryLong > 0) factors.push({ factor: `${details.sentenceLength.distribution.veryLong} sentences over 30 words`, impact: "Hard to parse", status: "weak" });
|
|
77
|
+
return { factors, evidence: [] };
|
|
78
|
+
}
|
|
79
|
+
},
|
|
80
|
+
headingStructure: {
|
|
81
|
+
label: "Heading Structure",
|
|
82
|
+
explain: (details) => {
|
|
83
|
+
if (!details) return { factors: [], evidence: [] };
|
|
84
|
+
const factors = [];
|
|
85
|
+
for (const issue of (details.hierarchy?.issues || [])) {
|
|
86
|
+
factors.push({ factor: issue.message, impact: issue.severity, status: issue.severity === "error" ? "critical" : "weak" });
|
|
87
|
+
}
|
|
88
|
+
if (details.questionHeadings?.count === 0) factors.push({ factor: "No question-style headings", impact: "AI can't extract Q&A answers", status: "missing" });
|
|
89
|
+
if (details.semanticCoverage?.coverageRatio < 30) factors.push({ factor: `Semantic coverage: ${details.semanticCoverage.coverageRatio}%`, impact: "Narrow topic coverage in headings", status: "weak" });
|
|
90
|
+
const evidence = (details.headings || []).slice(0, 8).map((h) => ({ type: `h${h.level}`, value: h.text }));
|
|
91
|
+
return { factors, evidence };
|
|
92
|
+
}
|
|
93
|
+
},
|
|
94
|
+
freshness: {
|
|
95
|
+
label: "Content Freshness",
|
|
96
|
+
explain: (details) => {
|
|
97
|
+
if (!details) return { factors: [], evidence: [] };
|
|
98
|
+
const factors = [];
|
|
99
|
+
if (details.datesFound === 0) factors.push({ factor: "No dates found anywhere on the page", impact: "AI can't assess content currency", status: "critical" });
|
|
100
|
+
if (details.age?.isOld) factors.push({ factor: `Content is ${details.age.modifiedAgeLabel} old`, impact: "AI deprioritizes stale content", status: "weak" });
|
|
101
|
+
if (details.age?.isStale) factors.push({ factor: `Last modified ${details.age.modifiedAgeLabel} ago`, impact: "Consider updating", status: "weak" });
|
|
102
|
+
const missingSignals = ["datePublished in structured data", "dateModified in structured data", "Visible freshness label"]
|
|
103
|
+
.filter((s) => !(details.freshnessSignals || []).includes(s));
|
|
104
|
+
for (const s of missingSignals) factors.push({ factor: `Missing: ${s}`, impact: "Incomplete freshness signals", status: "missing" });
|
|
105
|
+
const evidence = (details.dates || []).slice(0, 5).map((d) => ({ type: d.source, value: `${d.date} — "${d.raw}"` }));
|
|
106
|
+
return { factors, evidence };
|
|
107
|
+
}
|
|
108
|
+
},
|
|
109
|
+
security: {
|
|
110
|
+
label: "Security",
|
|
111
|
+
explain: (details) => {
|
|
112
|
+
if (!details) return { factors: [], evidence: [] };
|
|
113
|
+
const factors = [];
|
|
114
|
+
for (const check of (details.htmlChecks || [])) {
|
|
115
|
+
if (!check.passed && check.severity !== "info") {
|
|
116
|
+
factors.push({ factor: check.label, impact: check.detail || "Missing", status: check.severity === "error" ? "critical" : "weak" });
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
for (const header of (details.securityHeaders || [])) {
|
|
120
|
+
if (!header.present && header.critical) factors.push({ factor: `Missing: ${header.header}`, impact: "Critical security header", status: "critical" });
|
|
121
|
+
}
|
|
122
|
+
return { factors, evidence: [] };
|
|
123
|
+
}
|
|
124
|
+
},
|
|
125
|
+
socialMeta: {
|
|
126
|
+
label: "Social Meta",
|
|
127
|
+
explain: (details) => {
|
|
128
|
+
if (!details) return { factors: [], evidence: [] };
|
|
129
|
+
const factors = [];
|
|
130
|
+
const allTags = [...(details.openGraph?.tags || []), ...(details.twitter?.tags || [])];
|
|
131
|
+
for (const tag of allTags) {
|
|
132
|
+
if (tag.required && !tag.found) factors.push({ factor: `Missing: ${tag.tag}`, impact: "Required for social sharing", status: "missing" });
|
|
133
|
+
}
|
|
134
|
+
const evidence = allTags.filter((t) => t.found).map((t) => ({ type: t.tag, value: t.value?.slice(0, 80) || "" }));
|
|
135
|
+
return { factors, evidence };
|
|
136
|
+
}
|
|
137
|
+
},
|
|
138
|
+
internalLinks: {
|
|
139
|
+
label: "Internal Links",
|
|
140
|
+
explain: (details) => {
|
|
141
|
+
if (!details) return { factors: [], evidence: [] };
|
|
142
|
+
const factors = [];
|
|
143
|
+
if (details.internalLinks?.total === 0) factors.push({ factor: "No internal links", impact: "No site structure signals", status: "critical" });
|
|
144
|
+
if (details.anchorAnalysis?.empty > 0) factors.push({ factor: `${details.anchorAnalysis.empty} empty anchor text(s)`, impact: "Links with no description", status: "weak" });
|
|
145
|
+
if (details.anchorAnalysis?.generic > 0) factors.push({ factor: `${details.anchorAnalysis.generic} generic anchor text(s)`, impact: '"Click here" style links', status: "weak" });
|
|
146
|
+
const evidence = (details.internalLinks?.mostLinked || []).slice(0, 5).map((l) => ({ type: "internal", value: `${l.url} (${l.count}x)` }));
|
|
147
|
+
return { factors, evidence };
|
|
148
|
+
}
|
|
149
|
+
},
|
|
150
|
+
platformReady: {
|
|
151
|
+
label: "Platform Readiness",
|
|
152
|
+
explain: (details) => {
|
|
153
|
+
if (!details) return { factors: [], evidence: [] };
|
|
154
|
+
const factors = [];
|
|
155
|
+
for (const p of (details.platforms || [])) {
|
|
156
|
+
if (p.score < 50) {
|
|
157
|
+
const failed = (p.checks || []).filter((c) => !c.passed);
|
|
158
|
+
factors.push({ factor: `${p.platform}: ${p.score}/100`, impact: `Missing: ${failed.map((f) => f.label).join(", ")}`, status: "weak" });
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
return { factors, evidence: [] };
|
|
162
|
+
}
|
|
163
|
+
},
|
|
164
|
+
schema: {
|
|
165
|
+
label: "Schema Validation",
|
|
166
|
+
explain: (details) => {
|
|
167
|
+
if (!details) return { factors: [], evidence: [] };
|
|
168
|
+
const factors = [];
|
|
169
|
+
if (details.entityCount === 0) factors.push({ factor: "No JSON-LD found", impact: "AI can't understand page entities", status: "critical" });
|
|
170
|
+
for (const v of (details.validations || [])) {
|
|
171
|
+
for (const issue of v.issues) factors.push({ factor: `${v.type}: ${issue.message}`, impact: "Schema error", status: "critical" });
|
|
172
|
+
for (const enh of v.enhancements) factors.push({ factor: `${v.type}: ${enh.message}`, impact: "AI discoverability", status: "enhancement" });
|
|
173
|
+
}
|
|
174
|
+
return { factors, evidence: [] };
|
|
175
|
+
}
|
|
176
|
+
},
|
|
177
|
+
topics: {
|
|
178
|
+
label: "Topic Coverage",
|
|
179
|
+
explain: (details) => {
|
|
180
|
+
if (!details) return { factors: [], evidence: [] };
|
|
181
|
+
const factors = [];
|
|
182
|
+
if ((details.keywords?.length || 0) < 5) factors.push({ factor: "Few distinct keywords", impact: "Unclear topic focus", status: "weak" });
|
|
183
|
+
if ((details.topicClusters?.length || 0) < 3) factors.push({ factor: `Only ${details.topicClusters?.length || 0} topic cluster(s)`, impact: "Narrow topic coverage", status: "weak" });
|
|
184
|
+
const evidence = (details.keywords || []).slice(0, 5).map((k) => ({ type: "keyword", value: `"${k.term}" (${k.count}x, tfidf: ${k.tfidf})` }));
|
|
185
|
+
return { factors, evidence };
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
};
|
|
189
|
+
|
|
190
|
+
export async function explain(input, options = {}) {
|
|
191
|
+
const audit = await fullPageAudit(input, options);
|
|
192
|
+
const targetDim = options.dimension || null;
|
|
193
|
+
|
|
194
|
+
const explanations = {};
|
|
195
|
+
|
|
196
|
+
const dimsToExplain = targetDim
|
|
197
|
+
? { [targetDim]: DIMENSION_EXPLAINERS[targetDim] }
|
|
198
|
+
: DIMENSION_EXPLAINERS;
|
|
199
|
+
|
|
200
|
+
for (const [key, explainer] of Object.entries(dimsToExplain)) {
|
|
201
|
+
if (!explainer) continue;
|
|
202
|
+
const score = audit.dimensions[key]?.score ?? 0;
|
|
203
|
+
const detail = audit.details?.[key] || null;
|
|
204
|
+
const { factors, evidence } = explainer.explain(detail);
|
|
205
|
+
|
|
206
|
+
explanations[key] = {
|
|
207
|
+
label: explainer.label,
|
|
208
|
+
score,
|
|
209
|
+
factorCount: factors.length,
|
|
210
|
+
factors,
|
|
211
|
+
evidence: evidence.slice(0, 8)
|
|
212
|
+
};
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
// Sort by score ascending (worst first)
|
|
216
|
+
const sorted = Object.entries(explanations)
|
|
217
|
+
.sort((a, b) => a[1].score - b[1].score);
|
|
218
|
+
|
|
219
|
+
return {
|
|
220
|
+
kind: "geo-explain",
|
|
221
|
+
input,
|
|
222
|
+
compositeScore: audit.compositeScore,
|
|
223
|
+
targetDimension: targetDim,
|
|
224
|
+
explanations: Object.fromEntries(sorted),
|
|
225
|
+
summary: targetDim
|
|
226
|
+
? `${DIMENSION_EXPLAINERS[targetDim]?.label || targetDim}: ${explanations[targetDim]?.score ?? 0}/100. ${explanations[targetDim]?.factorCount || 0} factor(s) identified.`
|
|
227
|
+
: `Composite: ${audit.compositeScore}/100. ${sorted.filter(([, e]) => e.score < 40).length} critical dimension(s).`
|
|
228
|
+
};
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
export function renderExplainMarkdown(report) {
|
|
232
|
+
const lines = [
|
|
233
|
+
"# GEO Score Explanation",
|
|
234
|
+
"",
|
|
235
|
+
`- Input: \`${report.input}\``,
|
|
236
|
+
`- Composite Score: \`${report.compositeScore}/100\``,
|
|
237
|
+
`- Summary: ${report.summary}`,
|
|
238
|
+
""
|
|
239
|
+
];
|
|
240
|
+
|
|
241
|
+
for (const [key, exp] of Object.entries(report.explanations)) {
|
|
242
|
+
const icon = exp.score >= 70 ? "🟢" : exp.score >= 40 ? "🟡" : "🔴";
|
|
243
|
+
lines.push(`## ${icon} ${exp.label} — ${exp.score}/100`, "");
|
|
244
|
+
|
|
245
|
+
if (exp.factors.length > 0) {
|
|
246
|
+
lines.push("### Why this score?", "");
|
|
247
|
+
for (const f of exp.factors) {
|
|
248
|
+
const badge = f.status === "critical" ? "🔴" : f.status === "missing" ? "❌" : f.status === "enhancement" ? "💡" : "⚠️";
|
|
249
|
+
lines.push(`- ${badge} **${f.factor}** — ${f.impact}`);
|
|
250
|
+
}
|
|
251
|
+
lines.push("");
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
if (exp.evidence.length > 0) {
|
|
255
|
+
lines.push("### Evidence from page", "");
|
|
256
|
+
for (const e of exp.evidence) {
|
|
257
|
+
lines.push(`- [${e.type}] ${e.value}`);
|
|
258
|
+
}
|
|
259
|
+
lines.push("");
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
return lines.join("\n");
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
export async function writeExplainOutput(outputPath, content) {
|
|
267
|
+
return writeScanOutput(outputPath, content);
|
|
268
|
+
}
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared fetch utilities with retry, timeout, and rate limiting.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
const DEFAULT_TIMEOUT = 10_000;
|
|
6
|
+
const DEFAULT_RETRIES = 2;
|
|
7
|
+
const DEFAULT_RETRY_DELAY = 1000;
|
|
8
|
+
const USER_AGENT = "geo-ai-search-optimization/2.2.1";
|
|
9
|
+
|
|
10
|
+
export async function fetchWithRetry(url, options = {}) {
|
|
11
|
+
const timeout = options.timeout || DEFAULT_TIMEOUT;
|
|
12
|
+
const retries = options.retries ?? DEFAULT_RETRIES;
|
|
13
|
+
const retryDelay = options.retryDelay || DEFAULT_RETRY_DELAY;
|
|
14
|
+
const headers = { "user-agent": USER_AGENT, ...options.headers };
|
|
15
|
+
|
|
16
|
+
let lastError;
|
|
17
|
+
|
|
18
|
+
for (let attempt = 0; attempt <= retries; attempt++) {
|
|
19
|
+
try {
|
|
20
|
+
const response = await fetch(url, {
|
|
21
|
+
redirect: "follow",
|
|
22
|
+
headers,
|
|
23
|
+
signal: AbortSignal.timeout(timeout)
|
|
24
|
+
});
|
|
25
|
+
return response;
|
|
26
|
+
} catch (err) {
|
|
27
|
+
lastError = err;
|
|
28
|
+
if (attempt < retries) {
|
|
29
|
+
await new Promise((r) => setTimeout(r, retryDelay * (attempt + 1)));
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
throw new Error(`Failed to fetch ${url} after ${retries + 1} attempts: ${lastError.message}`);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export async function fetchText(url, options = {}) {
|
|
38
|
+
const response = await fetchWithRetry(url, options);
|
|
39
|
+
if (!response.ok) {
|
|
40
|
+
throw new Error(`HTTP ${response.status} for ${url}`);
|
|
41
|
+
}
|
|
42
|
+
return response.text();
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export async function fetchTextSafe(url, options = {}) {
|
|
46
|
+
try {
|
|
47
|
+
return { ok: true, text: await fetchText(url, options), url };
|
|
48
|
+
} catch (err) {
|
|
49
|
+
return { ok: false, error: err.message, url };
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Run multiple fetches with concurrency control.
|
|
55
|
+
*/
|
|
56
|
+
export async function fetchBatch(urls, fn, options = {}) {
|
|
57
|
+
const concurrency = options.concurrency || 3;
|
|
58
|
+
const results = [];
|
|
59
|
+
|
|
60
|
+
const chunks = [];
|
|
61
|
+
for (let i = 0; i < urls.length; i += concurrency) {
|
|
62
|
+
chunks.push(urls.slice(i, i + concurrency));
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
for (const chunk of chunks) {
|
|
66
|
+
const chunkResults = await Promise.all(chunk.map((url) => fn(url)));
|
|
67
|
+
results.push(...chunkResults);
|
|
68
|
+
|
|
69
|
+
// Rate limit between chunks
|
|
70
|
+
if (options.delayBetweenChunks && chunks.indexOf(chunk) < chunks.length - 1) {
|
|
71
|
+
await new Promise((r) => setTimeout(r, options.delayBetweenChunks));
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
return results;
|
|
76
|
+
}
|
package/src/full-audit.js
CHANGED
|
@@ -5,6 +5,7 @@ import { auditProject } from "./audit.js";
|
|
|
5
5
|
import { analyzeCrawlers } from "./crawlers.js";
|
|
6
6
|
import { validateLlmsTxt } from "./validate-llms.js";
|
|
7
7
|
import { fullPageAudit } from "./full-page-audit.js";
|
|
8
|
+
import { analyzeSitemap } from "./sitemap.js";
|
|
8
9
|
|
|
9
10
|
async function runSafe(label, fn) {
|
|
10
11
|
try {
|
|
@@ -54,8 +55,23 @@ export async function fullAudit(input, options = {}) {
|
|
|
54
55
|
: { ok: true, data: { score: 0, found: false, summary: "No llms.txt found in project." } }
|
|
55
56
|
]);
|
|
56
57
|
|
|
57
|
-
// Phase
|
|
58
|
-
|
|
58
|
+
// Phase 2.5: Sitemap analysis (if available)
|
|
59
|
+
let sitemapResult = null;
|
|
60
|
+
const sitemapPath = await findSpecialFile(root, "sitemap.xml");
|
|
61
|
+
if (sitemapPath) {
|
|
62
|
+
sitemapResult = await runSafe("sitemap", () => analyzeSitemap(sitemapPath));
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// Phase 3: Sample page audits
|
|
66
|
+
// Auto-discover from sitemap if no explicit URLs provided
|
|
67
|
+
let sampleUrls = options.sampleUrls || [];
|
|
68
|
+
if (sampleUrls.length === 0 && options.autoSample !== false && sitemapResult?.ok) {
|
|
69
|
+
const entries = sitemapResult.data.entries || [];
|
|
70
|
+
const sitemapUrls = entries.map((e) => e.loc).filter(Boolean);
|
|
71
|
+
const maxAutoSample = options.maxAutoSample || 5;
|
|
72
|
+
sampleUrls = sitemapUrls.slice(0, maxAutoSample);
|
|
73
|
+
}
|
|
74
|
+
|
|
59
75
|
const samplePages = [];
|
|
60
76
|
|
|
61
77
|
if (sampleUrls.length > 0) {
|
package/src/index.js
CHANGED
|
@@ -85,4 +85,10 @@ export { batchFullPageAudit, renderBatchFullPageAuditMarkdown, writeBatchFullPag
|
|
|
85
85
|
export { generateAutoFix, renderAutoFixMarkdown, writeAutoFixOutput } from "./auto-fix.js";
|
|
86
86
|
export { diagnose, renderDiagnoseMarkdown, writeDiagnoseOutput } from "./diagnose.js";
|
|
87
87
|
export { comparePages, renderCompareMarkdown, writeCompareOutput } from "./compare.js";
|
|
88
|
+
export { deepBenchmark, renderDeepBenchmarkMarkdown, writeDeepBenchmarkOutput } from "./deep-benchmark.js";
|
|
89
|
+
export { quickSummary, renderSummaryMarkdown } from "./summary.js";
|
|
90
|
+
export { fetchWithRetry, fetchText, fetchTextSafe, fetchBatch } from "./fetch-utils.js";
|
|
91
|
+
export { fullPageAuditToCsv, batchFullPageAuditToCsv, deepBenchmarkToCsv, compareToCsv, arrayToCsv } from "./csv-export.js";
|
|
92
|
+
export { explain, renderExplainMarkdown, writeExplainOutput } from "./explain.js";
|
|
93
|
+
export { monitorPage, renderMonitorMarkdown, writeMonitorOutput } from "./monitor.js";
|
|
88
94
|
export { savePageSnapshot, listPageSnapshots, loadPageSnapshot, buildPageTrend, renderPageTrendMarkdown, writePageTrendOutput } from "./page-snapshot.js";
|
package/src/monitor.js
ADDED
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
import { writeScanOutput } from "./scan.js";
|
|
2
|
+
import { fullPageAudit } from "./full-page-audit.js";
|
|
3
|
+
import { savePageSnapshot, buildPageTrend } from "./page-snapshot.js";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Monitor: run full-page-audit, save snapshot, compare with previous,
|
|
7
|
+
* and alert on score changes.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
export async function monitorPage(input, options = {}) {
|
|
11
|
+
const threshold = options.threshold ?? 5;
|
|
12
|
+
const dataDir = options.dataDir;
|
|
13
|
+
|
|
14
|
+
// Get previous trend before new audit
|
|
15
|
+
const previousTrend = await buildPageTrend(input, { dataDir });
|
|
16
|
+
const previousScore = previousTrend.snapshotCount > 0
|
|
17
|
+
? previousTrend.snapshots[previousTrend.snapshots.length - 1].compositeScore
|
|
18
|
+
: null;
|
|
19
|
+
|
|
20
|
+
// Run fresh audit
|
|
21
|
+
const audit = await fullPageAudit(input, options);
|
|
22
|
+
const currentScore = audit.compositeScore;
|
|
23
|
+
|
|
24
|
+
// Save snapshot
|
|
25
|
+
const saved = await savePageSnapshot(audit, { dataDir });
|
|
26
|
+
|
|
27
|
+
// Compute delta
|
|
28
|
+
const delta = previousScore !== null ? currentScore - previousScore : null;
|
|
29
|
+
const dropped = delta !== null && delta < -threshold;
|
|
30
|
+
const improved = delta !== null && delta > threshold;
|
|
31
|
+
|
|
32
|
+
// Build alert
|
|
33
|
+
let alert = null;
|
|
34
|
+
if (dropped) {
|
|
35
|
+
alert = {
|
|
36
|
+
level: "warning",
|
|
37
|
+
message: `Score dropped by ${Math.abs(delta)} points (${previousScore} → ${currentScore}). Threshold: ${threshold}.`,
|
|
38
|
+
dimensions: findDroppedDimensions(audit, previousTrend)
|
|
39
|
+
};
|
|
40
|
+
} else if (improved) {
|
|
41
|
+
alert = {
|
|
42
|
+
level: "success",
|
|
43
|
+
message: `Score improved by ${delta} points (${previousScore} → ${currentScore}).`,
|
|
44
|
+
dimensions: []
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// Updated trend (including new snapshot)
|
|
49
|
+
const updatedTrend = await buildPageTrend(input, { dataDir });
|
|
50
|
+
|
|
51
|
+
return {
|
|
52
|
+
kind: "geo-monitor",
|
|
53
|
+
input,
|
|
54
|
+
currentScore,
|
|
55
|
+
previousScore,
|
|
56
|
+
delta,
|
|
57
|
+
dropped,
|
|
58
|
+
improved,
|
|
59
|
+
stable: !dropped && !improved,
|
|
60
|
+
threshold,
|
|
61
|
+
alert,
|
|
62
|
+
snapshotPath: saved.path,
|
|
63
|
+
snapshotCount: updatedTrend.snapshotCount,
|
|
64
|
+
trend: updatedTrend.trend,
|
|
65
|
+
compositeLabel: audit.compositeLabel,
|
|
66
|
+
dimensions: Object.fromEntries(
|
|
67
|
+
Object.entries(audit.dimensions).map(([k, v]) => [k, v.score])
|
|
68
|
+
),
|
|
69
|
+
summary: alert
|
|
70
|
+
? alert.message
|
|
71
|
+
: previousScore !== null
|
|
72
|
+
? `Stable at ${currentScore}/100 (Δ${delta >= 0 ? "+" : ""}${delta}, within ±${threshold} threshold).`
|
|
73
|
+
: `First scan: ${currentScore}/100. Baseline established.`
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function findDroppedDimensions(audit, previousTrend) {
|
|
78
|
+
if (!previousTrend.trend?.dimensionTrends) return [];
|
|
79
|
+
|
|
80
|
+
const dropped = [];
|
|
81
|
+
for (const [dim, trend] of Object.entries(previousTrend.trend.dimensionTrends)) {
|
|
82
|
+
const current = audit.dimensions[dim]?.score ?? 0;
|
|
83
|
+
if (current < trend.latest - 5) {
|
|
84
|
+
dropped.push({ dimension: dim, was: trend.latest, now: current, delta: current - trend.latest });
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
return dropped.sort((a, b) => a.delta - b.delta);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export function renderMonitorMarkdown(report) {
|
|
92
|
+
const lines = [
|
|
93
|
+
"# GEO Monitor Report",
|
|
94
|
+
"",
|
|
95
|
+
`- Input: \`${report.input}\``,
|
|
96
|
+
`- Current Score: \`${report.currentScore}/100\` (${report.compositeLabel})`,
|
|
97
|
+
`- Previous Score: \`${report.previousScore ?? "—"}\``,
|
|
98
|
+
`- Delta: \`${report.delta !== null ? (report.delta >= 0 ? "+" : "") + report.delta : "—"}\``,
|
|
99
|
+
`- Status: ${report.dropped ? "🔴 DROPPED" : report.improved ? "🟢 IMPROVED" : "➡️ STABLE"}`,
|
|
100
|
+
`- Snapshot: \`${report.snapshotPath}\``,
|
|
101
|
+
`- Total snapshots: \`${report.snapshotCount}\``,
|
|
102
|
+
`- Summary: ${report.summary}`,
|
|
103
|
+
""
|
|
104
|
+
];
|
|
105
|
+
|
|
106
|
+
if (report.alert) {
|
|
107
|
+
const icon = report.alert.level === "warning" ? "⚠️" : "✅";
|
|
108
|
+
lines.push(`## ${icon} Alert`, "", report.alert.message, "");
|
|
109
|
+
|
|
110
|
+
if (report.alert.dimensions?.length > 0) {
|
|
111
|
+
lines.push("### Dimensions that dropped", "");
|
|
112
|
+
for (const d of report.alert.dimensions) {
|
|
113
|
+
lines.push(`- 🔴 **${d.dimension}**: ${d.was} → ${d.now} (${d.delta})`);
|
|
114
|
+
}
|
|
115
|
+
lines.push("");
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
if (report.dimensions) {
|
|
120
|
+
lines.push("## Current Dimensions", "");
|
|
121
|
+
const sorted = Object.entries(report.dimensions).sort((a, b) => a[1] - b[1]);
|
|
122
|
+
for (const [dim, score] of sorted) {
|
|
123
|
+
const icon = score >= 70 ? "🟢" : score >= 40 ? "🟡" : "🔴";
|
|
124
|
+
lines.push(`- ${icon} ${dim}: ${score}/100`);
|
|
125
|
+
}
|
|
126
|
+
lines.push("");
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
if (report.trend) {
|
|
130
|
+
lines.push(
|
|
131
|
+
"## Trend",
|
|
132
|
+
"",
|
|
133
|
+
`- Direction: **${report.trend.direction}**`,
|
|
134
|
+
`- First: ${report.trend.firstScore} → Latest: ${report.trend.latestScore}`,
|
|
135
|
+
""
|
|
136
|
+
);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
return lines.join("\n");
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
export async function writeMonitorOutput(outputPath, content) {
|
|
143
|
+
return writeScanOutput(outputPath, content);
|
|
144
|
+
}
|
package/src/summary.js
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { diagnose } from "./diagnose.js";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Quick summary: one-line score output for any input.
|
|
5
|
+
* Designed for scripting, CI, and quick checks.
|
|
6
|
+
*
|
|
7
|
+
* Output format: SCORE/100 LABEL INPUT
|
|
8
|
+
* Example: 72/100 Moderate https://example.com
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
export async function quickSummary(input, options = {}) {
|
|
12
|
+
const result = await diagnose(input, options);
|
|
13
|
+
|
|
14
|
+
return {
|
|
15
|
+
kind: "geo-summary",
|
|
16
|
+
input,
|
|
17
|
+
score: result.score,
|
|
18
|
+
label: result.scoreLabel,
|
|
19
|
+
type: result.analysisType,
|
|
20
|
+
oneLiner: `${result.score}/100 ${result.scoreLabel} ${input}`,
|
|
21
|
+
topIssue: result.quickWins[0] || null,
|
|
22
|
+
nextCommand: result.nextCommands[0]?.cmd || null
|
|
23
|
+
};
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function renderSummaryMarkdown(report) {
|
|
27
|
+
return `${report.score}/100 (${report.label}) — ${report.input}${report.topIssue ? `\nTop issue: ${report.topIssue}` : ""}\n`;
|
|
28
|
+
}
|