Import sanitized project structure and GitHub docs
14
.gitignore
vendored
Normal file
@@ -0,0 +1,14 @@
|
||||
.env
|
||||
state/
|
||||
logs/
|
||||
*.bak-*
|
||||
*.tar.gz
|
||||
|
||||
DouYinSparkFlow/.im_sdk_cache/
|
||||
DouYinSparkFlow/logs/
|
||||
DouYinSparkFlow/usersData.json
|
||||
DouYinSparkFlow/webui_settings.json
|
||||
DouYinSparkFlow/im_client_introspect.mjs
|
||||
DouYinSparkFlow/core/protocol_sender_debug.mjs
|
||||
DouYinSparkFlow/**/__pycache__/
|
||||
DouYinSparkFlow/**/*.pyc
|
||||
41
DouYinSparkFlow/.github/workflows/schedule.yml
vendored
Normal file
@@ -0,0 +1,41 @@
|
||||
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
|
||||
8
DouYinSparkFlow/.gitignore
vendored
Normal file
@@ -0,0 +1,8 @@
|
||||
.venv/
|
||||
__pycache__/
|
||||
.vscode/
|
||||
chrome/
|
||||
logs/
|
||||
.DS_Store
|
||||
usersData.json
|
||||
webui_settings.json
|
||||
54
DouYinSparkFlow/Dockerfile.server
Normal file
@@ -0,0 +1,54 @@
|
||||
FROM mcr.microsoft.com/playwright/python:v1.56.0-jammy
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
ARG HTTP_PROXY
|
||||
ARG HTTPS_PROXY
|
||||
ARG ALL_PROXY
|
||||
ARG http_proxy
|
||||
ARG https_proxy
|
||||
ARG all_proxy
|
||||
ARG PIP_INDEX_URL=https://pypi.tuna.tsinghua.edu.cn/simple
|
||||
ARG PIP_TRUSTED_HOST=pypi.tuna.tsinghua.edu.cn
|
||||
|
||||
ENV HTTP_PROXY=${HTTP_PROXY}
|
||||
ENV HTTPS_PROXY=${HTTPS_PROXY}
|
||||
ENV ALL_PROXY=${ALL_PROXY}
|
||||
ENV http_proxy=${http_proxy}
|
||||
ENV https_proxy=${https_proxy}
|
||||
ENV all_proxy=${all_proxy}
|
||||
ENV DEBIAN_FRONTEND=noninteractive
|
||||
ENV TZ=Asia/Shanghai
|
||||
ENV PIP_INDEX_URL=${PIP_INDEX_URL}
|
||||
ENV PIP_TRUSTED_HOST=${PIP_TRUSTED_HOST}
|
||||
|
||||
COPY requirements.txt .
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
|
||||
RUN sed -i 's/archive.ubuntu.com/mirrors.aliyun.com/g' /etc/apt/sources.list \
|
||||
&& sed -i 's/security.ubuntu.com/mirrors.aliyun.com/g' /etc/apt/sources.list \
|
||||
&& ln -fs /usr/share/zoneinfo/${TZ} /etc/localtime \
|
||||
&& echo ${TZ} > /etc/timezone \
|
||||
&& apt-get update && apt-get install -y \
|
||||
cron \
|
||||
curl \
|
||||
fluxbox \
|
||||
fonts-wqy-zenhei \
|
||||
fonts-noto-cjk \
|
||||
novnc \
|
||||
websockify \
|
||||
x11vnc \
|
||||
xfonts-intl-chinese \
|
||||
&& curl -fsSL -x http://127.0.0.1:7890 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 -x http://127.0.0.1:7890 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 \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
COPY . .
|
||||
|
||||
CMD ["python", "main.py", "--doTask"]
|
||||
21
DouYinSparkFlow/LICENSE
Normal file
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2026 2061360308
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
18
DouYinSparkFlow/README.md
Normal file
@@ -0,0 +1,18 @@
|
||||
# DouYinSparkFlow
|
||||
|
||||
这里是核心应用源码目录,包含:
|
||||
|
||||
- `core/`: 任务执行、协议发送、浏览器自动化等核心逻辑
|
||||
- `webui/`: Web 管理界面与相关后端处理
|
||||
- `utils/`: 配置、日志和通用辅助逻辑
|
||||
- `scripts/`: 运行和登录辅助脚本
|
||||
|
||||
## 本地开发入口
|
||||
|
||||
- 安装依赖:`requirements.txt` / `requirements-web.txt`
|
||||
- 应用入口:`main.py`
|
||||
- 容器构建参考:`Dockerfile.server`
|
||||
|
||||
运行时账号数据、Web 管理设置、浏览器缓存和日志文件不随仓库提供,需要在目标环境中自行生成。
|
||||
|
||||
仓库级说明、部署结构和敏感文件约定请查看上级目录的 [README.md](../README.md)。
|
||||
40
DouYinSparkFlow/config.json
Normal file
@@ -0,0 +1,40 @@
|
||||
{
|
||||
"multiTask": true,
|
||||
"taskCount": 1,
|
||||
"proxyAddress": "",
|
||||
"messageTemplate": "🤩今日火花+1\r\n",
|
||||
"browserSenderAccounts": [
|
||||
"94262577168",
|
||||
"抖音号:softwomen"
|
||||
],
|
||||
"sendStrategy": {
|
||||
"shuffleTargets": true,
|
||||
"accountStartDelaySecondsMin": 15,
|
||||
"accountStartDelaySecondsMax": 60,
|
||||
"messageIntervalSecondsMin": 25,
|
||||
"messageIntervalSecondsMax": 70,
|
||||
"messageVariants": [
|
||||
"🤩今日火花+1",
|
||||
"今天来补个火花",
|
||||
"给你续一下今天的火花",
|
||||
"路过给你加个小火花"
|
||||
]
|
||||
},
|
||||
"dailySendWindow": {
|
||||
"enabled": true,
|
||||
"startHour": 10,
|
||||
"endHour": 18,
|
||||
"scheduleIntervalMinutes": 10
|
||||
},
|
||||
"hitokotoTypes": [
|
||||
"文学",
|
||||
"影视",
|
||||
"诗词",
|
||||
"哲学"
|
||||
],
|
||||
"happyNewYear": {
|
||||
"enabled": true,
|
||||
"messageTemplate": "\r\n"
|
||||
},
|
||||
"useProtocolSender": false
|
||||
}
|
||||
0
DouYinSparkFlow/core/__init__.py
Normal file
63
DouYinSparkFlow/core/browser.py
Normal file
@@ -0,0 +1,63 @@
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
import traceback
|
||||
from pathlib import Path
|
||||
|
||||
from playwright.async_api import async_playwright
|
||||
from rich.console import Console
|
||||
|
||||
from utils.config import DEBUG, Environment, get_environment
|
||||
|
||||
|
||||
console = Console()
|
||||
PLAYWRIGHT_BROWSERS_PATH = "../chrome"
|
||||
|
||||
|
||||
def _local_browser_bundle_path():
|
||||
return Path(__file__).resolve().parent / PLAYWRIGHT_BROWSERS_PATH
|
||||
|
||||
|
||||
def configure_playwright_environment():
|
||||
if os.getenv("PLAYWRIGHT_BROWSERS_PATH"):
|
||||
return
|
||||
|
||||
env = get_environment()
|
||||
if env == Environment.PACKED:
|
||||
bundle_path = Path(sys.executable).resolve().parent / PLAYWRIGHT_BROWSERS_PATH
|
||||
else:
|
||||
bundle_path = _local_browser_bundle_path()
|
||||
|
||||
if bundle_path.exists():
|
||||
os.environ["PLAYWRIGHT_BROWSERS_PATH"] = str(bundle_path.resolve())
|
||||
|
||||
|
||||
async def install_browser():
|
||||
try:
|
||||
subprocess.run([sys.executable, "-m", "playwright", "install", "chromium"], check=True)
|
||||
console.print("[bold green]Browser install completed. Please run the command again.[/bold green]")
|
||||
except subprocess.CalledProcessError as exc:
|
||||
console.print(f"[bold red]Browser install failed: {exc}[/bold red]")
|
||||
|
||||
|
||||
async def get_browser(GUI=False):
|
||||
configure_playwright_environment()
|
||||
|
||||
headless = not GUI
|
||||
if get_environment() == Environment.LOCAL and DEBUG:
|
||||
headless = False
|
||||
|
||||
try:
|
||||
playwright = await async_playwright().start()
|
||||
browser = await playwright.chromium.launch(
|
||||
headless=headless,
|
||||
args=["--disable-dev-shm-usage"],
|
||||
)
|
||||
return playwright, browser
|
||||
except Exception as exc:
|
||||
if "Executable doesn't exist" in str(exc) and get_environment() != Environment.GITHUBACTION:
|
||||
console.print("[bold red]Playwright browser is missing.[/bold red]")
|
||||
await install_browser()
|
||||
sys.exit(1)
|
||||
traceback.print_exc()
|
||||
raise
|
||||
134
DouYinSparkFlow/core/friends.py
Normal file
@@ -0,0 +1,134 @@
|
||||
import asyncio
|
||||
from pathlib import Path
|
||||
|
||||
from core.browser import get_browser
|
||||
|
||||
|
||||
CHAT_PAGE_URL = "https://creator.douyin.com/creator-micro/data/following/chat"
|
||||
FRIENDS_TAB_SELECTOR = 'xpath=//*[@id="sub-app"]/div/div/div[1]/div[2]'
|
||||
TARGET_SELECTOR = (
|
||||
'xpath=//*[@id="sub-app"]/div/div[1]/div[2]/div[2]'
|
||||
'//div[contains(@class, "semi-list-item-body semi-list-item-body-flex-start")]'
|
||||
)
|
||||
SCROLLABLE_FRIENDS_SELECTOR = (
|
||||
'xpath=//*[@id="sub-app"]/div/div[1]/div[2]/div[2]/div/div/div[3]/div/div/div/ul/div'
|
||||
)
|
||||
NO_MORE_SELECTOR = 'xpath=//div[contains(@class, "no-more-tip-ftdJnu")]'
|
||||
LOADING_SELECTOR = 'xpath=//div[contains(@class, "semi-spin")]'
|
||||
FIRST_FRIEND_SELECTOR = (
|
||||
'xpath=//*[@id="sub-app"]/div/div/div[2]/div[2]/div/div/div[1]/div/div/div/ul/div/div/div[1]/li/div'
|
||||
)
|
||||
FRIEND_NAME_SELECTOR = """xpath=.//span[contains(@class, "item-header-name-")]"""
|
||||
LOGIN_MASK_SELECTORS = [".login-mask", ".login-guide-container", ".login-img-code-wrapper"]
|
||||
|
||||
|
||||
def update_collection_progress(new_names_count, no_more_visible, scroll_moved, idle_rounds, stuck_rounds, idle_limit=5, stuck_limit=2):
|
||||
next_idle_rounds = 0 if new_names_count > 0 else idle_rounds + 1
|
||||
next_stuck_rounds = 0 if scroll_moved else stuck_rounds + 1
|
||||
should_stop = no_more_visible or next_idle_rounds >= idle_limit or next_stuck_rounds >= stuck_limit
|
||||
return should_stop, next_idle_rounds, next_stuck_rounds
|
||||
|
||||
|
||||
async def _ensure_logged_in(page):
|
||||
for selector in LOGIN_MASK_SELECTORS:
|
||||
try:
|
||||
locator = page.locator(selector).first
|
||||
if await locator.count() > 0 and await locator.is_visible():
|
||||
raise RuntimeError("账号登录已失效,请重新扫码登录")
|
||||
except RuntimeError:
|
||||
raise
|
||||
except Exception:
|
||||
continue
|
||||
|
||||
|
||||
async def collect_friend_names(page):
|
||||
await page.wait_for_selector(FRIENDS_TAB_SELECTOR, timeout=30000)
|
||||
await page.locator(FRIENDS_TAB_SELECTOR).click()
|
||||
|
||||
await page.wait_for_selector(FIRST_FRIEND_SELECTOR, timeout=30000)
|
||||
await page.locator(FIRST_FRIEND_SELECTOR).click()
|
||||
await asyncio.sleep(2)
|
||||
|
||||
found_names = []
|
||||
seen_names = set()
|
||||
idle_rounds = 0
|
||||
stuck_rounds = 0
|
||||
|
||||
while True:
|
||||
target_elements = await page.locator(TARGET_SELECTOR).all()
|
||||
new_names_count = 0
|
||||
for element in target_elements:
|
||||
try:
|
||||
name = (await element.locator(FRIEND_NAME_SELECTOR).inner_text()).strip()
|
||||
except Exception:
|
||||
continue
|
||||
if not name or name in seen_names:
|
||||
continue
|
||||
seen_names.add(name)
|
||||
found_names.append(name)
|
||||
new_names_count += 1
|
||||
|
||||
no_more = page.locator(NO_MORE_SELECTOR).first
|
||||
if await no_more.count() > 0 and await no_more.is_visible():
|
||||
return found_names
|
||||
|
||||
loading = page.locator(LOADING_SELECTOR).first
|
||||
if await loading.count() > 0 and await loading.is_visible():
|
||||
await asyncio.sleep(1.5)
|
||||
|
||||
scrollable_element = await page.locator(SCROLLABLE_FRIENDS_SELECTOR).element_handle()
|
||||
if not scrollable_element:
|
||||
if found_names:
|
||||
return found_names
|
||||
raise RuntimeError("未找到好友列表滚动容器")
|
||||
|
||||
before_top = await page.evaluate("(element) => element.scrollTop", scrollable_element)
|
||||
await page.evaluate("(element) => element.scrollTop += 800", scrollable_element)
|
||||
await asyncio.sleep(1.5)
|
||||
after_top = await page.evaluate("(element) => element.scrollTop", scrollable_element)
|
||||
|
||||
should_stop, idle_rounds, stuck_rounds = update_collection_progress(
|
||||
new_names_count=new_names_count,
|
||||
no_more_visible=False,
|
||||
scroll_moved=after_top > before_top,
|
||||
idle_rounds=idle_rounds,
|
||||
stuck_rounds=stuck_rounds,
|
||||
)
|
||||
if should_stop:
|
||||
return found_names
|
||||
|
||||
|
||||
async def fetch_account_friends(account):
|
||||
cookies = list(account.get("cookies") or [])
|
||||
if not cookies:
|
||||
raise RuntimeError("账号没有可用 cookies,请重新扫码登录")
|
||||
|
||||
playwright = browser = context = page = None
|
||||
try:
|
||||
playwright, browser = await get_browser(GUI=False)
|
||||
context = await browser.new_context()
|
||||
context.set_default_navigation_timeout(120000)
|
||||
context.set_default_timeout(120000)
|
||||
page = await context.new_page()
|
||||
|
||||
await page.goto("https://creator.douyin.com/", wait_until="domcontentloaded", timeout=60000)
|
||||
await context.add_cookies(cookies)
|
||||
await page.goto(CHAT_PAGE_URL, wait_until="domcontentloaded", timeout=60000)
|
||||
await asyncio.sleep(2)
|
||||
|
||||
await _ensure_logged_in(page)
|
||||
friends = await collect_friend_names(page)
|
||||
return friends
|
||||
except RuntimeError:
|
||||
raise
|
||||
except Exception as exc:
|
||||
raise RuntimeError(f"刷新好友列表失败,请重试:{exc}") from exc
|
||||
finally:
|
||||
if page:
|
||||
await page.close()
|
||||
if context:
|
||||
await context.close()
|
||||
if browser:
|
||||
await browser.close()
|
||||
if playwright:
|
||||
await playwright.stop()
|
||||
83
DouYinSparkFlow/core/login.py
Normal file
@@ -0,0 +1,83 @@
|
||||
import asyncio
|
||||
|
||||
from rich.console import Console
|
||||
|
||||
from core.browser import get_browser
|
||||
from utils.config import normalize_unique_id, upsert_user_account
|
||||
|
||||
|
||||
console = Console()
|
||||
|
||||
READY_SELECTOR = (
|
||||
'xpath=//*[contains(@id, "garfish_app_for_douyin_creator_pc_home")]'
|
||||
'/div/div[2]/div/div[2]/div[1]'
|
||||
)
|
||||
XPATHS = {
|
||||
"unique_id": (
|
||||
'xpath=//*[contains(@id, "garfish_app_for_douyin_creator_pc_home")]'
|
||||
'/div/div[2]/div/div[2]/div[1]/div[2]/div[1]/div[3]'
|
||||
),
|
||||
"name": (
|
||||
'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]'
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
async def wait_for_logged_in_identity(page, timeout_ms=300000):
|
||||
await page.wait_for_selector(READY_SELECTOR, timeout=timeout_ms)
|
||||
|
||||
unique_id_element = await page.wait_for_selector(XPATHS["unique_id"], timeout=timeout_ms)
|
||||
name_element = await page.wait_for_selector(XPATHS["name"], timeout=timeout_ms)
|
||||
|
||||
unique_id_text = await unique_id_element.inner_text()
|
||||
username = (await name_element.inner_text()).strip()
|
||||
unique_id = normalize_unique_id(unique_id_text)
|
||||
return unique_id, username
|
||||
|
||||
|
||||
async def collect_login_result(page, context, timeout_ms=300000):
|
||||
unique_id, username = await wait_for_logged_in_identity(page, timeout_ms=timeout_ms)
|
||||
cookies = await context.cookies()
|
||||
return {
|
||||
"unique_id": unique_id,
|
||||
"username": username,
|
||||
"cookies": cookies,
|
||||
}
|
||||
|
||||
|
||||
async def userLogin(targets=None):
|
||||
playwright, browser = await get_browser(GUI=True)
|
||||
try:
|
||||
context = await browser.new_context()
|
||||
page = await context.new_page()
|
||||
|
||||
await page.goto("https://creator.douyin.com/")
|
||||
console.print("Please scan the QR code and finish logging into Douyin Creator Center.")
|
||||
|
||||
login_result = await collect_login_result(page, context)
|
||||
console.print(f"Unique ID: {login_result['unique_id']}")
|
||||
console.print(f"Name: {login_result['username']}")
|
||||
console.print(f"Cookies: found {len(login_result['cookies'])} cookies")
|
||||
|
||||
if targets is None:
|
||||
raw_targets = input(
|
||||
"Open Creator Center -> 互动管理 -> 私信管理 -> 朋友私信, then enter friend display names separated by spaces: "
|
||||
)
|
||||
targets = [target.strip() for target in raw_targets.split(" ") if target.strip()]
|
||||
|
||||
account = upsert_user_account(
|
||||
login_result["unique_id"],
|
||||
login_result["username"],
|
||||
login_result["cookies"],
|
||||
targets,
|
||||
)
|
||||
console.print(f"[bold green]Login complete. Updated account {account['username']}.[/bold green]")
|
||||
return account
|
||||
finally:
|
||||
await playwright.stop()
|
||||
await browser.close()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(userLogin())
|
||||
129
DouYinSparkFlow/core/msg_builder.py
Normal file
@@ -0,0 +1,129 @@
|
||||
"""
|
||||
core/msg_builder.py
|
||||
|
||||
Resolve configured message templates into concrete per-target messages.
|
||||
"""
|
||||
|
||||
import random
|
||||
from datetime import date
|
||||
from typing import Dict, List, Optional
|
||||
|
||||
from utils.config import get_config
|
||||
from utils.hitokoto import request_hitokoto
|
||||
|
||||
|
||||
FESTIVAL_WINDOW_START = date(2026, 2, 16)
|
||||
FESTIVAL_WINDOW_END = date(2026, 3, 3)
|
||||
|
||||
|
||||
def _is_holiday_mode_enabled(active_config: dict, today: date) -> bool:
|
||||
return bool(active_config.get("happyNewYear", {}).get("enabled", False)) and FESTIVAL_WINDOW_START <= today <= FESTIVAL_WINDOW_END
|
||||
|
||||
|
||||
def _render_holiday_message(active_config: dict, today: date) -> str:
|
||||
from utils.chinese_new_year_2026_mare import get_lunar_date, get_random_festival_quote
|
||||
|
||||
message = str(active_config.get("happyNewYear", {}).get("messageTemplate", "[API]"))
|
||||
if "[data]" in message:
|
||||
message = message.replace("[data]", today.strftime("%Y年%m月%d日"))
|
||||
if "[data_lunar]" in message:
|
||||
lunar_date = get_lunar_date(today)
|
||||
message = message.replace("[data_lunar]", lunar_date if lunar_date else "未知农历日期")
|
||||
if "[API]" in message:
|
||||
message = message.replace("[API]", get_random_festival_quote())
|
||||
return message.strip()
|
||||
|
||||
|
||||
def _get_message_templates(active_config: dict) -> List[str]:
|
||||
strategy = active_config.get("sendStrategy", {}) or {}
|
||||
variants = [str(item).strip() for item in strategy.get("messageVariants", []) if str(item).strip()]
|
||||
if variants:
|
||||
return variants
|
||||
return [str(active_config.get("messageTemplate", "续火花")).strip()]
|
||||
|
||||
|
||||
def _render_regular_message(template: str) -> str:
|
||||
message = template
|
||||
if "[API]" in message:
|
||||
message = message.replace("[API]", request_hitokoto())
|
||||
return message.strip()
|
||||
|
||||
|
||||
def build_message_candidates(config: Optional[dict] = None) -> List[str]:
|
||||
active_config = config or get_config()
|
||||
today = date.today()
|
||||
|
||||
if _is_holiday_mode_enabled(active_config, today):
|
||||
return [_render_holiday_message(active_config, today)]
|
||||
|
||||
candidates: List[str] = []
|
||||
for template in _get_message_templates(active_config):
|
||||
message = _render_regular_message(template)
|
||||
if message and message not in candidates:
|
||||
candidates.append(message)
|
||||
|
||||
if candidates:
|
||||
return candidates
|
||||
return ["续火花"]
|
||||
|
||||
|
||||
def _extract_previous_message(previous_messages: Optional[dict], target: str) -> str:
|
||||
if not previous_messages:
|
||||
return ""
|
||||
|
||||
previous = previous_messages.get(target, "")
|
||||
if isinstance(previous, dict):
|
||||
return str(previous.get("message", "")).strip()
|
||||
return str(previous).strip()
|
||||
|
||||
|
||||
def _choose_message(candidates: List[str], previous_message: str, last_message: str) -> str:
|
||||
filtered = [message for message in candidates if message != previous_message and message != last_message]
|
||||
if filtered:
|
||||
return random.choice(filtered)
|
||||
|
||||
filtered = [message for message in candidates if message != previous_message]
|
||||
if filtered:
|
||||
return random.choice(filtered)
|
||||
|
||||
filtered = [message for message in candidates if message != last_message]
|
||||
if filtered:
|
||||
return random.choice(filtered)
|
||||
|
||||
return random.choice(candidates)
|
||||
|
||||
|
||||
def build_message(previous_message: str = "", config: Optional[dict] = None, last_message: str = "") -> str:
|
||||
candidates = build_message_candidates(config)
|
||||
return _choose_message(candidates, previous_message.strip(), last_message.strip()).strip()
|
||||
|
||||
|
||||
def build_messages_for_targets(
|
||||
targets: List[str],
|
||||
previous_messages: Optional[dict] = None,
|
||||
config: Optional[dict] = None,
|
||||
) -> Dict[str, str]:
|
||||
active_config = config or get_config()
|
||||
strategy = active_config.get("sendStrategy", {}) or {}
|
||||
|
||||
ordered_targets = []
|
||||
seen_targets = set()
|
||||
for target in targets:
|
||||
normalized = str(target).strip()
|
||||
if not normalized or normalized in seen_targets:
|
||||
continue
|
||||
seen_targets.add(normalized)
|
||||
ordered_targets.append(normalized)
|
||||
|
||||
if strategy.get("shuffleTargets", True):
|
||||
random.shuffle(ordered_targets)
|
||||
|
||||
planned_messages: Dict[str, str] = {}
|
||||
last_message = ""
|
||||
for target in ordered_targets:
|
||||
previous_message = _extract_previous_message(previous_messages, target)
|
||||
message = build_message(previous_message=previous_message, config=active_config, last_message=last_message)
|
||||
planned_messages[target] = message
|
||||
last_message = message
|
||||
|
||||
return planned_messages
|
||||
279
DouYinSparkFlow/core/protocol_dispatch.py
Normal file
@@ -0,0 +1,279 @@
|
||||
import asyncio
|
||||
import json
|
||||
import os
|
||||
import random
|
||||
import shutil
|
||||
import subprocess
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
|
||||
from core.msg_builder import build_messages_for_targets
|
||||
from utils.config import get_userData, normalize_unique_id, repo_root, save_userData
|
||||
from utils.logger import setup_logger
|
||||
|
||||
|
||||
logger = setup_logger()
|
||||
PROTOCOL_SCRIPT = repo_root() / "core" / "protocol_sender.mjs"
|
||||
NODE_HELPER_IMAGE = "node:22-alpine"
|
||||
|
||||
|
||||
def _coerce_non_negative_int(value, default):
|
||||
try:
|
||||
return max(0, int(value))
|
||||
except (TypeError, ValueError):
|
||||
return max(0, int(default))
|
||||
|
||||
|
||||
def _normalize_send_strategy(config):
|
||||
raw = config.get("sendStrategy", {}) or {}
|
||||
start_min = _coerce_non_negative_int(raw.get("accountStartDelaySecondsMin", 0), 0)
|
||||
start_max = _coerce_non_negative_int(raw.get("accountStartDelaySecondsMax", start_min), start_min)
|
||||
if start_max < start_min:
|
||||
start_max = start_min
|
||||
|
||||
message_min = _coerce_non_negative_int(raw.get("messageIntervalSecondsMin", 0), 0)
|
||||
message_max = _coerce_non_negative_int(raw.get("messageIntervalSecondsMax", message_min), message_min)
|
||||
if message_max < message_min:
|
||||
message_max = message_min
|
||||
|
||||
strategy = {
|
||||
"shuffleTargets": bool(raw.get("shuffleTargets", True)),
|
||||
"accountStartDelaySecondsMin": start_min,
|
||||
"accountStartDelaySecondsMax": start_max,
|
||||
"messageIntervalSecondsMin": message_min,
|
||||
"messageIntervalSecondsMax": message_max,
|
||||
"messageVariants": [str(item).strip() for item in raw.get("messageVariants", []) if str(item).strip()],
|
||||
}
|
||||
if os.getenv("SPARKFLOW_MANUAL_RUN") == "1":
|
||||
strategy["accountStartDelaySecondsMin"] = 0
|
||||
strategy["accountStartDelaySecondsMax"] = 0
|
||||
strategy["messageIntervalSecondsMin"] = min(strategy["messageIntervalSecondsMin"], 3)
|
||||
strategy["messageIntervalSecondsMax"] = min(strategy["messageIntervalSecondsMax"], 6)
|
||||
return strategy
|
||||
|
||||
|
||||
def _account_identity_key(account):
|
||||
normalized_unique_id = normalize_unique_id(account.get("unique_id"))
|
||||
if normalized_unique_id:
|
||||
return f"uid:{normalized_unique_id}"
|
||||
|
||||
username = str(account.get("username", "")).strip()
|
||||
if username:
|
||||
return f"user:{username}"
|
||||
|
||||
return ""
|
||||
|
||||
|
||||
def _merge_protocol_runtime_state(accounts, result_by_username):
|
||||
changed = False
|
||||
now_iso = datetime.now(timezone.utc).isoformat(timespec="seconds")
|
||||
all_accounts = get_userData(force_reload=True)
|
||||
accounts_by_identity = {
|
||||
identity: account
|
||||
for account in all_accounts
|
||||
for identity in [_account_identity_key(account)]
|
||||
if identity
|
||||
}
|
||||
|
||||
for account in accounts:
|
||||
target_account = accounts_by_identity.get(_account_identity_key(account))
|
||||
if not target_account:
|
||||
continue
|
||||
|
||||
result = result_by_username.get(account.get("username"))
|
||||
if not result:
|
||||
continue
|
||||
|
||||
protocol_cache = result.get("protocol_targets_cache")
|
||||
if protocol_cache is not None:
|
||||
target_account["protocol_targets_cache"] = protocol_cache
|
||||
target_account["protocol_user_id"] = result.get("userId", "")
|
||||
changed = True
|
||||
|
||||
history = dict(target_account.get("message_history") or {})
|
||||
for entry in result.get("sent", []):
|
||||
if entry.get("dryRun") or not entry.get("success", True):
|
||||
continue
|
||||
|
||||
target = str(entry.get("target", "")).strip()
|
||||
message = str(entry.get("message", "")).strip()
|
||||
if not target or not message:
|
||||
continue
|
||||
|
||||
history[target] = {
|
||||
"message": message,
|
||||
"sentAt": str(entry.get("sentAt", now_iso)),
|
||||
}
|
||||
changed = True
|
||||
|
||||
if history:
|
||||
target_account["message_history"] = history
|
||||
|
||||
if changed:
|
||||
save_userData(all_accounts)
|
||||
|
||||
|
||||
def _host_repo_root():
|
||||
candidates = [
|
||||
Path("/opt/douyin-sparkflow/DouYinSparkFlow"),
|
||||
repo_root(),
|
||||
]
|
||||
for candidate in candidates:
|
||||
if (candidate / "core" / "protocol_sender.mjs").exists():
|
||||
return candidate
|
||||
return repo_root()
|
||||
|
||||
|
||||
def _build_protocol_command():
|
||||
node_path = shutil.which("node")
|
||||
if node_path:
|
||||
return [node_path, str(PROTOCOL_SCRIPT)], repo_root(), "local-node", str(repo_root())
|
||||
|
||||
docker_path = shutil.which("docker")
|
||||
if docker_path:
|
||||
host_repo = _host_repo_root()
|
||||
return (
|
||||
[
|
||||
docker_path,
|
||||
"run",
|
||||
"--rm",
|
||||
"-i",
|
||||
"--network",
|
||||
"host",
|
||||
"-v",
|
||||
f"{host_repo}:/workspace",
|
||||
"-w",
|
||||
"/workspace",
|
||||
NODE_HELPER_IMAGE,
|
||||
"node",
|
||||
"core/protocol_sender.mjs",
|
||||
],
|
||||
repo_root(),
|
||||
"docker-node-helper",
|
||||
"/workspace",
|
||||
)
|
||||
|
||||
raise RuntimeError("Neither node nor docker is available for the protocol sender")
|
||||
|
||||
|
||||
def _run_protocol_for_user(user, messages_by_target, dry_run, send_strategy):
|
||||
command, cwd, runner_label, runtime_repo_root = _build_protocol_command()
|
||||
payload = {
|
||||
"repoRoot": runtime_repo_root,
|
||||
"dryRun": dry_run,
|
||||
"account": user,
|
||||
"messagesByTarget": messages_by_target,
|
||||
"sendStrategy": send_strategy,
|
||||
}
|
||||
process = subprocess.run(
|
||||
command,
|
||||
input=json.dumps(payload, ensure_ascii=False),
|
||||
text=True,
|
||||
capture_output=True,
|
||||
cwd=str(cwd),
|
||||
check=False,
|
||||
)
|
||||
|
||||
stdout = (process.stdout or "").strip()
|
||||
if not stdout:
|
||||
raise RuntimeError(
|
||||
f"protocol sender returned no output for {user.get('username', 'unknown')}: {process.stderr}"
|
||||
)
|
||||
|
||||
try:
|
||||
data = json.loads(stdout)
|
||||
except json.JSONDecodeError as exc:
|
||||
raise RuntimeError(
|
||||
f"protocol sender produced invalid JSON for {user.get('username', 'unknown')}: {stdout}"
|
||||
) from exc
|
||||
|
||||
if process.returncode != 0 or not data.get("ok"):
|
||||
error_message = data.get("error") or process.stderr or "protocol sender failed"
|
||||
raise RuntimeError(
|
||||
f"{user.get('username', 'unknown')} protocol sender failed: {error_message}"
|
||||
)
|
||||
|
||||
data["runner"] = runner_label
|
||||
|
||||
return data
|
||||
|
||||
|
||||
async def run_protocol_tasks(config, accounts, message_builder):
|
||||
del message_builder
|
||||
|
||||
dry_run = bool(config.get("protocolDryRun", False))
|
||||
multi_task = bool(config.get("multiTask", True))
|
||||
concurrency = int(config.get("taskCount", 1)) if multi_task else 1
|
||||
semaphore = asyncio.Semaphore(max(concurrency, 1))
|
||||
send_strategy = _normalize_send_strategy(config)
|
||||
|
||||
async def _worker(user):
|
||||
async with semaphore:
|
||||
start_delay = random.randint(
|
||||
send_strategy["accountStartDelaySecondsMin"],
|
||||
send_strategy["accountStartDelaySecondsMax"],
|
||||
)
|
||||
if start_delay > 0:
|
||||
logger.info(
|
||||
"Delaying protocol sender for %s by %ss to avoid synchronized bursts",
|
||||
user.get("username", "unknown"),
|
||||
start_delay,
|
||||
)
|
||||
await asyncio.sleep(start_delay)
|
||||
|
||||
logger.info("Starting protocol sender for %s", user.get("username", "unknown"))
|
||||
messages_by_target = build_messages_for_targets(
|
||||
user.get("targets", []),
|
||||
previous_messages=user.get("message_history", {}),
|
||||
config=config,
|
||||
)
|
||||
logger.info(
|
||||
"Prepared %s protocol messages for %s with shuffleTargets=%s interval=%s-%ss manual_run=%s",
|
||||
len(messages_by_target),
|
||||
user.get("username", "unknown"),
|
||||
send_strategy["shuffleTargets"],
|
||||
send_strategy["messageIntervalSecondsMin"],
|
||||
send_strategy["messageIntervalSecondsMax"],
|
||||
os.getenv("SPARKFLOW_MANUAL_RUN") == "1",
|
||||
)
|
||||
result = await asyncio.to_thread(
|
||||
_run_protocol_for_user,
|
||||
user,
|
||||
messages_by_target,
|
||||
dry_run,
|
||||
send_strategy,
|
||||
)
|
||||
logger.info(
|
||||
"Protocol sender finished for %s resolved=%s unresolved=%s sent=%s",
|
||||
user.get("username", "unknown"),
|
||||
len(result.get("resolved", [])),
|
||||
len(result.get("unresolved", [])),
|
||||
len(result.get("sent", [])),
|
||||
)
|
||||
return result
|
||||
|
||||
gathered = await asyncio.gather(*(_worker(user) for user in accounts), return_exceptions=True)
|
||||
|
||||
result_by_username = {}
|
||||
failures = []
|
||||
for user, item in zip(accounts, gathered):
|
||||
if isinstance(item, Exception):
|
||||
failures.append(str(item))
|
||||
logger.error("Protocol sender failed for %s: %s", user.get("username", "unknown"), item)
|
||||
continue
|
||||
result_by_username[user.get("username")] = item
|
||||
unresolved = item.get("unresolved", [])
|
||||
if unresolved:
|
||||
logger.warning(
|
||||
"Protocol sender could not resolve %s targets for %s: %s",
|
||||
len(unresolved),
|
||||
user.get("username", "unknown"),
|
||||
[entry.get("target") for entry in unresolved],
|
||||
)
|
||||
|
||||
_merge_protocol_runtime_state(accounts, result_by_username)
|
||||
|
||||
if failures and not result_by_username:
|
||||
raise RuntimeError("; ".join(failures))
|
||||
|
||||
return [result_by_username[user.get("username")] for user in accounts if user.get("username") in result_by_username]
|
||||
728
DouYinSparkFlow/core/protocol_sender.mjs
Normal file
@@ -0,0 +1,728 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import vm from "node:vm";
|
||||
import { Blob } from "node:buffer";
|
||||
|
||||
const SDK_BUNDLES = [
|
||||
"https://lf-fe-creator.douyinstatic.com/obj/douyn-creator-scm-cdn/douyin-creator-mono-pc-data/static/js/lib-polyfill.f81f86eb.js",
|
||||
"https://lf-fe-creator.douyinstatic.com/obj/douyn-creator-scm-cdn/douyin-creator-mono-pc-data/static/js/lib-router.5ab9ff10.js",
|
||||
"https://lf-fe-creator.douyinstatic.com/obj/douyn-creator-scm-cdn/douyin-creator-mono-pc-data/static/js/2105.f8d74876.js",
|
||||
"https://lf-fe-creator.douyinstatic.com/obj/douyn-creator-scm-cdn/douyin-creator-mono-pc-data/static/js/douyin_creator_data_old.2f971672.js",
|
||||
"https://lf-fe-creator.douyinstatic.com/obj/douyn-creator-scm-cdn/douyin-creator-mono-pc-data/static/js/async/argus-builder-strategy.5a053c46.js",
|
||||
"https://lf-fe-creator.douyinstatic.com/obj/douyn-creator-scm-cdn/douyin-creator-mono-pc-data/static/js/async/7676.a4cd4900.js",
|
||||
"https://lf-fe-creator.douyinstatic.com/obj/douyn-creator-scm-cdn/douyin-creator-mono-pc-data/static/js/async/4916.56c33d22.js",
|
||||
"https://lf-fe-creator.douyinstatic.com/obj/douyn-creator-scm-cdn/douyin-creator-mono-pc-data/static/js/async/8198.b5c0b108.js",
|
||||
"https://lf-fe-creator.douyinstatic.com/obj/douyn-creator-scm-cdn/douyin-creator-mono-pc-data/static/js/async/4168.b2e72401.js",
|
||||
"https://lf-fe-creator.douyinstatic.com/obj/douyn-creator-scm-cdn/douyin-creator-mono-pc-data/static/js/async/7771.d27d1891.js",
|
||||
"https://lf-fe-creator.douyinstatic.com/obj/douyn-creator-scm-cdn/douyin-creator-mono-pc-data/static/js/async/6682.2a991dfb.js",
|
||||
"https://lf-fe-creator.douyinstatic.com/obj/douyn-creator-scm-cdn/douyin-creator-mono-pc-data/static/js/async/361.4fc40815.js",
|
||||
"https://lf-fe-creator.douyinstatic.com/obj/douyn-creator-scm-cdn/douyin-creator-mono-pc-data/static/js/async/pages-chat.6f823210.js",
|
||||
];
|
||||
|
||||
const CREATOR_CHAT_URL = "https://creator.douyin.com/creator-micro/data/following/chat";
|
||||
const USER_AGENT =
|
||||
"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/141 Safari/537.36";
|
||||
|
||||
function noop() {}
|
||||
|
||||
function toCookieString(cookies) {
|
||||
return (cookies || [])
|
||||
.filter((item) => item?.name && item?.value !== undefined)
|
||||
.map((item) => `${item.name}=${item.value}`)
|
||||
.join("; ");
|
||||
}
|
||||
|
||||
function normalizeNickname(value) {
|
||||
return String(value || "").trim();
|
||||
}
|
||||
|
||||
function stableNow() {
|
||||
return new Date().toISOString();
|
||||
}
|
||||
|
||||
function sleep(ms) {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
function toNonNegativeInteger(value, fallback = 0) {
|
||||
const parsed = Number.parseInt(value, 10);
|
||||
if (Number.isNaN(parsed) || parsed < 0) {
|
||||
return fallback;
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
|
||||
function normalizeSendStrategy(raw = {}) {
|
||||
const intervalMin = toNonNegativeInteger(raw.messageIntervalSecondsMin, 0);
|
||||
const intervalMax = Math.max(intervalMin, toNonNegativeInteger(raw.messageIntervalSecondsMax, intervalMin));
|
||||
return {
|
||||
messageIntervalSecondsMin: intervalMin,
|
||||
messageIntervalSecondsMax: intervalMax,
|
||||
};
|
||||
}
|
||||
|
||||
function randomBetweenInclusive(min, max) {
|
||||
if (max <= min) {
|
||||
return min;
|
||||
}
|
||||
return Math.floor(Math.random() * (max - min + 1)) + min;
|
||||
}
|
||||
|
||||
async function readStdinJson() {
|
||||
const chunks = [];
|
||||
for await (const chunk of process.stdin) {
|
||||
chunks.push(chunk);
|
||||
}
|
||||
const raw = Buffer.concat(chunks).toString("utf8").trim();
|
||||
if (!raw) {
|
||||
throw new Error("Missing JSON payload on stdin");
|
||||
}
|
||||
return JSON.parse(raw);
|
||||
}
|
||||
|
||||
async function ensureBundles(cacheDir) {
|
||||
await fs.promises.mkdir(cacheDir, { recursive: true });
|
||||
for (const url of SDK_BUNDLES) {
|
||||
const filename = url.split("/").at(-1);
|
||||
const filePath = path.join(cacheDir, filename);
|
||||
if (fs.existsSync(filePath)) {
|
||||
continue;
|
||||
}
|
||||
const response = await fetch(url, { headers: { "User-Agent": USER_AGENT } });
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to download SDK bundle ${url}: ${response.status}`);
|
||||
}
|
||||
const text = await response.text();
|
||||
await fs.promises.writeFile(filePath, text, "utf8");
|
||||
}
|
||||
}
|
||||
|
||||
function createWebpackRequire(bundleDir, cookieString) {
|
||||
const modules = {};
|
||||
const cache = {};
|
||||
|
||||
function requireModule(id) {
|
||||
if (cache[id]) {
|
||||
return cache[id].exports;
|
||||
}
|
||||
if (!modules[id]) {
|
||||
throw new Error(`Missing webpack module ${id}`);
|
||||
}
|
||||
const module = { exports: {} };
|
||||
cache[id] = module;
|
||||
modules[id].call(module.exports, module, module.exports, requireModule);
|
||||
return module.exports;
|
||||
}
|
||||
|
||||
requireModule.d = (exports, definition) => {
|
||||
for (const key of Object.keys(definition)) {
|
||||
if (!Object.prototype.hasOwnProperty.call(exports, key)) {
|
||||
Object.defineProperty(exports, key, {
|
||||
enumerable: true,
|
||||
get: definition[key],
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
requireModule.o = (obj, prop) => Object.prototype.hasOwnProperty.call(obj, prop);
|
||||
requireModule.r = (exports) => {
|
||||
if (typeof Symbol !== "undefined" && Symbol.toStringTag) {
|
||||
Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
|
||||
}
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
};
|
||||
requireModule.n = (mod) => {
|
||||
const getter = mod && mod.__esModule ? () => mod.default : () => mod;
|
||||
requireModule.d(getter, { a: getter });
|
||||
return getter;
|
||||
};
|
||||
requireModule.g = globalThis;
|
||||
requireModule.hmd = (module) => module;
|
||||
requireModule.nmd = (module) => module;
|
||||
|
||||
const chunkArray = [];
|
||||
chunkArray.push = (chunk) => Object.assign(modules, chunk[1]);
|
||||
|
||||
const fakeElement = () => ({
|
||||
style: {},
|
||||
setAttribute: noop,
|
||||
appendChild: noop,
|
||||
removeChild: noop,
|
||||
addEventListener: noop,
|
||||
removeEventListener: noop,
|
||||
getContext: () => ({}),
|
||||
});
|
||||
const documentRef = {
|
||||
cookie: cookieString,
|
||||
referrer: CREATOR_CHAT_URL,
|
||||
createElement: fakeElement,
|
||||
getElementsByTagName: () => [],
|
||||
querySelector: () => null,
|
||||
querySelectorAll: () => [],
|
||||
addEventListener: noop,
|
||||
removeEventListener: noop,
|
||||
body: { appendChild: noop, removeChild: noop },
|
||||
head: { appendChild: noop, removeChild: noop },
|
||||
documentElement: { style: {} },
|
||||
};
|
||||
function XMLHttpRequestStub() {
|
||||
this.open = noop;
|
||||
this.setRequestHeader = noop;
|
||||
this.send = noop;
|
||||
}
|
||||
function WebSocketStub() {
|
||||
this.readyState = 1;
|
||||
this.send = noop;
|
||||
this.close = noop;
|
||||
}
|
||||
|
||||
const context = {
|
||||
self: { webpackChunkdouyin_creator_data: chunkArray },
|
||||
window: {},
|
||||
globalThis: null,
|
||||
console,
|
||||
setTimeout,
|
||||
clearTimeout,
|
||||
setInterval,
|
||||
clearInterval,
|
||||
Buffer,
|
||||
TextDecoder,
|
||||
TextEncoder,
|
||||
Blob,
|
||||
document: documentRef,
|
||||
navigator: {
|
||||
userAgent: USER_AGENT,
|
||||
language: "en-US",
|
||||
cookieEnabled: true,
|
||||
onLine: true,
|
||||
platform: "Linux x86_64",
|
||||
sendBeacon: undefined,
|
||||
appName: "Netscape",
|
||||
},
|
||||
location: {
|
||||
href: CREATOR_CHAT_URL,
|
||||
protocol: "https:",
|
||||
search: "",
|
||||
pathname: "/creator-micro/data/following/chat",
|
||||
hostname: "creator.douyin.com",
|
||||
},
|
||||
localStorage: { getItem: () => null, setItem: noop, removeItem: noop },
|
||||
sessionStorage: { getItem: () => null, setItem: noop, removeItem: noop },
|
||||
performance: { now: () => Date.now() },
|
||||
fetch,
|
||||
XMLHttpRequest: XMLHttpRequestStub,
|
||||
WebSocket: WebSocketStub,
|
||||
URL,
|
||||
URLSearchParams,
|
||||
atob: (value) => Buffer.from(value, "base64").toString("binary"),
|
||||
btoa: (value) => Buffer.from(value, "binary").toString("base64"),
|
||||
crypto,
|
||||
};
|
||||
context.window = context;
|
||||
context.globalThis = context;
|
||||
|
||||
for (const entry of fs.readdirSync(bundleDir).filter((name) => name.endsWith(".js")).sort()) {
|
||||
const code = fs.readFileSync(path.join(bundleDir, entry), "utf8");
|
||||
try {
|
||||
vm.runInNewContext(code, context, { filename: entry });
|
||||
} catch {
|
||||
// Some bundles execute browser-only entrypoints after registering modules.
|
||||
}
|
||||
}
|
||||
|
||||
return requireModule;
|
||||
}
|
||||
|
||||
class ProtocolError extends Error {
|
||||
constructor(message, details = {}) {
|
||||
super(message);
|
||||
this.name = "ProtocolError";
|
||||
this.details = details;
|
||||
}
|
||||
}
|
||||
|
||||
function extractCookieMap(cookies) {
|
||||
const items = {};
|
||||
for (const item of cookies || []) {
|
||||
if (item?.name) {
|
||||
items[item.name] = item.value ?? "";
|
||||
}
|
||||
}
|
||||
return items;
|
||||
}
|
||||
|
||||
function buildCreatorHeaders(cookieString, cookieMap, referer = CREATOR_CHAT_URL) {
|
||||
return {
|
||||
"User-Agent": USER_AGENT,
|
||||
Referer: referer,
|
||||
Origin: "https://creator.douyin.com",
|
||||
Accept: "application/json, text/javascript",
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
Cookie: cookieString,
|
||||
"x-tt-passport-csrf-token":
|
||||
cookieMap.passport_csrf_token || cookieMap.passport_csrf_token_default || "",
|
||||
};
|
||||
}
|
||||
|
||||
function buildImHeaders(cookieString) {
|
||||
return {
|
||||
"User-Agent": USER_AGENT,
|
||||
Referer: CREATOR_CHAT_URL,
|
||||
Origin: "https://creator.douyin.com",
|
||||
Cookie: cookieString,
|
||||
};
|
||||
}
|
||||
|
||||
async function fetchJson(url, options = {}) {
|
||||
const timeoutMs = options.timeoutMs || 15000;
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
||||
const response = await fetch(url, {
|
||||
...options,
|
||||
signal: controller.signal,
|
||||
});
|
||||
const text = await response.text();
|
||||
clearTimeout(timer);
|
||||
let data = null;
|
||||
try {
|
||||
data = text ? JSON.parse(text) : null;
|
||||
} catch {
|
||||
data = null;
|
||||
}
|
||||
return { response, text, data };
|
||||
}
|
||||
|
||||
async function fetchSessionIdentity(cookieString, cookieMap) {
|
||||
const headers = buildCreatorHeaders(cookieString, cookieMap);
|
||||
const params = new URLSearchParams({
|
||||
aid: "2906",
|
||||
app_name: "aweme_creator_platform",
|
||||
device_platform: "web",
|
||||
referer: "",
|
||||
user_agent: USER_AGENT,
|
||||
cookie_enabled: "true",
|
||||
screen_width: "1280",
|
||||
screen_height: "720",
|
||||
browser_language: "en-US@posix",
|
||||
browser_platform: "Linux x86_64",
|
||||
browser_name: "Mozilla",
|
||||
browser_version: USER_AGENT,
|
||||
browser_online: "true",
|
||||
timezone_name: "Asia/Shanghai",
|
||||
});
|
||||
const { response, data, text } = await fetchJson(
|
||||
`https://creator.douyin.com/aweme/v1/creator/im/user_token/?${params.toString()}`,
|
||||
{ headers },
|
||||
);
|
||||
if (!response.ok || data?.status_code !== 0 || !data?.user_id) {
|
||||
throw new ProtocolError("Failed to resolve creator IM session identity", {
|
||||
status: response.status,
|
||||
body: text,
|
||||
});
|
||||
}
|
||||
return {
|
||||
userId: String(data.user_id),
|
||||
sessionToken: String(data.token || ""),
|
||||
};
|
||||
}
|
||||
|
||||
async function fetchIdentitySecurityToken(cookieString, cookieMap) {
|
||||
const headers = buildCreatorHeaders(cookieString, cookieMap);
|
||||
const params = new URLSearchParams({
|
||||
scene: "im_send_msg",
|
||||
auto_retry_req: "0",
|
||||
skip_verify: "0",
|
||||
identity_token_force_get_tag: "0",
|
||||
passport_jssdk_version: "5.1.4",
|
||||
passport_jssdk_type: "lite",
|
||||
is_from_ttaccountsdk: "1",
|
||||
aid: "2906",
|
||||
language: "zh",
|
||||
account_app_language: "en-US",
|
||||
id_token_version: "2.1.5",
|
||||
});
|
||||
const { response, data, text } = await fetchJson(
|
||||
`https://creator.douyin.com/passport/safe/get_identity_security_token/?${params.toString()}`,
|
||||
{ headers },
|
||||
);
|
||||
if (!response.ok || data?.message !== "success" || !data?.data?.identity_security_token) {
|
||||
throw new ProtocolError("Failed to resolve identity security token", {
|
||||
status: response.status,
|
||||
body: text,
|
||||
});
|
||||
}
|
||||
return {
|
||||
identitySecurityHeader: JSON.stringify({ token: data.data.identity_security_token }),
|
||||
realDeviceId: String(data.data.device_id || ""),
|
||||
};
|
||||
}
|
||||
|
||||
async function fetchProfileNickname(cookieString, secUid) {
|
||||
const url =
|
||||
"https://www.douyin.com/aweme/v1/web/user/profile/other/?" +
|
||||
new URLSearchParams({ sec_user_id: secUid }).toString();
|
||||
const { response, data, text } = await fetchJson(url, {
|
||||
headers: {
|
||||
"User-Agent": USER_AGENT,
|
||||
Referer: `https://www.douyin.com/user/${secUid}`,
|
||||
Cookie: cookieString,
|
||||
Accept: "application/json, text/javascript",
|
||||
},
|
||||
});
|
||||
if (!response.ok || data?.status_code !== 0) {
|
||||
return "";
|
||||
}
|
||||
return normalizeNickname(data?.user?.nickname);
|
||||
}
|
||||
|
||||
function stringifyMaybeLong(value) {
|
||||
if (value === null || value === undefined) {
|
||||
return "";
|
||||
}
|
||||
if (typeof value === "string" || typeof value === "number" || typeof value === "bigint") {
|
||||
return String(value);
|
||||
}
|
||||
if (typeof value.toString === "function" && value.toString !== Object.prototype.toString) {
|
||||
return value.toString();
|
||||
}
|
||||
return String(value);
|
||||
}
|
||||
|
||||
function selectPeerParticipant(conversation, selfUserId) {
|
||||
const participants = conversation?.firstPageParticipant?.participants || [];
|
||||
for (const participant of participants) {
|
||||
const currentUserId = stringifyMaybeLong(participant?.user_id);
|
||||
if (currentUserId && currentUserId !== selfUserId) {
|
||||
return participant;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
async function createProtocolClient({ bundleDir, cookieString, cookieMap, userId }) {
|
||||
const requireModule = createWebpackRequire(bundleDir, cookieString);
|
||||
const sdk = requireModule(61724);
|
||||
const { BytedIM } = requireModule(26440);
|
||||
|
||||
class AdditionalParamsPlugin extends sdk.BasePlugin {
|
||||
install() {}
|
||||
|
||||
async sendPacket(packet) {
|
||||
packet.device_id = 0;
|
||||
packet.device_platform = "douyin_creator";
|
||||
packet.headers = {
|
||||
...(packet.headers || {}),
|
||||
aid_new: 2906,
|
||||
app_name: "douyin_creator",
|
||||
};
|
||||
return packet;
|
||||
}
|
||||
}
|
||||
|
||||
class NodeHttpClient extends sdk.IMHttpClient {
|
||||
async send(url, method, body) {
|
||||
const fullUrl = /^https?:/i.test(url)
|
||||
? url
|
||||
: `${String(this.option.apiUrl).replace(/\/$/, "")}/${String(url).replace(/^\//, "")}`;
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort(), 20000);
|
||||
const response = await fetch(fullUrl, {
|
||||
method,
|
||||
headers: this.headers,
|
||||
body: body ? Buffer.from(body) : undefined,
|
||||
signal: controller.signal,
|
||||
});
|
||||
clearTimeout(timer);
|
||||
return response.arrayBuffer();
|
||||
}
|
||||
|
||||
sendByBeacon() {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
const client = new BytedIM(
|
||||
{
|
||||
appId: 2906,
|
||||
fpId: 9,
|
||||
appKey: "e1bd35ec9db7b8d846de66ed140b1ad9",
|
||||
service: 5,
|
||||
apiUrl: "https://imapi.douyin.com",
|
||||
frontierUrl: "wss://frontier-im.douyin.com/ws/v2",
|
||||
inboxType: 1,
|
||||
token: "",
|
||||
userId,
|
||||
deviceId: userId,
|
||||
authType: sdk.im_proto.AuthType.SESSION_AUTH,
|
||||
devicePlatform: "douyin_pc",
|
||||
timeout: 20000,
|
||||
acceptIncorrectInboxType: true,
|
||||
biz: "douyin_creator",
|
||||
withCredentials: false,
|
||||
httpHeaders: buildImHeaders(cookieString),
|
||||
headers: {},
|
||||
webSocketLevel: sdk.WebSocketLevel.PushOnly,
|
||||
debug: false,
|
||||
http: (ctx) => new NodeHttpClient(ctx),
|
||||
},
|
||||
[AdditionalParamsPlugin],
|
||||
);
|
||||
|
||||
const initResult = await client.init();
|
||||
if (initResult !== sdk.InitResult.Succeeded) {
|
||||
throw new ProtocolError("Protocol IM init did not succeed", { initResult });
|
||||
}
|
||||
|
||||
return { client };
|
||||
}
|
||||
|
||||
async function buildConversationCache({
|
||||
client,
|
||||
selfUserId,
|
||||
cookieString,
|
||||
existingCache = [],
|
||||
targetNames = [],
|
||||
}) {
|
||||
const cachedBySecUid = new Map(
|
||||
(existingCache || []).filter((entry) => entry?.secUid).map((entry) => [entry.secUid, entry]),
|
||||
);
|
||||
const wantedTargets = new Set((targetNames || []).map(normalizeNickname).filter(Boolean));
|
||||
const matchedTargets = new Set();
|
||||
const conversations = await client.getConversationListOnline();
|
||||
const cacheEntries = [];
|
||||
|
||||
for (const conversation of conversations) {
|
||||
if (conversation?.type !== 1) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const peer = selectPeerParticipant(conversation, selfUserId);
|
||||
if (!peer) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const peerUserId = stringifyMaybeLong(peer.user_id);
|
||||
const secUid = peer.sec_uid || "";
|
||||
if (!peerUserId || !secUid) {
|
||||
continue;
|
||||
}
|
||||
|
||||
let nickname = normalizeNickname(cachedBySecUid.get(secUid)?.nickname);
|
||||
if (!nickname) {
|
||||
try {
|
||||
nickname = await fetchProfileNickname(cookieString, secUid);
|
||||
} catch {
|
||||
nickname = "";
|
||||
}
|
||||
}
|
||||
|
||||
cacheEntries.push({
|
||||
nickname,
|
||||
peerUserId,
|
||||
secUid,
|
||||
conversationId: conversation.id,
|
||||
conversationShortId: conversation.shortId,
|
||||
updatedAt: stableNow(),
|
||||
});
|
||||
|
||||
if (nickname && wantedTargets.has(nickname)) {
|
||||
matchedTargets.add(nickname);
|
||||
if (matchedTargets.size === wantedTargets.size) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const deduped = new Map();
|
||||
for (const entry of existingCache || []) {
|
||||
if (!entry?.nickname || !entry?.secUid) {
|
||||
continue;
|
||||
}
|
||||
deduped.set(entry.secUid, entry);
|
||||
}
|
||||
for (const entry of cacheEntries) {
|
||||
if (!entry.nickname) {
|
||||
continue;
|
||||
}
|
||||
deduped.set(entry.secUid, entry);
|
||||
}
|
||||
return Array.from(deduped.values()).sort((left, right) =>
|
||||
left.nickname.localeCompare(right.nickname, "zh-CN"),
|
||||
);
|
||||
}
|
||||
|
||||
function buildTargetLookup(cacheEntries) {
|
||||
const byNickname = new Map();
|
||||
for (const entry of cacheEntries) {
|
||||
const key = normalizeNickname(entry.nickname);
|
||||
if (key && !byNickname.has(key)) {
|
||||
byNickname.set(key, entry);
|
||||
}
|
||||
}
|
||||
return byNickname;
|
||||
}
|
||||
|
||||
async function sendMessages({
|
||||
client,
|
||||
cacheEntries,
|
||||
messagesByTarget,
|
||||
dryRun,
|
||||
cookieString,
|
||||
cookieMap,
|
||||
sendStrategy,
|
||||
}) {
|
||||
if (!dryRun) {
|
||||
const identity = await fetchIdentitySecurityToken(cookieString, cookieMap);
|
||||
client.updateSendMessageHeaders({
|
||||
identity_security_token: identity.identitySecurityHeader,
|
||||
identity_security_device_id: identity.realDeviceId,
|
||||
identity_security_aid: "2906",
|
||||
});
|
||||
}
|
||||
|
||||
const byNickname = buildTargetLookup(cacheEntries);
|
||||
const resolved = [];
|
||||
const unresolved = [];
|
||||
const sent = [];
|
||||
const normalizedStrategy = normalizeSendStrategy(sendStrategy);
|
||||
|
||||
for (const [target, message] of Object.entries(messagesByTarget)) {
|
||||
const mapping = byNickname.get(normalizeNickname(target));
|
||||
if (!mapping) {
|
||||
unresolved.push({ target, reason: "conversation_not_found" });
|
||||
continue;
|
||||
}
|
||||
|
||||
const conversation = client.getConversation({ conversationId: mapping.conversationId });
|
||||
if (!conversation) {
|
||||
unresolved.push({ target, reason: "conversation_not_loaded", mapping });
|
||||
continue;
|
||||
}
|
||||
|
||||
resolved.push({
|
||||
target,
|
||||
nickname: mapping.nickname,
|
||||
peerUserId: mapping.peerUserId,
|
||||
conversationId: mapping.conversationId,
|
||||
conversationShortId: mapping.conversationShortId,
|
||||
});
|
||||
|
||||
let delayBeforeSendSeconds = 0;
|
||||
if (!dryRun && sent.length > 0 && normalizedStrategy.messageIntervalSecondsMax > 0) {
|
||||
delayBeforeSendSeconds = randomBetweenInclusive(
|
||||
normalizedStrategy.messageIntervalSecondsMin,
|
||||
normalizedStrategy.messageIntervalSecondsMax,
|
||||
);
|
||||
if (delayBeforeSendSeconds > 0) {
|
||||
await sleep(delayBeforeSendSeconds * 1000);
|
||||
}
|
||||
}
|
||||
|
||||
const payload = JSON.stringify({ text: message, aweType: 774 });
|
||||
const messageObject = await client.createMessage({
|
||||
type: 7,
|
||||
content: payload,
|
||||
conversation,
|
||||
insert: false,
|
||||
});
|
||||
|
||||
if (dryRun) {
|
||||
sent.push({
|
||||
target,
|
||||
dryRun: true,
|
||||
message,
|
||||
payload,
|
||||
conversationId: mapping.conversationId,
|
||||
delayBeforeSendSeconds,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
const sendResult = await client.sendMessage({ message: messageObject });
|
||||
sent.push({
|
||||
target,
|
||||
dryRun: false,
|
||||
message,
|
||||
success: Boolean(sendResult?.success),
|
||||
statusCode: sendResult?.statusCode ?? null,
|
||||
statusMsg: sendResult?.statusMsg ?? "",
|
||||
conversationId: mapping.conversationId,
|
||||
delayBeforeSendSeconds,
|
||||
sentAt: stableNow(),
|
||||
});
|
||||
}
|
||||
|
||||
return { resolved, unresolved, sent };
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const payload = await readStdinJson();
|
||||
const repoRoot = payload.repoRoot || process.cwd();
|
||||
const bundleDir = path.join(repoRoot, ".im_sdk_cache");
|
||||
await ensureBundles(bundleDir);
|
||||
|
||||
const account = payload.account || {};
|
||||
const cookieString = toCookieString(account.cookies);
|
||||
const cookieMap = extractCookieMap(account.cookies);
|
||||
const { userId } = await fetchSessionIdentity(cookieString, cookieMap);
|
||||
const { client } = await createProtocolClient({
|
||||
bundleDir,
|
||||
cookieString,
|
||||
cookieMap,
|
||||
userId,
|
||||
});
|
||||
|
||||
const cacheEntries = await buildConversationCache({
|
||||
client,
|
||||
selfUserId: userId,
|
||||
cookieString,
|
||||
existingCache: account.protocol_targets_cache || [],
|
||||
targetNames: Object.keys(payload.messagesByTarget || {}),
|
||||
});
|
||||
const execution = await sendMessages({
|
||||
client,
|
||||
cacheEntries,
|
||||
messagesByTarget: payload.messagesByTarget || {},
|
||||
dryRun: Boolean(payload.dryRun),
|
||||
cookieString,
|
||||
cookieMap,
|
||||
sendStrategy: payload.sendStrategy || {},
|
||||
});
|
||||
|
||||
try {
|
||||
console.log(
|
||||
JSON.stringify(
|
||||
{
|
||||
ok: true,
|
||||
username: account.username || "",
|
||||
userId,
|
||||
dryRun: Boolean(payload.dryRun),
|
||||
protocol_targets_cache: cacheEntries,
|
||||
...execution,
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
);
|
||||
} finally {
|
||||
await client.dispose();
|
||||
}
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
console.log(
|
||||
JSON.stringify(
|
||||
{
|
||||
ok: false,
|
||||
error: error?.message || String(error),
|
||||
details: error?.details || {},
|
||||
stack: error?.stack || "",
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
);
|
||||
process.exit(1);
|
||||
});
|
||||
556
DouYinSparkFlow/core/tasks.py
Normal file
@@ -0,0 +1,556 @@
|
||||
import asyncio
|
||||
import hashlib
|
||||
import logging
|
||||
import os
|
||||
from contextlib import contextmanager
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from pathlib import Path
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
from core.browser import get_browser
|
||||
from core.msg_builder import build_message
|
||||
from core.protocol_dispatch import run_protocol_tasks
|
||||
from utils.config import get_config, get_userData, normalize_unique_id, save_userData
|
||||
from utils.logger import setup_logger
|
||||
|
||||
|
||||
config = get_config()
|
||||
user_data = get_userData()
|
||||
logger = setup_logger(level=logging.DEBUG)
|
||||
debug_artifacts_dir = Path("logs/debug_artifacts")
|
||||
debug_artifacts_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
|
||||
async def retry_operation(name, operation, retries=3, delay=2, *args, **kwargs):
|
||||
for attempt in range(retries):
|
||||
try:
|
||||
return await operation(*args, **kwargs)
|
||||
except Exception as exc:
|
||||
if attempt < retries - 1:
|
||||
logger.warning("%s failed, retry %s/%s: %s", name, attempt + 1, retries, exc)
|
||||
await asyncio.sleep(delay)
|
||||
else:
|
||||
logger.error("%s failed after %s attempts: %s", name, retries, exc)
|
||||
raise
|
||||
|
||||
|
||||
def _safe_name(value):
|
||||
return "".join(ch if ch.isalnum() or ch in ("-", "_") else "_" for ch in value)[:80]
|
||||
|
||||
|
||||
async def save_debug_artifacts(page, account_name, target_name, stage):
|
||||
if not get_config(force_reload=True).get("saveDebugArtifacts", False):
|
||||
return
|
||||
|
||||
timestamp = datetime.now().strftime("%Y%m%d-%H%M%S")
|
||||
stem = f"{timestamp}-{_safe_name(account_name)}-{_safe_name(target_name)}-{stage}"
|
||||
screenshot_path = debug_artifacts_dir / f"{stem}.png"
|
||||
html_path = debug_artifacts_dir / f"{stem}.html"
|
||||
|
||||
await page.screenshot(path=str(screenshot_path), full_page=True)
|
||||
html_path.write_text(await page.content(), encoding="utf-8")
|
||||
logger.info("Saved debug artifacts at stage=%s for %s/%s", stage, account_name, target_name)
|
||||
|
||||
|
||||
async def locate_chat_input(page):
|
||||
selectors = [
|
||||
"xpath=//div[contains(@class, 'chat-input-dccKiL')]//div[@contenteditable='true']",
|
||||
"xpath=//div[@contenteditable='true' and @role='textbox']",
|
||||
"xpath=(//div[@contenteditable='true'])[last()]",
|
||||
]
|
||||
|
||||
last_error = None
|
||||
for selector in selectors:
|
||||
locator = page.locator(selector).first
|
||||
try:
|
||||
await locator.wait_for(state="visible", timeout=10000)
|
||||
await locator.click(timeout=5000)
|
||||
return locator, selector
|
||||
except Exception as exc:
|
||||
last_error = exc
|
||||
|
||||
raise RuntimeError(f"Unable to locate chat input, last error: {last_error}")
|
||||
|
||||
|
||||
async def read_chat_input_text(chat_input):
|
||||
try:
|
||||
return await chat_input.evaluate(
|
||||
"""(node) => {
|
||||
const raw = node.innerText ?? node.textContent ?? "";
|
||||
return raw.trim();
|
||||
}"""
|
||||
)
|
||||
except Exception:
|
||||
return ""
|
||||
|
||||
|
||||
async def confirm_message_sent(page, chat_input, message):
|
||||
await asyncio.sleep(2)
|
||||
|
||||
input_text = await read_chat_input_text(chat_input)
|
||||
if not input_text:
|
||||
return True, "chat input cleared"
|
||||
|
||||
first_line = message.split("\n")[0].strip()
|
||||
if first_line:
|
||||
try:
|
||||
bubble = page.locator(f"text={first_line}").last
|
||||
if await bubble.count() > 0:
|
||||
return True, "message bubble located"
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return False, f"chat input still contains: {input_text!r}"
|
||||
|
||||
|
||||
async def scroll_and_select_user(page, account_name, targets):
|
||||
friends_tab_selector = 'xpath=//*[@id="sub-app"]/div/div/div[1]/div[2]'
|
||||
target_selector = (
|
||||
'xpath=//*[@id="sub-app"]/div/div[1]/div[2]/div[2]'
|
||||
'//div[contains(@class, "semi-list-item-body semi-list-item-body-flex-start")]'
|
||||
)
|
||||
scrollable_friends_selector = (
|
||||
'xpath=//*[@id="sub-app"]/div/div[1]/div[2]/div[2]/div/div/div[3]/div/div/div/ul/div'
|
||||
)
|
||||
no_more_selector = 'xpath=//div[contains(@class, "no-more-tip-ftdJnu")]'
|
||||
loading_selector = 'xpath=//div[contains(@class, "semi-spin")]'
|
||||
first_friend_selector = (
|
||||
'xpath=//*[@id="sub-app"]/div/div/div[2]/div[2]/div/div/div[1]/div/div/div/ul/div/div/div[1]/li/div'
|
||||
)
|
||||
|
||||
logger.debug("Account %s is opening the friends tab", account_name)
|
||||
await page.wait_for_selector(friends_tab_selector)
|
||||
await page.locator(friends_tab_selector).click()
|
||||
|
||||
await page.wait_for_selector(first_friend_selector)
|
||||
await page.locator(first_friend_selector).click()
|
||||
await asyncio.sleep(2)
|
||||
|
||||
found_usernames = set()
|
||||
remaining_targets = set(targets)
|
||||
|
||||
while True:
|
||||
target_elements = await page.locator(target_selector).all()
|
||||
|
||||
for element in target_elements:
|
||||
try:
|
||||
span = element.locator("""xpath=.//span[contains(@class, "item-header-name-")]""")
|
||||
target_name = await span.inner_text()
|
||||
except Exception:
|
||||
continue
|
||||
|
||||
if target_name in found_usernames:
|
||||
continue
|
||||
found_usernames.add(target_name)
|
||||
logger.debug("Account %s found friend entry %s", account_name, target_name)
|
||||
|
||||
if target_name in targets:
|
||||
await element.click()
|
||||
logger.info("Account %s selected target friend %s", account_name, target_name)
|
||||
yield target_name
|
||||
|
||||
remaining_targets.discard(target_name)
|
||||
if not remaining_targets:
|
||||
logger.info("Account %s found all target friends", account_name)
|
||||
return
|
||||
break
|
||||
else:
|
||||
if await page.locator(no_more_selector).count() > 0:
|
||||
logger.warning("Account %s reached the end of the friend list. Missing targets: %s", account_name, sorted(remaining_targets))
|
||||
return
|
||||
|
||||
if await page.locator(loading_selector).count() > 0:
|
||||
logger.debug("Account %s is waiting for more friends to load", account_name)
|
||||
await asyncio.sleep(1.5)
|
||||
|
||||
scrollable_element = await page.locator(scrollable_friends_selector).element_handle()
|
||||
if not scrollable_element:
|
||||
raise RuntimeError(f"Account {account_name} could not find the friend list scroll container")
|
||||
|
||||
await page.evaluate("(element) => element.scrollTop += 800", scrollable_element)
|
||||
await asyncio.sleep(1.5)
|
||||
|
||||
|
||||
def _is_manual_run():
|
||||
return os.getenv("SPARKFLOW_MANUAL_RUN") == "1"
|
||||
|
||||
|
||||
def _schedule_timezone():
|
||||
timezone_name = (
|
||||
str(os.getenv("SPARKFLOW_TIMEZONE") or "").strip()
|
||||
or str(os.getenv("TZ") or "").strip()
|
||||
or "Asia/Shanghai"
|
||||
)
|
||||
try:
|
||||
return ZoneInfo(timezone_name)
|
||||
except Exception:
|
||||
if timezone_name == "Asia/Shanghai":
|
||||
logger.warning("Falling back to fixed UTC+8 because %r is unavailable", timezone_name)
|
||||
return timezone(timedelta(hours=8), name="Asia/Shanghai")
|
||||
logger.warning("Falling back to system timezone because %r is unavailable", timezone_name)
|
||||
return datetime.now().astimezone().tzinfo
|
||||
|
||||
|
||||
def _normalize_send_window(config):
|
||||
raw = config.get("dailySendWindow", {}) or {}
|
||||
normalized = {
|
||||
"enabled": bool(raw.get("enabled", False)),
|
||||
"startHour": int(raw.get("startHour", 10)),
|
||||
"endHour": int(raw.get("endHour", 18)),
|
||||
"scheduleIntervalMinutes": max(1, int(raw.get("scheduleIntervalMinutes", 10))),
|
||||
}
|
||||
if normalized["startHour"] < 0 or normalized["startHour"] > 23:
|
||||
normalized["enabled"] = False
|
||||
if normalized["endHour"] < 1 or normalized["endHour"] > 24:
|
||||
normalized["enabled"] = False
|
||||
if normalized["endHour"] <= normalized["startHour"]:
|
||||
normalized["enabled"] = False
|
||||
if bool(raw.get("enabled", False)) and not normalized["enabled"]:
|
||||
logger.warning("Invalid dailySendWindow=%s, disabling windowed sending for this run", raw)
|
||||
return normalized
|
||||
|
||||
|
||||
def _account_identity(user):
|
||||
return str(user.get("unique_id") or user.get("username") or "unknown").strip()
|
||||
|
||||
|
||||
def _parse_sent_at(raw_value, local_tz):
|
||||
if not raw_value:
|
||||
return None
|
||||
raw = str(raw_value).strip()
|
||||
if raw.endswith("Z"):
|
||||
raw = raw[:-1] + "+00:00"
|
||||
try:
|
||||
parsed = datetime.fromisoformat(raw)
|
||||
except ValueError:
|
||||
return None
|
||||
if parsed.tzinfo is None:
|
||||
return parsed.replace(tzinfo=local_tz)
|
||||
return parsed.astimezone(local_tz)
|
||||
|
||||
|
||||
def _target_sent_today(user, target_name, now):
|
||||
history = dict(user.get("message_history") or {})
|
||||
entry = history.get(target_name) or {}
|
||||
sent_at = _parse_sent_at(entry.get("sentAt"), now.tzinfo)
|
||||
return bool(sent_at and sent_at.date() == now.date())
|
||||
|
||||
|
||||
def _scheduled_send_time(user, target_name, send_window, now):
|
||||
window_minutes = (send_window["endHour"] - send_window["startHour"]) * 60
|
||||
start_of_window = now.replace(
|
||||
hour=send_window["startHour"],
|
||||
minute=0,
|
||||
second=0,
|
||||
microsecond=0,
|
||||
)
|
||||
seed = f"{now.date().isoformat()}|{_account_identity(user)}|{target_name}"
|
||||
digest = hashlib.sha256(seed.encode("utf-8")).digest()
|
||||
offset_minutes = int.from_bytes(digest[:8], "big") % window_minutes
|
||||
return start_of_window + timedelta(minutes=offset_minutes)
|
||||
|
||||
|
||||
def _select_due_targets(user, send_window, now):
|
||||
targets = list(user.get("targets") or [])
|
||||
if not send_window.get("enabled") or _is_manual_run():
|
||||
return targets, [], []
|
||||
|
||||
window_start = now.replace(
|
||||
hour=send_window["startHour"],
|
||||
minute=0,
|
||||
second=0,
|
||||
microsecond=0,
|
||||
)
|
||||
window_end = now.replace(
|
||||
hour=send_window["endHour"],
|
||||
minute=0,
|
||||
second=0,
|
||||
microsecond=0,
|
||||
)
|
||||
if now < window_start or now > window_end:
|
||||
return [], [], [(target, _scheduled_send_time(user, target, send_window, now)) for target in targets]
|
||||
|
||||
due_targets = []
|
||||
already_sent = []
|
||||
pending_targets = []
|
||||
for target_name in targets:
|
||||
if _target_sent_today(user, target_name, now):
|
||||
already_sent.append(target_name)
|
||||
continue
|
||||
scheduled_at = _scheduled_send_time(user, target_name, send_window, now)
|
||||
if now >= scheduled_at:
|
||||
due_targets.append(target_name)
|
||||
else:
|
||||
pending_targets.append((target_name, scheduled_at))
|
||||
return due_targets, already_sent, pending_targets
|
||||
|
||||
|
||||
def _prepare_active_users_for_run(active_config, active_user_data):
|
||||
if _is_manual_run():
|
||||
logger.info("SPARKFLOW_MANUAL_RUN=1, bypassing daily send window")
|
||||
return [dict(user, targets=list(user.get("targets") or [])) for user in active_user_data]
|
||||
|
||||
send_window = _normalize_send_window(active_config)
|
||||
if not send_window.get("enabled"):
|
||||
return [dict(user, targets=list(user.get("targets") or [])) for user in active_user_data]
|
||||
|
||||
schedule_tz = _schedule_timezone()
|
||||
now = datetime.now(schedule_tz)
|
||||
logger.info(
|
||||
"dailySendWindow enabled startHour=%s endHour=%s intervalMinutes=%s timezone=%s now=%s",
|
||||
send_window["startHour"],
|
||||
send_window["endHour"],
|
||||
send_window["scheduleIntervalMinutes"],
|
||||
getattr(schedule_tz, "key", str(schedule_tz)),
|
||||
now.isoformat(timespec="seconds"),
|
||||
)
|
||||
|
||||
runnable_users = []
|
||||
for user in active_user_data:
|
||||
due_targets, already_sent, pending_targets = _select_due_targets(user, send_window, now)
|
||||
pending_preview = [
|
||||
f"{target_name}@{scheduled_at.strftime('%H:%M')}"
|
||||
for target_name, scheduled_at in pending_targets[:5]
|
||||
]
|
||||
logger.info(
|
||||
"windowed user=%s dueTargets=%s alreadySentToday=%s pendingTargets=%s",
|
||||
user.get("username", "unknown"),
|
||||
due_targets,
|
||||
already_sent,
|
||||
pending_preview,
|
||||
)
|
||||
if due_targets:
|
||||
runnable_user = dict(user)
|
||||
runnable_user["targets"] = due_targets
|
||||
runnable_users.append(runnable_user)
|
||||
|
||||
if not runnable_users:
|
||||
logger.info("No targets are due for the current windowed run")
|
||||
return runnable_users
|
||||
|
||||
|
||||
def _account_match_tokens(user):
|
||||
tokens = set()
|
||||
username = str(user.get("username") or "").strip()
|
||||
unique_id = str(user.get("unique_id") or "").strip()
|
||||
normalized_unique_id = normalize_unique_id(unique_id)
|
||||
if username:
|
||||
tokens.add(username.lower())
|
||||
if unique_id:
|
||||
tokens.add(unique_id.lower())
|
||||
if normalized_unique_id:
|
||||
tokens.add(normalized_unique_id.lower())
|
||||
return tokens
|
||||
|
||||
|
||||
def _persist_browser_send_success(user, target_name, message, sent_at):
|
||||
target_username = str(user.get("username") or "").strip()
|
||||
target_unique_id = normalize_unique_id(user.get("unique_id"))
|
||||
if not target_username and not target_unique_id:
|
||||
logger.warning("Cannot persist browser send history without account identity for target=%s", target_name)
|
||||
return
|
||||
|
||||
accounts = get_userData(force_reload=True)
|
||||
matched_account = None
|
||||
for account in accounts:
|
||||
account_username = str(account.get("username") or "").strip()
|
||||
account_unique_id = normalize_unique_id(account.get("unique_id"))
|
||||
if target_unique_id and account_unique_id == target_unique_id:
|
||||
matched_account = account
|
||||
break
|
||||
if target_username and account_username == target_username:
|
||||
matched_account = account
|
||||
break
|
||||
|
||||
if matched_account is None:
|
||||
logger.warning(
|
||||
"Could not find account to persist browser send history for user=%s target=%s",
|
||||
target_username or target_unique_id or "unknown",
|
||||
target_name,
|
||||
)
|
||||
return
|
||||
|
||||
history = dict(matched_account.get("message_history") or {})
|
||||
history[target_name] = {
|
||||
"message": message,
|
||||
"sentAt": sent_at,
|
||||
}
|
||||
matched_account["message_history"] = history
|
||||
save_userData(accounts)
|
||||
|
||||
user_history = dict(user.get("message_history") or {})
|
||||
user_history[target_name] = {
|
||||
"message": message,
|
||||
"sentAt": sent_at,
|
||||
}
|
||||
user["message_history"] = user_history
|
||||
|
||||
logger.info(
|
||||
"Persisted browser send history for %s/%s at %s",
|
||||
matched_account.get("username", "unknown"),
|
||||
target_name,
|
||||
sent_at,
|
||||
)
|
||||
|
||||
|
||||
def _split_sender_modes(active_config, runnable_user_data):
|
||||
if not active_config.get("useProtocolSender", True):
|
||||
return [], runnable_user_data
|
||||
|
||||
browser_sender_accounts = {
|
||||
str(item).strip().lower()
|
||||
for item in (active_config.get("browserSenderAccounts") or [])
|
||||
if str(item).strip()
|
||||
}
|
||||
if not browser_sender_accounts:
|
||||
return runnable_user_data, []
|
||||
|
||||
protocol_users = []
|
||||
browser_users = []
|
||||
for user in runnable_user_data:
|
||||
if _account_match_tokens(user) & browser_sender_accounts:
|
||||
browser_users.append(user)
|
||||
else:
|
||||
protocol_users.append(user)
|
||||
return protocol_users, browser_users
|
||||
|
||||
|
||||
async def run_browser_tasks(active_config, browser_user_data):
|
||||
if not browser_user_data:
|
||||
return
|
||||
|
||||
playwright, browser = await get_browser()
|
||||
try:
|
||||
semaphore = asyncio.Semaphore(active_config["taskCount"] if active_config["multiTask"] else 1)
|
||||
tasks = []
|
||||
for user in browser_user_data:
|
||||
logger.info("Using browser sender for user=%s targets=%s", user.get("username", "unknown"), user["targets"])
|
||||
tasks.append(do_user_task(browser, user, semaphore))
|
||||
|
||||
await asyncio.gather(*tasks)
|
||||
finally:
|
||||
await playwright.stop()
|
||||
await browser.close()
|
||||
|
||||
|
||||
async def do_user_task(browser, user, semaphore):
|
||||
async with semaphore:
|
||||
account_name = user.get("username", "unknown")
|
||||
cookies = user["cookies"]
|
||||
targets = user["targets"]
|
||||
context = await browser.new_context()
|
||||
context.set_default_navigation_timeout(120000)
|
||||
context.set_default_timeout(120000)
|
||||
|
||||
try:
|
||||
page = await context.new_page()
|
||||
await retry_operation(
|
||||
"open creator home",
|
||||
page.goto,
|
||||
retries=3,
|
||||
delay=5,
|
||||
url="https://creator.douyin.com/",
|
||||
)
|
||||
await context.add_cookies(cookies)
|
||||
await retry_operation(
|
||||
"open chat page",
|
||||
page.goto,
|
||||
retries=3,
|
||||
delay=5,
|
||||
url="https://creator.douyin.com/creator-micro/data/following/chat",
|
||||
)
|
||||
|
||||
logger.info("Account %s started the message flow", account_name)
|
||||
async for target_name in scroll_and_select_user(page, account_name, targets):
|
||||
try:
|
||||
await save_debug_artifacts(page, account_name, target_name, "selected-friend")
|
||||
chat_input, selector_used = await locate_chat_input(page)
|
||||
logger.info("Using chat input selector %s for %s/%s", selector_used, account_name, target_name)
|
||||
|
||||
message = build_message()
|
||||
logger.info("Prepared message for %s/%s: %r", account_name, target_name, message)
|
||||
|
||||
lines = message.split("\n")
|
||||
for index, line in enumerate(lines):
|
||||
await chat_input.type(line, delay=50)
|
||||
if index < len(lines) - 1:
|
||||
await chat_input.press("Shift+Enter")
|
||||
|
||||
await save_debug_artifacts(page, account_name, target_name, "typed-message")
|
||||
|
||||
logger.info("Pressing Enter to send message for %s/%s", account_name, target_name)
|
||||
await chat_input.press("Enter")
|
||||
|
||||
sent_ok, detail = await confirm_message_sent(page, chat_input, message)
|
||||
await save_debug_artifacts(page, account_name, target_name, "after-send")
|
||||
|
||||
if not sent_ok:
|
||||
raise RuntimeError(detail)
|
||||
|
||||
logger.info("Message send confirmed for %s/%s: %s", account_name, target_name, detail)
|
||||
_persist_browser_send_success(
|
||||
user,
|
||||
target_name,
|
||||
message,
|
||||
datetime.now(timezone.utc).isoformat(timespec="seconds"),
|
||||
)
|
||||
except Exception:
|
||||
logger.exception("Send flow failed for %s/%s", account_name, target_name)
|
||||
await save_debug_artifacts(page, account_name, target_name, "send-error")
|
||||
raise
|
||||
finally:
|
||||
await context.close()
|
||||
|
||||
|
||||
async def runTasks():
|
||||
active_config = get_config(force_reload=True)
|
||||
all_user_data = get_userData(force_reload=True)
|
||||
active_user_data = [user for user in all_user_data if user.get("enabled", True)]
|
||||
disabled_user_data = [user for user in all_user_data if not user.get("enabled", True)]
|
||||
|
||||
logger.info("Starting tasks with config")
|
||||
logger.info("multiTask=%s taskCount=%s", active_config["multiTask"], active_config["taskCount"])
|
||||
logger.info("messageTemplate=%s", active_config["messageTemplate"])
|
||||
logger.info("sendStrategy=%s", active_config.get("sendStrategy", {}))
|
||||
logger.info("hitokotoTypes=%s", active_config["hitokotoTypes"])
|
||||
logger.info("enabledUsers=%s disabledUsers=%s", len(active_user_data), len(disabled_user_data))
|
||||
for user in active_user_data:
|
||||
logger.info("user=%s targets=%s", user.get("username", "unknown"), user["targets"])
|
||||
for user in disabled_user_data:
|
||||
logger.info("skipping disabled user=%s", user.get("username", "unknown"))
|
||||
|
||||
if not active_user_data:
|
||||
logger.warning("No enabled accounts are available for the task run")
|
||||
return
|
||||
|
||||
runnable_user_data = _prepare_active_users_for_run(active_config, active_user_data)
|
||||
if not runnable_user_data:
|
||||
return
|
||||
|
||||
with task_run_lock():
|
||||
protocol_user_data, browser_user_data = _split_sender_modes(active_config, runnable_user_data)
|
||||
if protocol_user_data:
|
||||
await run_protocol_tasks(active_config, protocol_user_data, build_message)
|
||||
await run_browser_tasks(active_config, browser_user_data)
|
||||
|
||||
|
||||
@contextmanager
|
||||
def task_run_lock():
|
||||
lock_path = Path("logs/task.run.lock")
|
||||
lock_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
try:
|
||||
handle = lock_path.open("x", encoding="utf-8")
|
||||
except FileExistsError as exc:
|
||||
raise RuntimeError("another task run is already in progress") from exc
|
||||
|
||||
try:
|
||||
handle.write(f"{os.getpid()}\n")
|
||||
handle.flush()
|
||||
yield
|
||||
finally:
|
||||
handle.close()
|
||||
try:
|
||||
lock_path.unlink()
|
||||
except FileNotFoundError:
|
||||
pass
|
||||
56
DouYinSparkFlow/docker-compose.example.yml
Normal file
@@ -0,0 +1,56 @@
|
||||
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"
|
||||
BIN
DouYinSparkFlow/docs/images/image.png
Normal file
|
After Width: | Height: | Size: 19 KiB |
BIN
DouYinSparkFlow/docs/images/屏幕截图 2026-02-14 223607.png
Normal file
|
After Width: | Height: | Size: 18 KiB |
BIN
DouYinSparkFlow/docs/images/屏幕截图 2026-02-14 224614.png
Normal file
|
After Width: | Height: | Size: 121 KiB |
BIN
DouYinSparkFlow/docs/images/屏幕截图 2026-02-14 224915.png
Normal file
|
After Width: | Height: | Size: 77 KiB |
BIN
DouYinSparkFlow/docs/images/屏幕截图 2026-02-14 224951.png
Normal file
|
After Width: | Height: | Size: 144 KiB |
175
DouYinSparkFlow/login_desktop_server.py
Normal file
@@ -0,0 +1,175 @@
|
||||
import asyncio
|
||||
import os
|
||||
import shutil
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi import FastAPI, HTTPException
|
||||
import uvicorn
|
||||
from playwright.async_api import async_playwright
|
||||
|
||||
from core.login import collect_login_result
|
||||
|
||||
|
||||
REMOTE_LOGIN_URL = "https://creator.douyin.com/"
|
||||
PROFILE_DIR = Path("/data/login-profile")
|
||||
|
||||
|
||||
class LoginDesktopManager:
|
||||
def __init__(self):
|
||||
self._lock = asyncio.Lock()
|
||||
self.playwright = None
|
||||
self.context = None
|
||||
self.page = None
|
||||
|
||||
async def start(self):
|
||||
async with self._lock:
|
||||
if self.context and not self._context_is_closed():
|
||||
return
|
||||
PROFILE_DIR.mkdir(parents=True, exist_ok=True)
|
||||
if self.playwright:
|
||||
try:
|
||||
await self.playwright.stop()
|
||||
except Exception:
|
||||
pass
|
||||
self.playwright = None
|
||||
self.playwright = await async_playwright().start()
|
||||
self.context = await self.playwright.chromium.launch_persistent_context(
|
||||
str(PROFILE_DIR),
|
||||
headless=False,
|
||||
viewport={"width": 1600, "height": 1000},
|
||||
args=[
|
||||
"--disable-dev-shm-usage",
|
||||
"--no-sandbox",
|
||||
"--start-maximized",
|
||||
],
|
||||
)
|
||||
self.page = self.context.pages[0] if self.context.pages else await self.context.new_page()
|
||||
await self.page.goto(REMOTE_LOGIN_URL, wait_until="domcontentloaded", timeout=60000)
|
||||
|
||||
def _context_is_closed(self):
|
||||
return not self.context or getattr(self.context, "_impl_obj", None) is None
|
||||
|
||||
async def _get_active_page(self):
|
||||
await self.ensure_running()
|
||||
try:
|
||||
if self.page and not self.page.is_closed():
|
||||
return self.page
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
for candidate in self.context.pages:
|
||||
if not candidate.is_closed():
|
||||
self.page = candidate
|
||||
return candidate
|
||||
except Exception:
|
||||
pass
|
||||
self.page = await self.context.new_page()
|
||||
return self.page
|
||||
|
||||
async def stop(self, clear_profile=False):
|
||||
async with self._lock:
|
||||
if self.page:
|
||||
await self.page.close()
|
||||
self.page = None
|
||||
if self.context:
|
||||
await self.context.close()
|
||||
self.context = None
|
||||
if self.playwright:
|
||||
await self.playwright.stop()
|
||||
self.playwright = None
|
||||
if clear_profile and PROFILE_DIR.exists():
|
||||
shutil.rmtree(PROFILE_DIR, ignore_errors=True)
|
||||
|
||||
async def reset(self):
|
||||
await self.stop(clear_profile=True)
|
||||
await self.start()
|
||||
|
||||
async def ensure_running(self):
|
||||
if not self.context or self._context_is_closed():
|
||||
await self.start()
|
||||
|
||||
async def status(self):
|
||||
await self.ensure_running()
|
||||
logged_in = False
|
||||
username = ""
|
||||
unique_id = ""
|
||||
page = await self._get_active_page()
|
||||
current_url = page.url if page else ""
|
||||
try:
|
||||
result = await collect_login_result(page, self.context, timeout_ms=1000)
|
||||
logged_in = True
|
||||
username = result["username"]
|
||||
unique_id = result["unique_id"]
|
||||
except Exception:
|
||||
pass
|
||||
return {
|
||||
"running": True,
|
||||
"logged_in": logged_in,
|
||||
"username": username,
|
||||
"unique_id": unique_id,
|
||||
"current_url": current_url,
|
||||
"profile_dir": str(PROFILE_DIR),
|
||||
}
|
||||
|
||||
async def open_login(self):
|
||||
try:
|
||||
page = await self._get_active_page()
|
||||
await page.goto(REMOTE_LOGIN_URL, wait_until="domcontentloaded", timeout=60000)
|
||||
except Exception:
|
||||
await self.reset()
|
||||
page = await self._get_active_page()
|
||||
await page.goto(REMOTE_LOGIN_URL, wait_until="domcontentloaded", timeout=60000)
|
||||
|
||||
async def export(self):
|
||||
page = await self._get_active_page()
|
||||
result = await collect_login_result(page, self.context, timeout_ms=5000)
|
||||
return result
|
||||
|
||||
|
||||
manager = LoginDesktopManager()
|
||||
app = FastAPI(title="Douyin Login Desktop")
|
||||
|
||||
|
||||
@app.on_event("startup")
|
||||
async def startup():
|
||||
await manager.start()
|
||||
|
||||
|
||||
@app.on_event("shutdown")
|
||||
async def shutdown():
|
||||
await manager.stop(clear_profile=False)
|
||||
|
||||
|
||||
@app.get("/health")
|
||||
async def health():
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
@app.get("/status")
|
||||
async def status():
|
||||
return await manager.status()
|
||||
|
||||
|
||||
@app.post("/open-login")
|
||||
async def open_login():
|
||||
await manager.open_login()
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
@app.post("/reset")
|
||||
async def reset():
|
||||
await manager.reset()
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
@app.post("/export")
|
||||
async def export():
|
||||
try:
|
||||
result = await manager.export()
|
||||
return {"ok": True, "result": result}
|
||||
except Exception as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
uvicorn.run(app, host="0.0.0.0", port=int(os.getenv("LOGIN_DESKTOP_API_PORT", "18090")), reload=False)
|
||||
64
DouYinSparkFlow/main.py
Normal file
@@ -0,0 +1,64 @@
|
||||
import argparse
|
||||
import asyncio
|
||||
|
||||
from rich.console import Console
|
||||
from rich.prompt import Prompt
|
||||
|
||||
from core.login import userLogin
|
||||
from utils.github_action_config import print_github_action_config
|
||||
|
||||
|
||||
console = Console()
|
||||
|
||||
|
||||
def interactive_cli():
|
||||
console.print("[bold green]Welcome to DouYin Spark Flow[/bold green]")
|
||||
console.print("[bold yellow]Choose an action:[/bold yellow]")
|
||||
console.print("[cyan]1.[/cyan] Add account login")
|
||||
console.print("[cyan]2.[/cyan] Export USER_DATA for GitHub Actions")
|
||||
console.print("[cyan]3.[/cyan] Run task locally")
|
||||
console.print("[cyan]4.[/cyan] Start Web Admin UI")
|
||||
|
||||
choice = Prompt.ask("Enter a choice (1/2/3/4)", choices=["1", "2", "3", "4"])
|
||||
|
||||
if choice == "1":
|
||||
console.print("[bold blue]Starting account login...[/bold blue]")
|
||||
while True:
|
||||
asyncio.run(userLogin())
|
||||
if Prompt.ask("Continue adding accounts? (y/n)", choices=["y", "n"]) == "n":
|
||||
break
|
||||
elif choice == "2":
|
||||
print_github_action_config()
|
||||
elif choice == "3":
|
||||
from core.tasks import runTasks
|
||||
|
||||
asyncio.run(runTasks())
|
||||
else:
|
||||
from webui.app import run_web_app
|
||||
|
||||
run_web_app()
|
||||
|
||||
|
||||
def build_parser():
|
||||
parser = argparse.ArgumentParser(description="DouYin Spark Flow")
|
||||
parser.add_argument("--doTask", action="store_true", help="Run the message task immediately")
|
||||
parser.add_argument("--web", action="store_true", help="Start the Web Admin UI")
|
||||
parser.add_argument("--host", default=None, help="Web UI bind host")
|
||||
parser.add_argument("--port", type=int, default=None, help="Web UI bind port")
|
||||
return parser
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
parser = build_parser()
|
||||
args = parser.parse_args()
|
||||
|
||||
if args.doTask:
|
||||
from core.tasks import runTasks
|
||||
|
||||
asyncio.run(runTasks())
|
||||
elif args.web:
|
||||
from webui.app import run_web_app
|
||||
|
||||
run_web_app(host=args.host, port=args.port)
|
||||
else:
|
||||
interactive_cli()
|
||||
261
DouYinSparkFlow/relogin_worker.py
Normal file
@@ -0,0 +1,261 @@
|
||||
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())
|
||||
5
DouYinSparkFlow/requirements-web.txt
Normal file
@@ -0,0 +1,5 @@
|
||||
fastapi==0.115.6
|
||||
itsdangerous==2.2.0
|
||||
Jinja2==3.1.6
|
||||
python-multipart==0.0.20
|
||||
uvicorn==0.34.0
|
||||
22
DouYinSparkFlow/requirements.txt
Normal file
@@ -0,0 +1,22 @@
|
||||
certifi==2025.11.12
|
||||
charset-normalizer==3.4.4
|
||||
colorama==0.4.6
|
||||
fastapi==0.115.6
|
||||
greenlet==3.2.4
|
||||
httpx==0.28.1
|
||||
idna==3.11
|
||||
itsdangerous==2.2.0
|
||||
Jinja2==3.1.6
|
||||
markdown-it-py==4.0.0
|
||||
mdurl==0.1.2
|
||||
playwright==1.56.0
|
||||
pyee==13.0.0
|
||||
pyperclip==1.11.0
|
||||
Pygments==2.19.2
|
||||
python-multipart==0.0.20
|
||||
qrcode==8.2
|
||||
requests==2.32.5
|
||||
rich==14.2.0
|
||||
typing_extensions==4.15.0
|
||||
urllib3==2.5.0
|
||||
uvicorn==0.34.0
|
||||
31
DouYinSparkFlow/scripts/start_login_desktop.sh
Normal file
@@ -0,0 +1,31 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
export DISPLAY="${DISPLAY:-:99}"
|
||||
export PLAYWRIGHT_BROWSERS_PATH="${PLAYWRIGHT_BROWSERS_PATH:-/ms-playwright}"
|
||||
export LOGIN_DESKTOP_API_PORT="${LOGIN_DESKTOP_API_PORT:-18090}"
|
||||
export LOGIN_DESKTOP_VNC_PORT="${LOGIN_DESKTOP_VNC_PORT:-5901}"
|
||||
export LOGIN_DESKTOP_WEB_PORT="${LOGIN_DESKTOP_WEB_PORT:-8788}"
|
||||
|
||||
mkdir -p /data/login-profile
|
||||
mkdir -p /app/logs/login_desktop
|
||||
|
||||
pkill -f "Xvfb ${DISPLAY}" >/dev/null 2>&1 || true
|
||||
pkill -f "x11vnc .*${LOGIN_DESKTOP_VNC_PORT}" >/dev/null 2>&1 || true
|
||||
pkill -f "websockify --web=/usr/share/novnc ${LOGIN_DESKTOP_WEB_PORT}" >/dev/null 2>&1 || true
|
||||
rm -f "/tmp/.X99-lock"
|
||||
rm -f "/tmp/.X11-unix/X99"
|
||||
|
||||
Xvfb "${DISPLAY}" -screen 0 1600x1000x24 -ac +extension RANDR > /app/logs/login_desktop/xvfb.log 2>&1 &
|
||||
for _ in $(seq 1 30); do
|
||||
if [ -S /tmp/.X11-unix/X99 ]; then
|
||||
break
|
||||
fi
|
||||
sleep 0.5
|
||||
done
|
||||
|
||||
fluxbox > /app/logs/login_desktop/fluxbox.log 2>&1 &
|
||||
x11vnc -display "${DISPLAY}" -forever -shared -rfbport "${LOGIN_DESKTOP_VNC_PORT}" -nopw > /app/logs/login_desktop/x11vnc.log 2>&1 &
|
||||
websockify --web=/usr/share/novnc "${LOGIN_DESKTOP_WEB_PORT}" "localhost:${LOGIN_DESKTOP_VNC_PORT}" > /app/logs/login_desktop/novnc.log 2>&1 &
|
||||
|
||||
exec python /app/login_desktop_server.py
|
||||
1
DouYinSparkFlow/usersData.example.json
Normal file
@@ -0,0 +1 @@
|
||||
[]
|
||||
0
DouYinSparkFlow/utils/__init__.py
Normal file
933
DouYinSparkFlow/utils/chinese_new_year_2026_mare.py
Normal file
@@ -0,0 +1,933 @@
|
||||
from datetime import date
|
||||
import random
|
||||
|
||||
# 2026年丙午马年 春节通用文案库 (每日30+条)
|
||||
# 特点:全场景通用、无特定对象、强日期贴合度
|
||||
SPRING_FESTIVAL_QUOTES = {
|
||||
# ==============================================
|
||||
# 2026年2月16日 除夕 (岁除/大年夜)
|
||||
# 核心:辞旧、团圆、守岁、年夜饭、跨年
|
||||
# ==============================================
|
||||
date(2026, 2, 16): [
|
||||
# 辞旧迎新篇
|
||||
"2026丙午除夕,烟火起,照人间,举杯敬此年。",
|
||||
"辞别乙巳,拥抱丙午。愿旧岁千般皆如意,新年万事定称心。",
|
||||
"最后一天,把所有的遗憾打包封存,把所有的期待整装待发。",
|
||||
"岁序更替,华章日新。站在新年的门槛,准备好迎接火热的马年。",
|
||||
"长路浩浩荡荡,万物尽可期待。2025圆满谢幕,2026正式启航。",
|
||||
"旧岁已展千重锦,新年再进百尺竿。除夕快乐,万事胜意。",
|
||||
"将往事清零,与岁月言和。愿新的一年,多喜乐,长安宁。",
|
||||
"光阴流转,又逢除夕。愿所有的努力,都能在马年开花结果。",
|
||||
"挥手作别旧时路,策马扬鞭新征程。除夕安康,福暖四季。",
|
||||
"跨年的钟声即将敲响,愿未来的日子,如骏马奔腾,一往无前。",
|
||||
|
||||
# 团圆守岁篇
|
||||
"万家灯火时,阖家团圆日。今夜围炉话岁,共享人间清欢。",
|
||||
"年夜饭的香气,是这一年最温暖的句号。",
|
||||
"灯火可亲,饭香扑鼻。愿岁岁年年,共占春风。",
|
||||
"守岁至天明,喜乐伴一生。今夜不谈烦恼,只叙团圆。",
|
||||
"窗外烟火璀璨,屋内笑语盈盈。这便是人间最好的光景。",
|
||||
"一杯屠苏酒,一桌团圆饭。敬过往,敬明天,敬每一个当下。",
|
||||
"今夜无眠,唯有欢喜。愿烟火向星辰,所愿皆成真。",
|
||||
"饺子滚一滚,福气进家门。除夕的饺子,包进了一整年的好运。",
|
||||
"围炉守岁,静待新春。愿马年的第一缕阳光,照亮心底的梦想。",
|
||||
"在这个辞旧迎新的夜晚,愿平安与健康,常伴左右。",
|
||||
|
||||
# 马年预热篇
|
||||
"丙午火马,即将登场。愿新的一年,生命力如烈火般旺盛。",
|
||||
"金蛇隐去,骏马奔腾。2026,准备好马力全开。",
|
||||
"除夕之夜,许下心愿:2026,马不停蹄奔向幸福。",
|
||||
"烟火升腾处,金马踏春来。愿新年,胜旧年。",
|
||||
"迎接丙午马年,愿前程如骏马驰骋,一马平川。",
|
||||
"除夕快乐!金马贺岁,福暖四季,万事胜意。",
|
||||
"2026,愿如骏马,不负韶华,驰骋万里。",
|
||||
"在这个火热的年份,愿日子过得红红火火,热气腾腾。",
|
||||
"策马扬鞭迎新岁,意气风发赴前程。除夕大吉。",
|
||||
"准备好,和金马一起,跨越山海,奔赴美好。",
|
||||
|
||||
# 短句补充篇
|
||||
"除夕快乐,2026你好。",
|
||||
"烟火年年,岁岁平安。",
|
||||
"旧疾当愈,新年可期。",
|
||||
"辞旧岁,迎新春,万事兴。",
|
||||
"丙午大吉,马到成功。",
|
||||
"万家团圆,喜乐安康。",
|
||||
"今夜好梦,明天好运。",
|
||||
"感恩过往,期待未来。",
|
||||
"福满人间,春回大地。",
|
||||
"跨年快乐,马年大吉。"
|
||||
],
|
||||
|
||||
# ==============================================
|
||||
# 2026年2月17日 正月初一 (春节/元日)
|
||||
# 核心:开门见喜、拜年、新岁、龙马精神
|
||||
# ==============================================
|
||||
date(2026, 2, 17): [
|
||||
# 开门见喜篇
|
||||
"正月初一,开门见喜。愿2026年的第一天,满载好运与福气。",
|
||||
"大年初一,喜气洋洋。推开窗,迎接第一缕春风与阳光。",
|
||||
"初一启新程,万事皆顺遂。愿这一年,所求皆如愿。",
|
||||
"门迎百福,户纳千祥。马年第一天,好运接踵而至。",
|
||||
"初一早,福气到。愿生活明朗,万物可爱。",
|
||||
"新岁启封,美好开场。2026,从这喜气洋洋的一天开始。",
|
||||
"晨光熹微,年味正浓。初一早安,马年吉祥。",
|
||||
"开启新岁的第一份好运,愿平安喜乐,一路随行。",
|
||||
"初一开门红,全年万事通。愿日子红红火火,蒸蒸日上。",
|
||||
"迎着朝阳,许下心愿:2026,一马当先,万事胜意。",
|
||||
|
||||
# 龙马精神篇
|
||||
"丙午马年,正月初一。愿龙马精神,常驻心间。",
|
||||
"2026,做一匹奔腾的骏马,跨越所有障碍,奔向理想。",
|
||||
"大年初一,祝龙马精神,身体康健,活力满满。",
|
||||
"马年第一天,愿拥有骏马的速度,更有骏马的耐力。",
|
||||
"春风得意马蹄疾,一日看尽长安花。新年伊始,意气风发。",
|
||||
"以梦为马,不负韶华。初一启程,奔赴山海。",
|
||||
"金马迎春,万象更新。愿精神抖擞,迎接每一个挑战。",
|
||||
"马到成功,从大年初一做起。每一步,都坚定有力。",
|
||||
"愿如骏马,驰骋疆场,所向披靡,收获满满。",
|
||||
"正月初一,愿一马平川,前程似锦,无往不利。",
|
||||
|
||||
# 拜年祈福篇
|
||||
"新春大吉,拜年啦。愿这一年,多喜乐,长安宁。",
|
||||
"大年初一,送上最真挚的祝福:四季平安,万事顺遂。",
|
||||
"初一纳福,愿福气满满,财运亨通,好运连连。",
|
||||
"拜年进行时,祝福送不停。愿2026,皆是欢喜。",
|
||||
"大年初一,不只是祝福,更是对未来的美好期许。",
|
||||
"新的一年,愿日子如熹光,温柔又安详。",
|
||||
"初一祈福,愿阖家欢乐,岁月静好,现世安稳。",
|
||||
"春风送暖,福气盈门。大年初一,喜乐安康。",
|
||||
"给时光拜个年,愿它温柔以待每一个努力的人。",
|
||||
"2026的第一声祝福:愿世间美好,与你环环相扣。",
|
||||
|
||||
# 短句补充篇
|
||||
"初一吉祥,马年大吉。",
|
||||
"开门纳福,万事亨通。",
|
||||
"龙马精神,一马当先。",
|
||||
"新春快乐,好事连连。",
|
||||
"元日安康,福暖人间。",
|
||||
"2026,向阳而生。",
|
||||
"大年初一,喜气盈门。",
|
||||
"马到成功,前程似锦。",
|
||||
"新年第一喜,好运属于你。",
|
||||
"喜乐无忧,自在如风。"
|
||||
],
|
||||
|
||||
# ==============================================
|
||||
# 2026年2月18日 正月初二 (回门/迎婿)
|
||||
# 核心:归家、亲情、欢聚、福满门
|
||||
# ==============================================
|
||||
date(2026, 2, 18): [
|
||||
# 归家欢聚篇
|
||||
"正月初二,归宁之喜。带上祝福,奔赴另一场团圆。",
|
||||
"初二回门,福气临门。愿每一次归家,都温暖如初。",
|
||||
"初一团圆,初二欢聚。亲情的纽带,从未如此紧密。",
|
||||
"带上爱与思念,回到温暖的港湾。初二快乐。",
|
||||
"正月初二,风和日丽。适合相聚,适合表达爱意。",
|
||||
"回门的路,是通往幸福的路。愿一路欢歌,一路笑语。",
|
||||
"初二时光,慢煮生活。愿烟火气中,皆是幸福味。",
|
||||
"走亲访友,传递温情。初二这一天,装满了爱。",
|
||||
"归宁日,欢喜时。愿所有的美好,都恰逢其时。",
|
||||
"初二启程,满载欢喜。愿相聚的时光,温柔又绵长。",
|
||||
|
||||
# 福满双门篇
|
||||
"正月初二,福满双门。愿两家喜乐,万事兴隆。",
|
||||
"初二迎福,愿福气不仅满盈小家,更福泽大家。",
|
||||
"门门有喜,户户纳福。初二这一天,喜气洋洋。",
|
||||
"双门纳福,马年吉祥。愿两边的长辈,福寿安康。",
|
||||
"正月初二,好事成双。愿快乐加倍,幸福翻倍。",
|
||||
"福临门,喜盈户。初二的日子,红红火火。",
|
||||
"两家欢喜,一门和气。愿这份福气,延续一整年。",
|
||||
"初二接福,愿生活有滋有味,日子顺顺当当。",
|
||||
"福气流转,爱意相传。正月初二,吉祥如意。",
|
||||
"双福临门,万事胜意。愿马年的每一天,都充满阳光。",
|
||||
|
||||
# 春日随行篇
|
||||
"初二春早,惠风和畅。愿春风十里,不如相聚有你。",
|
||||
"正月初二,踏春而行。愿脚步所至,皆是美好。",
|
||||
"春日暖阳,照见归途。初二这一天,温暖随行。",
|
||||
"春风送暖入屠苏,初二归宁乐陶陶。",
|
||||
"马年的春天,从初二的欢聚开始。生机勃勃,充满希望。",
|
||||
"花开正艳,春意正浓。初二出门,遇见美好。",
|
||||
"暖阳相伴,清风相随。初二的时光,惬意又美好。",
|
||||
"踏遍春色,归来仍是少年。正月初二,喜乐安康。",
|
||||
"春日迟迟,卉木萋萋。初二之日,愿心情如花般绽放。",
|
||||
"迎着春光,奔赴团圆。初二快乐,马年大吉。",
|
||||
|
||||
# 短句补充篇
|
||||
"初二回门,喜气洋洋。",
|
||||
"归宁日,幸福时。",
|
||||
"福满双门,好事成双。",
|
||||
"初二吉祥,马年安康。",
|
||||
"欢聚时刻,喜乐无忧。",
|
||||
"亲情无价,岁月留痕。",
|
||||
"初二纳福,万事顺遂。",
|
||||
"春风十里,不如团圆。",
|
||||
"回门之喜,福暖人心。",
|
||||
"马年初二,福气满满。"
|
||||
],
|
||||
|
||||
# ==============================================
|
||||
# 2026年2月19日 正月初三 (赤狗日/宅家)
|
||||
# 核心:静养、安歇、蓄力、宅家
|
||||
# ==============================================
|
||||
date(2026, 2, 19): [
|
||||
# 宅家静养篇
|
||||
"正月初三,安歇静养。给身体放个假,给心情充个电。",
|
||||
"初三宅家,慢享时光。在喧嚣之外,寻得一份宁静。",
|
||||
"初一忙,初二累,初三在家睡。享受难得的清闲。",
|
||||
"宅家的日子,也是一种幸福。初三快乐,自在随心。",
|
||||
"闭门谢客,静心养神。初三这一天,只属于自己。",
|
||||
"放慢脚步,享受慢生活。初三,宜休息,宜欢聚。",
|
||||
"窗外年味浓,屋内岁月静。初三宅家,惬意安然。",
|
||||
"暂时放下忙碌,享受片刻悠闲。正月初三,岁月静好。",
|
||||
"初三时光,用来虚度。愿日子慢一点,幸福长一点。",
|
||||
"在家纳福,平安喜乐。初三这一天,简单又美好。",
|
||||
|
||||
# 蓄力待发篇
|
||||
"初三蓄力,静待花开。为了更好的出发,此刻需要沉淀。",
|
||||
"养精蓄锐,马力全开。初三的休息,是为了未来的奔跑。",
|
||||
"积蓄力量,厚积薄发。2026,准备好惊艳全场。",
|
||||
"暂停,是为了更好的前行。初三,在宁静中积蓄能量。",
|
||||
"休整身心,整装待发。愿未来的路,走得更稳更远。",
|
||||
"初三时光,用来规划。愿马年的每一步,都走得坚定。",
|
||||
"充电完毕,满格出发。初三之后,又是新的征程。",
|
||||
"在安静中蓄力,在沉淀中成长。正月初三,未来可期。",
|
||||
"养足精神,迎接挑战。马年的精彩,还在后面。",
|
||||
"初三纳福,蓄力前行。愿2026,一往无前。",
|
||||
|
||||
# 平安纳福篇
|
||||
"正月初三,赤狗日。宜居家,纳平安,避纷争。",
|
||||
"在家纳福,百邪不侵。愿金马护宅,万事顺遂。",
|
||||
"初三吉祥,平安第一。愿日子安稳,岁月静好。",
|
||||
"纳福迎祥,阖家安康。初三这一天,福气满满。",
|
||||
"平安是福,健康是金。正月初三,祈愿平安。",
|
||||
"福宅安康,万事兴隆。初三纳福,马年吉祥。",
|
||||
"闭门纳福,开门迎喜。愿初三的宁静,带来一整年的安稳。",
|
||||
"岁月安稳,现世静好。初三之日,福暖人间。",
|
||||
"纳千祥,迎万福。正月初三,平安喜乐。",
|
||||
"金马护佑,平安相随。初三安康,万事大吉。",
|
||||
|
||||
# 短句补充篇
|
||||
"初三宅家,自在逍遥。",
|
||||
"静养身心,蓄力前行。",
|
||||
"闭门纳福,平安是福。",
|
||||
"初三吉祥,岁月静好。",
|
||||
"慢享时光,惬意安然。",
|
||||
"养精蓄锐,马到成功。",
|
||||
"正月初三,宜休息。",
|
||||
"在家享福,福气自来。",
|
||||
"沉淀自己,未来可期。",
|
||||
"初三安康,福满人间。"
|
||||
],
|
||||
|
||||
# ==============================================
|
||||
# 2026年2月20日 正月初四 (接灶神)
|
||||
# 核心:烟火、食禄、家肥屋润、三餐四季
|
||||
# ==============================================
|
||||
date(2026, 2, 20): [
|
||||
# 恭迎灶神篇
|
||||
"正月初四,恭迎灶神。愿三餐四季,温暖如初。",
|
||||
"灶神归位,烟火重燃。初四这一天,充满了生活气息。",
|
||||
"恭迎灶王爷,福泽满人间。愿家肥屋润,衣食无忧。",
|
||||
"初四接灶,五谷丰登。愿粮仓常满,日子富足。",
|
||||
"灶火初红,春意渐浓。迎接灶神,迎接美好。",
|
||||
"一炉香火,祈愿平安。初四接灶,马年吉祥。",
|
||||
"灶神下界,保祐平安。愿每一顿饭,都吃得香甜。",
|
||||
"正月初四,迎灶纳福。愿烟火气中,皆是幸福味。",
|
||||
"接灶神,纳吉祥。愿2026,衣食无忧,生活美满。",
|
||||
"灶火通明,福气盈门。初四大吉,万事顺遂。",
|
||||
|
||||
# 人间烟火篇
|
||||
"人间烟火气,最抚凡人心。初四这一天,重拾生活的热爱。",
|
||||
"三餐四季,温柔有趣。愿灶火不熄,爱与温暖常在。",
|
||||
"厨房里的烟火,是家里最美的风景。初四快乐。",
|
||||
"一碗热汤,温暖身心。愿马年的每一天,都热气腾腾。",
|
||||
"烟火升腾处,幸福正当时。初四,宜下厨,宜欢聚。",
|
||||
"柴米油盐酱醋茶,人间烟火也有趣。正月初四,岁月静好。",
|
||||
"灶台飘香,日子红火。愿生活有滋有味,红红火火。",
|
||||
"在烟火气中,感受生活的美好。初四这一天,惬意安然。",
|
||||
"灶火声声,笑语盈盈。愿家宅安宁,幸福绵长。",
|
||||
"人间有味是清欢。初四之日,愿享受每一顿家常便饭。",
|
||||
|
||||
# 食禄丰足篇
|
||||
"初四接灶,食禄丰足。愿2026,不愁吃穿,富足安康。",
|
||||
"米缸常满,日子香甜。愿马年的每一天,都衣食无忧。",
|
||||
"食禄双全,福气满满。正月初四,祈愿丰收。",
|
||||
"五谷丰登,食禄无忧。愿生活富足,岁月安稳。",
|
||||
"迎灶神,纳食禄。愿这一年,物质富足,精神丰盈。",
|
||||
"仓廪实而知礼节,衣食足而知荣辱。初四祈愿富足。",
|
||||
"食禄绵长,福泽深厚。马年初四,吉祥如意。",
|
||||
"愿手中有粮,心中不慌。初四接灶,岁岁安康。",
|
||||
"丰衣足食,安居乐业。正月初四,万事亨通。",
|
||||
"接灶神,保食禄。愿2026,日子过得殷实又幸福。",
|
||||
|
||||
# 短句补充篇
|
||||
"初四接灶,福气满堂。",
|
||||
"烟火人间,温暖相伴。",
|
||||
"家肥屋润,衣食无忧。",
|
||||
"灶神归位,万事顺遂。",
|
||||
"三餐四季,温柔有趣。",
|
||||
"食禄丰足,马年大吉。",
|
||||
"正月初四,宜纳福。",
|
||||
"烟火气中,幸福绵长。",
|
||||
"迎灶纳祥,岁岁安康。",
|
||||
"柴米油盐,皆是幸福。"
|
||||
],
|
||||
|
||||
# ==============================================
|
||||
# 2026年2月21日 正月初五 (破五/迎财神)
|
||||
# 核心:财运、破禁、送穷、发财
|
||||
# ==============================================
|
||||
date(2026, 2, 21): [
|
||||
# 迎财纳福篇
|
||||
"正月初五,迎财神。愿2026,财运亨通,富贵吉祥。",
|
||||
"五路财神齐到访,八方来财福满堂。初五接福啦。",
|
||||
"财神到,福运照。愿马年的每一天,都财源滚滚。",
|
||||
"初五迎财,开门见喜。愿事业有成,财运亨通。",
|
||||
"东路招财,西路纳珍。初五这一天,装满了财富。",
|
||||
"财神骑马到家门,金银财宝进家门。马年发大财。",
|
||||
"正月初五,财门大开。愿八方财源,滚滚而来。",
|
||||
"迎财神,纳千祥。愿2026,腰缠万贯,富贵安康。",
|
||||
"五路财神护佑,马年财运亨通。初五快乐。",
|
||||
"财星高照,福气临门。正月初五,恭喜发财。",
|
||||
|
||||
# 破五送穷篇
|
||||
"正月初五,破五送穷。送走烦恼,送走霉运,送走贫穷。",
|
||||
"破五之时,送穷出门。愿2026,轻装上阵,奔赴美好。",
|
||||
"鞭炮一响,穷鬼跑光。初五这一天,除旧布新。",
|
||||
"破除禁忌,送走穷困。愿马年的日子,蒸蒸日上。",
|
||||
"破五开运,万象更新。愿所有的不好,都随风而去。",
|
||||
"送穷迎富,福气满屋。正月初五,好运连连。",
|
||||
"破五之日,百无禁忌。愿想做的事,都能如愿。",
|
||||
"送走旧岁的穷气,迎来新年的财气。初五大吉。",
|
||||
"破五重生,焕然一新。愿2026,元气满满。",
|
||||
"除旧迎新,破五纳祥。愿未来的日子,一片光明。",
|
||||
|
||||
# 马年钱程篇
|
||||
"策马奔腾赴钱程,马不停蹄赚金银。初五快乐。",
|
||||
"马年行大运,财运滚滚来。愿事业如骏马,飞驰向前。",
|
||||
"金马送财,富贵花开。愿2026,钱途无量。",
|
||||
"马力全开搞事业,一心一意赚大钱。初五吉祥。",
|
||||
"如骏马驰骋,在财富的草原上,收获满满。",
|
||||
"马到成功,财到手。愿2026,盆满钵满。",
|
||||
"金马踏春来,财运随身带。正月初五,发财发财。",
|
||||
"驰骋商海,如骏马奔腾。愿财源广进,日进斗金。",
|
||||
"马年第一桶金,从初五迎财神开始。",
|
||||
"财运如骏马,日行千里,夜行八百。",
|
||||
|
||||
# 短句补充篇
|
||||
"初五迎财,富贵自来。",
|
||||
"送穷迎富,万事胜意。",
|
||||
"财神驾到,财源滚滚。",
|
||||
"破五开运,马到成功。",
|
||||
"五路接财,八方纳福。",
|
||||
"马年发财,钱途无量。",
|
||||
"正月初五,恭喜发财。",
|
||||
"财门大开,好运自来。",
|
||||
"送穷出门,迎富入宅。",
|
||||
"日进斗金,腰缠万贯。"
|
||||
],
|
||||
|
||||
# ==============================================
|
||||
# 2026年2月22日 正月初六 (送穷/开市)
|
||||
# 核心:顺意、开工、送穷、六六大顺
|
||||
# ==============================================
|
||||
date(2026, 2, 22): [
|
||||
# 六六大顺篇
|
||||
"正月初六,六六大顺。愿2026,万事顺遂,顺心如意。",
|
||||
"六六大顺日,马年吉祥时。愿好运连连,幸福满满。",
|
||||
"初六大顺,一顺百顺。愿生活顺心,事业顺利。",
|
||||
"天顺地顺人更顺,心顺意顺事事顺。正月初六快乐。",
|
||||
"顺风顺水,顺理成章。愿马年的每一天,都顺顺利利。",
|
||||
"六六大顺,福满人间。愿所有的美好,都如期而至。",
|
||||
"初六送福,顺字当头。愿日子过得舒心,过得顺心。",
|
||||
"顺气东来,福气西至。正月初六,万事亨通。",
|
||||
"顺顺利利开工,红红火火生活。初六大吉。",
|
||||
"顺境常伴,逆境不扰。愿2026,一路顺风。",
|
||||
|
||||
# 送穷启程篇
|
||||
"正月初六,送穷启程。愿霉运清零,好运加满。",
|
||||
"送走穷神,迎来福神。初六这一天,焕然一新。",
|
||||
"穷气送出门,福气迎进门。愿马年的日子,富足安康。",
|
||||
"初六送穷,一送永逸。愿2026,无病无灾,无贫无困。",
|
||||
"鞭炮声声送穷神,欢歌笑语迎新春。正月初六快乐。",
|
||||
"送穷归故里,迎富入新宅。愿生活蒸蒸日上。",
|
||||
"初六启程,甩掉包袱。愿轻装上阵,奔赴前程。",
|
||||
"穷神走,财神留。愿2026,富贵常伴。",
|
||||
"送穷之日,开启新程。愿马年的路,越走越宽。",
|
||||
"告别贫穷与烦恼,迎接富裕与快乐。初六吉祥。",
|
||||
|
||||
# 开市大吉篇
|
||||
"正月初六,开市大吉。愿2026,事业兴旺,财源广进。",
|
||||
"开工啦!愿马力全开,业绩长虹。",
|
||||
"初六启市,百业兴旺。愿生意兴隆,客似云来。",
|
||||
"开市迎财,大吉大利。愿马年的事业,如日中天。",
|
||||
"鞭炮一响,黄金万两。初六开工,红红火火。",
|
||||
"新征程,新起点。初六开市,未来可期。",
|
||||
"开门做生意,笑脸迎财神。愿2026,订单不断。",
|
||||
"初六开工,元气满满。愿工作顺利,薪水翻番。",
|
||||
"开市纳福,生意兴隆。愿马年的事业,一马当先。",
|
||||
"正月初六,宜开工。愿所有的努力,都有回报。",
|
||||
|
||||
# 短句补充篇
|
||||
"初六大顺,万事亨通。",
|
||||
"送穷迎富,开工大吉。",
|
||||
"六六大顺,马到成功。",
|
||||
"顺风顺水,前程似锦。",
|
||||
"开市纳财,富贵吉祥。",
|
||||
"正月初六,启程出发。",
|
||||
"霉运清零,好运加满。",
|
||||
"红红火火,开工大吉。",
|
||||
"顺字当头,幸福安康。",
|
||||
"马年开工,业绩长虹。"
|
||||
],
|
||||
|
||||
# ==============================================
|
||||
# 2026年2月23日 正月初七 (人日/庆寿)
|
||||
# 核心:生民、健康、成长、七菜
|
||||
# ==============================================
|
||||
date(2026, 2, 23): [
|
||||
# 众人生日篇
|
||||
"正月初七,人日快乐。愿世间所有人,平安健康。",
|
||||
"传说女娲造人,初七始成。这是属于每个人的生日。",
|
||||
"人日吉祥,喜乐安康。愿2026,善待每一个生命。",
|
||||
"初七庆生,福满人间。愿岁月温柔,不负韶华。",
|
||||
"所有人的生日,所有的祝福。愿平安常伴,健康常在。",
|
||||
"人日之时,许下心愿:愿众生皆苦,唯有你甜。",
|
||||
"正月初七,祝自己,也祝你,生日快乐。",
|
||||
"生而为人,何其有幸。初七这一天,感恩生命。",
|
||||
"人日纳福,愿每一个人,都能被世界温柔以待。",
|
||||
"初七之日,万物生辉。愿生命蓬勃,充满希望。",
|
||||
|
||||
# 健康成长篇
|
||||
"人日祈健康,愿身体无恙,精神饱满。",
|
||||
"正月初七,宜养生。愿龙马精神,常驻心间。",
|
||||
"健康是福,平安是金。愿2026,无病无灾。",
|
||||
"在这个属于人的日子,愿健康常伴左右。",
|
||||
"茁壮成长,不负春光。愿马年的每一天,都充满活力。",
|
||||
"身强体健,百病不侵。初七祈愿,健康长寿。",
|
||||
"愿如骏马,体魄强健,驰骋万里。",
|
||||
"人日吃顿好,身体没烦恼。愿营养均衡,健康无忧。",
|
||||
"正月初七,动起来。愿活力满满,元气十足。",
|
||||
"健康的体魄,是梦想的基石。初七快乐。",
|
||||
|
||||
# 七菜迎春篇
|
||||
"初七吃七菜,福气自然来。愿生活丰富多彩。",
|
||||
"七菜羹,聚福气。愿2026,集齐所有的美好。",
|
||||
"七种蔬菜,七种祝福。愿马年的日子,五彩斑斓。",
|
||||
"食七菜,迎新春。愿日子过得有滋有味。",
|
||||
"正月初七,尝鲜迎春。愿生活如七菜,清爽又健康。",
|
||||
"七菜同煮,福气满屋。愿阖家欢乐,岁月静好。",
|
||||
"吃口七菜羹,全年万事兴。初七吉祥。",
|
||||
"七种食材,七种好运。愿马年的每一天,都有惊喜。",
|
||||
"人日食七菜,健康又自在。愿身体安康,万事顺遂。",
|
||||
"七菜迎春,福满人间。正月初七,喜乐安康。",
|
||||
|
||||
# 短句补充篇
|
||||
"初七人日,喜乐安康。",
|
||||
"众人生日,平安吉祥。",
|
||||
"健康第一,万事无忧。",
|
||||
"人日纳福,马年大吉。",
|
||||
"生而自由,爱而无畏。",
|
||||
"七菜迎春,福气满满。",
|
||||
"正月初七,岁月静好。",
|
||||
"生命可贵,且行且惜。",
|
||||
"人日快乐,诸事顺遂。",
|
||||
"龙马精神,健康长寿。"
|
||||
],
|
||||
|
||||
# ==============================================
|
||||
# 2026年2月24日 正月初八 (开工/谷日)
|
||||
# 核心:耕耘、丰收、事业、启程
|
||||
# ==============================================
|
||||
date(2026, 2, 24): [
|
||||
# 开工启程篇
|
||||
"正月初八,开工大吉。愿2026,马力全开,再创辉煌。",
|
||||
"初八启程,奔赴前程。愿事业如骏马,一日千里。",
|
||||
"假期归零,快乐不归零。初八开工,元气满满。",
|
||||
"新的征程,从初八开始。愿脚踏实地,仰望星空。",
|
||||
"初八开工,喜气洋洋。愿工作顺利,步步高升。",
|
||||
"收心归位,全力以付。愿马年的事业,一马当先。",
|
||||
"正月初八,宜奋斗。愿每一份努力,都不被辜负。",
|
||||
"开工啦!愿2026,业绩长虹,薪水翻番。",
|
||||
"带着新年的喜气,投入工作的热情。初八快乐。",
|
||||
"启程出发,未来可期。正月初八,万事亨通。",
|
||||
|
||||
# 谷日祈丰篇
|
||||
"正月初八,谷日吉祥。愿五谷丰登,国泰民安。",
|
||||
"谷日祈丰收,愿大地回馈,仓廪丰实。",
|
||||
"初八是谷日,预示丰收年。愿生活富足,岁月安稳。",
|
||||
"春种一粒粟,秋收万颗子。初八祈愿,收获满满。",
|
||||
"五谷飘香,日子绵长。愿马年的每一天,都衣食无忧。",
|
||||
"谷日纳福,愿耕耘有收获,付出有回报。",
|
||||
"正月初八,惜粮感恩。愿每一粒粮食,都被珍惜。",
|
||||
"谷物丰登,福气盈门。愿2026,物质富足。",
|
||||
"谷日之时,许下心愿:愿世间无饥饿,人间皆温饱。",
|
||||
"初八谷日,福泽深厚。愿马年,岁岁丰收。",
|
||||
|
||||
# 耕耘收获篇
|
||||
"一分耕耘,一分收获。初八开工,愿辛勤付出,换来硕果累累。",
|
||||
"如农人耕耘,如骏马驰骋。愿在事业的田野,收获满满。",
|
||||
"播种希望,收获未来。正月初八,宜行动。",
|
||||
"不驰于空想,不骛于虚声。初八开始,脚踏实地。",
|
||||
"耕耘当下,收获未来。愿2026,满载而归。",
|
||||
"像守护庄稼一样,守护梦想。初八快乐。",
|
||||
"辛勤耕耘,静待花开。愿马年的事业,蒸蒸日上。",
|
||||
"只有播种,才有收获。初八启程,开始新的耕耘。",
|
||||
"愿汗水浇灌梦想,收获金色的未来。正月初八吉祥。",
|
||||
"耕耘岁月,收获幸福。愿2026,硕果累累。",
|
||||
|
||||
# 短句补充篇
|
||||
"初八开工,大吉大利。",
|
||||
"谷日祈丰,五谷丰登。",
|
||||
"马力全开,奔赴前程。",
|
||||
"耕耘收获,未来可期。",
|
||||
"正月初八,宜奋斗。",
|
||||
"业绩长虹,步步高升。",
|
||||
"仓廪丰实,衣食无忧。",
|
||||
"脚踏实地,仰望星空。",
|
||||
"开工启程,马到成功。",
|
||||
"播种希望,收获辉煌。"
|
||||
],
|
||||
|
||||
# ==============================================
|
||||
# 2026年2月25日 正月初九 (天公生)
|
||||
# 核心:天长地久、祈福、高远、玉皇诞
|
||||
# ==============================================
|
||||
date(2026, 2, 25): [
|
||||
# 天公诞辰篇
|
||||
"正月初九,天公生。愿上天庇佑,阖家安康。",
|
||||
"玉皇大帝诞辰日,一拜天公,风调雨顺。",
|
||||
"初九拜天公,福气满乾坤。愿2026,万事顺遂。",
|
||||
"天公作美,岁月静好。正月初九,吉祥如意。",
|
||||
"叩拜天公,祈愿平安。愿风调雨顺,国泰民安。",
|
||||
"初九吉日,天公赐福。愿所有的美好,都降临身边。",
|
||||
"天公生,福满门。愿金马踏云,带来祥瑞。",
|
||||
"正月初九,诚心祈福。愿上天眷顾,诸事皆宜。",
|
||||
"拜天公,纳千祥。愿2026,福运亨通。",
|
||||
"天公庇佑,金马护航。初九安康,万事大吉。",
|
||||
|
||||
# 长长久久篇
|
||||
"正月初九,长长久久。愿福气长久,财运长久。",
|
||||
"九为数之极,寓意圆满。愿2026,长长久久的幸福。",
|
||||
"初九之日,许下心愿:愿健康长久,快乐长久。",
|
||||
"长长久久的陪伴,长长久久的幸福。正月初九快乐。",
|
||||
"友谊长存,爱意长久。愿所有的关系,都天长地久。",
|
||||
"初九纳福,愿好运长久相伴,烦恼长久远离。",
|
||||
"幸福久久,好运连连。愿马年的每一天,都充满阳光。",
|
||||
"长长久久的岁月,长长久久的安康。",
|
||||
"正月初九,愿这份祝福,伴你天长地久。",
|
||||
"久久同心,万事胜意。愿2026,美好长存。",
|
||||
|
||||
# 志存高远篇
|
||||
"初九天公生,愿志存高远,心向星辰。",
|
||||
"如天马行空,自由自在。愿梦想无边界,前程无阻碍。",
|
||||
"仰望星空,脚踏实地。正月初九,未来可期。",
|
||||
"心有凌云志,脚下万里途。愿马年的你,驰骋万里。",
|
||||
"志在千里,壮心不已。愿2026,实现远大理想。",
|
||||
"天高地阔,任君驰骋。愿如骏马,飞跃高山。",
|
||||
"正月初九,宜立志。愿立下鸿鹄志,不负少年时。",
|
||||
"胸怀天下,志存高远。愿马年的事业,蒸蒸日上。",
|
||||
"心向蓝天,脚踏实地。愿每一步,都走得坚定。",
|
||||
"初九之日,愿眼界开阔,格局打开。",
|
||||
|
||||
# 短句补充篇
|
||||
"初九拜天,福泽绵绵。",
|
||||
"天长地久,幸福安康。",
|
||||
"天公赐福,万事大吉。",
|
||||
"正月初九,步步高升。",
|
||||
"志存高远,马到成功。",
|
||||
"福气久久,好运连连。",
|
||||
"风调雨顺,国泰民安。",
|
||||
"天马行空,自在逍遥。",
|
||||
"初九吉祥,福满人间。",
|
||||
"长长久久,万事胜意。"
|
||||
],
|
||||
|
||||
# ==============================================
|
||||
# 2026年2月26日 正月初十 (石不动/十全十美)
|
||||
# 核心:圆满、稳固、十全十美、基础
|
||||
# ==============================================
|
||||
date(2026, 2, 26): [
|
||||
# 十全十美篇
|
||||
"正月初十,十全十美。愿2026,圆满无缺,万事胜意。",
|
||||
"十全十美日,马年吉祥时。愿集齐所有的美好。",
|
||||
"初十圆满,事事如意。愿生活有滋有味,有声有色。",
|
||||
"十分幸福,十分美满。正月初十,福暖人间。",
|
||||
"十全十美,百事无忧。愿马年的每一天,都顺心顺意。",
|
||||
"初十这一天,愿所有的期待,都得到圆满答复。",
|
||||
"十分好运,十分福气。愿2026,好运连连。",
|
||||
"十全十美,千金不换。愿这份幸福,伴你一生。",
|
||||
"正月初十,愿生活满分,快乐满分。",
|
||||
"圆满之日,喜乐之时。愿马年,圆圆满满。",
|
||||
|
||||
# 根基稳固篇
|
||||
"正月初十,石不动。愿根基稳固,如磐石般坚定。",
|
||||
"石不动,心安稳。愿马年的每一步,都走得踏实。",
|
||||
"初十之日,宜固本。愿基础扎实,前程稳固。",
|
||||
"如磐石般坚定,如骏马般奔腾。愿动静皆宜。",
|
||||
"根基深厚,枝繁叶茂。愿事业如大树,茁壮成长。",
|
||||
"石不动,福常驻。愿家宅安宁,岁月静好。",
|
||||
"正月初十,愿初心如磐,使命在肩。",
|
||||
"稳固根基,才能行稳致远。初十吉祥。",
|
||||
"如石般坚定,如水般灵动。愿马年的日子,刚柔并济。",
|
||||
"初十纳福,愿基业长青,幸福长久。",
|
||||
|
||||
# 十福临门篇
|
||||
"初十迎十福,福满门庭。愿福气、财气、运气,统统到来。",
|
||||
"一福平安,二福健康。初十这一天,十福临门。",
|
||||
"集齐十福,召唤好运。愿2026,福气满满。",
|
||||
"十福齐至,万事亨通。正月初十,喜乐安康。",
|
||||
"福满十方,喜盈门庭。愿马年的日子,红红火火。",
|
||||
"初十接福,愿幸福像花儿一样,朵朵绽放。",
|
||||
"十全十美,五福临门。愿2026,好事成双。",
|
||||
"福运绵长,十全十美。愿每一个梦想,都开花结果。",
|
||||
"正月初十,愿福气东来,紫气西至。",
|
||||
"十福相伴,一生平安。愿马年,福暖四季。",
|
||||
|
||||
# 短句补充篇
|
||||
"初十圆满,十全十美。",
|
||||
"石不动,福常驻。",
|
||||
"根基稳固,行稳致远。",
|
||||
"十福临门,万事大吉。",
|
||||
"正月初十,圆满收官。",
|
||||
"十分幸福,十分美满。",
|
||||
"马年圆满,事事如意。",
|
||||
"初心如磐,未来可期。",
|
||||
"初十吉祥,福满人间。",
|
||||
"十全十美,喜乐无忧。"
|
||||
],
|
||||
|
||||
# ==============================================
|
||||
# 2026年2月27日 正月十一 (子婿日/宴请)
|
||||
# 核心:相聚、情谊、款待、热闹
|
||||
# ==============================================
|
||||
date(2026, 2, 27): [
|
||||
# 欢聚宴请篇
|
||||
"正月十一,欢聚时刻。愿情谊长存,温暖常在。",
|
||||
"子婿之日,宴请亲朋。愿欢声笑语,充满屋宇。",
|
||||
"正月十一,宜相聚。愿推杯换盏,共话桑麻。",
|
||||
"宴请八方客,喜迎四海宾。正月十一,热闹非凡。",
|
||||
"欢聚一堂,喜气洋洋。愿这份热闹,延续一整年。",
|
||||
"十一之日,美酒佳肴。愿吃得开心,聊得尽兴。",
|
||||
"高朋满座,胜友如云。愿马年的日子,贵人常伴。",
|
||||
"正月十一,把酒言欢。愿烦恼抛诸脑后,快乐常驻心间。",
|
||||
"相聚的时光,总是短暂。愿珍惜当下,不负相遇。",
|
||||
"宴请亲朋,共庆新春。正月十一,吉祥如意。",
|
||||
|
||||
# 情谊绵长篇
|
||||
"正月十一,情谊绵长。愿亲情、友情,如陈年老酒,越久越香。",
|
||||
"岁月流转,情谊不变。愿每一次相聚,都温暖如初。",
|
||||
"子婿之日,亲情浓。愿家人闲坐,灯火可亲。",
|
||||
"朋友相聚,友情深。愿高山流水,知音常在。",
|
||||
"情谊是冬日的暖阳,是夏日的清风。正月十一,感恩相遇。",
|
||||
"愿这份情谊,如骏马奔腾,跨越山海,永不褪色。",
|
||||
"正月十一,愿所有的感情,都能被温柔以待。",
|
||||
"相聚是缘,相守是福。愿情谊长存,岁月静好。",
|
||||
"把酒言欢,共叙情谊。愿马年的每一天,都有朋友相伴。",
|
||||
"十一之日,愿情谊之花,常开不败。",
|
||||
|
||||
# 余庆延续篇
|
||||
"年味未减,余庆延续。正月十一,依然喜气洋洋。",
|
||||
"春节的热闹,还在继续。愿快乐不减,福气依旧。",
|
||||
"正月十一,新年的余温尚在。愿好运连连,幸福满满。",
|
||||
"年虽过半,味仍浓。愿马年的日子,依然红红火火。",
|
||||
"余庆绵绵,福泽深厚。正月十一,万事顺遂。",
|
||||
"新年的脚步虽远,祝福的心意未减。",
|
||||
"正月十一,愿这份喜气,伴你左右。",
|
||||
"年味渐淡,情意更浓。愿每一次相聚,都值得珍藏。",
|
||||
"十一之日,愿新年的好运,继续加持。",
|
||||
"余庆延续,马年大吉。愿未来的日子,充满阳光。",
|
||||
|
||||
# 短句补充篇
|
||||
"正月十一,欢聚一堂。",
|
||||
"情谊绵长,温暖相伴。",
|
||||
"子婿之日,喜气洋洋。",
|
||||
"把酒言欢,共话美好。",
|
||||
"余庆延续,福满人间。",
|
||||
"高朋满座,胜友如云。",
|
||||
"十一吉祥,马年安康。",
|
||||
"相聚是缘,相守是福。",
|
||||
"年味依旧,快乐不减。",
|
||||
"宴请亲朋,共庆新春。"
|
||||
],
|
||||
|
||||
# ==============================================
|
||||
# 2026年2月28日 正月十二 (搭灯棚/备元宵)
|
||||
# 核心:预热、光明、期待、筹备
|
||||
# ==============================================
|
||||
date(2026, 2, 28): [
|
||||
# 搭棚迎灯篇
|
||||
"正月十二,搭灯棚。为即将到来的元宵,点亮希望。",
|
||||
"灯棚初搭,喜气初临。愿光明将至,幸福将至。",
|
||||
"正月十二,张灯结彩。愿大街小巷,充满节日的气氛。",
|
||||
"搭起灯棚,点亮心灯。愿2026,前途一片光明。",
|
||||
"十二之日,筹备元宵。愿所有的期待,都如期而至。",
|
||||
"灯棚高高挂,福气进门来。正月十二,吉祥如意。",
|
||||
"红红火火搭灯棚,热热闹闹迎元宵。",
|
||||
"正月十二,愿这一盏盏灯,照亮前行的路。",
|
||||
"搭灯棚,纳吉祥。愿马年的夜晚,不再黑暗。",
|
||||
"十二这一天,为团圆做准备。愿日子红红火火。",
|
||||
|
||||
# 元宵预热篇
|
||||
"年味未消,元宵将至。正月十二,期待满满。",
|
||||
"倒计时三天,元宵佳节即将登场。愿快乐加倍。",
|
||||
"正月十二,心向元宵。愿那一碗汤圆,甜进心里。",
|
||||
"春节的尾声,元宵的序曲。十二这一天,承上启下。",
|
||||
"期待那一夜的灯火,期待那一碗的香甜。",
|
||||
"正月十二,愿所有的美好,都在元宵之夜绽放。",
|
||||
"预热元宵,福气先行。愿2026,圆圆满满。",
|
||||
"十二之日,许下心愿:愿元宵之夜,月圆人圆。",
|
||||
"春节的热闹还在,元宵的期待已来。",
|
||||
"正月十二,愿这份期待,化作美好的现实。",
|
||||
|
||||
# 光明希望篇
|
||||
"正月十二,点亮心灯。愿心中有光,脚下有路。",
|
||||
"灯象征着希望。愿2026,如明灯指引,一路向前。",
|
||||
"十二之日,愿光明驱散黑暗,希望战胜绝望。",
|
||||
"心有明灯,不惧黑暗。愿马年的每一天,都充满阳光。",
|
||||
"搭灯棚,迎光明。愿前程似锦,未来可期。",
|
||||
"正月十二,愿这世间,灯火通明,温暖常在。",
|
||||
"光明将至,幸福随行。愿马年的夜晚,星光璀璨。",
|
||||
"点亮一盏灯,照亮一片天。十二这一天,充满希望。",
|
||||
"愿心灯长明,愿福运长伴。",
|
||||
"正月十二,愿如骏马,向着光明,飞驰而去。",
|
||||
|
||||
# 短句补充篇
|
||||
"正月十二,搭灯迎福。",
|
||||
"元宵预热,期待满满。",
|
||||
"心有明灯,前途光明。",
|
||||
"张灯结彩,喜气洋洋。",
|
||||
"十二吉祥,圆圆满满。",
|
||||
"筹备元宵,福气先行。",
|
||||
"灯火可亲,未来可期。",
|
||||
"马年十二,光明将至。",
|
||||
"搭起灯棚,点亮希望。",
|
||||
"喜迎元宵,万事顺遂。"
|
||||
],
|
||||
|
||||
# ==============================================
|
||||
# 2026年3月1日 正月十三 (试灯/赏花灯)
|
||||
# 核心:试灯、璀璨、浪漫、初亮
|
||||
# ==============================================
|
||||
date(2026, 3, 1): [
|
||||
# 试灯初亮篇
|
||||
"正月十三,试灯初亮。愿这一抹光,温暖整个春天。",
|
||||
"灯火试明,幸福先行。愿元宵之夜,璀璨夺目。",
|
||||
"十三试灯,点亮街头。愿这世界,五彩斑斓。",
|
||||
"试灯的夜晚,星光与灯光交相辉映。",
|
||||
"正月十三,灯火初上。愿这光亮,照亮心底的梦想。",
|
||||
"试灯迎元宵,喜气满人间。愿2026,光彩照人。",
|
||||
"十三之日,灯火璀璨。愿每一盏灯,都藏着美好祝福。",
|
||||
"试灯啦!愿马年的夜晚,不再孤单。",
|
||||
"正月十三,愿灯光驱散寒意,带来温暖。",
|
||||
"灯火初亮,希望初升。愿未来的日子,一片光明。",
|
||||
|
||||
# 璀璨浪漫篇
|
||||
"正月十三,灯火璀璨。愿生活如灯,五彩斑斓。",
|
||||
"试灯之夜,浪漫无边。愿遇见美好,遇见爱。",
|
||||
"灯火万家城四畔,星河一道水中央。十三之夜,美不胜收。",
|
||||
"正月十三,愿这璀璨的灯火,照亮浪漫的人生。",
|
||||
"灯光摇曳,人影婆娑。愿这一夜,温柔又美好。",
|
||||
"十三试灯,愿所有的浪漫,都恰逢其时。",
|
||||
"璀璨灯火,映照笑脸。愿马年的每一天,都灿烂如花。",
|
||||
"正月十三,愿在灯火阑珊处,遇见那个对的人。",
|
||||
"浪漫之夜,灯火可亲。愿幸福绵长,岁月静好。",
|
||||
"试灯初亮,浪漫开场。愿2026,不负韶华。",
|
||||
|
||||
# 期盼圆满篇
|
||||
"正月十三,期盼圆满。愿元宵之夜,月圆人圆事事圆。",
|
||||
"试灯是序曲,元宵是高潮。愿精彩值得等待。",
|
||||
"十三这一天,为圆满做最后的准备。",
|
||||
"期盼那一碗汤圆,期盼那一夜团圆。",
|
||||
"正月十三,愿所有的等待,都不负期待。",
|
||||
"试灯之时,许下心愿:愿2026,圆圆满满。",
|
||||
"期盼元宵,期盼美好。愿马年的第一个月圆,圆满无缺。",
|
||||
"正月十三,愿这份期盼,化作甜蜜的果实。",
|
||||
"灯火试明,圆满将至。愿幸福如约而至。",
|
||||
"十三之日,愿所有的梦想,都圆满落地。",
|
||||
|
||||
# 短句补充篇
|
||||
"正月十三,试灯纳福。",
|
||||
"灯火璀璨,浪漫无边。",
|
||||
"试灯初亮,希望在前。",
|
||||
"十三吉祥,圆满可期。",
|
||||
"璀璨灯火,照亮前程。",
|
||||
"喜迎元宵,万事胜意。",
|
||||
"试灯之夜,幸福相伴。",
|
||||
"马年十三,光彩照人。",
|
||||
"灯火可亲,岁月温柔。",
|
||||
"期盼圆满,福满人间。"
|
||||
],
|
||||
|
||||
# ==============================================
|
||||
# 2026年3月2日 正月十四 (元宵前夕/月色)
|
||||
# 核心:待圆、酝酿、惜别、倒数
|
||||
# ==============================================
|
||||
date(2026, 3, 2): [
|
||||
# 待圆酝酿篇
|
||||
"正月十四,待圆之时。所有的美好,都在悄然酝酿。",
|
||||
"月圆前夜,幸福将至。愿明天的团圆,圆满无缺。",
|
||||
"十四这一天,静候月圆。愿所有的期待,都开花结果。",
|
||||
"美好在酝酿,幸福在靠近。正月十四,满怀希望。",
|
||||
"元宵前夕,蓄势待发。愿明天的烟火,惊艳时光。",
|
||||
"十四之夜,月色渐浓。愿这温柔的夜,孕育美好的明天。",
|
||||
"待圆之日,心怀期盼。愿2026,事事圆满。",
|
||||
"正月十四,愿所有的遗憾,都在明天圆满。",
|
||||
"酝酿已久的幸福,即将在明天绽放。",
|
||||
"十四这一天,愿耐心等待,收获圆满。",
|
||||
|
||||
# 月色温柔篇
|
||||
"正月十四,月色温柔。愿这一夜的月光,照亮心底的柔软。",
|
||||
"月圆前夜,月色撩人。愿这温柔的光,伴你入梦。",
|
||||
"十四之夜,月光如水。愿岁月静好,现世安稳。",
|
||||
"月色朦胧,情意绵绵。愿这一夜,浪漫又安宁。",
|
||||
"正月十四,愿月光指引,找到回家的路。",
|
||||
"月光洒在身上,幸福藏在心里。",
|
||||
"十四的月亮,虽未圆满,却已温柔。",
|
||||
"愿这月色,洗去一身疲惫,带来满心欢喜。",
|
||||
"正月十四,月色相伴,幸福相随。",
|
||||
"月光所至,皆是美好。愿马年的夜晚,月色常明。",
|
||||
|
||||
# 惜别新春篇
|
||||
"正月十四,惜别新春。愿这份年味,永驻心间。",
|
||||
"春节的最后倒计时,珍惜最后的热闹。",
|
||||
"十四这一天,是春节的尾声,也是元宵的序曲。",
|
||||
"惜别新春,迎接元宵。愿美好延续,幸福长存。",
|
||||
"正月十四,愿抓住春节的尾巴,再快乐一次。",
|
||||
"年味渐淡,情意更浓。愿这份祝福,伴你一整年。",
|
||||
"惜别旧岁的热闹,迎接新年的安稳。",
|
||||
"十四之日,愿感恩相遇,珍惜拥有。",
|
||||
"新春将过,记忆永存。愿2026,温暖常在。",
|
||||
"正月十四,愿不负新春,不负韶华。",
|
||||
|
||||
# 短句补充篇
|
||||
"正月十四,静待月圆。",
|
||||
"月色温柔,幸福将至。",
|
||||
"元宵前夕,蓄势待发。",
|
||||
"惜别新春,迎接圆满。",
|
||||
"十四吉祥,万事顺遂。",
|
||||
"美好酝酿,幸福花开。",
|
||||
"月色撩人,情意绵绵。",
|
||||
"马年十四,期待满满。",
|
||||
"静待花开,如愿以偿。",
|
||||
"福暖元宵,圆满在即。"
|
||||
],
|
||||
|
||||
# ==============================================
|
||||
# 2026年3月3日 正月十五 (元宵节/上元节)
|
||||
# 核心:团圆、圆满、灯火、收官
|
||||
# ==============================================
|
||||
date(2026, 3, 3): [
|
||||
# 圆满团圆篇
|
||||
"正月十五,元宵佳节。愿月圆人圆,事事圆满。",
|
||||
"上元之夜,万家团圆。愿这一轮明月,照亮每一个归人。",
|
||||
"灯火良宵,鱼龙百戏。愿今宵团圆,岁岁长安。",
|
||||
"一碗汤圆,一份团圆。愿生活软糯香甜,日子圆圆满满。",
|
||||
"正月十五,月光所至,皆是团圆。",
|
||||
"闹元宵,庆团圆。愿所有的思念,都能奔赴相见。",
|
||||
"马年第一个月圆夜,愿美好与圆满撞个满怀。",
|
||||
"花好月圆人团圆,福满乾坤春满园。",
|
||||
"元宵佳节,愿天涯共此时,千里共婵娟。",
|
||||
"圆满收官,幸福续航。愿这份团圆,延续一整年。",
|
||||
|
||||
# 灯火璀璨篇
|
||||
"东风夜放花千树,更吹落,星如雨。上元灯火,璀璨人间。",
|
||||
"正月十五,花灯如昼。愿这漫天灯火,照亮前行的路。",
|
||||
"灯火万家,良辰美景。愿身处璀璨,心向光明。",
|
||||
"赏花灯,猜灯谜。愿元宵之夜,热闹非凡,喜乐无边。",
|
||||
"灯火阑珊处,美好正发生。愿你遇见惊喜,遇见幸运。",
|
||||
"上元灯火,映照笑脸。愿2026,光彩夺目,熠熠生辉。",
|
||||
"今夜灯明,如昼如幻。愿马年的日子,红红火火。",
|
||||
"一盏花灯,一份祈愿。愿心之所向,光亮通达。",
|
||||
"烟花绽放,灯火璀璨。愿这一刻的美好,定格成永恒。",
|
||||
"元宵夜,看花灯。愿生活如灯,五彩斑斓,充满希望。",
|
||||
|
||||
# 喜乐民俗篇
|
||||
"正月十五闹元宵,锣鼓喧天春意闹。愿欢声笑语,响彻云霄。",
|
||||
"吃汤圆,闹元宵。愿团团圆圆,甜甜蜜蜜。",
|
||||
"猜灯谜,赢好礼。愿智慧与福气,双双入怀。",
|
||||
"舞龙舞狮,锣鼓喧天。愿马年的运势,气势如虹。",
|
||||
"踩高跷,划旱船。愿民间喜乐,岁岁相传。",
|
||||
"元宵佳节,宜欢聚,宜赏灯,宜纳福。",
|
||||
"捏个汤圆,团团圆圆;挂盏灯笼,亮亮堂堂。",
|
||||
"上元祈福,百无禁忌。愿所求皆如愿,所行皆坦途。",
|
||||
"闹元宵,迎福气。愿2026,人气旺,财气旺,运气旺。",
|
||||
"传统民俗,热闹元宵。愿文化传承,岁月流芳。",
|
||||
|
||||
# 新春收官篇
|
||||
"正月十五,新春收官。感谢相遇,期待同行。",
|
||||
"年味虽淡,情意不减。元宵一过,整装出发。",
|
||||
"春节的最后一场狂欢,愿不留遗憾,尽兴而归。",
|
||||
"圆满收官,奔赴新程。愿马年的下半场,更加精彩。",
|
||||
"以元宵的圆满,开启全年的顺遂。",
|
||||
"告别新春,迎接春天。愿万物复苏,梦想发芽。",
|
||||
"正月十五,为春节画上一个完美的句号。",
|
||||
"收官之夜,许下宏愿。愿2026,马力全开,一往无前。",
|
||||
"新春已过,奋斗在即。愿不负春光,不负自己。",
|
||||
"元宵圆满,万事胜意。愿这一年,步履不停,收获满满。",
|
||||
|
||||
# 短句补充篇
|
||||
"元宵快乐,圆满吉祥。",
|
||||
"花好月圆,喜乐安康。",
|
||||
"灯火万家,幸福中华。",
|
||||
"上元佳节,福满人间。",
|
||||
"汤圆甜甜,日子圆圆。",
|
||||
"闹元宵,迎好运。",
|
||||
"马年元宵,圆满收官。",
|
||||
"花灯璀璨,前程似锦。",
|
||||
"月圆人圆,事事圆满。",
|
||||
"正月十五,万事亨通。"
|
||||
]
|
||||
}
|
||||
|
||||
lunar_calendar = {
|
||||
date(2026, 2, 16): "除夕",
|
||||
date(2026, 2, 17): "正月初一",
|
||||
date(2026, 2, 18): "正月初二",
|
||||
date(2026, 2, 19): "正月初三",
|
||||
date(2026, 2, 20): "正月初四",
|
||||
date(2026, 2, 21): "正月初五",
|
||||
date(2026, 2, 22): "正月初六",
|
||||
date(2026, 2, 23): "正月初七",
|
||||
date(2026, 2, 24): "正月初八",
|
||||
date(2026, 2, 25): "正月初九",
|
||||
date(2026, 2, 26): "正月初十",
|
||||
date(2026, 2, 27): "正月十一",
|
||||
date(2026, 2, 28): "正月十二",
|
||||
date(2026, 3, 1): "正月十三",
|
||||
date(2026, 3, 2): "正月十四",
|
||||
date(2026, 3, 3): "正月十五"
|
||||
}
|
||||
|
||||
def get_lunar_date(gregorian_date):
|
||||
"""
|
||||
根据公历日期获取对应的农历日期
|
||||
参数:gregorian_date - datetime.date 对象
|
||||
返回:str - 农历日期字符串;None - 日期不在农历范围内
|
||||
"""
|
||||
return lunar_calendar.get(gregorian_date, None)
|
||||
|
||||
def get_random_festival_quote():
|
||||
"""
|
||||
根据当前日期从 SPRING_FESTIVAL_QUOTES 中随机获取一条祝福语
|
||||
返回:str - 随机选中的祝福语;None - 当前日期无对应祝福语
|
||||
"""
|
||||
# 获取当前系统日期(年-月-日)
|
||||
today = date.today()
|
||||
|
||||
# 1. 检查当前日期是否在祝福语字典中
|
||||
if today in SPRING_FESTIVAL_QUOTES:
|
||||
# 2. 获取当日的所有祝福语列表
|
||||
daily_quotes = SPRING_FESTIVAL_QUOTES[today]
|
||||
# 3. 随机选择一条祝福语
|
||||
random_quote = random.choice(daily_quotes)
|
||||
return random_quote
|
||||
else:
|
||||
# 若当前日期无对应祝福语,返回提示(也可改为返回None)
|
||||
return f"今日({today.strftime('%Y年%m月%d日')})暂无专属春节祝福语"
|
||||
|
||||
# 测试调用示例
|
||||
if __name__ == "__main__":
|
||||
quote = get_random_festival_quote()
|
||||
print(quote)
|
||||
260
DouYinSparkFlow/utils/config.py
Normal file
@@ -0,0 +1,260 @@
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import secrets
|
||||
import sys
|
||||
from copy import deepcopy
|
||||
from enum import Enum
|
||||
from pathlib import Path
|
||||
|
||||
from utils.logger import setup_logger
|
||||
|
||||
|
||||
logger = setup_logger(level=logging.DEBUG)
|
||||
|
||||
DEBUG = False
|
||||
CONFIGFILE = "config.json"
|
||||
USERDATAFILE = "usersData.json"
|
||||
APPSETTINGSFILE = "webui_settings.json"
|
||||
|
||||
DEFAULT_CONFIG = {
|
||||
"multiTask": True,
|
||||
"taskCount": 5,
|
||||
"proxyAddress": "",
|
||||
"messageTemplate": "【AI续火花】",
|
||||
"saveDebugArtifacts": False,
|
||||
"useProtocolSender": True,
|
||||
"protocolDryRun": False,
|
||||
"browserSenderAccounts": [],
|
||||
"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]",
|
||||
},
|
||||
}
|
||||
|
||||
DEFAULT_APP_SETTINGS = {
|
||||
"admin_username": "admin",
|
||||
"admin_password_hash": "",
|
||||
"session_secret": "",
|
||||
"session_max_age_seconds": 8 * 60 * 60,
|
||||
"compose_root": "",
|
||||
"ui_host": "0.0.0.0",
|
||||
"ui_port": 8787,
|
||||
"login_poll_interval_seconds": 1,
|
||||
"ops_log_file": "/var/log/douyin-sparkflow.log",
|
||||
"proxy_refresh_script": "/opt/douyin-sparkflow/refresh_proxy.sh",
|
||||
"local_login_helper_url": "http://127.0.0.1:18765",
|
||||
"login_desktop_api_url": "http://127.0.0.1:18090",
|
||||
"server_host": "",
|
||||
"server_username": "",
|
||||
"server_password": "",
|
||||
}
|
||||
|
||||
config = None
|
||||
userData = None
|
||||
appSettings = None
|
||||
|
||||
|
||||
class Environment(Enum):
|
||||
GITHUBACTION = "GITHUB_ACTION"
|
||||
LOCAL = "LOCAL"
|
||||
PACKED = "PACKED"
|
||||
|
||||
def __str__(self):
|
||||
return self.value
|
||||
|
||||
|
||||
def get_environment():
|
||||
if getattr(sys, "frozen", False) and hasattr(sys, "_MEIPASS"):
|
||||
return Environment.PACKED
|
||||
if os.getenv("GITHUB_ACTIONS") == "true":
|
||||
return Environment.GITHUBACTION
|
||||
return Environment.LOCAL
|
||||
|
||||
|
||||
def repo_root():
|
||||
return Path(__file__).resolve().parents[1]
|
||||
|
||||
|
||||
def _runtime_root():
|
||||
env = get_environment()
|
||||
if env == Environment.PACKED:
|
||||
return Path(sys.executable).resolve().parent
|
||||
return repo_root()
|
||||
|
||||
|
||||
def config_path():
|
||||
return _runtime_root() / CONFIGFILE
|
||||
|
||||
|
||||
def users_data_path():
|
||||
return _runtime_root() / USERDATAFILE
|
||||
|
||||
|
||||
def app_settings_path():
|
||||
return _runtime_root() / APPSETTINGSFILE
|
||||
|
||||
|
||||
def default_compose_root():
|
||||
root = repo_root()
|
||||
parent = root.parent
|
||||
if (parent / "docker-compose.yml").exists():
|
||||
return str(parent)
|
||||
return str(root)
|
||||
|
||||
|
||||
def _merge_defaults(data, defaults):
|
||||
merged = deepcopy(defaults)
|
||||
for key, value in data.items():
|
||||
if key in merged and isinstance(merged[key], dict) and isinstance(value, dict):
|
||||
merged[key].update(value)
|
||||
else:
|
||||
merged[key] = value
|
||||
return merged
|
||||
|
||||
|
||||
def _load_json_file(path, defaults=None):
|
||||
if not path.exists():
|
||||
if defaults is None:
|
||||
raise FileNotFoundError(path)
|
||||
path.write_text(json.dumps(defaults, ensure_ascii=False, indent=2), encoding="utf-8")
|
||||
return deepcopy(defaults)
|
||||
|
||||
text = path.read_text(encoding="utf-8")
|
||||
if not text.strip():
|
||||
return deepcopy(defaults) if defaults is not None else None
|
||||
|
||||
data = json.loads(text)
|
||||
if defaults is None:
|
||||
return data
|
||||
# _merge_defaults only works with dicts; for list-shaped data
|
||||
# (e.g. usersData.json) just return the parsed data as-is.
|
||||
if not isinstance(data, dict) or not isinstance(defaults, dict):
|
||||
return data
|
||||
return _merge_defaults(data, defaults)
|
||||
|
||||
|
||||
def _save_json_file(path, data):
|
||||
path.write_text(json.dumps(data, ensure_ascii=False, indent=2), encoding="utf-8")
|
||||
|
||||
|
||||
def get_config(force_reload=False):
|
||||
global config
|
||||
if config is None or force_reload:
|
||||
config = _load_json_file(config_path(), DEFAULT_CONFIG)
|
||||
return deepcopy(config)
|
||||
|
||||
|
||||
def save_config(new_config):
|
||||
global config
|
||||
config = _merge_defaults(new_config, DEFAULT_CONFIG)
|
||||
_save_json_file(config_path(), config)
|
||||
return deepcopy(config)
|
||||
|
||||
|
||||
def get_userData(force_reload=False):
|
||||
global userData
|
||||
if userData is not None and not force_reload:
|
||||
return deepcopy(userData)
|
||||
|
||||
env = get_environment()
|
||||
if env == Environment.GITHUBACTION:
|
||||
raw = os.getenv("USER_DATA", "")
|
||||
if not raw:
|
||||
logger.error("Environment variable USER_DATA is not set")
|
||||
raise RuntimeError("USER_DATA is required in GITHUB_ACTIONS mode")
|
||||
userData = json.loads(raw)
|
||||
else:
|
||||
userData = _load_json_file(users_data_path(), [])
|
||||
|
||||
return deepcopy(userData)
|
||||
|
||||
|
||||
def save_userData(accounts):
|
||||
global userData
|
||||
normalized = list(accounts)
|
||||
userData = normalized
|
||||
_save_json_file(users_data_path(), normalized)
|
||||
return deepcopy(userData)
|
||||
|
||||
|
||||
def normalize_unique_id(unique_id):
|
||||
if not unique_id:
|
||||
return ""
|
||||
digits = "".join(ch for ch in str(unique_id) if ch.isdigit())
|
||||
return digits or str(unique_id).strip()
|
||||
|
||||
|
||||
def upsert_user_account(unique_id, username, cookies, targets, extra=None):
|
||||
unique_id = normalize_unique_id(unique_id)
|
||||
accounts = get_userData(force_reload=True)
|
||||
payload = {
|
||||
"unique_id": unique_id,
|
||||
"username": username,
|
||||
"cookies": cookies,
|
||||
"targets": list(targets),
|
||||
}
|
||||
if extra:
|
||||
payload.update(extra)
|
||||
|
||||
for account in accounts:
|
||||
if normalize_unique_id(account.get("unique_id")) == unique_id:
|
||||
if "enabled" not in payload:
|
||||
payload["enabled"] = account.get("enabled", True)
|
||||
account.update(payload)
|
||||
save_userData(accounts)
|
||||
return payload
|
||||
|
||||
if "enabled" not in payload:
|
||||
payload["enabled"] = True
|
||||
accounts.append(payload)
|
||||
save_userData(accounts)
|
||||
return payload
|
||||
|
||||
|
||||
def delete_user_account(unique_id):
|
||||
normalized_id = normalize_unique_id(unique_id)
|
||||
accounts = get_userData(force_reload=True)
|
||||
remaining = [item for item in accounts if normalize_unique_id(item.get("unique_id")) != normalized_id]
|
||||
removed = len(accounts) != len(remaining)
|
||||
if removed:
|
||||
save_userData(remaining)
|
||||
return removed
|
||||
|
||||
|
||||
def get_app_settings(force_reload=False):
|
||||
global appSettings
|
||||
if appSettings is None or force_reload:
|
||||
appSettings = _load_json_file(app_settings_path(), DEFAULT_APP_SETTINGS)
|
||||
if not appSettings.get("session_secret"):
|
||||
appSettings["session_secret"] = secrets.token_urlsafe(32)
|
||||
if not appSettings.get("compose_root"):
|
||||
appSettings["compose_root"] = default_compose_root()
|
||||
_save_json_file(app_settings_path(), appSettings)
|
||||
return deepcopy(appSettings)
|
||||
|
||||
|
||||
def save_app_settings(new_settings):
|
||||
global appSettings
|
||||
appSettings = _merge_defaults(new_settings, DEFAULT_APP_SETTINGS)
|
||||
if not appSettings.get("session_secret"):
|
||||
appSettings["session_secret"] = secrets.token_urlsafe(32)
|
||||
if not appSettings.get("compose_root"):
|
||||
appSettings["compose_root"] = default_compose_root()
|
||||
_save_json_file(app_settings_path(), appSettings)
|
||||
return deepcopy(appSettings)
|
||||
49
DouYinSparkFlow/utils/github_action_config.py
Normal file
@@ -0,0 +1,49 @@
|
||||
import json
|
||||
from rich.console import Console
|
||||
from rich.panel import Panel
|
||||
from utils.config import get_config
|
||||
import pyperclip
|
||||
|
||||
config = get_config()
|
||||
|
||||
# 初始化 rich 控制台
|
||||
console = Console()
|
||||
|
||||
|
||||
def compress_users_data():
|
||||
# 压缩 usersData.json 内容
|
||||
with open("usersData.json", "r", encoding="utf-8") as f:
|
||||
user_data = json.loads(f.read())
|
||||
|
||||
return json.dumps(user_data, ensure_ascii=False)
|
||||
|
||||
|
||||
def print_github_action_config():
|
||||
"""
|
||||
打印 GitHub Action 配置表格
|
||||
"""
|
||||
|
||||
# 输出前置步骤说明
|
||||
steps = (
|
||||
"1. 确保已克隆仓库并在仓库的 [bold yellow]Action[/bold yellow] 选项卡下启用 "
|
||||
"[bold green]DouYin Spark Flow Schedule Run[/bold green]\n"
|
||||
"2. 在仓库的设置选项卡下的 [bold yellow]Environment[/bold yellow] 配置项中添加 "
|
||||
"[bold green]user-data[/bold green] 环境,并将下方列出 Secrets 依次添加到该环境的 Secrets 中"
|
||||
)
|
||||
console.print(Panel(steps, title="前置步骤", expand=False, style="bold cyan"))
|
||||
|
||||
secrets = {
|
||||
"USER_DATA": compress_users_data()
|
||||
}
|
||||
if "proxyAddress" in config and config["proxyAddress"]:
|
||||
secrets["proxyAddress"] = config["proxyAddress"]
|
||||
|
||||
# 打印每个键名和键值
|
||||
console.print("\n[bold yellow]Secrets 配置:选中后右击鼠标复制(没有弹出菜单点击鼠标右键就完成复制了!)[/bold yellow]")
|
||||
|
||||
for key, value in secrets.items():
|
||||
console.rule(f"[bold cyan]{key}[/bold cyan]")
|
||||
console.print(f"[green]{value}[/green]\n")
|
||||
|
||||
pyperclip.copy(secrets["USER_DATA"])
|
||||
console.print("[bold yellow]提示:[/bold yellow][bold magenta] USER_DATA 的值已自动写入剪贴板(建议直接粘贴,手动复制可能多出空白符导致出错) [/bold magenta]")
|
||||
48
DouYinSparkFlow/utils/hitokoto.py
Normal file
@@ -0,0 +1,48 @@
|
||||
import requests
|
||||
from utils.config import get_config
|
||||
|
||||
hitokotoApi = "https://v1.hitokoto.cn/"
|
||||
|
||||
allHitokotoTypes = {
|
||||
"动画": "a",
|
||||
"漫画": "b",
|
||||
"游戏": "c",
|
||||
"文学": "d",
|
||||
"原创": "e",
|
||||
"来自网络": "f",
|
||||
"其他": "g",
|
||||
"影视": "h",
|
||||
"诗词": "i",
|
||||
"哲学": "k",
|
||||
"抖机灵": "l",
|
||||
}
|
||||
|
||||
|
||||
def request_hitokoto():
|
||||
"""请求一言 API 获取一句话"""
|
||||
config = get_config()
|
||||
|
||||
api_url = hitokotoApi
|
||||
|
||||
for t in allHitokotoTypes.keys():
|
||||
if t in config["hitokotoTypes"]:
|
||||
if "?" not in api_url:
|
||||
api_url += "?"
|
||||
if "c=" in api_url:
|
||||
api_url += f"&c={allHitokotoTypes[t]}"
|
||||
else:
|
||||
api_url += f"c={allHitokotoTypes[t]}"
|
||||
|
||||
try:
|
||||
response = requests.get(api_url, timeout=10)
|
||||
response.raise_for_status()
|
||||
data = response.json()
|
||||
theFrom = data.get("from")
|
||||
if theFrom is None or theFrom.strip() == "":
|
||||
theFrom = "未知来源"
|
||||
theFromWho = data.get("from_who")
|
||||
if theFromWho is None or theFromWho.strip() == "":
|
||||
theFromWho = "未知作者"
|
||||
return f"{data['hitokoto']} —— {theFrom} ({theFromWho})"
|
||||
except Exception as e:
|
||||
return "[error] 无法获取一言内容"
|
||||
54
DouYinSparkFlow/utils/logger.py
Normal file
@@ -0,0 +1,54 @@
|
||||
import logging
|
||||
import os
|
||||
from logging.handlers import RotatingFileHandler
|
||||
|
||||
# 创建 logs 文件夹(如果不存在)
|
||||
if not os.path.exists("logs"):
|
||||
os.makedirs("logs")
|
||||
|
||||
# 日志格式
|
||||
LOG_FORMAT = "%(asctime)s - %(name)s - %(levelname)s - %(filename)s:%(lineno)d - %(message)s"
|
||||
|
||||
# 日志文件路径
|
||||
LOG_FILE = "logs/app.log"
|
||||
|
||||
# 配置日志
|
||||
def setup_logger(name="app", level=logging.INFO):
|
||||
"""
|
||||
配置日志记录器
|
||||
:param name: 日志记录器名称
|
||||
:param level: 日志级别
|
||||
:return: 配置好的日志记录器
|
||||
"""
|
||||
logger = logging.getLogger(name)
|
||||
logger.setLevel(level)
|
||||
|
||||
# 防止重复添加处理器
|
||||
if not logger.handlers:
|
||||
# 控制台日志处理器
|
||||
console_handler = logging.StreamHandler()
|
||||
console_handler.setLevel(level)
|
||||
console_formatter = logging.Formatter(LOG_FORMAT)
|
||||
console_handler.setFormatter(console_formatter)
|
||||
|
||||
# 文件日志处理器(带日志轮转)
|
||||
file_handler = RotatingFileHandler(LOG_FILE, maxBytes=5 * 1024 * 1024, backupCount=3, encoding="utf-8")
|
||||
file_handler.setLevel(level)
|
||||
file_formatter = logging.Formatter(LOG_FORMAT)
|
||||
file_handler.setFormatter(file_formatter)
|
||||
|
||||
# 添加处理器到日志记录器
|
||||
logger.addHandler(console_handler)
|
||||
logger.addHandler(file_handler)
|
||||
|
||||
return logger
|
||||
|
||||
|
||||
# 示例:使用日志记录器
|
||||
if __name__ == "__main__":
|
||||
logger = setup_logger(level=logging.DEBUG)
|
||||
logger.debug("这是一个调试信息")
|
||||
logger.info("这是一个普通信息")
|
||||
logger.warning("这是一个警告信息")
|
||||
logger.error("这是一个错误信息")
|
||||
logger.critical("这是一个严重错误信息")
|
||||
BIN
DouYinSparkFlow/vendor/webdeps/annotated_types-0.7.0-py3-none-any.whl
vendored
Normal file
BIN
DouYinSparkFlow/vendor/webdeps/anyio-4.13.0-py3-none-any.whl
vendored
Normal file
BIN
DouYinSparkFlow/vendor/webdeps/click-8.3.1-py3-none-any.whl
vendored
Normal file
BIN
DouYinSparkFlow/vendor/webdeps/colorama-0.4.6-py2.py3-none-any.whl
vendored
Normal file
BIN
DouYinSparkFlow/vendor/webdeps/fastapi-0.115.6-py3-none-any.whl
vendored
Normal file
BIN
DouYinSparkFlow/vendor/webdeps/h11-0.16.0-py3-none-any.whl
vendored
Normal file
BIN
DouYinSparkFlow/vendor/webdeps/idna-3.11-py3-none-any.whl
vendored
Normal file
BIN
DouYinSparkFlow/vendor/webdeps/itsdangerous-2.2.0-py3-none-any.whl
vendored
Normal file
BIN
DouYinSparkFlow/vendor/webdeps/jinja2-3.1.6-py3-none-any.whl
vendored
Normal file
BIN
DouYinSparkFlow/vendor/webdeps/pydantic-2.12.5-py3-none-any.whl
vendored
Normal file
1
DouYinSparkFlow/webui/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
# Web admin package for DouYin Spark Flow.
|
||||
646
DouYinSparkFlow/webui/app.py
Normal file
@@ -0,0 +1,646 @@
|
||||
import json
|
||||
import logging
|
||||
import traceback
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
|
||||
import uvicorn
|
||||
from fastapi import FastAPI, Request
|
||||
from fastapi.responses import FileResponse, HTMLResponse, JSONResponse, PlainTextResponse, RedirectResponse, Response
|
||||
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 utils.config import (
|
||||
get_app_settings,
|
||||
get_config,
|
||||
get_userData,
|
||||
normalize_unique_id,
|
||||
save_app_settings,
|
||||
save_config,
|
||||
save_userData,
|
||||
upsert_user_account,
|
||||
)
|
||||
from webui.auth import (
|
||||
bootstrap_admin_password,
|
||||
clear_session,
|
||||
csrf_token,
|
||||
current_user,
|
||||
is_bootstrapped,
|
||||
is_https_request,
|
||||
issue_session,
|
||||
update_admin_password,
|
||||
validate_csrf,
|
||||
verify_password,
|
||||
)
|
||||
from webui.ops import get_ops_snapshot, read_log_tail, refresh_proxy, restart_proxy, run_task_now, update_daily_schedule
|
||||
|
||||
|
||||
BASE_DIR = Path(__file__).resolve().parent
|
||||
TEMPLATES_DIR = BASE_DIR / "templates"
|
||||
STATIC_DIR = BASE_DIR / "static"
|
||||
DEBUG_ARTIFACTS_DIR = BASE_DIR.parent / "logs" / "debug_artifacts"
|
||||
templates = Jinja2Templates(directory=str(TEMPLATES_DIR))
|
||||
|
||||
|
||||
def _dedupe_targets(values):
|
||||
seen = set()
|
||||
result = []
|
||||
for value in values:
|
||||
normalized = str(value).strip()
|
||||
if not normalized or normalized in seen:
|
||||
continue
|
||||
seen.add(normalized)
|
||||
result.append(normalized)
|
||||
return result
|
||||
|
||||
|
||||
def _split_target_entries(values):
|
||||
expanded = []
|
||||
for value in values:
|
||||
raw = str(value).replace(",", "\n")
|
||||
expanded.extend(raw.splitlines())
|
||||
return _dedupe_targets(expanded)
|
||||
|
||||
|
||||
def extract_targets_from_form(form):
|
||||
if hasattr(form, "getlist"):
|
||||
checkbox_targets = _split_target_entries(form.getlist("targets"))
|
||||
if checkbox_targets:
|
||||
return checkbox_targets
|
||||
raw_targets = str(form.get("targets", ""))
|
||||
return _split_target_entries([raw_targets])
|
||||
|
||||
|
||||
def find_account(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
|
||||
|
||||
|
||||
def is_account_enabled(account):
|
||||
return bool(account.get("enabled", True))
|
||||
|
||||
|
||||
def coerce_int(value, default, minimum=0):
|
||||
try:
|
||||
return max(minimum, int(str(value).strip()))
|
||||
except (TypeError, ValueError):
|
||||
return max(minimum, int(default))
|
||||
|
||||
|
||||
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("/")
|
||||
|
||||
|
||||
def login_desktop_public_url(request: Request) -> str:
|
||||
host = request.url.hostname or "127.0.0.1"
|
||||
scheme = request.url.scheme or "http"
|
||||
return f"{scheme}://{host}:8788/vnc.html?autoconnect=1&resize=scale&view_only=0"
|
||||
|
||||
|
||||
def call_login_desktop(path: str, *, method: str = "GET", payload: dict | None = None, timeout: int = 20) -> dict:
|
||||
url = f"{login_desktop_api_url()}{path}"
|
||||
data = None
|
||||
headers = {}
|
||||
if payload is not None:
|
||||
data = json.dumps(payload, ensure_ascii=False).encode("utf-8")
|
||||
headers["Content-Type"] = "application/json; charset=utf-8"
|
||||
request = urllib.request.Request(url, method=method, data=data, headers=headers)
|
||||
try:
|
||||
with urllib.request.urlopen(request, timeout=timeout) as response:
|
||||
body = response.read().decode("utf-8", errors="replace")
|
||||
return json.loads(body) if body.strip() else {}
|
||||
except urllib.error.HTTPError as exc:
|
||||
body = exc.read().decode("utf-8", errors="replace")
|
||||
raise RuntimeError(f"login-desktop API error {exc.code}: {body}") from exc
|
||||
except urllib.error.URLError as exc:
|
||||
raise RuntimeError(f"login-desktop unavailable: {exc.reason}") from exc
|
||||
|
||||
|
||||
def save_exported_login_result(login_result: dict, *, relogin_unique_id: str = "", display_name: str = "") -> tuple[dict, str]:
|
||||
unique_id = normalize_unique_id(login_result.get("unique_id"))
|
||||
username = str(display_name or login_result.get("username") or "").strip()
|
||||
cookies = list(login_result.get("cookies") or [])
|
||||
if not unique_id or not username or not cookies:
|
||||
raise RuntimeError("Exported login result is incomplete")
|
||||
|
||||
accounts = get_userData(force_reload=True)
|
||||
|
||||
if relogin_unique_id:
|
||||
target = find_account(accounts, relogin_unique_id)
|
||||
if not target:
|
||||
raise RuntimeError("Target account not found for relogin")
|
||||
target["unique_id"] = unique_id
|
||||
target["username"] = username
|
||||
target["cookies"] = cookies
|
||||
target.setdefault("enabled", True)
|
||||
save_userData(accounts)
|
||||
return target, "updated"
|
||||
|
||||
existing = find_account(accounts, unique_id)
|
||||
if existing:
|
||||
existing["username"] = username
|
||||
existing["cookies"] = cookies
|
||||
existing.setdefault("enabled", True)
|
||||
save_userData(accounts)
|
||||
return existing, "updated"
|
||||
|
||||
account = upsert_user_account(unique_id, username, cookies, [])
|
||||
return account, "created"
|
||||
|
||||
|
||||
def create_app():
|
||||
settings = get_app_settings()
|
||||
app = FastAPI(title="DouYin Spark Flow Admin")
|
||||
app.add_middleware(
|
||||
SessionMiddleware,
|
||||
secret_key=settings["session_secret"],
|
||||
max_age=settings["session_max_age_seconds"],
|
||||
same_site="lax",
|
||||
https_only=False,
|
||||
)
|
||||
app.mount("/static", StaticFiles(directory=str(STATIC_DIR)), name="static")
|
||||
DEBUG_ARTIFACTS_DIR.mkdir(parents=True, exist_ok=True)
|
||||
app.mount("/debug-artifacts", StaticFiles(directory=str(DEBUG_ARTIFACTS_DIR)), name="debug-artifacts")
|
||||
|
||||
@app.exception_handler(Exception)
|
||||
async def global_exception_handler(request: Request, exc: Exception):
|
||||
tb = traceback.format_exception(type(exc), exc, exc.__traceback__)
|
||||
tb_text = "".join(tb)
|
||||
logger.error("Unhandled exception on %s %s:\n%s", request.method, request.url.path, tb_text)
|
||||
return PlainTextResponse(f"Internal Server Error\n\n{tb_text}", status_code=500)
|
||||
|
||||
def render_template(request, template_name, context=None, status_code=200):
|
||||
base_context = context or {}
|
||||
base_context.update(
|
||||
{
|
||||
"request": request,
|
||||
"current_user": current_user(request),
|
||||
"csrf_token": csrf_token(request) if current_user(request) else "",
|
||||
"is_https": is_https_request(request),
|
||||
"app_settings": get_app_settings(force_reload=True),
|
||||
"login_desktop_public_url": login_desktop_public_url(request),
|
||||
}
|
||||
)
|
||||
return templates.TemplateResponse(request, template_name, base_context, status_code=status_code)
|
||||
|
||||
def redirect(path="/", status_code=303):
|
||||
return RedirectResponse(url=path, status_code=status_code)
|
||||
|
||||
def require_user(request):
|
||||
if not current_user(request):
|
||||
return redirect("/login")
|
||||
return None
|
||||
|
||||
def flash(request, message, level="info"):
|
||||
request.session["flash"] = {"message": message, "level": level}
|
||||
|
||||
def pop_flash(request):
|
||||
return request.session.pop("flash", None)
|
||||
|
||||
@app.get("/login", response_class=HTMLResponse)
|
||||
async def login_page(request: Request):
|
||||
if current_user(request):
|
||||
return redirect("/")
|
||||
return render_template(
|
||||
request,
|
||||
"login.html",
|
||||
{
|
||||
"flash": pop_flash(request),
|
||||
"bootstrapped": is_bootstrapped(),
|
||||
},
|
||||
)
|
||||
|
||||
@app.post("/bootstrap")
|
||||
async def bootstrap(request: Request):
|
||||
if is_bootstrapped():
|
||||
flash(request, "Admin login is already configured.", "warning")
|
||||
return redirect("/login")
|
||||
|
||||
form = await request.form()
|
||||
username = str(form.get("username", "admin")).strip() or "admin"
|
||||
password = str(form.get("password", ""))
|
||||
confirm = str(form.get("confirm_password", ""))
|
||||
if not password or password != confirm:
|
||||
flash(request, "Password setup failed. Please enter matching passwords.", "error")
|
||||
return redirect("/login")
|
||||
|
||||
bootstrap_admin_password(password, username=username)
|
||||
flash(request, "Admin credentials created. Please log in.", "success")
|
||||
return redirect("/login")
|
||||
|
||||
@app.post("/login")
|
||||
async def login_action(request: Request):
|
||||
if not is_bootstrapped():
|
||||
flash(request, "Create the admin password first.", "warning")
|
||||
return redirect("/login")
|
||||
|
||||
form = await request.form()
|
||||
username = str(form.get("username", "")).strip()
|
||||
password = str(form.get("password", ""))
|
||||
settings = get_app_settings(force_reload=True)
|
||||
if username != settings["admin_username"] or not verify_password(password, settings["admin_password_hash"]):
|
||||
flash(request, "Invalid username or password.", "error")
|
||||
return redirect("/login")
|
||||
|
||||
issue_session(request, username)
|
||||
flash(request, "Signed in successfully.", "success")
|
||||
return redirect("/")
|
||||
|
||||
@app.post("/logout")
|
||||
async def logout_action(request: Request):
|
||||
clear_session(request)
|
||||
return redirect("/login")
|
||||
|
||||
@app.get("/", response_class=HTMLResponse)
|
||||
async def dashboard(request: Request):
|
||||
maybe_redirect = require_user(request)
|
||||
if maybe_redirect:
|
||||
return maybe_redirect
|
||||
|
||||
return render_template(
|
||||
request,
|
||||
"dashboard.html",
|
||||
{
|
||||
"flash": pop_flash(request),
|
||||
"accounts": get_userData(force_reload=True),
|
||||
"runtime_config": get_config(force_reload=True),
|
||||
"ops": get_ops_snapshot(),
|
||||
},
|
||||
)
|
||||
|
||||
@app.post("/accounts/{unique_id}/update")
|
||||
async def update_account(request: Request, unique_id: str):
|
||||
maybe_redirect = require_user(request)
|
||||
if maybe_redirect:
|
||||
return maybe_redirect
|
||||
|
||||
form = await request.form()
|
||||
if not validate_csrf(request, str(form.get("csrf_token", ""))):
|
||||
return Response("Invalid CSRF token", status_code=403)
|
||||
|
||||
username = str(form.get("username", "")).strip()
|
||||
targets = extract_targets_from_form(form)
|
||||
|
||||
accounts = get_userData(force_reload=True)
|
||||
account = find_account(accounts, unique_id)
|
||||
if account:
|
||||
account["username"] = username or account.get("username", "")
|
||||
account["targets"] = targets
|
||||
account["enabled"] = str(form.get("enabled", "")) == "on"
|
||||
save_userData(accounts)
|
||||
flash(request, f"Updated account {account['username']}.", "success")
|
||||
else:
|
||||
flash(request, "Account not found.", "error")
|
||||
|
||||
return redirect("/")
|
||||
|
||||
@app.post("/accounts/{unique_id}/toggle-enabled")
|
||||
async def toggle_account_enabled(request: Request, unique_id: str):
|
||||
maybe_redirect = require_user(request)
|
||||
if maybe_redirect:
|
||||
return maybe_redirect
|
||||
|
||||
form = await request.form()
|
||||
if not validate_csrf(request, str(form.get("csrf_token", ""))):
|
||||
return Response("Invalid CSRF token", status_code=403)
|
||||
|
||||
accounts = get_userData(force_reload=True)
|
||||
account = find_account(accounts, unique_id)
|
||||
if not account:
|
||||
flash(request, "Account not found.", "error")
|
||||
return redirect("/")
|
||||
|
||||
account["enabled"] = not is_account_enabled(account)
|
||||
save_userData(accounts)
|
||||
flash(
|
||||
request,
|
||||
f"{account.get('username', 'Account')} 已{'启用' if account['enabled'] else '停用'}自动续火花。",
|
||||
"success",
|
||||
)
|
||||
return redirect("/")
|
||||
|
||||
@app.post("/accounts/{unique_id}/friends/refresh")
|
||||
async def refresh_account_friend_list(request: Request, unique_id: str):
|
||||
maybe_redirect = require_user(request)
|
||||
if maybe_redirect:
|
||||
return JSONResponse({"error": "Unauthorized"}, status_code=401)
|
||||
|
||||
form = await request.form()
|
||||
if not validate_csrf(request, str(form.get("csrf_token", ""))):
|
||||
return JSONResponse({"error": "Invalid CSRF token"}, status_code=403)
|
||||
|
||||
accounts = get_userData(force_reload=True)
|
||||
account = find_account(accounts, unique_id)
|
||||
if not account:
|
||||
return JSONResponse({"error": "Account not found."}, status_code=404)
|
||||
|
||||
try:
|
||||
friends = await fetch_account_friends(account)
|
||||
account["friends_cache"] = friends
|
||||
account["friends_cache_updated_at"] = datetime.now().isoformat(timespec="seconds")
|
||||
save_userData(accounts)
|
||||
return JSONResponse(
|
||||
{
|
||||
"friends": friends,
|
||||
"updated_at": account["friends_cache_updated_at"],
|
||||
"message": f"已刷新 {len(friends)} 个好友",
|
||||
}
|
||||
)
|
||||
except RuntimeError as exc:
|
||||
return JSONResponse({"error": str(exc)}, status_code=400)
|
||||
|
||||
@app.post("/accounts/{unique_id}/delete")
|
||||
async def delete_account(request: Request, unique_id: str):
|
||||
maybe_redirect = require_user(request)
|
||||
if maybe_redirect:
|
||||
return maybe_redirect
|
||||
|
||||
form = await request.form()
|
||||
if not validate_csrf(request, str(form.get("csrf_token", ""))):
|
||||
return Response("Invalid CSRF token", status_code=403)
|
||||
|
||||
accounts = get_userData(force_reload=True)
|
||||
updated_accounts = [item for item in accounts if normalize_unique_id(item.get("unique_id")) != normalize_unique_id(unique_id)]
|
||||
if len(updated_accounts) != len(accounts):
|
||||
save_userData(updated_accounts)
|
||||
flash(request, "Account deleted.", "success")
|
||||
else:
|
||||
flash(request, "Account not found.", "error")
|
||||
return redirect("/")
|
||||
|
||||
@app.post("/config")
|
||||
async def save_runtime_config(request: Request):
|
||||
maybe_redirect = require_user(request)
|
||||
if maybe_redirect:
|
||||
return maybe_redirect
|
||||
|
||||
form = await request.form()
|
||||
if not validate_csrf(request, str(form.get("csrf_token", ""))):
|
||||
return Response("Invalid CSRF token", status_code=403)
|
||||
|
||||
config = get_config(force_reload=True)
|
||||
if "messageTemplate" in form:
|
||||
config["messageTemplate"] = str(form.get("messageTemplate", config.get("messageTemplate", "")))
|
||||
if "multiTask" in form:
|
||||
config["multiTask"] = str(form.get("multiTask", "")) == "on"
|
||||
if "taskCount" in form:
|
||||
config["taskCount"] = coerce_int(form.get("taskCount", config.get("taskCount", 1)), config.get("taskCount", 1), 1)
|
||||
if "hitokotoTypes" in form:
|
||||
raw_types = str(form.get("hitokotoTypes", ""))
|
||||
config["hitokotoTypes"] = [item.strip() for item in raw_types.replace(",", "\n").splitlines() if item.strip()]
|
||||
|
||||
send_strategy = config.get("sendStrategy", {}) or {}
|
||||
if "shuffleTargets" in form:
|
||||
send_strategy["shuffleTargets"] = str(form.get("shuffleTargets", "")) == "on"
|
||||
if "accountStartDelaySecondsMin" in form:
|
||||
send_strategy["accountStartDelaySecondsMin"] = coerce_int(
|
||||
form.get("accountStartDelaySecondsMin", send_strategy.get("accountStartDelaySecondsMin", 0)),
|
||||
send_strategy.get("accountStartDelaySecondsMin", 0),
|
||||
0,
|
||||
)
|
||||
if "accountStartDelaySecondsMax" in form:
|
||||
send_strategy["accountStartDelaySecondsMax"] = coerce_int(
|
||||
form.get("accountStartDelaySecondsMax", send_strategy.get("accountStartDelaySecondsMax", 0)),
|
||||
send_strategy.get("accountStartDelaySecondsMax", 0),
|
||||
send_strategy.get("accountStartDelaySecondsMin", 0),
|
||||
)
|
||||
if "messageIntervalSecondsMin" in form:
|
||||
send_strategy["messageIntervalSecondsMin"] = coerce_int(
|
||||
form.get("messageIntervalSecondsMin", send_strategy.get("messageIntervalSecondsMin", 0)),
|
||||
send_strategy.get("messageIntervalSecondsMin", 0),
|
||||
0,
|
||||
)
|
||||
if "messageIntervalSecondsMax" in form:
|
||||
send_strategy["messageIntervalSecondsMax"] = coerce_int(
|
||||
form.get("messageIntervalSecondsMax", send_strategy.get("messageIntervalSecondsMax", 0)),
|
||||
send_strategy.get("messageIntervalSecondsMax", 0),
|
||||
send_strategy.get("messageIntervalSecondsMin", 0),
|
||||
)
|
||||
if "messageVariants" in form:
|
||||
raw_variants = str(form.get("messageVariants", ""))
|
||||
send_strategy["messageVariants"] = [
|
||||
item.strip() for item in raw_variants.replace("\r", "\n").split("\n") if item.strip()
|
||||
]
|
||||
config["sendStrategy"] = send_strategy
|
||||
|
||||
happy_new_year = config.get("happyNewYear", {})
|
||||
if "happyNewYearEnabled" in form:
|
||||
happy_new_year["enabled"] = str(form.get("happyNewYearEnabled", "")) == "on"
|
||||
if "happyNewYearTemplate" in form:
|
||||
happy_new_year["messageTemplate"] = str(form.get("happyNewYearTemplate", happy_new_year.get("messageTemplate", "")))
|
||||
config["happyNewYear"] = happy_new_year
|
||||
save_config(config)
|
||||
|
||||
flash(request, "Runtime config saved.", "success")
|
||||
return redirect("/")
|
||||
|
||||
@app.post("/settings")
|
||||
async def save_panel_settings(request: Request):
|
||||
maybe_redirect = require_user(request)
|
||||
if maybe_redirect:
|
||||
return maybe_redirect
|
||||
|
||||
form = await request.form()
|
||||
if not validate_csrf(request, str(form.get("csrf_token", ""))):
|
||||
return Response("Invalid CSRF token", status_code=403)
|
||||
|
||||
settings = get_app_settings(force_reload=True)
|
||||
settings["server_host"] = str(form.get("server_host", "")).strip()
|
||||
settings["server_username"] = str(form.get("server_username", "")).strip()
|
||||
settings["server_password"] = str(form.get("server_password", "")).strip()
|
||||
settings["compose_root"] = str(form.get("compose_root", settings.get("compose_root", ""))).strip()
|
||||
settings["ops_log_file"] = str(form.get("ops_log_file", settings.get("ops_log_file", ""))).strip()
|
||||
settings["proxy_refresh_script"] = str(form.get("proxy_refresh_script", settings.get("proxy_refresh_script", ""))).strip()
|
||||
settings["local_login_helper_url"] = str(
|
||||
form.get("local_login_helper_url", settings.get("local_login_helper_url", "http://127.0.0.1:18765"))
|
||||
).strip()
|
||||
settings["login_desktop_api_url"] = str(
|
||||
form.get("login_desktop_api_url", settings.get("login_desktop_api_url", "http://127.0.0.1:18090"))
|
||||
).strip()
|
||||
settings["ui_port"] = int(form.get("ui_port", settings.get("ui_port", 8787)))
|
||||
save_app_settings(settings)
|
||||
|
||||
new_password = str(form.get("new_password", ""))
|
||||
confirm_password = str(form.get("confirm_password", ""))
|
||||
if new_password:
|
||||
if new_password != confirm_password:
|
||||
flash(request, "Admin password was not updated because the confirmation did not match.", "error")
|
||||
return redirect("/")
|
||||
update_admin_password(new_password)
|
||||
|
||||
flash(request, "Panel settings saved.", "success")
|
||||
return redirect("/")
|
||||
|
||||
@app.post("/ops/run-now")
|
||||
async def run_now(request: Request):
|
||||
maybe_redirect = require_user(request)
|
||||
if maybe_redirect:
|
||||
return maybe_redirect
|
||||
|
||||
form = await request.form()
|
||||
if not validate_csrf(request, str(form.get("csrf_token", ""))):
|
||||
return Response("Invalid CSRF token", status_code=403)
|
||||
|
||||
pid = run_task_now()
|
||||
if pid == -1:
|
||||
flash(request, "Task launch failed. Check console logs for Missing Docker or protected log_file path.", "error")
|
||||
else:
|
||||
flash(request, f"Triggered a background task run (pid {pid}).", "success")
|
||||
return redirect("/")
|
||||
|
||||
@app.post("/ops/proxy/refresh")
|
||||
async def proxy_refresh(request: Request):
|
||||
maybe_redirect = require_user(request)
|
||||
if maybe_redirect:
|
||||
return maybe_redirect
|
||||
|
||||
form = await request.form()
|
||||
if not validate_csrf(request, str(form.get("csrf_token", ""))):
|
||||
return Response("Invalid CSRF token", status_code=403)
|
||||
|
||||
refresh_proxy()
|
||||
flash(request, "Proxy subscription refreshed.", "success")
|
||||
return redirect("/")
|
||||
|
||||
@app.post("/ops/proxy/restart")
|
||||
async def proxy_restart(request: Request):
|
||||
maybe_redirect = require_user(request)
|
||||
if maybe_redirect:
|
||||
return maybe_redirect
|
||||
|
||||
form = await request.form()
|
||||
if not validate_csrf(request, str(form.get("csrf_token", ""))):
|
||||
return Response("Invalid CSRF token", status_code=403)
|
||||
|
||||
restart_proxy()
|
||||
flash(request, "Proxy container restarted.", "success")
|
||||
return redirect("/")
|
||||
|
||||
@app.post("/ops/schedule")
|
||||
async def save_schedule(request: Request):
|
||||
maybe_redirect = require_user(request)
|
||||
if maybe_redirect:
|
||||
return maybe_redirect
|
||||
|
||||
form = await request.form()
|
||||
if not validate_csrf(request, str(form.get("csrf_token", ""))):
|
||||
return Response("Invalid CSRF token", status_code=403)
|
||||
|
||||
time_string = str(form.get("daily_schedule", "")).strip()
|
||||
result = update_daily_schedule(time_string)
|
||||
if getattr(result, "returncode", 1) == 0:
|
||||
flash(request, f"Updated the daily schedule to {time_string}.", "success")
|
||||
else:
|
||||
flash(request, f"Failed to update the daily schedule to {time_string}: {getattr(result, 'stderr', '')}", "error")
|
||||
return redirect("/")
|
||||
|
||||
@app.get("/ops/logs", response_class=HTMLResponse)
|
||||
async def logs_page(request: Request):
|
||||
maybe_redirect = require_user(request)
|
||||
if maybe_redirect:
|
||||
return maybe_redirect
|
||||
return render_template(
|
||||
request,
|
||||
"logs.html",
|
||||
{
|
||||
"flash": pop_flash(request),
|
||||
"log_tail": read_log_tail(400),
|
||||
},
|
||||
)
|
||||
|
||||
@app.get("/login-desktop/status")
|
||||
async def login_desktop_status(request: Request):
|
||||
maybe_redirect = require_user(request)
|
||||
if maybe_redirect:
|
||||
return JSONResponse({"redirect": "/login"}, status_code=401)
|
||||
try:
|
||||
payload = call_login_desktop("/status")
|
||||
payload["public_url"] = login_desktop_public_url(request)
|
||||
return JSONResponse(payload)
|
||||
except RuntimeError as exc:
|
||||
return JSONResponse({"ok": False, "error": str(exc), "public_url": login_desktop_public_url(request)}, status_code=503)
|
||||
|
||||
@app.post("/login-desktop/open")
|
||||
async def login_desktop_open(request: Request):
|
||||
maybe_redirect = require_user(request)
|
||||
if maybe_redirect:
|
||||
return JSONResponse({"redirect": "/login"}, status_code=401)
|
||||
form = await request.form()
|
||||
if not validate_csrf(request, str(form.get("csrf_token", ""))):
|
||||
return JSONResponse({"ok": False, "error": "Invalid CSRF token"}, status_code=403)
|
||||
try:
|
||||
call_login_desktop("/open-login", method="POST", payload={})
|
||||
return JSONResponse({"ok": True, "public_url": login_desktop_public_url(request)})
|
||||
except RuntimeError as exc:
|
||||
return JSONResponse({"ok": False, "error": str(exc)}, status_code=503)
|
||||
|
||||
@app.post("/login-desktop/reset")
|
||||
async def login_desktop_reset(request: Request):
|
||||
maybe_redirect = require_user(request)
|
||||
if maybe_redirect:
|
||||
return JSONResponse({"redirect": "/login"}, status_code=401)
|
||||
form = await request.form()
|
||||
if not validate_csrf(request, str(form.get("csrf_token", ""))):
|
||||
return JSONResponse({"ok": False, "error": "Invalid CSRF token"}, status_code=403)
|
||||
try:
|
||||
payload = call_login_desktop("/reset", method="POST", payload={}, timeout=120)
|
||||
return JSONResponse({"ok": True, "result": payload})
|
||||
except RuntimeError as exc:
|
||||
return JSONResponse({"ok": False, "error": str(exc)}, status_code=503)
|
||||
|
||||
@app.post("/login-desktop/save")
|
||||
async def login_desktop_save(request: Request):
|
||||
maybe_redirect = require_user(request)
|
||||
if maybe_redirect:
|
||||
return JSONResponse({"redirect": "/login"}, status_code=401)
|
||||
form = await request.form()
|
||||
if not validate_csrf(request, str(form.get("csrf_token", ""))):
|
||||
return JSONResponse({"ok": False, "error": "Invalid CSRF token"}, status_code=403)
|
||||
|
||||
relogin_unique_id = str(form.get("relogin_unique_id", "")).strip()
|
||||
display_name = str(form.get("display_name", "")).strip()
|
||||
try:
|
||||
payload = call_login_desktop("/export", method="POST", payload={}, timeout=30)
|
||||
if not payload.get("ok"):
|
||||
raise RuntimeError("login-desktop export did not return ok")
|
||||
account, action = save_exported_login_result(
|
||||
payload.get("result", {}),
|
||||
relogin_unique_id=relogin_unique_id,
|
||||
display_name=display_name,
|
||||
)
|
||||
return JSONResponse({
|
||||
"ok": True,
|
||||
"action": action,
|
||||
"account": {
|
||||
"unique_id": account.get("unique_id"),
|
||||
"username": account.get("username"),
|
||||
"enabled": account.get("enabled", True),
|
||||
},
|
||||
})
|
||||
except RuntimeError as exc:
|
||||
return JSONResponse({"ok": False, "error": str(exc)}, status_code=400)
|
||||
|
||||
return app
|
||||
|
||||
|
||||
app = create_app()
|
||||
|
||||
|
||||
def run_web_app(host=None, port=None):
|
||||
settings = get_app_settings(force_reload=True)
|
||||
uvicorn.run(
|
||||
"webui.app:app",
|
||||
host=host or settings["ui_host"],
|
||||
port=port or settings["ui_port"],
|
||||
reload=False,
|
||||
)
|
||||
73
DouYinSparkFlow/webui/auth.py
Normal file
@@ -0,0 +1,73 @@
|
||||
import hashlib
|
||||
import hmac
|
||||
import secrets
|
||||
|
||||
from utils.config import get_app_settings, save_app_settings
|
||||
|
||||
|
||||
def hash_password(password, salt=None):
|
||||
salt = salt or secrets.token_hex(16)
|
||||
digest = hashlib.pbkdf2_hmac("sha256", password.encode("utf-8"), salt.encode("utf-8"), 480000)
|
||||
return f"pbkdf2_sha256${salt}${digest.hex()}"
|
||||
|
||||
|
||||
def verify_password(password, stored_hash):
|
||||
if not stored_hash or "$" not in stored_hash:
|
||||
return False
|
||||
|
||||
algorithm, salt, digest = stored_hash.split("$", 2)
|
||||
if algorithm != "pbkdf2_sha256":
|
||||
return False
|
||||
candidate = hash_password(password, salt=salt)
|
||||
return hmac.compare_digest(candidate, stored_hash)
|
||||
|
||||
|
||||
def is_bootstrapped():
|
||||
return bool(get_app_settings().get("admin_password_hash"))
|
||||
|
||||
|
||||
def bootstrap_admin_password(password, username="admin"):
|
||||
settings = get_app_settings(force_reload=True)
|
||||
settings["admin_username"] = username.strip() or "admin"
|
||||
settings["admin_password_hash"] = hash_password(password)
|
||||
return save_app_settings(settings)
|
||||
|
||||
|
||||
def update_admin_password(password):
|
||||
settings = get_app_settings(force_reload=True)
|
||||
settings["admin_password_hash"] = hash_password(password)
|
||||
return save_app_settings(settings)
|
||||
|
||||
|
||||
def issue_session(request, username):
|
||||
request.session.clear()
|
||||
request.session["user"] = username
|
||||
request.session["csrf_token"] = secrets.token_urlsafe(24)
|
||||
|
||||
|
||||
def clear_session(request):
|
||||
request.session.clear()
|
||||
|
||||
|
||||
def current_user(request):
|
||||
return request.session.get("user")
|
||||
|
||||
|
||||
def csrf_token(request):
|
||||
token = request.session.get("csrf_token")
|
||||
if not token:
|
||||
token = secrets.token_urlsafe(24)
|
||||
request.session["csrf_token"] = token
|
||||
return token
|
||||
|
||||
|
||||
def validate_csrf(request, submitted_token):
|
||||
stored_token = request.session.get("csrf_token")
|
||||
return bool(stored_token and submitted_token and hmac.compare_digest(stored_token, submitted_token))
|
||||
|
||||
|
||||
def is_https_request(request):
|
||||
forwarded_proto = request.headers.get("x-forwarded-proto", "")
|
||||
if forwarded_proto:
|
||||
return forwarded_proto.lower() == "https"
|
||||
return request.url.scheme == "https"
|
||||
1275
DouYinSparkFlow/webui/login_sessions.py
Normal file
407
DouYinSparkFlow/webui/ops.py
Normal file
@@ -0,0 +1,407 @@
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import shlex
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
from utils.config import get_app_settings, get_config, repo_root, save_config
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
TASK_SCHEDULE_MARKERS = (
|
||||
"docker compose run --rm task",
|
||||
"docker compose run --rm douyin",
|
||||
"main.py --doTask",
|
||||
)
|
||||
HOST_CRONTAB_PATH = Path("/host-spool-cron/root")
|
||||
WINDOWED_SCHEDULE_RE = re.compile(r"^(\d{2}):(\d{2})-(\d{2}):(\d{2})/(\d+)m$", re.IGNORECASE)
|
||||
|
||||
|
||||
def running_in_container():
|
||||
return Path("/.dockerenv").exists()
|
||||
|
||||
|
||||
def compose_root():
|
||||
settings = get_app_settings()
|
||||
raw = settings.get("compose_root") or ""
|
||||
if raw:
|
||||
p = Path(raw)
|
||||
if (p / "docker-compose.yml").exists():
|
||||
return p
|
||||
# Docker-out-of-Docker: the compose file lives on the host at
|
||||
# /opt/douyin-sparkflow but is not always bind-mounted into /app.
|
||||
for candidate in [
|
||||
Path("/opt/douyin-sparkflow"),
|
||||
repo_root().parent,
|
||||
repo_root(),
|
||||
]:
|
||||
if (candidate / "docker-compose.yml").exists():
|
||||
return candidate
|
||||
# Fallback
|
||||
return Path(raw) if raw else repo_root()
|
||||
|
||||
|
||||
def compose_file_path():
|
||||
path = compose_root() / "docker-compose.yml"
|
||||
return path if path.exists() else None
|
||||
|
||||
|
||||
def compose_command(*args):
|
||||
compose_file = compose_file_path()
|
||||
base = ["docker", "compose"]
|
||||
if compose_file:
|
||||
base.extend(["-f", str(compose_file)])
|
||||
base.extend(args)
|
||||
return base
|
||||
|
||||
|
||||
def build_task_run_spec():
|
||||
if running_in_container():
|
||||
return [sys.executable, "main.py", "--doTask"], repo_root()
|
||||
if compose_file_path():
|
||||
return compose_command("run", "--rm", "task"), compose_root()
|
||||
return [sys.executable, "main.py", "--doTask"], repo_root()
|
||||
|
||||
|
||||
def build_scheduled_task_command():
|
||||
if running_in_container():
|
||||
return (
|
||||
"/bin/bash -lc 'container=$(docker ps --format \"{{.Names}}\" | "
|
||||
"grep -E \"^(douyin-web-hostfix|douyin-web)$\" | head -n 1); "
|
||||
"[ -n \"$container\" ] && docker exec \"$container\" sh -lc "
|
||||
"\"cd /app && python main.py --doTask\"'"
|
||||
)
|
||||
if compose_file_path():
|
||||
return f"cd {compose_root()} && /usr/bin/docker compose run --rm task"
|
||||
return f"cd {repo_root()} && {shlex.quote(sys.executable)} main.py --doTask"
|
||||
|
||||
|
||||
def run_command(args, cwd=None, timeout=120, check=False):
|
||||
"""Run a command and return the CompletedProcess.
|
||||
|
||||
``check`` defaults to False so callers can inspect the result without
|
||||
crashing when the command is unavailable (e.g. docker not installed).
|
||||
"""
|
||||
try:
|
||||
return subprocess.run(
|
||||
args,
|
||||
cwd=str(cwd or compose_root()),
|
||||
check=check,
|
||||
text=True,
|
||||
capture_output=True,
|
||||
timeout=timeout,
|
||||
)
|
||||
except FileNotFoundError:
|
||||
logger.warning("Command not found: %s", args[0] if args else args)
|
||||
return _empty_result()
|
||||
except subprocess.TimeoutExpired:
|
||||
logger.warning("Command timed out: %s", args)
|
||||
return _empty_result()
|
||||
except subprocess.CalledProcessError as exc:
|
||||
logger.warning("Command failed (rc=%s): %s", exc.returncode, args)
|
||||
return _empty_result(stderr=exc.stderr or "")
|
||||
|
||||
|
||||
def _empty_result(stdout="", stderr=""):
|
||||
"""Return a fake CompletedProcess for graceful degradation."""
|
||||
return subprocess.CompletedProcess(args=[], returncode=1, stdout=stdout, stderr=stderr)
|
||||
|
||||
|
||||
def run_background_command(args, log_path, cwd=None, env=None):
|
||||
log_path = Path(log_path)
|
||||
log_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
handle = log_path.open("ab")
|
||||
child_env = os.environ.copy()
|
||||
if env:
|
||||
child_env.update(env)
|
||||
process = subprocess.Popen(
|
||||
args,
|
||||
cwd=str(Path(cwd) if cwd else compose_root()),
|
||||
stdout=handle,
|
||||
stderr=subprocess.STDOUT,
|
||||
env=child_env,
|
||||
)
|
||||
handle.close()
|
||||
return process.pid
|
||||
|
||||
|
||||
def get_container_status():
|
||||
try:
|
||||
result = run_command(
|
||||
[
|
||||
"docker",
|
||||
"ps",
|
||||
"-a",
|
||||
"--format",
|
||||
"{{.Names}}\t{{.Image}}\t{{.Status}}\t{{.State}}\t{{.RunningFor}}\t{{.Labels}}",
|
||||
],
|
||||
timeout=15,
|
||||
)
|
||||
rows = []
|
||||
for raw_line in (result.stdout or "").splitlines():
|
||||
line = raw_line.strip()
|
||||
if not line:
|
||||
continue
|
||||
parts = line.split("\t", 5)
|
||||
while len(parts) < 6:
|
||||
parts.append("")
|
||||
name, image, status, state, running_for, labels = parts
|
||||
rows.append(
|
||||
{
|
||||
"Names": name,
|
||||
"Image": image,
|
||||
"Status": status,
|
||||
"State": state,
|
||||
"RunningFor": running_for,
|
||||
"Labels": labels,
|
||||
}
|
||||
)
|
||||
return rows
|
||||
except Exception as exc:
|
||||
logger.warning("get_container_status failed: %s", exc)
|
||||
return []
|
||||
|
||||
|
||||
class contextlib_suppress_json:
|
||||
def __enter__(self):
|
||||
return self
|
||||
|
||||
def __exit__(self, exc_type, exc, tb):
|
||||
return exc_type is json.JSONDecodeError
|
||||
|
||||
|
||||
def get_task_container_rows():
|
||||
try:
|
||||
rows = get_container_status()
|
||||
interesting_names = {"douyin-web-hostfix", "douyin-web", "douyin-task"}
|
||||
return [row for row in rows if row.get("Names") in interesting_names]
|
||||
except Exception as exc:
|
||||
logger.warning("get_task_container_rows failed: %s", exc)
|
||||
return []
|
||||
|
||||
|
||||
def run_task_now():
|
||||
try:
|
||||
log_file = Path(get_app_settings().get("ops_log_file") or "/var/log/douyin-sparkflow.log")
|
||||
command, cwd = build_task_run_spec()
|
||||
return run_background_command(
|
||||
command,
|
||||
log_file,
|
||||
cwd=cwd,
|
||||
env={
|
||||
"SPARKFLOW_MANUAL_RUN": "1",
|
||||
"PYTHONUNBUFFERED": "1",
|
||||
},
|
||||
)
|
||||
except Exception as exc:
|
||||
import traceback
|
||||
Path("task_error.txt").write_text(traceback.format_exc(), encoding="utf-8")
|
||||
logger.error("run_task_now failed: %s", exc)
|
||||
return -1
|
||||
|
||||
|
||||
def refresh_proxy():
|
||||
try:
|
||||
script = Path(get_app_settings().get("proxy_refresh_script") or "")
|
||||
if script.exists():
|
||||
return run_command(["bash", str(script)], timeout=120)
|
||||
return run_command(compose_command("restart", "proxy"), timeout=120)
|
||||
except Exception as exc:
|
||||
logger.error("refresh_proxy failed: %s", exc)
|
||||
return _empty_result(stderr=str(exc))
|
||||
|
||||
|
||||
def restart_proxy():
|
||||
try:
|
||||
return run_command(compose_command("restart", "proxy"), timeout=120)
|
||||
except Exception as exc:
|
||||
logger.error("restart_proxy failed: %s", exc)
|
||||
return _empty_result(stderr=str(exc))
|
||||
|
||||
|
||||
def read_log_tail(lines=200):
|
||||
log_path = Path(get_app_settings().get("ops_log_file") or "/var/log/douyin-sparkflow.log")
|
||||
if not log_path.exists():
|
||||
return ""
|
||||
content = log_path.read_text(encoding="utf-8", errors="replace").splitlines()
|
||||
return "\n".join(content[-lines:])
|
||||
|
||||
|
||||
def read_crontab():
|
||||
if running_in_container() and HOST_CRONTAB_PATH.exists():
|
||||
return HOST_CRONTAB_PATH.read_text(encoding="utf-8", errors="replace")
|
||||
try:
|
||||
result = subprocess.run(["crontab", "-l"], text=True, capture_output=True, timeout=10)
|
||||
if result.returncode != 0:
|
||||
return ""
|
||||
return result.stdout
|
||||
except Exception as exc:
|
||||
logger.warning("read_crontab failed: %s", exc)
|
||||
return ""
|
||||
|
||||
|
||||
def _format_window_schedule(window_config):
|
||||
return (
|
||||
f"{int(window_config['startHour']):02d}:00-"
|
||||
f"{int(window_config['endHour']):02d}:00/"
|
||||
f"{int(window_config['scheduleIntervalMinutes'])}m"
|
||||
)
|
||||
|
||||
|
||||
def parse_schedule_string(time_string):
|
||||
raw = str(time_string or "").strip()
|
||||
match = WINDOWED_SCHEDULE_RE.fullmatch(raw)
|
||||
if match:
|
||||
start_hour, start_minute, end_hour, end_minute, interval = [int(part) for part in match.groups()]
|
||||
if start_minute != 0 or end_minute != 0:
|
||||
raise ValueError("Window schedule must use whole hours, e.g. 10:00-18:00/10m")
|
||||
if start_hour not in range(24) or end_hour not in range(24) or end_hour <= start_hour:
|
||||
raise ValueError("Window schedule is out of range")
|
||||
if interval not in range(1, 60):
|
||||
raise ValueError("Window schedule interval must be between 1 and 59 minutes")
|
||||
return {
|
||||
"mode": "window",
|
||||
"startHour": start_hour,
|
||||
"endHour": end_hour,
|
||||
"scheduleIntervalMinutes": interval,
|
||||
}
|
||||
|
||||
if not re.fullmatch(r"\d{2}:\d{2}", raw):
|
||||
raise ValueError("Time must use HH:MM or HH:00-HH:00/10m format")
|
||||
hour, minute = [int(part) for part in raw.split(":", 1)]
|
||||
if hour not in range(24) or minute not in range(60):
|
||||
raise ValueError("Time is out of range")
|
||||
return {"mode": "fixed", "hour": hour, "minute": minute}
|
||||
|
||||
|
||||
def validate_time_string(time_string):
|
||||
parsed = parse_schedule_string(time_string)
|
||||
if parsed["mode"] != "fixed":
|
||||
raise ValueError("Time must use HH:MM format")
|
||||
return parsed["hour"], parsed["minute"]
|
||||
|
||||
|
||||
def replace_douyin_cron_schedule(crontab_text, time_string):
|
||||
schedule = parse_schedule_string(time_string)
|
||||
scheduled_command = build_scheduled_task_command()
|
||||
updated = []
|
||||
|
||||
for raw_line in crontab_text.splitlines():
|
||||
line = raw_line.rstrip("\n")
|
||||
if any(marker in line for marker in TASK_SCHEDULE_MARKERS):
|
||||
continue
|
||||
updated.append(line)
|
||||
|
||||
if schedule["mode"] == "window":
|
||||
updated.append(
|
||||
f"*/{schedule['scheduleIntervalMinutes']} {schedule['startHour']}-{schedule['endHour'] - 1} * * * "
|
||||
f"{scheduled_command} >> /var/log/douyin-sparkflow.log 2>&1"
|
||||
)
|
||||
updated.append(
|
||||
f"0 {schedule['endHour']} * * * "
|
||||
f"{scheduled_command} >> /var/log/douyin-sparkflow.log 2>&1"
|
||||
)
|
||||
else:
|
||||
updated.append(
|
||||
f"{schedule['minute']} {schedule['hour']} * * * "
|
||||
f"{scheduled_command} >> /var/log/douyin-sparkflow.log 2>&1"
|
||||
)
|
||||
|
||||
normalized = "\n".join(line for line in updated if line.strip())
|
||||
if normalized:
|
||||
normalized += "\n"
|
||||
return normalized
|
||||
|
||||
|
||||
def persist_schedule_config(time_string):
|
||||
parsed = parse_schedule_string(time_string)
|
||||
config = get_config(force_reload=True)
|
||||
window = dict(config.get("dailySendWindow") or {})
|
||||
if parsed["mode"] == "window":
|
||||
window.update(
|
||||
{
|
||||
"enabled": True,
|
||||
"startHour": parsed["startHour"],
|
||||
"endHour": parsed["endHour"],
|
||||
"scheduleIntervalMinutes": parsed["scheduleIntervalMinutes"],
|
||||
}
|
||||
)
|
||||
else:
|
||||
window.update({"enabled": False})
|
||||
config["dailySendWindow"] = window
|
||||
save_config(config)
|
||||
|
||||
|
||||
def update_daily_schedule(time_string):
|
||||
persist_schedule_config(time_string)
|
||||
current = read_crontab()
|
||||
updated = replace_douyin_cron_schedule(current, time_string)
|
||||
if running_in_container() and HOST_CRONTAB_PATH.parent.exists():
|
||||
try:
|
||||
HOST_CRONTAB_PATH.write_text(updated, encoding="utf-8")
|
||||
return subprocess.CompletedProcess(args=["write-host-crontab"], returncode=0, stdout="", stderr="")
|
||||
except Exception as exc:
|
||||
logger.error("update_daily_schedule failed: %s", exc)
|
||||
return _empty_result(stderr=str(exc))
|
||||
try:
|
||||
process = subprocess.run(["crontab", "-"], input=updated, text=True, capture_output=True, check=True, timeout=10)
|
||||
return process
|
||||
except Exception as exc:
|
||||
logger.error("update_daily_schedule 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 {})
|
||||
if window.get("enabled"):
|
||||
try:
|
||||
return _format_window_schedule(window)
|
||||
except Exception:
|
||||
logger.warning("current_daily_schedule found invalid dailySendWindow=%s", window)
|
||||
|
||||
for line in read_crontab().splitlines():
|
||||
if any(marker in line for marker in TASK_SCHEDULE_MARKERS):
|
||||
parts = line.split(maxsplit=5)
|
||||
if len(parts) >= 2:
|
||||
if parts[0].isdigit() and parts[1].isdigit():
|
||||
minute = int(parts[0])
|
||||
hour = int(parts[1])
|
||||
return f"{hour:02d}:{minute:02d}"
|
||||
return f"{parts[1]}:{parts[0]}"
|
||||
return ""
|
||||
|
||||
|
||||
def _check_image_present():
|
||||
"""Return True if the douyin-sparkflow:local image exists."""
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["docker", "image", "inspect", "douyin-sparkflow:local"],
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL,
|
||||
timeout=10,
|
||||
)
|
||||
return result.returncode == 0
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
def get_ops_snapshot():
|
||||
"""Collect operational metrics for the dashboard.
|
||||
|
||||
Every external call is individually guarded so the dashboard always
|
||||
renders, even when Docker or crontab are not available.
|
||||
"""
|
||||
return {
|
||||
"compose_root": str(compose_root()),
|
||||
"compose_file": str(compose_file_path() or ""),
|
||||
"containers": get_container_status(),
|
||||
"task_containers": get_task_container_rows(),
|
||||
"daily_schedule": current_daily_schedule(),
|
||||
"crontab": read_crontab(),
|
||||
"log_tail": read_log_tail(120),
|
||||
"image_present": _check_image_present(),
|
||||
}
|
||||
1
DouYinSparkFlow/webui/static/.gitkeep
Normal file
@@ -0,0 +1 @@
|
||||
|
||||
BIN
DouYinSparkFlow/webui/static/multiPagePlugins.zip
Normal file
446
DouYinSparkFlow/webui/static/multiPagePlugins/README.md
Normal file
@@ -0,0 +1,446 @@
|
||||
# Multi-Page Automation
|
||||
|
||||
一个用于批量跑通 ChatGPT OAuth 注册/登录流程的 Chrome 扩展。
|
||||
|
||||
当前版本基于侧边栏控制,支持单步执行、整套自动执行、停止当前流程、保存常用配置,以及通过 DuckDuckGo / Firefox Relay / Cloudflare Temp Email / QQ / 163 / Inbucket mailbox 协助完成注册邮箱与验证码处理。
|
||||
|
||||
## 当前能力
|
||||
|
||||
- 从 VPS 面板自动获取 OpenAI OAuth 授权链接
|
||||
- 自动打开 OpenAI 注册页并点击 `Sign up / Register`
|
||||
- 自动填写邮箱与密码
|
||||
- 支持自定义密码;留空时自动生成强密码
|
||||
- 自动显示当前使用中的密码,便于后续保存
|
||||
- 自动获取注册验证码与登录验证码
|
||||
- 支持 `QQ Mail`、`163 Mail`、`Inbucket mailbox`
|
||||
- 支持从 DuckDuckGo Email Protection 自动生成新的 `@duck.com` 地址
|
||||
- 支持从 Firefox Relay 自动创建新的 `@mozmail.com` mask
|
||||
- 支持从侧边栏配置的 Cloudflare Temp Email admin 页面自动创建新的临时邮箱
|
||||
- Step 5 同时兼容两种页面:
|
||||
- 页面要求填写 `birthday`
|
||||
- 页面要求填写 `age`
|
||||
- 支持 `Auto` 多轮运行
|
||||
- 支持中途 `Stop`
|
||||
- Step 8 会自动寻找 OAuth 同意页的“继续”按钮,并通过 Chrome debugger 输入事件发起点击,然后监听本地回调地址
|
||||
|
||||
|
||||
## 环境要求
|
||||
|
||||
- Chrome 浏览器
|
||||
- 打开扩展开发者模式
|
||||
- 你自己的 VPS 管理面板,且页面结构与当前脚本适配
|
||||
- 至少准备一种验证码接收方式:
|
||||
- DuckDuckGo `@duck.com` + QQ / 163 / Inbucket 转发
|
||||
- 可创建并收信的 Cloudflare Temp Email admin 页面,留空时默认使用 `https://mail.cloudflare.com/admin`
|
||||
- 手动填写一个可收信邮箱
|
||||
- 如果使用 `QQ` / `163` / `Inbucket`,对应页面需要提前能正常打开
|
||||
|
||||
## 安装
|
||||
|
||||
1. 打开 `chrome://extensions/`
|
||||
2. 开启“开发者模式”
|
||||
3. 点击“加载已解压的扩展程序”
|
||||
4. 选择本项目目录
|
||||
5. 打开扩展侧边栏
|
||||
|
||||
## 侧边栏配置说明
|
||||
|
||||
### `VPS`
|
||||
|
||||
你的管理面板 OAuth 页面地址,例如:
|
||||
|
||||
```txt
|
||||
http(s)://<your-host>/management.html#/oauth
|
||||
```
|
||||
|
||||
Step 1 依赖这个地址。Step 9 已改为邮箱资源清理,不再执行 VPS Verify。
|
||||
|
||||
### `Mail`
|
||||
|
||||
支持三种验证码来源:
|
||||
|
||||
- `163 Mail`
|
||||
- `QQ Mail`
|
||||
- `Inbucket`
|
||||
|
||||
说明:
|
||||
|
||||
- `QQ` 和 `163` 用于直接轮询网页邮箱
|
||||
- `Inbucket` 通过你在侧边栏里配置的 host 访问 `mailbox` 页面:`https://<your-inbucket-host>/m/<mailbox>/`
|
||||
|
||||
### `Mailbox`
|
||||
|
||||
仅当 `Mail = Inbucket` 时显示。
|
||||
|
||||
填写 Inbucket mailbox 名称,例如:
|
||||
|
||||
```txt
|
||||
tmp-mailbox
|
||||
```
|
||||
|
||||
脚本会自动打开:
|
||||
|
||||
```txt
|
||||
https://<your-inbucket-host>/m/<mailbox>/
|
||||
```
|
||||
|
||||
并且只检索未读邮件:
|
||||
|
||||
- 只匹配 `.message-list-entry.unseen`
|
||||
- 第 2 次轮询开始会自动点击 mailbox 页面上的刷新按钮
|
||||
- 识别到验证码后会尝试删除当前邮件,减少重复命中
|
||||
|
||||
### `Inbucket`
|
||||
|
||||
仅当 `Mail = Inbucket` 时显示。
|
||||
|
||||
这里填写 Inbucket host,支持两种格式:
|
||||
|
||||
- `your-inbucket-host`
|
||||
- `https://your-inbucket-host`
|
||||
|
||||
脚本会自动规范化成 origin 后再拼接 mailbox URL。
|
||||
|
||||
### `Email`
|
||||
|
||||
Step 3 使用的注册邮箱。
|
||||
|
||||
来源有三种:
|
||||
|
||||
- 手动粘贴
|
||||
- 选择 `duckduckgo` 后点击 `Auto`,从 DuckDuckGo Email Protection 自动获取一个新的 `@duck.com`
|
||||
- 选择 `cloudflare_temp_email` 后点击 `Auto`,从 `Cloudflare` 输入框对应的 admin 页面自动创建一个新的临时邮箱;留空时默认使用 `https://mail.cloudflare.com/admin`
|
||||
- 选择 `relay_firefox` 后点击 `Auto`,从 Firefox Relay 自动创建一个新的 `@mozmail.com` mask
|
||||
|
||||
注意:
|
||||
|
||||
- `Auto` 按钮会根据当前 `Email Source` 选择不同提供方
|
||||
- `relay_firefox` 模式下,Step 3 会优先自动创建新的 Relay mask
|
||||
- 如果你使用 Inbucket,它只是验证码收件箱,不会自动生成 Inbucket 地址
|
||||
|
||||
### `Email Source`
|
||||
|
||||
用于控制 Step 3 的注册邮箱来源:
|
||||
|
||||
- `duckduckgo`
|
||||
- `cloudflare_temp_email`
|
||||
- `relay_firefox`
|
||||
|
||||
它和 `Mail` 配置是独立的:
|
||||
|
||||
- `Email Source` 决定注册时用哪个邮箱地址
|
||||
- `Mail` 决定 Step 4 / Step 7 去哪里收验证码
|
||||
|
||||
例外:
|
||||
|
||||
- 当 `Email Source = cloudflare_temp_email` 时,Step 4 / Step 7 会直接回到 `Cloudflare` 输入框对应的 admin 页面收验证码;留空时默认使用 `https://mail.cloudflare.com/admin`,不使用 `Mail` 配置
|
||||
|
||||
### `Cloudflare`
|
||||
|
||||
仅当 `Email Source = cloudflare_temp_email` 时显示。
|
||||
|
||||
这里填写 Cloudflare Temp Email admin 页面地址,支持两种格式:
|
||||
|
||||
- `mail.cloudflare.com/admin`
|
||||
- `https://mail.cloudflare.com/admin`
|
||||
|
||||
行为说明:
|
||||
|
||||
- 留空时默认使用 `https://mail.cloudflare.com/admin`
|
||||
- 输入缺少协议时,会自动补成 `https://`
|
||||
- Step 3 / Step 4 / Step 7 都会复用这里的地址
|
||||
|
||||
### `Password`
|
||||
|
||||
- 留空:自动生成强密码
|
||||
- 手动输入:使用你自定义的密码
|
||||
- 可通过 `Show / Hide` 按钮切换显示
|
||||
|
||||
扩展会把本轮实际使用的密码同步回侧边栏,便于查看和复制。
|
||||
|
||||
### `Auto`
|
||||
|
||||
整套流程自动跑。
|
||||
|
||||
支持多轮运行,运行次数由右上角数字框决定。
|
||||
|
||||
## 工作流
|
||||
|
||||
### 单步模式
|
||||
|
||||
侧边栏共有 9 个步骤按钮,可逐步执行:
|
||||
|
||||
1. `Get OAuth Link`
|
||||
2. `Open Signup`
|
||||
3. `Fill Email / Password`
|
||||
4. `Get Signup Code`
|
||||
5. `Fill Name / Birthday`
|
||||
6. `Login via OAuth`
|
||||
7. `Get Login Code`
|
||||
8. `Manual OAuth Confirm`
|
||||
9. `Cleanup Email`
|
||||
|
||||
### Auto 模式
|
||||
|
||||
点击右上角 `Auto` 后,后台会按顺序跑完整流程。
|
||||
|
||||
当前 Auto 逻辑是:
|
||||
|
||||
1. Step 1 获取 VPS OAuth 链接
|
||||
2. Step 2 打开 OpenAI 注册页
|
||||
3. 按 `Email Source` 尝试自动准备注册邮箱
|
||||
4. 如果邮箱自动获取 / 创建失败,暂停并等待你在侧边栏修复后点击 `Continue`
|
||||
5. 继续执行 Step 3 ~ Step 9
|
||||
|
||||
也就是说:
|
||||
|
||||
- 如果当前邮箱来源可自动完成,整套流程更接近全自动
|
||||
- 如果不能自动获取或创建,Auto 会在邮箱阶段暂停
|
||||
|
||||
## 详细步骤说明
|
||||
|
||||
### Step 1: Get OAuth Link
|
||||
|
||||
通过 `content/vps-panel.js`:
|
||||
|
||||
- 打开 VPS OAuth 面板
|
||||
- 等待 `Codex OAuth` 卡片出现
|
||||
- 点击“登录”
|
||||
- 读取页面里的授权链接
|
||||
|
||||
结果会保存到侧边栏的 `OAuth` 字段。
|
||||
|
||||
### Step 2: Open Signup
|
||||
|
||||
通过 `content/signup-page.js`:
|
||||
|
||||
- 打开授权链接
|
||||
- 查找 `Sign up / Register / 创建账户` 按钮
|
||||
- 自动点击进入注册流程
|
||||
|
||||
### Step 3: Fill Email / Password
|
||||
|
||||
- 自动填写邮箱
|
||||
- 如页面先要求邮箱,再进入密码页,会自动切页继续填写
|
||||
- 使用自定义密码或自动生成密码
|
||||
- 提交注册表单
|
||||
|
||||
当 `Email Source = relay_firefox` 时,后台会在填写前先打开 `https://relay.firefox.com/accounts/profile/` 创建一个新的 mask 邮箱,并自动补一个 `tN` 账户名。
|
||||
|
||||
当 `Email Source = cloudflare_temp_email` 时,后台会在填写前先打开侧边栏 `Cloudflare` 输入框对应的 admin 页面;如果留空,则默认使用 `https://mail.cloudflare.com/admin`。随后进入 `账号 -> 创建账号`,默认关闭前缀开关,再创建一个新的临时邮箱。
|
||||
|
||||
实际使用的密码会写入会话状态,并同步到侧边栏显示。
|
||||
|
||||
### Step 4: Get Signup Code
|
||||
|
||||
默认根据 `Mail` 配置,轮询邮箱并提取 6 位验证码。
|
||||
|
||||
支持:
|
||||
|
||||
- `content/qq-mail.js`
|
||||
- `content/mail-163.js`
|
||||
- `content/inbucket-mail.js`
|
||||
|
||||
邮件匹配规则以以下关键词为主:
|
||||
|
||||
- 发件人:`openai`、`noreply`、`verify`、`auth`、`duckduckgo`、`forward`
|
||||
- 标题:`verify`、`verification`、`code`、`验证`、`confirm`
|
||||
|
||||
当 `Email Source = cloudflare_temp_email` 时:
|
||||
|
||||
- 不使用 `Mail`
|
||||
- 直接打开侧边栏 `Cloudflare` 输入框对应的 admin 页面;如果留空,则默认使用 `https://mail.cloudflare.com/admin`
|
||||
- 通过 `刷新` 轮询当前注册邮箱的验证码邮件
|
||||
|
||||
### Step 5: Fill Name / Birthday
|
||||
|
||||
随机生成人名与生日。
|
||||
|
||||
当前脚本支持两种页面结构:
|
||||
|
||||
- 页面要求 `birthday`
|
||||
- 页面要求 `age`
|
||||
|
||||
如果页面是生日模式,会填写年月日;如果页面上存在 `input[name='age']`,则直接填写年龄。
|
||||
|
||||
### Step 6: Login via OAuth
|
||||
|
||||
重新打开 OAuth 链接,使用刚注册的账号登录。
|
||||
|
||||
支持:
|
||||
|
||||
- 邮箱 + 密码登录
|
||||
- 提交后进入验证码验证流程
|
||||
|
||||
### Step 7: Get Login Code
|
||||
|
||||
与 Step 4 类似,但会使用稍微不同的关键词组合去找登录验证码邮件。
|
||||
|
||||
当 `Email Source = cloudflare_temp_email` 时,仍然走 admin 页轮询,并且只接受时间上能证明晚于 Step 4 的邮件;如果无法证明是更新邮件,会直接失败而不是复用旧验证码。
|
||||
|
||||
### Step 8: Manual OAuth Confirm
|
||||
|
||||
虽然按钮名称还是 `Manual OAuth Confirm`,但当前代码已经做了自动尝试:
|
||||
|
||||
- 在授权页定位“继续”按钮
|
||||
- 等待按钮可点击
|
||||
- 获取按钮坐标
|
||||
- 通过 Chrome `debugger` 的输入事件点击该按钮
|
||||
- 同时监听 `chrome.webNavigation.onBeforeNavigate`
|
||||
- 一旦捕获本地回调地址,就把结果保存到 `Callback`
|
||||
|
||||
注意:
|
||||
|
||||
- 这一步仍然是最容易因页面变化而失效的一步
|
||||
- 如果 120 秒内没有捕获到 localhost 回调,会报错超时
|
||||
- README 中的按钮名称沿用了旧文案,但代码行为是“自动尝试点击”
|
||||
|
||||
### Step 9: Cleanup Email
|
||||
|
||||
Step 9 现在用于清理本轮邮箱资源:
|
||||
|
||||
- `duckduckgo`:跳过,不做清理
|
||||
- `cloudflare_temp_email`:跳过,不做清理
|
||||
- `relay_firefox`:回到 Firefox Relay 页面,删除本轮刚创建的那个 mask 邮箱
|
||||
|
||||
## Duck 邮箱自动获取
|
||||
|
||||
通过 `content/duck-mail.js`:
|
||||
|
||||
- 打开 DuckDuckGo Email Protection Autofill 设置页
|
||||
- 查找当前私有地址
|
||||
- 如需要,点击 `Generate Private Duck Address`
|
||||
- 读取新的 `@duck.com` 地址
|
||||
|
||||
这个功能会被:
|
||||
|
||||
- 侧边栏 `Email` 旁边的 `Auto` 按钮使用
|
||||
- `Email Source = duckduckgo` 的 `Auto Run` 流程优先尝试使用
|
||||
|
||||
## Firefox Relay 自动创建 / 删除
|
||||
|
||||
通过 `content/relay-firefox.js`:
|
||||
|
||||
- 打开 Firefox Relay profile 页面
|
||||
- 点击 `Generate new mask`
|
||||
- 读取新的 `@mozmail.com` 地址
|
||||
- 自动设置下一个可用的 `tN` 标签
|
||||
- 在 Step 9 删除本轮创建的 mask
|
||||
|
||||
## Cloudflare Temp Email 自动创建 / 收码
|
||||
|
||||
通过 `content/cloudflare-temp-email.js`:
|
||||
|
||||
- 打开侧边栏 `Cloudflare` 输入框对应的 admin 页面;如果留空,则默认使用 `https://mail.cloudflare.com/admin`
|
||||
- 在 `账号 -> 创建账号` 默认关闭前缀,再创建新邮箱
|
||||
- 从创建成功弹窗里读取邮箱地址和 address id
|
||||
- 在 `邮件` 页通过 `查询` + `刷新` 轮询目标邮箱
|
||||
- 提取 Step 4 / Step 7 需要的 6 位验证码
|
||||
|
||||
## 停止机制
|
||||
|
||||
扩展内置了停止当前流程的能力:
|
||||
|
||||
- 侧边栏点击 `Stop`
|
||||
- Background 会广播 `STOP_FLOW`
|
||||
- 各 content script 会在等待、轮询、sleep、元素查找中尽量中断
|
||||
|
||||
适合以下场景:
|
||||
|
||||
- 卡在某一步
|
||||
- 邮件迟迟不来
|
||||
- 页面结构变化导致等待超时
|
||||
|
||||
## 状态与数据
|
||||
|
||||
主要使用 `chrome.storage.session` 保存运行时状态:
|
||||
|
||||
- 当前步骤
|
||||
- 每一步状态
|
||||
- OAuth 链接
|
||||
- 当前邮箱
|
||||
- 当前密码
|
||||
- localhost 回调地址
|
||||
- 账号记录
|
||||
- tab 注册信息
|
||||
- 自定义设置
|
||||
|
||||
特点:
|
||||
|
||||
- 浏览器会话级存储
|
||||
- 扩展运行期间可在多个步骤之间共享
|
||||
- 代码里已启用 `storage.session` 对 content script 的访问
|
||||
|
||||
## 项目结构
|
||||
|
||||
```txt
|
||||
background.js 后台主控,编排 1~9 步、Tab 复用、状态管理
|
||||
manifest.json 扩展清单
|
||||
data/names.js 随机姓名、生日数据
|
||||
content/utils.js 通用工具:等待元素、点击、日志、停止控制
|
||||
content/vps-panel.js VPS 面板步骤:Step 1
|
||||
content/signup-page.js OpenAI 注册/登录页步骤:Step 2 / 3 / 5 / 6 / 8
|
||||
content/duck-mail.js Duck 邮箱自动获取
|
||||
shared/cloudflare-temp-email.js Cloudflare Temp Email 纯逻辑辅助
|
||||
content/cloudflare-temp-email.js Cloudflare Temp Email 创建 / 轮询
|
||||
content/relay-firefox.js Firefox Relay mask 创建 / 删除
|
||||
content/qq-mail.js QQ 邮箱验证码轮询
|
||||
content/mail-163.js 163 邮箱验证码轮询
|
||||
content/inbucket-mail.js Inbucket mailbox 验证码轮询
|
||||
sidepanel/ 侧边栏 UI
|
||||
```
|
||||
|
||||
## 常见使用建议
|
||||
|
||||
### 1. 先单步验证,再开 Auto
|
||||
|
||||
推荐先手动跑通一次:
|
||||
|
||||
1. Step 1
|
||||
2. Step 2
|
||||
3. Step 3
|
||||
4. Step 4
|
||||
|
||||
确认邮箱和验证码链路稳定后,再使用 `Auto`。
|
||||
|
||||
### 2. Inbucket 建议使用专用 mailbox
|
||||
|
||||
当前 Inbucket 逻辑只看未读邮件,但还是建议:
|
||||
|
||||
- 给脚本准备一个相对独立的 mailbox
|
||||
- 避免收件箱里混入过多无关邮件
|
||||
|
||||
### 3. 邮箱自动获取失败时直接手动修复
|
||||
|
||||
如果 Duck 或 Relay 页面打不开、未登录或按钮变化:
|
||||
|
||||
- 直接在 `Email` 输入框中粘贴邮箱
|
||||
- 再继续执行 Step 3 或 Auto Continue
|
||||
|
||||
### 4. Step 8 失败时重点检查
|
||||
|
||||
- OAuth 同意页 DOM 是否变化
|
||||
- “继续”按钮是否变成了别的文案
|
||||
- localhost 回调是否真的触发
|
||||
- 浏览器是否允许 debugger 附加
|
||||
|
||||
## 已知限制
|
||||
|
||||
- Step 8 对页面结构较敏感
|
||||
- Duck / Relay 自动获取依赖各自页面真实 DOM
|
||||
- VPS 面板 DOM 也需要和当前脚本选择器匹配
|
||||
- `Auto` 按钮名称和 Step 8 的旧文案还未完全统一,但代码行为以实际实现为准
|
||||
|
||||
## 调试建议
|
||||
|
||||
- 打开扩展侧边栏看日志
|
||||
- 查看 Service Worker 控制台
|
||||
- 查看目标页面的 content script 控制台日志
|
||||
- 当某一步频繁失败时,优先检查当前页面选择器是否仍然匹配
|
||||
|
||||
## 安全说明
|
||||
|
||||
- 所有状态仅保存在浏览器会话中
|
||||
- 没有硬编码你的 VPS 地址、密码或账户
|
||||
- 自定义密码只存在当前会话存储中
|
||||
- 邮箱和密码会被记录到本轮 `accounts` 中,便于追踪本次运行结果
|
||||
1767
DouYinSparkFlow/webui/static/multiPagePlugins/background.js
Normal file
@@ -0,0 +1,531 @@
|
||||
// content/cloudflare-temp-email.js — Content script for Cloudflare Temp Email admin page
|
||||
|
||||
const CLOUDFLARE_TEMP_EMAIL_PREFIX = '[MultiPage:cloudflare-temp-email]';
|
||||
const isTopFrame = window === window.top;
|
||||
|
||||
const {
|
||||
combineDistinctTextParts = (parts = []) => parts
|
||||
.map((part) => String(part || '').replace(/\s+/g, ' ').trim())
|
||||
.filter(Boolean)
|
||||
.filter((part, index, values) => values.indexOf(part) === index)
|
||||
.join(' '),
|
||||
extractVerificationCode = () => null,
|
||||
generateReadableLocalPart = () => `mp${Date.now().toString(36)}${Math.random().toString(36).slice(2, 6)}`.slice(0, 18),
|
||||
normalizeDomainSuffix = (value) => String(value || '').trim().replace(/^@+/, '').toLowerCase(),
|
||||
parseCloudflareMailboxCredential = () => null,
|
||||
pickRandomSuffix = (options = []) => normalizeDomainSuffix(options[0] || ''),
|
||||
selectVerificationMessage = () => null,
|
||||
} = globalThis.MultiPageCloudflareTempEmail || {};
|
||||
|
||||
console.log(CLOUDFLARE_TEMP_EMAIL_PREFIX, 'Content script loaded on', location.href, 'frame:', isTopFrame ? 'top' : 'child');
|
||||
|
||||
if (!isTopFrame) {
|
||||
console.log(CLOUDFLARE_TEMP_EMAIL_PREFIX, 'Skipping child frame');
|
||||
} else {
|
||||
chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
|
||||
if (message.type !== 'CREATE_CLOUDFLARE_TEMP_EMAIL' && message.type !== 'POLL_EMAIL') {
|
||||
return;
|
||||
}
|
||||
|
||||
resetStopState();
|
||||
|
||||
const handler = message.type === 'CREATE_CLOUDFLARE_TEMP_EMAIL'
|
||||
? createCloudflareTempEmail
|
||||
: pollCloudflareTempEmail;
|
||||
|
||||
handler(message.step, message.payload || {}).then((result) => {
|
||||
sendResponse(result);
|
||||
}).catch((err) => {
|
||||
if (isStopError(err)) {
|
||||
if (message.step) {
|
||||
log(`Step ${message.step}: Stopped by user.`, 'warn');
|
||||
} else {
|
||||
log('Cloudflare Temp Email: Stopped by user.', 'warn');
|
||||
}
|
||||
sendResponse({ stopped: true, error: err.message });
|
||||
return;
|
||||
}
|
||||
|
||||
if (message.step) {
|
||||
reportError(message.step, err.message);
|
||||
}
|
||||
sendResponse({ error: err.message });
|
||||
});
|
||||
|
||||
return true;
|
||||
});
|
||||
|
||||
function getElementText(el) {
|
||||
return combineDistinctTextParts([
|
||||
el?.innerText,
|
||||
el?.textContent,
|
||||
el?.getAttribute?.('aria-label'),
|
||||
el?.getAttribute?.('title'),
|
||||
el?.value,
|
||||
]);
|
||||
}
|
||||
|
||||
function normalizeText(value) {
|
||||
return String(value || '').replace(/\s+/g, ' ').trim();
|
||||
}
|
||||
|
||||
function normalizeEmail(value) {
|
||||
return normalizeText(value).toLowerCase();
|
||||
}
|
||||
|
||||
function isVisible(el) {
|
||||
if (!el) return false;
|
||||
if (el.hidden) return false;
|
||||
const style = window.getComputedStyle(el);
|
||||
if (style.display === 'none' || style.visibility === 'hidden' || style.opacity === '0') {
|
||||
return false;
|
||||
}
|
||||
return Boolean(el.offsetWidth || el.offsetHeight || el.getClientRects().length);
|
||||
}
|
||||
|
||||
function findVisibleButton(pattern) {
|
||||
return Array.from(document.querySelectorAll('button'))
|
||||
.filter(isVisible)
|
||||
.find((button) => pattern.test(normalizeText(getElementText(button))));
|
||||
}
|
||||
|
||||
function findVisibleTab(pattern, occurrence = 'first') {
|
||||
const tabs = Array.from(document.querySelectorAll('.n-tabs-tab'))
|
||||
.filter(isVisible)
|
||||
.filter((tab) => pattern.test(normalizeText(getElementText(tab))));
|
||||
|
||||
return occurrence === 'last' ? tabs[tabs.length - 1] || null : tabs[0] || null;
|
||||
}
|
||||
|
||||
async function waitForCondition(predicate, timeout, message) {
|
||||
const startedAt = Date.now();
|
||||
|
||||
while (Date.now() - startedAt < timeout) {
|
||||
throwIfStopped();
|
||||
const value = predicate();
|
||||
if (value) {
|
||||
return value;
|
||||
}
|
||||
await sleep(150);
|
||||
}
|
||||
|
||||
throw new Error(message);
|
||||
}
|
||||
|
||||
async function clickTab(pattern, occurrence = 'first', timeout = 10000) {
|
||||
await waitForCondition(
|
||||
() => findVisibleTab(pattern, occurrence),
|
||||
timeout,
|
||||
`Timed out waiting for tab ${pattern}`
|
||||
);
|
||||
|
||||
const tab = findVisibleTab(pattern, occurrence);
|
||||
if (!tab) {
|
||||
throw new Error(`Could not find tab ${pattern}`);
|
||||
}
|
||||
|
||||
await humanPause(120, 260);
|
||||
simulateClick(tab);
|
||||
await sleep(400);
|
||||
return tab;
|
||||
}
|
||||
|
||||
function getCreateAddressInput() {
|
||||
return Array.from(document.querySelectorAll('input[placeholder="请输入"]')).find(isVisible) || null;
|
||||
}
|
||||
|
||||
function getPrefixSwitch() {
|
||||
return Array.from(document.querySelectorAll('[role="switch"], .n-switch')).find(isVisible) || null;
|
||||
}
|
||||
|
||||
function getCreateInputGroup() {
|
||||
return getCreateAddressInput()?.closest('.n-input-group') || null;
|
||||
}
|
||||
|
||||
function getCreateDomainSelect() {
|
||||
const group = getCreateInputGroup();
|
||||
|
||||
return Array.from(group?.querySelectorAll('.n-base-selection') || []).find(isVisible)
|
||||
|| Array.from(group?.querySelectorAll('.n-select') || []).find(isVisible)
|
||||
|| null;
|
||||
}
|
||||
|
||||
function getCurrentDomain() {
|
||||
const groupText = normalizeText(getCreateInputGroup()?.textContent || '');
|
||||
const match = groupText.match(/@([a-z0-9.-]+\.[a-z]{2,})/i);
|
||||
return normalizeDomainSuffix(match ? match[1] : '');
|
||||
}
|
||||
|
||||
function getVisibleDomainOptionEntries() {
|
||||
const seen = new Set();
|
||||
const entries = [];
|
||||
|
||||
for (const option of Array.from(document.querySelectorAll('.n-base-select-option, [role="option"]'))) {
|
||||
if (!isVisible(option)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const value = normalizeDomainSuffix(getElementText(option));
|
||||
if (!value || seen.has(value)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
seen.add(value);
|
||||
entries.push({ option, value });
|
||||
}
|
||||
|
||||
return entries;
|
||||
}
|
||||
|
||||
async function ensureCreateAccountPage() {
|
||||
await clickTab(/^账号$/, 'first');
|
||||
await clickTab(/^创建账号$/);
|
||||
await waitForCondition(
|
||||
() => getCreateAddressInput(),
|
||||
10000,
|
||||
'Cloudflare Temp Email create form did not load.'
|
||||
);
|
||||
}
|
||||
|
||||
async function ensureMailPage() {
|
||||
await clickTab(/^邮件$/, 'first');
|
||||
await clickTab(/^邮件$/, 'last');
|
||||
await waitForCondition(
|
||||
() => document.querySelector('input[placeholder="留空查询所有地址"]'),
|
||||
10000,
|
||||
'Cloudflare Temp Email mail page did not load.'
|
||||
);
|
||||
}
|
||||
|
||||
async function ensurePrefixDisabled() {
|
||||
const prefixSwitch = getPrefixSwitch();
|
||||
if (!prefixSwitch) {
|
||||
log('Cloudflare Temp Email: Prefix switch not present, assuming prefix is already disabled', 'info');
|
||||
return;
|
||||
}
|
||||
|
||||
if (prefixSwitch.getAttribute('aria-checked') === 'true') {
|
||||
await humanPause(120, 280);
|
||||
simulateClick(prefixSwitch);
|
||||
await waitForCondition(
|
||||
() => prefixSwitch.getAttribute('aria-checked') === 'false',
|
||||
5000,
|
||||
'Cloudflare Temp Email prefix switch did not turn off.'
|
||||
);
|
||||
log('Cloudflare Temp Email: Prefix disabled', 'ok');
|
||||
}
|
||||
}
|
||||
|
||||
async function selectRandomDomainSuffix() {
|
||||
const domainSelect = await waitForCondition(
|
||||
() => getCreateDomainSelect(),
|
||||
5000,
|
||||
'Could not find the Cloudflare Temp Email suffix selector.'
|
||||
);
|
||||
|
||||
await humanPause(120, 260);
|
||||
simulateClick(domainSelect);
|
||||
await sleep(300);
|
||||
|
||||
const optionEntries = await waitForCondition(
|
||||
() => {
|
||||
const entries = getVisibleDomainOptionEntries();
|
||||
return entries.length > 0 ? entries : null;
|
||||
},
|
||||
5000,
|
||||
'Could not find any available Cloudflare Temp Email suffix options.'
|
||||
);
|
||||
|
||||
const suffix = pickRandomSuffix(optionEntries.map((entry) => entry.value));
|
||||
const selectedEntry = optionEntries.find((entry) => entry.value === suffix);
|
||||
|
||||
if (!suffix || !selectedEntry?.option) {
|
||||
throw new Error('Could not choose a Cloudflare Temp Email suffix from the available options.');
|
||||
}
|
||||
|
||||
await humanPause(120, 260);
|
||||
simulateClick(selectedEntry.option);
|
||||
await waitForCondition(
|
||||
() => getCurrentDomain() === suffix,
|
||||
5000,
|
||||
`Cloudflare Temp Email suffix did not switch to ${suffix}.`
|
||||
);
|
||||
|
||||
log(`Cloudflare Temp Email: Selected suffix ${suffix}`, 'info');
|
||||
return suffix;
|
||||
}
|
||||
|
||||
function generateLocalPart() {
|
||||
return generateReadableLocalPart();
|
||||
}
|
||||
|
||||
function findCredentialDialog() {
|
||||
return Array.from(
|
||||
document.querySelectorAll('[role="dialog"], .n-dialog, .n-modal, .n-base-modal, .n-card')
|
||||
).find((el) => isVisible(el) && /邮箱地址凭证/.test(getElementText(el)));
|
||||
}
|
||||
|
||||
function extractCredentialToken(root) {
|
||||
if (!root) return '';
|
||||
|
||||
const candidates = [
|
||||
getElementText(root),
|
||||
...Array.from(root.querySelectorAll('textarea, input, pre, code')).map((el) => el.value || el.textContent || ''),
|
||||
];
|
||||
|
||||
for (const candidate of candidates) {
|
||||
const match = String(candidate || '').match(/[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+/);
|
||||
if (match) {
|
||||
return match[0];
|
||||
}
|
||||
}
|
||||
|
||||
return '';
|
||||
}
|
||||
|
||||
async function waitForCredentialDetails(timeout = 15000) {
|
||||
return waitForCondition(() => {
|
||||
const dialog = findCredentialDialog();
|
||||
const token = extractCredentialToken(dialog);
|
||||
const credential = parseCloudflareMailboxCredential(token);
|
||||
if (!credential?.email) {
|
||||
return null;
|
||||
}
|
||||
return credential;
|
||||
}, timeout, 'Timed out waiting for Cloudflare Temp Email credential dialog.');
|
||||
}
|
||||
|
||||
async function dismissCredentialDialog() {
|
||||
const dialog = findCredentialDialog();
|
||||
if (!dialog) return;
|
||||
|
||||
const closeButton = Array.from(dialog.querySelectorAll('button, .n-base-close'))
|
||||
.find((el) => isVisible(el) && (/关闭|确定|取消/.test(getElementText(el)) || el.classList?.contains('n-base-close')));
|
||||
|
||||
if (closeButton) {
|
||||
simulateClick(closeButton);
|
||||
await sleep(300);
|
||||
return;
|
||||
}
|
||||
|
||||
document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }));
|
||||
await sleep(300);
|
||||
}
|
||||
|
||||
async function createCloudflareTempEmail(step, payload = {}) {
|
||||
const { generateNew = true } = payload;
|
||||
|
||||
await ensureCreateAccountPage();
|
||||
await ensurePrefixDisabled();
|
||||
await selectRandomDomainSuffix();
|
||||
|
||||
const input = getCreateAddressInput();
|
||||
if (!input) {
|
||||
throw new Error('Could not find the Cloudflare Temp Email address input.');
|
||||
}
|
||||
|
||||
const createButton = findVisibleButton(/^创建新邮箱$/);
|
||||
if (!createButton) {
|
||||
throw new Error('Could not find the "创建新邮箱" button.');
|
||||
}
|
||||
|
||||
for (let attempt = 1; attempt <= 3; attempt++) {
|
||||
const localPart = generateLocalPart();
|
||||
fillInput(input, localPart);
|
||||
await humanPause(120, 260);
|
||||
simulateClick(createButton);
|
||||
log(`Cloudflare Temp Email: Creating mailbox attempt ${attempt}`, 'info');
|
||||
|
||||
try {
|
||||
const credential = await waitForCredentialDetails(10000);
|
||||
await dismissCredentialDialog();
|
||||
|
||||
return {
|
||||
...credential,
|
||||
domain: credential.domain || getCurrentDomain(),
|
||||
generated: Boolean(generateNew),
|
||||
};
|
||||
} catch (err) {
|
||||
if (attempt === 3) {
|
||||
throw err;
|
||||
}
|
||||
log(`Cloudflare Temp Email: Mailbox attempt ${attempt} did not complete, retrying`, 'warn');
|
||||
await dismissCredentialDialog().catch(() => {});
|
||||
await sleep(500);
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error('Cloudflare Temp Email mailbox creation did not succeed.');
|
||||
}
|
||||
|
||||
function getMessageRows() {
|
||||
return Array.from(document.querySelectorAll('.n-thing')).filter(isVisible);
|
||||
}
|
||||
|
||||
function parseMessageRow(row) {
|
||||
const rowText = row?.innerText || getElementText(row);
|
||||
const subject = normalizeText(
|
||||
row.querySelector('.n-thing-header__title')?.textContent
|
||||
|| row.querySelector('h3, h4, h2')?.textContent
|
||||
|| ''
|
||||
);
|
||||
const messageId = rowText.match(/ID:\s*([^\s]+)/i)?.[1] || null;
|
||||
const timestampText = rowText.match(/\d{4}\/\d{1,2}\/\d{1,2}\s+\d{1,2}:\d{2}:\d{2}/)?.[0] || '';
|
||||
const sender = normalizeText(rowText.match(/FROM:\s*([^\n]+)/i)?.[1] || '');
|
||||
const matchedEmail = normalizeEmail(rowText.match(/TO:\s*([^\n]+)/i)?.[1] || '');
|
||||
|
||||
return {
|
||||
combinedText: normalizeText(rowText),
|
||||
emailTimestamp: null,
|
||||
matchedEmail,
|
||||
messageId,
|
||||
row,
|
||||
sender,
|
||||
subject,
|
||||
timestampText,
|
||||
};
|
||||
}
|
||||
|
||||
function findMessageDetailRoot() {
|
||||
const deleteButton = Array.from(document.querySelectorAll('button'))
|
||||
.find((button) => isVisible(button) && /^删除$/.test(normalizeText(getElementText(button))));
|
||||
|
||||
let current = deleteButton?.parentElement || null;
|
||||
while (current && current !== document.body) {
|
||||
const text = getElementText(current);
|
||||
if (/FROM:/i.test(text) && /TO:/i.test(text)) {
|
||||
return current;
|
||||
}
|
||||
current = current.parentElement;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
async function openMessageRow(row, subject) {
|
||||
await humanPause(80, 180);
|
||||
simulateClick(row);
|
||||
await waitForCondition(() => {
|
||||
const detailRoot = findMessageDetailRoot();
|
||||
const detailText = normalizeText(getElementText(detailRoot));
|
||||
if (!detailText) return null;
|
||||
if (!subject || detailText.includes(subject)) {
|
||||
return detailRoot;
|
||||
}
|
||||
return null;
|
||||
}, 4000, `Timed out opening message ${subject || ''}`.trim());
|
||||
}
|
||||
|
||||
function buildMessageDetailText() {
|
||||
return normalizeText(getElementText(findMessageDetailRoot()));
|
||||
}
|
||||
|
||||
async function collectMessagesForTarget(targetEmail) {
|
||||
const normalizedTargetEmail = normalizeEmail(targetEmail);
|
||||
const rows = getMessageRows();
|
||||
const messages = [];
|
||||
|
||||
for (const row of rows) {
|
||||
const message = parseMessageRow(row);
|
||||
if (!message.matchedEmail || message.matchedEmail !== normalizedTargetEmail) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!extractVerificationCode(`${message.subject} ${message.combinedText}`)) {
|
||||
await openMessageRow(row, message.subject);
|
||||
message.combinedText = `${message.combinedText} ${buildMessageDetailText()}`.trim();
|
||||
}
|
||||
|
||||
messages.push(message);
|
||||
}
|
||||
|
||||
return messages;
|
||||
}
|
||||
|
||||
async function runMailQuery(targetEmail) {
|
||||
const queryInput = document.querySelector('input[placeholder="留空查询所有地址"]');
|
||||
if (!queryInput) {
|
||||
throw new Error('Could not find the admin mail query input.');
|
||||
}
|
||||
|
||||
const queryButton = findVisibleButton(/^查询$/);
|
||||
if (!queryButton) {
|
||||
throw new Error('Could not find the admin mail query button.');
|
||||
}
|
||||
|
||||
fillInput(queryInput, targetEmail);
|
||||
await humanPause(80, 180);
|
||||
simulateClick(queryButton);
|
||||
await sleep(800);
|
||||
}
|
||||
|
||||
async function refreshMailList() {
|
||||
const refreshButton = findVisibleButton(/^刷新$/);
|
||||
if (!refreshButton) {
|
||||
throw new Error('Could not find the admin mail refresh button.');
|
||||
}
|
||||
|
||||
simulateClick(refreshButton);
|
||||
await sleep(1000);
|
||||
}
|
||||
|
||||
async function pollCloudflareTempEmail(step, payload = {}) {
|
||||
const {
|
||||
filterAfterTimestamp = 0,
|
||||
intervalMs = 3000,
|
||||
maxAttempts = 20,
|
||||
senderFilters = [],
|
||||
subjectFilters = [],
|
||||
targetEmail = '',
|
||||
} = payload;
|
||||
|
||||
if (!targetEmail) {
|
||||
throw new Error('No target email provided for Cloudflare Temp Email polling.');
|
||||
}
|
||||
|
||||
await ensureMailPage();
|
||||
await runMailQuery(targetEmail);
|
||||
|
||||
log(`Step ${step}: Starting Cloudflare Temp Email poll for ${targetEmail}`, 'info');
|
||||
|
||||
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
|
||||
log(`Step ${step}: Polling Cloudflare Temp Email... attempt ${attempt}/${maxAttempts}`, 'info');
|
||||
await refreshMailList();
|
||||
|
||||
const messages = await collectMessagesForTarget(targetEmail);
|
||||
const match = selectVerificationMessage(messages, {
|
||||
filterAfterTimestamp,
|
||||
senderFilters,
|
||||
subjectFilters,
|
||||
targetEmail,
|
||||
});
|
||||
|
||||
if (match?.code) {
|
||||
log(
|
||||
`Step ${step}: Code found: ${match.code} (subject: ${(match.subject || '').slice(0, 60)})`,
|
||||
'ok'
|
||||
);
|
||||
return {
|
||||
ok: true,
|
||||
code: match.code,
|
||||
emailTimestamp: match.emailTimestamp,
|
||||
matchedEmail: match.matchedEmail,
|
||||
messageId: match.messageId,
|
||||
subject: match.subject,
|
||||
};
|
||||
}
|
||||
|
||||
if (attempt < maxAttempts) {
|
||||
await sleep(intervalMs);
|
||||
}
|
||||
}
|
||||
|
||||
const newerSuffix = Number(filterAfterTimestamp) > 0
|
||||
? ' newer than the previous verification message'
|
||||
: '';
|
||||
|
||||
throw new Error(
|
||||
`No matching verification email${newerSuffix} was found for ${targetEmail} after ${(maxAttempts * intervalMs / 1000).toFixed(0)}s.`
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
// content/duck-mail.js — Content script for DuckDuckGo Email Protection autofill settings
|
||||
|
||||
console.log('[MultiPage:duck-mail] Content script loaded on', location.href);
|
||||
|
||||
chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
|
||||
if (message.type !== 'FETCH_DUCK_EMAIL') return;
|
||||
|
||||
resetStopState();
|
||||
fetchDuckEmail(message.payload).then(result => {
|
||||
sendResponse(result);
|
||||
}).catch(err => {
|
||||
if (isStopError(err)) {
|
||||
log('Duck Mail: Stopped by user.', 'warn');
|
||||
sendResponse({ stopped: true, error: err.message });
|
||||
return;
|
||||
}
|
||||
sendResponse({ error: err.message });
|
||||
});
|
||||
|
||||
return true;
|
||||
});
|
||||
|
||||
async function fetchDuckEmail(payload = {}) {
|
||||
const { generateNew = true } = payload;
|
||||
|
||||
log(`Duck Mail: ${generateNew ? 'Generating' : 'Reading'} private address...`);
|
||||
|
||||
await waitForElement(
|
||||
'input.AutofillSettingsPanel__PrivateDuckAddressValue, button.AutofillSettingsPanel__GeneratorButton',
|
||||
15000
|
||||
);
|
||||
|
||||
const getAddressInput = () => document.querySelector('input.AutofillSettingsPanel__PrivateDuckAddressValue');
|
||||
const getGeneratorButton = () => document.querySelector('button.AutofillSettingsPanel__GeneratorButton')
|
||||
|| Array.from(document.querySelectorAll('button')).find(btn => /generate private duck address/i.test(btn.textContent || ''));
|
||||
const readEmail = () => {
|
||||
const value = getAddressInput()?.value?.trim() || '';
|
||||
return value.includes('@duck.com') ? value : '';
|
||||
};
|
||||
|
||||
const waitForEmailValue = async (previousValue = '') => {
|
||||
for (let i = 0; i < 100; i++) {
|
||||
const nextValue = readEmail();
|
||||
if (nextValue && nextValue !== previousValue) {
|
||||
return nextValue;
|
||||
}
|
||||
await sleep(150);
|
||||
}
|
||||
throw new Error('Timed out waiting for Duck address to appear.');
|
||||
};
|
||||
|
||||
const currentEmail = readEmail();
|
||||
if (currentEmail && !generateNew) {
|
||||
log(`Duck Mail: Found existing address ${currentEmail}`);
|
||||
return { email: currentEmail, generated: false };
|
||||
}
|
||||
|
||||
await humanPause(500, 1300);
|
||||
const generatorButton = getGeneratorButton();
|
||||
if (!generatorButton) {
|
||||
if (currentEmail) {
|
||||
log(`Duck Mail: Reusing existing address ${currentEmail}`, 'warn');
|
||||
return { email: currentEmail, generated: false };
|
||||
}
|
||||
throw new Error('Could not find "Generate Private Duck Address" button.');
|
||||
}
|
||||
|
||||
generatorButton.click();
|
||||
log('Duck Mail: Clicked "Generate Private Duck Address"');
|
||||
|
||||
const nextEmail = await waitForEmailValue(currentEmail);
|
||||
log(`Duck Mail: Ready address ${nextEmail}`, 'ok');
|
||||
return { email: nextEmail, generated: true };
|
||||
}
|
||||
@@ -0,0 +1,258 @@
|
||||
// content/inbucket-mail.js — Content script for Inbucket polling (steps 4, 7)
|
||||
// Injected dynamically on the configured Inbucket host
|
||||
//
|
||||
// Supported page:
|
||||
// - /m/<mailbox>/
|
||||
|
||||
const INBUCKET_PREFIX = '[MultiPage:inbucket-mail]';
|
||||
const isTopFrame = window === window.top;
|
||||
const SEEN_MAIL_IDS_KEY = 'seenInbucketMailIds';
|
||||
|
||||
console.log(INBUCKET_PREFIX, 'Content script loaded on', location.href, 'frame:', isTopFrame ? 'top' : 'child');
|
||||
|
||||
if (!isTopFrame) {
|
||||
console.log(INBUCKET_PREFIX, 'Skipping child frame');
|
||||
} else {
|
||||
|
||||
let seenMailIds = new Set();
|
||||
|
||||
async function loadSeenMailIds() {
|
||||
try {
|
||||
const data = await chrome.storage.session.get(SEEN_MAIL_IDS_KEY);
|
||||
if (Array.isArray(data[SEEN_MAIL_IDS_KEY])) {
|
||||
seenMailIds = new Set(data[SEEN_MAIL_IDS_KEY]);
|
||||
console.log(INBUCKET_PREFIX, `Loaded ${seenMailIds.size} previously seen mail ids`);
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn(INBUCKET_PREFIX, 'Session storage unavailable, using in-memory seen mail ids:', err?.message || err);
|
||||
}
|
||||
}
|
||||
|
||||
async function persistSeenMailIds() {
|
||||
try {
|
||||
await chrome.storage.session.set({ [SEEN_MAIL_IDS_KEY]: [...seenMailIds] });
|
||||
} catch (err) {
|
||||
console.warn(INBUCKET_PREFIX, 'Could not persist seen mail ids, continuing in-memory only:', err?.message || err);
|
||||
}
|
||||
}
|
||||
|
||||
loadSeenMailIds();
|
||||
|
||||
chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
|
||||
if (message.type === 'POLL_EMAIL') {
|
||||
resetStopState();
|
||||
handlePollEmail(message.step, message.payload).then(result => {
|
||||
sendResponse(result);
|
||||
}).catch(err => {
|
||||
if (isStopError(err)) {
|
||||
log(`Step ${message.step}: Stopped by user.`, 'warn');
|
||||
sendResponse({ stopped: true, error: err.message });
|
||||
return;
|
||||
}
|
||||
reportError(message.step, err.message);
|
||||
sendResponse({ error: err.message });
|
||||
});
|
||||
return true;
|
||||
}
|
||||
});
|
||||
|
||||
function normalizeText(value) {
|
||||
return (value || '').replace(/\s+/g, ' ').trim().toLowerCase();
|
||||
}
|
||||
|
||||
function extractVerificationCode(text) {
|
||||
const matchCn = text.match(/(?:代码为|验证码[^0-9]*?)[\s::]*(\d{6})/);
|
||||
if (matchCn) return matchCn[1];
|
||||
|
||||
const matchEn = text.match(/code[:\s]+is[:\s]+(\d{6})|code[:\s]+(\d{6})/i);
|
||||
if (matchEn) return matchEn[1] || matchEn[2];
|
||||
|
||||
const match6 = text.match(/\b(\d{6})\b/);
|
||||
if (match6) return match6[1];
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function rowMatchesFilters(mail, senderFilters, subjectFilters, targetEmail) {
|
||||
const sender = normalizeText(mail.sender);
|
||||
const subject = normalizeText(mail.subject);
|
||||
const mailbox = normalizeText(mail.mailbox);
|
||||
const combined = normalizeText(mail.combinedText);
|
||||
const targetLocal = normalizeText((targetEmail || '').split('@')[0]);
|
||||
|
||||
const senderMatch = senderFilters.some(f => sender.includes(f.toLowerCase()) || combined.includes(f.toLowerCase()));
|
||||
const subjectMatch = subjectFilters.some(f => subject.includes(f.toLowerCase()) || combined.includes(f.toLowerCase()));
|
||||
const mailboxMatch = Boolean(targetLocal) && mailbox.includes(targetLocal);
|
||||
const forwardedDuck = /duckduckgo|forward(?:ed)?\s*by/i.test(mail.combinedText);
|
||||
const code = extractVerificationCode(mail.combinedText);
|
||||
const keywordMatch = /openai|chatgpt|verify|verification|confirm|login|验证码|代码/.test(combined);
|
||||
|
||||
if (mailboxMatch) return { matched: true, mailboxMatch, code };
|
||||
if (senderMatch || subjectMatch) return { matched: true, mailboxMatch: false, code };
|
||||
if (code && (forwardedDuck || keywordMatch)) return { matched: true, mailboxMatch: false, code };
|
||||
|
||||
return { matched: false, mailboxMatch: false, code };
|
||||
}
|
||||
|
||||
function findMailboxEntries() {
|
||||
return document.querySelectorAll('.message-list-entry');
|
||||
}
|
||||
|
||||
function getMailboxEntryId(entry, index = 0) {
|
||||
const explicitId = entry.getAttribute('data-id') || entry.dataset?.id || '';
|
||||
if (explicitId) return explicitId;
|
||||
|
||||
const subject = entry.querySelector('.subject')?.textContent?.trim() || '';
|
||||
const sender = entry.querySelector('.from')?.textContent?.trim() || '';
|
||||
const dateText = entry.querySelector('.date')?.textContent?.trim() || '';
|
||||
|
||||
return `mailbox:${index}:${normalizeText(subject)}|${normalizeText(sender)}|${normalizeText(dateText)}`;
|
||||
}
|
||||
|
||||
function parseMailboxEntry(entry, index = 0) {
|
||||
const subject = entry.querySelector('.subject')?.textContent?.trim() || '';
|
||||
const sender = entry.querySelector('.from')?.textContent?.trim() || '';
|
||||
const dateText = entry.querySelector('.date')?.textContent?.trim() || '';
|
||||
const combinedText = [subject, sender, dateText].filter(Boolean).join(' ');
|
||||
|
||||
return {
|
||||
entry,
|
||||
dateText,
|
||||
sender,
|
||||
mailbox: '',
|
||||
subject,
|
||||
unread: entry.classList.contains('unseen'),
|
||||
combinedText,
|
||||
mailId: getMailboxEntryId(entry, index),
|
||||
};
|
||||
}
|
||||
|
||||
function getCurrentMailboxIds() {
|
||||
const ids = new Set();
|
||||
Array.from(findMailboxEntries()).forEach((entry, index) => {
|
||||
ids.add(getMailboxEntryId(entry, index));
|
||||
});
|
||||
return ids;
|
||||
}
|
||||
|
||||
async function refreshMailbox() {
|
||||
const refreshButton = document.querySelector('button[alt="Refresh Mailbox"]');
|
||||
if (!refreshButton) return;
|
||||
|
||||
simulateClick(refreshButton);
|
||||
await sleep(800);
|
||||
}
|
||||
|
||||
async function openMailboxEntry(entry) {
|
||||
simulateClick(entry);
|
||||
|
||||
for (let i = 0; i < 20; i++) {
|
||||
if (entry.classList.contains('selected') || document.querySelector('.message-header, .message-body, .button-bar')) {
|
||||
return;
|
||||
}
|
||||
await sleep(150);
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteCurrentMailboxMessage(step) {
|
||||
try {
|
||||
const deleteButton = await waitForElement('.button-bar button.danger', 5000);
|
||||
simulateClick(deleteButton);
|
||||
log(`Step ${step}: Deleted mailbox message`, 'ok');
|
||||
await sleep(1200);
|
||||
} catch (err) {
|
||||
log(`Step ${step}: Failed to delete mailbox message: ${err.message}`, 'warn');
|
||||
}
|
||||
}
|
||||
|
||||
async function handleMailboxPollEmail(step, payload) {
|
||||
const {
|
||||
senderFilters = [],
|
||||
subjectFilters = [],
|
||||
maxAttempts = 20,
|
||||
intervalMs = 3000,
|
||||
} = payload || {};
|
||||
|
||||
log(`Step ${step}: Starting email poll on Inbucket mailbox page (max ${maxAttempts} attempts)`);
|
||||
|
||||
try {
|
||||
await waitForElement('.message-list, .message-list-entry', 15000);
|
||||
log(`Step ${step}: Mailbox page loaded`);
|
||||
} catch {
|
||||
throw new Error('Inbucket mailbox page did not load. Make sure /m/<mailbox>/ is open.');
|
||||
}
|
||||
|
||||
const existingMailIds = getCurrentMailboxIds();
|
||||
log(`Step ${step}: Snapshotted ${existingMailIds.size} existing mailbox messages`);
|
||||
|
||||
const FALLBACK_AFTER = 3;
|
||||
|
||||
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
|
||||
log(`Polling Inbucket mailbox... attempt ${attempt}/${maxAttempts}`);
|
||||
|
||||
if (attempt > 1) {
|
||||
await refreshMailbox();
|
||||
}
|
||||
|
||||
const entries = Array.from(findMailboxEntries()).map(parseMailboxEntry);
|
||||
const useFallback = attempt > FALLBACK_AFTER;
|
||||
const candidates = [];
|
||||
|
||||
for (const mail of entries) {
|
||||
if (!mail.unread) continue;
|
||||
if (seenMailIds.has(mail.mailId)) continue;
|
||||
if (!useFallback && existingMailIds.has(mail.mailId)) continue;
|
||||
|
||||
const match = rowMatchesFilters(mail, senderFilters, subjectFilters, '');
|
||||
if (!match.matched) continue;
|
||||
|
||||
candidates.push({ ...mail, code: match.code });
|
||||
}
|
||||
|
||||
for (const mail of candidates) {
|
||||
const code = mail.code || extractVerificationCode(mail.combinedText);
|
||||
if (!code) continue;
|
||||
|
||||
await openMailboxEntry(mail.entry);
|
||||
await deleteCurrentMailboxMessage(step);
|
||||
|
||||
seenMailIds.add(mail.mailId);
|
||||
await persistSeenMailIds();
|
||||
|
||||
const source = existingMailIds.has(mail.mailId) ? 'fallback' : 'new';
|
||||
log(
|
||||
`Step ${step}: Code found: ${code} (${source}, sender: ${mail.sender || 'unknown'}, subject: ${(mail.subject || '').slice(0, 60)})`,
|
||||
'ok'
|
||||
);
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
code,
|
||||
emailTimestamp: Date.now(),
|
||||
mailId: mail.mailId,
|
||||
};
|
||||
}
|
||||
|
||||
if (attempt === FALLBACK_AFTER + 1) {
|
||||
log(`Step ${step}: No new mailbox messages yet, falling back to older matching messages`, 'warn');
|
||||
}
|
||||
|
||||
if (attempt < maxAttempts) {
|
||||
await sleep(intervalMs);
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error(
|
||||
`No matching verification email found in Inbucket mailbox after ${(maxAttempts * intervalMs / 1000).toFixed(0)}s. ` +
|
||||
'Check the mailbox page manually.'
|
||||
);
|
||||
}
|
||||
|
||||
async function handlePollEmail(step, payload) {
|
||||
if (!location.pathname.startsWith('/m/')) {
|
||||
throw new Error('Inbucket now only supports mailbox pages like /m/<mailbox>/.');
|
||||
}
|
||||
return handleMailboxPollEmail(step, payload);
|
||||
}
|
||||
|
||||
} // end of isTopFrame else block
|
||||
@@ -0,0 +1,296 @@
|
||||
// content/mail-163.js — Content script for 163 Mail (steps 4, 7)
|
||||
// Injected on: mail.163.com
|
||||
//
|
||||
// DOM structure:
|
||||
// Mail item: div[sign="letter"] with aria-label="你的 ChatGPT 代码为 479637 发件人 : OpenAI ..."
|
||||
// Sender: .nui-user (e.g., "OpenAI")
|
||||
// Subject: span.da0 (e.g., "你的 ChatGPT 代码为 479637")
|
||||
// Right-click menu: .nui-menu → .nui-menu-item with text "删除邮件"
|
||||
|
||||
const MAIL163_PREFIX = '[MultiPage:mail-163]';
|
||||
const isTopFrame = window === window.top;
|
||||
|
||||
console.log(MAIL163_PREFIX, 'Content script loaded on', location.href, 'frame:', isTopFrame ? 'top' : 'child');
|
||||
|
||||
// Only operate in the top frame
|
||||
if (!isTopFrame) {
|
||||
console.log(MAIL163_PREFIX, 'Skipping child frame');
|
||||
} else {
|
||||
|
||||
// Track codes we've already seen — persisted in chrome.storage.session to survive script re-injection
|
||||
let seenCodes = new Set();
|
||||
|
||||
async function loadSeenCodes() {
|
||||
try {
|
||||
const data = await chrome.storage.session.get('seenCodes');
|
||||
if (data.seenCodes && Array.isArray(data.seenCodes)) {
|
||||
seenCodes = new Set(data.seenCodes);
|
||||
console.log(MAIL163_PREFIX, `Loaded ${seenCodes.size} previously seen codes`);
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn(MAIL163_PREFIX, 'Session storage unavailable, using in-memory seen codes:', err?.message || err);
|
||||
}
|
||||
}
|
||||
|
||||
// Load previously seen codes on startup
|
||||
loadSeenCodes();
|
||||
|
||||
async function persistSeenCodes() {
|
||||
try {
|
||||
await chrome.storage.session.set({ seenCodes: [...seenCodes] });
|
||||
} catch (err) {
|
||||
console.warn(MAIL163_PREFIX, 'Could not persist seen codes, continuing in-memory only:', err?.message || err);
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Message Handler (top frame only)
|
||||
// ============================================================
|
||||
|
||||
chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
|
||||
if (message.type === 'POLL_EMAIL') {
|
||||
resetStopState();
|
||||
handlePollEmail(message.step, message.payload).then(result => {
|
||||
sendResponse(result);
|
||||
}).catch(err => {
|
||||
if (isStopError(err)) {
|
||||
log(`Step ${message.step}: Stopped by user.`, 'warn');
|
||||
sendResponse({ stopped: true, error: err.message });
|
||||
return;
|
||||
}
|
||||
reportError(message.step, err.message);
|
||||
sendResponse({ error: err.message });
|
||||
});
|
||||
return true;
|
||||
}
|
||||
});
|
||||
|
||||
// ============================================================
|
||||
// Find mail items
|
||||
// ============================================================
|
||||
|
||||
function findMailItems() {
|
||||
return document.querySelectorAll('div[sign="letter"]');
|
||||
}
|
||||
|
||||
function getCurrentMailIds() {
|
||||
const ids = new Set();
|
||||
findMailItems().forEach(item => {
|
||||
const id = item.getAttribute('id') || '';
|
||||
if (id) ids.add(id);
|
||||
});
|
||||
return ids;
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Email Polling
|
||||
// ============================================================
|
||||
|
||||
async function handlePollEmail(step, payload) {
|
||||
const { senderFilters, subjectFilters, maxAttempts, intervalMs } = payload;
|
||||
|
||||
log(`Step ${step}: Starting email poll on 163 Mail (max ${maxAttempts} attempts)`);
|
||||
|
||||
// Click inbox in sidebar to ensure we're in inbox view
|
||||
log(`Step ${step}: Waiting for sidebar...`);
|
||||
try {
|
||||
const inboxLink = await waitForElement('.nui-tree-item-text[title="收件箱"]', 5000);
|
||||
inboxLink.click();
|
||||
log(`Step ${step}: Clicked inbox`);
|
||||
} catch {
|
||||
log(`Step ${step}: Inbox link not found, proceeding...`, 'warn');
|
||||
}
|
||||
|
||||
// Wait for mail list to appear
|
||||
log(`Step ${step}: Waiting for mail list...`);
|
||||
let items = [];
|
||||
for (let i = 0; i < 20; i++) {
|
||||
items = findMailItems();
|
||||
if (items.length > 0) break;
|
||||
await sleep(500);
|
||||
}
|
||||
|
||||
if (items.length === 0) {
|
||||
await refreshInbox();
|
||||
await sleep(2000);
|
||||
items = findMailItems();
|
||||
}
|
||||
|
||||
if (items.length === 0) {
|
||||
throw new Error('163 Mail list did not load. Make sure inbox is open.');
|
||||
}
|
||||
|
||||
log(`Step ${step}: Mail list loaded, ${items.length} items`);
|
||||
|
||||
// Snapshot existing mail IDs
|
||||
const existingMailIds = getCurrentMailIds();
|
||||
log(`Step ${step}: Snapshotted ${existingMailIds.size} existing emails`);
|
||||
|
||||
const FALLBACK_AFTER = 3;
|
||||
|
||||
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
|
||||
log(`Polling 163 Mail... attempt ${attempt}/${maxAttempts}`);
|
||||
|
||||
if (attempt > 1) {
|
||||
await refreshInbox();
|
||||
await sleep(1000);
|
||||
}
|
||||
|
||||
const allItems = findMailItems();
|
||||
const useFallback = attempt > FALLBACK_AFTER;
|
||||
|
||||
for (const item of allItems) {
|
||||
const id = item.getAttribute('id') || '';
|
||||
|
||||
if (!useFallback && existingMailIds.has(id)) continue;
|
||||
|
||||
const senderEl = item.querySelector('.nui-user');
|
||||
const sender = senderEl ? senderEl.textContent.toLowerCase() : '';
|
||||
|
||||
const subjectEl = item.querySelector('span.da0');
|
||||
const subject = subjectEl ? subjectEl.textContent : '';
|
||||
|
||||
const ariaLabel = (item.getAttribute('aria-label') || '').toLowerCase();
|
||||
|
||||
const senderMatch = senderFilters.some(f => sender.includes(f.toLowerCase()) || ariaLabel.includes(f.toLowerCase()));
|
||||
const subjectMatch = subjectFilters.some(f => subject.toLowerCase().includes(f.toLowerCase()) || ariaLabel.includes(f.toLowerCase()));
|
||||
|
||||
if (senderMatch || subjectMatch) {
|
||||
const code = extractVerificationCode(subject + ' ' + ariaLabel);
|
||||
if (code && !seenCodes.has(code)) {
|
||||
seenCodes.add(code);
|
||||
persistSeenCodes();
|
||||
const source = useFallback && existingMailIds.has(id) ? 'fallback' : 'new';
|
||||
log(`Step ${step}: Code found: ${code} (${source}, subject: ${subject.slice(0, 40)})`, 'ok');
|
||||
|
||||
// Delete this email via right-click menu, WAIT for it to finish before returning
|
||||
await deleteEmail(item, step);
|
||||
// Extra wait to ensure deletion is processed
|
||||
await sleep(1000);
|
||||
|
||||
return { ok: true, code, emailTimestamp: Date.now(), mailId: id };
|
||||
} else if (code && seenCodes.has(code)) {
|
||||
log(`Step ${step}: Skipping already-seen code: ${code}`, 'info');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (attempt === FALLBACK_AFTER + 1) {
|
||||
log(`Step ${step}: No new emails after ${FALLBACK_AFTER} attempts, falling back to first match`, 'warn');
|
||||
}
|
||||
|
||||
if (attempt < maxAttempts) {
|
||||
await sleep(intervalMs);
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error(
|
||||
`No new matching email found on 163 Mail after ${(maxAttempts * intervalMs / 1000).toFixed(0)}s. ` +
|
||||
'Check inbox manually.'
|
||||
);
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Delete Email via Right-Click Menu
|
||||
// ============================================================
|
||||
|
||||
async function deleteEmail(item, step) {
|
||||
try {
|
||||
log(`Step ${step}: Deleting email...`);
|
||||
|
||||
// Strategy 1: Click the trash icon inside the mail item
|
||||
// Each mail item has: <b class="nui-ico nui-ico-delete" title="删除邮件" sign="trash">
|
||||
// These icons appear on hover, so we trigger mouseover first
|
||||
item.dispatchEvent(new MouseEvent('mouseover', { bubbles: true }));
|
||||
item.dispatchEvent(new MouseEvent('mouseenter', { bubbles: true }));
|
||||
await sleep(300);
|
||||
|
||||
const trashIcon = item.querySelector('[sign="trash"], .nui-ico-delete, [title="删除邮件"]');
|
||||
if (trashIcon) {
|
||||
trashIcon.click();
|
||||
log(`Step ${step}: Clicked trash icon`, 'ok');
|
||||
await sleep(1500);
|
||||
|
||||
// Check if item disappeared (confirm deletion)
|
||||
const stillExists = document.getElementById(item.id);
|
||||
if (!stillExists || stillExists.style.display === 'none') {
|
||||
log(`Step ${step}: Email deleted successfully`);
|
||||
} else {
|
||||
log(`Step ${step}: Email may not have been deleted, item still visible`, 'warn');
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Strategy 2: Select checkbox then click toolbar delete button
|
||||
log(`Step ${step}: Trash icon not found, trying checkbox + toolbar delete...`);
|
||||
const checkbox = item.querySelector('[sign="checkbox"], .nui-chk');
|
||||
if (checkbox) {
|
||||
checkbox.click();
|
||||
await sleep(300);
|
||||
|
||||
// Click toolbar delete button
|
||||
const toolbarBtns = document.querySelectorAll('.nui-btn .nui-btn-text');
|
||||
for (const btn of toolbarBtns) {
|
||||
if (btn.textContent.replace(/\s/g, '').includes('删除')) {
|
||||
btn.closest('.nui-btn').click();
|
||||
log(`Step ${step}: Clicked toolbar delete`, 'ok');
|
||||
await sleep(1500);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
log(`Step ${step}: Could not delete email (no delete button found)`, 'warn');
|
||||
} catch (err) {
|
||||
log(`Step ${step}: Failed to delete email: ${err.message}`, 'warn');
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Inbox Refresh
|
||||
// ============================================================
|
||||
|
||||
async function refreshInbox() {
|
||||
// Try toolbar "刷 新" button
|
||||
const toolbarBtns = document.querySelectorAll('.nui-btn .nui-btn-text');
|
||||
for (const btn of toolbarBtns) {
|
||||
if (btn.textContent.replace(/\s/g, '') === '刷新') {
|
||||
btn.closest('.nui-btn').click();
|
||||
console.log(MAIL163_PREFIX, 'Clicked "刷新" button');
|
||||
await sleep(800);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: click sidebar "收 信"
|
||||
const shouXinBtns = document.querySelectorAll('.ra0');
|
||||
for (const btn of shouXinBtns) {
|
||||
if (btn.textContent.replace(/\s/g, '').includes('收信')) {
|
||||
btn.click();
|
||||
console.log(MAIL163_PREFIX, 'Clicked "收信" button');
|
||||
await sleep(800);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
console.log(MAIL163_PREFIX, 'Could not find refresh button');
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Verification Code Extraction
|
||||
// ============================================================
|
||||
|
||||
function extractVerificationCode(text) {
|
||||
const matchCn = text.match(/(?:代码为|验证码[^0-9]*?)[\s::]*(\d{6})/);
|
||||
if (matchCn) return matchCn[1];
|
||||
|
||||
const matchEn = text.match(/code[:\s]+is[:\s]+(\d{6})|code[:\s]+(\d{6})/i);
|
||||
if (matchEn) return matchEn[1] || matchEn[2];
|
||||
|
||||
const match6 = text.match(/\b(\d{6})\b/);
|
||||
if (match6) return match6[1];
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
} // end of isTopFrame else block
|
||||
147
DouYinSparkFlow/webui/static/multiPagePlugins/content/qq-mail.js
Normal file
@@ -0,0 +1,147 @@
|
||||
// content/qq-mail.js — Content script for QQ Mail (steps 4, 7)
|
||||
// Injected on: mail.qq.com, wx.mail.qq.com
|
||||
// NOTE: all_frames: true
|
||||
//
|
||||
// Strategy for avoiding stale codes:
|
||||
// 1. On poll start, snapshot all existing mail IDs as "old"
|
||||
// 2. On each poll cycle, refresh inbox and look for NEW items (not in snapshot)
|
||||
// 3. Only extract codes from NEW items that match sender/subject filters
|
||||
// 4. Never fall back to older matching emails
|
||||
|
||||
const QQ_MAIL_PREFIX = '[MultiPage:qq-mail]';
|
||||
const isTopFrame = window === window.top;
|
||||
|
||||
console.log(QQ_MAIL_PREFIX, 'Content script loaded on', location.href, 'frame:', isTopFrame ? 'top' : 'child');
|
||||
|
||||
// ============================================================
|
||||
// Message Handler
|
||||
// ============================================================
|
||||
|
||||
chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
|
||||
if (message.type === 'POLL_EMAIL') {
|
||||
if (!isTopFrame) {
|
||||
sendResponse({ ok: false, reason: 'wrong-frame' });
|
||||
return;
|
||||
}
|
||||
resetStopState();
|
||||
handlePollEmail(message.step, message.payload).then(result => {
|
||||
sendResponse(result);
|
||||
}).catch(err => {
|
||||
if (isStopError(err)) {
|
||||
log(`Step ${message.step}: Stopped by user.`, 'warn');
|
||||
sendResponse({ stopped: true, error: err.message });
|
||||
return;
|
||||
}
|
||||
reportError(message.step, err.message);
|
||||
sendResponse({ error: err.message });
|
||||
});
|
||||
return true; // async response
|
||||
}
|
||||
});
|
||||
|
||||
// ============================================================
|
||||
// Get all current mail IDs from the list
|
||||
// ============================================================
|
||||
|
||||
function getCurrentMailIds() {
|
||||
const ids = new Set();
|
||||
document.querySelectorAll('.mail-list-page-item[data-mailid]').forEach(item => {
|
||||
ids.add(item.getAttribute('data-mailid'));
|
||||
});
|
||||
return ids;
|
||||
}
|
||||
|
||||
function collectMailItems() {
|
||||
return Array.from(document.querySelectorAll('.mail-list-page-item[data-mailid]')).map((item) => ({
|
||||
mailId: item.getAttribute('data-mailid') || '',
|
||||
sender: item.querySelector('.cmp-account-nick')?.textContent || '',
|
||||
subject: item.querySelector('.mail-subject')?.textContent || '',
|
||||
digest: item.querySelector('.mail-digest')?.textContent || '',
|
||||
}));
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Email Polling
|
||||
// ============================================================
|
||||
|
||||
async function handlePollEmail(step, payload) {
|
||||
const { senderFilters, subjectFilters, maxAttempts, intervalMs } = payload;
|
||||
|
||||
log(`Step ${step}: Starting email poll (max ${maxAttempts} attempts, every ${intervalMs / 1000}s)`);
|
||||
|
||||
// Wait for mail list to load
|
||||
try {
|
||||
await waitForElement('.mail-list-page-item', 10000);
|
||||
log(`Step ${step}: Mail list loaded`);
|
||||
} catch {
|
||||
throw new Error('Mail list did not load. Make sure QQ Mail inbox is open.');
|
||||
}
|
||||
|
||||
// Step 1: Snapshot existing mail IDs BEFORE we start waiting for new email
|
||||
const existingMailIds = getCurrentMailIds();
|
||||
log(`Step ${step}: Snapshotted ${existingMailIds.size} existing emails as "old"`);
|
||||
|
||||
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
|
||||
log(`Polling QQ Mail... attempt ${attempt}/${maxAttempts}`);
|
||||
|
||||
// Refresh inbox (skip on first attempt, list is fresh)
|
||||
if (attempt > 1) {
|
||||
await refreshInbox();
|
||||
await sleep(800);
|
||||
}
|
||||
|
||||
const result = MultiPageQQMail.findNewQQVerificationCode(collectMailItems(), {
|
||||
existingMailIds: [...existingMailIds],
|
||||
senderFilters,
|
||||
subjectFilters,
|
||||
});
|
||||
|
||||
if (result) {
|
||||
log(`Step ${step}: Code found: ${result.code} (${result.source}, subject: ${result.subject.slice(0, 40)})`, 'ok');
|
||||
return { ok: true, code: result.code, emailTimestamp: Date.now(), mailId: result.mailId };
|
||||
}
|
||||
|
||||
if (attempt < maxAttempts) {
|
||||
await sleep(intervalMs);
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error(
|
||||
`No new matching email found after ${(maxAttempts * intervalMs / 1000).toFixed(0)}s. ` +
|
||||
'Check QQ Mail manually. Email may be delayed or in spam folder.'
|
||||
);
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Inbox Refresh
|
||||
// ============================================================
|
||||
|
||||
async function refreshInbox() {
|
||||
// Try multiple strategies to refresh the mail list
|
||||
|
||||
// Strategy 1: Click any visible refresh button
|
||||
const refreshBtn = document.querySelector('[class*="refresh"], [title*="刷新"]');
|
||||
if (refreshBtn) {
|
||||
simulateClick(refreshBtn);
|
||||
console.log(QQ_MAIL_PREFIX, 'Clicked refresh button');
|
||||
await sleep(500);
|
||||
return;
|
||||
}
|
||||
|
||||
// Strategy 2: Click inbox in sidebar to reload list
|
||||
const sidebarInbox = document.querySelector('a[href*="inbox"], [class*="folder-item"][class*="inbox"], [title="收件箱"]');
|
||||
if (sidebarInbox) {
|
||||
simulateClick(sidebarInbox);
|
||||
console.log(QQ_MAIL_PREFIX, 'Clicked sidebar inbox');
|
||||
await sleep(500);
|
||||
return;
|
||||
}
|
||||
|
||||
// Strategy 3: Click the folder name in toolbar
|
||||
const folderName = document.querySelector('.toolbar-folder-name');
|
||||
if (folderName) {
|
||||
simulateClick(folderName);
|
||||
console.log(QQ_MAIL_PREFIX, 'Clicked toolbar folder name');
|
||||
await sleep(500);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,265 @@
|
||||
// content/relay-firefox.js — Content script for Firefox Relay profile page
|
||||
|
||||
console.log('[MultiPage:relay-firefox] Content script loaded on', location.href);
|
||||
|
||||
const {
|
||||
getNextRelayMaskLabel = (labels = []) => `t${labels.length + 1}`,
|
||||
} = globalThis.MultiPageEmailProvider || {};
|
||||
|
||||
const LABEL_INPUT_SELECTOR = 'input[placeholder="Add account name"], input[aria-label="Edit the label for this mask"]';
|
||||
|
||||
chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
|
||||
if (message.type !== 'CREATE_RELAY_MASK' && message.type !== 'DELETE_RELAY_MASK') return;
|
||||
|
||||
resetStopState();
|
||||
|
||||
const handler = message.type === 'CREATE_RELAY_MASK'
|
||||
? createRelayMask
|
||||
: deleteRelayMask;
|
||||
|
||||
handler(message.payload || {}).then(result => {
|
||||
sendResponse(result);
|
||||
}).catch(err => {
|
||||
if (isStopError(err)) {
|
||||
log('Relay: Stopped by user.', 'warn');
|
||||
sendResponse({ stopped: true, error: err.message });
|
||||
return;
|
||||
}
|
||||
sendResponse({ error: err.message });
|
||||
});
|
||||
|
||||
return true;
|
||||
});
|
||||
|
||||
function getElementText(el) {
|
||||
return [
|
||||
el?.innerText,
|
||||
el?.textContent,
|
||||
el?.getAttribute?.('aria-label'),
|
||||
el?.getAttribute?.('title'),
|
||||
el?.getAttribute?.('description'),
|
||||
].filter(Boolean).join(' ');
|
||||
}
|
||||
|
||||
function isVisible(el) {
|
||||
if (!el) return false;
|
||||
if (el.hidden) return false;
|
||||
const style = window.getComputedStyle(el);
|
||||
if (style.display === 'none' || style.visibility === 'hidden' || style.opacity === '0') {
|
||||
return false;
|
||||
}
|
||||
return Boolean(el.offsetWidth || el.offsetHeight || el.getClientRects().length);
|
||||
}
|
||||
|
||||
function extractMozmail(text) {
|
||||
const match = String(text || '').match(/[A-Z0-9._%+-]+@mozmail\.com/i);
|
||||
return match ? match[0].toLowerCase() : '';
|
||||
}
|
||||
|
||||
function getMaskButtons(root = document) {
|
||||
return Array.from(root.querySelectorAll('button')).filter((button) => extractMozmail(getElementText(button)));
|
||||
}
|
||||
|
||||
function getMaskEmails(root = document) {
|
||||
return Array.from(new Set(
|
||||
getMaskButtons(root)
|
||||
.map((button) => extractMozmail(getElementText(button)))
|
||||
.filter(Boolean)
|
||||
));
|
||||
}
|
||||
|
||||
function getVisibleLabelInputs(root = document) {
|
||||
return Array.from(root.querySelectorAll(LABEL_INPUT_SELECTOR)).filter(isVisible);
|
||||
}
|
||||
|
||||
function getExistingLabels() {
|
||||
return Array.from(document.querySelectorAll(LABEL_INPUT_SELECTOR))
|
||||
.map((input) => input.value.trim())
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
function findGenerateButton() {
|
||||
return document.querySelector('button[title="Generate new mask"]')
|
||||
|| Array.from(document.querySelectorAll('button')).find((button) => /generate new mask/i.test(getElementText(button)));
|
||||
}
|
||||
|
||||
function findDeleteButton(root) {
|
||||
return Array.from(root.querySelectorAll('button')).find((button) => /^delete$/i.test(getElementText(button).trim()));
|
||||
}
|
||||
|
||||
function getMaskButtonsIn(root) {
|
||||
return Array.from(root.querySelectorAll('button')).filter((button) => extractMozmail(getElementText(button)));
|
||||
}
|
||||
|
||||
function findMaskContainerForButton(button) {
|
||||
let current = button?.parentElement || null;
|
||||
|
||||
while (current && current !== document.body) {
|
||||
const maskButtons = getMaskButtonsIn(current);
|
||||
if (maskButtons.length === 1 && (current.querySelector(LABEL_INPUT_SELECTOR) || findDeleteButton(current))) {
|
||||
return current;
|
||||
}
|
||||
current = current.parentElement;
|
||||
}
|
||||
|
||||
return button?.closest('li') || button?.parentElement || null;
|
||||
}
|
||||
|
||||
function findMaskRowByEmail(email) {
|
||||
const normalizedEmail = String(email || '').toLowerCase();
|
||||
const button = getMaskButtons().find((candidate) => extractMozmail(getElementText(candidate)) === normalizedEmail);
|
||||
if (!button) return null;
|
||||
return findMaskContainerForButton(button);
|
||||
}
|
||||
|
||||
async function waitForNewMaskEmail(previousEmails = new Set(), timeout = 15000) {
|
||||
const startedAt = Date.now();
|
||||
|
||||
while (Date.now() - startedAt < timeout) {
|
||||
throwIfStopped();
|
||||
const currentEmails = getMaskEmails();
|
||||
const nextEmail = currentEmails.find((email) => !previousEmails.has(email));
|
||||
if (nextEmail) {
|
||||
return nextEmail;
|
||||
}
|
||||
await sleep(150);
|
||||
}
|
||||
|
||||
throw new Error('Timed out waiting for a new Relay mask to appear.');
|
||||
}
|
||||
|
||||
async function waitForMaskRow(email, timeout = 10000) {
|
||||
const startedAt = Date.now();
|
||||
|
||||
while (Date.now() - startedAt < timeout) {
|
||||
throwIfStopped();
|
||||
const row = findMaskRowByEmail(email);
|
||||
if (row) {
|
||||
return row;
|
||||
}
|
||||
await sleep(150);
|
||||
}
|
||||
|
||||
throw new Error(`Timed out waiting for Relay mask row: ${email}`);
|
||||
}
|
||||
|
||||
async function assignRelayLabel(maskRow) {
|
||||
const labelInput = Array.from(maskRow.querySelectorAll(LABEL_INPUT_SELECTOR)).find(isVisible)
|
||||
|| maskRow.querySelector(LABEL_INPUT_SELECTOR);
|
||||
|
||||
if (!labelInput) {
|
||||
throw new Error('Could not find Relay label input for the new mask.');
|
||||
}
|
||||
|
||||
const currentValue = labelInput.value.trim();
|
||||
if (currentValue) {
|
||||
return currentValue;
|
||||
}
|
||||
|
||||
const nextLabel = getNextRelayMaskLabel(getExistingLabels());
|
||||
|
||||
await humanPause(200, 450);
|
||||
fillInput(labelInput, nextLabel);
|
||||
labelInput.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true }));
|
||||
labelInput.dispatchEvent(new KeyboardEvent('keyup', { key: 'Enter', bubbles: true }));
|
||||
labelInput.blur();
|
||||
|
||||
for (let i = 0; i < 20; i++) {
|
||||
throwIfStopped();
|
||||
const labels = getExistingLabels();
|
||||
if (labels.includes(nextLabel) || labelInput.value.trim() === nextLabel) {
|
||||
log(`Relay: Assigned label ${nextLabel}`, 'ok');
|
||||
return nextLabel;
|
||||
}
|
||||
await sleep(150);
|
||||
}
|
||||
|
||||
throw new Error(`Relay label ${nextLabel} was not saved.`);
|
||||
}
|
||||
|
||||
async function createRelayMask(payload = {}) {
|
||||
const { generateNew = true } = payload;
|
||||
|
||||
log(`Relay: ${generateNew ? 'Creating' : 'Reading'} mask...`);
|
||||
await waitForElement(LABEL_INPUT_SELECTOR + ', button[title="Generate new mask"]', 20000);
|
||||
|
||||
const previousEmails = new Set(getMaskEmails());
|
||||
if (!generateNew && previousEmails.size > 0) {
|
||||
const email = Array.from(previousEmails)[0];
|
||||
return { email, label: null, generated: false };
|
||||
}
|
||||
|
||||
const generatorButton = findGenerateButton();
|
||||
if (!generatorButton) {
|
||||
throw new Error('Could not find "Generate new mask" button on Firefox Relay.');
|
||||
}
|
||||
|
||||
await humanPause(500, 1200);
|
||||
simulateClick(generatorButton);
|
||||
log('Relay: Clicked "Generate new mask"');
|
||||
|
||||
const email = await waitForNewMaskEmail(previousEmails);
|
||||
const maskRow = await waitForMaskRow(email);
|
||||
const label = await assignRelayLabel(maskRow);
|
||||
|
||||
log(`Relay: Ready mask ${email}`, 'ok');
|
||||
return { email, label, generated: true };
|
||||
}
|
||||
|
||||
function findVisibleDialogDeleteButton() {
|
||||
const dialogButtons = Array.from(document.querySelectorAll('[role="dialog"] button, dialog button, [aria-modal="true"] button'));
|
||||
return dialogButtons.find((button) => isVisible(button) && /delete|confirm|remove/i.test(getElementText(button)));
|
||||
}
|
||||
|
||||
async function waitForMaskRemoval(email, timeout = 15000) {
|
||||
const startedAt = Date.now();
|
||||
const normalizedEmail = String(email || '').toLowerCase();
|
||||
|
||||
while (Date.now() - startedAt < timeout) {
|
||||
throwIfStopped();
|
||||
const exists = getMaskEmails().includes(normalizedEmail);
|
||||
if (!exists) {
|
||||
return;
|
||||
}
|
||||
await sleep(200);
|
||||
}
|
||||
|
||||
throw new Error(`Timed out waiting for Relay mask deletion: ${email}`);
|
||||
}
|
||||
|
||||
async function deleteRelayMask(payload = {}) {
|
||||
const email = String(payload.email || '').trim().toLowerCase();
|
||||
if (!email) {
|
||||
throw new Error('No Relay mask email provided for deletion.');
|
||||
}
|
||||
|
||||
log(`Relay: Deleting ${email}...`);
|
||||
await waitForElement(LABEL_INPUT_SELECTOR + ', button[title="Generate new mask"]', 20000);
|
||||
|
||||
const maskRow = await waitForMaskRow(email);
|
||||
const detailsButton = Array.from(maskRow.querySelectorAll('button')).find((button) => /show mask details/i.test(getElementText(button)));
|
||||
if (detailsButton && isVisible(detailsButton)) {
|
||||
await humanPause(150, 300);
|
||||
simulateClick(detailsButton);
|
||||
await sleep(250);
|
||||
}
|
||||
|
||||
const deleteButton = findDeleteButton(maskRow);
|
||||
if (!deleteButton) {
|
||||
throw new Error(`Could not find Delete button for Relay mask ${email}.`);
|
||||
}
|
||||
|
||||
await humanPause(200, 400);
|
||||
simulateClick(deleteButton);
|
||||
|
||||
await sleep(300);
|
||||
const confirmButton = findVisibleDialogDeleteButton();
|
||||
if (confirmButton) {
|
||||
await humanPause(150, 300);
|
||||
simulateClick(confirmButton);
|
||||
}
|
||||
|
||||
await waitForMaskRemoval(email);
|
||||
log(`Relay: Deleted ${email}`, 'ok');
|
||||
return { deleted: true, email };
|
||||
}
|
||||
@@ -0,0 +1,569 @@
|
||||
// content/signup-page.js — Content script for OpenAI auth pages (steps 2, 3, 4-receive, 5)
|
||||
// Injected on: auth0.openai.com, auth.openai.com, accounts.openai.com
|
||||
|
||||
console.log('[MultiPage:signup-page] Content script loaded on', location.href);
|
||||
|
||||
// Listen for commands from Background
|
||||
chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
|
||||
if (message.type === 'GET_PAGE_STATE') {
|
||||
handleCommand(message).then((result) => {
|
||||
sendResponse({ ok: true, ...(result || {}) });
|
||||
}).catch(err => {
|
||||
sendResponse({ error: err.message });
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
if (message.type === 'EXECUTE_STEP' || message.type === 'FILL_CODE' || message.type === 'STEP8_FIND_AND_CLICK') {
|
||||
resetStopState();
|
||||
handleCommand(message).then((result) => {
|
||||
sendResponse({ ok: true, ...(result || {}) });
|
||||
}).catch(err => {
|
||||
if (isStopError(err)) {
|
||||
log(`Step ${message.step || 8}: Stopped by user.`, 'warn');
|
||||
sendResponse({ stopped: true, error: err.message });
|
||||
return;
|
||||
}
|
||||
|
||||
if (message.type === 'STEP8_FIND_AND_CLICK') {
|
||||
log(`Step 8: ${err.message}`, 'error');
|
||||
sendResponse({ error: err.message });
|
||||
return;
|
||||
}
|
||||
|
||||
reportError(message.step, err.message);
|
||||
sendResponse({ error: err.message });
|
||||
});
|
||||
return true;
|
||||
}
|
||||
});
|
||||
|
||||
async function handleCommand(message) {
|
||||
switch (message.type) {
|
||||
case 'GET_PAGE_STATE':
|
||||
return getCurrentPageState();
|
||||
case 'EXECUTE_STEP':
|
||||
switch (message.step) {
|
||||
case 2: return await step2_clickRegister();
|
||||
case 3: return await step3_fillEmailPassword(message.payload);
|
||||
case 5: return await step5_fillNameBirthday(message.payload);
|
||||
case 6: return await step6_login(message.payload);
|
||||
case 8: return await step8_findAndClick();
|
||||
default: throw new Error(`signup-page.js does not handle step ${message.step}`);
|
||||
}
|
||||
case 'FILL_CODE':
|
||||
// Step 4 = signup code, Step 7 = login code (same handler)
|
||||
return await fillVerificationCode(message.step, message.payload);
|
||||
case 'STEP8_FIND_AND_CLICK':
|
||||
return await step8_findAndClick();
|
||||
}
|
||||
}
|
||||
|
||||
function getCurrentPageState() {
|
||||
const consentButton = findVisibleConsentButton();
|
||||
const hasVisibleContinueButton = Boolean(consentButton);
|
||||
|
||||
return {
|
||||
url: location.href,
|
||||
hasVisibleContinueButton,
|
||||
isConsentPage: MultiPageOAuthFlow.isConsentPageState({
|
||||
url: location.href,
|
||||
hasVisibleContinueButton,
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Step 2: Click Register
|
||||
// ============================================================
|
||||
|
||||
async function step2_clickRegister() {
|
||||
log('Step 2: Looking for Register/Sign up button...');
|
||||
|
||||
let registerBtn = null;
|
||||
try {
|
||||
registerBtn = await waitForElementByText(
|
||||
'a, button, [role="button"], [role="link"]',
|
||||
/sign\s*up|register|create\s*account|注册/i,
|
||||
10000
|
||||
);
|
||||
} catch {
|
||||
// Some pages may have a direct link
|
||||
try {
|
||||
registerBtn = await waitForElement('a[href*="signup"], a[href*="register"]', 5000);
|
||||
} catch {
|
||||
throw new Error(
|
||||
'Could not find Register/Sign up button. ' +
|
||||
'Check auth page DOM in DevTools. URL: ' + location.href
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
await humanPause(450, 1200);
|
||||
reportComplete(2);
|
||||
simulateClick(registerBtn);
|
||||
log('Step 2: Clicked Register button');
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Step 3: Fill Email & Password
|
||||
// ============================================================
|
||||
|
||||
async function step3_fillEmailPassword(payload) {
|
||||
const { email } = payload;
|
||||
if (!email) throw new Error('No email provided. Paste email in Side Panel first.');
|
||||
|
||||
log(`Step 3: Filling email: ${email}`);
|
||||
|
||||
// Find email input
|
||||
let emailInput = null;
|
||||
try {
|
||||
emailInput = await waitForElement(
|
||||
'input[type="email"], input[name="email"], input[name="username"], input[id*="email"], input[placeholder*="email"], input[placeholder*="Email"]',
|
||||
10000
|
||||
);
|
||||
} catch {
|
||||
throw new Error('Could not find email input field on signup page. URL: ' + location.href);
|
||||
}
|
||||
|
||||
await humanPause(500, 1400);
|
||||
fillInput(emailInput, email);
|
||||
log('Step 3: Email filled');
|
||||
|
||||
// Check if password field is on the same page
|
||||
let passwordInput = document.querySelector('input[type="password"]');
|
||||
|
||||
if (!passwordInput) {
|
||||
// Need to submit email first to get to password page
|
||||
log('Step 3: No password field yet, submitting email first...');
|
||||
const submitBtn = document.querySelector('button[type="submit"]')
|
||||
|| await waitForElementByText('button', /continue|next|submit|继续|下一步/i, 5000).catch(() => null);
|
||||
|
||||
if (submitBtn) {
|
||||
await humanPause(400, 1100);
|
||||
simulateClick(submitBtn);
|
||||
log('Step 3: Submitted email, waiting for password field...');
|
||||
await sleep(2000);
|
||||
}
|
||||
|
||||
try {
|
||||
passwordInput = await waitForElement('input[type="password"]', 10000);
|
||||
} catch {
|
||||
throw new Error('Could not find password input after submitting email. URL: ' + location.href);
|
||||
}
|
||||
}
|
||||
|
||||
if (!payload.password) throw new Error('No password provided. Step 3 requires a generated password.');
|
||||
await humanPause(600, 1500);
|
||||
fillInput(passwordInput, payload.password);
|
||||
log('Step 3: Password filled');
|
||||
|
||||
// Report complete BEFORE submit, because submit causes page navigation
|
||||
// which kills the content script connection
|
||||
reportComplete(3, { email });
|
||||
|
||||
// Submit the form (page will navigate away after this)
|
||||
await sleep(500);
|
||||
const submitBtn = document.querySelector('button[type="submit"]')
|
||||
|| await waitForElementByText('button', /continue|sign\s*up|submit|注册|创建|create/i, 5000).catch(() => null);
|
||||
|
||||
if (submitBtn) {
|
||||
await humanPause(500, 1300);
|
||||
simulateClick(submitBtn);
|
||||
log('Step 3: Form submitted');
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Fill Verification Code (used by step 4 and step 7)
|
||||
// ============================================================
|
||||
|
||||
async function fillVerificationCode(step, payload) {
|
||||
const { code } = payload;
|
||||
if (!code) throw new Error('No verification code provided.');
|
||||
|
||||
log(`Step ${step}: Filling verification code: ${code}`);
|
||||
|
||||
// Find code input — could be a single input or multiple separate inputs
|
||||
let codeInput = null;
|
||||
try {
|
||||
codeInput = await waitForElement(
|
||||
'input[name="code"], input[name="otp"], input[type="text"][maxlength="6"], input[aria-label*="code"], input[placeholder*="code"], input[placeholder*="Code"], input[inputmode="numeric"]',
|
||||
10000
|
||||
);
|
||||
} catch {
|
||||
// Check for multiple single-digit inputs (common pattern)
|
||||
const singleInputs = document.querySelectorAll('input[maxlength="1"]');
|
||||
if (singleInputs.length >= 6) {
|
||||
log(`Step ${step}: Found single-digit code inputs, filling individually...`);
|
||||
for (let i = 0; i < 6 && i < singleInputs.length; i++) {
|
||||
fillInput(singleInputs[i], code[i]);
|
||||
await sleep(100);
|
||||
}
|
||||
await sleep(1000);
|
||||
reportComplete(step);
|
||||
return;
|
||||
}
|
||||
throw new Error('Could not find verification code input. URL: ' + location.href);
|
||||
}
|
||||
|
||||
fillInput(codeInput, code);
|
||||
log(`Step ${step}: Code filled`);
|
||||
|
||||
// Report complete BEFORE submit (page may navigate away)
|
||||
reportComplete(step);
|
||||
|
||||
// Submit
|
||||
await sleep(500);
|
||||
const submitBtn = document.querySelector('button[type="submit"]')
|
||||
|| await waitForElementByText('button', /verify|confirm|submit|continue|确认|验证/i, 5000).catch(() => null);
|
||||
|
||||
if (submitBtn) {
|
||||
await humanPause(450, 1200);
|
||||
simulateClick(submitBtn);
|
||||
log(`Step ${step}: Verification submitted`);
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Step 6: Login with registered account (on OAuth auth page)
|
||||
// ============================================================
|
||||
|
||||
async function step6_login(payload) {
|
||||
const { email, password } = payload;
|
||||
if (!email) throw new Error('No email provided for login.');
|
||||
|
||||
log(`Step 6: Logging in with ${email}...`);
|
||||
|
||||
// Wait for email input on the auth page
|
||||
let emailInput = null;
|
||||
try {
|
||||
emailInput = await waitForElement(
|
||||
'input[type="email"], input[name="email"], input[name="username"], input[id*="email"], input[placeholder*="email" i], input[placeholder*="Email"]',
|
||||
15000
|
||||
);
|
||||
} catch {
|
||||
throw new Error('Could not find email input on login page. URL: ' + location.href);
|
||||
}
|
||||
|
||||
await humanPause(500, 1400);
|
||||
fillInput(emailInput, email);
|
||||
log('Step 6: Email filled');
|
||||
|
||||
// Submit email
|
||||
await sleep(500);
|
||||
const submitBtn1 = document.querySelector('button[type="submit"]')
|
||||
|| await waitForElementByText('button', /continue|next|submit|继续|下一步/i, 5000).catch(() => null);
|
||||
if (submitBtn1) {
|
||||
await humanPause(400, 1100);
|
||||
simulateClick(submitBtn1);
|
||||
log('Step 6: Submitted email');
|
||||
}
|
||||
|
||||
const passwordInput = await waitForLoginPasswordField();
|
||||
if (passwordInput) {
|
||||
log('Step 6: Password field found, filling password...');
|
||||
await humanPause(550, 1450);
|
||||
fillInput(passwordInput, password);
|
||||
|
||||
await sleep(500);
|
||||
const submitBtn2 = document.querySelector('button[type="submit"]')
|
||||
|| await waitForElementByText('button', /continue|log\s*in|submit|sign\s*in|登录|继续/i, 5000).catch(() => null);
|
||||
// Report complete BEFORE submit in case page navigates
|
||||
reportComplete(6, { needsOTP: true });
|
||||
|
||||
if (submitBtn2) {
|
||||
await humanPause(450, 1200);
|
||||
simulateClick(submitBtn2);
|
||||
log('Step 6: Submitted password, may need verification code (step 7)');
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// No password field — OTP flow
|
||||
log('Step 6: No password field. OTP flow or auto-redirect.');
|
||||
reportComplete(6, { needsOTP: true });
|
||||
}
|
||||
|
||||
async function waitForLoginPasswordField(timeout = 25000) {
|
||||
const start = Date.now();
|
||||
|
||||
while (Date.now() - start < timeout) {
|
||||
throwIfStopped();
|
||||
|
||||
const passwordInput = findVisiblePasswordInput();
|
||||
if (passwordInput) {
|
||||
return passwordInput;
|
||||
}
|
||||
|
||||
await sleep(250);
|
||||
}
|
||||
|
||||
log(`Step 6: Password field did not appear within ${Math.round(timeout / 1000)}s.`, 'warn');
|
||||
return null;
|
||||
}
|
||||
|
||||
function findVisiblePasswordInput() {
|
||||
const inputs = document.querySelectorAll('input[type="password"]');
|
||||
for (const input of inputs) {
|
||||
if (isElementVisible(input)) {
|
||||
return input;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function isElementVisible(el) {
|
||||
if (!el) return false;
|
||||
const style = window.getComputedStyle(el);
|
||||
if (style.display === 'none' || style.visibility === 'hidden' || style.opacity === '0') {
|
||||
return false;
|
||||
}
|
||||
const rect = el.getBoundingClientRect();
|
||||
return rect.width > 0 && rect.height > 0;
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Step 8: Find "继续" on OAuth consent page for debugger click
|
||||
// ============================================================
|
||||
// After login + verification, page shows:
|
||||
// "使用 ChatGPT 登录到 Codex" with a "继续" submit button.
|
||||
// Background performs the actual click through the debugger Input API.
|
||||
|
||||
async function step8_findAndClick() {
|
||||
log('Step 8: Looking for OAuth consent "继续" button...');
|
||||
|
||||
const continueBtn = await findContinueButton();
|
||||
await waitForButtonEnabled(continueBtn);
|
||||
|
||||
await humanPause(350, 900);
|
||||
continueBtn.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||
continueBtn.focus();
|
||||
await sleep(250);
|
||||
|
||||
const rect = getSerializableRect(continueBtn);
|
||||
log('Step 8: Found "继续" button and prepared debugger click coordinates.');
|
||||
return {
|
||||
rect,
|
||||
buttonText: (continueBtn.textContent || '').trim(),
|
||||
url: location.href,
|
||||
};
|
||||
}
|
||||
|
||||
async function findContinueButton() {
|
||||
const visibleButton = findVisibleConsentButton();
|
||||
if (visibleButton) {
|
||||
return visibleButton;
|
||||
}
|
||||
|
||||
try {
|
||||
return await waitForElement(
|
||||
'button[type="submit"][data-dd-action-name="Continue"], button[type="submit"]._primary_3rdp0_107',
|
||||
10000
|
||||
);
|
||||
} catch {
|
||||
try {
|
||||
return await waitForElementByText('button', /继续|Continue/, 5000);
|
||||
} catch {
|
||||
throw new Error('Could not find "继续" button on OAuth consent page. URL: ' + location.href);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function findVisibleConsentButton() {
|
||||
const selectorMatches = document.querySelectorAll(
|
||||
'button[type="submit"][data-dd-action-name="Continue"], button[type="submit"]._primary_3rdp0_107'
|
||||
);
|
||||
|
||||
for (const button of selectorMatches) {
|
||||
if (isElementVisible(button)) {
|
||||
return button;
|
||||
}
|
||||
}
|
||||
|
||||
const buttons = document.querySelectorAll('button');
|
||||
for (const button of buttons) {
|
||||
if (!isElementVisible(button)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (/继续|Continue/i.test(button.textContent || '')) {
|
||||
return button;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
async function waitForButtonEnabled(button, timeout = 8000) {
|
||||
const start = Date.now();
|
||||
while (Date.now() - start < timeout) {
|
||||
throwIfStopped();
|
||||
if (isButtonEnabled(button)) return;
|
||||
await sleep(150);
|
||||
}
|
||||
throw new Error('"继续" button stayed disabled for too long. URL: ' + location.href);
|
||||
}
|
||||
|
||||
function isButtonEnabled(button) {
|
||||
return Boolean(button)
|
||||
&& !button.disabled
|
||||
&& button.getAttribute('aria-disabled') !== 'true';
|
||||
}
|
||||
|
||||
function getSerializableRect(el) {
|
||||
const rect = el.getBoundingClientRect();
|
||||
if (!rect.width || !rect.height) {
|
||||
throw new Error('"继续" button has no clickable size after scrolling. URL: ' + location.href);
|
||||
}
|
||||
|
||||
return {
|
||||
left: rect.left,
|
||||
top: rect.top,
|
||||
width: rect.width,
|
||||
height: rect.height,
|
||||
centerX: rect.left + (rect.width / 2),
|
||||
centerY: rect.top + (rect.height / 2),
|
||||
};
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Step 5: Fill Name & Birthday / Age
|
||||
// ============================================================
|
||||
|
||||
async function step5_fillNameBirthday(payload) {
|
||||
const { firstName, lastName, age, year, month, day } = payload;
|
||||
if (!firstName || !lastName) throw new Error('No name data provided.');
|
||||
|
||||
const resolvedAge = age ?? (year ? new Date().getFullYear() - Number(year) : null);
|
||||
const hasBirthdayData = [year, month, day].every(value => value != null && !Number.isNaN(Number(value)));
|
||||
if (!hasBirthdayData && (resolvedAge == null || Number.isNaN(Number(resolvedAge)))) {
|
||||
throw new Error('No birthday or age data provided.');
|
||||
}
|
||||
|
||||
const fullName = `${firstName} ${lastName}`;
|
||||
log(`Step 5: Filling name: ${fullName}`);
|
||||
|
||||
// Actual DOM structure:
|
||||
// - Full name: <input name="name" placeholder="全名" type="text">
|
||||
// - Birthday: React Aria DateField or hidden input[name="birthday"]
|
||||
// - Age: <input name="age" type="text|number">
|
||||
|
||||
// --- Full Name (single field, not first+last) ---
|
||||
let nameInput = null;
|
||||
try {
|
||||
nameInput = await waitForElement(
|
||||
'input[name="name"], input[placeholder*="全名"], input[autocomplete="name"]',
|
||||
10000
|
||||
);
|
||||
} catch {
|
||||
throw new Error('Could not find name input. URL: ' + location.href);
|
||||
}
|
||||
await humanPause(500, 1300);
|
||||
fillInput(nameInput, fullName);
|
||||
log(`Step 5: Name filled: ${fullName}`);
|
||||
|
||||
let birthdayMode = false;
|
||||
let ageInput = null;
|
||||
|
||||
for (let i = 0; i < 100; i++) {
|
||||
const yearSpinner = document.querySelector('[role="spinbutton"][data-type="year"]');
|
||||
const monthSpinner = document.querySelector('[role="spinbutton"][data-type="month"]');
|
||||
const daySpinner = document.querySelector('[role="spinbutton"][data-type="day"]');
|
||||
const hiddenBirthday = document.querySelector('input[name="birthday"]');
|
||||
ageInput = document.querySelector('input[name="age"]');
|
||||
|
||||
// Some pages include a hidden birthday input even though the real UI is "age".
|
||||
// In that case we must prioritize filling age to satisfy required validation.
|
||||
if (ageInput) break;
|
||||
|
||||
if ((yearSpinner && monthSpinner && daySpinner) || hiddenBirthday) {
|
||||
birthdayMode = true;
|
||||
break;
|
||||
}
|
||||
await sleep(100);
|
||||
}
|
||||
|
||||
if (birthdayMode) {
|
||||
if (!hasBirthdayData) {
|
||||
throw new Error('Birthday field detected, but no birthday data provided.');
|
||||
}
|
||||
|
||||
const yearSpinner = document.querySelector('[role="spinbutton"][data-type="year"]');
|
||||
const monthSpinner = document.querySelector('[role="spinbutton"][data-type="month"]');
|
||||
const daySpinner = document.querySelector('[role="spinbutton"][data-type="day"]');
|
||||
|
||||
if (yearSpinner && monthSpinner && daySpinner) {
|
||||
log('Step 5: Birthday fields detected, filling birthday...');
|
||||
|
||||
async function setSpinButton(el, value) {
|
||||
el.focus();
|
||||
await sleep(100);
|
||||
document.execCommand('selectAll', false, null);
|
||||
await sleep(50);
|
||||
|
||||
const valueStr = String(value);
|
||||
for (const char of valueStr) {
|
||||
el.dispatchEvent(new KeyboardEvent('keydown', { key: char, code: `Digit${char}`, bubbles: true }));
|
||||
el.dispatchEvent(new KeyboardEvent('keypress', { key: char, code: `Digit${char}`, bubbles: true }));
|
||||
el.dispatchEvent(new InputEvent('beforeinput', { inputType: 'insertText', data: char, bubbles: true }));
|
||||
el.dispatchEvent(new InputEvent('input', { inputType: 'insertText', data: char, bubbles: true }));
|
||||
await sleep(50);
|
||||
}
|
||||
|
||||
el.dispatchEvent(new KeyboardEvent('keyup', { key: 'Tab', code: 'Tab', bubbles: true }));
|
||||
el.blur();
|
||||
await sleep(100);
|
||||
}
|
||||
|
||||
await humanPause(450, 1100);
|
||||
await setSpinButton(yearSpinner, year);
|
||||
await humanPause(250, 650);
|
||||
await setSpinButton(monthSpinner, String(month).padStart(2, '0'));
|
||||
await humanPause(250, 650);
|
||||
await setSpinButton(daySpinner, String(day).padStart(2, '0'));
|
||||
log(`Step 5: Birthday filled: ${year}-${String(month).padStart(2, '0')}-${String(day).padStart(2, '0')}`);
|
||||
}
|
||||
|
||||
const hiddenBirthday = document.querySelector('input[name="birthday"]');
|
||||
if (hiddenBirthday) {
|
||||
const dateStr = `${year}-${String(month).padStart(2, '0')}-${String(day).padStart(2, '0')}`;
|
||||
hiddenBirthday.value = dateStr;
|
||||
hiddenBirthday.dispatchEvent(new Event('change', { bubbles: true }));
|
||||
log(`Step 5: Hidden birthday input set: ${dateStr}`);
|
||||
}
|
||||
} else if (ageInput) {
|
||||
if (resolvedAge == null || Number.isNaN(Number(resolvedAge))) {
|
||||
throw new Error('Age field detected, but no age data provided.');
|
||||
}
|
||||
await humanPause(500, 1300);
|
||||
fillInput(ageInput, String(resolvedAge));
|
||||
log(`Step 5: Age filled: ${resolvedAge}`);
|
||||
|
||||
// Some age-mode pages still submit a hidden birthday field.
|
||||
// Keep it aligned with generated data so backend validation won't reject.
|
||||
const hiddenBirthday = document.querySelector('input[name="birthday"]');
|
||||
if (hiddenBirthday && hasBirthdayData) {
|
||||
const dateStr = `${year}-${String(month).padStart(2, '0')}-${String(day).padStart(2, '0')}`;
|
||||
hiddenBirthday.value = dateStr;
|
||||
hiddenBirthday.dispatchEvent(new Event('change', { bubbles: true }));
|
||||
log(`Step 5: Hidden birthday input set (age mode): ${dateStr}`);
|
||||
}
|
||||
} else {
|
||||
throw new Error('Could not find birthday or age input. URL: ' + location.href);
|
||||
}
|
||||
|
||||
// Click "完成帐户创建" button
|
||||
await sleep(500);
|
||||
const completeBtn = document.querySelector('button[type="submit"]')
|
||||
|| await waitForElementByText('button', /完成|create|continue|finish|done|agree/i, 5000).catch(() => null);
|
||||
|
||||
// Report complete BEFORE submit (page navigates to add-phone after this)
|
||||
reportComplete(5);
|
||||
|
||||
if (completeBtn) {
|
||||
await humanPause(500, 1300);
|
||||
simulateClick(completeBtn);
|
||||
log('Step 5: Clicked "完成帐户创建"');
|
||||
}
|
||||
}
|
||||
337
DouYinSparkFlow/webui/static/multiPagePlugins/content/utils.js
Normal file
@@ -0,0 +1,337 @@
|
||||
// content/utils.js — Shared utilities for all content scripts
|
||||
|
||||
const SCRIPT_SOURCE = (() => {
|
||||
if (window.__MULTIPAGE_SOURCE) return window.__MULTIPAGE_SOURCE;
|
||||
const url = location.href;
|
||||
if (url.includes('auth0.openai.com') || url.includes('auth.openai.com') || url.includes('accounts.openai.com')) return 'signup-page';
|
||||
if (url.includes('mail.qq.com')) return 'qq-mail';
|
||||
if (url.includes('mail.163.com')) return 'mail-163';
|
||||
if (url.includes('duckduckgo.com/email/settings/autofill')) return 'duck-mail';
|
||||
if (url.includes('relay.firefox.com/accounts/profile')) return 'relay-firefox';
|
||||
if (url.includes('mail.cloudflare.com/admin')) return 'cloudflare-temp-email';
|
||||
if (url.includes('chatgpt.com')) return 'chatgpt';
|
||||
// VPS panel — detected dynamically since URL is configurable
|
||||
return 'vps-panel';
|
||||
})();
|
||||
|
||||
const LOG_PREFIX = `[MultiPage:${SCRIPT_SOURCE}]`;
|
||||
const STOP_ERROR_MESSAGE = 'Flow stopped by user.';
|
||||
let flowStopped = false;
|
||||
|
||||
chrome.runtime.onMessage.addListener((message) => {
|
||||
if (message.type === 'STOP_FLOW') {
|
||||
flowStopped = true;
|
||||
console.warn(LOG_PREFIX, STOP_ERROR_MESSAGE);
|
||||
}
|
||||
});
|
||||
|
||||
function resetStopState() {
|
||||
flowStopped = false;
|
||||
}
|
||||
|
||||
function isStopError(error) {
|
||||
const message = typeof error === 'string' ? error : error?.message;
|
||||
return message === STOP_ERROR_MESSAGE;
|
||||
}
|
||||
|
||||
function throwIfStopped() {
|
||||
if (flowStopped) {
|
||||
throw new Error(STOP_ERROR_MESSAGE);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Wait for a DOM element to appear.
|
||||
* @param {string} selector - CSS selector
|
||||
* @param {number} timeout - Max wait time in ms (default 10000)
|
||||
* @returns {Promise<Element>}
|
||||
*/
|
||||
function waitForElement(selector, timeout = 10000) {
|
||||
return new Promise((resolve, reject) => {
|
||||
throwIfStopped();
|
||||
|
||||
const existing = document.querySelector(selector);
|
||||
if (existing) {
|
||||
console.log(LOG_PREFIX, `Found immediately: ${selector}`);
|
||||
log(`Found element: ${selector}`);
|
||||
resolve(existing);
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(LOG_PREFIX, `Waiting for: ${selector} (timeout: ${timeout}ms)`);
|
||||
log(`Waiting for selector: ${selector}...`);
|
||||
|
||||
let settled = false;
|
||||
let stopTimer = null;
|
||||
const cleanup = () => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
observer.disconnect();
|
||||
clearTimeout(timer);
|
||||
clearTimeout(stopTimer);
|
||||
};
|
||||
|
||||
const observer = new MutationObserver(() => {
|
||||
if (flowStopped) {
|
||||
cleanup();
|
||||
reject(new Error(STOP_ERROR_MESSAGE));
|
||||
return;
|
||||
}
|
||||
const el = document.querySelector(selector);
|
||||
if (el) {
|
||||
cleanup();
|
||||
console.log(LOG_PREFIX, `Found after wait: ${selector}`);
|
||||
log(`Found element: ${selector}`);
|
||||
resolve(el);
|
||||
}
|
||||
});
|
||||
|
||||
observer.observe(document.body || document.documentElement, {
|
||||
childList: true,
|
||||
subtree: true,
|
||||
});
|
||||
|
||||
const timer = setTimeout(() => {
|
||||
cleanup();
|
||||
const msg = `Timeout waiting for ${selector} after ${timeout}ms on ${location.href}`;
|
||||
console.error(LOG_PREFIX, msg);
|
||||
reject(new Error(msg));
|
||||
}, timeout);
|
||||
|
||||
const pollStop = () => {
|
||||
if (settled) return;
|
||||
if (flowStopped) {
|
||||
cleanup();
|
||||
reject(new Error(STOP_ERROR_MESSAGE));
|
||||
return;
|
||||
}
|
||||
stopTimer = setTimeout(pollStop, 100);
|
||||
};
|
||||
pollStop();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Wait for an element matching a text pattern among multiple candidates.
|
||||
* @param {string} containerSelector - Selector for candidate elements
|
||||
* @param {RegExp} textPattern - Regex to match against textContent
|
||||
* @param {number} timeout - Max wait time in ms
|
||||
* @returns {Promise<Element>}
|
||||
*/
|
||||
function waitForElementByText(containerSelector, textPattern, timeout = 10000) {
|
||||
return new Promise((resolve, reject) => {
|
||||
throwIfStopped();
|
||||
|
||||
function search() {
|
||||
const candidates = document.querySelectorAll(containerSelector);
|
||||
for (const el of candidates) {
|
||||
if (textPattern.test(el.textContent)) {
|
||||
return el;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
const existing = search();
|
||||
if (existing) {
|
||||
console.log(LOG_PREFIX, `Found by text immediately: ${containerSelector} matching ${textPattern}`);
|
||||
log(`Found element by text: ${textPattern}`);
|
||||
resolve(existing);
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(LOG_PREFIX, `Waiting for text match: ${containerSelector} / ${textPattern}`);
|
||||
log(`Waiting for element with text: ${textPattern}...`);
|
||||
|
||||
let settled = false;
|
||||
let stopTimer = null;
|
||||
const cleanup = () => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
observer.disconnect();
|
||||
clearTimeout(timer);
|
||||
clearTimeout(stopTimer);
|
||||
};
|
||||
|
||||
const observer = new MutationObserver(() => {
|
||||
if (flowStopped) {
|
||||
cleanup();
|
||||
reject(new Error(STOP_ERROR_MESSAGE));
|
||||
return;
|
||||
}
|
||||
const el = search();
|
||||
if (el) {
|
||||
cleanup();
|
||||
console.log(LOG_PREFIX, `Found by text after wait: ${textPattern}`);
|
||||
log(`Found element by text: ${textPattern}`);
|
||||
resolve(el);
|
||||
}
|
||||
});
|
||||
|
||||
observer.observe(document.body || document.documentElement, {
|
||||
childList: true,
|
||||
subtree: true,
|
||||
});
|
||||
|
||||
const timer = setTimeout(() => {
|
||||
cleanup();
|
||||
const msg = `Timeout waiting for text "${textPattern}" in "${containerSelector}" after ${timeout}ms on ${location.href}`;
|
||||
console.error(LOG_PREFIX, msg);
|
||||
reject(new Error(msg));
|
||||
}, timeout);
|
||||
|
||||
const pollStop = () => {
|
||||
if (settled) return;
|
||||
if (flowStopped) {
|
||||
cleanup();
|
||||
reject(new Error(STOP_ERROR_MESSAGE));
|
||||
return;
|
||||
}
|
||||
stopTimer = setTimeout(pollStop, 100);
|
||||
};
|
||||
pollStop();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* React-compatible form filling.
|
||||
* Sets value via native setter and dispatches input + change events.
|
||||
* @param {HTMLInputElement} el
|
||||
* @param {string} value
|
||||
*/
|
||||
function fillInput(el, value) {
|
||||
throwIfStopped();
|
||||
const nativeInputValueSetter = Object.getOwnPropertyDescriptor(
|
||||
window.HTMLInputElement.prototype,
|
||||
'value'
|
||||
).set;
|
||||
nativeInputValueSetter.call(el, value);
|
||||
el.dispatchEvent(new Event('input', { bubbles: true }));
|
||||
el.dispatchEvent(new Event('change', { bubbles: true }));
|
||||
console.log(LOG_PREFIX, `Filled input ${el.name || el.id || el.type} with: ${value}`);
|
||||
log(`Filled input [${el.name || el.id || el.type || 'unknown'}]`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Fill a select element by setting its value and triggering change.
|
||||
* @param {HTMLSelectElement} el
|
||||
* @param {string} value
|
||||
*/
|
||||
function fillSelect(el, value) {
|
||||
throwIfStopped();
|
||||
el.value = value;
|
||||
el.dispatchEvent(new Event('change', { bubbles: true }));
|
||||
console.log(LOG_PREFIX, `Selected value ${value} in ${el.name || el.id}`);
|
||||
log(`Selected [${el.name || el.id || 'unknown'}] = ${value}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a log message to Side Panel via Background.
|
||||
* @param {string} message
|
||||
* @param {string} level - 'info' | 'ok' | 'warn' | 'error'
|
||||
*/
|
||||
function log(message, level = 'info') {
|
||||
chrome.runtime.sendMessage({
|
||||
type: 'LOG',
|
||||
source: SCRIPT_SOURCE,
|
||||
step: null,
|
||||
payload: { message, level, timestamp: Date.now() },
|
||||
error: null,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Report that this content script is loaded and ready.
|
||||
*/
|
||||
function reportReady() {
|
||||
console.log(LOG_PREFIX, 'Content script ready');
|
||||
chrome.runtime.sendMessage({
|
||||
type: 'CONTENT_SCRIPT_READY',
|
||||
source: SCRIPT_SOURCE,
|
||||
step: null,
|
||||
payload: {},
|
||||
error: null,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Report step completion.
|
||||
* @param {number} step
|
||||
* @param {Object} data - Step output data
|
||||
*/
|
||||
function reportComplete(step, data = {}) {
|
||||
console.log(LOG_PREFIX, `Step ${step} completed`, data);
|
||||
log(`Step ${step} completed successfully`, 'ok');
|
||||
chrome.runtime.sendMessage({
|
||||
type: 'STEP_COMPLETE',
|
||||
source: SCRIPT_SOURCE,
|
||||
step,
|
||||
payload: data,
|
||||
error: null,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Report step error.
|
||||
* @param {number} step
|
||||
* @param {string} errorMessage
|
||||
*/
|
||||
function reportError(step, errorMessage) {
|
||||
console.error(LOG_PREFIX, `Step ${step} failed: ${errorMessage}`);
|
||||
log(`Step ${step} failed: ${errorMessage}`, 'error');
|
||||
chrome.runtime.sendMessage({
|
||||
type: 'STEP_ERROR',
|
||||
source: SCRIPT_SOURCE,
|
||||
step,
|
||||
payload: {},
|
||||
error: errorMessage,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Simulate a click with proper event dispatching.
|
||||
* @param {Element} el
|
||||
*/
|
||||
function simulateClick(el) {
|
||||
throwIfStopped();
|
||||
el.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true }));
|
||||
console.log(LOG_PREFIX, `Clicked: ${el.tagName} ${el.textContent?.slice(0, 30) || ''}`);
|
||||
log(`Clicked [${el.tagName}] "${el.textContent?.trim().slice(0, 30) || ''}"`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Wait a specified number of milliseconds.
|
||||
* @param {number} ms
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
function sleep(ms) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const start = Date.now();
|
||||
|
||||
function tick() {
|
||||
if (flowStopped) {
|
||||
reject(new Error(STOP_ERROR_MESSAGE));
|
||||
return;
|
||||
}
|
||||
if (Date.now() - start >= ms) {
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
setTimeout(tick, Math.min(100, Math.max(25, ms - (Date.now() - start))));
|
||||
}
|
||||
|
||||
tick();
|
||||
});
|
||||
}
|
||||
|
||||
async function humanPause(min = 250, max = 850) {
|
||||
const duration = Math.floor(Math.random() * (max - min + 1)) + min;
|
||||
await sleep(duration);
|
||||
}
|
||||
|
||||
// Auto-report ready on load
|
||||
// Skip ready signal from child iframes of mail pages to avoid overwriting the top frame's registration
|
||||
const _isMailChildFrame = (SCRIPT_SOURCE === 'qq-mail' || SCRIPT_SOURCE === 'mail-163' || SCRIPT_SOURCE === 'inbucket-mail') && window !== window.top;
|
||||
if (!_isMailChildFrame) {
|
||||
reportReady();
|
||||
}
|
||||
@@ -0,0 +1,183 @@
|
||||
// content/vps-panel.js — Content script for VPS panel (steps 1, 9)
|
||||
// Injected on: VPS panel (user-configured URL)
|
||||
//
|
||||
// Actual DOM structure (after login click):
|
||||
// <div class="card">
|
||||
// <div class="card-header">
|
||||
// <span class="OAuthPage-module__cardTitle___yFaP0">Codex OAuth</span>
|
||||
// <button class="btn btn-primary"><span>登录</span></button>
|
||||
// </div>
|
||||
// <div class="OAuthPage-module__cardContent___1sXLA">
|
||||
// <div class="OAuthPage-module__authUrlBox___Iu1d4">
|
||||
// <div class="OAuthPage-module__authUrlLabel___mYFJB">授权链接:</div>
|
||||
// <div class="OAuthPage-module__authUrlValue___axvUJ">https://auth.openai.com/...</div>
|
||||
// <div class="OAuthPage-module__authUrlActions___venPj">
|
||||
// <button class="btn btn-secondary btn-sm"><span>复制链接</span></button>
|
||||
// <button class="btn btn-secondary btn-sm"><span>打开链接</span></button>
|
||||
// </div>
|
||||
// </div>
|
||||
// <div class="OAuthPage-module__callbackSection___8kA31">
|
||||
// <input class="input" placeholder="http://localhost:1455/auth/callback?code=...&state=...">
|
||||
// <button class="btn btn-secondary btn-sm"><span>提交回调 URL</span></button>
|
||||
// </div>
|
||||
// </div>
|
||||
// </div>
|
||||
|
||||
console.log('[MultiPage:vps-panel] Content script loaded on', location.href);
|
||||
|
||||
// Listen for commands from Background
|
||||
chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
|
||||
if (message.type === 'EXECUTE_STEP') {
|
||||
resetStopState();
|
||||
handleStep(message.step, message.payload).then(() => {
|
||||
sendResponse({ ok: true });
|
||||
}).catch(err => {
|
||||
if (isStopError(err)) {
|
||||
log(`Step ${message.step}: Stopped by user.`, 'warn');
|
||||
sendResponse({ stopped: true, error: err.message });
|
||||
return;
|
||||
}
|
||||
reportError(message.step, err.message);
|
||||
sendResponse({ error: err.message });
|
||||
});
|
||||
return true;
|
||||
}
|
||||
});
|
||||
|
||||
async function handleStep(step, payload) {
|
||||
switch (step) {
|
||||
case 1: return await step1_getOAuthLink();
|
||||
case 9: return await step9_vpsVerify(payload);
|
||||
default:
|
||||
throw new Error(`vps-panel.js does not handle step ${step}`);
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Step 1: Get OAuth Link
|
||||
// ============================================================
|
||||
|
||||
async function step1_getOAuthLink() {
|
||||
log('Step 1: Waiting for VPS panel to load (auto-login may take a moment)...');
|
||||
|
||||
// The page may start at #/login and auto-redirect to #/oauth.
|
||||
// Wait for the Codex OAuth card to appear (up to 30s for auto-login + redirect).
|
||||
let loginBtn = null;
|
||||
try {
|
||||
// Wait for any card-header containing "Codex" to appear
|
||||
const header = await waitForElementByText('.card-header', /codex/i, 30000);
|
||||
loginBtn = header.querySelector('button.btn.btn-primary, button.btn');
|
||||
log('Step 1: Found Codex OAuth card');
|
||||
} catch {
|
||||
throw new Error(
|
||||
'Codex OAuth card did not appear after 30s. Page may still be loading or not logged in. ' +
|
||||
'Current URL: ' + location.href
|
||||
);
|
||||
}
|
||||
|
||||
if (!loginBtn) {
|
||||
throw new Error('Found Codex OAuth card but no login button inside it. URL: ' + location.href);
|
||||
}
|
||||
|
||||
// Check if button is disabled (already clicked / loading)
|
||||
if (loginBtn.disabled) {
|
||||
log('Step 1: Login button is disabled (already loading), waiting for auth URL...');
|
||||
} else {
|
||||
await humanPause(500, 1400);
|
||||
simulateClick(loginBtn);
|
||||
log('Step 1: Clicked login button, waiting for auth URL...');
|
||||
}
|
||||
|
||||
// Wait for the auth URL to appear in the specific div
|
||||
let authUrlEl = null;
|
||||
try {
|
||||
authUrlEl = await waitForElement('[class*="authUrlValue"]', 15000);
|
||||
} catch {
|
||||
throw new Error(
|
||||
'Auth URL did not appear after clicking login. ' +
|
||||
'Check if VPS panel is logged in and Codex service is running. URL: ' + location.href
|
||||
);
|
||||
}
|
||||
|
||||
const oauthUrl = (authUrlEl.textContent || '').trim();
|
||||
if (!oauthUrl || !oauthUrl.startsWith('http')) {
|
||||
throw new Error(`Invalid OAuth URL found: "${oauthUrl.slice(0, 50)}". Expected URL starting with http.`);
|
||||
}
|
||||
|
||||
log(`Step 1: OAuth URL obtained: ${oauthUrl.slice(0, 80)}...`, 'ok');
|
||||
reportComplete(1, { oauthUrl });
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Step 9: VPS Verify — paste localhost URL and submit
|
||||
// ============================================================
|
||||
|
||||
async function step9_vpsVerify(payload) {
|
||||
// Get localhostUrl from payload (passed directly by background) or fallback to state
|
||||
let localhostUrl = payload?.localhostUrl;
|
||||
if (!localhostUrl) {
|
||||
log('Step 9: localhostUrl not in payload, fetching from state...');
|
||||
const state = await chrome.runtime.sendMessage({ type: 'GET_STATE' });
|
||||
localhostUrl = state.localhostUrl;
|
||||
}
|
||||
if (!localhostUrl) {
|
||||
throw new Error('No localhost URL found. Complete step 8 first.');
|
||||
}
|
||||
log(`Step 9: Got localhostUrl: ${localhostUrl.slice(0, 60)}...`);
|
||||
|
||||
log('Step 9: Looking for callback URL input...');
|
||||
|
||||
// Find the callback URL input
|
||||
// Actual DOM: <input class="input" placeholder="http://localhost:1455/auth/callback?code=...&state=...">
|
||||
let urlInput = null;
|
||||
try {
|
||||
urlInput = await waitForElement('[class*="callbackSection"] input.input', 10000);
|
||||
} catch {
|
||||
try {
|
||||
urlInput = await waitForElement('input[placeholder*="localhost"]', 5000);
|
||||
} catch {
|
||||
throw new Error('Could not find callback URL input on VPS panel. URL: ' + location.href);
|
||||
}
|
||||
}
|
||||
|
||||
await humanPause(600, 1500);
|
||||
fillInput(urlInput, localhostUrl);
|
||||
log(`Step 9: Filled callback URL: ${localhostUrl.slice(0, 80)}...`);
|
||||
|
||||
// Find and click "提交回调 URL" button
|
||||
let submitBtn = null;
|
||||
try {
|
||||
submitBtn = await waitForElementByText(
|
||||
'[class*="callbackActions"] button, [class*="callbackSection"] button',
|
||||
/提交/,
|
||||
5000
|
||||
);
|
||||
} catch {
|
||||
try {
|
||||
submitBtn = await waitForElementByText('button.btn', /提交回调/, 5000);
|
||||
} catch {
|
||||
throw new Error('Could not find "提交回调 URL" button. URL: ' + location.href);
|
||||
}
|
||||
}
|
||||
|
||||
await humanPause(450, 1200);
|
||||
simulateClick(submitBtn);
|
||||
log('Step 9: Clicked "提交回调 URL", waiting for authentication result...');
|
||||
|
||||
// Wait for "认证成功!" status badge to appear
|
||||
try {
|
||||
await waitForElementByText('.status-badge, [class*="status"]', /认证成功/, 30000);
|
||||
log('Step 9: Authentication successful!', 'ok');
|
||||
} catch {
|
||||
// Check if there's an error message instead
|
||||
const statusEl = document.querySelector('.status-badge, [class*="status"]');
|
||||
const statusText = statusEl ? statusEl.textContent : 'unknown';
|
||||
if (/成功|success/i.test(statusText)) {
|
||||
log('Step 9: Authentication successful!', 'ok');
|
||||
} else {
|
||||
log(`Step 9: Status after submit: "${statusText}". May still be processing.`, 'warn');
|
||||
}
|
||||
}
|
||||
|
||||
reportComplete(9);
|
||||
}
|
||||
38
DouYinSparkFlow/webui/static/multiPagePlugins/data/names.js
Normal file
@@ -0,0 +1,38 @@
|
||||
// data/names.js — English name lists for random generation
|
||||
|
||||
const FIRST_NAMES = [
|
||||
'James', 'John', 'Robert', 'Michael', 'William', 'David', 'Richard', 'Joseph', 'Thomas', 'Christopher',
|
||||
'Mary', 'Patricia', 'Jennifer', 'Linda', 'Barbara', 'Elizabeth', 'Susan', 'Jessica', 'Sarah', 'Karen',
|
||||
'Daniel', 'Matthew', 'Anthony', 'Mark', 'Donald', 'Steven', 'Andrew', 'Paul', 'Joshua', 'Kenneth',
|
||||
'Emma', 'Olivia', 'Ava', 'Isabella', 'Sophia', 'Mia', 'Charlotte', 'Amelia', 'Harper', 'Evelyn',
|
||||
];
|
||||
|
||||
const LAST_NAMES = [
|
||||
'Smith', 'Johnson', 'Williams', 'Brown', 'Jones', 'Garcia', 'Miller', 'Davis', 'Rodriguez', 'Martinez',
|
||||
'Hernandez', 'Lopez', 'Gonzalez', 'Wilson', 'Anderson', 'Thomas', 'Taylor', 'Moore', 'Jackson', 'Martin',
|
||||
'Lee', 'Perez', 'Thompson', 'White', 'Harris', 'Sanchez', 'Clark', 'Ramirez', 'Lewis', 'Robinson',
|
||||
];
|
||||
|
||||
/**
|
||||
* Generate a random full name.
|
||||
* @returns {{ firstName: string, lastName: string }}
|
||||
*/
|
||||
function generateRandomName() {
|
||||
const firstName = FIRST_NAMES[Math.floor(Math.random() * FIRST_NAMES.length)];
|
||||
const lastName = LAST_NAMES[Math.floor(Math.random() * LAST_NAMES.length)];
|
||||
return { firstName, lastName };
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a random birthday (age 19-25).
|
||||
* @returns {{ year: number, month: number, day: number }}
|
||||
*/
|
||||
function generateRandomBirthday() {
|
||||
const currentYear = new Date().getFullYear();
|
||||
const age = 19 + Math.floor(Math.random() * 7); // 19 to 25
|
||||
const year = currentYear - age;
|
||||
const month = 1 + Math.floor(Math.random() * 12); // 1 to 12
|
||||
const maxDay = new Date(year, month, 0).getDate(); // days in that month
|
||||
const day = 1 + Math.floor(Math.random() * maxDay);
|
||||
return { year, month, day };
|
||||
}
|
||||
BIN
DouYinSparkFlow/webui/static/multiPagePlugins/icons/icon128.png
Normal file
|
After Width: | Height: | Size: 2.7 KiB |
BIN
DouYinSparkFlow/webui/static/multiPagePlugins/icons/icon16.png
Normal file
|
After Width: | Height: | Size: 327 B |
BIN
DouYinSparkFlow/webui/static/multiPagePlugins/icons/icon48.png
Normal file
|
After Width: | Height: | Size: 1.0 KiB |
78
DouYinSparkFlow/webui/static/multiPagePlugins/manifest.json
Normal file
@@ -0,0 +1,78 @@
|
||||
{
|
||||
"manifest_version": 3,
|
||||
"name": "Multi-Page Automation",
|
||||
"version": "1.1.0",
|
||||
"description": "Automates multi-step OAuth registration workflow",
|
||||
"permissions": [
|
||||
"sidePanel",
|
||||
"tabs",
|
||||
"webNavigation",
|
||||
"debugger",
|
||||
"storage",
|
||||
"scripting",
|
||||
"activeTab"
|
||||
],
|
||||
"host_permissions": [
|
||||
"<all_urls>"
|
||||
],
|
||||
"background": {
|
||||
"service_worker": "background.js"
|
||||
},
|
||||
"side_panel": {
|
||||
"default_path": "sidepanel/sidepanel.html"
|
||||
},
|
||||
"content_scripts": [
|
||||
{
|
||||
"matches": [
|
||||
"https://auth0.openai.com/*",
|
||||
"https://auth.openai.com/*",
|
||||
"https://accounts.openai.com/*"
|
||||
],
|
||||
"js": ["content/utils.js", "shared/oauth-flow.js", "content/signup-page.js"],
|
||||
"run_at": "document_idle"
|
||||
},
|
||||
{
|
||||
"matches": [
|
||||
"https://mail.qq.com/*",
|
||||
"https://wx.mail.qq.com/*"
|
||||
],
|
||||
"js": ["content/utils.js", "shared/qq-mail.js", "content/qq-mail.js"],
|
||||
"all_frames": true,
|
||||
"run_at": "document_idle"
|
||||
},
|
||||
{
|
||||
"matches": [
|
||||
"https://mail.163.com/*"
|
||||
],
|
||||
"js": ["content/utils.js", "content/mail-163.js"],
|
||||
"all_frames": true,
|
||||
"run_at": "document_idle"
|
||||
},
|
||||
{
|
||||
"matches": [
|
||||
"https://duckduckgo.com/email/settings/autofill*"
|
||||
],
|
||||
"js": ["content/utils.js", "content/duck-mail.js"],
|
||||
"run_at": "document_idle"
|
||||
},
|
||||
{
|
||||
"matches": [
|
||||
"https://relay.firefox.com/accounts/profile/*"
|
||||
],
|
||||
"js": ["content/utils.js", "shared/email-provider.js", "content/relay-firefox.js"],
|
||||
"run_at": "document_idle"
|
||||
}
|
||||
],
|
||||
"action": {
|
||||
"default_icon": {
|
||||
"16": "icons/icon16.png",
|
||||
"48": "icons/icon48.png",
|
||||
"128": "icons/icon128.png"
|
||||
}
|
||||
},
|
||||
"icons": {
|
||||
"16": "icons/icon16.png",
|
||||
"48": "icons/icon48.png",
|
||||
"128": "icons/icon128.png"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,389 @@
|
||||
(function attachCloudflareTempEmailHelpers(globalScope) {
|
||||
const PRIMARY_LOCAL_PART_WORD_BANKS = [
|
||||
[
|
||||
'anew', 'brisk', 'candid', 'fervent', 'gentle',
|
||||
'humble', 'jovial', 'kindly', 'lucid', 'mellow',
|
||||
'nimble', 'open', 'polished', 'quick', 'rosy',
|
||||
'steady', 'tidy', 'upbeat', 'vantage', 'thunderous',
|
||||
],
|
||||
[
|
||||
'angled', 'bordered', 'crisp', 'eager', 'frozen',
|
||||
'golden', 'dotted', 'hushed', 'jagged', 'layered',
|
||||
'mellowed', 'narrow', 'opal', 'primal', 'quiet',
|
||||
'rippled', 'sunlit', 'trimmed', 'velvet', 'thumping',
|
||||
],
|
||||
[
|
||||
'anchor', 'beacon', 'cinder', 'drifter', 'ember',
|
||||
'meadow', 'nickel', 'orbit', 'prairie', 'quartz',
|
||||
'rivet', 'latch', 'signal', 'thicket', 'uplift',
|
||||
'voyager', 'willow', 'yonder', 'zephyr', 'wilderness',
|
||||
],
|
||||
];
|
||||
const EXTENDED_LOCAL_PART_WORD_BANKS = [
|
||||
['aurora', 'breezy', 'copper', 'drizzle', 'whimsy', 'glimmer', 'sapphire', 'harbor', 'inkwell', 'juniper'],
|
||||
['almond', 'bronzed', 'cobbled', 'dappled', 'marbled', 'northern', 'moonlit', 'orchard', 'plaited', 'radiant'],
|
||||
['acorn', 'bramble', 'citadel', 'daybreak', 'solstice', 'harvest', 'treeline', 'updraft', 'wildfire', 'yearling'],
|
||||
];
|
||||
const MID_EXTENDED_LOCAL_PART_WORD_BANKS = [
|
||||
[
|
||||
'citrine', 'dapper', 'elmwood', 'feather', 'gossamer',
|
||||
'halcyon', 'ivory', 'kestrel', 'lively', 'mistral',
|
||||
],
|
||||
[
|
||||
'lantern', 'mosaic', 'notched', 'oaken', 'pearled',
|
||||
'quilted', 'rusted', 'silken', 'tapered', 'umber',
|
||||
],
|
||||
[
|
||||
'meridian', 'northstar', 'overlook', 'peninsula', 'quickstep',
|
||||
'ridgeline', 'starling', 'turnpike', 'undertow', 'vale',
|
||||
],
|
||||
];
|
||||
const TOP_EXTENDED_LOCAL_PART_WORD_BANKS = [
|
||||
['whimsy', 'afterglow', 'birdsong', 'clearwater', 'dreamscape', 'everbright', 'firecrest', 'hinterland', 'isleward', 'keystone'],
|
||||
['marbled', 'auric', 'blossomed', 'celestial', 'dawnlit', 'embered', 'frosted', 'gilded', 'heartland', 'ironbound'],
|
||||
['solstice', 'airstream', 'brightside', 'crestfall', 'dovetail', 'elmshade', 'fieldstone', 'goldleaf', 'highwater', 'ivytrail'],
|
||||
];
|
||||
const HIGH_EXTENDED_LOCAL_PART_WORD_BANKS = [
|
||||
[
|
||||
'adrift', 'bellwether', 'cedar', 'daystar', 'emberglow',
|
||||
'fjord', 'glasswing', 'horizon', 'islander', 'jetstream',
|
||||
'kingsley', 'longview', 'moonrise', 'northbound', 'oakleaf',
|
||||
'pinelight', 'quasar', 'runestone', 'seaborne', 'trailhead',
|
||||
],
|
||||
[
|
||||
'bronzed', 'cobbled', 'drifted', 'etched', 'fernlike',
|
||||
'granulated', 'honeyed', 'indigo', 'jadeite', 'kindled',
|
||||
'lacquered', 'measured', 'navy', 'opaline', 'painted',
|
||||
'quenched', 'reeded', 'sanded', 'tempered', 'uplifted',
|
||||
],
|
||||
[
|
||||
'cosmos', 'drumbeat', 'everglade', 'fjordline', 'grove',
|
||||
'headland', 'icefield', 'journey', 'knoll', 'lagoon',
|
||||
'moorland', 'narrows', 'outpost', 'passage', 'quarry',
|
||||
'riverbend', 'shoal', 'tideline', 'upland', 'vista',
|
||||
],
|
||||
];
|
||||
const APEX_EXTENDED_LOCAL_PART_WORD_BANKS = [
|
||||
[
|
||||
'atlas', 'bluebird', 'crestline', 'dewdrop', 'eastwind',
|
||||
'flare', 'glen', 'harvestmoon', 'iris', 'joyride',
|
||||
'kindred', 'larkspur', 'midway', 'nightfall', 'overture',
|
||||
'prairiesky', 'quill', 'rosewood', 'sunflare', 'turnstone',
|
||||
'uplight', 'violet', 'wildwood', 'xylia', 'yearbright',
|
||||
'zenway', 'amberline', 'brightshore', 'cloudrest', 'dawnsong',
|
||||
],
|
||||
[
|
||||
'bronze', 'coppered', 'dawnwashed', 'everspun', 'firelit',
|
||||
'glazed', 'harbored', 'ivied', 'jade', 'keelmarked',
|
||||
'leafed', 'misted', 'nacre', 'oakmoss', 'pearlstone',
|
||||
'quartzite', 'rainsoft', 'sunwashed', 'timbered', 'umbered',
|
||||
'velour', 'windcut', 'xanthic', 'yellowed', 'zestful',
|
||||
'ashen', 'brightened', 'coasted', 'deepwater', 'emberlit',
|
||||
],
|
||||
[
|
||||
'cosmos', 'daybreak', 'evercrest', 'fieldpath', 'groveside',
|
||||
'hilltop', 'inlet', 'junction', 'keyway', 'lakeside',
|
||||
'moonpath', 'nest', 'oakridge', 'portside', 'quayside',
|
||||
'riverside', 'stonepath', 'trailway', 'uplook', 'valecrest',
|
||||
'woodline', 'xylogrove', 'yardarm', 'zenithal', 'aircrest',
|
||||
'bayshore', 'crossing', 'driftway', 'elmtrail', 'foreside',
|
||||
],
|
||||
];
|
||||
const BASE_EXTENDED_WORD_RANGE_START = 0.91;
|
||||
const MID_EXTENDED_WORD_RANGE_START = 0.95;
|
||||
const TOP_EXTENDED_WORD_RANGE_START = 0.97;
|
||||
const HIGH_EXTENDED_WORD_RANGE_START = 0.985;
|
||||
const APEX_EXTENDED_WORD_RANGE_START = 0.993;
|
||||
|
||||
function normalizeWhitespace(value) {
|
||||
return String(value || '').replace(/\s+/g, ' ').trim();
|
||||
}
|
||||
|
||||
function combineDistinctTextParts(parts = []) {
|
||||
const seen = new Set();
|
||||
const normalizedParts = [];
|
||||
|
||||
for (const part of parts) {
|
||||
const value = normalizeWhitespace(part);
|
||||
if (!value || seen.has(value)) {
|
||||
continue;
|
||||
}
|
||||
seen.add(value);
|
||||
normalizedParts.push(value);
|
||||
}
|
||||
|
||||
return normalizedParts.join(' ');
|
||||
}
|
||||
|
||||
function normalizeText(value) {
|
||||
return normalizeWhitespace(value).toLowerCase();
|
||||
}
|
||||
|
||||
function normalizeEmail(value) {
|
||||
return normalizeText(value);
|
||||
}
|
||||
|
||||
function normalizeDomainSuffix(value) {
|
||||
const match = normalizeText(value).match(/@?([a-z0-9.-]+\.[a-z]{2,})/i);
|
||||
return match ? match[1].toLowerCase() : '';
|
||||
}
|
||||
|
||||
function toFiniteNumber(value) {
|
||||
const numeric = typeof value === 'number' ? value : Number(value);
|
||||
return Number.isFinite(numeric) ? numeric : null;
|
||||
}
|
||||
|
||||
function parseAdminTimestamp(value) {
|
||||
const match = normalizeWhitespace(value).match(
|
||||
/^(\d{4})\/(\d{1,2})\/(\d{1,2})\s+(\d{1,2}):(\d{2})(?::(\d{2}))?$/
|
||||
);
|
||||
if (!match) return null;
|
||||
|
||||
const [, year, month, day, hour, minute, second = '0'] = match;
|
||||
const timestamp = new Date(
|
||||
Number(year),
|
||||
Number(month) - 1,
|
||||
Number(day),
|
||||
Number(hour),
|
||||
Number(minute),
|
||||
Number(second)
|
||||
).getTime();
|
||||
|
||||
return Number.isFinite(timestamp) ? timestamp : null;
|
||||
}
|
||||
|
||||
function extractVerificationCode(text) {
|
||||
const content = String(text || '');
|
||||
|
||||
const matchCn = content.match(/(?:代码为|验证码[^0-9]*?)[\s::]*(\d{6})/);
|
||||
if (matchCn) return matchCn[1];
|
||||
|
||||
const matchEn = content.match(/code[:\s]+is[:\s]+(\d{6})|code[:\s]+(\d{6})/i);
|
||||
if (matchEn) return matchEn[1] || matchEn[2];
|
||||
|
||||
const match6 = content.match(/\b(\d{6})\b/);
|
||||
if (match6) return match6[1];
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function decodeBase64Url(segment) {
|
||||
const base64 = String(segment || '')
|
||||
.replace(/-/g, '+')
|
||||
.replace(/_/g, '/');
|
||||
const padding = base64.length % 4 === 0 ? '' : '='.repeat(4 - (base64.length % 4));
|
||||
const padded = base64 + padding;
|
||||
|
||||
if (typeof Buffer !== 'undefined') {
|
||||
return Buffer.from(padded, 'base64').toString('utf8');
|
||||
}
|
||||
|
||||
if (typeof atob === 'function') {
|
||||
return atob(padded);
|
||||
}
|
||||
|
||||
throw new Error('No base64 decoder available.');
|
||||
}
|
||||
|
||||
function decodeJwtPayload(token) {
|
||||
const parts = String(token || '').split('.');
|
||||
if (parts.length < 2) return null;
|
||||
|
||||
try {
|
||||
return JSON.parse(decodeBase64Url(parts[1]));
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function parseCloudflareMailboxCredential(token) {
|
||||
const jwtMatch = String(token || '').match(/[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+/);
|
||||
if (!jwtMatch) return null;
|
||||
|
||||
const payload = decodeJwtPayload(jwtMatch[0]);
|
||||
const email = normalizeEmail(payload?.address || payload?.email || '');
|
||||
if (!email || !email.includes('@')) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const [localPart, ...domainParts] = email.split('@');
|
||||
const domain = domainParts.join('@');
|
||||
|
||||
return {
|
||||
addressId: toFiniteNumber(payload?.address_id),
|
||||
domain,
|
||||
email,
|
||||
localPart,
|
||||
provenance: 'created',
|
||||
};
|
||||
}
|
||||
|
||||
function pickWord(words, randomFn) {
|
||||
const randomValue = Math.max(0, Math.min(0.999999999999, Number(randomFn())));
|
||||
return words[Math.floor(randomValue * words.length)] || words[0];
|
||||
}
|
||||
|
||||
function pickReadableWord(bankIndex, randomFn) {
|
||||
const randomValue = Math.max(0, Math.min(0.999999999999, Number(randomFn())));
|
||||
const primaryWords = PRIMARY_LOCAL_PART_WORD_BANKS[bankIndex] || [];
|
||||
const extendedWords = EXTENDED_LOCAL_PART_WORD_BANKS[bankIndex] || [];
|
||||
const midExtendedWords = MID_EXTENDED_LOCAL_PART_WORD_BANKS[bankIndex] || [];
|
||||
const topExtendedWords = TOP_EXTENDED_LOCAL_PART_WORD_BANKS[bankIndex] || [];
|
||||
const highExtendedWords = HIGH_EXTENDED_LOCAL_PART_WORD_BANKS[bankIndex] || [];
|
||||
const apexExtendedWords = APEX_EXTENDED_LOCAL_PART_WORD_BANKS[bankIndex] || [];
|
||||
|
||||
if (apexExtendedWords.length > 0 && randomValue >= APEX_EXTENDED_WORD_RANGE_START) {
|
||||
const apexExtendedSpan = 1 - APEX_EXTENDED_WORD_RANGE_START;
|
||||
const apexExtendedValue = Math.min(0.999999999999, (randomValue - APEX_EXTENDED_WORD_RANGE_START) / apexExtendedSpan);
|
||||
return pickWord(apexExtendedWords, () => apexExtendedValue);
|
||||
}
|
||||
|
||||
if (highExtendedWords.length > 0 && randomValue >= HIGH_EXTENDED_WORD_RANGE_START) {
|
||||
const highExtendedSpan = APEX_EXTENDED_WORD_RANGE_START - HIGH_EXTENDED_WORD_RANGE_START;
|
||||
const highExtendedValue = Math.min(0.999999999999, (randomValue - HIGH_EXTENDED_WORD_RANGE_START) / highExtendedSpan);
|
||||
return pickWord(highExtendedWords, () => highExtendedValue);
|
||||
}
|
||||
|
||||
if (topExtendedWords.length > 0 && randomValue >= TOP_EXTENDED_WORD_RANGE_START) {
|
||||
const topExtendedSpan = HIGH_EXTENDED_WORD_RANGE_START - TOP_EXTENDED_WORD_RANGE_START;
|
||||
const topExtendedValue = Math.min(0.999999999999, (randomValue - TOP_EXTENDED_WORD_RANGE_START) / topExtendedSpan);
|
||||
return pickWord(topExtendedWords, () => topExtendedValue);
|
||||
}
|
||||
|
||||
if (midExtendedWords.length > 0 && randomValue >= MID_EXTENDED_WORD_RANGE_START) {
|
||||
const midExtendedSpan = TOP_EXTENDED_WORD_RANGE_START - MID_EXTENDED_WORD_RANGE_START;
|
||||
const midExtendedValue = Math.min(0.999999999999, (randomValue - MID_EXTENDED_WORD_RANGE_START) / midExtendedSpan);
|
||||
return pickWord(midExtendedWords, () => midExtendedValue);
|
||||
}
|
||||
|
||||
if (extendedWords.length > 0 && randomValue >= BASE_EXTENDED_WORD_RANGE_START) {
|
||||
const extendedSpan = MID_EXTENDED_WORD_RANGE_START - BASE_EXTENDED_WORD_RANGE_START;
|
||||
const extendedValue = Math.min(0.999999999999, (randomValue - BASE_EXTENDED_WORD_RANGE_START) / extendedSpan);
|
||||
return pickWord(extendedWords, () => extendedValue);
|
||||
}
|
||||
|
||||
return pickWord(primaryWords, () => randomValue);
|
||||
}
|
||||
|
||||
function generateReadableLocalPart(randomFn = Math.random, maxLength = 24) {
|
||||
for (let attempt = 1; attempt <= 20; attempt++) {
|
||||
const value = PRIMARY_LOCAL_PART_WORD_BANKS
|
||||
.map((_, bankIndex) => pickReadableWord(bankIndex, randomFn))
|
||||
.join('-');
|
||||
|
||||
if (value.length <= maxLength) {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
|
||||
return 'anew-dotted-latch';
|
||||
}
|
||||
|
||||
function pickRandomSuffix(options = [], randomFn = Math.random) {
|
||||
const seen = new Set();
|
||||
const suffixes = [];
|
||||
|
||||
for (const option of options) {
|
||||
const suffix = normalizeDomainSuffix(option);
|
||||
if (!suffix || seen.has(suffix)) {
|
||||
continue;
|
||||
}
|
||||
seen.add(suffix);
|
||||
suffixes.push(suffix);
|
||||
}
|
||||
|
||||
if (suffixes.length === 0) {
|
||||
return '';
|
||||
}
|
||||
|
||||
return pickWord(suffixes, randomFn);
|
||||
}
|
||||
|
||||
function compareMessageIds(left, right) {
|
||||
const leftNumber = toFiniteNumber(left);
|
||||
const rightNumber = toFiniteNumber(right);
|
||||
|
||||
if (leftNumber !== null && rightNumber !== null) {
|
||||
return rightNumber - leftNumber;
|
||||
}
|
||||
|
||||
return String(right || '').localeCompare(String(left || ''));
|
||||
}
|
||||
|
||||
function selectVerificationMessage(messages = [], options = {}) {
|
||||
const targetEmail = normalizeEmail(options.targetEmail || '');
|
||||
const senderFilters = (options.senderFilters || []).map(normalizeText);
|
||||
const subjectFilters = (options.subjectFilters || []).map(normalizeText);
|
||||
const filterAfterTimestamp = toFiniteNumber(options.filterAfterTimestamp) || 0;
|
||||
const candidates = [];
|
||||
|
||||
for (const message of messages) {
|
||||
const matchedEmail = normalizeEmail(message?.matchedEmail || message?.toEmail || '');
|
||||
if (targetEmail && matchedEmail && matchedEmail !== targetEmail) {
|
||||
continue;
|
||||
}
|
||||
if (targetEmail && !matchedEmail) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const subject = normalizeWhitespace(message?.subject || '');
|
||||
const combinedText = normalizeWhitespace(message?.combinedText || '');
|
||||
const sender = normalizeText(message?.sender || '');
|
||||
const searchText = normalizeText(`${subject} ${combinedText}`);
|
||||
const code = extractVerificationCode(`${subject} ${combinedText}`);
|
||||
if (!code) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const senderMatch = senderFilters.length === 0
|
||||
|| senderFilters.some((filter) => sender.includes(filter) || searchText.includes(filter));
|
||||
const subjectMatch = subjectFilters.length === 0
|
||||
|| subjectFilters.some((filter) => normalizeText(subject).includes(filter) || searchText.includes(filter));
|
||||
|
||||
if (!senderMatch && !subjectMatch) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const emailTimestamp = toFiniteNumber(message?.emailTimestamp)
|
||||
|| parseAdminTimestamp(message?.timestampText || '');
|
||||
|
||||
if (filterAfterTimestamp > 0 && (!emailTimestamp || emailTimestamp <= filterAfterTimestamp)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
candidates.push({
|
||||
code,
|
||||
emailTimestamp: emailTimestamp || 0,
|
||||
matchedEmail,
|
||||
messageId: message?.messageId ?? null,
|
||||
subject: subject || null,
|
||||
});
|
||||
}
|
||||
|
||||
candidates.sort((left, right) => {
|
||||
if (left.emailTimestamp !== right.emailTimestamp) {
|
||||
return right.emailTimestamp - left.emailTimestamp;
|
||||
}
|
||||
return compareMessageIds(left.messageId, right.messageId);
|
||||
});
|
||||
|
||||
return candidates[0] || null;
|
||||
}
|
||||
|
||||
const api = {
|
||||
combineDistinctTextParts,
|
||||
extractVerificationCode,
|
||||
generateReadableLocalPart,
|
||||
normalizeDomainSuffix,
|
||||
parseAdminTimestamp,
|
||||
parseCloudflareMailboxCredential,
|
||||
pickRandomSuffix,
|
||||
selectVerificationMessage,
|
||||
};
|
||||
|
||||
globalScope.MultiPageCloudflareTempEmail = api;
|
||||
|
||||
if (typeof module !== 'undefined' && module.exports) {
|
||||
module.exports = api;
|
||||
}
|
||||
})(typeof globalThis !== 'undefined' ? globalThis : this);
|
||||
@@ -0,0 +1,99 @@
|
||||
(function attachEmailProviderHelpers(globalScope) {
|
||||
const DEFAULT_CLOUDFLARE_TEMP_EMAIL_ADMIN_URL = 'https://mail.cloudflare.com/admin';
|
||||
const EMAIL_PROVIDER_DUCK = 'duckduckgo';
|
||||
const EMAIL_PROVIDER_RELAY_FIREFOX = 'relay_firefox';
|
||||
const EMAIL_PROVIDER_CLOUDFLARE_TEMP_EMAIL = 'cloudflare_temp_email';
|
||||
|
||||
function normalizeEmailProvider(value) {
|
||||
if (value === EMAIL_PROVIDER_RELAY_FIREFOX) {
|
||||
return EMAIL_PROVIDER_RELAY_FIREFOX;
|
||||
}
|
||||
if (value === EMAIL_PROVIDER_CLOUDFLARE_TEMP_EMAIL) {
|
||||
return EMAIL_PROVIDER_CLOUDFLARE_TEMP_EMAIL;
|
||||
}
|
||||
return EMAIL_PROVIDER_DUCK;
|
||||
}
|
||||
|
||||
function isRelayFirefoxProvider(value) {
|
||||
return normalizeEmailProvider(value) === EMAIL_PROVIDER_RELAY_FIREFOX;
|
||||
}
|
||||
|
||||
function isCloudflareTempEmailProvider(value) {
|
||||
return normalizeEmailProvider(value) === EMAIL_PROVIDER_CLOUDFLARE_TEMP_EMAIL;
|
||||
}
|
||||
|
||||
function getEmailProviderDisplayName(value) {
|
||||
if (isRelayFirefoxProvider(value)) {
|
||||
return 'Firefox Relay';
|
||||
}
|
||||
if (isCloudflareTempEmailProvider(value)) {
|
||||
return 'Cloudflare Temp Email';
|
||||
}
|
||||
return 'DuckDuckGo';
|
||||
}
|
||||
|
||||
function shouldUseEmailSourceForVerification(value) {
|
||||
return isCloudflareTempEmailProvider(value);
|
||||
}
|
||||
|
||||
function shouldSkipStep9Cleanup(value) {
|
||||
return !isRelayFirefoxProvider(value);
|
||||
}
|
||||
|
||||
function normalizeCloudflareTempEmailAdminUrl(value) {
|
||||
const raw = String(value || '').trim();
|
||||
if (!raw) {
|
||||
return DEFAULT_CLOUDFLARE_TEMP_EMAIL_ADMIN_URL;
|
||||
}
|
||||
|
||||
const candidate = /^[a-z][a-z0-9+.-]*:\/\//i.test(raw) ? raw : `https://${raw}`;
|
||||
|
||||
try {
|
||||
const parsed = new URL(candidate);
|
||||
parsed.pathname = parsed.pathname.replace(/\/+$/, '') || '/';
|
||||
return parsed.toString();
|
||||
} catch {
|
||||
return DEFAULT_CLOUDFLARE_TEMP_EMAIL_ADMIN_URL;
|
||||
}
|
||||
}
|
||||
|
||||
function getNextRelayMaskLabel(labels = []) {
|
||||
const used = new Set();
|
||||
|
||||
for (const rawLabel of labels) {
|
||||
const match = String(rawLabel || '').trim().match(/^t(\d+)$/i);
|
||||
if (!match) continue;
|
||||
const nextValue = Number(match[1]);
|
||||
if (Number.isInteger(nextValue) && nextValue > 0) {
|
||||
used.add(nextValue);
|
||||
}
|
||||
}
|
||||
|
||||
let candidate = 1;
|
||||
while (used.has(candidate)) {
|
||||
candidate += 1;
|
||||
}
|
||||
return `t${candidate}`;
|
||||
}
|
||||
|
||||
const api = {
|
||||
DEFAULT_CLOUDFLARE_TEMP_EMAIL_ADMIN_URL,
|
||||
EMAIL_PROVIDER_CLOUDFLARE_TEMP_EMAIL,
|
||||
EMAIL_PROVIDER_DUCK,
|
||||
EMAIL_PROVIDER_RELAY_FIREFOX,
|
||||
getEmailProviderDisplayName,
|
||||
getNextRelayMaskLabel,
|
||||
isCloudflareTempEmailProvider,
|
||||
isRelayFirefoxProvider,
|
||||
normalizeCloudflareTempEmailAdminUrl,
|
||||
normalizeEmailProvider,
|
||||
shouldUseEmailSourceForVerification,
|
||||
shouldSkipStep9Cleanup,
|
||||
};
|
||||
|
||||
globalScope.MultiPageEmailProvider = api;
|
||||
|
||||
if (typeof module !== 'undefined' && module.exports) {
|
||||
module.exports = api;
|
||||
}
|
||||
})(typeof globalThis !== 'undefined' ? globalThis : this);
|
||||
@@ -0,0 +1,85 @@
|
||||
(function attachOAuthFlowHelpers(globalScope) {
|
||||
const EXACT_CONSENT_PATH = '/sign-in-with-chatgpt/codex/consent';
|
||||
const SIGN_IN_WITH_CHATGPT_PATH_SEGMENT = '/sign-in-with-chatgpt/';
|
||||
|
||||
function parseUrl(input) {
|
||||
if (!input || typeof input !== 'string') {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
return new URL(input);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function isConsentUrl(url) {
|
||||
const parsed = parseUrl(url);
|
||||
if (!parsed) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return parsed.pathname === EXACT_CONSENT_PATH;
|
||||
}
|
||||
|
||||
function isConsentPageState(state = {}) {
|
||||
const { hasVisibleContinueButton = false, url = '' } = state;
|
||||
if (isConsentUrl(url)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const parsed = parseUrl(url);
|
||||
if (!parsed) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return parsed.pathname.includes(SIGN_IN_WITH_CHATGPT_PATH_SEGMENT) && Boolean(hasVisibleContinueButton);
|
||||
}
|
||||
|
||||
function hasAnyConsentPageState(states = []) {
|
||||
return states.some((state) => isConsentPageState(state));
|
||||
}
|
||||
|
||||
function isLoopbackCallbackUrl(url) {
|
||||
const parsed = parseUrl(url);
|
||||
if (!parsed) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
|
||||
return false;
|
||||
}
|
||||
|
||||
return parsed.hostname === 'localhost'
|
||||
|| parsed.hostname === '127.0.0.1'
|
||||
|| parsed.hostname === '::1'
|
||||
|| parsed.hostname === '[::1]';
|
||||
}
|
||||
|
||||
function findLoopbackCallbackUrl(candidates = []) {
|
||||
for (const candidate of candidates) {
|
||||
if (isLoopbackCallbackUrl(candidate)) {
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
const api = {
|
||||
EXACT_CONSENT_PATH,
|
||||
SIGN_IN_WITH_CHATGPT_PATH_SEGMENT,
|
||||
findLoopbackCallbackUrl,
|
||||
hasAnyConsentPageState,
|
||||
isConsentPageState,
|
||||
isConsentUrl,
|
||||
isLoopbackCallbackUrl,
|
||||
};
|
||||
|
||||
globalScope.MultiPageOAuthFlow = api;
|
||||
|
||||
if (typeof module !== 'undefined' && module.exports) {
|
||||
module.exports = api;
|
||||
}
|
||||
})(typeof globalThis !== 'undefined' ? globalThis : this);
|
||||
@@ -0,0 +1,67 @@
|
||||
(function attachQQMailHelpers(globalScope) {
|
||||
function normalizeText(value) {
|
||||
return (value || '').toLowerCase();
|
||||
}
|
||||
|
||||
function extractVerificationCode(text) {
|
||||
const matchCn = text.match(/(?:代码为|验证码[^0-9]*?)[\s::]*(\d{6})/);
|
||||
if (matchCn) return matchCn[1];
|
||||
|
||||
const matchEn = text.match(/code[:\s]+is[:\s]+(\d{6})|code[:\s]+(\d{6})/i);
|
||||
if (matchEn) return matchEn[1] || matchEn[2];
|
||||
|
||||
const match6 = text.match(/\b(\d{6})\b/);
|
||||
if (match6) return match6[1];
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function findNewQQVerificationCode(messages = [], options = {}) {
|
||||
const existingMailIds = new Set(options.existingMailIds || []);
|
||||
const senderFilters = options.senderFilters || [];
|
||||
const subjectFilters = options.subjectFilters || [];
|
||||
|
||||
for (const message of messages) {
|
||||
const mailId = message.mailId || '';
|
||||
if (!mailId || existingMailIds.has(mailId)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const sender = normalizeText(message.sender);
|
||||
const subject = normalizeText(message.subject);
|
||||
const digest = message.digest || '';
|
||||
|
||||
const senderMatch = senderFilters.some((filter) => sender.includes(normalizeText(filter)));
|
||||
const subjectMatch = subjectFilters.some((filter) => subject.includes(normalizeText(filter)));
|
||||
|
||||
if (!senderMatch && !subjectMatch) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const code = extractVerificationCode(`${message.subject || ''} ${digest}`);
|
||||
if (!code) {
|
||||
continue;
|
||||
}
|
||||
|
||||
return {
|
||||
code,
|
||||
mailId,
|
||||
source: 'new',
|
||||
subject: message.subject || '',
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
const api = {
|
||||
extractVerificationCode,
|
||||
findNewQQVerificationCode,
|
||||
};
|
||||
|
||||
globalScope.MultiPageQQMail = api;
|
||||
|
||||
if (typeof module !== 'undefined' && module.exports) {
|
||||
module.exports = api;
|
||||
}
|
||||
})(typeof globalThis !== 'undefined' ? globalThis : this);
|
||||
@@ -0,0 +1,669 @@
|
||||
/* ============================================================
|
||||
MultiPage Automation — Side Panel
|
||||
Design: Swiss Modernism + Developer Tool
|
||||
Font: Inter (UI) + JetBrains Mono (code)
|
||||
Themes: Light (default) + Dark (toggle)
|
||||
============================================================ */
|
||||
|
||||
/* ---- Light Theme (default) ---- */
|
||||
:root {
|
||||
--bg-base: #ffffff;
|
||||
--bg-surface: #f7f8fa;
|
||||
--bg-elevated: #eef0f4;
|
||||
--bg-hover: #e4e7ec;
|
||||
--bg-active: #dce0e8;
|
||||
|
||||
--border: #d8dce3;
|
||||
--border-subtle: #e8ecf1;
|
||||
|
||||
--text-primary: #1a1d24;
|
||||
--text-secondary: #5c6370;
|
||||
--text-muted: #9ca3af;
|
||||
|
||||
--blue: #2563eb;
|
||||
--blue-soft: rgba(37, 99, 235, 0.08);
|
||||
--blue-glow: rgba(37, 99, 235, 0.12);
|
||||
--green: #16a34a;
|
||||
--green-soft: rgba(22, 163, 74, 0.08);
|
||||
--orange: #ea580c;
|
||||
--orange-soft: rgba(234, 88, 12, 0.08);
|
||||
--red: #dc2626;
|
||||
--red-soft: rgba(220, 38, 38, 0.08);
|
||||
--cyan: #0891b2;
|
||||
--purple: #7c3aed;
|
||||
|
||||
--shadow-sm: 0 1px 2px rgba(0,0,0,0.04);
|
||||
--shadow-md: 0 2px 6px rgba(0,0,0,0.06);
|
||||
|
||||
--radius-sm: 6px;
|
||||
--radius-md: 8px;
|
||||
--transition: 150ms ease;
|
||||
}
|
||||
|
||||
/* ---- Dark Theme ---- */
|
||||
[data-theme="dark"] {
|
||||
--bg-base: #0f1117;
|
||||
--bg-surface: #181a21;
|
||||
--bg-elevated: #21242d;
|
||||
--bg-hover: #2a2e38;
|
||||
--bg-active: #323844;
|
||||
|
||||
--border: #2a2e38;
|
||||
--border-subtle: #21242d;
|
||||
|
||||
--text-primary: #e4e6eb;
|
||||
--text-secondary: #8b919e;
|
||||
--text-muted: #565c6a;
|
||||
|
||||
--blue: #3b82f6;
|
||||
--blue-soft: rgba(59, 130, 246, 0.12);
|
||||
--blue-glow: rgba(59, 130, 246, 0.18);
|
||||
--green: #22c55e;
|
||||
--green-soft: rgba(34, 197, 94, 0.12);
|
||||
--orange: #f97316;
|
||||
--orange-soft: rgba(249, 115, 22, 0.12);
|
||||
--red: #ef4444;
|
||||
--red-soft: rgba(239, 68, 68, 0.12);
|
||||
--cyan: #06b6d4;
|
||||
--purple: #a78bfa;
|
||||
|
||||
--shadow-sm: 0 1px 2px rgba(0,0,0,0.2);
|
||||
--shadow-md: 0 2px 8px rgba(0,0,0,0.3);
|
||||
}
|
||||
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
|
||||
body {
|
||||
font-family: 'Inter', -apple-system, BlinkMacSystemFont, sans-serif;
|
||||
font-size: 14px;
|
||||
color: var(--text-primary);
|
||||
background: var(--bg-base);
|
||||
padding: 12px;
|
||||
width: 100%;
|
||||
min-height: 100vh;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
transition: background var(--transition), color var(--transition);
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
Header
|
||||
============================================================ */
|
||||
|
||||
header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 14px;
|
||||
padding-bottom: 12px;
|
||||
border-bottom: 1px solid var(--border-subtle);
|
||||
}
|
||||
|
||||
.header-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.header-left svg { color: var(--blue); }
|
||||
|
||||
.header-left h1 {
|
||||
font-size: 16px;
|
||||
font-weight: 700;
|
||||
letter-spacing: -0.02em;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.header-btns {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
Theme Toggle
|
||||
============================================================ */
|
||||
|
||||
.theme-toggle {
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: var(--text-muted);
|
||||
border-radius: var(--radius-sm);
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: all var(--transition);
|
||||
}
|
||||
.theme-toggle:hover { background: var(--bg-hover); color: var(--text-primary); }
|
||||
.theme-toggle .icon-moon { display: block; }
|
||||
.theme-toggle .icon-sun { display: none; }
|
||||
[data-theme="dark"] .theme-toggle .icon-moon { display: none; }
|
||||
[data-theme="dark"] .theme-toggle .icon-sun { display: block; }
|
||||
|
||||
/* ============================================================
|
||||
Buttons
|
||||
============================================================ */
|
||||
|
||||
.btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 7px 14px;
|
||||
font-family: inherit;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
border: 1px solid transparent;
|
||||
border-radius: var(--radius-sm);
|
||||
cursor: pointer;
|
||||
transition: all var(--transition);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background: var(--blue);
|
||||
color: #fff;
|
||||
}
|
||||
.btn-primary:hover { opacity: 0.9; box-shadow: 0 2px 8px var(--blue-glow); }
|
||||
|
||||
.btn-success {
|
||||
background: var(--green);
|
||||
color: #fff;
|
||||
}
|
||||
.btn-success:hover { opacity: 0.9; box-shadow: 0 2px 8px var(--green-soft); }
|
||||
|
||||
.btn-danger {
|
||||
background: var(--red);
|
||||
color: #fff;
|
||||
}
|
||||
.btn-danger:hover { opacity: 0.9; box-shadow: 0 2px 8px var(--red-soft); }
|
||||
|
||||
.run-group {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.run-count-input {
|
||||
width: 42px;
|
||||
padding: 6px 4px;
|
||||
text-align: center;
|
||||
background: var(--bg-base);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
color: var(--text-primary);
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
outline: none;
|
||||
}
|
||||
.run-count-input:focus { border-color: var(--blue); }
|
||||
.run-count-input::-webkit-inner-spin-button { opacity: 0.5; }
|
||||
.btn-success:disabled, .btn-primary:disabled, .btn-danger:disabled { background: var(--bg-elevated); color: var(--text-muted); cursor: not-allowed; box-shadow: none; }
|
||||
.run-count-input:disabled { opacity: 0.5; cursor: not-allowed; }
|
||||
|
||||
.btn-ghost {
|
||||
background: transparent;
|
||||
color: var(--text-secondary);
|
||||
padding: 6px;
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
.btn-ghost:hover { background: var(--bg-hover); color: var(--text-primary); }
|
||||
|
||||
.btn-outline {
|
||||
background: transparent;
|
||||
border: 1px solid var(--border);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
.btn-outline:hover { border-color: var(--blue); color: var(--blue); background: var(--blue-soft); }
|
||||
|
||||
.btn-sm { padding: 5px 12px; font-size: 12px; }
|
||||
.btn-xs { padding: 4px 10px; font-size: 11px; }
|
||||
|
||||
/* ============================================================
|
||||
Data Card
|
||||
============================================================ */
|
||||
|
||||
#data-section { margin-bottom: 14px; }
|
||||
|
||||
.data-card {
|
||||
background: var(--bg-surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-md);
|
||||
padding: 12px 14px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 9px;
|
||||
box-shadow: var(--shadow-sm);
|
||||
}
|
||||
|
||||
.data-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.data-inline {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.data-label {
|
||||
width: 56px;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
color: var(--text-muted);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.data-value {
|
||||
font-size: 13px;
|
||||
color: var(--text-muted);
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.data-value.has-value { color: var(--text-primary); }
|
||||
|
||||
.mono {
|
||||
font-family: 'JetBrains Mono', 'Consolas', monospace;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.truncate {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.data-input {
|
||||
flex: 1;
|
||||
padding: 7px 10px;
|
||||
background: var(--bg-base);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
color: var(--text-primary);
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-size: 13px;
|
||||
outline: none;
|
||||
transition: border-color var(--transition), box-shadow var(--transition);
|
||||
min-width: 0;
|
||||
}
|
||||
.data-input::placeholder { color: var(--text-muted); }
|
||||
.data-input:focus { border-color: var(--blue); box-shadow: 0 0 0 3px var(--blue-soft); }
|
||||
|
||||
#btn-fetch-email {
|
||||
padding-inline: 10px;
|
||||
}
|
||||
|
||||
#btn-toggle-password {
|
||||
min-width: 58px;
|
||||
padding-inline: 10px;
|
||||
}
|
||||
|
||||
.data-select {
|
||||
flex: 1;
|
||||
padding: 7px 10px;
|
||||
background: var(--bg-base);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
color: var(--text-primary);
|
||||
font-family: inherit;
|
||||
font-size: 13px;
|
||||
outline: none;
|
||||
cursor: pointer;
|
||||
transition: border-color var(--transition);
|
||||
min-width: 0;
|
||||
}
|
||||
.data-select:focus { border-color: var(--blue); box-shadow: 0 0 0 3px var(--blue-soft); }
|
||||
[data-theme="dark"] .data-select { color-scheme: dark; }
|
||||
|
||||
/* Status Bar */
|
||||
.status-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-top: 8px;
|
||||
padding: 8px 12px;
|
||||
background: var(--bg-surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: var(--text-secondary);
|
||||
box-shadow: var(--shadow-sm);
|
||||
}
|
||||
|
||||
.status-dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
background: var(--text-muted);
|
||||
flex-shrink: 0;
|
||||
transition: background var(--transition);
|
||||
}
|
||||
|
||||
.status-bar.running .status-dot {
|
||||
background: var(--orange);
|
||||
animation: pulse 1.5s ease-in-out infinite;
|
||||
}
|
||||
.status-bar.running { color: var(--orange); }
|
||||
|
||||
.status-bar.completed .status-dot { background: var(--green); }
|
||||
.status-bar.completed { color: var(--green); }
|
||||
|
||||
.status-bar.failed .status-dot { background: var(--red); }
|
||||
.status-bar.failed { color: var(--red); }
|
||||
|
||||
.status-bar.stopped .status-dot { background: var(--cyan); }
|
||||
.status-bar.stopped { color: var(--cyan); }
|
||||
|
||||
@keyframes pulse {
|
||||
0%, 100% { opacity: 1; transform: scale(1); }
|
||||
50% { opacity: 0.4; transform: scale(0.85); }
|
||||
}
|
||||
|
||||
/* Auto Continue Bar */
|
||||
.auto-continue-bar {
|
||||
margin-top: 8px;
|
||||
padding: 8px 10px;
|
||||
background: var(--orange-soft);
|
||||
border: 1px solid rgba(234, 88, 12, 0.2);
|
||||
border-radius: var(--radius-sm);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
.auto-continue-bar svg { color: var(--orange); flex-shrink: 0; }
|
||||
.auto-hint { font-size: 13px; color: var(--orange); flex: 1; font-weight: 500; }
|
||||
|
||||
/* ============================================================
|
||||
Steps Section
|
||||
============================================================ */
|
||||
|
||||
#steps-section { margin-bottom: 14px; }
|
||||
|
||||
.steps-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.section-label {
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
color: var(--text-muted);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
}
|
||||
|
||||
.steps-progress {
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
color: var(--text-muted);
|
||||
background: var(--bg-surface);
|
||||
padding: 2px 8px;
|
||||
border-radius: 10px;
|
||||
border: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.steps-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 5px;
|
||||
}
|
||||
|
||||
.step-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 1px 0;
|
||||
transition: opacity var(--transition);
|
||||
}
|
||||
|
||||
/* Step Number Indicator */
|
||||
.step-indicator {
|
||||
width: 26px;
|
||||
height: 26px;
|
||||
border-radius: 50%;
|
||||
background: var(--bg-surface);
|
||||
border: 1.5px solid var(--border);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
transition: all var(--transition);
|
||||
}
|
||||
|
||||
.step-num {
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
color: var(--text-muted);
|
||||
transition: color var(--transition);
|
||||
}
|
||||
|
||||
.step-row.running .step-indicator { border-color: var(--orange); background: var(--orange-soft); }
|
||||
.step-row.running .step-num { color: var(--orange); }
|
||||
.step-row.running .step-indicator { animation: pulse 1.5s ease-in-out infinite; }
|
||||
|
||||
.step-row.completed .step-indicator { border-color: var(--green); background: var(--green-soft); }
|
||||
.step-row.completed .step-num { color: var(--green); }
|
||||
|
||||
.step-row.failed .step-indicator { border-color: var(--red); background: var(--red-soft); }
|
||||
.step-row.failed .step-num { color: var(--red); }
|
||||
|
||||
.step-row.stopped .step-indicator { border-color: var(--cyan); background: rgba(8, 145, 178, 0.08); }
|
||||
.step-row.stopped .step-num { color: var(--cyan); }
|
||||
|
||||
/* Step Button */
|
||||
.step-btn {
|
||||
flex: 1;
|
||||
padding: 8px 12px;
|
||||
font-family: inherit;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: var(--text-primary);
|
||||
background: var(--bg-surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
transition: all var(--transition);
|
||||
box-shadow: var(--shadow-sm);
|
||||
}
|
||||
.step-btn:hover:not(:disabled) { background: var(--bg-hover); border-color: var(--blue); }
|
||||
.step-btn:disabled { color: var(--text-muted); background: var(--bg-base); border-color: var(--border-subtle); cursor: not-allowed; opacity: 0.45; box-shadow: none; }
|
||||
|
||||
.step-row.running .step-btn { border-color: var(--orange); color: var(--orange); }
|
||||
.step-row.completed .step-btn { border-color: var(--border-subtle); color: var(--text-secondary); opacity: 0.7; }
|
||||
.step-row.failed .step-btn { border-color: var(--red); color: var(--red); }
|
||||
.step-row.stopped .step-btn { border-color: var(--cyan); color: var(--cyan); }
|
||||
|
||||
.step-status {
|
||||
width: 20px;
|
||||
text-align: center;
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.step-row.completed .step-status { color: var(--green); }
|
||||
.step-row.failed .step-status { color: var(--red); }
|
||||
.step-row.stopped .step-status { color: var(--cyan); }
|
||||
|
||||
/* ============================================================
|
||||
Log / Console Section
|
||||
============================================================ */
|
||||
|
||||
.log-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
#log-area {
|
||||
background: var(--bg-surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-md);
|
||||
padding: 10px 12px;
|
||||
height: 220px;
|
||||
overflow-y: auto;
|
||||
font-family: 'JetBrains Mono', 'Consolas', monospace;
|
||||
font-size: 12px;
|
||||
line-height: 1.7;
|
||||
color: var(--text-secondary);
|
||||
box-shadow: var(--shadow-sm);
|
||||
}
|
||||
|
||||
#log-area::-webkit-scrollbar { width: 5px; }
|
||||
#log-area::-webkit-scrollbar-track { background: transparent; }
|
||||
#log-area::-webkit-scrollbar-thumb { background: var(--bg-elevated); border-radius: 4px; }
|
||||
#log-area::-webkit-scrollbar-thumb:hover { background: var(--text-muted); }
|
||||
|
||||
.log-line { padding: 2.5px 0; }
|
||||
.log-line + .log-line { border-top: 1px solid var(--border-subtle); }
|
||||
|
||||
.log-time { color: var(--text-muted); }
|
||||
|
||||
.log-level { font-weight: 700; margin: 0 2px; }
|
||||
.log-level-info { color: var(--blue); }
|
||||
.log-level-ok { color: var(--green); }
|
||||
.log-level-warn { color: var(--orange); }
|
||||
.log-level-error { color: var(--red); }
|
||||
|
||||
.log-step-tag {
|
||||
display: inline-block;
|
||||
padding: 1px 5px;
|
||||
border-radius: 3px;
|
||||
font-weight: 700;
|
||||
font-size: 11px;
|
||||
margin-right: 3px;
|
||||
}
|
||||
.log-step-tag.step-1 { color: var(--cyan); background: rgba(8, 145, 178, 0.08); }
|
||||
.log-step-tag.step-2 { color: var(--purple); background: rgba(124, 58, 237, 0.08); }
|
||||
.log-step-tag.step-3 { color: #b45309; background: rgba(180, 83, 9, 0.06); }
|
||||
[data-theme="dark"] .log-step-tag.step-3 { color: #fbbf24; background: rgba(251, 191, 36, 0.1); }
|
||||
.log-step-tag.step-4 { color: var(--orange); background: var(--orange-soft); }
|
||||
.log-step-tag.step-5 { color: var(--green); background: var(--green-soft); }
|
||||
.log-step-tag.step-6 { color: var(--cyan); background: rgba(8, 145, 178, 0.08); }
|
||||
.log-step-tag.step-7 { color: var(--orange); background: var(--orange-soft); }
|
||||
.log-step-tag.step-8 { color: var(--purple); background: rgba(124, 58, 237, 0.08); }
|
||||
.log-step-tag.step-9 { color: var(--green); background: var(--green-soft); }
|
||||
|
||||
.log-msg { color: var(--text-secondary); }
|
||||
.log-line.log-ok .log-msg { color: var(--green); font-weight: 500; }
|
||||
.log-line.log-error .log-msg { color: var(--red); font-weight: 500; }
|
||||
.log-line.log-warn .log-msg { color: var(--orange); }
|
||||
|
||||
/* ============================================================
|
||||
Animations
|
||||
============================================================ */
|
||||
|
||||
@keyframes fadeIn {
|
||||
from { opacity: 0; transform: translateY(3px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
|
||||
.log-line { animation: fadeIn 120ms ease-out; }
|
||||
|
||||
/* ============================================================
|
||||
Toast Notifications
|
||||
============================================================ */
|
||||
|
||||
#toast-container {
|
||||
position: fixed;
|
||||
top: 12px;
|
||||
left: 12px;
|
||||
right: 12px;
|
||||
z-index: 1000;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.toast {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 10px 14px;
|
||||
border-radius: var(--radius-md);
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
box-shadow: var(--shadow-md), 0 4px 12px rgba(0,0,0,0.1);
|
||||
pointer-events: auto;
|
||||
animation: toastIn 250ms ease-out;
|
||||
border: 1px solid;
|
||||
}
|
||||
|
||||
.toast.toast-exit {
|
||||
animation: toastOut 200ms ease-in forwards;
|
||||
}
|
||||
|
||||
.toast-error {
|
||||
background: var(--red-soft);
|
||||
border-color: var(--red);
|
||||
color: var(--red);
|
||||
}
|
||||
|
||||
.toast-warn {
|
||||
background: var(--orange-soft);
|
||||
border-color: var(--orange);
|
||||
color: var(--orange);
|
||||
}
|
||||
|
||||
.toast-success {
|
||||
background: var(--green-soft);
|
||||
border-color: var(--green);
|
||||
color: var(--green);
|
||||
}
|
||||
|
||||
.toast-info {
|
||||
background: var(--blue-soft);
|
||||
border-color: var(--blue);
|
||||
color: var(--blue);
|
||||
}
|
||||
|
||||
.toast svg {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.toast-msg {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.toast-close {
|
||||
background: none;
|
||||
border: none;
|
||||
color: inherit;
|
||||
opacity: 0.6;
|
||||
cursor: pointer;
|
||||
padding: 2px;
|
||||
font-size: 16px;
|
||||
line-height: 1;
|
||||
}
|
||||
.toast-close:hover { opacity: 1; }
|
||||
|
||||
@keyframes toastIn {
|
||||
from { opacity: 0; transform: translateY(-10px) scale(0.96); }
|
||||
to { opacity: 1; transform: translateY(0) scale(1); }
|
||||
}
|
||||
|
||||
@keyframes toastOut {
|
||||
from { opacity: 1; transform: translateY(0) scale(1); }
|
||||
to { opacity: 0; transform: translateY(-10px) scale(0.96); }
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
*, *::before, *::after {
|
||||
animation-duration: 0.01ms !important;
|
||||
transition-duration: 0.01ms !important;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,179 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Multi-Page Automation</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">
|
||||
<link rel="stylesheet" href="sidepanel.css">
|
||||
</head>
|
||||
<body>
|
||||
<header>
|
||||
<div class="header-left">
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M13 2L3 14h9l-1 8 10-12h-9l1-8z"/>
|
||||
</svg>
|
||||
<h1>MultiPage</h1>
|
||||
</div>
|
||||
<div class="header-btns">
|
||||
<div class="run-group">
|
||||
<input type="number" id="input-run-count" class="run-count-input" value="1" min="1" max="50" title="Number of runs" />
|
||||
<button id="btn-auto-run" class="btn btn-success" title="Run all steps automatically">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor"><polygon points="5 3 19 12 5 21 5 3"/></svg>
|
||||
Auto
|
||||
</button>
|
||||
<button id="btn-stop" class="btn btn-danger" title="Stop current flow" disabled>Stop</button>
|
||||
</div>
|
||||
<button id="btn-reset" class="btn btn-ghost" title="Reset all steps">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<polyline points="1 4 1 10 7 10"/><path d="M3.51 15a9 9 0 1 0 2.13-9.36L1 10"/>
|
||||
</svg>
|
||||
</button>
|
||||
<button id="btn-theme" class="theme-toggle" title="Toggle theme">
|
||||
<svg class="icon-moon" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"/>
|
||||
</svg>
|
||||
<svg class="icon-sun" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<circle cx="12" cy="12" r="5"/><line x1="12" y1="1" x2="12" y2="3"/><line x1="12" y1="21" x2="12" y2="23"/><line x1="4.22" y1="4.22" x2="5.64" y2="5.64"/><line x1="18.36" y1="18.36" x2="19.78" y2="19.78"/><line x1="1" y1="12" x2="3" y2="12"/><line x1="21" y1="12" x2="23" y2="12"/><line x1="4.22" y1="19.78" x2="5.64" y2="18.36"/><line x1="18.36" y1="5.64" x2="19.78" y2="4.22"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<section id="data-section">
|
||||
<div class="data-card">
|
||||
<div class="data-row">
|
||||
<span class="data-label">VPS</span>
|
||||
<input type="password" id="input-vps-url" class="data-input" placeholder="http://ip:port/management.html#/oauth" />
|
||||
</div>
|
||||
<div class="data-row">
|
||||
<span class="data-label">Mail</span>
|
||||
<select id="select-mail-provider" class="data-select">
|
||||
<option value="163">163 Mail (mail.163.com)</option>
|
||||
<option value="qq">QQ Mail (wx.mail.qq.com)</option>
|
||||
<option value="inbucket">Inbucket (custom host)</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="data-row">
|
||||
<span class="data-label">Source</span>
|
||||
<select id="select-email-provider" class="data-select">
|
||||
<option value="duckduckgo">duckduckgo</option>
|
||||
<option value="cloudflare_temp_email">cloudflare_temp_email</option>
|
||||
<option value="relay_firefox">relay_firefox</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="data-row" id="row-cloudflare-temp-email-url" style="display:none;">
|
||||
<span class="data-label">Cloudflare</span>
|
||||
<input type="text" id="input-cloudflare-temp-email-url" class="data-input" placeholder="https://mail.cloudflare.com/admin" />
|
||||
</div>
|
||||
<div class="data-row" id="row-inbucket-host" style="display:none;">
|
||||
<span class="data-label">Inbucket</span>
|
||||
<input type="text" id="input-inbucket-host" class="data-input" placeholder="your-inbucket-host or https://your-inbucket-host" />
|
||||
</div>
|
||||
<div class="data-row" id="row-inbucket-mailbox" style="display:none;">
|
||||
<span class="data-label">Mailbox</span>
|
||||
<input type="text" id="input-inbucket-mailbox" class="data-input" placeholder="e.g. zju2001" />
|
||||
</div>
|
||||
<div class="data-row">
|
||||
<span class="data-label">Email</span>
|
||||
<div class="data-inline">
|
||||
<input type="text" id="input-email" class="data-input" placeholder="Paste signup email" />
|
||||
<button id="btn-fetch-email" class="btn btn-outline btn-sm" type="button">Auto</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="data-row">
|
||||
<span class="data-label">Password</span>
|
||||
<div class="data-inline">
|
||||
<input type="password" id="input-password" class="data-input" placeholder="Leave blank to auto-generate" />
|
||||
<button id="btn-toggle-password" class="btn btn-outline btn-sm" type="button">Show</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="data-row">
|
||||
<span class="data-label">OAuth</span>
|
||||
<span id="display-oauth-url" class="data-value mono truncate">Waiting...</span>
|
||||
</div>
|
||||
<div class="data-row">
|
||||
<span class="data-label">Callback</span>
|
||||
<span id="display-localhost-url" class="data-value mono truncate">Waiting...</span>
|
||||
</div>
|
||||
</div>
|
||||
<div id="status-bar" class="status-bar">
|
||||
<div class="status-dot"></div>
|
||||
<span id="display-status">Ready</span>
|
||||
</div>
|
||||
<div id="auto-continue-bar" class="auto-continue-bar" style="display:none;">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><line x1="12" y1="8" x2="12" y2="12"/><line x1="12" y1="16" x2="12.01" y2="16"/></svg>
|
||||
<span id="auto-hint" class="auto-hint">Use Auto to fetch email, or paste manually, then continue</span>
|
||||
<button id="btn-auto-continue" class="btn btn-primary btn-sm">Continue</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section id="steps-section">
|
||||
<div class="steps-header">
|
||||
<span class="section-label">Workflow</span>
|
||||
<span id="steps-progress" class="steps-progress">0 / 9</span>
|
||||
</div>
|
||||
<div class="steps-list">
|
||||
<div class="step-row" data-step="1">
|
||||
<div class="step-indicator" data-step="1"><span class="step-num">1</span></div>
|
||||
<button class="step-btn" data-step="1">Get OAuth Link</button>
|
||||
<span class="step-status" data-step="1"></span>
|
||||
</div>
|
||||
<div class="step-row" data-step="2">
|
||||
<div class="step-indicator" data-step="2"><span class="step-num">2</span></div>
|
||||
<button class="step-btn" data-step="2">Open Signup</button>
|
||||
<span class="step-status" data-step="2"></span>
|
||||
</div>
|
||||
<div class="step-row" data-step="3">
|
||||
<div class="step-indicator" data-step="3"><span class="step-num">3</span></div>
|
||||
<button class="step-btn" data-step="3">Fill Email / Password</button>
|
||||
<span class="step-status" data-step="3"></span>
|
||||
</div>
|
||||
<div class="step-row" data-step="4">
|
||||
<div class="step-indicator" data-step="4"><span class="step-num">4</span></div>
|
||||
<button class="step-btn" data-step="4">Get Signup Code</button>
|
||||
<span class="step-status" data-step="4"></span>
|
||||
</div>
|
||||
<div class="step-row" data-step="5">
|
||||
<div class="step-indicator" data-step="5"><span class="step-num">5</span></div>
|
||||
<button class="step-btn" data-step="5">Fill Name / Birthday</button>
|
||||
<span class="step-status" data-step="5"></span>
|
||||
</div>
|
||||
<div class="step-row" data-step="6">
|
||||
<div class="step-indicator" data-step="6"><span class="step-num">6</span></div>
|
||||
<button class="step-btn" data-step="6">Login via OAuth</button>
|
||||
<span class="step-status" data-step="6"></span>
|
||||
</div>
|
||||
<div class="step-row" data-step="7">
|
||||
<div class="step-indicator" data-step="7"><span class="step-num">7</span></div>
|
||||
<button class="step-btn" data-step="7">Get Login Code</button>
|
||||
<span class="step-status" data-step="7"></span>
|
||||
</div>
|
||||
<div class="step-row" data-step="8">
|
||||
<div class="step-indicator" data-step="8"><span class="step-num">8</span></div>
|
||||
<button class="step-btn" data-step="8">OAuth Auto Confirm</button>
|
||||
<span class="step-status" data-step="8"></span>
|
||||
</div>
|
||||
<div class="step-row" data-step="9">
|
||||
<div class="step-indicator" data-step="9"><span class="step-num">9</span></div>
|
||||
<button class="step-btn" data-step="9">Cleanup Email</button>
|
||||
<span class="step-status" data-step="9"></span>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section id="log-section">
|
||||
<div class="log-header">
|
||||
<span class="section-label">Console</span>
|
||||
<button id="btn-clear-log" class="btn btn-ghost btn-xs" title="Clear log">Clear</button>
|
||||
</div>
|
||||
<div id="log-area"></div>
|
||||
</section>
|
||||
|
||||
<div id="toast-container"></div>
|
||||
<script src="../shared/email-provider.js"></script>
|
||||
<script src="sidepanel.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,636 @@
|
||||
// sidepanel/sidepanel.js — Side Panel logic
|
||||
|
||||
const STATUS_ICONS = {
|
||||
pending: '',
|
||||
running: '',
|
||||
completed: '\u2713', // ✓
|
||||
failed: '\u2717', // ✗
|
||||
stopped: '\u25A0', // ■
|
||||
};
|
||||
|
||||
const logArea = document.getElementById('log-area');
|
||||
const displayOauthUrl = document.getElementById('display-oauth-url');
|
||||
const displayLocalhostUrl = document.getElementById('display-localhost-url');
|
||||
const displayStatus = document.getElementById('display-status');
|
||||
const statusBar = document.getElementById('status-bar');
|
||||
const inputEmail = document.getElementById('input-email');
|
||||
const inputPassword = document.getElementById('input-password');
|
||||
const btnFetchEmail = document.getElementById('btn-fetch-email');
|
||||
const autoHint = document.getElementById('auto-hint');
|
||||
const btnTogglePassword = document.getElementById('btn-toggle-password');
|
||||
const btnStop = document.getElementById('btn-stop');
|
||||
const btnReset = document.getElementById('btn-reset');
|
||||
const stepsProgress = document.getElementById('steps-progress');
|
||||
const btnAutoRun = document.getElementById('btn-auto-run');
|
||||
const btnAutoContinue = document.getElementById('btn-auto-continue');
|
||||
const autoContinueBar = document.getElementById('auto-continue-bar');
|
||||
const btnClearLog = document.getElementById('btn-clear-log');
|
||||
const inputVpsUrl = document.getElementById('input-vps-url');
|
||||
const selectMailProvider = document.getElementById('select-mail-provider');
|
||||
const selectEmailProvider = document.getElementById('select-email-provider');
|
||||
const rowCloudflareTempEmailUrl = document.getElementById('row-cloudflare-temp-email-url');
|
||||
const inputCloudflareTempEmailUrl = document.getElementById('input-cloudflare-temp-email-url');
|
||||
const rowInbucketHost = document.getElementById('row-inbucket-host');
|
||||
const inputInbucketHost = document.getElementById('input-inbucket-host');
|
||||
const rowInbucketMailbox = document.getElementById('row-inbucket-mailbox');
|
||||
const inputInbucketMailbox = document.getElementById('input-inbucket-mailbox');
|
||||
const inputRunCount = document.getElementById('input-run-count');
|
||||
|
||||
const {
|
||||
DEFAULT_CLOUDFLARE_TEMP_EMAIL_ADMIN_URL = 'https://mail.cloudflare.com/admin',
|
||||
EMAIL_PROVIDER_CLOUDFLARE_TEMP_EMAIL = 'cloudflare_temp_email',
|
||||
EMAIL_PROVIDER_DUCK = 'duckduckgo',
|
||||
EMAIL_PROVIDER_RELAY_FIREFOX = 'relay_firefox',
|
||||
getEmailProviderDisplayName = (value) => value === 'relay_firefox'
|
||||
? 'Firefox Relay'
|
||||
: value === 'cloudflare_temp_email'
|
||||
? 'Cloudflare Temp Email'
|
||||
: 'DuckDuckGo',
|
||||
normalizeCloudflareTempEmailAdminUrl = (value) => value || DEFAULT_CLOUDFLARE_TEMP_EMAIL_ADMIN_URL,
|
||||
normalizeEmailProvider = (value) => {
|
||||
if (value === 'relay_firefox') return 'relay_firefox';
|
||||
if (value === 'cloudflare_temp_email') return 'cloudflare_temp_email';
|
||||
return 'duckduckgo';
|
||||
},
|
||||
} = globalThis.MultiPageEmailProvider || {};
|
||||
|
||||
// ============================================================
|
||||
// Toast Notifications
|
||||
// ============================================================
|
||||
|
||||
const toastContainer = document.getElementById('toast-container');
|
||||
|
||||
const TOAST_ICONS = {
|
||||
error: '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><line x1="15" y1="9" x2="9" y2="15"/><line x1="9" y1="9" x2="15" y2="15"/></svg>',
|
||||
warn: '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z"/><line x1="12" y1="9" x2="12" y2="13"/><line x1="12" y1="17" x2="12.01" y2="17"/></svg>',
|
||||
success: '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M22 11.08V12a10 10 0 1 1-5.93-9.14"/><polyline points="22 4 12 14.01 9 11.01"/></svg>',
|
||||
info: '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><line x1="12" y1="16" x2="12" y2="12"/><line x1="12" y1="8" x2="12.01" y2="8"/></svg>',
|
||||
};
|
||||
|
||||
function showToast(message, type = 'error', duration = 4000) {
|
||||
const toast = document.createElement('div');
|
||||
toast.className = `toast toast-${type}`;
|
||||
toast.innerHTML = `${TOAST_ICONS[type] || ''}<span class="toast-msg">${escapeHtml(message)}</span><button class="toast-close">×</button>`;
|
||||
|
||||
toast.querySelector('.toast-close').addEventListener('click', () => dismissToast(toast));
|
||||
toastContainer.appendChild(toast);
|
||||
|
||||
if (duration > 0) {
|
||||
setTimeout(() => dismissToast(toast), duration);
|
||||
}
|
||||
}
|
||||
|
||||
function dismissToast(toast) {
|
||||
if (!toast.parentNode) return;
|
||||
toast.classList.add('toast-exit');
|
||||
toast.addEventListener('animationend', () => toast.remove());
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// State Restore on load
|
||||
// ============================================================
|
||||
|
||||
async function restoreState() {
|
||||
try {
|
||||
const state = await chrome.runtime.sendMessage({ type: 'GET_STATE', source: 'sidepanel' });
|
||||
|
||||
if (state.oauthUrl) {
|
||||
displayOauthUrl.textContent = state.oauthUrl;
|
||||
displayOauthUrl.classList.add('has-value');
|
||||
}
|
||||
if (state.localhostUrl) {
|
||||
displayLocalhostUrl.textContent = state.localhostUrl;
|
||||
displayLocalhostUrl.classList.add('has-value');
|
||||
}
|
||||
if (state.email) {
|
||||
inputEmail.value = state.email;
|
||||
}
|
||||
syncPasswordField(state);
|
||||
if (state.vpsUrl) {
|
||||
inputVpsUrl.value = state.vpsUrl;
|
||||
}
|
||||
if (state.mailProvider) {
|
||||
selectMailProvider.value = state.mailProvider;
|
||||
}
|
||||
if (state.emailProvider) {
|
||||
selectEmailProvider.value = normalizeEmailProvider(state.emailProvider);
|
||||
}
|
||||
if (state.cloudflareTempEmailAdminUrl) {
|
||||
inputCloudflareTempEmailUrl.value = state.cloudflareTempEmailAdminUrl;
|
||||
}
|
||||
if (state.inbucketHost) {
|
||||
inputInbucketHost.value = state.inbucketHost;
|
||||
}
|
||||
if (state.inbucketMailbox) {
|
||||
inputInbucketMailbox.value = state.inbucketMailbox;
|
||||
}
|
||||
|
||||
if (state.stepStatuses) {
|
||||
for (const [step, status] of Object.entries(state.stepStatuses)) {
|
||||
updateStepUI(Number(step), status);
|
||||
}
|
||||
}
|
||||
|
||||
if (state.logs) {
|
||||
for (const entry of state.logs) {
|
||||
appendLog(entry);
|
||||
}
|
||||
}
|
||||
|
||||
updateStatusDisplay(state);
|
||||
updateProgressCounter();
|
||||
updateMailProviderUI();
|
||||
updateAutoContinueHint();
|
||||
} catch (err) {
|
||||
console.error('Failed to restore state:', err);
|
||||
}
|
||||
}
|
||||
|
||||
function syncPasswordField(state) {
|
||||
inputPassword.value = state.customPassword || state.password || '';
|
||||
}
|
||||
|
||||
function updateMailProviderUI() {
|
||||
const useInbucket = selectMailProvider.value === 'inbucket';
|
||||
const useCloudflareTempEmail = getSelectedEmailProvider() === EMAIL_PROVIDER_CLOUDFLARE_TEMP_EMAIL;
|
||||
|
||||
inputCloudflareTempEmailUrl.placeholder = DEFAULT_CLOUDFLARE_TEMP_EMAIL_ADMIN_URL;
|
||||
rowCloudflareTempEmailUrl.style.display = useCloudflareTempEmail ? '' : 'none';
|
||||
rowInbucketHost.style.display = useInbucket ? '' : 'none';
|
||||
rowInbucketMailbox.style.display = useInbucket ? '' : 'none';
|
||||
}
|
||||
|
||||
function getSelectedEmailProvider() {
|
||||
return normalizeEmailProvider(selectEmailProvider.value);
|
||||
}
|
||||
|
||||
function getEmailProviderName(provider = getSelectedEmailProvider()) {
|
||||
return getEmailProviderDisplayName(provider);
|
||||
}
|
||||
|
||||
function updateAutoContinueHint() {
|
||||
const provider = getSelectedEmailProvider();
|
||||
if (!autoHint) return;
|
||||
if (provider === EMAIL_PROVIDER_RELAY_FIREFOX) {
|
||||
autoHint.textContent = 'Use Auto to create a Relay mask, or paste manually, then continue';
|
||||
return;
|
||||
}
|
||||
if (provider === EMAIL_PROVIDER_CLOUDFLARE_TEMP_EMAIL) {
|
||||
const configuredUrl = normalizeCloudflareTempEmailAdminUrl(inputCloudflareTempEmailUrl.value.trim());
|
||||
autoHint.textContent = `Use Auto to create a Cloudflare Temp Email mailbox from ${configuredUrl}, or paste an existing admin mailbox, then continue`;
|
||||
return;
|
||||
}
|
||||
autoHint.textContent = 'Use Auto to fetch Duck email, or paste manually, then continue';
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// UI Updates
|
||||
// ============================================================
|
||||
|
||||
function updateStepUI(step, status) {
|
||||
const statusEl = document.querySelector(`.step-status[data-step="${step}"]`);
|
||||
const row = document.querySelector(`.step-row[data-step="${step}"]`);
|
||||
|
||||
if (statusEl) statusEl.textContent = STATUS_ICONS[status] || '';
|
||||
if (row) {
|
||||
row.className = `step-row ${status}`;
|
||||
}
|
||||
|
||||
updateButtonStates();
|
||||
updateProgressCounter();
|
||||
}
|
||||
|
||||
function updateProgressCounter() {
|
||||
let completed = 0;
|
||||
document.querySelectorAll('.step-row').forEach(row => {
|
||||
if (row.classList.contains('completed')) completed++;
|
||||
});
|
||||
stepsProgress.textContent = `${completed} / 9`;
|
||||
}
|
||||
|
||||
function updateButtonStates() {
|
||||
const statuses = {};
|
||||
document.querySelectorAll('.step-row').forEach(row => {
|
||||
const step = Number(row.dataset.step);
|
||||
if (row.classList.contains('completed')) statuses[step] = 'completed';
|
||||
else if (row.classList.contains('running')) statuses[step] = 'running';
|
||||
else if (row.classList.contains('failed')) statuses[step] = 'failed';
|
||||
else if (row.classList.contains('stopped')) statuses[step] = 'stopped';
|
||||
else statuses[step] = 'pending';
|
||||
});
|
||||
|
||||
const anyRunning = Object.values(statuses).some(s => s === 'running');
|
||||
|
||||
for (let step = 1; step <= 9; step++) {
|
||||
const btn = document.querySelector(`.step-btn[data-step="${step}"]`);
|
||||
if (!btn) continue;
|
||||
|
||||
if (anyRunning) {
|
||||
btn.disabled = true;
|
||||
} else if (step === 1) {
|
||||
btn.disabled = false;
|
||||
} else {
|
||||
const prevStatus = statuses[step - 1];
|
||||
const currentStatus = statuses[step];
|
||||
btn.disabled = !(prevStatus === 'completed' || currentStatus === 'failed' || currentStatus === 'completed' || currentStatus === 'stopped');
|
||||
}
|
||||
}
|
||||
|
||||
updateStopButtonState(anyRunning || autoContinueBar.style.display !== 'none');
|
||||
}
|
||||
|
||||
function updateStopButtonState(active) {
|
||||
btnStop.disabled = !active;
|
||||
}
|
||||
|
||||
function updateStatusDisplay(state) {
|
||||
if (!state || !state.stepStatuses) return;
|
||||
|
||||
statusBar.className = 'status-bar';
|
||||
|
||||
const running = Object.entries(state.stepStatuses).find(([, s]) => s === 'running');
|
||||
if (running) {
|
||||
displayStatus.textContent = `Step ${running[0]} running...`;
|
||||
statusBar.classList.add('running');
|
||||
return;
|
||||
}
|
||||
|
||||
const failed = Object.entries(state.stepStatuses).find(([, s]) => s === 'failed');
|
||||
if (failed) {
|
||||
displayStatus.textContent = `Step ${failed[0]} failed`;
|
||||
statusBar.classList.add('failed');
|
||||
return;
|
||||
}
|
||||
|
||||
const stopped = Object.entries(state.stepStatuses).find(([, s]) => s === 'stopped');
|
||||
if (stopped) {
|
||||
displayStatus.textContent = `Step ${stopped[0]} stopped`;
|
||||
statusBar.classList.add('stopped');
|
||||
return;
|
||||
}
|
||||
|
||||
const lastCompleted = Object.entries(state.stepStatuses)
|
||||
.filter(([, s]) => s === 'completed')
|
||||
.map(([k]) => Number(k))
|
||||
.sort((a, b) => b - a)[0];
|
||||
|
||||
if (lastCompleted === 9) {
|
||||
displayStatus.textContent = 'All steps completed!';
|
||||
statusBar.classList.add('completed');
|
||||
} else if (lastCompleted) {
|
||||
displayStatus.textContent = `Step ${lastCompleted} done`;
|
||||
} else {
|
||||
displayStatus.textContent = 'Ready';
|
||||
}
|
||||
}
|
||||
|
||||
function appendLog(entry) {
|
||||
const time = new Date(entry.timestamp).toLocaleTimeString('en-US', { hour12: false });
|
||||
const levelLabel = entry.level.toUpperCase();
|
||||
const line = document.createElement('div');
|
||||
line.className = `log-line log-${entry.level}`;
|
||||
|
||||
const stepMatch = entry.message.match(/Step (\d)/);
|
||||
const stepNum = stepMatch ? stepMatch[1] : null;
|
||||
|
||||
let html = `<span class="log-time">${time}</span> `;
|
||||
html += `<span class="log-level log-level-${entry.level}">${levelLabel}</span> `;
|
||||
if (stepNum) {
|
||||
html += `<span class="log-step-tag step-${stepNum}">S${stepNum}</span>`;
|
||||
}
|
||||
html += `<span class="log-msg">${escapeHtml(entry.message)}</span>`;
|
||||
|
||||
line.innerHTML = html;
|
||||
logArea.appendChild(line);
|
||||
logArea.scrollTop = logArea.scrollHeight;
|
||||
}
|
||||
|
||||
function escapeHtml(text) {
|
||||
const div = document.createElement('div');
|
||||
div.textContent = text;
|
||||
return div.innerHTML;
|
||||
}
|
||||
|
||||
async function fetchSelectedEmail() {
|
||||
const defaultLabel = 'Auto';
|
||||
const provider = getSelectedEmailProvider();
|
||||
btnFetchEmail.disabled = true;
|
||||
btnFetchEmail.textContent = '...';
|
||||
|
||||
try {
|
||||
const response = await chrome.runtime.sendMessage({
|
||||
type: 'FETCH_PROVIDER_EMAIL',
|
||||
source: 'sidepanel',
|
||||
payload: { provider, generateNew: true },
|
||||
});
|
||||
|
||||
if (response?.error) {
|
||||
throw new Error(response.error);
|
||||
}
|
||||
if (!response?.email) {
|
||||
throw new Error('Provider email was not returned.');
|
||||
}
|
||||
|
||||
inputEmail.value = response.email;
|
||||
showToast(`Fetched ${response.email}`, 'success', 2500);
|
||||
return response.email;
|
||||
} catch (err) {
|
||||
showToast(`Auto fetch failed: ${err.message}`, 'error');
|
||||
throw err;
|
||||
} finally {
|
||||
btnFetchEmail.disabled = false;
|
||||
btnFetchEmail.textContent = defaultLabel;
|
||||
}
|
||||
}
|
||||
|
||||
function syncPasswordToggleLabel() {
|
||||
btnTogglePassword.textContent = inputPassword.type === 'password' ? 'Show' : 'Hide';
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Button Handlers
|
||||
// ============================================================
|
||||
|
||||
document.querySelectorAll('.step-btn').forEach(btn => {
|
||||
btn.addEventListener('click', async () => {
|
||||
const step = Number(btn.dataset.step);
|
||||
if (step === 3) {
|
||||
const provider = getSelectedEmailProvider();
|
||||
const email = inputEmail.value.trim();
|
||||
if (provider === EMAIL_PROVIDER_DUCK && !email) {
|
||||
showToast('Please paste email address or use Auto first', 'warn');
|
||||
return;
|
||||
}
|
||||
const payload = provider === EMAIL_PROVIDER_DUCK ? { step, email } : { step };
|
||||
await chrome.runtime.sendMessage({ type: 'EXECUTE_STEP', source: 'sidepanel', payload });
|
||||
} else {
|
||||
await chrome.runtime.sendMessage({ type: 'EXECUTE_STEP', source: 'sidepanel', payload: { step } });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
btnFetchEmail.addEventListener('click', async () => {
|
||||
await fetchSelectedEmail().catch(() => {});
|
||||
});
|
||||
|
||||
btnTogglePassword.addEventListener('click', () => {
|
||||
inputPassword.type = inputPassword.type === 'password' ? 'text' : 'password';
|
||||
syncPasswordToggleLabel();
|
||||
});
|
||||
|
||||
btnStop.addEventListener('click', async () => {
|
||||
btnStop.disabled = true;
|
||||
await chrome.runtime.sendMessage({ type: 'STOP_FLOW', source: 'sidepanel', payload: {} });
|
||||
showToast('Stopping current flow...', 'warn', 2000);
|
||||
});
|
||||
|
||||
// Auto Run
|
||||
btnAutoRun.addEventListener('click', async () => {
|
||||
const totalRuns = parseInt(inputRunCount.value) || 1;
|
||||
btnAutoRun.disabled = true;
|
||||
inputRunCount.disabled = true;
|
||||
btnAutoRun.innerHTML = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><path d="M12 6v6l4 2"/></svg> Running...';
|
||||
await chrome.runtime.sendMessage({ type: 'AUTO_RUN', source: 'sidepanel', payload: { totalRuns } });
|
||||
});
|
||||
|
||||
btnAutoContinue.addEventListener('click', async () => {
|
||||
const provider = getSelectedEmailProvider();
|
||||
const email = inputEmail.value.trim();
|
||||
if (!email) {
|
||||
showToast(`Please fetch or paste ${getEmailProviderName(provider)} email first!`, 'warn');
|
||||
return;
|
||||
}
|
||||
autoContinueBar.style.display = 'none';
|
||||
await chrome.runtime.sendMessage({ type: 'RESUME_AUTO_RUN', source: 'sidepanel', payload: { email } });
|
||||
});
|
||||
|
||||
// Reset
|
||||
btnReset.addEventListener('click', async () => {
|
||||
if (confirm('Reset all steps and data?')) {
|
||||
await chrome.runtime.sendMessage({ type: 'RESET', source: 'sidepanel' });
|
||||
displayOauthUrl.textContent = 'Waiting...';
|
||||
displayOauthUrl.classList.remove('has-value');
|
||||
displayLocalhostUrl.textContent = 'Waiting...';
|
||||
displayLocalhostUrl.classList.remove('has-value');
|
||||
inputEmail.value = '';
|
||||
displayStatus.textContent = 'Ready';
|
||||
statusBar.className = 'status-bar';
|
||||
logArea.innerHTML = '';
|
||||
document.querySelectorAll('.step-row').forEach(row => row.className = 'step-row');
|
||||
document.querySelectorAll('.step-status').forEach(el => el.textContent = '');
|
||||
btnAutoRun.disabled = false;
|
||||
inputRunCount.disabled = false;
|
||||
btnAutoRun.innerHTML = '<svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor"><polygon points="5 3 19 12 5 21 5 3"/></svg> Auto';
|
||||
autoContinueBar.style.display = 'none';
|
||||
updateStopButtonState(false);
|
||||
updateButtonStates();
|
||||
updateProgressCounter();
|
||||
}
|
||||
});
|
||||
|
||||
// Clear log
|
||||
btnClearLog.addEventListener('click', () => {
|
||||
logArea.innerHTML = '';
|
||||
});
|
||||
|
||||
// Save settings on change
|
||||
inputEmail.addEventListener('change', async () => {
|
||||
const email = inputEmail.value.trim();
|
||||
if (email) {
|
||||
await chrome.runtime.sendMessage({ type: 'SAVE_EMAIL', source: 'sidepanel', payload: { email } });
|
||||
}
|
||||
});
|
||||
|
||||
inputVpsUrl.addEventListener('change', async () => {
|
||||
const vpsUrl = inputVpsUrl.value.trim();
|
||||
if (vpsUrl) {
|
||||
await chrome.runtime.sendMessage({ type: 'SAVE_SETTING', source: 'sidepanel', payload: { vpsUrl } });
|
||||
}
|
||||
});
|
||||
|
||||
inputPassword.addEventListener('change', async () => {
|
||||
await chrome.runtime.sendMessage({
|
||||
type: 'SAVE_SETTING',
|
||||
source: 'sidepanel',
|
||||
payload: { customPassword: inputPassword.value },
|
||||
});
|
||||
});
|
||||
|
||||
selectMailProvider.addEventListener('change', async () => {
|
||||
updateMailProviderUI();
|
||||
await chrome.runtime.sendMessage({
|
||||
type: 'SAVE_SETTING', source: 'sidepanel',
|
||||
payload: { mailProvider: selectMailProvider.value },
|
||||
});
|
||||
});
|
||||
|
||||
selectEmailProvider.addEventListener('change', async () => {
|
||||
updateMailProviderUI();
|
||||
updateAutoContinueHint();
|
||||
await chrome.runtime.sendMessage({
|
||||
type: 'SAVE_SETTING',
|
||||
source: 'sidepanel',
|
||||
payload: { emailProvider: getSelectedEmailProvider() },
|
||||
});
|
||||
});
|
||||
|
||||
inputCloudflareTempEmailUrl.addEventListener('change', async () => {
|
||||
updateAutoContinueHint();
|
||||
await chrome.runtime.sendMessage({
|
||||
type: 'SAVE_SETTING',
|
||||
source: 'sidepanel',
|
||||
payload: { cloudflareTempEmailAdminUrl: inputCloudflareTempEmailUrl.value.trim() },
|
||||
});
|
||||
});
|
||||
|
||||
inputInbucketMailbox.addEventListener('change', async () => {
|
||||
await chrome.runtime.sendMessage({
|
||||
type: 'SAVE_SETTING',
|
||||
source: 'sidepanel',
|
||||
payload: { inbucketMailbox: inputInbucketMailbox.value.trim() },
|
||||
});
|
||||
});
|
||||
|
||||
inputInbucketHost.addEventListener('change', async () => {
|
||||
await chrome.runtime.sendMessage({
|
||||
type: 'SAVE_SETTING',
|
||||
source: 'sidepanel',
|
||||
payload: { inbucketHost: inputInbucketHost.value.trim() },
|
||||
});
|
||||
});
|
||||
|
||||
// ============================================================
|
||||
// Listen for Background broadcasts
|
||||
// ============================================================
|
||||
|
||||
chrome.runtime.onMessage.addListener((message) => {
|
||||
switch (message.type) {
|
||||
case 'LOG_ENTRY':
|
||||
appendLog(message.payload);
|
||||
if (message.payload.level === 'error') {
|
||||
showToast(message.payload.message, 'error');
|
||||
}
|
||||
break;
|
||||
|
||||
case 'STEP_STATUS_CHANGED': {
|
||||
const { step, status } = message.payload;
|
||||
updateStepUI(step, status);
|
||||
chrome.runtime.sendMessage({ type: 'GET_STATE', source: 'sidepanel' }).then(updateStatusDisplay);
|
||||
if (status === 'completed') {
|
||||
chrome.runtime.sendMessage({ type: 'GET_STATE', source: 'sidepanel' }).then(state => {
|
||||
syncPasswordField(state);
|
||||
if (state.oauthUrl) {
|
||||
displayOauthUrl.textContent = state.oauthUrl;
|
||||
displayOauthUrl.classList.add('has-value');
|
||||
}
|
||||
if (state.localhostUrl) {
|
||||
displayLocalhostUrl.textContent = state.localhostUrl;
|
||||
displayLocalhostUrl.classList.add('has-value');
|
||||
}
|
||||
});
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case 'AUTO_RUN_RESET': {
|
||||
// Full UI reset for next run
|
||||
displayOauthUrl.textContent = 'Waiting...';
|
||||
displayOauthUrl.classList.remove('has-value');
|
||||
displayLocalhostUrl.textContent = 'Waiting...';
|
||||
displayLocalhostUrl.classList.remove('has-value');
|
||||
inputEmail.value = '';
|
||||
displayStatus.textContent = 'Ready';
|
||||
statusBar.className = 'status-bar';
|
||||
logArea.innerHTML = '';
|
||||
document.querySelectorAll('.step-row').forEach(row => row.className = 'step-row');
|
||||
document.querySelectorAll('.step-status').forEach(el => el.textContent = '');
|
||||
updateStopButtonState(false);
|
||||
updateProgressCounter();
|
||||
break;
|
||||
}
|
||||
|
||||
case 'DATA_UPDATED': {
|
||||
if (message.payload.email) {
|
||||
inputEmail.value = message.payload.email;
|
||||
}
|
||||
if (message.payload.password !== undefined) {
|
||||
inputPassword.value = message.payload.password || '';
|
||||
}
|
||||
if (message.payload.oauthUrl) {
|
||||
displayOauthUrl.textContent = message.payload.oauthUrl;
|
||||
displayOauthUrl.classList.add('has-value');
|
||||
}
|
||||
if (message.payload.localhostUrl) {
|
||||
displayLocalhostUrl.textContent = message.payload.localhostUrl;
|
||||
displayLocalhostUrl.classList.add('has-value');
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case 'AUTO_RUN_STATUS': {
|
||||
const { phase, currentRun, totalRuns } = message.payload;
|
||||
const runLabel = totalRuns > 1 ? ` (${currentRun}/${totalRuns})` : '';
|
||||
switch (phase) {
|
||||
case 'waiting_email':
|
||||
autoContinueBar.style.display = 'flex';
|
||||
btnAutoRun.innerHTML = `Paused${runLabel}`;
|
||||
updateStopButtonState(true);
|
||||
break;
|
||||
case 'running':
|
||||
btnAutoRun.innerHTML = `Running${runLabel}`;
|
||||
updateStopButtonState(true);
|
||||
break;
|
||||
case 'complete':
|
||||
btnAutoRun.disabled = false;
|
||||
inputRunCount.disabled = false;
|
||||
btnAutoRun.innerHTML = '<svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor"><polygon points="5 3 19 12 5 21 5 3"/></svg> Auto';
|
||||
autoContinueBar.style.display = 'none';
|
||||
updateStopButtonState(false);
|
||||
break;
|
||||
case 'stopped':
|
||||
btnAutoRun.disabled = false;
|
||||
inputRunCount.disabled = false;
|
||||
btnAutoRun.innerHTML = '<svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor"><polygon points="5 3 19 12 5 21 5 3"/></svg> Auto';
|
||||
autoContinueBar.style.display = 'none';
|
||||
updateStopButtonState(false);
|
||||
break;
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// ============================================================
|
||||
// Theme Toggle
|
||||
// ============================================================
|
||||
|
||||
const btnTheme = document.getElementById('btn-theme');
|
||||
|
||||
function setTheme(theme) {
|
||||
document.documentElement.setAttribute('data-theme', theme);
|
||||
localStorage.setItem('multipage-theme', theme);
|
||||
}
|
||||
|
||||
function initTheme() {
|
||||
const saved = localStorage.getItem('multipage-theme');
|
||||
if (saved) {
|
||||
setTheme(saved);
|
||||
} else if (window.matchMedia('(prefers-color-scheme: dark)').matches) {
|
||||
setTheme('dark');
|
||||
}
|
||||
}
|
||||
|
||||
btnTheme.addEventListener('click', () => {
|
||||
const current = document.documentElement.getAttribute('data-theme');
|
||||
setTheme(current === 'dark' ? 'light' : 'dark');
|
||||
});
|
||||
|
||||
// ============================================================
|
||||
// Init
|
||||
// ============================================================
|
||||
|
||||
initTheme();
|
||||
restoreState().then(() => {
|
||||
syncPasswordToggleLabel();
|
||||
updateButtonStates();
|
||||
});
|
||||
@@ -0,0 +1,221 @@
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
|
||||
const {
|
||||
combineDistinctTextParts,
|
||||
extractVerificationCode,
|
||||
generateReadableLocalPart,
|
||||
parseAdminTimestamp,
|
||||
parseCloudflareMailboxCredential,
|
||||
pickRandomSuffix,
|
||||
selectVerificationMessage,
|
||||
} = require('../shared/cloudflare-temp-email.js');
|
||||
|
||||
function createJwt(payload) {
|
||||
const header = Buffer.from(JSON.stringify({ alg: 'HS256', typ: 'JWT' })).toString('base64url');
|
||||
const body = Buffer.from(JSON.stringify(payload)).toString('base64url');
|
||||
return `${header}.${body}.signature`;
|
||||
}
|
||||
|
||||
test('parseCloudflareMailboxCredential decodes email and address id from JWT token', () => {
|
||||
const token = createJwt({
|
||||
address: 'newmask@co.example.test',
|
||||
address_id: 42,
|
||||
});
|
||||
|
||||
assert.deepEqual(parseCloudflareMailboxCredential(token), {
|
||||
addressId: 42,
|
||||
domain: 'co.example.test',
|
||||
email: 'newmask@co.example.test',
|
||||
localPart: 'newmask',
|
||||
provenance: 'created',
|
||||
});
|
||||
});
|
||||
|
||||
test('parseAdminTimestamp parses admin timestamps into epoch milliseconds', () => {
|
||||
const value = parseAdminTimestamp('2026/4/7 10:33:07');
|
||||
assert.equal(Number.isFinite(value), true);
|
||||
assert.equal(new Date(value).getFullYear(), 2026);
|
||||
});
|
||||
|
||||
test('extractVerificationCode reads six-digit codes from mixed-language subjects', () => {
|
||||
assert.equal(extractVerificationCode('Your ChatGPT code is 377680'), '377680');
|
||||
assert.equal(extractVerificationCode('你的 ChatGPT 代码为 479637,请勿泄露。'), '479637');
|
||||
});
|
||||
|
||||
test('combineDistinctTextParts collapses duplicated text fragments from DOM sources', () => {
|
||||
const value = combineDistinctTextParts([
|
||||
'账号',
|
||||
'账号',
|
||||
' 账号 ',
|
||||
'',
|
||||
null,
|
||||
]);
|
||||
|
||||
assert.equal(value, '账号');
|
||||
});
|
||||
|
||||
test('combineDistinctTextParts keeps distinct fragments in order', () => {
|
||||
const value = combineDistinctTextParts([
|
||||
'邮箱地址凭证',
|
||||
'token-value',
|
||||
'token-value',
|
||||
'关闭',
|
||||
]);
|
||||
|
||||
assert.equal(value, '邮箱地址凭证 token-value 关闭');
|
||||
});
|
||||
|
||||
test('generateReadableLocalPart creates three lowercase hyphenated words', () => {
|
||||
const value = generateReadableLocalPart(() => 0);
|
||||
|
||||
assert.match(value, /^[a-z]+-[a-z]+-[a-z]+$/);
|
||||
assert.equal(value.split('-').length, 3);
|
||||
});
|
||||
|
||||
test('generateReadableLocalPart is deterministic for a fixed random sequence', () => {
|
||||
const sequence = [0.02, 0.31, 0.58];
|
||||
let index = 0;
|
||||
const value = generateReadableLocalPart(() => sequence[index++]);
|
||||
|
||||
assert.equal(value, 'anew-dotted-latch');
|
||||
});
|
||||
|
||||
test('generateReadableLocalPart retries until the generated local part fits the max length', () => {
|
||||
const sequence = [
|
||||
0.98, 0.98, 0.98,
|
||||
0.02, 0.31, 0.58,
|
||||
];
|
||||
let index = 0;
|
||||
const value = generateReadableLocalPart(() => sequence[index++], 20);
|
||||
|
||||
assert.equal(value.length <= 20, true);
|
||||
assert.equal(value, 'anew-dotted-latch');
|
||||
});
|
||||
|
||||
test('generateReadableLocalPart can use newly added higher-range words', () => {
|
||||
const sequence = [0.9, 0.9, 0.9];
|
||||
let index = 0;
|
||||
const value = generateReadableLocalPart(() => sequence[index++]);
|
||||
|
||||
assert.equal(value, 'vantage-velvet-zephyr');
|
||||
});
|
||||
|
||||
test('generateReadableLocalPart can use extended top-range words', () => {
|
||||
const sequence = [0.97, 0.97, 0.97];
|
||||
let index = 0;
|
||||
const value = generateReadableLocalPart(() => sequence[index++]);
|
||||
|
||||
assert.equal(value, 'whimsy-marbled-solstice');
|
||||
});
|
||||
|
||||
test('generateReadableLocalPart can use extended mid-range words', () => {
|
||||
const sequence = [0.962, 0.962, 0.962];
|
||||
let index = 0;
|
||||
const value = generateReadableLocalPart(() => sequence[index++]);
|
||||
|
||||
assert.equal(value, 'ivory-rusted-starling');
|
||||
});
|
||||
|
||||
test('generateReadableLocalPart can use extended apex-range words', () => {
|
||||
const sequence = [0.993, 0.993, 0.993];
|
||||
let index = 0;
|
||||
const value = generateReadableLocalPart(() => sequence[index++]);
|
||||
|
||||
assert.equal(value, 'atlas-bronze-cosmos');
|
||||
});
|
||||
|
||||
test('pickRandomSuffix selects a deterministic suffix from the available options', () => {
|
||||
const value = pickRandomSuffix([
|
||||
'co.example.test',
|
||||
'de.example.test',
|
||||
'ice.example.test',
|
||||
'work.example.test',
|
||||
], () => 0.74);
|
||||
|
||||
assert.equal(value, 'ice.example.test');
|
||||
});
|
||||
|
||||
test('pickRandomSuffix ignores empty and duplicate suffix values', () => {
|
||||
const value = pickRandomSuffix([
|
||||
' co.example.test ',
|
||||
'',
|
||||
null,
|
||||
'de.example.test',
|
||||
'@ICE.example.test',
|
||||
'de.example.test',
|
||||
], () => 0.9);
|
||||
|
||||
assert.equal(value, 'ice.example.test');
|
||||
});
|
||||
|
||||
test('selectVerificationMessage ignores messages for other recipients', () => {
|
||||
const result = selectVerificationMessage([
|
||||
{
|
||||
combinedText: 'Your ChatGPT code is 123456',
|
||||
emailTimestamp: parseAdminTimestamp('2026/4/7 10:33:07'),
|
||||
matchedEmail: 'someone-else@co.example.test',
|
||||
messageId: '11',
|
||||
subject: 'Your ChatGPT code is 123456',
|
||||
},
|
||||
], {
|
||||
filterAfterTimestamp: 0,
|
||||
senderFilters: ['openai'],
|
||||
subjectFilters: ['code'],
|
||||
targetEmail: 'target@co.example.test',
|
||||
});
|
||||
|
||||
assert.equal(result, null);
|
||||
});
|
||||
|
||||
test('selectVerificationMessage requires a strictly newer timestamp than filterAfterTimestamp', () => {
|
||||
const ts = parseAdminTimestamp('2026/4/7 10:33:07');
|
||||
const result = selectVerificationMessage([
|
||||
{
|
||||
combinedText: 'Enter this temporary verification code to continue: 377680',
|
||||
emailTimestamp: ts,
|
||||
matchedEmail: 'target@co.example.test',
|
||||
messageId: '11',
|
||||
subject: 'Your ChatGPT code is 377680',
|
||||
},
|
||||
], {
|
||||
filterAfterTimestamp: ts,
|
||||
senderFilters: ['openai'],
|
||||
subjectFilters: ['code'],
|
||||
targetEmail: 'target@co.example.test',
|
||||
});
|
||||
|
||||
assert.equal(result, null);
|
||||
});
|
||||
|
||||
test('selectVerificationMessage picks the newest matching message after the threshold', () => {
|
||||
const result = selectVerificationMessage([
|
||||
{
|
||||
combinedText: '旧验证码 111111',
|
||||
emailTimestamp: parseAdminTimestamp('2026/4/7 10:30:00'),
|
||||
matchedEmail: 'target@co.example.test',
|
||||
messageId: '8',
|
||||
subject: 'Your ChatGPT code is 111111',
|
||||
},
|
||||
{
|
||||
combinedText: 'Enter this temporary verification code to continue: 377680',
|
||||
emailTimestamp: parseAdminTimestamp('2026/4/7 10:33:07'),
|
||||
matchedEmail: 'target@co.example.test',
|
||||
messageId: '11',
|
||||
subject: 'Your ChatGPT code is 377680',
|
||||
},
|
||||
], {
|
||||
filterAfterTimestamp: parseAdminTimestamp('2026/4/7 10:31:00'),
|
||||
senderFilters: ['openai'],
|
||||
subjectFilters: ['code'],
|
||||
targetEmail: 'target@co.example.test',
|
||||
});
|
||||
|
||||
assert.deepEqual(result, {
|
||||
code: '377680',
|
||||
emailTimestamp: parseAdminTimestamp('2026/4/7 10:33:07'),
|
||||
matchedEmail: 'target@co.example.test',
|
||||
messageId: '11',
|
||||
subject: 'Your ChatGPT code is 377680',
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,106 @@
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
|
||||
const {
|
||||
DEFAULT_CLOUDFLARE_TEMP_EMAIL_ADMIN_URL,
|
||||
EMAIL_PROVIDER_DUCK,
|
||||
EMAIL_PROVIDER_CLOUDFLARE_TEMP_EMAIL,
|
||||
EMAIL_PROVIDER_RELAY_FIREFOX,
|
||||
getEmailProviderDisplayName,
|
||||
getNextRelayMaskLabel,
|
||||
isCloudflareTempEmailProvider,
|
||||
normalizeCloudflareTempEmailAdminUrl,
|
||||
normalizeEmailProvider,
|
||||
shouldUseEmailSourceForVerification,
|
||||
shouldSkipStep9Cleanup,
|
||||
} = require('../shared/email-provider.js');
|
||||
|
||||
test('normalizeEmailProvider keeps relay_firefox as-is', () => {
|
||||
assert.equal(normalizeEmailProvider('relay_firefox'), EMAIL_PROVIDER_RELAY_FIREFOX);
|
||||
});
|
||||
|
||||
test('normalizeEmailProvider keeps cloudflare_temp_email as-is', () => {
|
||||
assert.equal(
|
||||
normalizeEmailProvider('cloudflare_temp_email'),
|
||||
EMAIL_PROVIDER_CLOUDFLARE_TEMP_EMAIL
|
||||
);
|
||||
});
|
||||
|
||||
test('normalizeEmailProvider falls back to duckduckgo for unknown values', () => {
|
||||
assert.equal(normalizeEmailProvider('something-else'), EMAIL_PROVIDER_DUCK);
|
||||
});
|
||||
|
||||
test('isCloudflareTempEmailProvider identifies cloudflare_temp_email', () => {
|
||||
assert.equal(isCloudflareTempEmailProvider('cloudflare_temp_email'), true);
|
||||
});
|
||||
|
||||
test('isCloudflareTempEmailProvider rejects relay_firefox', () => {
|
||||
assert.equal(isCloudflareTempEmailProvider('relay_firefox'), false);
|
||||
});
|
||||
|
||||
test('getEmailProviderDisplayName returns Cloudflare Temp Email label', () => {
|
||||
assert.equal(
|
||||
getEmailProviderDisplayName('cloudflare_temp_email'),
|
||||
'Cloudflare Temp Email'
|
||||
);
|
||||
});
|
||||
|
||||
test('DEFAULT_CLOUDFLARE_TEMP_EMAIL_ADMIN_URL uses the public open-source-safe admin URL', () => {
|
||||
assert.equal(
|
||||
DEFAULT_CLOUDFLARE_TEMP_EMAIL_ADMIN_URL,
|
||||
'https://mail.cloudflare.com/admin'
|
||||
);
|
||||
});
|
||||
|
||||
test('normalizeCloudflareTempEmailAdminUrl falls back to the default URL for empty values', () => {
|
||||
assert.equal(
|
||||
normalizeCloudflareTempEmailAdminUrl(''),
|
||||
DEFAULT_CLOUDFLARE_TEMP_EMAIL_ADMIN_URL
|
||||
);
|
||||
});
|
||||
|
||||
test('normalizeCloudflareTempEmailAdminUrl trims whitespace and prepends https when protocol is missing', () => {
|
||||
assert.equal(
|
||||
normalizeCloudflareTempEmailAdminUrl(' custom.example.com/admin '),
|
||||
'https://custom.example.com/admin'
|
||||
);
|
||||
});
|
||||
|
||||
test('normalizeCloudflareTempEmailAdminUrl normalizes the default admin URL path', () => {
|
||||
assert.equal(
|
||||
normalizeCloudflareTempEmailAdminUrl('https://mail.cloudflare.com/admin/'),
|
||||
DEFAULT_CLOUDFLARE_TEMP_EMAIL_ADMIN_URL
|
||||
);
|
||||
});
|
||||
|
||||
test('getNextRelayMaskLabel returns t1 when there are no existing labels', () => {
|
||||
assert.equal(getNextRelayMaskLabel([]), 't1');
|
||||
});
|
||||
|
||||
test('getNextRelayMaskLabel fills the first numeric gap', () => {
|
||||
assert.equal(getNextRelayMaskLabel(['t1', 'hello', 't3']), 't2');
|
||||
});
|
||||
|
||||
test('shouldSkipStep9Cleanup returns false for relay_firefox', () => {
|
||||
assert.equal(shouldSkipStep9Cleanup('relay_firefox'), false);
|
||||
});
|
||||
|
||||
test('shouldUseEmailSourceForVerification returns true for cloudflare_temp_email', () => {
|
||||
assert.equal(shouldUseEmailSourceForVerification('cloudflare_temp_email'), true);
|
||||
});
|
||||
|
||||
test('shouldUseEmailSourceForVerification returns false for relay_firefox', () => {
|
||||
assert.equal(shouldUseEmailSourceForVerification('relay_firefox'), false);
|
||||
});
|
||||
|
||||
test('shouldUseEmailSourceForVerification returns false for duckduckgo', () => {
|
||||
assert.equal(shouldUseEmailSourceForVerification('duckduckgo'), false);
|
||||
});
|
||||
|
||||
test('shouldSkipStep9Cleanup returns true for duckduckgo', () => {
|
||||
assert.equal(shouldSkipStep9Cleanup('duckduckgo'), true);
|
||||
});
|
||||
|
||||
test('shouldSkipStep9Cleanup returns true for cloudflare_temp_email', () => {
|
||||
assert.equal(shouldSkipStep9Cleanup('cloudflare_temp_email'), true);
|
||||
});
|
||||
@@ -0,0 +1,102 @@
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
|
||||
const {
|
||||
hasAnyConsentPageState,
|
||||
findLoopbackCallbackUrl,
|
||||
isConsentUrl,
|
||||
isConsentPageState,
|
||||
isLoopbackCallbackUrl,
|
||||
} = require('../shared/oauth-flow.js');
|
||||
|
||||
test('isConsentUrl matches the exact known consent URL', () => {
|
||||
assert.equal(
|
||||
isConsentUrl('https://auth.openai.com/sign-in-with-chatgpt/codex/consent'),
|
||||
true
|
||||
);
|
||||
});
|
||||
|
||||
test('isConsentUrl rejects unrelated auth routes', () => {
|
||||
assert.equal(
|
||||
isConsentUrl('https://auth.openai.com/u/signup/identifier'),
|
||||
false
|
||||
);
|
||||
});
|
||||
|
||||
test('isConsentPageState accepts sign-in-with-chatgpt routes when a continue button is visible', () => {
|
||||
assert.equal(
|
||||
isConsentPageState({
|
||||
url: 'https://auth.openai.com/sign-in-with-chatgpt/codex/consent?state=abc',
|
||||
hasVisibleContinueButton: true,
|
||||
}),
|
||||
true
|
||||
);
|
||||
});
|
||||
|
||||
test('isConsentPageState rejects sign-in-with-chatgpt routes without a visible continue button when URL is not exact', () => {
|
||||
assert.equal(
|
||||
isConsentPageState({
|
||||
url: 'https://auth.openai.com/sign-in-with-chatgpt/codex/checkpoint',
|
||||
hasVisibleContinueButton: false,
|
||||
}),
|
||||
false
|
||||
);
|
||||
});
|
||||
|
||||
test('hasAnyConsentPageState returns true when consent appears after an initial non-consent state', () => {
|
||||
assert.equal(
|
||||
hasAnyConsentPageState([
|
||||
{
|
||||
url: 'https://auth.openai.com/u/signup/profile',
|
||||
hasVisibleContinueButton: false,
|
||||
},
|
||||
{
|
||||
url: 'https://auth.openai.com/sign-in-with-chatgpt/codex/consent?state=abc',
|
||||
hasVisibleContinueButton: true,
|
||||
},
|
||||
]),
|
||||
true
|
||||
);
|
||||
});
|
||||
|
||||
test('isLoopbackCallbackUrl accepts localhost callback URLs', () => {
|
||||
assert.equal(
|
||||
isLoopbackCallbackUrl('http://localhost:1455/auth/callback?code=abc&state=123'),
|
||||
true
|
||||
);
|
||||
});
|
||||
|
||||
test('isLoopbackCallbackUrl accepts 127.0.0.1 callback URLs', () => {
|
||||
assert.equal(
|
||||
isLoopbackCallbackUrl('http://127.0.0.1:8317/codex/callback?code=abc&state=123'),
|
||||
true
|
||||
);
|
||||
});
|
||||
|
||||
test('isLoopbackCallbackUrl rejects non-loopback callback URLs', () => {
|
||||
assert.equal(
|
||||
isLoopbackCallbackUrl('https://example.com/callback?code=abc&state=123'),
|
||||
false
|
||||
);
|
||||
});
|
||||
|
||||
test('findLoopbackCallbackUrl returns the first loopback callback URL from candidates', () => {
|
||||
assert.equal(
|
||||
findLoopbackCallbackUrl([
|
||||
'https://auth.openai.com/sign-in-with-chatgpt/codex/consent',
|
||||
'http://127.0.0.1:8317/codex/callback?code=abc&state=123',
|
||||
'http://localhost:1455/auth/callback?code=def&state=456',
|
||||
]),
|
||||
'http://127.0.0.1:8317/codex/callback?code=abc&state=123'
|
||||
);
|
||||
});
|
||||
|
||||
test('findLoopbackCallbackUrl returns null when no loopback callback URL exists', () => {
|
||||
assert.equal(
|
||||
findLoopbackCallbackUrl([
|
||||
'https://auth.openai.com/sign-in-with-chatgpt/codex/consent',
|
||||
'https://example.com/callback?code=abc&state=123',
|
||||
]),
|
||||
null
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,59 @@
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
|
||||
const {
|
||||
extractVerificationCode,
|
||||
findNewQQVerificationCode,
|
||||
} = require('../shared/qq-mail.js');
|
||||
|
||||
test('extractVerificationCode reads 6-digit codes from QQ mail text', () => {
|
||||
assert.equal(
|
||||
extractVerificationCode('你的 ChatGPT 代码为 479637,请勿泄露。'),
|
||||
'479637'
|
||||
);
|
||||
});
|
||||
|
||||
test('findNewQQVerificationCode rejects matching emails that already existed before polling', () => {
|
||||
const result = findNewQQVerificationCode([
|
||||
{
|
||||
mailId: 'old-1',
|
||||
sender: 'OpenAI',
|
||||
subject: '你的 ChatGPT 代码为 479637',
|
||||
digest: '用于验证你的邮箱地址',
|
||||
},
|
||||
], {
|
||||
existingMailIds: ['old-1'],
|
||||
senderFilters: ['openai', 'verify'],
|
||||
subjectFilters: ['code', '验证'],
|
||||
});
|
||||
|
||||
assert.equal(result, null);
|
||||
});
|
||||
|
||||
test('findNewQQVerificationCode accepts the first new matching email', () => {
|
||||
const result = findNewQQVerificationCode([
|
||||
{
|
||||
mailId: 'old-1',
|
||||
sender: 'OpenAI',
|
||||
subject: '你的 ChatGPT 代码为 111111',
|
||||
digest: '旧邮件',
|
||||
},
|
||||
{
|
||||
mailId: 'new-1',
|
||||
sender: 'OpenAI',
|
||||
subject: '你的 ChatGPT 代码为 222222',
|
||||
digest: '新邮件',
|
||||
},
|
||||
], {
|
||||
existingMailIds: ['old-1'],
|
||||
senderFilters: ['openai', 'verify'],
|
||||
subjectFilters: ['code', '验证'],
|
||||
});
|
||||
|
||||
assert.deepEqual(result, {
|
||||
code: '222222',
|
||||
mailId: 'new-1',
|
||||
source: 'new',
|
||||
subject: '你的 ChatGPT 代码为 222222',
|
||||
});
|
||||
});
|
||||
527
DouYinSparkFlow/webui/static/styles.css
Normal file
@@ -0,0 +1,527 @@
|
||||
:root {
|
||||
--bg: #f4f1ea;
|
||||
--panel: #fffdfa;
|
||||
--panel-strong: #f7efe2;
|
||||
--ink: #1f1d1a;
|
||||
--muted: #665f55;
|
||||
--line: #d9cdbd;
|
||||
--accent: #b84c2a;
|
||||
--accent-deep: #8f3215;
|
||||
--danger: #8f1d2c;
|
||||
--warning: #8f5d00;
|
||||
--success: #236c43;
|
||||
--shadow: 0 16px 40px rgba(63, 42, 17, 0.12);
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
min-height: 100vh;
|
||||
font-family: "Segoe UI", "PingFang SC", "Hiragino Sans GB", sans-serif;
|
||||
color: var(--ink);
|
||||
background:
|
||||
radial-gradient(circle at top left, rgba(184, 76, 42, 0.16), transparent 26%),
|
||||
radial-gradient(circle at top right, rgba(35, 108, 67, 0.12), transparent 24%),
|
||||
linear-gradient(180deg, #f9f4eb 0%, var(--bg) 100%);
|
||||
}
|
||||
|
||||
.topbar {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
justify-content: space-between;
|
||||
gap: 24px;
|
||||
padding: 32px 36px 18px;
|
||||
}
|
||||
|
||||
.eyebrow {
|
||||
margin: 0 0 4px;
|
||||
font-size: 12px;
|
||||
letter-spacing: 0.14em;
|
||||
text-transform: uppercase;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
h1, h2 {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: clamp(28px, 4vw, 42px);
|
||||
}
|
||||
|
||||
h2 {
|
||||
font-size: 22px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.page-shell {
|
||||
padding: 0 36px 36px;
|
||||
}
|
||||
|
||||
.grid {
|
||||
display: grid;
|
||||
gap: 20px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.grid.two-up {
|
||||
grid-template-columns: repeat(auto-fit, minmax(320px, 1fr));
|
||||
}
|
||||
|
||||
.panel {
|
||||
background: rgba(255, 253, 250, 0.92);
|
||||
border: 1px solid rgba(217, 205, 189, 0.8);
|
||||
border-radius: 22px;
|
||||
box-shadow: var(--shadow);
|
||||
padding: 22px;
|
||||
backdrop-filter: blur(10px);
|
||||
}
|
||||
|
||||
.panel.narrow {
|
||||
max-width: 520px;
|
||||
margin: 8vh auto 0;
|
||||
}
|
||||
|
||||
.panel-header {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.muted {
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.compact {
|
||||
margin-top: 6px;
|
||||
}
|
||||
|
||||
.banner {
|
||||
margin: 0 36px 18px;
|
||||
padding: 14px 18px;
|
||||
border-radius: 14px;
|
||||
border: 1px solid transparent;
|
||||
}
|
||||
|
||||
.banner.warning {
|
||||
background: rgba(255, 233, 194, 0.82);
|
||||
border-color: rgba(143, 93, 0, 0.25);
|
||||
}
|
||||
|
||||
.banner.success {
|
||||
background: rgba(202, 239, 218, 0.88);
|
||||
border-color: rgba(35, 108, 67, 0.24);
|
||||
}
|
||||
|
||||
.banner.error {
|
||||
background: rgba(250, 212, 219, 0.9);
|
||||
border-color: rgba(143, 29, 44, 0.22);
|
||||
}
|
||||
|
||||
.banner.info {
|
||||
background: rgba(213, 230, 247, 0.88);
|
||||
border-color: rgba(32, 87, 127, 0.2);
|
||||
}
|
||||
|
||||
.stack-form {
|
||||
display: grid;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
label {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
label span {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
input,
|
||||
textarea,
|
||||
button {
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
input,
|
||||
textarea {
|
||||
width: 100%;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 12px;
|
||||
background: #fff;
|
||||
color: var(--ink);
|
||||
padding: 12px 14px;
|
||||
}
|
||||
|
||||
textarea {
|
||||
resize: vertical;
|
||||
}
|
||||
|
||||
button,
|
||||
.link-button {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
min-height: 44px;
|
||||
border: 0;
|
||||
border-radius: 999px;
|
||||
padding: 0 18px;
|
||||
background: linear-gradient(135deg, var(--accent) 0%, var(--accent-deep) 100%);
|
||||
color: #fff;
|
||||
cursor: pointer;
|
||||
text-decoration: none;
|
||||
transition: transform 120ms ease, box-shadow 120ms ease;
|
||||
box-shadow: 0 10px 24px rgba(184, 76, 42, 0.2);
|
||||
}
|
||||
|
||||
button:hover,
|
||||
.link-button:hover {
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.ghost-button {
|
||||
background: rgba(255, 255, 255, 0.65);
|
||||
color: var(--ink);
|
||||
border: 1px solid var(--line);
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.danger-button {
|
||||
background: linear-gradient(135deg, #b53749 0%, var(--danger) 100%);
|
||||
box-shadow: 0 10px 24px rgba(143, 29, 44, 0.18);
|
||||
}
|
||||
|
||||
.stats {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(120px, 1fr));
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.stats article {
|
||||
background: var(--panel-strong);
|
||||
border-radius: 16px;
|
||||
padding: 14px;
|
||||
border: 1px solid rgba(217, 205, 189, 0.8);
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
display: block;
|
||||
font-size: 12px;
|
||||
color: var(--muted);
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.account-list {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.account-card {
|
||||
border: 1px solid rgba(217, 205, 189, 0.8);
|
||||
border-radius: 18px;
|
||||
padding: 16px;
|
||||
background: rgba(255, 255, 255, 0.78);
|
||||
display: grid;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.inline-meta,
|
||||
.button-row,
|
||||
.button-grid {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.button-grid {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.pill {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
min-height: 30px;
|
||||
padding: 0 12px;
|
||||
border-radius: 999px;
|
||||
background: rgba(184, 76, 42, 0.1);
|
||||
color: var(--accent-deep);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.pill.soft {
|
||||
background: rgba(31, 29, 26, 0.08);
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.table-shell {
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
|
||||
th,
|
||||
td {
|
||||
text-align: left;
|
||||
padding: 10px 12px;
|
||||
border-bottom: 1px solid rgba(217, 205, 189, 0.7);
|
||||
}
|
||||
|
||||
th {
|
||||
color: var(--muted);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.code-block {
|
||||
margin: 0;
|
||||
padding: 14px;
|
||||
border-radius: 16px;
|
||||
background: #171411;
|
||||
color: #f8f4ee;
|
||||
overflow: auto;
|
||||
font-family: "Cascadia Code", "SFMono-Regular", Consolas, monospace;
|
||||
font-size: 13px;
|
||||
line-height: 1.55;
|
||||
}
|
||||
|
||||
.code-block.tall {
|
||||
min-height: 320px;
|
||||
max-height: 520px;
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
min-height: 160px;
|
||||
border: 1px dashed var(--line);
|
||||
border-radius: 16px;
|
||||
color: var(--muted);
|
||||
background: rgba(255, 255, 255, 0.46);
|
||||
}
|
||||
|
||||
.qr-preview {
|
||||
display: block;
|
||||
width: 100%;
|
||||
max-width: 760px;
|
||||
border-radius: 20px;
|
||||
border: 4px solid #fff;
|
||||
box-shadow: 0 12px 36px rgba(63, 42, 17, 0.18);
|
||||
margin: 0 auto 18px;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
}
|
||||
|
||||
.qr-preview:hover {
|
||||
transform: scale(1.02);
|
||||
box-shadow: 0 18px 48px rgba(63, 42, 17, 0.24);
|
||||
}
|
||||
|
||||
.qr-preview.expanded {
|
||||
max-width: 900px;
|
||||
}
|
||||
|
||||
body.modal-open {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.image-modal[hidden] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.image-modal {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 1000;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
}
|
||||
|
||||
.image-modal-backdrop {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: rgba(17, 12, 8, 0.72);
|
||||
backdrop-filter: blur(6px);
|
||||
}
|
||||
|
||||
.image-modal-dialog {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
width: min(96vw, 1080px);
|
||||
max-height: 92vh;
|
||||
display: grid;
|
||||
gap: 16px;
|
||||
padding: 20px;
|
||||
border-radius: 24px;
|
||||
background: rgba(255, 253, 250, 0.98);
|
||||
box-shadow: 0 24px 60px rgba(17, 12, 8, 0.3);
|
||||
}
|
||||
|
||||
.image-modal-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.image-modal-preview {
|
||||
width: 100%;
|
||||
max-height: calc(92vh - 92px);
|
||||
object-fit: contain;
|
||||
border-radius: 18px;
|
||||
border: 1px solid rgba(217, 205, 189, 0.9);
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.friend-picker {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.friend-picker-toolbar {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.friend-picker-title {
|
||||
display: inline-block;
|
||||
font-weight: 600;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.friend-search {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.friend-picker-list {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: flex-start;
|
||||
align-content: flex-start;
|
||||
gap: 8px;
|
||||
max-height: 280px;
|
||||
overflow: auto;
|
||||
padding: 12px;
|
||||
border: 1px solid rgba(217, 205, 189, 0.9);
|
||||
border-radius: 16px;
|
||||
background: rgba(255, 255, 255, 0.8);
|
||||
}
|
||||
|
||||
.friend-picker-current-targets {
|
||||
padding: 10px 12px;
|
||||
border-radius: 12px;
|
||||
background: rgba(247, 239, 226, 0.65);
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.friend-option {
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0;
|
||||
min-height: 40px;
|
||||
max-width: 100%;
|
||||
padding: 8px 14px;
|
||||
border-radius: 999px;
|
||||
border: 1px solid rgba(217, 205, 189, 0.95);
|
||||
background: rgba(255, 255, 255, 0.92);
|
||||
cursor: pointer;
|
||||
transition: border-color 120ms ease, background-color 120ms ease, box-shadow 120ms ease;
|
||||
}
|
||||
|
||||
.friend-option span {
|
||||
font-weight: 600;
|
||||
line-height: 1.2;
|
||||
word-break: break-word;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.friend-option input {
|
||||
position: absolute;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
inset: 0;
|
||||
}
|
||||
|
||||
.friend-option:hover {
|
||||
border-color: rgba(143, 50, 21, 0.35);
|
||||
box-shadow: 0 8px 18px rgba(63, 42, 17, 0.08);
|
||||
}
|
||||
|
||||
.friend-option.selected {
|
||||
border-color: #2f6fed;
|
||||
background: #2f6fed;
|
||||
box-shadow: 0 10px 20px rgba(47, 111, 237, 0.22);
|
||||
}
|
||||
|
||||
.friend-option.selected span {
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.friend-picker-empty {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
min-height: 120px;
|
||||
border: 1px dashed var(--line);
|
||||
border-radius: 14px;
|
||||
color: var(--muted);
|
||||
background: rgba(255, 255, 255, 0.4);
|
||||
text-align: center;
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.check-row {
|
||||
grid-template-columns: auto 1fr;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.check-row input {
|
||||
width: auto;
|
||||
}
|
||||
|
||||
.ops-meta {
|
||||
margin-top: 16px;
|
||||
color: var(--muted);
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
@media (max-width: 720px) {
|
||||
.topbar,
|
||||
.page-shell {
|
||||
padding-left: 18px;
|
||||
padding-right: 18px;
|
||||
}
|
||||
|
||||
.banner {
|
||||
margin-left: 18px;
|
||||
margin-right: 18px;
|
||||
}
|
||||
|
||||
.panel-header {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.button-grid,
|
||||
.button-row,
|
||||
.inline-meta {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
button,
|
||||
.link-button {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
1039
DouYinSparkFlow/webui/templates/base.html
Normal file
731
DouYinSparkFlow/webui/templates/dashboard.html
Normal file
@@ -0,0 +1,731 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}抖音多账号续火花控制台{% endblock %}
|
||||
{% block page_title %}抖音多账号续火花控制台{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
{% set enabled_accounts = accounts | selectattr("enabled", "equalto", true) | list %}
|
||||
{% set proxy_rows = ops.containers | selectattr("Names", "equalto", "mihomo") | list %}
|
||||
<div class="layout-grid">
|
||||
<div class="stack">
|
||||
<section class="panel">
|
||||
<div class="stats-grid">
|
||||
<article class="stat-card">
|
||||
<div class="stat-meta">
|
||||
<span class="stat-label">账号总数</span>
|
||||
<span class="muted compact">已启用 {{ enabled_accounts|length }} / 总数 {{ accounts|length }}</span>
|
||||
<strong class="stat-value">{{ accounts|length }}</strong>
|
||||
</div>
|
||||
<div class="stat-icon blue">👥</div>
|
||||
</article>
|
||||
<article class="stat-card">
|
||||
<div class="stat-meta">
|
||||
<span class="stat-label">今日发送计划时间</span>
|
||||
<span class="muted compact">下一次执行:{{ ops.daily_schedule or "未配置" }}</span>
|
||||
<strong class="stat-subvalue">{{ ops.daily_schedule or "未配置" }}</strong>
|
||||
</div>
|
||||
<div class="stat-icon green">🕒</div>
|
||||
</article>
|
||||
<article class="stat-card">
|
||||
<div class="stat-meta">
|
||||
<span class="stat-label">代理容器状态</span>
|
||||
<span class="muted compact">容器在线 / 离线</span>
|
||||
<div class="status-line">
|
||||
<span class="pill {% if proxy_rows %}soft{% else %}warning{% endif %}">{{ proxy_rows|length }} / {{ ops.containers|length }}</span>
|
||||
<span class="pill {% if ops.image_present %}soft{% else %}warning{% endif %}">
|
||||
{% if ops.image_present %}镜像已构建{% else %}镜像待构建{% endif %}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="stat-icon soft">▣</div>
|
||||
</article>
|
||||
<article class="stat-card">
|
||||
<div class="stat-meta">
|
||||
<span class="stat-label">交互式登录桌面</span>
|
||||
<span class="muted compact"><a href="{{ login_desktop_public_url }}" target="_blank" rel="noreferrer">{{ login_desktop_public_url }}</a></span>
|
||||
<strong class="stat-subvalue">noVNC 直连</strong>
|
||||
</div>
|
||||
<div class="stat-icon blue">🖥</div>
|
||||
</article>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="panel" id="interactive-login-section">
|
||||
<div class="section-title-row">
|
||||
<div>
|
||||
<h2>交互式登录浏览器(推荐)</h2>
|
||||
<p class="muted compact">在网页里直接操作服务器浏览器完成扫码、短信验证和登录,不再依赖远程截图轮询。</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="login-stepbar">
|
||||
<div class="step-item active"><span class="step-index">1</span><span>打开浏览器</span></div>
|
||||
<div class="step-connector"></div>
|
||||
<div class="step-item active"><span class="step-index">2</span><span>扫码与验证</span></div>
|
||||
<div class="step-connector"></div>
|
||||
<div class="step-item active"><span class="step-index">3</span><span>进入创作者中心</span></div>
|
||||
<div class="step-connector"></div>
|
||||
<div class="step-item active"><span class="step-index">4</span><span>保存登录账号</span></div>
|
||||
</div>
|
||||
|
||||
<div class="login-grid">
|
||||
<div class="login-box warning">
|
||||
<div class="stack-form">
|
||||
<div class="status-line">
|
||||
<span class="pill soft">方式:交互式远端浏览器</span>
|
||||
<span class="pill">端口:8788</span>
|
||||
</div>
|
||||
<div class="login-lead">
|
||||
通过 noVNC 直接在网页里操作服务器浏览器完成登录。
|
||||
</div>
|
||||
<ul class="feature-list">
|
||||
<li>在远端浏览器中直接扫码登录。</li>
|
||||
<li>短信验证码、验证按钮都由你直接操作。</li>
|
||||
<li>登录完成后点击“保存当前登录账号”同步到系统。</li>
|
||||
</ul>
|
||||
<div class="button-row">
|
||||
<button type="button" class="success-button login-desktop-open" data-relogin-unique-id="">打开交互式登录浏览器 ↗</button>
|
||||
<button type="button" class="ghost-button login-desktop-reset">重置登录桌面 ↻</button>
|
||||
<button type="button" class="ghost-button login-desktop-save" data-relogin-unique-id="">保存当前登录账号 ↗</button>
|
||||
</div>
|
||||
<p class="muted compact">登录完成后,再点击“保存当前登录账号”,把当前浏览器中的账号写入后台。</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="login-box">
|
||||
<div class="stack-form">
|
||||
<strong>交互式浏览器窗口(远端浏览器工作区)</strong>
|
||||
<div class="url-field">
|
||||
<input id="desktop-public-url" type="text" value="{{ login_desktop_public_url }}" readonly>
|
||||
<button type="button" class="ghost-button url-copy" id="copy-public-url">⧉</button>
|
||||
</div>
|
||||
<div class="desktop-frame-wrap">
|
||||
<iframe class="desktop-frame" src="{{ login_desktop_public_url }}" title="交互式登录浏览器工作区" loading="lazy"></iframe>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="login-box highlight"
|
||||
id="login-desktop-controls"
|
||||
data-public-url="{{ login_desktop_public_url }}"
|
||||
data-csrf-token="{{ csrf_token }}">
|
||||
<div class="status-line">
|
||||
<h3>交互式登录浏览器</h3>
|
||||
<span class="pill soft">推荐</span>
|
||||
</div>
|
||||
<div class="status-list">
|
||||
<div class="status-item" id="desktop-status-checking">
|
||||
<span class="status-dot"></span>
|
||||
<span>检查中</span>
|
||||
</div>
|
||||
<div class="status-item pending" id="desktop-status-pending">
|
||||
<span class="status-dot"></span>
|
||||
<span>待登录</span>
|
||||
</div>
|
||||
<div class="status-item success" id="desktop-status-success">
|
||||
<span class="status-dot"></span>
|
||||
<span>已登录</span>
|
||||
</div>
|
||||
<div class="status-item error" id="desktop-status-error">
|
||||
<span class="status-dot"></span>
|
||||
<span>异常</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="info-card">
|
||||
<div class="status-line" style="margin-bottom: 8px;">
|
||||
<span class="pill" id="login-desktop-runtime-state">检查中</span>
|
||||
</div>
|
||||
<div id="login-desktop-status-text">正在检查交互式登录桌面状态。</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="panel" id="account-management">
|
||||
<div class="section-title-row">
|
||||
<div>
|
||||
<h2>账号管理({{ accounts|length }})</h2>
|
||||
<p class="muted compact">多账号、目标好友、启停状态、交互式登录同步都在这里集中管理。</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if accounts %}
|
||||
<div class="account-list">
|
||||
{% for account in accounts %}
|
||||
<article class="account-card">
|
||||
<div class="account-head">
|
||||
<div class="account-ident">
|
||||
<div class="avatar-photo">{{ account.username[:1] if account.username else "账" }}</div>
|
||||
<div>
|
||||
<div class="account-name">{{ account.username }}</div>
|
||||
<div class="account-sub">unique_id: {{ account.unique_id }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<span class="pill {% if account.enabled|default(true) %}soft{% else %}warning{% endif %}">
|
||||
{% if account.enabled|default(true) %}已启用{% else %}已停用{% endif %}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="metric-row">
|
||||
<div class="metric-box">
|
||||
<span class="label">Cookies</span>
|
||||
<strong>{{ account.cookies|length }}</strong>
|
||||
</div>
|
||||
<div class="metric-box">
|
||||
<span class="label">目标好友</span>
|
||||
<strong>{{ account.targets|length }}</strong>
|
||||
</div>
|
||||
<div class="metric-box">
|
||||
<span class="label">好友缓存</span>
|
||||
<strong>{{ account.friends_cache|default([], true)|length }}</strong>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form method="post" action="/accounts/{{ account.unique_id }}/update" class="stack-form">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||
<label>
|
||||
<span>显示名</span>
|
||||
<input type="text" name="username" value="{{ account.username }}">
|
||||
</label>
|
||||
<label class="check-row">
|
||||
<input type="checkbox" name="enabled" {% if account.enabled|default(true) %}checked{% endif %}>
|
||||
<span>启用自动续火花</span>
|
||||
</label>
|
||||
<label>
|
||||
<span>手动目标好友(每行一个)</span>
|
||||
<textarea name="targets" rows="5">{{ account.targets|default([], true)|join('\n') }}</textarea>
|
||||
</label>
|
||||
<p class="muted compact">好友太多刷不出来时,直接在这里填写目标昵称并保存。</p>
|
||||
|
||||
<div class="friend-picker"
|
||||
data-account-id="{{ account.unique_id }}"
|
||||
data-refresh-url="/accounts/{{ account.unique_id }}/friends/refresh"
|
||||
data-csrf-token="{{ csrf_token }}"
|
||||
data-updated-at="{{ account.friends_cache_updated_at|default('', true) }}">
|
||||
<div class="friend-picker-toolbar">
|
||||
<div>
|
||||
<span class="friend-picker-title">好友选择器</span>
|
||||
<p class="muted compact friend-picker-status">
|
||||
{% if account.friends_cache_updated_at %}
|
||||
上次刷新:{{ account.friends_cache_updated_at }}
|
||||
{% else %}
|
||||
还没有读取好友列表
|
||||
{% endif %}
|
||||
</p>
|
||||
</div>
|
||||
<button type="button" class="ghost-button friend-refresh-button">刷新好友列表</button>
|
||||
</div>
|
||||
|
||||
<label class="friend-search">
|
||||
<span>搜索好友</span>
|
||||
<input type="search" class="friend-search-input" placeholder="输入昵称筛选好友">
|
||||
</label>
|
||||
|
||||
<p class="friend-picker-current-targets muted compact">
|
||||
<strong>当前目标好友:</strong>
|
||||
<span>{{ account.targets|join('、') if account.targets else '未选择' }}</span>
|
||||
</p>
|
||||
|
||||
<p class="friend-picker-summary muted compact">已选 0 人</p>
|
||||
<div class="friend-selected-inputs"></div>
|
||||
<div class="friend-picker-list"></div>
|
||||
|
||||
<script type="application/json" id="friends-cache-{{ account.unique_id }}">{{ account.friends_cache|default([], true)|tojson }}</script>
|
||||
<script type="application/json" id="selected-targets-{{ account.unique_id }}">{{ account.targets|default([], true)|tojson }}</script>
|
||||
</div>
|
||||
|
||||
<button type="submit">保存账号</button>
|
||||
</form>
|
||||
|
||||
<div class="button-row">
|
||||
<button type="button" class="ghost-button login-desktop-open" data-relogin-unique-id="{{ account.unique_id }}" data-account-name="{{ account.username }}">打开登录浏览器</button>
|
||||
<button type="button" class="ghost-button login-desktop-save" data-relogin-unique-id="{{ account.unique_id }}" data-account-name="{{ account.username }}">保存当前浏览器登录</button>
|
||||
<form method="post" action="/accounts/{{ account.unique_id }}/toggle-enabled">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||
<button class="{% if account.enabled|default(true) %}soft-button{% else %}success-button{% endif %}" type="submit">
|
||||
{% if account.enabled|default(true) %}停用自动续火花{% else %}启用自动续火花{% endif %}
|
||||
</button>
|
||||
</form>
|
||||
<form method="post" action="/accounts/{{ account.unique_id }}/delete">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||
<button class="danger-button" type="submit">删除账号</button>
|
||||
</form>
|
||||
</div>
|
||||
</article>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="empty-state">还没有账号。先通过交互式登录浏览器登录并保存账号。</div>
|
||||
{% endif %}
|
||||
</section>
|
||||
|
||||
<div class="layout-grid" style="grid-template-columns: repeat(3, minmax(0, 1fr));">
|
||||
<section class="panel">
|
||||
<h2>容器状态</h2>
|
||||
<div class="table-shell">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Name</th>
|
||||
<th>Status</th>
|
||||
<th>Image</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for row in ops.containers %}
|
||||
<tr>
|
||||
<td>{{ row.Names }}</td>
|
||||
<td>
|
||||
<span class="pill {% if 'Up' in row.Status %}soft{% else %}warning{% endif %}">
|
||||
{{ row.Status }}
|
||||
</span>
|
||||
</td>
|
||||
<td>{{ row.Image }}</td>
|
||||
</tr>
|
||||
{% else %}
|
||||
<tr><td colspan="3">当前没有可见容器状态。</td></tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="panel">
|
||||
<h2>Cron / 调度(服务端 crontab)</h2>
|
||||
<pre class="code-block">{{ ops.crontab or "当前没有 crontab 任务。" }}</pre>
|
||||
</section>
|
||||
|
||||
<section class="panel">
|
||||
<h2>日志预览(最近 100 行)</h2>
|
||||
<pre class="code-block light tall">{{ ops.log_tail or "暂无日志" }}</pre>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<aside class="stack">
|
||||
<section class="panel" id="config-panel">
|
||||
<h2>运行配置</h2>
|
||||
<form method="post" action="/config" class="stack-form" style="margin-top: 14px;">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||
<label>
|
||||
<span>固定消息模板</span>
|
||||
<input type="text" name="messageTemplate" value="{{ runtime_config.messageTemplate }}">
|
||||
</label>
|
||||
<label>
|
||||
<span>消息变体(每行一个)</span>
|
||||
<textarea name="messageVariants" rows="5">{{ runtime_config.sendStrategy.messageVariants|join('\n') }}</textarea>
|
||||
</label>
|
||||
<label>
|
||||
<span>是否随机打乱目标顺序</span>
|
||||
<select name="shuffleTargets">
|
||||
<option value="on" {% if runtime_config.sendStrategy.shuffleTargets %}selected{% endif %}>是</option>
|
||||
<option value="" {% if not runtime_config.sendStrategy.shuffleTargets %}selected{% endif %}>否</option>
|
||||
</select>
|
||||
</label>
|
||||
<div class="settings-grid">
|
||||
<label>
|
||||
<span>两条消息随机间隔(最小)</span>
|
||||
<input type="number" name="messageIntervalSecondsMin" min="0" value="{{ runtime_config.sendStrategy.messageIntervalSecondsMin }}">
|
||||
</label>
|
||||
<label>
|
||||
<span>两条消息随机间隔(最大)</span>
|
||||
<input type="number" name="messageIntervalSecondsMax" min="0" value="{{ runtime_config.sendStrategy.messageIntervalSecondsMax }}">
|
||||
</label>
|
||||
</div>
|
||||
<button type="submit">保存运行配置</button>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<section class="panel" id="ops-panel">
|
||||
<h2>运维操作</h2>
|
||||
<div class="button-grid" style="margin-top: 14px;">
|
||||
<form method="post" action="/ops/run-now">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||
<button type="submit">立刻运行一次</button>
|
||||
</form>
|
||||
<form method="post" action="/ops/proxy/refresh">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||
<button class="ghost-button" type="submit">刷新代理订阅</button>
|
||||
</form>
|
||||
<form method="post" action="/ops/proxy/restart">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||
<button class="danger-button" type="submit">重启代理容器</button>
|
||||
</form>
|
||||
<a class="link-button" href="/ops/logs">查看详细日志</a>
|
||||
</div>
|
||||
|
||||
<form method="post" action="/ops/schedule" class="stack-form" style="margin-top: 16px;">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||
<label>
|
||||
<span>发送窗口(北京时间,例如 10:00-18:00/10m)</span>
|
||||
<input type="text" name="daily_schedule" value="{{ ops.daily_schedule }}" placeholder="10:00-18:00/10m">
|
||||
</label>
|
||||
<button type="submit" class="ghost-button">更新发送窗口</button>
|
||||
</form>
|
||||
|
||||
<div class="stack-form" style="margin-top: 16px;">
|
||||
<label>
|
||||
<span>Compose 根目录</span>
|
||||
<input type="text" value="{{ ops.compose_root }}" readonly>
|
||||
</label>
|
||||
<label>
|
||||
<span>Compose 文件路径</span>
|
||||
<input type="text" value="{{ ops.compose_file or '未检测到' }}" readonly>
|
||||
</label>
|
||||
</div>
|
||||
</section>
|
||||
</aside>
|
||||
</div>
|
||||
|
||||
<section class="panel" id="settings-panel" style="margin-top: 20px;">
|
||||
<div class="panel-header">
|
||||
<div>
|
||||
<h2>面板与服务器设置</h2>
|
||||
<p class="muted compact">服务器参数、交互式登录桌面 API 地址、日志路径等基础设置。</p>
|
||||
</div>
|
||||
</div>
|
||||
<form method="post" action="/settings" class="stack-form">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||
<div class="settings-grid">
|
||||
<label>
|
||||
<span>服务器 Host</span>
|
||||
<input type="text" name="server_host" value="{{ app_settings.server_host }}">
|
||||
</label>
|
||||
<label>
|
||||
<span>服务器用户名</span>
|
||||
<input type="text" name="server_username" value="{{ app_settings.server_username }}">
|
||||
</label>
|
||||
<label>
|
||||
<span>服务器密码</span>
|
||||
<input type="password" name="server_password" value="{{ app_settings.server_password }}">
|
||||
</label>
|
||||
<label>
|
||||
<span>Compose 根目录</span>
|
||||
<input type="text" name="compose_root" value="{{ app_settings.compose_root }}">
|
||||
</label>
|
||||
<label>
|
||||
<span>任务日志文件</span>
|
||||
<input type="text" name="ops_log_file" value="{{ app_settings.ops_log_file }}">
|
||||
</label>
|
||||
<label>
|
||||
<span>代理刷新脚本</span>
|
||||
<input type="text" name="proxy_refresh_script" value="{{ app_settings.proxy_refresh_script }}">
|
||||
</label>
|
||||
<label>
|
||||
<span>交互式登录桌面 API 地址</span>
|
||||
<input type="text" name="login_desktop_api_url" value="{{ app_settings.login_desktop_api_url }}">
|
||||
</label>
|
||||
<label>
|
||||
<span>Web UI 端口</span>
|
||||
<input type="number" min="1" max="65535" name="ui_port" value="{{ app_settings.ui_port }}">
|
||||
</label>
|
||||
<label>
|
||||
<span>修改管理员密码</span>
|
||||
<input type="password" name="new_password" placeholder="留空表示不修改">
|
||||
</label>
|
||||
<label>
|
||||
<span>确认新密码</span>
|
||||
<input type="password" name="confirm_password" placeholder="再次输入新密码">
|
||||
</label>
|
||||
</div>
|
||||
<div style="display:flex; justify-content:flex-end;">
|
||||
<button type="submit">保存设置</button>
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts %}
|
||||
<script>
|
||||
(() => {
|
||||
const root = document.getElementById("login-desktop-controls");
|
||||
if (!root) return;
|
||||
|
||||
const csrfToken = root.dataset.csrfToken || "";
|
||||
const publicUrl = root.dataset.publicUrl || "";
|
||||
const runtimeStateEl = document.getElementById("login-desktop-runtime-state");
|
||||
const statusTextEl = document.getElementById("login-desktop-status-text");
|
||||
const openButtons = document.querySelectorAll(".login-desktop-open");
|
||||
const saveButtons = document.querySelectorAll(".login-desktop-save");
|
||||
const resetButtons = document.querySelectorAll(".login-desktop-reset");
|
||||
const copyPublicUrlButton = document.getElementById("copy-public-url");
|
||||
const statusMap = {
|
||||
checking: document.getElementById("desktop-status-checking"),
|
||||
pending: document.getElementById("desktop-status-pending"),
|
||||
success: document.getElementById("desktop-status-success"),
|
||||
error: document.getElementById("desktop-status-error"),
|
||||
};
|
||||
|
||||
const setVisualStatus = (state) => {
|
||||
Object.values(statusMap).forEach((node) => {
|
||||
if (!node) return;
|
||||
node.style.opacity = "0.45";
|
||||
});
|
||||
if (statusMap[state]) {
|
||||
statusMap[state].style.opacity = "1";
|
||||
}
|
||||
};
|
||||
|
||||
const setStatus = (text, tone = "", state = "checking") => {
|
||||
if (statusTextEl) statusTextEl.textContent = text;
|
||||
if (runtimeStateEl) runtimeStateEl.className = `pill${tone ? ` ${tone}` : ""}`;
|
||||
setVisualStatus(state);
|
||||
};
|
||||
|
||||
const postForm = async (url, payload = {}) => {
|
||||
const formData = new FormData();
|
||||
formData.set("csrf_token", csrfToken);
|
||||
Object.entries(payload).forEach(([key, value]) => {
|
||||
formData.set(key, String(value ?? ""));
|
||||
});
|
||||
const response = await fetch(url, {
|
||||
method: "POST",
|
||||
body: formData,
|
||||
credentials: "same-origin",
|
||||
});
|
||||
const data = await response.json().catch(() => ({}));
|
||||
if (!response.ok || data.ok === false) {
|
||||
throw new Error(data.error || `request failed: ${response.status}`);
|
||||
}
|
||||
return data;
|
||||
};
|
||||
|
||||
const openDesktopWindow = () => {
|
||||
const popup = window.open(publicUrl, "_blank");
|
||||
if (!popup) {
|
||||
setStatus("浏览器拦截了交互式登录窗口,请允许弹窗后再试。", "danger", "error");
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
};
|
||||
|
||||
const pollStatus = async () => {
|
||||
try {
|
||||
const response = await fetch("/login-desktop/status", { credentials: "same-origin" });
|
||||
const data = await response.json();
|
||||
if (!response.ok || data.ok === false) {
|
||||
if (runtimeStateEl) runtimeStateEl.textContent = "不可用";
|
||||
setStatus(data.error || "交互式登录桌面不可用", "warning", "error");
|
||||
return;
|
||||
}
|
||||
if (runtimeStateEl) runtimeStateEl.textContent = data.logged_in ? "已登录" : "待登录";
|
||||
if (data.logged_in) {
|
||||
setStatus(`当前浏览器已登录:${data.username}(${data.unique_id})`, "soft", "success");
|
||||
} else {
|
||||
setStatus("正在检查交互式登录桌面状态。当前浏览器未登录,打开浏览器开始登录。", "", "pending");
|
||||
}
|
||||
} catch (error) {
|
||||
if (runtimeStateEl) runtimeStateEl.textContent = "异常";
|
||||
setStatus(`交互式登录桌面状态检查失败:${error.message}`, "danger", "error");
|
||||
}
|
||||
};
|
||||
|
||||
if (copyPublicUrlButton) {
|
||||
copyPublicUrlButton.addEventListener("click", async () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(publicUrl);
|
||||
setStatus("交互式登录地址已复制,可在新标签页打开。", "soft", "pending");
|
||||
} catch (error) {
|
||||
setStatus(`复制失败:${error.message}`, "warning", "error");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
openButtons.forEach((button) => {
|
||||
button.addEventListener("click", async () => {
|
||||
const reloginUniqueId = String(button.dataset.reloginUniqueId || "").trim();
|
||||
const accountName = String(button.dataset.accountName || "").trim();
|
||||
try {
|
||||
await postForm("/login-desktop/open");
|
||||
openDesktopWindow();
|
||||
if (reloginUniqueId) {
|
||||
setStatus(`已打开交互式登录浏览器,请使用账号 ${accountName || reloginUniqueId} 完成登录。`, "", "pending");
|
||||
} else {
|
||||
setStatus("已打开交互式登录浏览器,请在远端浏览器中完成抖音创作者中心登录。", "", "pending");
|
||||
}
|
||||
} catch (error) {
|
||||
setStatus(`打开交互式登录浏览器失败:${error.message}`, "danger", "error");
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
saveButtons.forEach((button) => {
|
||||
button.addEventListener("click", async () => {
|
||||
const reloginUniqueId = String(button.dataset.reloginUniqueId || "").trim();
|
||||
const accountName = String(button.dataset.accountName || "").trim();
|
||||
try {
|
||||
const data = await postForm("/login-desktop/save", {
|
||||
relogin_unique_id: reloginUniqueId,
|
||||
});
|
||||
setStatus(
|
||||
reloginUniqueId
|
||||
? `已把当前浏览器登录保存到账号:${accountName || reloginUniqueId}`
|
||||
: `已保存当前登录账号:${data.account?.username || ""}`,
|
||||
"soft",
|
||||
"success",
|
||||
);
|
||||
window.setTimeout(() => window.location.reload(), 800);
|
||||
} catch (error) {
|
||||
setStatus(`保存当前登录账号失败:${error.message}`, "danger", "error");
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
resetButtons.forEach((button) => {
|
||||
button.addEventListener("click", async () => {
|
||||
try {
|
||||
await postForm("/login-desktop/reset");
|
||||
setStatus("交互式登录桌面已重置,正在重新初始化浏览器…", "warning", "checking");
|
||||
await pollStatus();
|
||||
} catch (error) {
|
||||
setStatus(`重置交互式登录桌面失败:${error.message}`, "danger", "error");
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
setVisualStatus("checking");
|
||||
pollStatus();
|
||||
window.setInterval(pollStatus, 5000);
|
||||
})();
|
||||
|
||||
(() => {
|
||||
const pickers = document.querySelectorAll(".friend-picker");
|
||||
if (!pickers.length) return;
|
||||
|
||||
const parseJsonScript = (id) => {
|
||||
const el = document.getElementById(id);
|
||||
if (!el) return [];
|
||||
try {
|
||||
return JSON.parse(el.textContent || "[]");
|
||||
} catch (error) {
|
||||
console.error("Failed to parse friend picker JSON", id, error);
|
||||
return [];
|
||||
}
|
||||
};
|
||||
|
||||
const escapeHtml = (value) =>
|
||||
value
|
||||
.replaceAll("&", "&")
|
||||
.replaceAll("<", "<")
|
||||
.replaceAll(">", ">")
|
||||
.replaceAll('"', """)
|
||||
.replaceAll("'", "'");
|
||||
|
||||
pickers.forEach((picker) => {
|
||||
const accountId = picker.dataset.accountId;
|
||||
const refreshUrl = picker.dataset.refreshUrl;
|
||||
const csrfToken = picker.dataset.csrfToken;
|
||||
const searchInput = picker.querySelector(".friend-search-input");
|
||||
const refreshButton = picker.querySelector(".friend-refresh-button");
|
||||
const listEl = picker.querySelector(".friend-picker-list");
|
||||
const summaryEl = picker.querySelector(".friend-picker-summary");
|
||||
const statusEl = picker.querySelector(".friend-picker-status");
|
||||
const hiddenInputsEl = picker.querySelector(".friend-selected-inputs");
|
||||
|
||||
let friends = parseJsonScript(`friends-cache-${accountId}`);
|
||||
let selected = new Set(parseJsonScript(`selected-targets-${accountId}`));
|
||||
|
||||
const combinedFriends = () => {
|
||||
const merged = [];
|
||||
const seen = new Set();
|
||||
[...selected, ...friends].forEach((name) => {
|
||||
if (!name || seen.has(name)) return;
|
||||
seen.add(name);
|
||||
merged.push(name);
|
||||
});
|
||||
return merged;
|
||||
};
|
||||
|
||||
const renderHiddenInputs = () => {
|
||||
hiddenInputsEl.innerHTML = "";
|
||||
[...selected].forEach((name) => {
|
||||
const input = document.createElement("input");
|
||||
input.type = "hidden";
|
||||
input.name = "targets";
|
||||
input.value = name;
|
||||
hiddenInputsEl.appendChild(input);
|
||||
});
|
||||
};
|
||||
|
||||
const updateSummary = () => {
|
||||
summaryEl.textContent = `已选 ${selected.size} 人`;
|
||||
};
|
||||
|
||||
const renderList = () => {
|
||||
const query = (searchInput.value || "").trim().toLowerCase();
|
||||
const displayNames = combinedFriends().filter((name) => name.toLowerCase().includes(query));
|
||||
renderHiddenInputs();
|
||||
updateSummary();
|
||||
|
||||
if (!combinedFriends().length) {
|
||||
listEl.innerHTML = '<div class="friend-picker-empty">点击“刷新好友列表”后再勾选目标好友。</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
if (!displayNames.length) {
|
||||
listEl.innerHTML = '<div class="friend-picker-empty">没有匹配的好友。</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
listEl.innerHTML = displayNames
|
||||
.map(
|
||||
(name) => `
|
||||
<label class="friend-option ${selected.has(name) ? "selected" : ""}">
|
||||
<span>${escapeHtml(name)}</span>
|
||||
<input type="checkbox" value="${escapeHtml(name)}" ${selected.has(name) ? "checked" : ""}>
|
||||
</label>
|
||||
`
|
||||
)
|
||||
.join("");
|
||||
|
||||
listEl.querySelectorAll('input[type="checkbox"]').forEach((checkbox) => {
|
||||
checkbox.addEventListener("change", () => {
|
||||
const option = checkbox.closest(".friend-option");
|
||||
const value = checkbox.value;
|
||||
if (checkbox.checked) {
|
||||
selected.add(value);
|
||||
option?.classList.add("selected");
|
||||
} else {
|
||||
selected.delete(value);
|
||||
option?.classList.remove("selected");
|
||||
}
|
||||
renderHiddenInputs();
|
||||
updateSummary();
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
refreshButton.addEventListener("click", async () => {
|
||||
refreshButton.disabled = true;
|
||||
const originalText = refreshButton.textContent;
|
||||
refreshButton.textContent = "刷新中...";
|
||||
statusEl.textContent = "正在实时读取好友列表…";
|
||||
try {
|
||||
const formData = new FormData();
|
||||
formData.set("csrf_token", csrfToken);
|
||||
const response = await fetch(refreshUrl, {
|
||||
method: "POST",
|
||||
body: formData,
|
||||
credentials: "same-origin",
|
||||
});
|
||||
const payload = await response.json();
|
||||
if (!response.ok) {
|
||||
throw new Error(payload.error || "刷新好友列表失败");
|
||||
}
|
||||
friends = Array.isArray(payload.friends) ? payload.friends : [];
|
||||
statusEl.textContent = payload.message || `已刷新 ${friends.length} 个好友`;
|
||||
renderList();
|
||||
} catch (error) {
|
||||
statusEl.textContent = error.message || "刷新好友列表失败";
|
||||
} finally {
|
||||
refreshButton.disabled = false;
|
||||
refreshButton.textContent = originalText;
|
||||
}
|
||||
});
|
||||
|
||||
searchInput.addEventListener("input", renderList);
|
||||
renderList();
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
{% endblock %}
|
||||
117
DouYinSparkFlow/webui/templates/login.html
Normal file
@@ -0,0 +1,117 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>登录 | 抖音多账号续火花控制台</title>
|
||||
<style>
|
||||
body {
|
||||
margin: 0;
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-family: "Segoe UI", "PingFang SC", "Microsoft YaHei", sans-serif;
|
||||
background:
|
||||
radial-gradient(circle at top right, rgba(45, 107, 255, 0.12), transparent 28%),
|
||||
radial-gradient(circle at bottom left, rgba(43, 162, 76, 0.1), transparent 20%),
|
||||
#f4f7fb;
|
||||
color: #162033;
|
||||
}
|
||||
.card {
|
||||
width: min(92vw, 420px);
|
||||
background: #fff;
|
||||
border: 1px solid #dfe6f1;
|
||||
border-radius: 8px;
|
||||
padding: 28px;
|
||||
box-shadow: 0 16px 38px rgba(17, 35, 68, 0.12);
|
||||
}
|
||||
h1 {
|
||||
margin: 0 0 10px;
|
||||
font-size: 28px;
|
||||
}
|
||||
p {
|
||||
margin: 0 0 20px;
|
||||
color: #66748f;
|
||||
line-height: 1.6;
|
||||
}
|
||||
form {
|
||||
display: grid;
|
||||
gap: 14px;
|
||||
}
|
||||
label {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
}
|
||||
span {
|
||||
font-size: 13px;
|
||||
color: #66748f;
|
||||
}
|
||||
input {
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
border: 1px solid #cfd9ea;
|
||||
border-radius: 6px;
|
||||
padding: 12px 14px;
|
||||
}
|
||||
button {
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
padding: 12px 14px;
|
||||
background: linear-gradient(180deg, #3677ff, #2d6bff);
|
||||
color: #fff;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
}
|
||||
.flash {
|
||||
margin-bottom: 16px;
|
||||
padding: 12px 14px;
|
||||
border-radius: 6px;
|
||||
font-size: 13px;
|
||||
}
|
||||
.flash.success { background: #edf9f0; color: #13632b; }
|
||||
.flash.warning { background: #fff5e8; color: #915700; }
|
||||
.flash.error { background: #fff0f0; color: #8d2e2e; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="card">
|
||||
<h1>控制台登录</h1>
|
||||
<p>用于管理抖音多账号、目标好友、自动续火花任务与运维配置。</p>
|
||||
|
||||
{% if flash %}
|
||||
<div class="flash {{ flash.level }}">{{ flash.message }}</div>
|
||||
{% endif %}
|
||||
|
||||
{% if not bootstrapped %}
|
||||
<form method="post" action="/bootstrap">
|
||||
<label>
|
||||
<span>管理员用户名</span>
|
||||
<input type="text" name="username" value="admin">
|
||||
</label>
|
||||
<label>
|
||||
<span>管理员密码</span>
|
||||
<input type="password" name="password">
|
||||
</label>
|
||||
<label>
|
||||
<span>确认密码</span>
|
||||
<input type="password" name="confirm_password">
|
||||
</label>
|
||||
<button type="submit">初始化管理员账号</button>
|
||||
</form>
|
||||
{% else %}
|
||||
<form method="post" action="/login">
|
||||
<label>
|
||||
<span>管理员用户名</span>
|
||||
<input type="text" name="username" value="admin">
|
||||
</label>
|
||||
<label>
|
||||
<span>管理员密码</span>
|
||||
<input type="password" name="password">
|
||||
</label>
|
||||
<button type="submit">登录控制台</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
17
DouYinSparkFlow/webui/templates/logs.html
Normal file
@@ -0,0 +1,17 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}运行日志 | 抖音多账号续火花控制台{% endblock %}
|
||||
{% block page_title %}运行日志{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<section class="panel">
|
||||
<div class="panel-header">
|
||||
<div>
|
||||
<h2>详细日志</h2>
|
||||
<p class="muted compact">展示最近的任务输出,便于检查登录同步、发送过程和容器状态。</p>
|
||||
</div>
|
||||
<a class="link-button" href="/">返回控制台</a>
|
||||
</div>
|
||||
<pre class="code-block tall">{{ log_tail or "暂无日志" }}</pre>
|
||||
</section>
|
||||
{% endblock %}
|
||||
42
README.md
@@ -1 +1,41 @@
|
||||
# douyin-sparkflow
|
||||
# douyin-sparkflow
|
||||
|
||||
这个仓库是一个围绕 `DouYinSparkFlow/` 组织的部署仓库,用于保存核心应用源码、代理配置和容器编排文件,方便在本地或服务器上统一维护。当前整理目标是“私有内用、可放入 GitHub、避免提交运行态和敏感数据”。
|
||||
|
||||
## 仓库结构
|
||||
|
||||
- `DouYinSparkFlow/`: 核心应用源码,包含 Web UI、任务调度、账号登录与消息发送逻辑。
|
||||
- `proxy/`: 代理容器配置目录,当前包含 `mihomo` 配置文件。
|
||||
- `docker-compose.yml`: 统一的容器编排入口。
|
||||
- `refresh_proxy.sh`: 代理刷新脚本。
|
||||
|
||||
## 运行方式概览
|
||||
|
||||
- 支持本地运行核心应用,也支持通过 `docker-compose.yml` 在服务器上部署。
|
||||
- Web 管理入口、交互式登录桌面和定时任务都由仓库内现有脚本与配置驱动。
|
||||
- 定时发送、账号状态和消息模板属于运行时行为,不在本仓库中直接携带账号数据。
|
||||
|
||||
## 配置与敏感文件
|
||||
|
||||
以下内容不会进入当前 Git 仓库,需要在实际部署环境中自行补齐:
|
||||
|
||||
- `.env`
|
||||
- `state/`
|
||||
- `logs/`
|
||||
- `DouYinSparkFlow/usersData.json`
|
||||
- `DouYinSparkFlow/webui_settings.json`
|
||||
- `DouYinSparkFlow/.im_sdk_cache/`
|
||||
|
||||
如果需要复现运行环境,建议在目标机器上重新生成这些文件,而不是从仓库恢复。
|
||||
|
||||
## 使用边界
|
||||
|
||||
- 本仓库按内部项目资料整理,主要用于源码管理、部署维护和环境迁移。
|
||||
- 使用者需要自行评估平台规则、账号风险和运行后果。
|
||||
- 不建议把真实账号数据、浏览器登录态、日志或运行缓存提交到仓库。
|
||||
|
||||
## 许可证
|
||||
|
||||
核心应用当前采用 MIT 协议,许可证文件位于 [DouYinSparkFlow/LICENSE](DouYinSparkFlow/LICENSE)。
|
||||
|
||||
如需查看源码级说明,请优先阅读 [DouYinSparkFlow/README.md](DouYinSparkFlow/README.md)。
|
||||
|
||||
84
docker-compose.yml
Normal file
@@ -0,0 +1,84 @@
|
||||
services:
|
||||
proxy:
|
||||
image: metacubex/mihomo:latest
|
||||
container_name: mihomo
|
||||
restart: unless-stopped
|
||||
logging:
|
||||
driver: json-file
|
||||
options:
|
||||
max-size: "20m"
|
||||
max-file: "3"
|
||||
ports:
|
||||
- "7890:7890"
|
||||
- "9090:9090"
|
||||
volumes:
|
||||
- ./proxy/config.yaml:/root/.config/mihomo/config.yaml
|
||||
|
||||
web:
|
||||
build:
|
||||
context: ./DouYinSparkFlow
|
||||
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
|
||||
- login-desktop
|
||||
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,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:
|
||||
- "8787:8787"
|
||||
command: python main.py --web --host 0.0.0.0 --port 8787
|
||||
volumes:
|
||||
- ./DouYinSparkFlow:/app
|
||||
- ./DouYinSparkFlow/logs:/app/logs
|
||||
- /var/run/docker.sock:/var/run/docker.sock
|
||||
- /opt/douyin-sparkflow:/opt/douyin-sparkflow
|
||||
- /var/spool/cron/root:/var/spool/cron/crontabs/root
|
||||
|
||||
login-desktop:
|
||||
image: douyin-sparkflow:local
|
||||
container_name: login-desktop
|
||||
restart: unless-stopped
|
||||
depends_on:
|
||||
- proxy
|
||||
environment:
|
||||
TZ: Asia/Shanghai
|
||||
DISPLAY: :99
|
||||
HTTP_PROXY: http://proxy:7890
|
||||
HTTPS_PROXY: http://proxy:7890
|
||||
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:
|
||||
- "8788:6080"
|
||||
command: bash /app/scripts/start_login_desktop.sh
|
||||
volumes:
|
||||
- ./DouYinSparkFlow:/app
|
||||
- ./DouYinSparkFlow/logs:/app/logs
|
||||
- ./state/login-profile:/data/login-profile
|
||||
|
||||
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:
|
||||
- ./DouYinSparkFlow:/app
|
||||
- ./DouYinSparkFlow/logs:/app/logs
|
||||
restart: "no"
|
||||
9
refresh_proxy.sh
Normal file
@@ -0,0 +1,9 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
APP_ROOT=/opt/douyin-sparkflow
|
||||
SUB_URL='https://liangxin.xyz/api/v1/liangxin?OwO=6981c5a8452d44e8521c78f9f7bf1eea'
|
||||
curl -fsSL -A 'clash-verge/1.7.7' "$SUB_URL" -o "$APP_ROOT/proxy/config.yaml"
|
||||
if grep -q '^allow-lan:' "$APP_ROOT/proxy/config.yaml"; then sed -i 's/^allow-lan:.*/allow-lan: true/' "$APP_ROOT/proxy/config.yaml"; else echo 'allow-lan: true' >> "$APP_ROOT/proxy/config.yaml"; fi
|
||||
if grep -q '^bind-address:' "$APP_ROOT/proxy/config.yaml"; then sed -i "s#^bind-address:.*#bind-address: '*'#" "$APP_ROOT/proxy/config.yaml"; else echo "bind-address: '*'" >> "$APP_ROOT/proxy/config.yaml"; fi
|
||||
if grep -q '^external-controller:' "$APP_ROOT/proxy/config.yaml"; then sed -i "s#^external-controller:.*#external-controller: '0.0.0.0:9090'#" "$APP_ROOT/proxy/config.yaml"; else echo "external-controller: '0.0.0.0:9090'" >> "$APP_ROOT/proxy/config.yaml"; fi
|
||||
docker compose -f "$APP_ROOT/docker-compose.yml" restart proxy
|
||||