900 lines
30 KiB
Python
900 lines
30 KiB
Python
"""微博微豆 CK、资料、商城、充值账单与绑定 Scheme 客户端。
|
|
|
|
覆盖手动触发的只读查询和充值下单:
|
|
- 微博 CK -> 微豆首页资料
|
|
- bind_url -> 微信/QQ 小程序 URL Scheme
|
|
- 商品列表、兑换记录、微豆获取明细
|
|
- 创建充值订单并解析支付二维码,不自动完成支付
|
|
|
|
不提交角色绑定、不自动兑换。
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import time
|
|
from dataclasses import dataclass
|
|
from html.parser import HTMLParser
|
|
from typing import Any
|
|
from urllib.parse import parse_qs, urljoin, urlsplit
|
|
|
|
import requests
|
|
|
|
WEIBO_HOME_URL = "https://vipclub.e.weibo.com/aj/mall/home"
|
|
WEIBO_API_BASE = "https://vipclub.e.weibo.com/aj"
|
|
WEIBO_VPLUS_API_BASE = "https://vipclub.weibo.com/aj"
|
|
WEIBO_PAY_BASE = "https://pay.sc.weibo.com"
|
|
WEIBO_SCHEME_URL = "https://livelinkbind.game.qq.com/livelink"
|
|
WEIBO_MOBILE_UA = (
|
|
"Mozilla/5.0 (iPhone; CPU iPhone OS 18_7 like Mac OS X) "
|
|
"AppleWebKit/605.1.15 (KHTML, like Gecko) Mobile/15E148 Weibo"
|
|
)
|
|
WEIBO_DESKTOP_UA = (
|
|
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) "
|
|
"AppleWebKit/537.36 (KHTML, like Gecko) "
|
|
"Chrome/156.0.0.0 Safari/537.36"
|
|
)
|
|
WEIBO_AUTH_ERROR_CODES = {100002, 100004, 400000, 400002}
|
|
|
|
|
|
class WeiboError(RuntimeError):
|
|
"""微博基础流程的基类异常。"""
|
|
|
|
|
|
class WeiboTransportError(WeiboError):
|
|
"""网络或 HTTP 层错误。"""
|
|
|
|
|
|
class WeiboAuthError(WeiboError):
|
|
"""CK 无效或登录态失效。"""
|
|
|
|
|
|
class WeiboApiError(WeiboError):
|
|
"""微博业务接口返回非成功状态。"""
|
|
|
|
|
|
class WeiboBindError(WeiboError):
|
|
"""绑定链接或二维码 Scheme 无法生成。"""
|
|
|
|
|
|
class WeiboRechargeError(WeiboError):
|
|
"""充值下单、收银台或支付状态解析失败。"""
|
|
|
|
|
|
class WeiboExchangeError(WeiboError):
|
|
"""兑换前校验失败或服务端明确拒绝。"""
|
|
|
|
|
|
class WeiboExchangeUncertainError(WeiboExchangeError):
|
|
"""兑换请求已发出但结果无法确认。"""
|
|
|
|
|
|
@dataclass(frozen=True, slots=True)
|
|
class WeiboHomeSnapshot:
|
|
"""微豆首页中基础账号流程需要的字段。"""
|
|
|
|
uid: str
|
|
nickname: str
|
|
is_bound: bool
|
|
role_name: str
|
|
can_rebind: bool
|
|
rebind_remain_days: int
|
|
weidou_balance: int
|
|
bind_url: str
|
|
recharge_url: str = ""
|
|
activity: str = ""
|
|
|
|
|
|
@dataclass(frozen=True, slots=True)
|
|
class WeiboRechargeOrder:
|
|
order_id: str
|
|
pay_id: str
|
|
amount_yuan: int
|
|
single_reward: int
|
|
alipay_qr_code: str
|
|
desktop_pay_url: str
|
|
|
|
|
|
@dataclass(frozen=True, slots=True)
|
|
class WeiboPaymentStatus:
|
|
pay_id: str
|
|
out_pay_id: str
|
|
status: str
|
|
sub_status: str
|
|
|
|
|
|
@dataclass(frozen=True, slots=True)
|
|
class WeiboExchangeResult:
|
|
goods_id: int
|
|
goods_name: str
|
|
price: int
|
|
balance_before: int
|
|
balance_after: int | None
|
|
remain_before: int | None
|
|
remain_after: int | None
|
|
verified: bool
|
|
verify_error: str = ""
|
|
|
|
|
|
class _CashierParser(HTMLParser):
|
|
"""从微博 PC 收银台 HTML 中提取支付要素。"""
|
|
|
|
def __init__(self) -> None:
|
|
super().__init__()
|
|
self.pay_id = ""
|
|
self.desktop_pay_url = ""
|
|
|
|
def handle_starttag(self, tag: str, attrs: list[tuple[str, str | None]]) -> None:
|
|
values = {name: value or "" for name, value in attrs}
|
|
if tag == "input" and (
|
|
values.get("name") == "pay_id" or values.get("node-type") == "pay_id"
|
|
):
|
|
self.pay_id = values.get("value", "").strip()
|
|
elif tag == "a" and values.get("href", "").startswith(
|
|
f"{WEIBO_PAY_BASE}/pay/pc/comb?"
|
|
):
|
|
self.desktop_pay_url = values["href"].strip()
|
|
|
|
|
|
class _AlipayCashierParser(HTMLParser):
|
|
"""从支付宝电脑收银台 HTML 提取实际支付二维码内容。"""
|
|
|
|
def __init__(self) -> None:
|
|
super().__init__()
|
|
self.qr_code = ""
|
|
self.qr_image_url = ""
|
|
|
|
def handle_starttag(self, tag: str, attrs: list[tuple[str, str | None]]) -> None:
|
|
if tag != "input":
|
|
return
|
|
values = {name: value or "" for name, value in attrs}
|
|
input_id = values.get("id", "")
|
|
input_name = values.get("name", "")
|
|
value = values.get("value", "").strip()
|
|
if input_id == "J_qrCode" or input_name == "qrCode":
|
|
self.qr_code = value
|
|
elif input_id == "J_qrImgUrl" or input_name == "qrImgUrl":
|
|
self.qr_image_url = value
|
|
|
|
|
|
class _HtmlFormParser(HTMLParser):
|
|
"""提取简单 HTML 表单,用于跟随支付宝网关的自动提交页。"""
|
|
|
|
def __init__(self) -> None:
|
|
super().__init__()
|
|
self.action = ""
|
|
self.method = "GET"
|
|
self.fields: dict[str, str] = {}
|
|
self._inside_form = False
|
|
|
|
def handle_starttag(self, tag: str, attrs: list[tuple[str, str | None]]) -> None:
|
|
values = {name: value or "" for name, value in attrs}
|
|
if tag == "form" and not self.action:
|
|
self.action = values.get("action", "").strip()
|
|
self.method = values.get("method", "GET").strip().upper() or "GET"
|
|
self._inside_form = True
|
|
elif tag == "input" and self._inside_form:
|
|
name = values.get("name", "").strip()
|
|
if name:
|
|
self.fields[name] = values.get("value", "")
|
|
|
|
def handle_endtag(self, tag: str) -> None:
|
|
if tag == "form":
|
|
self._inside_form = False
|
|
|
|
|
|
def normalize_weibo_cookie(raw: str) -> str:
|
|
"""把粘贴内容整理成一行 Cookie。
|
|
|
|
支持带 ``Cookie:`` 前缀、多行 Cookie 和普通分号格式。不解析或改写值,
|
|
避免破坏微博 Cookie 中可能包含的百分号编码。
|
|
"""
|
|
|
|
text = (raw or "").strip()
|
|
if not text:
|
|
raise ValueError("CK 为空")
|
|
if text.lower().startswith("cookie:"):
|
|
text = text.split(":", 1)[1].strip()
|
|
|
|
normalized = text.replace("\r", ";").replace("\n", ";")
|
|
pairs: list[str] = []
|
|
for part in normalized.split(";"):
|
|
item = part.strip()
|
|
if not item:
|
|
continue
|
|
if "=" not in item:
|
|
raise ValueError(f"Cookie 片段格式错误: {item[:40]}")
|
|
pairs.append(item)
|
|
if not pairs:
|
|
raise ValueError("CK 为空")
|
|
return "; ".join(pairs)
|
|
|
|
|
|
def _parse_cookie_pairs(cookie: str) -> dict[str, str]:
|
|
pairs: dict[str, str] = {}
|
|
for item in cookie.split(";"):
|
|
name, separator, value = item.strip().partition("=")
|
|
if separator and name:
|
|
pairs[name] = value
|
|
return pairs
|
|
|
|
|
|
def _extract_subscribe_url(action_url: str) -> str:
|
|
"""把 ``sinaweibo://transparentbrowser`` 还原成实际 V+ 页面 URL。"""
|
|
|
|
value = (action_url or "").strip()
|
|
if not value:
|
|
return ""
|
|
if value.startswith("sinaweibo://transparentbrowser?"):
|
|
query = parse_qs(urlsplit(value).query, keep_blank_values=True)
|
|
return str((query.get("url") or [""])[0]).strip()
|
|
return value
|
|
|
|
|
|
def _subscribe_params(action_url: str) -> dict[str, str]:
|
|
url = _extract_subscribe_url(action_url)
|
|
if not url:
|
|
return {}
|
|
query = parse_qs(urlsplit(url).query, keep_blank_values=True)
|
|
return {key: str((values or [""])[0]) for key, values in query.items()}
|
|
|
|
|
|
def extract_bind_raw_url(bind_url: str) -> str:
|
|
"""从微博 ``sinaweibo://wbdiversion`` 链接提取来联内层 Query。
|
|
|
|
outer query 只解码一次,得到 ``path``;内层 ``code/faceUrl`` 仍保留
|
|
抓包中的多重编码,不能再 unquote。
|
|
"""
|
|
|
|
value = (bind_url or "").strip()
|
|
if not value:
|
|
raise WeiboBindError("微博未返回 bind_url")
|
|
|
|
try:
|
|
outer = parse_qs(urlsplit(value).query, keep_blank_values=True)
|
|
except ValueError as exc:
|
|
raise WeiboBindError("微博 bind_url 格式错误") from exc
|
|
path = (outer.get("path") or [""])[0]
|
|
marker = "/pages/gameAccountBind/index?"
|
|
if marker not in path:
|
|
raise WeiboBindError("bind_url 中缺少绑定小程序路径")
|
|
raw_url = path.split(marker, 1)[1]
|
|
if not raw_url:
|
|
raise WeiboBindError("bind_url 内层参数为空")
|
|
return raw_url
|
|
|
|
|
|
class WeiboClient:
|
|
"""微博微豆基础客户端。"""
|
|
|
|
def __init__(
|
|
self,
|
|
cookie: str = "",
|
|
*,
|
|
timeout: float = 20.0,
|
|
session: requests.Session | None = None,
|
|
):
|
|
self.cookie = normalize_weibo_cookie(cookie) if cookie else ""
|
|
self.timeout = timeout
|
|
self.session = session or requests.Session()
|
|
self.session.headers.update(
|
|
{
|
|
"User-Agent": WEIBO_MOBILE_UA,
|
|
"Accept": "application/json, text/plain, */*",
|
|
}
|
|
)
|
|
# 微博 Cookie 只挂到 .weibo.com,避免把 CK 发给来联/QQ 或支付宝域名。
|
|
for name, value in _parse_cookie_pairs(self.cookie).items():
|
|
self.session.cookies.set(name, value, domain=".weibo.com", path="/")
|
|
|
|
def _request_json(
|
|
self,
|
|
method: str,
|
|
url: str,
|
|
*,
|
|
headers: dict[str, str] | None = None,
|
|
params: dict[str, Any] | None = None,
|
|
data: dict[str, Any] | None = None,
|
|
json: dict[str, Any] | None = None,
|
|
) -> dict[str, Any]:
|
|
try:
|
|
response = self.session.request(
|
|
method,
|
|
url,
|
|
headers=headers,
|
|
params=params,
|
|
data=data,
|
|
json=json,
|
|
timeout=self.timeout,
|
|
)
|
|
response.raise_for_status()
|
|
payload = response.json()
|
|
except requests.RequestException as exc:
|
|
raise WeiboTransportError(f"请求微博失败: {exc}") from exc
|
|
except ValueError as exc:
|
|
raise WeiboTransportError("微博返回内容不是合法 JSON") from exc
|
|
if not isinstance(payload, dict):
|
|
raise WeiboTransportError("微博返回 JSON 结构异常")
|
|
return payload
|
|
|
|
def _request_text(
|
|
self,
|
|
method: str,
|
|
url: str,
|
|
*,
|
|
headers: dict[str, str] | None = None,
|
|
params: dict[str, Any] | None = None,
|
|
data: dict[str, Any] | None = None,
|
|
) -> str:
|
|
try:
|
|
response = self.session.request(
|
|
method,
|
|
url,
|
|
headers=headers,
|
|
params=params,
|
|
data=data,
|
|
timeout=self.timeout,
|
|
)
|
|
response.raise_for_status()
|
|
return response.text
|
|
except requests.RequestException as exc:
|
|
raise WeiboTransportError(f"请求微博收银台失败: {exc}") from exc
|
|
|
|
def _alipay_qr_from_page(self, html: str, page_url: str) -> str:
|
|
"""穿过支付宝网关自动提交表单,提取扫码支付内容。"""
|
|
|
|
current_html = html
|
|
current_url = page_url
|
|
for _ in range(4):
|
|
cashier = _AlipayCashierParser()
|
|
cashier.feed(current_html)
|
|
if cashier.qr_code:
|
|
return cashier.qr_code
|
|
|
|
form = _HtmlFormParser()
|
|
form.feed(current_html)
|
|
if not form.action:
|
|
break
|
|
action_url = urljoin(current_url, form.action)
|
|
current_html = self._request_text(
|
|
form.method,
|
|
action_url,
|
|
headers={
|
|
"User-Agent": WEIBO_DESKTOP_UA,
|
|
"Referer": current_url,
|
|
},
|
|
params=form.fields if form.method == "GET" else None,
|
|
data=form.fields if form.method != "GET" else None,
|
|
)
|
|
current_url = action_url
|
|
raise WeiboRechargeError("支付宝收银台没有返回扫码支付内容")
|
|
|
|
def _weibo_api(
|
|
self,
|
|
method: str,
|
|
path: str,
|
|
*,
|
|
params: dict[str, Any],
|
|
data: dict[str, Any] | None = None,
|
|
headers: dict[str, str] | None = None,
|
|
base: str = WEIBO_API_BASE,
|
|
) -> dict[str, Any]:
|
|
payload = self._request_json(
|
|
method,
|
|
f"{base}{path}",
|
|
params=params,
|
|
data=data,
|
|
headers=headers,
|
|
)
|
|
code = payload.get("code")
|
|
if code in WEIBO_AUTH_ERROR_CODES:
|
|
raise WeiboAuthError(str(payload.get("msg") or "微博 CK 已失效"))
|
|
if code != 100000:
|
|
raise WeiboApiError(str(payload.get("msg") or f"微博接口返回 code={code}"))
|
|
return payload
|
|
|
|
def fetch_home(self, activity: str = "") -> WeiboHomeSnapshot:
|
|
"""读取微豆首页并校验 CK。"""
|
|
|
|
if not self.cookie:
|
|
raise WeiboAuthError("CK 为空")
|
|
url = f"{WEIBO_HOME_URL}?F={activity}&_t={int(time.time() * 1000)}"
|
|
payload = self._request_json(
|
|
"GET",
|
|
url,
|
|
headers={
|
|
"Referer": f"https://vipclub.e.weibo.com/game/homepage?F={activity}",
|
|
},
|
|
)
|
|
code = payload.get("code")
|
|
if code in WEIBO_AUTH_ERROR_CODES:
|
|
raise WeiboAuthError(str(payload.get("msg") or "微博 CK 已失效"))
|
|
if code != 100000:
|
|
trace_id = str(payload.get("trace_id") or "")
|
|
message = str(payload.get("msg") or f"微博接口返回 code={code}")
|
|
if trace_id:
|
|
message = f"{message} (trace_id={trace_id})"
|
|
raise WeiboApiError(message)
|
|
|
|
data = payload.get("data") or {}
|
|
user_info = data.get("user_info") or {}
|
|
game_role = data.get("game_role") or {}
|
|
balance = data.get("weidou_balance") or {}
|
|
recharge_url = ""
|
|
for task in data.get("task_list") or []:
|
|
if not isinstance(task, dict) or task.get("button_type") != "cashier":
|
|
continue
|
|
candidate = _extract_subscribe_url(str(task.get("action_url") or ""))
|
|
if candidate:
|
|
recharge_url = candidate
|
|
break
|
|
subscribe = _subscribe_params(recharge_url)
|
|
resolved_activity = subscribe.get("F") or activity
|
|
uid = str(user_info.get("uid") or "").strip()
|
|
bind_url = str(game_role.get("bind_url") or "").strip()
|
|
if not uid:
|
|
raise WeiboApiError("微博首页没有返回 uid")
|
|
|
|
return WeiboHomeSnapshot(
|
|
uid=uid,
|
|
nickname=str(user_info.get("nickname") or ""),
|
|
is_bound=bool(game_role.get("is_bound")),
|
|
role_name=str(game_role.get("role_name") or ""),
|
|
can_rebind=bool(game_role.get("can_rebind")),
|
|
rebind_remain_days=int(game_role.get("rebind_remain_days") or 0),
|
|
weidou_balance=int(balance.get("balance") or 0),
|
|
bind_url=bind_url,
|
|
recharge_url=recharge_url,
|
|
activity=resolved_activity,
|
|
)
|
|
|
|
def list_mall(self, activity: str = "") -> dict[str, Any]:
|
|
"""查询微豆商城商品列表。"""
|
|
|
|
payload = self._weibo_api(
|
|
"GET",
|
|
"/mall/list",
|
|
params={"F": activity, "_t": int(time.time() * 1000)},
|
|
)
|
|
return payload.get("data") or {}
|
|
|
|
def exchange_goods(
|
|
self,
|
|
goods_id: int,
|
|
*,
|
|
expected_price: int,
|
|
activity: str = "",
|
|
) -> WeiboExchangeResult:
|
|
"""手动兑换商品:预检后只提交一次,成功后尽力复核。"""
|
|
|
|
before = self.list_mall(activity)
|
|
goods_list = before.get("list") or []
|
|
goods = next(
|
|
(
|
|
item
|
|
for item in goods_list
|
|
if isinstance(item, dict) and int(item.get("id") or 0) == goods_id
|
|
),
|
|
None,
|
|
)
|
|
if goods is None:
|
|
raise WeiboExchangeError("商品不存在或已下架")
|
|
price = int(goods.get("price") or 0)
|
|
balance_before = int(before.get("weidou_balance") or 0)
|
|
remain_before = (
|
|
int(goods["remain_count"])
|
|
if goods.get("remain_count") is not None
|
|
else None
|
|
)
|
|
if expected_price > 0 and price != expected_price:
|
|
raise WeiboExchangeError(
|
|
f"商品价格已变化:页面 {expected_price},当前 {price}"
|
|
)
|
|
if goods.get("is_sold_out") or remain_before == 0:
|
|
raise WeiboExchangeError("商品库存不足")
|
|
if balance_before < price:
|
|
raise WeiboExchangeError(
|
|
f"微豆余额不足:当前 {balance_before},需要 {price}"
|
|
)
|
|
|
|
try:
|
|
payload = self._weibo_api(
|
|
"GET",
|
|
"/game/exchangegoods",
|
|
params={
|
|
"F": activity,
|
|
"goods_id": goods_id,
|
|
"_t": int(time.time() * 1000),
|
|
},
|
|
headers={
|
|
"User-Agent": WEIBO_DESKTOP_UA,
|
|
"Referer": f"https://vipclub.e.weibo.com/game/mall?F={activity}",
|
|
},
|
|
)
|
|
except WeiboTransportError as exc:
|
|
raise WeiboExchangeUncertainError(
|
|
"兑换请求已发出,但网络中断导致结果未知;请先查兑换记录,不要立即重试"
|
|
) from exc
|
|
if payload.get("code") != 100000:
|
|
raise WeiboExchangeError(
|
|
str(payload.get("msg") or f"兑换失败 code={payload.get('code')}")
|
|
)
|
|
|
|
balance_after: int | None = None
|
|
remain_after: int | None = None
|
|
verified = False
|
|
verify_error = ""
|
|
try:
|
|
after = self.list_mall(activity)
|
|
balance_after = int(after.get("weidou_balance") or 0)
|
|
refreshed = next(
|
|
(
|
|
item
|
|
for item in (after.get("list") or [])
|
|
if isinstance(item, dict) and int(item.get("id") or 0) == goods_id
|
|
),
|
|
None,
|
|
)
|
|
if refreshed and refreshed.get("remain_count") is not None:
|
|
remain_after = int(refreshed["remain_count"])
|
|
verified = balance_after != balance_before or (
|
|
remain_after is not None
|
|
and remain_before is not None
|
|
and remain_after != remain_before
|
|
)
|
|
if not verified:
|
|
verify_error = "兑换成功,但余额和库存暂未变化,请查兑换记录复核"
|
|
except (WeiboError, ValueError, TypeError) as exc:
|
|
verify_error = f"兑换已提交,但复核失败: {exc}"
|
|
|
|
return WeiboExchangeResult(
|
|
goods_id=goods_id,
|
|
goods_name=str(goods.get("goods_name") or ""),
|
|
price=price,
|
|
balance_before=balance_before,
|
|
balance_after=balance_after,
|
|
remain_before=remain_before,
|
|
remain_after=remain_after,
|
|
verified=verified,
|
|
verify_error=verify_error,
|
|
)
|
|
|
|
def list_exchange_records(
|
|
self,
|
|
activity: str = "",
|
|
*,
|
|
page: int = 1,
|
|
page_size: int = 20,
|
|
) -> dict[str, Any]:
|
|
"""查询微豆兑换记录。"""
|
|
|
|
payload = self._weibo_api(
|
|
"GET",
|
|
"/mall/exchange",
|
|
params={
|
|
"F": activity,
|
|
"page": page,
|
|
"page_size": page_size,
|
|
"_t": int(time.time() * 1000),
|
|
},
|
|
)
|
|
return payload.get("data") or {}
|
|
|
|
def list_point_history(
|
|
self,
|
|
activity: str = "",
|
|
*,
|
|
page: int = 1,
|
|
page_size: int = 20,
|
|
) -> dict[str, Any]:
|
|
"""查询微豆获取/充值明细。"""
|
|
|
|
payload = self._weibo_api(
|
|
"GET",
|
|
"/point/get_point_recharge_history",
|
|
params={
|
|
"F": activity,
|
|
"page_no": page,
|
|
"page_size": page_size,
|
|
"_t": int(time.time() * 1000),
|
|
},
|
|
)
|
|
return payload.get("data") or {}
|
|
|
|
def _load_recharge_context(
|
|
self,
|
|
activity: str,
|
|
) -> tuple[dict[str, str], dict[str, Any], dict[str, Any]]:
|
|
home = self.fetch_home(activity)
|
|
if not home.recharge_url:
|
|
raise WeiboRechargeError("当前活动没有可用的助威充值入口")
|
|
subscribe = _subscribe_params(home.recharge_url)
|
|
vuid = subscribe.get("vuid", "")
|
|
source_itemid = subscribe.get("source_itemid", "")
|
|
if not vuid or not source_itemid:
|
|
raise WeiboRechargeError("充值入口缺少 vuid 或 source_itemid")
|
|
resolved_activity = subscribe.get("F") or home.activity or activity
|
|
subscribe["F"] = resolved_activity
|
|
page_headers = {
|
|
"User-Agent": WEIBO_DESKTOP_UA,
|
|
"Referer": home.recharge_url,
|
|
}
|
|
|
|
self._weibo_api(
|
|
"GET",
|
|
"/vmember/gfsupport",
|
|
params={
|
|
"vuid": vuid,
|
|
"source_itemid": source_itemid,
|
|
"F": resolved_activity,
|
|
"_t": int(time.time() * 1000),
|
|
},
|
|
base=WEIBO_VPLUS_API_BASE,
|
|
headers=page_headers,
|
|
)
|
|
user_payload = self._weibo_api(
|
|
"GET",
|
|
"/paycomponent/userinfo",
|
|
params={
|
|
"vuid": vuid,
|
|
"source_itemid": source_itemid,
|
|
"_t": int(time.time() * 1000),
|
|
},
|
|
base=WEIBO_VPLUS_API_BASE,
|
|
headers=page_headers,
|
|
)
|
|
user_data = user_payload.get("data") or {}
|
|
tabs = user_data.get("tab_info") or []
|
|
current_tab = next(
|
|
(tab for tab in tabs if isinstance(tab, dict) and tab.get("is_current")),
|
|
tabs[0] if tabs and isinstance(tabs[0], dict) else {},
|
|
)
|
|
tab_id = str(current_tab.get("tab_id") or "")
|
|
cashier_id = str(
|
|
current_tab.get("cashier_id") or user_data.get("cashier_id") or ""
|
|
)
|
|
if not tab_id or not cashier_id:
|
|
raise WeiboRechargeError("收银台没有返回 tab_id 或 cashier_id")
|
|
|
|
cashier_payload = self._weibo_api(
|
|
"GET",
|
|
"/paycomponent/cashierinfo",
|
|
params={
|
|
"F": resolved_activity,
|
|
"appName": "",
|
|
"from": "",
|
|
"wm": "",
|
|
"profileuid": "",
|
|
"tab_id": tab_id,
|
|
"cashier_id": cashier_id,
|
|
"device_id": "",
|
|
"tab_type": "",
|
|
"cashier_version": "0.2.3",
|
|
"vuid": vuid,
|
|
"bid": "",
|
|
"source_itemid": source_itemid,
|
|
},
|
|
base=WEIBO_VPLUS_API_BASE,
|
|
headers=page_headers,
|
|
)
|
|
return subscribe, current_tab, cashier_payload.get("data") or {}
|
|
|
|
def _recharge_bid(
|
|
self,
|
|
vuid: str,
|
|
source_itemid: str,
|
|
tab_type: str,
|
|
activity: str,
|
|
) -> str:
|
|
payload = self._weibo_api(
|
|
"GET",
|
|
"/paycomponent/privilege",
|
|
params={
|
|
"tab_type": tab_type,
|
|
"vuid": vuid,
|
|
"F": activity,
|
|
"_t": int(time.time() * 1000),
|
|
},
|
|
base=WEIBO_VPLUS_API_BASE,
|
|
headers={
|
|
"User-Agent": WEIBO_DESKTOP_UA,
|
|
"Referer": f"https://vipclub.weibo.com/vmember/subscribe?vuid={vuid}&source_itemid={source_itemid}&F={activity}",
|
|
},
|
|
)
|
|
data = payload.get("data") or {}
|
|
privilege = data.get("privilege") or {}
|
|
url = str(privilege.get("url") or "")
|
|
return str((parse_qs(urlsplit(url).query).get("bid") or [""])[0])
|
|
|
|
def create_recharge_order(
|
|
self,
|
|
*,
|
|
amount_yuan: int,
|
|
activity: str = "",
|
|
) -> WeiboRechargeOrder:
|
|
"""创建人工充值订单并解析支付二维码,不执行支付。"""
|
|
|
|
if amount_yuan < 1:
|
|
raise WeiboRechargeError("充值金额至少为 1 元")
|
|
subscribe, current_tab, cashier_data = self._load_recharge_context(activity)
|
|
tabs = cashier_data.get("tab_info") or []
|
|
tab = next(
|
|
(
|
|
item
|
|
for item in tabs
|
|
if isinstance(item, dict) and item.get("is_current")
|
|
),
|
|
tabs[0] if tabs and isinstance(tabs[0], dict) else current_tab,
|
|
)
|
|
menus = tab.get("menu") or []
|
|
menu = next(
|
|
(
|
|
item
|
|
for item in menus
|
|
if isinstance(item, dict)
|
|
and item.get("max_money") is not None
|
|
and str(item.get("subitemid") or "")
|
|
),
|
|
None,
|
|
)
|
|
if menu is None:
|
|
raise WeiboRechargeError("收银台没有返回自定义微豆充值档位")
|
|
max_money = int(menu.get("max_money") or 0)
|
|
if max_money and amount_yuan > max_money:
|
|
raise WeiboRechargeError(f"充值金额不能超过 {max_money} 元")
|
|
|
|
vuid = subscribe["vuid"]
|
|
source_itemid = subscribe["source_itemid"]
|
|
resolved_activity = subscribe["F"]
|
|
tab_type = str(tab.get("tab_type") or current_tab.get("tab_type") or "")
|
|
cashier_id = str(tab.get("cashier_id") or current_tab.get("cashier_id") or "")
|
|
bid = self._recharge_bid(
|
|
vuid,
|
|
source_itemid,
|
|
tab_type,
|
|
resolved_activity,
|
|
)
|
|
single_reward = amount_yuan * 100
|
|
ext = json.dumps(
|
|
{"bid": bid, "send_blog": 1},
|
|
ensure_ascii=False,
|
|
separators=(",", ":"),
|
|
)
|
|
params = {
|
|
"subitemid": str(menu.get("subitemid") or ""),
|
|
"itemid": str(menu.get("itemid") or ""),
|
|
"appkey": str(menu.get("appkey") or ""),
|
|
"commodity_id": str(menu.get("commodity_id") or ""),
|
|
"num": "1",
|
|
"salestrategy_id": "",
|
|
"discount_id": "",
|
|
"from": "",
|
|
"device_id": "",
|
|
"level_up_tag": "0",
|
|
"cashier_id": cashier_id,
|
|
"F": resolved_activity,
|
|
"to_uid": "",
|
|
"is_from_cashier": "1",
|
|
"return_url": "",
|
|
"ext": ext,
|
|
"ori_t_id": "",
|
|
"will_auto_renew": "0",
|
|
"cashierpw": "1",
|
|
"subpw": "",
|
|
"account": "",
|
|
"follow": "false",
|
|
"coupon_id": "",
|
|
"vuid": vuid,
|
|
"tab_type": tab_type,
|
|
"cashier_version": "0.2.3",
|
|
"gc": "",
|
|
"bid": "",
|
|
"mid": "",
|
|
"single_reward": str(single_reward),
|
|
}
|
|
payload = self._request_json(
|
|
"POST",
|
|
f"{WEIBO_VPLUS_API_BASE}/paycomponent/createorder",
|
|
params=params,
|
|
data=params,
|
|
headers={
|
|
"User-Agent": WEIBO_DESKTOP_UA,
|
|
"Origin": "https://vipclub.weibo.com",
|
|
"Referer": f"https://vipclub.weibo.com/vmember/subscribe?vuid={vuid}&source_itemid={source_itemid}&F={resolved_activity}",
|
|
},
|
|
)
|
|
if payload.get("code") in WEIBO_AUTH_ERROR_CODES:
|
|
raise WeiboAuthError(str(payload.get("msg") or "微博 CK 已失效"))
|
|
if payload.get("code") != 100000:
|
|
raise WeiboRechargeError(str(payload.get("msg") or "创建充值订单失败"))
|
|
order_data = payload.get("data") or {}
|
|
cashier_url = str(order_data.get("url") or "")
|
|
order_id = str(order_data.get("orderid") or "")
|
|
if not cashier_url or not order_id:
|
|
raise WeiboRechargeError("充值订单缺少支付地址或订单号")
|
|
|
|
html = self._request_text(
|
|
"GET",
|
|
cashier_url,
|
|
headers={
|
|
"User-Agent": WEIBO_DESKTOP_UA,
|
|
"Referer": "https://vipclub.weibo.com/",
|
|
},
|
|
)
|
|
parser = _CashierParser()
|
|
parser.feed(html)
|
|
if not parser.pay_id:
|
|
raise WeiboRechargeError("收银台页面没有返回 pay_id")
|
|
if not parser.desktop_pay_url:
|
|
raise WeiboRechargeError("收银台页面没有返回电脑端支付链接")
|
|
|
|
alipay_html = self._request_text(
|
|
"GET",
|
|
parser.desktop_pay_url,
|
|
headers={
|
|
"User-Agent": WEIBO_DESKTOP_UA,
|
|
"Referer": "https://pay.sc.weibo.com/pay/pc/cashier",
|
|
},
|
|
)
|
|
alipay_qr_code = self._alipay_qr_from_page(
|
|
alipay_html,
|
|
parser.desktop_pay_url,
|
|
)
|
|
return WeiboRechargeOrder(
|
|
order_id=order_id,
|
|
pay_id=parser.pay_id,
|
|
amount_yuan=amount_yuan,
|
|
single_reward=single_reward,
|
|
alipay_qr_code=alipay_qr_code,
|
|
desktop_pay_url=parser.desktop_pay_url,
|
|
)
|
|
|
|
def get_payment_status(self, pay_id: str) -> WeiboPaymentStatus:
|
|
"""手动查询充值支付状态。"""
|
|
|
|
if not pay_id.strip():
|
|
raise ValueError("pay_id 为空")
|
|
payload = self._request_json(
|
|
"POST",
|
|
f"{WEIBO_PAY_BASE}/aj/pay/status",
|
|
data={"pay_id": pay_id, "ispage": "1", "_t": "0"},
|
|
headers={
|
|
"Content-Type": "application/x-www-form-urlencoded",
|
|
"User-Agent": WEIBO_DESKTOP_UA,
|
|
"X-Requested-With": "XMLHttpRequest",
|
|
"Origin": WEIBO_PAY_BASE,
|
|
"Referer": f"{WEIBO_PAY_BASE}/pay/pc/cashier",
|
|
},
|
|
)
|
|
if str(payload.get("code")) != "100000":
|
|
raise WeiboRechargeError(str(payload.get("msg") or "查询支付状态失败"))
|
|
data = payload.get("data") or {}
|
|
return WeiboPaymentStatus(
|
|
pay_id=str(data.get("pay_id") or pay_id),
|
|
out_pay_id=str(data.get("out_pay_id") or ""),
|
|
status=str(data.get("status") or ""),
|
|
sub_status=str(data.get("sub_status") or ""),
|
|
)
|
|
|
|
def generate_bind_scheme(self, bind_url: str, account_type: str) -> str:
|
|
"""生成微信或 QQ 小程序 URL Scheme。"""
|
|
|
|
if account_type not in {"wx", "qq"}:
|
|
raise ValueError("account_type 只能是 wx 或 qq")
|
|
raw_url = extract_bind_raw_url(bind_url)
|
|
url = f"{WEIBO_SCHEME_URL}?c=Sdk&a=GenMiniappScheme&{raw_url}"
|
|
payload = self._request_json(
|
|
"POST",
|
|
url,
|
|
headers={"Content-Type": "application/json"},
|
|
json={"accountType": account_type},
|
|
)
|
|
if payload.get("iRet") != 0:
|
|
raise WeiboBindError(
|
|
f"生成绑定 Scheme 失败: {payload.get('sMsg') or payload}"
|
|
)
|
|
scheme = str((payload.get("jData") or {}).get("urlScheme") or "").strip()
|
|
if not scheme:
|
|
raise WeiboBindError("来联接口没有返回 urlScheme")
|
|
return scheme
|