#coding=utf-8 #!/usr/bin/python import re import json import html import time from urllib.parse import quote, unquote, parse_qs, urlencode, urlparse, urlunparse, urljoin import requests from requests.adapters import HTTPAdapter from urllib3.util.retry import Retry from base.spider import Spider YOUTUBE_CLASSES = [ {"type_id": "电影", "type_name": "电影"}, {"type_id": "直播", "type_name": "直播"}, {"type_id": "音乐", "type_name": "音乐"}, {"type_id": "HDR", "type_name": "HDR"}, {"type_id": "免费", "type_name": "免费"}, {"type_id": "短劇", "type_name": "短剧"}, {"type_id": "动画片", "type_name": "动画片"} ] CATEGORY_FILTERS = { "电影": [ {"key": "topic", "name": "官方分类", "value": [{"n": "电影", "v": "电影"}, {"n": "电影与动画", "v": "电影与动画"}, {"n": "电视节目", "v": "电视节目"}]}, {"key": "tid", "name": "地区", "value": [{"n": "大陆", "v": "大陆"}, {"n": "香港", "v": "香港"}, {"n": "台湾", "v": "台湾"}, {"n": "美国", "v": "美国"}, {"n": "韩国", "v": "韩国"}, {"n": "日本", "v": "日本"}]}, {"key": "date", "name": "排序/时间", "value": [{"n": "默认", "v": ""}, {"n": "最新", "v": "latest"}, {"n": "最热", "v": "hottest"}, {"n": "评分最高", "v": "favorite"}, {"n": "当天", "v": "day"}, {"n": "本周", "v": "week"}, {"n": "本月", "v": "month"}]} ], "直播": [ {"key": "topic", "name": "官方分类", "value": [{"n": "新闻直播", "v": "新闻直播"}, {"n": "游戏直播", "v": "游戏直播"}, {"n": "体育直播", "v": "体育直播"}, {"n": "音乐直播", "v": "音乐直播"}]}, {"key": "tid", "name": "直播类型", "value": [{"n": "全部直播", "v": "全部直播"}, {"n": "游戏", "v": "游戏"}, {"n": "新闻", "v": "新闻"}, {"n": "体育", "v": "体育"}, {"n": "音乐现场", "v": "音乐现场"}, {"n": "生活", "v": "生活"}]}, {"key": "date", "name": "排序/时间", "value": [{"n": "默认", "v": ""}, {"n": "最新", "v": "latest"}, {"n": "最热", "v": "hottest"}, {"n": "评分最高", "v": "favorite"}, {"n": "当天", "v": "day"}, {"n": "本周", "v": "week"}, {"n": "本月", "v": "month"}]} ], "音乐": [ {"key": "topic", "name": "官方分类", "value": [{"n": "音乐", "v": "音乐"}, {"n": "流行音乐", "v": "流行音乐"}, {"n": "摇滚乐", "v": "摇滚乐"}, {"n": "嘻哈音乐", "v": "嘻哈音乐"}, {"n": "电子音乐", "v": "电子音乐"}, {"n": "重金属", "v": "重金属"}, {"n": "重金属根源雷鬼", "v": "重金属根源雷鬼"}, {"n": "Dub", "v": "Dub"}, {"n": "Bass", "v": "Bass"}]}, {"key": "tid", "name": "形式/场景", "value": [{"n": "MV", "v": "MV"}, {"n": "现场", "v": "现场"}, {"n": "翻唱", "v": "翻唱"}, {"n": "精选", "v": "精选"}, {"n": "车载", "v": "车载"}]}, {"key": "date", "name": "排序/时间", "value": [{"n": "默认", "v": ""}, {"n": "最新", "v": "latest"}, {"n": "最热", "v": "hottest"}, {"n": "评分最高", "v": "favorite"}, {"n": "当天", "v": "day"}, {"n": "本周", "v": "week"}, {"n": "本月", "v": "month"}]} ], "HDR": [ {"key": "tid", "name": "画质类型", "value": [{"n": "HDR", "v": "HDR"}, {"n": "杜比", "v": "杜比"}, {"n": "4K", "v": "4K"}, {"n": "8K", "v": "8K"}, {"n": "12K", "v": "12K"}, {"n": "16K", "v": "16K"}]}, {"key": "topic", "name": "关联主题(加画质)", "value": [{"n": "自然+8K", "v": "自然+8K"}, {"n": "电影+4K", "v": "电影+4K"}, {"n": "风景+HDR", "v": "风景+HDR"}]}, {"key": "date", "name": "排序/时间", "value": [{"n": "默认", "v": ""}, {"n": "最新", "v": "latest"}, {"n": "最热", "v": "hottest"}, {"n": "评分最高", "v": "favorite"}, {"n": "当天", "v": "day"}, {"n": "本周", "v": "week"}, {"n": "本月", "v": "month"}]} ], "免费": [ {"key": "tid", "name": "免费内容", "value": [{"n": "免费电影", "v": "免费电影"}, {"n": "免费电视剧", "v": "免费电视剧"}, {"n": "免费纪录片", "v": "免费纪录片"}, {"n": "免费音乐", "v": "免费音乐"}, {"n": "免费课程", "v": "免费课程"}, {"n": "免费直播", "v": "免费直播"}]}, {"key": "topic", "name": "关联主题(加免费)", "value": [{"n": "电影+免费", "v": "电影+免费"}, {"n": "教育+免费", "v": "教育+免费"}, {"n": "音乐+免费", "v": "音乐+免费"}]}, {"key": "date", "name": "排序/时间", "value": [{"n": "默认", "v": ""}, {"n": "最新", "v": "latest"}, {"n": "最热", "v": "hottest"}, {"n": "评分最高", "v": "favorite"}, {"n": "当天", "v": "day"}, {"n": "本周", "v": "week"}, {"n": "本月", "v": "month"}]} ], "短劇": [ {"key": "date", "name": "排序/时间", "value": [{"n": "默认", "v": ""}, {"n": "最新", "v": "latest"}, {"n": "最热", "v": "hottest"}, {"n": "评分最高", "v": "favorite"}, {"n": "当天", "v": "day"}, {"n": "本周", "v": "week"}, {"n": "本月", "v": "month"}]}, {"key": "tid", "name": "地区/平台", "value": [{"n": "全部", "v": ""}, {"n": "抖音", "v": "抖音 短剧"}, {"n": "快手", "v": "快手 短剧"}, {"n": "大陆", "v": "大陆 短剧"}, {"n": "香港", "v": "香港 短剧"}, {"n": "澳門", "v": "澳門 短剧"}, {"n": "台湾", "v": "台湾 短剧"}, {"n": "新加坡", "v": "新加坡 短剧"}, {"n": "馬來西亞", "v": "馬來西亞 短剧"}, {"n": "泰國", "v": "泰國 短剧"}, {"n": "越南", "v": "越南 短剧"}, {"n": "印度", "v": "印度 短剧"}, {"n": "韩国", "v": "韩国 短剧"}, {"n": "日本", "v": "日本 短剧"}, {"n": "欧美", "v": "欧美 短剧"}, {"n": "腾讯", "v": "腾讯 短剧"}, {"n": "爱奇艺", "v": "爱奇艺 短剧"}, {"n": "优酷", "v": "优酷 短剧"}, {"n": "芒果", "v": "芒果TV 短剧"}, {"n": "搜狐", "v": "搜狐 短剧"}]}, {"key": "topic", "name": "类型/频道", "value": [{"n": "全部", "v": ""}, {"n": "都市", "v": "@Urbanshort-TV 都市 短劇"}, {"n": "爱情", "v": "爱情 短劇"}, {"n": "复仇", "v": "复仇 短劇"}, {"n": "霸总", "v": "霸总 短劇"}, {"n": "萌宝", "v": "萌宝 短劇"}, {"n": "古装", "v": "古装 短劇"}, {"n": "穿越", "v": "穿越 短劇"}, {"n": "喜剧", "v": "喜剧 短劇"}, {"n": "奇幻", "v": "奇幻 短劇"}, {"n": "九酱爱追剧", "v": "@NineSauceDramaTV"}, {"n": "百万好剧场", "v": "@1-pw5ox"}, {"n": "咖啡追剧", "v": "@@coffeedrama605"}, {"n": "斗罗短剧", "v": "@DouluoDrama123 斗羅短劇"}, {"n": "嘟嘟剧场", "v": "@DUDUJUCHANG"}, {"n": "牛牛短剧", "v": "@niuniuduanju"}]} ], "动画片": [ {"key": "date", "name": "排序/时间", "value": [{"n": "默认", "v": ""}, {"n": "最新", "v": "latest"}, {"n": "最热", "v": "hottest"}, {"n": "评分最高", "v": "favorite"}, {"n": "当天", "v": "day"}, {"n": "本周", "v": "week"}, {"n": "本月", "v": "month"}]}, {"key": "tid", "name": "平台/频道", "value": [{"n": "全部", "v": ""}, {"n": "小猪佩奇", "v": "@PeppaPigChineseOfficial 小猪佩奇 中文官方 - Peppa Pig"}, {"n": "CoComelon", "v": "@CoComelon"}, {"n": "国漫社", "v": "@Animation 次元 苍穹动漫 PP看动漫 公馆"}, {"n": "国漫工厂", "v": "@3DGuoman SUB"}, {"n": "阅文动漫", "v": "@yuewenanimation SUB"}, {"n": "哔哩", "v": "@madebybilibili 哔哩动漫"}, {"n": "腾讯", "v": "@TencentVideoAnimation SUB"}, {"n": "优酷", "v": "@youkuanimation 优酷动漫"}, {"n": "爱奇艺", "v": "@iQIYIAnime 爱奇艺动漫"}]}, {"key": "topic", "name": "主题/类别", "value": [{"n": "全部", "v": ""}, {"n": "默认中文国漫", "v": "國漫 劇集 3D"}, {"n": "默认动画", "v": "animation"}, {"n": "儿童早教", "v": "儿童早教"}, {"n": "儿童歌曲", "v": "儿童歌曲"}, {"n": "儿童音乐", "v": "儿童音乐"}, {"n": "儿童绘画", "v": "儿童绘画"}, {"n": "宝宝巴士", "v": "宝宝巴士"}, {"n": "儿歌多多", "v": "儿歌多多"}, {"n": "儿童英语启蒙", "v": "儿童英语启蒙"}, {"n": "儿童启蒙故事", "v": "儿童启蒙故事"}, {"n": "儿童安全教育", "v": "儿童安全教育"}, {"n": "默认英文国漫", "v": "3D Chinese cartoon"}, {"n": "合集", "v": "Anime ENG SUB 合集"}]} ] } class BaseExtractor: @staticmethod def extract_video_id(text): text = str(text or '').strip() for pattern in [ r'(?:v=|/v/|/embed/|/shorts/|youtu\.be/)([0-9A-Za-z_-]{11})', r'^([0-9A-Za-z_-]{11})$', ]: m = re.search(pattern, text) if m: return m.group(1) raise Exception('无法识别 YouTube 视频 ID') @staticmethod def _client_name_id(client_name): return { 'WEB': 1, 'MWEB': 2, 'ANDROID': 3, 'IOS': 5, 'TVHTML5': 7, 'ANDROID_VR': 28, 'WEB_EMBEDDED_PLAYER': 56, 'WEB_REMIX': 67, }.get(client_name, 1) @staticmethod def _extract_ytcfg(text): m = re.search(r'ytcfg\.set\s*\(\s*({.+?})\s*\)\s*;', text, re.S) if m: try: return json.loads(m.group(1)) except Exception: pass return None @staticmethod def _extract_json_after(text, marker): pos = text.find(marker) if pos < 0: return None start = text.find('{', pos) if start < 0: return None depth = 0 in_str = None escape = False for i in range(start, len(text)): ch = text[i] if escape: escape = False continue if ch == '\\': escape = True continue if in_str: if ch == in_str: in_str = None continue if ch == '"': in_str = ch continue if ch == '{': depth += 1 elif ch == '}': depth -= 1 if depth == 0: try: return json.loads(text[start:i+1]) except Exception: return None return None @staticmethod def _search(pattern, text, default=None): m = re.search(pattern, text or '', re.S) return m.group(1) if m else default @staticmethod def _extract_player_url(text): for pattern in [ r'"jsUrl":"([^"]+)"', r'"PLAYER_JS_URL":"([^"]+)"', r'(/s/player/[^"\\]+/base\.js)', ]: m = re.search(pattern, text) if m: return m.group(1).replace('\\/', '/') return '' class YouTubeLite(BaseExtractor): def __init__(self, session, headers=None, config=None): self.session = session self.headers = headers or {} self.config = config or {} self.player_cache = {} self.extract_cache = {} self.sig_plan_cache = {} self.n_function_cache = {} self.extract_cache_ttl = int(self.config.get('extract_cache_ttl') or 300) def extract(self, url_or_id): video_id = self.extract_video_id(url_or_id) cached = self.extract_cache.get(video_id) if cached and cached.get('expires', 0) > time.time(): return cached.get('data') watch_url = f"https://www.youtube.com/watch?v={video_id}" page = self._get(watch_url, timeout=60).text ytcfg = self._extract_ytcfg(page) or {} player_response = self._extract_initial_player_response(page) or {} player_url = self._extract_player_url(page) api_key = ytcfg.get('INNERTUBE_API_KEY') or self._search(r'"INNERTUBE_API_KEY":"([^"]+)"', page) visitor_data = ( self.config.get('visitor_data') or ytcfg.get('VISITOR_DATA') or (((ytcfg.get('INNERTUBE_CONTEXT') or {}).get('client') or {}).get('visitorData')) or ((player_response.get('responseContext') or {}).get('visitorData')) ) sts = self._extract_signature_timestamp(player_url) context = ytcfg.get('INNERTUBE_CONTEXT') or { 'client': {'clientName': 'WEB', 'clientVersion': '2.20240310.01.00', 'hl': 'en', 'gl': 'US'} } responses = [player_response] if player_response else [] if api_key: api_responses = self._call_player_api(video_id, api_key, context, watch_url, visitor_data, sts) if not isinstance(api_responses, list): api_responses = [api_responses] if api_responses else [] responses.extend([x for x in api_responses if x]) player_response = next((x for x in responses if (x.get('playabilityStatus') or {}).get('status') == 'OK'), player_response) status = (player_response.get('playabilityStatus') or {}).get('status') streaming = player_response.get('streamingData') or {} if status and status not in ('OK', 'LIVE_STREAM_OFFLINE') and not streaming: reason = (player_response.get('playabilityStatus') or {}).get('reason') or status raise Exception(f'YouTube 不可播放: {reason}') details = player_response.get('videoDetails') or {} raw_formats = [] seen_raw = set() for response in responses: response_streaming = (response or {}).get('streamingData') or {} source_raw = (response_streaming.get('formats') or []) + (response_streaming.get('adaptiveFormats') or []) for raw in source_raw: key = (raw.get('itag'), raw.get('url') or raw.get('signatureCipher') or raw.get('cipher') or raw.get('mimeType')) if key not in seen_raw: seen_raw.add(key) raw = raw.copy() raw['_client_name'] = (response or {}).get('_client_name') raw['_client_ua'] = (response or {}).get('_client_ua') raw_formats.append(raw) formats = [] for raw in raw_formats: item = self._normalize_format(raw, player_url) if item and item.get('url'): formats.append(item) if not formats: raise Exception('未获取到可用播放地址') data = { 'id': video_id, 'title': details.get('title') or video_id, 'duration': int(details.get('lengthSeconds') or 0), 'formats': formats, 'is_live': bool(details.get('isLive')), } self.extract_cache[video_id] = {'data': data, 'expires': time.time() + self.extract_cache_ttl} return data def _extract_signature_timestamp(self, player_url): try: code = self._get_player_code(player_url) sts = self._search(r'(?:signatureTimestamp|sts)\s*:\s*(\d{5})', code) return int(sts) if sts else None except Exception: return None def _get_po_token(self, client_name, context='gvs'): tokens = self.config.get('po_token') or self.config.get('po_tokens') or {} if isinstance(tokens, str): return tokens if isinstance(tokens, dict): return tokens.get(f'{client_name}.{context}') or tokens.get(client_name) or tokens.get(context) return None def _get(self, url, **kwargs): headers = self.headers.copy() headers.update(kwargs.pop('headers', {}) or {}) timeout = kwargs.pop('timeout', 60) r = self.session.get(url, headers=headers, timeout=timeout, **kwargs) r.raise_for_status() return r def _post_json(self, url, payload, headers=None): h = self.headers.copy() h.update({'Content-Type': 'application/json', 'Origin': 'https://www.youtube.com'}) if headers: h.update({k: v for k, v in headers.items() if v}) r = self.session.post(url, json=payload, headers=h, timeout=60) r.raise_for_status() return r.json() def _call_player_api(self, video_id, api_key, context, referer, visitor_data=None, sts=None): clients = [ {'client': {'clientName': 'ANDROID_VR', 'clientVersion': '1.65.10', 'deviceMake': 'Oculus', 'deviceModel': 'Quest 3', 'androidSdkVersion': 32, 'userAgent': 'com.google.android.apps.youtube.vr.oculus/1.65.10 (Linux; U; Android 12L; eureka-user Build/SQ3A.220605.009.A1) gzip', 'osName': 'Android', 'osVersion': '12L', 'hl': 'en', 'gl': 'US'}} ] results = [] fallback = None for ctx in clients: client_name = (ctx.get('client') or {}).get('clientName') try: url = f'https://www.youtube.com/youtubei/v1/player?key={api_key}&prettyPrint=false' payload = { 'context': ctx, 'videoId': video_id, 'playbackContext': {'contentPlaybackContext': {'html5Preference': 'HTML5_PREF_WANTS', **({'signatureTimestamp': sts} if sts else {})}}, 'contentCheckOk': True, 'racyCheckOk': True, } client = ctx.get('client') or {} headers = { 'Referer': referer, 'X-YouTube-Client-Name': str(self._client_name_id(client.get('clientName'))), 'X-YouTube-Client-Version': client.get('clientVersion') or '', } if visitor_data: headers['X-Goog-Visitor-Id'] = visitor_data client_ua = client.get('userAgent') if client_ua: headers['User-Agent'] = client_ua data = self._post_json(url, payload, headers=headers) streaming = data.get('streamingData') or {} if streaming: data['_client_name'] = client_name data['_client_ua'] = client_ua results.append(data) if streaming and fallback is None: fallback = data elif fallback is None: fallback = data except Exception: continue return results or ([fallback] if fallback else []) def _normalize_format(self, fmt, player_url): media_url = fmt.get('url') if not media_url: cipher = fmt.get('signatureCipher') or fmt.get('cipher') if cipher: media_url = self._decrypt_signature_cipher(cipher, player_url) if not media_url: return None media_url = self._decrypt_nsig(media_url, player_url) client_name = fmt.get('_client_name') po_token = self._get_po_token(client_name, 'gvs') if client_name else None if po_token: sep = '&' if '?' in media_url else '?' media_url = f'{media_url}{sep}pot={quote(po_token)}' mime = fmt.get('mimeType') or '' ext = 'mp4' if 'mp4' in mime else 'webm' if 'webm' in mime else 'unknown' codecs = self._search(r'codecs="([^"]+)"', mime) or '' has_audio = mime.startswith('audio/') or any(x in codecs for x in ('mp4a', 'opus', 'vorbis')) has_video = mime.startswith('video/') or any(x in codecs for x in ('avc', 'vp9', 'av01', 'h264')) headers = (fmt.get('http_headers') or {}).copy() if fmt.get('_client_ua'): headers['User-Agent'] = fmt.get('_client_ua') return { 'itag': fmt.get('itag'), 'url': media_url, 'mimeType': mime, 'client': fmt.get('_client_name'), 'ext': ext, 'width': fmt.get('width') or 0, 'height': fmt.get('height') or 0, 'fps': fmt.get('fps') or 0, 'bitrate': fmt.get('bitrate') or fmt.get('averageBitrate') or 0, 'contentLength': fmt.get('contentLength'), 'initRange': fmt.get('initRange') or {}, 'indexRange': fmt.get('indexRange') or {}, 'codecs': codecs, 'quality': fmt.get('qualityLabel') or fmt.get('quality'), 'vcodec': codecs if has_video else 'none', 'acodec': codecs if has_audio else 'none', 'headers': headers, } def _decrypt_signature_cipher(self, cipher, player_url): data = parse_qs(cipher) media_url = unquote(data.get('url', [''])[0]) sig = unquote(data.get('s', [''])[0]) sp = data.get('sp', ['sig'])[0] if not media_url: return '' if sig: decoded = self._decrypt_sig(sig, player_url) sep = '&' if '?' in media_url else '?' media_url = f'{media_url}{sep}{sp}={quote(decoded)}' return media_url def _decrypt_sig(self, sig, player_url): cache_key = player_url or '' if cache_key in self.sig_plan_cache: plan = self.sig_plan_cache[cache_key] else: code = self._get_player_code(player_url) plan = self._extract_sig_plan(code) self.sig_plan_cache[cache_key] = plan if not plan: return sig arr = list(sig) for op, arg in plan: if op == 'reverse': arr.reverse() elif op in ('slice', 'splice'): arr = arr[int(arg):] elif op == 'swap' and arr: j = int(arg) % len(arr) arr[0], arr[j] = arr[j], arr[0] return ''.join(arr) def _decrypt_nsig(self, media_url, player_url): try: parsed = urlparse(media_url) query = parse_qs(parsed.query) n_value = query.get('n', [None])[0] if not n_value: return media_url path_match = re.search(r'/n/([^/]+)', parsed.path) if path_match and path_match.group(1) != n_value: new_path = parsed.path.replace(f"/n/{path_match.group(1)}", f"/n/{n_value}", 1) return urlunparse(parsed._replace(path=new_path)) if player_url: func = self._get_n_function(player_url) if func: new_n = func(n_value) if new_n != n_value: query['n'] = [new_n] new_query = urlencode(query, doseq=True) return urlunparse(parsed._replace(query=new_query)) return media_url except Exception: return media_url def _get_n_function(self, player_url): if player_url in self.n_function_cache: return self.n_function_cache[player_url] code = self._get_player_code(player_url) func = self._extract_n_function(code) self.n_function_cache[player_url] = func return func def _get_player_code(self, player_url): if not player_url: return '' if player_url in self.player_cache: return self.player_cache[player_url] if player_url.startswith('//'): player_url = 'https:' + player_url elif player_url.startswith('/'): player_url = 'https://www.youtube.com' + player_url try: code = self._get(player_url, timeout=60).text except Exception: code = '' self.player_cache[player_url] = code return code def _extract_sig_plan(self, code): if not code: return None name = None for pattern in [ r'\.sig\|\|([a-zA-Z0-9_$]+)\(', r'"signature",\s*([a-zA-Z0-9_$]+)\(', r'([a-zA-Z0-9_$]+)=function\(a\)\{a=a\.split\(""\);', ]: m = re.search(pattern, code) if m: name = m.group(1) break if not name: return None body = self._extract_js_function_body(code, name) if not body: return None helper = self._search(r'([a-zA-Z0-9_$]+)\.[a-zA-Z0-9_$]+\(a,\d+\)', body) helper_map = self._extract_helper_object(code, helper) if helper else {} plan = [] for part in body.split(';'): if 'reverse()' in part: plan.append(('reverse', 0)) continue m = re.search(r'\.slice\((\d+)\)', part) if m: plan.append(('slice', int(m.group(1)))) continue m = re.search(r'\.splice\(0,(\d+)\)', part) if m: plan.append(('splice', int(m.group(1)))) continue m = re.search(r'([a-zA-Z0-9_$]+)\.([a-zA-Z0-9_$]+)\(a,(\d+)\)', part) if m and m.group(1) == helper: op = helper_map.get(m.group(2)) if op: plan.append((op, int(m.group(3)))) return plan or None def _extract_helper_object(self, code, name): if not name: return {} m = re.search(r'var\s+' + re.escape(name) + r'=\{(.+?)\};', code, re.S) or re.search(re.escape(name) + r'=\{(.+?)\};', code, re.S) if not m: return {} result = {} for method, body in re.findall(r'([a-zA-Z0-9_$]+):function\([a-z,]+\)\{(.*?)\}', m.group(1)): if '.reverse(' in body: result[method] = 'reverse' elif '.splice(' in body: result[method] = 'splice' elif '.slice(' in body: result[method] = 'slice' elif 'a[0]' in body and 'length' in body: result[method] = 'swap' return result def _extract_n_function(self, code): if not code: return None name = None for pattern in [ r'\.get\("n"\)\)&&\(b=([a-zA-Z0-9_$]+)(?:\[(\d+)\])?\(b\)', r'\.get\("n"\)\)&&\(b=([a-zA-Z0-9_$]+)\(b\)', r'([a-zA-Z0-9_$]+)=function\(a\)\{var b=a\.split\(""\)', r'function\s+([a-zA-Z0-9_$]+)\(a\)\{var b=a\.split\(""\)', r'([a-zA-Z0-9_$]+)=function\(a\)\{a=a\.split\(""\)', ]: m = re.search(pattern, code) if m: name = m.group(1) break if not name: return None body = self._extract_js_function_body(code, name) if not body: return None def transform(value): arr = list(value) for part in body.split(';'): if 'reverse()' in part: arr.reverse() m = re.search(r'\.slice\((\d+)\)', part) if m: arr = arr[int(m.group(1)):] m = re.search(r'\.splice\(0,(\d+)\)', part) if m: arr = arr[int(m.group(1)):] return ''.join(arr) or value return transform def _extract_js_function_body(self, code, name): starts = [] for pattern in [ r'function\s+' + re.escape(name) + r'\s*\([^)]*\)\s*\{', re.escape(name) + r'\s*=\s*function\s*\([^)]*\)\s*\{', r'var\s+' + re.escape(name) + r'\s*=\s*function\s*\([^)]*\)\s*\{', ]: m = re.search(pattern, code) if m: starts.append(m.end() - 1) if not starts: return '' start = starts[0] depth = 0 in_str = None escape = False for i in range(start, len(code)): ch = code[i] if escape: escape = False continue if ch == '\\': escape = True continue if in_str: if ch == in_str: in_str = None continue if ch in ('"', "'", '`'): in_str = ch continue if ch == '{': depth += 1 elif ch == '}': depth -= 1 if depth == 0: return code[start + 1:i] return '' def _extract_initial_player_response(self, text): return self._extract_json_after(text, 'ytInitialPlayerResponse') class YouTubeLiveLite(BaseExtractor): def __init__(self, session, headers=None, config=None): self.session = session self.headers = headers or {} self.config = config or {} self.cache = {} self.cache_ttl = int(self.config.get('live_cache_ttl') or 45) def extract_live(self, url_or_id): video_id = self.extract_video_id(url_or_id) now = time.time() cached = self.cache.get(video_id) if cached and cached.get('expires', 0) > now: return cached.get('data') watch_url = f'https://www.youtube.com/watch?v={video_id}' page = self._get(watch_url, timeout=60).text player_response = self._extract_initial_player_response(page) or {} ytcfg = self._extract_ytcfg(page) or {} api_key = ytcfg.get('INNERTUBE_API_KEY') or self._search(r'"INNERTUBE_API_KEY":"([^"]+)"', page) visitor_data = ( self.config.get('visitor_data') or ytcfg.get('VISITOR_DATA') or (((ytcfg.get('INNERTUBE_CONTEXT') or {}).get('client') or {}).get('visitorData')) or ((player_response.get('responseContext') or {}).get('visitorData')) ) status_obj = player_response.get('playabilityStatus') or {} streaming = player_response.get('streamingData') or {} details = player_response.get('videoDetails') or {} page_hls_url = streaming.get('hlsManifestUrl') or '' api_data = None if api_key: api_data = self._call_player_api(video_id, api_key, ytcfg, watch_url, visitor_data) if api_data: api_streaming = api_data.get('streamingData') or {} api_details = api_data.get('videoDetails') or {} api_hls_url = api_streaming.get('hlsManifestUrl') or '' if api_hls_url: streaming = api_streaming elif not page_hls_url and api_streaming: streaming = api_streaming if api_details: details = api_details status_obj = api_data.get('playabilityStatus') or status_obj if not (streaming.get('hlsManifestUrl') or '') and page_hls_url: streaming = dict(streaming or {}) streaming['hlsManifestUrl'] = page_hls_url hls_url = streaming.get('hlsManifestUrl') or '' is_live = bool(details.get('isLive') or hls_url) status = status_obj.get('status') or '' reason = status_obj.get('reason') or '' title = details.get('title') or video_id data = { 'id': video_id, 'title': title, 'is_live': is_live, 'status': status, 'reason': reason, 'hls_url': hls_url, 'duration': int(details.get('lengthSeconds') or 0), } self.cache[video_id] = {'data': data, 'expires': time.time() + self.cache_ttl} return data def _get(self, url, **kwargs): headers = self.headers.copy() headers.update(kwargs.pop('headers', {}) or {}) timeout = kwargs.pop('timeout', 60) response = self.session.get(url, headers=headers, timeout=timeout, **kwargs) response.raise_for_status() return response def _post_json(self, url, payload, headers=None): final_headers = self.headers.copy() final_headers.update({'Content-Type': 'application/json', 'Origin': 'https://www.youtube.com'}) if headers: final_headers.update({k: v for k, v in headers.items() if v}) response = self.session.post(url, json=payload, headers=final_headers, timeout=60) response.raise_for_status() return response.json() def _call_player_api(self, video_id, api_key, ytcfg, referer, visitor_data=None): context = ytcfg.get('INNERTUBE_CONTEXT') or { 'client': {'clientName': 'WEB', 'clientVersion': '2.20240310.01.00', 'hl': 'en', 'gl': 'US'} } clients = [ {'client': {'clientName': 'ANDROID_VR', 'clientVersion': '1.65.10', 'deviceMake': 'Oculus', 'deviceModel': 'Quest 3', 'androidSdkVersion': 32, 'userAgent': 'com.google.android.apps.youtube.vr.oculus/1.65.10 (Linux; U; Android 12L; eureka-user Build/SQ3A.220605.009.A1) gzip', 'osName': 'Android', 'osVersion': '12L', 'hl': 'en', 'gl': 'US'}} ] for ctx in clients: client = ctx.get('client') or {} client_name = client.get('clientName') or 'WEB' try: url = f'https://www.youtube.com/youtubei/v1/player?key={quote(api_key)}&prettyPrint=false' headers = { 'Referer': referer, 'X-YouTube-Client-Name': str(self._client_name_id(client_name)), 'X-YouTube-Client-Version': client.get('clientVersion') or '', } if visitor_data: headers['X-Goog-Visitor-Id'] = visitor_data if client.get('userAgent'): headers['User-Agent'] = client.get('userAgent') payload = { 'context': ctx, 'videoId': video_id, 'contentCheckOk': True, 'racyCheckOk': True, } data = self._post_json(url, payload, headers=headers) streaming = data.get('streamingData') or {} if streaming.get('hlsManifestUrl'): data['_client_name'] = client_name return data except Exception: continue return None def _extract_initial_player_response(self, text): return self._extract_json_after(text, 'ytInitialPlayerResponse') class Spider(Spider): def getName(self): return 'YouTube 视频+直播' def init(self, extend): try: self.extendDict = json.loads(extend) if extend else {} except Exception: self.extendDict = {} self.session = requests.Session() retry = Retry( total=3, backoff_factor=1, status_forcelist=[500, 502, 503, 504, 408, 429], allowed_methods=["GET", "POST"] ) adapter = HTTPAdapter(max_retries=retry, pool_connections=20, pool_maxsize=20) self.session.mount('http://', adapter) self.session.mount('https://', adapter) self.session.keep_alive = False self._cache = {} proxy_config = self.extendDict.get('proxy') if proxy_config: if isinstance(proxy_config, str): proxy_str = proxy_config.strip() if proxy_str: if not proxy_str.startswith(('http://', 'https://')): proxy_str = 'http://' + proxy_str self.session.proxies = {'http': proxy_str, 'https': proxy_str} elif isinstance(proxy_config, dict): proxies = {} for k, v in proxy_config.items(): if k in ('http', 'https') and v: v_str = v.strip() if v_str: if not v_str.startswith(('http://', 'https://')): v_str = 'http://' + v_str proxies[k] = v_str if proxies: self.session.proxies = proxies else: self.session.proxies = {} else: self.session.proxies = {} self.header = { 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36', 'Accept-Language': 'zh-CN,zh;q=0.9,en;q=0.8', 'Referer': 'https://www.youtube.com/' } self.session.headers.update(self.header) self.yt_video = YouTubeLite(self.session, self.header, self.extendDict) self.yt_live = YouTubeLiveLite(self.session, self.header, self.extendDict) self.search_page_cache = {} self.live_search_cache = {} self.hls_url_cache = {} self.hls_proxy_enabled = self.extendDict.get('hls_proxy', True) is not False self._hls_key_seq = 0 def setCache(self, key, value): self._cache[key] = value def getCache(self, key): return self._cache.get(key) def homeContent(self, filter): result = {'class': YOUTUBE_CLASSES} if filter: video_filters = {} for c in YOUTUBE_CLASSES: cid = c['type_id'] if cid in CATEGORY_FILTERS: video_filters[cid] = CATEGORY_FILTERS[cid] result['filters'] = video_filters return result def homeVideoContent(self): return {'list': []} def categoryContent(self, cid, page, filter, ext): page = int(page or 1) filters = ext if isinstance(ext, dict) else {} if self._is_live_category(cid): keyword = self._build_live_keyword(cid, filters) videos, has_more = self._search_live_page(keyword, page) else: keyword = self._build_video_keyword(cid, filters) videos, has_more = self._search_video_page(keyword, page) return { 'list': videos, 'page': page, 'pagecount': page + 1 if has_more else page, 'limit': len(videos), 'total': len(videos) } def searchContent(self, key, quick, pg=1): page = int(pg or 1) keyword = str(key or '').strip() videos_v, _ = self._search_video_page(keyword, page) live_keyword = f'{keyword} live' if 'live' not in keyword.lower() and '直播' not in keyword else keyword videos_l, _ = self._search_live_page(live_keyword, page) seen = set() merged = [] for v in videos_v + videos_l: if v['vod_id'] not in seen: seen.add(v['vod_id']) merged.append(v) return { 'list': merged[:30], 'page': page, 'pagecount': page + 1, 'limit': len(merged), 'total': len(merged) } def detailContent(self, did): video_id = did[0] is_live = False title = video_id status = '视频' has_super = False try: data = self.yt_video.extract(video_id) title = data.get('title') or video_id is_live = data.get('is_live', False) status = '直播中' if is_live else '视频' if not is_live: formats = data.get('formats') or [] all_videos = [x for x in formats if x.get('vcodec') != 'none' and x.get('acodec') == 'none'] high_res = [x for x in all_videos if int(x.get('height') or 0) >= 720] has_super = len(high_res) > 0 except Exception: is_live = False title = self._get_video_title(video_id) or video_id status = '视频' related = [] try: r = self.session.get(f'https://www.youtube.com/watch?v={video_id}', timeout=60) related = self._extract_videos_fixed(r.text, 20) except Exception: pass safe_title = self._safe_title(title) play_sources = [] play_urls = [] if is_live: play_sources.append('直播') play_urls.append(f'{safe_title}${video_id}@live') else: if has_super: play_sources.append('极客VP') play_urls.append(f'{safe_title}${video_id}@super') play_sources.append('万能MP') play_urls.append(f'{safe_title}${video_id}@normal') if related: play_sources.append('相关推荐') play_url2 = '#'.join([f"{self._safe_title(v['vod_name'])}${v['vod_id']}@normal" for v in related if v.get('vod_id') != video_id]) play_urls.append(play_url2) vod = { 'vod_id': video_id, 'vod_name': title, 'vod_pic': f'http://127.0.0.1:9978/proxy?do=py&type=image&vid={video_id}', 'vod_remarks': status, 'vod_play_from': '$$$'.join(play_sources), 'vod_play_url': '$$$'.join(play_urls) } return {'list': [vod]} def playerContent(self, flag, pid, vipFlags): raw_pid = pid.split('$')[-1] if '@' in raw_pid: video_id, quality_or_type = raw_pid.rsplit('@', 1) else: video_id, quality_or_type = raw_pid, 'super' if quality_or_type == 'live': return self._play_live(video_id) else: return self._play_video(video_id, quality_or_type) def _is_live_category(self, cid): return 'live' in cid.lower() or '直播' in cid.lower() def _build_live_keyword(self, cid, filters=None): terms = [cid] if isinstance(filters, dict): for value in filters.values(): term = self._normalize_filter_term(value) if term: terms.append(term) keyword = ' '.join(terms).strip() if 'live' not in keyword.lower() and '直播' not in keyword: keyword = f'{keyword} live' return keyword def _build_video_keyword(self, cid, filters=None): if cid.startswith('LIST:'): raw = cid[5:].strip() channels = [ch.strip() for ch in raw.split(',') if ch.strip()] terms = [] for ch in channels: if ch.startswith('@'): terms.append(f'channel:{ch}') else: terms.append(f'"{ch}"') keyword = ' OR '.join(terms) if terms else '' else: keyword = cid if isinstance(filters, dict): for value in filters.values(): term = self._normalize_filter_term(value) if term: keyword += ' ' + term return keyword.strip() def _normalize_filter_term(self, value): if isinstance(value, (list, tuple)): return ' '.join([self._normalize_filter_term(item) for item in value if item]) if isinstance(value, dict): return ' '.join([self._normalize_filter_term(item) for item in value.values() if item]) return re.sub(r'\s+', ' ', str(value or '')).strip()[:180] def _search_cache_key(self, key): return re.sub(r'\s+', ' ', str(key or '')).strip().lower() def _search_video_page(self, key, page=1): page = max(1, int(page or 1)) cache_key = self._search_cache_key(key) session = self.search_page_cache.get(cache_key) if page == 1 or not session: session = self._fetch_search_first_page(key) self.search_page_cache[cache_key] = session while len(session.get('pages', [])) < page and session.get('next'): data = self._fetch_search_continuation(session) videos = self._extract_videos_from_api(data, 30) session.setdefault('pages', []).append(videos) session['next'] = self._extract_continuation_token(data) pages = session.get('pages', []) videos = pages[page - 1] if len(pages) >= page else [] has_more = bool(session.get('next')) or len(pages) > page return videos, has_more def _search_live_page(self, key, page=1): page = max(1, int(page or 1)) cache_key = f'live_{self._search_cache_key(key)}' session = self.live_search_cache.get(cache_key) if page == 1 or not session: session = self._fetch_live_search_first_page(key) self.live_search_cache[cache_key] = session while len(session.get('pages', [])) < page and session.get('next'): data = self._fetch_search_continuation(session) videos = self._extract_live_videos_from_api(data, 30) session.setdefault('pages', []).append(videos) session['next'] = self._extract_continuation_token(data) pages = session.get('pages', []) videos = pages[page - 1] if len(pages) >= page else [] has_more = bool(session.get('next')) or len(pages) > page return videos, has_more def _fetch_live_search_first_page(self, key): search_url = f'https://www.youtube.com/results?search_query={quote(str(key or ""))}&sp=EgJAAQ%253D%253D' r = self.session.get(search_url, timeout=60) html_str = r.text data = self.yt_video._extract_json_after(html_str, 'ytInitialData') or {} ytcfg = self.yt_video._extract_ytcfg(html_str) or {} api_key = ytcfg.get('INNERTUBE_API_KEY') or self.yt_video._search(r'"INNERTUBE_API_KEY":"([^"]+)"', html_str) context = ytcfg.get('INNERTUBE_CONTEXT') or {'client': {'clientName': 'WEB', 'clientVersion': '2.20240310.01.00', 'hl': 'zh-CN', 'gl': 'US'}} client = context.get('client') or {} return { 'key': key, 'api_key': api_key, 'context': context, 'client_name': client.get('clientName') or 'WEB', 'client_version': client.get('clientVersion') or '2.20240310.01.00', 'referer': search_url, 'pages': [self._extract_live_videos_from_api(data, 30)], 'next': self._extract_continuation_token(data), } def _fetch_search_first_page(self, key): search_url = f'https://www.youtube.com/results?search_query={quote(str(key or ""))}' r = self.session.get(search_url, timeout=60) html_str = r.text data = self.yt_video._extract_json_after(html_str, 'ytInitialData') or {} ytcfg = self.yt_video._extract_ytcfg(html_str) or {} api_key = ytcfg.get('INNERTUBE_API_KEY') or self.yt_video._search(r'"INNERTUBE_API_KEY":"([^"]+)"', html_str) context = ytcfg.get('INNERTUBE_CONTEXT') or {'client': {'clientName': 'WEB', 'clientVersion': '2.20240310.01.00', 'hl': 'zh-CN', 'gl': 'US'}} client = context.get('client') or {} return { 'key': key, 'api_key': api_key, 'context': context, 'client_name': client.get('clientName') or 'WEB', 'client_version': client.get('clientVersion') or '2.20240310.01.00', 'referer': search_url, 'pages': [self._extract_videos_from_api(data, 30)], 'next': self._extract_continuation_token(data), } def _fetch_search_continuation(self, session): token = session.get('next') api_key = session.get('api_key') if not token or not api_key: return {} url = f'https://www.youtube.com/youtubei/v1/search?key={quote(api_key)}' headers = self.header.copy() headers.update({ 'Content-Type': 'application/json', 'Origin': 'https://www.youtube.com', 'Referer': session.get('referer') or 'https://www.youtube.com/', 'X-YouTube-Client-Name': str(self.yt_video._client_name_id(session.get('client_name'))), 'X-YouTube-Client-Version': session.get('client_version') or '2.20240310.01.00', }) payload = {'context': session.get('context') or {}, 'continuation': token} r = self.session.post(url, json=payload, headers=headers, timeout=60) r.raise_for_status() return r.json() def _extract_continuation_token(self, data): tokens = [] def scan(obj): if isinstance(obj, dict): endpoint = obj.get('continuationEndpoint') or {} token = endpoint.get('continuationCommand', {}).get('token') if token: tokens.append(token) renderer = obj.get('continuationItemRenderer') or {} token = renderer.get('continuationEndpoint', {}).get('continuationCommand', {}).get('token') if token: tokens.append(token) for value in obj.values(): scan(value) elif isinstance(obj, list): for value in obj: scan(value) scan(data) return tokens[0] if tokens else '' def _extract_videos_from_api(self, data, limit=30): videos = [] seen = set() def scan(obj): if len(videos) >= limit: return if isinstance(obj, dict): for key in ('videoRenderer', 'compactVideoRenderer', 'gridVideoRenderer'): if key in obj: item = self._parse_renderer(obj[key], is_live=False) if item and item['vod_id'] not in seen: seen.add(item['vod_id']) videos.append(item) for value in obj.values(): scan(value) elif isinstance(obj, list): for value in obj: scan(value) scan(data) return videos[:limit] def _extract_live_videos_from_api(self, data, limit=30): videos = [] seen = set() def scan(obj): if len(videos) >= limit: return if isinstance(obj, dict): for key in ('videoRenderer', 'compactVideoRenderer', 'gridVideoRenderer'): if key in obj: item = self._parse_renderer(obj[key], is_live=True) if item and item['vod_id'] not in seen: seen.add(item['vod_id']) videos.append(item) for value in obj.values(): scan(value) elif isinstance(obj, list): for value in obj: scan(value) scan(data) return videos[:limit] def _parse_renderer(self, renderer, is_live=False): try: vid = renderer.get('videoId') if not vid: nav = renderer.get('navigationEndpoint') or {} vid = (nav.get('watchEndpoint') or {}).get('videoId') if not vid: return None is_actually_live = False badges = renderer.get('badges', []) for badge in badges: if isinstance(badge, dict): text = badge.get('text', '') or badge.get('label', '') or '' if '直播' in text or 'LIVE' in text.upper(): is_actually_live = True break if not is_live and is_actually_live: return None title_obj = renderer.get('title') or renderer.get('headline') or {} title = title_obj.get('simpleText') or ''.join([x.get('text', '') for x in title_obj.get('runs', [])]) or 'YouTube Video' dur = '' if not is_actually_live: dur_obj = renderer.get('lengthText') or {} dur = dur_obj.get('simpleText') or '' remarks = '直播' if is_actually_live else (dur if dur else '视频') return { 'vod_id': vid, 'vod_name': html.unescape(title), 'vod_pic': f'http://127.0.0.1:9978/proxy?do=py&type=image&vid={vid}', 'vod_remarks': remarks } except Exception: return None def _extract_videos_fixed(self, html_str, limit=30): data = None match = re.search(r'var ytInitialData = (\{.*?\});', html_str) if match: try: data = json.loads(match.group(1)) except Exception: data = None if not data: return [] return self._extract_videos_from_api(data, limit) def _get_video_title(self, vid): try: r = self.session.get(f'https://www.youtube.com/oembed?url=https://www.youtube.com/watch?v={vid}&format=json', timeout=30) return r.json().get('title') or vid except Exception: return vid def _safe_title(self, title): if not title: return 'video' return re.sub(r'[#$@%&!?*|\\/:<>]', ' ', title)[:60] def _play_video(self, video_id, quality): try: data = self.yt_video.extract(video_id) formats = data.get('formats') or [] all_videos = [x for x in formats if x.get('vcodec') != 'none' and x.get('acodec') == 'none'] def filter_and_sort_videos(videos): filtered = [v for v in videos if int(v.get('height') or 0) >= 720] filtered.sort(key=lambda x: (int(x.get('height') or 0), int(x.get('bitrate') or 0)), reverse=True) seen_res = {} result = [] for v in filtered: h = int(v.get('height') or 0) if h not in seen_res: seen_res[h] = True result.append(v) return result webm_videos = filter_and_sort_videos([x for x in all_videos if x.get('ext') == 'webm']) mp4_videos = filter_and_sort_videos([x for x in all_videos if x.get('ext') == 'mp4']) if quality == 'normal': target_videos = mp4_videos if mp4_videos else webm_videos else: target_videos = webm_videos if webm_videos else mp4_videos audio_candidates = [x for x in formats if x.get('acodec') != 'none' and x.get('vcodec') == 'none'] audio_candidates.sort( key=lambda x: (1 if x.get('ext') == 'mp4' else 0, int(x.get('bitrate') or 0)), reverse=True ) audio = audio_candidates[0] if audio_candidates else None if not target_videos: raise Exception('未获取到可用视频流') cache_key = f'yt_{video_id}_{quality}' all_cached = {} for v in target_videos: all_cached[str(v.get('itag'))] = v if audio: all_cached[str(audio.get('itag'))] = audio self.setCache(cache_key, { 'target_videos': target_videos, 'audio_item': audio, 'all_by_itag': all_cached, 'duration': data.get('duration') or 0, 'expires': time.time() + 600, }) return { 'parse': 0, 'jx': 0, 'url': f'http://127.0.0.1:9978/proxy?do=py&type=mpd&vid={video_id}&quality={quality}', 'format': 'application/dash+xml' } except Exception: return { 'parse': 1, 'url': f'https://www.youtube.com/embed/{video_id}?autoplay=1', 'header': json.dumps(self.header) } def _proxy_mpd(self, params): vid = params.get('vid') quality = params.get('quality') or 'super' data = self.getCache(f'yt_{vid}_{quality}') if vid else None if not data: return [404, 'text/plain', '视频缓存已过期或不存在'] target_videos = data.get('target_videos') or [] audio_item = data.get('audio_item') or {} duration = data.get('duration') or 0 duration_pt = f"PT{int(duration)}S" media_base = f'http://127.0.0.1:9978/proxy?do=py&type=media&vid={vid}&quality={quality}' def build_video_repr(item, rank): itag = item.get('itag', 0) init = item.get('initRange') or {} index = item.get('indexRange') or {} return ( f' \n' f' {html.escape(media_base + f"&itag={itag}&track=video")}\n' f' ' f'\n' f' ' ) mpd = ( f'\n' f'\n' f' ' ) if target_videos: mime_type = "video/webm" if target_videos[0].get('ext') == 'webm' else "video/mp4" label = "极客VP" if mime_type == "video/webm" else "万能MP" mpd += f'\n \n' mpd += f' \n' for rank, v in enumerate(target_videos, start=1): mpd += build_video_repr(v, rank) + '\n' mpd += ' ' if audio_item: audio_itag = audio_item.get('itag', 0) audio_init = audio_item.get('initRange') or {} audio_index = audio_item.get('indexRange') or {} audio_mime = (audio_item.get('mimeType') or 'audio/mp4').split(';')[0] mpd += ( f'\n \n' f' \n' f' {html.escape(media_base + f"&itag={audio_itag}&track=audio")}\n' f' ' f'\n' f' \n' f' ' ) mpd += '\n \n' return [200, 'application/dash+xml', mpd] def _proxy_media(self, params): vid = params.get('vid') quality = params.get('quality') or 'super' itag = params.get('itag') track = params.get('track') data = self.getCache(f'yt_{vid}_{quality}') if vid else None if not data: return [404, 'text/plain', '媒体缓存不存在或已过期'] all_by_itag = data.get('all_by_itag') or {} media_item = None if itag and str(itag) in all_by_itag: media_item = all_by_itag[str(itag)] elif track == 'audio': media_item = data.get('audio_item') if not media_item: return [404, 'text/plain', f'流不存在 (itag={itag})'] target_url = media_item.get('url') if not target_url: return [404, 'text/plain', '播放地址不存在'] headers = self.header.copy() headers.update(media_item.get('headers') or {}) range_header = params.get('range') or params.get('Range') if range_header: headers['Range'] = range_header max_retries = 3 for attempt in range(max_retries + 1): try: r = self.session.get(target_url, headers=headers, stream=True, timeout=120) if r.status_code in (403, 404) and attempt < max_retries: try: fresh_data = self.yt_video.extract(vid) fresh_formats = fresh_data.get('formats', []) fresh_item = None for f in fresh_formats: if str(f.get('itag')) == str(itag): fresh_item = f break if fresh_item and fresh_item.get('url'): media_item['url'] = fresh_item['url'] target_url = fresh_item['url'] all_by_itag[str(itag)] = fresh_item data['all_by_itag'] = all_by_itag self.setCache(f'yt_{vid}_{quality}', data) continue except Exception: pass time.sleep(1) continue content_type = r.headers.get('content-type', 'application/octet-stream') resp_headers = { 'Content-Type': content_type, 'Accept-Ranges': 'bytes', 'Cache-Control': 'no-cache', } if r.headers.get('content-range'): resp_headers['Content-Range'] = r.headers.get('content-range') if r.headers.get('content-length'): resp_headers['Content-Length'] = r.headers.get('content-length') return [r.status_code, content_type, r.content, resp_headers] except Exception as e: if attempt == max_retries: return [500, 'text/plain', f'代理媒体失败: {str(e)}'] time.sleep(1) return [500, 'text/plain', '代理媒体失败'] def _play_live(self, video_id): try: data = self.yt_live.extract_live(video_id) hls_url = data.get('hls_url') or '' if not hls_url: raise Exception(data.get('reason') or '未获取到直播 HLS 地址') if self.extendDict.get('hls_probe'): self._probe_hls(video_id, hls_url) play_url = hls_url if self.hls_proxy_enabled: play_url = self._cache_hls_url(hls_url, video_id, 'master') return { 'parse': 0, 'jx': 0, 'url': play_url, 'header': self.header, 'format': 'application/x-mpegURL' } except Exception: return {'parse': 1, 'jx': 1, 'url': f'https://www.youtube.com/embed/{video_id}?autoplay=1'} def _probe_hls(self, video_id, hls_url): try: response = self.session.get(hls_url, headers=self.header, timeout=60) full_text = response.text or '' variant_url = self._pick_variant_playlist(hls_url, full_text) if variant_url: self.session.get(variant_url, headers=self.header, timeout=60) except Exception: pass def _pick_variant_playlist(self, base_url, text): lines = [line.strip() for line in (text or '').splitlines()] best_score = -1 best_url = '' for index, line in enumerate(lines): if not line.startswith('#EXT-X-STREAM-INF'): continue score = 0 bandwidth = re.search(r'BANDWIDTH=(\d+)', line) resolution = re.search(r'RESOLUTION=(\d+)x(\d+)', line) if bandwidth: score += int(bandwidth.group(1)) if resolution: score += int(resolution.group(1)) * int(resolution.group(2)) for next_line in lines[index + 1:]: if not next_line or next_line.startswith('#'): continue if score > best_score: best_score = score best_url = urljoin(base_url, next_line) break return best_url HLS_TTL = {'master': 6 * 3600, 'playlist': 6 * 3600, 'media': 120, 'media_retry': 120} def _hls_ttl(self, kind): return self.HLS_TTL.get(kind, 180) def _prune_hls_cache(self): now = time.time() expired = [k for k, v in self.hls_url_cache.items() if v.get('expires', 0) < now] for k in expired: self.hls_url_cache.pop(k, None) def _cache_hls_url(self, target_url, video_id='', kind='media'): self._prune_hls_cache() self._hls_key_seq += 1 key = f'{int(time.time() * 1000)}_{self._hls_key_seq}' self.hls_url_cache[key] = { 'url': target_url, 'video_id': video_id, 'kind': kind, 'expires': time.time() + self._hls_ttl(kind), } return f'http://127.0.0.1:9978/proxy?do=py&type=hls&key={quote(key)}' def _hls_headers(self, target_url, kind=None): if kind == 'media_retry': return { 'User-Agent': 'com.google.android.youtube/21.02.35 (Linux; U; Android 11) gzip', 'Accept': '*/*', } headers = self.header.copy() headers['Accept'] = '*/*' if kind in ('master', 'playlist'): headers['Origin'] = 'https://www.youtube.com' headers['Referer'] = 'https://www.youtube.com/' elif kind == 'media': headers['User-Agent'] = 'com.google.android.youtube/21.02.35 (Linux; U; Android 11) gzip' headers.pop('Origin', None) headers.pop('Referer', None) return headers def _rewrite_m3u8(self, text, base_url, video_id=''): output = [] for line in (text or '').splitlines(): stripped = line.strip() if not stripped: output.append(line) continue if stripped.startswith('#'): output.append(self._rewrite_m3u8_tag(line, base_url, video_id)) continue absolute = urljoin(base_url, stripped) kind = 'playlist' if stripped.endswith('.m3u8') or '/hls_playlist/' in stripped else 'media' output.append(self._cache_hls_url(absolute, video_id, kind)) return '\n'.join(output) + '\n' def _rewrite_m3u8_tag(self, line, base_url, video_id=''): def replace_uri(match): raw_url = match.group(1) absolute = urljoin(base_url, raw_url) proxied = self._cache_hls_url(absolute, video_id, 'media') return f'URI="{proxied}"' return re.sub(r'URI="([^"]+)"', replace_uri, line) def _proxy_hls(self, params): key = params.get('key') or '' item = self.hls_url_cache.get(key) if not item or item.get('expires', 0) < time.time(): return [404, 'text/plain', 'HLS 缓存已过期'] item['expires'] = time.time() + self._hls_ttl(item.get('kind')) target_url = item.get('url') or '' video_id = item.get('video_id', '') try: headers = self._hls_headers(target_url, item.get('kind')) max_retries = 3 for attempt in range(max_retries + 1): try: response = self.session.get(target_url, headers=headers, stream=True, timeout=120) if response.status_code in (403, 404) and attempt < max_retries: if video_id and item.get('kind') in ('master', 'playlist'): try: live_data = self.yt_live.extract_live(video_id) new_hls = live_data.get('hls_url') if new_hls: item['url'] = new_hls target_url = new_hls self.hls_url_cache[key] = item continue except Exception: pass time.sleep(1) continue content_type = response.headers.get('content-type') or '' is_m3u8 = item.get('kind') in ('master', 'playlist') or 'mpegurl' in content_type.lower() or target_url.split('?')[0].endswith('.m3u8') if is_m3u8: text = response.text rewritten = self._rewrite_m3u8(text, target_url, video_id) return [response.status_code, 'application/vnd.apple.mpegurl', rewritten, {'Content-Type': 'application/vnd.apple.mpegurl', 'Cache-Control': 'no-cache'}] resp_headers = {'Content-Type': content_type or 'application/octet-stream', 'Cache-Control': 'no-cache'} if response.headers.get('content-length'): resp_headers['Content-Length'] = response.headers.get('content-length') return [response.status_code, content_type or 'application/octet-stream', response.content, resp_headers] except Exception as e: if attempt == max_retries: raise time.sleep(1) except Exception as e: return [500, 'text/plain', f'HLS 代理失败: {str(e)}'] return [500, 'text/plain', 'HLS 代理失败'] def localProxy(self, params): if params.get('do') != 'py': return None typ = params.get('type') if typ == 'mpd': return self._proxy_mpd(params) if typ == 'media': return self._proxy_media(params) if typ == 'single': return self._proxy_single(params) if typ == 'image': return self._proxy_image(params) if typ == 'hls': return self._proxy_hls(params) return None def _proxy_image(self, params): vid = params.get('vid') if not vid: return [400, 'text/plain', '缺少 video id'] quality = params.get('quality', 'hqdefault') img_url = f'https://i.ytimg.com/vi/{vid}/{quality}.jpg' try: r = self.session.get(img_url, timeout=30) if r.status_code == 200: content_type = r.headers.get('content-type', 'image/jpeg') return [200, content_type, r.content, {'Cache-Control': 'max-age=86400'}] else: return [404, 'text/plain', f'图片不存在 ({r.status_code})'] except Exception: return [500, 'text/plain', '代理图片失败'] def _proxy_single(self, params): vid = params.get('vid') data = self.getCache(f'yt_single_{vid}') if vid else None if not data: return [404, 'text/plain', '播放缓存已过期或不存在'] target_url = data.get('url') if not target_url: return [404, 'text/plain', '播放地址不存在'] headers = (data.get('headers') or self.header).copy() range_header = params.get('range') or params.get('Range') if range_header: headers['Range'] = range_header try: r = self.session.get(target_url, headers=headers, stream=True, timeout=120) content_type = r.headers.get('content-type', 'video/mp4') resp_headers = { 'Content-Type': content_type, 'Accept-Ranges': 'bytes', 'Cache-Control': 'no-cache', } if r.headers.get('content-range'): resp_headers['Content-Range'] = r.headers.get('content-range') if r.headers.get('content-length'): resp_headers['Content-Length'] = r.headers.get('content-length') return [r.status_code, content_type, r.content, resp_headers] except Exception: return [500, 'text/plain', '代理播放失败'] def destroy(self): try: self.session.close() except Exception: pass