Every API call, every table write, every failure mode the pipeline was built to survive.
Run it yourself with npm run harvest -- <channel> (single-channel mode).
channels.md for why inventing one would
be worse than shipping none — so every channel-specific value here is a placeholder like
<channel> or <UC…>. Fill them in from a real
harvest run. The structural numbers (FETCH_BUFFER, MIN_DURATION_SEC,
API unit costs) are properties of the code and the YouTube API, and are real.
@<handle> → channels.list?forHandle=<handle> → UC….
UC ids pass straight through with zero API cost — which is why TARGETS prefers them.
channels.md requires every row to carry a resolved id or a handle you have opened in a browser yourself.channels DB write #1INSERT INTO channels (channel_id, channel_name, focus, notes) VALUES ($1, $2, $3, $4) ON CONFLICT (channel_id) DO UPDATE SET -- idempotent: re-running never duplicates channel_name = EXCLUDED.channel_name, focus = EXCLUDED.focus, notes = EXCLUDED.notes;
Every write in this pipeline is an upsert against a natural unique key — here channel_id.
That single discipline is what makes the whole harvester resumable and safe to re-run daily.
The most expensive YouTube call in the app (100 of the 10,000 daily units). One call per channel per run.
It returns up to FETCH_BUFFER = 50 videos; how many survive the ≥ 3-minute cut is a property of the
channel, since most creators now post a heavy tail of Shorts.
contentDetails.duration →
.match() on undefined threw, the catch returned [], and the whole channel got
"0 eligible videos". One upcoming premiere on a channel's front page zeroed it in 3 of 4 scheduled runs.item.contentDetails?.duration ?? 'PT0S' — one premiere can no longer blank a channel.// include-words OVERRIDE excludes — decide which error you'd rather make: // a false keep costs 1 LLM call; a false skip silently loses a map point. INCLUDE: /restaurant|eatery|food tour|cheap eats|hidden gem|hole.in.the.wall| best .{0,20}in (Toronto|Scarborough|Markham|…)|I (tried|ate)|dim sum|brunch|patio/ EXCLUDE: talk·music /podcast|interview|LIVE|Q&A|reaction/ · grocery /grocery haul|Costco run| convenience.store|meal prep/ home-cooking /(at home|in my kitchen).{0,16}(cook|bake|made)/ · /recipe|how to make/ outdoor /hiking|camping|Bruce Trail|Algonquin/ out-of-province /New York|NYC|Montreal|Vancouver|Chicago|Buffalo|Tokyo|abroad/ per-channel: <channel> → includeOnly /<term>/ // for channels where food is the minority format
Include-words winning is the whole point: a title like "my easy weeknight recipe — and the best pizza
in Little Italy" is caught by recipe and saved by best … in. Upstream measured that before this
filter existed, ~60–70% of a day's LLM quota went to music shows and grocery hauls.
The out-of-province line is doing more work here than it did upstream:
Toronto creators cross into Montreal, Buffalo and NYC constantly, and those videos are genuinely food reviews —
they just produce restaurants region.ts will drop. Filtering them at the title costs one regex; filtering
them after geocoding costs proxy bandwidth, an LLM call and a quarantined row.
-- ① did this video already yield restaurants? SELECT 1 FROM restaurants WHERE video_id = $1; -- ② or did the LLM already confirm it has none? (zero-yield tombstone) SELECT extraction_empty_at FROM videos WHERE video_id = $1;
videos.extraction_empty_at is set only when the LLM responded and found nothing
(a quota-dead call sets nothing, so those stay retryable).runPipeline, ×10 videos)src/pipeline.ts orchestrates 5 steps; concurrency 2 videos at a time, exponential backoff between retries.
videos videos.list · 1 unit · DB write #2INSERT INTO videos (video_id, title, channel_title, description, published_at) VALUES ($1,$2,$3,$4,$5) ON CONFLICT (video_id) DO UPDATE SET … , updated_at = now();
transcripts 0 quota (scrape) · DB write #3INSERT INTO transcripts (video_id, transcript_data, language_code, has_english) VALUES ($1, $2::jsonb, $3, $4) ON CONFLICT (video_id, language_code) DO UPDATE SET transcript_data = EXCLUDED.transcript_data;
No official API exists — this scrapes YouTube's caption endpoint. It's the flakiest step:
datacenter IPs get blocked (hence optional PROXY_URL for EC2), and it's cached in the DB so re-runs are free.
has_english gates whether the LLM step is worth running — but it is a weaker gate than upstream's
has_korean, which could just look for Hangul. English has no script of its own, so this reads the
caption track's language label and trusts it.
comment_threads 1 unit/page (100 threads) · DB write #4INSERT INTO comment_threads (video_id, youtube_thread_id, top_level_comment, replies) VALUES ($1,$2,$3::jsonb,$4::jsonb) ON CONFLICT (youtube_thread_id) DO UPDATE SET …;
Why comments? Viewers name the restaurant ("that's the banh mi place on Spadina, not the Dundas one") and correct what the auto-captions mangled — they're free disambiguation signal for the LLM, and they matter more here than upstream, because English ASR butchers exactly the non-English restaurant names the GTA is full of.
prompt = title + description + transcript + comments
+ "return ONLY establishments actually REVIEWED — not dishes, not name-drops"
→ [{"name":"…","area":"Kensington Market"}, …] // JSON array of {name, area}
Output is post-filtered: placeholder names (/no name|unnamed|didn't catch/i) are dropped before enrichment.
The result carries llmResponded — distinguishing "the model said zero" from "the call died".
null → looked identical to
"no restaurants" → quota-era runs persisted 13 food videos as empty (incl. one with the restaurant name in the title).retryDelay and retry;
per-day 429 → latch isDailyQuotaExhausted() and the whole harvest fail-fasts in seconds instead of grinding.// ordered query plan — most specific first, validation on the loose end 0. scope guard: qualifyArea() + ONTARIO_BOUNDS → out-of-Ontario hits are dropped 1. "<name> Kensington Market Toronto" // name + canonical area, area qualified to "…, Ontario, Canada" 2. "<name> Toronto" // name + city only 3. "<name>" + validate // bare name — hit MUST land inside ONTARIO_BOUNDS // (upstream had a step 4: a Korean spell-corrector. Deleted — searchText is already // fuzzy, and the corrector's failures were confident nonsense, not near-misses.) coords: Places location.latitude/longitude directly; else geocode the address
qualifyArea() appends ", Ontario, Canada" so the query itself is scoped, and
isInOntario() rejects anything landing outside ONTARIO_BOUNDS — a bounding box is cruder than a
province polygon, but it is the cheap check that catches Buffalo and Detroit, which sit right against the border.
② retry 429/5xx + request spacing: upstream lost correctly-named restaurants permanently to un-retried rate limits,
which is the worst kind of loss because it looks like "not found". ③ the validate rule on bare-name hits.restaurants, or tombstone → videos DB write #5INSERT INTO restaurants (video_id, restaurant_name, area, maps_link, place_id, address, short_address, latitude, longitude, category, description, last_enriched_at) VALUES (…, now()) ON CONFLICT (video_id, restaurant_name) DO UPDATE SET … ; -- same place re-reviewed = update, not dup -- OR, when llmResponded ∧ names.length = 0: UPDATE videos SET extraction_empty_at = now() WHERE video_id = $1; -- never re-pay for this video
Un-geocoded rows (out of province, or genuinely not in Places) stay with latitude IS NULL —
stored but invisible to the map. Nothing is deleted; provenance (which video said it) is always kept.
SELECT r.*, v.title AS video_title, v.channel_title FROM restaurants r LEFT JOIN videos v ON r.video_id = v.video_id WHERE r.latitude IS NOT NULL ORDER BY r.created_at DESC;
The viewer never talks to the pipeline — only to the table. That's why the map fills in live while a harvest runs.
| Table | Natural unique key | Written by | Role |
|---|---|---|---|
channels | channel_id | Stage A2 | Which channels we track + focus/notes |
videos | video_id | C1 (+ C6 tombstone) | Metadata · extraction_empty_at = zero-yield tombstone |
transcripts | (video_id, language_code) | C2 | Cached transcript jsonb · has_english gate |
comment_threads | youtube_thread_id | C3 | LLM disambiguation signal (jsonb top comment + replies) |
restaurants | (video_id, restaurant_name) | C6 | The product. lat/lng NULL = extracted but unmappable |
Three invariants to stay meticulous about:
(1) every write is an upsert on a natural key — re-runs are always safe;
(2) raw inputs (transcript, comments) are cached in the DB so only the LLM and Places steps ever need re-paying;
(3) a row in restaurants always traces back to the exact video that claimed it.
| Resource | Cost | Daily budget | Binding? |
|---|---|---|---|
| YouTube search.list (B1) | 100 units × 1 | 10,000 units | no |
| YouTube videos/commentThreads (B2, C1, C3) | ~1–10 units/video | no | |
| Transcript scrape (C2) | 0 API units · ~1–2 MB of Webshare proxy traffic per video | 1 GB/month residential proxy ≈ 500–1,000 transcripts | YES — the bottleneck |
| LLM extraction (C4) | 1 pass/video | no metered quota — the Claude Code Routine runs on the subscription, not a paid API | no, but it is not autonomous: a human or agent starts the run |
| Places searchText / Geocoding (C5) | ≤3 req/restaurant | no free daily cap — billed per request | no, but it is the one line with a $ on it |
Everything in Stage B exists to protect whichever column is scarce: filter before you pay, tombstone after you've
paid once. Two shape changes since this page was written. ① Extraction is no longer the cap. This page describes
the Gemini-era C4, whose free-tier RPD was a hard daily ceiling; extraction now runs as the Claude Code Routine on the
subscription, so what binds is proxy bandwidth — which is why harvest_fetch.ts takes a per-run
transcript budget and why transcripts are never deleted (a re-fetch spends the scarce resource twice).
② Upstream's Naver tier gave 25,000 free calls a day, so enrichment was simply never the constraint there.
Google has no free daily allowance of that kind, so a wasted enrichment call is now a small real cost rather than a
free retry — which makes the Stage B filters and the geocode cache more load-bearing, not less.
Three terms that get blurred together — this repo happens to contain a clean specimen of each.
A prototype's deliverable is the answer, not the code. The Puppeteer transcript scraper answered
"can we get auto-captions at all?" — then was thrown away for youtube-transcript-plus.
The research script answers "which channels actually yield restaurants?" — its output is meant to live on as
channels.md notes (hit rate, restaurants per video) while the runs themselves are forgotten.
You're allowed to write ugly prototypes because you've promised to delete them.
npm run tracerOne video → all 5 steps → real YouTube API, real LLM, real Google Places, real Postgres → a real coordinate. No mocks anywhere — that's what makes it a tracer and not a demo: it proves the architecture under real conditions, like a tracer round showing where your fire actually lands. Crucially, tracer code is not disposable: everything we hardened this week (retry latches, tombstones, area validation) was bolted onto that same skeleton. The skeleton never changed; it just grew armor.
"Viable" = a person can open the map and find a restaurant a YouTuber actually visited — the machinery for that works (geocoded points, popups linking Google Maps + the source video), though this fork has no pin count of its own to quote until a verified channel has been harvested. "Minimum" = the honest gaps: search matching is a stub, no dedup across channels reviewing the same place, free-tier pacing. An MVP's job is to generate product learning (do people want this? what do they search for?) — distinct from the tracer's technical learning.
Idempotent upserts, fail-fast quota latches, zero-yield tombstones, geocode validation, squatter-checked channel ids — none of these add features; they add trust in the data. The meticulous per-channel loop going forward:
① verify UC id (squatter check) → ② sample 50 titles, tune filter →
③ npm run harvest -- <channel> → ④ audit new rows (coords in Ontario? types actually food?) →
⑤ check /map → repeat for the next channel.
Generated 2026-06-13, retargeted to Toronto/GTA · a dated snapshot: it still describes the Gemini-era extractor, which this repo has since replaced with the Claude Code Routine · API docs · live map