初步增加, 扫码登录成功

This commit is contained in:
yml2213
2026-08-12 16:22:55 +08:00
parent 7db3b2cd4f
commit d19c0902f2
60 changed files with 7413 additions and 315 deletions
+10
View File
@@ -60,6 +60,16 @@ COOKIE_SECURE=false
# CORS 允许的源(逗号分隔,不设则默认开发环境)
# CORS_ORIGINS=https://example.com,https://www.example.com
# 应用宝充值 WorkerDocker Compose 内网服务;生产环境必须设置为随机长密钥)
# 生成方式: python3 -c "import secrets; print(secrets.token_urlsafe(32))"
YYB_WORKER_KEY=
# Docker 部署使用默认值;dev.sh 自动覆盖为 http://127.0.0.1:${YYB_WORKER_PORT:-8810}
# YYB_WORKER_URL=http://yyb-worker:8810
# YYB_WORKER_TIMEOUT=30
# 本地调试 Worker(仅 dev.sh 使用,默认只绑定 127.0.0.1
# YYB_WORKER_PORT=8810
# DEV_YYB_WORKER_URL=http://127.0.0.1:8810
# Roundcube 邮件验证码读取服务地址(可选;不填则使用默认服务)
# MAIL_ROUNDCUBE_URL=http://127.0.0.1:8000/
+20
View File
@@ -48,6 +48,26 @@ douyu_login_py/
访问 `http://localhost:8800`,默认账号 `admin / admin123`
### 应用宝充值 Worker
应用宝充值使用同仓库内独立的 `yyb-worker` Docker 服务,后台不直接运行逆向脚本。两个服务使用 Compose 默认内网通信,不共享目录、Cookie 或数据库。
```bash
cd /opt/douyu_login_py
python3 -c 'import secrets; print(secrets.token_urlsafe(32))'
```
将生成的值写入项目 `.env`
```dotenv
YYB_WORKER_URL=http://yyb-worker:8810
YYB_WORKER_KEY=随机密钥
```
随后执行 `./deploy.sh`,它会同时构建并启动后台、MySQL 和 Worker。Worker 不发布端口;扫码图片和支付二维码只经后台授权接口返回。Worker 的任务文件保存在独立 Docker volume `yyb-worker-data`。服务器只需要此仓库,不需要部署 `js_reverse`
本机热更新调试使用 `./dev.sh`(或 `./deploy.sh dev`)。它会构建并启动一个仅绑定 `127.0.0.1:8810` 的开发 Worker,后端自动使用该地址;Worker 动态数据写入 `data/yyb-worker-dev/`,按 `Ctrl+C` 会停止本次启动的容器。
**常用命令:**
```bash
+19
View File
@@ -5,6 +5,14 @@ set -e
ROOT_DIR="$(cd "$(dirname "$0")" && pwd)"
cd "$ROOT_DIR"
if [ -f "$ROOT_DIR/.env" ]; then
set -a
# shellcheck disable=SC1091
source "$ROOT_DIR/.env"
set +a
fi
APP_PORT="${APP_PORT:-8000}"
# ── 工具函数 ──────────────────────────────────────────────
@@ -42,6 +50,16 @@ cmd_deploy() {
echo " 使用: $COMPOSE"
echo ""
if [ -z "${YYB_WORKER_KEY:-}" ]; then
echo "❌ 请先在 .env 中设置 YYB_WORKER_KEY"
exit 1
fi
if ! $COMPOSE config --quiet; then
echo "❌ Docker Compose 配置无效,请检查 .env 中的必填变量"
exit 1
fi
# 创建数据和日志目录
mkdir -p data logs
@@ -91,6 +109,7 @@ cmd_reset() {
echo ""
echo " 将删除以下内容:"
echo " • MySQL 数据卷"
echo " • 应用宝 Worker 任务数据卷(扫码会话、二维码和订单记录)"
echo " • 历史Cookie (data/cookies/)"
echo " • 应用日志 (logs/)"
echo " • Docker容器和镜像"
+59 -2
View File
@@ -35,9 +35,15 @@ DB_POOL_SIZE="${DB_POOL_SIZE:-20}"
DB_MAX_OVERFLOW="${DB_MAX_OVERFLOW:-20}"
DB_POOL_TIMEOUT="${DB_POOL_TIMEOUT:-30}"
DB_POOL_RECYCLE="${DB_POOL_RECYCLE:-1800}"
YYB_WORKER_PORT="${YYB_WORKER_PORT:-8810}"
YYB_WORKER_IMAGE="${YYB_WORKER_IMAGE:-douyu-yyb-worker:local}"
DEV_YYB_WORKER_URL="${DEV_YYB_WORKER_URL:-http://127.0.0.1:${YYB_WORKER_PORT}}"
YYB_WORKER_CONTAINER="${YYB_WORKER_CONTAINER:-douyu-yyb-worker-dev}"
YYB_WORKER_DATA_DIR="${YYB_WORKER_DATA_DIR:-$ROOT_DIR/data/yyb-worker-dev}"
BACKEND_PID=""
FRONTEND_PID=""
WORKER_STARTED_BY_DEV=false
stop_tree() {
local pid="$1"
@@ -56,6 +62,9 @@ cleanup() {
trap - EXIT INT TERM
stop_tree "$FRONTEND_PID"
stop_tree "$BACKEND_PID"
if [ "$WORKER_STARTED_BY_DEV" = true ]; then
docker stop "$YYB_WORKER_CONTAINER" >/dev/null 2>&1 || true
fi
}
require_cmd() {
@@ -108,6 +117,11 @@ if [ -z "$DB_PASSWORD" ]; then
exit 1
fi
if [ -z "${YYB_WORKER_KEY:-}" ]; then
echo "请先在 .env 中设置 YYB_WORKER_KEY"
exit 1
fi
COMPOSE=$(detect_compose)
if [ -z "$COMPOSE" ]; then
echo "缺少 docker compose"
@@ -117,6 +131,48 @@ BACKEND_PROXY_HOST="${BACKEND_PROXY_HOST:-$(detect_backend_proxy_host)}"
BACKEND_PROXY_TARGET="${VITE_BACKEND_TARGET:-http://${BACKEND_PROXY_HOST}:${BACKEND_PORT}}"
mkdir -p data logs
mkdir -p "$YYB_WORKER_DATA_DIR"
trap cleanup EXIT INT TERM
start_yyb_worker() {
if docker ps --format '{{.Names}}' | grep -qx "$YYB_WORKER_CONTAINER"; then
echo "本地 YYB Worker 已运行: ${DEV_YYB_WORKER_URL}"
return
fi
if docker ps -a --format '{{.Names}}' | grep -qx "$YYB_WORKER_CONTAINER"; then
echo "发现已停止的本地 YYB Worker,正在清理旧容器..."
docker rm "$YYB_WORKER_CONTAINER" >/dev/null
fi
echo "正在构建本地 YYB Worker..."
YYB_WORKER_KEY="$YYB_WORKER_KEY" $COMPOSE build yyb-worker
echo "正在启动本地 YYB Worker..."
docker run -d --rm \
--name "$YYB_WORKER_CONTAINER" \
-p "127.0.0.1:${YYB_WORKER_PORT}:8810" \
-e TZ="${TZ:-Asia/Shanghai}" \
-e YYB_WORKER_KEY="$YYB_WORKER_KEY" \
-v "$YYB_WORKER_DATA_DIR:/app/config/worker-jobs" \
"$YYB_WORKER_IMAGE" >/dev/null
WORKER_STARTED_BY_DEV=true
echo "等待本地 YYB Worker 就绪..."
for i in $(seq 1 15); do
if curl -fsS "${DEV_YYB_WORKER_URL}/health" >/dev/null 2>&1; then
echo "YYB Worker 已就绪"
return
fi
if [ "$i" = "15" ]; then
echo "YYB Worker 启动超时,查看日志:"
docker logs "$YYB_WORKER_CONTAINER" || true
exit 1
fi
sleep 1
done
}
start_yyb_worker
echo "正在启动本地 MySQL..."
MYSQL_IMAGE="$MYSQL_IMAGE" \
@@ -146,12 +202,11 @@ echo " 后端: http://${BACKEND_HOST}:${BACKEND_PORT}"
echo " 前端: http://localhost:${FRONTEND_PORT}"
echo " API 代理: ${BACKEND_PROXY_TARGET}"
echo " MySQL: ${DB_HOST}:${DB_PORT}/${DB_NAME}"
echo " YYB Worker: ${DEV_YYB_WORKER_URL}"
echo " 退出: Ctrl+C"
echo "=============================="
echo ""
trap cleanup EXIT INT TERM
DB_HOST="$DB_HOST" \
DB_PORT="$DB_PORT" \
DB_NAME="$DB_NAME" \
@@ -161,6 +216,8 @@ DB_POOL_SIZE="$DB_POOL_SIZE" \
DB_MAX_OVERFLOW="$DB_MAX_OVERFLOW" \
DB_POOL_TIMEOUT="$DB_POOL_TIMEOUT" \
DB_POOL_RECYCLE="$DB_POOL_RECYCLE" \
YYB_WORKER_URL="$DEV_YYB_WORKER_URL" \
YYB_WORKER_KEY="$YYB_WORKER_KEY" \
LOG_LEVEL="$LOG_LEVEL" \
uv run uvicorn web.backend.main:app \
--host "$BACKEND_HOST" \
+25
View File
@@ -37,6 +37,9 @@ services:
- COOKIE_SECURE=${COOKIE_SECURE:-false}
# CORS 允许的源(逗号分隔)
- CORS_ORIGINS=${CORS_ORIGINS:-}
# 独立 yyb-worker 容器,只通过 Compose 默认内网访问。
- YYB_WORKER_URL=${YYB_WORKER_URL:-http://yyb-worker:8810}
- YYB_WORKER_KEY=${YYB_WORKER_KEY:-}
# Roundcube 邮件验证码读取服务地址(不填则使用代码默认值)
- MAIL_ROUNDCUBE_URL=${MAIL_ROUNDCUBE_URL:-}
# Uvicorn reload(生产环境保持 false
@@ -47,6 +50,8 @@ services:
depends_on:
mysql:
condition: service_healthy
yyb-worker:
condition: service_healthy
# 增加文件描述符限制,避免 "Too many open files" 错误
ulimits:
nofile:
@@ -83,8 +88,28 @@ services:
retries: 12
start_period: 20s
yyb-worker:
build:
context: ./services/yyb-worker
image: ${YYB_WORKER_IMAGE:-douyu-yyb-worker:local}
container_name: douyu-yyb-worker
restart: unless-stopped
environment:
- TZ=Asia/Shanghai
- YYB_WORKER_KEY=${YYB_WORKER_KEY:?请在 .env 中设置 YYB_WORKER_KEY}
volumes:
- yyb-worker-data:/app/config/worker-jobs
networks:
- default
healthcheck:
test: ["CMD", "python3", "-c", "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8810/health')"]
interval: 30s
timeout: 5s
retries: 3
volumes:
mysql-data:
yyb-worker-data:
networks:
order-site-net:
-312
View File
@@ -1,312 +0,0 @@
#!/usr/bin/env python3
"""查询斗鱼和平小店的完整账号数据。
使用方式:
1. 修改下面的 COOKIE;必要时修改活动配置。
2. 在仓库根目录运行:python3 douyu_xpd_query.py
Cookie 只在本机使用,不要提交到 git 或发送给他人。
"""
from __future__ import annotations
import json
import re
import sys
import time
from typing import Any
from urllib.parse import parse_qs, unquote, urlsplit
import requests
# ========================== 只需要修改这里 ==========================
COOKIE = ""
# 当前和平小店配置;活动更换后以斗鱼页面/项目配置中的值为准。
ACT_ALIAS = "20260623KDQFH"
ACT_ID = "46195"
ROOM_ID = "9263298"
# =====================================================================
def dump_json(value: Any) -> None:
"""以完整 JSON 输出,中文不转义。"""
print(json.dumps(value, ensure_ascii=False, indent=2, default=str))
class QueryError(RuntimeError):
pass
class XpdClient:
"""和平小店查询链路的最小独立实现。"""
UA = (
"Mozilla/5.0 (Linux; Android 12; HBN-AL00 Build/V417IR; wv) "
"AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 "
"Chrome/101.0.4951.61 Mobile Safari/537.36, Douyu_Android"
)
def __init__(self, cookie: str):
self.cookie = cookie.strip()
self.session = requests.Session()
self.session.trust_env = False
def _request(self, url: str, *, params: dict[str, Any], referer: str) -> requests.Response:
response = self.session.get(
url,
params=params,
headers={
"User-Agent": self.UA,
"Accept": "application/json, text/plain, */*",
"Accept-Language": "zh-CN,zh;q=0.9",
"X-Requested-With": "air.tv.douyu.android",
"Referer": referer,
"Cookie": self.cookie,
},
timeout=(8, 20),
)
response.raise_for_status()
return response
@staticmethod
def _parse_var(text: str, name: str) -> dict[str, Any]:
match = re.search(rf"var\s+{re.escape(name)}\s*=", text)
if not match:
raise QueryError(f"响应中找不到 var {name}: {text[:300]}")
chunk = text[match.end():].strip()
if chunk.endswith(";"):
chunk = chunk[:-1].rstrip()
try:
return json.loads(chunk)
except json.JSONDecodeError as exc:
raise QueryError(f"var {name} 不是合法 JSON: {text[:300]}") from exc
@staticmethod
def _int(value: Any) -> int | None:
try:
return int(value)
except (TypeError, ValueError):
return None
def _cookie_parts(self) -> dict[str, str]:
return {
item.split("=", 1)[0]: item.split("=", 1)[1]
for item in self.cookie.split("; ")
if "=" in item
}
def _token(self) -> str:
parts = self._cookie_parts()
for key in ("acf_uid", "acf_stk", "acf_ltkid"):
if not parts.get(key):
raise QueryError(f"Cookie 缺少 {key}")
return f"{parts['acf_uid']}_1_{parts['acf_stk']}_0_{parts['acf_ltkid']}"
def profile(self) -> dict[str, str]:
parts = self._cookie_parts()
return {
"uid": parts.get("acf_uid", ""),
"nickname": unquote(parts.get("acf_nickname", "")),
}
def embed(self) -> dict[str, Any]:
response = self._request(
"https://www.douyu.com/japi/carnival/nc/txEmbed/getIframeUrl",
params={"actAlias": ACT_ALIAS, "rid": ROOM_ID, "token": self._token()},
referer="https://www.douyu.com/topic/h5/hpxd01",
)
payload = response.json()
if payload.get("error") not in (0, "0", None):
raise QueryError(payload.get("msg") or f"获取 H5 参数失败: {payload}")
txurl = str((payload.get("data") or {}).get("txurlH5") or "")
if not txurl:
raise QueryError(f"响应没有 data.txurlH5: {payload}")
query = {key: values[0] for key, values in parse_qs(urlsplit(txurl).query).items() if values}
return {"query": query, "txurl_h5": txurl, "raw": payload}
def role(self, embed: dict[str, Any]) -> dict[str, Any]:
query = embed["query"]
response = self._request(
"https://apps.game.qq.com/daoju/igw/live",
params={
"acctype": "livelink", "_jsvar": "info",
"_service": "other.livelink.getrole.out", "_biz_code": "cjm",
"_act_id": ACT_ID, "_app_id": "2123", "isCode": "1",
"gameId": query.get("gameId", "cjm"), "actId": query.get("actId", ""),
"appId": query.get("appId", "bp_cf"), "livePlatId": query.get("livePlatId", "douyu"),
"code": query.get("code", ""), "timestamp": query.get("timestamp", ""),
"v": query.get("v", ""), "sig": query.get("sig", ""),
"authType": "delegate", "sAnchorId": ROOM_ID, "sVideoId": "",
},
referer="https://app.daoju.qq.com/",
)
info = self._parse_var(response.text, "info")
plat_id = info.get("platId")
return {
"game_open_id": str(info.get("gameOpenId") or ""),
"role_id": str(info.get("roleId") or ""),
"role_name": str(info.get("roleName") or ""),
"type": str(info.get("type") or ""),
# 0 是合法的 iOS 平台值,不能用 `or` 当作缺失处理。
"plat_id": str(plat_id) if plat_id is not None else "",
"raw": info,
}
def bind_info(self) -> dict[str, Any]:
response = self._request(
"https://www.douyu.com/japi/carnivalApi/v2/tencent/bindInfo",
params={"actAlias": ACT_ALIAS, "token": self._token()},
referer="https://www.douyu.com/",
)
payload = response.json()
if payload.get("error") not in (0, "0", None):
raise QueryError(payload.get("msg") or f"绑定信息查询失败: {payload}")
return payload
@staticmethod
def _area_id(role: dict[str, Any]) -> str:
"""优先使用 getrole 返回的大区,避免把 QQ/微信角色混查。"""
raw_area = role.get("raw", {}).get("area")
if raw_area not in (None, ""):
return str(raw_area)
return "1" if role.get("type") == "wx" else "2" if role.get("type") == "qq" else "1"
def _wallet_request(self, role: dict[str, Any], *, gamecoin: bool) -> dict[str, Any]:
areaid = self._area_id(role)
role_plat = role.get("plat_id")
plat = str(role_plat) if role_plat not in (None, "") else "1"
params = {
"_jsvar": "jbInfos" if gamecoin else "banlanceInfo",
"_service": "pay.idip.gamecoin.get" if gamecoin else "pay.midas.dq.get.ttpp",
"_app_id": "2123", "acctype": "ttpp", "areaid": areaid,
"eventid": "", "interwork": "0", "openid": role["game_open_id"],
"openkey": "openkey", "partition": "0", "pay_token": "",
"plat": plat, "plat_pc": "0", "roleid": role["role_id"],
"_biz_code": "cjm", "_act_id": ACT_ID, "_sid": "6",
}
if not gamecoin:
# 浏览器抓包使用毫秒时间戳;该参数也可避免复用旧请求。
params["_time"] = str(int(time.time() * 1000))
else:
params["coin_type"] = "1"
response = self._request("https://apps.game.qq.com/daoju/igw/live/", params=params, referer="https://app.daoju.qq.com/")
info = self._parse_var(response.text, params["_jsvar"])
return {"params": params, "raw": info}
def wallet(self, role: dict[str, Any]) -> dict[str, Any]:
"""查询点券钱包,raw 中保留服务端返回的全部字段。"""
result = self._wallet_request(role, gamecoin=False)
result["balance"] = self._int(result["raw"].get("balance"))
return result
def gamecoins(self, role: dict[str, Any]) -> dict[str, Any]:
"""查询车币、扭蛋币、夺宝碎片等全部 gamecoin 字段。"""
result = self._wallet_request(role, gamecoin=True)
raw = result["raw"]
names = {
"dq": "点券",
"jb": "车币",
"jb2": "扭蛋币",
"jb3": "夺宝碎片",
"jb4": "战备积分",
"jb5": "精英币",
}
currencies: dict[str, dict[str, Any]] = {}
for key, value in raw.items():
if re.fullmatch(r"(?:dq|jb|coin)\d*", key) and not key.endswith(("_name", "_rate")):
currencies[key] = {
"name": raw.get(f"{key}_name") or names.get(key, ""),
"value": self._int(value),
"rate": self._int(raw.get(f"{key}_rate")),
}
# 服务端有时只返回 jb4/jb5 的名称和倍率,不返回数值;保留为 None,
# 不把缺失误报成 0。gamecoin.dq 同样不覆盖 pay.midas 的点券 balance。
for key in ("jb4", "jb5"):
if key not in currencies and (f"{key}_name" in raw or f"{key}_rate" in raw):
currencies[key] = {
"name": raw.get(f"{key}_name") or names[key],
"value": self._int(raw.get(key)),
"rate": self._int(raw.get(f"{key}_rate")),
}
result["currencies"] = currencies
return result
def main() -> int:
if not COOKIE.strip():
print("请先在脚本顶部填写 COOKIE(完整斗鱼 Cookie 字符串)", file=sys.stderr)
return 2
client = XpdClient(COOKIE)
result: dict[str, Any] = {
"config": {
"act_alias": ACT_ALIAS,
"act_id": ACT_ID,
"room_id": ROOM_ID,
},
"profile": client.profile(),
}
try:
print("[1/6] 获取和平小店临时授权参数...", flush=True)
embed = client.embed()
result["embed"] = embed
print("[2/6] 获取绑定角色...", flush=True)
role = client.role(embed)
result["role"] = role
openid = str(role.get("game_open_id") or "")
roleid = str(role.get("role_id") or "")
raw_plat = role.get("plat_id")
plat = str(raw_plat) if raw_plat not in (None, "") else "1"
# 和平小店接口约定:微信大区 1,手Q大区 2。
areaid = client._area_id(role)
result["role_request"] = {
"openid": openid,
"roleid": roleid,
"plat": plat,
"areaid": areaid,
"wallet_key": f"{openid}|{roleid}|{plat}|{areaid}",
}
print("[3/6] 查询斗鱼侧小店绑定信息...", flush=True)
result["bind_info"] = client.bind_info()
if not openid or not roleid:
raise QueryError("没有获取到 gameOpenId/roleId,账号可能尚未绑定和平精英角色")
print("[4/6] 查询点券余额及钱包明细...", flush=True)
result["balance"] = client.wallet(role)
print("[5/6] 查询车币、扭蛋币、碎片等 gamecoin...", flush=True)
result["gamecoins"] = client.gamecoins(role)
print("[6/6] 输出角色诊断信息...", flush=True)
result["diagnosis"] = {
"message": "余额按 openid、roleid、plat、areaid 四项区分;不要把不同平台角色合并。",
"role_name": role.get("role_name", ""),
"role_type": role.get("type", ""),
"area": role.get("raw", {}).get("area"),
"partition": role.get("raw", {}).get("partition"),
"plat_id": role.get("plat_id", ""),
}
except (QueryError, requests.RequestException, OSError) as exc:
result["error"] = str(exc)
print(f"请求失败:{exc}", file=sys.stderr)
# 已成功拿到的字段仍然输出,方便定位是哪个接口失败。
dump_json(result)
return 1
except Exception as exc: # 保证脚本调试时也能输出已有数据
result["error"] = f"{type(exc).__name__}: {exc}"
print(f"未预期错误:{type(exc).__name__}: {exc}", file=sys.stderr)
dump_json(result)
return 1
dump_json(result)
return 0
if __name__ == "__main__":
raise SystemExit(main())
+5
View File
@@ -0,0 +1,5 @@
.git
node_modules
__pycache__
*.pyc
config
+18
View File
@@ -0,0 +1,18 @@
FROM node:20-bookworm-slim
ENV PYTHONUNBUFFERED=1 PYTHONDONTWRITEBYTECODE=1 NODE_ENV=production
RUN apt-get update \
&& apt-get install -y --no-install-recommends python3 python3-pip ca-certificates \
&& rm -rf /var/lib/apt/lists/* \
&& python3 -m pip install --no-cache-dir --break-system-packages --upgrade pip
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci --omit=dev
COPY requirements-worker.txt ./
RUN python3 -m pip install --no-cache-dir --break-system-packages -r requirements-worker.txt
COPY runtime/ ./
RUN mkdir -p /app/config/worker-jobs
EXPOSE 8810
HEALTHCHECK --interval=30s --timeout=5s --retries=3 CMD python3 -c "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8810/health')"
CMD ["python3", "scripts/yyb-worker.py", "--host", "0.0.0.0", "--port", "8810"]
+15
View File
@@ -0,0 +1,15 @@
# YYB Worker Runtime
此目录是部署到 `douyu_login_py` 的最小 YYB 运行时。构建上下文只包含 Worker 启动所需的 Python、Node/jsdom 依赖、协议脚本和静态重放数据。
- 源码分析和协议验证工作区仍在 `js_reverse/sites/yyb`;不要将该分析仓库上传到服务器。
- 不要向 `runtime/config/` 放入 Cookie、订单响应、二维码或付款链接。每个任务的动态数据仅写入 Docker volume 挂载的 `/app/config/worker-jobs/<job-id>/`
- `mall-order-template.json` 仅保留请求结构与非用户常量。登录态、商品、区服、角色、支付平台、动态令牌和加密字段均由当前任务运行时填充。
- 本服务没有 Compose 端口映射,只允许 `douyu-login` 通过默认 Compose 内网的 `http://yyb-worker:8810` 访问。`YYB_WORKER_KEY` 必须与后台服务配置一致。
本地独立验证:
```bash
docker build -t douyu-yyb-worker:local services/yyb-worker
docker run --rm -e YYB_WORKER_KEY=test-key douyu-yyb-worker:local
```
+781
View File
@@ -0,0 +1,781 @@
{
"name": "yyb-worker-runtime",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "yyb-worker-runtime",
"dependencies": {
"jsdom": "^24.1.3"
}
},
"node_modules/@asamuzakjp/css-color": {
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-3.2.0.tgz",
"integrity": "sha512-K1A6z8tS3XsmCMM86xoWdn7Fkdn9m6RSVtocUrJYIwZnFVkng/PvkEoWtOWmP+Scc6saYWHWZYbndEEXxl24jw==",
"license": "MIT",
"dependencies": {
"@csstools/css-calc": "^2.1.3",
"@csstools/css-color-parser": "^3.0.9",
"@csstools/css-parser-algorithms": "^3.0.4",
"@csstools/css-tokenizer": "^3.0.3",
"lru-cache": "^10.4.3"
}
},
"node_modules/@csstools/color-helpers": {
"version": "5.1.0",
"resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-5.1.0.tgz",
"integrity": "sha512-S11EXWJyy0Mz5SYvRmY8nJYTFFd1LCNV+7cXyAgQtOOuzb4EsgfqDufL+9esx72/eLhsRdGZwaldu/h+E4t4BA==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/csstools"
},
{
"type": "opencollective",
"url": "https://opencollective.com/csstools"
}
],
"license": "MIT-0",
"engines": {
"node": ">=18"
}
},
"node_modules/@csstools/css-calc": {
"version": "2.1.4",
"resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-2.1.4.tgz",
"integrity": "sha512-3N8oaj+0juUw/1H3YwmDDJXCgTB1gKU6Hc/bB502u9zR0q2vd786XJH9QfrKIEgFlZmhZiq6epXl4rHqhzsIgQ==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/csstools"
},
{
"type": "opencollective",
"url": "https://opencollective.com/csstools"
}
],
"license": "MIT",
"engines": {
"node": ">=18"
},
"peerDependencies": {
"@csstools/css-parser-algorithms": "^3.0.5",
"@csstools/css-tokenizer": "^3.0.4"
}
},
"node_modules/@csstools/css-color-parser": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-3.1.0.tgz",
"integrity": "sha512-nbtKwh3a6xNVIp/VRuXV64yTKnb1IjTAEEh3irzS+HkKjAOYLTGNb9pmVNntZ8iVBHcWDA2Dof0QtPgFI1BaTA==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/csstools"
},
{
"type": "opencollective",
"url": "https://opencollective.com/csstools"
}
],
"license": "MIT",
"dependencies": {
"@csstools/color-helpers": "^5.1.0",
"@csstools/css-calc": "^2.1.4"
},
"engines": {
"node": ">=18"
},
"peerDependencies": {
"@csstools/css-parser-algorithms": "^3.0.5",
"@csstools/css-tokenizer": "^3.0.4"
}
},
"node_modules/@csstools/css-parser-algorithms": {
"version": "3.0.5",
"resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-3.0.5.tgz",
"integrity": "sha512-DaDeUkXZKjdGhgYaHNJTV9pV7Y9B3b644jCLs9Upc3VeNGg6LWARAT6O+Q+/COo+2gg/bM5rhpMAtf70WqfBdQ==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/csstools"
},
{
"type": "opencollective",
"url": "https://opencollective.com/csstools"
}
],
"license": "MIT",
"engines": {
"node": ">=18"
},
"peerDependencies": {
"@csstools/css-tokenizer": "^3.0.4"
}
},
"node_modules/@csstools/css-tokenizer": {
"version": "3.0.4",
"resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-3.0.4.tgz",
"integrity": "sha512-Vd/9EVDiu6PPJt9yAh6roZP6El1xHrdvIVGjyBsHR0RYwNHgL7FJPyIIW4fANJNG6FtyZfvlRPpFI4ZM/lubvw==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/csstools"
},
{
"type": "opencollective",
"url": "https://opencollective.com/csstools"
}
],
"license": "MIT",
"engines": {
"node": ">=18"
}
},
"node_modules/agent-base": {
"version": "7.1.4",
"resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz",
"integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==",
"license": "MIT",
"engines": {
"node": ">= 14"
}
},
"node_modules/asynckit": {
"version": "0.4.0",
"resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz",
"integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==",
"license": "MIT"
},
"node_modules/call-bind-apply-helpers": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz",
"integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==",
"license": "MIT",
"dependencies": {
"es-errors": "^1.3.0",
"function-bind": "^1.1.2"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/combined-stream": {
"version": "1.0.8",
"resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz",
"integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==",
"license": "MIT",
"dependencies": {
"delayed-stream": "~1.0.0"
},
"engines": {
"node": ">= 0.8"
}
},
"node_modules/cssstyle": {
"version": "4.6.0",
"resolved": "https://registry.npmjs.org/cssstyle/-/cssstyle-4.6.0.tgz",
"integrity": "sha512-2z+rWdzbbSZv6/rhtvzvqeZQHrBaqgogqt85sqFNbabZOuFbCVFb8kPeEtZjiKkbrm395irpNKiYeFeLiQnFPg==",
"license": "MIT",
"dependencies": {
"@asamuzakjp/css-color": "^3.2.0",
"rrweb-cssom": "^0.8.0"
},
"engines": {
"node": ">=18"
}
},
"node_modules/cssstyle/node_modules/rrweb-cssom": {
"version": "0.8.0",
"resolved": "https://registry.npmjs.org/rrweb-cssom/-/rrweb-cssom-0.8.0.tgz",
"integrity": "sha512-guoltQEx+9aMf2gDZ0s62EcV8lsXR+0w8915TC3ITdn2YueuNjdAYh/levpU9nFaoChh9RUS5ZdQMrKfVEN9tw==",
"license": "MIT"
},
"node_modules/data-urls": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/data-urls/-/data-urls-5.0.0.tgz",
"integrity": "sha512-ZYP5VBHshaDAiVZxjbRVcFJpc+4xGgT0bK3vzy1HLN8jTO975HEbuYzZJcHoQEY5K1a0z8YayJkyVETa08eNTg==",
"license": "MIT",
"dependencies": {
"whatwg-mimetype": "^4.0.0",
"whatwg-url": "^14.0.0"
},
"engines": {
"node": ">=18"
}
},
"node_modules/debug": {
"version": "4.4.3",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
"integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
"license": "MIT",
"dependencies": {
"ms": "^2.1.3"
},
"engines": {
"node": ">=6.0"
},
"peerDependenciesMeta": {
"supports-color": {
"optional": true
}
}
},
"node_modules/decimal.js": {
"version": "10.6.0",
"resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz",
"integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==",
"license": "MIT"
},
"node_modules/delayed-stream": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz",
"integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==",
"license": "MIT",
"engines": {
"node": ">=0.4.0"
}
},
"node_modules/dunder-proto": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
"integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==",
"license": "MIT",
"dependencies": {
"call-bind-apply-helpers": "^1.0.1",
"es-errors": "^1.3.0",
"gopd": "^1.2.0"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/entities": {
"version": "6.0.1",
"resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz",
"integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==",
"license": "BSD-2-Clause",
"engines": {
"node": ">=0.12"
},
"funding": {
"url": "https://github.com/fb55/entities?sponsor=1"
}
},
"node_modules/es-define-property": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz",
"integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
}
},
"node_modules/es-errors": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz",
"integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
}
},
"node_modules/es-object-atoms": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz",
"integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==",
"license": "MIT",
"dependencies": {
"es-errors": "^1.3.0"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/es-set-tostringtag": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz",
"integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==",
"license": "MIT",
"dependencies": {
"es-errors": "^1.3.0",
"get-intrinsic": "^1.2.6",
"has-tostringtag": "^1.0.2",
"hasown": "^2.0.2"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/form-data": {
"version": "4.0.6",
"resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.6.tgz",
"integrity": "sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ==",
"license": "MIT",
"dependencies": {
"asynckit": "^0.4.0",
"combined-stream": "^1.0.8",
"es-set-tostringtag": "^2.1.0",
"hasown": "^2.0.4",
"mime-types": "^2.1.35"
},
"engines": {
"node": ">= 6"
}
},
"node_modules/function-bind": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
"integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/get-intrinsic": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
"integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==",
"license": "MIT",
"dependencies": {
"call-bind-apply-helpers": "^1.0.2",
"es-define-property": "^1.0.1",
"es-errors": "^1.3.0",
"es-object-atoms": "^1.1.1",
"function-bind": "^1.1.2",
"get-proto": "^1.0.1",
"gopd": "^1.2.0",
"has-symbols": "^1.1.0",
"hasown": "^2.0.2",
"math-intrinsics": "^1.1.0"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/get-proto": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz",
"integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==",
"license": "MIT",
"dependencies": {
"dunder-proto": "^1.0.1",
"es-object-atoms": "^1.0.0"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/gopd": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
"integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/has-symbols": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz",
"integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/has-tostringtag": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz",
"integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==",
"license": "MIT",
"dependencies": {
"has-symbols": "^1.0.3"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/hasown": {
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz",
"integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==",
"license": "MIT",
"dependencies": {
"function-bind": "^1.1.2"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/html-encoding-sniffer": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-4.0.0.tgz",
"integrity": "sha512-Y22oTqIU4uuPgEemfz7NDJz6OeKf12Lsu+QC+s3BVpda64lTiMYCyGwg5ki4vFxkMwQdeZDl2adZoqUgdFuTgQ==",
"license": "MIT",
"dependencies": {
"whatwg-encoding": "^3.1.1"
},
"engines": {
"node": ">=18"
}
},
"node_modules/http-proxy-agent": {
"version": "7.0.2",
"resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz",
"integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==",
"license": "MIT",
"dependencies": {
"agent-base": "^7.1.0",
"debug": "^4.3.4"
},
"engines": {
"node": ">= 14"
}
},
"node_modules/https-proxy-agent": {
"version": "7.0.6",
"resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz",
"integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==",
"license": "MIT",
"dependencies": {
"agent-base": "^7.1.2",
"debug": "4"
},
"engines": {
"node": ">= 14"
}
},
"node_modules/iconv-lite": {
"version": "0.6.3",
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz",
"integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==",
"license": "MIT",
"dependencies": {
"safer-buffer": ">= 2.1.2 < 3.0.0"
},
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/is-potential-custom-element-name": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz",
"integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==",
"license": "MIT"
},
"node_modules/jsdom": {
"version": "24.1.3",
"resolved": "https://registry.npmjs.org/jsdom/-/jsdom-24.1.3.tgz",
"integrity": "sha512-MyL55p3Ut3cXbeBEG7Hcv0mVM8pp8PBNWxRqchZnSfAiES1v1mRnMeFfaHWIPULpwsYfvO+ZmMZz5tGCnjzDUQ==",
"license": "MIT",
"dependencies": {
"cssstyle": "^4.0.1",
"data-urls": "^5.0.0",
"decimal.js": "^10.4.3",
"form-data": "^4.0.0",
"html-encoding-sniffer": "^4.0.0",
"http-proxy-agent": "^7.0.2",
"https-proxy-agent": "^7.0.5",
"is-potential-custom-element-name": "^1.0.1",
"nwsapi": "^2.2.12",
"parse5": "^7.1.2",
"rrweb-cssom": "^0.7.1",
"saxes": "^6.0.0",
"symbol-tree": "^3.2.4",
"tough-cookie": "^4.1.4",
"w3c-xmlserializer": "^5.0.0",
"webidl-conversions": "^7.0.0",
"whatwg-encoding": "^3.1.1",
"whatwg-mimetype": "^4.0.0",
"whatwg-url": "^14.0.0",
"ws": "^8.18.0",
"xml-name-validator": "^5.0.0"
},
"engines": {
"node": ">=18"
},
"peerDependencies": {
"canvas": "^2.11.2"
},
"peerDependenciesMeta": {
"canvas": {
"optional": true
}
}
},
"node_modules/lru-cache": {
"version": "10.4.3",
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz",
"integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==",
"license": "ISC"
},
"node_modules/math-intrinsics": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
"integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
}
},
"node_modules/mime-db": {
"version": "1.52.0",
"resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz",
"integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/mime-types": {
"version": "2.1.35",
"resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz",
"integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==",
"license": "MIT",
"dependencies": {
"mime-db": "1.52.0"
},
"engines": {
"node": ">= 0.6"
}
},
"node_modules/ms": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
"license": "MIT"
},
"node_modules/nwsapi": {
"version": "2.2.24",
"resolved": "https://registry.npmjs.org/nwsapi/-/nwsapi-2.2.24.tgz",
"integrity": "sha512-7YRhZ3jS45LwmSCT4b2sVFHt/WuovaktDU07QrtOBY2PXskss5a9jfmR9jptyumwXST+rFjrmppMY1KT/yn35A==",
"license": "MIT"
},
"node_modules/parse5": {
"version": "7.3.0",
"resolved": "https://registry.npmjs.org/parse5/-/parse5-7.3.0.tgz",
"integrity": "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==",
"license": "MIT",
"dependencies": {
"entities": "^6.0.0"
},
"funding": {
"url": "https://github.com/inikulin/parse5?sponsor=1"
}
},
"node_modules/psl": {
"version": "1.15.0",
"resolved": "https://registry.npmjs.org/psl/-/psl-1.15.0.tgz",
"integrity": "sha512-JZd3gMVBAVQkSs6HdNZo9Sdo0LNcQeMNP3CozBJb3JYC/QUYZTnKxP+f8oWRX4rHP5EurWxqAHTSwUCjlNKa1w==",
"license": "MIT",
"dependencies": {
"punycode": "^2.3.1"
},
"funding": {
"url": "https://github.com/sponsors/lupomontero"
}
},
"node_modules/punycode": {
"version": "2.3.1",
"resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz",
"integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==",
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/querystringify": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/querystringify/-/querystringify-2.2.0.tgz",
"integrity": "sha512-FIqgj2EUvTa7R50u0rGsyTftzjYmv/a3hO345bZNrqabNqjtgiDMgmo4mkUjd+nzU5oF3dClKqFIPUKybUyqoQ==",
"license": "MIT"
},
"node_modules/requires-port": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/requires-port/-/requires-port-1.0.0.tgz",
"integrity": "sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ==",
"license": "MIT"
},
"node_modules/rrweb-cssom": {
"version": "0.7.1",
"resolved": "https://registry.npmjs.org/rrweb-cssom/-/rrweb-cssom-0.7.1.tgz",
"integrity": "sha512-TrEMa7JGdVm0UThDJSx7ddw5nVm3UJS9o9CCIZ72B1vSyEZoziDqBYP3XIoi/12lKrJR8rE3jeFHMok2F/Mnsg==",
"license": "MIT"
},
"node_modules/safer-buffer": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
"integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
"license": "MIT"
},
"node_modules/saxes": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz",
"integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==",
"license": "ISC",
"dependencies": {
"xmlchars": "^2.2.0"
},
"engines": {
"node": ">=v12.22.7"
}
},
"node_modules/symbol-tree": {
"version": "3.2.4",
"resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz",
"integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==",
"license": "MIT"
},
"node_modules/tough-cookie": {
"version": "4.1.4",
"resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-4.1.4.tgz",
"integrity": "sha512-Loo5UUvLD9ScZ6jh8beX1T6sO1w2/MpCRpEP7V280GKMVUQ0Jzar2U3UJPsrdbziLEMMhu3Ujnq//rhiFuIeag==",
"license": "BSD-3-Clause",
"dependencies": {
"psl": "^1.1.33",
"punycode": "^2.1.1",
"universalify": "^0.2.0",
"url-parse": "^1.5.3"
},
"engines": {
"node": ">=6"
}
},
"node_modules/tr46": {
"version": "5.1.1",
"resolved": "https://registry.npmjs.org/tr46/-/tr46-5.1.1.tgz",
"integrity": "sha512-hdF5ZgjTqgAntKkklYw0R03MG2x/bSzTtkxmIRw/sTNV8YXsCJ1tfLAX23lhxhHJlEf3CRCOCGGWw3vI3GaSPw==",
"license": "MIT",
"dependencies": {
"punycode": "^2.3.1"
},
"engines": {
"node": ">=18"
}
},
"node_modules/universalify": {
"version": "0.2.0",
"resolved": "https://registry.npmjs.org/universalify/-/universalify-0.2.0.tgz",
"integrity": "sha512-CJ1QgKmNg3CwvAv/kOFmtnEN05f0D/cn9QntgNOQlQF9dgvVTHj3t+8JPdjqawCHk7V/KA+fbUqzZ9XWhcqPUg==",
"license": "MIT",
"engines": {
"node": ">= 4.0.0"
}
},
"node_modules/url-parse": {
"version": "1.5.10",
"resolved": "https://registry.npmjs.org/url-parse/-/url-parse-1.5.10.tgz",
"integrity": "sha512-WypcfiRhfeUP9vvF0j6rw0J3hrWrw6iZv3+22h6iRMJ/8z1Tj6XfLP4DsUix5MhMPnXpiHDoKyoZ/bdCkwBCiQ==",
"license": "MIT",
"dependencies": {
"querystringify": "^2.1.1",
"requires-port": "^1.0.0"
}
},
"node_modules/w3c-xmlserializer": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz",
"integrity": "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==",
"license": "MIT",
"dependencies": {
"xml-name-validator": "^5.0.0"
},
"engines": {
"node": ">=18"
}
},
"node_modules/webidl-conversions": {
"version": "7.0.0",
"resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-7.0.0.tgz",
"integrity": "sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==",
"license": "BSD-2-Clause",
"engines": {
"node": ">=12"
}
},
"node_modules/whatwg-encoding": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/whatwg-encoding/-/whatwg-encoding-3.1.1.tgz",
"integrity": "sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ==",
"deprecated": "Use @exodus/bytes instead for a more spec-conformant and faster implementation",
"license": "MIT",
"dependencies": {
"iconv-lite": "0.6.3"
},
"engines": {
"node": ">=18"
}
},
"node_modules/whatwg-mimetype": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-4.0.0.tgz",
"integrity": "sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==",
"license": "MIT",
"engines": {
"node": ">=18"
}
},
"node_modules/whatwg-url": {
"version": "14.2.0",
"resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-14.2.0.tgz",
"integrity": "sha512-De72GdQZzNTUBBChsXueQUnPKDkg/5A5zp7pFDuQAj5UFoENpiACU0wlCvzpAGnTkj++ihpKwKyYewn/XNUbKw==",
"license": "MIT",
"dependencies": {
"tr46": "^5.1.0",
"webidl-conversions": "^7.0.0"
},
"engines": {
"node": ">=18"
}
},
"node_modules/ws": {
"version": "8.21.3",
"resolved": "https://registry.npmjs.org/ws/-/ws-8.21.3.tgz",
"integrity": "sha512-201TZ/kPWxoPr/OKWjquZR1SWKXcvxdH+e1xrx89b3YbmzLMFCLfnaG1HFIgWzJOEWZ7MvpK++odZufgYR50Rw==",
"license": "MIT",
"engines": {
"node": ">=10.0.0"
},
"peerDependencies": {
"bufferutil": "^4.0.1",
"utf-8-validate": ">=5.0.2"
},
"peerDependenciesMeta": {
"bufferutil": {
"optional": true
},
"utf-8-validate": {
"optional": true
}
}
},
"node_modules/xml-name-validator": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz",
"integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==",
"license": "Apache-2.0",
"engines": {
"node": ">=18"
}
},
"node_modules/xmlchars": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz",
"integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==",
"license": "MIT"
}
}
}
+8
View File
@@ -0,0 +1,8 @@
{
"name": "yyb-worker-runtime",
"private": true,
"type": "module",
"dependencies": {
"jsdom": "^24.1.3"
}
}
@@ -0,0 +1,3 @@
curl_cffi>=0.13
pycryptodome>=3.20
segno>=1.6
@@ -0,0 +1,3 @@
{
"body": "{\"acct_id\":\"1\",\"call_param\":{\"call_func\":\"PlaceOrder\",\"call_param_json\":\"{\\\"app_id\\\":\\\"202406061128117473047424\\\",\\\"metadata\\\":{\\\"productItemId\\\":\\\"\\\",\\\"trace_key\\\":\\\"\\\",\\\"zone_name\\\":\\\"\\\"},\\\"offer_id\\\":\\\"1450243039\\\",\\\"content_id\\\":\\\"ct1755160919_GEOCGTMN\\\",\\\"res_offer_id\\\":\\\"800001492\\\",\\\"resourceid\\\":\\\"RES-A3UE3QOQSERB\\\",\\\"roleid\\\":\\\"\\\",\\\"sp_info\\\":\\\"\\\",\\\"zoneid\\\":\\\"\\\",\\\"product_list\\\":[{\\\"area\\\":\\\"\\\",\\\"partition\\\":\\\"\\\",\\\"platid\\\":\\\"1\\\",\\\"roleid\\\":\\\"\\\",\\\"rolename\\\":\\\"\\\",\\\"zoneid\\\":\\\"\\\",\\\"zonename\\\":\\\"\\\",\\\"product_id\\\":\\\"\\\",\\\"provide_offer_id\\\":\\\"\\\",\\\"quantity\\\":\\\"1\\\"}],\\\"saler\\\":\\\"\\\",\\\"app_metadata\\\":\\\"{\\\\\\\"personal\\\\\\\":\\\\\\\"0\\\\\\\"}\\\",\\\"resource_list\\\":[],\\\"water_extend\\\":\\\"{}\\\",\\\"pf\\\":\\\"\\\",\\\"device_type\\\":\\\"pc\\\",\\\"encrypt_msg\\\":\\\"\\\",\\\"web_token\\\":\\\"\\\",\\\"cipher_query\\\":\\\"\\\",\\\"game_coupon_id\\\":\\\"\\\"}\",\"call_type\":\"2\",\"login_check_param_json\":\"{\\\"openid\\\":\\\"\\\",\\\"openkey\\\":\\\"\\\",\\\"session_id\\\":\\\"\\\",\\\"session_type\\\":\\\"\\\",\\\"offer_id\\\":\\\"800001492\\\",\\\"wx_appid\\\":\\\"\\\",\\\"qq_appid\\\":\\\"\\\"}\"}}"
}
+891
View File
@@ -0,0 +1,891 @@
#!/usr/bin/env python3
"""YYB web_save encrypt_msg 最终交付(纯 Python,无浏览器依赖)。
链路(F-2046..F-2052,E3 已验证):
会话态(xMidasOps+key16/key1) + 订单参数
→ build_plaintext 21 字段明文(528 字符)
→ a:8 变换(标准 AES Te0-Te3,key16 按块轮转)
→ webSave(goodsBiz CHAOS VM 33 块变换)
→ encrypt_msg(1056 hex)
→ 提交 web_save → ret:0 + 微信支付 URL
用法:
python3 main.py verify # 回归测试:双向量 + 离线 + live 复现
python3 main.py gen --session S --order O # 仅生成 encrypt_msg(不联网)
python3 main.py submit --session S --order O [--appid A] [--body-tpl T]
# 生成 + 提交真实 web_save(需授权登录态)
python3 main.py sample-session # 用迁移的 live 捕获生成示例会话态
python3 main.py sample-order # 用迁移的 order7 生成示例订单
"""
from __future__ import annotations
import argparse
import json
import random
import re
import sys
import time
import urllib.request
import uuid
from pathlib import Path
ROOT = Path(__file__).resolve().parent
sys.path.insert(0, str(ROOT))
from pyvm.algorithm import build_plaintext, generate_encrypt_msg, generate_encrypt_msg_offline # noqa: E402
from pyvm.mall import MallSession, generate_encrypt_msg as mall_generate # noqa: E402
from pyvm.session import SessionState, load_session # noqa: E402
from pyvm.login_profile import midas_login_params # noqa: E402
REPLAY = ROOT / "replay"
DEFAULT_APPID = "1450243039"
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"]
# ---------------------------------------------------------------- 工具
def _load_cap(path: Path) -> dict:
"""加载 deepCap 捕获 JSON,兼容三种存档格式,返回顶层 dict(含 C 键)。
格式1: 直接 JSON 对象 {"u":85091,"C":[...]}
格式2: 双重编码 JSON 字符串 '"{\"u\":85091,...}"'(json.dumps(json.dumps(x)))
格式3: 手动转义去外层引号 {\"u\":85091,...}(replace('"','\\"') 后丢外层引号)
"""
raw = path.read_text().strip()
d = None
for attempt in (
lambda: json.loads(raw),
lambda: json.loads(json.loads(raw)),
lambda: json.loads(json.loads('"' + raw + '"')),
):
try:
d = attempt()
if isinstance(d, dict) and "C" in d:
return d
except Exception:
continue
raise ValueError(f"无法解析 deepCap 文件: {path}(前 80 字符: {raw[:80]!r})")
def _vector_of(body_path: Path) -> str:
"""从归档 body.json(json 双重编码)提取 encrypt_msg 期望值。"""
body = json.loads(json.loads(body_path.read_text().strip()))
bs = body["body"]
i = bs.find("encrypt_msg=")
return bs[i + len("encrypt_msg="): i + len("encrypt_msg=") + 1056]
def load_order(path: str | Path) -> dict:
p = Path(path)
if not p.exists():
raise FileNotFoundError(f"订单文件不存在: {p}")
return json.loads(p.read_text(encoding="utf-8"))
def parse_plaintext(path: str | Path) -> dict:
"""解析归档明文:JSON dict 或 key=value&... 两种格式自动识别。"""
raw = Path(path).read_text(encoding="utf-8").strip()
try:
d = json.loads(raw)
if isinstance(d, dict):
return {str(k): str(v) for k, v in d.items()}
except Exception:
pass
fields: dict[str, str] = {}
for kv in raw.split("&"):
k, _, v = kv.partition("=")
fields[k] = v
return fields
# ---------------------------------------------------------------- 命令
def cmd_verify(args=None) -> int:
"""回归测试:双向量(runA/runB)+ 离线(e2e/vector-b)+ live order7 复现。"""
fails = 0
def check(name: str, got: str, expected: str) -> None:
nonlocal fails
ok = len(got) == 1056 and got == expected
print(f" [{'' if ok else ''}] {name}: "
f"{'1056/1056' if ok else f'失配 len={len(got)}'}")
if not ok:
fails += 1
print(f" got head: {got[:32]}...")
print(f" exp head: {expected[:32]}...")
print("== 1. 深拷贝 18 参向量(generate_encrypt_msg)==")
for name, args_p, cb_p, body_p in [
("runA", "deepcaps/cap-85091.json", "deepcaps2/cap-69667.json", "deepcaps/body.json"),
("runB", "deepcaps2/cap-85091.json", "deepcaps2/cap-69667.json", "deepcaps2/body.json"),
]:
got = generate_encrypt_msg(str(REPLAY / args_p), str(REPLAY / cb_p))
check(name, got, _vector_of(REPLAY / body_p))
print("== 2. 离线生成(generate_encrypt_msg_offline,随机 key16/key1)==")
for name, plain_p, body_p_or_vector in [
("offline-runA(e2e)", "e2e/plaintext.json", "e2e/body.json"),
("offline-runB", "deepcaps2/plaintext.json", "vector-20260810b.json"),
]:
fields = parse_plaintext(REPLAY / plain_p)
params = {k: fields[k] for k in ORDER_FIELDS}
got = generate_encrypt_msg_offline(
params, fields["fk_extend"], fields["ts"], fields["_rand"])
if body_p_or_vector.endswith(".json") and "vector" in body_p_or_vector:
expected = json.loads((REPLAY / body_p_or_vector).read_text())["encrypt_msg"]
else:
expected = _vector_of(REPLAY / body_p_or_vector)
check(name, got, expected)
print("== 3. live 会话态复现(order7,精确 key)==")
fields = parse_plaintext(REPLAY / "live/order7-plaintext.txt")
params = {k: fields[k] for k in ORDER_FIELDS}
xmidas = json.loads((REPLAY / "live/order7-xmidasops.json").read_text())
cap = _load_cap(REPLAY / "live/caps7/cap-85091.json")
from pyvm.algorithm import decode_d
web_args = decode_d(cap["C"][2])
got = generate_encrypt_msg_offline(
params, fields["fk_extend"], fields["ts"], fields["_rand"],
xmidas=xmidas, args_template=web_args,
key16=web_args[6][0], key1=web_args[0][0],
)
expected = (REPLAY / "live/order7-page-encrypt.txt").read_text().strip()
check("live-order7", got, expected)
print("== 4. 更多 live 会话复现(order10 / order12,精确 key)==")
for od in ["order10", "order12"]:
d = REPLAY / "live" / od
fields = parse_plaintext(d / "plaintext.txt")
params = {k: fields[k] for k in ORDER_FIELDS}
keys = json.loads((d / "keys.json").read_text())
xmidas = json.loads((d / "xmidasops.json").read_text())
web_args = decode_d(_load_cap(d / "caps" / "cap-85091.json")["C"][2])
got = generate_encrypt_msg_offline(
params, fields["fk_extend"], fields["ts"], fields["_rand"],
xmidas=xmidas, args_template=web_args,
key16=keys["key16"], key1=keys["key1"],
)
body = (d / "body.txt").read_text()
m = re.search(r"encrypt_msg=([0-9a-f]{1056})", body)
check(f"live-{od}", got, m.group(1) if m else "")
print()
if fails:
print(f"{fails} 项失败")
return 1
print("✅ 全部通过(E3 双向量 + 离线 + live 复现 ×5)")
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,
args_template=args_tpl,
key16=st.key16, key1=st.key1,
)
def cmd_gen(args) -> int:
st = load_session(args.session)
order = load_order(args.order)
params = {k: order.get(k, "") for k in ORDER_FIELDS}
hex_msg = _gen_with_session(st, order)
out = Path(args.output) if args.output else ROOT / "config" / "encrypt_msg.txt"
out.parent.mkdir(parents=True, exist_ok=True)
out.write_text(hex_msg + "\n")
print(f"encrypt_msg ({len(hex_msg)} hex) → {out}")
print(hex_msg)
return 0
def _build_body(order: dict, st: SessionState, offline_hex: str, body_tpl: Path) -> str:
"""在归档 body 模板上刷新订单字段与动态值。"""
body = json.loads(json.loads(body_tpl.read_text()))["body"]
# 刷新订单绑定字段(明文 18 字段中出现在 body 里的)
for k in ORDER_FIELDS:
if k in ("ts", "from_h5"):
continue
v = str(order.get(k, ""))
body = re.sub(rf"{k}=[^&]*", f"{k}=" + v, body, count=1)
# 动态值
new_pc = str(uuid.uuid4()).upper() + str(int(time.time() * 1000))
body = re.sub(r"pc_st=[^&]+", "pc_st=" + new_pc, body)
body = re.sub(r"r=[0-9.]+", "r=" + str(random.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=" + offline_hex, body)
return body
def cmd_submit(args) -> int:
st = load_session(args.session)
order = load_order(args.order)
if not st.cookies:
print("⚠️ 会话态缺少 cookies —— 提交真实 web_save 需要登录态。")
print(" 仅生成模式请用: python3 main.py gen --session ... --order ...")
return 2
hex_msg = _gen_with_session(st, order)
body_tpl = Path(args.body_tpl) if args.body_tpl else REPLAY / "e2e" / "body.json"
body = _build_body(order, st, hex_msg, body_tpl)
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"),
headers={
"User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) "
"AppleWebKit/537.36 (KHTML, like Gecko) Chrome/150.0.0.0 Safari/537.36",
"Content-Type": "application/x-www-form-urlencoded",
"Origin": "https://pay.qq.com",
"Referer": "https://pay.qq.com/",
"Cookie": cookie_str,
},
method="POST",
)
print(f"[submit] POST {url}")
try:
with urllib.request.urlopen(req, timeout=30) as resp:
raw = resp.read().decode("utf-8", "replace")
except urllib.error.HTTPError as e:
raw = e.read().decode("utf-8", "replace")
except Exception as e: # noqa: BLE001
print(f"[submit] 网络错误: {e!r}")
return 3
print(f"[submit] 响应: {raw[:600]}")
try:
js = json.loads(raw)
ret = js.get("ret")
if ret == 0:
print("✅ web_save ret:0 —— 加密通过,支付流程继续")
return 0
print(f"❌ web_save ret:{ret}({js.get('err_code', '')})—— 见 case 踩坑记录")
return 1
except Exception:
return 1
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(
xmidas_ops=json.loads((REPLAY / "live/order7-xmidasops.json").read_text()),
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),
cookies={},
source="live/order7 (归档示例,cookies 为空)",
)
out = Path(args.output) if args.output else ROOT / "config" / "session-state.json"
out.parent.mkdir(parents=True, exist_ok=True)
out.write_text(json.dumps(st.to_json(), ensure_ascii=False, indent=2) + "\n")
print(f"示例会话态 → {out}(仅含归档 key16/key1/xmidas_ops,cookies 需自行捕获)")
return 0
def cmd_sample_order(args) -> int:
fields = parse_plaintext(REPLAY / "live/order7-plaintext.txt")
out = Path(args.output) if args.output else ROOT / "config" / "order.json"
out.parent.mkdir(parents=True, exist_ok=True)
out.write_text(json.dumps(fields, ensure_ascii=False, indent=2) + "\n")
print(f"示例订单 → {out}")
return 0
# ---------------------------------------------------------------- main
# ---------------------------------------------------------------- mall 命令
def cmd_mall_verify(args=None) -> int:
"""E3 黄金对验证:同会话 transform_input + xMidasOps → encrypt_msg 完全一致。"""
g = json.loads((ROOT / "config/golden/golden-final.json").read_text(encoding="utf-8"))
session = MallSession(g["transform_input"], g["xmidas_ops"])
hexout = mall_generate(session)
expected = g["encrypt_msg_hex"]
ok = hexout == expected
print(f" [{'' if ok else ''}] mall 黄金对复现: {('完全一致 ' + hexout[:24] + '...') if ok else '失配'}")
print(" xMidasOps:", len(session.xmidas_ops), "| encrypt_msg:", len(expected), "hex")
return 0 if ok else 1
def cmd_mall_gen(args) -> int:
"""从 mall 会话态生成 encrypt_msg(纯 Python,E3)。"""
d = json.loads(Path(args.session).read_text(encoding="utf-8"))
session = MallSession(d["transform_input"], d["xmidas_ops"])
hexout = mall_generate(session)
out = Path(args.output) if args.output else ROOT / "config" / "mall-encrypt_msg.txt"
out.parent.mkdir(parents=True, exist_ok=True)
out.write_text(hexout + "\n")
print(f"mall encrypt_msg ({len(hexout)} hex) → {out}")
print(hexout[:64] + "...")
return 0
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"))
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)
out.write_text(json.dumps(session.to_json(), ensure_ascii=False, indent=2) + "\n")
print(f"示例 mall 会话态 → {out}(含 transform_input + xMidasOps 59620)")
return 0
def cmd_mall_submit(args) -> int:
"""生成 encrypt_msg + 提交 PlaceOrder(纯 Python)。
会话态(transform_input + xMidasOps + cookies)来自浏览器采集;
订单模板(mall-order-template.json)来自采集的 PlaceOrder 请求体。
用 Python 生成的 encrypt_msg 替换模板中的原生值后提交。
"""
d = json.loads(Path(args.session).read_text(encoding="utf-8"))
session = MallSession(d["transform_input"], d["xmidas_ops"])
cookies = d.get("cookies", {})
if not cookies:
print("⚠️ 会话态缺少 cookies(登录态)——采集脚本需在登录后保存 cookie")
return 2
hex_msg = mall_generate(session)
print(f"Python encrypt_msg: {hex_msg[:32]}...")
tpl = json.loads(Path(args.order_template).read_text(encoding="utf-8"))
body = tpl["body"]
body_obj = json.loads(body)
_refresh_login_check(body_obj, cookies)
cpj = json.loads(body_obj["call_param"]["call_param_json"])
cpj["encrypt_msg"] = hex_msg
body_obj["call_param"]["call_param_json"] = json.dumps(cpj, ensure_ascii=False)
new_body = json.dumps(body_obj, ensure_ascii=False)
url = "https://pagedooapi.pay.qq.com/api/CommonCallMpgo?t=" + str(int(time.time() * 1000))
cookie_str = "; ".join(f"{k}={v}" for k, v in cookies.items())
# midas 域 cookie 兜底(profile 导出常缺 midas_openid/midas_openkey,值为 openid/accesstoken)
if "midas_openid" not in cookies and "openid" in cookies:
cookie_str += "; midas_openid=" + cookies["openid"]
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"),
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": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) "
"AppleWebKit/537.36 (KHTML, like Gecko) Chrome/151.0.0.0 Safari/537.36",
},
method="POST",
)
print(f"[submit] POST {url}")
try:
with urllib.request.urlopen(req, timeout=30) as resp:
raw = resp.read().decode("utf-8", "replace")
except urllib.error.HTTPError as e:
raw = e.read().decode("utf-8", "replace")
except Exception as e: # noqa: BLE001
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"
js = None
ret = None
try:
js = json.loads(raw)
if isinstance(js, dict):
ret = js.get("ret", js.get("result_code", js.get("code")))
except Exception:
pass
ok = ret in (0, "0")
if ok:
out.write_text(raw, encoding="utf-8")
print(f"✅ 提交完成(ret={ret}),响应已保存 → {out}")
return 0
# 失败:不覆盖上次成功响应,另存 .fail.json
fail_out = out.with_suffix(".fail.json")
fail_out.write_text(raw, encoding="utf-8")
info = ""
if isinstance(js, dict):
info = str(js.get("result_info") or js.get("msg") or js.get("err_code") or "")
print(f"❌ 提交失败(ret={ret}{info}")
print(f" 失败响应 → {fail_out}(保留上次成功响应)")
if ret in ("1018", 1018):
print(" 原因: mall 登录态失效——请重新在浏览器登录并采集 mall 会话态")
print(" (node scripts/capture-mall-data.mjs,或手动刷新 mall-session.json 的 cookies)")
return 1
def cmd_mall_capture(args) -> int:
"""用浏览器捕获 mall 会话态(需授权登录,Node 脚本)。"""
print("mall 会话态捕获(需在浏览器完成一次详情页购买):")
print(" node scripts/capture-mall-session.mjs --url '<详情页URL>' --duration 600")
print(" → config/golden-pair.frames.jsonl → python3 main.py mall sample")
return 0
def main() -> int:
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)")
gsub = pg.add_subparsers(dest="cmd", required=True)
gsub.add_parser("verify", help="E3 回归(双向量 + 离线 + live 复现)")
p = gsub.add_parser("gen", help="仅生成 encrypt_msg(不联网)")
p.add_argument("--session", required=True)
p.add_argument("--order", required=True)
p.add_argument("--output", default=None)
p = gsub.add_parser("submit", help="生成 + 提交真实 web_save(需授权登录态)")
p.add_argument("--session", required=True)
p.add_argument("--order", required=True)
p.add_argument("--appid", default=DEFAULT_APPID)
p.add_argument("--url", default=None)
p.add_argument("--body-tpl", default=None)
p = gsub.add_parser("sample-session", help="用归档 live 捕获生成示例会话态")
p.add_argument("--output", default=None)
p = gsub.add_parser("sample-order", help="用归档 order7 生成示例订单")
p.add_argument("--output", default=None)
# ---- mall 组 ----
pm = sub.add_parser("mall", help="mall 侧(PlaceOrder,pagedoo VM 108 opcode)")
msub = pm.add_subparsers(dest="cmd", required=True)
msub.add_parser("verify", help="E3 黄金对验证(e377650 复现 encrypt_msg)")
p = msub.add_parser("gen", help="从 mall 会话态生成 encrypt_msg(纯 Python)")
p.add_argument("--session", required=True)
p.add_argument("--output", default=None)
p = msub.add_parser("sample-session", help="从捕获或归档生成 mall 会话态")
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("--output", default=None)
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("--offer-id", default=None, help="当前商品服务端 offer ID")
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("--zone-id", default=None, help="游戏区服 ID;默认使用模板当前区服")
p.add_argument("--zone-name", default=None, help="游戏区服名称")
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.add_argument("--appid", default=DEFAULT_APPID)
p.add_argument("--output", default=None, help="二维码 PNG 输出路径")
msub.add_parser("capture", help="用浏览器捕获 mall 会话态(Node 脚本)")
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)
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 2
except (FileNotFoundError, ValueError) as e:
print(f"错误: {e}")
return 2
def build_mall_transform(fixed: dict) -> list:
"""仅CK全自动:固定槽模板 + 随机 key16/槽6/明文缓冲 构造 transform_input。
E3 验证(2026-08-11):mall 服务端不校验 encrypt_msg 内容——随机 key16/槽6/
明文缓冲(槽10)提交均 ret=0;仅需固定槽(Te/S-box/表)+ GetPayToken arrays。
⚠️ 风险标记:随机 key/明文缓冲目前可用,但服务端可能后续校验加密内容,
或风控严格后拒绝随机加密(如 goods 侧随机 key 已被拒 1099)。若风控升级,
`mall auto` 可能失效,需回退到真实会话态采集(capture-mall-data.mjs)。
"""
import random
ti = [None] * 18
for i in (1, 2, 3, 4, 5, 7, 8, 9, 11, 12, 13, 14, 15, 16, 17):
ti[i] = json.loads(json.dumps(fixed[str(i)]))
ti[0] = [[random.randint(0, 255) for _ in range(16)]]
ti[6] = [[random.randint(0, 255) for _ in range(16)]]
ti[10] = [[random.randint(0, 255) for _ in range(624)]]
return ti
def mall_getpaytoken(cookies: dict) -> tuple[list, str]:
"""GetPayToken(纯HTTP,仅需 cookies)→ (arrays 59620, pay_token)。
arrays = mall xMidasOps 来源(页面级);pay_token = mall web_token。
证据: evidence/getpaytoken-pure-http.json
"""
import urllib.request
login = midas_login_params(cookies)
login["offer_id"] = "800001492"
body = {
"acct_id": "1",
"call_param": {
"call_func": "GetPayToken",
"call_param_json": json.dumps(
{"version": "pagedoo-v2.0.0", "app_id": "202406061128117473047424",
"content_id": "ct1755160919_GEOCGTMN"}),
"call_type": "security_service",
"login_check_param_json": json.dumps(login),
},
}
url = "https://pagedooapi.pay.qq.com/api/CommonCallMpgo?t=" + str(int(time.time() * 1000))
cookie_str = "; ".join(f"{k}={v}" for k, v in cookies.items())
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": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) "
"AppleWebKit/537.36 (KHTML, like Gecko) Chrome/151.0.0.0 Safari/537.36",
}, 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")
js = json.loads(raw)
cr = json.loads(js["data"]["call_reply"])
data = cr.get("data", {})
return data.get("arrays", []), data.get("pay_token", "")
def _refresh_login_check(body_obj: dict, cookies: dict) -> None:
"""刷新 PlaceOrder 请求的 login_check_param_json(openid/openkey 等)为当前 CK。
模板(archived)中的 openid/openkey 是采集时的旧值,过期后必须用当前
mall-session cookies 替换,否则 pagedoo 返回 1018 login state check failed。
"""
cp = body_obj.get("call_param", {})
try:
lcp = json.loads(cp.get("login_check_param_json", "{}"))
except Exception: # noqa: BLE001
lcp = {}
lcp.update(midas_login_params(cookies))
if "offer_id" not in lcp:
lcp["offer_id"] = "800001492"
cp["login_check_param_json"] = json.dumps(lcp, ensure_ascii=False)
def _apply_card_selection(payload: dict, args) -> None:
"""将用户显式指定的和平精英商品、角色和平台写入本次 PlaceOrder 载荷。
模板提供产品的服务端关联字段;这里只覆盖用户选择的商品、角色和平台字段,
不会修改磁盘上的模板,也不会猜测不同点券档位对应的 product_id。
"""
quantity = getattr(args, "quantity", 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):
raise ValueError("订单模板缺少 product_list[0]")
product = products[0]
product_id = getattr(args, "product_id", None)
if product_id:
product["product_id"] = product_id
metadata = payload.setdefault("metadata", {})
if isinstance(metadata, dict):
metadata["productItemId"] = product_id
offer_id = getattr(args, "offer_id", None)
if offer_id:
product["provide_offer_id"] = offer_id
if quantity is not None:
product["quantity"] = str(quantity)
role_id = getattr(args, "role_id", None)
if role_id:
product["roleid"] = role_id
payload["roleid"] = role_id
role_name = getattr(args, "role_name", None)
if role_name:
product["rolename"] = role_name
zone_id = getattr(args, "zone_id", None)
if zone_id:
product["zoneid"] = zone_id
product["area"] = zone_id
payload["zoneid"] = zone_id
zone_name = getattr(args, "zone_name", None)
if zone_name:
product["zonename"] = zone_name
metadata = payload.setdefault("metadata", {})
if isinstance(metadata, dict):
metadata["zone_name"] = zone_name
metadata = payload.setdefault("metadata", {})
if isinstance(metadata, dict):
metadata["trace_key"] = uuid.uuid4().hex
pf = getattr(args, "pf", None)
if pf:
payload["pf"] = pf
def cmd_mall_auto(args) -> int:
"""仅凭 CK 全自动下单(纯HTTP + 纯Python,无浏览器采集)。
链路:GetPayToken(纯HTTP)→ arrays+xMidasOps + pay_token(web_token)
→ 构造 transform_input(固定槽+随机key/明文缓冲)
→ 纯 Python 生成 encrypt_msg → PlaceOrder 提交
"""
d = json.loads(Path(args.session).read_text(encoding="utf-8"))
cookies = d.get("cookies", {})
if not cookies:
print("⚠️ 缺少 cookies(登录态)")
return 2
print("[auto] ① GetPayToken(纯HTTP)...")
arrays, pay_token = mall_getpaytoken(cookies)
if len(arrays) != 59620:
print(f"❌ arrays 长度 {len(arrays)} != 59620")
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"))
ti = build_mall_transform(fixed)
print("[auto] ③ 纯 Python 生成 encrypt_msg...")
session = MallSession(ti, arrays)
hex_msg = mall_generate(session)
print(f"[auto] encrypt_msg ({len(hex_msg)} hex): {hex_msg[:24]}...")
print("[auto] ④ PlaceOrder 提交...")
tpl = json.loads(Path(args.order_template).read_text(encoding="utf-8"))
body_obj = json.loads(tpl["body"])
_refresh_login_check(body_obj, cookies)
cpj = json.loads(body_obj["call_param"]["call_param_json"])
_apply_card_selection(cpj, args)
cpj["encrypt_msg"] = hex_msg
cpj["web_token"] = pay_token
body_obj["call_param"]["call_param_json"] = json.dumps(cpj, ensure_ascii=False)
new_body = json.dumps(body_obj, ensure_ascii=False)
cookie_str = "; ".join(f"{k}={v}" for k, v in cookies.items())
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]
url = "https://pagedooapi.pay.qq.com/api/CommonCallMpgo?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": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) "
"AppleWebKit/537.36 (KHTML, like Gecko) Chrome/151.0.0.0 Safari/537.36",
}, 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.write_text(raw, encoding="utf-8")
js = json.loads(raw)
ret = js.get("result_code")
if ret == "0":
cr = json.loads(js["data"]["call_reply"])
print(f"✅ 仅CK全自动下单成功! token={cr['data']['token'][:24]}...")
print(f" 响应已保存 → {out}")
return 0
print(f"❌ 下单失败 ret={ret} ({js.get('result_info', '')})")
return 1
def cmd_mall_pay(args) -> int:
"""mall 下单 -> goods web_save -> 微信支付二维码(纯 Python,终极闭环)。
数据流:
① mall auto 下单(PlaceOrder 响应含 url_params -> goods 页)
② capture-goods-(auto|session).mjs 采集 goods 会话态
(xmidasops/key16/key1/args-template/web-token/body.txt)
③ 本命令:从捕获 body 取订单字段(web_token 绑定该订单),
recover_plaintext_from_buffer 恢复页面真实明文(ts/fk_extend/_rand),
纯 Python 生成 encrypt_msg -> 构造 web_save body -> 提交
-> channel_info.sign(weixin://wxpay/bizpayurl?pr=...)
E3(2026-08-11):同会话 4 变体全部 ret=0(页面body/精确纯py/新鲜ts/全流程),
web_save 不消费订单,同一订单可重复提交。
"""
# 1. goods 会话态 + 捕获 body(web_token 与订单绑定,同一次页面加载)
gd = Path(args.goods_dir)
if not gd.is_dir():
print(f"❌ goods 会话目录不存在: {gd}")
return 2
body_tpl = (gd / "body.txt").read_text(encoding="utf-8")
if not body_tpl:
print("❌ 捕获目录缺少 body.txt(web_save 请求体)")
return 2
body_fields = parse_plaintext(gd / "body.txt")
token_id = body_fields.get("token_id", "")
transaction_id = body_fields.get("transaction_id", "")
out_trade_no = body_fields.get("out_trade_no", "")
offer_type = body_fields.get("offer_type", "0")
if not token_id or not out_trade_no:
print(f"❌ 捕获 body 缺少 token 字段: token_id={token_id[:20]!r}")
return 2
# mall 响应仅作核对(若换了新订单,web_token 不适用,须重采 goods 会话)
resp_path = Path(args.mall_response)
if resp_path.exists():
try:
resp = json.loads(resp_path.read_text(encoding="utf-8"))
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(" goods web_token 绑定捕获页面订单,以捕获 body 订单为准继续")
except Exception as e: # noqa: BLE001
print(f"⚠️ mall 响应读取失败(忽略): {e!r}")
print(f"[pay] 订单 token_id={token_id[:20]}... out_trade_no={out_trade_no}")
# 2. 会话态(key16/key1/xmidas/args-template/web_token)
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 转储)
web_args = json.loads(at_file.read_text(encoding="utf-8"))
else:
# 旧版采集:deepCap D 编码(cap-85091.json)
web_args = decode_d(_load_cap(gd / "caps" / "cap-85091.json")["C"][2])
wt = gd / "web-token.txt"
web_token = wt.read_text(encoding="utf-8").strip() if wt.exists() else ""
body_tpl = (gd / "body.txt").read_text(encoding="utf-8")
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]}...")
# 3. 恢复页面真实明文(ts/fk_extend/_rand),刷新 ts 为当前一致值
try:
rec_plain = recover_plaintext_from_buffer(web_args[10][0], keys["key16"])
fields = dict(kv.split("=", 1) for kv in rec_plain.split("&"))
fk_extend = fields.get("fk_extend", "")
rand_val = fields.get("_rand", "")
rec_ts = fields.get("ts", "")
except Exception as e: # noqa: BLE001
print(f"⚠️ 缓冲明文恢复失败,回退 plaintext.txt: {e!r}")
fields = parse_plaintext(gd / "plaintext.txt")
fk_extend = fields.get("fk_extend", "")
rand_val = fields.get("_rand", "")
rec_ts = ""
params = {k: body_fields.get(k, fields.get(k, "")) for k in ORDER_FIELDS}
params["token_id"] = token_id
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("[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"),
)
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)]:
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"r=[0-9.]+", "r=" + str(random.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)
if web_token:
body = re.sub(r"web_token=[0-9A-F]+", "web_token=" + web_token, body)
# 5. cookies: mall 登录态(midas 域 cookie 兜底)
sess = json.loads(Path(args.session).read_text(encoding="utf-8"))
cookies = dict(sess.get("cookies", {}))
if not cookies:
print("⚠️ 会话态缺少 cookies(登录态)——mall-session.json 需在登录后保存")
return 2
cookie_str = "; ".join(f"{k}={v}" for k, v in cookies.items())
if "midas_openid" not in cookies and "openid" in cookies:
cookie_str += "; midas_openid=" + cookies["openid"]
if "midas_openkey" not in cookies and "accesstoken" in cookies:
cookie_str += "; midas_openkey=" + cookies["accesstoken"]
# 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"),
headers={
"User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) "
"AppleWebKit/537.36 (KHTML, like Gecko) Chrome/151.0.0.0 Safari/537.36",
"Content-Type": "application/x-www-form-urlencoded",
"Origin": "https://pay.qq.com",
"Referer": "https://pay.qq.com/",
"Cookie": cookie_str,
},
method="POST",
)
print(f"[pay] POST {url}")
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")
except Exception as e: # noqa: BLE001
print(f"[pay] 网络错误: {e!r}")
return 3
print(f"[pay] 响应: {raw[:500]}")
# 7. 解析支付通道
try:
js = json.loads(raw)
except Exception: # noqa: BLE001
js = {}
ret = js.get("ret")
if ret != 0:
print(f"❌ web_save ret:{ret} ({js.get('err_code', '')}) {js.get('msg', '')}")
return 1
info = js.get("info", {})
ci = info.get("channel_info", {})
sign = ci.get("sign", "")
if not sign:
print("❌ 未返回支付 sign,请检查响应")
return 1
print(f"✅ 支付通道建立: serialno={ci.get('serialno', '')}")
print(f" 微信支付链接: {sign}")
# 8. 渲染二维码(segno)
try:
import segno
except ImportError:
print("⚠️ 未安装 segno,跳过二维码渲染(pip install segno)")
return 0
out_png = Path(args.output) if args.output else ROOT / "config" / "pay-qr.png"
out_png.parent.mkdir(parents=True, exist_ok=True)
qr = segno.make(sign)
qr.save(str(out_png), scale=6, border=2)
print(f" 二维码 PNG → {out_png}")
print("\n ══ 微信扫码支付(终端二维码)══")
try:
qr.terminal(compact=False)
except Exception: # noqa: BLE001
pass
return 0
if __name__ == "__main__":
sys.exit(main())
@@ -0,0 +1,33 @@
"""YYB 加密引擎(goods + mall 统一框架,纯 Python)。
- algorithm.py: goods 侧 CHAOS VM(116 opcode)——web_save / web_new_encrypt
- pagedoo_vm.py: mall 侧 pagedoo VM(108 opcode)——PlaceOrder
- goods.py: goods 侧高层 API(GoodsSession + generate_encrypt_msg)
- mall.py: mall 侧高层 API(MallSession + generate_encrypt_msg)
- session.py: 会话态模型
"""
from .algorithm import (
build_plaintext,
decode_d,
generate_encrypt_msg,
generate_encrypt_msg_offline,
)
from .goods import GoodsSession, generate_encrypt_msg as goods_generate
from .mall import MallSession, generate_encrypt_msg as mall_generate
from .pagedoo_vm import PagedooVM, run_frame
from .session import SessionState, load_session
__all__ = [
"build_plaintext",
"decode_d",
"generate_encrypt_msg",
"generate_encrypt_msg_offline",
"GoodsSession",
"goods_generate",
"MallSession",
"mall_generate",
"PagedooVM",
"run_frame",
"SessionState",
"load_session",
]
File diff suppressed because it is too large Load Diff
+74
View File
@@ -0,0 +1,74 @@
"""goods 侧(web_save / web_new_encrypt,goodsBiz CHAOS VM 116 opcode)高层 API。
与 mall 侧(pyvm/mall.py)同属 YYB 加密体系。goods 侧 E3 已验证:
会话态(xMidasOps 59640 + key16/key1) + 订单参数
→ build_plaintext 21 字段明文(528 字符)
→ a:8 变换(Te0-3 + key16 按块轮转)
→ webSave(33 块变换)
→ encrypt_msg(1056 hex)
"""
from __future__ import annotations
import json
from pathlib import Path
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"]
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 = ""):
self.xmidas_ops = xmidas_ops
self.key16 = key16
self.key1 = key1
self.args_template_d = args_template_d
self.xmidas_token = xmidas_token
self.validate()
def validate(self) -> None:
if len(self.xmidas_ops) != 59640:
raise ValueError(f"goods xMidasOps 应为 59640,实际 {len(self.xmidas_ops)}")
if len(self.key16) != 16 or len(self.key1) != 16:
raise ValueError("key16/key1 应为 16 字节")
@classmethod
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", ""))
def to_json(self) -> dict:
return {
"xmidas_ops": self.xmidas_ops,
"key16": self.key16,
"key1": self.key1,
"args_template_d": self.args_template_d,
"xmidas_token": self.xmidas_token,
}
@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", ""))
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,
)
@@ -0,0 +1,27 @@
"""YYB mall APIs require different session fields for WeChat and QQ OAuth."""
from __future__ import annotations
WECHAT_APPID = "wxd44977328b36e647"
QQ_APPID = "102033112"
def midas_login_params(cookies: dict[str, str]) -> dict[str, str]:
"""Build the provider-specific Midas session fields from OAuth cookies."""
login_type = str(cookies.get("logintype", cookies.get("login_type", "WX"))).upper()
if login_type == "QC":
return {
"openid": cookies.get("openid", ""),
"openkey": cookies.get("accesstoken", ""),
"session_id": "openid",
"session_type": "kp_accesstoken",
"wx_appid": "",
"qq_appid": cookies.get("appid", QQ_APPID),
}
return {
"openid": cookies.get("openid", ""),
"openkey": cookies.get("accesstoken", ""),
"session_id": "hy_gameid",
"session_type": "wc_actoken",
"wx_appid": cookies.get("appid", WECHAT_APPID),
"qq_appid": "",
}
+147
View File
@@ -0,0 +1,147 @@
"""mall 侧(PlaceOrder,pagedoo CHAOS VM 108 opcode)高层 API。
与 goods 侧(pyvm/goods.py)同属 YYB 加密体系:
- goods: web_save / web_new_encrypt(midas.gtimg.cn/goodsBiz,116 opcode)
- mall: PlaceOrder(pagedoo.pay.qq.com,108 opcode,详情页 e377650 变换核心)
mall 加密链路(E3 已验证,U-2022 闭环):
会话态(xMidasOps 59620 + key16/d/Te + 624B 中间态)
→ e377650 变换核心(正确回调链 e344354=[624B中间态, d, e336201, key16])
→ 624B 密文 = encrypt_msg(1248 hex)
注意:xMidasOps 是 mall 详情页页面级数据表(59620 长度,服务端生成),
必须从浏览器捕获(与 goods 的 59640 不同)。
"""
from __future__ import annotations
import copy
import json
import re
from pathlib import Path
from typing import Any
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]
class MallSession:
"""mall 会话态:e377650 变换核心的完整输入。
transform_input: e377650 创建参数(18 槽)——从浏览器 deepCap/J 转储提取
[0]=key16(16B) [1..5]=Te/S-box [6]=d(16B) [7]=[211] [8]=S-box2
[9]=outbuf [10]=624B中间态(a:8输出) [11..16]=1024/256表 [17]=回调
xmidas_ops: mall 详情页 xMidasOps(59620,服务端生成,页面级)
"""
def __init__(self, transform_input: list, xmidas_ops: list):
self.transform_input = transform_input
self.xmidas_ops = xmidas_ops
self.validate()
def validate(self) -> None:
if len(self.transform_input) != 18:
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)}")
mid = self.transform_input[10]
if isinstance(mid, list) and mid and isinstance(mid[0], list):
if len(mid[0]) != 624:
raise ValueError(f"624B 中间态长度 != 624: {len(mid[0])}")
@classmethod
def from_capture_file(cls, frames_jsonl: str | Path) -> "MallSession":
"""从捕获的 frames.jsonl 提取同会话 transform_input + xMidasOps。
frames.jsonl 由 scripts/capture-mall-session.mjs 实时落盘:
- J|...|e377650|{JSON} e377650 创建参数(transform_input)
- XMIDAS_OPS|url|59620数组 mall 详情页 xMidasOps
"""
lines = Path(frames_jsonl).read_text(encoding="utf-8", errors="replace").splitlines()
transform_input = None
xmidas = None
# xMidasOps:取 mall 详情页(z.iwan / pagedoo)那条
for l in lines:
if l.startswith("XMIDAS_OPS"):
parts = l.split("|")
if len(parts) >= 3 and ("z.iwan" in parts[1] or "pagedoo" in parts[1]):
xmidas = [int(x) for x in parts[2].split(",")]
break
if xmidas is None:
# 回退:任意 59620 长度
for l in lines:
if l.startswith("XMIDAS_OPS"):
parts = l.split("|")
vals = [int(x) for x in parts[2].split(",")]
if len(vals) == 59620:
xmidas = vals
break
for l in lines:
if l.startswith("J|") and re.search(r"\|e377650\|", l):
transform_input = json.loads(l.split("|e377650|", 1)[1])
break
if transform_input is None:
raise ValueError("frames.jsonl 中未找到 e377650 J 转储(需 mall 详情页购买触发加密)")
if xmidas is None:
raise ValueError("frames.jsonl 中未找到 59620 长度 xMidasOps")
return cls(transform_input, xmidas)
def to_json(self) -> dict:
return {
"transform_input": self.transform_input,
"xmidas_ops": self.xmidas_ops,
}
@classmethod
def from_json(cls, d: dict) -> "MallSession":
return cls(d["transform_input"], d["xmidas_ops"])
def _mk_window(xmidas: list) -> Window:
w = Window()
for k in ("window", "self", "globalThis", "top", "parent", "frames"):
w.set(k, w)
w.set("document", JSObject())
w.set("navigator", JSObject())
w.set("location", JSObject())
w.set("localStorage", JSObject())
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("fetch", lambda *a: None)
w.set("xMidasOps", xmidas)
return w
def generate_encrypt_msg(session: MallSession, random_seed: int = 1) -> str:
"""用会话态重放 e377650 变换核心,产出 encrypt_msg(1248 hex)。
E3 验证:同会话 transform_input + xMidasOps(59620) → 1248 hex 完全一致。
"""
bytecode = json.loads((REPLAY / "vm" / "bytecode-478657.json").read_text())
w = _mk_window(session.xmidas_ops)
vm = PagedooVM(bytecode, GLOBALS, w, random_seed=random_seed)
vm._root_this = w
for k, v in vm._hosts.items():
if k != "Array.prototype" and w.get(k) is UNDEF:
w.set(k, v)
h = copy.deepcopy(session.transform_input)
frame_rand = vm.make(336201, [], w, GLOBALS, None)
# 正确回调链:e344354 = [624B中间态, d, e336201随机帧, key16]
h344 = [h[10], h[6], [frame_rand], h[0]]
frame344 = vm.make(344354, h344, w, GLOBALS, None)
h[17] = [frame344]
frame = vm.make(377650, h, w, GLOBALS, None)
vm.run(frame, [])
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 '?'}")
return "".join(f"{x & 255:02x}" for x in h9)
@@ -0,0 +1,90 @@
"""Read YYB's official order list after a payment is completed."""
from __future__ import annotations
from typing import Any
from curl_cffi import requests
ORDER_LIST_URL = (
"https://ydd.yyb.qq.com/trpc.wesee_live.private_domain_creator_shop_svr."
"private_domain_creator_shop_svr/GetPrivateDomainOrderList"
)
USER_AGENT = (
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) "
"AppleWebKit/537.36 (KHTML, like Gecko) Chrome/151.0.0.0 Safari/537.36"
)
def get_official_orders(cookies: dict[str, str], count: int = 20) -> dict[str, Any]:
"""Fetch the same completed-order list used by YYB's success page."""
response = requests.post(
ORDER_LIST_URL,
json={"count": count, "breakpoint": 0, "type": 1},
headers={
"Accept": "application/json, text/plain, */*",
"Origin": "https://m.yyb.qq.com",
"Referer": "https://m.yyb.qq.com/boc-mall/goods-mall/product-order-success",
"User-Agent": USER_AGENT,
},
cookies=cookies,
impersonate="chrome",
timeout=30,
)
if response.status_code != 200:
raise RuntimeError(f"订单状态查询失败: HTTP {response.status_code}")
try:
document = response.json()
except ValueError as exc:
raise RuntimeError("订单状态查询返回非 JSON") from exc
if not isinstance(document, dict):
raise RuntimeError("订单状态查询响应格式异常")
if document.get("ret_code") not in (None, 0, "0"):
raise RuntimeError(
f"订单状态查询失败: {document.get('ret_code')} {document.get('ret_msg', '')}"
)
if not isinstance(document.get("list", []), list):
raise RuntimeError("订单状态查询响应缺少 list")
return document
def order_ids(document: dict[str, Any]) -> set[str]:
"""Return the stable order IDs visible in an order-list response."""
return {
str(item["order_id"])
for item in document.get("list", [])
if isinstance(item, dict) and item.get("order_id") not in (None, "")
}
def is_finished(item: dict[str, Any]) -> bool:
"""YYB's completed-order representation observed on product-order-success."""
return item.get("is_finished") is True and item.get("status") in (1, "1")
def order_completion_states(document: dict[str, Any]) -> dict[str, bool]:
"""Snapshot whether each currently visible order is completed."""
return {
str(item["order_id"]): is_finished(item)
for item in document.get("list", [])
if isinstance(item, dict) and item.get("order_id") not in (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):
continue
order_id = str(item.get("order_id", ""))
if order_id and is_finished(item) and not previous_states.get(order_id, False):
return item
return None
def completion_summary(item: dict[str, Any]) -> dict[str, Any]:
"""Persist only the state needed to identify a confirmed completion."""
return {
"order_id": str(item.get("order_id", "")),
"is_finished": item.get("is_finished"),
"status": item.get("status"),
}
@@ -0,0 +1,987 @@
"""pagedoo shop CHAOS VM(108 opcode)纯 Python 移植 — mall PlaceOrder 加密地基。
对应浏览器 `p_5c660516.*` chunk 内的 `__TENCENT_CHAOS_VM`(pagedoo 变体,108 opcode 0..107)。
与 goods 侧(pyvm/algorithm.py,116 opcode)同属 CHAOS VM 家族:语义相同,opcode 编号不同(F-2055)。
JS 语义辅助复用 algorithm.py(_ic/i32/js_add/js_index/JSObject/JSFunction 等)。
当前验证锚点:
- 帧 e215058(encodeURI + %XX 解码 → 字节数组):Node 重放返回输入 JSON 串的 UTF-8 字节(F-2059)
- 帧 e377650 变换核心:Node 指令级复现(F-2060,254937/254938 一致)
输出编排帧链(e454218/e423160 getter 链)仍依赖 VM C 栈续延语义,见 cases/yyb-chaos-vm-xmidas-webnewencrypt.md 未决项。
"""
from __future__ import annotations
import json
import math
import urllib.parse
from pathlib import Path
from .algorithm import (
JSDate,
UNDEF,
JSFunction,
JSObject,
HostFunction,
_ic,
i32,
u32,
ushr,
shl,
shr,
js_typeof,
js_truthy,
js_str,
js_add,
js_num,
js_eq,
js_streq,
js_index,
js_set,
js_del,
js_keys,
js_call,
js_apply,
js_new,
h_math_floor,
h_math_round,
h_math_ceil,
h_math_min,
h_math_max,
h_math_abs,
h_math_pow,
h_math_sqrt,
h_parseint,
h_parsefloat,
h_isnan,
h_encodeuri,
h_encodeuricomponent,
h_decodeuri,
h_decodeuricomponent,
h_string_fromcharcode,
h_new_date,
)
__all__ = ["PagedooVM", "run_frame", "REPLAY"]
# ---------------------------------------------------------------- 辅助
def _cmp_lt(a, b):
try:
return a < b
except Exception:
return False
def _cmp_le(a, b):
try:
return a <= b
except Exception:
return False
def _cmp_gt(a, b):
try:
return a > b
except Exception:
return False
def _cmp_ge(a, b):
try:
return a >= b
except Exception:
return False
# ---------------------------------------------------------------- 宿主函数
def h_arr_push(this, *args):
if this is UNDEF or this is None:
this = []
if len(args) == 1:
this.append(args[0])
else:
this.extend(args)
return len(this)
def h_arr_shift(this):
if not this:
return UNDEF
return this.pop(0)
def h_arr_join(this, sep=None):
if sep is None:
sep = ","
return sep.join("" if x is UNDEF or x is None else js_str(x) for x in this)
def h_arr_slice(this, a=None, b=None):
n = len(this)
if a is None or a is UNDEF:
a = 0
if b is None or b is UNDEF:
b = n
a = int(a) if a == a else 0
b = int(b) if b == b else n
if a < 0:
a = max(0, n + a)
if b < 0:
b = max(0, n + b)
return list(this[a:b])
def h_arr_indexof(this, x, frm=0):
try:
return this.index(x, frm)
except ValueError:
return -1
def h_arr_concat(this, *others):
out = list(this)
for o in others:
if isinstance(o, list):
out.extend(o)
else:
out.append(o)
return out
def h_arr_pop(this):
if not this:
return UNDEF
return this.pop()
def h_arr_unshift(this, *vals):
for v in reversed(vals):
this.insert(0, v)
return len(this)
def h_arr_reverse(this):
this.reverse()
return this
def h_arr_splice(this, start, delete_count=0, *items):
n = len(this)
if start < 0:
start = max(0, n + start)
if delete_count is UNDEF or delete_count is None:
delete_count = n - start
removed = this[start:start + delete_count]
del this[start:start + delete_count]
for i, it in enumerate(items):
this.insert(start + i, it)
return removed
def h_arr_map(this, fn):
return [js_call(fn, UNDEF, [x]) for x in this]
def h_arr_foreach(this, fn):
for x in this:
js_call(fn, UNDEF, [x])
return UNDEF
def h_arr_filter(this, fn):
return [x for x in this if js_truthy(js_call(fn, UNDEF, [x]))]
def h_char_code_at(this, s, i=0):
if isinstance(this, str) and isinstance(s, int) and 0 <= s < len(this):
return ord(this[s])
return float("nan")
def h_char_at(this, i=0):
if isinstance(this, str) and isinstance(i, int) and 0 <= i < len(this):
return this[i]
return ""
def h_str_indexof(this, search, frm=0):
if not isinstance(this, str):
return -1
try:
return this.index(search, frm)
except ValueError:
return -1
def h_str_slice(this, a=None, b=None):
return this[a:b] if isinstance(this, str) else ""
def h_str_split(this, sep=None):
return this.split(sep) if isinstance(this, str) else [this]
def h_str_tolower(this):
return this.lower() if isinstance(this, str) else this
def h_str_toupper(this):
return this.upper() if isinstance(this, str) else this
def h_str_substr(this, start=0, length=None):
if not isinstance(this, str):
return ""
n = len(this)
if start < 0:
start = max(0, n + start)
if length is None or length is UNDEF:
return this[start:]
return this[start:start + int(length)]
def h_str_substring(this, start=0, end=None):
if not isinstance(this, str):
return ""
if end is None or end is UNDEF:
end = len(this)
start = max(0, min(int(start), len(this)))
end = max(0, min(int(end), len(this)))
if start > end:
start, end = end, start
return this[start:end]
def h_str_tostring(this):
return js_str(this)
def h_object_ctor(this, *args):
if len(args) == 1 and args[0] is not UNDEF and args[0] is not None:
return args[0]
return JSObject()
def _js_to_py(v):
"""JSON.stringify 辅助:把 JS 值转 JSON 可序列化。"""
if v is UNDEF:
return None
if isinstance(v, list):
return [_js_to_py(x) for x in v]
if isinstance(v, JSObject):
keys = v._d if hasattr(v, "_d") else {}
return {k: _js_to_py(val) for k, val in (keys.items() if isinstance(keys, dict) else [])}
if isinstance(v, Window):
return {}
return v
def _py_to_js(v):
if isinstance(v, dict):
o = JSObject()
for k, val in v.items():
o.set(k, _py_to_js(val))
return o
if isinstance(v, list):
return [_py_to_js(x) for x in v]
return v
def _pg_index(obj, key):
"""pagedoo 专用 js_index:补充字符串原型方法(goods 侧不需要)。"""
if isinstance(obj, JSDate):
if key == "getTime":
return HostFunction(lambda this: this.t if isinstance(this, JSDate) else obj.t, "getTime")
if key == "toString":
return HostFunction(lambda this: str(this), "toString")
if key == "valueOf":
return HostFunction(lambda this: this.t if isinstance(this, JSDate) else obj.t, "valueOf")
if isinstance(obj, str):
if isinstance(key, str):
if key == "length":
return len(obj)
if key == "charCodeAt":
return HostFunction(h_char_code_at, "charCodeAt")
if key == "charAt":
return HostFunction(h_char_at, "charAt")
if key == "indexOf":
return HostFunction(h_str_indexof, "indexOf")
if key == "slice":
return HostFunction(h_str_slice, "slice")
if key == "split":
return HostFunction(h_str_split, "split")
if key == "toLowerCase":
return HostFunction(h_str_tolower, "toLowerCase")
if key == "toUpperCase":
return HostFunction(h_str_toupper, "toUpperCase")
if key == "toString":
return HostFunction(h_str_tostring, "toString")
if key == "substr":
return HostFunction(h_str_substr, "substr")
if key == "substring":
return HostFunction(h_str_substring, "substring")
return js_index(obj, key)
# ---------------------------------------------------------------- VM
class PagedooVM:
"""pagedoo CHAOS VM 解释器(108 opcode,0..107)。
bytecode: 字节码数组(如 vm/bytecode-478657.json)
constants: 常量数组(浏览器调用第 4 参,即 globals)
window: 宿主环境对象(JSObject/Window)
"""
def __init__(self, bytecode, constants, window, random_seed=None):
self.o = bytecode
self.constants = constants
self.window = window
self.inst_id = 0
self.use_init_c = False
self.init_c = None
self._root_this = UNDEF
self._host_log = []
import random as _r
self._random = _r
if random_seed is not None:
self._random.seed(random_seed)
self._hosts = self._build_hosts()
def _build_hosts(self):
m = {}
m["Math"] = JSObject()
m["Math"].set("random", HostFunction(lambda this: self._random.random(), "random"))
m["Math"].set("floor", HostFunction(h_math_floor, "floor"))
m["Math"].set("round", HostFunction(h_math_round, "round"))
m["Math"].set("ceil", HostFunction(h_math_ceil, "ceil"))
m["Math"].set("min", HostFunction(h_math_min, "min"))
m["Math"].set("max", HostFunction(h_math_max, "max"))
m["Math"].set("abs", HostFunction(h_math_abs, "abs"))
m["Math"].set("pow", HostFunction(h_math_pow, "pow"))
m["Math"].set("sqrt", HostFunction(h_math_sqrt, "sqrt"))
m["parseInt"] = HostFunction(h_parseint, "parseInt")
m["parseFloat"] = HostFunction(h_parsefloat, "parseFloat")
m["isNaN"] = HostFunction(h_isnan, "isNaN")
m["encodeURI"] = HostFunction(h_encodeuri, "encodeURI")
m["encodeURIComponent"] = HostFunction(h_encodeuricomponent, "encodeURIComponent")
m["decodeURI"] = HostFunction(h_decodeuri, "decodeURI")
m["decodeURIComponent"] = HostFunction(h_decodeuricomponent, "decodeURIComponent")
m["String"] = JSObject()
m["String"].set("fromCharCode", HostFunction(h_string_fromcharcode, "fromCharCode"))
m["Date"] = HostFunction(h_new_date, "Date")
m["Array"] = JSObject()
m["Array"].set("isArray", HostFunction(lambda this, x: isinstance(x, list), "isArray"))
m["Object"] = HostFunction(h_object_ctor, "Object")
m["Number"] = HostFunction(lambda this, x=None: js_num(x) if x is not None and x is not UNDEF else 0.0, "Number")
m["Boolean"] = HostFunction(lambda this, x=None: bool(js_truthy(x)) if x is not None and x is not UNDEF else False, "Boolean")
jso = JSObject()
jso.set("stringify", HostFunction(lambda this, x, *a: json.dumps(_js_to_py(x), ensure_ascii=False), "stringify"))
jso.set("parse", HostFunction(lambda this, s: _py_to_js(json.loads(s)), "parse"))
m["JSON"] = jso
m["RegExp"] = HostFunction(lambda this, *a: JSObject(), "RegExp")
# Array.prototype 方法挂到 Array 对象上(VM 通过 i[A].push 等调用)
proto = JSObject()
for name, fn in [
("push", h_arr_push), ("shift", h_arr_shift), ("join", h_arr_join),
("slice", h_arr_slice), ("indexOf", h_arr_indexof), ("concat", h_arr_concat),
("pop", h_arr_pop), ("unshift", h_arr_unshift), ("reverse", h_arr_reverse),
("splice", h_arr_splice), ("map", h_arr_map), ("forEach", h_arr_foreach),
("filter", h_arr_filter),
]:
proto.set(name, HostFunction(fn, name))
m["Array.prototype"] = proto
return m
def host(self, name):
h = self.window.get(name)
if h is not UNDEF:
return h
return self._hosts.get(name, UNDEF)
def make(self, entry, args, s, n, t):
return JSFunction(self, entry, args, s, n, t)
# -- 原型方法查找:i[A].push(...) 形式,i[A] 是数组 --
def _arr_method(self, name):
p = self._hosts.get("Array.prototype")
if p is not UNDEF and isinstance(p, JSObject):
v = p.get(name)
if v is not UNDEF:
return v
return UNDEF
def run(self, fn: JSFunction, call_args, trace=None, csnap=None):
self.inst_id += 1
if trace is not None:
self._trace = trace
self._csnap = csnap
if self.use_init_c and self.init_c is not None:
C = self.init_c
self.use_init_c = False
else:
root_this = getattr(self, "_root_this", UNDEF)
C = [fn.s, fn.n, fn.args, root_this, call_args, fn, self.o, 0]
C = list(C)
p = UNDEF
u = fn.entry
d = [] # 异常续延栈(JS C)
t = UNDEF # 最近异常值(op3/op52)
o = self.o
l = fn.t # 异常处理器(JS l)
_get = _pg_index
_set = js_set
_arr = self._arr_method
while True:
try:
while True:
u += 1
op = o[u]
if op in (0, 4, 11, 18, 23, 26, 43, 44, 48, 50, 84, 107) and len(self._host_log) < 2000:
# 记录调用目标(简化)
_tgt = o[u + 2] if op in (4, 11, 44, 50) else (o[u + 2] if op in (0, 18, 23, 26, 43, 48, 84, 107) else o[u + 2])
try:
_tv = C[_tgt] if 0 <= _tgt < len(C) else UNDEF
_tr = type(_tv).__name__ if not isinstance(_tv, (int, float, str, bool, type(None))) else repr(_tv)[:30]
self._host_log.append((op, u, _tr))
except Exception:
self._host_log.append((op, u, "?"))
if getattr(self, "_trace", None) is not None and len(self._trace) < 500000:
self._trace.append((op, u))
if self._csnap is not None and len(self._csnap) < 500:
snap = []
for _si in range(min(42, len(C))):
_v = C[_si]
if isinstance(_v, str):
snap.append("s:" + _v[:30])
elif isinstance(_v, list):
snap.append(f"a[{len(_v)}]")
elif _v is UNDEF:
snap.append("u")
elif _v is None:
snap.append("n")
elif isinstance(_v, (int, float, bool)):
snap.append(_v)
else:
snap.append("o")
self._csnap.append((op, u, snap))
# ---------------- opcode dispatch(108,逐条对照 interpreter-clean.js)----------------
# 约定:S() 读下一个槽索引操作数并取 C[slot];imm 直接读。
if op == 0:
# for(h=[],f=c[++u];f>0;f--)h.push(i[c[++u]]);i[A]=i[B].apply(i[C],h)
f = o[u + 1]; u += 1
h = []
for _ in range(f):
h.append(_get(C, o[u + 1])); u += 1
a = o[u + 1]; b = o[u + 2]; c = o[u + 3]; u += 3
_set(C, a, js_apply(_get(C, b), _get(C, c), h))
elif op == 1:
a = o[u + 1]; b = o[u + 2]; c = o[u + 3]; u += 3
_set(C, a, js_num(_get(C, b)) - js_num(_get(C, c)))
elif op == 2:
a = o[u + 1]; u += 1
_set(C, a, False)
elif op == 3:
a = o[u + 1]; u += 1
_set(C, a, t)
elif op == 4:
a = o[u + 1]; b = o[u + 2]; c = o[u + 3]; dd = o[u + 4]; u += 4
_set(C, a, js_call(_get(C, b), p, [_get(C, c), _get(C, dd)]))
elif op == 5:
a = o[u + 1]; b = o[u + 2]; u += 2
_set(C, a, _get(C, b))
c = o[u + 1]; dd = o[u + 2]; imm = o[u + 3]; u += 3
_set(C, c, _get(_get(C, dd), imm))
e = o[u + 1]; u += 1
_set(C, e, "")
elif op == 6:
a = o[u + 1]; b = o[u + 2]; c = o[u + 3]; u += 3
_set(C, a, _cmp_lt(js_num(_get(C, b)), js_num(_get(C, c))))
elif op == 7:
a = o[u + 1]; b = o[u + 2]; c = o[u + 3]; u += 3
_set(C, a, js_del(_get(C, b), _get(C, c)))
elif op == 8:
a = o[u + 1]; b = o[u + 2]; imm1 = o[u + 3]; u += 3
_set(C, a, _get(_get(C, b), imm1))
c = o[u + 1]; dd = o[u + 2]; imm2 = o[u + 3]; u += 3
_set(C, c, _get(_get(C, dd), imm2))
elif op == 9:
if d:
d.pop()
elif op == 10:
f = o[u + 1]; u += 1
h = []
for _ in range(f):
h.append(_get(C, o[u + 1])); u += 1
dest = o[u + 1]; off = o[u + 2]; u += 2
_set(C, dest, self.make(u + off, h, C[0], C[1], l))
elif op == 11:
a = o[u + 1]; b = o[u + 2]; c = o[u + 3]; u += 3
_set(C, a, js_call(_get(C, b), p, [_get(C, c)]))
elif op == 12:
a = o[u + 1]; b = o[u + 2]; imm = o[u + 3]; u += 3
_set(C, a, shl(js_num(_get(C, b)), imm))
elif op == 13:
a = o[u + 1]; b = o[u + 2]; c = o[u + 3]; dd = o[u + 4]; e = o[u + 5]; u += 5
_set(C, a, _get(C, b))
v = _cmp_lt(js_num(_get(C, dd)), js_num(_get(C, e)))
_set(C, c, v)
u0 = u
if js_truthy(v):
u = u0 + o[u0 + 1]
else:
u = u0 + o[u0 + 2]
elif op == 14:
a = o[u + 1]; b = o[u + 2]; c = o[u + 3]; u += 3
_set(C, a, i32(_ic(_get(C, b))) | i32(_ic(_get(C, c))))
elif op == 15:
a = o[u + 1]; b = o[u + 2]; imm = o[u + 3]; u += 3
_set(C, a, js_num(_get(C, b)) - imm)
elif op == 16:
a = o[u + 1]; b = o[u + 2]; u += 2
_set(C, a, js_new(_get(C, b), []))
elif op == 17:
a = o[u + 1]; b = o[u + 2]; imm = o[u + 3]; u += 3
_set(C, a, "")
_set(C, b, js_str(_get(C, b)) + chr(imm & 0xFFFF))
elif op == 18:
a = o[u + 1]; b = o[u + 2]; c = o[u + 3]; dd = o[u + 4]; e = o[u + 5]; f = o[u + 6]; u += 6
_set(C, a, js_call(_get(C, b), _get(C, c), [_get(C, dd), _get(C, e), _get(C, f)]))
elif op == 19:
a = o[u + 1]; b = o[u + 2]; u += 2
_set(C, a, _get(C, b))
c = o[u + 1]; imm = o[u + 2]; u += 2
_set(C, c, imm)
dd = o[u + 1]; e = o[u + 2]; u += 2
_set(C, dd, _get(C, e))
elif op == 20:
a = o[u + 1]; u += 1
_set(C, a, "")
elif op == 21:
a = o[u + 1]; b = o[u + 2]; c = o[u + 3]; u += 3
_set(C, a, _get(_get(C, b), _get(C, c)))
dd = o[u + 1]; e = o[u + 2]; imm = o[u + 3]; u += 3
v = _cmp_gt(js_num(_get(C, e)), imm)
_set(C, dd, v)
u0 = u
if js_truthy(v):
u = u0 + o[u0 + 1]
else:
u = u0 + o[u0 + 2]
elif op == 22:
a = o[u + 1]; b = o[u + 2]; c = o[u + 3]; u += 3
_set(C, a, js_num(_get(C, b)) * js_num(_get(C, c)))
elif op == 23:
a = o[u + 1]; b = o[u + 2]; c = o[u + 3]; dd = o[u + 4]; u += 4
_set(C, a, js_call(_get(C, b), _get(C, c), [_get(C, dd)]))
elif op == 24:
a = o[u + 1]; b = o[u + 2]; u += 2
_set(C, a, js_num(_get(C, b)))
c = o[u + 1]; dd = o[u + 2]; u += 2
_set(C, c, js_num(_get(C, dd)) + 1)
e = o[u + 1]; f = o[u + 2]; u += 2
_set(C, e, _get(C, f))
elif op == 25:
a = o[u + 1]; b = o[u + 2]; c = o[u + 3]; dd = o[u + 4]; e = o[u + 5]; u += 5
_set(C, a, js_new(_get(C, b), [_get(C, c), _get(C, dd), _get(C, e)]))
elif op == 26:
a = o[u + 1]; b = o[u + 2]; c = o[u + 3]; u += 3
_set(C, a, _get(_get(C, b), _get(C, c)))
dd = o[u + 1]; e = o[u + 2]; f = o[u + 3]; g = o[u + 4]; u += 4
_set(C, dd, js_call(_get(C, e), _get(C, f), [_get(C, g)]))
hh = o[u + 1]; ii = o[u + 2]; j = o[u + 3]; kk = o[u + 4]; ll = o[u + 5]; u += 5
_set(C, hh, js_call(_get(C, ii), _get(C, j), [_get(C, kk), _get(C, ll)]))
elif op == 27:
a = o[u + 1]; n = o[u + 2]; u += 2
_set(C, a, [UNDEF] * int(n))
elif op == 28:
a = o[u + 1]; b = o[u + 2]; c = o[u + 3]; u += 3
_set(C, a, i32(_ic(_get(C, b))) ^ i32(_ic(_get(C, c))))
elif op == 29:
a = o[u + 1]; b = o[u + 2]; imm = o[u + 3]; u += 3
_set(C, a, _cmp_gt(js_num(_get(C, b)), imm))
elif op == 30:
a = o[u + 1]; b = o[u + 2]; c = o[u + 3]; u += 3
_set(C, a, i32(_ic(_get(C, b))) & i32(_ic(_get(C, c))))
elif op == 31:
a = o[u + 1]; b = o[u + 2]; u += 2
_set(C, a, _get(C, b))
c = o[u + 1]; dd = o[u + 2]; u += 2
_set(C, c, js_num(_get(C, dd)))
e = o[u + 1]; f = o[u + 2]; u += 2
_set(C, e, js_num(_get(C, f)) + 1)
elif op == 32:
# u += i[A] ? c[++u] : c[(++u,++u)] —— LHS u 在 body 开始读取
u0 = u
a = o[u0 + 1]
if js_truthy(_get(C, a)):
u = u0 + o[u0 + 2]
else:
u = u0 + o[u0 + 3]
elif op == 33:
a = o[u + 1]; imm = o[u + 2]; u += 2
_set(C, a, js_str(_get(C, a)) + chr(imm & 0xFFFF))
b = o[u + 1]; c = o[u + 2]; dd = o[u + 3]; u += 3
_set(C, b, _get(_get(C, c), _get(C, dd)))
elif op == 34:
a = o[u + 1]; b = o[u + 2]; u += 2
_set(C, a, js_typeof(_get(C, b)))
elif op == 35:
a = o[u + 1]; b = o[u + 2]; c = o[u + 3]; u += 3
_set(C, a, _cmp_le(js_num(_get(C, b)), js_num(_get(C, c))))
elif op == 36:
a = o[u + 1]; b = o[u + 2]; u += 2
_set(C, a, -js_num(_get(C, b)))
elif op == 37:
a = o[u + 1]; u += 1
_set(C, a, True)
elif op == 38:
a = o[u + 1]; b = o[u + 2]; c = o[u + 3]; u += 3
_set(C, a, isinstance(_get(C, b), type(_get(C, c))) if isinstance(_get(C, c), type) else False)
elif op == 39:
a = o[u + 1]; b = o[u + 2]; imm = o[u + 3]; u += 3
_set(C, a, js_add(_get(C, b), imm))
elif op == 40:
a = o[u + 1]; b = o[u + 2]; c = o[u + 3]; u += 3
js_set(_get(C, a), _get(C, b), _get(C, c))
elif op == 41:
a = o[u + 1]; imm = o[u + 2]; u += 2
_set(C, a, imm)
b = o[u + 1]; u += 1
_set(C, b, _get(C, b))
c = o[u + 1]; dd = o[u + 2]; e = o[u + 3]; u += 3
_set(C, c, _cmp_lt(js_num(_get(C, dd)), js_num(_get(C, e))))
elif op == 42:
a = o[u + 1]; b = o[u + 2]; c = o[u + 3]; u += 3
_set(C, a, js_num(_get(C, b)) / js_num(_get(C, c)))
elif op == 43:
a = o[u + 1]; b = o[u + 2]; c = o[u + 3]; dd = o[u + 4]; e = o[u + 5]; u += 5
_set(C, a, js_call(_get(C, b), _get(C, c), [_get(C, dd), _get(C, e)]))
elif op == 44:
a = o[u + 1]; b = o[u + 2]; u += 2
_bv = _get(C, b)
if getattr(self, "_dbg_u", None) == u:
print(f" [dbg] op44@{u}: A={a} B={b} i[B]={type(_bv).__name__}: {str(_bv)[:80]}")
_set(C, a, js_call(_bv, p, []))
elif op == 45:
a = o[u + 1]; u += 1
_set(C, a, p)
b = o[u + 1]; n = o[u + 2]; u += 2
_set(C, b, [UNDEF] * int(n))
c = o[u + 1]; u += 1
_set(C, c, "")
elif op == 46:
a = o[u + 1]; u += 1
obj = JSObject()
_set(C, a, obj)
b = o[u + 1]; imm = o[u + 2]; c = o[u + 3]; u += 3
js_set(_get(C, b), imm, _get(C, c))
dd = o[u + 1]; imm2 = o[u + 2]; e = o[u + 3]; u += 3
js_set(_get(C, dd), imm2, _get(C, e))
elif op == 47:
a = o[u + 1]; imm = o[u + 2]; u += 2
_set(C, a, js_str(_get(C, a)) + chr(imm & 0xFFFF))
elif op == 48:
a = o[u + 1]; b = o[u + 2]; c = o[u + 3]; dd = o[u + 4]; u += 4
_set(C, a, js_call(_get(C, b), _get(C, c), [_get(C, dd)]))
e = o[u + 1]; f = o[u + 2]; g = o[u + 3]; hh = o[u + 4]; ii = o[u + 5]; j = o[u + 6]; u += 6
_set(C, e, js_call(_get(C, f), _get(C, g), [_get(C, hh), _get(C, ii)]))
return _get(C, j)
elif op == 49:
a = o[u + 1]; u += 1
_set(C, a, JSObject())
elif op == 50:
a = o[u + 1]; b = o[u + 2]; c = o[u + 3]; dd = o[u + 4]; u += 4
_set(C, a, js_call(_get(C, b), p, [_get(C, c), _get(C, dd)]))
elif op == 51:
a = o[u + 1]; imm = o[u + 2]; u += 2
_set(C, a, js_str(_get(C, a)) + chr(imm & 0xFFFF))
f = o[u + 1]; u += 1
h = []
for _ in range(f):
h.append(_get(C, o[u + 1])); u += 1
dest = o[u + 1]; off = o[u + 2]; u += 2
frame = self.make(u + off, h, C[0], C[1], l)
_set(C, dest, frame)
b = o[u + 1]; c = o[u + 2]; e = o[u + 3]; u += 3
js_set(_get(C, b), _get(C, c), _get(C, e))
elif op == 52:
t = _get(C, o[u + 1]); u += 1
raise _VMThrow(t)
elif op == 53:
a = o[u + 1]; b = o[u + 2]; u += 2
_set(C, a, js_num(_get(C, b)))
elif op == 54:
a = o[u + 1]; b = o[u + 2]; imm = o[u + 3]; u += 3
_set(C, a, _cmp_lt(js_num(_get(C, b)), imm))
elif op == 55:
a = o[u + 1]; b = o[u + 2]; c = o[u + 3]; u += 3
_set(C, a, _get(_get(C, b), _get(C, c)))
elif op == 56:
# C.push(u+c[++u]) —— LHS u 在 c[++u] 前读取,且 u 推进 1
u0 = u
imm = o[u0 + 1]
d.append(u0 + imm)
u = u0 + 1
elif op == 57:
a = o[u + 1]; b = o[u + 2]; c = o[u + 3]; u += 3
_set(C, a, _cmp_ge(js_num(_get(C, b)), js_num(_get(C, c))))
elif op == 58:
a = o[u + 1]; b = o[u + 2]; c = o[u + 3]; u += 3
_set(C, a, js_eq(_get(C, b), _get(C, c)))
elif op == 59:
a = o[u + 1]; b = o[u + 2]; u += 2
_set(C, a, js_keys(_get(C, b)))
elif op == 60:
u += o[u + 1]
elif op == 61:
a = o[u + 1]; imm = o[u + 2]; b = o[u + 3]; u += 3
js_set(_get(C, a), imm, _get(C, b))
elif op == 62:
a = o[u + 1]; b = o[u + 2]; u += 2
_set(C, a, _get(C, b))
c = o[u + 1]; dd = o[u + 2]; e = o[u + 3]; u += 3
js_set(_get(C, c), _get(C, dd), _get(C, e))
elif op == 63:
return _get(C, o[u + 1])
elif op == 64:
a = o[u + 1]; b = o[u + 2]; imm = o[u + 3]; u += 3
_set(C, a, _get(_get(C, b), imm))
c = o[u + 1]; u += 1
_set(C, c, "")
dd = o[u + 1]; imm2 = o[u + 2]; u += 2
_set(C, dd, js_str(_get(C, dd)) + chr(imm2 & 0xFFFF))
elif op == 65:
a = o[u + 1]; b = o[u + 2]; c = o[u + 3]; u += 3
_set(C, a, shr(js_num(_get(C, b)), js_num(_get(C, c))))
elif op == 66:
a = o[u + 1]; imm1 = o[u + 2]; u += 2
_set(C, a, js_str(_get(C, a)) + chr(imm1 & 0xFFFF))
b = o[u + 1]; imm2 = o[u + 2]; u += 2
_set(C, b, js_str(_get(C, b)) + chr(imm2 & 0xFFFF))
elif op == 67:
a = o[u + 1]; u += 1
_set(C, a, p)
elif op == 68:
a = o[u + 1]; b = o[u + 2]; c = o[u + 3]; u += 3
_set(C, a, _get(_get(C, b), _get(C, c)))
dd = o[u + 1]; e = o[u + 2]; u += 2
_set(C, dd, _get(C, e))
f = o[u + 1]; u += 1
_set(C, f, "")
elif op == 69:
a = o[u + 1]; imm = o[u + 2]; u += 2
_set(C, a, js_num(_get(C, a)) - imm)
b = o[u + 1]; c = o[u + 2]; dd = o[u + 3]; e = o[u + 4]; u += 4
_set(C, b, js_new(_get(C, c), [_get(C, dd), _get(C, e)]))
f = o[u + 1]; g = o[u + 2]; u += 2
_set(C, f, _get(C, g))
elif op == 70:
a = o[u + 1]; b = o[u + 2]; imm = o[u + 3]; u += 3
_set(C, a, i32(_ic(_get(C, b))) | imm)
elif op == 71:
f = o[u + 1]; u += 1
h = []
for _ in range(f):
h.append(_get(C, o[u + 1])); u += 1
dest = o[u + 1]; off = o[u + 2]; u += 2
_set(C, dest, self.make(u + off, h, C[0], C[1], l))
elif op == 72:
a = o[u + 1]; u += 1
_set(C, a, None)
elif op == 73:
a = o[u + 1]; b = o[u + 2]; imm = o[u + 3]; u += 3
_set(C, a, i32(_ic(_get(C, b))) & imm)
elif op == 74:
a = o[u + 1]; imm1 = o[u + 2]; b = o[u + 3]; u += 3
js_set(_get(C, a), imm1, _get(C, b))
c = o[u + 1]; u += 1
obj = JSObject()
_set(C, c, obj)
dd = o[u + 1]; imm2 = o[u + 2]; e = o[u + 3]; u += 3
js_set(_get(C, dd), imm2, _get(C, e))
elif op == 75:
a = o[u + 1]; b = o[u + 2]; imm = o[u + 3]; u += 3
_set(C, a, shr(js_num(_get(C, b)), imm))
elif op == 76:
a = o[u + 1]; imm = o[u + 2]; b = o[u + 3]; u += 3
_set(C, a, imm + js_num(_get(C, b)))
elif op == 77:
a = o[u + 1]; b = o[u + 2]; c = o[u + 3]; dd = o[u + 4]; u += 4
_set(C, a, js_new(_get(C, b), [_get(C, c), _get(C, dd)]))
elif op == 78:
a = o[u + 1]; b = o[u + 2]; c = o[u + 3]; u += 3
_set(C, a, ushr(js_num(_get(C, b)), js_num(_get(C, c))))
elif op == 79:
a = o[u + 1]; u += 1
_set(C, a, js_num(_get(C, a)) + 1)
elif op == 80:
a = o[u + 1]; imm = o[u + 2]; u += 2
_set(C, a, imm)
elif op == 81:
a = o[u + 1]; imm = o[u + 2]; b = o[u + 3]; u += 3
_set(C, a, imm - js_num(_get(C, b)))
elif op == 82:
a = o[u + 1]; b = o[u + 2]; c = o[u + 3]; u += 3
_set(C, a, _cmp_gt(js_num(_get(C, b)), js_num(_get(C, c))))
elif op == 83:
a = o[u + 1]; b = o[u + 2]; imm = o[u + 3]; u += 3
_set(C, a, ushr(js_num(_get(C, b)), imm))
elif op == 84:
a = o[u + 1]; b = o[u + 2]; c = o[u + 3]; u += 3
_set(C, a, js_call(_get(C, b), _get(C, c), []))
elif op == 85:
a = o[u + 1]; imm1 = o[u + 2]; b = o[u + 3]; u += 3
js_set(_get(C, a), imm1, _get(C, b))
c = o[u + 1]; imm2 = o[u + 2]; dd = o[u + 3]; u += 3
js_set(_get(C, c), imm2, _get(C, dd))
e = o[u + 1]; u += 1
_set(C, e, "")
elif op == 86:
a = o[u + 1]; b = o[u + 2]; u += 2
h = _get(C, a)
if h is not UNDEF and h is not None and len(h):
_set(C, b, True)
c = o[u + 1]; u += 1
_set(C, c, h.pop(0))
else:
_set(C, b, False)
u += 1
elif op == 87:
a = o[u + 1]; b = o[u + 2]; imm = o[u + 3]; u += 3
_set(C, a, _get(_get(C, b), imm))
elif op == 88:
a = o[u + 1]; u += 1
_set(C, a, js_num(_get(C, a)) - 1)
elif op == 89:
a = o[u + 1]; b = o[u + 2]; c = o[u + 3]; u += 3
_set(C, a, shl(js_num(_get(C, b)), js_num(_get(C, c))))
elif op == 90:
a = o[u + 1]; b = o[u + 2]; c = o[u + 3]; u += 3
_set(C, a, js_eq(_get(C, b), _get(C, c)))
elif op == 91:
a = o[u + 1]; b = o[u + 2]; c = o[u + 3]; u += 3
_set(C, a, js_new(_get(C, b), [_get(C, c)]))
elif op == 92:
a = o[u + 1]; b = o[u + 2]; imm = o[u + 3]; u += 3
_set(C, a, _cmp_le(js_num(_get(C, b)), imm))
elif op == 93:
a = o[u + 1]; b = o[u + 2]; c = o[u + 3]; u += 3
_set(C, a, js_num(_get(C, b)) % js_num(_get(C, c)))
elif op == 94:
a = o[u + 1]; b = o[u + 2]; imm = o[u + 3]; u += 3
_set(C, a, i32(_ic(_get(C, b))) ^ imm)
elif op == 95:
a = o[u + 1]; b = o[u + 2]; c = o[u + 3]; u += 3
_set(C, a, js_new(_get(C, b), [_get(C, c)]))
dd = o[u + 1]; e = o[u + 2]; u += 2
_set(C, dd, _get(C, e))
f = o[u + 1]; imm = o[u + 2]; u += 2
_set(C, f, imm)
elif op == 96:
a = o[u + 1]; b = o[u + 2]; c = o[u + 3]; u += 3
obj2 = _get(C, c)
_set(C, a, _get(C, b) in obj2 if isinstance(obj2, (list, dict, JSObject)) else False)
elif op == 97:
a = o[u + 1]; b = o[u + 2]; u += 2
_set(C, a, not js_truthy(_get(C, b)))
elif op == 98:
a = o[u + 1]; b = o[u + 2]; imm = o[u + 3]; u += 3
_set(C, a, _cmp_ge(js_num(_get(C, b)), imm))
elif op == 99:
a = o[u + 1]; b = o[u + 2]; c = o[u + 3]; u += 3
_set(C, a, js_add(_get(C, b), _get(C, c)))
elif op == 100:
a = o[u + 1]; b = o[u + 2]; imm = o[u + 3]; u += 3
_set(C, a, _get(C, b) == imm)
elif op == 101:
a = o[u + 1]; b = o[u + 2]; c = o[u + 3]; u += 3
_set(C, a, js_eq(_get(C, b), _get(C, c)))
elif op == 102:
a = o[u + 1]; b = o[u + 2]; u += 2
_set(C, a, _get(C, b))
elif op == 103:
a = o[u + 1]; b = o[u + 2]; imm1 = o[u + 3]; u += 3
_set(C, a, _get(_get(C, b), imm1))
c = o[u + 1]; dd = o[u + 2]; u += 2
_set(C, c, _get(C, dd))
e = o[u + 1]; f = o[u + 2]; imm2 = o[u + 3]; u += 3
_set(C, e, _get(_get(C, f), imm2))
elif op == 104:
a = o[u + 1]; b = o[u + 2]; u += 2
_set(C, a, js_num(_get(C, b)))
elif op == 105:
a = o[u + 1]; b = o[u + 2]; c = o[u + 3]; u += 3
v = _get(_get(C, b), _get(C, c))
_set(C, a, v)
dd = o[u + 1]; e = o[u + 2]; f = o[u + 3]; u += 3
js_set(_get(C, dd), _get(C, e), _get(C, f))
u0 = u
u = u0 + o[u0 + 1]
elif op == 106:
a = o[u + 1]; b = o[u + 2]; u += 2
_set(C, a, ~i32(_ic(_get(C, b))))
elif op == 107:
f = o[u + 1]; u += 1
h = []
for _ in range(f):
h.append(_get(C, o[u + 1])); u += 1
a = o[u + 1]; b = o[u + 2]; u += 2
_set(C, a, js_apply(_get(C, b), p, h))
else:
raise RuntimeError(f"未知 opcode {op} @u={u}")
except _VMThrow as ex:
t = ex.value
if d:
u = d.pop()
continue
if l is not UNDEF and l is not None:
return js_call(l, UNDEF, [t, C, []])
raise
except Exception:
if d:
u = d.pop()
continue
raise
class _VMThrow(Exception):
def __init__(self, value):
super().__init__("vm throw")
self.value = value
# ---------------------------------------------------------------- 便捷入口
def _mk_window(vmlog=None):
from .algorithm import Window
w = Window()
for k in ("window", "self", "globalThis", "top", "parent", "frames"):
w.set(k, w)
w.set("document", JSObject())
w.set("navigator", JSObject())
w.set("location", JSObject())
w.set("localStorage", JSObject())
w.set("sessionStorage", JSObject())
w.set("screen", JSObject())
w.set("history", JSObject())
w.set("XMLHttpRequest", HostFunction(lambda this, *a: None, "XHR"))
w.set("fetch", HostFunction(lambda this, *a: None, "fetch"))
return w
def run_frame(entry, h, bytecode, constants, window=None, call_args=(), random_seed=None):
"""便捷入口:用 pagedoo VM 执行指定 entry 的帧(h 为创建参数数组)。"""
if window is None:
window = _mk_window()
vm = PagedooVM(bytecode, constants, window, random_seed=random_seed)
# 挂宿主
for k, v in vm._hosts.items():
if k != "Array.prototype" and window.get(k) is UNDEF:
window.set(k, v)
frame = vm.make(entry, h, window, constants, None)
return vm.run(frame, list(call_args)), vm
@@ -0,0 +1,91 @@
"""会话态模型:web_new_encrypt 离线生成所需的会话级输入。
F-2049/F-2051(E3):encrypt_msg 与当前会话绑定——
- xMidasOps: goods.shtml 页面内嵌伪随机表(59640 值,服务端每次生成,页面级)
- key16: goodsBiz VM 加载期 Math.random 前 16 次调用(页面级恒定)
- key1: 诱饵密钥(点击级,捕获即可)
服务端能验证 key 派生状态(随机 key 变体 ret:1099),因此新订单必须先捕获会话态。
"""
from __future__ import annotations
import json
from dataclasses import dataclass, field
from pathlib import Path
DEFAULT_XMIDAS_TOKEN = "DE46DBA4754D42A6B66ADD4319FF80C144D9ED22ED73384C271793D9A9AA2FFBD6C104BE8D4A7F4ED2A8688FCB6F7540"
@dataclass
class SessionState:
"""一次 goods 页加载捕获的会话态。
xmidas_ops: list[int] 59640 项(页面级)
key16: list[int] 16 字节(VM 加载期 Math.random 前 16 次)
key1: list[int] 16 字节(诱饵)
xmidas_token: str
cookies: dict[str, str](提交 web_save 用,可选)
openid/openkey: str(提交 web_save 用,可选)
"""
xmidas_ops: list[int] = field(default_factory=list)
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 编码,会话绑定)
cookies: dict[str, str] = field(default_factory=dict)
openid: str = ""
openkey: str = ""
source: str = "" # 捕获来源说明
def validate(self) -> list[str]:
errs: list[str] = []
if len(self.xmidas_ops) != 59640:
errs.append(f"xmidas_ops 长度 {len(self.xmidas_ops)} != 59640")
if len(self.key16) != 16:
errs.append(f"key16 长度 {len(self.key16)} != 16")
if len(self.key1) != 16:
errs.append(f"key1 长度 {len(self.key1)} != 16")
if not self.xmidas_token:
errs.append("xmidas_token 为空")
if not self.args_template_d:
errs.append("args_template_d 为空(需 deepcap PC 85091 的 C[2] 原始 D 编码)")
return errs
def to_json(self) -> dict:
return {
"xmidas_ops": self.xmidas_ops,
"key16": self.key16,
"key1": self.key1,
"xmidas_token": self.xmidas_token,
"args_template_d": self.args_template_d,
"cookies": self.cookies,
"openid": self.openid,
"openkey": self.openkey,
"source": self.source,
}
@classmethod
def from_json(cls, d: dict) -> "SessionState":
return cls(
xmidas_ops=list(d.get("xmidas_ops", [])),
key16=list(d.get("key16", [])),
key1=list(d.get("key1", [])),
xmidas_token=d.get("xmidas_token", DEFAULT_XMIDAS_TOKEN),
args_template_d=d.get("args_template_d", ""),
cookies=dict(d.get("cookies", {})),
openid=d.get("openid", ""),
openkey=d.get("openkey", ""),
source=d.get("source", ""),
)
def load_session(path: str | Path) -> SessionState:
"""从 JSON 加载会话态并校验。"""
p = Path(path)
if not p.exists():
raise FileNotFoundError(f"会话态文件不存在: {p}")
st = SessionState.from_json(json.loads(p.read_text(encoding="utf-8")))
errs = st.validate()
if errs:
raise ValueError("会话态校验失败:\n " + "\n ".join(errs))
return st
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -0,0 +1 @@
token_id=&transaction_id=&out_trade_no=&offer_type=&type=&pf=&pfkey=&from_h5=&pc_st=&r=&openid=&openkey=&session_id=&session_type=&sck=&anti_auto_script_token_id=&zoneid=&buy_quantity=&provide_uin=&wx_appid=&extend=&jump_url=&wcp=&biz_appid=&wx_direct_pay=&wx_publice_pay=&uuid=&pay_method=&pushtype=&wx_order_interface=&encrypt_msg=&base_key_version=&encrypt_way=&web_token=&webversion=&from_https=&t=&__refer=
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -0,0 +1 @@
{"0": ["K", "R", "R", "R", "R"], "1": ["R", "R", "R"], "2": ["R"], "3": ["R"], "4": ["R", "R", "R", "R"], "5": ["R", "R", "R", "R", "K", "R"], "6": ["R", "R", "R"], "7": ["R", "R", "R"], "8": ["R", "R", "K", "R", "R", "K"], "9": [], "10": ["K", "R", "R", "K"], "11": ["R", "R", "R"], "12": ["R", "R", "K"], "13": ["R", "R", "R", "R", "R", "R", "K"], "14": ["R", "R", "R"], "15": ["R", "R", "K"], "16": ["R", "R"], "17": ["R", "R", "K"], "18": ["R", "R", "R", "R", "R", "R"], "19": ["R", "R", "R", "K", "R", "R"], "20": ["R"], "21": ["R", "R", "R", "R", "R", "K", "R", "K"], "22": ["R", "R", "R"], "23": ["R", "R", "R", "R"], "24": ["R", "R", "R", "R", "R", "R"], "25": ["R", "R", "R", "R", "R"], "26": ["R", "R", "R", "R", "R", "R", "R", "R", "R", "R", "R", "R"], "27": ["R", "K"], "28": ["R", "R", "R"], "29": ["R", "R", "K"], "30": ["R", "R", "R"], "31": ["R", "R", "R", "R", "R", "R"], "32": ["R", "K"], "33": ["R", "K", "R", "R", "R"], "34": ["R", "R"], "35": ["R", "R", "R"], "36": ["R", "R"], "37": ["R"], "38": ["R", "R", "R"], "39": ["R", "R", "K"], "40": ["R", "R", "R"], "41": ["R", "K", "R", "R", "R", "R", "R"], "42": ["R", "R", "R"], "43": ["R", "R", "R", "R", "R"], "44": ["R", "R"], "45": ["R", "R", "K", "R"], "46": ["R", "R", "K", "R", "R", "K", "R"], "47": ["R", "K"], "48": ["R", "R", "R", "R", "R", "R", "R", "R", "R", "R", "R"], "50": ["R", "R", "R", "R", "R"], "51": ["R", "K", "K", "R", "R", "K", "R", "R", "R"], "52": ["R", "R", "R"], "54": ["R", "R", "K"], "55": ["R", "R", "R"], "56": ["K"], "57": ["R", "R", "R"], "58": ["R", "R", "R"], "59": ["R", "R"], "60": ["K"], "61": ["R", "K", "R"], "62": ["R", "R", "R", "R", "R"], "63": ["R", "R", "R", "K", "R", "R", "K"], "65": ["R", "R", "R"], "66": ["R", "K", "R", "K"], "67": ["R"], "68": ["R", "R", "R", "R", "R", "R"], "69": ["R", "R", "K", "R", "R", "R", "R", "R"], "70": ["R", "R", "K"], "71": ["K", "R", "R", "K"], "72": ["R"], "73": ["R", "R", "K"], "74": ["R", "K", "R", "R", "R", "K", "R"], "75": ["R", "R", "K"], "76": ["R", "K", "R"], "77": ["R", "R", "R", "R"], "78": ["R", "R", "R"], "79": ["R", "R"], "80": ["R", "K"], "81": ["R", "K", "R"], "82": ["R", "R", "R"], "83": ["R", "R", "K"], "84": ["R", "R", "R"], "85": ["R", "K", "R", "R", "K", "R", "R"], "86": ["R", "R", "R"], "87": ["R", "R", "K"], "88": ["R", "R"], "89": ["R", "R", "R"], "90": ["R", "R", "R"], "91": ["R", "R", "R"], "92": ["R", "R", "K"], "93": ["R", "R", "R"], "94": ["R", "R", "K"], "95": ["R", "R", "R", "R", "R", "R", "K"], "96": ["R", "R", "R"], "97": ["R", "R"], "98": ["R", "R", "K"], "99": ["R", "R", "R"], "100": ["R", "R", "K"], "101": ["R", "R", "K"], "102": ["R", "R"], "103": ["R", "R", "K", "R", "R", "R", "R", "K"], "104": ["R", "R"], "105": ["R", "R", "R", "R", "R", "R", "K"], "106": ["R", "R"], "107": ["K", "R", "R", "R", "K", "K", "R", "R", "R", "R"]}
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -0,0 +1,132 @@
#!/usr/bin/env node
/**
* 在 jsdom 中执行 goods.shtml 的原始脚本并导出 fp-behv 参数。
*
* 该脚本只生成 DeviceFP,不会提交 fp-behv 或 web_save。
* 调用方必须把同一份 goods HTML、goods URL 与生成的 fp 参数配对使用。
*/
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { JSDOM, ResourceLoader } from 'jsdom';
import { patchEnvironment } from './jsdom-patch-env.mjs';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const ROOT = path.resolve(__dirname, '..');
const argv = process.argv.slice(2);
const getArg = (name, fallback = '') => {
const index = argv.indexOf(name);
return index === -1 ? fallback : argv[index + 1];
};
const htmlPath = getArg('--html');
const goodsUrl = getArg('--goods-url');
const cookiePath = getArg('--cookies');
const outputPath = getArg('--output');
const waitMs = Number.parseInt(getArg('--wait', '12000'), 10);
const useArchivedAssets = argv.includes('--archived-assets');
function fail(message) {
console.error(`generate-devicefp-jsdom: ${message}`);
process.exit(2);
}
if (!htmlPath || !goodsUrl || !outputPath) {
fail('用法: node scripts/generate-devicefp-jsdom.mjs --html <goods.html> --goods-url <URL> --output <fp.json> [--cookies <session.json>]');
}
if (!fs.existsSync(htmlPath)) fail(`HTML 不存在: ${htmlPath}`);
if (cookiePath && !fs.existsSync(cookiePath)) fail(`会话文件不存在: ${cookiePath}`);
const archived = {
'vendor.': path.join(ROOT, 'replay', 'vendor.js'),
'cgiVendor.': path.join(ROOT, 'replay', 'cgiVendor.js'),
'goodsBiz.': path.join(ROOT, 'replay', 'goodsBiz.js'),
'goods.': path.join(ROOT, 'replay', 'goods.js'),
};
class GoodsLoader extends ResourceLoader {
fetch(url, options) {
if (!useArchivedAssets) return super.fetch(url, options);
try {
const file = new URL(url).pathname.split('/').pop();
for (const [prefix, localPath] of Object.entries(archived)) {
if (file.startsWith(prefix)) return Promise.resolve(fs.readFileSync(localPath));
}
} catch {
// jsdom will surface resource errors through its virtual console.
}
return Promise.resolve(Buffer.from(''));
}
}
const session = cookiePath ? JSON.parse(fs.readFileSync(cookiePath, 'utf8')) : {};
const cookies = session.cookies || {};
const captured = [];
const resourceErrors = [];
const dom = new JSDOM(fs.readFileSync(htmlPath, 'utf8'), {
url: goodsUrl,
referrer: 'https://z.iwan.yyb.qq.com/',
pretendToBeVisual: true,
runScripts: 'dangerously',
resources: new GoodsLoader(),
beforeParse(window) {
patchEnvironment(window);
for (const [name, value] of Object.entries(cookies)) {
if (value) window.document.cookie = `${name}=${value}`;
}
// goods boot only needs an accepted fp-behv response to continue. Keep all
// traffic local so this process cannot accidentally submit a payment.
const NativeXHR = window.XMLHttpRequest;
window.XMLHttpRequest = class extends NativeXHR {
open(method, url, async = true) {
this.__yybMethod = method;
this.__yybUrl = String(url);
return super.open(method, url, async);
}
send(body) {
const requestBody = String(body || '');
if (this.__yybUrl.includes('fp-behv.fcg')) {
captured.push({ url: this.__yybUrl, body: requestBody });
}
this.readyState = 4;
this.status = 200;
this.responseText = '{"ret":0,"msg":"","interval":10,"page_num":8,"report_flag":0}';
if (typeof this.onreadystatechange === 'function') this.onreadystatechange();
if (typeof this.onload === 'function') this.onload();
}
};
window.addEventListener('error', event => {
resourceErrors.push(String(event.error || event.message || 'unknown error'));
});
},
});
await new Promise(resolve => setTimeout(resolve, Number.isFinite(waitMs) ? waitMs : 12000));
const fp = captured.at(-1);
if (!fp) {
dom.window.close();
fail(`未生成 fp-behv;加载错误: ${resourceErrors.slice(0, 3).join(' | ') || '无'}`);
}
const values = Object.fromEntries(new URLSearchParams(fp.body));
if (!values.SessionID || !values.DeviceFP) {
dom.window.close();
fail('fp-behv 缺少 SessionID 或 DeviceFP');
}
const result = {
generated_at: new Date().toISOString(),
goods_url: goodsUrl,
fp_url: new URL(fp.url, goodsUrl).href,
fp_body: fp.body,
session_id: values.SessionID,
device_fp_length: values.DeviceFP.length,
archived_assets: useArchivedAssets,
};
fs.mkdirSync(path.dirname(outputPath), { recursive: true });
fs.writeFileSync(outputPath, JSON.stringify(result, null, 2) + '\n');
dom.window.close();
console.log(`DeviceFP 已保存: ${outputPath}`);
console.log(`SessionID: ${result.session_id}`);
console.log(`DeviceFP 长度: ${result.device_fp_length}`);
@@ -0,0 +1,405 @@
/**
* jsdom 环境补丁(skill 路径 B 第 4 步,最小化:只补 kepler 实际读取项)。
* 数据源:scripts/capture-kepler-env.mjs 采集的真实浏览器快照。
*
* 补丁项(capture-kepler-env 证明读取):
* canvas 2d + webgl(指纹核心,jsdom 无)
* navigator.platform/languages/maxTouchPoints/vendor/webdriver/...
* screen 尺寸/色深,window 尺寸,plugins/mimeTypes,perf.now
*/
import fs from 'node:fs';
const BROWSER_ENV = {
navigator: {
platform: 'MacIntel',
languages: ['zh-CN', 'zh'],
hardwareConcurrency: 10,
deviceMemory: 16,
maxTouchPoints: 0,
webdriver: false,
vendor: 'Google Inc.',
vendorSub: '',
productSub: '20030107',
onLine: true,
pdfViewerEnabled: true,
},
screen: { width: 1440, height: 900, availWidth: 1440, availHeight: 900, colorDepth: 24, pixelDepth: 24 },
window: { devicePixelRatio: 1, innerWidth: 1440, innerHeight: 900, outerWidth: 1442, outerHeight: 1026 },
plugins: [
['PDF Viewer', 'internal-pdf-viewer', 'Portable Document Format'],
['Chrome PDF Viewer', 'internal-pdf-viewer', 'Portable Document Format'],
['Chromium PDF Viewer', 'internal-pdf-viewer', 'Portable Document Format'],
['Microsoft Edge PDF Viewer', 'internal-pdf-viewer', 'Portable Document Format'],
['WebKit built-in PDF', 'internal-pdf-viewer', 'Portable Document Format'],
],
mimeTypes: [
['application/pdf', 'Portable Document Format', ['pdf']],
['text/pdf', 'Portable Document Format', ['pdf']],
],
// 常见 macOS 字体(measureText 字体探测用)
fonts: ['Arial', 'Helvetica', 'Times New Roman', 'Courier New', 'Verdana', 'Georgia',
'Trebuchet MS', 'Comic Sans MS', 'Palatino Linotype', 'Book Antiqua', 'Tahoma',
'PingFang SC', 'PingFang HK', 'PingFang TC', 'Microsoft YaHei', 'SimSun', 'SimHei',
'Hiragino Sans GB', 'Hiragino Kaku Gothic ProN', 'Menlo', 'Monaco', 'Consolas',
'Lucida Console', 'Arial Black', 'Impact', 'Lucida Sans Unicode'],
};
// 浏览器真实 canvas 64x64 像素 + toDataURL(capture 自真实 Chrome,注入 mock 用)
let BROWSER_CANVAS = null;
try {
BROWSER_CANVAS = JSON.parse(fs.readFileSync('/tmp/browser-canvas64.json', 'utf8'));
} catch (e) {}
// 固定 canvas 输出(浏览器 240x60 指纹画布 toDataURL 约 13762 字符,生成一个同量级固定 PNG)
function makeCanvasDataURL() {
// 构造浏览器同量级、高熵(类真实 PNG)的 data URL,长度 13762 附近
const target = 13762;
const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/';
let s = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAQAAAAEACAIAAAD';
while (s.length < target) {
// 用随机高熵块(基于可复现伪随机,避免每次全变导致调试困难)
let seed = s.length * 2654435761 >>> 0;
for (let i = 0; i < 16 && s.length < target; i++) {
seed = (seed * 1103515245 + 12345) >>> 0;
s += chars[seed % 64];
}
}
s = s.slice(0, target);
return s;
}
export function patchEnvironment(window) {
const klog = (k, v) => { try { if (window.__klog && window.__klog.length < 5000) window.__klog.push(k + '=' + String(v).slice(0, 80)); } catch(e){} };
// Encoding API:kepler 探测 typeof window.TextEncoder(浏览器 "function",jsdom 缺)
if (typeof window.TextEncoder === 'undefined' && typeof globalThis.TextEncoder === 'function') {
window.TextEncoder = globalThis.TextEncoder;
try { window.TextEncoder.prototype = globalThis.TextEncoder.prototype; } catch (e) {}
}
if (typeof window.TextDecoder === 'undefined' && typeof globalThis.TextDecoder === 'function') {
window.TextDecoder = globalThis.TextDecoder;
try { window.TextDecoder.prototype = globalThis.TextDecoder.prototype; } catch (e) {}
}
// Web Audio mock:kepler 仅探测 OfflineAudioContext 存在 + sampleRate + destination.maxChannelCount + createOscillator(浏览器实测 8 条调用)
if (typeof window.OfflineAudioContext === 'undefined') {
const mkAudioParam = (defaultValue, minValue, maxValue, writable) => {
const p = {
defaultValue, minValue, maxValue, automationRate: 'a-rate',
cancelScheduledValues() { return this; }, setValueAtTime() { return this; },
linearRampToValueAtTime() { return this; }, exponentialRampToValueAtTime() { return this; },
setTargetAtTime() { return this; }, setValueCurveAtTime() { return this; },
cancelAndHoldAtTime() { return this; },
};
if (writable) {
// Chrome 150 实测:DynamicsCompressorNode 的 AudioParam.value 可写且钳制到 [min,max]
let val = defaultValue;
Object.defineProperty(p, 'value', {
get() { return val; },
set(v) { val = Math.min(maxValue, Math.max(minValue, v)); },
enumerable: true, configurable: true,
});
} else {
// Chrome 150 实测:OscillatorNode 的 frequency/detune 为 getter-only,严格模式赋值抛 TypeError
Object.defineProperty(p, 'value', {
get() { return defaultValue; },
set() { throw new TypeError('Cannot set property value of #<AudioParam> which has only a getter'); },
enumerable: true, configurable: true,
});
}
return p;
};
const mockNode = () => ({
type: 'sine', value: 0, gain: { value: 1 },
frequency: mkAudioParam(440, -22050, 22050),
detune: mkAudioParam(0, -153600, 153600),
connect() {}, disconnect() {}, start() {}, stop() {}, addEventListener() {}, removeEventListener() {},
});
class MockOfflineAudioContext {
constructor(channels, length, sampleRate) {
this.channels = channels;
this.length = length;
this.sampleRate = sampleRate || 44100;
this.destination = { maxChannelCount: 2, channelCount: 2, channelCountMode: 'explicit' };
this.currentTime = 0;
this.__listeners = {};
// 浏览器实测:startRendering() 后 renderedBuffer 为 AudioBuffer;kepler 读它做音频指纹
Object.defineProperty(this, 'renderedBuffer', {
get: () => ({
getChannelData: () => new Float32Array(1),
length: 1, numberOfChannels: 1, sampleRate: this.sampleRate || 44100, duration: 1 / (this.sampleRate || 44100),
}),
configurable: true,
});
}
addEventListener(type, fn) {
(this.__listeners[type] = this.__listeners[type] || []).push(fn);
}
removeEventListener(type, fn) {
const arr = this.__listeners[type] || [];
const i = arr.indexOf(fn);
if (i !== -1) arr.splice(i, 1);
}
dispatchEvent(ev) {
const arr = this.__listeners[ev.type] || [];
for (const fn of arr.slice()) fn.call(this, ev);
return true;
}
createOscillator() { return mockNode(); }
createGain() { return mockNode(); }
createDynamicsCompressor() {
// Chrome 150 实测值(capture-kepler-env 采集)
return {
threshold: mkAudioParam(-24, -100, 0, true),
knee: mkAudioParam(30, 0, 40, true),
ratio: mkAudioParam(12, 1, 20, true),
attack: mkAudioParam(0.003000000026077032, 0, 1, true),
release: mkAudioParam(0.25, 0, 1, true),
connect() {}, disconnect() {}, addEventListener() {}, removeEventListener() {},
};
}
createAnalyser() { return mockNode(); }
createBiquadFilter() { return mockNode(); }
createBuffer() { return { getChannelData: () => new Float32Array(0), numberOfChannels: 1, length: 0, sampleRate: this.sampleRate }; }
startRendering() {
// Chrome 行为:渲染完成后派发 oncomplete 事件(OfflineAudioCompletionEvent,带 renderedBuffer)
const buf = this.renderedBuffer;
Promise.resolve().then(() => {
const ev = { renderedBuffer: buf, target: this, currentTarget: this, type: 'complete', timeStamp: Date.now() };
if (typeof this.oncomplete === 'function') {
try {
this.oncomplete(ev);
} catch (e) {}
}
try { this.dispatchEvent(ev); } catch (e) {}
});
return Promise.resolve(buf);
}
suspend() { return Promise.resolve(); }
resume() { return Promise.resolve(); }
close() { return Promise.resolve(); }
}
window.OfflineAudioContext = MockOfflineAudioContext;
window.AudioContext = MockOfflineAudioContext;
window.webkitOfflineAudioContext = MockOfflineAudioContext;
}
const nav = window.navigator;
// 带读取日志的 getter(kepler 读取时记录,便于与浏览器对比)
for (const [k, v] of Object.entries(BROWSER_ENV.navigator)) {
try { Object.defineProperty(nav, k, { get: () => { klog('navigator.' + k, v); return v; }, configurable: true }); } catch (e) {}
}
const scr = window.screen;
for (const k of ['width', 'height', 'availWidth', 'availHeight', 'colorDepth', 'pixelDepth']) {
try { Object.defineProperty(scr, k, { get: () => { klog('screen.' + k, BROWSER_ENV.screen[k]); return BROWSER_ENV.screen[k]; }, configurable: true }); } catch (e) {}
}
for (const k of ['devicePixelRatio', 'innerWidth', 'innerHeight', 'outerWidth', 'outerHeight']) {
try { Object.defineProperty(window, k, { get: () => { klog('window.' + k, BROWSER_ENV.window[k]); return BROWSER_ENV.window[k]; }, configurable: true }); } catch (e) {}
}
// plugins / mimeTypes(只读对象,支持索引与 length)
const mkPluginArray = (items) => {
const arr = items.map(([name, filename, description], i) => {
const p = {
name, filename, description,
length: 0, item: (j) => arr[j] || null, namedItem: (n) => arr.find(x => x.name === n) || null,
[Symbol.iterator]: function* () { yield* arr; },
refresh: () => {},
};
p.index = i;
return p;
});
Object.defineProperty(arr, 'length', { value: items.length });
arr.item = (j) => arr[j] || null;
arr.namedItem = (n) => arr.find(x => x.name === n) || null;
arr.refresh = () => {};
arr[Symbol.iterator] = function* () { yield* arr; };
return arr;
};
const plugins = mkPluginArray(BROWSER_ENV.plugins.map(([n, f, d]) => [n, f, d]));
const mimes = mkPluginArray(BROWSER_ENV.mimeTypes.map(([t, d, s]) => [t, d, s.join(',')]));
Object.defineProperty(nav, 'plugins', { value: plugins });
Object.defineProperty(nav, 'mimeTypes', { value: mimes });
// document.fonts(FontFaceSet 简化实现,支持 entries/keys/values/forEach/check/size)
const fontList = BROWSER_ENV.fonts.map(f => ({
family: f, weight: '400', style: 'normal', status: 'loaded',
}));
const fontSet = {
size: fontList.length,
status: 'loaded',
ready: new Promise((res) => res({})),
entries: () => fontList.entries(),
keys: function* () { for (const f of fontList) yield f.family; },
values: function* () { for (const f of fontList) yield f; },
forEach: (cb) => fontList.forEach(cb),
check: (font, text) => true,
load: (font, text) => Promise.resolve([]),
add: () => {}, delete: () => {},
[Symbol.iterator]: function* () { yield* fontList; },
};
try { Object.defineProperty(window.document, 'fonts', { value: fontSet, configurable: true }); } catch (e) {}
// ---- performance 资源条目(kepler 编码资源时序;jsdom 默认无) ----
const perfEntries = [
['https://pay.qq.com/midas/minipay_v2/views/cpay/goods.shtml', 'navigation', 132, 0],
['https://pub.idqqimg.com/pc/misc/sentry/raven.min.js', 'script', 3, 0],
['https://midas.gtimg.cn/midas/minipay_v2/js/vendor.35cb3bc7d38036fed653.js', 'script', 85, 0],
['https://midas.gtimg.cn/midas/minipay_v2/js/cgiVendor.53275fa063461d28e404.js', 'script', 41, 0],
['https://midas.gtimg.cn/midas/minipay_v2/js/app/goodsBiz.47e6a80d478ce0bdfd50..js', 'script', 156, 0],
['https://midas.gtimg.cn/midas/minipay_v2/js/app/goods.e87ce812409f000bf518.js', 'script', 210, 0],
['https://midas.gtimg.cn/midas/minipay_v2/css/goods.e87ce812409f000bf518.css', 'link', 24, 0],
['https://midas.gtimg.cn/midas/minipay_v2/css/vendor.35cb3bc7d38036fed653.css', 'link', 12, 0],
['https://api.unipay.qq.com/v1/r/1450243039/web_page_info', 'xmlhttprequest', 88, 0],
['https://api.unipay.qq.com/cgi-bin/fp-behv.fcg', 'xmlhttprequest', 2, 0],
['https://xui.ptlogin2.qq.com/js/ptlogin_v1.js', 'script', 67, 0],
['https://midas.gtimg.cn/store_config/1561715149975dieRkvjp.png', 'img', 18, 0],
].map(([name, initiatorType, duration, transferSize]) => ({
name, initiatorType, duration, transferSize, startTime: 0, responseEnd: duration, connectEnd: duration, domContentLoadedEventEnd: 0, loadEventEnd: 0,
}));
const perf = window.performance;
try {
perf.getEntries = () => perfEntries.slice();
perf.getEntriesByType = (t) => t === 'resource' ? perfEntries.filter(e => e.initiatorType !== 'navigation') : (t === 'navigation' ? perfEntries.filter(e => e.initiatorType === 'navigation') : []);
perf.timeOrigin = 1786000000000;
if (!('getEntries' in perf) ) { /* ensure own prop */ }
} catch (e) {}
// ---- canvas 2d mock(记录绘制,提供固定 measureText/toDataURL/getImageData) ----
const makeCtx = (canvas) => {
const mark = (m, ...a) => klog('mock2d.' + m, a.map(x => String(x).slice(0, 30)).join('|'));
const ctx = {
canvas,
fillStyle: '#000', strokeStyle: '#000', font: '10px sans-serif', textBaseline: 'alphabetic',
globalAlpha: 1, globalCompositeOperation: 'source-over', lineWidth: 1, lineCap: 'butt',
shadowBlur: 0, shadowColor: 'rgba(0,0,0,0)', shadowOffsetX: 0, shadowOffsetY: 0,
measureText: (t) => {
mark('measureText', t);
// 字体相关宽度(kepler 字体枚举依赖:不同字体宽度不同)
const font = ctx.font || '10px sans-serif';
const fam = String(font).split(/\s+/).pop() || 'sans-serif';
let seed = 5381;
for (const ch of fam) seed = ((seed * 33) ^ ch.charCodeAt(0)) >>> 0;
const fontDelta = (seed % 73) / 2; // 0..36px,显著差异
const hasEmoji = /[\uD800-\uDBFF]|[\u00C0-\u024F]|¯/.test(String(t));
const emojiDelta = hasEmoji ? (seed % 17) : 0;
return { width: String(t).length * 7.2 + fontDelta + emojiDelta, actualBoundingBoxAscent: 10, actualBoundingBoxDescent: 3 };
},
getImageData: (x, y, w, h) => {
mark('getImageData', x, y, w, h);
try { if (window.__itrace) window.__gdAt = window.__itrace.length - 1; } catch (e) {}
if (BROWSER_CANVAS && BROWSER_CANVAS.data && w === 64 && h === 64) {
return { width: 64, height: 64, data: new Uint8ClampedArray(BROWSER_CANVAS.data) };
}
// 浏览器式像素:平滑全渐变(每像素颜色不同,模拟真实渲染的色彩丰富度)
const data = new Uint8ClampedArray(w * h * 4);
for (let py = 0; py < h; py++) {
for (let px = 0; px < w; px++) {
const i = (py * w + px) * 4;
// 平滑渐变:每像素的 RGB 随位置连续变化,产生大量不同颜色
const r = (px * 255 / w) & 0xff;
const g = (py * 255 / h) & 0xff;
const b = ((px + py) * 255 / (w + h)) & 0xff;
data[i] = r; data[i+1] = g; data[i+2] = b;
data[i+3] = 255 - ((px * 3 + py) % 80); // alpha 也变化
}
}
const img = { width: w, height: h, data };
return new Proxy(img, {
get(t, k) {
if (k === 'data') klog('imgData.data', 'len=' + t.data.length);
else if (k === 'width' || k === 'height') klog('imgData.' + k, t[k]);
return t[k];
},
});
},
createImageData: (w, h) => ({ width: w, height: h, data: new Uint8ClampedArray(w * h * 4) }),
createLinearGradient: () => ({ addColorStop: () => {} }),
createRadialGradient: () => ({ addColorStop: () => {} }),
createPattern: () => ({ setTransform: () => {} }),
toDataURL: (type, q) => {
mark('toDataURL');
if (BROWSER_CANVAS && BROWSER_CANVAS.toDataURL) return BROWSER_CANVAS.toDataURL;
const c = (canvas.__tdc = (canvas.__tdc || 0) + 1);
const cc = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/'[c % 32];
return canvas.__dataURL.slice(0, canvas.__dataURL.length - 1 - (c % 16)) + cc + canvas.__dataURL.slice(canvas.__dataURL.length - (c % 16));
},
getTransform: () => ({ a: 1, b: 0, c: 0, d: 1, e: 0, f: 0 }),
setTransform: (...a) => { mark('setTransform'); }, resetTransform: () => { mark('resetTransform'); }, transform: () => { mark('transform'); }, translate: (...a) => { mark('translate', a[0], a[1]); },
rotate: (a) => { mark('rotate', a); }, scale: (...a) => { mark('scale', a[0], a[1]); }, save: () => { mark('save'); }, restore: () => { mark('restore'); }, beginPath: () => { mark('beginPath'); },
closePath: () => { mark('closePath'); }, moveTo: (...a) => { mark('moveTo', a[0], a[1]); }, lineTo: (...a) => { mark('lineTo', a[0], a[1]); }, bezierCurveTo: () => { mark('bezierCurveTo'); },
quadraticCurveTo: () => { mark('quadraticCurveTo'); }, arc: (...a) => { mark('arc', a[0], a[1], a[2]); }, arcTo: () => { mark('arcTo'); }, rect: (...a) => { mark('rect', a[0], a[1], a[2], a[3]); }, fill: (...a) => { mark('fill', a[0]); },
stroke: () => { mark('stroke'); }, clip: () => { mark('clip'); }, fillRect: (...a) => { mark('fillRect', a[0], a[1], a[2], a[3]); }, strokeRect: () => { mark('strokeRect'); }, clearRect: () => { mark('clearRect'); },
fillText: (...a) => { mark('fillText', a[0], a[1], a[2]); }, strokeText: (...a) => { mark('strokeText', a[0]); }, drawImage: () => { mark('drawImage'); }, putImageData: () => { mark('putImageData'); },
isPointInPath: () => false, isPointInStroke: () => false,
};
return ctx;
};
// ---- webgl mock(kepler 调 getContext('webgl') + toDataURL) ----
const makeWebGL = (canvas) => ({
canvas,
drawingBufferWidth: 240, drawingBufferHeight: 60,
getParameter: (p) => {
klog('webgl.getParameter', p);
const map = {
7936: 'WebKit', 7937: 'WebGL 1.0 (OpenGL ES 2.0 Chromium)', 7938: 'WebGL 1.0',
35724: 'WebGL', 37445: 'Google Inc. (NVIDIA)', 37446: 'ANGLE (Apple, Apple M1, OpenGL 4.1)',
3379: 64, 34076: 16384, 34921: 0, 36347: 8192,
};
return map[p] !== undefined ? map[p] : 0;
},
getExtension: (name) => {
klog('webgl.getExtension', name);
return name === 'WEBGL_debug_renderer_info' ? { UNMASKED_VENDOR_WEBGL: 37445, UNMASKED_RENDERER_WEBGL: 37446 } : null;
},
getSupportedExtensions: () => ['ANGLE_instanced_arrays', 'EXT_blend_minmax', 'EXT_texture_filter_anisotropic', 'OES_element_index_uint', 'OES_standard_derivatives', 'WEBGL_debug_renderer_info'],
getContextAttributes: () => ({ alpha: true, antialias: true, depth: true, failIfMajorPerformanceCaveat: false, premultipliedAlpha: true, preserveDrawingBuffer: false, stencil: false, powerPreference: 'default' }),
readPixels: () => {}, getUniformLocation: () => ({}), createBuffer: () => ({}),
createShader: () => ({}), createProgram: () => ({}), getShaderParameter: () => true,
getProgramParameter: () => true, bindBuffer: () => {}, bufferData: () => {},
shaderSource: () => {}, compileShader: () => {}, attachShader: () => {},
linkProgram: () => {}, useProgram: () => {}, vertexAttribPointer: () => {},
enableVertexAttribArray: () => {}, drawArrays: () => {}, viewport: () => {},
clearColor: () => {}, clear: () => {}, enable: () => {}, disable: () => {},
texImage2D: () => {}, texParameteri: () => {}, activeTexture: () => {}, bindTexture: () => {},
uniform1f: () => {}, uniform2f: () => {}, uniform3f: () => {}, uniform1i: () => {},
getAttribLocation: () => 0, getError: () => 0, getShaderInfoLog: () => '', getProgramInfoLog: () => '',
pixelStorei: () => {}, colorMask: () => {}, depthMask: () => {}, depthFunc: () => {},
blendFunc: () => {}, cullFace: () => {}, frontFace: () => {}, lineWidth: () => {},
getFramebufferAttachmentParameter: () => null, isContextLost: () => false,
getContextAttributes: () => ({}), loseContext: () => {}, restoreContext: () => {},
getParameterWithDefault: () => 0,
});
const canvasDataURL = makeCanvasDataURL();
const getContextOrig = window.HTMLCanvasElement.prototype.getContext;
// jsdom canvas 默认尺寸改 64x64(浏览器 kepler 指纹画布尺寸)
try {
Object.defineProperty(window.HTMLCanvasElement.prototype, 'width', {
get() { return 64; }, set() {}, configurable: true,
});
Object.defineProperty(window.HTMLCanvasElement.prototype, 'height', {
get() { return 64; }, set() {}, configurable: true,
});
} catch (e) {}
window.HTMLCanvasElement.prototype.getContext = function (type) {
if (type === '2d') {
if (!this.__ctx2d) {
this.__ctx2d = makeCtx(this);
Object.defineProperty(this, '__dataURL', { value: canvasDataURL, configurable: true });
}
return this.__ctx2d;
}
if (type === 'webgl' || type === 'experimental-webgl') {
if (!this.__webgl) {
this.__webgl = makeWebGL(this);
Object.defineProperty(this, '__dataURL', { value: canvasDataURL, configurable: true });
}
return this.__webgl;
}
return getContextOrig ? getContextOrig.apply(this, arguments) : null;
};
Object.defineProperty(window.HTMLCanvasElement.prototype, 'toDataURL', {
value: function () { return canvasDataURL; },
configurable: true,
});
}
@@ -0,0 +1,352 @@
#!/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 random
import re
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 derive_key1_from_key16, generate_encrypt_msg_offline # noqa: E402
from pyvm.login_profile import midas_login_params # noqa: E402
from pyvm.order_status import ( # noqa: E402
completion_summary,
find_completed_order,
get_official_orders,
order_completion_states,
order_ids,
)
APPID = "1450243039"
GOODS_URL = "https://pay.qq.com/midas/minipay_v2/views/cpay/goods.shtml"
SAVE_URL = f"https://api.unipay.qq.com/v1/r/{APPID}/web_save"
FP_URL = "https://api.unipay.qq.com/cgi-bin/fp-behv.fcg"
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", "from_h5", "webversion",
]
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": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) "
"AppleWebKit/537.36 (KHTML, like Gecko) Chrome/150.0.0.0 Safari/537.36",
"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() -> tuple[str, list]:
runtime_template = ROOT / "replay/live/default"
runtime_args = runtime_template / "args-template.json"
runtime_body = runtime_template / "body.txt"
if runtime_args.exists() and runtime_body.exists():
return runtime_body.read_text(encoding="utf-8"), load_json(runtime_args)
candidates = sorted((ROOT / "replay/live").glob("*/args-template.json"), reverse=True)
for path in candidates:
if path.exists():
body = (path.parent / "body.txt").read_text(encoding="utf-8")
return body, load_json(path)
raise FileNotFoundError("缺少归档 goods body.txt/args-template.json")
def replace_form_value(body: str, name: str, value: str) -> str:
return re.sub(rf"({re.escape(name)}=)[^&]*", rf"\g<1>{value}", body, count=1)
def build_save_body(template: str, order: dict[str, str], cookies: dict[str, str], web_token: str,
anti_token: str, encrypt_msg: str) -> str:
login = midas_login_params(cookies)
values = {
"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"),
"openid": cookies.get("openid", ""),
"openkey": cookies.get("accesstoken", ""),
"sck": hashlib.md5((APPID + cookies.get("accesstoken", "")).encode()).hexdigest().upper(),
"web_token": web_token,
"anti_auto_script_token_id": anti_token,
"pc_st": str(uuid.uuid4()).upper() + str(int(time.time() * 1000)),
"r": str(random.random()),
"t": str(int(time.time() * 1000)),
"encrypt_msg": encrypt_msg,
"session_id": login["session_id"],
"session_type": login["session_type"],
"wx_appid": login["wx_appid"],
"qq_appid": login["qq_appid"],
}
body = template
for name, value in values.items():
body = replace_form_value(body, name, value)
return body
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
output.parent.mkdir(parents=True, exist_ok=True)
segno.make(sign).save(str(output), scale=6, border=2)
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."""
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,
}
path.write_text(json.dumps(record, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
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=12, help="jsdom 等待 DeviceFP 的秒数")
parser.add_argument("--zone-id", default="1", help="所选游戏区服 ID")
parser.add_argument("--pf", default="", help="所选 Android/iOS 支付平台标识")
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="仅生成付款码,不等待订单完成")
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()
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}"
out_dir.mkdir(parents=True, exist_ok=True)
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"
html_path.write_text(html, encoding="utf-8")
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())
(out_dir / "fp-response.json").write_text(fp_response, encoding="utf-8")
template, web_args = load_template_args()
key16 = [random.randrange(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])
random_suffix = "".join(random.choices(string.ascii_letters + string.digits, k=8)) + "\x01"
params = {
"token_id": order.get("token_id", ""),
"openid": cookies.get("openid", ""),
"openkey": cookies.get("accesstoken", ""),
"session_id": "hy_gameid",
"session_type": "wc_actoken",
"zoneid": "1",
"pay_method": "wechat",
"buy_quantity": "1",
"mb_pwd": "",
"pay_id": "",
"auth_key": "",
"card_value": "",
"accounttype": "",
"provide_uin": "",
"extend": "",
"from_h5": "1",
"webversion": "web_1.0.6",
}
# The archived page body contains server-selected payment fields. Preserve
# them, replacing only values that are tied to the fresh order/session.
for field in ORDER_FIELDS:
match = re.search(rf"(?:^|&){field}=([^&]*)", template)
if match:
params[field] = match.group(1)
params.update(midas_login_params(cookies))
now_seconds = str(int(time.time()))
encrypt_msg = generate_encrypt_msg_offline(
params, "tdrc_session%3D" + fp["session_id"], now_seconds, random_suffix,
key16=key16, key1=key1, args_template=web_args, xmidas=xmidas, xmidas_token=web_token,
)
body = build_save_body(template, order, cookies, web_token, anti_token, encrypt_msg)
print("[jsdom-pay] 提交 web_save...")
raw = request_bytes(SAVE_URL, cookies, body.encode())
(out_dir / "web-save-response.json").write_text(raw, encoding="utf-8")
try:
response_json = json.loads(raw)
except json.JSONDecodeError:
response_json = {}
if response_json.get("ret") != 0:
print(f"[jsdom-pay] web_save 失败: ret={response_json.get('ret')} {response_json.get('msg', '')}")
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
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}")
print(f"[jsdom-pay] 付款链接: {sign}")
if args.skip_payment_check:
print("[jsdom-pay] 已跳过付款结果检查。")
return 0
print("[jsdom-pay] 记录付款前订单列表,等待微信付款完成...")
baseline_document = get_official_orders(cookies)
baseline_states = order_completion_states(baseline_document)
status_path = out_dir / "payment-status.json"
save_payment_status(status_path, baseline_document, baseline_states)
deadline = time.monotonic() + args.payment_timeout
while time.monotonic() < deadline:
time.sleep(args.payment_interval)
document = get_official_orders(cookies)
completed = find_completed_order(document, baseline_states)
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
@@ -0,0 +1,303 @@
#!/usr/bin/env python3
"""应用宝 QQ 二维码登录(纯 Python,无浏览器自动化)。"""
from __future__ import annotations
import argparse
import json
import os
import re
import secrets
import tempfile
import time
import uuid
from dataclasses import dataclass
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
ROOT = Path(__file__).resolve().parent.parent
OPEN_APPID = "102033112"
PT_APPID = "716027609"
PT_DAID = "383"
CALLBACK = "https://yybadaccess.3g.qq.com/pc_yyb/pcyyb_oauth?login_type=QC"
GRAPH_SHOW = "https://graph.qq.com/oauth2.0/show"
XLOGIN = "https://xui.ptlogin2.qq.com/cgi-bin/xlogin"
QR_SHOW = "https://xui.ptlogin2.qq.com/ssl/ptqrshow"
QR_POLL = "https://xui.ptlogin2.qq.com/ssl/ptqrlogin"
USER_INFO = "https://yybadaccess.3g.qq.com/pc_yyb/pcyyb_get_user_info"
LOGIN_JUMP = "https://graph.qq.com/oauth2.0/login_jump"
USER_AGENT = (
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) "
"AppleWebKit/537.36 (KHTML, like Gecko) Chrome/151.0.0.0 Safari/537.36"
)
class NoRedirect(HTTPRedirectHandler):
"""Keep OAuth redirects visible while CookieJar receives Set-Cookie headers."""
def redirect_request(self, req, fp, code, msg, headers, newurl): # noqa: D401
return None
@dataclass
class Response:
status: int
body: bytes
headers: object
@property
def text(self) -> str:
return self.body.decode("utf-8", "replace")
def location(self) -> str:
return str(self.headers.get("Location", ""))
class Client:
def __init__(self) -> None:
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:
request_headers = {"User-Agent": USER_AGENT, "Accept": "*/*"}
if referer:
request_headers["Referer"] = referer
request_headers.update(headers or {})
request = Request(url, data=body, headers=request_headers, method=method)
try:
response = self.opener.open(request, timeout=30)
except HTTPError as error:
response = error
return Response(response.status, response.read(), response.headers)
def cookie(self, name: str) -> str:
for cookie in self.jar:
if cookie.name == name:
return cookie.value
return ""
def cookies(self) -> dict[str, str]:
return {cookie.name: cookie.value for cookie in self.jar}
def js_int32(value: int) -> int:
value &= 0xFFFFFFFF
return value - 0x100000000 if value >= 0x80000000 else value
def ptqr_token(qrsig: str) -> int:
"""QQ QR's zero-seeded JS 32-bit hash used as ptqrtoken."""
result = 0
for char in qrsig:
result += (js_int32(result) << 5) + ord(char)
return js_int32(result) & 0x7FFFFFFF
def g_tk(p_skey: str) -> int:
"""QQ OAuth's 5381-seeded hash used as g_tk."""
result = 5381
for char in p_skey:
result += (js_int32(result) << 5) + ord(char)
return js_int32(result) & 0x7FFFFFFF
def parse_poll(body: str) -> tuple[int, str]:
match = re.search(r"ptuiCB\((.*)\)", body, re.S)
if not match:
raise ValueError("QQ 二维码轮询响应缺少 ptuiCB")
values = re.findall(r"'([^']*)'", match.group(1))
if not values or not values[0].lstrip("-").isdigit():
raise ValueError("QQ 二维码轮询响应格式异常")
callback = next((value for value in values[1:] if value.startswith("https://")), "")
return int(values[0]), callback
def login_type_header(value: str) -> str:
try:
return {"QC": "1", "MOBILEQ": "1", "WX": "2"}[value]
except KeyError as exc:
raise ValueError(f"未知应用宝登录类型: {value!r}") from exc
def authorize_params(state: str, p_skey: str) -> dict[str, str]:
return {
"auth_time": str(int(time.time() * 1000)),
"client_id": OPEN_APPID,
"from_ptlogin": "1",
"g_tk": str(g_tk(p_skey)),
"openapi": "1010",
"redirect_uri": CALLBACK,
"response_type": "code",
"scope": "",
"src": "1",
"state": state,
"switch": "",
"ui": str(uuid.uuid4()).upper(),
"update_auth": "1",
}
def write_session(path: Path, cookies: dict[str, str]) -> None:
"""Merge QQ OAuth results while retaining mall data in the session file."""
document: dict = {}
if path.exists():
try:
document = json.loads(path.read_text(encoding="utf-8"))
except json.JSONDecodeError as exc:
raise ValueError(f"会话文件不是有效 JSON: {path}") from exc
prior = document.get("cookies", {})
if not isinstance(prior, dict):
prior = {}
document["cookies"] = {**prior, **cookies}
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)
try:
with os.fdopen(fd, "w", encoding="utf-8") as handle:
json.dump(document, handle, ensure_ascii=False, indent=2)
handle.write("\n")
handle.flush()
os.fsync(handle.fileno())
os.chmod(temporary, 0o600)
os.replace(temporary, path)
os.chmod(path, 0o600)
except BaseException:
Path(temporary).unlink(missing_ok=True)
raise
def main() -> int:
parser = argparse.ArgumentParser(description="应用宝 QQ 扫码登录(纯 Python)")
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="轮询间隔秒数")
args = parser.parse_args()
if args.timeout <= 0 or args.interval <= 0:
raise ValueError("--timeout 和 --interval 必须为正数")
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,
}
show_url = f"{GRAPH_SHOW}?{urlencode(show_query)}"
show = client.request(show_url, referer="https://m.yyb.qq.com/")
if show.status != 200:
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,
"pt_3rd_aid": OPEN_APPID,
"pt_feedback_link": f"https://support.qq.com/products/77942?customInfo=.appid{OPEN_APPID}",
"theme": "2", "verify_theme": "",
}
xlogin_url = f"{XLOGIN}?{urlencode(xlogin_query)}"
xlogin = client.request(xlogin_url, referer=show_url)
if xlogin.status != 200:
raise RuntimeError(f"QQ 登录页初始化失败: HTTP {xlogin.status}")
login_sig = client.cookie("pt_login_sig")
if not login_sig:
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,
}
qr_url = f"{QR_SHOW}?{urlencode(qr_query)}"
image = client.request(qr_url, referer=xlogin_url)
if image.status != 200 or not image.body:
raise RuntimeError(f"QQ 二维码请求失败: HTTP {image.status}")
qrsig = client.cookie("qrsig")
if not qrsig:
raise RuntimeError("QQ 二维码响应未写入 qrsig")
args.qr.parent.mkdir(parents=True, exist_ok=True)
args.qr.write_bytes(image.body)
print(f"QQ 登录二维码: {args.qr}")
print("请用 QQ 扫码并在手机确认;本终端将自动继续。")
deadline = time.monotonic() + args.timeout
callback = ""
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,
"pt_js_version": "c1987b96",
}
poll = client.request(f"{QR_POLL}?{urlencode(poll_query)}", referer=xlogin_url)
if poll.status != 200:
raise RuntimeError(f"QQ 二维码轮询失败: HTTP {poll.status}")
code, callback = parse_poll(poll.text)
if code == 0:
break
if code in (65, 68):
raise RuntimeError("QQ 二维码已失效,请重新执行登录")
if code not in (66, 67):
raise RuntimeError(f"QQ 二维码登录失败: ptuiCB={code}")
time.sleep(args.interval)
else:
raise TimeoutError("QQ 二维码轮询超时")
if not callback:
raise RuntimeError("QQ 登录成功响应缺少 check_sig 回调")
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"):
raise RuntimeError("QQ check_sig 未跳转到 OAuth login_jump")
jump = client.request(login_jump, referer=callback)
if jump.status != 200:
raise RuntimeError(f"QQ OAuth login_jump 失败: HTTP {jump.status}")
p_skey = client.cookie("p_skey")
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,
headers={"Content-Type": "application/x-www-form-urlencoded"},
)
oauth_callback = authorize.location()
if authorize.status != 302 or not oauth_callback.startswith(CALLBACK):
raise RuntimeError("QQ OAuth authorize 未跳转到 YYB 回调")
yyb_callback = client.request(oauth_callback, referer="https://graph.qq.com/")
if yyb_callback.status != 302:
raise RuntimeError(f"YYB QQ OAuth 回调失败: HTTP {yyb_callback.status}")
cookies = client.cookies()
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/",
})
if info.status != 200:
raise RuntimeError(f"YYB QQ 登录态校验失败: HTTP {info.status}")
try:
value = json.loads(info.text)
except json.JSONDecodeError as exc:
raise RuntimeError("YYB QQ 登录态校验返回非 JSON") from exc
if isinstance(value, dict) and value.get("ret") not in (None, 0):
raise RuntimeError(f"YYB QQ 登录态校验失败: ret={value.get('ret')}")
write_session(args.session, cookies)
print(f"QQ 登录成功,cookies 已写入: {args.session}")
return 0
if __name__ == "__main__":
try:
raise SystemExit(main())
except (OSError, TimeoutError, ValueError, RuntimeError) as error:
print(f"QQ 登录失败: {error}")
raise SystemExit(1) from error
@@ -0,0 +1,227 @@
#!/usr/bin/env python3
"""应用宝微信二维码登录(纯 Python,无浏览器自动化)。
二维码由微信 OAuth 生成,用户用微信扫码确认;本脚本轮询授权结果,完成
YYB OAuth 回调后将动态 cookies 合并到 mall-session.json,供后续纯 CK 流程使用。
"""
from __future__ import annotations
import argparse
import gzip
import json
import os
import re
import tempfile
import time
from dataclasses import dataclass
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
ROOT = Path(__file__).resolve().parent.parent
OPEN_APPID = "wxd44977328b36e647"
CALLBACK = "https://yybadaccess.3g.qq.com/pc_yyb/pcyyb_oauth?login_type=WX"
OPEN_QR = "https://open.weixin.qq.com/connect/qrconnect"
POLL_QR = "https://lp.open.weixin.qq.com/connect/l/qrconnect"
USER_INFO = "https://yybadaccess.3g.qq.com/pc_yyb/pcyyb_get_user_info"
HREF = "data:text/css;base64,Ci5pbXBvd2VyQm94IC5xcmNvZGUge3dpZHRoOiAxNjBweDttYXJnaW4tdG9wOjI1cHh9"
USER_AGENT = (
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) "
"AppleWebKit/537.36 (KHTML, like Gecko) Chrome/150.0.0.0 Safari/537.36"
)
class NoRedirect(HTTPRedirectHandler):
"""保留 OAuth 回调的 302 和 Set-Cookie。"""
def redirect_request(self, req, fp, code, msg, headers, newurl): # noqa: D401
return None
@dataclass
class Response:
status: int
body: bytes
@property
def text(self) -> str:
try:
return gzip.decompress(self.body).decode("utf-8", "replace")
except OSError:
return self.body.decode("utf-8", "replace")
class Client:
def __init__(self) -> None:
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:
request_headers = {"User-Agent": USER_AGENT, "Accept": "*/*"}
if referer:
request_headers["Referer"] = referer
request_headers.update(headers or {})
request = Request(url, headers=request_headers, method="GET")
try:
response = self.opener.open(request, timeout=30)
except HTTPError as error:
response = error
return Response(response.status, response.read())
def cookies(self) -> dict[str, str]:
return {cookie.name: cookie.value for cookie in self.jar}
def extract_uuid(page: str) -> str:
for pattern in (
r"var\s+G\s*=\s*['\"]([A-Za-z0-9_-]{8,64})['\"]",
r"connect/qrcode/([A-Za-z0-9_-]{8,64})",
r"uuid=([A-Za-z0-9_-]{8,64})",
):
match = re.search(pattern, page, re.I)
if match:
return match.group(1)
raise ValueError("微信授权页未找到二维码 UUID")
def parse_poll(body: str) -> tuple[int, str]:
errcode = re.search(r"wx_errcode\s*=\s*(-?\d+)", body)
if not errcode:
raise ValueError("二维码轮询响应缺少 wx_errcode")
code = re.search(r"wx_code\s*=\s*['\"]([^'\"]*)['\"]", body)
return int(errcode.group(1)), code.group(1) if code else ""
def login_type_header(value: str) -> str:
try:
return {"MOBILEQ": "1", "WX": "2"}[value]
except KeyError as exc:
raise ValueError(f"未知应用宝登录类型: {value!r}") from exc
def write_session(path: Path, cookies: dict[str, str]) -> None:
"""合并登录结果,保留 mall 的变换数据与用户已有配置。"""
document: dict = {}
if path.exists():
try:
document = json.loads(path.read_text(encoding="utf-8"))
except json.JSONDecodeError as exc:
raise ValueError(f"会话文件不是有效 JSON: {path}") from exc
prior = document.get("cookies", {})
if not isinstance(prior, dict):
prior = {}
document["cookies"] = {**prior, **cookies}
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)
try:
with os.fdopen(fd, "w", encoding="utf-8") as handle:
json.dump(document, handle, ensure_ascii=False, indent=2)
handle.write("\n")
handle.flush()
os.fsync(handle.fileno())
os.chmod(temporary, 0o600)
os.replace(temporary, path)
os.chmod(path, 0o600)
except BaseException:
Path(temporary).unlink(missing_ok=True)
raise
def main() -> int:
parser = argparse.ArgumentParser(description="应用宝微信扫码登录(纯 Python")
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="轮询间隔秒数")
args = parser.parse_args()
if args.timeout <= 0 or args.interval <= 0:
raise ValueError("--timeout 和 --interval 必须为正数")
client = Client()
state = f"{time.time():.6f}"
query = {
"appid": OPEN_APPID,
"fast_login": "0",
"href": HREF,
"redirect_uri": CALLBACK,
"response_type": "code",
"scope": "snsapi_login,snsapi_runtime_pcsdk",
"self_redirect": "true",
"state": state,
}
authorization_url = f"{OPEN_QR}?{urlencode(query)}"
page = client.request(authorization_url, referer="https://m.yyb.qq.com/")
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)
if image.status != 200 or not image.body:
raise RuntimeError(f"微信二维码请求失败: HTTP {image.status}")
args.qr.parent.mkdir(parents=True, exist_ok=True)
args.qr.write_bytes(image.body)
print(f"微信登录二维码: {args.qr}")
print("请用微信扫码并在手机确认;本终端将自动继续。")
deadline = time.monotonic() + args.timeout
last = ""
code = ""
while time.monotonic() < deadline:
poll_query = {"uuid": uuid}
if last:
poll_query["last"] = last
poll = client.request(f"{POLL_QR}?{urlencode(poll_query)}", referer=authorization_url)
errcode, code = parse_poll(poll.text)
if errcode == 405:
break
if errcode == 402:
raise RuntimeError("二维码已过期,请重新执行登录")
if errcode == 403:
raise RuntimeError("用户取消了扫码登录")
if errcode == 404:
last = "404"
time.sleep(args.interval)
else:
raise TimeoutError("二维码轮询超时")
if not code:
raise RuntimeError("扫码成功响应缺少 OAuth code")
callback_url = f"{CALLBACK}&{urlencode({'code': code, 'state': state})}"
callback = client.request(callback_url, referer=authorization_url)
if callback.status not in (200, 302):
raise RuntimeError(f"YYB OAuth 回调失败: HTTP {callback.status}")
cookies = client.cookies()
openid = cookies.get("openid", "")
access_token = cookies.get("accesstoken", "")
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/",
})
if info.status != 200:
raise RuntimeError(f"YYB 登录态校验失败: HTTP {info.status}")
try:
value = json.loads(info.text)
except json.JSONDecodeError as exc:
raise RuntimeError("YYB 登录态校验返回非 JSON") from exc
if isinstance(value, dict) and value.get("ret") not in (None, 0):
raise RuntimeError(f"YYB 登录态校验失败: ret={value.get('ret')}")
write_session(args.session, cookies)
print(f"登录成功,cookies 已写入: {args.session}")
return 0
if __name__ == "__main__":
try:
raise SystemExit(main())
except (OSError, TimeoutError, ValueError, RuntimeError) as error:
print(f"登录失败: {error}")
raise SystemExit(1) from error
@@ -0,0 +1,203 @@
#!/usr/bin/env python3
"""用已登录的 YYB CK 选择和平精英点券档位、区服和角色。"""
from __future__ import annotations
import argparse
import json
import random
import re
import sys
import time
import uuid
from pathlib import Path
from urllib.parse import unquote, urlencode
from curl_cffi import requests
ROOT = Path(__file__).resolve().parent.parent
sys.path.insert(0, str(ROOT))
from pyvm.login_profile import midas_login_params # noqa: E402
YYB_APP_ID = 52575843
SOURCE_ID = "24292013"
PRODUCTS_URL = "https://ydd.yyb.qq.com/new_direct_buy_shop/GetTokenMod"
CMALL_URL = "https://storeapi.pay.qq.com/api/unipay/{offer_id}/cmall_query"
PLATFORMS = {
"android": {
"label": "Android",
"query_platform": "pc_android",
"cmall_pf": "mds_storeopen_qb-__mds_myappjp_PC_aW9zd2hpdGVsaX0-android",
"order_pf": "mds_myappjp-__mds_myappjp_PC_aW9zd2hpdGVsaX0-android",
},
"ios": {
"label": "iOS",
"query_platform": "pc_ios",
"cmall_pf": "mds_storeopen_qb-__mds_myappjp_PC_aW9zd2hpdGVsaX0-iap",
"order_pf": "mds_myappjp-__mds_myappjp_PC_aW9zd2hpdGVsaX0-iap",
},
}
UA = (
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) "
"AppleWebKit/537.36 (KHTML, like Gecko) Chrome/150.0.0.0 Safari/537.36"
)
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}
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]]:
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,
)
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', '')}")
products = document.get("token_mod", {}).get("products", [])
result: list[dict[str, str | int]] = []
for item in products:
product = item.get("product", {}) if isinstance(item, dict) else {}
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.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):
raise RuntimeError("点券商品响应缺少 product_id 或 offer_id")
return result
class Cmall:
def __init__(self, cookies: dict[str, str], offer_id: str, platform: str) -> None:
self.cookies = cookies
self.offer_id = offer_id
self.platform = platform
self.session_token = f"{str(uuid.uuid4()).upper()}{int(time.time() * 1000)}"
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,
"session_token": self.session_token,
"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,
)
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', '')}")
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")]
def roles(self, zone_id: str) -> list[dict[str, str]]:
response = self.query("15", use_currency_offerid="1", zoneid=zone_id)
roles = response.get("role_info") or response.get("role_list") or []
return [{"role_id": str(item.get("role_id", "")),
"name": unquote(str(item.get("role_name", ""))),
"ban_status": str(item.get("ban_status", ""))}
for item in roles if isinstance(item, dict) and item.get("role_id")]
def choose(label: str, options: list[dict], display) -> dict:
if not options:
raise RuntimeError(f"没有可选择的{label}")
print(f"\n可选{label}:")
for index, item in enumerate(options, start=1):
print(f" {index}. {display(item)}")
while True:
raw = input(f"请选择{label}序号 [1-{len(options)}]: ").strip()
if raw.isdigit() and 1 <= int(raw) <= len(options):
return options[int(raw) - 1]
print("请输入列表中的有效序号。")
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="仅列出当前所有点券档位")
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()],
lambda item: item["label"],
)
platform = str(selected_platform["id"])
else:
platform = args.platform
print(f"已选择平台: {PLATFORMS[platform]['label']}")
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']}")
return 0
if args.points is None:
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)
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']}")
roles = cmall.roles(zone["zone_id"])
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"]}
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']}")
print(f"选择已保存: {args.output}")
return 0
if __name__ == "__main__":
try:
raise SystemExit(main())
except (OSError, ValueError, RuntimeError, requests.RequestsError) as error:
print(f"选择失败: {error}")
raise SystemExit(1) from error
@@ -0,0 +1,338 @@
#!/usr/bin/env python3
"""Small local HTTP worker for the YYB admin integration.
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
import base64
import importlib.util
import json
import os
import re
import subprocess
import threading
import time
import uuid
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from pathlib import Path
from urllib.parse import urlparse
ROOT = Path(__file__).resolve().parent.parent
DEFAULT_DATA = ROOT / "config" / "worker-jobs"
WORKER_KEY = os.environ.get("YYB_WORKER_KEY", "")
_jobs: dict[str, dict] = {}
_lock = threading.Lock()
def _load_selector():
path = ROOT / "scripts" / "select-peace-elite.py"
spec = importlib.util.spec_from_file_location("yyb_worker_selector", path)
if not spec or not spec.loader:
raise RuntimeError("无法加载和平精英选择器")
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
return module
def _job_dir(job_id: str) -> Path:
return Path(_jobs[job_id]["directory"])
def _safe_log(job: dict, line: str) -> None:
# Do not persist cookies, payment URI, or long opaque tokens in the worker API.
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)
with _lock:
job["logs"] = (job.get("logs", []) + [clean.strip()])[-100:]
def _run_process(job_id: str, command: list[str], phase: str) -> None:
job = _jobs[job_id]
environment = os.environ.copy()
environment.pop("NODE_OPTIONS", None)
with _lock:
job["phase"] = phase
job["status"] = "running"
try:
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()
with _lock:
job["process_pid"] = None
if code != 0:
job["status"] = "failed"
job["phase"] = phase
job["message"] = f"{phase}失败(退出码 {code}"
elif phase == "login":
job["status"] = "ready"
job["phase"] = "selection"
job["message"] = "登录成功,请选择平台、点券、区服和角色"
else:
job["status"] = "success"
job["phase"] = "completed"
job["message"] = "付款流程已完成"
except Exception as exc: # noqa: BLE001
with _lock:
job["status"] = "failed"
job["message"] = str(exc)
def _start_login(job_id: str, provider: str, timeout: int) -> None:
job = _jobs[job_id]
directory = _job_dir(job_id)
session = directory / "mall-session.json"
qr = directory / ("qq-login.jpg" if provider == "qq" else "wechat-login.jpg")
command = ["python3", 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()
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"))
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):
raise ValueError("当前登录态不支持该点券档位")
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)
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}
def _start_payment(job_id: str, selection: dict) -> None:
directory = _job_dir(job_id)
session = directory / "mall-session.json"
response = directory / "mall-order-response.json"
output = directory / "jsdom-order"
command = ["python3", "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("order_pf"):
command.extend(["--pf", str(selection["order_pf"])])
def run() -> None:
_run_process(job_id, command, "order")
job = _jobs[job_id]
if job.get("status") != "success" or not response.exists():
return
pay_command = ["python3", "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", ""))]
_run_process(job_id, pay_command, "payment")
threading.Thread(target=run, daemon=True).start()
def _stop_job(job_id: str) -> None:
if job_id not in _jobs:
raise ValueError("任务不存在")
pid = _jobs[job_id].get("process_pid")
if pid:
try:
os.kill(int(pid), 15)
except ProcessLookupError:
pass
with _lock:
_jobs[job_id]["status"] = "failed"
_jobs[job_id]["phase"] = "stopped"
_jobs[job_id]["message"] = "任务已停止"
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"}}
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"
output = Path(job["directory"]) / "jsdom-order"
for name in ("wechat-pay.png", "payment-status.json"):
path = None
if output.exists():
direct = output / name
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"
elif path:
try:
status = json.loads(path.read_text(encoding="utf-8"))
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,
"matched_completion": {
"is_finished": matched.get("is_finished"),
"status": matched.get("status"),
} if isinstance(matched, dict) else None,
}
except (OSError, json.JSONDecodeError):
pass
return result
class Handler(BaseHTTPRequestHandler):
server_version = "YYBWorker/1"
def _json(self, status: int, value: dict) -> None:
body = json.dumps(value, ensure_ascii=False).encode("utf-8")
self.send_response(status)
self.send_header("Content-Type", "application/json; charset=utf-8")
self.send_header("Content-Length", str(len(body)))
self.end_headers()
self.wfile.write(body)
def _body(self) -> dict:
length = int(self.headers.get("Content-Length", "0"))
return json.loads(self.rfile.read(length) or b"{}")
def _authorized(self) -> bool:
if not WORKER_KEY:
return True
value = self.headers.get("Authorization", "")
return value == f"Bearer {WORKER_KEY}"
def do_POST(self) -> None: # noqa: N802
if not self._authorized():
return self._json(401, {"detail": "未授权"})
path = urlparse(self.path).path.strip("/").split("/")
try:
if path == ["v1", "jobs"]:
job_id = uuid.uuid4().hex[:16]
directory = DEFAULT_DATA / job_id
directory.mkdir(parents=True, exist_ok=True)
_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]
body = self._body()
if job_id not in _jobs or body.get("provider") not in {"qq", "wechat"}:
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":
job_id = path[2]
body = self._body()
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")
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)
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)
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)
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"],
"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"],
"order_pf": selector.PLATFORMS[body["platform"]]["order_pf"],
}
_jobs[job_id]["phase"] = "payment"
return self._json(200, _public_job(job_id))
if len(path) == 4 and path[:2] == ["v1", "jobs"] and path[3] == "payment":
job_id = path[2]
if job_id not in _jobs or not _jobs[job_id].get("selection"):
return self._json(400, {"detail": "请先完成角色选择"})
_start_payment(job_id, _jobs[job_id]["selection"])
return self._json(202, _public_job(job_id))
if len(path) == 4 and path[:2] == ["v1", "jobs"] and path[3] == "stop":
_stop_job(path[2])
return self._json(200, _public_job(path[2]))
return self._json(404, {"detail": "接口不存在"})
except Exception as exc: # noqa: BLE001
return self._json(500, {"detail": str(exc)})
def do_GET(self) -> None: # noqa: N802
if urlparse(self.path).path == "/health":
return self._json(200, {"status": "ok"})
if not self._authorized():
return self._json(401, {"detail": "未授权"})
path = urlparse(self.path).path.strip("/").split("/")
if len(path) == 3 and path[:2] == ["v1", "jobs"] and path[2] in _jobs:
return self._json(200, _public_job(path[2]))
return self._json(404, {"detail": "接口不存在"})
def log_message(self, fmt: str, *args) -> None:
return
def main() -> int:
global DEFAULT_DATA, WORKER_KEY
parser = argparse.ArgumentParser(description="YYB admin worker")
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("--key", default=None, help="HTTP Bearer 鉴权密钥;默认读取 YYB_WORKER_KEY")
args = parser.parse_args()
DEFAULT_DATA = args.data_dir
if args.key is not None:
WORKER_KEY = args.key
if args.host not in {"127.0.0.1", "localhost", "::1"} and not WORKER_KEY:
parser.error("监听非本机地址时必须设置 --key 或 YYB_WORKER_KEY")
DEFAULT_DATA.mkdir(parents=True, exist_ok=True)
server = ThreadingHTTPServer((args.host, args.port), Handler)
print(f"YYB worker listening on {args.host}:{args.port}", flush=True)
server.serve_forever()
return 0
if __name__ == "__main__":
raise SystemExit(main())
+2
View File
@@ -154,6 +154,8 @@ _SENSITIVE_COLUMNS: tuple[tuple[str, str], ...] = (
("huya_register_items", "password"),
("huya_register_items", "cookie"),
("huya_register_success_logs", "password"),
("yyb_recharge_tasks", "login_qr_data"),
("yyb_recharge_tasks", "payment_qr_data"),
("proxy_config", "api_url"),
("proxy_config", "http"),
("proxy_config", "https"),
+6 -1
View File
@@ -12,7 +12,7 @@ from fastapi.responses import FileResponse
from starlette.middleware.base import BaseHTTPMiddleware
from .database import init_db
from .routers import auth, users, accounts, account_check, dashboard, login, proxy, cookies, huya, douyu
from .routers import auth, users, accounts, account_check, dashboard, login, proxy, cookies, huya, douyu, yyb
from .schemas import AppInfo
from .version import get_app_version
from utils import setup_logger
@@ -41,6 +41,10 @@ async def lifespan(app: FastAPI):
cleaned_douyu = cleanup_orphan_douyu_tasks(db, message="任务已中断(服务重启)")
if cleaned_douyu:
logger.info(f"启动清理斗鱼残留任务: {cleaned_douyu}")
from .services.yyb_service import cleanup_orphan_yyb_tasks
cleaned_yyb = cleanup_orphan_yyb_tasks(db, message="任务已中断(服务重启)")
if cleaned_yyb:
logger.info(f"启动清理应用宝残留任务: {cleaned_yyb}")
finally:
db.close()
yield
@@ -97,6 +101,7 @@ app.include_router(proxy.router)
app.include_router(cookies.router)
app.include_router(huya.router)
app.include_router(douyu.router)
app.include_router(yyb.router)
@app.get("/api/health")
@@ -0,0 +1,49 @@
"""增加应用宝和平精英充值任务"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
revision: str = "20260812_0020"
down_revision: Union[str, None] = "20260808_0019"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
bind = op.get_bind()
if sa.inspect(bind).has_table("yyb_recharge_tasks"):
return
op.create_table(
"yyb_recharge_tasks",
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
sa.Column("task_id", sa.String(64), nullable=False),
sa.Column("created_by", sa.Integer(), sa.ForeignKey("users.id"), nullable=False),
sa.Column("worker_job_id", sa.String(64), nullable=False),
sa.Column("provider", sa.String(16), nullable=False, server_default=""),
sa.Column("platform", sa.String(16), nullable=False, server_default="android"),
sa.Column("points", sa.Integer(), nullable=True),
sa.Column("product_id", sa.String(128), nullable=True, server_default=""),
sa.Column("zone_id", sa.String(64), nullable=True, server_default=""),
sa.Column("zone_name", sa.String(128), nullable=True, server_default=""),
sa.Column("role_id", sa.String(64), nullable=True, server_default=""),
sa.Column("role_name", sa.String(128), nullable=True, server_default=""),
sa.Column("status", sa.String(32), nullable=False, server_default="created"),
sa.Column("phase", sa.String(32), nullable=False, server_default="login"),
sa.Column("message", sa.String(512), nullable=True, server_default=""),
sa.Column("result", sa.JSON(), nullable=True),
sa.Column("login_qr_data", sa.Text(), nullable=True),
sa.Column("payment_qr_data", sa.Text(), nullable=True),
sa.Column("created_at", sa.DateTime(), nullable=True),
sa.Column("finished_at", sa.DateTime(), nullable=True),
)
op.create_index("uq_yyb_recharge_tasks_task_id", "yyb_recharge_tasks", ["task_id"], unique=True)
op.create_index("ix_yyb_recharge_tasks_created_by", "yyb_recharge_tasks", ["created_by"])
op.create_index("ix_yyb_recharge_tasks_worker_job_id", "yyb_recharge_tasks", ["worker_job_id"])
op.create_index("ix_yyb_recharge_tasks_status", "yyb_recharge_tasks", ["status"])
def downgrade() -> None:
bind = op.get_bind()
if sa.inspect(bind).has_table("yyb_recharge_tasks"):
op.drop_table("yyb_recharge_tasks")
@@ -0,0 +1,28 @@
"""扩展应用宝二维码密文字段容量"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
revision: str = "20260812_0021"
down_revision: Union[str, None] = "20260812_0020"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def _is_mysql() -> bool:
return op.get_bind().dialect.name in {"mysql", "mariadb"}
def upgrade() -> None:
if _is_mysql():
op.execute("ALTER TABLE yyb_recharge_tasks MODIFY login_qr_data MEDIUMTEXT NULL")
op.execute("ALTER TABLE yyb_recharge_tasks MODIFY payment_qr_data MEDIUMTEXT NULL")
def downgrade() -> None:
if _is_mysql():
op.execute("ALTER TABLE yyb_recharge_tasks MODIFY login_qr_data TEXT NULL")
op.execute("ALTER TABLE yyb_recharge_tasks MODIFY payment_qr_data TEXT NULL")
+29
View File
@@ -125,6 +125,35 @@ class DouyuTask(Base):
account = relationship("Account", back_populates="douyu_tasks")
class YybRechargeTask(Base):
"""应用宝和平精英点券充值任务。
YYB 登录身份与斗鱼账号无关,因此任务只关联创建者,不复用 Account。
"""
__tablename__ = "yyb_recharge_tasks"
id = Column(Integer, primary_key=True, autoincrement=True)
task_id = Column(String(64), unique=True, nullable=False, index=True)
created_by = Column(Integer, ForeignKey("users.id"), nullable=False, index=True)
worker_job_id = Column(String(64), nullable=False, index=True)
provider = Column(String(16), default="", nullable=False)
platform = Column(String(16), default="android", nullable=False)
points = Column(Integer, nullable=True)
product_id = Column(String(128), default="")
zone_id = Column(String(64), default="")
zone_name = Column(String(128), default="")
role_id = Column(String(64), default="")
role_name = Column(String(128), default="")
status = Column(String(32), default="created", nullable=False, index=True)
phase = Column(String(32), default="login", nullable=False)
message = Column(String(512), default="")
result = Column(JSON, nullable=True)
login_qr_data = Column(EncryptedText(), default="")
payment_qr_data = Column(EncryptedText(), default="")
created_at = Column(DateTime, default=_utcnow)
finished_at = Column(DateTime, nullable=True)
class DouyuConfig(Base):
"""斗鱼业务配置"""
__tablename__ = "douyu_config"
+8
View File
@@ -26,6 +26,9 @@ PERMISSIONS = {
# 斗鱼活动
"douyu:task": "斗鱼任务管理",
"douyu:config": "斗鱼配置管理",
"yyb:session": "应用宝扫码登录与角色查询",
"yyb:recharge": "应用宝充值与付款",
"yyb:history": "查看应用宝充值历史",
# 虎牙
"huya:account": "虎牙账号管理(兼容旧权限)",
"huya:view_all": "查看所有虎牙账号",
@@ -62,6 +65,9 @@ ROLE_PERMISSIONS = {
"cookie:export",
"douyu:task",
"douyu:config",
"yyb:session",
"yyb:recharge",
"yyb:history",
"huya:account",
"huya:view_all",
"huya:import",
@@ -80,6 +86,8 @@ ROLE_PERMISSIONS = {
"support": [
"account:view_assigned",
"douyu:task",
"yyb:session",
"yyb:history",
"huya:view_assigned",
"huya:task",
],
+124
View File
@@ -0,0 +1,124 @@
"""应用宝和平精英充值工作台 API。"""
from __future__ import annotations
import uuid
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy.orm import Session
from ..database import get_db
from ..deps import get_current_user, require_permission
from ..models import User, YybRechargeTask
from ..permissions import user_has_permission
from ..schemas import YybLoginRequest, YybSelectionRequest, YybTaskCreateRequest
from ..services.yyb_service import public_task, sync_task
from ..services.yyb_worker_client import YybWorkerClient, YybWorkerError
router = APIRouter(prefix="/api/yyb", tags=["应用宝充值"])
def _get_task(db: Session, task_id: int, current: User) -> YybRechargeTask:
task = db.query(YybRechargeTask).filter(YybRechargeTask.id == task_id).first()
if not task:
raise HTTPException(404, "充值任务不存在")
if task.created_by != current.id and not user_has_permission(current, "yyb:history"):
raise HTTPException(403, "无权查看该充值任务")
return task
def _worker_call(call):
try:
return call()
except YybWorkerError as exc:
raise HTTPException(502, str(exc)) from exc
@router.post("/tasks")
def create_task(payload: YybTaskCreateRequest, db: Session = Depends(get_db), current: User = Depends(require_permission("yyb:session"))):
data = _worker_call(YybWorkerClient().create_job)
task = YybRechargeTask(task_id=uuid.uuid4().hex[:16], created_by=current.id,
worker_job_id=str(data["job_id"]), status=str(data.get("status", "created")),
phase="login", message="请选择登录方式")
db.add(task)
db.commit()
db.refresh(task)
return public_task(task, include_qr=False)
@router.post("/tasks/{task_id}/login")
def login(task_id: int, payload: YybLoginRequest, db: Session = Depends(get_db), current: User = Depends(require_permission("yyb:session"))):
task = _get_task(db, task_id, current)
data = _worker_call(lambda: YybWorkerClient().login(task.worker_job_id, payload.provider, payload.timeout))
task.provider = payload.provider
task.status = str(data.get("status", "waiting_login"))
task.phase = "login"
task.message = "请扫码登录并在手机确认"
if data.get("qr_data"):
task.login_qr_data = data["qr_data"]
db.commit()
return public_task(task)
@router.get("/tasks/{task_id}")
def get_task(task_id: int, db: Session = Depends(get_db), current: User = Depends(get_current_user)):
task = _get_task(db, task_id, current)
try:
sync_task(db, task, YybWorkerClient())
except YybWorkerError:
# Worker 暂时重启时仍返回最近一次持久化状态。
pass
return public_task(task, include_qr=user_has_permission(current, "yyb:session"),
include_payment_qr=user_has_permission(current, "yyb:recharge"))
@router.get("/tasks")
def list_tasks(limit: int = Query(50, ge=1, le=200), db: Session = Depends(get_db), current: User = Depends(require_permission("yyb:history"))):
tasks = db.query(YybRechargeTask).order_by(YybRechargeTask.id.desc()).limit(limit).all()
return [public_task(task, include_qr=False) for task in tasks]
@router.get("/tasks/{task_id}/selection-options")
def selection_options(task_id: int, platform: str = Query("android"), points: int | None = Query(None), zone_id: str | None = Query(None), db: Session = Depends(get_db), current: User = Depends(require_permission("yyb:session"))):
task = _get_task(db, task_id, current)
data = _worker_call(lambda: YybWorkerClient().selection_options(task.worker_job_id, platform, points, zone_id))
task.platform = platform
db.commit()
return data
@router.post("/tasks/{task_id}/selection")
def selection(task_id: int, payload: YybSelectionRequest, db: Session = Depends(get_db), current: User = Depends(require_permission("yyb:session"))):
task = _get_task(db, task_id, current)
data = _worker_call(lambda: YybWorkerClient().selection(task.worker_job_id, payload.model_dump()))
selected = data.get("selection", payload.model_dump())
for field in ("platform", "points", "product_id", "zone_id", "zone_name", "role_id", "role_name"):
setattr(task, field, selected[field])
task.phase, task.status, task.message = "payment", "ready", "选择已保存,可以创建付款码"
db.commit()
return public_task(task)
@router.post("/tasks/{task_id}/payment")
def payment(task_id: int, db: Session = Depends(get_db), current: User = Depends(require_permission("yyb:recharge"))):
task = _get_task(db, task_id, current)
if not task.product_id or not task.role_id:
raise HTTPException(400, "请先完成商品、区服和角色选择")
data = _worker_call(lambda: YybWorkerClient().payment(task.worker_job_id))
task.status = str(data.get("status", "running"))
task.phase = "payment"
task.message = "正在创建订单和付款码"
db.commit()
return public_task(task)
@router.post("/tasks/{task_id}/stop")
def stop(task_id: int, db: Session = Depends(get_db), current: User = Depends(require_permission("yyb:session"))):
task = _get_task(db, task_id, current)
data = _worker_call(lambda: YybWorkerClient().stop(task.worker_job_id))
task.status = "failed"
task.phase = "stopped"
task.message = "任务已停止"
db.commit()
return public_task(task)
+19
View File
@@ -13,6 +13,25 @@ from .huya_defaults import (
)
class YybTaskCreateRequest(BaseModel):
"""创建 YYB 任务;登录方式在下一步选择。"""
class YybLoginRequest(BaseModel):
provider: str = Field(..., pattern="^(qq|wechat)$")
timeout: int = Field(600, ge=60, le=1800)
class YybSelectionRequest(BaseModel):
platform: str = Field(..., pattern="^(android|ios)$")
points: int = Field(..., gt=0)
product_id: str = Field(..., min_length=1, max_length=128)
zone_id: str = Field(..., min_length=1, max_length=64)
zone_name: str = Field("", max_length=128)
role_id: str = Field(..., min_length=1, max_length=64)
role_name: str = Field(..., min_length=1, max_length=128)
def _ensure_tz(dt: Optional[datetime]) -> Optional[datetime]:
"""确保 datetime 带有 UTC 时区信息,无时区的视为 UTC。"""
if dt is None:
+74
View File
@@ -0,0 +1,74 @@
"""应用宝充值任务服务。"""
from __future__ import annotations
from datetime import datetime, timezone
from typing import Any
from sqlalchemy.orm import Session
from ..models import YybRechargeTask
from .yyb_worker_client import YybWorkerClient
def _utcnow():
return datetime.now(timezone.utc)
def cleanup_orphan_yyb_tasks(db: Session, message: str) -> int:
rows = db.query(YybRechargeTask).filter(YybRechargeTask.status.in_(["created", "waiting_login", "running", "ready"])).all()
for task in rows:
task.status = "failed"
task.message = message
task.finished_at = _utcnow()
if rows:
db.commit()
return len(rows)
def sync_task(db: Session, task: YybRechargeTask, worker: YybWorkerClient) -> YybRechargeTask:
data = worker.get_job(task.worker_job_id)
task.status = str(data.get("status", task.status))
task.phase = str(data.get("phase", task.phase))
task.message = str(data.get("message", task.message))
if data.get("provider"):
task.provider = str(data["provider"])
if data.get("qr_data"):
task.login_qr_data = str(data["qr_data"])
task.result = {**(task.result or {}), "login_qr_mime_type": data.get("qr_mime_type", "image/jpeg")}
if data.get("payment_qr_data"):
task.payment_qr_data = str(data["payment_qr_data"])
task.result = {**(task.result or {}), "payment_qr_mime_type": data.get("payment_qr_mime_type", "image/png")}
task.result = {
**(task.result or {}),
"logs": data.get("logs", []),
"payment_status": data.get("payment_status"),
}
if task.status in {"success", "failed"} and task.finished_at is None:
task.finished_at = _utcnow()
db.commit()
db.refresh(task)
return task
def public_task(task: YybRechargeTask, include_qr: bool = True,
include_payment_qr: bool | None = None) -> dict[str, Any]:
result: dict[str, Any] = {
"id": task.id, "task_id": task.task_id,
"provider": task.provider, "platform": task.platform, "points": task.points,
"product_id": task.product_id, "zone_id": task.zone_id, "zone_name": task.zone_name,
"role_id": task.role_id, "role_name": task.role_name, "status": task.status,
"phase": task.phase, "message": task.message, "result": task.result,
"created_by": task.created_by, "created_at": task.created_at,
"finished_at": task.finished_at,
}
if task.result:
result["login_qr_mime_type"] = task.result.get("login_qr_mime_type", "image/jpeg")
result["payment_qr_mime_type"] = task.result.get("payment_qr_mime_type", "image/png")
if include_payment_qr is None:
include_payment_qr = include_qr
if include_qr:
result["login_qr_data"] = task.login_qr_data or ""
if include_payment_qr:
result["payment_qr_data"] = task.payment_qr_data or ""
return result
+57
View File
@@ -0,0 +1,57 @@
"""应用宝 Worker HTTP 客户端。"""
from __future__ import annotations
import os
from typing import Any
import requests
class YybWorkerError(RuntimeError):
"""Worker 返回业务错误。"""
class YybWorkerClient:
def __init__(self) -> None:
self.base_url = os.getenv("YYB_WORKER_URL", "http://127.0.0.1:8810").rstrip("/")
self.key = os.getenv("YYB_WORKER_KEY", "")
self.timeout = float(os.getenv("YYB_WORKER_TIMEOUT", "30"))
def _request(self, method: str, path: str, payload: dict[str, Any] | None = None) -> dict[str, Any]:
headers = {"Accept": "application/json"}
if self.key:
headers["Authorization"] = f"Bearer {self.key}"
try:
response = requests.request(method, self.base_url + path, json=payload,
headers=headers, timeout=self.timeout)
data = response.json()
except (requests.RequestException, ValueError) as exc:
raise YybWorkerError(f"应用宝 Worker 不可用: {exc}") from exc
if response.status_code >= 400:
raise YybWorkerError(str(data.get("detail", "Worker 请求失败")))
return data
def create_job(self) -> dict[str, Any]:
return self._request("POST", "/v1/jobs")
def login(self, worker_job_id: str, provider: str, timeout: int = 600) -> dict[str, Any]:
return self._request("POST", f"/v1/jobs/{worker_job_id}/login",
{"provider": provider, "timeout": timeout})
def get_job(self, worker_job_id: str) -> dict[str, Any]:
return self._request("GET", f"/v1/jobs/{worker_job_id}")
def selection_options(self, worker_job_id: str, platform: str,
points: int | None = None, zone_id: str | None = None) -> dict[str, Any]:
return self._request("POST", f"/v1/jobs/{worker_job_id}/selection-options",
{"platform": platform, "points": points, "zone_id": zone_id})
def selection(self, worker_job_id: str, selection: dict[str, Any]) -> dict[str, Any]:
return self._request("POST", f"/v1/jobs/{worker_job_id}/selection", selection)
def payment(self, worker_job_id: str) -> dict[str, Any]:
return self._request("POST", f"/v1/jobs/{worker_job_id}/payment")
def stop(self, worker_job_id: str) -> dict[str, Any]:
return self._request("POST", f"/v1/jobs/{worker_job_id}/stop")
+2
View File
@@ -23,6 +23,7 @@ const HuyaAssignmentsPage = lazy(() => import('./pages/HuyaAssignmentsPage'));
const HuyaCookiePage = lazy(() => import('./pages/HuyaCookiePage'));
const HuyaRegisterPage = lazy(() => import('./pages/HuyaRegisterPage'));
const HuyaTasksPage = lazy(() => import('./pages/HuyaTasksPage'));
const YybRechargePage = lazy(() => import('./pages/YybRechargePage'));
function RouteFallback() {
return (
@@ -81,6 +82,7 @@ function AppContent() {
<Route path="huya/assignments" element={lazyRoute(<HuyaAssignmentsPage />)} />
<Route path="huya/cookies" element={lazyRoute(<HuyaCookiePage />)} />
<Route path="huya/tasks" element={lazyRoute(<HuyaTasksPage />)} />
<Route path="yyb/recharge" element={lazyRoute(<YybRechargePage />)} />
<Route path="proxy" element={lazyRoute(<ProxyPage />)} />
<Route path="users" element={lazyRoute(<UsersPage />)} />
</Route>
+1
View File
@@ -10,3 +10,4 @@ export { huyaApi } from './huya';
export { loginApi } from './login';
export { proxyApi } from './proxy';
export { userApi } from './users';
export { yybApi } from './yyb';
+33
View File
@@ -147,6 +147,39 @@ export interface LoginTaskItem {
finished_at: string | null;
}
export interface YybTask {
id: number;
task_id: string;
worker_job_id?: string;
created_by: number;
provider: string;
platform: 'android' | 'ios';
points: number | null;
product_id: string;
zone_id: string;
zone_name: string;
role_id: string;
role_name: string;
status: string;
phase: string;
message: string;
result: { logs?: string[]; payment_status?: Record<string, unknown> | null } | null;
login_qr_data?: string;
login_qr_mime_type?: string;
payment_qr_data?: string;
payment_qr_mime_type?: string;
created_at: string | null;
finished_at: string | null;
}
export interface YybSelectionOptions {
products: Array<{ points: number; product_id: string; price_fen: number; offer_id: string; name: string }>;
zones: Array<{ zone_id: string; name: string }>;
roles: Array<{ role_id: string; name: string; ban_status: string }>;
default_product: { points: number; product_id: string; price_fen: number; offer_id: string; name: string } | null;
default_zone: { zone_id: string; name: string } | null;
}
export interface BatchLoginResult {
batch_id: string;
count: number;
+17
View File
@@ -0,0 +1,17 @@
import api from './client';
import type { YybTask, YybSelectionOptions } from './types';
export const yybApi = {
createTask: () => api.post<YybTask, YybTask>('/yyb/tasks', {}),
login: (id: number, provider: 'qq' | 'wechat') =>
api.post<YybTask, YybTask>(`/yyb/tasks/${id}/login`, { provider, timeout: 600 }),
getTask: (id: number) => api.get<YybTask, YybTask>(`/yyb/tasks/${id}`),
listTasks: () => api.get<YybTask[], YybTask[]>('/yyb/tasks'),
options: (id: number, platform: 'android' | 'ios', points?: number, zone_id?: string) =>
api.get<YybSelectionOptions, YybSelectionOptions>(`/yyb/tasks/${id}/selection-options`, {
params: { platform, ...(points ? { points } : {}), ...(zone_id ? { zone_id } : {}) },
}),
select: (id: number, data: { platform: 'android' | 'ios'; points: number; product_id: string; zone_id: string; zone_name: string; role_id: string; role_name: string }) =>
api.post<YybTask, YybTask>(`/yyb/tasks/${id}/selection`, data),
payment: (id: number) => api.post<YybTask, YybTask>(`/yyb/tasks/${id}/payment`),
};
+4
View File
@@ -94,6 +94,10 @@ export default function MainLayout({ onLogout }: { onLogout?: () => void }) {
huyaItems.push({ key: '/huya/tasks', label: '任务操作台', icon: <ShoppingCartOutlined /> });
}
if (canAny(['yyb:session', 'yyb:history'])) {
douyuItems.push({ key: '/yyb/recharge', label: '应用宝充值', icon: <ShoppingCartOutlined /> });
}
if (douyuItems.length > 0) {
menuItems.push({ key: 'group-douyu', type: 'group', label: '斗鱼', children: douyuItems });
}
@@ -0,0 +1,96 @@
import { useEffect, useMemo, useState } from 'react';
import { Alert, Button, Card, Col, Descriptions, Image, Radio, Row, Select, Space, Steps, Tag, Typography, message } from 'antd';
import { CheckCircleOutlined, QrcodeOutlined, ReloadOutlined, ShoppingCartOutlined, WechatOutlined } from '@ant-design/icons';
import { yybApi } from '../api/modules';
import type { YybSelectionOptions, YybTask } from '../api/types';
import { getUser } from '../store/auth';
import { usePermissions } from '../hooks/usePermissions';
const { Title, Text } = Typography;
const phaseIndex: Record<string, number> = { login: 0, selection: 1, payment: 2, completed: 3 };
export default function YybRechargePage() {
const [task, setTask] = useState<YybTask | null>(null);
const [options, setOptions] = useState<YybSelectionOptions | null>(null);
const [platform, setPlatform] = useState<'android' | 'ios'>('android');
const [points, setPoints] = useState<number>();
const [zoneId, setZoneId] = useState<string>();
const [roleId, setRoleId] = useState<string>();
const [loading, setLoading] = useState(false);
const { can } = usePermissions(getUser());
const refresh = async () => {
if (!task) return;
try { setTask(await yybApi.getTask(task.id)); } catch { /* 保留当前状态 */ }
};
useEffect(() => {
if (!task || ['success', 'failed'].includes(task.status)) return;
const timer = window.setInterval(refresh, 2500);
return () => window.clearInterval(timer);
}, [task?.id, task?.status]);
useEffect(() => {
if (task?.status === 'ready' && task.phase === 'selection' && !options) {
void loadOptions(platform);
}
}, [task?.status, task?.phase]);
const loadOptions = async (nextPlatform: 'android' | 'ios', nextPoints?: number, nextZone?: string) => {
if (!task) return;
setLoading(true);
try {
const data = await yybApi.options(task.id, nextPlatform, nextPoints, nextZone);
setOptions(data);
if (nextPoints === undefined && data.default_product) setPoints(data.default_product.points);
if (nextZone === undefined && data.default_zone) setZoneId(data.default_zone.zone_id);
setRoleId(undefined);
} catch (error) { message.error(error instanceof Error ? error.message : '查询商品失败'); }
finally { setLoading(false); }
};
const selectedProduct = useMemo(() => options?.products.find(item => item.points === points), [options, points]);
const selectedZone = useMemo(() => options?.zones.find(item => item.zone_id === zoneId), [options, zoneId]);
const selectedRole = useMemo(() => options?.roles.find(item => item.role_id === roleId), [options, roleId]);
const createTask = async () => {
setLoading(true);
try { setTask(await yybApi.createTask()); setOptions(null); } catch (error) { message.error(error instanceof Error ? error.message : '创建任务失败'); }
finally { setLoading(false); }
};
const startLogin = async (provider: 'qq' | 'wechat') => {
if (!task) return;
setLoading(true);
try { setTask(await yybApi.login(task.id, provider)); } catch (error) { message.error(error instanceof Error ? error.message : '启动登录失败'); }
finally { setLoading(false); }
};
const submitSelection = async () => {
if (!task || !selectedProduct || !selectedZone || !selectedRole) return;
setLoading(true);
try {
setTask(await yybApi.select(task.id, { platform, points: selectedProduct.points, product_id: selectedProduct.product_id, zone_id: selectedZone.zone_id, zone_name: selectedZone.name, role_id: selectedRole.role_id, role_name: selectedRole.name }));
message.success('选择已保存');
} catch (error) { message.error(error instanceof Error ? error.message : '保存选择失败'); }
finally { setLoading(false); }
};
const createPayment = async () => {
if (!task) return;
setLoading(true);
try { setTask(await yybApi.payment(task.id)); } catch (error) { message.error(error instanceof Error ? error.message : '创建付款码失败'); }
finally { setLoading(false); }
};
return <Space direction="vertical" size={16} style={{ width: '100%' }}>
<Row justify="space-between" align="middle"><Title level={3} style={{ margin: 0 }}></Title><Button icon={<ReloadOutlined />} onClick={task ? refresh : createTask}>{task ? '刷新任务' : '新建充值任务'}</Button></Row>
<Steps current={phaseIndex[task?.phase || 'login']} items={[{ title: '扫码登录' }, { title: '选择充值信息' }, { title: '微信付款' }, { title: '完成' }]} />
{!task && <Card><Space direction="vertical"><Text>使 YYB </Text><Button type="primary" icon={<ShoppingCartOutlined />} onClick={createTask} loading={loading}></Button></Space></Card>}
{task && <>
{task.status === 'failed' && <Alert type="error" showIcon message={task.message || '任务失败'} />}
{task.phase === 'login' && <Card title="扫码登录"><Space direction="vertical" size={12}><Radio.Group value={task.provider || undefined} onChange={event => void startLogin(event.target.value)} disabled={loading}><Radio.Button value="qq">QQ </Radio.Button><Radio.Button value="wechat"><WechatOutlined /> </Radio.Button></Radio.Group>{task.login_qr_data && <Image width={240} preview src={`data:${task.login_qr_mime_type || 'image/jpeg'};base64,${task.login_qr_data}`} />}<Text type="secondary">{task.message}</Text></Space></Card>}
{task.phase === 'selection' && <Card title="选择充值信息" loading={loading}><Space direction="vertical" style={{ width: '100%' }}><Radio.Group value={platform} onChange={event => { const value = event.target.value; setPlatform(value); void loadOptions(value); }}><Radio.Button value="android">Android </Radio.Button><Radio.Button value="ios">iOS </Radio.Button></Radio.Group>{options && <Row gutter={[12, 12]}><Col xs={24} md={8}><Select style={{ width: '100%' }} placeholder="点券档位" value={points} options={options.products.map(item => ({ value: item.points, label: `${item.points} 点券(${(item.price_fen / 100).toFixed(2)} 元)` }))} onChange={value => { setPoints(value); void loadOptions(platform, value, zoneId); }} /></Col><Col xs={24} md={8}><Select style={{ width: '100%' }} placeholder="区服" value={zoneId} options={options.zones.map(item => ({ value: item.zone_id, label: `${item.name}${item.zone_id}` }))} onChange={value => { setZoneId(value); void loadOptions(platform, points, value); }} /></Col><Col xs={24} md={8}><Select style={{ width: '100%' }} placeholder="角色" value={roleId} options={options.roles.filter(item => item.ban_status !== '1').map(item => ({ value: item.role_id, label: `${item.name}${item.role_id}` }))} onChange={setRoleId} /></Col></Row>}<Button type="primary" disabled={!selectedProduct || !selectedZone || !selectedRole} onClick={submitSelection}></Button></Space></Card>}
{task.phase === 'payment' && <Card title="微信付款"><Descriptions column={1} size="small"><Descriptions.Item label="平台">{task.platform === 'ios' ? 'iOS' : 'Android'}</Descriptions.Item><Descriptions.Item label="商品">{task.points} </Descriptions.Item><Descriptions.Item label="区服">{task.zone_name}</Descriptions.Item><Descriptions.Item label="角色">{task.role_name}{task.role_id}</Descriptions.Item></Descriptions>{task.payment_qr_data ? <Space direction="vertical"><Image width={280} preview src={`data:${task.payment_qr_mime_type || 'image/png'};base64,${task.payment_qr_data}`} /><Tag icon={<QrcodeOutlined />} color="blue">使</Tag></Space> : can('yyb:recharge') ? <Button type="primary" icon={<WechatOutlined />} onClick={createPayment} loading={loading || task.status === 'running'} disabled={task.status === 'running'}>{task.status === 'running' ? '正在生成付款码' : '生成微信付款码'}</Button> : <Text type="secondary"></Text>}<div><Text type="secondary">{task.message}</Text></div></Card>}
{task.phase === 'completed' && <Alert type="success" showIcon icon={<CheckCircleOutlined />} message="充值订单已确认完成" description={task.message} />}
</>}
</Space>;
}