diff --git a/__pycache__/main.cpython-312.pyc b/__pycache__/main.cpython-312.pyc index 1764664..bcaa6e9 100644 Binary files a/__pycache__/main.cpython-312.pyc and b/__pycache__/main.cpython-312.pyc differ diff --git a/bg.jpg b/bg.jpg index 16f066b..c7108fa 100644 Binary files a/bg.jpg and b/bg.jpg differ diff --git a/config.yaml b/config.yaml index 0ea24e8..ab4d72d 100644 --- a/config.yaml +++ b/config.yaml @@ -2,7 +2,7 @@ # 账号配置 accounts: - - username: "9006787894" + - username: "用户9006787894" password: "a778899" email: "jtmjcm@bdhg.xyz" email_password: "www123" @@ -21,7 +21,7 @@ proxy: # 极验配置 geetest: # 最大重试次数(遇到点选验证码时重试) - max_retries: 5 + max_retries: 10 # 日志配置 log: diff --git a/douyu/crypto.py b/douyu/crypto.py index 597a3af..e03dea4 100644 --- a/douyu/crypto.py +++ b/douyu/crypto.py @@ -5,6 +5,7 @@ import hashlib from Crypto.PublicKey import RSA from Crypto.Cipher import PKCS1_v1_5 from Crypto.Cipher import AES +from Crypto.Cipher import ARC4 # 斗鱼RSA公钥(从JS中提取) @@ -18,6 +19,9 @@ o2kz+dJoGnZM0b99VwIDAQAB # 斗鱼AES密钥(16位) DOUYU_AES_KEY = "1234567890abcdef" +# 斗鱼登录页 cryptoData 使用的 RC4 密钥 +DOUYU_RC4_KEY = "7TkbRSEWvVWebXbr" + def md5(text: str) -> str: """MD5加密""" @@ -58,6 +62,7 @@ def encrypt_password(password: str) -> str: def encrypt_nickname_or_phone(text: str) -> str: """加密昵称或手机号""" - # 斗鱼使用特殊的Base64编码 - encoded = base64.b64encode(text.encode('utf-8')).decode('utf-8') - return encoded + key = DOUYU_RC4_KEY.encode('utf-8') + cipher = ARC4.new(key) + encrypted = cipher.encrypt(text.encode('utf-8')) + return base64.b64encode(encrypted).decode('utf-8') diff --git a/douyu/login.py b/douyu/login.py index 86cccec..e42c18b 100644 --- a/douyu/login.py +++ b/douyu/login.py @@ -19,10 +19,13 @@ import sys sys.path.insert(0, str(Path(__file__).parent.parent)) from geetest import run_solver from geetest.solver import ( - _generate_seed, get_w1, get_w2, get_w3, + _generate_seed, get_w1, get_w2, +) +from geetest.network import ( + get_js_address, + get_c_s, + req_fullpage_validate, ) -from geetest.network import get_js_address, get_c_s, req_slide, get_picture, req_end -from geetest.imaging import download_picture class LoginResult: @@ -44,7 +47,16 @@ class DouyuLogin: VERIFY_API = "https://passport.douyu.com/wgapi/member/passport/remotelogin/verify" LOGIN_CALLBACK_API = "https://www.douyu.com/api/passport/login" WEBLOGIN_API = "https://msg.douyu.com/webLogin" - REQUEST_TIMEOUT = (3.05, 12) + LOGIN_REFERER = ( + "https://passport.douyu.com/index/login?" + "passport_reg_callback=PASSPORT_REG_SUCCESS_CALLBACK&" + "passport_login_callback=PASSPORT_LOGIN_SUCCESS_CALLBACK&" + "passport_close_callback=PASSPORT_CLOSE_CALLBACK&" + "passport_dp_callback=PASSPORT_DP_CALLBACK&" + "type=login&client_id=1&" + "state=https%3A%2F%2Fwww.douyu.com%2Fdirectory" + ) + REQUEST_TIMEOUT = (10, 30) def __init__( self, @@ -61,7 +73,7 @@ class DouyuLogin: self.session = requests.Session() # 初始化代理管理器 - self.proxy_manager = get_proxy_manager(proxy_api_url) + self.proxy_manager = get_proxy_manager(proxy_api_url) if proxy_api_url else None self._setup_session() @@ -74,8 +86,8 @@ class DouyuLogin: 'Accept': 'application/json, text/javascript, */*; q=0.01', 'Accept-Language': 'zh-CN,zh;q=0.9', 'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8', - 'Referer': 'https://www.douyu.com/', - 'Origin': 'https://www.douyu.com', + 'Referer': self.LOGIN_REFERER, + 'Origin': 'https://passport.douyu.com', 'X-Requested-With': 'XMLHttpRequest', }) @@ -105,15 +117,19 @@ class DouyuLogin: } else: # 从代理管理器获取代理 - new_proxy = self.proxy_manager.get_proxy() - if new_proxy: - self.session.proxies = { - 'http': new_proxy, - 'https': new_proxy, - } + if self.proxy_manager: + new_proxy = self.proxy_manager.get_proxy() + if new_proxy: + self.session.proxies = { + 'http': new_proxy, + 'https': new_proxy, + } def _refresh_proxy(self) -> Optional[str]: """刷新代理IP""" + if not self.proxy_manager: + return None + new_proxy = self.proxy_manager.get_proxy() if new_proxy: self._apply_proxy(new_proxy) @@ -125,32 +141,47 @@ class DouyuLogin: parsed = urlsplit(url) return urlunsplit((parsed.scheme, parsed.netloc, parsed.path, "", "")) - def _request(self, method: str, url: str, **kwargs) -> requests.Response: - """统一发送请求,附带分段超时和更明确的错误信息。""" + def _request(self, method: str, url: str, max_retries: int = 3, **kwargs) -> requests.Response: + """ + 统一发送请求,附带分段超时和更明确的错误信息。 + 代理连接失败时自动重试获取新的代理IP。 + """ timeout = kwargs.pop('timeout', self.timeout) safe_url = self._safe_url(url) - started = time.monotonic() - try: - response = self.session.request(method, url, timeout=timeout, **kwargs) - except requests.Timeout as exc: - elapsed = time.monotonic() - started - raise TimeoutError( - f"{method.upper()} {safe_url} 超时,耗时 {elapsed:.1f}s," - f"timeout={timeout}" - ) from exc - except requests.RequestException as exc: - elapsed = time.monotonic() - started - raise ConnectionError( - f"{method.upper()} {safe_url} 请求失败,耗时 {elapsed:.1f}s: {exc}" - ) from exc + for attempt in range(max_retries): + started = time.monotonic() - elapsed = time.monotonic() - started - logger.debug( - f"{method.upper()} {safe_url} -> {response.status_code} " - f"({elapsed:.2f}s)" - ) - return response + try: + response = self.session.request(method, url, timeout=timeout, **kwargs) + elapsed = time.monotonic() - started + logger.debug( + f"{method.upper()} {safe_url} -> {response.status_code} " + f"({elapsed:.2f}s)" + ) + return response + except requests.Timeout as exc: + elapsed = time.monotonic() - started + raise TimeoutError( + f"{method.upper()} {safe_url} 超时,耗时 {elapsed:.1f}s," + f"timeout={timeout}" + ) from exc + except requests.ProxyError as exc: + elapsed = time.monotonic() - started + logger.warning(f"代理连接失败,尝试 {attempt + 1}/{max_retries}: {exc}") + if attempt < max_retries - 1: + # 刷新代理 + self._refresh_proxy() + time.sleep(1) + continue + raise ConnectionError( + f"{method.upper()} {safe_url} 代理连接失败,已重试 {max_retries} 次" + ) from exc + except requests.RequestException as exc: + elapsed = time.monotonic() - started + raise ConnectionError( + f"{method.upper()} {safe_url} 请求失败,耗时 {elapsed:.1f}s: {exc}" + ) from exc def _request_json(self, method: str, url: str, source: str, **kwargs) -> dict: """请求 JSON 接口,并在响应异常时输出可定位的信息。""" @@ -181,8 +212,8 @@ class DouyuLogin: logger.info("步骤1: 第一次登录,获取极验参数...") gt, challenge, code_token, initial_cookies = self._first_login() - # 2️⃣ 极验滑块验证 - logger.info("步骤2: 极验滑块验证...") + # 2️⃣ 极验 fullpage 验证 + logger.info("步骤2: 极验 fullpage 验证...") validate, seccode = self._solve_geetest(gt, challenge) # 3️⃣ 第二次登录(带极验) @@ -229,7 +260,12 @@ class DouyuLogin: 'password': encrypted_password, 'biz_type': '1', 'room_id': '0', - 'redirect_url': 'https://www.douyu.com/', + 'redirect_url': self.LOGIN_REFERER, + 't': str(int(time.time() * 1000)), + 'client_id': '1', + 'did': '', + 'lang': '', + 'isMultiAccount': '0', } payload = self._request_json( @@ -262,7 +298,7 @@ class DouyuLogin: def _solve_geetest(self, gt: str, challenge: str) -> Tuple[str, str]: """ - 解决极验滑块验证(带重试机制) + 解决极验 fullpage 验证(带重试机制) Args: gt: 极验gt参数 @@ -271,70 +307,41 @@ class DouyuLogin: Returns: (validate, seccode) """ - logger.info("开始极验滑块验证...") + logger.info("开始极验 fullpage 验证...") for attempt in range(self.max_geetest_retries): try: logger.info(f"极验验证尝试 {attempt + 1}/{self.max_geetest_retries}") - # 使用geetest-v3-silde-crack的完整流程 + # 按斗鱼登录页 HAR:fullpage 智能检测流程,不进入图片滑块。 str_16 = _generate_seed() + proxies = dict(self.session.proxies) # 获取JS地址 - get_js_address(gt) + get_js_address(gt, proxies=proxies) # 获取第一个w值 w1 = get_w1(gt, challenge, str_16) # 获取c和s - c, s = get_c_s(gt, challenge, w1) + c, s = get_c_s(gt, challenge, w1, proxies=proxies) # 获取第二个w值 w2 = get_w2(gt, challenge, c, s, str_16) - # 请求滑块 - req_slide(gt, challenge, w2) + # HAR 中 ajax.php 直接返回 validate。 + result = req_fullpage_validate(gt, challenge, w2, proxies=proxies) - # 获取图片 - result = get_picture(gt, challenge) - - # 检查返回的数据结构 - if len(result) == 6: - bg, fullbg, c, s, slice, challenge_new = result - else: - # 如果返回的数据结构不对,可能是点选验证码,需要重试 - logger.warning(f"返回数据结构异常,可能是点选验证码,重试中...") - # 刷新代理 - self._refresh_proxy() - time.sleep(1) - continue - - # 检查是否是滑块验证码(bg和slice不为空) - if not bg or not slice: - logger.warning(f"未获取到滑块图片,可能是点选验证码,重试中...") - # 刷新代理 - self._refresh_proxy() - time.sleep(1) - continue - - # 识别缺口位置 - hkjl = download_picture(bg, fullbg, slice) - - # 获取第三个w值 - w3 = get_w3(str_16, challenge_new, hkjl, c, s, gt) - - # 最后验证 - result = req_end(gt, challenge_new, w3) - - # 从req_end的响应中提取validate + # 从 fullpage ajax.php 响应中提取 validate if isinstance(result, dict): - validate = result.get('validate', '') - success = result.get('success', 0) - message = result.get('message', '') + data = result.get('data', {}) + validate = data.get('validate', '') or result.get('validate', '') + success = data.get('result') == 'success' or result.get('success') == 1 + message = data.get('result') or result.get('message', '') - if success == 1 and validate: + if success and validate: seccode = f"{validate}|jordan" - logger.success(f"极验滑块验证成功! validate={validate[:20]}...") + logger.success(f"极验 fullpage 验证成功! validate={validate[:20]}...") return validate, seccode else: logger.warning(f"极验验证失败: {message},重试中...") @@ -346,14 +353,15 @@ class DouyuLogin: validate = str(result) if validate: seccode = f"{validate}|jordan" - logger.success(f"极验滑块验证成功! validate={validate[:20]}...") + logger.success(f"极验 fullpage 验证成功! validate={validate[:20]}...") return validate, seccode except Exception as e: logger.warning(f"极验验证异常: {e},重试中...") # 刷新代理 self._refresh_proxy() - time.sleep(1) + # 增加等待时间,避免请求过于频繁 + time.sleep(2) continue raise ValueError(f"极验验证失败,已重试 {self.max_geetest_retries} 次") @@ -363,6 +371,13 @@ class DouyuLogin: """ 第二次登录(带极验验证) + Args: + gt: 极验gt参数 + challenge: 第一次登录返回的challenge + validate: 极验验证返回的validate + seccode: 极验验证返回的seccode + code_token: 第一次登录返回的code_token + Returns: remote_code: 用于邮箱验证的code """ @@ -370,6 +385,7 @@ class DouyuLogin: encrypted_password = encrypt_password(self.account.password) # 参考HAR文件中的完整参数 + # 注意:geetest_challenge应该使用第一次登录返回的challenge data = { 'type': '1', 'nicknameOrPhoneEncrypt': encrypted_username, @@ -386,7 +402,7 @@ class DouyuLogin: 'code_data[geetest_seccode]': seccode, 'code_data[gt_version]': 'v3', 'code_data[code]': '', - 'redirect_url': 'https://www.douyu.com/', + 'redirect_url': self.LOGIN_REFERER, 't': str(int(time.time() * 1000)), 'client_id': '1', 'did': '', @@ -395,6 +411,8 @@ class DouyuLogin: 'biz_type': '1', } + logger.debug(f"第二次登录参数: challenge={challenge[:20]}..., validate={validate[:20]}...") + payload = self._request_json( 'post', self.LOGIN_API, diff --git a/fullbg.jpg b/fullbg.jpg index 9d13d43..962777b 100644 Binary files a/fullbg.jpg and b/fullbg.jpg differ diff --git a/geetest/network.py b/geetest/network.py index fcaa30a..38df9ba 100644 --- a/geetest/network.py +++ b/geetest/network.py @@ -2,9 +2,40 @@ import time import requests import json import re -from typing import Tuple +from typing import Mapping, Optional, Tuple REQUEST_TIMEOUT = (3.05, 12) +PASSPORT_REFERER = "https://passport.douyu.com/" + + +def _get( + url: str, + *, + params: Optional[dict] = None, + headers: Optional[dict] = None, + proxies: Optional[Mapping[str, str]] = None, +) -> requests.Response: + """发送极验 GET 请求,确保使用同一个代理出口。""" + return requests.get( + url, + params=params, + headers=headers, + proxies=dict(proxies or {}), + timeout=REQUEST_TIMEOUT, + ) + + +def _parse_jsonp_response(response: requests.Response, source: str) -> dict: + """解析极验 JSONP 响应。""" + response.raise_for_status() + match = re.search(r'\((.*)\)$', response.text) + if not match: + raise ValueError(f"{source} 无法解析 JSONP 响应") + + data = json.loads(match.group(1)) + if data.get('status') == 'error': + raise ValueError(f"{source} 失败: {data.get('user_error', data.get('error', '未知错误'))}") + return data def _parse_json_response(response: requests.Response, source: str) -> dict: @@ -83,18 +114,18 @@ def get_challenge_gt() -> Tuple[str, str]: -def get_js_address(gt:str) -> dict: +def get_js_address(gt: str, proxies: Optional[Mapping[str, str]] = None) -> dict: headers = { 'accept': '*/*', 'accept-language': 'zh-CN,zh;q=0.9', - 'referer': 'https://demos.geetest.com/', - 'sec-ch-ua': '"Chromium";v="140", "Not=A?Brand";v="24", "Google Chrome";v="140"', + 'referer': PASSPORT_REFERER, + 'sec-ch-ua': '"Google Chrome";v="149", "Chromium";v="149", "Not)A;Brand";v="24"', 'sec-ch-ua-mobile': '?0', - 'sec-ch-ua-platform': '"Windows"', + 'sec-ch-ua-platform': '"macOS"', 'sec-fetch-dest': 'script', 'sec-fetch-mode': 'no-cors', - 'sec-fetch-site': 'same-site', - 'user-agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/140.0.0.0 Safari/537.36', + 'sec-fetch-site': 'cross-site', + 'user-agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/149.0.0.0 Safari/537.36', } params = { @@ -102,62 +133,72 @@ def get_js_address(gt:str) -> dict: 'callback': 'geetest_' + str(int(round(time.time() * 1000))), } - response = requests.get( - 'https://apiv6.geetest.com/gettype.php', + response = _get( + 'https://api.geetest.com/gettype.php', params=params, headers=headers, - timeout=REQUEST_TIMEOUT, + proxies=proxies, ) - response.raise_for_status() - match = re.search(r'\((.*)\)$', response.text) - if match: - json_str = match.group(1) - return json.loads(json_str) - else: - raise ValueError("无法解析 JSONP 响应") + return _parse_jsonp_response(response, "极验 gettype") -def get_c_s(gt:str, challenge:str, w:str) -> Tuple[list[int],str]: +def get_c_s( + gt: str, + challenge: str, + w: str, + proxies: Optional[Mapping[str, str]] = None, +) -> Tuple[list[int], str]: headers = { 'accept': '*/*', 'accept-language': 'zh-CN,zh;q=0.9', - 'referer': 'https://demos.geetest.com/', - 'sec-ch-ua': '"Chromium";v="140", "Not=A?Brand";v="24", "Google Chrome";v="140"', + 'referer': PASSPORT_REFERER, + 'sec-ch-ua': '"Google Chrome";v="149", "Chromium";v="149", "Not)A;Brand";v="24"', 'sec-ch-ua-mobile': '?0', - 'sec-ch-ua-platform': '"Windows"', + 'sec-ch-ua-platform': '"macOS"', 'sec-fetch-dest': 'script', 'sec-fetch-mode': 'no-cors', - 'sec-fetch-site': 'same-site', - 'user-agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/140.0.0.0 Safari/537.36', + 'sec-fetch-site': 'cross-site', + 'user-agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/149.0.0.0 Safari/537.36', } - response = requests.get( + response = _get( 'https://api.geetest.com/get.php?gt=' + gt + '&challenge=' + challenge + '&lang=zh-cn&pt=0&client_type=web&w=' + w + '&callback=geetest_' + str( int(round(time.time() * 1000))), headers=headers, - timeout=REQUEST_TIMEOUT, + proxies=proxies, ) - response.raise_for_status() + data = _parse_jsonp_response(response, "极验 get.php") + return data['data']['c'], data['data']['s'] - # 检查是否是错误响应 - if response.text.startswith('{'): - # 直接JSON响应 - data = json.loads(response.text) - if 'status' in data and data['status'] == 'error': - raise ValueError(f"极验请求失败: {data.get('user_error', data.get('error', '未知错误'))}") - match = re.search(r'\((.*)\)$', response.text) - if match: - json_str = match.group(1) - data = json.loads(json_str) +def req_fullpage_validate( + gt: str, + challenge: str, + w: str, + proxies: Optional[Mapping[str, str]] = None, +) -> dict: + """HAR 中的 fullpage 最终校验,成功后直接返回 validate。""" + headers = { + 'Accept': '*/*', + 'Accept-Language': 'zh-CN,zh;q=0.9', + 'Connection': 'keep-alive', + 'Referer': PASSPORT_REFERER, + 'Sec-Fetch-Dest': 'script', + 'Sec-Fetch-Mode': 'no-cors', + 'Sec-Fetch-Site': 'cross-site', + 'User-Agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/149.0.0.0 Safari/537.36', + 'sec-ch-ua': '"Google Chrome";v="149", "Chromium";v="149", "Not)A;Brand";v="24"', + 'sec-ch-ua-mobile': '?0', + 'sec-ch-ua-platform': '"macOS"', + } - # 检查是否是错误响应 - if 'status' in data and data['status'] == 'error': - raise ValueError(f"极验请求失败: {data.get('user_error', data.get('error', '未知错误'))}") - - return data['data']['c'], data['data']['s'] - else: - raise ValueError("无法解析 JSONP 响应") + response = _get( + 'https://api.geetest.com/ajax.php?gt=' + gt + '&challenge=' + challenge + '&lang=zh-cn&pt=0&client_type=web&w=' + w + '&callback=geetest_' + str( + int(round(time.time() * 1000))), + headers=headers, + proxies=proxies, + ) + return _parse_jsonp_response(response, "极验 fullpage ajax.php") def req_slide(gt:str, challenge:str, w2:str) -> None: headers = { diff --git a/slice.jpg b/slice.jpg index 4b5311b..843ba49 100644 Binary files a/slice.jpg and b/slice.jpg differ