[object Object]

← back to Commercialrealestate

CRCP: per-property public-record enrichment module (free assessor dossier + ULA + deed-gap decl)

4d95c1e00a69edcc169710f78d306eb926efcaf5 · 2026-08-06 16:25:34 -0700 · Steve Abrams

enrich-property.js — given an AIN or address, returns the free LA County Assessor
parcel dossier (address, use, size, age, units, beds/baths, assessed values, last
recording date) + best-effort ULA price cross-ref, and explicitly declares the
gated deed-parties gap (grantor/grantee via RR/CC) with its cost paths rather than
silently omitting. Cycle 1 of the property-enrichment engine (yoloforever). $0.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

Files touched

Diff

commit 4d95c1e00a69edcc169710f78d306eb926efcaf5
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Thu Aug 6 16:25:34 2026 -0700

    CRCP: per-property public-record enrichment module (free assessor dossier + ULA + deed-gap decl)
    
    enrich-property.js — given an AIN or address, returns the free LA County Assessor
    parcel dossier (address, use, size, age, units, beds/baths, assessed values, last
    recording date) + best-effort ULA price cross-ref, and explicitly declares the
    gated deed-parties gap (grantor/grantee via RR/CC) with its cost paths rather than
    silently omitting. Cycle 1 of the property-enrichment engine (yoloforever). $0.
    
    Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---
 scripts/enrich-property.js | 141 +++++++++++++++++++++++++++++++++++++++++++++
 1 file changed, 141 insertions(+)

diff --git a/scripts/enrich-property.js b/scripts/enrich-property.js
new file mode 100644
index 0000000..8f9725d
--- /dev/null
+++ b/scripts/enrich-property.js
@@ -0,0 +1,141 @@
+#!/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) => {
+    https.get(url, { headers: { 'User-Agent': 'CRCP-enrich/1.0 (public-records)' } }, 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); } });
+    }).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.SitusZipCode),
+    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.TotalValue ?? a.Roll_totalValue ?? null,
+    assessed_land: a.LandValue ?? a.Roll_LandValue ?? null,
+    assessed_improvement: a.ImpValue ?? a.Roll_ImpValue ?? null,
+    last_recording_date: epochToDate(a.RecordingDate),   // last transfer the assessor processed (lagged)
+    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;
+  const street = (m ? m[2] : addr).toUpperCase().replace(/'/g, "''");
+  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);
+}
+
+// Best-effort ULA price match (LA City $5.3M+): by ZIP. Exact instrument→AIN match needs the deed.
+function ulaMatches(zip) {
+  try {
+    const u = JSON.parse(fs.readFileSync(path.join(__dirname, '..', 'data', 'ula-la-deals.json'), 'utf8'));
+    return (u.deals || []).filter(d => d.zip && zip && String(d.zip) === String(zip))
+      .slice(0, 10).map(d => ({ date: d.date, price: d.price, use: d.use, doc_number: 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_recording_date ? `assessor shows last transfer recorded ${dossier.last_recording_date}` : null,
+  };
+}
+
+async function enrichByAin(ain) {
+  const parcel = await assessorByAin(ain);
+  if (!parcel) return { ain, found: false };
+  return { ain, found: true, parcel, ula_sales: ulaMatches(parcel.zip), deed: deedStub(parcel), sources: ['LA County Assessor (public)', 'LA City 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); });
+}

← f05ff59 CRCP: retry ranked.json fetch-only so success body (12 event  ·  back to Commercialrealestate  ·  CRCP: surface listing.created_at as '🕓 captured <date+time> e515bc5 →