初步增加, 扫码登录成功
This commit is contained in:
@@ -60,6 +60,16 @@ COOKIE_SECURE=false
|
|||||||
# CORS 允许的源(逗号分隔,不设则默认开发环境)
|
# CORS 允许的源(逗号分隔,不设则默认开发环境)
|
||||||
# CORS_ORIGINS=https://example.com,https://www.example.com
|
# CORS_ORIGINS=https://example.com,https://www.example.com
|
||||||
|
|
||||||
|
# 应用宝充值 Worker(Docker 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 邮件验证码读取服务地址(可选;不填则使用默认服务)
|
# Roundcube 邮件验证码读取服务地址(可选;不填则使用默认服务)
|
||||||
# MAIL_ROUNDCUBE_URL=http://127.0.0.1:8000/
|
# MAIL_ROUNDCUBE_URL=http://127.0.0.1:8000/
|
||||||
|
|
||||||
|
|||||||
@@ -48,6 +48,26 @@ douyu_login_py/
|
|||||||
|
|
||||||
访问 `http://localhost:8800`,默认账号 `admin / admin123`。
|
访问 `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
|
```bash
|
||||||
|
|||||||
@@ -5,6 +5,14 @@ set -e
|
|||||||
|
|
||||||
ROOT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
ROOT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||||
cd "$ROOT_DIR"
|
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}"
|
APP_PORT="${APP_PORT:-8000}"
|
||||||
|
|
||||||
# ── 工具函数 ──────────────────────────────────────────────
|
# ── 工具函数 ──────────────────────────────────────────────
|
||||||
@@ -42,6 +50,16 @@ cmd_deploy() {
|
|||||||
echo " 使用: $COMPOSE"
|
echo " 使用: $COMPOSE"
|
||||||
echo ""
|
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
|
mkdir -p data logs
|
||||||
|
|
||||||
@@ -91,6 +109,7 @@ cmd_reset() {
|
|||||||
echo ""
|
echo ""
|
||||||
echo " 将删除以下内容:"
|
echo " 将删除以下内容:"
|
||||||
echo " • MySQL 数据卷"
|
echo " • MySQL 数据卷"
|
||||||
|
echo " • 应用宝 Worker 任务数据卷(扫码会话、二维码和订单记录)"
|
||||||
echo " • 历史Cookie (data/cookies/)"
|
echo " • 历史Cookie (data/cookies/)"
|
||||||
echo " • 应用日志 (logs/)"
|
echo " • 应用日志 (logs/)"
|
||||||
echo " • Docker容器和镜像"
|
echo " • Docker容器和镜像"
|
||||||
|
|||||||
@@ -35,9 +35,15 @@ DB_POOL_SIZE="${DB_POOL_SIZE:-20}"
|
|||||||
DB_MAX_OVERFLOW="${DB_MAX_OVERFLOW:-20}"
|
DB_MAX_OVERFLOW="${DB_MAX_OVERFLOW:-20}"
|
||||||
DB_POOL_TIMEOUT="${DB_POOL_TIMEOUT:-30}"
|
DB_POOL_TIMEOUT="${DB_POOL_TIMEOUT:-30}"
|
||||||
DB_POOL_RECYCLE="${DB_POOL_RECYCLE:-1800}"
|
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=""
|
BACKEND_PID=""
|
||||||
FRONTEND_PID=""
|
FRONTEND_PID=""
|
||||||
|
WORKER_STARTED_BY_DEV=false
|
||||||
|
|
||||||
stop_tree() {
|
stop_tree() {
|
||||||
local pid="$1"
|
local pid="$1"
|
||||||
@@ -56,6 +62,9 @@ cleanup() {
|
|||||||
trap - EXIT INT TERM
|
trap - EXIT INT TERM
|
||||||
stop_tree "$FRONTEND_PID"
|
stop_tree "$FRONTEND_PID"
|
||||||
stop_tree "$BACKEND_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() {
|
require_cmd() {
|
||||||
@@ -108,6 +117,11 @@ if [ -z "$DB_PASSWORD" ]; then
|
|||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
if [ -z "${YYB_WORKER_KEY:-}" ]; then
|
||||||
|
echo "请先在 .env 中设置 YYB_WORKER_KEY"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
COMPOSE=$(detect_compose)
|
COMPOSE=$(detect_compose)
|
||||||
if [ -z "$COMPOSE" ]; then
|
if [ -z "$COMPOSE" ]; then
|
||||||
echo "缺少 docker compose"
|
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}}"
|
BACKEND_PROXY_TARGET="${VITE_BACKEND_TARGET:-http://${BACKEND_PROXY_HOST}:${BACKEND_PORT}}"
|
||||||
|
|
||||||
mkdir -p data logs
|
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..."
|
echo "正在启动本地 MySQL..."
|
||||||
MYSQL_IMAGE="$MYSQL_IMAGE" \
|
MYSQL_IMAGE="$MYSQL_IMAGE" \
|
||||||
@@ -146,12 +202,11 @@ echo " 后端: http://${BACKEND_HOST}:${BACKEND_PORT}"
|
|||||||
echo " 前端: http://localhost:${FRONTEND_PORT}"
|
echo " 前端: http://localhost:${FRONTEND_PORT}"
|
||||||
echo " API 代理: ${BACKEND_PROXY_TARGET}"
|
echo " API 代理: ${BACKEND_PROXY_TARGET}"
|
||||||
echo " MySQL: ${DB_HOST}:${DB_PORT}/${DB_NAME}"
|
echo " MySQL: ${DB_HOST}:${DB_PORT}/${DB_NAME}"
|
||||||
|
echo " YYB Worker: ${DEV_YYB_WORKER_URL}"
|
||||||
echo " 退出: Ctrl+C"
|
echo " 退出: Ctrl+C"
|
||||||
echo "=============================="
|
echo "=============================="
|
||||||
echo ""
|
echo ""
|
||||||
|
|
||||||
trap cleanup EXIT INT TERM
|
|
||||||
|
|
||||||
DB_HOST="$DB_HOST" \
|
DB_HOST="$DB_HOST" \
|
||||||
DB_PORT="$DB_PORT" \
|
DB_PORT="$DB_PORT" \
|
||||||
DB_NAME="$DB_NAME" \
|
DB_NAME="$DB_NAME" \
|
||||||
@@ -161,6 +216,8 @@ DB_POOL_SIZE="$DB_POOL_SIZE" \
|
|||||||
DB_MAX_OVERFLOW="$DB_MAX_OVERFLOW" \
|
DB_MAX_OVERFLOW="$DB_MAX_OVERFLOW" \
|
||||||
DB_POOL_TIMEOUT="$DB_POOL_TIMEOUT" \
|
DB_POOL_TIMEOUT="$DB_POOL_TIMEOUT" \
|
||||||
DB_POOL_RECYCLE="$DB_POOL_RECYCLE" \
|
DB_POOL_RECYCLE="$DB_POOL_RECYCLE" \
|
||||||
|
YYB_WORKER_URL="$DEV_YYB_WORKER_URL" \
|
||||||
|
YYB_WORKER_KEY="$YYB_WORKER_KEY" \
|
||||||
LOG_LEVEL="$LOG_LEVEL" \
|
LOG_LEVEL="$LOG_LEVEL" \
|
||||||
uv run uvicorn web.backend.main:app \
|
uv run uvicorn web.backend.main:app \
|
||||||
--host "$BACKEND_HOST" \
|
--host "$BACKEND_HOST" \
|
||||||
|
|||||||
@@ -37,6 +37,9 @@ services:
|
|||||||
- COOKIE_SECURE=${COOKIE_SECURE:-false}
|
- COOKIE_SECURE=${COOKIE_SECURE:-false}
|
||||||
# CORS 允许的源(逗号分隔)
|
# CORS 允许的源(逗号分隔)
|
||||||
- CORS_ORIGINS=${CORS_ORIGINS:-}
|
- CORS_ORIGINS=${CORS_ORIGINS:-}
|
||||||
|
# 独立 yyb-worker 容器,只通过 Compose 默认内网访问。
|
||||||
|
- YYB_WORKER_URL=${YYB_WORKER_URL:-http://yyb-worker:8810}
|
||||||
|
- YYB_WORKER_KEY=${YYB_WORKER_KEY:-}
|
||||||
# Roundcube 邮件验证码读取服务地址(不填则使用代码默认值)
|
# Roundcube 邮件验证码读取服务地址(不填则使用代码默认值)
|
||||||
- MAIL_ROUNDCUBE_URL=${MAIL_ROUNDCUBE_URL:-}
|
- MAIL_ROUNDCUBE_URL=${MAIL_ROUNDCUBE_URL:-}
|
||||||
# Uvicorn reload(生产环境保持 false)
|
# Uvicorn reload(生产环境保持 false)
|
||||||
@@ -47,6 +50,8 @@ services:
|
|||||||
depends_on:
|
depends_on:
|
||||||
mysql:
|
mysql:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
|
yyb-worker:
|
||||||
|
condition: service_healthy
|
||||||
# 增加文件描述符限制,避免 "Too many open files" 错误
|
# 增加文件描述符限制,避免 "Too many open files" 错误
|
||||||
ulimits:
|
ulimits:
|
||||||
nofile:
|
nofile:
|
||||||
@@ -83,8 +88,28 @@ services:
|
|||||||
retries: 12
|
retries: 12
|
||||||
start_period: 20s
|
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:
|
volumes:
|
||||||
mysql-data:
|
mysql-data:
|
||||||
|
yyb-worker-data:
|
||||||
|
|
||||||
networks:
|
networks:
|
||||||
order-site-net:
|
order-site-net:
|
||||||
|
|||||||
@@ -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())
|
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
.git
|
||||||
|
node_modules
|
||||||
|
__pycache__
|
||||||
|
*.pyc
|
||||||
|
config
|
||||||
@@ -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"]
|
||||||
@@ -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
|
||||||
|
```
|
||||||
Generated
+781
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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\\\":\\\"\\\"}\"}}"
|
||||||
|
}
|
||||||
@@ -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
@@ -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": "",
|
||||||
|
}
|
||||||
@@ -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())
|
||||||
@@ -154,6 +154,8 @@ _SENSITIVE_COLUMNS: tuple[tuple[str, str], ...] = (
|
|||||||
("huya_register_items", "password"),
|
("huya_register_items", "password"),
|
||||||
("huya_register_items", "cookie"),
|
("huya_register_items", "cookie"),
|
||||||
("huya_register_success_logs", "password"),
|
("huya_register_success_logs", "password"),
|
||||||
|
("yyb_recharge_tasks", "login_qr_data"),
|
||||||
|
("yyb_recharge_tasks", "payment_qr_data"),
|
||||||
("proxy_config", "api_url"),
|
("proxy_config", "api_url"),
|
||||||
("proxy_config", "http"),
|
("proxy_config", "http"),
|
||||||
("proxy_config", "https"),
|
("proxy_config", "https"),
|
||||||
|
|||||||
+6
-1
@@ -12,7 +12,7 @@ from fastapi.responses import FileResponse
|
|||||||
from starlette.middleware.base import BaseHTTPMiddleware
|
from starlette.middleware.base import BaseHTTPMiddleware
|
||||||
|
|
||||||
from .database import init_db
|
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 .schemas import AppInfo
|
||||||
from .version import get_app_version
|
from .version import get_app_version
|
||||||
from utils import setup_logger
|
from utils import setup_logger
|
||||||
@@ -41,6 +41,10 @@ async def lifespan(app: FastAPI):
|
|||||||
cleaned_douyu = cleanup_orphan_douyu_tasks(db, message="任务已中断(服务重启)")
|
cleaned_douyu = cleanup_orphan_douyu_tasks(db, message="任务已中断(服务重启)")
|
||||||
if cleaned_douyu:
|
if cleaned_douyu:
|
||||||
logger.info(f"启动清理斗鱼残留任务: {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:
|
finally:
|
||||||
db.close()
|
db.close()
|
||||||
yield
|
yield
|
||||||
@@ -97,6 +101,7 @@ app.include_router(proxy.router)
|
|||||||
app.include_router(cookies.router)
|
app.include_router(cookies.router)
|
||||||
app.include_router(huya.router)
|
app.include_router(huya.router)
|
||||||
app.include_router(douyu.router)
|
app.include_router(douyu.router)
|
||||||
|
app.include_router(yyb.router)
|
||||||
|
|
||||||
|
|
||||||
@app.get("/api/health")
|
@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")
|
||||||
@@ -125,6 +125,35 @@ class DouyuTask(Base):
|
|||||||
account = relationship("Account", back_populates="douyu_tasks")
|
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):
|
class DouyuConfig(Base):
|
||||||
"""斗鱼业务配置"""
|
"""斗鱼业务配置"""
|
||||||
__tablename__ = "douyu_config"
|
__tablename__ = "douyu_config"
|
||||||
|
|||||||
@@ -26,6 +26,9 @@ PERMISSIONS = {
|
|||||||
# 斗鱼活动
|
# 斗鱼活动
|
||||||
"douyu:task": "斗鱼任务管理",
|
"douyu:task": "斗鱼任务管理",
|
||||||
"douyu:config": "斗鱼配置管理",
|
"douyu:config": "斗鱼配置管理",
|
||||||
|
"yyb:session": "应用宝扫码登录与角色查询",
|
||||||
|
"yyb:recharge": "应用宝充值与付款",
|
||||||
|
"yyb:history": "查看应用宝充值历史",
|
||||||
# 虎牙
|
# 虎牙
|
||||||
"huya:account": "虎牙账号管理(兼容旧权限)",
|
"huya:account": "虎牙账号管理(兼容旧权限)",
|
||||||
"huya:view_all": "查看所有虎牙账号",
|
"huya:view_all": "查看所有虎牙账号",
|
||||||
@@ -62,6 +65,9 @@ ROLE_PERMISSIONS = {
|
|||||||
"cookie:export",
|
"cookie:export",
|
||||||
"douyu:task",
|
"douyu:task",
|
||||||
"douyu:config",
|
"douyu:config",
|
||||||
|
"yyb:session",
|
||||||
|
"yyb:recharge",
|
||||||
|
"yyb:history",
|
||||||
"huya:account",
|
"huya:account",
|
||||||
"huya:view_all",
|
"huya:view_all",
|
||||||
"huya:import",
|
"huya:import",
|
||||||
@@ -80,6 +86,8 @@ ROLE_PERMISSIONS = {
|
|||||||
"support": [
|
"support": [
|
||||||
"account:view_assigned",
|
"account:view_assigned",
|
||||||
"douyu:task",
|
"douyu:task",
|
||||||
|
"yyb:session",
|
||||||
|
"yyb:history",
|
||||||
"huya:view_assigned",
|
"huya:view_assigned",
|
||||||
"huya:task",
|
"huya:task",
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -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)
|
||||||
@@ -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]:
|
def _ensure_tz(dt: Optional[datetime]) -> Optional[datetime]:
|
||||||
"""确保 datetime 带有 UTC 时区信息,无时区的视为 UTC。"""
|
"""确保 datetime 带有 UTC 时区信息,无时区的视为 UTC。"""
|
||||||
if dt is None:
|
if dt is None:
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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")
|
||||||
@@ -23,6 +23,7 @@ const HuyaAssignmentsPage = lazy(() => import('./pages/HuyaAssignmentsPage'));
|
|||||||
const HuyaCookiePage = lazy(() => import('./pages/HuyaCookiePage'));
|
const HuyaCookiePage = lazy(() => import('./pages/HuyaCookiePage'));
|
||||||
const HuyaRegisterPage = lazy(() => import('./pages/HuyaRegisterPage'));
|
const HuyaRegisterPage = lazy(() => import('./pages/HuyaRegisterPage'));
|
||||||
const HuyaTasksPage = lazy(() => import('./pages/HuyaTasksPage'));
|
const HuyaTasksPage = lazy(() => import('./pages/HuyaTasksPage'));
|
||||||
|
const YybRechargePage = lazy(() => import('./pages/YybRechargePage'));
|
||||||
|
|
||||||
function RouteFallback() {
|
function RouteFallback() {
|
||||||
return (
|
return (
|
||||||
@@ -81,6 +82,7 @@ function AppContent() {
|
|||||||
<Route path="huya/assignments" element={lazyRoute(<HuyaAssignmentsPage />)} />
|
<Route path="huya/assignments" element={lazyRoute(<HuyaAssignmentsPage />)} />
|
||||||
<Route path="huya/cookies" element={lazyRoute(<HuyaCookiePage />)} />
|
<Route path="huya/cookies" element={lazyRoute(<HuyaCookiePage />)} />
|
||||||
<Route path="huya/tasks" element={lazyRoute(<HuyaTasksPage />)} />
|
<Route path="huya/tasks" element={lazyRoute(<HuyaTasksPage />)} />
|
||||||
|
<Route path="yyb/recharge" element={lazyRoute(<YybRechargePage />)} />
|
||||||
<Route path="proxy" element={lazyRoute(<ProxyPage />)} />
|
<Route path="proxy" element={lazyRoute(<ProxyPage />)} />
|
||||||
<Route path="users" element={lazyRoute(<UsersPage />)} />
|
<Route path="users" element={lazyRoute(<UsersPage />)} />
|
||||||
</Route>
|
</Route>
|
||||||
|
|||||||
@@ -10,3 +10,4 @@ export { huyaApi } from './huya';
|
|||||||
export { loginApi } from './login';
|
export { loginApi } from './login';
|
||||||
export { proxyApi } from './proxy';
|
export { proxyApi } from './proxy';
|
||||||
export { userApi } from './users';
|
export { userApi } from './users';
|
||||||
|
export { yybApi } from './yyb';
|
||||||
|
|||||||
@@ -147,6 +147,39 @@ export interface LoginTaskItem {
|
|||||||
finished_at: string | null;
|
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 {
|
export interface BatchLoginResult {
|
||||||
batch_id: string;
|
batch_id: string;
|
||||||
count: number;
|
count: number;
|
||||||
|
|||||||
@@ -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`),
|
||||||
|
};
|
||||||
@@ -94,6 +94,10 @@ export default function MainLayout({ onLogout }: { onLogout?: () => void }) {
|
|||||||
huyaItems.push({ key: '/huya/tasks', label: '任务操作台', icon: <ShoppingCartOutlined /> });
|
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) {
|
if (douyuItems.length > 0) {
|
||||||
menuItems.push({ key: 'group-douyu', type: 'group', label: '斗鱼', children: douyuItems });
|
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>;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user