Files
nanocode/skills/xiaohongshu-cli/xhs_cli/qr_login.py
T
Ubuntu 32b43f85bd a
2026-07-03 16:58:28 +08:00

549 lines
19 KiB
Python

"""QR code login for Xiaohongshu.
Supports two backends:
1. Browser-assisted login via Camoufox. The browser performs the real
QR completion flow, while the CLI renders the QR code in the terminal
from the browser's ``qrcode/create`` response and exports cookies after
login succeeds.
2. Legacy pure-HTTP login flow. This remains as a fallback when the
browser backend is unavailable.
"""
from __future__ import annotations
import logging
import random
import subprocess
import sys
import time
from typing import Any
from .client import XhsClient
from .cookies import save_cookies
from .exceptions import NeedVerifyError, XhsApiError
logger = logging.getLogger(__name__)
LOGIN_URL = "https://www.xiaohongshu.com/login"
QR_CREATE_ENDPOINT = "/api/sns/web/v1/login/qrcode/create"
QR_USERINFO_ENDPOINT = "/api/qrcode/userinfo"
QR_STATUS_ENDPOINT = "/api/sns/web/v1/login/qrcode/status"
# QR code status values
QR_WAITING = 0 # Waiting for scan
QR_SCANNED = 1 # Scanned, awaiting confirmation
QR_CONFIRMED = 2 # Login confirmed
# Poll config
POLL_INTERVAL_S = 2.0
POLL_TIMEOUT_S = 240 # 4 minutes
BROWSER_EXPORT_COOKIE_NAMES = (
"a1",
"webId",
"web_session",
"web_session_sec",
"id_token",
"websectiga",
"sec_poison_id",
"xsecappid",
"gid",
"abRequestId",
"webBuild",
"loadts",
)
class BrowserQrLoginUnavailable(XhsApiError):
"""Raised when the browser-assisted QR backend cannot be started."""
def _emit_status(on_status: callable[[str], None] | None, msg: str) -> None:
"""Send a status message to the callback or stdout."""
if on_status:
on_status(msg)
else:
print(msg)
def _apply_session_cookies(client: XhsClient, payload: dict[str, Any]) -> None:
"""Persist any session cookies returned by the QR login endpoints."""
login_info = payload.get("login_info", {})
if not isinstance(login_info, dict):
login_info = {}
session = payload.get("session") or login_info.get("session", "")
secure_session = payload.get("secure_session") or login_info.get("secure_session", "")
if session:
client.cookies["web_session"] = str(session)
if secure_session:
client.cookies["web_session_sec"] = str(secure_session)
def _build_saved_cookies(a1: str, webid: str, payload: dict[str, Any]) -> dict[str, str]:
"""Build the cookie payload persisted after QR login succeeds."""
login_info = payload.get("login_info", {})
if not isinstance(login_info, dict):
login_info = {}
session = payload.get("session") or login_info.get("session") or payload.get("web_session", "")
secure_session = (
payload.get("secure_session")
or login_info.get("secure_session")
or payload.get("web_session_sec", "")
)
cookies = {
"a1": a1,
"webId": webid,
}
if session:
cookies["web_session"] = str(session)
if secure_session:
cookies["web_session_sec"] = str(secure_session)
return cookies
def _normalize_browser_cookies(raw_cookies: list[dict[str, Any]]) -> dict[str, str]:
"""Convert Playwright cookies into the local persisted cookie shape."""
cookies: dict[str, str] = {}
for entry in raw_cookies:
name = entry.get("name")
value = entry.get("value")
domain = entry.get("domain", "")
if not isinstance(name, str) or not isinstance(value, str):
continue
if name not in BROWSER_EXPORT_COOKIE_NAMES:
continue
if not isinstance(domain, str) or "xiaohongshu.com" not in domain:
continue
cookies[name] = value
return cookies
def _unwrap_browser_response_payload(payload: dict[str, Any]) -> dict[str, Any]:
"""Return the inner `data` payload when browser responses use the common envelope."""
data = payload.get("data")
if isinstance(data, dict):
return data
return payload
def _browser_response_payload(response: Any) -> dict[str, Any]:
"""Decode a browser response body as JSON and unwrap the common envelope."""
try:
data = response.json()
except Exception as exc:
raise XhsApiError(f"Browser response from {response.url} was not valid JSON.") from exc
if not isinstance(data, dict):
raise XhsApiError(f"Browser response from {response.url} returned unexpected payload: {data!r}")
return _unwrap_browser_response_payload(data)
def _raise_for_browser_response(response: Any) -> None:
"""Map browser QR completion failures onto the same domain errors as the HTTP client."""
status = getattr(response, "status", None)
if status in (461, 471):
raise NeedVerifyError(
verify_type=response.headers.get("verifytype", "unknown"),
verify_uuid=response.headers.get("verifyuuid", "unknown"),
)
if status and status >= 400:
try:
body = response.text()
except Exception:
body = "<unavailable>"
raise XhsApiError(
f"Browser-assisted QR completion failed: HTTP {status} body={body[:300]}"
)
def _wait_for_browser_login_settled(page: Any) -> None:
"""Wait briefly for the browser session and post-login page state to stabilize."""
try:
page.wait_for_url("**/explore*", timeout=5_000)
except Exception:
logger.debug("Browser-assisted QR login did not navigate to /explore before timeout")
try:
response = page.wait_for_response(
lambda resp: "/api/sns/web/v2/user/me" in resp.url and resp.request.method == "GET",
timeout=5_000,
)
except Exception:
logger.debug("Browser-assisted QR login did not observe a post-login user/me response before timeout")
return
try:
payload = _browser_response_payload(response)
except Exception as exc:
logger.debug("Failed to parse browser user/me response after QR login: %s", exc)
return
if bool(payload.get("guest", False)):
logger.debug("Browser-assisted QR login settled with guest=true in user/me payload: %s", payload)
def _resolved_user_id(info: dict[str, object]) -> str:
"""Extract the current user ID from activate/self payloads."""
if not isinstance(info, dict):
return ""
login_info = info.get("login_info")
if isinstance(login_info, dict) and login_info.get("user_id"):
return str(login_info["user_id"])
basic = info.get("basic_info", info)
if isinstance(basic, dict) and basic.get("user_id"):
return str(basic["user_id"])
if info.get("user_id"):
return str(info["user_id"])
if info.get("userid"):
return str(info["userid"])
return ""
def _complete_confirmed_session(
client: XhsClient,
qr_id: str,
code: str,
confirmed_user_id: str,
*,
retries: int = 5,
wait_s: float = 1.0,
) -> dict[str, object]:
"""Finalize QR login after confirmation until the session switches users."""
last_data: dict[str, object] = {}
last_self_info_user_id = ""
for attempt in range(retries):
completion_data = client.complete_qr_login(qr_id, code)
_apply_session_cookies(client, completion_data)
last_data = completion_data
completed_user_id = _resolved_user_id(completion_data)
self_info_user_id = ""
try:
self_info_user_id = _resolved_user_id(client.get_self_info())
last_self_info_user_id = self_info_user_id
except Exception as exc:
logger.debug(
"QR post-confirm self info check failed attempt=%d: %s",
attempt + 1,
exc,
)
logger.debug(
"QR post-confirm completion attempt=%d confirmed_user_id=%s "
"completion_user_id=%s self_info_user_id=%s cookies=%s data=%s",
attempt + 1,
confirmed_user_id,
completed_user_id,
self_info_user_id,
{
"web_session": client.cookies.get("web_session"),
"web_session_sec": client.cookies.get("web_session_sec"),
},
completion_data,
)
if completed_user_id and completed_user_id == confirmed_user_id:
return completion_data
if self_info_user_id and self_info_user_id == confirmed_user_id:
return completion_data
if attempt + 1 < retries:
time.sleep(wait_s)
raise XhsApiError(
"QR login confirmed, but completion never returned the confirmed user session. "
f"expected={confirmed_user_id} "
f"completion_user={_resolved_user_id(last_data) or 'unknown'} "
f"self_info_user={last_self_info_user_id or 'unknown'} "
f"completion_data={last_data}"
)
def _generate_a1() -> str:
"""Generate a fresh a1 cookie value (52 hex chars with embedded timestamp)."""
prefix = "".join(random.choices("0123456789abcdef", k=24))
ts = str(int(time.time() * 1000))
suffix = "".join(random.choices("0123456789abcdef", k=15))
return prefix + ts + suffix
def _generate_webid() -> str:
"""Generate a webId cookie value (32 hex chars)."""
return "".join(random.choices("0123456789abcdef", k=32))
def _render_qr_half_blocks(matrix: list[list[bool]]) -> str:
"""Render QR matrix using half-block characters (▀▄█ and space)."""
if not matrix:
return ""
size = len(matrix)
lines: list[str] = []
for row_idx in range(0, size, 2):
line = ""
for col_idx in range(size):
top = matrix[row_idx][col_idx]
bot = matrix[row_idx + 1][col_idx] if row_idx + 1 < size else False
if top and bot:
line += "█"
elif top and not bot:
line += "▀"
elif not top and bot:
line += "▄"
else:
line += " "
lines.append(line)
return "\n".join(lines)
def _display_qr_in_terminal(data: str) -> bool:
"""Display *data* as a QR code in the terminal. Returns True on success."""
try:
import qrcode # type: ignore[import-untyped]
except ImportError:
return False
qr = qrcode.QRCode(border=4)
qr.add_data(data)
qr.make(fit=True)
modules = qr.get_matrix()
print(_render_qr_half_blocks(modules))
return True
def _ensure_camoufox_ready() -> None:
"""Validate that the Camoufox package and browser binary are available."""
try:
import camoufox # noqa: F401
except ImportError as exc:
raise BrowserQrLoginUnavailable(
"Browser-assisted QR login requires the `camoufox` package."
) from exc
try:
result = subprocess.run(
[sys.executable, "-m", "camoufox", "path"],
capture_output=True,
text=True,
timeout=15,
)
except (OSError, subprocess.SubprocessError) as exc:
raise BrowserQrLoginUnavailable(
"Unable to validate the Camoufox browser installation."
) from exc
if result.returncode != 0 or not result.stdout.strip():
raise BrowserQrLoginUnavailable(
"Camoufox browser runtime is missing. Run `python -m camoufox fetch` first."
)
def _browser_assisted_qrcode_login(
*,
on_status: callable[[str], None] | None = None,
timeout_s: int = POLL_TIMEOUT_S,
) -> dict[str, str]:
"""Log in by letting a real browser complete the QR flow, then export cookies."""
_ensure_camoufox_ready()
try:
from camoufox.sync_api import Camoufox
except ImportError as exc:
raise BrowserQrLoginUnavailable(
"Camoufox sync API is unavailable in the current environment."
) from exc
state = {"last_status": -1}
_emit_status(on_status, "🔑 Starting browser-assisted QR login...")
with Camoufox(headless=False) as browser:
page = browser.new_page()
def _handle_response(response) -> None:
url = response.url
if QR_USERINFO_ENDPOINT not in url:
return
try:
payload = _unwrap_browser_response_payload(response.json())
except Exception as exc:
logger.debug("Failed to parse browser QR poll response: %s", exc)
return
code_status = int(payload.get("codeStatus", -1))
if code_status == state["last_status"]:
return
state["last_status"] = code_status
if code_status == QR_SCANNED:
_emit_status(on_status, "📲 Scanned! Waiting for confirmation...")
elif code_status == QR_CONFIRMED:
_emit_status(on_status, "✅ Login confirmed!")
page.on("response", _handle_response)
try:
with page.expect_response(
lambda response: QR_CREATE_ENDPOINT in response.url and response.request.method == "POST",
timeout=20_000,
) as qr_response_info:
page.goto(LOGIN_URL, wait_until="domcontentloaded")
except Exception as exc:
raise XhsApiError("Failed to load Xiaohongshu login page in Camoufox.") from exc
qr_payload = _browser_response_payload(qr_response_info.value)
qr_url = str(qr_payload.get("url", "")).strip()
if not qr_url:
raise XhsApiError(f"Browser-assisted QR login did not expose a QR URL: {qr_payload}")
_emit_status(on_status, "\n📱 Scan the QR code below with the Xiaohongshu app:\n")
if not _display_qr_in_terminal(qr_url):
_emit_status(on_status, "⚠️ Install 'qrcode' for terminal rendering: pip install qrcode")
_emit_status(on_status, f"QR URL: {qr_url}")
_emit_status(on_status, "\n⏳ Waiting for QR code scan...")
try:
with page.expect_response(
lambda response: QR_STATUS_ENDPOINT in response.url and response.request.method == "GET",
timeout=timeout_s * 1000,
) as completion_info:
pass
except Exception as exc:
raise XhsApiError("QR code login timed out while waiting for browser completion.") from exc
_raise_for_browser_response(completion_info.value)
completion_data = _browser_response_payload(completion_info.value)
login_info = completion_data.get("login_info", {})
if not isinstance(login_info, dict):
login_info = {}
_wait_for_browser_login_settled(page)
cookies = _normalize_browser_cookies(page.context.cookies())
session = login_info.get("session")
secure_session = login_info.get("secure_session")
if isinstance(session, str) and session:
cookies["web_session"] = session
if isinstance(secure_session, str) and secure_session:
cookies["web_session_sec"] = secure_session
required = ("a1", "webId", "web_session")
missing = [name for name in required if not cookies.get(name)]
if missing:
raise XhsApiError(
"Browser-assisted QR login succeeded, but exported cookies were incomplete: "
f"missing={', '.join(missing)} completion_data={completion_data}"
)
save_cookies(cookies)
user_id = str(login_info.get("user_id", "")).strip() or _resolved_user_id(completion_data)
if user_id:
_emit_status(on_status, f"👤 User ID: {user_id}")
return cookies
def _http_qrcode_login(
*,
on_status: callable[[str], None] | None = None,
timeout_s: int = POLL_TIMEOUT_S,
) -> dict[str, str]:
"""Run the legacy pure-HTTP QR login flow."""
a1 = _generate_a1()
webid = _generate_webid()
tmp_cookies = {"a1": a1, "webId": webid}
_emit_status(on_status, "🔑 Starting QR code login...")
with XhsClient(tmp_cookies, request_delay=0) as client:
try:
activate_data = client.login_activate()
_apply_session_cookies(client, activate_data)
guest_session = activate_data.get("session", "")
logger.debug(
"Initial activate: session=%s user_id=%s",
guest_session, activate_data.get("user_id"),
)
except Exception as exc:
logger.debug("Initial activate failed (non-fatal): %s", exc)
qr_data = client.create_qr_login()
qr_id = qr_data["qr_id"]
code = qr_data["code"]
qr_url = qr_data["url"]
logger.debug("QR created: qr_id=%s, code=%s", qr_id, code)
_emit_status(on_status, "\n📱 Scan the QR code below with the Xiaohongshu app:\n")
if not _display_qr_in_terminal(qr_url):
_emit_status(on_status, "⚠️ Install 'qrcode' for terminal rendering: pip install qrcode")
_emit_status(on_status, f"QR URL: {qr_url}")
_emit_status(on_status, "\n⏳ Waiting for QR code scan...")
start = time.time()
last_status = -1
consecutive_errors = 0
while (time.time() - start) < timeout_s:
time.sleep(POLL_INTERVAL_S)
try:
status_data = client.check_qr_status(qr_id, code)
except Exception as exc:
logger.debug("QR status check error: %s", exc)
consecutive_errors += 1
if consecutive_errors >= 3:
raise XhsApiError(f"QR status polling failed repeatedly: {exc}") from exc
continue
else:
consecutive_errors = 0
code_status = status_data.get("codeStatus", -1)
logger.debug("QR poll: codeStatus=%s data=%s", code_status, status_data)
if code_status != last_status:
last_status = code_status
if code_status == QR_SCANNED:
_emit_status(on_status, "📲 Scanned! Waiting for confirmation...")
elif code_status == QR_CONFIRMED:
_emit_status(on_status, "✅ Login confirmed!")
if code_status == QR_CONFIRMED:
confirmed_user_id = status_data.get("userId", "")
if not confirmed_user_id:
raise XhsApiError("QR login confirmed but no confirmed userId was returned.")
completion_data = _complete_confirmed_session(
client,
qr_id,
code,
confirmed_user_id,
)
user_id = _resolved_user_id(completion_data) or confirmed_user_id
cookies = _build_saved_cookies(a1, webid, client.cookies)
save_cookies(cookies)
_emit_status(on_status, f"👤 User ID: {user_id}")
return cookies
elapsed = time.time() - start
if int(elapsed) % 30 == 0 and int(elapsed) > 0:
_emit_status(on_status, " Still waiting...")
raise XhsApiError("QR code login timed out after 4 minutes")
def qrcode_login(
*,
on_status: callable[[str], None] | None = None,
timeout_s: int = POLL_TIMEOUT_S,
prefer_browser_assisted: bool = False,
) -> dict[str, str]:
"""Run the QR code login flow."""
if prefer_browser_assisted:
try:
return _browser_assisted_qrcode_login(on_status=on_status, timeout_s=timeout_s)
except BrowserQrLoginUnavailable as exc:
logger.info("Browser-assisted QR login unavailable, falling back to HTTP flow: %s", exc)
return _http_qrcode_login(on_status=on_status, timeout_s=timeout_s)