Spaces:
Sleeping
Sleeping
Commit ·
401a193
1
Parent(s): 2f51a0e
Add per-user cookies + proxy for YouTube access (UI fields + operator secrets)
Browse filesA 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>
- README.md +10 -2
- app.py +48 -9
- pipeline/download.py +64 -32
- 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
|
| 50 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
| 65 |
|
| 66 |
-
|
| 67 |
-
|
|
|
|
| 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
|
| 19 |
-
|
| 20 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 21 |
if cookiefile:
|
| 22 |
opts["cookiefile"] = cookiefile
|
| 23 |
-
|
| 24 |
-
|
| 25 |
-
|
| 26 |
-
|
| 27 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 44 |
-
|
| 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 |
-
|
| 53 |
-
|
| 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 |
-
|
| 67 |
-
path =
|
| 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 (
|
| 95 |
-
"
|
|
|
|
| 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]
|