Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 33 additions & 4 deletions src/services/flow_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -2993,20 +2993,46 @@ async def _get_recaptcha_token(
self._set_request_fingerprint(None)
else:
self._set_request_fingerprint(None)
token = await self._get_api_captcha_token(captcha_method, project_id, action)
api_result = await self._get_api_captcha_token(captcha_method, project_id, action)
if api_result is None:
return None, None
token, captcha_user_agent = api_result
# 把打码服务返回的 userAgent 合并到 fingerprint, 让 Flow API 提交请求沿用同一 UA。
# 否则 Google reCAPTCHA V3 评估会因 UA 不一致判定 UNUSUAL_ACTIVITY 并返回
# "reCAPTCHA evaluation failed"。其他 Client Hint (sec-ch-ua-platform 等)
# 由 _make_request 根据 UA 自动推断 (Windows → "Windows", etc.)。
if captcha_user_agent:
existing_fp = self._request_fingerprint_ctx.get()
merged_fp = dict(existing_fp) if isinstance(existing_fp, dict) else {}
merged_fp["user_agent"] = captcha_user_agent
self._set_request_fingerprint(merged_fp)
debug_logger.log_info(
f"[reCAPTCHA {captcha_method}] 已将打码 UA 注入请求指纹: "
f"{captcha_user_agent[:80]}"
)
return token, None
else:
debug_logger.log_info(f"[reCAPTCHA] 未知的打码方式: {captcha_method}")
self._set_request_fingerprint(None)
return None, None

async def _get_api_captcha_token(self, method: str, project_id: str, action: str = "IMAGE_GENERATION") -> Optional[str]:
async def _get_api_captcha_token(
self,
method: str,
project_id: str,
action: str = "IMAGE_GENERATION"
) -> Optional[tuple[str, str]]:
"""通用API打码服务

Args:
method: 打码服务类型
project_id: 项目ID
action: reCAPTCHA action类型 (IMAGE_GENERATION 或 VIDEO_GENERATION)

Returns:
(gRecaptchaResponse, userAgent) 元组, 或 None (失败时)。
返回 userAgent 是为了让后续 Flow API 请求沿用打码时的 UA,
避免 reCAPTCHA V3 评估因 UA 不一致判定 UNUSUAL_ACTIVITY。
"""
# 获取配置
if method == "yescaptcha":
Expand Down Expand Up @@ -3110,7 +3136,10 @@ async def _get_api_captcha_token(self, method: str, project_id: str, action: str
response = solution.get('gRecaptchaResponse')
if response:
debug_logger.log_info(f"[reCAPTCHA {method}] Token获取成功")
return response
# 同时返回 userAgent, 调用方会注入到 fingerprint context,
# 保证 Flow API 提交请求时使用与打码一致的 UA。
user_agent = solution.get('userAgent')
return response, user_agent

await asyncio.sleep(3)

Expand Down
90 changes: 90 additions & 0 deletions tests/test_api_captcha_fingerprint.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
"""Verify API-mode captcha injects solution user_agent into the request fingerprint.

背景: YesCaptcha / CapMonster / EzCaptcha / CapSolver 返回的 solution 包含
gRecaptchaResponse 与 userAgent。Google reCAPTCHA V3 评估会校验 token 与
提交请求的 User-Agent 一致性, 因此调用 Flow API 时必须沿用打码服务返回的
UA, 否则服务端判定 UNUSUAL_ACTIVITY 并返回 reCAPTCHA evaluation failed。
"""

import unittest
from unittest.mock import patch, AsyncMock, MagicMock

from src.services.flow_client import FlowClient


class _FakeProxyManager:
async def get_request_proxy_url(self):
return None


class _FakeAsyncSession:
"""模拟 curl_cffi 的 AsyncSession: createTask 返回 taskId, getTaskResult 返回 ready。"""

def __init__(self):
self._calls = 0

async def __aenter__(self):
return self

async def __aexit__(self, *args):
return False

async def post(self, *args, **kwargs):
self._calls += 1
response = MagicMock()
if self._calls == 1:
# createTask
response.status_code = 200
response.json.return_value = {
"errorId": 0,
"taskId": "tid-xyz",
}
else:
# getTaskResult
response.status_code = 200
response.json.return_value = {
"errorId": 0,
"status": "ready",
"solution": {
"gRecaptchaResponse": "token-abc",
"userAgent": (
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
"AppleWebKit/537.36 (KHTML, like Gecko) "
"Chrome/147.0.0.0 Safari/537.36"
),
},
}
return response


class ApiCaptchaFingerprintTests(unittest.IsolatedAsyncioTestCase):
async def test_api_captcha_returns_token_and_user_agent(self):
"""_get_api_captcha_token 必须返回 (token, userAgent) 元组。"""
flow = FlowClient.__new__(FlowClient)
flow.proxy_manager = _FakeProxyManager()
fake_session = _FakeAsyncSession()

with patch("src.services.flow_client.AsyncSession", lambda *a, **kw: fake_session), \
patch("src.services.flow_client.config") as cfg, \
patch("asyncio.sleep", new=AsyncMock()):
cfg.yescaptcha_api_key = "key"
cfg.yescaptcha_base_url = "https://api.yescaptcha.com"
cfg.yescaptcha_task_type = "RecaptchaV3TaskProxylessM1"
cfg.debug_enabled = False

result = await flow._get_api_captcha_token(
method="yescaptcha",
project_id="proj-1",
action="IMAGE_GENERATION",
)

self.assertIsNotNone(result, "函数不应返回 None, 因为我们 mock 了 ready 状态")
self.assertIsInstance(result, tuple, "_get_api_captcha_token 应返回 (token, userAgent) 元组")
token, user_agent = result
self.assertEqual(token, "token-abc")
self.assertIn("Windows", user_agent, "userAgent 应当来自打码服务 solution, 包含 Windows")
self.assertIn("Chrome/147", user_agent)


if __name__ == "__main__":
unittest.main()
Loading