4062 lines
186 KiB
Python
4062 lines
186 KiB
Python
#coding=utf-8
|
||
#!/usr/bin/python
|
||
import re
|
||
import os
|
||
import sys
|
||
import json
|
||
import html
|
||
import time
|
||
import socket
|
||
import hashlib
|
||
from urllib.parse import quote, unquote, parse_qs, urlencode, urlparse, urlunparse, urljoin
|
||
|
||
import requests
|
||
from base.spider import Spider
|
||
|
||
sys.path.append('..')
|
||
|
||
# ---------- 日志路径 ----------
|
||
# 置空 = 关闭日志(不再每次调用都抛 NameError)
|
||
# 需要排障时改成:
|
||
DEBUG_LOG = '/storage/emulated/0/源码/ytb_debug.log'
|
||
#DEBUG_LOG = ''
|
||
|
||
|
||
def _ensure_log_dir():
|
||
if not DEBUG_LOG:
|
||
return
|
||
try:
|
||
log_dir = os.path.dirname(DEBUG_LOG)
|
||
if log_dir and not os.path.exists(log_dir):
|
||
os.makedirs(log_dir, exist_ok=True)
|
||
except:
|
||
pass
|
||
_ensure_log_dir()
|
||
|
||
def debug_log(message, data=None):
|
||
if not DEBUG_LOG:
|
||
return
|
||
try:
|
||
log_dir = os.path.dirname(DEBUG_LOG)
|
||
if log_dir and not os.path.exists(log_dir):
|
||
os.makedirs(log_dir, exist_ok=True)
|
||
line = f"[{time.strftime('%Y-%m-%d %H:%M:%S')}] {message}"
|
||
if data is not None:
|
||
if isinstance(data, (dict, list)):
|
||
line += ' ' + json.dumps(data, ensure_ascii=False, default=str)
|
||
else:
|
||
line += ' ' + str(data)
|
||
with open(DEBUG_LOG, 'a', encoding='utf-8') as f:
|
||
f.write(line + '\n')
|
||
except Exception:
|
||
pass
|
||
|
||
# ==================== 从 youtube.json 加载分类和过滤器 ====================
|
||
YOUTUBE_CLASSES = [
|
||
{"type_name": "推荐", "type_id": "YouTube 直播 24小時"},
|
||
{"type_id": "YouTube 新聞 Live", "type_name": "新闻"},
|
||
{"type_id": "劇集", "type_name": "剧集"},
|
||
{"type_id": "電影", "type_name": "电影"},
|
||
{"type_id": "动画片", "type_name": "动画片"},
|
||
{"type_id": "綜藝", "type_name": "综艺"},
|
||
{"type_id": "短劇", "type_name": "短剧"},
|
||
{"type_id": "紀錄片", "type_name": "纪录片"},
|
||
{"type_id": "體育", "type_name": "体育"},
|
||
{"type_id": "音樂", "type_name": "音乐"},
|
||
{"type_id": "放松", "type_name": "放松"},
|
||
{"type_id": "時尚潮流", "type_name": "时尚潮流"},
|
||
{"type_id": "宇宙", "type_name": "科普"},
|
||
{"type_id": "科技", "type_name": "科技"},
|
||
{"type_id": "解說", "type_name": "解说"},
|
||
{"type_id": "神秘", "type_name": "神秘"},
|
||
{"type_id": "4K", "type_name": "4K"},
|
||
{"type_id": "16K HDR", "type_name": "16K HDR"},
|
||
{"type_id": "LIST:自媒體 We Media,零度解说 @lingdujieshuo,老高與小茉 @laogao,李子柒 Liziqi @cnliziqi,康1+1 @user-mr5bh4bk8z,不良林,涌哥侃侃 @ygkkk,悟空的日常,Learn English with EnglishClass101.com,Speak English With Vanessa,Tangerine Academy,听笙阁 @tingshengge,李永樂老師 @TchLiyongle,滇西小哥 @dianxixiaoge,脑洞乌托邦 @NDWTB,自说自话的总裁 @STBoss,老肉雜談 @老肉雜談,老饭骨 @LaoFanGu,小高姐的 Magic Ingredients @MagicIngredients,小穎美食 @XiaoYingFood,primitivetechnology9550 @primitivetechnology9550,Mr Beast@MrBeast,Airforceproud95 @Airforceproud95,TheGreatWar @TheGreatWar,Mark Rober @MarkRober", "type_name": "自媒体"}
|
||
]
|
||
CATEGORY_FILTERS = {
|
||
"YouTube 直播 24小時": [
|
||
{
|
||
"key": "tid",
|
||
"name": "中文",
|
||
"value": [
|
||
{"n": "默认", "v": "鳳凰衛視 @phoenixtv 中天新闻不斷電直播"},
|
||
{"n": "鳳凰衛視", "v": "大事發生看鳳凰 鳳凰 鳳凰衛視 @phoenixtv"},
|
||
{"n": "中天新闻", "v": "直播 中天新闻"}
|
||
]
|
||
}
|
||
],
|
||
"短劇": [
|
||
{
|
||
"key": "time",
|
||
"name": "時間",
|
||
"value": [
|
||
{"n": "時間全選", "v": ""},
|
||
{"n": "2025", "v": "2025"},
|
||
{"n": "2024", "v": "2024"},
|
||
{"n": "2023", "v": "2023"},
|
||
{"n": "2022", "v": "2022"},
|
||
{"n": "2021", "v": "2021"},
|
||
{"n": "2020", "v": "2020"},
|
||
{"n": "2019", "v": "2019"},
|
||
{"n": "2018", "v": "2018"},
|
||
{"n": "2017", "v": "2017"},
|
||
{"n": "2016", "v": "2016"},
|
||
{"n": "2015", "v": "2015"},
|
||
{"n": "2014", "v": "2014"},
|
||
{"n": "2013", "v": "2013"},
|
||
{"n": "2012", "v": "2012"},
|
||
{"n": "2011", "v": "2011"},
|
||
{"n": "2010", "v": "2010"},
|
||
{"n": "2009", "v": "2009"},
|
||
{"n": "2008", "v": "2008"},
|
||
{"n": "2007", "v": "2007"},
|
||
{"n": "2006", "v": "2006"},
|
||
{"n": "2005", "v": "2005"},
|
||
{"n": "2004", "v": "2004"},
|
||
{"n": "2003", "v": "2003"},
|
||
{"n": "2002", "v": "2002"},
|
||
{"n": "2001", "v": "2001"},
|
||
{"n": "2000", "v": "2000"},
|
||
{"n": "1999", "v": "1999"},
|
||
{"n": "1998", "v": "1998"},
|
||
{"n": "1997", "v": "1997"},
|
||
{"n": "1996", "v": "1996"},
|
||
{"n": "1995", "v": "1995"},
|
||
{"n": "1994", "v": "1994"},
|
||
{"n": "1993", "v": "1993"},
|
||
{"n": "1992", "v": "1992"},
|
||
{"n": "1991", "v": "1991"},
|
||
{"n": "1990", "v": "1990"},
|
||
{"n": "1989", "v": "1989"},
|
||
{"n": "1988", "v": "1988"},
|
||
{"n": "1987", "v": "1987"},
|
||
{"n": "1986", "v": "1986"},
|
||
{"n": "1985", "v": "1985"},
|
||
{"n": "1984", "v": "1984"},
|
||
{"n": "1983", "v": "1983"},
|
||
{"n": "1982", "v": "1982"},
|
||
{"n": "1981", "v": "1981"},
|
||
{"n": "1980", "v": "1980"},
|
||
{"n": "1979", "v": "1979"},
|
||
{"n": "1978", "v": "1978"},
|
||
{"n": "1977", "v": "1977"},
|
||
{"n": "1976", "v": "1976"},
|
||
{"n": "1975", "v": "1975"},
|
||
{"n": "1974", "v": "1974"},
|
||
{"n": "1973", "v": "1973"},
|
||
{"n": "1972", "v": "1972"},
|
||
{"n": "1971", "v": "1971"},
|
||
{"n": "1970", "v": "1970"},
|
||
{"n": "1969", "v": "1969"},
|
||
{"n": "1968", "v": "1968"},
|
||
{"n": "1967", "v": "1967"},
|
||
{"n": "1966", "v": "1966"},
|
||
{"n": "1965", "v": "1965"},
|
||
{"n": "1964", "v": "1964"},
|
||
{"n": "1963", "v": "1963"},
|
||
{"n": "1962", "v": "1962"},
|
||
{"n": "1961", "v": "1961"},
|
||
{"n": "1960", "v": "1960"},
|
||
{"n": "1959", "v": "1959"},
|
||
{"n": "1958", "v": "1958"}
|
||
]
|
||
},
|
||
{
|
||
"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": "芒果TV 短剧"},
|
||
{"n": "搜狐", "v": "搜狐 短剧"}
|
||
]
|
||
},
|
||
{
|
||
"key": "tid",
|
||
"name": "短劇",
|
||
"value": [
|
||
{"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": "time",
|
||
"name": "時間",
|
||
"value": [
|
||
{"n": "時間全選", "v": ""},
|
||
{"n": "2024", "v": "2024"},
|
||
{"n": "2023", "v": "2023"},
|
||
{"n": "2022", "v": "2022"},
|
||
{"n": "2021", "v": "2021"},
|
||
{"n": "2020", "v": "2020"},
|
||
{"n": "2019", "v": "2019"},
|
||
{"n": "2018", "v": "2018"},
|
||
{"n": "2017", "v": "2017"},
|
||
{"n": "2016", "v": "2016"},
|
||
{"n": "2015", "v": "2015"},
|
||
{"n": "2014", "v": "2014"},
|
||
{"n": "2013", "v": "2013"},
|
||
{"n": "2012", "v": "2012"},
|
||
{"n": "2011", "v": "2011"},
|
||
{"n": "2010", "v": "2010"},
|
||
{"n": "2009", "v": "2009"},
|
||
{"n": "2008", "v": "2008"},
|
||
{"n": "2007", "v": "2007"},
|
||
{"n": "2006", "v": "2006"},
|
||
{"n": "2005", "v": "2005"},
|
||
{"n": "2004", "v": "2004"},
|
||
{"n": "2003", "v": "2003"},
|
||
{"n": "2002", "v": "2002"},
|
||
{"n": "2001", "v": "2001"},
|
||
{"n": "2000", "v": "2000"},
|
||
{"n": "1999", "v": "1999"},
|
||
{"n": "1998", "v": "1998"},
|
||
{"n": "1997", "v": "1997"},
|
||
{"n": "1996", "v": "1996"},
|
||
{"n": "1995", "v": "1995"},
|
||
{"n": "1994", "v": "1994"},
|
||
{"n": "1993", "v": "1993"},
|
||
{"n": "1992", "v": "1992"},
|
||
{"n": "1991", "v": "1991"},
|
||
{"n": "1990", "v": "1990"},
|
||
{"n": "1989", "v": "1989"},
|
||
{"n": "1988", "v": "1988"},
|
||
{"n": "1987", "v": "1987"},
|
||
{"n": "1986", "v": "1986"},
|
||
{"n": "1985", "v": "1985"},
|
||
{"n": "1984", "v": "1984"},
|
||
{"n": "1983", "v": "1983"},
|
||
{"n": "1982", "v": "1982"},
|
||
{"n": "1981", "v": "1981"},
|
||
{"n": "1980", "v": "1980"},
|
||
{"n": "1979", "v": "1979"},
|
||
{"n": "1978", "v": "1978"},
|
||
{"n": "1977", "v": "1977"},
|
||
{"n": "1976", "v": "1976"},
|
||
{"n": "1975", "v": "1975"},
|
||
{"n": "1974", "v": "1974"},
|
||
{"n": "1973", "v": "1973"},
|
||
{"n": "1972", "v": "1972"},
|
||
{"n": "1971", "v": "1971"},
|
||
{"n": "1970", "v": "1970"},
|
||
{"n": "1969", "v": "1969"},
|
||
{"n": "1968", "v": "1968"},
|
||
{"n": "1967", "v": "1967"},
|
||
{"n": "1966", "v": "1966"},
|
||
{"n": "1965", "v": "1965"},
|
||
{"n": "1964", "v": "1964"},
|
||
{"n": "1963", "v": "1963"},
|
||
{"n": "1962", "v": "1962"},
|
||
{"n": "1961", "v": "1961"},
|
||
{"n": "1960", "v": "1960"},
|
||
{"n": "1959", "v": "1959"},
|
||
{"n": "1958", "v": "1958"}
|
||
]
|
||
},
|
||
{
|
||
"key": "tid",
|
||
"name": "中文",
|
||
"value": [
|
||
{"n": "默认中文国漫", "v": "國漫 劇集 3D"},
|
||
{"n": "默认", "v": "animation"},
|
||
{"n": "儿童早教", "v": "儿童早教"},
|
||
{"n": "儿童歌曲", "v": "儿童歌曲"},
|
||
{"n": "儿童音乐", "v": "儿童音乐"},
|
||
{"n": "儿童绘画", "v": "儿童绘画"},
|
||
{"n": "宝宝巴士", "v": "宝宝巴士"},
|
||
{"n": "儿歌多多", "v": "儿歌多多"},
|
||
{"n": "儿童英语启蒙", "v": "儿童英语启蒙"},
|
||
{"n": "儿童启蒙故事", "v": "儿童启蒙故事"},
|
||
{"n": "儿童安全教育", "v": "儿童安全教育"}
|
||
]
|
||
},
|
||
{
|
||
"key": "tid",
|
||
"name": "English",
|
||
"value": [
|
||
{"n": "默认英文国漫", "v": "3D Chinese cartoon"},
|
||
{"n": "小猪佩奇", "v": "@PeppaPigChineseOfficial 小猪佩奇 中文官方 - Peppa Pig"},
|
||
{"n": "CoComelon", "v": "@CoComelon"},
|
||
{"n": "合集", "v": "Anime ENG SUB 合集"},
|
||
{"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 爱奇艺动漫"}
|
||
]
|
||
}
|
||
],
|
||
"YouTube 新聞 Live": [
|
||
{
|
||
"key": "tid",
|
||
"name": "中文",
|
||
"value": [
|
||
{"n": "默认", "v": "中天新闻24小時 大事發生看鳳凰 鳳凰 鳳凰衛視 @phoenixtv 三立LIVE新闻 台視新聞 東森新聞 TVBSNEWS"},
|
||
{"n": "鳳凰衛視", "v": "大事發生看鳳凰 鳳凰 鳳凰衛視 @phoenixtv"},
|
||
{"n": "中天新闻", "v": "直播 中天新闻"},
|
||
{"n": "TVBSEWS", "v": "直播 TVBSNEWS"},
|
||
{"n": "東森新聞 CH51", "v": "直播 東森新聞 CH51"},
|
||
{"n": "三立LIVE新闻", "v": "直播 三立LIVE新闻 @setnews "},
|
||
{"n": "台視新聞台HD", "v": "直播 台視新聞 TTV NEWS"},
|
||
{"n": "中視新聞 HD", "v": "直播 Taiwan CTV news HD Live"},
|
||
{"n": "港台", "v": "直播 港台"},
|
||
{"n": "赛事", "v": "直播 赛事"},
|
||
{"n": "CCTV", "v": "直播 CCTV"},
|
||
{"n": "CNA", "v": "@channelnewsasia"}
|
||
]
|
||
},
|
||
{
|
||
"key": "tid",
|
||
"name": "English",
|
||
"value": [
|
||
{"n": "Live", "v": "live"},
|
||
{"n": "CNN", "v": "live CNN"},
|
||
{"n": "BBC", "v": "live BBC"},
|
||
{"n": "CBS", "v": "@CBSNews"},
|
||
{"n": "ABC", "v": "live ABC NEWS"},
|
||
{"n": "NBC", "v": "@NBCNews"},
|
||
{"n": "LiveNOW from FOX", "v": "@livenowfox"},
|
||
{"n": "Sky News", "v": "@SkyNews"},
|
||
{"n": "euronews", "v": "@euronews"},
|
||
{"n": "GBNews", "v": "@GBNewsOnline"},
|
||
{"n": "Al Jazeera English", "v": "@aljazeeraenglish"},
|
||
{"n": "ABCNews (Australia)", "v": "@abcnewsaustralia"},
|
||
{"n": "FRANCE 24 English", "v": "live FRANCE 24 English"},
|
||
{"n": "Reuters", "v": "live Reuters"}
|
||
]
|
||
},
|
||
{
|
||
"key": "tid",
|
||
"name": "中文",
|
||
"value": [
|
||
{"n": "默认", "v": "News"},
|
||
{"n": "News", "v": "news"},
|
||
{"n": "时政", "v": "时政 新闻"},
|
||
{"n": "体育", "v": "体育 新闻"},
|
||
{"n": "大陆", "v": "大陆 新闻"},
|
||
{"n": "HKTVB", "v": "@tvbnewsofficial"},
|
||
{"n": "少康戰情室", "v": " @tvbssituationroom"},
|
||
{"n": "政经龙凤配", "v": " @觀點"},
|
||
{"n": "FOCUS全球新闻", "v": " @TVBSNEWS FOCUS全球新闻"},
|
||
{"n": "新闻大白话", "v": " @TVBSNEWS 新闻大白话"},
|
||
{"n": "东森新闻", "v": "关键时刻新闻"},
|
||
{"n": "港台", "v": "港台 新闻"}
|
||
]
|
||
},
|
||
{
|
||
"key": "tid",
|
||
"name": "English",
|
||
"value": [
|
||
{"n": "科技與發展", "v": "閱兵 奧運會 航母 航空母艦 潛水艇 核武器 坦克 武器 卫星 火箭 輪船 飛機 飛碟"},
|
||
{"n": "法治与社会", "v": "法治 法制 社会 卖淫 淫秽 污蔑 赌博 毒品 裸聊 诈骗 拐卖 强奸 勒索"},
|
||
{"n": "News", "v": "News"},
|
||
{"n": "CNN", "v": "CNN news"},
|
||
{"n": "BBC", "v": "BBC news"}
|
||
]
|
||
}
|
||
],
|
||
"劇集": [
|
||
{
|
||
"key": "time",
|
||
"name": "時間",
|
||
"value": [
|
||
{"n": "時間全選", "v": ""},
|
||
{"n": "2024", "v": "2024"},
|
||
{"n": "2023", "v": "2023"},
|
||
{"n": "2022", "v": "2022"},
|
||
{"n": "2021", "v": "2021"},
|
||
{"n": "2020", "v": "2020"},
|
||
{"n": "2019", "v": "2019"},
|
||
{"n": "2018", "v": "2018"},
|
||
{"n": "2017", "v": "2017"},
|
||
{"n": "2016", "v": "2016"},
|
||
{"n": "2015", "v": "2015"},
|
||
{"n": "2014", "v": "2014"},
|
||
{"n": "2013", "v": "2013"},
|
||
{"n": "2012", "v": "2012"},
|
||
{"n": "2011", "v": "2011"},
|
||
{"n": "2010", "v": "2010"},
|
||
{"n": "2009", "v": "2009"},
|
||
{"n": "2008", "v": "2008"},
|
||
{"n": "2007", "v": "2007"},
|
||
{"n": "2006", "v": "2006"},
|
||
{"n": "2005", "v": "2005"},
|
||
{"n": "2004", "v": "2004"},
|
||
{"n": "2003", "v": "2003"},
|
||
{"n": "2002", "v": "2002"},
|
||
{"n": "2001", "v": "2001"},
|
||
{"n": "2000", "v": "2000"},
|
||
{"n": "1999", "v": "1999"},
|
||
{"n": "1998", "v": "1998"},
|
||
{"n": "1997", "v": "1997"},
|
||
{"n": "1996", "v": "1996"},
|
||
{"n": "1995", "v": "1995"},
|
||
{"n": "1994", "v": "1994"},
|
||
{"n": "1993", "v": "1993"},
|
||
{"n": "1992", "v": "1992"},
|
||
{"n": "1991", "v": "1991"},
|
||
{"n": "1990", "v": "1990"},
|
||
{"n": "1989", "v": "1989"},
|
||
{"n": "1988", "v": "1988"},
|
||
{"n": "1987", "v": "1987"},
|
||
{"n": "1986", "v": "1986"},
|
||
{"n": "1985", "v": "1985"},
|
||
{"n": "1984", "v": "1984"},
|
||
{"n": "1983", "v": "1983"},
|
||
{"n": "1982", "v": "1982"},
|
||
{"n": "1981", "v": "1981"},
|
||
{"n": "1980", "v": "1980"},
|
||
{"n": "1979", "v": "1979"},
|
||
{"n": "1978", "v": "1978"},
|
||
{"n": "1977", "v": "1977"},
|
||
{"n": "1976", "v": "1976"},
|
||
{"n": "1975", "v": "1975"},
|
||
{"n": "1974", "v": "1974"},
|
||
{"n": "1973", "v": "1973"},
|
||
{"n": "1972", "v": "1972"},
|
||
{"n": "1971", "v": "1971"},
|
||
{"n": "1970", "v": "1970"},
|
||
{"n": "1969", "v": "1969"},
|
||
{"n": "1968", "v": "1968"},
|
||
{"n": "1967", "v": "1967"},
|
||
{"n": "1966", "v": "1966"},
|
||
{"n": "1965", "v": "1965"},
|
||
{"n": "1964", "v": "1964"},
|
||
{"n": "1963", "v": "1963"},
|
||
{"n": "1962", "v": "1962"},
|
||
{"n": "1961", "v": "1961"},
|
||
{"n": "1960", "v": "1960"},
|
||
{"n": "1959", "v": "1959"},
|
||
{"n": "1958", "v": "1958"}
|
||
]
|
||
},
|
||
{
|
||
"key": "tid",
|
||
"name": "中文",
|
||
"value": [
|
||
{"n": "默認", "v": ""},
|
||
{"n": "華語熱播電視劇官方頻道", "v": "華語熱播電視劇官方頻道"},
|
||
{"n": "TVB国语", "v": "TVB Drama 国语剧场"},
|
||
{"n": "粵劇", "v": "粵劇 劇集"},
|
||
{"n": "TVB", "v": "@TVB"},
|
||
{"n": "国剧放映社", "v": "国剧放映社"},
|
||
{"n": "大陆", "v": "大陆 剧集"},
|
||
{"n": "腾讯", "v": "腾讯 剧集"},
|
||
{"n": "爱奇艺", "v": "爱奇艺 剧集"},
|
||
{"n": "优酷", "v": "优酷 剧集"},
|
||
{"n": "芒果", "v": "芒果TV 剧集"},
|
||
{"n": "搜狐", "v": "搜狐 剧集"},
|
||
{"n": "华数", "v": "华数 剧集"},
|
||
{"n": "港台", "v": "港台 剧集"},
|
||
{"n": "美国", "v": "美国 Full Episode 完整剧集"},
|
||
{"n": "Netflix", "v": "Netflix Full Episode 完整剧集"},
|
||
{"n": "Disney", "v": "disney Full Episode 完整剧集"},
|
||
{"n": "Apple", "v": "apple Full Episode 完整剧集"},
|
||
{"n": "Amazon", "v": "amazon Full Episode 完整剧集"},
|
||
{"n": "HBO", "v": "hbo Full Episode 完整剧集"},
|
||
{"n": "韩国", "v": "韩国 剧集"},
|
||
{"n": "日本", "v": "日本 剧集"},
|
||
{"n": "英国", "v": "英国 Full Episode 完整剧集"}
|
||
]
|
||
},
|
||
{
|
||
"key": "tid",
|
||
"name": "English",
|
||
"value": [
|
||
{"n": "Drama", "v": "Full Episode drama"},
|
||
{"n": "US", "v": "drama Full Episode US"},
|
||
{"n": "Netflix", "v": "netflix Full Episode drama"},
|
||
{"n": "Disney", "v": "disney Full Episode drama"},
|
||
{"n": "Apple", "v": "apple Full Episode drama"},
|
||
{"n": "Amazon", "v": "amazon Full Episode drama"},
|
||
{"n": "HBO", "v": "hbo Full Episode drama"},
|
||
{"n": "Korea", "v": "korea Full Episode drama"},
|
||
{"n": "Japan", "v": "japan Full Episode drama"},
|
||
{"n": "UK", "v": "uk Full Episode drama"}
|
||
]
|
||
}
|
||
],
|
||
"電影": [
|
||
{
|
||
"key": "time",
|
||
"name": "時間",
|
||
"value": [
|
||
{"n": "時間全選", "v": ""},
|
||
{"n": "2026", "v": "2026"},
|
||
{"n": "2025", "v": "2025"},
|
||
{"n": "2024", "v": "2024"},
|
||
{"n": "2023", "v": "2023"},
|
||
{"n": "2022", "v": "2022"},
|
||
{"n": "2021", "v": "2021"},
|
||
{"n": "2020", "v": "2020"},
|
||
{"n": "2019", "v": "2019"},
|
||
{"n": "2018", "v": "2018"},
|
||
{"n": "2017", "v": "2017"},
|
||
{"n": "2016", "v": "2016"},
|
||
{"n": "2015", "v": "2015"},
|
||
{"n": "2014", "v": "2014"},
|
||
{"n": "2013", "v": "2013"},
|
||
{"n": "2012", "v": "2012"},
|
||
{"n": "2011", "v": "2011"},
|
||
{"n": "2010", "v": "2010"},
|
||
{"n": "2009", "v": "2009"},
|
||
{"n": "2008", "v": "2008"},
|
||
{"n": "2007", "v": "2007"},
|
||
{"n": "2006", "v": "2006"},
|
||
{"n": "2005", "v": "2005"},
|
||
{"n": "2004", "v": "2004"},
|
||
{"n": "2003", "v": "2003"},
|
||
{"n": "2002", "v": "2002"},
|
||
{"n": "2001", "v": "2001"},
|
||
{"n": "2000", "v": "2000"},
|
||
{"n": "1999", "v": "1999"},
|
||
{"n": "1998", "v": "1998"},
|
||
{"n": "1997", "v": "1997"},
|
||
{"n": "1996", "v": "1996"},
|
||
{"n": "1995", "v": "1995"},
|
||
{"n": "1994", "v": "1994"},
|
||
{"n": "1993", "v": "1993"},
|
||
{"n": "1992", "v": "1992"},
|
||
{"n": "1991", "v": "1991"},
|
||
{"n": "1990", "v": "1990"},
|
||
{"n": "1989", "v": "1989"},
|
||
{"n": "1988", "v": "1988"},
|
||
{"n": "1987", "v": "1987"},
|
||
{"n": "1986", "v": "1986"},
|
||
{"n": "1985", "v": "1985"},
|
||
{"n": "1984", "v": "1984"},
|
||
{"n": "1983", "v": "1983"},
|
||
{"n": "1982", "v": "1982"},
|
||
{"n": "1981", "v": "1981"},
|
||
{"n": "1980", "v": "1980"},
|
||
{"n": "1979", "v": "1979"},
|
||
{"n": "1978", "v": "1978"},
|
||
{"n": "1977", "v": "1977"},
|
||
{"n": "1976", "v": "1976"},
|
||
{"n": "1975", "v": "1975"},
|
||
{"n": "1974", "v": "1974"},
|
||
{"n": "1973", "v": "1973"},
|
||
{"n": "1972", "v": "1972"},
|
||
{"n": "1971", "v": "1971"},
|
||
{"n": "1970", "v": "1970"},
|
||
{"n": "1969", "v": "1969"},
|
||
{"n": "1968", "v": "1968"},
|
||
{"n": "1967", "v": "1967"},
|
||
{"n": "1966", "v": "1966"},
|
||
{"n": "1965", "v": "1965"},
|
||
{"n": "1964", "v": "1964"},
|
||
{"n": "1963", "v": "1963"},
|
||
{"n": "1962", "v": "1962"},
|
||
{"n": "1961", "v": "1961"},
|
||
{"n": "1960", "v": "1960"},
|
||
{"n": "1959", "v": "1959"},
|
||
{"n": "1958", "v": "1958"}
|
||
]
|
||
},
|
||
{
|
||
"key": "tid",
|
||
"name": "中文",
|
||
"value": [
|
||
{"n": "默認", "v": ""},
|
||
{"n": "大陆", "v": "大陆 电影"},
|
||
{"n": "腾讯", "v": "腾讯 电影"},
|
||
{"n": "爱奇艺", "v": "爱奇艺 电影"},
|
||
{"n": "优酷", "v": "优酷 电影"},
|
||
{"n": "芒果", "v": "芒果TV 电影"},
|
||
{"n": "搜狐", "v": "搜狐 电影"},
|
||
{"n": "港台", "v": "港台 电影"},
|
||
{"n": "美国", "v": "美国 电影"},
|
||
{"n": "Netflix", "v": "netflix Full movie 电影"},
|
||
{"n": "Disney", "v": "disney Full movie 电影"},
|
||
{"n": "Apple", "v": "apple Full movie 电影"},
|
||
{"n": "Amazon", "v": "amazon Full movie 电影"},
|
||
{"n": "HBO", "v": "hbo Full movie 电影"},
|
||
{"n": "韩国", "v": "韩国 Full movie 电影"},
|
||
{"n": "日本", "v": "日本 Full movie 电影"},
|
||
{"n": "英国", "v": "英国 Full movie 电影"}
|
||
]
|
||
},
|
||
{
|
||
"key": "tid",
|
||
"name": "English",
|
||
"value": [
|
||
{"n": "movie", "v": "youtube movies Full movie"},
|
||
{"n": "US", "v": "us Full movie movie"},
|
||
{"n": "Netflix movie", "v": "netflix Full movie movie"},
|
||
{"n": "Disney", "v": "disney Full movie movie"},
|
||
{"n": "Apple", "v": "apple Full movie movie"},
|
||
{"n": "Amazon", "v": "amazon Full movie movie"},
|
||
{"n": "HBO", "v": "hbo Full movie movie"},
|
||
{"n": "Koera", "v": "korea Full movie movie"},
|
||
{"n": "Japan", "v": "japan Full movie movie"},
|
||
{"n": "UK", "v": "uk Full movie movie"}
|
||
]
|
||
}
|
||
],
|
||
"綜藝": [
|
||
{
|
||
"key": "time",
|
||
"name": "時間",
|
||
"value": [
|
||
{"n": "時間全選", "v": ""},
|
||
{"n": "2024", "v": "2024"},
|
||
{"n": "2023", "v": "2023"},
|
||
{"n": "2022", "v": "2022"},
|
||
{"n": "2021", "v": "2021"},
|
||
{"n": "2020", "v": "2020"},
|
||
{"n": "2019", "v": "2019"},
|
||
{"n": "2018", "v": "2018"},
|
||
{"n": "2017", "v": "2017"},
|
||
{"n": "2016", "v": "2016"},
|
||
{"n": "2015", "v": "2015"},
|
||
{"n": "2014", "v": "2014"},
|
||
{"n": "2013", "v": "2013"},
|
||
{"n": "2012", "v": "2012"},
|
||
{"n": "2011", "v": "2011"},
|
||
{"n": "2010", "v": "2010"},
|
||
{"n": "2009", "v": "2009"},
|
||
{"n": "2008", "v": "2008"},
|
||
{"n": "2007", "v": "2007"},
|
||
{"n": "2006", "v": "2006"},
|
||
{"n": "2005", "v": "2005"},
|
||
{"n": "2004", "v": "2004"},
|
||
{"n": "2003", "v": "2003"},
|
||
{"n": "2002", "v": "2002"},
|
||
{"n": "2001", "v": "2001"},
|
||
{"n": "2000", "v": "2000"},
|
||
{"n": "1999", "v": "1999"},
|
||
{"n": "1998", "v": "1998"},
|
||
{"n": "1997", "v": "1997"},
|
||
{"n": "1996", "v": "1996"},
|
||
{"n": "1995", "v": "1995"},
|
||
{"n": "1994", "v": "1994"},
|
||
{"n": "1993", "v": "1993"},
|
||
{"n": "1992", "v": "1992"},
|
||
{"n": "1991", "v": "1991"},
|
||
{"n": "1990", "v": "1990"},
|
||
{"n": "1989", "v": "1989"},
|
||
{"n": "1988", "v": "1988"},
|
||
{"n": "1987", "v": "1987"},
|
||
{"n": "1986", "v": "1986"},
|
||
{"n": "1985", "v": "1985"},
|
||
{"n": "1984", "v": "1984"},
|
||
{"n": "1983", "v": "1983"},
|
||
{"n": "1982", "v": "1982"},
|
||
{"n": "1981", "v": "1981"},
|
||
{"n": "1980", "v": "1980"},
|
||
{"n": "1979", "v": "1979"},
|
||
{"n": "1978", "v": "1978"},
|
||
{"n": "1977", "v": "1977"},
|
||
{"n": "1976", "v": "1976"},
|
||
{"n": "1975", "v": "1975"},
|
||
{"n": "1974", "v": "1974"},
|
||
{"n": "1973", "v": "1973"},
|
||
{"n": "1972", "v": "1972"},
|
||
{"n": "1971", "v": "1971"},
|
||
{"n": "1970", "v": "1970"},
|
||
{"n": "1969", "v": "1969"},
|
||
{"n": "1968", "v": "1968"},
|
||
{"n": "1967", "v": "1967"},
|
||
{"n": "1966", "v": "1966"},
|
||
{"n": "1965", "v": "1965"},
|
||
{"n": "1964", "v": "1964"},
|
||
{"n": "1963", "v": "1963"},
|
||
{"n": "1962", "v": "1962"},
|
||
{"n": "1961", "v": "1961"},
|
||
{"n": "1960", "v": "1960"},
|
||
{"n": "1959", "v": "1959"},
|
||
{"n": "1958", "v": "1958"}
|
||
]
|
||
},
|
||
{
|
||
"key": "tid",
|
||
"name": "中文",
|
||
"value": [
|
||
{"n": "默认", "v": "Variety show"},
|
||
{"n": "大陆", "v": "大陆 综艺"},
|
||
{"n": "芒果", "v": "芒果 综艺"},
|
||
{"n": "腾讯", "v": "腾讯 综艺"},
|
||
{"n": "爱奇艺", "v": "爱奇艺 综艺"},
|
||
{"n": "优酷", "v": "优酷 综艺"},
|
||
{"n": "港台", "v": "港台 综艺"},
|
||
{"n": "美国", "v": "美国 综艺"},
|
||
{"n": "Netflix", "v": "Netflix 综艺"},
|
||
{"n": "韩国", "v": "CRAVITY on Variety Shows 韩国 综艺"},
|
||
{"n": "日本", "v": "日本 综艺"},
|
||
{"n": "英国", "v": "英国 综艺"}
|
||
]
|
||
},
|
||
{
|
||
"key": "tid",
|
||
"name": "English",
|
||
"value": [
|
||
{"n": "Variety", "v": "variety"},
|
||
{"n": "Netflix variety", "v": "netflix variety"},
|
||
{"n": "Korea", "v": "korea variety"},
|
||
{"n": "Japan", "v": "japan variety"},
|
||
{"n": "UK", "v": "uk variety"}
|
||
]
|
||
},
|
||
{
|
||
"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": "潘长江"},
|
||
{"n": "郭冬临", "v": "郭冬临"},
|
||
{"n": "严顺开", "v": "严顺开"},
|
||
{"n": "文松", "v": "文松"}
|
||
]
|
||
}
|
||
],
|
||
"紀錄片": [
|
||
{
|
||
"key": "time",
|
||
"name": "時間",
|
||
"value": [
|
||
{"n": "時間全選", "v": ""},
|
||
{"n": "2024", "v": "2024"},
|
||
{"n": "2023", "v": "2023"},
|
||
{"n": "2022", "v": "2022"},
|
||
{"n": "2021", "v": "2021"},
|
||
{"n": "2020", "v": "2020"},
|
||
{"n": "2019", "v": "2019"},
|
||
{"n": "2018", "v": "2018"},
|
||
{"n": "2017", "v": "2017"},
|
||
{"n": "2016", "v": "2016"},
|
||
{"n": "2015", "v": "2015"},
|
||
{"n": "2014", "v": "2014"},
|
||
{"n": "2013", "v": "2013"},
|
||
{"n": "2012", "v": "2012"},
|
||
{"n": "2011", "v": "2011"},
|
||
{"n": "2010", "v": "2010"},
|
||
{"n": "2009", "v": "2009"},
|
||
{"n": "2008", "v": "2008"},
|
||
{"n": "2007", "v": "2007"},
|
||
{"n": "2006", "v": "2006"},
|
||
{"n": "2005", "v": "2005"},
|
||
{"n": "2004", "v": "2004"},
|
||
{"n": "2003", "v": "2003"},
|
||
{"n": "2002", "v": "2002"},
|
||
{"n": "2001", "v": "2001"},
|
||
{"n": "2000", "v": "2000"},
|
||
{"n": "1999", "v": "1999"},
|
||
{"n": "1998", "v": "1998"},
|
||
{"n": "1997", "v": "1997"},
|
||
{"n": "1996", "v": "1996"},
|
||
{"n": "1995", "v": "1995"},
|
||
{"n": "1994", "v": "1994"},
|
||
{"n": "1993", "v": "1993"},
|
||
{"n": "1992", "v": "1992"},
|
||
{"n": "1991", "v": "1991"},
|
||
{"n": "1990", "v": "1990"},
|
||
{"n": "1989", "v": "1989"},
|
||
{"n": "1988", "v": "1988"},
|
||
{"n": "1987", "v": "1987"},
|
||
{"n": "1986", "v": "1986"},
|
||
{"n": "1985", "v": "1985"},
|
||
{"n": "1984", "v": "1984"},
|
||
{"n": "1983", "v": "1983"},
|
||
{"n": "1982", "v": "1982"},
|
||
{"n": "1981", "v": "1981"},
|
||
{"n": "1980", "v": "1980"},
|
||
{"n": "1979", "v": "1979"},
|
||
{"n": "1978", "v": "1978"},
|
||
{"n": "1977", "v": "1977"},
|
||
{"n": "1976", "v": "1976"},
|
||
{"n": "1975", "v": "1975"},
|
||
{"n": "1974", "v": "1974"},
|
||
{"n": "1973", "v": "1973"},
|
||
{"n": "1972", "v": "1972"},
|
||
{"n": "1971", "v": "1971"},
|
||
{"n": "1970", "v": "1970"},
|
||
{"n": "1969", "v": "1969"},
|
||
{"n": "1968", "v": "1968"},
|
||
{"n": "1967", "v": "1967"},
|
||
{"n": "1966", "v": "1966"},
|
||
{"n": "1965", "v": "1965"},
|
||
{"n": "1964", "v": "1964"},
|
||
{"n": "1963", "v": "1963"},
|
||
{"n": "1962", "v": "1962"},
|
||
{"n": "1961", "v": "1961"},
|
||
{"n": "1960", "v": "1960"},
|
||
{"n": "1959", "v": "1959"},
|
||
{"n": "1958", "v": "1958"}
|
||
]
|
||
},
|
||
{
|
||
"key": "tid",
|
||
"name": "中文",
|
||
"value": [
|
||
{"n": "地區🎶排序", "v": "歷史記錄片 地球記錄片 宇宙記錄片 海洋記錄片 戰爭記錄片 大自然生存記錄片"},
|
||
{"n": "默认", "v": "documentary"},
|
||
{"n": "BBC纪录片", "v": "BBC 纪录片"},
|
||
{"n": "国家地理", "v": "国家地理 纪录片"},
|
||
{"n": "Netflix纪录片", "v": "netflix 纪录片"},
|
||
{"n": "BBC", "v": "BBC documentary"},
|
||
{"n": "National Geographic", "v": "National Geographic documentary"},
|
||
{"n": "Netflix", "v": "netflix documentary"},
|
||
{"n": "历史", "v": "历史 纪录片"},
|
||
{"n": "野性", "v": "野性 纪录片"},
|
||
{"n": "地球", "v": "地球 纪录片"},
|
||
{"n": "宇宙", "v": "宇宙 纪录片"},
|
||
{"n": "海洋", "v": "海洋 纪录片"},
|
||
{"n": "人文", "v": "人文 纪录片"},
|
||
{"n": "战争", "v": "战争 纪录片"}
|
||
]
|
||
},
|
||
{
|
||
"key": "tid",
|
||
"name": "English",
|
||
"value": [
|
||
{"n": "History", "v": "Full history documentary"},
|
||
{"n": "WILD", "v": "Full wild documentary"},
|
||
{"n": "Earch", "v": "Full earth documentary"},
|
||
{"n": "Universe", "v": "Full universe documentary"},
|
||
{"n": "Oceans", "v": "Full oceans documentary"},
|
||
{"n": "Humanism", "v": "Full humanism documentary"},
|
||
{"n": "Wars", "v": "Full war documentary"}
|
||
]
|
||
}
|
||
],
|
||
"音樂": [
|
||
{
|
||
"key": "time",
|
||
"name": "時間",
|
||
"value": [
|
||
{"n": "時間全選", "v": ""},
|
||
{"n": "2024", "v": "2024"},
|
||
{"n": "2023", "v": "2023"},
|
||
{"n": "2022", "v": "2022"},
|
||
{"n": "2021", "v": "2021"},
|
||
{"n": "2020", "v": "2020"},
|
||
{"n": "2019", "v": "2019"},
|
||
{"n": "2018", "v": "2018"},
|
||
{"n": "2017", "v": "2017"},
|
||
{"n": "2016", "v": "2016"},
|
||
{"n": "2015", "v": "2015"},
|
||
{"n": "2014", "v": "2014"},
|
||
{"n": "2013", "v": "2013"},
|
||
{"n": "2012", "v": "2012"},
|
||
{"n": "2011", "v": "2011"},
|
||
{"n": "2010", "v": "2010"},
|
||
{"n": "2009", "v": "2009"},
|
||
{"n": "2008", "v": "2008"},
|
||
{"n": "2007", "v": "2007"},
|
||
{"n": "2006", "v": "2006"},
|
||
{"n": "2005", "v": "2005"},
|
||
{"n": "2004", "v": "2004"},
|
||
{"n": "2003", "v": "2003"},
|
||
{"n": "2002", "v": "2002"},
|
||
{"n": "2001", "v": "2001"},
|
||
{"n": "2000", "v": "2000"},
|
||
{"n": "1999", "v": "1999"},
|
||
{"n": "1998", "v": "1998"},
|
||
{"n": "1997", "v": "1997"},
|
||
{"n": "1996", "v": "1996"},
|
||
{"n": "1995", "v": "1995"},
|
||
{"n": "1994", "v": "1994"},
|
||
{"n": "1993", "v": "1993"},
|
||
{"n": "1992", "v": "1992"},
|
||
{"n": "1991", "v": "1991"},
|
||
{"n": "1990", "v": "1990"},
|
||
{"n": "1989", "v": "1989"},
|
||
{"n": "1988", "v": "1988"},
|
||
{"n": "1987", "v": "1987"},
|
||
{"n": "1986", "v": "1986"},
|
||
{"n": "1985", "v": "1985"},
|
||
{"n": "1984", "v": "1984"},
|
||
{"n": "1983", "v": "1983"},
|
||
{"n": "1982", "v": "1982"},
|
||
{"n": "1981", "v": "1981"},
|
||
{"n": "1980", "v": "1980"},
|
||
{"n": "1979", "v": "1979"},
|
||
{"n": "1978", "v": "1978"},
|
||
{"n": "1977", "v": "1977"},
|
||
{"n": "1976", "v": "1976"},
|
||
{"n": "1975", "v": "1975"},
|
||
{"n": "1974", "v": "1974"},
|
||
{"n": "1973", "v": "1973"},
|
||
{"n": "1972", "v": "1972"},
|
||
{"n": "1971", "v": "1971"},
|
||
{"n": "1970", "v": "1970"},
|
||
{"n": "1969", "v": "1969"},
|
||
{"n": "1968", "v": "1968"},
|
||
{"n": "1967", "v": "1967"},
|
||
{"n": "1966", "v": "1966"},
|
||
{"n": "1965", "v": "1965"},
|
||
{"n": "1964", "v": "1964"},
|
||
{"n": "1963", "v": "1963"},
|
||
{"n": "1962", "v": "1962"},
|
||
{"n": "1961", "v": "1961"},
|
||
{"n": "1960", "v": "1960"},
|
||
{"n": "1959", "v": "1959"},
|
||
{"n": "1958", "v": "1958"}
|
||
]
|
||
},
|
||
{
|
||
"key": "tid",
|
||
"name": "地區",
|
||
"value": [
|
||
{"n": "華語音樂", "v": "華語音樂"},
|
||
{"n": "華語MV", "v": "華語MV"},
|
||
{"n": "环球视听", "v": "环球视听1980 @RippleOfficialEvent"},
|
||
{"n": "YouTube 點閱率最高", "v": "YouTube 點閱率最高觀看次數最多華語歌曲"},
|
||
{"n": "海外抖音", "v": "TikTok 翻唱 抖音 音樂"},
|
||
{"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": "时光音乐会MV", "v": "时光音乐会MV"},
|
||
{"n": "中国好声音MV", "v": "中国好声音MV"},
|
||
{"n": "滚石MV", "v": "滚石MV"},
|
||
{"n": "古风MV", "v": "古风MV"},
|
||
{"n": "韩国女团MV", "v": "韩国女团MV"},
|
||
{"n": "欧美", "v": "欧美 音乐"},
|
||
{"n": "BillBoard", "v": "BillBoard"}
|
||
]
|
||
},
|
||
{
|
||
"key": "tid",
|
||
"name": "愛好",
|
||
"value": [
|
||
{"n": "合辑", "v": "合辑"},
|
||
{"n": "超級女聲", "v": "超級女聲"},
|
||
{"n": "whitneyhouston", "v": "@whitneyhoustonmusic"},
|
||
{"n": "玛丽.凯丽", "v": "@MariahCarey"},
|
||
{"n": "LadyGaga", "v": "@LadyGaga"},
|
||
{"n": "G.E.M邓紫棋", "v": "@gem0816"},
|
||
{"n": "麦当娜", "v": "@madonna"},
|
||
{"n": "Taylor Swift", "v": "@TaylorSwift"},
|
||
{"n": "舞曲", "v": "蹦迪現場 慢搖 夜店 低音 女聲 "},
|
||
{"n": "80-90", "v": "80 90 音樂"},
|
||
{"n": "人聲", "v": "人聲 音樂"},
|
||
{"n": "A8製造", "v": "A8製造 工體音樂"},
|
||
{"n": "中国MTV", "v": "@ChineseMusicTV"},
|
||
{"n": "滚石", "v": "@RockRecordsTaipei"},
|
||
{"n": "失傳已久", "v": "嗨音雷虎 失傳 嗨音會所 音樂"},
|
||
{"n": "重低音DJ", "v": "3D 8D 慢搖 重低音 音樂"},
|
||
{"n": "車載舞曲", "v": "車載慢搖DJ歌曲串燒 深水炸彈DJ歌曲串燒 越南鼓DJ歌曲串燒 音樂"},
|
||
{"n": "超級女聲", "v": "超級女聲"},
|
||
{"n": "tseries", "v": "@tseries"},
|
||
{"n": "戲曲", "v": "秦腔 京剧 越剧 黄梅戏 评剧 豫剧 昆曲 高腔 梆子腔 河北梆子 晋剧 蒲剧 雁剧 上党梆子 武安平调 二人台 吉剧 龙江剧 越调 河南曲剧 山东梆子 淮剧 滑稽戏 婺剧 绍剧 徽剧 闽剧 莆仙戏 梨园戏 高甲戏 赣剧 采茶戏 汉剧 湘剧 祁剧 湖南花鼓戏 粤剧 潮剧 桂剧 彩调 壮剧 川剧 黔剧 滇剧 傣剧 藏剧 皮影戏"}
|
||
]
|
||
},
|
||
{
|
||
"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": "@jjlin • 音乐🎶"},
|
||
{"n": "周深", "v": "周深 音樂🎶"},
|
||
{"n": "張瑋伽", "v": "張瑋伽 演唱會 巡演 音樂"},
|
||
{"n": "孫露", "v": "孫露 演唱會 巡演 音樂"},
|
||
{"n": "鳳凰傳奇", "v": "鳳凰傳奇 演 巡演 音樂"},
|
||
{"n": "刀郎", "v": "刀郎 演唱會 巡演 音樂"},
|
||
{"n": "S.H.E", "v": "S.H.E 演唱會 巡演 音樂"},
|
||
{"n": "慕容曉曉", "v": "慕容曉曉 演唱會 巡演 音樂"},
|
||
{"n": "東方紅豔", "v": "東方紅豔 演唱會 巡演 音樂"},
|
||
{"n": "孟庭葦", "v": "孟庭葦 演唱會 巡演 音樂"},
|
||
{"n": "斯琴高麗", "v": "斯琴高麗 演唱會 巡演 音樂"},
|
||
{"n": "程響", "v": "程響 演唱會 巡演 音樂"},
|
||
{"n": "蔣雪兒", "v": "蔣雪兒 演唱會 巡演 音樂"}
|
||
]
|
||
}
|
||
],
|
||
"體育": [
|
||
{
|
||
"key": "time",
|
||
"name": "時間",
|
||
"value": [
|
||
{"n": "時間全選", "v": ""},
|
||
{"n": "2024", "v": "2024"},
|
||
{"n": "2023", "v": "2023"},
|
||
{"n": "2022", "v": "2022"},
|
||
{"n": "2021", "v": "2021"},
|
||
{"n": "2020", "v": "2020"},
|
||
{"n": "2019", "v": "2019"},
|
||
{"n": "2018", "v": "2018"},
|
||
{"n": "2017", "v": "2017"},
|
||
{"n": "2016", "v": "2016"},
|
||
{"n": "2015", "v": "2015"},
|
||
{"n": "2014", "v": "2014"},
|
||
{"n": "2013", "v": "2013"},
|
||
{"n": "2012", "v": "2012"},
|
||
{"n": "2011", "v": "2011"},
|
||
{"n": "2010", "v": "2010"},
|
||
{"n": "2009", "v": "2009"},
|
||
{"n": "2008", "v": "2008"},
|
||
{"n": "2007", "v": "2007"},
|
||
{"n": "2006", "v": "2006"},
|
||
{"n": "2005", "v": "2005"},
|
||
{"n": "2004", "v": "2004"},
|
||
{"n": "2003", "v": "2003"},
|
||
{"n": "2002", "v": "2002"},
|
||
{"n": "2001", "v": "2001"},
|
||
{"n": "2000", "v": "2000"},
|
||
{"n": "1999", "v": "1999"},
|
||
{"n": "1998", "v": "1998"},
|
||
{"n": "1997", "v": "1997"},
|
||
{"n": "1996", "v": "1996"},
|
||
{"n": "1995", "v": "1995"},
|
||
{"n": "1994", "v": "1994"},
|
||
{"n": "1993", "v": "1993"},
|
||
{"n": "1992", "v": "1992"},
|
||
{"n": "1991", "v": "1991"},
|
||
{"n": "1990", "v": "1990"},
|
||
{"n": "1989", "v": "1989"},
|
||
{"n": "1988", "v": "1988"},
|
||
{"n": "1987", "v": "1987"},
|
||
{"n": "1986", "v": "1986"},
|
||
{"n": "1985", "v": "1985"},
|
||
{"n": "1984", "v": "1984"},
|
||
{"n": "1983", "v": "1983"},
|
||
{"n": "1982", "v": "1982"},
|
||
{"n": "1981", "v": "1981"},
|
||
{"n": "1980", "v": "1980"},
|
||
{"n": "1979", "v": "1979"},
|
||
{"n": "1978", "v": "1978"},
|
||
{"n": "1977", "v": "1977"},
|
||
{"n": "1976", "v": "1976"},
|
||
{"n": "1975", "v": "1975"},
|
||
{"n": "1974", "v": "1974"},
|
||
{"n": "1973", "v": "1973"},
|
||
{"n": "1972", "v": "1972"},
|
||
{"n": "1971", "v": "1971"},
|
||
{"n": "1970", "v": "1970"},
|
||
{"n": "1969", "v": "1969"},
|
||
{"n": "1968", "v": "1968"},
|
||
{"n": "1967", "v": "1967"},
|
||
{"n": "1966", "v": "1966"},
|
||
{"n": "1965", "v": "1965"},
|
||
{"n": "1964", "v": "1964"},
|
||
{"n": "1963", "v": "1963"},
|
||
{"n": "1962", "v": "1962"},
|
||
{"n": "1961", "v": "1961"},
|
||
{"n": "1960", "v": "1960"},
|
||
{"n": "1959", "v": "1959"},
|
||
{"n": "1958", "v": "1958"}
|
||
]
|
||
},
|
||
{
|
||
"key": "tid",
|
||
"name": "中文",
|
||
"value": [
|
||
{"n": "默认", "v": ""},
|
||
{"n": "體育直播", "v": "体育直播"},
|
||
{"n": "體育赛事", "v": "体育赛事"},
|
||
{"n": "足球比賽", "v": "足球賽事"},
|
||
{"n": "篮球比賽", "v": "篮球賽事"},
|
||
{"n": "极限運動", "v": "极限運動"},
|
||
{"n": "室内運動", "v": "室内运动"},
|
||
{"n": "户外運動", "v": "户外运动"},
|
||
{"n": "瑜伽健身", "v": "@kundalyoghealthylifestyle4566"},
|
||
{"n": "健身運動", "v": "健身運動"}
|
||
]
|
||
},
|
||
{
|
||
"key": "tid",
|
||
"name": "English",
|
||
"value": [
|
||
{"n": "Live", "v": "live sports"},
|
||
{"n": "Games", "v": "live games"},
|
||
{"n": "Soccer", "v": "live soccer"},
|
||
{"n": "NBA", "v": "NBA"},
|
||
{"n": "Extreme", "v": "extreme sports"},
|
||
{"n": "InDoor", "v": "indoor sports"},
|
||
{"n": "OutDoor", "v": "outdoor sports"},
|
||
{"n": "Workout", "v": "workout"}
|
||
]
|
||
},
|
||
{
|
||
"key": "tid",
|
||
"name": "體育",
|
||
"value": [
|
||
{"n": "女足港場", "v": "女足港場 @Hong KongWomensStadium"},
|
||
{"n": "全國校運動會", "v": "全國大專 校院運動會 全中運 女子組賽事 全國中等 學校運動會"},
|
||
{"n": "女中儀隊", "v": "北一女中樂儀旗隊永續發展協會 北一女中家長會樂儀旗家長後援會 北一女中儀隊校友隊 台灣 学校运动会 景美女中儀隊 北一女樂儀旗隊 full樂儀隊 "},
|
||
{"n": "校園熱舞", "v": "full 校園熱舞 開南熱無 開南大學課外活動組 女生熱舞社 南寶熱舞社 寶踐熱舞社 NTDC 熱舞社 STUST"},
|
||
{"n": "红星体育官方频道", "v": "红星体育官方频道【高清直播】"},
|
||
{"n": "中國體育比賽傳奇", "v": "中國體育比赛傳奇"},
|
||
{"n": "愛爾達體育家族", "v": "愛爾達體育家族 ELTA Sports"},
|
||
{"n": "公視體育", "v": "公視體育"},
|
||
{"n": "體育之光", "v": "體育之光"},
|
||
{"n": "偶然體育賽事", "v": "偶然體育賽事"}
|
||
]
|
||
}
|
||
],
|
||
"時尚潮流": [
|
||
{
|
||
"key": "tid",
|
||
"name": "時裝秀",
|
||
"value": [
|
||
{"n": "街舞", "v": "脫衣舞 丁字褲 街舞 太空步 機械舞 舞 裸體舞蹈 霹靂舞 魔性舞蹈 鬼步舞 木偶舞 女性藝術舞蹈"},
|
||
{"n": "時尚走秀", "v": "T台走秀 lingerie show"},
|
||
{"n": "時裝秀", "v": "hdr ASM lingerieTV 東京ファッションショー 下着ショー"},
|
||
{"n": "潮流秀", "v": "FASHION IN UHD"},
|
||
{"n": "時裝模特", "v": "FASHION Runway"},
|
||
{"n": "模特", "v": "比基尼 泳裝 頂級車模 空姐 寫真 Car model Stewardess Portrait"},
|
||
{"n": "裸体秀", "v": "hdr 人體藝術 裸体秀 Nude show"},
|
||
{"n": "無限亂鬥", "v": "hdr 廟會秀 無限HD 公廟 鋼管舞 脫衣舞 舞女 清純 寫真"}
|
||
]
|
||
},
|
||
{
|
||
"key": "tid",
|
||
"name": "小姐姐",
|
||
"value": [
|
||
{"n": "小姐姐超清", "v": "小姐姐超清"},
|
||
{"n": "国内小姐姐", "v": "快手模特 抖音模特 国内小姐姐"},
|
||
{"n": "韩国小姐姐", "v": "韩国小姐姐"},
|
||
{"n": "日本小姐姐", "v": "日本小姐姐"},
|
||
{"n": "俄罗斯小姐姐", "v": "俄罗斯小姐姐"},
|
||
{"n": "混血小姐姐", "v": "混血小姐姐"},
|
||
{"n": "越南小姐姐", "v": "越南小姐姐"},
|
||
{"n": "Al小姐姐", "v": "Al美女超清"},
|
||
{"n": "抖音热门小姐姐", "v": "抖音热门小姐姐"},
|
||
{"n": "快手热门美女", "v": "快手热门美女"},
|
||
{"n": "打碟小姐姐", "v": "打碟小姐姐"},
|
||
{"n": "冲浪小姐姐", "v": "冲浪小姐姐"},
|
||
{"n": "蹦迪小姐姐", "v": "蹦迪小姐姐"},
|
||
{"n": "艺校小姐姐", "v": "艺校小姐姐"},
|
||
{"n": "环球小姐", "v": "环球小姐"},
|
||
{"n": "泰国人妖", "v": "泰国人妖"},
|
||
{"n": "人间胸器", "v": "人间胸器"}
|
||
]
|
||
},
|
||
{
|
||
"key": "tid",
|
||
"name": "English",
|
||
"value": [
|
||
{"n": "sexy Miss", "v": "sexy Miss"},
|
||
{"n": "Hot sexy Girl", "v": "Hot sexy Girl"},
|
||
{"n": "Korean Girl", "v": "Korean sexy Girl"},
|
||
{"n": "Japanese Girl", "v": "Japanese sexy Girl"},
|
||
{"n": "Russian Girl", "v": "Russian sexy Girl"},
|
||
{"n": "Vietnamese Girl", "v": "Vietnamese sexy Girl"},
|
||
{"n": "AI Girl", "v": "AI Girl"},
|
||
{"n": "TikTok Hot Siste", "v": "TikTok Hot sexy Girl"},
|
||
{"n": "Cute Girl", "v": "sexy Cute Girl"},
|
||
{"n": "Girl Dj", "v": "sexy Girl Dj"},
|
||
{"n": "Girl Surfer", "v": "sexy Girl Surfer"},
|
||
{"n": "Dance Girl", "v": "Dance sexy Girl"},
|
||
{"n": "Miss Universe", "v": "Miss Universe"},
|
||
{"n": "Thai Shemale", "v": "Thai Shemale"}
|
||
]
|
||
}
|
||
],
|
||
"解說": [
|
||
{
|
||
"key": "tid",
|
||
"name": "頻道主",
|
||
"value": [
|
||
{"n": "宇哥侃故事", "v": "@yuge"},
|
||
{"n": "零度解说", "v": "@lingdujieshuo"}
|
||
]
|
||
}
|
||
],
|
||
"16K HDR": [
|
||
{
|
||
"key": "tid",
|
||
"name": "風景",
|
||
"value": [
|
||
{"n": "運動", "v": "GoPro 女翼裝飛行 極限自行車運動"},
|
||
{"n": "風景", "v": "hdr 大自然"},
|
||
{"n": "Links TV頻道主", "v": "@linksphotograph Links TV hdr"},
|
||
{"n": "放鬆", "v": "hdr 放鬆"},
|
||
{"n": "動物世界", "v": "hdr Carnivorous Animals 動物世界"},
|
||
{"n": "深海世界", "v": "hdr Invertebrate Fish 深海世界"},
|
||
{"n": "飛禽走獸", "v": "hdr Birds of Prey Columbiform Birds Passerine Birds"},
|
||
{"n": "生物世界", "v": "hdr Amphibians Reptiles 生物世界"}
|
||
]
|
||
}
|
||
],
|
||
"Full YouTube": [
|
||
{
|
||
"key": "tid",
|
||
"name": "頻道主",
|
||
"value": [
|
||
{"n": "李子柒", "v": "李子柒 Liziqi @cnliziqi"},
|
||
{"n": "滇西小哥", "v": "滇西小哥 @dianxixiaoge"},
|
||
{"n": "老高與小茉", "v": "老高與小茉 @laogao"},
|
||
{"n": "李永樂老師", "v": "李永樂老師 @TchLiyongle"}
|
||
]
|
||
},
|
||
{
|
||
"key": "tid",
|
||
"name": "美食頻道主",
|
||
"value": [
|
||
{"n": "美食作家王刚", "v": "美食作家王刚 @chefwang"},
|
||
{"n": "小高姐的 Magic Ingredients", "v": "小高姐的 Magic Ingredients @MagicIngredients"},
|
||
{"n": "小穎美食", "v": "小穎美食 @XiaoYingFood"}
|
||
]
|
||
},
|
||
{
|
||
"key": "tid",
|
||
"name": "野外頻道主",
|
||
"value": [
|
||
{"n": "野外求生", "v": "primitivetechnology9550 @primitivetechnology9550"}
|
||
]
|
||
},
|
||
{
|
||
"key": "tid",
|
||
"name": "科普頻道主",
|
||
"value": [
|
||
{"n": "科普", "v": "Mr Beast@MrBeast"},
|
||
{"n": "航天大學", "v": "Airforceproud95 @Airforceproud95"},
|
||
{"n": "世界大戰", "v": "TheGreatWar @TheGreatWar"},
|
||
{"n": "MarkRober", "v": "Mark Rober @MarkRober"}
|
||
]
|
||
},
|
||
{
|
||
"key": "tid",
|
||
"name": "教材",
|
||
"value": [
|
||
{"n": "不良林", "v": "不良林"},
|
||
{"n": "涌哥侃侃", "v": "涌哥侃侃 @ygkkk"},
|
||
{"n": "悟空的日常", "v": "悟空的日常"}
|
||
]
|
||
}
|
||
],
|
||
"宇宙": [
|
||
{
|
||
"key": "tid",
|
||
"name": "科普知識",
|
||
"value": [
|
||
{"n": "宇宙", "v": "光年 黑洞 銀河系 空間站 太空技術"},
|
||
{"n": "粒子", "v": "空間粒子 宇宙磁場 四維空間 元素 量子 光波 光源 靈魂"},
|
||
{"n": "靠蒙", "v": "microorganism"}
|
||
]
|
||
},
|
||
{
|
||
"key": "tid",
|
||
"name": "歷史科普",
|
||
"value": [
|
||
{"n": "世界大戰", "v": "世界大戰 二戰 日侵 八國聯軍"},
|
||
{"n": "人物", "v": "古代名人 歷史名人 歷代祖先"},
|
||
{"n": "生物進化史", "v": "人類進化 微生物進化 動物進化 地球進化"},
|
||
{"n": "靠蒙", "v": "歷史 History"}
|
||
]
|
||
}
|
||
]
|
||
}
|
||
CATEGORY_QUERY = {}
|
||
CATEGORY_ALIASES = {}
|
||
try:
|
||
YOUTUBE_CLASSES
|
||
except NameError:
|
||
YOUTUBE_CLASSES = []
|
||
try:
|
||
CATEGORY_FILTERS
|
||
except NameError:
|
||
CATEGORY_FILTERS = {}
|
||
|
||
# ==================== 节点风险 / 健康度管理 ====================
|
||
# 说明: 这里的"节点"指 Innertube 客户端节点(ANDROID_VR / ANDROID / IOS / WEB ...),
|
||
# 与下方 Spider._auto_detect_proxy 里的"代理节点"是两层不同的东西。
|
||
|
||
# 风险等级: 0=可用, 1=可疑(降权), 2=高风险(冷却), 3=不可用(长冷却)
|
||
RISK_OK, RISK_SUSPECT, RISK_HIGH, RISK_DEAD = 0, 1, 2, 3
|
||
|
||
# 需要人机校验 / 登录 / 年龄验证等"风险弹窗"类状态
|
||
RISK_STATUS = {
|
||
'LOGIN_REQUIRED': RISK_HIGH,
|
||
'AGE_VERIFICATION_REQUIRED': RISK_HIGH,
|
||
'AGE_CHECK_REQUIRED': RISK_HIGH,
|
||
'CONTENT_CHECK_REQUIRED': RISK_SUSPECT,
|
||
'UNPLAYABLE': RISK_SUSPECT,
|
||
'ERROR': RISK_HIGH,
|
||
'LIVE_STREAM_OFFLINE': RISK_OK,
|
||
}
|
||
|
||
# reason 文案里出现这些词 = 触发了 Google 的风控/人机校验
|
||
BOT_REASON_PATTERNS = (
|
||
'not a bot',
|
||
'confirm you',
|
||
'sign in to confirm',
|
||
'登录以确认',
|
||
'確認您不是機器人',
|
||
'确认您不是机器人',
|
||
'inappropriate for some users',
|
||
'this helps protect our community',
|
||
)
|
||
|
||
# 地区/版权类, 换客户端节点没用, 直接判死
|
||
FATAL_REASON_PATTERNS = (
|
||
'not available in your country',
|
||
'blocked it in your country',
|
||
'在您所在的国家',
|
||
'video is private',
|
||
'video unavailable',
|
||
'has been removed',
|
||
'account associated with this video has been terminated',
|
||
)
|
||
|
||
|
||
class NodeHealth:
|
||
"""客户端节点健康表: 记录成功/失败, 高风险节点进入冷却, 排序时降权。"""
|
||
|
||
def __init__(self):
|
||
self.state = {}
|
||
|
||
def _slot(self, name):
|
||
if len(self.state) > 64:
|
||
self.state.clear()
|
||
return self.state.setdefault(name, {
|
||
'ok': 0, 'fail': 0, 'risk': RISK_OK, 'cooldown_until': 0.0, 'last_reason': '',
|
||
})
|
||
|
||
def markOk(self, name):
|
||
s = self._slot(name)
|
||
s['ok'] += 1
|
||
s['risk'] = RISK_OK
|
||
s['cooldown_until'] = 0.0
|
||
s['last_reason'] = ''
|
||
|
||
def markRisk(self, name, risk, reason=''):
|
||
s = self._slot(name)
|
||
s['fail'] += 1
|
||
s['risk'] = max(s['risk'], risk)
|
||
s['last_reason'] = str(reason)[:200]
|
||
# 冷却时长随风险等级递增, 避免反复撞同一个被风控的节点
|
||
cool = {RISK_OK: 0, RISK_SUSPECT: 60, RISK_HIGH: 600, RISK_DEAD: 1800}.get(risk, 60)
|
||
if cool:
|
||
s['cooldown_until'] = time.time() + cool
|
||
|
||
def isCooling(self, name):
|
||
return self._slot(name)['cooldown_until'] > time.time()
|
||
|
||
def weight(self, name):
|
||
"""排序权重, 越小越优先。"""
|
||
s = self._slot(name)
|
||
base = s['risk'] * 10
|
||
if self.isCooling(name):
|
||
base += 100
|
||
base += min(s['fail'], 9)
|
||
base -= min(s['ok'], 5)
|
||
return base
|
||
|
||
def snapshot(self):
|
||
now = time.time()
|
||
return {k: {'risk': v['risk'], 'ok': v['ok'], 'fail': v['fail'],
|
||
'cool': max(0, int(v['cooldown_until'] - now))}
|
||
for k, v in self.state.items()}
|
||
|
||
|
||
# 进程级共享, 跨 extract 调用保留节点健康度
|
||
NODE_HEALTH = NodeHealth()
|
||
|
||
|
||
# ==================== 全局 bot-check 熔断 ====================
|
||
# 实测(ytb_debug.log): IP 被标记后, 8 个客户端节点会全部返回 LOGIN_REQUIRED。
|
||
# 此时继续轮询没有任何意义, 只会
|
||
# a) 让 UI 卡 3~5 秒
|
||
# b) 短时间内打出十几个请求, 进一步加深 IP 标记
|
||
# 因此一轮全灭后开启熔断, 期间直接短路到兜底。
|
||
BOT_BREAKER = {'until': 0.0, 'hits': 0, 'reason': ''}
|
||
|
||
|
||
def breakerOpen():
|
||
return time.time() < BOT_BREAKER['until']
|
||
|
||
|
||
def breakerTrip(seconds, reason=''):
|
||
BOT_BREAKER['hits'] += 1
|
||
# 连续命中则指数退避, 上限 30 分钟
|
||
factor = min(2 ** max(0, BOT_BREAKER['hits'] - 1), 8)
|
||
BOT_BREAKER['until'] = time.time() + min(seconds * factor, 1800)
|
||
BOT_BREAKER['reason'] = str(reason)[:200]
|
||
debug_log('bot breaker tripped', {'hits': BOT_BREAKER['hits'],
|
||
'seconds': int(BOT_BREAKER['until'] - time.time()),
|
||
'reason': BOT_BREAKER['reason'][:80]})
|
||
|
||
|
||
def breakerReset():
|
||
if BOT_BREAKER['hits'] or BOT_BREAKER['until']:
|
||
debug_log('bot breaker reset')
|
||
BOT_BREAKER['until'] = 0.0
|
||
BOT_BREAKER['hits'] = 0
|
||
BOT_BREAKER['reason'] = ''
|
||
|
||
|
||
def breakerRemain():
|
||
return max(0, int(BOT_BREAKER['until'] - time.time()))
|
||
|
||
|
||
# ==================== 登录态 Cookie ====================
|
||
def parseCookieInput(raw_value):
|
||
"""接受三种写法, 统一返回 Cookie 请求头字符串:
|
||
1) 直接就是 Cookie 头: "SID=...; HSID=...; SAPISID=..."
|
||
2) dict: {"SID": "...", "SAPISID": "..."}
|
||
3) Netscape cookies.txt 的多行文本
|
||
"""
|
||
if not raw_value:
|
||
return ''
|
||
if isinstance(raw_value, dict):
|
||
return '; '.join('%s=%s' % (k, v) for k, v in raw_value.items() if k and v)
|
||
value = str(raw_value).strip()
|
||
if '\n' in value or '\t' in value:
|
||
pairs = []
|
||
for line in value.splitlines():
|
||
line = line.strip()
|
||
if not line or line.startswith('#'):
|
||
continue
|
||
cols = line.split('\t')
|
||
if len(cols) >= 7 and cols[5]:
|
||
pairs.append('%s=%s' % (cols[5], cols[6]))
|
||
if pairs:
|
||
return '; '.join(pairs)
|
||
return value
|
||
|
||
|
||
def sapisidHash(cookie_header, origin='https://www.youtube.com'):
|
||
"""Google 内部接口鉴权头。
|
||
格式: SAPISIDHASH <ts>_<sha1(ts + ' ' + SAPISID + ' ' + origin)>
|
||
只用本机 Cookie 计算, 不做任何网络请求。
|
||
"""
|
||
try:
|
||
jar = {}
|
||
for part in (cookie_header or '').split(';'):
|
||
if '=' in part:
|
||
k, v = part.split('=', 1)
|
||
jar[k.strip()] = v.strip()
|
||
sapisid = jar.get('SAPISID') or jar.get('__Secure-3PAPISID') or jar.get('__Secure-1PAPISID')
|
||
if not sapisid:
|
||
return None
|
||
ts = str(int(time.time()))
|
||
digest = hashlib.sha1(('%s %s %s' % (ts, sapisid, origin)).encode('utf-8')).hexdigest()
|
||
return 'SAPISIDHASH %s_%s' % (ts, digest)
|
||
except Exception as e:
|
||
debug_log('sapisidHash error', repr(e))
|
||
return None
|
||
|
||
|
||
def authHeaders(cookie_header):
|
||
"""有 Cookie 就补齐 Google 需要的一整套认证头。"""
|
||
headers = {}
|
||
if not cookie_header:
|
||
return headers
|
||
headers['Cookie'] = cookie_header
|
||
auth = sapisidHash(cookie_header)
|
||
if auth:
|
||
headers['Authorization'] = auth
|
||
headers['X-Origin'] = 'https://www.youtube.com'
|
||
headers['X-Goog-AuthUser'] = '0'
|
||
return headers
|
||
|
||
|
||
|
||
# ==================== po_token 远程 provider ====================
|
||
class PoTokenProvider:
|
||
"""对接 bgutil-ytdlp-pot-provider 的 HTTP server。
|
||
|
||
协议(见文末引用):
|
||
GET {base}/ping -> 健康检查
|
||
POST {base}/get_pot -> {"content_binding": "...", "proxy": "..."}
|
||
-> {"po_token": "...", ...}
|
||
注意: 新版 provider 已弃用 visitor_data / data_sync_id 字段, 必须用 content_binding,
|
||
否则返回 400。
|
||
|
||
设计原则: 全程 fail-soft —— provider 挂了只会拿不到 token, 绝不能拖垮播放或抛异常。
|
||
"""
|
||
|
||
def __init__(self, session, config=None):
|
||
self.session = session
|
||
self.config = config or {}
|
||
base = self.config.get('po_token_url') or ''
|
||
if base and '://' not in base:
|
||
base = 'http://' + base
|
||
self.base = base.rstrip('/')
|
||
self.timeout = float(self.config.get('po_token_timeout') or 6)
|
||
# provider 的 token 默认按小时算, 这里换成秒
|
||
self.ttl = int(float(self.config.get('po_token_ttl') or 6) * 3600)
|
||
self.cache = {}
|
||
self.cooldown_until = 0.0
|
||
self.enabled = bool(self.base)
|
||
|
||
def _cool(self, seconds=180):
|
||
self.cooldown_until = time.time() + seconds
|
||
|
||
def available(self):
|
||
return self.enabled and time.time() >= self.cooldown_until
|
||
|
||
def ping(self):
|
||
if not self.available():
|
||
return False
|
||
try:
|
||
r = self.session.get(self.base + '/ping', timeout=self.timeout)
|
||
ok = r.status_code == 200
|
||
if not ok:
|
||
self._cool()
|
||
return ok
|
||
except Exception as e:
|
||
debug_log('pot ping failed', repr(e))
|
||
self._cool()
|
||
return False
|
||
|
||
def get(self, content_binding, proxy=None):
|
||
"""按 content_binding 取 token, 带本地 TTL 缓存。失败返回 None。"""
|
||
if not self.available() or not content_binding:
|
||
return None
|
||
now = time.time()
|
||
hit = self.cache.get(content_binding)
|
||
if hit and hit.get('expires', 0) > now:
|
||
return hit.get('token')
|
||
payload = {'content_binding': str(content_binding)}
|
||
if proxy:
|
||
payload['proxy'] = proxy if '://' in str(proxy) else 'http://' + str(proxy)
|
||
try:
|
||
r = self.session.post(self.base + '/get_pot', json=payload, timeout=self.timeout)
|
||
if r.status_code >= 400:
|
||
debug_log('pot http error', {'status': r.status_code, 'body': r.text[:200]})
|
||
# 400 = 用了被弃用字段; 5xx = 生成失败。都冷却一段时间
|
||
self._cool(300 if r.status_code >= 500 else 600)
|
||
return None
|
||
data = r.json() or {}
|
||
token = data.get('po_token') or data.get('poToken') or data.get('token')
|
||
if not token:
|
||
debug_log('pot empty token', {'keys': list(data.keys())[:8]})
|
||
self._cool()
|
||
return None
|
||
# 缓存封顶, 防止长跑内存膨胀
|
||
if len(self.cache) > 128:
|
||
self.cache.clear()
|
||
self.cache[content_binding] = {'token': token, 'expires': now + self.ttl}
|
||
debug_log('pot ok', {'binding': str(content_binding)[:24], 'len': len(token)})
|
||
return token
|
||
except Exception as e:
|
||
debug_log('pot request error', repr(e))
|
||
self._cool()
|
||
return None
|
||
|
||
|
||
def capCache(store, limit):
|
||
"""字典缓存封顶: 超过上限直接清空(比 LRU 简单, 且不会在弱设备上抖动)。"""
|
||
try:
|
||
if isinstance(store, dict) and len(store) > limit:
|
||
store.clear()
|
||
except Exception:
|
||
pass
|
||
|
||
|
||
|
||
def classifyPlayability(status, reason):
|
||
"""把 playabilityStatus 归类成 (风险等级, 类别)。"""
|
||
text_reason = (str(reason or '')).lower()
|
||
for pat in FATAL_REASON_PATTERNS:
|
||
if pat.lower() in text_reason:
|
||
return RISK_DEAD, 'FATAL'
|
||
for pat in BOT_REASON_PATTERNS:
|
||
if pat.lower() in text_reason:
|
||
return RISK_HIGH, 'BOT_CHECK'
|
||
if status in RISK_STATUS:
|
||
return RISK_STATUS[status], status
|
||
if status and status != 'OK':
|
||
return RISK_SUSPECT, status
|
||
return RISK_OK, 'OK'
|
||
|
||
|
||
class RiskBlocked(Exception):
|
||
"""所有客户端节点都被风控拦截时抛出, 携带结构化信息供上层兜底。"""
|
||
|
||
def __init__(self, video_id, category, reason, nodes=None):
|
||
self.video_id = video_id
|
||
self.category = category
|
||
self.reason = reason
|
||
self.nodes = nodes or {}
|
||
super().__init__('[%s] %s' % (category, reason))
|
||
|
||
|
||
# ==================== 核心提取类(合并优化) ====================
|
||
class YouTubeLite:
|
||
"""普通视频提取,合并 0712 优化:快速 API、编码优先级、SDR/HDR 识别"""
|
||
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.extract_cache_ttl = int(self.config.get('extract_cache_ttl') or 300)
|
||
# ---- 节点风险 / 探测开关 (均可由 ext 覆盖) ----
|
||
self.node_health = NODE_HEALTH
|
||
# probe=0 关闭播放前探测(最快); 1=仅 best 探测; 2=全部探测
|
||
self.probe_level = int(self.config.get('probe', 1) or 0)
|
||
self.probe_timeout = float(self.config.get('probe_timeout') or 4)
|
||
self.probe_max = int(self.config.get('probe_max') or 3)
|
||
# 允许 av01 / 无 contentLength 等高风险码流(默认不允许)
|
||
self.allow_risky = bool(self.config.get('allow_risky'))
|
||
self.last_block = None
|
||
self.visitor_data = None
|
||
self.pot = PoTokenProvider(session, self.config)
|
||
# 缓存上限(条), 防止长时间运行 OOM 导致闪退
|
||
self.cache_limit = int(self.config.get('cache_limit') or 64)
|
||
self._current_video_id = None
|
||
self.cookie_header = parseCookieInput(self.config.get('cookie'))
|
||
|
||
def extract(self, url_or_id, force=False):
|
||
video_id = self.extract_video_id(url_or_id)
|
||
cached = self.extract_cache.get(video_id)
|
||
now = time.time()
|
||
if force:
|
||
self.extract_cache.pop(video_id, None)
|
||
cached = None
|
||
if cached and cached.get('expires', 0) > now:
|
||
debug_log('extract cache hit', {'video_id': video_id, 'ttl': int(cached.get('expires', 0) - now)})
|
||
return cached.get('data')
|
||
# 熔断期间直接短路, 一个网络请求都不发
|
||
if breakerOpen() and not self.config.get('ignore_breaker'):
|
||
debug_log('extract short-circuit by breaker', {'video_id': video_id, 'remain': breakerRemain()})
|
||
raise RiskBlocked(video_id, 'BOT_CHECK',
|
||
BOT_BREAKER['reason'] or '触发人机校验, 冷却中',
|
||
self.node_health.snapshot())
|
||
watch_url = f"https://www.youtube.com/watch?v={video_id}"
|
||
debug_log('extract start', {'input': url_or_id, 'video_id': video_id})
|
||
page_resp = self._get(watch_url)
|
||
page = page_resp.text
|
||
debug_log('watch page', {'status': page_resp.status_code, 'length': len(page)})
|
||
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._extract_visitor_data(ytcfg, player_response)
|
||
self.visitor_data = visitor_data
|
||
self._current_video_id = video_id
|
||
# 原代码这里恒为 None, 导致 _extract_signature_timestamp 是死代码,
|
||
# WEB / MWEB 节点缺 signatureTimestamp 时签名会失效。
|
||
sts = None
|
||
if player_url and self.config.get('sts', True) is not False:
|
||
sts = self._extract_signature_timestamp(video_id, player_url, ytcfg)
|
||
debug_log('page parsed', {'has_ytcfg': bool(ytcfg), 'has_initial_pr': bool(player_response), 'initial_status': (player_response.get('playabilityStatus') or {}).get('status'), 'initial_has_streaming': bool(player_response.get('streamingData')), 'has_api_key': bool(api_key), 'has_visitor': bool(visitor_data), 'sts': sts, 'player_url': 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])
|
||
debug_log('player api result', {'responses': len(api_responses), 'has_streaming': [bool((x or {}).get('streamingData')) for x in api_responses]})
|
||
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
|
||
risk, category = classifyPlayability(status, reason)
|
||
debug_log('extract blocked', {'video_id': video_id, 'status': status,
|
||
'category': category, 'reason': str(reason)[:160],
|
||
'health': self.node_health.snapshot()})
|
||
raise RiskBlocked(video_id, category, reason, self.node_health.snapshot())
|
||
details = player_response.get('videoDetails') or {}
|
||
raw_formats = []
|
||
seen_raw = set()
|
||
source_counts = []
|
||
for response in responses:
|
||
response_streaming = (response or {}).get('streamingData') or {}
|
||
source_raw = (response_streaming.get('formats') or []) + (response_streaming.get('adaptiveFormats') or [])
|
||
source_counts.append({'formats': len(response_streaming.get('formats') or []), 'adaptive': len(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)
|
||
debug_log('raw formats', {'sources': source_counts, 'total': len(raw_formats), 'sample_keys': sorted(list(raw_formats[0].keys())) if raw_formats else []})
|
||
formats = []
|
||
cipher_count = 0
|
||
for raw in raw_formats:
|
||
if raw.get('signatureCipher') or raw.get('cipher'):
|
||
cipher_count += 1
|
||
item = self._normalize_format(raw, player_url)
|
||
if item and item.get('url'):
|
||
formats.append(item)
|
||
debug_log('normalized formats', {'count': len(formats), 'cipher_count': cipher_count, 'progressive': len([x for x in formats if x.get('vcodec') != 'none' and x.get('acodec') != 'none'])})
|
||
if not formats:
|
||
raise RiskBlocked(video_id, 'NO_FORMAT', '所有客户端节点均未产出可用码流',
|
||
self.node_health.snapshot())
|
||
data = {
|
||
'id': video_id,
|
||
'title': details.get('title') or video_id,
|
||
'duration': int(details.get('lengthSeconds') or 0),
|
||
'formats': formats,
|
||
# 点播也可能带 HLS, 作为最后的兜底播放地址
|
||
'hls_url': streaming.get('hlsManifestUrl') or '',
|
||
}
|
||
breakerReset() # 拿到码流 = IP 目前没被拦, 复位熔断
|
||
capCache(self.extract_cache, self.cache_limit)
|
||
capCache(self.player_cache, 2)
|
||
capCache(self.sig_plan_cache, 8)
|
||
self.extract_cache[video_id] = {'data': data, 'expires': time.time() + self.extract_cache_ttl}
|
||
return data
|
||
|
||
@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')
|
||
|
||
def _client_name_id(self, client_name):
|
||
return {
|
||
'WEB': 1,
|
||
'MWEB': 2,
|
||
'ANDROID': 3,
|
||
'IOS': 5,
|
||
'TVHTML5': 7,
|
||
'TVHTML5_SIMPLY_EMBEDDED_PLAYER': 85,
|
||
'ANDROID_VR': 28,
|
||
'WEB_EMBEDDED_PLAYER': 56,
|
||
'WEB_REMIX': 67,
|
||
}.get(client_name, 1)
|
||
|
||
def _extract_visitor_data(self, ytcfg, player_response):
|
||
return (
|
||
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'))
|
||
)
|
||
|
||
def _extract_signature_timestamp(self, video_id, player_url, ytcfg=None):
|
||
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 as e:
|
||
debug_log('sts extract error', repr(e))
|
||
return None
|
||
|
||
def _get_po_token(self, client_name, context='gvs'):
|
||
"""先用 ext 里写死的静态 token, 没有再走远程 provider。"""
|
||
tokens = self.config.get('po_token') or self.config.get('po_tokens') or {}
|
||
if isinstance(tokens, str) and tokens:
|
||
return tokens
|
||
if isinstance(tokens, dict):
|
||
static = tokens.get(f'{client_name}.{context}') or tokens.get(client_name) or tokens.get(context)
|
||
if static:
|
||
return static
|
||
# 远程 provider: gvs 上下文绑定 visitorData, player 上下文绑定 videoId
|
||
try:
|
||
if not self.pot.available():
|
||
return None
|
||
binding = self.visitor_data if context == 'gvs' else (self._current_video_id or self.visitor_data)
|
||
if not binding:
|
||
return None
|
||
proxy = None
|
||
try:
|
||
proxy = (self.session.proxies or {}).get('https') or (self.session.proxies or {}).get('http')
|
||
except Exception:
|
||
proxy = None
|
||
return self.pot.get(binding, proxy)
|
||
except Exception as e:
|
||
debug_log('po_token provider error', repr(e))
|
||
return None
|
||
|
||
|
||
def _video_codec_priority(self, item):
|
||
mime = (item.get('mimeType') or '').lower()
|
||
codecs = (item.get('codecs') or '').lower()
|
||
if 'vp9.2' in mime or 'vp09.02' in codecs:
|
||
return 4
|
||
if 'vp9' in mime or 'vp09' in codecs:
|
||
return 3
|
||
if 'avc' in codecs or 'h264' in codecs:
|
||
return 2
|
||
if 'av01' in codecs:
|
||
return 1
|
||
return 0
|
||
|
||
def _is_hdr_video(self, item):
|
||
mime = (item.get('mimeType') or '').lower()
|
||
codecs = (item.get('codecs') or '').lower()
|
||
color = item.get('colorInfo') or {}
|
||
return 'vp9.2' in mime or 'vp09.02' in codecs or bool(color.get('hdrMetadataInfo'))
|
||
|
||
@staticmethod
|
||
def url_expired(media_url, slack=60):
|
||
"""YouTube 直链带 expire=<unix秒>, 过期后必然 403 -> 表现为"无法播放"。"""
|
||
try:
|
||
q = parse_qs(urlparse(media_url or '').query)
|
||
exp = int((q.get('expire') or ['0'])[0])
|
||
return bool(exp) and (time.time() + slack) >= exp
|
||
except Exception:
|
||
return False
|
||
|
||
# ---------- 码流(节点产物)风险评分 ----------
|
||
# 分值越高越容易 403 / 解码失败 / 卡顿, 排序时降权
|
||
def _format_risk(self, item):
|
||
score = 0
|
||
codecs = (item.get('codecs') or '').lower()
|
||
client = (item.get('client') or '').upper()
|
||
url = item.get('url') or ''
|
||
# 1) AV1: 大量电视盒子无硬解, 软解直接卡死
|
||
if 'av01' in codecs:
|
||
score += 4
|
||
# 2) WEB / MWEB 节点直链普遍需要 po_token, 无 pot 极易 403
|
||
if client in ('WEB', 'MWEB', 'WEB_EMBEDDED_PLAYER') and 'pot=' not in url:
|
||
score += 5
|
||
# 3) 缺 contentLength 的分片流在 DASH 组装时容易失败
|
||
if not item.get('contentLength'):
|
||
score += 2
|
||
# 4) n 参数未解密成功(仍是密文)会被服务端限速到几十 KB/s
|
||
if 'n=' in url and item.get('_nsig_failed'):
|
||
score += 3
|
||
# 5) HDR(VP9.2) 在 SDR 设备上偏色/黑屏
|
||
if self._is_hdr_video(item):
|
||
score += 1
|
||
# 5.5) 直链已过期 -> 必 403
|
||
if self.url_expired(url):
|
||
score += 9
|
||
# 6) 超高码率 4K 在弱网/弱盒子上必然卡
|
||
if int(item.get('height') or 0) >= 2160 and int(item.get('bitrate') or 0) > 25_000_000:
|
||
score += 2
|
||
return score
|
||
|
||
def _is_risky_best_video(self, item):
|
||
"""兼容旧调用点: 风险分 >= 4 视为高风险。"""
|
||
if self.allow_risky:
|
||
return False
|
||
return self._format_risk(item) >= 4
|
||
|
||
def choose_playable(self, formats, quality=None):
|
||
all_videos = [x for x in formats if x.get('vcodec') != 'none' and x.get('acodec') == 'none']
|
||
candidates = all_videos[:]
|
||
if quality == '4k':
|
||
candidates = [x for x in candidates if int(x.get('height') or 0) >= 2160]
|
||
elif quality == '2k':
|
||
candidates = [x for x in candidates if 1440 <= int(x.get('height') or 0) < 2160]
|
||
elif quality == '1080p':
|
||
candidates = [x for x in candidates if 1000 <= int(x.get('height') or 0) < 1440]
|
||
elif quality == 'best':
|
||
safe_candidates = [x for x in candidates if not self._is_risky_best_video(x)]
|
||
if safe_candidates:
|
||
candidates = safe_candidates
|
||
else:
|
||
candidates = [x for x in candidates if int(x.get('height') or 0) >= 1080]
|
||
if not candidates and quality == 'best':
|
||
candidates = all_videos
|
||
if not candidates:
|
||
return None
|
||
# 风险分升序优先, 其次编码优先级/高度/码率降序
|
||
candidates.sort(key=lambda x: (
|
||
self._format_risk(x),
|
||
-self._video_codec_priority(x),
|
||
-int(x.get('height') or 0),
|
||
-int(x.get('bitrate') or 0),
|
||
))
|
||
selected = candidates[0]
|
||
debug_log('video selected fast', {
|
||
'quality': quality,
|
||
'itag': selected.get('itag'),
|
||
'height': selected.get('height'),
|
||
'mime': selected.get('mimeType'),
|
||
'risk': self._format_risk(selected),
|
||
'codec_priority': self._video_codec_priority(selected),
|
||
'candidates': len(candidates),
|
||
'probe_skipped': True,
|
||
})
|
||
return selected
|
||
|
||
# ---------- 风险感知的候选队列 ----------
|
||
def rank_candidates(self, formats, quality=None):
|
||
videos = [x for x in formats if x.get('vcodec') != 'none' and x.get('acodec') == 'none']
|
||
if quality == '4k':
|
||
pool = [x for x in videos if int(x.get('height') or 0) >= 2160]
|
||
elif quality == '2k':
|
||
pool = [x for x in videos if 1440 <= int(x.get('height') or 0) < 2160]
|
||
elif quality == '1080p':
|
||
pool = [x for x in videos if 1000 <= int(x.get('height') or 0) < 1440]
|
||
else:
|
||
pool = videos[:]
|
||
pool = pool or videos
|
||
pool.sort(key=lambda x: (
|
||
self._format_risk(x),
|
||
-self._video_codec_priority(x),
|
||
-int(x.get('height') or 0),
|
||
-int(x.get('bitrate') or 0),
|
||
))
|
||
return pool
|
||
|
||
def progressive_fallback(self, formats):
|
||
"""音视频合一的 progressive 流(itag 18/22), 兼容性最好, 用于最后兜底。"""
|
||
prog = [x for x in formats if x.get('vcodec') != 'none' and x.get('acodec') != 'none']
|
||
if not prog:
|
||
return None
|
||
prog.sort(key=lambda x: (self._format_risk(x), -int(x.get('height') or 0)))
|
||
return prog[0]
|
||
|
||
def pick_playable_with_probe(self, formats, quality=None, force_probe=False):
|
||
"""按风险从低到高逐个轻探测(Range: bytes=0-1), 403/404 就换下一个码流节点。
|
||
|
||
probe_level: 0=不探测 1=仅 best/4k 探测 2=全部探测
|
||
"""
|
||
candidates = self.rank_candidates(formats, quality)
|
||
if not candidates:
|
||
return None, []
|
||
need_probe = force_probe or self.probe_level >= 2 or (self.probe_level == 1 and quality in ('best', '4k'))
|
||
if not need_probe:
|
||
return candidates[0], []
|
||
tried = []
|
||
for item in candidates[:max(1, self.probe_max)]:
|
||
ok, code = self._probe_format(item)
|
||
tried.append({'itag': item.get('itag'), 'client': item.get('client'),
|
||
'risk': self._format_risk(item), 'ok': ok, 'code': code})
|
||
if ok:
|
||
debug_log('probe picked', {'itag': item.get('itag'), 'tried': tried})
|
||
return item, tried
|
||
# 该码流不可用 -> 给产出它的客户端节点记一次风险
|
||
client = item.get('client')
|
||
if client:
|
||
self.node_health.markRisk(client, RISK_SUSPECT, 'probe %s' % code)
|
||
debug_log('probe all failed', tried)
|
||
return candidates[0], tried
|
||
|
||
def choose_video_tracks(self, formats, quality=None):
|
||
videos = [x for x in formats if x.get('vcodec') != 'none' and x.get('acodec') == 'none']
|
||
cap = 2160 if quality in ('best', '4k') else 1440 if quality == '2k' else 1080
|
||
videos = [x for x in videos if int(x.get('height') or 0) <= cap] or videos
|
||
vp9 = [x for x in videos if self._video_codec_priority(x) >= 3]
|
||
if vp9:
|
||
videos = vp9
|
||
sdr = [x for x in videos if not self._is_hdr_video(x)]
|
||
hdr = [x for x in videos if self._is_hdr_video(x)]
|
||
sort_key = lambda x: (int(x.get('height') or 0), int(x.get('bitrate') or 0))
|
||
sdr.sort(key=sort_key, reverse=True)
|
||
hdr.sort(key=sort_key, reverse=True)
|
||
tracks = []
|
||
if sdr:
|
||
item = sdr[0].copy()
|
||
item['track_name'] = 'SDR'
|
||
item['is_hdr'] = False
|
||
tracks.append(item)
|
||
if hdr:
|
||
item = hdr[0].copy()
|
||
item['track_name'] = 'HDR'
|
||
item['is_hdr'] = True
|
||
tracks.append(item)
|
||
if not tracks:
|
||
item = self.choose_playable(formats, quality)
|
||
if item:
|
||
item = item.copy()
|
||
item['track_name'] = 'HDR' if self._is_hdr_video(item) else 'SDR'
|
||
item['is_hdr'] = self._is_hdr_video(item)
|
||
tracks.append(item)
|
||
debug_log('video tracks selected', [{'name': x.get('track_name'), 'itag': x.get('itag'), 'height': x.get('height'), 'codecs': x.get('codecs')} for x in tracks])
|
||
return tracks
|
||
|
||
def choose_audio(self, formats):
|
||
candidates = [x for x in formats if x.get('acodec') != 'none' and x.get('vcodec') == 'none']
|
||
if not candidates:
|
||
return None
|
||
candidates.sort(key=lambda x: (1 if x.get('ext') == 'mp4' else 0, int(x.get('bitrate') or 0)), reverse=True)
|
||
selected = candidates[0]
|
||
debug_log('audio selected fast', {
|
||
'itag': selected.get('itag'),
|
||
'mime': selected.get('mimeType'),
|
||
'bitrate': selected.get('bitrate'),
|
||
'probe_skipped': True,
|
||
})
|
||
return selected
|
||
|
||
def _probe_format(self, item):
|
||
try:
|
||
headers = self.headers.copy()
|
||
headers.update(item.get('headers') or {})
|
||
headers['Range'] = 'bytes=0-1'
|
||
r = self.session.get(item.get('url'), headers=headers, stream=True,
|
||
timeout=getattr(self, 'probe_timeout', 4))
|
||
if r.url and r.url != item.get('url'):
|
||
item['url'] = r.url
|
||
item['redirected'] = True
|
||
debug_log('probe redirected url', self._url_summary(r.url))
|
||
status_code = r.status_code
|
||
r.close()
|
||
return status_code in (200, 206), status_code
|
||
except Exception as e:
|
||
return False, repr(e)
|
||
|
||
def choose_best_video_audio(self, formats):
|
||
videos = [x for x in formats if x.get('vcodec') != 'none' and x.get('acodec') == 'none']
|
||
audios = [x for x in formats if x.get('acodec') != 'none' and x.get('vcodec') == 'none']
|
||
videos.sort(key=lambda x: (int(x.get('height') or 0), int(x.get('bitrate') or 0)), reverse=True)
|
||
audios.sort(key=lambda x: int(x.get('bitrate') or 0), reverse=True)
|
||
return (videos[0] if videos else None), (audios[0] if audios else None)
|
||
|
||
def _url_summary(self, media_url):
|
||
parsed = urlparse(media_url or '')
|
||
query = parse_qs(parsed.query)
|
||
keys = ['itag', 'mime', 'c', 'expire', 'ip', 'mip', 'source', 'requiressl', 'gir', 'clen', 'dur', 'n', 'pot', 'sig', 'lsig', 'cms_redirect']
|
||
return {
|
||
'host': parsed.netloc,
|
||
'path': parsed.path,
|
||
'len': len(media_url or ''),
|
||
'params': {k: bool(query.get(k)) if k in ('pot', 'sig', 'lsig', 'cms_redirect') else (query.get(k, [''])[0][:80]) for k in keys if k in query}
|
||
}
|
||
|
||
def _get(self, url, **kwargs):
|
||
headers = self.headers.copy()
|
||
headers.update(kwargs.pop('headers', {}) or {})
|
||
r = self.session.get(url, headers=headers, timeout=kwargs.pop('timeout', 15), **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=15)
|
||
r.raise_for_status()
|
||
return r.json()
|
||
|
||
# ---------- 客户端节点注册表 ----------
|
||
def _client_nodes(self, context):
|
||
"""返回节点列表; risky=该节点产出的直链更容易 403(需 po_token)。"""
|
||
return [
|
||
{'name': 'ANDROID_VR', 'risky': False, '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'}},
|
||
{'name': 'ANDROID', 'risky': False, 'client': {'clientName': 'ANDROID', 'clientVersion': '21.02.35', 'androidSdkVersion': 30, 'userAgent': 'com.google.android.youtube/21.02.35 (Linux; U; Android 11) gzip', 'osName': 'Android', 'osVersion': '11', 'hl': 'en', 'gl': 'US'}},
|
||
{'name': 'IOS', 'risky': False, 'client': {'clientName': 'IOS', 'clientVersion': '21.02.3', 'deviceMake': 'Apple', 'deviceModel': 'iPhone16,2', 'userAgent': 'com.google.ios.youtube/21.02.3 (iPhone16,2; U; CPU iOS 18_3_2 like Mac OS X;)', 'osName': 'iPhone', 'osVersion': '18.3.2.22D82', 'hl': 'en', 'gl': 'US'}},
|
||
{'name': 'TVHTML5', 'risky': False, 'client': {'clientName': 'TVHTML5', 'clientVersion': '7.20250101.00.00', 'userAgent': 'Mozilla/5.0 (PlayStation; PlayStation 4/12.00) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.0 Safari/605.1.15', 'hl': 'en', 'gl': 'US'}},
|
||
{'name': (((context or {}).get('client') or {}).get('clientName') or 'WEB'), 'risky': True, 'raw': context},
|
||
# TVHTML5_SIMPLY_EMBEDDED_PLAYER 已被 YouTube 下线, 实测固定返回
|
||
# "YouTube is no longer supported in this application or device.", 故移除。
|
||
# WEB_EMBEDDED_PLAYER 必须带 thirdParty.embedUrl, 否则返回 "This video is unavailable"。
|
||
{'name': 'WEB_EMBEDDED_PLAYER', 'risky': True, 'embed': True, 'client': {'clientName': 'WEB_EMBEDDED_PLAYER', 'clientVersion': '1.20260115.01.00', 'userAgent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36', 'hl': 'en', 'gl': 'US'}},
|
||
{'name': 'MWEB', 'risky': True, 'client': {'clientName': 'MWEB', 'clientVersion': '2.20260115.01.00', 'userAgent': 'Mozilla/5.0 (iPad; CPU OS 16_7_10 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.6 Mobile/15E148 Safari/604.1,gzip(gfe)', 'hl': 'en', 'gl': 'US'}},
|
||
]
|
||
|
||
def _call_player_api(self, video_id, api_key, context, referer, visitor_data=None, sts=None):
|
||
nodes = self._client_nodes(context)
|
||
# ext.clients 可指定只用哪些节点(按给定顺序), 例如 ["ANDROID_VR","IOS"]
|
||
wanted = self.config.get('clients')
|
||
if isinstance(wanted, str):
|
||
wanted = [x.strip() for x in wanted.split(',') if x.strip()]
|
||
if isinstance(wanted, list) and wanted:
|
||
table = {n['name']: n for n in nodes}
|
||
nodes = [table[x] for x in wanted if x in table] or nodes
|
||
# 按健康度排序: 冷却中/高风险的节点自动排到最后, 但不丢弃(全挂时仍可兜底)
|
||
nodes.sort(key=lambda n: (self.node_health.weight(n['name']), 1 if n.get('risky') else 0))
|
||
debug_log('node order', {'order': [n['name'] for n in nodes], 'health': self.node_health.snapshot()})
|
||
|
||
results = []
|
||
fallback = None
|
||
blocked = [] # 被风险拦截的节点
|
||
fatal = None # 地区/删除类, 换节点也没用
|
||
|
||
for node in nodes:
|
||
client_name = node['name']
|
||
ctx = node.get('raw') or {'client': node['client']}
|
||
if node.get('embed'):
|
||
ctx = dict(ctx)
|
||
ctx['thirdParty'] = {'embedUrl': 'https://www.youtube.com/'}
|
||
client = ctx.get('client') or {}
|
||
if self.node_health.isCooling(client_name) and results:
|
||
debug_log('node skipped (cooling)', {'client': client_name})
|
||
continue
|
||
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,
|
||
}
|
||
# 有 po_token 就带上, 显著降低 WEB/MWEB 节点被风控的概率
|
||
po = self._get_po_token(client_name, 'player')
|
||
if po:
|
||
payload['serviceIntegrityDimensions'] = {'poToken': po}
|
||
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
|
||
# 登录态: Cookie + SAPISIDHASH。目前对付 bot check 最有效的手段
|
||
headers.update(authHeaders(self.cookie_header))
|
||
client_ua = client.get('userAgent')
|
||
if client_ua:
|
||
headers['User-Agent'] = client_ua
|
||
|
||
data = self._post_json(url, payload, headers=headers)
|
||
ps = data.get('playabilityStatus') or {}
|
||
status = ps.get('status')
|
||
reason = ps.get('reason') or (((ps.get('errorScreen') or {}).get('playerErrorMessageRenderer') or {}).get('subreason') or {}).get('simpleText') or ''
|
||
risk, category = classifyPlayability(status, reason)
|
||
|
||
streaming = data.get('streamingData') or {}
|
||
formats = streaming.get('formats') or []
|
||
adaptive = streaming.get('adaptiveFormats') or []
|
||
direct_video = [x for x in adaptive if (x.get('url') or x.get('signatureCipher') or x.get('cipher')) and str(x.get('mimeType') or '').startswith('video/')]
|
||
direct_any = [x for x in formats + adaptive if x.get('url') or x.get('signatureCipher') or x.get('cipher')]
|
||
has_streaming = bool(streaming)
|
||
|
||
debug_log('player api client', {'client': client_name, 'status': status, 'risk': risk, 'category': category,
|
||
'has_streaming': has_streaming, 'formats': len(formats),
|
||
'adaptive': len(adaptive), 'direct_any': len(direct_any),
|
||
'direct_video': len(direct_video)})
|
||
|
||
if category == 'FATAL':
|
||
fatal = fatal or (category, reason or status)
|
||
self.node_health.markRisk(client_name, RISK_DEAD, reason)
|
||
# 地区/下架类: 换节点无意义, 但仍继续跑完(某些节点 gl 不同可能可用)
|
||
blocked.append({'client': client_name, 'status': status, 'category': category, 'reason': reason})
|
||
continue
|
||
|
||
if not has_streaming and risk >= RISK_SUSPECT:
|
||
# 风险拦截: 记账 + 冷却 + 换下一个节点, 不上抛
|
||
self.node_health.markRisk(client_name, risk, reason)
|
||
blocked.append({'client': client_name, 'status': status, 'category': category, 'reason': reason})
|
||
debug_log('node blocked', {'client': client_name, 'category': category, 'reason': reason[:120]})
|
||
continue
|
||
|
||
if has_streaming:
|
||
self.node_health.markOk(client_name)
|
||
data['_client_name'] = client_name
|
||
data['_client_ua'] = client_ua
|
||
data['_client_risky'] = bool(node.get('risky'))
|
||
results.append(data)
|
||
# 低风险节点直接拿到视频直链 -> 快速返回
|
||
if not node.get('risky') and direct_video and status == 'OK':
|
||
debug_log('player api fast return', {'client': client_name, 'direct_video': len(direct_video)})
|
||
return results
|
||
if fallback is None:
|
||
fallback = data
|
||
elif fallback is None:
|
||
fallback = data
|
||
except Exception as e:
|
||
self.node_health.markRisk(client_name, RISK_SUSPECT, repr(e))
|
||
debug_log('player api client error', {'client': client_name, 'error': repr(e)})
|
||
continue
|
||
|
||
self.last_block = {'blocked': blocked, 'fatal': fatal, 'health': self.node_health.snapshot()}
|
||
# 一轮下来全灭且主因是人机校验 -> 开熔断, 避免继续空轮询加深 IP 标记
|
||
if not results and blocked:
|
||
bot_hits = [b for b in blocked if b.get('category') in ('BOT_CHECK', 'LOGIN_REQUIRED')]
|
||
if len(bot_hits) >= max(2, len(nodes) // 2):
|
||
breakerTrip(int(self.config.get('bot_cooldown') or 300),
|
||
bot_hits[0].get('reason') or 'BOT_CHECK')
|
||
if not results and blocked:
|
||
category, reason = fatal if fatal else (blocked[0].get('category'), blocked[0].get('reason') or blocked[0].get('status'))
|
||
raise RiskBlocked(video_id, category, reason, self.node_health.snapshot())
|
||
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
|
||
before_n = media_url
|
||
media_url = self._decrypt_nsig(media_url, player_url)
|
||
nsig_failed = ('n=' in media_url) and (media_url == before_n)
|
||
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'),
|
||
'colorInfo': fmt.get('colorInfo') or {},
|
||
'vcodec': codecs if has_video else 'none',
|
||
'acodec': codecs if has_audio else 'none',
|
||
'headers': headers,
|
||
'_nsig_failed': nsig_failed,
|
||
'_client_risky': bool(fmt.get('_client_risky')),
|
||
}
|
||
|
||
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)
|
||
debug_log('signature cipher', {'sp': sp, 'sig_len': len(sig), 'decoded_changed': decoded != sig, 'has_player': bool(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.get(cache_key)
|
||
debug_log('sig plan cache', {'has_plan': bool(plan), 'plan': plan[:8] if plan else None})
|
||
else:
|
||
code = self._get_player_code(player_url)
|
||
plan = self._extract_sig_plan(code)
|
||
self.sig_plan_cache[cache_key] = plan
|
||
debug_log('sig plan', {'code_len': len(code), 'has_plan': bool(plan), 'plan': plan[:8] if plan else None})
|
||
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)
|
||
fixed = urlunparse(parsed._replace(path=new_path))
|
||
debug_log('n path synced', {'old': path_match.group(1), 'new_len': len(n_value), 'changed': fixed != media_url})
|
||
return fixed
|
||
debug_log('n present', {'n_len': len(n_value), 'has_path_n': bool(path_match)})
|
||
return media_url
|
||
except Exception as e:
|
||
debug_log('n sync error', repr(e))
|
||
return media_url
|
||
|
||
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).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)
|
||
debug_log('n function', {'name': name, 'body_len': len(body)})
|
||
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_ytcfg(self, text):
|
||
m = re.search(r'ytcfg\.set\s*\(\s*({.+?})\s*\)\s*;', text, re.S)
|
||
if not m:
|
||
return None
|
||
try:
|
||
return json.loads(m.group(1))
|
||
except Exception:
|
||
return None
|
||
|
||
def _extract_initial_player_response(self, text):
|
||
return self._extract_json_after(text, 'ytInitialPlayerResponse')
|
||
|
||
def _extract_json_after(self, 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
|
||
|
||
def _extract_player_url(self, 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 ''
|
||
|
||
@staticmethod
|
||
def _search(pattern, text, default=None):
|
||
m = re.search(pattern, text or '', re.S)
|
||
return m.group(1) if m else default
|
||
|
||
|
||
# ==================== 直播提取类 ====================
|
||
class YouTubeLiveLite:
|
||
"""直播提取(原 youtubelive.py)"""
|
||
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_video_id(self, 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})$',
|
||
]:
|
||
match = re.search(pattern, text)
|
||
if match:
|
||
return match.group(1)
|
||
raise Exception('无法识别 YouTube 视频 ID')
|
||
|
||
def extract_live(self, url_or_id):
|
||
if breakerOpen() and not (self.config or {}).get('ignore_breaker'):
|
||
debug_log('live short-circuit by breaker', {'remain': breakerRemain()})
|
||
raise RiskBlocked(self.extract_video_id(url_or_id), 'BOT_CHECK',
|
||
BOT_BREAKER['reason'] or '触发人机校验, 冷却中')
|
||
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:
|
||
debug_log('live cache hit', {'video_id': video_id, 'ttl': int(cached.get('expires', 0) - now)})
|
||
return cached.get('data')
|
||
watch_url = f'https://www.youtube.com/watch?v={video_id}'
|
||
debug_log('live extract start', {'input': url_or_id, 'video_id': video_id})
|
||
response = self._get(watch_url)
|
||
page = response.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._extract_visitor_data(ytcfg, player_response)
|
||
status_obj = player_response.get('playabilityStatus') or {}
|
||
streaming = player_response.get('streamingData') or {}
|
||
details = player_response.get('videoDetails') or {}
|
||
debug_log('live page parsed', {
|
||
'status': status_obj.get('status'),
|
||
'reason': status_obj.get('reason'),
|
||
'is_live': details.get('isLiveContent'),
|
||
'has_hls': bool(streaming.get('hlsManifestUrl')),
|
||
'has_api_key': bool(api_key),
|
||
'has_visitor': bool(visitor_data),
|
||
})
|
||
page_hls_url = streaming.get('hlsManifestUrl') or ''
|
||
hls_source = 'page' if page_hls_url else ''
|
||
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
|
||
hls_source = api_data.get('_client_name') or 'api'
|
||
elif not page_hls_url and api_streaming:
|
||
streaming = api_streaming
|
||
hls_source = api_data.get('_client_name') or 'api_no_hls'
|
||
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_source = 'page_fallback'
|
||
hls_url = streaming.get('hlsManifestUrl') or ''
|
||
is_live = bool(details.get('isLiveContent') 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),
|
||
}
|
||
debug_log('live extract result', {
|
||
'video_id': video_id,
|
||
'status': status,
|
||
'is_live': is_live,
|
||
'has_hls': bool(hls_url),
|
||
'hls_source': hls_source,
|
||
'duration': data.get('duration'),
|
||
})
|
||
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 {})
|
||
response = self.session.get(url, headers=headers, timeout=kwargs.pop('timeout', 15), **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=15)
|
||
response.raise_for_status()
|
||
return response.json()
|
||
|
||
def _call_player_api(self, video_id, api_key, ytcfg, referer, visitor_data=None):
|
||
# 直播侧原本没有任何风险识别, 这里与点播侧对齐
|
||
self.node_health = getattr(self, 'node_health', NODE_HEALTH)
|
||
self.cookie_header = getattr(self, 'cookie_header', None) or parseCookieInput((self.config or {}).get('cookie'))
|
||
context = ytcfg.get('INNERTUBE_CONTEXT') or {
|
||
'client': {'clientName': 'WEB', 'clientVersion': '2.20240310.01.00', 'hl': 'en', 'gl': 'US'}
|
||
}
|
||
clients = [
|
||
{'client': {'clientName': 'ANDROID', 'clientVersion': '21.02.35', 'androidSdkVersion': 30, 'userAgent': 'com.google.android.youtube/21.02.35 (Linux; U; Android 11) gzip', 'osName': 'Android', 'osVersion': '11', 'hl': 'en', 'gl': 'US'}},
|
||
{'client': {'clientName': 'IOS', 'clientVersion': '21.02.3', 'deviceMake': 'Apple', 'deviceModel': 'iPhone16,2', 'userAgent': 'com.google.ios.youtube/21.02.3 (iPhone16,2; U; CPU iOS 18_3_2 like Mac OS X;)', 'osName': 'iPhone', 'osVersion': '18.3.2.22D82', 'hl': 'en', 'gl': 'US'}},
|
||
{'client': {'clientName': 'TVHTML5', 'clientVersion': '7.20250101.00.00', 'userAgent': 'Mozilla/5.0 (PlayStation; PlayStation 4/12.00) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.0 Safari/605.1.15', 'hl': 'en', 'gl': 'US'}},
|
||
{'client': {'clientName': 'MWEB', 'clientVersion': '2.20260115.01.00', 'userAgent': 'Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Mobile/15E148 Safari/604.1', 'hl': 'en', 'gl': 'US'}},
|
||
context,
|
||
]
|
||
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 {}
|
||
status = (data.get('playabilityStatus') or {}).get('status')
|
||
debug_log('live api client', {
|
||
'client': client_name,
|
||
'status': status,
|
||
'has_hls': bool(streaming.get('hlsManifestUrl')),
|
||
'has_streaming': bool(streaming),
|
||
})
|
||
if streaming.get('hlsManifestUrl'):
|
||
data['_client_name'] = client_name
|
||
return data
|
||
except Exception as e:
|
||
debug_log('live api client error', {'client': client_name, 'error': repr(e)})
|
||
return None
|
||
|
||
def _extract_visitor_data(self, ytcfg, player_response):
|
||
return (
|
||
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'))
|
||
)
|
||
|
||
def _extract_ytcfg(self, text):
|
||
m = re.search(r'ytcfg\.set\s*\(\s*({.+?})\s*\)\s*;', text, re.S)
|
||
if not m:
|
||
return None
|
||
try:
|
||
return json.loads(m.group(1))
|
||
except Exception:
|
||
return None
|
||
|
||
def _extract_initial_player_response(self, text):
|
||
return self._extract_json_after(text, 'ytInitialPlayerResponse')
|
||
|
||
def _extract_json_after(self, 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
|
||
|
||
def _client_name_id(self, client_name):
|
||
return {
|
||
'WEB': 1,
|
||
'MWEB': 2,
|
||
'ANDROID': 3,
|
||
'IOS': 5,
|
||
'TVHTML5': 7,
|
||
'TVHTML5_SIMPLY_EMBEDDED_PLAYER': 85,
|
||
'ANDROID_VR': 28,
|
||
'WEB_EMBEDDED_PLAYER': 56,
|
||
'WEB_REMIX': 67,
|
||
}.get(client_name, 1)
|
||
|
||
|
||
# ==================== 主 Spider 类 ====================
|
||
class Spider(Spider):
|
||
def getName(self):
|
||
return 'YouTube 视频+直播(优化版)'
|
||
|
||
def init(self, extend):
|
||
# init 抛异常 = TVBox 认为源加载失败 -> 反复重载接口
|
||
try:
|
||
self._init(extend)
|
||
except Exception as e:
|
||
debug_log('init fatal', repr(e))
|
||
self._init_minimal()
|
||
|
||
def _init_minimal(self):
|
||
"""init 失败时的最小可用状态, 保证后续方法不会 AttributeError。"""
|
||
self.extendDict = getattr(self, 'extendDict', {}) or {}
|
||
if not hasattr(self, 'session'):
|
||
self.session = requests.Session()
|
||
self.proxy_str = getattr(self, 'proxy_str', '')
|
||
self.yt_classes = getattr(self, 'yt_classes', None) or YOUTUBE_CLASSES
|
||
self.yt_filters = getattr(self, 'yt_filters', None) or CATEGORY_FILTERS
|
||
self.header = getattr(self, 'header', None) or {
|
||
'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/',
|
||
}
|
||
if not hasattr(self, 'yt_video'):
|
||
self.yt_video = YouTubeLite(self.session, self.header, self.extendDict)
|
||
if not hasattr(self, 'yt_live'):
|
||
self.yt_live = YouTubeLiveLite(self.session, self.header, self.extendDict)
|
||
for attr in ('search_page_cache', 'live_search_cache', 'hls_url_cache'):
|
||
if not hasattr(self, attr):
|
||
setattr(self, attr, {})
|
||
self.hls_proxy_enabled = getattr(self, 'hls_proxy_enabled', True)
|
||
self._hls_key_seq = getattr(self, '_hls_key_seq', 0)
|
||
self.direct_segments = getattr(self, 'direct_segments', False)
|
||
|
||
def _init(self, extend):
|
||
try:
|
||
self.extendDict = json.loads(extend) if extend else {}
|
||
except Exception:
|
||
self.extendDict = {}
|
||
|
||
self.session = requests.Session()
|
||
# 确保 requests 在没有指定代理时,信任环境变量中的系统代理配置
|
||
self.session.trust_env = True
|
||
|
||
# ----- 1. 代理配置:三级回退逻辑 -----
|
||
self.proxy_str = None
|
||
proxy = self.extendDict.get('proxy')
|
||
|
||
if proxy:
|
||
# 第一级:优先使用 ext 传入的代理
|
||
if isinstance(proxy, str):
|
||
if not proxy.startswith('http://') and not proxy.startswith('https://'):
|
||
proxy = 'http://' + proxy
|
||
self.session.proxies = {'http': proxy, 'https': proxy}
|
||
self.proxy_str = proxy.replace('http://', '').replace('https://', '')
|
||
debug_log('使用 ext 传入代理', {'proxy': proxy})
|
||
elif isinstance(proxy, dict):
|
||
proxies = {}
|
||
for k, v in proxy.items():
|
||
if k in ('http', 'https') and v:
|
||
if not v.startswith('http://') and not v.startswith('https://'):
|
||
v = 'http://' + v
|
||
proxies[k] = v
|
||
if proxies:
|
||
self.session.proxies = proxies
|
||
self.proxy_str = (proxies.get('https') or proxies.get('http') or '').replace('http://', '').replace('https://', '')
|
||
debug_log('使用 ext 传入的字典代理', proxies)
|
||
else:
|
||
self._auto_detect_proxy()
|
||
else:
|
||
self._auto_detect_proxy()
|
||
else:
|
||
# 第二级/第三级:如果不写/没传入,进入自动探测回退流程
|
||
self._auto_detect_proxy()
|
||
# ---------------------------------------------------------
|
||
|
||
# ----- 2. 加载自定义分类与过滤器配置 -----
|
||
self.yt_classes = YOUTUBE_CLASSES
|
||
self.yt_filters = CATEGORY_FILTERS
|
||
|
||
if 'json' in self.extendDict and self.extendDict['json']:
|
||
try:
|
||
config_url = self.extendDict['json']
|
||
if config_url.startswith('./'):
|
||
import os
|
||
config_path = os.path.join(os.path.dirname(__file__), config_url[2:])
|
||
with open(config_path, 'r', encoding='utf-8') as f:
|
||
custom_config = json.load(f)
|
||
else:
|
||
# 使用当前确定好的代理去请求 JSON
|
||
r = self.session.get(config_url, timeout=10)
|
||
custom_config = r.json()
|
||
|
||
if 'class' in custom_config:
|
||
self.yt_classes = custom_config['class']
|
||
if 'filters' in custom_config:
|
||
self.yt_filters = custom_config['filters']
|
||
except Exception as e:
|
||
debug_log("加载外部 JSON 配置文件失败", repr(e))
|
||
|
||
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/'
|
||
}
|
||
# 登录态 Cookie: ext.cookie 直接给字符串/字典, 或 ext.cookie_file 给 cookies.txt 路径
|
||
cookie_raw = self.extendDict.get('cookie')
|
||
cookie_file = self.extendDict.get('cookie_file')
|
||
if not cookie_raw and cookie_file:
|
||
try:
|
||
with open(cookie_file, 'r', encoding='utf-8', errors='ignore') as fp:
|
||
cookie_raw = fp.read()
|
||
except Exception as e:
|
||
debug_log('cookie_file 读取失败', repr(e))
|
||
self.cookie_header = parseCookieInput(cookie_raw)
|
||
if self.cookie_header:
|
||
self.extendDict['cookie'] = self.cookie_header
|
||
self.header.update(authHeaders(self.cookie_header))
|
||
debug_log('已启用登录态 Cookie', {'len': len(self.cookie_header),
|
||
'has_sapisid': 'SAPISID' in self.cookie_header})
|
||
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 = {} # 直播 HLS 代理缓存
|
||
self.hls_proxy_enabled = self.extendDict.get('hls_proxy', True) is not False
|
||
self._hls_key_seq = 0
|
||
self.direct_segments = str(self.extendDict.get('seg') or 'proxy').lower() == 'direct'
|
||
# po_token provider 自检(可选, 失败只记日志不影响启动)
|
||
if self.extendDict.get('po_token_url') and self.extendDict.get('po_token_ping', True) is not False:
|
||
debug_log('po_token provider', {'base': self.yt_video.pot.base,
|
||
'alive': self.yt_video.pot.ping()})
|
||
|
||
# ---------- 统一兜底: 拦截风险弹窗 / 无法播放 ----------
|
||
def _risk_message(self, err):
|
||
if isinstance(err, RiskBlocked):
|
||
mapping = {
|
||
'BOT_CHECK': ('触发 YouTube 人机校验(出口 IP 被标记)。'
|
||
'按有效性排序: 1) 配 ext.cookie 登录态 2) 换出口 IP/节点 3) po_token'
|
||
+ (' [冷却 %ds]' % breakerRemain() if breakerRemain() else '')),
|
||
'LOGIN_REQUIRED': '该视频需要登录账号才能播放',
|
||
'AGE_VERIFICATION_REQUIRED': '该视频有年龄限制, 需登录验证',
|
||
'AGE_CHECK_REQUIRED': '该视频有年龄限制, 需登录验证',
|
||
'FATAL': '该视频已下架/私享/在当前地区不可用',
|
||
'NO_FORMAT': '未取到可用码流, 请稍后重试或更换代理节点',
|
||
}
|
||
return mapping.get(err.category, str(err.reason or err.category))
|
||
return str(err)
|
||
|
||
def _fallback_play_result(self, video_id, err, formats=None, hls_url=''):
|
||
"""降级链: progressive 直链 -> 最低风险码流 -> embed(可关) -> 静默失败。
|
||
|
||
目的是尽量不让 TVBox 弹出 WebView 风险窗口。
|
||
"""
|
||
message = self._risk_message(err)
|
||
debug_log('fallback play', {'video_id': video_id, 'msg': message,
|
||
'breaker_remain': breakerRemain(),
|
||
'cookie': bool(getattr(self, 'cookie_header', ''))})
|
||
|
||
# 0) 点播 HLS(hlsManifestUrl): 单地址、自适应, 盒子兼容性最好
|
||
try:
|
||
if hls_url:
|
||
play_url = self._cache_hls_url(hls_url, video_id, 'master') if self.hls_proxy_enabled else hls_url
|
||
debug_log('fallback -> hls')
|
||
return {'parse': 0, 'jx': 0, 'url': play_url, 'header': self.header,
|
||
'format': 'application/x-mpegURL'}
|
||
except Exception as e:
|
||
debug_log('fallback hls error', repr(e))
|
||
|
||
# 1) progressive(音视频合一)直链, 兼容性最好
|
||
try:
|
||
if formats:
|
||
prog = self.yt_video.progressive_fallback(formats)
|
||
if prog and prog.get('url'):
|
||
headers = self.header.copy()
|
||
headers.update(prog.get('headers') or {})
|
||
debug_log('fallback -> progressive', {'itag': prog.get('itag')})
|
||
return {'parse': 0, 'jx': 0, 'url': prog['url'], 'header': headers}
|
||
except Exception as e:
|
||
debug_log('fallback progressive error', repr(e))
|
||
|
||
# 2) 风险最低的纯视频码流(无音轨也好过黑屏)
|
||
try:
|
||
if formats:
|
||
ranked = self.yt_video.rank_candidates(formats, '1080p')
|
||
if ranked and ranked[0].get('url'):
|
||
headers = self.header.copy()
|
||
headers.update(ranked[0].get('headers') or {})
|
||
debug_log('fallback -> lowest risk video', {'itag': ranked[0].get('itag')})
|
||
return {'parse': 0, 'jx': 0, 'url': ranked[0]['url'], 'header': headers}
|
||
except Exception as e:
|
||
debug_log('fallback lowrisk error', repr(e))
|
||
|
||
# 3) embed(会弹 WebView), 默认开启; ext 里 embed_fallback=false 可彻底关闭弹窗
|
||
if self.extendDict.get('embed_fallback', True) is not False:
|
||
fatal = isinstance(err, RiskBlocked) and err.category == 'FATAL'
|
||
if not fatal:
|
||
return {'parse': 1, 'jx': 1,
|
||
'url': f'https://www.youtube.com/embed/{video_id}?autoplay=1',
|
||
'header': json.dumps(self.header)}
|
||
|
||
# 4) 完全不弹窗: 返回空 url, 播放器直接提示失败
|
||
return {'parse': 0, 'jx': 0, 'url': '', 'msg': message}
|
||
|
||
# 进程级缓存: 避免每次 init 都重新探测
|
||
_PROXY_CACHE = {'proxy': None, 'expires': 0.0}
|
||
_PROXY_BLACKLIST = {}
|
||
|
||
def _auto_detect_proxy(self):
|
||
"""代理节点探测。
|
||
|
||
用裸 TCP connect 代替 HTTP 请求:
|
||
- 本机端口没开时 connect 立刻 ECONNREFUSED, 12 个端口串行也在 50ms 内跑完
|
||
- 不需要线程, 避免 Android Python 沙箱里线程残留导致的闪退
|
||
"""
|
||
now = time.time()
|
||
cache = Spider._PROXY_CACHE
|
||
ttl = int(self.extendDict.get('proxy_cache_ttl') or 300)
|
||
|
||
if cache.get('expires', 0) > now:
|
||
cached = cache.get('proxy')
|
||
if cached:
|
||
self.session.proxies = {'http': cached, 'https': cached}
|
||
self.proxy_str = cached.replace('http://', '').replace('https://', '')
|
||
else:
|
||
self.session.proxies = {}
|
||
self.proxy_str = ''
|
||
debug_log('代理节点缓存命中', {'proxy': cached})
|
||
return
|
||
|
||
proxy_list = self.extendDict.get('proxy_list') or [
|
||
"http://127.0.0.1:2080",
|
||
"http://127.0.0.1:7890",
|
||
"http://127.0.0.1:10809",
|
||
"http://127.0.0.1:10172",
|
||
"http://127.0.0.1:20172",
|
||
"http://127.0.0.1:7891",
|
||
"http://127.0.0.1:10808",
|
||
"http://127.0.0.1:1087",
|
||
"http://127.0.0.1:3128",
|
||
"http://127.0.0.1:1080",
|
||
"http://127.0.0.1:8080",
|
||
"http://127.0.0.1:9090",
|
||
]
|
||
candidates = [p for p in proxy_list if Spider._PROXY_BLACKLIST.get(p, 0) < now] or proxy_list
|
||
connect_timeout = float(self.extendDict.get('proxy_timeout') or 0.35)
|
||
|
||
picked = None
|
||
for p in candidates:
|
||
try:
|
||
parsed = urlparse(p if '://' in p else 'http://' + p)
|
||
host = parsed.hostname or '127.0.0.1'
|
||
port = parsed.port or 80
|
||
sock = socket.create_connection((host, port), timeout=connect_timeout)
|
||
sock.close()
|
||
picked = p
|
||
break
|
||
except Exception:
|
||
Spider._PROXY_BLACKLIST[p] = now + 300
|
||
continue
|
||
|
||
# 黑名单封顶, 防止长期运行后无限增长
|
||
if len(Spider._PROXY_BLACKLIST) > 64:
|
||
Spider._PROXY_BLACKLIST.clear()
|
||
|
||
if picked:
|
||
self.session.proxies = {'http': picked, 'https': picked}
|
||
self.proxy_str = picked.replace('http://', '').replace('https://', '')
|
||
Spider._PROXY_CACHE = {'proxy': picked, 'expires': now + ttl}
|
||
debug_log('代理节点探测成功', {'proxy': picked, 'tried': len(candidates)})
|
||
return
|
||
|
||
self.session.proxies = {}
|
||
self.proxy_str = ''
|
||
Spider._PROXY_CACHE = {'proxy': None, 'expires': now + min(ttl, 60)}
|
||
debug_log('无可用代理节点, 回退系统/全局代理')
|
||
|
||
# ---------- 对外方法防崩护栏 ----------
|
||
# catvod / TVBox 二开在这些方法抛异常时, 轻则重载接口, 重则闪退
|
||
def homeContent(self, filter):
|
||
try:
|
||
return self._homeContent(filter)
|
||
except Exception as e:
|
||
debug_log('homeContent error', repr(e))
|
||
return {'class': getattr(self, 'yt_classes', []) or []}
|
||
|
||
def categoryContent(self, cid, page, filter, ext):
|
||
try:
|
||
return self._categoryContent(cid, page, filter, ext)
|
||
except Exception as e:
|
||
debug_log('categoryContent error', repr(e))
|
||
return {'list': [], 'page': int(page or 1), 'pagecount': int(page or 1),
|
||
'limit': 30, 'total': 0}
|
||
|
||
def searchContent(self, key, quick, pg=1):
|
||
try:
|
||
return self._searchContent(key, quick, pg)
|
||
except Exception as e:
|
||
debug_log('searchContent error', repr(e))
|
||
return {'list': [], 'page': int(pg or 1)}
|
||
|
||
def detailContent(self, did):
|
||
try:
|
||
return self._detailContent(did)
|
||
except Exception as e:
|
||
debug_log('detailContent error', repr(e))
|
||
return {'list': []}
|
||
|
||
def _homeContent(self, filter):
|
||
result = {'class': self.yt_classes}
|
||
if filter:
|
||
video_filters = {}
|
||
for c in self.yt_classes:
|
||
cid = c['type_id']
|
||
if cid in self.yt_filters:
|
||
video_filters[cid] = self.yt_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 _get_quality_label(self, height):
|
||
if height >= 2160:
|
||
return '4K'
|
||
elif height >= 1440:
|
||
return '2K'
|
||
elif height >= 1080:
|
||
return '1080P'
|
||
elif height >= 720:
|
||
return '720P'
|
||
elif height >= 480:
|
||
return '480P'
|
||
elif height >= 360:
|
||
return '360P'
|
||
else:
|
||
return f'{height}P'
|
||
|
||
# ---------- 解析 HLS 主播放列表(直播多画质) ----------
|
||
def _parse_hls_master(self, master_url):
|
||
"""返回 list of dict: {height, width, bandwidth, url}"""
|
||
try:
|
||
r = self.session.get(master_url, headers=self.header, timeout=10)
|
||
r.raise_for_status()
|
||
lines = r.text.splitlines()
|
||
variants = []
|
||
i = 0
|
||
while i < len(lines):
|
||
line = lines[i].strip()
|
||
if line.startswith('#EXT-X-STREAM-INF'):
|
||
bandwidth = re.search(r'BANDWIDTH=(\d+)', line)
|
||
resolution = re.search(r'RESOLUTION=(\d+)x(\d+)', line)
|
||
height = int(resolution.group(2)) if resolution else 0
|
||
width = int(resolution.group(1)) if resolution else 0
|
||
bw = int(bandwidth.group(1)) if bandwidth else 0
|
||
if i + 1 < len(lines):
|
||
url_line = lines[i+1].strip()
|
||
if not url_line.startswith('#'):
|
||
full_url = urljoin(master_url, url_line)
|
||
variants.append({
|
||
'height': height,
|
||
'width': width,
|
||
'bandwidth': bw,
|
||
'url': full_url
|
||
})
|
||
i += 2
|
||
else:
|
||
i += 1
|
||
variants.sort(key=lambda x: x['height'], reverse=True)
|
||
return variants
|
||
except Exception as e:
|
||
debug_log('parse hls master error', {'master_url': master_url, 'error': repr(e)})
|
||
return []
|
||
|
||
# ---------- detailContent(方案A:按高度分 SDR/HDR) ----------
|
||
def _detailContent(self, did):
|
||
video_id = did[0]
|
||
# 检测是否为直播
|
||
try:
|
||
live_data = self.yt_live.extract_live(video_id)
|
||
is_live = live_data.get('is_live') or bool(live_data.get('hls_url'))
|
||
title = live_data.get('title') or video_id
|
||
status = '直播中' if is_live else '未开播'
|
||
except Exception as e:
|
||
debug_log('detail live check failed', {'video_id': video_id, 'error': repr(e)})
|
||
is_live = False
|
||
title = self._get_video_title(video_id) or video_id
|
||
status = '视频'
|
||
|
||
play_sources = []
|
||
play_urls = []
|
||
|
||
if is_live:
|
||
hls_url = live_data.get('hls_url')
|
||
if hls_url:
|
||
variants = self._parse_hls_master(hls_url)
|
||
if variants:
|
||
for v in variants:
|
||
height = v['height']
|
||
label = self._get_quality_label(height)
|
||
cache_key = f'live_{video_id}_{height}'
|
||
self.setCache(cache_key, {'url': v['url'], 'expires': time.time() + 300})
|
||
play_sources.append(label)
|
||
play_urls.append(f'{label}${video_id}@live_{height}')
|
||
else:
|
||
play_sources.append('直播')
|
||
play_urls.append(f'直播${video_id}@live')
|
||
else:
|
||
play_sources.append('直播')
|
||
play_urls.append(f'直播${video_id}@live')
|
||
else:
|
||
# ---- 点播:方案A ----
|
||
try:
|
||
data = self.yt_video.extract(video_id)
|
||
formats = data.get('formats', [])
|
||
# 只取视频流
|
||
video_streams = [f for f in formats if f.get('vcodec') != 'none' and f.get('acodec') == 'none']
|
||
# 按高度分组
|
||
height_groups = {}
|
||
for f in video_streams:
|
||
h = int(f.get('height', 0))
|
||
if h <= 0:
|
||
continue
|
||
height_groups.setdefault(h, []).append(f)
|
||
|
||
# 对每个高度,按码率排序取最佳,并区分 SDR/HDR
|
||
for h in sorted(height_groups.keys(), reverse=True):
|
||
items = height_groups[h]
|
||
# 选 SDR 和 HDR 中码率最高的各一个
|
||
sdr_items = [x for x in items if not self.yt_video._is_hdr_video(x)]
|
||
hdr_items = [x for x in items if self.yt_video._is_hdr_video(x)]
|
||
sdr_item = max(sdr_items, key=lambda x: int(x.get('bitrate') or 0)) if sdr_items else None
|
||
hdr_item = max(hdr_items, key=lambda x: int(x.get('bitrate') or 0)) if hdr_items else None
|
||
|
||
label_base = self._get_quality_label(h)
|
||
if sdr_item:
|
||
name = f'{label_base} SDR'
|
||
play_sources.append(name)
|
||
play_urls.append(f'{name}${video_id}@{h}_sdr')
|
||
if hdr_item:
|
||
name = f'{label_base} HDR'
|
||
play_sources.append(name)
|
||
play_urls.append(f'{name}${video_id}@{h}_hdr')
|
||
# 若两者都无(理论上不会),忽略
|
||
|
||
# 如果没有生成任何线路,回退
|
||
if not play_sources:
|
||
raise Exception('No video streams found')
|
||
except Exception as e:
|
||
debug_log('detail get formats error', {'video_id': video_id, 'error': repr(e)})
|
||
play_sources.append('最高画质')
|
||
play_urls.append(f'最高画质${video_id}@best')
|
||
|
||
# 获取相关推荐
|
||
related = []
|
||
try:
|
||
r = self.session.get(f'https://www.youtube.com/watch?v={video_id}', timeout=10)
|
||
related = self._extract_videos_fixed(r.text, 20)
|
||
except Exception:
|
||
pass
|
||
|
||
if related:
|
||
related_urls = []
|
||
for v in related:
|
||
if v.get('vod_id') != video_id:
|
||
related_urls.append(f"{self._safe_title(v['vod_name'])}${v['vod_id']}@best")
|
||
if related_urls:
|
||
play_sources.append('相关推荐')
|
||
play_urls.append('#'.join(related_urls))
|
||
|
||
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]}
|
||
|
||
# ---------- playerContent ----------
|
||
def playerContent(self, flag, pid, vipFlags):
|
||
# catvod / 影视仓在这里拿到异常会直接重载接口甚至闪退, 因此整体兜住
|
||
try:
|
||
return self._playerContent(flag, pid, vipFlags)
|
||
except Exception as e:
|
||
debug_log('playerContent fatal', repr(e))
|
||
try:
|
||
vid = str(pid).split('$')[-1].split('@')[0]
|
||
except Exception:
|
||
vid = ''
|
||
return self._fallback_play_result(vid, e, None)
|
||
|
||
def _playerContent(self, flag, pid, vipFlags):
|
||
raw_pid = str(pid or '').split('$')[-1]
|
||
if '@' in raw_pid:
|
||
video_id, quality_or_type = raw_pid.rsplit('@', 1)
|
||
else:
|
||
video_id, quality_or_type = raw_pid, 'best'
|
||
|
||
# 直播处理
|
||
if quality_or_type == 'live':
|
||
return self._play_live(video_id)
|
||
elif quality_or_type.startswith('live_'):
|
||
height_str = quality_or_type.split('_')[1]
|
||
if height_str.isdigit():
|
||
return self._play_live_by_height(video_id, int(height_str))
|
||
else:
|
||
return self._play_live(video_id)
|
||
else:
|
||
# 点播:解析高度和类型
|
||
if quality_or_type.endswith('_sdr') or quality_or_type.endswith('_hdr'):
|
||
parts = quality_or_type.rsplit('_', 1)
|
||
if len(parts) == 2 and parts[1] in ('sdr', 'hdr'):
|
||
height_str, hdr_flag = parts
|
||
if height_str.isdigit():
|
||
return self._play_video_by_height_and_type(video_id, int(height_str), hdr_flag)
|
||
# 兼容旧格式(纯高度或 best 等)
|
||
if quality_or_type.isdigit():
|
||
return self._play_video_by_height_and_type(video_id, int(quality_or_type), 'sdr') # 默认 SDR
|
||
else:
|
||
quality = quality_or_type if quality_or_type in ('best', '4k', '2k', '1080p') else 'best'
|
||
return self._play_video(video_id, quality)
|
||
|
||
# ---------- 按高度和 SDR/HDR 类型播放点播 ----------
|
||
def _play_video_by_height_and_type(self, video_id, target_height, hdr_type):
|
||
formats = None
|
||
try:
|
||
data = self.yt_video.extract(video_id)
|
||
formats = data.get('formats', [])
|
||
video_streams = [f for f in formats if f.get('vcodec') != 'none' and f.get('acodec') == 'none']
|
||
# 按高度筛选(>= target_height 中取最小,若无则取最大)
|
||
candidates = [f for f in video_streams if int(f.get('height', 0)) >= target_height]
|
||
if not candidates:
|
||
candidates = video_streams
|
||
# 再按 HDR 类型筛选
|
||
is_hdr_wanted = (hdr_type == 'hdr')
|
||
candidates = [f for f in candidates if self.yt_video._is_hdr_video(f) == is_hdr_wanted]
|
||
if not candidates:
|
||
# 若无匹配,则取该类型中任意一个(或回退到无类型)
|
||
candidates = [f for f in video_streams if self.yt_video._is_hdr_video(f) == is_hdr_wanted]
|
||
if not candidates:
|
||
candidates = video_streams # 最终回退
|
||
# 选码率最高的
|
||
selected_video = max(candidates, key=lambda x: int(x.get('bitrate') or 0))
|
||
audio = self.yt_video.choose_audio(formats)
|
||
if audio:
|
||
cache_key = f'yt_{video_id}_{target_height}_{hdr_type}'
|
||
self.setCache(cache_key, {
|
||
'video_tracks': [selected_video],
|
||
'video_url': selected_video['url'],
|
||
'audio_url': audio['url'],
|
||
'video_item': selected_video,
|
||
'audio_item': audio,
|
||
'duration': data.get('duration') or 0,
|
||
'expires': time.time() + 300,
|
||
})
|
||
return {
|
||
'parse': 0,
|
||
'jx': 0,
|
||
'url': f'http://127.0.0.1:9978/proxy?do=py&type=mpd&vid={video_id}&quality={target_height}_{hdr_type}',
|
||
'format': 'application/dash+xml'
|
||
}
|
||
else:
|
||
headers = self.header.copy()
|
||
headers.update(selected_video.get('headers') or {})
|
||
return {'parse': 0, 'jx': 0, 'url': selected_video['url'], 'header': headers}
|
||
except Exception as e:
|
||
debug_log('_play_video_by_height_and_type error', {'video_id': video_id, 'height': target_height, 'type': hdr_type, 'error': repr(e)})
|
||
return self._fallback_play_result(video_id, e, formats)
|
||
|
||
|
||
def _play_live_by_height(self, video_id, target_height):
|
||
cache_key = f'live_{video_id}_{target_height}'
|
||
cached = self.getCache(cache_key)
|
||
if cached and cached.get('url'):
|
||
variant_url = cached['url']
|
||
if self.hls_proxy_enabled:
|
||
play_url = self._cache_hls_url(variant_url, video_id, 'master')
|
||
else:
|
||
play_url = variant_url
|
||
return {
|
||
'parse': 0,
|
||
'jx': 0,
|
||
'url': play_url,
|
||
'header': self.header,
|
||
'format': 'application/x-mpegURL'
|
||
}
|
||
else:
|
||
return self._play_live(video_id)
|
||
|
||
|
||
def _is_live_category(self, cid):
|
||
cid_lower = cid.lower()
|
||
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([x for x in terms if x]).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 _cap_spider_caches(self):
|
||
limit = int(self.extendDict.get('cache_limit') or 64)
|
||
capCache(self.search_page_cache, limit)
|
||
capCache(self.live_search_cache, limit)
|
||
capCache(self.hls_url_cache, limit * 4)
|
||
|
||
def _search_cache_key(self, key):
|
||
return re.sub(r'\s+', ' ', str(key or '')).strip().lower()
|
||
|
||
def _search_video_page(self, key, page=1):
|
||
self._cap_spider_caches()
|
||
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=10)
|
||
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=10)
|
||
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=10)
|
||
r.raise_for_status()
|
||
return r.json()
|
||
|
||
def _extract_continuation_token(self, data):
|
||
tokens = []
|
||
def scan(obj, _depth=0):
|
||
if _depth > 40:
|
||
return
|
||
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, _depth=0):
|
||
if _depth > 40:
|
||
return
|
||
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, _depth=0):
|
||
if _depth > 40:
|
||
return
|
||
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
|
||
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 = (renderer.get('lengthText') or {}).get('simpleText') or ''
|
||
if is_live:
|
||
remarks = '直播'
|
||
else:
|
||
remarks = 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=5)
|
||
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_live(self, video_id):
|
||
try:
|
||
data = self.yt_live.extract_live(video_id)
|
||
hls_url = data.get('hls_url') or ''
|
||
if not hls_url:
|
||
status = data.get('status') or 'NO_HLS'
|
||
reason = data.get('reason') or '未获取到直播 HLS 地址'
|
||
debug_log('_play_live no hls', {'video_id': video_id, 'status': status, 'reason': reason})
|
||
raise Exception(reason)
|
||
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')
|
||
debug_log('return live hls', {'video_id': video_id, 'url_len': len(hls_url), 'status': data.get('status'), 'proxy': self.hls_proxy_enabled})
|
||
return {
|
||
'parse': 0,
|
||
'jx': 0,
|
||
'url': play_url,
|
||
'header': self.header,
|
||
'format': 'application/x-mpegURL'
|
||
}
|
||
except Exception as e:
|
||
debug_log('_play_live error', {'video_id': video_id, 'error': repr(e)})
|
||
return self._fallback_play_result(video_id, e, None)
|
||
|
||
def _play_video(self, video_id, quality):
|
||
formats = None
|
||
try:
|
||
data = self.yt_video.extract(video_id)
|
||
formats = data.get('formats') or []
|
||
# 命中缓存但直链已过期 -> 强制重取一次, 这是"点了没反应/无法播放"的高频原因
|
||
if formats and self.yt_video.url_expired(formats[0].get('url')):
|
||
debug_log('cached url expired, re-extract', {'video_id': video_id})
|
||
data = self.yt_video.extract(video_id, force=True)
|
||
formats = data.get('formats') or []
|
||
playable, probe_log = self.yt_video.pick_playable_with_probe(formats, quality)
|
||
if probe_log and not any(x.get('ok') for x in probe_log):
|
||
# 全部探测失败 -> 换一批节点重取
|
||
debug_log('all probe failed, re-extract', {'video_id': video_id})
|
||
data = self.yt_video.extract(video_id, force=True)
|
||
formats = data.get('formats') or []
|
||
playable, probe_log = self.yt_video.pick_playable_with_probe(formats, quality)
|
||
if probe_log:
|
||
debug_log('probe log', probe_log)
|
||
if not playable:
|
||
playable = self.yt_video.choose_playable(formats, quality)
|
||
if playable:
|
||
audio = self.yt_video.choose_audio(data['formats'])
|
||
debug_log('selected playable', {'itag': playable.get('itag'), 'client': playable.get('client'), 'mime': playable.get('mimeType'), 'height': playable.get('height'), 'has_n': 'n=' in playable.get('url', ''), 'redirected': bool(playable.get('redirected')), 'ua': (playable.get('headers') or {}).get('User-Agent', '')[:60], 'url_len': len(playable.get('url', ''))})
|
||
debug_log('selected audio', {'itag': audio.get('itag') if audio else None, 'client': audio.get('client') if audio else None, 'mime': audio.get('mimeType') if audio else None, 'bitrate': audio.get('bitrate') if audio else None})
|
||
if audio:
|
||
cache_key = f'yt_{video_id}_{quality}'
|
||
self.setCache(cache_key, {
|
||
'video_url': playable['url'],
|
||
'audio_url': audio['url'],
|
||
'video_item': playable,
|
||
'audio_item': audio,
|
||
'duration': data.get('duration') or 0,
|
||
'expires': time.time() + 300,
|
||
})
|
||
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'}
|
||
debug_log('return direct url', self.yt_video._url_summary(playable.get('url')))
|
||
headers = self.header.copy()
|
||
headers.update(playable.get('headers') or {})
|
||
return {'parse': 0, 'jx': 0, 'url': playable['url'], 'header': headers}
|
||
raise Exception(f'没有可直接播放的 {quality} 视频流格式')
|
||
except Exception as e:
|
||
debug_log('_play_video error', repr(e))
|
||
hls = ''
|
||
try:
|
||
hls = ((self.yt_video.extract_cache.get(video_id) or {}).get('data') or {}).get('hls_url') or ''
|
||
except Exception:
|
||
hls = ''
|
||
return self._fallback_play_result(video_id, e, formats, hls)
|
||
|
||
# ------------------ HLS 代理(原 youtubelive) ------------------
|
||
def _probe_hls(self, video_id, hls_url):
|
||
try:
|
||
response = self.session.get(hls_url, headers=self.header, timeout=10)
|
||
full_text = response.text or ''
|
||
text = full_text[:5000]
|
||
lines = [line.strip() for line in full_text.splitlines() if line.strip()][:12]
|
||
variant_url = self._pick_variant_playlist(hls_url, full_text)
|
||
debug_log('hls master probe', {
|
||
'video_id': video_id,
|
||
'status': response.status_code,
|
||
'content_type': response.headers.get('content-type'),
|
||
'length': len(response.text or ''),
|
||
'has_extm3u': text.startswith('#EXTM3U'),
|
||
'has_stream_inf': '#EXT-X-STREAM-INF' in text,
|
||
'has_media_sequence': '#EXT-X-MEDIA-SEQUENCE' in text,
|
||
'variant': bool(variant_url),
|
||
'sample': lines,
|
||
})
|
||
if variant_url:
|
||
child = self.session.get(variant_url, headers=self.header, timeout=10)
|
||
child_text = child.text[:5000] if child.text else ''
|
||
child_lines = [line.strip() for line in child_text.splitlines() if line.strip()][:12]
|
||
debug_log('hls variant probe', {
|
||
'video_id': video_id,
|
||
'status': child.status_code,
|
||
'content_type': child.headers.get('content-type'),
|
||
'length': len(child.text or ''),
|
||
'has_extm3u': child_text.startswith('#EXTM3U'),
|
||
'has_media_sequence': '#EXT-X-MEDIA-SEQUENCE' in child_text,
|
||
'has_segments': bool(re.search(r'^[^#].+', child_text, re.M)),
|
||
'sample': child_lines,
|
||
})
|
||
except Exception as e:
|
||
debug_log('hls probe error', {'video_id': video_id, 'error': repr(e)})
|
||
|
||
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 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=10)
|
||
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 as e:
|
||
return [500, 'text/plain', f'代理图片失败: {str(e)}']
|
||
|
||
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=30)
|
||
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 as e:
|
||
return [500, 'text/plain', f'代理播放失败: {str(e)}']
|
||
|
||
def _proxy_mpd(self, params):
|
||
vid = params.get('vid')
|
||
quality = params.get('quality') or '1080p'
|
||
data = self.getCache(f'yt_{vid}_{quality}') if vid else None
|
||
if not data:
|
||
return [404, 'text/plain', '视频缓存已过期或不存在']
|
||
video_url = data.get('video_url')
|
||
audio_url = data.get('audio_url')
|
||
duration = data.get('duration') or 'PT0S'
|
||
video_item = data.get('video_item') or {}
|
||
audio_item = data.get('audio_item') or {}
|
||
media_base = f'http://127.0.0.1:9978/proxy?do=py&type=media&vid={vid}&quality={quality}'
|
||
duration_pt = f"PT{int(duration or 0)}S"
|
||
video_mime = (video_item.get('mimeType') or 'video/webm').split(';')[0]
|
||
audio_mime = (audio_item.get('mimeType') or 'audio/mp4').split(';')[0]
|
||
video_init = video_item.get('initRange') or {}
|
||
video_index = video_item.get('indexRange') or {}
|
||
audio_init = audio_item.get('initRange') or {}
|
||
audio_index = audio_item.get('indexRange') or {}
|
||
mpd = f'''<?xml version="1.0" encoding="UTF-8"?>
|
||
<MPD xmlns="urn:mpeg:dash:schema:mpd:2011" type="static" mediaPresentationDuration="{duration_pt}" minBufferTime="PT1.5S" profiles="urn:mpeg:dash:profile:isoff-on-demand:2011">
|
||
<Period id="1" start="PT0S">
|
||
<AdaptationSet mimeType="{html.escape(video_mime)}" startWithSAP="1" segmentAlignment="true" scanType="progressive">
|
||
<Representation id="v{video_item.get('itag', 1)}" bandwidth="{video_item.get('bitrate', 1000000)}" codecs="{html.escape(video_item.get('codecs') or '')}" height="{video_item.get('height', 0)}" width="{video_item.get('width', 0)}">
|
||
<BaseURL>{html.escape(media_base + '&track=video')}</BaseURL>
|
||
<SegmentBase indexRange="{video_index.get('start', '0')}-{video_index.get('end', '0')}"><Initialization range="{video_init.get('start', '0')}-{video_init.get('end', '0')}"/></SegmentBase>
|
||
</Representation>
|
||
</AdaptationSet>
|
||
'''
|
||
if audio_url:
|
||
mpd += f''' <AdaptationSet mimeType="{html.escape(audio_mime)}" startWithSAP="1" segmentAlignment="true" lang="und">
|
||
<Representation id="a{audio_item.get('itag', 1)}" bandwidth="{audio_item.get('bitrate', 128000)}" codecs="{html.escape(audio_item.get('codecs') or '')}" audioSamplingRate="44100">
|
||
<BaseURL>{html.escape(media_base + '&track=audio')}</BaseURL>
|
||
<SegmentBase indexRange="{audio_index.get('start', '0')}-{audio_index.get('end', '0')}"><Initialization range="{audio_init.get('start', '0')}-{audio_init.get('end', '0')}"/></SegmentBase>
|
||
</Representation>
|
||
</AdaptationSet>
|
||
'''
|
||
mpd += ' </Period>\n</MPD>'
|
||
return [200, 'application/dash+xml', mpd]
|
||
|
||
def _proxy_media(self, params):
|
||
vid = params.get('vid')
|
||
quality = params.get('quality') or '1080p'
|
||
track = params.get('track')
|
||
data = self.getCache(f'yt_{vid}_{quality}') if vid else None
|
||
if not data or track not in ('video', 'audio'):
|
||
return [404, 'text/plain', '媒体不存在']
|
||
target_url = data.get('video_url') if track == 'video' else data.get('audio_url')
|
||
if not target_url:
|
||
return [404, 'text/plain', f'{track} 流不存在']
|
||
media_item = data.get('video_item') if track == 'video' else data.get('audio_item')
|
||
headers = self.header.copy()
|
||
headers.update((media_item or {}).get('headers') or {})
|
||
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=30)
|
||
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:
|
||
return [500, 'text/plain', f'代理媒体失败: {str(e)}']
|
||
|
||
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():
|
||
debug_log('hls proxy missing', {'key': key})
|
||
return [404, 'text/plain', 'HLS 缓存已过期']
|
||
item['expires'] = time.time() + self._hls_ttl(item.get('kind'))
|
||
target_url = item.get('url') or ''
|
||
try:
|
||
headers = self._hls_headers(target_url, item.get('kind'))
|
||
response = self.session.get(target_url, headers=headers, stream=True, timeout=15)
|
||
retried = False
|
||
if item.get('kind') == 'media' and response.status_code == 403:
|
||
retry_headers = self._hls_headers(target_url, 'media_retry')
|
||
response.close()
|
||
retried = True
|
||
response = self.session.get(target_url, headers=retry_headers, stream=True, timeout=15)
|
||
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')
|
||
debug_log('hls proxy response', {
|
||
'key': key,
|
||
'kind': item.get('kind'),
|
||
'status': response.status_code,
|
||
'content_type': content_type,
|
||
'is_m3u8': is_m3u8,
|
||
'url_len': len(target_url),
|
||
'path_tail': target_url.split('?')[0][-80:],
|
||
'retried': retried,
|
||
})
|
||
if is_m3u8:
|
||
text = response.text
|
||
rewritten = self._rewrite_m3u8(text, target_url, item.get('video_id') or '')
|
||
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:
|
||
debug_log('hls proxy error', {'key': key, 'error': repr(e)})
|
||
return [500, 'text/plain', f'HLS 代理失败: {str(e)}']
|
||
|
||
def destroy(self):
|
||
# 释放引用, 减少反复重载接口时的内存堆积
|
||
try:
|
||
for store in (getattr(self, 'search_page_cache', None),
|
||
getattr(self, 'live_search_cache', None),
|
||
getattr(self, 'hls_url_cache', None)):
|
||
if isinstance(store, dict):
|
||
store.clear()
|
||
yv = getattr(self, 'yt_video', None)
|
||
if yv:
|
||
for name in ('extract_cache', 'player_cache', 'sig_plan_cache'):
|
||
store = getattr(yv, name, None)
|
||
if isinstance(store, dict):
|
||
store.clear()
|
||
sess = getattr(self, 'session', None)
|
||
if sess:
|
||
sess.close()
|
||
except Exception as e:
|
||
debug_log('destroy error', repr(e))
|
||
|
||
try:
|
||
self.session.close()
|
||
except Exception:
|
||
pass |