From 47e19ed7b2b14b565cad311af56b2f99223051e0 Mon Sep 17 00:00:00 2001 From: yml2213 Date: Sun, 30 Aug 2026 21:04:52 +0800 Subject: [PATCH] =?UTF-8?q?style:=20=E7=BB=9F=E4=B8=80=20Ruff=20=E4=BB=A3?= =?UTF-8?q?=E7=A0=81=E6=A0=BC=E5=BC=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- core/douyu/__init__.py | 7 +- core/douyu/cookie_enricher.py | 70 +- core/douyu/crypto.py | 18 +- core/douyu/login_api.py | 38 +- core/douyu/login_api_iframe.py | 86 +- core/douyu/login_api_wgapi.py | 86 +- core/douyu/proxy_parser.py | 8 +- core/douyu/proxy_platforms/xiequ.py | 15 +- core/douyu/proxy_platforms/xkdaili.py | 1 + core/douyu/proxy_resolver.py | 71 +- core/douyu/proxy_verifier.py | 32 +- core/douyu/recharge_api.py | 81 +- core/geetest/common/crypto.py | 245 ++- core/geetest/common/imaging.py | 68 +- core/geetest/common/network.py | 316 +-- core/geetest/common/performance.py | 89 +- core/geetest/v3_slide/solver.py | 110 +- core/huya/account_env.py | 42 +- core/huya/activity_structs.py | 8 +- core/huya/auto_register.py | 18 +- core/huya/cert_forge.py | 3 +- core/huya/device_fingerprint.py | 18 +- core/huya/device_profile.py | 14 +- core/huya/nonce_forge.py | 23 +- core/huya/shop_structs.py | 265 +-- core/huya/taf_protocol.py | 121 +- core/huya/udb_aes.py | 1 + core/huya/wup_encoder.py | 50 +- core/huya/wup_protocol.py | 46 +- core/sms_provider.py | 50 +- docs/Frida绕过配方.md | 9 +- scripts/migrate_sqlite_to_mysql.py | 44 +- services/yyb-worker/runtime/main.py | 291 ++- services/yyb-worker/runtime/pyvm/__init__.py | 1 + services/yyb-worker/runtime/pyvm/algorithm.py | 1792 +++++++++++------ services/yyb-worker/runtime/pyvm/goods.py | 66 +- .../yyb-worker/runtime/pyvm/login_profile.py | 1 + services/yyb-worker/runtime/pyvm/mall.py | 62 +- .../yyb-worker/runtime/pyvm/order_status.py | 5 +- .../yyb-worker/runtime/pyvm/pagedoo_vm.py | 864 ++++++-- .../yyb-worker/runtime/pyvm/payment_errors.py | 1 + services/yyb-worker/runtime/pyvm/protocol.py | 36 +- services/yyb-worker/runtime/pyvm/session.py | 5 +- .../yyb-worker/runtime/scripts/jsdom-pay.py | 332 ++- .../yyb-worker/runtime/scripts/login-qq.py | 108 +- .../runtime/scripts/login-wechat.py | 45 +- .../runtime/scripts/select-peace-elite.py | 186 +- .../yyb-worker/runtime/scripts/yyb-worker.py | 355 +++- .../runtime/tests/test_jsdom_pay.py | 145 +- utils/logger.py | 32 +- web/backend/crypto_storage.py | 26 +- web/backend/database.py | 10 +- web/backend/main.py | 26 +- .../20260623_0001_initial_web_schema.py | 30 +- .../20260624_0002_sensitive_columns_text.py | 56 +- .../versions/20260624_0003_proxy_platform.py | 4 +- .../versions/20260704_0004_huya_foundation.py | 20 +- .../20260704_0005_huya_recharge_goods.py | 14 +- .../20260712_0007_huya_register_history.py | 52 +- .../20260724_0008_douyu_activity_tasks.py | 61 +- ...60725_0009_database_performance_indexes.py | 16 +- ...0728_0012_douyu_esports_can_change_time.py | 5 +- .../20260807_0017_douyu_xpd_fragments.py | 4 +- ...60808_0019_dedupe_douyu_success_cookies.py | 6 +- .../versions/20260812_0020_yyb_recharge.py | 16 +- .../20260812_0021_yyb_qr_mediumtext.py | 8 +- .../20260812_0022_yyb_payment_meta.py | 19 +- .../20260813_0023_soft_delete_users.py | 4 +- .../20260813_0024_douyu_workbench_scopes.py | 58 +- ...260813_0025_douyu_workbench_empty_state.py | 4 +- ...260813_0026_douyu_gold_recharge_channel.py | 36 +- .../20260813_0027_douyu_supplier_order_id.py | 5 +- .../20260829_0030_huya_login_channel.py | 11 +- .../20260830_0031_mysql_task_query_indexes.py | 12 +- web/backend/routers/audit.py | 25 +- web/backend/routers/auth.py | 19 +- web/backend/routers/dashboard.py | 54 +- web/backend/routers/login.py | 32 +- web/backend/routers/proxy.py | 22 +- web/backend/routers/users.py | 101 +- web/backend/routers/yyb.py | 217 +- web/backend/services/account_service.py | 52 +- web/backend/services/audit_service.py | 17 +- web/backend/services/huya_register_runner.py | 181 +- web/backend/services/huya_runner.py | 95 +- web/backend/services/huya_runner_core.py | 6 +- web/backend/services/huya_service.py | 51 +- web/backend/services/proxy_service.py | 45 +- web/backend/services/yyb_service.py | 80 +- web/backend/services/yyb_worker_client.py | 44 +- 90 files changed, 5574 insertions(+), 2350 deletions(-) diff --git a/core/douyu/__init__.py b/core/douyu/__init__.py index c23dfd8..c9180aa 100644 --- a/core/douyu/__init__.py +++ b/core/douyu/__init__.py @@ -6,7 +6,12 @@ from .login_api_wgapi import WgapiLoginAPI from .login_api_iframe import IframeLoginAPI from .email_verifier import EmailVerifier from .activity_client import DouyuActivityClient, DouyuActivityError -from .recharge_api import FishFinRechargeClient, FishFinRechargeConfig, FishFinRechargeConfigError, FishFinRechargeError +from .recharge_api import ( + FishFinRechargeClient, + FishFinRechargeConfig, + FishFinRechargeConfigError, + FishFinRechargeError, +) __all__ = [ "DouyuLogin", diff --git a/core/douyu/cookie_enricher.py b/core/douyu/cookie_enricher.py index 1006229..efaeb2e 100644 --- a/core/douyu/cookie_enricher.py +++ b/core/douyu/cookie_enricher.py @@ -15,7 +15,9 @@ class CookieEnricher: CSRF_API = "https://www.douyu.com/japi/carnival/nc/common/generateCsrf" CSRF_REFERER = "https://www.douyu.com/pages/live-peace-handbook/web/shop?ditchname=pass0&roomId=9263298" ACF_CCN_API = "https://www.douyu.com/curl/csrfNlApi/getCsrfCookie" - ACF_CCN_REFERER = "https://www.douyu.com/pages/ord-task-center?clientType=web&panelSource=1&rid=0" + ACF_CCN_REFERER = ( + "https://www.douyu.com/pages/ord-task-center?clientType=web&panelSource=1&rid=0" + ) TIMEOUT = (5, 10) RETRIES = 3 @@ -47,25 +49,27 @@ class CookieEnricher: logger.warning(f"补CK失败 {attempt}/{max_attempts}: {e}") if attempt < max_attempts: self.sleep_interruptible(1) - raise ValueError(f"已重试 {max_attempts} 次仍未补齐CK: {last_error}") from last_error + raise ValueError( + f"已重试 {max_attempts} 次仍未补齐CK: {last_error}" + ) from last_error def generate_csrf_cookie(self) -> str: """访问 generateCsrf 接口,从 Set-Cookie 中同步 cvl_csrf_token。""" logger.info("补齐CSRF Cookie...") headers = { - 'Accept': 'application/json, text/plain, */*', - 'Accept-Language': 'zh-CN,zh;q=0.9,en-US;q=0.6,en;q=0.5', - 'Origin': 'https://www.douyu.com', - 'Referer': self.CSRF_REFERER, - 'Sec-Fetch-Dest': 'empty', - 'Sec-Fetch-Mode': 'cors', - 'Sec-Fetch-Site': 'same-origin', + "Accept": "application/json, text/plain, */*", + "Accept-Language": "zh-CN,zh;q=0.9,en-US;q=0.6,en;q=0.5", + "Origin": "https://www.douyu.com", + "Referer": self.CSRF_REFERER, + "Sec-Fetch-Dest": "empty", + "Sec-Fetch-Mode": "cors", + "Sec-Fetch-Site": "same-origin", # 覆盖登录接口的默认表单头,尽量贴近浏览器抓包。 - 'Content-Type': None, - 'X-Requested-With': None, + "Content-Type": None, + "X-Requested-With": None, } response = self.request( - 'post', + "post", self.CSRF_API, headers=headers, timeout=self.TIMEOUT, @@ -82,11 +86,11 @@ class CookieEnricher: preview = body[:200].replace("\n", "\\n") raise ValueError(f"生成CSRF失败: 响应不是有效 JSON: {preview}") from exc - if payload.get('error') != 0: + if payload.get("error") != 0: raise ValueError(f"生成CSRF失败: {payload.get('msg', '未知错误')}") cookies = self.session.cookies.get_dict() - csrf_token = cookies.get('cvl_csrf_token', '') + csrf_token = cookies.get("cvl_csrf_token", "") if not csrf_token: raise ValueError("生成CSRF失败: 响应没有 cvl_csrf_token") @@ -97,25 +101,25 @@ class CookieEnricher: """访问 getCsrfCookie 接口,从 Set-Cookie 中同步 acf_ccn。""" logger.info("补齐 acf_ccn Cookie...") headers = { - 'Accept': 'application/json, text/plain, */*', - 'Accept-Language': 'zh-CN,zh;q=0.9', - 'Cache-Control': 'no-cache', - 'Pragma': 'no-cache', - 'Priority': 'u=1, i', - 'Referer': self.ACF_CCN_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': '"macOS"', - 'Sec-Fetch-Dest': 'empty', - 'Sec-Fetch-Mode': 'cors', - 'Sec-Fetch-Site': 'same-origin', + "Accept": "application/json, text/plain, */*", + "Accept-Language": "zh-CN,zh;q=0.9", + "Cache-Control": "no-cache", + "Pragma": "no-cache", + "Priority": "u=1, i", + "Referer": self.ACF_CCN_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": '"macOS"', + "Sec-Fetch-Dest": "empty", + "Sec-Fetch-Mode": "cors", + "Sec-Fetch-Site": "same-origin", # 该接口抓包没有 Origin、表单 Content-Type 和 X-Requested-With。 - 'Origin': None, - 'Content-Type': None, - 'X-Requested-With': None, + "Origin": None, + "Content-Type": None, + "X-Requested-With": None, } response = self.request( - 'get', + "get", self.ACF_CCN_API, headers=headers, timeout=self.TIMEOUT, @@ -123,10 +127,10 @@ class CookieEnricher: response.raise_for_status() cookies = self.session.cookies.get_dict() - acf_ccn = cookies.get('acf_ccn', '') or response.cookies.get('acf_ccn', '') - if acf_ccn and not cookies.get('acf_ccn'): + acf_ccn = cookies.get("acf_ccn", "") or response.cookies.get("acf_ccn", "") + if acf_ccn and not cookies.get("acf_ccn"): # 极少数情况下响应 Cookie 未合入 get_dict,手动补到斗鱼域名下。 - self.session.cookies.set('acf_ccn', acf_ccn, domain='.douyu.com', path='/') + self.session.cookies.set("acf_ccn", acf_ccn, domain=".douyu.com", path="/") if not acf_ccn: raise ValueError("补齐 acf_ccn 失败: 响应没有 acf_ccn") diff --git a/core/douyu/crypto.py b/core/douyu/crypto.py index e03dea4..c4c0e92 100644 --- a/core/douyu/crypto.py +++ b/core/douyu/crypto.py @@ -25,21 +25,21 @@ DOUYU_RC4_KEY = "7TkbRSEWvVWebXbr" def md5(text: str) -> str: """MD5加密""" - return hashlib.md5(text.encode('utf-8')).hexdigest() + return hashlib.md5(text.encode("utf-8")).hexdigest() def rsa_encrypt(text: str, public_key: str = DOUYU_RSA_PUBLIC_KEY) -> str: """RSA加密""" key = RSA.import_key(public_key) cipher = PKCS1_v1_5.new(key) - encrypted = cipher.encrypt(text.encode('utf-8')) - return base64.b64encode(encrypted).decode('utf-8') + encrypted = cipher.encrypt(text.encode("utf-8")) + return base64.b64encode(encrypted).decode("utf-8") def aes_encrypt(text: str, key: str = DOUYU_AES_KEY) -> str: """AES加密""" - key_bytes = key.encode('utf-8') - text_bytes = text.encode('utf-8') + key_bytes = key.encode("utf-8") + text_bytes = text.encode("utf-8") # 填充到16的倍数 padding_len = 16 - (len(text_bytes) % 16) @@ -47,7 +47,7 @@ def aes_encrypt(text: str, key: str = DOUYU_AES_KEY) -> str: cipher = AES.new(key_bytes, AES.MODE_ECB) encrypted = cipher.encrypt(text_bytes) - return base64.b64encode(encrypted).decode('utf-8') + return base64.b64encode(encrypted).decode("utf-8") def encrypt_username(username: str) -> str: @@ -62,7 +62,7 @@ def encrypt_password(password: str) -> str: def encrypt_nickname_or_phone(text: str) -> str: """加密昵称或手机号""" - key = DOUYU_RC4_KEY.encode('utf-8') + 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') + encrypted = cipher.encrypt(text.encode("utf-8")) + return base64.b64encode(encrypted).decode("utf-8") diff --git a/core/douyu/login_api.py b/core/douyu/login_api.py index 8a64259..b1ef525 100644 --- a/core/douyu/login_api.py +++ b/core/douyu/login_api.py @@ -47,7 +47,9 @@ class LoginAPIStrategy(ABC): ... @abstractmethod - def build_first_login_data(self, username: str, password: str, referer: str) -> dict: + def build_first_login_data( + self, username: str, password: str, referer: str + ) -> dict: """构建第一次登录请求参数(获取极验参数)""" ... @@ -85,11 +87,11 @@ class LoginAPIStrategy(ABC): Returns: (gt, challenge, code_token) """ - geetest_data = payload.get('data', {}).get('geetest', {}) - code_data = geetest_data.get('code_data', {}) - gt = code_data.get('gt', '') - challenge = code_data.get('challenge', '') - code_token = geetest_data.get('code_token', '') + geetest_data = payload.get("data", {}).get("geetest", {}) + code_data = geetest_data.get("code_data", {}) + gt = code_data.get("gt", "") + challenge = code_data.get("challenge", "") + code_token = geetest_data.get("code_token", "") return gt, challenge, code_token def extract_remote_code(self, payload: dict) -> str: @@ -101,7 +103,7 @@ class LoginAPIStrategy(ABC): Returns: remote_code 字符串 """ - return payload.get('data', {}).get('remoteLogin', {}).get('code', '') + return payload.get("data", {}).get("remoteLogin", {}).get("code", "") def extract_login_url(self, payload: dict) -> str: """从验证码响应提取登录 URL @@ -112,27 +114,27 @@ class LoginAPIStrategy(ABC): Returns: 登录回调 URL """ - data = payload.get('data', {}) + data = payload.get("data", {}) if not isinstance(data, dict): - return '' + return "" # `remoteLogin` 与“跳过绑定手机号”完成后都会返回同类回调地址, # 但不同版本接口的字段命名不完全一致。 - for key in ('url', 'loginUrl', 'login_url', 'redirectUrl', 'redirect_url'): + for key in ("url", "loginUrl", "login_url", "redirectUrl", "redirect_url"): value = data.get(key) if isinstance(value, str) and value: return value - return '' + return "" def extract_mobile_bind_unique_key(self, payload: dict) -> str: """提取服务端要求绑定手机号时返回的一次性继续登录标识。""" - data = payload.get('data', {}) + data = payload.get("data", {}) if not isinstance(data, dict): - return '' + return "" def find_unique_key(value: object) -> str: if isinstance(value, dict): - unique_key = value.get('uniqueKey') or value.get('unique_key') + unique_key = value.get("uniqueKey") or value.get("unique_key") if isinstance(unique_key, str) and unique_key: return unique_key for child in value.values(): @@ -144,14 +146,14 @@ class LoginAPIStrategy(ABC): result = find_unique_key(child) if result: return result - return '' + return "" return find_unique_key(data) def build_skip_mobile_bind_data(self, unique_key: str) -> dict: """构建跳过手机号绑定、继续完成网页登录的请求参数。""" return { - 'type': '3', - 'uniqueKey': unique_key, - 'biz_type': '1', + "type": "3", + "uniqueKey": unique_key, + "biz_type": "1", } diff --git a/core/douyu/login_api_iframe.py b/core/douyu/login_api_iframe.py index ae21ca2..4e8dbb1 100644 --- a/core/douyu/login_api_iframe.py +++ b/core/douyu/login_api_iframe.py @@ -42,9 +42,11 @@ class IframeLoginAPI(LoginAPIStrategy): def encrypt_username(self, username: str) -> str: """RC4 加密后 URL 编码""" encrypted = encrypt_nickname_or_phone(username) - return urllib.parse.quote(encrypted, safe='') + return urllib.parse.quote(encrypted, safe="") - def build_first_login_data(self, username: str, password: str, referer: str) -> dict: + def build_first_login_data( + self, username: str, password: str, referer: str + ) -> dict: """构建第一次登录参数(获取极验参数) 注:第一次登录使用 wgapi 接口,所以用户名用 base64 编码(而非 URL 编码)。 @@ -52,17 +54,17 @@ class IframeLoginAPI(LoginAPIStrategy): # 第一次登录用 wgapi 接口,需要 base64 编码的用户名 encrypted_username = encrypt_nickname_or_phone(username) return { - 'type': '1', - 'nicknameOrPhoneEncrypt': encrypted_username, - 'password': encrypt_password(password), - 'biz_type': '1', - 'room_id': '0', - 'redirect_url': referer, - 't': str(int(time.time() * 1000)), - 'client_id': '1', - 'did': '', - 'lang': '', - 'isMultiAccount': '0', + "type": "1", + "nicknameOrPhoneEncrypt": encrypted_username, + "password": encrypt_password(password), + "biz_type": "1", + "room_id": "0", + "redirect_url": referer, + "t": str(int(time.time() * 1000)), + "client_id": "1", + "did": "", + "lang": "", + "isMultiAccount": "0", } def build_second_login_data( @@ -81,46 +83,46 @@ class IframeLoginAPI(LoginAPIStrategy): 登录使用 wgapi 接口,参数格式与 wgapi 策略一致。 """ # seccode 格式: validate|jordan(requests 会自动 URL 编码) - seccode_value = validate + '|jordan' + seccode_value = validate + "|jordan" # 使用 base64 编码(wgapi 接口要求),而不是 URL 编码 encrypted_username = encrypt_nickname_or_phone(username) return { - 'type': '1', - 'nicknameOrPhoneEncrypt': encrypted_username, - 'password': encrypt_password(password), - 'room_id': '0', - 'code_type': '1', - 'code_token': code_token, - 'gt_version': 'v3', - 'geetest_challenge': challenge, - 'geetest_validate': validate, - 'geetest_seccode': seccode_value, - 'code_data[geetest_challenge]': challenge, - 'code_data[geetest_validate]': validate, - 'code_data[geetest_seccode]': seccode_value, - 'code_data[gt_version]': 'v3', - 'code_data[code]': '', - 'redirect_url': referer, - 't': str(int(time.time() * 1000)), - 'client_id': '1', - 'did': '', - 'lang': '', - 'isMultiAccount': '0', - 'biz_type': '1', + "type": "1", + "nicknameOrPhoneEncrypt": encrypted_username, + "password": encrypt_password(password), + "room_id": "0", + "code_type": "1", + "code_token": code_token, + "gt_version": "v3", + "geetest_challenge": challenge, + "geetest_validate": validate, + "geetest_seccode": seccode_value, + "code_data[geetest_challenge]": challenge, + "code_data[geetest_validate]": validate, + "code_data[geetest_seccode]": seccode_value, + "code_data[gt_version]": "v3", + "code_data[code]": "", + "redirect_url": referer, + "t": str(int(time.time() * 1000)), + "client_id": "1", + "did": "", + "lang": "", + "isMultiAccount": "0", + "biz_type": "1", } def build_send_email_data(self, code: str) -> dict: """构建发送邮箱验证参数""" - return {'cacheId': code} + return {"cacheId": code} def build_verify_data(self, code: str, captcha: str) -> dict: """构建提交验证码参数""" return { - 'veify_type': '2', # e 语言原文如此,斗鱼接口拼写 - 'phoneCaptcha': captcha, - 'client_id': '1', - 'isMultiAccount': '0', - 'cacheId': code, + "veify_type": "2", # e 语言原文如此,斗鱼接口拼写 + "phoneCaptcha": captcha, + "client_id": "1", + "isMultiAccount": "0", + "cacheId": code, } diff --git a/core/douyu/login_api_wgapi.py b/core/douyu/login_api_wgapi.py index dd8e928..a281c2c 100644 --- a/core/douyu/login_api_wgapi.py +++ b/core/douyu/login_api_wgapi.py @@ -36,20 +36,22 @@ class WgapiLoginAPI(LoginAPIStrategy): """RC4 加密后 base64 编码""" return encrypt_nickname_or_phone(username) - def build_first_login_data(self, username: str, password: str, referer: str) -> dict: + def build_first_login_data( + self, username: str, password: str, referer: str + ) -> dict: """构建第一次登录参数""" return { - 'type': '1', - 'nicknameOrPhoneEncrypt': self.encrypt_username(username), - 'password': encrypt_password(password), - 'biz_type': '1', - 'room_id': '0', - 'redirect_url': referer, - 't': str(int(time.time() * 1000)), - 'client_id': '1', - 'did': '', - 'lang': '', - 'isMultiAccount': '0', + "type": "1", + "nicknameOrPhoneEncrypt": self.encrypt_username(username), + "password": encrypt_password(password), + "biz_type": "1", + "room_id": "0", + "redirect_url": referer, + "t": str(int(time.time() * 1000)), + "client_id": "1", + "did": "", + "lang": "", + "isMultiAccount": "0", } def build_second_login_data( @@ -65,44 +67,44 @@ class WgapiLoginAPI(LoginAPIStrategy): ) -> dict: """构建第二次登录参数(带极验验证)""" return { - 'type': '1', - 'nicknameOrPhoneEncrypt': self.encrypt_username(username), - 'password': encrypt_password(password), - 'room_id': '0', - 'code_type': '1', - 'code_token': code_token, - 'gt_version': 'v3', - 'geetest_challenge': challenge, - 'geetest_validate': validate, - 'geetest_seccode': seccode, - 'code_data[geetest_challenge]': challenge, - 'code_data[geetest_validate]': validate, - 'code_data[geetest_seccode]': seccode, - 'code_data[gt_version]': 'v3', - 'code_data[code]': '', - 'redirect_url': referer, - 't': str(int(time.time() * 1000)), - 'client_id': '1', - 'did': '', - 'lang': '', - 'isMultiAccount': '0', - 'biz_type': '1', + "type": "1", + "nicknameOrPhoneEncrypt": self.encrypt_username(username), + "password": encrypt_password(password), + "room_id": "0", + "code_type": "1", + "code_token": code_token, + "gt_version": "v3", + "geetest_challenge": challenge, + "geetest_validate": validate, + "geetest_seccode": seccode, + "code_data[geetest_challenge]": challenge, + "code_data[geetest_validate]": validate, + "code_data[geetest_seccode]": seccode, + "code_data[gt_version]": "v3", + "code_data[code]": "", + "redirect_url": referer, + "t": str(int(time.time() * 1000)), + "client_id": "1", + "did": "", + "lang": "", + "isMultiAccount": "0", + "biz_type": "1", } def build_send_email_data(self, code: str) -> dict: """构建发送邮箱验证参数""" return { - 'code': code, - 'client_id': '1', + "code": code, + "client_id": "1", } def build_verify_data(self, code: str, captcha: str) -> dict: """构建提交验证码参数""" return { - 'verify_type': '2', - 'captcha': captcha, - 'isMultiAccount': '0', - 'code': code, - 'client_id': '1', - 'redirect_url': '//www.douyu.com/api/passport/login', + "verify_type": "2", + "captcha": captcha, + "isMultiAccount": "0", + "code": code, + "client_id": "1", + "redirect_url": "//www.douyu.com/api/passport/login", } diff --git a/core/douyu/proxy_parser.py b/core/douyu/proxy_parser.py index 7dd901d..d490c95 100644 --- a/core/douyu/proxy_parser.py +++ b/core/douyu/proxy_parser.py @@ -24,7 +24,7 @@ def parse_proxy_response(text: str) -> tuple[list[str], Optional[str]]: code = data.get("code") msg = data.get("msg", "") or "" if code != 0 and ("白名单" in msg or "添加白名单" in msg): - ip_match = re.search(r'(\d+\.\d+\.\d+\.\d+)', msg) + ip_match = re.search(r"(\d+\.\d+\.\d+\.\d+)", msg) if ip_match: return [], ip_match.group(1) @@ -46,12 +46,12 @@ def parse_proxy_response(text: str) -> tuple[list[str], Optional[str]]: except (json.JSONDecodeError, ValueError): pass - if '添加白名单' in text or '白名单' in text: - ip_match = re.search(r'(\d+\.\d+\.\d+\.\d+)', text) + if "添加白名单" in text or "白名单" in text: + ip_match = re.search(r"(\d+\.\d+\.\d+\.\d+)", text) if ip_match: return [], ip_match.group(1) - matches = re.findall(r'(\d+\.\d+\.\d+\.\d+):(\d+)', text) + matches = re.findall(r"(\d+\.\d+\.\d+\.\d+):(\d+)", text) proxies = [f"http://{ip}:{port}" for ip, port in matches] if proxies: return proxies, None diff --git a/core/douyu/proxy_platforms/xiequ.py b/core/douyu/proxy_platforms/xiequ.py index 1734e11..6d09be0 100644 --- a/core/douyu/proxy_platforms/xiequ.py +++ b/core/douyu/proxy_platforms/xiequ.py @@ -67,7 +67,10 @@ class XiequAdapter(BaseWhitelistAdapter): # 映射到统一格式 return [ - {"ip": r.get("IP", r.get("ip", "")), "memo": r.get("MEMO", r.get("memo", ""))} + { + "ip": r.get("IP", r.get("ip", "")), + "memo": r.get("MEMO", r.get("memo", "")), + } for r in data if r.get("IP") or r.get("ip") ] @@ -97,7 +100,7 @@ class XiequAdapter(BaseWhitelistAdapter): # 频率限制,等待后重试一次 if retry and ("频率过快" in text or "稍后" in text): wait = 5 - match = re.search(r'(\d+)\s*秒', text) + match = re.search(r"(\d+)\s*秒", text) if match: wait = int(match.group(1)) logger.info(f"白名单添加被限流,等待 {wait} 秒后重试...") @@ -147,9 +150,13 @@ class XiequAdapter(BaseWhitelistAdapter): try: records = self.get_whitelist() count = len(records) - my_records = [r for r in records if r.get("memo", "").startswith(self.MEMO_PREFIX)] + my_records = [ + r for r in records if r.get("memo", "").startswith(self.MEMO_PREFIX) + ] - msg = f"连接成功,白名单共 {count} 条记录,其中本机相关 {len(my_records)} 条" + msg = ( + f"连接成功,白名单共 {count} 条记录,其中本机相关 {len(my_records)} 条" + ) logger.info(msg) return True, msg diff --git a/core/douyu/proxy_platforms/xkdaili.py b/core/douyu/proxy_platforms/xkdaili.py index 958941b..c5017c4 100644 --- a/core/douyu/proxy_platforms/xkdaili.py +++ b/core/douyu/proxy_platforms/xkdaili.py @@ -51,6 +51,7 @@ class XkdailiAdapter(BaseWhitelistAdapter): } base_params.update(params) from urllib.parse import urlencode + return f"{self.BASE_URL}?{urlencode(base_params)}" def _parse_response(self, text: str) -> tuple[bool, str]: diff --git a/core/douyu/proxy_resolver.py b/core/douyu/proxy_resolver.py index 2249dbb..131c9fc 100644 --- a/core/douyu/proxy_resolver.py +++ b/core/douyu/proxy_resolver.py @@ -17,11 +17,9 @@ LogFunc = Callable[[str, str], None] class WhitelistSyncer(Protocol): """代理解析流程需要的白名单能力。""" - def sync_ip(self, ip: str) -> tuple[bool, str]: - ... + def sync_ip(self, ip: str) -> tuple[bool, str]: ... - def get_local_exit_ip(self) -> Optional[str]: - ... + def get_local_exit_ip(self) -> Optional[str]: ... class ProxyResolver: @@ -62,14 +60,16 @@ class ProxyResolver: return log_method = getattr( logger, - level if level in ('debug', 'info', 'warning', 'error', 'success') else 'info', + level + if level in ("debug", "info", "warning", "error", "success") + else "info", logger.info, ) log_method(message) def _sync_ip(self, ip: str) -> tuple[bool, str]: if not self.whitelist_syncer: - return False, '未配置白名单 UID/UKEY' + return False, "未配置白名单 UID/UKEY" ok, sync_msg = self.whitelist_syncer.sync_ip(ip) if ok: self._last_synced_ip = ip @@ -82,14 +82,14 @@ class ProxyResolver: local_ip = self.whitelist_syncer.get_local_exit_ip() if local_ip and local_ip != self._last_synced_ip: - self._log('info', f"[尝试 {attempt}] 检测出口IP: {local_ip},同步白名单...") + self._log("info", f"[尝试 {attempt}] 检测出口IP: {local_ip},同步白名单...") ok, sync_msg = self._sync_ip(local_ip) if ok: - self._log('info', f"白名单同步成功: {sync_msg}") + self._log("info", f"白名单同步成功: {sync_msg}") else: - self._log('warning', f"白名单同步失败: {sync_msg}") + self._log("warning", f"白名单同步失败: {sync_msg}") elif local_ip == self._last_synced_ip: - self._log('debug', f"[尝试 {attempt}] 出口IP未变: {local_ip}") + self._log("debug", f"[尝试 {attempt}] 出口IP未变: {local_ip}") def fetch_verified( self, @@ -107,18 +107,18 @@ class ProxyResolver: for attempt in range(1, max_attempts + 1): if self._is_stopped(): - return None, '任务已停止' + return None, "任务已停止" if attempt > 1: delay = min(attempt - 1, 2) if self.log_func: - self._log('info', f'等待 {delay}s 后重试...') + self._log("info", f"等待 {delay}s 后重试...") if self._wait_or_stopped(delay): - return None, '任务已停止' + return None, "任务已停止" self._sync_local_exit_ip_if_needed(attempt) if self._is_stopped(): - return None, '任务已停止' - self._log('info', f'代理预检 {attempt}/{max_attempts}: 正在获取代理') + return None, "任务已停止" + self._log("info", f"代理预检 {attempt}/{max_attempts}: 正在获取代理") try: response = requests.get(self.api_url, timeout=10) @@ -128,38 +128,49 @@ class ProxyResolver: proxy_urls, whitelist_ip = parse_proxy_response(text) if proxy_urls: - self._log('info', f'获取到 {len(proxy_urls)} 个代理,并发验证') - available, msg = verify_proxies_concurrent(proxy_urls, return_all=return_all) + self._log("info", f"获取到 {len(proxy_urls)} 个代理,并发验证") + available, msg = verify_proxies_concurrent( + proxy_urls, return_all=return_all + ) if available: if not return_all: - self._log('success', f'代理预检成功: {available}') + self._log("success", f"代理预检成功: {available}") return available, msg last_error = msg - self._log('warning', f"代理预检 {attempt}/{max_attempts}: {last_error}") + self._log( + "warning", f"代理预检 {attempt}/{max_attempts}: {last_error}" + ) continue if whitelist_ip and self.whitelist_syncer: if self.sync_whitelist_once and self._has_synced_whitelist: - last_error = f'白名单已同步但代理API仍返回白名单错误: {whitelist_ip}' - self._log('warning', last_error) + last_error = ( + f"白名单已同步但代理API仍返回白名单错误: {whitelist_ip}" + ) + self._log("warning", last_error) continue - self._log('warning', f'代理需要白名单IP: {whitelist_ip},自动同步...') + self._log( + "warning", f"代理需要白名单IP: {whitelist_ip},自动同步..." + ) ok, sync_msg = self._sync_ip(whitelist_ip) - self._log('success' if ok else 'error', f'白名单同步: {sync_msg}') + self._log("success" if ok else "error", f"白名单同步: {sync_msg}") if ok: - self._log('info', '白名单已更新,立即重试...') + self._log("info", "白名单已更新,立即重试...") continue - return None, f'白名单同步失败: {sync_msg}' + return None, f"白名单同步失败: {sync_msg}" - last_error = '代理API响应无法解析' - self._log('warning', f"代理预检 {attempt}/{max_attempts}: {last_error}: {text[:80]}") + last_error = "代理API响应无法解析" + self._log( + "warning", + f"代理预检 {attempt}/{max_attempts}: {last_error}: {text[:80]}", + ) except Exception as exc: - last_error = f'代理API请求失败: {exc}' - self._log('warning', f"代理预检 {attempt}/{max_attempts}: {last_error}") + last_error = f"代理API请求失败: {exc}" + self._log("warning", f"代理预检 {attempt}/{max_attempts}: {last_error}") - return None, f'代理预检失败({max_attempts}次尝试均失败): {last_error}' + return None, f"代理预检失败({max_attempts}次尝试均失败): {last_error}" def resolve_working_proxy( diff --git a/core/douyu/proxy_verifier.py b/core/douyu/proxy_verifier.py index 28180c9..d9d5f64 100644 --- a/core/douyu/proxy_verifier.py +++ b/core/douyu/proxy_verifier.py @@ -16,24 +16,24 @@ def verify_proxy_url(proxy_url: str, timeout: tuple = (3, 5)) -> tuple[bool, str Returns: (是否可用, 消息) """ - proxies = {'http': proxy_url, 'https': proxy_url} + proxies = {"http": proxy_url, "https": proxy_url} # ── 斗鱼主站可达性验证 ── try: response = requests.get( - 'https://www.douyu.com', + "https://www.douyu.com", proxies=proxies, timeout=timeout, - headers={'User-Agent': 'Mozilla/5.0'}, + headers={"User-Agent": "Mozilla/5.0"}, ) response.raise_for_status() - return True, '代理可用 → 斗鱼可达' + return True, "代理可用 → 斗鱼可达" except Exception as exc: err_msg = str(exc) - if 'Tunnel connection failed' in err_msg or '503' in err_msg: - detail = '代理拒绝连接(白名单可能未生效)' - elif 'timed out' in err_msg.lower(): - detail = '连接超时' + if "Tunnel connection failed" in err_msg or "503" in err_msg: + detail = "代理拒绝连接(白名单可能未生效)" + elif "timed out" in err_msg.lower(): + detail = "连接超时" else: detail = type(exc).__name__ logger.debug(f"代理验证失败 [{proxy_url}]: 斗鱼主站不可达: {detail}") @@ -54,7 +54,7 @@ def verify_proxies_concurrent( return_all=True: (可用代理 URL 列表或 None, 消息) """ if not proxy_urls: - return None, '无代理可验证' + return None, "无代理可验证" if len(proxy_urls) == 1: ok, msg = verify_proxy_url(proxy_urls[0], timeout) @@ -64,7 +64,9 @@ def verify_proxies_concurrent( if return_all: available: list[str] = [] - with ThreadPoolExecutor(max_workers=min(max_workers, len(proxy_urls))) as executor: + with ThreadPoolExecutor( + max_workers=min(max_workers, len(proxy_urls)) + ) as executor: future_map = { executor.submit(verify_proxy_url, proxy, timeout): proxy for proxy in proxy_urls @@ -77,9 +79,11 @@ def verify_proxies_concurrent( except Exception: continue if available: - logger.success(f"并发验证找到 {len(available)}/{len(proxy_urls)} 个可用代理") - return available, f'找到 {len(available)} 个可用代理' - return None, f'共 {len(proxy_urls)} 个代理均不可用' + logger.success( + f"并发验证找到 {len(available)}/{len(proxy_urls)} 个可用代理" + ) + return available, f"找到 {len(available)} 个可用代理" + return None, f"共 {len(proxy_urls)} 个代理均不可用" with ThreadPoolExecutor(max_workers=min(max_workers, len(proxy_urls))) as executor: future_map = { @@ -99,4 +103,4 @@ def verify_proxies_concurrent( except Exception: continue - return None, f'共 {len(proxy_urls)} 个代理均不可用' + return None, f"共 {len(proxy_urls)} 个代理均不可用" diff --git a/core/douyu/recharge_api.py b/core/douyu/recharge_api.py index d30798a..bafce9a 100644 --- a/core/douyu/recharge_api.py +++ b/core/douyu/recharge_api.py @@ -44,7 +44,8 @@ class FishFinRechargeConfig: app_key=os.getenv("FISH_FIN_RECHARGE_APP_KEY", "").strip(), notify_url=os.getenv("FISH_FIN_RECHARGE_NOTIFY_URL", "").strip(), timeout=(8, timeout), - debug=os.getenv("FISH_FIN_RECHARGE_DEBUG", "false").strip().lower() in {"1", "true", "yes"}, + debug=os.getenv("FISH_FIN_RECHARGE_DEBUG", "false").strip().lower() + in {"1", "true", "yes"}, ) def validate(self) -> None: @@ -86,7 +87,9 @@ class FishFinRechargeClient: def _sign_value(value: Any) -> str: """将参数转为待签名文本;对象按稳定紧凑 JSON 表示。""" if isinstance(value, (dict, list, tuple)): - return json.dumps(value, ensure_ascii=False, separators=(",", ":"), sort_keys=True) + return json.dumps( + value, ensure_ascii=False, separators=(",", ":"), sort_keys=True + ) if isinstance(value, bool): return "true" if value else "false" return str(value) @@ -143,7 +146,9 @@ class FishFinRechargeClient: received = str(payload.get("sign") or "").strip().lower() return bool(received) and received == self.sign(payload, method) - def _request(self, method: str, path: str, params: Mapping[str, Any] | None = None) -> dict[str, Any]: + def _request( + self, method: str, path: str, params: Mapping[str, Any] | None = None + ) -> dict[str, Any]: """补齐公共参数、签名并执行一次 JSON 请求。""" request_params: dict[str, Any] = { "app_id": self.config.app_id, @@ -167,27 +172,37 @@ class FishFinRechargeClient: if key != "sign" }, # 只记录摘要,便于关联排查而不暴露可重放的签名。 - "sign_digest": hashlib.sha256(request_params["sign"].encode("utf-8")).hexdigest()[:12], + "sign_digest": hashlib.sha256( + request_params["sign"].encode("utf-8") + ).hexdigest()[:12], } if self.config.debug: sign_params = self.normalized_params(request_params) - sign_query = "&".join(f"{key}={sign_params[key]}" for key in sorted(sign_params)) - trace_event.update({ - "url": url, - "content_type": "application/json", - "json_body": self._debug_payload(request_params), - "sign_params": sign_params, - # 不把可重放签名原文或 AppSecret 写入日志,只记录待签名串摘要。 - "sign_source_digest": hashlib.sha256( - f"{sign_query}{method.upper()}".encode("utf-8") - ).hexdigest()[:12], - }) + sign_query = "&".join( + f"{key}={sign_params[key]}" for key in sorted(sign_params) + ) + trace_event.update( + { + "url": url, + "content_type": "application/json", + "json_body": self._debug_payload(request_params), + "sign_params": sign_params, + # 不把可重放签名原文或 AppSecret 写入日志,只记录待签名串摘要。 + "sign_source_digest": hashlib.sha256( + f"{sign_query}{method.upper()}".encode("utf-8") + ).hexdigest()[:12], + } + ) self.trace(trace_event) try: if method.upper() == "GET": - response = self.session.get(url, params=request_params, timeout=self.config.timeout) + response = self.session.get( + url, params=request_params, timeout=self.config.timeout + ) else: - response = self.session.post(url, json=request_params, timeout=self.config.timeout) + response = self.session.post( + url, json=request_params, timeout=self.config.timeout + ) response.raise_for_status() payload = response.json() except requests.RequestException as exc: @@ -205,18 +220,22 @@ class FishFinRechargeClient: "message": payload.get("msg") or payload.get("message") or "", "out_order_id": self._response_value(payload, "out_order_id", "outOrderId"), "order_id": self._response_value(payload, "order_id", "orderId"), - "order_status": self._response_value(payload, "order_status", "orderStatus"), + "order_status": self._response_value( + payload, "order_status", "orderStatus" + ), "fail_reason": self._response_value(payload, "fail_reason", "failReason"), } if self.config.debug: - trace_event.update({ - "response_headers": { - key: value - for key, value in response.headers.items() - if key.lower() in {"content-type", "x-request-id", "request-id"} - }, - "response_body": self._debug_payload(payload), - }) + trace_event.update( + { + "response_headers": { + key: value + for key, value in response.headers.items() + if key.lower() in {"content-type", "x-request-id", "request-id"} + }, + "response_body": self._debug_payload(payload), + } + ) self.trace(trace_event) return payload @@ -263,7 +282,11 @@ class FishFinRechargeClient: raise ValueError("product_id 不能为空") if not isinstance(buy_num, int) or isinstance(buy_num, bool) or buy_num < 1: raise ValueError("buy_num 必须是不小于 1 的整数") - if not isinstance(order_type, int) or isinstance(order_type, bool) or order_type not in {0, 1, 2, 3}: + if ( + not isinstance(order_type, int) + or isinstance(order_type, bool) + or order_type not in {0, 1, 2, 3} + ): raise ValueError("order_type 必须是 0 至 3 的整数") if not isinstance(recharge_arg, list) or not recharge_arg: raise ValueError("recharge_arg 必须是非空数组") @@ -287,7 +310,9 @@ class FishFinRechargeClient: out_order_id = str(out_order_id).strip() if not out_order_id: raise ValueError("out_order_id 不能为空") - return self._request("GET", self.QUERY_ORDER_PATH, {"out_order_id": out_order_id}) + return self._request( + "GET", self.QUERY_ORDER_PATH, {"out_order_id": out_order_id} + ) def account_info(self) -> dict[str, Any]: """查询商户账户信息。""" diff --git a/core/geetest/common/crypto.py b/core/geetest/common/crypto.py index 3b459fe..99bd409 100644 --- a/core/geetest/common/crypto.py +++ b/core/geetest/common/crypto.py @@ -4,46 +4,78 @@ from typing import Any from Crypto.PublicKey import RSA from Crypto.Cipher import PKCS1_v1_5, AES + # 随机产生4个字符组成的字符串 def four_random_chart() -> str: return hex(int(65536 * (1 + random.random())))[2:][1:] + # PKCS#1 v1.5 填充 + RSA 加密 -def parse_jsbn_bigint(n_obj:dict[Any, int]) -> int: +def parse_jsbn_bigint(n_obj: dict[Any, int]) -> int: DB = 28 DV = 1 << DB - t = n_obj['t'] + t = n_obj["t"] result = 0 for i in range(t): - result += n_obj[i] * (DV ** i) + result += n_obj[i] * (DV**i) return result -def encrypt_data(plaintext:str) -> str: + +def encrypt_data(plaintext: str) -> str: cipher = PKCS1_v1_5.new(public_key) - encrypted = cipher.encrypt(plaintext.encode('utf-8')) + encrypted = cipher.encrypt(plaintext.encode("utf-8")) # 转换为十六进制,确保偶数长度 hex_result = encrypted.hex() if len(hex_result) % 2 == 1: - hex_result = '0' + hex_result + hex_result = "0" + hex_result return hex_result -def RSA_jiami_r(str_16:str) -> str: + +def RSA_jiami_r(str_16: str) -> str: global public_key # 你的数据 n_data = { - 0: 134982529, 1: 254232810, 2: 164556709, 3: 234907349, - 4: 134685994, 5: 35463984, 6: 258277946, 7: 12518857, - 8: 44638621, 9: 93783641, 10: 212253739, 11: 62792472, - 12: 186688352, 13: 109500232, 14: 182488077, 15: 261196188, - 16: 26354094, 17: 103248217, 18: 106891695, 19: 165771045, - 20: 41530993, 21: 263704736, 22: 111785174, 23: 12753611, - 24: 232116673, 25: 155524985, 26: 218291229, 27: 122452343, - 28: 248250238, 29: 118739550, 30: 251169095, 31: 129059733, - 32: 149835464, 33: 5498868, 34: 71719731, 35: 154456417, + 0: 134982529, + 1: 254232810, + 2: 164556709, + 3: 234907349, + 4: 134685994, + 5: 35463984, + 6: 258277946, + 7: 12518857, + 8: 44638621, + 9: 93783641, + 10: 212253739, + 11: 62792472, + 12: 186688352, + 13: 109500232, + 14: 182488077, + 15: 261196188, + 16: 26354094, + 17: 103248217, + 18: 106891695, + 19: 165771045, + 20: 41530993, + 21: 263704736, + 22: 111785174, + 23: 12753611, + 24: 232116673, + 25: 155524985, + 26: 218291229, + 27: 122452343, + 28: 248250238, + 29: 118739550, + 30: 251169095, + 31: 129059733, + 32: 149835464, + 33: 5498868, + 34: 71719731, + 35: 154456417, 36: 49635, - 't': 37, 's': 0 + "t": 37, + "s": 0, } e = 65537 @@ -54,11 +86,12 @@ def RSA_jiami_r(str_16:str) -> str: encrypted = encrypt_data(str_16) return encrypted + # AES加密 # 加密模式: AES-CBC # 密钥长度: 128位 # IV: 固定为 "0000000000000000" -def parse_string_to_wordarray(text:str) -> list[int]: +def parse_string_to_wordarray(text: str) -> list[int]: """将字符串转换为 WordArray 格式""" length = len(text) words = [] @@ -81,13 +114,15 @@ def parse_string_to_wordarray(text:str) -> list[int]: words[word_index] |= char_code << shift return words -def AES_O(plaintext:str, str_16:str) -> list[int]: + + +def AES_O(plaintext: str, str_16: str) -> list[int]: # 密钥 key_words = parse_string_to_wordarray(str_16) - key = b''.join(w.to_bytes(4, 'big') for w in key_words) + key = b"".join(w.to_bytes(4, "big") for w in key_words) # IV - iv = b'0000' * 4 # "0000000000000000" + iv = b"0000" * 4 # "0000000000000000" # 填充(PKCS7) pad_len = 16 - len(plaintext) % 16 @@ -100,13 +135,14 @@ def AES_O(plaintext:str, str_16:str) -> list[int]: # 结果是字节数组 return list(ciphertext) + # 自定义base64编码 -def geetest_base64_encode(data:list[int]) -> dict[str, Any]: +def geetest_base64_encode(data: list[int]) -> dict[str, Any]: """极验自定义Base64编码""" # 配置 - charset = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789()' - pad_char = '.' + charset = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789()" + pad_char = "." # 位掩码 (这些是打乱的) masks = [7274496, 9483264, 19220, 235] @@ -159,13 +195,10 @@ def geetest_base64_encode(data:list[int]) -> dict[str, Any]: break - return { - "res": encoded, - "end": padding - } + return {"res": encoded, "end": padding} -def encrypt_string(e:str, t:list[int], n:str) -> str: +def encrypt_string(e: str, t: list[int], n: str) -> str: """ JS加密函数的Python实现 @@ -185,7 +218,7 @@ def encrypt_string(e:str, t:list[int], n:str) -> str: # 每次读取2个字符(十六进制) while o < len(n): - r = n[o:o + 2] # 取2个字符 + r = n[o : o + 2] # 取2个字符 if len(r) < 2: break o += 2 @@ -205,7 +238,7 @@ def encrypt_string(e:str, t:list[int], n:str) -> str: return i -def simple_md5(message:str) -> str: +def simple_md5(message: str) -> str: """ 简化版MD5实现,结构更清晰 """ @@ -216,31 +249,139 @@ def simple_md5(message:str) -> str: # 轮移位常量 shifts = [ - 7, 12, 17, 22, 7, 12, 17, 22, 7, 12, 17, 22, 7, 12, 17, 22, - 5, 9, 14, 20, 5, 9, 14, 20, 5, 9, 14, 20, 5, 9, 14, 20, - 4, 11, 16, 23, 4, 11, 16, 23, 4, 11, 16, 23, 4, 11, 16, 23, - 6, 10, 15, 21, 6, 10, 15, 21, 6, 10, 15, 21, 6, 10, 15, 21 + 7, + 12, + 17, + 22, + 7, + 12, + 17, + 22, + 7, + 12, + 17, + 22, + 7, + 12, + 17, + 22, + 5, + 9, + 14, + 20, + 5, + 9, + 14, + 20, + 5, + 9, + 14, + 20, + 5, + 9, + 14, + 20, + 4, + 11, + 16, + 23, + 4, + 11, + 16, + 23, + 4, + 11, + 16, + 23, + 4, + 11, + 16, + 23, + 6, + 10, + 15, + 21, + 6, + 10, + 15, + 21, + 6, + 10, + 15, + 21, + 6, + 10, + 15, + 21, ] # K常数(与JavaScript版本中的常数对应) K = [ - 0xd76aa478, 0xe8c7b756, 0x242070db, 0xc1bdceee, - 0xf57c0faf, 0x4787c62a, 0xa8304613, 0xfd469501, - 0x698098d8, 0x8b44f7af, 0xffff5bb1, 0x895cd7be, - 0x6b901122, 0xfd987193, 0xa679438e, 0x49b40821, - 0xf61e2562, 0xc040b340, 0x265e5a51, 0xe9b6c7aa, - 0xd62f105d, 0x02441453, 0xd8a1e681, 0xe7d3fbc8, - 0x21e1cde6, 0xc33707d6, 0xf4d50d87, 0x455a14ed, - 0xa9e3e905, 0xfcefa3f8, 0x676f02d9, 0x8d2a4c8a, - 0xfffa3942, 0x8771f681, 0x6d9d6122, 0xfde5380c, - 0xa4beea44, 0x4bdecfa9, 0xf6bb4b60, 0xbebfbc70, - 0x289b7ec6, 0xeaa127fa, 0xd4ef3085, 0x04881d05, - 0xd9d4d039, 0xe6db99e5, 0x1fa27cf8, 0xc4ac5665, - 0xf4292244, 0x432aff97, 0xab9423a7, 0xfc93a039, - 0x655b59c3, 0x8f0ccc92, 0xffeff47d, 0x85845dd1, - 0x6fa87e4f, 0xfe2ce6e0, 0xa3014314, 0x4e0811a1, - 0xf7537e82, 0xbd3af235, 0x2ad7d2bb, 0xeb86d391 + 0xD76AA478, + 0xE8C7B756, + 0x242070DB, + 0xC1BDCEEE, + 0xF57C0FAF, + 0x4787C62A, + 0xA8304613, + 0xFD469501, + 0x698098D8, + 0x8B44F7AF, + 0xFFFF5BB1, + 0x895CD7BE, + 0x6B901122, + 0xFD987193, + 0xA679438E, + 0x49B40821, + 0xF61E2562, + 0xC040B340, + 0x265E5A51, + 0xE9B6C7AA, + 0xD62F105D, + 0x02441453, + 0xD8A1E681, + 0xE7D3FBC8, + 0x21E1CDE6, + 0xC33707D6, + 0xF4D50D87, + 0x455A14ED, + 0xA9E3E905, + 0xFCEFA3F8, + 0x676F02D9, + 0x8D2A4C8A, + 0xFFFA3942, + 0x8771F681, + 0x6D9D6122, + 0xFDE5380C, + 0xA4BEEA44, + 0x4BDECFA9, + 0xF6BB4B60, + 0xBEBFBC70, + 0x289B7EC6, + 0xEAA127FA, + 0xD4EF3085, + 0x04881D05, + 0xD9D4D039, + 0xE6DB99E5, + 0x1FA27CF8, + 0xC4AC5665, + 0xF4292244, + 0x432AFF97, + 0xAB9423A7, + 0xFC93A039, + 0x655B59C3, + 0x8F0CCC92, + 0xFFEFF47D, + 0x85845DD1, + 0x6FA87E4F, + 0xFE2CE6E0, + 0xA3014314, + 0x4E0811A1, + 0xF7537E82, + 0xBD3AF235, + 0x2AD7D2BB, + 0xEB86D391, ] # 实际实现... - return verify_result(message) \ No newline at end of file + return verify_result(message) diff --git a/core/geetest/common/imaging.py b/core/geetest/common/imaging.py index 54f8367..2e67e01 100644 --- a/core/geetest/common/imaging.py +++ b/core/geetest/common/imaging.py @@ -11,6 +11,7 @@ REQUEST_TIMEOUT = (3.05, 12) def pilImgToCv2(img: Image.Image, flag=cv2.COLOR_RGB2BGR): return cv2.cvtColor(np.asarray(img), flag) + # 识别图片缺口返回滑块距离 def shibie(img: Image.Image, slice: Image.Image): # 通过 pilImgToCv2 将图片置灰 @@ -38,20 +39,70 @@ def shibie(img: Image.Image, slice: Image.Image): # showImg(resultBg) # 可以通过它来看处理后的图片效果 return distance + # 还原图片 -def restore_geetest_image(input_path:str, output_path:str) -> None: +def restore_geetest_image(input_path: str, output_path: str) -> None: """ 还原极验打乱的验证码图像 """ Ut = [ - 39, 38, 48, 49, 41, 40, 46, 47, 35, 34, 50, 51, 33, 32, 28, 29, - 27, 26, 36, 37, 31, 30, 44, 45, 43, 42, 12, 13, 23, 22, 14, 15, - 21, 20, 8, 9, 25, 24, 6, 7, 3, 2, 0, 1, 11, 10, 4, 5, 19, 18, 16, 17 + 39, + 38, + 48, + 49, + 41, + 40, + 46, + 47, + 35, + 34, + 50, + 51, + 33, + 32, + 28, + 29, + 27, + 26, + 36, + 37, + 31, + 30, + 44, + 45, + 43, + 42, + 12, + 13, + 23, + 22, + 14, + 15, + 21, + 20, + 8, + 9, + 25, + 24, + 6, + 7, + 3, + 2, + 0, + 1, + 11, + 10, + 4, + 5, + 19, + 18, + 16, + 17, ] # 打开混淆图像 img = Image.open(input_path) - new_img = Image.new("RGB", (260,160)) + new_img = Image.new("RGB", (260, 160)) r = 160 for _ in range(len(Ut)): a = r / 2 @@ -63,11 +114,12 @@ def restore_geetest_image(input_path:str, output_path:str) -> None: new_img.save(output_path) logger.debug("图像已还原并保存到: {}", output_path) + # 下载图片 -def download_picture(bg:str, fullbg:str, slice:str) -> int: +def download_picture(bg: str, fullbg: str, slice: str) -> int: for i in range(3): if i == 0: - url = "https://static.geetest.com/"+bg + url = "https://static.geetest.com/" + bg response = requests.get(url, timeout=REQUEST_TIMEOUT) response.raise_for_status() with open("bg.jpg", "wb") as f: @@ -87,4 +139,4 @@ def download_picture(bg:str, fullbg:str, slice:str) -> int: response.raise_for_status() with open("slice.jpg", "wb") as f: f.write(response.content) - return shibie(Image.open('fullbg.jpg'), Image.open('slice.jpg')) + return shibie(Image.open("fullbg.jpg"), Image.open("slice.jpg")) diff --git a/core/geetest/common/network.py b/core/geetest/common/network.py index b621f96..ce2d41a 100644 --- a/core/geetest/common/network.py +++ b/core/geetest/common/network.py @@ -29,13 +29,15 @@ def _get( def _parse_jsonp_response(response: requests.Response, source: str) -> dict: """解析极验 JSONP 响应。""" response.raise_for_status() - match = re.search(r'\((.*)\)$', response.text) + 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', '未知错误'))}") + if data.get("status") == "error": + raise ValueError( + f"{source} 失败: {data.get('user_error', data.get('error', '未知错误'))}" + ) return data @@ -55,26 +57,26 @@ def _parse_json_response(response: requests.Response, source: str) -> dict: def get_challenge_gt_bak() -> Tuple[str, str]: headers = { - 'accept': 'application/json, text/javascript, */*; q=0.01', - 'accept-language': 'zh-CN,zh;q=0.9', - 'priority': 'u=1, i', - 'referer': 'https://demos.geetest.com/slide-float.html', - 'sec-ch-ua': '"Chromium";v="140", "Not=A?Brand";v="24", "Google Chrome";v="140"', - 'sec-ch-ua-mobile': '?0', - 'sec-ch-ua-platform': '"Windows"', - 'sec-fetch-dest': 'empty', - 'sec-fetch-mode': 'cors', - 'sec-fetch-site': 'same-origin', - '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', - 'x-requested-with': 'XMLHttpRequest', + "accept": "application/json, text/javascript, */*; q=0.01", + "accept-language": "zh-CN,zh;q=0.9", + "priority": "u=1, i", + "referer": "https://demos.geetest.com/slide-float.html", + "sec-ch-ua": '"Chromium";v="140", "Not=A?Brand";v="24", "Google Chrome";v="140"', + "sec-ch-ua-mobile": "?0", + "sec-ch-ua-platform": '"Windows"', + "sec-fetch-dest": "empty", + "sec-fetch-mode": "cors", + "sec-fetch-site": "same-origin", + "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", + "x-requested-with": "XMLHttpRequest", } params = { - 't': str(int(round(time.time() * 1000))), + "t": str(int(round(time.time() * 1000))), } response = requests.get( - 'https://demos.geetest.com/gt/register-slide', + "https://demos.geetest.com/gt/register-slide", params=params, headers=headers, timeout=10, @@ -85,21 +87,21 @@ def get_challenge_gt_bak() -> Tuple[str, str]: def get_challenge_gt() -> Tuple[str, str]: headers = { - 'User-Agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/146.0.0.0 Safari/537.36', - 'Accept': 'application/json, text/javascript, */*; q=0.01', - 'Content-Type': 'application/x-www-form-urlencoded', - 'referer': 'https://passport.douyu.com/member/login?state=https%3A%2F%2Fwww.douyu.com%2Fmember%2FcpSecurity%2Fcheck_geetest_status', + "User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/146.0.0.0 Safari/537.36", + "Accept": "application/json, text/javascript, */*; q=0.01", + "Content-Type": "application/x-www-form-urlencoded", + "referer": "https://passport.douyu.com/member/login?state=https%3A%2F%2Fwww.douyu.com%2Fmember%2FcpSecurity%2Fcheck_geetest_status", } data = { - 'type': '1', - 'nicknameOrPhoneEncrypt': '03ILaBwtmmCm0A==', - 'password': '57219dddec71c31b7647683fa5306103', - 'biz_type': '1', + "type": "1", + "nicknameOrPhoneEncrypt": "03ILaBwtmmCm0A==", + "password": "57219dddec71c31b7647683fa5306103", + "biz_type": "1", } response = requests.post( - 'https://passport.douyu.com/wgapi/member/passport/login', + "https://passport.douyu.com/wgapi/member/passport/login", headers=headers, data=data, timeout=10, @@ -114,28 +116,27 @@ def get_challenge_gt() -> Tuple[str, str]: raise ValueError(f"斗鱼登录接口返回中缺少极验参数: {preview}") from exc - def get_js_address(gt: str, proxies: Optional[Mapping[str, str]] = None) -> dict: headers = { - 'accept': '*/*', - 'accept-language': 'zh-CN,zh;q=0.9', - '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': '"macOS"', - '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', + "accept": "*/*", + "accept-language": "zh-CN,zh;q=0.9", + "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": '"macOS"', + "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", } params = { - 'gt': gt, - 'callback': 'geetest_' + str(int(round(time.time() * 1000))), + "gt": gt, + "callback": "geetest_" + str(int(round(time.time() * 1000))), } response = _get( - 'https://api.geetest.com/gettype.php', + "https://api.geetest.com/gettype.php", params=params, headers=headers, proxies=proxies, @@ -143,6 +144,7 @@ def get_js_address(gt: str, proxies: Optional[Mapping[str, str]] = None) -> dict return _parse_jsonp_response(response, "极验 gettype") + def get_c_s( gt: str, challenge: str, @@ -150,26 +152,32 @@ def get_c_s( proxies: Optional[Mapping[str, str]] = None, ) -> Tuple[list[int], str]: headers = { - 'accept': '*/*', - 'accept-language': 'zh-CN,zh;q=0.9', - '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': '"macOS"', - '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', + "accept": "*/*", + "accept-language": "zh-CN,zh;q=0.9", + "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": '"macOS"', + "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", } 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))), + "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, proxies=proxies, ) data = _parse_jsonp_response(response, "极验 get.php") - return data['data']['c'], data['data']['s'] + return data["data"]["c"], data["data"]["s"] def req_fullpage_validate( @@ -180,159 +188,181 @@ def req_fullpage_validate( ) -> 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"', + "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"', } 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))), + "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: + +def req_slide(gt: str, challenge: str, w2: str) -> None: headers = { - 'Accept': '*/*', - 'Accept-Language': 'zh-CN,zh;q=0.9', - 'Connection': 'keep-alive', - 'Referer': 'https://demos.geetest.com/', - 'Sec-Fetch-Dest': 'script', - 'Sec-Fetch-Mode': 'no-cors', - 'Sec-Fetch-Site': 'cross-site', - 'Sec-Fetch-Storage-Access': 'active', - '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-ch-ua': '"Chromium";v="140", "Not=A?Brand";v="24", "Google Chrome";v="140"', - 'sec-ch-ua-mobile': '?0', - 'sec-ch-ua-platform': '"Windows"', + "Accept": "*/*", + "Accept-Language": "zh-CN,zh;q=0.9", + "Connection": "keep-alive", + "Referer": "https://demos.geetest.com/", + "Sec-Fetch-Dest": "script", + "Sec-Fetch-Mode": "no-cors", + "Sec-Fetch-Site": "cross-site", + "Sec-Fetch-Storage-Access": "active", + "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-ch-ua": '"Chromium";v="140", "Not=A?Brand";v="24", "Google Chrome";v="140"', + "sec-ch-ua-mobile": "?0", + "sec-ch-ua-platform": '"Windows"', } response = requests.get( - 'https://api.geevisit.com/ajax.php?gt=' + gt + '&challenge=' + challenge + '&lang=zh-cn&pt=0&client_type=web&w=' + w2 + '&callback=geetest_' + str( - int(round(time.time() * 1000))), + "https://api.geevisit.com/ajax.php?gt=" + + gt + + "&challenge=" + + challenge + + "&lang=zh-cn&pt=0&client_type=web&w=" + + w2 + + "&callback=geetest_" + + str(int(round(time.time() * 1000))), headers=headers, timeout=REQUEST_TIMEOUT, ) response.raise_for_status() -def get_picture(gt:str, challenge:str) -> tuple[str, str, list[int], str, str, str]: + +def get_picture(gt: str, challenge: str) -> tuple[str, str, list[int], str, str, str]: headers = { - 'Accept': '*/*', - 'Accept-Language': 'zh-CN,zh;q=0.9', - 'Connection': 'keep-alive', - 'Referer': 'https://demos.geetest.com/', - 'Sec-Fetch-Dest': 'script', - 'Sec-Fetch-Mode': 'no-cors', - 'Sec-Fetch-Site': 'cross-site', - 'Sec-Fetch-Storage-Access': 'active', - '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-ch-ua': '"Chromium";v="140", "Not=A?Brand";v="24", "Google Chrome";v="140"', - 'sec-ch-ua-mobile': '?0', - 'sec-ch-ua-platform': '"Windows"', + "Accept": "*/*", + "Accept-Language": "zh-CN,zh;q=0.9", + "Connection": "keep-alive", + "Referer": "https://demos.geetest.com/", + "Sec-Fetch-Dest": "script", + "Sec-Fetch-Mode": "no-cors", + "Sec-Fetch-Site": "cross-site", + "Sec-Fetch-Storage-Access": "active", + "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-ch-ua": '"Chromium";v="140", "Not=A?Brand";v="24", "Google Chrome";v="140"', + "sec-ch-ua-mobile": "?0", + "sec-ch-ua-platform": '"Windows"', } params = { - 'is_next': 'true', - 'type': 'slide3', - 'gt': gt, - 'challenge': challenge, - 'lang': 'zh-cn', - 'https': 'true', - 'protocol': 'https://', - 'offline': 'false', - 'product': 'embed', - 'api_server': 'api.geevisit.com', - 'isPC': 'true', - 'autoReset': 'true', - 'width': '100%', - 'callback': 'geetest_'+str(int(round(time.time() * 1000))), + "is_next": "true", + "type": "slide3", + "gt": gt, + "challenge": challenge, + "lang": "zh-cn", + "https": "true", + "protocol": "https://", + "offline": "false", + "product": "embed", + "api_server": "api.geevisit.com", + "isPC": "true", + "autoReset": "true", + "width": "100%", + "callback": "geetest_" + str(int(round(time.time() * 1000))), } response = requests.get( - 'https://api.geevisit.com/get.php', + "https://api.geevisit.com/get.php", params=params, headers=headers, timeout=REQUEST_TIMEOUT, ) response.raise_for_status() logger.debug("极验 get.php 原始响应: {}", response.text[:500]) - match = re.search(r'\((.*)\)$', response.text) + match = re.search(r"\((.*)\)$", response.text) if match: json_str = match.group(1) data = json.loads(json_str) - if 'data' in data and isinstance(data['data'], dict): + if "data" in data and isinstance(data["data"], dict): # 新版极验格式 - inner_data = data['data'] + inner_data = data["data"] return ( - inner_data.get('bg', ''), - inner_data.get('fullbg', ''), - inner_data.get('c', []), - inner_data.get('s', ''), - inner_data.get('slice', ''), - inner_data.get('challenge', '') + inner_data.get("bg", ""), + inner_data.get("fullbg", ""), + inner_data.get("c", []), + inner_data.get("s", ""), + inner_data.get("slice", ""), + inner_data.get("challenge", ""), ) else: # 旧版极验格式 return ( - data.get('bg', ''), - data.get('fullbg', ''), - data.get('c', []), - data.get('s', ''), - data.get('slice', ''), - data.get('challenge', '') + data.get("bg", ""), + data.get("fullbg", ""), + data.get("c", []), + data.get("s", ""), + data.get("slice", ""), + data.get("challenge", ""), ) else: raise ValueError("无法解析 JSONP 响应") -def req_end(gt:str, challenge:str, w:str) -> dict: + +def req_end(gt: str, challenge: str, w: str) -> dict: headers = { - 'Accept': '*/*', - 'Accept-Language': 'zh-CN,zh;q=0.9', - 'Connection': 'keep-alive', - 'Referer': 'https://demos.geetest.com/', - 'Sec-Fetch-Dest': 'script', - 'Sec-Fetch-Mode': 'no-cors', - 'Sec-Fetch-Site': 'cross-site', - 'Sec-Fetch-Storage-Access': 'active', - '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-ch-ua': '"Chromium";v="140", "Not=A?Brand";v="24", "Google Chrome";v="140"', - 'sec-ch-ua-mobile': '?0', - 'sec-ch-ua-platform': '"Windows"', + "Accept": "*/*", + "Accept-Language": "zh-CN,zh;q=0.9", + "Connection": "keep-alive", + "Referer": "https://demos.geetest.com/", + "Sec-Fetch-Dest": "script", + "Sec-Fetch-Mode": "no-cors", + "Sec-Fetch-Site": "cross-site", + "Sec-Fetch-Storage-Access": "active", + "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-ch-ua": '"Chromium";v="140", "Not=A?Brand";v="24", "Google Chrome";v="140"', + "sec-ch-ua-mobile": "?0", + "sec-ch-ua-platform": '"Windows"', } response = requests.get( - 'https://api.geevisit.com/ajax.php?gt='+gt+'&challenge='+challenge+'&lang=zh-cn&%24_BCm=0&client_type=web&w='+w+'&callback=geetest_'+str(int(round(time.time() * 1000))), + "https://api.geevisit.com/ajax.php?gt=" + + gt + + "&challenge=" + + challenge + + "&lang=zh-cn&%24_BCm=0&client_type=web&w=" + + w + + "&callback=geetest_" + + str(int(round(time.time() * 1000))), headers=headers, timeout=REQUEST_TIMEOUT, ) response.raise_for_status() logger.debug("极验 ajax.php 原始响应: {}", response.text[:500]) - match = re.search(r'\((.*)\)$', response.text) + match = re.search(r"\((.*)\)$", response.text) if match: json_str = match.group(1) data = json.loads(json_str) logger.debug("极验验证响应: {}", data) # 检查验证是否成功 - if data.get('success') == 1: + if data.get("success") == 1: return data else: # 如果验证失败,返回错误信息 return { - 'success': 0, - 'message': data.get('message', '验证失败'), - 'validate': '' + "success": 0, + "message": data.get("message", "验证失败"), + "validate": "", } else: raise ValueError("无法解析 JSONP 响应") diff --git a/core/geetest/common/performance.py b/core/geetest/common/performance.py index 561be00..59154ac 100644 --- a/core/geetest/common/performance.py +++ b/core/geetest/common/performance.py @@ -18,63 +18,70 @@ def generate_fake_performance_timing(base_time: Optional[int] = None) -> dict[st # 定义合理的时间间隔范围(毫秒) intervals = { - 'fetch': random.randint(1, 2), - 'domain_lookup_start': random.randint(3, 5), - 'domain_lookup': random.randint(5, 15), - 'connect': random.randint(50, 150), - 'ssl_offset': random.randint(30, 50), - 'request': random.randint(1, 5), - 'response': random.randint(20, 100), - 'response_end': random.randint(1, 3), - 'unload_start': random.randint(1, 3), - 'unload': random.randint(1, 5), - 'dom_loading': random.randint(1, 3), - 'dom_interactive': random.randint(50, 200), - 'dom_content_loaded': random.randint(1, 3), - 'load_event': random.randint(0, 5) + "fetch": random.randint(1, 2), + "domain_lookup_start": random.randint(3, 5), + "domain_lookup": random.randint(5, 15), + "connect": random.randint(50, 150), + "ssl_offset": random.randint(30, 50), + "request": random.randint(1, 5), + "response": random.randint(20, 100), + "response_end": random.randint(1, 3), + "unload_start": random.randint(1, 3), + "unload": random.randint(1, 5), + "dom_loading": random.randint(1, 3), + "dom_interactive": random.randint(50, 200), + "dom_content_loaded": random.randint(1, 3), + "load_event": random.randint(0, 5), } timing = {} # 按照时间顺序构建 - timing['navigationStart'] = base_time - timing['fetchStart'] = timing['navigationStart'] + intervals['fetch'] - timing['domainLookupStart'] = timing['fetchStart'] + intervals['domain_lookup_start'] - timing['domainLookupEnd'] = timing['domainLookupStart'] + intervals['domain_lookup'] + timing["navigationStart"] = base_time + timing["fetchStart"] = timing["navigationStart"] + intervals["fetch"] + timing["domainLookupStart"] = ( + timing["fetchStart"] + intervals["domain_lookup_start"] + ) + timing["domainLookupEnd"] = timing["domainLookupStart"] + intervals["domain_lookup"] - timing['connectStart'] = timing['domainLookupEnd'] - timing['secureConnectionStart'] = timing['connectStart'] + intervals['ssl_offset'] - timing['connectEnd'] = timing['connectStart'] + intervals['connect'] + timing["connectStart"] = timing["domainLookupEnd"] + timing["secureConnectionStart"] = timing["connectStart"] + intervals["ssl_offset"] + timing["connectEnd"] = timing["connectStart"] + intervals["connect"] - timing['requestStart'] = timing['connectEnd'] + intervals['request'] - timing['responseStart'] = timing['requestStart'] + intervals['response'] - timing['responseEnd'] = timing['responseStart'] + intervals['response_end'] + timing["requestStart"] = timing["connectEnd"] + intervals["request"] + timing["responseStart"] = timing["requestStart"] + intervals["response"] + timing["responseEnd"] = timing["responseStart"] + intervals["response_end"] - timing['unloadEventStart'] = timing['responseEnd'] + intervals['unload_start'] - timing['unloadEventEnd'] = timing['unloadEventStart'] + intervals['unload'] + timing["unloadEventStart"] = timing["responseEnd"] + intervals["unload_start"] + timing["unloadEventEnd"] = timing["unloadEventStart"] + intervals["unload"] - timing['domLoading'] = timing['unloadEventEnd'] + intervals['dom_loading'] - timing['domInteractive'] = timing['domLoading'] + intervals['dom_interactive'] - timing['domContentLoadedEventStart'] = timing['domInteractive'] - timing['domContentLoadedEventEnd'] = timing['domInteractive'] + intervals['dom_content_loaded'] - timing['domComplete'] = timing['domContentLoadedEventEnd'] - timing['loadEventStart'] = timing['domComplete'] - timing['loadEventEnd'] = timing['loadEventStart'] + intervals['load_event'] + timing["domLoading"] = timing["unloadEventEnd"] + intervals["dom_loading"] + timing["domInteractive"] = timing["domLoading"] + intervals["dom_interactive"] + timing["domContentLoadedEventStart"] = timing["domInteractive"] + timing["domContentLoadedEventEnd"] = ( + timing["domInteractive"] + intervals["dom_content_loaded"] + ) + timing["domComplete"] = timing["domContentLoadedEventEnd"] + timing["loadEventStart"] = timing["domComplete"] + timing["loadEventEnd"] = timing["loadEventStart"] + intervals["load_event"] # 无重定向的情况 - timing['redirectStart'] = 0 - timing['redirectEnd'] = 0 + timing["redirectStart"] = 0 + timing["redirectEnd"] = 0 return timing + def __ease_out_expo(sep): - ''' - 轨迹相关操作 - ''' + """ + 轨迹相关操作 + """ if sep == 1: return 1 else: return 1 - pow(2, -10 * sep) + + def get_slide_track(distance): """ 根据滑动距离生成滑动轨迹 @@ -86,7 +93,9 @@ def get_slide_track(distance): """ if not isinstance(distance, int) or distance < 0: - raise ValueError(f"distance类型必须是大于等于0的整数: distance: {distance}, type: {type(distance)}") + raise ValueError( + f"distance类型必须是大于等于0的整数: distance: {distance}, type: {type(distance)}" + ) # 初始化轨迹列表 slide_track = [ [random.randint(-50, -10), random.randint(-50, -10), 0], @@ -110,4 +119,4 @@ def get_slide_track(distance): slide_track.append([x, _y, t]) _x = x slide_track.append(slide_track[-1]) - return slide_track, slide_track[-1][2] \ No newline at end of file + return slide_track, slide_track[-1][2] diff --git a/core/geetest/v3_slide/solver.py b/core/geetest/v3_slide/solver.py index cac3270..8cc9694 100644 --- a/core/geetest/v3_slide/solver.py +++ b/core/geetest/v3_slide/solver.py @@ -2,25 +2,60 @@ import time import random import json from loguru import logger -from core.geetest.common.trajectory import generate_realistic_trajectory, process_mouse_trajectory, compress_trajectory, TrajectoryEncoder, \ - H -from core.geetest.common.crypto import four_random_chart, RSA_jiami_r, AES_O, geetest_base64_encode, encrypt_string, simple_md5 +from core.geetest.common.trajectory import ( + generate_realistic_trajectory, + process_mouse_trajectory, + compress_trajectory, + TrajectoryEncoder, + H, +) +from core.geetest.common.crypto import ( + four_random_chart, + RSA_jiami_r, + AES_O, + geetest_base64_encode, + encrypt_string, + simple_md5, +) from core.geetest.common.imaging import download_picture -from core.geetest.common.network import get_challenge_gt, get_js_address, get_c_s, req_slide, get_picture, req_end -from core.geetest.common.performance import generate_fake_performance_timing, get_slide_track +from core.geetest.common.network import ( + get_challenge_gt, + get_js_address, + get_c_s, + req_slide, + get_picture, + req_end, +) +from core.geetest.common.performance import ( + generate_fake_performance_timing, + get_slide_track, +) def _generate_seed() -> str: - return four_random_chart() + four_random_chart() + four_random_chart() + four_random_chart() + return ( + four_random_chart() + + four_random_chart() + + four_random_chart() + + four_random_chart() + ) -def get_w1(gt:str, challenge:str, str_16:str) -> str: + +def get_w1(gt: str, challenge: str, str_16: str) -> str: r = RSA_jiami_r(str_16) - plaintext = '{"gt":"' + gt + '","challenge":"' + challenge + '","offline":false,"new_captcha":true,"product":"float","width":"300px","https":true,"api_server":"apiv6.geetest.com","protocol":"https://","type":"fullpage","static_servers":["static.geetest.com/","static.geevisit.com/"],"voice":"/static/js/voice.1.2.6.js","click":"/static/js/click.3.1.2.js","beeline":"/static/js/beeline.1.0.1.js","fullpage":"/static/js/fullpage.9.2.0-guwyxh.js","slide":"/static/js/slide.7.9.3.js","geetest":"/static/js/geetest.6.0.9.js","aspect_radio":{"slide":103,"click":128,"voice":128,"beeline":50},"cc":16,"ww":true,"i":"-1!!-1!!-1!!-1!!-1!!-1!!-1!!-1!!-1!!-1!!-1!!-1!!-1!!-1!!-1!!-1!!-1!!-1!!-1!!-1!!-1!!-1!!-1!!-1!!-1!!-1!!-1!!-1!!-1!!-1!!-1!!-1!!-1!!-1!!-1!!-1!!-1!!-1!!-1!!-1!!-1!!-1!!-1!!-1!!-1!!-1!!-1!!-1!!-1!!-1!!-1!!-1!!-1!!-1!!-1!!-1!!-1!!-1!!-1!!-1!!-1!!-1!!-1!!-1!!-1!!-1!!-1!!-1!!-1!!-1!!-1!!-1!!-1!!-1"}' + plaintext = ( + '{"gt":"' + + gt + + '","challenge":"' + + challenge + + '","offline":false,"new_captcha":true,"product":"float","width":"300px","https":true,"api_server":"apiv6.geetest.com","protocol":"https://","type":"fullpage","static_servers":["static.geetest.com/","static.geevisit.com/"],"voice":"/static/js/voice.1.2.6.js","click":"/static/js/click.3.1.2.js","beeline":"/static/js/beeline.1.0.1.js","fullpage":"/static/js/fullpage.9.2.0-guwyxh.js","slide":"/static/js/slide.7.9.3.js","geetest":"/static/js/geetest.6.0.9.js","aspect_radio":{"slide":103,"click":128,"voice":128,"beeline":50},"cc":16,"ww":true,"i":"-1!!-1!!-1!!-1!!-1!!-1!!-1!!-1!!-1!!-1!!-1!!-1!!-1!!-1!!-1!!-1!!-1!!-1!!-1!!-1!!-1!!-1!!-1!!-1!!-1!!-1!!-1!!-1!!-1!!-1!!-1!!-1!!-1!!-1!!-1!!-1!!-1!!-1!!-1!!-1!!-1!!-1!!-1!!-1!!-1!!-1!!-1!!-1!!-1!!-1!!-1!!-1!!-1!!-1!!-1!!-1!!-1!!-1!!-1!!-1!!-1!!-1!!-1!!-1!!-1!!-1!!-1!!-1!!-1!!-1!!-1!!-1!!-1!!-1"}' + ) o = AES_O(plaintext, str_16) i = geetest_base64_encode(o) - return i['res'] + i['end'] + r + return i["res"] + i["end"] + r -def get_w2(gt:str, challenge:str, c:list[int], s:str,str_16:str) -> str: + +def get_w2(gt: str, challenge: str, c: list[int], s: str, str_16: str) -> str: # 伪造浏览器性能数据 fake_timing = generate_fake_performance_timing() # 映射 @@ -45,7 +80,7 @@ def get_w2(gt:str, challenge:str, c:list[int], s:str,str_16:str) -> str: "r": fake_timing["domContentLoadedEventEnd"], "s": fake_timing["domComplete"], "t": fake_timing["loadEventStart"], - "u": fake_timing["loadEventEnd"] + "u": fake_timing["loadEventEnd"], } first_time = int(round(time.time() * 1000)) # 伪造脚本开始运行时间 @@ -55,10 +90,9 @@ def get_w2(gt:str, challenge:str, c:list[int], s:str,str_16:str) -> str: start_y=random.randint(400, 500), end_x=853, end_y=288, - start_time=first_time + start_time=first_time, ) - trajectory = process_mouse_trajectory(guiji_yuanshu_shuzu)["data"] compressed = compress_trajectory(trajectory) tt = encrypt_string(compressed, c, s) @@ -67,14 +101,30 @@ def get_w2(gt:str, challenge:str, c:list[int], s:str,str_16:str) -> str: rp = simple_md5(gt + challenge + passtime) - plaintext = '{"lang":"zh-cn","type":"fullpage","tt":"'+tt+'","light":"DIV_0","s":"c7c3e21112fe4f741921cb3e4ff9f7cb","h":"321f9af1e098233dbd03f250fd2b5e21","hh":"39bd9cad9e425c3a8f51610fd506e3b3","hi":"09eb21b3ae9542a9bc1e8b63b3d9a467","vip_order":-1,"ct":-1,"ep":{"v":"9.2.0-guwyxh","te":false,"$_BBn":true,"ven":"Google Inc. (AMD)","ren":"ANGLE (AMD, AMD Radeon RX 6750 GRE 12GB (0x000073DF) Direct3D11 vs_5_0 ps_5_0, D3D11)","fp":'+json.dumps(guiji_yuanshu_shuzu[0], separators=(',', ':'))+',"lp":'+json.dumps(guiji_yuanshu_shuzu[-1], separators=(',', ':'))+',"em":{"ph":0,"cp":0,"ek":"11","wd":1,"nt":0,"si":0,"sc":0},"tm":'+json.dumps(web_load_time, separators=(',', ':'))+',"dnf":"dnf","by":0},"passtime":'+passtime+',"rp":"'+rp+'","captcha_token":"112439067","tsfq":"xovrayel"}' - + plaintext = ( + '{"lang":"zh-cn","type":"fullpage","tt":"' + + tt + + '","light":"DIV_0","s":"c7c3e21112fe4f741921cb3e4ff9f7cb","h":"321f9af1e098233dbd03f250fd2b5e21","hh":"39bd9cad9e425c3a8f51610fd506e3b3","hi":"09eb21b3ae9542a9bc1e8b63b3d9a467","vip_order":-1,"ct":-1,"ep":{"v":"9.2.0-guwyxh","te":false,"$_BBn":true,"ven":"Google Inc. (AMD)","ren":"ANGLE (AMD, AMD Radeon RX 6750 GRE 12GB (0x000073DF) Direct3D11 vs_5_0 ps_5_0, D3D11)","fp":' + + json.dumps(guiji_yuanshu_shuzu[0], separators=(",", ":")) + + ',"lp":' + + json.dumps(guiji_yuanshu_shuzu[-1], separators=(",", ":")) + + ',"em":{"ph":0,"cp":0,"ek":"11","wd":1,"nt":0,"si":0,"sc":0},"tm":' + + json.dumps(web_load_time, separators=(",", ":")) + + ',"dnf":"dnf","by":0},"passtime":' + + passtime + + ',"rp":"' + + rp + + '","captcha_token":"112439067","tsfq":"xovrayel"}' + ) result = geetest_base64_encode(AES_O(plaintext, str_16)) - return result['res']+result['end']+result['end'] + return result["res"] + result["end"] + result["end"] -def get_w3(str_16:str, challenge:str, hkjl:int, c:list[int], s:str, gt:str) -> str: + +def get_w3( + str_16: str, challenge: str, hkjl: int, c: list[int], s: str, gt: str +) -> str: encoder = TrajectoryEncoder() u = RSA_jiami_r(str_16) # 伪造浏览器性能数据 @@ -101,11 +151,9 @@ def get_w3(str_16:str, challenge:str, hkjl:int, c:list[int], s:str, gt:str) -> s "r": fake_timing["domContentLoadedEventEnd"], "s": fake_timing["domComplete"], "t": fake_timing["loadEventStart"], - "u": fake_timing["loadEventEnd"] + "u": fake_timing["loadEventEnd"], } - - trajectory = get_slide_track(hkjl)[0] logger.debug("滑动轨迹: {}", trajectory) @@ -117,13 +165,25 @@ def get_w3(str_16:str, challenge:str, hkjl:int, c:list[int], s:str, gt:str) -> s passtime = str(trajectory[-1][2]) - rp = simple_md5(gt + challenge[:32] + passtime) - plaintext = '{"lang":"zh-cn","userresponse":"'+userresponse+'","passtime":'+passtime+',"imgload":50,"aa":"'+aa+'","ep":{"v":"7.9.3","$_BIT":false,"me":true,"tm":'+json.dumps(web_load_time, separators=(',', ':'))+',"td":-1},"h9s9":"1816378497","rp":"'+rp+'"}' + plaintext = ( + '{"lang":"zh-cn","userresponse":"' + + userresponse + + '","passtime":' + + passtime + + ',"imgload":50,"aa":"' + + aa + + '","ep":{"v":"7.9.3","$_BIT":false,"me":true,"tm":' + + json.dumps(web_load_time, separators=(",", ":")) + + ',"td":-1},"h9s9":"1816378497","rp":"' + + rp + + '"}' + ) + + h = geetest_base64_encode(AES_O(plaintext, str_16))["res"] + return h + u - h = geetest_base64_encode(AES_O(plaintext, str_16))['res'] - return h+u def run_solver() -> None: # 16位字符串 @@ -154,7 +214,7 @@ def run_solver() -> None: hkjl = download_picture(bg, fullbg, slice) # 获取第三个w值 - w3 = get_w3(str_16,challenge,hkjl,c,s,gt) + w3 = get_w3(str_16, challenge, hkjl, c, s, gt) # 最后的验证 message = req_end(gt, challenge, w3) diff --git a/core/huya/account_env.py b/core/huya/account_env.py index 0fe860b..fcf5877 100644 --- a/core/huya/account_env.py +++ b/core/huya/account_env.py @@ -26,6 +26,7 @@ python -m core.huya.account_env <账号> <密码> --new-device # 抛弃旧环境, 换新设备 python -m core.huya.account_env <账号> --show # 只查看该账号绑定 """ + from __future__ import annotations import hashlib @@ -35,10 +36,10 @@ import sys import time from .device_profile import ( - HDID32, # 登录帧 t1.t0 32hex 常量 (R15 公式结果, k1 来源见 R39; device_profile.py) + HDID32, # 登录帧 t1.t0 32hex 常量 (R15 公式结果, k1 来源见 R39; device_profile.py) _load_db, _save_db, - get_profile, # 幂等画像: 同账号永远复用同一套 (data/huya_device_profiles.json) + get_profile, # 幂等画像: 同账号永远复用同一套 (data/huya_device_profiles.json) ) from .app_login import HuyaAppPasswordLogin @@ -77,7 +78,9 @@ def get_or_create_env(account: str, force_new: bool = False) -> dict: record["guid32"] = _rand_hex(16, b"guid") changed = True if "hebe" not in record: - record["hebe"] = {f"Hebe_D{i}": _rand_hex(16, f"hebe{i}".encode()) for i in range(1, 6)} + record["hebe"] = { + f"Hebe_D{i}": _rand_hex(16, f"hebe{i}".encode()) for i in range(1, 6) + } changed = True if changed: db[account] = record @@ -88,8 +91,12 @@ def get_or_create_env(account: str, force_new: bool = False) -> dict: # --------------------------------------------------------------------------- # 绑定 + 登录: 主流程 # --------------------------------------------------------------------------- -def bind_and_login(account: str, password: str, - force_new_device: bool = False, proxies: dict | None = None) -> dict: +def bind_and_login( + account: str, + password: str, + force_new_device: bool = False, + proxies: dict | None = None, +) -> dict: """账号 ↔ 环境绑定并登录。 注册链在 login_cred_with_flow 内部执行一次 (register_device 用的 fingerprint @@ -97,9 +104,11 @@ def bind_and_login(account: str, password: str, 沿用同一组设备字段 (app_login.login_cred_with_flow)。 """ env = get_or_create_env(account, force_new=force_new_device) - print(f"[env] {account} ↔ {env.get('vendor')}/{env.get('model')} " - f"fp40={env.get('fingerprint', '')[:12]}... guid32={env.get('guid32', '')[:12]}... " - f"t1.t0={env.get('hdid', '')[:12]}...") + print( + f"[env] {account} ↔ {env.get('vendor')}/{env.get('model')} " + f"fp40={env.get('fingerprint', '')[:12]}... guid32={env.get('guid32', '')[:12]}... " + f"t1.t0={env.get('hdid', '')[:12]}..." + ) # device_info 显式传入本环境 (否则 HuyaAppPasswordLogin 内部会再走一次 # get_profile —— 结果相同, 但显式传入让"环境→注册→登录"的数据流向可读); @@ -108,13 +117,16 @@ def bind_and_login(account: str, password: str, login_env.setdefault("device_id", env.get("device_id")) result = HuyaAppPasswordLogin( - account, password, proxies=proxies, device_info=login_env, + account, + password, + proxies=proxies, + device_info=login_env, ).login() # ---- 绑定元数据回写 (令牌不入库: t2/t5 每次登录实时签发, 环境才是长期身份) ---- db = _load_db() record = dict(db.get(account) or env) - record.setdefault("bound_at", int(time.time())) # 首次绑定时间 + record.setdefault("bound_at", int(time.time())) # 首次绑定时间 record["last_login"] = { "ok": result.success, "msg": result.message[:120], @@ -125,9 +137,13 @@ def bind_and_login(account: str, password: str, return { "account": account, - "env": {"model": env.get("model"), "vendor": env.get("vendor"), - "fingerprint": env.get("fingerprint"), "guid32": env.get("guid32"), - "hdid_t1t0": env.get("hdid")}, + "env": { + "model": env.get("model"), + "vendor": env.get("vendor"), + "fingerprint": env.get("fingerprint"), + "guid32": env.get("guid32"), + "hdid_t1t0": env.get("hdid"), + }, "login_success": result.success, "login_message": result.message, "code": getattr(result, "code", None), diff --git a/core/huya/activity_structs.py b/core/huya/activity_structs.py index 49ae749..6b5afee 100644 --- a/core/huya/activity_structs.py +++ b/core/huya/activity_structs.py @@ -245,7 +245,9 @@ class ScoreExchangePrizeResp(TafStruct): self.msg = ins.read_string(1, default=self.msg) self.orderId = ins.read_string(3, default=self.orderId) self.exchangeInfo = ins.read_struct(4, ExchangeInfo) or self.exchangeInfo - self.actPreCondition = ins.read_struct(5, ExchangeActPreCondition) or self.actPreCondition + self.actPreCondition = ( + ins.read_struct(5, ExchangeActPreCondition) or self.actPreCondition + ) def write_to(self, os: TafOutputStream): os.write_int32(0, self.status) @@ -512,7 +514,9 @@ class ActTaskDetailItem(TafStruct): @property def spu_id(self) -> str: - return self._extract_spu_id(self.taskUrl) or self._extract_spu_id(self.taskParams) + return self._extract_spu_id(self.taskUrl) or self._extract_spu_id( + self.taskParams + ) def to_dict(self) -> dict: return { diff --git a/core/huya/auto_register.py b/core/huya/auto_register.py index 4c9c0c5..e8eff76 100644 --- a/core/huya/auto_register.py +++ b/core/huya/auto_register.py @@ -172,7 +172,11 @@ def register_huya_with_sms_line( if login_result.success and login_result.cookie: cookie = login_result.cookie uid = cookie_value(cookie, "udb_uid") or cookie_value(cookie, "yyuid") - username = cookie_value(cookie, "udb_passport") or cookie_value(cookie, "username") or uid + username = ( + cookie_value(cookie, "udb_passport") + or cookie_value(cookie, "username") + or uid + ) if not change_password: return HuyaAutoRegisterResult( phone=phone, @@ -207,7 +211,9 @@ def register_huya_with_sms_line( attempts=attempts, ) - password = fixed_password.strip() or generate_huya_password(password_prefix) + password = fixed_password.strip() or generate_huya_password( + password_prefix + ) change_result = change_huya_password_with_sms_line( uid=uid, cookie=cookie, @@ -220,7 +226,9 @@ def register_huya_with_sms_line( stop_event=stop_event, ) if not change_result.success: - failed_status = "stopped" if change_result.message == "已停止" else "error" + failed_status = ( + "stopped" if change_result.message == "已停止" else "error" + ) return HuyaAutoRegisterResult( phone=phone, provider=item.provider, @@ -230,7 +238,9 @@ def register_huya_with_sms_line( cookie=cookie, code=poll_result.code, change_code=change_result.code, - sdid=change_result.sdid or login_result.sdid or code_result.sdid, + sdid=change_result.sdid + or login_result.sdid + or code_result.sdid, normalized_phone=normalized_phone, username=username, uid=uid, diff --git a/core/huya/cert_forge.py b/core/huya/cert_forge.py index d186e12..407c465 100644 --- a/core/huya/cert_forge.py +++ b/core/huya/cert_forge.py @@ -2,6 +2,7 @@ 证书格式:base64( [0x0c][key_idx][AES-128-ECB(key16, zeropad(P1))] ) """ + from __future__ import annotations import base64 @@ -48,7 +49,7 @@ def parse_p1(data: bytes) -> dict: def tk(n: int) -> bytes: nonlocal o - b = data[o:o + n] + b = data[o : o + n] o += n return b diff --git a/core/huya/device_fingerprint.py b/core/huya/device_fingerprint.py index 3d8bbe9..cce1155 100644 --- a/core/huya/device_fingerprint.py +++ b/core/huya/device_fingerprint.py @@ -29,9 +29,12 @@ FP_STATE_ROOT = Path(__file__).resolve().parents[2] / "data" / "huya_fp_states" def account_state_dir(account: str) -> Path: """账号专属指纹状态目录 (持久化, 保证同一账号多次登录是同一台'设备')。""" - safe = "".join(c if c.isalnum() or c in "-_." else "_" for c in (account or "anon"))[:64] + safe = "".join( + c if c.isalnum() or c in "-_." else "_" for c in (account or "anon") + )[:64] return FP_STATE_ROOT / safe + DEFAULT_TIMEOUT = (8, 40) @@ -52,7 +55,9 @@ class HuyaSdidResult: HDID_PREFIX = "__HDID__" -def _run_node_runner(state_dir: Path, app_id: str, timeout: tuple[float, float]) -> tuple[str, str]: +def _run_node_runner( + state_dir: Path, app_id: str, timeout: tuple[float, float] +) -> tuple[str, str]: """调用 node runner,返回 (sdid, hdid)。 若 state_dir/device.json 存在 (账号画像派生的设备覆盖参数), runner 会以该 @@ -79,9 +84,9 @@ def _run_node_runner(state_dir: Path, app_id: str, timeout: tuple[float, float]) for line in (proc.stdout or "").splitlines(): line = line.strip() if line.startswith(SDID_PREFIX) and len(line) > len(SDID_PREFIX) + 20: - sdid = line[len(SDID_PREFIX):] + sdid = line[len(SDID_PREFIX) :] if line.startswith(HDID_PREFIX) and len(line) > len(HDID_PREFIX) + 20: - hdid = line[len(HDID_PREFIX):] + hdid = line[len(HDID_PREFIX) :] if sdid: return sdid, hdid stderr_tail = (proc.stderr or "").strip().splitlines() @@ -168,8 +173,9 @@ def get_huya_sdid( try: sdid, hdid = _run_node_runner(state_dir, app_id, timeout) if sdid: - logger.debug("虎牙设备指纹成功(node): sdid={}... hdid={}...", - sdid[:24], hdid[:10]) + logger.debug( + "虎牙设备指纹成功(node): sdid={}... hdid={}...", sdid[:24], hdid[:10] + ) return HuyaSdidResult(sdid=sdid, hdid=hdid, source="fingerprint") except HuyaFingerprintError as exc: logger.warning("虎牙 hydevice 指纹失败: {}", exc) diff --git a/core/huya/device_profile.py b/core/huya/device_profile.py index 2d502c4..f1a5664 100644 --- a/core/huya/device_profile.py +++ b/core/huya/device_profile.py @@ -12,6 +12,7 @@ - 固定: hdid(32hex 硬锚,全账号同一) / app_version / sdk_version; - 动态签发: safedeviceid、登录帧 device_id。 """ + from __future__ import annotations import hashlib @@ -85,6 +86,7 @@ def record_login(account: str, ok: bool, message: str = "") -> None: 供 app_login 登录流程调用, GUI 设备绑定页读取展示。 """ import time as _time + db = _load_db() rec = db.get(account) if rec is None: @@ -112,7 +114,9 @@ def _load_db() -> dict: def _save_db(db: dict) -> None: try: PRIMARY_PROFILE_DB.parent.mkdir(parents=True, exist_ok=True) - PRIMARY_PROFILE_DB.write_text(json.dumps(db, indent=2, ensure_ascii=False), encoding="utf-8") + PRIMARY_PROFILE_DB.write_text( + json.dumps(db, indent=2, ensure_ascii=False), encoding="utf-8" + ) except Exception: pass @@ -130,8 +134,12 @@ def _enrich_profile(profile: dict) -> tuple[dict, bool]: out["guid32"] = hashlib.sha256(os.urandom(16) + b"guid").hexdigest() changed = True if len(out.get("hebe") or {}) < 5: - out["hebe"] = {f"Hebe_D{i}": hashlib.sha256(os.urandom(16) + f"hebe{i}".encode()).hexdigest() - for i in range(1, 6)} + out["hebe"] = { + f"Hebe_D{i}": hashlib.sha256( + os.urandom(16) + f"hebe{i}".encode() + ).hexdigest() + for i in range(1, 6) + } changed = True return out, changed diff --git a/core/huya/nonce_forge.py b/core/huya/nonce_forge.py index f386764..8ce8bd1 100644 --- a/core/huya/nonce_forge.py +++ b/core/huya/nonce_forge.py @@ -2,6 +2,7 @@ 基于 XXTEA 算法与 uid + k1 派生密钥。 """ + from __future__ import annotations import hashlib @@ -27,13 +28,23 @@ def _xxtea_encrypt_words(v: list[int], k: list[int]) -> list[int]: p = 0 while p < n - 1: y = v[p + 1] - z = (v[p] + ((((z >> 5) ^ (y << 2)) + ((y >> 3) ^ (z << 4))) - ^ ((s ^ y) + (k[(p & 3) ^ e] ^ z)))) & 0xFFFFFFFF + z = ( + v[p] + + ( + (((z >> 5) ^ (y << 2)) + ((y >> 3) ^ (z << 4))) + ^ ((s ^ y) + (k[(p & 3) ^ e] ^ z)) + ) + ) & 0xFFFFFFFF v[p] = z p += 1 y = v[0] - z = (v[n - 1] + ((((z >> 5) ^ (y << 2)) + ((y >> 3) ^ (z << 4))) - ^ ((s ^ y) + (k[((n - 1) & 3) ^ e] ^ z)))) & 0xFFFFFFFF + z = ( + v[n - 1] + + ( + (((z >> 5) ^ (y << 2)) + ((y >> 3) ^ (z << 4))) + ^ ((s ^ y) + (k[((n - 1) & 3) ^ e] ^ z)) + ) + ) & 0xFFFFFFFF v[n - 1] = z q -= 1 return v @@ -45,9 +56,9 @@ def xxtea_encrypt(data: bytes, key16: bytes) -> bytes: nwords = (n // 4) + 1 v = [0] * nwords for i in range(n // 4): - v[i] = struct.unpack(" - self.payoutChargeAmount: int = 0 # tag 1 - self.cancelPayoutTypeList: List[int] = [] # tag 2 - self.recycleSupplierId: int = 0 # tag 3 - self.claimPrice: int = 0 # tag 4 + self.payoutTypeList: List[int] = [] # tag 0 Vector + self.payoutChargeAmount: int = 0 # tag 1 + self.cancelPayoutTypeList: List[int] = [] # tag 2 + self.recycleSupplierId: int = 0 # tag 3 + self.claimPrice: int = 0 # tag 4 def write_to(self, os: TafOutputStream): # HAR 实证:空 list/0 值也会写出 @@ -691,8 +712,8 @@ class CreateOrderAccountParam(TafStruct): class PromotionItem(TafStruct): def __init__(self): - self.promotionId: int = 0 # tag 0 - self.promotionType: int = 0 # tag 1 + self.promotionId: int = 0 # tag 0 + self.promotionType: int = 0 # tag 1 def write_to(self, os: TafOutputStream): _opt_int(os, 0, self.promotionId) @@ -705,40 +726,41 @@ class PromotionItem(TafStruct): class CreateOrderReqV5(TafStruct): """下单请求 (shopMiddleUI.createOrderV5)""" + def __init__(self): - self.userId = UserId() # tag 0 - self.shopAppInfo = ShopAppInfo() # tag 1 - self.receiveId: int = 0 # tag 2 - self.pid: int = 0 # tag 3 - self.skuId: int = 0 # tag 4 - self.itemCount: int = 1 # tag 5 - self.remark: str = "" # tag 6 - self.spuId: str = "" # tag 7 - self.gameId: str = "" # tag 8 - self.orderId: int = 0 # tag 9 - self.src: int = 0 # tag 10 - self.couponUserIds: List[int] = [] # tag 11 Vector - self.orderType: int = 0 # tag 12 + self.userId = UserId() # tag 0 + self.shopAppInfo = ShopAppInfo() # tag 1 + self.receiveId: int = 0 # tag 2 + self.pid: int = 0 # tag 3 + self.skuId: int = 0 # tag 4 + self.itemCount: int = 1 # tag 5 + self.remark: str = "" # tag 6 + self.spuId: str = "" # tag 7 + self.gameId: str = "" # tag 8 + self.orderId: int = 0 # tag 9 + self.src: int = 0 # tag 10 + self.couponUserIds: List[int] = [] # tag 11 Vector + self.orderType: int = 0 # tag 12 self.extraParam: Optional[CreateOrderExtraParam] = None # tag 13 - self.scene: int = 0 # tag 14 - self.promotionItems: List = [] # tag 15 Vector - self.sourceId: str = "" # tag 16 - self.env: Dict[str, str] = {} # tag 17 Map - self.orderScene: int = 0 # tag 18 - self.watchWord: str = "" # tag 19 - self.marketingChannel: str = "" # tag 20 + self.scene: int = 0 # tag 14 + self.promotionItems: List = [] # tag 15 Vector + self.sourceId: str = "" # tag 16 + self.env: Dict[str, str] = {} # tag 17 Map + self.orderScene: int = 0 # tag 18 + self.watchWord: str = "" # tag 19 + self.marketingChannel: str = "" # tag 20 self.promotionParam: Optional[CreateOrderPromotionParam] = None # tag 21 - self.externalTraceKey: str = "" # tag 22 - self.kefuUid: int = 0 # tag 23 - self.accountParam: Optional[CreateOrderAccountParam] = None # tag 24 - self.parentOrderId: int = 0 # tag 25 - self.vendorAccountType: str = "" # tag 26 - self.vendorAccountVal: str = "" # tag 27 - self.vendorSubAccountVal: str = "" # tag 28 - self.vendorSubAccountType: str = "" # tag 29 - self.bizType: int = 0 # tag 30 - self.gameCategoryId: int = 0 # tag 31 - self.ext: str = "" # tag 32 + self.externalTraceKey: str = "" # tag 22 + self.kefuUid: int = 0 # tag 23 + self.accountParam: Optional[CreateOrderAccountParam] = None # tag 24 + self.parentOrderId: int = 0 # tag 25 + self.vendorAccountType: str = "" # tag 26 + self.vendorAccountVal: str = "" # tag 27 + self.vendorSubAccountVal: str = "" # tag 28 + self.vendorSubAccountType: str = "" # tag 29 + self.bizType: int = 0 # tag 30 + self.gameCategoryId: int = 0 # tag 31 + self.ext: str = "" # tag 32 def write_to(self, os: TafOutputStream): # HAR 实证:createOrderV5 会写出完整字段,即使值为 0/空串/空 list @@ -782,13 +804,14 @@ class CreateOrderReqV5(TafStruct): class CreateOrderRsp(TafStruct): """下单响应 (shopMiddleUI.createOrderV5)""" + def __init__(self): - self.code: int = 0 # tag 0 - self.message: str = "" # tag 1 - self.orderId: int = 0 # tag 2 虎牙订单号 - self.subOrderId: int = 0 # tag 4 - self.orderStatus: int = 0 # tag 5 - self.riskUrl: str = "" # tag 8 风控跳转URL(code!=200时可能有) + self.code: int = 0 # tag 0 + self.message: str = "" # tag 1 + self.orderId: int = 0 # tag 2 虎牙订单号 + self.subOrderId: int = 0 # tag 4 + self.orderStatus: int = 0 # tag 5 + self.riskUrl: str = "" # tag 8 风控跳转URL(code!=200时可能有) def read_from(self, ins: TafInputStream): self.code = ins.read_int32(0, default=self.code) @@ -806,19 +829,21 @@ class CreateOrderRsp(TafStruct): # 支付(payOrderSubmitV5) # ============================================================ + class PayOrderRes(TafStruct): """ 发起支付响应 (shopMiddleUI.payOrderSubmitV5) 结构从 state_shop_ts.js 的 payOrderRes 推断,tag 顺序按出现顺序 """ + def __init__(self): - self.code: int = 0 # tag 0 - self.message: str = "" # tag 1 - self.orderId: int = 0 # tag 2 虎牙订单号 - self.appOrderId: str = "" # tag 3 - self.payOrderId: str = "" # tag 4 支付宝 payOrderId - self.payUrl: str = "" # tag 5 支付宝下单URL(含sign) - self.amount: int = 0 # tag 6 + self.code: int = 0 # tag 0 + self.message: str = "" # tag 1 + self.orderId: int = 0 # tag 2 虎牙订单号 + self.appOrderId: str = "" # tag 3 + self.payOrderId: str = "" # tag 4 支付宝 payOrderId + self.payUrl: str = "" # tag 5 支付宝下单URL(含sign) + self.amount: int = 0 # tag 6 def read_from(self, ins: TafInputStream): self.code = ins.read_int32(0, default=self.code) diff --git a/core/huya/taf_protocol.py b/core/huya/taf_protocol.py index 316b5b7..d9dadb9 100644 --- a/core/huya/taf_protocol.py +++ b/core/huya/taf_protocol.py @@ -8,6 +8,7 @@ 0x08 MAP 0x09 LIST 0x0a STRUCT_BEGIN 0x0b STRUCT_END 0x0c ZERO 0x0d SIMPLE_LIST """ + import struct import io from typing import Any, Dict, List, Optional, Tuple @@ -24,16 +25,17 @@ class TafType: STRING4 = 0x07 MAP = 0x08 LIST = 0x09 - STRUCT_BEGIN = 0x0a - STRUCT_END = 0x0b - ZERO = 0x0c - SIMPLE_LIST = 0x0d + STRUCT_BEGIN = 0x0A + STRUCT_END = 0x0B + ZERO = 0x0C + SIMPLE_LIST = 0x0D # ============================================================ # 输出流(编码) # ============================================================ + class TafOutputStream: """TAF 编码输出流""" @@ -46,9 +48,9 @@ class TafOutputStream: # ---- head ---- def write_head(self, tag: int, data_type: int): if tag < 15: - self.buf.write(struct.pack('B', (tag << 4) | data_type)) + self.buf.write(struct.pack("B", (tag << 4) | data_type)) else: - self.buf.write(struct.pack('BB', 0xF0 | data_type, tag)) + self.buf.write(struct.pack("BB", 0xF0 | data_type, tag)) # ---- 整数(带自动优化) ---- def write_int8(self, tag: int, value: int): @@ -56,28 +58,28 @@ class TafOutputStream: self.write_head(tag, TafType.ZERO) else: self.write_head(tag, TafType.INT8) - self.buf.write(struct.pack('b', value)) + self.buf.write(struct.pack("b", value)) def write_int16(self, tag: int, value: int): if -128 <= value <= 127: self.write_int8(tag, value) else: self.write_head(tag, TafType.INT16) - self.buf.write(struct.pack('>h', value)) + self.buf.write(struct.pack(">h", value)) def write_int32(self, tag: int, value: int): if -32768 <= value <= 32767: self.write_int16(tag, value) else: self.write_head(tag, TafType.INT32) - self.buf.write(struct.pack('>i', value)) + self.buf.write(struct.pack(">i", value)) def write_int64(self, tag: int, value: int): if -2147483648 <= value <= 2147483647: self.write_int32(tag, value) else: self.write_head(tag, TafType.INT64) - self.buf.write(struct.pack('>q', value)) + self.buf.write(struct.pack(">q", value)) def write_uint64(self, tag: int, value: int): """uint64:超过 int32 范围用 INT64""" @@ -85,27 +87,27 @@ class TafOutputStream: self.write_int32(tag, value) else: self.write_head(tag, TafType.INT64) - self.buf.write(struct.pack('>Q', value)) + self.buf.write(struct.pack(">Q", value)) # ---- 浮点 ---- def write_float(self, tag: int, value: float): self.write_head(tag, TafType.FLOAT) - self.buf.write(struct.pack('>f', value)) + self.buf.write(struct.pack(">f", value)) def write_double(self, tag: int, value: float): self.write_head(tag, TafType.DOUBLE) - self.buf.write(struct.pack('>d', value)) + self.buf.write(struct.pack(">d", value)) # ---- 字符串 ---- def write_string(self, tag: int, value: str): - encoded = value.encode('utf-8') + encoded = value.encode("utf-8") length = len(encoded) if length > 255: self.write_head(tag, TafType.STRING4) - self.buf.write(struct.pack('>I', length)) + self.buf.write(struct.pack(">I", length)) else: self.write_head(tag, TafType.STRING1) - self.buf.write(struct.pack('B', length)) + self.buf.write(struct.pack("B", length)) self.buf.write(encoded) # ---- 字节数组 ---- @@ -133,8 +135,9 @@ class TafOutputStream: self.write_struct_end() # ---- Map ---- - def write_map(self, tag: int, value: Dict[Any, Any], - key_writer=None, val_writer=None): + def write_map( + self, tag: int, value: Dict[Any, Any], key_writer=None, val_writer=None + ): self.write_head(tag, TafType.MAP) self.write_int32(0, len(value)) for k, v in value.items(): @@ -172,7 +175,7 @@ class TafOutputStream: self.write_map(tag, value) elif isinstance(value, (list, tuple)): self.write_list(tag, list(value)) - elif hasattr(value, 'write_to'): + elif hasattr(value, "write_to"): self.write_struct(tag, value) else: raise TypeError(f"不支持的类型: {type(value)}") @@ -182,6 +185,7 @@ class TafOutputStream: # 输入流(解码)—— 完整实现,支持所有类型 # ============================================================ + class TafInputStream: """TAF 解码输入流""" @@ -201,14 +205,14 @@ class TafInputStream: data = self.buf.read(1) if not data: raise EOFError("读取到文件末尾") - b = struct.unpack('B', data)[0] + b = struct.unpack("B", data)[0] tag = (b >> 4) & 0x0F data_type = b & 0x0F if tag == 15: data = self.buf.read(1) if not data: raise EOFError("读取 tag 扩展字节失败") - tag = struct.unpack('B', data)[0] + tag = struct.unpack("B", data)[0] return tag, data_type # ---- 跳过 ---- @@ -228,10 +232,10 @@ class TafInputStream: elif data_type == TafType.DOUBLE: self.buf.read(8) elif data_type == TafType.STRING1: - length = struct.unpack('B', self.buf.read(1))[0] + length = struct.unpack("B", self.buf.read(1))[0] self.buf.read(length) elif data_type == TafType.STRING4: - length = struct.unpack('>I', self.buf.read(4))[0] + length = struct.unpack(">I", self.buf.read(4))[0] self.buf.read(length) elif data_type == TafType.MAP: self._skip_map() @@ -258,13 +262,13 @@ class TafInputStream: if dtype == TafType.ZERO: return 0 if dtype == TafType.INT8: - return struct.unpack('b', self.buf.read(1))[0] + return struct.unpack("b", self.buf.read(1))[0] if dtype == TafType.INT16: - return struct.unpack('>h', self.buf.read(2))[0] + return struct.unpack(">h", self.buf.read(2))[0] if dtype == TafType.INT32: - return struct.unpack('>i', self.buf.read(4))[0] + return struct.unpack(">i", self.buf.read(4))[0] if dtype == TafType.INT64: - return struct.unpack('>q', self.buf.read(8))[0] + return struct.unpack(">q", self.buf.read(8))[0] raise ValueError(f"期望整数, 实际 0x{dtype:02x}") def _skip_struct(self): @@ -335,19 +339,23 @@ class TafInputStream: if dtype == TafType.ZERO: return 0 if dtype == TafType.INT8: - return struct.unpack('B', self.buf.read(1))[0] + return struct.unpack("B", self.buf.read(1))[0] if dtype == TafType.INT16: - return struct.unpack('>H', self.buf.read(2))[0] + return struct.unpack(">H", self.buf.read(2))[0] if dtype == TafType.INT32: - return struct.unpack('>I', self.buf.read(4))[0] + return struct.unpack(">I", self.buf.read(4))[0] if dtype == TafType.INT64: - return struct.unpack('>Q', self.buf.read(8))[0] + return struct.unpack(">Q", self.buf.read(8))[0] raise ValueError(f"期望 uint, 实际 0x{dtype:02x}") - def read_boolean(self, tag: int, required: bool = False, default: bool = False) -> bool: + def read_boolean( + self, tag: int, required: bool = False, default: bool = False + ) -> bool: return bool(self.read_int8(tag, required, 1 if default else 0)) - def read_float(self, tag: int, required: bool = False, default: float = 0.0) -> float: + def read_float( + self, tag: int, required: bool = False, default: float = 0.0 + ) -> float: found = self._find_tag(tag, required) if not found: return default @@ -355,12 +363,14 @@ class TafInputStream: if dtype == TafType.ZERO: return 0.0 if dtype == TafType.FLOAT: - return struct.unpack('>f', self.buf.read(4))[0] + return struct.unpack(">f", self.buf.read(4))[0] if dtype == TafType.DOUBLE: - return struct.unpack('>d', self.buf.read(8))[0] + return struct.unpack(">d", self.buf.read(8))[0] return float(self._read_int_value(dtype)) - def read_double(self, tag: int, required: bool = False, default: float = 0.0) -> float: + def read_double( + self, tag: int, required: bool = False, default: float = 0.0 + ) -> float: return self.read_float(tag, required, default) def read_string(self, tag: int, required: bool = False, default: str = "") -> str: @@ -369,14 +379,16 @@ class TafInputStream: return default dtype = found[1] if dtype == TafType.STRING1: - length = struct.unpack('B', self.buf.read(1))[0] + length = struct.unpack("B", self.buf.read(1))[0] elif dtype == TafType.STRING4: - length = struct.unpack('>I', self.buf.read(4))[0] + length = struct.unpack(">I", self.buf.read(4))[0] else: raise ValueError(f"期望 string, 实际 0x{dtype:02x}") - return self.buf.read(length).decode('utf-8', errors='replace') + return self.buf.read(length).decode("utf-8", errors="replace") - def read_bytes(self, tag: int, required: bool = False, default: bytes = b'') -> bytes: + def read_bytes( + self, tag: int, required: bool = False, default: bytes = b"" + ) -> bytes: found = self._find_tag(tag, required) if not found: return default @@ -388,8 +400,9 @@ class TafInputStream: return self.buf.read(length) # ---- 复合类型 ---- - def read_map(self, tag: int, required: bool = False, - key_reader=None, val_reader=None) -> Dict: + def read_map( + self, tag: int, required: bool = False, key_reader=None, val_reader=None + ) -> Dict: found = self._find_tag(tag, required) if not found: return {} @@ -405,8 +418,7 @@ class TafInputStream: result[k] = v return result - def read_list(self, tag: int, required: bool = False, - item_reader=None) -> List: + def read_list(self, tag: int, required: bool = False, item_reader=None) -> List: found = self._find_tag(tag, required) if not found: return [] @@ -439,13 +451,18 @@ class TafInputStream: return reader(self, 0) # 自动推断 if dtype == TafType.STRING1: - length = struct.unpack('B', self.buf.read(1))[0] - return self.buf.read(length).decode('utf-8', errors='replace') + length = struct.unpack("B", self.buf.read(1))[0] + return self.buf.read(length).decode("utf-8", errors="replace") if dtype == TafType.STRING4: - length = struct.unpack('>I', self.buf.read(4))[0] - return self.buf.read(length).decode('utf-8', errors='replace') - if dtype in (TafType.ZERO, TafType.INT8, TafType.INT16, - TafType.INT32, TafType.INT64): + length = struct.unpack(">I", self.buf.read(4))[0] + return self.buf.read(length).decode("utf-8", errors="replace") + if dtype in ( + TafType.ZERO, + TafType.INT8, + TafType.INT16, + TafType.INT32, + TafType.INT64, + ): return self._read_int_value(dtype) if dtype == TafType.STRUCT_BEGIN: # 未知 struct,跳过 @@ -459,6 +476,7 @@ class TafInputStream: # 结构体基类 # ============================================================ + class TafStruct: """TAF 结构体基类:子类实现 write_to / read_from""" @@ -470,8 +488,7 @@ class TafStruct: def to_dict(self) -> Dict[str, Any]: """调试用:转字典""" - return {k: v for k, v in self.__dict__.items() - if not k.startswith('_')} + return {k: v for k, v in self.__dict__.items() if not k.startswith("_")} def __repr__(self): return f"{self.__class__.__name__}({self.to_dict()})" diff --git a/core/huya/udb_aes.py b/core/huya/udb_aes.py index 5b075ef..8d52d2a 100644 --- a/core/huya/udb_aes.py +++ b/core/huya/udb_aes.py @@ -2,6 +2,7 @@ 基于 AES-128-ECB 与 0 填充。 """ + from __future__ import annotations from Crypto.Cipher import AES diff --git a/core/huya/wup_encoder.py b/core/huya/wup_encoder.py index 589bf72..e2d1b27 100644 --- a/core/huya/wup_encoder.py +++ b/core/huya/wup_encoder.py @@ -134,12 +134,18 @@ def make_user_action(now_ms: int | None = None) -> str: "longitude": "-1.0", "ssid": "", "user_action": [ - {"id": "24", "time": str(t1), - "x": str(random.randint(150, 900)), - "y": str(random.randint(800, 1600))}, - {"id": "11", "time": str(t2), - "x": str(random.randint(150, 900)), - "y": str(random.randint(800, 1600))}, + { + "id": "24", + "time": str(t1), + "x": str(random.randint(150, 900)), + "y": str(random.randint(800, 1600)), + }, + { + "id": "11", + "time": str(t2), + "x": str(random.randint(150, 900)), + "y": str(random.randint(800, 1600)), + }, ], }, ensure_ascii=False, @@ -152,10 +158,17 @@ def make_trace_id(pid: int = 0) -> str: return f"{random.getrandbits(64):016x}-{pid}-{int(_time.time() * 1000)}" -def _build_wup_data(w: _Writer, uid_str: str, sha1_password: str, - safedeviceid: str, hdid: str, session: int, - trace_id: str, user_action_json: str, - device_info: Dict[str, str]) -> None: +def _build_wup_data( + w: _Writer, + uid_str: str, + sha1_password: str, + safedeviceid: str, + hdid: str, + session: int, + trace_id: str, + user_action_json: str, + device_info: Dict[str, str], +) -> None: """编码 _wup_data struct。""" meta_json = _build_meta_json(session, trace_id) name = _make_name(uid_str) @@ -179,7 +192,9 @@ def _build_wup_data(w: _Writer, uid_str: str, sha1_password: str, # -- t1: 设备信息 struct -- di = device_info w.struct_begin(1) - w.string(0, hdid) # t1.t0 = HDID32 (登录帧32hex设备证书, libhydeviceid.so 硬锚, 换→APP_SIGN_NOT_MATCH) + w.string( + 0, hdid + ) # t1.t0 = HDID32 (登录帧32hex设备证书, libhydeviceid.so 硬锚, 换→APP_SIGN_NOT_MATCH) w.string(1, di.get("app_version", "13.4.22")) w.string(2, di.get("sdk_version", "1.0.80138")) w.string(3, "") @@ -224,8 +239,17 @@ def build_password_login_wup( ) -> bytes: """构造密码登录的 WUP TAF 请求体。""" wd = _Writer() - _build_wup_data(wd, uid_str, sha1_password, safedeviceid, hdid, - session, trace_id, user_action_json, device_info) + _build_wup_data( + wd, + uid_str, + sha1_password, + safedeviceid, + hdid, + session, + trace_id, + user_action_json, + device_info, + ) wup_data = wd.get() req = _Writer() diff --git a/core/huya/wup_protocol.py b/core/huya/wup_protocol.py index a0651d8..1197868 100644 --- a/core/huya/wup_protocol.py +++ b/core/huya/wup_protocol.py @@ -9,6 +9,7 @@ Wup 包结构: tag7:sBuffer(bytes), tag8:iTimeout, tag9:context(map), tag10:status(map) sBuffer = Map<"tReq", 编码后的请求结构体> """ + import struct from typing import Any, Dict, Optional from .taf_protocol import TafOutputStream, TafInputStream, TafType, TafStruct @@ -18,16 +19,16 @@ class WupRequest: """Wup 请求对象""" def __init__(self): - self.iVersion: int = 3 # tag 1 - self.cPacketType: int = 0 # tag 2 - self.iMessageType: int = 0 # tag 3 - self.iRequestId: int = 0 # tag 4 - self.sServantName: str = "" # tag 5 - self.sFuncName: str = "" # tag 6 - self.sBuffer: bytes = b'' # tag 7 - self.iTimeout: int = 3000 # tag 8 - self.context: Dict[str, str] = {} # tag 9 - self.status: Dict[str, str] = {} # tag 10 + self.iVersion: int = 3 # tag 1 + self.cPacketType: int = 0 # tag 2 + self.iMessageType: int = 0 # tag 3 + self.iRequestId: int = 0 # tag 4 + self.sServantName: str = "" # tag 5 + self.sFuncName: str = "" # tag 6 + self.sBuffer: bytes = b"" # tag 7 + self.iTimeout: int = 3000 # tag 8 + self.context: Dict[str, str] = {} # tag 9 + self.status: Dict[str, str] = {} # tag 10 self.newdata: Dict[str, bytes] = {} def setServant(self, name: str): @@ -49,7 +50,7 @@ class WupRequest: """ os = TafOutputStream() - if isinstance(struct_data, TafStruct) or hasattr(struct_data, 'write_to'): + if isinstance(struct_data, TafStruct) or hasattr(struct_data, "write_to"): # 结构体对象:STRUCT_BEGIN + 内容 + STRUCT_END os.write_struct(0, struct_data) elif isinstance(struct_data, dict): @@ -83,7 +84,7 @@ class WupRequest: os.write_map(tag, value) elif isinstance(value, (list, tuple)): os.write_list(tag, list(value)) - elif hasattr(value, 'write_to'): + elif hasattr(value, "write_to"): os.write_struct(tag, value) else: raise TypeError(f"不支持的字段类型: {type(value)}") @@ -115,14 +116,14 @@ class WupRequest: # 3. 长度前缀 length = 4 + len(wup_body) - return struct.pack('>I', length) + wup_body + return struct.pack(">I", length) + wup_body def normalize_wup_payload(data: bytes) -> bytes: """去掉可选的 4 字节 WUP 长度前缀,返回裸 WUP body""" if len(data) < 4: return data - declared_len = struct.unpack('>I', data[0:4])[0] + declared_len = struct.unpack(">I", data[0:4])[0] if declared_len == len(data) or declared_len + 4 == len(data): return data[4:] return data @@ -138,7 +139,7 @@ class WupResponse: self.iRequestId: int = 0 self.sServantName: str = "" self.sFuncName: str = "" - self.sBuffer: bytes = b'' + self.sBuffer: bytes = b"" self.iTimeout: int = 0 self.context: Dict[str, str] = {} self.status: Dict[str, str] = {} @@ -249,19 +250,20 @@ class WupResponse: # 辅助:按已知 dtype 读取值 # ============================================================ + def _read_string_value(ins: TafInputStream, dtype: int) -> str: if dtype == TafType.STRING1: - length = struct.unpack('B', ins.buf.read(1))[0] + length = struct.unpack("B", ins.buf.read(1))[0] elif dtype == TafType.STRING4: - length = struct.unpack('>I', ins.buf.read(4))[0] + length = struct.unpack(">I", ins.buf.read(4))[0] else: return "" - return ins.buf.read(length).decode('utf-8', errors='replace') + return ins.buf.read(length).decode("utf-8", errors="replace") def _read_bytes_value(ins: TafInputStream, dtype: int) -> bytes: if dtype != TafType.SIMPLE_LIST: - return b'' + return b"" ins.read_head() # 元素类型 INT8 length = ins._read_int_len() return ins.buf.read(length) @@ -276,6 +278,10 @@ def _read_map_value(ins: TafInputStream, dtype: int) -> Dict: _, kt = ins.read_head() k = _read_string_value(ins, kt) _, vt = ins.read_head() - v = _read_string_value(ins, vt) if vt in (TafType.STRING1, TafType.STRING4) else "" + v = ( + _read_string_value(ins, vt) + if vt in (TafType.STRING1, TafType.STRING4) + else "" + ) result[k] = v return result diff --git a/core/sms_provider.py b/core/sms_provider.py index 6e29b31..834d45f 100644 --- a/core/sms_provider.py +++ b/core/sms_provider.py @@ -58,12 +58,14 @@ def parse_sms_lines(text: str) -> list[SmsLine]: continue if not phone or not url: continue - rows.append(SmsLine( - phone=phone, - url=url, - provider=detect_provider(url), - raw=line, - )) + rows.append( + SmsLine( + phone=phone, + url=url, + provider=detect_provider(url), + raw=line, + ) + ) return rows @@ -133,32 +135,50 @@ class SmsProviderClient: if low.startswith("yes|"): code = extract_sms_code(body) if code: - return SmsPollResult(status="code", code=code, message="收到验证码", raw=body) - return SmsPollResult(status="error", message=f"已收到短信但未识别验证码: {body}", raw=body) + return SmsPollResult( + status="code", code=code, message="收到验证码", raw=body + ) + return SmsPollResult( + status="error", message=f"已收到短信但未识别验证码: {body}", raw=body + ) code = extract_sms_code(body) if code: - return SmsPollResult(status="code", code=code, message="收到验证码", raw=body) + return SmsPollResult( + status="code", code=code, message="收到验证码", raw=body + ) return SmsPollResult(status="waiting", message=body, raw=body) - def _parse_sms8(self, body: str, after_time: datetime | None = None) -> SmsPollResult: + def _parse_sms8( + self, body: str, after_time: datetime | None = None + ) -> SmsPollResult: try: payload = json.loads(body) except json.JSONDecodeError: code = extract_sms_code(body) if code: - return SmsPollResult(status="code", code=code, message="收到验证码", raw=body) - return SmsPollResult(status="error", message=f"短信平台返回非 JSON: {body[:120]}", raw=body) + return SmsPollResult( + status="code", code=code, message="收到验证码", raw=body + ) + return SmsPollResult( + status="error", message=f"短信平台返回非 JSON: {body[:120]}", raw=body + ) data = payload.get("data") if isinstance(payload.get("data"), dict) else {} msg = str(payload.get("msg") or "") code_text = str(data.get("code") or "") code_time = _parse_sms8_time(str(data.get("code_time") or "")) if after_time and code_time and code_time < after_time.replace(tzinfo=None): - return SmsPollResult(status="waiting", message="验证码时间早于本次发码", raw=body) + return SmsPollResult( + status="waiting", message="验证码时间早于本次发码", raw=body + ) code = extract_sms_code(code_text) if int(payload.get("code") or 0) == 1 and code: - return SmsPollResult(status="code", code=code, message="收到验证码", raw=body) + return SmsPollResult( + status="code", code=code, message="收到验证码", raw=body + ) if code: - return SmsPollResult(status="code", code=code, message="收到验证码", raw=body) + return SmsPollResult( + status="code", code=code, message="收到验证码", raw=body + ) return SmsPollResult(status="waiting", message=msg or "暂无验证码", raw=body) diff --git a/docs/Frida绕过配方.md b/docs/Frida绕过配方.md index 497b337..5f0de21 100644 --- a/docs/Frida绕过配方.md +++ b/docs/Frida绕过配方.md @@ -12,11 +12,14 @@ ```python import frida, time -d = frida.get_device_manager().add_remote_device("127.0.0.1:31878") # frida-server -l 0.0.0.0:31878 +d = frida.get_device_manager().add_remote_device( + "127.0.0.1:31878" +) # frida-server -l 0.0.0.0:31878 pid = d.spawn(["com.duowan.kiwi"]) s = d.attach(pid) -from bypass_loader import load_bypass # scripts/ 在 sys.path (同目录运行即可) -load_bypass(s) # ← 唯一的绕过步骤, 可传参 +from bypass_loader import load_bypass # scripts/ 在 sys.path (同目录运行即可) + +load_bypass(s) # ← 唯一的绕过步骤, 可传参 d.resume(pid) # 之后挂业务钩子 (挂业务 JS 的时机不再敏感 — patch_guard 已内置延迟) diff --git a/scripts/migrate_sqlite_to_mysql.py b/scripts/migrate_sqlite_to_mysql.py index 640b9e7..1883cba 100644 --- a/scripts/migrate_sqlite_to_mysql.py +++ b/scripts/migrate_sqlite_to_mysql.py @@ -73,10 +73,14 @@ def _normalize_value(value: object, target_column) -> object: try: return json.loads(value) except json.JSONDecodeError as exc: - raise ValueError(f"列 {target_column.name} 存在非法 JSON:{value[:120]!r}") from exc + raise ValueError( + f"列 {target_column.name} 存在非法 JSON:{value[:120]!r}" + ) from exc -def _source_rows(source_connection: Connection, source_table: Table, target_table: Table) -> Iterable[dict]: +def _source_rows( + source_connection: Connection, source_table: Table, target_table: Table +) -> Iterable[dict]: columns = [column.name for column in target_table.columns] missing_columns = [column for column in columns if column not in source_table.c] if missing_columns: @@ -88,7 +92,9 @@ def _source_rows(source_connection: Connection, source_table: Table, target_tabl primary_key_columns = list(source_table.primary_key.columns) statement = select(*(source_table.c[name] for name in columns)) if primary_key_columns: - statement = statement.order_by(*(column.asc() for column in primary_key_columns)) + statement = statement.order_by( + *(column.asc() for column in primary_key_columns) + ) for row in source_connection.execute(statement).mappings(): yield { @@ -101,12 +107,12 @@ def _ensure_target_is_empty(target_engine: Engine, target_tables: list[Table]) - """拒绝向已有业务数据的 MySQL 写入,防止误覆盖。""" with target_engine.connect() as connection: occupied = [ - table.name - for table in target_tables - if _count_rows(connection, table) > 0 + table.name for table in target_tables if _count_rows(connection, table) > 0 ] if occupied: - raise RuntimeError(f"目标 MySQL 已存在业务数据,拒绝迁移:{', '.join(occupied)}") + raise RuntimeError( + f"目标 MySQL 已存在业务数据,拒绝迁移:{', '.join(occupied)}" + ) def _upgrade_source_sqlite(source_path: Path) -> None: @@ -115,7 +121,11 @@ def _upgrade_source_sqlite(source_path: Path) -> None: source_env = os.environ.copy() source_env["DATABASE_URL"] = f"sqlite:///{source_path}" subprocess.run( - [sys.executable, "-c", "from web.backend.database import run_migrations; run_migrations()"], + [ + sys.executable, + "-c", + "from web.backend.database import run_migrations; run_migrations()", + ], cwd=PROJECT_ROOT, env=source_env, check=True, @@ -141,7 +151,9 @@ def main() -> int: target_url = target_url or DATABASE_URL if not target_url.startswith("mysql+"): - raise ValueError("目标库必须是 MySQL;可传 --target-url,或设置 DB_HOST/DB_* 环境变量") + raise ValueError( + "目标库必须是 MySQL;可传 --target-url,或设置 DB_HOST/DB_* 环境变量" + ) print("正在初始化目标 MySQL 表结构...") run_migrations() @@ -153,7 +165,9 @@ def main() -> int: try: source_table_names = set(inspect(source_engine).get_table_names()) - unexpected_tables = source_table_names - target_table_names - IGNORED_SOURCE_TABLES + unexpected_tables = ( + source_table_names - target_table_names - IGNORED_SOURCE_TABLES + ) if unexpected_tables: raise RuntimeError( "源 SQLite 存在当前程序无法识别的表:" @@ -171,7 +185,10 @@ def main() -> int: _ensure_target_is_empty(target_engine, tables_to_copy) - with source_engine.connect() as source_connection, target_engine.begin() as target_connection: + with ( + source_engine.connect() as source_connection, + target_engine.begin() as target_connection, + ): for target_table in tables_to_copy: source_table = source_metadata.tables[target_table.name] source_count = _count_rows(source_connection, source_table) @@ -184,7 +201,10 @@ def main() -> int: for batch in _chunks(rows, args.batch_size): target_connection.execute(target_table.insert(), batch) - with source_engine.connect() as source_connection, target_engine.connect() as target_connection: + with ( + source_engine.connect() as source_connection, + target_engine.connect() as target_connection, + ): for target_table in tables_to_copy: source_table = source_metadata.tables[target_table.name] source_count = _count_rows(source_connection, source_table) diff --git a/services/yyb-worker/runtime/main.py b/services/yyb-worker/runtime/main.py index 2e6d540..90f9d9f 100644 --- a/services/yyb-worker/runtime/main.py +++ b/services/yyb-worker/runtime/main.py @@ -17,6 +17,7 @@ python3 main.py sample-session # 用迁移的 live 捕获生成示例会话态 python3 main.py sample-order # 用迁移的 order7 生成示例订单 """ + from __future__ import annotations import argparse @@ -50,14 +51,31 @@ from pyvm.protocol import ( # noqa: E402 REPLAY = ROOT / "replay" DEFAULT_APPID = PAY_APPID DEFAULT_SAVE_URL = f"https://api.unipay.qq.com/v1/r/{DEFAULT_APPID}/web_save" -ORDER_FIELDS = ["token_id", "openid", "openkey", "session_id", "session_type", "zoneid", - "pay_method", "buy_quantity", "mb_pwd", "pay_id", "auth_key", - "card_value", "accounttype", "provide_uin", "extend", "ts", - "from_h5", "webversion"] +ORDER_FIELDS = [ + "token_id", + "openid", + "openkey", + "session_id", + "session_type", + "zoneid", + "pay_method", + "buy_quantity", + "mb_pwd", + "pay_id", + "auth_key", + "card_value", + "accounttype", + "provide_uin", + "extend", + "ts", + "from_h5", + "webversion", +] # ---------------------------------------------------------------- 工具 + def _write_private_text(path: Path, content: str) -> None: """写入任务协议证据并限制为当前用户可读。""" path.parent.mkdir(parents=True, exist_ok=True) @@ -65,6 +83,7 @@ def _write_private_text(path: Path, content: str) -> None: path.write_text(content, encoding="utf-8") os.chmod(path, 0o600) + def _load_cap(path: Path) -> dict: """加载 deepCap 捕获 JSON,兼容三种存档格式,返回顶层 dict(含 C 键)。 @@ -113,32 +132,47 @@ def parse_plaintext(path: str | Path) -> dict: # ---------------------------------------------------------------- 命令 + def cmd_verify(args=None) -> int: """校验当前受版本控制的 goods 协议材料,不依赖已删除的历史抓包。""" args_path = REPLAY / "live" / "default" / "args-template.json" args_template = json.loads(args_path.read_text(encoding="utf-8")) validate_goods_materials(args_template) - required = ("bytecode.json", "constants.json", "xmidasops.json", "e2e/multi-1.json", - "e2e/ws-args.json", "deepcaps2/cap-69667.json") + required = ( + "bytecode.json", + "constants.json", + "xmidasops.json", + "e2e/multi-1.json", + "e2e/ws-args.json", + "deepcaps2/cap-69667.json", + ) missing = [name for name in required if not (REPLAY / name).exists()] if missing: print(f"❌ goods 协议材料缺失: {', '.join(missing)}") return 1 print("✅ goods 协议材料校验通过(18 槽模板、Te/S-box、VM 文件均完整)") - print(" 历史逐字节黄金向量未随仓库保留;如需恢复,可放入 replay/golden 后由 CI 自动执行。") + print( + " 历史逐字节黄金向量未随仓库保留;如需恢复,可放入 replay/golden 后由 CI 自动执行。" + ) return 0 def _gen_with_session(st: SessionState, order: dict) -> str: """用会话态 + 订单参数生成 encrypt_msg(args_template 会话绑定,必须用捕获值)。""" from pyvm.algorithm import decode_d + params = {k: order.get(k, "") for k in ORDER_FIELDS} args_tpl = decode_d(st.args_template_d) return generate_encrypt_msg_offline( - params, order.get("fk_extend", ""), order.get("ts", ""), order.get("_rand", ""), - xmidas=st.xmidas_ops, xmidas_token=st.xmidas_token, + params, + order.get("fk_extend", ""), + order.get("ts", ""), + order.get("_rand", ""), + xmidas=st.xmidas_ops, + xmidas_token=st.xmidas_token, args_template=args_tpl, - key16=st.key16, key1=st.key1, + key16=st.key16, + key1=st.key1, ) @@ -186,7 +220,8 @@ def cmd_submit(args) -> int: url = args.url or DEFAULT_SAVE_URL cookie_str = "; ".join(f"{k}={v}" for k, v in st.cookies.items()) req = urllib.request.Request( - url, data=body.encode("utf-8"), + url, + data=body.encode("utf-8"), headers={ "User-Agent": GOODS_USER_AGENT, "Content-Type": "application/x-www-form-urlencoded", @@ -221,6 +256,7 @@ def cmd_submit(args) -> int: def cmd_sample_session(args) -> int: """用迁移的 live 捕获生成示例会话态(key16/key1/xmidas_ops)。""" from pyvm.algorithm import decode_d + web_args = decode_d(_load_cap(REPLAY / "live/caps7/cap-85091.json")["C"][2]) cap7 = _load_cap(REPLAY / "live/caps7/cap-85091.json") st = SessionState( @@ -228,7 +264,9 @@ def cmd_sample_session(args) -> int: key16=list(web_args[6][0]), key1=list(web_args[0][0]), xmidas_token="DE46DBA4754D42A6B66ADD4319FF80C144D9ED22ED73384C271793D9A9AA2FFBD6C104BE8D4A7F4ED2A8688FCB6F7540", - args_template_d=cap7["C"][2] if isinstance(cap7["C"][2], str) else json.dumps(cap7["C"][2], ensure_ascii=False), + args_template_d=cap7["C"][2] + if isinstance(cap7["C"][2], str) + else json.dumps(cap7["C"][2], ensure_ascii=False), cookies={}, source="live/order7 (归档示例,cookies 为空)", ) @@ -252,6 +290,7 @@ def cmd_sample_order(args) -> int: # ---------------------------------------------------------------- mall 命令 + def cmd_mall_verify(args=None) -> int: """校验当前受版本控制的 mall VM 和固定槽模板。""" fixed_path = REPLAY / "mall" / "transform-fixed.json" @@ -282,7 +321,9 @@ def cmd_mall_gen(args) -> int: def cmd_mall_sample(args) -> int: """用归档同会话黄金对生成示例 mall 会话态(config/mall-session.json)。""" - g = json.loads((ROOT / "config/golden/golden-final.json").read_text(encoding="utf-8")) + g = json.loads( + (ROOT / "config/golden/golden-final.json").read_text(encoding="utf-8") + ) session = MallSession(g["transform_input"], g["xmidas_ops"]) out = Path(args.output) if args.output else ROOT / "config" / "mall-session.json" out.parent.mkdir(parents=True, exist_ok=True) @@ -325,7 +366,8 @@ def cmd_mall_submit(args) -> int: if "midas_openkey" not in cookies and "accesstoken" in cookies: cookie_str += "; midas_openkey=" + cookies["accesstoken"] req = urllib.request.Request( - url, data=new_body.encode("utf-8"), + url, + data=new_body.encode("utf-8"), headers={ "Content-Type": "application/json", "Accept": "application/json, text/plain, */*", @@ -346,7 +388,11 @@ def cmd_mall_submit(args) -> int: print(f"[submit] 网络错误: {e!r}") return 3 print(f"[submit] 响应: {raw[:800]}") - out = Path(args.output) if args.output else ROOT / "config" / "mall-order-response.json" + out = ( + Path(args.output) + if args.output + else ROOT / "config" / "mall-order-response.json" + ) js = None ret = None try: @@ -370,7 +416,9 @@ def cmd_mall_submit(args) -> int: print(f" 失败响应 → {fail_out}(保留上次成功响应)") if ret in ("1018", 1018): print(" 原因: mall 登录态失效——请重新在浏览器登录并采集 mall 会话态") - print(" (node scripts/capture-mall-data.mjs,或手动刷新 mall-session.json 的 cookies)") + print( + " (node scripts/capture-mall-data.mjs,或手动刷新 mall-session.json 的 cookies)" + ) return 1 @@ -383,11 +431,15 @@ def cmd_mall_capture(args) -> int: def main() -> int: - ap = argparse.ArgumentParser(description="YYB 加密参数生成框架(goods web_save + mall PlaceOrder)") + ap = argparse.ArgumentParser( + description="YYB 加密参数生成框架(goods web_save + mall PlaceOrder)" + ) sub = ap.add_subparsers(dest="module", required=True) # ---- goods 组 ---- - pg = sub.add_parser("goods", help="goods 侧(web_save / web_new_encrypt,CHAOS VM 116 opcode)") + pg = sub.add_parser( + "goods", help="goods 侧(web_save / web_new_encrypt,CHAOS VM 116 opcode)" + ) gsub = pg.add_subparsers(dest="cmd", required=True) gsub.add_parser("verify", help="校验当前 goods 协议材料") p = gsub.add_parser("gen", help="仅生成 encrypt_msg(不联网)") @@ -416,30 +468,64 @@ def main() -> int: p.add_argument("--frames", default=None, help="最新捕获的 frames.jsonl(优先)") p.add_argument("--output", default=None) p = msub.add_parser("submit", help="生成 encrypt_msg + 提交 PlaceOrder(纯 Python)") - p.add_argument("--session", required=True, help="mall-session.json(含 transform_input/xmidas/cookies)") - p.add_argument("--order-template", default=str(ROOT / "config" / "mall-order-template.json")) + p.add_argument( + "--session", + required=True, + help="mall-session.json(含 transform_input/xmidas/cookies)", + ) + p.add_argument( + "--order-template", default=str(ROOT / "config" / "mall-order-template.json") + ) p.add_argument("--output", default=None) - p = msub.add_parser("auto", help="仅凭 CK 全自动下单(纯HTTP GetPayToken + 纯Python encrypt_msg,无浏览器采集)") + p = msub.add_parser( + "auto", + help="仅凭 CK 全自动下单(纯HTTP GetPayToken + 纯Python encrypt_msg,无浏览器采集)", + ) p.add_argument("--session", default=str(ROOT / "config" / "mall-session.json")) - p.add_argument("--order-template", default=str(ROOT / "config" / "mall-order-template.json")) - p.add_argument("--product-id", default=None, help="和平精英点券商品 ID;默认使用模板当前商品") + p.add_argument( + "--order-template", default=str(ROOT / "config" / "mall-order-template.json") + ) + p.add_argument( + "--product-id", default=None, help="和平精英点券商品 ID;默认使用模板当前商品" + ) p.add_argument("--offer-id", default=None, help="当前商品服务端 offer ID") - p.add_argument("--quantity", type=int, default=None, help="当前点券商品购买份数(正整数)") + p.add_argument( + "--quantity", type=int, default=None, help="当前点券商品购买份数(正整数)" + ) p.add_argument("--role-id", default=None, help="游戏角色 ID;默认使用模板当前角色") - p.add_argument("--role-name", default=None, help="游戏角色名称;默认使用模板当前角色") + p.add_argument( + "--role-name", default=None, help="游戏角色名称;默认使用模板当前角色" + ) p.add_argument("--zone-id", default=None, help="游戏区服 ID;默认使用模板当前区服") p.add_argument("--zone-name", default=None, help="游戏区服名称") - p.add_argument("--area", default=None, help="游戏大区 ID(QQ 平台与 zoneid 不同,来自角色查询的 partition_info)") + p.add_argument( + "--area", + default=None, + help="游戏大区 ID(QQ 平台与 zoneid 不同,来自角色查询的 partition_info)", + ) p.add_argument("--partition", default=None, help="游戏分区 ID(QQ 平台可为空)") - p.add_argument("--pf", default=None, help="支付平台标识;由角色选择器按 Android/iOS 写入") + p.add_argument( + "--pf", default=None, help="支付平台标识;由角色选择器按 Android/iOS 写入" + ) p.add_argument("--output", default=None) - p = msub.add_parser("pay", help="mall 下单 -> goods web_save -> 微信支付二维码(纯 Python)") - p.add_argument("--mall-response", default=str(ROOT / "config" / "mall-order-response.json"), - help="PlaceOrder 响应(含 url_params/token)") - p.add_argument("--goods-dir", default=str(ROOT / "replay" / "live" / "order10"), - help="goods 会话态目录(plaintext/body/xmidasops/keys/cap/web-token)") - p.add_argument("--session", default=str(ROOT / "config" / "mall-session.json"), - help="mall 登录态(含 cookies)") + p = msub.add_parser( + "pay", help="mall 下单 -> goods web_save -> 微信支付二维码(纯 Python)" + ) + p.add_argument( + "--mall-response", + default=str(ROOT / "config" / "mall-order-response.json"), + help="PlaceOrder 响应(含 url_params/token)", + ) + p.add_argument( + "--goods-dir", + default=str(ROOT / "replay" / "live" / "order10"), + help="goods 会话态目录(plaintext/body/xmidasops/keys/cap/web-token)", + ) + p.add_argument( + "--session", + default=str(ROOT / "config" / "mall-session.json"), + help="mall 登录态(含 cookies)", + ) p.add_argument("--appid", default=DEFAULT_APPID) p.add_argument("--output", default=None, help="二维码 PNG 输出路径") msub.add_parser("capture", help="用浏览器捕获 mall 会话态(Node 脚本)") @@ -447,20 +533,29 @@ def main() -> int: args = ap.parse_args() try: if args.module == "goods": - return {"verify": cmd_verify, "gen": cmd_gen, "submit": cmd_submit, - "sample-session": cmd_sample_session, "sample-order": cmd_sample_order}[args.cmd](args) + return { + "verify": cmd_verify, + "gen": cmd_gen, + "submit": cmd_submit, + "sample-session": cmd_sample_session, + "sample-order": cmd_sample_order, + }[args.cmd](args) elif args.module == "mall": - return {"verify": cmd_mall_verify, "gen": cmd_mall_gen, "submit": cmd_mall_submit, - "sample-session": cmd_mall_sample, "pay": cmd_mall_pay, "auto": cmd_mall_auto, - "capture": cmd_mall_capture}[args.cmd](args) + return { + "verify": cmd_mall_verify, + "gen": cmd_mall_gen, + "submit": cmd_mall_submit, + "sample-session": cmd_mall_sample, + "pay": cmd_mall_pay, + "auto": cmd_mall_auto, + "capture": cmd_mall_capture, + }[args.cmd](args) return 2 except (FileNotFoundError, ValueError) as e: print(f"错误: {e}") return 2 - - def build_mall_transform(fixed: dict) -> list: """仅CK全自动:固定槽模板 + 随机 key16/槽6/明文缓冲 构造 transform_input。 @@ -487,6 +582,7 @@ def mall_getpaytoken(cookies: dict) -> tuple[list, str]: 证据: evidence/getpaytoken-pure-http.json """ import urllib.request + login = midas_login_params(cookies) login["offer_id"] = "800001492" body = { @@ -494,8 +590,12 @@ def mall_getpaytoken(cookies: dict) -> tuple[list, str]: "call_param": { "call_func": "GetPayToken", "call_param_json": json.dumps( - {"version": "pagedoo-v2.0.0", "app_id": "202406061128117473047424", - "content_id": "ct1755160919_GEOCGTMN"}), + { + "version": "pagedoo-v2.0.0", + "app_id": "202406061128117473047424", + "content_id": "ct1755160919_GEOCGTMN", + } + ), "call_type": "security_service", "login_check_param_json": json.dumps(login), }, @@ -505,12 +605,19 @@ def mall_getpaytoken(cookies: dict) -> tuple[list, str]: for k, v in [("midas_openid", "openid"), ("midas_openkey", "accesstoken")]: if k not in cookies and v in cookies: cookie_str += f"; {k}=" + cookies[v] - req = urllib.request.Request(url, data=json.dumps(body).encode("utf-8"), headers={ - "Content-Type": "application/json", "Accept": "application/json, text/plain, */*", - "Origin": "https://z.iwan.yyb.qq.com", "Referer": "https://z.iwan.yyb.qq.com/", - "Cookie": cookie_str, - "User-Agent": MALL_USER_AGENT, - }, method="POST") + req = urllib.request.Request( + url, + data=json.dumps(body).encode("utf-8"), + headers={ + "Content-Type": "application/json", + "Accept": "application/json, text/plain, */*", + "Origin": "https://z.iwan.yyb.qq.com", + "Referer": "https://z.iwan.yyb.qq.com/", + "Cookie": cookie_str, + "User-Agent": MALL_USER_AGENT, + }, + method="POST", + ) try: with urllib.request.urlopen(req, timeout=30) as r: raw = r.read().decode("utf-8", "replace") @@ -549,7 +656,11 @@ def _apply_card_selection(payload: dict, args) -> None: if quantity is not None and quantity <= 0: raise ValueError("--quantity 必须是正整数") products = payload.get("product_list") - if not isinstance(products, list) or not products or not isinstance(products[0], dict): + if ( + not isinstance(products, list) + or not products + or not isinstance(products[0], dict) + ): raise ValueError("订单模板缺少 product_list[0]") product = products[0] product_id = getattr(args, "product_id", None) @@ -617,7 +728,9 @@ def cmd_mall_auto(args) -> int: return 1 print(f"[auto] arrays {len(arrays)} | pay_token {pay_token[:16]}...") print("[auto] ② 构造 transform_input(固定槽 + 随机 key/明文缓冲)...") - fixed = json.loads((ROOT / "replay/mall/transform-fixed.json").read_text(encoding="utf-8")) + fixed = json.loads( + (ROOT / "replay/mall/transform-fixed.json").read_text(encoding="utf-8") + ) validate_mall_materials(fixed) ti = build_mall_transform(fixed) print("[auto] ③ 纯 Python 生成 encrypt_msg...") @@ -639,18 +752,29 @@ def cmd_mall_auto(args) -> int: if k not in cookies and v in cookies: cookie_str += f"; {k}=" + cookies[v] url = MALL_API_URL + "?t=" + str(int(time.time() * 1000)) - req = urllib.request.Request(url, data=new_body.encode("utf-8"), headers={ - "Content-Type": "application/json", "Accept": "application/json, text/plain, */*", - "Origin": "https://z.iwan.yyb.qq.com", "Referer": "https://z.iwan.yyb.qq.com/", - "Cookie": cookie_str, - "User-Agent": MALL_USER_AGENT, - }, method="POST") + req = urllib.request.Request( + url, + data=new_body.encode("utf-8"), + headers={ + "Content-Type": "application/json", + "Accept": "application/json, text/plain, */*", + "Origin": "https://z.iwan.yyb.qq.com", + "Referer": "https://z.iwan.yyb.qq.com/", + "Cookie": cookie_str, + "User-Agent": MALL_USER_AGENT, + }, + method="POST", + ) try: with urllib.request.urlopen(req, timeout=30) as r: raw = r.read().decode("utf-8", "replace") except urllib.error.HTTPError as e: raw = e.read().decode("utf-8", "replace") - out = Path(args.output) if args.output else ROOT / "config" / "mall-order-response.json" + out = ( + Path(args.output) + if args.output + else ROOT / "config" / "mall-order-response.json" + ) _write_private_text(out, raw) js = json.loads(raw) ret = js.get("result_code") @@ -669,7 +793,9 @@ def cmd_mall_auto(args) -> int: return 0 detail = js.get("result_info", "") or call_reply.get("result_info", "") or "" print(f"❌ 下单失败 ret={ret} inner={inner_ret} ({detail})") - print(f" {describe_payment_failure(str(cookies.get('logintype', '')), 'order', f'{ret} {inner_ret} {detail}')}") + print( + f" {describe_payment_failure(str(cookies.get('logintype', '')), 'order', f'{ret} {inner_ret} {detail}')}" + ) print(f" 响应已保存 → {out}") return 1 @@ -714,10 +840,13 @@ def cmd_mall_pay(args) -> int: cr = json.loads(resp["data"]["call_reply"]) up = cr["data"]["url_params"] from urllib.parse import parse_qs, urlparse + q = parse_qs(urlparse(up).query) mall_tid = q.get("token_id", [""])[0] if mall_tid and mall_tid != token_id: - print(f"⚠️ mall 响应订单({mall_tid[:16]}...)与捕获会话订单({token_id[:16]}...)不一致") + print( + f"⚠️ mall 响应订单({mall_tid[:16]}...)与捕获会话订单({token_id[:16]}...)不一致" + ) print(" goods web_token 绑定捕获页面订单,以捕获 body 订单为准继续") except Exception as e: # noqa: BLE001 print(f"⚠️ mall 响应读取失败(忽略): {e!r}") @@ -727,6 +856,7 @@ def cmd_mall_pay(args) -> int: keys = json.loads((gd / "keys.json").read_text(encoding="utf-8")) xmidas = json.loads((gd / "xmidasops.json").read_text(encoding="utf-8")) from pyvm.algorithm import decode_d, recover_plaintext_from_buffer + at_file = gd / "args-template.json" if at_file.exists(): # 新版采集:decoded 18参直接读取(capture-goods-session.mjs J 转储) @@ -740,8 +870,10 @@ def cmd_mall_pay(args) -> int: if not web_token: m = re.search(r"web_token=([0-9A-F]+)", body_tpl) web_token = m.group(1) if m else "" - print(f"[pay] goods 会话态: xmidasops={len(xmidas)} key16={'有' if keys.get('key16') else '无'} " - f"web_token={web_token[:12]}...") + print( + f"[pay] goods 会话态: xmidasops={len(xmidas)} key16={'有' if keys.get('key16') else '无'} " + f"web_token={web_token[:12]}..." + ) # 3. 恢复页面真实明文(ts/fk_extend/_rand),刷新 ts 为当前一致值 try: @@ -761,22 +893,37 @@ def cmd_mall_pay(args) -> int: now_ms = int(time.time() * 1000) params["ts"] = str(now_ms // 1000) if rec_ts: - print(f"[pay] 页面真实明文: ts={rec_ts}(捕获) -> 使用当前 ts={params['ts']} " - f"(E3:ts 与 body t 一致即可,不必等于页面值)") + print( + f"[pay] 页面真实明文: ts={rec_ts}(捕获) -> 使用当前 ts={params['ts']} " + f"(E3:ts 与 body t 一致即可,不必等于页面值)" + ) print("[pay] 生成 goods encrypt_msg(纯 Python)...") hex_msg = generate_encrypt_msg_offline( - params, fk_extend, params["ts"], rand_val, - xmidas=xmidas, args_template=web_args, - key16=keys.get("key16"), key1=keys.get("key1"), + params, + fk_extend, + params["ts"], + rand_val, + xmidas=xmidas, + args_template=web_args, + key16=keys.get("key16"), + key1=keys.get("key1"), ) print(f"[pay] encrypt_msg ({len(hex_msg)} hex): {hex_msg[:32]}...") # 4. 构造 web_save body(会话订单 token + 当前动态值 + 纯 py encrypt_msg) body = body_tpl - for k, v in [("token_id", token_id), ("transaction_id", transaction_id), - ("out_trade_no", out_trade_no), ("offer_type", offer_type)]: + for k, v in [ + ("token_id", token_id), + ("transaction_id", transaction_id), + ("out_trade_no", out_trade_no), + ("offer_type", offer_type), + ]: body = re.sub(rf"{k}=[^&]*", f"{k}=" + v, body, count=1) - body = re.sub(r"pc_st=[^&]+", "pc_st=" + str(uuid.uuid4()).upper() + str(int(time.time() * 1000)), body) + body = re.sub( + r"pc_st=[^&]+", + "pc_st=" + str(uuid.uuid4()).upper() + str(int(time.time() * 1000)), + body, + ) body = re.sub(r"r=[0-9.]+", "r=" + str(secrets.SystemRandom().random()), body) body = re.sub(r"&t=[0-9]+", "&t=" + str(int(time.time() * 1000)), body) body = re.sub(r"encrypt_msg=[0-9a-f]+", "encrypt_msg=" + hex_msg, body) @@ -798,7 +945,8 @@ def cmd_mall_pay(args) -> int: # 6. POST web_save url = f"https://api.unipay.qq.com/v1/r/{args.appid}/web_save" req = urllib.request.Request( - url, data=body.encode("utf-8"), + url, + data=body.encode("utf-8"), headers={ "User-Agent": MALL_USER_AGENT, "Content-Type": "application/x-www-form-urlencoded", @@ -855,5 +1003,6 @@ def cmd_mall_pay(args) -> int: pass return 0 + if __name__ == "__main__": sys.exit(main()) diff --git a/services/yyb-worker/runtime/pyvm/__init__.py b/services/yyb-worker/runtime/pyvm/__init__.py index a00334c..821fb41 100644 --- a/services/yyb-worker/runtime/pyvm/__init__.py +++ b/services/yyb-worker/runtime/pyvm/__init__.py @@ -6,6 +6,7 @@ - mall.py: mall 侧高层 API(MallSession + generate_encrypt_msg) - session.py: 会话态模型 """ + from .algorithm import ( build_plaintext, decode_d, diff --git a/services/yyb-worker/runtime/pyvm/algorithm.py b/services/yyb-worker/runtime/pyvm/algorithm.py index 09aa5f7..3828de9 100644 --- a/services/yyb-worker/runtime/pyvm/algorithm.py +++ b/services/yyb-worker/runtime/pyvm/algorithm.py @@ -11,6 +11,7 @@ 本文件复刻解释器语义,可在无浏览器环境运行;两组独立真实向量均 1056/1056 逐字节一致。 """ + from __future__ import annotations import itertools import json @@ -24,6 +25,7 @@ REPLAY = Path(__file__).resolve().parent.parent / "replay" M32 = 0xFFFFFFFF + def _ic(x): # JS ToInt32 操作数强转:undefined/null/NaN->0,浮点截断,字符串解析 if x is UNDEF or x is None: @@ -40,26 +42,33 @@ def _ic(x): except Exception: return 0 + def to_i32(x): return _ic(x) & M32 + def i32(x): """JS 位运算结果:有符号 32 位。""" v = _ic(x) & M32 return v - (1 << 32) if v & 0x80000000 else v + def u32(x): return _ic(x) & M32 + def ushr(x, n): return u32(x) >> (_ic(n) & 31) + def shl(x, n): return i32(u32(x) << (_ic(n) & 31)) + def shr(x, n): return i32(_ic(x) >> (_ic(n) & 31)) + def js_typeof(v): if v is None or v is UNDEF: return "undefined" @@ -73,6 +82,7 @@ def js_typeof(v): return "function" return "object" + def js_truthy(v): if v is None or v is UNDEF: return False @@ -84,24 +94,29 @@ def js_truthy(v): return len(v) > 0 return True + def _nan_ok(x): try: return not (isinstance(x, float) and math.isnan(x)) except Exception: return True + def _cmp_lt(a, b): x, y = js_num(a), js_num(b) return x < y if (_nan_ok(x) and _nan_ok(y)) else False + def _cmp_le(a, b): x, y = js_num(a), js_num(b) return x <= y if (_nan_ok(x) and _nan_ok(y)) else False + def _cmp_gt(a, b): x, y = js_num(a), js_num(b) return x > y if (_nan_ok(x) and _nan_ok(y)) else False + def _cmp_ge(a, b): x, y = js_num(a), js_num(b) return x >= y if (_nan_ok(x) and _nan_ok(y)) else False @@ -110,39 +125,56 @@ def _cmp_ge(a, b): class _Undef: def __repr__(self): return "undefined" + def __bool__(self): return False + def __eq__(self, other): return other is UNDEF or other is None + def __hash__(self): return hash("__undef__") + def __int__(self): return 0 + def __index__(self): return 0 + def __xor__(self, o): return 0 ^ int(o) + def __rxor__(self, o): return int(o) ^ 0 + def __and__(self, o): return 0 & int(o) + def __rand__(self, o): return int(o) & 0 + def __or__(self, o): return 0 | int(o) + def __ror__(self, o): return int(o) | 0 + def __lshift__(self, o): return 0 << int(o) + def __rlshift__(self, o): return int(o) << 0 + def __rshift__(self, o): return 0 >> int(o) + def __rrshift__(self, o): return int(o) >> 0 + UNDEF = _Undef() + def js_str(v): if v is UNDEF or v is None: return "undefined" @@ -152,12 +184,19 @@ def js_str(v): return str(int(v)) return str(v) + def js_add(a, b): # JS + 运算符:仅当两侧均为 number 时数值相加,否则字符串拼接 - if isinstance(a, (int, float)) and not isinstance(a, bool) and isinstance(b, (int, float)) and not isinstance(b, bool): + if ( + isinstance(a, (int, float)) + and not isinstance(a, bool) + and isinstance(b, (int, float)) + and not isinstance(b, bool) + ): return a + b return js_str(a) + js_str(b) + def js_num(v): if v is UNDEF or v is None: return float("nan") @@ -170,6 +209,7 @@ def js_num(v): except Exception: return float("nan") + def js_eq(a, b): """JS 松散 ==(本 VM 用到的主要情形)。""" if a is UNDEF or a is None: @@ -194,16 +234,23 @@ def js_eq(a, b): return False return a == b + def js_streq(a, b): - return a is b or (a is not UNDEF and b is not UNDEF and type(a) is type(b) and a == b) + return a is b or ( + a is not UNDEF and b is not UNDEF and type(a) is type(b) and a == b + ) + def js_index(obj, key): """obj[key](含数组越界/稀疏语义)。""" if isinstance(key, float) and key.is_integer(): key = int(key) if obj is None or obj is UNDEF: - raise TypeError("Cannot read properties of " + ("null" if obj is None else "undefined") + - f" key={key!r} u={getattr(VM, '_last_u', None)}") + raise TypeError( + "Cannot read properties of " + + ("null" if obj is None else "undefined") + + f" key={key!r} u={getattr(VM, '_last_u', None)}" + ) if isinstance(obj, str): if isinstance(key, int): if 0 <= key < len(obj): @@ -225,18 +272,31 @@ def js_index(obj, key): return UNDEF if key == "length": return len(obj) - if isinstance(key, str) and key.lstrip('-').isdigit(): + if isinstance(key, str) and key.lstrip("-").isdigit(): i = int(key) if 0 <= i < len(obj): return obj[i] - if key in ("push", "shift", "join", "slice", "indexOf", "concat", "reverse", "splice", "map", "forEach", "pop", "unshift"): + if key in ( + "push", + "shift", + "join", + "slice", + "indexOf", + "concat", + "reverse", + "splice", + "map", + "forEach", + "pop", + "unshift", + ): return ARRAY_METHODS[key] return UNDEF if isinstance(obj, dict): if key in obj: return obj[key] # 数字键 - if isinstance(key, str) and key.lstrip('-').isdigit(): + if isinstance(key, str) and key.lstrip("-").isdigit(): return obj.get(int(key), UNDEF) return UNDEF if isinstance(obj, JSObject): @@ -251,6 +311,7 @@ def js_index(obj, key): except (AttributeError, TypeError): return UNDEF + def js_set(obj, key, val): if isinstance(key, float) and key.is_integer(): key = int(key) @@ -261,9 +322,9 @@ def js_set(obj, key, val): obj[key] = val return if key == "length": - del obj[int(val):] + del obj[int(val) :] return - if isinstance(key, str) and key.lstrip('-').isdigit(): + if isinstance(key, str) and key.lstrip("-").isdigit(): i = int(key) while len(obj) <= i: obj.append(UNDEF) @@ -280,7 +341,7 @@ def js_set(obj, key, val): obj.set(key, val) return # 通用宿主对象:支持 setattr(如 loader 的属性) - if hasattr(obj, '__dict__') and not isinstance(obj, (str, bytes, int, float)): + if hasattr(obj, "__dict__") and not isinstance(obj, (str, bytes, int, float)): try: setattr(obj, str(key), val) return @@ -293,8 +354,13 @@ def js_set(obj, key, val): pass if isinstance(obj, str): raise TypeError("Cannot assign to read only property") - cur = getattr(getattr(__import__('pyvm.algorithm', fromlist=['x']), 'VM'), '_last_u', None) - raise TypeError(f"invalid set target obj={type(obj).__name__} key={key!r} val={type(val).__name__} u={cur}") + cur = getattr( + getattr(__import__("pyvm.algorithm", fromlist=["x"]), "VM"), "_last_u", None + ) + raise TypeError( + f"invalid set target obj={type(obj).__name__} key={key!r} val={type(val).__name__} u={cur}" + ) + def js_del(obj, key): if isinstance(obj, list): @@ -305,6 +371,7 @@ def js_del(obj, key): return obj.delete(key) return False + def js_keys(obj): if isinstance(obj, list): return [str(i) for i in range(len(obj))] @@ -314,32 +381,45 @@ def js_keys(obj): return list(obj.d.keys()) return [] + class JSObject: """普通 JS 对象(可含任意键)。""" + def __init__(self): self.d = {} + def get(self, key): return self.d.get(key, UNDEF) + def set(self, key, val): self.d[key] = val + def delete(self, key): return self.d.pop(key, None) is not None + def __repr__(self): return "JSObject(" + ",".join(str(k) for k in list(self.d)[:8]) + ")" + class Window: def __init__(self): self.d = {} + def get(self, key): return self.d.get(key, UNDEF) + def set(self, key, val): self.d[key] = val + def __repr__(self): return "" + class JSFunction: """VM 函数(解释器实例工厂返回的 h)。""" + __slots__ = ("entry", "args", "s", "n", "t", "vm", "name") + def __init__(self, vm, entry, args, s, n, t): self.vm = vm self.entry = entry @@ -348,19 +428,25 @@ class JSFunction: self.n = n self.t = t self.name = "h" + def __repr__(self): return "f:h" + class HostFunction: __slots__ = ("fn", "name") + def __init__(self, fn, name="anon"): self.fn = fn self.name = name + def __call__(self, this, *args): return self.fn(this, *args) + def __repr__(self): return "f:" + self.name + def js_call(fn, this, args): """fn.call(this, ...args) —— 兼容 VM 函数与宿主函数。""" if isinstance(fn, JSFunction): @@ -371,11 +457,14 @@ def js_call(fn, this, args): return fn(this, *args) raise TypeError(f"{str(fn)} is not a function u={getattr(VM, '_last_u', None)}") + def js_apply(fn, this, args): return js_call(fn, this, args) + # ---------------------------------------------------------------- 宿主函数 + def h_to_charcode(this, s, i=None): if i is None: # String.fromCharCode @@ -384,28 +473,41 @@ def h_to_charcode(this, s, i=None): return ord(s[i]) return float("nan") + ARRAY_METHODS = {} STRING_METHODS = {} + + def _arr_push(this, *args): for a in args: this.append(a) return len(this) + + def _arr_shift(this): if not this: return UNDEF return this.pop(0) + + def _arr_join(this, sep=None): parts = [] for x in this: parts.append("" if x is UNDEF or x is None else str(x)) return ("," if sep is None else sep).join(parts) + + def _arr_slice(this, a=None, b=None): return this[slice(a, b)] if (a is not None and b is not None) else this[a:] + + def _arr_indexof(this, x, frm=0): try: return this.index(x, frm or 0) except ValueError: return -1 + + def _arr_concat(this, *others): out = list(this) for o in others: @@ -414,162 +516,269 @@ def _arr_concat(this, *others): else: out.append(o) return out + + def _arr_pop(this): if not this: return UNDEF return this.pop() + + def _arr_unshift(this, *vals): for v in reversed(vals): this.insert(0, v) return len(this) + + def _arr_reverse(this): this.reverse() return this + + def _arr_splice(this, start, delete_count=0, *items): - del this[start:start + delete_count] + del this[start : start + delete_count] for i, it in enumerate(items): this.insert(start + i, it) return this + + def _arr_map(this, fn): return [js_call(fn, UNDEF, [x]) for x in this] + + def _arr_foreach(this, fn): for x in this: js_call(fn, UNDEF, [x]) + + def _arr_filter(this, fn): return [x for x in this if js_truthy(js_call(fn, UNDEF, [x]))] -for _k, _f in [("push", _arr_push), ("shift", _arr_shift), ("join", _arr_join), ("slice", _arr_slice), - ("indexOf", _arr_indexof), ("concat", _arr_concat), ("pop", _arr_pop), - ("unshift", _arr_unshift), ("reverse", _arr_reverse), ("splice", _arr_splice), - ("map", _arr_map), ("forEach", _arr_foreach), ("filter", _arr_filter)]: + +for _k, _f in [ + ("push", _arr_push), + ("shift", _arr_shift), + ("join", _arr_join), + ("slice", _arr_slice), + ("indexOf", _arr_indexof), + ("concat", _arr_concat), + ("pop", _arr_pop), + ("unshift", _arr_unshift), + ("reverse", _arr_reverse), + ("splice", _arr_splice), + ("map", _arr_map), + ("forEach", _arr_foreach), + ("filter", _arr_filter), +]: ARRAY_METHODS[_k] = HostFunction(_f, _k) + def _str_split(this, sep=None, limit=None): if sep is None or sep == UNDEF: return [this] if sep == "": return list(this) parts = this.split(sep) - return parts if limit is None or limit == UNDEF else parts[:int(limit)] + return parts if limit is None or limit == UNDEF else parts[: int(limit)] + + def _str_substring(this, a=0, b=None): - return this[int(a):None if b is None or b == UNDEF else int(b)] + return this[int(a) : None if b is None or b == UNDEF else int(b)] + + def _str_substr(this, a=0, length=None): - return this[int(a):None if length is None or length == UNDEF else int(a) + int(length)] + return this[ + int(a) : None if length is None or length == UNDEF else int(a) + int(length) + ] + + def _str_charat(this, i=0): i = int(i) return this[i] if 0 <= i < len(this) else "" + + def _str_charcodeat(this, i=0): i = int(i) return ord(this[i]) if 0 <= i < len(this) else float("nan") + + def _str_indexof(this, x, frm=0): return this.find(str(x), int(frm)) + + def _str_lastindexof(this, x): return this.rfind(str(x)) + + def _str_slice(this, a=None, b=None): - return this[None if a is None else int(a):None if b is None else int(b)] + return this[None if a is None else int(a) : None if b is None else int(b)] + + def _str_replace(this, pat, rep): return this.replace(str(pat), str(rep)) + + def _str_tolower(this): return this.lower() + + def _str_toupper(this): return this.upper() + + def _str_trim(this): return this.strip() + + def _str_concat(this, *others): return this + "".join(str(o) for o in others) + + def _str_match(this, pat): import re as _re + return [m for m in _re.findall(str(pat), this)] + + def _str_starts(this, s): return this.startswith(str(s)) + + def _str_ends(this, s): return this.endswith(str(s)) + + def _str_includes(this, s): return str(s) in this + + def _str_search(this, pat): import re as _re + m = _re.search(str(pat), this) return m.start() if m else -1 -for _k, _f in [("split", _str_split), ("substring", _str_substring), ("substr", _str_substr), - ("charAt", _str_charat), ("charCodeAt", _str_charcodeat), ("indexOf", _str_indexof), - ("lastIndexOf", _str_lastindexof), ("slice", _str_slice), ("replace", _str_replace), - ("toLowerCase", _str_tolower), ("toUpperCase", _str_toupper), ("trim", _str_trim), - ("concat", _str_concat), ("match", _str_match), ("startsWith", _str_starts), - ("endsWith", _str_ends), ("includes", _str_includes), ("search", _str_search)]: + +for _k, _f in [ + ("split", _str_split), + ("substring", _str_substring), + ("substr", _str_substr), + ("charAt", _str_charat), + ("charCodeAt", _str_charcodeat), + ("indexOf", _str_indexof), + ("lastIndexOf", _str_lastindexof), + ("slice", _str_slice), + ("replace", _str_replace), + ("toLowerCase", _str_tolower), + ("toUpperCase", _str_toupper), + ("trim", _str_trim), + ("concat", _str_concat), + ("match", _str_match), + ("startsWith", _str_starts), + ("endsWith", _str_ends), + ("includes", _str_includes), + ("search", _str_search), +]: STRING_METHODS[_k] = HostFunction(_f, _k) + def h_math_random(this): return random.random() + def h_math_floor(this, x): return math.floor(float(x)) + def h_math_round(this, x): - return float(math.floor(float(x) + 0.5)) if float(x) >= 0 else float(math.ceil(float(x) - 0.5)) + return ( + float(math.floor(float(x) + 0.5)) + if float(x) >= 0 + else float(math.ceil(float(x) - 0.5)) + ) + def h_math_ceil(this, x): return math.ceil(float(x)) + def h_math_min(this, *args): return min(float(a) for a in args) if args else float("inf") + def h_math_max(this, *args): return max(float(a) for a in args) if args else float("-inf") + def h_math_abs(this, x): return abs(float(x)) + def h_math_pow(this, a, b): return float(a) ** float(b) + def h_math_sqrt(this, x): return math.sqrt(float(x)) + def h_parseint(this, s, radix=None): if isinstance(s, str): return int(s, radix or 10) return int(s) + def h_parsefloat(this, s): return float(s) + def h_isnan(this, x): try: return math.isnan(float(x)) except Exception: return True + def h_encodeuri(this, s): return urllib.parse.quote(str(s), safe=";/?:@&=+$,#") + def h_encodeuricomponent(this, s): return urllib.parse.quote(str(s), safe="-_.!~*'()") + def h_decodeuri(this, s): return urllib.parse.unquote(str(s)) + def h_decodeuricomponent(this, s): return urllib.parse.unquote(str(s)) + def h_string_fromcharcode(this, *codes): return "".join(chr(c & 0xFFFF) for c in codes) + class JSDate: def __init__(self, *args): self.t = 0.0 if args: self.t = float(args[0]) + def getTime(self): return self.t + def __repr__(self): return "" + def h_new_date(this, *args): return JSDate(*args) + # ---------------------------------------------------------------- 解释器 + class VM: def __init__(self, bytecode, constants, window, random_seed=None): self.o = bytecode @@ -582,6 +791,7 @@ class VM: self.trace_on = False self._all_trace = [] import random as _r + self._random = _r if random_seed is not None: self._random.seed(random_seed) @@ -590,7 +800,9 @@ class VM: def _build_hosts(self): m = {} m["Math"] = JSObject() - m["Math"].set("random", HostFunction(lambda this: self._random.random(), "random")) + m["Math"].set( + "random", HostFunction(lambda this: self._random.random(), "random") + ) m["Math"].set("floor", HostFunction(h_math_floor, "floor")) m["Math"].set("round", HostFunction(h_math_round, "round")) m["Math"].set("ceil", HostFunction(h_math_ceil, "ceil")) @@ -602,12 +814,18 @@ class VM: m["parseInt"] = HostFunction(h_parseint, "parseInt") m["parseFloat"] = HostFunction(h_parsefloat, "parseFloat") m["isNaN"] = HostFunction(h_isnan, "isNaN") - m["encodeURIComponent"] = HostFunction(h_encodeuricomponent, "encodeURIComponent") + m["encodeURIComponent"] = HostFunction( + h_encodeuricomponent, "encodeURIComponent" + ) m["encodeURI"] = HostFunction(h_encodeuri, "encodeURI") - m["decodeURIComponent"] = HostFunction(h_decodeuricomponent, "decodeURIComponent") + m["decodeURIComponent"] = HostFunction( + h_decodeuricomponent, "decodeURIComponent" + ) m["decodeURI"] = HostFunction(h_decodeuri, "decodeURI") m["String"] = JSObject() - m["String"].set("fromCharCode", HostFunction(h_string_fromcharcode, "fromCharCode")) + m["String"].set( + "fromCharCode", HostFunction(h_string_fromcharcode, "fromCharCode") + ) m["Date"] = HostFunction(h_new_date, "Date") return m @@ -633,546 +851,821 @@ class VM: C = list(C) p = UNDEF u = fn.entry - d = [] # 异常续延栈 - l = UNDEF # 最近异常(op12 读取) + d = [] # 异常续延栈 + l = UNDEF # 最近异常(op12 读取) o = self.o - cap = None # 外部捕获回调(验证用) - if not hasattr(self, '_all_trace'): + cap = None # 外部捕获回调(验证用) + if not hasattr(self, "_all_trace"): self._all_trace = [] while True: - try: - while True: - u += 1 - op = o[u] - if self.trace_on and len(self._all_trace) < 800000: - self._all_trace.append((op, u)) - # ---- 结果捕获(与 Node 移植一致:webSave 返回点)---- - if u == 121386 and not getattr(self, '_cap121', None): - self._cap121 = {} - for _s in (10, 11, 13, 14, 15, 30, 42, 43, 48, 59, 70, 85, 86, 50, 54, 66, 88, 17, 63): - self._cap121[_s] = js_index(C, _s) - if u == 117134 and self.capture_at_return: - v = js_index(C, 3476) - if isinstance(v, list): - self.out_arr = list(v) - parts = [] - for x in v: - try: - parts.append("%02x" % (int(x) & 255)) - except Exception: - parts.append("??") - self.out_hex = "".join(parts) - # 仅供离线排查 webSave 输出缓冲何时变化,生产路径默认不启用。 - if getattr(self, "capture_output_lengths", False): - v = js_index(C, 3476) - if isinstance(v, list): - length = len(v) - if length != getattr(self, "_last_output_length", None): - self._last_output_length = length - self.output_length_trace.append((u, op, length)) - # ---- opcode dispatch ---- - if op == 0: - a, b, c = o[u+1], o[u+2], o[u+3]; u += 3 - js_set(C, a, js_index(C, b) - c) - elif op == 1: - a, b, c, dd, e = o[u+1], o[u+2], o[u+3], o[u+4], o[u+5]; u += 5 - js_set(C, a, js_index(C, b)) - js_set(C, c, i32(_ic(js_index(C, dd)) ^ _ic(js_index(C, e)))) - js_set(C, o[u+1], js_index(C, o[u+2])); u += 2 - elif op == 2: - a, b, c = o[u+1], o[u+2], o[u+3]; u += 3 - js_set(C, a, js_index(js_index(C, b), js_index(C, c)) is not UNDEF) - elif op == 3: - a, b, c = o[u+1], o[u+2], o[u+3]; u += 3 - js_set(C, a, ushr(js_index(C, b), c)) - elif op == 4: - a, b = o[u+1], o[u+2]; u += 2 - js_set(C, a, not js_truthy(js_index(C, b))) - elif op == 5: - a, b, c = o[u+1], o[u+2], o[u+3]; u += 3 - js_set(C, a, _ic(js_index(C, b)) & _ic(c)) - a2, b2, c2 = o[u+1], o[u+2], o[u+3]; u += 3 - js_set(C, a2, js_index(js_index(C, b2), js_index(C, c2))) - a3, b3, c3 = o[u+1], o[u+2], o[u+3]; u += 3 - js_set(C, a3, i32(_ic(js_index(C, b3)) ^ _ic(js_index(C, c3)))) - elif op == 6: - if d: - d.pop() - elif op == 7: - a, b = o[u+1], o[u+2]; u += 2 - js_set(C, a, js_keys(js_index(C, b))) - elif op == 8: - a, b, c = o[u+1], o[u+2], o[u+3]; u += 3 - js_set(C, a, js_index(js_index(C, b), js_index(C, c))) - a2, b2, c2 = o[u+1], o[u+2], o[u+3]; u += 3 - js_set(C, a2, i32(_ic(js_index(C, b2)) ^ _ic(js_index(C, c2)))) - a3, b3, imm = o[u+1], o[u+2], o[u+3]; u += 3 - js_set(C, a3, js_index(js_index(C, b3), imm)) - elif op == 9: - a, b, imm1 = o[u+1], o[u+2], o[u+3]; u += 3 - js_set(C, a, js_index(js_index(C, b), imm1)) - c, imm2, d = o[u+1], o[u+2], o[u+3]; u += 3 - js_set(js_index(C, c), imm2, js_index(C, d)) - elif op == 10: - a, b, c = o[u+1], o[u+2], o[u+3]; u += 3 - js_set(C, a, _ic(js_index(C, b)) & _ic(c)) - a2, b2, c2 = o[u+1], o[u+2], o[u+3]; u += 3 - js_set(C, a2, i32(_ic(js_index(C, b2)) ^ _ic(js_index(C, c2)))) - a3, b3 = o[u+1], o[u+2]; u += 2 - js_set(C, a3, js_index(C, b3)) - elif op == 11: - a, b, c = o[u+1], o[u+2], o[u+3]; u += 3 - js_set(C, a, shr(js_index(C, b), c)) - elif op == 12: - a = o[u+1]; u += 1 - js_set(C, a, l) - elif op == 13: - a, c = o[u+1], o[u+2]; u += 2 - js_set(C, a, js_str(js_index(C, a)) + chr(c & 0xFFFF)) - elif op == 14: - a, b, c = o[u+1], o[u+2], o[u+3]; u += 3 - js_set(C, a, js_num(js_index(C, b)) % js_num(js_index(C, c))) - elif op == 15: - a, b = o[u+1], o[u+2]; u += 2 - js_set(C, a, ~i32(js_index(C, b))) - elif op == 16: - a, b, c = o[u+1], o[u+2], o[u+3]; u += 3 - js_set(C, a, js_index(js_index(C, b), js_index(C, c))) - a2 = o[u+1]; u += 1 - js_set(C, a2, "") - a3, c2 = o[u+1], o[u+2]; u += 2 - js_set(C, a3, js_str(js_index(C, a3)) + chr(c2 & 0xFFFF)) - elif op == 17: - a, b, c = o[u+1], o[u+2], o[u+3]; u += 3 - js_set(C, a, js_call(js_index(C, b), p, [js_index(C, c)])) - elif op == 18: - a, b, c = o[u+1], o[u+2], o[u+3]; u += 3 - js_set(C, a, js_add(js_index(C, b), js_index(C, c))) - elif op == 19: - a, b, c = o[u+1], o[u+2], o[u+3]; u += 3 - js_set(C, a, "") - js_set(C, b, js_add(js_index(C, b), chr(c & 0xFFFF))) - elif op == 20: - a, b, c = o[u+1], o[u+2], o[u+3]; u += 3 - js_set(C, a, i32(_ic(js_index(C, b)) | _ic(c))) - elif op == 21: - a = o[u+1]; u += 1 - js_set(C, a, "") - elif op == 22: - a, b = o[u+1], o[u+2]; u += 2 - js_set(C, a, [UNDEF] * b) - elif op == 23: - a, imm, b = o[u+1], o[u+2], o[u+3]; u += 3 - js_set(js_index(C, a), imm, js_index(C, b)) - c2 = o[u+1]; u += 1 - js_set(C, c2, "") - d2, ch = o[u+1], o[u+2]; u += 2 - js_set(C, d2, js_str(js_index(C, d2)) + chr(ch & 0xFFFF)) - elif op == 24: - a, b, c = o[u+1], o[u+2], o[u+3]; u += 3 - js_set(C, a, i32(_ic(js_index(C, b)) ^ _ic(js_index(C, c)))) - a2, b2 = o[u+1], o[u+2]; u += 2 - js_set(C, a2, js_index(C, b2)) - a3, b3 = o[u+1], o[u+2]; u += 2 - js_set(C, a3, js_index(C, b3)) - elif op == 25: - a, b, c = o[u+1], o[u+2], o[u+3]; u += 3 - js_set(C, a, shr(js_index(C, b), js_index(C, c))) - elif op == 26: - a, b = o[u+1], o[u+2]; u += 2 - js_set(C, a, -js_index(C, b)) - c2, imm, d2 = o[u+1], o[u+2], o[u+3]; u += 3 - js_set(js_index(C, c2), imm, js_index(C, d2)) - e2, f2, imm2 = o[u+1], o[u+2], o[u+3]; u += 3 - js_set(C, e2, js_index(js_index(C, f2), imm2)) - elif op == 27: - a, b, c = o[u+1], o[u+2], o[u+3]; u += 3 - js_del(js_index(C, b), js_index(C, c)) - elif op == 28: - a, c = o[u+1], o[u+2]; u += 2 - js_set(C, a, js_str(js_index(C, a)) + chr(c & 0xFFFF)) - a2, b2, c2 = o[u+1], o[u+2], o[u+3]; u += 3 - js_set(C, a2, js_index(js_index(C, b2), js_index(C, c2))) - elif op == 29: - a, imm, c = o[u+1], o[u+2], o[u+3]; u += 3 - js_set(C, a, js_add(imm, js_index(C, c))) - elif op == 30: - a, imm, c = o[u+1], o[u+2], o[u+3]; u += 3 - js_set(C, a, imm - js_num(js_index(C, c))) - elif op == 31: - a, imm1 = o[u+1], o[u+2]; u += 2 - js_set(C, a, imm1) - b2, imm2, c2 = o[u+1], o[u+2], o[u+3]; u += 3 - js_set(js_index(C, b2), imm2, js_index(C, c2)) - elif op == 32: - a, imm1 = o[u+1], o[u+2]; u += 2 - js_set(C, a, imm1) - b2, imm2, c2 = o[u+1], o[u+2], o[u+3]; u += 3 - js_set(js_index(C, b2), imm2, js_index(C, c2)) - d3, imm3 = o[u+1], o[u+2]; u += 2 - js_set(C, d3, imm3) - elif op == 33: - a, c1, b, c2 = o[u+1], o[u+2], o[u+3], o[u+4]; u += 4 - js_set(C, a, js_str(js_index(C, a)) + chr(c1 & 0xFFFF)) - js_set(C, b, js_str(js_index(C, b)) + chr(c2 & 0xFFFF)) - elif op == 34: - a, b = o[u+1], o[u+2]; u += 2 - js_set(C, a, b) - elif op == 35: - a, b, c = o[u+1], o[u+2], o[u+3]; u += 3 - js_set(C, a, js_index(js_index(C, b), c)) - elif op == 36: - a, b, c, dd = o[u+1], o[u+2], o[u+3], o[u+4]; u += 4 - js_set(C, a, js_new(js_index(C, b), [js_index(C, c), js_index(C, dd)])) - elif op == 37: - a, b, c = o[u+1], o[u+2], o[u+3]; u += 3 - js_set(C, a, js_call(js_index(C, b), js_index(C, c), [])) - elif op == 38: - a, b = o[u+1], o[u+2]; u += 2 - js_set(C, a, js_index(C, b)) - c2x, d1, imm1 = o[u+1], o[u+2], o[u+3]; u += 3 - js_set(C, c2x, ushr(js_index(C, d1), imm1)) - e, f, imm2 = o[u+1], o[u+2], o[u+3]; u += 3 - js_set(C, e, js_index(C, f) & imm2) - elif op == 39: - a, b, c = o[u+1], o[u+2], o[u+3]; u += 3 - js_set(C, a, js_eq(js_index(C, b), js_index(C, c))) - elif op == 40: - a, b = o[u+1], o[u+2]; u += 2 - js_set(C, a, -js_index(C, b)) - elif op == 41: - a, b = o[u+1], o[u+2]; u += 2 - js_set(C, a, [UNDEF] * b) - a2, b2 = o[u+1], o[u+2]; u += 2 - js_set(C, a2, [UNDEF] * b2) - elif op == 42: - a, b, c = o[u+1], o[u+2], o[u+3]; u += 3 - js_set(C, a, _cmp_le(js_index(C, b), js_index(C, c))) - elif op == 43: - a, b, c = o[u+1], o[u+2], o[u+3]; u += 3 - js_set(C, a, ushr(js_index(C, b), c)) - a2, b2, c2 = o[u+1], o[u+2], o[u+3]; u += 3 - js_set(C, a2, js_index(C, b2) & c2) - a3, b3, c3 = o[u+1], o[u+2], o[u+3]; u += 3 - js_set(C, a3, js_index(js_index(C, b3), js_index(C, c3))) - elif op == 44: - a, b, c = o[u+1], o[u+2], o[u+3]; u += 3 - js_set(C, a, shl(js_index(C, b), c)) - elif op == 45: - a = o[u+1]; u += 1 - return js_index(C, a) - elif op == 46: - a = o[u+1]; u += 1 - js_set(C, a, JSObject()) - elif op == 47: - a, b, imm1 = o[u+1], o[u+2], o[u+3]; u += 3 - js_set(C, a, js_index(js_index(C, b), imm1)) - c2, d2 = o[u+1], o[u+2]; u += 2 - js_set(C, c2, -js_index(C, d2)) - e2, imm2, f2 = o[u+1], o[u+2], o[u+3]; u += 3 - js_set(js_index(C, e2), imm2, js_index(C, f2)) - elif op == 48: - a, imm1, b = o[u+1], o[u+2], o[u+3]; u += 3 - js_set(js_index(C, a), imm1, js_index(C, b)) - c2, imm2 = o[u+1], o[u+2]; u += 2 - js_set(C, c2, imm2) - d2, imm3, e2 = o[u+1], o[u+2], o[u+3]; u += 3 - js_set(js_index(C, d2), imm3, js_index(C, e2)) - elif op == 49: - a, b, c = o[u+1], o[u+2], o[u+3]; u += 3 - js_set(C, a, i32(_ic(js_index(C, b)) | _ic(js_index(C, c)))) - elif op == 50: - a, b, c = o[u+1], o[u+2], o[u+3]; u += 3 - js_set(C, a, _cmp_gt(js_index(C, b), js_index(C, c))) - elif op == 51: - a, b, c = o[u+1], o[u+2], o[u+3]; u += 3 - js_set(C, a, js_index(js_index(C, b), js_index(C, c))) - a2, b2, c2 = o[u+1], o[u+2], o[u+3]; u += 3 - js_set(C, a2, js_index(js_index(C, b2), js_index(C, c2))) - a3, b3, c3 = o[u+1], o[u+2], o[u+3]; u += 3 - js_set(C, a3, i32(_ic(js_index(C, b3)) ^ _ic(js_index(C, c3)))) - elif op == 52: - a, b, c = o[u+1], o[u+2], o[u+3]; u += 3 - js_set(C, a, js_index(js_index(C, b), js_index(C, c))) - elif op == 53: - a, b, c = o[u+1], o[u+2], o[u+3]; u += 3 - js_set(C, a, _ic(js_index(C, b)) & _ic(js_index(C, c))) - elif op == 54: - a, b, c = o[u+1], o[u+2], o[u+3]; u += 3 - js_set(C, a, js_streq(js_index(C, b), js_index(C, c))) - elif op == 55: - a, imm1, b = o[u+1], o[u+2], o[u+3]; u += 3 - js_set(js_index(C, a), imm1, js_index(C, b)) - c2, imm2 = o[u+1], o[u+2]; u += 2 - js_set(C, c2, imm2) - elif op == 56: - a, b, c, dd = o[u+1], o[u+2], o[u+3], o[u+4]; u += 4 - js_set(C, a, js_call(js_index(C, b), js_index(C, c), [js_index(C, dd)])) - elif op == 57: - a, b, c = o[u+1], o[u+2], o[u+3]; u += 3 - js_set(C, a, isinstance(js_index(C, b), type(js_index(C, c))) if isinstance(js_index(C, c), type) else False) - elif op == 58: - ac = o[u+1]; u += 1 - f = [] - for _ in range(ac): - f.append(js_index(C, o[u+1])); u += 1 - dest = o[u+1]; u += 1 - off = o[u+1]; u += 1 - js_set(C, dest, self.make(u - 1 + off, f, fn.s, fn.n, fn.t)) - elif op == 59: - a, imm1, b = o[u+1], o[u+2], o[u+3]; u += 3 - js_set(js_index(C, a), imm1, js_index(C, b)) - c2, imm2, d2 = o[u+1], o[u+2], o[u+3]; u += 3 - js_set(js_index(C, c2), imm2, js_index(C, d2)) - elif op == 60: - a, b, c, dd = o[u+1], o[u+2], o[u+3], o[u+4]; u += 4 - js_set(C, a, js_call(js_index(C, b), p, [js_index(C, c), js_index(C, dd)])) - elif op == 61: - a, b, c = o[u+1], o[u+2], o[u+3]; u += 3 - js_set(C, a, i32(_ic(js_index(C, b)) ^ _ic(c))) - elif op == 62: - a, b, c = o[u+1], o[u+2], o[u+3]; u += 3 - js_set(C, a, js_new(js_index(C, b), [js_index(C, c)])) - elif op == 63: - a, b, c = o[u+1], o[u+2], o[u+3]; u += 3 - js_set(C, a, _cmp_lt(js_index(C, b), c)) - elif op == 64: - a, b, c = o[u+1], o[u+2], o[u+3]; u += 3 - js_set(C, a, js_num(js_index(C, b)) - js_num(js_index(C, c))) - elif op == 65: - a, imm1 = o[u+1], o[u+2]; u += 2 - js_set(C, a, imm1) - b2, c2 = o[u+1], o[u+2]; u += 2 - js_set(C, b2, -js_index(C, c2)) - d2, imm2, e2 = o[u+1], o[u+2], o[u+3]; u += 3 - js_set(js_index(C, d2), imm2, js_index(C, e2)) - elif op == 66: - a, b = o[u+1], o[u+2]; u += 2 - js_set(C, a, js_new(js_index(C, b), [])) - elif op == 67: - a, b, c = o[u+1], o[u+2], o[u+3]; u += 3 - js_set(C, a, js_index(js_index(C, b), c)) - a2, b2, c2 = o[u+1], o[u+2], o[u+3]; u += 3 - js_set(js_index(C, a2), js_index(C, b2), js_index(C, c2)) - a3, b3 = o[u+1], o[u+2]; u += 2 - js_set(C, a3, js_index(js_index(C, b3), o[u+1])); u += 1 - elif op == 68: - a, b, c = o[u+1], o[u+2], o[u+3]; u += 3 - js_set(C, a, js_index(js_index(C, b), c)) - a2 = o[u+1]; u += 1 - js_set(C, a2, "") - a3, c2 = o[u+1], o[u+2]; u += 2 - js_set(C, a3, js_str(js_index(C, a3)) + chr(c2 & 0xFFFF)) - elif op == 69: - a, b, c = o[u+1], o[u+2], o[u+3]; u += 3 - js_set(C, a, _cmp_ge(js_index(C, b), js_index(C, c))) - elif op == 70: - a, b = o[u+1], o[u+2]; u += 2 - js_set(C, a, js_index(C, b) - 0) - elif op == 71: - a, b, c = o[u+1], o[u+2], o[u+3]; u += 3 - js_set(C, a, _ic(js_index(C, b)) & _ic(c)) - elif op == 72: - a, b = o[u+1], o[u+2]; u += 2 - js_set(C, a, js_index(C, b) - 1) - elif op == 73: - a, b, c = o[u+1], o[u+2], o[u+3]; u += 3 - js_set(C, a, _cmp_le(js_index(C, b), c)) - elif op == 74: - a, b = o[u+1], o[u+2]; u += 2 - js_set(C, a, js_typeof(js_index(C, b))) - elif op == 75: - a, b, c, dd = o[u+1], o[u+2], o[u+3], o[u+4]; u += 4 - js_set(C, a, js_call(js_index(C, b), p, [js_index(C, c), js_index(C, dd)])) - elif op == 76: - a, b, c = o[u+1], o[u+2], o[u+3]; u += 3 - js_set(js_index(C, a), js_index(C, b), js_index(C, c)) - a2 = o[u+1]; u += 1 - js_set(C, a2, "") - a3, c2 = o[u+1], o[u+2]; u += 2 - js_set(C, a3, js_str(js_index(C, a3)) + chr(c2 & 0xFFFF)) - elif op == 77: - a, b, c, dd, e, f = o[u+1], o[u+2], o[u+3], o[u+4], o[u+5], o[u+6]; u += 6 - js_set(C, a, js_index(js_index(C, b), c)) - js_set(C, dd, js_index(js_index(C, e), f)) - elif op == 78: - a, b, c, dd, e, f = o[u+1], o[u+2], o[u+3], o[u+4], o[u+5], o[u+6]; u += 6 - js_set(C, a, js_index(C, b)) - js_set(C, c, js_index(C, dd)) - js_set(C, e, js_index(C, f)) - elif op == 79: - a = o[u+1]; u += 1 - d.append(u - 1 + a) - elif op == 80: - ac = o[u+1]; u += 1 - f = [] - for _ in range(ac): - f.append(js_index(C, o[u+1])); u += 1 - dest = o[u+1]; u += 1 - fn = js_index(C, o[u+1]); u += 1 - js_set(C, dest, js_apply(fn, p, f)) - elif op == 81: - a, b, c, dd = o[u+1], o[u+2], o[u+3], o[u+4]; u += 4 - js_set(C, a, js_call(js_index(C, b), js_index(C, c), [js_index(C, dd)])) - elif op == 82: - a = o[u+1]; u += 1 - js_set(C, a, False) - elif op == 83: - a, b, c = o[u+1], o[u+2], o[u+3]; u += 3 - js_set(C, a, _cmp_ge(js_index(C, b), c)) - elif op == 84: - a, imm1, b = o[u+1], o[u+2], o[u+3]; u += 3 - js_set(js_index(C, a), imm1, js_index(C, b)) - c2, d2, imm2 = o[u+1], o[u+2], o[u+3]; u += 3 - js_set(C, c2, js_index(js_index(C, d2), imm2)) - e2, f2 = o[u+1], o[u+2]; u += 2 - js_set(C, e2, -js_index(C, f2)) - elif op == 85: - u += o[u+1] - elif op == 86: - a, b, c = o[u+1], o[u+2], o[u+3]; u += 3 - js_set(C, a, i32(_ic(js_index(C, b)) ^ _ic(js_index(C, c)))) - elif op == 87: - ac = o[u+1]; u += 1 - f = [] - for _ in range(ac): - f.append(js_index(C, o[u+1])); u += 1 - dest = o[u+1]; u += 1 - off = o[u+1]; u += 1 - js_set(C, dest, self.make(u - 1 + off, f, fn.s, fn.n, fn.t)) - elif op == 88: - a, b, c = o[u+1], o[u+2], o[u+3]; u += 3 - js_set(C, a, _cmp_lt(js_index(C, b), js_index(C, c))) - elif op == 89: - a, b, c = o[u+1], o[u+2], o[u+3]; u += 3 - js_set(C, a, js_eq(js_index(C, b), c)) - elif op == 90: - a, b, c = o[u+1], o[u+2], o[u+3]; u += 3 - js_set(C, a, js_num(js_index(C, b)) / js_num(js_index(C, c))) - elif op == 91: - a, imm, b = o[u+1], o[u+2], o[u+3]; u += 3 - js_set(js_index(C, a), imm, js_index(C, b)) - elif op == 92: - a, b, c = o[u+1], o[u+2], o[u+3]; u += 3 - js_set(js_index(C, a), js_index(C, b), js_index(C, c)) - elif op == 93: - a, ch = o[u+1], o[u+2]; u += 2 - js_set(C, a, js_str(js_index(C, a)) + chr(ch & 0xFFFF)) - b2, imm, c2 = o[u+1], o[u+2], o[u+3]; u += 3 - js_set(js_index(C, b2), imm, js_index(C, c2)) - d2 = o[u+1]; u += 1 - js_set(C, d2, "") - elif op == 94: - cond = o[u+1] - then_off = o[u+2] - else_off = o[u+3] - u += (then_off if js_truthy(js_index(C, cond)) else else_off) - elif op == 95: - a, b = o[u+1], o[u+2]; u += 2 - js_set(C, a, js_index(C, b)) - elif op == 96: - a = o[u+1]; u += 1 - js_set(C, a, p) - elif op == 97: - ac = o[u+1]; u += 1 - f = [] - for _ in range(ac): - f.append(js_index(C, o[u+1])); u += 1 - dest = o[u+1]; u += 1 - fn = js_index(C, o[u+1]); u += 1 - thisv = js_index(C, o[u+1]); u += 1 - if not (isinstance(fn, (JSFunction, HostFunction)) or callable(fn)): - raise RuntimeError("op97 non-function at u=%s fn=%r" % (u, fn)) - js_set(C, dest, js_apply(fn, thisv, f)) - elif op == 98: - a = o[u+1]; u += 1 - raise _VMThrow(js_index(C, a)) - elif op == 99: - a, imm1, b = o[u+1], o[u+2], o[u+3]; u += 3 - js_set(js_index(C, a), imm1, js_index(C, b)) - c2, d2, imm2 = o[u+1], o[u+2], o[u+3]; u += 3 - js_set(C, c2, js_index(js_index(C, d2), imm2)) - elif op == 100: - a, b, c, dd = o[u+1], o[u+2], o[u+3], o[u+4]; u += 4 - js_set(C, a, js_new(js_index(C, b), [js_index(C, c), js_index(C, dd)])) - elif op == 101: - a, b, c = o[u+1], o[u+2], o[u+3]; u += 3 - f = js_index(C, a) - if isinstance(f, list) and len(f) > 0: - js_set(C, b, True) - js_set(C, c, f.pop(0)) - else: - js_set(C, b, False) + try: + while True: + u += 1 + op = o[u] + if self.trace_on and len(self._all_trace) < 800000: + self._all_trace.append((op, u)) + # ---- 结果捕获(与 Node 移植一致:webSave 返回点)---- + if u == 121386 and not getattr(self, "_cap121", None): + self._cap121 = {} + for _s in ( + 10, + 11, + 13, + 14, + 15, + 30, + 42, + 43, + 48, + 59, + 70, + 85, + 86, + 50, + 54, + 66, + 88, + 17, + 63, + ): + self._cap121[_s] = js_index(C, _s) + if u == 117134 and self.capture_at_return: + v = js_index(C, 3476) + if isinstance(v, list): + self.out_arr = list(v) + parts = [] + for x in v: + try: + parts.append("%02x" % (int(x) & 255)) + except Exception: + parts.append("??") + self.out_hex = "".join(parts) + # 仅供离线排查 webSave 输出缓冲何时变化,生产路径默认不启用。 + if getattr(self, "capture_output_lengths", False): + v = js_index(C, 3476) + if isinstance(v, list): + length = len(v) + if length != getattr(self, "_last_output_length", None): + self._last_output_length = length + self.output_length_trace.append((u, op, length)) + # ---- opcode dispatch ---- + if op == 0: + a, b, c = o[u + 1], o[u + 2], o[u + 3] + u += 3 + js_set(C, a, js_index(C, b) - c) + elif op == 1: + a, b, c, dd, e = ( + o[u + 1], + o[u + 2], + o[u + 3], + o[u + 4], + o[u + 5], + ) + u += 5 + js_set(C, a, js_index(C, b)) + js_set(C, c, i32(_ic(js_index(C, dd)) ^ _ic(js_index(C, e)))) + js_set(C, o[u + 1], js_index(C, o[u + 2])) + u += 2 + elif op == 2: + a, b, c = o[u + 1], o[u + 2], o[u + 3] + u += 3 + js_set( + C, a, js_index(js_index(C, b), js_index(C, c)) is not UNDEF + ) + elif op == 3: + a, b, c = o[u + 1], o[u + 2], o[u + 3] + u += 3 + js_set(C, a, ushr(js_index(C, b), c)) + elif op == 4: + a, b = o[u + 1], o[u + 2] + u += 2 + js_set(C, a, not js_truthy(js_index(C, b))) + elif op == 5: + a, b, c = o[u + 1], o[u + 2], o[u + 3] + u += 3 + js_set(C, a, _ic(js_index(C, b)) & _ic(c)) + a2, b2, c2 = o[u + 1], o[u + 2], o[u + 3] + u += 3 + js_set(C, a2, js_index(js_index(C, b2), js_index(C, c2))) + a3, b3, c3 = o[u + 1], o[u + 2], o[u + 3] + u += 3 + js_set(C, a3, i32(_ic(js_index(C, b3)) ^ _ic(js_index(C, c3)))) + elif op == 6: + if d: + d.pop() + elif op == 7: + a, b = o[u + 1], o[u + 2] + u += 2 + js_set(C, a, js_keys(js_index(C, b))) + elif op == 8: + a, b, c = o[u + 1], o[u + 2], o[u + 3] + u += 3 + js_set(C, a, js_index(js_index(C, b), js_index(C, c))) + a2, b2, c2 = o[u + 1], o[u + 2], o[u + 3] + u += 3 + js_set(C, a2, i32(_ic(js_index(C, b2)) ^ _ic(js_index(C, c2)))) + a3, b3, imm = o[u + 1], o[u + 2], o[u + 3] + u += 3 + js_set(C, a3, js_index(js_index(C, b3), imm)) + elif op == 9: + a, b, imm1 = o[u + 1], o[u + 2], o[u + 3] + u += 3 + js_set(C, a, js_index(js_index(C, b), imm1)) + c, imm2, d = o[u + 1], o[u + 2], o[u + 3] + u += 3 + js_set(js_index(C, c), imm2, js_index(C, d)) + elif op == 10: + a, b, c = o[u + 1], o[u + 2], o[u + 3] + u += 3 + js_set(C, a, _ic(js_index(C, b)) & _ic(c)) + a2, b2, c2 = o[u + 1], o[u + 2], o[u + 3] + u += 3 + js_set(C, a2, i32(_ic(js_index(C, b2)) ^ _ic(js_index(C, c2)))) + a3, b3 = o[u + 1], o[u + 2] + u += 2 + js_set(C, a3, js_index(C, b3)) + elif op == 11: + a, b, c = o[u + 1], o[u + 2], o[u + 3] + u += 3 + js_set(C, a, shr(js_index(C, b), c)) + elif op == 12: + a = o[u + 1] u += 1 - elif op == 102: - a, b = o[u+1], o[u+2]; u += 2 - js_set(C, a, js_index(C, b) + 1) - elif op == 103: - a, b, c = o[u+1], o[u+2], o[u+3]; u += 3 - js_set(C, a, i32(_ic(js_index(C, b)) ^ _ic(js_index(C, c)))) - a2, b2 = o[u+1], o[u+2]; u += 2 - js_set(C, a2, js_index(C, b2)) - elif op == 104: - a, imm1, b = o[u+1], o[u+2], o[u+3]; u += 3 - js_set(js_index(C, a), imm1, js_index(C, b)) - c2, d2, imm2 = o[u+1], o[u+2], o[u+3]; u += 3 - js_set(C, c2, js_index(js_index(C, d2), imm2)) - e2, imm3, f2 = o[u+1], o[u+2], o[u+3]; u += 3 - js_set(js_index(C, e2), imm3, js_index(C, f2)) - elif op == 105: - a, b, c, dd, e = o[u+1], o[u+2], o[u+3], o[u+4], o[u+5]; u += 5 - js_set(C, a, js_call(js_index(C, b), js_index(C, c), [js_index(C, dd), js_index(C, e)])) - elif op == 106: - a = o[u+1]; u += 1 - js_set(C, a, True) - elif op == 107: - a, b, c = o[u+1], o[u+2], o[u+3]; u += 3 - js_set(C, a, js_add(js_index(C, b), c)) - elif op == 108: - a, b, c = o[u+1], o[u+2], o[u+3]; u += 3 - js_set(C, a, shl(js_index(C, b), js_index(C, c))) - elif op == 109: - a, b, c, dd = o[u+1], o[u+2], o[u+3], o[u+4]; u += 4 - js_set(C, a, js_index(C, b)) - js_set(C, c, js_index(C, dd)) - elif op == 110: - a, b, c = o[u+1], o[u+2], o[u+3]; u += 3 - js_set(C, a, js_streq(js_index(C, b), c)) - elif op == 111: - a, b = o[u+1], o[u+2]; u += 2 - js_set(C, a, js_call(js_index(C, b), p, [])) - elif op == 112: - a, imm1 = o[u+1], o[u+2]; u += 2 - js_set(C, a, imm1) - b2, imm2, c2 = o[u+1], o[u+2], o[u+3]; u += 3 - js_set(js_index(C, b2), imm2, js_index(C, c2)) - d2, e2, imm3 = o[u+1], o[u+2], o[u+3]; u += 3 - js_set(C, d2, js_index(js_index(C, e2), imm3)) - elif op == 113: - a, b, c = o[u+1], o[u+2], o[u+3]; u += 3 - js_set(C, a, _cmp_gt(js_index(C, b), c)) - elif op == 114: - a, b, c = o[u+1], o[u+2], o[u+3]; u += 3 - js_set(C, a, js_num(js_index(C, b)) * js_num(js_index(C, c))) - elif op == 115: - a = o[u+1]; u += 1 - js_set(C, a, None) - else: - raise RuntimeError("unknown opcode %s at %s" % (op, u)) - except _VMThrow as e: - if not d: - raise RuntimeError("VM uncaught throw: %s" % (e.value,)) - l = e.value - u = d.pop() - continue - except Exception as e: - if not d: - raise - if not isinstance(d, list): - raise RuntimeError("d corrupted: %r (type %s) at trace %s" % (d, type(d).__name__, self._trace[-3:])) - l = e - u = d.pop() - continue + js_set(C, a, l) + elif op == 13: + a, c = o[u + 1], o[u + 2] + u += 2 + js_set(C, a, js_str(js_index(C, a)) + chr(c & 0xFFFF)) + elif op == 14: + a, b, c = o[u + 1], o[u + 2], o[u + 3] + u += 3 + js_set(C, a, js_num(js_index(C, b)) % js_num(js_index(C, c))) + elif op == 15: + a, b = o[u + 1], o[u + 2] + u += 2 + js_set(C, a, ~i32(js_index(C, b))) + elif op == 16: + a, b, c = o[u + 1], o[u + 2], o[u + 3] + u += 3 + js_set(C, a, js_index(js_index(C, b), js_index(C, c))) + a2 = o[u + 1] + u += 1 + js_set(C, a2, "") + a3, c2 = o[u + 1], o[u + 2] + u += 2 + js_set(C, a3, js_str(js_index(C, a3)) + chr(c2 & 0xFFFF)) + elif op == 17: + a, b, c = o[u + 1], o[u + 2], o[u + 3] + u += 3 + js_set(C, a, js_call(js_index(C, b), p, [js_index(C, c)])) + elif op == 18: + a, b, c = o[u + 1], o[u + 2], o[u + 3] + u += 3 + js_set(C, a, js_add(js_index(C, b), js_index(C, c))) + elif op == 19: + a, b, c = o[u + 1], o[u + 2], o[u + 3] + u += 3 + js_set(C, a, "") + js_set(C, b, js_add(js_index(C, b), chr(c & 0xFFFF))) + elif op == 20: + a, b, c = o[u + 1], o[u + 2], o[u + 3] + u += 3 + js_set(C, a, i32(_ic(js_index(C, b)) | _ic(c))) + elif op == 21: + a = o[u + 1] + u += 1 + js_set(C, a, "") + elif op == 22: + a, b = o[u + 1], o[u + 2] + u += 2 + js_set(C, a, [UNDEF] * b) + elif op == 23: + a, imm, b = o[u + 1], o[u + 2], o[u + 3] + u += 3 + js_set(js_index(C, a), imm, js_index(C, b)) + c2 = o[u + 1] + u += 1 + js_set(C, c2, "") + d2, ch = o[u + 1], o[u + 2] + u += 2 + js_set(C, d2, js_str(js_index(C, d2)) + chr(ch & 0xFFFF)) + elif op == 24: + a, b, c = o[u + 1], o[u + 2], o[u + 3] + u += 3 + js_set(C, a, i32(_ic(js_index(C, b)) ^ _ic(js_index(C, c)))) + a2, b2 = o[u + 1], o[u + 2] + u += 2 + js_set(C, a2, js_index(C, b2)) + a3, b3 = o[u + 1], o[u + 2] + u += 2 + js_set(C, a3, js_index(C, b3)) + elif op == 25: + a, b, c = o[u + 1], o[u + 2], o[u + 3] + u += 3 + js_set(C, a, shr(js_index(C, b), js_index(C, c))) + elif op == 26: + a, b = o[u + 1], o[u + 2] + u += 2 + js_set(C, a, -js_index(C, b)) + c2, imm, d2 = o[u + 1], o[u + 2], o[u + 3] + u += 3 + js_set(js_index(C, c2), imm, js_index(C, d2)) + e2, f2, imm2 = o[u + 1], o[u + 2], o[u + 3] + u += 3 + js_set(C, e2, js_index(js_index(C, f2), imm2)) + elif op == 27: + a, b, c = o[u + 1], o[u + 2], o[u + 3] + u += 3 + js_del(js_index(C, b), js_index(C, c)) + elif op == 28: + a, c = o[u + 1], o[u + 2] + u += 2 + js_set(C, a, js_str(js_index(C, a)) + chr(c & 0xFFFF)) + a2, b2, c2 = o[u + 1], o[u + 2], o[u + 3] + u += 3 + js_set(C, a2, js_index(js_index(C, b2), js_index(C, c2))) + elif op == 29: + a, imm, c = o[u + 1], o[u + 2], o[u + 3] + u += 3 + js_set(C, a, js_add(imm, js_index(C, c))) + elif op == 30: + a, imm, c = o[u + 1], o[u + 2], o[u + 3] + u += 3 + js_set(C, a, imm - js_num(js_index(C, c))) + elif op == 31: + a, imm1 = o[u + 1], o[u + 2] + u += 2 + js_set(C, a, imm1) + b2, imm2, c2 = o[u + 1], o[u + 2], o[u + 3] + u += 3 + js_set(js_index(C, b2), imm2, js_index(C, c2)) + elif op == 32: + a, imm1 = o[u + 1], o[u + 2] + u += 2 + js_set(C, a, imm1) + b2, imm2, c2 = o[u + 1], o[u + 2], o[u + 3] + u += 3 + js_set(js_index(C, b2), imm2, js_index(C, c2)) + d3, imm3 = o[u + 1], o[u + 2] + u += 2 + js_set(C, d3, imm3) + elif op == 33: + a, c1, b, c2 = o[u + 1], o[u + 2], o[u + 3], o[u + 4] + u += 4 + js_set(C, a, js_str(js_index(C, a)) + chr(c1 & 0xFFFF)) + js_set(C, b, js_str(js_index(C, b)) + chr(c2 & 0xFFFF)) + elif op == 34: + a, b = o[u + 1], o[u + 2] + u += 2 + js_set(C, a, b) + elif op == 35: + a, b, c = o[u + 1], o[u + 2], o[u + 3] + u += 3 + js_set(C, a, js_index(js_index(C, b), c)) + elif op == 36: + a, b, c, dd = o[u + 1], o[u + 2], o[u + 3], o[u + 4] + u += 4 + js_set( + C, + a, + js_new(js_index(C, b), [js_index(C, c), js_index(C, dd)]), + ) + elif op == 37: + a, b, c = o[u + 1], o[u + 2], o[u + 3] + u += 3 + js_set(C, a, js_call(js_index(C, b), js_index(C, c), [])) + elif op == 38: + a, b = o[u + 1], o[u + 2] + u += 2 + js_set(C, a, js_index(C, b)) + c2x, d1, imm1 = o[u + 1], o[u + 2], o[u + 3] + u += 3 + js_set(C, c2x, ushr(js_index(C, d1), imm1)) + e, f, imm2 = o[u + 1], o[u + 2], o[u + 3] + u += 3 + js_set(C, e, js_index(C, f) & imm2) + elif op == 39: + a, b, c = o[u + 1], o[u + 2], o[u + 3] + u += 3 + js_set(C, a, js_eq(js_index(C, b), js_index(C, c))) + elif op == 40: + a, b = o[u + 1], o[u + 2] + u += 2 + js_set(C, a, -js_index(C, b)) + elif op == 41: + a, b = o[u + 1], o[u + 2] + u += 2 + js_set(C, a, [UNDEF] * b) + a2, b2 = o[u + 1], o[u + 2] + u += 2 + js_set(C, a2, [UNDEF] * b2) + elif op == 42: + a, b, c = o[u + 1], o[u + 2], o[u + 3] + u += 3 + js_set(C, a, _cmp_le(js_index(C, b), js_index(C, c))) + elif op == 43: + a, b, c = o[u + 1], o[u + 2], o[u + 3] + u += 3 + js_set(C, a, ushr(js_index(C, b), c)) + a2, b2, c2 = o[u + 1], o[u + 2], o[u + 3] + u += 3 + js_set(C, a2, js_index(C, b2) & c2) + a3, b3, c3 = o[u + 1], o[u + 2], o[u + 3] + u += 3 + js_set(C, a3, js_index(js_index(C, b3), js_index(C, c3))) + elif op == 44: + a, b, c = o[u + 1], o[u + 2], o[u + 3] + u += 3 + js_set(C, a, shl(js_index(C, b), c)) + elif op == 45: + a = o[u + 1] + u += 1 + return js_index(C, a) + elif op == 46: + a = o[u + 1] + u += 1 + js_set(C, a, JSObject()) + elif op == 47: + a, b, imm1 = o[u + 1], o[u + 2], o[u + 3] + u += 3 + js_set(C, a, js_index(js_index(C, b), imm1)) + c2, d2 = o[u + 1], o[u + 2] + u += 2 + js_set(C, c2, -js_index(C, d2)) + e2, imm2, f2 = o[u + 1], o[u + 2], o[u + 3] + u += 3 + js_set(js_index(C, e2), imm2, js_index(C, f2)) + elif op == 48: + a, imm1, b = o[u + 1], o[u + 2], o[u + 3] + u += 3 + js_set(js_index(C, a), imm1, js_index(C, b)) + c2, imm2 = o[u + 1], o[u + 2] + u += 2 + js_set(C, c2, imm2) + d2, imm3, e2 = o[u + 1], o[u + 2], o[u + 3] + u += 3 + js_set(js_index(C, d2), imm3, js_index(C, e2)) + elif op == 49: + a, b, c = o[u + 1], o[u + 2], o[u + 3] + u += 3 + js_set(C, a, i32(_ic(js_index(C, b)) | _ic(js_index(C, c)))) + elif op == 50: + a, b, c = o[u + 1], o[u + 2], o[u + 3] + u += 3 + js_set(C, a, _cmp_gt(js_index(C, b), js_index(C, c))) + elif op == 51: + a, b, c = o[u + 1], o[u + 2], o[u + 3] + u += 3 + js_set(C, a, js_index(js_index(C, b), js_index(C, c))) + a2, b2, c2 = o[u + 1], o[u + 2], o[u + 3] + u += 3 + js_set(C, a2, js_index(js_index(C, b2), js_index(C, c2))) + a3, b3, c3 = o[u + 1], o[u + 2], o[u + 3] + u += 3 + js_set(C, a3, i32(_ic(js_index(C, b3)) ^ _ic(js_index(C, c3)))) + elif op == 52: + a, b, c = o[u + 1], o[u + 2], o[u + 3] + u += 3 + js_set(C, a, js_index(js_index(C, b), js_index(C, c))) + elif op == 53: + a, b, c = o[u + 1], o[u + 2], o[u + 3] + u += 3 + js_set(C, a, _ic(js_index(C, b)) & _ic(js_index(C, c))) + elif op == 54: + a, b, c = o[u + 1], o[u + 2], o[u + 3] + u += 3 + js_set(C, a, js_streq(js_index(C, b), js_index(C, c))) + elif op == 55: + a, imm1, b = o[u + 1], o[u + 2], o[u + 3] + u += 3 + js_set(js_index(C, a), imm1, js_index(C, b)) + c2, imm2 = o[u + 1], o[u + 2] + u += 2 + js_set(C, c2, imm2) + elif op == 56: + a, b, c, dd = o[u + 1], o[u + 2], o[u + 3], o[u + 4] + u += 4 + js_set( + C, + a, + js_call(js_index(C, b), js_index(C, c), [js_index(C, dd)]), + ) + elif op == 57: + a, b, c = o[u + 1], o[u + 2], o[u + 3] + u += 3 + js_set( + C, + a, + isinstance(js_index(C, b), type(js_index(C, c))) + if isinstance(js_index(C, c), type) + else False, + ) + elif op == 58: + ac = o[u + 1] + u += 1 + f = [] + for _ in range(ac): + f.append(js_index(C, o[u + 1])) + u += 1 + dest = o[u + 1] + u += 1 + off = o[u + 1] + u += 1 + js_set(C, dest, self.make(u - 1 + off, f, fn.s, fn.n, fn.t)) + elif op == 59: + a, imm1, b = o[u + 1], o[u + 2], o[u + 3] + u += 3 + js_set(js_index(C, a), imm1, js_index(C, b)) + c2, imm2, d2 = o[u + 1], o[u + 2], o[u + 3] + u += 3 + js_set(js_index(C, c2), imm2, js_index(C, d2)) + elif op == 60: + a, b, c, dd = o[u + 1], o[u + 2], o[u + 3], o[u + 4] + u += 4 + js_set( + C, + a, + js_call( + js_index(C, b), p, [js_index(C, c), js_index(C, dd)] + ), + ) + elif op == 61: + a, b, c = o[u + 1], o[u + 2], o[u + 3] + u += 3 + js_set(C, a, i32(_ic(js_index(C, b)) ^ _ic(c))) + elif op == 62: + a, b, c = o[u + 1], o[u + 2], o[u + 3] + u += 3 + js_set(C, a, js_new(js_index(C, b), [js_index(C, c)])) + elif op == 63: + a, b, c = o[u + 1], o[u + 2], o[u + 3] + u += 3 + js_set(C, a, _cmp_lt(js_index(C, b), c)) + elif op == 64: + a, b, c = o[u + 1], o[u + 2], o[u + 3] + u += 3 + js_set(C, a, js_num(js_index(C, b)) - js_num(js_index(C, c))) + elif op == 65: + a, imm1 = o[u + 1], o[u + 2] + u += 2 + js_set(C, a, imm1) + b2, c2 = o[u + 1], o[u + 2] + u += 2 + js_set(C, b2, -js_index(C, c2)) + d2, imm2, e2 = o[u + 1], o[u + 2], o[u + 3] + u += 3 + js_set(js_index(C, d2), imm2, js_index(C, e2)) + elif op == 66: + a, b = o[u + 1], o[u + 2] + u += 2 + js_set(C, a, js_new(js_index(C, b), [])) + elif op == 67: + a, b, c = o[u + 1], o[u + 2], o[u + 3] + u += 3 + js_set(C, a, js_index(js_index(C, b), c)) + a2, b2, c2 = o[u + 1], o[u + 2], o[u + 3] + u += 3 + js_set(js_index(C, a2), js_index(C, b2), js_index(C, c2)) + a3, b3 = o[u + 1], o[u + 2] + u += 2 + js_set(C, a3, js_index(js_index(C, b3), o[u + 1])) + u += 1 + elif op == 68: + a, b, c = o[u + 1], o[u + 2], o[u + 3] + u += 3 + js_set(C, a, js_index(js_index(C, b), c)) + a2 = o[u + 1] + u += 1 + js_set(C, a2, "") + a3, c2 = o[u + 1], o[u + 2] + u += 2 + js_set(C, a3, js_str(js_index(C, a3)) + chr(c2 & 0xFFFF)) + elif op == 69: + a, b, c = o[u + 1], o[u + 2], o[u + 3] + u += 3 + js_set(C, a, _cmp_ge(js_index(C, b), js_index(C, c))) + elif op == 70: + a, b = o[u + 1], o[u + 2] + u += 2 + js_set(C, a, js_index(C, b) - 0) + elif op == 71: + a, b, c = o[u + 1], o[u + 2], o[u + 3] + u += 3 + js_set(C, a, _ic(js_index(C, b)) & _ic(c)) + elif op == 72: + a, b = o[u + 1], o[u + 2] + u += 2 + js_set(C, a, js_index(C, b) - 1) + elif op == 73: + a, b, c = o[u + 1], o[u + 2], o[u + 3] + u += 3 + js_set(C, a, _cmp_le(js_index(C, b), c)) + elif op == 74: + a, b = o[u + 1], o[u + 2] + u += 2 + js_set(C, a, js_typeof(js_index(C, b))) + elif op == 75: + a, b, c, dd = o[u + 1], o[u + 2], o[u + 3], o[u + 4] + u += 4 + js_set( + C, + a, + js_call( + js_index(C, b), p, [js_index(C, c), js_index(C, dd)] + ), + ) + elif op == 76: + a, b, c = o[u + 1], o[u + 2], o[u + 3] + u += 3 + js_set(js_index(C, a), js_index(C, b), js_index(C, c)) + a2 = o[u + 1] + u += 1 + js_set(C, a2, "") + a3, c2 = o[u + 1], o[u + 2] + u += 2 + js_set(C, a3, js_str(js_index(C, a3)) + chr(c2 & 0xFFFF)) + elif op == 77: + a, b, c, dd, e, f = ( + o[u + 1], + o[u + 2], + o[u + 3], + o[u + 4], + o[u + 5], + o[u + 6], + ) + u += 6 + js_set(C, a, js_index(js_index(C, b), c)) + js_set(C, dd, js_index(js_index(C, e), f)) + elif op == 78: + a, b, c, dd, e, f = ( + o[u + 1], + o[u + 2], + o[u + 3], + o[u + 4], + o[u + 5], + o[u + 6], + ) + u += 6 + js_set(C, a, js_index(C, b)) + js_set(C, c, js_index(C, dd)) + js_set(C, e, js_index(C, f)) + elif op == 79: + a = o[u + 1] + u += 1 + d.append(u - 1 + a) + elif op == 80: + ac = o[u + 1] + u += 1 + f = [] + for _ in range(ac): + f.append(js_index(C, o[u + 1])) + u += 1 + dest = o[u + 1] + u += 1 + fn = js_index(C, o[u + 1]) + u += 1 + js_set(C, dest, js_apply(fn, p, f)) + elif op == 81: + a, b, c, dd = o[u + 1], o[u + 2], o[u + 3], o[u + 4] + u += 4 + js_set( + C, + a, + js_call(js_index(C, b), js_index(C, c), [js_index(C, dd)]), + ) + elif op == 82: + a = o[u + 1] + u += 1 + js_set(C, a, False) + elif op == 83: + a, b, c = o[u + 1], o[u + 2], o[u + 3] + u += 3 + js_set(C, a, _cmp_ge(js_index(C, b), c)) + elif op == 84: + a, imm1, b = o[u + 1], o[u + 2], o[u + 3] + u += 3 + js_set(js_index(C, a), imm1, js_index(C, b)) + c2, d2, imm2 = o[u + 1], o[u + 2], o[u + 3] + u += 3 + js_set(C, c2, js_index(js_index(C, d2), imm2)) + e2, f2 = o[u + 1], o[u + 2] + u += 2 + js_set(C, e2, -js_index(C, f2)) + elif op == 85: + u += o[u + 1] + elif op == 86: + a, b, c = o[u + 1], o[u + 2], o[u + 3] + u += 3 + js_set(C, a, i32(_ic(js_index(C, b)) ^ _ic(js_index(C, c)))) + elif op == 87: + ac = o[u + 1] + u += 1 + f = [] + for _ in range(ac): + f.append(js_index(C, o[u + 1])) + u += 1 + dest = o[u + 1] + u += 1 + off = o[u + 1] + u += 1 + js_set(C, dest, self.make(u - 1 + off, f, fn.s, fn.n, fn.t)) + elif op == 88: + a, b, c = o[u + 1], o[u + 2], o[u + 3] + u += 3 + js_set(C, a, _cmp_lt(js_index(C, b), js_index(C, c))) + elif op == 89: + a, b, c = o[u + 1], o[u + 2], o[u + 3] + u += 3 + js_set(C, a, js_eq(js_index(C, b), c)) + elif op == 90: + a, b, c = o[u + 1], o[u + 2], o[u + 3] + u += 3 + js_set(C, a, js_num(js_index(C, b)) / js_num(js_index(C, c))) + elif op == 91: + a, imm, b = o[u + 1], o[u + 2], o[u + 3] + u += 3 + js_set(js_index(C, a), imm, js_index(C, b)) + elif op == 92: + a, b, c = o[u + 1], o[u + 2], o[u + 3] + u += 3 + js_set(js_index(C, a), js_index(C, b), js_index(C, c)) + elif op == 93: + a, ch = o[u + 1], o[u + 2] + u += 2 + js_set(C, a, js_str(js_index(C, a)) + chr(ch & 0xFFFF)) + b2, imm, c2 = o[u + 1], o[u + 2], o[u + 3] + u += 3 + js_set(js_index(C, b2), imm, js_index(C, c2)) + d2 = o[u + 1] + u += 1 + js_set(C, d2, "") + elif op == 94: + cond = o[u + 1] + then_off = o[u + 2] + else_off = o[u + 3] + u += then_off if js_truthy(js_index(C, cond)) else else_off + elif op == 95: + a, b = o[u + 1], o[u + 2] + u += 2 + js_set(C, a, js_index(C, b)) + elif op == 96: + a = o[u + 1] + u += 1 + js_set(C, a, p) + elif op == 97: + ac = o[u + 1] + u += 1 + f = [] + for _ in range(ac): + f.append(js_index(C, o[u + 1])) + u += 1 + dest = o[u + 1] + u += 1 + fn = js_index(C, o[u + 1]) + u += 1 + thisv = js_index(C, o[u + 1]) + u += 1 + if not ( + isinstance(fn, (JSFunction, HostFunction)) or callable(fn) + ): + raise RuntimeError( + "op97 non-function at u=%s fn=%r" % (u, fn) + ) + js_set(C, dest, js_apply(fn, thisv, f)) + elif op == 98: + a = o[u + 1] + u += 1 + raise _VMThrow(js_index(C, a)) + elif op == 99: + a, imm1, b = o[u + 1], o[u + 2], o[u + 3] + u += 3 + js_set(js_index(C, a), imm1, js_index(C, b)) + c2, d2, imm2 = o[u + 1], o[u + 2], o[u + 3] + u += 3 + js_set(C, c2, js_index(js_index(C, d2), imm2)) + elif op == 100: + a, b, c, dd = o[u + 1], o[u + 2], o[u + 3], o[u + 4] + u += 4 + js_set( + C, + a, + js_new(js_index(C, b), [js_index(C, c), js_index(C, dd)]), + ) + elif op == 101: + a, b, c = o[u + 1], o[u + 2], o[u + 3] + u += 3 + f = js_index(C, a) + if isinstance(f, list) and len(f) > 0: + js_set(C, b, True) + js_set(C, c, f.pop(0)) + else: + js_set(C, b, False) + u += 1 + elif op == 102: + a, b = o[u + 1], o[u + 2] + u += 2 + js_set(C, a, js_index(C, b) + 1) + elif op == 103: + a, b, c = o[u + 1], o[u + 2], o[u + 3] + u += 3 + js_set(C, a, i32(_ic(js_index(C, b)) ^ _ic(js_index(C, c)))) + a2, b2 = o[u + 1], o[u + 2] + u += 2 + js_set(C, a2, js_index(C, b2)) + elif op == 104: + a, imm1, b = o[u + 1], o[u + 2], o[u + 3] + u += 3 + js_set(js_index(C, a), imm1, js_index(C, b)) + c2, d2, imm2 = o[u + 1], o[u + 2], o[u + 3] + u += 3 + js_set(C, c2, js_index(js_index(C, d2), imm2)) + e2, imm3, f2 = o[u + 1], o[u + 2], o[u + 3] + u += 3 + js_set(js_index(C, e2), imm3, js_index(C, f2)) + elif op == 105: + a, b, c, dd, e = ( + o[u + 1], + o[u + 2], + o[u + 3], + o[u + 4], + o[u + 5], + ) + u += 5 + js_set( + C, + a, + js_call( + js_index(C, b), + js_index(C, c), + [js_index(C, dd), js_index(C, e)], + ), + ) + elif op == 106: + a = o[u + 1] + u += 1 + js_set(C, a, True) + elif op == 107: + a, b, c = o[u + 1], o[u + 2], o[u + 3] + u += 3 + js_set(C, a, js_add(js_index(C, b), c)) + elif op == 108: + a, b, c = o[u + 1], o[u + 2], o[u + 3] + u += 3 + js_set(C, a, shl(js_index(C, b), js_index(C, c))) + elif op == 109: + a, b, c, dd = o[u + 1], o[u + 2], o[u + 3], o[u + 4] + u += 4 + js_set(C, a, js_index(C, b)) + js_set(C, c, js_index(C, dd)) + elif op == 110: + a, b, c = o[u + 1], o[u + 2], o[u + 3] + u += 3 + js_set(C, a, js_streq(js_index(C, b), c)) + elif op == 111: + a, b = o[u + 1], o[u + 2] + u += 2 + js_set(C, a, js_call(js_index(C, b), p, [])) + elif op == 112: + a, imm1 = o[u + 1], o[u + 2] + u += 2 + js_set(C, a, imm1) + b2, imm2, c2 = o[u + 1], o[u + 2], o[u + 3] + u += 3 + js_set(js_index(C, b2), imm2, js_index(C, c2)) + d2, e2, imm3 = o[u + 1], o[u + 2], o[u + 3] + u += 3 + js_set(C, d2, js_index(js_index(C, e2), imm3)) + elif op == 113: + a, b, c = o[u + 1], o[u + 2], o[u + 3] + u += 3 + js_set(C, a, _cmp_gt(js_index(C, b), c)) + elif op == 114: + a, b, c = o[u + 1], o[u + 2], o[u + 3] + u += 3 + js_set(C, a, js_num(js_index(C, b)) * js_num(js_index(C, c))) + elif op == 115: + a = o[u + 1] + u += 1 + js_set(C, a, None) + else: + raise RuntimeError("unknown opcode %s at %s" % (op, u)) + except _VMThrow as e: + if not d: + raise RuntimeError("VM uncaught throw: %s" % (e.value,)) + l = e.value + u = d.pop() + continue + except Exception as e: + if not d: + raise + if not isinstance(d, list): + raise RuntimeError( + "d corrupted: %r (type %s) at trace %s" + % (d, type(d).__name__, self._trace[-3:]) + ) + l = e + u = d.pop() + continue class _VMThrow(Exception): @@ -1191,6 +1684,7 @@ def js_new(ctor, args): # ---------------------------------------------------------------- 编码/解码 + def decode_d(v): """把深拷贝探针的 D 编码还原为 Python 值。""" if not isinstance(v, str): @@ -1229,7 +1723,7 @@ def decode_d(v): for ch in inner: if in_str: cur += ch - if ch == '"' and cur[-2:] != "\\\"": + if ch == '"' and cur[-2:] != '\\"': in_str = False elif ch == '"': in_str = True @@ -1250,7 +1744,7 @@ def decode_d(v): for p in parts: ci = p.find(":") key = json.loads(p[:ci]) - vs = p[ci + 1:] + vs = p[ci + 1 :] try: obj.set(key, decode_d(json.loads(vs))) except Exception: @@ -1264,7 +1758,9 @@ def decode_d(v): return v -def generate_encrypt_msg(args_json: str, cb_json: str, out_json: str | None = None) -> str: +def generate_encrypt_msg( + args_json: str, cb_json: str, out_json: str | None = None +) -> str: """由深拷贝 18 参(cap-85091 的 C[2])与回调 4 参(cap-69667 的 C[2])生成 encrypt_msg hex。""" replay = REPLAY bytecode = json.loads((replay / "bytecode.json").read_text()) @@ -1279,7 +1775,10 @@ def generate_encrypt_msg(args_json: str, cb_json: str, out_json: str | None = No window.set("Math", None) # placeholder; replaced below # 页面全局 window.set("xMidasOps", xmidas) - window.set("xMidasToken", "DE46DBA4754D42A6B66ADD4319FF80C144D9ED22ED73384C271793D9A9AA2FFBD6C104BE8D4A7F4ED2A8688FCB6F7540") + window.set( + "xMidasToken", + "DE46DBA4754D42A6B66ADD4319FF80C144D9ED22ED73384C271793D9A9AA2FFBD6C104BE8D4A7F4ED2A8688FCB6F7540", + ) window.set("xMidasVersion", "web_1.0.6") cfg = JSObject() webpay = JSObject() @@ -1293,8 +1792,15 @@ def generate_encrypt_msg(args_json: str, cb_json: str, out_json: str | None = No window.set("Math", vm._hosts["Math"]) window.set("Date", vm._hosts["Date"]) window.set("String", vm._hosts["String"]) - for k in ("parseInt", "parseFloat", "isNaN", "encodeURIComponent", "encodeURI", - "decodeURIComponent", "decodeURI"): + for k in ( + "parseInt", + "parseFloat", + "isNaN", + "encodeURIComponent", + "encodeURI", + "decodeURIComponent", + "decodeURI", + ): window.set(k, vm._hosts[k]) args = decode_d(args_raw) @@ -1331,8 +1837,17 @@ def generate_encrypt_msg(args_json: str, cb_json: str, out_json: str | None = No if __name__ == "__main__": import sys - a = sys.argv[1] if len(sys.argv) > 1 else str(REPLAY / "deepcaps2" / "cap-85091.json") - b = sys.argv[2] if len(sys.argv) > 2 else str(REPLAY / "deepcaps2" / "cap-69667.json") + + a = ( + sys.argv[1] + if len(sys.argv) > 1 + else str(REPLAY / "deepcaps2" / "cap-85091.json") + ) + b = ( + sys.argv[2] + if len(sys.argv) > 2 + else str(REPLAY / "deepcaps2" / "cap-69667.json") + ) o = sys.argv[3] if len(sys.argv) > 3 else None hexout = generate_encrypt_msg(a, b, o) print(hexout) @@ -1340,6 +1855,7 @@ if __name__ == "__main__": # ---------------------------------------------------------------- 离线生成(A2) + def _js_encodeuri(s): """JS encodeURIComponent 等价(不转义 -_.!~*'())。""" return urllib.parse.quote(str(s), safe="-_.!~*'()") @@ -1351,10 +1867,29 @@ def build_plaintext(params, fk_extend, ts, rand_val, jun_st_len="5"): pay_method/buy_quantity/mb_pwd/pay_id/auth_key/card_value/accounttype/ provide_uin/extend/from_h5/webversion) """ - fields = ["token_id","openid","openkey","session_id","session_type","zoneid", - "pay_method","buy_quantity","mb_pwd","pay_id","auth_key","card_value", - "accounttype","provide_uin","extend","ts","from_h5","webversion", - "fk_extend","_junStLen","_rand"] + fields = [ + "token_id", + "openid", + "openkey", + "session_id", + "session_type", + "zoneid", + "pay_method", + "buy_quantity", + "mb_pwd", + "pay_id", + "auth_key", + "card_value", + "accounttype", + "provide_uin", + "extend", + "ts", + "from_h5", + "webversion", + "fk_extend", + "_junStLen", + "_rand", + ] parts = [] for f in fields: if f == "ts": @@ -1383,7 +1918,9 @@ def recover_plaintext_from_buffer(buffer, key16, te_tables=None): 离线生成 → 1056/1056 复现页面 encrypt_msg。 """ if te_tables is None: - te_cap = json.loads(json.loads((REPLAY / "e2e" / "multi-1.json").read_text().strip())) + te_cap = json.loads( + json.loads((REPLAY / "e2e" / "multi-1.json").read_text().strip()) + ) te_args = decode_d(te_cap["C"][2]) te_tables = [te_args[i][0] for i in (2, 3, 4, 5)] Te0, Te1, Te2, Te3 = te_tables @@ -1416,7 +1953,7 @@ def recover_plaintext_from_buffer(buffer, key16, te_tables=None): if not sols: sols = list(inv_block(blk, mid_all)) if not sols: - raise ValueError(f"第 {blk} 块(字节 {4*blk})无法反推明文") + raise ValueError(f"第 {blk} 块(字节 {4 * blk})无法反推明文") out.extend(sols[0]) return bytes(out).decode("latin-1") @@ -1432,7 +1969,9 @@ def derive_key1_from_key16(key16, te_tables=None, sbox=None): 捕获 key1 完全一致(order10/order12),证明 key1 = G(key16) 可纯计算。 """ if te_tables is None or sbox is None: - te_cap = json.loads(json.loads((REPLAY / "e2e" / "multi-1.json").read_text().strip())) + te_cap = json.loads( + json.loads((REPLAY / "e2e" / "multi-1.json").read_text().strip()) + ) te_args = decode_d(te_cap["C"][2]) te_tables = [te_args[i][0] for i in (2, 3, 4, 5)] # 注意:S-box 需要来自 args_template slot[5](跨会话恒定),无法从 e2e 推断 @@ -1468,11 +2007,20 @@ def derive_key1_from_key16(key16, te_tables=None, sbox=None): return k1 -def generate_encrypt_msg_offline(params, fk_extend, ts, rand_val, - key16=None, key1=None, - args_template=None, cb_json=None, - random_seed=None, xmidas=None, xmidas_token=None, - diagnostics=None): +def generate_encrypt_msg_offline( + params, + fk_extend, + ts, + rand_val, + key16=None, + key1=None, + args_template=None, + cb_json=None, + random_seed=None, + xmidas=None, + xmidas_token=None, + diagnostics=None, +): """纯离线生成 encrypt_msg(A2 路线,2026-08-10 双向量 E3 闭环)。 params: 订单字段字典(18 字段,见 build_plaintext);fk_extend: tdrc_session 串 @@ -1486,6 +2034,7 @@ def generate_encrypt_msg_offline(params, fk_extend, ts, rand_val, args_template: 常量表模板(跨次恒定,缺省用 work/replay/e2e/ws-args.json)。 """ import random as _r + if random_seed is not None: _r.seed(random_seed) replay = REPLAY @@ -1498,23 +2047,40 @@ def generate_encrypt_msg_offline(params, fk_extend, ts, rand_val, for _k in ("window", "self", "globalThis", "top", "parent", "frames"): window.set(_k, window) window.set("xMidasOps", xmidas) - window.set("xMidasToken", xmidas_token or "DE46DBA4754D42A6B66ADD4319FF80C144D9ED22ED73384C271793D9A9AA2FFBD6C104BE8D4A7F4ED2A8688FCB6F7540") + window.set( + "xMidasToken", + xmidas_token + or "DE46DBA4754D42A6B66ADD4319FF80C144D9ED22ED73384C271793D9A9AA2FFBD6C104BE8D4A7F4ED2A8688FCB6F7540", + ) window.set("xMidasVersion", "web_1.0.6") - cfg = JSObject(); wp = JSObject() + cfg = JSObject() + wp = JSObject() wp.set("old_encrypt_offerid", ["1450007826", "1110165571"]) - im = JSObject(); im.set("webpay", wp); cfg.set("itemMap", im) + im = JSObject() + im.set("webpay", wp) + cfg.set("itemMap", im) window.set("__midasStaticConfig_midas_webpay", cfg) vm = VM(bytecode, constants, window) - window.set("Math", vm._hosts["Math"]); window.set("Date", vm._hosts["Date"]) + window.set("Math", vm._hosts["Math"]) + window.set("Date", vm._hosts["Date"]) window.set("String", vm._hosts["String"]) - for k in ("parseInt", "parseFloat", "isNaN", "encodeURIComponent", "encodeURI", - "decodeURIComponent", "decodeURI"): + for k in ( + "parseInt", + "parseFloat", + "isNaN", + "encodeURIComponent", + "encodeURI", + "decodeURIComponent", + "decodeURI", + ): window.set(k, vm._hosts[k]) # 常量表模板(18 参结构,来自 e2e 点击) if args_template is None: - tpl = json.loads(json.loads((replay / "e2e" / "ws-args.json").read_text().strip())) + tpl = json.loads( + json.loads((replay / "e2e" / "ws-args.json").read_text().strip()) + ) tpl_args = decode_d(tpl["C"][2]) else: tpl_args = args_template @@ -1543,14 +2109,26 @@ def generate_encrypt_msg_offline(params, fk_extend, ts, rand_val, f"webSave 明文必须为 16 字节对齐,当前 {len(pb)} 字节;" "请用 _rand 补齐后再生成 encrypt_msg" ) + def _cont(*a): return None - te_cap = json.loads(json.loads((replay / "e2e" / "multi-1.json").read_text().strip())) + + te_cap = json.loads( + json.loads((replay / "e2e" / "multi-1.json").read_text().strip()) + ) te_args = decode_d(te_cap["C"][2]) te_tables = [te_args[i][0] for i in (2, 3, 4, 5)] outbuf = [] - tr_args = [[list(pb)], [_cont], [te_tables[0]], [te_tables[1]], - [te_tables[2]], [te_tables[3]], [outbuf], [key16]] + tr_args = [ + [list(pb)], + [_cont], + [te_tables[0]], + [te_tables[1]], + [te_tables[2]], + [te_tables[3]], + [outbuf], + [key16], + ] vm.run(vm.make(39808, tr_args, window, constants, None), []) # 2) 组装 webSave 18 参(表常量 + 随机 key + 变换缓冲) @@ -1559,8 +2137,8 @@ def generate_encrypt_msg_offline(params, fk_extend, ts, rand_val, args[i] = tpl_args[i] args[0] = [key1] args[6] = [key16] - args[7] = tpl_args[7] # [211, 602] - args[9] = [[]] # 输出密文缓冲(webSave 填充) + args[7] = tpl_args[7] # [211, 602] + args[9] = [[]] # 输出密文缓冲(webSave 填充) args[10] = [outbuf] cb_args[0] = args[10] cb_args[1] = args[6] @@ -1590,10 +2168,12 @@ def generate_encrypt_msg_offline(params, fk_extend, ts, rand_val, if vm.out_hex is None: raise RuntimeError("webSave capture failed") if diagnostics is not None: - diagnostics.update({ - "plaintext_length": len(pb), - "transform_output_length": len(outbuf), - "ciphertext_length": len(vm.out_arr), - "output_length_trace": vm.output_length_trace, - }) + diagnostics.update( + { + "plaintext_length": len(pb), + "transform_output_length": len(outbuf), + "ciphertext_length": len(vm.out_arr), + "output_length_trace": vm.output_length_trace, + } + ) return vm.out_hex diff --git a/services/yyb-worker/runtime/pyvm/goods.py b/services/yyb-worker/runtime/pyvm/goods.py index 427c836..e2bad87 100644 --- a/services/yyb-worker/runtime/pyvm/goods.py +++ b/services/yyb-worker/runtime/pyvm/goods.py @@ -7,6 +7,7 @@ → webSave(33 块变换) → encrypt_msg(1056 hex) """ + from __future__ import annotations import json @@ -16,17 +17,39 @@ from typing import Any from .algorithm import generate_encrypt_msg_offline REPLAY = Path(__file__).resolve().parent.parent / "replay" -ORDER_FIELDS = ["token_id", "openid", "openkey", "session_id", "session_type", "zoneid", - "pay_method", "buy_quantity", "mb_pwd", "pay_id", "auth_key", - "card_value", "accounttype", "provide_uin", "extend", "ts", - "from_h5", "webversion"] +ORDER_FIELDS = [ + "token_id", + "openid", + "openkey", + "session_id", + "session_type", + "zoneid", + "pay_method", + "buy_quantity", + "mb_pwd", + "pay_id", + "auth_key", + "card_value", + "accounttype", + "provide_uin", + "extend", + "ts", + "from_h5", + "webversion", +] class GoodsSession: """goods 会话态:xMidasOps(59640,服务端生成)+ key16/key1 + args_template。""" - def __init__(self, xmidas_ops: list, key16: list, key1: list, - args_template_d: str = "", xmidas_token: str = ""): + def __init__( + self, + xmidas_ops: list, + key16: list, + key1: list, + args_template_d: str = "", + xmidas_token: str = "", + ): self.xmidas_ops = xmidas_ops self.key16 = key16 self.key1 = key1 @@ -44,8 +67,13 @@ class GoodsSession: def from_session_state(cls, path: str | Path) -> "GoodsSession": """从 scripts/capture-session.mjs 生成的 session-state.json 加载。""" d = json.loads(Path(path).read_text(encoding="utf-8")) - return cls(d["xmidas_ops"], d["key16"], d["key1"], - d.get("args_template_d", ""), d.get("xmidas_token", "")) + return cls( + d["xmidas_ops"], + d["key16"], + d["key1"], + d.get("args_template_d", ""), + d.get("xmidas_token", ""), + ) def to_json(self) -> dict: return { @@ -58,17 +86,29 @@ class GoodsSession: @classmethod def from_json(cls, d: dict) -> "GoodsSession": - return cls(d["xmidas_ops"], d["key16"], d["key1"], - d.get("args_template_d", ""), d.get("xmidas_token", "")) + return cls( + d["xmidas_ops"], + d["key16"], + d["key1"], + d.get("args_template_d", ""), + d.get("xmidas_token", ""), + ) def generate_encrypt_msg(session: GoodsSession, order: dict) -> str: """用会话态 + 订单参数生成 goods encrypt_msg(1056 hex)。""" from .algorithm import decode_d + params = {k: order.get(k, "") for k in ORDER_FIELDS} args_tpl = decode_d(session.args_template_d) if session.args_template_d else None return generate_encrypt_msg_offline( - params, order.get("fk_extend", ""), order.get("ts", ""), order.get("_rand", ""), - xmidas=session.xmidas_ops, xmidas_token=session.xmidas_token, - args_template=args_tpl, key16=session.key16, key1=session.key1, + params, + order.get("fk_extend", ""), + order.get("ts", ""), + order.get("_rand", ""), + xmidas=session.xmidas_ops, + xmidas_token=session.xmidas_token, + args_template=args_tpl, + key16=session.key16, + key1=session.key1, ) diff --git a/services/yyb-worker/runtime/pyvm/login_profile.py b/services/yyb-worker/runtime/pyvm/login_profile.py index 1e990d0..42ff29d 100644 --- a/services/yyb-worker/runtime/pyvm/login_profile.py +++ b/services/yyb-worker/runtime/pyvm/login_profile.py @@ -1,4 +1,5 @@ """YYB mall APIs require different session fields for WeChat and QQ OAuth.""" + from __future__ import annotations WECHAT_APPID = "wxd44977328b36e647" diff --git a/services/yyb-worker/runtime/pyvm/mall.py b/services/yyb-worker/runtime/pyvm/mall.py index 9ae6b5e..81c29eb 100644 --- a/services/yyb-worker/runtime/pyvm/mall.py +++ b/services/yyb-worker/runtime/pyvm/mall.py @@ -12,6 +12,7 @@ mall 加密链路(E3 已验证,U-2022 闭环): 注意:xMidasOps 是 mall 详情页页面级数据表(59620 长度,服务端生成), 必须从浏览器捕获(与 goods 的 59640 不同)。 """ + from __future__ import annotations import copy @@ -24,9 +25,28 @@ from .algorithm import UNDEF, JSObject, Window from .pagedoo_vm import PagedooVM REPLAY = Path(__file__).resolve().parent.parent / "replay" / "mall" -GLOBALS = [UNDEF, None, True, False, 4294967295, 3995986053, 2103143698, 1622111212, - 4263108271, 3162892160, 1960464030, 2867129963, 3224029870, 3514649446, - 1382846327, 1898428403, 1268470028, 1457769175, 1595352606, 1100935262] +GLOBALS = [ + UNDEF, + None, + True, + False, + 4294967295, + 3995986053, + 2103143698, + 1622111212, + 4263108271, + 3162892160, + 1960464030, + 2867129963, + 3224029870, + 3514649446, + 1382846327, + 1898428403, + 1268470028, + 1457769175, + 1595352606, + 1100935262, +] class MallSession: @@ -45,9 +65,13 @@ class MallSession: def validate(self) -> None: if len(self.transform_input) != 18: - raise ValueError(f"transform_input 应为 18 槽,实际 {len(self.transform_input)}") + raise ValueError( + f"transform_input 应为 18 槽,实际 {len(self.transform_input)}" + ) if len(self.xmidas_ops) != 59620: - raise ValueError(f"mall xMidasOps 应为 59620(非 goods 59640),实际 {len(self.xmidas_ops)}") + raise ValueError( + f"mall xMidasOps 应为 59620(非 goods 59640),实际 {len(self.xmidas_ops)}" + ) mid = self.transform_input[10] if isinstance(mid, list) and mid and isinstance(mid[0], list): if len(mid[0]) != 624: @@ -61,7 +85,11 @@ class MallSession: - J|...|e377650|{JSON} e377650 创建参数(transform_input) - XMIDAS_OPS|url|59620数组 mall 详情页 xMidasOps """ - lines = Path(frames_jsonl).read_text(encoding="utf-8", errors="replace").splitlines() + lines = ( + Path(frames_jsonl) + .read_text(encoding="utf-8", errors="replace") + .splitlines() + ) transform_input = None xmidas = None # xMidasOps:取 mall 详情页(z.iwan / pagedoo)那条 @@ -85,7 +113,9 @@ class MallSession: transform_input = json.loads(l.split("|e377650|", 1)[1]) break if transform_input is None: - raise ValueError("frames.jsonl 中未找到 e377650 J 转储(需 mall 详情页购买触发加密)") + raise ValueError( + "frames.jsonl 中未找到 e377650 J 转储(需 mall 详情页购买触发加密)" + ) if xmidas is None: raise ValueError("frames.jsonl 中未找到 59620 长度 xMidasOps") return cls(transform_input, xmidas) @@ -112,8 +142,18 @@ def _mk_window(xmidas: list) -> Window: w.set("sessionStorage", JSObject()) w.set("screen", JSObject()) w.set("history", JSObject()) - w.set("XMLHttpRequest", type("XHR", (), { - "open": lambda *a: None, "send": lambda *a: None, "setRequestHeader": lambda *a: None})) + w.set( + "XMLHttpRequest", + type( + "XHR", + (), + { + "open": lambda *a: None, + "send": lambda *a: None, + "setRequestHeader": lambda *a: None, + }, + ), + ) w.set("fetch", lambda *a: None) w.set("xMidasOps", xmidas) return w @@ -143,5 +183,7 @@ def generate_encrypt_msg(session: MallSession, random_seed: int = 1) -> str: h9 = h[9][0] if isinstance(h[9], list) and h[9] else h[9] if not isinstance(h9, list) or len(h9) != 624: - raise RuntimeError(f"e377650 输出异常: {type(h9).__name__} len={len(h9) if isinstance(h9, list) else '?'}") + raise RuntimeError( + f"e377650 输出异常: {type(h9).__name__} len={len(h9) if isinstance(h9, list) else '?'}" + ) return "".join(f"{x & 255:02x}" for x in h9) diff --git a/services/yyb-worker/runtime/pyvm/order_status.py b/services/yyb-worker/runtime/pyvm/order_status.py index 35cec4d..8bd5691 100644 --- a/services/yyb-worker/runtime/pyvm/order_status.py +++ b/services/yyb-worker/runtime/pyvm/order_status.py @@ -1,4 +1,5 @@ """Read YYB's official order list after a payment is completed.""" + from __future__ import annotations from typing import Any @@ -70,7 +71,9 @@ def order_completion_states(document: dict[str, Any]) -> dict[str, bool]: } -def find_completed_order(document: dict[str, Any], previous_states: dict[str, bool]) -> dict[str, Any] | None: +def find_completed_order( + document: dict[str, Any], previous_states: dict[str, bool] +) -> dict[str, Any] | None: """Find an order that appeared or transitioned to completed after the QR display.""" for item in document.get("list", []): if not isinstance(item, dict): diff --git a/services/yyb-worker/runtime/pyvm/pagedoo_vm.py b/services/yyb-worker/runtime/pyvm/pagedoo_vm.py index 0fd1a6a..9ca3ce4 100644 --- a/services/yyb-worker/runtime/pyvm/pagedoo_vm.py +++ b/services/yyb-worker/runtime/pyvm/pagedoo_vm.py @@ -9,6 +9,7 @@ JS 语义辅助复用 algorithm.py(_ic/i32/js_add/js_index/JSObject/JSFunction - 帧 e377650 变换核心:Node 指令级复现(F-2060,254937/254938 一致) 输出编排帧链(e454218/e423160 getter 链)仍依赖 VM C 栈续延语义,见 cases/yyb-chaos-vm-xmidas-webnewencrypt.md 未决项。 """ + from __future__ import annotations import json @@ -66,6 +67,7 @@ __all__ = ["PagedooVM", "run_frame", "REPLAY"] # ---------------------------------------------------------------- 辅助 + def _cmp_lt(a, b): try: return a < b @@ -96,6 +98,7 @@ def _cmp_ge(a, b): # ---------------------------------------------------------------- 宿主函数 + def h_arr_push(this, *args): if this is UNDEF or this is None: this = [] @@ -173,8 +176,8 @@ def h_arr_splice(this, start, delete_count=0, *items): start = max(0, n + start) if delete_count is UNDEF or delete_count is None: delete_count = n - start - removed = this[start:start + delete_count] - del this[start:start + delete_count] + removed = this[start : start + delete_count] + del this[start : start + delete_count] for i, it in enumerate(items): this.insert(start + i, it) return removed @@ -239,7 +242,7 @@ def h_str_substr(this, start=0, length=None): start = max(0, n + start) if length is None or length is UNDEF: return this[start:] - return this[start:start + int(length)] + return this[start : start + int(length)] def h_str_substring(this, start=0, end=None): @@ -272,7 +275,10 @@ def _js_to_py(v): return [_js_to_py(x) for x in v] if isinstance(v, JSObject): keys = v._d if hasattr(v, "_d") else {} - return {k: _js_to_py(val) for k, val in (keys.items() if isinstance(keys, dict) else [])} + return { + k: _js_to_py(val) + for k, val in (keys.items() if isinstance(keys, dict) else []) + } if isinstance(v, Window): return {} return v @@ -293,11 +299,15 @@ def _pg_index(obj, key): """pagedoo 专用 js_index:补充字符串原型方法(goods 侧不需要)。""" if isinstance(obj, JSDate): if key == "getTime": - return HostFunction(lambda this: this.t if isinstance(this, JSDate) else obj.t, "getTime") + return HostFunction( + lambda this: this.t if isinstance(this, JSDate) else obj.t, "getTime" + ) if key == "toString": return HostFunction(lambda this: str(this), "toString") if key == "valueOf": - return HostFunction(lambda this: this.t if isinstance(this, JSDate) else obj.t, "valueOf") + return HostFunction( + lambda this: this.t if isinstance(this, JSDate) else obj.t, "valueOf" + ) if isinstance(obj, str): if isinstance(key, str): if key == "length": @@ -327,6 +337,7 @@ def _pg_index(obj, key): # ---------------------------------------------------------------- VM + class PagedooVM: """pagedoo CHAOS VM 解释器(108 opcode,0..107)。 @@ -345,6 +356,7 @@ class PagedooVM: self._root_this = UNDEF self._host_log = [] import random as _r + self._random = _r if random_seed is not None: self._random.seed(random_seed) @@ -353,7 +365,9 @@ class PagedooVM: def _build_hosts(self): m = {} m["Math"] = JSObject() - m["Math"].set("random", HostFunction(lambda this: self._random.random(), "random")) + m["Math"].set( + "random", HostFunction(lambda this: self._random.random(), "random") + ) m["Math"].set("floor", HostFunction(h_math_floor, "floor")) m["Math"].set("round", HostFunction(h_math_round, "round")) m["Math"].set("ceil", HostFunction(h_math_ceil, "ceil")) @@ -366,29 +380,61 @@ class PagedooVM: m["parseFloat"] = HostFunction(h_parsefloat, "parseFloat") m["isNaN"] = HostFunction(h_isnan, "isNaN") m["encodeURI"] = HostFunction(h_encodeuri, "encodeURI") - m["encodeURIComponent"] = HostFunction(h_encodeuricomponent, "encodeURIComponent") + m["encodeURIComponent"] = HostFunction( + h_encodeuricomponent, "encodeURIComponent" + ) m["decodeURI"] = HostFunction(h_decodeuri, "decodeURI") - m["decodeURIComponent"] = HostFunction(h_decodeuricomponent, "decodeURIComponent") + m["decodeURIComponent"] = HostFunction( + h_decodeuricomponent, "decodeURIComponent" + ) m["String"] = JSObject() - m["String"].set("fromCharCode", HostFunction(h_string_fromcharcode, "fromCharCode")) + m["String"].set( + "fromCharCode", HostFunction(h_string_fromcharcode, "fromCharCode") + ) m["Date"] = HostFunction(h_new_date, "Date") m["Array"] = JSObject() - m["Array"].set("isArray", HostFunction(lambda this, x: isinstance(x, list), "isArray")) + m["Array"].set( + "isArray", HostFunction(lambda this, x: isinstance(x, list), "isArray") + ) m["Object"] = HostFunction(h_object_ctor, "Object") - m["Number"] = HostFunction(lambda this, x=None: js_num(x) if x is not None and x is not UNDEF else 0.0, "Number") - m["Boolean"] = HostFunction(lambda this, x=None: bool(js_truthy(x)) if x is not None and x is not UNDEF else False, "Boolean") + m["Number"] = HostFunction( + lambda this, x=None: js_num(x) if x is not None and x is not UNDEF else 0.0, + "Number", + ) + m["Boolean"] = HostFunction( + lambda this, x=None: ( + bool(js_truthy(x)) if x is not None and x is not UNDEF else False + ), + "Boolean", + ) jso = JSObject() - jso.set("stringify", HostFunction(lambda this, x, *a: json.dumps(_js_to_py(x), ensure_ascii=False), "stringify")) - jso.set("parse", HostFunction(lambda this, s: _py_to_js(json.loads(s)), "parse")) + jso.set( + "stringify", + HostFunction( + lambda this, x, *a: json.dumps(_js_to_py(x), ensure_ascii=False), + "stringify", + ), + ) + jso.set( + "parse", HostFunction(lambda this, s: _py_to_js(json.loads(s)), "parse") + ) m["JSON"] = jso m["RegExp"] = HostFunction(lambda this, *a: JSObject(), "RegExp") # Array.prototype 方法挂到 Array 对象上(VM 通过 i[A].push 等调用) proto = JSObject() for name, fn in [ - ("push", h_arr_push), ("shift", h_arr_shift), ("join", h_arr_join), - ("slice", h_arr_slice), ("indexOf", h_arr_indexof), ("concat", h_arr_concat), - ("pop", h_arr_pop), ("unshift", h_arr_unshift), ("reverse", h_arr_reverse), - ("splice", h_arr_splice), ("map", h_arr_map), ("forEach", h_arr_foreach), + ("push", h_arr_push), + ("shift", h_arr_shift), + ("join", h_arr_join), + ("slice", h_arr_slice), + ("indexOf", h_arr_indexof), + ("concat", h_arr_concat), + ("pop", h_arr_pop), + ("unshift", h_arr_unshift), + ("reverse", h_arr_reverse), + ("splice", h_arr_splice), + ("map", h_arr_map), + ("forEach", h_arr_foreach), ("filter", h_arr_filter), ]: proto.set(name, HostFunction(fn, name)) @@ -427,10 +473,10 @@ class PagedooVM: C = list(C) p = UNDEF u = fn.entry - d = [] # 异常续延栈(JS C) - t = UNDEF # 最近异常值(op3/op52) + d = [] # 异常续延栈(JS C) + t = UNDEF # 最近异常值(op3/op52) o = self.o - l = fn.t # 异常处理器(JS l) + l = fn.t # 异常处理器(JS l) _get = _pg_index _set = js_set _arr = self._arr_method @@ -440,16 +486,36 @@ class PagedooVM: while True: u += 1 op = o[u] - if op in (0, 4, 11, 18, 23, 26, 43, 44, 48, 50, 84, 107) and len(self._host_log) < 2000: + if ( + op in (0, 4, 11, 18, 23, 26, 43, 44, 48, 50, 84, 107) + and len(self._host_log) < 2000 + ): # 记录调用目标(简化) - _tgt = o[u + 2] if op in (4, 11, 44, 50) else (o[u + 2] if op in (0, 18, 23, 26, 43, 48, 84, 107) else o[u + 2]) + _tgt = ( + o[u + 2] + if op in (4, 11, 44, 50) + else ( + o[u + 2] + if op in (0, 18, 23, 26, 43, 48, 84, 107) + else o[u + 2] + ) + ) try: _tv = C[_tgt] if 0 <= _tgt < len(C) else UNDEF - _tr = type(_tv).__name__ if not isinstance(_tv, (int, float, str, bool, type(None))) else repr(_tv)[:30] + _tr = ( + type(_tv).__name__ + if not isinstance( + _tv, (int, float, str, bool, type(None)) + ) + else repr(_tv)[:30] + ) self._host_log.append((op, u, _tr)) except Exception: self._host_log.append((op, u, "?")) - if getattr(self, "_trace", None) is not None and len(self._trace) < 500000: + if ( + getattr(self, "_trace", None) is not None + and len(self._trace) < 500000 + ): self._trace.append((op, u)) if self._csnap is not None and len(self._csnap) < 500: snap = [] @@ -472,60 +538,107 @@ class PagedooVM: # 约定:S() 读下一个槽索引操作数并取 C[slot];imm 直接读。 if op == 0: # for(h=[],f=c[++u];f>0;f--)h.push(i[c[++u]]);i[A]=i[B].apply(i[C],h) - f = o[u + 1]; u += 1 + f = o[u + 1] + u += 1 h = [] for _ in range(f): - h.append(_get(C, o[u + 1])); u += 1 - a = o[u + 1]; b = o[u + 2]; c = o[u + 3]; u += 3 + h.append(_get(C, o[u + 1])) + u += 1 + a = o[u + 1] + b = o[u + 2] + c = o[u + 3] + u += 3 _set(C, a, js_apply(_get(C, b), _get(C, c), h)) elif op == 1: - a = o[u + 1]; b = o[u + 2]; c = o[u + 3]; u += 3 + a = o[u + 1] + b = o[u + 2] + c = o[u + 3] + u += 3 _set(C, a, js_num(_get(C, b)) - js_num(_get(C, c))) elif op == 2: - a = o[u + 1]; u += 1 + a = o[u + 1] + u += 1 _set(C, a, False) elif op == 3: - a = o[u + 1]; u += 1 + a = o[u + 1] + u += 1 _set(C, a, t) elif op == 4: - a = o[u + 1]; b = o[u + 2]; c = o[u + 3]; dd = o[u + 4]; u += 4 + a = o[u + 1] + b = o[u + 2] + c = o[u + 3] + dd = o[u + 4] + u += 4 _set(C, a, js_call(_get(C, b), p, [_get(C, c), _get(C, dd)])) elif op == 5: - a = o[u + 1]; b = o[u + 2]; u += 2 + a = o[u + 1] + b = o[u + 2] + u += 2 _set(C, a, _get(C, b)) - c = o[u + 1]; dd = o[u + 2]; imm = o[u + 3]; u += 3 + c = o[u + 1] + dd = o[u + 2] + imm = o[u + 3] + u += 3 _set(C, c, _get(_get(C, dd), imm)) - e = o[u + 1]; u += 1 + e = o[u + 1] + u += 1 _set(C, e, "") elif op == 6: - a = o[u + 1]; b = o[u + 2]; c = o[u + 3]; u += 3 + a = o[u + 1] + b = o[u + 2] + c = o[u + 3] + u += 3 _set(C, a, _cmp_lt(js_num(_get(C, b)), js_num(_get(C, c)))) elif op == 7: - a = o[u + 1]; b = o[u + 2]; c = o[u + 3]; u += 3 + a = o[u + 1] + b = o[u + 2] + c = o[u + 3] + u += 3 _set(C, a, js_del(_get(C, b), _get(C, c))) elif op == 8: - a = o[u + 1]; b = o[u + 2]; imm1 = o[u + 3]; u += 3 + a = o[u + 1] + b = o[u + 2] + imm1 = o[u + 3] + u += 3 _set(C, a, _get(_get(C, b), imm1)) - c = o[u + 1]; dd = o[u + 2]; imm2 = o[u + 3]; u += 3 + c = o[u + 1] + dd = o[u + 2] + imm2 = o[u + 3] + u += 3 _set(C, c, _get(_get(C, dd), imm2)) elif op == 9: if d: d.pop() elif op == 10: - f = o[u + 1]; u += 1 + f = o[u + 1] + u += 1 h = [] for _ in range(f): - h.append(_get(C, o[u + 1])); u += 1 - dest = o[u + 1]; off = o[u + 2]; u += 2 + h.append(_get(C, o[u + 1])) + u += 1 + dest = o[u + 1] + off = o[u + 2] + u += 2 _set(C, dest, self.make(u + off, h, C[0], C[1], l)) elif op == 11: - a = o[u + 1]; b = o[u + 2]; c = o[u + 3]; u += 3 + a = o[u + 1] + b = o[u + 2] + c = o[u + 3] + u += 3 _set(C, a, js_call(_get(C, b), p, [_get(C, c)])) elif op == 12: - a = o[u + 1]; b = o[u + 2]; imm = o[u + 3]; u += 3 + a = o[u + 1] + b = o[u + 2] + imm = o[u + 3] + u += 3 _set(C, a, shl(js_num(_get(C, b)), imm)) elif op == 13: - a = o[u + 1]; b = o[u + 2]; c = o[u + 3]; dd = o[u + 4]; e = o[u + 5]; u += 5 + a = o[u + 1] + b = o[u + 2] + c = o[u + 3] + dd = o[u + 4] + e = o[u + 5] + u += 5 _set(C, a, _get(C, b)) v = _cmp_lt(js_num(_get(C, dd)), js_num(_get(C, e))) _set(C, c, v) @@ -535,35 +648,73 @@ class PagedooVM: else: u = u0 + o[u0 + 2] elif op == 14: - a = o[u + 1]; b = o[u + 2]; c = o[u + 3]; u += 3 + a = o[u + 1] + b = o[u + 2] + c = o[u + 3] + u += 3 _set(C, a, i32(_ic(_get(C, b))) | i32(_ic(_get(C, c)))) elif op == 15: - a = o[u + 1]; b = o[u + 2]; imm = o[u + 3]; u += 3 + a = o[u + 1] + b = o[u + 2] + imm = o[u + 3] + u += 3 _set(C, a, js_num(_get(C, b)) - imm) elif op == 16: - a = o[u + 1]; b = o[u + 2]; u += 2 + a = o[u + 1] + b = o[u + 2] + u += 2 _set(C, a, js_new(_get(C, b), [])) elif op == 17: - a = o[u + 1]; b = o[u + 2]; imm = o[u + 3]; u += 3 + a = o[u + 1] + b = o[u + 2] + imm = o[u + 3] + u += 3 _set(C, a, "") _set(C, b, js_str(_get(C, b)) + chr(imm & 0xFFFF)) elif op == 18: - a = o[u + 1]; b = o[u + 2]; c = o[u + 3]; dd = o[u + 4]; e = o[u + 5]; f = o[u + 6]; u += 6 - _set(C, a, js_call(_get(C, b), _get(C, c), [_get(C, dd), _get(C, e), _get(C, f)])) + a = o[u + 1] + b = o[u + 2] + c = o[u + 3] + dd = o[u + 4] + e = o[u + 5] + f = o[u + 6] + u += 6 + _set( + C, + a, + js_call( + _get(C, b), + _get(C, c), + [_get(C, dd), _get(C, e), _get(C, f)], + ), + ) elif op == 19: - a = o[u + 1]; b = o[u + 2]; u += 2 + a = o[u + 1] + b = o[u + 2] + u += 2 _set(C, a, _get(C, b)) - c = o[u + 1]; imm = o[u + 2]; u += 2 + c = o[u + 1] + imm = o[u + 2] + u += 2 _set(C, c, imm) - dd = o[u + 1]; e = o[u + 2]; u += 2 + dd = o[u + 1] + e = o[u + 2] + u += 2 _set(C, dd, _get(C, e)) elif op == 20: - a = o[u + 1]; u += 1 + a = o[u + 1] + u += 1 _set(C, a, "") elif op == 21: - a = o[u + 1]; b = o[u + 2]; c = o[u + 3]; u += 3 + a = o[u + 1] + b = o[u + 2] + c = o[u + 3] + u += 3 _set(C, a, _get(_get(C, b), _get(C, c))) - dd = o[u + 1]; e = o[u + 2]; imm = o[u + 3]; u += 3 + dd = o[u + 1] + e = o[u + 2] + imm = o[u + 3] + u += 3 v = _cmp_gt(js_num(_get(C, e)), imm) _set(C, dd, v) u0 = u @@ -572,46 +723,103 @@ class PagedooVM: else: u = u0 + o[u0 + 2] elif op == 22: - a = o[u + 1]; b = o[u + 2]; c = o[u + 3]; u += 3 + a = o[u + 1] + b = o[u + 2] + c = o[u + 3] + u += 3 _set(C, a, js_num(_get(C, b)) * js_num(_get(C, c))) elif op == 23: - a = o[u + 1]; b = o[u + 2]; c = o[u + 3]; dd = o[u + 4]; u += 4 + a = o[u + 1] + b = o[u + 2] + c = o[u + 3] + dd = o[u + 4] + u += 4 _set(C, a, js_call(_get(C, b), _get(C, c), [_get(C, dd)])) elif op == 24: - a = o[u + 1]; b = o[u + 2]; u += 2 + a = o[u + 1] + b = o[u + 2] + u += 2 _set(C, a, js_num(_get(C, b))) - c = o[u + 1]; dd = o[u + 2]; u += 2 + c = o[u + 1] + dd = o[u + 2] + u += 2 _set(C, c, js_num(_get(C, dd)) + 1) - e = o[u + 1]; f = o[u + 2]; u += 2 + e = o[u + 1] + f = o[u + 2] + u += 2 _set(C, e, _get(C, f)) elif op == 25: - a = o[u + 1]; b = o[u + 2]; c = o[u + 3]; dd = o[u + 4]; e = o[u + 5]; u += 5 - _set(C, a, js_new(_get(C, b), [_get(C, c), _get(C, dd), _get(C, e)])) + a = o[u + 1] + b = o[u + 2] + c = o[u + 3] + dd = o[u + 4] + e = o[u + 5] + u += 5 + _set( + C, + a, + js_new(_get(C, b), [_get(C, c), _get(C, dd), _get(C, e)]), + ) elif op == 26: - a = o[u + 1]; b = o[u + 2]; c = o[u + 3]; u += 3 + a = o[u + 1] + b = o[u + 2] + c = o[u + 3] + u += 3 _set(C, a, _get(_get(C, b), _get(C, c))) - dd = o[u + 1]; e = o[u + 2]; f = o[u + 3]; g = o[u + 4]; u += 4 + dd = o[u + 1] + e = o[u + 2] + f = o[u + 3] + g = o[u + 4] + u += 4 _set(C, dd, js_call(_get(C, e), _get(C, f), [_get(C, g)])) - hh = o[u + 1]; ii = o[u + 2]; j = o[u + 3]; kk = o[u + 4]; ll = o[u + 5]; u += 5 - _set(C, hh, js_call(_get(C, ii), _get(C, j), [_get(C, kk), _get(C, ll)])) + hh = o[u + 1] + ii = o[u + 2] + j = o[u + 3] + kk = o[u + 4] + ll = o[u + 5] + u += 5 + _set( + C, + hh, + js_call( + _get(C, ii), _get(C, j), [_get(C, kk), _get(C, ll)] + ), + ) elif op == 27: - a = o[u + 1]; n = o[u + 2]; u += 2 + a = o[u + 1] + n = o[u + 2] + u += 2 _set(C, a, [UNDEF] * int(n)) elif op == 28: - a = o[u + 1]; b = o[u + 2]; c = o[u + 3]; u += 3 + a = o[u + 1] + b = o[u + 2] + c = o[u + 3] + u += 3 _set(C, a, i32(_ic(_get(C, b))) ^ i32(_ic(_get(C, c)))) elif op == 29: - a = o[u + 1]; b = o[u + 2]; imm = o[u + 3]; u += 3 + a = o[u + 1] + b = o[u + 2] + imm = o[u + 3] + u += 3 _set(C, a, _cmp_gt(js_num(_get(C, b)), imm)) elif op == 30: - a = o[u + 1]; b = o[u + 2]; c = o[u + 3]; u += 3 + a = o[u + 1] + b = o[u + 2] + c = o[u + 3] + u += 3 _set(C, a, i32(_ic(_get(C, b))) & i32(_ic(_get(C, c)))) elif op == 31: - a = o[u + 1]; b = o[u + 2]; u += 2 + a = o[u + 1] + b = o[u + 2] + u += 2 _set(C, a, _get(C, b)) - c = o[u + 1]; dd = o[u + 2]; u += 2 + c = o[u + 1] + dd = o[u + 2] + u += 2 _set(C, c, js_num(_get(C, dd))) - e = o[u + 1]; f = o[u + 2]; u += 2 + e = o[u + 1] + f = o[u + 2] + u += 2 _set(C, e, js_num(_get(C, f)) + 1) elif op == 32: # u += i[A] ? c[++u] : c[(++u,++u)] —— LHS u 在 body 开始读取 @@ -622,103 +830,203 @@ class PagedooVM: else: u = u0 + o[u0 + 3] elif op == 33: - a = o[u + 1]; imm = o[u + 2]; u += 2 + a = o[u + 1] + imm = o[u + 2] + u += 2 _set(C, a, js_str(_get(C, a)) + chr(imm & 0xFFFF)) - b = o[u + 1]; c = o[u + 2]; dd = o[u + 3]; u += 3 + b = o[u + 1] + c = o[u + 2] + dd = o[u + 3] + u += 3 _set(C, b, _get(_get(C, c), _get(C, dd))) elif op == 34: - a = o[u + 1]; b = o[u + 2]; u += 2 + a = o[u + 1] + b = o[u + 2] + u += 2 _set(C, a, js_typeof(_get(C, b))) elif op == 35: - a = o[u + 1]; b = o[u + 2]; c = o[u + 3]; u += 3 + a = o[u + 1] + b = o[u + 2] + c = o[u + 3] + u += 3 _set(C, a, _cmp_le(js_num(_get(C, b)), js_num(_get(C, c)))) elif op == 36: - a = o[u + 1]; b = o[u + 2]; u += 2 + a = o[u + 1] + b = o[u + 2] + u += 2 _set(C, a, -js_num(_get(C, b))) elif op == 37: - a = o[u + 1]; u += 1 + a = o[u + 1] + u += 1 _set(C, a, True) elif op == 38: - a = o[u + 1]; b = o[u + 2]; c = o[u + 3]; u += 3 - _set(C, a, isinstance(_get(C, b), type(_get(C, c))) if isinstance(_get(C, c), type) else False) + a = o[u + 1] + b = o[u + 2] + c = o[u + 3] + u += 3 + _set( + C, + a, + isinstance(_get(C, b), type(_get(C, c))) + if isinstance(_get(C, c), type) + else False, + ) elif op == 39: - a = o[u + 1]; b = o[u + 2]; imm = o[u + 3]; u += 3 + a = o[u + 1] + b = o[u + 2] + imm = o[u + 3] + u += 3 _set(C, a, js_add(_get(C, b), imm)) elif op == 40: - a = o[u + 1]; b = o[u + 2]; c = o[u + 3]; u += 3 + a = o[u + 1] + b = o[u + 2] + c = o[u + 3] + u += 3 js_set(_get(C, a), _get(C, b), _get(C, c)) elif op == 41: - a = o[u + 1]; imm = o[u + 2]; u += 2 + a = o[u + 1] + imm = o[u + 2] + u += 2 _set(C, a, imm) - b = o[u + 1]; u += 1 + b = o[u + 1] + u += 1 _set(C, b, _get(C, b)) - c = o[u + 1]; dd = o[u + 2]; e = o[u + 3]; u += 3 + c = o[u + 1] + dd = o[u + 2] + e = o[u + 3] + u += 3 _set(C, c, _cmp_lt(js_num(_get(C, dd)), js_num(_get(C, e)))) elif op == 42: - a = o[u + 1]; b = o[u + 2]; c = o[u + 3]; u += 3 + a = o[u + 1] + b = o[u + 2] + c = o[u + 3] + u += 3 _set(C, a, js_num(_get(C, b)) / js_num(_get(C, c))) elif op == 43: - a = o[u + 1]; b = o[u + 2]; c = o[u + 3]; dd = o[u + 4]; e = o[u + 5]; u += 5 - _set(C, a, js_call(_get(C, b), _get(C, c), [_get(C, dd), _get(C, e)])) + a = o[u + 1] + b = o[u + 2] + c = o[u + 3] + dd = o[u + 4] + e = o[u + 5] + u += 5 + _set( + C, + a, + js_call(_get(C, b), _get(C, c), [_get(C, dd), _get(C, e)]), + ) elif op == 44: - a = o[u + 1]; b = o[u + 2]; u += 2 + a = o[u + 1] + b = o[u + 2] + u += 2 _bv = _get(C, b) if getattr(self, "_dbg_u", None) == u: - print(f" [dbg] op44@{u}: A={a} B={b} i[B]={type(_bv).__name__}: {str(_bv)[:80]}") + print( + f" [dbg] op44@{u}: A={a} B={b} i[B]={type(_bv).__name__}: {str(_bv)[:80]}" + ) _set(C, a, js_call(_bv, p, [])) elif op == 45: - a = o[u + 1]; u += 1 + a = o[u + 1] + u += 1 _set(C, a, p) - b = o[u + 1]; n = o[u + 2]; u += 2 + b = o[u + 1] + n = o[u + 2] + u += 2 _set(C, b, [UNDEF] * int(n)) - c = o[u + 1]; u += 1 + c = o[u + 1] + u += 1 _set(C, c, "") elif op == 46: - a = o[u + 1]; u += 1 + a = o[u + 1] + u += 1 obj = JSObject() _set(C, a, obj) - b = o[u + 1]; imm = o[u + 2]; c = o[u + 3]; u += 3 + b = o[u + 1] + imm = o[u + 2] + c = o[u + 3] + u += 3 js_set(_get(C, b), imm, _get(C, c)) - dd = o[u + 1]; imm2 = o[u + 2]; e = o[u + 3]; u += 3 + dd = o[u + 1] + imm2 = o[u + 2] + e = o[u + 3] + u += 3 js_set(_get(C, dd), imm2, _get(C, e)) elif op == 47: - a = o[u + 1]; imm = o[u + 2]; u += 2 + a = o[u + 1] + imm = o[u + 2] + u += 2 _set(C, a, js_str(_get(C, a)) + chr(imm & 0xFFFF)) elif op == 48: - a = o[u + 1]; b = o[u + 2]; c = o[u + 3]; dd = o[u + 4]; u += 4 + a = o[u + 1] + b = o[u + 2] + c = o[u + 3] + dd = o[u + 4] + u += 4 _set(C, a, js_call(_get(C, b), _get(C, c), [_get(C, dd)])) - e = o[u + 1]; f = o[u + 2]; g = o[u + 3]; hh = o[u + 4]; ii = o[u + 5]; j = o[u + 6]; u += 6 - _set(C, e, js_call(_get(C, f), _get(C, g), [_get(C, hh), _get(C, ii)])) + e = o[u + 1] + f = o[u + 2] + g = o[u + 3] + hh = o[u + 4] + ii = o[u + 5] + j = o[u + 6] + u += 6 + _set( + C, + e, + js_call(_get(C, f), _get(C, g), [_get(C, hh), _get(C, ii)]), + ) return _get(C, j) elif op == 49: - a = o[u + 1]; u += 1 + a = o[u + 1] + u += 1 _set(C, a, JSObject()) elif op == 50: - a = o[u + 1]; b = o[u + 2]; c = o[u + 3]; dd = o[u + 4]; u += 4 + a = o[u + 1] + b = o[u + 2] + c = o[u + 3] + dd = o[u + 4] + u += 4 _set(C, a, js_call(_get(C, b), p, [_get(C, c), _get(C, dd)])) elif op == 51: - a = o[u + 1]; imm = o[u + 2]; u += 2 + a = o[u + 1] + imm = o[u + 2] + u += 2 _set(C, a, js_str(_get(C, a)) + chr(imm & 0xFFFF)) - f = o[u + 1]; u += 1 + f = o[u + 1] + u += 1 h = [] for _ in range(f): - h.append(_get(C, o[u + 1])); u += 1 - dest = o[u + 1]; off = o[u + 2]; u += 2 + h.append(_get(C, o[u + 1])) + u += 1 + dest = o[u + 1] + off = o[u + 2] + u += 2 frame = self.make(u + off, h, C[0], C[1], l) _set(C, dest, frame) - b = o[u + 1]; c = o[u + 2]; e = o[u + 3]; u += 3 + b = o[u + 1] + c = o[u + 2] + e = o[u + 3] + u += 3 js_set(_get(C, b), _get(C, c), _get(C, e)) elif op == 52: - t = _get(C, o[u + 1]); u += 1 + t = _get(C, o[u + 1]) + u += 1 raise _VMThrow(t) elif op == 53: - a = o[u + 1]; b = o[u + 2]; u += 2 + a = o[u + 1] + b = o[u + 2] + u += 2 _set(C, a, js_num(_get(C, b))) elif op == 54: - a = o[u + 1]; b = o[u + 2]; imm = o[u + 3]; u += 3 + a = o[u + 1] + b = o[u + 2] + imm = o[u + 3] + u += 3 _set(C, a, _cmp_lt(js_num(_get(C, b)), imm)) elif op == 55: - a = o[u + 1]; b = o[u + 2]; c = o[u + 3]; u += 3 + a = o[u + 1] + b = o[u + 2] + c = o[u + 3] + u += 3 _set(C, a, _get(_get(C, b), _get(C, c))) elif op == 56: # C.push(u+c[++u]) —— LHS u 在 c[++u] 前读取,且 u 推进 1 @@ -727,209 +1035,385 @@ class PagedooVM: d.append(u0 + imm) u = u0 + 1 elif op == 57: - a = o[u + 1]; b = o[u + 2]; c = o[u + 3]; u += 3 + a = o[u + 1] + b = o[u + 2] + c = o[u + 3] + u += 3 _set(C, a, _cmp_ge(js_num(_get(C, b)), js_num(_get(C, c)))) elif op == 58: - a = o[u + 1]; b = o[u + 2]; c = o[u + 3]; u += 3 + a = o[u + 1] + b = o[u + 2] + c = o[u + 3] + u += 3 _set(C, a, js_eq(_get(C, b), _get(C, c))) elif op == 59: - a = o[u + 1]; b = o[u + 2]; u += 2 + a = o[u + 1] + b = o[u + 2] + u += 2 _set(C, a, js_keys(_get(C, b))) elif op == 60: u += o[u + 1] elif op == 61: - a = o[u + 1]; imm = o[u + 2]; b = o[u + 3]; u += 3 + a = o[u + 1] + imm = o[u + 2] + b = o[u + 3] + u += 3 js_set(_get(C, a), imm, _get(C, b)) elif op == 62: - a = o[u + 1]; b = o[u + 2]; u += 2 + a = o[u + 1] + b = o[u + 2] + u += 2 _set(C, a, _get(C, b)) - c = o[u + 1]; dd = o[u + 2]; e = o[u + 3]; u += 3 + c = o[u + 1] + dd = o[u + 2] + e = o[u + 3] + u += 3 js_set(_get(C, c), _get(C, dd), _get(C, e)) elif op == 63: return _get(C, o[u + 1]) elif op == 64: - a = o[u + 1]; b = o[u + 2]; imm = o[u + 3]; u += 3 + a = o[u + 1] + b = o[u + 2] + imm = o[u + 3] + u += 3 _set(C, a, _get(_get(C, b), imm)) - c = o[u + 1]; u += 1 + c = o[u + 1] + u += 1 _set(C, c, "") - dd = o[u + 1]; imm2 = o[u + 2]; u += 2 + dd = o[u + 1] + imm2 = o[u + 2] + u += 2 _set(C, dd, js_str(_get(C, dd)) + chr(imm2 & 0xFFFF)) elif op == 65: - a = o[u + 1]; b = o[u + 2]; c = o[u + 3]; u += 3 + a = o[u + 1] + b = o[u + 2] + c = o[u + 3] + u += 3 _set(C, a, shr(js_num(_get(C, b)), js_num(_get(C, c)))) elif op == 66: - a = o[u + 1]; imm1 = o[u + 2]; u += 2 + a = o[u + 1] + imm1 = o[u + 2] + u += 2 _set(C, a, js_str(_get(C, a)) + chr(imm1 & 0xFFFF)) - b = o[u + 1]; imm2 = o[u + 2]; u += 2 + b = o[u + 1] + imm2 = o[u + 2] + u += 2 _set(C, b, js_str(_get(C, b)) + chr(imm2 & 0xFFFF)) elif op == 67: - a = o[u + 1]; u += 1 + a = o[u + 1] + u += 1 _set(C, a, p) elif op == 68: - a = o[u + 1]; b = o[u + 2]; c = o[u + 3]; u += 3 + a = o[u + 1] + b = o[u + 2] + c = o[u + 3] + u += 3 _set(C, a, _get(_get(C, b), _get(C, c))) - dd = o[u + 1]; e = o[u + 2]; u += 2 + dd = o[u + 1] + e = o[u + 2] + u += 2 _set(C, dd, _get(C, e)) - f = o[u + 1]; u += 1 + f = o[u + 1] + u += 1 _set(C, f, "") elif op == 69: - a = o[u + 1]; imm = o[u + 2]; u += 2 + a = o[u + 1] + imm = o[u + 2] + u += 2 _set(C, a, js_num(_get(C, a)) - imm) - b = o[u + 1]; c = o[u + 2]; dd = o[u + 3]; e = o[u + 4]; u += 4 + b = o[u + 1] + c = o[u + 2] + dd = o[u + 3] + e = o[u + 4] + u += 4 _set(C, b, js_new(_get(C, c), [_get(C, dd), _get(C, e)])) - f = o[u + 1]; g = o[u + 2]; u += 2 + f = o[u + 1] + g = o[u + 2] + u += 2 _set(C, f, _get(C, g)) elif op == 70: - a = o[u + 1]; b = o[u + 2]; imm = o[u + 3]; u += 3 + a = o[u + 1] + b = o[u + 2] + imm = o[u + 3] + u += 3 _set(C, a, i32(_ic(_get(C, b))) | imm) elif op == 71: - f = o[u + 1]; u += 1 + f = o[u + 1] + u += 1 h = [] for _ in range(f): - h.append(_get(C, o[u + 1])); u += 1 - dest = o[u + 1]; off = o[u + 2]; u += 2 + h.append(_get(C, o[u + 1])) + u += 1 + dest = o[u + 1] + off = o[u + 2] + u += 2 _set(C, dest, self.make(u + off, h, C[0], C[1], l)) elif op == 72: - a = o[u + 1]; u += 1 + a = o[u + 1] + u += 1 _set(C, a, None) elif op == 73: - a = o[u + 1]; b = o[u + 2]; imm = o[u + 3]; u += 3 + a = o[u + 1] + b = o[u + 2] + imm = o[u + 3] + u += 3 _set(C, a, i32(_ic(_get(C, b))) & imm) elif op == 74: - a = o[u + 1]; imm1 = o[u + 2]; b = o[u + 3]; u += 3 + a = o[u + 1] + imm1 = o[u + 2] + b = o[u + 3] + u += 3 js_set(_get(C, a), imm1, _get(C, b)) - c = o[u + 1]; u += 1 + c = o[u + 1] + u += 1 obj = JSObject() _set(C, c, obj) - dd = o[u + 1]; imm2 = o[u + 2]; e = o[u + 3]; u += 3 + dd = o[u + 1] + imm2 = o[u + 2] + e = o[u + 3] + u += 3 js_set(_get(C, dd), imm2, _get(C, e)) elif op == 75: - a = o[u + 1]; b = o[u + 2]; imm = o[u + 3]; u += 3 + a = o[u + 1] + b = o[u + 2] + imm = o[u + 3] + u += 3 _set(C, a, shr(js_num(_get(C, b)), imm)) elif op == 76: - a = o[u + 1]; imm = o[u + 2]; b = o[u + 3]; u += 3 + a = o[u + 1] + imm = o[u + 2] + b = o[u + 3] + u += 3 _set(C, a, imm + js_num(_get(C, b))) elif op == 77: - a = o[u + 1]; b = o[u + 2]; c = o[u + 3]; dd = o[u + 4]; u += 4 + a = o[u + 1] + b = o[u + 2] + c = o[u + 3] + dd = o[u + 4] + u += 4 _set(C, a, js_new(_get(C, b), [_get(C, c), _get(C, dd)])) elif op == 78: - a = o[u + 1]; b = o[u + 2]; c = o[u + 3]; u += 3 + a = o[u + 1] + b = o[u + 2] + c = o[u + 3] + u += 3 _set(C, a, ushr(js_num(_get(C, b)), js_num(_get(C, c)))) elif op == 79: - a = o[u + 1]; u += 1 + a = o[u + 1] + u += 1 _set(C, a, js_num(_get(C, a)) + 1) elif op == 80: - a = o[u + 1]; imm = o[u + 2]; u += 2 + a = o[u + 1] + imm = o[u + 2] + u += 2 _set(C, a, imm) elif op == 81: - a = o[u + 1]; imm = o[u + 2]; b = o[u + 3]; u += 3 + a = o[u + 1] + imm = o[u + 2] + b = o[u + 3] + u += 3 _set(C, a, imm - js_num(_get(C, b))) elif op == 82: - a = o[u + 1]; b = o[u + 2]; c = o[u + 3]; u += 3 + a = o[u + 1] + b = o[u + 2] + c = o[u + 3] + u += 3 _set(C, a, _cmp_gt(js_num(_get(C, b)), js_num(_get(C, c)))) elif op == 83: - a = o[u + 1]; b = o[u + 2]; imm = o[u + 3]; u += 3 + a = o[u + 1] + b = o[u + 2] + imm = o[u + 3] + u += 3 _set(C, a, ushr(js_num(_get(C, b)), imm)) elif op == 84: - a = o[u + 1]; b = o[u + 2]; c = o[u + 3]; u += 3 + a = o[u + 1] + b = o[u + 2] + c = o[u + 3] + u += 3 _set(C, a, js_call(_get(C, b), _get(C, c), [])) elif op == 85: - a = o[u + 1]; imm1 = o[u + 2]; b = o[u + 3]; u += 3 + a = o[u + 1] + imm1 = o[u + 2] + b = o[u + 3] + u += 3 js_set(_get(C, a), imm1, _get(C, b)) - c = o[u + 1]; imm2 = o[u + 2]; dd = o[u + 3]; u += 3 + c = o[u + 1] + imm2 = o[u + 2] + dd = o[u + 3] + u += 3 js_set(_get(C, c), imm2, _get(C, dd)) - e = o[u + 1]; u += 1 + e = o[u + 1] + u += 1 _set(C, e, "") elif op == 86: - a = o[u + 1]; b = o[u + 2]; u += 2 + a = o[u + 1] + b = o[u + 2] + u += 2 h = _get(C, a) if h is not UNDEF and h is not None and len(h): _set(C, b, True) - c = o[u + 1]; u += 1 + c = o[u + 1] + u += 1 _set(C, c, h.pop(0)) else: _set(C, b, False) u += 1 elif op == 87: - a = o[u + 1]; b = o[u + 2]; imm = o[u + 3]; u += 3 + a = o[u + 1] + b = o[u + 2] + imm = o[u + 3] + u += 3 _set(C, a, _get(_get(C, b), imm)) elif op == 88: - a = o[u + 1]; u += 1 + a = o[u + 1] + u += 1 _set(C, a, js_num(_get(C, a)) - 1) elif op == 89: - a = o[u + 1]; b = o[u + 2]; c = o[u + 3]; u += 3 + a = o[u + 1] + b = o[u + 2] + c = o[u + 3] + u += 3 _set(C, a, shl(js_num(_get(C, b)), js_num(_get(C, c)))) elif op == 90: - a = o[u + 1]; b = o[u + 2]; c = o[u + 3]; u += 3 + a = o[u + 1] + b = o[u + 2] + c = o[u + 3] + u += 3 _set(C, a, js_eq(_get(C, b), _get(C, c))) elif op == 91: - a = o[u + 1]; b = o[u + 2]; c = o[u + 3]; u += 3 + a = o[u + 1] + b = o[u + 2] + c = o[u + 3] + u += 3 _set(C, a, js_new(_get(C, b), [_get(C, c)])) elif op == 92: - a = o[u + 1]; b = o[u + 2]; imm = o[u + 3]; u += 3 + a = o[u + 1] + b = o[u + 2] + imm = o[u + 3] + u += 3 _set(C, a, _cmp_le(js_num(_get(C, b)), imm)) elif op == 93: - a = o[u + 1]; b = o[u + 2]; c = o[u + 3]; u += 3 + a = o[u + 1] + b = o[u + 2] + c = o[u + 3] + u += 3 _set(C, a, js_num(_get(C, b)) % js_num(_get(C, c))) elif op == 94: - a = o[u + 1]; b = o[u + 2]; imm = o[u + 3]; u += 3 + a = o[u + 1] + b = o[u + 2] + imm = o[u + 3] + u += 3 _set(C, a, i32(_ic(_get(C, b))) ^ imm) elif op == 95: - a = o[u + 1]; b = o[u + 2]; c = o[u + 3]; u += 3 + a = o[u + 1] + b = o[u + 2] + c = o[u + 3] + u += 3 _set(C, a, js_new(_get(C, b), [_get(C, c)])) - dd = o[u + 1]; e = o[u + 2]; u += 2 + dd = o[u + 1] + e = o[u + 2] + u += 2 _set(C, dd, _get(C, e)) - f = o[u + 1]; imm = o[u + 2]; u += 2 + f = o[u + 1] + imm = o[u + 2] + u += 2 _set(C, f, imm) elif op == 96: - a = o[u + 1]; b = o[u + 2]; c = o[u + 3]; u += 3 + a = o[u + 1] + b = o[u + 2] + c = o[u + 3] + u += 3 obj2 = _get(C, c) - _set(C, a, _get(C, b) in obj2 if isinstance(obj2, (list, dict, JSObject)) else False) + _set( + C, + a, + _get(C, b) in obj2 + if isinstance(obj2, (list, dict, JSObject)) + else False, + ) elif op == 97: - a = o[u + 1]; b = o[u + 2]; u += 2 + a = o[u + 1] + b = o[u + 2] + u += 2 _set(C, a, not js_truthy(_get(C, b))) elif op == 98: - a = o[u + 1]; b = o[u + 2]; imm = o[u + 3]; u += 3 + a = o[u + 1] + b = o[u + 2] + imm = o[u + 3] + u += 3 _set(C, a, _cmp_ge(js_num(_get(C, b)), imm)) elif op == 99: - a = o[u + 1]; b = o[u + 2]; c = o[u + 3]; u += 3 + a = o[u + 1] + b = o[u + 2] + c = o[u + 3] + u += 3 _set(C, a, js_add(_get(C, b), _get(C, c))) elif op == 100: - a = o[u + 1]; b = o[u + 2]; imm = o[u + 3]; u += 3 + a = o[u + 1] + b = o[u + 2] + imm = o[u + 3] + u += 3 _set(C, a, _get(C, b) == imm) elif op == 101: - a = o[u + 1]; b = o[u + 2]; c = o[u + 3]; u += 3 + a = o[u + 1] + b = o[u + 2] + c = o[u + 3] + u += 3 _set(C, a, js_eq(_get(C, b), _get(C, c))) elif op == 102: - a = o[u + 1]; b = o[u + 2]; u += 2 + a = o[u + 1] + b = o[u + 2] + u += 2 _set(C, a, _get(C, b)) elif op == 103: - a = o[u + 1]; b = o[u + 2]; imm1 = o[u + 3]; u += 3 + a = o[u + 1] + b = o[u + 2] + imm1 = o[u + 3] + u += 3 _set(C, a, _get(_get(C, b), imm1)) - c = o[u + 1]; dd = o[u + 2]; u += 2 + c = o[u + 1] + dd = o[u + 2] + u += 2 _set(C, c, _get(C, dd)) - e = o[u + 1]; f = o[u + 2]; imm2 = o[u + 3]; u += 3 + e = o[u + 1] + f = o[u + 2] + imm2 = o[u + 3] + u += 3 _set(C, e, _get(_get(C, f), imm2)) elif op == 104: - a = o[u + 1]; b = o[u + 2]; u += 2 + a = o[u + 1] + b = o[u + 2] + u += 2 _set(C, a, js_num(_get(C, b))) elif op == 105: - a = o[u + 1]; b = o[u + 2]; c = o[u + 3]; u += 3 + a = o[u + 1] + b = o[u + 2] + c = o[u + 3] + u += 3 v = _get(_get(C, b), _get(C, c)) _set(C, a, v) - dd = o[u + 1]; e = o[u + 2]; f = o[u + 3]; u += 3 + dd = o[u + 1] + e = o[u + 2] + f = o[u + 3] + u += 3 js_set(_get(C, dd), _get(C, e), _get(C, f)) u0 = u u = u0 + o[u0 + 1] elif op == 106: - a = o[u + 1]; b = o[u + 2]; u += 2 + a = o[u + 1] + b = o[u + 2] + u += 2 _set(C, a, ~i32(_ic(_get(C, b)))) elif op == 107: - f = o[u + 1]; u += 1 + f = o[u + 1] + u += 1 h = [] for _ in range(f): - h.append(_get(C, o[u + 1])); u += 1 - a = o[u + 1]; b = o[u + 2]; u += 2 + h.append(_get(C, o[u + 1])) + u += 1 + a = o[u + 1] + b = o[u + 2] + u += 2 _set(C, a, js_apply(_get(C, b), p, h)) else: raise RuntimeError(f"未知 opcode {op} @u={u}") @@ -957,8 +1441,10 @@ class _VMThrow(Exception): # ---------------------------------------------------------------- 便捷入口 + def _mk_window(vmlog=None): from .algorithm import Window + w = Window() for k in ("window", "self", "globalThis", "top", "parent", "frames"): w.set(k, w) @@ -974,7 +1460,9 @@ def _mk_window(vmlog=None): return w -def run_frame(entry, h, bytecode, constants, window=None, call_args=(), random_seed=None): +def run_frame( + entry, h, bytecode, constants, window=None, call_args=(), random_seed=None +): """便捷入口:用 pagedoo VM 执行指定 entry 的帧(h 为创建参数数组)。""" if window is None: window = _mk_window() diff --git a/services/yyb-worker/runtime/pyvm/payment_errors.py b/services/yyb-worker/runtime/pyvm/payment_errors.py index a6b549b..1508caf 100644 --- a/services/yyb-worker/runtime/pyvm/payment_errors.py +++ b/services/yyb-worker/runtime/pyvm/payment_errors.py @@ -1,4 +1,5 @@ """YYB 下单和付款失败响应的统一归类。""" + from __future__ import annotations diff --git a/services/yyb-worker/runtime/pyvm/protocol.py b/services/yyb-worker/runtime/pyvm/protocol.py index 4447f45..87bb095 100644 --- a/services/yyb-worker/runtime/pyvm/protocol.py +++ b/services/yyb-worker/runtime/pyvm/protocol.py @@ -2,6 +2,7 @@ 这里仅收敛已由成功请求验证的固定值,不负责推断或修改协议字段。 """ + from __future__ import annotations import hashlib @@ -47,21 +48,38 @@ def file_fingerprint(path: Path) -> str: return _sha256_prefix_bytes(path.read_bytes()) -def validate_goods_materials(args_template: list, xmidas: list[int] | None = None) -> None: +def validate_goods_materials( + args_template: list, xmidas: list[int] | None = None +) -> None: """校验 goods webSave VM 所需静态表结构,发现升级时尽早失败。""" if not isinstance(args_template, list) or len(args_template) != 18: raise ValueError("goods args-template 必须是 18 槽数组") for index in (0, 1, 2, 3, 4, 5, 6, 8, 9, 10, 11, 12, 13, 14, 15, 16): if not isinstance(args_template[index], list) or not args_template[index]: raise ValueError(f"goods args-template 槽 {index} 缺失") - expected_lengths = {0: 16, 1: 256, 2: 256, 3: 256, 4: 256, 5: 256, - 6: 16, 10: 528, 11: 1024, 12: 1024, 13: 256, - 14: 256, 15: 256, 16: 256} + expected_lengths = { + 0: 16, + 1: 256, + 2: 256, + 3: 256, + 4: 256, + 5: 256, + 6: 16, + 10: 528, + 11: 1024, + 12: 1024, + 13: 256, + 14: 256, + 15: 256, + 16: 256, + } for index, expected in expected_lengths.items(): value = args_template[index][0] if not isinstance(value, list) or len(value) != expected: actual = len(value) if isinstance(value, list) else "非数组" - raise ValueError(f"goods args-template 槽 {index} 长度异常: {actual} != {expected}") + raise ValueError( + f"goods args-template 槽 {index} 长度异常: {actual} != {expected}" + ) if xmidas is not None and len(xmidas) != 59640: raise ValueError(f"goods xMidasOps 长度异常: {len(xmidas)} != 59640") @@ -70,7 +88,9 @@ def validate_mall_materials(transform_fixed: dict[str, Any]) -> None: """校验 mall 固定槽模板;动态槽仍由当前会话的 GetPayToken 填充。""" if not isinstance(transform_fixed, dict): raise ValueError("mall transform-fixed 必须是对象") - required = {str(index) for index in (1, 2, 3, 4, 5, 7, 8, 9, 11, 12, 13, 14, 15, 16, 17)} + required = { + str(index) for index in (1, 2, 3, 4, 5, 7, 8, 9, 11, 12, 13, 14, 15, 16, 17) + } missing = sorted(required - set(transform_fixed)) if missing: raise ValueError(f"mall transform-fixed 缺少槽: {', '.join(missing)}") @@ -79,7 +99,9 @@ def validate_mall_materials(transform_fixed: dict[str, Any]) -> None: raise ValueError(f"mall transform-fixed 槽 {index} 不是数组") -def goods_material_diagnostics(root: Path, args_template: list, xmidas: list[int]) -> dict[str, Any]: +def goods_material_diagnostics( + root: Path, args_template: list, xmidas: list[int] +) -> dict[str, Any]: """构造脱敏协议指纹,便于区分页面升级和服务端业务拒绝。""" validate_goods_materials(args_template, xmidas) replay = root / "replay" diff --git a/services/yyb-worker/runtime/pyvm/session.py b/services/yyb-worker/runtime/pyvm/session.py index 5a6c2f5..0fbedb6 100644 --- a/services/yyb-worker/runtime/pyvm/session.py +++ b/services/yyb-worker/runtime/pyvm/session.py @@ -6,6 +6,7 @@ F-2049/F-2051(E3):encrypt_msg 与当前会话绑定—— - key1: 诱饵密钥(点击级,捕获即可) 服务端能验证 key 派生状态(随机 key 变体 ret:1099),因此新订单必须先捕获会话态。 """ + from __future__ import annotations import json @@ -31,7 +32,9 @@ class SessionState: key16: list[int] = field(default_factory=list) key1: list[int] = field(default_factory=list) xmidas_token: str = DEFAULT_XMIDAS_TOKEN - args_template_d: str = "" # webSave 18 参深拷贝(deepcap PC 85091 的 C[2] 原始 D 编码,会话绑定) + args_template_d: str = ( + "" # webSave 18 参深拷贝(deepcap PC 85091 的 C[2] 原始 D 编码,会话绑定) + ) cookies: dict[str, str] = field(default_factory=dict) openid: str = "" openkey: str = "" diff --git a/services/yyb-worker/runtime/scripts/jsdom-pay.py b/services/yyb-worker/runtime/scripts/jsdom-pay.py index e20144a..f6ccbf8 100644 --- a/services/yyb-worker/runtime/scripts/jsdom-pay.py +++ b/services/yyb-worker/runtime/scripts/jsdom-pay.py @@ -5,6 +5,7 @@ 付款码只在服务端 ``web_save`` 返回 ``ret=0`` 后渲染。付款后通过商城官方订单 列表确认本次新出现的完成订单;该检查不触发付款或确认操作。 """ + from __future__ import annotations import argparse @@ -26,7 +27,11 @@ from pathlib import Path ROOT = Path(__file__).resolve().parent.parent sys.path.insert(0, str(ROOT)) -from pyvm.algorithm import build_plaintext, derive_key1_from_key16, generate_encrypt_msg_offline # noqa: E402 +from pyvm.algorithm import ( + build_plaintext, + derive_key1_from_key16, + generate_encrypt_msg_offline, +) # noqa: E402 from pyvm.login_profile import midas_login_params # noqa: E402 from pyvm.payment_errors import describe_payment_failure # noqa: E402 from pyvm.protocol import ( # noqa: E402 @@ -64,6 +69,8 @@ def write_private_text(path: Path, content: str) -> None: def write_private_json(path: Path, value: dict) -> None: write_private_text(path, json.dumps(value, ensure_ascii=False, indent=2) + "\n") + + def load_json(path: Path) -> dict: return json.loads(path.read_text(encoding="utf-8")) @@ -74,8 +81,12 @@ def parse_url_params(mall_response: dict) -> dict[str, str]: url_params = call_reply["data"]["url_params"] except (KeyError, TypeError, json.JSONDecodeError) as exc: raise ValueError("mall 响应缺少 data.call_reply.data.url_params") from exc - return {key: values[-1] for key, values in urllib.parse.parse_qs( - urllib.parse.urlparse(url_params).query, keep_blank_values=True).items()} + return { + key: values[-1] + for key, values in urllib.parse.parse_qs( + urllib.parse.urlparse(url_params).query, keep_blank_values=True + ).items() + } def cookie_header(cookies: dict[str, str]) -> str: @@ -96,8 +107,9 @@ def request_bytes(url: str, cookies: dict[str, str], body: bytes | None = None) if body is not None: headers["Content-Type"] = "application/x-www-form-urlencoded" headers["Origin"] = "https://pay.qq.com" - request = urllib.request.Request(url, data=body, headers=headers, - method="POST" if body is not None else "GET") + request = urllib.request.Request( + url, data=body, headers=headers, method="POST" if body is not None else "GET" + ) try: with urllib.request.urlopen(request, timeout=30) as response: return response.read().decode("utf-8", "replace") @@ -105,7 +117,9 @@ def request_bytes(url: str, cookies: dict[str, str], body: bytes | None = None) return exc.read().decode("utf-8", "replace") -def goods_page_url(cookies: dict[str, str], order: dict[str, str], zone_id: str = "1", pf: str = "") -> str: +def goods_page_url( + cookies: dict[str, str], order: dict[str, str], zone_id: str = "1", pf: str = "" +) -> str: openid = cookies.get("openid") or cookies.get("midas_openid") openkey = cookies.get("accesstoken") or cookies.get("midas_openkey") if not openid or not openkey: @@ -135,7 +149,9 @@ def extract_goods_state(html: str) -> tuple[list[int], str, str]: token = re.search(r'id="xMidasToken"\s+value="([0-9A-Fa-f]+)"', html) anti = re.search(r'id="antiAutoScriptToken"\s+value="([0-9A-Fa-f]+)"', html) if not ops or not token or not anti: - raise ValueError("goods 页面缺少 xMidasOps/xMidasToken/antiAutoScriptToken,登录态或订单已失效") + raise ValueError( + "goods 页面缺少 xMidasOps/xMidasToken/antiAutoScriptToken,登录态或订单已失效" + ) xmidas = [int(value) for value in ops.group(1).split(",") if value] if len(xmidas) != 59640: raise ValueError(f"goods xMidasOps 长度异常: {len(xmidas)}") @@ -147,16 +163,25 @@ def load_template_args() -> list: runtime_args = runtime_template / "args-template.json" if runtime_args.exists(): return load_json(runtime_args) - candidates = sorted((ROOT / "replay/live").glob("*/args-template.json"), reverse=True) + candidates = sorted( + (ROOT / "replay/live").glob("*/args-template.json"), reverse=True + ) for path in candidates: if path.exists(): return load_json(path) raise FileNotFoundError("缺少归档 goods args-template.json") -def build_save_fields(order: dict[str, str], cookies: dict[str, str], web_token: str, - anti_token: str, encrypt_msg: str, zone_id: str, pf: str, - amount_fen: int) -> dict[str, str]: +def build_save_fields( + order: dict[str, str], + cookies: dict[str, str], + web_token: str, + anti_token: str, + encrypt_msg: str, + zone_id: str, + pf: str, + amount_fen: int, +) -> dict[str, str]: """按当前订单构造完整 web_save 表单,不能使用脱敏空模板。""" if amount_fen <= 0: raise ValueError("充值金额必须为正数") @@ -176,7 +201,9 @@ def build_save_fields(order: dict[str, str], cookies: dict[str, str], web_token: "openkey": cookies.get("accesstoken", ""), "session_id": login["session_id"], "session_type": login["session_type"], - "sck": hashlib.md5((APPID + cookies.get("accesstoken", "")).encode()).hexdigest().upper(), + "sck": hashlib.md5((APPID + cookies.get("accesstoken", "")).encode()) + .hexdigest() + .upper(), "anti_auto_script_token_id": anti_token, "zoneid": zone_id, "buy_quantity": "1", @@ -207,17 +234,38 @@ def build_save_fields(order: dict[str, str], cookies: dict[str, str], web_token: return fields -def build_save_body(order: dict[str, str], cookies: dict[str, str], web_token: str, - anti_token: str, encrypt_msg: str, zone_id: str, pf: str, - amount_fen: int) -> str: +def build_save_body( + order: dict[str, str], + cookies: dict[str, str], + web_token: str, + anti_token: str, + encrypt_msg: str, + zone_id: str, + pf: str, + amount_fen: int, +) -> str: """编码完整支付表单,确保加密明文和实际请求使用同一上下文。""" - return urllib.parse.urlencode(build_save_fields( - order, cookies, web_token, anti_token, encrypt_msg, zone_id, pf, amount_fen, - )) + return urllib.parse.urlencode( + build_save_fields( + order, + cookies, + web_token, + anti_token, + encrypt_msg, + zone_id, + pf, + amount_fen, + ) + ) -def build_page_info_body(order: dict[str, str], cookies: dict[str, str], anti_token: str, - zone_id: str, pf: str) -> str: +def build_page_info_body( + order: dict[str, str], + cookies: dict[str, str], + anti_token: str, + zone_id: str, + pf: str, +) -> str: """构造网页端 fp-behv 后的 web_page_info 表单。""" login = midas_login_params(cookies) fields = { @@ -235,7 +283,9 @@ def build_page_info_body(order: dict[str, str], cookies: dict[str, str], anti_to "openkey": cookies.get("accesstoken", ""), "session_id": login["session_id"], "session_type": login["session_type"], - "sck": hashlib.md5((APPID + cookies.get("accesstoken", "")).encode()).hexdigest().upper(), + "sck": hashlib.md5((APPID + cookies.get("accesstoken", "")).encode()) + .hexdigest() + .upper(), "anti_auto_script_token_id": anti_token, "isusempaymode": "1", "zoneid": zone_id, @@ -249,10 +299,13 @@ def build_page_info_body(order: dict[str, str], cookies: dict[str, str], anti_to return urllib.parse.urlencode(fields) -def make_encrypt_rand(params: dict[str, str], fk_extend: str, ts: str, - is_qq_login: bool) -> str: +def make_encrypt_rand( + params: dict[str, str], fk_extend: str, ts: str, is_qq_login: bool +) -> str: """按登录渠道生成页面已验证形态的 _rand,不能仅按长度替换控制字节。""" - prefix = "".join(secrets.choice(string.ascii_letters + string.digits) for _ in range(8)) + prefix = "".join( + secrets.choice(string.ascii_letters + string.digits) for _ in range(8) + ) # 微信历史成功请求固定使用 8 个随机字符加 \x01。_rand 位于加密明文中, # 末控制字节是协议内容,不能为了对齐擅自替换为 QQ 使用的 \x03。 if not is_qq_login: @@ -264,15 +317,27 @@ def make_encrypt_rand(params: dict[str, str], fk_extend: str, ts: str, return prefix + ("\x03" * padding_length) -def save_web_save_request_meta(out_dir: Path, fields: dict[str, str], device_fp_length: int, - plaintext_length: int) -> None: +def save_web_save_request_meta( + out_dir: Path, fields: dict[str, str], device_fp_length: int, plaintext_length: int +) -> None: """保存可比对的脱敏请求形态,避免在证据目录落盘支付凭据。""" - sensitive = ("token_id", "transaction_id", "out_trade_no", "openid", "openkey", - "sck", "anti_auto_script_token_id", "web_token", "encrypt_msg") + sensitive = ( + "token_id", + "transaction_id", + "out_trade_no", + "openid", + "openkey", + "sck", + "anti_auto_script_token_id", + "web_token", + "encrypt_msg", + ) fingerprints = { name: { "length": len(fields.get(name, "")), - "sha256_prefix": hashlib.sha256(fields.get(name, "").encode()).hexdigest()[:12], + "sha256_prefix": hashlib.sha256(fields.get(name, "").encode()).hexdigest()[ + :12 + ], } for name in sensitive } @@ -290,7 +355,9 @@ def make_qr(sign: str, output: Path) -> None: try: import segno except ImportError as exc: - raise RuntimeError("缺少 segno;请安装后重新执行: python3 -m pip install segno") from exc + raise RuntimeError( + "缺少 segno;请安装后重新执行: python3 -m pip install segno" + ) from exc make_private_directory(output.parent) segno.make(sign).save(str(output), scale=6, border=2) os.chmod(output, 0o600) @@ -303,7 +370,9 @@ def node_environment() -> dict[str, str]: return environment -def save_payment_status(path: Path, document: dict, baseline: dict[str, bool], matched: dict | None = None) -> None: +def save_payment_status( + path: Path, document: dict, baseline: dict[str, bool], matched: dict | None = None +) -> None: """Persist a small, non-payment-side-effect status record for this run.""" from pyvm.order_status import completion_summary, order_ids @@ -316,8 +385,13 @@ def save_payment_status(path: Path, document: dict, baseline: dict[str, bool], m write_private_json(path, record) -def save_payment_meta(path: Path, order: dict[str, str], baseline: dict[str, bool], - document: dict, portal_serial_no: str = "") -> None: +def save_payment_meta( + path: Path, + order: dict[str, str], + baseline: dict[str, bool], + document: dict, + portal_serial_no: str = "", +) -> None: """Persist the order identifiers and pre-payment baseline needed by a later check. ``check`` only re-reads this file and never touches web_save, so re-running a @@ -343,7 +417,9 @@ def save_payment_meta(path: Path, order: dict[str, str], baseline: dict[str, boo write_private_json(path, record) -def _match_finished_by_identifiers(listed: list[dict], identifiers: dict[str, str]) -> dict | None: +def _match_finished_by_identifiers( + listed: list[dict], identifiers: dict[str, str] +) -> dict | None: """Match a finished order by the identifiers recorded at QR creation time. The official order list may not carry token_id/out_trade_no, so no fallback @@ -397,23 +473,52 @@ def cmd_check_only(session_path: Path, out_dir: Path) -> int: def main() -> int: - parser = argparse.ArgumentParser(description="YYB: jsdom DeviceFP + web_save -> 微信付款码") + parser = argparse.ArgumentParser( + description="YYB: jsdom DeviceFP + web_save -> 微信付款码" + ) parser.add_argument("--session", default=str(ROOT / "config/mall-session.json")) - parser.add_argument("--mall-response", default=str(ROOT / "config/mall-order-response.json")) - parser.add_argument("--out-dir", default=None, help="运行证据目录;默认 config/jsdom-order-") + parser.add_argument( + "--mall-response", default=str(ROOT / "config/mall-order-response.json") + ) + parser.add_argument( + "--out-dir", + default=None, + help="运行证据目录;默认 config/jsdom-order-", + ) parser.add_argument("--qr", default=None, help="付款二维码 PNG 路径") - parser.add_argument("--wait", type=int, default=5, help="jsdom 等待 DeviceFP 的秒数") + parser.add_argument( + "--wait", type=int, default=5, help="jsdom 等待 DeviceFP 的秒数" + ) parser.add_argument("--zone-id", default="1", help="所选游戏区服 ID") parser.add_argument("--pf", default="", help="所选 Android/iOS 支付平台标识") - parser.add_argument("--amount-fen", type=int, default=0, help="所选点券的价格,单位分(check-only 模式不需要)") - parser.add_argument("--dry-run", action="store_true", help="仅拉取页面并生成 DeviceFP,不上报或创建付款码") - parser.add_argument("--payment-timeout", type=float, default=300, - help="付款码生成后等待订单完成的最长秒数") - parser.add_argument("--payment-interval", type=float, default=3, - help="订单完成状态检查间隔秒数") - parser.add_argument("--skip-payment-check", action="store_true", help="仅生成付款码,不等待订单完成") - parser.add_argument("--check-only", action="store_true", - help="只读检测到账(依赖 --out-dir 下已保存的 payment-meta.json),不创建订单") + parser.add_argument( + "--amount-fen", + type=int, + default=0, + help="所选点券的价格,单位分(check-only 模式不需要)", + ) + parser.add_argument( + "--dry-run", + action="store_true", + help="仅拉取页面并生成 DeviceFP,不上报或创建付款码", + ) + parser.add_argument( + "--payment-timeout", + type=float, + default=300, + help="付款码生成后等待订单完成的最长秒数", + ) + parser.add_argument( + "--payment-interval", type=float, default=3, help="订单完成状态检查间隔秒数" + ) + parser.add_argument( + "--skip-payment-check", action="store_true", help="仅生成付款码,不等待订单完成" + ) + parser.add_argument( + "--check-only", + action="store_true", + help="只读检测到账(依赖 --out-dir 下已保存的 payment-meta.json),不创建订单", + ) args = parser.parse_args() if args.payment_timeout <= 0 or args.payment_interval <= 0: raise ValueError("--payment-timeout 和 --payment-interval 必须为正数") @@ -421,7 +526,9 @@ def main() -> int: session_path = Path(args.session).resolve() response_path = Path(args.mall_response).resolve() stamp = time.strftime("%Y%m%d-%H%M%S") - out_dir = Path(args.out_dir) if args.out_dir else ROOT / "config" / f"jsdom-order-{stamp}" + out_dir = ( + Path(args.out_dir) if args.out_dir else ROOT / "config" / f"jsdom-order-{stamp}" + ) if args.check_only: make_private_directory(out_dir) return cmd_check_only(session_path, out_dir) @@ -444,7 +551,9 @@ def main() -> int: raise ValueError("mall 响应无法解析 url_params") from exc stamp = time.strftime("%Y%m%d-%H%M%S") - out_dir = Path(args.out_dir) if args.out_dir else ROOT / "config" / f"jsdom-order-{stamp}" + out_dir = ( + Path(args.out_dir) if args.out_dir else ROOT / "config" / f"jsdom-order-{stamp}" + ) make_private_directory(out_dir) url = goods_page_url(cookies, order, args.zone_id, args.pf) print(f"[jsdom-pay] 订单: {order['token_id'][:20]}...") @@ -457,9 +566,18 @@ def main() -> int: fp_path = out_dir / "device-fp.json" command = [ - "node", str(ROOT / "scripts/generate-devicefp-jsdom.mjs"), - "--html", str(html_path), "--goods-url", url, "--cookies", str(session_path), - "--output", str(fp_path), "--wait", str(args.wait * 1000), + "node", + str(ROOT / "scripts/generate-devicefp-jsdom.mjs"), + "--html", + str(html_path), + "--goods-url", + url, + "--cookies", + str(session_path), + "--output", + str(fp_path), + "--wait", + str(args.wait * 1000), ] print("[jsdom-pay] 运行 jsdom DeviceFP...") subprocess.run(command, check=True, cwd=ROOT, env=node_environment()) @@ -469,36 +587,53 @@ def main() -> int: return 0 print("[jsdom-pay] 上报 fp-behv...") - fp_response = request_bytes(fp.get("fp_url", FP_URL), cookies, fp["fp_body"].encode()) + fp_response = request_bytes( + fp.get("fp_url", FP_URL), cookies, fp["fp_body"].encode() + ) write_private_text(out_dir / "fp-response.json", fp_response) try: fp_json = json.loads(fp_response) except json.JSONDecodeError: fp_json = {} if fp_json.get("ret") != 0: - print(f"[jsdom-pay] fp-behv 失败: ret={fp_json.get('ret')} {fp_json.get('msg', '')}") + print( + f"[jsdom-pay] fp-behv 失败: ret={fp_json.get('ret')} {fp_json.get('msg', '')}" + ) return 1 # QQ 成功 HAR 包含该前置;微信历史成功链路没有,不能将 QQ 状态机混入微信请求。 if midas_login_params(cookies)["qq_appid"]: print("[jsdom-pay] 拉取 QQ web_page_info...") - page_info = request_bytes(PAGE_INFO_URL, cookies, build_page_info_body( - order, cookies, anti_token, args.zone_id, payment_pf, - ).encode()) + page_info = request_bytes( + PAGE_INFO_URL, + cookies, + build_page_info_body( + order, + cookies, + anti_token, + args.zone_id, + payment_pf, + ).encode(), + ) write_private_text(out_dir / "web-page-info-response.json", page_info) try: page_info_json = json.loads(page_info) except json.JSONDecodeError: page_info_json = {} if page_info_json.get("ret") != 0: - print(f"[jsdom-pay] QQ web_page_info 失败: ret={page_info_json.get('ret')} " - f"{page_info_json.get('msg', '')}") + print( + f"[jsdom-pay] QQ web_page_info 失败: ret={page_info_json.get('ret')} " + f"{page_info_json.get('msg', '')}" + ) print(f"[jsdom-pay] 证据目录: {out_dir}") return 1 web_args = load_template_args() validate_goods_materials(web_args, xmidas) - write_private_json(out_dir / "protocol-materials.json", goods_material_diagnostics(ROOT, web_args, xmidas)) + write_private_json( + out_dir / "protocol-materials.json", + goods_material_diagnostics(ROOT, web_args, xmidas), + ) print("[jsdom-pay] 协议材料校验通过") # key 派生已破解(F-2052,2026-08-12):key16 可随机生成,key1 由反解器 # derive_key1_from_key16 求解(key16=Sbox[Te 链(key1)]),满足服务端派生校验, @@ -529,18 +664,38 @@ def main() -> int: now_seconds = str(int(time.time())) fk_extend = "tdrc_session%3D" + fp["session_id"] random_suffix = make_encrypt_rand( - params, fk_extend, now_seconds, bool(midas_login_params(cookies)["qq_appid"]), + params, + fk_extend, + now_seconds, + bool(midas_login_params(cookies)["qq_appid"]), + ) + plaintext_length = len( + build_plaintext(params, fk_extend, now_seconds, random_suffix).encode("latin-1") ) - plaintext_length = len(build_plaintext(params, fk_extend, now_seconds, random_suffix).encode("latin-1")) encrypt_msg = generate_encrypt_msg_offline( - params, fk_extend, now_seconds, random_suffix, - key16=key16, key1=key1, args_template=web_args, xmidas=xmidas, xmidas_token=web_token, + params, + fk_extend, + now_seconds, + random_suffix, + key16=key16, + key1=key1, + args_template=web_args, + xmidas=xmidas, + xmidas_token=web_token, ) fields = build_save_fields( - order, cookies, web_token, anti_token, encrypt_msg, - args.zone_id, payment_pf, args.amount_fen, + order, + cookies, + web_token, + anti_token, + encrypt_msg, + args.zone_id, + payment_pf, + args.amount_fen, + ) + save_web_save_request_meta( + out_dir, fields, int(fp.get("device_fp_length", 0)), plaintext_length ) - save_web_save_request_meta(out_dir, fields, int(fp.get("device_fp_length", 0)), plaintext_length) body = urllib.parse.urlencode(fields) from pyvm.order_status import order_completion_states, get_official_orders @@ -561,10 +716,14 @@ def main() -> int: except json.JSONDecodeError: response_json = {} if response_json.get("ret") != 0: - detail = f"ret={response_json.get('ret')} err_code={response_json.get('err_code', '')} " \ - f"{response_json.get('msg', '')}" + detail = ( + f"ret={response_json.get('ret')} err_code={response_json.get('err_code', '')} " + f"{response_json.get('msg', '')}" + ) print(f"[jsdom-pay] web_save 失败: {detail}") - print(f"[jsdom-pay] {describe_payment_failure(cookies.get('logintype', ''), 'payment', detail)}") + print( + f"[jsdom-pay] {describe_payment_failure(cookies.get('logintype', ''), 'payment', detail)}" + ) print(f"[jsdom-pay] 证据目录: {out_dir}") return 1 sign = response_json.get("info", {}).get("channel_info", {}).get("sign", "") @@ -588,7 +747,13 @@ def main() -> int: baseline_document = {"list": []} baseline_states = {} print("[jsdom-pay] 记录付款前订单基线与本次订单标识...") - save_payment_meta(out_dir / "payment-meta.json", order, baseline_states, baseline_document, portal_serial_no) + save_payment_meta( + out_dir / "payment-meta.json", + order, + baseline_states, + baseline_document, + portal_serial_no, + ) if args.skip_payment_check: print("[jsdom-pay] 已保存基线;后台将单独执行只读到账检测") return 0 @@ -596,17 +761,23 @@ def main() -> int: print("[jsdom-pay] 记录付款前订单列表,等待微信付款完成...") status_path = out_dir / "payment-status.json" save_payment_status(status_path, baseline_document, baseline_states) - identifiers = {key: value for key, value in { - "token_id": order.get("token_id", ""), - "transaction_id": order.get("transaction_id", ""), - "out_trade_no": order.get("out_trade_no", ""), - }.items() if value} + identifiers = { + key: value + for key, value in { + "token_id": order.get("token_id", ""), + "transaction_id": order.get("transaction_id", ""), + "out_trade_no": order.get("out_trade_no", ""), + }.items() + if value + } deadline = time.monotonic() + args.payment_timeout while time.monotonic() < deadline: time.sleep(args.payment_interval) document = get_official_orders(cookies) completed = _match_finished_by_identifiers( - [item for item in document.get("list", []) if isinstance(item, dict)], identifiers) + [item for item in document.get("list", []) if isinstance(item, dict)], + identifiers, + ) save_payment_status(status_path, document, baseline_states, completed) if completed: print("[jsdom-pay] 微信付款成功,商城订单已完成。") @@ -620,6 +791,11 @@ def main() -> int: if __name__ == "__main__": try: raise SystemExit(main()) - except (FileNotFoundError, ValueError, RuntimeError, subprocess.CalledProcessError) as exc: + except ( + FileNotFoundError, + ValueError, + RuntimeError, + subprocess.CalledProcessError, + ) as exc: print(f"错误: {exc}", file=sys.stderr) raise SystemExit(2) from exc diff --git a/services/yyb-worker/runtime/scripts/login-qq.py b/services/yyb-worker/runtime/scripts/login-qq.py index df7b856..1fe49b7 100644 --- a/services/yyb-worker/runtime/scripts/login-qq.py +++ b/services/yyb-worker/runtime/scripts/login-qq.py @@ -1,5 +1,6 @@ #!/usr/bin/env python3 """应用宝 QQ 二维码登录(纯 Python,无浏览器自动化)。""" + from __future__ import annotations import argparse @@ -15,7 +16,12 @@ from http.cookiejar import CookieJar from pathlib import Path from urllib.error import HTTPError from urllib.parse import urlencode -from urllib.request import HTTPRedirectHandler, HTTPCookieProcessor, Request, build_opener +from urllib.request import ( + HTTPRedirectHandler, + HTTPCookieProcessor, + Request, + build_opener, +) ROOT = Path(__file__).resolve().parent.parent OPEN_APPID = "102033112" @@ -60,8 +66,15 @@ class Client: self.jar = CookieJar() self.opener = build_opener(NoRedirect, HTTPCookieProcessor(self.jar)) - def request(self, url: str, *, method: str = "GET", body: bytes | None = None, - referer: str = "", headers: dict[str, str] | None = None) -> Response: + def request( + self, + url: str, + *, + method: str = "GET", + body: bytes | None = None, + referer: str = "", + headers: dict[str, str] | None = None, + ) -> Response: request_headers = {"User-Agent": USER_AGENT, "Accept": "*/*"} if referer: request_headers["Referer"] = referer @@ -155,7 +168,9 @@ def write_session(path: Path, cookies: dict[str, str]) -> None: document["login_type"] = cookies.get("logintype", "QC") document["login_updated_at"] = int(time.time()) path.parent.mkdir(parents=True, exist_ok=True) - fd, temporary = tempfile.mkstemp(prefix=".mall-session-", suffix=".tmp", dir=path.parent) + fd, temporary = tempfile.mkstemp( + prefix=".mall-session-", suffix=".tmp", dir=path.parent + ) try: with os.fdopen(fd, "w", encoding="utf-8") as handle: json.dump(document, handle, ensure_ascii=False, indent=2) @@ -172,7 +187,9 @@ def write_session(path: Path, cookies: dict[str, str]) -> None: def main() -> int: parser = argparse.ArgumentParser(description="应用宝 QQ 扫码登录(纯 Python)") - parser.add_argument("--session", type=Path, default=ROOT / "config/mall-session.json") + parser.add_argument( + "--session", type=Path, default=ROOT / "config/mall-session.json" + ) parser.add_argument("--qr", type=Path, default=ROOT / "config/qq-login.jpg") parser.add_argument("--timeout", type=float, default=300, help="二维码等待秒数") parser.add_argument("--interval", type=float, default=3, help="轮询间隔秒数") @@ -183,8 +200,12 @@ def main() -> int: client = Client() state = secrets.token_urlsafe(14) show_query = { - "which": "Login", "display": "pc", "response_type": "code", "client_id": OPEN_APPID, - "redirect_uri": CALLBACK, "state": state, + "which": "Login", + "display": "pc", + "response_type": "code", + "client_id": OPEN_APPID, + "redirect_uri": CALLBACK, + "state": state, } show_url = f"{GRAPH_SHOW}?{urlencode(show_query)}" show = client.request(show_url, referer="https://m.yyb.qq.com/") @@ -192,11 +213,18 @@ def main() -> int: raise RuntimeError(f"QQ OAuth 页面请求失败: HTTP {show.status}") xlogin_query = { - "appid": PT_APPID, "daid": PT_DAID, "style": "33", "login_text": "登录", - "hide_title_bar": "1", "hide_border": "1", "target": "self", "s_url": LOGIN_JUMP, + "appid": PT_APPID, + "daid": PT_DAID, + "style": "33", + "login_text": "登录", + "hide_title_bar": "1", + "hide_border": "1", + "target": "self", + "s_url": LOGIN_JUMP, "pt_3rd_aid": OPEN_APPID, "pt_feedback_link": f"https://support.qq.com/products/77942?customInfo=.appid{OPEN_APPID}", - "theme": "2", "verify_theme": "", + "theme": "2", + "verify_theme": "", } xlogin_url = f"{XLOGIN}?{urlencode(xlogin_query)}" xlogin = client.request(xlogin_url, referer=show_url) @@ -207,9 +235,16 @@ def main() -> int: raise RuntimeError("QQ 登录页未写入 pt_login_sig") qr_query = { - "appid": PT_APPID, "e": "2", "l": "M", "s": "3", "d": "72", "v": "4", - "t": str(secrets.randbelow(1_000_000) / 1_000_000), "daid": PT_DAID, - "pt_3rd_aid": OPEN_APPID, "u1": LOGIN_JUMP, + "appid": PT_APPID, + "e": "2", + "l": "M", + "s": "3", + "d": "72", + "v": "4", + "t": str(secrets.randbelow(1_000_000) / 1_000_000), + "daid": PT_DAID, + "pt_3rd_aid": OPEN_APPID, + "u1": LOGIN_JUMP, } qr_url = f"{QR_SHOW}?{urlencode(qr_query)}" image = client.request(qr_url, referer=xlogin_url) @@ -228,10 +263,23 @@ def main() -> int: o1v_id = secrets.token_hex(16) while time.monotonic() < deadline: poll_query = { - "u1": LOGIN_JUMP, "ptqrtoken": str(ptqr_token(qrsig)), "ptredirect": "0", "h": "1", "t": "1", - "g": "1", "from_ui": "1", "ptlang": "2052", "action": f"0-0-{int(time.time() * 1000)}", - "js_ver": "26071711", "js_type": "1", "login_sig": login_sig, "pt_uistyle": "40", - "aid": PT_APPID, "daid": PT_DAID, "pt_3rd_aid": OPEN_APPID, "o1vId": o1v_id, + "u1": LOGIN_JUMP, + "ptqrtoken": str(ptqr_token(qrsig)), + "ptredirect": "0", + "h": "1", + "t": "1", + "g": "1", + "from_ui": "1", + "ptlang": "2052", + "action": f"0-0-{int(time.time() * 1000)}", + "js_ver": "26071711", + "js_type": "1", + "login_sig": login_sig, + "pt_uistyle": "40", + "aid": PT_APPID, + "daid": PT_DAID, + "pt_3rd_aid": OPEN_APPID, + "o1vId": o1v_id, "pt_js_version": "c1987b96", } poll = client.request(f"{QR_POLL}?{urlencode(poll_query)}", referer=xlogin_url) @@ -252,7 +300,9 @@ def main() -> int: check_sig = client.request(callback, referer=xlogin_url) login_jump = check_sig.location() - if check_sig.status != 302 or not login_jump.startswith("https://graph.qq.com/oauth2.0/login_jump"): + if check_sig.status != 302 or not login_jump.startswith( + "https://graph.qq.com/oauth2.0/login_jump" + ): raise RuntimeError("QQ check_sig 未跳转到 OAuth login_jump") jump = client.request(login_jump, referer=callback) if jump.status != 200: @@ -262,8 +312,10 @@ def main() -> int: if not p_skey: raise RuntimeError("QQ check_sig 未写入 p_skey") authorize = client.request( - "https://graph.qq.com/oauth2.0/authorize", method="POST", - body=urlencode(authorize_params(state, p_skey)).encode("utf-8"), referer=login_jump, + "https://graph.qq.com/oauth2.0/authorize", + method="POST", + body=urlencode(authorize_params(state, p_skey)).encode("utf-8"), + referer=login_jump, headers={"Content-Type": "application/x-www-form-urlencoded"}, ) oauth_callback = authorize.location() @@ -276,12 +328,16 @@ def main() -> int: if not cookies.get("openid") or not cookies.get("accesstoken"): raise RuntimeError("YYB QQ OAuth 回调未写入 openid/accesstoken") login_type = cookies.get("logintype", "QC") - info = client.request(USER_INFO, headers={ - "Ual-Access-Login-Type": login_type_header(login_type), - "Ual-Access-Access-Token": cookies["accesstoken"], - "Ual-Access-Openid": cookies["openid"], - "Origin": "https://m.yyb.qq.com", "Referer": "https://m.yyb.qq.com/", - }) + info = client.request( + USER_INFO, + headers={ + "Ual-Access-Login-Type": login_type_header(login_type), + "Ual-Access-Access-Token": cookies["accesstoken"], + "Ual-Access-Openid": cookies["openid"], + "Origin": "https://m.yyb.qq.com", + "Referer": "https://m.yyb.qq.com/", + }, + ) if info.status != 200: raise RuntimeError(f"YYB QQ 登录态校验失败: HTTP {info.status}") try: diff --git a/services/yyb-worker/runtime/scripts/login-wechat.py b/services/yyb-worker/runtime/scripts/login-wechat.py index e85990c..113cf9a 100644 --- a/services/yyb-worker/runtime/scripts/login-wechat.py +++ b/services/yyb-worker/runtime/scripts/login-wechat.py @@ -4,6 +4,7 @@ 二维码由微信 OAuth 生成,用户用微信扫码确认;本脚本轮询授权结果,完成 YYB OAuth 回调后将动态 cookies 合并到 mall-session.json,供后续纯 CK 流程使用。 """ + from __future__ import annotations import argparse @@ -18,7 +19,12 @@ from http.cookiejar import CookieJar from pathlib import Path from urllib.error import HTTPError from urllib.parse import urlencode -from urllib.request import HTTPRedirectHandler, HTTPCookieProcessor, Request, build_opener +from urllib.request import ( + HTTPRedirectHandler, + HTTPCookieProcessor, + Request, + build_opener, +) ROOT = Path(__file__).resolve().parent.parent OPEN_APPID = "wxd44977328b36e647" @@ -58,7 +64,9 @@ class Client: self.jar = CookieJar() self.opener = build_opener(NoRedirect, HTTPCookieProcessor(self.jar)) - def request(self, url: str, *, referer: str = "", headers: dict[str, str] | None = None) -> Response: + def request( + self, url: str, *, referer: str = "", headers: dict[str, str] | None = None + ) -> Response: request_headers = {"User-Agent": USER_AGENT, "Accept": "*/*"} if referer: request_headers["Referer"] = referer @@ -116,7 +124,9 @@ def write_session(path: Path, cookies: dict[str, str]) -> None: document["login_type"] = cookies.get("logintype", "WX") document["login_updated_at"] = int(time.time()) path.parent.mkdir(parents=True, exist_ok=True) - fd, temporary = tempfile.mkstemp(prefix=".mall-session-", suffix=".tmp", dir=path.parent) + fd, temporary = tempfile.mkstemp( + prefix=".mall-session-", suffix=".tmp", dir=path.parent + ) try: with os.fdopen(fd, "w", encoding="utf-8") as handle: json.dump(document, handle, ensure_ascii=False, indent=2) @@ -133,7 +143,9 @@ def write_session(path: Path, cookies: dict[str, str]) -> None: def main() -> int: parser = argparse.ArgumentParser(description="应用宝微信扫码登录(纯 Python)") - parser.add_argument("--session", type=Path, default=ROOT / "config/mall-session.json") + parser.add_argument( + "--session", type=Path, default=ROOT / "config/mall-session.json" + ) parser.add_argument("--qr", type=Path, default=ROOT / "config/wechat-login.jpg") parser.add_argument("--timeout", type=float, default=300, help="二维码等待秒数") parser.add_argument("--interval", type=float, default=2, help="轮询间隔秒数") @@ -158,7 +170,9 @@ def main() -> int: if page.status != 200: raise RuntimeError(f"微信授权页请求失败: HTTP {page.status}") uuid = extract_uuid(page.text) - image = client.request(f"https://open.weixin.qq.com/connect/qrcode/{uuid}", referer=authorization_url) + image = client.request( + f"https://open.weixin.qq.com/connect/qrcode/{uuid}", referer=authorization_url + ) if image.status != 200 or not image.body: raise RuntimeError(f"微信二维码请求失败: HTTP {image.status}") args.qr.parent.mkdir(parents=True, exist_ok=True) @@ -173,7 +187,9 @@ def main() -> int: poll_query = {"uuid": uuid} if last: poll_query["last"] = last - poll = client.request(f"{POLL_QR}?{urlencode(poll_query)}", referer=authorization_url) + poll = client.request( + f"{POLL_QR}?{urlencode(poll_query)}", referer=authorization_url + ) errcode, code = parse_poll(poll.text) if errcode == 405 and code: break @@ -202,13 +218,16 @@ def main() -> int: login_type = cookies.get("logintype", "WX") if not openid or not access_token: raise RuntimeError("YYB OAuth 回调未写入 openid/accesstoken") - info = client.request(USER_INFO, headers={ - "Ual-Access-Login-Type": login_type_header(login_type), - "Ual-Access-Access-Token": access_token, - "Ual-Access-Openid": openid, - "Origin": "https://m.yyb.qq.com", - "Referer": "https://m.yyb.qq.com/", - }) + info = client.request( + USER_INFO, + headers={ + "Ual-Access-Login-Type": login_type_header(login_type), + "Ual-Access-Access-Token": access_token, + "Ual-Access-Openid": openid, + "Origin": "https://m.yyb.qq.com", + "Referer": "https://m.yyb.qq.com/", + }, + ) if info.status != 200: raise RuntimeError(f"YYB 登录态校验失败: HTTP {info.status}") try: diff --git a/services/yyb-worker/runtime/scripts/select-peace-elite.py b/services/yyb-worker/runtime/scripts/select-peace-elite.py index bcc0e60..29feb60 100644 --- a/services/yyb-worker/runtime/scripts/select-peace-elite.py +++ b/services/yyb-worker/runtime/scripts/select-peace-elite.py @@ -1,5 +1,6 @@ #!/usr/bin/env python3 """用已登录的 YYB CK 选择和平精英点券档位、区服和角色。""" + from __future__ import annotations import argparse @@ -45,26 +46,43 @@ UA = ( def load_cookies(path: Path) -> dict[str, str]: session = json.loads(path.read_text(encoding="utf-8")) - cookies = {str(key): str(value) for key, value in session.get("cookies", {}).items() if value} + cookies = { + str(key): str(value) + for key, value in session.get("cookies", {}).items() + if value + } if not cookies.get("openid") or not cookies.get("accesstoken"): raise ValueError("会话缺少 openid/accesstoken,请先执行 login-wechat.py") return cookies -def product_options(cookies: dict[str, str], platform: str) -> list[dict[str, str | int]]: +def product_options( + cookies: dict[str, str], platform: str +) -> list[dict[str, str | int]]: response = requests.post( PRODUCTS_URL, - json={"platform": PLATFORMS[platform]["query_platform"], "source_id": SOURCE_ID, - "yyb_app_id": YYB_APP_ID}, - headers={"Accept": "application/json, text/plain, */*", "Origin": "https://m.yyb.qq.com", - "Referer": "https://m.yyb.qq.com/boc-mall/goods-mall/", "User-Agent": UA}, - cookies=cookies, impersonate="chrome", timeout=30, + json={ + "platform": PLATFORMS[platform]["query_platform"], + "source_id": SOURCE_ID, + "yyb_app_id": YYB_APP_ID, + }, + headers={ + "Accept": "application/json, text/plain, */*", + "Origin": "https://m.yyb.qq.com", + "Referer": "https://m.yyb.qq.com/boc-mall/goods-mall/", + "User-Agent": UA, + }, + cookies=cookies, + impersonate="chrome", + timeout=30, ) if response.status_code != 200: raise RuntimeError(f"点券商品查询失败: HTTP {response.status_code}") document = response.json() if document.get("code") not in (None, 0): - raise RuntimeError(f"点券商品查询失败: {document.get('code')} {document.get('message', '')}") + raise RuntimeError( + f"点券商品查询失败: {document.get('code')} {document.get('message', '')}" + ) products = document.get("token_mod", {}).get("products", []) result: list[dict[str, str | int]] = [] for item in products: @@ -72,15 +90,19 @@ def product_options(cookies: dict[str, str], platform: str) -> list[dict[str, st match = re.fullmatch(r"(\d+)点券", str(product.get("product_name", ""))) if not match or str(product.get("status")) != "20": continue - result.append({ - "points": int(match.group(1)), - "product_id": str(product.get("product_id", "")), - "price_fen": int(product.get("price", 0)), - "offer_id": str(product.get("res_offer_id", "")), - "name": str(product.get("product_name", "")), - }) + result.append( + { + "points": int(match.group(1)), + "product_id": str(product.get("product_id", "")), + "price_fen": int(product.get("price", 0)), + "offer_id": str(product.get("res_offer_id", "")), + "name": str(product.get("product_name", "")), + } + ) result.sort(key=lambda item: int(item["points"])) - if not result or any(not item["product_id"] or not item["offer_id"] for item in result): + if not result or any( + not item["product_id"] or not item["offer_id"] for item in result + ): raise RuntimeError("点券商品响应缺少 product_id 或 offer_id") return result @@ -95,30 +117,48 @@ class Cmall: def query(self, cmd: str, **extra: str) -> dict: login = midas_login_params(self.cookies) params = { - "from_h5": "1", "pf": PLATFORMS[self.platform]["cmall_pf"], "r": str(random.random()), "cmd": cmd, + "from_h5": "1", + "pf": PLATFORMS[self.platform]["cmall_pf"], + "r": str(random.random()), + "cmd": cmd, "session_token": self.session_token, - "pfkey": "pfkey", "webversion": "", **extra, + "pfkey": "pfkey", + "webversion": "", + **extra, **login, } # 当前商城页面将查询参数放在 URL 上,但请求方法为 POST 且没有 body。 response = requests.post( CMALL_URL.format(offer_id=self.offer_id) + "?" + urlencode(params), - headers={"Origin": "https://z.iwan.yyb.qq.com", "Referer": "https://z.iwan.yyb.qq.com/", - "User-Agent": UA}, - cookies=self.cookies, impersonate="chrome", timeout=30, + headers={ + "Origin": "https://z.iwan.yyb.qq.com", + "Referer": "https://z.iwan.yyb.qq.com/", + "User-Agent": UA, + }, + cookies=self.cookies, + impersonate="chrome", + timeout=30, ) if response.status_code != 200: raise RuntimeError(f"游戏数据查询失败: HTTP {response.status_code}") document = response.json() if document.get("ret") not in (0, "0"): - raise RuntimeError(f"游戏数据查询失败: {document.get('ret')} {document.get('msg', '')}") + raise RuntimeError( + f"游戏数据查询失败: {document.get('ret')} {document.get('msg', '')}" + ) return document def zones(self) -> list[dict[str, str]]: response = self.query("14", use_currency_offerid="1") zones = response.get("zone_list", []) - return [{"zone_id": str(item.get("zone_id", "")), "name": str(item.get("zone_name", ""))} - for item in zones if isinstance(item, dict) and item.get("zone_id")] + return [ + { + "zone_id": str(item.get("zone_id", "")), + "name": str(item.get("zone_name", "")), + } + for item in zones + if isinstance(item, dict) and item.get("zone_id") + ] def roles(self, zone_id: str) -> list[dict[str, str]]: response = self.query("15", use_currency_offerid="1", zoneid=zone_id) @@ -126,14 +166,18 @@ class Cmall: # QQ 平台和平精英的 area 与 zoneid 不同(如 area=2, zoneid=1); # PlaceOrder 校验角色时需要该分区信息,随角色一并返回。 partition = response.get("partition_info") or {} - return [{ - "role_id": str(item.get("role_id", "")), - "name": unquote(str(item.get("role_name", ""))), - "ban_status": str(item.get("ban_status", "")), - "area": str(partition.get("area", "")), - "partition": str(partition.get("partition", "")), - "platid": str(partition.get("platid", "")), - } for item in roles if isinstance(item, dict) and item.get("role_id")] + return [ + { + "role_id": str(item.get("role_id", "")), + "name": unquote(str(item.get("role_name", ""))), + "ban_status": str(item.get("ban_status", "")), + "area": str(partition.get("area", "")), + "partition": str(partition.get("partition", "")), + "platid": str(partition.get("platid", "")), + } + for item in roles + if isinstance(item, dict) and item.get("role_id") + ] def choose(label: str, options: list[dict], display) -> dict: @@ -151,18 +195,31 @@ def choose(label: str, options: list[dict], display) -> dict: def main() -> int: parser = argparse.ArgumentParser(description="和平精英点券和角色选择(纯 CK 查询)") - parser.add_argument("--session", type=Path, default=ROOT / "config/mall-session.json") - parser.add_argument("--output", type=Path, default=ROOT / "config/peace-elite-selection.json") - parser.add_argument("--platform", choices=tuple(PLATFORMS), default=None, - help="预选 Android/iOS;不传则显示平台菜单") - parser.add_argument("--points", type=int, default=None, help="预选点券数;不传则显示菜单") - parser.add_argument("--list-products", action="store_true", help="仅列出当前所有点券档位") + parser.add_argument( + "--session", type=Path, default=ROOT / "config/mall-session.json" + ) + parser.add_argument( + "--output", type=Path, default=ROOT / "config/peace-elite-selection.json" + ) + parser.add_argument( + "--platform", + choices=tuple(PLATFORMS), + default=None, + help="预选 Android/iOS;不传则显示平台菜单", + ) + parser.add_argument( + "--points", type=int, default=None, help="预选点券数;不传则显示菜单" + ) + parser.add_argument( + "--list-products", action="store_true", help="仅列出当前所有点券档位" + ) args = parser.parse_args() cookies = load_cookies(args.session) if args.platform is None: selected_platform = choose( - "平台", [{"id": key, **value} for key, value in PLATFORMS.items()], + "平台", + [{"id": key, **value} for key, value in PLATFORMS.items()], lambda item: item["label"], ) platform = str(selected_platform["id"]) @@ -172,32 +229,59 @@ def main() -> int: products = product_options(cookies, platform) if args.list_products: for product in products: - print(f"{product['points']}点券\t{product['price_fen'] / 100:g}元\t{product['product_id']}") + print( + f"{product['points']}点券\t{product['price_fen'] / 100:g}元\t{product['product_id']}" + ) return 0 if args.points is None: - product = choose("点券档位", products, lambda item: f"{item['points']}点券({item['price_fen'] / 100:g}元)") + product = choose( + "点券档位", + products, + lambda item: f"{item['points']}点券({item['price_fen'] / 100:g}元)", + ) else: - product = next((item for item in products if item["points"] == args.points), None) + product = next( + (item for item in products if item["points"] == args.points), None + ) if product is None: available = ", ".join(str(item["points"]) for item in products) raise ValueError(f"不支持 {args.points} 点券;当前可选: {available}") print(f"已选择: {product['points']}点券({product['price_fen'] / 100:g}元)") cmall = Cmall(cookies, str(product["offer_id"]), platform) - zone = choose("区服", cmall.zones(), lambda item: f"{item['name']}(ID {item['zone_id']})") + zone = choose( + "区服", cmall.zones(), lambda item: f"{item['name']}(ID {item['zone_id']})" + ) roles = cmall.roles(zone["zone_id"]) - role = choose("角色", roles, lambda item: f"{item['name']}({'禁用' if item['ban_status'] == '1' else '可用'})") + role = choose( + "角色", + roles, + lambda item: ( + f"{item['name']}({'禁用' if item['ban_status'] == '1' else '可用'})" + ), + ) if role["ban_status"] == "1": raise RuntimeError("所选角色已被封禁,不能充值") - selection = {"platform": platform, "order_pf": PLATFORMS[platform]["order_pf"], - "points": product["points"], "product_id": product["product_id"], - "offer_id": product["offer_id"], "price_fen": product["price_fen"], - "zone_id": zone["zone_id"], "zone_name": zone["name"], - "role_id": role["role_id"], "role_name": role["name"]} + selection = { + "platform": platform, + "order_pf": PLATFORMS[platform]["order_pf"], + "points": product["points"], + "product_id": product["product_id"], + "offer_id": product["offer_id"], + "price_fen": product["price_fen"], + "zone_id": zone["zone_id"], + "zone_name": zone["name"], + "role_id": role["role_id"], + "role_name": role["name"], + } args.output.parent.mkdir(parents=True, exist_ok=True) - args.output.write_text(json.dumps(selection, ensure_ascii=False, indent=2) + "\n", encoding="utf-8") - print(f"\n已选择 {selection['points']}点券 / {selection['zone_name']} / {selection['role_name']}") + args.output.write_text( + json.dumps(selection, ensure_ascii=False, indent=2) + "\n", encoding="utf-8" + ) + print( + f"\n已选择 {selection['points']}点券 / {selection['zone_name']} / {selection['role_name']}" + ) print(f"选择已保存: {args.output}") return 0 diff --git a/services/yyb-worker/runtime/scripts/yyb-worker.py b/services/yyb-worker/runtime/scripts/yyb-worker.py index 0d2b498..ac84bf3 100644 --- a/services/yyb-worker/runtime/scripts/yyb-worker.py +++ b/services/yyb-worker/runtime/scripts/yyb-worker.py @@ -5,6 +5,7 @@ The worker owns per-job sessions and invokes the already verified protocol scripts. It intentionally exposes QR images and state only; cookies and raw payment links never leave the worker API. """ + from __future__ import annotations import argparse @@ -79,15 +80,23 @@ def _safe_log(job: dict, line: str) -> None: clean = re.sub(r"weixin://\S+", "[付款链接已隐藏]", line) clean = re.sub(r"HOLD_[A-Za-z0-9_-]+", "[订单已隐藏]", clean) clean = re.sub(r"(订单\s*[::]\s*)\S+", r"\1[订单已隐藏]", clean) - clean = re.sub(r"(?:token|openid|openkey|cookie)=\S+", "[敏感字段已隐藏]", clean, flags=re.I) - clean = re.sub(r"(?:pay_token|web_token|anti_token|session_id|sessionid)\s*[:= ]\s*\S+", - "[敏感字段已隐藏]", clean, flags=re.I) + clean = re.sub( + r"(?:token|openid|openkey|cookie)=\S+", "[敏感字段已隐藏]", clean, flags=re.I + ) + clean = re.sub( + r"(?:pay_token|web_token|anti_token|session_id|sessionid)\s*[:= ]\s*\S+", + "[敏感字段已隐藏]", + clean, + flags=re.I, + ) timestamp = time.strftime("%H:%M:%S") with _lock: job["logs"] = (job.get("logs", []) + [f"[{timestamp}] {clean.strip()}"])[-100:] -def _run_process(job_id: str, command: list[str], phase: str, mark_success: bool = True) -> int: +def _run_process( + job_id: str, command: list[str], phase: str, mark_success: bool = True +) -> int: """Run one stage process and return its exit code. When ``mark_success`` is False the caller owns the post-success state @@ -104,16 +113,24 @@ def _run_process(job_id: str, command: list[str], phase: str, mark_success: bool stage_started = time.monotonic() _safe_log(job, f"[{phase}] 开始执行") try: - process = subprocess.Popen(command, cwd=ROOT, stdout=subprocess.PIPE, - stderr=subprocess.STDOUT, text=True, - bufsize=1, env=environment) + process = subprocess.Popen( + command, + cwd=ROOT, + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + text=True, + bufsize=1, + env=environment, + ) with _lock: job["process_pid"] = process.pid assert process.stdout is not None for line in process.stdout: _safe_log(job, line) code = process.wait() - _safe_log(job, f"[{phase}] 执行结束,耗时 {time.monotonic() - stage_started:.1f} 秒") + _safe_log( + job, f"[{phase}] 执行结束,耗时 {time.monotonic() - stage_started:.1f} 秒" + ) with _lock: job["process_pid"] = None if code != 0: @@ -134,7 +151,9 @@ def _run_process(job_id: str, command: list[str], phase: str, mark_success: bool job["message"] = "付款流程已完成" return code except Exception as exc: # noqa: BLE001 - _safe_log(job, f"[{phase}] 执行异常,耗时 {time.monotonic() - stage_started:.1f} 秒") + _safe_log( + job, f"[{phase}] 执行异常,耗时 {time.monotonic() - stage_started:.1f} 秒" + ) with _lock: job["status"] = "failed" job["message"] = str(exc) @@ -146,42 +165,74 @@ def _start_login(job_id: str, provider: str, timeout: int) -> None: directory = _job_dir(job_id) session = directory / "mall-session.json" qr = directory / ("qq-login.jpg" if provider == "qq" else "wechat-login.jpg") - command = [sys.executable, f"scripts/login-{provider}.py", "--session", str(session), - "--qr", str(qr), "--timeout", str(timeout)] + command = [ + sys.executable, + f"scripts/login-{provider}.py", + "--session", + str(session), + "--qr", + str(qr), + "--timeout", + str(timeout), + ] with _lock: job["provider"] = provider job["qr_path"] = str(qr) job["session_path"] = str(session) job["status"] = "waiting_login" job["phase"] = "login" - threading.Thread(target=_run_process, args=(job_id, command, "login"), daemon=True).start() + threading.Thread( + target=_run_process, args=(job_id, command, "login"), daemon=True + ).start() -def _selection_options(job_id: str, platform: str, points: int | None, zone_id: str | None = None) -> dict: +def _selection_options( + job_id: str, platform: str, points: int | None, zone_id: str | None = None +) -> dict: if job_id not in _jobs: raise ValueError("任务不存在") selector = _load_selector() - session = json.loads((_job_dir(job_id) / "mall-session.json").read_text(encoding="utf-8")) + session = json.loads( + (_job_dir(job_id) / "mall-session.json").read_text(encoding="utf-8") + ) cookies = session.get("cookies", {}) products = selector.product_options(cookies, platform) - if points is not None and not any(int(item["points"]) == points for item in products): + if points is not None and not any( + int(item["points"]) == points for item in products + ): raise ValueError("当前登录态不支持该点券档位") - product = next((item for item in products if int(item["points"]) == points), None) if points else None + product = ( + next((item for item in products if int(item["points"]) == points), None) + if points + else None + ) if product is None: product = products[0] cmall = selector.Cmall(cookies, str(product["offer_id"]), platform) zones = cmall.zones() - selected_zone = next((zone for zone in zones if str(zone["zone_id"]) == str(zone_id)), None) + selected_zone = next( + (zone for zone in zones if str(zone["zone_id"]) == str(zone_id)), None + ) if zone_id and selected_zone is None: raise ValueError("区服不存在") selected_zone = selected_zone or (zones[0] if zones else None) roles = cmall.roles(selected_zone["zone_id"]) if selected_zone else [] - return {"products": products, "zones": zones, "roles": roles, - "default_product": product, "default_zone": selected_zone} + return { + "products": products, + "zones": zones, + "roles": roles, + "default_product": product, + "default_zone": selected_zone, + } -def _payment_stage(job_id: str, command: list[str], phase: str, running_message: str, - failed_message: str) -> bool: +def _payment_stage( + job_id: str, + command: list[str], + phase: str, + running_message: str, + failed_message: str, +) -> bool: """Run one payment stage; return True on success without touching final state.""" job = _jobs[job_id] with _lock: @@ -210,18 +261,32 @@ def _check_payment_once(job_id: str) -> int: """Read-only completion check; returns 0=confirmed, 1=not yet, 2=check failed.""" job = _jobs[job_id] directory = _job_dir(job_id) - command = [sys.executable, "scripts/jsdom-pay.py", "--check-only", - "--session", str(directory / "mall-session.json"), - "--out-dir", str(directory / "jsdom-order")] + command = [ + sys.executable, + "scripts/jsdom-pay.py", + "--check-only", + "--session", + str(directory / "mall-session.json"), + "--out-dir", + str(directory / "jsdom-order"), + ] environment = os.environ.copy() environment.pop("NODE_OPTIONS", None) check_started = time.monotonic() _safe_log(job, "[到账检测] 开始执行") try: - result = subprocess.run(command, cwd=ROOT, capture_output=True, text=True, - env=environment, timeout=90) + result = subprocess.run( + command, + cwd=ROOT, + capture_output=True, + text=True, + env=environment, + timeout=90, + ) except subprocess.TimeoutExpired: - _safe_log(job, f"[到账检测] 执行超时,耗时 {time.monotonic() - check_started:.1f} 秒") + _safe_log( + job, f"[到账检测] 执行超时,耗时 {time.monotonic() - check_started:.1f} 秒" + ) with _lock: job["payment_last_checked_at"] = int(time.time()) return 2 @@ -229,7 +294,9 @@ def _check_payment_once(job_id: str) -> int: _safe_log(job, line) for line in (result.stderr or "").splitlines(): _safe_log(job, line) - _safe_log(job, f"[到账检测] 执行结束,耗时 {time.monotonic() - check_started:.1f} 秒") + _safe_log( + job, f"[到账检测] 执行结束,耗时 {time.monotonic() - check_started:.1f} 秒" + ) with _lock: job["payment_last_checked_at"] = int(time.time()) return result.returncode @@ -275,28 +342,62 @@ def _payment_flow(job_id: str, selection: dict) -> None: job["status"] = "ordering" job["phase"] = "payment" job["message"] = "正在创建商城订单" - order_cmd = [sys.executable, "main.py", "mall", "auto", "--session", str(session), - "--order-template", str(ROOT / "config" / "mall-order-template.json"), - "--quantity", "1", "--product-id", str(selection["product_id"]), - "--offer-id", str(selection["offer_id"]), - "--role-id", str(selection["role_id"]), "--role-name", str(selection["role_name"]), - "--zone-id", str(selection["zone_id"]), "--zone-name", str(selection["zone_name"]), - "--output", str(response)] + order_cmd = [ + sys.executable, + "main.py", + "mall", + "auto", + "--session", + str(session), + "--order-template", + str(ROOT / "config" / "mall-order-template.json"), + "--quantity", + "1", + "--product-id", + str(selection["product_id"]), + "--offer-id", + str(selection["offer_id"]), + "--role-id", + str(selection["role_id"]), + "--role-name", + str(selection["role_name"]), + "--zone-id", + str(selection["zone_id"]), + "--zone-name", + str(selection["zone_name"]), + "--output", + str(response), + ] if selection.get("area"): order_cmd.extend(["--area", str(selection["area"])]) if selection.get("partition"): order_cmd.extend(["--partition", str(selection["partition"])]) if selection.get("order_pf"): order_cmd.extend(["--pf", str(selection["order_pf"])]) - if not _payment_stage(job_id, order_cmd, "order", "正在创建商城订单", "创建商城订单失败"): + if not _payment_stage( + job_id, order_cmd, "order", "正在创建商城订单", "创建商城订单失败" + ): return - pay_cmd = [sys.executable, "scripts/jsdom-pay.py", "--session", str(session), - "--mall-response", str(response), "--out-dir", str(output), - "--zone-id", str(selection["zone_id"]), - "--pf", str(selection.get("order_pf", "")), - "--amount-fen", str(selection["price_fen"]), - "--skip-payment-check"] - if not _payment_stage(job_id, pay_cmd, "payment", "正在生成微信付款码", "生成付款码失败"): + pay_cmd = [ + sys.executable, + "scripts/jsdom-pay.py", + "--session", + str(session), + "--mall-response", + str(response), + "--out-dir", + str(output), + "--zone-id", + str(selection["zone_id"]), + "--pf", + str(selection.get("order_pf", "")), + "--amount-fen", + str(selection["price_fen"]), + "--skip-payment-check", + ] + if not _payment_stage( + job_id, pay_cmd, "payment", "正在生成微信付款码", "生成付款码失败" + ): return meta_path = output / "payment-meta.json" try: @@ -318,7 +419,9 @@ def _start_payment(job_id: str, selection: dict) -> None: _jobs[job_id]["status"] = "ordering" _jobs[job_id]["phase"] = "payment" _jobs[job_id]["message"] = "正在创建商城订单" - threading.Thread(target=_payment_flow, args=(job_id, selection), daemon=True).start() + threading.Thread( + target=_payment_flow, args=(job_id, selection), daemon=True + ).start() def _stop_job(job_id: str) -> None: @@ -344,13 +447,18 @@ def _stop_job(job_id: str) -> None: def _public_job(job_id: str) -> dict: job = _jobs[job_id] - result = {key: value for key, value in job.items() - if key not in {"directory", "session_path", "process_pid"}} + result = { + key: value + for key, value in job.items() + if key not in {"directory", "session_path", "process_pid"} + } qr_path = job.get("qr_path", "") if qr_path and Path(qr_path).exists(): qr_bytes = Path(qr_path).read_bytes() result["qr_data"] = base64.b64encode(qr_bytes).decode("ascii") - result["qr_mime_type"] = "image/jpeg" if qr_bytes.startswith(b"\xff\xd8\xff") else "image/png" + result["qr_mime_type"] = ( + "image/jpeg" if qr_bytes.startswith(b"\xff\xd8\xff") else "image/png" + ) output = Path(job["directory"]) / "jsdom-order" for name in ("wechat-pay.png", "payment-status.json"): path = None @@ -359,18 +467,32 @@ def _public_job(job_id: str) -> dict: path = direct if direct.exists() else next(output.glob(f"*/{name}"), None) if path and name.endswith(".png"): payment_qr_bytes = path.read_bytes() - result["payment_qr_data"] = base64.b64encode(payment_qr_bytes).decode("ascii") - result["payment_qr_mime_type"] = "image/jpeg" if payment_qr_bytes.startswith(b"\xff\xd8\xff") else "image/png" + result["payment_qr_data"] = base64.b64encode(payment_qr_bytes).decode( + "ascii" + ) + result["payment_qr_mime_type"] = ( + "image/jpeg" + if payment_qr_bytes.startswith(b"\xff\xd8\xff") + else "image/png" + ) elif path: try: status = json.loads(path.read_text(encoding="utf-8")) - matched = status.get("matched_completion") if isinstance(status, dict) else None + matched = ( + status.get("matched_completion") + if isinstance(status, dict) + else None + ) result["payment_status"] = { - "checked_at": status.get("checked_at") if isinstance(status, dict) else None, + "checked_at": status.get("checked_at") + if isinstance(status, dict) + else None, "matched_completion": { "is_finished": matched.get("is_finished"), "status": matched.get("status"), - } if isinstance(matched, dict) else None, + } + if isinstance(matched, dict) + else None, } except (OSError, json.JSONDecodeError): pass @@ -411,8 +533,14 @@ class Handler(BaseHTTPRequestHandler): directory = DEFAULT_DATA / job_id directory.mkdir(parents=True, exist_ok=True) os.chmod(directory, 0o700) - _jobs[job_id] = {"job_id": job_id, "status": "created", "phase": "login", - "logs": [], "directory": str(directory), "created_at": int(time.time())} + _jobs[job_id] = { + "job_id": job_id, + "status": "created", + "phase": "login", + "logs": [], + "directory": str(directory), + "created_at": int(time.time()), + } return self._json(201, _public_job(job_id)) if len(path) == 4 and path[:2] == ["v1", "jobs"] and path[3] == "login": job_id = path[2] @@ -421,44 +549,87 @@ class Handler(BaseHTTPRequestHandler): return self._json(400, {"detail": "无效任务或登录方式"}) _start_login(job_id, body["provider"], int(body.get("timeout", 600))) return self._json(202, _public_job(job_id)) - if len(path) == 4 and path[:2] == ["v1", "jobs"] and path[3] == "selection-options": + if ( + len(path) == 4 + and path[:2] == ["v1", "jobs"] + and path[3] == "selection-options" + ): job_id = path[2] body = self._body() - options = _selection_options(job_id, str(body.get("platform", "android")), body.get("points"), body.get("zone_id")) + options = _selection_options( + job_id, + str(body.get("platform", "android")), + body.get("points"), + body.get("zone_id"), + ) _jobs[job_id]["selection_options"] = options return self._json(200, options) if len(path) == 4 and path[:2] == ["v1", "jobs"] and path[3] == "selection": job_id = path[2] body = self._body() - required = ("platform", "points", "product_id", "role_id", "role_name", "zone_id") + required = ( + "platform", + "points", + "product_id", + "role_id", + "role_name", + "zone_id", + ) if job_id not in _jobs or any(not body.get(key) for key in required): return self._json(400, {"detail": "选择参数不完整"}) selector = _load_selector() if body["platform"] not in selector.PLATFORMS: return self._json(400, {"detail": "不支持的平台"}) session_path = _job_dir(job_id) / "mall-session.json" - cookies = json.loads(session_path.read_text(encoding="utf-8")).get("cookies", {}) - product = next((item for item in selector.product_options(cookies, body["platform"]) - if str(item["product_id"]) == str(body["product_id"]) - and int(item["points"]) == int(body["points"])), None) + cookies = json.loads(session_path.read_text(encoding="utf-8")).get( + "cookies", {} + ) + product = next( + ( + item + for item in selector.product_options(cookies, body["platform"]) + if str(item["product_id"]) == str(body["product_id"]) + and int(item["points"]) == int(body["points"]) + ), + None, + ) if product is None: return self._json(400, {"detail": "商品已失效,请重新选择"}) - cmall = selector.Cmall(cookies, str(product["offer_id"]), body["platform"]) - zone = next((item for item in cmall.zones() - if str(item["zone_id"]) == str(body["zone_id"])), None) + cmall = selector.Cmall( + cookies, str(product["offer_id"]), body["platform"] + ) + zone = next( + ( + item + for item in cmall.zones() + if str(item["zone_id"]) == str(body["zone_id"]) + ), + None, + ) if zone is None: return self._json(400, {"detail": "区服已失效,请重新选择"}) - role = next((item for item in cmall.roles(zone["zone_id"]) - if str(item["role_id"]) == str(body["role_id"])), None) + role = next( + ( + item + for item in cmall.roles(zone["zone_id"]) + if str(item["role_id"]) == str(body["role_id"]) + ), + None, + ) if role is None or role.get("ban_status") == "1": return self._json(400, {"detail": "角色不可充值,请重新选择"}) _jobs[job_id]["selection"] = { - "platform": body["platform"], "points": product["points"], + "platform": body["platform"], + "points": product["points"], "price_fen": product["price_fen"], - "product_id": product["product_id"], "offer_id": product["offer_id"], - "zone_id": zone["zone_id"], "zone_name": zone["name"], - "role_id": role["role_id"], "role_name": role["name"], - "area": role.get("area", ""), "partition": role.get("partition", ""), + "product_id": product["product_id"], + "offer_id": product["offer_id"], + "zone_id": zone["zone_id"], + "zone_name": zone["name"], + "role_id": role["role_id"], + "role_name": role["name"], + "area": role.get("area", ""), + "partition": role.get("partition", ""), "order_pf": selector.PLATFORMS[body["platform"]]["order_pf"], } _jobs[job_id]["phase"] = "payment" @@ -473,14 +644,23 @@ class Handler(BaseHTTPRequestHandler): with _lock: if job.get("status") == "stopped": return self._json(400, {"detail": "任务已停止,不能生成付款码"}) - if job.get("status") in {"ordering", "waiting_payment", "payment_timeout"} \ - or job.get("process_pid"): - return self._json(400, {"detail": "已有进行中的支付流程,请勿重复操作"}) + if job.get("status") in { + "ordering", + "waiting_payment", + "payment_timeout", + } or job.get("process_pid"): + return self._json( + 400, {"detail": "已有进行中的支付流程,请勿重复操作"} + ) if not job.get("selection"): return self._json(400, {"detail": "请先完成角色选择"}) _start_payment(job_id, job["selection"]) return self._json(202, _public_job(job_id)) - if len(path) == 5 and path[:2] == ["v1", "jobs"] and path[3:5] == ["payment", "check"]: + if ( + len(path) == 5 + and path[:2] == ["v1", "jobs"] + and path[3:5] == ["payment", "check"] + ): job_id = path[2] if job_id not in _jobs: return self._json(400, {"detail": "任务不存在"}) @@ -541,7 +721,9 @@ def _restore_jobs(data_dir: Path) -> int: continue if not meta_path.exists(): _jobs[job_id] = { - "job_id": job_id, "status": "ready", "phase": "selection", + "job_id": job_id, + "status": "ready", + "phase": "selection", "logs": ["服务重启,已从任务目录恢复登录会话"], "directory": str(directory), "created_at": int(time.time()), @@ -569,7 +751,9 @@ def _restore_jobs(data_dir: Path) -> int: except (OSError, json.JSONDecodeError): pass _jobs[job_id] = { - "job_id": job_id, "status": status, "phase": phase, + "job_id": job_id, + "status": status, + "phase": phase, "logs": ["服务重启,已从任务目录恢复本任务"], "directory": str(directory), "created_at": int(time.time()), @@ -587,9 +771,15 @@ def main() -> int: parser.add_argument("--host", default="127.0.0.1") parser.add_argument("--port", type=int, default=8810) parser.add_argument("--data-dir", type=Path, default=DEFAULT_DATA) - parser.add_argument("--evidence-ttl-hours", type=int, default=DEFAULT_EVIDENCE_TTL_HOURS, - help="任务原始证据保留时长;0 表示不自动清理") - parser.add_argument("--key", default=None, help="HTTP Bearer 鉴权密钥;默认读取 YYB_WORKER_KEY") + parser.add_argument( + "--evidence-ttl-hours", + type=int, + default=DEFAULT_EVIDENCE_TTL_HOURS, + help="任务原始证据保留时长;0 表示不自动清理", + ) + parser.add_argument( + "--key", default=None, help="HTTP Bearer 鉴权密钥;默认读取 YYB_WORKER_KEY" + ) args = parser.parse_args() # 强制绝对路径:子进程以 ROOT 为 cwd,相对路径会让任务文件写到错误位置。 DEFAULT_DATA = Path(args.data_dir).resolve() @@ -603,7 +793,10 @@ def main() -> int: os.chmod(DEFAULT_DATA, 0o700) removed = _cleanup_expired_jobs(DEFAULT_DATA, args.evidence_ttl_hours) if removed: - print(f"已清理 {removed} 个过期任务证据(保留期 {args.evidence_ttl_hours} 小时)", flush=True) + print( + f"已清理 {removed} 个过期任务证据(保留期 {args.evidence_ttl_hours} 小时)", + flush=True, + ) restored = _restore_jobs(DEFAULT_DATA) if restored: print(f"已从任务目录恢复 {restored} 个支付任务", flush=True) diff --git a/services/yyb-worker/runtime/tests/test_jsdom_pay.py b/services/yyb-worker/runtime/tests/test_jsdom_pay.py index 2c76422..b46e093 100644 --- a/services/yyb-worker/runtime/tests/test_jsdom_pay.py +++ b/services/yyb-worker/runtime/tests/test_jsdom_pay.py @@ -1,4 +1,5 @@ """YYB 付款表单的离线回归测试。""" + from __future__ import annotations import importlib.util @@ -23,14 +24,24 @@ from pyvm.protocol import goods_material_diagnostics, validate_goods_materials class TestJsdomPay(unittest.TestCase): def test_build_save_body_has_current_payment_context(self): body = _MODULE.build_save_body( - {"token_id": "token", "transaction_id": "transaction", "out_trade_no": "trade"}, + { + "token_id": "token", + "transaction_id": "transaction", + "out_trade_no": "trade", + }, {"openid": "openid", "accesstoken": "access-token", "logintype": "WX"}, - "web-token", "anti-token", "cipher", "2", - "mds_myappjp-__mds_myappjp_PC_aW9zd2hpdGVsaX0-iap", 600, + "web-token", + "anti-token", + "cipher", + "2", + "mds_myappjp-__mds_myappjp_PC_aW9zd2hpdGVsaX0-iap", + 600, ) fields = parse_qs(body, keep_blank_values=True) self.assertEqual(fields["type"], ["bg"]) - self.assertEqual(fields["pf"], ["mds_myappjp-__mds_myappjp_PC_aW9zd2hpdGVsaX0-iap"]) + self.assertEqual( + fields["pf"], ["mds_myappjp-__mds_myappjp_PC_aW9zd2hpdGVsaX0-iap"] + ) self.assertEqual(fields["zoneid"], ["2"]) self.assertEqual(fields["pay_method"], ["wechat"]) self.assertEqual(fields["wcp"], ["type=CNY&amt=600"]) @@ -39,9 +50,23 @@ class TestJsdomPay(unittest.TestCase): def test_qq_payment_uses_qq_oauth_session_fields(self): body = _MODULE.build_save_body( - {"token_id": "token", "transaction_id": "transaction", "out_trade_no": "trade"}, - {"openid": "openid", "accesstoken": "access-token", "logintype": "QC", "appid": "102033112"}, - "web-token", "anti-token", "cipher", "1", "pf", 100, + { + "token_id": "token", + "transaction_id": "transaction", + "out_trade_no": "trade", + }, + { + "openid": "openid", + "accesstoken": "access-token", + "logintype": "QC", + "appid": "102033112", + }, + "web-token", + "anti-token", + "cipher", + "1", + "pf", + 100, ) fields = parse_qs(body, keep_blank_values=True) self.assertEqual(fields["session_id"], ["openid"]) @@ -52,18 +77,38 @@ class TestJsdomPay(unittest.TestCase): def test_wechat_payment_does_not_include_qq_offer_field(self): body = _MODULE.build_save_body( - {"token_id": "token", "transaction_id": "transaction", "out_trade_no": "trade"}, + { + "token_id": "token", + "transaction_id": "transaction", + "out_trade_no": "trade", + }, {"openid": "openid", "accesstoken": "access-token", "logintype": "WX"}, - "web-token", "anti-token", "cipher", "1", "pf", 100, + "web-token", + "anti-token", + "cipher", + "1", + "pf", + 100, ) fields = parse_qs(body, keep_blank_values=True) self.assertNotIn("offerid_for_qq_appid", fields) def test_qq_page_info_matches_the_web_flow(self): body = _MODULE.build_page_info_body( - {"token_id": "token", "transaction_id": "transaction", "out_trade_no": "trade"}, - {"openid": "openid", "accesstoken": "access-token", "logintype": "QC", "appid": "102033112"}, - "anti-token", "1", "pf", + { + "token_id": "token", + "transaction_id": "transaction", + "out_trade_no": "trade", + }, + { + "openid": "openid", + "accesstoken": "access-token", + "logintype": "QC", + "appid": "102033112", + }, + "anti-token", + "1", + "pf", ) fields = parse_qs(body, keep_blank_values=True) self.assertEqual(fields["isusempaymode"], ["1"]) @@ -71,13 +116,17 @@ class TestJsdomPay(unittest.TestCase): self.assertNotIn("pay_method", fields) def test_qq_risk_rejection_is_not_reported_as_qr_error(self): - message = describe_payment_failure("QC", "payment", "ret=1099 err_code=1099-1007-0") + message = describe_payment_failure( + "QC", "payment", "ret=1099 err_code=1099-1007-0" + ) self.assertIn("风控/限流", message) self.assertIn("未生成微信付款码", message) self.assertIn("停止连续重试", message) def test_wechat_rejection_is_not_labeled_as_risk_control(self): - message = describe_payment_failure("WX", "payment", "ret=1099 err_code=1099-1007-0") + message = describe_payment_failure( + "WX", "payment", "ret=1099 err_code=1099-1007-0" + ) self.assertIn("服务端拒绝", message) self.assertNotIn("风控", message) @@ -88,17 +137,30 @@ class TestJsdomPay(unittest.TestCase): def test_qq_encrypt_rand_aligns_plaintext_to_aes_block(self): params = { - "token_id": "t", "openid": "o", "openkey": "k", "session_id": "openid", - "session_type": "kp_accesstoken", "zoneid": "1", "pay_method": "wechat", - "buy_quantity": "1", "from_h5": "1", "webversion": "minipayv2", + "token_id": "t", + "openid": "o", + "openkey": "k", + "session_id": "openid", + "session_type": "kp_accesstoken", + "zoneid": "1", + "pay_method": "wechat", + "buy_quantity": "1", + "from_h5": "1", + "webversion": "minipayv2", } - rand_value = _MODULE.make_encrypt_rand(params, "tdrc_session%3Dpay-test", "1775990000", True) - plaintext = _MODULE.build_plaintext(params, "tdrc_session%3Dpay-test", "1775990000", rand_value) + rand_value = _MODULE.make_encrypt_rand( + params, "tdrc_session%3Dpay-test", "1775990000", True + ) + plaintext = _MODULE.build_plaintext( + params, "tdrc_session%3Dpay-test", "1775990000", rand_value + ) self.assertEqual(len(plaintext.encode("latin-1")) % 16, 0) self.assertGreaterEqual(len(rand_value), 8) def test_wechat_encrypt_rand_preserves_historical_control_byte(self): - rand_value = _MODULE.make_encrypt_rand({}, "tdrc_session%3Dpay-test", "1775990000", False) + rand_value = _MODULE.make_encrypt_rand( + {}, "tdrc_session%3Dpay-test", "1775990000", False + ) self.assertEqual(len(rand_value), 9) self.assertEqual(rand_value[-1], "\x01") @@ -124,12 +186,26 @@ class TestJsdomPay(unittest.TestCase): import json template = _MODULE.load_template_args() - xmidas = json.loads((_MODULE.ROOT / "replay" / "xmidasops.json").read_text(encoding="utf-8")) + xmidas = json.loads( + (_MODULE.ROOT / "replay" / "xmidasops.json").read_text(encoding="utf-8") + ) params = { - "token_id": "token", "openid": "openid", "openkey": "key", "session_id": "openid", - "session_type": "kp_accesstoken", "zoneid": "1", "pay_method": "wechat", - "buy_quantity": "1", "mb_pwd": "", "pay_id": "", "auth_key": "", "card_value": "", - "accounttype": "", "provide_uin": "", "extend": "", "from_h5": "1", + "token_id": "token", + "openid": "openid", + "openkey": "key", + "session_id": "openid", + "session_type": "kp_accesstoken", + "zoneid": "1", + "pay_method": "wechat", + "buy_quantity": "1", + "mb_pwd": "", + "pay_id": "", + "auth_key": "", + "card_value": "", + "accounttype": "", + "provide_uin": "", + "extend": "", + "from_h5": "1", "webversion": "minipayv2", } timestamp = "1775990000" @@ -137,16 +213,27 @@ class TestJsdomPay(unittest.TestCase): for length in range(512): params["extend"] = "x" * length rand_value = _MODULE.make_encrypt_rand(params, fk_extend, timestamp, True) - plaintext = _MODULE.build_plaintext(params, fk_extend, timestamp, rand_value) + plaintext = _MODULE.build_plaintext( + params, fk_extend, timestamp, rand_value + ) if len(plaintext.encode("latin-1")) == 528: break else: self.fail("无法构造 528B goods 测试明文") key16 = list(range(16)) - key1 = derive_key1_from_key16(key16, [template[i][0] for i in (1, 2, 3, 4)], template[5][0]) + key1 = derive_key1_from_key16( + key16, [template[i][0] for i in (1, 2, 3, 4)], template[5][0] + ) ciphertext = generate_encrypt_msg_offline( - params, fk_extend, timestamp, rand_value, key16=key16, key1=key1, - args_template=template, xmidas=xmidas, xmidas_token="A" * 96, + params, + fk_extend, + timestamp, + rand_value, + key16=key16, + key1=key1, + args_template=template, + xmidas=xmidas, + xmidas_token="A" * 96, ) self.assertEqual(len(ciphertext), 1056) diff --git a/utils/logger.py b/utils/logger.py index 9fc422b..d4a1817 100644 --- a/utils/logger.py +++ b/utils/logger.py @@ -67,8 +67,12 @@ class _SensitiveDataFilter(logging.Filter): @classmethod def redact(cls, message: str) -> str: - message = cls._HEADER_RE.sub(lambda m: f"{m.group(1)}{m.group(2)} [REDACTED]", message) - message = cls._KEY_VALUE_RE.sub(lambda m: f"{m.group('prefix')}[REDACTED]", message) + message = cls._HEADER_RE.sub( + lambda m: f"{m.group(1)}{m.group(2)} [REDACTED]", message + ) + message = cls._KEY_VALUE_RE.sub( + lambda m: f"{m.group('prefix')}[REDACTED]", message + ) message = cls._BEARER_RE.sub("Bearer [REDACTED]", message) return cls._JWT_RE.sub("[REDACTED_JWT]", message) @@ -105,7 +109,9 @@ class _SizeAndDayRotatingFileHandler(BaseRotatingHandler): self._filename_prefix = re.sub(r"-\d{4}-\d{2}-\d{2}$", "", path.stem) def _path_for_day(self, day) -> Path: - return self._log_dir / f"{self._filename_prefix}-{day.isoformat()}{self._suffix}" + return ( + self._log_dir / f"{self._filename_prefix}-{day.isoformat()}{self._suffix}" + ) def shouldRollover(self, record: logging.LogRecord) -> bool: # noqa: N802 if datetime.now().date() != self._active_day: @@ -133,7 +139,9 @@ class _SizeAndDayRotatingFileHandler(BaseRotatingHandler): archive = source.with_name(f"{source.name}.{stamp}.{os.getpid()}.gz") sequence = 1 while archive.exists(): - archive = source.with_name(f"{source.name}.{stamp}.{os.getpid()}.{sequence}.gz") + archive = source.with_name( + f"{source.name}.{stamp}.{os.getpid()}.{sequence}.gz" + ) sequence += 1 with source.open("rb") as raw, gzip.open(archive, "wb") as compressed: shutil.copyfileobj(raw, compressed) @@ -160,7 +168,9 @@ def _configure_standard_logging(level: str, log_path: Path) -> logging.Logger: """将业务日志和关键框架日志收敛到单一安全轮转文件。""" log_level = getattr(logging, level.upper(), logging.INFO) framework_level = max(log_level, logging.INFO) - formatter = logging.Formatter("%(asctime)s | %(levelname)-8s | %(name)s | %(message)s") + formatter = logging.Formatter( + "%(asctime)s | %(levelname)-8s | %(name)s | %(message)s" + ) redaction_filter = _SensitiveDataFilter() console_handler = logging.StreamHandler(sys.stdout) @@ -168,9 +178,13 @@ def _configure_standard_logging(level: str, log_path: Path) -> logging.Logger: console_handler.setFormatter(formatter) console_handler.addFilter(redaction_filter) - retention_days = _parse_positive_int(os.getenv("LOG_RETENTION_DAYS"), _DEFAULT_RETENTION_DAYS) + retention_days = _parse_positive_int( + os.getenv("LOG_RETENTION_DAYS"), _DEFAULT_RETENTION_DAYS + ) rotation_size = _parse_size(os.getenv("LOG_ROTATION_SIZE")) - file_handler = _SizeAndDayRotatingFileHandler(log_path, rotation_size, retention_days) + file_handler = _SizeAndDayRotatingFileHandler( + log_path, rotation_size, retention_days + ) file_handler.setLevel(log_level) file_handler.setFormatter(formatter) file_handler.addFilter(redaction_filter) @@ -199,7 +213,9 @@ def _configure_standard_logging(level: str, log_path: Path) -> logging.Logger: return app_logger -def setup_logger(level: str = "INFO", log_dir: str | None = None, log_file: str | None = None) -> None: +def setup_logger( + level: str = "INFO", log_dir: str | None = None, log_file: str | None = None +) -> None: """配置控制台和文件日志。 ``log_dir`` 默认写入 ``app-YYYY-MM-DD.log``;``log_file`` 可指定完整文件名。环境变量 diff --git a/web/backend/crypto_storage.py b/web/backend/crypto_storage.py index 4f1f113..7a3426e 100644 --- a/web/backend/crypto_storage.py +++ b/web/backend/crypto_storage.py @@ -91,7 +91,7 @@ def encrypt_value(value: str | None) -> str | None: def _decrypt_with_candidate(value: str, candidate: _KeyCandidate) -> str: - raw = _b64d(value[len(_PREFIX):]) + raw = _b64d(value[len(_PREFIX) :]) if len(raw) < 28: raise ValueError("密文字段长度无效") nonce = raw[:12] @@ -112,7 +112,9 @@ def decrypt_value(value: str | None) -> str | None: return _decrypt_with_candidate(value, candidate) except Exception as exc: last_error = exc - raise ValueError("敏感字段解密失败,请确认 APP_ENCRYPTION_KEY 是否正确") from last_error + raise ValueError( + "敏感字段解密失败,请确认 APP_ENCRYPTION_KEY 是否正确" + ) from last_error def decrypt_value_with_key_name(value: str) -> tuple[str, str]: @@ -125,7 +127,9 @@ def decrypt_value_with_key_name(value: str) -> tuple[str, str]: return _decrypt_with_candidate(value, candidate), candidate.name except Exception as exc: last_error = exc - raise ValueError("敏感字段解密失败,请确认 APP_ENCRYPTION_KEY 是否正确") from last_error + raise ValueError( + "敏感字段解密失败,请确认 APP_ENCRYPTION_KEY 是否正确" + ) from last_error class EncryptedText(TypeDecorator): @@ -184,9 +188,15 @@ def encrypt_existing_sensitive_data(engine: Engine) -> int: for table_name, column_name in _SENSITIVE_COLUMNS: if not _table_exists(engine, table_name): continue - rows = conn.execute( - text(f"SELECT id, {column_name} FROM {table_name} WHERE {column_name} IS NOT NULL") - ).mappings().all() + rows = ( + conn.execute( + text( + f"SELECT id, {column_name} FROM {table_name} WHERE {column_name} IS NOT NULL" + ) + ) + .mappings() + .all() + ) for row in rows: raw_value = row[column_name] if raw_value is None or raw_value == "": @@ -204,7 +214,9 @@ def encrypt_existing_sensitive_data(engine: Engine) -> int: continue encrypted = encrypt_value(plain_value) conn.execute( - text(f"UPDATE {table_name} SET {column_name} = :value WHERE id = :id"), + text( + f"UPDATE {table_name} SET {column_name} = :value WHERE id = :id" + ), {"value": encrypted, "id": row["id"]}, ) changed += 1 diff --git a/web/backend/database.py b/web/backend/database.py index b9c8f5d..8d33b9f 100644 --- a/web/backend/database.py +++ b/web/backend/database.py @@ -32,7 +32,9 @@ def _get_database_url() -> str: db_user = os.getenv("DB_USER", "douyu_login").strip() db_password = os.getenv("DB_PASSWORD", "") if not db_name or not db_user or not db_password: - raise RuntimeError("使用 DB_HOST 时必须同时设置 DB_NAME、DB_USER 和 DB_PASSWORD") + raise RuntimeError( + "使用 DB_HOST 时必须同时设置 DB_NAME、DB_USER 和 DB_PASSWORD" + ) return ( f"mysql+pymysql://{quote_plus(db_user)}:{quote_plus(db_password)}" f"@{db_host}:{db_port}/{db_name}?charset=utf8mb4" @@ -69,6 +71,7 @@ engine = create_engine(DATABASE_URL, **engine_options) if DATABASE_URL.startswith("sqlite"): + @event.listens_for(engine, "connect") def _set_sqlite_pragmas(dbapi_connection, connection_record): """提升 SQLite 并发写入稳定性。""" @@ -78,6 +81,7 @@ if DATABASE_URL.startswith("sqlite"): cursor.execute("PRAGMA foreign_keys=ON") cursor.close() + SessionLocal = sessionmaker(bind=engine, autocommit=False, autoflush=False) Base = declarative_base() @@ -104,7 +108,9 @@ def run_migrations(): from alembic.config import Config config = Config(str(PROJECT_ROOT / "alembic.ini")) - config.set_main_option("script_location", str(PROJECT_ROOT / "web" / "backend" / "migrations")) + config.set_main_option( + "script_location", str(PROJECT_ROOT / "web" / "backend" / "migrations") + ) config.set_main_option("sqlalchemy.url", DATABASE_URL) # 标记为应用内嵌调用,env.py 据此跳过 fileConfig,避免覆盖 uvicorn 日志配置。 os.environ["ALEMBIC_EMBEDDED"] = "1" diff --git a/web/backend/main.py b/web/backend/main.py index 866ebe4..f57d80f 100644 --- a/web/backend/main.py +++ b/web/backend/main.py @@ -12,7 +12,20 @@ from fastapi.responses import FileResponse from starlette.middleware.base import BaseHTTPMiddleware from .database import init_db -from .routers import auth, users, accounts, account_check, dashboard, login, proxy, cookies, huya, douyu, yyb, audit +from .routers import ( + auth, + users, + accounts, + account_check, + dashboard, + login, + proxy, + cookies, + huya, + douyu, + yyb, + audit, +) from .schemas import AppInfo from .version import get_app_version from utils import setup_logger @@ -22,7 +35,9 @@ from utils import setup_logger async def lifespan(app: FastAPI): # 本地 dev.sh 和 Docker 均显式设置 LOG_DIR;直接运行时回退到项目 logs/。 _log_level = os.getenv("LOG_LEVEL", "INFO") - _log_dir = Path(os.getenv("LOG_DIR", str(Path(__file__).resolve().parents[2] / "logs"))) + _log_dir = Path( + os.getenv("LOG_DIR", str(Path(__file__).resolve().parents[2] / "logs")) + ) setup_logger(level=_log_level, log_dir=str(_log_dir)) init_db() @@ -46,6 +61,7 @@ async def lifespan(app: FastAPI): if cleaned_relogin: logger.info(f"启动清理 CK 重登残留任务: {cleaned_relogin} 条") from .services.yyb_service import cleanup_orphan_yyb_tasks + cleaned_yyb = cleanup_orphan_yyb_tasks(db, message="任务已中断(服务重启)") if cleaned_yyb: logger.info(f"启动清理应用宝残留任务: {cleaned_yyb} 条") @@ -65,7 +81,11 @@ _cors_env = os.getenv("CORS_ORIGINS", "") if _cors_env: _cors_origins = [o.strip() for o in _cors_env.split(",") if o.strip()] else: - _cors_origins = ["http://localhost:5174", "http://localhost:5173", "http://localhost:3000"] + _cors_origins = [ + "http://localhost:5174", + "http://localhost:5173", + "http://localhost:3000", + ] app.add_middleware( CORSMiddleware, diff --git a/web/backend/migrations/versions/20260623_0001_initial_web_schema.py b/web/backend/migrations/versions/20260623_0001_initial_web_schema.py index debe4f4..2e0486d 100644 --- a/web/backend/migrations/versions/20260623_0001_initial_web_schema.py +++ b/web/backend/migrations/versions/20260623_0001_initial_web_schema.py @@ -39,7 +39,9 @@ def _add_column_if_missing(bind, table_name: str, column: sa.Column) -> bool: return True -def _create_index_if_missing(bind, name: str, table_name: str, columns: list[str], unique: bool = False) -> None: +def _create_index_if_missing( + bind, name: str, table_name: str, columns: list[str], unique: bool = False +) -> None: if name not in _indexes(bind, table_name): op.create_index(name, table_name, columns, unique=unique) @@ -62,8 +64,12 @@ def upgrade() -> None: sa.PrimaryKeyConstraint("id"), ) else: - _add_column_if_missing(bind, "users", sa.Column("custom_permissions", sa.JSON(), nullable=True)) - _create_index_if_missing(bind, "ix_users_username", "users", ["username"], unique=True) + _add_column_if_missing( + bind, "users", sa.Column("custom_permissions", sa.JSON(), nullable=True) + ) + _create_index_if_missing( + bind, "ix_users_username", "users", ["username"], unique=True + ) if not _has_table(bind, "accounts"): op.create_table( @@ -84,8 +90,14 @@ def upgrade() -> None: sa.PrimaryKeyConstraint("id"), ) else: - _add_column_if_missing(bind, "accounts", sa.Column("tag", sa.String(length=64), server_default="")) - added_ssl = _add_column_if_missing(bind, "accounts", sa.Column("email_imap_ssl", sa.Boolean(), server_default=sa.text("1"))) + _add_column_if_missing( + bind, "accounts", sa.Column("tag", sa.String(length=64), server_default="") + ) + added_ssl = _add_column_if_missing( + bind, + "accounts", + sa.Column("email_imap_ssl", sa.Boolean(), server_default=sa.text("1")), + ) if added_ssl: op.execute( "UPDATE accounts SET email_imap_port = 143, email_imap_ssl = 0 " @@ -95,7 +107,9 @@ def upgrade() -> None: "UPDATE accounts SET email_imap_port = 143, email_imap_ssl = 0 " "WHERE email_imap_server = 'mail.bdhg.xyz'" ) - _create_index_if_missing(bind, "ix_accounts_assigned_to", "accounts", ["assigned_to"]) + _create_index_if_missing( + bind, "ix_accounts_assigned_to", "accounts", ["assigned_to"] + ) if not _has_table(bind, "proxy_config"): op.create_table( @@ -127,7 +141,9 @@ def upgrade() -> None: sa.ForeignKeyConstraint(["created_by"], ["users.id"]), sa.PrimaryKeyConstraint("id"), ) - _create_index_if_missing(bind, "ix_login_tasks_batch_id", "login_tasks", ["batch_id"]) + _create_index_if_missing( + bind, "ix_login_tasks_batch_id", "login_tasks", ["batch_id"] + ) if not _has_table(bind, "audit_logs"): op.create_table( diff --git a/web/backend/migrations/versions/20260624_0002_sensitive_columns_text.py b/web/backend/migrations/versions/20260624_0002_sensitive_columns_text.py index f8a3248..493798f 100644 --- a/web/backend/migrations/versions/20260624_0002_sensitive_columns_text.py +++ b/web/backend/migrations/versions/20260624_0002_sensitive_columns_text.py @@ -19,16 +19,30 @@ depends_on: Union[str, Sequence[str], None] = None def upgrade() -> None: with op.batch_alter_table("accounts") as batch: - batch.alter_column("password", existing_type=sa.String(length=256), type_=sa.Text()) - batch.alter_column("email", existing_type=sa.String(length=128), type_=sa.Text()) - batch.alter_column("email_password", existing_type=sa.String(length=256), type_=sa.Text()) + batch.alter_column( + "password", existing_type=sa.String(length=256), type_=sa.Text() + ) + batch.alter_column( + "email", existing_type=sa.String(length=128), type_=sa.Text() + ) + batch.alter_column( + "email_password", existing_type=sa.String(length=256), type_=sa.Text() + ) with op.batch_alter_table("proxy_config") as batch: - batch.alter_column("api_url", existing_type=sa.String(length=512), type_=sa.Text()) + batch.alter_column( + "api_url", existing_type=sa.String(length=512), type_=sa.Text() + ) batch.alter_column("http", existing_type=sa.String(length=256), type_=sa.Text()) - batch.alter_column("https", existing_type=sa.String(length=256), type_=sa.Text()) - batch.alter_column("whitelist_uid", existing_type=sa.String(length=64), type_=sa.Text()) - batch.alter_column("whitelist_ukey", existing_type=sa.String(length=128), type_=sa.Text()) + batch.alter_column( + "https", existing_type=sa.String(length=256), type_=sa.Text() + ) + batch.alter_column( + "whitelist_uid", existing_type=sa.String(length=64), type_=sa.Text() + ) + batch.alter_column( + "whitelist_ukey", existing_type=sa.String(length=128), type_=sa.Text() + ) with op.batch_alter_table("login_tasks") as batch: batch.alter_column("cookie", existing_type=sa.Text(), type_=sa.Text()) @@ -39,13 +53,27 @@ def downgrade() -> None: batch.alter_column("cookie", existing_type=sa.Text(), type_=sa.Text()) with op.batch_alter_table("proxy_config") as batch: - batch.alter_column("whitelist_ukey", existing_type=sa.Text(), type_=sa.String(length=128)) - batch.alter_column("whitelist_uid", existing_type=sa.Text(), type_=sa.String(length=64)) - batch.alter_column("https", existing_type=sa.Text(), type_=sa.String(length=256)) + batch.alter_column( + "whitelist_ukey", existing_type=sa.Text(), type_=sa.String(length=128) + ) + batch.alter_column( + "whitelist_uid", existing_type=sa.Text(), type_=sa.String(length=64) + ) + batch.alter_column( + "https", existing_type=sa.Text(), type_=sa.String(length=256) + ) batch.alter_column("http", existing_type=sa.Text(), type_=sa.String(length=256)) - batch.alter_column("api_url", existing_type=sa.Text(), type_=sa.String(length=512)) + batch.alter_column( + "api_url", existing_type=sa.Text(), type_=sa.String(length=512) + ) with op.batch_alter_table("accounts") as batch: - batch.alter_column("email_password", existing_type=sa.Text(), type_=sa.String(length=256)) - batch.alter_column("email", existing_type=sa.Text(), type_=sa.String(length=128)) - batch.alter_column("password", existing_type=sa.Text(), type_=sa.String(length=256)) + batch.alter_column( + "email_password", existing_type=sa.Text(), type_=sa.String(length=256) + ) + batch.alter_column( + "email", existing_type=sa.Text(), type_=sa.String(length=128) + ) + batch.alter_column( + "password", existing_type=sa.Text(), type_=sa.String(length=256) + ) diff --git a/web/backend/migrations/versions/20260624_0003_proxy_platform.py b/web/backend/migrations/versions/20260624_0003_proxy_platform.py index c0bfcab..00b6a1d 100644 --- a/web/backend/migrations/versions/20260624_0003_proxy_platform.py +++ b/web/backend/migrations/versions/20260624_0003_proxy_platform.py @@ -22,9 +22,7 @@ def upgrade() -> None: batch.add_column( sa.Column("whitelist_platform", sa.String(32), server_default="xiequ") ) - batch.add_column( - sa.Column("whitelist_credentials", sa.JSON(), nullable=True) - ) + batch.add_column(sa.Column("whitelist_credentials", sa.JSON(), nullable=True)) def downgrade() -> None: diff --git a/web/backend/migrations/versions/20260704_0004_huya_foundation.py b/web/backend/migrations/versions/20260704_0004_huya_foundation.py index 98eb37e..43235dc 100644 --- a/web/backend/migrations/versions/20260704_0004_huya_foundation.py +++ b/web/backend/migrations/versions/20260704_0004_huya_foundation.py @@ -27,7 +27,9 @@ def _indexes(bind, table_name: str) -> set[str]: return {index["name"] for index in sa.inspect(bind).get_indexes(table_name)} -def _create_index_if_missing(bind, name: str, table_name: str, columns: list[str], unique: bool = False) -> None: +def _create_index_if_missing( + bind, name: str, table_name: str, columns: list[str], unique: bool = False +) -> None: if name not in _indexes(bind, table_name): op.create_index(name, table_name, columns, unique=unique) @@ -59,7 +61,9 @@ def upgrade() -> None: ) _create_index_if_missing(bind, "ix_huya_accounts_uid", "huya_accounts", ["uid"]) _create_index_if_missing(bind, "ix_huya_accounts_yyuid", "huya_accounts", ["yyuid"]) - _create_index_if_missing(bind, "ix_huya_accounts_assigned_to", "huya_accounts", ["assigned_to"]) + _create_index_if_missing( + bind, "ix_huya_accounts_assigned_to", "huya_accounts", ["assigned_to"] + ) if not _has_table(bind, "huya_tasks"): op.create_table( @@ -79,7 +83,9 @@ def upgrade() -> None: sa.PrimaryKeyConstraint("id"), ) _create_index_if_missing(bind, "ix_huya_tasks_batch_id", "huya_tasks", ["batch_id"]) - _create_index_if_missing(bind, "ix_huya_tasks_task_type", "huya_tasks", ["task_type"]) + _create_index_if_missing( + bind, "ix_huya_tasks_task_type", "huya_tasks", ["task_type"] + ) if not _has_table(bind, "huya_config"): op.create_table( @@ -106,13 +112,17 @@ def upgrade() -> None: sa.Column("updated_at", sa.DateTime(), nullable=True), sa.PrimaryKeyConstraint("id"), ) - _create_index_if_missing(bind, "ix_huya_goods_snapshot_product_id", "huya_goods_snapshot", ["product_id"]) + _create_index_if_missing( + bind, "ix_huya_goods_snapshot_product_id", "huya_goods_snapshot", ["product_id"] + ) def downgrade() -> None: bind = op.get_bind() if _has_table(bind, "huya_goods_snapshot"): - op.drop_index("ix_huya_goods_snapshot_product_id", table_name="huya_goods_snapshot") + op.drop_index( + "ix_huya_goods_snapshot_product_id", table_name="huya_goods_snapshot" + ) op.drop_table("huya_goods_snapshot") if _has_table(bind, "huya_config"): op.drop_table("huya_config") diff --git a/web/backend/migrations/versions/20260704_0005_huya_recharge_goods.py b/web/backend/migrations/versions/20260704_0005_huya_recharge_goods.py index be0ed06..4c0452c 100644 --- a/web/backend/migrations/versions/20260704_0005_huya_recharge_goods.py +++ b/web/backend/migrations/versions/20260704_0005_huya_recharge_goods.py @@ -27,7 +27,9 @@ def _indexes(bind, table_name: str) -> set[str]: return {index["name"] for index in sa.inspect(bind).get_indexes(table_name)} -def _create_index_if_missing(bind, name: str, table_name: str, columns: list[str], unique: bool = False) -> None: +def _create_index_if_missing( + bind, name: str, table_name: str, columns: list[str], unique: bool = False +) -> None: if name not in _indexes(bind, table_name): op.create_index(name, table_name, columns, unique=unique) @@ -71,7 +73,13 @@ def downgrade() -> None: if _has_table(bind, "huya_recharge_goods_snapshot"): indexes = _indexes(bind, "huya_recharge_goods_snapshot") if "ix_huya_recharge_goods_snapshot_sku_id" in indexes: - op.drop_index("ix_huya_recharge_goods_snapshot_sku_id", table_name="huya_recharge_goods_snapshot") + op.drop_index( + "ix_huya_recharge_goods_snapshot_sku_id", + table_name="huya_recharge_goods_snapshot", + ) if "ix_huya_recharge_goods_snapshot_spu_id" in indexes: - op.drop_index("ix_huya_recharge_goods_snapshot_spu_id", table_name="huya_recharge_goods_snapshot") + op.drop_index( + "ix_huya_recharge_goods_snapshot_spu_id", + table_name="huya_recharge_goods_snapshot", + ) op.drop_table("huya_recharge_goods_snapshot") diff --git a/web/backend/migrations/versions/20260712_0007_huya_register_history.py b/web/backend/migrations/versions/20260712_0007_huya_register_history.py index 92e1130..74bf9c6 100644 --- a/web/backend/migrations/versions/20260712_0007_huya_register_history.py +++ b/web/backend/migrations/versions/20260712_0007_huya_register_history.py @@ -59,9 +59,17 @@ def upgrade() -> None: sa.PrimaryKeyConstraint("id"), sa.UniqueConstraint("batch_id"), ) - op.create_index("ix_huya_register_batches_batch_id", "huya_register_batches", ["batch_id"]) - op.create_index("ix_huya_register_batches_created_by", "huya_register_batches", ["created_by"]) - op.create_index("ix_huya_register_batches_status", "huya_register_batches", ["status"]) + op.create_index( + "ix_huya_register_batches_batch_id", "huya_register_batches", ["batch_id"] + ) + op.create_index( + "ix_huya_register_batches_created_by", + "huya_register_batches", + ["created_by"], + ) + op.create_index( + "ix_huya_register_batches_status", "huya_register_batches", ["status"] + ) if not _has_table(bind, "huya_register_items"): op.create_table( @@ -91,10 +99,18 @@ def upgrade() -> None: sa.ForeignKeyConstraint(["batch_db_id"], ["huya_register_batches.id"]), sa.PrimaryKeyConstraint("id"), ) - op.create_index("ix_huya_register_items_batch_db_id", "huya_register_items", ["batch_db_id"]) - op.create_index("ix_huya_register_items_batch_id", "huya_register_items", ["batch_id"]) - op.create_index("ix_huya_register_items_phone", "huya_register_items", ["phone"]) - op.create_index("ix_huya_register_items_status", "huya_register_items", ["status"]) + op.create_index( + "ix_huya_register_items_batch_db_id", "huya_register_items", ["batch_db_id"] + ) + op.create_index( + "ix_huya_register_items_batch_id", "huya_register_items", ["batch_id"] + ) + op.create_index( + "ix_huya_register_items_phone", "huya_register_items", ["phone"] + ) + op.create_index( + "ix_huya_register_items_status", "huya_register_items", ["status"] + ) if not _has_table(bind, "huya_register_success_logs"): op.create_table( @@ -116,10 +132,24 @@ def upgrade() -> None: sa.ForeignKeyConstraint(["created_by"], ["users.id"]), sa.PrimaryKeyConstraint("id"), ) - op.create_index("ix_huya_register_success_logs_batch_id", "huya_register_success_logs", ["batch_id"]) - op.create_index("ix_huya_register_success_logs_phone", "huya_register_success_logs", ["phone"]) - op.create_index("ix_huya_register_success_logs_tag", "huya_register_success_logs", ["tag"]) - op.create_index("ix_huya_register_success_logs_created_at", "huya_register_success_logs", ["created_at"]) + op.create_index( + "ix_huya_register_success_logs_batch_id", + "huya_register_success_logs", + ["batch_id"], + ) + op.create_index( + "ix_huya_register_success_logs_phone", + "huya_register_success_logs", + ["phone"], + ) + op.create_index( + "ix_huya_register_success_logs_tag", "huya_register_success_logs", ["tag"] + ) + op.create_index( + "ix_huya_register_success_logs_created_at", + "huya_register_success_logs", + ["created_at"], + ) def downgrade() -> None: diff --git a/web/backend/migrations/versions/20260724_0008_douyu_activity_tasks.py b/web/backend/migrations/versions/20260724_0008_douyu_activity_tasks.py index 8338d09..10169ed 100644 --- a/web/backend/migrations/versions/20260724_0008_douyu_activity_tasks.py +++ b/web/backend/migrations/versions/20260724_0008_douyu_activity_tasks.py @@ -38,7 +38,9 @@ def _add_column_if_missing(bind, table_name: str, column: sa.Column) -> None: op.add_column(table_name, column) -def _create_index_if_missing(bind, name: str, table_name: str, columns: list[str], unique: bool = False) -> None: +def _create_index_if_missing( + bind, name: str, table_name: str, columns: list[str], unique: bool = False +) -> None: if name not in _indexes(bind, table_name): op.create_index(name, table_name, columns, unique=unique) @@ -46,16 +48,40 @@ def _create_index_if_missing(bind, name: str, table_name: str, columns: list[str def upgrade() -> None: bind = op.get_bind() - _add_column_if_missing(bind, "accounts", sa.Column("uid", sa.String(length=32), nullable=True)) - _add_column_if_missing(bind, "accounts", sa.Column("nickname", sa.String(length=128), nullable=True)) - _add_column_if_missing(bind, "accounts", sa.Column("points", sa.Integer(), nullable=True)) - _add_column_if_missing(bind, "accounts", sa.Column("game_name", sa.String(length=128), nullable=True)) - _add_column_if_missing(bind, "accounts", sa.Column("game_channel", sa.String(length=128), nullable=True)) - _add_column_if_missing(bind, "accounts", sa.Column("gold_balance", sa.Integer(), nullable=True)) - _add_column_if_missing(bind, "accounts", sa.Column("exchange_balance", sa.Integer(), nullable=True)) - _add_column_if_missing(bind, "accounts", sa.Column("bind_status", sa.String(length=32), nullable=True)) - _add_column_if_missing(bind, "accounts", sa.Column("change_role_wait_time", sa.Integer(), nullable=True)) - _add_column_if_missing(bind, "accounts", sa.Column("updated_at", sa.DateTime(), nullable=True)) + _add_column_if_missing( + bind, "accounts", sa.Column("uid", sa.String(length=32), nullable=True) + ) + _add_column_if_missing( + bind, "accounts", sa.Column("nickname", sa.String(length=128), nullable=True) + ) + _add_column_if_missing( + bind, "accounts", sa.Column("points", sa.Integer(), nullable=True) + ) + _add_column_if_missing( + bind, "accounts", sa.Column("game_name", sa.String(length=128), nullable=True) + ) + _add_column_if_missing( + bind, + "accounts", + sa.Column("game_channel", sa.String(length=128), nullable=True), + ) + _add_column_if_missing( + bind, "accounts", sa.Column("gold_balance", sa.Integer(), nullable=True) + ) + _add_column_if_missing( + bind, "accounts", sa.Column("exchange_balance", sa.Integer(), nullable=True) + ) + _add_column_if_missing( + bind, "accounts", sa.Column("bind_status", sa.String(length=32), nullable=True) + ) + _add_column_if_missing( + bind, + "accounts", + sa.Column("change_role_wait_time", sa.Integer(), nullable=True), + ) + _add_column_if_missing( + bind, "accounts", sa.Column("updated_at", sa.DateTime(), nullable=True) + ) _create_index_if_missing(bind, "ix_accounts_uid", "accounts", ["uid"]) if not _has_table(bind, "douyu_tasks"): @@ -75,8 +101,12 @@ def upgrade() -> None: sa.ForeignKeyConstraint(["created_by"], ["users.id"]), sa.PrimaryKeyConstraint("id"), ) - _create_index_if_missing(bind, "ix_douyu_tasks_batch_id", "douyu_tasks", ["batch_id"]) - _create_index_if_missing(bind, "ix_douyu_tasks_task_type", "douyu_tasks", ["task_type"]) + _create_index_if_missing( + bind, "ix_douyu_tasks_batch_id", "douyu_tasks", ["batch_id"] + ) + _create_index_if_missing( + bind, "ix_douyu_tasks_task_type", "douyu_tasks", ["task_type"] + ) if not _has_table(bind, "douyu_config"): op.create_table( @@ -121,7 +151,10 @@ def downgrade() -> None: if _has_table(bind, "douyu_goods_snapshot"): indexes = _indexes(bind, "douyu_goods_snapshot") if "ix_douyu_goods_snapshot_commodity_id" in indexes: - op.drop_index("ix_douyu_goods_snapshot_commodity_id", table_name="douyu_goods_snapshot") + op.drop_index( + "ix_douyu_goods_snapshot_commodity_id", + table_name="douyu_goods_snapshot", + ) op.drop_table("douyu_goods_snapshot") if _has_table(bind, "douyu_config"): op.drop_table("douyu_config") diff --git a/web/backend/migrations/versions/20260725_0009_database_performance_indexes.py b/web/backend/migrations/versions/20260725_0009_database_performance_indexes.py index 9c2341c..96ea141 100644 --- a/web/backend/migrations/versions/20260725_0009_database_performance_indexes.py +++ b/web/backend/migrations/versions/20260725_0009_database_performance_indexes.py @@ -27,7 +27,9 @@ def _indexes(bind, table_name: str) -> set[str]: return {index["name"] for index in sa.inspect(bind).get_indexes(table_name)} -def _create_index_if_missing(bind, name: str, table_name: str, columns: list[str]) -> None: +def _create_index_if_missing( + bind, name: str, table_name: str, columns: list[str] +) -> None: if name not in _indexes(bind, table_name): op.create_index(name, table_name, columns) @@ -50,8 +52,16 @@ INDEXES = [ ("ix_huya_tasks_account_id", "huya_tasks", ["account_id", "id"]), ("ix_huya_tasks_created_by_id", "huya_tasks", ["created_by", "id"]), ("ix_huya_tasks_batch_status_id", "huya_tasks", ["batch_id", "status", "id"]), - ("ix_huya_register_items_batch_db_line", "huya_register_items", ["batch_db_id", "line"]), - ("ix_huya_register_success_logs_batch_id_id", "huya_register_success_logs", ["batch_id", "id"]), + ( + "ix_huya_register_items_batch_db_line", + "huya_register_items", + ["batch_db_id", "line"], + ), + ( + "ix_huya_register_success_logs_batch_id_id", + "huya_register_success_logs", + ["batch_id", "id"], + ), ] diff --git a/web/backend/migrations/versions/20260728_0012_douyu_esports_can_change_time.py b/web/backend/migrations/versions/20260728_0012_douyu_esports_can_change_time.py index 74626cd..dbda1cb 100644 --- a/web/backend/migrations/versions/20260728_0012_douyu_esports_can_change_time.py +++ b/web/backend/migrations/versions/20260728_0012_douyu_esports_can_change_time.py @@ -21,7 +21,10 @@ def upgrade() -> None: bind = op.get_bind() columns = {column["name"] for column in sa.inspect(bind).get_columns("accounts")} if "esports_can_change_time" not in columns: - op.add_column("accounts", sa.Column("esports_can_change_time", sa.Integer(), nullable=True)) + op.add_column( + "accounts", + sa.Column("esports_can_change_time", sa.Integer(), nullable=True), + ) def downgrade() -> None: diff --git a/web/backend/migrations/versions/20260807_0017_douyu_xpd_fragments.py b/web/backend/migrations/versions/20260807_0017_douyu_xpd_fragments.py index 2b8a06d..3367604 100644 --- a/web/backend/migrations/versions/20260807_0017_douyu_xpd_fragments.py +++ b/web/backend/migrations/versions/20260807_0017_douyu_xpd_fragments.py @@ -23,7 +23,9 @@ def upgrade() -> None: return columns = {column["name"] for column in sa.inspect(bind).get_columns("accounts")} if "xpd_fragments" not in columns: - op.add_column("accounts", sa.Column("xpd_fragments", sa.Integer(), nullable=True)) + op.add_column( + "accounts", sa.Column("xpd_fragments", sa.Integer(), nullable=True) + ) def downgrade() -> None: diff --git a/web/backend/migrations/versions/20260808_0019_dedupe_douyu_success_cookies.py b/web/backend/migrations/versions/20260808_0019_dedupe_douyu_success_cookies.py index 086dbd6..1a55f69 100644 --- a/web/backend/migrations/versions/20260808_0019_dedupe_douyu_success_cookies.py +++ b/web/backend/migrations/versions/20260808_0019_dedupe_douyu_success_cookies.py @@ -56,10 +56,8 @@ def upgrade() -> None: return for index in range(0, len(delete_ids), 500): - chunk = delete_ids[index:index + 500] - bind.execute( - task_table.delete().where(task_table.c.id.in_(chunk)) - ) + chunk = delete_ids[index : index + 500] + bind.execute(task_table.delete().where(task_table.c.id.in_(chunk))) def downgrade() -> None: diff --git a/web/backend/migrations/versions/20260812_0020_yyb_recharge.py b/web/backend/migrations/versions/20260812_0020_yyb_recharge.py index ec363ae..3fe1219 100644 --- a/web/backend/migrations/versions/20260812_0020_yyb_recharge.py +++ b/web/backend/migrations/versions/20260812_0020_yyb_recharge.py @@ -18,7 +18,9 @@ def upgrade() -> None: "yyb_recharge_tasks", sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True), sa.Column("task_id", sa.String(64), nullable=False), - sa.Column("created_by", sa.Integer(), sa.ForeignKey("users.id"), nullable=False), + sa.Column( + "created_by", sa.Integer(), sa.ForeignKey("users.id"), nullable=False + ), sa.Column("worker_job_id", sa.String(64), nullable=False), sa.Column("provider", sa.String(16), nullable=False, server_default=""), sa.Column("platform", sa.String(16), nullable=False, server_default="android"), @@ -37,9 +39,15 @@ def upgrade() -> None: sa.Column("created_at", sa.DateTime(), nullable=True), sa.Column("finished_at", sa.DateTime(), nullable=True), ) - op.create_index("uq_yyb_recharge_tasks_task_id", "yyb_recharge_tasks", ["task_id"], unique=True) - op.create_index("ix_yyb_recharge_tasks_created_by", "yyb_recharge_tasks", ["created_by"]) - op.create_index("ix_yyb_recharge_tasks_worker_job_id", "yyb_recharge_tasks", ["worker_job_id"]) + op.create_index( + "uq_yyb_recharge_tasks_task_id", "yyb_recharge_tasks", ["task_id"], unique=True + ) + op.create_index( + "ix_yyb_recharge_tasks_created_by", "yyb_recharge_tasks", ["created_by"] + ) + op.create_index( + "ix_yyb_recharge_tasks_worker_job_id", "yyb_recharge_tasks", ["worker_job_id"] + ) op.create_index("ix_yyb_recharge_tasks_status", "yyb_recharge_tasks", ["status"]) diff --git a/web/backend/migrations/versions/20260812_0021_yyb_qr_mediumtext.py b/web/backend/migrations/versions/20260812_0021_yyb_qr_mediumtext.py index 18634ba..7c55e10 100644 --- a/web/backend/migrations/versions/20260812_0021_yyb_qr_mediumtext.py +++ b/web/backend/migrations/versions/20260812_0021_yyb_qr_mediumtext.py @@ -18,8 +18,12 @@ def _is_mysql() -> bool: def upgrade() -> None: if _is_mysql(): - op.execute("ALTER TABLE yyb_recharge_tasks MODIFY login_qr_data MEDIUMTEXT NULL") - op.execute("ALTER TABLE yyb_recharge_tasks MODIFY payment_qr_data MEDIUMTEXT NULL") + op.execute( + "ALTER TABLE yyb_recharge_tasks MODIFY login_qr_data MEDIUMTEXT NULL" + ) + op.execute( + "ALTER TABLE yyb_recharge_tasks MODIFY payment_qr_data MEDIUMTEXT NULL" + ) def downgrade() -> None: diff --git a/web/backend/migrations/versions/20260812_0022_yyb_payment_meta.py b/web/backend/migrations/versions/20260812_0022_yyb_payment_meta.py index b980902..312c9c1 100644 --- a/web/backend/migrations/versions/20260812_0022_yyb_payment_meta.py +++ b/web/backend/migrations/versions/20260812_0022_yyb_payment_meta.py @@ -13,10 +13,21 @@ depends_on: Union[str, Sequence[str], None] = None def upgrade() -> None: - op.add_column("yyb_recharge_tasks", sa.Column("price_fen", sa.Integer(), nullable=True)) - op.add_column("yyb_recharge_tasks", sa.Column("payment_started_at", sa.DateTime(), nullable=True)) - op.add_column("yyb_recharge_tasks", sa.Column("payment_qr_created_at", sa.DateTime(), nullable=True)) - op.add_column("yyb_recharge_tasks", sa.Column("payment_last_checked_at", sa.DateTime(), nullable=True)) + op.add_column( + "yyb_recharge_tasks", sa.Column("price_fen", sa.Integer(), nullable=True) + ) + op.add_column( + "yyb_recharge_tasks", + sa.Column("payment_started_at", sa.DateTime(), nullable=True), + ) + op.add_column( + "yyb_recharge_tasks", + sa.Column("payment_qr_created_at", sa.DateTime(), nullable=True), + ) + op.add_column( + "yyb_recharge_tasks", + sa.Column("payment_last_checked_at", sa.DateTime(), nullable=True), + ) def downgrade() -> None: diff --git a/web/backend/migrations/versions/20260813_0023_soft_delete_users.py b/web/backend/migrations/versions/20260813_0023_soft_delete_users.py index cf8ad5b..1fead23 100644 --- a/web/backend/migrations/versions/20260813_0023_soft_delete_users.py +++ b/web/backend/migrations/versions/20260813_0023_soft_delete_users.py @@ -23,7 +23,9 @@ def upgrade() -> None: op.add_column("users", sa.Column("deleted_at", sa.DateTime(), nullable=True)) op.create_index("ix_users_deleted_at", "users", ["deleted_at"]) if "deleted_username" not in columns: - op.add_column("users", sa.Column("deleted_username", sa.String(length=64), nullable=True)) + op.add_column( + "users", sa.Column("deleted_username", sa.String(length=64), nullable=True) + ) def downgrade() -> None: diff --git a/web/backend/migrations/versions/20260813_0024_douyu_workbench_scopes.py b/web/backend/migrations/versions/20260813_0024_douyu_workbench_scopes.py index 1e3f17d..86c629d 100644 --- a/web/backend/migrations/versions/20260813_0024_douyu_workbench_scopes.py +++ b/web/backend/migrations/versions/20260813_0024_douyu_workbench_scopes.py @@ -26,35 +26,71 @@ def upgrade() -> None: if "handbook_scope" not in _columns(bind, "douyu_tasks"): op.add_column( "douyu_tasks", - sa.Column("handbook_scope", sa.String(length=16), nullable=False, server_default="legacy"), + sa.Column( + "handbook_scope", + sa.String(length=16), + nullable=False, + server_default="legacy", + ), + ) + op.create_index( + "ix_douyu_tasks_handbook_scope", "douyu_tasks", ["handbook_scope"] ) - op.create_index("ix_douyu_tasks_handbook_scope", "douyu_tasks", ["handbook_scope"]) if not sa.inspect(bind).has_table("douyu_workbench_accounts"): op.create_table( "douyu_workbench_accounts", sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True), - sa.Column("user_id", sa.Integer(), sa.ForeignKey("users.id"), nullable=False), + sa.Column( + "user_id", sa.Integer(), sa.ForeignKey("users.id"), nullable=False + ), sa.Column("handbook_scope", sa.String(length=16), nullable=False), - sa.Column("account_id", sa.Integer(), sa.ForeignKey("accounts.id"), nullable=False), + sa.Column( + "account_id", sa.Integer(), sa.ForeignKey("accounts.id"), nullable=False + ), sa.Column("created_at", sa.DateTime(), nullable=True), - sa.UniqueConstraint("user_id", "handbook_scope", "account_id", name="uq_douyu_workbench_account"), + sa.UniqueConstraint( + "user_id", + "handbook_scope", + "account_id", + name="uq_douyu_workbench_account", + ), + ) + op.create_index( + "ix_douyu_workbench_accounts_user_id", + "douyu_workbench_accounts", + ["user_id"], + ) + op.create_index( + "ix_douyu_workbench_accounts_handbook_scope", + "douyu_workbench_accounts", + ["handbook_scope"], + ) + op.create_index( + "ix_douyu_workbench_accounts_account_id", + "douyu_workbench_accounts", + ["account_id"], ) - op.create_index("ix_douyu_workbench_accounts_user_id", "douyu_workbench_accounts", ["user_id"]) - op.create_index("ix_douyu_workbench_accounts_handbook_scope", "douyu_workbench_accounts", ["handbook_scope"]) - op.create_index("ix_douyu_workbench_accounts_account_id", "douyu_workbench_accounts", ["account_id"]) if not sa.inspect(bind).has_table("douyu_workbenches"): op.create_table( "douyu_workbenches", sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True), - sa.Column("user_id", sa.Integer(), sa.ForeignKey("users.id"), nullable=False), + sa.Column( + "user_id", sa.Integer(), sa.ForeignKey("users.id"), nullable=False + ), sa.Column("handbook_scope", sa.String(length=16), nullable=False), sa.Column("updated_at", sa.DateTime(), nullable=True), sa.UniqueConstraint("user_id", "handbook_scope", name="uq_douyu_workbench"), ) - op.create_index("ix_douyu_workbenches_user_id", "douyu_workbenches", ["user_id"]) - op.create_index("ix_douyu_workbenches_handbook_scope", "douyu_workbenches", ["handbook_scope"]) + op.create_index( + "ix_douyu_workbenches_user_id", "douyu_workbenches", ["user_id"] + ) + op.create_index( + "ix_douyu_workbenches_handbook_scope", + "douyu_workbenches", + ["handbook_scope"], + ) def downgrade() -> None: diff --git a/web/backend/migrations/versions/20260813_0025_douyu_workbench_empty_state.py b/web/backend/migrations/versions/20260813_0025_douyu_workbench_empty_state.py index bbf0ab5..d3db005 100644 --- a/web/backend/migrations/versions/20260813_0025_douyu_workbench_empty_state.py +++ b/web/backend/migrations/versions/20260813_0025_douyu_workbench_empty_state.py @@ -30,7 +30,9 @@ def upgrade() -> None: sa.UniqueConstraint("user_id", "handbook_scope", name="uq_douyu_workbench"), ) op.create_index("ix_douyu_workbenches_user_id", "douyu_workbenches", ["user_id"]) - op.create_index("ix_douyu_workbenches_handbook_scope", "douyu_workbenches", ["handbook_scope"]) + op.create_index( + "ix_douyu_workbenches_handbook_scope", "douyu_workbenches", ["handbook_scope"] + ) def downgrade() -> None: diff --git a/web/backend/migrations/versions/20260813_0026_douyu_gold_recharge_channel.py b/web/backend/migrations/versions/20260813_0026_douyu_gold_recharge_channel.py index 21c2a13..98227a4 100644 --- a/web/backend/migrations/versions/20260813_0026_douyu_gold_recharge_channel.py +++ b/web/backend/migrations/versions/20260813_0026_douyu_gold_recharge_channel.py @@ -27,9 +27,33 @@ def upgrade() -> None: return columns = _columns(bind) additions = ( - ("gold_recharge_channel", sa.Column("gold_recharge_channel", sa.String(length=16), nullable=True, server_default="wechat_qr")), - ("gold_api_product_id", sa.Column("gold_api_product_id", sa.String(length=128), nullable=True, server_default="111570")), - ("gold_api_account_template_name", sa.Column("gold_api_account_template_name", sa.String(length=64), nullable=True, server_default="斗鱼UID")), + ( + "gold_recharge_channel", + sa.Column( + "gold_recharge_channel", + sa.String(length=16), + nullable=True, + server_default="wechat_qr", + ), + ), + ( + "gold_api_product_id", + sa.Column( + "gold_api_product_id", + sa.String(length=128), + nullable=True, + server_default="111570", + ), + ), + ( + "gold_api_account_template_name", + sa.Column( + "gold_api_account_template_name", + sa.String(length=64), + nullable=True, + server_default="斗鱼UID", + ), + ), ) for name, column in additions: if name not in columns: @@ -41,6 +65,10 @@ def downgrade() -> None: if not sa.inspect(bind).has_table("douyu_config"): return columns = _columns(bind) - for name in ("gold_api_account_template_name", "gold_api_product_id", "gold_recharge_channel"): + for name in ( + "gold_api_account_template_name", + "gold_api_product_id", + "gold_recharge_channel", + ): if name in columns: op.drop_column("douyu_config", name) diff --git a/web/backend/migrations/versions/20260813_0027_douyu_supplier_order_id.py b/web/backend/migrations/versions/20260813_0027_douyu_supplier_order_id.py index 87aee19..6055c36 100644 --- a/web/backend/migrations/versions/20260813_0027_douyu_supplier_order_id.py +++ b/web/backend/migrations/versions/20260813_0027_douyu_supplier_order_id.py @@ -24,7 +24,10 @@ def upgrade() -> None: return columns = {column["name"] for column in inspector.get_columns("douyu_tasks")} if "supplier_out_order_id" not in columns: - op.add_column("douyu_tasks", sa.Column("supplier_out_order_id", sa.String(length=64), nullable=True)) + op.add_column( + "douyu_tasks", + sa.Column("supplier_out_order_id", sa.String(length=64), nullable=True), + ) op.create_index( "ix_douyu_tasks_supplier_out_order_id", "douyu_tasks", diff --git a/web/backend/migrations/versions/20260829_0030_huya_login_channel.py b/web/backend/migrations/versions/20260829_0030_huya_login_channel.py index f0ca0fd..f2fd854 100644 --- a/web/backend/migrations/versions/20260829_0030_huya_login_channel.py +++ b/web/backend/migrations/versions/20260829_0030_huya_login_channel.py @@ -25,10 +25,17 @@ def upgrade() -> None: return columns = {column["name"] for column in inspector.get_columns("huya_accounts")} if "login_channel" not in columns: - op.add_column("huya_accounts", sa.Column("login_channel", sa.String(16), nullable=False, server_default="")) + op.add_column( + "huya_accounts", + sa.Column( + "login_channel", sa.String(16), nullable=False, server_default="" + ), + ) indexes = {index["name"] for index in inspector.get_indexes("huya_accounts")} if "ix_huya_accounts_login_channel" not in indexes: - op.create_index("ix_huya_accounts_login_channel", "huya_accounts", ["login_channel"]) + op.create_index( + "ix_huya_accounts_login_channel", "huya_accounts", ["login_channel"] + ) def downgrade() -> None: diff --git a/web/backend/migrations/versions/20260830_0031_mysql_task_query_indexes.py b/web/backend/migrations/versions/20260830_0031_mysql_task_query_indexes.py index 0a8fe86..ce7df3c 100644 --- a/web/backend/migrations/versions/20260830_0031_mysql_task_query_indexes.py +++ b/web/backend/migrations/versions/20260830_0031_mysql_task_query_indexes.py @@ -27,8 +27,16 @@ INDEXES = ( ["handbook_scope", "task_type", "id"], ), # Supports terminal-task retention and finished-time ordered Cookie views. - ("ix_douyu_tasks_status_finished_at_id", "douyu_tasks", ["status", "finished_at", "id"]), - ("ix_login_tasks_status_finished_at_id", "login_tasks", ["status", "finished_at", "id"]), + ( + "ix_douyu_tasks_status_finished_at_id", + "douyu_tasks", + ["status", "finished_at", "id"], + ), + ( + "ix_login_tasks_status_finished_at_id", + "login_tasks", + ["status", "finished_at", "id"], + ), ) diff --git a/web/backend/routers/audit.py b/web/backend/routers/audit.py index a62f5dd..c7d54e1 100644 --- a/web/backend/routers/audit.py +++ b/web/backend/routers/audit.py @@ -36,7 +36,10 @@ def _audit_log_out(db: Session, row: AuditLog) -> dict: accounts = ( db.query(DouyuTask, Account) .join(Account, Account.id == DouyuTask.account_id) - .filter(DouyuTask.batch_id == batch_id, DouyuTask.task_type == "create_gold_qr") + .filter( + DouyuTask.batch_id == batch_id, + DouyuTask.task_type == "create_gold_qr", + ) .order_by(DouyuTask.id.asc()) .limit(100) .all() @@ -56,14 +59,19 @@ def _audit_log_out(db: Session, row: AuditLog) -> dict: batch_id = row.target.removeprefix("douyu_batch:") task = ( db.query(DouyuTask) - .filter(DouyuTask.batch_id == batch_id, DouyuTask.task_type == "create_gold_qr") + .filter( + DouyuTask.batch_id == batch_id, + DouyuTask.task_type == "create_gold_qr", + ) .order_by(DouyuTask.id.asc()) .first() ) task_result = task.result if task and isinstance(task.result, dict) else {} recharge_channel = str(task_result.get("recharge_channel") or "wechat_qr") detail["recharge_channel"] = recharge_channel - detail["payment_method"] = "API 直充支付" if recharge_channel == "supplier_api" else "微信扫码支付" + detail["payment_method"] = ( + "API 直充支付" if recharge_channel == "supplier_api" else "微信扫码支付" + ) if isinstance(detail, dict): detail_text = json.dumps(detail, ensure_ascii=False, separators=(",", ":")) return { @@ -99,7 +107,9 @@ def list_audit_logs( query = query.filter(AuditLog.action == action.strip()) if keyword: pattern = f"%{keyword.strip()}%" - query = query.filter((AuditLog.target.ilike(pattern)) | (AuditLog.detail.ilike(pattern))) + query = query.filter( + (AuditLog.target.ilike(pattern)) | (AuditLog.detail.ilike(pattern)) + ) if success is not None: query = query.filter(AuditLog.success == success) if start_time: @@ -107,7 +117,12 @@ def list_audit_logs( if end_time: query = query.filter(AuditLog.created_at <= end_time) total = query.count() - rows = query.order_by(AuditLog.id.desc()).offset((page - 1) * page_size).limit(page_size).all() + rows = ( + query.order_by(AuditLog.id.desc()) + .offset((page - 1) * page_size) + .limit(page_size) + .all() + ) return { "items": [_audit_log_out(db, row) for row in rows], "total": total, diff --git a/web/backend/routers/auth.py b/web/backend/routers/auth.py index 31429c0..404d8df 100644 --- a/web/backend/routers/auth.py +++ b/web/backend/routers/auth.py @@ -41,7 +41,9 @@ def login(req: LoginRequest, response: Response, db: Session = Depends(get_db)): ) # 审计 - db.add(AuditLog(user_id=user.id, username=user.username, action="login", target="auth")) + db.add( + AuditLog(user_id=user.id, username=user.username, action="login", target="auth") + ) db.commit() return TokenResponse( @@ -66,8 +68,19 @@ def me(current_user: User = Depends(get_current_user)): @router.post("/logout") -def logout(response: Response, current_user: User = Depends(get_current_user), db: Session = Depends(get_db)): +def logout( + response: Response, + current_user: User = Depends(get_current_user), + db: Session = Depends(get_db), +): response.delete_cookie(key="access_token", path="/") - db.add(AuditLog(user_id=current_user.id, username=current_user.username, action="logout", target="auth")) + db.add( + AuditLog( + user_id=current_user.id, + username=current_user.username, + action="logout", + target="auth", + ) + ) db.commit() return {"message": "已登出"} diff --git a/web/backend/routers/dashboard.py b/web/backend/routers/dashboard.py index 0c49a97..e340d67 100644 --- a/web/backend/routers/dashboard.py +++ b/web/backend/routers/dashboard.py @@ -44,7 +44,9 @@ def _empty_task_summary() -> dict: def _can_view_huya_all(user: User) -> bool: """兼容旧 huya:account 全量权限。""" - return user_has_permission(user, "huya:view_all") or user_has_permission(user, "huya:account") + return user_has_permission(user, "huya:view_all") or user_has_permission( + user, "huya:account" + ) @router.get("/summary") @@ -65,12 +67,17 @@ def dashboard_summary( ) login_tasks = _empty_task_summary() - if any(user_has_permission(current, permission) for permission in ( - "login:batch", - "login:view_all", - "login:view_assigned", - )): - login_query = db.query(LoginTask).join(Account, LoginTask.account_id == Account.id) + if any( + user_has_permission(current, permission) + for permission in ( + "login:batch", + "login:view_all", + "login:view_assigned", + ) + ): + login_query = db.query(LoginTask).join( + Account, LoginTask.account_id == Account.id + ) if not user_has_permission(current, "login:view_all"): login_query = login_query.filter(Account.assigned_to == current.id) login_tasks = _task_summary(login_query, LoginTask) @@ -87,27 +94,40 @@ def dashboard_summary( cookies = cookie_query.with_entities(func.count(LoginTask.id)).scalar() or 0 huya_accounts = 0 - can_view_huya_accounts = any(user_has_permission(current, permission) for permission in ( - "huya:account", - "huya:view_all", - "huya:view_assigned", - )) + can_view_huya_accounts = any( + user_has_permission(current, permission) + for permission in ( + "huya:account", + "huya:view_all", + "huya:view_assigned", + ) + ) if can_view_huya_accounts: huya_account_query = db.query(HuyaAccount) if not _can_view_huya_all(current): - huya_account_query = huya_account_query.filter(HuyaAccount.assigned_to == current.id) - huya_accounts = huya_account_query.with_entities(func.count(HuyaAccount.id)).scalar() or 0 + huya_account_query = huya_account_query.filter( + HuyaAccount.assigned_to == current.id + ) + huya_accounts = ( + huya_account_query.with_entities(func.count(HuyaAccount.id)).scalar() or 0 + ) huya_tasks = _empty_task_summary() huya_goods = 0 huya_recharge_goods = 0 if user_has_permission(current, "huya:task"): - huya_task_query = db.query(HuyaTask).join(HuyaAccount, HuyaTask.account_id == HuyaAccount.id) + huya_task_query = db.query(HuyaTask).join( + HuyaAccount, HuyaTask.account_id == HuyaAccount.id + ) if not _can_view_huya_all(current): - huya_task_query = huya_task_query.filter(HuyaAccount.assigned_to == current.id) + huya_task_query = huya_task_query.filter( + HuyaAccount.assigned_to == current.id + ) huya_tasks = _task_summary(huya_task_query, HuyaTask) huya_goods = db.query(func.count(HuyaGoodsSnapshot.id)).scalar() or 0 - huya_recharge_goods = db.query(func.count(HuyaRechargeGoodsSnapshot.id)).scalar() or 0 + huya_recharge_goods = ( + db.query(func.count(HuyaRechargeGoodsSnapshot.id)).scalar() or 0 + ) return { "douyu": { diff --git a/web/backend/routers/login.py b/web/backend/routers/login.py index 9ea855c..3718e0b 100644 --- a/web/backend/routers/login.py +++ b/web/backend/routers/login.py @@ -106,7 +106,11 @@ def list_tasks( current: User = Depends(get_current_user), ): """查看登录任务列表。""" - query = db.query(LoginTask).options(defer(LoginTask.cookie)).join(Account, LoginTask.account_id == Account.id) + query = ( + db.query(LoginTask) + .options(defer(LoginTask.cookie)) + .join(Account, LoginTask.account_id == Account.id) + ) # 客服只能看自己账号的任务 if not user_has_permission(current, "login:view_all"): @@ -130,12 +134,20 @@ def list_tasks( result = [] for t in rows: - result.append(LoginTaskOut( - id=t.id, batch_id=t.batch_id, account_id=t.account_id, - account_username=accounts_map.get(t.account_id, ""), - status=t.status, cookie="", message=t.message or "", - created_by=t.created_by, created_at=t.created_at, finished_at=t.finished_at, - )) + result.append( + LoginTaskOut( + id=t.id, + batch_id=t.batch_id, + account_id=t.account_id, + account_username=accounts_map.get(t.account_id, ""), + status=t.status, + cookie="", + message=t.message or "", + created_by=t.created_by, + created_at=t.created_at, + finished_at=t.finished_at, + ) + ) return result @@ -178,7 +190,11 @@ def delete_tasks( ids = [int(x) for x in task_ids.split(",") if x.strip().isdigit()] if not ids: raise HTTPException(status_code=400, detail="无效的任务ID") - deleted = db.query(LoginTask).filter(LoginTask.id.in_(ids)).delete(synchronize_session=False) + deleted = ( + db.query(LoginTask) + .filter(LoginTask.id.in_(ids)) + .delete(synchronize_session=False) + ) db.commit() return {"message": f"已删除 {deleted} 个任务", "deleted": deleted, "success": True} diff --git a/web/backend/routers/proxy.py b/web/backend/routers/proxy.py index bc28b43..fddf8ff 100644 --- a/web/backend/routers/proxy.py +++ b/web/backend/routers/proxy.py @@ -11,7 +11,9 @@ from ..schemas import ProxyConfigOut, ProxyConfigUpdate, PlatformInfo, PlatformF from ..deps import require_permission, authenticate_websocket from ..services.proxy_service import proxy_service from core.douyu.proxy_platforms import ( - get_platform_names, get_platform_labels, get_credential_fields, + get_platform_names, + get_platform_labels, + get_credential_fields, ) router = APIRouter(prefix="/api/proxy", tags=["代理与白名单"]) @@ -37,7 +39,9 @@ def update_proxy_config( api_url=req.api_url if req.api_url is not None else "", http=req.http if req.http is not None else "", https=req.https if req.https is not None else "", - whitelist_enabled=req.whitelist_enabled if req.whitelist_enabled is not None else False, + whitelist_enabled=req.whitelist_enabled + if req.whitelist_enabled is not None + else False, whitelist_platform=req.whitelist_platform or "xiequ", whitelist_credentials=req.whitelist_credentials, whitelist_uid=req.whitelist_uid, @@ -53,16 +57,19 @@ def list_platforms(): result = [] for name in get_platform_names(): fields = get_credential_fields(name) - result.append(PlatformInfo( - name=name, - label=labels.get(name, name), - credential_fields=[PlatformFieldDef(**f) for f in fields], - )) + result.append( + PlatformInfo( + name=name, + label=labels.get(name, name), + credential_fields=[PlatformFieldDef(**f) for f in fields], + ) + ) return result # ---- WebSocket 日志推送 ---- + @router.websocket("/ws/test/{test_id}") async def ws_test_logs(websocket: WebSocket, test_id: str): """WebSocket 推送代理/白名单测试实时日志(需认证)。""" @@ -100,6 +107,7 @@ async def ws_test_logs(websocket: WebSocket, test_id: str): # ---- 异步测试 API 端点 ---- + @router.post("/test") async def test_proxy( db: Session = Depends(get_db), diff --git a/web/backend/routers/users.py b/web/backend/routers/users.py index 0f54e22..dc37f36 100644 --- a/web/backend/routers/users.py +++ b/web/backend/routers/users.py @@ -25,16 +25,18 @@ def list_users( users = db.query(User).filter(User.deleted_at.is_(None)).order_by(User.id).all() result = [] for u in users: - result.append(UserInfo( - id=u.id, - username=u.username, - role=u.role, - is_active=u.is_active, - remark=u.remark or "", - created_at=u.created_at, - permissions=get_user_permissions(u), - custom_permissions=u.custom_permissions, - )) + result.append( + UserInfo( + id=u.id, + username=u.username, + role=u.role, + is_active=u.is_active, + remark=u.remark or "", + created_at=u.created_at, + permissions=get_user_permissions(u), + custom_permissions=u.custom_permissions, + ) + ) return result @@ -58,13 +60,22 @@ def create_user( db.commit() db.refresh(user) - db.add(AuditLog(user_id=current.id, username=current.username, - action="user:create", target=user.username)) + db.add( + AuditLog( + user_id=current.id, + username=current.username, + action="user:create", + target=user.username, + ) + ) db.commit() return UserInfo( - id=user.id, username=user.username, role=user.role, - is_active=user.is_active, remark=user.remark or "", + id=user.id, + username=user.username, + role=user.role, + is_active=user.is_active, + remark=user.remark or "", permissions=get_user_permissions(user), custom_permissions=user.custom_permissions, ) @@ -97,13 +108,22 @@ def update_user( db.commit() db.refresh(user) - db.add(AuditLog(user_id=current.id, username=current.username, - action="user:edit", target=user.username)) + db.add( + AuditLog( + user_id=current.id, + username=current.username, + action="user:edit", + target=user.username, + ) + ) db.commit() return UserInfo( - id=user.id, username=user.username, role=user.role, - is_active=user.is_active, remark=user.remark or "", + id=user.id, + username=user.username, + role=user.role, + is_active=user.is_active, + remark=user.remark or "", permissions=get_user_permissions(user), custom_permissions=user.custom_permissions, ) @@ -126,21 +146,27 @@ def rename_user( raise HTTPException(status_code=400, detail="用户名至少 2 个字符") if username == user.username: return UserInfo( - id=user.id, username=user.username, role=user.role, - is_active=user.is_active, remark=user.remark or "", - permissions=get_user_permissions(user), custom_permissions=user.custom_permissions, + id=user.id, + username=user.username, + role=user.role, + is_active=user.is_active, + remark=user.remark or "", + permissions=get_user_permissions(user), + custom_permissions=user.custom_permissions, ) if db.query(User.id).filter(User.username == username).first(): raise HTTPException(status_code=400, detail="用户名已存在") old_username = user.username user.username = username - db.add(AuditLog( - user_id=current.id, - username=current.username, - action="user:rename", - target=f"{old_username} -> {username}", - )) + db.add( + AuditLog( + user_id=current.id, + username=current.username, + action="user:rename", + target=f"{old_username} -> {username}", + ) + ) try: db.commit() except IntegrityError as exc: @@ -148,9 +174,13 @@ def rename_user( raise HTTPException(status_code=400, detail="用户名已存在") from exc db.refresh(user) return UserInfo( - id=user.id, username=user.username, role=user.role, - is_active=user.is_active, remark=user.remark or "", - permissions=get_user_permissions(user), custom_permissions=user.custom_permissions, + id=user.id, + username=user.username, + role=user.role, + is_active=user.is_active, + remark=user.remark or "", + permissions=get_user_permissions(user), + custom_permissions=user.custom_permissions, ) @@ -182,8 +212,14 @@ def delete_user( .update({HuyaAccount.assigned_to: None}, synchronize_session=False) ) deleted_username = user.username - db.add(AuditLog(user_id=current.id, username=current.username, - action="user:delete", target=deleted_username)) + db.add( + AuditLog( + user_id=current.id, + username=current.username, + action="user:delete", + target=deleted_username, + ) + ) user.is_active = False user.deleted_at = datetime.now(timezone.utc) user.deleted_username = deleted_username @@ -202,6 +238,7 @@ def delete_user( def list_permissions(_: User = Depends(require_permission("user:assign_permissions"))): """返回所有可用权限点及角色默认权限映射。""" from ..permissions import ROLE_PERMISSIONS + return { "permissions": PERMISSIONS, "role_permissions": ROLE_PERMISSIONS, diff --git a/web/backend/routers/yyb.py b/web/backend/routers/yyb.py index c6e8062..6b489ad 100644 --- a/web/backend/routers/yyb.py +++ b/web/backend/routers/yyb.py @@ -20,7 +20,9 @@ from ..services.audit_service import record_audit router = APIRouter(prefix="/api/yyb", tags=["应用宝充值"]) -def _get_task(db: Session, task_id: int, current: User, write: bool = False) -> YybRechargeTask: +def _get_task( + db: Session, task_id: int, current: User, write: bool = False +) -> YybRechargeTask: """读取任务。 查看:本人或 yyb:history;写操作:本人或 yyb:manage。 @@ -53,14 +55,26 @@ def _creator_username(db: Session, task: YybRechargeTask) -> str: @router.post("/tasks") -def create_task(payload: YybTaskCreateRequest, db: Session = Depends(get_db), current: User = Depends(require_permission("yyb:session"))): +def create_task( + payload: YybTaskCreateRequest, + db: Session = Depends(get_db), + current: User = Depends(require_permission("yyb:session")), +): data = _worker_call(YybWorkerClient().create_job) - task = YybRechargeTask(task_id=uuid.uuid4().hex[:16], created_by=current.id, - worker_job_id=str(data["job_id"]), status=str(data.get("status", "created")), - phase="login", message="请选择登录方式") + task = YybRechargeTask( + task_id=uuid.uuid4().hex[:16], + created_by=current.id, + worker_job_id=str(data["job_id"]), + status=str(data.get("status", "created")), + phase="login", + message="请选择登录方式", + ) db.add(task) record_audit( - db, current, action="recharge:yyb:create", target=f"yyb_task:{task.task_id}", + db, + current, + action="recharge:yyb:create", + target=f"yyb_task:{task.task_id}", detail={"task_id": task.task_id, "status": task.status}, ) db.commit() @@ -69,9 +83,18 @@ def create_task(payload: YybTaskCreateRequest, db: Session = Depends(get_db), cu @router.post("/tasks/{task_id}/login") -def login(task_id: int, payload: YybLoginRequest, db: Session = Depends(get_db), current: User = Depends(require_permission("yyb:session"))): +def login( + task_id: int, + payload: YybLoginRequest, + db: Session = Depends(get_db), + current: User = Depends(require_permission("yyb:session")), +): task = _get_task(db, task_id, current, write=True) - data = _worker_call(lambda: YybWorkerClient().login(task.worker_job_id, payload.provider, payload.timeout)) + data = _worker_call( + lambda: YybWorkerClient().login( + task.worker_job_id, payload.provider, payload.timeout + ) + ) task.provider = payload.provider task.status = str(data.get("status", "waiting_login")) task.phase = "login" @@ -79,31 +102,48 @@ def login(task_id: int, payload: YybLoginRequest, db: Session = Depends(get_db), if data.get("qr_data"): task.login_qr_data = data["qr_data"] record_audit( - db, current, action="recharge:yyb:login", target=f"yyb_task:{task.task_id}", - detail={"task_id": task.task_id, "provider": payload.provider, "status": task.status}, + db, + current, + action="recharge:yyb:login", + target=f"yyb_task:{task.task_id}", + detail={ + "task_id": task.task_id, + "provider": payload.provider, + "status": task.status, + }, ) db.commit() return public_task(task, creator_username=_creator_username(db, task)) @router.get("/tasks/{task_id}") -def get_task(task_id: int, db: Session = Depends(get_db), current: User = Depends(get_current_user)): +def get_task( + task_id: int, + db: Session = Depends(get_db), + current: User = Depends(get_current_user), +): task = _get_task(db, task_id, current) try: sync_task(db, task, YybWorkerClient()) except YybWorkerError: # Worker 暂时重启时仍返回最近一次持久化状态。 pass - return public_task(task, include_qr=user_has_permission(current, "yyb:session"), - include_payment_qr=user_has_permission(current, "yyb:recharge"), - creator_username=_creator_username(db, task)) + return public_task( + task, + include_qr=user_has_permission(current, "yyb:session"), + include_payment_qr=user_has_permission(current, "yyb:recharge"), + creator_username=_creator_username(db, task), + ) @router.get("/tasks") -def list_tasks(scope: str = Query("mine", pattern="^(mine|all)$"), - status: str | None = Query(None, max_length=32), - limit: int = Query(50, ge=1, le=200), - db: Session = Depends(get_db), current: User = Depends(require_permission("yyb:session"))): +def list_tasks( + scope: str = Query("mine", pattern="^(mine|all)$"), + status: str | None = Query(None, max_length=32), + limit: int = Query(50, ge=1, le=200), + db: Session = Depends(get_db), + current: User = Depends(require_permission("yyb:session")), +): if scope == "all" and not user_has_permission(current, "yyb:history"): raise HTTPException(403, "无权查看全部充值任务") query = db.query(YybRechargeTask) @@ -114,36 +154,80 @@ def list_tasks(scope: str = Query("mine", pattern="^(mine|all)$"), rows = query.order_by(YybRechargeTask.id.desc()).limit(limit).all() usernames = { user.id: user.username - for user in db.query(User).filter(User.id.in_({row.created_by for row in rows})).all() + for user in db.query(User) + .filter(User.id.in_({row.created_by for row in rows})) + .all() } - return [public_task(task, include_qr=False, creator_username=usernames.get(task.created_by, "")) - for task in rows] + return [ + public_task( + task, include_qr=False, creator_username=usernames.get(task.created_by, "") + ) + for task in rows + ] @router.get("/tasks/{task_id}/selection-options") -def selection_options(task_id: int, platform: str = Query("android"), points: int | None = Query(None), zone_id: str | None = Query(None), db: Session = Depends(get_db), current: User = Depends(require_permission("yyb:session"))): +def selection_options( + task_id: int, + platform: str = Query("android"), + points: int | None = Query(None), + zone_id: str | None = Query(None), + db: Session = Depends(get_db), + current: User = Depends(require_permission("yyb:session")), +): task = _get_task(db, task_id, current, write=True) - data = _worker_call(lambda: YybWorkerClient().selection_options(task.worker_job_id, platform, points, zone_id)) + data = _worker_call( + lambda: YybWorkerClient().selection_options( + task.worker_job_id, platform, points, zone_id + ) + ) task.platform = platform db.commit() return data @router.post("/tasks/{task_id}/selection") -def selection(task_id: int, payload: YybSelectionRequest, db: Session = Depends(get_db), current: User = Depends(require_permission("yyb:session"))): +def selection( + task_id: int, + payload: YybSelectionRequest, + db: Session = Depends(get_db), + current: User = Depends(require_permission("yyb:session")), +): task = _get_task(db, task_id, current, write=True) - data = _worker_call(lambda: YybWorkerClient().selection(task.worker_job_id, payload.model_dump())) + data = _worker_call( + lambda: YybWorkerClient().selection(task.worker_job_id, payload.model_dump()) + ) selected = data.get("selection", payload.model_dump()) - for field in ("platform", "points", "product_id", "zone_id", "zone_name", "role_id", "role_name"): + for field in ( + "platform", + "points", + "product_id", + "zone_id", + "zone_name", + "role_id", + "role_name", + ): setattr(task, field, selected[field]) task.price_fen = int(selected.get("price_fen") or 0) - task.phase, task.status, task.message = "payment", "ready", "选择已保存,可以生成付款码" + task.phase, task.status, task.message = ( + "payment", + "ready", + "选择已保存,可以生成付款码", + ) record_audit( - db, current, action="recharge:yyb:selection", target=f"yyb_task:{task.task_id}", + db, + current, + action="recharge:yyb:selection", + target=f"yyb_task:{task.task_id}", detail={ - "task_id": task.task_id, "product_id": task.product_id, "points": task.points, - "zone_id": task.zone_id, "zone_name": task.zone_name, "role_id": task.role_id, - "role_name": task.role_name, "price_fen": task.price_fen, + "task_id": task.task_id, + "product_id": task.product_id, + "points": task.points, + "zone_id": task.zone_id, + "zone_name": task.zone_name, + "role_id": task.role_id, + "role_name": task.role_name, + "price_fen": task.price_fen, }, ) db.commit() @@ -151,10 +235,19 @@ def selection(task_id: int, payload: YybSelectionRequest, db: Session = Depends( @router.post("/tasks/{task_id}/payment") -def payment(task_id: int, db: Session = Depends(get_db), current: User = Depends(require_permission("yyb:recharge"))): +def payment( + task_id: int, + db: Session = Depends(get_db), + current: User = Depends(require_permission("yyb:recharge")), +): # 接手他人任务需 yyb:manage;锁行做原子状态迁移,防止并发双击重复下单。 _get_task(db, task_id, current, write=True) - task = db.query(YybRechargeTask).filter(YybRechargeTask.id == task_id).with_for_update().first() + task = ( + db.query(YybRechargeTask) + .filter(YybRechargeTask.id == task_id) + .with_for_update() + .first() + ) if not task: raise HTTPException(404, "充值任务不存在") if task.status != "ready" or task.phase != "payment": @@ -174,8 +267,12 @@ def payment(task_id: int, db: Session = Depends(get_db), current: User = Depends task.message = "生成付款码失败,请稍后重试" task.payment_started_at = None record_audit( - db, current, action="recharge:yyb:payment", target=f"yyb_task:{task.task_id}", - detail="生成付款码失败", success=False, + db, + current, + action="recharge:yyb:payment", + target=f"yyb_task:{task.task_id}", + detail="生成付款码失败", + success=False, ) db.commit() raise @@ -183,16 +280,30 @@ def payment(task_id: int, db: Session = Depends(get_db), current: User = Depends task.phase = str(data.get("phase", "payment")) task.message = str(data.get("message", task.message)) record_audit( - db, current, action="recharge:yyb:payment", target=f"yyb_task:{task.task_id}", - detail={"task_id": task.task_id, "product_id": task.product_id, "points": task.points, - "price_fen": task.price_fen, "status": task.status}, + db, + current, + action="recharge:yyb:payment", + target=f"yyb_task:{task.task_id}", + detail={ + "task_id": task.task_id, + "product_id": task.product_id, + "points": task.points, + "price_fen": task.price_fen, + "status": task.status, + }, ) db.commit() - return public_task(task, include_qr=False, creator_username=_creator_username(db, task)) + return public_task( + task, include_qr=False, creator_username=_creator_username(db, task) + ) @router.post("/tasks/{task_id}/payment/check") -def payment_check(task_id: int, db: Session = Depends(get_db), current: User = Depends(require_permission("yyb:recharge"))): +def payment_check( + task_id: int, + db: Session = Depends(get_db), + current: User = Depends(require_permission("yyb:recharge")), +): task = _get_task(db, task_id, current, write=True) if task.status not in {"waiting_payment", "payment_timeout"}: raise HTTPException(409, "当前任务状态不支持检测到账") @@ -204,16 +315,29 @@ def payment_check(task_id: int, db: Session = Depends(get_db), current: User = D if task.status == "success": task.phase = "completed" record_audit( - db, current, action="recharge:yyb:payment_check", target=f"yyb_task:{task.task_id}", - detail={"task_id": task.task_id, "status": task.status, "message": task.message}, + db, + current, + action="recharge:yyb:payment_check", + target=f"yyb_task:{task.task_id}", + detail={ + "task_id": task.task_id, + "status": task.status, + "message": task.message, + }, success=task.status != "failed", ) db.commit() - return public_task(task, include_qr=False, creator_username=_creator_username(db, task)) + return public_task( + task, include_qr=False, creator_username=_creator_username(db, task) + ) @router.post("/tasks/{task_id}/stop") -def stop(task_id: int, db: Session = Depends(get_db), current: User = Depends(require_permission("yyb:session"))): +def stop( + task_id: int, + db: Session = Depends(get_db), + current: User = Depends(require_permission("yyb:session")), +): task = _get_task(db, task_id, current, write=True) data = _worker_call(lambda: YybWorkerClient().stop(task.worker_job_id)) task.status = "stopped" @@ -221,7 +345,10 @@ def stop(task_id: int, db: Session = Depends(get_db), current: User = Depends(re task.message = str(data.get("message", "任务已停止")) task.finished_at = _utcnow() record_audit( - db, current, action="recharge:yyb:stop", target=f"yyb_task:{task.task_id}", + db, + current, + action="recharge:yyb:stop", + target=f"yyb_task:{task.task_id}", detail={"task_id": task.task_id, "status": task.status}, ) db.commit() diff --git a/web/backend/services/account_service.py b/web/backend/services/account_service.py index 3079b0b..ca1d3b4 100644 --- a/web/backend/services/account_service.py +++ b/web/backend/services/account_service.py @@ -8,27 +8,31 @@ from sqlalchemy.orm import Session from ..models import Account, LoginTask -EMAIL_PATTERN = re.compile(r'^[^\s@|]+@[^\s@|]+\.[^\s@|]+$') +EMAIL_PATTERN = re.compile(r"^[^\s@|]+@[^\s@|]+\.[^\s@|]+$") def split_account_line(line: str) -> list[str]: """拆分一行账号文本,支持 |、tab、逗号、空格分隔。""" - if '|' in line: - return line.split('|') - if '\t' in line: - return line.split('\t') - if ',' in line: + if "|" in line: + return line.split("|") + if "\t" in line: + return line.split("\t") + if "," in line: return next(csv.reader([line])) return line.split() def cookie_account_ids_query(db: Session): """返回有成功登录记录(cookie非空)的账号ID子查询。""" - return db.query(LoginTask.account_id).filter( - LoginTask.status == 'success', - LoginTask.cookie != '', - LoginTask.cookie.isnot(None), - ).distinct() + return ( + db.query(LoginTask.account_id) + .filter( + LoginTask.status == "success", + LoginTask.cookie != "", + LoginTask.cookie.isnot(None), + ) + .distinct() + ) def parse_and_build_accounts( @@ -61,9 +65,9 @@ def parse_and_build_accounts( skipped = 0 duplicated = 0 seen_in_batch: set[str] = set() - for line in text.strip().split('\n'): + for line in text.strip().split("\n"): line = line.strip() - if not line or line.startswith('#'): + if not line or line.startswith("#"): continue parts = split_account_line(line) if len(parts) < 4: @@ -88,14 +92,16 @@ def parse_and_build_accounts( seen_in_batch.add(username_key) email_cfg = get_email_config_for_account(email) - accounts.append(Account( - username=username, - password=password, - email=email, - email_password=email_password, - email_imap_server=email_cfg['server'], - email_imap_port=email_cfg.get('port', 993), - email_imap_ssl=email_cfg.get('ssl', True), - tag=tag, - )) + accounts.append( + Account( + username=username, + password=password, + email=email, + email_password=email_password, + email_imap_server=email_cfg["server"], + email_imap_port=email_cfg.get("port", 993), + email_imap_ssl=email_cfg.get("ssl", True), + tag=tag, + ) + ) return accounts, skipped, duplicated diff --git a/web/backend/services/audit_service.py b/web/backend/services/audit_service.py index 936d43e..c2dc8eb 100644 --- a/web/backend/services/audit_service.py +++ b/web/backend/services/audit_service.py @@ -12,8 +12,17 @@ from ..models import AuditLog, User _SENSITIVE_KEY_PARTS = ( - "cookie", "token", "password", "passwd", "secret", "signature", "sign", - "qr_data", "authorization", "credential", "private_key", + "cookie", + "token", + "password", + "passwd", + "secret", + "signature", + "sign", + "qr_data", + "authorization", + "credential", + "private_key", ) @@ -42,7 +51,9 @@ def record_audit( ) -> AuditLog: """加入一条审计记录;调用方负责与业务变更一起提交事务。""" if isinstance(detail, Mapping): - detail_text = json.dumps(_safe_value(detail), ensure_ascii=False, separators=(",", ":")) + detail_text = json.dumps( + _safe_value(detail), ensure_ascii=False, separators=(",", ":") + ) elif detail is None: detail_text = "" else: diff --git a/web/backend/services/huya_register_runner.py b/web/backend/services/huya_register_runner.py index a1696fd..3e1c9d2 100644 --- a/web/backend/services/huya_register_runner.py +++ b/web/backend/services/huya_register_runner.py @@ -200,7 +200,9 @@ def snapshot_from_batch(batch: HuyaRegisterBatch) -> dict: } -def load_batch_snapshot(batch_id: str, *, recover_interrupted: bool = True) -> dict | None: +def load_batch_snapshot( + batch_id: str, *, recover_interrupted: bool = True +) -> dict | None: """从数据库加载批次详情;若服务中断则标记为 interrupted。""" db = SessionLocal() try: @@ -246,7 +248,9 @@ def load_batch_snapshot(batch_id: str, *, recover_interrupted: bool = True) -> d db.close() -def list_batch_summaries(limit: int = 50, live_batch_ids: set[str] | None = None) -> list[dict]: +def list_batch_summaries( + limit: int = 50, live_batch_ids: set[str] | None = None +) -> list[dict]: """列出最近的注册批次摘要。live_batch_ids 中的 running 保持运行中。""" live = live_batch_ids or set() db = SessionLocal() @@ -269,42 +273,51 @@ def list_batch_summaries(limit: int = 50, live_batch_ids: set[str] | None = None if status == "running": running_count = max( 0, - int(row.total or 0) - int(row.success_count or 0) - int(row.failed_count or 0) - int(row.stopped_count or 0), + int(row.total or 0) + - int(row.success_count or 0) + - int(row.failed_count or 0) + - int(row.stopped_count or 0), ) - result.append({ - "batch_id": row.batch_id, - "status": status, - "message": message, - "tag": row.tag or "", - "created_by": row.created_by, - "concurrency": int(row.concurrency or 1), - "wait_seconds": float(row.wait_seconds or 180), - "poll_interval": float(row.poll_interval or 5), - "password_prefix": row.password_prefix or "hy", - "use_proxy": bool(row.use_proxy), - "total": int(row.total or 0), - "success_count": int(row.success_count or 0), - "failed_count": int(row.failed_count or 0), - "stopped_count": int(row.stopped_count or 0), - "running_count": running_count, - "created_at": row.created_at, - "started_at": row.started_at, - "finished_at": row.finished_at, - "items": [], - }) + result.append( + { + "batch_id": row.batch_id, + "status": status, + "message": message, + "tag": row.tag or "", + "created_by": row.created_by, + "concurrency": int(row.concurrency or 1), + "wait_seconds": float(row.wait_seconds or 180), + "poll_interval": float(row.poll_interval or 5), + "password_prefix": row.password_prefix or "hy", + "use_proxy": bool(row.use_proxy), + "total": int(row.total or 0), + "success_count": int(row.success_count or 0), + "failed_count": int(row.failed_count or 0), + "stopped_count": int(row.stopped_count or 0), + "running_count": running_count, + "created_at": row.created_at, + "started_at": row.started_at, + "finished_at": row.finished_at, + "items": [], + } + ) return result finally: db.close() -def _refresh_batch_counts(batch_row: HuyaRegisterBatchModel, items: list[HuyaRegisterItemModel]): +def _refresh_batch_counts( + batch_row: HuyaRegisterBatchModel, items: list[HuyaRegisterItemModel] +): batch_row.total = len(items) batch_row.success_count = sum(1 for item in items if item.status == "success") batch_row.failed_count = sum(1 for item in items if item.status == "error") batch_row.stopped_count = sum(1 for item in items if item.status == "stopped") -def format_success_export_line(username: str, uid: str, password: str, phone: str, sms_url: str) -> str: +def format_success_export_line( + username: str, uid: str, password: str, phone: str, sms_url: str +) -> str: """统一成功导出格式。""" account = (username or uid or "").strip() return f"{account}----{password or ''}----{phone or ''}----{sms_url or ''}" @@ -319,7 +332,9 @@ def export_success_logs_text( """从成功流水表导出 txt。""" db = SessionLocal() try: - query = db.query(HuyaRegisterSuccessLog).order_by(HuyaRegisterSuccessLog.id.asc()) + query = db.query(HuyaRegisterSuccessLog).order_by( + HuyaRegisterSuccessLog.id.asc() + ) if batch_id: query = query.filter(HuyaRegisterSuccessLog.batch_id == batch_id) if tag: @@ -350,7 +365,9 @@ def list_success_logs( """列出成功流水(含密码,供管理端展示/导出)。""" db = SessionLocal() try: - query = db.query(HuyaRegisterSuccessLog).order_by(HuyaRegisterSuccessLog.id.desc()) + query = db.query(HuyaRegisterSuccessLog).order_by( + HuyaRegisterSuccessLog.id.desc() + ) if batch_id: query = query.filter(HuyaRegisterSuccessLog.batch_id == batch_id) if tag: @@ -406,7 +423,11 @@ class HuyaRegisterRunner: def _create_proxy_fetcher(self) -> ProxyFetcher | None: """按需创建 API 代理获取器。""" - if not self.batch.use_proxy or not self.proxy_config or not self.proxy_config.enabled: + if ( + not self.batch.use_proxy + or not self.proxy_config + or not self.proxy_config.enabled + ): return None if not self.proxy_config.api_url: return None @@ -414,9 +435,15 @@ class HuyaRegisterRunner: wl_platform = "xiequ" wl_credentials = None if self.proxy_config.whitelist_enabled: - wl_platform = getattr(self.proxy_config, "whitelist_platform", None) or "xiequ" + wl_platform = ( + getattr(self.proxy_config, "whitelist_platform", None) or "xiequ" + ) wl_credentials = getattr(self.proxy_config, "whitelist_credentials", None) - if not wl_credentials and self.proxy_config.whitelist_uid and self.proxy_config.whitelist_ukey: + if ( + not wl_credentials + and self.proxy_config.whitelist_uid + and self.proxy_config.whitelist_ukey + ): wl_credentials = { "uid": self.proxy_config.whitelist_uid, "ukey": self.proxy_config.whitelist_ukey, @@ -446,7 +473,11 @@ class HuyaRegisterRunner: return db = SessionLocal() try: - row = db.query(HuyaRegisterBatchModel).filter(HuyaRegisterBatchModel.id == self.batch.db_id).first() + row = ( + db.query(HuyaRegisterBatchModel) + .filter(HuyaRegisterBatchModel.id == self.batch.db_id) + .first() + ) if not row: return row.status = self.batch.status @@ -485,7 +516,11 @@ class HuyaRegisterRunner: return db = SessionLocal() try: - row = db.query(HuyaRegisterItemModel).filter(HuyaRegisterItemModel.id == item.db_id).first() + row = ( + db.query(HuyaRegisterItemModel) + .filter(HuyaRegisterItemModel.id == item.db_id) + .first() + ) if not row: return row.status = item.status @@ -518,12 +553,16 @@ class HuyaRegisterRunner: setattr(item, key, value) self._persist_item(index) - def _save_success(self, index: int, result: HuyaAutoRegisterResult) -> tuple[int | None, str, str]: + def _save_success( + self, index: int, result: HuyaAutoRegisterResult + ) -> tuple[int | None, str, str]: """成功时:写账号 + 成功流水(成功一个写一条,立即可导出)。""" db = SessionLocal() try: # upsert_huya_cookie 内部会 commit 一次 - account = upsert_huya_cookie(db, result.cookie, tag=self.batch.tag, username_hint="") + account = upsert_huya_cookie( + db, result.cookie, tag=self.batch.tag, username_hint="" + ) account.game_phone = result.phone or account.game_phone or "" if result.username: account.username = result.username @@ -537,23 +576,29 @@ class HuyaRegisterRunner: account.updated_at = _now() item = self.batch.items[index] - db.add(HuyaRegisterSuccessLog( - batch_id=self.batch.batch_id, - item_id=item.db_id, - account_id=account.id, - phone=result.phone or item.phone, - username=result.username or account.username or "", - uid=result.uid or account.uid or account.yyuid or "", - password=result.password or "", - sms_url=sms_url, - tag=self.batch.tag, - provider=result.provider or item.provider, - created_by=self.batch.created_by, - created_at=_now(), - )) + db.add( + HuyaRegisterSuccessLog( + batch_id=self.batch.batch_id, + item_id=item.db_id, + account_id=account.id, + phone=result.phone or item.phone, + username=result.username or account.username or "", + uid=result.uid or account.uid or account.yyuid or "", + password=result.password or "", + sms_url=sms_url, + tag=self.batch.tag, + provider=result.provider or item.provider, + created_by=self.batch.created_by, + created_at=_now(), + ) + ) db.commit() db.refresh(account) - return account.id, account.username or "", account.uid or account.yyuid or "" + return ( + account.id, + account.username or "", + account.uid or account.yyuid or "", + ) finally: db.close() @@ -578,12 +623,16 @@ class HuyaRegisterRunner: def _run_one(self, index: int, item: SmsLine): if self._stop.is_set(): - self._set_item(index, status="stopped", message="已停止", finished_at=_now()) + self._set_item( + index, status="stopped", message="已停止", finished_at=_now() + ) return proxies, proxy_error = self._resolve_proxy() if proxy_error: - self._set_item(index, status="error", message=proxy_error, finished_at=_now()) + self._set_item( + index, status="error", message=proxy_error, finished_at=_now() + ) return self._set_item( @@ -714,7 +763,12 @@ class HuyaRegisterRunner: futures = [] for index in indices: if self._stop.is_set(): - self._set_item(index, status="stopped", message="已停止", finished_at=_now()) + self._set_item( + index, + status="stopped", + message="已停止", + finished_at=_now(), + ) continue item = self.sms_lines[index] futures.append(executor.submit(self._run_one, index, item)) @@ -841,7 +895,11 @@ class HuyaRegisterRegistry: # DB 同步为 running,避免返回 pending 导致前端误判 db = SessionLocal() try: - row = db.query(HuyaRegisterBatchModel).filter(HuyaRegisterBatchModel.id == db_id).first() + row = ( + db.query(HuyaRegisterBatchModel) + .filter(HuyaRegisterBatchModel.id == db_id) + .first() + ) if row: row.status = "running" row.message = "批次运行中" @@ -850,7 +908,9 @@ class HuyaRegisterRegistry: finally: db.close() - runner = HuyaRegisterRunner(batch=batch, sms_lines=sms_lines, proxy_config=proxy_config) + runner = HuyaRegisterRunner( + batch=batch, sms_lines=sms_lines, proxy_config=proxy_config + ) with self._lock: self._runners[batch_id] = runner return runner @@ -930,7 +990,9 @@ class HuyaRegisterRegistry: if poll_interval is not None: batch_row.poll_interval = int(max(1.0, float(poll_interval))) if password_prefix is not None: - batch_row.password_prefix = (password_prefix or "hy").strip()[:8] or "hy" + batch_row.password_prefix = (password_prefix or "hy").strip()[ + :8 + ] or "hy" if fixed_password is not None: batch_row.fixed_password = (fixed_password or "").strip() if use_proxy is not None: @@ -965,7 +1027,12 @@ class HuyaRegisterRegistry: batch = _batch_from_db(batch_row, item_rows) sms_lines = [ - SmsLine(phone=item.phone, url=item.sms_url, provider=item.provider, raw=f"{item.phone}----{item.sms_url}") + SmsLine( + phone=item.phone, + url=item.sms_url, + provider=item.provider, + raw=f"{item.phone}----{item.sms_url}", + ) for item in batch.items ] finally: diff --git a/web/backend/services/huya_runner.py b/web/backend/services/huya_runner.py index 559fc85..7dbb426 100644 --- a/web/backend/services/huya_runner.py +++ b/web/backend/services/huya_runner.py @@ -19,15 +19,24 @@ from .huya_runner_recharge import RechargeMixin class HuyaBatchRunner( - HuyaBatchRunnerCore, BindMixin, GoodsMixin, RechargeMixin, + HuyaBatchRunnerCore, + BindMixin, + GoodsMixin, + RechargeMixin, ): """批量执行虎牙任务(功能域 Mixin 聚合 + 批次调度)。""" - def _execute_one(self, task_id: int, account_info: dict, config_info: dict, total: int): + def _execute_one( + self, task_id: int, account_info: dict, config_info: dict, total: int + ): worker_db = SessionLocal() try: task = worker_db.query(HuyaTask).filter(HuyaTask.id == task_id).first() - account = worker_db.query(HuyaAccount).filter(HuyaAccount.id == account_info["account_id"]).first() + account = ( + worker_db.query(HuyaAccount) + .filter(HuyaAccount.id == account_info["account_id"]) + .first() + ) if not task or not account: return @@ -59,28 +68,48 @@ class HuyaBatchRunner( "create_recharge_order", }: self._mark_task(worker_db, task, "failed", "该虎牙任务执行器暂未实现") - self._push_log("warning", f"[{current}] {name} 暂未实现: {self.task_type}") + self._push_log( + "warning", f"[{current}] {name} 暂未实现: {self.task_type}" + ) return try: if self.task_type == "query_points": - self._execute_query_points(worker_db, task, account, account_info, config_info) + self._execute_query_points( + worker_db, task, account, account_info, config_info + ) elif self.task_type == "refresh_goods": - self._execute_refresh_goods(worker_db, task, account, account_info, config_info) + self._execute_refresh_goods( + worker_db, task, account, account_info, config_info + ) elif self.task_type == "refresh_recharge_goods": - self._execute_refresh_recharge_goods(worker_db, task, account, account_info, config_info) + self._execute_refresh_recharge_goods( + worker_db, task, account, account_info, config_info + ) elif self.task_type == "exchange_goods": - self._execute_exchange_goods(worker_db, task, account, account_info, config_info) + self._execute_exchange_goods( + worker_db, task, account, account_info, config_info + ) elif self.task_type == "create_recharge_order": - self._execute_create_recharge_order(worker_db, task, account, account_info, config_info) + self._execute_create_recharge_order( + worker_db, task, account, account_info, config_info + ) elif self.task_type == "get_bind_qr": - self._execute_get_bind_qr(worker_db, task, account, account_info, config_info) + self._execute_get_bind_qr( + worker_db, task, account, account_info, config_info + ) elif self.task_type == "confirm_bind": - self._execute_confirm_bind(worker_db, task, account, account_info, config_info) + self._execute_confirm_bind( + worker_db, task, account, account_info, config_info + ) elif self.task_type == "query_game_name": - self._execute_query_game_name(worker_db, task, account, account_info, config_info) + self._execute_query_game_name( + worker_db, task, account, account_info, config_info + ) elif self.task_type == "query_exchange_records": - self._execute_query_exchange_records(worker_db, task, account, account_info, config_info) + self._execute_query_exchange_records( + worker_db, task, account, account_info, config_info + ) worker_db.refresh(task) if task.status == "success": self._push_log("success", f"[{current}] {name} {task.message}") @@ -124,17 +153,19 @@ class HuyaBatchRunner( task.status = "pending" task.message = "等待执行" task.finished_at = None - task_infos.append({ - "task_id": task.id, - "account_info": { - "account_id": account.id, - "uid": account.uid or "", - "yyuid": account.yyuid or "", - "username": account.username or "", - "nickname": account.nickname or "", - "cookie": normalize_huya_cookie(account.cookie or ""), - }, - }) + task_infos.append( + { + "task_id": task.id, + "account_info": { + "account_id": account.id, + "uid": account.uid or "", + "yyuid": account.yyuid or "", + "username": account.username or "", + "nickname": account.nickname or "", + "cookie": normalize_huya_cookie(account.cookie or ""), + }, + } + ) self.db.commit() total = len(task_infos) @@ -149,13 +180,15 @@ class HuyaBatchRunner( if self._stop.is_set(): self._push_log("warning", "任务已停止,跳过剩余账号") break - futures.append(executor.submit( - self._execute_one, - item["task_id"], - item["account_info"], - config_info, - total, - )) + futures.append( + executor.submit( + self._execute_one, + item["task_id"], + item["account_info"], + config_info, + total, + ) + ) for future in as_completed(futures): try: diff --git a/web/backend/services/huya_runner_core.py b/web/backend/services/huya_runner_core.py index f720c01..ce81cb7 100644 --- a/web/backend/services/huya_runner_core.py +++ b/web/backend/services/huya_runner_core.py @@ -20,8 +20,10 @@ from typing import TYPE_CHECKING if TYPE_CHECKING: from .huya_runner import HuyaBatchRunner + class HuyaBatchRunnerCore: """虎牙任务执行器公共基础:批次状态、日志、任务落库。""" + def __init__( self, db: Session, @@ -156,7 +158,8 @@ class HuyaBatchRegistry: expired = [ batch_id for batch_id, batch in self._batches.items() - if batch.get("finished") and now - float(batch.get("finished_at") or now) > ttl_seconds + if batch.get("finished") + and now - float(batch.get("finished_at") or now) > ttl_seconds ] for batch_id in expired: self._batches.pop(batch_id, None) @@ -206,4 +209,3 @@ class HuyaBatchRegistry: huya_batch_registry = HuyaBatchRegistry() - diff --git a/web/backend/services/huya_service.py b/web/backend/services/huya_service.py index 512c91f..4cbce3d 100644 --- a/web/backend/services/huya_service.py +++ b/web/backend/services/huya_service.py @@ -37,7 +37,10 @@ def apply_huya_config_defaults(config: HuyaConfig) -> bool: """补齐虎牙配置默认值,返回是否发生变更。""" changed = False for field in HUYA_CONFIG_FIELDS: - if field == "bind_act_id" and str(getattr(config, field, "") or "").strip() == "17096": + if ( + field == "bind_act_id" + and str(getattr(config, field, "") or "").strip() == "17096" + ): setattr(config, field, HUYA_CONFIG_DEFAULTS[field]) changed = True continue @@ -160,7 +163,9 @@ def split_huya_password_line(line: str) -> HuyaPasswordLine | None: ) -def import_huya_password_accounts(db: Session, text: str, tag: str = "") -> tuple[int, int]: +def import_huya_password_accounts( + db: Session, text: str, tag: str = "" +) -> tuple[int, int]: """导入虎牙账号密码,返回 (导入/更新数, 跳过数)。""" created_or_updated = 0 skipped = 0 @@ -207,13 +212,17 @@ def import_huya_password_accounts(db: Session, text: str, tag: str = "") -> tupl return created_or_updated, skipped -def _upsert_huya_account(db: Session, parsed: dict, tag: str = "", status: str | None = None) -> HuyaAccount: +def _upsert_huya_account( + db: Session, parsed: dict, tag: str = "", status: str | None = None +) -> HuyaAccount: """按 uid/yyuid 新增或更新虎牙账号。""" account = None if parsed["uid"]: account = db.query(HuyaAccount).filter(HuyaAccount.uid == parsed["uid"]).first() if account is None and parsed["yyuid"]: - account = db.query(HuyaAccount).filter(HuyaAccount.yyuid == parsed["yyuid"]).first() + account = ( + db.query(HuyaAccount).filter(HuyaAccount.yyuid == parsed["yyuid"]).first() + ) if account is None: account = HuyaAccount( @@ -273,13 +282,17 @@ def save_huya_login_cookie_to_account( return account -def upsert_huya_cookie(db: Session, cookie: str, tag: str = "", username_hint: str = "") -> HuyaAccount: +def upsert_huya_cookie( + db: Session, cookie: str, tag: str = "", username_hint: str = "" +) -> HuyaAccount: """保存单条登录得到的虎牙 Cookie。""" line = f"{username_hint}----{cookie}" if username_hint else cookie parsed = parse_huya_cookie_line(line) if not parsed: raise ValueError("登录成功但 Cookie 中没有识别到虎牙 uid") - account = _upsert_huya_account(db, parsed, tag=(tag or "").strip(), status="login_success") + account = _upsert_huya_account( + db, parsed, tag=(tag or "").strip(), status="login_success" + ) db.commit() db.refresh(account) return account @@ -368,18 +381,24 @@ def create_planned_tasks( batch_id = uuid.uuid4().hex[:12] payload = payload or {} accounts = db.query(HuyaAccount).filter(HuyaAccount.id.in_(account_ids)).all() - if task_type in {"refresh_goods", "refresh_recharge_goods", "create_recharge_order"} and accounts: + if ( + task_type + in {"refresh_goods", "refresh_recharge_goods", "create_recharge_order"} + and accounts + ): # 全局快照和单笔支付二维码使用一个选中的 CK 即可;兑换商品需要保留多账号批量任务。 accounts = accounts[:1] for account in accounts: - db.add(HuyaTask( - batch_id=batch_id, - account_id=account.id, - task_type=task_type, - status="planned", - message="任务已创建,等待执行", - result={"payload": payload} if payload else None, - created_by=created_by, - )) + db.add( + HuyaTask( + batch_id=batch_id, + account_id=account.id, + task_type=task_type, + status="planned", + message="任务已创建,等待执行", + result={"payload": payload} if payload else None, + created_by=created_by, + ) + ) db.commit() return batch_id, len(accounts) diff --git a/web/backend/services/proxy_service.py b/web/backend/services/proxy_service.py index 4e40d4a..e45e43e 100644 --- a/web/backend/services/proxy_service.py +++ b/web/backend/services/proxy_service.py @@ -8,7 +8,11 @@ from typing import Optional from sqlalchemy.orm import Session -from core.douyu.proxy import resolve_working_proxy, verify_proxy_url, parse_proxy_response +from core.douyu.proxy import ( + resolve_working_proxy, + verify_proxy_url, + parse_proxy_response, +) from core.douyu.proxy_platforms import create_adapter, get_platform_labels from core.douyu.proxy_platforms.base import _get_local_exit_ip from ..models import ProxyConfig as ProxyConfigModel, AuditLog @@ -20,8 +24,8 @@ def _build_whitelist_params(cfg: ProxyConfigModel) -> dict: Returns: {"whitelist_platform": str, "whitelist_credentials": dict|None} """ - platform = getattr(cfg, 'whitelist_platform', None) or "xiequ" - credentials = getattr(cfg, 'whitelist_credentials', None) + platform = getattr(cfg, "whitelist_platform", None) or "xiequ" + credentials = getattr(cfg, "whitelist_credentials", None) # 向后兼容:旧字段有值但新字段为空时,自动迁移 if not credentials and cfg.whitelist_uid and cfg.whitelist_ukey: @@ -57,7 +61,10 @@ class ProxyService: # 应用层自动迁移:旧字段有值但新字段为空时,填充新字段 if cfg.whitelist_uid and cfg.whitelist_ukey and not cfg.whitelist_credentials: cfg.whitelist_platform = "xiequ" - cfg.whitelist_credentials = {"uid": cfg.whitelist_uid, "ukey": cfg.whitelist_ukey} + cfg.whitelist_credentials = { + "uid": cfg.whitelist_uid, + "ukey": cfg.whitelist_ukey, + } db.commit() return cfg @@ -65,7 +72,10 @@ class ProxyService: @staticmethod def update_config( db: Session, - enabled, api_url, http, https, + enabled, + api_url, + http, + https, whitelist_enabled, whitelist_platform="xiequ", whitelist_credentials=None, @@ -96,12 +106,14 @@ class ProxyService: db.refresh(cfg) if current_user: - db.add(AuditLog( - user_id=current_user.id, - username=current_user.username, - action="proxy:update", - target="proxy_config", - )) + db.add( + AuditLog( + user_id=current_user.id, + username=current_user.username, + action="proxy:update", + target="proxy_config", + ) + ) db.commit() return cfg @@ -258,18 +270,21 @@ class ProxyService: # 3. 检查并同步白名单 records = adapter.get_whitelist() - in_list = any(r.get('ip') == local_ip for r in records) + in_list = any(r.get("ip") == local_ip for r in records) if records: push("info", f"白名单共 {len(records)} 条记录") if in_list: - record = next((r for r in records if r.get('ip') == local_ip), {}) - memo = record.get('memo', '') + record = next((r for r in records if r.get("ip") == local_ip), {}) + memo = record.get("memo", "") if memo == adapter.memo: push("success", f"本机IP {local_ip} 已在白名单中 (备注正确)") else: - push("warning", f'本机IP {local_ip} 备注不匹配 (当前: "{memo}"),更新中...') + push( + "warning", + f'本机IP {local_ip} 备注不匹配 (当前: "{memo}"),更新中...', + ) sync_ok, sync_msg = adapter.sync_ip(local_ip) push("success" if sync_ok else "error", f"白名单更新: {sync_msg}") else: diff --git a/web/backend/services/yyb_service.py b/web/backend/services/yyb_service.py index a53d23a..f71bb18 100644 --- a/web/backend/services/yyb_service.py +++ b/web/backend/services/yyb_service.py @@ -32,9 +32,22 @@ def _as_utc(value: datetime | None) -> datetime | None: def cleanup_orphan_yyb_tasks(db: Session, message: str) -> int: - rows = db.query(YybRechargeTask).filter( - YybRechargeTask.status.in_(["created", "waiting_login", "ready", "running", "ordering", "waiting_payment"]) - ).all() + rows = ( + db.query(YybRechargeTask) + .filter( + YybRechargeTask.status.in_( + [ + "created", + "waiting_login", + "ready", + "running", + "ordering", + "waiting_payment", + ] + ) + ) + .all() + ) for task in rows: if task.status == "ordering": task.status = "waiting_payment" @@ -50,7 +63,9 @@ def cleanup_orphan_yyb_tasks(db: Session, message: str) -> int: return len(rows) -def sync_task(db: Session, task: YybRechargeTask, worker: YybWorkerClient) -> YybRechargeTask: +def sync_task( + db: Session, task: YybRechargeTask, worker: YybWorkerClient +) -> YybRechargeTask: data = worker.get_job(task.worker_job_id) task.status = str(data.get("status", task.status)) task.phase = str(data.get("phase", task.phase)) @@ -59,10 +74,16 @@ def sync_task(db: Session, task: YybRechargeTask, worker: YybWorkerClient) -> Yy task.provider = str(data["provider"]) if data.get("qr_data"): task.login_qr_data = str(data["qr_data"]) - task.result = {**(task.result or {}), "login_qr_mime_type": data.get("qr_mime_type", "image/jpeg")} + task.result = { + **(task.result or {}), + "login_qr_mime_type": data.get("qr_mime_type", "image/jpeg"), + } if data.get("payment_qr_data"): task.payment_qr_data = str(data["payment_qr_data"]) - task.result = {**(task.result or {}), "payment_qr_mime_type": data.get("payment_qr_mime_type", "image/png")} + task.result = { + **(task.result or {}), + "payment_qr_mime_type": data.get("payment_qr_mime_type", "image/png"), + } task.result = { **(task.result or {}), "logs": data.get("logs", []), @@ -76,32 +97,53 @@ def sync_task(db: Session, task: YybRechargeTask, worker: YybWorkerClient) -> Yy task.payment_last_checked_at = last_checked_at if task.status in {"success", "failed"} and task.finished_at is None: task.finished_at = _utcnow() - if task.status not in {"success", "failed", "stopped"} and task.finished_at is not None: + if ( + task.status not in {"success", "failed", "stopped"} + and task.finished_at is not None + ): task.finished_at = None db.commit() db.refresh(task) return task -def public_task(task: YybRechargeTask, include_qr: bool = True, - include_payment_qr: bool | None = None, - creator_username: str = "") -> dict[str, Any]: +def public_task( + task: YybRechargeTask, + include_qr: bool = True, + include_payment_qr: bool | None = None, + creator_username: str = "", +) -> dict[str, Any]: result: dict[str, Any] = { - "id": task.id, "task_id": task.task_id, - "provider": task.provider, "platform": task.platform, "points": task.points, + "id": task.id, + "task_id": task.task_id, + "provider": task.provider, + "platform": task.platform, + "points": task.points, "price_fen": task.price_fen, - "product_id": task.product_id, "zone_id": task.zone_id, "zone_name": task.zone_name, - "role_id": task.role_id, "role_name": task.role_name, "status": task.status, - "phase": task.phase, "message": task.message, "result": task.result, - "created_by": task.created_by, "created_by_username": creator_username, - "created_at": _as_utc(task.created_at), "finished_at": _as_utc(task.finished_at), + "product_id": task.product_id, + "zone_id": task.zone_id, + "zone_name": task.zone_name, + "role_id": task.role_id, + "role_name": task.role_name, + "status": task.status, + "phase": task.phase, + "message": task.message, + "result": task.result, + "created_by": task.created_by, + "created_by_username": creator_username, + "created_at": _as_utc(task.created_at), + "finished_at": _as_utc(task.finished_at), "payment_started_at": _as_utc(task.payment_started_at), "payment_qr_created_at": _as_utc(task.payment_qr_created_at), "payment_last_checked_at": _as_utc(task.payment_last_checked_at), } if task.result: - result["login_qr_mime_type"] = task.result.get("login_qr_mime_type", "image/jpeg") - result["payment_qr_mime_type"] = task.result.get("payment_qr_mime_type", "image/png") + result["login_qr_mime_type"] = task.result.get( + "login_qr_mime_type", "image/jpeg" + ) + result["payment_qr_mime_type"] = task.result.get( + "payment_qr_mime_type", "image/png" + ) if include_payment_qr is None: include_payment_qr = include_qr if include_qr: diff --git a/web/backend/services/yyb_worker_client.py b/web/backend/services/yyb_worker_client.py index cc9f7fc..1e6d217 100644 --- a/web/backend/services/yyb_worker_client.py +++ b/web/backend/services/yyb_worker_client.py @@ -18,13 +18,20 @@ class YybWorkerClient: self.key = os.getenv("YYB_WORKER_KEY", "") self.timeout = float(os.getenv("YYB_WORKER_TIMEOUT", "30")) - def _request(self, method: str, path: str, payload: dict[str, Any] | None = None) -> dict[str, Any]: + def _request( + self, method: str, path: str, payload: dict[str, Any] | None = None + ) -> dict[str, Any]: headers = {"Accept": "application/json"} if self.key: headers["Authorization"] = f"Bearer {self.key}" try: - response = requests.request(method, self.base_url + path, json=payload, - headers=headers, timeout=self.timeout) + response = requests.request( + method, + self.base_url + path, + json=payload, + headers=headers, + timeout=self.timeout, + ) data = response.json() except (requests.RequestException, ValueError) as exc: raise YybWorkerError(f"应用宝 Worker 不可用: {exc}") from exc @@ -35,19 +42,34 @@ class YybWorkerClient: def create_job(self) -> dict[str, Any]: return self._request("POST", "/v1/jobs") - def login(self, worker_job_id: str, provider: str, timeout: int = 600) -> dict[str, Any]: - return self._request("POST", f"/v1/jobs/{worker_job_id}/login", - {"provider": provider, "timeout": timeout}) + def login( + self, worker_job_id: str, provider: str, timeout: int = 600 + ) -> dict[str, Any]: + return self._request( + "POST", + f"/v1/jobs/{worker_job_id}/login", + {"provider": provider, "timeout": timeout}, + ) def get_job(self, worker_job_id: str) -> dict[str, Any]: return self._request("GET", f"/v1/jobs/{worker_job_id}") - def selection_options(self, worker_job_id: str, platform: str, - points: int | None = None, zone_id: str | None = None) -> dict[str, Any]: - return self._request("POST", f"/v1/jobs/{worker_job_id}/selection-options", - {"platform": platform, "points": points, "zone_id": zone_id}) + def selection_options( + self, + worker_job_id: str, + platform: str, + points: int | None = None, + zone_id: str | None = None, + ) -> dict[str, Any]: + return self._request( + "POST", + f"/v1/jobs/{worker_job_id}/selection-options", + {"platform": platform, "points": points, "zone_id": zone_id}, + ) - def selection(self, worker_job_id: str, selection: dict[str, Any]) -> dict[str, Any]: + def selection( + self, worker_job_id: str, selection: dict[str, Any] + ) -> dict[str, Any]: return self._request("POST", f"/v1/jobs/{worker_job_id}/selection", selection) def payment(self, worker_job_id: str) -> dict[str, Any]: