geo-ai-search-optimization 2.0.0 → 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/package.json +15 -3
- package/src/auto-fix.js +349 -0
- package/src/batch-full-page-audit.js +151 -0
- package/src/citability.js +311 -0
- package/src/citation-check.js +1 -1
- package/src/cli-site-ops-commands.js +391 -2
- package/src/compare.js +175 -0
- package/src/config.js +105 -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 +24 -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/readability.js +252 -0
- package/src/security.js +249 -0
- package/src/sitemap.js +323 -0
- package/src/social-meta.js +293 -0
- package/src/topics.js +275 -0
- package/src/url-onboarding.js +1 -1
- package/src/validate-llms.js +307 -0
- package/src/validate-schema.js +306 -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/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
|
+
}
|