← back to Homesonspec

apps/workers/scripts/enrich-nearby.ts

166 lines

/**
 * Public-records "nearby" enrichment stage.
 *
 * WHY: builders' own nearby-schools / nearby-places / amenities pages are
 * frequently robots.txt-disallowed (e.g. Lennar `/*nearby-schools`,
 * `/*amenities`). Rather than scrape those blocked pages, we attach the SAME
 * objective facts from PUBLIC RECORDS:
 *   - schools  → NCES CCD via the Urban Institute Education Data API
 *                (public domain; objective only — NO ratings/quality → Fair-Housing safe)
 *   - places   → OpenStreetMap / Overpass (ODbL; attribution recorded)
 *
 * Keyed on Community.lat/lon; writes to Community.amenities (JSON) with
 * per-source attribution + retrievedAt. Idempotent (skips already-enriched),
 * op-N batched (NEARBY_LIMIT, default 40), $0 (free public APIs).
 *
 * Run:  DATABASE_URL=… NEARBY_LIMIT=40 pnpm --filter @homesonspec/workers exec tsx scripts/enrich-nearby.ts
 */
import { prisma, Prisma } from "@homesonspec/database";

const YEAR = process.env.NCES_YEAR ?? "2022";
const LIMIT = Number(process.env.NEARBY_LIMIT ?? 40);
const SCHOOL_N = 6;
const PLACE_RADIUS_M = 8000;
const UA = "HomesOnSpecBot/0.1 (+https://homesonspec.com/bot; contact: data@homesonspec.com)";

const STATE_FIPS: Record<string, number> = {
  AL:1,AK:2,AZ:4,AR:5,CA:6,CO:8,CT:9,DE:10,DC:11,FL:12,GA:13,HI:15,ID:16,IL:17,IN:18,IA:19,
  KS:20,KY:21,LA:22,ME:23,MD:24,MA:25,MI:26,MN:27,MS:28,MO:29,MT:30,NE:31,NV:32,NH:33,NJ:34,
  NM:35,NY:36,NC:37,ND:38,OH:39,OK:40,OR:41,PA:42,RI:44,SC:45,SD:46,TN:47,TX:48,UT:49,VT:50,
  VA:51,WA:53,WV:54,WI:55,WY:56,
};
const LEVEL: Record<number, string> = { 1: "Elementary", 2: "Middle", 3: "High", 4: "Other" };

function milesBetween(aLat: number, aLon: number, bLat: number, bLon: number): number {
  const R = 3958.8;
  const p1 = (aLat * Math.PI) / 180, p2 = (bLat * Math.PI) / 180;
  const dp = ((bLat - aLat) * Math.PI) / 180, dl = ((bLon - aLon) * Math.PI) / 180;
  const x = Math.sin(dp / 2) ** 2 + Math.cos(p1) * Math.cos(p2) * Math.sin(dl / 2) ** 2;
  return R * 2 * Math.asin(Math.sqrt(x));
}

interface School { ncessch: string; name: string; level: string; lat: number; lon: number; city: string | null }
const stateSchoolCache = new Map<number, School[]>();

async function schoolsForState(fips: number): Promise<School[]> {
  const hit = stateSchoolCache.get(fips);
  if (hit) return hit;
  let out: School[] = [];
  try {
    const res = await fetch(`https://educationdata.urban.org/api/v1/schools/ccd/directory/${YEAR}/?fips=${fips}`, {
      headers: { "User-Agent": UA, Accept: "application/json" },
      signal: AbortSignal.timeout(45_000),
    });
    if (res.ok) {
      const data: any = await res.json();
      out = (data.results ?? [])
        .filter((s: any) => s.latitude != null && s.longitude != null)
        .map((s: any): School => ({
          ncessch: String(s.ncessch),
          name: s.school_name,
          level: LEVEL[s.school_level] ?? "",
          lat: s.latitude,
          lon: s.longitude,
          city: s.city_location ?? null,
        }));
    }
  } catch {
    /* fail-soft: no schools for this state this run */
  }
  stateSchoolCache.set(fips, out);
  return out;
}

