geo-ai-search-optimization 1.4.2 → 2.2.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.
Files changed (45) hide show
  1. package/action.yml +130 -0
  2. package/examples/github-action.yml +27 -0
  3. package/package.json +17 -3
  4. package/src/audit-diff.js +184 -0
  5. package/src/auto-fix.js +349 -0
  6. package/src/batch-full-page-audit.js +151 -0
  7. package/src/batch-page-audit.js +140 -0
  8. package/src/benchmark.js +126 -0
  9. package/src/ci.js +81 -0
  10. package/src/citability.js +311 -0
  11. package/src/citation-check.js +157 -0
  12. package/src/citation-monitor.js +86 -0
  13. package/src/cli-site-ops-commands.js +638 -4
  14. package/src/compare.js +175 -0
  15. package/src/config.js +105 -0
  16. package/src/content-gap.js +170 -0
  17. package/src/crawlers.js +286 -0
  18. package/src/diagnose.js +221 -0
  19. package/src/eeat.js +251 -0
  20. package/src/freshness.js +281 -0
  21. package/src/full-audit.js +269 -0
  22. package/src/full-page-audit.js +273 -0
  23. package/src/heading-structure.js +287 -0
  24. package/src/index.d.ts +492 -0
  25. package/src/index.js +35 -0
  26. package/src/internal-links.js +298 -0
  27. package/src/page-audit.js +1 -1
  28. package/src/page-snapshot.js +198 -0
  29. package/src/pdf-report.js +205 -0
  30. package/src/platform-ready.js +238 -0
  31. package/src/plugins.js +126 -0
  32. package/src/pm-brief.js +8 -0
  33. package/src/pre-commit.js +62 -0
  34. package/src/readability.js +252 -0
  35. package/src/report.js +37 -12
  36. package/src/security.js +249 -0
  37. package/src/sitemap.js +323 -0
  38. package/src/snapshot.js +51 -0
  39. package/src/social-meta.js +293 -0
  40. package/src/topics.js +275 -0
  41. package/src/trend.js +102 -0
  42. package/src/url-onboarding.js +1 -1
  43. package/src/validate-llms.js +307 -0
  44. package/src/validate-schema.js +306 -0
  45. package/src/watch.js +49 -0
