From cb50ea0a5cccd2a80e3c8a0b7df64c26e3bcd37e Mon Sep 17 00:00:00 2001 From: genz27 Date: Sun, 1 Mar 2026 07:41:37 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=96=B0=E5=A2=9E=E9=AA=8C=E8=AF=81?= =?UTF-8?q?=E7=A0=81=E5=88=86=E6=95=B0=E6=B5=8B=E8=AF=95=E5=B9=B6=E4=BC=98?= =?UTF-8?q?=E5=8C=96=E6=B5=8F=E8=A7=88=E5=99=A8=E6=89=93=E7=A0=81=E6=B5=81?= =?UTF-8?q?=E7=A8=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 后台新增 /api/captcha/score-test,支持按当前打码配置直接测分并返回详细结果 - 管理后台新增当前打码分数测试入口,展示 score、耗时、action、hostname 等信息 - 优化 browser 与 personal 模式的 reCAPTCHA 执行、页面测分、代理兼容与 UA 策略 --- src/api/admin.py | 469 ++++++++++++++++- src/services/browser_captcha.py | 620 +++++++++++++++++++++-- src/services/browser_captcha_personal.py | 452 +++++++++++++++++ static/manage.html | 8 +- 4 files changed, 1501 insertions(+), 48 deletions(-) diff --git a/src/api/admin.py b/src/api/admin.py index 8a6dcb9..c600a7e 100644 --- a/src/api/admin.py +++ b/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") diff --git a/src/services/browser_captcha.py b/src/services/browser_captcha.py index 4e1e48f..1765c42 100644 --- a/src/services/browser_captcha.py +++ b/src/services/browser_captcha.py @@ -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 = f"""""" 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 + diff --git a/src/services/browser_captcha_personal.py b/src/services/browser_captcha_personal.py index 72fcc32..8b12dc6 100644 --- a/src/services/browser_captcha_personal.py +++ b/src/services/browser_captcha_personal.py @@ -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, + } diff --git a/static/manage.html b/static/manage.html index 5b698e2..cec1e18 100644 --- a/static/manage.html +++ b/static/manage.html @@ -436,7 +436,11 @@ - +
+ + +
+

测试目标:https://antcpt.com/score_detector/

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