fix: preserve xpd role platform in wallet queries

This commit is contained in:
yml2213
2026-08-09 10:06:57 +08:00
parent 8b7ff770e2
commit ba35ac5c0d
4 changed files with 359 additions and 7 deletions
+7 -3
View File
@@ -936,13 +936,16 @@ class DouyuActivityClient:
headers=self._xpd_daoju_headers(), headers=self._xpd_daoju_headers(),
) )
info = self._xpd_parse_var(response.text, "info") info = self._xpd_parse_var(response.text, "info")
plat_id = info.get("platId")
area = info.get("area")
return { return {
"game_open_id": str(info.get("gameOpenId") or ""), "game_open_id": str(info.get("gameOpenId") or ""),
"role_id": str(info.get("roleId") or ""), "role_id": str(info.get("roleId") or ""),
"role_name": str(info.get("roleName") or ""), "role_name": str(info.get("roleName") or ""),
"type": str(info.get("type") or ""), "type": str(info.get("type") or ""),
"plat_id": str(info.get("platId") or ""), # 0 是合法的 iOS 平台值,不能用 `or` 当作缺失处理。
"area": str(info.get("area") or ""), "plat_id": str(plat_id) if plat_id is not None else "",
"area": str(area) if area is not None else "",
"raw": info, "raw": info,
} }
@@ -1225,7 +1228,8 @@ class DouyuActivityClient:
"roleid": roleid, "roleid": roleid,
"_biz_code": "cjm", "_biz_code": "cjm",
"_act_id": act_id, "_act_id": act_id,
"_time": str(int(time.time())), # 抓包中的接口使用毫秒级时间戳。
"_time": str(int(time.time() * 1000)),
"_sid": "6", "_sid": "6",
} }
response = self._request( response = self._request(
+312
View File
@@ -0,0 +1,312 @@
#!/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())
+24
View File
@@ -1,4 +1,5 @@
import json import json
import re
import unittest import unittest
from types import SimpleNamespace from types import SimpleNamespace
from unittest.mock import Mock from unittest.mock import Mock
@@ -47,6 +48,29 @@ class XpdExchangeTests(unittest.TestCase):
'paytype': 1, 'paytype': 1,
}) })
def test_role_and_balance_preserve_ios_platform_zero(self):
self.client._request = Mock(return_value=SimpleNamespace(
text=(
'var info={"gameOpenId":"openid-ios","roleId":"role-ios",'
'"roleName":"iOS角色","type":"wx","platId":0,"area":1};'
),
))
role = self.client.xpd_get_role(embed_query={}, act_id='46195', rid='9263298')
self.assertEqual(role['plat_id'], '0')
self.assertEqual(role['area'], '1')
self.client._request = Mock(return_value=SimpleNamespace(
text='var banlanceInfo={"ret":0,"balance":2199};',
))
result = self.client.xpd_balance(
embed_query={}, act_id='46195', openid='openid-ios',
roleid='role-ios', plat='0', areaid='1',
)
params = self.client._request.call_args.kwargs['params']
self.assertEqual(params['plat'], '0')
self.assertTrue(re.fullmatch(r'\d{13}', params['_time']))
self.assertEqual(result['balance'], 2199)
def test_exchange_surfaces_daoju_failure_message(self): def test_exchange_surfaces_daoju_failure_message(self):
self.client._request = Mock(return_value=SimpleNamespace( self.client._request = Mock(return_value=SimpleNamespace(
text='var buyInfo={"ret":"-1","msg":"点券不足"};', text='var buyInfo={"ret":"-1","msg":"点券不足"};',
+16 -4
View File
@@ -1067,7 +1067,15 @@ class DouyuBatchRunner:
return {"embed": embed, "role": role} return {"embed": embed, "role": role}
def _xpd_area_id(self, role: dict, account: Account) -> int: def _xpd_area_id(self, role: dict, account: Account) -> int:
"""角色大区: 微信=1, 手Q=2, 未知回退账号已存值或 1""" """角色大区: 优先使用接口值,微信=1手Q=2未知回退已存值。"""
raw_area = role.get("area")
if raw_area not in (None, ""):
try:
area_id = int(raw_area)
except (TypeError, ValueError):
area_id = 0
if area_id > 0:
return area_id
role_type = str(role.get("type") or "") role_type = str(role.get("type") or "")
if role_type == "wx": if role_type == "wx":
return 1 return 1
@@ -1344,12 +1352,14 @@ class DouyuBatchRunner:
self._mark_task(db, task, "failed", "未获取到小店绑定角色") self._mark_task(db, task, "failed", "未获取到小店绑定角色")
return return
area_id = self._xpd_area_id(role, account) area_id = self._xpd_area_id(role, account)
role_plat = role.get("plat_id")
plat = str(role_plat) if role_plat not in (None, "") else "1"
result = client.xpd_balance( result = client.xpd_balance(
embed_query=ctx["embed"]["query"], embed_query=ctx["embed"]["query"],
act_id=str(config["xpd_act_id"]), act_id=str(config["xpd_act_id"]),
openid=str(role.get("game_open_id") or ""), openid=str(role.get("game_open_id") or ""),
roleid=str(role.get("role_id") or ""), roleid=str(role.get("role_id") or ""),
plat=str(role.get("plat_id") or "1"), plat=plat,
areaid=str(area_id), areaid=str(area_id),
) )
balance = result.get("balance") balance = result.get("balance")
@@ -1386,7 +1396,8 @@ class DouyuBatchRunner:
embed_query: dict = {} embed_query: dict = {}
openid = str(account.xpd_openid or "") openid = str(account.xpd_openid or "")
roleid = str(account.xpd_role_id or "") roleid = str(account.xpd_role_id or "")
plat = str(account.xpd_plat_id or "1") stored_plat = account.xpd_plat_id
plat = str(stored_plat) if stored_plat is not None else "1"
areaid = str(account.xpd_area_id or 1) areaid = str(account.xpd_area_id or 1)
role: dict = {} role: dict = {}
try: try:
@@ -1397,7 +1408,8 @@ class DouyuBatchRunner:
role_area = self._xpd_area_id(role, account) role_area = self._xpd_area_id(role, account)
openid = str(role.get("game_open_id") or "") or openid openid = str(role.get("game_open_id") or "") or openid
roleid = str(role.get("role_id") or "") or roleid roleid = str(role.get("role_id") or "") or roleid
plat = str(role.get("plat_id") or "1") or plat role_plat = role.get("plat_id")
plat = str(role_plat) if role_plat not in (None, "") else plat
areaid = str(role_area) or areaid areaid = str(role_area) or areaid
self._apply_xpd_role_to_account(account, role, role_area) self._apply_xpd_role_to_account(account, role, role_area)
except Exception: except Exception: