Sync all projects
This commit is contained in:
@@ -0,0 +1,1573 @@
|
||||
#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
|
||||
Reference in New Issue
Block a user