← back to Commercialrealestate

scripts/fetch-sba-lenders.js

235 lines

#!/usr/bin/env node
// fetch-sba-lenders.js — CRCP "SBA Lenders" builder for the lending-intelligence layer.
//
// Pulls REAL public data only ($0) from the official U.S. SBA FOIA loan-level datasets
// (data.sba.gov — "7(a) & 504 FOIA"), the SBA's own loan-approval disclosures:
//   - FOIA_7a_FY2020_Present  — every 7(a) loan approved FY2020→present (~181 MB CSV)
//   - FOIA_504_FY2010_Present — every 504 loan approved FY2010→present (~59 MB CSV)
//
// These CSVs are LARGE, so they are STREAMED through curl → a line-by-line parser (never
// buffered whole into memory). We filter to BorrState=CA and aggregate to one row per
// LENDER (7(a) originating BankName, or 504 CDC_Name) with:
//   loan_count, total_approved ($), avg_loan ($), the fiscal-year span, and — because
//   ProjectCounty carries "LOS ANGELES" cleanly — an LA-County breakout (la_loan_count /
//   la_approved). Program (7(a)/504) is kept distinct, so a lender that does both appears
//   as two rows.
//
// NO PERSONAL DATA — borrower names/addresses are read only to filter to CA, never stored.
// Output is institution-level (lender) aggregates only.
//
// Every row is source-cited: source="U.S. SBA", source_url=the dataset page,
// identifier=lender name, retrieved_at=now.
//
// Idempotent + re-runnable. Logs the real source URLs + row counts. Atomic write.
// If the CSVs can't be reached/streamed, writes an HONEST manual_review_only stub
// (lenders:[] + a deep link) — never fabricates lenders.
//
// Run: node scripts/fetch-sba-lenders.js

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

const ROOT = path.join(__dirname, '..');
const OUT = path.join(ROOT, 'data', 'sba-lenders.json');

// ---- real public source (SBA 7(a) & 504 FOIA CSVs, verified reachable 2026-07-31, $0, no key) ----
const DATASET_HOME = 'https://data.sba.gov/dataset/7a-504-foia';
const BASE = 'https://data.sba.gov/sites/default/files/uploaded_resources';
// Most-recent FOIA files (as-of 2026-06-30). We use ONLY the most recent file per program
// (7(a) FY2020-Present, 504 FY2010-Present) to keep the download bounded — see meta.scope.
const SOURCES = [
  { program: '7(a)', url: `${BASE}/FOIA_7a_FY2020_Present_asof_260630.csv`, lenderCol: 'BankName' },
  { program: '504', url: `${BASE}/FOIA_504_FY2010_Present_asof_260630.csv`, lenderCol: 'CDC_Name' },
];

const FILTER_STATE = 'CA';
const LA_COUNTY = 'LOS ANGELES';

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

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

