上传文件至「py」

This commit is contained in:
2026-08-14 11:24:29 +02:00
parent 4c73051e61
commit 4c008aa4a3
5 changed files with 4454 additions and 0 deletions
+242
View File
@@ -0,0 +1,242 @@
# -*- coding: utf-8 -*-
#作者 千城-爱折腾 🚓 内容均从互联网收集而来 仅供交流学习使用 请24小时内删除,版权归原网站所有 如侵犯了您的权益 请通知作者 将及时删除侵权内容
# [email protected]===================
import re
import requests
from bs4 import BeautifulSoup
from base.spider import Spider
class Spider(Spider):
def __init__(self):
self.host = 'https://m.xiaomidj.com'
self.headers = {
'User-Agent': 'Mozilla/5.0 (Linux; Android 11; SM-G975F) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.120 Mobile Safari/537.36',
'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8',
'Accept-Language': 'zh-CN,zh;q=0.8',
'Referer': self.host,
}
self.default_pic = 'https://raw.giteeusercontent.com/xianluyuan/WP/raw/master/image_download_1767716901851.jpg?metadata=eyJyIjoibWFzdGVyIiwiZnAiOiJpbWFnZV9kb3dubG9hZF8xNzY3NzE2OTAxODUxLmpwZyIsInVpZCI6MTYwMTU2NzEsInBpZCI6NDMxOTI0MDgsInN0byI6ImdpdC1zaGFyZGluZy1zdG8tMTB0LTA0MSIsInJwIjoicmVwb3MvYWEvZWEvYWFlYWJmM2IxODE0YjJjNjc0OTcyODM3ZGU2YThjZDkxYjc1MjYxZjUxNWJhNTg5OGIyOGI3YmZjZjA0NTgzMy5naXQiLCJpc3AiOnRydWUsImV4cGlyZV9hdCI6MTc4MDc1NjgwMH0&signature=cEOlUwSkvyXHiEVgFLk9cgEauGonjpnvdQALP9qkPsc'
self.type_map = {
'串烧车载': '1', '国潮改版': '2', '外文Remix': '3',
'视频舞曲': '31', '酒吧视频': '32', '跳舞视频': '33',
'前场Deep': '38', 'Hiphop': '39', 'Dubstep': '40',
'酒吧串烧': '14', '包房串烧': '15', '包房嗨曲': '41',
'越南电鼓': '5', '前场套曲': '42', '主场套曲': '43',
'后场套曲': '44', '派对歌路': '45', '综合套曲': '46',
'伤感串烧': '9', '劲爆舞曲': '10', '电音车载': '11',
'试音车载': '12', '车载连版': '13', '中文ProgHouse': '17',
'中文FunkyHouse': '18', '中文Electro': '19', '中文Dance&Club': '20',
'中文Disco': '47', '中文越南鼓': '21', '中文综合': '22',
'外文Electro&House': '24', '外文Dance&Club': '25', '外文Disco': '26',
'外文综合': '27', '韩国风Bounce': '28', '反差/变速': '29',
'开场音乐': '30', '私房串烧': '35', '私房单曲': '36', '越南风': '34',
}
def getName(self):
return "精彩DJ"
def homeContent(self, filter):
classes = [{'type_id': cid, 'type_name': name} for name, cid in self.type_map.items()]
return {'class': classes, 'filters': {}}
def homeVideoContent(self):
return self._get_home_recommend()
def _get_home_recommend(self):
url = self.host
resp = requests.get(url, headers=self.headers, timeout=15)
resp.encoding = 'utf-8'
soup = BeautifulSoup(resp.text, 'html.parser')
items = []
for box in soup.select('.index_list_box'):
block_name_tag = box.select_one('.huititle .ztitle li')
if not block_name_tag:
continue
block_name = block_name_tag.get_text(strip=True)
for dl in box.select('.modiv2 dl'):
link = dl.select_one('dt a')
if not link:
continue
href = link.get('href')
if '/' in href:
song_id = href.strip('/').split('/')[-1].replace('.html', '')
else:
song_id = href.replace('.html', '')
title = link.get('title', '').strip()
date_tag = dl.select_one('.d2 font')
date = date_tag.get_text(strip=True) if date_tag else ''
items.append({
'vod_id': song_id,
'vod_name': title,
'vod_pic': self.default_pic,
'vod_remarks': date,
'vod_year': '',
'type_name': block_name
})
return {'list': items, 'pagecount': 1, 'page': 1}
def categoryContent(self, tid, pg, filter, extend):
url = f"{self.host}/dj/id-{tid}-{pg}.html"
print(f"[DEBUG] 请求分类页: {url}")
resp = requests.get(url, headers=self.headers, timeout=15)
resp.encoding = 'utf-8'
soup = BeautifulSoup(resp.text, 'html.parser')
items = []
# 多种选择器适配不同结构
dls = soup.select('.modiv2 dl')
if not dls:
dls = soup.select('.songs_list dl')
if not dls:
dls = soup.select('.index_list_box .modiv2 dl')
if not dls:
dls = [dl for dl in soup.find_all('dl') if dl.find('dt') and dl.find('dt').find('a')]
print(f"[DEBUG] 找到 {len(dls)} 个条目")
for dl in dls:
link = dl.select_one('dt a')
if not link:
continue
href = link.get('href')
if '/' in href:
song_id = href.strip('/').split('/')[-1].replace('.html', '')
else:
song_id = href.replace('.html', '')
title = link.get('title', '').strip()
if not title:
title = link.get_text(strip=True)
date_tag = dl.select_one('.d2 font')
if not date_tag:
date_tag = dl.select_one('.date')
date = date_tag.get_text(strip=True) if date_tag else ''
items.append({
'vod_id': song_id,
'vod_name': title,
'vod_pic': self.default_pic,
'vod_remarks': date,
'vod_year': '',
})
# 分页
total_page = 99
pagination = soup.select('.pagination a')
if not pagination:
pagination = soup.select('.page-list a')
if pagination:
last_link = pagination[-1].get('href')
if last_link:
match = re.search(r'-(\d+)\.html', last_link)
if match:
total_page = int(match.group(1))
elif 'page=' in last_link:
total_page = int(last_link.split('page=')[-1].split('&')[0])
return {
'list': items,
'pagecount': total_page,
'page': int(pg)
}
def detailContent(self, ids):
song_id = ids[0]
url = f"{self.host}/dj/{song_id}.html"
resp = requests.get(url, headers=self.headers, timeout=15)
resp.encoding = 'utf-8'
html = resp.text
mp3_url = None
match = re.search(r"var firstplay\s*=\s*'([^']+)'", html)
if match:
mp3_url = match.group(1)
title = ''
title_match = re.search(r'<div class="center music-name">\s*<<span>(.*?)</span>', html, re.DOTALL)
if title_match:
title = title_match.group(1).strip()
else:
soup = BeautifulSoup(html, 'html.parser')
title_tag = soup.select_one('.music-name span')
if title_tag:
title = title_tag.get_text(strip=True)
pic = ''
pic_match = re.search(r'<img src="([^"]+)" class="[^"]*">', html)
if pic_match:
pic = pic_match.group(1)
else:
soup = BeautifulSoup(html, 'html.parser')
img = soup.select_one('.music-player__img img')
if img and img.get('src'):
pic = img['src']
# 兜底:如果页面没抓到图,就用默认图
if not pic:
pic = self.default_pic
video = {
'vod_id': song_id,
'vod_name': title,
'vod_pic': pic,
'vod_remarks': '',
'vod_year': '',
'vod_area': '',
'vod_actor': '',
'vod_director': '',
'vod_content': '',
'vod_play_from': '精彩DJ',
'vod_play_url': f'正片${mp3_url}' if mp3_url else ''
}
return {'list': [video]}
def playerContent(self, flag, vid, vipFlags):
if not vid.startswith('http'):
song_id = vid
url = f"{self.host}/dj/{song_id}.html"
resp = requests.get(url, headers=self.headers, timeout=15)
html = resp.text
match = re.search(r"var firstplay\s*=\s*'([^']+)'", html)
if match:
mp3_url = match.group(1)
else:
mp3_url = ''
else:
mp3_url = vid
return {'jx': 0, 'parse': 0, 'url': mp3_url, 'header': self.headers}
def searchContent(self, key, quick, pg='1'):
url = f"{self.host}/search/dj"
params = {'key': key, 'page': pg}
resp = requests.get(url, headers=self.headers, params=params, timeout=15)
resp.encoding = 'utf-8'
soup = BeautifulSoup(resp.text, 'html.parser')
items = []
for dl in soup.select('.modiv2 dl'):
link = dl.select_one('dt a')
if not link:
continue
href = link.get('href')
if '/' in href:
song_id = href.strip('/').split('/')[-1].replace('.html', '')
else:
song_id = href.replace('.html', '')
title = link.get('title', '').strip()
items.append({
'vod_id': song_id,
'vod_name': title,
'vod_pic': self.default_pic,
'vod_remarks': '',
'vod_year': '',
})
return {'list': items, 'page': pg}
def init(self, extend=''):
pass
def destroy(self):
pass
def localProxy(self, param):
pass
+232
View File
@@ -0,0 +1,232 @@
# -*- coding: utf-8 -*-
import requests
import urllib.parse
import json
class Spider:
def init(self, extend=""):
self.host = "https://5721004.xyz"
self.headers = {
'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',
'Referer': 'https://www.pandalive.co.kr/',
'Origin': 'https://www.pandalive.co.kr'
}
print("PandaLive 專業版 (僅 PandaTV) 初始化成功")
def getName(self):
return "PandaLive"
def getDependence(self):
return []
def isVideoFormat(self, url):
return False
def manualVideoCheck(self):
pass
def destroy(self):
pass
def localProxy(self, param):
return None
def homeContent(self, filter):
"""首頁 - 僅保留 PandaTV 分類與對應篩選器"""
try:
# 只保留 PandaTV
classes = [
{'type_id': 'pandalive', 'type_name': '🐼 PandaTV'}
]
filters = {
"pandalive": [
{
"key": "type",
"name": "類型",
"value": [
{"n": "全部", "v": "all"},
{"n": "🔞 19+", "v": "adult"},
{"n": "🔐 密碼房", "v": "pw"},
{"n": "💎 粉絲房", "v": "fan"}
]
},
{
"key": "sort",
"name": "排序",
"value": [
{"n": "觀眾量 ↓", "v": "user-desc"},
{"n": "實時熱度 ↓", "v": "totalScoreCnt-desc"},
{"n": "關注量 ↓", "v": "bookmarkCnt-desc"}
]
}
]
}
# 獲取首頁推薦數據 (從 JSON 獲取以保證有圖片)
all_data = self._fetch_json_data()
return {
'class': classes,
'list': all_data[:30],
'filters': filters
}
except Exception as e:
print(f"homeContent錯誤: {e}")
return {'class': [], 'list': []}
def homeVideoContent(self):
try:
return {'list': self._fetch_json_data()[:20]}
except:
return {'list': []}
def categoryContent(self, tid, pg, filter, extend):
"""分類頁 - 基於 JSON 的篩選排序邏輯"""
try:
all_list = self._fetch_json_data()
filtered = all_list
# 1. 執行篩選
f_type = extend.get('type', 'all')
if f_type == 'adult':
filtered = [v for v in filtered if v.get('_isAdult')]
elif f_type == 'pw':
filtered = [v for v in filtered if v.get('_isPw')]
elif f_type == 'fan':
filtered = [v for v in filtered if v.get('_type') == 'fan']
# 2. 執行排序
sort_type = extend.get('sort', 'user-desc')
if sort_type == 'user-desc':
filtered.sort(key=lambda x: x.get('_user_count', 0), reverse=True)
elif sort_type == 'totalScoreCnt-desc':
filtered.sort(key=lambda x: x.get('_score', 0), reverse=True)
elif sort_type == 'bookmarkCnt-desc':
filtered.sort(key=lambda x: x.get('_bookmark', 0), reverse=True)
# 3. 分頁
pg = int(pg)
limit = 30
start = (pg - 1) * limit
end = start + limit
page_list = filtered[start:end] if start < len(filtered) else []
return {
'list': page_list,
'page': pg,
'pagecount': (len(filtered) + limit - 1) // limit if filtered else 1,
'limit': limit,
'total': len(filtered)
}
except Exception as e:
print(f"categoryContent錯誤: {e}")
return {'list': [], 'page': int(pg)}
def _fetch_json_data(self):
"""抓取 list.json 數據,確保 vod_pic 獲取正確"""
try:
url = f"{self.host}/player/list.json"
res = requests.get(url, headers=self.headers, timeout=10)
if res.status_code != 200:
return []
data = res.json()
raw_list = data.get('list', [])
processed = []
for item in raw_list:
user_id = item.get('userId', '')
nick = item.get('userNick', '未知主播')
title = item.get('title', '無標題')
is_adult = item.get('isAdult', False)
is_pw = item.get('isPw', False)
v_type = item.get('type', '')
processed.append({
'vod_id': f"live_{user_id}",
'vod_name': f"📺 {nick}",
'vod_pic': item.get('thumbUrl', 'https://tupian.li/images/2024/03/30/660769b1ba623.png'),
'vod_remarks': f"👤 {item.get('user', 0)} {'🔞' if is_adult else ''}",
'vod_content': title,
'vod_actor': user_id,
'_isAdult': is_adult,
'_isPw': is_pw,
'_type': v_type,
'_user_count': item.get('user', 0),
'_score': item.get('totalScoreCnt', 0),
'_bookmark': item.get('bookmarkCnt', 0)
})
return processed
except Exception as e:
print(f"JSON抓取失敗: {e}")
return []
def detailContent(self, ids):
"""詳情頁 - 保持對接 list.m3u 獲取真實流地址的邏輯"""
try:
first_id = ids[0] if isinstance(ids, list) else ids
user_id = first_id.replace("live_", "")
stream_url = ""
m3u_res = requests.get(f"{self.host}/player/list.m3u", headers=self.headers, timeout=10)
if m3u_res.status_code == 200:
lines = m3u_res.text.split('\n')
for i, line in enumerate(lines):
# 匹配格式: #EXTINF:0,主播ID,主播名稱
if f",{user_id}," in line and i + 1 < len(lines):
stream_url = lines[i+1].strip()
break
if not stream_url:
# 如果 M3U 匹配不到,嘗試模糊匹配主播 ID
for i, line in enumerate(lines):
if user_id in line and i + 1 < len(lines):
stream_url = lines[i+1].strip()
break
proxies = [
"https://hubu.515355.xyz/proxy/?",
"https://flank.515355.xyz/proxy/",
"https://uae2.515355.xyz/proxy/",
"https://pol.515355.xyz/proxy/",
"https://f00.515355.xyz/proxy/",
"https://ce2.515355.xyz/proxy/?",
]
# 1. 首先创建列表,并将“直连”作为第一个元素添加进去
play_links = [f"直連${stream_url}"]
# 2. 然后通过 extend() 方法或循环,将生成的代理链接追加到列表中
play_links.extend([f"代理{i}${p}{stream_url}" for i, p in enumerate(proxies, 1)])
vod = {
'vod_id': first_id,
'vod_name': f"PandaTV - {user_id}",
'vod_pic': 'https://tupian.li/images/2024/03/30/660769b1ba623.png',
'vod_content': f'主播: {user_id}',
'vod_play_from': 'PandaLive',
'vod_play_url': '#'.join(play_links)
}
return {'list': [vod]}
except Exception as e:
print(f"detailContent錯誤: {e}")
return {'list': []}
def searchContent(self, key, quick, pg="1"):
"""搜索 - 帶 pg="1" 修復"""
try:
all_v = self._fetch_json_data()
key_l = key.lower()
res = [v for v in all_v if key_l in v['vod_name'].lower() or key_l in v['vod_actor'].lower()]
return {'list': res[:50], 'page': int(pg)}
except:
return {'list': [], 'page': int(pg)}
def playerContent(self, flag, id, vipFlags):
return {
'parse': 0,
'url': id,
'header': self.headers
}
+3201
View File
@@ -0,0 +1,3201 @@
#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'''<?xml version="1.0" encoding="UTF-8"?>
<MPD xmlns="urn:mpeg:dash:schema:mpd:2011" type="static" mediaPresentationDuration="{duration_pt}" minBufferTime="PT1.5S" profiles="urn:mpeg:dash:profile:isoff-on-demand:2011">
<Period id="1" start="PT0S">
<AdaptationSet mimeType="{html.escape(video_mime)}" startWithSAP="1" segmentAlignment="true" scanType="progressive">
<Representation id="v{video_item.get('itag', 1)}" bandwidth="{video_item.get('bitrate', 1000000)}" codecs="{html.escape(video_item.get('codecs') or '')}" height="{video_item.get('height', 0)}" width="{video_item.get('width', 0)}">
<BaseURL>{html.escape(media_base + '&track=video')}</BaseURL>
<SegmentBase indexRange="{video_index.get('start', '0')}-{video_index.get('end', '0')}"><Initialization range="{video_init.get('start', '0')}-{video_init.get('end', '0')}"/></SegmentBase>
</Representation>
</AdaptationSet>
'''
if audio_url:
mpd += f''' <AdaptationSet mimeType="{html.escape(audio_mime)}" startWithSAP="1" segmentAlignment="true" lang="und">
<Representation id="a{audio_item.get('itag', 1)}" bandwidth="{audio_item.get('bitrate', 128000)}" codecs="{html.escape(audio_item.get('codecs') or '')}" audioSamplingRate="44100">
<BaseURL>{html.escape(media_base + '&track=audio')}</BaseURL>
<SegmentBase indexRange="{audio_index.get('start', '0')}-{audio_index.get('end', '0')}"><Initialization range="{audio_init.get('start', '0')}-{audio_init.get('end', '0')}"/></SegmentBase>
</Representation>
</AdaptationSet>
'''
mpd += ' </Period>\n</MPD>'
return [200, 'application/dash+xml', mpd]
def _proxy_media(self, params):
vid = params.get('vid')
quality = params.get('quality') or '1080p'
track = params.get('track')
data = self.getCache(f'yt_{vid}_{quality}') if vid else None
if not data or track not in ('video', 'audio'):
return [404, 'text/plain', '媒体不存在']
target_url = data.get('video_url') if track == 'video' else data.get('audio_url')
if not target_url:
return [404, 'text/plain', f'{track} 流不存在']
media_item = data.get('video_item') if track == 'video' else data.get('audio_item')
headers = self.header.copy()
headers.update((media_item or {}).get('headers') or {})
range_header = params.get('range') or params.get('Range')
if range_header:
headers['Range'] = range_header
try:
r = self.session.get(target_url, headers=headers, stream=True, timeout=30)
content_type = r.headers.get('content-type', 'application/octet-stream')
resp_headers = {'Content-Type': content_type, 'Accept-Ranges': 'bytes', 'Cache-Control': 'no-cache'}
if r.headers.get('content-range'):
resp_headers['Content-Range'] = r.headers.get('content-range')
if r.headers.get('content-length'):
resp_headers['Content-Length'] = r.headers.get('content-length')
return [r.status_code, content_type, r.content, resp_headers]
except Exception as e:
return [500, 'text/plain', f'代理媒体失败: {str(e)}']
def _proxy_hls(self, params):
key = params.get('key') or ''
item = self.hls_url_cache.get(key)
if not item or item.get('expires', 0) < time.time():
debug_log('hls proxy missing', {'key': key})
return [404, 'text/plain', 'HLS 缓存已过期']
item['expires'] = time.time() + self._hls_ttl(item.get('kind'))
target_url = item.get('url') or ''
try:
headers = self._hls_headers(target_url, item.get('kind'))
response = self.session.get(target_url, headers=headers, stream=True, timeout=15)
retried = False
if item.get('kind') == 'media' and response.status_code == 403:
retry_headers = self._hls_headers(target_url, 'media_retry')
response.close()
retried = True
response = self.session.get(target_url, headers=retry_headers, stream=True, timeout=15)
content_type = response.headers.get('content-type') or ''
is_m3u8 = item.get('kind') in ('master', 'playlist') or 'mpegurl' in content_type.lower() or target_url.split('?')[0].endswith('.m3u8')
debug_log('hls proxy response', {
'key': key,
'kind': item.get('kind'),
'status': response.status_code,
'content_type': content_type,
'is_m3u8': is_m3u8,
'url_len': len(target_url),
'path_tail': target_url.split('?')[0][-80:],
'retried': retried,
})
if is_m3u8:
text = response.text
rewritten = self._rewrite_m3u8(text, target_url, item.get('video_id') or '')
return [response.status_code, 'application/vnd.apple.mpegurl', rewritten, {'Content-Type': 'application/vnd.apple.mpegurl', 'Cache-Control': 'no-cache'}]
resp_headers = {'Content-Type': content_type or 'application/octet-stream', 'Cache-Control': 'no-cache'}
if response.headers.get('content-length'):
resp_headers['Content-Length'] = response.headers.get('content-length')
return [response.status_code, content_type or 'application/octet-stream', response.content, resp_headers]
except Exception as e:
debug_log('hls proxy error', {'key': key, 'error': repr(e)})
return [500, 'text/plain', f'HLS 代理失败: {str(e)}']
def destroy(self):
try:
self.session.close()
except Exception:
pass
+188
View File
@@ -0,0 +1,188 @@
# coding=utf-8
#!/usr/bin/python
import sys
sys.path.append('..')
from base.spider import Spider
import json
import time
import urllib.parse
import re
import requests
from lxml import etree
import base64
class Spider(Spider):
def getName(self):
return "UAA[听]"
def init(self, extend):
pass
def homeContent(self, filter):
result = {}
cateManual = {
"有声小说": "有声小说",
"淫词艳曲": "淫词艳曲",
"激情骚麦": "激情骚麦",
"寸止训练": "寸止训练",
"ASMR": "ASMR"
}
classes = []
for key in cateManual:
classes.append({
'type_name': key,
'type_id': cateManual[key]
})
result['class'] = classes
return result
def homeVideoContent(self):
result = {}
return result
def categoryContent(self, tid, pg, filter, extend):
result = {}
url = 'https://www.uaa2601.com/api/audio/app/audio/search?category={0}&orderType=1&page={1}&searchType=1&size=42'.format(tid, pg)
rsp = self.fetch(url)
content = rsp.text
videos = []
data = json.loads(content)
for item in data['model']['data']:
videos.append({
"vod_id": item['id'],
"vod_name": item['title'],
"vod_pic": item['coverUrl'],
"vod_remarks": item['categories']
})
result['list'] = videos
result['page'] = pg
result['pagecount'] = 9999
result['limit'] = 42
result['total'] = 999999
return result
def detailContent(self, array):
tid = array[0]
url = 'https://www.uaa2601.com/api/audio/app/audio/intro?id={0}'.format(tid)
rsp = self.fetch(url)
content = rsp.text
data = json.loads(content)
model = data['model']
# 构建播放列表
play_list = []
if 'chapters' in model and model['chapters']:
for chapter in model['chapters']:
chapter_id = chapter.get('id', '')
chapter_title = chapter.get('title', '{}'.format(chapter.get('order', 1)))
# 获取章节播放链接
chapter_url = self.getChapterUrl(chapter_id)
if chapter_url:
play_list.append('{}${}'.format(chapter_title, chapter_url))
# 如果没有章节信息,使用默认播放链接
if not play_list and 'latestReadChapterUrl' in model:
play_list.append('第1集${}'.format(model['latestReadChapterUrl']))
play_url = '#'.join(play_list) if play_list else ''
# 构建详细信息
vod_actor = model.get('author', '未知') # CV信息
vod_area = model.get('categories', '') # 分类信息
# 构建备注信息,包含收听量和收藏量
remarks_parts = []
if 'playCount' in model:
play_count = self.format_count(model['playCount'])
remarks_parts.append(f'收听:{play_count}')
if 'collectCount' in model:
collect_count = self.format_count(model['collectCount'])
remarks_parts.append(f'收藏:{collect_count}')
vod_remarks = ' | '.join(remarks_parts) if remarks_parts else model.get('updateState', '')
vod = {
"vod_id": tid,
"vod_name": model['title'],
"vod_pic": model['coverUrl'],
"vod_content": model.get('intro', ''),
"vod_actor": vod_actor, # 显示CV信息
"vod_area": vod_area, # 显示分类信息
"vod_remarks": vod_remarks, # 显示收听量和收藏量
"vod_play_from": "UAA",
"vod_play_url": play_url
}
result = {
'list': [vod]
}
return result
def format_count(self, count):
"""格式化数字显示,如18200显示为18.2K"""
try:
count = int(count)
if count >= 10000:
return f"{count/10000:.1f}"
elif count >= 1000:
return f"{count/1000:.1f}K"
else:
return str(count)
except:
return str(count)
def getChapterUrl(self, chapter_id):
"""获取章节播放链接"""
if not chapter_id:
return ''
try:
url = 'https://www.uaa2601.com/api/audio/app/audio/chapter?id={}'.format(chapter_id)
rsp = self.fetch(url)
data = json.loads(rsp.text)
if data.get('model') and data['model'].get('chapterUrl'):
return data['model']['chapterUrl']
except:
pass
return ''
def searchContent(self, key, quick, page='1'):
result = {}
url = 'https://www.uaa001.com/api/audio/app/audio/search?category=&keyword={0}&orderType=1&orderType=1&origin=&page=1&searchType=1&size=32&tag='.format(urllib.parse.quote(key))
rsp = self.fetch(url)
content = rsp.text
videos = []
data = json.loads(content)
for item in data['model']['data']:
videos.append({
"vod_id": item['id'],
"vod_name": item['title'],
"vod_pic": item['coverUrl'],
"vod_remarks": item['categories']
})
result['list'] = videos
return result
def playerContent(self, flag, id, vipFlags):
result = {}
# 直接从播放链接播放,不需要额外解析
result["parse"] = 0
result["playUrl"] = ''
result["url"] = id
result["header"] = {
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36",
"Referer": "https://www.uaa2601.com/"
}
return result
def isVideoFormat(self, url):
pass
def manualVideoCheck(self):
pass
def localProxy(self, param):
action = {}
return [200, "video/MP2T", action, ""]
+591
View File
@@ -0,0 +1,591 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
ztzssz.com 爬虫 - TVBox/影视仓 Spider 插件
支持分类浏览、筛选(类型/地区/语言/年份/字母)、搜索、详情获取、播放链接解析
选集正序排列,海报封面补充
"""
import re
import json
import logging
import urllib.parse
import os
import sys
import requests
from bs4 import BeautifulSoup
sys.path.append(os.path.dirname(os.path.abspath(__file__)))
try:
from base.spider import Spider as BaseSpider
except ImportError:
BaseSpider = object
logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s')
logger = logging.getLogger(__name__)
class Spider(BaseSpider):
"""ztzssz.com 爬虫"""
BASE_URL = "https://www.ztzssz.com"
HEADERS = {
"User-Agent": "Mozilla/5.0 (Linux; Android 12; SM-S908U) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Mobile Safari/537.36",
"Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
"Accept-Language": "zh-CN,zh;q=0.9,en;q=0.8",
"Referer": "https://www.ztzssz.com/",
}
# 分类:键为网站分类ID,与 /vodtype/{id}.html 对应
CATEGORY_MAP = {
"1": {"name": "电影", "url": "/vodtype/1.html"},
"2": {"name": "电视剧", "url": "/vodtype/2.html"},
"3": {"name": "综艺", "url": "/vodtype/3.html"},
"4": {"name": "动漫", "url": "/vodtype/4.html"},
"20": {"name": "短剧", "url": "/vodtype/20.html"},
"35": {"name": "动画片", "url": "/vodtype/35.html"},
"36": {"name": "4K电影", "url": "/vodtype/36.html"},
"37": {"name": "Netflix作品", "url": "/vodtype/37.html"},
}
def __init__(self):
try:
super().__init__()
except Exception:
pass
self.session = requests.Session()
self.session.verify = False
self.session.headers.update(self.HEADERS)
def init(self, extend):
pass
def getName(self):
return "ztzssz影视"
def _parse_ext(self, ext):
"""解析ext参数,兼容dict和JSON字符串"""
if not ext:
return {}
if isinstance(ext, dict):
return ext
if isinstance(ext, str):
try:
return json.loads(ext)
except Exception:
return {}
return {}
def _get(self, url):
try:
resp = self.session.get(url, timeout=30)
resp.encoding = "utf-8"
return resp
except Exception as e:
logger.error(f"请求失败 {url}: {e}")
return None
# ==================== 首页 ====================
def homeContent(self, filter=False):
try:
url = f"{self.BASE_URL}/"
resp = self._get(url)
if not resp:
return {}
classes = [{"type_id": cid, "type_name": info["name"]}
for cid, info in self.CATEGORY_MAP.items()]
home_list = self._parse_video_list(resp.text)
return {
"class": classes,
"filters": self._get_filters(),
"list": home_list,
}
except Exception as e:
logger.error(f"获取首页失败: {e}")
return {}
def homeVideoContent(self):
home = self.homeContent()
return {"list": home.get("list", [])}
# ==================== 筛选 ====================
def _get_filters(self):
"""筛选配置:类型/地区/语言/年份/字母
网站筛选URL为中文值(如 喜剧/大陆/国语),故筛选value直接用中文。
"""
filters = {}
# 类型:按分类区分(电影/电视剧/动漫等类型不同),这里取较通用的集合
type_values_common = [
{"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": "历史"}, {"n": "运动", "v": "运动"},
{"n": "农村", "v": "农村"}, {"n": "儿童", "v": "儿童"},
{"n": "网络电影", "v": "网络电影"},
]
# 电视剧/综艺/动漫常用类型
type_values_series = [
{"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": "其他"},
]
area_values = [
{"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": "其他"},
]
lang_values = [
{"n": "全部", "v": ""},
{"n": "国语", "v": "国语"}, {"n": "英语", "v": "英语"},
{"n": "粤语", "v": "粤语"}, {"n": "闽南语", "v": "闽南语"},
{"n": "韩语", "v": "韩语"}, {"n": "日语", "v": "日语"},
{"n": "法语", "v": "法语"}, {"n": "德语", "v": "德语"},
{"n": "其它", "v": "其它"},
]
year_values = [{"n": "全部", "v": ""}]
for y in range(2026, 1999, -1):
year_values.append({"n": str(y), "v": str(y)})
letter_values = [{"n": "全部", "v": ""}]
for letter in "ABCDEFGHIJKLMNOPQRSTUVWXYZ":
letter_values.append({"n": letter, "v": letter})
letter_values.append({"n": "其他", "v": "0"})
for cate_id in self.CATEGORY_MAP:
if cate_id in ("2", "3", "4", "20"):
tv = type_values_series
else:
tv = type_values_common
filters[cate_id] = [
{"key": "type", "name": "类型", "value": tv},
{"key": "area", "name": "地区", "value": area_values},
{"key": "lang", "name": "语言", "value": lang_values},
{"key": "year", "name": "年份", "value": year_values},
{"key": "letter", "name": "字母", "value": letter_values},
]
return filters
# ==================== 分类 ====================
def categoryContent(self, tid, pg, filter, ext):
try:
page = int(pg) if pg else 1
type_id = str(tid)
cate_info = self.CATEGORY_MAP.get(type_id)
if not cate_info:
return {"list": [], "page": page, "pagecount": 1, "limit": 20, "total": 0}
ext_dict = self._parse_ext(ext)
type_filter = ext_dict.get('type', '')
lang_filter = ext_dict.get('lang', '')
year_filter = ext_dict.get('year', '')
letter_filter = ext_dict.get('letter', '')
area_filter = ext_dict.get('area', '')
has_filter = any([type_filter, lang_filter, year_filter, letter_filter, area_filter])
if has_filter:
# 筛选URL: /vodshow/{cate_id}-{area}--{type}-{lang}-{letter}---{page}---{year}.html
# 共12段: [cate_id, area, '', type, lang, letter, '', '', page, '', '', year]
seg_page = str(page) if page > 1 else ''
segs = [
type_id, area_filter, '', type_filter, lang_filter,
letter_filter, '', '', seg_page, '', '', year_filter
]
url = f"{self.BASE_URL}/vodshow/{'-'.join(segs)}.html"
else:
# 无筛选: /vodtype/{id}.html,分页 /vodtype/{id}-{page}.html
url = self.BASE_URL + cate_info["url"]
if page > 1:
url = url.replace('.html', f'-{page}.html')
resp = self._get(url)
if not resp:
return {"list": [], "page": page, "pagecount": 1, "limit": 20, "total": 0}
videos = self._parse_video_list(resp.text)
pagecount = self._parse_total_pages(resp.text)
return {
"list": videos,
"page": page,
"pagecount": pagecount,
"limit": 20,
"total": len(videos) * pagecount if pagecount else len(videos),
}
except Exception as e:
logger.error(f"获取分类内容失败: {e}")
return {"list": [], "page": 1, "pagecount": 1, "limit": 20, "total": 0}
def _parse_total_pages(self, html):
"""解析总页数,格式: <span class="num">1/1213</span>"""
patterns = [
r'class="num"[^>]*>\s*(\d+)\s*/\s*(\d+)',
r'(\d+)\s*/\s*(\d+)\s*</span>',
r'/vodtype/\d+-(\d+)\.html["\'][^>]*>尾页',
r'/vodshow/[^"\'-]+-(\d+)---\.html["\'][^>]*>尾页',
]
for pattern in patterns:
match = re.search(pattern, html)
if match:
return int(match.group(2) if match.lastindex and match.lastindex >= 2 else match.group(1))
return 1
def _parse_video_list(self, html):
"""解析视频列表(首页/分类/筛选/搜索通用)
卡片: a.ewave-vodlist__thumb (含 data-original, title, href) 或 div.ewave-vodlist__thumb > a.thumb-link
"""
videos = []
soup = BeautifulSoup(html, 'html.parser')
# 兼容两种结构:a.vodlist__thumb 自带链接,或 div.vodlist__thumb 内含 a.thumb-link
items = soup.find_all('a', class_=re.compile(r'vodlist__thumb'))
if not items:
items = soup.find_all('div', class_=re.compile(r'vodlist__thumb'))
seen_ids = set()
for item in items:
href = item.get('href', '')
if not href:
a_inner = item.find('a', href=True)
href = a_inner.get('href', '') if a_inner else ''
vid_match = re.search(r'/voddetail/(\d+)\.html', href)
if not vid_match:
continue
vid_id = vid_match.group(1)
if vid_id in seen_ids:
continue
seen_ids.add(vid_id)
title = item.get('title', '')
poster = item.get('data-original', '')
if not poster:
img = item.find('img')
if img:
poster = img.get('data-original', '') or img.get('src', '')
if not title:
img = item.find('img')
if img:
title = img.get('alt', '') or item.get('title', '')
remark_tag = item.find(class_=re.compile(r'pic-text|pic_tag'))
remarks = remark_tag.get_text(strip=True) if remark_tag else ''
if title:
videos.append({
"vod_id": vid_id,
"vod_name": title,
"vod_pic": poster,
"vod_remarks": remarks,
})
return videos
# ==================== 详情 ====================
def detailContent(self, ids):
try:
vod_id = ids[0] if isinstance(ids, list) else str(ids)
url = f"{self.BASE_URL}/voddetail/{vod_id}.html"
resp = self._get(url)
if not resp:
return {"list": []}
html = resp.text
soup = BeautifulSoup(html, 'html.parser')
# 标题: h1.title 内第一个 span(去除评分)
title = ''
h1 = soup.find('h1', class_=re.compile(r'title'))
if h1:
span = h1.find('span')
title = span.get_text(strip=True) if span else h1.get_text(strip=True)
if not title:
h1 = soup.find('h1')
if h1:
title = h1.get_text(strip=True)
# 海报: div.ewave-content__thumb 内 img[data-original]
poster = ''
thumb_box = soup.find(class_=re.compile(r'content__thumb|vodlist__thumb'))
if thumb_box:
img = thumb_box.find('img')
if img:
poster = img.get('data-original', '') or img.get('src', '')
# 信息: p.data 列表,一个p内可能含多个 label(类型/地区/年份用 span.text-muted 分隔)
year = area = type_name = actor = director = content = remarks = ''
data_ps = soup.find_all('p', class_=re.compile(r'data'))
for p in data_ps:
# 收集该p下所有 label span 及其后续文本,按 label 分组
labels = p.find_all('span', class_=re.compile(r'text-muted|left'))
for idx, label_tag in enumerate(labels):
label = label_tag.get_text(strip=True)
# 该label之后、下一个label之前的所有文本
nxt = labels[idx + 1] if idx + 1 < len(labels) else None
val = ''
for sib in label_tag.next_siblings:
if sib is nxt:
break
if hasattr(sib, 'get_text'):
t = sib.get_text(strip=True)
else:
t = str(sib).strip()
if t:
val += t
val = val.strip()
if label.startswith('类型'):
type_name = val
elif label.startswith('地区'):
area = val
elif label.startswith('年份'):
year_match = re.search(r'(\d{4})', val)
year = year_match.group(1) if year_match else val
elif label.startswith('主演'):
actor = val
elif label.startswith('导演'):
director = val
elif label.startswith('更新'):
remarks = val
# 简介: p.desc
desc_tag = soup.find('p', class_=re.compile(r'desc|content__desc'))
if desc_tag:
content = desc_tag.get_text(strip=True)
content = re.sub(r'详情\s*$', '', content).strip()
# 播放源和集数(正序)
play_from_list, play_url_list = self._parse_play_sources(html, vod_id)
vod_item = {
"vod_id": vod_id,
"vod_name": title,
"vod_pic": poster,
"type_name": type_name,
"vod_year": year,
"vod_area": area,
"vod_remarks": remarks,
"vod_actor": actor,
"vod_director": director,
"vod_content": content,
"vod_play_from": '$$$'.join(play_from_list),
"vod_play_url": '$$$'.join(play_url_list),
}
return {"list": [vod_item]}
except Exception as e:
logger.error(f"获取详情失败: {e}")
return {"list": []}
def _parse_play_sources(self, html, vod_id):
"""解析播放源和集数 - 正序排列
结构: ul.nav-tabs > li > a[href="#playlist{sid}"] (源名)
div.tab-pane#playlist{sid} > ul > a[href="/vodplay/{vid}-{sid}-{nid}.html"] (集数)
"""
play_from_list = []
play_url_list = []
soup = BeautifulSoup(html, 'html.parser')
# 源名映射: {sid: 源名}
source_names = {}
nav = soup.find('ul', class_=re.compile(r'nav-tabs'))
if nav:
for a in nav.find_all('a', href=True):
m = re.search(r'#playlist(\w+)', a.get('href', ''))
if m:
source_names[m.group(1)] = a.get_text(strip=True)
# 每个 tab-pane 为一个源
panes = soup.find_all('div', class_=re.compile(r'tab-pane'))
if not panes:
# 兜底:直接按 play 链接分组
return self._parse_play_sources_fallback(html, vod_id)
for pane in panes:
pane_id = pane.get('id', '')
sid_match = re.search(r'playlist(\w+)', pane_id)
if not sid_match:
continue
sid = sid_match.group(1)
from_name = source_names.get(sid, f"线路{sid}")
ul = pane.find('ul')
if not ul:
continue
episodes = []
seen_nid = set()
for a in ul.find_all('a', href=re.compile(r'/vodplay/')):
href = a.get('href', '')
m = re.search(r'/vodplay/(\d+)-(\w+)-(\d+)\.html', href)
if not m:
continue
nid = int(m.group(3))
if nid in seen_nid:
continue
seen_nid.add(nid)
ep_name = a.get_text(strip=True) or f"{nid}"
full_url = self.BASE_URL + href
episodes.append((nid, ep_name, full_url))
if not episodes:
continue
# 正序排列
episodes = sorted(episodes, key=lambda x: x[0])
play_from_list.append(from_name)
urls = [f"{name}${u}" for _, name, u in episodes]
play_url_list.append('#'.join(urls))
if not play_from_list:
return self._parse_play_sources_fallback(html, vod_id)
return play_from_list, play_url_list
def _parse_play_sources_fallback(self, html, vod_id):
"""兜底:从所有 play 链接按 sid 分组"""
play_from_list = []
play_url_list = []
links = re.findall(r'/vodplay/\d+-\w+-\d+\.html', html)
sources = {}
for link in links:
m = re.match(r'/vodplay/(\d+)-(\w+)-(\d+)\.html', link)
if not m:
continue
sid = m.group(2)
nid = int(m.group(3))
sources.setdefault(sid, {})[nid] = link
for sid in sorted(sources.keys()):
eps = sources[sid]
episodes = sorted(eps.items())
play_from_list.append(f"线路{sid}")
urls = [f"{nid}集${self.BASE_URL}{link}" for nid, link in episodes]
play_url_list.append('#'.join(urls))
return play_from_list, play_url_list
# ==================== 播放 ====================
def playerContent(self, flag, id, vipFlags):
"""解析播放页 m3u8 链接"""
try:
url = id
# 兼容相对路径
if url.startswith('/'):
url = self.BASE_URL + url
elif not url.startswith('http'):
url = self.BASE_URL + '/vodplay/' + url
resp = self._get(url)
if not resp:
return {}
m3u8_url = self._extract_m3u8(resp.text)
if not m3u8_url:
return {}
return {
"parse": 0,
"playUrl": "",
"url": m3u8_url,
"header": "",
}
except Exception as e:
logger.error(f"解析播放失败: {e}")
return {}
def _extract_m3u8(self, html):
"""从播放页提取 m3u8 链接"""
match = re.search(r'player_aaaa\s*=\s*({[^<]+})', html)
if match:
try:
data = json.loads(match.group(1))
m3u8_url = data.get('url', '')
if m3u8_url:
return m3u8_url
except Exception:
pass
# 兜底:直接匹配 m3u8
m = re.search(r'(https?://[^"\'\\s]+\.m3u8[^"\'\\s]*)', html)
return m.group(1) if m else ''
# ==================== 搜索 ====================
def searchContent(self, key, quick, pg="1"):
"""搜索内容 - TVBox标准接口(key, quick, pg)
优先使用AJAX接口(JSON格式,速度快),失败则回退到HTML搜索页
"""
try:
page = int(pg) if pg else 1
encoded_key = urllib.parse.quote(key)
# 优先尝试 AJAX 建议接口(返回JSON,数据干净)
ajax_url = f"{self.BASE_URL}/index.php/ajax/suggest?mid=1&wd={encoded_key}"
resp = self._get(ajax_url)
if resp and resp.headers.get('content-type', '').find('json') >= 0:
try:
data = resp.json()
if data.get('code') == 1 and data.get('list'):
videos = []
for item in data['list']:
videos.append({
"vod_id": str(item.get('id', '')),
"vod_name": item.get('name', ''),
"vod_pic": item.get('pic', ''),
"vod_remarks": item.get('note', ''),
})
return {
"list": videos,
"page": data.get('page', page),
"pagecount": data.get('pagecount', 1),
"limit": data.get('limit', 20),
"total": data.get('total', len(videos)),
}
except Exception:
pass
# 回退到HTML搜索页
url = f"{self.BASE_URL}/vodsearch/-------------.html?wd={encoded_key}"
if page > 1:
url += f"&page={page}"
resp = self._get(url)
if not resp:
return {"list": [], "page": page, "pagecount": 1, "limit": 20, "total": 0}
if any(k in resp.text for k in ['验证码', '人机验证', '安全验证', 'just_a_test']):
logger.warning("搜索被安全验证拦截")
return {"list": [], "page": page, "pagecount": 1, "limit": 20, "total": 0}
videos = self._parse_video_list(resp.text)
pagecount = self._parse_total_pages(resp.text)
return {
"list": videos,
"page": page,
"pagecount": pagecount if pagecount > 1 else 1,
"limit": 20,
"total": len(videos) * pagecount if pagecount > 1 else len(videos),
}
except Exception as e:
logger.error(f"搜索失败: {e}")
return {"list": [], "page": 1, "pagecount": 1, "limit": 20, "total": 0}