geo-ai-search-optimization 1.3.14 → 1.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/README.md +25 -0
- package/package.json +1 -1
- package/src/cli-site-ops-commands.js +18 -0
- package/src/index.js +1 -0
- package/src/page-audit.js +464 -0
package/README.md
CHANGED
|
@@ -63,6 +63,24 @@ geo-ai-search-optimization agent-orchestrator ./reports/agent-playbook-pack.json
|
|
|
63
63
|
- 做完之后下一条是什么
|
|
64
64
|
- 可直接复制给 agent 的 orchestrator prompt
|
|
65
65
|
|
|
66
|
+
## Page Audit 命令
|
|
67
|
+
|
|
68
|
+
如果你不想一次看整站,而是想先判断“这个单页为什么不容易被生成式搜索理解与引用”,可以直接用 `page-audit`:
|
|
69
|
+
|
|
70
|
+
```bash
|
|
71
|
+
geo-ai-search-optimization page-audit https://example.com/blog/geo-guide
|
|
72
|
+
geo-ai-search-optimization page-audit ./content/posts/geo-guide.mdx
|
|
73
|
+
geo-ai-search-optimization page-audit ./content/posts/geo-guide.mdx --json --out ./reports/page-audit.json
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
它会输出:
|
|
77
|
+
|
|
78
|
+
- 单页 GEO 分数
|
|
79
|
+
- 页面级问题区域
|
|
80
|
+
- 推荐新增模块
|
|
81
|
+
- rewrite brief
|
|
82
|
+
- 可直接交给 agent 的 page-level prompt
|
|
83
|
+
|
|
66
84
|
## Agent Resume 命令
|
|
67
85
|
|
|
68
86
|
如果 GEO 工作已经做过一轮或多轮,你不想让下一个 agent 从头重新判断,而是想让它从最近一个可靠恢复点继续,可以直接用 `agent-resume`:
|
|
@@ -938,6 +956,13 @@ geo-ai-search-optimization help
|
|
|
938
956
|
- `agent-handoff / apply-plan / completion-report / handoff-bundle / share-pack / export-pack / html-pack / publish-pack / exec-summary / fix-plan / owner-board / meeting-pack / pm-brief / roadmap / report` 已统一进入 registry
|
|
939
957
|
- CLI 更接近所有命令族都使用显式 registry metadata 的结构
|
|
940
958
|
|
|
959
|
+
## New in 1.4.0
|
|
960
|
+
|
|
961
|
+
- 新增 `page-audit`
|
|
962
|
+
- 支持对单个 URL 或本地单页文件做 GEO 页面级审计
|
|
963
|
+
- 输出页面分数、问题区域、推荐新增模块、rewrite brief 与 agent prompt
|
|
964
|
+
- 产品能力从“站点级诊断”扩展到“页面级修复入口”
|
|
965
|
+
|
|
941
966
|
## New in 1.2.20
|
|
942
967
|
|
|
943
968
|
- 新增 `agent-continue`
|
package/package.json
CHANGED
|
@@ -13,6 +13,7 @@ import {
|
|
|
13
13
|
writeInteractiveOnboardingOutput
|
|
14
14
|
} from "./interactive-onboarding.js";
|
|
15
15
|
import { createLlmsTxt } from "./llms-txt.js";
|
|
16
|
+
import { auditPage, renderPageAuditMarkdown, writePageAuditOutput } from "./page-audit.js";
|
|
16
17
|
import { createQuickStartPlan, renderQuickStartMarkdown, writeQuickStartOutput } from "./quick-start.js";
|
|
17
18
|
import { renderScanMarkdown, scanProject, writeScanOutput } from "./scan.js";
|
|
18
19
|
import { createSchemaTemplate } from "./schema.js";
|
|
@@ -23,6 +24,7 @@ export const SITE_OPS_HELP_LINES = [
|
|
|
23
24
|
" geo-ai-search-optimization quick-start [--json] [--out <file>]",
|
|
24
25
|
" geo-ai-search-optimization onboard [--url <website-url>] [--goal <goal>] [--existing-assets <list>] [--json] [--out <file>]",
|
|
25
26
|
" geo-ai-search-optimization onboard-url <website-url> [--json] [--out <file>]",
|
|
27
|
+
" geo-ai-search-optimization page-audit <url-or-file> [--json] [--out <file>]",
|
|
26
28
|
" geo-ai-search-optimization init-llms [target-dir] [--site-name <name>] [--site-url <url>] [--overwrite] [--json]",
|
|
27
29
|
" geo-ai-search-optimization init-schema <type> [target-dir] [--site-url <url>] [--overwrite] [--json]",
|
|
28
30
|
" geo-ai-search-optimization audit <project-path> [--json] [--out <file>] [--max-file-size <bytes>] [--max-examples <count>]",
|
|
@@ -75,6 +77,21 @@ const handleOnboardUrl = createStructuredOutputCommandHandler({
|
|
|
75
77
|
getOutputJson: (args) => hasFlag(args, "--json")
|
|
76
78
|
});
|
|
77
79
|
|
|
80
|
+
const handlePageAudit = createStructuredOutputCommandHandler({
|
|
81
|
+
commandLabel: "单页审计",
|
|
82
|
+
execute: async (args) => {
|
|
83
|
+
const input = args.find((value) => !value.startsWith("-"));
|
|
84
|
+
if (!input) {
|
|
85
|
+
throw new Error("page-audit requires a URL or file path");
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
return auditPage(input, {});
|
|
89
|
+
},
|
|
90
|
+
renderMarkdown: (report) => `${renderPageAuditMarkdown(report)}\n`,
|
|
91
|
+
writeOutput: writePageAuditOutput,
|
|
92
|
+
getOutputJson: (args) => hasFlag(args, "--json")
|
|
93
|
+
});
|
|
94
|
+
|
|
78
95
|
const handleInitLlms = createActionCommandHandler({
|
|
79
96
|
execute: async (args) =>
|
|
80
97
|
createLlmsTxt({
|
|
@@ -153,6 +170,7 @@ export const SITE_OPS_COMMAND_HANDLERS = {
|
|
|
153
170
|
"quick-start": handleQuickStart,
|
|
154
171
|
onboard: handleInteractiveOnboard,
|
|
155
172
|
"onboard-url": handleOnboardUrl,
|
|
173
|
+
"page-audit": handlePageAudit,
|
|
156
174
|
"init-llms": handleInitLlms,
|
|
157
175
|
"init-schema": handleInitSchema,
|
|
158
176
|
audit: handleAudit,
|
package/src/index.js
CHANGED
|
@@ -34,6 +34,7 @@ export { installSkill } from "./install-skill.js";
|
|
|
34
34
|
export { runCli } from "./cli.js";
|
|
35
35
|
export { runDoctor, renderDoctorMarkdown } from "./doctor.js";
|
|
36
36
|
export { createLlmsTxt } from "./llms-txt.js";
|
|
37
|
+
export { auditPage, renderPageAuditMarkdown, writePageAuditOutput } from "./page-audit.js";
|
|
37
38
|
export { createExecSummary, renderExecSummaryMarkdown, writeExecSummaryOutput } from "./exec-summary.js";
|
|
38
39
|
export { createExportPack, renderExportPackMarkdown, writeExportPack } from "./export-pack.js";
|
|
39
40
|
export { createMeetingPack, renderMeetingPackMarkdown, writeMeetingPackOutput } from "./meeting-pack.js";
|
|
@@ -0,0 +1,464 @@
|
|
|
1
|
+
import fs from "node:fs/promises";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { SIGNAL_PATTERNS, writeScanOutput } from "./scan.js";
|
|
4
|
+
|
|
5
|
+
function isUrlInput(input) {
|
|
6
|
+
return /^https?:\/\//i.test(input);
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
function normalizeUrl(inputUrl) {
|
|
10
|
+
const normalizedInput = /^https?:\/\//i.test(inputUrl) ? inputUrl : `https://${inputUrl}`;
|
|
11
|
+
return new URL(normalizedInput);
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
async function fetchText(url) {
|
|
15
|
+
const response = await fetch(url, {
|
|
16
|
+
redirect: "follow",
|
|
17
|
+
headers: {
|
|
18
|
+
"user-agent": "geo-ai-search-optimization/1.4.0"
|
|
19
|
+
}
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
const text = await response.text();
|
|
23
|
+
return {
|
|
24
|
+
ok: response.ok,
|
|
25
|
+
status: response.status,
|
|
26
|
+
url: response.url,
|
|
27
|
+
text
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
async function readLocalFile(inputPath) {
|
|
32
|
+
const resolvedPath = path.resolve(inputPath);
|
|
33
|
+
const text = await fs.readFile(resolvedPath, "utf8");
|
|
34
|
+
return {
|
|
35
|
+
path: resolvedPath,
|
|
36
|
+
text
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function extractTitle(text) {
|
|
41
|
+
const htmlMatch = text.match(/<title[^>]*>(.*?)<\/title>/i);
|
|
42
|
+
if (htmlMatch) {
|
|
43
|
+
return htmlMatch[1].trim();
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const markdownMatch = text.match(/^#\s+(.+)$/m);
|
|
47
|
+
return markdownMatch ? markdownMatch[1].trim() : null;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function extractMetaDescription(text) {
|
|
51
|
+
const htmlMatch = text.match(/<meta[^>]+name=["']description["'][^>]+content=["']([^"']+)["']/i);
|
|
52
|
+
if (htmlMatch) {
|
|
53
|
+
return htmlMatch[1].trim();
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const markdownMatch = text.match(/^description:\s*["']?(.+?)["']?$/im);
|
|
57
|
+
return markdownMatch ? markdownMatch[1].trim() : null;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function extractCanonical(text) {
|
|
61
|
+
const htmlMatch = text.match(/<link[^>]+rel=["']canonical["'][^>]+href=["']([^"']+)["']/i);
|
|
62
|
+
if (htmlMatch) {
|
|
63
|
+
return htmlMatch[1].trim();
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const frontmatterMatch = text.match(/^canonical:\s*["']?(.+?)["']?$/im);
|
|
67
|
+
return frontmatterMatch ? frontmatterMatch[1].trim() : null;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function extractHeadings(text) {
|
|
71
|
+
const headings = [];
|
|
72
|
+
const markdownMatches = text.matchAll(/^(#{1,6})\s+(.+)$/gm);
|
|
73
|
+
for (const match of markdownMatches) {
|
|
74
|
+
headings.push({
|
|
75
|
+
level: match[1].length,
|
|
76
|
+
text: match[2].trim()
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const htmlMatches = text.matchAll(/<h([1-6])[^>]*>(.*?)<\/h\1>/gim);
|
|
81
|
+
for (const match of htmlMatches) {
|
|
82
|
+
headings.push({
|
|
83
|
+
level: Number.parseInt(match[1], 10),
|
|
84
|
+
text: match[2].replace(/<[^>]+>/g, "").trim()
|
|
85
|
+
});
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
return headings;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function extractFirstParagraph(text) {
|
|
92
|
+
const stripped = text
|
|
93
|
+
.replace(/<script[\s\S]*?<\/script>/gi, " ")
|
|
94
|
+
.replace(/<style[\s\S]*?<\/style>/gi, " ")
|
|
95
|
+
.replace(/<[^>]+>/g, " ")
|
|
96
|
+
.replace(/\r/g, "");
|
|
97
|
+
|
|
98
|
+
const paragraphs = stripped
|
|
99
|
+
.split(/\n\s*\n/)
|
|
100
|
+
.map((entry) => entry.replace(/\s+/g, " ").trim())
|
|
101
|
+
.filter(Boolean);
|
|
102
|
+
|
|
103
|
+
return paragraphs[0] || null;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function buildSignalSummary(reference, text) {
|
|
107
|
+
return Object.fromEntries(
|
|
108
|
+
Object.keys(SIGNAL_PATTERNS).map((signal) => {
|
|
109
|
+
const matched = SIGNAL_PATTERNS[signal].test(text);
|
|
110
|
+
return [
|
|
111
|
+
signal,
|
|
112
|
+
{
|
|
113
|
+
count: matched ? 1 : 0,
|
|
114
|
+
examples: matched ? [reference] : []
|
|
115
|
+
}
|
|
116
|
+
];
|
|
117
|
+
})
|
|
118
|
+
);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
const PAGE_SCORE_RULES = [
|
|
122
|
+
{
|
|
123
|
+
key: "title",
|
|
124
|
+
label: "页面有清晰 title / 主标题",
|
|
125
|
+
maxPoints: 12,
|
|
126
|
+
passed: (page) => Boolean(page.metadata.title)
|
|
127
|
+
},
|
|
128
|
+
{
|
|
129
|
+
key: "metaDescription",
|
|
130
|
+
label: "页面有摘要描述",
|
|
131
|
+
maxPoints: 8,
|
|
132
|
+
passed: (page) => Boolean(page.metadata.metaDescription)
|
|
133
|
+
},
|
|
134
|
+
{
|
|
135
|
+
key: "canonical",
|
|
136
|
+
label: "页面有 canonical",
|
|
137
|
+
maxPoints: 8,
|
|
138
|
+
passed: (page) => Boolean(page.metadata.canonical)
|
|
139
|
+
},
|
|
140
|
+
{
|
|
141
|
+
key: "jsonLd",
|
|
142
|
+
label: "页面有结构化数据",
|
|
143
|
+
maxPoints: 12,
|
|
144
|
+
passed: (page) => page.signals.json_ld.count > 0
|
|
145
|
+
},
|
|
146
|
+
{
|
|
147
|
+
key: "author",
|
|
148
|
+
label: "页面有作者 / 时间 / 审校信号",
|
|
149
|
+
maxPoints: 10,
|
|
150
|
+
passed: (page) => page.signals.author_markers.count > 0
|
|
151
|
+
},
|
|
152
|
+
{
|
|
153
|
+
key: "citations",
|
|
154
|
+
label: "页面有来源链接",
|
|
155
|
+
maxPoints: 10,
|
|
156
|
+
passed: (page) => page.signals.citations.count > 0
|
|
157
|
+
},
|
|
158
|
+
{
|
|
159
|
+
key: "qaHeadings",
|
|
160
|
+
label: "页面结构适合被提取答案",
|
|
161
|
+
maxPoints: 12,
|
|
162
|
+
passed: (page) => page.signals.qa_headings.count > 0 || page.headingStats.questionHeadingCount > 0
|
|
163
|
+
},
|
|
164
|
+
{
|
|
165
|
+
key: "comparison",
|
|
166
|
+
label: "页面覆盖比较 / 决策信息",
|
|
167
|
+
maxPoints: 8,
|
|
168
|
+
passed: (page) => page.signals.comparison_intent.count > 0
|
|
169
|
+
},
|
|
170
|
+
{
|
|
171
|
+
key: "evidence",
|
|
172
|
+
label: "页面有第一方证据或方法论",
|
|
173
|
+
maxPoints: 10,
|
|
174
|
+
passed: (page) => page.signals.original_research.count > 0
|
|
175
|
+
},
|
|
176
|
+
{
|
|
177
|
+
key: "directAnswer",
|
|
178
|
+
label: "页面开头可快速提取直接答案",
|
|
179
|
+
maxPoints: 10,
|
|
180
|
+
passed: (page) => Boolean(page.directAnswerCandidate && page.directAnswerCandidate.length >= 50)
|
|
181
|
+
}
|
|
182
|
+
];
|
|
183
|
+
|
|
184
|
+
function evaluatePageScore(page) {
|
|
185
|
+
const checks = PAGE_SCORE_RULES.map((rule) => {
|
|
186
|
+
const passed = rule.passed(page);
|
|
187
|
+
return {
|
|
188
|
+
key: rule.key,
|
|
189
|
+
label: rule.label,
|
|
190
|
+
passed,
|
|
191
|
+
maxPoints: rule.maxPoints,
|
|
192
|
+
pointsAwarded: passed ? rule.maxPoints : 0
|
|
193
|
+
};
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
const score = checks.reduce((sum, check) => sum + check.pointsAwarded, 0);
|
|
197
|
+
return {
|
|
198
|
+
score,
|
|
199
|
+
maxScore: 100,
|
|
200
|
+
checks
|
|
201
|
+
};
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
function getScoreLabel(score) {
|
|
205
|
+
if (score >= 80) {
|
|
206
|
+
return "稳固";
|
|
207
|
+
}
|
|
208
|
+
if (score >= 60) {
|
|
209
|
+
return "可优化";
|
|
210
|
+
}
|
|
211
|
+
if (score >= 40) {
|
|
212
|
+
return "偏弱";
|
|
213
|
+
}
|
|
214
|
+
return "需要重做";
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
function buildProblemAreas(page) {
|
|
218
|
+
const areas = [];
|
|
219
|
+
|
|
220
|
+
if (!page.metadata.title || !page.metadata.metaDescription || !page.metadata.canonical) {
|
|
221
|
+
areas.push({
|
|
222
|
+
area: "页面元信息",
|
|
223
|
+
severity: !page.metadata.title ? "高" : "中",
|
|
224
|
+
issues: [
|
|
225
|
+
!page.metadata.title ? "缺少 title / 主标题" : null,
|
|
226
|
+
!page.metadata.metaDescription ? "缺少摘要描述" : null,
|
|
227
|
+
!page.metadata.canonical ? "缺少 canonical" : null
|
|
228
|
+
].filter(Boolean),
|
|
229
|
+
whyItMatters: "模型和搜索系统需要先理解这页是什么、应该归到哪个 canonical 实体上。"
|
|
230
|
+
});
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
if (page.signals.json_ld.count === 0 || page.signals.author_markers.count === 0 || page.signals.citations.count === 0) {
|
|
234
|
+
areas.push({
|
|
235
|
+
area: "可信度与引用",
|
|
236
|
+
severity: page.signals.citations.count === 0 ? "高" : "中",
|
|
237
|
+
issues: [
|
|
238
|
+
page.signals.json_ld.count === 0 ? "缺少结构化数据" : null,
|
|
239
|
+
page.signals.author_markers.count === 0 ? "缺少作者 / 更新时间 / 审校信号" : null,
|
|
240
|
+
page.signals.citations.count === 0 ? "缺少来源链接" : null
|
|
241
|
+
].filter(Boolean),
|
|
242
|
+
whyItMatters: "如果页面没有可信来源、作者和结构化实体,AI 搜索更难放心引用。"
|
|
243
|
+
});
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
if (page.headingStats.questionHeadingCount === 0 && page.signals.qa_headings.count === 0) {
|
|
247
|
+
areas.push({
|
|
248
|
+
area: "答案可提取性",
|
|
249
|
+
severity: "高",
|
|
250
|
+
issues: ["缺少问答式或决策式标题", "页面结构不够 answer-first"],
|
|
251
|
+
whyItMatters: "模型需要在很短上下文里抓到直接答案、适用对象和限制条件。"
|
|
252
|
+
});
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
if (page.signals.original_research.count === 0 && page.signals.comparison_intent.count === 0) {
|
|
256
|
+
areas.push({
|
|
257
|
+
area: "差异化内容",
|
|
258
|
+
severity: "中",
|
|
259
|
+
issues: ["缺少方法论 / 第一方证据", "缺少比较与决策内容"],
|
|
260
|
+
whyItMatters: "如果页面没有独特证据和决策信息,就很难在生成式结果里占据引用位置。"
|
|
261
|
+
});
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
return areas;
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
function buildRecommendedBlocks(page) {
|
|
268
|
+
const blocks = [];
|
|
269
|
+
if (!page.directAnswerCandidate || page.directAnswerCandidate.length < 50) {
|
|
270
|
+
blocks.push("在首屏补一个直接答案摘要块,先回答“这是什么 / 适合谁 / 主要限制是什么”。");
|
|
271
|
+
}
|
|
272
|
+
if (page.headingStats.questionHeadingCount === 0) {
|
|
273
|
+
blocks.push("补 2 到 4 个问答式 H2,例如“适合谁”“什么时候不适合”“和替代方案差别是什么”。");
|
|
274
|
+
}
|
|
275
|
+
if (page.signals.citations.count === 0) {
|
|
276
|
+
blocks.push("增加来源链接或证据区块,给关键事实、数据和判断补出处。");
|
|
277
|
+
}
|
|
278
|
+
if (page.signals.original_research.count === 0) {
|
|
279
|
+
blocks.push("补第一方证据区块,例如方法论、流程、案例、截图、基准或内部数据。");
|
|
280
|
+
}
|
|
281
|
+
if (page.signals.comparison_intent.count === 0) {
|
|
282
|
+
blocks.push("增加 comparison / alternatives / trade-offs 模块,帮助模型提取决策信息。");
|
|
283
|
+
}
|
|
284
|
+
if (page.signals.json_ld.count === 0) {
|
|
285
|
+
blocks.push("补与页面可见内容一致的 JSON-LD。");
|
|
286
|
+
}
|
|
287
|
+
return blocks;
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
function buildRewriteBrief(page, problems, recommendedBlocks) {
|
|
291
|
+
return {
|
|
292
|
+
objective: "把当前页面改成更容易被生成式搜索引擎理解、提取、引用的 answer-first 页面。",
|
|
293
|
+
audience: "负责该页面的 PM、内容负责人、SEO 或 agent。",
|
|
294
|
+
keep: [
|
|
295
|
+
page.metadata.title ? `保留核心主题:${page.metadata.title}` : "保留当前页面的核心主题,不要改成另一个意图。",
|
|
296
|
+
"保留事实准确性,不用为了 SEO 或 GEO 添加页面没有证据支撑的说法。"
|
|
297
|
+
],
|
|
298
|
+
change: problems.flatMap((problem) => problem.issues).slice(0, 6),
|
|
299
|
+
addSections: recommendedBlocks,
|
|
300
|
+
openingPattern: "用 2 到 4 句先给直接答案,再说明适用对象、关键限制与下一步。",
|
|
301
|
+
evidencePattern: "给结论补来源链接、案例、方法论、数据或截图说明。",
|
|
302
|
+
schemaHint:
|
|
303
|
+
page.signals.json_ld.count > 0
|
|
304
|
+
? "保留现有 schema,但确认与页面可见内容一致。"
|
|
305
|
+
: "优先考虑 Article / FAQPage / Product / Organization 中最贴近页面真实内容的 schema。"
|
|
306
|
+
};
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
function buildAgentPrompt(page, rewriteBrief) {
|
|
310
|
+
return [
|
|
311
|
+
"请先把这页当成单页 GEO 修复任务处理。",
|
|
312
|
+
`当前页面:${page.reference}`,
|
|
313
|
+
`当前分数:${page.score.score}/${page.score.maxScore}(${getScoreLabel(page.score.score)})`,
|
|
314
|
+
`优先改动:${rewriteBrief.change.slice(0, 4).join(";") || "补直接答案、证据与结构化信息"}`,
|
|
315
|
+
"先重写页面 opening,再补问答式标题、来源链接、证据区块和最合适的 schema。",
|
|
316
|
+
"如果是代码仓库,请优先找模板、metadata helper、schema 生成点,而不是只改单一页面文案。",
|
|
317
|
+
"完成后重新跑 page-audit,确认分数和问题区域是否改善。"
|
|
318
|
+
].join(" ");
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
function buildSummary(page, problems) {
|
|
322
|
+
if (page.score.score >= 80) {
|
|
323
|
+
return "这页已经具备较好的 GEO 基础,下一步重点是增强差异化证据与可引用素材。";
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
if (problems.length === 0) {
|
|
327
|
+
return "这页没有明显结构性缺口,可以继续加强引用资产和第一方证据。";
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
return `这页当前最需要优先处理的是${problems[0].area},否则模型很难稳定理解并引用页面内容。`;
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
export async function auditPage(input, options = {}) {
|
|
334
|
+
const sourceType = isUrlInput(input) ? "url" : "file";
|
|
335
|
+
let source;
|
|
336
|
+
|
|
337
|
+
if (sourceType === "url") {
|
|
338
|
+
const url = normalizeUrl(input);
|
|
339
|
+
const result = await fetchText(url);
|
|
340
|
+
if (!result.ok) {
|
|
341
|
+
throw new Error(`抓取失败:${url.toString()}(状态码 ${result.status})`);
|
|
342
|
+
}
|
|
343
|
+
source = {
|
|
344
|
+
reference: result.url,
|
|
345
|
+
content: result.text,
|
|
346
|
+
fetchStatus: result.status
|
|
347
|
+
};
|
|
348
|
+
} else {
|
|
349
|
+
const result = await readLocalFile(input);
|
|
350
|
+
source = {
|
|
351
|
+
reference: result.path,
|
|
352
|
+
content: result.text,
|
|
353
|
+
fetchStatus: null
|
|
354
|
+
};
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
const headings = extractHeadings(source.content);
|
|
358
|
+
const page = {
|
|
359
|
+
kind: "geo-page-audit",
|
|
360
|
+
input,
|
|
361
|
+
sourceType,
|
|
362
|
+
reference: source.reference,
|
|
363
|
+
fetchStatus: source.fetchStatus,
|
|
364
|
+
metadata: {
|
|
365
|
+
title: extractTitle(source.content),
|
|
366
|
+
metaDescription: extractMetaDescription(source.content),
|
|
367
|
+
canonical: extractCanonical(source.content)
|
|
368
|
+
},
|
|
369
|
+
directAnswerCandidate: extractFirstParagraph(source.content),
|
|
370
|
+
headingStats: {
|
|
371
|
+
totalHeadingCount: headings.length,
|
|
372
|
+
questionHeadingCount: headings.filter((heading) => heading.text.includes("?") || heading.text.includes("?")).length
|
|
373
|
+
},
|
|
374
|
+
headings: headings.slice(0, options.maxHeadings || 12),
|
|
375
|
+
signals: buildSignalSummary(source.reference, source.content)
|
|
376
|
+
};
|
|
377
|
+
|
|
378
|
+
page.score = evaluatePageScore(page);
|
|
379
|
+
page.scoreLabel = getScoreLabel(page.score.score);
|
|
380
|
+
page.problemAreas = buildProblemAreas(page);
|
|
381
|
+
page.recommendedBlocks = buildRecommendedBlocks(page);
|
|
382
|
+
page.rewriteBrief = buildRewriteBrief(page, page.problemAreas, page.recommendedBlocks);
|
|
383
|
+
page.summary = buildSummary(page, page.problemAreas);
|
|
384
|
+
page.agentPrompt = buildAgentPrompt(page, page.rewriteBrief);
|
|
385
|
+
|
|
386
|
+
return page;
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
export function renderPageAuditMarkdown(report) {
|
|
390
|
+
const lines = [
|
|
391
|
+
"# GEO 单页审计",
|
|
392
|
+
"",
|
|
393
|
+
`- 输入:\`${report.input}\``,
|
|
394
|
+
`- 来源类型:\`${report.sourceType}\``,
|
|
395
|
+
`- 分析对象:\`${report.reference}\``,
|
|
396
|
+
`- GEO 单页分数:\`${report.score.score}/${report.score.maxScore}\`(${report.scoreLabel})`,
|
|
397
|
+
`- 总结:${report.summary}`,
|
|
398
|
+
""
|
|
399
|
+
];
|
|
400
|
+
|
|
401
|
+
if (report.fetchStatus !== null) {
|
|
402
|
+
lines.push(`- 抓取状态:\`${report.fetchStatus}\``);
|
|
403
|
+
lines.push("");
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
lines.push("## 页面快照", "");
|
|
407
|
+
lines.push(`- title:\`${report.metadata.title ?? "缺失"}\``);
|
|
408
|
+
lines.push(`- meta description:\`${report.metadata.metaDescription ?? "缺失"}\``);
|
|
409
|
+
lines.push(`- canonical:\`${report.metadata.canonical ?? "缺失"}\``);
|
|
410
|
+
lines.push(`- 首段直接答案候选:${report.directAnswerCandidate ? report.directAnswerCandidate.slice(0, 180) : "缺失"}`);
|
|
411
|
+
|
|
412
|
+
lines.push("", "## 问题区域", "");
|
|
413
|
+
if (report.problemAreas.length === 0) {
|
|
414
|
+
lines.push("- 当前没有明显的页面级高风险区域。");
|
|
415
|
+
} else {
|
|
416
|
+
for (const area of report.problemAreas) {
|
|
417
|
+
lines.push(`- ${area.area}|${area.severity}|${area.issues.join("、")}`);
|
|
418
|
+
lines.push(` ${area.whyItMatters}`);
|
|
419
|
+
}
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
lines.push("", "## 推荐新增模块", "");
|
|
423
|
+
for (const item of report.recommendedBlocks) {
|
|
424
|
+
lines.push(`- ${item}`);
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
lines.push("", "## Rewrite Brief", "");
|
|
428
|
+
lines.push(`- 目标:${report.rewriteBrief.objective}`);
|
|
429
|
+
lines.push(`- Opening pattern:${report.rewriteBrief.openingPattern}`);
|
|
430
|
+
lines.push(`- Evidence pattern:${report.rewriteBrief.evidencePattern}`);
|
|
431
|
+
lines.push(`- Schema hint:${report.rewriteBrief.schemaHint}`);
|
|
432
|
+
|
|
433
|
+
lines.push("", "## 建议保留", "");
|
|
434
|
+
for (const item of report.rewriteBrief.keep) {
|
|
435
|
+
lines.push(`- ${item}`);
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
lines.push("", "## 建议调整", "");
|
|
439
|
+
for (const item of report.rewriteBrief.change) {
|
|
440
|
+
lines.push(`- ${item}`);
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
lines.push("", "## 建议新增段落", "");
|
|
444
|
+
for (const item of report.rewriteBrief.addSections) {
|
|
445
|
+
lines.push(`- ${item}`);
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
lines.push("", "## 页面标题快照", "");
|
|
449
|
+
for (const heading of report.headings) {
|
|
450
|
+
lines.push(`- H${heading.level}:${heading.text}`);
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
lines.push("", "## 评分明细", "");
|
|
454
|
+
for (const check of report.score.checks) {
|
|
455
|
+
lines.push(`- ${check.label}:\`${check.passed}\`(${check.pointsAwarded}/${check.maxPoints})`);
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
lines.push("", "## Agent Prompt", "", report.agentPrompt, "");
|
|
459
|
+
return lines.join("\n");
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
export async function writePageAuditOutput(outputPath, content) {
|
|
463
|
+
return writeScanOutput(outputPath, content);
|
|
464
|
+
}
|