← back to Commercialrealestate

scripts/fetch-fdic-lenders.js

186 lines

#!/usr/bin/env node
// fetch-fdic-lenders.js — CRCP "Lenders" (banks) builder for the lending-intelligence layer.
//
// Pulls REAL public data only ($0) from the FDIC BankFind Suite API (official, unauthenticated):
//   1. /banks/locations  — every FDIC-insured bank BRANCH physically in Los Angeles County.
//   2. /banks/institutions — institution metadata (assets, deposits, class, HQ, web, est date)
//      for the distinct banks (by FDIC CERT #) that operate those LA-County branches.
//
// The deliverable (data/fdic-lenders.json) is a DIRECTORY OF LENDERS with an LA-County presence —
// the national + regional + local banks a loan officer actually works alongside — each row joined
// to its authoritative FDIC institution facts and its LA-County branch count. A companion
// data/fdic-branches.json holds the branch-level rows.
//
// Every row is source-cited: source="FDIC BankFind", source_url=official per-institution deep link,
// retrieved_at=now. No personal data — institutions + branches only.
//
// Idempotent + re-runnable. Logs real source URLs + row counts. $0, public, no key.
//
// Run: node scripts/fetch-fdic-lenders.js

const fs = require('fs');
const path = require('path');
const { execFileSync } = require('child_process');

const ROOT = path.join(__dirname, '..');
const OUT_LENDERS = path.join(ROOT, 'data', 'fdic-lenders.json');
const OUT_BRANCHES = path.join(ROOT, 'data', 'fdic-branches.json');

// ---- real public source (FDIC BankFind Suite REST API, verified reachable 2026-07-31, $0) ----
const API = 'https://api.fdic.gov/banks';
const HUMAN = 'https://banks.data.fdic.gov/bankfind-suite/bankfind'; // public human lookup
const COUNTY = 'Los Angeles';
const STATE = 'CA';

function log(...a) { console.log('[fdic-lenders]', ...a); }

// GET a URL to a string via curl (-L follows the api.fdic.gov redirect, hard timeout). Public, $0.
function get(url) {
  return execFileSync('curl', ['-sL', '--max-time', '40', url], {
    encoding: 'utf8', maxBuffer: 64 * 1024 * 1024,
  });
}

// Page through an FDIC endpoint (limit/offset) until every row is collected.
// `total` is captured from the FIRST page only; a later page occasionally omits it, and letting
// that reset the bound truncated the pull at 1000 (the LA-branch set is 1414).
function fetchAll(endpoint, filters, fields, sortField) {
  const rows = [];
  const LIMIT = 1000;
  let offset = 0, total = null;
  const page = (off) => {
    const url = `${API}/${endpoint}?filters=${encodeURIComponent(filters)}`
      + `&fields=${encodeURIComponent(fields)}`
      + `&limit=${LIMIT}&offset=${off}&sort_by=${sortField}&sort_order=ASC&format=json`;
    return JSON.parse(get(url));
  };
  do {
    let d;
    try { d = page(offset); }
    catch (e) { throw new Error(`FDIC ${endpoint} parse failed at offset ${offset}: ${e.message}`); }
    const t = (d.meta && d.meta.total) != null ? d.meta.total
            : (d.totals && d.totals.count != null ? d.totals.count : null);
    if (total == null && t != null) total = t;
    let batch = (d.data || []).map(x => x.data || x);
    if (!batch.length && total != null && rows.length < total) {
      try { batch = (page(offset).data || []).map(x => x.data || x); } catch (_) {} // one retry on transient empty
    }
    rows.push(...batch);
    log(`${endpoint}: ${rows.length}/${total != null ? total : '?'}`);
    if (!batch.length) break;
    offset += LIMIT;
  } while (total != null && rows.length < total);
  return rows;
}

function atomicWrite(file, obj) {
  fs.writeFileSync(file + '.tmp', JSON.stringify(obj));
  fs.renameSync(file + '.tmp', file);
}

