Files
urieljareth 621bbc5f5c feat: local content-mining platform for YouTube creator scraping
Scraper de canales de YouTube hacia notas Markdown para base de conocimiento
(Obsidian-ready), con plataforma web local.

Engine + CLI (Workstream A):
- Modular pipeline: discover/extract/parse/chapters/render/store + ratelimit
- SQLite store con migración idempotente: FTS5 (transcript search), columnas
  de metadata enriquecida, tablas cookies_meta y scrape_jobs
- Módulos: segments, cookies (Netscape vault), export (json/csv/srt/html),
  analysis (word freq/timeline/wordcloud), monitor (watch loop), pipeline
- CLI Click group: search, export, audio, channels, watch, analyze, re-render
- Fix del bug de scoping de cookies en cli.py

Webapp local (Workstream B):
- FastAPI backend: dashboard, channels, videos facetado, transcript, search
  FTS, analysis, scrape jobs con SSE, cookies drag-and-drop, exports,
  folders (abrir en OS), tools (re-render, formato)
- SPA no-build (Alpine.js + Tailwind + Chart.js por CDN): 9 vistas, tema
  dark command-center con acento rojo→rosa, cookie vault drag-drop,
  consola de scrapeo con progreso live vía SSE

Launcher + subagentes (Workstream C):
- start-server.bat / stop-server.bat con auto port-scan + browser open
- .opencode/agent/webapp-builder.md + .opencode/goals/webapp-build.md

Tests: 38 pytest verdes. Sin funcionalidad de IA (enfoque data-mining).
2026-07-26 23:19:34 -06:00

297 lines
17 KiB
Markdown

