Pipeline Flow. how one channel becomes a dot on the map

← back to the map

Walkthrough: one channel, end to end

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).

This walkthrough is a template. Upstream wrote it around a named Korean channel, with that channel's real id, real subscriber count and real per-run video counts. This fork ships no verified Toronto channel list — see 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.
① channel resolved ② videos listed & filtered ③ per-video pipeline ×10 ④ restaurants on /map

Stage A — Channel registration

A1

Resolve the channel identity YouTube API · 1 unit

scripts/harvest_channels.ts → getChannelId() → src/youtube.ts

@<handle> → channels.list?forHandle=<handle> → UC…. UC ids pass straight through with zero API cost — which is why TARGETS prefers them.

Failure upstream hit: handles can be squatted. A target written as a bare handle resolved to a 7-subscriber impostor account sitting on the name of a real, large channel — and the harvest would have quietly ingested a stranger's videos as if they were the creator's.
Mitigation: resolve once, check the subscriber count against what you expect, then pin the UC id in TARGETS. This is exactly why channels.md requires every row to carry a resolved id or a handle you have opened in a browser yourself.
A2

Upsert into channels DB write #1

upsertChannel() → src/db.ts
INSERT 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.

Stage B — Video discovery & triage

B1

List recent uploads search.list · 100 units ⚠️

findVideosByChannelId(channelId, FETCH_BUFFER=50)

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.

B2

Fetch durations, drop Shorts videos.list · 1 unit

getVideoDetailsByIds() → MIN_DURATION_SEC = 180
Failure we hit: scheduled premieres have no 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.
Mitigation: item.contentDetails?.duration ?? 'PT0S' — one premiere can no longer blank a channel.
B3

Title prefilter — don't pay for off-topic videos 0 API calls

isLikelyRestaurantVideo(title, target)
// 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.

B4

Skip what's already done DB read

alreadyHarvested() — two checks
-- ① 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;
Failure we hit: without ②, confirmed-empty videos were re-extracted ×3 retries every run forever — a single hiking video ate ~15 LLM calls over 5 days re-confirming that it contains no restaurants.
Mitigation: videos.extraction_empty_at is set only when the LLM responded and found nothing (a quota-dead call sets nothing, so those stay retryable).

Stage C — Per-video pipeline (runPipeline, ×10 videos)

src/pipeline.ts orchestrates 5 steps; concurrency 2 videos at a time, exponential backoff between retries.

C1

Ingest metadata → videos videos.list · 1 unit · DB write #2

ingestVideoById() → upsertVideo()
INSERT 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();
C2

Extract transcript → transcripts 0 quota (scrape) · DB write #3

extractAndSaveTranscript() — youtube-transcript-plus, English first ('en', then 'en-CA', 'en-US')
INSERT 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.

C3

Fetch comments → comment_threads 1 unit/page (100 threads) · DB write #4

fetchAndPersistCommentsByVideoId() — paginated
INSERT 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.

C4

LLM extraction — names + areas Gemini Flash · 1 req (free tier: limited/day)

extractRestaurantsFromVideo() → src/llm.ts generateContent()
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".

Failure we hit: free-tier 429s were swallowed as null → looked identical to "no restaurants" → quota-era runs persisted 13 food videos as empty (incl. one with the restaurant name in the title).
Mitigation: per-minute 429 → honor the server's retryDelay and retry; per-day 429 → latch isDailyQuotaExhausted() and the whole harvest fail-fasts in seconds instead of grinding.
C5

Places enrichment — name → coordinates Places searchText ≤3 req + Geocoding fallback

enrichRestaurantsWithPlaces() → src/places.ts placesTextSearch() / geocodeAddress()
// 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
The failure this guards against: a text search for a bare restaurant name does not return "not found" — it returns its best guess anywhere on earth. Upstream watched Naver answer a New York deli with a Korean lookalike and a Tokyo tonkatsu shop with a health-goods store. Google does the same thing in a shape that bites harder here: Toronto shares restaurant names with Montreal, Buffalo, Chicago and New York, and a creator's day trip to any of them will happily resolve to a plausible, wrong, out-of-province pin.
Mitigations: ① 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.
C6

Persist → restaurants, or tombstone → videos DB write #5

upsertManyRestaurants() / markVideoExtractionEmpty()
INSERT 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.

D

Read path — the map GET /restaurants

src/server.ts → public/map.html (20s polling, clear-then-draw)
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.

The 5 tables — who writes what

TableNatural unique keyWritten byRole
channelschannel_idStage A2Which channels we track + focus/notes
videosvideo_idC1 (+ C6 tombstone)Metadata · extraction_empty_at = zero-yield tombstone
transcripts(video_id, language_code)C2Cached transcript jsonb · has_english gate
comment_threadsyoutube_thread_idC3LLM disambiguation signal (jsonb top comment + replies)
restaurants(video_id, restaurant_name)C6The 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.

Quota economics per channel run (one channel, 10 videos)
ResourceCostDaily budgetBinding?
YouTube search.list (B1)100 units × 110,000 unitsno
YouTube videos/commentThreads (B2, C1, C3)~1–10 units/videono
Transcript scrape (C2)0 API units · ~1–2 MB of Webshare proxy traffic per video1 GB/month residential proxy ≈ 500–1,000 transcriptsYES — the bottleneck
LLM extraction (C4)1 pass/videono metered quota — the Claude Code Routine runs on the subscription, not a paid APIno, but it is not autonomous: a human or agent starts the run
Places searchText / Geocoding (C5)≤3 req/restaurantno free daily cap — billed per requestno, 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.

Where we are: prototype → tracer bullet → MVP

Three terms that get blurred together — this repo happens to contain a clean specimen of each.

Prototype — disposable code that answers a question

legacy/ puppeteer experiments · scripts/research_channels.ts

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.

Tracer bullet — a thin end-to-end slice you keep and flesh out

src/pipeline.ts · npm run tracer

One 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.

MVP — the smallest thing users can use, built to learn from

/map + the daily self-harvest

"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.

Now — hardening: "works once" → "works unattended, daily, correctly"

This week's work, and the modular channel runbook

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