feat: 新增验证码分数测试并优化浏览器打码流程

- 后台新增 /api/captcha/score-test,支持按当前打码配置直接测分并返回详细结果
- 管理后台新增当前打码分数测试入口,展示 score、耗时、action、hostname 等信息
- 优化 browser 与 personal 模式的 reCAPTCHA 执行、页面测分、代理兼容与 UA 策略
This commit is contained in:
genz27
2026-03-01 07:41:37 +08:00
parent e5ef238849
commit cb50ea0a5c
4 changed files with 1501 additions and 48 deletions

View File

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

View File

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

View File

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

View File

@@ -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'))},