Spaces:
Sleeping
Sleeping
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 +2 -0
- README.md +33 -95
- app.py +93 -182
- extension/README.md +0 -78
- extension/background.js +0 -34
- extension/content_bridge.js +0 -25
- extension/content_hook.js +0 -62
- extension/manifest.json +0 -32
- extension/popup.html +0 -45
- extension/popup.js +0 -179
- pipeline/__init__.py +4 -3
- pipeline/download.py +0 -162
- pipeline/frames.py +122 -85
- pipeline/sentiment.py +78 -57
- pipeline/transcribe.py +66 -26
- requirements.txt +2 -1
.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**
|
| 18 |
-
**`.docx` tutorial** (text +
|
| 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
|
| 25 |
-
BERT classifier
|
| 26 |
-
|
| 27 |
-
|
| 28 |
-
|
| 29 |
-
|
| 30 |
-
|
| 31 |
-
|
| 32 |
-
|
| 33 |
-
|
| 34 |
-
|
| 35 |
-
|
| 36 |
-
|
| 37 |
-
|
| 38 |
-
|
| 39 |
token) captions each screenshot.
|
| 40 |
-
|
| 41 |
|
| 42 |
-
##
|
| 43 |
|
| 44 |
-
- **
|
| 45 |
-
|
| 46 |
<https://huggingface.co/settings/tokens>.
|
| 47 |
-
- **
|
| 48 |
-
(
|
| 49 |
-
|
| 50 |
-
|
| 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 |
-
##
|
| 61 |
|
| 62 |
-
|
| 63 |
-
|
| 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 |
-
|
| 69 |
-
|
| 70 |
-
|
| 71 |
-
|
| 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 |
-
|
| 4 |
-
|
| 5 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
| 84 |
-
"""Materialize
|
| 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(
|
| 111 |
-
|
| 112 |
-
proxy = (raw or "").strip() or os.environ.get("YT_PROXY", "").strip()
|
| 113 |
return proxy or None
|
| 114 |
|
| 115 |
|
| 116 |
-
def
|
| 117 |
-
|
| 118 |
-
|
| 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
|
| 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,
|
| 158 |
-
|
| 159 |
-
cookies_text, proxy_url, po_token_in, visitor_data_in,
|
| 160 |
progress=gr.Progress()):
|
| 161 |
-
"""Generator
|
| 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 |
-
|
| 179 |
-
|
| 180 |
-
|
| 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.
|
| 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 |
-
|
| 199 |
-
|
| 200 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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.
|
| 207 |
-
progress(0.
|
| 208 |
-
yield status("
|
| 209 |
-
|
| 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 |
-
#
|
| 222 |
-
progress(0.
|
| 223 |
-
|
| 224 |
-
|
| 225 |
-
|
| 226 |
-
|
| 227 |
-
|
| 228 |
-
|
|
|
|
|
|
|
| 229 |
|
| 230 |
-
#
|
| 231 |
-
progress(0.
|
| 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 |
-
#
|
| 244 |
-
selected =
|
| 245 |
-
|
| 246 |
-
|
| 247 |
-
|
| 248 |
-
|
| 249 |
-
|
| 250 |
-
|
| 251 |
-
|
| 252 |
-
|
| 253 |
-
|
| 254 |
-
|
| 255 |
-
|
| 256 |
-
|
| 257 |
-
#
|
| 258 |
-
progress(0.
|
| 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 |
-
|
|
|
|
| 264 |
ranking, gr.update(value=transcript), gr.update(value=out_path))
|
| 265 |
|
| 266 |
except gr.Error:
|
| 267 |
raise
|
| 268 |
-
except (
|
|
|
|
| 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
|
| 282 |
-
"
|
| 283 |
-
"
|
|
|
|
| 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 |
-
"
|
| 340 |
-
"
|
| 341 |
-
"
|
| 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.
|
| 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:
|
| 352 |
lead = gr.Slider(0.0, 5.0, value=1.0, step=0.5, label="Lead offset (s)")
|
| 353 |
-
|
| 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,
|
| 370 |
-
|
| 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(
|
|
|
|
| 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
|
| 5 |
-
-> tutorial -> frames
|
|
|
|
| 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:
|
| 2 |
-
|
| 3 |
-
|
| 4 |
-
|
| 5 |
-
|
| 6 |
-
|
| 7 |
-
|
| 8 |
-
|
| 9 |
-
|
| 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 |
-
|
| 20 |
-
GRID_FPS = 1.0
|
| 21 |
-
MAX_CANDIDATES = 1500
|
| 22 |
|
| 23 |
-
|
| 24 |
-
|
| 25 |
-
|
| 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 |
-
"""
|
| 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 |
-
|
| 70 |
-
(difflib.SequenceMatcher(None, quote, s["text"].lower()).ratio(), s)
|
| 71 |
-
|
| 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],
|
| 80 |
-
|
| 81 |
-
|
| 82 |
-
"""Weighted capture time, snapped to the nearest candidate frame.
|
| 83 |
|
| 84 |
-
|
| 85 |
-
|
| 86 |
-
|
| 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 |
-
|
| 101 |
-
|
| 102 |
-
|
| 103 |
-
|
| 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 |
-
|
| 118 |
-
|
| 119 |
-
|
| 120 |
-
|
| 121 |
-
|
|
|
|
|
|
|
| 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
|
| 2 |
|
| 3 |
-
|
| 4 |
-
classifier ``OmarMedhat7/youtube-sentiment-analysis-model``
|
| 5 |
-
The video with the highest positive share wins.
|
| 6 |
-
|
| 7 |
"""
|
| 8 |
from __future__ import annotations
|
| 9 |
|
| 10 |
-
import
|
| 11 |
-
import
|
|
|
|
|
|
|
| 12 |
from functools import lru_cache
|
| 13 |
|
| 14 |
-
from yt_dlp import YoutubeDL
|
| 15 |
-
|
| 16 |
SENTIMENT_MODEL = "OmarMedhat7/youtube-sentiment-analysis-model"
|
| 17 |
-
|
|
|
|
| 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,
|
| 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(
|
| 55 |
-
|
| 56 |
-
|
| 57 |
-
if
|
| 58 |
-
|
| 59 |
-
|
| 60 |
-
|
| 61 |
-
|
| 62 |
-
|
| 63 |
-
|
| 64 |
-
|
| 65 |
-
|
| 66 |
-
|
| 67 |
-
|
| 68 |
-
|
| 69 |
-
|
| 70 |
-
|
| 71 |
-
|
| 72 |
-
|
| 73 |
-
|
| 74 |
-
|
| 75 |
-
|
| 76 |
-
|
| 77 |
-
|
| 78 |
-
|
| 79 |
-
|
| 80 |
-
|
| 81 |
-
|
| 82 |
-
|
| 83 |
-
|
| 84 |
-
|
| 85 |
-
|
| 86 |
-
|
| 87 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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["
|
| 100 |
if comments:
|
| 101 |
-
results = clf(comments)
|
| 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
|
|
|
|
|
|
|
| 111 |
except Exception as exc:
|
| 112 |
item["positive_share"] = 0.0
|
| 113 |
item["n_comments"] = 0
|
| 114 |
-
item["note"] = f"
|
| 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
|
| 2 |
|
| 3 |
-
|
| 4 |
-
|
|
|
|
| 5 |
"""
|
| 6 |
from __future__ import annotations
|
| 7 |
|
| 8 |
-
import os
|
| 9 |
-
from functools import lru_cache
|
| 10 |
|
| 11 |
-
|
| 12 |
-
|
| 13 |
-
WHISPER_MODEL = os.environ.get("WHISPER_MODEL", "base")
|
| 14 |
|
| 15 |
|
| 16 |
-
|
| 17 |
-
|
| 18 |
-
|
| 19 |
|
| 20 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 21 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 22 |
|
| 23 |
-
|
| 24 |
-
|
| 25 |
-
|
| 26 |
-
|
| 27 |
-
|
| 28 |
-
|
| 29 |
-
|
| 30 |
-
|
| 31 |
-
|
| 32 |
-
|
| 33 |
-
|
| 34 |
-
|
| 35 |
-
progress(min(s.end / total, 1.0), desc="Transcribing")
|
| 36 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 37 |
if not segs:
|
| 38 |
-
raise
|
| 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 |
-
|
| 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
|