← back to Commercialrealestate

scripts/fetch-assessor.js

115 lines

#!/usr/bin/env node
// fetch-assessor.js — stream the LA County Assessor public parcel roll (all homes/parcels) to disk.
//
// Source: LA County eGIS "Assessor Parcel Data (Rolls 2021-2025)" hosted feature layer (public, FREE, $0).
//   https://services.arcgis.com/RmCCgQtiZLDCtblq/arcgis/rest/services/Parcel_Data_2021_Table/FeatureServer/0
// The full layer is 12.1M rows = ~2.4M parcels × 5 roll years (2021-2025). We pull ONLY the latest roll
// (RollYear=2025, ~2.43M rows = one record per parcel — the current snapshot of "all homes"). This is the
// disk-aware scoped subset; pulling all 5 years would 5× the size for redundant historical valuations we
// don't need. The cap is LOGGED, never silent. Override with CC_ROLL_YEAR= or CC_ALL_YEARS=1.
//
// Streams NDJSON to data/raw/assessor-parcels-<year>.ndjson (one parcel per line) — never holds the whole
// roll in RAM. Reports running row count + on-disk size. data/raw/ is gitignored (big raw file).
//
// Run: node scripts/fetch-assessor.js            # 2025 roll, all parcels
//      CC_LIMIT=5000 node scripts/fetch-assessor.js   # smoke test (first 5k)
'use strict';
const fs = require('fs');
const path = require('path');
const https = require('https');

const BASE = 'https://services.arcgis.com/RmCCgQtiZLDCtblq/arcgis/rest/services/Parcel_Data_2021_Table/FeatureServer/0';
const PAGE = 2000;                                  // = layer maxRecordCount
const ROLL_YEAR = process.env.CC_ROLL_YEAR || '2025';
const ALL_YEARS = process.env.CC_ALL_YEARS === '1';
const LIMIT = +(process.env.CC_LIMIT || 0) || 0;     // 0 = no cap (full roll)
const WHERE = ALL_YEARS ? '1=1' : `RollYear='${ROLL_YEAR}'`;

// Field subset: identity + location + the valuation/physical attrs that matter for CRE + broker work.
// (* would pull ~60 cols incl. redundant base-year strings; this keeps the file lean but complete.)
const FIELDS = [
  'AIN', 'RollYear', 'PropertyLocation', 'SitusHouseNo', 'SitusStreet', 'SitusUnit',
  'SitusCity', 'SitusZIP5', 'UseType', 'UseCode', 'UseCodeDescChar1', 'UseCodeDescChar2',
  'YearBuilt', 'EffectiveYearBuilt', 'SQFTmain', 'Bedrooms', 'Bathrooms', 'Units',
  'RecordingDate', 'Roll_LandValue', 'Roll_ImpValue', 'Roll_totLandImp', 'Roll_TotalValue',
  'netTaxableValue', 'isTaxableParcel', 'ParcelClassification', 'AdminRegion',
  'CENTER_LAT', 'CENTER_LON'
].join(',');

const RAW_DIR = path.join(__dirname, '..', 'data', 'raw');
fs.mkdirSync(RAW_DIR, { recursive: true });
const OUT = path.join(RAW_DIR, `assessor-parcels-${ALL_YEARS ? 'all' : ROLL_YEAR}.ndjson`);

function getJSON(url) {
  return new Promise((resolve, reject) => {
    const req = https.get(url, { timeout: 60000 }, res => {
      if (res.statusCode !== 200) { res.resume(); return reject(new Error('HTTP ' + res.statusCode)); }
      let buf = '';
      res.setEncoding('utf8');
      res.on('data', d => buf += d);
      res.on('end', () => { try { resolve(JSON.parse(buf)); } catch (e) { reject(new Error('bad JSON: ' + e.message)); } });
    });
    req.on('timeout', () => { req.destroy(new Error('timeout')); });
    req.on('error', reject);
  });
}

