mirror of
https://github.com/halfwaystudent/douyin-sparkflow.git
synced 2026-08-29 03:57:07 +08:00
fix: harden scheduling and deployment flow
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -14,3 +14,4 @@ DouYinSparkFlow/im_client_introspect.mjs
|
||||
DouYinSparkFlow/core/protocol_sender_debug.mjs
|
||||
DouYinSparkFlow/**/__pycache__/
|
||||
DouYinSparkFlow/**/*.pyc
|
||||
DouYinSparkFlow/config.json
|
||||
|
||||
@@ -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
@@ -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
|
||||
@@ -6,3 +6,4 @@ logs/
|
||||
.DS_Store
|
||||
usersData.json
|
||||
webui_settings.json
|
||||
config.json
|
||||
|
||||
@@ -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
@@ -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": {
|
||||
@@ -1,6 +1,4 @@
|
||||
import asyncio
|
||||
from pathlib import Path
|
||||
|
||||
from core.browser import get_browser
|
||||
|
||||
|
||||
|
||||
@@ -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"
|
||||
@@ -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())
|
||||
@@ -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")
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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] 无法获取一言内容"
|
||||
|
||||
@@ -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
@@ -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 {})
|
||||
|
||||
@@ -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)
|
||||
|
||||
---
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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`。登录完成后点击 **保存当前账号**,面板会把登录态写入账号配置。
|
||||
|
||||

|
||||
|
||||
@@ -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` 分钟。
|
||||
|
||||

|
||||
|
||||
@@ -69,7 +75,7 @@
|
||||
|
||||
### 登录工作区打不开
|
||||
|
||||
先点击 **新窗口打开**。如果仍然不可用,检查 `login-desktop` 容器是否运行,并确认服务器防火墙或安全组已放行登录桌面端口。
|
||||
先确认 SSH 隧道仍在运行,再检查 `login-desktop` 容器。默认不需要把 8788 暴露到公网。
|
||||
|
||||
```bash
|
||||
docker compose ps
|
||||
|
||||
Reference in New Issue
Block a user