mirror of
https://github.com/zc-zhangchen/any-auto-register.git
synced 2026-05-08 00:04:07 +08:00
feat: 支持 Docker 部署与 CPA 自动维护
This commit is contained in:
@@ -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
57
Dockerfile
Normal 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"]
|
||||
83
README.md
83
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 推荐启动方式
|
||||
|
||||
@@ -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",
|
||||
|
||||
91
api/tasks.py
91
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}
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}")
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
|
||||
@@ -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
25
docker-compose.yml
Normal 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"
|
||||
12
frontend/package-lock.json
generated
12
frontend/package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -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 系统',
|
||||
|
||||
2
main.py
2
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}' 启动,"
|
||||
|
||||
75
scripts/install_camoufox.py
Normal file
75
scripts/install_camoufox.py
Normal 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
245
services/cpa_manager.py
Normal 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
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user