← back to Ventura Corridor
src/enrich/score_headlines_heur.ts
125 lines
/**
* Headline voice classifier — pure heuristic, no LLM.
*
* Rough but fast. Buckets:
* 1 TEMPLATE — boilerplate / default-CMS / just-company-name
* 2 FUNCTIONAL — descriptive but flat
* 3 SHARP — specific claim, well-edited
* 4 EXCEPTIONAL — distinctive voice, rhetorical flourish
*
* Run instantly across 472 rows with zero inference cost.
*/
import { query, pool } from '../../db/pool.ts';
const VOICE_LABELS = ['', 'TEMPLATE', 'FUNCTIONAL', 'SHARP', 'EXCEPTIONAL'];
const TEMPLATE_PATTERNS = [
/^welcome\s+to\b/i,
/^home\s*$/i,
/^home\s*page$/i,
/^untitled/i,
/^index\s*(of|page)?/i,
/\|\s*default\s+page$/i,
/lorem ipsum/i,
// Generic JS-error / SPA loading H1s
/there is some problem/i,
/^(oops|error|something went wrong|page not found|404)\b/i,
/^loading\b|^please wait/i,
/^enable javascript|enable cookies/i,
/^just a moment|^one moment/i,
// Corporate-chain locator pages
/^find (a|an|the) .{1,30} (near|in) /i,
/^locations? near you$/i,
/^locate (a|an|the) /i,
];
const FUNCTIONAL_HINTS = [
/^(we\s+(are|provide|offer|specialize))/i,
/\b(serving|servicing|located in|conveniently located)\b/i,
/\b(your local|your trusted|your one-stop)\b/i,
/\b(quality|professional|expert|reliable|premier|leading|best)\b/i,
];
const EXCEPTIONAL_HINTS = [
/[—–]/, // em / en dash
/\.\.\./, // ellipsis
/["']/, // quotation marks
/[?!](?!\s*$)/,// internal question/exclam, not just terminal
/\b(don't|won't|can't|isn't|never|always|nothing|everything)\b/i,
];
function normalize(s: string): string {
return s.toLowerCase().replace(/[^\w\s]/g, ' ').replace(/\s+/g, ' ').trim();
}
function classify(headline: string, companyName: string): number {
const h = headline.trim();
const wordCount = h.split(/\s+/).length;
const nh = normalize(h);
const nn = normalize(companyName);
// 1. TEMPLATE
for (const re of TEMPLATE_PATTERNS) if (re.test(h)) return 1;
// Just-company-name (or very close to it)
if (nh === nn) return 1;
if (nh.length > 0 && nn.length > 0 && (nh.includes(nn) || nn.includes(nh)) && Math.abs(nh.length - nn.length) < 8) return 1;
// Address-listing template: "[Brand] [number] [Street] in [City]" — Domino's, Starbucks, etc.
if (/\b\d{2,5}\s+[A-Za-z]+\s+(blvd|ave|st|rd|way|drive|dr|street|avenue|boulevard)\b/i.test(h)) return 1;
if (/^\S.{0,40}\s+\d+\s+\S+\s+(blvd|ave|st|rd)\s+in\s+/i.test(h)) return 1;
// Headline starts with the company name AND is short — basically a label
if (nn.length > 3 && nh.startsWith(nn) && wordCount <= 6) return 1;
// Single short word that looks like a default
if (wordCount === 1 && nh.length < 25) return 1;
// 4. EXCEPTIONAL — has rhetorical signal AND reasonable length
let exceptionalScore = 0;
for (const re of EXCEPTIONAL_HINTS) if (re.test(h)) exceptionalScore++;
if (exceptionalScore >= 2 && wordCount >= 4 && wordCount <= 18) return 4;
if (exceptionalScore >= 1 && wordCount >= 5 && wordCount <= 12 && /[.!?]$/.test(h.trim()) === false) {
// tighter check — has voice + concise
if (!/(welcome|home|index|page)/i.test(h)) return 4;
}
// 2. FUNCTIONAL — has functional hints
for (const re of FUNCTIONAL_HINTS) if (re.test(h)) return 2;
// 3. SHARP — 4-15 words, no boilerplate, has a noun-claim
if (wordCount >= 4 && wordCount <= 15) {
// bonus: contains a number or specific claim
if (/\b(\d+|first|only|exclusive|unique|fresh|same-day|signature)\b/i.test(h)) return 3;
if (wordCount >= 5 && wordCount <= 10) return 3;
}
// Default to FUNCTIONAL when nothing else fires
return 2;
}
async function main() {
const r = await query<{ business_id: number; headline: string; name: string }>(`
SELECT e.business_id, e.headline, b.name
FROM business_enrichment e
JOIN businesses b ON b.id = e.business_id
WHERE e.headline IS NOT NULL AND length(e.headline) >= 4
ORDER BY e.business_id
`);
console.log(`[hvh] classifying ${r.rowCount} headlines · heuristic`);
const counts = [0, 0, 0, 0, 0];
for (const row of r.rows) {
const v = classify(row.headline, row.name);
counts[v]++;
await query(
`UPDATE business_enrichment SET headline_voice=$1, headline_voice_label=$2, headline_voice_at=now() WHERE business_id=$3`,
[v, VOICE_LABELS[v], row.business_id]
);
}
console.log(`[hvh] done`);
console.log(` TEMPLATE : ${counts[1]}`);
console.log(` FUNCTIONAL : ${counts[2]}`);
console.log(` SHARP : ${counts[3]}`);
console.log(` EXCEPTIONAL : ${counts[4]}`);
await pool.end();
}
main().catch((e) => { console.error('[hvh]', e); process.exit(1); });