diff --git a/.env.example b/.env.example index 1f4e6d5..da29075 100644 --- a/.env.example +++ b/.env.example @@ -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 diff --git a/.github/workflows/schedule.yml b/.github/workflows/schedule.yml new file mode 100644 index 0000000..be5c999 --- /dev/null +++ b/.github/workflows/schedule.yml @@ -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 \ No newline at end of file diff --git a/.gitignore b/.gitignore index e20482a..7cabccb 100644 --- a/.gitignore +++ b/.gitignore @@ -14,3 +14,4 @@ DouYinSparkFlow/im_client_introspect.mjs DouYinSparkFlow/core/protocol_sender_debug.mjs DouYinSparkFlow/**/__pycache__/ DouYinSparkFlow/**/*.pyc +DouYinSparkFlow/config.json diff --git a/CHANGELOG.md b/CHANGELOG.md index 3b73153..9732b51 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/DouYinSparkFlow/.github/workflows/schedule.yml b/DouYinSparkFlow/.github/workflows/schedule.yml deleted file mode 100644 index d844c06..0000000 --- a/DouYinSparkFlow/.github/workflows/schedule.yml +++ /dev/null @@ -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 \ No newline at end of file diff --git a/DouYinSparkFlow/.gitignore b/DouYinSparkFlow/.gitignore index 71c253a..f2c0f18 100644 --- a/DouYinSparkFlow/.gitignore +++ b/DouYinSparkFlow/.gitignore @@ -6,3 +6,4 @@ logs/ .DS_Store usersData.json webui_settings.json +config.json diff --git a/DouYinSparkFlow/Dockerfile.server b/DouYinSparkFlow/Dockerfile.server index 2f8af8b..99eef73 100644 --- a/DouYinSparkFlow/Dockerfile.server +++ b/DouYinSparkFlow/Dockerfile.server @@ -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 . . diff --git a/DouYinSparkFlow/README.md b/DouYinSparkFlow/README.md index 2a95497..e69ee53 100644 --- a/DouYinSparkFlow/README.md +++ b/DouYinSparkFlow/README.md @@ -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) - 容器编排配置 --- diff --git a/DouYinSparkFlow/config.json b/DouYinSparkFlow/config.example.json similarity index 95% rename from DouYinSparkFlow/config.json rename to DouYinSparkFlow/config.example.json index 0c91034..74d28c0 100644 --- a/DouYinSparkFlow/config.json +++ b/DouYinSparkFlow/config.example.json @@ -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": { diff --git a/DouYinSparkFlow/core/friends.py b/DouYinSparkFlow/core/friends.py index 3a714fc..cc9165d 100644 --- a/DouYinSparkFlow/core/friends.py +++ b/DouYinSparkFlow/core/friends.py @@ -1,6 +1,4 @@ import asyncio -from pathlib import Path - from core.browser import get_browser diff --git a/DouYinSparkFlow/core/tasks.py b/DouYinSparkFlow/core/tasks.py index 6c90419..f068570 100644 --- a/DouYinSparkFlow/core/tasks.py +++ b/DouYinSparkFlow/core/tasks.py @@ -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)), diff --git a/DouYinSparkFlow/docker-compose.example.yml b/DouYinSparkFlow/docker-compose.example.yml deleted file mode 100644 index 225d66f..0000000 --- a/DouYinSparkFlow/docker-compose.example.yml +++ /dev/null @@ -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" diff --git a/DouYinSparkFlow/relogin_worker.py b/DouYinSparkFlow/relogin_worker.py deleted file mode 100644 index 3a1ac5b..0000000 --- a/DouYinSparkFlow/relogin_worker.py +++ /dev/null @@ -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()) diff --git a/DouYinSparkFlow/scripts/cron_runner.py b/DouYinSparkFlow/scripts/cron_runner.py index 40a5213..437b5c2 100644 --- a/DouYinSparkFlow/scripts/cron_runner.py +++ b/DouYinSparkFlow/scripts/cron_runner.py @@ -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 diff --git a/DouYinSparkFlow/tests/test_config_contract.py b/DouYinSparkFlow/tests/test_config_contract.py new file mode 100644 index 0000000..42cad1e --- /dev/null +++ b/DouYinSparkFlow/tests/test_config_contract.py @@ -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() \ No newline at end of file diff --git a/DouYinSparkFlow/tests/test_deployment_contract.py b/DouYinSparkFlow/tests/test_deployment_contract.py new file mode 100644 index 0000000..abc5fc6 --- /dev/null +++ b/DouYinSparkFlow/tests/test_deployment_contract.py @@ -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() \ No newline at end of file diff --git a/DouYinSparkFlow/tests/test_webui_safety.py b/DouYinSparkFlow/tests/test_webui_safety.py index e6b01f7..8301eb6 100644 --- a/DouYinSparkFlow/tests/test_webui_safety.py +++ b/DouYinSparkFlow/tests/test_webui_safety.py @@ -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") diff --git a/DouYinSparkFlow/utils/config.py b/DouYinSparkFlow/utils/config.py index f1de884..6bab7ff 100644 --- a/DouYinSparkFlow/utils/config.py +++ b/DouYinSparkFlow/utils/config.py @@ -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 = { diff --git a/DouYinSparkFlow/utils/hitokoto.py b/DouYinSparkFlow/utils/hitokoto.py index b2a76d0..91e4f58 100644 --- a/DouYinSparkFlow/utils/hitokoto.py +++ b/DouYinSparkFlow/utils/hitokoto.py @@ -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] 无法获取一言内容" diff --git a/DouYinSparkFlow/webui/app.py b/DouYinSparkFlow/webui/app.py index cf51f70..b1a973e 100644 --- a/DouYinSparkFlow/webui/app.py +++ b/DouYinSparkFlow/webui/app.py @@ -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) diff --git a/DouYinSparkFlow/webui/login_sessions.py b/DouYinSparkFlow/webui/login_sessions.py deleted file mode 100644 index c83ccc4..0000000 --- a/DouYinSparkFlow/webui/login_sessions.py +++ /dev/null @@ -1,1275 +0,0 @@ -import asyncio - -import contextlib - -import math - -import secrets - -from dataclasses import asdict, dataclass, field - -from datetime import datetime - -from pathlib import Path - - - -from core.browser import get_browser - -from core.login import XPATHS, collect_login_result - -from utils.config import ( - get_app_settings, - get_userData, - normalize_unique_id, - repo_root, - save_userData, - upsert_user_account, -) -import logging - -import traceback - - - -logger = logging.getLogger(__name__) - - - -@dataclass - -class LoginSessionState: - session_id: str - - status: str = "idle" - - message: str = "" - - created_at: str = field(default_factory=lambda: datetime.now().isoformat(timespec="seconds")) - - updated_at: str = field(default_factory=lambda: datetime.now().isoformat(timespec="seconds")) - - screenshot_path: str = "" - screenshot_updated_at: str = "" - unique_id: str = "" - username: str = "" - cookies: list = field(default_factory=list) - pending_command: str = field(default=None) - last_command: str = "" - relogin_unique_id: str = "" - relogin_username: str = "" - default_targets: list = field(default_factory=list) - - - def touch(self, status=None, message=None): - - self.updated_at = datetime.now().isoformat(timespec="seconds") - - if status: - - self.status = status - - if message is not None: - - self.message = message - - def mark_screenshot_updated(self): - self.screenshot_updated_at = datetime.now().isoformat(timespec="seconds") - - - - - -class LoginSessionManager: - def __init__(self): - - self._lock = asyncio.Lock() - - self._state = None - - self._task = None - - self._cancel_event = None - - self._background_tasks = set() - - self._artifact_dir = repo_root() / "logs" / "login_sessions" - - self._artifact_dir.mkdir(parents=True, exist_ok=True) - - - - def get_public_state(self): - if not self._state: - - return None - - state = asdict(self._state) - - state["has_cookies"] = bool(self._state.cookies) - state.pop("cookies", None) - return state - - def _find_account_by_unique_id(self, accounts, unique_id): - normalized = normalize_unique_id(unique_id) - for account in accounts: - if normalize_unique_id(account.get("unique_id")) == normalized: - return account - return None - - - async def send_command(self, cmd: str): - - async with self._lock: - - if self._state: - logger.info("Queued login session command for %s: %s", self._state.session_id, cmd) - - self._state.pending_command = cmd - self._state.last_command = str(cmd or "").strip() - normalized_cmd = self._normalize_code_command(cmd) - if normalized_cmd.startswith("click:"): - display = normalized_cmd.split(":", 1)[1].strip() or normalized_cmd - self._state.touch(message=f"已提交命令:{display},等待远端页面执行…") - else: - self._state.touch(status="submitting_code", message="验证码已提交到远端浏览器,正在输入并验证…") - - - - def _session_screenshot_path(self, session_id): - return self._artifact_dir / f"{session_id}.png" - - async def _capture_login_screenshot(self, page, screenshot_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 - best_locator = None - best_area = -1 - for selector in selectors: - locator = page.locator(selector).first - try: - if await locator.count() > 0 and await locator.is_visible(): - box = await locator.bounding_box() - area = (box["width"] * box["height"]) if box else 0 - if area > best_area: - best_area = area - best_locator = (locator, selector) - except Exception: - continue - if best_locator: - locator, selector = best_locator - await locator.scroll_into_view_if_needed() - await locator.screenshot(path=screenshot_path) - return selector - await page.screenshot(path=screenshot_path, full_page=True) - return "page" - - async def _is_verification_step(self, 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 _is_verification_method_selection_step(self, page, scope=None): - actual_scope = scope or page - option_texts = [ - "接收短信验证码", - "手机刷脸验证", - "验证登录密码", - "发送短信验证", - ] - visible_matches = 0 - for text in option_texts: - locator = actual_scope.get_by_text(text, exact=False).first - try: - if await locator.count() > 0 and await locator.is_visible(): - visible_matches += 1 - except Exception: - continue - return visible_matches >= 2 - - async def _enter_sms_verification_flow(self, page, scope, state): - sms_target = await self._find_best_verification_flow_target( - page, - scope, - ["接收短信验证码", "发送短信验证"], - ) - if not sms_target: - return False - - try: - await sms_target.scroll_into_view_if_needed() - await sms_target.click(force=True, timeout=15000) - state.touch(status="awaiting_sms_request", message="已进入短信验证码验证,正在打开发码页面…") - await asyncio.sleep(1.0) - return True - except Exception as exc: - logger.warning("Failed to enter SMS verification flow: %s", exc) - return False - - async def _refresh_expired_qr_if_needed(self, page, state): - refresh_target = await self._find_best_visible_text_target( - page, - page, - ["点击刷新", "刷新", "刷新二维码"], - ) - expired_target = await self._find_best_visible_text_target( - page, - page, - ["二维码失效", "二维码已失效"], - ) - if not refresh_target and not expired_target: - return False - - target = refresh_target or expired_target - try: - await target.scroll_into_view_if_needed() - await target.click(force=True, timeout=10000) - state.touch(message="QR code expired and was refreshed automatically") - await asyncio.sleep(1.5) - return True - except Exception as exc: - logger.warning("Failed to auto-refresh expired QR: %s", exc) - return False - - - def _track_background_task(self, task): - - self._background_tasks.add(task) - - task.add_done_callback(self._background_tasks.discard) - - return task - - - - async def _finish_cancelled_task(self, task, session_id): - - try: - - await asyncio.wait_for(asyncio.shield(task), timeout=5) - - except asyncio.TimeoutError: - - logger.warning("Login session %s did not stop in time; cancelling task", session_id) - - task.cancel() - - with contextlib.suppress(asyncio.CancelledError, Exception): - - await task - - except asyncio.CancelledError: - - raise - - except Exception as exc: - - logger.warning("Login session %s cleanup ended with error: %s", session_id, exc) - - - - async def _resolve_interaction_scope(self, page): - - modal_selectors = [ - - ".pc-login-verification-modal", - - ".semi-modal-content", - - ".semi-modal", - - 'div[role="dialog"]', - - ] - - for selector in modal_selectors: - - modal = page.locator(selector).first - - if await modal.count() > 0 and await modal.is_visible(): - - logger.info("Operating within verification modal scope: %s", selector) - - return modal - - return page - - - - async def _find_first_visible_text_target(self, scope, texts): - - for text in texts: - - candidate = scope.get_by_text(text, exact=False).first - - if await candidate.count() > 0 and await candidate.is_visible(): - - return candidate - - return None - - - - async def _find_first_visible_locator(self, scope, selectors): - - for selector in selectors: - - group = scope.locator(selector) - - count = await group.count() - - for index in range(count): - - candidate = group.nth(index) - - if await candidate.is_visible(): - - return candidate - - return None - - - - async def _find_visible_locators(self, scope, selectors): - - for selector in selectors: - - group = scope.locator(selector) - - count = await group.count() - - visible = [] - - for index in range(count): - - candidate = group.nth(index) - - if await candidate.is_visible(): - - visible.append(candidate) - - if visible: - - return visible - - return [] - - async def _visible_text_snapshot(self, scope, limit=20): - snippets = [] - candidates = await scope.locator("body, div, span, button, label").all() - for candidate in candidates[:200]: - try: - if not await candidate.is_visible(): - continue - text = (await candidate.inner_text()).strip() - if not text: - continue - if text not in snippets: - snippets.append(text[:80]) - if len(snippets) >= limit: - break - except Exception: - continue - return snippets - - def _normalize_code_command(self, cmd): - digits = "".join(ch for ch in str(cmd or "") if ch.isdigit()) - if 4 <= len(digits) <= 8: - return digits - return str(cmd or "").strip() - - async def _find_verification_code_input(self, page, scope=None): - actual_scope = scope or await self._resolve_interaction_scope(page) - return await self._find_best_visible_locator( - page, - actual_scope, - [ - '.semi-input-number input', - 'input[placeholder*="验证码"]', - 'input[class*="code"]', - 'input[name*="code"]', - 'input[inputmode="numeric"]', - 'input[type="tel"]', - 'input[type="number"]', - 'input[type="text"]', - 'textarea', - '[contenteditable="true"]', - '[role="textbox"]', - ], - ) - - async def _read_verification_code_value(self, page, scope=None): - actual_scope = scope or await self._resolve_interaction_scope(page) - digit_inputs = await self._find_visible_locators( - actual_scope, - [ - '.semi-input-number input', - 'input[type="number"]', - 'input[inputmode="numeric"]', - 'input[type="tel"]', - ], - ) - if len(digit_inputs) > 1: - values = [] - for item in digit_inputs: - try: - value = (await item.input_value()).strip() - except Exception: - value = "" - values.append(value) - joined = "".join(values).strip() - if joined: - return joined - - target_input = await self._find_verification_code_input(page, actual_scope) - if not target_input: - return "" - - for reader in ( - lambda: target_input.input_value(), - lambda: target_input.inner_text(), - lambda: target_input.text_content(), - ): - try: - value = (await reader() or "").strip() - if value: - return value - except Exception: - continue - return "" - - async def _click_action_button_dom_fallback(self, page, labels): - return await page.evaluate( - """(buttonLabels) => { - const labels = buttonLabels.map((item) => String(item || "").trim()).filter(Boolean); - const isVisible = (el) => { - const rect = el.getBoundingClientRect(); - const style = window.getComputedStyle(el); - return rect.width > 0 && rect.height > 0 && style.visibility !== "hidden" && style.display !== "none"; - }; - const candidates = Array.from(document.querySelectorAll("*")) - .filter((el) => isVisible(el)) - .map((el) => { - const text = (el.innerText || el.textContent || el.value || "").trim(); - if (!text || !labels.some((label) => text === label || text.includes(label))) { - return null; - } - const rect = el.getBoundingClientRect(); - return { el, text, rect }; - }) - .filter(Boolean) - .sort((left, right) => { - const topDiff = right.rect.top - left.rect.top; - if (Math.abs(topDiff) > 1) return topDiff; - return (right.rect.width * right.rect.height) - (left.rect.width * left.rect.height); - }); - const clickable = (el) => el.closest('button,[role="button"],input[type="button"],input[type="submit"],a,[class*="button"],[class*="btn"]') || el; - if (!candidates.length) { - return { clicked: false, reason: "no-candidate" }; - } - const target = clickable(candidates[0].el); - target.click(); - target.dispatchEvent(new MouseEvent("click", { bubbles: true, cancelable: true })); - return { clicked: true, text: candidates[0].text }; - }""", - labels, - ) - - async def _click_verify_button_if_ready(self, page, scope, state): - code_value = "".join(ch for ch in await self._read_verification_code_value(page, scope) if ch.isdigit()) - if len(code_value) < 4: - return False - - confirm_btn = await self._find_best_visible_action_target( - page, - scope, - ["验证", "确定", "登录", "提交", "下一步"], - ) - if not confirm_btn: - return False - - try: - await confirm_btn.scroll_into_view_if_needed() - await confirm_btn.click(force=True, timeout=10000) - with contextlib.suppress(Exception): - await confirm_btn.evaluate( - """(el) => { - el.click(); - el.dispatchEvent(new MouseEvent("click", { bubbles: true, cancelable: true })); - }""", - ) - with contextlib.suppress(Exception): - dom_click_result = await self._click_action_button_dom_fallback(page, ["验证", "确定", "登录", "提交", "下一步"]) - logger.info("DOM verify-click fallback result: %s", dom_click_result) - with contextlib.suppress(Exception): - await page.keyboard.press("Enter") - state.touch(status="submitting_code", message=f"验证码 {code_value} 已提交,正在验证…") - return True - except Exception as exc: - logger.warning("Failed to click verify button after code entry: %s", exc) - return False - - async def _capture_screenshot_if_due( - self, - page, - state, - *, - prefer_verification=False, - force=False, - min_interval_seconds=2.0, - timing_state=None, - ): - timing_state = timing_state if timing_state is not None else {} - now = asyncio.get_running_loop().time() - last_capture_at = timing_state.get("last_capture_at", 0.0) - if not force and now - last_capture_at < min_interval_seconds: - return False - - await self._capture_login_screenshot( - page, - state.screenshot_path, - prefer_verification=prefer_verification, - ) - timing_state["last_capture_at"] = now - state.mark_screenshot_updated() - return True - - async def _submit_code_command(self, page, scope, cmd, state): - digit_inputs = await self._find_visible_locators( - scope, - [ - '.semi-input-number input', - 'input[type="number"]', - 'input[inputmode="numeric"]', - 'input[type="tel"]', - ], - ) - if len(digit_inputs) >= len(cmd) and len(digit_inputs) > 1: - for index, char in enumerate(cmd): - await digit_inputs[index].focus() - with contextlib.suppress(Exception): - await digit_inputs[index].fill("") - await digit_inputs[index].type(char, delay=50) - - await asyncio.sleep(0.2) - - confirm_btn = await self._find_best_visible_action_target( - page, - scope, - ["验证", "确定", "登录", "提交", "下一步"], - ) - if confirm_btn: - await confirm_btn.click(force=True) - with contextlib.suppress(Exception): - await confirm_btn.click(force=True, timeout=3000) - await page.keyboard.press("Enter") - state.touch(message=f"Submitted code (digits): {cmd}") - return - - target_input = await self._find_best_visible_locator( - page, - scope, - [ - 'input[placeholder*="验证码"]', - 'input[class*="code"]', - 'input[name*="code"]', - 'input[inputmode="numeric"]', - 'input[type="tel"]', - 'input[type="text"]', - 'textarea', - '[contenteditable="true"]', - '[role="textbox"]', - ], - ) - - if target_input: - logger.info("Target input found, typing code: %s", cmd) - await target_input.click(force=True) - with contextlib.suppress(Exception): - await target_input.focus() - with contextlib.suppress(Exception): - await target_input.fill("") - with contextlib.suppress(Exception): - await target_input.press("Control+A") - with contextlib.suppress(Exception): - await target_input.press("Backspace") - try: - await target_input.press_sequentially(cmd, delay=100) - except Exception: - await page.keyboard.type(cmd, delay=100) - else: - logger.warning("No direct code input field found, falling back to keyboard typing") - await page.keyboard.type(cmd, delay=100) - - dom_fallback = await page.evaluate( - """(code) => { - const isVisible = (el) => { - const rect = el.getBoundingClientRect(); - const style = window.getComputedStyle(el); - return rect.width > 0 && rect.height > 0 && style.visibility !== "hidden" && style.display !== "none"; - }; - const fire = (el) => { - el.dispatchEvent(new Event("input", { bubbles: true })); - el.dispatchEvent(new Event("change", { bubbles: true })); - }; - const inputs = Array.from(document.querySelectorAll('input, textarea, [contenteditable="true"], [role="textbox"]')) - .filter(isVisible); - const digitInputs = inputs.filter((el) => { - const type = (el.getAttribute("type") || "").toLowerCase(); - const inputMode = (el.getAttribute("inputmode") || "").toLowerCase(); - return type === "number" || type === "tel" || inputMode === "numeric"; - }); - if (digitInputs.length >= code.length && digitInputs.length > 1) { - for (let i = 0; i < code.length; i += 1) { - const el = digitInputs[i]; - if ("value" in el) { - el.value = code[i]; - } else { - el.textContent = code[i]; - } - fire(el); - } - return { mode: "digit-inputs", count: digitInputs.length }; - } - const target = inputs[0]; - if (!target) { - return { mode: "none", count: 0 }; - } - if ("value" in target) { - target.value = code; - } else { - target.textContent = code; - } - fire(target); - return { mode: "single-input", count: inputs.length }; - }""", - cmd, - ) - logger.info("DOM fallback result for code submit: %s", dom_fallback) - - await asyncio.sleep(0.2) - - confirm_btn = await self._find_best_visible_action_target( - page, - scope, - ["验证", "确定", "登录", "提交", "下一步"], - ) - if confirm_btn: - await confirm_btn.scroll_into_view_if_needed() - await confirm_btn.click(force=True) - with contextlib.suppress(Exception): - await confirm_btn.click(force=True, timeout=3000) - with contextlib.suppress(Exception): - dom_click_result = await self._click_action_button_dom_fallback(page, ["验证", "确定", "登录", "提交", "下一步"]) - logger.info("DOM confirm-click fallback result after code submit: %s", dom_click_result) - await page.keyboard.press("Enter") - state.touch(message=f"Submitted code: {cmd}") - return - - await page.keyboard.press("Enter") - state.touch(message=f"Submitted code (via Enter): {cmd}") - - - - async def _viewport_center(self, page): - - size = getattr(page, "viewport_size", None) or await page.evaluate( - - "() => ({ width: window.innerWidth, height: window.innerHeight })" - - ) - - return size["width"] / 2, size["height"] / 2 - - - - async def _pick_most_central_visible(self, page, candidates): - - if not candidates: - - return None - - center_x, center_y = await self._viewport_center(page) - - best_candidate = None - - best_distance = None - - for candidate in candidates: - - try: - - if not await candidate.is_visible(): - - continue - - box = await candidate.bounding_box() - - if not box: - - continue - - candidate_center_x = box["x"] + box["width"] / 2 - - candidate_center_y = box["y"] + box["height"] / 2 - - distance = math.hypot(candidate_center_x - center_x, candidate_center_y - center_y) - - if best_distance is None or distance < best_distance: - - best_candidate = candidate - - best_distance = distance - - except Exception: - - continue - - return best_candidate - - - - async def _find_best_visible_locator(self, page, scope, selectors): - - search_scopes = [scope] - - if scope is not page: - - search_scopes.append(page) - - - - candidates = [] - - for current_scope in search_scopes: - - for selector in selectors: - - group = current_scope.locator(selector) - - count = await group.count() - - for index in range(count): - - candidates.append(group.nth(index)) - - return await self._pick_most_central_visible(page, candidates) - - - - async def _find_best_visible_text_target(self, page, scope, texts): - - search_scopes = [scope] - - if scope is not page: - - search_scopes.append(page) - - - - candidates = [] - - for current_scope in search_scopes: - - for text in texts: - - group = current_scope.get_by_text(text, exact=False) - - count = await group.count() - - for index in range(count): - - candidates.append(group.nth(index)) - - return await self._pick_most_central_visible(page, candidates) - - async def _find_best_visible_action_target(self, page, scope, texts): - - search_scopes = [scope] - - if scope is not page: - - search_scopes.append(page) - - button_selectors = [ - "button", - '[role="button"]', - 'input[type="button"]', - 'input[type="submit"]', - '[class*="button"]', - '[class*="btn"]', - ] - - candidates = [] - for current_scope in search_scopes: - for selector in button_selectors: - group = current_scope.locator(selector) - count = await group.count() - for index in range(count): - candidate = group.nth(index) - try: - if not await candidate.is_visible(): - continue - text = (await candidate.inner_text()).strip() - except Exception: - text = "" - - if not text: - try: - text = (await candidate.get_attribute("value") or "").strip() - except Exception: - text = "" - - if text and any(keyword in text for keyword in texts): - candidates.append(candidate) - - return await self._pick_most_central_visible(page, candidates) - - async def _find_best_verification_flow_target(self, page, scope, texts): - - action_target = await self._find_best_visible_action_target(page, scope, texts) - if action_target: - return action_target - - selector_candidates = [] - search_scopes = [scope] - if scope is not page: - search_scopes.append(page) - - selectors = [ - '[class*="verify"]', - '[class*="security"]', - '[class*="option"]', - '[class*="item"]', - 'li', - 'div', - ] - - for current_scope in search_scopes: - for selector in selectors: - group = current_scope.locator(selector) - count = await group.count() - for index in range(count): - candidate = group.nth(index) - try: - if not await candidate.is_visible(): - continue - text = (await candidate.inner_text()).strip() - except Exception: - continue - if text and any(keyword in text for keyword in texts): - selector_candidates.append(candidate) - - if selector_candidates: - return await self._pick_most_central_visible(page, selector_candidates) - - return await self._find_best_visible_text_target(page, scope, texts) - - - - async def start(self, relogin_unique_id=None): - async with self._lock: - if self._state and self._state.status in { - "starting", - "awaiting_scan", - "awaiting_sms_request", - "awaiting_code", - "submitting_code", - "authenticated", - }: - return self.get_public_state() - - relogin_account = None - if relogin_unique_id: - relogin_account = self._find_account_by_unique_id( - get_userData(force_reload=True), - relogin_unique_id, - ) - if not relogin_account: - raise RuntimeError("Account not found for relogin") - - session_id = secrets.token_urlsafe(12) - screenshot_path = str(self._session_screenshot_path(session_id)) - self._state = LoginSessionState( - session_id=session_id, - status="starting", - message=( - f"Creating remote relogin session for {relogin_account.get('username', 'unknown')}" - if relogin_account - else "Creating remote login session" - ), - screenshot_path=screenshot_path, - relogin_unique_id=relogin_account.get("unique_id", "") if relogin_account else "", - relogin_username=relogin_account.get("username", "") if relogin_account else "", - default_targets=list(relogin_account.get("targets", [])) if relogin_account else [], - ) - self._cancel_event = asyncio.Event() - self._task = asyncio.create_task(self._run_login_flow(self._state, self._cancel_event)) - return self.get_public_state() - - - async def cancel(self): - - async with self._lock: - - if not self._state: - - return None - - task = self._task - - cancel_event = self._cancel_event - - session_id = self._state.session_id - - - - if cancel_event: - - cancel_event.set() - - - - self._task = None - - self._cancel_event = None - - self._state.touch(status="cancelled", message="已放弃本轮登录,可重新扫码") - - - - if task and not task.done(): - - self._track_background_task(asyncio.create_task(self._finish_cancelled_task(task, session_id))) - - return self.get_public_state() - - - - async def finalize(self, targets, display_name=None): - async with self._lock: - if not self._state or self._state.status != "authenticated": - raise RuntimeError("No authenticated login session is ready to save") - - username = display_name.strip() if display_name else self._state.username - final_targets = [target for target in targets if target] or list(self._state.default_targets) - - if self._state.relogin_unique_id: - accounts = get_userData(force_reload=True) - account = self._find_account_by_unique_id(accounts, self._state.relogin_unique_id) - if account: - account["unique_id"] = self._state.unique_id - account["username"] = username - account["cookies"] = self._state.cookies - account["targets"] = final_targets - save_userData(accounts) - self._state.touch(status="saved", message=f"Updated account {account['username']}") - return account - - account = upsert_user_account( - self._state.unique_id, - username, - self._state.cookies, - final_targets, - ) - self._state.touch(status="saved", message=f"Saved account {account['username']}") - return account - - - async def _run_login_flow(self, state, cancel_event): - - playwright = browser = context = page = None - - try: - - logger.info(f"Setting up login flow for session {state.session_id}") - - state.touch(status="starting", message="Opening Douyin Creator Center") - - 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() - - - state.touch(status="starting", message="Opening Douyin Creator Center") - - await page.goto("https://creator.douyin.com/", wait_until="domcontentloaded", timeout=60000) - - - - await asyncio.sleep(3) - - qr_selectors = [".login-mask", 'div[class*="qrcode"]', "canvas", ".login-img-code-wrapper"] - qr_found = False - - for selector in qr_selectors: - - try: - - await page.wait_for_selector(selector, timeout=5000) - - qr_found = True - - break - - except: - - continue - - - - msg = "Scan the QR code with the Douyin app" if qr_found else "Opening login page (generating QR code...)" - - state.touch(status="awaiting_scan", message=msg) - - screenshot_timing = {} - poll_interval = 0.5 - last_sms_flow_enter_at = 0.0 - - with contextlib.suppress(Exception): - await self._capture_screenshot_if_due( - page, - state, - prefer_verification=False, - force=True, - timing_state=screenshot_timing, - ) - - while not cancel_event.is_set(): - screenshot_force = False - await self._refresh_expired_qr_if_needed(page, state) - - unique_id_locator = page.locator(XPATHS["unique_id"]).first - name_locator = page.locator(XPATHS["name"]).first - - if await unique_id_locator.count() > 0 and await name_locator.count() > 0: - - logger.info("Authentication elements found, finishing login...") - - result = await collect_login_result(page, context, timeout_ms=5000) - - state.unique_id = result["unique_id"] - - state.username = result["username"] - - state.cookies = result["cookies"] - - state.touch(status="authenticated", message=f"Logged in as {state.username}") - - await page.screenshot(path=state.screenshot_path, full_page=True, timeout=15000) - state.mark_screenshot_updated() - - return - - is_verifying = await self._is_verification_step(page) - - if is_verifying: - scope = await self._resolve_interaction_scope(page) - is_selection_step = await self._is_verification_method_selection_step(page, scope) - now = asyncio.get_running_loop().time() - if is_selection_step and now - last_sms_flow_enter_at >= 2.0: - entered = await self._enter_sms_verification_flow(page, scope, state) - if entered: - last_sms_flow_enter_at = now - screenshot_force = True - with contextlib.suppress(Exception): - await self._capture_screenshot_if_due( - page, - state, - prefer_verification=True, - force=True, - min_interval_seconds=0.0, - timing_state=screenshot_timing, - ) - await asyncio.sleep(0.5) - continue - - code_input = await self._find_verification_code_input(page, scope) - if code_input: - if state.status not in {"awaiting_code", "submitting_code"}: - state.touch(status="awaiting_code", message="扫码成功,请输入收到的验证码登录") - screenshot_force = True - elif state.status not in {"awaiting_sms_request", "awaiting_code", "submitting_code"}: - state.touch(status="awaiting_sms_request", message="扫码成功,请先点击获取验证码") - screenshot_force = True - elif state.status not in {"awaiting_scan", "authenticated"}: - state.touch(status="awaiting_scan", message="请先扫码登录,扫码后会进入验证码验证") - screenshot_force = True - - if "/creator-home/" in page.url and state.status != "authenticated": - state.touch(status="submitting_code", message="正在跳转登录结果页面…") - - if state.pending_command: - - cmd = state.pending_command - - state.pending_command = None - - logger.info(f"Executing command for session {state.session_id}: {cmd}") - - scope = page - try: - - scope = await self._resolve_interaction_scope(page) - normalized_cmd = self._normalize_code_command(cmd) - - if normalized_cmd.startswith("click:"): - - text = normalized_cmd.split(":", 1)[1].strip() - - logger.info(f"Trying to click text: {text}") - - found_btn = await self._find_best_verification_flow_target( - - page, - - scope, - - [text, "接收短信验证码", "获取验证码", "重新发送", "重发", "发送验证码"], - - ) - - if found_btn: - - await found_btn.scroll_into_view_if_needed() - - await found_btn.click(timeout=15000, force=True) - - if "接收短信验证码" in text: - state.touch(status="awaiting_sms_request", message="已选择短信验证码验证,正在进入发码页面…") - elif any(keyword in text for keyword in ["验证码", "发送", "重发"]): - state.touch(status="awaiting_code", message="验证码已请求,请输入收到的验证码") - else: - state.touch(message=f"Clicked: {text}") - screenshot_force = True - - else: - - logger.warning(f"No button found with text: {text}") - snapshot = await self._visible_text_snapshot(scope) - state.touch(status="awaiting_sms_request", message=f"Button not found: {text} | visible={snapshot[:6]}") - screenshot_force = True - - else: - - await self._submit_code_command(page, scope, normalized_cmd, state) - state.touch(status="submitting_code", message="验证码已提交,正在完成登录…") - screenshot_force = True - - except Exception as e: - - logger.error(f"Command execution failed: {e}") - - snapshot = await self._visible_text_snapshot(scope) - - state.touch(message=f"Execution failed: {str(e)} | visible={snapshot[:6]}") - screenshot_force = True - - if is_verifying: - scope = await self._resolve_interaction_scope(page) - if state.status in {"awaiting_code", "submitting_code"}: - clicked_verify = await self._click_verify_button_if_ready(page, scope, state) - if clicked_verify: - screenshot_force = True - - try: - - await self._capture_screenshot_if_due( - page, - state, - prefer_verification=bool(is_verifying), - force=screenshot_force, - min_interval_seconds=2.0, - timing_state=screenshot_timing, - ) - except Exception as e: - logger.warning(f"Screenshot attempt failed: {e}") - - await asyncio.sleep(poll_interval) - - - - state.touch(status="cancelled", message="Login session cancelled") - - except Exception as exc: - - logger.error(f"Login session flow failed: {exc}") - - traceback.print_exc() - - state.touch(status="error", message=str(exc)) - - finally: - - logger.info("Closing browser session") - - if page: - - with contextlib.suppress(Exception): - - await page.close() - - if context: - - with contextlib.suppress(Exception): - - await context.close() - - if browser: - - with contextlib.suppress(Exception): - - await browser.close() - - if playwright: - - with contextlib.suppress(Exception): - - await playwright.stop() - - - - - -login_session_manager = LoginSessionManager() - diff --git a/DouYinSparkFlow/webui/ops.py b/DouYinSparkFlow/webui/ops.py index 35aab7e..703df29 100644 --- a/DouYinSparkFlow/webui/ops.py +++ b/DouYinSparkFlow/webui/ops.py @@ -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 {}) diff --git a/README.md b/README.md index 905ec87..e90f5d9 100644 --- a/README.md +++ b/README.md @@ -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 @ +``` + +然后打开 `http://127.0.0.1:8788/vnc.html?autoconnect=1&resize=scale&view_only=0`。 @@ -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) --- diff --git a/deploy/install-local.ps1 b/deploy/install-local.ps1 index 000d076..e740bde 100644 --- a/deploy/install-local.ps1 +++ b/deploy/install-local.ps1 @@ -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 diff --git a/deploy/install-local.sh b/deploy/install-local.sh index a74ceba..5a8ef85 100755 --- a/deploy/install-local.sh +++ b/deploy/install-local.sh @@ -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 diff --git a/deploy/install-server.sh b/deploy/install-server.sh index 5f0f3df..aaf4a3e 100755 --- a/deploy/install-server.sh +++ b/deploy/install-server.sh @@ -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} @${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" diff --git a/docker-compose.yml b/docker-compose.yml index 3fcc9dd..ba071da 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -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 diff --git a/docs/usage.md b/docs/usage.md index ce2b98d..66db2a2 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -25,7 +25,13 @@ 进入 **登录工作区**,在远端浏览器里完成抖音扫码、验证码或其他人工验证步骤。 -如果内嵌 noVNC 无法连接,点击 **新窗口打开**。登录完成后点击 **保存当前账号**,面板会把登录态写入账号配置。 +noVNC 默认只监听服务器的 `127.0.0.1:8788`。远程服务器先在本地建立 SSH 隧道: + +```bash +ssh -L 8788:127.0.0.1:8788 @ +``` + +然后点击 **新窗口打开**,或访问 `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