mirror of
https://github.com/halfwaystudent/douyin-sparkflow.git
synced 2026-09-07 00:17:20 +08:00
feat: add direct-first Mihomo fallback
This commit is contained in:
@@ -8,7 +8,7 @@ from pathlib import Path
|
||||
from playwright.async_api import async_playwright
|
||||
from rich.console import Console
|
||||
|
||||
from utils.config import DEBUG, Environment, get_environment
|
||||
from utils.config import DEBUG, Environment, get_app_settings, get_environment
|
||||
|
||||
|
||||
console = Console()
|
||||
@@ -52,6 +52,75 @@ def _browser_args():
|
||||
]
|
||||
|
||||
|
||||
def _douyin_network_mode():
|
||||
settings = get_app_settings(force_reload=True)
|
||||
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 the fallback unless explicitly selected.
|
||||
mode = _douyin_network_mode()
|
||||
if mode == "mihomo":
|
||||
return ("mihomo",)
|
||||
return ("direct", "mihomo")
|
||||
|
||||
|
||||
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(
|
||||
os.getenv("SPARKFLOW_DOUYIN_PROXY_URL")
|
||||
or settings.get("douyin_proxy_url", "http://proxy:7890")
|
||||
).strip() or None
|
||||
|
||||
|
||||
def _browser_launch_options(GUI=False, network_mode=None):
|
||||
args = _browser_args()
|
||||
proxy = _douyin_browser_proxy(network_mode=network_mode)
|
||||
if proxy:
|
||||
return {
|
||||
"headless": _headless_for(GUI),
|
||||
"args": args,
|
||||
"proxy": {"server": proxy},
|
||||
}
|
||||
args.append("--no-proxy-server")
|
||||
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):
|
||||
raw = str(value or "").strip()
|
||||
if not raw:
|
||||
@@ -78,15 +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(
|
||||
headless=_headless_for(GUI),
|
||||
args=_browser_args(),
|
||||
)
|
||||
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:
|
||||
@@ -97,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)
|
||||
@@ -105,11 +171,11 @@ async def get_persistent_browser_context(profile_name, GUI=False, root=None):
|
||||
|
||||
try:
|
||||
playwright = await async_playwright().start()
|
||||
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),
|
||||
headless=_headless_for(GUI),
|
||||
viewport={"width": 1600, "height": 1000},
|
||||
args=_browser_args(),
|
||||
**launch_options,
|
||||
)
|
||||
return playwright, context, profile_dir
|
||||
except Exception as exc:
|
||||
|
||||
@@ -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"
|
||||
@@ -141,7 +146,21 @@ async def _wait_for_first_friend_or_empty(page):
|
||||
return False
|
||||
|
||||
|
||||
async def _wait_for_chat_or_login(page, timeout_seconds=30):
|
||||
deadline = asyncio.get_running_loop().time() + timeout_seconds
|
||||
while asyncio.get_running_loop().time() < deadline:
|
||||
await _ensure_logged_in(page)
|
||||
try:
|
||||
if await page.locator("#sub-app").count() > 0:
|
||||
return
|
||||
except Exception:
|
||||
pass
|
||||
await asyncio.sleep(0.5)
|
||||
raise RuntimeError("chat page did not load within timeout")
|
||||
|
||||
|
||||
async def collect_friend_names(page):
|
||||
await _wait_for_chat_or_login(page)
|
||||
await _click_friends_tab(page)
|
||||
await asyncio.sleep(1)
|
||||
|
||||
@@ -204,31 +223,19 @@ 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 page.goto("https://creator.douyin.com/", wait_until="domcontentloaded", timeout=60000)
|
||||
await context.add_cookies(cookies)
|
||||
await page.goto(CHAT_PAGE_URL, wait_until="domcontentloaded", timeout=60000)
|
||||
await asyncio.sleep(2)
|
||||
|
||||
await _ensure_logged_in(page)
|
||||
friends = await collect_friend_names(page)
|
||||
return friends
|
||||
except RuntimeError:
|
||||
raise
|
||||
except Exception as exc:
|
||||
raise RuntimeError(f"刷新好友列表失败,请重试:{exc}") from exc
|
||||
await page.goto(CHAT_PAGE_URL, wait_until="commit", timeout=30000)
|
||||
await asyncio.sleep(1)
|
||||
return await collect_friend_names(page)
|
||||
finally:
|
||||
if page:
|
||||
await page.close()
|
||||
@@ -238,3 +245,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}")
|
||||
|
||||
@@ -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
|
||||
@@ -1625,6 +1630,13 @@ def _manual_run_unsent_only():
|
||||
return _is_manual_run() and os.getenv("SPARKFLOW_MANUAL_UNSENT_ONLY") == "1"
|
||||
|
||||
|
||||
def _requested_account_refs():
|
||||
raw = os.getenv("SPARKFLOW_ACCOUNT_REFS")
|
||||
if raw is None:
|
||||
return None
|
||||
return {item.strip() for item in raw.split(",") if item.strip()}
|
||||
|
||||
|
||||
def _unsent_retry_max_attempts():
|
||||
raw_value = str(os.getenv("SPARKFLOW_UNSENT_RETRY_MAX_ATTEMPTS") or "3").strip()
|
||||
try:
|
||||
@@ -2347,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 = []
|
||||
|
||||
@@ -2364,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(
|
||||
@@ -2376,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:
|
||||
@@ -2384,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
|
||||
@@ -2409,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,
|
||||
)
|
||||
@@ -2430,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(
|
||||
@@ -2446,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"]:
|
||||
@@ -2719,6 +2735,9 @@ async def _do_user_task_locked(browser, user, send_strategy, profile_config, fri
|
||||
async def runTasks():
|
||||
active_config = get_config(force_reload=True)
|
||||
all_user_data = get_userData(force_reload=True)
|
||||
requested_refs = _requested_account_refs()
|
||||
if requested_refs is not None:
|
||||
all_user_data = [user for user in all_user_data if user.get("account_ref") in requested_refs]
|
||||
active_user_data = [user for user in all_user_data if user.get("enabled", True)]
|
||||
disabled_user_data = [user for user in all_user_data if not user.get("enabled", True)]
|
||||
|
||||
|
||||
Reference in New Issue
Block a user