vivekchakraverty Claude Opus 4.8 commited on
Commit
f07812e
·
1 Parent(s): 357c3d4

Re-architect: API-based acquisition, no download; ffmpeg -ss screenshots

Browse files

- Comments via YouTube Data API v3 (key in UI or YOUTUBE_API_KEY secret) instead of yt-dlp
scraping; sentiment ranking unchanged
- Transcript via youtube-transcript-api (already timestamped) instead of faster-whisper
- Screenshots without downloading: yt-dlp resolves a stream URL, ffmpeg -ss grabs one
frame per weighted timestamp (sharpest of 3); falls back to text-only if blocked
- Remove the whole YouTube-access section (cookies/proxy/PO-token UI) and the browser
extension; thin fallback kept via optional YT_COOKIES / YT_PROXY secrets
- Drop faster-whisper + full video download; add youtube-transcript-api + numpy
- gitignore cookies.txt; README rewritten for the new architecture

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

.gitignore CHANGED
@@ -12,3 +12,5 @@ venv/
12
  runs/
13
  .gradio/
14
  .DS_Store
 
 
 
12
  runs/
13
  .gradio/
14
  .DS_Store
15
+ cookies.txt
16
+ *.cookies.txt
README.md CHANGED
@@ -14,112 +14,50 @@ short_description: Make Tutorials from YouTube Videos
14
 
15
  # YouTube → Tutorial Post Generator
16
 
17
- Give a **topic** and your **Hugging Face token**, and this Space builds a downloadable
18
- **`.docx` tutorial** (text + AI-captioned screenshots) from the single best YouTube video
19
- on that topic.
20
 
21
  ## Pipeline
22
 
