← back to Commercialrealestate
scripts/fetch-ncua-credit-unions.js
279 lines
#!/usr/bin/env node
// fetch-ncua-credit-unions.js — CRCP "Credit Unions" builder for the lending-intelligence layer.
//
// Pulls REAL public data only ($0, unauthenticated) from the official NCUA Quarterly Call Report
// bulk data file. Unlike FDIC, NCUA has no clean REST API — the canonical public route is the
// quarterly ZIP of pipe/comma-delimited Call Report tables published at:
// https://ncua.gov/analysis/credit-union-corporate-call-report-data/quarterly-data
// file: /files/publications/analysis/call-report-data-YYYY-MM.zip (verified reachable 2026-07-31)
//
// From that one official ZIP we read three tables:
// • "Credit Union Branch Information.txt" — every physical office, with PhysicalAddressCountyName2.
// This is how we scope to Los Angeles County (STATE=CA, county="Los Angeles").
// • FOICU.txt — credit-union master (charter #, name, HQ city/state, year opened, MDI flag).
// • FS220.txt — financial statement (ACCT_010 = TOTAL ASSETS, ACCT_083 = number of current members).
//
// The deliverable (data/ncua-credit-unions.json) is a DIRECTORY OF CREDIT UNIONS with an office/branch
// in LA County — each row joined to its authoritative NCUA master facts + total-assets/members from the
// same quarter's Call Report, plus its LA-County branch count.
//
// Every row is source-cited: source="NCUA", source_url=official per-CU deep link on the NCUA
// Research-a-Credit-Union locator, identifier=NCUA charter #, retrieved_at=now. No personal data —
// institutions only. Idempotent + re-runnable. Logs real source URLs + row counts. $0, public, no key.
//
// If the bulk ZIP genuinely cannot be reached/parsed, writes an HONEST manual_review_only stub
// (meta explains why + deep-links the official lookup) with an EMPTY array — never invents data.
//
// Run: node scripts/fetch-ncua-credit-unions.js
const fs = require('fs');
const path = require('path');
const os = require('os');
const { execFileSync } = require('child_process');
const ROOT = path.join(__dirname, '..');
const OUT = path.join(ROOT, 'data', 'ncua-credit-unions.json');
// ---- real public source (NCUA Quarterly Call Report bulk data, verified reachable 2026-07-31, $0) ----
const FILES_BASE = 'https://ncua.gov/files/publications/analysis';
const HUMAN = 'https://ncua.gov/analysis/credit-union-corporate-call-report-data/quarterly-data';
// NCUA "Research a Credit Union" public locator — per-CU deep link by charter number.
const LOCATOR = 'https://mapping.ncua.gov/ResearchCreditUnion';
const STATE = 'CA';
const COUNTY = 'Los Angeles';
// Quarter candidates, newest-first — the current year's Q1/Q2/Q3/Q4 then the prior year's Q4.
// (The most recent published quarter wins; we probe until one downloads.)
function quarterCandidates() {
const now = new Date();
const y = now.getUTCFullYear();
const q = ['12', '09', '06', '03'];
const cands = [];
for (const yr of [y, y - 1]) for (const m of q) cands.push(`${yr}-${m}`);
return cands;
}
function log(...a) { console.log('[ncua-credit-unions]', ...a); }
// Download a URL to a local file via curl (-L follows redirects, hard timeout). Public, $0.
function download(url, dest) {
execFileSync('curl', ['-sL', '--fail', '--max-time', '200', '-o', dest, url], {
stdio: ['ignore', 'ignore', 'ignore'], maxBuffer: 8 * 1024 * 1024,
});
}
// Minimal CSV parser (RFC-4180-ish: quoted fields, embedded commas, doubled quotes). The NCUA
// txt tables are comma-delimited with double-quoted string fields.
function parseCSV(text) {
const rows = [];
let row = [], field = '', inq = false;
for (let i = 0; i < text.length; i++) {
const c = text[i];
if (inq) {
if (c === '"') { if (text[i + 1] === '"') { field += '"'; i++; } else inq = false; }
else field += c;
} else {
if (c === '"') inq = true;
else if (c === ',') { row.push(field); field = ''; }
else if (c === '\r') { /* skip */ }
else if (c === '\n') { row.push(field); rows.push(row); row = []; field = ''; }
else field += c;
}
}
if (field.length || row.length) { row.push(field); rows.push(row); }
return rows;
}
// Read a member of the downloaded ZIP to a string via the system `unzip -p` (present on macOS/Linux).
function unzipMember(zipPath, member) {
return execFileSync('unzip', ['-p', zipPath, member], {
encoding: 'utf8', maxBuffer: 256 * 1024 * 1024,
});
}
function atomicWrite(file, obj) {
fs.writeFileSync(file + '.tmp', JSON.stringify(obj));
fs.renameSync(file + '.tmp', file);
}
// Parse an NCUA numeric that may be blank/"" — returns a Number or null.
function num(v) {
if (v == null) return null;
const s = String(v).trim();
if (!s) return null;
const n = Number(s);
return Number.isFinite(n) ? n : null;
}
// The NCUA CYCLE_DATE / dates look like "3/31/2026 0:00:00" — keep the date part only.
function datePart(v) {
if (!v) return null;
const s = String(v).trim();
const m = s.match(/^(\d{1,2}\/\d{1,2}\/\d{4})/);
return m ? m[1] : (s || null);
}
// Write the honest empty stub (used only if the official bulk route can't be reached/parsed).
function writeStub(reason, retrieved_at, quarter) {
const meta = {
source: 'NCUA',
source_url: HUMAN,
source_home: HUMAN,
access_method: 'manual_review_only',
personal_data: false,
scope: `Credit unions with a physical office in ${COUNTY} County, ${STATE}`,
note: `Automated bulk route unavailable (${reason}). No data fabricated. `
+ `Look up credit unions manually at the official NCUA locator: ${LOCATOR} `
+ `(or download the quarterly Call Report ZIP at ${HUMAN}).`,
quarter: quarter || null,
count: 0,
retrieved_at,
};
atomicWrite(OUT, { meta, credit_unions: [] });
log('WROTE MANUAL-REVIEW STUB (empty array):', reason);
}
function main() {
const retrieved_at = new Date().toISOString();
const tmp = path.join(os.tmpdir(), `ncua-cr-${process.pid}.zip`);
// 1) Download the most recent reachable quarterly Call Report ZIP.
let quarter = null;
for (const q of quarterCandidates()) {
const url = `${FILES_BASE}/call-report-data-${q}.zip`;
try {
download(url, tmp);
if (fs.existsSync(tmp) && fs.statSync(tmp).size > 100000) { quarter = q; log(`downloaded ${url}`); break; }
} catch (_) { /* try older quarter */ }
}
if (!quarter) { writeStub('quarterly Call Report ZIP not reachable', retrieved_at, null); return; }
// 2) Read the three tables out of the ZIP.
let branchRows, foicuRows, fsRows;
try {
branchRows = parseCSV(unzipMember(tmp, 'Credit Union Branch Information.txt'));
foicuRows = parseCSV(unzipMember(tmp, 'FOICU.txt'));
fsRows = parseCSV(unzipMember(tmp, 'FS220.txt'));
} catch (e) {
try { fs.unlinkSync(tmp); } catch (_) {}
writeStub(`ZIP member parse failed: ${e.message.split('\n')[0]}`, retrieved_at, quarter);
return;
}
try { fs.unlinkSync(tmp); } catch (_) {}
const idx = (hdr, name) => hdr.indexOf(name);
// --- Branches: find CUs (by CU_NUMBER) with an LA-County office; count LA branches each. ---
const bh = branchRows[0] || [];
const bCU = idx(bh, 'CU_NUMBER');
const bState = idx(bh, 'PhysicalAddressStateCode');
const bCounty = idx(bh, 'PhysicalAddressCountyName2');
const bCity = idx(bh, 'PhysicalAddressCity');
if (bCU < 0 || bState < 0 || bCounty < 0) {
writeStub('Branch Information table missing expected columns', retrieved_at, quarter);
return;
}
const laBranchCount = {}; // CU_NUMBER -> LA-County office count
const laCity = {}; // CU_NUMBER -> a representative LA-County city
let laBranchTotal = 0;
for (let i = 1; i < branchRows.length; i++) {
const r = branchRows[i];
if (!r || r.length <= Math.max(bCU, bState, bCounty)) continue;
const st = (r[bState] || '').trim();
const cty = (r[bCounty] || '').trim();
if (st === STATE && cty.toLowerCase() === COUNTY.toLowerCase()) {
const cu = (r[bCU] || '').trim();
if (!cu) continue;
laBranchCount[cu] = (laBranchCount[cu] || 0) + 1;
laBranchTotal++;
if (!laCity[cu] && bCity >= 0 && (r[bCity] || '').trim()) laCity[cu] = (r[bCity] || '').trim();
}
}
const laCUs = Object.keys(laBranchCount);
log(`LA County offices: ${laBranchTotal} across ${laCUs.length} distinct credit unions`);
if (!laCUs.length) { writeStub('no LA-County offices found in Branch Information table', retrieved_at, quarter); return; }
// --- FOICU master keyed by CU_NUMBER. ---
const fh = foicuRows[0] || [];
const fCU = idx(fh, 'CU_NUMBER');
const fName = idx(fh, 'CU_NAME');
const fCity = idx(fh, 'CITY');
const fState = idx(fh, 'STATE');
const fZip = idx(fh, 'ZIP_CODE');
const fStreet = idx(fh, 'STREET');
const fYear = idx(fh, 'YEAR_OPENED');
const fMDI = idx(fh, 'IsMDI');
const fCharterState = idx(fh, 'CharterState');
const master = {};
for (let i = 1; i < foicuRows.length; i++) {
const r = foicuRows[i];
if (!r || fCU < 0 || r.length <= fCU) continue;
master[(r[fCU] || '').trim()] = r;
}
// --- FS220 financials keyed by CU_NUMBER (ACCT_010 assets, ACCT_083 members). ---
const sh = fsRows[0] || [];
const sCU = idx(sh, 'CU_NUMBER');
const sAssets = idx(sh, 'ACCT_010');
const sMembers = idx(sh, 'ACCT_083');
const fin = {};
for (let i = 1; i < fsRows.length; i++) {
const r = fsRows[i];
if (!r || sCU < 0 || r.length <= sCU) continue;
fin[(r[sCU] || '').trim()] = r;
}
log(`FS220 financial rows: ${Object.keys(fin).length}; FOICU master rows: ${Object.keys(master).length}`);
// --- Build the credit-unions directory: one row per CU with an LA-County office. ---
const credit_unions = laCUs.map((cu, i) => {
const m = master[cu] || [];
const f = fin[cu] || [];
const get = (row, i2) => (i2 >= 0 && row && row.length > i2 ? row[i2] : null);
const name = (get(m, fName) || '').trim() || `NCUA charter ${cu}`;
const assets = sAssets >= 0 ? num(get(f, sAssets)) : null; // TOTAL ASSETS ($ actual dollars)
const members = sMembers >= 0 ? num(get(f, sMembers)) : null;
return {
id: i + 1,
charter: cu, // NCUA charter number (CU_NUMBER)
name,
members: members != null ? members : null,
total_assets: assets != null ? assets : null, // $ actual dollars (NOT thousands)
la_branches: laBranchCount[cu] || 0,
city: laCity[cu] || (get(m, fCity) || '').trim() || null,
hq_city: (get(m, fCity) || '').trim() || null,
hq_state: (get(m, fState) || '').trim() || null,
charter_state: (get(m, fCharterState) || '').trim() || null,
zip: (get(m, fZip) || '').trim() || null,
street: (get(m, fStreet) || '').trim() || null,
year_opened: get(m, fYear) ? num(get(m, fYear)) : null,
mdi: fMDI >= 0 ? /^(1|true|yes)$/i.test((get(m, fMDI) || '').trim()) : null, // minority depository institution
// provenance (package §16 evidence-first)
source: 'NCUA',
source_url: `${LOCATOR}?ID=${encodeURIComponent(cu)}`,
identifier: `NCUA charter ${cu}`,
retrieved_at,
};
}).sort((a, b) => (b.la_branches - a.la_branches) || ((b.total_assets || 0) - (a.total_assets || 0)));
const meta = {
source: 'NCUA',
source_url: HUMAN,
source_home: HUMAN,
access_method: 'official_bulk_file',
dataset: 'NCUA Quarterly Call Report data',
quarter,
file: `${FILES_BASE}/call-report-data-${quarter}.zip`,
scope: `Credit unions with a physical office in ${COUNTY} County, ${STATE} (scoped via the Call Report Branch Information county field)`,
la_offices: laBranchTotal,
personal_data: false,
count: credit_unions.length,
retrieved_at,
};
atomicWrite(OUT, { meta, credit_unions });
log(`wrote ${credit_unions.length} credit unions → ${path.relative(ROOT, OUT)}`);
log('source:', HUMAN, `(quarter ${quarter}, $0, official NCUA public bulk file)`);
}
main();