vivekchakraverty commited on
Commit
23f7291
·
verified ·
1 Parent(s): 57602ab

Transcript via transcriptapi.com (no proxy) as primary; youtube-transcript-api fallback

Browse files
Files changed (1) hide show
  1. pipeline/transcribe.py +76 -27
pipeline/transcribe.py CHANGED
@@ -1,29 +1,64 @@
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 re
 
12
  import time
 
 
 
13
 
14
- # Strip credentials from any proxy URL that a library exception might echo, so a pasted
15
- # http://user:pass@host proxy never leaks into the UI/logs.
16
- _CRED_RE = re.compile(r"(https?://)[^/@\s]+@")
17
-
18
-
19
- def _redact(text: str) -> str:
20
- return _CRED_RE.sub(r"\1", str(text))
21
 
22
 
23
  class TranscriptError(RuntimeError):
24
  """Raised when no usable transcript can be fetched (disabled, blocked, none)."""
25
 
26
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
27
  def _fetch_raw(video_id: str, langs: list[str], proxy: str | None) -> list[dict]:
28
  from youtube_transcript_api import YouTubeTranscriptApi
29
 
@@ -61,23 +96,37 @@ def get_segments(video_id: str, languages=("en", "en-US", "en-GB"),
61
  proxy: str | None = None, attempts: int = 3) -> list[dict]:
62
  """Return timestamped segments ``[{start, end, text}]`` for ``video_id``.
63
 
64
- Retries transient connection/TLS failures; raises TranscriptError otherwise.
65
- ``proxy`` (the YT_PROXY secret) routes around datacenter-IP blocks.
 
66
  """
67
  langs = list(languages)
68
- raw, last = None, None
69
- for attempt in range(attempts):
 
 
70
  try:
71
- raw = _fetch_raw(video_id, langs, proxy)
72
- break
73
  except Exception as exc:
74
- last = exc
75
- if _is_transient(exc) and attempt < attempts - 1:
76
- time.sleep(1.5 * (attempt + 1))
77
- continue
78
- raise TranscriptError(_hint(exc)) from exc
 
 
 
 
 
 
 
 
 
79
  if raw is None:
80
- raise TranscriptError(_hint(last) if last else "Unknown transcript error.")
 
 
 
81
 
82
  segs = []
83
  for r in raw:
@@ -103,7 +152,7 @@ def transcript_text(segs: list[dict]) -> str:
103
 
104
  def _hint(exc: Exception) -> str:
105
  name = type(exc).__name__
106
- msg = _redact(str(exc))
107
  low = (name + " " + msg).lower()
108
  # Check specific transcript states first (note: "transcripts" contains "ip").
109
  if "disabled" in low or "transcriptsdisabled" in low:
 
1
+ """Stage 4: fetch a timestamped transcript (no video download).
2
 
3
+ Primary source is **transcriptapi.com** (a hosted transcript API). Because it's called on
4
+ its own domainnot ``youtube.com`` the Space reaches it directly, so transcripts need
5
+ **no proxy** at all. Set the ``TRANSCRIPTAPI_KEY`` Space secret to enable it.
6
+
7
+ Fallback is **youtube-transcript-api**, which scrapes ``www.youtube.com`` and therefore
8
+ does need ``YT_PROXY`` from a datacenter IP (the block usually shows up as a TLS/SSL reset).
9
+
10
+ Either way the snippets carry ``start``/``duration`` timing, normalized into
11
+ ``{start, end, text}`` segments the rest of the pipeline expects.
12
  """
13
  from __future__ import annotations
14
 
15
+ import json
16
+ import os
17
  import time
18
+ import urllib.error
19
+ import urllib.parse
20
+ import urllib.request
21
 
22
+ TRANSCRIPTAPI_URL = "https://transcriptapi.com/api/v2/youtube/transcript"
 
 
 
 
 
 
23
 
24
 
25
  class TranscriptError(RuntimeError):
26
  """Raised when no usable transcript can be fetched (disabled, blocked, none)."""
27
 
28
 
