← back to Restaurant Directory

src/crawl/front_page.ts

237 lines

/**
 * Front-page crawler — lacountyeats.
 *
 * For every facility with a website on file (facility_enrichment.website),
 * fetch the front page with Playwright headless Chromium, persist the raw
 * HTML to disk, and INSERT one row into front_page_audits. The HTML files
 * feed Pass 1 of the ad-social-tracker skill (regex pixel detection, no LLM).
 *
 * - Concurrency ≤ 5 (task spec — Mac2 has 4 other agents running)
 * - 5s navigation timeout (task spec)
 * - 30-day staleness gate (skip facilities audited within last 30 days)
 * - Respects robots.txt with a per-host fetch + 1h memory cache
 * - User-Agent: LACountyEatsBot/1.0
 *
 * Run:
 *   tsx src/crawl/front_page.ts --limit=50    # smoke
 *   tsx src/crawl/front_page.ts --limit=1000  # batch
 */
import 'dotenv/config';
import { mkdirSync, writeFileSync } 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 RAW_DIR = join(ROOT, 'data', 'raw');
mkdirSync(RAW_DIR, { recursive: true });

const CONCURRENCY = Math.min(parseInt(process.env.CRAWL_CONCURRENCY || '5', 10), 5);
const TIMEOUT_MS = parseInt(process.env.CRAWL_TIMEOUT_MS || '5000', 10);
const STALE_DAYS = parseInt(process.env.CRAWL_STALE_DAYS || '30', 10);
const USER_AGENT = 'LACountyEatsBot/1.0 (+https://lacountyeats.com; contact info@lacountyeats.com)';

interface FacilityRow {
  facility_id: string;
  name: string;
  website: string;
}

async function pickQueue(limit: number): Promise<FacilityRow[]> {
  const r = await query<FacilityRow>(
    `
    SELECT f.facility_id, f.name, fe.website
    FROM facility f
    JOIN facility_enrichment fe ON fe.facility_id = f.facility_id
    WHERE fe.website IS NOT NULL
      AND fe.website ~ '^https?://'
      AND NOT EXISTS (
        SELECT 1 FROM front_page_audits a
        WHERE a.facility_id = f.facility_id
          AND a.audited_at > now() - ($2::int || ' days')::interval
      )
    ORDER BY f.facility_id
    LIMIT $1
    `,
    [limit, STALE_DAYS],
  );
  return r.rows;
}

// ── robots.txt: minimal allow/deny check, per-host cache (1h)
type RobotsRule = { allow: string[]; disallow: string[] };
const robotsCache = new Map<string, { rules: RobotsRule; ts: number }>();
const ROBOTS_TTL_MS = 60 * 60 * 1000;

