482 lines
18 KiB
Python
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}")
|