29
+ def _fetch_transcriptapi(video_id: str, langs: list[str]) -> list[dict]:
30
+ """Fetch timestamped snippets from transcriptapi.com. Requires TRANSCRIPTAPI_KEY.
31
+
32
+ Returns ``[{text, start, duration}]``. Raises on auth/credit/HTTP errors so the
33
+ caller can fall back to youtube-transcript-api.
34
+ """
35
+ key = os.environ.get("TRANSCRIPTAPI_KEY", "").strip()
36
+ if not key:
37
+ raise RuntimeError("TRANSCRIPTAPI_KEY not set")
38
+ params = {"video_url": video_id, "format": "json", "include_timestamp": "true"}
39
+ if langs:
40
+ params["language"] = ",".join(langs)
41
+ url = TRANSCRIPTAPI_URL + "?" + urllib.parse.urlencode(params)
42
+ req = urllib.request.Request(url, headers={"Authorization": f"Bearer {key}",
43
+ "Accept": "application/json"})
44
+ try:
45
+ with urllib.request.urlopen(req, timeout=30) as resp:
46
+ data = json.load(resp)
47
+ except urllib.error.HTTPError as exc:
48
+ body = exc.read().decode("utf-8", "ignore")
49
+ if exc.code in (401, 403):
50
+ raise RuntimeError(f"transcriptapi auth failed (HTTP {exc.code}); "
51
+ f"check TRANSCRIPTAPI_KEY. {body[:120]}") from exc
52
+ if exc.code in (402, 429):
53
+ raise RuntimeError(f"transcriptapi out of credits / rate-limited "
54
+ f"(HTTP {exc.code}). {body[:120]}") from exc
55
+ raise RuntimeError(f"transcriptapi HTTP {exc.code}: {body[:160]}") from exc
56
+ snippets = data.get("transcript") or []
57
+ return [{"text": s.get("text", ""),
58
+ "start": s.get("start", 0.0),
59
+ "duration": s.get("duration", 0.0)} for s in snippets]
60
+
61
+
62
  def _fetch_raw(video_id: str, langs: list[str], proxy: str | None) -> list[dict]:
63
  from youtube_transcript_api import YouTubeTranscriptApi
64
 
 
96
  proxy: str | None = None, attempts: int = 3) -> list[dict]:
97
  """Return timestamped segments ``[{start, end, text}]`` for ``video_id``.
98
 
99
+ Tries transcriptapi.com first (if ``TRANSCRIPTAPI_KEY`` is set — no proxy needed),
100
+ then falls back to youtube-transcript-api (which uses ``proxy`` to dodge datacenter
101
+ blocks). Retries transient connection/TLS failures; raises TranscriptError otherwise.
102
  """
103
  langs = list(languages)
104
+ raw, last, api_err = None, None, None
105
+
106
+ # Primary: transcriptapi.com (own domain -> reachable from the Space without a proxy).
107
+ if os.environ.get("TRANSCRIPTAPI_KEY", "").strip():
108
  try:
109
+ raw = _fetch_transcriptapi(video_id, langs)
 
110
  except Exception as exc:
111
+ api_err = exc # remember, but still try the fallback below
112
+
113
+ # Fallback: youtube-transcript-api (needs YT_PROXY from a datacenter IP).
114
+ if raw is None:
115
+ for attempt in range(attempts):
116
+ try:
117
+ raw = _fetch_raw(video_id, langs, proxy)
118
+ break
119
+ except Exception as exc:
120
+ last = exc
121
+ if _is_transient(exc) and attempt < attempts - 1:
122
+ time.sleep(1.5 * (attempt + 1))
123
+ continue
124
+ break
125
  if raw is None:
126
+ hint = _hint(last) if last else "Unknown transcript error."
127
+ if api_err is not None:
128
+ hint = f"transcriptapi.com: {api_err} | fallback {hint}"
129
+ raise TranscriptError(hint)
130
 
131
  segs = []
132
  for r in raw:
 
152
 
153
  def _hint(exc: Exception) -> str:
154
  name = type(exc).__name__
155
+ msg = str(exc)
156
  low = (name + " " + msg).lower()
157
  # Check specific transcript states first (note: "transcripts" contains "ip").
158
  if "disabled" in low or "transcriptsdisabled" in low: