vivekchakraverty Claude Opus 4.8 commited on
Commit
401a193
·
1 Parent(s): 2f51a0e

Add per-user cookies + proxy for YouTube access (UI fields + operator secrets)

Browse files

A hosted Space can't read a visitor's browser cookies, so make access explicit and
self-serve:
- UI: "YouTube access" panel with a cookies box (raw cookies.txt or base64) and a proxy
URL box, used only for that run; throwaway-account warning included
- Fall back to operator-wide YT_COOKIES / YT_PROXY secrets when the UI fields are empty
- Thread proxy through download + comment-fetching; add player-client fallbacks
(default/android/tv/web_safari) for best-effort access without cookies
- Honest caveat in UI + README: free datacenter proxies (e.g. Webshare free tier) usually
do NOT bypass YouTube's block

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

Files changed (4) hide show
  1. README.md +10 -2
  2. app.py +48 -9
  3. pipeline/download.py +64 -32
  4. pipeline/sentiment.py +5 -3
README.md CHANGED
@@ -46,8 +46,16 @@ on that topic.
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 may block the Space's IP.** If downloads/comments fail, add a Netscape-format
50
- cookie file as a Space secret named `YT_COOKIES` (the file's contents).
 
 
 
 
 
 
 
 
51
 
52
  ## Local run
53
 
 
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
  ## Local run
61
 
app.py CHANGED
@@ -60,13 +60,14 @@ def _maybe_b64_decode(text: str) -> str | None:
60
  return decoded if _looks_like_netscape(decoded) else None
61
 
62
 
63
- def _cookiefile(workdir: str) -> str | None:
64
- """Materialize the YT_COOKIES secret to a Netscape cookie file on disk.
65
 
66
- Accepts either the raw cookies.txt contents (tabs preserved) or a base64 encoding of
67
- them. A missing header line is added so yt-dlp accepts the file.
 
68
  """
69
- data = os.environ.get("YT_COOKIES")
70
  if not data or not data.strip():
71
  return None
72
 
@@ -86,6 +87,12 @@ def _cookiefile(workdir: str) -> str | None:
86
  return path
87
 
88
 
 
 
 
 
 
 
89
  def _ranking_rows(scored: list[dict]) -> list[list]:
90
  rows = []
91
  for rank, v in enumerate(scored, start=1):
@@ -122,6 +129,7 @@ def _collect_keywords(primary_kw, secondary_kw) -> dict:
122
 
123
  def run_pipeline(topic, hf_token, llm_model, vlm_model, w_llm, w_whisper, lead,
124
  max_minutes, max_shots, primary_kw, secondary_kw,
 
125
  progress=gr.Progress()):
126
  """Generator that yields (status_md, ranking_df, transcript, docx_file)."""
127
  log: list[str] = []
@@ -140,7 +148,15 @@ def run_pipeline(topic, hf_token, llm_model, vlm_model, w_llm, w_whisper, lead,
140
  frames_dir = os.path.join(workdir, "frames")
141
  video_path = None
142
  try:
143
- cookiefile = _cookiefile(workdir)
 
 
 
 
 
 
 
 
144
 
145
  # 1. Search ------------------------------------------------------------------
146
  progress(0.02, desc="Searching")
@@ -150,7 +166,7 @@ def run_pipeline(topic, hf_token, llm_model, vlm_model, w_llm, w_whisper, lead,
150
 
151
  # 2. Sentiment ranking -------------------------------------------------------
152
  yield status("💬 Fetching comments and scoring sentiment…"), gr.update(), gr.update(), gr.update()
153
- best, scored = sentiment_mod.rank_by_sentiment(videos, cookiefile, progress)
154
  ranking = gr.update(value=_ranking_rows(scored))
155
  yield (status(f"🏆 Picked **{best.get('title', best['video_id'])}** "
156
  f"({best['positive_share'] * 100:.0f}% positive)."),
@@ -160,7 +176,7 @@ def run_pipeline(topic, hf_token, llm_model, vlm_model, w_llm, w_whisper, lead,
160
  progress(0.25, desc="Downloading")
161
  yield status("⬇️ Downloading the chosen video…"), ranking, gr.update(), gr.update()
162
  video_path, duration = download_mod.download_video(
163
- best["url"], workdir, cookiefile, int(max_minutes))
164
  wav = download_mod.extract_audio(video_path, workdir)
165
 
166
  # 4. Transcribe --------------------------------------------------------------
@@ -246,6 +262,28 @@ def build_ui():
246
  vlm_model = gr.Dropdown(VLM_CHOICES, value=VLM_CHOICES[0],
247
  label="Vision model (captions)", allow_custom_value=True)
248
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
249
  with gr.Accordion("SEO / AEO keywords (optional)", open=False):
250
  gr.Markdown(
251
  "The **primary keyword** is used naturally ~3× in the body and placed in "
@@ -281,7 +319,8 @@ def build_ui():
281
  run_btn.click(
282
  run_pipeline,
283
  inputs=[topic, hf_token, llm_model, vlm_model, w_llm, w_whisper, lead,
284
- max_minutes, max_shots, primary_kw, secondary_kw],
 
285
  outputs=[status_md, ranking_df, transcript_box, docx_file],
286
  )
287
  return demo
 
60
  return decoded if _looks_like_netscape(decoded) else None
61
 
62
 
63
+ def _cookiefile(workdir: str, raw: str | None = None) -> str | None:
64
+ """Materialize cookies to a Netscape cookie file on disk; return its path or None.
65
 
66
+ ``raw`` is the per-user UI value; if empty we fall back to the operator-wide
67
+ ``YT_COOKIES`` secret. Accepts either raw cookies.txt contents (tabs preserved) or a
68
+ base64 encoding of them. A missing header line is added so yt-dlp accepts the file.
69
  """
70
+ data = (raw or "").strip() or os.environ.get("YT_COOKIES")
71
  if not data or not data.strip():
72
  return None
73
 
 
87
  return path
88
 
89
 
90
+ def _resolve_proxy(raw: str | None = None) -> str | None:
91
+ """Per-user proxy URL, falling back to the operator-wide YT_PROXY secret."""
92
+ proxy = (raw or "").strip() or os.environ.get("YT_PROXY", "").strip()
93
+ return proxy or None
94
+
95
+
96
  def _ranking_rows(scored: list[dict]) -> list[list]:
97
  rows = []
98
  for rank, v in enumerate(scored, start=1):
 
129
 
130
  def run_pipeline(topic, hf_token, llm_model, vlm_model, w_llm, w_whisper, lead,
131
  max_minutes, max_shots, primary_kw, secondary_kw,
132
+ cookies_text, proxy_url,
133
  progress=gr.Progress()):
134
  """Generator that yields (status_md, ranking_df, transcript, docx_file)."""
135
  log: list[str] = []
 
148
  frames_dir = os.path.join(workdir, "frames")
149
  video_path = None
150
  try:
151
+ cookiefile = _cookiefile(workdir, cookies_text)
152
+ proxy = _resolve_proxy(proxy_url)
153
+ auth_bits = []
154
+ if cookiefile:
155
+ auth_bits.append("cookies")
156
+ if proxy:
157
+ auth_bits.append("proxy")
158
+ if auth_bits:
159
+ yield status("🔐 Using " + " + ".join(auth_bits) + " for YouTube access."), gr.update(), gr.update(), gr.update()
160
 
161
  # 1. Search ------------------------------------------------------------------
162
  progress(0.02, desc="Searching")
 
166
 
167
  # 2. Sentiment ranking -------------------------------------------------------
168
  yield status("💬 Fetching comments and scoring sentiment…"), gr.update(), gr.update(), gr.update()
169
+ best, scored = sentiment_mod.rank_by_sentiment(videos, cookiefile, progress, proxy)
170
  ranking = gr.update(value=_ranking_rows(scored))
171
  yield (status(f"🏆 Picked **{best.get('title', best['video_id'])}** "
172
  f"({best['positive_share'] * 100:.0f}% positive)."),
 
176
  progress(0.25, desc="Downloading")
177
  yield status("⬇️ Downloading the chosen video…"), ranking, gr.update(), gr.update()
178
  video_path, duration = download_mod.download_video(
179
+ best["url"], workdir, cookiefile, int(max_minutes), proxy)
180
  wav = download_mod.extract_audio(video_path, workdir)
181
 
182
  # 4. Transcribe --------------------------------------------------------------
 
262
  vlm_model = gr.Dropdown(VLM_CHOICES, value=VLM_CHOICES[0],
263
  label="Vision model (captions)", allow_custom_value=True)
264
 
265
+ with gr.Accordion("YouTube access — cookies / proxy (often required)", open=False):
266
+ gr.Markdown(
267
+ "⚠️ **YouTube usually blocks the Space's datacenter IP.** To download, give "
268
+ "the Space **your own** access below — it is used only for your run and "
269
+ "deleted afterward.\n\n"
270
+ "- **Use a throwaway Google account, not your main one.** yt-dlp activity "
271
+ "can get an account rate-limited or flagged.\n"
272
+ "- **Cookies:** export a `youtube.com` cookies.txt (Netscape format) from a "
273
+ "logged-in throwaway account and paste it (raw or base64) below.\n"
274
+ "- **Proxy:** a **residential** proxy works; **free *datacenter* proxies "
275
+ "(e.g. Webshare's free tier) usually do NOT** get past YouTube's block and "
276
+ "have tight bandwidth caps.\n"
277
+ "- An operator can instead set Space secrets `YT_COOKIES` / `YT_PROXY` as "
278
+ "shared defaults."
279
+ )
280
+ cookies_text = gr.Textbox(
281
+ label="YouTube cookies (cookies.txt contents or base64)", lines=4,
282
+ placeholder="# Netscape HTTP Cookie File … (or a base64 blob)")
283
+ proxy_url = gr.Textbox(
284
+ label="Proxy URL (optional)", type="password",
285
+ placeholder="http://user:pass@host:port")
286
+
287
  with gr.Accordion("SEO / AEO keywords (optional)", open=False):
288
  gr.Markdown(
289
  "The **primary keyword** is used naturally ~3× in the body and placed in "
 
319
  run_btn.click(
320
  run_pipeline,
321
  inputs=[topic, hf_token, llm_model, vlm_model, w_llm, w_whisper, lead,
322
+ max_minutes, max_shots, primary_kw, secondary_kw,
323
+ cookies_text, proxy_url],
324
  outputs=[status_md, ranking_df, transcript_box, docx_file],
325
  )
326
  return demo
pipeline/download.py CHANGED
@@ -2,6 +2,10 @@
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
  from __future__ import annotations
7
 
@@ -10,61 +14,81 @@ import subprocess
10
 
11
  from yt_dlp import YoutubeDL
12
 
 
 
 
13
 
14
  class DownloadError(RuntimeError):
15
  """Raised for user-actionable download failures (blocked IP, too long, etc.)."""
16
 
17
 
18
- def probe_duration(url: str, cookiefile: str | None = None) -> float:
19
- """Return the video duration in seconds without downloading."""
20
- opts = {"skip_download": True, "quiet": True, "no_warnings": True}
 
 
 
 
21
  if cookiefile:
22
  opts["cookiefile"] = cookiefile
23
- try:
24
- with YoutubeDL(opts) as ydl:
25
- info = ydl.extract_info(url, download=False)
26
- except Exception as exc:
27
- raise DownloadError(_blocked_hint(exc)) from exc
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
28
  return float(info.get("duration") or 0.0)
29
 
30
 
31
  def download_video(url: str, out_dir: str, cookiefile: str | None = None,
32
- max_minutes: int = 20) -> tuple[str, float]:
33
  """Download the video to ``out_dir``; return ``(mp4_path, duration_seconds)``."""
34
  os.makedirs(out_dir, exist_ok=True)
35
 
36
- duration = probe_duration(url, cookiefile)
37
  if duration and duration > max_minutes * 60:
38
  raise DownloadError(
39
  f"Video is {duration / 60:.0f} min long, over the {max_minutes} min cap for "
40
  "this free-CPU Space. Pick a shorter video or raise the cap."
41
  )
42
 
43
- out_tmpl = os.path.join(out_dir, "video.%(ext)s")
44
- opts = {
45
- "outtmpl": out_tmpl,
46
  "format": "bestvideo[height<=720][ext=mp4]+bestaudio[ext=m4a]/best[height<=720]/best",
47
  "merge_output_format": "mp4",
48
- "quiet": True,
49
- "no_warnings": True,
50
- "noplaylist": True,
51
  }
52
- if cookiefile:
53
- opts["cookiefile"] = cookiefile
54
-
55
- try:
56
- with YoutubeDL(opts) as ydl:
57
- info = ydl.extract_info(url, download=True)
58
- path = ydl.prepare_filename(info)
59
- except Exception as exc:
60
- raise DownloadError(_blocked_hint(exc)) from exc
61
-
62
  duration = float(info.get("duration") or duration or 0.0)
63
 
64
  # merge_output_format may have rewritten the extension to .mp4
65
  if not os.path.exists(path):
66
- base, _ = os.path.splitext(path)
67
- path = base + ".mp4"
68
  if not os.path.exists(path):
69
  raise DownloadError("yt-dlp reported success but no output file was found.")
70
  return path, duration
@@ -86,13 +110,21 @@ def extract_audio(video_path: str, out_dir: str) -> str:
86
  return wav_path
87
 
88
 
89
- def _blocked_hint(exc: Exception) -> str:
90
- msg = str(exc)
91
  lowered = msg.lower()
92
  if any(k in lowered for k in ("sign in", "bot", "403", "429", "confirm you", "cookies")):
 
 
 
 
 
 
 
93
  return (
94
- "YouTube blocked this request (common from datacenter IPs like HF Spaces). "
95
- "Add a Netscape cookie file as the Space secret YT_COOKIES and retry.\n"
 
96
  f"Original error: {msg[:300]}"
97
  )
98
  return f"Download failed: {msg[:400]}"
 
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
 
 
14
 
15
  from yt_dlp import YoutubeDL
16
 
17
+ # Player clients tried in order. "android"/"tv" sometimes succeed where "web" is blocked.
18
+ _PLAYER_CLIENTS = ["default", "android", "tv", "web_safari"]
19
+
20
 
21
  class DownloadError(RuntimeError):
22
  """Raised for user-actionable download failures (blocked IP, too long, etc.)."""
23
 
24
 
25
+ def _base_opts(cookiefile: str | None, proxy: str | None, player_client: str) -> dict:
26
+ opts = {
27
+ "quiet": True,
28
+ "no_warnings": True,
29
+ "noplaylist": True,
30
+ "extractor_args": {"youtube": {"player_client": [player_client]}},
31
+ }
32
  if cookiefile:
33
  opts["cookiefile"] = cookiefile
34
+ if proxy:
35
+ opts["proxy"] = proxy
36
+ return opts
37
+
38
+
39
+ def _extract_with_fallback(url: str, base: dict, cookiefile: str | None,
40
+ proxy: str | None, download: bool):
41
+ """Run extract_info, retrying across player clients on failure.
42
+
43
+ Returns ``(info, ydl)`` from the first client that works; raises DownloadError with a
44
+ helpful hint if all clients fail.
45
+ """
46
+ last_exc = None
47
+ for client in _PLAYER_CLIENTS:
48
+ opts = {**_base_opts(cookiefile, proxy, client), **base}
49
+ try:
50
+ ydl = YoutubeDL(opts)
51
+ info = ydl.extract_info(url, download=download)
52
+ return info, ydl
53
+ except Exception as exc: # try the next client
54
+ last_exc = exc
55
+ continue
56
+ raise DownloadError(_blocked_hint(last_exc, bool(cookiefile), bool(proxy)))
57
+
58
+
59
+ def probe_duration(url: str, cookiefile: str | None = None,
60
+ proxy: str | None = None) -> float:
61
+ """Return the video duration in seconds without downloading."""
62
+ info, _ = _extract_with_fallback(url, {"skip_download": True}, cookiefile, proxy,
63
+ download=False)
64
  return float(info.get("duration") or 0.0)
65
 
66
 
67
  def download_video(url: str, out_dir: str, cookiefile: str | None = None,
68
+ max_minutes: int = 20, proxy: str | None = None) -> tuple[str, float]:
69
  """Download the video to ``out_dir``; return ``(mp4_path, duration_seconds)``."""
70
  os.makedirs(out_dir, exist_ok=True)
71
 
72
+ duration = probe_duration(url, cookiefile, proxy)
73
  if duration and duration > max_minutes * 60:
74
  raise DownloadError(
75
  f"Video is {duration / 60:.0f} min long, over the {max_minutes} min cap for "
76
  "this free-CPU Space. Pick a shorter video or raise the cap."
77
  )
78
 
79
+ base = {
80
+ "outtmpl": os.path.join(out_dir, "video.%(ext)s"),
 
81
  "format": "bestvideo[height<=720][ext=mp4]+bestaudio[ext=m4a]/best[height<=720]/best",
82
  "merge_output_format": "mp4",
 
 
 
83
  }
84
+ info, ydl = _extract_with_fallback(url, base, cookiefile, proxy, download=True)
85
+ path = ydl.prepare_filename(info)
 
 
 
 
 
 
 
 
86
  duration = float(info.get("duration") or duration or 0.0)
87
 
88
  # merge_output_format may have rewritten the extension to .mp4
89
  if not os.path.exists(path):
90
+ base_path, _ = os.path.splitext(path)
91
+ path = base_path + ".mp4"
92
  if not os.path.exists(path):
93
  raise DownloadError("yt-dlp reported success but no output file was found.")
94
  return path, duration
 
110
  return wav_path
111
 
112
 
113
+ def _blocked_hint(exc: Exception | None, had_cookies: bool, had_proxy: bool) -> str:
114
+ msg = str(exc) if exc else "unknown error"
115
  lowered = msg.lower()
116
  if any(k in lowered for k in ("sign in", "bot", "403", "429", "confirm you", "cookies")):
117
+ used = []
118
+ if had_cookies:
119
+ used.append("cookies")
120
+ if had_proxy:
121
+ used.append("proxy")
122
+ ctx = (f" Your {' and '.join(used)} did not get past it"
123
+ if used else " No cookies or proxy were provided")
124
  return (
125
+ "YouTube blocked this request (typical from datacenter IPs)." + ctx + ". "
126
+ "Provide throwaway-account cookies and/or a residential proxy and retry. "
127
+ "Note: free datacenter proxies usually do NOT bypass this block.\n"
128
  f"Original error: {msg[:300]}"
129
  )
130
  return f"Download failed: {msg[:400]}"
pipeline/sentiment.py CHANGED
@@ -50,7 +50,7 @@ def _polarity(scores) -> float:
50
  return val
51
 
52
 
53
- def _fetch_comments(url: str, cookiefile: str | None) -> list[str]:
54
  opts = {
55
  "skip_download": True,
56
  "getcomments": True,
@@ -60,6 +60,8 @@ def _fetch_comments(url: str, cookiefile: str | None) -> list[str]:
60
  }
61
  if cookiefile:
62
  opts["cookiefile"] = cookiefile
 
 
63
  with YoutubeDL(opts) as ydl:
64
  info = ydl.extract_info(url, download=False)
65
  comments = info.get("comments") or []
@@ -68,7 +70,7 @@ def _fetch_comments(url: str, cookiefile: str | None) -> list[str]:
68
 
69
 
70
  def rank_by_sentiment(videos: list[dict], cookiefile: str | None = None,
71
- progress=None) -> tuple[dict, list[dict]]:
72
  """Score each video and return ``(best_video, scored)``.
73
 
74
  ``scored`` mirrors ``videos`` with added keys: ``positive_share`` (0..1),
@@ -85,7 +87,7 @@ def rank_by_sentiment(videos: list[dict], cookiefile: str | None = None,
85
  progress((i + 1) / len(videos), desc=f"Sentiment {i + 1}/{len(videos)}")
86
  item = dict(v)
87
  try:
88
- comments = _fetch_comments(v["url"], cookiefile)
89
  if comments:
90
  results = clf(comments) # list aligned with comments (each = list of labels)
91
  pols = [_polarity(r) for r in results]
 
50
  return val
51
 
52
 
53
+ def _fetch_comments(url: str, cookiefile: str | None, proxy: str | None) -> list[str]:
54
  opts = {
55
  "skip_download": True,
56
  "getcomments": True,
 
60
  }
61
  if cookiefile:
62
  opts["cookiefile"] = cookiefile
63
+ if proxy:
64
+ opts["proxy"] = proxy
65
  with YoutubeDL(opts) as ydl:
66
  info = ydl.extract_info(url, download=False)
67
  comments = info.get("comments") or []
 
70
 
71
 
72
  def rank_by_sentiment(videos: list[dict], cookiefile: str | None = None,
73
+ progress=None, proxy: str | None = None) -> tuple[dict, list[dict]]:
74
  """Score each video and return ``(best_video, scored)``.
75
 
76
  ``scored`` mirrors ``videos`` with added keys: ``positive_share`` (0..1),
 
87
  progress((i + 1) / len(videos), desc=f"Sentiment {i + 1}/{len(videos)}")
88
  item = dict(v)
89
  try:
90
+ comments = _fetch_comments(v["url"], cookiefile, proxy)
91
  if comments:
92
  results = clf(comments) # list aligned with comments (each = list of labels)
93
  pols = [_polarity(r) for r in results]