Files
live-hub-py/services/yyb-worker/runtime/scripts/login-wechat.py
T

250 lines
8.9 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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 (
HTTPCookieProcessor,
HTTPRedirectHandler,
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):
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.IGNORECASE)
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 and code:
break
if errcode == 405:
# 已扫码、手机待确认:继续轮询,不能立即退出(否则 code 为空导致登录失败)。
last = "405"
elif errcode == 402:
raise RuntimeError("二维码已过期,请重新执行登录")
elif errcode == 403:
raise RuntimeError("用户取消了扫码登录")
else:
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