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
+220 -71
View File
@@ -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="游戏分区 IDQQ 平台可为空)")
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())
@@ -6,6 +6,7 @@
- mall.py: mall 侧高层 API(MallSession + generate_encrypt_msg)
- session.py: 会话态模型
"""
from .algorithm import (
build_plaintext,
decode_d,
File diff suppressed because it is too large Load Diff
+53 -13
View File
@@ -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,
)
@@ -1,4 +1,5 @@
"""YYB mall APIs require different session fields for WeChat and QQ OAuth."""
from __future__ import annotations
WECHAT_APPID = "wxd44977328b36e647"
+52 -10
View File
@@ -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)
@@ -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):
File diff suppressed because it is too large Load Diff
@@ -1,4 +1,5 @@
"""YYB 下单和付款失败响应的统一归类。"""
from __future__ import annotations
+29 -7
View File
@@ -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"
+4 -1
View File
@@ -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 = ""
+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
+82 -26
View File
@@ -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:
@@ -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:
@@ -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
+274 -81
View File
@@ -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)
@@ -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)