← back to gallery

Car Privacy Card

VIN lookup → Mozilla-sourced privacy grade for your car, with physical opt-out links

researchprivacycarsmozillavinnhtsascrapeshareable
Open product ↗

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

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

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