package/src/compare.js ADDED
@@ -0,0 +1,175 @@
1
+ import { writeScanOutput } from "./scan.js";
2
+ import { fullPageAudit } from "./full-page-audit.js";
3
+
4
+ /**
5
+ * Compare two pages across all 12 dimensions.
6
+ * Produces a side-by-side analysis with delta scoring.
7
+ */
8
+
9
+ export async function comparePages(inputA, inputB, options = {}) {
10
+ const [resultA, resultB] = await Promise.all([
11
+ fullPageAudit(inputA, options),
12
+ fullPageAudit(inputB, options)
13
+ ]);
14
+
15
+ const dimensions = {};
16
+ const allDimKeys = new Set([
17
+ ...Object.keys(resultA.dimensions),
18
+ ...Object.keys(resultB.dimensions)
19
+ ]);
20
+
21
+ let aWins = 0;
22
+ let bWins = 0;
23
+ let ties = 0;
24
+
25
+ for (const key of allDimKeys) {
26
+ const scoreA = resultA.dimensions[key]?.score ?? 0;
27
+ const scoreB = resultB.dimensions[key]?.score ?? 0;
28
+ const delta = scoreA - scoreB;
29
+ const winner = delta > 0 ? "A" : delta < 0 ? "B" : "tie";
30
+
31
+ if (winner === "A") aWins++;
32
+ else if (winner === "B") bWins++;
33
+ else ties++;
34
+
35
+ dimensions[key] = {
36
+ scoreA,
37
+ scoreB,
38
+ delta,
39
+ winner,
40
+ labelA: resultA.dimensions[key]?.label ?? "",
41
+ labelB: resultB.dimensions[key]?.label ?? ""
42
+ };
43
+ }
44
+
45
+ const compositeA = resultA.compositeScore;
46
+ const compositeB = resultB.compositeScore;
47
+ const compositeDelta = compositeA - compositeB;
48
+
49
+ // Identify where each page is stronger
50
+ const aStrengths = Object.entries(dimensions)
51
+ .filter(([, d]) => d.delta > 10)
52
+ .sort((a, b) => b[1].delta - a[1].delta)
53
+ .map(([key, d]) => ({ dimension: key, delta: d.delta }));
54
+
55
+ const bStrengths = Object.entries(dimensions)
56
+ .filter(([, d]) => d.delta < -10)
57
+ .sort((a, b) => a[1].delta - b[1].delta)
58
+ .map(([key, d]) => ({ dimension: key, delta: Math.abs(d.delta) }));
59
+
60
+ // Platform comparison
61
+ const platformComparison = [];
62
+ if (resultA.platformSummary && resultB.platformSummary) {
63
+ for (let i = 0; i < resultA.platformSummary.length; i++) {
64
+ const pA = resultA.platformSummary[i];
65
+ const pB = resultB.platformSummary[i];
66
+ if (pA && pB) {
67
+ platformComparison.push({
68
+ platform: pA.platform,
69
+ scoreA: pA.score,
70
+ scoreB: pB.score,
71
+ delta: pA.score - pB.score,
72
+ readinessA: pA.readiness,
73
+ readinessB: pB.readiness
74
+ });
75
+ }
76
+ }
77
+ }
78
+
79
+ const overallWinner = compositeDelta > 5 ? "A" : compositeDelta < -5 ? "B" : "tie";
80
+
81
+ return {
82
+ kind: "geo-compare",
83
+ inputA,
84
+ inputB,
85
+ compositeA,
86
+ compositeB,
87
+ compositeDelta,
88
+ overallWinner,
89
+ dimensionWins: { A: aWins, B: bWins, ties },
90
+ dimensions,
91
+ aStrengths,
92
+ bStrengths,
93
+ platformComparison,
94
+ summary: overallWinner === "tie"
95
+ ? `Near parity. A: ${compositeA}/100, B: ${compositeB}/100 (Δ${compositeDelta >= 0 ? "+" : ""}${compositeDelta}).`
96
+ : `${overallWinner === "A" ? "Page A" : "Page B"} leads. A: ${compositeA}/100, B: ${compositeB}/100 (Δ${compositeDelta >= 0 ? "+" : ""}${compositeDelta}). Dimension wins: A=${aWins}, B=${bWins}, Ties=${ties}.`
97
+ };
98
+ }
99
+
100
+ export function renderCompareMarkdown(report) {
101
+ const lines = [
102
+ "# GEO Page Comparison",
103
+ "",
104
+ `- **Page A**: \`${report.inputA}\``,
105
+ `- **Page B**: \`${report.inputB}\``,
106
+ `- Summary: ${report.summary}`,
107
+ "",
108
+ "## Composite Scores",
109
+ "",
110
+ `| | Page A | Page B | Delta |`,
111
+ `|--|--------|--------|-------|`,
112
+ `| **Composite** | **${report.compositeA}** | **${report.compositeB}** | ${report.compositeDelta >= 0 ? "+" : ""}${report.compositeDelta} |`,
113
+ ""
114
+ ];
115
+
116
+ const dimLabels = {
117
+ base: "Base", citability: "Citability", eeat: "E-E-A-T", readability: "Readability",
118
+ headingStructure: "Headings", internalLinks: "Links", socialMeta: "Social",
119
+ platformReady: "Platforms", schema: "Schema", freshness: "Freshness",
120
+ security: "Security", topics: "Topics"
121
+ };
122
+
123
+ lines.push(
124
+ "## Dimension Comparison",
125
+ "",
126
+ "| Dimension | Page A | Page B | Δ | Winner |",
127
+ "|-----------|--------|--------|---|--------|"
128
+ );
129
+
130
+ const sorted = Object.entries(report.dimensions)
131
+ .sort((a, b) => Math.abs(b[1].delta) - Math.abs(a[1].delta));
132
+
133
+ for (const [key, d] of sorted) {
134
+ const winIcon = d.winner === "A" ? "◀️ A" : d.winner === "B" ? "B ▶️" : "—";
135
+ lines.push(`| ${dimLabels[key] || key} | ${d.scoreA} | ${d.scoreB} | ${d.delta >= 0 ? "+" : ""}${d.delta} | ${winIcon} |`);
136
+ }
137
+ lines.push("");
138
+
139
+ if (report.aStrengths.length > 0) {
140
+ lines.push("## Page A Strengths", "");
141
+ for (const s of report.aStrengths) {
142
+ lines.push(`- **${dimLabels[s.dimension] || s.dimension}**: +${s.delta} points ahead`);
143
+ }
144
+ lines.push("");
145
+ }
146
+
147
+ if (report.bStrengths.length > 0) {
148
+ lines.push("## Page B Strengths", "");
149
+ for (const s of report.bStrengths) {
150
+ lines.push(`- **${dimLabels[s.dimension] || s.dimension}**: +${s.delta} points ahead`);
151
+ }
152
+ lines.push("");
153
+ }
154
+
155
+ if (report.platformComparison.length > 0) {
156
+ lines.push(
157
+ "## Platform Readiness Comparison",
158
+ "",
159
+ "| Platform | A Score | B Score | Δ | A Status | B Status |",
160
+ "|----------|---------|---------|---|----------|----------|"
161
+ );
162
+ for (const p of report.platformComparison) {
163
+ lines.push(`| ${p.platform} | ${p.scoreA} | ${p.scoreB} | ${p.delta >= 0 ? "+" : ""}${p.delta} | ${p.readinessA} | ${p.readinessB} |`);
164
+ }
165
+ lines.push("");
166
+ }
167
+
168
+ lines.push(`## Verdict`, "", `**Dimension wins**: A=${report.dimensionWins.A}, B=${report.dimensionWins.B}, Ties=${report.dimensionWins.ties}`, "");
169
+
170
+ return lines.join("\n");
171
+ }
172
+
173
+ export async function writeCompareOutput(outputPath, content) {
174
+ return writeScanOutput(outputPath, content);
175
+ }
package/src/config.js ADDED
@@ -0,0 +1,105 @@
1
+ import fs from "node:fs/promises";
2
+ import path from "node:path";
3
+
4
+ const CONFIG_FILE_NAMES = [".georc.json", "geo.config.json"];
5
+
6
+ async function findConfigFile(startDir) {
7
+ let dir = path.resolve(startDir || ".");
8
+
9
+ for (let i = 0; i < 10; i++) {
10
+ for (const name of CONFIG_FILE_NAMES) {
11
+ const candidate = path.join(dir, name);
12
+ try {
13
+ await fs.access(candidate);
14
+ return candidate;
15
+ } catch {
16
+ // continue
17
+ }
18
+ }
19
+
20
+ const parent = path.dirname(dir);
21
+ if (parent === dir) break;
22
+ dir = parent;
23
+ }
24
+
25
+ return null;
26
+ }
27
+
28
+ export async function loadConfig(options = {}) {
29
+ const configPath = options.configPath || await findConfigFile(options.startDir || ".");
30
+
31
+ if (!configPath) {
32
+ return { _source: null, _found: false };
33
+ }
34
+
35
+ try {
36
+ const content = await fs.readFile(configPath, "utf8");
37
+ const config = JSON.parse(content);
38
+ return { ...config, _source: configPath, _found: true };
39
+ } catch (err) {
40
+ throw new Error(`Failed to read config at ${configPath}: ${err.message}`);
41
+ }
42
+ }
43
+
44
+ export async function initConfig(options = {}) {
45
+ const targetDir = path.resolve(options.targetDir || ".");
46
+ const outputPath = path.join(targetDir, ".georc.json");
47
+
48
+ try {
49
+ await fs.access(outputPath);
50
+ if (!options.overwrite) {
51
+ throw new Error(`Config already exists at ${outputPath}. Use --overwrite to replace.`);
52
+ }
53
+ } catch (err) {
54
+ if (err.message.includes("already exists")) throw err;
55
+ }
56
+
57
+ const config = {
58
+ $schema: "https://geo-ai-search-optimization.dev/schema/georc.json",
59
+ site: {
60
+ name: options.siteName || null,
61
+ url: options.siteUrl || null
62
+ },
63
+ audit: {
64
+ minScore: options.minScore || 40,
65
+ maxFileSize: 1_000_000,
66
+ maxExamples: 5
67
+ },
68
+ ci: {
69
+ minScore: options.minScore || 40,
70
+ failOnRegression: false,
71
+ baselineDir: ".geo-data"
72
+ },
73
+ crawlers: {
74
+ strategy: "open"
75
+ },
76
+ output: {
77
+ format: "markdown",
78
+ dataDir: ".geo-data"
79
+ },
80
+ plugins: []
81
+ };
82
+
83
+ await fs.mkdir(targetDir, { recursive: true });
84
+ await fs.writeFile(outputPath, `${JSON.stringify(config, null, 2)}\n`, "utf8");
85
+
86
+ return { outputPath, config };
87
+ }
88
+
89
+ export function mergeConfigWithOptions(config, cliOptions) {
90
+ const merged = { ...cliOptions };
91
+
92
+ if (config._found) {
93
+ if (config.site?.url && !merged.siteUrl) merged.siteUrl = config.site.url;
94
+ if (config.site?.name && !merged.siteName) merged.siteName = config.site.name;
95
+ if (config.audit?.minScore !== undefined && merged.minScore === undefined) merged.minScore = config.audit.minScore;
96
+ if (config.audit?.maxFileSize !== undefined && merged.maxFileSize === undefined) merged.maxFileSize = config.audit.maxFileSize;
97
+ if (config.audit?.maxExamples !== undefined && merged.maxExamples === undefined) merged.maxExamples = config.audit.maxExamples;
98
+ if (config.ci?.failOnRegression && merged.failOnRegression === undefined) merged.failOnRegression = config.ci.failOnRegression;
99
+ if (config.output?.format && !merged.format) merged.format = config.output.format;
100
+ if (config.output?.dataDir && !merged.dataDir) merged.dataDir = config.output.dataDir;
101
+ if (config.crawlers?.strategy && !merged.strategy) merged.strategy = config.crawlers.strategy;
102
+ }
103
+
104
+ return merged;
105
+ }
@@ -0,0 +1,170 @@
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
+ const SIGNAL_LABELS = {
7
+ title: "标题",
8
+ meta_description: "Meta Description",
9
+ canonical: "Canonical",
10
+ json_ld: "结构化数据 (JSON-LD)",
11
+ faq_schema: "FAQ Schema",
12
+ howto_schema: "HowTo Schema",
13
+ article_schema: "Article Schema",
14
+ organization_schema: "Organization Schema",
15
+ product_schema: "Product Schema",
16
+ breadcrumb_schema: "Breadcrumb Schema",
17
+ author_markers: "作者 / 发布时间",
18
+ qa_headings: "问答式标题",
19
+ comparison_intent: "比较 / 替代内容",
20
+ original_research: "原创研究 / 方法论",
21
+ citations: "引用 / 来源链接"
22
+ };
23
+
24
+ const SIGNAL_PRIORITIES = {
25
+ json_ld: 1,
26
+ qa_headings: 1,
27
+ author_markers: 2,
28
+ citations: 2,
29
+ original_research: 2,
30
+ comparison_intent: 3,
31
+ faq_schema: 3,
32
+ article_schema: 3,
33
+ canonical: 4,
34
+ meta_description: 4,
35
+ title: 4,
36
+ breadcrumb_schema: 5,
37
+ howto_schema: 5,
38
+ organization_schema: 5,
39
+ product_schema: 5
40
+ };
41
+
42
+ const CONTENT_SUGGESTIONS = {
43
+ json_ld: "在页面中加入与内容一致的 JSON-LD 结构化数据。",
44
+ qa_headings: "增加问答式 H2/H3 标题,覆盖用户常问的问题。",
45
+ author_markers: "补上作者、审校者、发布时间或更新时间。",
46
+ citations: "为关键结论和数据补上来源链接。",
47
+ original_research: "创建包含第一方数据、方法论或 benchmark 的内容。",
48
+ comparison_intent: "新增 comparison / vs / alternatives 内容页面。",
49
+ faq_schema: "实现 FAQ Schema 标记。",
50
+ article_schema: "实现 Article Schema 标记。",
51
+ canonical: "确保页面有正确的 canonical 标签。",
52
+ meta_description: "补齐 meta description。",
53
+ breadcrumb_schema: "增加 breadcrumb schema。"
54
+ };
55
+
56
+ async function loadBenchmarkJson(filePath) {
57
+ const raw = await fs.readFile(path.resolve(filePath), "utf8");
58
+ const data = JSON.parse(raw);
59
+ if (data.kind === "geo-benchmark") return data;
60
+ throw new Error("提供的 JSON 不是有效的 benchmark 结果");
61
+ }
62
+
63
+ export async function analyzeContentGap(ownUrl, competitorUrls, options = {}) {
64
+ let ownAudit;
65
+ let competitorAudits;
66
+
67
+ if (options.benchmarkJson) {
68
+ const benchmark = await loadBenchmarkJson(options.benchmarkJson);
69
+ const ownPageResult = benchmark.pageResults.find(
70
+ (r) => r.input === benchmark.ownUrl || r.reference === benchmark.ownUrl
71
+ );
72
+ if (!ownPageResult) throw new Error("benchmark JSON 中找不到自己的审计结果");
73
+ ownAudit = ownPageResult;
74
+ competitorAudits = benchmark.pageResults.filter((r) => r !== ownPageResult);
75
+ } else {
76
+ ownAudit = await auditPage(ownUrl, {});
77
+ competitorAudits = [];
78
+ for (const url of competitorUrls) {
79
+ try {
80
+ competitorAudits.push(await auditPage(url, {}));
81
+ } catch {
82
+ // skip failed
83
+ }
84
+ }
85
+ }
86
+
87
+ // Find gaps: signals competitors have that we don't
88
+ const gaps = [];
89
+ const allSignals = Object.keys(ownAudit.signals);
90
+
91
+ for (const signal of allSignals) {
92
+ const ownHas = ownAudit.signals[signal]?.count > 0;
93
+ if (ownHas) continue;
94
+
95
+ const competitorsWithSignal = competitorAudits.filter(
96
+ (audit) => audit.signals[signal]?.count > 0
97
+ );
98
+
99
+ if (competitorsWithSignal.length > 0) {
100
+ gaps.push({
101
+ signal,
102
+ label: SIGNAL_LABELS[signal] || signal,
103
+ priority: SIGNAL_PRIORITIES[signal] || 5,
104
+ competitorCount: competitorsWithSignal.length,
105
+ totalCompetitors: competitorAudits.length,
106
+ suggestion: CONTENT_SUGGESTIONS[signal] || `补齐 ${signal} 信号。`
107
+ });
108
+ }
109
+ }
110
+
111
+ // Sort by priority (lower = more important)
112
+ gaps.sort((a, b) => a.priority - b.priority);
113
+
114
+ // Build content backlog
115
+ const backlog = gaps.map((gap, index) => ({
116
+ rank: index + 1,
117
+ signal: gap.signal,
118
+ label: gap.label,
119
+ action: gap.suggestion,
120
+ urgency: gap.priority <= 2 ? "高" : gap.priority <= 3 ? "中" : "低",
121
+ competitorCoverage: `${gap.competitorCount}/${gap.totalCompetitors}`
122
+ }));
123
+
124
+ return {
125
+ kind: "geo-content-gap",
126
+ ownUrl: ownUrl || ownAudit.input,
127
+ ownScore: ownAudit.score.score,
128
+ competitorCount: competitorAudits.length,
129
+ gapCount: gaps.length,
130
+ gaps,
131
+ backlog,
132
+ summary: gaps.length === 0
133
+ ? "你的页面信号覆盖已经与竞品持平或领先,当前没有明显缺口。"
134
+ : `发现 ${gaps.length} 个信号缺口,最高优先级:${gaps[0].label}。`
135
+ };
136
+ }
137
+
138
+ export function renderContentGapMarkdown(report) {
139
+ const lines = [
140
+ "# GEO 内容缺口分析",
141
+ "",
142
+ `- 你的 URL:\`${report.ownUrl}\``,
143
+ `- 你的分数:\`${report.ownScore}/100\``,
144
+ `- 竞品数量:\`${report.competitorCount}\``,
145
+ `- 缺口数量:\`${report.gapCount}\``,
146
+ `- 总结:${report.summary}`,
147
+ ""
148
+ ];
149
+
150
+ if (report.gaps.length > 0) {
151
+ lines.push("## 缺口矩阵", "", "| 信号 | 竞品覆盖 | 紧急度 | 建议 |", "|------|----------|--------|------|");
152
+ for (const gap of report.gaps) {
153
+ lines.push(`| ${gap.label} | ${gap.competitorCount}/${gap.totalCompetitors} | P${gap.priority} | ${gap.suggestion} |`);
154
+ }
155
+ }
156
+
157
+ if (report.backlog.length > 0) {
158
+ lines.push("", "## 内容 Backlog", "");
159
+ for (const item of report.backlog) {
160
+ lines.push(`${item.rank}. **${item.label}**(${item.urgency})— ${item.action}`);
161
+ }
162
+ }
163
+
164
+ lines.push("");
165
+ return lines.join("\n");
166
+ }
167
+
168
+ export async function writeContentGapOutput(outputPath, content) {
169
+ return writeScanOutput(outputPath, content);
170
+ }