← back to La Socrata Ingester
src/cslb/download.js
83 lines
// CSLB License Master downloader — the free public portal is an ASP.NET
// session/postback flow (no static URL). Flow:
// 1. GET the page -> session cookie + viewstate tokens
// 2. POST ddlStatus=M (dropdown-change postback) -> page with the CSV button + new tokens
// 3. POST the CSV linkbutton -> streams the License Master CSV
// Writes the CSV to tmp/cslb-master.csv.
import fs from 'fs';
import path from 'path';
import { fileURLToPath } from 'url';
const URL = 'https://www.cslb.ca.gov/onlineservices/dataportal/ContractorList';
const UA = 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120 Safari/537.36';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const OUT = path.resolve(__dirname, '../../tmp/cslb-master.csv');
const pick = (html, name) => {
const m = html.match(new RegExp(`id="${name}"[^>]*value="([^"]*)"`)) ||
html.match(new RegExp(`name="${name}"[^>]*value="([^"]*)"`));
return m ? m[1] : '';
};
const tokens = (html) => ({
__VIEWSTATE: pick(html, '__VIEWSTATE'),
__VIEWSTATEGENERATOR: pick(html, '__VIEWSTATEGENERATOR'),
__EVENTVALIDATION: pick(html, '__EVENTVALIDATION'),
});
// find the CSV download linkbutton's postback target from the page
function findCsvTarget(html) {
const targets = [...html.matchAll(/__doPostBack\(&?#?39?;?'?([^'&]*(?:CSV|Csv)[^'&]*)/g)].map((m) => m[1]);
// prefer a Master CSV button
return targets.find((t) => /master/i.test(t)) || targets.find((t) => /csv/i.test(t)) || 'ctl00$MainContent$lbMasterCSV';
}
async function post(cookie, fields) {
const body = new URLSearchParams(fields).toString();
return fetch(URL, {
method: 'POST',
headers: { 'User-Agent': UA, 'Content-Type': 'application/x-www-form-urlencoded', Cookie: cookie, Referer: URL },
body,
});
}
async function main() {
// 1. GET
const r1 = await fetch(URL, { headers: { 'User-Agent': UA } });
const cookie = (r1.headers.get('set-cookie') || '').split(',').map((c) => c.split(';')[0].trim()).filter((c) => /=/.test(c)).join('; ');
const html1 = await r1.text();
const t1 = tokens(html1);
console.log('step1: session', cookie ? 'ok' : 'MISSING', '| viewstate', t1.__VIEWSTATE ? t1.__VIEWSTATE.length + 'b' : 'MISSING');
// 2. select License Master (dropdown-change postback)
const r2 = await post(cookie, {
__EVENTTARGET: 'ctl00$MainContent$ddlStatus', __EVENTARGUMENT: '', __LASTFOCUS: '',
...t1, 'ctl00$MainContent$ddlStatus': 'M',
});
const html2 = await r2.text();
const t2 = tokens(html2);
const csvTarget = findCsvTarget(html2);
console.log('step2: master selected | csv target =', csvTarget, '| viewstate', t2.__VIEWSTATE ? t2.__VIEWSTATE.length + 'b' : 'MISSING');
// 3. click the CSV download button -> stream to file
const r3 = await post(cookie, {
__EVENTTARGET: csvTarget, __EVENTARGUMENT: '', __LASTFOCUS: '',
...t2, 'ctl00$MainContent$ddlStatus': 'M',
});
const ct = r3.headers.get('content-type') || '';
const cd = r3.headers.get('content-disposition') || '';
console.log('step3: status', r3.status, '| content-type', ct, '| disposition', cd.slice(0, 60));
if (!/csv|octet-stream|text\/plain|application\/vnd/i.test(ct) && !/attachment/i.test(cd)) {
const txt = await r3.text();
fs.writeFileSync(OUT.replace('.csv', '-debug.html'), txt);
throw new Error(`step3 did not return a file (got ${ct}); wrote debug HTML. First 200: ${txt.slice(0, 200)}`);
}
const buf = Buffer.from(await r3.arrayBuffer());
fs.mkdirSync(path.dirname(OUT), { recursive: true });
fs.writeFileSync(OUT, buf);
const lines = buf.toString('utf8', 0, Math.min(buf.length, 400)).split('\n')[0];
console.log(`✔ wrote ${OUT} — ${(buf.length / 1e6).toFixed(1)} MB`);
console.log(' header:', lines.slice(0, 180));
}
main().catch((e) => { console.error('✖', e.message); process.exit(1); });