function fmtBytes(n) { return n > 1e9 ? (n / 1e9).toFixed(2) + ' GB' : (n / 1e6).toFixed(1) + ' MB'; }

async function main() {
  const t0 = Date.now();
  // total count for progress
  const cnt = await getJSON(`${BASE}/query?where=${encodeURIComponent(WHERE)}&returnCountOnly=true&f=json`);
  const total = cnt.count || 0;
  const target = LIMIT ? Math.min(LIMIT, total) : total;
  console.log(`[assessor] LA County Assessor Parcel Roll — WHERE ${WHERE} → ${total.toLocaleString()} parcels` +
    (LIMIT ? ` (CAPPED to ${LIMIT.toLocaleString()} via CC_LIMIT)` : '') +
    (!ALL_YEARS ? `\n[assessor] SCOPE NOTE: pulling RollYear=${ROLL_YEAR} only (current snapshot, 1 row/parcel). Historical rolls 2021-2024 intentionally skipped (5× redundant). Set CC_ALL_YEARS=1 to pull all.` : ''));
  console.log(`[assessor] streaming NDJSON → ${OUT}  ($0 — public open data)`);

  // Resume support: CC_START_OFFSET continues a partial pull (append) instead of re-fetching from 0.
  const START = +(process.env.CC_START_OFFSET || 0) || 0;
  const ws = fs.createWriteStream(OUT, { flags: START ? 'a' : 'w' });
  let offset = START, written = START, pages = 0;
  if (START) console.log(`[assessor] RESUME from offset ${START.toLocaleString()} (appending to existing file)`);
  while (written < target) {
    const url = `${BASE}/query?where=${encodeURIComponent(WHERE)}&outFields=${FIELDS}` +
      `&returnGeometry=false&orderByFields=AIN&resultOffset=${offset}&resultRecordCount=${PAGE}&f=json`;
    let data;
    for (let attempt = 1; ; attempt++) {
      try { data = await getJSON(url); break; }
      catch (e) {
        if (attempt >= 4) throw new Error(`page @offset ${offset} failed after ${attempt} tries: ${e.message}`);
        await new Promise(r => setTimeout(r, 1500 * attempt));
      }
    }
    const feats = data.features || [];
    if (!feats.length) break;
    for (const f of feats) {
      if (written >= target) break;
      ws.write(JSON.stringify(f.attributes) + '\n');
      written++;
    }
    offset += feats.length;
    pages++;
    if (pages % 25 === 0 || written >= target) {
      let sz = 0; try { sz = fs.statSync(OUT).size; } catch (_) {}
      const pct = total ? ((written / target) * 100).toFixed(1) : '?';
      process.stdout.write(`[assessor] ${written.toLocaleString()}/${target.toLocaleString()} (${pct}%) · ${fmtBytes(sz)} · ${pages} pages\n`);
    }
    // Terminate ONLY on the server's real end-of-data signal. This ArcGIS layer returns SHORT pages
    // (< maxRecordCount) mid-dataset while exceededTransferLimit is still true, so a short page is NOT
    // the end — breaking on feats.length<PAGE truncated the pull at ~294k/2.43M (the old bug).
    if (data.exceededTransferLimit === false) break;  // last (partial) page; no more rows after this
  }
  await new Promise(r => ws.end(r));
  const sz = fs.statSync(OUT).size;
  const secs = ((Date.now() - t0) / 1000).toFixed(0);
  console.log(`[assessor] DONE — wrote ${written.toLocaleString()} parcels · ${fmtBytes(sz)} · ${pages} pages · ${secs}s · $0 (public open data)`);
  console.log(`[assessor] file: ${OUT}`);
  // emit a small machine-readable result line (last line) for callers/ingest
  console.log(JSON.stringify({ ok: true, rows: written, bytes: sz, file: OUT, where: WHERE, rollYear: ALL_YEARS ? 'all' : ROLL_YEAR }));
}

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