TutorialMaker / pipeline /transcribe.py
vivekchakraverty's picture
Transcript via transcriptapi.com (no proxy) as primary; youtube-transcript-api fallback
23f7291 verified
Raw
History Blame
7.35 kB
"""Stage 4: fetch a timestamped transcript (no video download).
Primary source is **transcriptapi.com** (a hosted transcript API). Because it's called on
its own domain — not ``youtube.com`` — the Space reaches it directly, so transcripts need
**no proxy** at all. Set the ``TRANSCRIPTAPI_KEY`` Space secret to enable it.
Fallback is **youtube-transcript-api**, which scrapes ``www.youtube.com`` and therefore
does need ``YT_PROXY`` from a datacenter IP (the block usually shows up as a TLS/SSL reset).
Either way the snippets carry ``start``/``duration`` timing, normalized into
``{start, end, text}`` segments the rest of the pipeline expects.
"""
from __future__ import annotations
import json
import os
import time
import urllib.error
import urllib.parse
import urllib.request
TRANSCRIPTAPI_URL = "https://transcriptapi.com/api/v2/youtube/transcript"
class TranscriptError(RuntimeError):
"""Raised when no usable transcript can be fetched (disabled, blocked, none)."""
def _fetch_transcriptapi(video_id: str, langs: list[str]) -> list[dict]:
"""Fetch timestamped snippets from transcriptapi.com. Requires TRANSCRIPTAPI_KEY.
Returns ``[{text, start, duration}]``. Raises on auth/credit/HTTP errors so the
caller can fall back to youtube-transcript-api.
"""
key = os.environ.get("TRANSCRIPTAPI_KEY", "").strip()
if not key:
raise RuntimeError("TRANSCRIPTAPI_KEY not set")
params = {"video_url": video_id, "format": "json", "include_timestamp": "true"}
if langs:
params["language"] = ",".join(langs)
url = TRANSCRIPTAPI_URL + "?" + urllib.parse.urlencode(params)
req = urllib.request.Request(url, headers={"Authorization": f"Bearer {key}",
"Accept": "application/json"})
try:
with urllib.request.urlopen(req, timeout=30) as resp:
data = json.load(resp)
except urllib.error.HTTPError as exc:
body = exc.read().decode("utf-8", "ignore")
if exc.code in (401, 403):
raise RuntimeError(f"transcriptapi auth failed (HTTP {exc.code}); "
f"check TRANSCRIPTAPI_KEY. {body[:120]}") from exc
if exc.code in (402, 429):
raise RuntimeError(f"transcriptapi out of credits / rate-limited "
f"(HTTP {exc.code}). {body[:120]}") from exc
raise RuntimeError(f"transcriptapi HTTP {exc.code}: {body[:160]}") from exc
snippets = data.get("transcript") or []
return [{"text": s.get("text", ""),
"start": s.get("start", 0.0),
"duration": s.get("duration", 0.0)} for s in snippets]
def _fetch_raw(video_id: str, langs: list[str], proxy: str | None) -> list[dict]:
from youtube_transcript_api import YouTubeTranscriptApi
# --- 1.x instance API ---
api = None
if proxy:
try:
from youtube_transcript_api.proxies import GenericProxyConfig
api = YouTubeTranscriptApi(
proxy_config=GenericProxyConfig(http_url=proxy, https_url=proxy))
except Exception:
api = YouTubeTranscriptApi()
else:
api = YouTubeTranscriptApi()
if hasattr(api, "fetch"):
fetched = api.fetch(video_id, languages=langs)
return [{"text": s.text, "start": s.start, "duration": s.duration} for s in fetched]
# --- 0.6.x classmethod API ---
kwargs = {"languages": langs}
if proxy:
kwargs["proxies"] = {"http": proxy, "https": proxy}
data = YouTubeTranscriptApi.get_transcript(video_id, **kwargs)
return [{"text": d["text"], "start": d["start"], "duration": d.get("duration", 0)} for d in data]
def _is_transient(exc: Exception) -> bool:
s = (type(exc).__name__ + " " + str(exc)).lower()
return any(k in s for k in ("ssl", "eof", "timed out", "timeout", "reset",
"connection", "max retries", "temporarily"))
def get_segments(video_id: str, languages=("en", "en-US", "en-GB"),
proxy: str | None = None, attempts: int = 3) -> list[dict]:
"""Return timestamped segments ``[{start, end, text}]`` for ``video_id``.
Tries transcriptapi.com first (if ``TRANSCRIPTAPI_KEY`` is set — no proxy needed),
then falls back to youtube-transcript-api (which uses ``proxy`` to dodge datacenter
blocks). Retries transient connection/TLS failures; raises TranscriptError otherwise.
"""
langs = list(languages)
raw, last, api_err = None, None, None
# Primary: transcriptapi.com (own domain -> reachable from the Space without a proxy).
if os.environ.get("TRANSCRIPTAPI_KEY", "").strip():
try:
raw = _fetch_transcriptapi(video_id, langs)
except Exception as exc:
api_err = exc # remember, but still try the fallback below
# Fallback: youtube-transcript-api (needs YT_PROXY from a datacenter IP).
if raw is None:
for attempt in range(attempts):
try:
raw = _fetch_raw(video_id, langs, proxy)
break
except Exception as exc:
last = exc
if _is_transient(exc) and attempt < attempts - 1:
time.sleep(1.5 * (attempt + 1))
continue
break
if raw is None:
hint = _hint(last) if last else "Unknown transcript error."
if api_err is not None:
hint = f"transcriptapi.com: {api_err} | fallback {hint}"
raise TranscriptError(hint)
segs = []
for r in raw:
text = (r.get("text") or "").strip()
if not text:
continue
start = float(r.get("start") or 0.0)
dur = float(r.get("duration") or 0.0)
segs.append({"start": start, "end": start + dur, "text": text})
if not segs:
raise TranscriptError("The transcript came back empty for this video.")
return segs
def transcript_text(segs: list[dict]) -> str:
"""Render segments as ``[mm:ss] text`` lines for the LLM and the UI preview."""
lines = []
for s in segs:
m, sec = divmod(int(s["start"]), 60)
lines.append(f"[{m:02d}:{sec:02d}] {s['text']}")
return "\n".join(lines)
def _hint(exc: Exception) -> str:
name = type(exc).__name__
msg = str(exc)
low = (name + " " + msg).lower()
# Check specific transcript states first (note: "transcripts" contains "ip").
if "disabled" in low or "transcriptsdisabled" in low:
return "This video has transcripts/captions disabled — pick another video."
if "notranscript" in low or "no transcript" in low:
return "No transcript is available for this video in the requested languages."
if "unavailable" in low or "videounavailable" in low:
return "The video is unavailable (private/removed/region-locked)."
if any(k in low for k in ("ssl", "eof", "reset", "connection", "max retries",
"timed out", "blocked", "forbidden", "too many", "429")):
return ("YouTube blocked the transcript request from this Space's IP "
"(datacenter IPs are commonly blocked — seen here as a TLS/SSL reset). "
"Set a residential proxy as the YT_PROXY Space secret and retry. "
f"[{name}]")
return f"Could not fetch transcript: {name}: {msg[:200]}"