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/src/eeat.js ADDED
@@ -0,0 +1,251 @@
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)) return stripHtml(content);
17
+ return content.replace(/^---[\s\S]*?---/, "").trim();
18
+ }
19
+
20
+ // Experience signals: first-hand, personal, case study, hands-on
21
+ const EXPERIENCE_PATTERNS = [
22
+ { pattern: /\b(I|we)\s+(tested|tried|used|built|implemented|deployed|ran|conducted|measured|observed)\b/gi, label: "First-person experience" },
23
+ { pattern: /\b(case study|real[- ]world|hands[- ]on|in practice|from experience|in my experience|our experience)\b/gi, label: "Experience claim" },
24
+ { pattern: /\b(screenshot|demo|walkthrough|step[- ]by[- ]step|tutorial|how I|how we)\b/gi, label: "Practical demonstration" },
25
+ { pattern: /\b(lesson learned|mistake|challenge|obstacle|solution we found)\b/gi, label: "Experience narrative" },
26
+ { pattern: /\b(client|customer|user)\s+(feedback|review|testimonial|said|reported)\b/gi, label: "User experience" }
27
+ ];
28
+
29
+ // Expertise signals: technical depth, methodology, credentials
30
+ const EXPERTISE_PATTERNS = [
31
+ { pattern: /\b(methodology|framework|algorithm|architecture|protocol|specification)\b/gi, label: "Technical methodology" },
32
+ { pattern: /\b(PhD|professor|researcher|engineer|scientist|expert|specialist|certified|credential)\b/gi, label: "Credential mention" },
33
+ { pattern: /\b(analysis|benchmark|experiment|hypothesis|correlation|regression|statistical)\b/gi, label: "Analytical depth" },
34
+ { pattern: /\b(best practice|anti[- ]pattern|trade[- ]off|caveat|limitation|edge case)\b/gi, label: "Expert nuance" },
35
+ { pattern: /\b(according to|based on|as described in|per the|RFC|ISO|IEEE|W3C)\b/gi, label: "Standards reference" }
36
+ ];
37
+
38
+ // Authoritativeness signals: citations, institutional backing, recognition
39
+ const AUTHORITY_PATTERNS = [
40
+ { pattern: /\b(cited by|referenced by|published in|featured in|recognized by|awarded)\b/gi, label: "External recognition" },
41
+ { pattern: /\b(university|institute|laboratory|foundation|consortium|association)\b/gi, label: "Institutional backing" },
42
+ { pattern: /\b(peer[- ]reviewed|journal|conference|proceedings|publication)\b/gi, label: "Academic publication" },
43
+ { pattern: /https?:\/\/[^\s)>]+/gi, label: "Source link" },
44
+ { pattern: /\b(source|reference|citation|bibliography|works cited)\b/gi, label: "Citation markers" }
45
+ ];
46
+
47
+ // Trustworthiness signals: transparency, disclosure, accuracy
48
+ const TRUST_PATTERNS = [
49
+ { pattern: /\b(disclosure|disclaimer|conflict of interest|affiliate|sponsored|paid)\b/gi, label: "Disclosure" },
50
+ { pattern: /\b(updated|last reviewed|reviewed by|fact[- ]checked|verified)\b/gi, label: "Content freshness" },
51
+ { pattern: /\b(privacy policy|terms of service|cookie policy|GDPR|CCPA)\b/gi, label: "Policy transparency" },
52
+ { pattern: /\b(correction|erratum|update note|editor'?s note)\b/gi, label: "Correction transparency" },
53
+ { pattern: /\b(contact|email|phone|address|about us|our team)\b/gi, label: "Contact information" }
54
+ ];
55
+
56
+ function countSignals(text, patterns) {
57
+ const matches = [];
58
+ for (const { pattern, label } of patterns) {
59
+ const found = text.match(pattern) || [];
60
+ if (found.length > 0) {
61
+ matches.push({ label, count: found.length, examples: found.slice(0, 3).map((m) => m.trim()) });
62
+ }
63
+ }
64
+ return matches;
65
+ }
66
+
67
+ function computeDimensionScore(matches, maxScore) {
68
+ const totalMatches = matches.reduce((sum, m) => sum + m.count, 0);
69
+ const uniquePatterns = matches.length;
70
+
71
+ let score = 0;
72
+ score += Math.min(uniquePatterns * 15, 60);
73
+ score += Math.min(totalMatches * 3, 40);
74
+
75
+ return Math.min(score, maxScore);
76
+ }
77
+
78
+ function analyzeAuthorPresence(content) {
79
+ const signals = [];
80
+
81
+ if (/<meta[^>]+name=["']author["'][^>]+content=["']([^"']+)["']/i.test(content)) {
82
+ signals.push("meta author tag");
83
+ }
84
+ if (/"author"\s*:\s*\{/i.test(content) || /"author"\s*:\s*"/i.test(content)) {
85
+ signals.push("JSON-LD author");
86
+ }
87
+ if (/\bby\s+[A-Z][a-z]+\s+[A-Z][a-z]+/m.test(content)) {
88
+ signals.push("byline pattern");
89
+ }
90
+ if (/\bdatePublished\b|\bdateModified\b/i.test(content)) {
91
+ signals.push("date signals in structured data");
92
+ }
93
+ if (/<time\b[^>]*datetime/i.test(content)) {
94
+ signals.push("HTML time element");
95
+ }
96
+ if (/\b(reviewed by|edited by|fact[- ]checked by)\b/i.test(content)) {
97
+ signals.push("editorial review signal");
98
+ }
99
+
100
+ return signals;
101
+ }
102
+
103
+ function buildRecommendations(experience, expertise, authority, trust, authorSignals) {
104
+ const recs = [];
105
+
106
+ if (experience.length === 0) {
107
+ recs.push("Add first-person experience narratives, case studies, or hands-on demonstrations to show real-world experience.");
108
+ }
109
+ if (expertise.length < 2) {
110
+ recs.push("Increase technical depth with methodology descriptions, expert nuance (caveats, trade-offs), or standards references.");
111
+ }
112
+ if (authority.length < 2) {
113
+ recs.push("Add more source links, cite external research, or reference institutional backing to strengthen authoritativeness.");
114
+ }
115
+ if (trust.length < 2) {
116
+ recs.push("Improve transparency with disclosure statements, content freshness indicators (last reviewed date), and contact information.");
117
+ }
118
+ if (authorSignals.length === 0) {
119
+ recs.push("Add author attribution: byline, author meta tag, JSON-LD author field, and reviewer credit.");
120
+ }
121
+ if (!authorSignals.includes("date signals in structured data") && !authorSignals.includes("HTML time element")) {
122
+ recs.push("Add datePublished and dateModified to structured data and visible content.");
123
+ }
124
+
125
+ return recs;
126
+ }
127
+
128
+ async function fetchContent(url) {
129
+ const response = await fetch(url, {
130
+ redirect: "follow",
131
+ headers: { "user-agent": "geo-ai-search-optimization/2.2.0" },
132
+ signal: AbortSignal.timeout(10_000)
133
+ });
134
+ if (!response.ok) throw new Error(`Failed to fetch: ${url} (status ${response.status})`);
135
+ return response.text();
136
+ }
137
+
138
+ export async function analyzeEeat(input, options = {}) {
139
+ let rawContent;
140
+ let source;
141
+
142
+ if (/^https?:\/\//i.test(input)) {
143
+ rawContent = await fetchContent(input);
144
+ source = input;
145
+ } else {
146
+ const filePath = path.resolve(input);
147
+ rawContent = await fs.readFile(filePath, "utf8");
148
+ source = filePath;
149
+ }
150
+
151
+ const plainText = extractPlainText(rawContent);
152
+ const wordCount = plainText.split(/\s+/).length;
153
+
154
+ const experience = countSignals(plainText, EXPERIENCE_PATTERNS);
155
+ const expertise = countSignals(plainText, EXPERTISE_PATTERNS);
156
+ const authority = countSignals(plainText, AUTHORITY_PATTERNS);
157
+ const trust = countSignals(plainText, TRUST_PATTERNS);
158
+ const authorSignals = analyzeAuthorPresence(rawContent);
159
+
160
+ const experienceScore = computeDimensionScore(experience, 100);
161
+ const expertiseScore = computeDimensionScore(expertise, 100);
162
+ const authorityScore = computeDimensionScore(authority, 100);
163
+ const trustScore = computeDimensionScore(trust, 100);
164
+
165
+ const overallScore = Math.round(
166
+ experienceScore * 0.2 + expertiseScore * 0.3 + authorityScore * 0.25 + trustScore * 0.25
167
+ );
168
+
169
+ const recommendations = buildRecommendations(experience, expertise, authority, trust, authorSignals);
170
+
171
+ return {
172
+ kind: "geo-eeat",
173
+ source,
174
+ wordCount,
175
+ score: overallScore,
176
+ scoreLabel: getScoreLabel(overallScore),
177
+ dimensions: {
178
+ experience: { score: experienceScore, signals: experience },
179
+ expertise: { score: expertiseScore, signals: expertise },
180
+ authoritativeness: { score: authorityScore, signals: authority },
181
+ trustworthiness: { score: trustScore, signals: trust }
182
+ },
183
+ authorPresence: authorSignals,
184
+ recommendations,
185
+ summary: `E-E-A-T score: ${overallScore}/100 (${getScoreLabel(overallScore)}). Experience: ${experienceScore}, Expertise: ${expertiseScore}, Authority: ${authorityScore}, Trust: ${trustScore}.`
186
+ };
187
+ }
188
+
189
+ function getScoreLabel(score) {
190
+ if (score >= 80) return "Strong";
191
+ if (score >= 60) return "Moderate";
192
+ if (score >= 40) return "Weak";
193
+ return "Very weak";
194
+ }
195
+
196
+ export function renderEeatMarkdown(report) {
197
+ const lines = [
198
+ "# E-E-A-T Analysis",
199
+ "",
200
+ `- Source: \`${report.source}\``,
201
+ `- Overall Score: \`${report.score}/100\` (${report.scoreLabel})`,
202
+ `- Word Count: \`${report.wordCount}\``,
203
+ `- Summary: ${report.summary}`,
204
+ "",
205
+ "## Dimension Scores",
206
+ "",
207
+ `| Dimension | Score | Rating |`,
208
+ `|-----------|-------|--------|`,
209
+ `| Experience | ${report.dimensions.experience.score}/100 | ${getScoreLabel(report.dimensions.experience.score)} |`,
210
+ `| Expertise | ${report.dimensions.expertise.score}/100 | ${getScoreLabel(report.dimensions.expertise.score)} |`,
211
+ `| Authoritativeness | ${report.dimensions.authoritativeness.score}/100 | ${getScoreLabel(report.dimensions.authoritativeness.score)} |`,
212
+ `| Trustworthiness | ${report.dimensions.trustworthiness.score}/100 | ${getScoreLabel(report.dimensions.trustworthiness.score)} |`,
213
+ ""
214
+ ];
215
+
216
+ for (const [dim, data] of Object.entries(report.dimensions)) {
217
+ if (data.signals.length > 0) {
218
+ lines.push(`## ${dim.charAt(0).toUpperCase() + dim.slice(1)} Signals`, "");
219
+ for (const signal of data.signals) {
220
+ lines.push(`- ${signal.label}: \`${signal.count}\` match(es) — e.g. ${signal.examples.map((e) => `"${e}"`).join(", ")}`);
221
+ }
222
+ lines.push("");
223
+ }
224
+ }
225
+
226
+ if (report.authorPresence.length > 0) {
227
+ lines.push("## Author Presence", "");
228
+ for (const signal of report.authorPresence) {
229
+ lines.push(`- ✅ ${signal}`);
230
+ }
231
+ lines.push("");
232
+ } else {
233
+ lines.push("## Author Presence", "", "- ❌ No author attribution detected.", "");
234
+ }
235
+
236
+ lines.push("## Recommendations", "");
237
+ if (report.recommendations.length === 0) {
238
+ lines.push("- E-E-A-T signals are well-represented.");
239
+ } else {
240
+ for (const rec of report.recommendations) {
241
+ lines.push(`- ${rec}`);
242
+ }
243
+ }
244
+ lines.push("");
245
+
246
+ return lines.join("\n");
247
+ }
248
+
249
+ export async function writeEeatOutput(outputPath, content) {
250
+ return writeScanOutput(outputPath, content);
251
+ }
@@ -0,0 +1,281 @@
1
+ import fs from "node:fs/promises";
2
+ import path from "node:path";
3
+ import { writeScanOutput } from "./scan.js";
4
+
5
+ const DATE_PATTERNS = [
6
+ // ISO dates
7
+ { pattern: /\b(20[12]\d-[01]\d-[0-3]\d)/g, format: "ISO" },
8
+ // US format: Month DD, YYYY
9
+ { pattern: /\b(January|February|March|April|May|June|July|August|September|October|November|December)\s+(\d{1,2}),?\s+(20[12]\d)/gi, format: "US" },
10
+ // Structured data dates
11
+ { pattern: /"datePublished"\s*:\s*"([^"]+)"/g, format: "schema-published" },
12
+ { pattern: /"dateModified"\s*:\s*"([^"]+)"/g, format: "schema-modified" },
13
+ { pattern: /"dateCreated"\s*:\s*"([^"]+)"/g, format: "schema-created" },
14
+ // HTML time element
15
+ { pattern: /<time[^>]+datetime=["']([^"']+)["']/gi, format: "html-time" },
16
+ // Meta tags
17
+ { pattern: /name=["'](?:date|article:published_time|article:modified_time)["'][^>]+content=["']([^"']+)["']/gi, format: "meta" },
18
+ { pattern: /content=["']([^"']+)["'][^>]+name=["'](?:date|article:published_time|article:modified_time)["']/gi, format: "meta-reverse" },
19
+ // Common patterns
20
+ { pattern: /\b(?:Published|Updated|Posted|Modified|Created|Reviewed|Last updated)[:\s]+(\d{4}-\d{2}-\d{2}|\w+\s+\d{1,2},?\s+\d{4})/gi, format: "visible-label" }
21
+ ];
22
+
23
+ const MONTH_MAP = {
24
+ january: 0, february: 1, march: 2, april: 3, may: 4, june: 5,
25
+ july: 6, august: 7, september: 8, october: 9, november: 10, december: 11
26
+ };
27
+
28
+ function parseDate(dateStr) {
29
+ // Try ISO first
30
+ const isoMatch = dateStr.match(/(\d{4})-(\d{2})-(\d{2})/);
31
+ if (isoMatch) {
32
+ const d = new Date(isoMatch[0]);
33
+ if (!Number.isNaN(d.getTime())) return d;
34
+ }
35
+
36
+ // Try US format
37
+ const usMatch = dateStr.match(/(January|February|March|April|May|June|July|August|September|October|November|December)\s+(\d{1,2}),?\s+(\d{4})/i);
38
+ if (usMatch) {
39
+ const month = MONTH_MAP[usMatch[1].toLowerCase()];
40
+ if (month !== undefined) {
41
+ return new Date(Number.parseInt(usMatch[3]), month, Number.parseInt(usMatch[2]));
42
+ }
43
+ }
44
+
45
+ // Generic attempt
46
+ const d = new Date(dateStr);
47
+ if (!Number.isNaN(d.getTime()) && d.getFullYear() >= 2000) return d;
48
+ return null;
49
+ }
50
+
51
+ function extractDates(content) {
52
+ const found = [];
53
+ const seen = new Set();
54
+
55
+ for (const { pattern, format } of DATE_PATTERNS) {
56
+ let match;
57
+ const regex = new RegExp(pattern.source, pattern.flags);
58
+ while ((match = regex.exec(content)) !== null) {
59
+ const raw = match[1] || match[0];
60
+ const parsed = parseDate(raw);
61
+ if (parsed && !seen.has(parsed.toISOString().slice(0, 10))) {
62
+ seen.add(parsed.toISOString().slice(0, 10));
63
+ found.push({
64
+ raw: raw.trim().slice(0, 60),
65
+ date: parsed.toISOString().slice(0, 10),
66
+ source: format,
67
+ timestamp: parsed.getTime()
68
+ });
69
+ }
70
+ }
71
+ }
72
+
73
+ return found.sort((a, b) => b.timestamp - a.timestamp);
74
+ }
75
+
76
+ function computeAge(dates) {
77
+ if (dates.length === 0) return null;
78
+
79
+ const now = new Date();
80
+ const published = dates.find((d) => d.source.includes("published") || d.source === "visible-label") || dates[dates.length - 1];
81
+ const modified = dates.find((d) => d.source.includes("modified")) || dates[0];
82
+
83
+ const publishedDate = new Date(published.date);
84
+ const modifiedDate = new Date(modified.date);
85
+
86
+ const publishedAgeDays = Math.floor((now - publishedDate) / (1000 * 60 * 60 * 24));
87
+ const modifiedAgeDays = Math.floor((now - modifiedDate) / (1000 * 60 * 60 * 24));
88
+
89
+ return {
90
+ publishedDate: published.date,
91
+ modifiedDate: modified.date,
92
+ publishedAgeDays,
93
+ modifiedAgeDays,
94
+ publishedAgeLabel: formatAge(publishedAgeDays),
95
+ modifiedAgeLabel: formatAge(modifiedAgeDays),
96
+ isStale: modifiedAgeDays > 180,
97
+ isOld: modifiedAgeDays > 365
98
+ };
99
+ }
100
+
101
+ function formatAge(days) {
102
+ if (days < 7) return `${days} day(s)`;
103
+ if (days < 30) return `${Math.floor(days / 7)} week(s)`;
104
+ if (days < 365) return `${Math.floor(days / 30)} month(s)`;
105
+ return `${Math.round((days / 365) * 10) / 10} year(s)`;
106
+ }
107
+
108
+ function analyzeFreshnessSignals(content) {
109
+ const signals = [];
110
+
111
+ if (/"datePublished"/i.test(content)) signals.push("datePublished in structured data");
112
+ if (/"dateModified"/i.test(content)) signals.push("dateModified in structured data");
113
+ if (/<time\b/i.test(content)) signals.push("HTML <time> element");
114
+ if (/article:published_time/i.test(content)) signals.push("article:published_time meta");
115
+ if (/article:modified_time/i.test(content)) signals.push("article:modified_time meta");
116
+ if (/\b(updated|last reviewed|last modified|revised)\b/i.test(content)) signals.push("Visible freshness label");
117
+ if (/"dateCreated"/i.test(content)) signals.push("dateCreated in structured data");
118
+
119
+ return signals;
120
+ }
121
+
122
+ function computeScore(dates, age, signals) {
123
+ if (dates.length === 0) return 10; // No dates at all
124
+
125
+ let score = 30; // Has at least one date
126
+
127
+ // Signal variety
128
+ score += Math.min(signals.length * 8, 24);
129
+
130
+ // Age-based scoring
131
+ if (age) {
132
+ if (age.modifiedAgeDays <= 30) score += 25;
133
+ else if (age.modifiedAgeDays <= 90) score += 20;
134
+ else if (age.modifiedAgeDays <= 180) score += 12;
135
+ else if (age.modifiedAgeDays <= 365) score += 5;
136
+
137
+ // Has both published and modified
138
+ if (age.publishedDate !== age.modifiedDate) score += 10;
139
+ }
140
+
141
+ // Structured data dates (highest value)
142
+ const hasSchemaDate = dates.some((d) => d.source.startsWith("schema-"));
143
+ if (hasSchemaDate) score += 11;
144
+
145
+ return Math.max(0, Math.min(100, score));
146
+ }
147
+
148
+ function buildRecommendations(dates, age, signals) {
149
+ const recs = [];
150
+
151
+ if (dates.length === 0) {
152
+ recs.push("Add datePublished and dateModified to your structured data (JSON-LD).");
153
+ recs.push("Add visible publication and update dates to your content.");
154
+ return recs;
155
+ }
156
+
157
+ if (!signals.includes("datePublished in structured data")) {
158
+ recs.push("Add datePublished to your JSON-LD structured data.");
159
+ }
160
+ if (!signals.includes("dateModified in structured data")) {
161
+ recs.push("Add dateModified to your JSON-LD structured data.");
162
+ }
163
+ if (!signals.includes("Visible freshness label")) {
164
+ recs.push("Add a visible 'Last updated' or 'Reviewed on' date to your page.");
165
+ }
166
+
167
+ if (age) {
168
+ if (age.isStale) {
169
+ recs.push(`Content was last modified ${age.modifiedAgeLabel} ago. Review and update to maintain freshness.`);
170
+ }
171
+ if (age.isOld) {
172
+ recs.push("Content is over 1 year old. AI systems may deprioritize stale content.");
173
+ }
174
+ if (age.publishedDate === age.modifiedDate) {
175
+ recs.push("datePublished and dateModified are the same. Update dateModified when content changes.");
176
+ }
177
+ }
178
+
179
+ return recs;
180
+ }
181
+
182
+ async function fetchContent(url) {
183
+ const response = await fetch(url, {
184
+ redirect: "follow",
185
+ headers: { "user-agent": "geo-ai-search-optimization/2.2.0" },
186
+ signal: AbortSignal.timeout(10_000)
187
+ });
188
+ if (!response.ok) throw new Error(`Failed to fetch: ${url} (status ${response.status})`);
189
+ return response.text();
190
+ }
191
+
192
+ export async function analyzeFreshness(input, options = {}) {
193
+ let content;
194
+ let source;
195
+
196
+ if (/^https?:\/\//i.test(input)) {
197
+ content = await fetchContent(input);
198
+ source = input;
199
+ } else {
200
+ const filePath = path.resolve(input);
201
+ content = await fs.readFile(filePath, "utf8");
202
+ source = filePath;
203
+ }
204
+
205
+ const dates = extractDates(content);
206
+ const age = computeAge(dates);
207
+ const signals = analyzeFreshnessSignals(content);
208
+ const score = computeScore(dates, age, signals);
209
+ const recommendations = buildRecommendations(dates, age, signals);
210
+
211
+ return {
212
+ kind: "geo-freshness",
213
+ source,
214
+ datesFound: dates.length,
215
+ dates: dates.slice(0, 20),
216
+ age,
217
+ freshnessSignals: signals,
218
+ score,
219
+ scoreLabel: score >= 80 ? "Fresh" : score >= 50 ? "Aging" : score >= 30 ? "Stale" : "No dates",
220
+ recommendations,
221
+ summary: age
222
+ ? `Content freshness: ${score}/100. Published: ${age.publishedDate}. Last modified: ${age.modifiedDate} (${age.modifiedAgeLabel} ago).`
223
+ : `Content freshness: ${score}/100. No publication dates detected.`
224
+ };
225
+ }
226
+
227
+ export function renderFreshnessMarkdown(report) {
228
+ const lines = [
229
+ "# Content Freshness Analysis",
230
+ "",
231
+ `- Source: \`${report.source}\``,
232
+ `- Score: \`${report.score}/100\` (${report.scoreLabel})`,
233
+ `- Dates found: \`${report.datesFound}\``,
234
+ `- Summary: ${report.summary}`,
235
+ ""
236
+ ];
237
+
238
+ if (report.age) {
239
+ lines.push(
240
+ "## Content Age",
241
+ "",
242
+ `- Published: \`${report.age.publishedDate}\` (${report.age.publishedAgeLabel} ago)`,
243
+ `- Last modified: \`${report.age.modifiedDate}\` (${report.age.modifiedAgeLabel} ago)`,
244
+ `- Stale: \`${report.age.isStale}\``,
245
+ `- Old (> 1 year): \`${report.age.isOld}\``,
246
+ ""
247
+ );
248
+ }
249
+
250
+ if (report.freshnessSignals.length > 0) {
251
+ lines.push("## Freshness Signals", "");
252
+ for (const signal of report.freshnessSignals) {
253
+ lines.push(`- ✅ ${signal}`);
254
+ }
255
+ lines.push("");
256
+ }
257
+
258
+ if (report.dates.length > 0) {
259
+ lines.push("## Detected Dates", "");
260
+ for (const date of report.dates.slice(0, 10)) {
261
+ lines.push(`- \`${date.date}\` (${date.source}) — "${date.raw}"`);
262
+ }
263
+ lines.push("");
264
+ }
265
+
266
+ lines.push("## Recommendations", "");
267
+ if (report.recommendations.length === 0) {
268
+ lines.push("- Content freshness signals are well-configured.");
269
+ } else {
270
+ for (const rec of report.recommendations) {
271
+ lines.push(`- ${rec}`);
272
+ }
273
+ }
274
+ lines.push("");
275
+
276
+ return lines.join("\n");
277
+ }
278
+
279
+ export async function writeFreshnessOutput(outputPath, content) {
280
+ return writeScanOutput(outputPath, content);
281
+ }