← back to Nationalrealestate

src/ingest/listings/shared.ts

184 lines

/**
 * Shared listing-ingest helpers: polite fetch (UA + jitter), robots.txt honoring,
 * JSON-LD extraction, firm resolution by domain, and county-centroid region crosswalk.
 */
import { query } from '../../../db/pool.ts';

export const UA =
  'Mozilla/5.0 (compatible; USRealEstate-Research/1.0; +https://usrealestate.local/about)';

const jitter = () => 2000 + Math.floor(Math.random() * 2000); // 2–4s
export const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));

export async function politeFetch(url: string): Promise<{ ok: boolean; status: number; body: string }> {
  await sleep(jitter());
  try {
    const res = await fetch(url, { redirect: 'follow', headers: { 'user-agent': UA } });
    const body = res.ok ? await res.text() : '';
    return { ok: res.ok, status: res.status, body };
  } catch {
    return { ok: false, status: 0, body: '' };
  }
}

/** Raw fetch (no jitter) for sitemaps/robots — still polite UA, still honors status. */
export async function rawFetch(url: string): Promise<{ ok: boolean; status: number; body: string }> {
  try {
    const res = await fetch(url, { redirect: 'follow', headers: { 'user-agent': UA } });
    const body = res.ok ? await res.text() : '';
    return { ok: res.ok, status: res.status, body };
  } catch {
    return { ok: false, status: 0, body: '' };
  }
}

/**
 * Minimal robots.txt gate for `User-agent: *`. Returns a predicate that is true when a
 * path is ALLOWED. Honors Disallow with `*` wildcards and Allow overrides (longest-match).
 * A missing/unreachable robots.txt = allow-all (standard behavior).
 */
