← back to Nationalrealestate
src/crawl/firm_front_page.ts
217 lines
/**
* Firm front-page crawler — Playwright chromium, concurrency 5.
*
* For firm_site rows with a url and crawl_status IS NULL: check robots.txt
* (skip domains that disallow all for *), load the homepage (15s timeout),
* record http_status + <title> + IDX listing-search heuristic + screenshot,
* and extract mailto:/tel:/contact-page links into firm_contacts.
*
* Run: npm run crawl:firms # default 100
* tsx src/crawl/firm_front_page.ts -- --limit=250
*
* $0 — local Playwright, public homepages only.
*/
import 'dotenv/config';
import { mkdirSync } from 'node:fs';
import { join } from 'node:path';
import { chromium, type Browser } from 'playwright';
import { pool, query } from '../../db/pool.ts';
const ROOT = new URL('../..', import.meta.url).pathname;
const SCREEN_DIR = join(ROOT, 'data', 'screenshots');
mkdirSync(SCREEN_DIR, { recursive: true });
const CONCURRENCY = parseInt(process.env.CRAWL_CONCURRENCY || '5', 10);
const TIMEOUT_MS = parseInt(process.env.CRAWL_TIMEOUT_MS || '15000', 10);
const UA = 'Mozilla/5.0 (Macintosh; Intel Mac OS X 14_0) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36 usrealestate/0.1 (front-page-audit; +local)';
interface SiteRow { site_id: number; firm_id: number; url: string }
const robotsCache = new Map<string, boolean>(); // host -> allowed
async function robotsAllows(url: string): Promise<boolean> {
let h: string;
try { h = new URL(url).hostname; } catch { return false; }
const cached = robotsCache.get(h);
if (cached !== undefined) return cached;
let allowed = true;
try {
const res = await fetch(`https://${h}/robots.txt`, {
headers: { 'User-Agent': UA },
signal: AbortSignal.timeout(8000),
redirect: 'follow',
});
if (res.ok) {
const txt = await res.text();
// Only honor the wildcard group; we just need "is the homepage off-limits".
const lines = txt.split(/\r?\n/).map(l => l.replace(/#.*$/, '').trim());
let inStar = false;
for (const line of lines) {
const ua = line.match(/^user-agent:\s*(.+)$/i);
if (ua) { inStar = ua[1].trim() === '*'; continue; }
if (!inStar) continue;
const dis = line.match(/^disallow:\s*(.*)$/i);
if (dis && dis[1].trim() === '/') { allowed = false; break; }
}
}
} catch {} // unreachable robots.txt -> assume allowed (public homepage)
robotsCache.set(h, allowed);
return allowed;
}
interface Capture {
httpStatus: number | null;
title: string | null;
hasIdx: boolean | null;
screenshotPath: string | null;
emails: string[];
phones: string[];
contactUrls: string[];
finalUrl: string | null;
error: string | null;
}
async function captureOne(browser: Browser, s: SiteRow): Promise<Capture> {
const out: Capture = { httpStatus: null, title: null, hasIdx: null, screenshotPath: null, emails: [], phones: [], contactUrls: [], finalUrl: null, error: null };
const ctx = await browser.newContext({
ignoreHTTPSErrors: true,
userAgent: UA,
viewport: { width: 1280, height: 900 },
});
const page = await ctx.newPage();
try {
const resp = await page.goto(s.url, { waitUntil: 'domcontentloaded', timeout: TIMEOUT_MS });
if (!resp) { out.error = 'no response'; return out; }
await page.waitForTimeout(1500); // let lazy-rendered nav/search widgets appear
out.httpStatus = resp.status();
out.finalUrl = page.url();
out.title = ((await page.title()) || '').trim().slice(0, 500) || null;
// Passed as a string so tsx doesn't inject __name() helpers into the page.
const meta: any = await page.evaluate(`(function(){
var text = (document.body && document.body.innerText || '').slice(0, 40000);
var idxText = /search homes|search properties|property search|properties for sale|homes for sale|view listings|our listings|all listings|featured listings|find your (dream )?home|idx/i.test(text);
var links = Array.prototype.slice.call(document.querySelectorAll('a[href]'));
var emails = [], phones = [], contacts = [];
var idxHref = false;
for (var i = 0; i < links.length; i++) {
var href = links[i].getAttribute('href') || '';
if (/^mailto:/i.test(href)) {
var em = href.replace(/^mailto:/i, '').split('?')[0].trim().toLowerCase();
if (em && emails.indexOf(em) < 0) emails.push(em);
} else if (/^tel:/i.test(href)) {
var ph = href.replace(/^tel:/i, '').split('?')[0].trim();
if (ph && phones.indexOf(ph) < 0) phones.push(ph);
} else {
try {
var u = new URL(links[i].href, location.href);
if (/\\/(contact|contact-us|contactus)\\b/i.test(u.pathname) && u.hostname === location.hostname) {
if (contacts.indexOf(u.href) < 0) contacts.push(u.href);
}
if (/idx|property-search|propertysearch|search.*(home|propert|listing)|listings?\\b|homes-for-sale/i.test(u.pathname + u.search)) idxHref = true;
} catch (e) {}
}
}
var idxWidget = !!document.querySelector('[class*="idx" i], [id*="idx" i], [class*="property-search" i], input[placeholder*="search" i][placeholder*="address" i], input[placeholder*="city" i]');
return { hasIdx: !!(idxText || idxHref || idxWidget),
emails: emails.slice(0, 10), phones: phones.slice(0, 10), contacts: contacts.slice(0, 3) };
})()`);
out.hasIdx = meta.hasIdx;
out.emails = meta.emails;
out.phones = meta.phones;
out.contactUrls = meta.contacts;
const shotName = `firm-${s.firm_id}.jpg`;
await page.screenshot({ path: join(SCREEN_DIR, shotName), type: 'jpeg', quality: 70, fullPage: false });
out.screenshotPath = `screenshots/${shotName}`;
} catch (e: any) {
out.error = (e?.message || String(e)).slice(0, 300);
} finally {
await ctx.close().catch(() => {});
}
return out;
}
async function persist(s: SiteRow, c: Capture) {
const status = c.error ? 'error:' + c.error.slice(0, 120) : 'ok';
await query(
`UPDATE firm_site SET http_status = $2, title = $3, has_idx_listings = $4,
screenshot_path = $5, crawl_status = $6, crawled_at = NOW()
WHERE id = $1`,
[s.site_id, c.httpStatus, c.title, c.hasIdx, c.screenshotPath, status],
);
const src = c.finalUrl || s.url;
const rows: [string, string][] = [
...c.emails.map(v => ['email', v] as [string, string]),
...c.phones.map(v => ['phone', v] as [string, string]),
...c.contactUrls.map(v => ['contact_url', v] as [string, string]),
];
for (const [kind, value] of rows) {
await query(
`INSERT INTO firm_contacts (firm_id, kind, value, source_url) VALUES ($1, $2, $3, $4)
ON CONFLICT (firm_id, kind, value) DO NOTHING`,
[s.firm_id, kind, value.slice(0, 500), src.slice(0, 600)],
);
}
return rows.length;
}
async function main() {
const argLimit = process.argv.find(a => a.startsWith('--limit='));
const limit = argLimit ? parseInt(argLimit.split('=')[1], 10) : 100;
const run = await query<{ id: number }>(
`INSERT INTO ingest_runs (source, notes) VALUES ('firm_crawl', $1) RETURNING id`,
[`front-page crawl, batch limit ${limit}, concurrency ${CONCURRENCY}`],
);
const runId = run.rows[0].id;
const r = await query<SiteRow>(`
SELECT s.id AS site_id, s.firm_id, s.url
FROM firm_site s JOIN firm f ON f.id = s.firm_id
WHERE s.url IS NOT NULL AND s.crawl_status IS NULL
ORDER BY f.agent_count DESC NULLS LAST, s.id
LIMIT $1
`, [limit]);
const queue = r.rows.slice();
console.log(`[crawl] run #${runId} · ${queue.length} sites · concurrency ${CONCURRENCY} · timeout ${TIMEOUT_MS}ms`);
const browser = await chromium.launch({ headless: true });
let done = 0, ok = 0, fail = 0, robots = 0, contacts = 0, idx = 0;
await Promise.all(Array.from({ length: CONCURRENCY }).map(async () => {
while (queue.length) {
const s = queue.shift()!;
if (!(await robotsAllows(s.url))) {
robots++;
await query(`UPDATE firm_site SET crawl_status = 'robots_disallow', crawled_at = NOW() WHERE id = $1`, [s.site_id]);
console.log(`[crawl] ⛔ robots disallow: ${s.url}`);
continue;
}
const c = await captureOne(browser, s);
contacts += await persist(s, c);
done++;
if (c.error) fail++; else { ok++; if (c.hasIdx) idx++; }
if (done % 10 === 0 || !queue.length) {
console.log(`[crawl] ${done} done · ok=${ok} fail=${fail} robots=${robots} idx=${idx} contacts=${contacts} · last: ${s.url} ${c.error ? '⚠ ' + c.error.slice(0, 60) : '(' + c.httpStatus + ')'}`);
}
}
}));
await browser.close();
await query(
`UPDATE ingest_runs SET finished_at = NOW(), status = 'ok', rows_upserted = $2, rows_skipped = $3,
notes = notes || $4 WHERE id = $1`,
[runId, ok, fail + robots, ` · ${ok} ok, ${fail} errors, ${robots} robots-skipped, ${idx} idx, ${contacts} contacts`],
);
console.log(`[crawl] done · ${ok} ok · ${fail} errors · ${robots} robots-skipped · ${idx} with IDX · ${contacts} contact rows`);
await pool.end();
}
main().catch(async (e) => {
console.error('[crawl]', e);
try { await pool.end(); } catch {}
process.exit(1);
});