feat: 支持 Docker 部署与 CPA 自动维护

This commit is contained in:
zhangchen
2026-03-30 03:44:09 +08:00
parent b659c3a9b0
commit 336451ca44
17 changed files with 679 additions and 47 deletions

View File

@@ -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/

57
Dockerfile Normal file
View File

@@ -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"]

View File

@@ -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 推荐启动方式

View File

@@ -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",

View File

@@ -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}

View File

@@ -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 服务解 TurnstileCamoufox/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

View File

@@ -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}")

View File

@@ -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)

View File

@@ -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()

25
docker-compose.yml Normal file
View File

@@ -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"

View File

@@ -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",

View File

@@ -33,6 +33,10 @@ const SELECT_FIELDS: Record<string, { label: string; value: string }[]> = {
{ 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 系统',

View File

@@ -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}' 启动,"

View File

@@ -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()

245
services/cpa_manager.py Normal file
View File

@@ -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

View File

@@ -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,

View File

@@ -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,