diff --git a/py/gogogo.py b/py/gogogo.py deleted file mode 100644 index f14b649..0000000 --- a/py/gogogo.py +++ /dev/null @@ -1,3201 +0,0 @@ -#coding=utf-8 -#!/usr/bin/python -import re -import os -import sys -import json -import html -import time -from urllib.parse import quote, unquote, parse_qs, urlencode, urlparse, urlunparse, urljoin - -import requests -from base.spider import Spider - -sys.path.append('..') - -# ---------- 日志路径 ---------- -DEBUG_LOG = '/storage/emulated/0/源码/ytb_debug.log' -def _ensure_log_dir(): - 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): - 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": "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 = {} - -# ==================== 核心提取类(合并优化) ==================== -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) - - def extract(self, url_or_id): - video_id = self.extract_video_id(url_or_id) - cached = self.extract_cache.get(video_id) - now = time.time() - 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') - 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) - # 0712 优化:ANDROID_VR 返回明文,无需 sts - sts = None - 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 - raise Exception(f'YouTube 不可播放: {reason}') - 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 Exception('未获取到可用播放地址') - data = { - 'id': video_id, - 'title': details.get('title') or video_id, - 'duration': int(details.get('lengthSeconds') or 0), - 'formats': formats, - } - 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, - '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'): - tokens = self.config.get('po_token') or self.config.get('po_tokens') or {} - if isinstance(tokens, str): - return tokens - if isinstance(tokens, dict): - return tokens.get(f'{client_name}.{context}') or tokens.get(client_name) or tokens.get(context) - return None - - # ---------- 0712 优化:编码优先级、HDR 识别、不探测 ---------- - 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')) - - def _is_risky_best_video(self, item): - codecs = (item.get('codecs') or '').lower() - return 'av01' in codecs - - 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._video_codec_priority(x), - int(x.get('height') or 0), - int(x.get('bitrate') or 0) - ), reverse=True) - selected = candidates[0] - debug_log('video selected fast', { - 'quality': quality, - 'itag': selected.get('itag'), - 'height': selected.get('height'), - 'mime': selected.get('mimeType'), - 'codec_priority': self._video_codec_priority(selected), - 'candidates': len(candidates), - 'probe_skipped': True, - }) - return selected - - def choose_video_tracks(self, formats, quality=None): - """返回 SDR 和 HDR 各一个最高分辨率的轨道(0712 原逻辑)""" - 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=10) - 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() - - # ---------- 0712 优化:ANDROID_VR 快速返回 ---------- - def _call_player_api(self, video_id, api_key, context, referer, visitor_data=None, sts=None): - clients = [ - {'client': {'clientName': 'ANDROID_VR', 'clientVersion': '1.65.10', 'deviceMake': 'Oculus', 'deviceModel': 'Quest 3', 'androidSdkVersion': 32, 'userAgent': 'com.google.android.apps.youtube.vr.oculus/1.65.10 (Linux; U; Android 12L; eureka-user Build/SQ3A.220605.009.A1) gzip', 'osName': 'Android', 'osVersion': '12L', 'hl': 'en', 'gl': 'US'}}, - {'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'}}, - context, - {'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'}}, - ] - results = [] - fallback = None - for ctx in clients: - client_name = (ctx.get('client') or {}).get('clientName') - try: - url = f'https://www.youtube.com/youtubei/v1/player?key={api_key}&prettyPrint=false' - payload = { - 'context': ctx, - 'videoId': video_id, - 'playbackContext': {'contentPlaybackContext': {'html5Preference': 'HTML5_PREF_WANTS', **({'signatureTimestamp': sts} if sts else {})}}, - 'contentCheckOk': True, - 'racyCheckOk': True, - } - client = ctx.get('client') or {} - headers = { - 'Referer': referer, - 'X-YouTube-Client-Name': str(self._client_name_id(client.get('clientName'))), - 'X-YouTube-Client-Version': client.get('clientVersion') or '', - } - if visitor_data: - headers['X-Goog-Visitor-Id'] = visitor_data - client_ua = client.get('userAgent') - if client_ua: - headers['User-Agent'] = client_ua - data = self._post_json(url, payload, headers=headers) - status = (data.get('playabilityStatus') or {}).get('status') - 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, 'has_streaming': has_streaming, 'formats': len(formats), 'adaptive': len(adaptive), 'direct_any': len(direct_any), 'direct_video': len(direct_video)}) - if has_streaming: - data['_client_name'] = client_name - data['_client_ua'] = client_ua - results.append(data) - # 0712 快速返回:ANDROID_VR 有 direct_video 即返回 - if client_name == 'ANDROID_VR' and direct_video: - debug_log('player api fast return', {'client': client_name, 'direct_video': len(direct_video)}) - return results - if has_streaming and fallback is None: - fallback = data - elif fallback is None: - fallback = data - except Exception as e: - debug_log('player api client error', {'client': client_name, 'error': repr(e)}) - continue - return results or ([fallback] if fallback else []) - - def _normalize_format(self, fmt, player_url): - media_url = fmt.get('url') - if not media_url: - cipher = fmt.get('signatureCipher') or fmt.get('cipher') - if cipher: - media_url = self._decrypt_signature_cipher(cipher, player_url) - if not media_url: - return None - media_url = self._decrypt_nsig(media_url, player_url) - client_name = fmt.get('_client_name') - po_token = self._get_po_token(client_name, 'gvs') if client_name else None - if po_token: - sep = '&' if '?' in media_url else '?' - media_url = f'{media_url}{sep}pot={quote(po_token)}' - mime = fmt.get('mimeType') or '' - ext = 'mp4' if 'mp4' in mime else 'webm' if 'webm' in mime else 'unknown' - codecs = self._search(r'codecs="([^"]+)"', mime) or '' - has_audio = mime.startswith('audio/') or any(x in codecs for x in ('mp4a', 'opus', 'vorbis')) - has_video = mime.startswith('video/') or any(x in codecs for x in ('avc', 'vp9', 'av01', 'h264')) - headers = (fmt.get('http_headers') or {}).copy() - if fmt.get('_client_ua'): - headers['User-Agent'] = fmt.get('_client_ua') - return { - 'itag': fmt.get('itag'), - 'url': media_url, - 'mimeType': mime, - 'client': fmt.get('_client_name'), - 'ext': ext, - 'width': fmt.get('width') or 0, - 'height': fmt.get('height') or 0, - 'fps': fmt.get('fps') or 0, - 'bitrate': fmt.get('bitrate') or fmt.get('averageBitrate') or 0, - 'contentLength': fmt.get('contentLength'), - 'initRange': fmt.get('initRange') or {}, - 'indexRange': fmt.get('indexRange') or {}, - 'codecs': codecs, - 'quality': fmt.get('qualityLabel') or fmt.get('quality'), - 'colorInfo': fmt.get('colorInfo') or {}, # 用于 HDR 判断 - 'vcodec': codecs if has_video else 'none', - 'acodec': codecs if has_audio else 'none', - 'headers': headers, - } - - def _decrypt_signature_cipher(self, cipher, player_url): - data = parse_qs(cipher) - media_url = unquote(data.get('url', [''])[0]) - sig = unquote(data.get('s', [''])[0]) - sp = data.get('sp', ['sig'])[0] - if not media_url: - return '' - if sig: - decoded = self._decrypt_sig(sig, player_url) - 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): - 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): - 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': '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, - '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): - try: - self.extendDict = json.loads(extend) if extend else {} - except Exception: - self.extendDict = {} - self.session = requests.Session() - - # ----- 代理配置:优先使用 ext.proxy,否则自动检测 ----- - proxy = self.extendDict.get('proxy') - if proxy: - # 用户显式配置了代理,直接使用 - 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://', '') - 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 '127.0.0.1:2080').replace('http://', '').replace('https://', '') - else: - # 代理配置无效,回退自动检测 - self._auto_detect_proxy() - else: - self._auto_detect_proxy() - else: - # 未配置代理,自动检测 - self._auto_detect_proxy() - # --------------------------------------------------------- - - self.header = { - 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36', - 'Accept-Language': 'zh-CN,zh;q=0.9,en;q=0.8', - 'Referer': 'https://www.youtube.com/' - } - self.session.headers.update(self.header) - - # 初始化两个提取器 - self.yt_video = YouTubeLite(self.session, self.header, self.extendDict) - self.yt_live = YouTubeLiveLite(self.session, self.header, self.extendDict) - self.search_page_cache = {} # 视频搜索缓存 - self.live_search_cache = {} # 直播搜索缓存 - self.hls_url_cache = {} # 直播 HLS 代理缓存 - self.hls_proxy_enabled = self.extendDict.get('hls_proxy', True) is not False - self._hls_key_seq = 0 - # 0712 配置:MPD 分段是否直接返回原始 URL - self.direct_segments = str(self.extendDict.get('seg') or 'proxy').lower() == 'direct' - - def _auto_detect_proxy(self): - """依次尝试预设代理地址,第一个成功的设为 session.proxies,否则保持默认(允许系统代理)""" - proxy_list = [ - "http://127.0.0.1:2080", - "http://127.0.0.1:7890", - "http://127.0.0.1:10809", - "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" - ] - for p in proxy_list: - try: - test_proxies = {'http': p, 'https': p} - r = self.session.get('https://www.youtube.com', proxies=test_proxies, timeout=5) - if r.status_code < 400: - self.session.proxies = test_proxies - self.proxy_str = p.replace('http://', '').replace('https://', '') - debug_log('自动代理检测成功', {'proxy': p}) - return - except Exception: - continue - if hasattr(self.session, 'proxies'): - del self.session.proxies - self.proxy_str = '' # 可保留,仅用于日志 - debug_log('所有预设代理均不可用,将依赖系统代理或直连') - - def homeContent(self, filter): - result = {'class': YOUTUBE_CLASSES} - if filter: - video_filters = {} - for c in YOUTUBE_CLASSES: - cid = c['type_id'] - if cid in CATEGORY_FILTERS: - video_filters[cid] = CATEGORY_FILTERS[cid] - result['filters'] = video_filters - return result - - def homeVideoContent(self): - return {'list': []} - - def categoryContent(self, cid, page, filter, ext): - page = int(page or 1) - filters = ext if isinstance(ext, dict) else {} - if self._is_live_category(cid): - keyword = self._build_live_keyword(cid, filters) - videos, has_more = self._search_live_page(keyword, page) - else: - keyword = self._build_video_keyword(cid, filters) - videos, has_more = self._search_video_page(keyword, page) - return { - 'list': videos, - 'page': page, - 'pagecount': page + 1 if has_more else page, - 'limit': len(videos), - 'total': len(videos) - } - - def searchContent(self, key, quick, pg=1): - page = int(pg or 1) - keyword = str(key or '').strip() - videos_v, _ = self._search_video_page(keyword, page) - live_keyword = f'{keyword} live' if 'live' not in keyword.lower() and '直播' not in keyword else keyword - videos_l, _ = self._search_live_page(live_keyword, page) - seen = set() - merged = [] - for v in videos_v + videos_l: - if v['vod_id'] not in seen: - seen.add(v['vod_id']) - merged.append(v) - return { - 'list': merged[:30], - 'page': page, - 'pagecount': page + 1, - 'limit': len(merged), - 'total': len(merged) - } - - # ---------- 画质名称辅助 ---------- - def _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): - raw_pid = pid.split('$')[-1] - if '@' in raw_pid: - video_id, quality_or_type = raw_pid.rsplit('@', 1) - else: - video_id, quality_or_type = raw_pid, '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): - 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 {'parse': 1, 'url': f'https://www.youtube.com/embed/{video_id}?autoplay=1', 'header': json.dumps(self.header)} - - - 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 _search_cache_key(self, key): - return re.sub(r'\s+', ' ', str(key or '')).strip().lower() - - def _search_video_page(self, key, page=1): - page = max(1, int(page or 1)) - cache_key = self._search_cache_key(key) - session = self.search_page_cache.get(cache_key) - if page == 1 or not session: - session = self._fetch_search_first_page(key) - self.search_page_cache[cache_key] = session - while len(session.get('pages', [])) < page and session.get('next'): - data = self._fetch_search_continuation(session) - videos = self._extract_videos_from_api(data, 30) - session.setdefault('pages', []).append(videos) - session['next'] = self._extract_continuation_token(data) - pages = session.get('pages', []) - videos = pages[page - 1] if len(pages) >= page else [] - has_more = bool(session.get('next')) or len(pages) > page - return videos, has_more - - def _search_live_page(self, key, page=1): - page = max(1, int(page or 1)) - cache_key = f'live_{self._search_cache_key(key)}' - session = self.live_search_cache.get(cache_key) - if page == 1 or not session: - session = self._fetch_live_search_first_page(key) - self.live_search_cache[cache_key] = session - while len(session.get('pages', [])) < page and session.get('next'): - data = self._fetch_search_continuation(session) - videos = self._extract_live_videos_from_api(data, 30) - session.setdefault('pages', []).append(videos) - session['next'] = self._extract_continuation_token(data) - pages = session.get('pages', []) - videos = pages[page - 1] if len(pages) >= page else [] - has_more = bool(session.get('next')) or len(pages) > page - return videos, has_more - - def _fetch_live_search_first_page(self, key): - search_url = f'https://www.youtube.com/results?search_query={quote(str(key or ""))}&sp=EgJAAQ%253D%253D' - r = self.session.get(search_url, timeout=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): - if isinstance(obj, dict): - endpoint = obj.get('continuationEndpoint') or {} - token = endpoint.get('continuationCommand', {}).get('token') - if token: - tokens.append(token) - renderer = obj.get('continuationItemRenderer') or {} - token = renderer.get('continuationEndpoint', {}).get('continuationCommand', {}).get('token') - if token: - tokens.append(token) - for value in obj.values(): - scan(value) - elif isinstance(obj, list): - for value in obj: - scan(value) - scan(data) - return tokens[0] if tokens else '' - - def _extract_videos_from_api(self, data, limit=30): - videos = [] - seen = set() - def scan(obj): - if len(videos) >= limit: - return - if isinstance(obj, dict): - for key in ('videoRenderer', 'compactVideoRenderer', 'gridVideoRenderer'): - if key in obj: - item = self._parse_renderer(obj[key], is_live=False) - if item and item['vod_id'] not in seen: - seen.add(item['vod_id']) - videos.append(item) - for value in obj.values(): - scan(value) - elif isinstance(obj, list): - for value in obj: - scan(value) - scan(data) - return videos[:limit] - - def _extract_live_videos_from_api(self, data, limit=30): - videos = [] - seen = set() - def scan(obj): - if len(videos) >= limit: - return - if isinstance(obj, dict): - for key in ('videoRenderer', 'compactVideoRenderer', 'gridVideoRenderer'): - if key in obj: - item = self._parse_renderer(obj[key], is_live=True) - if item and item['vod_id'] not in seen: - seen.add(item['vod_id']) - videos.append(item) - for value in obj.values(): - scan(value) - elif isinstance(obj, list): - for value in obj: - scan(value) - scan(data) - return videos[:limit] - - def _parse_renderer(self, renderer, is_live=False): - try: - vid = renderer.get('videoId') - if not vid: - nav = renderer.get('navigationEndpoint') or {} - vid = (nav.get('watchEndpoint') or {}).get('videoId') - if not vid: - return None - 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 {'parse': 1, 'jx': 1, 'url': f'https://www.youtube.com/embed/{video_id}?autoplay=1'} - - def _play_video(self, video_id, quality): - try: - data = self.yt_video.extract(video_id) - playable = self.yt_video.choose_playable(data['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)) - return {'parse': 1, 'url': f'https://www.youtube.com/embed/{video_id}?autoplay=1', 'header': json.dumps(self.header)} - - # ------------------ 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''' - - - - - {html.escape(media_base + '&track=video')} - - - -''' - if audio_url: - mpd += f''' - - {html.escape(media_base + '&track=audio')} - - - -''' - mpd += ' \n' - 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: - self.session.close() - except Exception: - pass \ No newline at end of file