← back to Re Flyer Aggregator

scripts/ingest-la-sales.mjs

85 lines

#!/usr/bin/env node
// TK-10708  LA (06037) sale-event backfill from the LA Assessor PAIS "Recent Sales
// Parcels" ArcGIS layer -- prices INFERRED from Documentary Transfer Tax (property-tax
// -> price), refreshed weekly, single-parcel sales, last ~24mo. This fills the gap that
// left recent_commercial_deals with 0 LA deals: LA had 153k commercial_parcel rows but
// 0 parcel_event rows because the wrong (parcel, no-price) layer was configured.
//
// Writes parcel_event rows joined AIN->commercial_parcel.ain, so recent_commercial_deals
// (parcel_event JOIN commercial_parcel, amount>250k, last 18mo) lights up for LA.
// Provenance is labelled source='assessor_dtt_inferred' (codex: label the inference).
// "no names, just price" (Steve) -- we pull AIN/address/date/price only, no owner names.
//
// Reversible: DELETE FROM parcel_event WHERE county_fips='06037' AND source='assessor_dtt_inferred';
// Usage: node scripts/ingest-la-sales.mjs [--dry]

import { execFileSync } from 'node:child_process';

const DRY = process.argv.includes('--dry');
const LAYER = 'https://egispais.gis.lacounty.gov/pais/rest/services/PAIS/pais_sales_parcels/MapServer/0/query';
const WHERE = "USETYPE='C/I' AND SALEPRICE>250000";
const PAGE = 1000;  // LA PAIS maxRecordCount is 1000
const psql = sql => execFileSync('psql', ['usre', '-t', '-A', '-c', sql], { encoding: 'utf8' }).trim();
const q = v => `'${String(v).replace(/'/g, "''")}'`;

async function pull() {
  const rows = [];
  for (let offset = 0; ; offset += PAGE) {
    const u = new URL(LAYER);
    u.search = new URLSearchParams({
      where: WHERE, outFields: 'AIN,SAADDR,SALEDATE,SALEPRICE,USETYPE',
      returnGeometry: 'false', orderByFields: 'AIN', resultRecordCount: String(PAGE),
      resultOffset: String(offset), f: 'json'
    }).toString();
    const r = await fetch(u).then(x => x.json());
    const feats = r.features || [];
    for (const f of feats) {
      const a = f.attributes;
      const ain = String(a.AIN ?? '').replace(/\D/g, '').padStart(10, '0');
      const price = Number(a.SALEPRICE);
      if (!ain || !(price > 250000)) continue;
      const d = a.SALEDATE ? new Date(a.SALEDATE) : null;
      const date = d && !isNaN(d) ? d.toISOString().slice(0, 10) : null;
      if (!date) continue;
      rows.push({ ain, price: Math.round(price), date, addr: (a.SAADDR || '').trim() });
    }
    if (feats.length < PAGE) break;
    if (offset > 20000) break; // safety cap
  }
  // dedup on (ain,date,price)
  const seen = new Set(), out = [];
  for (const r of rows) { const k = `${r.ain}|${r.date}|${r.price}`; if (!seen.has(k)) { seen.add(k); out.push(r); } }
  return out;
}

const rows = await pull();
console.log(`Pulled ${rows.length} LA C/I sale events (>250k, DTT-inferred).`);
console.log('sample:', rows.slice(0, 3).map(r => `${r.addr||'(no addr)'} $${r.price.toLocaleString()} ${r.date}`).join(' | '));

if (DRY) { console.log('DRY -- no writes.'); process.exit(0); }

// "be first": detect NEW deals vs what we already have, BEFORE reloading.
const prior = new Set(psql(`SELECT source_id||'|'||event_date||'|'||amount::bigint FROM parcel_event WHERE county_fips='06037' AND source='assessor_dtt_inferred'`).split('\n').filter(Boolean));
const fresh = rows.filter(r => !prior.has(`${r.ain}|${r.date}|${r.price}`));
if (fresh.length) {
  const stamp = new Date().toISOString().slice(0, 10);
  execFileSync('bash', ['-c', `cat > "${process.cwd()}/out/new-la-deals-${stamp}.json"`], { input: JSON.stringify(fresh, null, 2) });
  console.log(`NEW closed LA deals this run: ${fresh.length} (written to out/new-la-deals-${stamp}.json) -> feed 'tell people first'`);
} else {
  console.log(`NEW closed LA deals this run: 0 (source frozen at 2024-06 until a fresher DTT feed is connected).`);
}

// reversible reload: clear prior DTT-inferred LA events, then bulk insert in chunks
psql(`DELETE FROM parcel_event WHERE county_fips='06037' AND source='assessor_dtt_inferred'`);
let n = 0;
for (let i = 0; i < rows.length; i += 500) {
  const vals = rows.slice(i, i + 500).map(r =>
    `('06037',${q(r.ain)},'sale',${q(r.date)},${r.price},'deed',${q(JSON.stringify({ note: 'LA PAIS DTT-inferred', addr: r.addr || null }))}::jsonb,'assessor_dtt_inferred',${q(LAYER)})`
  ).join(',');
  psql(`INSERT INTO parcel_event (county_fips,source_id,event_type,event_date,amount,doc_type,detail,source,source_url) VALUES ${vals}`);
  n += Math.min(500, rows.length - i);
}
console.log(`Inserted ${n} LA parcel_event rows.`);
const deals = psql(`SELECT count(*) FROM recent_commercial_deals WHERE county_fips='06037'`);
console.log(`recent_commercial_deals now shows ${deals} LA deals (join x commercial_parcel, last 18mo).`);