function parseRobots(txt: string): RobotsRule {
  const rules: RobotsRule = { allow: [], disallow: [] };
  let applies = false;
  for (const raw of txt.split(/\r?\n/)) {
    const line = raw.replace(/#.*$/, '').trim();
    if (!line) continue;
    const m = line.match(/^([A-Za-z-]+)\s*:\s*(.*)$/);
    if (!m) continue;
    const [, key, val] = m;
    const k = key.toLowerCase();
    if (k === 'user-agent') {
      const ua = val.trim().toLowerCase();
      applies = ua === '*' || ua.includes('lacountyeatsbot');
    } else if (applies && k === 'disallow' && val) {
      rules.disallow.push(val.trim());
    } else if (applies && k === 'allow' && val) {
      rules.allow.push(val.trim());
    }
  }
  return rules;
}

async function isAllowedByRobots(target: string): Promise<boolean> {
  let u: URL;
  try {
    u = new URL(target);
  } catch {
    return false;
  }
  const host = u.origin;
  const cached = robotsCache.get(host);
  let rules: RobotsRule;
  if (cached && Date.now() - cached.ts < ROBOTS_TTL_MS) {
    rules = cached.rules;
  } else {
    try {
      const res = await fetch(host + '/robots.txt', {
        headers: { 'User-Agent': USER_AGENT },
        signal: AbortSignal.timeout(4000),
      });
      // Treat any non-2xx as "no robots" → allow.
      rules = res.ok ? parseRobots(await res.text()) : { allow: [], disallow: [] };
    } catch {
      rules = { allow: [], disallow: [] };
    }
    robotsCache.set(host, { rules, ts: Date.now() });
  }
  // Longest-match wins between allow/disallow.
  const path = u.pathname + u.search;
  let bestAllow = -1;
  let bestDisallow = -1;
  for (const p of rules.allow) if (p && path.startsWith(p) && p.length > bestAllow) bestAllow = p.length;
  for (const p of rules.disallow) if (p && path.startsWith(p) && p.length > bestDisallow) bestDisallow = p.length;
  if (bestDisallow === -1) return true;
  return bestAllow >= bestDisallow;
}

interface Capture {
  http_status: number | null;
  fetch_ms: number | null;
  raw_html_path: string | null;
  error_message: string | null;
  fetched_url: string;
}

async function captureOne(browser: Browser, f: FacilityRow): Promise<Capture> {
  const out: Capture = {
    http_status: null,
    fetch_ms: null,
    raw_html_path: null,
    error_message: null,
    fetched_url: f.website,
  };

  if (!(await isAllowedByRobots(f.website))) {
    out.error_message = 'blocked_by_robots';
    return out;
  }

  const ctx = await browser.newContext({
    ignoreHTTPSErrors: true,
    userAgent: USER_AGENT,
    viewport: { width: 1280, height: 900 },
  });
  const page = await ctx.newPage();
  const startedAt = Date.now();
  try {
    const resp = await page.goto(f.website, { waitUntil: 'load', timeout: TIMEOUT_MS });
    out.fetch_ms = Date.now() - startedAt;
    if (!resp) {
      out.error_message = 'no_response';
      return out;
    }
    out.http_status = resp.status();
    out.fetched_url = page.url();
    const html = await page.content();
    const ts = Date.now();
    const rel = `raw/facility-${f.facility_id}-${ts}.html`;
    writeFileSync(join(RAW_DIR, `facility-${f.facility_id}-${ts}.html`), html, 'utf8');
    out.raw_html_path = rel;
  } catch (e: unknown) {
    out.error_message = (e instanceof Error ? e.message : String(e)).slice(0, 500);
    out.fetch_ms = Date.now() - startedAt;
  } finally {
    await ctx.close().catch(() => {});
  }
  return out;
}

async function insertAudit(facilityId: string, url: string, c: Capture) {
  await query(
    `
    INSERT INTO front_page_audits
      (facility_id, url, audited_at, raw_html_path, http_status, fetch_ms, error_message)
    VALUES ($1, $2, now(), $3, $4, $5, $6)
    `,
    [facilityId, url, c.raw_html_path, c.http_status, c.fetch_ms, c.error_message],
  );
}

async function main() {
  const argLimit = process.argv.find((a) => a.startsWith('--limit='));
  const limit = argLimit ? parseInt(argLimit.split('=')[1], 10) : 1000;

  const queue = await pickQueue(limit);
  console.log(
    `[crawl] ${queue.length} facilities in queue · concurrency ${CONCURRENCY} · timeout ${TIMEOUT_MS}ms · stale ${STALE_DAYS}d`,
  );
  if (queue.length === 0) {
    console.log('[crawl] nothing to do');
    await pool.end();
    return;
  }

  const browser = await chromium.launch({ headless: true });
  const startedAt = Date.now();
  let done = 0;
  let ok = 0;
  let fail = 0;

  const workQueue = queue.slice();
  await Promise.all(
    Array.from({ length: CONCURRENCY }).map(async () => {
      while (workQueue.length) {
        const f = workQueue.shift()!;
        const c = await captureOne(browser, f);
        await insertAudit(f.facility_id, f.website, c);
        done++;
        if (c.raw_html_path) ok++;
        else fail++;
        if (done % 10 === 0 || done === queue.length) {
          const tail = c.raw_html_path
            ? `last: ${f.name} (${c.http_status || '?'}) ${c.fetch_ms || 0}ms`
            : `last fail: ${f.name} → ${(c.error_message || '').slice(0, 80)}`;
          console.log(`[crawl] ${done}/${queue.length}  ok=${ok} fail=${fail}  · ${tail}`);
        }
      }
    }),
  );

  await browser.close();
  const elapsed = Math.round((Date.now() - startedAt) / 1000);
  console.log('');
  console.log(`[crawl] done in ${elapsed}s · ${ok} ok · ${fail} failed`);
  await pool.end();
}

main().catch((e) => {
  console.error('[crawl]', e);
  process.exit(1);
});