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
@@ -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
+ }
@@ -0,0 +1,311 @@
1
+ import fs from "node:fs/promises";
2
+ import path from "node:path";
3
+ import { writeScanOutput } from "./scan.js";
4
+
5
+ function stripHtml(text) {
6
+ return text
7
+ .replace(/<script[\s\S]*?<\/script>/gi, " ")
8
+ .replace(/<style[\s\S]*?<\/style>/gi, " ")
9
+ .replace(/<[^>]+>/g, " ")
10
+ .replace(/&[a-z0-9#]+;/gi, " ")
11
+ .replace(/\s+/g, " ")
12
+ .trim();
13
+ }
14
+
15
+ function extractPlainText(content) {
16
+ if (/<html|<head|<body/i.test(content)) {
17
+ return stripHtml(content);
18
+ }
19
+ return content.replace(/^---[\s\S]*?---/, "").trim();
20
+ }
21
+
22
+ function splitSentences(text) {
23
+ return text
24
+ .split(/(?<=[.!?。!?])\s+/)
25
+ .map((s) => s.trim())
26
+ .filter((s) => s.length > 10);
27
+ }
28
+
29
+ function splitParagraphs(text) {
30
+ return text
31
+ .split(/\n\s*\n/)
32
+ .map((p) => p.replace(/\s+/g, " ").trim())
33
+ .filter((p) => p.length > 20);
34
+ }
35
+
36
+ const STAT_PATTERN = /\b\d+(\.\d+)?\s*(%|percent|million|billion|thousand|x|times|fold)\b/i;
37
+ const DATE_PATTERN = /\b(20[12]\d|19\d\d)[-/]\d{1,2}[-/]\d{1,2}\b|\b(January|February|March|April|May|June|July|August|September|October|November|December)\s+\d{1,2},?\s+20[12]\d\b/i;
38
+ const NUMBER_FACT_PATTERN = /\b\d{2,}(\.\d+)?\b/;
39
+ const CLAIM_VERBS = /\b(is|are|was|were|shows?|demonstrates?|indicates?|proves?|reveals?|found|according to|reported|measured|calculated|estimated|averaged)\b/i;
40
+ const COMPARISON_PATTERN = /\b(more than|less than|greater|fewer|higher|lower|faster|slower|better|worse|compared to|versus|outperform|exceed)\b/i;
41
+
42
+ function analyzeClaims(sentences) {
43
+ let factualClaims = 0;
44
+ let statClaims = 0;
45
+ let datedClaims = 0;
46
+ let comparisonClaims = 0;
47
+
48
+ for (const sentence of sentences) {
49
+ const hasNumber = NUMBER_FACT_PATTERN.test(sentence);
50
+ const hasClaimVerb = CLAIM_VERBS.test(sentence);
51
+ const hasStat = STAT_PATTERN.test(sentence);
52
+ const hasDate = DATE_PATTERN.test(sentence);
53
+ const hasComparison = COMPARISON_PATTERN.test(sentence);
54
+
55
+ if (hasNumber && hasClaimVerb) factualClaims++;
56
+ if (hasStat) statClaims++;
57
+ if (hasDate) datedClaims++;
58
+ if (hasComparison) comparisonClaims++;
59
+ }
60
+
61
+ const total = sentences.length || 1;
62
+ return {
63
+ total: sentences.length,
64
+ factualClaims,
65
+ statClaims,
66
+ datedClaims,
67
+ comparisonClaims,
68
+ claimDensity: Math.round((factualClaims / total) * 100),
69
+ statDensity: Math.round((statClaims / total) * 100)
70
+ };
71
+ }
72
+
73
+ const ENTITY_PATTERN = /\b[A-Z][a-z]+(?:\s+[A-Z][a-z]+)+\b/g;
74
+ const TECH_TERM_PATTERN = /\b[A-Z]{2,}(?:\s+[A-Z]{2,})*\b/g;
75
+ const BRAND_PATTERN = /\b[A-Z][a-zA-Z]*(?:\.(?:com|io|ai|org|net))\b/g;
76
+
77
+ function analyzeEntities(text) {
78
+ const namedEntities = new Set((text.match(ENTITY_PATTERN) || []).map((e) => e.trim()));
79
+ const techTerms = new Set((text.match(TECH_TERM_PATTERN) || []).filter((t) => t.length > 1));
80
+ const brands = new Set((text.match(BRAND_PATTERN) || []).map((b) => b.trim()));
81
+ const wordCount = text.split(/\s+/).length || 1;
82
+
83
+ return {
84
+ namedEntities: [...namedEntities].slice(0, 20),
85
+ techTerms: [...techTerms].slice(0, 20),
86
+ brands: [...brands].slice(0, 10),
87
+ entityDensity: Math.round(((namedEntities.size + techTerms.size) / wordCount) * 1000) / 10
88
+ };
89
+ }
90
+
91
+ function analyzeQuotability(sentences) {
92
+ const quotable = [];
93
+
94
+ for (const sentence of sentences) {
95
+ const wordCount = sentence.split(/\s+/).length;
96
+ if (wordCount < 8 || wordCount > 40) continue;
97
+
98
+ let score = 0;
99
+ if (STAT_PATTERN.test(sentence)) score += 3;
100
+ if (CLAIM_VERBS.test(sentence)) score += 2;
101
+ if (NUMBER_FACT_PATTERN.test(sentence)) score += 2;
102
+ if (COMPARISON_PATTERN.test(sentence)) score += 2;
103
+ if (wordCount >= 12 && wordCount <= 25) score += 1;
104
+
105
+ if (score >= 3) {
106
+ quotable.push({ sentence: sentence.slice(0, 200), score, wordCount });
107
+ }
108
+ }
109
+
110
+ return quotable.sort((a, b) => b.score - a.score).slice(0, 10);
111
+ }
112
+
113
+ function analyzeStructure(content) {
114
+ const hasList = /<[ou]l\b/i.test(content) || /^[-*]\s/m.test(content);
115
+ const hasTable = /<table\b/i.test(content) || /\|.*\|.*\|/m.test(content);
116
+ const hasDefinition = /<dl\b/i.test(content) || /\b(is defined as|refers to|means)\b/i.test(content);
117
+ const hasHeading = /<h[1-6]\b/i.test(content) || /^#{1,6}\s/m.test(content);
118
+ const headingCount = ((content.match(/<h[1-6]\b/gi) || []).length) +
119
+ ((content.match(/^#{1,6}\s/gm) || []).length);
120
+
121
+ let structureScore = 0;
122
+ if (hasList) structureScore += 20;
123
+ if (hasTable) structureScore += 20;
124
+ if (hasDefinition) structureScore += 15;
125
+ if (hasHeading && headingCount >= 3) structureScore += 25;
126
+ else if (hasHeading) structureScore += 15;
127
+ if (headingCount >= 5) structureScore += 20;
128
+
129
+ return {
130
+ hasList,
131
+ hasTable,
132
+ hasDefinition,
133
+ hasHeading,
134
+ headingCount,
135
+ structureScore: Math.min(structureScore, 100)
136
+ };
137
+ }
138
+
139
+ function computeCitabilityScore(claims, entities, quotable, structure, wordCount) {
140
+ const w = {
141
+ claimDensity: 25,
142
+ entityRichness: 15,
143
+ quotability: 25,
144
+ structure: 20,
145
+ length: 15
146
+ };
147
+
148
+ const claimScore = Math.min(claims.claimDensity * 2, 100);
149
+ const entityScore = Math.min(entities.entityDensity * 10, 100);
150
+ const quotabilityScore = Math.min(quotable.length * 10, 100);
151
+ const lengthScore = wordCount >= 300 ? (wordCount >= 800 ? 100 : 60) : 20;
152
+
153
+ const weighted =
154
+ (claimScore * w.claimDensity +
155
+ entityScore * w.entityRichness +
156
+ quotabilityScore * w.quotability +
157
+ structure.structureScore * w.structure +
158
+ lengthScore * w.length) / 100;
159
+
160
+ return Math.round(Math.min(weighted, 100));
161
+ }
162
+
163
+ function getScoreLabel(score) {
164
+ if (score >= 80) return "Highly citable";
165
+ if (score >= 60) return "Moderately citable";
166
+ if (score >= 40) return "Low citability";
167
+ return "Poor citability";
168
+ }
169
+
170
+ function buildRecommendations(claims, entities, quotable, structure, wordCount) {
171
+ const recs = [];
172
+
173
+ if (claims.claimDensity < 15) {
174
+ recs.push("Add more factual claims with specific numbers, statistics, or data points to increase claim density.");
175
+ }
176
+ if (claims.statDensity < 5) {
177
+ recs.push("Include statistics (percentages, measurements, benchmarks) to make claims more citable.");
178
+ }
179
+ if (entities.entityDensity < 1) {
180
+ recs.push("Reference more named entities, brands, or technical terms to help AI identify the topic.");
181
+ }
182
+ if (quotable.length < 3) {
183
+ recs.push("Write more self-contained, quotable sentences (12-25 words) with clear factual assertions.");
184
+ }
185
+ if (!structure.hasList) {
186
+ recs.push("Add bulleted or numbered lists to make information scannable and extractable.");
187
+ }
188
+ if (!structure.hasTable) {
189
+ recs.push("Add comparison tables or data tables for easy extraction by AI systems.");
190
+ }
191
+ if (structure.headingCount < 3) {
192
+ recs.push("Add more descriptive headings to organize content into clearly defined sections.");
193
+ }
194
+ if (wordCount < 300) {
195
+ recs.push("Expand content to at least 300 words to provide sufficient depth for AI citation.");
196
+ }
197
+
198
+ return recs;
199
+ }
200
+
201
+ async function fetchContent(url) {
202
+ const response = await fetch(url, {
203
+ redirect: "follow",
204
+ headers: { "user-agent": "geo-ai-search-optimization/2.2.0" },
205
+ signal: AbortSignal.timeout(10_000)
206
+ });
207
+ if (!response.ok) throw new Error(`Failed to fetch: ${url} (status ${response.status})`);
208
+ return response.text();
209
+ }
210
+
211
+ export async function analyzeCitability(input, options = {}) {
212
+ let rawContent;
213
+ let source;
214
+
215
+ if (/^https?:\/\//i.test(input)) {
216
+ rawContent = await fetchContent(input);
217
+ source = input;
218
+ } else {
219
+ const filePath = path.resolve(input);
220
+ rawContent = await fs.readFile(filePath, "utf8");
221
+ source = filePath;
222
+ }
223
+
224
+ const plainText = extractPlainText(rawContent);
225
+ const sentences = splitSentences(plainText);
226
+ const paragraphs = splitParagraphs(plainText);
227
+ const wordCount = plainText.split(/\s+/).length;
228
+
229
+ const claims = analyzeClaims(sentences);
230
+ const entities = analyzeEntities(plainText);
231
+ const quotable = analyzeQuotability(sentences);
232
+ const structure = analyzeStructure(rawContent);
233
+ const score = computeCitabilityScore(claims, entities, quotable, structure, wordCount);
234
+ const recommendations = buildRecommendations(claims, entities, quotable, structure, wordCount);
235
+
236
+ return {
237
+ kind: "geo-citability",
238
+ source,
239
+ wordCount,
240
+ sentenceCount: sentences.length,
241
+ paragraphCount: paragraphs.length,
242
+ score,
243
+ scoreLabel: getScoreLabel(score),
244
+ claims,
245
+ entities,
246
+ quotableSentences: quotable,
247
+ structure,
248
+ recommendations,
249
+ summary: `Citability score: ${score}/100 (${getScoreLabel(score)}). ${recommendations[0] || "Content is well-structured for AI citation."}`
250
+ };
251
+ }
252
+
253
+ export function renderCitabilityMarkdown(report) {
254
+ const lines = [
255
+ "# Citability Analysis",
256
+ "",
257
+ `- Source: \`${report.source}\``,
258
+ `- Citability Score: \`${report.score}/100\` (${report.scoreLabel})`,
259
+ `- Word Count: \`${report.wordCount}\``,
260
+ `- Sentences: \`${report.sentenceCount}\``,
261
+ `- Summary: ${report.summary}`,
262
+ "",
263
+ "## Claim Analysis",
264
+ "",
265
+ `- Factual claims: \`${report.claims.factualClaims}/${report.claims.total}\` (${report.claims.claimDensity}%)`,
266
+ `- Statistical claims: \`${report.claims.statClaims}\` (${report.claims.statDensity}%)`,
267
+ `- Dated claims: \`${report.claims.datedClaims}\``,
268
+ `- Comparison claims: \`${report.claims.comparisonClaims}\``,
269
+ "",
270
+ "## Entity Analysis",
271
+ "",
272
+ `- Entity density: \`${report.entities.entityDensity}%\``,
273
+ `- Named entities: ${report.entities.namedEntities.slice(0, 10).map((e) => `\`${e}\``).join(", ") || "none detected"}`,
274
+ `- Technical terms: ${report.entities.techTerms.slice(0, 10).map((t) => `\`${t}\``).join(", ") || "none detected"}`,
275
+ "",
276
+ "## Structure",
277
+ "",
278
+ `- Lists: \`${report.structure.hasList}\``,
279
+ `- Tables: \`${report.structure.hasTable}\``,
280
+ `- Definitions: \`${report.structure.hasDefinition}\``,
281
+ `- Headings: \`${report.structure.headingCount}\``,
282
+ `- Structure score: \`${report.structure.structureScore}/100\``,
283
+ "",
284
+ "## Top Quotable Sentences",
285
+ ""
286
+ ];
287
+
288
+ if (report.quotableSentences.length === 0) {
289
+ lines.push("- No highly quotable sentences detected.");
290
+ } else {
291
+ for (const q of report.quotableSentences) {
292
+ lines.push(`- (score ${q.score}) ${q.sentence}`);
293
+ }
294
+ }
295
+
296
+ lines.push("", "## Recommendations", "");
297
+ if (report.recommendations.length === 0) {
298
+ lines.push("- Content is well-optimized for AI citability.");
299
+ } else {
300
+ for (const rec of report.recommendations) {
301
+ lines.push(`- ${rec}`);
302
+ }
303
+ }
304
+
305
+ lines.push("");
306
+ return lines.join("\n");
307
+ }
308
+
309
+ export async function writeCitabilityOutput(outputPath, content) {
310
+ return writeScanOutput(outputPath, content);
311
+ }
@@ -0,0 +1,157 @@
1
+ import { writeScanOutput } from "./scan.js";
2
+
3
+ const DEFAULT_ENGINES = ["perplexity", "google"];
4
+
5
+ function buildPerplexityUrl(query) {
6
+ return `https://www.perplexity.ai/search?q=${encodeURIComponent(query)}`;
7
+ }
8
+
9
+ function buildGoogleUrl(query) {
10
+ return `https://www.google.com/search?q=${encodeURIComponent(query)}`;
11
+ }
12
+
13
+ function extractDomain(siteUrl) {
14
+ try {
15
+ return new URL(siteUrl).hostname.replace(/^www\./, "");
16
+ } catch {
17
+ return siteUrl.replace(/^https?:\/\//, "").replace(/^www\./, "").split("/")[0];
18
+ }
19
+ }
20
+
21
+ async function fetchWithTimeout(url, timeoutMs = 10000) {
22
+ const controller = new AbortController();
23
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
24
+ try {
25
+ const response = await fetch(url, {
26
+ signal: controller.signal,
27
+ headers: {
28
+ "user-agent": "geo-ai-search-optimization/2.2.0"
29
+ },
30
+ redirect: "follow"
31
+ });
32
+ const text = await response.text();
33
+ return { ok: response.ok, status: response.status, text };
34
+ } catch (error) {
35
+ return { ok: false, status: 0, text: "", error: error.message };
36
+ } finally {
37
+ clearTimeout(timer);
38
+ }
39
+ }
40
+
41
+ function checkTextForCitation(text, domain) {
42
+ const lowerText = text.toLowerCase();
43
+ const lowerDomain = domain.toLowerCase();
44
+ return lowerText.includes(lowerDomain);
45
+ }
46
+
47
+ async function checkPerplexity(query, domain) {
48
+ // Note: Perplexity may block automated requests.
49
+ // This is a best-effort check — in production, use their API.
50
+ const url = buildPerplexityUrl(query);
51
+ const result = await fetchWithTimeout(url);
52
+ return {
53
+ engine: "perplexity",
54
+ query,
55
+ url,
56
+ found: result.ok ? checkTextForCitation(result.text, domain) : false,
57
+ status: result.status,
58
+ error: result.error || null
59
+ };
60
+ }
61
+
62
+ async function checkGoogle(query, domain) {
63
+ const url = buildGoogleUrl(query);
64
+ const result = await fetchWithTimeout(url);
65
+ return {
66
+ engine: "google",
67
+ query,
68
+ url,
69
+ found: result.ok ? checkTextForCitation(result.text, domain) : false,
70
+ status: result.status,
71
+ error: result.error || null
72
+ };
73
+ }
74
+
75
+ const ENGINE_CHECKERS = {
76
+ perplexity: checkPerplexity,
77
+ google: checkGoogle
78
+ };
79
+
80
+ export async function checkCitation(siteUrl, queries, options = {}) {
81
+ if (!siteUrl) throw new Error("citation-check 需要一个网站 URL");
82
+ if (!queries || queries.length === 0) throw new Error("citation-check 需要至少一个查询");
83
+
84
+ const engines = options.engines || DEFAULT_ENGINES;
85
+ const domain = extractDomain(siteUrl);
86
+ const results = [];
87
+
88
+ for (const query of queries) {
89
+ for (const engine of engines) {
90
+ const checker = ENGINE_CHECKERS[engine];
91
+ if (!checker) {
92
+ results.push({
93
+ engine,
94
+ query,
95
+ url: null,
96
+ found: false,
97
+ status: 0,
98
+ error: `不支持的搜索引擎:${engine}`
99
+ });
100
+ continue;
101
+ }
102
+ const result = await checker(query, domain);
103
+ results.push(result);
104
+ }
105
+ }
106
+
107
+ const totalChecks = results.length;
108
+ const foundCount = results.filter((r) => r.found).length;
109
+ const notFoundCount = results.filter((r) => !r.found && !r.error).length;
110
+ const errorCount = results.filter((r) => r.error).length;
111
+
112
+ return {
113
+ kind: "geo-citation-check",
114
+ siteUrl,
115
+ domain,
116
+ queries,
117
+ engines,
118
+ totalChecks,
119
+ foundCount,
120
+ notFoundCount,
121
+ errorCount,
122
+ foundRate: totalChecks > 0 ? Math.round((foundCount / totalChecks) * 100) : 0,
123
+ results,
124
+ summary: foundCount > 0
125
+ ? `在 ${totalChecks} 次检查中发现 ${foundCount} 次引用(${Math.round((foundCount / totalChecks) * 100)}% 引用率)。`
126
+ : `在 ${totalChecks} 次检查中未发现引用。`
127
+ };
128
+ }
129
+
130
+ export function renderCitationCheckMarkdown(report) {
131
+ const lines = [
132
+ "# GEO 引用检查",
133
+ "",
134
+ `- 网站:\`${report.siteUrl}\``,
135
+ `- 域名:\`${report.domain}\``,
136
+ `- 查询数量:\`${report.queries.length}\``,
137
+ `- 搜索引擎:\`${report.engines.join(", ")}\``,
138
+ `- 引用率:\`${report.foundRate}%\`(${report.foundCount}/${report.totalChecks})`,
139
+ "",
140
+ report.summary,
141
+ ""
142
+ ];
143
+
144
+ lines.push("## 详细结果", "", "| 查询 | 引擎 | 状态 | 引用 |", "|------|------|------|------|");
145
+ for (const r of report.results) {
146
+ const status = r.error ? `错误: ${r.error}` : `${r.status}`;
147
+ const found = r.found ? "✓ 发现" : "✗ 未发现";
148
+ lines.push(`| ${r.query} | ${r.engine} | ${status} | ${found} |`);
149
+ }
150
+
151
+ lines.push("");
152
+ return lines.join("\n");
153
+ }
154
+
155
+ export async function writeCitationCheckOutput(outputPath, content) {
156
+ return writeScanOutput(outputPath, content);
157
+ }