From 336451ca44e7bd2f5049efb8be9c53384fd6aba8 Mon Sep 17 00:00:00 2001 From: zhangchen <1987834247@qq.com> Date: Mon, 30 Mar 2026 03:44:09 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=94=AF=E6=8C=81=20Docker=20=E9=83=A8?= =?UTF-8?q?=E7=BD=B2=E4=B8=8E=20CPA=20=E8=87=AA=E5=8A=A8=E7=BB=B4=E6=8A=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .dockerignore | 4 + Dockerfile | 57 +++++++ README.md | 83 ++++++++++ api/config.py | 2 + api/tasks.py | 91 ++++++++--- core/base_captcha.py | 9 +- core/base_platform.py | 7 +- core/db.py | 3 +- core/scheduler.py | 40 ++++- docker-compose.yml | 25 +++ frontend/package-lock.json | 12 -- frontend/src/pages/Settings.tsx | 15 ++ main.py | 2 + scripts/install_camoufox.py | 75 +++++++++ services/cpa_manager.py | 245 +++++++++++++++++++++++++++++ services/solver_manager.py | 33 +++- services/turnstile_solver/start.py | 23 +++ 17 files changed, 679 insertions(+), 47 deletions(-) create mode 100644 Dockerfile create mode 100644 docker-compose.yml create mode 100644 scripts/install_camoufox.py create mode 100644 services/cpa_manager.py diff --git a/.dockerignore b/.dockerignore index 65da272..952f155 100644 --- a/.dockerignore +++ b/.dockerignore @@ -7,6 +7,10 @@ __pycache__/ .git/ data/ static/ +_ext_targets/ +services/external_logs/ +services/turnstile_solver/solver.log +account_manager.db *.egg-info/ dist/ build/ diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..635ee3c --- /dev/null +++ b/Dockerfile @@ -0,0 +1,57 @@ +FROM node:22-bookworm-slim AS frontend-builder + +WORKDIR /build/frontend + +COPY frontend/package.json frontend/package-lock.json ./ +RUN npm ci + +COPY frontend/ ./ +RUN npm run build + + +FROM python:3.12-slim-bookworm + +ARG CAMOUFOX_VERSION=135.0.1 +ARG CAMOUFOX_RELEASE=beta.24 + +ENV PYTHONDONTWRITEBYTECODE=1 \ + PYTHONUNBUFFERED=1 \ + PIP_NO_CACHE_DIR=1 \ + HOST=0.0.0.0 \ + PORT=8000 \ + APP_CONDA_ENV=docker \ + DATABASE_URL=sqlite:////app/data/account_manager.db \ + APP_ENABLE_SOLVER=1 \ + SOLVER_PORT=8889 \ + SOLVER_BIND_HOST=0.0.0.0 \ + LOCAL_SOLVER_URL=http://127.0.0.1:8889 + +WORKDIR /app + +RUN apt-get update \ + && apt-get install -y --no-install-recommends \ + git \ + tini \ + libgtk-3-0 \ + libgdk-pixbuf-2.0-0 \ + libcairo-gobject2 \ + libpangocairo-1.0-0 \ + libxcursor1 \ + && rm -rf /var/lib/apt/lists/* + +COPY requirements.txt ./ +COPY scripts/install_camoufox.py /tmp/install_camoufox.py +RUN pip install -r requirements.txt \ + && python -m playwright install --with-deps chromium \ + && CAMOUFOX_VERSION="$CAMOUFOX_VERSION" CAMOUFOX_RELEASE="$CAMOUFOX_RELEASE" python /tmp/install_camoufox.py + +COPY . ./ +COPY --from=frontend-builder /build/static ./static + +RUN mkdir -p /app/data /app/_ext_targets + +EXPOSE 8000 +VOLUME ["/app/data", "/app/_ext_targets"] + +ENTRYPOINT ["tini", "--"] +CMD ["python", "-u", "main.py"] diff --git a/README.md b/README.md index 007a3ab..8b8770d 100644 --- a/README.md +++ b/README.md @@ -143,6 +143,89 @@ D:\codemodule\ai\any-auto-register\static *** +## Docker 部署 + +仓库根目录已提供: + +- `Dockerfile` +- `docker-compose.yml` + +默认部署内容包括: + +- FastAPI 后端 +- 已构建的前端静态页 +- SQLite 数据库持久化目录 `./data` +- 随后端自动拉起的本地 Turnstile Solver + +### 1. 启动 + +```bash +docker compose up -d --build +``` + +首次构建会额外下载 Python 依赖、Playwright Chromium 和 Camoufox,耗时会明显更长。 + +当前 Dockerfile 已改为通过固定直链安装 Camoufox,避免构建时访问 GitHub Releases API 触发匿名限流。 + +### 2. 访问 + +```text +http://localhost:8000 +``` + +### 3. 停止 + +```bash +docker compose down +``` + +### 4. 查看日志 + +```bash +docker compose logs -f app +``` + +### 5. 数据持久化 + +容器默认使用: + +```text +DATABASE_URL=sqlite:////app/data/account_manager.db +``` + +宿主机会挂载到: + +```text +./data +``` + +### 6. 常用环境变量 + +| 变量名 | 默认值 | 说明 | +| --- | --- | --- | +| `HOST` | `0.0.0.0` | FastAPI 监听地址 | +| `PORT` | `8000` | FastAPI 监听端口 | +| `DATABASE_URL` | `sqlite:////app/data/account_manager.db` | SQLite 数据库地址 | +| `APP_ENABLE_SOLVER` | `1` | 是否自动启动本地 Solver,设为 `0` 可禁用 | +| `SOLVER_PORT` | `8889` | Solver 监听端口 | +| `LOCAL_SOLVER_URL` | `http://127.0.0.1:8889` | 后端访问 Solver 的地址 | + +### 7. Camoufox 构建参数 + +如果后续上游 Camoufox 版本有变,可以在构建时覆盖: + +```bash +CAMOUFOX_VERSION=135.0.1 CAMOUFOX_RELEASE=beta.24 docker compose build app +``` + +### 8. Docker 部署说明 + +- 当前 Docker 镜像主要覆盖主应用和本地 Turnstile Solver。 +- `grok2api`、`CLIProxyAPI`、`Kiro Account Manager` 的自动安装/拉起逻辑仍偏向宿主机环境,尤其依赖 `conda`、Go、Windows 可执行文件时,不建议直接放进当前 Linux 容器里启动。 +- 如果你只需要 Web UI、账号管理、任务调度和本地 Solver,当前 Compose 配置可以直接使用。 + +*** + ## 启动方式 ### Windows 推荐启动方式 diff --git a/api/config.py b/api/config.py index d6dac62..7040fb1 100644 --- a/api/config.py +++ b/api/config.py @@ -15,6 +15,8 @@ CONFIG_KEYS = [ "cfworker_api_url", "cfworker_admin_token", "cfworker_domain", "cfworker_fingerprint", "luckmail_base_url", "luckmail_api_key", "luckmail_email_type", "luckmail_domain", "cpa_api_url", "cpa_api_key", + "cpa_cleanup_enabled", "cpa_cleanup_interval_minutes", "cpa_cleanup_threshold", + "cpa_cleanup_concurrency", "cpa_cleanup_register_delay_seconds", "team_manager_url", "team_manager_key", "cliproxyapi_management_key", "grok2api_url", "grok2api_app_key", "grok2api_pool", "grok2api_quota", diff --git a/api/tasks.py b/api/tasks.py index 62488b9..00ff062 100644 --- a/api/tasks.py +++ b/api/tasks.py @@ -3,6 +3,7 @@ from fastapi.responses import StreamingResponse from pydantic import BaseModel, Field from sqlmodel import Session, select from typing import Optional +from copy import deepcopy from core.db import TaskLog, engine import time, json, asyncio, threading, logging @@ -48,6 +49,75 @@ class TaskLogBatchDeleteRequest(BaseModel): ids: list[int] +def _prepare_register_request(req: RegisterTaskRequest) -> RegisterTaskRequest: + from core.config_store import config_store + + req_data = req.model_dump() + req_data["extra"] = deepcopy(req_data.get("extra") or {}) + prepared = RegisterTaskRequest(**req_data) + + mail_provider = prepared.extra.get("mail_provider") or config_store.get("mail_provider", "") + if mail_provider == "luckmail": + platform = prepared.platform + if platform in ("tavily", "openblocklabs"): + raise HTTPException(400, f"LuckMail 渠道暂时不支持 {platform} 项目注册") + + mapping = { + "trae": "trae", + "cursor": "cursor", + "grok": "grok", + "kiro": "kiro", + "chatgpt": "openai" + } + prepared.extra["luckmail_project_code"] = mapping.get(platform, platform) + + return prepared + + +def _create_task_record(task_id: str, req: RegisterTaskRequest, source: str, meta: dict | None = None): + with _tasks_lock: + _tasks[task_id] = { + "id": task_id, + "status": "pending", + "platform": req.platform, + "source": source, + "meta": meta or {}, + "progress": f"0/{req.count}", + "logs": [], + } + + +def enqueue_register_task( + req: RegisterTaskRequest, + *, + background_tasks: BackgroundTasks | None = None, + source: str = "manual", + meta: dict | None = None, +) -> str: + prepared = _prepare_register_request(req) + task_id = f"task_{int(time.time()*1000)}" + _create_task_record(task_id, prepared, source, meta) + if background_tasks is None: + thread = threading.Thread(target=_run_register, args=(task_id, prepared), daemon=True) + thread.start() + else: + background_tasks.add_task(_run_register, task_id, prepared) + return task_id + + +def has_active_register_task(*, platform: str | None = None, source: str | None = None) -> bool: + with _tasks_lock: + for task in _tasks.values(): + if task.get("status") not in ("pending", "running"): + continue + if platform and task.get("platform") != platform: + continue + if source and task.get("source") != source: + continue + return True + return False + + def _log(task_id: str, msg: str): """向任务追加一条日志""" ts = time.strftime("%H:%M:%S") @@ -220,26 +290,7 @@ def create_register_task( req: RegisterTaskRequest, background_tasks: BackgroundTasks, ): - mail_provider = req.extra.get("mail_provider") - if mail_provider == "luckmail": - platform = req.platform - if platform in ("tavily", "openblocklabs"): - raise HTTPException(400, f"LuckMail 渠道暂时不支持 {platform} 项目注册") - - mapping = { - "trae": "trae", - "cursor": "cursor", - "grok": "grok", - "kiro": "kiro", - "chatgpt": "openai" - } - req.extra["luckmail_project_code"] = mapping.get(platform, platform) - - task_id = f"task_{int(time.time()*1000)}" - with _tasks_lock: - _tasks[task_id] = {"id": task_id, "status": "pending", - "progress": f"0/{req.count}", "logs": []} - background_tasks.add_task(_run_register, task_id, req) + task_id = enqueue_register_task(req, background_tasks=background_tasks) return {"task_id": task_id} diff --git a/core/base_captcha.py b/core/base_captcha.py index 75acb53..144fd14 100644 --- a/core/base_captcha.py +++ b/core/base_captcha.py @@ -1,5 +1,10 @@ """验证码解决器基类""" from abc import ABC, abstractmethod +import os + + +def _default_solver_url() -> str: + return os.getenv("LOCAL_SOLVER_URL") or f"http://127.0.0.1:{os.getenv('SOLVER_PORT', '8889')}" class BaseCaptcha(ABC): @@ -57,8 +62,8 @@ class ManualCaptcha(BaseCaptcha): class LocalSolverCaptcha(BaseCaptcha): """调用本地 api_solver 服务解 Turnstile(Camoufox/patchright)""" - def __init__(self, solver_url: str = "http://localhost:8889"): - self.solver_url = solver_url.rstrip("/") + def __init__(self, solver_url: str | None = None): + self.solver_url = (solver_url or _default_solver_url()).rstrip("/") def solve_turnstile(self, page_url: str, site_key: str) -> str: import requests, time diff --git a/core/base_platform.py b/core/base_platform.py index f48aee2..4243780 100644 --- a/core/base_platform.py +++ b/core/base_platform.py @@ -3,6 +3,7 @@ from abc import ABC, abstractmethod from dataclasses import dataclass, field from typing import Optional from enum import Enum +import os import time @@ -108,6 +109,10 @@ class BasePlatform(ABC): elif t == "manual": return ManualCaptcha() elif t == "local_solver": - url = self.config.extra.get("solver_url", "http://localhost:8889") + url = ( + self.config.extra.get("solver_url") + or os.getenv("LOCAL_SOLVER_URL") + or f"http://127.0.0.1:{os.getenv('SOLVER_PORT', '8889')}" + ) return LocalSolverCaptcha(url) raise ValueError(f"未知验证码解决器: {t}") diff --git a/core/db.py b/core/db.py index 1d0d01d..15191c0 100644 --- a/core/db.py +++ b/core/db.py @@ -1,5 +1,6 @@ """数据库模型 - SQLite via SQLModel""" from datetime import datetime, timezone +import os from typing import Optional from sqlmodel import Field, SQLModel, create_engine, Session, select import json @@ -8,7 +9,7 @@ import json def _utcnow(): return datetime.now(timezone.utc) -DATABASE_URL = "sqlite:///account_manager.db" +DATABASE_URL = os.getenv("DATABASE_URL", "sqlite:///account_manager.db") engine = create_engine(DATABASE_URL) diff --git a/core/scheduler.py b/core/scheduler.py index 6389622..6856a24 100644 --- a/core/scheduler.py +++ b/core/scheduler.py @@ -12,11 +12,17 @@ class Scheduler: def __init__(self): self._running = False self._thread: threading.Thread = None + self._loop_interval_seconds = 60 + self._trial_check_interval_seconds = 3600 + self._last_trial_check_at = 0.0 + self._last_cpa_maintenance_at = 0.0 def start(self): if self._running: return self._running = True + self._last_trial_check_at = 0.0 + self._last_cpa_maintenance_at = 0.0 self._thread = threading.Thread(target=self._loop, daemon=True) self._thread.start() print("[Scheduler] 已启动") @@ -26,12 +32,28 @@ class Scheduler: def _loop(self): while self._running: - try: - self.check_trial_expiry() - except Exception as e: - print(f"[Scheduler] 错误: {e}") - # 每小时检查一次 - time.sleep(3600) + now = time.time() + if now - self._last_trial_check_at >= self._trial_check_interval_seconds: + try: + self.check_trial_expiry() + self._last_trial_check_at = now + except Exception as e: + print(f"[Scheduler] Trial 检查错误: {e}") + + cpa_interval = self._get_cpa_maintenance_interval_seconds() + if cpa_interval and now - self._last_cpa_maintenance_at >= cpa_interval: + try: + self.check_cpa_credentials() + self._last_cpa_maintenance_at = now + except Exception as e: + print(f"[Scheduler] CPA 维护错误: {e}") + + time.sleep(self._loop_interval_seconds) + + def _get_cpa_maintenance_interval_seconds(self) -> int: + from services.cpa_manager import get_cpa_maintenance_interval_seconds + + return get_cpa_maintenance_interval_seconds() def check_trial_expiry(self): """检查 trial 到期账号,更新状态""" @@ -93,5 +115,11 @@ class Scheduler: results["error"] += 1 return results + def check_cpa_credentials(self): + """清理 CPA 中的 error 凭证,并在低于阈值时自动补注册。""" + from services.cpa_manager import maintain_cpa_credentials + + return maintain_cpa_credentials() + scheduler = Scheduler() diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..a90df01 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,25 @@ +services: + app: + build: + context: . + dockerfile: Dockerfile + args: + CAMOUFOX_VERSION: ${CAMOUFOX_VERSION:-135.0.1} + CAMOUFOX_RELEASE: ${CAMOUFOX_RELEASE:-beta.24} + container_name: any-auto-register + restart: unless-stopped + ports: + - "8000:8000" + environment: + HOST: 0.0.0.0 + PORT: 8000 + DATABASE_URL: sqlite:////app/data/account_manager.db + APP_ENABLE_SOLVER: "1" + SOLVER_PORT: 8889 + SOLVER_BIND_HOST: 0.0.0.0 + LOCAL_SOLVER_URL: http://127.0.0.1:8889 + APP_CONDA_ENV: docker + volumes: + - ./data:/app/data + - ./_ext_targets:/app/_ext_targets + shm_size: "1gb" diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 36ce2fc..3ef30de 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -2322,18 +2322,6 @@ "dev": true, "license": "ISC" }, - "node_modules/jiti": { - "version": "2.6.1", - "resolved": "https://registry.npmmirror.com/jiti/-/jiti-2.6.1.tgz", - "integrity": "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true, - "bin": { - "jiti": "lib/jiti-cli.mjs" - } - }, "node_modules/js-tokens": { "version": "4.0.0", "resolved": "https://registry.npmmirror.com/js-tokens/-/js-tokens-4.0.0.tgz", diff --git a/frontend/src/pages/Settings.tsx b/frontend/src/pages/Settings.tsx index c158c60..2e549a2 100644 --- a/frontend/src/pages/Settings.tsx +++ b/frontend/src/pages/Settings.tsx @@ -33,6 +33,10 @@ const SELECT_FIELDS: Record = { { label: '本地 Solver (Camoufox)', value: 'local_solver' }, { label: '手动', value: 'manual' }, ], + cpa_cleanup_enabled: [ + { label: '关闭', value: '0' }, + { label: '开启', value: '1' }, + ], } const TAB_ITEMS = [ @@ -146,6 +150,17 @@ const TAB_ITEMS = [ { key: 'cpa_api_key', label: 'API Key', secret: true }, ], }, + { + title: 'CPA 自动维护', + desc: '定时删除 status=error 的凭证,剩余数量低于阈值时自动按现有配置补注册 ChatGPT', + fields: [ + { key: 'cpa_cleanup_enabled', label: '自动维护', type: 'select' }, + { key: 'cpa_cleanup_interval_minutes', label: '检查间隔(分钟)', placeholder: '60' }, + { key: 'cpa_cleanup_threshold', label: '最低凭证阈值', placeholder: '5' }, + { key: 'cpa_cleanup_concurrency', label: '补注册并发数', placeholder: '1' }, + { key: 'cpa_cleanup_register_delay_seconds', label: '每个注册延迟(秒)', placeholder: '0' }, + ], + }, { title: 'Team Manager', desc: '上传到自建 Team Manager 系统', diff --git a/main.py b/main.py index 37e629d..2c31e06 100644 --- a/main.py +++ b/main.py @@ -36,6 +36,8 @@ def _print_runtime_info() -> None: current_env = _detect_conda_env() print(f"[Runtime] Python: {sys.executable}") print(f"[Runtime] Conda Env: {current_env or '未检测到'}") + if EXPECTED_CONDA_ENV == "docker": + return if current_env and current_env != EXPECTED_CONDA_ENV: print( f"[WARN] 当前环境为 '{current_env}',推荐使用 '{EXPECTED_CONDA_ENV}' 启动," diff --git a/scripts/install_camoufox.py b/scripts/install_camoufox.py new file mode 100644 index 0000000..a311083 --- /dev/null +++ b/scripts/install_camoufox.py @@ -0,0 +1,75 @@ +import json +import os +import shutil +import tempfile +import urllib.request +import zipfile +from pathlib import Path + +from platformdirs import user_cache_dir + + +def main() -> None: + version = os.environ["CAMOUFOX_VERSION"] + release = os.environ["CAMOUFOX_RELEASE"] + arch_map = { + "x86_64": "x86_64", + "amd64": "x86_64", + "aarch64": "arm64", + "arm64": "arm64", + "i386": "i686", + "i686": "i686", + "x86": "i686", + } + machine = os.uname().machine.lower() + arch = arch_map.get(machine) + if not arch: + raise SystemExit(f"Unsupported Camoufox arch: {machine}") + + tag = f"v{version}-{release}" + asset_name = f"camoufox-{version}-{release}-lin.{arch}.zip" + asset_url = f"https://github.com/daijro/camoufox/releases/download/{tag}/{asset_name}" + addon_url = "https://addons.mozilla.org/firefox/downloads/latest/ublock-origin/latest.xpi" + install_dir = Path(user_cache_dir("camoufox")) + temp_dir = Path(tempfile.mkdtemp(prefix="camoufox-install-")) + + try: + if install_dir.exists(): + shutil.rmtree(install_dir) + install_dir.mkdir(parents=True, exist_ok=True) + + archive_path = temp_dir / asset_name + print(f"Downloading Camoufox package: {asset_url}") + urllib.request.urlretrieve(asset_url, archive_path) + with zipfile.ZipFile(archive_path) as zf: + zf.extractall(install_dir) + + version_path = install_dir / "version.json" + version_path.write_text( + json.dumps({"version": version, "release": release}), + encoding="utf-8", + ) + + addon_dir = install_dir / "addons" / "UBO" + addon_dir.mkdir(parents=True, exist_ok=True) + addon_path = temp_dir / "ublock-origin.xpi" + print(f"Downloading default addon UBO: {addon_url}") + urllib.request.urlretrieve(addon_url, addon_path) + with zipfile.ZipFile(addon_path) as zf: + zf.extractall(addon_dir) + + for path in install_dir.rglob("*"): + if path.is_dir(): + path.chmod(0o755) + else: + path.chmod(0o644) + + binary = install_dir / "camoufox-bin" + if binary.exists(): + binary.chmod(0o755) + finally: + shutil.rmtree(temp_dir, ignore_errors=True) + + +if __name__ == "__main__": + main() diff --git a/services/cpa_manager.py b/services/cpa_manager.py new file mode 100644 index 0000000..43c9321 --- /dev/null +++ b/services/cpa_manager.py @@ -0,0 +1,245 @@ +"""CPA 凭证维护:清理异常凭证并在低于阈值时自动补注册。""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import Any + +import requests + + +DEFAULT_INTERVAL_MINUTES = 60 +DEFAULT_THRESHOLD = 5 +DEFAULT_CONCURRENCY = 1 +DEFAULT_REGISTER_DELAY_SECONDS = 0.0 +AUTO_REGISTER_SOURCE = "cpa_replenish" + + +@dataclass +class CpaMaintenanceConfig: + enabled: bool + interval_minutes: int + threshold: int + concurrency: int + register_delay_seconds: float + + +def _get_config_store(): + from core.config_store import config_store + + return config_store + + +def _to_bool(value: str | None, default: bool = False) -> bool: + raw = str(value or "").strip().lower() + if raw in {"1", "true", "yes", "on"}: + return True + if raw in {"0", "false", "no", "off"}: + return False + return default + + +def _to_int(value: str | None, default: int, minimum: int = 0) -> int: + try: + return max(minimum, int(float(str(value or "").strip()))) + except Exception: + return default + + +def _to_float(value: str | None, default: float, minimum: float = 0.0) -> float: + try: + return max(minimum, float(str(value or "").strip())) + except Exception: + return default + + +def get_cpa_maintenance_config() -> CpaMaintenanceConfig: + config_store = _get_config_store() + return CpaMaintenanceConfig( + enabled=_to_bool(config_store.get("cpa_cleanup_enabled", ""), default=False), + interval_minutes=_to_int( + config_store.get("cpa_cleanup_interval_minutes", ""), + DEFAULT_INTERVAL_MINUTES, + minimum=1, + ), + threshold=_to_int( + config_store.get("cpa_cleanup_threshold", ""), + DEFAULT_THRESHOLD, + minimum=1, + ), + concurrency=_to_int( + config_store.get("cpa_cleanup_concurrency", ""), + DEFAULT_CONCURRENCY, + minimum=1, + ), + register_delay_seconds=_to_float( + config_store.get("cpa_cleanup_register_delay_seconds", ""), + DEFAULT_REGISTER_DELAY_SECONDS, + minimum=0.0, + ), + ) + + +def get_cpa_maintenance_interval_seconds() -> int: + config_store = _get_config_store() + api_url = str(config_store.get("cpa_api_url", "") or "").strip() + config = get_cpa_maintenance_config() + if not config.enabled or not api_url: + return 0 + return config.interval_minutes * 60 + + +def _api_base(api_url: str | None = None) -> str: + config_store = _get_config_store() + base_url = str(api_url or config_store.get("cpa_api_url", "") or "").strip() + if not base_url: + raise RuntimeError("CPA API URL 未配置") + return base_url.rstrip("/") + + +def _headers(api_key: str | None = None) -> dict[str, str]: + config_store = _get_config_store() + token = str(api_key or config_store.get("cpa_api_key", "") or "").strip() + headers = {"Accept": "application/json"} + if token: + headers["Authorization"] = f"Bearer {token}" + return headers + + +def _request(method: str, path: str, *, api_url: str | None = None, api_key: str | None = None, json_body: dict | None = None) -> Any: + response = requests.request( + method, + f"{_api_base(api_url)}{path}", + headers=_headers(api_key), + json=json_body, + timeout=30, + verify=False, + ) + response.raise_for_status() + if not response.content: + return {} + try: + return response.json() + except ValueError: + return response.text + + +def list_auth_files(*, api_url: str | None = None, api_key: str | None = None) -> list[dict[str, Any]]: + data = _request("GET", "/v0/management/auth-files", api_url=api_url, api_key=api_key) + files = data.get("files", []) if isinstance(data, dict) else [] + return [item for item in files if isinstance(item, dict)] + + +def delete_auth_files(names: list[str], *, api_url: str | None = None, api_key: str | None = None) -> Any: + clean_names = [name for name in names if str(name).strip()] + if not clean_names: + return {"deleted": 0} + return _request( + "DELETE", + "/v0/management/auth-files", + api_url=api_url, + api_key=api_key, + json_body={"names": clean_names}, + ) + + +def _count_remaining(files: list[dict[str, Any]]) -> int: + return sum( + 1 + for item in files + if str(item.get("name", "")).strip() and str(item.get("status", "")).strip().lower() != "error" + ) + + +def _error_names(files: list[dict[str, Any]]) -> list[str]: + return sorted( + { + str(item.get("name", "")).strip() + for item in files + if str(item.get("status", "")).strip().lower() == "error" and str(item.get("name", "")).strip() + } + ) + + +def _normalize_executor(executor: str | None) -> str: + value = str(executor or "").strip() + if value in {"protocol", "headless", "headed"}: + return value + return "protocol" + + +def _normalize_solver(solver: str | None) -> str: + value = str(solver or "").strip() + if value in {"yescaptcha", "local_solver", "manual"}: + return value + return "yescaptcha" + + +def _trigger_register(missing_count: int, *, config: CpaMaintenanceConfig, remaining_count: int) -> dict[str, Any]: + from api.tasks import RegisterTaskRequest, enqueue_register_task, has_active_register_task + + if has_active_register_task(platform="chatgpt", source=AUTO_REGISTER_SOURCE): + print("[CPA] 已存在进行中的自动补注册任务,跳过本轮补注册") + return {"triggered": False, "reason": "task_running"} + + config_store = _get_config_store() + req = RegisterTaskRequest( + platform="chatgpt", + count=missing_count, + concurrency=config.concurrency, + register_delay_seconds=config.register_delay_seconds, + executor_type=_normalize_executor(config_store.get("default_executor", "protocol")), + captcha_solver=_normalize_solver(config_store.get("default_captcha_solver", "yescaptcha")), + extra={}, + ) + task_id = enqueue_register_task( + req, + source=AUTO_REGISTER_SOURCE, + meta={ + "remaining": remaining_count, + "threshold": config.threshold, + "missing": missing_count, + }, + ) + print( + f"[CPA] 剩余凭证 {remaining_count} 低于阈值 {config.threshold}," + f"已创建自动注册任务 {task_id},补充 {missing_count} 个" + ) + return {"triggered": True, "task_id": task_id} + + +def maintain_cpa_credentials() -> dict[str, Any]: + config = get_cpa_maintenance_config() + if not config.enabled: + return {"ok": False, "reason": "disabled"} + + files = list_auth_files() + error_names = _error_names(files) + deleted_count = 0 + + if error_names: + delete_auth_files(error_names) + deleted_count = len(error_names) + print(f"[CPA] 已删除 {deleted_count} 个 status=error 的凭证") + files = list_auth_files() + + remaining_count = _count_remaining(files) + result: dict[str, Any] = { + "ok": True, + "deleted": deleted_count, + "remaining": remaining_count, + "threshold": config.threshold, + } + + if remaining_count >= config.threshold: + print(f"[CPA] 剩余凭证 {remaining_count},阈值 {config.threshold},无需补注册") + result["register"] = {"triggered": False, "reason": "enough_credentials"} + return result + + missing_count = config.threshold - remaining_count + result["register"] = _trigger_register( + missing_count, + config=config, + remaining_count=remaining_count, + ) + return result diff --git a/services/solver_manager.py b/services/solver_manager.py index 4153d39..d858849 100644 --- a/services/solver_manager.py +++ b/services/solver_manager.py @@ -6,16 +6,34 @@ import time import threading import requests -SOLVER_PORT = 8889 -SOLVER_URL = f"http://localhost:{SOLVER_PORT}" _proc: subprocess.Popen = None _log_file = None _lock = threading.Lock() +def _solver_enabled() -> bool: + return os.getenv("APP_ENABLE_SOLVER", "1").lower() not in {"0", "false", "no"} + + +def _solver_port() -> int: + return int(os.getenv("SOLVER_PORT", "8889")) + + +def _solver_url() -> str: + return (os.getenv("LOCAL_SOLVER_URL") or f"http://127.0.0.1:{_solver_port()}").rstrip("/") + + +def _solver_bind_host() -> str: + return os.getenv("SOLVER_BIND_HOST", "0.0.0.0") + + +def _solver_browser_type() -> str: + return os.getenv("SOLVER_BROWSER_TYPE", "camoufox") + + def is_running() -> bool: try: - r = requests.get(f"{SOLVER_URL}/", timeout=2) + r = requests.get(f"{_solver_url()}/", timeout=2) return r.status_code < 500 except Exception: return False @@ -24,6 +42,9 @@ def is_running() -> bool: def start(): global _proc, _log_file with _lock: + if not _solver_enabled(): + print("[Solver] 已禁用,跳过自动启动") + return if is_running(): print("[Solver] 已在运行") return @@ -40,9 +61,11 @@ def start(): "-u", solver_script, "--browser_type", - "camoufox", + _solver_browser_type(), + "--host", + _solver_bind_host(), "--port", - str(SOLVER_PORT), + str(_solver_port()), ], stdout=_log_file, stderr=subprocess.STDOUT, diff --git a/services/turnstile_solver/start.py b/services/turnstile_solver/start.py index 16600d9..c1febc9 100644 --- a/services/turnstile_solver/start.py +++ b/services/turnstile_solver/start.py @@ -1,13 +1,36 @@ """启动本地 Turnstile Solver 服务""" import sys import os +from pathlib import Path sys.path.insert(0, os.path.dirname(__file__)) from api_solver import create_app, parse_args import asyncio + +def _prepend_env_path(name: str, value: str) -> None: + current = os.getenv(name, "") + parts = [p for p in current.split(":") if p] + if value in parts: + return + os.environ[name] = ":".join([value, *parts]) if parts else value + + +def _prepare_camoufox_env(browser_type: str) -> None: + if browser_type != "camoufox" or os.name == "nt": + return + try: + from platformdirs import user_cache_dir + except Exception: + return + + camoufox_dir = Path(user_cache_dir("camoufox")) + if camoufox_dir.is_dir(): + _prepend_env_path("LD_LIBRARY_PATH", str(camoufox_dir)) + if __name__ == "__main__": args = parse_args() + _prepare_camoufox_env(args.browser_type) app = create_app( headless=not args.no_headless, useragent=args.useragent,