geo-ai-search-optimization 1.4.1 → 2.0.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 +23 -0
- package/examples/github-action.yml +27 -0
- package/package.json +3 -1
- package/src/audit-diff.js +184 -0
- package/src/batch-page-audit.js +140 -0
- package/src/benchmark.js +126 -0
- package/src/ci.js +81 -0
- package/src/citation-check.js +157 -0
- package/src/citation-monitor.js +86 -0
- package/src/cli-site-ops-commands.js +274 -4
- package/src/content-gap.js +170 -0
- package/src/index.js +12 -0
- package/src/pm-brief.js +8 -0
- package/src/pre-commit.js +62 -0
- package/src/repo-patch-plan.js +257 -0
- package/src/report.js +37 -12
- package/src/snapshot.js +51 -0
- package/src/trend.js +102 -0
- package/src/watch.js +49 -0
package/README.md
CHANGED
|
@@ -100,6 +100,23 @@ geo-ai-search-optimization rewrite-pack ./content/posts/geo-guide.mdx --json --o
|
|
|
100
100
|
- execution checklist
|
|
101
101
|
- 可直接交给 agent 的 rewrite prompt
|
|
102
102
|
|
|
103
|
+
## Repo Patch Plan 命令
|
|
104
|
+
|
|
105
|
+
如果你希望从“页面建议”继续推进到“仓库里先改哪些文件和模板”,可以直接用 `repo-patch-plan`:
|
|
106
|
+
|
|
107
|
+
```bash
|
|
108
|
+
geo-ai-search-optimization repo-patch-plan ./your-site
|
|
109
|
+
geo-ai-search-optimization repo-patch-plan ./your-site --json --out ./reports/repo-patch-plan.json
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
它会输出:
|
|
113
|
+
|
|
114
|
+
- 候选文件区域
|
|
115
|
+
- patch packets
|
|
116
|
+
- likely files
|
|
117
|
+
- execution notes
|
|
118
|
+
- 可直接交给 agent 的 repo-level prompt
|
|
119
|
+
|
|
103
120
|
## Agent Resume 命令
|
|
104
121
|
|
|
105
122
|
如果 GEO 工作已经做过一轮或多轮,你不想让下一个 agent 从头重新判断,而是想让它从最近一个可靠恢复点继续,可以直接用 `agent-resume`:
|
|
@@ -988,6 +1005,12 @@ geo-ai-search-optimization help
|
|
|
988
1005
|
- 基于 `page-audit` 继续产出 metadata drafts、章节结构、FAQ questions、schema recommendations 与 execution checklist
|
|
989
1006
|
- 让产品从“页面级诊断”继续推进到“页面级改写方案”
|
|
990
1007
|
|
|
1008
|
+
## New in 1.4.2
|
|
1009
|
+
|
|
1010
|
+
- 新增 `repo-patch-plan`
|
|
1011
|
+
- 把 GEO 问题继续收敛到仓库级 likely files、模板入口、metadata / schema / navigation 区域
|
|
1012
|
+
- 让 agent 更容易从“页面建议”切到“仓库改动计划”
|
|
1013
|
+
|
|
991
1014
|
## New in 1.2.20
|
|
992
1015
|
|
|
993
1016
|
- 新增 `agent-continue`
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
# Example GitHub Action workflow for GEO CI checks
|
|
2
|
+
# Add this to .github/workflows/geo-ci.yml in your project
|
|
3
|
+
|
|
4
|
+
name: GEO CI
|
|
5
|
+
on:
|
|
6
|
+
push:
|
|
7
|
+
branches: [main]
|
|
8
|
+
pull_request:
|
|
9
|
+
branches: [main]
|
|
10
|
+
|
|
11
|
+
jobs:
|
|
12
|
+
geo-check:
|
|
13
|
+
runs-on: ubuntu-latest
|
|
14
|
+
steps:
|
|
15
|
+
- uses: actions/checkout@v4
|
|
16
|
+
- uses: actions/setup-node@v4
|
|
17
|
+
with:
|
|
18
|
+
node-version: 20
|
|
19
|
+
- run: npm install -g geo-ai-search-optimization
|
|
20
|
+
- name: Run GEO audit
|
|
21
|
+
run: geo-ai-search-optimization ci . --min-score 40 --json
|
|
22
|
+
- name: Run GEO audit with baseline
|
|
23
|
+
if: always()
|
|
24
|
+
run: |
|
|
25
|
+
# Optional: compare against a saved baseline
|
|
26
|
+
# geo-ai-search-optimization ci . --min-score 40 --baseline baseline.json --fail-on-regression
|
|
27
|
+
echo "Baseline comparison skipped (no baseline file)"
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "geo-ai-search-optimization",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "2.0.0",
|
|
4
4
|
"description": "Install and run a Generative Engine Optimization (GEO)-first, SEO-supported Codex skill for website optimization.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -13,6 +13,7 @@
|
|
|
13
13
|
"bin",
|
|
14
14
|
"src",
|
|
15
15
|
"resources",
|
|
16
|
+
"examples",
|
|
16
17
|
"README.md",
|
|
17
18
|
"LICENSE"
|
|
18
19
|
],
|
|
@@ -20,6 +21,7 @@
|
|
|
20
21
|
"node": ">=18"
|
|
21
22
|
},
|
|
22
23
|
"scripts": {
|
|
24
|
+
"test": "node --test 'test/**/*.test.js'",
|
|
23
25
|
"postinstall": "node ./src/postinstall.js",
|
|
24
26
|
"check": "node ./src/cli.js --help",
|
|
25
27
|
"scan:self": "node ./src/cli.js scan ./resources/geo-ai-search-optimization"
|
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
import fs from "node:fs/promises";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { writeScanOutput } from "./scan.js";
|
|
4
|
+
|
|
5
|
+
async function loadAuditJson(filePath) {
|
|
6
|
+
const resolved = path.resolve(filePath);
|
|
7
|
+
const raw = await fs.readFile(resolved, "utf8");
|
|
8
|
+
const data = JSON.parse(raw);
|
|
9
|
+
if (!data.kind || !data.score || !data.checks) {
|
|
10
|
+
throw new Error(`${resolved} 不是有效的审计 JSON(缺少 kind / score / checks)`);
|
|
11
|
+
}
|
|
12
|
+
return data;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function diffChecks(baselineChecks, currentChecks) {
|
|
16
|
+
const baselineMap = new Map(baselineChecks.map((c) => [c.key, c]));
|
|
17
|
+
return currentChecks.map((current) => {
|
|
18
|
+
const baseline = baselineMap.get(current.key);
|
|
19
|
+
if (!baseline) {
|
|
20
|
+
return { ...current, baselinePassed: null, delta: current.pointsAwarded };
|
|
21
|
+
}
|
|
22
|
+
return {
|
|
23
|
+
key: current.key,
|
|
24
|
+
label: current.label,
|
|
25
|
+
baselinePassed: baseline.passed,
|
|
26
|
+
currentPassed: current.passed,
|
|
27
|
+
baselinePoints: baseline.pointsAwarded,
|
|
28
|
+
currentPoints: current.pointsAwarded,
|
|
29
|
+
maxPoints: current.maxPoints,
|
|
30
|
+
delta: current.pointsAwarded - baseline.pointsAwarded
|
|
31
|
+
};
|
|
32
|
+
});
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function classifyChanges(checkDiffs) {
|
|
36
|
+
const improved = [];
|
|
37
|
+
const regressed = [];
|
|
38
|
+
const unchanged = [];
|
|
39
|
+
|
|
40
|
+
for (const diff of checkDiffs) {
|
|
41
|
+
if (diff.delta > 0) {
|
|
42
|
+
improved.push(diff);
|
|
43
|
+
} else if (diff.delta < 0) {
|
|
44
|
+
regressed.push(diff);
|
|
45
|
+
} else {
|
|
46
|
+
unchanged.push(diff);
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
return { improved, regressed, unchanged };
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function diffAreas(baselineAreas, currentAreas) {
|
|
54
|
+
if (!baselineAreas || !currentAreas) return [];
|
|
55
|
+
|
|
56
|
+
const baselineMap = new Map(baselineAreas.map((a) => [a.key, a]));
|
|
57
|
+
return currentAreas.map((current) => {
|
|
58
|
+
const baseline = baselineMap.get(current.key);
|
|
59
|
+
if (!baseline) {
|
|
60
|
+
return { key: current.key, title: current.title, baselineSeverity: null, currentSeverity: current.severity, newIssues: current.issues, resolvedIssues: [] };
|
|
61
|
+
}
|
|
62
|
+
const baselineIssueSet = new Set(baseline.issues);
|
|
63
|
+
const currentIssueSet = new Set(current.issues);
|
|
64
|
+
return {
|
|
65
|
+
key: current.key,
|
|
66
|
+
title: current.title,
|
|
67
|
+
baselineSeverity: baseline.severity,
|
|
68
|
+
currentSeverity: current.severity,
|
|
69
|
+
newIssues: current.issues.filter((i) => !baselineIssueSet.has(i)),
|
|
70
|
+
resolvedIssues: baseline.issues.filter((i) => !currentIssueSet.has(i))
|
|
71
|
+
};
|
|
72
|
+
});
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export function createAuditDiff(baseline, current) {
|
|
76
|
+
const scoreDelta = current.score - baseline.score;
|
|
77
|
+
const checkDiffs = diffChecks(baseline.checks, current.checks);
|
|
78
|
+
const { improved, regressed, unchanged } = classifyChanges(checkDiffs);
|
|
79
|
+
const areaDiffs = diffAreas(baseline.areas, current.areas);
|
|
80
|
+
|
|
81
|
+
const hasRegression = regressed.length > 0;
|
|
82
|
+
const allNewIssues = areaDiffs.flatMap((a) => a.newIssues);
|
|
83
|
+
const allResolvedIssues = areaDiffs.flatMap((a) => a.resolvedIssues);
|
|
84
|
+
|
|
85
|
+
let verdict;
|
|
86
|
+
if (scoreDelta > 0 && !hasRegression) {
|
|
87
|
+
verdict = "改善";
|
|
88
|
+
} else if (scoreDelta === 0 && !hasRegression) {
|
|
89
|
+
verdict = "持平";
|
|
90
|
+
} else if (scoreDelta > 0 && hasRegression) {
|
|
91
|
+
verdict = "部分改善(存在回归)";
|
|
92
|
+
} else if (scoreDelta < 0) {
|
|
93
|
+
verdict = "退步";
|
|
94
|
+
} else {
|
|
95
|
+
verdict = "持平(存在回归)";
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
return {
|
|
99
|
+
kind: "geo-audit-diff",
|
|
100
|
+
baseline: {
|
|
101
|
+
score: baseline.score,
|
|
102
|
+
maxScore: baseline.maxScore,
|
|
103
|
+
scoreLabel: baseline.scoreLabel,
|
|
104
|
+
kind: baseline.kind
|
|
105
|
+
},
|
|
106
|
+
current: {
|
|
107
|
+
score: current.score,
|
|
108
|
+
maxScore: current.maxScore,
|
|
109
|
+
scoreLabel: current.scoreLabel,
|
|
110
|
+
kind: current.kind
|
|
111
|
+
},
|
|
112
|
+
scoreDelta,
|
|
113
|
+
verdict,
|
|
114
|
+
checkDiffs,
|
|
115
|
+
improved,
|
|
116
|
+
regressed,
|
|
117
|
+
unchanged,
|
|
118
|
+
areaDiffs,
|
|
119
|
+
newIssues: allNewIssues,
|
|
120
|
+
resolvedIssues: allResolvedIssues,
|
|
121
|
+
hasRegression
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
export async function createAuditDiffFromFiles(baselinePath, currentPath) {
|
|
126
|
+
const baseline = await loadAuditJson(baselinePath);
|
|
127
|
+
const current = await loadAuditJson(currentPath);
|
|
128
|
+
return createAuditDiff(baseline, current);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
export function renderAuditDiffMarkdown(diff) {
|
|
132
|
+
const lines = [
|
|
133
|
+
"# GEO 审计对比",
|
|
134
|
+
"",
|
|
135
|
+
`- 判定:**${diff.verdict}**`,
|
|
136
|
+
`- 基线分数:\`${diff.baseline.score}/${diff.baseline.maxScore}\`(${diff.baseline.scoreLabel})`,
|
|
137
|
+
`- 当前分数:\`${diff.current.score}/${diff.current.maxScore}\`(${diff.current.scoreLabel})`,
|
|
138
|
+
`- 分数变化:\`${diff.scoreDelta >= 0 ? "+" : ""}${diff.scoreDelta}\``,
|
|
139
|
+
""
|
|
140
|
+
];
|
|
141
|
+
|
|
142
|
+
lines.push("## 评分明细对比", "", "| 检查项 | 基线 | 当前 | 变化 |", "|--------|------|------|------|");
|
|
143
|
+
for (const check of diff.checkDiffs) {
|
|
144
|
+
const baseStr = check.baselinePassed === null ? "N/A" : `${check.baselinePassed ? "✓" : "✗"} (${check.baselinePoints})`;
|
|
145
|
+
const curStr = `${check.currentPassed ? "✓" : "✗"} (${check.currentPoints})`;
|
|
146
|
+
const deltaStr = check.delta > 0 ? `+${check.delta}` : check.delta === 0 ? "—" : String(check.delta);
|
|
147
|
+
lines.push(`| ${check.label} | ${baseStr} | ${curStr} | ${deltaStr} |`);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
if (diff.improved.length > 0) {
|
|
151
|
+
lines.push("", "## 改善项", "");
|
|
152
|
+
for (const item of diff.improved) {
|
|
153
|
+
lines.push(`- ${item.label}:+${item.delta} 分`);
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
if (diff.regressed.length > 0) {
|
|
158
|
+
lines.push("", "## ⚠ 回归项", "");
|
|
159
|
+
for (const item of diff.regressed) {
|
|
160
|
+
lines.push(`- ${item.label}:${item.delta} 分`);
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
if (diff.resolvedIssues.length > 0) {
|
|
165
|
+
lines.push("", "## 已解决问题", "");
|
|
166
|
+
for (const issue of diff.resolvedIssues) {
|
|
167
|
+
lines.push(`- ${issue}`);
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
if (diff.newIssues.length > 0) {
|
|
172
|
+
lines.push("", "## 新增问题", "");
|
|
173
|
+
for (const issue of diff.newIssues) {
|
|
174
|
+
lines.push(`- ${issue}`);
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
lines.push("");
|
|
179
|
+
return lines.join("\n");
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
export async function writeAuditDiffOutput(outputPath, content) {
|
|
183
|
+
return writeScanOutput(outputPath, content);
|
|
184
|
+
}
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
import fs from "node:fs/promises";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { auditPage } from "./page-audit.js";
|
|
4
|
+
import { writeScanOutput } from "./scan.js";
|
|
5
|
+
|
|
6
|
+
async function runWithConcurrencyLimit(tasks, limit) {
|
|
7
|
+
const results = [];
|
|
8
|
+
let index = 0;
|
|
9
|
+
|
|
10
|
+
async function worker() {
|
|
11
|
+
while (index < tasks.length) {
|
|
12
|
+
const currentIndex = index++;
|
|
13
|
+
results[currentIndex] = await tasks[currentIndex]();
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const workers = Array.from({ length: Math.min(limit, tasks.length) }, () => worker());
|
|
18
|
+
await Promise.all(workers);
|
|
19
|
+
return results;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export async function batchAuditPages(urls, options = {}) {
|
|
23
|
+
if (!urls || urls.length === 0) {
|
|
24
|
+
throw new Error("batch-page-audit 需要至少一个 URL 或文件路径");
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const concurrency = options.concurrency || 3;
|
|
28
|
+
const tasks = urls.map((url) => async () => {
|
|
29
|
+
try {
|
|
30
|
+
const result = await auditPage(url, {});
|
|
31
|
+
return { url, success: true, result };
|
|
32
|
+
} catch (error) {
|
|
33
|
+
return { url, success: false, error: error.message, result: null };
|
|
34
|
+
}
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
const results = await runWithConcurrencyLimit(tasks, concurrency);
|
|
38
|
+
|
|
39
|
+
const successResults = results.filter((r) => r.success);
|
|
40
|
+
const failedResults = results.filter((r) => !r.success);
|
|
41
|
+
|
|
42
|
+
const scores = successResults.map((r) => r.result.score.score);
|
|
43
|
+
const averageScore = scores.length > 0 ? Math.round(scores.reduce((a, b) => a + b, 0) / scores.length) : 0;
|
|
44
|
+
const worstPage = successResults.length > 0
|
|
45
|
+
? successResults.reduce((worst, current) =>
|
|
46
|
+
current.result.score.score < worst.result.score.score ? current : worst
|
|
47
|
+
)
|
|
48
|
+
: null;
|
|
49
|
+
const bestPage = successResults.length > 0
|
|
50
|
+
? successResults.reduce((best, current) =>
|
|
51
|
+
current.result.score.score > best.result.score.score ? current : best
|
|
52
|
+
)
|
|
53
|
+
: null;
|
|
54
|
+
|
|
55
|
+
// Signal coverage matrix
|
|
56
|
+
const allSignals = new Set();
|
|
57
|
+
for (const r of successResults) {
|
|
58
|
+
for (const key of Object.keys(r.result.signals)) {
|
|
59
|
+
allSignals.add(key);
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const signalCoverage = {};
|
|
64
|
+
for (const signal of allSignals) {
|
|
65
|
+
const pagesWithSignal = successResults.filter((r) => r.result.signals[signal]?.count > 0).length;
|
|
66
|
+
signalCoverage[signal] = {
|
|
67
|
+
pagesWithSignal,
|
|
68
|
+
totalPages: successResults.length,
|
|
69
|
+
coveragePercent: successResults.length > 0 ? Math.round((pagesWithSignal / successResults.length) * 100) : 0
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
return {
|
|
74
|
+
kind: "geo-batch-page-audit",
|
|
75
|
+
totalPages: urls.length,
|
|
76
|
+
successCount: successResults.length,
|
|
77
|
+
failedCount: failedResults.length,
|
|
78
|
+
averageScore,
|
|
79
|
+
maxScore: 100,
|
|
80
|
+
worstPage: worstPage ? { url: worstPage.url, score: worstPage.result.score.score } : null,
|
|
81
|
+
bestPage: bestPage ? { url: bestPage.url, score: bestPage.result.score.score } : null,
|
|
82
|
+
signalCoverage,
|
|
83
|
+
pages: results.map((r) => ({
|
|
84
|
+
url: r.url,
|
|
85
|
+
success: r.success,
|
|
86
|
+
score: r.success ? r.result.score.score : null,
|
|
87
|
+
scoreLabel: r.success ? r.result.scoreLabel : null,
|
|
88
|
+
error: r.success ? null : r.error
|
|
89
|
+
})),
|
|
90
|
+
pageResults: successResults.map((r) => r.result),
|
|
91
|
+
failed: failedResults.map((r) => ({ url: r.url, error: r.error }))
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export function renderBatchPageAuditMarkdown(report) {
|
|
96
|
+
const lines = [
|
|
97
|
+
"# GEO 批量页面审计",
|
|
98
|
+
"",
|
|
99
|
+
`- 总页面数:\`${report.totalPages}\``,
|
|
100
|
+
`- 成功审计:\`${report.successCount}\``,
|
|
101
|
+
`- 失败:\`${report.failedCount}\``,
|
|
102
|
+
`- 平均分数:\`${report.averageScore}/${report.maxScore}\``,
|
|
103
|
+
""
|
|
104
|
+
];
|
|
105
|
+
|
|
106
|
+
if (report.bestPage) {
|
|
107
|
+
lines.push(`- 最佳页面:\`${report.bestPage.url}\`(${report.bestPage.score} 分)`);
|
|
108
|
+
}
|
|
109
|
+
if (report.worstPage) {
|
|
110
|
+
lines.push(`- 最差页面:\`${report.worstPage.url}\`(${report.worstPage.score} 分)`);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
lines.push("", "## 逐页分数", "", "| 页面 | 分数 | 状态 |", "|------|------|------|");
|
|
114
|
+
for (const page of report.pages) {
|
|
115
|
+
if (page.success) {
|
|
116
|
+
lines.push(`| ${page.url} | ${page.score}/${report.maxScore} | ${page.scoreLabel} |`);
|
|
117
|
+
} else {
|
|
118
|
+
lines.push(`| ${page.url} | — | 失败:${page.error} |`);
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
lines.push("", "## 信号覆盖矩阵", "", "| 信号 | 覆盖页数 | 覆盖率 |", "|------|----------|--------|");
|
|
123
|
+
for (const [signal, coverage] of Object.entries(report.signalCoverage)) {
|
|
124
|
+
lines.push(`| ${signal} | ${coverage.pagesWithSignal}/${coverage.totalPages} | ${coverage.coveragePercent}% |`);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
if (report.failed.length > 0) {
|
|
128
|
+
lines.push("", "## 失败页面", "");
|
|
129
|
+
for (const f of report.failed) {
|
|
130
|
+
lines.push(`- ${f.url}:${f.error}`);
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
lines.push("");
|
|
135
|
+
return lines.join("\n");
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
export async function writeBatchPageAuditOutput(outputPath, content) {
|
|
139
|
+
return writeScanOutput(outputPath, content);
|
|
140
|
+
}
|
package/src/benchmark.js
ADDED
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
import { auditPage } from "./page-audit.js";
|
|
2
|
+
import { writeScanOutput } from "./scan.js";
|
|
3
|
+
|
|
4
|
+
export async function runBenchmark(ownUrl, competitorUrls, options = {}) {
|
|
5
|
+
if (!ownUrl) {
|
|
6
|
+
throw new Error("benchmark 需要你自己的 URL");
|
|
7
|
+
}
|
|
8
|
+
if (!competitorUrls || competitorUrls.length === 0) {
|
|
9
|
+
throw new Error("benchmark 需要至少一个竞品 URL(使用 --competitors)");
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
const allUrls = [ownUrl, ...competitorUrls];
|
|
13
|
+
const results = [];
|
|
14
|
+
|
|
15
|
+
for (const url of allUrls) {
|
|
16
|
+
try {
|
|
17
|
+
const result = await auditPage(url, {});
|
|
18
|
+
results.push({ url, success: true, result });
|
|
19
|
+
} catch (error) {
|
|
20
|
+
results.push({ url, success: false, error: error.message, result: null });
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const ownResult = results[0];
|
|
25
|
+
const competitorResults = results.slice(1);
|
|
26
|
+
|
|
27
|
+
// Build comparison matrix
|
|
28
|
+
const signalKeys = new Set();
|
|
29
|
+
for (const r of results.filter((r) => r.success)) {
|
|
30
|
+
for (const key of Object.keys(r.result.signals)) {
|
|
31
|
+
signalKeys.add(key);
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const matrix = [];
|
|
36
|
+
for (const signal of signalKeys) {
|
|
37
|
+
const row = {
|
|
38
|
+
signal,
|
|
39
|
+
own: ownResult.success ? (ownResult.result.signals[signal]?.count > 0) : null,
|
|
40
|
+
competitors: competitorResults.map((r) =>
|
|
41
|
+
r.success ? (r.result.signals[signal]?.count > 0) : null
|
|
42
|
+
)
|
|
43
|
+
};
|
|
44
|
+
matrix.push(row);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// Score comparison
|
|
48
|
+
const scoreComparison = results.map((r) => ({
|
|
49
|
+
url: r.url,
|
|
50
|
+
score: r.success ? r.result.score.score : null,
|
|
51
|
+
scoreLabel: r.success ? r.result.scoreLabel : null,
|
|
52
|
+
isOwn: r.url === ownUrl
|
|
53
|
+
}));
|
|
54
|
+
|
|
55
|
+
// Gaps: signals competitors have that you don't
|
|
56
|
+
const gaps = [];
|
|
57
|
+
if (ownResult.success) {
|
|
58
|
+
for (const signal of signalKeys) {
|
|
59
|
+
const ownHas = ownResult.result.signals[signal]?.count > 0;
|
|
60
|
+
if (!ownHas) {
|
|
61
|
+
const competitorsWithSignal = competitorResults
|
|
62
|
+
.filter((r) => r.success && r.result.signals[signal]?.count > 0)
|
|
63
|
+
.map((r) => r.url);
|
|
64
|
+
if (competitorsWithSignal.length > 0) {
|
|
65
|
+
gaps.push({ signal, competitorsWithSignal });
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
return {
|
|
72
|
+
kind: "geo-benchmark",
|
|
73
|
+
ownUrl,
|
|
74
|
+
competitorUrls,
|
|
75
|
+
scoreComparison,
|
|
76
|
+
signalMatrix: matrix,
|
|
77
|
+
gaps,
|
|
78
|
+
results: results.map((r) => ({
|
|
79
|
+
url: r.url,
|
|
80
|
+
success: r.success,
|
|
81
|
+
score: r.success ? r.result.score.score : null,
|
|
82
|
+
error: r.success ? null : r.error
|
|
83
|
+
})),
|
|
84
|
+
pageResults: results.filter((r) => r.success).map((r) => r.result)
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export function renderBenchmarkMarkdown(report) {
|
|
89
|
+
const lines = [
|
|
90
|
+
"# GEO 竞品基准对比",
|
|
91
|
+
"",
|
|
92
|
+
`- 你的 URL:\`${report.ownUrl}\``,
|
|
93
|
+
`- 竞品数量:\`${report.competitorUrls.length}\``,
|
|
94
|
+
""
|
|
95
|
+
];
|
|
96
|
+
|
|
97
|
+
lines.push("## 分数对比", "", "| URL | 分数 | 状态 | 角色 |", "|-----|------|------|------|");
|
|
98
|
+
for (const entry of report.scoreComparison) {
|
|
99
|
+
const role = entry.isOwn ? "你的" : "竞品";
|
|
100
|
+
lines.push(`| ${entry.url} | ${entry.score ?? "—"}/100 | ${entry.scoreLabel ?? "失败"} | ${role} |`);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
lines.push("", "## 信号覆盖矩阵", "");
|
|
104
|
+
const headerUrls = report.scoreComparison.map((e) => e.url.replace(/https?:\/\//, "").slice(0, 30));
|
|
105
|
+
lines.push(`| 信号 | ${headerUrls.join(" | ")} |`);
|
|
106
|
+
lines.push(`|------${headerUrls.map(() => "|------").join("")}|`);
|
|
107
|
+
for (const row of report.signalMatrix) {
|
|
108
|
+
const ownCell = row.own === null ? "—" : row.own ? "✓" : "✗";
|
|
109
|
+
const compCells = row.competitors.map((c) => c === null ? "—" : c ? "✓" : "✗");
|
|
110
|
+
lines.push(`| ${row.signal} | ${ownCell} | ${compCells.join(" | ")} |`);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
if (report.gaps.length > 0) {
|
|
114
|
+
lines.push("", "## 你的信号缺口(竞品有而你没有)", "");
|
|
115
|
+
for (const gap of report.gaps) {
|
|
116
|
+
lines.push(`- **${gap.signal}**:${gap.competitorsWithSignal.join("、")} 有此信号`);
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
lines.push("");
|
|
121
|
+
return lines.join("\n");
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
export async function writeBenchmarkOutput(outputPath, content) {
|
|
125
|
+
return writeScanOutput(outputPath, content);
|
|
126
|
+
}
|
package/src/ci.js
ADDED
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import fs from "node:fs/promises";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { auditProject } from "./audit.js";
|
|
4
|
+
import { writeScanOutput } from "./scan.js";
|
|
5
|
+
|
|
6
|
+
export async function runCiCheck(projectPath, options = {}) {
|
|
7
|
+
const minScore = options.minScore ?? 40;
|
|
8
|
+
const failOnRegression = options.failOnRegression ?? false;
|
|
9
|
+
|
|
10
|
+
const result = await auditProject(projectPath, {});
|
|
11
|
+
|
|
12
|
+
let baselineScore = null;
|
|
13
|
+
let regression = false;
|
|
14
|
+
|
|
15
|
+
if (options.baseline) {
|
|
16
|
+
const baselinePath = path.resolve(options.baseline);
|
|
17
|
+
const raw = await fs.readFile(baselinePath, "utf8");
|
|
18
|
+
const baseline = JSON.parse(raw);
|
|
19
|
+
baselineScore = baseline.score;
|
|
20
|
+
if (result.score < baselineScore) {
|
|
21
|
+
regression = true;
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
let exitCode = 0;
|
|
26
|
+
let status;
|
|
27
|
+
|
|
28
|
+
if (regression && failOnRegression) {
|
|
29
|
+
exitCode = 2;
|
|
30
|
+
status = "回归";
|
|
31
|
+
} else if (result.score < minScore) {
|
|
32
|
+
exitCode = 1;
|
|
33
|
+
status = "低于阈值";
|
|
34
|
+
} else {
|
|
35
|
+
status = "通过";
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
return {
|
|
39
|
+
kind: "geo-ci-check",
|
|
40
|
+
projectPath,
|
|
41
|
+
score: result.score,
|
|
42
|
+
maxScore: result.maxScore,
|
|
43
|
+
scoreLabel: result.scoreLabel,
|
|
44
|
+
minScore,
|
|
45
|
+
baselineScore,
|
|
46
|
+
regression,
|
|
47
|
+
failOnRegression,
|
|
48
|
+
exitCode,
|
|
49
|
+
status,
|
|
50
|
+
summary: exitCode === 0
|
|
51
|
+
? `CI 检查通过:分数 ${result.score}/${result.maxScore}(阈值 ${minScore})`
|
|
52
|
+
: exitCode === 1
|
|
53
|
+
? `CI 检查失败:分数 ${result.score} 低于阈值 ${minScore}`
|
|
54
|
+
: `CI 检查失败:检测到回归(当前 ${result.score},基线 ${baselineScore})`,
|
|
55
|
+
audit: result
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function renderCiCheckMarkdown(report) {
|
|
60
|
+
const lines = [
|
|
61
|
+
"# GEO CI 检查",
|
|
62
|
+
"",
|
|
63
|
+
`- 状态:**${report.status}**`,
|
|
64
|
+
`- 分数:\`${report.score}/${report.maxScore}\`(${report.scoreLabel})`,
|
|
65
|
+
`- 阈值:\`${report.minScore}\``,
|
|
66
|
+
""
|
|
67
|
+
];
|
|
68
|
+
|
|
69
|
+
if (report.baselineScore !== null) {
|
|
70
|
+
lines.push(`- 基线分数:\`${report.baselineScore}\``);
|
|
71
|
+
lines.push(`- 回归:\`${report.regression}\``);
|
|
72
|
+
lines.push("");
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
lines.push(report.summary, "");
|
|
76
|
+
return lines.join("\n");
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export async function writeCiCheckOutput(outputPath, content) {
|
|
80
|
+
return writeScanOutput(outputPath, content);
|
|
81
|
+
}
|