style: 统一 Ruff 代码格式

This commit is contained in:
yml2213
2026-08-30 21:04:52 +08:00
parent c891ac982e
commit 47e19ed7b2
90 changed files with 5574 additions and 2350 deletions
+254 -78
View File
@@ -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-<timestamp>")
parser.add_argument(
"--mall-response", default=str(ROOT / "config/mall-order-response.json")
)
parser.add_argument(
"--out-dir",
default=None,
help="运行证据目录;默认 config/jsdom-order-<timestamp>",
)
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