[object Object]

← 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

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,'&quot;')}" data-city="${(p.city||'').replace(/"/g,'&quot;')}" 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 β†’