Spaces:
Sleeping
Sleeping
Commit ·
ffdac8f
1
Parent(s): f07812e
Transcript: detect IP-block (SSL/EOF) + retry; expose module-level demo
Browse files- youtube-transcript-api hits www.youtube.com, which datacenter IPs get blocked from
(TLS reset / SSL EOF). Retry transient connection failures and, on a clear block, the
error now tells the user to set the YT_PROXY (residential) secret. Fixed a substring
bug where "transcripts" matched the "ip" block keyword.
- app.py exposes module-level `demo` so HF's SSR launcher stops warning
"Launching demo not found in __main__".
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- app.py +6 -1
- pipeline/transcribe.py +63 -39
app.py
CHANGED
|
@@ -285,5 +285,10 @@ def build_ui():
|
|
| 285 |
return demo
|
| 286 |
|
| 287 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 288 |
if __name__ == "__main__":
|
| 289 |
-
|
|
|
|
| 285 |
return demo
|
| 286 |
|
| 287 |
|
| 288 |
+
# Expose a module-level `demo` so HF Spaces' SSR launcher finds it (avoids the
|
| 289 |
+
# "Launching demo not found in __main__" fallback warning).
|
| 290 |
+
demo = build_ui()
|
| 291 |
+
demo.queue()
|
| 292 |
+
|
| 293 |
if __name__ == "__main__":
|
| 294 |
+
demo.launch()
|
pipeline/transcribe.py
CHANGED
|
@@ -1,56 +1,77 @@
|
|
| 1 |
"""Stage 4: fetch a timestamped transcript via youtube-transcript-api (no download).
|
| 2 |
|
| 3 |
-
youtube-transcript-api
|
| 4 |
-
|
| 5 |
-
|
|
|
|
|
|
|
| 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 |
-
|
| 19 |
-
|
| 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 |
-
|
| 27 |
-
|
| 28 |
-
|
| 29 |
-
|
| 30 |
-
|
| 31 |
-
|
| 32 |
-
|
| 33 |
-
|
| 34 |
-
|
| 35 |
-
|
| 36 |
-
|
| 37 |
-
|
| 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
|
| 54 |
text = (r.get("text") or "").strip()
|
| 55 |
if not text:
|
| 56 |
continue
|
|
@@ -75,14 +96,17 @@ 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
|
| 85 |
-
|
| 86 |
-
|
|
|
|
|
|
|
| 87 |
f"[{name}]")
|
| 88 |
return f"Could not fetch transcript: {name}: {msg[:200]}"
|
|
|
|
| 1 |
"""Stage 4: fetch a timestamped transcript via youtube-transcript-api (no download).
|
| 2 |
|
| 3 |
+
youtube-transcript-api scrapes ``www.youtube.com``, which datacenter IPs (like a Space)
|
| 4 |
+
often get blocked from — usually as a TLS reset / SSL EOF rather than a clean 4xx. We
|
| 5 |
+
retry a few times for transient failures and, when it's clearly a block, point the user at
|
| 6 |
+
the ``YT_PROXY`` fallback. The returned snippets already carry ``start``/``duration``
|
| 7 |
+
timestamps, which we normalize into ``{start, end, text}`` segments.
|
| 8 |
"""
|
| 9 |
from __future__ import annotations
|
| 10 |
|
| 11 |
+
import time
|
| 12 |
+
|
| 13 |
|
| 14 |
class TranscriptError(RuntimeError):
|
| 15 |
"""Raised when no usable transcript can be fetched (disabled, blocked, none)."""
|
| 16 |
|
| 17 |
|
| 18 |
+
def _fetch_raw(video_id: str, langs: list[str], proxy: str | None) -> list[dict]:
|
| 19 |
+
from youtube_transcript_api import YouTubeTranscriptApi
|
| 20 |
+
|
| 21 |
+
# --- 1.x instance API ---
|
| 22 |
+
api = None
|
| 23 |
+
if proxy:
|
| 24 |
+
try:
|
| 25 |
+
from youtube_transcript_api.proxies import GenericProxyConfig
|
| 26 |
+
api = YouTubeTranscriptApi(
|
| 27 |
+
proxy_config=GenericProxyConfig(http_url=proxy, https_url=proxy))
|
| 28 |
+
except Exception:
|
| 29 |
+
api = YouTubeTranscriptApi()
|
| 30 |
+
else:
|
| 31 |
+
api = YouTubeTranscriptApi()
|
| 32 |
+
|
| 33 |
+
if hasattr(api, "fetch"):
|
| 34 |
+
fetched = api.fetch(video_id, languages=langs)
|
| 35 |
+
return [{"text": s.text, "start": s.start, "duration": s.duration} for s in fetched]
|
| 36 |
+
|
| 37 |
+
# --- 0.6.x classmethod API ---
|
| 38 |
+
kwargs = {"languages": langs}
|
| 39 |
+
if proxy:
|
| 40 |
+
kwargs["proxies"] = {"http": proxy, "https": proxy}
|
| 41 |
+
data = YouTubeTranscriptApi.get_transcript(video_id, **kwargs)
|
| 42 |
+
return [{"text": d["text"], "start": d["start"], "duration": d.get("duration", 0)} for d in data]
|
| 43 |
+
|
| 44 |
+
|
| 45 |
+
def _is_transient(exc: Exception) -> bool:
|
| 46 |
+
s = (type(exc).__name__ + " " + str(exc)).lower()
|
| 47 |
+
return any(k in s for k in ("ssl", "eof", "timed out", "timeout", "reset",
|
| 48 |
+
"connection", "max retries", "temporarily"))
|
| 49 |
+
|
| 50 |
+
|
| 51 |
def get_segments(video_id: str, languages=("en", "en-US", "en-GB"),
|
| 52 |
+
proxy: str | None = None, attempts: int = 3) -> list[dict]:
|
| 53 |
"""Return timestamped segments ``[{start, end, text}]`` for ``video_id``.
|
| 54 |
|
| 55 |
+
Retries transient connection/TLS failures; raises TranscriptError otherwise.
|
| 56 |
+
``proxy`` (the YT_PROXY secret) routes around datacenter-IP blocks.
|
|
|
|
| 57 |
"""
|
|
|
|
|
|
|
| 58 |
langs = list(languages)
|
| 59 |
+
raw, last = None, None
|
| 60 |
+
for attempt in range(attempts):
|
| 61 |
+
try:
|
| 62 |
+
raw = _fetch_raw(video_id, langs, proxy)
|
| 63 |
+
break
|
| 64 |
+
except Exception as exc:
|
| 65 |
+
last = exc
|
| 66 |
+
if _is_transient(exc) and attempt < attempts - 1:
|
| 67 |
+
time.sleep(1.5 * (attempt + 1))
|
| 68 |
+
continue
|
| 69 |
+
raise TranscriptError(_hint(exc)) from exc
|
| 70 |
+
if raw is None:
|
| 71 |
+
raise TranscriptError(_hint(last) if last else "Unknown transcript error.")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 72 |
|
| 73 |
segs = []
|
| 74 |
+
for r in raw:
|
| 75 |
text = (r.get("text") or "").strip()
|
| 76 |
if not text:
|
| 77 |
continue
|
|
|
|
| 96 |
name = type(exc).__name__
|
| 97 |
msg = str(exc)
|
| 98 |
low = (name + " " + msg).lower()
|
| 99 |
+
# Check specific transcript states first (note: "transcripts" contains "ip").
|
| 100 |
if "disabled" in low or "transcriptsdisabled" in low:
|
| 101 |
return "This video has transcripts/captions disabled — pick another video."
|
| 102 |
if "notranscript" in low or "no transcript" in low:
|
| 103 |
return "No transcript is available for this video in the requested languages."
|
| 104 |
if "unavailable" in low or "videounavailable" in low:
|
| 105 |
return "The video is unavailable (private/removed/region-locked)."
|
| 106 |
+
if any(k in low for k in ("ssl", "eof", "reset", "connection", "max retries",
|
| 107 |
+
"timed out", "blocked", "forbidden", "too many", "429")):
|
| 108 |
+
return ("YouTube blocked the transcript request from this Space's IP "
|
| 109 |
+
"(datacenter IPs are commonly blocked — seen here as a TLS/SSL reset). "
|
| 110 |
+
"Set a residential proxy as the YT_PROXY Space secret and retry. "
|
| 111 |
f"[{name}]")
|
| 112 |
return f"Could not fetch transcript: {name}: {msg[:200]}"
|