fix: harden scheduling and deployment flow

This commit is contained in:
Rixuan Shao
2026-07-11 14:58:05 +08:00
parent c496d90039
commit e3142cabbf
28 changed files with 552 additions and 1750 deletions
+7
View File
@@ -1,8 +1,15 @@
APP_ROOT=/opt/douyin-sparkflow
TZ=Asia/Shanghai
WEB_BIND_ADDRESS=0.0.0.0
WEB_PORT=8787
SPARKFLOW_SESSION_COOKIE_SECURE=0
# Keep noVNC and proxy control ports local by default. Use an SSH tunnel for remote access.
LOGIN_DESKTOP_BIND_ADDRESS=127.0.0.1
LOGIN_DESKTOP_WEB_PORT=8788
LOGIN_DESKTOP_PUBLIC_URL=http://127.0.0.1:8788/vnc.html?autoconnect=1&resize=scale&view_only=0
PROXY_BIND_ADDRESS=127.0.0.1
PROXY_HTTP_PORT=7890
PROXY_CONTROLLER_PORT=9090
+63
View File
@@ -0,0 +1,63 @@
name: DouYin Spark Flow Schedule Run
on:
workflow_dispatch:
schedule:
- cron: "0 2 * * *" # 10:00 Asia/Shanghai
permissions:
contents: read
concurrency:
group: douyin-sparkflow-scheduled-send
cancel-in-progress: false
defaults:
run:
working-directory: DouYinSparkFlow
jobs:
run-main:
timeout-minutes: 30
runs-on: ubuntu-latest
environment: user-data
steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
- name: Set up Python
uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5
with:
python-version: "3.11"
- name: Install dependencies
run: |
python -m pip install --upgrade pip
pip install -r requirements.txt
- name: Run unit tests
run: python -m unittest discover -s tests -v
- name: Test Douyin accessibility
run: |
status="$(curl -sS -o /dev/null -w '%{http_code}' --max-time 30 https://creator.douyin.com/)"
test "$status" != "000"
- name: Ensure Chromium is installed
run: playwright install chromium --with-deps --only-shell
- name: Run DouYin Spark Flow
env:
USER_DATA: ${{ secrets.USER_DATA }}
SPARKFLOW_BROWSER_PROFILE_ROOT: ${{ runner.temp }}/douyin-sparkflow-browser-profiles
SPARKFLOW_MANUAL_RUN: "1"
PYTHONUNBUFFERED: "1"
run: python main.py --doTask
- uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
if: ${{ !cancelled() }}
with:
name: run-logs
path: DouYinSparkFlow/logs/
if-no-files-found: ignore
retention-days: 3
workflow-keepalive:
if: github.event_name == 'schedule'
runs-on: ubuntu-latest
permissions:
actions: write
steps:
- uses: liskin/gh-workflow-keepalive@f72ff1a1336129f29bf0166c0fd0ca6cf1bcb38c # v1
+1
View File
@@ -14,3 +14,4 @@ DouYinSparkFlow/im_client_introspect.mjs
DouYinSparkFlow/core/protocol_sender_debug.mjs
DouYinSparkFlow/**/__pycache__/
DouYinSparkFlow/**/*.pyc
DouYinSparkFlow/config.json
+6
View File
@@ -10,6 +10,12 @@
- Added unit coverage for send-state behavior, stale-lock safety, overview redaction, authentication, and public settings.
- Made stale-lock PID probing portable across Linux and Windows and sanitized the public configuration template.
- Removed obsolete standalone account, login-workspace, and settings templates now consolidated into the dashboard.
- Fixed standard Compose scheduling by sharing persistent browser profiles with scheduler/task services, while limiting host Docker access to services that actually need it.
- Moved the GitHub Actions workflow to the repository root, corrected its working directory/tests/profile/artifact paths, and pinned actions to commit SHAs.
- Split the tracked `config.example.json` template from the ignored runtime `config.json`, with update-time config preservation.
- Made login-desktop API/public URLs honor container environment settings and synchronize the configured schedule at Web startup.
- Bound noVNC and Mihomo host ports to loopback by default and documented SSH-tunnel access.
- Added an explicit Node.js image build check for protocol mode, made the cron reader tolerate Windows UTF-8 BOM files, and removed unused legacy login-session, relogin-worker, and nested Compose entrypoints.
## 2026-05-30
-41
View File
@@ -1,41 +0,0 @@
name: DouYin Spark Flow Schedule Run
on:
workflow_dispatch: # 允许手动触发
schedule: # 定时任务
- cron: "0 1 * * *" # 每天 1:00 UTC(对应北京时间 9:00
jobs:
run-main:
timeout-minutes: 20
runs-on: ubuntu-latest
environment: user-data
steps:
- uses: actions/checkout@v4
- name: Set up Python
uses: actions/setup-python@v4
with:
python-version: '3.11'
- name: Test Douyin Accessibility
run: |
curl -I https://creator.douyin.com/
- name: Install dependencies
run: |
python -m pip install --upgrade pip
pip install -r requirements.txt
- name: Ensure browsers are installed
run: playwright install chromium --with-deps --only-shell
- name: Run DouYin Spark Flow
env:
USER_DATA: ${{ secrets.USER_DATA }}
run: python main.py --doTask
- uses: actions/upload-artifact@v4
if: ${{ !cancelled() }}
with:
name: run-logs
path: logs/
workflow-keepalive:
if: github.event_name == 'schedule'
runs-on: ubuntu-latest
permissions:
actions: write
steps:
- uses: liskin/gh-workflow-keepalive@v1
+1
View File
@@ -6,3 +6,4 @@ logs/
.DS_Store
usersData.json
webui_settings.json
config.json
+5 -9
View File
@@ -1,4 +1,5 @@
FROM mcr.microsoft.com/playwright/python:v1.56.0-jammy
ARG PLAYWRIGHT_BASE_IMAGE=mcr.microsoft.com/playwright/python:v1.56.0-jammy
FROM ${PLAYWRIGHT_BASE_IMAGE}
WORKDIR /app
@@ -32,6 +33,7 @@ RUN sed -i 's/archive.ubuntu.com/mirrors.aliyun.com/g' /etc/apt/sources.list \
&& apt-get update && apt-get install -y \
cron \
curl \
docker.io \
fluxbox \
fonts-wqy-zenhei \
fonts-noto-cjk \
@@ -39,14 +41,8 @@ RUN sed -i 's/archive.ubuntu.com/mirrors.aliyun.com/g' /etc/apt/sources.list \
websockify \
x11vnc \
xfonts-intl-chinese \
&& curl -fsSL ${HTTP_PROXY:+-x "$HTTP_PROXY"} https://download.docker.com/linux/static/stable/x86_64/docker-25.0.3.tgz -o docker.tgz \
&& tar xzvf docker.tgz \
&& mv docker/docker /usr/bin/docker \
&& chmod +x /usr/bin/docker \
&& rm -rf docker docker.tgz \
&& mkdir -p /usr/local/lib/docker/cli-plugins \
&& curl -SL ${HTTP_PROXY:+-x "$HTTP_PROXY"} https://github.com/docker/compose/releases/download/v2.24.5/docker-compose-linux-x86_64 -o /usr/local/lib/docker/cli-plugins/docker-compose \
&& chmod +x /usr/local/lib/docker/cli-plugins/docker-compose \
&& docker --version \
&& node --version \
&& rm -rf /var/lib/apt/lists/*
COPY . .
+23 -24
View File
@@ -33,7 +33,6 @@
|------|------|
| `app.py` | FastAPI 主应用,路由定义 |
| `auth.py` | 用户认证和会话管理 |
| `login_sessions.py` | 登录会话生命周期管理 |
| `ops.py` | 操作接口(启动/停止任务、刷新好友等) |
#### 前端资源
@@ -73,11 +72,10 @@ webui/
| `cron_runner.py` | Cron 任务运行器 |
| `start_login_desktop.sh` | 登录桌面启动脚本 |
### `docs/` - 文档和资源
### `docs/` - 截图资源
| 文件/目录 | 说明 |
| 目录 | 说明 |
|----------|------|
| `usage.md` | 详细使用教程(含截图) |
| `images/` | 界面截图和示意图 |
---
@@ -138,9 +136,9 @@ docker run -d \
## ⚙️ 配置文件
### `config.json` - 应用配置
### `config.example.json` 与 `config.json` - 应用配置
主配置文件控制发送窗口、好友扫描、浏览器 Profile 和消息策略。仓库版本不包含真实账号标识
`config.example.json` 是公开模板;首次运行会生成不受 Git 跟踪的 `config.json`,用于保存 Web 中修改的发送窗口、好友扫描、浏览器 Profile 和消息策略:
```json
{
@@ -151,7 +149,7 @@ docker run -d \
"enabled": true,
"startHour": 10,
"endHour": 18,
"scheduleIntervalMinutes": 10
"scheduleIntervalMinutes": 20
},
"friendListScan": {
"maxScanSeconds": 300,
@@ -185,17 +183,17 @@ docker run -d \
示例结构:
```json
{
"accounts": [
{
"id": "user_123",
"nickname": "用户昵称",
"friends": [...],
"last_send_time": "2026-06-20T10:30:00",
"send_history": [...]
}
]
}
[
{
"unique_id": "123456789",
"username": "账号显示名",
"cookies": [],
"targets": ["目标好友"],
"enabled": true,
"message_history": {},
"failure_queue": {}
}
]
```
### `webui_settings.json` - Web UI 设置
@@ -265,8 +263,8 @@ Web UI 提供以下 RESTful API 接口:
定时触发 → tasks.py 检查发送条件
→ 筛选需要发送的好友
→ msg_builder.py 构建消息内容
→ protocol_sender.mjs 发送消息
→ 等待发送确认
按配置选择 Playwright 浏览器发送或 protocol_sender.mjs 协议发送
→ 等待强证据发送确认
→ 记录发送历史
→ 更新下次发送时间
```
@@ -318,7 +316,7 @@ A: 检查 `login_desktop_server.py` 是否正常运行,端口 18090 是否被
A: 确保已安装 Playwright`playwright install chromium`
**Q: 消息发送失败?**
A: 检查网络连接,查看 `logs/tasks.log` 中的错误信息。
A: 检查网络连接,查看 `logs/app.log` 或 Web 运行日志中的错误信息。
**Q: Web UI 无法访问?**
A: 检查端口 8787 是否被占用,防火墙是否允许该端口。
@@ -331,7 +329,7 @@ A: 检查端口 8787 是否被占用,防火墙是否允许该端口。
```
playwright>=1.40.0 # 浏览器自动化
apscheduler>=3.10.0 # 任务调度
自定义 cron_runner.py # 任务调度(无需额外 Python 依赖)
```
### `requirements-web.txt` - Web 依赖
@@ -352,6 +350,7 @@ jinja2>=3.1.0 # 模板引擎
以下文件**绝对不要**提交到 Git 仓库:
-`config.json` - 运行时发送配置
-`usersData.json` - 包含账号数据和好友信息
-`webui_settings.json` - 包含管理员密码
-`.env` - 包含环境变量和密钥
@@ -372,8 +371,8 @@ jinja2>=3.1.0 # 模板引擎
## 📚 相关文档
- [项目主 README](../README.md) - 整体介绍和快速开始
- [使用文档](docs/usage.md) - 详细使用教程
- [更新日志](CHANGELOG.md) - 版本更新历史
- [使用文档](../docs/usage.md) - 详细使用教程
- [更新日志](../CHANGELOG.md) - 版本更新历史
- [Docker 部署](../docker-compose.yml) - 容器编排配置
---
@@ -24,7 +24,7 @@
"enabled": true,
"startHour": 10,
"endHour": 18,
"scheduleIntervalMinutes": 10
"scheduleIntervalMinutes": 20
},
"hitokotoTypes": [
"文学",
@@ -33,7 +33,7 @@
"哲学"
],
"happyNewYear": {
"enabled": true,
"enabled": false,
"messageTemplate": "\r\n"
},
"friendListScan": {
-2
View File
@@ -1,6 +1,4 @@
import asyncio
from pathlib import Path
from core.browser import get_browser
+5 -1
View File
@@ -120,7 +120,11 @@ def _normalize_persistent_profile_config(active_config):
raw = active_config.get("persistentBrowserProfiles", {}) or {}
return {
"enabled": bool(raw.get("enabled", False)),
"root": str(raw.get("root") or "/opt/douyin-sparkflow/state/browser-profiles"),
"root": str(
os.getenv("SPARKFLOW_BROWSER_PROFILE_ROOT")
or raw.get("root")
or "/opt/douyin-sparkflow/state/browser-profiles"
),
"seedCookiesWhenEmpty": bool(raw.get("seedCookiesWhenEmpty", True)),
"syncStoredCookiesBeforeRun": bool(raw.get("syncStoredCookiesBeforeRun", True)),
"refreshStoredCookiesAfterLogin": bool(raw.get("refreshStoredCookiesAfterLogin", True)),
@@ -1,56 +0,0 @@
services:
proxy:
image: metacubex/mihomo:latest
container_name: mihomo
restart: unless-stopped
ports:
- "7890:7890"
- "9090:9090"
volumes:
- ./proxy/config.yaml:/root/.config/mihomo/config.yaml
web:
build:
context: .
dockerfile: Dockerfile.server
network: host
args:
HTTP_PROXY: http://127.0.0.1:7890
HTTPS_PROXY: http://127.0.0.1:7890
ALL_PROXY: socks5://127.0.0.1:7890
image: douyin-sparkflow:local
container_name: douyin-web
restart: unless-stopped
depends_on:
- proxy
environment:
TZ: Asia/Shanghai
HTTP_PROXY: http://proxy:7890
HTTPS_PROXY: http://proxy:7890
ALL_PROXY: socks5://proxy:7890
NO_PROXY: localhost,127.0.0.1,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:
- "8787:8787"
command: python main.py --web --host 0.0.0.0 --port 8787
volumes:
- .:/app
- ./logs:/app/logs
- /var/run/docker.sock:/var/run/docker.sock
- /var/spool/cron/root:/var/spool/cron/crontabs/root
task:
image: douyin-sparkflow:local
container_name: douyin-task
depends_on:
- proxy
environment:
TZ: Asia/Shanghai
HTTP_PROXY: http://proxy:7890
HTTPS_PROXY: http://proxy:7890
ALL_PROXY: socks5://proxy:7890
NO_PROXY: localhost,127.0.0.1,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
command: python main.py --doTask
volumes:
- .:/app
- ./logs:/app/logs
restart: "no"
-261
View File
@@ -1,261 +0,0 @@
import argparse
import asyncio
import json
from datetime import datetime
from pathlib import Path
def parse_args():
parser = argparse.ArgumentParser(description="Run a headless Douyin relogin worker.")
parser.add_argument("--repo-root", required=True)
parser.add_argument("--account-index", type=int, required=True)
parser.add_argument("--state-file", required=True)
parser.add_argument("--screenshot-path", required=True)
parser.add_argument("--poll-interval", type=float, default=2.0)
parser.add_argument("--timeout-seconds", type=int, default=900)
return parser.parse_args()
def now_iso():
return datetime.now().isoformat(timespec="seconds")
def write_state(path: Path, **payload):
base = {"updated_at": now_iso(), **payload}
path.write_text(json.dumps(base, ensure_ascii=False, indent=2), encoding="utf-8")
async def capture_login_screenshot(page, screenshot_path: Path, prefer_verification=False):
verification_selectors = [
".pc-login-verification-modal",
".semi-modal-content",
".semi-modal",
'div[role="dialog"]',
]
qr_selectors = [
".login-img-code-wrapper",
'div[class*="qrcode"]',
"canvas",
".login-mask",
".login-guide-container",
]
selectors = verification_selectors + qr_selectors if prefer_verification else qr_selectors + verification_selectors
for selector in selectors:
locator = page.locator(selector).first
try:
if await locator.count() > 0 and await locator.is_visible():
await locator.scroll_into_view_if_needed()
await locator.screenshot(path=str(screenshot_path))
return selector
except Exception:
continue
await page.screenshot(path=str(screenshot_path), full_page=True)
return "page"
async def is_verification_step(page):
modal_selectors = [
".pc-login-verification-modal",
".semi-modal-content",
".semi-modal",
'div[role="dialog"]',
]
for selector in modal_selectors:
locator = page.locator(selector).first
try:
if await locator.count() > 0 and await locator.is_visible():
return True
except Exception:
continue
verification_texts = [
"身份验证",
"以确保为本人操作",
"短信验证码",
"安全验证",
]
for text in verification_texts:
locator = page.get_by_text(text, exact=False).first
try:
if await locator.count() > 0 and await locator.is_visible():
return True
except Exception:
continue
return False
async def refresh_expired_qr_if_needed(page):
refresh_texts = ["点击刷新", "刷新", "刷新二维码"]
expired_texts = ["二维码失效", "二维码已失效"]
for text in refresh_texts:
locator = page.get_by_text(text, exact=False).first
try:
if await locator.count() > 0 and await locator.is_visible():
await locator.scroll_into_view_if_needed()
await locator.click(force=True, timeout=10000)
await asyncio.sleep(1.5)
return True
except Exception:
continue
for text in expired_texts:
locator = page.get_by_text(text, exact=False).first
try:
if await locator.count() > 0 and await locator.is_visible():
await locator.scroll_into_view_if_needed()
await locator.click(force=True, timeout=10000)
await asyncio.sleep(1.5)
return True
except Exception:
continue
return False
async def main():
args = parse_args()
repo_root = Path(args.repo_root).resolve()
state_file = Path(args.state_file).resolve()
screenshot_path = Path(args.screenshot_path).resolve()
screenshot_path.parent.mkdir(parents=True, exist_ok=True)
state_file.parent.mkdir(parents=True, exist_ok=True)
import sys
sys.path.insert(0, str(repo_root))
from core.browser import get_browser
from core.login import collect_login_result
from utils.config import get_userData, save_userData
accounts = get_userData(force_reload=True)
account = accounts[args.account_index]
write_state(
state_file,
status="starting",
message=f"Preparing relogin session for {account.get('username', 'unknown')}",
account_index=args.account_index,
username=account.get("username", ""),
screenshot_path=str(screenshot_path),
)
playwright = browser = context = page = None
started_at = asyncio.get_running_loop().time()
timeout_seconds = max(args.timeout_seconds, 60)
try:
playwright, browser = await get_browser(GUI=False)
context = await browser.new_context(
viewport={"width": 1600, "height": 1200},
device_scale_factor=2,
)
page = await context.new_page()
await page.goto("https://creator.douyin.com/", wait_until="domcontentloaded", timeout=60000)
await asyncio.sleep(3)
selectors = [
"canvas",
".login-img-code-wrapper",
'div[class*="qrcode"]',
".login-mask",
".login-guide-container",
".pc-login-verification-modal",
".semi-modal-content",
".semi-modal",
'div[role="dialog"]',
]
while True:
if asyncio.get_running_loop().time() - started_at > timeout_seconds:
write_state(
state_file,
status="timeout",
message="Login session timed out before authentication completed",
account_index=args.account_index,
username=account.get("username", ""),
screenshot_path=str(screenshot_path),
)
return
await refresh_expired_qr_if_needed(page)
unique_id_locator = page.locator(
'xpath=//*[contains(@id, "garfish_app_for_douyin_creator_pc_home")]'
'/div/div[2]/div/div[2]/div[1]/div[2]/div[1]/div[3]'
).first
name_locator = page.locator(
'xpath=//*[contains(@id, "garfish_app_for_douyin_creator_pc_home")]'
'/div/div[2]/div/div[2]/div[1]/div[2]/div[1]/div[1]/div[1]'
).first
if await unique_id_locator.count() > 0 and await name_locator.count() > 0:
result = await collect_login_result(page, context, timeout_ms=5000)
refreshed_accounts = get_userData(force_reload=True)
refreshed_accounts[args.account_index]["unique_id"] = result["unique_id"]
refreshed_accounts[args.account_index]["username"] = result["username"]
refreshed_accounts[args.account_index]["cookies"] = result["cookies"]
save_userData(refreshed_accounts)
await page.screenshot(path=str(screenshot_path), full_page=True, timeout=15000)
write_state(
state_file,
status="authenticated",
message=f"Authenticated as {result['username']}",
account_index=args.account_index,
username=result["username"],
unique_id=result["unique_id"],
screenshot_path=str(screenshot_path),
)
return
verification = await is_verification_step(page)
await capture_login_screenshot(page, screenshot_path, prefer_verification=verification)
write_state(
state_file,
status="waiting_verify" if verification else "awaiting_scan",
message="Identity verification is required" if verification else "Scan the QR code with the Douyin app",
account_index=args.account_index,
username=account.get("username", ""),
screenshot_path=str(screenshot_path),
)
await asyncio.sleep(args.poll_interval)
except Exception as exc:
write_state(
state_file,
status="error",
message=str(exc),
account_index=args.account_index,
username=account.get("username", ""),
screenshot_path=str(screenshot_path),
)
raise
finally:
if page:
try:
await page.close()
except Exception:
pass
if context:
try:
await context.close()
except Exception:
pass
if browser:
try:
await browser.close()
except Exception:
pass
if playwright:
try:
await playwright.stop()
except Exception:
pass
if __name__ == "__main__":
try:
asyncio.run(main())
except AttributeError:
loop = asyncio.get_event_loop()
loop.run_until_complete(main())
+1 -1
View File
@@ -58,7 +58,7 @@ def read_crontab(path):
if not path.exists():
return []
lines = []
for raw_line in path.read_text(encoding="utf-8", errors="replace").splitlines():
for raw_line in path.read_text(encoding="utf-8-sig", errors="replace").splitlines():
line = raw_line.strip()
if not line or line.startswith("#") or "=" in line.split(maxsplit=1)[0]:
continue
@@ -0,0 +1,37 @@
import json
import os
import tempfile
import unittest
from pathlib import Path
from unittest.mock import patch
from core import tasks
from utils import config as config_module
class ConfigContractTests(unittest.TestCase):
def test_default_config_matches_public_example(self):
example_path = Path(config_module.__file__).resolve().parents[1] / "config.example.json"
example = json.loads(example_path.read_text(encoding="utf-8"))
self.assertEqual(example, config_module.DEFAULT_CONFIG)
def test_missing_runtime_config_is_created_from_safe_defaults(self):
with tempfile.TemporaryDirectory() as temp_dir:
path = Path(temp_dir) / "config.json"
loaded = config_module._load_json_file(path, config_module.DEFAULT_CONFIG)
self.assertEqual(config_module.DEFAULT_CONFIG, loaded)
self.assertEqual(
config_module.DEFAULT_CONFIG,
json.loads(path.read_text(encoding="utf-8")),
)
self.assertFalse(loaded["useProtocolSender"])
self.assertTrue(loaded["persistentBrowserProfiles"]["enabled"])
def test_profile_root_environment_override_wins(self):
with patch.dict(os.environ, {"SPARKFLOW_BROWSER_PROFILE_ROOT": "/tmp/sparkflow-profiles"}):
normalized = tasks._normalize_persistent_profile_config(config_module.DEFAULT_CONFIG)
self.assertEqual("/tmp/sparkflow-profiles", normalized["root"])
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,103 @@
import unittest
from pathlib import Path
REPO_ROOT = Path(__file__).resolve().parents[2]
SOURCE_ROOT = REPO_ROOT / "DouYinSparkFlow"
class DeploymentContractTests(unittest.TestCase):
def test_github_workflow_is_at_repository_root(self):
workflow = REPO_ROOT / ".github" / "workflows" / "schedule.yml"
self.assertTrue(workflow.is_file())
self.assertFalse((SOURCE_ROOT / ".github" / "workflows" / "schedule.yml").exists())
text = workflow.read_text(encoding="utf-8")
self.assertIn("working-directory: DouYinSparkFlow", text)
self.assertIn("SPARKFLOW_BROWSER_PROFILE_ROOT", text)
self.assertIn("SPARKFLOW_MANUAL_RUN", text)
self.assertIn("path: DouYinSparkFlow/logs/", text)
def test_github_actions_are_pinned_to_commit_shas(self):
import re
workflow = (REPO_ROOT / ".github" / "workflows" / "schedule.yml").read_text(encoding="utf-8")
uses_values = re.findall(r"^\s*-?\s*uses:\s*([^#\s]+)", workflow, flags=re.MULTILINE)
self.assertTrue(uses_values)
for value in uses_values:
self.assertRegex(value, r"^[^@]+@[0-9a-f]{40}$")
def test_runtime_config_is_not_tracked_as_the_template(self):
self.assertTrue((SOURCE_ROOT / "config.example.json").is_file())
self.assertIn("config.json", (SOURCE_ROOT / ".gitignore").read_text(encoding="utf-8"))
def test_compose_runtime_mounts_follow_least_privilege(self):
text = (REPO_ROOT / "docker-compose.yml").read_text(encoding="utf-8")
web = text.split(" web:", 1)[1].split("\n login-desktop:", 1)[0]
scheduler = text.split(" scheduler:", 1)[1].split("\n task:", 1)[0]
task = text.split(" task:", 1)[1]
self.assertIn("/var/run/docker.sock:/var/run/docker.sock", web)
self.assertIn(".:/opt/douyin-sparkflow", web)
self.assertIn("/var/run/docker.sock:/var/run/docker.sock", scheduler)
self.assertNotIn(".:/opt/douyin-sparkflow", scheduler)
self.assertNotIn("/var/run/docker.sock:/var/run/docker.sock", task)
self.assertNotIn(".:/opt/douyin-sparkflow", task)
for service in (scheduler, task):
self.assertIn(
"./state/browser-profiles:/opt/douyin-sparkflow/state/browser-profiles",
service,
)
def test_cron_reader_accepts_windows_utf8_bom(self):
import tempfile
from scripts.cron_runner import read_crontab
with tempfile.TemporaryDirectory() as temp_dir:
path = Path(temp_dir) / "root"
path.write_text(
"*/20 10-17 * * * cd /app && python main.py --doTask\n",
encoding="utf-8-sig",
)
lines = read_crontab(path)
self.assertEqual(len(lines), 1)
self.assertTrue(lines[0].startswith("*/20 "))
def test_sensitive_ports_bind_to_loopback_by_default(self):
text = (REPO_ROOT / "docker-compose.yml").read_text(encoding="utf-8")
self.assertIn("${PROXY_BIND_ADDRESS:-127.0.0.1}:${PROXY_HTTP_PORT:-7890}:7890", text)
self.assertIn(
"${LOGIN_DESKTOP_BIND_ADDRESS:-127.0.0.1}:${LOGIN_DESKTOP_WEB_PORT:-8788}:6080",
text,
)
def test_container_login_api_and_public_url_are_wired(self):
text = (REPO_ROOT / "docker-compose.yml").read_text(encoding="utf-8")
self.assertIn("SPARKFLOW_LOGIN_DESKTOP_API_URL: http://login-desktop:18090", text)
self.assertIn("SPARKFLOW_LOGIN_DESKTOP_PUBLIC_URL", text)
def test_installers_preserve_runtime_config_and_do_not_require_bash_on_windows(self):
server = (REPO_ROOT / "deploy" / "install-server.sh").read_text(encoding="utf-8")
windows = (REPO_ROOT / "deploy" / "install-local.ps1").read_text(encoding="utf-8")
self.assertIn("runtime_config_backup", server)
self.assertIn("Restored runtime config.json", server)
self.assertNotIn("bash ./refresh_proxy.sh", windows)
self.assertIn("Initialize-ProxyConfig", windows)
def test_playwright_base_image_argument_is_used(self):
dockerfile = (SOURCE_ROOT / "Dockerfile.server").read_text(encoding="utf-8")
self.assertTrue(dockerfile.startswith("ARG PLAYWRIGHT_BASE_IMAGE="))
self.assertIn("FROM ${PLAYWRIGHT_BASE_IMAGE}", dockerfile)
self.assertIn("docker.io", dockerfile)
self.assertIn("node --version", dockerfile)
self.assertNotIn("github.com/docker/compose", dockerfile)
def test_legacy_unused_entrypoints_are_removed(self):
self.assertFalse((SOURCE_ROOT / "webui" / "login_sessions.py").exists())
self.assertFalse((SOURCE_ROOT / "relogin_worker.py").exists())
self.assertFalse((SOURCE_ROOT / "docker-compose.example.yml").exists())
if __name__ == "__main__":
unittest.main()
@@ -104,6 +104,53 @@ class WebUiSafetyTests(unittest.TestCase):
self.assertEqual(200, response.status_code, path)
self.assertEqual("no-store", response.headers["cache-control"])
def test_login_desktop_urls_honor_container_environment(self):
with (
patch.dict(
os.environ,
{
"SPARKFLOW_LOGIN_DESKTOP_API_URL": "http://login-desktop:18090",
"SPARKFLOW_LOGIN_DESKTOP_PUBLIC_URL": "http://127.0.0.1:8788/vnc.html",
},
),
patch.object(app_module, "get_app_settings", return_value={}),
):
self.assertEqual("http://login-desktop:18090", app_module.login_desktop_api_url())
request = type("Request", (), {"url": type("Url", (), {"hostname": "example", "scheme": "http"})()})()
self.assertEqual(
"http://127.0.0.1:8788/vnc.html",
app_module.login_desktop_public_url(request),
)
def test_schedule_sync_writes_configured_window_to_shared_spool(self):
with tempfile.TemporaryDirectory() as temp_dir:
cron_path = Path(temp_dir) / "root"
with (
patch.object(ops, "HOST_CRONTAB_PATH", cron_path),
patch.object(ops, "running_in_container", return_value=True),
patch.object(ops, "read_crontab", return_value=""),
patch.object(
ops,
"get_config",
return_value={
"dailySendWindow": {
"enabled": True,
"startHour": 10,
"endHour": 18,
"scheduleIntervalMinutes": 20,
}
},
),
):
result = ops.sync_daily_schedule_from_config()
self.assertEqual(0, result.returncode)
text = cron_path.read_text(encoding="utf-8")
self.assertIn("*/20 10-17 * * *", text)
self.assertIn("0 18 * * *", text)
self.assertIn("20 18 * * *", text)
self.assertIn("docker exec", text)
def test_overview_api_requires_authentication_and_disables_cache(self):
client = TestClient(app_module.app)
response = client.get("/api/ops/overview")
+41 -25
View File
@@ -19,39 +19,55 @@ APPSETTINGSFILE = "webui_settings.json"
DEFAULT_CONFIG = {
"multiTask": True,
"taskCount": 5,
"taskCount": 1,
"proxyAddress": "",
"messageTemplate": "【AI续火花】",
"messageTemplate": "🤩今日火花+1\r\n",
"saveDebugArtifacts": False,
"useProtocolSender": True,
"useProtocolSender": False,
"protocolDryRun": False,
"browserSenderAccounts": [],
"persistentBrowserProfiles": {
"sendStrategy": {
"shuffleTargets": True,
"accountStartDelaySecondsMin": 15,
"accountStartDelaySecondsMax": 60,
"messageIntervalSecondsMin": 25,
"messageIntervalSecondsMax": 70,
"messageVariants": [
"🤩今日火花+1",
"今天来补个火花",
"给你续一下今天的火花",
"路过给你加个小火花"
]
},
"dailySendWindow": {
"enabled": True,
"startHour": 10,
"endHour": 18,
"scheduleIntervalMinutes": 20
},
"hitokotoTypes": [
"文学",
"影视",
"诗词",
"哲学"
],
"happyNewYear": {
"enabled": False,
"messageTemplate": "\r\n"
},
"friendListScan": {
"maxScanSeconds": 300,
"idleScanSeconds": 120,
"scrollStepPx": 400,
"scrollDelaySeconds": 0.8
},
"persistentBrowserProfiles": {
"enabled": True,
"root": "/opt/douyin-sparkflow/state/browser-profiles",
"seedCookiesWhenEmpty": True,
"syncStoredCookiesBeforeRun": True,
"refreshStoredCookiesAfterLogin": True,
},
"sendStrategy": {
"shuffleTargets": True,
"accountStartDelaySecondsMin": 0,
"accountStartDelaySecondsMax": 20,
"messageIntervalSecondsMin": 18,
"messageIntervalSecondsMax": 45,
"messageVariants": [],
},
"dailySendWindow": {
"enabled": False,
"startHour": 10,
"endHour": 18,
"scheduleIntervalMinutes": 10,
},
"hitokotoTypes": ["文学", "影视", "诗词", "哲学"],
"happyNewYear": {
"enabled": False,
"messageTemplate": "【[data]|[data_lunar]】\n[API]",
},
"refreshStoredCookiesAfterLogin": True
}
}
DEFAULT_APP_SETTINGS = {
+1 -1
View File
@@ -44,5 +44,5 @@ def request_hitokoto():
if theFromWho is None or theFromWho.strip() == "":
theFromWho = "未知作者"
return f"{data['hitokoto']} —— {theFrom} ({theFromWho})"
except Exception as e:
except Exception:
return "[error] 无法获取一言内容"
+28 -6
View File
@@ -1,9 +1,11 @@
import json
import logging
import os
from datetime import datetime, timedelta, timezone
from pathlib import Path
import urllib.error
import urllib.request
from contextlib import asynccontextmanager
import uvicorn
from fastapi import FastAPI, Request
@@ -12,8 +14,6 @@ from fastapi.staticfiles import StaticFiles
from fastapi.templating import Jinja2Templates
from starlette.middleware.sessions import SessionMiddleware
logger = logging.getLogger(__name__)
from core.friends import fetch_account_friends
from core.send_state import history_entry_is_strong_confirmed_today, parse_sent_at
from core.tasks import run_browser_tasks, task_run_lock
@@ -50,9 +50,12 @@ from webui.ops import (
run_task_now,
run_unsent_retry_now,
task_run_lock_status,
sync_daily_schedule_from_config,
update_daily_schedule,
)
logger = logging.getLogger(__name__)
BASE_DIR = Path(__file__).resolve().parent
TEMPLATES_DIR = BASE_DIR / "templates"
@@ -190,12 +193,17 @@ def mark_target_unconfirmed(account, target_name, *, reason="manual_reset_possib
def login_desktop_api_url():
settings = get_app_settings(force_reload=True)
return str(settings.get("login_desktop_api_url") or "http://127.0.0.1:18090").rstrip("/")
configured = os.getenv("SPARKFLOW_LOGIN_DESKTOP_API_URL") or settings.get("login_desktop_api_url")
return str(configured or "http://127.0.0.1:18090").rstrip("/")
def login_desktop_public_url(request: Request) -> str:
settings = get_app_settings(force_reload=True)
configured_url = str(settings.get("login_desktop_public_url") or "").strip()
configured_url = str(
os.getenv("SPARKFLOW_LOGIN_DESKTOP_PUBLIC_URL")
or settings.get("login_desktop_public_url")
or ""
).strip()
if configured_url:
return configured_url
@@ -274,13 +282,27 @@ def public_app_settings():
def create_app():
settings = get_app_settings()
app = FastAPI(title="DouYin Spark Flow Admin")
@asynccontextmanager
async def lifespan(_app):
result = sync_daily_schedule_from_config()
if result.returncode != 0:
logger.warning("Failed to synchronize the configured daily schedule: %s", result.stderr)
yield
secure_cookie = str(os.getenv("SPARKFLOW_SESSION_COOKIE_SECURE") or "").strip().lower() in {
"1",
"true",
"yes",
"on",
}
app = FastAPI(title="DouYin Spark Flow Admin", lifespan=lifespan)
app.add_middleware(
SessionMiddleware,
secret_key=settings["session_secret"],
max_age=settings["session_max_age_seconds"],
same_site="lax",
https_only=False,
https_only=secure_cookie,
)
app.mount("/static", StaticFiles(directory=str(STATIC_DIR)), name="static")
DEBUG_ARTIFACTS_DIR.mkdir(parents=True, exist_ok=True)
File diff suppressed because it is too large Load Diff
+40 -1
View File
@@ -13,7 +13,7 @@ from pathlib import Path
from zoneinfo import ZoneInfo
from core.send_state import history_entry_is_strong_confirmed_today, parse_sent_at
from utils.config import get_app_settings, get_config, get_userData, normalize_unique_id, repo_root, save_config
from utils.config import get_app_settings, get_config, get_userData, repo_root, save_config
logger = logging.getLogger(__name__)
@@ -423,6 +423,8 @@ def refresh_proxy():
def restart_proxy():
try:
if running_in_container():
return run_command(["docker", "restart", "mihomo"], timeout=120)
return run_command(compose_command("restart", "proxy"), timeout=120)
except Exception as exc:
logger.error("restart_proxy failed: %s", exc)
@@ -566,6 +568,43 @@ def update_daily_schedule(time_string):
return _empty_result(stderr=str(exc))
def sync_daily_schedule_from_config():
config = get_config(force_reload=True)
window = dict(config.get("dailySendWindow") or {})
if not window.get("enabled"):
return subprocess.CompletedProcess(
args=["sync-daily-schedule"],
returncode=0,
stdout="schedule disabled; existing crontab left unchanged",
stderr="",
)
try:
time_string = _format_window_schedule(window)
current = read_crontab()
updated = replace_douyin_cron_schedule(current, time_string)
if updated == current:
return subprocess.CompletedProcess(
args=["sync-daily-schedule"], returncode=0, stdout="already synchronized", stderr=""
)
if running_in_container() and HOST_CRONTAB_PATH.parent.exists():
HOST_CRONTAB_PATH.write_text(updated, encoding="utf-8")
return subprocess.CompletedProcess(
args=["sync-daily-schedule"], returncode=0, stdout="host spool updated", stderr=""
)
return subprocess.run(
["crontab", "-"],
input=updated,
text=True,
capture_output=True,
check=False,
timeout=10,
)
except Exception as exc:
logger.error("sync_daily_schedule_from_config failed: %s", exc)
return _empty_result(stderr=str(exc))
def current_daily_schedule():
config = get_config(force_reload=True)
window = dict(config.get("dailySendWindow") or {})
+43 -28
View File
@@ -90,17 +90,24 @@ cp .env.example .env
nano .env # 根据需要修改配置
# 3. 启动服务
docker-compose up -d
docker compose up -d
# 4. 访问 Web 界面
# 浏览器打开 http://localhost:8787
```
**服务端口说明**
- `8787`: Web 管理控制台
- `18090`: 登录桌面 API
- `5901`: VNC 远程桌面
- `8788`: noVNC Web 桌面
- `8787`Web 管理控制台,默认监听全部网卡
- `8788`noVNC 登录桌面,默认只绑定 `127.0.0.1`
- `7890` / `9090`:代理和控制端口,默认只绑定 `127.0.0.1`
服务器远程访问 noVNC 时,请先建立 SSH 隧道:
```bash
ssh -L 8788:127.0.0.1:8788 <user>@<server-ip>
```
然后打开 `http://127.0.0.1:8788/vnc.html?autoconnect=1&resize=scale&view_only=0`
</details>
@@ -137,7 +144,7 @@ python main.py --web
4. **启动任务** → 在"概览"页面启动定时任务
5. **监控运行** → 在"发送控制台"查看实时日志和发送记录
📖 详细使用教程请查看 [使用文档](DouYinSparkFlow/docs/usage.md)
📖 详细使用教程请查看 [使用文档](docs/usage.md)
---
@@ -156,7 +163,6 @@ douyin-sparkflow/
│ ├── webui/ # Web 界面
│ │ ├── app.py # FastAPI 主应用
│ │ ├── auth.py # 认证模块
│ │ ├── login_sessions.py # 登录会话管理
│ │ ├── ops.py # 操作接口
│ │ ├── static/ # 静态资源(CSS/JS
│ │ └── templates/ # HTML 模板
@@ -167,8 +173,8 @@ douyin-sparkflow/
│ ├── scripts/ # 辅助脚本
│ ├── docs/ # 文档和截图
│ ├── main.py # 主入口
── login_desktop_server.py # 登录桌面服务
│ └── relogin_worker.py # 重登录工作进程
── login_desktop_server.py # 登录桌面服务
├── .github/workflows/ # GitHub Actions 定时任务
├── proxy/ # 代理配置
│ └── config.yaml # Mihomo 代理配置
├── docker-compose.yml # 容器编排配置
@@ -209,22 +215,24 @@ douyin-sparkflow/
#### `.env` - 环境变量配置
```bash
# 代理配置(可选)
PROXY_URL=http://proxy-container:7890
# Web 服务端口
WEB_BIND_ADDRESS=0.0.0.0
WEB_PORT=8787
# 登录桌面端口
LOGIN_DESKTOP_PORT=18090
# noVNC 默认仅允许本机或 SSH 隧道访问
LOGIN_DESKTOP_BIND_ADDRESS=127.0.0.1
LOGIN_DESKTOP_WEB_PORT=8788
LOGIN_DESKTOP_PUBLIC_URL=http://127.0.0.1:8788/vnc.html?autoconnect=1&resize=scale&view_only=0
# VNC 端口
VNC_PORT=5901
# Mihomo 代理和控制端口默认仅绑定本机
PROXY_BIND_ADDRESS=127.0.0.1
PROXY_HTTP_PORT=7890
PROXY_CONTROLLER_PORT=9090
PROXY_SUB_URL=
```
#### `config.json` - 应用配置
#### `config.example.json` 与 `config.json` - 应用配置
仓库中的 `DouYinSparkFlow/config.json` 是不含账号数据的模板。常用配置示例:
仓库跟踪 `DouYinSparkFlow/config.example.json`;首次运行会生成被 Git 忽略的 `DouYinSparkFlow/config.json`。常用配置示例:
```json
{
@@ -235,7 +243,7 @@ VNC_PORT=5901
"enabled": true,
"startHour": 10,
"endHour": 18,
"scheduleIntervalMinutes": 10
"scheduleIntervalMinutes": 20
},
"friendListScan": {
"maxScanSeconds": 300,
@@ -265,7 +273,7 @@ VNC_PORT=5901
本项目提供完整的 Docker Compose 配置,包含以下服务:
- **douyin-web**: Web 管理控制台服务
- **web**(容器名 `douyin-web`):Web 管理控制台服务
- **login-desktop**: 登录桌面服务(包含浏览器环境)
- **proxy**: Mihomo 代理服务(可选)
- **scheduler**: 发送窗口定时调度服务
@@ -278,19 +286,19 @@ VNC_PORT=5901
cp .env.example .env
# 2. 启动所有服务
docker-compose up -d
docker compose up -d
# 3. 查看日志
docker-compose logs -f
docker compose logs -f
# 4. 停止服务
docker-compose down
docker compose down
```
#### 仅部署 Web 服务
```bash
docker-compose up -d douyin-web
docker compose up -d web
```
### 服务器部署最佳实践
@@ -359,9 +367,16 @@ mode: rule
# ... 更多配置见配置文件
```
### 默认网络安全
noVNC、Mihomo 代理端口和控制端口默认仅绑定 `127.0.0.1`。远程服务器请优先通过 SSH 隧道、VPN 或带认证的 HTTPS 反向代理访问,不建议直接把 8788、7890、9090 暴露到公网。
Web 通过 HTTPS 反向代理部署时,可设置 `SPARKFLOW_SESSION_COOKIE_SECURE=1`
### GitHub Actions 定时任务
支持通过 GitHub Actions 运行定时任务,配置文件:`.github/workflows/schedule.yml`
工作流位于 `.github/workflows/schedule.yml`。在仓库的 `user-data` Environment 中配置 `USER_DATA` Secret 后,可以手动触发或按北京时间 10:00 定时执行一次手动模式发送。工作流会先执行单元测试和网络可达性检查,再处理当天尚未强确认的目标。
---
@@ -372,7 +387,7 @@ mode: rule
- **浏览器自动化**: Playwright - 跨浏览器自动化
- **容器化**: Docker + Docker Compose
- **代理**: Mihomo (Clash Meta)
- **任务调度**: APScheduler
- **任务调度**: `scheduler` 容器 + `scripts/cron_runner.py`
- **模板引擎**: Jinja2
---
@@ -432,7 +447,7 @@ mode: rule
- **项目主页**: [GitHub Repository](https://github.com/halfwaystudent/douyin-sparkflow)
- **社区讨论**: [Linux Do 社区](https://linux.do)
- **使用文档**: [docs/usage.md](DouYinSparkFlow/docs/usage.md)
- **使用文档**: [docs/usage.md](docs/usage.md)
- **问题反馈**: [Issues](https://github.com/halfwaystudent/douyin-sparkflow/issues)
---
+52 -2
View File
@@ -58,6 +58,56 @@ function Get-EnvValue {
return $DefaultValue
}
function Set-YamlScalar {
param(
[string]$Path,
[string]$Key,
[string]$Value
)
$content = if (Test-Path $Path) { @(Get-Content -Path $Path) } else { @() }
$escapedKey = [regex]::Escape($Key)
$found = $false
$updated = foreach ($line in $content) {
if ($line -match "^${escapedKey}:") {
$found = $true
"${Key}: ${Value}"
} else {
$line
}
}
if (-not $found) {
$updated = @($updated) + "${Key}: ${Value}"
}
Set-Content -Path $Path -Value $updated -Encoding utf8
}
function Initialize-ProxyConfig {
$configPath = "proxy/config.yaml"
$examplePath = "proxy/config.example.yaml"
$subscription = Get-EnvValue -Path ".env" -Key "PROXY_SUB_URL" -DefaultValue ""
$userAgent = Get-EnvValue -Path ".env" -Key "PROXY_USER_AGENT" -DefaultValue "clash-verge/1.7.7"
if ($subscription) {
$tempPath = "$configPath.tmp"
try {
Invoke-WebRequest -Uri $subscription -Headers @{ "User-Agent" = $userAgent } -OutFile $tempPath -UseBasicParsing
Move-Item -Force $tempPath $configPath
Write-Host "Proxy subscription refreshed: $configPath"
} finally {
Remove-Item -Force $tempPath -ErrorAction SilentlyContinue
}
} elseif (-not (Test-Path $configPath)) {
Copy-Item $examplePath $configPath
Write-Host "PROXY_SUB_URL is empty. Created a DIRECT-only proxy config."
}
Set-YamlScalar -Path $configPath -Key "mixed-port" -Value "7890"
Set-YamlScalar -Path $configPath -Key "allow-lan" -Value "true"
Set-YamlScalar -Path $configPath -Key "bind-address" -Value "'*'"
Set-YamlScalar -Path $configPath -Key "external-controller" -Value "'0.0.0.0:9090'"
}
Require-Command docker
docker compose version | Out-Null
@@ -69,7 +119,7 @@ if ($ProxySubUrl) {
Set-EnvValue -Path ".env" -Key "PROXY_SUB_URL" -Value $ProxySubUrl
}
New-Item -ItemType Directory -Force -Path "proxy", "state/cron", "state/login-profile", "DouYinSparkFlow/logs" | Out-Null
New-Item -ItemType Directory -Force -Path "proxy", "state/cron", "state/login-profile", "state/browser-profiles", "DouYinSparkFlow/logs" | Out-Null
if (-not (Test-Path "proxy/config.yaml")) {
Copy-Item "proxy/config.example.yaml" "proxy/config.yaml"
}
@@ -81,7 +131,7 @@ if (-not (Test-Path "state/cron/root") -or (Get-Item "state/cron/root").Length -
) | Set-Content -Path "state/cron/root" -Encoding utf8
}
bash ./refresh_proxy.sh
Initialize-ProxyConfig
docker compose up -d --build
+1 -1
View File
@@ -38,7 +38,7 @@ if [ -n "$proxy_sub_url" ]; then
set_env_value ".env" "PROXY_SUB_URL" "$proxy_sub_url"
fi
mkdir -p proxy state/cron state/login-profile DouYinSparkFlow/logs
mkdir -p proxy state/cron state/login-profile state/browser-profiles DouYinSparkFlow/logs
if [ ! -f "proxy/config.yaml" ]; then
cp "proxy/config.example.yaml" "proxy/config.yaml"
fi
+27 -6
View File
@@ -73,19 +73,32 @@ ensure_docker() {
prepare_repo() {
run_root mkdir -p "$(dirname "$APP_ROOT")"
local runtime_config_backup=""
if [ -f "$APP_ROOT/DouYinSparkFlow/config.json" ]; then
runtime_config_backup="$(mktemp)"
run_root cp "$APP_ROOT/DouYinSparkFlow/config.json" "$runtime_config_backup"
fi
if [ -d "$APP_ROOT/.git" ]; then
log "Updating existing repository at $APP_ROOT"
run_root git -C "$APP_ROOT" fetch origin "$BRANCH"
run_root git -C "$APP_ROOT" checkout -B "$BRANCH" "origin/$BRANCH"
run_root git -C "$APP_ROOT" reset --hard "origin/$BRANCH"
else
if [ -e "$APP_ROOT" ] && [ "$ACTION" = "install" ]; then
echo "$APP_ROOT exists but is not a git checkout. Move it aside or set ACTION=update after fixing it." >&2
if [ -e "$APP_ROOT" ]; then
echo "$APP_ROOT exists but is not a git checkout. Back up runtime data and move the directory aside before installing." >&2
exit 1
fi
log "Cloning $REPO_URL#$BRANCH into $APP_ROOT"
run_root git clone --branch "$BRANCH" "$REPO_URL" "$APP_ROOT"
fi
if [ -n "$runtime_config_backup" ]; then
run_root mkdir -p "$APP_ROOT/DouYinSparkFlow"
run_root cp "$runtime_config_backup" "$APP_ROOT/DouYinSparkFlow/config.json"
rm -f "$runtime_config_backup"
log "Restored runtime config.json after repository update"
fi
}
set_env_value() {
@@ -143,7 +156,7 @@ prepare_runtime_files() {
set_env_value "$env_file" "APP_ROOT" "$APP_ROOT"
set_env_value "$env_file" "DEFAULT_SCHEDULE" "$DEFAULT_SCHEDULE"
for key in TZ WEB_PORT LOGIN_DESKTOP_WEB_PORT PROXY_HTTP_PORT PROXY_CONTROLLER_PORT PROXY_SUB_URL PROXY_USER_AGENT PLAYWRIGHT_BASE_IMAGE HTTP_PROXY_BUILD HTTPS_PROXY_BUILD ALL_PROXY_BUILD PIP_INDEX_URL PIP_TRUSTED_HOST; do
for key in TZ WEB_BIND_ADDRESS WEB_PORT SPARKFLOW_SESSION_COOKIE_SECURE LOGIN_DESKTOP_BIND_ADDRESS LOGIN_DESKTOP_WEB_PORT LOGIN_DESKTOP_PUBLIC_URL PROXY_BIND_ADDRESS PROXY_HTTP_PORT PROXY_CONTROLLER_PORT PROXY_SUB_URL PROXY_USER_AGENT PLAYWRIGHT_BASE_IMAGE HTTP_PROXY_BUILD HTTPS_PROXY_BUILD ALL_PROXY_BUILD PIP_INDEX_URL PIP_TRUSTED_HOST; do
if [ -n "${!key:-}" ]; then
set_env_value "$env_file" "$key" "${!key}"
fi
@@ -164,6 +177,7 @@ prepare_runtime_files() {
"$APP_ROOT/proxy" \
"$APP_ROOT/state/cron" \
"$APP_ROOT/state/login-profile" \
"$APP_ROOT/state/browser-profiles" \
"$APP_ROOT/DouYinSparkFlow/logs"
if [ ! -f "$APP_ROOT/proxy/config.yaml" ]; then
@@ -189,7 +203,7 @@ compose_up() {
sleep 2
done
if [ "$tries" -ge 30 ]; then
echo "Proxy did not become reachable on 127.0.0.1:7890; the build may fail downloading docker/compose binaries." >&2
echo "Proxy did not become reachable on 127.0.0.1:7890; the build may fail downloading external packages." >&2
fi
log "Building and starting remaining containers"
run_root env DOCKER_BUILDKIT=1 COMPOSE_DOCKER_CLI_BUILD=1 \
@@ -201,15 +215,22 @@ compose_up() {
print_summary() {
local env_file="$APP_ROOT/.env"
local web_port login_port host_ip
local web_port login_port login_bind host_ip
web_port="$(read_env_value "$env_file" WEB_PORT)"
login_port="$(read_env_value "$env_file" LOGIN_DESKTOP_WEB_PORT)"
login_bind="$(read_env_value "$env_file" LOGIN_DESKTOP_BIND_ADDRESS)"
host_ip="$(hostname -I 2>/dev/null | awk '{print $1}')"
host_ip="${host_ip:-127.0.0.1}"
echo
echo "Douyin SparkFlow is running."
echo "Web UI: http://${host_ip}:${web_port:-8787}"
echo "Login desktop: http://${host_ip}:${login_port:-8788}/vnc.html?autoconnect=1&resize=scale&view_only=0"
if [ "${login_bind:-127.0.0.1}" = "127.0.0.1" ]; then
echo "Login desktop is local-only: http://127.0.0.1:${login_port:-8788}/vnc.html?autoconnect=1&resize=scale&view_only=0"
echo "For remote access, create an SSH tunnel: ssh -L ${login_port:-8788}:127.0.0.1:${login_port:-8788} <user>@${host_ip}"
else
echo "Login desktop: http://${host_ip}:${login_port:-8788}/vnc.html?autoconnect=1&resize=scale&view_only=0"
echo "Warning: public noVNC access should be protected by a firewall or VPN."
fi
echo
echo "Runtime files preserved outside git: .env, state/, proxy/config.yaml, DouYinSparkFlow/logs/, usersData.json, webui_settings.json."
echo "Update later with: ACTION=update bash $APP_ROOT/deploy/install-server.sh"
+8 -4
View File
@@ -9,8 +9,8 @@ services:
max-size: "20m"
max-file: "3"
ports:
- "${PROXY_HTTP_PORT:-7890}:7890"
- "${PROXY_CONTROLLER_PORT:-9090}:9090"
- "${PROXY_BIND_ADDRESS:-127.0.0.1}:${PROXY_HTTP_PORT:-7890}:7890"
- "${PROXY_BIND_ADDRESS:-127.0.0.1}:${PROXY_CONTROLLER_PORT:-9090}:9090"
volumes:
- ./proxy/config.yaml:/root/.config/mihomo/config.yaml:ro
@@ -43,8 +43,10 @@ services:
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
SPARKFLOW_LOGIN_DESKTOP_API_URL: http://login-desktop:18090
LOGIN_DESKTOP_PUBLIC_PORT: ${LOGIN_DESKTOP_WEB_PORT:-8788}
SPARKFLOW_LOGIN_DESKTOP_PUBLIC_URL: ${LOGIN_DESKTOP_PUBLIC_URL:-http://127.0.0.1:8788/vnc.html?autoconnect=1&resize=scale&view_only=0}
SPARKFLOW_SESSION_COOKIE_SECURE: ${SPARKFLOW_SESSION_COOKIE_SECURE:-0}
ports:
- "${WEB_PORT:-8787}:8787"
- "${WEB_BIND_ADDRESS:-0.0.0.0}:${WEB_PORT:-8787}:8787"
command: python main.py --web --host 0.0.0.0 --port 8787
volumes:
- ./DouYinSparkFlow:/app
@@ -69,7 +71,7 @@ services:
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
ports:
- "${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
volumes:
- ./DouYinSparkFlow:/app
@@ -94,6 +96,8 @@ services:
- ./DouYinSparkFlow:/app
- ./DouYinSparkFlow/logs:/app/logs
- ./state/cron:/host-spool-cron
- ./state/browser-profiles:/opt/douyin-sparkflow/state/browser-profiles
- /var/run/docker.sock:/var/run/docker.sock
task:
image: douyin-sparkflow:local
+10 -4
View File
@@ -25,7 +25,13 @@
进入 **登录工作区**,在远端浏览器里完成抖音扫码、验证码或其他人工验证步骤。
如果内嵌 noVNC 无法连接,点击 **新窗口打开**。登录完成后点击 **保存当前账号**,面板会把登录态写入账号配置。
noVNC 默认只监听服务器的 `127.0.0.1:8788`。远程服务器先在本地建立 SSH 隧道:
```bash
ssh -L 8788:127.0.0.1:8788 <user>@<server-ip>
```
然后点击 **新窗口打开**,或访问 `http://127.0.0.1:8788/vnc.html?autoconnect=1&resize=scale&view_only=0`。登录完成后点击 **保存当前账号**,面板会把登录态写入账号配置。
![登录工作区](images/usage-login-workspace.png)
@@ -44,10 +50,10 @@
发送窗口使用北京时间,例如:
```text
10:00-18:00/10m
10:00-18:00/20m
```
这个例子表示每天 `10:00``18:00` 之间执行,调度间隔为 `10` 分钟。
这个例子表示每天 `10:00``18:00` 之间执行,调度间隔为 `20` 分钟。
![运行与系统](images/usage-settings.png)
@@ -69,7 +75,7 @@
### 登录工作区打不开
点击 **新窗口打开**。如果仍然不可用,检查 `login-desktop` 容器是否运行,并确认服务器防火墙或安全组已放行登录桌面端口
确认 SSH 隧道仍在运行,再检查 `login-desktop` 容器。默认不需要把 8788 暴露到公网
```bash
docker compose ps