Files
qw-register-helper/qwen_oauth_login.py
T
2026-03-13 21:00:17 +08:00

482 lines
18 KiB
Python

from __future__ import annotations
import json
import os
import pty
import re
import select
import shutil
import subprocess
import tempfile
import time
from pathlib import Path
from typing import Any, Callable
from playwright.sync_api import TimeoutError as PlaywrightTimeoutError
from playwright.sync_api import sync_playwright
AUTHORIZE_URL_RE = re.compile(r"https://chat\.qwen\.ai/authorize\?user_code=[^\s]+&client=qwen-code")
CONTAINER_CREDENTIAL_RE = re.compile(r"/root/\.cli-proxy-api/(?P<filename>[^\s]+\.json)")
IDENTITY_PROMPT = "Please input your email address or alias for Qwen:"
SUCCESS_MARKER = "Qwen authentication successful"
def safe_email_name(email: str) -> str:
return email.replace("@", "_at_").replace(".", "_")
def write_qwen_login_config(work_dir: Path, api_key: str = "sk-temp-qwen-login") -> tuple[Path, Path]:
work_dir.mkdir(parents=True, exist_ok=True)
auth_dir = work_dir / "auth"
auth_dir.mkdir(parents=True, exist_ok=True)
config_path = work_dir / "config.yaml"
config_path.write_text(
"\n".join(
[
'port: 8317',
'auth-dir: "/root/.cli-proxy-api"',
"api-keys:",
f' - "{api_key}"',
"",
]
),
encoding="utf-8",
)
return config_path, auth_dir
class QwenOAuthLoginRunner:
def __init__(
self,
*,
config_path: Path,
auth_dir: Path,
image: str = "eceasy/cli-proxy-api:latest",
process_factory: Callable[..., subprocess.Popen[str]] | None = None,
) -> None:
self.config_path = config_path
self.auth_dir = auth_dir
self.image = image
self.process_factory = process_factory or subprocess.Popen
self.process: subprocess.Popen[str] | None = None
self._master_fd: int | None = None
self._output_buffer = ""
def start(self) -> None:
if self.process is not None:
return
command = [
"docker",
"run",
"--rm",
"-i",
"-v",
f"{self.config_path}:/CLIProxyAPI/config.yaml",
"-v",
f"{self.auth_dir}:/root/.cli-proxy-api",
self.image,
"/CLIProxyAPI/CLIProxyAPI",
"--qwen-login",
]
if self.process_factory is subprocess.Popen:
master_fd, slave_fd = pty.openpty()
self._master_fd = master_fd
try:
self.process = self.process_factory(
command,
stdin=slave_fd,
stdout=slave_fd,
stderr=slave_fd,
text=False,
close_fds=True,
)
finally:
os.close(slave_fd)
return
self.process = self.process_factory(
command,
stdin=subprocess.PIPE,
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT,
text=True,
bufsize=1,
)
def wait_for_authorize_url(self, timeout_seconds: float = 30.0) -> str:
return self._wait_for_match(AUTHORIZE_URL_RE, timeout_seconds)
def wait_for_identity_prompt(self, timeout_seconds: float = 120.0) -> None:
self._wait_for_text(IDENTITY_PROMPT, timeout_seconds)
def submit_identity(self, email: str) -> None:
if self.process is None:
raise RuntimeError("runner process is not started")
if self._master_fd is not None:
os.write(self._master_fd, f"{email}\n".encode("utf-8"))
return
if self.process.stdin is None:
raise RuntimeError("runner process is not started")
self.process.stdin.write(f"{email}\n")
self.process.stdin.flush()
def wait_for_credentials(self, timeout_seconds: float = 60.0) -> Path:
filename = self._wait_for_group(CONTAINER_CREDENTIAL_RE, "filename", timeout_seconds)
return self.auth_dir / filename
def close(self) -> None:
if self.process is None:
return
if self.process.poll() is None:
self.process.terminate()
try:
self.process.wait(timeout=5)
except subprocess.TimeoutExpired:
self.process.kill()
self.process = None
if self._master_fd is not None:
os.close(self._master_fd)
self._master_fd = None
def _wait_for_text(self, needle: str, timeout_seconds: float) -> str:
deadline = time.time() + timeout_seconds
while time.time() < deadline:
if needle in self._output_buffer:
return needle
if not self._read_available_text(timeout_seconds=0.2):
time.sleep(0.1)
raise TimeoutError(f"did not observe text: {needle}")
def _wait_for_match(self, pattern: re.Pattern[str], timeout_seconds: float) -> str:
deadline = time.time() + timeout_seconds
while time.time() < deadline:
match = pattern.search(self._output_buffer)
if match:
return match.group(0)
if not self._read_available_text(timeout_seconds=0.2):
time.sleep(0.1)
raise TimeoutError(f"did not observe pattern: {pattern.pattern}")
def _wait_for_group(self, pattern: re.Pattern[str], group_name: str, timeout_seconds: float) -> str:
deadline = time.time() + timeout_seconds
while time.time() < deadline:
match = pattern.search(self._output_buffer)
if match:
return match.group(group_name)
if not self._read_available_text(timeout_seconds=0.2):
time.sleep(0.1)
raise TimeoutError(f"did not observe pattern: {pattern.pattern}")
def _read_available_text(self, timeout_seconds: float = 0.0) -> bool:
if self.process is None:
raise RuntimeError("runner process is not started")
if self._master_fd is not None:
readable, _, _ = select.select([self._master_fd], [], [], timeout_seconds)
if not readable:
if self.process.poll() is not None:
raise RuntimeError("qwen oauth login process exited unexpectedly")
return False
chunk = os.read(self._master_fd, 4096)
if not chunk:
if self.process.poll() is not None:
raise RuntimeError("qwen oauth login process exited unexpectedly")
return False
self._output_buffer += chunk.decode("utf-8", errors="replace")
return True
if self.process.stdout is None:
raise RuntimeError("runner process is not started")
line = self.process.stdout.readline()
if line:
self._output_buffer += line
return True
if self.process.poll() is not None:
raise RuntimeError("qwen oauth login process exited unexpectedly")
return False
class QwenOAuthBrowserAutomator:
def __init__(self, *, headed: bool = True) -> None:
self.headed = headed
def authorize(self, authorize_url: str, email: str, password: str) -> None:
with sync_playwright() as playwright:
browser = self._launch_browser(playwright)
context = browser.new_context(viewport={"width": 1440, "height": 960})
page = context.new_page()
page.set_default_timeout(5000)
page.goto(authorize_url, wait_until="domcontentloaded")
self._assert_not_invalid(page)
self._login_if_needed(page, email, password)
self._confirm_if_present(page)
self._approve_if_present(page)
self._wait_for_authorization_progress(page)
browser.close()
def _launch_browser(self, playwright: Any) -> Any:
try:
return playwright.chromium.launch(channel="chrome", headless=not self.headed)
except Exception:
return playwright.chromium.launch(headless=not self.headed)
def _assert_not_invalid(self, page: Any) -> None:
text = page.locator("body").inner_text()
if "无效的 user code" in text or "认证失败" in text:
raise RuntimeError("qwen oauth page reported invalid user code")
def _login_if_needed(self, page: Any, email: str, password: str) -> None:
email_was_updated = self._fill_email(page, email)
if email_was_updated and not self._has_visible_password_input(page):
self._click_submit(page, [r"下一步", r"Next", r"登录", r"Sign in"])
if self._fill_password(page, password):
self._click_submit(page, [r"登录", r"Sign in"])
def _approve_if_present(self, page: Any) -> None:
self._click_submit(page, [r"授权", r"Authorize", r"同意", r"Allow"], required=False)
def _confirm_if_present(self, page: Any) -> None:
self._click_submit(page, [r"确定", r"确认", r"Confirm", r"OK", r"继续"], required=False)
def _wait_for_authorization_progress(self, page: Any) -> None:
deadline = time.time() + 20
success_markers = [
"授权成功",
"认证成功",
"认证完成",
"Authorization successful",
"Authenticated",
"You can close this window",
]
while time.time() < deadline:
self._assert_not_invalid(page)
self._confirm_if_present(page)
self._approve_if_present(page)
try:
body_text = page.locator("body").inner_text(timeout=1000)
except Exception:
body_text = ""
if any(marker in body_text for marker in success_markers):
return
current_url = page.url
if "user_code=" not in current_url and "/authorize" not in current_url and "/auth?" not in current_url:
return
page.wait_for_timeout(1000)
def _fill_email(self, page: Any, email: str) -> bool:
for selector in self._email_selectors():
locator = page.locator(selector).first
try:
locator.wait_for(state="visible", timeout=3000)
current_value = (locator.input_value() or "").strip()
if current_value and current_value.lower() == email.lower():
return False
self._set_input_value(locator, email)
return True
except PlaywrightTimeoutError:
continue
except Exception:
continue
return False
def _fill_password(self, page: Any, password: str) -> bool:
deadline = time.time() + 15
while time.time() < deadline:
if self._fill_first(page, self._password_selectors(), password, wait_timeout=3000):
return True
page.wait_for_timeout(500)
try:
page.screenshot(path=str(Path(tempfile.gettempdir()) / "qwen-oauth-password-timeout.png"), full_page=True)
except Exception:
pass
raise RuntimeError("could not fill qwen password input")
def _has_visible_password_input(self, page: Any) -> bool:
for selector in self._password_selectors():
locator = page.locator(selector).first
try:
locator.wait_for(state="visible", timeout=500)
return True
except PlaywrightTimeoutError:
continue
except Exception:
continue
return False
def _fill_first(self, page: Any, selectors: list[str], value: str, *, wait_timeout: int = 2000) -> bool:
for selector in selectors:
locator = page.locator(selector).first
try:
locator.wait_for(state="visible", timeout=wait_timeout)
self._set_input_value(locator, value)
return True
except PlaywrightTimeoutError:
continue
except Exception:
continue
return False
def _set_input_value(self, locator: Any, value: str) -> None:
locator.click()
locator.evaluate(
"""(element, nextValue) => {
const prototype = Object.getPrototypeOf(element);
const descriptor = Object.getOwnPropertyDescriptor(prototype, 'value');
if (descriptor && descriptor.set) {
descriptor.set.call(element, nextValue);
} else {
element.value = nextValue;
}
element.dispatchEvent(new Event('input', { bubbles: true }));
element.dispatchEvent(new Event('change', { bubbles: true }));
}""",
value,
)
actual_value = locator.input_value()
if actual_value == value:
return
locator.fill(value)
actual_value = locator.input_value()
if actual_value == value:
return
locator.press("Meta+A")
locator.type(value, delay=30)
actual_value = locator.input_value()
if actual_value != value:
raise RuntimeError("failed to set input value")
def _click_submit(self, page: Any, names: list[str], *, required: bool = True) -> bool:
for selector in ["button[type='submit']", "input[type='submit']"]:
locator = page.locator(selector)
count = locator.count()
for index in range(count):
candidate = locator.nth(index)
try:
candidate.wait_for(state="visible", timeout=1000)
label = (candidate.inner_text() or "").strip()
if self._is_forbidden_button_text(label):
continue
candidate.click()
return True
except Exception:
continue
for role in ["button", "link"]:
for name in names:
locator = page.get_by_role(role, name=re.compile(name, re.IGNORECASE))
count = locator.count()
for index in range(count):
candidate = locator.nth(index)
try:
candidate.wait_for(state="visible", timeout=1000)
label = (candidate.inner_text() or "").strip()
if self._is_forbidden_button_text(label):
continue
candidate.click()
return True
except PlaywrightTimeoutError:
continue
if required:
raise RuntimeError(f"could not find clickable element for: {names}")
return False
@staticmethod
def _is_forbidden_button_text(text: str) -> bool:
lower = text.lower()
forbidden = ["google", "github", "apple", "microsoft", "wechat", "扫码", "qr", "二维码"]
return any(item in lower for item in forbidden)
@staticmethod
def _email_selectors() -> list[str]:
return [
"input[type='email']",
"input[name='email']",
"input[placeholder*='邮箱']",
"input[placeholder*='Email']",
]
@staticmethod
def _password_selectors() -> list[str]:
return [
"input[type='password']",
"input[name='password']",
"input[placeholder*='密码']",
"input[placeholder*='Password']",
]
def provision_qwen_oauth_credentials(
*,
email: str,
password: str,
output_dir: Path,
headed: bool = True,
runner: QwenOAuthLoginRunner | None = None,
browser_automator: QwenOAuthBrowserAutomator | None = None,
work_dir: Path | None = None,
log_fn: Callable[[str], None] | None = None,
) -> dict[str, Any]:
temp_dir: tempfile.TemporaryDirectory[str] | None = None
if runner is None:
if work_dir is None:
temp_dir = tempfile.TemporaryDirectory(prefix="qwen-oauth-")
work_dir = Path(temp_dir.name)
config_path, auth_dir = write_qwen_login_config(work_dir)
runner = QwenOAuthLoginRunner(config_path=config_path, auth_dir=auth_dir)
if browser_automator is None:
browser_automator = QwenOAuthBrowserAutomator(headed=headed)
try:
if log_fn is not None:
log_fn("启动 CLIProxyAPI Qwen OAuth")
runner.start()
authorize_url = runner.wait_for_authorize_url()
if log_fn is not None:
log_fn("获取授权链接成功")
browser_automator.authorize(authorize_url, email, password)
if log_fn is not None:
log_fn("浏览器登录完成,等待 CLI 输入邮箱")
runner.wait_for_identity_prompt()
runner.submit_identity(email)
if log_fn is not None:
log_fn("已提交邮箱 alias,等待凭证文件")
official_file = runner.wait_for_credentials()
official_payload = wait_for_json_file(official_file)
output_dir.mkdir(parents=True, exist_ok=True)
destination = output_dir / f"qwen_oauth_{safe_email_name(email)}.json"
shutil.copy2(official_file, destination)
if log_fn is not None:
log_fn(f"OAuth 凭证已保存: {destination.name}")
return {
"status": "success",
"authorize_url": authorize_url,
"oauth_file": str(destination),
"oauth_payload": official_payload,
}
finally:
runner.close()
if temp_dir is not None:
temp_dir.cleanup()
def wait_for_json_file(path: Path, timeout_seconds: float = 20.0) -> dict[str, Any]:
deadline = time.time() + timeout_seconds
while time.time() < deadline:
if not path.exists():
time.sleep(0.2)
continue
try:
text = path.read_text(encoding="utf-8").strip()
except OSError:
time.sleep(0.2)
continue
if not text:
time.sleep(0.2)
continue
try:
payload = json.loads(text)
except json.JSONDecodeError:
time.sleep(0.2)
continue
if isinstance(payload, dict):
return payload
time.sleep(0.2)
raise TimeoutError(f"oauth credential file did not become valid json: {path}")