801 lines
28 KiB
Python
801 lines
28 KiB
Python
#!/usr/bin/env python3
|
|
"""使用 jsdom DeviceFP 完成一笔已创建 YYB 订单的 web_save。
|
|
|
|
本脚本不创建 mall 订单;先由 ``main.py mall auto`` 创建订单,再执行本脚本。
|
|
付款码只在服务端 ``web_save`` 返回 ``ret=0`` 后渲染。付款后通过商城官方订单
|
|
列表确认本次新出现的完成订单;该检查不触发付款或确认操作。
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import hashlib
|
|
import json
|
|
import os
|
|
import re
|
|
import secrets
|
|
import string
|
|
import subprocess
|
|
import sys
|
|
import time
|
|
import urllib.error
|
|
import urllib.parse
|
|
import urllib.request
|
|
import uuid
|
|
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,
|
|
)
|
|
from pyvm.login_profile import midas_login_params
|
|
from pyvm.payment_errors import describe_payment_failure
|
|
from pyvm.protocol import (
|
|
DEFAULT_ORDER_PF,
|
|
GOODS_USER_AGENT,
|
|
PAY_APPID,
|
|
PAY_FP_URL,
|
|
PAY_GOODS_URL,
|
|
PAY_PAGE_INFO_URL,
|
|
PAY_SAVE_URL,
|
|
PAY_WEB_VERSION,
|
|
PAY_WEBVERSION,
|
|
goods_material_diagnostics,
|
|
validate_goods_materials,
|
|
)
|
|
|
|
APPID = PAY_APPID
|
|
GOODS_URL = PAY_GOODS_URL
|
|
SAVE_URL = PAY_SAVE_URL
|
|
PAGE_INFO_URL = PAY_PAGE_INFO_URL
|
|
FP_URL = PAY_FP_URL
|
|
|
|
|
|
def make_private_directory(path: Path) -> None:
|
|
"""创建私有证据目录;已有目录也收紧权限。"""
|
|
path.mkdir(parents=True, exist_ok=True)
|
|
os.chmod(path, 0o700)
|
|
|
|
|
|
def write_private_text(path: Path, content: str) -> None:
|
|
"""写入含订单或会话材料的原始证据,权限固定为 0600。"""
|
|
path.write_text(content, encoding="utf-8")
|
|
os.chmod(path, 0o600)
|
|
|
|
|
|
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"))
|
|
|
|
|
|
def parse_url_params(mall_response: dict) -> dict[str, str]:
|
|
try:
|
|
call_reply = json.loads(mall_response["data"]["call_reply"])
|
|
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()
|
|
}
|
|
|
|
|
|
def cookie_header(cookies: dict[str, str]) -> str:
|
|
pairs = dict(cookies)
|
|
if not pairs.get("midas_openid") and pairs.get("openid"):
|
|
pairs["midas_openid"] = pairs["openid"]
|
|
if not pairs.get("midas_openkey") and pairs.get("accesstoken"):
|
|
pairs["midas_openkey"] = pairs["accesstoken"]
|
|
return "; ".join(f"{name}={value}" for name, value in pairs.items() if value)
|
|
|
|
|
|
def request_bytes(url: str, cookies: dict[str, str], body: bytes | None = None) -> str:
|
|
headers = {
|
|
"User-Agent": GOODS_USER_AGENT,
|
|
"Cookie": cookie_header(cookies),
|
|
"Referer": "https://pay.qq.com/",
|
|
}
|
|
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"
|
|
)
|
|
try:
|
|
with urllib.request.urlopen(request, timeout=30) as response:
|
|
return response.read().decode("utf-8", "replace")
|
|
except urllib.error.HTTPError as exc:
|
|
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:
|
|
openid = cookies.get("openid") or cookies.get("midas_openid")
|
|
openkey = cookies.get("accesstoken") or cookies.get("midas_openkey")
|
|
if not openid or not openkey:
|
|
raise ValueError("会话缺少 openid/accesstoken,请先完成授权扫码登录")
|
|
login = midas_login_params(cookies)
|
|
params = {
|
|
"appid": APPID,
|
|
"openid": openid,
|
|
"openkey": openkey,
|
|
"session_id": login["session_id"],
|
|
"session_type": login["session_type"],
|
|
"sandbox": "",
|
|
"wxappid": login["wx_appid"],
|
|
"qqAppid": login["qq_appid"],
|
|
"pf": pf or "mds_myappjp-__mds_myappjp_PC_aW9zd2hpdGVsaX0-android",
|
|
"buy_quantity": "1",
|
|
"goodstokenurl": order["url_params"],
|
|
"zoneid": zone_id,
|
|
"supportCloseConfirm": "1",
|
|
"t": str(int(time.time() * 1000)),
|
|
}
|
|
return GOODS_URL + "?" + urllib.parse.urlencode(params)
|
|
|
|
|
|
def extract_goods_state(html: str) -> tuple[list[int], str, str]:
|
|
ops = re.search(r"var\s+xMidasOps\s*=\s*\[([^]]+)]", html)
|
|
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,登录态或订单已失效"
|
|
)
|
|
xmidas = [int(value) for value in ops.group(1).split(",") if value]
|
|
if len(xmidas) != 59640:
|
|
raise ValueError(f"goods xMidasOps 长度异常: {len(xmidas)}")
|
|
return xmidas, token.group(1).upper(), anti.group(1).upper()
|
|
|
|
|
|
def load_template_args() -> list:
|
|
runtime_template = ROOT / "replay/live/default"
|
|
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
|
|
)
|
|
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]:
|
|
"""按当前订单构造完整 web_save 表单,不能使用脱敏空模板。"""
|
|
if amount_fen <= 0:
|
|
raise ValueError("充值金额必须为正数")
|
|
login = midas_login_params(cookies)
|
|
fields = {
|
|
"token_id": order.get("token_id", ""),
|
|
"transaction_id": order.get("transaction_id", ""),
|
|
"out_trade_no": order.get("out_trade_no", ""),
|
|
"offer_type": order.get("offer_type", "0"),
|
|
"type": "bg",
|
|
"pf": pf,
|
|
"pfkey": "pfkey",
|
|
"from_h5": "1",
|
|
"pc_st": str(uuid.uuid4()).upper() + str(int(time.time() * 1000)),
|
|
"r": str(secrets.SystemRandom().random()),
|
|
"openid": cookies.get("openid", ""),
|
|
"openkey": cookies.get("accesstoken", ""),
|
|
"session_id": login["session_id"],
|
|
"session_type": login["session_type"],
|
|
"sck": hashlib.md5((APPID + cookies.get("accesstoken", "")).encode())
|
|
.hexdigest()
|
|
.upper(),
|
|
"anti_auto_script_token_id": anti_token,
|
|
"zoneid": zone_id,
|
|
"buy_quantity": "1",
|
|
"provide_uin": "",
|
|
"wx_appid": login["wx_appid"],
|
|
"extend": "",
|
|
"jump_url": "https://pay.qq.com/midas/minipay_v2/views/public/mb.shtml",
|
|
"wcp": f"type=CNY&amt={amount_fen}",
|
|
"biz_appid": "wx951bdcac522929b6",
|
|
"wx_direct_pay": "1",
|
|
"wx_publice_pay": "1",
|
|
"uuid": str(uuid.uuid4()).upper(),
|
|
"pay_method": "wechat",
|
|
"pushtype": "NodeJS",
|
|
"wx_order_interface": "1",
|
|
"encrypt_msg": encrypt_msg,
|
|
"base_key_version": PAY_WEB_VERSION,
|
|
"encrypt_way": "web_new_encrypt",
|
|
"web_token": web_token,
|
|
"webversion": PAY_WEBVERSION,
|
|
"from_https": "1",
|
|
"t": str(int(time.time() * 1000)),
|
|
"__refer": "https://z.iwan.yyb.qq.com/",
|
|
}
|
|
# QQ 网页端会携带该字段;微信 OAuth 不携带,不能使用固定 appid 混入两条链路。
|
|
if login["qq_appid"]:
|
|
fields["offerid_for_qq_appid"] = login["qq_appid"]
|
|
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:
|
|
"""编码完整支付表单,确保加密明文和实际请求使用同一上下文。"""
|
|
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:
|
|
"""构造网页端 fp-behv 后的 web_page_info 表单。"""
|
|
login = midas_login_params(cookies)
|
|
fields = {
|
|
"token_id": order.get("token_id", ""),
|
|
"transaction_id": order.get("transaction_id", ""),
|
|
"out_trade_no": order.get("out_trade_no", ""),
|
|
"offer_type": order.get("offer_type", "0"),
|
|
"type": "bg",
|
|
"pf": pf,
|
|
"pfkey": "pfkey",
|
|
"from_h5": "1",
|
|
"pc_st": str(uuid.uuid4()).upper() + str(int(time.time() * 1000)),
|
|
"r": str(secrets.SystemRandom().random()),
|
|
"openid": cookies.get("openid", ""),
|
|
"openkey": cookies.get("accesstoken", ""),
|
|
"session_id": login["session_id"],
|
|
"session_type": login["session_type"],
|
|
"sck": hashlib.md5((APPID + cookies.get("accesstoken", "")).encode())
|
|
.hexdigest()
|
|
.upper(),
|
|
"anti_auto_script_token_id": anti_token,
|
|
"isusempaymode": "1",
|
|
"zoneid": zone_id,
|
|
"webversion": PAY_WEBVERSION,
|
|
"from_https": "1",
|
|
"t": str(int(time.time() * 1000)),
|
|
"__refer": "https://z.iwan.yyb.qq.com/",
|
|
}
|
|
if login["qq_appid"]:
|
|
fields["offerid_for_qq_appid"] = login["qq_appid"]
|
|
return urllib.parse.urlencode(fields)
|
|
|
|
|
|
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)
|
|
)
|
|
# 微信历史成功请求固定使用 8 个随机字符加 \x01。_rand 位于加密明文中,
|
|
# 末控制字节是协议内容,不能为了对齐擅自替换为 QQ 使用的 \x03。
|
|
if not is_qq_login:
|
|
return prefix + "\x01"
|
|
|
|
# QQ 当前订单的明文长度会随 OAuth 字段变化;补齐后才能避免 VM 丢弃尾块。
|
|
base_length = len(build_plaintext(params, fk_extend, ts, "").encode("latin-1"))
|
|
padding_length = (-(base_length + len(prefix))) % 16
|
|
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:
|
|
"""保存可比对的脱敏请求形态,避免在证据目录落盘支付凭据。"""
|
|
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
|
|
],
|
|
}
|
|
for name in sensitive
|
|
}
|
|
meta = {
|
|
"field_names": sorted(fields),
|
|
"plaintext_length": plaintext_length,
|
|
"encrypt_msg_length": len(fields.get("encrypt_msg", "")),
|
|
"device_fp_length": device_fp_length,
|
|
"sensitive_field_fingerprints": fingerprints,
|
|
}
|
|
write_private_json(out_dir / "web-save-request-meta.json", meta)
|
|
|
|
|
|
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
|
|
make_private_directory(output.parent)
|
|
segno.make(sign).save(str(output), scale=6, border=2)
|
|
os.chmod(output, 0o600)
|
|
|
|
|
|
def node_environment() -> dict[str, str]:
|
|
"""Prevent a developer's Node inspector setting from affecting the jsdom worker."""
|
|
environment = os.environ.copy()
|
|
environment.pop("NODE_OPTIONS", None)
|
|
return environment
|
|
|
|
|
|
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
|
|
|
|
record = {
|
|
"checked_at": int(time.time()),
|
|
"baseline_order_states": baseline,
|
|
"listed_order_ids": sorted(order_ids(document)),
|
|
"matched_completion": completion_summary(matched) if matched else None,
|
|
}
|
|
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:
|
|
"""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
|
|
check is side-effect free.
|
|
|
|
The official order list (GetPrivateDomainOrderList) exposes its own ``order_id``
|
|
which equals web_save's ``portal_serial_no``; save it under ``order_id`` so the
|
|
completion check can match precisely instead of guessing by "newly completed".
|
|
"""
|
|
from pyvm.order_status import order_ids
|
|
|
|
record = {
|
|
"order_identifiers": {
|
|
"token_id": order.get("token_id", ""),
|
|
"transaction_id": order.get("transaction_id", ""),
|
|
"out_trade_no": order.get("out_trade_no", ""),
|
|
"order_id": portal_serial_no,
|
|
},
|
|
"baseline_order_states": baseline,
|
|
"baseline_order_ids": sorted(order_ids(document)),
|
|
"qr_created_at": int(time.time()),
|
|
}
|
|
write_private_json(path, record)
|
|
|
|
|
|
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
|
|
to "any newly completed order" is allowed: an unrelated order finishing on
|
|
the same account must not be reported as this payment.
|
|
"""
|
|
from pyvm.order_status import is_finished
|
|
|
|
for item in listed:
|
|
if is_finished(item) and any(
|
|
identifiers.get(key) and str(item.get(key, "")) == str(identifiers[key])
|
|
for key in identifiers
|
|
):
|
|
return item
|
|
return None
|
|
|
|
|
|
def cmd_check_only(session_path: Path, out_dir: Path) -> int:
|
|
"""Read-only payment completion check bound to a previously created QR.
|
|
|
|
Only an order identifier recorded at QR creation time counts as a match;
|
|
a fallback to "any newly completed order" is intentionally NOT used because
|
|
the order list cannot be proven to carry our token/out_trade_no fields,
|
|
and another order completing on the same account could be misjudged.
|
|
|
|
Exit codes: 0 = confirmed, 1 = not confirmed yet, 2 = check failed.
|
|
"""
|
|
from pyvm.order_status import (
|
|
completion_summary,
|
|
get_official_orders,
|
|
)
|
|
|
|
session = load_json(session_path)
|
|
cookies = dict(session.get("cookies", {}))
|
|
if not cookies:
|
|
raise ValueError("会话 cookies 为空,无法检测到账")
|
|
meta = load_json(out_dir / "payment-meta.json")
|
|
baseline = meta.get("baseline_order_states", {})
|
|
print("[jsdom-pay:check] 拉取官方订单列表...")
|
|
document = get_official_orders(cookies)
|
|
listed = [item for item in document.get("list", []) if isinstance(item, dict)]
|
|
matched = _match_finished_by_identifiers(listed, meta.get("order_identifiers", {}))
|
|
status_path = out_dir / "payment-status.json"
|
|
save_payment_status(status_path, document, baseline, matched)
|
|
if matched:
|
|
print(f"[jsdom-pay:check] 已按订单标识确认到账: {completion_summary(matched)}")
|
|
return 0
|
|
print("[jsdom-pay:check] 未匹配到本次订单标识,保持未确认状态,请人工核对")
|
|
return 1
|
|
|
|
|
|
def main() -> int:
|
|
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("--qr", default=None, help="付款二维码 PNG 路径")
|
|
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),不创建订单",
|
|
)
|
|
args = parser.parse_args()
|
|
if args.payment_timeout <= 0 or args.payment_interval <= 0:
|
|
raise ValueError("--payment-timeout 和 --payment-interval 必须为正数")
|
|
|
|
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}"
|
|
)
|
|
if args.check_only:
|
|
make_private_directory(out_dir)
|
|
return cmd_check_only(session_path, out_dir)
|
|
if args.amount_fen <= 0:
|
|
raise ValueError("充值金额必须为正数")
|
|
payment_pf = args.pf or DEFAULT_ORDER_PF
|
|
|
|
session = load_json(session_path)
|
|
cookies = dict(session.get("cookies", {}))
|
|
if not cookies:
|
|
raise ValueError("会话 cookies 为空,请先完成授权扫码登录")
|
|
response = load_json(response_path)
|
|
order = parse_url_params(response)
|
|
if not order.get("token_id"):
|
|
raise ValueError("mall 响应未包含 token_id")
|
|
try:
|
|
call_reply = json.loads(response["data"]["call_reply"])
|
|
order["url_params"] = call_reply["data"]["url_params"]
|
|
except (KeyError, TypeError, json.JSONDecodeError) as exc:
|
|
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}"
|
|
)
|
|
make_private_directory(out_dir)
|
|
url = goods_page_url(cookies, order, args.zone_id, args.pf)
|
|
print(f"[jsdom-pay] 订单: {order['token_id'][:20]}...")
|
|
print("[jsdom-pay] 拉取同订单 goods 页面...")
|
|
html = request_bytes(url, cookies)
|
|
html_path = out_dir / "goods.html"
|
|
write_private_text(html_path, html)
|
|
xmidas, web_token, anti_token = extract_goods_state(html)
|
|
print(f"[jsdom-pay] goods: xMidasOps={len(xmidas)} web_token={web_token[:12]}...")
|
|
|
|
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),
|
|
]
|
|
print("[jsdom-pay] 运行 jsdom DeviceFP...")
|
|
subprocess.run(command, check=True, cwd=ROOT, env=node_environment())
|
|
fp = load_json(fp_path)
|
|
if args.dry_run:
|
|
print(f"[jsdom-pay] dry-run 完成,证据目录: {out_dir}")
|
|
return 0
|
|
|
|
print("[jsdom-pay] 上报 fp-behv...")
|
|
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', '')}"
|
|
)
|
|
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(),
|
|
)
|
|
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] 证据目录: {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),
|
|
)
|
|
print("[jsdom-pay] 协议材料校验通过")
|
|
# key 派生已破解(F-2052,2026-08-12):key16 可随机生成,key1 由反解器
|
|
# derive_key1_from_key16 求解(key16=Sbox[Te 链(key1)]),满足服务端派生校验,
|
|
# 无需浏览器捕获 key(微信渠道纯 HTTP 已 ret:0)。
|
|
key16 = [secrets.randbelow(256) for _ in range(16)]
|
|
tables = [web_args[index][0] for index in (1, 2, 3, 4)]
|
|
key1 = derive_key1_from_key16(key16, te_tables=tables, sbox=web_args[5][0])
|
|
params = {
|
|
"token_id": order.get("token_id", ""),
|
|
"openid": cookies.get("openid", ""),
|
|
"openkey": cookies.get("accesstoken", ""),
|
|
"session_id": midas_login_params(cookies)["session_id"],
|
|
"session_type": midas_login_params(cookies)["session_type"],
|
|
"zoneid": args.zone_id,
|
|
"pay_method": "wechat",
|
|
"buy_quantity": "1",
|
|
"mb_pwd": "",
|
|
"pay_id": "",
|
|
"auth_key": "",
|
|
"card_value": "",
|
|
"accounttype": "",
|
|
"provide_uin": "",
|
|
"extend": "",
|
|
"from_h5": "1",
|
|
"webversion": PAY_WEBVERSION,
|
|
}
|
|
params.update(midas_login_params(cookies))
|
|
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"]),
|
|
)
|
|
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,
|
|
)
|
|
fields = build_save_fields(
|
|
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
|
|
)
|
|
body = urllib.parse.urlencode(fields)
|
|
from pyvm.order_status import get_official_orders, order_completion_states
|
|
|
|
# 尽量在发起支付前建立基线,缩小极快支付造成的检测窗口;失败不阻塞支付,付款码生成后重试。
|
|
baseline_document = None
|
|
baseline_states = None
|
|
try:
|
|
baseline_document = get_official_orders(cookies)
|
|
baseline_states = order_completion_states(baseline_document)
|
|
print("[jsdom-pay] 支付前订单基线已建立")
|
|
except Exception as exc: # noqa: BLE001
|
|
print(f"[jsdom-pay] 支付前建立基线失败({exc}),将在付款码生成后重试")
|
|
print("[jsdom-pay] 提交 web_save...")
|
|
raw = request_bytes(SAVE_URL, cookies, body.encode())
|
|
write_private_text(out_dir / "web-save-response.json", raw)
|
|
try:
|
|
response_json = json.loads(raw)
|
|
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', '')}"
|
|
)
|
|
print(f"[jsdom-pay] web_save 失败: {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", "")
|
|
if not sign.startswith("weixin://"):
|
|
print("[jsdom-pay] web_save 成功,但响应没有微信付款链接")
|
|
return 1
|
|
info = response_json.get("info", {}) or {}
|
|
portal_serial_no = str(info.get("portal_serial_no", "") or "")
|
|
qr_path = Path(args.qr) if args.qr else out_dir / "wechat-pay.png"
|
|
make_qr(sign, qr_path)
|
|
print("[jsdom-pay] 微信付款码已生成")
|
|
print(f"[jsdom-pay] PNG: {qr_path}")
|
|
|
|
if baseline_document is None:
|
|
try:
|
|
baseline_document = get_official_orders(cookies)
|
|
baseline_states = order_completion_states(baseline_document)
|
|
except Exception as exc: # noqa: BLE001
|
|
# 付款码已生成,基线缺失只影响确认精度(仅按订单标识确认),不能阻断付款。
|
|
print(f"[jsdom-pay] 基线获取失败: {exc};将仅按订单标识确认到账")
|
|
baseline_document = {"list": []}
|
|
baseline_states = {}
|
|
print("[jsdom-pay] 记录付款前订单基线与本次订单标识...")
|
|
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
|
|
|
|
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
|
|
}
|
|
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,
|
|
)
|
|
save_payment_status(status_path, document, baseline_states, completed)
|
|
if completed:
|
|
print("[jsdom-pay] 微信付款成功,商城订单已完成。")
|
|
print(f"[jsdom-pay] 状态记录: {status_path}")
|
|
return 0
|
|
print("[jsdom-pay] 在等待期限内未确认到本次订单完成。")
|
|
print(f"[jsdom-pay] 状态记录: {status_path}")
|
|
return 1
|
|
|
|
|
|
if __name__ == "__main__":
|
|
try:
|
|
raise SystemExit(main())
|
|
except (
|
|
FileNotFoundError,
|
|
ValueError,
|
|
RuntimeError,
|
|
subprocess.CalledProcessError,
|
|
) as exc:
|
|
print(f"错误: {exc}", file=sys.stderr)
|
|
raise SystemExit(2) from exc
|