β back to Commercialrealestate
CRE: add Census ACS demographics fetcher (70 LA submarkets) + per-card π demographics line; npm run demographics
baf6f58961b83478e22b627921687729d40fc156 Β· 2026-06-27 00:40:16 -0700 Β· Steve
Files touched
M package.jsonM public/index.htmlA scripts/fetch-demographics.js
Diff
commit baf6f58961b83478e22b627921687729d40fc156
Author: Steve <steve@designerwallcoverings.com>
Date: Sat Jun 27 00:40:16 2026 -0700
CRE: add Census ACS demographics fetcher (70 LA submarkets) + per-card π demographics line; npm run demographics
---
package.json | 4 +-
public/index.html | 12 ++++++
scripts/fetch-demographics.js | 92 +++++++++++++++++++++++++++++++++++++++++++
3 files changed, 107 insertions(+), 1 deletion(-)
diff --git a/package.json b/package.json
index 23e2d2e..bfc6a56 100644
--- a/package.json
+++ b/package.json
@@ -2,11 +2,13 @@
"name": "commercialrealestate",
"version": "0.1.0",
"private": true,
- "description": "San Fernando Valley CRE investment ranker ($400k down, leveraged) β Crexi-sourced, Qwen-analyzed",
+ "description": "LA County CRE investment explorer β multi-firm sourced, Census + assessor enriched, Qwen-analyzed",
"scripts": {
"scrape": "node scripts/scrape-crexi.js",
"refresh": "NODE_PATH=$HOME/.claude/skills/browserbase/node_modules node scripts/refresh-all.js",
+ "sweep": "bash scripts/run-sweep.sh",
"discover": "NODE_PATH=$HOME/.claude/skills/browserbase/node_modules node scripts/discover-firm.js",
+ "demographics": "node scripts/fetch-demographics.js",
"analyze": "node scripts/analyze.js",
"serve": "node scripts/serve.js"
},
diff --git a/public/index.html b/public/index.html
index efbc545..ecd5885 100644
--- a/public/index.html
+++ b/public/index.html
@@ -206,6 +206,16 @@ const SCEN = { '25':{budget:400000,downPct:25,ratePct:6.75,amortYears:30,closing
'cash':{budget:400000,downPct:100,ratePct:0,amortYears:30,closingPct:2.5} };
let DATA=null, scenario='25', history=[];
+window.DEMO = {}; // US Census ACS demographics by lowercased city (data/demographics.json)
+function demoLine(p){
+ const d = window.DEMO[(p.city||'').toLowerCase()]; if(!d) return '';
+ const bits=[];
+ if(d.renter_pct!=null) bits.push(`${d.renter_pct}% renters`);
+ if(d.median_gross_rent) bits.push(`$${d.median_gross_rent.toLocaleString()} med rent`);
+ if(d.median_income) bits.push(`$${Math.round(d.median_income/1000)}k income`);
+ if(d.population) bits.push(`${Math.round(d.population/1000)}k pop`);
+ return bits.length ? `<div class="small" title="US Census ACS 2022 β ${p.city} (ZCTA ${d.zcta})">π ${bits.join(' Β· ')}</div>` : '';
+}
// Rich filter state
const F = { types:new Set(), firms:new Set(), cityQuery:'', priceMin:null, priceMax:null,
capMin:0, unitsMin:null, yearMin:null, status:'all', afford:false };
@@ -262,6 +272,7 @@ function card(p){
<div class="thesis">${p.qwen?.thesis||''}</div>
${p.qwen?.risks?.length?`<div><span class="small">Risks</span><ul class="lst">${p.qwen.risks.map(r=>`<li>${r}</li>`).join('')}</ul></div>`:''}
${p.upside_note?`<div class="small">π‘ ${p.upside_note}</div>`:''}
+ ${demoLine(p)}
<div class="src"><a href="${p.source}" target="_blank" rel="noopener noreferrer">View listing β</a> Β· <button class="lcbtn" data-id="${p.id}" title="Pull fresh comps from LoopNet via a cloud browser (~$0.03)">π Live comps</button> Β· <button class="histbtn" data-id="${p.id}" data-address="${(p.address||'').replace(/"/g,'"')}" data-city="${(p.city||'').replace(/"/g,'"')}" data-zip="${p.zip||''}" title="LA County assessor + permit + film history (free)">π Property history</button></div>
<div class="lcout" id="lc-${p.id}"></div>
<div class="histout" id="hist-${p.id}"></div>
@@ -459,6 +470,7 @@ function closeDrawer(){ $('#drawer').classList.remove('open'); $('#scrim').class
// ---- boot ----
fetch('data/comps.json').then(r=>r.json()).then(c=>{ if(DATA) DATA.comps=c; else window.__comps=c; }).catch(()=>{});
+fetch('data/demographics.json').then(r=>r.json()).then(d=>{ window.DEMO=d.byCity||{}; if(DATA) render(); }).catch(()=>{});
fetch('data/ranked.json').then(r=>r.json()).then(d=>{
DATA=d; if(window.__comps) DATA.comps=window.__comps;
$('#sub').innerHTML = `${d.ranked.length} listings Β· <b>${d.meta.market}</b>${d.generated?' Β· sourced '+d.generated:''}`;
diff --git a/scripts/fetch-demographics.js b/scripts/fetch-demographics.js
new file mode 100644
index 0000000..bacd859
--- /dev/null
+++ b/scripts/fetch-demographics.js
@@ -0,0 +1,92 @@
+// fetch-demographics.js β pull real US Census ACS 5-year (2022) 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).
+//
+// Auth: needs a free Census API key in CENSUS_API_KEY (env or commercialrealestate/.env). Get one at
+// api.census.gov/data/key_signup.html. $0 β Census is free. 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, '..');
+// Read CENSUS_API_KEY from env or .env
+let KEY = process.env.CENSUS_API_KEY;
+if (!KEY) { try { KEY = (fs.readFileSync(path.join(ROOT, '.env'), 'utf8').match(/^CENSUS_API_KEY=(.+)$/m) || [])[1]?.trim(); } catch (_) {} }
+if (!KEY) { console.error('No CENSUS_API_KEY (env or .env). Get a free one at api.census.gov/data/key_signup.html'); process.exit(2); }
+
+// 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'
+};
+
+// ACS5 2022 variables we pull.
+const VARS = {
+ B01003_001E: 'population', B19013_001E: 'median_income', B25064_001E: 'median_gross_rent',
+ B25003_001E: '_occupied', B25003_003E: '_renter_occ', B01002_001E: 'median_age',
+ B25077_001E: 'median_home_value', B25071_001E: 'rent_burden_pct'
+};
+const VARLIST = Object.keys(VARS).join(',');
+
+function get(url) {
+ return new Promise((resolve, reject) => {
+ https.get(url, res => { let d = ''; res.on('data', c => d += c); res.on('end', () => resolve({ status: res.statusCode, body: d })); }).on('error', reject);
+ });
+}
+const numOrNull = v => { const n = +v; return Number.isFinite(n) && n > -666666 ? n : null; }; // ACS uses -666666666 for n/a
+
+(async () => {
+ const out = {};
+ const cities = Object.entries(CITY_ZCTA);
+ console.log(`Fetching ACS 2022 demographics for ${cities.length} LA County submarketsβ¦`);
+ let ok = 0, fail = 0;
+ for (const [city, zcta] of cities) {
+ const url = `https://api.census.gov/data/2022/acs/acs5?get=NAME,${VARLIST}&for=zip%20code%20tabulation%20area:${zcta}&key=${KEY}`;
+ try {
+ const r = await get(url);
+ if (r.status !== 200) { console.log(` β ${city} (${zcta}) HTTP ${r.status}`); fail++; continue; }
+ const j = JSON.parse(r.body); // [[header...],[row...]]
+ const header = j[0], row = j[1];
+ if (!row) { console.log(` β ${city} (${zcta}) no data`); fail++; continue; }
+ const rec = {};
+ header.forEach((h, i) => { if (VARS[h]) rec[VARS[h]] = numOrNull(row[i]); });
+ const renterPct = (rec._occupied && rec._renter_occ != null) ? Math.round(1000 * rec._renter_occ / rec._occupied) / 10 : null;
+ out[city] = {
+ zcta, population: rec.population, median_income: rec.median_income,
+ median_gross_rent: rec.median_gross_rent, renter_pct: renterPct,
+ median_age: rec.median_age, median_home_value: rec.median_home_value,
+ rent_burden_pct: rec.rent_burden_pct,
+ source: 'US Census ACS 5-year 2022', year: 2022
+ };
+ ok++;
+ } catch (e) { console.log(` β ${city} (${zcta}) ${e.message}`); fail++; }
+ }
+ const payload = { generated: new Date().toISOString().slice(0, 10), source: 'US Census ACS 5-year (2022), per representative ZCTA', 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} failed) ===`);
+ if (ok) { const s = out[Object.keys(out)[0]]; console.log('sample:', JSON.stringify(s)); }
+})();
β 46ede35 CRE data: Crexi LA County sweep β 104β1028 properties (all a
Β·
back to Commercialrealestate
Β·
CRE: live Census ACS demographics for 73 LA submarkets (keyl b6964fd β