"""微博微豆 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._last_text_url = "" 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() self._last_text_url = str(getattr(response, "url", "") or url) 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, *, home: WeiboHomeSnapshot | None = None, ) -> tuple[str, dict[str, str], dict[str, Any], dict[str, Any]]: home = home or 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 home.uid, subscribe, current_tab, cashier_payload.get("data") or {} def check_payment_access( self, activity: str = "", *, home: WeiboHomeSnapshot | None = None, ) -> None: """账号检测时确认 CK 也能进入微博支付域。""" uid, _, _, cashier_data = self._load_recharge_context( activity, home=home, ) 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 {}, ) menu = next( ( item for item in tab.get("menu") or [] if isinstance(item, dict) and item.get("weibo_unified_params") ), None, ) if menu is None: raise WeiboRechargeError("收银台没有返回支付检测参数") self._preflight_recharge(uid, menu) def _preflight_recharge( self, uid: str, menu: dict[str, Any], ) -> None: """确认当前 CK 能通过微博支付域;此接口不会创建订单。""" unified = menu.get("weibo_unified_params") or {} response = self.session.post( f"{WEIBO_PAY_BASE}/aj/pay/modular/prepare", data={ "uid": uid, "seller_id": str(unified.get("seller_id") or ""), "modular_id": str(unified.get("modular_id") or ""), "default_cashier": str(unified.get("default_cashier") or ""), "total_amount": str(menu.get("price") or 6000), "hidden_channel": "0", "extra": json.dumps( {"goods_type": 2}, separators=(",", ":"), ), "gsid": "", "lang": "zh_cn", "is4g": "0", "from": "", "aid": "", "wm": "", }, headers={ "Content-Type": "application/x-www-form-urlencoded;charset=UTF-8", "User-Agent": WEIBO_DESKTOP_UA, "Origin": "https://vipclub.weibo.com", "Referer": "https://vipclub.weibo.com/", }, allow_redirects=False, timeout=self.timeout, ) location = response.headers.get("location", "") location_url = urlsplit(location) if location else None if response.status_code in {301, 302, 303, 307, 308}: if location_url and location_url.path == "/sso/login.php": raise WeiboRechargeError( "微博支付未登录:当前 CK 能访问微豆页面," "但不能用于支付,请重新登录微博后重新导入 CK" ) if location_url and location_url.path == "/sorry": query = parse_qs(location_url.query, keep_blank_values=True) if "usernotexists" in query: raise WeiboRechargeError( "微博支付账号不存在或未开通支付资格," "请先在微博 App/网页确认该账号可以正常发起支付" ) raise WeiboRechargeError( "微博支付预检被拒绝,请重新登录微博后重新导入 CK" ) if response.status_code >= 400: raise WeiboRechargeError(f"微博支付预检失败: HTTP {response.status_code}") 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 元") _uid, 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/", }, ) if urlsplit(self._last_text_url).path.rstrip("/") == "/sorry": raise WeiboRechargeError( "微博支付页拒绝了当前账号(跳转 /sorry)。" "CK 首页仍有效,请重新登录微博更新 CK," "或确认该账号具备支付/助威资格" ) 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