ai-cancel-watch
A public, live ledger of enterprise AI projects that got scrapped. Every row is a real news story about a real company killing a real AI pilot — extracted at runtime from three public feeds via LLM, never seeded.
Built for the AI-skeptical CFO, board director, or consultant tired of vendor decks claiming "transformational ROI" — this is the counter-corpus.
What it does
- Scrapes three public feeds on a cron schedule for cancellation stories.
- Enriches each new candidate via OpenRouter (
anthropic/claude-haiku-4-5), which extracts a strict JSON object:{company, tool, vendor, duration_months, stated_reason, cost_usd, is_cancellation_story}. Rows whereis_cancellation_story=falseare dropped — they never enter the database. - Exposes a searchable / sortable table + per-row OG share cards (1200×630 SVG).
- Accepts crowdsourced submissions via a public POST endpoint — same LLM enrichment runs server-side, the row is stored as
verified=0and shown with an "Unverified" badge.
There is no auth. There is no seed data. If all three sources are down on day one, the table is empty — that is the correct behaviour. The "Live source health" panel at the bottom of the UI shows each source's last_fetched_at, status, and items added on the last run so users can verify the system is actually live.
Data sources
All fetched at runtime from real public endpoints. No mocks, no presets.
| Source | URL | Refresh |
|---|---|---|
| HN Algolia (story search) | https://hn.algolia.com/api/v1/search_by_date?query=<term>&tags=story&hitsPerPage=50 — rotates 6 query terms (scraps AI, abandons AI, shelves AI, killed AI pilot, sunset AI, shuts down AI tool), one term per tick | every 30 minutes |
| Google News RSS | https://news.google.com/rss/search?q=%22scraps+AI%22+OR+%22shelves+AI%22+OR+%22abandons+AI%22+OR+%22killed+AI+pilot%22+OR+%22sunset+AI%22+OR+%22shuts+down+AI+tool%22&hl=en-US&gl=US&ceid=US:en | every 60 minutes (at minute 7) |
| The Register | https://www.theregister.com/headlines.atom (pre-filtered with a regex; only candidates touching cancellation verbs near AI terms are sent to the LLM) | every 60 minutes (at minute 23) |
| User submissions | POST /ai-cancel-watch/api/submit — fetches the submitted URL with a 10s timeout / 1 MB cap, extracts <title> + <meta og:description> + plaintext body, runs the same LLM enrichment, stores as verified=0 | on-demand |
| Favicons (rendering only) | https://www.google.com/s2/favicons?domain=<host>&sz=128 | per render |
| Enrichment (not a row source) | OpenRouter anthropic/claude-haiku-4-5 | per candidate |
Dedupe key is a canonicalized URL (lowercased host, no www., no #fragment, no tracking query params).
API
All endpoints are public. No auth, no API keys for callers.
| Method | Path | What it returns |
|---|---|---|
| GET | /ai-cancel-watch/health | {ok:true} |
| GET | /ai-cancel-watch/ | the SPA |
| GET | /ai-cancel-watch/api/stats | {total, total_cost_usd, distinct_vendors, distinct_companies, last_7_days} |
| GET | /ai-cancel-watch/api/cancellations | paginated list. Query: q, vendor, year, min_cost, verified (0/1/all), sort (date_desc/cost_desc/duration_asc), limit (≤200), offset |
| GET | /ai-cancel-watch/api/cancellations/:id | single row |
| GET | /ai-cancel-watch/api/vendors | distinct vendor list (for filter UI) |
| GET | /ai-cancel-watch/api/years | distinct year list (for filter UI) |
| GET | /ai-cancel-watch/api/leaderboard | {weekly_hero, top_cost, shortest_life} |
| GET | /ai-cancel-watch/api/sources | per-source {name, label, url, interval, last_fetched_at, last_status, items_total, ...} |
| POST | /ai-cancel-watch/api/submit | body {source_url, note?}. Fetches the URL, enriches via LLM. Returns {id, verified:0} or {error} with HTTP 400/409 |
| GET | /ai-cancel-watch/cards/:id.svg | 1200×630 SVG cancellation card, Cache-Control: public, max-age=3600 |
| GET | /ai-cancel-watch/share/:id | HTML page with og:image → /cards/:id.svg, designed for social previews |
Stack
- Node ≥ 22 (uses native
fetch,AbortSignal.timeout) - Express 4 + helmet + compression
- better-sqlite3 (WAL mode)
- node-cron
- fast-xml-parser (RSS / Atom)
No frontend build step. Vanilla JS SPA in public/ (~500 lines).
Run locally
npm install
cp .env.example .env
# edit .env and set OPENROUTER_API_KEY for enrichment to work
node server.js
# open http://localhost:4849/ai-cancel-watch/
Without OPENROUTER_API_KEY, the scrapers still run and log to fetch_log, but enrichment fails so no rows get inserted — the system shows "0 tracked" and the sources panel reflects that honestly.
Schema
CREATE TABLE cancellations (
id, company, tool, vendor, duration_months, stated_reason, cost_usd,
source_url, source_name, headline, snippet, published_at, scraped_at,
url_canonical UNIQUE, verified, enrichment_raw
);
CREATE TABLE fetch_log (
id, source_name, status, items_found, items_new, error, ts
);
No users table. No sessions. No admin login. Bad rows are fixed by SSH'ing into the DB — outside MVP.
Out of scope
Predicting cancellations, vendor reviews, individual layoffs (that's ai-pink-slip), email alerts, RSS output, payments, edit-from-UI, PNG card rasterization (SVG is enough — Twitter/LinkedIn render SVG og:image fine and we don't ship a headless Chrome in a 4-hour build).