fix: prefer direct login browser access with proxy fallback

This commit is contained in:
Rixuan Shao
2026-08-25 11:35:20 +08:00
parent 63443fda67
commit 4dd58ecb80
10 changed files with 335 additions and 23 deletions
+5
View File
@@ -15,6 +15,11 @@ LOGIN_DESKTOP_PIDS_LIMIT=256
LOGIN_DESKTOP_IDLE_TIMEOUT_SECONDS=1800 LOGIN_DESKTOP_IDLE_TIMEOUT_SECONDS=1800
LOGIN_DESKTOP_STOP_AFTER_EXPORT_SECONDS=60 LOGIN_DESKTOP_STOP_AFTER_EXPORT_SECONDS=60
LOGIN_DESKTOP_STATUS_CACHE_SECONDS=15 LOGIN_DESKTOP_STATUS_CACHE_SECONDS=15
# Login browser uses direct access first; Mihomo is a fallback when direct access fails.
LOGIN_DESKTOP_PROXY_MODE=auto
LOGIN_DESKTOP_PROXY=http://proxy:7890
LOGIN_DESKTOP_PREFLIGHT_TIMEOUT_SECONDS=15
LOGIN_DESKTOP_NETWORK_CACHE_SECONDS=30
PROXY_BIND_ADDRESS=127.0.0.1 PROXY_BIND_ADDRESS=127.0.0.1
PROXY_HTTP_PORT=7890 PROXY_HTTP_PORT=7890
PROXY_CONTROLLER_PORT=9090 PROXY_CONTROLLER_PORT=9090
+4
View File
@@ -1,3 +1,7 @@
## 2026-08-25
- Made login-browser networking direct-first with Mihomo fallback and explicit preflight errors.
# Changelog # Changelog
## 2026-07-11 ## 2026-07-11
+198 -10
View File
@@ -2,8 +2,10 @@ import asyncio
import os import os
import shutil import shutil
import time import time
import urllib.request
from contextlib import asynccontextmanager from contextlib import asynccontextmanager
from pathlib import Path from pathlib import Path
from urllib.parse import urlsplit
from fastapi import FastAPI, HTTPException, Request, Response from fastapi import FastAPI, HTTPException, Request, Response
import uvicorn import uvicorn
@@ -28,6 +30,16 @@ if LOGIN_DESKTOP_MODE not in {"native", "novnc"}:
IDLE_TIMEOUT_SECONDS = max(300, int(os.getenv("LOGIN_DESKTOP_IDLE_TIMEOUT_SECONDS", "1800"))) IDLE_TIMEOUT_SECONDS = max(300, int(os.getenv("LOGIN_DESKTOP_IDLE_TIMEOUT_SECONDS", "1800")))
STOP_AFTER_EXPORT_SECONDS = max(0, int(os.getenv("LOGIN_DESKTOP_STOP_AFTER_EXPORT_SECONDS", "60"))) STOP_AFTER_EXPORT_SECONDS = max(0, int(os.getenv("LOGIN_DESKTOP_STOP_AFTER_EXPORT_SECONDS", "60")))
STATUS_CACHE_SECONDS = max(1, int(os.getenv("LOGIN_DESKTOP_STATUS_CACHE_SECONDS", "15"))) STATUS_CACHE_SECONDS = max(1, int(os.getenv("LOGIN_DESKTOP_STATUS_CACHE_SECONDS", "15")))
LOGIN_NETWORK_MODE = str(os.getenv("LOGIN_DESKTOP_PROXY_MODE", "auto")).strip().lower()
if LOGIN_NETWORK_MODE not in {"auto", "direct", "proxy"}:
LOGIN_NETWORK_MODE = "auto"
LOGIN_PROXY_SERVER = str(os.getenv("LOGIN_DESKTOP_PROXY", "http://proxy:7890")).strip()
LOGIN_PREFLIGHT_TIMEOUT_SECONDS = max(
3, int(os.getenv("LOGIN_DESKTOP_PREFLIGHT_TIMEOUT_SECONDS", "15"))
)
LOGIN_NETWORK_CACHE_SECONDS = max(
0, int(os.getenv("LOGIN_DESKTOP_NETWORK_CACHE_SECONDS", "30"))
)
GENERIC_WWW_NAMES = { GENERIC_WWW_NAMES = {
"", "",
"我的", "我的",
@@ -51,6 +63,64 @@ GENERIC_WWW_NAMES = {
} }
class LoginNetworkError(RuntimeError):
"""Raised when the login browser cannot reach Douyin."""
def __init__(self, message, *, checks=None):
super().__init__(message)
self.checks = checks or {}
def _safe_proxy_label(proxy_server):
if not proxy_server:
return ""
try:
parsed = urlsplit(proxy_server)
host = parsed.hostname or ""
port = f":{parsed.port}" if parsed.port else ""
return f"{host}{port}" if host else "configured proxy"
except ValueError:
return "configured proxy"
def _probe_login_target(proxy_server=None, timeout_seconds=15):
"""Probe Douyin without inheriting the process proxy environment."""
if proxy_server:
handlers = [
urllib.request.ProxyHandler(
{"http": proxy_server, "https": proxy_server}
)
]
else:
handlers = [urllib.request.ProxyHandler({})]
opener = urllib.request.build_opener(*handlers)
request = urllib.request.Request(
REMOTE_LOGIN_URL,
headers={"User-Agent": "DouYinSparkFlow-login-preflight/1"},
)
started = time.monotonic()
try:
with opener.open(request, timeout=timeout_seconds) as response:
response.read(256)
status = int(getattr(response, "status", 200))
if status >= 400:
raise RuntimeError(f"HTTP {status}")
return {
"ok": True,
"status": status,
"latency_ms": round((time.monotonic() - started) * 1000),
}
except Exception as exc:
error = str(exc) or exc.__class__.__name__
if proxy_server:
error = error.replace(proxy_server, _safe_proxy_label(proxy_server))
return {
"ok": False,
"error": error[:240],
"latency_ms": round((time.monotonic() - started) * 1000),
}
class LoginDesktopManager: class LoginDesktopManager:
def __init__(self): def __init__(self):
self._lock = asyncio.Lock() self._lock = asyncio.Lock()
@@ -63,6 +133,83 @@ class LoginDesktopManager:
self._status_checked_at = 0.0 self._status_checked_at = 0.0
self._idle_monitor_task = None self._idle_monitor_task = None
self._scheduled_stop_task = None self._scheduled_stop_task = None
self._network_route = None
self._network_checks = {}
self._network_checked_at = 0.0
def _network_payload(self):
route = dict(self._network_route or {})
return {
"mode": LOGIN_NETWORK_MODE,
"selected": route.get("mode", ""),
"proxy": _safe_proxy_label(LOGIN_PROXY_SERVER) if route.get("mode") == "proxy" else "",
"checked_at": route.get("checked_at", ""),
"checks": dict(self._network_checks),
}
def _invalidate_network_route(self):
self._network_route = None
self._network_checked_at = 0.0
async def _select_network_route(self, *, force=False):
now = time.monotonic()
if (
not force
and self._network_route
and now - self._network_checked_at < LOGIN_NETWORK_CACHE_SECONDS
):
return dict(self._network_route)
checks = {}
candidates = []
if LOGIN_NETWORK_MODE in {"auto", "direct"}:
candidates.append(("direct", None))
if LOGIN_NETWORK_MODE in {"auto", "proxy"} and LOGIN_PROXY_SERVER:
candidates.append(("proxy", LOGIN_PROXY_SERVER))
for mode, proxy_server in candidates:
result = await asyncio.to_thread(
_probe_login_target,
proxy_server,
LOGIN_PREFLIGHT_TIMEOUT_SECONDS,
)
checks[mode] = result
if result.get("ok"):
route = {
"mode": mode,
"proxy": _safe_proxy_label(proxy_server),
"checked_at": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
}
self._network_checks = checks
self._network_route = route
self._network_checked_at = now
return dict(route)
self._network_checks = checks
self._network_route = None
self._network_checked_at = now
if LOGIN_NETWORK_MODE == "direct":
message = "无法直连抖音创作者中心,请检查服务器网络出口"
elif LOGIN_NETWORK_MODE == "proxy":
message = f"代理 {_safe_proxy_label(LOGIN_PROXY_SERVER)} 无法访问抖音创作者中心"
else:
message = "直连和代理都无法访问抖音创作者中心"
raise LoginNetworkError(message, checks=checks)
async def network_preflight(self, *, force=True):
try:
route = await self._select_network_route(force=force)
return {
"ok": True,
"route": route,
"network": self._network_payload(),
}
except LoginNetworkError as exc:
return {
"ok": False,
"error": str(exc),
"network": self._network_payload(),
}
def mark_activity(self): def mark_activity(self):
self._last_activity = time.monotonic() self._last_activity = time.monotonic()
@@ -136,6 +283,7 @@ class LoginDesktopManager:
except Exception: except Exception:
pass pass
self.playwright = None self.playwright = None
route = await self._select_network_route()
self.playwright = await async_playwright().start() self.playwright = await async_playwright().start()
launch_args = [ launch_args = [
"--start-maximized", "--start-maximized",
@@ -158,11 +306,18 @@ class LoginDesktopManager:
"--renderer-process-limit=2", "--renderer-process-limit=2",
] ]
) )
launch_options = {
"headless": False,
"viewport": {"width": 1600, "height": 1000},
"args": launch_args,
}
if route["mode"] == "proxy":
launch_options["proxy"] = {"server": LOGIN_PROXY_SERVER}
else:
launch_args.append("--no-proxy-server")
self.context = await self.playwright.chromium.launch_persistent_context( self.context = await self.playwright.chromium.launch_persistent_context(
str(PROFILE_DIR), str(PROFILE_DIR),
headless=False, **launch_options,
viewport={"width": 1600, "height": 1000},
args=launch_args,
) )
self.page = self.context.pages[0] if self.context.pages else await self.context.new_page() self.page = self.context.pages[0] if self.context.pages else await self.context.new_page()
@@ -227,7 +382,12 @@ class LoginDesktopManager:
await page.bring_to_front() await page.bring_to_front()
except Exception: except Exception:
pass pass
return {"ok": True, "url": page.url, "mode": LOGIN_DESKTOP_MODE} return {
"ok": True,
"url": page.url,
"mode": LOGIN_DESKTOP_MODE,
"network": self._network_payload(),
}
async def status(self): async def status(self):
now = time.monotonic() now = time.monotonic()
@@ -247,6 +407,7 @@ class LoginDesktopManager:
"unique_id": "", "unique_id": "",
"current_url": "", "current_url": "",
"profile_dir": str(PROFILE_DIR), "profile_dir": str(PROFILE_DIR),
"network": self._network_payload(),
} }
self._status_cache = payload self._status_cache = payload
self._status_checked_at = now self._status_checked_at = now
@@ -272,6 +433,7 @@ class LoginDesktopManager:
"unique_id": "", "unique_id": "",
"current_url": "", "current_url": "",
"profile_dir": str(PROFILE_DIR), "profile_dir": str(PROFILE_DIR),
"network": self._network_payload(),
} }
self._status_cache = payload self._status_cache = payload
self._status_checked_at = now self._status_checked_at = now
@@ -295,6 +457,7 @@ class LoginDesktopManager:
"unique_id": unique_id, "unique_id": unique_id,
"current_url": current_url, "current_url": current_url,
"profile_dir": str(PROFILE_DIR), "profile_dir": str(PROFILE_DIR),
"network": self._network_payload(),
} }
self._status_cache = payload self._status_cache = payload
self._status_checked_at = now self._status_checked_at = now
@@ -309,7 +472,7 @@ class LoginDesktopManager:
try: try:
page = await self._get_active_page() page = await self._get_active_page()
if page.url.startswith(REMOTE_LOGIN_URL): if page.url.startswith(REMOTE_LOGIN_URL):
return {"ok": True, "url": page.url} return {"ok": True, "url": page.url, "network": self._network_payload()}
refresh_url = f"{REMOTE_LOGIN_URL}?qr_refresh={int(time.time() * 1000)}" refresh_url = f"{REMOTE_LOGIN_URL}?qr_refresh={int(time.time() * 1000)}"
try: try:
await page.goto(refresh_url, wait_until="commit", timeout=30000) await page.goto(refresh_url, wait_until="commit", timeout=30000)
@@ -318,7 +481,7 @@ class LoginDesktopManager:
await self.start() await self.start()
page = await self._get_active_page() page = await self._get_active_page()
await page.goto(refresh_url, wait_until="commit", timeout=30000) await page.goto(refresh_url, wait_until="commit", timeout=30000)
return {"ok": True, "url": page.url} return {"ok": True, "url": page.url, "network": self._network_payload()}
finally: finally:
self._page_operation_lock.release() self._page_operation_lock.release()
@@ -516,6 +679,14 @@ async def health():
return {"ok": True} return {"ok": True}
@app.get("/preflight")
async def preflight():
result = await manager.network_preflight(force=True)
if not result.get("ok"):
raise HTTPException(status_code=502, detail=result)
return result
@app.get("/status") @app.get("/status")
async def status(): async def status():
return await manager.status() return await manager.status()
@@ -523,8 +694,13 @@ async def status():
@app.post("/open-login") @app.post("/open-login")
async def open_login(): async def open_login():
await manager.open_login() try:
return {"ok": True} return await manager.open_login()
except LoginNetworkError as exc:
raise HTTPException(
status_code=502,
detail={"code": "LOGIN_NETWORK_UNAVAILABLE", "message": str(exc), "checks": exc.checks},
) from exc
@app.post("/reset") @app.post("/reset")
@@ -546,7 +722,13 @@ async def focus():
@app.post("/refresh-qr") @app.post("/refresh-qr")
async def refresh_qr(): async def refresh_qr():
return await manager.refresh_login_qr() try:
return await manager.refresh_login_qr()
except LoginNetworkError as exc:
raise HTTPException(
status_code=502,
detail={"code": "LOGIN_NETWORK_UNAVAILABLE", "message": str(exc), "checks": exc.checks},
) from exc
@app.post("/export") @app.post("/export")
@@ -566,7 +748,13 @@ async def export():
async def login_qr(): async def login_qr():
if manager._page_operation_lock.locked(): if manager._page_operation_lock.locked():
raise HTTPException(status_code=503, detail="login page is busy; retry shortly") raise HTTPException(status_code=503, detail="login page is busy; retry shortly")
page = await manager._get_active_page() try:
page = await manager._get_active_page()
except LoginNetworkError as exc:
raise HTTPException(
status_code=502,
detail={"code": "LOGIN_NETWORK_UNAVAILABLE", "message": str(exc), "checks": exc.checks},
) from exc
expired = await page.locator('[class*="qrcode_expired"]').count() expired = await page.locator('[class*="qrcode_expired"]').count()
if expired and await page.locator('[class*="qrcode_expired"]').first.is_visible(): if expired and await page.locator('[class*="qrcode_expired"]').first.is_visible():
raise HTTPException(status_code=409, detail="login QR code has expired") raise HTTPException(status_code=409, detail="login QR code has expired")
@@ -72,9 +72,9 @@ class DeploymentContractTests(unittest.TestCase):
compose = (REPO_ROOT / "docker-compose.yml").read_text(encoding="utf-8") compose = (REPO_ROOT / "docker-compose.yml").read_text(encoding="utf-8")
self.assertIn("# Build proxies must never leak", dockerfile) self.assertIn("# Build proxies must never leak", dockerfile)
self.assertIn("http_proxy=", dockerfile) self.assertIn("http_proxy=", dockerfile)
self.assertGreaterEqual(compose.count("http_proxy: http://proxy:7890"), 4) self.assertGreaterEqual(compose.count("http_proxy: http://proxy:7890"), 3)
self.assertGreaterEqual(compose.count("https_proxy: http://proxy:7890"), 4) self.assertGreaterEqual(compose.count("https_proxy: http://proxy:7890"), 3)
self.assertGreaterEqual(compose.count("no_proxy:"), 4) self.assertGreaterEqual(compose.count("no_proxy:"), 3)
def test_sensitive_ports_bind_to_loopback_by_default(self): def test_sensitive_ports_bind_to_loopback_by_default(self):
text = (REPO_ROOT / "docker-compose.yml").read_text(encoding="utf-8") text = (REPO_ROOT / "docker-compose.yml").read_text(encoding="utf-8")
@@ -114,6 +114,20 @@ class DeploymentContractTests(unittest.TestCase):
for entry in ("logs/", "config.json", "usersData.json", "webui_settings.json"): for entry in ("logs/", "config.json", "usersData.json", "webui_settings.json"):
self.assertIn(entry, dockerignore) self.assertIn(entry, dockerignore)
def test_login_desktop_uses_direct_first_network_route(self):
compose = (REPO_ROOT / "docker-compose.yml").read_text(encoding="utf-8")
env_example = (REPO_ROOT / ".env.example").read_text(encoding="utf-8")
server = (SOURCE_ROOT / "login_desktop_server.py").read_text(encoding="utf-8")
login_block = compose.split(" login-desktop:", 1)[1].split(" scheduler:", 1)[0]
self.assertIn("LOGIN_DESKTOP_PROXY_MODE: ${LOGIN_DESKTOP_PROXY_MODE:-auto}", login_block)
self.assertIn("LOGIN_DESKTOP_PROXY: ${LOGIN_DESKTOP_PROXY:-http://proxy:7890}", login_block)
self.assertNotIn("HTTP_PROXY: http://proxy:7890", login_block)
self.assertIn("LOGIN_DESKTOP_PROXY_MODE=auto", env_example)
self.assertIn('candidates.append(("direct", None))', server)
self.assertIn('candidates.append(("proxy", LOGIN_PROXY_SERVER))', server)
self.assertIn('"--no-proxy-server"', server)
self.assertIn('"/preflight"', server)
def test_login_desktop_resource_controls_are_configured(self): def test_login_desktop_resource_controls_are_configured(self):
compose = (REPO_ROOT / "docker-compose.yml").read_text(encoding="utf-8") compose = (REPO_ROOT / "docker-compose.yml").read_text(encoding="utf-8")
start_script = (SOURCE_ROOT / "scripts" / "start_login_desktop.sh").read_text(encoding="utf-8") start_script = (SOURCE_ROOT / "scripts" / "start_login_desktop.sh").read_text(encoding="utf-8")
@@ -0,0 +1,76 @@
from unittest import IsolatedAsyncioTestCase
from unittest.mock import patch
from login_desktop_server import LoginDesktopManager, LoginNetworkError
class LoginNetworkTests(IsolatedAsyncioTestCase):
async def test_auto_mode_prefers_direct_route(self):
manager = LoginDesktopManager()
with (
patch("login_desktop_server.LOGIN_NETWORK_MODE", "auto"),
patch("login_desktop_server.LOGIN_PROXY_SERVER", "http://proxy:7890"),
patch(
"login_desktop_server._probe_login_target",
return_value={"ok": True, "status": 200, "latency_ms": 10},
) as probe,
):
route = await manager._select_network_route(force=True)
self.assertEqual("direct", route["mode"])
probe.assert_called_once_with(None, 15)
async def test_auto_mode_falls_back_to_proxy(self):
manager = LoginDesktopManager()
with (
patch("login_desktop_server.LOGIN_NETWORK_MODE", "auto"),
patch("login_desktop_server.LOGIN_PROXY_SERVER", "http://proxy:7890"),
patch(
"login_desktop_server._probe_login_target",
side_effect=[
{"ok": False, "error": "direct failed"},
{"ok": True, "status": 200, "latency_ms": 20},
],
) as probe,
):
route = await manager._select_network_route(force=True)
self.assertEqual("proxy", route["mode"])
self.assertIsNone(probe.call_args_list[0].args[0])
self.assertEqual("http://proxy:7890", probe.call_args_list[1].args[0])
async def test_auto_mode_reports_both_failures(self):
manager = LoginDesktopManager()
with (
patch("login_desktop_server.LOGIN_NETWORK_MODE", "auto"),
patch("login_desktop_server.LOGIN_PROXY_SERVER", "http://proxy:7890"),
patch(
"login_desktop_server._probe_login_target",
side_effect=[
{"ok": False, "error": "direct failed"},
{"ok": False, "error": "proxy failed"},
],
),
):
with self.assertRaises(LoginNetworkError) as caught:
await manager._select_network_route(force=True)
self.assertIn("直连和代理", str(caught.exception))
self.assertEqual({"direct", "proxy"}, set(caught.exception.checks))
async def test_network_preflight_exposes_selected_route_without_credentials(self):
manager = LoginDesktopManager()
with (
patch("login_desktop_server.LOGIN_NETWORK_MODE", "proxy"),
patch("login_desktop_server.LOGIN_PROXY_SERVER", "http://user:secret@proxy:7890"),
patch(
"login_desktop_server._probe_login_target",
return_value={"ok": True, "status": 200, "latency_ms": 5},
),
):
result = await manager.network_preflight(force=True)
self.assertTrue(result["ok"])
self.assertEqual("proxy", result["route"]["mode"])
self.assertEqual("proxy:7890", result["network"]["proxy"])
self.assertNotIn("secret", repr(result))
+11 -1
View File
@@ -295,7 +295,17 @@ def call_login_desktop(path: str, *, method: str = "GET", payload: dict | None =
return json.loads(body) if body.strip() else {} return json.loads(body) if body.strip() else {}
except urllib.error.HTTPError as exc: except urllib.error.HTTPError as exc:
body = exc.read().decode("utf-8", errors="replace") body = exc.read().decode("utf-8", errors="replace")
raise RuntimeError(f"login-desktop API error {exc.code}: {body}") from exc message = body
try:
payload = json.loads(body)
detail = payload.get("detail") if isinstance(payload, dict) else None
if isinstance(detail, dict):
message = str(detail.get("message") or detail.get("code") or body)
elif detail:
message = str(detail)
except (TypeError, ValueError):
pass
raise RuntimeError(f"login-desktop API error {exc.code}: {message}") from exc
except (urllib.error.URLError, TimeoutError) as exc: except (urllib.error.URLError, TimeoutError) as exc:
reason = getattr(exc, "reason", exc) reason = getattr(exc, "reason", exc)
raise RuntimeError(f"login-desktop unavailable: {reason}") from exc raise RuntimeError(f"login-desktop unavailable: {reason}") from exc
+11
View File
@@ -448,9 +448,20 @@
if (retries > 1 && workspace.state === "active") { if (retries > 1 && workspace.state === "active") {
if (qrStatus) qrStatus.textContent = "浏览器正在生成二维码,继续等待..."; if (qrStatus) qrStatus.textContent = "浏览器正在生成二维码,继续等待...";
refreshLoginQr(1400, retries - 1); refreshLoginQr(1400, retries - 1);
} else if (qrStatus) {
qrStatus.textContent = "登录页面在规定时间内没有生成二维码,请稍后重试。";
} }
return; return;
} }
if (response.status === 409) {
if (qrStatus) qrStatus.textContent = "二维码已过期,请点击刷新二维码。";
return;
}
if (response.status === 502) {
const data = await response.json().catch(() => ({}));
if (qrStatus) qrStatus.textContent = data.error || "无法访问抖音创作者中心,请检查服务器网络出口。";
return;
}
if (!response.ok) throw new Error(String(response.status)); if (!response.ok) throw new Error(String(response.status));
const blob = await response.blob(); const blob = await response.blob();
const previous = qrImage.dataset.objectUrl || ""; const previous = qrImage.dataset.objectUrl || "";
+5
View File
@@ -225,6 +225,9 @@ WEB_PORT=8787
LOGIN_DESKTOP_BIND_ADDRESS=127.0.0.1 LOGIN_DESKTOP_BIND_ADDRESS=127.0.0.1
LOGIN_DESKTOP_WEB_PORT=8788 LOGIN_DESKTOP_WEB_PORT=8788
LOGIN_DESKTOP_PUBLIC_URL=/login-desktop/proxy/vnc.html?autoconnect=1&resize=scale&view_only=0&path=login-desktop/proxy/websockify LOGIN_DESKTOP_PUBLIC_URL=/login-desktop/proxy/vnc.html?autoconnect=1&resize=scale&view_only=0&path=login-desktop/proxy/websockify
# 登录浏览器默认先直连抖音,直连失败时再尝试 Mihomo
LOGIN_DESKTOP_PROXY_MODE=auto
LOGIN_DESKTOP_PROXY=http://proxy:7890
# Mihomo 代理和控制端口默认仅绑定本机 # Mihomo 代理和控制端口默认仅绑定本机
PROXY_BIND_ADDRESS=127.0.0.1 PROXY_BIND_ADDRESS=127.0.0.1
@@ -233,6 +236,8 @@ PROXY_CONTROLLER_PORT=9090
PROXY_SUB_URL= PROXY_SUB_URL=
``` ```
登录工作区的网络路径与发送任务分开处理。`LOGIN_DESKTOP_PROXY_MODE=auto` 时,登录浏览器会从 `login-desktop` 容器内先直连 `creator.douyin.com`;只有直连预检失败时才使用 `LOGIN_DESKTOP_PROXY`。发送任务容器仍可独立使用 Mihomo。若两条路径都不可用,登录页面会显示明确的网络错误,不会无限等待二维码。
#### `config.example.json``config.json` - 应用配置 #### `config.example.json``config.json` - 应用配置
仓库跟踪 `DouYinSparkFlow/config.example.json`;首次运行会生成被 Git 忽略的 `DouYinSparkFlow/config.json`。常用配置示例: 仓库跟踪 `DouYinSparkFlow/config.example.json`;首次运行会生成被 Git 忽略的 `DouYinSparkFlow/config.json`。常用配置示例:
+4 -8
View File
@@ -77,14 +77,10 @@ services:
LOGIN_DESKTOP_IDLE_TIMEOUT_SECONDS: ${LOGIN_DESKTOP_IDLE_TIMEOUT_SECONDS:-1800} LOGIN_DESKTOP_IDLE_TIMEOUT_SECONDS: ${LOGIN_DESKTOP_IDLE_TIMEOUT_SECONDS:-1800}
LOGIN_DESKTOP_STOP_AFTER_EXPORT_SECONDS: ${LOGIN_DESKTOP_STOP_AFTER_EXPORT_SECONDS:-60} LOGIN_DESKTOP_STOP_AFTER_EXPORT_SECONDS: ${LOGIN_DESKTOP_STOP_AFTER_EXPORT_SECONDS:-60}
LOGIN_DESKTOP_STATUS_CACHE_SECONDS: ${LOGIN_DESKTOP_STATUS_CACHE_SECONDS:-15} LOGIN_DESKTOP_STATUS_CACHE_SECONDS: ${LOGIN_DESKTOP_STATUS_CACHE_SECONDS:-15}
HTTP_PROXY: http://proxy:7890 LOGIN_DESKTOP_PROXY_MODE: ${LOGIN_DESKTOP_PROXY_MODE:-auto}
HTTPS_PROXY: http://proxy:7890 LOGIN_DESKTOP_PROXY: ${LOGIN_DESKTOP_PROXY:-http://proxy:7890}
ALL_PROXY: socks5://proxy:7890 LOGIN_DESKTOP_PREFLIGHT_TIMEOUT_SECONDS: ${LOGIN_DESKTOP_PREFLIGHT_TIMEOUT_SECONDS:-15}
http_proxy: http://proxy:7890 LOGIN_DESKTOP_NETWORK_CACHE_SECONDS: ${LOGIN_DESKTOP_NETWORK_CACHE_SECONDS:-30}
https_proxy: http://proxy:7890
all_proxy: socks5://proxy:7890
NO_PROXY: localhost,127.0.0.1,login-desktop,douyin.com,amemv.com,snssdk.com,bytedance.com,pstatp.com,volccdn.com,bytescm.com,byted.net,douyinstatic.com,bytecdn.cn,byteimg.com,bytegoofy.com,toutiaostatic.com
no_proxy: localhost,127.0.0.1,login-desktop,douyin.com,amemv.com,snssdk.com,bytedance.com,pstatp.com,volccdn.com,bytescm.com,byted.net,douyinstatic.com,bytecdn.cn,byteimg.com,bytegoofy.com,toutiaostatic.com
ports: ports:
- "${LOGIN_DESKTOP_BIND_ADDRESS:-127.0.0.1}:${LOGIN_DESKTOP_WEB_PORT:-8788}:6080" - "${LOGIN_DESKTOP_BIND_ADDRESS:-127.0.0.1}:${LOGIN_DESKTOP_WEB_PORT:-8788}:6080"
command: bash /app/scripts/start_login_desktop.sh command: bash /app/scripts/start_login_desktop.sh
+4 -1
View File
@@ -79,13 +79,16 @@ ssh -L 8788:127.0.0.1:8788 <user>@<server-ip>
### 登录工作区打不开 ### 登录工作区打不开
先确认 SSH 隧道仍在运行,再检查 `login-desktop` 容器。默认不需要把 8788 暴露到公网。 先确认 SSH 隧道仍在运行,再检查 `login-desktop` 容器。默认不需要把 8788 暴露到公网。登录浏览器默认优先直连抖音,代理只作为备用路径。
```bash ```bash
docker compose ps docker compose ps
docker compose logs -f login-desktop docker compose logs -f login-desktop
docker compose exec login-desktop curl -fsS http://127.0.0.1:18090/preflight
``` ```
如果预检失败,页面会显示直连和代理各自的检查结果;通常不需要修改发送任务使用的 Mihomo 配置。
### 账号保存后没有好友列表 ### 账号保存后没有好友列表
进入 **账号与目标**,点击 **刷新好友列表**。刷新需要当前账号的登录态有效,如果登录态过期,回到 **登录工作区** 重新登录并保存。 进入 **账号与目标**,点击 **刷新好友列表**。刷新需要当前账号的登录态有效,如果登录态过期,回到 **登录工作区** 重新登录并保存。