← back to Commercialrealestate

scripts/enrich-property.js

165 lines

#!/usr/bin/env node
// enrich-property.js — per-property PUBLIC-RECORD dossier for CRCP ("figure each property out").
//
// Given an AIN (or a street address), pulls everything the FREE, non-proprietary public sources
// expose about the parcel and returns a normalized dossier. This is the free foundation of the
// per-property enrichment engine; the paid/slow DEED-PARTIES layer (grantor/grantee via the LA
// RR/CC recorded deed) is a separate, Steve-gated source decision (see docs memo) and is stubbed
// here as `deed: { available:false, path: ... }` rather than silently omitted.
//
// FREE SOURCES USED (all $0, public record, no key):
//   • LA County Assessor parcel roll (ArcGIS FeatureServer) — address, use, size, age, units,
//     beds/baths, assessed values, last recording date. Keyed by AIN; searchable by street.
//   • ULA price feed (data/ula-la-deals.json, already built) — exact sale price for LA City
//     $5.3M+ sales, matched by ZIP (best-effort; exact match needs the deed→AIN bridge).
//
// Usage:
//   node scripts/enrich-property.js --ain 4207016028
//   node scripts/enrich-property.js --address "4150 MADISON AVE"
//   const { enrichByAin } = require('./enrich-property');  // programmatic
'use strict';
const fs = require('fs');
const path = require('path');
const https = require('https');

const ASSESSOR = 'https://services.arcgis.com/RmCCgQtiZLDCtblq/arcgis/rest/services/Parcel_Data_2021_Table/FeatureServer/0/query';

function getJson(url) {
  return new Promise((resolve, reject) => {
    // 8s timeout — public gov ArcGIS hangs under load; without this a slow upstream would hang the
    // Express request forever (Cody Cycle-2 HIGH). https.get's `timeout` fires the event but does
    // NOT auto-abort, so destroy the request on timeout to fail fast with a 502.
    const req = https.get(url, { headers: { 'User-Agent': 'CRCP-enrich/1.0 (public-records)' }, timeout: 8000 }, res => {
      if (res.statusCode !== 200) { res.resume(); return reject(new Error('HTTP ' + res.statusCode)); }
      let b = ''; res.on('data', c => b += c); res.on('end', () => { try { resolve(JSON.parse(b)); } catch (e) { reject(e); } });
    });
    req.on('timeout', () => req.destroy(new Error('assessor timeout')));
    req.on('error', reject);
  });
}

const epochToDate = ms => (ms == null || isNaN(+ms)) ? null : new Date(+ms).toISOString().slice(0, 10);
const clean = v => (v == null || String(v).trim() === '') ? null : String(v).trim();

// Normalize one assessor parcel row → dossier shape.
function normalize(a) {
  return {
    ain: clean(a.AIN),
    roll_year: clean(a.RollYear),
    address: clean(a.PropertyLocation),
    street: [clean(a.SitusHouseNo), clean(a.SitusStreet), clean(a.SitusDirection)].filter(Boolean).join(' ') || null,
    city: clean(a.SitusCity),
    zip: clean(a.SitusZIP5) || clean(a.SitusZIP),   // field is ALL-CAPS SitusZIP5 (Cody-verified fix)
    use_type: clean(a.UseType),
    use_desc: clean(a.UseDescription) || clean(a.SpecificUseType) || clean(a.GeneralUseType),
    year_built: a.YearBuilt || null,
    effective_year: a.EffectiveYearBuilt || null,
    sqft: a.SQFTmain || null,
    units: a.Units || null,
    bedrooms: a.Bedrooms || null,
    bathrooms: a.Bathrooms || null,
    assessed_total: a.Roll_TotalValue ?? a.TotalValue ?? a.Roll_totLandImp ?? null,   // 2025 roll uses Roll_TotalValue
    assessed_land: a.LandValue ?? a.Roll_LandValue ?? null,
    assessed_improvement: a.ImpValue ?? a.Roll_ImpValue ?? null,
    assessed_as_of: clean(a.RollYear),   // which assessment roll these figures come from (data age)
    // NOTE: this is the last transfer the ASSESSOR processed that triggered a Prop-13 reassessment —
    // lagged, and NOT necessarily the most recent sale (exclusion-eligible transfers may not appear).
    last_transfer_recorded: epochToDate(a.RecordingDate),
    tax_rate_area: clean(a.TaxRateArea),
  };
}

// Pick the newest RollYear row for a parcel.
function pickLatest(features) {
  const rows = (features || []).map(f => f.attributes);
  rows.sort((x, y) => String(y.RollYear || '').localeCompare(String(x.RollYear || '')));
  return rows[0] || null;
}

async function assessorByAin(ain) {
  const url = `${ASSESSOR}?where=AIN%3D%27${encodeURIComponent(ain)}%27&outFields=*&f=json`;
  const d = await getJson(url);
  const row = pickLatest(d.features);
  return row ? normalize(row) : null;
}

