← back to Ventura Corridor
src/crawl/news_scraper.ts
257 lines
/**
* News scraper — Phase iii.
*
* For every business with a website, fetch the homepage, identify candidate
* news/blog/press links via common selectors, then fetch up to MAX_PER_SITE
* articles and extract title + body. Hash + insert into news_items.
*
* No Playwright — fetch + cheerio. Stays fast on the long tail (153 sites
* resolves in ~3min at 4 concurrent / 500ms politeness window).
*
* Polite by default:
* - One in-flight request per host at a time (per-host queue keyed by hostname)
* - 500ms inter-request gap per host
* - 8s navigation timeout
* - Honors robots.txt Disallow / for User-agent: *
* - Custom UA identifying the project + a contact mailto
*
* Run:
* npm run crawl:news # entire corpus, default concurrency 4
* tsx src/crawl/news_scraper.ts -- --limit=10 # smoke test
* tsx src/crawl/news_scraper.ts -- --business=12345 # one biz only
*/
import 'dotenv/config';
import { createHash } from 'node:crypto';
import * as cheerio from 'cheerio';
import { pool, query } from '../../db/pool.ts';
const UA = 'ventura-corridor/0.1 (news-scraper; +https://leads.venturaclaw.com; contact: steve@designerwallcoverings.com)';
const TIMEOUT_MS = parseInt(process.env.NEWS_TIMEOUT_MS || '8000', 10);
const CONCURRENCY = parseInt(process.env.NEWS_CONCURRENCY || '4', 10);
const HOST_GAP_MS = parseInt(process.env.NEWS_HOST_GAP_MS || '500', 10);
const MAX_PER_SITE = parseInt(process.env.NEWS_MAX_PER_SITE || '3', 10);
const NEWS_KEYWORDS = ['news', 'blog', 'press', 'announcements', 'updates', 'articles', 'posts', 'newsroom', 'media', 'stories', 'journal'];
const BAD_PATH_HINTS = ['login', 'signin', 'signup', 'register', 'cart', 'checkout', 'account', 'wp-admin', 'wp-login', 'feed.xml', '/feed/', '/rss', '.xml', '.json', '/api/', '/static/', '/assets/'];
interface BusinessRow { id: number; name: string; website: string }
const argv = process.argv.slice(2).filter(a => a !== '--');
const limitArg = parseInt(argv.find(a => a.startsWith('--limit='))?.slice(8) || '0', 10) || 0;
const businessArg = parseInt(argv.find(a => a.startsWith('--business='))?.slice(11) || '0', 10) || 0;
const dryRun = argv.includes('--dry-run');
function sleep(ms: number) { return new Promise<void>(r => setTimeout(r, ms)); }
async function fetchText(url: string): Promise<{ ok: boolean; status: number; text?: string; finalUrl?: string }> {
try {
const res = await fetch(url, {
redirect: 'follow',
headers: { 'User-Agent': UA, 'Accept': 'text/html,application/xhtml+xml' },
signal: AbortSignal.timeout(TIMEOUT_MS)
});
if (!res.ok) return { ok: false, status: res.status };
const ctype = res.headers.get('content-type') || '';
if (!ctype.includes('html')) return { ok: false, status: 415 };
const text = await res.text();
return { ok: true, status: res.status, text, finalUrl: res.url };
} catch (e: any) {
return { ok: false, status: 0 };
}
}
const robotsCache = new Map<string, boolean>(); // hostname -> isAllowed
async function isAllowedByRobots(homepageUrl: string): Promise<boolean> {
let host: string;
try { host = new URL(homepageUrl).hostname; } catch { return false; }
if (robotsCache.has(host)) return robotsCache.get(host)!;
const robotsUrl = new URL('/robots.txt', homepageUrl).toString();
const r = await fetchText(robotsUrl);
let allowed = true;
if (r.ok && r.text) {
// Crude: if we see "User-agent: *" then "Disallow: /" with nothing more
// permissive, treat as blocked. Anything else falls through to allowed.
const lines = r.text.split(/\r?\n/).map(l => l.trim());
let inStar = false;
for (const l of lines) {
const m = l.match(/^([Uu]ser-agent|[Dd]isallow|[Aa]llow):\s*(.*)$/);
if (!m) continue;
const k = m[1].toLowerCase(); const v = m[2].trim();
if (k === 'user-agent') inStar = (v === '*');
else if (inStar && k === 'disallow' && v === '/') allowed = false;
else if (inStar && k === 'allow' && v === '/') allowed = true;
}
}
robotsCache.set(host, allowed);
return allowed;
}
function absUrl(base: string, href: string): string | null {
try { return new URL(href, base).toString(); } catch { return null; }
}
function looksLikeNewsLink(href: string, text: string): boolean {
const h = href.toLowerCase(); const t = text.toLowerCase();
if (BAD_PATH_HINTS.some(b => h.includes(b))) return false;
return NEWS_KEYWORDS.some(kw => h.includes('/' + kw) || t.includes(kw));
}
function extractCandidateUrls(homepageHtml: string, homepageUrl: string): string[] {
const $ = cheerio.load(homepageHtml);
const homeHost = (() => { try { return new URL(homepageUrl).hostname; } catch { return ''; } })();
const seen = new Set<string>();
const out: string[] = [];
$('a[href]').each((_, el) => {
const href = String($(el).attr('href') || '');
const text = $(el).text().trim();
if (!href || href.startsWith('#') || href.startsWith('mailto:') || href.startsWith('tel:')) return;
if (!looksLikeNewsLink(href, text)) return;
const abs = absUrl(homepageUrl, href);
if (!abs) return;
let host = '';
try { host = new URL(abs).hostname; } catch { return; }
if (host !== homeHost) return; // same-origin only
if (seen.has(abs)) return;
seen.add(abs);
out.push(abs);
});
return out.slice(0, 12);
}
function extractArticle(html: string, url: string): { title: string; body: string; published?: string } {
const $ = cheerio.load(html);
// Title: <article h1> > <main h1> > og:title > <title>
const title =
$('article h1').first().text().trim()
|| $('main h1').first().text().trim()
|| $('meta[property="og:title"]').attr('content')?.trim()
|| $('h1').first().text().trim()
|| $('title').text().trim()
|| '';
// Body: <article> > <main> > biggest .post / .entry / .content > body
// Strip nav/aside/footer/script/style first.
['nav', 'header', 'footer', 'aside', 'script', 'style', 'noscript', 'form', '.menu', '.nav', '.navigation', '.sidebar'].forEach(sel => $(sel).remove());
let bodyHtml = $('article').first().html()
|| $('main').first().html()
|| $('.entry-content, .post-content, .blog-content, .article-content').first().html()
|| $('body').html()
|| '';
const body = cheerio.load(bodyHtml || '').text().replace(/\s+/g, ' ').trim().slice(0, 8000);
const published = $('meta[property="article:published_time"]').attr('content')
|| $('meta[name="date"]').attr('content')
|| $('time[datetime]').first().attr('datetime')
|| $('time').first().text().trim()
|| undefined;
return { title, body, published };
}
async function pickQueue(): Promise<BusinessRow[]> {
if (businessArg) {
const r = await query<BusinessRow>(`SELECT id, name, website FROM businesses WHERE id = $1`, [businessArg]);
return r.rows;
}
const r = await query<BusinessRow>(`
SELECT b.id, b.name, b.website
FROM businesses b
WHERE b.website IS NOT NULL
AND b.website ~ '^https?://'
ORDER BY
(SELECT COUNT(*) FROM news_items n WHERE n.business_id = b.id) ASC, -- under-covered first
b.id ASC
${limitArg ? `LIMIT ${limitArg}` : ''}
`);
return r.rows;
}
interface ScrapeResult { businessId: number; inserted: number; skipped: number; error?: string }
const lastFetchByHost = new Map<string, number>();
async function politeFetch(url: string): Promise<{ ok: boolean; text?: string; finalUrl?: string; status: number }> {
let host = '';
try { host = new URL(url).hostname; } catch { return { ok: false, status: 0 }; }
const last = lastFetchByHost.get(host) || 0;
const wait = (last + HOST_GAP_MS) - Date.now();
if (wait > 0) await sleep(wait);
const r = await fetchText(url);
lastFetchByHost.set(host, Date.now());
return r;
}
async function scrapeBusiness(b: BusinessRow): Promise<ScrapeResult> {
const out: ScrapeResult = { businessId: b.id, inserted: 0, skipped: 0 };
const allowed = await isAllowedByRobots(b.website).catch(() => true);
if (!allowed) { out.error = 'robots_disallow'; return out; }
const home = await politeFetch(b.website);
if (!home.ok || !home.text) { out.error = `home_${home.status}`; return out; }
const candidates = extractCandidateUrls(home.text, home.finalUrl || b.website);
if (candidates.length === 0) { out.error = 'no_candidates'; return out; }
const articleUrls = candidates.slice(0, MAX_PER_SITE);
for (const url of articleUrls) {
const r = await politeFetch(url);
if (!r.ok || !r.text) { out.skipped++; continue; }
const a = extractArticle(r.text, url);
if (!a.title || a.body.length < 120) { out.skipped++; continue; }
const hashSrc = (a.title + '|' + a.body.slice(0, 1000));
const contentHash = createHash('sha1').update(hashSrc).digest('hex').slice(0, 16);
const excerpt = a.body.slice(0, 280);
let publishedGuess: Date | null = null;
if (a.published) {
const t = new Date(a.published);
if (!isNaN(t.getTime())) publishedGuess = t;
}
if (dryRun) {
out.inserted++;
continue;
}
const ins = await query(`
INSERT INTO news_items (business_id, source_url, title, body, excerpt, published_guess, content_hash)
VALUES ($1, $2, $3, $4, $5, $6, $7)
ON CONFLICT (business_id, content_hash) DO NOTHING
`, [b.id, url, a.title.slice(0, 300), a.body, excerpt, publishedGuess, contentHash]);
if ((ins.rowCount ?? 0) > 0) out.inserted++; else out.skipped++;
}
return out;
}
async function main() {
const queue = await pickQueue();
console.log(`[news_scraper] queue=${queue.length} concurrency=${CONCURRENCY} timeout=${TIMEOUT_MS}ms host_gap=${HOST_GAP_MS}ms${dryRun ? ' DRY-RUN' : ''}`);
let i = 0;
let totals = { inserted: 0, skipped: 0, errored: 0, businesses_with_finds: 0 };
// Simple host-pinned scheduler: spawn CONCURRENCY workers; each pulls
// next item from the queue. Polite-fetch already serializes per-host.
async function worker() {
while (true) {
const idx = i++; if (idx >= queue.length) return;
const b = queue[idx];
try {
const r = await scrapeBusiness(b);
totals.inserted += r.inserted;
totals.skipped += r.skipped;
if (r.error) totals.errored++;
if (r.inserted > 0) totals.businesses_with_finds++;
const tag = r.error ? `· ${r.error}` : `· +${r.inserted} new${r.skipped ? ` / ${r.skipped} dup` : ''}`;
console.log(` [${String(idx + 1).padStart(4)}/${queue.length}] ${b.name.padEnd(40)} ${tag}`);
} catch (e: any) {
totals.errored++;
console.warn(` [${String(idx + 1).padStart(4)}/${queue.length}] ${b.name} · err ${e?.message || e}`);
}
}
}
await Promise.all(Array.from({ length: CONCURRENCY }, () => worker()));
console.log(`[news_scraper] done · inserted=${totals.inserted} skipped=${totals.skipped} errored=${totals.errored} businesses_with_finds=${totals.businesses_with_finds}`);
await pool.end();
}
main().catch(e => { console.error('FATAL', e); pool.end(); process.exit(1); });