mirror of
https://github.com/TheSmallHanCat/flow2api.git
synced 2026-05-08 06:49:25 +08:00
feat: 新增验证码分数测试并优化浏览器打码流程
- 后台新增 /api/captcha/score-test,支持按当前打码配置直接测分并返回详细结果 - 管理后台新增当前打码分数测试入口,展示 score、耗时、action、hostname 等信息 - 优化 browser 与 personal 模式的 reCAPTCHA 执行、页面测分、代理兼容与 UA 策略
This commit is contained in:
469
src/api/admin.py
469
src/api/admin.py
@@ -1,10 +1,12 @@
|
||||
"""Admin API routes"""
|
||||
import asyncio
|
||||
from fastapi import APIRouter, Depends, HTTPException, Header, Request
|
||||
from fastapi.responses import JSONResponse
|
||||
from pydantic import BaseModel
|
||||
from typing import Optional, List
|
||||
from typing import Optional, List, Dict, Any
|
||||
import secrets
|
||||
import time
|
||||
import re
|
||||
from curl_cffi.requests import AsyncSession
|
||||
from ..core.auth import AuthManager
|
||||
from ..core.database import Database
|
||||
@@ -21,6 +23,187 @@ db: Database = None
|
||||
|
||||
# Store active admin session tokens (in production, use Redis or database)
|
||||
active_admin_tokens = set()
|
||||
SUPPORTED_API_CAPTCHA_METHODS = {"yescaptcha", "capmonster", "ezcaptcha", "capsolver"}
|
||||
|
||||
|
||||
def _mask_token(token: Optional[str]) -> str:
|
||||
if not token:
|
||||
return ""
|
||||
if len(token) <= 24:
|
||||
return token
|
||||
return f"{token[:18]}...{token[-8:]}"
|
||||
|
||||
|
||||
def _guess_client_hints_from_user_agent(user_agent: str) -> Dict[str, str]:
|
||||
"""根据 UA 补全常见的 sec-ch-* 头。"""
|
||||
ua = (user_agent or "").strip()
|
||||
if not ua:
|
||||
return {}
|
||||
|
||||
headers: Dict[str, str] = {}
|
||||
major_match = re.search(r"(?:Chrome|Chromium|Edg|EdgA|EdgiOS)/(\d+)", ua)
|
||||
is_mobile = any(token in ua for token in ("Android", "iPhone", "iPad", "Mobile"))
|
||||
headers["sec-ch-ua-mobile"] = "?1" if is_mobile else "?0"
|
||||
|
||||
if "Windows" in ua:
|
||||
headers["sec-ch-ua-platform"] = '"Windows"'
|
||||
elif "Macintosh" in ua or "Mac OS X" in ua:
|
||||
headers["sec-ch-ua-platform"] = '"macOS"'
|
||||
elif "Android" in ua:
|
||||
headers["sec-ch-ua-platform"] = '"Android"'
|
||||
elif "iPhone" in ua or "iPad" in ua:
|
||||
headers["sec-ch-ua-platform"] = '"iOS"'
|
||||
elif "Linux" in ua:
|
||||
headers["sec-ch-ua-platform"] = '"Linux"'
|
||||
|
||||
if major_match:
|
||||
major = major_match.group(1)
|
||||
if "Edg/" in ua:
|
||||
headers["sec-ch-ua"] = (
|
||||
f'"Not:A-Brand";v="99", "Microsoft Edge";v="{major}", "Chromium";v="{major}"'
|
||||
)
|
||||
else:
|
||||
headers["sec-ch-ua"] = (
|
||||
f'"Not:A-Brand";v="99", "Google Chrome";v="{major}", "Chromium";v="{major}"'
|
||||
)
|
||||
|
||||
return headers
|
||||
|
||||
|
||||
def _guess_impersonate_from_user_agent(user_agent: str) -> str:
|
||||
"""从 UA 选择可用的 curl_cffi 浏览器指纹版本。"""
|
||||
ua = (user_agent or "").strip()
|
||||
major_match = re.search(r"(?:Chrome|Chromium|Edg|EdgA|EdgiOS)/(\d+)", ua)
|
||||
if not major_match:
|
||||
return "chrome120"
|
||||
|
||||
try:
|
||||
major = int(major_match.group(1))
|
||||
except Exception:
|
||||
return "chrome120"
|
||||
|
||||
if major >= 124:
|
||||
return "chrome124"
|
||||
if major >= 120:
|
||||
return "chrome120"
|
||||
return "chrome120"
|
||||
|
||||
|
||||
def _build_proxy_map(proxy_url: str) -> Optional[Dict[str, str]]:
|
||||
normalized = (proxy_url or "").strip()
|
||||
if not normalized:
|
||||
return None
|
||||
return {"http": normalized, "https": normalized}
|
||||
|
||||
|
||||
async def _resolve_score_test_verify_proxy(
|
||||
captcha_method: str,
|
||||
browser_proxy_enabled: bool,
|
||||
browser_proxy_url: str
|
||||
) -> tuple[Optional[Dict[str, str]], bool, str, str]:
|
||||
"""
|
||||
选择 score-test 的 verify 请求代理,优先与浏览器打码代理保持一致。
|
||||
返回: (proxies, used, source, proxy_url)
|
||||
"""
|
||||
# 浏览器打码模式优先使用 browser_proxy,确保与取 token 出口一致
|
||||
if captcha_method in {"browser", "personal"} and browser_proxy_enabled and browser_proxy_url:
|
||||
proxy_map = _build_proxy_map(browser_proxy_url)
|
||||
if proxy_map:
|
||||
return proxy_map, True, "captcha_browser_proxy", browser_proxy_url
|
||||
|
||||
# 退回请求代理配置
|
||||
try:
|
||||
if proxy_manager:
|
||||
proxy_cfg = await proxy_manager.get_proxy_config()
|
||||
if proxy_cfg and proxy_cfg.enabled and proxy_cfg.proxy_url:
|
||||
proxy_map = _build_proxy_map(proxy_cfg.proxy_url)
|
||||
if proxy_map:
|
||||
return proxy_map, True, "request_proxy", proxy_cfg.proxy_url
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return None, False, "none", ""
|
||||
|
||||
|
||||
async def _solve_recaptcha_with_api_service(
|
||||
method: str,
|
||||
website_url: str,
|
||||
website_key: str,
|
||||
action: str,
|
||||
enterprise: bool = False
|
||||
) -> Optional[str]:
|
||||
"""使用当前配置的第三方打码服务获取 token。"""
|
||||
if method == "yescaptcha":
|
||||
client_key = config.yescaptcha_api_key
|
||||
base_url = config.yescaptcha_base_url
|
||||
task_type = "RecaptchaV3TaskProxylessM1"
|
||||
elif method == "capmonster":
|
||||
client_key = config.capmonster_api_key
|
||||
base_url = config.capmonster_base_url
|
||||
task_type = "RecaptchaV3TaskProxyless"
|
||||
elif method == "ezcaptcha":
|
||||
client_key = config.ezcaptcha_api_key
|
||||
base_url = config.ezcaptcha_base_url
|
||||
task_type = "ReCaptchaV3TaskProxylessS9"
|
||||
elif method == "capsolver":
|
||||
client_key = config.capsolver_api_key
|
||||
base_url = config.capsolver_base_url
|
||||
task_type = "ReCaptchaV3EnterpriseTaskProxyLess" if enterprise else "ReCaptchaV3TaskProxyLess"
|
||||
else:
|
||||
raise RuntimeError(f"不支持的打码方式: {method}")
|
||||
|
||||
if not client_key:
|
||||
raise RuntimeError(f"{method} API Key 未配置")
|
||||
|
||||
task: Dict[str, Any] = {
|
||||
"websiteURL": website_url,
|
||||
"websiteKey": website_key,
|
||||
"type": task_type,
|
||||
"pageAction": action,
|
||||
}
|
||||
|
||||
if enterprise and method == "capsolver":
|
||||
task["isEnterprise"] = True
|
||||
|
||||
create_url = f"{base_url.rstrip('/')}/createTask"
|
||||
get_url = f"{base_url.rstrip('/')}/getTaskResult"
|
||||
|
||||
async with AsyncSession() as session:
|
||||
create_resp = await session.post(
|
||||
create_url,
|
||||
json={"clientKey": client_key, "task": task},
|
||||
impersonate="chrome120",
|
||||
timeout=30
|
||||
)
|
||||
create_json = create_resp.json()
|
||||
task_id = create_json.get("taskId")
|
||||
|
||||
if not task_id:
|
||||
error_desc = create_json.get("errorDescription") or create_json.get("errorMessage") or str(create_json)
|
||||
raise RuntimeError(f"{method} createTask 失败: {error_desc}")
|
||||
|
||||
for _ in range(40):
|
||||
poll_resp = await session.post(
|
||||
get_url,
|
||||
json={"clientKey": client_key, "taskId": task_id},
|
||||
impersonate="chrome120",
|
||||
timeout=30
|
||||
)
|
||||
poll_json = poll_resp.json()
|
||||
if poll_json.get("status") == "ready":
|
||||
solution = poll_json.get("solution", {}) or {}
|
||||
token = solution.get("gRecaptchaResponse") or solution.get("token")
|
||||
if token:
|
||||
return token
|
||||
raise RuntimeError(f"{method} 返回结果缺少 token: {poll_json}")
|
||||
|
||||
if poll_json.get("errorId") not in (None, 0):
|
||||
error_desc = poll_json.get("errorDescription") or poll_json.get("errorMessage") or str(poll_json)
|
||||
raise RuntimeError(f"{method} getTaskResult 失败: {error_desc}")
|
||||
|
||||
await asyncio.sleep(3)
|
||||
|
||||
raise RuntimeError(f"{method} 获取 token 超时")
|
||||
|
||||
|
||||
def set_dependencies(tm: TokenManager, pm: ProxyManager, database: Database):
|
||||
@@ -73,6 +256,14 @@ class ProxyTestRequest(BaseModel):
|
||||
timeout_seconds: Optional[int] = 15
|
||||
|
||||
|
||||
class CaptchaScoreTestRequest(BaseModel):
|
||||
website_url: Optional[str] = "https://antcpt.com/score_detector/"
|
||||
website_key: Optional[str] = "6LcR_okUAAAAAPYrPe-HK_0RULO1aZM15ENyM-Mf"
|
||||
action: Optional[str] = "homepage"
|
||||
verify_url: Optional[str] = "https://antcpt.com/score_detector/verify.php"
|
||||
enterprise: Optional[bool] = False
|
||||
|
||||
|
||||
class GenerationConfigRequest(BaseModel):
|
||||
image_timeout: int
|
||||
video_timeout: int
|
||||
@@ -1036,6 +1227,282 @@ async def get_captcha_config(token: str = Depends(verify_admin_token)):
|
||||
}
|
||||
|
||||
|
||||
@router.post("/api/captcha/score-test")
|
||||
async def test_captcha_score(
|
||||
request: Optional[CaptchaScoreTestRequest] = None,
|
||||
token: str = Depends(verify_admin_token)
|
||||
):
|
||||
"""使用当前打码方式获取 token,并提交到 antcpt 校验分数。"""
|
||||
req = request or CaptchaScoreTestRequest()
|
||||
website_url = (req.website_url or "https://antcpt.com/score_detector/").strip()
|
||||
website_key = (req.website_key or "6LcR_okUAAAAAPYrPe-HK_0RULO1aZM15ENyM-Mf").strip()
|
||||
action = (req.action or "homepage").strip()
|
||||
verify_url = (req.verify_url or "https://antcpt.com/score_detector/verify.php").strip()
|
||||
enterprise = bool(req.enterprise)
|
||||
|
||||
started_at = time.time()
|
||||
captcha_config = await db.get_captcha_config()
|
||||
captcha_method = (captcha_config.captcha_method or config.captcha_method or "").strip().lower()
|
||||
browser_proxy_enabled = bool(captcha_config.browser_proxy_enabled)
|
||||
browser_proxy_url = captcha_config.browser_proxy_url or ""
|
||||
|
||||
token_value: Optional[str] = None
|
||||
fingerprint: Optional[Dict[str, Any]] = None
|
||||
token_elapsed_ms = 0
|
||||
verify_elapsed_ms = 0
|
||||
verify_http_status = None
|
||||
verify_result: Dict[str, Any] = {}
|
||||
verify_headers: Dict[str, str] = {}
|
||||
verify_proxy_used = False
|
||||
verify_proxy_source = "none"
|
||||
verify_proxy_url = ""
|
||||
verify_impersonate = "chrome120"
|
||||
page_verify_only = captcha_method in {"browser", "personal"}
|
||||
verify_mode = "browser_page" if page_verify_only else "server_post"
|
||||
|
||||
try:
|
||||
token_start = time.time()
|
||||
if captcha_method == "browser":
|
||||
from ..services.browser_captcha import BrowserCaptchaService
|
||||
service = await BrowserCaptchaService.get_instance(db)
|
||||
score_payload, browser_id = await service.get_custom_score(
|
||||
website_url=website_url,
|
||||
website_key=website_key,
|
||||
verify_url=verify_url,
|
||||
action=action,
|
||||
enterprise=enterprise
|
||||
)
|
||||
if isinstance(score_payload, dict):
|
||||
token_value = score_payload.get("token")
|
||||
verify_elapsed_ms = int(score_payload.get("verify_elapsed_ms") or 0)
|
||||
verify_http_status = score_payload.get("verify_http_status")
|
||||
verify_result = score_payload.get("verify_result") if isinstance(score_payload.get("verify_result"), dict) else {}
|
||||
verify_mode = score_payload.get("verify_mode") or "browser_page"
|
||||
score_token_elapsed = score_payload.get("token_elapsed_ms")
|
||||
if isinstance(score_token_elapsed, (int, float)):
|
||||
token_elapsed_ms = int(score_token_elapsed)
|
||||
if token_value:
|
||||
fingerprint = await service.get_fingerprint(browser_id)
|
||||
verify_proxy_used = bool(browser_proxy_enabled and browser_proxy_url)
|
||||
verify_proxy_source = "captcha_browser_proxy" if verify_proxy_used else "browser_direct"
|
||||
verify_proxy_url = browser_proxy_url if verify_proxy_used else ""
|
||||
elif captcha_method == "personal":
|
||||
from ..services.browser_captcha_personal import BrowserCaptchaService
|
||||
service = await BrowserCaptchaService.get_instance(db)
|
||||
score_payload = await service.get_custom_score(
|
||||
website_url=website_url,
|
||||
website_key=website_key,
|
||||
verify_url=verify_url,
|
||||
action=action,
|
||||
enterprise=enterprise
|
||||
)
|
||||
if isinstance(score_payload, dict):
|
||||
token_value = score_payload.get("token")
|
||||
verify_elapsed_ms = int(score_payload.get("verify_elapsed_ms") or 0)
|
||||
verify_http_status = score_payload.get("verify_http_status")
|
||||
verify_result = score_payload.get("verify_result") if isinstance(score_payload.get("verify_result"), dict) else {}
|
||||
verify_mode = score_payload.get("verify_mode") or "browser_page"
|
||||
score_token_elapsed = score_payload.get("token_elapsed_ms")
|
||||
if isinstance(score_token_elapsed, (int, float)):
|
||||
token_elapsed_ms = int(score_token_elapsed)
|
||||
if token_value:
|
||||
fingerprint = service.get_last_fingerprint()
|
||||
verify_proxy_used = bool(browser_proxy_enabled and browser_proxy_url)
|
||||
verify_proxy_source = "captcha_browser_proxy" if verify_proxy_used else "browser_direct"
|
||||
verify_proxy_url = browser_proxy_url if verify_proxy_used else ""
|
||||
elif captcha_method in SUPPORTED_API_CAPTCHA_METHODS:
|
||||
token_value = await _solve_recaptcha_with_api_service(
|
||||
method=captcha_method,
|
||||
website_url=website_url,
|
||||
website_key=website_key,
|
||||
action=action,
|
||||
enterprise=enterprise
|
||||
)
|
||||
else:
|
||||
return {
|
||||
"success": False,
|
||||
"message": f"当前打码方式不支持分数测试: {captcha_method}",
|
||||
"captcha_method": captcha_method,
|
||||
"website_url": website_url,
|
||||
"website_key": website_key,
|
||||
"action": action,
|
||||
"verify_url": verify_url,
|
||||
"enterprise": enterprise,
|
||||
"token_acquired": False,
|
||||
"elapsed_ms": int((time.time() - started_at) * 1000)
|
||||
}
|
||||
if token_elapsed_ms <= 0:
|
||||
token_elapsed_ms = int((time.time() - token_start) * 1000)
|
||||
|
||||
if not token_value:
|
||||
return {
|
||||
"success": False,
|
||||
"message": "未获取到 reCAPTCHA token",
|
||||
"captcha_method": captcha_method,
|
||||
"website_url": website_url,
|
||||
"website_key": website_key,
|
||||
"action": action,
|
||||
"verify_url": verify_url,
|
||||
"enterprise": enterprise,
|
||||
"token_acquired": False,
|
||||
"token_elapsed_ms": token_elapsed_ms,
|
||||
"browser_proxy_enabled": browser_proxy_enabled,
|
||||
"browser_proxy_url": browser_proxy_url if browser_proxy_enabled else "",
|
||||
"fingerprint": fingerprint,
|
||||
"elapsed_ms": int((time.time() - started_at) * 1000)
|
||||
}
|
||||
|
||||
if verify_mode == "server_post" and not page_verify_only:
|
||||
verify_start = time.time()
|
||||
verify_headers = {
|
||||
"accept": "application/json, text/javascript, */*; q=0.01",
|
||||
"content-type": "application/json",
|
||||
"origin": "https://antcpt.com",
|
||||
"referer": website_url,
|
||||
"x-requested-with": "XMLHttpRequest",
|
||||
}
|
||||
if isinstance(fingerprint, dict):
|
||||
ua = (fingerprint.get("user_agent") or "").strip()
|
||||
lang = (fingerprint.get("accept_language") or "").strip()
|
||||
sec_ch_ua = (fingerprint.get("sec_ch_ua") or "").strip()
|
||||
sec_ch_ua_mobile = (fingerprint.get("sec_ch_ua_mobile") or "").strip()
|
||||
sec_ch_ua_platform = (fingerprint.get("sec_ch_ua_platform") or "").strip()
|
||||
|
||||
if ua:
|
||||
verify_headers["user-agent"] = ua
|
||||
if lang:
|
||||
verify_headers["accept-language"] = lang if "," in lang else f"{lang},zh;q=0.9"
|
||||
if sec_ch_ua:
|
||||
verify_headers["sec-ch-ua"] = sec_ch_ua
|
||||
if sec_ch_ua_mobile:
|
||||
verify_headers["sec-ch-ua-mobile"] = sec_ch_ua_mobile
|
||||
if sec_ch_ua_platform:
|
||||
verify_headers["sec-ch-ua-platform"] = sec_ch_ua_platform
|
||||
|
||||
if verify_headers.get("user-agent"):
|
||||
for header_name, header_value in _guess_client_hints_from_user_agent(
|
||||
verify_headers.get("user-agent", "")
|
||||
).items():
|
||||
if header_value and not verify_headers.get(header_name):
|
||||
verify_headers[header_name] = header_value
|
||||
verify_impersonate = _guess_impersonate_from_user_agent(verify_headers.get("user-agent", ""))
|
||||
|
||||
verify_proxies, verify_proxy_used, verify_proxy_source, verify_proxy_url = (
|
||||
await _resolve_score_test_verify_proxy(
|
||||
captcha_method=captcha_method,
|
||||
browser_proxy_enabled=browser_proxy_enabled,
|
||||
browser_proxy_url=browser_proxy_url
|
||||
)
|
||||
)
|
||||
|
||||
async with AsyncSession() as session:
|
||||
verify_resp = await session.post(
|
||||
verify_url,
|
||||
json={"g-recaptcha-response": token_value},
|
||||
headers=verify_headers,
|
||||
proxies=verify_proxies,
|
||||
impersonate=verify_impersonate,
|
||||
timeout=30
|
||||
)
|
||||
verify_elapsed_ms = int((time.time() - verify_start) * 1000)
|
||||
verify_http_status = verify_resp.status_code
|
||||
|
||||
try:
|
||||
verify_result = verify_resp.json()
|
||||
except Exception:
|
||||
verify_result = {"raw": verify_resp.text}
|
||||
else:
|
||||
verify_headers = {
|
||||
"origin": "https://antcpt.com",
|
||||
"referer": website_url,
|
||||
"x-requested-with": "XMLHttpRequest",
|
||||
}
|
||||
if isinstance(fingerprint, dict):
|
||||
verify_headers.update({
|
||||
"user-agent": fingerprint.get("user_agent", ""),
|
||||
"accept-language": fingerprint.get("accept_language", ""),
|
||||
"sec-ch-ua": fingerprint.get("sec_ch_ua", ""),
|
||||
"sec-ch-ua-mobile": fingerprint.get("sec_ch_ua_mobile", ""),
|
||||
"sec-ch-ua-platform": fingerprint.get("sec_ch_ua_platform", ""),
|
||||
})
|
||||
|
||||
verify_success = bool(verify_result.get("success")) if isinstance(verify_result, dict) else False
|
||||
score_value = verify_result.get("score") if isinstance(verify_result, dict) else None
|
||||
|
||||
return {
|
||||
"success": verify_success,
|
||||
"message": "分数校验成功" if verify_success else "分数校验未通过",
|
||||
"captcha_method": captcha_method,
|
||||
"website_url": website_url,
|
||||
"website_key": website_key,
|
||||
"action": action,
|
||||
"verify_url": verify_url,
|
||||
"enterprise": enterprise,
|
||||
"token_acquired": True,
|
||||
"token_preview": _mask_token(token_value),
|
||||
"token_elapsed_ms": token_elapsed_ms,
|
||||
"verify_elapsed_ms": verify_elapsed_ms,
|
||||
"verify_http_status": verify_http_status,
|
||||
"score": score_value,
|
||||
"verify_result": verify_result,
|
||||
"verify_request_meta": {
|
||||
"mode": verify_mode,
|
||||
"proxy_used": verify_proxy_used,
|
||||
"user_agent": verify_headers.get("user-agent", ""),
|
||||
"accept_language": verify_headers.get("accept-language", ""),
|
||||
"sec_ch_ua": verify_headers.get("sec-ch-ua", ""),
|
||||
"sec_ch_ua_mobile": verify_headers.get("sec-ch-ua-mobile", ""),
|
||||
"sec_ch_ua_platform": verify_headers.get("sec-ch-ua-platform", ""),
|
||||
"origin": verify_headers.get("origin", ""),
|
||||
"referer": verify_headers.get("referer", ""),
|
||||
"x_requested_with": verify_headers.get("x-requested-with", ""),
|
||||
"proxy_source": verify_proxy_source,
|
||||
"proxy_url": verify_proxy_url,
|
||||
"impersonate": verify_impersonate,
|
||||
},
|
||||
"browser_proxy_enabled": browser_proxy_enabled,
|
||||
"browser_proxy_url": browser_proxy_url if browser_proxy_enabled else "",
|
||||
"fingerprint": fingerprint,
|
||||
"elapsed_ms": int((time.time() - started_at) * 1000)
|
||||
}
|
||||
except Exception as e:
|
||||
return {
|
||||
"success": False,
|
||||
"message": f"分数测试失败: {str(e)}",
|
||||
"captcha_method": captcha_method,
|
||||
"website_url": website_url,
|
||||
"website_key": website_key,
|
||||
"action": action,
|
||||
"verify_url": verify_url,
|
||||
"enterprise": enterprise,
|
||||
"token_acquired": bool(token_value),
|
||||
"token_preview": _mask_token(token_value),
|
||||
"token_elapsed_ms": token_elapsed_ms,
|
||||
"verify_elapsed_ms": verify_elapsed_ms,
|
||||
"verify_http_status": verify_http_status,
|
||||
"verify_result": verify_result,
|
||||
"verify_request_meta": {
|
||||
"mode": verify_mode,
|
||||
"proxy_used": verify_proxy_used,
|
||||
"user_agent": verify_headers.get("user-agent", ""),
|
||||
"accept_language": verify_headers.get("accept-language", ""),
|
||||
"sec_ch_ua": verify_headers.get("sec-ch-ua", ""),
|
||||
"sec_ch_ua_mobile": verify_headers.get("sec-ch-ua-mobile", ""),
|
||||
"sec_ch_ua_platform": verify_headers.get("sec-ch-ua-platform", ""),
|
||||
"origin": verify_headers.get("origin", ""),
|
||||
"referer": verify_headers.get("referer", ""),
|
||||
"x_requested_with": verify_headers.get("x-requested-with", ""),
|
||||
"proxy_source": verify_proxy_source,
|
||||
"proxy_url": verify_proxy_url,
|
||||
"impersonate": verify_impersonate,
|
||||
},
|
||||
"browser_proxy_enabled": browser_proxy_enabled,
|
||||
"browser_proxy_url": browser_proxy_url if browser_proxy_enabled else "",
|
||||
"fingerprint": fingerprint,
|
||||
"elapsed_ms": int((time.time() - started_at) * 1000)
|
||||
}
|
||||
|
||||
|
||||
# ========== Plugin Configuration Endpoints ==========
|
||||
|
||||
@router.get("/api/plugin/config")
|
||||
|
||||
@@ -192,9 +192,42 @@ def parse_proxy_url(proxy_url: str) -> Optional[Dict[str, str]]:
|
||||
return proxy_config
|
||||
return None
|
||||
|
||||
def normalize_browser_proxy_url(proxy_url: str) -> tuple[Optional[str], Optional[str]]:
|
||||
"""将浏览器代理标准化为 Playwright/Chromium 可接受的格式。
|
||||
|
||||
Chromium 不支持带账号密码的 socks5 代理认证。
|
||||
对于 `socks5://user:pass@host:port`,自动降级为 `http://user:pass@host:port`,
|
||||
方便兼容同时提供 HTTP/SOCKS5 双入口的代理服务商。
|
||||
|
||||
Returns:
|
||||
(normalized_proxy_url, warning_message)
|
||||
"""
|
||||
if not proxy_url:
|
||||
return None, None
|
||||
|
||||
proxy_url = proxy_url.strip()
|
||||
match = re.match(r'^(socks5|http|https)://(?:([^:]+):([^@]+)@)?([^:]+):(\d+)$', proxy_url)
|
||||
if not match:
|
||||
if not re.match(r'^(http|https|socks5)://', proxy_url):
|
||||
proxy_url = f"http://{proxy_url}"
|
||||
return proxy_url, None
|
||||
|
||||
protocol, username, password, host, port = match.groups()
|
||||
if protocol == "socks5" and username and password:
|
||||
normalized = f"http://{username}:{password}@{host}:{port}"
|
||||
warning = (
|
||||
"检测到带认证的 SOCKS5 代理。"
|
||||
"Chromium 不支持 socks5 用户名密码认证,"
|
||||
f"已自动改用 HTTP 代理启动浏览器: http://{host}:{port}"
|
||||
)
|
||||
return normalized, warning
|
||||
|
||||
return proxy_url, None
|
||||
|
||||
def validate_browser_proxy_url(proxy_url: str) -> tuple[bool, str]:
|
||||
if not proxy_url: return True, None
|
||||
parsed = parse_proxy_url(proxy_url)
|
||||
normalized_proxy_url, _ = normalize_browser_proxy_url(proxy_url)
|
||||
parsed = parse_proxy_url(normalized_proxy_url)
|
||||
if not parsed: return False, "代理格式错误"
|
||||
return True, None
|
||||
|
||||
@@ -203,46 +236,33 @@ class TokenBrowser:
|
||||
|
||||
每次都是新的随机 UA,避免长时间运行导致的各种问题
|
||||
"""
|
||||
|
||||
# UA 池(去掉 120-127,加入移动端 UA,并保留一批不常见补丁版本)
|
||||
# UA ???? 2026-03-01 ??????? score >= 0.3 ? UA?
|
||||
UA_LIST = [
|
||||
# Windows Chrome (128-132)
|
||||
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/132.0.0.0 Safari/537.36",
|
||||
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36",
|
||||
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Safari/537.36",
|
||||
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/129.0.0.0 Safari/537.36",
|
||||
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0.0.0 Safari/537.36",
|
||||
# Windows Chrome 不常见补丁版本
|
||||
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/132.0.6834.210 Safari/537.36",
|
||||
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.6778.265 Safari/537.36",
|
||||
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.6723.172 Safari/537.36",
|
||||
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/129.0.6668.177 Safari/537.36",
|
||||
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0.6613.186 Safari/537.36",
|
||||
"Mozilla/5.0 (Windows NT 10.0; WOW64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Safari/537.36",
|
||||
|
||||
# Windows Edge (128-132)
|
||||
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/132.0.0.0 Safari/537.36 Edg/132.0.0.0",
|
||||
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36 Edg/131.0.0.0",
|
||||
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Safari/537.36 Edg/130.0.0.0",
|
||||
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/129.0.0.0 Safari/537.36 Edg/129.0.0.0",
|
||||
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0.0.0 Safari/537.36 Edg/128.0.0.0",
|
||||
# Windows Edge 不常见补丁版本
|
||||
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/132.0.6834.210 Safari/537.36 Edg/132.0.2957.171",
|
||||
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.6778.265 Safari/537.36 Edg/131.0.2903.146",
|
||||
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.6723.172 Safari/537.36 Edg/130.0.2849.142",
|
||||
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/129.0.6668.177 Safari/537.36 Edg/129.0.2792.124",
|
||||
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0.6613.186 Safari/537.36 Edg/128.0.2739.111",
|
||||
|
||||
# Windows Firefox (128-134)
|
||||
"Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:134.0) Gecko/20100101 Firefox/134.0",
|
||||
"Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:133.0) Gecko/20100101 Firefox/133.0",
|
||||
"Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:132.0) Gecko/20100101 Firefox/132.0",
|
||||
"Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:131.0) Gecko/20100101 Firefox/131.0",
|
||||
"Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:130.0) Gecko/20100101 Firefox/130.0",
|
||||
"Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:129.0) Gecko/20100101 Firefox/129.0",
|
||||
"Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:128.0) Gecko/20100101 Firefox/128.0",
|
||||
|
||||
# macOS Chrome (128-132)
|
||||
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/132.0.0.0 Safari/537.36",
|
||||
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36",
|
||||
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Safari/537.36",
|
||||
@@ -252,73 +272,50 @@ class TokenBrowser:
|
||||
"Mozilla/5.0 (Macintosh; Intel Mac OS X 14_3_1) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36",
|
||||
"Mozilla/5.0 (Macintosh; Intel Mac OS X 14_2_1) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Safari/537.36",
|
||||
"Mozilla/5.0 (Macintosh; Intel Mac OS X 13_6_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/129.0.0.0 Safari/537.36",
|
||||
"Mozilla/5.0 (Macintosh; Intel Mac OS X 13_5_2) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0.0.0 Safari/537.36",
|
||||
# macOS Chrome 不常见补丁版本
|
||||
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/132.0.6834.210 Safari/537.36",
|
||||
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.6778.265 Safari/537.36",
|
||||
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.6723.172 Safari/537.36",
|
||||
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/129.0.6668.177 Safari/537.36",
|
||||
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0.6613.186 Safari/537.36",
|
||||
|
||||
# macOS Safari
|
||||
"Mozilla/5.0 (Macintosh; Intel Mac OS X 14_4_1) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.2 Safari/605.1.15",
|
||||
"Mozilla/5.0 (Macintosh; Intel Mac OS X 14_3_1) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1 Safari/605.1.15",
|
||||
"Mozilla/5.0 (Macintosh; Intel Mac OS X 14_2_1) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0 Safari/605.1.15",
|
||||
"Mozilla/5.0 (Macintosh; Intel Mac OS X 13_6_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Safari/605.1.15",
|
||||
|
||||
# macOS Edge (128-132)
|
||||
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/132.0.0.0 Safari/537.36 Edg/132.0.0.0",
|
||||
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36 Edg/131.0.0.0",
|
||||
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Safari/537.36 Edg/130.0.0.0",
|
||||
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/129.0.0.0 Safari/537.36 Edg/129.0.0.0",
|
||||
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0.0.0 Safari/537.36 Edg/128.0.0.0",
|
||||
# macOS Edge 不常见补丁版本
|
||||
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/132.0.6834.210 Safari/537.36 Edg/132.0.2957.171",
|
||||
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.6778.265 Safari/537.36 Edg/131.0.2903.146",
|
||||
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.6723.172 Safari/537.36 Edg/130.0.2849.142",
|
||||
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/129.0.6668.177 Safari/537.36 Edg/129.0.2792.124",
|
||||
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0.6613.186 Safari/537.36 Edg/128.0.2739.111",
|
||||
|
||||
# macOS Firefox (128-134)
|
||||
"Mozilla/5.0 (Macintosh; Intel Mac OS X 14.4; rv:134.0) Gecko/20100101 Firefox/134.0",
|
||||
"Mozilla/5.0 (Macintosh; Intel Mac OS X 14.3; rv:133.0) Gecko/20100101 Firefox/133.0",
|
||||
"Mozilla/5.0 (Macintosh; Intel Mac OS X 14.2; rv:132.0) Gecko/20100101 Firefox/132.0",
|
||||
"Mozilla/5.0 (Macintosh; Intel Mac OS X 14.1; rv:131.0) Gecko/20100101 Firefox/131.0",
|
||||
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:130.0) Gecko/20100101 Firefox/130.0",
|
||||
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:129.0) Gecko/20100101 Firefox/129.0",
|
||||
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:128.0) Gecko/20100101 Firefox/128.0",
|
||||
|
||||
# Android Chrome Mobile (128-132)
|
||||
"Mozilla/5.0 (Linux; Android 14; Pixel 8 Pro) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/132.0.6834.163 Mobile Safari/537.36",
|
||||
"Mozilla/5.0 (Linux; Android 14; SM-S9180) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.6778.260 Mobile Safari/537.36",
|
||||
"Mozilla/5.0 (Linux; Android 13; Pixel 7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.6723.172 Mobile Safari/537.36",
|
||||
"Mozilla/5.0 (Linux; Android 12; M2102J20SG) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/129.0.6668.177 Mobile Safari/537.36",
|
||||
"Mozilla/5.0 (Linux; Android 11; M2012K11AC) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0.6613.186 Mobile Safari/537.36",
|
||||
|
||||
# Android Edge Mobile
|
||||
"Mozilla/5.0 (Linux; Android 14; Pixel 8 Pro) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/132.0.6834.163 Mobile Safari/537.36 EdgA/132.0.2957.171",
|
||||
"Mozilla/5.0 (Linux; Android 14; SM-S9180) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.6778.260 Mobile Safari/537.36 EdgA/131.0.2903.146",
|
||||
"Mozilla/5.0 (Linux; Android 13; Pixel 7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.6723.172 Mobile Safari/537.36 EdgA/130.0.2849.142",
|
||||
"Mozilla/5.0 (Linux; Android 12; M2102J20SG) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/129.0.6668.177 Mobile Safari/537.36 EdgA/129.0.2792.124",
|
||||
"Mozilla/5.0 (Linux; Android 11; M2012K11AC) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0.6613.186 Mobile Safari/537.36 EdgA/128.0.2739.111",
|
||||
|
||||
# Android Samsung Browser (相对少见)
|
||||
"Mozilla/5.0 (Linux; Android 14; SM-S9180) AppleWebKit/537.36 (KHTML, like Gecko) SamsungBrowser/28.0 Chrome/132.0.6834.163 Mobile Safari/537.36",
|
||||
"Mozilla/5.0 (Linux; Android 13; SM-S9110) AppleWebKit/537.36 (KHTML, like Gecko) SamsungBrowser/27.0 Chrome/130.0.6723.172 Mobile Safari/537.36",
|
||||
"Mozilla/5.0 (Linux; Android 12; SM-G9910) AppleWebKit/537.36 (KHTML, like Gecko) SamsungBrowser/26.0 Chrome/128.0.6613.186 Mobile Safari/537.36",
|
||||
|
||||
# iOS Safari
|
||||
"Mozilla/5.0 (iPhone; CPU iPhone OS 18_2 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.2 Mobile/15E148 Safari/604.1",
|
||||
"Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1 Mobile/15E148 Safari/604.1",
|
||||
"Mozilla/5.0 (iPhone; CPU iPhone OS 18_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0 Mobile/15E148 Safari/604.1",
|
||||
"Mozilla/5.0 (iPad; CPU OS 18_2 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.2 Mobile/15E148 Safari/604.1",
|
||||
|
||||
# iOS Chrome / Edge
|
||||
"Mozilla/5.0 (iPhone; CPU iPhone OS 18_2 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/132.0.6834.95 Mobile/15E148 Safari/604.1",
|
||||
"Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.112 Mobile/15E148 Safari/604.1",
|
||||
"Mozilla/5.0 (iPhone; CPU iPhone OS 18_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/130.0.6723.90 Mobile/15E148 Safari/604.1",
|
||||
"Mozilla/5.0 (iPhone; CPU iPhone OS 18_2 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) EdgiOS/132.2957.171 Mobile/15E148 Safari/604.1",
|
||||
"Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) EdgiOS/131.2903.146 Mobile/15E148 Safari/604.1",
|
||||
"Mozilla/5.0 (Macintosh; Intel Mac OS X 14_4_1) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/132.0.6834.210 Safari/537.36",
|
||||
"Mozilla/5.0 (Macintosh; Intel Mac OS X 13_6_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/132.0.6834.210 Safari/537.36",
|
||||
"Mozilla/5.0 (Macintosh; Intel Mac OS X 14_4_1) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/132.0.6834.210 Safari/537.36 Edg/132.0.2957.171",
|
||||
"Mozilla/5.0 (Macintosh; Intel Mac OS X 13_6_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/132.0.6834.210 Safari/537.36 Edg/132.0.2957.171",
|
||||
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/132.0.6834.210 Safari/537.36 OPR/117.0.0.0",
|
||||
]
|
||||
|
||||
# 分辨率池
|
||||
@@ -341,6 +338,7 @@ class TokenBrowser:
|
||||
self._solve_count = 0
|
||||
self._error_count = 0
|
||||
self._last_fingerprint: Optional[Dict[str, Any]] = None
|
||||
self._browser_proxy_active = False
|
||||
# 打码成功后延迟关闭浏览器:等待上游图片/视频请求完成通知
|
||||
self._pending_release_events: List[asyncio.Event] = []
|
||||
self._pending_release_tasks: List[asyncio.Task] = []
|
||||
@@ -361,14 +359,19 @@ class TokenBrowser:
|
||||
# 代理配置
|
||||
proxy_option = None
|
||||
raw_proxy_url = None
|
||||
self._browser_proxy_active = False
|
||||
try:
|
||||
if self.db:
|
||||
captcha_config = await self.db.get_captcha_config()
|
||||
if captcha_config.browser_proxy_enabled and captcha_config.browser_proxy_url:
|
||||
candidate_proxy_url = captcha_config.browser_proxy_url.strip()
|
||||
proxy_option = parse_proxy_url(candidate_proxy_url)
|
||||
normalized_proxy_url, proxy_warning = normalize_browser_proxy_url(candidate_proxy_url)
|
||||
if proxy_warning:
|
||||
debug_logger.log_warning(f"[BrowserCaptcha] Token-{self.token_id} {proxy_warning}")
|
||||
proxy_option = parse_proxy_url(normalized_proxy_url)
|
||||
if proxy_option:
|
||||
raw_proxy_url = candidate_proxy_url
|
||||
raw_proxy_url = normalized_proxy_url
|
||||
self._browser_proxy_active = True
|
||||
debug_logger.log_info(f"[BrowserCaptcha] Token-{self.token_id} 使用代理: {proxy_option['server']}")
|
||||
except: pass
|
||||
|
||||
@@ -384,6 +387,8 @@ class TokenBrowser:
|
||||
proxy=proxy_option,
|
||||
args=[
|
||||
'--disable-blink-features=AutomationControlled',
|
||||
'--disable-quic',
|
||||
'--disable-features=UseDnsHttpsSvcb',
|
||||
'--no-sandbox',
|
||||
'--disable-dev-shm-usage',
|
||||
'--disable-setuid-sandbox',
|
||||
@@ -454,6 +459,130 @@ class TokenBrowser:
|
||||
self._last_fingerprint[key] = value
|
||||
except Exception as e:
|
||||
debug_logger.log_warning(f"[BrowserCaptcha] Token-{self.token_id} 提取浏览器指纹失败: {type(e).__name__}: {str(e)[:200]}")
|
||||
|
||||
async def _verify_score_in_page(self, page, token: str, verify_url: str) -> Dict[str, Any]:
|
||||
"""直接读取测试页面展示的分数,避免 verify.php 与页面显示口径不一致。"""
|
||||
_ = token
|
||||
_ = verify_url
|
||||
started_at = time.time()
|
||||
timeout_seconds = 25.0
|
||||
refresh_clicked = False
|
||||
last_snapshot: Dict[str, Any] = {}
|
||||
|
||||
try:
|
||||
timeout_seconds = float(getattr(config, "browser_score_dom_wait_seconds", 25) or 25)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
while (time.time() - started_at) < timeout_seconds:
|
||||
try:
|
||||
result = await page.evaluate(
|
||||
"""
|
||||
() => {
|
||||
const bodyText = ((document.body && document.body.innerText) || "")
|
||||
.replace(/\\u00a0/g, " ")
|
||||
.replace(/\\r/g, "");
|
||||
const patterns = [
|
||||
{ source: "current_score", regex: /Your score is:\\s*([01](?:\\.\\d+)?)/i },
|
||||
{ source: "selected_score", regex: /Selected Score Test:[\\s\\S]{0,400}?Score:\\s*([01](?:\\.\\d+)?)/i },
|
||||
{ source: "history_score", regex: /(?:^|\\n)\\s*Score:\\s*([01](?:\\.\\d+)?)\\s*;/i },
|
||||
];
|
||||
let score = null;
|
||||
let source = "";
|
||||
for (const item of patterns) {
|
||||
const match = bodyText.match(item.regex);
|
||||
if (!match) continue;
|
||||
const parsed = Number(match[1]);
|
||||
if (!Number.isNaN(parsed) && parsed >= 0 && parsed <= 1) {
|
||||
score = parsed;
|
||||
source = item.source;
|
||||
break;
|
||||
}
|
||||
}
|
||||
const uaMatch = bodyText.match(/Current User Agent:\\s*([^\\n]+)/i);
|
||||
const ipMatch = bodyText.match(/Current IP Address:\\s*([^\\n]+)/i);
|
||||
return {
|
||||
score,
|
||||
source,
|
||||
raw_text: bodyText.slice(0, 4000),
|
||||
current_user_agent: uaMatch ? uaMatch[1].trim() : "",
|
||||
current_ip_address: ipMatch ? ipMatch[1].trim() : "",
|
||||
title: document.title || "",
|
||||
url: location.href || "",
|
||||
};
|
||||
}
|
||||
"""
|
||||
)
|
||||
except Exception as e:
|
||||
result = {"error": f"{type(e).__name__}: {str(e)[:200]}"}
|
||||
|
||||
if isinstance(result, dict):
|
||||
last_snapshot = result
|
||||
score = result.get("score")
|
||||
if isinstance(score, (int, float)):
|
||||
elapsed_ms = int((time.time() - started_at) * 1000)
|
||||
return {
|
||||
"verify_mode": "browser_page_dom",
|
||||
"verify_elapsed_ms": elapsed_ms,
|
||||
"verify_http_status": None,
|
||||
"verify_result": {
|
||||
"success": True,
|
||||
"score": score,
|
||||
"source": result.get("source") or "antcpt_dom",
|
||||
"raw_text": result.get("raw_text") or "",
|
||||
"current_user_agent": result.get("current_user_agent") or "",
|
||||
"current_ip_address": result.get("current_ip_address") or "",
|
||||
"page_title": result.get("title") or "",
|
||||
"page_url": result.get("url") or "",
|
||||
},
|
||||
}
|
||||
|
||||
if not refresh_clicked and (time.time() - started_at) >= 2:
|
||||
refresh_clicked = True
|
||||
try:
|
||||
await page.evaluate(
|
||||
"""
|
||||
() => {
|
||||
const nodes = Array.from(
|
||||
document.querySelectorAll('button, input[type="button"], input[type="submit"], a')
|
||||
);
|
||||
const target = nodes.find((node) => {
|
||||
const text = (node.innerText || node.textContent || node.value || "").trim();
|
||||
return /Refresh score now!?/i.test(text);
|
||||
});
|
||||
if (target) {
|
||||
target.click();
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
"""
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
await asyncio.sleep(0.5)
|
||||
|
||||
elapsed_ms = int((time.time() - started_at) * 1000)
|
||||
if not isinstance(last_snapshot, dict):
|
||||
last_snapshot = {"raw": last_snapshot}
|
||||
|
||||
return {
|
||||
"verify_mode": "browser_page_dom",
|
||||
"verify_elapsed_ms": elapsed_ms,
|
||||
"verify_http_status": None,
|
||||
"verify_result": {
|
||||
"success": False,
|
||||
"score": None,
|
||||
"source": "antcpt_dom_timeout",
|
||||
"raw_text": last_snapshot.get("raw_text") or "",
|
||||
"current_user_agent": last_snapshot.get("current_user_agent") or "",
|
||||
"current_ip_address": last_snapshot.get("current_ip_address") or "",
|
||||
"page_title": last_snapshot.get("title") or "",
|
||||
"page_url": last_snapshot.get("url") or "",
|
||||
"error": last_snapshot.get("error") or "未在页面中读取到分数",
|
||||
},
|
||||
}
|
||||
|
||||
async def _close_browser(self, playwright, browser, context):
|
||||
"""关闭浏览器实例"""
|
||||
@@ -576,17 +705,51 @@ class TokenBrowser:
|
||||
await page.add_init_script("Object.defineProperty(navigator, 'webdriver', {get: () => undefined});")
|
||||
|
||||
page_url = f"https://labs.google/fx/tools/flow/project/{project_id}"
|
||||
primary_host = "https://www.recaptcha.net" if self._browser_proxy_active else "https://www.google.com"
|
||||
secondary_host = "https://www.google.com" if primary_host == "https://www.recaptcha.net" else "https://www.recaptcha.net"
|
||||
debug_logger.log_info(
|
||||
f"[BrowserCaptcha] Token-{self.token_id} 加载 enterprise.js: primary={primary_host}, secondary={secondary_host}"
|
||||
)
|
||||
|
||||
async def handle_route(route):
|
||||
if route.request.url.rstrip('/') == page_url.rstrip('/'):
|
||||
html = f"""<html><head><script src="https://www.google.com/recaptcha/enterprise.js?render={website_key}"></script></head><body></body></html>"""
|
||||
html = f"""<html><head><script>
|
||||
(() => {{
|
||||
const urls = [
|
||||
'{primary_host}/recaptcha/enterprise.js?render={website_key}',
|
||||
'{secondary_host}/recaptcha/enterprise.js?render={website_key}'
|
||||
];
|
||||
const loadScript = (index) => {{
|
||||
if (index >= urls.length) return;
|
||||
const script = document.createElement('script');
|
||||
script.src = urls[index];
|
||||
script.async = true;
|
||||
script.onerror = () => loadScript(index + 1);
|
||||
document.head.appendChild(script);
|
||||
}};
|
||||
loadScript(0);
|
||||
}})();
|
||||
</script></head><body></body></html>"""
|
||||
await route.fulfill(status=200, content_type="text/html", body=html)
|
||||
elif any(d in route.request.url for d in ["google.com", "gstatic.com", "recaptcha.net"]):
|
||||
await route.continue_()
|
||||
else:
|
||||
await route.abort()
|
||||
|
||||
def handle_request_failed(request):
|
||||
try:
|
||||
failed_url = request.url or ""
|
||||
if not any(d in failed_url for d in ["google.com", "gstatic.com", "recaptcha.net"]):
|
||||
return
|
||||
failure = request.failure or ""
|
||||
debug_logger.log_warning(
|
||||
f"[BrowserCaptcha] Token-{self.token_id} 资源加载失败: url={failed_url[:200]}, error={failure}"
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
await page.route("**/*", handle_route)
|
||||
page.on("requestfailed", handle_request_failed)
|
||||
reload_ok_event = asyncio.Event()
|
||||
clr_ok_event = asyncio.Event()
|
||||
|
||||
@@ -676,6 +839,194 @@ class TokenBrowser:
|
||||
except:
|
||||
pass
|
||||
|
||||
async def _execute_custom_captcha(
|
||||
self,
|
||||
context,
|
||||
website_url: str,
|
||||
website_key: str,
|
||||
action: str,
|
||||
verify_url: Optional[str] = None,
|
||||
enterprise: bool = False,
|
||||
) -> Any:
|
||||
"""在任意站点执行 reCAPTCHA,用于分数测试等非 Flow 场景。"""
|
||||
page = None
|
||||
try:
|
||||
page = await context.new_page()
|
||||
await page.add_init_script("Object.defineProperty(navigator, 'webdriver', {get: () => undefined});")
|
||||
|
||||
primary_host = "https://www.recaptcha.net" if self._browser_proxy_active else "https://www.google.com"
|
||||
secondary_host = "https://www.google.com" if primary_host == "https://www.recaptcha.net" else "https://www.recaptcha.net"
|
||||
script_path = "recaptcha/enterprise.js" if enterprise else "recaptcha/api.js"
|
||||
execute_target = "grecaptcha.enterprise.execute" if enterprise else "grecaptcha.execute"
|
||||
ready_target = "grecaptcha.enterprise.ready" if enterprise else "grecaptcha.ready"
|
||||
wait_expression = (
|
||||
"typeof grecaptcha !== 'undefined' && typeof grecaptcha.enterprise !== 'undefined' && "
|
||||
"typeof grecaptcha.enterprise.execute === 'function'"
|
||||
) if enterprise else (
|
||||
"typeof grecaptcha !== 'undefined' && typeof grecaptcha.execute === 'function'"
|
||||
)
|
||||
api_label = "enterprise.js" if enterprise else "api.js"
|
||||
|
||||
debug_logger.log_info(
|
||||
f"[BrowserCaptcha] Token-{self.token_id} 加载真实自定义页面 {api_label}: primary={primary_host}, secondary={secondary_host}, url={website_url}"
|
||||
)
|
||||
|
||||
def handle_request_failed(request):
|
||||
try:
|
||||
failed_url = request.url or ""
|
||||
if not any(d in failed_url for d in ["google.com", "gstatic.com", "recaptcha.net", "antcpt.com"]):
|
||||
return
|
||||
failure = request.failure or ""
|
||||
debug_logger.log_warning(
|
||||
f"[BrowserCaptcha] Token-{self.token_id} 自定义资源加载失败: url={failed_url[:200]}, error={failure}"
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
page.on("requestfailed", handle_request_failed)
|
||||
|
||||
try:
|
||||
await page.goto(website_url, wait_until="domcontentloaded", timeout=30000)
|
||||
except Exception as e:
|
||||
debug_logger.log_warning(
|
||||
f"[BrowserCaptcha] Token-{self.token_id} 自定义 page.goto 失败: {type(e).__name__}: {str(e)[:200]}"
|
||||
)
|
||||
return None
|
||||
|
||||
page_loaded = False
|
||||
for _ in range(20):
|
||||
try:
|
||||
ready_state = await page.evaluate("document.readyState")
|
||||
if ready_state == "complete":
|
||||
page_loaded = True
|
||||
break
|
||||
except Exception:
|
||||
pass
|
||||
await asyncio.sleep(0.5)
|
||||
if not page_loaded:
|
||||
debug_logger.log_warning(f"[BrowserCaptcha] Token-{self.token_id} 自定义页面 readyState 未达到 complete,继续尝试预热")
|
||||
|
||||
# 模拟更自然的前台交互,避免冷启动空白上下文直接 execute。
|
||||
try:
|
||||
await page.mouse.move(320, 220)
|
||||
await page.mouse.move(520, 320, steps=12)
|
||||
await page.mouse.wheel(0, 240)
|
||||
await page.bring_to_front()
|
||||
await page.evaluate("""
|
||||
(() => {
|
||||
try {
|
||||
window.focus();
|
||||
window.dispatchEvent(new Event('focus'));
|
||||
document.dispatchEvent(new MouseEvent('mousemove', {
|
||||
bubbles: true,
|
||||
clientX: Math.max(32, Math.floor((window.innerWidth || 1280) * 0.4)),
|
||||
clientY: Math.max(32, Math.floor((window.innerHeight || 720) * 0.35))
|
||||
}));
|
||||
window.scrollTo(0, Math.min(280, document.body?.scrollHeight || 280));
|
||||
} catch (e) {}
|
||||
})()
|
||||
""")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
warmup_seconds = float(getattr(config, "browser_score_test_warmup_seconds", 12) or 12)
|
||||
if warmup_seconds > 0:
|
||||
debug_logger.log_info(
|
||||
f"[BrowserCaptcha] Token-{self.token_id} 真实页面预热 {warmup_seconds:.1f}s 后再执行自定义打码"
|
||||
)
|
||||
await asyncio.sleep(warmup_seconds)
|
||||
|
||||
try:
|
||||
await page.wait_for_function(wait_expression, timeout=15000)
|
||||
except Exception as e:
|
||||
debug_logger.log_warning(
|
||||
f"[BrowserCaptcha] Token-{self.token_id} 自定义 grecaptcha 未就绪,尝试补注入脚本: {type(e).__name__}: {str(e)[:200]}"
|
||||
)
|
||||
try:
|
||||
await page.evaluate(f"""
|
||||
(primaryUrl, secondaryUrl) => {{
|
||||
const existing = Array.from(document.scripts || []).some((script) => {{
|
||||
const src = script?.src || "";
|
||||
return src.includes('/recaptcha/');
|
||||
}});
|
||||
if (existing) return;
|
||||
const urls = [primaryUrl, secondaryUrl];
|
||||
const loadScript = (index) => {{
|
||||
if (index >= urls.length) return;
|
||||
const script = document.createElement('script');
|
||||
script.src = urls[index];
|
||||
script.async = true;
|
||||
script.onerror = () => loadScript(index + 1);
|
||||
document.head.appendChild(script);
|
||||
}};
|
||||
loadScript(0);
|
||||
}}
|
||||
""", f"{primary_host}/{script_path}?render={website_key}", f"{secondary_host}/{script_path}?render={website_key}")
|
||||
await page.wait_for_function(wait_expression, timeout=15000)
|
||||
except Exception as inject_error:
|
||||
debug_logger.log_warning(
|
||||
f"[BrowserCaptcha] Token-{self.token_id} 自定义 grecaptcha 最终未就绪: {type(inject_error).__name__}: {str(inject_error)[:200]}"
|
||||
)
|
||||
return None
|
||||
|
||||
await self._capture_page_fingerprint(page)
|
||||
|
||||
token = await asyncio.wait_for(
|
||||
page.evaluate(
|
||||
f"""
|
||||
(actionName) => {{
|
||||
return new Promise((resolve, reject) => {{
|
||||
const timeout = setTimeout(() => reject(new Error('timeout')), 25000);
|
||||
try {{
|
||||
{ready_target}(function() {{
|
||||
{execute_target}('{website_key}', {{action: actionName}})
|
||||
.then(t => {{
|
||||
clearTimeout(timeout);
|
||||
resolve(t);
|
||||
}})
|
||||
.catch(e => {{
|
||||
clearTimeout(timeout);
|
||||
reject(e);
|
||||
}});
|
||||
}});
|
||||
}} catch (e) {{
|
||||
clearTimeout(timeout);
|
||||
reject(e);
|
||||
}}
|
||||
}});
|
||||
}}
|
||||
""",
|
||||
action,
|
||||
),
|
||||
timeout=30,
|
||||
)
|
||||
|
||||
post_wait_seconds = float(getattr(config, "browser_recaptcha_settle_seconds", 3) or 3)
|
||||
if post_wait_seconds > 0:
|
||||
debug_logger.log_info(
|
||||
f"[BrowserCaptcha] Token-{self.token_id} 自定义打码已完成,额外等待 {post_wait_seconds:.1f}s 后返回 token"
|
||||
)
|
||||
await asyncio.sleep(post_wait_seconds)
|
||||
|
||||
if verify_url:
|
||||
verify_payload = await self._verify_score_in_page(page, token, verify_url)
|
||||
return {
|
||||
"token": token,
|
||||
**verify_payload,
|
||||
}
|
||||
|
||||
return token
|
||||
except Exception as e:
|
||||
msg = f"{type(e).__name__}: {str(e)}"
|
||||
debug_logger.log_warning(f"[BrowserCaptcha] Token-{self.token_id} 自定义打码失败: {msg[:200]}")
|
||||
return None
|
||||
finally:
|
||||
if page:
|
||||
try:
|
||||
await page.close()
|
||||
except:
|
||||
pass
|
||||
|
||||
def get_last_fingerprint(self) -> Optional[Dict[str, Any]]:
|
||||
"""返回最近一次打码浏览器的指纹快照。"""
|
||||
if not self._last_fingerprint:
|
||||
@@ -730,6 +1081,115 @@ class TokenBrowser:
|
||||
await asyncio.sleep(1)
|
||||
|
||||
return None
|
||||
|
||||
async def get_custom_token(
|
||||
self,
|
||||
website_url: str,
|
||||
website_key: str,
|
||||
action: str = "homepage",
|
||||
enterprise: bool = False,
|
||||
) -> Optional[str]:
|
||||
"""获取任意站点的 reCAPTCHA token,成功后立即关闭浏览器。"""
|
||||
async with self._semaphore:
|
||||
max_retries = 3
|
||||
|
||||
for attempt in range(max_retries):
|
||||
playwright = None
|
||||
browser = None
|
||||
context = None
|
||||
try:
|
||||
start_ts = time.time()
|
||||
playwright, browser, context = await self._create_browser()
|
||||
token = await self._execute_custom_captcha(
|
||||
context=context,
|
||||
website_url=website_url,
|
||||
website_key=website_key,
|
||||
action=action,
|
||||
enterprise=enterprise,
|
||||
)
|
||||
|
||||
if token:
|
||||
self._solve_count += 1
|
||||
debug_logger.log_info(
|
||||
f"[BrowserCaptcha] Token-{self.token_id} 自定义 token 获取成功 ({(time.time()-start_ts)*1000:.0f}ms)"
|
||||
)
|
||||
return token
|
||||
|
||||
self._error_count += 1
|
||||
debug_logger.log_warning(
|
||||
f"[BrowserCaptcha] Token-{self.token_id} 自定义打码尝试 {attempt+1}/{max_retries} 失败"
|
||||
)
|
||||
except Exception as e:
|
||||
self._error_count += 1
|
||||
debug_logger.log_error(
|
||||
f"[BrowserCaptcha] Token-{self.token_id} 自定义浏览器错误: {type(e).__name__}: {str(e)[:200]}"
|
||||
)
|
||||
finally:
|
||||
await self._close_browser(playwright, browser, context)
|
||||
|
||||
if attempt < max_retries - 1:
|
||||
await asyncio.sleep(1)
|
||||
|
||||
return None
|
||||
|
||||
async def get_custom_score(
|
||||
self,
|
||||
website_url: str,
|
||||
website_key: str,
|
||||
verify_url: str,
|
||||
action: str = "homepage",
|
||||
enterprise: bool = False,
|
||||
) -> Dict[str, Any]:
|
||||
"""在同一个浏览器页面里获取 token 并直接校验分数。"""
|
||||
async with self._semaphore:
|
||||
max_retries = 3
|
||||
|
||||
for attempt in range(max_retries):
|
||||
playwright = None
|
||||
browser = None
|
||||
context = None
|
||||
try:
|
||||
started_at = time.time()
|
||||
playwright, browser, context = await self._create_browser()
|
||||
payload = await self._execute_custom_captcha(
|
||||
context=context,
|
||||
website_url=website_url,
|
||||
website_key=website_key,
|
||||
action=action,
|
||||
verify_url=verify_url,
|
||||
enterprise=enterprise,
|
||||
)
|
||||
|
||||
if isinstance(payload, dict) and payload.get("token"):
|
||||
self._solve_count += 1
|
||||
payload.setdefault("token_elapsed_ms", int((time.time() - started_at) * 1000))
|
||||
debug_logger.log_info(
|
||||
f"[BrowserCaptcha] Token-{self.token_id} 页面内分数校验成功 ({(time.time()-started_at)*1000:.0f}ms)"
|
||||
)
|
||||
return payload
|
||||
|
||||
self._error_count += 1
|
||||
debug_logger.log_warning(
|
||||
f"[BrowserCaptcha] Token-{self.token_id} 页面内分数校验尝试 {attempt+1}/{max_retries} 失败"
|
||||
)
|
||||
except Exception as e:
|
||||
self._error_count += 1
|
||||
debug_logger.log_error(
|
||||
f"[BrowserCaptcha] Token-{self.token_id} 页面内分数校验异常: {type(e).__name__}: {str(e)[:200]}"
|
||||
)
|
||||
finally:
|
||||
await self._close_browser(playwright, browser, context)
|
||||
|
||||
if attempt < max_retries - 1:
|
||||
await asyncio.sleep(1)
|
||||
|
||||
return {
|
||||
"token": None,
|
||||
"verify_mode": "browser_page",
|
||||
"verify_elapsed_ms": 0,
|
||||
"verify_http_status": None,
|
||||
"verify_result": {}
|
||||
}
|
||||
|
||||
|
||||
class BrowserCaptchaService:
|
||||
@@ -888,6 +1348,73 @@ class BrowserCaptchaService:
|
||||
self._log_stats()
|
||||
return token, browser_id
|
||||
|
||||
async def get_custom_token(
|
||||
self,
|
||||
website_url: str,
|
||||
website_key: str,
|
||||
action: str = "homepage",
|
||||
enterprise: bool = False,
|
||||
) -> tuple[Optional[str], int]:
|
||||
"""获取任意站点的 reCAPTCHA token,用于分数测试。"""
|
||||
self._check_available()
|
||||
|
||||
if self._token_semaphore:
|
||||
async with self._token_semaphore:
|
||||
browser_id = self._get_next_browser_id()
|
||||
browser = await self._get_or_create_browser(browser_id)
|
||||
token = await browser.get_custom_token(
|
||||
website_url=website_url,
|
||||
website_key=website_key,
|
||||
action=action,
|
||||
enterprise=enterprise,
|
||||
)
|
||||
return token, browser_id
|
||||
|
||||
browser_id = self._get_next_browser_id()
|
||||
browser = await self._get_or_create_browser(browser_id)
|
||||
token = await browser.get_custom_token(
|
||||
website_url=website_url,
|
||||
website_key=website_key,
|
||||
action=action,
|
||||
enterprise=enterprise,
|
||||
)
|
||||
return token, browser_id
|
||||
|
||||
async def get_custom_score(
|
||||
self,
|
||||
website_url: str,
|
||||
website_key: str,
|
||||
verify_url: str,
|
||||
action: str = "homepage",
|
||||
enterprise: bool = False,
|
||||
) -> tuple[Dict[str, Any], int]:
|
||||
"""在浏览器页面内完成 token 获取与分数校验。"""
|
||||
self._check_available()
|
||||
|
||||
if self._token_semaphore:
|
||||
async with self._token_semaphore:
|
||||
browser_id = self._get_next_browser_id()
|
||||
browser = await self._get_or_create_browser(browser_id)
|
||||
payload = await browser.get_custom_score(
|
||||
website_url=website_url,
|
||||
website_key=website_key,
|
||||
verify_url=verify_url,
|
||||
action=action,
|
||||
enterprise=enterprise,
|
||||
)
|
||||
return payload, browser_id
|
||||
|
||||
browser_id = self._get_next_browser_id()
|
||||
browser = await self._get_or_create_browser(browser_id)
|
||||
payload = await browser.get_custom_score(
|
||||
website_url=website_url,
|
||||
website_key=website_key,
|
||||
verify_url=verify_url,
|
||||
action=action,
|
||||
enterprise=enterprise,
|
||||
)
|
||||
return payload, browser_id
|
||||
|
||||
async def get_fingerprint(self, browser_id: int) -> Optional[Dict[str, Any]]:
|
||||
"""获取指定浏览器最近一次打码时的指纹快照。"""
|
||||
async with self._browsers_lock:
|
||||
@@ -946,3 +1473,4 @@ class BrowserCaptchaService:
|
||||
"browsers": []
|
||||
}
|
||||
return base_stats
|
||||
|
||||
|
||||
@@ -11,6 +11,7 @@ import subprocess
|
||||
from typing import Optional, Dict, Any
|
||||
|
||||
from ..core.logger import debug_logger
|
||||
from ..core.config import config
|
||||
|
||||
|
||||
# ==================== Docker 环境检测 ====================
|
||||
@@ -156,6 +157,9 @@ class BrowserCaptchaService:
|
||||
self._running = False # 向后兼容
|
||||
self._recaptcha_ready = False # 向后兼容
|
||||
self._last_fingerprint: Optional[Dict[str, Any]] = None
|
||||
# 自定义站点打码常驻页(用于 score-test)
|
||||
self._custom_tabs: dict[str, Dict[str, Any]] = {}
|
||||
self._custom_lock = asyncio.Lock()
|
||||
|
||||
@classmethod
|
||||
async def get_instance(cls, db=None) -> 'BrowserCaptchaService':
|
||||
@@ -371,6 +375,51 @@ class BrowserCaptchaService:
|
||||
debug_logger.log_warning("[BrowserCaptcha] reCAPTCHA 加载超时")
|
||||
return False
|
||||
|
||||
async def _wait_for_custom_recaptcha(
|
||||
self,
|
||||
tab,
|
||||
website_key: str,
|
||||
enterprise: bool = False,
|
||||
) -> bool:
|
||||
"""等待任意站点的 reCAPTCHA 加载,用于分数测试。"""
|
||||
debug_logger.log_info("[BrowserCaptcha] 检测自定义 reCAPTCHA...")
|
||||
|
||||
ready_check = (
|
||||
"typeof grecaptcha !== 'undefined' && typeof grecaptcha.enterprise !== 'undefined' && "
|
||||
"typeof grecaptcha.enterprise.execute === 'function'"
|
||||
) if enterprise else (
|
||||
"typeof grecaptcha !== 'undefined' && typeof grecaptcha.execute === 'function'"
|
||||
)
|
||||
script_path = "recaptcha/enterprise.js" if enterprise else "recaptcha/api.js"
|
||||
label = "Enterprise" if enterprise else "V3"
|
||||
|
||||
is_ready = await tab.evaluate(ready_check)
|
||||
if is_ready:
|
||||
debug_logger.log_info(f"[BrowserCaptcha] 自定义 reCAPTCHA {label} 已加载")
|
||||
return True
|
||||
|
||||
debug_logger.log_info("[BrowserCaptcha] 未检测到自定义 reCAPTCHA,注入脚本...")
|
||||
await tab.evaluate(f"""
|
||||
(() => {{
|
||||
if (document.querySelector('script[src*="recaptcha"]')) return;
|
||||
const script = document.createElement('script');
|
||||
script.src = 'https://www.google.com/{script_path}?render={website_key}';
|
||||
script.async = true;
|
||||
document.head.appendChild(script);
|
||||
}})()
|
||||
""")
|
||||
|
||||
await tab.sleep(3)
|
||||
for i in range(20):
|
||||
is_ready = await tab.evaluate(ready_check)
|
||||
if is_ready:
|
||||
debug_logger.log_info(f"[BrowserCaptcha] 自定义 reCAPTCHA {label} 已加载(等待了 {i * 0.5} 秒)")
|
||||
return True
|
||||
await tab.sleep(0.5)
|
||||
|
||||
debug_logger.log_warning("[BrowserCaptcha] 自定义 reCAPTCHA 加载超时")
|
||||
return False
|
||||
|
||||
async def _execute_recaptcha_on_tab(self, tab, action: str = "IMAGE_GENERATION") -> Optional[str]:
|
||||
"""在指定标签页执行 reCAPTCHA 获取 token
|
||||
|
||||
@@ -430,6 +479,192 @@ class BrowserCaptchaService:
|
||||
|
||||
return token
|
||||
|
||||
async def _execute_custom_recaptcha_on_tab(
|
||||
self,
|
||||
tab,
|
||||
website_key: str,
|
||||
action: str = "homepage",
|
||||
enterprise: bool = False,
|
||||
) -> Optional[str]:
|
||||
"""在指定标签页执行任意站点的 reCAPTCHA。"""
|
||||
ts = int(time.time() * 1000)
|
||||
token_var = f"_custom_recaptcha_token_{ts}"
|
||||
error_var = f"_custom_recaptcha_error_{ts}"
|
||||
execute_target = "grecaptcha.enterprise.execute" if enterprise else "grecaptcha.execute"
|
||||
|
||||
execute_script = f"""
|
||||
(() => {{
|
||||
window.{token_var} = null;
|
||||
window.{error_var} = null;
|
||||
|
||||
try {{
|
||||
grecaptcha.ready(function() {{
|
||||
{execute_target}('{website_key}', {{action: '{action}'}})
|
||||
.then(function(token) {{
|
||||
window.{token_var} = token;
|
||||
}})
|
||||
.catch(function(err) {{
|
||||
window.{error_var} = err.message || 'execute failed';
|
||||
}});
|
||||
}});
|
||||
}} catch (e) {{
|
||||
window.{error_var} = e.message || 'exception';
|
||||
}}
|
||||
}})()
|
||||
"""
|
||||
|
||||
await tab.evaluate(execute_script)
|
||||
|
||||
token = None
|
||||
for _ in range(30):
|
||||
await tab.sleep(0.5)
|
||||
token = await tab.evaluate(f"window.{token_var}")
|
||||
if token:
|
||||
break
|
||||
error = await tab.evaluate(f"window.{error_var}")
|
||||
if error:
|
||||
debug_logger.log_error(f"[BrowserCaptcha] 自定义 reCAPTCHA 错误: {error}")
|
||||
break
|
||||
|
||||
try:
|
||||
await tab.evaluate(f"delete window.{token_var}; delete window.{error_var};")
|
||||
except:
|
||||
pass
|
||||
|
||||
if token:
|
||||
post_wait_seconds = 3
|
||||
try:
|
||||
post_wait_seconds = float(getattr(config, "browser_recaptcha_settle_seconds", 3) or 3)
|
||||
except Exception:
|
||||
pass
|
||||
if post_wait_seconds > 0:
|
||||
debug_logger.log_info(
|
||||
f"[BrowserCaptcha] 自定义 reCAPTCHA 已完成,额外等待 {post_wait_seconds:.1f}s 后返回 token"
|
||||
)
|
||||
await tab.sleep(post_wait_seconds)
|
||||
|
||||
return token
|
||||
|
||||
async def _verify_score_on_tab(self, tab, token: str, verify_url: str) -> Dict[str, Any]:
|
||||
"""直接读取测试页面展示的分数,避免 verify.php 与页面显示口径不一致。"""
|
||||
_ = token
|
||||
_ = verify_url
|
||||
started_at = time.time()
|
||||
timeout_seconds = 25.0
|
||||
refresh_clicked = False
|
||||
last_snapshot: Dict[str, Any] = {}
|
||||
|
||||
try:
|
||||
timeout_seconds = float(getattr(config, "browser_score_dom_wait_seconds", 25) or 25)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
while (time.time() - started_at) < timeout_seconds:
|
||||
try:
|
||||
result = await tab.evaluate("""
|
||||
(() => {
|
||||
const bodyText = ((document.body && document.body.innerText) || "")
|
||||
.replace(/\\u00a0/g, " ")
|
||||
.replace(/\\r/g, "");
|
||||
const patterns = [
|
||||
{ source: "current_score", regex: /Your score is:\\s*([01](?:\\.\\d+)?)/i },
|
||||
{ source: "selected_score", regex: /Selected Score Test:[\\s\\S]{0,400}?Score:\\s*([01](?:\\.\\d+)?)/i },
|
||||
{ source: "history_score", regex: /(?:^|\\n)\\s*Score:\\s*([01](?:\\.\\d+)?)\\s*;/i },
|
||||
];
|
||||
let score = null;
|
||||
let source = "";
|
||||
for (const item of patterns) {
|
||||
const match = bodyText.match(item.regex);
|
||||
if (!match) continue;
|
||||
const parsed = Number(match[1]);
|
||||
if (!Number.isNaN(parsed) && parsed >= 0 && parsed <= 1) {
|
||||
score = parsed;
|
||||
source = item.source;
|
||||
break;
|
||||
}
|
||||
}
|
||||
const uaMatch = bodyText.match(/Current User Agent:\\s*([^\\n]+)/i);
|
||||
const ipMatch = bodyText.match(/Current IP Address:\\s*([^\\n]+)/i);
|
||||
return {
|
||||
score,
|
||||
source,
|
||||
raw_text: bodyText.slice(0, 4000),
|
||||
current_user_agent: uaMatch ? uaMatch[1].trim() : "",
|
||||
current_ip_address: ipMatch ? ipMatch[1].trim() : "",
|
||||
title: document.title || "",
|
||||
url: location.href || "",
|
||||
};
|
||||
})()
|
||||
""")
|
||||
except Exception as e:
|
||||
result = {"error": f"{type(e).__name__}: {str(e)[:200]}"}
|
||||
|
||||
if isinstance(result, dict):
|
||||
last_snapshot = result
|
||||
score = result.get("score")
|
||||
if isinstance(score, (int, float)):
|
||||
elapsed_ms = int((time.time() - started_at) * 1000)
|
||||
return {
|
||||
"verify_mode": "browser_page_dom",
|
||||
"verify_elapsed_ms": elapsed_ms,
|
||||
"verify_http_status": None,
|
||||
"verify_result": {
|
||||
"success": True,
|
||||
"score": score,
|
||||
"source": result.get("source") or "antcpt_dom",
|
||||
"raw_text": result.get("raw_text") or "",
|
||||
"current_user_agent": result.get("current_user_agent") or "",
|
||||
"current_ip_address": result.get("current_ip_address") or "",
|
||||
"page_title": result.get("title") or "",
|
||||
"page_url": result.get("url") or "",
|
||||
},
|
||||
}
|
||||
|
||||
if not refresh_clicked and (time.time() - started_at) >= 2:
|
||||
refresh_clicked = True
|
||||
try:
|
||||
await tab.evaluate("""
|
||||
(() => {
|
||||
const nodes = Array.from(
|
||||
document.querySelectorAll('button, input[type="button"], input[type="submit"], a')
|
||||
);
|
||||
const target = nodes.find((node) => {
|
||||
const text = (node.innerText || node.textContent || node.value || "").trim();
|
||||
return /Refresh score now!?/i.test(text);
|
||||
});
|
||||
if (target) {
|
||||
target.click();
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
})()
|
||||
""")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
await tab.sleep(0.5)
|
||||
|
||||
elapsed_ms = int((time.time() - started_at) * 1000)
|
||||
if not isinstance(last_snapshot, dict):
|
||||
last_snapshot = {"raw": last_snapshot}
|
||||
|
||||
return {
|
||||
"verify_mode": "browser_page_dom",
|
||||
"verify_elapsed_ms": elapsed_ms,
|
||||
"verify_http_status": None,
|
||||
"verify_result": {
|
||||
"success": False,
|
||||
"score": None,
|
||||
"source": "antcpt_dom_timeout",
|
||||
"raw_text": last_snapshot.get("raw_text") or "",
|
||||
"current_user_agent": last_snapshot.get("current_user_agent") or "",
|
||||
"current_ip_address": last_snapshot.get("current_ip_address") or "",
|
||||
"page_title": last_snapshot.get("title") or "",
|
||||
"page_url": last_snapshot.get("url") or "",
|
||||
"error": last_snapshot.get("error") or "未在页面中读取到分数",
|
||||
},
|
||||
}
|
||||
|
||||
async def _extract_tab_fingerprint(self, tab) -> Optional[Dict[str, Any]]:
|
||||
"""从 nodriver 标签页提取浏览器指纹信息。"""
|
||||
try:
|
||||
@@ -702,6 +937,16 @@ class BrowserCaptchaService:
|
||||
await self.stop_resident_mode()
|
||||
|
||||
try:
|
||||
custom_items = list(self._custom_tabs.values())
|
||||
self._custom_tabs.clear()
|
||||
for item in custom_items:
|
||||
tab = item.get("tab") if isinstance(item, dict) else None
|
||||
if tab:
|
||||
try:
|
||||
await tab.close()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
if self.browser:
|
||||
try:
|
||||
self.browser.stop()
|
||||
@@ -857,3 +1102,210 @@ class BrowserCaptchaService:
|
||||
if self._resident_tabs:
|
||||
return next(iter(self._resident_tabs.keys()))
|
||||
return self.resident_project_id
|
||||
|
||||
async def get_custom_token(
|
||||
self,
|
||||
website_url: str,
|
||||
website_key: str,
|
||||
action: str = "homepage",
|
||||
enterprise: bool = False,
|
||||
) -> Optional[str]:
|
||||
"""为任意站点执行 reCAPTCHA,用于分数测试等场景。
|
||||
|
||||
与普通 legacy 模式不同,这里会复用同一个常驻标签页,避免每次冷启动新 tab。
|
||||
"""
|
||||
await self.initialize()
|
||||
self._last_fingerprint = None
|
||||
|
||||
cache_key = f"{website_url}|{website_key}|{1 if enterprise else 0}"
|
||||
warmup_seconds = float(getattr(config, "browser_score_test_warmup_seconds", 12) or 12)
|
||||
per_request_settle_seconds = float(
|
||||
getattr(config, "browser_score_test_settle_seconds", 2.5) or 2.5
|
||||
)
|
||||
max_retries = 2
|
||||
|
||||
async with self._custom_lock:
|
||||
for attempt in range(max_retries):
|
||||
start_time = time.time()
|
||||
custom_info = self._custom_tabs.get(cache_key)
|
||||
tab = custom_info.get("tab") if isinstance(custom_info, dict) else None
|
||||
|
||||
try:
|
||||
if tab is None:
|
||||
debug_logger.log_info(f"[BrowserCaptcha] [Custom] 创建常驻测试标签页: {website_url}")
|
||||
tab = await self.browser.get(website_url, new_tab=True)
|
||||
custom_info = {
|
||||
"tab": tab,
|
||||
"recaptcha_ready": False,
|
||||
"warmed_up": False,
|
||||
"created_at": time.time(),
|
||||
}
|
||||
self._custom_tabs[cache_key] = custom_info
|
||||
|
||||
page_loaded = False
|
||||
for _ in range(20):
|
||||
ready_state = await tab.evaluate("document.readyState")
|
||||
if ready_state == "complete":
|
||||
page_loaded = True
|
||||
break
|
||||
await tab.sleep(0.5)
|
||||
|
||||
if not page_loaded:
|
||||
raise RuntimeError("自定义页面加载超时")
|
||||
|
||||
if not custom_info.get("recaptcha_ready"):
|
||||
recaptcha_ready = await self._wait_for_custom_recaptcha(
|
||||
tab=tab,
|
||||
website_key=website_key,
|
||||
enterprise=enterprise,
|
||||
)
|
||||
if not recaptcha_ready:
|
||||
raise RuntimeError("自定义 reCAPTCHA 无法加载")
|
||||
custom_info["recaptcha_ready"] = True
|
||||
|
||||
try:
|
||||
await tab.evaluate("""
|
||||
(() => {
|
||||
try {
|
||||
const body = document.body || document.documentElement;
|
||||
const width = window.innerWidth || 1280;
|
||||
const height = window.innerHeight || 720;
|
||||
const x = Math.max(24, Math.floor(width * 0.38));
|
||||
const y = Math.max(24, Math.floor(height * 0.32));
|
||||
const moveEvent = new MouseEvent('mousemove', {
|
||||
bubbles: true,
|
||||
clientX: x,
|
||||
clientY: y
|
||||
});
|
||||
const overEvent = new MouseEvent('mouseover', {
|
||||
bubbles: true,
|
||||
clientX: x,
|
||||
clientY: y
|
||||
});
|
||||
window.focus();
|
||||
window.dispatchEvent(new Event('focus'));
|
||||
document.dispatchEvent(moveEvent);
|
||||
document.dispatchEvent(overEvent);
|
||||
if (body) {
|
||||
body.dispatchEvent(moveEvent);
|
||||
body.dispatchEvent(overEvent);
|
||||
}
|
||||
window.scrollTo(0, Math.min(320, document.body?.scrollHeight || 320));
|
||||
} catch (e) {}
|
||||
})()
|
||||
""")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
if not custom_info.get("warmed_up"):
|
||||
if warmup_seconds > 0:
|
||||
debug_logger.log_info(
|
||||
f"[BrowserCaptcha] [Custom] 首次预热测试页面 {warmup_seconds:.1f}s 后再执行 token"
|
||||
)
|
||||
try:
|
||||
await tab.evaluate("""
|
||||
(() => {
|
||||
try {
|
||||
window.scrollTo(0, Math.min(240, document.body.scrollHeight || 240));
|
||||
window.dispatchEvent(new Event('mousemove'));
|
||||
window.dispatchEvent(new Event('focus'));
|
||||
} catch (e) {}
|
||||
})()
|
||||
""")
|
||||
except Exception:
|
||||
pass
|
||||
await tab.sleep(warmup_seconds)
|
||||
custom_info["warmed_up"] = True
|
||||
elif per_request_settle_seconds > 0:
|
||||
debug_logger.log_info(
|
||||
f"[BrowserCaptcha] [Custom] 复用测试标签页,执行前额外等待 {per_request_settle_seconds:.1f}s"
|
||||
)
|
||||
await tab.sleep(per_request_settle_seconds)
|
||||
|
||||
debug_logger.log_info(f"[BrowserCaptcha] [Custom] 使用常驻测试标签页执行验证 (action: {action})...")
|
||||
token = await self._execute_custom_recaptcha_on_tab(
|
||||
tab=tab,
|
||||
website_key=website_key,
|
||||
action=action,
|
||||
enterprise=enterprise,
|
||||
)
|
||||
|
||||
duration_ms = (time.time() - start_time) * 1000
|
||||
if token:
|
||||
extracted_fingerprint = await self._extract_tab_fingerprint(tab)
|
||||
if not extracted_fingerprint:
|
||||
try:
|
||||
fallback_ua = await tab.evaluate("navigator.userAgent || ''")
|
||||
fallback_lang = await tab.evaluate("navigator.language || ''")
|
||||
extracted_fingerprint = {
|
||||
"user_agent": fallback_ua or "",
|
||||
"accept_language": fallback_lang or "",
|
||||
"proxy_url": None,
|
||||
}
|
||||
except Exception:
|
||||
extracted_fingerprint = None
|
||||
self._last_fingerprint = extracted_fingerprint
|
||||
debug_logger.log_info(
|
||||
f"[BrowserCaptcha] [Custom] ✅ 常驻测试标签页 Token获取成功(耗时 {duration_ms:.0f}ms)"
|
||||
)
|
||||
return token
|
||||
|
||||
raise RuntimeError("自定义 token 获取失败(返回 null)")
|
||||
except Exception as e:
|
||||
debug_logger.log_warning(
|
||||
f"[BrowserCaptcha] [Custom] 尝试 {attempt + 1}/{max_retries} 失败: {str(e)}"
|
||||
)
|
||||
stale_info = self._custom_tabs.pop(cache_key, None)
|
||||
stale_tab = stale_info.get("tab") if isinstance(stale_info, dict) else None
|
||||
if stale_tab:
|
||||
try:
|
||||
await stale_tab.close()
|
||||
except Exception:
|
||||
pass
|
||||
if attempt >= max_retries - 1:
|
||||
debug_logger.log_error(f"[BrowserCaptcha] [Custom] 获取token异常: {str(e)}")
|
||||
return None
|
||||
|
||||
return None
|
||||
|
||||
async def get_custom_score(
|
||||
self,
|
||||
website_url: str,
|
||||
website_key: str,
|
||||
verify_url: str,
|
||||
action: str = "homepage",
|
||||
enterprise: bool = False,
|
||||
) -> Dict[str, Any]:
|
||||
"""在同一个常驻标签页里获取 token 并直接校验页面分数。"""
|
||||
token_started_at = time.time()
|
||||
token = await self.get_custom_token(
|
||||
website_url=website_url,
|
||||
website_key=website_key,
|
||||
action=action,
|
||||
enterprise=enterprise,
|
||||
)
|
||||
token_elapsed_ms = int((time.time() - token_started_at) * 1000)
|
||||
|
||||
if not token:
|
||||
return {
|
||||
"token": None,
|
||||
"token_elapsed_ms": token_elapsed_ms,
|
||||
"verify_mode": "browser_page",
|
||||
"verify_elapsed_ms": 0,
|
||||
"verify_http_status": None,
|
||||
"verify_result": {},
|
||||
}
|
||||
|
||||
cache_key = f"{website_url}|{website_key}|{1 if enterprise else 0}"
|
||||
async with self._custom_lock:
|
||||
custom_info = self._custom_tabs.get(cache_key)
|
||||
tab = custom_info.get("tab") if isinstance(custom_info, dict) else None
|
||||
if tab is None:
|
||||
raise RuntimeError("页面分数测试标签页不存在")
|
||||
verify_payload = await self._verify_score_on_tab(tab, token, verify_url)
|
||||
|
||||
return {
|
||||
"token": token,
|
||||
"token_elapsed_ms": token_elapsed_ms,
|
||||
**verify_payload,
|
||||
}
|
||||
|
||||
@@ -436,7 +436,11 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<button onclick="saveCaptchaConfig()" class="inline-flex items-center justify-center rounded-md bg-primary text-primary-foreground hover:bg-primary/90 h-9 px-4 w-full mt-4">保存配置</button>
|
||||
<div class="grid grid-cols-2 gap-2 mt-4">
|
||||
<button id="btnTestCaptchaScore" onclick="testCaptchaScore()" class="inline-flex items-center justify-center rounded-md border border-input bg-background hover:bg-accent h-9 px-4">测试当前打码分数</button>
|
||||
<button onclick="saveCaptchaConfig()" class="inline-flex items-center justify-center rounded-md bg-primary text-primary-foreground hover:bg-primary/90 h-9 px-4">保存配置</button>
|
||||
</div>
|
||||
<p id="captchaScoreTestResult" class="text-xs text-muted-foreground mt-2">测试目标:<code class="bg-muted px-1 py-0.5 rounded">https://antcpt.com/score_detector/</code></p>
|
||||
</div>
|
||||
|
||||
<!-- 调试配置 -->
|
||||
@@ -772,6 +776,8 @@
|
||||
toggleBrowserProxyInput=()=>{const enabled=$('cfgBrowserProxyEnabled').checked;$('browserProxyUrlInput').classList.toggle('hidden',!enabled)},
|
||||
loadCaptchaConfig=async()=>{try{console.log('开始加载验证码配置...');const r=await apiRequest('/api/captcha/config');if(!r){console.error('API请求失败');return}const d=await r.json();console.log('验证码配置数据:',d);$('cfgCaptchaMethod').value=d.captcha_method||'yescaptcha';$('cfgYescaptchaApiKey').value=d.yescaptcha_api_key||'';$('cfgYescaptchaBaseUrl').value=d.yescaptcha_base_url||'https://api.yescaptcha.com';$('cfgCapmonsterApiKey').value=d.capmonster_api_key||'';$('cfgCapmonsterBaseUrl').value=d.capmonster_base_url||'https://api.capmonster.cloud';$('cfgEzcaptchaApiKey').value=d.ezcaptcha_api_key||'';$('cfgEzcaptchaBaseUrl').value=d.ezcaptcha_base_url||'https://api.ez-captcha.com';$('cfgCapsolverApiKey').value=d.capsolver_api_key||'';$('cfgCapsolverBaseUrl').value=d.capsolver_base_url||'https://api.capsolver.com';$('cfgBrowserProxyEnabled').checked=d.browser_proxy_enabled||false;$('cfgBrowserProxyUrl').value=d.browser_proxy_url||'';$('cfgBrowserCount').value=d.browser_count||1;toggleCaptchaOptions();toggleBrowserProxyInput();console.log('验证码配置加载成功')}catch(e){console.error('加载验证码配置失败:',e);showToast('加载验证码配置失败: '+e.message,'error')}},
|
||||
saveCaptchaConfig=async()=>{const method=$('cfgCaptchaMethod').value,yesApiKey=$('cfgYescaptchaApiKey').value.trim(),yesBaseUrl=$('cfgYescaptchaBaseUrl').value.trim(),capApiKey=$('cfgCapmonsterApiKey').value.trim(),capBaseUrl=$('cfgCapmonsterBaseUrl').value.trim(),ezApiKey=$('cfgEzcaptchaApiKey').value.trim(),ezBaseUrl=$('cfgEzcaptchaBaseUrl').value.trim(),solverApiKey=$('cfgCapsolverApiKey').value.trim(),solverBaseUrl=$('cfgCapsolverBaseUrl').value.trim(),browserProxyEnabled=$('cfgBrowserProxyEnabled').checked,browserProxyUrl=$('cfgBrowserProxyUrl').value.trim(),browserCount=parseInt($('cfgBrowserCount').value)||1;console.log('保存验证码配置:',{method,yesApiKey,yesBaseUrl,capApiKey,capBaseUrl,ezApiKey,ezBaseUrl,solverApiKey,solverBaseUrl,browserProxyEnabled,browserProxyUrl,browserCount});try{const r=await apiRequest('/api/captcha/config',{method:'POST',body:JSON.stringify({captcha_method:method,yescaptcha_api_key:yesApiKey,yescaptcha_base_url:yesBaseUrl,capmonster_api_key:capApiKey,capmonster_base_url:capBaseUrl,ezcaptcha_api_key:ezApiKey,ezcaptcha_base_url:ezBaseUrl,capsolver_api_key:solverApiKey,capsolver_base_url:solverBaseUrl,browser_proxy_enabled:browserProxyEnabled,browser_proxy_url:browserProxyUrl,browser_count:browserCount})});if(!r){console.error('保存请求失败');return}const d=await r.json();console.log('保存结果:',d);if(d.success){showToast('验证码配置保存成功','success');await new Promise(r=>setTimeout(r,200));await loadCaptchaConfig()}else{console.error('保存失败:',d);showToast(d.message||'保存失败','error')}}catch(e){console.error('保存失败:',e);showToast('保存失败: '+e.message,'error')}},
|
||||
updateCaptchaScoreTestResult=(message,isSuccess)=>{const el=$('captchaScoreTestResult');if(!el)return;el.textContent=message;el.className=`text-xs mt-2 ${isSuccess?'text-green-600':'text-red-600'}`},
|
||||
testCaptchaScore=async()=>{const btn=$('btnTestCaptchaScore');if(btn){btn.disabled=true;btn.textContent='测试中...'}updateCaptchaScoreTestResult('正在按当前打码方式测试分数...',true);try{const r=await apiRequest('/api/captcha/score-test',{method:'POST',body:JSON.stringify({})});if(!r){updateCaptchaScoreTestResult('分数测试请求失败',false);return}const d=await r.json();const verify=d.verify_result||{};const score=verify.score!==undefined?verify.score:(d.score!==undefined?d.score:'-');const action=verify.action||d.action||'-';const hostname=verify.hostname||'-';const parts=[`方式: ${d.captcha_method||'-'}`,`score=${score}`,`action=${action}`,`hostname=${hostname}`,d.token_elapsed_ms!==undefined?`取token ${d.token_elapsed_ms}ms`:null,d.verify_elapsed_ms!==undefined?`校验 ${d.verify_elapsed_ms}ms`:null,d.elapsed_ms!==undefined?`总耗时 ${d.elapsed_ms}ms`:null,d.message||null].filter(Boolean);updateCaptchaScoreTestResult(parts.join(' | '),!!d.success);showToast(d.success?`分数测试成功: ${score}`:`分数测试失败: ${d.message||'未知错误'}`,d.success?'success':'error')}catch(e){updateCaptchaScoreTestResult('分数测试失败: '+e.message,false);showToast('分数测试失败: '+e.message,'error')}finally{if(btn){btn.disabled=false;btn.textContent='测试当前打码分数'}}},
|
||||
loadPluginConfig=async()=>{try{const r=await apiRequest('/api/plugin/config');if(!r)return;const d=await r.json();if(d.success&&d.config){$('cfgPluginConnectionUrl').value=d.config.connection_url||'';$('cfgPluginConnectionToken').value=d.config.connection_token||'';$('cfgAutoEnableOnUpdate').checked=d.config.auto_enable_on_update||false}}catch(e){console.error('加载插件配置失败:',e);showToast('加载插件配置失败: '+e.message,'error')}},
|
||||
savePluginConfig=async()=>{const token=$('cfgPluginConnectionToken').value.trim();const autoEnable=$('cfgAutoEnableOnUpdate').checked;try{const r=await apiRequest('/api/plugin/config',{method:'POST',body:JSON.stringify({connection_token:token,auto_enable_on_update:autoEnable})});if(!r)return;const d=await r.json();if(d.success){showToast('插件配置保存成功','success');await loadPluginConfig()}else{showToast(d.message||'保存失败','error')}}catch(e){showToast('保存失败: '+e.message,'error')}},
|
||||
copyConnectionUrl=()=>{const url=$('cfgPluginConnectionUrl').value;if(!url){showToast('连接接口为空','error');return}navigator.clipboard.writeText(url).then(()=>showToast('连接接口已复制','success')).catch(()=>showToast('复制失败','error'))},
|
||||
|
||||
Reference in New Issue
Block a user