← 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);
  }
}