Files
giampy-dogservice/src/lib/seo.ts
T
2026-04-20 12:48:58 +02:00

110 lines
4.2 KiB
TypeScript

export type CopyAnalysis = {
length: number;
words: number;
sentences: number;
avgWordsPerSentence: number;
readability: number; // 0..100, higher = easier
keywordMatches: string[];
missingKeywords: string[];
seoScore: number; // 0..100
geoScore: number; // 0..100
seoTips: string[];
geoTips: string[];
};
const SYLLABLE_VOWELS = /[aeiouàèéìòùAEIOUÀÈÉÌÒÙ]+/g;
function countSyllables(word: string): number {
const matches = word.match(SYLLABLE_VOWELS);
return matches ? matches.length : 1;
}
export function analyzeCopy(text: string, primaryKeywords: string[] = []): CopyAnalysis {
const clean = text.trim();
const length = clean.length;
const wordList = clean.split(/\s+/).filter(Boolean);
const words = wordList.length;
const sentences = Math.max(1, (clean.match(/[.!?]+/g) ?? []).length);
const avgWordsPerSentence = words / sentences;
// Flesch reading ease approximation
const syllables = wordList.reduce((n, w) => n + countSyllables(w), 0);
const asl = words ? words / sentences : 0;
const asw = words ? syllables / words : 0;
const readability = Math.max(0, Math.min(100, Math.round(206.835 - 1.015 * asl - 84.6 * asw)));
const lower = clean.toLowerCase();
const keywordMatches: string[] = [];
const missingKeywords: string[] = [];
for (const kw of primaryKeywords) {
if (!kw) continue;
if (lower.includes(kw.toLowerCase())) keywordMatches.push(kw);
else missingKeywords.push(kw);
}
// SEO tips
const seoTips: string[] = [];
if (length === 0) seoTips.push('Il testo è vuoto.');
if (length > 0 && length < 40) seoTips.push('Testo molto breve: difficile farsi indicizzare.');
if (length > 320) seoTips.push('Testo lungo: valuta di spezzarlo in paragrafi o bullet.');
if (avgWordsPerSentence > 24) seoTips.push('Frasi troppo lunghe: scendi sotto le 24 parole per frase.');
if (readability < 45) seoTips.push('Leggibilità bassa: semplifica lessico e sintassi.');
if (missingKeywords.length && words > 4) {
seoTips.push(`Manca una keyword primaria: ${missingKeywords.slice(0, 2).join(', ')}.`);
}
if (/[A-Z]{6,}/.test(clean)) seoTips.push('Evita testo tutto maiuscolo: penalizza la leggibilità.');
// GEO tips (Generative Engine Optimization)
const geoTips: string[] = [];
const questionMarks = (clean.match(/\?/g) ?? []).length;
const hasNumbers = /\d/.test(clean);
const hasEntities = /\b(sassari|sardegna|coni|cammino di santiago|giampy)\b/i.test(clean);
const firstPerson = /\b(io|mio|mia|miei|mie|sono|faccio|porto|prendo)\b/i.test(clean);
const declarative = sentences >= 1 && /[.!]/.test(clean);
if (words >= 20 && questionMarks === 0)
geoTips.push('Aggiungi una domanda esplicita per farti citare come risposta diretta.');
if (!hasNumbers && words >= 20)
geoTips.push('Inserisci dati concreti (anni, numero clienti, distanze) per aumentare la citabilità.');
if (!hasEntities && words >= 15)
geoTips.push('Nomina entità specifiche (luogo, certificazione, brand) per il grounding generativo.');
if (!firstPerson && words >= 20)
geoTips.push('Usa la prima persona: i motori generativi estraggono più facilmente testimonianze autoriali.');
if (!declarative && words >= 10)
geoTips.push('Termina con punto fermo: le frasi complete vengono citate più spesso.');
// Scores
let seoScore = 100;
seoScore -= seoTips.length * 12;
if (keywordMatches.length === 0 && primaryKeywords.length && words > 4) seoScore -= 10;
if (readability >= 60) seoScore += 5;
seoScore = Math.max(0, Math.min(100, seoScore));
let geoScore = 100;
geoScore -= geoTips.length * 14;
if (hasEntities) geoScore += 5;
if (hasNumbers) geoScore += 3;
geoScore = Math.max(0, Math.min(100, geoScore));
return {
length,
words,
sentences,
avgWordsPerSentence: Math.round(avgWordsPerSentence * 10) / 10,
readability,
keywordMatches,
missingKeywords,
seoScore,
geoScore,
seoTips,
geoTips
};
}
export function aggregateScore(analyses: CopyAnalysis[]): { seo: number; geo: number } {
if (!analyses.length) return { seo: 0, geo: 0 };
const seo = Math.round(analyses.reduce((a, x) => a + x.seoScore, 0) / analyses.length);
const geo = Math.round(analyses.reduce((a, x) => a + x.geoScore, 0) / analyses.length);
return { seo, geo };
}