1573 lines
70 KiB
Python
1573 lines
70 KiB
Python
#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' <Representation id="v{itag}" bandwidth="{item.get("bitrate", 1000000)}" '
|
|
f'codecs="{html.escape(item.get("codecs") or "")}" '
|
|
f'height="{item.get("height", 0)}" width="{item.get("width", 0)}" '
|
|
f'frameRate="{item.get("fps", 30)}" qualityRanking="{rank}">\n'
|
|
f' <BaseURL>{html.escape(media_base + f"&itag={itag}&track=video")}</BaseURL>\n'
|
|
f' <SegmentBase indexRange="{index.get("start", "0")}-{index.get("end", "0")}">'
|
|
f'<Initialization range="{init.get("start", "0")}-{init.get("end", "0")}"/></SegmentBase>\n'
|
|
f' </Representation>'
|
|
)
|
|
|
|
mpd = (
|
|
f'<?xml version="1.0" encoding="UTF-8"?>\n'
|
|
f'<MPD xmlns="urn:mpeg:dash:schema:mpd:2011" type="static" '
|
|
f'mediaPresentationDuration="{duration_pt}" minBufferTime="PT1.5S" '
|
|
f'profiles="urn:mpeg:dash:profile:isoff-on-demand:2011">\n'
|
|
f' <Period id="1" start="PT0S">'
|
|
)
|
|
|
|
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 <AdaptationSet mimeType="{mime_type}" startWithSAP="1" segmentAlignment="true" scanType="progressive">\n'
|
|
mpd += f' <Label>{label}</Label>\n'
|
|
for rank, v in enumerate(target_videos, start=1):
|
|
mpd += build_video_repr(v, rank) + '\n'
|
|
mpd += ' </AdaptationSet>'
|
|
|
|
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 <AdaptationSet mimeType="{html.escape(audio_mime)}" startWithSAP="1" segmentAlignment="true" lang="und">\n'
|
|
f' <Representation id="a{audio_itag}" bandwidth="{audio_item.get("bitrate", 128000)}" '
|
|
f'codecs="{html.escape(audio_item.get("codecs") or "")}" audioSamplingRate="44100">\n'
|
|
f' <BaseURL>{html.escape(media_base + f"&itag={audio_itag}&track=audio")}</BaseURL>\n'
|
|
f' <SegmentBase indexRange="{audio_index.get("start", "0")}-{audio_index.get("end", "0")}">'
|
|
f'<Initialization range="{audio_init.get("start", "0")}-{audio_init.get("end", "0")}"/></SegmentBase>\n'
|
|
f' </Representation>\n'
|
|
f' </AdaptationSet>'
|
|
)
|
|
|
|
mpd += '\n </Period>\n</MPD>'
|
|
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 |