← back to Small Business Builder
src/scrape/website_meta.js
209 lines
// Fetch a small-business website and pull:
// - <title>, meta description, OG/Twitter meta (title/description/image/site_name)
// - hero image (og:image first, else first <img> with width/height)
// - color palette (best-effort — extracts inline hex colors + image-derived if palette skill present;
// here we use a light heuristic: scrape inline `color:` / `background-color:` hex values from the HTML+CSS)
// - phone (tel: links + first 10-digit pattern)
// - email (mailto: links)
//
// Local-only fetch w/ undici. No headless browser.
import { request } from 'undici';
import * as cheerio from 'cheerio';
import { promises as dns } from 'node:dns';
import net from 'node:net';
const UA = 'Mozilla/5.0 (Macintosh; Intel Mac OS X 14_0) AppleWebKit/537.36 ' +
'(KHTML, like Gecko) Chrome/124.0 Safari/537.36 small-business-builder/0.1';
// SSRF guard — reject loopback / link-local / RFC1918 / IPv6-equivalent. Without
// this, the scrape endpoint becomes a probe for internal services
// (127.0.0.1, 169.254.169.254 cloud metadata, 10/8, 172.16/12, 192.168/16, ::1, fc00::/7).
function isPrivateAddr(addr) {
if (!addr) return true;
if (net.isIPv4(addr)) {
const [a, b] = addr.split('.').map(Number);
if (a === 10) return true;
if (a === 127) return true;
if (a === 0) return true;
if (a === 169 && b === 254) return true;
if (a === 172 && b >= 16 && b <= 31) return true;
if (a === 192 && b === 168) return true;
if (a === 100 && b >= 64 && b <= 127) return true; // CGNAT
if (a >= 224) return true; // multicast / reserved
return false;
}
if (net.isIPv6(addr)) {
const lo = addr.toLowerCase();
if (lo === '::1' || lo === '::') return true;
if (lo.startsWith('fe80:') || lo.startsWith('fc') || lo.startsWith('fd')) return true;
if (lo.startsWith('::ffff:')) {
const v4 = lo.slice(7);
if (net.isIPv4(v4)) return isPrivateAddr(v4);
}
return false;
}
return true;
}
async function assertPublicHost(hostname) {
// If literal IP, check directly. Otherwise resolve all A/AAAA and reject if ANY
// address is private (defends against rebinding-style multi-record DNS).
if (net.isIP(hostname)) {
if (isPrivateAddr(hostname)) throw new Error('host not allowed');
return;
}
let addrs = [];
try {
addrs = await dns.lookup(hostname, { all: true, verbatim: true });
} catch {
throw new Error('host not resolvable');
}
if (!addrs.length) throw new Error('host not resolvable');
for (const { address } of addrs) {
if (isPrivateAddr(address)) throw new Error('host not allowed');
}
}
export async function fetchHtml(url, timeoutMs = 15000) {
let parsed;
try { parsed = new URL(url); } catch { throw new Error('invalid url'); }
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
throw new Error('only http/https supported');
}
await assertPublicHost(parsed.hostname);
// undici v7 dropped maxRedirections on the low-level request(); use global
// fetch() which transparently follows up to 20 redirects and re-validates the
// final hostname is public via assertPublicHost only at the entry. Anything
// sketchier gets rejected.
const ac = new AbortController();
const t = setTimeout(() => ac.abort(), timeoutMs);
try {
const res = await fetch(url, {
method: 'GET',
headers: { 'user-agent': UA, accept: 'text/html,*/*' },
redirect: 'follow',
signal: ac.signal,
});
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const finalUrl = res.url || url;
if (finalUrl !== url) {
try { await assertPublicHost(new URL(finalUrl).hostname); } catch { throw new Error('redirect to non-public host'); }
}
// Capture iframe-embedability so the website-analysis result page can show a
// "preview blocked — open in new tab" placeholder instead of a blank white
// iframe when X-Frame-Options/CSP frame-ancestors disallow embedding.
const xfo = (res.headers.get('x-frame-options') || '').toLowerCase();
const csp = (res.headers.get('content-security-policy') || '').toLowerCase();
const fa = csp.match(/frame-ancestors\s+([^;]+)/);
const faVal = fa ? fa[1].trim() : '';
let iframeBlocked = false;
if (xfo.includes('deny') || xfo.includes('sameorigin')) iframeBlocked = true;
if (faVal && !faVal.includes('*')) iframeBlocked = true; // anything other than wildcard blocks us cross-origin
const body = await res.text();
return { html: body, url: finalUrl, status: res.status, iframe_blocked: !!iframeBlocked };
} finally {
clearTimeout(t);
}
}
function abs(base, href) {
try { return new URL(href, base).toString(); } catch { return null; }
}
function extractHexColors(text, limit = 32) {
// Match #abc, #abcdef, rgb(...) — but normalize to hex-ish key
const seen = new Map();
const re = /#([0-9a-fA-F]{3}|[0-9a-fA-F]{6})\b/g;
let m;
while ((m = re.exec(text)) && seen.size < limit) {
let h = m[1].toLowerCase();
if (h.length === 3) h = h.split('').map(c => c + c).join('');
const key = '#' + h;
seen.set(key, (seen.get(key) || 0) + 1);
}
// Sort by frequency descending
return [...seen.entries()].sort((a, b) => b[1] - a[1]).map(([c]) => c);
}
function extractPhone(text) {
// tel: first — that's the only unambiguous signal. Then look for an area-code
// pattern that's clearly a phone (parens around area code OR explicit
// `-`/`.` separator). The previous fallback matched any three groups of digits
// separated by whitespace — timestamps, image dimensions, and CSS values
// (`200 300 4000`) all qualified.
const tel = text.match(/tel:([+\d\-\s().]{7,})/i);
if (tel) return tel[1].trim();
const m = text.match(/(?:\+?1[\s.\-]?)?(?:\((\d{3})\)\s*|(\d{3})[.\-])(\d{3})[.\-](\d{4})/);
if (m) {
const area = m[1] || m[2];
return `(${area}) ${m[3]}-${m[4]}`;
}
return null;
}
function extractEmail(text) {
const m = text.match(/mailto:([^"'>\s]+)/i);
if (m) return m[1].trim();
const e = text.match(/\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}\b/);
return e ? e[0] : null;
}
export async function scrapeWebsiteMeta(url) {
if (!/^https?:\/\//i.test(url)) url = 'https://' + url;
const { html, iframe_blocked } = await fetchHtml(url);
const $ = cheerio.load(html);
const meta = (name) =>
$(`meta[property="${name}"]`).attr('content') ||
$(`meta[name="${name}"]`).attr('content') ||
null;
const title = meta('og:title') || $('title').first().text().trim() || null;
const description = meta('og:description') || meta('description') || meta('twitter:description') || null;
const siteName = meta('og:site_name') || null;
let heroImage = meta('og:image') || meta('twitter:image') || null;
if (heroImage) heroImage = abs(url, heroImage);
if (!heroImage) {
// first reasonably-large <img> — must declare width >=400 AND height >=300.
// (Previous predicate inverted the truthiness so that a missing width/height
// accepted 1x1 tracking pixels as the hero.)
$('img').each((_, el) => {
if (heroImage) return false;
const src = $(el).attr('src');
if (!src) return;
const w = parseInt($(el).attr('width') || '0', 10);
const h = parseInt($(el).attr('height') || '0', 10);
if (w >= 400 && h >= 300) {
heroImage = abs(url, src);
return false;
}
});
}
// Inline-CSS palette (don't fetch external CSS for now — keep it light + safe)
const inlineCss = $('style').map((_, el) => $(el).text()).get().join('\n');
const palette = extractHexColors(inlineCss + '\n' + html);
// Phone + email — search visible text + tel/mailto links
const visibleText = $('body').text().replace(/\s+/g, ' ');
const phone = extractPhone(html) || extractPhone(visibleText);
const email = extractEmail(html) || extractEmail(visibleText);
return {
url,
title,
description,
site_name: siteName,
hero_image: heroImage,
palette,
color_primary: palette[0] || null,
color_accent: palette[1] || null,
phone,
email,
iframe_blocked: !!iframe_blocked,
};
}