diff --git a/config/setting.toml b/config/setting.toml
deleted file mode 100644
index ca60e6f..0000000
--- a/config/setting.toml
+++ /dev/null
@@ -1,76 +0,0 @@
-[global]
-api_key = "han1234"
-admin_username = "admin"
-admin_password = "admin"
-
-[flow]
-labs_base_url = "https://labs.google/fx/api"
-api_base_url = "https://aisandbox-pa.googleapis.com/v1"
-timeout = 120
-max_retries = 4
-image_request_timeout = 40
-image_timeout_retry_count = 1
-image_timeout_retry_delay = 0.8
-image_timeout_use_media_proxy_fallback = true
-image_prefer_media_proxy = true
-image_slot_wait_timeout = 900
-image_launch_soft_limit = 25
-image_launch_wait_timeout = 900
-image_launch_stagger_ms = 250
-video_slot_wait_timeout = 480
-video_launch_soft_limit = 20
-video_launch_wait_timeout = 480
-video_launch_stagger_ms = 250
-poll_interval = 3.0
-max_poll_attempts = 200
-
-[server]
-host = "0.0.0.0"
-port = 8000
-
-[debug]
-enabled = false
-log_requests = true
-log_responses = true
-mask_token = true
-
-[proxy]
-proxy_enabled = false
-proxy_url = ""
-
-[generation]
-image_timeout = 300
-video_timeout = 1500
-
-[call_logic]
-call_mode = "default" # default=随机轮询策略, polling=顺序轮询策略
-
-[admin]
-error_ban_threshold = 3
-
-[cache]
-enabled = false
-timeout = 7200 # 缓存超时时间(秒), 默认2小时
-base_url = "" # 缓存文件访问的基础URL, 留空则使用服务器地址
-
-[captcha]
-captcha_method = "personal" # 打码方式: yescaptcha/browser/personal/remote_browser
-browser_recaptcha_settle_seconds = 1.0 # 打码完成额外稳态等待,速度优先可设 1.0
-browser_launch_background = true # browser 打码默认后台启动(最小化/避免抢占前台)
-
-# 内置浏览器打码 (personal) 配置
-# 每个项目(project_id)对应一个常驻标签页,标签页会被复用以提高性能
-# 推荐配置(每个标签约占用200-300MB内存):
-# - 2GB内存: 3个标签
-# - 4GB内存: 5个标签
-# - 8GB内存: 10个标签
-# - 16GB内存: 20个标签
-# 注意:标签数量取决于不同项目的数量,单个项目的并发请求会复用同一个标签页
-personal_max_resident_tabs = 5 # 最大常驻标签页数量
-personal_idle_tab_ttl_seconds = 600 # 标签页空闲超时(秒),超时后自动回收
-
-yescaptcha_api_key = "" # YesCaptcha API密钥
-yescaptcha_base_url = "https://api.yescaptcha.com"
-remote_browser_base_url = "http://127.0.0.1:8060" # 本地 token 池服务地址
-remote_browser_api_key = "" # 本地 token 池服务 API Key
-remote_browser_timeout = 35 # 远程有头打码请求超时(秒)
diff --git a/config/setting_example.toml b/config/setting_example.toml
index 0d4f1b7..2c9c8c8 100644
--- a/config/setting_example.toml
+++ b/config/setting_example.toml
@@ -54,8 +54,7 @@ timeout = 7200 # 缓存超时时间(秒), 默认2小时; 设置为0表示不自
base_url = "" # 缓存文件访问的基础URL, 留空则使用服务器地址
[captcha]
-captcha_method = "browser" # 打码方式: yescaptcha/browser/personal/remote_browser
-browser_launch_background = true # 有头浏览器是否默认后台启动;设为 false 可直接看到窗口
+captcha_method = "extension" # 打码方式: extension/yescaptcha/browser/personal/remote_browser
browser_recaptcha_settle_seconds = 3.0 # reload/clr 就绪后的额外稳态等待
browser_count = 1 # browser 模式的有头浏览器实例数量
personal_project_pool_size = 4 # personal 模式下单个 Token 默认维护的项目池数量(仅影响项目轮换,不决定打码标签页数量)
@@ -66,3 +65,5 @@ yescaptcha_base_url = "https://api.yescaptcha.com"
remote_browser_base_url = "" # 远程有头打码服务地址
remote_browser_api_key = "" # 远程有头打码服务 API Key
remote_browser_timeout = 60 # 远程有头打码请求超时(秒)
+capsolver_api_key = ""
+capsolver_base_url = "https://api.capsolver.com"
diff --git a/extension/background.js b/extension/background.js
new file mode 100644
index 0000000..517e3f6
--- /dev/null
+++ b/extension/background.js
@@ -0,0 +1,242 @@
+let ws = null;
+let reconnectTimeout = null;
+let heartbeatInterval = null;
+
+const DEFAULT_SETTINGS = {
+ serverUrl: "ws://127.0.0.1:8000/captcha_ws",
+ apiKey: "",
+ routeKey: "",
+ clientLabel: ""
+};
+
+function getSettings() {
+ return new Promise((resolve) => {
+ chrome.storage.local.get(DEFAULT_SETTINGS, (stored) => {
+ resolve({
+ serverUrl: (stored.serverUrl || DEFAULT_SETTINGS.serverUrl).trim(),
+ apiKey: (stored.apiKey || "").trim(),
+ routeKey: (stored.routeKey || "").trim(),
+ clientLabel: (stored.clientLabel || "").trim()
+ });
+ });
+ });
+}
+
+function closeSocket() {
+ if (heartbeatInterval) clearInterval(heartbeatInterval);
+ heartbeatInterval = null;
+ if (reconnectTimeout) clearTimeout(reconnectTimeout);
+ reconnectTimeout = null;
+ if (ws) {
+ try {
+ ws.close();
+ } catch (e) {
+ console.log("[Flow2API] Close socket error", e);
+ }
+ ws = null;
+ }
+}
+
+function sleep(ms) {
+ return new Promise(resolve => setTimeout(resolve, ms));
+}
+
+function waitForTabReady(tabId, timeoutMs = 12000) {
+ return new Promise((resolve) => {
+ let settled = false;
+ const finish = () => {
+ if (settled) return;
+ settled = true;
+ chrome.tabs.onUpdated.removeListener(onUpdated);
+ clearTimeout(timer);
+ resolve();
+ };
+ const onUpdated = (updatedTabId, changeInfo) => {
+ if (updatedTabId === tabId && changeInfo.status === "complete") {
+ finish();
+ }
+ };
+ const timer = setTimeout(finish, timeoutMs);
+
+ chrome.tabs.onUpdated.addListener(onUpdated);
+ chrome.tabs.get(tabId, (tab) => {
+ if (chrome.runtime.lastError) {
+ finish();
+ return;
+ }
+ if (tab && tab.status === "complete") {
+ finish();
+ }
+ });
+ });
+}
+
+async function connectWS() {
+ if (ws && (ws.readyState === WebSocket.OPEN || ws.readyState === WebSocket.CONNECTING)) return;
+
+ const settings = await getSettings();
+ const url = new URL(settings.serverUrl || DEFAULT_SETTINGS.serverUrl);
+ if (settings.apiKey) {
+ url.searchParams.set("key", settings.apiKey);
+ }
+ if (settings.routeKey) {
+ url.searchParams.set("route_key", settings.routeKey);
+ }
+ if (settings.clientLabel) {
+ url.searchParams.set("client_label", settings.clientLabel);
+ }
+
+ ws = new WebSocket(url.toString());
+
+ ws.onopen = () => {
+ console.log("[Flow2API] Background connected to WebSocket", url.toString());
+ ws.send(JSON.stringify({
+ type: "register",
+ route_key: settings.routeKey,
+ client_label: settings.clientLabel
+ }));
+ if (heartbeatInterval) clearInterval(heartbeatInterval);
+ heartbeatInterval = setInterval(() => {
+ if (ws && ws.readyState === WebSocket.OPEN) {
+ ws.send(JSON.stringify({ type: "ping" }));
+ }
+ }, 20000);
+ };
+
+ let tokenQueue = Promise.resolve();
+
+ ws.onmessage = async (event) => {
+ let data;
+ try {
+ data = JSON.parse(event.data);
+ } catch (e) {
+ return;
+ }
+
+ if (data.type === "register_ack") {
+ console.log("[Flow2API] Registered route key:", data.route_key || "(empty)");
+ return;
+ }
+
+ if (data.type === "get_token") {
+ tokenQueue = tokenQueue.then(() => handleGetToken(data)).catch(err => {
+ console.error("[Flow2API] Queue Error:", err);
+ });
+ }
+ };
+
+ ws.onclose = () => {
+ console.log("[Flow2API] WebSocket Closed. Reconnecting in 2s...");
+ ws = null;
+ if (heartbeatInterval) clearInterval(heartbeatInterval);
+ if (reconnectTimeout) clearTimeout(reconnectTimeout);
+ reconnectTimeout = setTimeout(connectWS, 2000);
+ };
+
+ ws.onerror = (e) => {
+ console.log("[Flow2API] WebSocket Error", e);
+ };
+}
+
+async function handleGetToken(data) {
+ let newTabId = null;
+ try {
+ console.log("[Flow2API] Auto-opening fresh Google Labs tab to avoid token expiry...");
+ const newTab = await chrome.tabs.create({ url: "https://labs.google/fx/tools/flow", active: false });
+ newTabId = newTab.id;
+
+ await waitForTabReady(newTabId);
+ await sleep(1200);
+
+ let successResponse = null;
+ let lastErrorMsg = "No response from tab.";
+ const scriptTimeoutMs = data.action === "VIDEO_GENERATION" ? 30000 : 20000;
+
+ try {
+ const results = await chrome.scripting.executeScript({
+ target: { tabId: newTabId },
+ world: "MAIN",
+ func: async (action, timeoutMs) => {
+ return new Promise((resolve, reject) => {
+ let settled = false;
+ const finish = (fn, value) => {
+ if (settled) return;
+ settled = true;
+ fn(value);
+ };
+ try {
+ function run() {
+ grecaptcha.enterprise.ready(function() {
+ grecaptcha.enterprise.execute("6LdsFiUsAAAAAIjVDZcuLhaHiDn5nnHVXVRQGeMV", { action: action })
+ .then(token => finish(resolve, token))
+ .catch(err => finish(reject, err.message || "reCAPTCHA evaluation failed internally"));
+ });
+ }
+
+ if (typeof grecaptcha !== "undefined" && grecaptcha.enterprise) {
+ run();
+ } else {
+ const s = document.createElement("script");
+ s.src = "https://www.google.com/recaptcha/enterprise.js?render=6LdsFiUsAAAAAIjVDZcuLhaHiDn5nnHVXVRQGeMV";
+ s.onload = run;
+ s.onerror = () => finish(reject, "Failed to load enterprise.js via network");
+ document.head.appendChild(s);
+ }
+
+ setTimeout(() => finish(reject, "Timeout generating reCAPTCHA locally"), timeoutMs);
+ } catch (e) {
+ finish(reject, e.message);
+ }
+ });
+ },
+ args: [data.action || "IMAGE_GENERATION", scriptTimeoutMs]
+ });
+
+ if (results && results[0] && results[0].result) {
+ successResponse = { status: "success", token: results[0].result };
+ }
+ } catch (e) {
+ lastErrorMsg = e.message || "Script execution failed";
+ }
+
+ if (successResponse) {
+ ws.send(JSON.stringify({
+ req_id: data.req_id,
+ status: successResponse.status,
+ token: successResponse.token
+ }));
+ } else {
+ ws.send(JSON.stringify({
+ req_id: data.req_id,
+ status: "error",
+ error: "Extension script failed: " + lastErrorMsg
+ }));
+ }
+ } catch (err) {
+ ws.send(JSON.stringify({
+ req_id: data.req_id,
+ status: "error",
+ error: err.message
+ }));
+ } finally {
+ if (newTabId) {
+ try {
+ await chrome.tabs.remove(newTabId);
+ console.log("[Flow2API] Closed temporary token tab.");
+ } catch (e) {
+ console.log("[Flow2API] Error closing tab:", e);
+ }
+ }
+ }
+}
+
+chrome.storage.onChanged.addListener((changes, areaName) => {
+ if (areaName !== "local") return;
+ if (changes.routeKey || changes.serverUrl || changes.apiKey || changes.clientLabel) {
+ console.log("[Flow2API] Extension settings changed, reconnecting WebSocket...");
+ closeSocket();
+ connectWS();
+ }
+});
+
+connectWS();
diff --git a/extension/content.js b/extension/content.js
new file mode 100644
index 0000000..219e621
--- /dev/null
+++ b/extension/content.js
@@ -0,0 +1,63 @@
+console.log("[Flow2API] Captcha Worker injected.");
+
+function getRecaptchaToken(action) {
+ return new Promise((resolve, reject) => {
+ const reqId = Date.now() + Math.random().toString();
+ const script = document.createElement("script");
+ script.textContent = `
+ try {
+ function runCaptcha() {
+ grecaptcha.enterprise.ready(function() {
+ grecaptcha.enterprise.execute('6LdsFiUsAAAAAIjVDZcuLhaHiDn5nnHVXVRQGeMV', {action: '${action}'})
+ .then(token => window.postMessage({type: 'reCAPTCHA_result', reqId: '${reqId}', token: token}, '*'))
+ .catch(err => window.postMessage({type: 'reCAPTCHA_error', reqId: '${reqId}', error: err.message}, '*'));
+ });
+ }
+
+ if (typeof grecaptcha !== "undefined" && grecaptcha.enterprise) {
+ runCaptcha();
+ } else {
+ const rScript = document.createElement('script');
+ rScript.src = "https://www.google.com/recaptcha/enterprise.js?render=6LdsFiUsAAAAAIjVDZcuLhaHiDn5nnHVXVRQGeMV";
+ rScript.onload = () => { runCaptcha(); };
+ rScript.onerror = () => { window.postMessage({type: 'reCAPTCHA_error', reqId: '${reqId}', error: 'Failed to load enterprise.js'}, '*'); };
+ document.head.appendChild(rScript);
+ }
+ } catch (e) {
+ window.postMessage({type: 'reCAPTCHA_error', reqId: '${reqId}', error: e.message}, '*');
+ }
+ `;
+
+ const listener = (event) => {
+ if (event.source !== window || !event.data) return;
+ if (event.data.reqId === reqId) {
+ window.removeEventListener("message", listener);
+ script.remove();
+ if (event.data.type === 'reCAPTCHA_result') {
+ resolve(event.data.token);
+ } else {
+ reject(new Error(event.data.error || "Unknown reCAPTCHA Error"));
+ }
+ }
+ };
+ window.addEventListener("message", listener);
+ document.documentElement.appendChild(script);
+
+ setTimeout(() => {
+ window.removeEventListener("message", listener);
+ script.remove();
+ reject(new Error("Timeout generating reCAPTCHA"));
+ }, 15000);
+ });
+}
+
+chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
+ if (message.type === "get_token") {
+ console.log("[Flow2API] Generating token for action: " + message.action);
+ getRecaptchaToken(message.action)
+ .then(token => sendResponse({status: "success", token: token}))
+ .catch(err => sendResponse({status: "error", error: err.message}));
+ return true;
+ }
+});
+
diff --git a/extension/manifest.json b/extension/manifest.json
new file mode 100644
index 0000000..f69c33e
--- /dev/null
+++ b/extension/manifest.json
@@ -0,0 +1,29 @@
+{
+ "manifest_version": 3,
+ "name": "Flow2API Captcha Worker",
+ "version": "1.0.0",
+ "description": "Generate Google Labs reCAPTCHA Enterprise tokens for Flow2API.",
+ "permissions": [
+ "storage",
+ "tabs",
+ "scripting"
+ ],
+ "host_permissions": [
+ "https://labs.google/*"
+ ],
+ "background": {
+ "service_worker": "background.js"
+ },
+ "content_scripts": [
+ {
+ "matches": [
+ "https://labs.google/*"
+ ],
+ "js": [
+ "content.js"
+ ],
+ "run_at": "document_idle"
+ }
+ ],
+ "options_page": "options.html"
+}
diff --git a/extension/options.html b/extension/options.html
new file mode 100644
index 0000000..4bded69
--- /dev/null
+++ b/extension/options.html
@@ -0,0 +1,100 @@
+
+
+
+
+
+ Flow2API Captcha Worker Settings
+
+
+
+
+
+
Flow2API Captcha Worker
+
给当前浏览器实例设置一个独立的路由键,Flow2API 后端就能把对应账号的验证码请求固定发到这个实例。
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
管理台 token 里填写同样的 Route Key,比如 9223 对 9223。后端会使用 API Key 校验 WebSocket 连接。
+
+
+
+
+
diff --git a/extension/options.js b/extension/options.js
new file mode 100644
index 0000000..09ebcdb
--- /dev/null
+++ b/extension/options.js
@@ -0,0 +1,73 @@
+const DEFAULT_SETTINGS = {
+ serverUrl: "ws://127.0.0.1:8000/captcha_ws",
+ apiKey: "",
+ routeKey: "",
+ clientLabel: ""
+};
+
+const $ = (id) => document.getElementById(id);
+
+function normalizeSettings(values) {
+ return {
+ serverUrl: (values.serverUrl || DEFAULT_SETTINGS.serverUrl).trim(),
+ apiKey: (values.apiKey || "").trim(),
+ routeKey: (values.routeKey || "").trim(),
+ clientLabel: (values.clientLabel || "").trim()
+ };
+}
+
+function setStatus(message, isError = false) {
+ const status = $("status");
+ status.textContent = message;
+ status.style.color = isError ? "#b91c1c" : "#065f46";
+}
+
+function isValidWsUrl(value) {
+ try {
+ const url = new URL(value);
+ return url.protocol === "ws:" || url.protocol === "wss:";
+ } catch (e) {
+ return false;
+ }
+}
+
+function loadSettings() {
+ chrome.storage.local.get(DEFAULT_SETTINGS, (stored) => {
+ const settings = normalizeSettings(stored);
+ $("serverUrl").value = settings.serverUrl;
+ $("apiKey").value = settings.apiKey;
+ $("routeKey").value = settings.routeKey;
+ $("clientLabel").value = settings.clientLabel;
+ });
+}
+
+function saveSettings() {
+ const settings = normalizeSettings({
+ serverUrl: $("serverUrl").value,
+ apiKey: $("apiKey").value,
+ routeKey: $("routeKey").value,
+ clientLabel: $("clientLabel").value
+ });
+
+ if (!isValidWsUrl(settings.serverUrl)) {
+ setStatus("WebSocket URL 必须以 ws:// 或 wss:// 开头。", true);
+ return;
+ }
+ if (!settings.apiKey) {
+ setStatus("请填写 Flow2API API Key。", true);
+ return;
+ }
+
+ chrome.storage.local.set(settings, () => {
+ if (chrome.runtime.lastError) {
+ setStatus(`保存失败:${chrome.runtime.lastError.message}`, true);
+ return;
+ }
+ setStatus("已保存,后台连接会自动重连。");
+ });
+}
+
+document.addEventListener("DOMContentLoaded", () => {
+ loadSettings();
+ $("saveBtn").addEventListener("click", saveSettings);
+});
diff --git a/src/api/admin.py b/src/api/admin.py
index 5d22051..3905050 100644
--- a/src/api/admin.py
+++ b/src/api/admin.py
@@ -371,13 +371,23 @@ async def _solve_recaptcha_with_api_service(
create_url = f"{base_url.rstrip('/')}/createTask"
get_url = f"{base_url.rstrip('/')}/getTaskResult"
- # Do not use curl_cffi impersonation for captcha API JSON endpoints: some ASGI servers
- # (for example FastAPI/Uvicorn) may receive an empty body and return 422.
+ # 获取代理配置
+ proxies = None
+ try:
+ if proxy_manager:
+ proxy_cfg = await proxy_manager.get_proxy_config()
+ if proxy_cfg and proxy_cfg.enabled and proxy_cfg.proxy_url:
+ proxies = {"http": proxy_cfg.proxy_url, "https": proxy_cfg.proxy_url}
+ except Exception:
+ pass
+
async with AsyncSession() as session:
create_resp = await session.post(
create_url,
json={"clientKey": client_key, "task": task},
- timeout=30
+ impersonate="chrome120",
+ timeout=30,
+ proxies=proxies
)
create_json = create_resp.json()
task_id = create_json.get("taskId")
@@ -390,7 +400,9 @@ async def _solve_recaptcha_with_api_service(
poll_resp = await session.post(
get_url,
json={"clientKey": client_key, "taskId": task_id},
- timeout=30
+ impersonate="chrome120",
+ timeout=30,
+ proxies=proxies
)
poll_json = poll_resp.json()
if poll_json.get("status") == "ready":
@@ -470,6 +482,7 @@ class AddTokenRequest(BaseModel):
project_name: Optional[str] = None
remark: Optional[str] = None
captcha_proxy_url: Optional[str] = None
+ extension_route_key: Optional[str] = None
image_enabled: bool = True
video_enabled: bool = True
image_concurrency: int = -1
@@ -482,6 +495,7 @@ class UpdateTokenRequest(BaseModel):
project_name: Optional[str] = None
remark: Optional[str] = None
captcha_proxy_url: Optional[str] = None
+ extension_route_key: Optional[str] = None
image_enabled: Optional[bool] = None
video_enabled: Optional[bool] = None
image_concurrency: Optional[int] = None
@@ -549,6 +563,7 @@ class ImportTokenItem(BaseModel):
session_token: Optional[str] = None
is_active: bool = True
captcha_proxy_url: Optional[str] = None
+ extension_route_key: Optional[str] = None
image_enabled: bool = True
video_enabled: bool = True
image_concurrency: int = -1
@@ -679,6 +694,7 @@ async def get_tokens(token: str = Depends(verify_admin_token)):
"current_project_id": row.get("current_project_id"), # 🆕 项目ID
"current_project_name": row.get("current_project_name"), # 🆕 项目名称
"captcha_proxy_url": row.get("captcha_proxy_url") or "",
+ "extension_route_key": row.get("extension_route_key") or "",
"image_enabled": bool(row.get("image_enabled")),
"video_enabled": bool(row.get("video_enabled")),
"image_concurrency": row.get("image_concurrency"),
@@ -707,6 +723,7 @@ async def add_token(
project_name=request.project_name,
remark=request.remark,
captcha_proxy_url=request.captcha_proxy_url.strip() if request.captcha_proxy_url is not None else None,
+ extension_route_key=request.extension_route_key.strip() if request.extension_route_key is not None else None,
image_enabled=request.image_enabled,
video_enabled=request.video_enabled,
image_concurrency=request.image_concurrency,
@@ -770,6 +787,7 @@ async def update_token(
project_name=request.project_name,
remark=request.remark,
captcha_proxy_url=request.captcha_proxy_url.strip() if request.captcha_proxy_url is not None else None,
+ extension_route_key=request.extension_route_key.strip() if request.extension_route_key is not None else None,
image_enabled=request.image_enabled,
video_enabled=request.video_enabled,
image_concurrency=request.image_concurrency,
@@ -973,6 +991,7 @@ async def import_tokens(
at=at,
at_expires=at_expires,
captcha_proxy_url=item.captcha_proxy_url.strip() if item.captcha_proxy_url is not None else None,
+ extension_route_key=item.extension_route_key.strip() if item.extension_route_key is not None else None,
image_enabled=item.image_enabled,
video_enabled=item.video_enabled,
image_concurrency=item.image_concurrency,
@@ -986,6 +1005,7 @@ async def import_tokens(
existing.at = at
existing.at_expires = at_expires
existing.captcha_proxy_url = item.captcha_proxy_url
+ existing.extension_route_key = item.extension_route_key
existing.image_enabled = item.image_enabled
existing.video_enabled = item.video_enabled
existing.image_concurrency = item.image_concurrency
@@ -996,6 +1016,7 @@ async def import_tokens(
new_token = await token_manager.add_token(
st=st,
captcha_proxy_url=item.captcha_proxy_url.strip() if item.captcha_proxy_url is not None else None,
+ extension_route_key=item.extension_route_key.strip() if item.extension_route_key is not None else None,
image_enabled=item.image_enabled,
video_enabled=item.video_enabled,
image_concurrency=item.image_concurrency,
@@ -1669,8 +1690,336 @@ async def test_captcha_score(
_request: Optional[CaptchaScoreTestRequest] = None,
_token: str = Depends(verify_admin_token)
):
- """分数测试已禁用。"""
- raise HTTPException(status_code=403, detail="已禁用分数测试")
+ """使用当前打码方式获取 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", "remote_browser"}
+ 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 == "remote_browser":
+ score_payload = await _score_test_with_remote_browser_service(
+ website_url=website_url,
+ website_key=website_key,
+ verify_url=verify_url,
+ action=action,
+ enterprise=enterprise,
+ )
+ if isinstance(score_payload, dict):
+ if score_payload.get("success") is False:
+ raise RuntimeError(score_payload.get("message") or "远程打码分数测试失败")
+ 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 "remote_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)
+ fingerprint = score_payload.get("fingerprint") if isinstance(score_payload.get("fingerprint"), dict) else None
+ elif captcha_method in SUPPORTED_API_CAPTCHA_METHODS:
+ if captcha_method == "capsolver" and "antcpt.com" in website_url:
+ # CapSolver specifically blocks antcpt.com. Test against labs.google to verify API key config.
+ token_value = await _solve_recaptcha_with_api_service(
+ method=captcha_method,
+ website_url="https://labs.google/",
+ website_key="6LdsFiUsAAAAAIjVDZcuLhaHiDn5nnHVXVRQGeMV",
+ action="IMAGE_GENERATION",
+ enterprise=True
+ )
+ if token_value:
+ if token_elapsed_ms <= 0:
+ token_elapsed_ms = int((time.time() - token_start) * 1000)
+ return {
+ "success": True,
+ "message": "CapSolver不支持antcpt。已成功用 Google Labs 测试连通性",
+ "captcha_method": captcha_method,
+ "website_url": "https://labs.google/",
+ "website_key": "6LdsFiUsAAAAAIjVDZcuLhaHiDn5nnHVXVRQGeMV",
+ "action": "IMAGE_GENERATION",
+ "verify_url": "",
+ "enterprise": True,
+ "token_acquired": True,
+ "token_preview": _mask_token(token_value),
+ "token_elapsed_ms": token_elapsed_ms,
+ "verify_elapsed_ms": 0,
+ "verify_http_status": 200,
+ "score": 0.9,
+ "verify_result": {"success": True, "message": "跳过分数校验"},
+ "verify_request_meta": {},
+ "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)
+ }
+ else:
+ 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)
+
+ # 远程有头打码的 custom-score 可能由页面内直接完成校验,
+ # 在部分实现里不会显式回传 token,本地按 verify_result 兜底判定。
+ if captcha_method == "remote_browser" and not token_value and isinstance(verify_result, dict):
+ if verify_result.get("success") is True:
+ token_value = verify_result.get("token") or verify_result.get("gRecaptchaResponse") or "__verified_by_remote__"
+
+ 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 ==========
diff --git a/src/api/routes.py b/src/api/routes.py
index 7d41ec9..c843a59 100644
--- a/src/api/routes.py
+++ b/src/api/routes.py
@@ -9,10 +9,10 @@ import re
from urllib.parse import urlparse
from curl_cffi.requests import AsyncSession
-from fastapi import APIRouter, Depends, HTTPException, Query, Request
+from fastapi import APIRouter, Depends, HTTPException, Query, Request, WebSocket, WebSocketDisconnect
from fastapi.responses import JSONResponse, StreamingResponse
-from ..core.auth import verify_api_key_flexible
+from ..core.auth import AuthManager, verify_api_key_flexible
from ..core.logger import debug_logger
from ..core.model_resolver import get_base_model_aliases, resolve_model_name
from ..core.models import (
@@ -22,6 +22,7 @@ from ..core.models import (
GeminiGenerateContentRequest,
)
from ..services.generation_handler import MODEL_CONFIG, GenerationHandler
+from ..services.browser_captcha_extension import ExtensionCaptchaService
router = APIRouter()
@@ -78,6 +79,7 @@ class NormalizedGenerationRequest:
prompt: str
images: List[bytes]
messages: Optional[List[ChatMessage]] = None
+ video_media_id: Optional[str] = None
def set_generation_handler(handler: GenerationHandler):
@@ -286,11 +288,18 @@ def _sanitize_media_prompt(prompt: str) -> str:
async def _extract_prompt_and_images_from_openai_messages(
messages: List[ChatMessage],
-) -> tuple[str, List[bytes]]:
+) -> tuple[str, List[bytes], Optional[str]]:
+ """Extract prompt, images, and optional video_media_id from messages.
+
+ Returns:
+ (prompt, images, video_media_id)
+ video_media_id is set when an image_url starts with "extend://"
+ """
last_message = messages[-1]
content = last_message.content
prompt_parts: List[str] = []
images: List[bytes] = []
+ video_media_id: Optional[str] = None
if isinstance(content, str):
prompt_parts.append(content)
@@ -303,10 +312,14 @@ async def _extract_prompt_and_images_from_openai_messages(
prompt_parts.append(text)
elif item_type == "image_url":
image_url = item.get("image_url", {}).get("url", "")
- images.append(await _load_image_bytes_from_uri(image_url))
+ # extend://MEDIA_ID 用于视频续写
+ if image_url.startswith("extend://"):
+ video_media_id = image_url[len("extend://"):]
+ else:
+ images.append(await _load_image_bytes_from_uri(image_url))
prompt = "\n".join(part for part in prompt_parts if part).strip()
- return prompt, images
+ return prompt, images, video_media_id
async def _append_openai_reference_images(
@@ -411,7 +424,7 @@ async def _normalize_openai_request(
request: ChatCompletionRequest,
) -> NormalizedGenerationRequest:
if request.messages:
- prompt, images = await _extract_prompt_and_images_from_openai_messages(
+ prompt, images, video_media_id = await _extract_prompt_and_images_from_openai_messages(
request.messages
)
if request.image and not images:
@@ -423,6 +436,7 @@ async def _normalize_openai_request(
prompt=prompt,
images=images,
messages=request.messages,
+ video_media_id=video_media_id,
)
if request.contents:
@@ -472,6 +486,7 @@ async def _collect_non_stream_result(
prompt: str,
images: List[bytes],
base_url_override: Optional[str] = None,
+ video_media_id: Optional[str] = None,
) -> str:
handler = _ensure_generation_handler()
result = None
@@ -481,6 +496,7 @@ async def _collect_non_stream_result(
images=images if images else None,
stream=False,
base_url_override=base_url_override,
+ video_media_id=video_media_id,
):
result = chunk
@@ -709,6 +725,7 @@ async def _iterate_openai_stream(
images=normalized.images if normalized.images else None,
stream=True,
base_url_override=base_url_override,
+ video_media_id=normalized.video_media_id,
):
if chunk.startswith("data: "):
yield chunk
@@ -732,6 +749,7 @@ async def _iterate_gemini_stream(
images=normalized.images if normalized.images else None,
stream=True,
base_url_override=base_url_override,
+ video_media_id=normalized.video_media_id,
):
if chunk.startswith("data: "):
payload_text = chunk[6:].strip()
@@ -854,14 +872,13 @@ async def create_chat_completion(
},
)
- payload = _enrich_payload_with_direct_url(
- _parse_handler_result(
- await _collect_non_stream_result(
- normalized.model,
- normalized.prompt,
- normalized.images,
- request_base_url,
- )
+ payload = _parse_handler_result(
+ await _collect_non_stream_result(
+ normalized.model,
+ normalized.prompt,
+ normalized.images,
+ base_url_override=request_base_url,
+ video_media_id=normalized.video_media_id,
)
)
return _build_openai_json_response(payload)
@@ -894,7 +911,8 @@ async def generate_content(
normalized.model,
normalized.prompt,
normalized.images,
- request_base_url,
+ base_url_override=request_base_url,
+ video_media_id=normalized.video_media_id,
)
)
)
@@ -953,3 +971,32 @@ async def stream_generate_content(
status_code=500,
content=_build_gemini_error_payload(500, str(exc)),
)
+
+@router.websocket("/captcha_ws")
+async def captcha_websocket_endpoint(websocket: WebSocket):
+ from ..core.logger import debug_logger
+ api_key = (
+ websocket.query_params.get("key")
+ or websocket.query_params.get("api_key")
+ or websocket.headers.get("x-goog-api-key")
+ or ""
+ ).strip()
+ authorization = (websocket.headers.get("authorization") or "").strip()
+ if authorization.lower().startswith("bearer "):
+ api_key = authorization[7:].strip()
+
+ if not api_key or not AuthManager.verify_api_key(api_key):
+ await websocket.close(code=1008)
+ return
+
+ service = await ExtensionCaptchaService.get_instance()
+ await service.connect(websocket)
+ try:
+ while True:
+ data = await websocket.receive_text()
+ await service.handle_message(websocket, data)
+ except WebSocketDisconnect:
+ service.disconnect(websocket)
+ except Exception as e:
+ debug_logger.log_error(f"WebSocket error: {e}")
+ service.disconnect(websocket)
diff --git a/src/core/config.py b/src/core/config.py
index 10f3449..ab3b968 100644
--- a/src/core/config.py
+++ b/src/core/config.py
@@ -12,8 +12,11 @@ class Config:
self._admin_password: Optional[str] = None
def _load_config(self) -> Dict[str, Any]:
- """Load configuration from setting.toml"""
- config_path = Path(__file__).parent.parent.parent / "config" / "setting.toml"
+ """Load configuration from setting.toml, falling back to the example file."""
+ config_dir = Path(__file__).parent.parent.parent / "config"
+ config_path = config_dir / "setting.toml"
+ if not config_path.exists():
+ config_path = config_dir / "setting_example.toml"
with open(config_path, "rb") as f:
return tomli.load(f)
diff --git a/src/core/database.py b/src/core/database.py
index 02a1e1d..be06852 100644
--- a/src/core/database.py
+++ b/src/core/database.py
@@ -408,6 +408,7 @@ class Database:
("image_concurrency", "INTEGER DEFAULT -1"),
("video_concurrency", "INTEGER DEFAULT -1"),
("captcha_proxy_url", "TEXT"), # token级打码代理
+ ("extension_route_key", "TEXT"), # extension 模式路由键
("ban_reason", "TEXT"), # 禁用原因
("banned_at", "TIMESTAMP"), # 禁用时间
]
@@ -567,6 +568,7 @@ class Database:
image_concurrency INTEGER DEFAULT -1,
video_concurrency INTEGER DEFAULT -1,
captcha_proxy_url TEXT,
+ extension_route_key TEXT,
ban_reason TEXT,
banned_at TIMESTAMP
)
@@ -845,13 +847,15 @@ class Database:
cursor = await db.execute("""
INSERT INTO tokens (st, at, at_expires, email, name, remark, is_active,
credits, user_paygate_tier, current_project_id, current_project_name,
- image_enabled, video_enabled, image_concurrency, video_concurrency, captcha_proxy_url)
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
+ image_enabled, video_enabled, image_concurrency, video_concurrency,
+ captcha_proxy_url, extension_route_key)
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""", (token.st, token.at, token.at_expires, token.email, token.name, token.remark,
token.is_active, token.credits, token.user_paygate_tier,
token.current_project_id, token.current_project_name,
token.image_enabled, token.video_enabled,
- token.image_concurrency, token.video_concurrency, token.captcha_proxy_url))
+ token.image_concurrency, token.video_concurrency,
+ token.captcha_proxy_url, token.extension_route_key))
await db.commit()
token_id = cursor.lastrowid
diff --git a/src/core/model_resolver.py b/src/core/model_resolver.py
index 558cfa1..96f5180 100644
--- a/src/core/model_resolver.py
+++ b/src/core/model_resolver.py
@@ -184,6 +184,11 @@ VIDEO_BASE_MODELS = {
"landscape": "veo_3_1_r2v_fast_ultra_relaxed",
"portrait": "veo_3_1_r2v_fast_portrait_ultra_relaxed",
},
+ # Extend models (视频续写)
+ "veo_3_1_extend": {
+ "landscape": "veo_3_1_extend",
+ "portrait": "veo_3_1_extend_portrait",
+ },
}
diff --git a/src/core/models.py b/src/core/models.py
index c9f1d37..df65b0b 100644
--- a/src/core/models.py
+++ b/src/core/models.py
@@ -42,6 +42,7 @@ class Token(BaseModel):
# 打码代理(token 级,可覆盖全局浏览器打码代理)
captcha_proxy_url: Optional[str] = None
+ extension_route_key: Optional[str] = None
# 429禁用相关
ban_reason: Optional[str] = None # 禁用原因: "429_rate_limit" 或 None
diff --git a/src/services/browser_captcha_extension.py b/src/services/browser_captcha_extension.py
new file mode 100644
index 0000000..e323a9e
--- /dev/null
+++ b/src/services/browser_captcha_extension.py
@@ -0,0 +1,214 @@
+import asyncio
+import json
+import time
+import uuid
+from dataclasses import dataclass, field
+from typing import Any, Dict, Optional
+
+from fastapi import WebSocket
+
+from ..core.logger import debug_logger
+
+
+@dataclass
+class ExtensionConnection:
+ websocket: WebSocket
+ route_key: str = ""
+ client_label: str = ""
+ connected_at: float = field(default_factory=time.time)
+
+
+class ExtensionCaptchaService:
+ _instance: Optional["ExtensionCaptchaService"] = None
+ _lock = asyncio.Lock()
+
+ def __init__(self, db=None):
+ self.db = db
+ self.active_connections: list[ExtensionConnection] = []
+ self.pending_requests: dict[str, tuple[asyncio.Future, WebSocket]] = {}
+
+ @classmethod
+ async def get_instance(cls, db=None) -> "ExtensionCaptchaService":
+ if cls._instance is None:
+ async with cls._lock:
+ if cls._instance is None:
+ cls._instance = cls(db=db)
+ elif db is not None and cls._instance.db is None:
+ cls._instance.db = db
+ return cls._instance
+
+ async def connect(self, websocket: WebSocket):
+ await websocket.accept()
+ conn = ExtensionConnection(
+ websocket=websocket,
+ route_key=(websocket.query_params.get("route_key") or "").strip(),
+ client_label=(websocket.query_params.get("client_label") or "").strip(),
+ )
+ self.active_connections.append(conn)
+ debug_logger.log_info(
+ f"[Extension Captcha] Client connected. Total: {len(self.active_connections)}, "
+ f"route_key={conn.route_key or '-'}, label={conn.client_label or '-'}"
+ )
+
+ def disconnect(self, websocket: WebSocket):
+ for conn in list(self.active_connections):
+ if conn.websocket is websocket:
+ self.active_connections.remove(conn)
+ debug_logger.log_info(
+ f"[Extension Captcha] Client disconnected. Total: {len(self.active_connections)}, "
+ f"route_key={conn.route_key or '-'}, label={conn.client_label or '-'}"
+ )
+ return
+
+ def _find_connection(self, websocket: WebSocket) -> Optional[ExtensionConnection]:
+ for conn in self.active_connections:
+ if conn.websocket is websocket:
+ return conn
+ return None
+
+ def _select_connection(self, route_key: str) -> Optional[ExtensionConnection]:
+ normalized_key = (route_key or "").strip()
+ if normalized_key:
+ for conn in self.active_connections:
+ if conn.route_key == normalized_key:
+ return conn
+ return None
+ # Empty token routes are only allowed to use an empty extension route.
+ # A keyed route such as "9223" belongs to a specific browser/account
+ # and must never be borrowed by another token just because it is the
+ # only extension online.
+ for conn in self.active_connections:
+ if not conn.route_key:
+ return conn
+ return None
+
+ def _describe_routes(self) -> str:
+ labels = []
+ for conn in self.active_connections:
+ label = conn.route_key or "(empty)"
+ if conn.client_label:
+ label = f"{label}:{conn.client_label}"
+ labels.append(label)
+ return ", ".join(labels)
+
+ def describe_routes(self) -> str:
+ return self._describe_routes()
+
+ async def _send_ack(self, websocket: WebSocket, payload: Dict[str, Any]):
+ try:
+ await websocket.send_text(json.dumps(payload))
+ except Exception:
+ pass
+
+ async def _resolve_route_key(self, token_id: Optional[int]) -> str:
+ if not token_id or not self.db:
+ return ""
+ try:
+ token = await self.db.get_token(token_id)
+ if token and token.extension_route_key:
+ return token.extension_route_key.strip()
+ except Exception as e:
+ debug_logger.log_warning(f"[Extension Captcha] Failed to resolve route key for token {token_id}: {e}")
+ return ""
+
+ def _has_connection_for_route_key(self, route_key: str) -> bool:
+ return self._select_connection(route_key) is not None
+
+ async def has_connection_for_token(self, token_id: Optional[int]) -> tuple[bool, str]:
+ route_key = await self._resolve_route_key(token_id)
+ return self._has_connection_for_route_key(route_key), route_key
+
+ async def handle_message(self, websocket: WebSocket, data: str):
+ try:
+ payload = json.loads(data)
+ message_type = payload.get("type")
+
+ if message_type == "register":
+ conn = self._find_connection(websocket)
+ if conn:
+ conn.route_key = (payload.get("route_key") or conn.route_key or "").strip()
+ conn.client_label = (payload.get("client_label") or conn.client_label or "").strip()
+ debug_logger.log_info(
+ f"[Extension Captcha] Client registered route_key={conn.route_key or '-'}, "
+ f"label={conn.client_label or '-'}"
+ )
+ await self._send_ack(
+ websocket,
+ {
+ "type": "register_ack",
+ "route_key": conn.route_key,
+ "client_label": conn.client_label,
+ },
+ )
+ return
+
+ req_id = payload.get("req_id")
+ if req_id and req_id in self.pending_requests:
+ future, owner_websocket = self.pending_requests[req_id]
+ if websocket is not owner_websocket:
+ debug_logger.log_warning(f"[Extension Captcha] Ignoring response from non-owner connection: {req_id}")
+ return
+ if not future.done():
+ future.set_result(payload)
+ except Exception as e:
+ debug_logger.log_error(f"[Extension Captcha] Error handling message: {e}")
+
+ async def get_token(
+ self,
+ project_id: str,
+ action: str = "IMAGE_GENERATION",
+ timeout: int = 20,
+ token_id: Optional[int] = None,
+ ) -> Optional[str]:
+ if not self.active_connections:
+ debug_logger.log_warning("[Extension Captcha] No active extension connections available.")
+ raise RuntimeError("Chrome Extension not connected or Google Labs tab not open.")
+
+ route_key = await self._resolve_route_key(token_id)
+ conn = self._select_connection(route_key)
+ if conn is None:
+ available = self._describe_routes() or "none"
+ raise RuntimeError(
+ f"No Chrome Extension connection matches token_id={token_id} route_key='{route_key}'. "
+ f"Available route keys: {available}"
+ )
+
+ req_id = f"req_{uuid.uuid4().hex}"
+ future = asyncio.get_running_loop().create_future()
+ self.pending_requests[req_id] = (future, conn.websocket)
+
+ request_data = {
+ "type": "get_token",
+ "req_id": req_id,
+ "action": action,
+ "project_id": project_id,
+ "route_key": route_key,
+ }
+
+ try:
+ debug_logger.log_info(
+ f"[Extension Captcha] Dispatching token request via route_key={route_key or '-'}, "
+ f"label={conn.client_label or '-'}, project_id={project_id}, action={action}"
+ )
+ await conn.websocket.send_text(json.dumps(request_data))
+ result = await asyncio.wait_for(future, timeout=timeout)
+
+ if result.get("status") == "success":
+ return result.get("token")
+
+ error_msg = result.get("error")
+ debug_logger.log_error(f"[Extension Captcha] Error from extension: {error_msg}")
+ return None
+
+ except asyncio.TimeoutError:
+ debug_logger.log_error(f"[Extension Captcha] Timeout waiting for token (req_id: {req_id})")
+ return None
+ except Exception as e:
+ debug_logger.log_error(f"[Extension Captcha] Communication error: {e}")
+ return None
+ finally:
+ self.pending_requests.pop(req_id, None)
+
+ async def report_flow_error(self, project_id: str, error_reason: str, error_message: str = ""):
+ _ = project_id, error_message
+ debug_logger.log_warning(f"[Extension Captcha] Flow error reported (ignoring): {error_reason}")
diff --git a/src/services/flow_client.py b/src/services/flow_client.py
index dae88bc..c4e66c7 100644
--- a/src/services/flow_client.py
+++ b/src/services/flow_client.py
@@ -39,19 +39,18 @@ class FlowClient:
)
self._remote_browser_prefill_last_sent: Dict[str, float] = {}
- # Default "real browser" headers (Android Chrome style) to reduce upstream 4xx/5xx instability.
+ # Default "real browser" headers (macOS Chrome Desktop) to reduce upstream 4xx/5xx instability.
# These will be applied as defaults (won't override caller-provided headers).
+ # NOTE: Must match the UA platform (macOS) generated by _generate_user_agent.
self._default_client_headers = {
- "sec-ch-ua-mobile": "?1",
- "sec-ch-ua-platform": "\"Android\"",
+ "sec-ch-ua-mobile": "?0",
+ "sec-ch-ua-platform": "\"macOS\"",
"sec-fetch-dest": "empty",
"sec-fetch-mode": "cors",
"sec-fetch-site": "cross-site",
"x-browser-channel": "stable",
"x-browser-copyright": "Copyright 2026 Google LLC. All Rights reserved.",
- "x-browser-validation": "UujAs0GAwdnCJ9nvrswZ+O+oco0=",
"x-browser-year": "2026",
- "x-client-data": "CJS2yQEIpLbJAQipncoBCNj9ygEIlKHLAQiFoM0BGP6lzwE="
}
# 发车策略改为“请求到就发”:
# 不在 flow2api 本地对提交做批次整形或排队,避免把同批请求打成阶梯。
@@ -78,50 +77,10 @@ class FlowClient:
seed = int(hashlib.md5(account_id.encode()).hexdigest()[:8], 16)
rng = random.Random(seed)
- # Chrome 版本池
- chrome_versions = ["130.0.0.0", "131.0.0.0", "132.0.0.0", "129.0.0.0"]
- # Firefox 版本池
- firefox_versions = ["133.0", "132.0", "131.0", "134.0"]
- # Safari 版本池
- safari_versions = ["18.2", "18.1", "18.0", "17.6"]
- # Edge 版本池
- edge_versions = ["130.0.0.0", "131.0.0.0", "132.0.0.0"]
-
- # 操作系统配置
- os_configs = [
- # Windows
- {
- "platform": "Windows NT 10.0; Win64; x64",
- "browsers": [
- lambda r: f"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/{r.choice(chrome_versions)} Safari/537.36",
- lambda r: f"Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:{r.choice(firefox_versions).split('.')[0]}.0) Gecko/20100101 Firefox/{r.choice(firefox_versions)}",
- lambda r: f"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/{r.choice(chrome_versions)} Safari/537.36 Edg/{r.choice(edge_versions)}",
- ]
- },
- # macOS
- {
- "platform": "Macintosh; Intel Mac OS X 10_15_7",
- "browsers": [
- lambda r: f"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/{r.choice(chrome_versions)} Safari/537.36",
- lambda r: f"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/{r.choice(safari_versions)} Safari/605.1.15",
- lambda r: f"Mozilla/5.0 (Macintosh; Intel Mac OS X 14.{r.randint(0, 7)}; rv:{r.choice(firefox_versions).split('.')[0]}.0) Gecko/20100101 Firefox/{r.choice(firefox_versions)}",
- ]
- },
- # Linux
- {
- "platform": "X11; Linux x86_64",
- "browsers": [
- lambda r: f"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/{r.choice(chrome_versions)} Safari/537.36",
- lambda r: f"Mozilla/5.0 (X11; Linux x86_64; rv:{r.choice(firefox_versions).split('.')[0]}.0) Gecko/20100101 Firefox/{r.choice(firefox_versions)}",
- lambda r: f"Mozilla/5.0 (X11; Ubuntu; Linux x86_64; rv:{r.choice(firefox_versions).split('.')[0]}.0) Gecko/20100101 Firefox/{r.choice(firefox_versions)}",
- ]
- }
- ]
-
- # 使用固定种子随机选择操作系统和浏览器
- os_config = rng.choice(os_configs)
- browser_generator = rng.choice(os_config["browsers"])
- user_agent = browser_generator(rng)
+ # Chrome 版本池 - 匹配真实 Mac mini Chrome 147 环境
+ chrome_versions = ["147.0.7727.56", "146.0.7688.92", "145.0.7649.100"]
+ ch_version = rng.choice(chrome_versions)
+ user_agent = f"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/{ch_version} Safari/537.36"
# 缓存结果
self._user_agent_cache[account_id] = user_agent
@@ -155,7 +114,9 @@ class FlowClient:
at_token: Optional[str] = None,
timeout: Optional[int] = None,
use_media_proxy: bool = False,
- respect_fingerprint_proxy: bool = True
+ respect_fingerprint_proxy: bool = True,
+ force_no_proxy: bool = False,
+ allow_urllib_fallback: bool = True
) -> Dict[str, Any]:
"""统一HTTP请求处理
@@ -171,22 +132,24 @@ class FlowClient:
timeout: 自定义超时时间(秒),不传则使用默认值
use_media_proxy: 是否使用图片上传/下载代理
respect_fingerprint_proxy: 是否优先使用打码浏览器指纹里的代理
+ allow_urllib_fallback: curl_cffi 网络失败时是否允许 urllib 二次兜底
"""
fingerprint = self._request_fingerprint_ctx.get()
proxy_url = None
- if self.proxy_manager:
- if use_media_proxy and hasattr(self.proxy_manager, "get_media_proxy_url"):
- proxy_url = await self.proxy_manager.get_media_proxy_url()
- elif hasattr(self.proxy_manager, "get_request_proxy_url"):
- proxy_url = await self.proxy_manager.get_request_proxy_url()
- else:
- proxy_url = await self.proxy_manager.get_proxy_url()
+ if not force_no_proxy:
+ if self.proxy_manager:
+ if use_media_proxy and hasattr(self.proxy_manager, "get_media_proxy_url"):
+ proxy_url = await self.proxy_manager.get_media_proxy_url()
+ elif hasattr(self.proxy_manager, "get_request_proxy_url"):
+ proxy_url = await self.proxy_manager.get_request_proxy_url()
+ else:
+ proxy_url = await self.proxy_manager.get_proxy_url()
- if respect_fingerprint_proxy and isinstance(fingerprint, dict) and "proxy_url" in fingerprint:
- proxy_url = fingerprint.get("proxy_url")
- if proxy_url == "":
- proxy_url = None
+ if respect_fingerprint_proxy and isinstance(fingerprint, dict) and "proxy_url" in fingerprint:
+ proxy_url = fingerprint.get("proxy_url")
+ if proxy_url == "":
+ proxy_url = None
request_timeout = timeout or self.timeout
if headers is None:
@@ -234,6 +197,22 @@ class FlowClient:
for key, value in self._default_client_headers.items():
headers.setdefault(key, value)
+ # Dynamic fix for sec-ch-ua headers when fingerprint is missing to avoid UA/Platform mismatch
+ if not isinstance(fingerprint, dict) or not fingerprint.get("sec_ch_ua_platform"):
+ ua_lower = headers.get("User-Agent", "").lower()
+ if "android" in ua_lower:
+ headers["sec-ch-ua-platform"] = "\"Android\""
+ headers["sec-ch-ua-mobile"] = "?1"
+ elif "mac" in ua_lower:
+ headers["sec-ch-ua-platform"] = "\"macOS\""
+ headers["sec-ch-ua-mobile"] = "?0"
+ elif "linux" in ua_lower or "x11" in ua_lower:
+ headers["sec-ch-ua-platform"] = "\"Linux\""
+ headers["sec-ch-ua-mobile"] = "?0"
+ else:
+ headers["sec-ch-ua-platform"] = "\"Windows\""
+ headers["sec-ch-ua-mobile"] = "?0"
+
# Log request
if config.debug_enabled:
if isinstance(fingerprint, dict):
@@ -252,14 +231,14 @@ class FlowClient:
start_time = time.time()
try:
- async with AsyncSession() as session:
+ async with AsyncSession(trust_env=False) as session:
if method.upper() == "GET":
response = await session.get(
url,
headers=headers,
proxy=proxy_url,
timeout=request_timeout,
- impersonate="chrome110"
+ impersonate="chrome124"
)
else: # POST
response = await session.post(
@@ -268,7 +247,7 @@ class FlowClient:
json=json_data,
proxy=proxy_url,
timeout=request_timeout,
- impersonate="chrome110"
+ impersonate="chrome124"
)
duration_ms = (time.time() - start_time) * 1000
@@ -322,7 +301,7 @@ class FlowClient:
debug_logger.log_error(f"[API FAILED] Request Body: {json_data}")
debug_logger.log_error(f"[API FAILED] Exception: {error_msg}")
- if self._should_fallback_to_urllib(error_msg):
+ if allow_urllib_fallback and self._should_fallback_to_urllib(error_msg):
debug_logger.log_warning(
f"[HTTP FALLBACK] curl_cffi 请求失败,回退 urllib: {method.upper()} {url}"
)
@@ -436,6 +415,19 @@ class FlowClient:
"operation timed out",
])
+ def _is_proxy_connection_error(self, error: Exception) -> bool:
+ """识别本地/上游代理不可用导致的连接失败。"""
+ error_lower = str(error).lower()
+ return any(keyword in error_lower for keyword in [
+ "failed to connect to 127.0.0.1 port",
+ "failed to connect to localhost port",
+ "proxyerror",
+ "proxy error",
+ "failed to connect to proxy",
+ "couldn't connect to server",
+ "curl: (7)",
+ ])
+
def _is_retryable_network_error(self, error_str: str) -> bool:
"""识别可重试的 TLS/连接类网络错误。"""
error_lower = (error_str or "").lower()
@@ -449,6 +441,10 @@ class FlowClient:
"connection reset",
"connection aborted",
"connection was reset",
+ "connection timed out",
+ "curl: (28)",
+ "timed out",
+ "timeout",
"unexpected eof",
"empty reply from server",
"recv failure",
@@ -462,6 +458,38 @@ class FlowClient:
"""控制轻量控制面请求的超时,避免认证/项目接口长时间挂起。"""
return max(5, min(int(self.timeout or 0) or 120, 10))
+ def _get_video_submit_timeout(self) -> int:
+ """视频提交接口应快速返回 operation,避免单次网络挂死拖满整条链路。"""
+ return max(30, min(int(self.timeout or 0) or 120, 75))
+
+ def _get_video_poll_timeout(self) -> int:
+ """视频状态查询是轻量轮询,请求超时不应超过下一轮轮询太久。"""
+ return max(10, min(int(self.timeout or 0) or 120, 45))
+
+ async def _make_video_api_request(
+ self,
+ url: str,
+ json_data: Dict[str, Any],
+ at: str,
+ timeout: int,
+ ) -> Dict[str, Any]:
+ """视频 API 加硬截止,避免 curl_cffi 底层偶发卡住导致整条请求悬挂。"""
+ try:
+ return await asyncio.wait_for(
+ self._make_request(
+ method="POST",
+ url=url,
+ json_data=json_data,
+ use_at=True,
+ at_token=at,
+ timeout=timeout,
+ allow_urllib_fallback=False
+ ),
+ timeout=timeout + 5
+ )
+ except asyncio.TimeoutError as exc:
+ raise Exception(f"Flow video API request timed out after {timeout}s") from exc
+
async def _acquire_image_launch_gate(
self,
token_id: Optional[int],
@@ -603,14 +631,29 @@ class FlowClient:
}
"""
url = f"{self.labs_base_url}/auth/session"
- result = await self._make_request(
- method="GET",
- url=url,
- use_st=True,
- st_token=st,
- timeout=self._get_control_plane_timeout(),
- )
- return result
+ try:
+ return await self._make_request(
+ method="GET",
+ url=url,
+ use_st=True,
+ st_token=st,
+ timeout=self._get_control_plane_timeout(),
+ )
+ except Exception as e:
+ if not self._is_proxy_connection_error(e):
+ raise
+
+ debug_logger.log_warning(
+ f"[AUTH] ST->AT failed via configured proxy, retrying direct connection: {e}"
+ )
+ return await self._make_request(
+ method="GET",
+ url=url,
+ use_st=True,
+ st_token=st,
+ timeout=self._get_control_plane_timeout(),
+ force_no_proxy=True,
+ )
# ========== 项目管理 (使用ST) ==========
@@ -842,6 +885,19 @@ class FlowClient:
max_retries = config.flow_max_retries
last_error: Optional[Exception] = None
+ captcha_method = getattr(config, "captcha_method", "personal")
+ if captcha_method == "personal":
+ try:
+ from .browser_captcha_personal import BrowserCaptchaService
+ service = await BrowserCaptchaService.get_instance(self.db)
+ fingerprint = service.get_last_fingerprint()
+ if not fingerprint:
+ await service.get_token(project_id, "uploadUserImage")
+ fingerprint = service.get_last_fingerprint()
+ self._set_request_fingerprint(fingerprint)
+ except Exception as e:
+ debug_logger.log_error(f"[UPLOAD] Failed to pre-fetch fingerprint: {e}")
+
for retry_attempt in range(max_retries):
try:
new_result = await self._make_request(
@@ -1295,12 +1351,11 @@ class FlowClient:
json_data["useV2ModelConfig"] = True
try:
- result = await self._make_request(
- method="POST",
+ result = await self._make_video_api_request(
url=url,
json_data=json_data,
- use_at=True,
- at_token=at
+ at=at,
+ timeout=self._get_video_submit_timeout()
)
return result
except Exception as e:
@@ -1425,12 +1480,11 @@ class FlowClient:
}
try:
- result = await self._make_request(
- method="POST",
+ result = await self._make_video_api_request(
url=url,
json_data=json_data,
- use_at=True,
- at_token=at
+ at=at,
+ timeout=self._get_video_submit_timeout()
)
return result
except Exception as e:
@@ -1558,12 +1612,11 @@ class FlowClient:
json_data["useV2ModelConfig"] = True
try:
- result = await self._make_request(
- method="POST",
+ result = await self._make_video_api_request(
url=url,
json_data=json_data,
- use_at=True,
- at_token=at
+ at=at,
+ timeout=self._get_video_submit_timeout()
)
return result
except Exception as e:
@@ -1687,12 +1740,11 @@ class FlowClient:
json_data["useV2ModelConfig"] = True
try:
- result = await self._make_request(
- method="POST",
+ result = await self._make_video_api_request(
url=url,
json_data=json_data,
- use_at=True,
- at_token=at
+ at=at,
+ timeout=self._get_video_submit_timeout()
)
return result
except Exception as e:
@@ -1714,6 +1766,286 @@ class FlowClient:
# 所有重试都失败
raise last_error
+ # ========== 视频续写 (Video Extend) ==========
+
+ async def generate_video_extend(
+ self,
+ at: str,
+ project_id: str,
+ prompt: str,
+ model_key: str,
+ aspect_ratio: str,
+ video_media_id: str,
+ user_paygate_tier: str = "PAYGATE_TIER_ONE",
+ token_id: Optional[int] = None,
+ token_video_concurrency: Optional[int] = None,
+ ) -> dict:
+ """视频续写,基于已生成的视频延伸7秒
+
+ Args:
+ at: Access Token
+ project_id: 项目ID
+ prompt: 续写提示词
+ model_key: veo_3_1_extend_portrait / veo_3_1_extend 等
+ aspect_ratio: 视频宽高比
+ video_media_id: 源视频的 mediaGenerationId
+ user_paygate_tier: 用户等级
+
+ Returns:
+ 同 generate_video_text (operations 列表)
+ """
+ url = f"{self.api_base_url}/video:batchAsyncGenerateVideoExtendVideo"
+
+ # 403/reCAPTCHA 重试逻辑 - 最多重试3次
+ max_retries = 3
+ last_error = None
+
+ for retry_attempt in range(max_retries):
+ launch_gate_acquired = False
+ launch_ok, _, _ = await self._acquire_video_launch_gate(
+ token_id=token_id,
+ token_video_concurrency=token_video_concurrency,
+ )
+ if not launch_ok:
+ last_error = Exception("Video launch queue wait timeout")
+ raise last_error
+
+ launch_gate_acquired = True
+ try:
+ recaptcha_token, browser_id = await self._get_recaptcha_token(
+ project_id,
+ action="VIDEO_GENERATION",
+ token_id=token_id
+ )
+ finally:
+ if launch_gate_acquired:
+ await self._release_video_launch_gate(token_id)
+ if not recaptcha_token:
+ last_error = Exception("Failed to obtain reCAPTCHA token")
+ should_retry = await self._handle_missing_recaptcha_token(
+ retry_attempt=retry_attempt,
+ max_retries=max_retries,
+ browser_id=browser_id,
+ project_id=project_id,
+ log_prefix="[VIDEO EXTEND] 续写",
+ )
+ if should_retry:
+ continue
+ raise last_error
+ session_id = self._generate_session_id()
+ workflow_id = str(uuid.uuid4())
+
+ json_data = {
+ "clientContext": {
+ "recaptchaContext": {
+ "token": recaptcha_token,
+ "applicationType": "RECAPTCHA_APPLICATION_TYPE_WEB"
+ },
+ "sessionId": session_id,
+ "projectId": project_id,
+ "tool": "PINHOLE",
+ "userPaygateTier": user_paygate_tier
+ },
+ "mediaGenerationContext": {
+ "batchId": str(uuid.uuid4())
+ },
+ "requests": [{
+ "aspectRatio": aspect_ratio,
+ "seed": random.randint(1, 99999),
+ "textInput": {
+ "structuredPrompt": {
+ "parts": [{"text": prompt}]
+ }
+ },
+ "videoInput": {
+ "mediaId": video_media_id
+ },
+ "videoModelKey": model_key,
+ "metadata": {
+ "workflowId": workflow_id
+ }
+ }],
+ "useV2ModelConfig": True
+ }
+
+ # Debug: 打印请求体用于调试
+ import json as _json
+ debug_logger.log_info(f"[VIDEO EXTEND] Request URL: {url}")
+ debug_logger.log_info(f"[VIDEO EXTEND] Request JSON: {_json.dumps(json_data, indent=2, ensure_ascii=False)[:2000]}")
+
+ try:
+ result = await self._make_video_api_request(
+ url=url,
+ json_data=json_data,
+ at=at,
+ timeout=self._get_video_submit_timeout()
+ )
+ return result
+ except Exception as e:
+ last_error = e
+ should_retry = await self._handle_retryable_generation_error(
+ error=e,
+ retry_attempt=retry_attempt,
+ max_retries=max_retries,
+ browser_id=browser_id,
+ project_id=project_id,
+ log_prefix="[VIDEO EXTEND] 续写",
+ )
+ if should_retry:
+ continue
+ raise
+ finally:
+ await self._notify_browser_captcha_request_finished(browser_id)
+
+ # 所有重试都失败
+ raise last_error
+
+ # ========== 视频拼接 (Video Concatenation) ==========
+
+ async def run_concatenation(
+ self,
+ at: str,
+ original_media_id: str,
+ extend_media_id: str,
+ ) -> dict:
+ """
+ 调用 Google runVideoFxConcatenation API 拼接视频
+
+ Args:
+ at: 认证 token
+ original_media_id: 原始视频的 mediaGenerationId (UUID)
+ extend_media_id: 续写视频的 mediaGenerationId (UUID)
+
+ Returns:
+ 包含 operation name 的字典
+ """
+ url = f"{self.api_base_url}:runVideoFxConcatenation"
+
+ json_data = {
+ "inputVideos": [
+ {
+ "mediaGenerationId": original_media_id,
+ "lengthNanos": 8000,
+ "startTimeOffset": "0s",
+ "endTimeOffset": "8s"
+ },
+ {
+ "mediaGenerationId": extend_media_id,
+ "lengthNanos": 8000,
+ "startTimeOffset": "1s",
+ "endTimeOffset": "8s"
+ }
+ ]
+ }
+
+ debug_logger.log_info(f"[CONCAT] 提交拼接任务: original={original_media_id[:12]}..., extend={extend_media_id[:12]}...")
+
+ result = await self._make_request(
+ method="POST",
+ url=url,
+ json_data=json_data,
+ use_at=True,
+ at_token=at
+ )
+ debug_logger.log_info(f"[CONCAT] 拼接任务已提交: {json.dumps(result, ensure_ascii=False)[:300]}")
+ return result
+
+ async def poll_concatenation_status(
+ self,
+ at: str,
+ operation_name: str,
+ timeout: int = 300,
+ poll_interval: int = 3,
+ ) -> dict:
+ """
+ 轮询拼接任务状态,直到完成或超时
+
+ Args:
+ at: 认证 token
+ operation_name: 拼接任务的 operation name
+ timeout: 超时秒数
+ poll_interval: 轮询间隔秒数
+
+ Returns:
+ 包含 outputUri 和 mediaGenerationId 的字典
+ """
+ url = f"{self.api_base_url}:runVideoFxCheckConcatenationStatus"
+ json_data = {
+ "operation": {
+ "operation": {
+ "name": operation_name
+ }
+ }
+ }
+
+ start_time = time.time()
+
+ while time.time() - start_time < timeout:
+ result = await self._make_request(
+ method="POST",
+ url=url,
+ json_data=json_data,
+ use_at=True,
+ at_token=at,
+ timeout=300, # concat API returns base64 video (~14MB), needs longer timeout
+ )
+
+ status = result.get("status", "")
+ output_uri = result.get("outputUri", "")
+ encoded_video = result.get("encodedVideo", "")
+
+ ev_len = len(encoded_video) if encoded_video else 0
+ elapsed = int(time.time() - start_time)
+ all_keys = list(result.keys())
+ debug_logger.log_info(
+ f"[CONCAT] 状态: {status}, outputUri={'yes' if output_uri else 'no'}, "
+ f"encodedVideo={ev_len} chars, elapsed={elapsed}s, keys={all_keys}"
+ )
+
+ # 优先检查 outputUri
+ if output_uri:
+ debug_logger.log_info(f"[CONCAT] 拼接完成 (outputUri): {output_uri[:120]}")
+ return result
+
+ # Google API 返回 encodedVideo(base64 编码的 MP4)而不是 outputUri
+ if encoded_video and "SUCCESSFUL" in status:
+ try:
+ import os
+ video_bytes = base64.b64decode(encoded_video)
+ video_filename = f"concat_{uuid.uuid4().hex[:12]}.mp4"
+
+ # 保存到 tmp/ 目录(FastAPI 已挂载为 /tmp 静态文件)
+ save_dir = "tmp"
+ os.makedirs(save_dir, exist_ok=True)
+ save_path = os.path.join(save_dir, video_filename)
+
+ with open(save_path, "wb") as f:
+ f.write(video_bytes)
+
+ # 构造 URL:FastAPI 挂载了 /tmp -> /app/tmp/
+ serve_url = f"/tmp/{video_filename}"
+ debug_logger.log_info(f"[CONCAT] 拼接完成 (encodedVideo): 保存 {len(video_bytes)} bytes -> {serve_url}")
+
+ result["outputUri"] = serve_url
+ result["local_file"] = save_path
+ return result
+ except Exception as e:
+ debug_logger.log_error(f"[CONCAT] 解码 encodedVideo 失败: {e}")
+ raise Exception(f"解码拼接视频失败: {e}")
+
+ # SUCCESSFUL but neither outputUri nor encodedVideo
+ if "SUCCESSFUL" in status:
+ debug_logger.log_warning(f"[CONCAT] SUCCESSFUL 但无 outputUri/encodedVideo: {json.dumps(result, ensure_ascii=False)[:300]}")
+
+ if "FAILED" in status or "ERROR" in status:
+ debug_logger.log_error(f"[CONCAT] 失败: {status}, 响应: {json.dumps(result, ensure_ascii=False)[:300]}")
+ raise Exception(f"视频拼接失败: {status}")
+
+ await asyncio.sleep(poll_interval)
+
+ debug_logger.log_error(f"[CONCAT] 超时 ({timeout}s),放弃拼接")
+ raise Exception(f"视频拼接超时 ({timeout}s)")
+
# ========== 视频放大 (Video Upsampler) ==========
async def upsample_video(
@@ -1804,12 +2136,11 @@ class FlowClient:
}
try:
- result = await self._make_request(
- method="POST",
+ result = await self._make_video_api_request(
url=url,
json_data=json_data,
- use_at=True,
- at_token=at
+ at=at,
+ timeout=self._get_video_submit_timeout()
)
return result
except Exception as e:
@@ -1860,12 +2191,11 @@ class FlowClient:
for retry_attempt in range(max_retries):
try:
- return await self._make_request(
- method="POST",
+ return await self._make_video_api_request(
url=url,
json_data=json_data,
- use_at=True,
- at_token=at
+ at=at,
+ timeout=self._get_video_poll_timeout()
)
except Exception as e:
last_error = e
@@ -2013,6 +2343,17 @@ class FlowClient:
)
except Exception:
pass
+ elif config.captcha_method == "extension":
+ try:
+ from .browser_captcha_extension import ExtensionCaptchaService
+ service = await ExtensionCaptchaService.get_instance()
+ await service.report_flow_error(
+ project_id=project_id,
+ error_reason=error_reason or "",
+ error_message=error_message or "",
+ )
+ except Exception:
+ pass
elif config.captcha_method == "personal" and project_id:
try:
from .browser_captcha_personal import BrowserCaptchaService
@@ -2317,6 +2658,24 @@ class FlowClient:
captcha_method = config.captcha_method
debug_logger.log_info(f"[reCAPTCHA] 开始获取 token: method={captcha_method}, project_id={project_id}, action={action}")
+ if captcha_method == "extension":
+ try:
+ from .browser_captcha_extension import ExtensionCaptchaService
+ service = await ExtensionCaptchaService.get_instance(self.db)
+ extension_timeout = 45 if action == "VIDEO_GENERATION" else 25
+ token = await service.get_token(
+ project_id,
+ action,
+ timeout=extension_timeout,
+ token_id=token_id
+ )
+ self._set_request_fingerprint(None)
+ return token, None
+ except Exception as e:
+ debug_logger.log_error(f"[reCAPTCHA Extension] 错误: {str(e)}")
+ self._set_request_fingerprint(None)
+ return None, None
+
# 内置浏览器打码 (nodriver)
if captcha_method == "personal":
debug_logger.log_info(f"[reCAPTCHA] 使用 personal 模式")
@@ -2397,7 +2756,19 @@ class FlowClient:
return None, None
# API打码服务
elif captcha_method in ["yescaptcha", "capmonster", "ezcaptcha", "capsolver"]:
- self._set_request_fingerprint(None)
+ # 为 API 打码也设置指纹(包含代理),确保 token 获取和后续请求环境一致
+ if self.proxy_manager:
+ try:
+ proxy_url = await self.proxy_manager.get_request_proxy_url()
+ if proxy_url:
+ self._set_request_fingerprint({"proxy_url": proxy_url})
+ else:
+ self._set_request_fingerprint(None)
+ except Exception as e:
+ debug_logger.log_warning(f"[reCAPTCHA] Failed to get proxy for API captcha: {e}")
+ self._set_request_fingerprint(None)
+ else:
+ self._set_request_fingerprint(None)
token = await self._get_api_captcha_token(captcha_method, project_id, action)
return token, None
else:
@@ -2443,8 +2814,23 @@ class FlowClient:
page_action = action
try:
- # Do not use curl_cffi impersonation for captcha API JSON endpoints: some ASGI
- # servers (for example FastAPI/Uvicorn) may receive an empty body and return 422.
+ # 获取代理配置,让打码API请求也走代理
+ # 注意:curl_cffi 对 SOCKS5 使用 proxy 参数,HTTP 代理使用 proxies 参数
+ proxy = None
+ proxies = None
+ if self.proxy_manager:
+ try:
+ proxy_url = await self.proxy_manager.get_request_proxy_url()
+ if proxy_url:
+ if proxy_url.startswith("socks5://"):
+ # curl_cffi 对 SOCKS5 使用 proxy 参数
+ proxy = proxy_url
+ else:
+ # HTTP/HTTPS 代理使用 proxies 字典
+ proxies = {"http": proxy_url, "https": proxy_url}
+ except Exception as e:
+ debug_logger.log_warning(f"[reCAPTCHA {method}] Failed to get proxy: {e}")
+
async with AsyncSession() as session:
create_url = f"{base_url}/createTask"
create_data = {
@@ -2457,11 +2843,16 @@ class FlowClient:
}
}
- result = await session.post(create_url, json=create_data)
+ if proxy:
+ result = await session.post(create_url, json=create_data, impersonate="chrome124", proxy=proxy)
+ else:
+ result = await session.post(create_url, json=create_data, impersonate="chrome124", proxies=proxies)
+
+ debug_logger.log_info(f"[reCAPTCHA {method}] createTask response status: {result.status_code}")
result_json = result.json()
task_id = result_json.get('taskId')
- debug_logger.log_info(f"[reCAPTCHA {method}] created task_id: {task_id}")
+ debug_logger.log_info(f"[reCAPTCHA {method}] created task_id: {task_id}, response: {result_json}")
if not task_id:
error_desc = result_json.get('errorDescription', 'Unknown error')
@@ -2474,7 +2865,11 @@ class FlowClient:
"clientKey": client_key,
"taskId": task_id
}
- result = await session.post(get_url, json=get_data)
+ # 根据代理类型使用不同参数
+ if proxy:
+ result = await session.post(get_url, json=get_data, impersonate="chrome124", proxy=proxy)
+ else:
+ result = await session.post(get_url, json=get_data, impersonate="chrome124", proxies=proxies)
result_json = result.json()
debug_logger.log_info(f"[reCAPTCHA {method}] polling #{i+1}: {result_json}")
diff --git a/src/services/generation_handler.py b/src/services/generation_handler.py
index 0fd1019..243f668 100644
--- a/src/services/generation_handler.py
+++ b/src/services/generation_handler.py
@@ -281,14 +281,14 @@ MODEL_CONFIG = {
"veo_3_1_t2v_portrait": {
"type": "video",
"video_type": "t2v",
- "model_key": "veo_3_1_t2v_portrait",
+ "model_key": "veo_3_1_t2v_fast_portrait",
"aspect_ratio": "VIDEO_ASPECT_RATIO_PORTRAIT",
"supports_images": False
},
"veo_3_1_t2v_landscape": {
"type": "video",
"video_type": "t2v",
- "model_key": "veo_3_1_t2v",
+ "model_key": "veo_3_1_t2v_fast",
"aspect_ratio": "VIDEO_ASPECT_RATIO_LANDSCAPE",
"supports_images": False
},
@@ -661,10 +661,51 @@ MODEL_CONFIG = {
"min_images": 0,
"max_images": 3,
"upsample": {"resolution": "VIDEO_RESOLUTION_1080P", "model_key": "veo_3_1_upsampler_1080p"}
- }
+ },
+
+ # ========== 视频续写 (Extend - Video Continuation) ==========
+ # 基于已生成的视频续写7秒,最多续写20次(最长148秒)
+ # 需要提供源视频的 mediaGenerationId
+
+ # VEO 3.1 Extend (横竖屏)
+ "veo_3_1_extend_portrait": {
+ "type": "video",
+ "video_type": "extend",
+ "model_key": "veo_3_1_extend_fast_portrait_ultra",
+ "aspect_ratio": "VIDEO_ASPECT_RATIO_PORTRAIT",
+ "supports_images": False,
+ "requires_video_id": True,
+ },
+ "veo_3_1_extend": {
+ "type": "video",
+ "video_type": "extend",
+ "model_key": "veo_3_1_extend_fast_ultra",
+ "aspect_ratio": "VIDEO_ASPECT_RATIO_LANDSCAPE",
+ "supports_images": False,
+ "requires_video_id": True,
+ },
}
+def _known_video_model_keys() -> set[str]:
+ return {
+ cfg["model_key"]
+ for cfg in MODEL_CONFIG.values()
+ if cfg.get("type") == "video" and cfg.get("model_key")
+ }
+
+
+def _resolve_tier_two_model_key(model_key: str) -> str:
+ """Only upgrade to an ultra key when that exact upstream key is known valid."""
+ if "ultra" in model_key:
+ return model_key
+ if "_fl" in model_key:
+ candidate = model_key.replace("_fl", "_ultra_fl")
+ else:
+ candidate = model_key + "_ultra"
+ return candidate if candidate in _known_video_model_keys() else model_key
+
+
class GenerationHandler:
"""统一生成处理器"""
@@ -777,7 +818,8 @@ class GenerationHandler:
prompt: str,
images: Optional[List[bytes]] = None,
stream: bool = False,
- base_url_override: Optional[str] = None
+ base_url_override: Optional[str] = None,
+ video_media_id: Optional[str] = None,
) -> AsyncGenerator:
"""统一生成入口
@@ -816,7 +858,8 @@ class GenerationHandler:
model_config = MODEL_CONFIG[model]
generation_type = model_config["type"]
- request_operation = f"generate_{generation_type}"
+ video_type_for_op = model_config.get("video_type", "")
+ request_operation = "extend_video" if video_type_for_op == "extend" else f"generate_{generation_type}"
prompt_for_log = prompt if len(prompt) <= 2000 else f"{prompt[:2000]}...(truncated)"
request_payload = {
"model": model,
@@ -978,7 +1021,8 @@ class GenerationHandler:
generation_result=generation_result,
response_state=response_state,
request_log_state=request_log_state,
- pending_token_state=pending_token_state
+ pending_token_state=pending_token_state,
+ video_media_id=video_media_id,
):
yield chunk
perf_trace["generation_pipeline_ms"] = int((time.time() - generation_pipeline_started_at) * 1000)
@@ -1428,7 +1472,8 @@ class GenerationHandler:
generation_result: Optional[Dict[str, Any]] = None,
response_state: Optional[Dict[str, Any]] = None,
request_log_state: Optional[Dict[str, Any]] = None,
- pending_token_state: Optional[Dict[str, bool]] = None
+ pending_token_state: Optional[Dict[str, bool]] = None,
+ video_media_id: Optional[str] = None,
) -> AsyncGenerator:
"""处理视频生成 (异步轮询)"""
@@ -1458,11 +1503,40 @@ class GenerationHandler:
# 根据账号tier自动调整模型 key
user_tier = normalized_tier
- model_key, tier_message = self._resolve_video_model_key_for_tier(model_config, user_tier)
- if tier_message and stream:
- yield self._create_stream_chunk(f"{tier_message}\n")
- if model_key != model_config["model_key"]:
- debug_logger.log_info(f"[VIDEO] 账号层级自动调整模型: {model_config['model_key']} -> {model_key}")
+
+ # Extend 模型跳过 ultra 自动降级(只有 ultra 版本有效)
+ is_extend = video_type == "extend"
+
+ # TIER_TWO 账号需要使用 ultra 版本的模型
+ if user_tier == "PAYGATE_TIER_TWO":
+ # 如果模型 key 不包含 ultra,自动添加
+ if "ultra" not in model_key:
+ original_model_key = model_key
+ model_key = _resolve_tier_two_model_key(model_key)
+ if stream:
+ if model_key != original_model_key:
+ yield self._create_stream_chunk(f"TIER_TWO 账号自动切换到 ultra 模型: {model_key}\n")
+ else:
+ yield self._create_stream_chunk(f"TIER_TWO 账号保持当前模型: {model_key}\n")
+ if model_key != original_model_key:
+ debug_logger.log_info(f"[VIDEO] TIER_TWO 账号,模型自动调整: {original_model_key} -> {model_key}")
+ else:
+ debug_logger.log_info(f"[VIDEO] TIER_TWO 账号,未找到有效 ultra 变体,保持模型: {model_key}")
+
+
+ # TIER_ONE 账号需要使用非 ultra 版本
+ elif user_tier == "PAYGATE_TIER_ONE":
+ # 如果模型 key 包含 ultra,需要移除(避免用户误用)
+ if "ultra" in model_key:
+ # veo_3_1_i2v_s_fast_ultra_fl -> veo_3_1_i2v_s_fast_fl
+ # veo_3_1_t2v_fast_ultra -> veo_3_1_t2v_fast
+ # veo_3_1_r2v_fast_landscape_ultra -> veo_3_1_r2v_fast_landscape
+ # veo_3_1_extend_fast_portrait_ultra -> veo_3_1_extend_fast_portrait
+ model_key = model_key.replace("_ultra_fl", "_fl").replace("_ultra", "")
+
+ if stream:
+ yield self._create_stream_chunk(f"TIER_ONE 账号自动切换到标准模型: {model_key}\n")
+ debug_logger.log_info(f"[VIDEO] TIER_ONE 账号,模型自动调整: {model_config['model_key']} -> {model_key}")
# 更新 model_config 中的 model_key
model_config = dict(model_config) # 创建副本避免修改原配置
@@ -1602,6 +1676,31 @@ class GenerationHandler:
token_video_concurrency=token.video_concurrency,
)
+ # Extend: 视频续写
+ elif video_type == "extend":
+ if not video_media_id:
+ error_msg = "❌ 视频续写需要提供源视频的 mediaGenerationId,请在 image_url 中传入 extend://VIDEO_MEDIA_ID"
+ if stream:
+ yield self._create_stream_chunk(f"{error_msg}\n")
+ self._mark_generation_failed(generation_result, error_msg)
+ yield self._create_error_response(error_msg, status_code=400)
+ return
+
+ debug_logger.log_info(f"[EXTEND] 续写视频: {video_media_id}")
+ if stream:
+ yield self._create_stream_chunk(f"视频续写任务提交中,源视频: {video_media_id[:8]}...\n")
+ result = await self.flow_client.generate_video_extend(
+ at=token.at,
+ project_id=project_id,
+ prompt=prompt,
+ video_media_id=video_media_id,
+ model_key=model_config["model_key"],
+ aspect_ratio=model_config["aspect_ratio"],
+ user_paygate_tier=normalized_tier,
+ token_id=token.id,
+ token_video_concurrency=token.video_concurrency,
+ )
+
# T2V 或 R2V无图: 纯文本生成
else:
result = await self.flow_client.generate_video_text(
@@ -1654,6 +1753,8 @@ class GenerationHandler:
# 检查是否需要放大
upsample_config = model_config.get("upsample")
+ # 如果是 extend,传入源视频 media_id 用于后续拼接
+ extend_source_id = video_media_id if video_type == "extend" else None
async for chunk in self._poll_video_result(
token,
project_id,
@@ -1663,6 +1764,7 @@ class GenerationHandler:
generation_result,
response_state,
request_log_state,
+ extend_source_media_id=extend_source_id,
):
yield chunk
@@ -1678,7 +1780,8 @@ class GenerationHandler:
upsample_config: Optional[Dict] = None,
generation_result: Optional[Dict[str, Any]] = None,
response_state: Optional[Dict[str, Any]] = None,
- request_log_state: Optional[Dict[str, Any]] = None
+ request_log_state: Optional[Dict[str, Any]] = None,
+ extend_source_media_id: Optional[str] = None,
) -> AsyncGenerator:
"""轮询视频生成结果
@@ -1728,7 +1831,12 @@ class GenerationHandler:
metadata = operation["operation"].get("metadata", {})
video_info = metadata.get("video", {})
video_url = video_info.get("fifeUrl")
- video_media_id = video_info.get("mediaGenerationId")
+ # Extract short UUID from Google Storage URL (e.g., /video/UUID?)
+ # Both extend API and concat API need this short UUID format,
+ # NOT the CAUS base64 mediaGenerationId from video_info
+ import re as _re
+ _uuid_match = _re.search(r'/video/([0-9a-f-]{36})', video_url or '')
+ video_media_id = _uuid_match.group(1) if _uuid_match else video_info.get("mediaGenerationId", "")
aspect_ratio = video_info.get("aspectRatio", "VIDEO_ASPECT_RATIO_LANDSCAPE")
if not video_url:
@@ -1764,7 +1872,14 @@ class GenerationHandler:
# 递归轮询放大结果(不再放大)
async for chunk in self._poll_video_result(
- token, project_id, upsample_operations, stream, None, generation_result, response_state, request_log_state
+ token,
+ project_id,
+ upsample_operations,
+ stream,
+ None,
+ generation_result,
+ response_state,
+ request_log_state,
):
yield chunk
return
@@ -1776,6 +1891,62 @@ class GenerationHandler:
if stream:
yield self._create_stream_chunk(f"⚠️ 放大失败: {str(e)},返回原始视频\n")
+ # ========== Extend 视频拼接 ==========
+ if extend_source_media_id and video_media_id:
+ try:
+ if stream:
+ yield self._create_stream_chunk("\n视频续写完成,正在拼接完整视频...\n")
+ debug_logger.log_info(f"[CONCAT] 开始拼接: original={extend_source_media_id[:12]}..., extend={video_media_id[:12]}...")
+
+ # 提交拼接任务
+ concat_result = await self.flow_client.run_concatenation(
+ at=token.at,
+ original_media_id=extend_source_media_id,
+ extend_media_id=video_media_id,
+ )
+
+ # 获取 operation name
+ concat_op = concat_result.get("operation", {}).get("operation", {}).get("name", "")
+ if concat_op:
+ if stream:
+ yield self._create_stream_chunk("拼接任务已提交,等待完成...\n")
+
+ # 轮询拼接状态
+ concat_status = await self.flow_client.poll_concatenation_status(
+ at=token.at,
+ operation_name=concat_op,
+ timeout=300,
+ poll_interval=3,
+ )
+
+ concat_url = concat_status.get("outputUri", "")
+ if concat_url:
+ # 如果是本地路径(/tmp/xxx.mp4),构造完整 URL
+ if concat_url.startswith("/tmp/"):
+ server_host = config.server_host or "0.0.0.0"
+ server_port = config.server_port or 8000
+ # 对外使用 localhost
+ host = "localhost" if server_host == "0.0.0.0" else server_host
+ concat_url = f"http://{host}:{server_port}{concat_url}"
+ video_url = concat_url # 替换为拼接后的完整视频 URL
+ if stream:
+ yield self._create_stream_chunk("✅ 视频拼接完成!返回 16s 完整视频\n")
+ debug_logger.log_info(f"[CONCAT] 拼接成功: {concat_url[:80]}...")
+ else:
+ if stream:
+ yield self._create_stream_chunk("⚠️ 拼接完成但无 URL,返回续写片段\n")
+ else:
+ debug_logger.log_warning("[CONCAT] 拼接任务创建失败,返回续写片段")
+ if stream:
+ yield self._create_stream_chunk("⚠️ 拼接任务创建失败,返回续写片段\n")
+ except Exception as e:
+ import traceback
+ debug_logger.log_error(f"[CONCAT] 拼接失败: {str(e)}")
+ debug_logger.log_error(f"[CONCAT] traceback: {traceback.format_exc()}")
+ if stream:
+ yield self._create_stream_chunk(f"⚠️ 拼接失败: {str(e)},返回续写片段\n")
+ # 拼接失败不影响返回,继续使用 extend 片段的 URL
+
# 缓存视频 (如果启用)
local_url = video_url
if config.cache_enabled:
@@ -1812,7 +1983,8 @@ class GenerationHandler:
response_state["url"] = local_url
response_state["generated_assets"] = {
"type": "video",
- "final_video_url": local_url
+ "final_video_url": local_url,
+ "mediaGenerationId": video_media_id,
}
# 返回结果
@@ -1820,9 +1992,10 @@ class GenerationHandler:
if stream:
yield self._create_stream_chunk(
- f"",
+ f"",
finish_reason="stop"
)
+
else:
yield self._create_completion_response(
local_url, # 直接传URL,让方法内部格式化
@@ -1857,6 +2030,16 @@ class GenerationHandler:
self._mark_generation_failed(generation_result, error_msg)
yield self._create_error_response(error_msg, status_code=502)
return
+
+ elif status == "MEDIA_GENERATION_STATUS_ACTIVE" and attempt > 80:
+ # 如果持续4分钟(80次 * 3秒 = 240秒)依然是 ACTIVE 状态,则判定为卡死
+ error_msg = "视频生成超时 (上游卡顿超过4分钟,已自动取消)"
+ await self._fail_video_task(checked_operations, error_msg)
+ self._mark_generation_failed(generation_result, error_msg)
+ if stream:
+ yield self._create_stream_chunk(f"❌ {error_msg}\n")
+ yield self._create_error_response(error_msg, status_code=504)
+ return
except Exception as e:
last_poll_error = e
diff --git a/src/services/load_balancer.py b/src/services/load_balancer.py
index dfac4ab..536dd37 100644
--- a/src/services/load_balancer.py
+++ b/src/services/load_balancer.py
@@ -117,6 +117,26 @@ class LoadBalancer:
self._round_robin_state[scenario] = selected["token"].id
return selected
+ async def _check_extension_route(self, token: Token) -> tuple[bool, str]:
+ """Ensure extension captcha requests are routed to the selected account."""
+ if config.captcha_method != "extension":
+ return True, ""
+
+ try:
+ from .browser_captcha_extension import ExtensionCaptchaService
+
+ service = await ExtensionCaptchaService.get_instance(getattr(self.token_manager, "db", None))
+ has_connection, route_key = await service.has_connection_for_token(token.id)
+ if has_connection:
+ return True, ""
+
+ available = service.describe_routes() or "none"
+ if route_key:
+ return False, f"扩展路由 {route_key} 未连接(可用路由: {available})"
+ return False, f"扩展路由未配置或匿名插件未连接(可用路由: {available})"
+ except Exception as exc:
+ return False, f"扩展路由检查失败: {exc}"
+
async def select_token(
self,
for_image_generation: bool = False,
@@ -171,6 +191,11 @@ class LoadBalancer:
filtered_reasons[token.id] = "图片生成已禁用"
continue
+ route_ok, route_reason = await self._check_extension_route(token)
+ if not route_ok:
+ filtered_reasons[token.id] = route_reason
+ continue
+
if (
enforce_concurrency_filter
and self.concurrency_manager
@@ -184,6 +209,11 @@ class LoadBalancer:
filtered_reasons[token.id] = "视频生成已禁用"
continue
+ route_ok, route_reason = await self._check_extension_route(token)
+ if not route_ok:
+ filtered_reasons[token.id] = route_reason
+ continue
+
if (
enforce_concurrency_filter
and self.concurrency_manager
diff --git a/src/services/proxy_manager.py b/src/services/proxy_manager.py
index 8d0caa7..29a7aed 100644
--- a/src/services/proxy_manager.py
+++ b/src/services/proxy_manager.py
@@ -47,6 +47,7 @@ class ProxyManager:
# 协议前缀格式
if line.startswith(("http://", "https://", "socks5://", "socks5h://")):
+
# 已是标准 user:pass@host:port(或 host:port)
if "@" in line:
return line
diff --git a/src/services/token_manager.py b/src/services/token_manager.py
index 23b0e0f..133c0e3 100644
--- a/src/services/token_manager.py
+++ b/src/services/token_manager.py
@@ -209,7 +209,8 @@ class TokenManager:
video_enabled: bool = True,
image_concurrency: int = -1,
video_concurrency: int = -1,
- captcha_proxy_url: Optional[str] = None
+ captcha_proxy_url: Optional[str] = None,
+ extension_route_key: Optional[str] = None,
) -> Token:
"""Add a new token and prepare its pooled projects."""
existing_token = await self.db.get_token_by_st(st)
@@ -284,7 +285,8 @@ class TokenManager:
video_enabled=video_enabled,
image_concurrency=image_concurrency,
video_concurrency=video_concurrency,
- captcha_proxy_url=captcha_proxy_url
+ captcha_proxy_url=captcha_proxy_url,
+ extension_route_key=extension_route_key,
)
token_id = await self.db.add_token(token)
@@ -314,7 +316,8 @@ class TokenManager:
video_enabled: Optional[bool] = None,
image_concurrency: Optional[int] = None,
video_concurrency: Optional[int] = None,
- captcha_proxy_url: Optional[str] = None
+ captcha_proxy_url: Optional[str] = None,
+ extension_route_key: Optional[str] = None,
):
"""Update token (支持修改project_id和project_name)
@@ -344,6 +347,8 @@ class TokenManager:
update_fields["video_concurrency"] = video_concurrency
if captcha_proxy_url is not None:
update_fields["captcha_proxy_url"] = captcha_proxy_url
+ if extension_route_key is not None:
+ update_fields["extension_route_key"] = extension_route_key
# 检查token是否因429被禁用,如果是且未过期,则清空429状态
token = await self.db.get_token(token_id)
diff --git a/tests/test_veo_lite_support.py b/tests/test_veo_lite_support.py
index 4779f5f..2e57662 100644
--- a/tests/test_veo_lite_support.py
+++ b/tests/test_veo_lite_support.py
@@ -62,7 +62,7 @@ class VeoLiteFlowClientTests(unittest.IsolatedAsyncioTestCase):
async def test_generate_video_text_uses_v2_payload_for_lite(self):
captured = {}
- async def fake_make_request(method, url, json_data, use_at, at_token):
+ async def fake_make_request(method, url, json_data, use_at, at_token, **kwargs):
captured["url"] = url
captured["json_data"] = json_data
return {"operations": [{"operation": {"name": "task-1"}}]}
@@ -92,7 +92,7 @@ class VeoLiteFlowClientTests(unittest.IsolatedAsyncioTestCase):
async def test_generate_video_start_end_uses_v2_payload_for_interpolation_lite(self):
captured = {}
- async def fake_make_request(method, url, json_data, use_at, at_token):
+ async def fake_make_request(method, url, json_data, use_at, at_token, **kwargs):
captured["url"] = url
captured["json_data"] = json_data
return {"operations": [{"operation": {"name": "task-2"}}]}