← back to La Socrata Ingester

README.md

104 lines

# LA Socrata / ArcGIS Public-Data Ingester

Programmatic ingester for Los Angeles public data → the local `realestate` Postgres DB.
**$0** — pulls from free public government APIs, writes to local Postgres. No scraping,
no captcha, no login. Feeds the WhoLivedThere / ClaimMyAddress / BubbesBlock home-history stack.

Built TK-10450 (2026-08-10). All endpoints live-probed 2026-08-10.

## Why Socrata (+ one ArcGIS adapter)

LA City and LA County publish open data on **Socrata**, which exposes the **SODA API** —
real REST/JSON endpoints with SoQL (`$where`/`$select`/`$order`/`$limit`/`$offset`). That's
the "feed-first" doctrine applied to gov data: no selector-rot, no headless browser.

The one exception: **LA County assessor parcels live on ArcGIS FeatureServer**, not Socrata,
so this ingester carries two adapters behind one common page-generator interface.

## Sources

| Source (`--list` name)          | Platform | Dataset            | Rows    | Cadence | Dedup key              | Cursor                |
|---------------------------------|----------|--------------------|---------|---------|------------------------|-----------------------|
| `building_permits`              | Socrata  | `pi9x-tg5x`        | 405k    | daily   | (dataset_id,permit_nbr)| `issue_date`          |
| `building_permits_2010_2019`    | Socrata  | `dyxf-7hc4`        | 533k    | static  | (dataset_id,permit_nbr)| —                     |
| `building_permits_pre2010`      | Socrata  | `e67z-kt2n`        | 640k    | static  | (dataset_id,permit_nbr)| —                     |
| `building_permits_electrical`   | Socrata  | `ysqd-apz7`        | 354k    | daily   | (dataset_id,permit_nbr)| `issue_date`          |
| `building_permits_mech_plumb`   | Socrata  | `67is-svtd`        | 293k    | daily   | (dataset_id,permit_nbr)| `issue_date`          |
| `assessor_parcels`              | ArcGIS   | Parcel_Data_2021   | 12.1M   | annual  | (ain,roll_year)        | `RollYear`            |
| `code_enforcement_open`         | Socrata  | `u82d-eh7z`        | 29k     | daily   | apno                   | `adddttm`             |
| `code_enforcement_closed`       | Socrata  | `rken-a55j`        | 822k    | daily   | apno                   | `resdttm`             |
| `business_registrations`        | Socrata  | `6rrh-rzua`        | 632k    | weekly  | location_account       | `location_start_date` |
| `film_permits_weho`             | Socrata  | `xd5y-dzvf`        | 1.9k    | live    | permit_id              | —                     |

**Film permits — LA City has NO live public dataset** (both prior datasets deprecated).
FilmLA holds the consolidated LA City/County permit and only releases bulk via CPRA.
WeHo is the live free feed; a business-registration NAICS `512100` filter is a weak proxy
for LA City production companies.

## Setup

```bash
cd ~/Projects/la-socrata-ingester
npm install                       # one dep: pg
cp .env.example .env              # optional: add a free SOCRATA_APP_TOKEN to raise throttle
npm run schema                    # creates la_*_raw + la_ingest_state in realestate DB
```

## Usage

```bash
node src/cli.js --list                          # show all sources
node src/cli.js building_permits --max=25        # smoke test (25 rows)
node src/cli.js code_enforcement_open            # incremental refresh (since last cursor)
node src/cli.js "building permits"               # whole group
node src/cli.js all                              # every non-static source, incremental
node src/cli.js assessor_parcels --full          # 12.1M-row backfill (ALL roll years)
```

- No args / `--full`: full crawl. Otherwise **incremental** from the stored high-water mark
  in `la_ingest_state` (Socrata `cursor > last`, so daily re-runs are cheap).
- `assessor_parcels` defaults to the **latest roll year only** (~2.4M); `--full` pulls all 12.1M.
- `--max=N` caps rows (testing); `--page=N` overrides page size.
- Static historical permit buckets are excluded from `all` — run them once explicitly.

### One-time backfill (run in order; each is resumable — cursor persists on failure)

```bash
node src/cli.js building_permits --full
node src/cli.js building_permits_2010_2019
node src/cli.js building_permits_pre2010
node src/cli.js code_enforcement_open --full
node src/cli.js code_enforcement_closed --full
node src/cli.js business_registrations --full
node src/cli.js film_permits_weho --full
node src/cli.js assessor_parcels          # latest roll year first (sane); --full later
```

### Scheduled incremental (suggested launchd/cron, daily)

```bash
cd ~/Projects/la-socrata-ingester && node src/cli.js all >> tmp/ingest.log 2>&1
```

## Schema & joins

Each `la_*_raw` table keeps full source fidelity in `raw jsonb` **plus** typed columns for
the fields we query/join, **plus** provenance (`source_url`, `fetched_at`; `dataset_id` where
multiple datasets share a table). Upserts are `ON CONFLICT (<key>) DO UPDATE`.

- **Permits ↔ parcels**: `la_building_permits_raw.apn = la_assessor_parcels_raw.ain`
  (both 10-digit APN/AIN). Clean join.
- **Code enforcement ↔ parcels**: code cases carry LADBS's internal `prclid`, **not** the AIN —
  join by geocoded address / lat-lon, not parcel key.
- **Business ↔ address**: `zip_code` + `street_address` (+ lat/lon from the source GeoJSON).

## Design notes / gotchas

- Socrata pages via `$order=:id` (always-unique system field) so rows arriving mid-crawl
  never shift the window. ArcGIS pages via `resultOffset`/`resultRecordCount` (2k cap) and
  honors `exceededTransferLimit`.
- Rows with a null primary-key component are skipped (logged as "keyless"), never crash.
- HTTP layer retries with exponential backoff on 429/5xx.
- Deprecated datasets to avoid (verified dead): permits `xnhu-aczu`; film `tg4x-b46p`, `c2az-nhru`.
- Assessor is a **tabular** FeatureServer (no geometry); parcel polygons are a separate service.