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.
- package/action.yml +130 -0
- package/examples/github-action.yml +27 -0
- package/package.json +17 -3
- package/src/audit-diff.js +184 -0
- package/src/auto-fix.js +349 -0
- package/src/batch-full-page-audit.js +151 -0
- package/src/batch-page-audit.js +140 -0
- package/src/benchmark.js +126 -0
- package/src/ci.js +81 -0
- package/src/citability.js +311 -0
- package/src/citation-check.js +157 -0
- package/src/citation-monitor.js +86 -0
- package/src/cli-site-ops-commands.js +638 -4
- package/src/compare.js +175 -0
- package/src/config.js +105 -0
- package/src/content-gap.js +170 -0
- package/src/crawlers.js +286 -0
- package/src/diagnose.js +221 -0
- package/src/eeat.js +251 -0
- package/src/freshness.js +281 -0
- package/src/full-audit.js +269 -0
- package/src/full-page-audit.js +273 -0
- package/src/heading-structure.js +287 -0
- package/src/index.d.ts +492 -0
- package/src/index.js +35 -0
- package/src/internal-links.js +298 -0
- package/src/page-audit.js +1 -1
- package/src/page-snapshot.js +198 -0
- package/src/pdf-report.js +205 -0
- package/src/platform-ready.js +238 -0
- package/src/plugins.js +126 -0
- package/src/pm-brief.js +8 -0
- package/src/pre-commit.js +62 -0
- package/src/readability.js +252 -0
- package/src/report.js +37 -12
- package/src/security.js +249 -0
- package/src/sitemap.js +323 -0
- package/src/snapshot.js +51 -0
- package/src/social-meta.js +293 -0
- package/src/topics.js +275 -0
- package/src/trend.js +102 -0
- package/src/url-onboarding.js +1 -1
- package/src/validate-llms.js +307 -0
- package/src/validate-schema.js +306 -0
- package/src/watch.js +49 -0
|
@@ -0,0 +1,252 @@
|
|
|
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-z]+;/gi, " ")
|
|
11
|
+
.replace(/\s+/g, " ")
|
|
12
|
+
.trim();
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function extractPlainText(content) {
|
|
16
|
+
if (/<html|<head|<body/i.test(content)) return stripHtml(content);
|
|
17
|
+
return content.replace(/^---[\s\S]*?---/, "").trim();
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function splitSentences(text) {
|
|
21
|
+
return text
|
|
22
|
+
.split(/(?<=[.!?。!?])\s+/)
|
|
23
|
+
.map((s) => s.trim())
|
|
24
|
+
.filter((s) => s.length > 5);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function splitWords(text) {
|
|
28
|
+
return text.split(/\s+/).filter((w) => w.length > 0);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function countSyllables(word) {
|
|
32
|
+
const cleaned = word.toLowerCase().replace(/[^a-z]/g, "");
|
|
33
|
+
if (cleaned.length <= 2) return 1;
|
|
34
|
+
|
|
35
|
+
let count = 0;
|
|
36
|
+
const vowels = "aeiouy";
|
|
37
|
+
let prevVowel = false;
|
|
38
|
+
|
|
39
|
+
for (const char of cleaned) {
|
|
40
|
+
const isVowel = vowels.includes(char);
|
|
41
|
+
if (isVowel && !prevVowel) count++;
|
|
42
|
+
prevVowel = isVowel;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
if (cleaned.endsWith("e") && count > 1) count--;
|
|
46
|
+
return Math.max(count, 1);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function computeFleschKincaid(words, sentences, syllables) {
|
|
50
|
+
if (sentences === 0 || words === 0) return 0;
|
|
51
|
+
return Math.round(
|
|
52
|
+
(0.39 * (words / sentences) + 11.8 * (syllables / words) - 15.59) * 10
|
|
53
|
+
) / 10;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function computeFleschReadingEase(words, sentences, syllables) {
|
|
57
|
+
if (sentences === 0 || words === 0) return 0;
|
|
58
|
+
return Math.round(
|
|
59
|
+
(206.835 - 1.015 * (words / sentences) - 84.6 * (syllables / words)) * 10
|
|
60
|
+
) / 10;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function getGradeLabel(grade) {
|
|
64
|
+
if (grade <= 6) return "Easy (general audience)";
|
|
65
|
+
if (grade <= 8) return "Standard (most adults)";
|
|
66
|
+
if (grade <= 12) return "Moderate (educated readers)";
|
|
67
|
+
if (grade <= 16) return "Difficult (college level)";
|
|
68
|
+
return "Very difficult (graduate+)";
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function getReadingEaseLabel(score) {
|
|
72
|
+
if (score >= 80) return "Easy";
|
|
73
|
+
if (score >= 60) return "Standard";
|
|
74
|
+
if (score >= 40) return "Moderate";
|
|
75
|
+
if (score >= 20) return "Difficult";
|
|
76
|
+
return "Very difficult";
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function analyzePassiveVoice(sentences) {
|
|
80
|
+
const passivePattern = /\b(is|are|was|were|been|being|be)\s+([\w]+ed|[\w]+en)\b/gi;
|
|
81
|
+
let passiveCount = 0;
|
|
82
|
+
|
|
83
|
+
for (const sentence of sentences) {
|
|
84
|
+
if (passivePattern.test(sentence)) {
|
|
85
|
+
passiveCount++;
|
|
86
|
+
}
|
|
87
|
+
passivePattern.lastIndex = 0;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
return {
|
|
91
|
+
passiveCount,
|
|
92
|
+
passiveRatio: sentences.length > 0 ? Math.round((passiveCount / sentences.length) * 100) : 0
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function analyzeSentenceLength(sentences) {
|
|
97
|
+
if (sentences.length === 0) return { avg: 0, min: 0, max: 0, longCount: 0, shortCount: 0, distribution: {} };
|
|
98
|
+
|
|
99
|
+
const lengths = sentences.map((s) => splitWords(s).length);
|
|
100
|
+
const avg = Math.round(lengths.reduce((a, b) => a + b, 0) / lengths.length);
|
|
101
|
+
const min = Math.min(...lengths);
|
|
102
|
+
const max = Math.max(...lengths);
|
|
103
|
+
const longCount = lengths.filter((l) => l > 30).length;
|
|
104
|
+
const shortCount = lengths.filter((l) => l < 8).length;
|
|
105
|
+
|
|
106
|
+
const distribution = {
|
|
107
|
+
short: lengths.filter((l) => l < 10).length,
|
|
108
|
+
medium: lengths.filter((l) => l >= 10 && l <= 20).length,
|
|
109
|
+
long: lengths.filter((l) => l > 20 && l <= 30).length,
|
|
110
|
+
veryLong: lengths.filter((l) => l > 30).length
|
|
111
|
+
};
|
|
112
|
+
|
|
113
|
+
return { avg, min, max, longCount, shortCount, distribution };
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function estimateReadingTime(wordCount) {
|
|
117
|
+
const wpm = 238; // Average adult reading speed
|
|
118
|
+
const minutes = Math.ceil(wordCount / wpm);
|
|
119
|
+
return minutes;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function buildRecommendations(grade, readingEase, passive, sentenceStats, wordCount) {
|
|
123
|
+
const recs = [];
|
|
124
|
+
|
|
125
|
+
if (grade > 12) {
|
|
126
|
+
recs.push("Simplify language. Aim for grade level 8-10 for maximum AI citability and reach.");
|
|
127
|
+
}
|
|
128
|
+
if (sentenceStats.avg > 25) {
|
|
129
|
+
recs.push(`Average sentence length is ${sentenceStats.avg} words. Break long sentences (target: 15-20 words).`);
|
|
130
|
+
}
|
|
131
|
+
if (sentenceStats.longCount > 3) {
|
|
132
|
+
recs.push(`${sentenceStats.longCount} sentences exceed 30 words. Split for clarity.`);
|
|
133
|
+
}
|
|
134
|
+
if (passive.passiveRatio > 20) {
|
|
135
|
+
recs.push(`Passive voice used in ${passive.passiveRatio}% of sentences. Prefer active voice for clarity.`);
|
|
136
|
+
}
|
|
137
|
+
if (readingEase < 40) {
|
|
138
|
+
recs.push("Content is hard to read. Use shorter words and simpler sentence structures.");
|
|
139
|
+
}
|
|
140
|
+
if (wordCount < 300) {
|
|
141
|
+
recs.push("Content is thin. AI systems prefer comprehensive content (500+ words).");
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
return recs;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
async function fetchContent(url) {
|
|
148
|
+
const response = await fetch(url, {
|
|
149
|
+
redirect: "follow",
|
|
150
|
+
headers: { "user-agent": "geo-ai-search-optimization/2.2.0" },
|
|
151
|
+
signal: AbortSignal.timeout(10_000)
|
|
152
|
+
});
|
|
153
|
+
if (!response.ok) throw new Error(`Failed to fetch: ${url} (status ${response.status})`);
|
|
154
|
+
return response.text();
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
export async function analyzeReadability(input, options = {}) {
|
|
158
|
+
let rawContent;
|
|
159
|
+
let source;
|
|
160
|
+
|
|
161
|
+
if (/^https?:\/\//i.test(input)) {
|
|
162
|
+
rawContent = await fetchContent(input);
|
|
163
|
+
source = input;
|
|
164
|
+
} else {
|
|
165
|
+
const filePath = path.resolve(input);
|
|
166
|
+
rawContent = await fs.readFile(filePath, "utf8");
|
|
167
|
+
source = filePath;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
const plainText = extractPlainText(rawContent);
|
|
171
|
+
const sentences = splitSentences(plainText);
|
|
172
|
+
const words = splitWords(plainText);
|
|
173
|
+
const totalSyllables = words.reduce((sum, w) => sum + countSyllables(w), 0);
|
|
174
|
+
|
|
175
|
+
const grade = computeFleschKincaid(words.length, sentences.length, totalSyllables);
|
|
176
|
+
const readingEase = computeFleschReadingEase(words.length, sentences.length, totalSyllables);
|
|
177
|
+
const passive = analyzePassiveVoice(sentences);
|
|
178
|
+
const sentenceStats = analyzeSentenceLength(sentences);
|
|
179
|
+
const readingTime = estimateReadingTime(words.length);
|
|
180
|
+
const recommendations = buildRecommendations(grade, readingEase, passive, sentenceStats, words.length);
|
|
181
|
+
|
|
182
|
+
const score = Math.round(Math.min(100, Math.max(0,
|
|
183
|
+
readingEase * 0.4 +
|
|
184
|
+
(grade <= 10 ? 40 : grade <= 14 ? 20 : 0) +
|
|
185
|
+
(passive.passiveRatio <= 15 ? 20 : passive.passiveRatio <= 25 ? 10 : 0)
|
|
186
|
+
)));
|
|
187
|
+
|
|
188
|
+
return {
|
|
189
|
+
kind: "geo-readability",
|
|
190
|
+
source,
|
|
191
|
+
wordCount: words.length,
|
|
192
|
+
sentenceCount: sentences.length,
|
|
193
|
+
readingTime,
|
|
194
|
+
fleschKincaidGrade: grade,
|
|
195
|
+
gradeLabel: getGradeLabel(grade),
|
|
196
|
+
fleschReadingEase: readingEase,
|
|
197
|
+
readingEaseLabel: getReadingEaseLabel(readingEase),
|
|
198
|
+
passiveVoice: passive,
|
|
199
|
+
sentenceLength: sentenceStats,
|
|
200
|
+
score,
|
|
201
|
+
scoreLabel: score >= 70 ? "Good" : score >= 40 ? "Fair" : "Needs improvement",
|
|
202
|
+
recommendations,
|
|
203
|
+
summary: `Readability: Grade ${grade} (${getGradeLabel(grade)}). Reading ease: ${readingEase} (${getReadingEaseLabel(readingEase)}). ~${readingTime} min read.`
|
|
204
|
+
};
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
export function renderReadabilityMarkdown(report) {
|
|
208
|
+
const lines = [
|
|
209
|
+
"# Readability Analysis",
|
|
210
|
+
"",
|
|
211
|
+
`- Source: \`${report.source}\``,
|
|
212
|
+
`- Score: \`${report.score}/100\` (${report.scoreLabel})`,
|
|
213
|
+
`- Summary: ${report.summary}`,
|
|
214
|
+
"",
|
|
215
|
+
"## Key Metrics",
|
|
216
|
+
"",
|
|
217
|
+
`| Metric | Value |`,
|
|
218
|
+
`|--------|-------|`,
|
|
219
|
+
`| Word Count | ${report.wordCount} |`,
|
|
220
|
+
`| Sentence Count | ${report.sentenceCount} |`,
|
|
221
|
+
`| Reading Time | ~${report.readingTime} min |`,
|
|
222
|
+
`| Flesch-Kincaid Grade | ${report.fleschKincaidGrade} (${report.gradeLabel}) |`,
|
|
223
|
+
`| Flesch Reading Ease | ${report.fleschReadingEase} (${report.readingEaseLabel}) |`,
|
|
224
|
+
`| Passive Voice | ${report.passiveVoice.passiveRatio}% (${report.passiveVoice.passiveCount} sentences) |`,
|
|
225
|
+
"",
|
|
226
|
+
"## Sentence Length",
|
|
227
|
+
"",
|
|
228
|
+
`- Average: \`${report.sentenceLength.avg}\` words`,
|
|
229
|
+
`- Range: \`${report.sentenceLength.min}\` - \`${report.sentenceLength.max}\` words`,
|
|
230
|
+
`- Short (< 10): \`${report.sentenceLength.distribution.short}\``,
|
|
231
|
+
`- Medium (10-20): \`${report.sentenceLength.distribution.medium}\``,
|
|
232
|
+
`- Long (20-30): \`${report.sentenceLength.distribution.long}\``,
|
|
233
|
+
`- Very long (> 30): \`${report.sentenceLength.distribution.veryLong}\``,
|
|
234
|
+
""
|
|
235
|
+
];
|
|
236
|
+
|
|
237
|
+
lines.push("## Recommendations", "");
|
|
238
|
+
if (report.recommendations.length === 0) {
|
|
239
|
+
lines.push("- Readability is well-optimized for AI and general audiences.");
|
|
240
|
+
} else {
|
|
241
|
+
for (const rec of report.recommendations) {
|
|
242
|
+
lines.push(`- ${rec}`);
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
lines.push("");
|
|
246
|
+
|
|
247
|
+
return lines.join("\n");
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
export async function writeReadabilityOutput(outputPath, content) {
|
|
251
|
+
return writeScanOutput(outputPath, content);
|
|
252
|
+
}
|
package/src/report.js
CHANGED
|
@@ -1,8 +1,11 @@
|
|
|
1
1
|
import { auditProject, renderAuditMarkdown } from "./audit.js";
|
|
2
|
+
import { runBenchmark, renderBenchmarkMarkdown } from "./benchmark.js";
|
|
2
3
|
import { renderScanMarkdown, scanProject, writeScanOutput } from "./scan.js";
|
|
3
4
|
import { analyzeWebsiteUrl, renderWebsiteOnboardingMarkdown } from "./url-onboarding.js";
|
|
4
5
|
|
|
5
|
-
|
|
6
|
+
import { checkCitation, renderCitationCheckMarkdown } from "./citation-check.js";
|
|
7
|
+
|
|
8
|
+
const VALID_MODES = new Set(["auto", "audit", "onboarding", "scan", "benchmark", "citation"]);
|
|
6
9
|
const VALID_FORMATS = new Set(["markdown", "html", "json"]);
|
|
7
10
|
|
|
8
11
|
function isUrlInput(input) {
|
|
@@ -178,6 +181,10 @@ function renderHtmlDocument(title, body) {
|
|
|
178
181
|
|
|
179
182
|
function buildTitle(mode) {
|
|
180
183
|
switch (mode) {
|
|
184
|
+
case "citation":
|
|
185
|
+
return "GEO 引用检查报告";
|
|
186
|
+
case "benchmark":
|
|
187
|
+
return "GEO 竞品基准对比";
|
|
181
188
|
case "onboarding":
|
|
182
189
|
return "GEO 网站 Onboarding 报告";
|
|
183
190
|
case "scan":
|
|
@@ -196,7 +203,17 @@ export async function generateReport(input, options = {}) {
|
|
|
196
203
|
const format = normalizeFormat(options.format);
|
|
197
204
|
|
|
198
205
|
let payload;
|
|
199
|
-
if (mode === "
|
|
206
|
+
if (mode === "citation") {
|
|
207
|
+
if (!options.queries || options.queries.length === 0) {
|
|
208
|
+
throw new Error("citation 模式需要 --queries 参数");
|
|
209
|
+
}
|
|
210
|
+
payload = await checkCitation(input, options.queries, { engines: options.engines });
|
|
211
|
+
} else if (mode === "benchmark") {
|
|
212
|
+
if (!options.competitors || options.competitors.length === 0) {
|
|
213
|
+
throw new Error("benchmark 模式需要 --competitors 参数");
|
|
214
|
+
}
|
|
215
|
+
payload = await runBenchmark(input, options.competitors);
|
|
216
|
+
} else if (mode === "onboarding") {
|
|
200
217
|
payload = await analyzeWebsiteUrl(input, {});
|
|
201
218
|
} else if (mode === "scan") {
|
|
202
219
|
payload = await scanProject(input, {
|
|
@@ -216,19 +233,27 @@ export async function generateReport(input, options = {}) {
|
|
|
216
233
|
} else if (format === "html") {
|
|
217
234
|
const title = buildTitle(mode);
|
|
218
235
|
const body =
|
|
219
|
-
mode === "
|
|
220
|
-
?
|
|
221
|
-
: mode === "
|
|
222
|
-
?
|
|
223
|
-
|
|
236
|
+
mode === "citation"
|
|
237
|
+
? `<section><h1>GEO 引用检查</h1><pre>${escapeHtml(renderCitationCheckMarkdown(payload).trim())}</pre></section>`
|
|
238
|
+
: mode === "benchmark"
|
|
239
|
+
? `<section><h1>GEO 竞品基准对比</h1><pre>${escapeHtml(renderBenchmarkMarkdown(payload).trim())}</pre></section>`
|
|
240
|
+
: mode === "onboarding"
|
|
241
|
+
? renderOnboardingHtml(payload)
|
|
242
|
+
: mode === "scan"
|
|
243
|
+
? renderScanHtml(payload)
|
|
244
|
+
: renderAuditHtml(payload);
|
|
224
245
|
content = renderHtmlDocument(title, body);
|
|
225
246
|
} else {
|
|
226
247
|
content =
|
|
227
|
-
mode === "
|
|
228
|
-
? `${
|
|
229
|
-
: mode === "
|
|
230
|
-
?
|
|
231
|
-
:
|
|
248
|
+
mode === "citation"
|
|
249
|
+
? `${renderCitationCheckMarkdown(payload)}\n`
|
|
250
|
+
: mode === "benchmark"
|
|
251
|
+
? `${renderBenchmarkMarkdown(payload)}\n`
|
|
252
|
+
: mode === "onboarding"
|
|
253
|
+
? `${renderWebsiteOnboardingMarkdown(payload)}\n`
|
|
254
|
+
: mode === "scan"
|
|
255
|
+
? renderScanMarkdown(payload)
|
|
256
|
+
: `${renderAuditMarkdown(payload)}\n`;
|
|
232
257
|
}
|
|
233
258
|
|
|
234
259
|
return {
|
package/src/security.js
ADDED
|
@@ -0,0 +1,249 @@
|
|
|
1
|
+
import fs from "node:fs/promises";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { writeScanOutput } from "./scan.js";
|
|
4
|
+
|
|
5
|
+
const SECURITY_HEADERS = [
|
|
6
|
+
{ name: "content-security-policy", label: "Content-Security-Policy", weight: 15, critical: true },
|
|
7
|
+
{ name: "x-frame-options", label: "X-Frame-Options", weight: 10, critical: false },
|
|
8
|
+
{ name: "x-content-type-options", label: "X-Content-Type-Options", weight: 10, critical: false },
|
|
9
|
+
{ name: "strict-transport-security", label: "Strict-Transport-Security (HSTS)", weight: 15, critical: true },
|
|
10
|
+
{ name: "referrer-policy", label: "Referrer-Policy", weight: 8, critical: false },
|
|
11
|
+
{ name: "permissions-policy", label: "Permissions-Policy", weight: 8, critical: false },
|
|
12
|
+
{ name: "x-xss-protection", label: "X-XSS-Protection", weight: 5, critical: false }
|
|
13
|
+
];
|
|
14
|
+
|
|
15
|
+
function analyzeHtmlSecurity(content) {
|
|
16
|
+
const checks = [];
|
|
17
|
+
|
|
18
|
+
// Viewport
|
|
19
|
+
const hasViewport = /<meta[^>]+name=["']viewport["']/i.test(content);
|
|
20
|
+
checks.push({ key: "viewport", label: "Viewport meta tag", passed: hasViewport, severity: hasViewport ? "pass" : "warning" });
|
|
21
|
+
|
|
22
|
+
// HTTPS references
|
|
23
|
+
const httpLinks = (content.match(/(?:src|href|action)=["']http:\/\//gi) || []).length;
|
|
24
|
+
const hasMixedContent = httpLinks > 0;
|
|
25
|
+
checks.push({
|
|
26
|
+
key: "mixedContent",
|
|
27
|
+
label: "No mixed content (HTTP resources on HTTPS page)",
|
|
28
|
+
passed: !hasMixedContent,
|
|
29
|
+
severity: hasMixedContent ? "error" : "pass",
|
|
30
|
+
detail: hasMixedContent ? `${httpLinks} HTTP resource reference(s) found` : null
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
// Robots meta
|
|
34
|
+
const robotsMeta = content.match(/<meta[^>]+name=["']robots["'][^>]+content=["']([^"']+)["']/i);
|
|
35
|
+
const robotsDirectives = robotsMeta ? robotsMeta[1].toLowerCase().split(",").map((d) => d.trim()) : [];
|
|
36
|
+
const hasNoindex = robotsDirectives.includes("noindex");
|
|
37
|
+
const hasNofollow = robotsDirectives.includes("nofollow");
|
|
38
|
+
|
|
39
|
+
checks.push({
|
|
40
|
+
key: "robotsMeta",
|
|
41
|
+
label: "Robots meta tag (indexing allowed)",
|
|
42
|
+
passed: !hasNoindex,
|
|
43
|
+
severity: hasNoindex ? "warning" : "pass",
|
|
44
|
+
detail: robotsMeta ? `Directives: ${robotsDirectives.join(", ")}` : "No robots meta tag (defaults: index, follow)"
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
if (hasNofollow) {
|
|
48
|
+
checks.push({
|
|
49
|
+
key: "nofollow",
|
|
50
|
+
label: "Nofollow directive",
|
|
51
|
+
passed: false,
|
|
52
|
+
severity: "info",
|
|
53
|
+
detail: "Page has nofollow — link equity won't flow to linked pages"
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// X-UA-Compatible (legacy, informational only)
|
|
58
|
+
|
|
59
|
+
// Charset
|
|
60
|
+
const hasCharset = /<meta[^>]+charset/i.test(content) || /charset=/i.test(content);
|
|
61
|
+
checks.push({ key: "charset", label: "Character encoding declared", passed: hasCharset, severity: hasCharset ? "pass" : "warning" });
|
|
62
|
+
|
|
63
|
+
// Language
|
|
64
|
+
const hasLang = /<html[^>]+lang=/i.test(content);
|
|
65
|
+
checks.push({ key: "lang", label: "HTML lang attribute", passed: hasLang, severity: hasLang ? "pass" : "warning" });
|
|
66
|
+
|
|
67
|
+
// HTTPS canonical
|
|
68
|
+
const canonical = content.match(/<link[^>]+rel=["']canonical["'][^>]+href=["']([^"']+)["']/i);
|
|
69
|
+
const canonicalIsHttps = canonical ? canonical[1].startsWith("https://") : false;
|
|
70
|
+
if (canonical) {
|
|
71
|
+
checks.push({
|
|
72
|
+
key: "httpsCanonical",
|
|
73
|
+
label: "Canonical URL uses HTTPS",
|
|
74
|
+
passed: canonicalIsHttps,
|
|
75
|
+
severity: canonicalIsHttps ? "pass" : "error"
|
|
76
|
+
});
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
return checks;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function analyzeHeaders(headers) {
|
|
83
|
+
const results = [];
|
|
84
|
+
|
|
85
|
+
for (const spec of SECURITY_HEADERS) {
|
|
86
|
+
const value = headers[spec.name] || null;
|
|
87
|
+
results.push({
|
|
88
|
+
header: spec.label,
|
|
89
|
+
key: spec.name,
|
|
90
|
+
present: Boolean(value),
|
|
91
|
+
value: value ? value.slice(0, 100) : null,
|
|
92
|
+
weight: spec.weight,
|
|
93
|
+
critical: spec.critical
|
|
94
|
+
});
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
return results;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function computeScore(htmlChecks, headerResults, isHttps) {
|
|
101
|
+
let score = 0;
|
|
102
|
+
|
|
103
|
+
// HTTPS base
|
|
104
|
+
score += isHttps ? 20 : 0;
|
|
105
|
+
|
|
106
|
+
// HTML checks
|
|
107
|
+
const htmlPassed = htmlChecks.filter((c) => c.passed).length;
|
|
108
|
+
score += Math.round((htmlPassed / Math.max(htmlChecks.length, 1)) * 30);
|
|
109
|
+
|
|
110
|
+
// Security headers
|
|
111
|
+
if (headerResults) {
|
|
112
|
+
const totalWeight = headerResults.reduce((sum, h) => sum + h.weight, 0);
|
|
113
|
+
const earnedWeight = headerResults.filter((h) => h.present).reduce((sum, h) => sum + h.weight, 0);
|
|
114
|
+
score += Math.round((earnedWeight / totalWeight) * 50);
|
|
115
|
+
} else {
|
|
116
|
+
score += 25; // Can't check headers for local files, give partial credit
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
return Math.max(0, Math.min(100, score));
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function buildRecommendations(htmlChecks, headerResults, isHttps) {
|
|
123
|
+
const recs = [];
|
|
124
|
+
|
|
125
|
+
if (!isHttps) {
|
|
126
|
+
recs.push("Migrate to HTTPS. It's a ranking signal and required for security.");
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
for (const check of htmlChecks) {
|
|
130
|
+
if (!check.passed && check.severity !== "info") {
|
|
131
|
+
recs.push(`Fix: ${check.label}${check.detail ? ` — ${check.detail}` : ""}`);
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
if (headerResults) {
|
|
136
|
+
const missing = headerResults.filter((h) => !h.present);
|
|
137
|
+
const critical = missing.filter((h) => h.critical);
|
|
138
|
+
const optional = missing.filter((h) => !h.critical);
|
|
139
|
+
|
|
140
|
+
if (critical.length > 0) {
|
|
141
|
+
recs.push(`Add critical security headers: ${critical.map((h) => h.header).join(", ")}`);
|
|
142
|
+
}
|
|
143
|
+
if (optional.length > 0) {
|
|
144
|
+
recs.push(`Add recommended security headers: ${optional.map((h) => h.header).join(", ")}`);
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
return recs;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
async function fetchWithHeaders(url) {
|
|
152
|
+
const response = await fetch(url, {
|
|
153
|
+
redirect: "follow",
|
|
154
|
+
headers: { "user-agent": "geo-ai-search-optimization/2.2.0" },
|
|
155
|
+
signal: AbortSignal.timeout(10_000)
|
|
156
|
+
});
|
|
157
|
+
const text = await response.text();
|
|
158
|
+
const headers = {};
|
|
159
|
+
for (const [key, value] of response.headers.entries()) {
|
|
160
|
+
headers[key.toLowerCase()] = value;
|
|
161
|
+
}
|
|
162
|
+
return {
|
|
163
|
+
ok: response.ok,
|
|
164
|
+
status: response.status,
|
|
165
|
+
url: response.url,
|
|
166
|
+
isHttps: response.url.startsWith("https://"),
|
|
167
|
+
headers,
|
|
168
|
+
text
|
|
169
|
+
};
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
export async function analyzeSecurity(input, options = {}) {
|
|
173
|
+
let content;
|
|
174
|
+
let source;
|
|
175
|
+
let headers = null;
|
|
176
|
+
let isHttps = true; // Assume true for local files
|
|
177
|
+
|
|
178
|
+
if (/^https?:\/\//i.test(input)) {
|
|
179
|
+
const result = await fetchWithHeaders(input);
|
|
180
|
+
content = result.text;
|
|
181
|
+
source = result.url;
|
|
182
|
+
headers = result.headers;
|
|
183
|
+
isHttps = result.isHttps;
|
|
184
|
+
} else {
|
|
185
|
+
const filePath = path.resolve(input);
|
|
186
|
+
content = await fs.readFile(filePath, "utf8");
|
|
187
|
+
source = filePath;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
const htmlChecks = analyzeHtmlSecurity(content);
|
|
191
|
+
const headerResults = headers ? analyzeHeaders(headers) : null;
|
|
192
|
+
const score = computeScore(htmlChecks, headerResults, isHttps);
|
|
193
|
+
const recommendations = buildRecommendations(htmlChecks, headerResults, isHttps);
|
|
194
|
+
|
|
195
|
+
return {
|
|
196
|
+
kind: "geo-security",
|
|
197
|
+
source,
|
|
198
|
+
isHttps,
|
|
199
|
+
score,
|
|
200
|
+
scoreLabel: score >= 80 ? "Good" : score >= 50 ? "Fair" : "Needs work",
|
|
201
|
+
htmlChecks,
|
|
202
|
+
securityHeaders: headerResults,
|
|
203
|
+
recommendations,
|
|
204
|
+
summary: `Security score: ${score}/100. HTTPS: ${isHttps ? "yes" : "no"}. ${recommendations.length} issue(s) found.`
|
|
205
|
+
};
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
export function renderSecurityMarkdown(report) {
|
|
209
|
+
const lines = [
|
|
210
|
+
"# Security Analysis",
|
|
211
|
+
"",
|
|
212
|
+
`- Source: \`${report.source}\``,
|
|
213
|
+
`- Score: \`${report.score}/100\` (${report.scoreLabel})`,
|
|
214
|
+
`- HTTPS: \`${report.isHttps}\``,
|
|
215
|
+
`- Summary: ${report.summary}`,
|
|
216
|
+
"",
|
|
217
|
+
"## HTML Security Checks",
|
|
218
|
+
""
|
|
219
|
+
];
|
|
220
|
+
|
|
221
|
+
for (const check of report.htmlChecks) {
|
|
222
|
+
const icon = check.passed ? "✅" : (check.severity === "error" ? "❌" : "⚠️");
|
|
223
|
+
lines.push(`- ${icon} ${check.label}${check.detail ? ` — ${check.detail}` : ""}`);
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
if (report.securityHeaders) {
|
|
227
|
+
lines.push("", "## Security Headers", "", "| Header | Present | Value |", "|--------|---------|-------|");
|
|
228
|
+
for (const h of report.securityHeaders) {
|
|
229
|
+
const icon = h.present ? "✅" : (h.critical ? "❌" : "—");
|
|
230
|
+
lines.push(`| ${h.header} | ${icon} | ${h.value || "—"} |`);
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
lines.push("", "## Recommendations", "");
|
|
235
|
+
if (report.recommendations.length === 0) {
|
|
236
|
+
lines.push("- Security configuration looks good.");
|
|
237
|
+
} else {
|
|
238
|
+
for (const rec of report.recommendations) {
|
|
239
|
+
lines.push(`- ${rec}`);
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
lines.push("");
|
|
243
|
+
|
|
244
|
+
return lines.join("\n");
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
export async function writeSecurityOutput(outputPath, content) {
|
|
248
|
+
return writeScanOutput(outputPath, content);
|
|
249
|
+
}
|