From 23c40ec5aedf12989bd1d36701ebd69afd50c5c8 Mon Sep 17 00:00:00 2001 From: XuanRan_Nyan <31968665+xxxxuanran@users.noreply.github.com> Date: Tue, 9 Sep 2025 10:29:05 +0800 Subject: [PATCH] =?UTF-8?q?refactor(Douyu):=20=E5=8E=BB=E9=99=A4=E5=AF=B9?= =?UTF-8?q?=20JSEngine=20=E7=9A=84=E5=BC=BA=E4=BE=9D=E8=B5=96=20(#1448)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * refactor(Douyu): 去除对 JSEngine 的强依赖 cleanup test remove test 修复竞争操作 cleanup fix some err 补充 jsengine 判断 add missing break fix unbinding var * add single-flight --- .gitignore | 4 +- biliup/plugins/__init__.py | 15 +- biliup/plugins/douyu.py | 564 +++++++++++++++++++++++++------------ 3 files changed, 398 insertions(+), 185 deletions(-) diff --git a/.gitignore b/.gitignore index b94451e..cbd9897 100644 --- a/.gitignore +++ b/.gitignore @@ -48,4 +48,6 @@ target _internal .trae/ pnpm-lock.yaml -cookies.json \ No newline at end of file +cookies.json +.augment/ +.cunzhi-memory/ \ No newline at end of file diff --git a/biliup/plugins/__init__.py b/biliup/plugins/__init__.py index ee14bae..e391b23 100644 --- a/biliup/plugins/__init__.py +++ b/biliup/plugins/__init__.py @@ -56,16 +56,29 @@ def json_loads(text: Union[str, None]) -> Dict[str, Any]: except json.JSONDecodeError as e: raise ValueError(f"Invalid JSON: {text}") from e + +def test_jsengine(): + try: + import jsengine + jsengine.jsengine() + except Exception as e: + logger.exception(f"jsengine test failed: {e}") + return False + return True + + def generate_fake_buvid3(): u = uuid.uuid4() u_str = str(u).upper().replace('-', '') formatted = f"{u_str[0:8]}-{u_str[8:12]}-{u_str[12:16]}-{u_str[16:20]}-{u_str[20:]}infoc" return formatted + class SubmitOption: App = "app" BCutAndroid = "b-cut-android" + class Wbi: WTS = "wts" W_RID = "w_rid" @@ -93,7 +106,7 @@ class Wbi: self.last_update = int(time.time()) logger.info(f"Updated wbi key successfully") - def sign(self, query: dict, ts: int = None): + def sign(self, query: dict, ts: int = 0): """ 生成签名。 :param query: 请求参数 diff --git a/biliup/plugins/douyu.py b/biliup/plugins/douyu.py index 376b40c..394aa3b 100644 --- a/biliup/plugins/douyu.py +++ b/biliup/plugins/douyu.py @@ -1,49 +1,72 @@ import hashlib import time -import random +import httpx +import asyncio from urllib.parse import parse_qs, urlencode, quote -from functools import lru_cache +from async_lru import alru_cache +from typing import Union, Any, Optional -from biliup.common.util import client -from biliup.config import config -from biliup.Danmaku import DanmakuClient + +from ..common.util import client +from ..config import config +from ..Danmaku import DanmakuClient from ..engine.decorators import Plugin from ..engine.download import DownloadBase -from ..plugins import logger, match1, random_user_agent, json_loads +from ..plugins import logger, match1, random_user_agent, json_loads, test_jsengine + + +DOUYU_DEFAULT_DID = "10000000000000000000000000001501" +DOUYU_WEB_DOMAIN = "www.douyu.com" +DOUYU_PLAY_DOMAIN = "playweb.douyucdn.cn" +DOUYU_MOBILE_DOMAIN = "m.douyu.com" @Plugin.download(regexp=r'https?://(?:(?:www|m)\.)?douyu\.com') class Douyu(DownloadBase): def __init__(self, fname, url, suffix='flv'): super().__init__(fname, url, suffix) - self.__room_id = match1(url, r'rid=(\d+)') + self.room_id: str = "" 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', 'hw-h5') self.douyu_rate = config.get('douyu_rate', 0) + self.__js_runable = test_jsengine() + async def acheck_stream(self, is_check=False): if len(self.url.split("douyu.com/")) < 2: 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" + self.fake_headers['referer'] = f"https://{DOUYU_WEB_DOMAIN}" try: - if not self.__room_id: - self.__room_id = _get_real_rid(self.url) + self.room_id = str(match1(self.url, r'rid=(\d+)')) + if not self.room_id.isdigit(): + self.room_id = await get_real_rid(self.url) except: logger.exception(f"{self.plugin_msg}: 获取房间号错误") return False - try: - 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}: 获取直播间信息错误") + for _ in range(3): # 缓解 #1376 海外请求失败问题 + try: + room_info = await client.get( + f"https://{DOUYU_WEB_DOMAIN}/betard/{self.room_id}", + headers=self.fake_headers + ) + room_info.raise_for_status() + except httpx.RequestError as e: + logger.debug(f"{self.plugin_msg}: {e}", exc_info=True) + continue + except: + logger.exception(f"{self.plugin_msg}: 获取直播间信息错误") + return False + else: + break + else: + logger.error(f"{self.plugin_msg}: 获取直播间信息失败") return False + room_info = json_loads(room_info.text)['room'] if room_info['show_status'] != 1: logger.debug(f"{self.plugin_msg}: 未开播") @@ -53,135 +76,206 @@ class Douyu(DownloadBase): return False if self.douyu_disable_interactive_game: gift_info = ( - await client.get(f"https://www.douyu.com/api/interactive/web/v2/list?rid={self.__room_id}", - headers=self.fake_headers) - ).json().get('data', {}) + await client.get( + f"https://{DOUYU_WEB_DOMAIN}/api/interactive/web/v2/list?rid={self.room_id}", + headers=self.fake_headers + )).json().get('data', {}) if gift_info: logger.debug(f"{self.plugin_msg}: 正在运行互动游戏") return False self.room_title = room_info['room_name'] + if room_info['isVip'] == 1: + async with DouyuUtils._lock: + DouyuUtils.VipRoom.add(self.room_id) if is_check: - try: - import jsengine - try: - jsengine.jsengine() - except jsengine.exceptions.RuntimeError as e: - extra_msg = "如需录制斗鱼直播," - logger.error(f"\n{e}\n{extra_msg}请至少安装一个 Javascript 解释器,如 pip install quickjs") - return False - except: - logger.exception(f"{self.plugin_msg}: ") - return False return True - try: - import jsengine - ctx = jsengine.jsengine() - js_enc = ( - await client.get(f'https://www.douyu.com/swf_api/homeH5Enc?rids={self.__room_id}', - headers=self.fake_headers) - ).json()['data'][f'room{self.__room_id}'] - js_enc = js_enc.replace('return eval', 'return [strc, vdwdae325w_64we];') + # 提到 self 以供 hack 功能使用 + self.__req_query = { + 'cdn': self.douyu_cdn, + 'rate': str(self.douyu_rate), + 'ver': '219032101', + 'iar': '0', # ispreload? 1: 忽略 rate 参数,使用默认画质 + 'ive': '0', # rate? 0~19 时、19~24 时请求数 >=3 为真 + 'rid': self.room_id, + 'hevc': '0', + 'fa': '0', # isaudio + 'sov': '0', # use wasm? + } - sign_fun, sign_v = ctx.eval(f'{js_enc};ub98484234();') - - tt = str(int(time.time())) - did = hashlib.md5(tt.encode('utf-8')).hexdigest() - rb = hashlib.md5(f"{self.__room_id}{did}{tt}{sign_v}".encode('utf-8')).hexdigest() - sign_fun = sign_fun.rstrip(';').replace("CryptoJS.MD5(cb).toString()", f'"{rb}"') - sign_fun += f'("{self.__room_id}","{did}","{tt}");' - - params = parse_qs(ctx.eval(sign_fun)) - except: - logger.exception(f"{self.plugin_msg}: 获取签名参数异常") + for _ in range(2): # 允许多重试一次以剔除 scdn + # self.__js_runable = False + try: + play_info = await self.aget_web_play_info(self.room_id, self.__req_query) + if play_info['rtmp_cdn'].startswith('scdn'): + new_cdn = play_info['cdnsWithName'][-1]['cdn'] + logger.debug(f"{self.plugin_msg}: 回避 scdn 为 {new_cdn}") + self.__req_query['cdn'] = new_cdn + continue + except (RuntimeError, ValueError) as e: + logger.warning(f"{self.plugin_msg}: {e}") + except httpx.RequestError as e: + logger.debug(f"{self.plugin_msg}: {e}", exc_info=True) + except Exception as e: + logger.exception(f"{self.plugin_msg}: 未处理的错误 {e},自动重试") + else: + break + else: + # Unknown Error + logger.error(f"{self.plugin_msg}: 获取播放信息失败") return False - 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.raw_stream_url = f"{play_info['rtmp_url']}/{play_info['rtmp_live']}" - self._req_data = parse_qs(urlencode(params, doseq=True, encoding='utf-8')) - - try: - 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 + # HACK: 构造 hs-h5 cdn 直播流链接 + # self.douyu_cdn = 'hs-h5' + if self.douyu_cdn == 'hs-h5' and play_info['rtmp_cdn'] != 'hs-h5': + if not self.__js_runable: + logger.warning(f"{self.plugin_msg}: 未找到 jsengine,无法构建 hs-h5 链接") + is_tct = play_info['rtmp_cdn'] == 'tct-h5' + try: + fake_host, cname_url = await self.build_hs_url(self.raw_stream_url, is_tct) + except: + logger.exception(f"{self.plugin_msg}: 构建 hs-h5 链接失败") + else: + self.raw_stream_url = cname_url + self.stream_headers['Host'] = fake_host return True + def danmaku_init(self): if self.douyu_danmaku: content = { - 'room_id': self.__room_id, + 'room_id': self.room_id, } self.danmaku = DanmakuClient(self.url, self.gen_download_filename(), content) - 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], + + async def aget_sign(self, rid: Union[str, int]) -> dict[str, Any]: + ''' + :param rid: 房间号 + :return: sign dict + ''' + if not self.__js_runable: + raise RuntimeError("jsengine not found") + try: + import jsengine + ctx = jsengine.jsengine() + js_enc = ( + await client.get(f'https://www.douyu.com/swf_api/homeH5Enc?rids={rid}', + headers=self.fake_headers) + ).json()['data'][f'room{rid}'] + js_enc = js_enc.replace('return eval', 'return [strc, vdwdae325w_64we];') + + sign_fun, sign_v = ctx.eval(f'{js_enc};ub98484234();') # type: ignore + + tt = str(int(time.time())) + did = hashlib.md5(tt.encode('utf-8')).hexdigest() + rb = hashlib.md5(f"{rid}{did}{tt}{sign_v}".encode('utf-8')).hexdigest() + sign_fun = sign_fun.rstrip(';').replace("CryptoJS.MD5(cb).toString()", f'"{rb}"') + sign_fun += f'("{rid}","{did}","{tt}");' + + params = parse_qs(ctx.eval(sign_fun)) + + except Exception as e: + logger.exception(f"{self.plugin_msg}: 获取签名参数异常") + raise e + return params + + + async def aget_web_play_info( + self, + room_id: Union[str, int], + req_query: dict[str, Any], + req_method: str = "POST", + did: str = DOUYU_DEFAULT_DID, + ) -> dict[str, Any]: + ''' + :param room_id: 房间号 + :param req_query: 请求参数 + :param req_method: 请求方法。可选 GET, POST(默认) + :param did: douyuid + :return: PlayInfo + ''' + if type(room_id) == int: + room_id = str(room_id) + if not self.__js_runable: + s = await DouyuUtils.sign(type="stream", ts=int(time.time()), did=did, rid=room_id) + logger.debug(f"{self.plugin_msg}: 免 JSEngine 签名参数 {s}") + auth_param = { + "enc_data": s['key']['enc_data'], + "tt": s['ts'], + "did": did, + "auth": s['auth'], } - 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(), - }) + req_query.update(auth_param) 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 = json_loads(live_data.text).get('data') - if mobile or preview: - return live_data - if isinstance(live_data, dict): - 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 + s = await self.aget_sign(room_id) + logger.debug(f"{self.plugin_msg}: JSEngine 签名参数 {s}") + req_query.update(s) + api_ver = "V1" if not self.__js_runable else "" + is_vip = room_id in DouyuUtils.VipRoom # 非 vip room 需要 e 参数,部分直播间可直接请求 hs-h5 + req_method = "GET" if is_vip and not api_ver else "POST" + path = f"/lapi/live/getH5Play{api_ver}/{room_id}" + url = f"https://{DOUYU_PLAY_DOMAIN}{path}" if req_method == "GET" else f"https://{DOUYU_WEB_DOMAIN}{path}" + # url += f"?{urlencode(req_query, doseq=True, encoding='utf-8')}" + logger.debug(f"{self.plugin_msg}: 使用参数 {str(req_query)} 请求 {url}") + if req_method == "GET": + rsp = await client.get( + url, + headers=self.fake_headers, + params=req_query + ) + else: + rsp = await client.post( + url, + headers={**self.fake_headers, 'user-agent': DouyuUtils.UserAgent}, + params=req_query, # V1 接口需使用查询参数 + data=req_query # 原接口需使用请求体 + ) + rsp.raise_for_status() + play_data = json_loads(rsp.text) + if not play_data: + raise RuntimeError(f"获取播放信息失败 {rsp}") + if play_data['error'] != 0 or not play_data.get('data', {}): + raise ValueError(f"获取播放信息错误 {str(play_data)}") + return play_data['data'] - def parse_stream_info(self, url) -> tuple[str, str, str]: + async def aget_mobile_play_info( + self, + req_query: dict[str, Any] + ) -> dict[str, Any]: + if not self.__js_runable: + raise RuntimeError("jsengine not found") + url = f'https://{DOUYU_MOBILE_DOMAIN}/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(), + # }) + rsp = await client.post( + url, + headers={**self.fake_headers, 'user-agent': random_user_agent('mobile')}, + data=req_query + ) + rsp.raise_for_status() + play_data = json_loads(rsp.text) + if play_data['code'] != 0: + raise ValueError(f"获取播放信息错误 {str(play_data)}") + return play_data['data'] + + + def parse_stream_info(self, url) -> tuple[str, str, dict]: ''' 解析推流信息 ''' @@ -199,8 +293,18 @@ class Douyu(DownloadBase): return f"dyliveflv{i}" return app_name list = url.split('?') - query = parse_qs(list[1]) - origin = query.get('origin', ['0'])[0] + query = {k: v[0] for k, v in parse_qs(list[1]).items()} + stream_id = list[0].split('/')[-1].split('.')[0].split('_')[0] + rtmp_url = list[0].split(stream_id)[0] + return get_tx_app_name(rtmp_url[:-1]), stream_id, query + + + async def build_tx_url(self, tx_app_name, stream_id, query) -> str: + ''' + 构建腾讯CDN URL + return: tx_url + ''' + origin = query.get('origin', 'unknown') if origin not in ['tct', 'hw', 'dy']: ''' dy: 斗鱼自建 @@ -210,79 +314,173 @@ class Douyu(DownloadBase): 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_play_info = await self.aget_mobile_play_info(self.__req_query) + _, _, m_query = self.parse_stream_info(m_play_info['url']) + # 需要移动端的宽松验证 token m_query.pop('vhost', None) query.update({ - 'fcdn': ['tct'], + 'fcdn': 'tct', **m_query, }) query = urlencode(query, doseq=True, encoding='utf-8') - return tx_host, stream_id, tx_url % query + return tx_url % query - async def build_hs_url(self, url, *, tx_host=None, stream_id=None) -> tuple[str, str, str]: + + async def build_hs_url(self, url: str, is_tct: bool = False) -> tuple[str, str]: ''' 构建火山CDN URL - return: hs_host, stream_id, hs_ip_url + :param url: 腾讯云 URL + :param is_tct: 是否为 tct 流 + return: fake_hs_host, hs_cname_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], - } - ) + tx_app_name, stream_id, query = self.parse_stream_info(url) + # 必须从 tct 转 hs + if not is_tct: + url = await self.build_tx_url(tx_app_name, stream_id, query) + tx_host = url.split('//')[1].split('/')[0] + hs_host = f"{tx_app_name.replace('dyliveflv', 'huos')}.douyucdn2.cn" + hs_host = hs_host.replace('huos1.', 'huosa.') + encoded_url = quote(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 + hs_cname_host = "douyu-pull.s.volcfcdndvs.com" + hs_cname_url = f"http://{hs_cname_host}/live/{stream_id}.flv?{query}" + return (hs_host, hs_cname_url) -@lru_cache(maxsize=None) -def _get_real_rid(url): - import requests - headers = { - "user-agent": random_user_agent('mobile'), - } +class DouyuUtils: + ''' + 逆向实现 //shark2.douyucdn.cn/front-publish/live-master/js/player_first_preload_stream/player_first_preload_stream_6cd7aab.js + ''' + WhiteEncryptKey: dict = dict() + VipRoom: set = set() + # enc_data 会校验 UA + UserAgent: str = "" + # 防止并发访问 + _lock: asyncio.Lock = asyncio.Lock() + _update_key_event: Optional[asyncio.Event] = None + + @staticmethod + def is_key_valid(): + return ( + bool(DouyuUtils.WhiteEncryptKey) # Key 存在 + and + DouyuUtils.WhiteEncryptKey.get('expire_at', 0) > int(time.time()) # Key 过期 + ) + + @staticmethod + async def update_key( + domain: str = DOUYU_WEB_DOMAIN, + did: str = DOUYU_DEFAULT_DID + ) -> bool: + # single-flight + async with DouyuUtils._lock: + if DouyuUtils._update_key_event is not None: + evt = DouyuUtils._update_key_event + leader = False + else: + DouyuUtils._update_key_event = asyncio.Event() + evt = DouyuUtils._update_key_event + leader = True + if not leader: + await evt.wait() + return DouyuUtils.is_key_valid() + + try: + # 防风控 + async with DouyuUtils._lock: + DouyuUtils.UserAgent = random_user_agent() + + rsp = await client.get( + f"https://{domain}/wgapi/livenc/liveweb/websec/getEncryption", + params={"did": did}, + headers={ + "User-Agent": DouyuUtils.UserAgent + }, + ) + rsp.raise_for_status() + data = json_loads(rsp.text) + if data['error'] != 0: + raise RuntimeError(f'getEncryption error: code={data["error"]}, msg={data["msg"]}') + data['data']['cpp']['expire_at'] = int(time.time()) + 86400 + + async with DouyuUtils._lock: + DouyuUtils.WhiteEncryptKey = data['data'] + return True + except Exception: + logger.exception(f"{DouyuUtils.__name__}: 获取加密密钥失败") + return False + finally: + async with DouyuUtils._lock: + if DouyuUtils._update_key_event is not None: + DouyuUtils._update_key_event.set() + DouyuUtils._update_key_event = None + + + @staticmethod + async def sign( + type: str, # unused + ts: int, + did: str, + rid: Union[str, int], + ) -> dict[str, Any]: + ''' + :param type: unused + :param ts: 10位Unix时间戳 + :param did: douyuid + :param rid: 房间号 + ''' + if not rid: + raise ValueError("rid is None") + + # 确保密钥有效 + for _ in range(2): # 重试两次 + if not DouyuUtils.is_key_valid(): + if not (await DouyuUtils.update_key()): + continue + break + else: + raise RuntimeError("获取加密密钥失败") + + if not type: + type = "stream" + if not ts: + ts = int(time.time()) + if not did: + did = DOUYU_DEFAULT_DID + + rand_str = DouyuUtils.WhiteEncryptKey['rand_str'] + enc_time = DouyuUtils.WhiteEncryptKey['enc_time'] + key = DouyuUtils.WhiteEncryptKey['key'] + is_special = DouyuUtils.WhiteEncryptKey['is_special'] + key_data = {k: v for k, v in DouyuUtils.WhiteEncryptKey.items() if k not in ["cpp"]} + + secret = rand_str + salt = "" if is_special else f"{rid}{ts}" + for _ in range(enc_time): + secret = hashlib.md5(f"{secret}{key}".encode('utf-8')).hexdigest() + auth = hashlib.md5(f"{secret}{key}{salt}".encode('utf-8')).hexdigest() + + return { + 'key': key_data, + 'alg_ver': "1.0", + "key_ver": "", + 'auth': auth, + 'ts': ts, + } + + +@alru_cache(maxsize=None) +async def get_real_rid(url: str) -> str: rid = url.split('douyu.com/')[1].split('/')[0].split('?')[0] or match1(url, r'douyu.com/(\d+)') - resp = requests.get(f"https://m.douyu.com/{rid}", headers=headers) + resp = await client.get(f"https://{DOUYU_MOBILE_DOMAIN}/{rid}", headers={ + "User-Agent": random_user_agent('mobile') + }) real_rid = match1(resp.text, r'roomInfo":{"rid":(\d+)') - return real_rid \ No newline at end of file + return str(real_rid)