Import sanitized project structure and GitHub docs

This commit is contained in:
Rixuan Shao
2026-05-17 18:28:25 +08:00
parent d7e6bb1628
commit de4f3aeb74
87 changed files with 16696 additions and 1 deletions

14
.gitignore vendored Normal file
View 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

View 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
View File

@@ -0,0 +1,8 @@
.venv/
__pycache__/
.vscode/
chrome/
logs/
.DS_Store
usersData.json
webui_settings.json

View 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
View 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
View 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)。

View 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
}

View File

View 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

View 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()

View 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())

View 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

View 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]

View 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);
});

View 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

View 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"

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 121 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 77 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 144 KiB

View 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
View 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()

View 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())

View 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

View 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

View 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

View File

@@ -0,0 +1 @@
[]

View File

View 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)

View 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)

View 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]")

View 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] 无法获取一言内容"

View 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("这是一个严重错误信息")

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@@ -0,0 +1 @@
# Web admin package for DouYin Spark Flow.

View 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,
)

View 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"

File diff suppressed because it is too large Load Diff

View 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(),
}

View File

@@ -0,0 +1 @@

Binary file not shown.

View 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` 中,便于追踪本次运行结果

File diff suppressed because it is too large Load Diff

View 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.`
);
}
}

View File

@@ -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 };
}

View File

@@ -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

View File

@@ -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

View 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);
}
}

View File

@@ -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 };
}

View File

@@ -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 "完成帐户创建"');
}
}

View 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();
}

View File

@@ -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);
}

View 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 };
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 327 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 KiB

View 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"
}
}

View File

@@ -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);

View File

@@ -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);

View File

@@ -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);

View File

@@ -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);

View File

@@ -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;
}
}

View File

@@ -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>

View File

@@ -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">&times;</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();
});

View File

@@ -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',
});
});

View File

@@ -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);
});

View File

@@ -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
);
});

View File

@@ -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',
});
});

View 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%;
}
}

File diff suppressed because it is too large Load Diff

View 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("&", "&amp;")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll('"', "&quot;")
.replaceAll("'", "&#39;");
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 %}

View 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>

View 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 %}

View File

@@ -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
View 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
View 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