← back to Beverlyhillsbutler

scripts/fetch-bh-properties.js

190 lines

#!/usr/bin/env node
// fetch-bh-properties.js — Stream Beverly Hills property parcels from LA County Assessor
// (public, FREE, $0 — LA County eGIS ArcGIS feature service)
//
// Source: LA County Assessor Parcel Data (public open data)
//   https://services.arcgis.com/RmCCgQtiZLDCtblq/arcgis/rest/services/Parcel_Data_2021_Table/FeatureServer/0
// Filters: SitusZIP5 IN ('90210','90211','90212') AND RollYear='2025'
// Expected: ~13,658 BH parcels
//
// Usage:
//   node scripts/fetch-bh-properties.js             # full fetch
//   node scripts/fetch-bh-properties.js --limit 100 # smoke test
//
// Output: data/bh-properties.json (array of property objects)
'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;
const ROLL_YEAR = '2025';
// Beverly Hills spans 3 zip codes
const BH_ZIPS = ['90210', '90211', '90212'];
const WHERE = `SitusZIP5 IN ('${BH_ZIPS.join("','")}') AND RollYear='${ROLL_YEAR}'`;

const FIELDS = [
  'AIN', 'RollYear', 'PropertyLocation',
  'SitusHouseNo', 'SitusFraction', 'SitusDirection', 'SitusStreet', 'SitusUnit',
  'SitusCity', 'SitusZIP5',
  'UseType', 'UseCode', 'UseCodeDescChar1', 'UseCodeDescChar2',
  'YearBuilt', 'EffectiveYearBuilt', 'SQFTmain', 'Bedrooms', 'Bathrooms', 'Units',
  'RecordingDate',
  'Roll_LandValue', 'Roll_ImpValue', 'Roll_totLandImp', 'Roll_TotalValue',
  'Roll_HomeOwnersExemp', 'netTaxableValue', 'isTaxableParcel',
  'ParcelClassification', 'AdminRegion',
  'CENTER_LAT', 'CENTER_LON'
].join(',');

// Parse --limit N from args
const limitArg = process.argv.indexOf('--limit');
const LIMIT = limitArg >= 0 ? parseInt(process.argv[limitArg + 1], 10) : 0;

const DATA_DIR = path.join(__dirname, '..', 'data');
fs.mkdirSync(DATA_DIR, { recursive: true });
const OUT = path.join(DATA_DIR, 'bh-properties.json');

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('JSON parse error: ' + e.message)); }
      });
    });
    req.on('timeout', () => { req.destroy(new Error('timeout')); });
    req.on('error', reject);
  });
}

function fmtUSD(n) {
  if (!n) return null;
  return new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD', maximumFractionDigits: 0 }).format(n);
}

