diff --git a/.env.example b/.env.example index 2b65176..24abcee 100644 --- a/.env.example +++ b/.env.example @@ -15,11 +15,13 @@ LOGIN_DESKTOP_PIDS_LIMIT=256 LOGIN_DESKTOP_IDLE_TIMEOUT_SECONDS=1800 LOGIN_DESKTOP_STOP_AFTER_EXPORT_SECONDS=60 LOGIN_DESKTOP_STATUS_CACHE_SECONDS=15 + # Login browser uses direct access by default; Mihomo is an advanced option. LOGIN_DESKTOP_PROXY_MODE=direct 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_HTTP_PORT=7890 PROXY_CONTROLLER_PORT=9090 diff --git a/DouYinSparkFlow/core/browser.py b/DouYinSparkFlow/core/browser.py index d36f171..4aa5e51 100644 --- a/DouYinSparkFlow/core/browser.py +++ b/DouYinSparkFlow/core/browser.py @@ -52,13 +52,26 @@ def _browser_args(): ] -def _douyin_browser_proxy(): - """Return an explicit proxy for Douyin browser traffic, or None for direct.""" +def _douyin_network_mode(): settings = get_app_settings(force_reload=True) - mode = str( + return str( os.getenv("SPARKFLOW_DOUYIN_NETWORK_MODE") or settings.get("douyin_network_mode", "direct") ).strip().lower() + + +def douyin_network_modes(): + # Direct is the default; Mihomo is used only when explicitly selected. + mode = _douyin_network_mode() + if mode == "mihomo": + return ("mihomo",) + return ("direct",) + + +def _douyin_browser_proxy(network_mode=None): + # Return an explicit proxy URL for Douyin traffic, or None for direct. + settings = get_app_settings(force_reload=True) + mode = str(network_mode or _douyin_network_mode()).strip().lower() if mode != "mihomo": return None return str( @@ -67,13 +80,45 @@ def _douyin_browser_proxy(): ).strip() or None -def _browser_launch_options(GUI=False): +def _browser_launch_options(GUI=False, network_mode=None): args = _browser_args() - proxy = _douyin_browser_proxy() + proxy = _douyin_browser_proxy(network_mode=network_mode) if proxy: - return {"headless": _headless_for(GUI), "args": args, "proxy": {"server": proxy}} + return { + "headless": _headless_for(GUI), + "args": args, + "proxy": {"server": proxy}, + } args.append("--no-proxy-server") - return {"headless": _headless_for(GUI), "args": args} + return { + "headless": _headless_for(GUI), + "args": args, + } + + +async def select_douyin_network_mode(target_url): + # Select the first route that can load the target before a task starts. + failures = [] + for network_mode in douyin_network_modes(): + playwright = browser = page = None + try: + playwright, browser = await get_browser(network_mode=network_mode) + page = await browser.new_page() + response = await page.goto(target_url, wait_until="commit", timeout=30000) + status = response.status if response is not None else None + if status is not None and status < 500: + return network_mode + failures.append(f"{network_mode}: HTTP {status}") + except Exception as exc: + failures.append(f"{network_mode}: {exc}") + finally: + if page: + await page.close() + if browser: + await browser.close() + if playwright: + await playwright.stop() + raise RuntimeError(f"Douyin network preflight failed: {'; '.join(failures)}") def sanitize_profile_name(value): @@ -102,12 +147,12 @@ async def install_browser(): console.print(f"[bold red]Browser install failed: {exc}[/bold red]") -async def get_browser(GUI=False): +async def get_browser(GUI=False, network_mode=None): configure_playwright_environment() try: playwright = await async_playwright().start() - browser = await playwright.chromium.launch(**_browser_launch_options(GUI)) + browser = await playwright.chromium.launch(**_browser_launch_options(GUI, network_mode=network_mode)) return playwright, browser except Exception as exc: if "Executable doesn't exist" in str(exc) and get_environment() != Environment.GITHUBACTION: @@ -118,7 +163,7 @@ async def get_browser(GUI=False): raise -async def get_persistent_browser_context(profile_name, GUI=False, root=None): +async def get_persistent_browser_context(profile_name, GUI=False, root=None, network_mode=None): configure_playwright_environment() profile_dir = browser_profile_root(root) / sanitize_profile_name(profile_name) @@ -126,7 +171,7 @@ async def get_persistent_browser_context(profile_name, GUI=False, root=None): try: playwright = await async_playwright().start() - launch_options = _browser_launch_options(GUI) + launch_options = _browser_launch_options(GUI, network_mode=network_mode) launch_options["viewport"] = {"width": 1600, "height": 1000} context = await playwright.chromium.launch_persistent_context( str(profile_dir), diff --git a/DouYinSparkFlow/core/friends.py b/DouYinSparkFlow/core/friends.py index c0a6a54..66e9601 100644 --- a/DouYinSparkFlow/core/friends.py +++ b/DouYinSparkFlow/core/friends.py @@ -1,5 +1,10 @@ import asyncio -from core.browser import get_browser +import logging + +from core.browser import douyin_network_modes, get_browser + + +logger = logging.getLogger(__name__) CHAT_PAGE_URL = "https://creator.douyin.com/creator-micro/data/following/chat" @@ -218,19 +223,15 @@ async def collect_friend_names(page): return found_names -async def fetch_account_friends(account): +async def _fetch_account_friends_once(account, network_mode): cookies = list(account.get("cookies") or []) - if not cookies: - raise RuntimeError("账号没有可用 cookies,请重新扫码登录") - playwright = browser = context = page = None try: - playwright, browser = await get_browser(GUI=False) + playwright, browser = await get_browser(GUI=False, network_mode=network_mode) context = await browser.new_context() context.set_default_navigation_timeout(120000) context.set_default_timeout(120000) page = await context.new_page() - await context.add_cookies(cookies) await page.goto(CHAT_PAGE_URL, wait_until="commit", timeout=30000) await asyncio.sleep(1) @@ -250,3 +251,38 @@ async def fetch_account_friends(account): await browser.close() if playwright: await playwright.stop() + + +async def fetch_account_friends(account): + cookies = list(account.get("cookies") or []) + if not cookies: + raise RuntimeError("account has no cookies; scan login QR code first") + + modes = douyin_network_modes() + last_error = None + for index, network_mode in enumerate(modes): + try: + friends = await _fetch_account_friends_once(account, network_mode) + logger.info( + "Friend refresh route=%s count=%s attempt=%s/%s", + network_mode, + len(friends), + index + 1, + len(modes), + ) + if friends or index == len(modes) - 1: + return friends + logger.warning( + "Friend refresh route=%s returned zero friends; trying next route", + network_mode, + ) + except RuntimeError as exc: + text = str(exc).lower() + if any(marker in text for marker in ("login", "cookie", "scan", "登录", "扫码")): + raise + last_error = exc + logger.warning("Friend refresh route=%s failed; trying next route: %s", network_mode, exc) + except Exception as exc: + last_error = exc + logger.warning("Friend refresh route=%s failed; trying next route: %s", network_mode, exc) + raise RuntimeError(f"friend refresh failed after routes {modes}: {last_error}") diff --git a/DouYinSparkFlow/core/tasks.py b/DouYinSparkFlow/core/tasks.py index c2d83e5..f5b7e5e 100644 --- a/DouYinSparkFlow/core/tasks.py +++ b/DouYinSparkFlow/core/tasks.py @@ -12,7 +12,12 @@ from datetime import datetime, timedelta, timezone from pathlib import Path from zoneinfo import ZoneInfo -from core.browser import get_browser, get_persistent_browser_context, sanitize_profile_name +from core.browser import ( + get_browser, + get_persistent_browser_context, + sanitize_profile_name, + select_douyin_network_mode, +) from core.msg_builder import build_message, build_message_candidates from core.protocol_dispatch import run_protocol_tasks from core.send_state import parse_sent_at, target_is_strong_confirmed_today @@ -2354,6 +2359,8 @@ async def run_browser_tasks(active_config, browser_user_data): send_strategy = _normalize_send_strategy(active_config) friend_scan_config = _normalize_friend_list_scan_config(active_config) profile_config = _normalize_persistent_profile_config(active_config) + network_mode = await select_douyin_network_mode(CREATOR_HOME_URL) + logger.info("Selected Douyin task network route=%s", network_mode) semaphore = asyncio.Semaphore(active_config["taskCount"] if active_config["multiTask"] else 1) tasks = [] @@ -2371,11 +2378,11 @@ async def run_browser_tasks(active_config, browser_user_data): user.get("username", "unknown"), len(user["targets"]), ) - tasks.append(do_user_task(None, user, semaphore, send_strategy, profile_config, friend_scan_config)) + tasks.append(do_user_task(None, user, semaphore, send_strategy, profile_config, friend_scan_config, network_mode)) await asyncio.gather(*tasks) return - playwright, browser = await get_browser() + playwright, browser = await get_browser(network_mode=network_mode) try: for user in browser_user_data: logger.info( @@ -2383,7 +2390,7 @@ async def run_browser_tasks(active_config, browser_user_data): user.get("username", "unknown"), len(user["targets"]), ) - tasks.append(do_user_task(browser, user, semaphore, send_strategy, profile_config, friend_scan_config)) + tasks.append(do_user_task(browser, user, semaphore, send_strategy, profile_config, friend_scan_config, network_mode)) await asyncio.gather(*tasks) finally: @@ -2391,7 +2398,7 @@ async def run_browser_tasks(active_config, browser_user_data): await browser.close() -async def do_user_task(browser, user, semaphore, send_strategy, profile_config, friend_scan_config): +async def do_user_task(browser, user, semaphore, send_strategy, profile_config, friend_scan_config, network_mode): async with semaphore: account_name = user.get("username", "unknown") account_lock_handle = None @@ -2416,6 +2423,7 @@ async def do_user_task(browser, user, semaphore, send_strategy, profile_config, profile_config, friend_scan_config, account_name, + network_mode, ), timeout=timeout_seconds, ) @@ -2437,7 +2445,7 @@ async def do_user_task(browser, user, semaphore, send_strategy, profile_config, _release_browser_account_lock(account_lock_handle, account_lock_path, account_name) -async def _do_user_task_locked(browser, user, send_strategy, profile_config, friend_scan_config, account_name): +async def _do_user_task_locked(browser, user, send_strategy, profile_config, friend_scan_config, account_name, network_mode): cookies = user["cookies"] targets = user["targets"] start_delay = _random_delay_seconds( @@ -2453,6 +2461,7 @@ async def _do_user_task_locked(browser, user, send_strategy, profile_config, fri owned_playwright, context, profile_dir = await get_persistent_browser_context( _account_profile_name(user), root=profile_config["root"], + network_mode=network_mode, ) logger.info("Opened persistent browser profile for %s at %s", account_name, profile_dir) if profile_config["syncStoredCookiesBeforeRun"]: diff --git a/DouYinSparkFlow/tests/test_network_fallback.py b/DouYinSparkFlow/tests/test_network_fallback.py new file mode 100644 index 0000000..331b78d --- /dev/null +++ b/DouYinSparkFlow/tests/test_network_fallback.py @@ -0,0 +1,32 @@ +import unittest +from pathlib import Path + +REPO_ROOT = Path(__file__).resolve().parents[2] +SOURCE_ROOT = REPO_ROOT / "DouYinSparkFlow" + +class NetworkFallbackContractTests(unittest.TestCase): + def test_browser_exposes_direct_first_routes_and_preflight(self): + browser = (SOURCE_ROOT / "core" / "browser.py").read_text(encoding="utf-8") + self.assertIn("def douyin_network_modes", browser) + self.assertIn("return (\"direct\", \"mihomo\")", browser) + self.assertIn("async def select_douyin_network_mode", browser) + self.assertIn("get_browser(network_mode=network_mode)", browser) + + def test_friend_refresh_can_try_the_next_route(self): + friends = (SOURCE_ROOT / "core" / "friends.py").read_text(encoding="utf-8") + self.assertIn("for index, network_mode in enumerate(modes)", friends) + self.assertIn("returned zero friends; trying next route", friends) + self.assertIn("get_browser(GUI=False, network_mode=network_mode)", friends) + + def test_tasks_select_route_before_browser_creation(self): + tasks = (SOURCE_ROOT / "core" / "tasks.py").read_text(encoding="utf-8") + self.assertIn("select_douyin_network_mode(CREATOR_HOME_URL)", tasks) + self.assertIn("get_browser(network_mode=network_mode)", tasks) + self.assertIn("network_mode=network_mode", tasks) + + def test_login_defaults_to_auto_in_compose(self): + compose = (REPO_ROOT / "docker-compose.yml").read_text(encoding="utf-8") + self.assertIn("LOGIN_DESKTOP_PROXY_MODE: ${LOGIN_DESKTOP_PROXY_MODE:-auto}", compose) + +if __name__ == "__main__": + unittest.main() diff --git a/README.md b/README.md index 295d901..6e5fc37 100644 --- a/README.md +++ b/README.md @@ -91,12 +91,17 @@ git clone https://github.com/halfwaystudent/douyin-sparkflow.git cd douyin-sparkflow -# 2. 配置环境变量 +# 2. 创建本地环境变量 cp .env.example .env nano .env # 根据需要修改配置 +# 可选:在本地 .env 中填写 PROXY_SUB_URL,不要提交真实订阅地址 -# 3. 启动服务 -docker compose up -d +# 3. 初始化运行时文件并启动服务 +# 会创建 proxy/config.yaml;没有订阅时使用 DIRECT-only 配置 +bash ./deploy/install-local.sh + +# Windows PowerShell 使用: +# powershell -ExecutionPolicy Bypass -File .\deploy\install-local.ps1 # 4. 访问 Web 界面 # 浏览器打开 http://localhost:8787 @@ -185,7 +190,8 @@ douyin-sparkflow/ │ └── login_desktop_server.py # 登录桌面服务 ├── .github/workflows/ # GitHub Actions 定时任务 ├── proxy/ # 代理配置 -│ └── config.yaml # Mihomo 代理配置 +│ ├── config.example.yaml # Git 跟踪的安全模板 +│ └── config.yaml # 本地生成,Git 忽略 ├── docker-compose.yml # 容器编排配置 ├── .env.example # 环境变量模板 ├── refresh_proxy.sh # 代理刷新脚本 @@ -231,19 +237,24 @@ WEB_PORT=8787 LOGIN_DESKTOP_BIND_ADDRESS=127.0.0.1 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 + # 登录浏览器默认直连;Mihomo 仅作为高级选项 LOGIN_DESKTOP_PROXY_MODE=direct LOGIN_DESKTOP_PROXY=http://proxy:7890 + # Mihomo 代理和控制端口默认仅绑定本机 PROXY_BIND_ADDRESS=127.0.0.1 PROXY_HTTP_PORT=7890 PROXY_CONTROLLER_PORT=9090 +# 可选:Mihomo/Clash 订阅地址。通常包含敏感 token,只写入本地 .env。 PROXY_SUB_URL= ``` + 默认抖音业务网络使用直连。登录、好友刷新和浏览器发送会显式禁用环境代理,避免未配置的 Mihomo 影响正常使用。高级用户可在 Web UI「系统设置」中选择 Mihomo 并填写代理地址;登录浏览器仍可通过 `LOGIN_DESKTOP_PROXY_MODE=proxy` 强制使用代理。 + #### `config.example.json` 与 `config.json` - 应用配置 仓库跟踪 `DouYinSparkFlow/config.example.json`;首次运行会生成被 Git 忽略的 `DouYinSparkFlow/config.json`。常用配置示例: @@ -299,8 +310,8 @@ PROXY_SUB_URL= # 1. 准备环境变量 cp .env.example .env -# 2. 启动所有服务 -docker compose up -d +# 2. 初始化 proxy/config.yaml 并启动所有服务 +bash ./deploy/install-local.sh # 3. 查看日志 docker compose logs -f @@ -372,15 +383,17 @@ server { ### 代理配置 -项目支持通过代理访问抖音服务,配置文件位于 `proxy/config.yaml`: +项目支持通过代理访问抖音服务。仓库提供 `proxy/config.example.yaml` 作为安全模板,部署脚本会在启动前生成本地的 `proxy/config.yaml`: ```yaml mixed-port: 7890 allow-lan: true mode: rule -# ... 更多配置见配置文件 +# ... 更多配置见 proxy/config.example.yaml ``` +如果 `PROXY_SUB_URL` 不为空,`refresh_proxy.sh` 会下载订阅并更新本地配置;如果为空,则生成 DIRECT-only 配置。不要在 Git 中提交包含订阅 token 的 `proxy/config.yaml`。首次部署不要跳过初始化步骤直接执行 `docker compose up -d`,否则 Docker 可能把缺失的配置文件创建成目录。 + ### 默认网络安全 diff --git a/docker-compose.yml b/docker-compose.yml index e6b3993..93a2288 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -22,12 +22,6 @@ services: args: PLAYWRIGHT_BASE_IMAGE: ${PLAYWRIGHT_BASE_IMAGE:-swr.cn-north-4.myhuaweicloud.com/ddn-k8s/mcr.microsoft.com/playwright/python:v1.56.0-jammy} NODE_RUNTIME_IMAGE: ${NODE_RUNTIME_IMAGE:-node:22-bookworm-slim} - HTTP_PROXY: ${HTTP_PROXY_BUILD:-} - HTTPS_PROXY: ${HTTPS_PROXY_BUILD:-} - ALL_PROXY: ${ALL_PROXY_BUILD:-} - http_proxy: ${HTTP_PROXY_BUILD:-} - https_proxy: ${HTTPS_PROXY_BUILD:-} - all_proxy: ${ALL_PROXY_BUILD:-} PIP_INDEX_URL: ${PIP_INDEX_URL:-https://pypi.tuna.tsinghua.edu.cn/simple} PIP_TRUSTED_HOST: ${PIP_TRUSTED_HOST:-pypi.tuna.tsinghua.edu.cn} image: douyin-sparkflow:local @@ -69,10 +63,12 @@ services: 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_STATUS_CACHE_SECONDS: ${LOGIN_DESKTOP_STATUS_CACHE_SECONDS:-15} + LOGIN_DESKTOP_PROXY_MODE: ${LOGIN_DESKTOP_PROXY_MODE:-direct} LOGIN_DESKTOP_PROXY: ${LOGIN_DESKTOP_PROXY:-http://proxy:7890} LOGIN_DESKTOP_PREFLIGHT_TIMEOUT_SECONDS: ${LOGIN_DESKTOP_PREFLIGHT_TIMEOUT_SECONDS:-15} LOGIN_DESKTOP_NETWORK_CACHE_SECONDS: ${LOGIN_DESKTOP_NETWORK_CACHE_SECONDS:-30} + ports: - "${LOGIN_DESKTOP_BIND_ADDRESS:-127.0.0.1}:${LOGIN_DESKTOP_WEB_PORT:-8788}:6080" command: bash /app/scripts/start_login_desktop.sh