← back to Commercialrealestate
scripts/deed-source-cpra.js
159 lines
#!/usr/bin/env node
// deed-source-cpra.js — the FREE, $0 CPRA deed-parties adapter for CRCP.
//
// WHAT THIS IS: the $0 path that eventually REPLACES enrich-property.js's `deedStub()`
// with REAL grantor/grantee (seller/buyer) + recorded sale price + instrument# per
// property. It stays DORMANT until Steve files the California Public Records Act request
// (see docs/cpra-deed-request.md) and the LA County Registrar-Recorder/County Clerk
// (RR/CC) returns a grantor-grantee index file (CSV or JSON). Once that file lands,
// serve.js (or enrich-property.js) can swap `deedStub(dossier)` for:
// const cpra = require('./deed-source-cpra');
// const records = cpra.loadCpraIndex('<path-to-returned-index>');
// const deed = cpra.deedByAin(records, dossier.ain); // {available:true, ...} or {available:false}
//
// PUBLIC RECORD ONLY: recorded deeds are public record; this maps a government-returned
// grantor-grantee index. Nothing proprietary, no paid feed, no scraping — just parsing
// the file the county sends back under the CPRA request.
//
// NODE BUILT-INS ONLY (fs, path) — no npm deps, matches enrich-property.js style.
'use strict';
const fs = require('fs');
const path = require('path');
// The RR/CC-returned column names are UNKNOWN until the CPRA request actually returns a
// file (the county controls its own export format). Do NOT treat these as verified — they
// are PROVISIONAL guesses so the adapter has a shape to normalize against. When the real
// file arrives, open it, read the header row, and correct this map to the true column names.
//
// TODO(cpra-columns): replace each right-hand column name below with the EXACT header the
// RR/CC index file actually uses. Verify against the returned file's header/keys before use.
const COLS = {
ain: 'AIN', // parcel id (may come back as 'APN', 'ParcelNumber', 'PARCEL_ID')
grantor: 'Grantor', // seller (may be 'GRANTOR_NAME', 'SellerName')
grantee: 'Grantee', // buyer (may be 'GRANTEE_NAME', 'BuyerName')
instrument: 'InstrumentNumber', // doc/instrument # (may be 'DocumentNumber', 'DOC_NUM', 'RecordingNumber')
recording_date: 'RecordingDate', // (may be 'RECORD_DATE', 'DateRecorded')
price: 'SalePrice', // stated consideration / DTT-derived (may be 'Consideration', 'DocTransferTax', null)
};
const clean = v => (v == null || String(v).trim() === '') ? null : String(v).trim();
// Parse a price-ish cell → number or null. Tolerates "$1,250,000", "1250000.00", blanks.
function toPrice(v) {
const s = clean(v);
if (s == null) return null;
const n = Number(s.replace(/[^0-9.\-]/g, ''));
return Number.isFinite(n) && n !== 0 ? n : null;
}
// Tolerant CSV line splitter — handles quoted fields, embedded commas, and "" escaped quotes.
function splitCsvLine(line) {
const out = [];
let cur = '';
let inQ = false;
for (let i = 0; i < line.length; i++) {
const c = line[i];
if (inQ) {
if (c === '"') {
if (line[i + 1] === '"') { cur += '"'; i++; } // escaped quote
else inQ = false;
} else cur += c;
} else if (c === '"') inQ = true;
else if (c === ',') { out.push(cur); cur = ''; }
else cur += c;
}
out.push(cur);
return out.map(s => s.trim());
}
// Minimal CSV → array-of-objects (header row keyed). Tolerates \r\n, blank lines.
function parseCsv(text) {
const lines = text.split(/\r?\n/).filter(l => l.trim() !== '');
if (!lines.length) return [];
const header = splitCsvLine(lines[0]);
const rows = [];
for (let i = 1; i < lines.length; i++) {
const cells = splitCsvLine(lines[i]);
const obj = {};
header.forEach((h, j) => { obj[h] = cells[j]; });
rows.push(obj);
}
return rows;
}
// Map one raw RR/CC row (whatever its columns) → normalized deed record via COLS.
function normalizeRow(raw) {
return {
ain: clean(raw[COLS.ain]),
grantor: clean(raw[COLS.grantor]),
grantee: clean(raw[COLS.grantee]),
instrument: clean(raw[COLS.instrument]),
recording_date: clean(raw[COLS.recording_date]),
price: toPrice(raw[COLS.price]), // nullable — deeds often carry no stated consideration
};
}
// loadCpraIndex(filePath) — read a local .csv or .json RR/CC grantor-grantee index and
// return normalized records [{ain, grantor, grantee, instrument, recording_date, price}].
// Detects format by file extension. Throws if the file is missing or unparseable — the
// caller (a dormant swap-in) decides how to fall back to deedStub() until the file exists.
function loadCpraIndex(filePath) {
const abs = path.resolve(filePath);
const text = fs.readFileSync(abs, 'utf8');
const ext = path.extname(abs).toLowerCase();
let raw;
if (ext === '.json') {
const parsed = JSON.parse(text);
// Accept either a bare array or a wrapper like {records:[...]} / {rows:[...]} / {data:[...]}.
raw = Array.isArray(parsed) ? parsed
: (parsed.records || parsed.rows || parsed.data || parsed.results || []);
} else {
// Default everything else (.csv, .txt, delimited) to the tolerant CSV parser.
raw = parseCsv(text);
}
return (raw || []).map(normalizeRow).filter(r => r.ain); // drop rows with no AIN to join on
}
// deedByAin(records, ain) — return the deed object enrich-property expects for one AIN,
// in the SAME shape the app renders (deals-flow.html renderDossier reads d.deed.available).
// If multiple recorded transfers exist for the AIN, return the most recent by recording_date.
function deedByAin(records, ain) {
const key = clean(ain);
if (!key) return { available: false };
const hits = (records || []).filter(r => r.ain === key);
if (!hits.length) return { available: false };
hits.sort((a, b) => String(b.recording_date || '').localeCompare(String(a.recording_date || '')));
const r = hits[0];
return {
available: true,
grantor: r.grantor,
grantee: r.grantee,
instrument: r.instrument,
recording_date: r.recording_date,
price: r.price, // nullable
ain: r.ain,
source: 'LA County RR/CC grantor-grantee index (CPRA)',
};
}
module.exports = { loadCpraIndex, deedByAin, COLS };
// Tiny CLI for testing once a real RR/CC file exists:
// node scripts/deed-source-cpra.js --file <path-to-index.csv|.json> --ain <AIN>
if (require.main === module) {
const args = process.argv.slice(2);
const fileI = args.indexOf('--file');
const ainI = args.indexOf('--ain');
if (fileI < 0 || ainI < 0) {
console.error('usage: node scripts/deed-source-cpra.js --file <path> --ain <AIN>');
process.exit(2);
}
try {
const records = loadCpraIndex(args[fileI + 1]);
console.log(JSON.stringify(deedByAin(records, args[ainI + 1]), null, 2));
} catch (e) {
console.error('error:', e.message);
process.exit(1);
}
}