← back to Ca Donations
scripts/ingest-calaccess-political.mjs
250 lines
// Ingest CA state campaign-finance CONTRIBUTIONS from the CAL-ACCESS daily bulk dump.
//
// Source: https://campaignfinance.cdn.sos.ca.gov/dbwebexport.zip (~1.58 GB, daily, free)
// Table of interest: RCPT_CD (receipts / contributions), tab-delimited, header row present.
// RCPT = Schedule A monetary contributions received on Form 460/461/etc.
//
// v1 scope: filter to RCPT_DATE >= 2018-01-01 (RCPT_CD is ~10M+ rows / 3.8 GB uncompressed).
// Maps into political_contributions with jurisdiction='state', source_slug='calaccess',
// form = the row's FORM_TYPE, external_id = FILING_ID:TRAN_ID (idempotent upsert).
//
// Recipient committee/candidate NAME resolved via CVR_CAMPAIGN_DISCLOSURE_CD (FILING_ID ->
// FILER_NAML), which is a direct join every RCPT row supports. We DON'T install the
// django-calaccess-raw-data Django/MySQL stack — we stream the TSVs straight from the zip.
//
// RCPT_CD columns (verified against the dump's own header at runtime, mapped BY NAME):
// FILING_ID, TRAN_ID, FORM_TYPE, RCPT_DATE, AMOUNT, CMTE_ID,
// CTRIB_NAML/NAMF/NAMT/NAMS, CTRIB_EMP, CTRIB_OCC, CTRIB_CITY, CTRIB_ST, CTRIB_ZIP4,
// OFFICE_CD, OFFIC_DSCR
//
// $0 — local bulk parse, no paid API.
import fs from 'node:fs';
import path from 'node:path';
import readline from 'node:readline';
import { fileURLToPath } from 'node:url';
import unzipper from 'unzipper';
import { q, startRun, upsertSource, pool } from '../lib/db.js';
import { refreshPoliticalAgg } from './refresh-political-agg.mjs';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const RAW = path.join(__dirname, '..', 'data', 'raw');
const ZIP = path.join(RAW, 'dbwebexport.zip');
const SLUG = 'calaccess';
const SINCE = process.env.SINCE || '2018-01-01';
const BATCH = 2000;
const MAX_ROWS = process.env.MAX_ROWS ? parseInt(process.env.MAX_ROWS, 10) : 0; // 0 = no cap
async function openEntry(wantBase) {
const dir = await unzipper.Open.file(ZIP);
return dir.files.find(
(f) => path.basename(f.path).toLowerCase() === wantBase.toLowerCase(),
) || null;
}
// Fully-buffered line reader with a per-line async callback that the loop AWAITS,
// so DB flushes apply real backpressure (readline's async iterator pauses the stream
// while we're awaiting inside the loop).
async function forEachLine(entry, onLine) {
const rl = readline.createInterface({ input: entry.stream(), crlfDelay: Infinity });
for await (const line of rl) await onLine(line);
}
function headerIndex(line) {
const idx = {};
line.split('\t').forEach((name, i) => (idx[name.trim().toUpperCase()] = i));
return idx;
}
// Strip NUL + other control chars Postgres UTF8 rejects; trim; empty -> null.
function clean(s) {
if (s == null) return null;
// Drop C0 control chars (incl. NUL) + DEL.
const t = String(s).replace(/[\x00-\x1F\x7F]/g, '').trim();
return t === '' ? null : t;
}
function joinName(...parts) {
return parts.map((p) => clean(p) || '').filter(Boolean).join(' ') || null;
}
// CAL-ACCESS dates are M/D/YYYY (optionally with a trailing time). Return YYYY-MM-DD or null.
// The raw data has typo'd years (e.g. 8201, 5201, 6830); reject anything outside a sane window
// so a bad year can't slip past the SINCE lexical filter.
function parseDate(s) {
if (!s) return null;
const t = s.trim();
let y, mo, d;
const m = t.match(/^(\d{1,2})\/(\d{1,2})\/(\d{4})/);
if (m) { mo = +m[1]; d = +m[2]; y = +m[3]; }
else {
const iso = t.match(/^(\d{4})-(\d{1,2})-(\d{1,2})/);
if (!iso) return null;
y = +iso[1]; mo = +iso[2]; d = +iso[3];
}
if (y < 1974 || y > 2030 || mo < 1 || mo > 12 || d < 1 || d > 31) return null;
return `${String(y).padStart(4, '0')}-${String(mo).padStart(2, '0')}-${String(d).padStart(2, '0')}`;
}
const COLS = [
'source_slug', 'external_id', 'donor_name', 'donor_employer', 'donor_occupation',
'donor_city', 'donor_state', 'donor_zip', 'amount', 'contribution_date',
'recipient_name', 'recipient_id', 'office', 'jurisdiction', 'form',
];
async function flush(rows) {
if (!rows.length) return 0;
// Dedupe within the batch by external_id (RCPT keeps every AMEND_ID, so FILING_ID:TRAN_ID
// repeats). Postgres rejects a multi-row upsert that touches the same conflict key twice;
// keep the LAST occurrence (later amendments overwrite earlier ones).
const byKey = new Map();
for (const r of rows) byKey.set(r.external_id, r);
rows = [...byKey.values()];
const ph = rows.map((_, i) => {
const b = i * COLS.length;
return '(' + COLS.map((_, j) => `$${b + j + 1}`).join(',') + ')';
});
const vals = [];
for (const r of rows) vals.push(...COLS.map((c) => r[c]));
await q(
`INSERT INTO political_contributions (${COLS.join(',')}) VALUES ${ph.join(',')}
ON CONFLICT (source_slug, external_id) DO UPDATE SET
donor_name=EXCLUDED.donor_name, donor_employer=EXCLUDED.donor_employer,
donor_occupation=EXCLUDED.donor_occupation, donor_city=EXCLUDED.donor_city,
donor_state=EXCLUDED.donor_state, donor_zip=EXCLUDED.donor_zip,
amount=EXCLUDED.amount, contribution_date=EXCLUDED.contribution_date,
recipient_name=EXCLUDED.recipient_name, recipient_id=EXCLUDED.recipient_id,
office=EXCLUDED.office, form=EXCLUDED.form`,
vals,
);
return rows.length;
}
// Build FILING_ID -> { name, filerId } from CVR_CAMPAIGN_DISCLOSURE_CD (recipient of the filing).
// Keep the AMEND_ID=highest? Simpler: last-writer-wins is fine for a display name.
async function buildRecipientMap() {
const map = new Map();
const entry = await openEntry('CVR_CAMPAIGN_DISCLOSURE_CD.TSV');
if (!entry) {
console.warn(' CVR_CAMPAIGN_DISCLOSURE_CD.TSV not found — recipient names blank.');
return map;
}
let idx = null;
await forEachLine(entry, (line) => {
if (idx === null) { idx = headerIndex(line); return; }
if (!line) return;
const c = line.split('\t');
const filingId = c[idx['FILING_ID']];
if (!filingId) return;
const name = joinName(c[idx['FILER_NAMT']], c[idx['FILER_NAMF']], c[idx['FILER_NAML']], c[idx['FILER_NAMS']]);
const filerId = clean(c[idx['FILER_ID']]);
if (name || filerId) map.set(filingId, { name, filerId });
});
console.log(` CVR recipient map: ${map.size} filings.`);
return map;
}
async function main() {
if (!fs.existsSync(ZIP)) {
console.error(`Missing ${ZIP}. Download dbwebexport.zip into data/raw/ first.`);
process.exit(1);
}
await upsertSource({
slug: SLUG,
name: 'CAL-ACCESS Campaign Finance (RCPT contributions)',
url: 'https://campaignfinance.cdn.sos.ca.gov/dbwebexport.zip',
jurisdiction: 'state', family: 'political', granularity: 'donor', access: 'bulk',
notes: `RCPT_CD Schedule A receipts, contribution_date >= ${SINCE}. Daily CAL-ACCESS dump.`,
});
const run = await startRun(SLUG);
let seen = 0, kept = 0, upserted = 0, skippedOld = 0, skippedNoTran = 0;
try {
console.log('Building recipient map from CVR_CAMPAIGN_DISCLOSURE_CD...');
const recip = await buildRecipientMap();
const rcpt = await openEntry('RCPT_CD.TSV');
if (!rcpt) throw new Error('RCPT_CD.TSV not found in dump');
let idx = null, need = null;
let batch = [];
let stop = false;
await forEachLine(rcpt, async (line) => {
if (stop) return;
if (idx === null) {
idx = headerIndex(line);
need = {
filing: idx['FILING_ID'], tran: idx['TRAN_ID'], form: idx['FORM_TYPE'],
date: idx['RCPT_DATE'], amt: idx['AMOUNT'], cmte: idx['CMTE_ID'],
naml: idx['CTRIB_NAML'], namf: idx['CTRIB_NAMF'], namt: idx['CTRIB_NAMT'], nams: idx['CTRIB_NAMS'],
emp: idx['CTRIB_EMP'], occ: idx['CTRIB_OCC'],
city: idx['CTRIB_CITY'], st: idx['CTRIB_ST'], zip: idx['CTRIB_ZIP4'],
officeCd: idx['OFFICE_CD'], officeDscr: idx['OFFIC_DSCR'],
};
if (need.date === undefined || need.amt === undefined || need.tran === undefined) {
throw new Error(`RCPT_CD header missing required cols. Header=${line}`);
}
return;
}
if (!line) return;
seen++;
const c = line.split('\t');
const tran = c[need.tran];
if (!tran) { skippedNoTran++; return; }
const date = parseDate(c[need.date]);
if (!date || date < SINCE) { skippedOld++; return; }
const filingId = c[need.filing] || null;
const r = filingId ? recip.get(filingId) : null;
const amtRaw = c[need.amt];
const amount = amtRaw != null && amtRaw.trim() !== '' ? Number(amtRaw) : null;
const office = (need.officeDscr !== undefined ? clean(c[need.officeDscr]) : null)
|| (need.officeCd !== undefined ? clean(c[need.officeCd]) : null) || null;
batch.push({
source_slug: SLUG,
external_id: clean(filingId ? `${filingId}:${tran}` : tran),
donor_name: joinName(c[need.namt], c[need.namf], c[need.naml], c[need.nams]),
donor_employer: clean(c[need.emp]),
donor_occupation: clean(c[need.occ]),
donor_city: clean(c[need.city]),
donor_state: clean(c[need.st]),
donor_zip: clean(c[need.zip]),
amount: Number.isFinite(amount) ? amount : null,
contribution_date: date,
recipient_name: r?.name || null,
recipient_id: clean(c[need.cmte]) || r?.filerId || filingId || null,
office,
jurisdiction: 'state',
form: clean(c[need.form]) || '460A',
});
kept++;
if (batch.length >= BATCH) {
upserted += await flush(batch);
batch = [];
}
if (MAX_ROWS && kept >= MAX_ROWS) stop = true;
});
if (batch.length) upserted += await flush(batch);
console.log('Refreshing political_agg rollup...');
const aggCounts = await refreshPoliticalAgg();
for (const r of aggCounts) console.log(` agg ${r.dimension}: ${r.groups} groups (min_donors=${r.min_donors})`);
await run.done(seen, upserted, 'ok',
`kept ${kept} (>=${SINCE}); skippedOld ${skippedOld}; skippedNoTran ${skippedNoTran}`);
console.log(`CAL-ACCESS RCPT: seen ${seen}, kept ${kept} (>=${SINCE}), upserted ${upserted}. $0 (local bulk).`);
} catch (e) {
await run.done(seen, upserted, 'error', String(e.message));
console.error('FAILED:', e.stack || e.message);
process.exitCode = 1;
} finally {
await pool.end();
}
}
main();