From 0b00a33f92c541faa82db9572e53d313873099d9 Mon Sep 17 00:00:00 2001 From: XuanRan_Nyan <31968665+xxxxuanran@users.noreply.github.com> Date: Thu, 24 Apr 2025 16:04:46 +0800 Subject: [PATCH] =?UTF-8?q?feat(douyu):=20=E6=9E=84=E5=BB=BA=E6=96=97?= =?UTF-8?q?=E9=B1=BC=E7=9B=B4=E6=92=AD=20tct=20&=20hs=20=E6=B5=81=E9=93=BE?= =?UTF-8?q?=E6=8E=A5=20(#1256)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(douyu): 构建斗鱼直播流链接 * 构建斗鱼直播流链接 * fix func err * cleanup * 默认 cdn 更换为 hw-h5 * Update __init__.py --------- Co-authored-by: ForgQi <34411314+ForgQi@users.noreply.github.com> --- biliup/__init__.py | 2 +- biliup/engine/download.py | 5 +- biliup/plugins/douyu.py | 185 ++++++++++++++++++++++++++++++++++---- 3 files changed, 173 insertions(+), 19 deletions(-) diff --git a/biliup/__init__.py b/biliup/__init__.py index 1638869..403b287 100644 --- a/biliup/__init__.py +++ b/biliup/__init__.py @@ -2,7 +2,7 @@ import logging import platform import sys -__version__ = "0.4.93" +__version__ = "0.4.94" LOG_CONF = { 'version': 1, diff --git a/biliup/engine/download.py b/biliup/engine/download.py index bbd848a..586a25d 100644 --- a/biliup/engine/download.py +++ b/biliup/engine/download.py @@ -210,7 +210,10 @@ class DownloadBase(ABC): # 文件名不含后戳 fmt_file_name = self.gen_download_filename(is_fmt=True) # ffmpeg 输入参数 - input_args = [] + input_args = [ + # '-http_proxy', 'http://127.0.0.1:10808', + # "-fflags", "+genpts", + ] # ffmpeg 输出参数 output_args = [ '-c', diff --git a/biliup/plugins/douyu.py b/biliup/plugins/douyu.py index ab5cc72..376b40c 100644 --- a/biliup/plugins/douyu.py +++ b/biliup/plugins/douyu.py @@ -1,6 +1,7 @@ import hashlib import time -from urllib.parse import parse_qs +import random +from urllib.parse import parse_qs, urlencode, quote from functools import lru_cache from biliup.common.util import client @@ -8,7 +9,7 @@ from biliup.config import config from biliup.Danmaku import DanmakuClient from ..engine.decorators import Plugin from ..engine.download import DownloadBase -from ..plugins import logger, match1, random_user_agent +from ..plugins import logger, match1, random_user_agent, json_loads @Plugin.download(regexp=r'https?://(?:(?:www|m)\.)?douyu\.com') @@ -18,7 +19,7 @@ class Douyu(DownloadBase): self.__room_id = match1(url, r'rid=(\d+)') self.douyu_danmaku = config.get('douyu_danmaku', False) self.douyu_disable_interactive_game = config.get('douyu_disable_interactive_game', False) - self.douyu_cdn = config.get('douyu_cdn', 'tct-h5') + self.douyu_cdn = config.get('douyu_cdn', 'hw-h5') self.douyu_rate = config.get('douyu_rate', 0) async def acheck_stream(self, is_check=False): @@ -26,6 +27,9 @@ class Douyu(DownloadBase): logger.error(f"{self.plugin_msg}: 直播间地址错误") return False + self.fake_headers['referer'] = f"https://www.douyu.com" + self.fake_headers['origin'] = f"https://www.douyu.com" + try: if not self.__room_id: self.__room_id = _get_real_rid(self.url) @@ -34,9 +38,9 @@ class Douyu(DownloadBase): return False try: - room_info = ( - await client.get(f"https://www.douyu.com/betard/{self.__room_id}", headers=self.fake_headers) - ).json()['room'] + room_info = await client.get(f"https://www.douyu.com/betard/{self.__room_id}", headers=self.fake_headers) + room_info.raise_for_status() + room_info = json_loads(room_info.text)['room'] except: logger.exception(f"{self.plugin_msg}: 获取直播间信息错误") return False @@ -93,16 +97,43 @@ class Douyu(DownloadBase): logger.exception(f"{self.plugin_msg}: 获取签名参数异常") return False - params['cdn'] = self.douyu_cdn - params['rate'] = int(self.douyu_rate) + params.update({ + 'cdn': [self.douyu_cdn], + 'rate': [str(self.douyu_rate)], + 'iar': ['1'], + 'ive': ['0'], + 'rid': self.__room_id, + 'hevc': ['0'], + 'fa': ['0'], + 'sov': ['0'], + 'ver': ['219032101'], + }) + + self._req_data = parse_qs(urlencode(params, doseq=True, encoding='utf-8')) try: - live_data = await self.get_play_info(self.__room_id, params) - self.raw_stream_url = f"{live_data['rtmp_url']}/{live_data['rtmp_live']}" + live_data = await self.get_play_info(self.__room_id, self._req_data) + url = f"{live_data['rtmp_url']}/{live_data['rtmp_live']}" except: logger.exception(f"{self.plugin_msg}: ") return False + host = None + is_tct = (live_data['rtmp_url'].find('tc-tct.douyucdn2.cn') != -1) + # HACK: 构建斗鱼直播流链接 + try: + if self.douyu_cdn == 'hs-h5': + host, _, url = await self.build_hs_url(url) + elif self.douyu_cdn == 'tct-h5' and not is_tct: + _, _, url = await self.build_tx_url(url) + except (RuntimeError, ValueError) as e: + logger.error(f"{self.plugin_msg}: {e}") + + if url: + self.raw_stream_url = url + if host: + self.fake_headers['host'] = host + return True def danmaku_init(self): @@ -112,19 +143,139 @@ class Douyu(DownloadBase): } self.danmaku = DanmakuClient(self.url, self.gen_download_filename(), content) - async def get_play_info(self, room_id, params): - live_data = await client.post( - f'https://www.douyu.com/lapi/live/getH5Play/{room_id}', headers=self.fake_headers, params=params) + async def get_play_info(self, room_id, data, mobile=False, preview=False): + req_headers = self.fake_headers.copy() + if mobile: + url = 'https://m.douyu.com/api/room/ratestream' + elif preview: + c_time_str = str(time.time_ns()) + url = f'https://playweb.douyucdn.cn/lapi/live/hlsH5Preview/{room_id}?{c_time_str[:18]}' + data = { + 'rid': self.__room_id, + 'did': data.get('did', ["10000000000000000000000000001501"])[0], + } + req_headers.update({ + 'Rid': self.__room_id, + 'Time': c_time_str[:13], + 'Auth': hashlib.md5(f"{self.__room_id}{c_time_str[:13]}".encode('utf-8')).hexdigest(), + }) + else: + url = f'https://www.douyu.com/lapi/live/getH5Play/{room_id}' + live_data = await client.post(url, headers=req_headers, data=data) if not live_data.is_success: raise RuntimeError(live_data.text) - live_data = live_data.json().get('data') + live_data = json_loads(live_data.text).get('data') + if mobile or preview: + return live_data if isinstance(live_data, dict): - if not live_data['rtmp_cdn'].endswith('h5') or 'akm' in live_data['rtmp_cdn']: - params['cdn'] = live_data['cdnsWithName'][-1]['cdn'] - return await self.get_play_info(room_id, params) + if live_data.get('rtmp_cdn', '').startswith('scdn'): + import copy + __data = copy.deepcopy(data) + __data['cdn'] = live_data['cdnsWithName'][-1]['cdn'] + return await self.get_play_info(room_id, __data) return live_data raise RuntimeError(live_data) + def get_p2p_play_info(self, xp2p_unity_api_domain): + url = f"https://{xp2p_unity_api_domain}" + pass + + + def parse_stream_info(self, url) -> tuple[str, str, str]: + ''' + 解析推流信息 + ''' + def get_tx_app_name(rtmp_url) -> str: + ''' + 获取腾讯云推流应用名 + ''' + host = rtmp_url.split('//')[1].split('/')[0] + app_name = rtmp_url.split('/')[-1] + # group 按顺序排序 + i = match1(host, r'.+(sa|3a|1a|3|1)') + if i: + if i == "sa": + i = "1" + return f"dyliveflv{i}" + return app_name + list = url.split('?') + query = parse_qs(list[1]) + origin = query.get('origin', ['0'])[0] + if origin not in ['tct', 'hw', 'dy']: + ''' + dy: 斗鱼自建 + tct: 腾讯云 + hw: 华为云 + ''' + raise ValueError(f"当前流来源 {origin} 不支持切换为腾讯云推流") + elif origin == 'dy': + logger.warning(f"{self.plugin_msg}: 当前流来源 {origin} 可能不存在腾讯云流") + query_str = urlencode(query, doseq=True, encoding='utf-8') + stream_id = list[0].split('/')[-1].split('.')[0] + rtmp_url = list[0].split(stream_id)[0] + return get_tx_app_name(rtmp_url[:-1]), stream_id.split('_')[0], query_str + + async def build_tx_url(self, url, callback=None) -> tuple[str, str, str]: + ''' + 构建腾讯CDN URL + return: tx_host, stream_id, tx_url + ''' + tx_app_name, stream_id, query = self.parse_stream_info(url) + tx_host = "tc-tct.douyucdn2.cn" + tx_url = f"https://{tx_host}/{tx_app_name}/{stream_id}.flv?%s" + query = parse_qs(query) + m_data = await self.get_play_info(self.__room_id, self._req_data, mobile=True) + _, _, m_query = self.parse_stream_info(m_data['url']) + m_query = parse_qs(m_query) + m_query.pop('vhost', None) + query.update({ + 'fcdn': ['tct'], + **m_query, + }) + query = urlencode(query, doseq=True, encoding='utf-8') + return tx_host, stream_id, tx_url % query + + async def build_hs_url(self, url, *, tx_host=None, stream_id=None) -> tuple[str, str, str]: + ''' + 构建火山CDN URL + return: hs_host, stream_id, hs_ip_url + ''' + if not tx_host: + tx_host, stream_id, tx_url = await self.build_tx_url(url) + else: + tx_url = url + query = parse_qs(tx_url.split('?')[-1]) + encoded_url = quote(tx_url, safe='') + query.update( + { + 'fp_user_url': [encoded_url], + 'vhost': [tx_host], + 'domain': [tx_host], + } + ) + query = urlencode(query, doseq=True, encoding='utf-8') + hs_host = "douyu-pull.s.volcfcdndvs.com" + # hs_host = "huos3.douyucdn2.cn" + hs_ip_list = [ + "98.98.121.44", # singapore + "128.14.110.71", # singapore + "128.14.110.70", # singapore + "104.166.175.26", # russia + "104.166.175.2", # russia + "45.43.34.227", # hongkong + "192.169.99.102", # hongkong + "156.59.27.66", # japan + "156.59.27.65", # japan + "23.248.183.107", # south-africa + "23.248.183.106", # south-africa + ] + hs_ip = random.choice(hs_ip_list) + logger.info(f"{self.plugin_msg}: 使用 {hs_ip} 作为火山CDN节点") + hs_url = f"http://{hs_host}/live/{stream_id}.flv?{query}" + hs_ip_url = hs_url.replace(hs_host, hs_ip) + return hs_host, stream_id, hs_ip_url + + @lru_cache(maxsize=None) def _get_real_rid(url): import requests