interface Place { name: string; type: string; distance_mi: number }
let osmFails = 0;
let osmDisabled = false; // circuit breaker: stop hammering Overpass once it's clearly busy
async function placesNear(lat: number, lon: number): Promise<{ places: Place[]; ok: boolean }> {
  if (osmDisabled) return { places: [], ok: false };
  const q = `[out:json][timeout:25];(` +
    `nwr(around:${PLACE_RADIUS_M},${lat},${lon})[amenity=hospital];` +
    `nwr(around:${PLACE_RADIUS_M},${lat},${lon})[amenity=pharmacy];` +
    `nwr(around:${PLACE_RADIUS_M},${lat},${lon})[amenity=library];` +
    `nwr(around:${PLACE_RADIUS_M},${lat},${lon})[shop=supermarket];` +
    `nwr(around:${PLACE_RADIUS_M},${lat},${lon})[leisure=park];` +
    `);out center tags 60;`;
  for (const ep of ["https://overpass-api.de/api/interpreter", "https://overpass.kumi.systems/api/interpreter"]) {
    try {
      const res = await fetch(ep, {
        method: "POST",
        headers: { "User-Agent": UA, "Content-Type": "application/x-www-form-urlencoded" },
        body: "data=" + encodeURIComponent(q),
        signal: AbortSignal.timeout(35_000),
      });
      if (!res.ok) continue;
      const ct = res.headers.get("content-type") ?? "";
      if (!ct.includes("json")) continue; // busy → XML error page
      const data: any = await res.json();
      const rows: Place[] = [];
      for (const el of data.elements ?? []) {
        const t = el.tags ?? {};
        if (!t.name) continue;
        const la = el.lat ?? el.center?.lat, lo = el.lon ?? el.center?.lon;
        if (la == null) continue;
        rows.push({ name: t.name, type: t.amenity || t.shop || t.leisure || "place", distance_mi: Math.round(milesBetween(lat, lon, la, lo) * 10) / 10 });
      }
      rows.sort((a, b) => a.distance_mi - b.distance_mi);
      osmFails = 0;
      return { places: rows.slice(0, 10), ok: true };
    } catch {
      /* try next mirror */
    }
  }
  if (++osmFails >= 3) { osmDisabled = true; console.log("  (OSM/Overpass busy — disabling places for the rest of this run; schools unaffected)"); }
  return { places: [], ok: false }; // OSM transiently unavailable — schools still land
}

async function main() {
  const communities = await prisma.community.findMany({
    where: { lat: { not: null }, lon: { not: null }, amenities: { equals: Prisma.DbNull } },
    select: { id: true, name: true, city: true, state: true, lat: true, lon: true },
    orderBy: { createdAt: "asc" },
    take: LIMIT,
  });
  console.log(`enrich-nearby: ${communities.length} unenriched communities with coords (limit ${LIMIT}, NCES ${YEAR})`);
  let enriched = 0, withSchools = 0, withPlaces = 0;
  const nowIso = new Date().toISOString();

  for (const c of communities) {
    const lat = Number(c.lat), lon = Number(c.lon);
    const fips = STATE_FIPS[(c.state ?? "").toUpperCase()];
    let schools: any[] = [];
    if (fips) {
      const all = await schoolsForState(fips);
      schools = all
        .map((s) => ({ ...s, distance_mi: Math.round(milesBetween(lat, lon, s.lat, s.lon) * 10) / 10 }))
        .sort((a, b) => a.distance_mi - b.distance_mi)
        .slice(0, SCHOOL_N)
        .map((s) => ({ name: s.name, level: s.level, distance_mi: s.distance_mi, city: s.city, ncessch: s.ncessch }));
    }
    const { places, ok: placesOk } = await placesNear(lat, lon);

    const sources: any[] = [];
    if (schools.length) sources.push({ field: "schools", source: "NCES Common Core of Data (US Dept of Education) via Urban Institute Education Data API", license: "public domain", retrievedAt: nowIso });
    if (places.length) sources.push({ field: "places", source: "OpenStreetMap", license: "ODbL — © OpenStreetMap contributors", retrievedAt: nowIso });

    const payload = {
      schools,
      places,
      _note: "Objective public-records facts near this community. Schools are location facts only — no ratings (Fair Housing). Sourced from public records, NOT the builder site.",
      _sources: sources,
      _enrichedAt: nowIso,
      _placesSourceStatus: placesOk ? "ok" : "unavailable-this-run",
    };
    await prisma.community.update({ where: { id: c.id }, data: { amenities: payload } });
    enriched++;
    if (schools.length) withSchools++;
    if (places.length) withPlaces++;
    console.log(`  ✓ ${c.name}, ${c.city} ${c.state}: ${schools.length} schools, ${places.length} places${placesOk ? "" : " (OSM busy)"}`);
  }

  console.log(`\nDONE: enriched ${enriched} communities · ${withSchools} got schools · ${withPlaces} got places · $0 (public APIs)`);
  await prisma.$disconnect();
}

main().catch(async (e) => { console.error(e); await prisma.$disconnect(); process.exit(1); });