car-privacy-card
Look up your car make/model/year (or VIN) and get a Mozilla-sourced privacy scorecard: what data the vehicle collects, who buys it, and which connectivity components owners have physically disabled.
The data is real: brand rubrics come from the Mozilla Privacy Not Included — Cars listing (scraped weekly), VIN decoding hits the NHTSA vPIC API, and physical opt-out write-ups are curated links to real community posts (arkadiyt.com, Privacy4Cars, NYT, Reuters, Reddit). We do not invent hardware instructions.
What it does
- Brand directory: 25 car brands scraped from Mozilla Privacy Not Included, with the full rubric (data categories collected, sells / shares, deletion controls, track record, AI usage, biometric collection).
- VIN decoder: paste a 17-character VIN, we call
https://vpic.nhtsa.dot.gov/api/vehicles/DecodeVin/{vin}and resolve Make → brand card. - Make/Year/Model picker as fallback: NHTSA car-makes dataset, cached daily; models fetched on demand and cached 30 days.
- Letter grade A–F computed deterministically from rubric flags (no randomization).
- Leaderboard of worst offenders, sorted by score ascending.
- Shareable OG image per brand: PNG generated server-side from SVG via
@resvg/resvg-js./b/:slugHTML page injectsog:image+twitter:cardso Twitter / Reddit / Telegram render a large preview card with the grade. - OEM privacy-policy link panel with weekly SHA-256 diff: if Mozilla cites an OEM privacy URL, we re-fetch it weekly and surface "policy changed N days ago" badges.
- Physical opt-out write-ups: curated links only (arkadiyt.com RAV4, Privacy4Cars per-brand guides, NYT GM-insurance investigation, Reuters Tesla cameras). Re-validated weekly via HEAD check; broken links shown with strikethrough.
Out of scope (deliberately): user accounts, email alerts, payments, per-trim data, in-product modem-removal walkthroughs (we link out, never fabricate hardware instructions).
Stack
- Node.js 22+, ES modules
- Express, helmet, compression
better-sqlite3with WAL journalingnode-cronfor refresh schedulescheeriofor HTML parsing@resvg/resvg-jsfor SVG → PNG OG-image rendering
Data sources
All endpoints are public. No API keys.
| Source | URL | Refresh |
|--------|-----|---------|
| Mozilla Privacy Not Included — Cars (listing) | https://www.mozillafoundation.org/en/privacynotincluded/categories/cars/ | weekly Sun 03:00 UTC (also on boot if brands table empty) |
| Mozilla brand detail pages | https://www.mozillafoundation.org/en/privacynotincluded/<brand>/ | same as listing, batched after with ~1s throttle |
| NHTSA vPIC DecodeVin | https://vpic.nhtsa.dot.gov/api/vehicles/DecodeVin/{vin}?format=json | on-demand per VIN; cached 30 days |
| NHTSA vPIC GetMakesForVehicleType/car | https://vpic.nhtsa.dot.gov/api/vehicles/GetMakesForVehicleType/car?format=json | daily 04:00 UTC + on cache miss |
| NHTSA vPIC GetModelsForMakeYear | https://vpic.nhtsa.dot.gov/api/vehicles/GetModelsForMakeYear/make/{make}/modelyear/{year}?format=json | on-demand; cached 30 days |
| OEM privacy-policy URLs (cited by Mozilla) | varies per brand | weekly Sun 04:00 UTC; SHA-256 body diff |
| Community hardware opt-out write-ups | scrapers/optouts_seed.js (arkadiyt.com, privacy4cars.com, NYT, Reuters, Reddit) | weekly Sun 04:00 UTC HEAD revalidation |
If Mozilla scrape fails on the first boot, /api/brands returns HTTP 503 and the UI surfaces "data load failed — retrying." We never seed placeholder brands.
Endpoints
All under /car-privacy-card:
| Method | Path | Purpose |
|--------|------|---------|
| GET | /health | {ok:true}, no DB hit |
| GET | /api/brands | Slim list {slug, name, grade, score, data_stale} |
| GET | /api/brand/:slug | Full rubric, opt-outs, OEM policies, Mozilla blurb |
| GET | /api/leaderboard | Brands sorted by score ascending, top 25 |
| GET | /api/search?q= | LIKE search on brands.name, min length 2 |
| GET | /api/vin/:vin | NHTSA DecodeVin → {vin, make, model, year, brand_slug, brand_grade} |
| GET | /api/makes | NHTSA car makes |
| GET | /api/models?make=&year= | NHTSA models for that make+year |
| GET | /api/policies/:slug | OEM privacy URLs + last-changed timestamps |
| GET | /api/meta | Last-refresh timestamps + counts |
| GET | /og/:slug.png | PNG OG image, 1200×630, cached 7 days |
| GET | /b/:slug | HTML page with og: + twitter: meta for link previews |
| GET | / | SPA shell |
| GET | /leaderboard | SPA shell (renders leaderboard view) |
Grading
Deterministic, no randomization. Computed from the scraped rubric:
score = 100
- 2 * min(count(data_categories), 10)
- 18 if sells_data
- 10 if shares_data
- 8 if not user_can_delete
- 12 if track_record == 'bad'
- 6 if ai_used
- 5 if has_biometric
clamp [0, 100]
grade: 90+ A, 75+ B, 60+ C, 45+ D, else F
The category-count weight is capped at 10 (the SPEC's original 5 × N produced score 0 for every car brand once Mozilla rubric text mentioned more than 20 categories). The current weights preserve the rank ordering Mozilla intends but keep the brands distinguishable.
Running locally
npm install
node server.js
# server boots on :4782, base path /car-privacy-card
# first boot fetches Mozilla listing + 25 brand detail pages (~40s)
open http://localhost:4782/car-privacy-card/
PORT env var overrides the port. There are no required secrets.
Files
server.js — express app, mounts router under /car-privacy-card
db.js — better-sqlite3 schema + prepared statements
routes/api.js — JSON endpoints
routes/og.js — OG image route + cache
routes/pages.js — HTML pages with og: meta injection
scrapers/mozilla.js — listing + detail page scrape (cheerio)
scrapers/nhtsa.js — vPIC client
scrapers/policies.js — OEM privacy URL diff (SHA-256)
scrapers/optouts_seed.js — curated real URLs for physical opt-outs
scrapers/index.js — orchestrator + node-cron registration
lib/grade.js — rubric → score → A-F bucket
lib/og.js — SVG template + render-to-PNG
lib/slug.js — make name → brand slug normaliser
public/index.html — SPA shell with <!--META--> placeholder
public/app.js — vanilla JS router, fetch wrappers, views
public/style.css — dark theme
Notes
- The Mozilla listing page returns all PNI products; we filter by
<input class="product-categories" value="Cars">to get only the car rows. - Genesis, Mini, Infiniti, Ram, Porsche are folded onto their parent brand's Mozilla rubric (Hyundai, BMW, Nissan, Chrysler, Audi) in
lib/slug.js. This is conservative — Mozilla only reviewed 25 brand pages. - VINs are validated against the standard regex
^[A-HJ-NPR-Z0-9]{17}$(no I/O/Q allowed). - OG image renderer falls back to
og-default.pngfor the home and leaderboard pages.