style: 统一 Ruff 代码格式

This commit is contained in:
yml2213
2026-08-30 21:04:52 +08:00
parent c891ac982e
commit 47e19ed7b2
90 changed files with 5574 additions and 2350 deletions
+254 -78
View File
@@ -5,6 +5,7 @@
付款码只在服务端 ``web_save`` 返回 ``ret=0`` 后渲染。付款后通过商城官方订单
列表确认本次新出现的完成订单;该检查不触发付款或确认操作。
"""
from __future__ import annotations
import argparse
@@ -26,7 +27,11 @@ from pathlib import Path
ROOT = Path(__file__).resolve().parent.parent
sys.path.insert(0, str(ROOT))
from pyvm.algorithm import build_plaintext, derive_key1_from_key16, generate_encrypt_msg_offline # noqa: E402
from pyvm.algorithm import (
build_plaintext,
derive_key1_from_key16,
generate_encrypt_msg_offline,
) # noqa: E402
from pyvm.login_profile import midas_login_params # noqa: E402
from pyvm.payment_errors import describe_payment_failure # noqa: E402
from pyvm.protocol import ( # noqa: E402
@@ -64,6 +69,8 @@ def write_private_text(path: Path, content: str) -> None:
def write_private_json(path: Path, value: dict) -> None:
write_private_text(path, json.dumps(value, ensure_ascii=False, indent=2) + "\n")
def load_json(path: Path) -> dict:
return json.loads(path.read_text(encoding="utf-8"))
@@ -74,8 +81,12 @@ def parse_url_params(mall_response: dict) -> dict[str, str]:
url_params = call_reply["data"]["url_params"]
except (KeyError, TypeError, json.JSONDecodeError) as exc:
raise ValueError("mall 响应缺少 data.call_reply.data.url_params") from exc
return {key: values[-1] for key, values in urllib.parse.parse_qs(
urllib.parse.urlparse(url_params).query, keep_blank_values=True).items()}
return {
key: values[-1]
for key, values in urllib.parse.parse_qs(
urllib.parse.urlparse(url_params).query, keep_blank_values=True
).items()
}
def cookie_header(cookies: dict[str, str]) -> str:
@@ -96,8 +107,9 @@ def request_bytes(url: str, cookies: dict[str, str], body: bytes | None = None)
if body is not None:
headers["Content-Type"] = "application/x-www-form-urlencoded"
headers["Origin"] = "https://pay.qq.com"
request = urllib.request.Request(url, data=body, headers=headers,
method="POST" if body is not None else "GET")
request = urllib.request.Request(
url, data=body, headers=headers, method="POST" if body is not None else "GET"
)
try:
with urllib.request.urlopen(request, timeout=30) as response:
return response.read().decode("utf-8", "replace")
@@ -105,7 +117,9 @@ def request_bytes(url: str, cookies: dict[str, str], body: bytes | None = None)
return exc.read().decode("utf-8", "replace")
def goods_page_url(cookies: dict[str, str], order: dict[str, str], zone_id: str = "1", pf: str = "") -> str:
def goods_page_url(
cookies: dict[str, str], order: dict[str, str], zone_id: str = "1", pf: str = ""
) -> str:
openid = cookies.get("openid") or cookies.get("midas_openid")
openkey = cookies.get("accesstoken") or cookies.get("midas_openkey")
if not openid or not openkey:
@@ -135,7 +149,9 @@ def extract_goods_state(html: str) -> tuple[list[int], str, str]:
token = re.search(r'id="xMidasToken"\s+value="([0-9A-Fa-f]+)"', html)
anti = re.search(r'id="antiAutoScriptToken"\s+value="([0-9A-Fa-f]+)"', html)
if not ops or not token or not anti:
raise ValueError("goods 页面缺少 xMidasOps/xMidasToken/antiAutoScriptToken,登录态或订单已失效")
raise ValueError(
"goods 页面缺少 xMidasOps/xMidasToken/antiAutoScriptToken,登录态或订单已失效"
)
xmidas = [int(value) for value in ops.group(1).split(",") if value]
if len(xmidas) != 59640:
raise ValueError(f"goods xMidasOps 长度异常: {len(xmidas)}")
@@ -147,16 +163,25 @@ def load_template_args() -> list:
runtime_args = runtime_template / "args-template.json"
if runtime_args.exists():
return load_json(runtime_args)
candidates = sorted((ROOT / "replay/live").glob("*/args-template.json"), reverse=True)
candidates = sorted(
(ROOT / "replay/live").glob("*/args-template.json"), reverse=True
)
for path in candidates:
if path.exists():
return load_json(path)
raise FileNotFoundError("缺少归档 goods args-template.json")
def build_save_fields(order: dict[str, str], cookies: dict[str, str], web_token: str,
anti_token: str, encrypt_msg: str, zone_id: str, pf: str,
amount_fen: int) -> dict[str, str]:
def build_save_fields(
order: dict[str, str],
cookies: dict[str, str],
web_token: str,
anti_token: str,
encrypt_msg: str,
zone_id: str,
pf: str,
amount_fen: int,
) -> dict[str, str]:
"""按当前订单构造完整 web_save 表单,不能使用脱敏空模板。"""
if amount_fen <= 0:
raise ValueError("充值金额必须为正数")
@@ -176,7 +201,9 @@ def build_save_fields(order: dict[str, str], cookies: dict[str, str], web_token:
"openkey": cookies.get("accesstoken", ""),
"session_id": login["session_id"],
"session_type": login["session_type"],
"sck": hashlib.md5((APPID + cookies.get("accesstoken", "")).encode()).hexdigest().upper(),
"sck": hashlib.md5((APPID + cookies.get("accesstoken", "")).encode())
.hexdigest()
.upper(),
"anti_auto_script_token_id": anti_token,
"zoneid": zone_id,
"buy_quantity": "1",
@@ -207,17 +234,38 @@ def build_save_fields(order: dict[str, str], cookies: dict[str, str], web_token:
return fields
def build_save_body(order: dict[str, str], cookies: dict[str, str], web_token: str,
anti_token: str, encrypt_msg: str, zone_id: str, pf: str,
amount_fen: int) -> str:
def build_save_body(
order: dict[str, str],
cookies: dict[str, str],
web_token: str,
anti_token: str,
encrypt_msg: str,
zone_id: str,
pf: str,
amount_fen: int,
) -> str:
"""编码完整支付表单,确保加密明文和实际请求使用同一上下文。"""
return urllib.parse.urlencode(build_save_fields(
order, cookies, web_token, anti_token, encrypt_msg, zone_id, pf, amount_fen,
))
return urllib.parse.urlencode(
build_save_fields(
order,
cookies,
web_token,
anti_token,
encrypt_msg,
zone_id,
pf,
amount_fen,
)
)
def build_page_info_body(order: dict[str, str], cookies: dict[str, str], anti_token: str,
zone_id: str, pf: str) -> str:
def build_page_info_body(
order: dict[str, str],
cookies: dict[str, str],
anti_token: str,
zone_id: str,
pf: str,
) -> str:
"""构造网页端 fp-behv 后的 web_page_info 表单。"""
login = midas_login_params(cookies)
fields = {
@@ -235,7 +283,9 @@ def build_page_info_body(order: dict[str, str], cookies: dict[str, str], anti_to
"openkey": cookies.get("accesstoken", ""),
"session_id": login["session_id"],
"session_type": login["session_type"],
"sck": hashlib.md5((APPID + cookies.get("accesstoken", "")).encode()).hexdigest().upper(),
"sck": hashlib.md5((APPID + cookies.get("accesstoken", "")).encode())
.hexdigest()
.upper(),
"anti_auto_script_token_id": anti_token,
"isusempaymode": "1",
"zoneid": zone_id,
@@ -249,10 +299,13 @@ def build_page_info_body(order: dict[str, str], cookies: dict[str, str], anti_to
return urllib.parse.urlencode(fields)
def make_encrypt_rand(params: dict[str, str], fk_extend: str, ts: str,
is_qq_login: bool) -> str:
def make_encrypt_rand(
params: dict[str, str], fk_extend: str, ts: str, is_qq_login: bool
) -> str:
"""按登录渠道生成页面已验证形态的 _rand,不能仅按长度替换控制字节。"""
prefix = "".join(secrets.choice(string.ascii_letters + string.digits) for _ in range(8))
prefix = "".join(
secrets.choice(string.ascii_letters + string.digits) for _ in range(8)
)
# 微信历史成功请求固定使用 8 个随机字符加 \x01。_rand 位于加密明文中,
# 末控制字节是协议内容,不能为了对齐擅自替换为 QQ 使用的 \x03。
if not is_qq_login:
@@ -264,15 +317,27 @@ def make_encrypt_rand(params: dict[str, str], fk_extend: str, ts: str,
return prefix + ("\x03" * padding_length)
def save_web_save_request_meta(out_dir: Path, fields: dict[str, str], device_fp_length: int,
plaintext_length: int) -> None:
def save_web_save_request_meta(
out_dir: Path, fields: dict[str, str], device_fp_length: int, plaintext_length: int
) -> None:
"""保存可比对的脱敏请求形态,避免在证据目录落盘支付凭据。"""
sensitive = ("token_id", "transaction_id", "out_trade_no", "openid", "openkey",
"sck", "anti_auto_script_token_id", "web_token", "encrypt_msg")
sensitive = (
"token_id",
"transaction_id",
"out_trade_no",
"openid",
"openkey",
"sck",
"anti_auto_script_token_id",
"web_token",
"encrypt_msg",
)
fingerprints = {
name: {
"length": len(fields.get(name, "")),
"sha256_prefix": hashlib.sha256(fields.get(name, "").encode()).hexdigest()[:12],
"sha256_prefix": hashlib.sha256(fields.get(name, "").encode()).hexdigest()[
:12
],
}
for name in sensitive
}
@@ -290,7 +355,9 @@ def make_qr(sign: str, output: Path) -> None:
try:
import segno
except ImportError as exc:
raise RuntimeError("缺少 segno;请安装后重新执行: python3 -m pip install segno") from exc
raise RuntimeError(
"缺少 segno;请安装后重新执行: python3 -m pip install segno"
) from exc
make_private_directory(output.parent)
segno.make(sign).save(str(output), scale=6, border=2)
os.chmod(output, 0o600)
@@ -303,7 +370,9 @@ def node_environment() -> dict[str, str]:
return environment
def save_payment_status(path: Path, document: dict, baseline: dict[str, bool], matched: dict | None = None) -> None:
def save_payment_status(
path: Path, document: dict, baseline: dict[str, bool], matched: dict | None = None
) -> None:
"""Persist a small, non-payment-side-effect status record for this run."""
from pyvm.order_status import completion_summary, order_ids
@@ -316,8 +385,13 @@ def save_payment_status(path: Path, document: dict, baseline: dict[str, bool], m
write_private_json(path, record)
def save_payment_meta(path: Path, order: dict[str, str], baseline: dict[str, bool],
document: dict, portal_serial_no: str = "") -> None:
def save_payment_meta(
path: Path,
order: dict[str, str],
baseline: dict[str, bool],
document: dict,
portal_serial_no: str = "",
) -> None:
"""Persist the order identifiers and pre-payment baseline needed by a later check.
``check`` only re-reads this file and never touches web_save, so re-running a
@@ -343,7 +417,9 @@ def save_payment_meta(path: Path, order: dict[str, str], baseline: dict[str, boo
write_private_json(path, record)
def _match_finished_by_identifiers(listed: list[dict], identifiers: dict[str, str]) -> dict | None:
def _match_finished_by_identifiers(
listed: list[dict], identifiers: dict[str, str]
) -> dict | None:
"""Match a finished order by the identifiers recorded at QR creation time.
The official order list may not carry token_id/out_trade_no, so no fallback
@@ -397,23 +473,52 @@ def cmd_check_only(session_path: Path, out_dir: Path) -> int:
def main() -> int:
parser = argparse.ArgumentParser(description="YYB: jsdom DeviceFP + web_save -> 微信付款码")
parser = argparse.ArgumentParser(
description="YYB: jsdom DeviceFP + web_save -> 微信付款码"
)
parser.add_argument("--session", default=str(ROOT / "config/mall-session.json"))
parser.add_argument("--mall-response", default=str(ROOT / "config/mall-order-response.json"))
parser.add_argument("--out-dir", default=None, help="运行证据目录;默认 config/jsdom-order-<timestamp>")
parser.add_argument(
"--mall-response", default=str(ROOT / "config/mall-order-response.json")
)
parser.add_argument(
"--out-dir",
default=None,
help="运行证据目录;默认 config/jsdom-order-<timestamp>",
)
parser.add_argument("--qr", default=None, help="付款二维码 PNG 路径")
parser.add_argument("--wait", type=int, default=5, help="jsdom 等待 DeviceFP 的秒数")
parser.add_argument(
"--wait", type=int, default=5, help="jsdom 等待 DeviceFP 的秒数"
)
parser.add_argument("--zone-id", default="1", help="所选游戏区服 ID")
parser.add_argument("--pf", default="", help="所选 Android/iOS 支付平台标识")
parser.add_argument("--amount-fen", type=int, default=0, help="所选点券的价格,单位分(check-only 模式不需要)")
parser.add_argument("--dry-run", action="store_true", help="仅拉取页面并生成 DeviceFP,不上报或创建付款码")
parser.add_argument("--payment-timeout", type=float, default=300,
help="付款码生成后等待订单完成的最长秒数")
parser.add_argument("--payment-interval", type=float, default=3,
help="订单完成状态检查间隔秒数")
parser.add_argument("--skip-payment-check", action="store_true", help="仅生成付款码,不等待订单完成")
parser.add_argument("--check-only", action="store_true",
help="只读检测到账(依赖 --out-dir 下已保存的 payment-meta.json),不创建订单")
parser.add_argument(
"--amount-fen",
type=int,
default=0,
help="所选点券的价格,单位分(check-only 模式不需要)",
)
parser.add_argument(
"--dry-run",
action="store_true",
help="仅拉取页面并生成 DeviceFP,不上报或创建付款码",
)
parser.add_argument(
"--payment-timeout",
type=float,
default=300,
help="付款码生成后等待订单完成的最长秒数",
)
parser.add_argument(
"--payment-interval", type=float, default=3, help="订单完成状态检查间隔秒数"
)
parser.add_argument(
"--skip-payment-check", action="store_true", help="仅生成付款码,不等待订单完成"
)
parser.add_argument(
"--check-only",
action="store_true",
help="只读检测到账(依赖 --out-dir 下已保存的 payment-meta.json),不创建订单",
)
args = parser.parse_args()
if args.payment_timeout <= 0 or args.payment_interval <= 0:
raise ValueError("--payment-timeout 和 --payment-interval 必须为正数")
@@ -421,7 +526,9 @@ def main() -> int:
session_path = Path(args.session).resolve()
response_path = Path(args.mall_response).resolve()
stamp = time.strftime("%Y%m%d-%H%M%S")
out_dir = Path(args.out_dir) if args.out_dir else ROOT / "config" / f"jsdom-order-{stamp}"
out_dir = (
Path(args.out_dir) if args.out_dir else ROOT / "config" / f"jsdom-order-{stamp}"
)
if args.check_only:
make_private_directory(out_dir)
return cmd_check_only(session_path, out_dir)
@@ -444,7 +551,9 @@ def main() -> int:
raise ValueError("mall 响应无法解析 url_params") from exc
stamp = time.strftime("%Y%m%d-%H%M%S")
out_dir = Path(args.out_dir) if args.out_dir else ROOT / "config" / f"jsdom-order-{stamp}"
out_dir = (
Path(args.out_dir) if args.out_dir else ROOT / "config" / f"jsdom-order-{stamp}"
)
make_private_directory(out_dir)
url = goods_page_url(cookies, order, args.zone_id, args.pf)
print(f"[jsdom-pay] 订单: {order['token_id'][:20]}...")
@@ -457,9 +566,18 @@ def main() -> int:
fp_path = out_dir / "device-fp.json"
command = [
"node", str(ROOT / "scripts/generate-devicefp-jsdom.mjs"),
"--html", str(html_path), "--goods-url", url, "--cookies", str(session_path),
"--output", str(fp_path), "--wait", str(args.wait * 1000),
"node",
str(ROOT / "scripts/generate-devicefp-jsdom.mjs"),
"--html",
str(html_path),
"--goods-url",
url,
"--cookies",
str(session_path),
"--output",
str(fp_path),
"--wait",
str(args.wait * 1000),
]
print("[jsdom-pay] 运行 jsdom DeviceFP...")
subprocess.run(command, check=True, cwd=ROOT, env=node_environment())
@@ -469,36 +587,53 @@ def main() -> int:
return 0
print("[jsdom-pay] 上报 fp-behv...")
fp_response = request_bytes(fp.get("fp_url", FP_URL), cookies, fp["fp_body"].encode())
fp_response = request_bytes(
fp.get("fp_url", FP_URL), cookies, fp["fp_body"].encode()
)
write_private_text(out_dir / "fp-response.json", fp_response)
try:
fp_json = json.loads(fp_response)
except json.JSONDecodeError:
fp_json = {}
if fp_json.get("ret") != 0:
print(f"[jsdom-pay] fp-behv 失败: ret={fp_json.get('ret')} {fp_json.get('msg', '')}")
print(
f"[jsdom-pay] fp-behv 失败: ret={fp_json.get('ret')} {fp_json.get('msg', '')}"
)
return 1
# QQ 成功 HAR 包含该前置;微信历史成功链路没有,不能将 QQ 状态机混入微信请求。
if midas_login_params(cookies)["qq_appid"]:
print("[jsdom-pay] 拉取 QQ web_page_info...")
page_info = request_bytes(PAGE_INFO_URL, cookies, build_page_info_body(
order, cookies, anti_token, args.zone_id, payment_pf,
).encode())
page_info = request_bytes(
PAGE_INFO_URL,
cookies,
build_page_info_body(
order,
cookies,
anti_token,
args.zone_id,
payment_pf,
).encode(),
)
write_private_text(out_dir / "web-page-info-response.json", page_info)
try:
page_info_json = json.loads(page_info)
except json.JSONDecodeError:
page_info_json = {}
if page_info_json.get("ret") != 0:
print(f"[jsdom-pay] QQ web_page_info 失败: ret={page_info_json.get('ret')} "
f"{page_info_json.get('msg', '')}")
print(
f"[jsdom-pay] QQ web_page_info 失败: ret={page_info_json.get('ret')} "
f"{page_info_json.get('msg', '')}"
)
print(f"[jsdom-pay] 证据目录: {out_dir}")
return 1
web_args = load_template_args()
validate_goods_materials(web_args, xmidas)
write_private_json(out_dir / "protocol-materials.json", goods_material_diagnostics(ROOT, web_args, xmidas))
write_private_json(
out_dir / "protocol-materials.json",
goods_material_diagnostics(ROOT, web_args, xmidas),
)
print("[jsdom-pay] 协议材料校验通过")
# key 派生已破解(F-2052,2026-08-12):key16 可随机生成,key1 由反解器
# derive_key1_from_key16 求解(key16=Sbox[Te 链(key1)]),满足服务端派生校验,
@@ -529,18 +664,38 @@ def main() -> int:
now_seconds = str(int(time.time()))
fk_extend = "tdrc_session%3D" + fp["session_id"]
random_suffix = make_encrypt_rand(
params, fk_extend, now_seconds, bool(midas_login_params(cookies)["qq_appid"]),
params,
fk_extend,
now_seconds,
bool(midas_login_params(cookies)["qq_appid"]),
)
plaintext_length = len(
build_plaintext(params, fk_extend, now_seconds, random_suffix).encode("latin-1")
)
plaintext_length = len(build_plaintext(params, fk_extend, now_seconds, random_suffix).encode("latin-1"))
encrypt_msg = generate_encrypt_msg_offline(
params, fk_extend, now_seconds, random_suffix,
key16=key16, key1=key1, args_template=web_args, xmidas=xmidas, xmidas_token=web_token,
params,
fk_extend,
now_seconds,
random_suffix,
key16=key16,
key1=key1,
args_template=web_args,
xmidas=xmidas,
xmidas_token=web_token,
)
fields = build_save_fields(
order, cookies, web_token, anti_token, encrypt_msg,
args.zone_id, payment_pf, args.amount_fen,
order,
cookies,
web_token,
anti_token,
encrypt_msg,
args.zone_id,
payment_pf,
args.amount_fen,
)
save_web_save_request_meta(
out_dir, fields, int(fp.get("device_fp_length", 0)), plaintext_length
)
save_web_save_request_meta(out_dir, fields, int(fp.get("device_fp_length", 0)), plaintext_length)
body = urllib.parse.urlencode(fields)
from pyvm.order_status import order_completion_states, get_official_orders
@@ -561,10 +716,14 @@ def main() -> int:
except json.JSONDecodeError:
response_json = {}
if response_json.get("ret") != 0:
detail = f"ret={response_json.get('ret')} err_code={response_json.get('err_code', '')} " \
f"{response_json.get('msg', '')}"
detail = (
f"ret={response_json.get('ret')} err_code={response_json.get('err_code', '')} "
f"{response_json.get('msg', '')}"
)
print(f"[jsdom-pay] web_save 失败: {detail}")
print(f"[jsdom-pay] {describe_payment_failure(cookies.get('logintype', ''), 'payment', detail)}")
print(
f"[jsdom-pay] {describe_payment_failure(cookies.get('logintype', ''), 'payment', detail)}"
)
print(f"[jsdom-pay] 证据目录: {out_dir}")
return 1
sign = response_json.get("info", {}).get("channel_info", {}).get("sign", "")
@@ -588,7 +747,13 @@ def main() -> int:
baseline_document = {"list": []}
baseline_states = {}
print("[jsdom-pay] 记录付款前订单基线与本次订单标识...")
save_payment_meta(out_dir / "payment-meta.json", order, baseline_states, baseline_document, portal_serial_no)
save_payment_meta(
out_dir / "payment-meta.json",
order,
baseline_states,
baseline_document,
portal_serial_no,
)
if args.skip_payment_check:
print("[jsdom-pay] 已保存基线;后台将单独执行只读到账检测")
return 0
@@ -596,17 +761,23 @@ def main() -> int:
print("[jsdom-pay] 记录付款前订单列表,等待微信付款完成...")
status_path = out_dir / "payment-status.json"
save_payment_status(status_path, baseline_document, baseline_states)
identifiers = {key: value for key, value in {
"token_id": order.get("token_id", ""),
"transaction_id": order.get("transaction_id", ""),
"out_trade_no": order.get("out_trade_no", ""),
}.items() if value}
identifiers = {
key: value
for key, value in {
"token_id": order.get("token_id", ""),
"transaction_id": order.get("transaction_id", ""),
"out_trade_no": order.get("out_trade_no", ""),
}.items()
if value
}
deadline = time.monotonic() + args.payment_timeout
while time.monotonic() < deadline:
time.sleep(args.payment_interval)
document = get_official_orders(cookies)
completed = _match_finished_by_identifiers(
[item for item in document.get("list", []) if isinstance(item, dict)], identifiers)
[item for item in document.get("list", []) if isinstance(item, dict)],
identifiers,
)
save_payment_status(status_path, document, baseline_states, completed)
if completed:
print("[jsdom-pay] 微信付款成功,商城订单已完成。")
@@ -620,6 +791,11 @@ def main() -> int:
if __name__ == "__main__":
try:
raise SystemExit(main())
except (FileNotFoundError, ValueError, RuntimeError, subprocess.CalledProcessError) as exc:
except (
FileNotFoundError,
ValueError,
RuntimeError,
subprocess.CalledProcessError,
) as exc:
print(f"错误: {exc}", file=sys.stderr)
raise SystemExit(2) from exc
+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)