// Parse one CSV line into fields (handles quoted fields + embedded commas/quotes).
// SBA FOIA CSVs are RFC-4180-ish: fields optionally double-quoted, "" = escaped quote.
function parseCsvLine(line) {
  const out = [];
  let cur = '', inq = false;
  for (let i = 0; i < line.length; i++) {
    const c = line[i];
    if (inq) {
      if (c === '"') {
        if (line[i + 1] === '"') { cur += '"'; i++; }
        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;
}

// Stream one CSV via curl, filter to CA, fold into `agg` (keyed by "program|lender").
// Returns a promise. A field can contain an embedded newline (rare) — the streamed
// readline splits on \n, so a record with an embedded newline is skipped defensively
// (length-guard) rather than mis-parsed; these are negligible and never fabricated.
function streamAndAggregate(src, agg) {
  return new Promise((resolve, reject) => {
    // -sL follows redirects; --fail-with-body surfaces a 4xx/5xx as a non-zero exit.
    const curl = spawn('curl', ['-sL', '--fail', '--max-time', '900', src.url]);
    const rl = readline.createInterface({ input: curl.stdout, crlfDelay: Infinity });

    let header = null, idx = null, seen = 0, caKept = 0;
    let curlErr = '';
    curl.stderr.on('data', d => { curlErr += d.toString(); });

    rl.on('line', (line) => {
      if (!line) return;
      if (header === null) {
        header = parseCsvLine(line).map(h => h.trim());
        idx = {};
        header.forEach((h, i) => { idx[h] = i; });
        // Required columns must exist or we abort this source (never guess positions).
        const need = ['BorrState', 'GrossApproval', 'ApprovalFY', 'ProjectCounty', src.lenderCol];
        const missing = need.filter(n => idx[n] == null);
        if (missing.length) { rl.close(); curl.kill(); reject(new Error(`${src.program}: missing columns ${missing.join(',')}`)); }
        return;
      }
      const f = parseCsvLine(line);
      if (f.length < header.length) return; // defensive: truncated / embedded-newline row
      seen++;
      if (f[idx['BorrState']] !== FILTER_STATE) return;

      const lender = (f[idx[src.lenderCol]] || '').trim();
      if (!lender) return;
      const gross = parseFloat(f[idx['GrossApproval']]) || 0;
      const fy = parseInt(f[idx['ApprovalFY']], 10);
      const isLA = (f[idx['ProjectCounty']] || '').trim().toUpperCase() === LA_COUNTY;

      const key = `${src.program}|${lender.toUpperCase()}`;
      let a = agg[key];
      if (!a) {
        a = agg[key] = {
          program: src.program, lender, loan_count: 0, total_approved: 0,
          la_loan_count: 0, la_approved: 0, fy_min: null, fy_max: null,
        };
      }
      a.loan_count++;
      a.total_approved += gross;
      if (isLA) { a.la_loan_count++; a.la_approved += gross; }
      if (Number.isFinite(fy)) {
        if (a.fy_min == null || fy < a.fy_min) a.fy_min = fy;
        if (a.fy_max == null || fy > a.fy_max) a.fy_max = fy;
      }
      caKept++;
    });

    rl.on('close', () => {
      if (curl.exitCode && curl.exitCode !== 0 && seen === 0) {
        return reject(new Error(`${src.program}: curl exit ${curl.exitCode} ${curlErr.trim()}`));
      }
      log(`${src.program}: streamed ${seen} rows, kept ${caKept} CA loan rows`);
      resolve({ seen, caKept });
    });
    curl.on('error', reject);
  });
}

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

  try {
    for (const src of SOURCES) {
      log(`streaming ${src.program} ← ${src.url}`);
      await streamAndAggregate(src, agg);
    }
  } catch (e) {
    // HONEST stub — never invent lenders.
    log('ERROR streaming SBA CSVs:', e.message);
    atomicWrite(OUT, {
      meta: {
        source: 'U.S. SBA',
        source_url: DATASET_HOME,
        access_method: 'manual_review_only',
        personal_data: false,
        scope: `SBA 7(a) + 504 FOIA loan-level data, aggregated by lender, BorrState=${FILTER_STATE} (LA-County breakout). SOURCE UNREACHABLE at build time — see source_url.`,
        fiscal_years: null,
        count: 0,
        error: e.message,
        retrieved_at,
      },
      lenders: [],
    });
    log(`wrote HONEST stub (0 lenders) → ${path.relative(ROOT, OUT)}`);
    log('deep link:', DATASET_HOME);
    return;
  }

  const keys = Object.keys(agg);
  let fyMin = null, fyMax = null;
  const lenders = keys.map((k, i) => {
    const a = agg[k];
    if (a.fy_min != null && (fyMin == null || a.fy_min < fyMin)) fyMin = a.fy_min;
    if (a.fy_max != null && (fyMax == null || a.fy_max > fyMax)) fyMax = a.fy_max;
    const round = (n) => Math.round(n);
    const fiscal_years = (a.fy_min != null && a.fy_max != null)
      ? (a.fy_min === a.fy_max ? `FY${a.fy_min}` : `FY${a.fy_min}–FY${a.fy_max}`)
      : null;
    return {
      id: i + 1,
      lender: a.lender,
      program: a.program,
      loan_count: a.loan_count,
      total_approved: round(a.total_approved),           // $ dollars
      avg_loan: a.loan_count ? round(a.total_approved / a.loan_count) : 0, // $ dollars
      la_loan_count: a.la_loan_count,
      la_approved: round(a.la_approved),                 // $ dollars
      fy_min: a.fy_min,
      fy_max: a.fy_max,
      fiscal_years,
      // provenance (evidence-first) — institution-level only, NO borrower data.
      // source_url points at the ACTUAL program FOIA CSV this row aggregates (the record-level
      // source), not the generic dataset landing page (kept separately as dataset_page).
      source: 'U.S. SBA',
      source_url: (SOURCES.find(s => s.program === a.program) || {}).url || DATASET_HOME,
      dataset_page: DATASET_HOME,
      identifier: a.lender,
      retrieved_at,
    };
  }).sort((x, y) => (y.total_approved - x.total_approved) || (y.loan_count - x.loan_count));

  // Reassign ids in sorted order so the top lender is id 1 (stable, unique numeric id).
  lenders.forEach((r, i) => { r.id = i + 1; });

  const fiscal_years = (fyMin != null && fyMax != null) ? `FY${fyMin}–FY${fyMax}` : null;

  atomicWrite(OUT, {
    meta: {
      source: 'U.S. SBA',
      source_url: DATASET_HOME,
      access_method: 'official_csv_stream',
      personal_data: false,
      scope: `SBA 7(a) + 504 FOIA loan-level approvals aggregated to one row per lender+program, filtered to BorrState=${FILTER_STATE}, with a Los Angeles-County breakout (la_loan_count / la_approved via ProjectCounty). Most-recent FOIA file per program only: 7(a)=FY2020-Present, 504=FY2010-Present (bounded download; older FOIA files at source_url).`,
      window_note: 'Unequal windows: 504 covers FY2010–present (~16 yrs), 7(a) covers FY2020–present (~6 yrs). Totals are NOT time-normalized — do not rank a 504 CDC against a 7(a) bank on total_approved without accounting for the window (filter by Program, or compare within a program).',
      fiscal_years,
      count: lenders.length,
      retrieved_at,
    },
    lenders,
  });

  log(`wrote ${lenders.length} SBA lenders → ${path.relative(ROOT, OUT)}`);
  log(`fiscal years covered: ${fiscal_years}`);
  log('source:', DATASET_HOME, '($0, official SBA FOIA CSVs)');
}

main().catch(e => { console.error('[sba-lenders] fatal', e); process.exit(1); });