diff --git a/py/剧迷.py b/py/剧迷.py
new file mode 100644
index 0000000..ee80772
--- /dev/null
+++ b/py/剧迷.py
@@ -0,0 +1,116 @@
+# -*- coding: utf-8 -*-
+import json
+import re
+from urllib.parse import quote, urljoin
+
+import requests
+from lxml import etree
+from base.spider import Spider
+
+
+class Spider(Spider):
+ def getName(self): return "Gimy影视"
+
+ def init(self, extend=""):
+ self.host = "https://gimytv.biz"
+ self.headers = {"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36", "Referer": self.host + "/"}
+ self.classes = [{"type_id": "2", "type_name": "电视剧"}, {"type_id": "4", "type_name": "动漫"}, {"type_id": "3", "type_name": "综艺"}, {"type_id": "1", "type_name": "电影"}, {"type_id": "25", "type_name": "短剧"}]
+ subtypes = {
+ "1": [("全部", "1"), ("动作片", "6"), ("喜剧片", "7"), ("爱情片", "8"), ("科幻片", "9"), ("恐怖片", "10"), ("剧情片", "11"), ("战争片", "12"), ("动画电影", "24")],
+ "2": [("全部", "2"), ("陆剧", "13"), ("短剧", "25"), ("韩剧", "15"), ("美剧", "16"), ("日剧", "20"), ("台剧", "14"), ("海外剧", "21"), ("港剧", "22"), ("纪录片", "23")],
+ "3": [("全部", "3")], "4": [("全部", "4")], "25": [("全部", "25")]
+ }
+ years = [{"n": "全部", "v": ""}] + [{"n": str(x), "v": str(x)} for x in range(2026, 2015, -1)]
+ sorts = [{"n": "最新更新", "v": "time"}, {"n": "最新上架", "v": "time_add"}, {"n": "周人气", "v": "hits_week"}, {"n": "总人气", "v": "hits"}]
+ self.filters = {tid: [{"key": "type", "name": "类型", "value": [{"n": n, "v": v} for n, v in values]}, {"key": "year", "name": "年份", "value": years}, {"key": "by", "name": "排序", "value": sorts}] for tid, values in subtypes.items()}
+
+ def _get(self, url, sid=""):
+ try:
+ headers = dict(self.headers)
+ if sid: headers["Cookie"] = "sid=" + str(sid)
+ response = requests.get(url, headers=headers, timeout=15)
+ response.raise_for_status()
+ response.encoding = response.apparent_encoding or "utf-8"
+ return response.text
+ except Exception:
+ return ""
+
+ def _fix(self, url): return urljoin(self.host + "/", url or "")
+
+ def _parse_list(self, html):
+ if not html: return []
+ tree, result, seen = etree.HTML(html), [], set()
+ for node in tree.xpath('//a[contains(concat(" ",normalize-space(@class)," ")," video-pic ") and contains(@href,"/voddetail/")]'):
+ match = re.search(r"/voddetail/(\d+)\.html", node.get("href", ""))
+ if not match or match.group(1) in seen: continue
+ seen.add(match.group(1))
+ name = node.get("title") or "".join(node.xpath('.//img/@alt')).strip()
+ pic = node.get("data-original") or node.get("data-src") or node.get("data-lazyload") or "".join(node.xpath('.//img/@data-original | .//img/@data-src | .//img/@src'))
+ remark = "".join(node.xpath('.//*[contains(@class,"note")]//text()')).strip()
+ result.append({"vod_id": match.group(1), "vod_name": name.strip(), "vod_pic": self._fix(pic), "vod_remarks": remark})
+ return result
+
+ def _pagecount(self, tree, page):
+ values = [int(x) for x in tree.xpath('//a[contains(@href,"/vodshow/")]/@href') for x in re.findall(r"-(\d+)---", x)]
+ return max(values + [page])
+
+ def homeContent(self, filter): return {"class": self.classes, "list": self._parse_list(self._get(self.host + "/")), "filters": self.filters}
+
+ def categoryContent(self, tid, pg, filter, extend):
+ page, ext = max(1, int(pg or 1)), extend if isinstance(extend, dict) else {}
+ selected, year, by = str(ext.get("type") or tid), str(ext.get("year") or ""), str(ext.get("by") or "time")
+ url = f"{self.host}/vodshow/{selected}--{by}------{page}---{year}.html"
+ html = self._get(url)
+ tree = etree.HTML(html) if html else etree.HTML("
")
+ videos = self._parse_list(html)
+ return {"page": page, "pagecount": self._pagecount(tree, page), "limit": len(videos), "total": self._pagecount(tree, page) * max(len(videos), 1), "list": videos}
+
+ def detailContent(self, ids):
+ result = []
+ for vid in ids:
+ html = self._get(f"{self.host}/voddetail/{vid}.html")
+ if not html: continue
+ tree = etree.HTML(html)
+ name = "".join(tree.xpath('//h1/text()')).strip() or "".join(tree.xpath('//h2/text()')).strip()
+ pic = "".join(tree.xpath('//meta[@property="og:image"]/@content | //div[contains(@class,"detail-pic")]//img/@data-original | //div[contains(@class,"detail-pic")]//img/@src'))
+ content = " ".join(x.strip() for x in tree.xpath('//span[contains(@class,"detail-intro")]//text() | //div[contains(@class,"details-content-all")]//text()') if x.strip())
+ sources, playlists = [], []
+ panels = tree.xpath('//div[contains(concat(" ",normalize-space(@class)," ")," playlist-mobile ")]')
+ for panel in panels:
+ episodes = panel.xpath('.//ul//a[contains(@href,"/video/")]')
+ if not episodes: continue
+ source = "".join(panel.xpath('./li[1]//text()')).strip() or "".join(panel.xpath('./span[1]//text()')).strip() or f"线路{len(sources) + 1}"
+ plays = []
+ for episode in episodes:
+ href = episode.get("href", "")
+ sid_match = re.search(r"sid=(\d+)", href)
+ onclick_match = re.search(r"changeSid\(['\"]?(\d+)", episode.get("onclick", ""))
+ sid = (onclick_match or sid_match).group(1) if onclick_match or sid_match else "1"
+ path = href.split("#", 1)[0]
+ label = "".join(episode.xpath(".//text()")).strip() or str(len(plays) + 1)
+ plays.append(f"{label}${path}@@{sid}")
+ sources.append(source)
+ playlists.append("#".join(plays))
+ result.append({"vod_id": str(vid), "vod_name": name, "vod_pic": self._fix(pic), "vod_content": content, "vod_play_from": "$$$".join(sources), "vod_play_url": "$$$".join(playlists)})
+ return {"list": result}
+
+ def searchContent(self, key, quick, pg="1"):
+ page = max(1, int(pg or 1))
+ url = f"{self.host}/vodsearch/-------------.html?wd={quote(key)}&page={page}"
+ return {"page": page, "pagecount": page, "list": self._parse_list(self._get(url))}
+
+ def playerContent(self, flag, id, vipFlags):
+ path, sid = (id.rsplit("@@", 1) + ["1"])[:2] if "@@" in id else (id, "1")
+ url = self._fix(path)
+ html = self._get(url, sid)
+ marker = "var player_aaaa="
+ if marker in html:
+ try:
+ data = json.JSONDecoder().raw_decode(html.split(marker, 1)[1])[0]
+ play_url = data.get("url", "")
+ if int(data.get("encrypt", 0)) == 1: play_url = quote(play_url, safe=":/?&=%")
+ if play_url and any(x in play_url.lower() for x in (".m3u8", ".mp4", ".flv")):
+ return {"parse": 0, "url": play_url, "header": {"User-Agent": self.headers["User-Agent"], "Referer": url}}
+ except Exception:
+ pass
+ return {"parse": 1, "url": url, "header": {**self.headers, "Cookie": "sid=" + sid}}
diff --git a/py/可可.py b/py/可可.py
new file mode 100644
index 0000000..26ff1cf
--- /dev/null
+++ b/py/可可.py
@@ -0,0 +1,426 @@
+#coding=utf-8
+#!/usr/bin/env python3
+# -*- coding: utf-8 -*-
+"""
+TVBox / 影视仓 Python源脚本
+站点: 可可影视 (103.51.147.112:51120)
+"""
+
+import sys
+import re
+import json
+import requests
+import urllib3
+from urllib.parse import quote
+from pyquery import PyQuery as pq
+sys.path.append('..')
+from base.spider import Spider
+
+# 禁用SSL警告
+urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)
+
+class Spider(Spider):
+
+ def __init__(self):
+ super().__init__()
+ self.site = 'https://103.51.147.112:51120'
+ self.session = requests.Session()
+ self.session.headers.update({
+ '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://103.51.147.112:51120/'
+ })
+ self.cateManual = {
+ '电影': '1',
+ '连续剧': '2',
+ '动漫': '3',
+ '综艺纪录': '4',
+ '短剧': '6'
+ }
+ # 隐晦的站点标识
+ self._mark = chr(26143) + chr(27827)
+
+ def init(self, extend=""):
+ pass
+
+ def getName(self):
+ return "可可影视"
+
+ def isVideoFormat(self, url):
+ pass
+
+ def manualVideoCheck(self):
+ pass
+
+ def homeContent(self, filter):
+ result = {'class': [], 'filters': {}, 'list': [], 'parse': 0, 'jx': 0}
+ for k, v in self.cateManual.items():
+ result['class'].append({
+ 'type_id': str(v),
+ 'type_name': k
+ })
+ return result
+
+ def homeVideoContent(self):
+ videos = []
+ try:
+ url = f'{self.site}/channel/1.html'
+ r = self.session.get(url, timeout=15, verify=False)
+ r.encoding = 'utf-8'
+ doc = pq(r.text)
+ items = doc('.module-item')
+ seen = set()
+ for item in items.items():
+ a = item.find('.v-item')
+ href = a.attr('href') or ''
+ vid = self.getVid(href)
+ if not vid or vid in seen:
+ continue
+ seen.add(vid)
+
+ # 标题
+ titles = item.find('.v-item-title')
+ title = ''
+ for j in range(len(titles)):
+ t = titles.eq(j).text().strip()
+ if t and t != '可可影视-kekys.com':
+ title = t
+ break
+
+ # 图片
+ pic = ''
+ imgs = item.find('img')
+ for j in range(len(imgs)):
+ img = imgs.eq(j)
+ src = img.attr('data-original') or ''
+ if src and 'placeholder' not in src and 'logo_placeholder' not in src:
+ pic = src
+ break
+ if pic and pic.startswith('/'):
+ pic = 'https://vres.zyxpedu.com' + pic
+
+ # 备注
+ note = ''
+ bottom = item.find('.v-item-bottom span')
+ if bottom.length:
+ note = bottom.text().strip()
+
+ if title:
+ videos.append({
+ 'vod_id': vid,
+ 'vod_name': title,
+ 'vod_pic': pic,
+ 'vod_remarks': note
+ })
+ except Exception as e:
+ print(f'homeVideoContent error: {e}')
+ return {'list': videos[:24], 'parse': 0, 'jx': 0}
+
+ def categoryContent(self, tid, pg, filter, extend):
+ result = {'list': [], 'parse': 0, 'jx': 0}
+ page = int(pg) if pg else 1
+ try:
+ url = f'{self.site}/channel/{tid}.html?page={page}'
+ r = self.session.get(url, timeout=15, verify=False)
+ r.encoding = 'utf-8'
+ doc = pq(r.text)
+ items = doc('.module-item')
+ for item in items.items():
+ a = item.find('.v-item')
+ href = a.attr('href') or ''
+ vid = self.getVid(href)
+ if not vid:
+ continue
+
+ # 标题
+ titles = item.find('.v-item-title')
+ title = ''
+ for j in range(len(titles)):
+ t = titles.eq(j).text().strip()
+ if t and t != '可可影视-kekys.com':
+ title = t
+ break
+
+ # 图片
+ pic = ''
+ imgs = item.find('img')
+ for j in range(len(imgs)):
+ img = imgs.eq(j)
+ src = img.attr('data-original') or ''
+ if src and 'placeholder' not in src and 'logo_placeholder' not in src:
+ pic = src
+ break
+ if pic and pic.startswith('/'):
+ pic = 'https://vres.zyxpedu.com' + pic
+
+ # 备注
+ note = ''
+ bottom = item.find('.v-item-bottom span')
+ if bottom.length:
+ note = bottom.text().strip()
+
+ if title:
+ result['list'].append({
+ 'vod_id': vid,
+ 'vod_name': title,
+ 'vod_pic': pic,
+ 'vod_remarks': note
+ })
+ except Exception as e:
+ print(f'categoryContent error: {e}')
+
+ result['page'] = page
+ # 修复:满页24条说明还有下一页,否则已到尾页
+ result['pagecount'] = page + 1 if len(result['list']) >= 24 else page
+ result['limit'] = len(result['list'])
+ result['total'] = len(result['list'])
+ return result
+
+ def detailContent(self, ids):
+ result = {'list': [], 'parse': 0, 'jx': 0}
+ vid = ids[0] if ids else ''
+ if not vid:
+ return result
+ try:
+ url = f'{self.site}/detail/{vid}.html'
+ r = self.session.get(url, timeout=15, verify=False)
+ r.encoding = 'utf-8'
+ html = r.text
+
+ # 标题:从title标签提取,最可靠
+ title = ''
+ title_match = re.search(r'(.+?)', html)
+ if title_match:
+ title = title_match.group(1).split('-')[0].strip()
+ # 去掉特殊字符水印
+ title = re.sub(r'[𝕜𝕜𝕪𝕤𝟘𝟙𝕔𝕠𝕞.\s]+', ' ', title).strip()
+ title = re.sub(r'\s+', ' ', title).strip()
+
+ # 图片:从og:image提取
+ pic = ''
+ og_img = re.search(r']+href="(/play/\d+-(\d+)-(\d+)\.html)"[^>]+class="episode-item"[^>]*>(.*?)', html, re.DOTALL)
+ # 如果没匹配到,试试class在href前面的情况
+ if not all_play:
+ all_play = re.findall(r']+class="episode-item"[^>]+href="(/play/\d+-(\d+)-(\d+)\.html)"[^>]*>(.*?)', html, re.DOTALL)
+ for href, sid, nid, link_html in all_play:
+ text = re.sub(r'<[^>]+>', '', link_html).strip()
+ if not text:
+ continue
+ if sid not in episodes_by_sid:
+ episodes_by_sid[sid] = []
+ episodes_by_sid[sid].append(f'{text}${href}')
+ if sid not in seen_sids:
+ seen_sids.add(sid)
+ sids_in_order.append(sid)
+
+ # 线路名称
+ source_labels = []
+ all_labels = re.findall(r'class="source-item-label"[^>]*>([^<]+)', html)
+ for label in all_labels:
+ label = label.strip()
+ if label:
+ source_labels.append(label)
+
+ for i, sid in enumerate(sids_in_order):
+ if sid in episodes_by_sid and episodes_by_sid[sid]:
+ if i < len(source_labels):
+ line_name = source_labels[i]
+ else:
+ line_name = f'线路{sid}'
+ # 跳过4K线路(只有APP端能用)
+ if line_name == '4K':
+ continue
+ play_from.append(line_name)
+ play_url.append('#'.join(episodes_by_sid[sid]))
+
+ vod = {
+ 'vod_id': vid,
+ 'vod_name': title,
+ 'vod_pic': pic,
+ 'type_name': '',
+ 'vod_year': '',
+ 'vod_area': '',
+ 'vod_remarks': '',
+ 'vod_actor': '',
+ 'vod_director': self._mark,
+ 'vod_content': desc,
+ 'vod_play_from': '$$$'.join(play_from) if play_from else '',
+ 'vod_play_url': '$$$'.join(play_url) if play_url else ''
+ }
+ result['list'].append(vod)
+ except Exception as e:
+ print(f'detailContent error: {e}')
+ return result
+
+ def playerContent(self, flag, id, vipFlags):
+ result = {}
+ try:
+ play_url = id
+ if id and not id.startswith('http'):
+ play_url = self.site + id
+
+ r = self.session.get(play_url, timeout=15, verify=False)
+ r.encoding = 'utf-8'
+
+ video_url = ''
+
+ patterns = [
+ r"src:\s*['\"]([^'\"]+\.(m3u8|mp4)[^'\"]*)['\"]",
+ r'"url"\s*:\s*"([^"]+\.(m3u8|mp4)[^"]*)"',
+ r"url\s*:\s*'([^']+\.(m3u8|mp4)[^']*)'",
+ ]
+
+ for pat in patterns:
+ m = re.search(pat, r.text, re.DOTALL)
+ if m:
+ video_url = m.group(1)
+ break
+
+ # 修复:使用非捕获组 (?:m3u8|mp4),避免 re.findall 只返回捕获组内容
+ if not video_url:
+ all_urls = re.findall(r'https?://[^\s"\'<>]+\.(?:m3u8|mp4)[^\s"\'<>]*', r.text)
+ if all_urls:
+ for u in all_urls:
+ if 'index.m3u8' in u or 'video.m3u8' in u or '.mp4' in u:
+ video_url = u
+ break
+ if not video_url:
+ video_url = all_urls[0]
+
+ # 修复:处理相对路径视频URL
+ if video_url and not video_url.startswith('http'):
+ if video_url.startswith('/'):
+ video_url = self.site + video_url
+ else:
+ video_url = self.site + '/' + video_url
+
+ if video_url:
+ result['parse'] = 0
+ result['url'] = video_url
+ result['jx'] = 0
+ result['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',
+ 'Referer': self.site + '/'
+ }
+ else:
+ result['parse'] = 1
+ result['url'] = play_url
+ result['jx'] = 0
+ result['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',
+ 'Referer': self.site + '/'
+ }
+ except Exception as e:
+ print(f'playerContent error: {e}')
+ result['parse'] = 1
+ result['url'] = id if id.startswith('http') else self.site + id
+ result['jx'] = 0
+ result['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',
+ 'Referer': self.site + '/'
+ }
+ return result
+
+ def searchContent(self, key, quick, pg='1'):
+ result = {'list': [], 'parse': 0, 'jx': 0}
+ page = int(pg) if pg else 1
+ try:
+ # 先访问搜索页获取token
+ search_url = f'{self.site}/search?k={quote(key)}'
+ r = self.session.get(search_url, timeout=15, verify=False)
+ r.encoding = 'utf-8'
+ t_match = re.search(r'name="t" value="([^"]+)"', r.text)
+ t = t_match.group(1) if t_match else ''
+
+ if t:
+ url = f'{self.site}/search?k={quote(key)}&t={quote(t)}'
+ if page > 1:
+ url += f'&page={page}'
+ r = self.session.get(url, timeout=15, verify=False)
+ r.encoding = 'utf-8'
+
+ doc = pq(r.text)
+ items = doc('.search-result-item')
+ for item in items.items():
+ # 修复:兼容 href 在元素本身或子级 标签上的情况
+ href = item.attr('href') or ''
+ if not href:
+ a_tag = item.find('a')
+ if a_tag.length:
+ href = a_tag.attr('href') or ''
+
+ vid = self.getVid(href)
+ if not vid:
+ continue
+
+ # 标题
+ title = ''
+ title_elem = item.find('.title')
+ if title_elem.length:
+ title = title_elem.text().strip()
+ if not title:
+ img = item.find('img')
+ if img.length:
+ title = img.attr('alt') or img.attr('title') or ''
+ title = title.strip()
+
+ # 图片
+ pic = ''
+ imgs = item.find('img')
+ for j in range(len(imgs)):
+ img = imgs.eq(j)
+ src = img.attr('data-original') or img.attr('src') or ''
+ if src and 'placeholder' not in src and 'logo_placeholder' not in src:
+ pic = src
+ break
+ if pic and pic.startswith('/'):
+ pic = 'https://vres.zyxpedu.com' + pic
+
+ if title:
+ result['list'].append({
+ 'vod_id': vid,
+ 'vod_name': title,
+ 'vod_pic': pic,
+ 'vod_remarks': ''
+ })
+ except Exception as e:
+ print(f'searchContent error: {e}')
+
+ result['page'] = page
+ result['pagecount'] = page + 1 if len(result['list']) > 0 else page
+ result['limit'] = len(result['list'])
+ result['total'] = len(result['list'])
+ return result
+
+ def localProxy(self, params):
+ return [200, "video/MP2T", {}, ""]
+
+ def getVid(self, url):
+ if not url:
+ return ''
+ m = re.search(r'/detail/(\d+)\.html', url)
+ if m:
+ return m.group(1)
+ m = re.search(r'/play/(\d+)-', url)
+ if m:
+ return m.group(1)
+ return ''
diff --git a/py/咖啡直播.py b/py/咖啡直播.py
new file mode 100644
index 0000000..b0c181b
--- /dev/null
+++ b/py/咖啡直播.py
@@ -0,0 +1,375 @@
+# coding=utf-8
+#!/usr/bin/env python3
+# @name 咖啡直播
+# @author 转自 OmniBox JS
+# @description 体育赛事录像回放 + 直播(足球/篮球/NBA)
+# @version 2.0.0
+
+import json
+import requests
+
+
+class Spider:
+
+ def getName(self):
+ return "咖啡直播"
+
+ def getDependence(self):
+ return []
+
+ def init(self, extend=""):
+ self.host = "https://kafeizhibo.cc"
+ self.headers = {
+ "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/134.0.0.0 Safari/537.36",
+ "Accept": "application/json, text/plain, */*",
+ "Referer": "https://kafeizhibo.com/live/all"
+ }
+
+ def log(self, msg):
+ print("[咖啡直播] " + str(msg))
+
+ # =========================================================
+ # 工具
+ # =========================================================
+
+ def _normalize_url(self, path):
+ if not path:
+ return ""
+ if path.startswith("http"):
+ return path
+ if path.startswith("//"):
+ return "https:" + path
+ return self.host + ("" if path.startswith("/") else "/") + path
+
+ def _get(self, path, params=None, referer=None):
+ h = dict(self.headers)
+ if referer:
+ h["Referer"] = referer
+ resp = requests.get(self.host + path, headers=h, params=params, timeout=10)
+ return resp.json()
+
+ # =========================================================
+ # 直播部分
+ # =========================================================
+
+ def _fetch_live_all(self):
+ """GET /api/v1/archor — 返回所有正在直播的频道"""
+ return self._get("/api/v1/archor", referer=self.host + "/live/all")
+
+ def _parse_live_list(self, items, category_filter=None):
+ """
+ category: 1=足球, 2=篮球, None=全部
+ 每个 archor 代表一个独立直播频道(同一场球可能有多个频道)
+ 合并同 match_id 的频道到一个 vod,多线路在 detail 里处理
+ """
+ # 按 room_id 去重(同一 room_id 只取第一个,避免重复)
+ seen_rooms = set()
+ result = []
+ for item in items:
+ if category_filter and item.get("category") != category_filter:
+ continue
+ room_id = str(item.get("room_id", ""))
+ if room_id in seen_rooms:
+ continue
+ seen_rooms.add(room_id)
+
+ home = item.get("home_team", "")
+ away = item.get("away_team", "")
+ league = item.get("league_name", "")
+ h_score = item.get("home_score", 0)
+ a_score = item.get("away_score", 0)
+ title = "{} vs {} ({})".format(home, away, league)
+
+ pic = self._normalize_url(item.get("screenshot", ""))
+ if not pic or "default" in pic:
+ mi = item.get("match_info") or {}
+ pic = mi.get("home_team_logo", "")
+
+ result.append({
+ "vod_id": "live_{}".format(room_id),
+ "vod_name": "🔴 " + title,
+ "vod_pic": pic,
+ "vod_remarks": "{} - {} | {}".format(h_score, a_score, item.get("name", "")),
+ })
+ return result
+
+ def _detail_live(self, room_id):
+ """GET /api/v1/room/{room_id} — 获取直播间多线路"""
+ try:
+ data = self._get(
+ "/api/v1/room/{}".format(room_id),
+ referer=self.host + "/room/{}".format(room_id)
+ )
+ if data.get("code") != 200 or not data.get("data"):
+ return {"list": []}
+
+ d = data["data"]
+ room_info = d.get("room_info", {})
+ signals = d.get("signals", [])
+
+ home = room_info.get("home_team", "")
+ away = room_info.get("away_team", "")
+ league = room_info.get("league", "")
+ h_score = room_info.get("home_score", 0)
+ a_score = room_info.get("away_score", 0)
+ title = "{} vs {} ({})".format(home, away, league)
+
+ teams = d.get("teams", {})
+ pic = (teams.get("home") or {}).get("logo", "")
+
+ # 每条 signal 是一个线路(官方直播/原声直播)
+ episodes = []
+ for sig in signals:
+ url = sig.get("stream_url", "")
+ if url:
+ name = sig.get("name", "线路")
+ episodes.append("{}${}".format(name, url))
+
+ # 如果 signals 为空,fallback 到 archor
+ if not episodes:
+ archor = d.get("archor", {})
+ url = archor.get("stream_url", "")
+ if url:
+ episodes.append("{}${}".format(archor.get("name", "直播"), url))
+
+ vod = {
+ "vod_id": "live_{}".format(room_id),
+ "vod_name": "🔴 " + title,
+ "vod_pic": pic,
+ "vod_content": "{} {} vs {},比分 {} - {}".format(
+ league, home, away, h_score, a_score
+ ),
+ "vod_play_from": "直播线路",
+ "vod_play_url": "#".join(episodes),
+ }
+ return {"list": [vod]}
+ except Exception as e:
+ self.log("直播详情失败: " + str(e))
+ return {"list": []}
+
+ # =========================================================
+ # 录像部分
+ # =========================================================
+
+ def _fetch_recordings(self, page=1, size=30, league=None, type_id=None):
+ params = {"page": page, "size": size}
+ if league:
+ params["league"] = league
+ elif type_id and type_id not in ("all", "nba", "live_all", "live_1", "live_2"):
+ params["type"] = type_id
+ h = dict(self.headers)
+ h["Referer"] = self.host + "/pc/replay"
+ resp = requests.get(self.host + "/api/v1/recordings", headers=h, params=params, timeout=10)
+ return resp.json()
+
+ def _parse_video_list(self, items):
+ result = []
+ for item in items:
+ title = "{} vs {} ({})".format(
+ item["home_team"], item["away_team"], item["league_name"]
+ )
+ score = "{} - {}".format(item["home_score"], item["away_score"])
+ pic = item.get("cover_image", "")
+ if pic and not pic.startswith("http"):
+ pic = self._normalize_url(pic)
+ if not pic or "default_cover" in pic:
+ pic = item.get("home_team_logo", "")
+ remarks = "{} | {} | {}个录像".format(
+ score, item["start_time"], item.get("recording_count", 0)
+ )
+ result.append({
+ "vod_id": str(item["match_id"]),
+ "vod_name": title,
+ "vod_pic": pic,
+ "vod_remarks": remarks,
+ })
+ return result
+
+ def _detail_recording(self, vid):
+ try:
+ h = dict(self.headers)
+ h["Referer"] = self.host + "/pc/replay"
+ resp = requests.get(
+ "{}/api/v1/match/{}/recordings".format(self.host, vid),
+ headers=h,
+ timeout=10
+ )
+ data = resp.json()
+ if data.get("code") != 200 or not data.get("data"):
+ return {"list": []}
+
+ match = data["data"]["match"]
+ replays = data["data"].get("replays", [])
+ highlights = data["data"].get("highlights", [])
+
+ title = "{} vs {} ({})".format(
+ match["home_team"], match["away_team"], match["league_name"]
+ )
+ pic = match.get("home_team_logo") or match.get("away_team_logo") or ""
+
+ episodes = []
+ for idx, rec in enumerate(replays):
+ if rec.get("video_url"):
+ name = rec.get("title") or "录像{}".format(idx + 1)
+ episodes.append("{}${}".format(name, rec["video_url"]))
+ for idx, rec in enumerate(highlights):
+ if rec.get("video_url"):
+ name = rec.get("title") or "集锦{}".format(idx + 1)
+ episodes.append("{}${}".format(name, rec["video_url"]))
+
+ vod = {
+ "vod_id": str(vid),
+ "vod_name": title,
+ "vod_pic": pic,
+ "vod_content": "{} {} {} vs {},比分 {} - {},比赛时间:{}".format(
+ match["league_name"], match.get("match_round", ""),
+ match["home_team"], match["away_team"],
+ match["home_score"], match["away_score"],
+ match["start_time"]
+ ),
+ "vod_play_from": "录像源",
+ "vod_play_url": "#".join(episodes),
+ }
+ return {"list": [vod]}
+ except Exception as e:
+ self.log("录像详情失败: " + str(e))
+ return {"list": []}
+
+ # =========================================================
+ # FongMi 接口
+ # =========================================================
+
+ def homeContent(self, filter):
+ categories = [
+ # 直播分类
+ {"type_id": "live_all", "type_name": "🔴 直播全部"},
+ {"type_id": "live_1", "type_name": "🔴 直播足球"},
+ {"type_id": "live_2", "type_name": "🔴 直播篮球"},
+ # 录像分类
+ {"type_id": "all", "type_name": "录像全部"},
+ {"type_id": "1", "type_name": "录像足球"},
+ {"type_id": "2", "type_name": "录像篮球"},
+ {"type_id": "nba", "type_name": "录像NBA"},
+ ]
+ # 首页展示直播列表
+ try:
+ data = self._fetch_live_all()
+ vod_list = self._parse_live_list(data.get("data", [])) if data.get("code") == 200 else []
+ except Exception as e:
+ self.log("首页失败: " + str(e))
+ vod_list = []
+ return {"class": categories, "list": vod_list}
+
+ def homeVideoContent(self):
+ return {"list": []}
+
+ def categoryContent(self, tid, pg, filter, extend):
+ pg = int(pg) if pg else 1
+
+ # ---- 直播分类 ----
+ if tid in ("live_all", "live_1", "live_2"):
+ try:
+ data = self._fetch_live_all()
+ if data.get("code") == 200:
+ cat = None if tid == "live_all" else int(tid.split("_")[1])
+ vod_list = self._parse_live_list(data.get("data", []), category_filter=cat)
+ else:
+ vod_list = []
+ except Exception as e:
+ self.log("直播分类失败: " + str(e))
+ vod_list = []
+ return {"list": vod_list, "page": 1, "pagecount": 1, "limit": 100, "total": len(vod_list)}
+
+ # ---- 录像分类 ----
+ try:
+ if tid == "nba":
+ data = self._fetch_recordings(pg, 20, league="NBA")
+ size = 20
+ elif tid == "all":
+ data = self._fetch_recordings(pg, 30)
+ size = 30
+ else:
+ data = self._fetch_recordings(pg, 30, type_id=tid)
+ size = 30
+
+ vod_list = []
+ pagecount = 1
+ if data.get("code") == 200 and data.get("data"):
+ vod_list = self._parse_video_list(data["data"])
+ pagecount = pg + 1 if len(data["data"]) == size else pg
+ except Exception as e:
+ self.log("录像分类失败: " + str(e))
+ vod_list = []
+ pagecount = 1
+
+ return {"list": vod_list, "page": pg, "pagecount": pagecount, "limit": 30, "total": len(vod_list)}
+
+ def detailContent(self, ids):
+ vid = ids[0] if isinstance(ids, list) and ids else str(ids)
+ if vid.startswith("live_"):
+ room_id = vid[5:] # 去掉 "live_" 前缀
+ return self._detail_live(room_id)
+ else:
+ return self._detail_recording(vid)
+
+ def searchContent(self, key, quick, pg=1):
+ if not key:
+ return {"list": []}
+ keyword = key.lower()
+ result = []
+
+ # 搜索直播
+ try:
+ data = self._fetch_live_all()
+ if data.get("code") == 200:
+ for item in data.get("data", []):
+ if (keyword in item.get("home_team", "").lower()
+ or keyword in item.get("away_team", "").lower()
+ or keyword in item.get("league_name", "").lower()
+ or keyword in item.get("title", "").lower()):
+ room_id = str(item.get("room_id", ""))
+ home = item.get("home_team", "")
+ away = item.get("away_team", "")
+ league = item.get("league_name", "")
+ result.append({
+ "vod_id": "live_{}".format(room_id),
+ "vod_name": "🔴 {} vs {} ({})".format(home, away, league),
+ "vod_pic": "",
+ "vod_remarks": "直播中",
+ })
+ except Exception as e:
+ self.log("搜索直播失败: " + str(e))
+
+ # 搜索录像
+ try:
+ data = self._fetch_recordings(1, 100)
+ if data.get("code") == 200:
+ for item in data["data"]:
+ if (keyword in item["home_team"].lower()
+ or keyword in item["away_team"].lower()
+ or keyword in item["league_name"].lower()):
+ title = "{} vs {} ({})".format(
+ item["home_team"], item["away_team"], item["league_name"]
+ )
+ result.append({
+ "vod_id": str(item["match_id"]),
+ "vod_name": title,
+ "vod_pic": "",
+ "vod_remarks": "{} - {}".format(item["home_score"], item["away_score"]),
+ })
+ except Exception as e:
+ self.log("搜索录像失败: " + str(e))
+
+ return {"list": result, "page": 1, "pagecount": 1}
+
+ def playerContent(self, flag, id, vipFlags):
+ return {
+ "parse": 0,
+ "playUrl": "",
+ "url": id,
+ "header": json.dumps({
+ "User-Agent": self.headers["User-Agent"],
+ "Referer": self.host,
+ "Origin": self.host,
+ })
+ }
diff --git a/py/聚合直播.py b/py/聚合直播.py
new file mode 100644
index 0000000..a24d511
--- /dev/null
+++ b/py/聚合直播.py
@@ -0,0 +1,1260 @@
+# -*- coding: utf-8 -*-
+# by @
+# 修复版本 - 参考最新三合一.js重构虎牙、斗鱼、B站直播逻辑
+# 修复:虎牙清晰度选择,确保ratio参数正确传递码率值
+# 修复:斗鱼切换分辨率只能播放1秒的问题(每次重新获取安全密钥和签名)
+# 修复:B站使用特殊UA和WBI签名绕过-352风控 [^90^][^30^]
+import json
+import re
+import sys
+import time
+import hashlib
+import random
+import urllib.parse
+from base64 import b64decode, b64encode
+from urllib.parse import parse_qs
+import requests
+from pyquery import PyQuery as pq
+sys.path.append('..')
+from base.spider import Spider
+from concurrent.futures import ThreadPoolExecutor
+
+
+class Spider(Spider):
+
+ def init(self, extend=""):
+ # 初始化B站WBI密钥
+ self.bili_wbi_keys = None
+ self.bili_wbi_expire = 0
+ pass
+
+ def getName(self):
+ return "直播"
+
+ def isVideoFormat(self, url):
+ pass
+
+ def manualVideoCheck(self):
+ pass
+
+ def destroy(self):
+ pass
+
+ headers = [
+ {
+ # 特殊UA绕过B站风控 [^90^]
+ "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36 Edg/126.0.0.0"
+ },
+ {
+ "User-Agent": "Dart/3.4 (dart:io)"
+ }
+ ]
+
+ excepturl = 'https://www.baidu.com'
+
+ hosts = {
+ "huya": ["https://www.huya.com", "https://mp.huya.com"],
+ "douyu": "https://www.douyu.com",
+ "wangyi": "https://cc.163.com",
+ "bili": ["https://api.live.bilibili.com", "https://api.bilibili.com"]
+ }
+
+ referers = {
+ "huya": "https://live.cdn.huya.com",
+ "douyu": "https://m.douyu.com",
+ "bili": "https://live.bilibili.com"
+ }
+
+ playheaders = {
+ "wangyi": {
+ "User-Agent": "ExoPlayer",
+ "Connection": "Keep-Alive",
+ "Icy-MetaData": "1"
+ },
+ "bili": {
+ 'Accept': '*/*',
+ 'Icy-MetaData': '1',
+ 'referer': 'https://live.bilibili.com',
+ 'user-agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36'
+ },
+ 'huya': {
+ 'User-Agent': 'ExoPlayer',
+ 'Connection': 'Keep-Alive',
+ 'Icy-MetaData': '1'
+ },
+ 'douyu': {
+ 'User-Agent': 'libmpv',
+ 'Icy-MetaData': '1'
+ }
+ }
+
+ # WBI签名相关常量 [^30^]
+ MIXIN_KEY_ENC_TAB = [
+ 46, 47, 18, 2, 53, 8, 23, 32, 15, 50, 10, 31, 58, 3, 45, 35, 27, 43, 5, 49,
+ 33, 9, 42, 19, 29, 28, 14, 39, 12, 38, 41, 13, 37, 48, 7, 16, 24, 55, 40,
+ 61, 26, 17, 0, 1, 60, 51, 30, 4, 22, 25, 54, 21, 56, 59, 6, 63, 57, 62, 11,
+ 36, 20, 34, 44, 52
+ ]
+
+ def _get_bili_wbi_keys(self):
+ """获取B站WBI密钥 [^30^]"""
+ try:
+ # 检查缓存
+ if self.bili_wbi_keys and time.time() < self.bili_wbi_expire:
+ return self.bili_wbi_keys
+
+ # 从导航接口获取 - 使用特殊UA [^90^]
+ resp = self.fetch(
+ 'https://api.bilibili.com/x/web-interface/nav',
+ headers={
+ 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36',
+ 'Referer': 'https://www.bilibili.com/'
+ }
+ ).json()
+
+ if resp.get('code') != 0:
+ return None
+
+ img_url = resp['data']['wbi_img']['img_url']
+ sub_url = resp['data']['wbi_img']['sub_url']
+
+ # 提取文件名作为key
+ img_key = img_url.rsplit('/', 1)[1].split('.')[0]
+ sub_key = sub_url.rsplit('/', 1)[1].split('.')[0]
+
+ self.bili_wbi_keys = (img_key, sub_key)
+ self.bili_wbi_expire = time.time() + 86400 # 24小时过期
+
+ return self.bili_wbi_keys
+ except Exception as e:
+ print(f"获取B站WBI密钥失败: {e}")
+ return None
+
+ def _get_mixin_key(self, orig: str):
+ """生成mixin_key [^30^]"""
+ return ''.join([orig[i] for i in self.MIXIN_KEY_ENC_TAB])[:32]
+
+ def _enc_wbi(self, params: dict):
+ """WBI签名 [^30^]"""
+ keys = self._get_bili_wbi_keys()
+ if not keys:
+ return params
+
+ img_key, sub_key = keys
+ mixin_key = self._get_mixin_key(img_key + sub_key)
+
+ # 添加时间戳
+ params['wts'] = round(time.time())
+
+ # 排序参数
+ params = dict(sorted(params.items()))
+
+ # 过滤特殊字符
+ params = {
+ k: ''.join(filter(lambda c: c not in "!'()*", str(v)))
+ for k, v in params.items()
+ }
+
+ # 计算签名
+ query = urllib.parse.urlencode(params)
+ w_rid = hashlib.md5((query + mixin_key).encode()).hexdigest()
+
+ params['w_rid'] = w_rid
+ return params
+
+ def process_bili(self):
+ """获取B站分类列表 - 使用WBI签名 [^30^]"""
+ try:
+ # 尝试获取分类列表 - 使用特殊UA和WBI签名
+ params = {'need_entrance': 1, 'parent_id': 0}
+ signed_params = self._enc_wbi(params)
+
+ data = self.fetch(
+ f'{self.hosts["bili"][0]}/room/v1/Area/getList',
+ params=signed_params,
+ headers=self.headers[0]
+ ).json()
+
+ if data.get('code') == 0 and data.get('data'):
+ # 保存分类数据供后续使用
+ self.bili_areas = data['data']
+ return ('bili', [{'key': 'cate', 'name': '分类',
+ 'value': [{'n': i['name'], 'v': str(i['id'])}
+ for i in data['data']]}])
+ return 'bili', None
+ except Exception as e:
+ print(f"bili处理错误: {e}")
+ # 使用默认分类
+ return 'bili', [{'key': 'cate', 'name': '分类',
+ 'value': [{'n': '网游', 'v': '2'}, {'n': '手游', 'v': '3'},
+ {'n': '单机', 'v': '6'}, {'n': '娱乐', 'v': '1'},
+ {'n': '电台', 'v': '5'}, {'n': '虚拟主播', 'v': '9'},
+ {'n': '生活', 'v': '10'}, {'n': '知识', 'v': '11'},
+ {'n': '赛事', 'v': '13'}]}]
+
+ def process_douyu(self):
+ try:
+ self.dyufdata = self.fetch(
+ f'{self.referers["douyu"]}/api/cate/list',
+ headers=self.headers[1]
+ ).json()
+ return ('douyu', [{'key': 'cate', 'name': '分类',
+ 'value': [{'n': i['cate1Name'], 'v': str(i['cate1Id'])}
+ for i in self.dyufdata['data']['cate1Info']]}])
+ except Exception as e:
+ print(f"douyu错误: {e}")
+ return 'douyu', None
+
+ def homeContent(self, filter):
+ result = {}
+ cateManual = {
+ "虎牙": "huya",
+ "斗鱼": "douyu",
+ "网易": "wangyi"
+
+ }
+ classes = []
+ filters = {
+ 'huya': [{'key': 'cate', 'name': '分类',
+ 'value': [{'n': '网游', 'v': '1'}, {'n': '单机', 'v': '2'},
+ {'n': '娱乐', 'v': '8'}, {'n': '手游', 'v': '3'}]}]
+ }
+
+ with ThreadPoolExecutor(max_workers=2) as executor:
+ futures = {
+ executor.submit(self.process_bili): 'bili',
+ executor.submit(self.process_douyu): 'douyu'
+ }
+
+ for future in futures:
+ platform, filter_data = future.result()
+ if filter_data:
+ filters[platform] = filter_data
+
+ for k in cateManual:
+ classes.append({
+ 'type_name': k,
+ 'type_id': cateManual[k]
+ })
+
+ result['class'] = classes
+ result['filters'] = filters
+ return result
+
+ def homeVideoContent(self):
+ pass
+
+ def categoryContent(self, tid, pg, filter, extend):
+ vdata = []
+ result = {}
+ pagecount = 9999
+ result['page'] = pg
+ result['limit'] = 90
+ result['total'] = 999999
+ if tid == 'wangyi':
+ vdata, pagecount = self.wyccContent(tid, pg, filter, extend, vdata)
+ elif 'bili' in tid:
+ vdata, pagecount = self.biliContent(tid, pg, filter, extend, vdata)
+ elif 'huya' in tid:
+ vdata, pagecount = self.huyaContent(tid, pg, filter, extend, vdata)
+ elif 'douyu' in tid:
+ vdata, pagecount = self.douyuContent(tid, pg, filter, extend, vdata)
+ result['list'] = vdata
+ result['pagecount'] = pagecount
+ return result
+
+ def wyccContent(self, tid, pg, filter, extend, vdata):
+ params = {
+ 'format': 'json',
+ 'start': (int(pg) - 1) * 20,
+ 'size': '20',
+ }
+ response = self.fetch(f'{self.hosts[tid]}/api/category/live/', params=params, headers=self.headers[0]).json()
+ for i in response['lives']:
+ if i.get('cuteid'):
+ bvdata = self.buildvod(
+ vod_id=f"{tid}@@{i['cuteid']}",
+ vod_name=i.get('title'),
+ vod_pic=i.get('cover'),
+ vod_remarks=i.get('nickname'),
+ style={"type": "rect", "ratio": 1.33}
+ )
+ vdata.append(bvdata)
+ return vdata, 9999
+
+ def biliContent(self, tid, pg, filter, extend, vdata):
+ """B站分类内容 - 使用WBI签名绕过风控 [^30^][^90^]"""
+ try:
+ # 分类列表 - 显示子分类
+ if extend.get('cate') and pg == '1' and 'click' not in tid:
+ # 从已保存的分类数据中找到对应分类的子分类
+ if hasattr(self, 'bili_areas'):
+ for area in self.bili_areas:
+ if str(area['id']) == extend['cate']:
+ for sub_area in area.get('list', []):
+ v = self.buildvod(
+ vod_id=f"click_{tid}@@{extend['cate']}@@{sub_area['id']}",
+ vod_name=sub_area.get('name'),
+ vod_pic=sub_area.get('pic'),
+ vod_tag=1,
+ style={"type": "oval", "ratio": 1}
+ )
+ vdata.append(v)
+ return vdata, 1
+ # 如果没有找到子分类,直接返回空,让用户进入房间列表
+ return vdata, 1
+
+ # 房间列表 - 使用getList接口并添加WBI签名 [^30^]
+ if 'click' in tid:
+ # 子分类房间
+ ids = tid.split('_')[1].split('@@')
+ tid = ids[0]
+ parent_area_id = ids[1]
+ area_id = ids[2]
+ else:
+ # 默认使用分类ID作为parent_area_id,area_id为0表示该分类下所有
+ parent_area_id = extend.get('cate', '2') # 默认网游
+ area_id = 0
+
+ # 构建请求参数并添加WBI签名 [^30^]
+ params = {
+ 'parent_area_id': parent_area_id,
+ 'area_id': area_id,
+ 'page': pg,
+ 'platform': 'web',
+ 'sort_type': 'online' # 按热度排序
+ }
+ signed_params = self._enc_wbi(params)
+
+ # 调用getList接口
+ api_url = f'{self.hosts[tid][0]}/xlive/web-interface/v1/second/getList'
+ data = self.fetch(api_url, params=signed_params, headers=self.headers[0]).json()
+
+ # 如果WBI签名失败,尝试不带签名
+ if data.get('code') == -352:
+ print("WBI签名失败,尝试无签名请求...")
+ params = {
+ 'parent_area_id': parent_area_id,
+ 'area_id': area_id,
+ 'page': pg,
+ 'platform': 'web',
+ 'sort_type': 'online'
+ }
+ data = self.fetch(api_url, params=params, headers=self.headers[0]).json()
+
+ if data.get('code') == 0:
+ room_list = data.get('data', {}).get('list', [])
+ for room in room_list:
+ if room.get('roomid'):
+ # 处理在线人数显示
+ online = room.get('online', 0)
+ if online > 10000:
+ online_str = f"{online / 10000:.1f}万"
+ else:
+ online_str = str(online)
+
+ v = self.buildvod(
+ f"{tid}@@{room['roomid']}",
+ room.get('title', '未知标题'),
+ room.get('cover') or room.get('system_cover'),
+ f"{online_str}人",
+ 0,
+ room.get('uname', ''),
+ style={"type": "rect", "ratio": 1.33}
+ )
+ vdata.append(v)
+
+ # 检查是否有更多数据
+ has_more = data.get('data', {}).get('has_more', 0)
+ if not has_more:
+ pagecount = int(pg)
+ else:
+ pagecount = 9999
+ else:
+ print(f"B站API返回错误: {data.get('message', '未知错误')} (code: {data.get('code')})")
+ pagecount = 1
+
+ return vdata, pagecount
+
+ except Exception as e:
+ print(f"B站内容获取错误: {e}")
+ return vdata, 1
+
+ def huyaContent(self, tid, pg, filter, extend, vdata):
+ if extend.get('cate') and pg == '1' and 'click' not in tid:
+ id = extend.get('cate')
+ data = self.fetch(f'{self.referers[tid]}/liveconfig/game/bussLive?bussType={id}',
+ headers=self.headers[1]).json()
+ for i in data['data']:
+ v = self.buildvod(
+ vod_id=f"click_{tid}@@{int(i['gid'])}",
+ vod_name=i.get('gameFullName'),
+ vod_pic=f'https://huyaimg.msstatic.com/cdnimage/game/{int(i["gid"])}-MS.jpg',
+ vod_tag=1,
+ style={"type": "oval", "ratio": 1}
+ )
+ vdata.append(v)
+ return vdata, 1
+ else:
+ gid = ''
+ if 'click' in tid:
+ ids = tid.split('_')[1].split('@@')
+ tid = ids[0]
+ gid = f'&gameId={ids[1]}'
+ data = self.fetch(f'{self.hosts[tid][0]}/cache.php?m=LiveList&do=getLiveListByPage&tagAll=0{gid}&page={pg}',
+ headers=self.headers[1]).json()
+ for i in data['data']['datas']:
+ if i.get('profileRoom'):
+ v = self.buildvod(
+ f"{tid}@@{i['profileRoom']}",
+ i.get('introduction'),
+ i.get('screenshot'),
+ str(int(i.get('totalCount', '1')) / 10000) + '万',
+ 0,
+ i.get('nick'),
+ style={"type": "rect", "ratio": 1.33}
+
+ )
+ vdata.append(v)
+ return vdata, 9999
+
+ def douyuContent(self, tid, pg, filter, extend, vdata):
+ if extend.get('cate') and pg == '1' and 'click' not in tid:
+ for i in self.dyufdata['data']['cate2Info']:
+ if str(i['cate1Id']) == extend['cate']:
+ v = self.buildvod(
+ vod_id=f"click_{tid}@@{i['cate2Id']}",
+ vod_name=i.get('cate2Name'),
+ vod_pic=i.get('icon'),
+ vod_remarks=i.get('count'),
+ vod_tag=1,
+ style={"type": "oval", "ratio": 1}
+ )
+ vdata.append(v)
+ return vdata, 1
+ else:
+ path = f'/japi/weblist/apinc/allpage/6/{pg}'
+ if 'click' in tid:
+ ids = tid.split('_')[1].split('@@')
+ tid = ids[0]
+ path = f'/gapi/rkc/directory/mixList/2_{ids[1]}/{pg}'
+ url = f'{self.hosts[tid]}{path}'
+ data = self.fetch(url, headers=self.headers[1]).json()
+ for i in data['data']['rl']:
+ v = self.buildvod(
+ vod_id=f"{tid}@@{i['rid']}",
+ vod_name=i.get('rn'),
+ vod_pic=i.get('rs16'),
+ vod_year=str(int(i.get('ol', 1)) / 10000) + '万',
+ vod_remarks=i.get('nn'),
+ style={"type": "rect", "ratio": 1.33}
+ )
+ vdata.append(v)
+ return vdata, 9999
+
+ def detailContent(self, ids):
+ ids = ids[0].split('@@')
+ if ids[0] == 'wangyi':
+ vod = self.wyccDetail(ids)
+ elif ids[0] == 'bili':
+ vod = self.biliDetail(ids)
+ elif ids[0] == 'huya':
+ vod = self.huyaDetail(ids)
+ elif ids[0] == 'douyu':
+ vod = self.douyuDetail(ids)
+ return {'list': [vod]}
+
+ def wyccDetail(self, ids):
+ try:
+ vdata = self.getpq(f'{self.hosts[ids[0]]}/{ids[1]}', self.headers[0])('script').eq(-1).text()
+
+ def get_quality_name(vbr):
+ if vbr <= 600:
+ return "标清"
+ elif vbr <= 1000:
+ return "高清"
+ elif vbr <= 2000:
+ return "超清"
+ else:
+ return "蓝光"
+
+ data = json.loads(vdata)['props']['pageProps']['roomInfoInitData']
+ name = data['live'].get('title', ids[0])
+ vod = self.buildvod(vod_name=data.get('keywords_suffix'), vod_remarks=data['live'].get('title'),
+ vod_content=data.get('description_suffix'))
+ resolution_data = data['live']['quickplay']['resolution']
+ all_streams = {}
+ sorted_qualities = sorted(resolution_data.items(),
+ key=lambda x: x[1]['vbr'],
+ reverse=True)
+ for quality, data in sorted_qualities:
+ vbr = data['vbr']
+ quality_name = get_quality_name(vbr)
+ for cdn_name, url in data['cdn'].items():
+ if cdn_name not in all_streams and type(url) == str and url.startswith('http'):
+ all_streams[cdn_name] = []
+ if isinstance(url, str) and url.startswith('http'):
+ all_streams[cdn_name].extend([quality_name, url])
+ plists = []
+ names = []
+ for i, (cdn_name, stream_list) in enumerate(all_streams.items(), 1):
+ names.append(f'线路{i}')
+ pstr = f"{name}${ids[0]}@@{self.e64(json.dumps(stream_list))}"
+ plists.append(pstr)
+ vod['vod_play_from'] = "$$$".join(names)
+ vod['vod_play_url'] = "$$$".join(plists)
+ return vod
+ except Exception as e:
+ return self.handle_exception(e)
+
+ def biliDetail(self, ids):
+ """
+ B站直播详情 - 使用playUrl接口获取多清晰度
+ """
+ try:
+ room_id = ids[1]
+
+ # 获取房间信息
+ info_res = self.fetch(
+ f'{self.hosts["bili"][0]}/room/v1/Room/get_info?room_id={room_id}',
+ headers=self.headers[0]
+ ).json()
+
+ if info_res.get('code') != 0:
+ return self.handle_exception(Exception("获取房间信息失败"))
+
+ room_info = info_res['data']
+ title = room_info.get('title', 'B站直播')
+
+ vod = self.buildvod(
+ vod_name=title,
+ type_name=f"{room_info.get('parent_area_name', '')}/{room_info.get('area_name', '')}",
+ vod_director=room_info.get('uname', ''),
+ vod_remarks=f"在线{room_info.get('online', 0)}人"
+ )
+
+ # 获取播放地址信息
+ play_res = self.fetch(
+ f'{self.hosts["bili"][0]}/room/v1/Room/playUrl?cid={room_id}&qn=10000&platform=web',
+ headers={
+ **self.headers[0],
+ 'Referer': 'https://live.bilibili.com/',
+ 'Origin': 'https://live.bilibili.com'
+ }
+ ).json()
+
+ if play_res.get('code') != 0:
+ return self.handle_exception(Exception("获取播放地址失败"))
+
+ play_data = play_res['data']
+ accept_quality = play_data.get('accept_quality', ['10000', '400', '250', '150'])
+ quality_desc = {item['qn']: item['desc'] for item in play_data.get('quality_description', [])}
+
+ # 构建清晰度列表
+ qualities = []
+ for qn in sorted([int(q) for q in accept_quality], reverse=True):
+ desc = quality_desc.get(qn, f'清晰度{qn}')
+ qualities.append(f"{desc}$bili@@{room_id}@@{qn}")
+
+ vod['vod_play_from'] = 'B站直播'
+ vod['vod_play_url'] = '#'.join(qualities)
+ return vod
+
+ except Exception as e:
+ print(f"B站详情错误: {e}")
+ return self.handle_exception(e)
+
+ def huyaDetail(self, ids):
+ """
+ 虎牙播放详情 - 参考最新三合一.js重构
+ 支持多线路多清晰度选择
+ 核心算法:通过房间信息API获取uid、streamName和rateArray,为每个清晰度生成签名URL
+ 清晰度说明:
+ - 蓝光8M/6M/4M/10M = 8000/6000/4000/10000 kbps = 1080P+
+ - 蓝光 = 3000 kbps = 1080P
+ - 超清 = 2000 kbps = 1080P (官方标准)
+ - 高清 = 1200 kbps = 720P
+ - 标清/流畅 = 500-800 kbps = 480P/540P
+ """
+ try:
+ room_id = ids[1]
+
+ # 1. 获取房间信息
+ api_url = f'{self.hosts[ids[0]][1]}/cache.php?m=Live&do=profileRoom&roomid={room_id}'
+ res = self.fetch(api_url, headers=self.headers[0])
+
+ if res.status_code != 200:
+ return self.handle_exception(Exception(f"API请求失败: {res.status_code}"))
+
+ data = res.json()
+ if not data or not data.get('data'):
+ return self.handle_exception(Exception("房间数据为空"))
+
+ room_data = data['data']
+
+ # 2. 提取关键信息
+ uid = room_data.get('profileInfo', {}).get('uid')
+ stream_info = room_data.get('stream', {})
+ live_data = room_data.get('liveData', {})
+
+ if not uid:
+ return self.handle_exception(Exception("缺少uid"))
+
+ # 3. 获取streamName和码率信息
+ base_stream_list = stream_info.get('baseSteamInfoList', [])
+ if not base_stream_list:
+ return self.handle_exception(Exception("无直播流信息"))
+
+ # 获取第一个CDN的streamName作为基准
+ base_stream = base_stream_list[0]
+ stream_name = base_stream.get('sStreamName')
+ if not stream_name:
+ return self.handle_exception(Exception("无法获取streamName"))
+
+ # 4. 构建VOD对象
+ vod = self.buildvod(
+ vod_name=live_data.get('introduction', '虎牙直播'),
+ type_name=live_data.get('gameFullName', ''),
+ vod_director=live_data.get('nick', ''),
+ vod_remarks=live_data.get('contentIntro', ''),
+ )
+
+ # 5. 获取所有CDN线路
+ cdn_list = []
+ for stream in base_stream_list:
+ cdn_type = stream.get('sCdnType', 'AL')
+ flv_url = stream.get('sFlvUrl', '')
+ hls_url = stream.get('sHlsUrl', '')
+ stream_name_cdn = stream.get('sStreamName', stream_name)
+
+ if flv_url:
+ cdn_list.append({
+ 'cdn': cdn_type,
+ 'flv_base': flv_url,
+ 'hls_base': hls_url,
+ 'stream_name': stream_name_cdn,
+ 'priority': stream.get('iWebPriorityRate', 0)
+ })
+
+ # 按优先级排序
+ cdn_list.sort(key=lambda x: x['priority'], reverse=True)
+
+ # 6. 获取清晰度列表 (rateArray)
+ rate_array = stream_info.get('rateArray', [])
+
+ # 如果没有rateArray,尝试从vMultiStreamInfo获取
+ if not rate_array and 'vMultiStreamInfo' in room_data:
+ rate_array = room_data['vMultiStreamInfo']
+
+ # 如果仍然没有,使用默认清晰度(按虎牙官方标准)
+ if not rate_array:
+ rate_array = [
+ {'sDisplayName': '蓝光4M', 'iBitRate': 4000},
+ {'sDisplayName': '蓝光', 'iBitRate': 3000},
+ {'sDisplayName': '超清', 'iBitRate': 2000}, # 2000kbps = 1080P
+ {'sDisplayName': '高清', 'iBitRate': 1200}, # 1200kbps = 720P
+ {'sDisplayName': '流畅', 'iBitRate': 500}
+ ]
+
+ # 过滤和排序清晰度
+ # 虎牙的rateArray中,iBitRate就是码率值,sDisplayName是显示名称
+ # 需要确保:超清=2000kbps(1080P),高清=1200kbps(720P)
+ filtered_rates = []
+ seen_bitrates = set()
+
+ for rate in rate_array:
+ bit_rate = rate.get('iBitRate', 0)
+ name = rate.get('sDisplayName', '')
+
+ # 跳过重复的码率
+ if bit_rate in seen_bitrates:
+ continue
+
+ # 修正清晰度名称,确保符合虎牙标准
+ # 2000kbps应该是超清(1080P),不是高清
+ if bit_rate == 2000 and ('高清' in name or '720' in name):
+ name = '超清' # 强制修正为超清
+ elif bit_rate == 1200 and ('标清' in name or '480' in name):
+ name = '高清' # 1200kbps对应高清
+ elif bit_rate == 2000 and name == '原画':
+ name = '超清' # 修正原画为超清
+
+ seen_bitrates.add(bit_rate)
+ filtered_rates.append({
+ 'sDisplayName': name,
+ 'iBitRate': bit_rate
+ })
+
+ # 按码率从高到低排序
+ sorted_rates = sorted(filtered_rates, key=lambda x: x['iBitRate'], reverse=True)
+
+ # 7. 为每个CDN生成各清晰度的播放URL
+ play_lines = []
+ line_names = []
+
+ for cdn_idx, cdn in enumerate(cdn_list[:3]): # 最多取3个CDN
+ cdn_name = cdn['cdn']
+ line_names.append(f"线路{cdn_idx + 1}({cdn_name})")
+
+ qualities = []
+ for rate in sorted_rates:
+ quality_name = rate['sDisplayName']
+ bit_rate = rate['iBitRate']
+
+ # 生成该清晰度的URL
+ quality_url = self._generate_huya_play_url(
+ cdn, uid, stream_name, bit_rate
+ )
+
+ qualities.extend([quality_name, quality_url])
+
+ # 编码该线路的所有清晰度
+ encoded_qualities = self.e64(json.dumps(qualities))
+ play_lines.append(f"{live_data.get('introduction', '直播')}${ids[0]}@@{encoded_qualities}")
+
+ # 8. 构建播放数据
+ vod['vod_play_from'] = "$$$".join(line_names)
+ vod['vod_play_url'] = "$$$".join(play_lines)
+
+ return vod
+
+ except Exception as e:
+ return self.handle_exception(e)
+
+ def _generate_huya_play_url(self, cdn, uid, stream_name, bit_rate):
+ """
+ 生成虎牙播放URL,参考最新三合一.js算法
+ 关键:ratio参数必须正确设置为iBitRate值(如2000、4000等)
+ """
+ # 基础URL构建
+ flv_base = cdn['flv_base']
+ stream = cdn['stream_name']
+
+ # 生成时间戳和签名参数
+ timestamp = int(time.time())
+ seqid = f"{uid}{timestamp}"
+ ss = hashlib.md5(f"{seqid}|huya_adr|102".encode()).hexdigest()
+ ws_time = hex(timestamp + 21600)[2:] # 16进制,有效期6小时
+
+ # 计算wsSecret
+ ws_secret = hashlib.md5(
+ f"DWq8BcJ3h6DJt6TY_{uid}_{stream_name}_{ss}_{ws_time}".encode()
+ ).hexdigest()
+
+ # 构建基础URL
+ base_url = f"{flv_base}/{stream}.flv"
+
+ # 关键修复:ratio参数直接使用iBitRate值
+ # 超清=2000,高清=1200,蓝光=3000/4000/6000/8000
+ if bit_rate > 0:
+ ratio_param = f"ratio={bit_rate}"
+ else:
+ # 原画/0码率时,使用默认2000或从URL推断
+ ratio_param = "ratio=2000"
+
+ # 构建完整URL
+ play_url = (
+ f"{base_url}?{ratio_param}&wsSecret={ws_secret}&wsTime={ws_time}"
+ f"&ctype=huya_adr&seqid={seqid}&uid={uid}"
+ f"&fs=bgct&ver=1&t=102"
+ )
+
+ return play_url
+
+ def douyuDetail(self, ids):
+ """
+ 斗鱼播放详情 - 参考最新三合一.js重构
+ 核心算法:设备ID生成 -> 获取加密密钥 -> 计算签名 -> 获取播放地址
+ 修复:切换分辨率只能播放1秒的问题
+ 方案:存储房间号和码率信息,在playerContent中实时获取对应码率的URL
+ """
+ try:
+ channel = ids[1]
+ headers = self.gethr(0, zr=f'{self.hosts[ids[0]]}/{channel}')
+
+ # 1. 初始化会话和设备ID (参考JS中的initialize和setupDeviceId)
+ session = {}
+
+ # 请求首页获取Cookie
+ try:
+ home_res = self.fetch(f'{self.hosts[ids[0]]}/{channel}', headers=headers)
+ if home_res.headers.get('Set-Cookie'):
+ cookie_str = home_res.headers.get('Set-Cookie')
+ # 解析dy_did
+ did_match = re.search(r'dy_did=([a-f0-9]{32})', cookie_str)
+ if did_match:
+ device_id = did_match.group(1)
+ else:
+ device_id = self._generate_random_hex(32)
+ else:
+ device_id = self._generate_random_hex(32)
+ except:
+ device_id = self._generate_random_hex(32)
+
+ session['dy_did'] = device_id
+ session['mantine-color-scheme-value'] = 'light'
+
+ # 2. 获取房间基本信息
+ betard_res = self.fetch(f'{self.hosts[ids[0]]}/betard/{channel}', headers=headers).json()
+ if not betard_res or not betard_res.get('room'):
+ return self.handle_exception(Exception("获取房间信息失败"))
+
+ room_info = betard_res['room']
+ vname = room_info.get('room_name', '斗鱼直播')
+
+ vod = self.buildvod(
+ vod_name=vname,
+ vod_remarks=room_info.get('second_lvl_name', ''),
+ vod_director=room_info.get('nickname', ''),
+ )
+
+ # 3. 获取安全密钥 (参考JS中的getSecurityKey)
+ sec_url = f"{self.hosts[ids[0]]}/wgapi/livenc/liveweb/websec/getEncryption?did={device_id}"
+ sec_res = self.fetch(sec_url, headers=headers).json()
+
+ if not sec_res or sec_res.get('error') != 0:
+ return self.handle_exception(Exception("获取加密密钥失败"))
+
+ security_data = sec_res['data']
+ secret_key = security_data.get('key')
+ random_str = security_data.get('rand_str')
+ enc_time = security_data.get('enc_time', 1)
+ enc_data = security_data.get('enc_data')
+
+ # 4. 计算签名 (参考JS中的computeSignature)
+ current_time = int(time.time())
+
+ # 迭代计算MD5
+ current = random_str
+ for _ in range(enc_time):
+ current = hashlib.md5(f"{current}{secret_key}".encode()).hexdigest()
+
+ signature = hashlib.md5(f"{current}{secret_key}{channel}{current_time}".encode()).hexdigest()
+
+ # 5. 请求播放地址 (参考JS中的requestStreamData)
+ play_payload = {
+ 'enc_data': enc_data,
+ 'tt': str(current_time),
+ 'did': device_id,
+ 'auth': signature,
+ 'cdn': '',
+ 'rate': '',
+ 'hevc': '0',
+ 'fa': '0',
+ 'ive': '0'
+ }
+
+ play_api = f"{self.hosts[ids[0]]}/lapi/live/getH5PlayV1/{channel}"
+
+ # 构建请求头带Cookie
+ play_headers = headers.copy()
+ cookie_str = '; '.join([f"{k}={v}" for k, v in session.items()])
+ play_headers['Cookie'] = cookie_str
+ play_headers['Content-Type'] = 'application/x-www-form-urlencoded'
+
+ play_res = requests.post(play_api, data=play_payload, headers=play_headers, timeout=10).json()
+
+ if not play_res or play_res.get('error') != 0:
+ # 尝试旧版API
+ play_res = self._try_legacy_douyu_api(channel, device_id, signature, current_time, play_headers)
+ if not play_res:
+ return self.handle_exception(Exception("获取播放地址失败"))
+
+ stream_info = play_res.get('data', {})
+
+ # 6. 检查并更新设备ID (参考JS中的checkAndUpdateDeviceId)
+ rtmp_live = stream_info.get('rtmp_live', '')
+ if rtmp_live:
+ did_match = re.search(r'did=([a-f0-9]{32})', rtmp_live)
+ if did_match and did_match.group(1) != device_id:
+ device_id = did_match.group(1)
+ session['dy_did'] = device_id
+ # 重新请求
+ play_payload['did'] = device_id
+ play_res = requests.post(play_api, data=play_payload, headers=play_headers, timeout=10).json()
+ if play_res and play_res.get('error') == 0:
+ stream_info = play_res.get('data', {})
+
+ # 7. 提取播放URL和多码率信息
+ stream_url = None
+ if stream_info.get('rtmp_url') and stream_info.get('rtmp_live'):
+ stream_url = f"{stream_info['rtmp_url']}/{stream_info['rtmp_live']}"
+ elif stream_info.get('hls_url'):
+ stream_url = stream_info['hls_url']
+
+ if not stream_url:
+ return self.handle_exception(Exception("无法获取播放地址"))
+
+ # 8. 构建多码率选项
+ multirates = stream_info.get('multirates', [])
+
+ # 关键修复:存储房间号和码率信息,而不是直接存储URL
+ # 这样在切换清晰度时可以重新获取对应码率的签名URL
+ qualities = []
+
+ if multirates:
+ # 按码率排序
+ sorted_rates = sorted(multirates, key=lambda x: x.get('bit', 0), reverse=True)
+ for rate in sorted_rates:
+ bit_rate = rate.get('rate', -1)
+ name = rate.get('name', f"{bit_rate}P")
+
+ # 存储格式:码率值,用于playerContent中重新获取URL
+ # 使用特殊标记#来区分这是码率值而不是URL
+ qualities.extend([name, f"#{bit_rate}"])
+ else:
+ # 只有原画
+ qualities = ['原画', '#-1']
+
+ # 同时存储房间号和设备信息,用于重新获取URL
+ # 格式:房间号|设备ID|签名信息(base64编码)
+ session_info = {
+ 'channel': channel,
+ 'device_id': device_id,
+ 'secret_key': secret_key,
+ 'random_str': random_str,
+ 'enc_time': enc_time,
+ 'enc_data': enc_data
+ }
+ encoded_session = self.e64(json.dumps(session_info))
+
+ # 9. 构建播放数据
+ # vod_play_url格式:房间名$平台@@base64(清晰度列表)@@base64(会话信息)
+ encoded_qualities = self.e64(json.dumps(qualities))
+ vod['vod_play_from'] = '斗鱼直播'
+ vod['vod_play_url'] = f"{vname}${ids[0]}@@{encoded_qualities}@@{encoded_session}"
+
+ return vod
+
+ except Exception as e:
+ return self.handle_exception(e)
+
+ def _generate_random_hex(self, length):
+ """生成随机十六进制字符串"""
+ hex_chars = '0123456789abcdef'
+ return ''.join(random.choice(hex_chars) for _ in range(length))
+
+ def _try_legacy_douyu_api(self, channel, device_id, signature, timestamp, headers):
+ """尝试使用旧版API获取播放地址"""
+ try:
+ legacy_payload = {
+ 'did': device_id,
+ 'tt': str(timestamp),
+ 'sign': signature,
+ 'cdn': '',
+ 'rate': '-1',
+ 'ver': 'Douyu_223061205',
+ 'iar': '1',
+ 'ive': '1',
+ 'hevc': '0',
+ 'fa': '0'
+ }
+ legacy_api = f"https://www.douyu.com/lapi/live/getH5Play/{channel}"
+ res = requests.post(legacy_api, data=legacy_payload, headers=headers, timeout=10)
+ return res.json() if res.status_code == 200 else None
+ except:
+ return None
+
+ def _get_douyu_play_url(self, channel, device_id, secret_key, random_str, enc_time, enc_data, rate):
+ """
+ 获取斗鱼指定码率的播放URL(带签名)
+ 用于切换清晰度时重新获取URL
+ """
+ try:
+ current_time = int(time.time())
+
+ # 重新计算签名
+ current = random_str
+ for _ in range(enc_time):
+ current = hashlib.md5(f"{current}{secret_key}".encode()).hexdigest()
+
+ signature = hashlib.md5(f"{current}{secret_key}{channel}{current_time}".encode()).hexdigest()
+
+ # 构建请求
+ play_payload = {
+ 'enc_data': enc_data,
+ 'tt': str(current_time),
+ 'did': device_id,
+ 'auth': signature,
+ 'cdn': '',
+ 'rate': str(rate) if rate > 0 else '',
+ 'hevc': '0',
+ 'fa': '0',
+ 'ive': '0'
+ }
+
+ play_api = f"https://www.douyu.com/lapi/live/getH5PlayV1/{channel}"
+
+ headers = {
+ 'User-Agent': self.headers[0]['User-Agent'],
+ 'Referer': f'https://www.douyu.com/{channel}',
+ 'Origin': 'https://www.douyu.com',
+ 'Cookie': f'dy_did={device_id}; mantine-color-scheme-value=light',
+ 'Content-Type': 'application/x-www-form-urlencoded'
+ }
+
+ play_res = requests.post(play_api, data=play_payload, headers=headers, timeout=10).json()
+
+ if not play_res or play_res.get('error') != 0:
+ # 尝试旧版API
+ return self._get_douyu_play_url_legacy(channel, device_id, signature, current_time, rate)
+
+ stream_info = play_res.get('data', {})
+
+ # 检查设备ID是否匹配
+ if stream_info.get('rtmp_live'):
+ did_match = re.search(r'did=([a-f0-9]{32})', stream_info['rtmp_live'])
+ if did_match and did_match.group(1) != device_id:
+ # 设备ID不匹配,使用新设备ID重新获取
+ return self._get_douyu_play_url(channel, did_match.group(1), secret_key, random_str, enc_time, enc_data, rate)
+
+ if stream_info.get('rtmp_url') and stream_info.get('rtmp_live'):
+ return f"{stream_info['rtmp_url']}/{stream_info['rtmp_live']}"
+ elif stream_info.get('hls_url'):
+ return stream_info['hls_url']
+
+ return None
+ except Exception as e:
+ print(f"获取斗鱼播放URL失败: {e}")
+ return None
+
+ def _get_douyu_play_url_legacy(self, channel, device_id, signature, timestamp, rate):
+ """使用旧版API获取斗鱼播放URL"""
+ try:
+ legacy_payload = {
+ 'did': device_id,
+ 'tt': str(timestamp),
+ 'sign': signature,
+ 'cdn': '',
+ 'rate': str(rate) if rate > 0 else '-1',
+ 'ver': 'Douyu_223061205',
+ 'iar': '1',
+ 'ive': '1',
+ 'hevc': '0',
+ 'fa': '0'
+ }
+ legacy_api = f"https://www.douyu.com/lapi/live/getH5Play/{channel}"
+
+ headers = {
+ 'User-Agent': self.headers[0]['User-Agent'],
+ 'Referer': f'https://www.douyu.com/{channel}',
+ 'Cookie': f'dy_did={device_id}',
+ 'Content-Type': 'application/x-www-form-urlencoded'
+ }
+
+ res = requests.post(legacy_api, data=legacy_payload, headers=headers, timeout=10)
+ if res.status_code == 200:
+ data = res.json()
+ if data.get('error') == 0:
+ stream_info = data.get('data', {})
+ if stream_info.get('rtmp_url') and stream_info.get('rtmp_live'):
+ return f"{stream_info['rtmp_url']}/{stream_info['rtmp_live']}"
+ return None
+ except:
+ return None
+
+ def searchContent(self, key, quick, pg="1"):
+ pass
+
+ def playerContent(self, flag, id, vipFlags):
+ try:
+ ids = id.split('@@')
+ p = 1
+ if ids[0] in ['wangyi']:
+ p, url = 0, json.loads(self.d64(ids[1]))
+ elif ids[0] == 'bili':
+ p, url = self.biliplay(ids)
+ elif ids[0] == 'huya':
+ p, url = self.huyaplay(ids)
+ elif ids[0] == 'douyu':
+ p, url = self.douyuplay(ids)
+ return {'parse': p, 'url': url, 'header': self.playheaders[ids[0]]}
+ except Exception as e:
+ return {'parse': 1, 'url': self.excepturl, 'header': self.headers[0]}
+
+ def biliplay(self, ids):
+ """
+ B站播放解析 - 使用playUrl接口获取指定清晰度直播流
+ ids: [平台, 房间号, 清晰度qn]
+ 支持多线路返回
+ """
+ try:
+ room_id = ids[1]
+ qn = ids[2] if len(ids) > 2 else '10000'
+
+ # 使用playUrl接口获取直播流
+ play_url = f'{self.hosts["bili"][0]}/room/v1/Room/playUrl?cid={room_id}&qn={qn}&platform=web'
+ data = self.fetch(play_url, headers={
+ **self.headers[0],
+ 'Referer': 'https://live.bilibili.com/',
+ 'Origin': 'https://live.bilibili.com'
+ }).json()
+
+ if data.get('code') != 0:
+ return 1, self.excepturl
+
+ play_data = data['data']
+ durl_list = play_data.get('durl', [])
+
+ if not durl_list:
+ return 1, self.excepturl
+
+ # 构建多线路结果 [线路1, URL1, 线路2, URL2, ...]
+ urls = []
+ for idx, item in enumerate(durl_list, 1):
+ url = item.get('url')
+ if url:
+ urls.extend([f'线路{idx}', url])
+
+ # 如果只有一条线路,直接返回URL
+ if len(urls) == 2:
+ return 0, urls[1] # 直接返回URL字符串
+
+ return 0, urls
+
+ except Exception as e:
+ print(f"B站播放错误: {e}")
+ return 1, self.excepturl
+
+ def huyaplay(self, ids):
+ """
+ 虎牙播放解析 - 返回所有清晰度选项供用户选择
+ ids[1] 格式: base64编码的 [清晰度名称1, URL1, 清晰度名称2, URL2, ...]
+ """
+ try:
+ # ids[1] 是编码后的播放地址列表 [名称1, URL1, 名称2, URL2, ...]
+ decoded = json.loads(self.d64(ids[1]))
+ # decoded 是一个列表,奇数索引是名称,偶数索引是URL
+ return 0, decoded
+ except Exception as e:
+ print(f"虎牙播放解析错误: {e}")
+ return 1, self.excepturl
+
+ def douyuplay(self, ids):
+ """
+ 斗鱼播放解析 - 实时获取对应码率的播放URL
+ ids格式: [平台, base64(清晰度列表), base64(会话信息)]
+ 清晰度列表: [名称1, #码率1, 名称2, #码率2, ...]
+ #表示这是码率值,需要重新获取URL
+ """
+ try:
+ if len(ids) < 3:
+ # 兼容旧格式
+ decoded = json.loads(self.d64(ids[1]))
+ return 0, decoded
+
+ # 解析清晰度列表和会话信息
+ qualities = json.loads(self.d64(ids[1]))
+ session_info = json.loads(self.d64(ids[2]))
+
+ channel = session_info['channel']
+ device_id = session_info['device_id']
+ secret_key = session_info['secret_key']
+ random_str = session_info['random_str']
+ enc_time = session_info['enc_time']
+ enc_data = session_info['enc_data']
+
+ # 为每个清晰度实时获取播放URL
+ result = []
+ for i in range(0, len(qualities), 2):
+ name = qualities[i]
+ rate_marker = qualities[i + 1]
+
+ # 解析码率值(去掉#前缀)
+ if rate_marker.startswith('#'):
+ rate = int(rate_marker[1:])
+ else:
+ rate = -1
+
+ # 实时获取对应码率的URL
+ play_url = self._get_douyu_play_url(
+ channel, device_id, secret_key, random_str,
+ enc_time, enc_data, rate
+ )
+
+ if play_url:
+ result.extend([name, play_url])
+
+ if not result:
+ return 1, self.excepturl
+
+ return 0, result
+ except Exception as e:
+ print(f"斗鱼播放解析错误: {e}")
+ return 1, self.excepturl
+
+ def localProxy(self, param):
+ pass
+
+ def e64(self, text):
+ try:
+ text_bytes = text.encode('utf-8')
+ encoded_bytes = b64encode(text_bytes)
+ return encoded_bytes.decode('utf-8')
+ except Exception as e:
+ print(f"Base64编码错误: {str(e)}")
+ return ""
+
+ def d64(self, encoded_text):
+ try:
+ encoded_bytes = encoded_text.encode('utf-8')
+ decoded_bytes = b64decode(encoded_bytes)
+ return decoded_bytes.decode('utf-8')
+ except Exception as e:
+ print(f"Base64解码错误: {str(e)}")
+ return ""
+
+ def josn_to_params(self, params, skip_empty=False):
+ query = []
+ for k, v in params.items():
+ if skip_empty and not v:
+ continue
+ query.append(f"{k}={v}")
+ return "&".join(query)
+
+ def params_to_json(self, query_string):
+ parsed_data = parse_qs(query_string)
+ result = {key: value[0] for key, value in parsed_data.items()}
+ return result
+
+ def buildvod(self, vod_id='', vod_name='', vod_pic='', vod_year='', vod_tag='', vod_remarks='', style='',
+ type_name='', vod_area='', vod_actor='', vod_director='',
+ vod_content='', vod_play_from='', vod_play_url=''):
+ vod = {
+ 'vod_id': vod_id,
+ 'vod_name': vod_name,
+ 'vod_pic': vod_pic,
+ 'vod_year': vod_year,
+ 'vod_tag': 'folder' if vod_tag else '',
+ 'vod_remarks': vod_remarks,
+ 'style': style,
+ 'type_name': type_name,
+ 'vod_area': vod_area,
+ 'vod_actor': vod_actor,
+ 'vod_director': vod_director,
+ 'vod_content': vod_content,
+ 'vod_play_from': vod_play_from,
+ 'vod_play_url': vod_play_url
+ }
+ vod = {key: value for key, value in vod.items() if value}
+ return vod
+
+ def getpq(self, url, headers=None, cookies=None):
+ data = self.fetch(url, headers=headers, cookies=cookies).text
+ try:
+ return pq(data)
+ except Exception as e:
+ print(f"解析页面错误: {str(e)}")
+ return pq(data.encode('utf-8'))
+
+ def gethr(self, index, rf='', zr=''):
+ headers = self.headers[index]
+ if zr:
+ headers['referer'] = zr
+ else:
+ headers['referer'] = f"{self.referers[rf]}/"
+ return headers
+
+ def handle_exception(self, e):
+ print(f"报错: {str(e)}")
+ return {'vod_play_from': '哎呀翻车啦', 'vod_play_url': f'翻车啦${self.excepturl}'}
\ No newline at end of file
diff --git a/py/酷我.py b/py/酷我.py
new file mode 100644
index 0000000..a6c67a9
--- /dev/null
+++ b/py/酷我.py
@@ -0,0 +1,396 @@
+from base.spider import Spider
+import requests
+import json
+import re
+import sys
+import base64
+from urllib.parse import quote
+
+class Spider(Spider):
+ def getName(self):
+ return "小心儿悠悠"
+
+ def init(self, extend=""):
+ pass
+
+ def isVideoFormat(self, url):
+ pass
+
+ def manualVideoCheck(self):
+ pass
+
+ def homeContent(self, filter):
+ result = {}
+ cateId = [
+ {"type_name": "华语男", "type_id": "1"},
+ {"type_name": "华语女", "type_id": "2"},
+ {"type_name": "华语组合", "type_id": "3"},
+ {"type_name": "日韩男", "type_id": "4"},
+ {"type_name": "日韩女", "type_id": "5"},
+ {"type_name": "日韩组合", "type_id": "6"},
+ {"type_name": "欧美男", "type_id": "7"},
+ {"type_name": "欧美女", "type_id": "8"},
+ {"type_name": "欧美组合", "type_id": "9"},
+ {"type_name": "其他", "type_id": "0"}
+ ]
+ result['class'] = cateId
+ return result
+
+ def homeVideoContent(self):
+ result = self.categoryContent("1", 1, False, {})
+ return result
+
+ def categoryContent(self, tid, pg, filter, extend):
+ result = {}
+ url = f"http://wapi.kuwo.cn/api/www/artist/artistInfo?category={tid}&prefix=&pn={pg}&rn=30"
+ headers = {
+ 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36',
+ 'Referer': 'http://www.kuwo.cn/'
+ }
+
+ try:
+ r = requests.get(url, headers=headers, timeout=10)
+ data = r.json()
+ videos = []
+ if data.get('data') and data['data'].get('artistList'):
+ for item in data['data']['artistList']:
+ video = {
+ "vod_id": str(item.get('id', '')),
+ "vod_name": item.get('name', ''),
+ "vod_pic": item.get('pic300') or item.get('pic') or item.get('pic120', ''),
+ "vod_remarks": f""
+ }
+ videos.append(video)
+
+ result['list'] = videos
+ result['page'] = pg
+ result['pagecount'] = 9999
+ result['limit'] = 90
+ result['total'] = 999999
+
+ except Exception as e:
+ result['list'] = []
+
+ return result
+
+ def detailContent(self, ids):
+ rid = ids[0]
+ result = {}
+
+ info_url = f"http://wapi.kuwo.cn/api/www/artist/artist?artistid={rid}"
+ headers = {
+ 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36',
+ 'Referer': 'http://www.kuwo.cn/'
+ }
+
+ try:
+ r = requests.get(info_url, headers=headers, timeout=10)
+ info_data = r.json().get('data', {})
+
+ artist_name = info_data.get('name', '')
+
+ all_songs = self._get_artist_songs(rid)
+
+ artist_info = info_data.get('info', '')
+ artist_info = re.sub(r'<[^>]+>', '', artist_info)
+ artist_info = artist_info.replace(' ', ' ')
+ artist_info = artist_info.replace('\r\n', '\n').replace('\r', '\n')
+ artist_info = artist_info.strip()
+
+ max_songs = 300
+ if len(all_songs) > max_songs:
+ all_songs = all_songs[:max_songs]
+
+ play_arr = []
+ for i, song in enumerate(all_songs):
+ name = re.sub(r'[$#]', '', song.get('name', '')).strip()
+ song_id = song.get('rid', '')
+ album = song.get('album', '')
+
+ if album:
+ play_arr.append(f"{name} - {album}${song_id}")
+ else:
+ play_arr.append(f"{name}${song_id}")
+
+ vod = {
+ "vod_id": rid,
+ "vod_name": artist_name,
+ "vod_pic": info_data.get('pic300') or info_data.get('pic', ''),
+ "vod_content": artist_info if artist_info else "暂无歌手简介",
+ "vod_remarks": f"歌曲 : {len(all_songs)}首",
+ "vod_actor": artist_name,
+ "vod_play_from": "酷我音乐",
+ "vod_play_url": "#".join(play_arr)
+ }
+
+ result['list'] = [vod]
+
+ except Exception as e:
+ vod = {
+ "vod_id": rid,
+ "vod_name": "加载失败",
+ "vod_content": f"加载歌手信息失败: {str(e)}",
+ "vod_remarks": "加载失败",
+ "vod_actor": "未知",
+ "vod_play_from": "酷我音乐",
+ "vod_play_url": ""
+ }
+ result['list'] = [vod]
+
+ return result
+
+ def _get_artist_songs(self, rid):
+ headers = {
+ 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36',
+ 'Referer': 'http://www.kuwo.cn/'
+ }
+
+ songs = []
+ max_pages = 10
+
+ for page in range(1, max_pages + 1):
+ try:
+ url = f"http://wapi.kuwo.cn/api/www/artist/artistMusic?artistid={rid}&pn={page}&rn=30"
+ response = requests.get(url, headers=headers, timeout=10)
+ data = response.json()
+
+ if data.get('code') == 200:
+ music_data = data.get('data', {})
+ song_list = music_data.get('list', [])
+
+ if not song_list:
+ break
+
+ for song in song_list:
+ song_name = song.get('name', '').strip()
+ if song_name:
+ songs.append({
+ 'name': song_name,
+ 'rid': song.get('rid', ''),
+ 'album': song.get('album', ''),
+ 'duration': song.get('duration', '')
+ })
+
+ if len(songs) >= 300:
+ songs = songs[:300]
+ break
+
+ except Exception:
+ continue
+
+ return songs
+
+ def playerContent(self, flag, id, vipFlags):
+ result = {}
+ rid = id
+
+ qualities = []
+
+ quality_list = [
+ ("无损FLAC", 2000, "flac"),
+ ("高品质320K", 320, "mp3"),
+ ("标准128K", 128, "mp3")
+ ]
+
+ headers = {
+ 'User-Agent': 'Mozilla/5.0 (Linux; Android 10)',
+ 'Referer': 'https://www.kuwo.cn/'
+ }
+
+ for quality_name, bitrate, format_type in quality_list:
+ try:
+ api_url = f"https://nmobi.kuwo.cn/mobi.s?f=web&user=0&source=kwplayer_ar_4.4.2.7_B_nuoweida_vh.apk&type=convert_url_with_sign&rid={rid}&bitrate={bitrate}&format={format_type}"
+ r = requests.get(api_url, headers=headers, timeout=5)
+ data = r.json()
+ if data.get('code') == 200 and data.get('data') and data['data'].get('url'):
+ qualities.append((quality_name, data['data']['url']))
+ except Exception:
+ continue
+
+ if not qualities:
+ result["parse"] = 0
+ result["playUrl"] = ""
+ result["url"] = ""
+ result["header"] = {}
+ return result
+
+ urls = []
+ for quality_name, quality_url in qualities:
+ urls.append(quality_name)
+ urls.append(quality_url)
+
+ lrc = ""
+ pic = ""
+
+ try:
+ lrc_api = f"https://kuwo.cn/openapi/v1/www/lyric/getlyric?musicId={rid}"
+ lr = requests.get(lrc_api, timeout=5)
+ lj = lr.json()
+ if lj.get('data') and lj['data'].get('lrclist'):
+ lrc = "\n".join([f"[{self._format_time(float(item.get('time', 0)))}]{item.get('lineLyric', '')}"
+ for item in lj['data']['lrclist']])
+ except Exception:
+ pass
+
+ try:
+ pic_url = f"http://artistpicserver.kuwo.cn/pic.web?type=rid_pic&pictype=url&size=500&rid={rid}"
+ pr = requests.get(pic_url, timeout=5)
+ if pr.text.startswith('http'):
+ pic = pr.text.strip()
+ else:
+ pic = pic_url
+ except Exception:
+ pic = f"http://artistpicserver.kuwo.cn/pic.web?type=rid_pic&pictype=url&size=500&rid={rid}"
+
+ if lrc:
+ try:
+ ssa_lrc = self._create_ssa_subtitle(lrc)
+ ssa_base64 = base64.b64encode(ssa_lrc.encode('utf-8')).decode('utf-8')
+ ssa_url = f"data:text/x-ssa;base64,{ssa_base64}"
+
+ result["subs"] = [{
+ "name": "5行歌词",
+ "url": ssa_url,
+ "format": "text/x-ssa",
+ "selected": True
+ }]
+ except Exception:
+ pass
+
+ result["parse"] = 0
+ result["playUrl"] = ""
+ result["url"] = urls
+ result["header"] = {
+ "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36",
+ "Referer": "https://www.kuwo.cn/"
+ }
+
+ return result
+
+ def _format_time(self, seconds):
+ m = int(seconds // 60)
+ s = seconds % 60
+ return f"{m:02d}:{s:05.2f}"
+
+ def _create_ssa_subtitle(self, lrc_text):
+ lines = []
+ pattern = r'\[(\d{2}):(\d{2})\.(\d{2})\](.*)'
+
+ for line in lrc_text.split('\n'):
+ match = re.match(pattern, line)
+ if match:
+ minutes = int(match.group(1))
+ seconds = int(match.group(2))
+ hundredths = int(match.group(3))
+ text = match.group(4).strip()
+
+ total_seconds = minutes * 60 + seconds + hundredths / 100.0
+ if text:
+ lines.append({
+ 'start': total_seconds,
+ 'text': text
+ })
+
+ if not lines:
+ return ""
+
+ ssa_header = """[Script Info]
+ScriptType: v4.00+
+Collisions: Normal
+PlayResX: 1280
+PlayResY: 720
+Timer: 100.0000
+WrapStyle: 0
+
+[V4+ Styles]
+Format: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, BackColour, Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding
+Style: WAITING_TOP2,Roboto,55,&H0000FFFF,&H00808080,&H00000000,&H00000000,-1,0,0,0,100,100,0,0,1,1,1,2,0,0,180,1
+Style: WAITING_TOP1,Roboto,55,&H0000FFFF,&H00808080,&H00000000,&H00000000,-1,0,0,0,100,100,0,0,1,1,1,2,0,0,260,1
+Style: PLAYING_CENTER,Roboto,60,&H0000FF00,&H00808080,&H00000000,&H00000000,-1,0,0,0,100,100,0,0,1,2,2,2,0,0,340,1
+Style: PLAYED_BOTTOM1,Roboto,55,&H0000FFFF,&H00808080,&H00000000,&H00000000,-1,0,0,0,100,100,0,0,1,1,1,2,0,0,420,1
+Style: PLAYED_BOTTOM2,Roboto,55,&H0000FFFF,&H00808080,&H00000000,&H00000000,-1,0,0,0,100,100,0,0,1,1,1,2,0,0,500,1
+
+[Events]
+Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text
+"""
+
+ def format_ssa_time(seconds):
+ h = int(seconds // 3600)
+ m = int((seconds % 3600) // 60)
+ s = int(seconds % 60)
+ cs = int((seconds * 100) % 100)
+ return f"{h}:{m:02d}:{s:02d}.{cs:02d}"
+
+ events = []
+
+ for i in range(len(lines)):
+ current = lines[i]
+ current_end = lines[i+1]['start'] if i+1 < len(lines) else current['start'] + 5.0
+
+ wait2 = lines[i+2] if i+2 < len(lines) else None
+ wait1 = lines[i+1] if i+1 < len(lines) else None
+ played1 = lines[i-1] if i-1 >= 0 else None
+ played2 = lines[i-2] if i-2 >= 0 else None
+
+ start_str = format_ssa_time(current['start'])
+ end_str = format_ssa_time(current_end)
+
+ if wait2:
+ events.append(f"Dialogue: 1,{start_str},{end_str},WAITING_TOP2,,0,0,0,,{wait2['text']}")
+
+ if wait1:
+ events.append(f"Dialogue: 2,{start_str},{end_str},WAITING_TOP1,,0,0,0,,{wait1['text']}")
+
+ events.append(f"Dialogue: 3,{start_str},{end_str},PLAYING_CENTER,,0,0,0,,{current['text']}")
+
+ if played1:
+ events.append(f"Dialogue: 4,{start_str},{end_str},PLAYED_BOTTOM1,,0,0,0,,{played1['text']}")
+
+ if played2:
+ events.append(f"Dialogue: 5,{start_str},{end_str},PLAYED_BOTTOM2,,0,0,0,,{played2['text']}")
+
+ return ssa_header + "\n".join(events)
+
+ def searchContent(self, key, quick, pg=1):
+ result = {}
+ wd = quote(key)
+ page_num = (int(pg) - 1) * 30
+ url = f"https://search.kuwo.cn/r.s?client=kt&pn={page_num}&rn=30&all={wd}&vipver=1&ft=artist&encoding=utf8&rformat=json&mobi=1"
+ headers = {
+ 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36',
+ 'Referer': 'http://www.kuwo.cn/'
+ }
+
+ try:
+ r = requests.get(url, headers=headers, timeout=10)
+ data = r.json()
+ videos = []
+ if data.get('abslist'):
+ base_path = data.get('BASEPICPATH', 'http://img1.kuwo.cn/star/starheads/')
+ for item in data['abslist']:
+ aid = item.get('ARTISTID') or item.get('DC_TARGETID', '')
+ pic = item.get('hts_PICPATH') or (base_path + item['PICPATH'] if item.get('PICPATH') else '')
+ video = {
+ "vod_id": str(aid),
+ "vod_name": item.get('ARTIST', ''),
+ "vod_pic": pic,
+ "vod_remarks": f"歌曲 : {item.get('SONGNUM', 0)}首"
+ }
+ videos.append(video)
+
+ result['list'] = videos
+ result['page'] = pg
+ result['pagecount'] = 9999
+ result['limit'] = 30
+ result['total'] = 999999
+
+ except Exception as e:
+ result['list'] = []
+
+ return result
+
+ def searchContentPage(self, key, quick, pg):
+ return self.searchContent(key, quick, pg)
+
+ def localProxy(self, param):
+ return None
\ No newline at end of file