function main() {
  const retrieved_at = new Date().toISOString();

  // 1) Every FDIC bank branch in LA County.
  const branchFilters = `STALP:${STATE} AND COUNTY:"${COUNTY}"`;
  const branchFields = 'NAME,CERT,UNINUM,ADDRESS,CITY,ZIP,COUNTY,STALP,SERVTYPE,ESTYMD,MAINOFF,LATITUDE,LONGITUDE';
  const rawBranches = fetchAll('locations', branchFilters, branchFields, 'NAME');
  log(`LA County branches fetched: ${rawBranches.length}`);

  // 2) Distinct institutions (by CERT) that own those branches.
  const branchCount = {};
  rawBranches.forEach(b => { if (b.CERT != null) branchCount[b.CERT] = (branchCount[b.CERT] || 0) + 1; });
  const certs = Object.keys(branchCount);
  log(`distinct lenders (CERT) with LA-County presence: ${certs.length}`);

  // Fetch institution metadata in CERT batches (OR-filter, chunked to keep the URL sane).
  const instFields = 'NAME,CERT,CITY,COUNTY,STALP,STNAME,ASSET,DEP,ESTYMD,BKCLASS,WEBADDR,OFFICES,ACTIVE,NETINC,ROA';
  const instByCert = {};
  const CHUNK = 50;
  for (let i = 0; i < certs.length; i += CHUNK) {
    const slice = certs.slice(i, i + CHUNK);
    const filt = 'CERT:(' + slice.join(' OR ') + ')';
    const url = `${API}/institutions?filters=${encodeURIComponent(filt)}`
      + `&fields=${encodeURIComponent(instFields)}&limit=1000&format=json`;
    let rows = [];
    for (let attempt = 0; attempt < 3 && !rows.length; attempt++) {
      try { rows = (JSON.parse(get(url)).data || []).map(x => x.data || x); }
      catch (e) { if (attempt === 2) throw new Error(`FDIC institutions parse failed (chunk ${i}): ${e.message}`); }
    }
    rows.forEach(r => { if (r.CERT != null) instByCert[r.CERT] = r; });
    log(`institution meta: ${Object.keys(instByCert).length}/${certs.length}`);
  }

  const BKCLASS = {
    N: 'National bank', SM: 'State member bank', NM: 'State non-member bank',
    SB: 'Savings bank', SA: 'Savings association', OI: 'Insured US branch of a foreign bank',
  };

  // Build the lenders directory: one row per institution with an LA-County branch.
  const lenders = certs.map((cert, idx) => {
    const m = instByCert[cert] || {};
    const web = m.WEBADDR ? m.WEBADDR.replace(/^https?:\/\//, '') : null;
    return {
      id: idx + 1,
      cert: Number(cert),
      name: m.NAME || (rawBranches.find(b => String(b.CERT) === String(cert)) || {}).NAME || `FDIC cert ${cert}`,
      bank_class: BKCLASS[m.BKCLASS] || m.BKCLASS || null,
      la_branches: branchCount[cert] || 0,
      total_offices: m.OFFICES != null ? Number(m.OFFICES) : null,
      total_assets_000: m.ASSET != null ? Number(m.ASSET) : null,   // $ thousands
      deposits_000: m.DEP != null ? Number(m.DEP) : null,           // $ thousands
      net_income_000: m.NETINC != null ? Number(m.NETINC) : null,
      roa: m.ROA != null ? Number(m.ROA) : null,
      hq_city: m.CITY || null,
      hq_county: m.COUNTY || null,
      hq_state: m.STALP || null,
      established: m.ESTYMD || null,
      active: m.ACTIVE === 1 || m.ACTIVE === '1',
      website: web,
      // provenance (package §16 evidence-first)
      source: 'FDIC BankFind',
      source_url: `https://banks.data.fdic.gov/bankfind-suite/bankfind/details/${cert}`,
      identifier: `FDIC CERT ${cert}`,
      retrieved_at,
    };
  }).sort((a, b) => (b.la_branches - a.la_branches) || ((b.total_assets_000 || 0) - (a.total_assets_000 || 0)));

  // Branch-level rows (business addresses only — public FDIC facts).
  const branches = rawBranches.map((b, idx) => ({
    id: idx + 1,
    cert: b.CERT != null ? Number(b.CERT) : null,
    bank: b.NAME || null,
    address: b.ADDRESS || null,
    city: b.CITY || null,
    zip: b.ZIP || null,
    county: b.COUNTY || null,
    state: b.STALP || null,
    branch_type: b.SERVTYPE || null,
    main_office: b.MAINOFF === 1 || b.MAINOFF === '1',
    established: b.ESTYMD || null,
    lat: b.LATITUDE != null ? Number(b.LATITUDE) : null,
    lng: b.LONGITUDE != null ? Number(b.LONGITUDE) : null,
    source: 'FDIC BankFind',
    source_url: b.CERT != null ? `https://banks.data.fdic.gov/bankfind-suite/bankfind/details/${b.CERT}` : HUMAN,
    retrieved_at,
  }));

  const meta = {
    source: 'FDIC BankFind Suite API',
    source_home: HUMAN,
    api: API,
    scope: `FDIC-insured banks with a branch in ${COUNTY} County, ${STATE}`,
    access_method: 'official_api',
    personal_data: false,
    retrieved_at,
  };

  atomicWrite(OUT_LENDERS, { meta: { ...meta, count: lenders.length }, lenders });
  atomicWrite(OUT_BRANCHES, { meta: { ...meta, count: branches.length }, branches });
  log(`wrote ${lenders.length} lenders → ${path.relative(ROOT, OUT_LENDERS)}`);
  log(`wrote ${branches.length} branches → ${path.relative(ROOT, OUT_BRANCHES)}`);
  log('source:', HUMAN, '($0, official FDIC public API)');
}

main();