From 8106f503baf18eae8ee36886f8d1153a843febc1 Mon Sep 17 00:00:00 2001 From: qinzhaoxuan Date: Fri, 13 Mar 2026 20:55:19 +0800 Subject: [PATCH] Initialize project and mask sensitive information --- .env.example | 4 + .gitignore | 5 + README.md | 53 ++++ cloudflare_temp_email_client.py | 149 +++++++++ qwen_oauth_login.py | 481 ++++++++++++++++++++++++++++ qwen_register.py | 544 ++++++++++++++++++++++++++++++++ router_management_client.py | 39 +++ 7 files changed, 1275 insertions(+) create mode 100644 .env.example create mode 100644 .gitignore create mode 100644 README.md create mode 100644 cloudflare_temp_email_client.py create mode 100644 qwen_oauth_login.py create mode 100644 qwen_register.py create mode 100644 router_management_client.py diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..e5698f3 --- /dev/null +++ b/.env.example @@ -0,0 +1,4 @@ +CLOUDFLARE_TEMP_EMAIL_BASE_URL="https://example.com/" +ADMIN_PASSWORDS='["***","***"]' +CLI_PROXY_API_BASE_URL= +CLI_PROXY_API_KEY= \ No newline at end of file diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..01716ab --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +.env +venv/ +tokens/ +__pycache__/ +*.pyc diff --git a/README.md b/README.md new file mode 100644 index 0000000..bd6d0e5 --- /dev/null +++ b/README.md @@ -0,0 +1,53 @@ + + +> **⚠️ 声明:本仓库代码仅供学习交流使用,严禁用于任何商业用途!如果您觉得有帮助,欢迎点个 Star ⭐️ 支持一下!** + +## 环境准备 +1. 创建并激活虚拟环境(Python 3.9+): +```bash +cd qwen-register +python3 -m venv venv +source venv/bin/activate +``` + +2. 安装依赖: +```bash +pip install requests +``` + +3. 配置环境变量: +```bash +CLOUDFLARE_TEMP_EMAIL_BASE_URL=https://example.com/ +ADMIN_PASSWORDS='["***","***"]' +CLI_PROXY_API_BASE_URL=http://example.com:8317 +CLI_PROXY_API_KEY=sk-*** +``` + +## 运行 +```bash +cd qwen-register +source venv/bin/activate +python qwen_register.py +``` + +默认会批量注册 `5` 个账号。可以通过 `--count` 或环境变量 `QWEN_REGISTER_COUNT` 覆盖: + +```bash +python qwen_register.py --count 1 +python qwen_register.py --count 10 +``` + +批量模式下: +- 单条失败会记录到结果里,并继续后续注册 +- 每两条注册之间会随机等待 `10-30` 秒 +- 最终会输出 `success_count` 和 `failed_count` 统计 +- 运行中的阶段日志输出到 `stderr`,最终 JSON 结果输出到 `stdout` + +如果要在注册成功后自动注册、激活、生成官方 Qwen OAuth 凭证并上传到 CLIProxyAPI: + +```bash +python qwen_register.py --count 5 \ + --cli-proxy-api-base-url http://example.com:8317 \ + --cli-proxy-api-key sk-*** \ + --oauth-headed +``` diff --git a/cloudflare_temp_email_client.py b/cloudflare_temp_email_client.py new file mode 100644 index 0000000..5a9f6f9 --- /dev/null +++ b/cloudflare_temp_email_client.py @@ -0,0 +1,149 @@ +from __future__ import annotations + +from email import message_from_string +import re +import time +from typing import Any + +import requests + + +class CloudflareTempEmailError(RuntimeError): + pass + + +class CloudflareTempEmailClient: + def __init__( + self, + base_url: str, + admin_passwords: list[str], + *, + session: requests.Session | None = None, + timeout: float = 20.0, + ) -> None: + if not base_url: + raise ValueError("base_url is required") + if not admin_passwords: + raise ValueError("admin_passwords is required") + + self.base_url = base_url.rstrip("/") + self.admin_passwords = [item for item in admin_passwords if item] + self.timeout = timeout + self.session = session or requests.Session() + + def create_address(self, *, name: str, domain: str = "", enable_prefix: bool = True) -> dict[str, Any]: + payload = { + "enablePrefix": enable_prefix, + "name": name, + "domain": domain, + } + errors: list[str] = [] + for password in self.admin_passwords: + try: + response = self.session.post( + f"{self.base_url}/admin/new_address", + headers={"x-admin-auth": password, "Content-Type": "application/json"}, + json=payload, + timeout=self.timeout, + ) + response.raise_for_status() + data = response.json() + if not data.get("address") or not data.get("jwt"): + raise CloudflareTempEmailError(f"malformed create address response: {data}") + return data + except Exception as exc: # pragma: no cover - exercised via fallback test + errors.append(str(exc)) + continue + raise CloudflareTempEmailError("; ".join(errors) or "create address failed") + + def list_mails(self, jwt: str, *, limit: int = 20, offset: int = 0) -> list[dict[str, Any]]: + response = self.session.get( + f"{self.base_url}/api/mails", + headers={"Authorization": f"Bearer {jwt}"}, + params={"limit": limit, "offset": offset}, + timeout=self.timeout, + ) + response.raise_for_status() + data = response.json() + if isinstance(data, list): + return [item for item in data if isinstance(item, dict)] + if isinstance(data, dict): + items = data.get("results") or data.get("mails") or data.get("items") or [] + return [item for item in items if isinstance(item, dict)] + return [] + + def get_mail(self, jwt: str, mail_id: str) -> dict[str, Any]: + response = self.session.get( + f"{self.base_url}/api/mails/{mail_id}", + headers={"Authorization": f"Bearer {jwt}"}, + timeout=self.timeout, + ) + response.raise_for_status() + data = response.json() + if not isinstance(data, dict): + raise CloudflareTempEmailError("malformed mail detail response") + return data + + def wait_for_verification_link( + self, + jwt: str, + *, + timeout_seconds: float = 120.0, + poll_interval_seconds: float = 5.0, + ) -> dict[str, str]: + deadline = time.time() + timeout_seconds + while time.time() <= deadline: + for item in self.list_mails(jwt): + mail_id = str(item.get("id") or "").strip() + link = self.extract_verification_link(item) + if link: + return {"mail_id": mail_id, "link": link} + if not mail_id: + continue + mail = self.get_mail(jwt, mail_id) + link = self.extract_verification_link(mail) + if link: + return {"mail_id": mail_id, "link": link} + time.sleep(max(poll_interval_seconds, 0.0)) + raise TimeoutError("activation mail timeout") + + @staticmethod + def extract_verification_link(payload: dict[str, Any]) -> str | None: + parts = [str(v) for v in payload.values() if v is not None] + raw = str(payload.get("raw") or "") + if raw: + try: + message = message_from_string(raw) + for part in message.walk(): + candidate = CloudflareTempEmailClient._decode_mail_part(part) + if candidate: + parts.append(candidate) + except Exception: + parts.append(raw) + blob = " ".join(parts) + patterns = [ + r'https://chat\.qwen\.ai[^\s"\'<>]+', + r'https://[^"\']+activation[^"\']+', + ] + for pattern in patterns: + match = re.search(pattern, blob, flags=re.IGNORECASE) + if match: + return match.group(0).replace("&", "&").rstrip('"\'>#') + return None + + @staticmethod + def _decode_mail_part(part: Any) -> str: + payload = part.get_payload(decode=True) + if payload is None: + return "" + charset = part.get_content_charset() or "utf-8" + try: + return payload.decode(charset, errors="ignore") + except LookupError: + return payload.decode("utf-8", errors="ignore") + + @staticmethod + def extract_code(payload: dict[str, Any]) -> str | None: + blob = " ".join(str(v) for v in payload.values() if v is not None) + match = re.search(r"(?[^\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}") diff --git a/qwen_register.py b/qwen_register.py new file mode 100644 index 0000000..b405607 --- /dev/null +++ b/qwen_register.py @@ -0,0 +1,544 @@ +from __future__ import annotations + +import argparse +import hashlib +import json +import os +import random +import shutil +import sys +import tempfile +import time +from pathlib import Path +from typing import Any + +import requests + +from cloudflare_temp_email_client import CloudflareTempEmailClient +from qwen_oauth_login import provision_qwen_oauth_credentials +from router_management_client import RouterManagementClient + +OUT_DIR = Path(__file__).parent.resolve() +TOKENS_DIR = OUT_DIR / "tokens" + +COMMON_US_FIRST_NAMES = [ + "James", + "John", + "Robert", + "Michael", + "William", + "David", + "Richard", + "Joseph", + "Thomas", + "Charles", + "Mary", + "Patricia", + "Jennifer", + "Linda", + "Elizabeth", + "Barbara", + "Susan", + "Jessica", + "Sarah", + "Karen", +] + +COMMON_US_MIDDLE_NAMES = [ + "alex", + "jordan", + "taylor", + "morgan", + "casey", + "quinn", + "blake", + "reese", +] + +COMMON_US_LAST_NAMES = [ + "Smith", + "Johnson", + "Williams", + "Brown", + "Jones", + "Garcia", + "Miller", + "Davis", + "Rodriguez", + "Martinez", + "Wilson", + "Anderson", + "Thomas", + "Taylor", + "Moore", + "Jackson", + "Martin", + "Lee", + "Perez", + "Thompson", +] + + +def log_message( + message: str, + *, + level: str = "INFO", + item_index: int | None = None, + total_count: int | None = None, +) -> None: + timestamp = time.strftime("%Y-%m-%d %H:%M:%S") + scope = ( + f" [{item_index}/{total_count}]" + if item_index is not None and total_count is not None + else "" + ) + print(f"{timestamp} [{level}]{scope} {message}", file=sys.stderr) + + +def load_dotenv_file(path: Path) -> None: + if not path.exists(): + return + for raw_line in path.read_text(encoding="utf-8").splitlines(): + line = raw_line.strip() + if not line or line.startswith("#") or "=" not in line: + continue + key, value = line.split("=", 1) + key = key.strip() + value = value.strip() + if not key or key in os.environ: + continue + if len(value) >= 2 and value[0] == value[-1] and value[0] in {"'", '"'}: + value = value[1:-1] + os.environ[key] = value + + +def sha256_hex(value: str) -> str: + return hashlib.sha256(value.encode("utf-8")).hexdigest() + + +def build_signup_payload( + *, name: str, email: str, password: str, module: str = "chat" +) -> dict[str, Any]: + return { + "name": name, + "email": email, + "password": sha256_hex(password), + "agree": True, + "profile_image_url": "", + "oauth_sub": "", + "oauth_token": "", + "module": module, + } + + +def build_signin_payload(*, email: str, password: str) -> dict[str, Any]: + return { + "email": email, + "password": sha256_hex(password), + } + + +def extract_token_payload(data: dict[str, Any]) -> dict[str, Any]: + return { + "access_token": (data.get("token") or "").strip(), + "email": (data.get("email") or "").strip(), + "user_id": (data.get("id") or "").strip(), + "role": (data.get("role") or "").strip(), + "token_type": (data.get("token_type") or "Bearer").strip(), + "expired": data.get("expires_at"), + "type": "qwen", + "raw": data, + } + + +def write_token_artifacts( + *, out_dir: Path, email: str, password: str, token_payload: dict[str, Any] +) -> dict[str, str]: + out_dir.mkdir(parents=True, exist_ok=True) + safe_email = email.replace("@", "_at_").replace(".", "_") + timestamp = int(time.time()) + token_file = out_dir / f"token_{safe_email}_{timestamp}.json" + accounts_file = out_dir / "accounts.txt" + token_file.write_text( + json.dumps(token_payload, ensure_ascii=False, indent=2), encoding="utf-8" + ) + with accounts_file.open("a", encoding="utf-8") as handle: + handle.write(f"{email}----{password}\n") + return {"token_file": str(token_file), "accounts_file": str(accounts_file)} + + +def write_oauth_artifact(*, out_dir: Path, email: str, source_file: Path) -> str: + out_dir.mkdir(parents=True, exist_ok=True) + destination = ( + out_dir / f"qwen_oauth_{email.replace('@', '_at_').replace('.', '_')}.json" + ) + shutil.copy2(source_file, destination) + return str(destination) + + +class QwenClient: + def __init__( + self, + *, + base_url: str = "https://chat.qwen.ai", + session: requests.Session | None = None, + timeout: float = 20.0, + ) -> None: + self.base_url = base_url.rstrip("/") + self.session = session or requests.Session() + self.timeout = timeout + self.session.headers.update( + { + "User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Safari/537.36", + "Accept": "application/json", + } + ) + + def signup(self, payload: dict[str, Any]) -> dict[str, Any]: + response = self.session.post( + f"{self.base_url}/api/v1/auths/signup", + json=payload, + timeout=self.timeout, + ) + response.raise_for_status() + return response.json() + + def signin(self, payload: dict[str, Any]) -> dict[str, Any]: + response = self.session.post( + f"{self.base_url}/api/v2/auths/signin", + json=payload, + timeout=self.timeout, + ) + response.raise_for_status() + return response.json() + + def activate(self, url: str) -> dict[str, Any]: + response = self.session.get( + url, + timeout=self.timeout, + allow_redirects=True, + ) + response.raise_for_status() + try: + data = response.json() + if isinstance(data, dict): + return data + except ValueError: + pass + return {"status": "ok", "url": url} + + +def generate_profile() -> dict[str, str]: + first = random.choice(COMMON_US_FIRST_NAMES) + last = random.choice(COMMON_US_LAST_NAMES) + middle = random.choice(COMMON_US_MIDDLE_NAMES) + return { + "name": f"{first} {last}", + "email_local": f"{first.lower()}.{middle}.{last.lower()}", + } + + +def random_password(length: int = 14) -> str: + import string + + chars = string.ascii_letters + string.digits + "!@#$%^&*" + password = "".join(random.choice(chars) for _ in range(length)) + if not any(ch.isalpha() for ch in password): + password = "Aa1!" + password[4:] + if not any(ch.isdigit() for ch in password): + password = "A1a!" + password[4:] + return password + + +def parse_admin_passwords(value: str) -> list[str]: + raw = (value or "").strip() + if not raw: + return [] + if raw.startswith("["): + try: + parsed = json.loads(raw) + except json.JSONDecodeError: + parsed = [] + return [str(item).strip() for item in parsed if str(item).strip()] + return [item.strip() for item in raw.split(",") if item.strip()] + + +def register_once( + *, + email_client: CloudflareTempEmailClient, + qwen_client: QwenClient, + out_dir: Path = TOKENS_DIR, + upload_client: RouterManagementClient | None = None, + oauth_provisioner: Any = provision_qwen_oauth_credentials, + oauth_headed: bool = True, + log_fn: Any = log_message, + item_index: int | None = None, + total_count: int | None = None, +) -> dict[str, Any]: + profile = generate_profile() + log_fn("初始化注册任务", item_index=item_index, total_count=total_count) + mailbox = email_client.create_address(name=profile["email_local"]) + email = mailbox["address"] + password = random_password() + name = profile["name"] + log_fn(f"创建临时邮箱: {email}", item_index=item_index, total_count=total_count) + + log_fn("提交 Qwen 注册", item_index=item_index, total_count=total_count) + signup_data = qwen_client.signup( + build_signup_payload(name=name, email=email, password=password) + ) + token_payload = extract_token_payload(signup_data) + activation_result: dict[str, Any] + try: + log_fn("等待激活邮件", item_index=item_index, total_count=total_count) + verification = email_client.wait_for_verification_link( + mailbox["jwt"], + timeout_seconds=120.0, + poll_interval_seconds=5.0, + ) + activate_response = qwen_client.activate(verification["link"]) + signin_data = qwen_client.signin( + build_signin_payload(email=email, password=password) + ) + refreshed_token_payload = extract_token_payload( + signin_data.get("data") or signin_data + ) + if refreshed_token_payload["access_token"]: + token_payload = refreshed_token_payload + log_fn("激活成功", item_index=item_index, total_count=total_count) + activation_result = { + "status": "success", + "mail_id": verification["mail_id"], + "link": verification["link"], + "response": activate_response, + } + except TimeoutError as exc: + log_fn( + f"激活超时: {exc}", + level="WARN", + item_index=item_index, + total_count=total_count, + ) + activation_result = { + "status": "timeout", + "error": str(exc), + } + except Exception as exc: + log_fn( + f"激活失败: {exc}", + level="ERROR", + item_index=item_index, + total_count=total_count, + ) + activation_result = { + "status": "failed", + "error": str(exc), + } + if not token_payload["access_token"]: + log_fn("重新登录获取 token", item_index=item_index, total_count=total_count) + signin_data = qwen_client.signin( + build_signin_payload(email=email, password=password) + ) + token_payload = extract_token_payload(signin_data.get("data") or signin_data) + if not token_payload["access_token"]: + raise RuntimeError(f"Qwen did not return a token payload: {signup_data}") + + log_fn("保存本地认证文件", item_index=item_index, total_count=total_count) + artifact_paths = write_token_artifacts( + out_dir=out_dir, + email=email, + password=password, + token_payload=token_payload, + ) + oauth_result: dict[str, Any] + upload_source = Path(artifact_paths["token_file"]) + try: + log_fn("启动 Qwen OAuth", item_index=item_index, total_count=total_count) + oauth_data = oauth_provisioner( + email=email, + password=password, + output_dir=out_dir, + headed=oauth_headed, + log_fn=lambda message, level="INFO": log_fn( + message, level=level, item_index=item_index, total_count=total_count + ), + ) + oauth_result = oauth_data + if oauth_data.get("status") == "success" and oauth_data.get("oauth_file"): + upload_source = Path(str(oauth_data["oauth_file"])) + log_fn("Qwen OAuth 成功", item_index=item_index, total_count=total_count) + except Exception as exc: + oauth_result = { + "status": "failed", + "error": str(exc), + } + log_fn( + f"Qwen OAuth 失败: {exc}", + level="ERROR", + item_index=item_index, + total_count=total_count, + ) + print(f"[qwen-register] qwen oauth automation failed: {exc}", file=sys.stderr) + upload_result: dict[str, Any] = {"status": "skipped"} + if upload_client is not None: + try: + log_fn( + f"上传认证文件: {upload_source.name}", + item_index=item_index, + total_count=total_count, + ) + upload_response = upload_client.upload_auth_file(upload_source) + upload_result = { + "status": "success", + "response": upload_response, + "source_file": str(upload_source), + } + log_fn("上传成功", item_index=item_index, total_count=total_count) + except Exception as exc: + upload_result = { + "status": "failed", + "error": str(exc), + "source_file": str(upload_source), + } + log_fn( + f"上传失败: {exc}", + level="ERROR", + item_index=item_index, + total_count=total_count, + ) + print(f"[qwen-register] auth upload failed: {exc}", file=sys.stderr) + log_fn("完成", item_index=item_index, total_count=total_count) + return { + "email": email, + "password": password, + "activation": activation_result, + "oauth": oauth_result, + "token_payload": token_payload, + "upload": upload_result, + **artifact_paths, + } + + +def run_registration_batch( + *, + count: int, + register_fn: Any, + log_fn: Any = log_message, +) -> dict[str, Any]: + if count <= 0: + raise ValueError("count must be greater than 0") + results: list[dict[str, Any]] = [] + log_fn(f"开始批量注册,总数: {count}") + for index in range(count): + try: + log_fn("开始当前注册", item_index=index + 1, total_count=count) + results.append(register_fn(index + 1, count)) + except Exception as exc: + failure = { + "status": "failed", + "error": str(exc), + } + results.append(failure) + log_fn( + f"当前注册失败: {exc}", + level="ERROR", + item_index=index + 1, + total_count=count, + ) + print( + f"[qwen-register] batch item {index + 1}/{count} failed: {exc}", + file=sys.stderr, + ) + if index < count - 1: + delay_seconds = random.randint(10, 30) + log_fn( + f"等待下一次注册: {delay_seconds}s", + item_index=index + 1, + total_count=count, + ) + time.sleep(delay_seconds) + failed_count = sum(1 for item in results if item.get("status") == "failed") + summary = { + "count": count, + "success_count": count - failed_count, + "failed_count": failed_count, + "results": results, + } + log_fn( + f"批量注册完成,成功: {summary['success_count']},失败: {summary['failed_count']}" + ) + return summary + + +def build_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser(description="Qwen register via HTTP") + parser.add_argument("--once", action="store_true", help="run once and exit") + parser.add_argument( + "--mail-base-url", + default=os.environ.get( + "CLOUDFLARE_TEMP_EMAIL_BASE_URL", "https://example.com/" + ), + ) + parser.add_argument( + "--admin-passwords", default=os.environ.get("ADMIN_PASSWORDS", "") + ) + parser.add_argument( + "--cli-proxy-api-base-url", default=os.environ.get("CLI_PROXY_API_BASE_URL", "") + ) + parser.add_argument( + "--cli-proxy-api-key", default=os.environ.get("CLI_PROXY_API_KEY", "") + ) + parser.add_argument( + "--oauth-headed", + action="store_true", + default=os.environ.get("QWEN_OAUTH_HEADED", "1") != "0", + ) + parser.add_argument( + "--count", + type=int, + default=int(os.environ.get("QWEN_REGISTER_COUNT", "5") or "5"), + ) + return parser + + +def main() -> int: + load_dotenv_file(OUT_DIR / ".env") + args = build_parser().parse_args() + admin_passwords = parse_admin_passwords(args.admin_passwords) + if not admin_passwords: + raise SystemExit("ADMIN_PASSWORDS is required") + + email_client = CloudflareTempEmailClient( + base_url=args.mail_base_url, + admin_passwords=admin_passwords, + ) + qwen_client = QwenClient() + upload_client = None + if args.cli_proxy_api_base_url and args.cli_proxy_api_key: + upload_client = RouterManagementClient( + base_url=args.cli_proxy_api_base_url, + api_key=args.cli_proxy_api_key, + ) + batch_output = run_registration_batch( + count=args.count, + register_fn=lambda item_index, total_count: register_once( + email_client=email_client, + qwen_client=qwen_client, + upload_client=upload_client, + oauth_headed=args.oauth_headed, + item_index=item_index, + total_count=total_count, + ), + log_fn=log_message, + ) + output: dict[str, Any] | list[dict[str, Any]] + if args.count == 1: + output = batch_output["results"][0] + else: + output = batch_output + # print(json.dumps(output, ensure_ascii=False, indent=2)) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/router_management_client.py b/router_management_client.py new file mode 100644 index 0000000..c3c2a99 --- /dev/null +++ b/router_management_client.py @@ -0,0 +1,39 @@ +from __future__ import annotations + +from pathlib import Path +from typing import Any + +import requests + + +class RouterManagementClient: + def __init__( + self, + *, + base_url: str, + api_key: str, + session: requests.Session | None = None, + timeout: float = 20.0, + ) -> None: + if not base_url: + raise ValueError("base_url is required") + if not api_key: + raise ValueError("api_key is required") + self.base_url = base_url.rstrip("/") + self.api_key = api_key + self.session = session or requests.Session() + self.timeout = timeout + + def upload_auth_file(self, file_path: Path) -> dict[str, Any]: + with file_path.open("rb") as handle: + response = self.session.post( + f"{self.base_url}/v0/management/auth-files", + headers={"Authorization": f"Bearer {self.api_key}"}, + files={"file": (file_path.name, handle, "application/json")}, + timeout=self.timeout, + ) + response.raise_for_status() + data = response.json() + if not isinstance(data, dict): + raise RuntimeError("malformed upload response") + return data