← 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
A scripts/enrich-property.js
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 →