# Design: yt-channel-scraper → Local Content-Mining Platform
**Date:** 2026-07-26
**Status:** Approved (design review)
**Stack decision:** FastAPI + Alpine.js + Tailwind (CDN), no Node/npm build step
**Scope rule:** Data-mining + content focus. **No AI** (excluded: LLM summaries, RAG, NER, speaker diarization).
---
## 1. Goal
Turn the existing yt-dlp-based CLI scraper into a **local "intelligent platform"** for scraping and mining content-creator data. Add the high-value CLI features from `OPPORTUNITIES.md` (minus AI), a visually attractive local webapp with drag-and-drop cookie session login, `.bat` launchers with automatic port handling, and an opencode subagent + sub-goal that owns the webapp implementation.
Everything runs **100% locally** — no remote deploy, no auth, no rate limits beyond YouTube's own.
---
## 2. Context (current state)
- Modular pipeline: `cli → discover → extract → parse → chapters → render → store`, plus `ratelimit` and `config`.
- SQLite (`data/state.db`): tables `channels` (2 rows), `videos` (883 rows). Statuses: `pending/done/no_subtitles/error`.
- Rich metadata (views, likes, tags, thumbnail, description) currently lives **only in Markdown frontmatter**, not in the DB.
- 33 transcripts already rendered as `.md` under `data/markdown/<channel>/`.
- A valid Netscape cookie file exists at `cookies/253506e4-387c-4cda-9f11-e95548f8ca0b.txt`.
- **Known bug:** `cli.py:228-229``_process_one()` references `cookies` and `cookies_from_browser` that are local to `main()` and therefore out of scope. Cookies are currently non-functional. This design fixes it.
---
## 3. Architecture decision
**Approach A — Monorepo, in-package webapp (chosen).**
The FastAPI app lives inside the existing package at `src/yt_scraper/webapp/` and reuses `Store` + pipeline modules directly. The same FastAPI process serves the SPA static files, the JSON API, and SSE. One repo, one process, zero query duplication.
Rejected alternatives:
- B (separate top-level package importing `yt_scraper`): doubles plumbing for no local benefit.
- C (separate processes sharing only DB): duplicates query logic, breaks the "one platform" feel.
---
## 4. New repo layout
```
src/yt_scraper/
cli.py +subcommands: search, export, audio, channels, watch, analyze, re-render
store.py +schema migration, +segments/metadata/cookies/jobs queries
segments.py NEW populate transcript_segments + FTS5; backfill from .md
export.py NEW json/csv/srt/html exporters
analysis.py NEW word freq, top-N terms, term-over-time timeline (matplotlib)
monitor.py NEW watch-mode loop (interval discovery + process)
cookies.py NEW Netscape parse/validate/vault; resolve active cookie path
webapp/
__init__.py
app.py FastAPI factory: static mount, CORS off (local), /healthz, SSE mount
api.py REST routers (channels, videos, search, transcript, analysis, scrape, cookies, exports)
jobs.py in-process scrape job runner + SSE event bus (queue/stream/cancel)
static/
index.html SPA shell (Tailwind CDN, Alpine.js, Chart.js CDN)
app.js Alpine app: views, state, API client, SSE consumer
styles.css custom dark "command-center" theme
pipeline glue: extract.py / cli.py now accept the active cookie path
templates/
video.md.j2 unchanged (re-render reuses it)
gallery.html.j2 NEW template for `export --format html`
docs/superpowers/specs/2026-07-26-platform-design.md this file
.opencode/
agent/webapp-builder.md NEW subagent owning Workstream B
goals/webapp-build.md NEW sub-goal the subagent executes
start-server.bat NEW auto port-scan + launch + open browser (repo root)
stop-server.bat NEW graceful shutdown + port cleanup (repo root)
.run/ gitignored runtime: server.info (port/PID)
data/analysis/ gitignored output of `analyze` (PNG/CSV)
```
`pyproject.toml` gains optional deps: `fastapi`, `uvicorn[standard]`, `matplotlib`, `wordcloud`, and `ffmpeg` is a system requirement for `audio`.
---
## 5. Data model (non-destructive, idempotent migration)
Run automatically on every `Store` instantiation via `_init_schema` + `ALTER TABLE ADD COLUMN` guards.
### 5.1 `videos` — added columns
```
view_count INTEGER
like_count INTEGER
tags TEXT -- JSON array
thumbnail TEXT
description TEXT
chapters_json TEXT -- JSON of chapters (for re-render)
segments_json TEXT -- JSON of segments (for re-render, avoids re-download)
```
### 5.2 New `transcript_segments` + FTS5
```
CREATE TABLE transcript_segments (
video_id TEXT NOT NULL,
idx INTEGER NOT NULL,
start_sec REAL NOT NULL,
end_sec REAL NOT NULL,
text TEXT NOT NULL,
PRIMARY KEY (video_id, idx),
FOREIGN KEY (video_id) REFERENCES videos(video_id)
);
CREATE INDEX idx_segments_video ON transcript_segments(video_id);
-- FTS5 external-content table over transcript_segments(text)
CREATE VIRTUAL TABLE transcript_fts USING fts5(
text, content='transcript_segments', content_rowid='rowid'
);
```
Populated during scrape (after parse) **and** by a one-time backfill that parses the 33 existing `.md` files. Drives `search` (CLI) and webapp search (snippet + timestamp).
### 5.3 New `cookies_meta`
```
CREATE TABLE cookies_meta (
id TEXT PRIMARY KEY, -- uuid (matches filename stem)
filename TEXT NOT NULL,
label TEXT,
added_at TEXT NOT NULL,
expires_at TEXT, -- earliest cookie expiry, ISO
is_active INTEGER DEFAULT 0, -- exactly one row may be 1
has_session INTEGER DEFAULT 0, -- 1 if SID/SAPISID/LOGIN_INFO present
cookie_count INTEGER DEFAULT 0
);
```
Cookie files are stored in `cookies/`. Exactly **one** cookie may be active globally. `cookies.py` resolves the active path for yt-dlp's `cookiefile`.
### 5.4 New `scrape_jobs`
```
CREATE TABLE scrape_jobs (
id TEXT PRIMARY KEY,
channel_id TEXT,
opts_json TEXT,
status TEXT NOT NULL, -- queued/running/done/cancelled/error
started_at TEXT,
finished_at TEXT,
total INTEGER DEFAULT 0,
completed INTEGER DEFAULT 0,
last_error TEXT
);
```
The scrape console + SSE consumer read/write this table.
### 5.5 Backfill
One-time `segments.backfill_from_markdown()` invoked on first run (or via `re-render --backfill`): parse each done `.md`, extract segments + chapters + frontmatter metadata, write into `transcript_segments`/`transcript_fts` and the new `videos` columns. Idempotent (skip videos already having segments).
---
## 6. Workstream A — Core engine + CLI
### 6.1 CLI subcommands (new)
| Command | Behavior |
|---|---|
| `search "query" [-c CHANNEL] [-l N]` | FTS5 MATCH → prints video_id, start timestamp, snippet. |
| `export --format {json,csv,srt,html} [-c CHANNEL] [-o DIR]` | JSON = full dump; CSV = metadata table; SRT = subtitles from segments; HTML = Jinja2 gallery (`gallery.html.j2`). |
| `audio [-c CHANNEL] [--limit N] [--from DATE]` | yt-dlp `bestaudio` → mp3 via FFmpegExtractAudio. Skips videos already having audio unless `--force`. Records nothing new in DB beyond existing `done`. |
| `re-render [-c CHANNEL]` | Rebuild all `.md` from `segments_json`+`chapters_json`+metadata using the current `video.md.j2`. Errors if segments missing (hint: run backfill). |
| `channels add <URL>` / `channels list` / `channels remove <id|handle>` | Multi-channel management. `list` prints a Rich table. |
| `--all-channels` (global flag) | When set, `search`/`export`/`analyze`/`re-render` operate across every channel instead of one. Mutually exclusive with `-c`. |
| `watch --interval 6h [-c CHANNEL] [--once]` | Loop: `discover_channel` → upsert → process pending → sleep. `--once` runs a single iteration (useful for Task Scheduler). |
| `analyze --wordcloud --top-words N --timeline TERM [-c CHANNEL] [-o DIR]` | Saves `wordcloud.png`, `top_words.csv`, `timeline.png` to `data/analysis/`. Pure frequency mining (regex tokenization, Spanish stopwords). |
### 6.2 Bug fix
`_process_one()` and `extract_video()` already accept `cookies_file`/`cookies_from_browser` — the bug is that `main()` doesn't pass its local `cookies`/`cookies_from_browser` into `_process_one()`. Fix: thread the two values through `_process_one()`'s signature. Additionally, when neither flag is given, fall back to the **active cookie from the vault** (`cookies.resolve_active_path()`), so the webapp's drag-and-drop selection works for CLI too.
### 6.3 Module responsibilities
- `segments.py`: `store_segments(video_id, segments)`, `populate_fts(video_id)`, `search(query, channel_id, limit) -> list[Hit]`, `backfill_from_markdown(store, md_dir)`.
- `export.py`: `export_json/csv/srt/html(store, segments_store, channel_id, out_dir)`.
- `analysis.py`: `word_frequency(channel_id)`, `top_words(n)`, `term_timeline(term)` — returns data structures; matplotlib rendering optional behind a `render=True` flag so the webapp can call the same functions for Chart.js data without PNG.
- `monitor.py`: `watch_loop(config, interval, once)`.
- `cookies.py`: `parse_netscape(path) -> CookieFile`, `validate(meta) -> {valid, has_session, expires_at}`, `import_upload(text_or_path) -> id`, `set_active(id)`, `resolve_active_path() -> str|None`, `list_vault() -> list[CookieMeta]`.
---
## 7. Workstream B — Webapp (owned by the `webapp-builder` subagent)
### 7.1 Backend (FastAPI)
- `app.py`: factory mounts `/static` (SPA), includes API routers under `/api`, exposes `/healthz`, and an SSE endpoint `/api/scrape/{job_id}/stream`.
- `api.py` routers:
- `GET /api/dashboard` → aggregate stats (per-channel counts, total duration, date range, uploads-over-time buckets, top tags, duration histogram, status breakdown).
- `GET /api/channels`, `POST /api/channels {url}`, `DELETE /api/channels/{id}`.
- `GET /api/videos?channel=&status=&from=&to=&min_dur=&q=&sort=&page=&size=`.
- `GET /api/videos/{id}` → full metadata + chapters.
- `GET /api/videos/{id}/transcript` → segments list (for the reader).
- `GET /api/search?q=&channel=&limit=` → FTS hits with snippet + timestamp.
- `GET /api/analysis?channel=&type=wordcloud|topwords|timeline&term=` → JSON for Chart.js.
- `POST /api/scrape {channel_id, opts}` → creates a `scrape_jobs` row, enqueues in `jobs.py`, returns `{job_id}`.
- `GET /api/scrape/{job_id}/stream` (SSE) → progress + log lines.
- `DELETE /api/scrape/{job_id}` → cancel.
- `POST /api/cookies/upload` (multipart/text) → import; `GET /api/cookies`; `POST /api/cookies/{id}/activate`; `DELETE /api/cookies/{id}`; `POST /api/cookies/{id}/test`.
- `GET /api/export?format=&channel=` → streams the file.
- `jobs.py`: a single-threaded `queue.Queue` worker runs scrape jobs sequentially (avoids concurrent yt-dlp hammering). Emits SSE events (`progress`, `log`, `done`, `error`). Persisted in `scrape_jobs`.
- The active cookie from the vault is passed to every scrape job automatically.
### 7.2 Frontend (Alpine.js SPA)
Design language: **dark "data command center."** Near-black canvas (`zinc-950`), glassy cards (`backdrop-blur`, subtle borders), red→rose accent gradient (creator-content nod), monospace data accents, Chart.js viz, smooth Alpine `x-transition`s. Responsive (works on the laptop you launch it on). No auth.
Views (hash routes):
1. **Dashboard** — channel cards + global charts (uploads-over-time line, top-tags bar, duration histogram, status donut).
2. **Channels** — list / add (paste URL) / remove / drill-in to a channel detail.
3. **Videos** — faceted table/grid: filter by channel, status, date range, min duration, free-text; thumbnail grid or list; sort; click → detail.
4. **Video detail** — metadata header, chapters sidebar, full transcript reader with per-segment timestamps, in-page term highlight, "open on YouTube at t", per-video export (srt/json), audio-download trigger.
5. **Search** — FTS across all transcripts → result cards with highlighted snippet + timestamp deep-link into the video detail at that segment.
6. **Analysis** — word cloud (canvas), top-N terms bar, term-over-time timeline.
7. **Scrape console** — launch a job (pick channel + options: limit, since, languages, shorts, which cookie), live progress via SSE, streaming log, cancel button, watch-mode toggle.
8. **Cookie vault** — drag-and-drop zone (see §8), vault list, test-session.
9. **Exports** — pick format + scope → download.
### 7.3 "Intelligent" without AI
Smart faceted filters, cross-channel term mining, term-over-time timelines, saved searches (localStorage), live monitoring + auto-processing, auto-suggestions from top terms. Feels like a command center for content-creator data mining.
---
## 8. Cookie drag-and-drop UX
- Drop zone accepts **multiple `.txt` files** *or* **pasted Netscape cookie text**.
- On upload (`POST /api/cookies/upload`): `cookies.parse_netscape()` validates the format, extracts the earliest expiry, detects session cookies (`SID`, `SAPISID`, `__Secure-3PSID`, `LOGIN_INFO`), and returns a card: `{label, expires_at, has_session, cookie_count, size}`.
- Vault list with **radio = active** (exactly one active globally), rename, delete.
- The active cookie path is passed to yt-dlp as `cookiefile` for every scrape (members-only, age-gate, etc.).
- **"Test session"** → lightweight yt-dlp call (fetch the logged-in user's identity / a known members-only-free check) → reports `OK / expired / weak`.
- The existing `cookies/253506e4-...txt` is auto-imported into the vault on first server start.
---
## 9. Launcher `.bat` (automatic port system)
PowerShell-assisted `.bat` files at the repo root.
- **`start-server.bat`**
1. Resolve project root (script dir).
2. Find a free TCP port: scan from `8000` upward until `bind` succeeds (PowerShell `TcpListener` probe).
3. Write `port`, `PID`, and ISO timestamp to `.run/server.info`.
4. Launch `python -m uvicorn yt_scraper.webapp.app:app --port <port>` via `start` (minimized).
5. Poll `http://localhost:<port>/healthz` until 200 (or timeout ~30s).
6. Open `http://localhost:<port>` in the default browser.
7. Echo a clear success banner (port, URL) or an error.
8. Idempotent: if `.run/server.info` already describes a live server, just re-open the browser.
- **`stop-server.bat`**
1. Read `.run/server.info`.
2. Graceful terminate the uvicorn process (taskkill by PID, then `/T` for children).
3. Confirm the port is freed (re-probe).
4. Remove `.run/server.info`.
5. Echo a clear banner.
`.run/` is added to `.gitignore`.
---
## 10. Subagent + sub-goal
- **`.opencode/agent/webapp-builder.md`** — an opencode agent specialized for this webapp. It knows: the FastAPI + Alpine + Tailwind (CDN, no-build) stack; the `Store` API surface; the design language (§7.2); and its verification steps (start server, `curl /healthz`, smoke-test each SPA view, run `pytest`). It receives Workstream B scope and is expected to reuse `Store`, `segments`, `analysis`, `cookies` rather than duplicating logic.
- **`.opencode/goals/webapp-build.md`** — the sub-goal: "Implement the local webapp (Workstream B) per `docs/superpowers/specs/2026-07-26-platform-design.md`. Build backend routers, SSE job runner, and the 9-view Alpine SPA. Verify with `/healthz`, smoke tests, and `pytest`."
---
## 11. Dependencies
Add to `pyproject.toml` `[project.optional-dependencies]`:
```
web = ["fastapi>=0.110", "uvicorn[standard]>=0.27"]
analysis = ["matplotlib>=3.8", "wordcloud>=1.9"]
```
System requirement for `audio`: `ffmpeg` (`winget install ffmpeg` or `choco install ffmpeg`). The CLI will detect ffmpeg and print a clear hint if missing.
`cookies/` is added to `.gitignore` (real session secrets must never be committed).
---
## 12. Build order
A → B → C:
1. **A1** Schema migration + backfill + fix cookies bug.
2. **A2** `segments`/`export`/`analysis`/`monitor`/`cookies` modules.
3. **A3** CLI subcommands (`search`, `export`, `audio`, `channels`, `watch`, `analyze`, `re-render`).
4. **A4** Tests; `pytest` green.
5. **B** Webapp (subagent) — backend routers, job runner, SPA, drag-drop cookies. Verify `/healthz` + smoke.
6. **C** `.bat` launcher + `.opencode` agent/goal files.
---
## 13. Testing & verification
- `pytest tests/ -v` covering: schema migration idempotency, FTS search, segment backfill from `.md`, cookie Netscape parse/validate, exporters (json/csv/srt/html), analysis frequency/timeline, multi-channel store ops.
- Webapp: `/healthz` 200; representative endpoint per router; SPA loads; SSE streams a fake job.
- Manual: run `start-server.bat`, confirm browser opens, drag-drop the real cookie, trigger a small scrape (`--limit 1`) against the existing Nostal Vlad channel, verify a new `.md` + FTS row appear and search returns the new content.
- `python -m yt_scraper.cli search "..."` returns hits after backfill.
---
## 14. Out of scope (explicitly excluded)
- AI features: LLM summaries, Q&A/RAG, NER (spaCy), speaker diarization (pyannote).
- CLI `stats`, `clip`, `thumbnails`, `translation` (dropped per user scope decision — aggregate *queries* still exist internally to feed the dashboard).
- Remote deployment, auth, multi-user, cloud sync.