export async function robotsGate(host: string): Promise<(path: string) => boolean> {
  const { ok, body } = await rawFetch(`https://${host}/robots.txt`);
  if (!ok) return () => true;
  const rules: { allow: boolean; pattern: string }[] = [];
  let inStar = false;
  for (const raw of body.split('\n')) {
    const line = raw.replace(/#.*/, '').trim();
    if (!line) continue;
    const m = line.match(/^(user-agent|allow|disallow)\s*:\s*(.*)$/i);
    if (!m) continue;
    const [, k, v] = m;
    const key = k.toLowerCase();
    if (key === 'user-agent') { inStar = v.trim() === '*'; continue; }
    if (!inStar) continue;
    if (key === 'allow' && v) rules.push({ allow: true, pattern: v.trim() });
    if (key === 'disallow' && v) rules.push({ allow: false, pattern: v.trim() });
  }
  const toRe = (p: string) =>
    new RegExp('^' + p.replace(/[.+?^${}()|[\]\\]/g, '\\$&').replace(/\*/g, '.*'));
  const compiled = rules.map((r) => ({ allow: r.allow, re: toRe(r.pattern), len: r.pattern.length }));
  return (path: string) => {
    let best: { allow: boolean; len: number } | null = null;
    for (const r of compiled) {
      if (r.re.test(path) && (!best || r.len > best.len)) best = { allow: r.allow, len: r.len };
    }
    return best ? best.allow : true;
  };
}

/** Extract all parseable JSON-LD objects (flattening @graph + arrays) from a page. */
export function extractJsonLd(html: string): any[] {
  const out: any[] = [];
  // quotes optional: some sites (e.g. Weichert) emit `<script type=application/ld+json>` unquoted
  const re = /<script[^>]*type=["']?application\/ld\+json["']?[^>]*>([\s\S]*?)<\/script>/gi;
  let m: RegExpExecArray | null;
  while ((m = re.exec(html))) {
    let parsed: any;
    try { parsed = JSON.parse(m[1].trim()); } catch { continue; }
    const items = Array.isArray(parsed) ? parsed : [parsed];
    for (const it of items) {
      if (it && it['@graph'] && Array.isArray(it['@graph'])) out.push(...it['@graph']);
      else out.push(it);
    }
  }
  return out;
}

export function typeMatches(node: any, ...types: string[]): boolean {
  const t = node?.['@type'];
  const arr = Array.isArray(t) ? t : [t];
  return arr.some((x) => types.includes(String(x)));
}

/** "300,000.00" | "$97,500" | 97500 -> 97500. null on junk. */
export function num(v: unknown): number | null {
  if (v == null) return null;
  const n = Number(String(v).replace(/[^0-9.]/g, ''));
  return Number.isFinite(n) && n > 0 ? n : null;
}

/** "1344 sqft" | "1,344" -> 1344. */
export function sqftOf(v: unknown): number | null {
  return num(v);
}

/** Resolve the firm holding this listing by matching the source host to firm_site.url. */
export async function resolveFirmByHost(host: string): Promise<number | null> {
  const bare = host.replace(/^www\./, '');
  const r = await query<{ id: number }>(
    `SELECT f.id
       FROM firm_site fs JOIN firm f ON f.id = fs.firm_id
      WHERE fs.url ILIKE $1
      ORDER BY f.agent_count DESC NULLS LAST, f.id
      LIMIT 1`,
    [`%${bare}%`],
  );
  return r.rows[0]?.id ?? null;
}

/**
 * County region_id for a listing: nearest county centroid within the same state.
 * Cheap squared-distance over the ~254 counties of one state; no geocode API. Null if
 * no state or no lat/lng.
 */
const countyCache = new Map<string, { id: number; lat: number; lng: number }[]>();
export async function resolveRegion(
  state: string | null,
  lat: number | null,
  lng: number | null,
): Promise<number | null> {
  if (!state || lat == null || lng == null) return null;
  const st = state.toUpperCase();
  let counties = countyCache.get(st);
  if (!counties) {
    const r = await query<{ id: number; lat: number; lng: number }>(
      `SELECT id, lat, lng FROM region
        WHERE region_type='county' AND state_code=$1 AND lat IS NOT NULL AND lng IS NOT NULL`,
      [st],
    );
    counties = r.rows;
    countyCache.set(st, counties);
  }
  let best: number | null = null;
  let bestD = Infinity;
  for (const c of counties) {
    const d = (c.lat - lat) ** 2 + (c.lng - lng) ** 2;
    if (d < bestD) { bestD = d; best = c.id; }
  }
  return best;
}

/**
 * Fallback region resolver by ZIP → county_fips → county region, for adapters whose
 * listings carry a postalCode but no lat/lng (e.g. Weichert). Chain:
 * zip_county.zip → zip_county.county_fips = region.fips (region_type='county').
 */
const zipRegionCache = new Map<string, number | null>();
export async function resolveRegionByZip(zip: string | null): Promise<number | null> {
  if (!zip) return null;
  const z = zip.trim().slice(0, 5);
  if (!/^\d{5}$/.test(z)) return null;
  if (zipRegionCache.has(z)) return zipRegionCache.get(z)!;
  const r = await query<{ id: number }>(
    `SELECT r.id FROM zip_county zc
        JOIN region r ON r.fips = zc.county_fips AND r.region_type = 'county'
       WHERE zc.zip = $1 LIMIT 1`, [z]);
  const id = r.rows[0]?.id ?? null;
  zipRegionCache.set(z, id);
  return id;
}

/** Pull all leaf <loc> URLs from a sitemap or sitemap-index (one level of recursion). */
export async function sitemapLocs(url: string, depth = 1): Promise<string[]> {
  const { ok, body } = await rawFetch(url);
  if (!ok) return [];
  const locs = [...body.matchAll(/<loc>\s*([^<]+?)\s*<\/loc>/gi)].map((m) => m[1]);
  const isIndex = /<sitemapindex/i.test(body);
  if (isIndex && depth > 0) {
    const child: string[] = [];
    for (const l of locs.slice(0, 3)) child.push(...(await sitemapLocs(l, depth - 1)));
    return child;
  }
  return locs;
}