← back to Commercialrealestate
scripts/fetch-demographics.js
90 lines
// fetch-demographics.js — pull real US Census ACS 5-year demographics per LA County submarket and
// write data/demographics.json, keyed by lowercased city name. Surfaces the figures that matter for
// CRE underwriting: population, median household income, median gross rent, renter-occupied %, median
// age, median home value, and rent burden (gross rent as % of income).
//
// SOURCE: Census Reporter (api.censusreporter.org) — a free, KEYLESS wrapper over the same Census ACS
// 5-year data. $0, no signup. (The raw Census API at api.census.gov needs a key; this avoids that.)
// Run: node scripts/fetch-demographics.js
//
// Each city maps to a representative ZCTA (ZIP-code tabulation area), the geography ACS publishes
// uniformly across incorporated cities and LA neighborhoods alike. Figures are directional submarket
// context (one ZCTA per city), not parcel-level — verify in DD.
'use strict';
const fs = require('fs');
const path = require('path');
const https = require('https');
const ROOT = path.join(__dirname, '..');
// Representative ZCTA per submarket (lowercased city name -> ZIP).
const CITY_ZCTA = {
'los angeles': '90015', 'pasadena': '91101', 'inglewood': '90301', 'long beach': '90802',
'san pedro': '90731', 'santa monica': '90404', 'torrance': '90503', 'glendale': '91205',
'north hollywood': '91601', 'pomona': '91766', 'van nuys': '91405', 'burbank': '91505',
'el monte': '91731', 'culver city': '90232', 'west hollywood': '90046', 'northridge': '91324',
'whittier': '90602', 'downey': '90241', 'reseda': '91335', 'venice': '90291', 'canoga park': '91303',
'beverly hills': '90210', 'sherman oaks': '91403', 'panorama city': '91402', 'tujunga': '91042',
'sun valley': '91352', 'altadena': '91001', 'montebello': '90640', 'lake balboa': '91406',
'granada hills': '91344', 'woodland hills': '91367', 'san gabriel': '91776', 'temple city': '91780',
'north hills': '91343', 'studio city': '91604', 'sunland': '91040', 'pico rivera': '90660',
'rosemead': '91770', 'lancaster': '93534', 'newhall': '91321', 'pacoima': '91331',
'valley village': '91607', 'sylmar': '91342', 'winnetka': '91306', 'la crescenta': '91214',
'baldwin park': '91706', 'porter ranch': '91326', 'hollywood': '90028', 'marina del rey': '90292',
'malibu': '90265', 'redondo beach': '90277', 'signal hill': '90755', 'hacienda heights': '91745',
'arcadia': '91006', 'palmdale': '93550', 'santa clarita': '91350', 'mission hills': '91345',
'tarzana': '91356', 'gardena': '90247', 'agoura hills': '91301', 'alhambra': '91801',
'west hills': '91307', 'san fernando': '91340', 'eagle rock': '90041', 'mar vista': '90066',
'pacific palisades': '90272', 'santa fe springs': '90670', 'south el monte': '91733',
'norwalk': '90650', 'monrovia': '91016', 'littlerock': '93543', 'castaic': '91384',
'la canada flintridge': '91011'
};
// ACS tables we pull from Census Reporter.
const TABLES = 'B01003,B19013,B25064,B25003,B01002,B25077,B25071';
function get(url) {
return new Promise((resolve, reject) => {
https.get(url, { headers: { 'User-Agent': 'cre-demographics/1.0' } },
res => { let d = ''; res.on('data', c => d += c); res.on('end', () => resolve({ status: res.statusCode, body: d })); }).on('error', reject);
});
}
const n = v => { const x = +v; return Number.isFinite(x) ? x : null; };
const chunk = (arr, k) => { const o = []; for (let i = 0; i < arr.length; i += k) o.push(arr.slice(i, i + k)); return o; };
(async () => {
const cities = Object.entries(CITY_ZCTA);
const zToCity = {}; cities.forEach(([c, z]) => zToCity['86000US' + z] = { city: c, zcta: z });
console.log(`Fetching ACS demographics for ${cities.length} LA County submarkets via Census Reporter (keyless)…`);
const out = {};
let ok = 0, fail = 0;
for (const group of chunk(Object.keys(zToCity), 18)) {
const url = `https://api.censusreporter.org/1.0/data/show/latest?table_ids=${TABLES}&geo_ids=${group.join(',')}`;
try {
const r = await get(url);
if (r.status !== 200) { console.log(` ✗ chunk HTTP ${r.status}`); fail += group.length; continue; }
const j = JSON.parse(r.body);
for (const geo of group) {
const d = j.data && j.data[geo]; const meta = zToCity[geo];
if (!d) { fail++; continue; }
const est = t => (d[t] && d[t].estimate) || {};
const pop = n(est('B01003').B01003001), inc = n(est('B19013').B19013001), rent = n(est('B25064').B25064001);
const occ = n(est('B25003').B25003001), ren = n(est('B25003').B25003003), age = n(est('B01002').B01002001);
const homeVal = n(est('B25077').B25077001), burden = n(est('B25071').B25071001);
out[meta.city] = {
zcta: meta.zcta, population: pop, median_income: inc, median_gross_rent: rent,
renter_pct: (occ && ren != null) ? Math.round(1000 * ren / occ) / 10 : null,
median_age: age, median_home_value: homeVal, rent_burden_pct: burden,
source: 'US Census ACS 5-year (via Census Reporter)'
};
ok++;
}
} catch (e) { console.log(` ✗ chunk ${e.message}`); fail += group.length; }
}
const payload = { generated: new Date().toISOString().slice(0, 10), source: 'US Census ACS 5-year, per representative ZCTA (Census Reporter)', count: ok, byCity: out };
fs.writeFileSync(path.join(ROOT, 'data', 'demographics.json'), JSON.stringify(payload, null, 2));
console.log(`\n=== demographics.json written: ${ok} submarkets (${fail} missing) ===`);
if (ok) { const k = Object.keys(out)[0]; console.log('sample', k, '→', JSON.stringify(out[k])); }
})();