// Address search: match SitusStreet + house number (ArcGIS LIKE). Returns up to `limit` candidates.
async function assessorByAddress(addr, limit = 8) {
  const m = String(addr).trim().match(/^(\d+)\s+(.*)$/);
  const house = m ? m[1] : null;
  // Strip to letters/digits/space only — removes ArcGIS-SQL injection chars (quotes, --, ;) AND
  // makes the single-quote-escape moot (Cody Cycle-2). Then guard against roll-wide '%%' scans:
  // require a house number OR a street term of >=4 chars before hitting the 2.4M-row service.
  const street = (m ? m[2] : addr).toUpperCase().replace(/[^A-Z0-9 ]/g, ' ').replace(/\s+/g, ' ').trim();
  if (!house && street.length < 4) return [];
  let where = `UPPER(SitusStreet) LIKE '%${street}%'`;
  if (house) where = `SitusHouseNo='${house}' AND ${where}`;
  const url = `${ASSESSOR}?where=${encodeURIComponent(where)}&outFields=*&f=json&resultRecordCount=${limit * 5}`;
  const d = await getJson(url);
  // dedupe by AIN, keep latest roll per AIN
  const byAin = new Map();
  for (const f of (d.features || [])) { const a = f.attributes; const k = a.AIN; if (!byAin.has(k) || String(a.RollYear) > String(byAin.get(k).RollYear)) byAin.set(k, a); }
  return [...byAin.values()].slice(0, limit).map(normalize);
}

// ULA sales in the same ZIP — a COARSE, ZIP-level candidate list, NOT a confirmed match for this
// parcel. A precise instrument→AIN match needs the gated RR/CC deed lookup (the assessor layer has
// no instrument field to join on — Cody-verified). Each candidate carries its doc_number so a user
// can pull the actual recorded deed to confirm whether it's THIS property.
function ulaZipCandidates(zip) {
  if (!zip) return [];
  try {
    const u = JSON.parse(fs.readFileSync(path.join(__dirname, '..', 'data', 'ula-la-deals.json'), 'utf8'));
    return (u.deals || []).filter(d => d.zip && String(d.zip) === String(zip))
      .slice(0, 12).map(d => ({ date: d.date, price: d.price, use: d.use, instrument: d.doc_number, basis: d.price_basis }));
  } catch (_) { return []; }
}

// The deed-parties layer is a gated source decision — declare the gap explicitly, never silently omit.
function deedStub(dossier) {
  return {
    available: false,
    reason: 'grantor/grantee + recorded sale price live in the LA RR/CC recorded deed, which is not free online (Gov. Code §6254.21).',
    paths: [
      { name: 'CPRA bulk index request', cost: '$0 (weeks)', gives: 'grantor, grantee, instrument, recording date, AIN' },
      { name: 'NETR per-record', cost: '~$3.50–8.00/property (metered, gated)', gives: 'transfer detail + document image' },
      { name: 'Assessor data-sales change-of-ownership file', cost: '~$500–5,000 bulk (gated)', gives: 'price + parties + AIN, all-county' },
    ],
    bridge_hint: dossier && dossier.last_transfer_recorded ? `assessor shows last reassessment-triggering transfer recorded ${dossier.last_transfer_recorded} (lagged; may not be the latest sale)` : null,
  };
}

async function enrichByAin(ain) {
  const parcel = await assessorByAin(ain);
  if (!parcel) return { ain, found: false };
  const cands = ulaZipCandidates(parcel.zip);
  return {
    ain, found: true, parcel,
    ula_zip_candidates: cands,   // COARSE ZIP-level candidate sales — confirm via each instrument's deed
    ula_note: cands.length ? `${cands.length} ULA $5.3M+ sale(s) recorded in ZIP ${parcel.zip} — candidates, not parcel-confirmed (precise match needs the gated deed lookup)` : null,
    deed: deedStub(parcel),
    sources: ['LA County Assessor parcel roll (public)', 'LA City Measure ULA (public)'],
  };
}

async function enrichByAddress(addr) {
  const candidates = await assessorByAddress(addr);
  if (!candidates.length) return { query: addr, found: false };
  if (candidates.length === 1) return enrichByAin(candidates[0].ain);
  return { query: addr, found: true, multiple: true, candidates };
}

module.exports = { enrichByAin, enrichByAddress, assessorByAin, assessorByAddress };

if (require.main === module) {
  (async () => {
    const args = process.argv.slice(2);
    const ainI = args.indexOf('--ain'); const addrI = args.indexOf('--address');
    let out;
    if (ainI >= 0) out = await enrichByAin(args[ainI + 1]);
    else if (addrI >= 0) out = await enrichByAddress(args[addrI + 1]);
    else { console.error('usage: --ain <AIN> | --address "<street>"'); process.exit(1); }
    console.log(JSON.stringify(out, null, 2));
  })().catch(e => { console.error('enrich failed:', e.message); process.exit(1); });
}