mirror of
https://github.com/halfwaystudent/douyin-sparkflow.git
synced 2026-09-07 00:17:20 +08:00
fix: prefer direct login browser access with proxy fallback
This commit is contained in:
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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))
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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 || "";
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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 配置。
|
||||||
|
|
||||||
### 账号保存后没有好友列表
|
### 账号保存后没有好友列表
|
||||||
|
|
||||||
进入 **账号与目标**,点击 **刷新好友列表**。刷新需要当前账号的登录态有效,如果登录态过期,回到 **登录工作区** 重新登录并保存。
|
进入 **账号与目标**,点击 **刷新好友列表**。刷新需要当前账号的登录态有效,如果登录态过期,回到 **登录工作区** 重新登录并保存。
|
||||||
|
|||||||
Reference in New Issue
Block a user