function normalizeParcel(attrs) {
  // Build a clean address string
  const parts = [attrs.SitusHouseNo, attrs.SitusFraction, attrs.SitusDirection, attrs.SitusStreet, attrs.SitusUnit]
    .filter(Boolean).join(' ').trim();
  const addr = parts || attrs.PropertyLocation || '';

  // Format APN: XXXXXXXXXX → XXXX-XXX-XXX
  const ain = String(attrs.AIN || '');
  const apn = ain.length === 10 ? `${ain.slice(0,4)}-${ain.slice(4,7)}-${ain.slice(7)}` : ain;

  // Convert recording date (milliseconds) to date string
  let lastSaleDate = null;
  if (attrs.RecordingDate) {
    try { lastSaleDate = new Date(attrs.RecordingDate).toISOString().slice(0, 10); } catch (_) {}
  }

  return {
    ain: ain,
    apn: apn,
    address: addr,
    city: attrs.SitusCity || 'BEVERLY HILLS',
    zip: attrs.SitusZIP5 || '',
    property_location: attrs.PropertyLocation || addr,
    use_type: attrs.UseType || '',
    use_code: attrs.UseCode || '',
    use_desc: attrs.UseCodeDescChar1 || '',
    use_desc2: attrs.UseCodeDescChar2 || '',
    year_built: attrs.YearBuilt || null,
    effective_year_built: attrs.EffectiveYearBuilt || null,
    sqft: attrs.SQFTmain || null,
    bedrooms: attrs.Bedrooms || null,
    bathrooms: attrs.Bathrooms || null,
    units: attrs.Units || null,
    last_sale_date: lastSaleDate,
    land_value: attrs.Roll_LandValue || null,
    improvement_value: attrs.Roll_ImpValue || null,
    total_value: attrs.Roll_TotalValue || null,
    net_taxable_value: attrs.netTaxableValue || null,
    homeowner_exemption: attrs.Roll_HomeOwnersExemp || null,
    parcel_classification: attrs.ParcelClassification || '',
    admin_region: attrs.AdminRegion || '',
    lat: attrs.CENTER_LAT || null,
    lon: attrs.CENTER_LON || null,
    roll_year: attrs.RollYear || ROLL_YEAR,
    // formatted display values
    total_value_fmt: fmtUSD(attrs.Roll_TotalValue),
    land_value_fmt: fmtUSD(attrs.Roll_LandValue),
    improvement_value_fmt: fmtUSD(attrs.Roll_ImpValue),
    // county links
    assessor_url: `https://assessor.lacounty.gov/search-by-ain/?ain=${ain}`,
    tax_url: `https://tax.lacounty.gov/`
  };
}

async function main() {
  console.log(`[bh-fetch] LA County Assessor — Beverly Hills Parcel Fetch ($0, free public data)`);
  console.log(`[bh-fetch] Filter: ${WHERE}`);

  // Get count first
  const cntData = await getJSON(
    `${BASE}/query?where=${encodeURIComponent(WHERE)}&returnCountOnly=true&f=json`
  );
  const total = cntData.count || 0;
  const target = LIMIT ? Math.min(LIMIT, total) : total;
  console.log(`[bh-fetch] Total BH parcels available: ${total.toLocaleString()}${LIMIT ? ` (capped to ${target} via --limit)` : ''}`);
  console.log(`[bh-fetch] Output: ${OUT}`);

  const properties = [];
  let offset = 0, pages = 0;

  while (properties.length < target) {
    const url = `${BASE}/query?where=${encodeURIComponent(WHERE)}&outFields=${encodeURIComponent(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 at 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 (properties.length >= target) break;
      properties.push(normalizeParcel(f.attributes));
    }

    offset += feats.length;
    pages++;
    const pct = total ? ((properties.length / target) * 100).toFixed(1) : '?';
    process.stdout.write(`[bh-fetch] ${properties.length.toLocaleString()}/${target.toLocaleString()} (${pct}%) · page ${pages}\n`);

    // ArcGIS end-of-results signal
    if (data.exceededTransferLimit === false || feats.length < PAGE) break;
  }

  // Save JSON
  fs.writeFileSync(OUT, JSON.stringify(properties, null, 2));

  // Compute stats
  const valued = properties.filter(p => p.total_value);
  const avgVal = valued.length ? Math.round(valued.reduce((s, p) => s + p.total_value, 0) / valued.length) : 0;
  const maxVal = valued.length ? Math.max(...valued.map(p => p.total_value)) : 0;
  const byUse = {};
  for (const p of properties) {
    const k = p.use_desc || 'Unknown';
    byUse[k] = (byUse[k] || 0) + 1;
  }

  console.log(`\n[bh-fetch] Done: ${properties.length.toLocaleString()} properties saved to ${OUT}`);
  console.log(`[bh-fetch] Avg assessed value: ${fmtUSD(avgVal)}`);
  console.log(`[bh-fetch] Max assessed value: ${fmtUSD(maxVal)}`);
  console.log(`[bh-fetch] By use type:`, byUse);
  console.log(`[bh-fetch] Cost: $0 (LA County open data)`);
}

main().catch(e => { console.error('[bh-fetch] FATAL:', e.message); process.exit(1); });