Spaces:
Sleeping
Sleeping
Update webscout.py
Browse files- webscout.py +572 -9
webscout.py
CHANGED
|
@@ -20,14 +20,80 @@ try:
|
|
| 20 |
except ImportError:
|
| 21 |
LXML_AVAILABLE = False
|
| 22 |
|
| 23 |
-
|
| 24 |
-
|
| 25 |
-
|
| 26 |
-
|
| 27 |
-
|
| 28 |
-
|
| 29 |
-
|
| 30 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 31 |
|
| 32 |
logger = logging.getLogger("webscout.WEBS")
|
| 33 |
|
|
@@ -1078,4 +1144,501 @@ class WEBS:
|
|
| 1078 |
except Exception as e:
|
| 1079 |
raise e
|
| 1080 |
|
| 1081 |
-
return results
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 20 |
except ImportError:
|
| 21 |
LXML_AVAILABLE = False
|
| 22 |
|
| 23 |
+
import re
|
| 24 |
+
from decimal import Decimal
|
| 25 |
+
from html import unescape
|
| 26 |
+
from math import atan2, cos, radians, sin, sqrt
|
| 27 |
+
from typing import Any, Dict, List, Union
|
| 28 |
+
from urllib.parse import unquote
|
| 29 |
+
import orjson
|
| 30 |
+
|
| 31 |
+
from .exceptions import WebscoutE
|
| 32 |
+
|
| 33 |
+
REGEX_STRIP_TAGS = re.compile("<.*?>")
|
| 34 |
+
|
| 35 |
+
|
| 36 |
+
def json_dumps(obj: Any) -> str:
|
| 37 |
+
try:
|
| 38 |
+
return orjson.dumps(obj).decode("utf-8")
|
| 39 |
+
except Exception as ex:
|
| 40 |
+
raise WebscoutE(f"{type(ex).__name__}: {ex}") from ex
|
| 41 |
+
|
| 42 |
+
|
| 43 |
+
def json_loads(obj: Union[str, bytes]) -> Any:
|
| 44 |
+
try:
|
| 45 |
+
return orjson.loads(obj)
|
| 46 |
+
except Exception as ex:
|
| 47 |
+
raise WebscoutE(f"{type(ex).__name__}: {ex}") from ex
|
| 48 |
+
|
| 49 |
+
|
| 50 |
+
def _extract_vqd(html_bytes: bytes, keywords: str) -> str:
|
| 51 |
+
"""Extract vqd from html bytes."""
|
| 52 |
+
for c1, c1_len, c2 in (
|
| 53 |
+
(b'vqd="', 5, b'"'),
|
| 54 |
+
(b"vqd=", 4, b"&"),
|
| 55 |
+
(b"vqd='", 5, b"'"),
|
| 56 |
+
):
|
| 57 |
+
try:
|
| 58 |
+
start = html_bytes.index(c1) + c1_len
|
| 59 |
+
end = html_bytes.index(c2, start)
|
| 60 |
+
return html_bytes[start:end].decode()
|
| 61 |
+
except ValueError:
|
| 62 |
+
pass
|
| 63 |
+
raise WebscoutE(f"_extract_vqd() {keywords=} Could not extract vqd.")
|
| 64 |
+
|
| 65 |
+
|
| 66 |
+
def _text_extract_json(html_bytes: bytes, keywords: str) -> List[Dict[str, str]]:
|
| 67 |
+
"""text(backend="api") -> extract json from html."""
|
| 68 |
+
try:
|
| 69 |
+
start = html_bytes.index(b"DDG.pageLayout.load('d',") + 24
|
| 70 |
+
end = html_bytes.index(b");DDG.duckbar.load(", start)
|
| 71 |
+
data = html_bytes[start:end]
|
| 72 |
+
result: List[Dict[str, str]] = json_loads(data)
|
| 73 |
+
return result
|
| 74 |
+
except Exception as ex:
|
| 75 |
+
raise WebscoutE(f"_text_extract_json() {keywords=} {type(ex).__name__}: {ex}") from ex
|
| 76 |
+
raise WebscoutE(f"_text_extract_json() {keywords=} return None")
|
| 77 |
+
|
| 78 |
+
|
| 79 |
+
def _normalize(raw_html: str) -> str:
|
| 80 |
+
"""Strip HTML tags from the raw_html string."""
|
| 81 |
+
return unescape(REGEX_STRIP_TAGS.sub("", raw_html)) if raw_html else ""
|
| 82 |
+
|
| 83 |
+
|
| 84 |
+
def _normalize_url(url: str) -> str:
|
| 85 |
+
"""Unquote URL and replace spaces with '+'."""
|
| 86 |
+
return unquote(url.replace(" ", "+")) if url else ""
|
| 87 |
+
|
| 88 |
+
|
| 89 |
+
def _calculate_distance(lat1: Decimal, lon1: Decimal, lat2: Decimal, lon2: Decimal) -> float:
|
| 90 |
+
"""Calculate distance between two points in km. Haversine formula."""
|
| 91 |
+
R = 6371.0087714 # Earth's radius in km
|
| 92 |
+
rlat1, rlon1, rlat2, rlon2 = map(radians, [float(lat1), float(lon1), float(lat2), float(lon2)])
|
| 93 |
+
dlon, dlat = rlon2 - rlon1, rlat2 - rlat1
|
| 94 |
+
a = sin(dlat / 2) ** 2 + cos(rlat1) * cos(rlat2) * sin(dlon / 2) ** 2
|
| 95 |
+
c = 2 * atan2(sqrt(a), sqrt(1 - a))
|
| 96 |
+
return R * c
|
| 97 |
|
| 98 |
logger = logging.getLogger("webscout.WEBS")
|
| 99 |
|
|
|
|
| 1144 |
except Exception as e:
|
| 1145 |
raise e
|
| 1146 |
|
| 1147 |
+
return results
|
| 1148 |
+
import requests
|
| 1149 |
+
import http.cookiejar as cookiejar
|
| 1150 |
+
import sys
|
| 1151 |
+
import json
|
| 1152 |
+
from xml.etree import ElementTree
|
| 1153 |
+
import re
|
| 1154 |
+
from requests import HTTPError
|
| 1155 |
+
import html.parser
|
| 1156 |
+
|
| 1157 |
+
html_parser = html.parser.HTMLParser()
|
| 1158 |
+
import html
|
| 1159 |
+
|
| 1160 |
+
def unescape(string):
|
| 1161 |
+
return html.unescape(string)
|
| 1162 |
+
WATCH_URL = 'https://www.youtube.com/watch?v={video_id}'
|
| 1163 |
+
|
| 1164 |
+
class TranscriptRetrievalError(Exception):
|
| 1165 |
+
"""
|
| 1166 |
+
Base class for exceptions raised when a transcript cannot be retrieved.
|
| 1167 |
+
"""
|
| 1168 |
+
ERROR_MESSAGE = '\nCould not retrieve a transcript for the video {video_url}!'
|
| 1169 |
+
CAUSE_MESSAGE_INTRO = ' This is most likely caused by:\n\n{cause}'
|
| 1170 |
+
CAUSE_MESSAGE = ''
|
| 1171 |
+
GITHUB_REFERRAL = (
|
| 1172 |
+
'\n\nIf you are sure that the described cause is not responsible for this error '
|
| 1173 |
+
'and that a transcript should be retrievable, please create an issue at '
|
| 1174 |
+
'https://github.com/OE-LUCIFER/Webscout/issues. '
|
| 1175 |
+
'Please add which version of webscout you are using '
|
| 1176 |
+
'and provide the information needed to replicate the error. '
|
| 1177 |
+
)
|
| 1178 |
+
|
| 1179 |
+
def __init__(self, video_id):
|
| 1180 |
+
self.video_id = video_id
|
| 1181 |
+
super(TranscriptRetrievalError, self).__init__(self._build_error_message())
|
| 1182 |
+
|
| 1183 |
+
def _build_error_message(self):
|
| 1184 |
+
cause = self.cause
|
| 1185 |
+
error_message = self.ERROR_MESSAGE.format(video_url=WATCH_URL.format(video_id=self.video_id))
|
| 1186 |
+
|
| 1187 |
+
if cause:
|
| 1188 |
+
error_message += self.CAUSE_MESSAGE_INTRO.format(cause=cause) + self.GITHUB_REFERRAL
|
| 1189 |
+
|
| 1190 |
+
return error_message
|
| 1191 |
+
|
| 1192 |
+
@property
|
| 1193 |
+
def cause(self):
|
| 1194 |
+
return self.CAUSE_MESSAGE
|
| 1195 |
+
|
| 1196 |
+
class YouTubeRequestFailedError(TranscriptRetrievalError):
|
| 1197 |
+
CAUSE_MESSAGE = 'Request to YouTube failed: {reason}'
|
| 1198 |
+
|
| 1199 |
+
def __init__(self, video_id, http_error):
|
| 1200 |
+
self.reason = str(http_error)
|
| 1201 |
+
super(YouTubeRequestFailedError, self).__init__(video_id)
|
| 1202 |
+
|
| 1203 |
+
@property
|
| 1204 |
+
def cause(self):
|
| 1205 |
+
return self.CAUSE_MESSAGE.format(reason=self.reason)
|
| 1206 |
+
|
| 1207 |
+
class VideoUnavailableError(TranscriptRetrievalError):
|
| 1208 |
+
CAUSE_MESSAGE = 'The video is no longer available'
|
| 1209 |
+
|
| 1210 |
+
class InvalidVideoIdError(TranscriptRetrievalError):
|
| 1211 |
+
CAUSE_MESSAGE = (
|
| 1212 |
+
'You provided an invalid video id. Make sure you are using the video id and NOT the url!\n\n'
|
| 1213 |
+
'Do NOT run: `YouTubeTranscriptApi.get_transcript("https://www.youtube.com/watch?v=1234")`\n'
|
| 1214 |
+
'Instead run: `YouTubeTranscriptApi.get_transcript("1234")`'
|
| 1215 |
+
)
|
| 1216 |
+
|
| 1217 |
+
class TooManyRequestsError(TranscriptRetrievalError):
|
| 1218 |
+
CAUSE_MESSAGE = (
|
| 1219 |
+
'YouTube is receiving too many requests from this IP and now requires solving a captcha to continue. '
|
| 1220 |
+
'One of the following things can be done to work around this:\n\
|
| 1221 |
+
- Manually solve the captcha in a browser and export the cookie. '
|
| 1222 |
+
'Read here how to use that cookie with '
|
| 1223 |
+
'youtube-transcript-api: https://github.com/jdepoix/youtube-transcript-api#cookies\n\
|
| 1224 |
+
- Use a different IP address\n\
|
| 1225 |
+
- Wait until the ban on your IP has been lifted'
|
| 1226 |
+
)
|
| 1227 |
+
|
| 1228 |
+
class TranscriptsDisabledError(TranscriptRetrievalError):
|
| 1229 |
+
CAUSE_MESSAGE = 'Subtitles are disabled for this video'
|
| 1230 |
+
|
| 1231 |
+
class NoTranscriptAvailableError(TranscriptRetrievalError):
|
| 1232 |
+
CAUSE_MESSAGE = 'No transcripts are available for this video'
|
| 1233 |
+
|
| 1234 |
+
class NotTranslatableError(TranscriptRetrievalError):
|
| 1235 |
+
CAUSE_MESSAGE = 'The requested language is not translatable'
|
| 1236 |
+
|
| 1237 |
+
class TranslationLanguageNotAvailableError(TranscriptRetrievalError):
|
| 1238 |
+
CAUSE_MESSAGE = 'The requested translation language is not available'
|
| 1239 |
+
|
| 1240 |
+
class CookiePathInvalidError(TranscriptRetrievalError):
|
| 1241 |
+
CAUSE_MESSAGE = 'The provided cookie file was unable to be loaded'
|
| 1242 |
+
|
| 1243 |
+
class CookiesInvalidError(TranscriptRetrievalError):
|
| 1244 |
+
CAUSE_MESSAGE = 'The cookies provided are not valid (may have expired)'
|
| 1245 |
+
|
| 1246 |
+
class FailedToCreateConsentCookieError(TranscriptRetrievalError):
|
| 1247 |
+
CAUSE_MESSAGE = 'Failed to automatically give consent to saving cookies'
|
| 1248 |
+
|
| 1249 |
+
class NoTranscriptFoundError(TranscriptRetrievalError):
|
| 1250 |
+
CAUSE_MESSAGE = (
|
| 1251 |
+
'No transcripts were found for any of the requested language codes: {requested_language_codes}\n\n'
|
| 1252 |
+
'{transcript_data}'
|
| 1253 |
+
)
|
| 1254 |
+
|
| 1255 |
+
def __init__(self, video_id, requested_language_codes, transcript_data):
|
| 1256 |
+
self._requested_language_codes = requested_language_codes
|
| 1257 |
+
self._transcript_data = transcript_data
|
| 1258 |
+
super(NoTranscriptFoundError, self).__init__(video_id)
|
| 1259 |
+
|
| 1260 |
+
@property
|
| 1261 |
+
def cause(self):
|
| 1262 |
+
return self.CAUSE_MESSAGE.format(
|
| 1263 |
+
requested_language_codes=self._requested_language_codes,
|
| 1264 |
+
transcript_data=str(self._transcript_data),
|
| 1265 |
+
)
|
| 1266 |
+
|
| 1267 |
+
|
| 1268 |
+
|
| 1269 |
+
def _raise_http_errors(response, video_id):
|
| 1270 |
+
try:
|
| 1271 |
+
response.raise_for_status()
|
| 1272 |
+
return response
|
| 1273 |
+
except HTTPError as error:
|
| 1274 |
+
raise YouTubeRequestFailedError(error, video_id)
|
| 1275 |
+
|
| 1276 |
+
|
| 1277 |
+
class TranscriptListFetcher(object):
|
| 1278 |
+
def __init__(self, http_client):
|
| 1279 |
+
self._http_client = http_client
|
| 1280 |
+
|
| 1281 |
+
def fetch(self, video_id):
|
| 1282 |
+
return TranscriptList.build(
|
| 1283 |
+
self._http_client,
|
| 1284 |
+
video_id,
|
| 1285 |
+
self._extract_captions_json(self._fetch_video_html(video_id), video_id),
|
| 1286 |
+
)
|
| 1287 |
+
|
| 1288 |
+
def _extract_captions_json(self, html, video_id):
|
| 1289 |
+
splitted_html = html.split('"captions":')
|
| 1290 |
+
|
| 1291 |
+
if len(splitted_html) <= 1:
|
| 1292 |
+
if video_id.startswith('http://') or video_id.startswith('https://'):
|
| 1293 |
+
raise InvalidVideoIdError(video_id)
|
| 1294 |
+
if 'class="g-recaptcha"' in html:
|
| 1295 |
+
raise TooManyRequestsError(video_id)
|
| 1296 |
+
if '"playabilityStatus":' not in html:
|
| 1297 |
+
raise VideoUnavailableError(video_id)
|
| 1298 |
+
|
| 1299 |
+
raise TranscriptsDisabledError(video_id)
|
| 1300 |
+
|
| 1301 |
+
captions_json = json.loads(
|
| 1302 |
+
splitted_html[1].split(',"videoDetails')[0].replace('\n', '')
|
| 1303 |
+
).get('playerCaptionsTracklistRenderer')
|
| 1304 |
+
if captions_json is None:
|
| 1305 |
+
raise TranscriptsDisabledError(video_id)
|
| 1306 |
+
|
| 1307 |
+
if 'captionTracks' not in captions_json:
|
| 1308 |
+
raise TranscriptsDisabledError(video_id)
|
| 1309 |
+
|
| 1310 |
+
return captions_json
|
| 1311 |
+
|
| 1312 |
+
def _create_consent_cookie(self, html, video_id):
|
| 1313 |
+
match = re.search('name="v" value="(.*?)"', html)
|
| 1314 |
+
if match is None:
|
| 1315 |
+
raise FailedToCreateConsentCookieError(video_id)
|
| 1316 |
+
self._http_client.cookies.set('CONSENT', 'YES+' + match.group(1), domain='.youtube.com')
|
| 1317 |
+
|
| 1318 |
+
def _fetch_video_html(self, video_id):
|
| 1319 |
+
html = self._fetch_html(video_id)
|
| 1320 |
+
if 'action="https://consent.youtube.com/s"' in html:
|
| 1321 |
+
self._create_consent_cookie(html, video_id)
|
| 1322 |
+
html = self._fetch_html(video_id)
|
| 1323 |
+
if 'action="https://consent.youtube.com/s"' in html:
|
| 1324 |
+
raise FailedToCreateConsentCookieError(video_id)
|
| 1325 |
+
return html
|
| 1326 |
+
|
| 1327 |
+
def _fetch_html(self, video_id):
|
| 1328 |
+
response = self._http_client.get(WATCH_URL.format(video_id=video_id), headers={'Accept-Language': 'en-US'})
|
| 1329 |
+
return unescape(_raise_http_errors(response, video_id).text)
|
| 1330 |
+
|
| 1331 |
+
|
| 1332 |
+
class TranscriptList(object):
|
| 1333 |
+
"""
|
| 1334 |
+
This object represents a list of transcripts. It can be iterated over to list all transcripts which are available
|
| 1335 |
+
for a given YouTube video. Also it provides functionality to search for a transcript in a given language.
|
| 1336 |
+
"""
|
| 1337 |
+
|
| 1338 |
+
def __init__(self, video_id, manually_created_transcripts, generated_transcripts, translation_languages):
|
| 1339 |
+
"""
|
| 1340 |
+
The constructor is only for internal use. Use the static build method instead.
|
| 1341 |
+
|
| 1342 |
+
:param video_id: the id of the video this TranscriptList is for
|
| 1343 |
+
:type video_id: str
|
| 1344 |
+
:param manually_created_transcripts: dict mapping language codes to the manually created transcripts
|
| 1345 |
+
:type manually_created_transcripts: dict[str, Transcript]
|
| 1346 |
+
:param generated_transcripts: dict mapping language codes to the generated transcripts
|
| 1347 |
+
:type generated_transcripts: dict[str, Transcript]
|
| 1348 |
+
:param translation_languages: list of languages which can be used for translatable languages
|
| 1349 |
+
:type translation_languages: list[dict[str, str]]
|
| 1350 |
+
"""
|
| 1351 |
+
self.video_id = video_id
|
| 1352 |
+
self._manually_created_transcripts = manually_created_transcripts
|
| 1353 |
+
self._generated_transcripts = generated_transcripts
|
| 1354 |
+
self._translation_languages = translation_languages
|
| 1355 |
+
|
| 1356 |
+
@staticmethod
|
| 1357 |
+
def build(http_client, video_id, captions_json):
|
| 1358 |
+
"""
|
| 1359 |
+
Factory method for TranscriptList.
|
| 1360 |
+
|
| 1361 |
+
:param http_client: http client which is used to make the transcript retrieving http calls
|
| 1362 |
+
:type http_client: requests.Session
|
| 1363 |
+
:param video_id: the id of the video this TranscriptList is for
|
| 1364 |
+
:type video_id: str
|
| 1365 |
+
:param captions_json: the JSON parsed from the YouTube pages static HTML
|
| 1366 |
+
:type captions_json: dict
|
| 1367 |
+
:return: the created TranscriptList
|
| 1368 |
+
:rtype TranscriptList:
|
| 1369 |
+
"""
|
| 1370 |
+
translation_languages = [
|
| 1371 |
+
{
|
| 1372 |
+
'language': translation_language['languageName']['simpleText'],
|
| 1373 |
+
'language_code': translation_language['languageCode'],
|
| 1374 |
+
} for translation_language in captions_json.get('translationLanguages', [])
|
| 1375 |
+
]
|
| 1376 |
+
|
| 1377 |
+
manually_created_transcripts = {}
|
| 1378 |
+
generated_transcripts = {}
|
| 1379 |
+
|
| 1380 |
+
for caption in captions_json['captionTracks']:
|
| 1381 |
+
if caption.get('kind', '') == 'asr':
|
| 1382 |
+
transcript_dict = generated_transcripts
|
| 1383 |
+
else:
|
| 1384 |
+
transcript_dict = manually_created_transcripts
|
| 1385 |
+
|
| 1386 |
+
transcript_dict[caption['languageCode']] = Transcript(
|
| 1387 |
+
http_client,
|
| 1388 |
+
video_id,
|
| 1389 |
+
caption['baseUrl'],
|
| 1390 |
+
caption['name']['simpleText'],
|
| 1391 |
+
caption['languageCode'],
|
| 1392 |
+
caption.get('kind', '') == 'asr',
|
| 1393 |
+
translation_languages if caption.get('isTranslatable', False) else [],
|
| 1394 |
+
)
|
| 1395 |
+
|
| 1396 |
+
return TranscriptList(
|
| 1397 |
+
video_id,
|
| 1398 |
+
manually_created_transcripts,
|
| 1399 |
+
generated_transcripts,
|
| 1400 |
+
translation_languages,
|
| 1401 |
+
)
|
| 1402 |
+
|
| 1403 |
+
def __iter__(self):
|
| 1404 |
+
return iter(list(self._manually_created_transcripts.values()) + list(self._generated_transcripts.values()))
|
| 1405 |
+
|
| 1406 |
+
def find_transcript(self, language_codes):
|
| 1407 |
+
"""
|
| 1408 |
+
Finds a transcript for a given language code. Manually created transcripts are returned first and only if none
|
| 1409 |
+
are found, generated transcripts are used. If you only want generated transcripts use
|
| 1410 |
+
`find_manually_created_transcript` instead.
|
| 1411 |
+
|
| 1412 |
+
:param language_codes: A list of language codes in a descending priority. For example, if this is set to
|
| 1413 |
+
['de', 'en'] it will first try to fetch the german transcript (de) and then fetch the english transcript (en) if
|
| 1414 |
+
it fails to do so.
|
| 1415 |
+
:type languages: list[str]
|
| 1416 |
+
:return: the found Transcript
|
| 1417 |
+
:rtype Transcript:
|
| 1418 |
+
:raises: NoTranscriptFound
|
| 1419 |
+
"""
|
| 1420 |
+
return self._find_transcript(language_codes, [self._manually_created_transcripts, self._generated_transcripts])
|
| 1421 |
+
|
| 1422 |
+
def find_generated_transcript(self, language_codes):
|
| 1423 |
+
"""
|
| 1424 |
+
Finds an automatically generated transcript for a given language code.
|
| 1425 |
+
|
| 1426 |
+
:param language_codes: A list of language codes in a descending priority. For example, if this is set to
|
| 1427 |
+
['de', 'en'] it will first try to fetch the german transcript (de) and then fetch the english transcript (en) if
|
| 1428 |
+
it fails to do so.
|
| 1429 |
+
:type languages: list[str]
|
| 1430 |
+
:return: the found Transcript
|
| 1431 |
+
:rtype Transcript:
|
| 1432 |
+
:raises: NoTranscriptFound
|
| 1433 |
+
"""
|
| 1434 |
+
return self._find_transcript(language_codes, [self._generated_transcripts])
|
| 1435 |
+
|
| 1436 |
+
def find_manually_created_transcript(self, language_codes):
|
| 1437 |
+
"""
|
| 1438 |
+
Finds a manually created transcript for a given language code.
|
| 1439 |
+
|
| 1440 |
+
:param language_codes: A list of language codes in a descending priority. For example, if this is set to
|
| 1441 |
+
['de', 'en'] it will first try to fetch the german transcript (de) and then fetch the english transcript (en) if
|
| 1442 |
+
it fails to do so.
|
| 1443 |
+
:type languages: list[str]
|
| 1444 |
+
:return: the found Transcript
|
| 1445 |
+
:rtype Transcript:
|
| 1446 |
+
:raises: NoTranscriptFound
|
| 1447 |
+
"""
|
| 1448 |
+
return self._find_transcript(language_codes, [self._manually_created_transcripts])
|
| 1449 |
+
|
| 1450 |
+
def _find_transcript(self, language_codes, transcript_dicts):
|
| 1451 |
+
for language_code in language_codes:
|
| 1452 |
+
for transcript_dict in transcript_dicts:
|
| 1453 |
+
if language_code in transcript_dict:
|
| 1454 |
+
return transcript_dict[language_code]
|
| 1455 |
+
|
| 1456 |
+
raise NoTranscriptFoundError(
|
| 1457 |
+
self.video_id,
|
| 1458 |
+
language_codes,
|
| 1459 |
+
self
|
| 1460 |
+
)
|
| 1461 |
+
|
| 1462 |
+
def __str__(self):
|
| 1463 |
+
return (
|
| 1464 |
+
'For this video ({video_id}) transcripts are available in the following languages:\n\n'
|
| 1465 |
+
'(MANUALLY CREATED)\n'
|
| 1466 |
+
'{available_manually_created_transcript_languages}\n\n'
|
| 1467 |
+
'(GENERATED)\n'
|
| 1468 |
+
'{available_generated_transcripts}\n\n'
|
| 1469 |
+
'(TRANSLATION LANGUAGES)\n'
|
| 1470 |
+
'{available_translation_languages}'
|
| 1471 |
+
).format(
|
| 1472 |
+
video_id=self.video_id,
|
| 1473 |
+
available_manually_created_transcript_languages=self._get_language_description(
|
| 1474 |
+
str(transcript) for transcript in self._manually_created_transcripts.values()
|
| 1475 |
+
),
|
| 1476 |
+
available_generated_transcripts=self._get_language_description(
|
| 1477 |
+
str(transcript) for transcript in self._generated_transcripts.values()
|
| 1478 |
+
),
|
| 1479 |
+
available_translation_languages=self._get_language_description(
|
| 1480 |
+
'{language_code} ("{language}")'.format(
|
| 1481 |
+
language=translation_language['language'],
|
| 1482 |
+
language_code=translation_language['language_code'],
|
| 1483 |
+
) for translation_language in self._translation_languages
|
| 1484 |
+
)
|
| 1485 |
+
)
|
| 1486 |
+
|
| 1487 |
+
def _get_language_description(self, transcript_strings):
|
| 1488 |
+
description = '\n'.join(' - {transcript}'.format(transcript=transcript) for transcript in transcript_strings)
|
| 1489 |
+
return description if description else 'None'
|
| 1490 |
+
|
| 1491 |
+
|
| 1492 |
+
class Transcript(object):
|
| 1493 |
+
def __init__(self, http_client, video_id, url, language, language_code, is_generated, translation_languages):
|
| 1494 |
+
"""
|
| 1495 |
+
You probably don't want to initialize this directly. Usually you'll access Transcript objects using a
|
| 1496 |
+
TranscriptList.
|
| 1497 |
+
|
| 1498 |
+
:param http_client: http client which is used to make the transcript retrieving http calls
|
| 1499 |
+
:type http_client: requests.Session
|
| 1500 |
+
:param video_id: the id of the video this TranscriptList is for
|
| 1501 |
+
:type video_id: str
|
| 1502 |
+
:param url: the url which needs to be called to fetch the transcript
|
| 1503 |
+
:param language: the name of the language this transcript uses
|
| 1504 |
+
:param language_code:
|
| 1505 |
+
:param is_generated:
|
| 1506 |
+
:param translation_languages:
|
| 1507 |
+
"""
|
| 1508 |
+
self._http_client = http_client
|
| 1509 |
+
self.video_id = video_id
|
| 1510 |
+
self._url = url
|
| 1511 |
+
self.language = language
|
| 1512 |
+
self.language_code = language_code
|
| 1513 |
+
self.is_generated = is_generated
|
| 1514 |
+
self.translation_languages = translation_languages
|
| 1515 |
+
self._translation_languages_dict = {
|
| 1516 |
+
translation_language['language_code']: translation_language['language']
|
| 1517 |
+
for translation_language in translation_languages
|
| 1518 |
+
}
|
| 1519 |
+
|
| 1520 |
+
def fetch(self, preserve_formatting=False):
|
| 1521 |
+
"""
|
| 1522 |
+
Loads the actual transcript data.
|
| 1523 |
+
:param preserve_formatting: whether to keep select HTML text formatting
|
| 1524 |
+
:type preserve_formatting: bool
|
| 1525 |
+
:return: a list of dictionaries containing the 'text', 'start' and 'duration' keys
|
| 1526 |
+
:rtype [{'text': str, 'start': float, 'end': float}]:
|
| 1527 |
+
"""
|
| 1528 |
+
response = self._http_client.get(self._url, headers={'Accept-Language': 'en-US'})
|
| 1529 |
+
return _TranscriptParser(preserve_formatting=preserve_formatting).parse(
|
| 1530 |
+
_raise_http_errors(response, self.video_id).text,
|
| 1531 |
+
)
|
| 1532 |
+
|
| 1533 |
+
def __str__(self):
|
| 1534 |
+
return '{language_code} ("{language}"){translation_description}'.format(
|
| 1535 |
+
language=self.language,
|
| 1536 |
+
language_code=self.language_code,
|
| 1537 |
+
translation_description='[TRANSLATABLE]' if self.is_translatable else ''
|
| 1538 |
+
)
|
| 1539 |
+
|
| 1540 |
+
@property
|
| 1541 |
+
def is_translatable(self):
|
| 1542 |
+
return len(self.translation_languages) > 0
|
| 1543 |
+
|
| 1544 |
+
def translate(self, language_code):
|
| 1545 |
+
if not self.is_translatable:
|
| 1546 |
+
raise NotTranslatableError(self.video_id)
|
| 1547 |
+
|
| 1548 |
+
if language_code not in self._translation_languages_dict:
|
| 1549 |
+
raise TranslationLanguageNotAvailableError(self.video_id)
|
| 1550 |
+
|
| 1551 |
+
return Transcript(
|
| 1552 |
+
self._http_client,
|
| 1553 |
+
self.video_id,
|
| 1554 |
+
'{url}&tlang={language_code}'.format(url=self._url, language_code=language_code),
|
| 1555 |
+
self._translation_languages_dict[language_code],
|
| 1556 |
+
language_code,
|
| 1557 |
+
True,
|
| 1558 |
+
[],
|
| 1559 |
+
)
|
| 1560 |
+
|
| 1561 |
+
|
| 1562 |
+
class _TranscriptParser(object):
|
| 1563 |
+
_FORMATTING_TAGS = [
|
| 1564 |
+
'strong', # important
|
| 1565 |
+
'em', # emphasized
|
| 1566 |
+
'b', # bold
|
| 1567 |
+
'i', # italic
|
| 1568 |
+
'mark', # marked
|
| 1569 |
+
'small', # smaller
|
| 1570 |
+
'del', # deleted
|
| 1571 |
+
'ins', # inserted
|
| 1572 |
+
'sub', # subscript
|
| 1573 |
+
'sup', # superscript
|
| 1574 |
+
]
|
| 1575 |
+
|
| 1576 |
+
def __init__(self, preserve_formatting=False):
|
| 1577 |
+
self._html_regex = self._get_html_regex(preserve_formatting)
|
| 1578 |
+
|
| 1579 |
+
def _get_html_regex(self, preserve_formatting):
|
| 1580 |
+
if preserve_formatting:
|
| 1581 |
+
formats_regex = '|'.join(self._FORMATTING_TAGS)
|
| 1582 |
+
formats_regex = r'<\/?(?!\/?(' + formats_regex + r')\b).*?\b>'
|
| 1583 |
+
html_regex = re.compile(formats_regex, re.IGNORECASE)
|
| 1584 |
+
else:
|
| 1585 |
+
html_regex = re.compile(r'<[^>]*>', re.IGNORECASE)
|
| 1586 |
+
return html_regex
|
| 1587 |
+
|
| 1588 |
+
def parse(self, plain_data):
|
| 1589 |
+
return [
|
| 1590 |
+
{
|
| 1591 |
+
'text': re.sub(self._html_regex, '', unescape(xml_element.text)),
|
| 1592 |
+
'start': float(xml_element.attrib['start']),
|
| 1593 |
+
'duration': float(xml_element.attrib.get('dur', '0.0')),
|
| 1594 |
+
}
|
| 1595 |
+
for xml_element in ElementTree.fromstring(plain_data)
|
| 1596 |
+
if xml_element.text is not None
|
| 1597 |
+
]
|
| 1598 |
+
|
| 1599 |
+
WATCH_URL = 'https://www.youtube.com/watch?v={video_id}'
|
| 1600 |
+
|
| 1601 |
+
class transcriber(object):
|
| 1602 |
+
@classmethod
|
| 1603 |
+
def list_transcripts(cls, video_id, proxies=None, cookies=None):
|
| 1604 |
+
with requests.Session() as http_client:
|
| 1605 |
+
if cookies:
|
| 1606 |
+
http_client.cookies = cls._load_cookies(cookies, video_id)
|
| 1607 |
+
http_client.proxies = proxies if proxies else {}
|
| 1608 |
+
return TranscriptListFetcher(http_client).fetch(video_id)
|
| 1609 |
+
|
| 1610 |
+
@classmethod
|
| 1611 |
+
def get_transcripts(cls, video_ids, languages=('en',), continue_after_error=False, proxies=None,
|
| 1612 |
+
cookies=None, preserve_formatting=False):
|
| 1613 |
+
|
| 1614 |
+
assert isinstance(video_ids, list), "`video_ids` must be a list of strings"
|
| 1615 |
+
|
| 1616 |
+
data = {}
|
| 1617 |
+
unretrievable_videos = []
|
| 1618 |
+
|
| 1619 |
+
for video_id in video_ids:
|
| 1620 |
+
try:
|
| 1621 |
+
data[video_id] = cls.get_transcript(video_id, languages, proxies, cookies, preserve_formatting)
|
| 1622 |
+
except Exception as exception:
|
| 1623 |
+
if not continue_after_error:
|
| 1624 |
+
raise exception
|
| 1625 |
+
|
| 1626 |
+
unretrievable_videos.append(video_id)
|
| 1627 |
+
|
| 1628 |
+
return data, unretrievable_videos
|
| 1629 |
+
|
| 1630 |
+
@classmethod
|
| 1631 |
+
def get_transcript(cls, video_id, languages=('en',), proxies=None, cookies=None, preserve_formatting=False):
|
| 1632 |
+
assert isinstance(video_id, str), "`video_id` must be a string"
|
| 1633 |
+
return cls.list_transcripts(video_id, proxies, cookies).find_transcript(languages).fetch(preserve_formatting=preserve_formatting)
|
| 1634 |
+
|
| 1635 |
+
@classmethod
|
| 1636 |
+
def _load_cookies(cls, cookies, video_id):
|
| 1637 |
+
try:
|
| 1638 |
+
cookie_jar = cookiejar.MozillaCookieJar()
|
| 1639 |
+
cookie_jar.load(cookies)
|
| 1640 |
+
if not cookie_jar:
|
| 1641 |
+
raise CookiesInvalidError(video_id)
|
| 1642 |
+
return cookie_jar
|
| 1643 |
+
except:
|
| 1644 |
+
raise CookiePathInvalidError(video_id)
|