vivekchakraverty Claude Opus 4.8 commited on
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>

Files changed (4) hide show
  1. README.md +51 -0
  2. app.py +27 -6
  3. pipeline/download.py +50 -18
  4. 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(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,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` 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")
@@ -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 _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
@@ -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 _PLAYER_CLIENTS:
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(_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 "
@@ -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) -> 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]}"
 
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) -> list[str]:
 
 
 
 
 
 
54
  opts = {
55
  "skip_download": True,
56
  "getcomments": True,
57
  "quiet": True,
58
  "no_warnings": True,
59
- "extractor_args": {"youtube": {"max_comments": [str(MAX_COMMENTS)], "comment_sort": ["top"]}},
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) -> 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,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]