Spaces:
Sleeping
Sleeping
Commit ·
dc57844
1
Parent(s): 401a193
Add PO-token support (manual PO token + visitor data) with README guide
Browse files- download + comment-fetch accept po_token/visitor_data and inject them into yt-dlp
extractor_args; when a PO token is present, web-family player clients are tried so the
token applies
- UI: PO token + visitor data fields in the "YouTube access" panel, with a pointer to the
README's PO-token guide; operator secrets YT_POT / YT_VISITOR_DATA as shared defaults
- README: step-by-step manual PO-token + visitor-data extraction guide, automated
(bgutil) note, and a link to the official yt-dlp PO Token Guide
- Block-error hint now suggests the PO-token route
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- README.md +51 -0
- app.py +27 -6
- pipeline/download.py +50 -18
- pipeline/sentiment.py +13 -4
README.md
CHANGED
|
@@ -57,6 +57,57 @@ on that topic.
|
|
| 57 |
- A hosted Space **cannot** open or read a visitor's browser — auth must be supplied
|
| 58 |
explicitly.
|
| 59 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 60 |
## Local run
|
| 61 |
|
| 62 |
```bash
|
|
|
|
| 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 |
+
### Get them manually (web client, ~2 min)
|
| 72 |
+
|
| 73 |
+
1. Open a fresh **Incognito** window and go to <https://www.youtube.com> (a throwaway
|
| 74 |
+
logged-in account is fine; if you log in, also export that session's cookies).
|
| 75 |
+
2. Open **DevTools → Network** (F12), then **play any video**.
|
| 76 |
+
3. **Visitor data:** filter Network for `v1/player`, open the request to
|
| 77 |
+
`youtubei/v1/player`, and in the **Request payload** copy
|
| 78 |
+
`context.client.visitorData` (a string ending in `%3D%3D`). Paste it into the
|
| 79 |
+
**Visitor data** field.
|
| 80 |
+
4. **PO token (gvs):** filter Network for `googlevideo.com`, open a `videoplayback`
|
| 81 |
+
request, and copy the value of its **`pot`** query parameter. In the **PO token**
|
| 82 |
+
field enter it prefixed with the client + context:
|
| 83 |
+
|
| 84 |
+
```
|
| 85 |
+
web.gvs+<pot-value>
|
| 86 |
+
```
|
| 87 |
+
|
| 88 |
+
You can supply more than one, comma-separated, e.g.
|
| 89 |
+
`web.gvs+<gvs-pot>, web.player+<player-pot>` (the player PO token comes from the
|
| 90 |
+
`v1/player` request's response, field `...poToken`).
|
| 91 |
+
5. Run the Space. When a PO token is present it automatically uses the **web** player
|
| 92 |
+
client so the token applies.
|
| 93 |
+
|
| 94 |
+
**Tips**
|
| 95 |
+
- The PO token is bound to the **visitor data** (and cookies) it was made with — always
|
| 96 |
+
submit them together, from the same session.
|
| 97 |
+
- Prefer using the **same Incognito session** for cookies + visitor data + PO token.
|
| 98 |
+
- Tokens are short-lived; refresh when you next get blocked.
|
| 99 |
+
|
| 100 |
+
### Automated / operator option
|
| 101 |
+
|
| 102 |
+
yt-dlp can auto-generate PO tokens via the
|
| 103 |
+
[`bgutil-ytdlp-pot-provider`](https://github.com/Brainicism/bgutil-ytdlp-pot-provider)
|
| 104 |
+
plugin, but that needs a small provider server running alongside yt-dlp — not bundled in
|
| 105 |
+
this hosted Space. A self-hosting operator can run it and set shared defaults via the
|
| 106 |
+
`YT_POT` / `YT_VISITOR_DATA` Space secrets.
|
| 107 |
+
|
| 108 |
+
See the authoritative, up-to-date method in the
|
| 109 |
+
[yt-dlp PO Token Guide](https://github.com/yt-dlp/yt-dlp/wiki/PO-Token-Guide).
|
| 110 |
+
|
| 111 |
## Local run
|
| 112 |
|
| 113 |
```bash
|
app.py
CHANGED
|
@@ -93,6 +93,13 @@ def _resolve_proxy(raw: str | None = None) -> str | None:
|
|
| 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,7 +136,7 @@ def _collect_keywords(primary_kw, secondary_kw) -> dict:
|
|
| 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] = []
|
|
@@ -150,11 +157,14 @@ def run_pipeline(topic, hf_token, llm_model, vlm_model, w_llm, w_whisper, lead,
|
|
| 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 |
|
|
@@ -166,7 +176,8 @@ def run_pipeline(topic, hf_token, llm_model, vlm_model, w_llm, w_whisper, lead,
|
|
| 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(
|
|
|
|
| 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,7 +187,7 @@ def run_pipeline(topic, hf_token, llm_model, vlm_model, w_llm, w_whisper, lead,
|
|
| 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 --------------------------------------------------------------
|
|
@@ -271,15 +282,25 @@ def build_ui():
|
|
| 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`
|
| 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")
|
|
@@ -320,7 +341,7 @@ def build_ui():
|
|
| 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
|
|
|
|
| 93 |
return proxy or None
|
| 94 |
|
| 95 |
|
| 96 |
+
def _resolve_pot(po_token: str | None, visitor_data: str | None) -> tuple[str | None, str | None]:
|
| 97 |
+
"""Per-user PO token + visitor data, falling back to YT_POT / YT_VISITOR_DATA secrets."""
|
| 98 |
+
pot = (po_token or "").strip() or os.environ.get("YT_POT", "").strip()
|
| 99 |
+
vis = (visitor_data or "").strip() or os.environ.get("YT_VISITOR_DATA", "").strip()
|
| 100 |
+
return (pot or None), (vis or None)
|
| 101 |
+
|
| 102 |
+
|
| 103 |
def _ranking_rows(scored: list[dict]) -> list[list]:
|
| 104 |
rows = []
|
| 105 |
for rank, v in enumerate(scored, start=1):
|
|
|
|
| 136 |
|
| 137 |
def run_pipeline(topic, hf_token, llm_model, vlm_model, w_llm, w_whisper, lead,
|
| 138 |
max_minutes, max_shots, primary_kw, secondary_kw,
|
| 139 |
+
cookies_text, proxy_url, po_token_in, visitor_data_in,
|
| 140 |
progress=gr.Progress()):
|
| 141 |
"""Generator that yields (status_md, ranking_df, transcript, docx_file)."""
|
| 142 |
log: list[str] = []
|
|
|
|
| 157 |
try:
|
| 158 |
cookiefile = _cookiefile(workdir, cookies_text)
|
| 159 |
proxy = _resolve_proxy(proxy_url)
|
| 160 |
+
po_token, visitor_data = _resolve_pot(po_token_in, visitor_data_in)
|
| 161 |
auth_bits = []
|
| 162 |
if cookiefile:
|
| 163 |
auth_bits.append("cookies")
|
| 164 |
if proxy:
|
| 165 |
auth_bits.append("proxy")
|
| 166 |
+
if po_token:
|
| 167 |
+
auth_bits.append("PO token")
|
| 168 |
if auth_bits:
|
| 169 |
yield status("🔐 Using " + " + ".join(auth_bits) + " for YouTube access."), gr.update(), gr.update(), gr.update()
|
| 170 |
|
|
|
|
| 176 |
|
| 177 |
# 2. Sentiment ranking -------------------------------------------------------
|
| 178 |
yield status("💬 Fetching comments and scoring sentiment…"), gr.update(), gr.update(), gr.update()
|
| 179 |
+
best, scored = sentiment_mod.rank_by_sentiment(
|
| 180 |
+
videos, cookiefile, progress, proxy, po_token, visitor_data)
|
| 181 |
ranking = gr.update(value=_ranking_rows(scored))
|
| 182 |
yield (status(f"🏆 Picked **{best.get('title', best['video_id'])}** "
|
| 183 |
f"({best['positive_share'] * 100:.0f}% positive)."),
|
|
|
|
| 187 |
progress(0.25, desc="Downloading")
|
| 188 |
yield status("⬇️ Downloading the chosen video…"), ranking, gr.update(), gr.update()
|
| 189 |
video_path, duration = download_mod.download_video(
|
| 190 |
+
best["url"], workdir, cookiefile, int(max_minutes), proxy, po_token, visitor_data)
|
| 191 |
wav = download_mod.extract_audio(video_path, workdir)
|
| 192 |
|
| 193 |
# 4. Transcribe --------------------------------------------------------------
|
|
|
|
| 282 |
"can get an account rate-limited or flagged.\n"
|
| 283 |
"- **Cookies:** export a `youtube.com` cookies.txt (Netscape format) from a "
|
| 284 |
"logged-in throwaway account and paste it (raw or base64) below.\n"
|
| 285 |
+
"- **PO token (free, no proxy):** a Proof-of-Origin token + visitor data "
|
| 286 |
+
"can pass the bot-check from a datacenter IP. **See this Space's README → "
|
| 287 |
+
"\"PO token\" guide** for how to get and paste them.\n"
|
| 288 |
"- **Proxy:** a **residential** proxy works; **free *datacenter* proxies "
|
| 289 |
"(e.g. Webshare's free tier) usually do NOT** get past YouTube's block and "
|
| 290 |
"have tight bandwidth caps.\n"
|
| 291 |
+
"- An operator can instead set Space secrets `YT_COOKIES` / `YT_PROXY` / "
|
| 292 |
+
"`YT_POT` / `YT_VISITOR_DATA` as shared defaults."
|
| 293 |
)
|
| 294 |
cookies_text = gr.Textbox(
|
| 295 |
label="YouTube cookies (cookies.txt contents or base64)", lines=4,
|
| 296 |
placeholder="# Netscape HTTP Cookie File … (or a base64 blob)")
|
| 297 |
+
with gr.Row():
|
| 298 |
+
po_token_in = gr.Textbox(
|
| 299 |
+
label="PO token (see README) — CLIENT.CONTEXT+TOKEN", lines=2, scale=3,
|
| 300 |
+
placeholder="web.gvs+AbC…, web.player+XyZ…")
|
| 301 |
+
visitor_data_in = gr.Textbox(
|
| 302 |
+
label="Visitor data (pairs with the PO token)", scale=2,
|
| 303 |
+
placeholder="Cgt...%3D%3D")
|
| 304 |
proxy_url = gr.Textbox(
|
| 305 |
label="Proxy URL (optional)", type="password",
|
| 306 |
placeholder="http://user:pass@host:port")
|
|
|
|
| 341 |
run_pipeline,
|
| 342 |
inputs=[topic, hf_token, llm_model, vlm_model, w_llm, w_whisper, lead,
|
| 343 |
max_minutes, max_shots, primary_kw, secondary_kw,
|
| 344 |
+
cookies_text, proxy_url, po_token_in, visitor_data_in],
|
| 345 |
outputs=[status_md, ranking_df, transcript_box, docx_file],
|
| 346 |
)
|
| 347 |
return demo
|
pipeline/download.py
CHANGED
|
@@ -10,24 +10,44 @@ getting past datacenter-IP blocking without cookies.
|
|
| 10 |
from __future__ import annotations
|
| 11 |
|
| 12 |
import os
|
|
|
|
| 13 |
import subprocess
|
| 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
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 26 |
opts = {
|
| 27 |
"quiet": True,
|
| 28 |
"no_warnings": True,
|
| 29 |
"noplaylist": True,
|
| 30 |
-
"extractor_args": {"youtube":
|
| 31 |
}
|
| 32 |
if cookiefile:
|
| 33 |
opts["cookiefile"] = cookiefile
|
|
@@ -37,15 +57,17 @@ def _base_opts(cookiefile: str | None, proxy: str | None, player_client: str) ->
|
|
| 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
|
| 48 |
-
opts = {**_base_opts(cookiefile, proxy, client), **base}
|
| 49 |
try:
|
| 50 |
ydl = YoutubeDL(opts)
|
| 51 |
info = ydl.extract_info(url, download=download)
|
|
@@ -53,23 +75,27 @@ def _extract_with_fallback(url: str, base: dict, cookiefile: str | None,
|
|
| 53 |
except Exception as exc: # try the next client
|
| 54 |
last_exc = exc
|
| 55 |
continue
|
| 56 |
-
raise DownloadError(
|
|
|
|
| 57 |
|
| 58 |
|
| 59 |
-
def probe_duration(url: str, cookiefile: str | None = None,
|
| 60 |
-
|
| 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
|
|
|
|
|
|
|
| 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 "
|
|
@@ -81,7 +107,8 @@ def download_video(url: str, out_dir: str, cookiefile: str | None = None,
|
|
| 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 |
|
|
@@ -110,21 +137,26 @@ def extract_audio(video_path: str, out_dir: str) -> str:
|
|
| 110 |
return wav_path
|
| 111 |
|
| 112 |
|
| 113 |
-
def _blocked_hint(exc: Exception | None, had_cookies: bool, had_proxy: bool
|
|
|
|
| 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 |
-
|
| 123 |
-
|
|
|
|
|
|
|
| 124 |
return (
|
| 125 |
"YouTube blocked this request (typical from datacenter IPs)." + ctx + ". "
|
| 126 |
-
"
|
| 127 |
-
"
|
|
|
|
| 128 |
f"Original error: {msg[:300]}"
|
| 129 |
)
|
| 130 |
return f"Download failed: {msg[:400]}"
|
|
|
|
| 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
|
|
|
|
| 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)
|
|
|
|
| 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 "
|
|
|
|
| 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 |
|
|
|
|
| 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/sentiment.py
CHANGED
|
@@ -8,6 +8,7 @@ single video that fails simply scores 0 and we never abort the whole ranking.
|
|
| 8 |
from __future__ import annotations
|
| 9 |
|
| 10 |
import os
|
|
|
|
| 11 |
from functools import lru_cache
|
| 12 |
|
| 13 |
from yt_dlp import YoutubeDL
|
|
@@ -50,13 +51,19 @@ def _polarity(scores) -> float:
|
|
| 50 |
return val
|
| 51 |
|
| 52 |
|
| 53 |
-
def _fetch_comments(url: str, cookiefile: str | None, proxy: str | None
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 54 |
opts = {
|
| 55 |
"skip_download": True,
|
| 56 |
"getcomments": True,
|
| 57 |
"quiet": True,
|
| 58 |
"no_warnings": True,
|
| 59 |
-
"extractor_args": {"youtube":
|
| 60 |
}
|
| 61 |
if cookiefile:
|
| 62 |
opts["cookiefile"] = cookiefile
|
|
@@ -70,7 +77,9 @@ def _fetch_comments(url: str, cookiefile: str | None, proxy: str | None) -> list
|
|
| 70 |
|
| 71 |
|
| 72 |
def rank_by_sentiment(videos: list[dict], cookiefile: str | None = None,
|
| 73 |
-
progress=None, proxy: str | None = None
|
|
|
|
|
|
|
| 74 |
"""Score each video and return ``(best_video, scored)``.
|
| 75 |
|
| 76 |
``scored`` mirrors ``videos`` with added keys: ``positive_share`` (0..1),
|
|
@@ -87,7 +96,7 @@ def rank_by_sentiment(videos: list[dict], cookiefile: str | None = None,
|
|
| 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]
|
|
|
|
| 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
|
|
|
|
| 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
|
|
|
|
| 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),
|
|
|
|
| 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]
|