← back to Abrams Report
scrapers/scrape-html.js
224 lines
'use strict';
const fetch = require('node-fetch');
const cheerio = require('cheerio');
const crypto = require('crypto');
const { upsertHeadline, logScrape } = require('./db');
const { SCRAPE_SOURCES } = require('./sources');
const { fetchViaBrowserbase } = require('./fetch-browserbase');
// Use a real browser UA — many sites 403/404 a bot UA
const BROWSER_UA =
'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 ' +
'(KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36';
const FETCH_OPTS = {
timeout: 25000,
redirect: 'follow',
headers: {
'User-Agent': BROWSER_UA,
'Accept':
'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8',
'Accept-Language': 'en-US,en;q=0.5',
},
};
function makeUid(source, url) {
return crypto.createHash('sha1').update(source + '|' + url).digest('hex').slice(0, 16);
}
function isBreaking(title) {
return /breaking|emergency|urgent|just in/i.test(title);
}
function isDeveloping(title) {
return /developing|exclusive|update:|report:/i.test(title);
}
function resolveUrl(href, baseUrl) {
try {
return new URL(href, baseUrl).href;
} catch {
return null;
}
}
function cleanTitle(text) {
return String(text || '')
.replace(/[\r\n\t]/g, ' ')
.replace(/\s+/g, ' ')
.trim()
.slice(0, 200);
}
function slugToTitle(url) {
try {
const u = new URL(url);
const last = u.pathname.split('/').filter(Boolean).pop() || '';
return last
.replace(/[-_]+/g, ' ')
.replace(/\.\w+$/, '')
.replace(/\b\w/g, c => c.toUpperCase())
.slice(0, 100);
} catch {
return '';
}
}
async function fetchHtml(url, useBrowserbase = false) {
if (useBrowserbase) {
return fetchViaBrowserbase(url);
}
const res = await fetch(url, FETCH_OPTS);
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return res.text();
}
// ─── Sitemap parsing (Cole & Son uses this) ─────────────────────────────────
function parseSitemapXml(xml, src) {
// Returns array of { title, url }
const $ = cheerio.load(xml, { xmlMode: true });
const items = [];
$('url').each((_, el) => {
const loc = $(el).find('loc').first().text().trim();
if (!loc) return;
if (src.linkFilter && !src.linkFilter.test(loc)) return;
if (src.excludeFilter && src.excludeFilter.test(loc)) return;
let title = '';
if (src.sitemapTitleFromImage) {
// <image:title> child gives a human-readable title
const imgTitle = $(el).find('image\\:title, title').first().text().trim();
if (imgTitle) title = imgTitle;
}
if (!title) title = slugToTitle(loc);
if (!title || title.length < 4) return;
const lastmod = $(el).find('lastmod').first().text().trim();
items.push({ title, url: loc, lastmod });
});
// Sort newest first if we have lastmod
items.sort((a, b) => (b.lastmod || '').localeCompare(a.lastmod || ''));
return items;
}
async function parseSitemapIndex(xml, src) {
// For Scalamandre: pick newest child sitemaps that match filter, then read entries
const $ = cheerio.load(xml, { xmlMode: true });
const childSitemaps = [];
$('sitemap').each((_, el) => {
const loc = $(el).find('loc').first().text().trim();
const lastmod = $(el).find('lastmod').first().text().trim();
if (!loc) return;
if (src.sitemapFilter && !src.sitemapFilter.test(loc)) return;
childSitemaps.push({ loc, lastmod });
});
childSitemaps.sort((a, b) => (b.lastmod || '').localeCompare(a.lastmod || ''));
// Limit to first 2 child sitemaps for cost
const items = [];
for (const cs of childSitemaps.slice(0, 2)) {
try {
const childXml = await fetchHtml(cs.loc);
items.push(...parseSitemapXml(childXml, src));
} catch (e) {
console.error(`[sitemap-index] ${src.key} child ${cs.loc} failed: ${e.message}`);
}
}
return items;
}
// ─── HTML scrape with selector + filters ────────────────────────────────────
function parseHtml(html, src) {
const $ = cheerio.load(html);
const seen = new Set();
const items = [];
$(src.selector).each((_, el) => {
const href = $(el).attr('href');
if (!href) return;
if (href.startsWith('#')) return;
const absUrl = resolveUrl(href, src.baseUrl);
if (!absUrl) return;
if (seen.has(absUrl)) return;
if (src.linkFilter && !src.linkFilter.test(absUrl)) return;
if (src.excludeFilter && src.excludeFilter.test(absUrl)) return;
seen.add(absUrl);
// Title: prefer anchor text, then <img alt>, then URL slug
let title = cleanTitle($(el).text());
if (!title || title.length < 5) {
title = cleanTitle($(el).find('img').attr('alt') || '');
}
if (!title || title.length < 5) {
title = slugToTitle(absUrl);
}
if (!title || title.length < 5) return;
items.push({ title, url: absUrl });
});
return items;
}
async function scrapeSite(src) {
let items = [];
let errorMsg = null;
try {
const html = await fetchHtml(src.url, !!src.browserbase);
if (src.parser === 'sitemap') {
items = parseSitemapXml(html, src);
} else if (src.parser === 'sitemap-index') {
items = await parseSitemapIndex(html, src);
} else {
items = parseHtml(html, src);
}
} catch (err) {
errorMsg = err.message;
console.error(`[html] ${src.key} failed: ${err.message}`);
}
// Cap at 6 most recent per source (per spec)
items = items.slice(0, 6);
let added = 0;
for (const it of items) {
const uid = makeUid(src.key, it.url);
added += upsertHeadline({
uid,
title: it.title,
url: it.url,
source: src.name,
category: src.category,
pub_date: null,
is_breaking: isBreaking(it.title) ? 1 : 0,
is_developing: isDeveloping(it.title) ? 1 : 0,
is_top: 0,
});
}
logScrape(src.key, added, errorMsg);
return added;
}
async function scrapeAllHtml(category = null) {
const sources = category
? SCRAPE_SOURCES.filter(s => s.category === category)
: SCRAPE_SOURCES;
const results = [];
for (const src of sources) {
const count = await scrapeSite(src);
console.log(`[html] ${src.name}: +${count}`);
results.push({ source: src.key, added: count });
// Polite delay; Browserbase sources need a longer beat
await new Promise(r => setTimeout(r, src.browserbase ? 1500 : 500));
}
return results;
}
module.exports = { scrapeAllHtml, scrapeSite };