23
  1. **Search** — top 5 videos via the [`adarshajay/youtube-search`](https://huggingface.co/spaces/adarshajay/youtube-search) Space.
24
- 2. **Sentiment rank** — fetch top comments per video with `yt-dlp` and score them with the
25
- BERT classifier [`OmarMedhat7/youtube-sentiment-analysis-model`](https://huggingface.co/OmarMedhat7/youtube-sentiment-analysis-model);
26
- the video with the highest positive share wins.
27
- 3. **Download** the winner with `yt-dlp` and extract audio with `ffmpeg`.
28
- 4. **Transcribe** locally with `faster-whisper` (segment timestamps).
29
- 5. **Candidate frames** are extracted densely from the video, then the **video is
30
- auto-deleted** — only small JPEGs and the transcript remain.
31
- 6. **Tutorial text** `deepseek-ai/DeepSeek-V3` (HF Inference Providers, billed to your
32
- token) turns the transcript into an **answer-engine-optimized** post: an answer-first
33
- paragraph, H2 step headings, an FAQ, a meta description, a URL slug, and a
34
- last-updated/source citation. Optional **primary/secondary keyword** placement (primary
35
- ~3× + in title/slug/meta/intro/H2; each secondary once).
36
- 7. **Screenshot selection** a weighted indicator blends each step's LLM-suggested
37
- timestamp with Whisper's actual speech timing.
38
- 8. **Captions** — a vision model (default `Qwen/Qwen2.5-VL-72B-Instruct`, billed to your
39
  token) captions each screenshot.
40
- 9. **Assemble** the `.docx` for download.
41
 
42
- ## Notes
43
 
44
- - **Your HF token is used only for the LLM and vision-model calls** and is billed to your
45
- account. Create a fine-grained token with *"Make calls to Inference Providers"* at
46
  <https://huggingface.co/settings/tokens>.
47
- - **Free CPU tier:** Whisper runs on CPU, so transcription is slow — keep videos short
48
- (default cap ~20 min).
49
- - **YouTube usually blocks the Space's datacenter IP.** Each visitor can supply their own
50
- access in the **"YouTube access cookies / proxy"** panel (used only for that run, then
51
- deleted):
52
- - **Cookies:** a `youtube.com` cookies.txt (Netscape format, raw or base64) exported
53
- from a **throwaway** Google account.
54
- - **Proxy:** a **residential** proxy URL. **Free *datacenter* proxies (e.g. Webshare's
55
- free tier) usually do *not* bypass YouTube's block** and have tight bandwidth caps.
56
- - An operator can set shared defaults via the `YT_COOKIES` / `YT_PROXY` Space secrets.
57
- - A hosted Space **cannot** open or read a visitor's browser — auth must be supplied
58
- explicitly.
59
 
60
- ## PO token guide (free, no proxy)
61
 
62
- YouTube increasingly requires a **Proof-of-Origin (PO) token** to serve video from
63
- datacenter IPs. A matching **PO token + visitor data** pair can get the Space past the
64
- "Sign in to confirm you're not a bot" wall **without any proxy** — and it's free. It's
65
- fiddly to extract by hand and the tokens expire (hours), so re-do this when downloads
66
- start failing.
67
 
68
- Paste the values into the app's **"YouTube access — cookies / proxy"** panel:
69
- **PO token** field and **Visitor data** field.
70
-
71
- ### Easiest: the PO Token Grabber extension (one click)
72
-
73
- This repo ships a tiny Chrome/Edge extension in [`extension/`](extension/) that reads the
74
- PO token + visitor data for you. Grab it with the **⬇️ Download the PO Token Grabber
75
- extension (.zip)** button in the app's *YouTube access* panel (or from
76
- [`extension/`](extension/)), unzip it, and load it unpacked (`chrome://extensions` →
77
- Developer mode → Load unpacked). Open a YouTube video and press play, then click the
78
- extension **while on the YouTube tab** to capture the values. Open this Space in another
79
- tab and hit **⤵ Fill the Space tab** to inject them straight into the fields below (or use
80
- the Copy buttons and paste). See
81
- [`extension/README.md`](extension/README.md). (It's unpublished/sideloaded and may need
82
- updates when YouTube changes; the manual steps below always work as a fallback.)
83
-
84
- ### Get them manually (web client, ~2 min)
85
-
86
- 1. Open a fresh **Incognito** window and go to <https://www.youtube.com> (a throwaway
87
- logged-in account is fine; if you log in, also export that session's cookies).
88
- 2. Open **DevTools → Network** (F12), then **play any video**.
89
- 3. **Visitor data:** filter Network for `v1/player`, open the request to
90
- `youtubei/v1/player`, and in the **Request payload** copy
91
- `context.client.visitorData` (a string ending in `%3D%3D`). Paste it into the
92
- **Visitor data** field.
93
- 4. **PO token (gvs):** filter Network for `googlevideo.com`, open a `videoplayback`
94
- request, and copy the value of its **`pot`** query parameter. In the **PO token**
95
- field enter it prefixed with the client + context:
96
-
97
- ```
98
- web.gvs+<pot-value>
99
- ```
100
-
101
- You can supply more than one, comma-separated, e.g.
102
- `web.gvs+<gvs-pot>, web.player+<player-pot>` (the player PO token comes from the
103
- `v1/player` request's response, field `...poToken`).
104
- 5. Run the Space. When a PO token is present it automatically uses the **web** player
105
- client so the token applies.
106
-
107
- **Tips**
108
- - The PO token is bound to the **visitor data** (and cookies) it was made with — always
109
- submit them together, from the same session.
110
- - Prefer using the **same Incognito session** for cookies + visitor data + PO token.
111
- - Tokens are short-lived; refresh when you next get blocked.
112
-
113
- ### Automated / operator option
114
-
115
- yt-dlp can auto-generate PO tokens via the
116
- [`bgutil-ytdlp-pot-provider`](https://github.com/Brainicism/bgutil-ytdlp-pot-provider)
117
- plugin, but that needs a small provider server running alongside yt-dlp — not bundled in
118
- this hosted Space. A self-hosting operator can run it and set shared defaults via the
119
- `YT_POT` / `YT_VISITOR_DATA` Space secrets.
120
-
121
- See the authoritative, up-to-date method in the
122
- [yt-dlp PO Token Guide](https://github.com/yt-dlp/yt-dlp/wiki/PO-Token-Guide).
123
 
124
  ## Local run
125
 
 
14
 
15
  # YouTube → Tutorial Post Generator
16
 
17
+ Give a **topic**, your **Hugging Face token**, and a **YouTube Data API key**, and this
18
+ Space builds a downloadable **`.docx` tutorial** (text + captioned screenshots) from the
19
+ single best YouTube video on that topic. **No video download** — acquisition is API-based.
20
 
21
  ## Pipeline
22
 
23
  1. **Search** — top 5 videos via the [`adarshajay/youtube-search`](https://huggingface.co/spaces/adarshajay/youtube-search) Space.
24
+ 2. **Sentiment rank** — fetch each video's comments via the **YouTube Data API v3** and
25
+ score them with the BERT classifier
26
+ [`OmarMedhat7/youtube-sentiment-analysis-model`](https://huggingface.co/OmarMedhat7/youtube-sentiment-analysis-model);
27
+ the highest positive share wins.
28
+ 3. **Transcript** fetched with **`youtube-transcript-api`** (already timestamped; no
29
+ download, no Whisper).
30
+ 4. **Tutorial text** `deepseek-ai/DeepSeek-V3` (HF Inference Providers, billed to your
31
+ token) turns the transcript into an **answer-engine-optimized** post: answer-first
32
+ paragraph, H2 steps, FAQ, meta description, URL slug, last-updated/source citation.
33
+ Optional **primary/secondary keyword** placement.
34
+ 5. **Screenshots** — real frames at the right moments **without downloading the video**:
35
+ `yt-dlp` resolves a direct stream URL (metadata only), then `ffmpeg -ss T -frames:v 1`
36
+ grabs one frame per timestamp (sharpest of 3 candidates). Timestamps come from a
37
+ weighted blend of the LLM's suggestion and the transcript's actual timing.
38
+ 6. **Captions** — a vision model (default `Qwen/Qwen2.5-VL-72B-Instruct`, billed to your
39
  token) captions each screenshot.
40
+ 7. **Assemble** the `.docx` for download.
41
 
42
+ ## Keys & setup
43
 
44
+ - **Hugging Face token** for the LLM + vision-model calls, billed to your account.
45
+ Create a fine-grained token with *"Make calls to Inference Providers"* at
46
  <https://huggingface.co/settings/tokens>.
47
+ - **YouTube Data API key** for fetching comments. Create one in the
48
+ [Google Cloud Console](https://console.cloud.google.com/) and **enable *YouTube Data API
49
+ v3***. Provide it in the UI, or set the **`YOUTUBE_API_KEY`** Space secret. (Without a
50
+ key the Space skips sentiment and just uses the top search result.)
 
 
 
 
 
 
 
 
51
 
52
+ ## Notes on YouTube access
53
 
54
+ The transcript and the stream-URL resolution hit YouTube directly. From a datacenter IP
55
+ (like a Space) these can occasionally be **blocked**:
 
 
 
56
 
57
+ - If transcript/stream resolution fails, set the optional **`YT_PROXY`** secret (a
58
+ residential proxy) and/or **`YT_COOKIES`** (Netscape cookies.txt contents, raw or
59
+ base64) — used only as a thin fallback.
60
+ - If the stream URL can't be resolved, the Space still produces a **text-only** tutorial.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
61
 
62
  ## Local run
63
 
app.py CHANGED
@@ -1,8 +1,13 @@
1
- """Gradio Space: YouTube topic -> captioned .docx tutorial.
2
 
3
- Orchestrates the pipeline stages and streams progress/status to the UI. Heavy ML
4
- imports (torch/transformers/faster-whisper) are lazy inside the pipeline modules, so app
5
- startup stays fast.
 
 
 
 
 
6
  """
7
  from __future__ import annotations
8
 
@@ -18,7 +23,6 @@ import gradio as gr
18
  from pipeline import (
19
  captions as captions_mod,
20
  docx_builder,
21
- download as download_mod,
22
  frames as frames_mod,
23
  search as search_mod,
24
  sentiment as sentiment_mod,
@@ -26,26 +30,6 @@ from pipeline import (
26
  tutorial as tutorial_mod,
27
  )
28
 
29
- APP_DIR = os.path.dirname(os.path.abspath(__file__))
30
- EXTENSION_DIR = os.path.join(APP_DIR, "extension")
31
-
32
-
33
- def _build_extension_zip() -> str | None:
34
- """Zip the bundled browser extension for one-click download; return the .zip path.
35
-
36
- The extension's files (manifest.json, etc.) sit at the **root** of the archive, so the
37
- unzipped folder directly contains manifest.json and can be selected as-is in
38
- "Load unpacked". Returns None if the folder isn't present.
39
- """
40
- if not os.path.isdir(EXTENSION_DIR):
41
- return None
42
- out_base = os.path.join(tempfile.gettempdir(), "tutorialmaker-extension")
43
- try:
44
- return shutil.make_archive(out_base, "zip", root_dir=EXTENSION_DIR)
45
- except Exception:
46
- return None
47
-
48
-
49
  LLM_CHOICES = [
50
  "deepseek-ai/DeepSeek-V3",
51
  "meta-llama/Llama-3.3-70B-Instruct",
@@ -58,18 +42,13 @@ VLM_CHOICES = [
58
  ]
59
 
60
 
 
61
  def _looks_like_netscape(text: str) -> bool:
62
- """True if ``text`` is already a tab-separated Netscape cookie file."""
63
  head = text.lstrip()
64
  return head.startswith("#") or "\tTRUE\t" in text or "\tFALSE\t" in text
65
 
66
 
67
  def _maybe_b64_decode(text: str) -> str | None:
68
- """If ``text`` is base64 that decodes to a cookie file, return the decoded text.
69
-
70
- HF secret fields can turn the required TAB characters into spaces, which breaks the
71
- Netscape format. Pasting base64 of the file avoids that; we auto-detect and decode it.
72
- """
73
  compact = "".join(text.split())
74
  if len(compact) < 16 or re.search(r"[^A-Za-z0-9+/=]", compact):
75
  return None
@@ -80,53 +59,43 @@ def _maybe_b64_decode(text: str) -> str | None:
80
  return decoded if _looks_like_netscape(decoded) else None
81
 
82
 
83
- def _cookiefile(workdir: str, raw: str | None = None) -> str | None:
84
- """Materialize cookies to a Netscape cookie file on disk; return its path or None.
85
-
86
- ``raw`` is the per-user UI value; if empty we fall back to the operator-wide
87
- ``YT_COOKIES`` secret. Accepts either raw cookies.txt contents (tabs preserved) or a
88
- base64 encoding of them. A missing header line is added so yt-dlp accepts the file.
89
- """
90
- data = (raw or "").strip() or os.environ.get("YT_COOKIES")
91
  if not data or not data.strip():
92
  return None
93
-
94
  if not _looks_like_netscape(data):
95
  decoded = _maybe_b64_decode(data)
96
  if decoded:
97
  data = decoded
98
-
99
  if not data.lstrip().startswith(("# Netscape", "# HTTP")):
100
  data = "# Netscape HTTP Cookie File\n" + data.lstrip("\n")
101
  if not data.endswith("\n"):
102
  data += "\n"
103
-
104
  path = os.path.join(workdir, "cookies.txt")
105
  with open(path, "w", encoding="utf-8", newline="\n") as fh:
106
  fh.write(data)
107
  return path
108
 
109
 
110
- def _resolve_proxy(raw: str | None = None) -> str | None:
111
- """Per-user proxy URL, falling back to the operator-wide YT_PROXY secret."""
112
- proxy = (raw or "").strip() or os.environ.get("YT_PROXY", "").strip()
113
  return proxy or None
114
 
115
 
116
- def _resolve_pot(po_token: str | None, visitor_data: str | None) -> tuple[str | None, str | None]:
117
- """Per-user PO token + visitor data, falling back to YT_POT / YT_VISITOR_DATA secrets."""
118
- pot = (po_token or "").strip() or os.environ.get("YT_POT", "").strip()
119
- vis = (visitor_data or "").strip() or os.environ.get("YT_VISITOR_DATA", "").strip()
120
- return (pot or None), (vis or None)
121
 
122
 
 
123
  def _ranking_rows(scored: list[dict]) -> list[list]:
124
  rows = []
125
  for rank, v in enumerate(scored, start=1):
126
  rows.append([
127
  rank,
128
  v.get("title", v["video_id"]),
129
- f"{v['positive_share'] * 100:.0f}%",
130
  v.get("n_comments", 0),
131
  v.get("note", "") or "ok",
132
  v["url"],
@@ -139,13 +108,8 @@ def _safe_name(text: str) -> str:
139
 
140
 
141
  def _collect_keywords(primary_kw, secondary_kw) -> dict:
142
- """Build ``{"primary": str, "secondary": [str, ...]}`` from the two keyword inputs.
143
-
144
- Secondary keywords are comma-separated. Duplicates and the primary are removed.
145
- """
146
  primary = (primary_kw or "").strip()
147
- secondary = []
148
- seen = {primary.lower()}
149
  for part in (secondary_kw or "").split(","):
150
  kw = part.strip()
151
  if kw and kw.lower() not in seen:
@@ -154,11 +118,10 @@ def _collect_keywords(primary_kw, secondary_kw) -> dict:
154
  return {"primary": primary, "secondary": secondary}
155
 
156
 
157
- def run_pipeline(topic, hf_token, llm_model, vlm_model, w_llm, w_whisper, lead,
158
- max_minutes, max_shots, primary_kw, secondary_kw,
159
- cookies_text, proxy_url, po_token_in, visitor_data_in,
160
  progress=gr.Progress()):
161
- """Generator that yields (status_md, ranking_df, transcript, docx_file)."""
162
  log: list[str] = []
163
 
164
  def status(msg: str):
@@ -173,62 +136,53 @@ def run_pipeline(topic, hf_token, llm_model, vlm_model, w_llm, w_whisper, lead,
173
 
174
  workdir = tempfile.mkdtemp(prefix="ytt_")
175
  frames_dir = os.path.join(workdir, "frames")
176
- video_path = None
177
  try:
178
- cookiefile = _cookiefile(workdir, cookies_text)
179
- proxy = _resolve_proxy(proxy_url)
180
- po_token, visitor_data = _resolve_pot(po_token_in, visitor_data_in)
181
- auth_bits = []
182
- if cookiefile:
183
- auth_bits.append("cookies")
184
- if proxy:
185
- auth_bits.append("proxy")
186
- if po_token:
187
- auth_bits.append("PO token")
188
- if auth_bits:
189
- yield status("🔐 Using " + " + ".join(auth_bits) + " for YouTube access."), gr.update(), gr.update(), gr.update()
190
 
191
  # 1. Search ------------------------------------------------------------------
192
- progress(0.02, desc="Searching")
193
  yield status(f"🔍 Searching top videos for **{topic}**…"), gr.update(), gr.update(), gr.update()
194
  videos = search_mod.search_top5(topic)
195
  yield status(f"Found {len(videos)} candidate videos."), gr.update(), gr.update(), gr.update()
196
 
197
- # 2. Sentiment ranking -------------------------------------------------------
198
- yield status("💬 Fetching comments and scoring sentiment…"), gr.update(), gr.update(), gr.update()
199
- best, scored = sentiment_mod.rank_by_sentiment(
200
- videos, cookiefile, progress, proxy, po_token, visitor_data)
 
 
 
 
 
 
 
201
  ranking = gr.update(value=_ranking_rows(scored))
202
- yield (status(f"🏆 Picked **{best.get('title', best['video_id'])}** "
203
- f"({best['positive_share'] * 100:.0f}% positive)."),
204
  ranking, gr.update(), gr.update())
205
 
206
- # 3. Download + audio --------------------------------------------------------
207
- progress(0.25, desc="Downloading")
208
- yield status("⬇️ Downloading the chosen video…"), ranking, gr.update(), gr.update()
209
- video_path, duration = download_mod.download_video(
210
- best["url"], workdir, cookiefile, int(max_minutes), proxy, po_token, visitor_data)
211
- wav = download_mod.extract_audio(video_path, workdir)
212
-
213
- # 4. Transcribe --------------------------------------------------------------
214
- progress(0.4, desc="Transcribing")
215
- yield status("📝 Transcribing with Whisper (this is the slow part on CPU)…"), ranking, gr.update(), gr.update()
216
- segs = transcribe_mod.transcribe(wav, progress)
217
  transcript = transcribe_mod.transcript_text(segs)
218
  yield (status(f"Transcript ready ({len(segs)} segments)."),
219
  ranking, gr.update(value=transcript), gr.update())
220
 
221
- # 5. Candidate frames, then DELETE the video --------------------------------
222
- progress(0.6, desc="Extracting frames")
223
- candidates = frames_mod.extract_candidates(video_path, frames_dir, duration)
224
- frames_mod.delete_video(video_path)
225
- video_path = None
226
- yield (status(f"🎞️ Extracted {len(candidates)} candidate frames and "
227
- f"**deleted the downloaded video**."),
228
- ranking, gr.update(value=transcript), gr.update())
 
 
229
 
230
- # 6. Tutorial text -----------------------------------------------------------
231
- progress(0.72, desc="Writing tutorial")
232
  keywords = _collect_keywords(primary_kw, secondary_kw)
233
  kw_note = f" • primary: '{keywords['primary']}'" if keywords["primary"] else ""
234
  if keywords["secondary"]:
@@ -240,119 +194,77 @@ def run_pipeline(topic, hf_token, llm_model, vlm_model, w_llm, w_whisper, lead,
240
  yield (status(f"🔑 Primary keyword '{keywords['primary']}' appears {n}× in the post."),
241
  ranking, gr.update(value=transcript), gr.update())
242
 
243
- # 7. Weighted screenshot selection ------------------------------------------
244
- selected = frames_mod.select_screenshots(
245
- tut["steps"], segs, candidates,
246
- w_llm=float(w_llm), w_whisper=float(w_whisper), lead=float(lead),
247
- max_shots=int(max_shots),
248
- )
249
- yield (status(f"🖼️ Selected {len(selected)} screenshots via the weighted indicator."),
250
- ranking, gr.update(value=transcript), gr.update())
251
-
252
- # 8. Captions ----------------------------------------------------------------
253
- progress(0.85, desc="Captioning")
254
- yield status(f"✍️ Captioning screenshots with `{vlm_model}`…"), ranking, gr.update(value=transcript), gr.update()
255
- caps = captions_mod.caption_frames(selected, tut["steps"], hf_token.strip(), vlm_model, progress)
256
-
257
- # 9. DOCX --------------------------------------------------------------------
258
- progress(0.95, desc="Building document")
259
  out_path = os.path.join(workdir, f"{_safe_name(tut['title'])}.docx")
260
  docx_builder.build_docx(tut, selected, caps, out_path, source_url=best["url"])
261
 
262
  progress(1.0, desc="Done")
263
- yield (status(" Done! Download your tutorial below."),
 
264
  ranking, gr.update(value=transcript), gr.update(value=out_path))
265
 
266
  except gr.Error:
267
  raise
268
- except (download_mod.DownloadError, RuntimeError, ValueError) as exc:
 
269
  raise gr.Error(str(exc))
270
- finally:
271
- # Always remove the video if it somehow survived; keep frames/docx until the
272
- # response is sent (Gradio copies the returned file out).
273
- if video_path:
274
- frames_mod.delete_video(video_path)
275
 
276
 
277
  def build_ui():
278
  with gr.Blocks(title="YouTube → Tutorial Post") as demo:
279
  gr.Markdown(
280
  "# 📝 YouTube → Tutorial Post Generator\n"
281
- "Enter a topic and your Hugging Face token. The Space picks the best video, "
282
- "transcribes it, and builds a **captioned `.docx` tutorial**. Your token is "
283
- "used only for the LLM + vision-model calls and **billed to your account**."
 
284
  )
285
  with gr.Row():
286
  with gr.Column(scale=2):
287
  topic = gr.Textbox(label="Topic", placeholder="e.g. Excel pivot tables for beginners")
288
  hf_token = gr.Textbox(label="Hugging Face token", type="password",
289
  placeholder="hf_… (Inference Providers permission)")
 
 
290
  with gr.Column(scale=1):
291
  llm_model = gr.Dropdown(LLM_CHOICES, value=LLM_CHOICES[0],
292
  label="Tutorial LLM", allow_custom_value=True)
293
  vlm_model = gr.Dropdown(VLM_CHOICES, value=VLM_CHOICES[0],
294
  label="Vision model (captions)", allow_custom_value=True)
295
 
296
- with gr.Accordion("YouTube access — cookies / proxy (often required)", open=False):
297
- gr.Markdown(
298
- "⚠️ **YouTube usually blocks the Space's datacenter IP.** To download, give "
299
- "the Space **your own** access below — it is used only for your run and "
300
- "deleted afterward.\n\n"
301
- "- **Use a throwaway Google account, not your main one.** yt-dlp activity "
302
- "can get an account rate-limited or flagged.\n"
303
- "- **Cookies:** export a `youtube.com` cookies.txt (Netscape format) from a "
304
- "logged-in throwaway account and paste it (raw or base64) below.\n"
305
- "- **PO token (free, no proxy):** a Proof-of-Origin token + visitor data "
306
- "can pass the bot-check from a datacenter IP. **See this Space's README → "
307
- "\"PO token\" guide** for how to get and paste them.\n"
308
- "- **Proxy:** a **residential** proxy works; **free *datacenter* proxies "
309
- "(e.g. Webshare's free tier) usually do NOT** get past YouTube's block and "
310
- "have tight bandwidth caps.\n"
311
- "- An operator can instead set Space secrets `YT_COOKIES` / `YT_PROXY` / "
312
- "`YT_POT` / `YT_VISITOR_DATA` as shared defaults."
313
- )
314
- _ext_zip = _build_extension_zip()
315
- if _ext_zip:
316
- gr.DownloadButton(
317
- "⬇️ Download the PO Token Grabber extension (.zip)",
318
- value=_ext_zip, size="sm")
319
- cookies_text = gr.Textbox(
320
- label="YouTube cookies (cookies.txt contents or base64)", lines=4,
321
- placeholder="# Netscape HTTP Cookie File … (or a base64 blob)")
322
- with gr.Row():
323
- po_token_in = gr.Textbox(
324
- label="PO token (see README) — CLIENT.CONTEXT+TOKEN", lines=2, scale=3,
325
- elem_id="tm_pot_token",
326
- placeholder="web.gvs+AbC…, web.player+XyZ…")
327
- visitor_data_in = gr.Textbox(
328
- label="Visitor data (pairs with the PO token)", scale=2,
329
- elem_id="tm_visitor_data",
330
- placeholder="Cgt...%3D%3D")
331
- proxy_url = gr.Textbox(
332
- label="Proxy URL (optional)", type="password",
333
- placeholder="http://user:pass@host:port")
334
-
335
  with gr.Accordion("SEO / AEO keywords (optional)", open=False):
336
  gr.Markdown(
337
  "The **primary keyword** is used naturally ~3× in the body and placed in "
338
- "the title, URL slug, meta description, the first 100 words, and one or "
339
- "two H2 headings. Each **secondary keyword** is used once. The post also "
340
- "follows answer-engine best practices (direct answer up top, FAQ, "
341
- "last-updated date, source citation)."
342
  )
343
- primary_kw = gr.Textbox(label="Primary keyword",
344
- placeholder="e.g. godot ai plugin")
345
  secondary_kw = gr.Textbox(label="Secondary keywords (comma-separated)",
346
- placeholder="e.g. gdscript assistant, ai game tools")
347
 
348
  with gr.Accordion("Advanced settings", open=False):
349
  with gr.Row():
350
  w_llm = gr.Slider(0.0, 1.0, value=0.4, step=0.05, label="Weight: LLM timestamp")
351
- w_whisper = gr.Slider(0.0, 1.0, value=0.6, step=0.05, label="Weight: Whisper timing")
352
  lead = gr.Slider(0.0, 5.0, value=1.0, step=0.5, label="Lead offset (s)")
353
- with gr.Row():
354
- max_minutes = gr.Slider(2, 60, value=20, step=1, label="Max video length (min)")
355
- max_shots = gr.Slider(1, 15, value=8, step=1, label="Max screenshots")
356
 
357
  run_btn = gr.Button("Generate tutorial", variant="primary")
358
 
@@ -366,13 +278,12 @@ def build_ui():
366
 
367
  run_btn.click(
368
  run_pipeline,
369
- inputs=[topic, hf_token, llm_model, vlm_model, w_llm, w_whisper, lead,
370
- max_minutes, max_shots, primary_kw, secondary_kw,
371
- cookies_text, proxy_url, po_token_in, visitor_data_in],
372
  outputs=[status_md, ranking_df, transcript_box, docx_file],
373
  )
374
  return demo
375
 
376
 
377
  if __name__ == "__main__":
378
- build_ui().queue().launch(allowed_paths=[tempfile.gettempdir()])
 
1
+ """Gradio Space: YouTube topic -> captioned .docx tutorial (API-based acquisition).
2
 
3
+ Pipeline:
4
+ search top videos -> rank by YouTube Data API comment sentiment -> transcript via
5
+ youtube-transcript-api -> DeepSeek-V3 tutorial -> real screenshots via yt-dlp stream URL
6
+ + ffmpeg -ss (weighted timestamps) -> VLM captions -> .docx.
7
+
8
+ No video download and no cookies/proxy/PO-token UI. A thin fallback remains via the
9
+ optional Space secrets YT_COOKIES / YT_PROXY (used only to resolve the stream URL and the
10
+ transcript when the Space's datacenter IP is blocked).
11
  """
12
  from __future__ import annotations
13
 
 
23
  from pipeline import (
24
  captions as captions_mod,
25
  docx_builder,
 
26
  frames as frames_mod,
27
  search as search_mod,
28
  sentiment as sentiment_mod,
 
30
  tutorial as tutorial_mod,
31
  )
32
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
33
  LLM_CHOICES = [
34
  "deepseek-ai/DeepSeek-V3",
35
  "meta-llama/Llama-3.3-70B-Instruct",
 
42
  ]
43
 
44
 
45
+ # --------------------------------------------------------------- thin access fallback
46
  def _looks_like_netscape(text: str) -> bool:
 
47
  head = text.lstrip()
48
  return head.startswith("#") or "\tTRUE\t" in text or "\tFALSE\t" in text
49
 
50
 
51
  def _maybe_b64_decode(text: str) -> str | None:
 
 
 
 
 
52
  compact = "".join(text.split())
53
  if len(compact) < 16 or re.search(r"[^A-Za-z0-9+/=]", compact):
54
  return None
 
59
  return decoded if _looks_like_netscape(decoded) else None
60
 
61
 
62
+ def _cookiefile(workdir: str) -> str | None:
63
+ """Materialize the optional YT_COOKIES secret to a Netscape file; return path or None."""
64
+ data = os.environ.get("YT_COOKIES")
 
 
 
 
 
65
  if not data or not data.strip():
66
  return None
 
67
  if not _looks_like_netscape(data):
68
  decoded = _maybe_b64_decode(data)
69
  if decoded:
70
  data = decoded
 
71
  if not data.lstrip().startswith(("# Netscape", "# HTTP")):
72
  data = "# Netscape HTTP Cookie File\n" + data.lstrip("\n")
73
  if not data.endswith("\n"):
74
  data += "\n"
 
75
  path = os.path.join(workdir, "cookies.txt")
76
  with open(path, "w", encoding="utf-8", newline="\n") as fh:
77
  fh.write(data)
78
  return path
79
 
80
 
81
+ def _resolve_proxy() -> str | None:
82
+ proxy = os.environ.get("YT_PROXY", "").strip()
 
83
  return proxy or None
84
 
85
 
86
+ def _resolve_api_key(ui_key: str | None) -> str | None:
87
+ key = (ui_key or "").strip() or os.environ.get("YOUTUBE_API_KEY", "").strip()
88
+ return key or None
 
 
89
 
90
 
91
+ # ----------------------------------------------------------------------------- helpers
92
  def _ranking_rows(scored: list[dict]) -> list[list]:
93
  rows = []
94
  for rank, v in enumerate(scored, start=1):
95
  rows.append([
96
  rank,
97
  v.get("title", v["video_id"]),
98
+ f"{v.get('positive_share', 0) * 100:.0f}%",
99
  v.get("n_comments", 0),
100
  v.get("note", "") or "ok",
101
  v["url"],
 
108
 
109
 
110
  def _collect_keywords(primary_kw, secondary_kw) -> dict:
 
 
 
 
111
  primary = (primary_kw or "").strip()
112
+ secondary, seen = [], {primary.lower()}
 
113
  for part in (secondary_kw or "").split(","):
114
  kw = part.strip()
115
  if kw and kw.lower() not in seen:
 
118
  return {"primary": primary, "secondary": secondary}
119
 
120
 
121
+ def run_pipeline(topic, hf_token, yt_api_key, llm_model, vlm_model,
122
+ w_llm, w_whisper, lead, max_shots, primary_kw, secondary_kw,
 
123
  progress=gr.Progress()):
124
+ """Generator yielding (status_md, ranking_df, transcript, docx_file)."""
125
  log: list[str] = []
126
 
127
  def status(msg: str):
 
136
 
137
  workdir = tempfile.mkdtemp(prefix="ytt_")
138
  frames_dir = os.path.join(workdir, "frames")
 
139
  try:
140
+ api_key = _resolve_api_key(yt_api_key)
141
+ cookiefile = _cookiefile(workdir)
142
+ proxy = _resolve_proxy()
 
 
 
 
 
 
 
 
 
143
 
144
  # 1. Search ------------------------------------------------------------------
145
+ progress(0.03, desc="Searching")
146
  yield status(f"🔍 Searching top videos for **{topic}**…"), gr.update(), gr.update(), gr.update()
147
  videos = search_mod.search_top5(topic)
148
  yield status(f"Found {len(videos)} candidate videos."), gr.update(), gr.update(), gr.update()
149
 
150
+ # 2. Sentiment ranking (YouTube Data API comments) ---------------------------
151
+ if api_key:
152
+ yield status("💬 Fetching comments (YouTube Data API) and scoring sentiment…"), gr.update(), gr.update(), gr.update()
153
+ best, scored = sentiment_mod.rank_by_sentiment(videos, api_key, progress)
154
+ picked_msg = f"({best['positive_share'] * 100:.0f}% positive comments)"
155
+ else:
156
+ best = videos[0]
157
+ scored = [{**v, "positive_share": 0.0, "n_comments": 0,
158
+ "note": "sentiment skipped (no API key)", "search_rank": i}
159
+ for i, v in enumerate(videos)]
160
+ picked_msg = "(no YouTube Data API key → used top search result)"
161
  ranking = gr.update(value=_ranking_rows(scored))
162
+ yield (status(f"🏆 Picked **{best.get('title', best['video_id'])}** {picked_msg}."),
 
163
  ranking, gr.update(), gr.update())
164
 
165
+ # 3. Transcript (youtube-transcript-api) -------------------------------------
166
+ progress(0.3, desc="Transcript")
167
+ yield status("📝 Fetching the timestamped transcript…"), ranking, gr.update(), gr.update()
168
+ segs = transcribe_mod.get_segments(best["video_id"], proxy=proxy)
 
 
 
 
 
 
 
169
  transcript = transcribe_mod.transcript_text(segs)
170
  yield (status(f"Transcript ready ({len(segs)} segments)."),
171
  ranking, gr.update(value=transcript), gr.update())
172
 
173
+ # 4. Resolve a stream URL for screenshots (best-effort) ----------------------
174
+ progress(0.45, desc="Resolving stream")
175
+ stream_url = None
176
+ try:
177
+ stream_url, _ = frames_mod.get_stream_url(best["video_id"], cookiefile, proxy)
178
+ except Exception as exc:
179
+ yield (status(f"⚠️ Couldn't resolve a video stream for screenshots "
180
+ f"({type(exc).__name__}) — producing a **text-only** tutorial. "
181
+ "Set YT_PROXY/YT_COOKIES secrets to enable screenshots."),
182
+ ranking, gr.update(value=transcript), gr.update())
183
 
184
+ # 5. Tutorial text -----------------------------------------------------------
185
+ progress(0.6, desc="Writing tutorial")
186
  keywords = _collect_keywords(primary_kw, secondary_kw)
187
  kw_note = f" • primary: '{keywords['primary']}'" if keywords["primary"] else ""
188
  if keywords["secondary"]:
 
194
  yield (status(f"🔑 Primary keyword '{keywords['primary']}' appears {n}× in the post."),
195
  ranking, gr.update(value=transcript), gr.update())
196
 
197
+ # 6. Screenshots (weighted timestamps -> ffmpeg grab) + captions -------------
198
+ selected, caps = {}, {}
199
+ if stream_url:
200
+ progress(0.75, desc="Screenshots")
201
+ times = frames_mod.compute_shot_times(
202
+ tut["steps"], segs, w_llm=float(w_llm), w_whisper=float(w_whisper),
203
+ lead=float(lead), max_shots=int(max_shots))
204
+ yield status(f"🎞️ Capturing {len(times)} screenshots at weighted timestamps…"), ranking, gr.update(value=transcript), gr.update()
205
+ selected = frames_mod.capture_shots(times, stream_url, frames_dir, proxy, progress)
206
+ if selected:
207
+ progress(0.88, desc="Captioning")
208
+ yield status(f"✍️ Captioning {len(selected)} screenshots with `{vlm_model}`…"), ranking, gr.update(value=transcript), gr.update()
209
+ caps = captions_mod.caption_frames(selected, tut["steps"], hf_token.strip(), vlm_model, progress)
210
+
211
+ # 7. DOCX --------------------------------------------------------------------
212
+ progress(0.96, desc="Building document")
213
  out_path = os.path.join(workdir, f"{_safe_name(tut['title'])}.docx")
214
  docx_builder.build_docx(tut, selected, caps, out_path, source_url=best["url"])
215
 
216
  progress(1.0, desc="Done")
217
+ shots_msg = f"{len(selected)} screenshots" if selected else "text-only"
218
+ yield (status(f"✅ Done ({shots_msg}). Download your tutorial below."),
219
  ranking, gr.update(value=transcript), gr.update(value=out_path))
220
 
221
  except gr.Error:
222
  raise
223
+ except (transcribe_mod.TranscriptError, sentiment_mod.SentimentError,
224
+ RuntimeError, ValueError) as exc:
225
  raise gr.Error(str(exc))
 
 
 
 
 
226
 
227
 
228
  def build_ui():
229
  with gr.Blocks(title="YouTube → Tutorial Post") as demo:
230
  gr.Markdown(
231
  "# 📝 YouTube → Tutorial Post Generator\n"
232
+ "Enter a topic, your **Hugging Face token** (LLM + vision model, billed to you) "
233
+ "and a **YouTube Data API key** (for comments). The Space picks the best video by "
234
+ "comment sentiment, pulls its transcript, writes an AEO-friendly tutorial, grabs "
235
+ "real screenshots at the right moments, and builds a **.docx**."
236
  )
237
  with gr.Row():
238
  with gr.Column(scale=2):
239
  topic = gr.Textbox(label="Topic", placeholder="e.g. Excel pivot tables for beginners")
240
  hf_token = gr.Textbox(label="Hugging Face token", type="password",
241
  placeholder="hf_… (Inference Providers permission)")
242
+ yt_api_key = gr.Textbox(label="YouTube Data API key", type="password",
243
+ placeholder="for comments (or set the YOUTUBE_API_KEY secret)")
244
  with gr.Column(scale=1):
245
  llm_model = gr.Dropdown(LLM_CHOICES, value=LLM_CHOICES[0],
246
  label="Tutorial LLM", allow_custom_value=True)
247
  vlm_model = gr.Dropdown(VLM_CHOICES, value=VLM_CHOICES[0],
248
  label="Vision model (captions)", allow_custom_value=True)
249
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
250
  with gr.Accordion("SEO / AEO keywords (optional)", open=False):
251
  gr.Markdown(
252
  "The **primary keyword** is used naturally ~3× in the body and placed in "
253
+ "the title, URL slug, meta description, the first 100 words, and one or two "
254
+ "H2 headings. Each **secondary keyword** is used once. The post also follows "
255
+ "answer-engine best practices (direct answer up top, FAQ, last-updated date, "
256
+ "source citation)."
257
  )
258
+ primary_kw = gr.Textbox(label="Primary keyword", placeholder="e.g. excel pivot tables")
 
259
  secondary_kw = gr.Textbox(label="Secondary keywords (comma-separated)",
260
+ placeholder="e.g. pivot chart, data summary")
261
 
262
  with gr.Accordion("Advanced settings", open=False):
263
  with gr.Row():
264
  w_llm = gr.Slider(0.0, 1.0, value=0.4, step=0.05, label="Weight: LLM timestamp")
265
+ w_whisper = gr.Slider(0.0, 1.0, value=0.6, step=0.05, label="Weight: transcript timing")
266
  lead = gr.Slider(0.0, 5.0, value=1.0, step=0.5, label="Lead offset (s)")
267
+ max_shots = gr.Slider(1, 15, value=8, step=1, label="Max screenshots")
 
 
268
 
269
  run_btn = gr.Button("Generate tutorial", variant="primary")
270
 
 
278
 
279
  run_btn.click(
280
  run_pipeline,
281
+ inputs=[topic, hf_token, yt_api_key, llm_model, vlm_model,
282
+ w_llm, w_whisper, lead, max_shots, primary_kw, secondary_kw],
 
283
  outputs=[status_md, ranking_df, transcript_box, docx_file],
284
  )
285
  return demo
286
 
287
 
288
  if __name__ == "__main__":
289
+ build_ui().queue().launch()
extension/README.md DELETED
@@ -1,78 +0,0 @@
1
- # TutorialMaker — PO Token Grabber (browser extension)
2
-
3
- A tiny **Chrome/Edge (Manifest V3)** extension that reads the two values the
4
- [TutorialMaker Space](https://huggingface.co/spaces/vivekchakraverty/TutorialMaker) needs
5
- to get past YouTube's bot-check **without a proxy**:
6
-
7
- - **`visitor_data`** — read from the YouTube page's `ytcfg`.
8
- - **gvs `pot`** (Proof-of-Origin token) — captured from the player's
9
- `googlevideo.com/videoplayback` request.
10
-
11
- It just surfaces these for one-click copy; it sends nothing anywhere.
12
-
13
- ## Install (unpacked)
14
-
15
- 1. Open `chrome://extensions` (or `edge://extensions`, `comet://extensions`).
16
- 2. Turn on **Developer mode** (top-right).
17
- 3. Click **Load unpacked** and select the folder that **directly contains
18
- `manifest.json`** — i.e. the unzipped download folder, or this `extension/` folder if
19
- you cloned the repo. (If you see a "Manifest file is missing" error, you selected a
20
- parent folder — go one level deeper to the one holding `manifest.json`.)
21
-
22
- > Firefox: it's MV3-compatible in spirit, but Firefox needs a `background.scripts` entry
23
- > and a `browser_specific_settings` block instead of `service_worker`. Minor manifest
24
- > tweaks required.
25
-
26
- ## Use
27
-
28
- 1. **Use a throwaway Google account**, not your main one.
29
- 2. Open a YouTube **video** and press **play** (this makes the player fetch
30
- `videoplayback`, which carries the `pot`).
31
- 3. Click the extension icon **while on the YouTube tab** — this captures the PO token and
32
- reads your visitor data.
33
- 4. Open the **TutorialMaker Space** in another tab (the direct `https://<space>.hf.space`
34
- app URL is the most reliable target).
35
- 5. Click the extension icon again, then **⤵ Fill the Space tab** — it injects both values
36
- straight into the Space's "PO token" and "Visitor data" fields (expand the "YouTube
37
- access" panel to see them).
38
- - Or use **Copy PO token** / **Copy visitor data** and paste manually.
39
- 6. Run the Space. Tokens expire within hours — re-grab when downloads start failing.
40
-
41
- ## How it works
42
-
43
- - `background.js` — a read-only `webRequest` listener on `*://*.googlevideo.com/videoplayback*`
44
- pulls the `pot` (and `c`/client) query params and stores the latest in
45
- `chrome.storage.session`. It prefers `c=WEB` tokens (what yt-dlp's web client uses).
46
- - `popup.js` — on open, runs a MAIN-world script via `chrome.scripting` on the active
47
- YouTube tab to read `visitor_data` (cached in session storage), reads the captured
48
- `pot`, and formats the PO token as `web.gvs+<token>`.
49
- - **Auto-fill** — "Fill the Space tab" injects `fillSpaceFields` into the Space tab (all
50
- frames, so it works both at `*.hf.space` and when embedded in `huggingface.co/spaces`).
51
- It sets `#tm_pot_token` / `#tm_visitor_data` using the native value setter plus `input`
52
- events so Gradio's Svelte bindings register the change. Requires the `*://*.hf.space/*`
53
- host permission.
54
-
55
- ## Troubleshooting
56
-
57
- **"No PO token yet" even though the video is playing.** YouTube fetches media via
58
- `fetch`/XHR (SABR), so the token is captured by a page-level hook (`content_hook.js`), not
59
- only the service worker. If it still doesn't appear:
60
-
61
- - **Reload the video page** after installing the extension (the hook must be in place
62
- *before* the player starts).
63
- - Press **play** and let it **buffer a few seconds**, then click **Refresh** in the popup.
64
- - If the popup says *"Media requests were seen but none carried a PO token,"* your current
65
- session isn't attaching one — try a **logged-out Incognito** window (with the extension
66
- allowed in Incognito), or just use **cookies** instead.
67
- - Make sure the extension is **allowed in Incognito** if you test there
68
- (`chrome://extensions` → Details → Allow in Incognito).
69
-
70
- ## Caveats
71
-
72
- - **Unpublished / sideloaded** — you load it yourself; trust accordingly. The code is
73
- small and dependency-free on purpose.
74
- - **YouTube changes break it** — the `pot`/`visitor_data` locations move over time; this
75
- may need updates. The authoritative manual method lives in the
76
- [yt-dlp PO Token Guide](https://github.com/yt-dlp/yt-dlp/wiki/PO-Token-Guide).
77
- - **Per session** — tokens are short-lived; this is a one-click refresh, not a permanent fix.
78
- - Automating bot-check bypass is the same YouTube-ToS gray area as the rest of this tooling.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
extension/background.js DELETED
@@ -1,34 +0,0 @@
1
- // Service worker. Two jobs:
2
- // 1. Raise the session-storage access level so the youtube.com content-script bridge
3
- // (an "untrusted" context) can persist captured values for the popup to read.
4
- // 2. A secondary, read-only webRequest capture of the gvs PO token from
5
- // *.googlevideo.com/videoplayback requests (the page-level fetch/XHR hook is primary).
6
-
7
- try {
8
- chrome.storage.session.setAccessLevel({ accessLevel: "TRUSTED_AND_UNTRUSTED_CONTEXTS" });
9
- } catch (e) {}
10
-
11
- const FILTER = { urls: ["*://*.googlevideo.com/*"] };
12
-
13
- chrome.webRequest.onBeforeRequest.addListener(
14
- (details) => {
15
- try {
16
- if (details.url.indexOf("videoplayback") === -1 &&
17
- details.url.indexOf("initplayback") === -1) return;
18
- chrome.storage.session.set({ gvAnySeen: true, lastSeen: Date.now() });
19
- const u = new URL(details.url);
20
- const pot = u.searchParams.get("pot");
21
- if (!pot) return;
22
- const client = u.searchParams.get("c") || "";
23
- chrome.storage.session.get(["gvsClient"]).then((cur) => {
24
- const haveWeb = cur.gvsClient === "WEB";
25
- if (!haveWeb || client === "WEB") {
26
- chrome.storage.session.set({ gvsPot: pot, gvsClient: client, potTime: Date.now() });
27
- }
28
- });
29
- } catch (e) {
30
- // ignore malformed URLs
31
- }
32
- },
33
- FILTER
34
- );
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
extension/content_bridge.js DELETED
@@ -1,25 +0,0 @@
1
- // Isolated-world bridge: receives postMessages from the MAIN-world hook and persists the
2
- // captured values to session storage (which the popup reads). Needs the background script
3
- // to have raised the session-storage access level for untrusted contexts.
4
- window.addEventListener("message", function (e) {
5
- if (e.source !== window) return;
6
- const d = e.data;
7
- if (!d || d.__potgrab !== true) return;
8
-
9
- if (d.type === "seen") {
10
- chrome.storage.session.set({ gvAnySeen: true, lastSeen: Date.now() });
11
- } else if (d.type === "pot" && d.data && d.data.pot) {
12
- chrome.storage.session.get(["gvsClient"]).then(function (cur) {
13
- const haveWeb = cur.gvsClient === "WEB";
14
- if (!haveWeb || d.data.c === "WEB") {
15
- chrome.storage.session.set({
16
- gvsPot: d.data.pot,
17
- gvsClient: d.data.c || "",
18
- potTime: Date.now(),
19
- });
20
- }
21
- });
22
- } else if (d.type === "visitor" && d.data && d.data.v) {
23
- chrome.storage.session.set({ visitorData: d.data.v });
24
- }
25
- });
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
extension/content_hook.js DELETED
@@ -1,62 +0,0 @@
1
- // MAIN-world hook (runs in the page's JS context at document_start).
2
- //
3
- // Modern YouTube fetches media via fetch()/XHR to *.googlevideo.com/videoplayback?...&pot=...
4
- // (the MV3 service-worker webRequest path can miss these). We patch fetch + XHR to observe
5
- // those request URLs, pull the gvs `pot`, and post it to the isolated bridge script. We
6
- // also report visitor_data once it's available. We only observe — requests pass through
7
- // untouched.
8
- (function () {
9
- function report(type, data) {
10
- try { window.postMessage({ __potgrab: true, type: type, data: data }, "*"); } catch (e) {}
11
- }
12
-
13
- function scanUrl(url) {
14
- try {
15
- if (!url) return;
16
- const s = String(url);
17
- if (
18
- s.indexOf("videoplayback") === -1 &&
19
- s.indexOf("initplayback") === -1 &&
20
- s.indexOf("googlevideo.com") === -1
21
- ) return;
22
- report("seen", null);
23
- const u = new URL(s, location.href);
24
- const pot = u.searchParams.get("pot");
25
- if (pot) report("pot", { pot: pot, c: u.searchParams.get("c") || "" });
26
- } catch (e) {}
27
- }
28
-
29
- try {
30
- const origFetch = window.fetch;
31
- if (origFetch) {
32
- window.fetch = function (input) {
33
- try { scanUrl(typeof input === "string" ? input : (input && input.url)); } catch (e) {}
34
- return origFetch.apply(this, arguments);
35
- };
36
- }
37
- } catch (e) {}
38
-
39
- try {
40
- const origOpen = XMLHttpRequest.prototype.open;
41
- XMLHttpRequest.prototype.open = function (method, url) {
42
- try { scanUrl(url); } catch (e) {}
43
- return origOpen.apply(this, arguments);
44
- };
45
- } catch (e) {}
46
-
47
- function reportVisitor() {
48
- try {
49
- if (window.ytcfg && typeof ytcfg.get === "function") {
50
- const v = ytcfg.get("VISITOR_DATA");
51
- if (v) { report("visitor", { v: v }); return true; }
52
- }
53
- } catch (e) {}
54
- return false;
55
- }
56
- if (!reportVisitor()) {
57
- let tries = 0;
58
- const iv = setInterval(function () {
59
- if (reportVisitor() || ++tries > 30) clearInterval(iv);
60
- }, 1000);
61
- }
62
- })();
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
extension/manifest.json DELETED
@@ -1,32 +0,0 @@
1
- {
2
- "manifest_version": 3,
3
- "name": "TutorialMaker PO Token Grabber",
4
- "version": "1.1.0",
5
- "description": "Reads your YouTube visitor data and gvs PO token so you can paste them into the TutorialMaker Space.",
6
- "permissions": ["webRequest", "storage", "scripting", "tabs"],
7
- "host_permissions": [
8
- "*://*.youtube.com/*",
9
- "*://*.googlevideo.com/*",
10
- "*://*.hf.space/*"
11
- ],
12
- "background": { "service_worker": "background.js" },
13
- "content_scripts": [
14
- {
15
- "matches": ["*://*.youtube.com/*"],
16
- "js": ["content_hook.js"],
17
- "run_at": "document_start",
18
- "world": "MAIN",
19
- "all_frames": true
20
- },
21
- {
22
- "matches": ["*://*.youtube.com/*"],
23
- "js": ["content_bridge.js"],
24
- "run_at": "document_start",
25
- "all_frames": true
26
- }
27
- ],
28
- "action": {
29
- "default_popup": "popup.html",
30
- "default_title": "PO Token Grabber"
31
- }
32
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
extension/popup.html DELETED
@@ -1,45 +0,0 @@
1
- <!DOCTYPE html>
2
- <html>
3
- <head>
4
- <meta charset="utf-8" />
5
- <style>
6
- body { font-family: system-ui, sans-serif; width: 340px; margin: 0; padding: 12px; }
7
- h1 { font-size: 14px; margin: 0 0 8px; }
8
- label { display: block; font-size: 11px; color: #444; margin: 8px 0 2px; }
9
- textarea { width: 100%; box-sizing: border-box; font-family: monospace; font-size: 11px;
10
- resize: vertical; }
11
- .row { display: flex; gap: 6px; margin-top: 6px; }
12
- button { flex: 1; padding: 6px; font-size: 12px; cursor: pointer; }
13
- #status { margin-top: 10px; font-size: 11px; min-height: 14px; }
14
- #status.ok { color: #1a7f37; }
15
- #status.warn { color: #9a6700; }
16
- .hint { font-size: 10px; color: #666; margin-top: 8px; line-height: 1.4; }
17
- </style>
18
- </head>
19
- <body>
20
- <h1>PO Token Grabber</h1>
21
-
22
- <label for="potField">PO token (paste into the Space's "PO token" field)</label>
23
- <textarea id="potField" rows="2" readonly placeholder="web.gvs+…"></textarea>
24
-
25
- <label for="visField">Visitor data (paste into the Space's "Visitor data" field)</label>
26
- <textarea id="visField" rows="2" readonly placeholder="Cgt…%3D%3D"></textarea>
27
-
28
- <div class="row">
29
- <button id="fillSpace"><b>⤵ Fill the Space tab</b></button>
30
- </div>
31
- <div class="row">
32
- <button id="copyPot">Copy PO token</button>
33
- <button id="copyVis">Copy visitor data</button>
34
- <button id="refresh">Refresh</button>
35
- </div>
36
-
37
- <div id="status"></div>
38
- <div class="hint">
39
- Tip: open a YouTube video, press <b>play</b> (so the token is generated), then click
40
- this icon. Use a throwaway account. Tokens expire within hours.
41
- </div>
42
-
43
- <script src="popup.js"></script>
44
- </body>
45
- </html>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
extension/popup.js DELETED
@@ -1,179 +0,0 @@
1
- // Popup: read visitor_data from the active YouTube tab (main world) + the captured gvs PO
2
- // token from session storage, then either copy the values or auto-fill the Space's fields.
3
-
4
- // Runs in the page's MAIN world via chrome.scripting; must be self-contained.
5
- function readVisitorData() {
6
- try {
7
- if (window.ytcfg && typeof window.ytcfg.get === "function") {
8
- const v = window.ytcfg.get("VISITOR_DATA");
9
- if (v) return v;
10
- const ctx = window.ytcfg.get("INNERTUBE_CONTEXT");
11
- if (ctx && ctx.client && ctx.client.visitorData) return ctx.client.visitorData;
12
- }
13
- } catch (e) {}
14
- try {
15
- const r = window.ytInitialPlayerResponse;
16
- if (r && r.responseContext && r.responseContext.visitorData) {
17
- return r.responseContext.visitorData;
18
- }
19
- } catch (e) {}
20
- return null;
21
- }
22
-
23
- // Injected into the Space tab (default/isolated world) to fill the Gradio inputs by their
24
- // elem_id. Uses the native value setter so Gradio's Svelte bindings register the change.
25
- function fillSpaceFields(poToken, visitorData) {
26
- function setVal(id, val) {
27
- if (!val) return false;
28
- const root = document.getElementById(id);
29
- if (!root) return false;
30
- const el = root.querySelector("textarea, input");
31
- if (!el) return false;
32
- const proto =
33
- el.tagName === "TEXTAREA"
34
- ? window.HTMLTextAreaElement.prototype
35
- : window.HTMLInputElement.prototype;
36
- const setter = Object.getOwnPropertyDescriptor(proto, "value").set;
37
- setter.call(el, val);
38
- el.dispatchEvent(new Event("input", { bubbles: true }));
39
- el.dispatchEvent(new Event("change", { bubbles: true }));
40
- return true;
41
- }
42
- let n = 0;
43
- if (setVal("tm_pot_token", poToken)) n++;
44
- if (setVal("tm_visitor_data", visitorData)) n++;
45
- return n;
46
- }
47
-
48
- const SPACE_RE = /(\.hf\.space)|(huggingface\.co\/spaces\/)/i;
49
-
50
- function setStatus(msg, kind) {
51
- const el = document.getElementById("status");
52
- el.textContent = msg;
53
- el.className = kind || "";
54
- }
55
-
56
- async function getActiveTab() {
57
- const [tab] = await chrome.tabs.query({ active: true, currentWindow: true });
58
- return tab;
59
- }
60
-
61
- function isYouTube(url) {
62
- return /^https?:\/\/([^/]*\.)?youtube\.com\//.test(url || "");
63
- }
64
-
65
- async function load() {
66
- const potEl = document.getElementById("potField");
67
- const visEl = document.getElementById("visField");
68
-
69
- const tab = await getActiveTab();
70
-
71
- // Read visitor_data live if we're on YouTube, and remember it for later (e.g. when the
72
- // Space tab is the one in focus during fill).
73
- let liveVisitor = null;
74
- if (tab && isYouTube(tab.url)) {
75
- try {
76
- const res = await chrome.scripting.executeScript({
77
- target: { tabId: tab.id },
78
- world: "MAIN",
79
- func: readVisitorData,
80
- });
81
- liveVisitor = res && res[0] ? res[0].result : null;
82
- } catch (e) {}
83
- if (liveVisitor) await chrome.storage.session.set({ visitorData: liveVisitor });
84
- }
85
-
86
- const store = await chrome.storage.session.get([
87
- "gvsPot",
88
- "gvsClient",
89
- "potTime",
90
- "visitorData",
91
- "gvAnySeen",
92
- ]);
93
- const visitor = liveVisitor || store.visitorData || "";
94
-
95
- visEl.value = visitor;
96
- potEl.value = store.gvsPot ? "web.gvs+" + store.gvsPot : "";
97
-
98
- // Is the Space open in some tab? Enable the fill button if so.
99
- const all = await chrome.tabs.query({});
100
- const spaceTabs = all.filter((t) => SPACE_RE.test(t.url || ""));
101
- document.getElementById("fillSpace").disabled = spaceTabs.length === 0;
102
-
103
- const notes = [];
104
- if (!visitor) notes.push("No visitor data yet — open the popup once on the YouTube tab.");
105
- if (!store.gvsPot) {
106
- if (store.gvAnySeen) {
107
- notes.push("Media requests were seen but none carried a PO token — this session may "
108
- + "not attach one. Try a logged-out Incognito window, reload the video, or use "
109
- + "cookies instead.");
110
- } else if (isYouTube(tab && tab.url)) {
111
- notes.push("No media captured yet — press play and let the video buffer a few "
112
- + "seconds, then Refresh. If a video is already playing, reload the page.");
113
- } else {
114
- notes.push("Open a YouTube video and press play to capture a PO token.");
115
- }
116
- }
117
- if (store.gvsPot && store.gvsClient && store.gvsClient !== "WEB") {
118
- notes.push("Captured client is '" + store.gvsClient + "', not WEB — use the normal web player.");
119
- }
120
- if (spaceTabs.length === 0) notes.push("Open the TutorialMaker Space in a tab to enable auto-fill.");
121
- if (store.potTime) {
122
- const mins = Math.round((Date.now() - store.potTime) / 60000);
123
- notes.push("Token captured ~" + mins + " min ago (they expire within hours).");
124
- }
125
- setStatus(notes.join(" ") || "Ready.", notes.length ? "warn" : "ok");
126
- }
127
-
128
- async function copy(id, label) {
129
- const val = document.getElementById(id).value;
130
- if (!val) {
131
- setStatus("Nothing to copy yet for " + label + ".", "warn");
132
- return;
133
- }
134
- try {
135
- await navigator.clipboard.writeText(val);
136
- setStatus("Copied " + label + " ✓", "ok");
137
- } catch (e) {
138
- setStatus("Copy failed — select the text and copy manually.", "warn");
139
- }
140
- }
141
-
142
- async function fillSpace() {
143
- const poToken = document.getElementById("potField").value;
144
- const visitorData = document.getElementById("visField").value;
145
- if (!poToken && !visitorData) {
146
- setStatus("Nothing to fill yet — capture the values first.", "warn");
147
- return;
148
- }
149
- const all = await chrome.tabs.query({});
150
- const spaceTabs = all.filter((t) => SPACE_RE.test(t.url || ""));
151
- if (spaceTabs.length === 0) {
152
- setStatus("No TutorialMaker Space tab found — open it first.", "warn");
153
- return;
154
- }
155
- let filled = 0;
156
- for (const t of spaceTabs) {
157
- try {
158
- const results = await chrome.scripting.executeScript({
159
- target: { tabId: t.id, allFrames: true },
160
- func: fillSpaceFields,
161
- args: [poToken, visitorData],
162
- });
163
- for (const r of results) filled += r.result || 0;
164
- } catch (e) {}
165
- }
166
- if (filled > 0) {
167
- setStatus("Filled " + filled + " field(s) in the Space ✓ (expand the panel to see).", "ok");
168
- } else {
169
- setStatus("Couldn't find the fields — make sure the Space finished loading.", "warn");
170
- }
171
- }
172
-
173
- document.addEventListener("DOMContentLoaded", () => {
174
- load();
175
- document.getElementById("copyPot").addEventListener("click", () => copy("potField", "PO token"));
176
- document.getElementById("copyVis").addEventListener("click", () => copy("visField", "visitor data"));
177
- document.getElementById("fillSpace").addEventListener("click", fillSpace);
178
- document.getElementById("refresh").addEventListener("click", load);
179
- });
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
pipeline/__init__.py CHANGED
@@ -1,6 +1,7 @@
1
- """Pipeline package: YouTube topic -> captioned .docx tutorial.
2
 
3
  Stages (see app.run_pipeline for orchestration):
4
- search -> sentiment -> download -> transcribe -> frames (extract + delete video)
5
- -> tutorial -> frames.select (weighted) -> captions -> docx_builder
 
6
  """
 
1
+ """Pipeline package: YouTube topic -> captioned .docx tutorial (API-based, no download).
2
 
3
  Stages (see app.run_pipeline for orchestration):
4
+ search -> sentiment (YouTube Data API comments) -> transcribe (youtube-transcript-api)
5
+ -> tutorial (LLM) -> frames (yt-dlp stream URL + ffmpeg -ss, weighted timestamps)
6
+ -> captions (VLM) -> docx_builder
7
  """
pipeline/download.py DELETED
@@ -1,162 +0,0 @@
1
- """Stage 3: download the chosen video and extract audio for Whisper.
2
-
3
- Downloads a <=720p mp4 with yt-dlp, then uses ffmpeg to produce a 16 kHz mono wav.
4
- Enforces a duration cap up front so a long video can't stall the free-CPU Space.
5
-
6
- Both YouTube-facing calls accept an optional ``cookiefile`` and ``proxy`` (per-user or
7
- operator-wide), and fall back across a few yt-dlp player clients to improve the odds of
8
- getting past datacenter-IP blocking without cookies.
9
- """
10
- from __future__ import annotations
11
-
12
- import os
13
- import re
14
- import subprocess
15
-
16
- from yt_dlp import YoutubeDL
17
-
18
- # Player clients tried in order. "android"/"tv" sometimes succeed where "web" is blocked.
19
- _PLAYER_CLIENTS = ["default", "android", "tv", "web_safari"]
20
- # When a (web) PO token is supplied it only applies to web-family clients, so try those.
21
- _POT_CLIENTS = ["web_safari", "web", "default"]
22
-
23
-
24
- class DownloadError(RuntimeError):
25
- """Raised for user-actionable download failures (blocked IP, too long, etc.)."""
26
-
27
-
28
- def _parse_pot(po_token: str | None) -> list[str]:
29
- """Split a PO-token field into yt-dlp entries (comma/semicolon/newline separated).
30
-
31
- Each entry should be in yt-dlp's ``CLIENT.CONTEXT+TOKEN`` form, e.g. ``web.gvs+AbC``.
32
- """
33
- if not po_token:
34
- return []
35
- return [p.strip() for p in re.split(r"[,\n;]+", po_token) if p.strip()]
36
-
37
-
38
- def _base_opts(cookiefile: str | None, proxy: str | None, player_client: str,
39
- po_token: str | None = None, visitor_data: str | None = None) -> dict:
40
- yt_args: dict = {"player_client": [player_client]}
41
- pots = _parse_pot(po_token)
42
- if pots:
43
- yt_args["po_token"] = pots
44
- if visitor_data and visitor_data.strip():
45
- yt_args["visitor_data"] = [visitor_data.strip()]
46
- opts = {
47
- "quiet": True,
48
- "no_warnings": True,
49
- "noplaylist": True,
50
- "extractor_args": {"youtube": yt_args},
51
- }
52
- if cookiefile:
53
- opts["cookiefile"] = cookiefile
54
- if proxy:
55
- opts["proxy"] = proxy
56
- return opts
57
-
58
-
59
- def _extract_with_fallback(url: str, base: dict, cookiefile: str | None,
60
- proxy: str | None, download: bool,
61
- po_token: str | None = None, visitor_data: str | None = None):
62
- """Run extract_info, retrying across player clients on failure.
63
-
64
- Returns ``(info, ydl)`` from the first client that works; raises DownloadError with a
65
- helpful hint if all clients fail.
66
- """
67
- clients = _POT_CLIENTS if _parse_pot(po_token) else _PLAYER_CLIENTS
68
- last_exc = None
69
- for client in clients:
70
- opts = {**_base_opts(cookiefile, proxy, client, po_token, visitor_data), **base}
71
- try:
72
- ydl = YoutubeDL(opts)
73
- info = ydl.extract_info(url, download=download)
74
- return info, ydl
75
- except Exception as exc: # try the next client
76
- last_exc = exc
77
- continue
78
- raise DownloadError(
79
- _blocked_hint(last_exc, bool(cookiefile), bool(proxy), bool(_parse_pot(po_token))))
80
-
81
-
82
- def probe_duration(url: str, cookiefile: str | None = None, proxy: str | None = None,
83
- po_token: str | None = None, visitor_data: str | None = None) -> float:
84
- """Return the video duration in seconds without downloading."""
85
- info, _ = _extract_with_fallback(url, {"skip_download": True}, cookiefile, proxy,
86
- download=False, po_token=po_token,
87
- visitor_data=visitor_data)
88
- return float(info.get("duration") or 0.0)
89
-
90
-
91
- def download_video(url: str, out_dir: str, cookiefile: str | None = None,
92
- max_minutes: int = 20, proxy: str | None = None,
93
- po_token: str | None = None,
94
- visitor_data: str | None = None) -> tuple[str, float]:
95
- """Download the video to ``out_dir``; return ``(mp4_path, duration_seconds)``."""
96
- os.makedirs(out_dir, exist_ok=True)
97
-
98
- duration = probe_duration(url, cookiefile, proxy, po_token, visitor_data)
99
- if duration and duration > max_minutes * 60:
100
- raise DownloadError(
101
- f"Video is {duration / 60:.0f} min long, over the {max_minutes} min cap for "
102
- "this free-CPU Space. Pick a shorter video or raise the cap."
103
- )
104
-
105
- base = {
106
- "outtmpl": os.path.join(out_dir, "video.%(ext)s"),
107
- "format": "bestvideo[height<=720][ext=mp4]+bestaudio[ext=m4a]/best[height<=720]/best",
108
- "merge_output_format": "mp4",
109
- }
110
- info, ydl = _extract_with_fallback(url, base, cookiefile, proxy, download=True,
111
- po_token=po_token, visitor_data=visitor_data)
112
- path = ydl.prepare_filename(info)
113
- duration = float(info.get("duration") or duration or 0.0)
114
-
115
- # merge_output_format may have rewritten the extension to .mp4
116
- if not os.path.exists(path):
117
- base_path, _ = os.path.splitext(path)
118
- path = base_path + ".mp4"
119
- if not os.path.exists(path):
120
- raise DownloadError("yt-dlp reported success but no output file was found.")
121
- return path, duration
122
-
123
-
124
- def extract_audio(video_path: str, out_dir: str) -> str:
125
- """Extract 16 kHz mono wav from ``video_path`` via ffmpeg; return the wav path."""
126
- wav_path = os.path.join(out_dir, "audio.wav")
127
- cmd = [
128
- "ffmpeg", "-y", "-i", video_path,
129
- "-vn", "-ac", "1", "-ar", "16000", "-f", "wav", wav_path,
130
- ]
131
- proc = subprocess.run(cmd, capture_output=True, text=True)
132
- if proc.returncode != 0 or not os.path.exists(wav_path):
133
- raise DownloadError(
134
- "ffmpeg failed to extract audio. Is ffmpeg installed (packages.txt)?\n"
135
- + proc.stderr[-600:]
136
- )
137
- return wav_path
138
-
139
-
140
- def _blocked_hint(exc: Exception | None, had_cookies: bool, had_proxy: bool,
141
- had_pot: bool = False) -> str:
142
- msg = str(exc) if exc else "unknown error"
143
- lowered = msg.lower()
144
- if any(k in lowered for k in ("sign in", "bot", "403", "429", "confirm you", "cookies",
145
- "po_token", "po token")):
146
- used = []
147
- if had_cookies:
148
- used.append("cookies")
149
- if had_proxy:
150
- used.append("proxy")
151
- if had_pot:
152
- used.append("PO token")
153
- ctx = (f" Your {' + '.join(used)} did not get past it"
154
- if used else " No cookies, proxy, or PO token were provided")
155
- return (
156
- "YouTube blocked this request (typical from datacenter IPs)." + ctx + ". "
157
- "Try throwaway-account cookies, a PO token + visitor data (see the README "
158
- "'PO token' guide), and/or a residential proxy. Note: free datacenter proxies "
159
- "usually do NOT bypass this block.\n"
160
- f"Original error: {msg[:300]}"
161
- )
162
- return f"Download failed: {msg[:400]}"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
pipeline/frames.py CHANGED
@@ -1,122 +1,159 @@
1
- """Stage 5 + 6: candidate frames and the weighted screenshot indicator.
2
-
3
- Flow:
4
- * ``extract_candidates`` pulls a dense, downscaled grid of frames from the video in a
5
- single ffmpeg pass, *before* the video is deleted.
6
- * ``delete_video`` removes the source video (auto-delete once the transcript + frames
7
- exist).
8
- * ``select_screenshots`` runs *after* the LLM. For each step it computes a capture time
9
- that blends the LLM's suggested timestamp with the Whisper segment timing of the text
10
- the step quotes, then snaps that target to the nearest pre-extracted candidate frame.
11
  """
12
  from __future__ import annotations
13
 
14
  import difflib
15
- import glob
16
  import os
17
  import subprocess
18
 
19
- # A 1 fps grid gives <=0.5 s snapping error. Capped so a long video can't explode disk.
20
- GRID_FPS = 1.0
21
- MAX_CANDIDATES = 1500
22
 
23
-
24
- def extract_candidates(video_path: str, out_dir: str, duration: float) -> list[tuple[float, str]]:
25
- """Extract a downscaled candidate-frame grid; return sorted ``[(time_s, path), ...]``."""
26
- os.makedirs(out_dir, exist_ok=True)
27
- eff_fps = GRID_FPS
28
- if duration and duration * GRID_FPS > MAX_CANDIDATES:
29
- eff_fps = MAX_CANDIDATES / duration # thin the grid for very long videos
30
-
31
- pattern = os.path.join(out_dir, "cand_%06d.jpg")
32
- cmd = [
33
- "ffmpeg", "-y", "-i", video_path,
34
- "-vf", f"fps={eff_fps:.6f},scale=-2:720",
35
- "-q:v", "4", pattern,
36
- ]
37
- proc = subprocess.run(cmd, capture_output=True, text=True)
38
- files = sorted(glob.glob(os.path.join(out_dir, "cand_*.jpg")))
39
- if not files:
40
- raise RuntimeError(
41
- "ffmpeg extracted no candidate frames.\n" + proc.stderr[-600:]
42
- )
43
- # fps filter emits frame i (1-based) at ~ (i-1)/eff_fps seconds.
44
- return [((i) / eff_fps, f) for i, f in enumerate(files)]
45
-
46
-
47
- def delete_video(video_path: str) -> None:
48
- """Delete the downloaded video (and any sibling temp media) if present."""
49
- for p in (video_path,):
50
- try:
51
- if p and os.path.exists(p):
52
- os.remove(p)
53
- except OSError:
54
- pass
55
 
56
 
 
57
  def _clamp(x: float, lo: float, hi: float) -> float:
58
  return max(lo, min(hi, x))
59
 
60
 
61
  def best_match_segment(quote: str, t_llm: float, segs: list[dict]) -> dict:
62
- """Find the Whisper segment a step refers to.
63
-
64
- Prefer the segment whose text best matches ``quote``; if there's no decent textual
65
- match, fall back to the segment nearest ``t_llm``.
66
- """
67
  quote = (quote or "").strip().lower()
68
  if quote:
69
- scored = [
70
- (difflib.SequenceMatcher(None, quote, s["text"].lower()).ratio(), s)
71
- for s in segs
72
- ]
73
- ratio, best = max(scored, key=lambda t: t[0])
74
  if ratio >= 0.3:
75
  return best
76
  return min(segs, key=lambda s: abs((s["start"] + s["end"]) / 2 - t_llm))
77
 
78
 
79
- def pick_time(step: dict, segs: list[dict], cand_times: list[float], *,
80
- w_llm: float = 0.4, w_whisper: float = 0.6, lead: float = 1.0,
81
- max_drift: float = 20.0) -> float:
82
- """Weighted capture time, snapped to the nearest candidate frame.
83
 
84
- ``t_whisper`` (segment mid + lead) is ground truth for *when* the referenced text is
85
- spoken; ``t_llm`` is the model's guess of *what* matters. We blend them, but if the
86
- LLM time drifts far from the matched segment we clamp it back into that segment so a
87
- hallucinated timestamp can't drag the shot off-topic.
88
  """
89
  seg = best_match_segment(step.get("quote", ""), float(step.get("t_llm", 0.0)), segs)
90
  t_whisper = (seg["start"] + seg["end"]) / 2 + lead
91
  t_llm = float(step.get("t_llm", t_whisper))
92
  if abs(t_llm - t_whisper) > max_drift:
93
  t_llm = _clamp(t_llm, seg["start"], seg["end"])
 
 
94
 
95
- total = w_llm + w_whisper or 1.0
96
- t_target = (w_llm * t_llm + w_whisper * t_whisper) / total
97
- return min(cand_times, key=lambda c: abs(c - t_target))
98
 
99
-
100
- def select_screenshots(steps: list[dict], segs: list[dict],
101
- candidates: list[tuple[float, str]], *,
102
- w_llm: float = 0.4, w_whisper: float = 0.6, lead: float = 1.0,
103
- max_shots: int = 8) -> dict[int, dict]:
104
- """Return ``{step_index: {"time", "path"}}`` for the steps that get a screenshot.
105
-
106
- When there are more steps than ``max_shots``, the highest-``importance`` steps win.
107
- """
108
- if not candidates:
109
  return {}
110
- cand_times = [t for t, _ in candidates]
111
- time_to_path = dict(candidates)
112
-
113
  order = sorted(range(len(steps)), key=lambda i: -float(steps[i].get("importance", 0.5)))
114
  chosen = set(order[:max_shots])
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
115
 
 
 
 
 
116
  out: dict[int, dict] = {}
117
- for i, step in enumerate(steps):
118
- if i not in chosen:
119
- continue
120
- t = pick_time(step, segs, cand_times, w_llm=w_llm, w_whisper=w_whisper, lead=lead)
121
- out[i] = {"time": t, "path": time_to_path[t]}
 
 
122
  return out
 
1
+ """Stage 5 + 6: real screenshots via yt-dlp stream URL + ffmpeg, at weighted timestamps.
2
+
3
+ No full download. We resolve a direct video stream URL with yt-dlp (metadata only), then
4
+ ``ffmpeg -ss T -frames:v 1`` grabs a single frame per timestamp over HTTP range requests
5
+ (only a few seconds of data each). For each shot we grab 3 candidates around T and keep
6
+ the sharpest (Laplacian variance) to avoid landing on a blurry/transition frame.
7
+
8
+ The capture timestamp itself comes from the weighted indicator: a blend of the LLM's
9
+ suggested timestamp and the transcript segment timing of the text the step quotes.
 
10
  """
11
  from __future__ import annotations
12
 
13
  import difflib
 
14
  import os
15
  import subprocess
16
 
17
+ from yt_dlp import YoutubeDL
 
 
18
 
19
+ # Format preference: muxed (single A/V url) <=720p first, then video-only, then anything.
20
+ _FORMAT = ("best[height<=720][vcodec!=none][acodec!=none]/"
21
+ "best[height<=720][vcodec!=none]/bestvideo[height<=720]/best[vcodec!=none]/best")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
22
 
23
 
24
+ # --------------------------------------------------------------------------- weighting
25
  def _clamp(x: float, lo: float, hi: float) -> float:
26
  return max(lo, min(hi, x))
27
 
28
 
29
  def best_match_segment(quote: str, t_llm: float, segs: list[dict]) -> dict:
30
+ """The transcript segment a step refers to: best text match, else nearest to t_llm."""
 
 
 
 
31
  quote = (quote or "").strip().lower()
32
  if quote:
33
+ ratio, best = max(
34
+ ((difflib.SequenceMatcher(None, quote, s["text"].lower()).ratio(), s) for s in segs),
35
+ key=lambda t: t[0])
 
 
36
  if ratio >= 0.3:
37
  return best
38
  return min(segs, key=lambda s: abs((s["start"] + s["end"]) / 2 - t_llm))
39
 
40
 
41
+ def pick_time(step: dict, segs: list[dict], *, w_llm: float = 0.4, w_whisper: float = 0.6,
42
+ lead: float = 1.0, max_drift: float = 20.0) -> float:
43
+ """Weighted capture time (seconds). No snapping — we grab on demand at any T.
 
44
 
45
+ Transcript timing is ground truth for *when* the text is spoken; the LLM time is its
46
+ guess of *what* matters. Blend them, clamping a far-off LLM time back into the matched
47
+ segment so a hallucinated timestamp can't drag the shot off-topic.
 
48
  """
49
  seg = best_match_segment(step.get("quote", ""), float(step.get("t_llm", 0.0)), segs)
50
  t_whisper = (seg["start"] + seg["end"]) / 2 + lead
51
  t_llm = float(step.get("t_llm", t_whisper))
52
  if abs(t_llm - t_whisper) > max_drift:
53
  t_llm = _clamp(t_llm, seg["start"], seg["end"])
54
+ total = (w_llm + w_whisper) or 1.0
55
+ return max(0.0, (w_llm * t_llm + w_whisper * t_whisper) / total)
56
 
 
 
 
57
 
58
+ def compute_shot_times(steps: list[dict], segs: list[dict], *, w_llm: float = 0.4,
59
+ w_whisper: float = 0.6, lead: float = 1.0,
60
+ max_shots: int = 8) -> dict[int, float]:
61
+ """Return ``{step_index: capture_time}`` for the top-``max_shots`` steps by importance."""
62
+ if not segs:
 
 
 
 
 
63
  return {}
 
 
 
64
  order = sorted(range(len(steps)), key=lambda i: -float(steps[i].get("importance", 0.5)))
65
  chosen = set(order[:max_shots])
66
+ return {
67
+ i: pick_time(steps[i], segs, w_llm=w_llm, w_whisper=w_whisper, lead=lead)
68
+ for i in range(len(steps)) if i in chosen
69
+ }
70
+
71
+
72
+ # ----------------------------------------------------------------------- stream + grab
73
+ def get_stream_url(video_id: str, cookiefile: str | None = None,
74
+ proxy: str | None = None) -> tuple[str, float]:
75
+ """Resolve a direct video stream URL (no download); return ``(url, duration_seconds)``."""
76
+ opts = {"quiet": True, "no_warnings": True, "skip_download": True, "format": _FORMAT}
77
+ if cookiefile:
78
+ opts["cookiefile"] = cookiefile
79
+ if proxy:
80
+ opts["proxy"] = proxy
81
+ with YoutubeDL(opts) as ydl:
82
+ info = ydl.extract_info(f"https://www.youtube.com/watch?v={video_id}", download=False)
83
+
84
+ stream = info.get("url")
85
+ if not stream:
86
+ for f in info.get("requested_formats") or []:
87
+ if f.get("vcodec") not in (None, "none") and f.get("url"):
88
+ stream = f["url"]
89
+ break
90
+ if not stream:
91
+ vids = [f for f in info.get("formats", [])
92
+ if f.get("vcodec") not in (None, "none") and f.get("url")]
93
+ if vids:
94
+ stream = sorted(vids, key=lambda f: f.get("height") or 0)[-1]["url"]
95
+ if not stream:
96
+ raise RuntimeError("Could not resolve a video stream URL for frame capture.")
97
+ return stream, float(info.get("duration") or 0.0)
98
+
99
+
100
+ def _grab(stream_url: str, t: float, out_path: str, proxy: str | None) -> bool:
101
+ """Grab one frame at time ``t`` via ffmpeg input-seek; return True on success."""
102
+ cmd = ["ffmpeg", "-y", "-loglevel", "error", "-ss", f"{max(0.0, t):.2f}",
103
+ "-i", stream_url, "-frames:v", "1", "-q:v", "2", out_path]
104
+ env = dict(os.environ)
105
+ if proxy: # stream URL is bound to the extraction IP; route ffmpeg through it too
106
+ env["http_proxy"] = env["https_proxy"] = proxy
107
+ try:
108
+ subprocess.run(cmd, capture_output=True, text=True, timeout=90, env=env)
109
+ except subprocess.TimeoutExpired:
110
+ return False
111
+ return os.path.exists(out_path) and os.path.getsize(out_path) > 0
112
+
113
+
114
+ def _sharpness(path: str) -> float:
115
+ """Variance of the Laplacian (higher = sharper). Used to reject blurry frames."""
116
+ import numpy as np
117
+ from PIL import Image
118
+
119
+ im = np.asarray(Image.open(path).convert("L"), dtype=np.float64)
120
+ if im.shape[0] < 3 or im.shape[1] < 3:
121
+ return 0.0
122
+ lap = (-4 * im[1:-1, 1:-1] + im[:-2, 1:-1] + im[2:, 1:-1]
123
+ + im[1:-1, :-2] + im[1:-1, 2:])
124
+ return float(lap.var())
125
+
126
+
127
+ def grab_best_frame(stream_url: str, t: float, out_dir: str, idx: int,
128
+ proxy: str | None = None) -> str | None:
129
+ """Grab candidates around ``t`` and keep the sharpest; return its path or None."""
130
+ cands = []
131
+ for k, dt in enumerate((-0.5, 0.0, 0.5)):
132
+ p = os.path.join(out_dir, f"shot_{idx}_{k}.jpg")
133
+ if _grab(stream_url, t + dt, p, proxy):
134
+ cands.append(p)
135
+ if not cands:
136
+ return None
137
+ best = max(cands, key=_sharpness)
138
+ for p in cands:
139
+ if p != best:
140
+ try:
141
+ os.remove(p)
142
+ except OSError:
143
+ pass
144
+ return best
145
+
146
 
147
+ def capture_shots(times: dict[int, float], stream_url: str, out_dir: str,
148
+ proxy: str | None = None, progress=None) -> dict[int, dict]:
149
+ """Grab a frame for each ``{step_index: time}``; return ``{idx: {"time", "path"}}``."""
150
+ os.makedirs(out_dir, exist_ok=True)
151
  out: dict[int, dict] = {}
152
+ items = list(times.items())
153
+ for n, (idx, t) in enumerate(items):
154
+ if progress:
155
+ progress((n + 1) / max(1, len(items)), desc=f"Screenshot {n + 1}/{len(items)}")
156
+ path = grab_best_frame(stream_url, t, out_dir, idx, proxy)
157
+ if path:
158
+ out[idx] = {"time": t, "path": path}
159
  return out
pipeline/sentiment.py CHANGED
@@ -1,29 +1,32 @@
1
- """Stage 2: rank candidate videos by the sentiment of their top comments.
2
 
3
- We fetch comments with yt-dlp (no video download) and score them with the BERT text
4
- classifier ``OmarMedhat7/youtube-sentiment-analysis-model`` running inside the Space.
5
- The video with the highest positive share wins. Comment scraping is flaky, so any
6
- single video that fails simply scores 0 and we never abort the whole ranking.
7
  """
8
  from __future__ import annotations
9
 
10
- import os
11
- import re
 
 
12
  from functools import lru_cache
13
 
14
- from yt_dlp import YoutubeDL
15
-
16
  SENTIMENT_MODEL = "OmarMedhat7/youtube-sentiment-analysis-model"
17
- MAX_COMMENTS = 30
 
18
 
19
- # Label strings vary by model; treat anything that looks positive/negative accordingly.
20
  _POSITIVE = {"positive", "pos", "label_2", "label_1"}
21
  _NEGATIVE = {"negative", "neg", "label_0"}
22
 
23
 
 
 
 
 
24
  @lru_cache(maxsize=1)
25
  def _classifier():
26
- """Lazily build and cache the text-classification pipeline (heavy import)."""
27
  from transformers import pipeline
28
 
29
  return pipeline(
@@ -31,16 +34,11 @@ def _classifier():
31
  model=SENTIMENT_MODEL,
32
  truncation=True,
33
  max_length=256,
34
- top_k=None, # return all label scores so we can read the dominant one
35
  )
36
 
37
 
38
  def _polarity(scores) -> float:
39
- """Map one classifier output to a polarity in [-1, 1].
40
-
41
- ``scores`` is a list of ``{"label", "score"}`` (top_k=None). Positive labels add,
42
- negative labels subtract; neutral/unknown contribute nothing.
43
- """
44
  val = 0.0
45
  for s in scores:
46
  label = str(s["label"]).strip().lower()
@@ -51,43 +49,65 @@ def _polarity(scores) -> float:
51
  return val
52
 
53
 
54
- def _fetch_comments(url: str, cookiefile: str | None, proxy: str | None,
55
- po_token: str | None = None, visitor_data: str | None = None) -> list[str]:
56
- yt_args: dict = {"max_comments": [str(MAX_COMMENTS)], "comment_sort": ["top"]}
57
- if po_token:
58
- yt_args["po_token"] = [p.strip() for p in re.split(r"[,\n;]+", po_token) if p.strip()]
59
- if visitor_data and visitor_data.strip():
60
- yt_args["visitor_data"] = [visitor_data.strip()]
61
- opts = {
62
- "skip_download": True,
63
- "getcomments": True,
64
- "quiet": True,
65
- "no_warnings": True,
66
- "extractor_args": {"youtube": yt_args},
67
- }
68
- if cookiefile:
69
- opts["cookiefile"] = cookiefile
70
- if proxy:
71
- opts["proxy"] = proxy
72
- with YoutubeDL(opts) as ydl:
73
- info = ydl.extract_info(url, download=False)
74
- comments = info.get("comments") or []
75
- texts = [c.get("text", "").strip() for c in comments if c.get("text")]
76
- return texts[:MAX_COMMENTS]
77
-
78
-
79
- def rank_by_sentiment(videos: list[dict], cookiefile: str | None = None,
80
- progress=None, proxy: str | None = None,
81
- po_token: str | None = None,
82
- visitor_data: str | None = None) -> tuple[dict, list[dict]]:
83
- """Score each video and return ``(best_video, scored)``.
84
-
85
- ``scored`` mirrors ``videos`` with added keys: ``positive_share`` (0..1),
86
- ``n_comments``, ``note``. Ordering of ``scored`` is by positive_share desc, with the
87
- original search order as the tie-break / fallback.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
88
  """
89
  if not videos:
90
  raise ValueError("No videos to rank.")
 
 
91
 
92
  clf = _classifier()
93
  scored: list[dict] = []
@@ -96,9 +116,9 @@ def rank_by_sentiment(videos: list[dict], cookiefile: str | None = None,
96
  progress((i + 1) / len(videos), desc=f"Sentiment {i + 1}/{len(videos)}")
97
  item = dict(v)
98
  try:
99
- comments = _fetch_comments(v["url"], cookiefile, proxy, po_token, visitor_data)
100
  if comments:
101
- results = clf(comments) # list aligned with comments (each = list of labels)
102
  pols = [_polarity(r) for r in results]
103
  positives = sum(1 for p in pols if p > 0)
104
  item["positive_share"] = positives / len(pols)
@@ -107,14 +127,15 @@ def rank_by_sentiment(videos: list[dict], cookiefile: str | None = None,
107
  else:
108
  item["positive_share"] = 0.0
109
  item["n_comments"] = 0
110
- item["note"] = "no comments fetched"
 
 
111
  except Exception as exc:
112
  item["positive_share"] = 0.0
113
  item["n_comments"] = 0
114
- item["note"] = f"comment fetch failed: {type(exc).__name__}"
115
  item["search_rank"] = i
116
  scored.append(item)
117
 
118
- # Best = highest positive share; ties and all-zero fall back to original search order.
119
  scored.sort(key=lambda d: (-d["positive_share"], d["search_rank"]))
120
  return scored[0], scored
 
1
+ """Stage 2: rank candidate videos by the sentiment of their comments.
2
 
3
+ Comments are fetched via the **YouTube Data API v3** (commentThreads) using an API key,
4
+ then scored with the BERT classifier ``OmarMedhat7/youtube-sentiment-analysis-model``
5
+ running inside the Space. The video with the highest positive share wins. Any video whose
6
+ comments are disabled or fail to fetch simply scores 0 and never aborts the ranking.
7
  """
8
  from __future__ import annotations
9
 
10
+ import json
11
+ import urllib.error
12
+ import urllib.parse
13
+ import urllib.request
14
  from functools import lru_cache
15
 
 
 
16
  SENTIMENT_MODEL = "OmarMedhat7/youtube-sentiment-analysis-model"
17
+ COMMENTS_API = "https://www.googleapis.com/youtube/v3/commentThreads"
18
+ MAX_COMMENTS = 200 # cap per video to bound time/quota (commentThreads = 1 unit/100)
19
 
 
20
  _POSITIVE = {"positive", "pos", "label_2", "label_1"}
21
  _NEGATIVE = {"negative", "neg", "label_0"}
22
 
23
 
24
+ class SentimentError(RuntimeError):
25
+ """Raised for API-key / quota problems that should surface to the user."""
26
+
27
+
28
  @lru_cache(maxsize=1)
29
  def _classifier():
 
30
  from transformers import pipeline
31
 
32
  return pipeline(
 
34
  model=SENTIMENT_MODEL,
35
  truncation=True,
36
  max_length=256,
37
+ top_k=None,
38
  )
39
 
40
 
41
  def _polarity(scores) -> float:
 
 
 
 
 
42
  val = 0.0
43
  for s in scores:
44
  label = str(s["label"]).strip().lower()
 
49
  return val
50
 
51
 
52
+ def _fetch_comments(video_id: str, api_key: str, cap: int = MAX_COMMENTS) -> list[str]:
53
+ """Fetch up to ``cap`` top-relevance comments via the YouTube Data API.
54
+
55
+ Returns [] if comments are disabled. Raises SentimentError on auth/quota failures
56
+ (which apply to every video, so the caller should stop).
57
+ """
58
+ comments: list[str] = []
59
+ page = None
60
+ while len(comments) < cap:
61
+ params = {
62
+ "part": "snippet",
63
+ "videoId": video_id,
64
+ "maxResults": "100",
65
+ "order": "relevance",
66
+ "textFormat": "plainText",
67
+ "key": api_key,
68
+ }
69
+ if page:
70
+ params["pageToken"] = page
71
+ url = COMMENTS_API + "?" + urllib.parse.urlencode(params)
72
+ try:
73
+ with urllib.request.urlopen(url, timeout=30) as resp:
74
+ data = json.load(resp)
75
+ except urllib.error.HTTPError as exc:
76
+ body = exc.read().decode("utf-8", "ignore")
77
+ if exc.code == 403 and "commentsDisabled" in body:
78
+ return []
79
+ if exc.code in (400, 403) and ("keyInvalid" in body or "quota" in body.lower()
80
+ or "forbidden" in body.lower()):
81
+ raise SentimentError(
82
+ f"YouTube Data API rejected the key (HTTP {exc.code}). Check the key "
83
+ f"and that YouTube Data API v3 is enabled / has quota. {body[:160]}")
84
+ # video-specific error (e.g. not found): treat as no comments
85
+ return comments
86
+ except Exception:
87
+ return comments
88
+
89
+ for item in data.get("items", []):
90
+ sn = item.get("snippet", {}).get("topLevelComment", {}).get("snippet", {})
91
+ text = sn.get("textDisplay") or sn.get("textOriginal")
92
+ if text:
93
+ comments.append(text.strip())
94
+ page = data.get("nextPageToken")
95
+ if not page:
96
+ break
97
+ return comments[:cap]
98
+
99
+
100
+ def rank_by_sentiment(videos: list[dict], api_key: str,
101
+ progress=None) -> tuple[dict, list[dict]]:
102
+ """Score each video by comment sentiment; return ``(best_video, scored)``.
103
+
104
+ ``scored`` mirrors ``videos`` with ``positive_share`` (0..1), ``n_comments``, ``note``
105
+ and is sorted by positive_share desc (search order as tie-break / fallback).
106
  """
107
  if not videos:
108
  raise ValueError("No videos to rank.")
109
+ if not api_key:
110
+ raise SentimentError("A YouTube Data API key is required to fetch comments.")
111
 
112
  clf = _classifier()
113
  scored: list[dict] = []
 
116
  progress((i + 1) / len(videos), desc=f"Sentiment {i + 1}/{len(videos)}")
117
  item = dict(v)
118
  try:
119
+ comments = _fetch_comments(v["video_id"], api_key)
120
  if comments:
121
+ results = clf(comments)
122
  pols = [_polarity(r) for r in results]
123
  positives = sum(1 for p in pols if p > 0)
124
  item["positive_share"] = positives / len(pols)
 
127
  else:
128
  item["positive_share"] = 0.0
129
  item["n_comments"] = 0
130
+ item["note"] = "no comments"
131
+ except SentimentError:
132
+ raise # key/quota problem affects all videos
133
  except Exception as exc:
134
  item["positive_share"] = 0.0
135
  item["n_comments"] = 0
136
+ item["note"] = f"fetch failed: {type(exc).__name__}"
137
  item["search_rank"] = i
138
  scored.append(item)
139
 
 
140
  scored.sort(key=lambda d: (-d["positive_share"], d["search_rank"]))
141
  return scored[0], scored
pipeline/transcribe.py CHANGED
@@ -1,41 +1,64 @@
1
- """Stage 4: timestamped transcription with faster-whisper (CPU/int8).
2
 
3
- Returns a list of segments ``{"start", "end", "text"}`` in seconds. The model is cached
4
- at module level so repeated runs in the same Space process don't reload weights.
 
5
  """
6
  from __future__ import annotations
7
 
8
- import os
9
- from functools import lru_cache
10
 
11
- # Small model keeps CPU transcription tractable on the free tier. Override with
12
- # WHISPER_MODEL (e.g. "small", "tiny") via env if needed.
13
- WHISPER_MODEL = os.environ.get("WHISPER_MODEL", "base")
14
 
15
 
16
- @lru_cache(maxsize=1)
17
- def _model():
18
- from faster_whisper import WhisperModel
19
 
20
- return WhisperModel(WHISPER_MODEL, device="cpu", compute_type="int8")
 
 
 
 
21
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
22
 
23
- def transcribe(wav_path: str, progress=None) -> list[dict]:
24
- """Transcribe ``wav_path`` and return timestamped segments."""
25
- model = _model()
26
- segments, info = model.transcribe(wav_path, vad_filter=True)
27
-
28
- total = float(getattr(info, "duration", 0.0)) or None
29
- segs: list[dict] = []
30
- for s in segments: # generator -> streamed, so we can report progress
31
- text = s.text.strip()
32
- if text:
33
- segs.append({"start": float(s.start), "end": float(s.end), "text": text})
34
- if progress and total:
35
- progress(min(s.end / total, 1.0), desc="Transcribing")
36
 
 
 
 
 
 
 
 
 
37
  if not segs:
38
- raise RuntimeError("Whisper produced an empty transcript (no speech detected).")
39
  return segs
40
 
41
 
@@ -46,3 +69,20 @@ def transcript_text(segs: list[dict]) -> str:
46
  m, sec = divmod(int(s["start"]), 60)
47
  lines.append(f"[{m:02d}:{sec:02d}] {s['text']}")
48
  return "\n".join(lines)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Stage 4: fetch a timestamped transcript via youtube-transcript-api (no download).
2
 
3
+ youtube-transcript-api returns caption snippets that already carry ``start`` (and
4
+ ``duration``) timestamps, so we just normalize them into ``{start, end, text}`` segments
5
+ for the rest of the pipeline. No Whisper, no audio, no video.
6
  """
7
  from __future__ import annotations
8
 
 
 
9
 
10
+ class TranscriptError(RuntimeError):
11
+ """Raised when no usable transcript can be fetched (disabled, blocked, none)."""
 
12
 
13
 
14
+ def get_segments(video_id: str, languages=("en", "en-US", "en-GB"),
15
+ proxy: str | None = None) -> list[dict]:
16
+ """Return timestamped segments ``[{start, end, text}]`` for ``video_id``.
17
 
18
+ Works across youtube-transcript-api 1.x (instance ``.fetch``) and older 0.6.x
19
+ (classmethod ``.get_transcript``). ``proxy`` is an optional http(s) proxy URL used as
20
+ a thin fallback when the Space's IP is blocked.
21
+ """
22
+ from youtube_transcript_api import YouTubeTranscriptApi
23
 
24
+ langs = list(languages)
25
+ raw = None
26
+ try:
27
+ # --- 1.x instance API ---
28
+ api = None
29
+ if proxy:
30
+ try:
31
+ from youtube_transcript_api.proxies import GenericProxyConfig
32
+ api = YouTubeTranscriptApi(
33
+ proxy_config=GenericProxyConfig(http_url=proxy, https_url=proxy))
34
+ except Exception:
35
+ api = YouTubeTranscriptApi()
36
+ else:
37
+ api = YouTubeTranscriptApi()
38
 
39
+ if hasattr(api, "fetch"):
40
+ fetched = api.fetch(video_id, languages=langs)
41
+ raw = [{"text": s.text, "start": s.start, "duration": s.duration} for s in fetched]
42
+ else:
43
+ # --- 0.6.x classmethod API ---
44
+ kwargs = {"languages": langs}
45
+ if proxy:
46
+ kwargs["proxies"] = {"http": proxy, "https": proxy}
47
+ data = YouTubeTranscriptApi.get_transcript(video_id, **kwargs)
48
+ raw = [{"text": d["text"], "start": d["start"], "duration": d.get("duration", 0)} for d in data]
49
+ except Exception as exc:
50
+ raise TranscriptError(_hint(exc)) from exc
 
51
 
52
+ segs = []
53
+ for r in raw or []:
54
+ text = (r.get("text") or "").strip()
55
+ if not text:
56
+ continue
57
+ start = float(r.get("start") or 0.0)
58
+ dur = float(r.get("duration") or 0.0)
59
+ segs.append({"start": start, "end": start + dur, "text": text})
60
  if not segs:
61
+ raise TranscriptError("The transcript came back empty for this video.")
62
  return segs
63
 
64
 
 
69
  m, sec = divmod(int(s["start"]), 60)
70
  lines.append(f"[{m:02d}:{sec:02d}] {s['text']}")
71
  return "\n".join(lines)
72
+
73
+
74
+ def _hint(exc: Exception) -> str:
75
+ name = type(exc).__name__
76
+ msg = str(exc)
77
+ low = (name + " " + msg).lower()
78
+ if "disabled" in low or "transcriptsdisabled" in low:
79
+ return "This video has transcripts/captions disabled — pick another video."
80
+ if "notranscript" in low or "no transcript" in low:
81
+ return "No transcript is available for this video in the requested languages."
82
+ if "unavailable" in low or "videounavailable" in low:
83
+ return "The video is unavailable (private/removed/region-locked)."
84
+ if "block" in low or "ip" in low or "too many" in low or "429" in low:
85
+ return ("YouTube blocked the transcript request (common from datacenter IPs). "
86
+ "Set a YT_PROXY Space secret (residential proxy) and retry. "
87
+ f"[{name}]")
88
+ return f"Could not fetch transcript: {name}: {msg[:200]}"
requirements.txt CHANGED
@@ -2,8 +2,9 @@ gradio>=4.44
2
  gradio_client>=1.3
3
  huggingface_hub>=0.27
4
  yt-dlp>=2024.12.13
5
- faster-whisper>=1.1.0
6
  transformers>=4.46
7
  torch>=2.2
 
8
  python-docx>=1.1.2
9
  Pillow>=10.4
 
2
  gradio_client>=1.3
3
  huggingface_hub>=0.27
4
  yt-dlp>=2024.12.13
5
+ youtube-transcript-api>=1.0
6
  transformers>=4.46
7
  torch>=2.2
8
+ numpy>=1.26
9
  python-docx>=1.1.2
10
  Pillow>=10.4