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
+1
View File
@@ -0,0 +1 @@
# Web admin package for DouYin Spark Flow.
+646
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,
)
+73
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
+407
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(),
}
+1
View File
@@ -0,0 +1 @@
Binary file not shown.
@@ -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
@@ -0,0 +1,531 @@
// content/cloudflare-temp-email.js — Content script for Cloudflare Temp Email admin page
const CLOUDFLARE_TEMP_EMAIL_PREFIX = '[MultiPage:cloudflare-temp-email]';
const isTopFrame = window === window.top;
const {
combineDistinctTextParts = (parts = []) => parts
.map((part) => String(part || '').replace(/\s+/g, ' ').trim())
.filter(Boolean)
.filter((part, index, values) => values.indexOf(part) === index)
.join(' '),
extractVerificationCode = () => null,
generateReadableLocalPart = () => `mp${Date.now().toString(36)}${Math.random().toString(36).slice(2, 6)}`.slice(0, 18),
normalizeDomainSuffix = (value) => String(value || '').trim().replace(/^@+/, '').toLowerCase(),
parseCloudflareMailboxCredential = () => null,
pickRandomSuffix = (options = []) => normalizeDomainSuffix(options[0] || ''),
selectVerificationMessage = () => null,
} = globalThis.MultiPageCloudflareTempEmail || {};
console.log(CLOUDFLARE_TEMP_EMAIL_PREFIX, 'Content script loaded on', location.href, 'frame:', isTopFrame ? 'top' : 'child');
if (!isTopFrame) {
console.log(CLOUDFLARE_TEMP_EMAIL_PREFIX, 'Skipping child frame');
} else {
chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
if (message.type !== 'CREATE_CLOUDFLARE_TEMP_EMAIL' && message.type !== 'POLL_EMAIL') {
return;
}
resetStopState();
const handler = message.type === 'CREATE_CLOUDFLARE_TEMP_EMAIL'
? createCloudflareTempEmail
: pollCloudflareTempEmail;
handler(message.step, message.payload || {}).then((result) => {
sendResponse(result);
}).catch((err) => {
if (isStopError(err)) {
if (message.step) {
log(`Step ${message.step}: Stopped by user.`, 'warn');
} else {
log('Cloudflare Temp Email: Stopped by user.', 'warn');
}
sendResponse({ stopped: true, error: err.message });
return;
}
if (message.step) {
reportError(message.step, err.message);
}
sendResponse({ error: err.message });
});
return true;
});
function getElementText(el) {
return combineDistinctTextParts([
el?.innerText,
el?.textContent,
el?.getAttribute?.('aria-label'),
el?.getAttribute?.('title'),
el?.value,
]);
}
function normalizeText(value) {
return String(value || '').replace(/\s+/g, ' ').trim();
}
function normalizeEmail(value) {
return normalizeText(value).toLowerCase();
}
function isVisible(el) {
if (!el) return false;
if (el.hidden) return false;
const style = window.getComputedStyle(el);
if (style.display === 'none' || style.visibility === 'hidden' || style.opacity === '0') {
return false;
}
return Boolean(el.offsetWidth || el.offsetHeight || el.getClientRects().length);
}
function findVisibleButton(pattern) {
return Array.from(document.querySelectorAll('button'))
.filter(isVisible)
.find((button) => pattern.test(normalizeText(getElementText(button))));
}
function findVisibleTab(pattern, occurrence = 'first') {
const tabs = Array.from(document.querySelectorAll('.n-tabs-tab'))
.filter(isVisible)
.filter((tab) => pattern.test(normalizeText(getElementText(tab))));
return occurrence === 'last' ? tabs[tabs.length - 1] || null : tabs[0] || null;
}
async function waitForCondition(predicate, timeout, message) {
const startedAt = Date.now();
while (Date.now() - startedAt < timeout) {
throwIfStopped();
const value = predicate();
if (value) {
return value;
}
await sleep(150);
}
throw new Error(message);
}
async function clickTab(pattern, occurrence = 'first', timeout = 10000) {
await waitForCondition(
() => findVisibleTab(pattern, occurrence),
timeout,
`Timed out waiting for tab ${pattern}`
);
const tab = findVisibleTab(pattern, occurrence);
if (!tab) {
throw new Error(`Could not find tab ${pattern}`);
}
await humanPause(120, 260);
simulateClick(tab);
await sleep(400);
return tab;
}
function getCreateAddressInput() {
return Array.from(document.querySelectorAll('input[placeholder="请输入"]')).find(isVisible) || null;
}
function getPrefixSwitch() {
return Array.from(document.querySelectorAll('[role="switch"], .n-switch')).find(isVisible) || null;
}
function getCreateInputGroup() {
return getCreateAddressInput()?.closest('.n-input-group') || null;
}
function getCreateDomainSelect() {
const group = getCreateInputGroup();
return Array.from(group?.querySelectorAll('.n-base-selection') || []).find(isVisible)
|| Array.from(group?.querySelectorAll('.n-select') || []).find(isVisible)
|| null;
}
function getCurrentDomain() {
const groupText = normalizeText(getCreateInputGroup()?.textContent || '');
const match = groupText.match(/@([a-z0-9.-]+\.[a-z]{2,})/i);
return normalizeDomainSuffix(match ? match[1] : '');
}
function getVisibleDomainOptionEntries() {
const seen = new Set();
const entries = [];
for (const option of Array.from(document.querySelectorAll('.n-base-select-option, [role="option"]'))) {
if (!isVisible(option)) {
continue;
}
const value = normalizeDomainSuffix(getElementText(option));
if (!value || seen.has(value)) {
continue;
}
seen.add(value);
entries.push({ option, value });
}
return entries;
}
async function ensureCreateAccountPage() {
await clickTab(/^账号$/, 'first');
await clickTab(/^创建账号$/);
await waitForCondition(
() => getCreateAddressInput(),
10000,
'Cloudflare Temp Email create form did not load.'
);
}
async function ensureMailPage() {
await clickTab(/^邮件$/, 'first');
await clickTab(/^邮件$/, 'last');
await waitForCondition(
() => document.querySelector('input[placeholder="留空查询所有地址"]'),
10000,
'Cloudflare Temp Email mail page did not load.'
);
}
async function ensurePrefixDisabled() {
const prefixSwitch = getPrefixSwitch();
if (!prefixSwitch) {
log('Cloudflare Temp Email: Prefix switch not present, assuming prefix is already disabled', 'info');
return;
}
if (prefixSwitch.getAttribute('aria-checked') === 'true') {
await humanPause(120, 280);
simulateClick(prefixSwitch);
await waitForCondition(
() => prefixSwitch.getAttribute('aria-checked') === 'false',
5000,
'Cloudflare Temp Email prefix switch did not turn off.'
);
log('Cloudflare Temp Email: Prefix disabled', 'ok');
}
}
async function selectRandomDomainSuffix() {
const domainSelect = await waitForCondition(
() => getCreateDomainSelect(),
5000,
'Could not find the Cloudflare Temp Email suffix selector.'
);
await humanPause(120, 260);
simulateClick(domainSelect);
await sleep(300);
const optionEntries = await waitForCondition(
() => {
const entries = getVisibleDomainOptionEntries();
return entries.length > 0 ? entries : null;
},
5000,
'Could not find any available Cloudflare Temp Email suffix options.'
);
const suffix = pickRandomSuffix(optionEntries.map((entry) => entry.value));
const selectedEntry = optionEntries.find((entry) => entry.value === suffix);
if (!suffix || !selectedEntry?.option) {
throw new Error('Could not choose a Cloudflare Temp Email suffix from the available options.');
}
await humanPause(120, 260);
simulateClick(selectedEntry.option);
await waitForCondition(
() => getCurrentDomain() === suffix,
5000,
`Cloudflare Temp Email suffix did not switch to ${suffix}.`
);
log(`Cloudflare Temp Email: Selected suffix ${suffix}`, 'info');
return suffix;
}
function generateLocalPart() {
return generateReadableLocalPart();
}
function findCredentialDialog() {
return Array.from(
document.querySelectorAll('[role="dialog"], .n-dialog, .n-modal, .n-base-modal, .n-card')
).find((el) => isVisible(el) && /邮箱地址凭证/.test(getElementText(el)));
}
function extractCredentialToken(root) {
if (!root) return '';
const candidates = [
getElementText(root),
...Array.from(root.querySelectorAll('textarea, input, pre, code')).map((el) => el.value || el.textContent || ''),
];
for (const candidate of candidates) {
const match = String(candidate || '').match(/[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+/);
if (match) {
return match[0];
}
}
return '';
}
async function waitForCredentialDetails(timeout = 15000) {
return waitForCondition(() => {
const dialog = findCredentialDialog();
const token = extractCredentialToken(dialog);
const credential = parseCloudflareMailboxCredential(token);
if (!credential?.email) {
return null;
}
return credential;
}, timeout, 'Timed out waiting for Cloudflare Temp Email credential dialog.');
}
async function dismissCredentialDialog() {
const dialog = findCredentialDialog();
if (!dialog) return;
const closeButton = Array.from(dialog.querySelectorAll('button, .n-base-close'))
.find((el) => isVisible(el) && (/关闭|确定|取消/.test(getElementText(el)) || el.classList?.contains('n-base-close')));
if (closeButton) {
simulateClick(closeButton);
await sleep(300);
return;
}
document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }));
await sleep(300);
}
async function createCloudflareTempEmail(step, payload = {}) {
const { generateNew = true } = payload;
await ensureCreateAccountPage();
await ensurePrefixDisabled();
await selectRandomDomainSuffix();
const input = getCreateAddressInput();
if (!input) {
throw new Error('Could not find the Cloudflare Temp Email address input.');
}
const createButton = findVisibleButton(/^创建新邮箱$/);
if (!createButton) {
throw new Error('Could not find the "创建新邮箱" button.');
}
for (let attempt = 1; attempt <= 3; attempt++) {
const localPart = generateLocalPart();
fillInput(input, localPart);
await humanPause(120, 260);
simulateClick(createButton);
log(`Cloudflare Temp Email: Creating mailbox attempt ${attempt}`, 'info');
try {
const credential = await waitForCredentialDetails(10000);
await dismissCredentialDialog();
return {
...credential,
domain: credential.domain || getCurrentDomain(),
generated: Boolean(generateNew),
};
} catch (err) {
if (attempt === 3) {
throw err;
}
log(`Cloudflare Temp Email: Mailbox attempt ${attempt} did not complete, retrying`, 'warn');
await dismissCredentialDialog().catch(() => {});
await sleep(500);
}
}
throw new Error('Cloudflare Temp Email mailbox creation did not succeed.');
}
function getMessageRows() {
return Array.from(document.querySelectorAll('.n-thing')).filter(isVisible);
}
function parseMessageRow(row) {
const rowText = row?.innerText || getElementText(row);
const subject = normalizeText(
row.querySelector('.n-thing-header__title')?.textContent
|| row.querySelector('h3, h4, h2')?.textContent
|| ''
);
const messageId = rowText.match(/ID:\s*([^\s]+)/i)?.[1] || null;
const timestampText = rowText.match(/\d{4}\/\d{1,2}\/\d{1,2}\s+\d{1,2}:\d{2}:\d{2}/)?.[0] || '';
const sender = normalizeText(rowText.match(/FROM:\s*([^\n]+)/i)?.[1] || '');
const matchedEmail = normalizeEmail(rowText.match(/TO:\s*([^\n]+)/i)?.[1] || '');
return {
combinedText: normalizeText(rowText),
emailTimestamp: null,
matchedEmail,
messageId,
row,
sender,
subject,
timestampText,
};
}
function findMessageDetailRoot() {
const deleteButton = Array.from(document.querySelectorAll('button'))
.find((button) => isVisible(button) && /^删除$/.test(normalizeText(getElementText(button))));
let current = deleteButton?.parentElement || null;
while (current && current !== document.body) {
const text = getElementText(current);
if (/FROM:/i.test(text) && /TO:/i.test(text)) {
return current;
}
current = current.parentElement;
}
return null;
}
async function openMessageRow(row, subject) {
await humanPause(80, 180);
simulateClick(row);
await waitForCondition(() => {
const detailRoot = findMessageDetailRoot();
const detailText = normalizeText(getElementText(detailRoot));
if (!detailText) return null;
if (!subject || detailText.includes(subject)) {
return detailRoot;
}
return null;
}, 4000, `Timed out opening message ${subject || ''}`.trim());
}
function buildMessageDetailText() {
return normalizeText(getElementText(findMessageDetailRoot()));
}
async function collectMessagesForTarget(targetEmail) {
const normalizedTargetEmail = normalizeEmail(targetEmail);
const rows = getMessageRows();
const messages = [];
for (const row of rows) {
const message = parseMessageRow(row);
if (!message.matchedEmail || message.matchedEmail !== normalizedTargetEmail) {
continue;
}
if (!extractVerificationCode(`${message.subject} ${message.combinedText}`)) {
await openMessageRow(row, message.subject);
message.combinedText = `${message.combinedText} ${buildMessageDetailText()}`.trim();
}
messages.push(message);
}
return messages;
}
async function runMailQuery(targetEmail) {
const queryInput = document.querySelector('input[placeholder="留空查询所有地址"]');
if (!queryInput) {
throw new Error('Could not find the admin mail query input.');
}
const queryButton = findVisibleButton(/^查询$/);
if (!queryButton) {
throw new Error('Could not find the admin mail query button.');
}
fillInput(queryInput, targetEmail);
await humanPause(80, 180);
simulateClick(queryButton);
await sleep(800);
}
async function refreshMailList() {
const refreshButton = findVisibleButton(/^刷新$/);
if (!refreshButton) {
throw new Error('Could not find the admin mail refresh button.');
}
simulateClick(refreshButton);
await sleep(1000);
}
async function pollCloudflareTempEmail(step, payload = {}) {
const {
filterAfterTimestamp = 0,
intervalMs = 3000,
maxAttempts = 20,
senderFilters = [],
subjectFilters = [],
targetEmail = '',
} = payload;
if (!targetEmail) {
throw new Error('No target email provided for Cloudflare Temp Email polling.');
}
await ensureMailPage();
await runMailQuery(targetEmail);
log(`Step ${step}: Starting Cloudflare Temp Email poll for ${targetEmail}`, 'info');
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
log(`Step ${step}: Polling Cloudflare Temp Email... attempt ${attempt}/${maxAttempts}`, 'info');
await refreshMailList();
const messages = await collectMessagesForTarget(targetEmail);
const match = selectVerificationMessage(messages, {
filterAfterTimestamp,
senderFilters,
subjectFilters,
targetEmail,
});
if (match?.code) {
log(
`Step ${step}: Code found: ${match.code} (subject: ${(match.subject || '').slice(0, 60)})`,
'ok'
);
return {
ok: true,
code: match.code,
emailTimestamp: match.emailTimestamp,
matchedEmail: match.matchedEmail,
messageId: match.messageId,
subject: match.subject,
};
}
if (attempt < maxAttempts) {
await sleep(intervalMs);
}
}
const newerSuffix = Number(filterAfterTimestamp) > 0
? ' newer than the previous verification message'
: '';
throw new Error(
`No matching verification email${newerSuffix} was found for ${targetEmail} after ${(maxAttempts * intervalMs / 1000).toFixed(0)}s.`
);
}
}
@@ -0,0 +1,74 @@
// content/duck-mail.js — Content script for DuckDuckGo Email Protection autofill settings
console.log('[MultiPage:duck-mail] Content script loaded on', location.href);
chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
if (message.type !== 'FETCH_DUCK_EMAIL') return;
resetStopState();
fetchDuckEmail(message.payload).then(result => {
sendResponse(result);
}).catch(err => {
if (isStopError(err)) {
log('Duck Mail: Stopped by user.', 'warn');
sendResponse({ stopped: true, error: err.message });
return;
}
sendResponse({ error: err.message });
});
return true;
});
async function fetchDuckEmail(payload = {}) {
const { generateNew = true } = payload;
log(`Duck Mail: ${generateNew ? 'Generating' : 'Reading'} private address...`);
await waitForElement(
'input.AutofillSettingsPanel__PrivateDuckAddressValue, button.AutofillSettingsPanel__GeneratorButton',
15000
);
const getAddressInput = () => document.querySelector('input.AutofillSettingsPanel__PrivateDuckAddressValue');
const getGeneratorButton = () => document.querySelector('button.AutofillSettingsPanel__GeneratorButton')
|| Array.from(document.querySelectorAll('button')).find(btn => /generate private duck address/i.test(btn.textContent || ''));
const readEmail = () => {
const value = getAddressInput()?.value?.trim() || '';
return value.includes('@duck.com') ? value : '';
};
const waitForEmailValue = async (previousValue = '') => {
for (let i = 0; i < 100; i++) {
const nextValue = readEmail();
if (nextValue && nextValue !== previousValue) {
return nextValue;
}
await sleep(150);
}
throw new Error('Timed out waiting for Duck address to appear.');
};
const currentEmail = readEmail();
if (currentEmail && !generateNew) {
log(`Duck Mail: Found existing address ${currentEmail}`);
return { email: currentEmail, generated: false };
}
await humanPause(500, 1300);
const generatorButton = getGeneratorButton();
if (!generatorButton) {
if (currentEmail) {
log(`Duck Mail: Reusing existing address ${currentEmail}`, 'warn');
return { email: currentEmail, generated: false };
}
throw new Error('Could not find "Generate Private Duck Address" button.');
}
generatorButton.click();
log('Duck Mail: Clicked "Generate Private Duck Address"');
const nextEmail = await waitForEmailValue(currentEmail);
log(`Duck Mail: Ready address ${nextEmail}`, 'ok');
return { email: nextEmail, generated: true };
}
@@ -0,0 +1,258 @@
// content/inbucket-mail.js — Content script for Inbucket polling (steps 4, 7)
// Injected dynamically on the configured Inbucket host
//
// Supported page:
// - /m/<mailbox>/
const INBUCKET_PREFIX = '[MultiPage:inbucket-mail]';
const isTopFrame = window === window.top;
const SEEN_MAIL_IDS_KEY = 'seenInbucketMailIds';
console.log(INBUCKET_PREFIX, 'Content script loaded on', location.href, 'frame:', isTopFrame ? 'top' : 'child');
if (!isTopFrame) {
console.log(INBUCKET_PREFIX, 'Skipping child frame');
} else {
let seenMailIds = new Set();
async function loadSeenMailIds() {
try {
const data = await chrome.storage.session.get(SEEN_MAIL_IDS_KEY);
if (Array.isArray(data[SEEN_MAIL_IDS_KEY])) {
seenMailIds = new Set(data[SEEN_MAIL_IDS_KEY]);
console.log(INBUCKET_PREFIX, `Loaded ${seenMailIds.size} previously seen mail ids`);
}
} catch (err) {
console.warn(INBUCKET_PREFIX, 'Session storage unavailable, using in-memory seen mail ids:', err?.message || err);
}
}
async function persistSeenMailIds() {
try {
await chrome.storage.session.set({ [SEEN_MAIL_IDS_KEY]: [...seenMailIds] });
} catch (err) {
console.warn(INBUCKET_PREFIX, 'Could not persist seen mail ids, continuing in-memory only:', err?.message || err);
}
}
loadSeenMailIds();
chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
if (message.type === 'POLL_EMAIL') {
resetStopState();
handlePollEmail(message.step, message.payload).then(result => {
sendResponse(result);
}).catch(err => {
if (isStopError(err)) {
log(`Step ${message.step}: Stopped by user.`, 'warn');
sendResponse({ stopped: true, error: err.message });
return;
}
reportError(message.step, err.message);
sendResponse({ error: err.message });
});
return true;
}
});
function normalizeText(value) {
return (value || '').replace(/\s+/g, ' ').trim().toLowerCase();
}
function extractVerificationCode(text) {
const matchCn = text.match(/(?:代码为|验证码[^0-9]*?)[\s:]*(\d{6})/);
if (matchCn) return matchCn[1];
const matchEn = text.match(/code[:\s]+is[:\s]+(\d{6})|code[:\s]+(\d{6})/i);
if (matchEn) return matchEn[1] || matchEn[2];
const match6 = text.match(/\b(\d{6})\b/);
if (match6) return match6[1];
return null;
}
function rowMatchesFilters(mail, senderFilters, subjectFilters, targetEmail) {
const sender = normalizeText(mail.sender);
const subject = normalizeText(mail.subject);
const mailbox = normalizeText(mail.mailbox);
const combined = normalizeText(mail.combinedText);
const targetLocal = normalizeText((targetEmail || '').split('@')[0]);
const senderMatch = senderFilters.some(f => sender.includes(f.toLowerCase()) || combined.includes(f.toLowerCase()));
const subjectMatch = subjectFilters.some(f => subject.includes(f.toLowerCase()) || combined.includes(f.toLowerCase()));
const mailboxMatch = Boolean(targetLocal) && mailbox.includes(targetLocal);
const forwardedDuck = /duckduckgo|forward(?:ed)?\s*by/i.test(mail.combinedText);
const code = extractVerificationCode(mail.combinedText);
const keywordMatch = /openai|chatgpt|verify|verification|confirm|login|验证码|代码/.test(combined);
if (mailboxMatch) return { matched: true, mailboxMatch, code };
if (senderMatch || subjectMatch) return { matched: true, mailboxMatch: false, code };
if (code && (forwardedDuck || keywordMatch)) return { matched: true, mailboxMatch: false, code };
return { matched: false, mailboxMatch: false, code };
}
function findMailboxEntries() {
return document.querySelectorAll('.message-list-entry');
}
function getMailboxEntryId(entry, index = 0) {
const explicitId = entry.getAttribute('data-id') || entry.dataset?.id || '';
if (explicitId) return explicitId;
const subject = entry.querySelector('.subject')?.textContent?.trim() || '';
const sender = entry.querySelector('.from')?.textContent?.trim() || '';
const dateText = entry.querySelector('.date')?.textContent?.trim() || '';
return `mailbox:${index}:${normalizeText(subject)}|${normalizeText(sender)}|${normalizeText(dateText)}`;
}
function parseMailboxEntry(entry, index = 0) {
const subject = entry.querySelector('.subject')?.textContent?.trim() || '';
const sender = entry.querySelector('.from')?.textContent?.trim() || '';
const dateText = entry.querySelector('.date')?.textContent?.trim() || '';
const combinedText = [subject, sender, dateText].filter(Boolean).join(' ');
return {
entry,
dateText,
sender,
mailbox: '',
subject,
unread: entry.classList.contains('unseen'),
combinedText,
mailId: getMailboxEntryId(entry, index),
};
}
function getCurrentMailboxIds() {
const ids = new Set();
Array.from(findMailboxEntries()).forEach((entry, index) => {
ids.add(getMailboxEntryId(entry, index));
});
return ids;
}
async function refreshMailbox() {
const refreshButton = document.querySelector('button[alt="Refresh Mailbox"]');
if (!refreshButton) return;
simulateClick(refreshButton);
await sleep(800);
}
async function openMailboxEntry(entry) {
simulateClick(entry);
for (let i = 0; i < 20; i++) {
if (entry.classList.contains('selected') || document.querySelector('.message-header, .message-body, .button-bar')) {
return;
}
await sleep(150);
}
}
async function deleteCurrentMailboxMessage(step) {
try {
const deleteButton = await waitForElement('.button-bar button.danger', 5000);
simulateClick(deleteButton);
log(`Step ${step}: Deleted mailbox message`, 'ok');
await sleep(1200);
} catch (err) {
log(`Step ${step}: Failed to delete mailbox message: ${err.message}`, 'warn');
}
}
async function handleMailboxPollEmail(step, payload) {
const {
senderFilters = [],
subjectFilters = [],
maxAttempts = 20,
intervalMs = 3000,
} = payload || {};
log(`Step ${step}: Starting email poll on Inbucket mailbox page (max ${maxAttempts} attempts)`);
try {
await waitForElement('.message-list, .message-list-entry', 15000);
log(`Step ${step}: Mailbox page loaded`);
} catch {
throw new Error('Inbucket mailbox page did not load. Make sure /m/<mailbox>/ is open.');
}
const existingMailIds = getCurrentMailboxIds();
log(`Step ${step}: Snapshotted ${existingMailIds.size} existing mailbox messages`);
const FALLBACK_AFTER = 3;
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
log(`Polling Inbucket mailbox... attempt ${attempt}/${maxAttempts}`);
if (attempt > 1) {
await refreshMailbox();
}
const entries = Array.from(findMailboxEntries()).map(parseMailboxEntry);
const useFallback = attempt > FALLBACK_AFTER;
const candidates = [];
for (const mail of entries) {
if (!mail.unread) continue;
if (seenMailIds.has(mail.mailId)) continue;
if (!useFallback && existingMailIds.has(mail.mailId)) continue;
const match = rowMatchesFilters(mail, senderFilters, subjectFilters, '');
if (!match.matched) continue;
candidates.push({ ...mail, code: match.code });
}
for (const mail of candidates) {
const code = mail.code || extractVerificationCode(mail.combinedText);
if (!code) continue;
await openMailboxEntry(mail.entry);
await deleteCurrentMailboxMessage(step);
seenMailIds.add(mail.mailId);
await persistSeenMailIds();
const source = existingMailIds.has(mail.mailId) ? 'fallback' : 'new';
log(
`Step ${step}: Code found: ${code} (${source}, sender: ${mail.sender || 'unknown'}, subject: ${(mail.subject || '').slice(0, 60)})`,
'ok'
);
return {
ok: true,
code,
emailTimestamp: Date.now(),
mailId: mail.mailId,
};
}
if (attempt === FALLBACK_AFTER + 1) {
log(`Step ${step}: No new mailbox messages yet, falling back to older matching messages`, 'warn');
}
if (attempt < maxAttempts) {
await sleep(intervalMs);
}
}
throw new Error(
`No matching verification email found in Inbucket mailbox after ${(maxAttempts * intervalMs / 1000).toFixed(0)}s. ` +
'Check the mailbox page manually.'
);
}
async function handlePollEmail(step, payload) {
if (!location.pathname.startsWith('/m/')) {
throw new Error('Inbucket now only supports mailbox pages like /m/<mailbox>/.');
}
return handleMailboxPollEmail(step, payload);
}
} // end of isTopFrame else block
@@ -0,0 +1,296 @@
// content/mail-163.js — Content script for 163 Mail (steps 4, 7)
// Injected on: mail.163.com
//
// DOM structure:
// Mail item: div[sign="letter"] with aria-label="你的 ChatGPT 代码为 479637 发件人 OpenAI ..."
// Sender: .nui-user (e.g., "OpenAI")
// Subject: span.da0 (e.g., "你的 ChatGPT 代码为 479637")
// Right-click menu: .nui-menu → .nui-menu-item with text "删除邮件"
const MAIL163_PREFIX = '[MultiPage:mail-163]';
const isTopFrame = window === window.top;
console.log(MAIL163_PREFIX, 'Content script loaded on', location.href, 'frame:', isTopFrame ? 'top' : 'child');
// Only operate in the top frame
if (!isTopFrame) {
console.log(MAIL163_PREFIX, 'Skipping child frame');
} else {
// Track codes we've already seen — persisted in chrome.storage.session to survive script re-injection
let seenCodes = new Set();
async function loadSeenCodes() {
try {
const data = await chrome.storage.session.get('seenCodes');
if (data.seenCodes && Array.isArray(data.seenCodes)) {
seenCodes = new Set(data.seenCodes);
console.log(MAIL163_PREFIX, `Loaded ${seenCodes.size} previously seen codes`);
}
} catch (err) {
console.warn(MAIL163_PREFIX, 'Session storage unavailable, using in-memory seen codes:', err?.message || err);
}
}
// Load previously seen codes on startup
loadSeenCodes();
async function persistSeenCodes() {
try {
await chrome.storage.session.set({ seenCodes: [...seenCodes] });
} catch (err) {
console.warn(MAIL163_PREFIX, 'Could not persist seen codes, continuing in-memory only:', err?.message || err);
}
}
// ============================================================
// Message Handler (top frame only)
// ============================================================
chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
if (message.type === 'POLL_EMAIL') {
resetStopState();
handlePollEmail(message.step, message.payload).then(result => {
sendResponse(result);
}).catch(err => {
if (isStopError(err)) {
log(`Step ${message.step}: Stopped by user.`, 'warn');
sendResponse({ stopped: true, error: err.message });
return;
}
reportError(message.step, err.message);
sendResponse({ error: err.message });
});
return true;
}
});
// ============================================================
// Find mail items
// ============================================================
function findMailItems() {
return document.querySelectorAll('div[sign="letter"]');
}
function getCurrentMailIds() {
const ids = new Set();
findMailItems().forEach(item => {
const id = item.getAttribute('id') || '';
if (id) ids.add(id);
});
return ids;
}
// ============================================================
// Email Polling
// ============================================================
async function handlePollEmail(step, payload) {
const { senderFilters, subjectFilters, maxAttempts, intervalMs } = payload;
log(`Step ${step}: Starting email poll on 163 Mail (max ${maxAttempts} attempts)`);
// Click inbox in sidebar to ensure we're in inbox view
log(`Step ${step}: Waiting for sidebar...`);
try {
const inboxLink = await waitForElement('.nui-tree-item-text[title="收件箱"]', 5000);
inboxLink.click();
log(`Step ${step}: Clicked inbox`);
} catch {
log(`Step ${step}: Inbox link not found, proceeding...`, 'warn');
}
// Wait for mail list to appear
log(`Step ${step}: Waiting for mail list...`);
let items = [];
for (let i = 0; i < 20; i++) {
items = findMailItems();
if (items.length > 0) break;
await sleep(500);
}
if (items.length === 0) {
await refreshInbox();
await sleep(2000);
items = findMailItems();
}
if (items.length === 0) {
throw new Error('163 Mail list did not load. Make sure inbox is open.');
}
log(`Step ${step}: Mail list loaded, ${items.length} items`);
// Snapshot existing mail IDs
const existingMailIds = getCurrentMailIds();
log(`Step ${step}: Snapshotted ${existingMailIds.size} existing emails`);
const FALLBACK_AFTER = 3;
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
log(`Polling 163 Mail... attempt ${attempt}/${maxAttempts}`);
if (attempt > 1) {
await refreshInbox();
await sleep(1000);
}
const allItems = findMailItems();
const useFallback = attempt > FALLBACK_AFTER;
for (const item of allItems) {
const id = item.getAttribute('id') || '';
if (!useFallback && existingMailIds.has(id)) continue;
const senderEl = item.querySelector('.nui-user');
const sender = senderEl ? senderEl.textContent.toLowerCase() : '';
const subjectEl = item.querySelector('span.da0');
const subject = subjectEl ? subjectEl.textContent : '';
const ariaLabel = (item.getAttribute('aria-label') || '').toLowerCase();
const senderMatch = senderFilters.some(f => sender.includes(f.toLowerCase()) || ariaLabel.includes(f.toLowerCase()));
const subjectMatch = subjectFilters.some(f => subject.toLowerCase().includes(f.toLowerCase()) || ariaLabel.includes(f.toLowerCase()));
if (senderMatch || subjectMatch) {
const code = extractVerificationCode(subject + ' ' + ariaLabel);
if (code && !seenCodes.has(code)) {
seenCodes.add(code);
persistSeenCodes();
const source = useFallback && existingMailIds.has(id) ? 'fallback' : 'new';
log(`Step ${step}: Code found: ${code} (${source}, subject: ${subject.slice(0, 40)})`, 'ok');
// Delete this email via right-click menu, WAIT for it to finish before returning
await deleteEmail(item, step);
// Extra wait to ensure deletion is processed
await sleep(1000);
return { ok: true, code, emailTimestamp: Date.now(), mailId: id };
} else if (code && seenCodes.has(code)) {
log(`Step ${step}: Skipping already-seen code: ${code}`, 'info');
}
}
}
if (attempt === FALLBACK_AFTER + 1) {
log(`Step ${step}: No new emails after ${FALLBACK_AFTER} attempts, falling back to first match`, 'warn');
}
if (attempt < maxAttempts) {
await sleep(intervalMs);
}
}
throw new Error(
`No new matching email found on 163 Mail after ${(maxAttempts * intervalMs / 1000).toFixed(0)}s. ` +
'Check inbox manually.'
);
}
// ============================================================
// Delete Email via Right-Click Menu
// ============================================================
async function deleteEmail(item, step) {
try {
log(`Step ${step}: Deleting email...`);
// Strategy 1: Click the trash icon inside the mail item
// Each mail item has: <b class="nui-ico nui-ico-delete" title="删除邮件" sign="trash">
// These icons appear on hover, so we trigger mouseover first
item.dispatchEvent(new MouseEvent('mouseover', { bubbles: true }));
item.dispatchEvent(new MouseEvent('mouseenter', { bubbles: true }));
await sleep(300);
const trashIcon = item.querySelector('[sign="trash"], .nui-ico-delete, [title="删除邮件"]');
if (trashIcon) {
trashIcon.click();
log(`Step ${step}: Clicked trash icon`, 'ok');
await sleep(1500);
// Check if item disappeared (confirm deletion)
const stillExists = document.getElementById(item.id);
if (!stillExists || stillExists.style.display === 'none') {
log(`Step ${step}: Email deleted successfully`);
} else {
log(`Step ${step}: Email may not have been deleted, item still visible`, 'warn');
}
return;
}
// Strategy 2: Select checkbox then click toolbar delete button
log(`Step ${step}: Trash icon not found, trying checkbox + toolbar delete...`);
const checkbox = item.querySelector('[sign="checkbox"], .nui-chk');
if (checkbox) {
checkbox.click();
await sleep(300);
// Click toolbar delete button
const toolbarBtns = document.querySelectorAll('.nui-btn .nui-btn-text');
for (const btn of toolbarBtns) {
if (btn.textContent.replace(/\s/g, '').includes('删除')) {
btn.closest('.nui-btn').click();
log(`Step ${step}: Clicked toolbar delete`, 'ok');
await sleep(1500);
return;
}
}
}
log(`Step ${step}: Could not delete email (no delete button found)`, 'warn');
} catch (err) {
log(`Step ${step}: Failed to delete email: ${err.message}`, 'warn');
}
}
// ============================================================
// Inbox Refresh
// ============================================================
async function refreshInbox() {
// Try toolbar "刷 新" button
const toolbarBtns = document.querySelectorAll('.nui-btn .nui-btn-text');
for (const btn of toolbarBtns) {
if (btn.textContent.replace(/\s/g, '') === '刷新') {
btn.closest('.nui-btn').click();
console.log(MAIL163_PREFIX, 'Clicked "刷新" button');
await sleep(800);
return;
}
}
// Fallback: click sidebar "收 信"
const shouXinBtns = document.querySelectorAll('.ra0');
for (const btn of shouXinBtns) {
if (btn.textContent.replace(/\s/g, '').includes('收信')) {
btn.click();
console.log(MAIL163_PREFIX, 'Clicked "收信" button');
await sleep(800);
return;
}
}
console.log(MAIL163_PREFIX, 'Could not find refresh button');
}
// ============================================================
// Verification Code Extraction
// ============================================================
function extractVerificationCode(text) {
const matchCn = text.match(/(?:代码为|验证码[^0-9]*?)[\s:]*(\d{6})/);
if (matchCn) return matchCn[1];
const matchEn = text.match(/code[:\s]+is[:\s]+(\d{6})|code[:\s]+(\d{6})/i);
if (matchEn) return matchEn[1] || matchEn[2];
const match6 = text.match(/\b(\d{6})\b/);
if (match6) return match6[1];
return null;
}
} // end of isTopFrame else block
@@ -0,0 +1,147 @@
// content/qq-mail.js — Content script for QQ Mail (steps 4, 7)
// Injected on: mail.qq.com, wx.mail.qq.com
// NOTE: all_frames: true
//
// Strategy for avoiding stale codes:
// 1. On poll start, snapshot all existing mail IDs as "old"
// 2. On each poll cycle, refresh inbox and look for NEW items (not in snapshot)
// 3. Only extract codes from NEW items that match sender/subject filters
// 4. Never fall back to older matching emails
const QQ_MAIL_PREFIX = '[MultiPage:qq-mail]';
const isTopFrame = window === window.top;
console.log(QQ_MAIL_PREFIX, 'Content script loaded on', location.href, 'frame:', isTopFrame ? 'top' : 'child');
// ============================================================
// Message Handler
// ============================================================
chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
if (message.type === 'POLL_EMAIL') {
if (!isTopFrame) {
sendResponse({ ok: false, reason: 'wrong-frame' });
return;
}
resetStopState();
handlePollEmail(message.step, message.payload).then(result => {
sendResponse(result);
}).catch(err => {
if (isStopError(err)) {
log(`Step ${message.step}: Stopped by user.`, 'warn');
sendResponse({ stopped: true, error: err.message });
return;
}
reportError(message.step, err.message);
sendResponse({ error: err.message });
});
return true; // async response
}
});
// ============================================================
// Get all current mail IDs from the list
// ============================================================
function getCurrentMailIds() {
const ids = new Set();
document.querySelectorAll('.mail-list-page-item[data-mailid]').forEach(item => {
ids.add(item.getAttribute('data-mailid'));
});
return ids;
}
function collectMailItems() {
return Array.from(document.querySelectorAll('.mail-list-page-item[data-mailid]')).map((item) => ({
mailId: item.getAttribute('data-mailid') || '',
sender: item.querySelector('.cmp-account-nick')?.textContent || '',
subject: item.querySelector('.mail-subject')?.textContent || '',
digest: item.querySelector('.mail-digest')?.textContent || '',
}));
}
// ============================================================
// Email Polling
// ============================================================
async function handlePollEmail(step, payload) {
const { senderFilters, subjectFilters, maxAttempts, intervalMs } = payload;
log(`Step ${step}: Starting email poll (max ${maxAttempts} attempts, every ${intervalMs / 1000}s)`);
// Wait for mail list to load
try {
await waitForElement('.mail-list-page-item', 10000);
log(`Step ${step}: Mail list loaded`);
} catch {
throw new Error('Mail list did not load. Make sure QQ Mail inbox is open.');
}
// Step 1: Snapshot existing mail IDs BEFORE we start waiting for new email
const existingMailIds = getCurrentMailIds();
log(`Step ${step}: Snapshotted ${existingMailIds.size} existing emails as "old"`);
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
log(`Polling QQ Mail... attempt ${attempt}/${maxAttempts}`);
// Refresh inbox (skip on first attempt, list is fresh)
if (attempt > 1) {
await refreshInbox();
await sleep(800);
}
const result = MultiPageQQMail.findNewQQVerificationCode(collectMailItems(), {
existingMailIds: [...existingMailIds],
senderFilters,
subjectFilters,
});
if (result) {
log(`Step ${step}: Code found: ${result.code} (${result.source}, subject: ${result.subject.slice(0, 40)})`, 'ok');
return { ok: true, code: result.code, emailTimestamp: Date.now(), mailId: result.mailId };
}
if (attempt < maxAttempts) {
await sleep(intervalMs);
}
}
throw new Error(
`No new matching email found after ${(maxAttempts * intervalMs / 1000).toFixed(0)}s. ` +
'Check QQ Mail manually. Email may be delayed or in spam folder.'
);
}
// ============================================================
// Inbox Refresh
// ============================================================
async function refreshInbox() {
// Try multiple strategies to refresh the mail list
// Strategy 1: Click any visible refresh button
const refreshBtn = document.querySelector('[class*="refresh"], [title*="刷新"]');
if (refreshBtn) {
simulateClick(refreshBtn);
console.log(QQ_MAIL_PREFIX, 'Clicked refresh button');
await sleep(500);
return;
}
// Strategy 2: Click inbox in sidebar to reload list
const sidebarInbox = document.querySelector('a[href*="inbox"], [class*="folder-item"][class*="inbox"], [title="收件箱"]');
if (sidebarInbox) {
simulateClick(sidebarInbox);
console.log(QQ_MAIL_PREFIX, 'Clicked sidebar inbox');
await sleep(500);
return;
}
// Strategy 3: Click the folder name in toolbar
const folderName = document.querySelector('.toolbar-folder-name');
if (folderName) {
simulateClick(folderName);
console.log(QQ_MAIL_PREFIX, 'Clicked toolbar folder name');
await sleep(500);
}
}
@@ -0,0 +1,265 @@
// content/relay-firefox.js — Content script for Firefox Relay profile page
console.log('[MultiPage:relay-firefox] Content script loaded on', location.href);
const {
getNextRelayMaskLabel = (labels = []) => `t${labels.length + 1}`,
} = globalThis.MultiPageEmailProvider || {};
const LABEL_INPUT_SELECTOR = 'input[placeholder="Add account name"], input[aria-label="Edit the label for this mask"]';
chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
if (message.type !== 'CREATE_RELAY_MASK' && message.type !== 'DELETE_RELAY_MASK') return;
resetStopState();
const handler = message.type === 'CREATE_RELAY_MASK'
? createRelayMask
: deleteRelayMask;
handler(message.payload || {}).then(result => {
sendResponse(result);
}).catch(err => {
if (isStopError(err)) {
log('Relay: Stopped by user.', 'warn');
sendResponse({ stopped: true, error: err.message });
return;
}
sendResponse({ error: err.message });
});
return true;
});
function getElementText(el) {
return [
el?.innerText,
el?.textContent,
el?.getAttribute?.('aria-label'),
el?.getAttribute?.('title'),
el?.getAttribute?.('description'),
].filter(Boolean).join(' ');
}
function isVisible(el) {
if (!el) return false;
if (el.hidden) return false;
const style = window.getComputedStyle(el);
if (style.display === 'none' || style.visibility === 'hidden' || style.opacity === '0') {
return false;
}
return Boolean(el.offsetWidth || el.offsetHeight || el.getClientRects().length);
}
function extractMozmail(text) {
const match = String(text || '').match(/[A-Z0-9._%+-]+@mozmail\.com/i);
return match ? match[0].toLowerCase() : '';
}
function getMaskButtons(root = document) {
return Array.from(root.querySelectorAll('button')).filter((button) => extractMozmail(getElementText(button)));
}
function getMaskEmails(root = document) {
return Array.from(new Set(
getMaskButtons(root)
.map((button) => extractMozmail(getElementText(button)))
.filter(Boolean)
));
}
function getVisibleLabelInputs(root = document) {
return Array.from(root.querySelectorAll(LABEL_INPUT_SELECTOR)).filter(isVisible);
}
function getExistingLabels() {
return Array.from(document.querySelectorAll(LABEL_INPUT_SELECTOR))
.map((input) => input.value.trim())
.filter(Boolean);
}
function findGenerateButton() {
return document.querySelector('button[title="Generate new mask"]')
|| Array.from(document.querySelectorAll('button')).find((button) => /generate new mask/i.test(getElementText(button)));
}
function findDeleteButton(root) {
return Array.from(root.querySelectorAll('button')).find((button) => /^delete$/i.test(getElementText(button).trim()));
}
function getMaskButtonsIn(root) {
return Array.from(root.querySelectorAll('button')).filter((button) => extractMozmail(getElementText(button)));
}
function findMaskContainerForButton(button) {
let current = button?.parentElement || null;
while (current && current !== document.body) {
const maskButtons = getMaskButtonsIn(current);
if (maskButtons.length === 1 && (current.querySelector(LABEL_INPUT_SELECTOR) || findDeleteButton(current))) {
return current;
}
current = current.parentElement;
}
return button?.closest('li') || button?.parentElement || null;
}
function findMaskRowByEmail(email) {
const normalizedEmail = String(email || '').toLowerCase();
const button = getMaskButtons().find((candidate) => extractMozmail(getElementText(candidate)) === normalizedEmail);
if (!button) return null;
return findMaskContainerForButton(button);
}
async function waitForNewMaskEmail(previousEmails = new Set(), timeout = 15000) {
const startedAt = Date.now();
while (Date.now() - startedAt < timeout) {
throwIfStopped();
const currentEmails = getMaskEmails();
const nextEmail = currentEmails.find((email) => !previousEmails.has(email));
if (nextEmail) {
return nextEmail;
}
await sleep(150);
}
throw new Error('Timed out waiting for a new Relay mask to appear.');
}
async function waitForMaskRow(email, timeout = 10000) {
const startedAt = Date.now();
while (Date.now() - startedAt < timeout) {
throwIfStopped();
const row = findMaskRowByEmail(email);
if (row) {
return row;
}
await sleep(150);
}
throw new Error(`Timed out waiting for Relay mask row: ${email}`);
}
async function assignRelayLabel(maskRow) {
const labelInput = Array.from(maskRow.querySelectorAll(LABEL_INPUT_SELECTOR)).find(isVisible)
|| maskRow.querySelector(LABEL_INPUT_SELECTOR);
if (!labelInput) {
throw new Error('Could not find Relay label input for the new mask.');
}
const currentValue = labelInput.value.trim();
if (currentValue) {
return currentValue;
}
const nextLabel = getNextRelayMaskLabel(getExistingLabels());
await humanPause(200, 450);
fillInput(labelInput, nextLabel);
labelInput.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true }));
labelInput.dispatchEvent(new KeyboardEvent('keyup', { key: 'Enter', bubbles: true }));
labelInput.blur();
for (let i = 0; i < 20; i++) {
throwIfStopped();
const labels = getExistingLabels();
if (labels.includes(nextLabel) || labelInput.value.trim() === nextLabel) {
log(`Relay: Assigned label ${nextLabel}`, 'ok');
return nextLabel;
}
await sleep(150);
}
throw new Error(`Relay label ${nextLabel} was not saved.`);
}
async function createRelayMask(payload = {}) {
const { generateNew = true } = payload;
log(`Relay: ${generateNew ? 'Creating' : 'Reading'} mask...`);
await waitForElement(LABEL_INPUT_SELECTOR + ', button[title="Generate new mask"]', 20000);
const previousEmails = new Set(getMaskEmails());
if (!generateNew && previousEmails.size > 0) {
const email = Array.from(previousEmails)[0];
return { email, label: null, generated: false };
}
const generatorButton = findGenerateButton();
if (!generatorButton) {
throw new Error('Could not find "Generate new mask" button on Firefox Relay.');
}
await humanPause(500, 1200);
simulateClick(generatorButton);
log('Relay: Clicked "Generate new mask"');
const email = await waitForNewMaskEmail(previousEmails);
const maskRow = await waitForMaskRow(email);
const label = await assignRelayLabel(maskRow);
log(`Relay: Ready mask ${email}`, 'ok');
return { email, label, generated: true };
}
function findVisibleDialogDeleteButton() {
const dialogButtons = Array.from(document.querySelectorAll('[role="dialog"] button, dialog button, [aria-modal="true"] button'));
return dialogButtons.find((button) => isVisible(button) && /delete|confirm|remove/i.test(getElementText(button)));
}
async function waitForMaskRemoval(email, timeout = 15000) {
const startedAt = Date.now();
const normalizedEmail = String(email || '').toLowerCase();
while (Date.now() - startedAt < timeout) {
throwIfStopped();
const exists = getMaskEmails().includes(normalizedEmail);
if (!exists) {
return;
}
await sleep(200);
}
throw new Error(`Timed out waiting for Relay mask deletion: ${email}`);
}
async function deleteRelayMask(payload = {}) {
const email = String(payload.email || '').trim().toLowerCase();
if (!email) {
throw new Error('No Relay mask email provided for deletion.');
}
log(`Relay: Deleting ${email}...`);
await waitForElement(LABEL_INPUT_SELECTOR + ', button[title="Generate new mask"]', 20000);
const maskRow = await waitForMaskRow(email);
const detailsButton = Array.from(maskRow.querySelectorAll('button')).find((button) => /show mask details/i.test(getElementText(button)));
if (detailsButton && isVisible(detailsButton)) {
await humanPause(150, 300);
simulateClick(detailsButton);
await sleep(250);
}
const deleteButton = findDeleteButton(maskRow);
if (!deleteButton) {
throw new Error(`Could not find Delete button for Relay mask ${email}.`);
}
await humanPause(200, 400);
simulateClick(deleteButton);
await sleep(300);
const confirmButton = findVisibleDialogDeleteButton();
if (confirmButton) {
await humanPause(150, 300);
simulateClick(confirmButton);
}
await waitForMaskRemoval(email);
log(`Relay: Deleted ${email}`, 'ok');
return { deleted: true, email };
}
@@ -0,0 +1,569 @@
// content/signup-page.js — Content script for OpenAI auth pages (steps 2, 3, 4-receive, 5)
// Injected on: auth0.openai.com, auth.openai.com, accounts.openai.com
console.log('[MultiPage:signup-page] Content script loaded on', location.href);
// Listen for commands from Background
chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
if (message.type === 'GET_PAGE_STATE') {
handleCommand(message).then((result) => {
sendResponse({ ok: true, ...(result || {}) });
}).catch(err => {
sendResponse({ error: err.message });
});
return true;
}
if (message.type === 'EXECUTE_STEP' || message.type === 'FILL_CODE' || message.type === 'STEP8_FIND_AND_CLICK') {
resetStopState();
handleCommand(message).then((result) => {
sendResponse({ ok: true, ...(result || {}) });
}).catch(err => {
if (isStopError(err)) {
log(`Step ${message.step || 8}: Stopped by user.`, 'warn');
sendResponse({ stopped: true, error: err.message });
return;
}
if (message.type === 'STEP8_FIND_AND_CLICK') {
log(`Step 8: ${err.message}`, 'error');
sendResponse({ error: err.message });
return;
}
reportError(message.step, err.message);
sendResponse({ error: err.message });
});
return true;
}
});
async function handleCommand(message) {
switch (message.type) {
case 'GET_PAGE_STATE':
return getCurrentPageState();
case 'EXECUTE_STEP':
switch (message.step) {
case 2: return await step2_clickRegister();
case 3: return await step3_fillEmailPassword(message.payload);
case 5: return await step5_fillNameBirthday(message.payload);
case 6: return await step6_login(message.payload);
case 8: return await step8_findAndClick();
default: throw new Error(`signup-page.js does not handle step ${message.step}`);
}
case 'FILL_CODE':
// Step 4 = signup code, Step 7 = login code (same handler)
return await fillVerificationCode(message.step, message.payload);
case 'STEP8_FIND_AND_CLICK':
return await step8_findAndClick();
}
}
function getCurrentPageState() {
const consentButton = findVisibleConsentButton();
const hasVisibleContinueButton = Boolean(consentButton);
return {
url: location.href,
hasVisibleContinueButton,
isConsentPage: MultiPageOAuthFlow.isConsentPageState({
url: location.href,
hasVisibleContinueButton,
}),
};
}
// ============================================================
// Step 2: Click Register
// ============================================================
async function step2_clickRegister() {
log('Step 2: Looking for Register/Sign up button...');
let registerBtn = null;
try {
registerBtn = await waitForElementByText(
'a, button, [role="button"], [role="link"]',
/sign\s*up|register|create\s*account|注册/i,
10000
);
} catch {
// Some pages may have a direct link
try {
registerBtn = await waitForElement('a[href*="signup"], a[href*="register"]', 5000);
} catch {
throw new Error(
'Could not find Register/Sign up button. ' +
'Check auth page DOM in DevTools. URL: ' + location.href
);
}
}
await humanPause(450, 1200);
reportComplete(2);
simulateClick(registerBtn);
log('Step 2: Clicked Register button');
}
// ============================================================
// Step 3: Fill Email & Password
// ============================================================
async function step3_fillEmailPassword(payload) {
const { email } = payload;
if (!email) throw new Error('No email provided. Paste email in Side Panel first.');
log(`Step 3: Filling email: ${email}`);
// Find email input
let emailInput = null;
try {
emailInput = await waitForElement(
'input[type="email"], input[name="email"], input[name="username"], input[id*="email"], input[placeholder*="email"], input[placeholder*="Email"]',
10000
);
} catch {
throw new Error('Could not find email input field on signup page. URL: ' + location.href);
}
await humanPause(500, 1400);
fillInput(emailInput, email);
log('Step 3: Email filled');
// Check if password field is on the same page
let passwordInput = document.querySelector('input[type="password"]');
if (!passwordInput) {
// Need to submit email first to get to password page
log('Step 3: No password field yet, submitting email first...');
const submitBtn = document.querySelector('button[type="submit"]')
|| await waitForElementByText('button', /continue|next|submit|继续|下一步/i, 5000).catch(() => null);
if (submitBtn) {
await humanPause(400, 1100);
simulateClick(submitBtn);
log('Step 3: Submitted email, waiting for password field...');
await sleep(2000);
}
try {
passwordInput = await waitForElement('input[type="password"]', 10000);
} catch {
throw new Error('Could not find password input after submitting email. URL: ' + location.href);
}
}
if (!payload.password) throw new Error('No password provided. Step 3 requires a generated password.');
await humanPause(600, 1500);
fillInput(passwordInput, payload.password);
log('Step 3: Password filled');
// Report complete BEFORE submit, because submit causes page navigation
// which kills the content script connection
reportComplete(3, { email });
// Submit the form (page will navigate away after this)
await sleep(500);
const submitBtn = document.querySelector('button[type="submit"]')
|| await waitForElementByText('button', /continue|sign\s*up|submit|注册|创建|create/i, 5000).catch(() => null);
if (submitBtn) {
await humanPause(500, 1300);
simulateClick(submitBtn);
log('Step 3: Form submitted');
}
}
// ============================================================
// Fill Verification Code (used by step 4 and step 7)
// ============================================================
async function fillVerificationCode(step, payload) {
const { code } = payload;
if (!code) throw new Error('No verification code provided.');
log(`Step ${step}: Filling verification code: ${code}`);
// Find code input — could be a single input or multiple separate inputs
let codeInput = null;
try {
codeInput = await waitForElement(
'input[name="code"], input[name="otp"], input[type="text"][maxlength="6"], input[aria-label*="code"], input[placeholder*="code"], input[placeholder*="Code"], input[inputmode="numeric"]',
10000
);
} catch {
// Check for multiple single-digit inputs (common pattern)
const singleInputs = document.querySelectorAll('input[maxlength="1"]');
if (singleInputs.length >= 6) {
log(`Step ${step}: Found single-digit code inputs, filling individually...`);
for (let i = 0; i < 6 && i < singleInputs.length; i++) {
fillInput(singleInputs[i], code[i]);
await sleep(100);
}
await sleep(1000);
reportComplete(step);
return;
}
throw new Error('Could not find verification code input. URL: ' + location.href);
}
fillInput(codeInput, code);
log(`Step ${step}: Code filled`);
// Report complete BEFORE submit (page may navigate away)
reportComplete(step);
// Submit
await sleep(500);
const submitBtn = document.querySelector('button[type="submit"]')
|| await waitForElementByText('button', /verify|confirm|submit|continue|确认|验证/i, 5000).catch(() => null);
if (submitBtn) {
await humanPause(450, 1200);
simulateClick(submitBtn);
log(`Step ${step}: Verification submitted`);
}
}
// ============================================================
// Step 6: Login with registered account (on OAuth auth page)
// ============================================================
async function step6_login(payload) {
const { email, password } = payload;
if (!email) throw new Error('No email provided for login.');
log(`Step 6: Logging in with ${email}...`);
// Wait for email input on the auth page
let emailInput = null;
try {
emailInput = await waitForElement(
'input[type="email"], input[name="email"], input[name="username"], input[id*="email"], input[placeholder*="email" i], input[placeholder*="Email"]',
15000
);
} catch {
throw new Error('Could not find email input on login page. URL: ' + location.href);
}
await humanPause(500, 1400);
fillInput(emailInput, email);
log('Step 6: Email filled');
// Submit email
await sleep(500);
const submitBtn1 = document.querySelector('button[type="submit"]')
|| await waitForElementByText('button', /continue|next|submit|继续|下一步/i, 5000).catch(() => null);
if (submitBtn1) {
await humanPause(400, 1100);
simulateClick(submitBtn1);
log('Step 6: Submitted email');
}
const passwordInput = await waitForLoginPasswordField();
if (passwordInput) {
log('Step 6: Password field found, filling password...');
await humanPause(550, 1450);
fillInput(passwordInput, password);
await sleep(500);
const submitBtn2 = document.querySelector('button[type="submit"]')
|| await waitForElementByText('button', /continue|log\s*in|submit|sign\s*in|登录|继续/i, 5000).catch(() => null);
// Report complete BEFORE submit in case page navigates
reportComplete(6, { needsOTP: true });
if (submitBtn2) {
await humanPause(450, 1200);
simulateClick(submitBtn2);
log('Step 6: Submitted password, may need verification code (step 7)');
}
return;
}
// No password field — OTP flow
log('Step 6: No password field. OTP flow or auto-redirect.');
reportComplete(6, { needsOTP: true });
}
async function waitForLoginPasswordField(timeout = 25000) {
const start = Date.now();
while (Date.now() - start < timeout) {
throwIfStopped();
const passwordInput = findVisiblePasswordInput();
if (passwordInput) {
return passwordInput;
}
await sleep(250);
}
log(`Step 6: Password field did not appear within ${Math.round(timeout / 1000)}s.`, 'warn');
return null;
}
function findVisiblePasswordInput() {
const inputs = document.querySelectorAll('input[type="password"]');
for (const input of inputs) {
if (isElementVisible(input)) {
return input;
}
}
return null;
}
function isElementVisible(el) {
if (!el) return false;
const style = window.getComputedStyle(el);
if (style.display === 'none' || style.visibility === 'hidden' || style.opacity === '0') {
return false;
}
const rect = el.getBoundingClientRect();
return rect.width > 0 && rect.height > 0;
}
// ============================================================
// Step 8: Find "继续" on OAuth consent page for debugger click
// ============================================================
// After login + verification, page shows:
// "使用 ChatGPT 登录到 Codex" with a "继续" submit button.
// Background performs the actual click through the debugger Input API.
async function step8_findAndClick() {
log('Step 8: Looking for OAuth consent "继续" button...');
const continueBtn = await findContinueButton();
await waitForButtonEnabled(continueBtn);
await humanPause(350, 900);
continueBtn.scrollIntoView({ behavior: 'smooth', block: 'center' });
continueBtn.focus();
await sleep(250);
const rect = getSerializableRect(continueBtn);
log('Step 8: Found "继续" button and prepared debugger click coordinates.');
return {
rect,
buttonText: (continueBtn.textContent || '').trim(),
url: location.href,
};
}
async function findContinueButton() {
const visibleButton = findVisibleConsentButton();
if (visibleButton) {
return visibleButton;
}
try {
return await waitForElement(
'button[type="submit"][data-dd-action-name="Continue"], button[type="submit"]._primary_3rdp0_107',
10000
);
} catch {
try {
return await waitForElementByText('button', /继续|Continue/, 5000);
} catch {
throw new Error('Could not find "继续" button on OAuth consent page. URL: ' + location.href);
}
}
}
function findVisibleConsentButton() {
const selectorMatches = document.querySelectorAll(
'button[type="submit"][data-dd-action-name="Continue"], button[type="submit"]._primary_3rdp0_107'
);
for (const button of selectorMatches) {
if (isElementVisible(button)) {
return button;
}
}
const buttons = document.querySelectorAll('button');
for (const button of buttons) {
if (!isElementVisible(button)) {
continue;
}
if (/继续|Continue/i.test(button.textContent || '')) {
return button;
}
}
return null;
}
async function waitForButtonEnabled(button, timeout = 8000) {
const start = Date.now();
while (Date.now() - start < timeout) {
throwIfStopped();
if (isButtonEnabled(button)) return;
await sleep(150);
}
throw new Error('"继续" button stayed disabled for too long. URL: ' + location.href);
}
function isButtonEnabled(button) {
return Boolean(button)
&& !button.disabled
&& button.getAttribute('aria-disabled') !== 'true';
}
function getSerializableRect(el) {
const rect = el.getBoundingClientRect();
if (!rect.width || !rect.height) {
throw new Error('"继续" button has no clickable size after scrolling. URL: ' + location.href);
}
return {
left: rect.left,
top: rect.top,
width: rect.width,
height: rect.height,
centerX: rect.left + (rect.width / 2),
centerY: rect.top + (rect.height / 2),
};
}
// ============================================================
// Step 5: Fill Name & Birthday / Age
// ============================================================
async function step5_fillNameBirthday(payload) {
const { firstName, lastName, age, year, month, day } = payload;
if (!firstName || !lastName) throw new Error('No name data provided.');
const resolvedAge = age ?? (year ? new Date().getFullYear() - Number(year) : null);
const hasBirthdayData = [year, month, day].every(value => value != null && !Number.isNaN(Number(value)));
if (!hasBirthdayData && (resolvedAge == null || Number.isNaN(Number(resolvedAge)))) {
throw new Error('No birthday or age data provided.');
}
const fullName = `${firstName} ${lastName}`;
log(`Step 5: Filling name: ${fullName}`);
// Actual DOM structure:
// - Full name: <input name="name" placeholder="全名" type="text">
// - Birthday: React Aria DateField or hidden input[name="birthday"]
// - Age: <input name="age" type="text|number">
// --- Full Name (single field, not first+last) ---
let nameInput = null;
try {
nameInput = await waitForElement(
'input[name="name"], input[placeholder*="全名"], input[autocomplete="name"]',
10000
);
} catch {
throw new Error('Could not find name input. URL: ' + location.href);
}
await humanPause(500, 1300);
fillInput(nameInput, fullName);
log(`Step 5: Name filled: ${fullName}`);
let birthdayMode = false;
let ageInput = null;
for (let i = 0; i < 100; i++) {
const yearSpinner = document.querySelector('[role="spinbutton"][data-type="year"]');
const monthSpinner = document.querySelector('[role="spinbutton"][data-type="month"]');
const daySpinner = document.querySelector('[role="spinbutton"][data-type="day"]');
const hiddenBirthday = document.querySelector('input[name="birthday"]');
ageInput = document.querySelector('input[name="age"]');
// Some pages include a hidden birthday input even though the real UI is "age".
// In that case we must prioritize filling age to satisfy required validation.
if (ageInput) break;
if ((yearSpinner && monthSpinner && daySpinner) || hiddenBirthday) {
birthdayMode = true;
break;
}
await sleep(100);
}
if (birthdayMode) {
if (!hasBirthdayData) {
throw new Error('Birthday field detected, but no birthday data provided.');
}
const yearSpinner = document.querySelector('[role="spinbutton"][data-type="year"]');
const monthSpinner = document.querySelector('[role="spinbutton"][data-type="month"]');
const daySpinner = document.querySelector('[role="spinbutton"][data-type="day"]');
if (yearSpinner && monthSpinner && daySpinner) {
log('Step 5: Birthday fields detected, filling birthday...');
async function setSpinButton(el, value) {
el.focus();
await sleep(100);
document.execCommand('selectAll', false, null);
await sleep(50);
const valueStr = String(value);
for (const char of valueStr) {
el.dispatchEvent(new KeyboardEvent('keydown', { key: char, code: `Digit${char}`, bubbles: true }));
el.dispatchEvent(new KeyboardEvent('keypress', { key: char, code: `Digit${char}`, bubbles: true }));
el.dispatchEvent(new InputEvent('beforeinput', { inputType: 'insertText', data: char, bubbles: true }));
el.dispatchEvent(new InputEvent('input', { inputType: 'insertText', data: char, bubbles: true }));
await sleep(50);
}
el.dispatchEvent(new KeyboardEvent('keyup', { key: 'Tab', code: 'Tab', bubbles: true }));
el.blur();
await sleep(100);
}
await humanPause(450, 1100);
await setSpinButton(yearSpinner, year);
await humanPause(250, 650);
await setSpinButton(monthSpinner, String(month).padStart(2, '0'));
await humanPause(250, 650);
await setSpinButton(daySpinner, String(day).padStart(2, '0'));
log(`Step 5: Birthday filled: ${year}-${String(month).padStart(2, '0')}-${String(day).padStart(2, '0')}`);
}
const hiddenBirthday = document.querySelector('input[name="birthday"]');
if (hiddenBirthday) {
const dateStr = `${year}-${String(month).padStart(2, '0')}-${String(day).padStart(2, '0')}`;
hiddenBirthday.value = dateStr;
hiddenBirthday.dispatchEvent(new Event('change', { bubbles: true }));
log(`Step 5: Hidden birthday input set: ${dateStr}`);
}
} else if (ageInput) {
if (resolvedAge == null || Number.isNaN(Number(resolvedAge))) {
throw new Error('Age field detected, but no age data provided.');
}
await humanPause(500, 1300);
fillInput(ageInput, String(resolvedAge));
log(`Step 5: Age filled: ${resolvedAge}`);
// Some age-mode pages still submit a hidden birthday field.
// Keep it aligned with generated data so backend validation won't reject.
const hiddenBirthday = document.querySelector('input[name="birthday"]');
if (hiddenBirthday && hasBirthdayData) {
const dateStr = `${year}-${String(month).padStart(2, '0')}-${String(day).padStart(2, '0')}`;
hiddenBirthday.value = dateStr;
hiddenBirthday.dispatchEvent(new Event('change', { bubbles: true }));
log(`Step 5: Hidden birthday input set (age mode): ${dateStr}`);
}
} else {
throw new Error('Could not find birthday or age input. URL: ' + location.href);
}
// Click "完成帐户创建" button
await sleep(500);
const completeBtn = document.querySelector('button[type="submit"]')
|| await waitForElementByText('button', /完成|create|continue|finish|done|agree/i, 5000).catch(() => null);
// Report complete BEFORE submit (page navigates to add-phone after this)
reportComplete(5);
if (completeBtn) {
await humanPause(500, 1300);
simulateClick(completeBtn);
log('Step 5: Clicked "完成帐户创建"');
}
}
@@ -0,0 +1,337 @@
// content/utils.js — Shared utilities for all content scripts
const SCRIPT_SOURCE = (() => {
if (window.__MULTIPAGE_SOURCE) return window.__MULTIPAGE_SOURCE;
const url = location.href;
if (url.includes('auth0.openai.com') || url.includes('auth.openai.com') || url.includes('accounts.openai.com')) return 'signup-page';
if (url.includes('mail.qq.com')) return 'qq-mail';
if (url.includes('mail.163.com')) return 'mail-163';
if (url.includes('duckduckgo.com/email/settings/autofill')) return 'duck-mail';
if (url.includes('relay.firefox.com/accounts/profile')) return 'relay-firefox';
if (url.includes('mail.cloudflare.com/admin')) return 'cloudflare-temp-email';
if (url.includes('chatgpt.com')) return 'chatgpt';
// VPS panel — detected dynamically since URL is configurable
return 'vps-panel';
})();
const LOG_PREFIX = `[MultiPage:${SCRIPT_SOURCE}]`;
const STOP_ERROR_MESSAGE = 'Flow stopped by user.';
let flowStopped = false;
chrome.runtime.onMessage.addListener((message) => {
if (message.type === 'STOP_FLOW') {
flowStopped = true;
console.warn(LOG_PREFIX, STOP_ERROR_MESSAGE);
}
});
function resetStopState() {
flowStopped = false;
}
function isStopError(error) {
const message = typeof error === 'string' ? error : error?.message;
return message === STOP_ERROR_MESSAGE;
}
function throwIfStopped() {
if (flowStopped) {
throw new Error(STOP_ERROR_MESSAGE);
}
}
/**
* Wait for a DOM element to appear.
* @param {string} selector - CSS selector
* @param {number} timeout - Max wait time in ms (default 10000)
* @returns {Promise<Element>}
*/
function waitForElement(selector, timeout = 10000) {
return new Promise((resolve, reject) => {
throwIfStopped();
const existing = document.querySelector(selector);
if (existing) {
console.log(LOG_PREFIX, `Found immediately: ${selector}`);
log(`Found element: ${selector}`);
resolve(existing);
return;
}
console.log(LOG_PREFIX, `Waiting for: ${selector} (timeout: ${timeout}ms)`);
log(`Waiting for selector: ${selector}...`);
let settled = false;
let stopTimer = null;
const cleanup = () => {
if (settled) return;
settled = true;
observer.disconnect();
clearTimeout(timer);
clearTimeout(stopTimer);
};
const observer = new MutationObserver(() => {
if (flowStopped) {
cleanup();
reject(new Error(STOP_ERROR_MESSAGE));
return;
}
const el = document.querySelector(selector);
if (el) {
cleanup();
console.log(LOG_PREFIX, `Found after wait: ${selector}`);
log(`Found element: ${selector}`);
resolve(el);
}
});
observer.observe(document.body || document.documentElement, {
childList: true,
subtree: true,
});
const timer = setTimeout(() => {
cleanup();
const msg = `Timeout waiting for ${selector} after ${timeout}ms on ${location.href}`;
console.error(LOG_PREFIX, msg);
reject(new Error(msg));
}, timeout);
const pollStop = () => {
if (settled) return;
if (flowStopped) {
cleanup();
reject(new Error(STOP_ERROR_MESSAGE));
return;
}
stopTimer = setTimeout(pollStop, 100);
};
pollStop();
});
}
/**
* Wait for an element matching a text pattern among multiple candidates.
* @param {string} containerSelector - Selector for candidate elements
* @param {RegExp} textPattern - Regex to match against textContent
* @param {number} timeout - Max wait time in ms
* @returns {Promise<Element>}
*/
function waitForElementByText(containerSelector, textPattern, timeout = 10000) {
return new Promise((resolve, reject) => {
throwIfStopped();
function search() {
const candidates = document.querySelectorAll(containerSelector);
for (const el of candidates) {
if (textPattern.test(el.textContent)) {
return el;
}
}
return null;
}
const existing = search();
if (existing) {
console.log(LOG_PREFIX, `Found by text immediately: ${containerSelector} matching ${textPattern}`);
log(`Found element by text: ${textPattern}`);
resolve(existing);
return;
}
console.log(LOG_PREFIX, `Waiting for text match: ${containerSelector} / ${textPattern}`);
log(`Waiting for element with text: ${textPattern}...`);
let settled = false;
let stopTimer = null;
const cleanup = () => {
if (settled) return;
settled = true;
observer.disconnect();
clearTimeout(timer);
clearTimeout(stopTimer);
};
const observer = new MutationObserver(() => {
if (flowStopped) {
cleanup();
reject(new Error(STOP_ERROR_MESSAGE));
return;
}
const el = search();
if (el) {
cleanup();
console.log(LOG_PREFIX, `Found by text after wait: ${textPattern}`);
log(`Found element by text: ${textPattern}`);
resolve(el);
}
});
observer.observe(document.body || document.documentElement, {
childList: true,
subtree: true,
});
const timer = setTimeout(() => {
cleanup();
const msg = `Timeout waiting for text "${textPattern}" in "${containerSelector}" after ${timeout}ms on ${location.href}`;
console.error(LOG_PREFIX, msg);
reject(new Error(msg));
}, timeout);
const pollStop = () => {
if (settled) return;
if (flowStopped) {
cleanup();
reject(new Error(STOP_ERROR_MESSAGE));
return;
}
stopTimer = setTimeout(pollStop, 100);
};
pollStop();
});
}
/**
* React-compatible form filling.
* Sets value via native setter and dispatches input + change events.
* @param {HTMLInputElement} el
* @param {string} value
*/
function fillInput(el, value) {
throwIfStopped();
const nativeInputValueSetter = Object.getOwnPropertyDescriptor(
window.HTMLInputElement.prototype,
'value'
).set;
nativeInputValueSetter.call(el, value);
el.dispatchEvent(new Event('input', { bubbles: true }));
el.dispatchEvent(new Event('change', { bubbles: true }));
console.log(LOG_PREFIX, `Filled input ${el.name || el.id || el.type} with: ${value}`);
log(`Filled input [${el.name || el.id || el.type || 'unknown'}]`);
}
/**
* Fill a select element by setting its value and triggering change.
* @param {HTMLSelectElement} el
* @param {string} value
*/
function fillSelect(el, value) {
throwIfStopped();
el.value = value;
el.dispatchEvent(new Event('change', { bubbles: true }));
console.log(LOG_PREFIX, `Selected value ${value} in ${el.name || el.id}`);
log(`Selected [${el.name || el.id || 'unknown'}] = ${value}`);
}
/**
* Send a log message to Side Panel via Background.
* @param {string} message
* @param {string} level - 'info' | 'ok' | 'warn' | 'error'
*/
function log(message, level = 'info') {
chrome.runtime.sendMessage({
type: 'LOG',
source: SCRIPT_SOURCE,
step: null,
payload: { message, level, timestamp: Date.now() },
error: null,
});
}
/**
* Report that this content script is loaded and ready.
*/
function reportReady() {
console.log(LOG_PREFIX, 'Content script ready');
chrome.runtime.sendMessage({
type: 'CONTENT_SCRIPT_READY',
source: SCRIPT_SOURCE,
step: null,
payload: {},
error: null,
});
}
/**
* Report step completion.
* @param {number} step
* @param {Object} data - Step output data
*/
function reportComplete(step, data = {}) {
console.log(LOG_PREFIX, `Step ${step} completed`, data);
log(`Step ${step} completed successfully`, 'ok');
chrome.runtime.sendMessage({
type: 'STEP_COMPLETE',
source: SCRIPT_SOURCE,
step,
payload: data,
error: null,
});
}
/**
* Report step error.
* @param {number} step
* @param {string} errorMessage
*/
function reportError(step, errorMessage) {
console.error(LOG_PREFIX, `Step ${step} failed: ${errorMessage}`);
log(`Step ${step} failed: ${errorMessage}`, 'error');
chrome.runtime.sendMessage({
type: 'STEP_ERROR',
source: SCRIPT_SOURCE,
step,
payload: {},
error: errorMessage,
});
}
/**
* Simulate a click with proper event dispatching.
* @param {Element} el
*/
function simulateClick(el) {
throwIfStopped();
el.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true }));
console.log(LOG_PREFIX, `Clicked: ${el.tagName} ${el.textContent?.slice(0, 30) || ''}`);
log(`Clicked [${el.tagName}] "${el.textContent?.trim().slice(0, 30) || ''}"`);
}
/**
* Wait a specified number of milliseconds.
* @param {number} ms
* @returns {Promise<void>}
*/
function sleep(ms) {
return new Promise((resolve, reject) => {
const start = Date.now();
function tick() {
if (flowStopped) {
reject(new Error(STOP_ERROR_MESSAGE));
return;
}
if (Date.now() - start >= ms) {
resolve();
return;
}
setTimeout(tick, Math.min(100, Math.max(25, ms - (Date.now() - start))));
}
tick();
});
}
async function humanPause(min = 250, max = 850) {
const duration = Math.floor(Math.random() * (max - min + 1)) + min;
await sleep(duration);
}
// Auto-report ready on load
// Skip ready signal from child iframes of mail pages to avoid overwriting the top frame's registration
const _isMailChildFrame = (SCRIPT_SOURCE === 'qq-mail' || SCRIPT_SOURCE === 'mail-163' || SCRIPT_SOURCE === 'inbucket-mail') && window !== window.top;
if (!_isMailChildFrame) {
reportReady();
}
@@ -0,0 +1,183 @@
// content/vps-panel.js — Content script for VPS panel (steps 1, 9)
// Injected on: VPS panel (user-configured URL)
//
// Actual DOM structure (after login click):
// <div class="card">
// <div class="card-header">
// <span class="OAuthPage-module__cardTitle___yFaP0">Codex OAuth</span>
// <button class="btn btn-primary"><span>登录</span></button>
// </div>
// <div class="OAuthPage-module__cardContent___1sXLA">
// <div class="OAuthPage-module__authUrlBox___Iu1d4">
// <div class="OAuthPage-module__authUrlLabel___mYFJB">授权链接:</div>
// <div class="OAuthPage-module__authUrlValue___axvUJ">https://auth.openai.com/...</div>
// <div class="OAuthPage-module__authUrlActions___venPj">
// <button class="btn btn-secondary btn-sm"><span>复制链接</span></button>
// <button class="btn btn-secondary btn-sm"><span>打开链接</span></button>
// </div>
// </div>
// <div class="OAuthPage-module__callbackSection___8kA31">
// <input class="input" placeholder="http://localhost:1455/auth/callback?code=...&state=...">
// <button class="btn btn-secondary btn-sm"><span>提交回调 URL</span></button>
// </div>
// </div>
// </div>
console.log('[MultiPage:vps-panel] Content script loaded on', location.href);
// Listen for commands from Background
chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
if (message.type === 'EXECUTE_STEP') {
resetStopState();
handleStep(message.step, message.payload).then(() => {
sendResponse({ ok: true });
}).catch(err => {
if (isStopError(err)) {
log(`Step ${message.step}: Stopped by user.`, 'warn');
sendResponse({ stopped: true, error: err.message });
return;
}
reportError(message.step, err.message);
sendResponse({ error: err.message });
});
return true;
}
});
async function handleStep(step, payload) {
switch (step) {
case 1: return await step1_getOAuthLink();
case 9: return await step9_vpsVerify(payload);
default:
throw new Error(`vps-panel.js does not handle step ${step}`);
}
}
// ============================================================
// Step 1: Get OAuth Link
// ============================================================
async function step1_getOAuthLink() {
log('Step 1: Waiting for VPS panel to load (auto-login may take a moment)...');
// The page may start at #/login and auto-redirect to #/oauth.
// Wait for the Codex OAuth card to appear (up to 30s for auto-login + redirect).
let loginBtn = null;
try {
// Wait for any card-header containing "Codex" to appear
const header = await waitForElementByText('.card-header', /codex/i, 30000);
loginBtn = header.querySelector('button.btn.btn-primary, button.btn');
log('Step 1: Found Codex OAuth card');
} catch {
throw new Error(
'Codex OAuth card did not appear after 30s. Page may still be loading or not logged in. ' +
'Current URL: ' + location.href
);
}
if (!loginBtn) {
throw new Error('Found Codex OAuth card but no login button inside it. URL: ' + location.href);
}
// Check if button is disabled (already clicked / loading)
if (loginBtn.disabled) {
log('Step 1: Login button is disabled (already loading), waiting for auth URL...');
} else {
await humanPause(500, 1400);
simulateClick(loginBtn);
log('Step 1: Clicked login button, waiting for auth URL...');
}
// Wait for the auth URL to appear in the specific div
let authUrlEl = null;
try {
authUrlEl = await waitForElement('[class*="authUrlValue"]', 15000);
} catch {
throw new Error(
'Auth URL did not appear after clicking login. ' +
'Check if VPS panel is logged in and Codex service is running. URL: ' + location.href
);
}
const oauthUrl = (authUrlEl.textContent || '').trim();
if (!oauthUrl || !oauthUrl.startsWith('http')) {
throw new Error(`Invalid OAuth URL found: "${oauthUrl.slice(0, 50)}". Expected URL starting with http.`);
}
log(`Step 1: OAuth URL obtained: ${oauthUrl.slice(0, 80)}...`, 'ok');
reportComplete(1, { oauthUrl });
}
// ============================================================
// Step 9: VPS Verify — paste localhost URL and submit
// ============================================================
async function step9_vpsVerify(payload) {
// Get localhostUrl from payload (passed directly by background) or fallback to state
let localhostUrl = payload?.localhostUrl;
if (!localhostUrl) {
log('Step 9: localhostUrl not in payload, fetching from state...');
const state = await chrome.runtime.sendMessage({ type: 'GET_STATE' });
localhostUrl = state.localhostUrl;
}
if (!localhostUrl) {
throw new Error('No localhost URL found. Complete step 8 first.');
}
log(`Step 9: Got localhostUrl: ${localhostUrl.slice(0, 60)}...`);
log('Step 9: Looking for callback URL input...');
// Find the callback URL input
// Actual DOM: <input class="input" placeholder="http://localhost:1455/auth/callback?code=...&state=...">
let urlInput = null;
try {
urlInput = await waitForElement('[class*="callbackSection"] input.input', 10000);
} catch {
try {
urlInput = await waitForElement('input[placeholder*="localhost"]', 5000);
} catch {
throw new Error('Could not find callback URL input on VPS panel. URL: ' + location.href);
}
}
await humanPause(600, 1500);
fillInput(urlInput, localhostUrl);
log(`Step 9: Filled callback URL: ${localhostUrl.slice(0, 80)}...`);
// Find and click "提交回调 URL" button
let submitBtn = null;
try {
submitBtn = await waitForElementByText(
'[class*="callbackActions"] button, [class*="callbackSection"] button',
/提交/,
5000
);
} catch {
try {
submitBtn = await waitForElementByText('button.btn', /提交回调/, 5000);
} catch {
throw new Error('Could not find "提交回调 URL" button. URL: ' + location.href);
}
}
await humanPause(450, 1200);
simulateClick(submitBtn);
log('Step 9: Clicked "提交回调 URL", waiting for authentication result...');
// Wait for "认证成功!" status badge to appear
try {
await waitForElementByText('.status-badge, [class*="status"]', /认证成功/, 30000);
log('Step 9: Authentication successful!', 'ok');
} catch {
// Check if there's an error message instead
const statusEl = document.querySelector('.status-badge, [class*="status"]');
const statusText = statusEl ? statusEl.textContent : 'unknown';
if (/成功|success/i.test(statusText)) {
log('Step 9: Authentication successful!', 'ok');
} else {
log(`Step 9: Status after submit: "${statusText}". May still be processing.`, 'warn');
}
}
reportComplete(9);
}
@@ -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

@@ -0,0 +1,78 @@
{
"manifest_version": 3,
"name": "Multi-Page Automation",
"version": "1.1.0",
"description": "Automates multi-step OAuth registration workflow",
"permissions": [
"sidePanel",
"tabs",
"webNavigation",
"debugger",
"storage",
"scripting",
"activeTab"
],
"host_permissions": [
"<all_urls>"
],
"background": {
"service_worker": "background.js"
},
"side_panel": {
"default_path": "sidepanel/sidepanel.html"
},
"content_scripts": [
{
"matches": [
"https://auth0.openai.com/*",
"https://auth.openai.com/*",
"https://accounts.openai.com/*"
],
"js": ["content/utils.js", "shared/oauth-flow.js", "content/signup-page.js"],
"run_at": "document_idle"
},
{
"matches": [
"https://mail.qq.com/*",
"https://wx.mail.qq.com/*"
],
"js": ["content/utils.js", "shared/qq-mail.js", "content/qq-mail.js"],
"all_frames": true,
"run_at": "document_idle"
},
{
"matches": [
"https://mail.163.com/*"
],
"js": ["content/utils.js", "content/mail-163.js"],
"all_frames": true,
"run_at": "document_idle"
},
{
"matches": [
"https://duckduckgo.com/email/settings/autofill*"
],
"js": ["content/utils.js", "content/duck-mail.js"],
"run_at": "document_idle"
},
{
"matches": [
"https://relay.firefox.com/accounts/profile/*"
],
"js": ["content/utils.js", "shared/email-provider.js", "content/relay-firefox.js"],
"run_at": "document_idle"
}
],
"action": {
"default_icon": {
"16": "icons/icon16.png",
"48": "icons/icon48.png",
"128": "icons/icon128.png"
}
},
"icons": {
"16": "icons/icon16.png",
"48": "icons/icon48.png",
"128": "icons/icon128.png"
}
}
@@ -0,0 +1,389 @@
(function attachCloudflareTempEmailHelpers(globalScope) {
const PRIMARY_LOCAL_PART_WORD_BANKS = [
[
'anew', 'brisk', 'candid', 'fervent', 'gentle',
'humble', 'jovial', 'kindly', 'lucid', 'mellow',
'nimble', 'open', 'polished', 'quick', 'rosy',
'steady', 'tidy', 'upbeat', 'vantage', 'thunderous',
],
[
'angled', 'bordered', 'crisp', 'eager', 'frozen',
'golden', 'dotted', 'hushed', 'jagged', 'layered',
'mellowed', 'narrow', 'opal', 'primal', 'quiet',
'rippled', 'sunlit', 'trimmed', 'velvet', 'thumping',
],
[
'anchor', 'beacon', 'cinder', 'drifter', 'ember',
'meadow', 'nickel', 'orbit', 'prairie', 'quartz',
'rivet', 'latch', 'signal', 'thicket', 'uplift',
'voyager', 'willow', 'yonder', 'zephyr', 'wilderness',
],
];
const EXTENDED_LOCAL_PART_WORD_BANKS = [
['aurora', 'breezy', 'copper', 'drizzle', 'whimsy', 'glimmer', 'sapphire', 'harbor', 'inkwell', 'juniper'],
['almond', 'bronzed', 'cobbled', 'dappled', 'marbled', 'northern', 'moonlit', 'orchard', 'plaited', 'radiant'],
['acorn', 'bramble', 'citadel', 'daybreak', 'solstice', 'harvest', 'treeline', 'updraft', 'wildfire', 'yearling'],
];
const MID_EXTENDED_LOCAL_PART_WORD_BANKS = [
[
'citrine', 'dapper', 'elmwood', 'feather', 'gossamer',
'halcyon', 'ivory', 'kestrel', 'lively', 'mistral',
],
[
'lantern', 'mosaic', 'notched', 'oaken', 'pearled',
'quilted', 'rusted', 'silken', 'tapered', 'umber',
],
[
'meridian', 'northstar', 'overlook', 'peninsula', 'quickstep',
'ridgeline', 'starling', 'turnpike', 'undertow', 'vale',
],
];
const TOP_EXTENDED_LOCAL_PART_WORD_BANKS = [
['whimsy', 'afterglow', 'birdsong', 'clearwater', 'dreamscape', 'everbright', 'firecrest', 'hinterland', 'isleward', 'keystone'],
['marbled', 'auric', 'blossomed', 'celestial', 'dawnlit', 'embered', 'frosted', 'gilded', 'heartland', 'ironbound'],
['solstice', 'airstream', 'brightside', 'crestfall', 'dovetail', 'elmshade', 'fieldstone', 'goldleaf', 'highwater', 'ivytrail'],
];
const HIGH_EXTENDED_LOCAL_PART_WORD_BANKS = [
[
'adrift', 'bellwether', 'cedar', 'daystar', 'emberglow',
'fjord', 'glasswing', 'horizon', 'islander', 'jetstream',
'kingsley', 'longview', 'moonrise', 'northbound', 'oakleaf',
'pinelight', 'quasar', 'runestone', 'seaborne', 'trailhead',
],
[
'bronzed', 'cobbled', 'drifted', 'etched', 'fernlike',
'granulated', 'honeyed', 'indigo', 'jadeite', 'kindled',
'lacquered', 'measured', 'navy', 'opaline', 'painted',
'quenched', 'reeded', 'sanded', 'tempered', 'uplifted',
],
[
'cosmos', 'drumbeat', 'everglade', 'fjordline', 'grove',
'headland', 'icefield', 'journey', 'knoll', 'lagoon',
'moorland', 'narrows', 'outpost', 'passage', 'quarry',
'riverbend', 'shoal', 'tideline', 'upland', 'vista',
],
];
const APEX_EXTENDED_LOCAL_PART_WORD_BANKS = [
[
'atlas', 'bluebird', 'crestline', 'dewdrop', 'eastwind',
'flare', 'glen', 'harvestmoon', 'iris', 'joyride',
'kindred', 'larkspur', 'midway', 'nightfall', 'overture',
'prairiesky', 'quill', 'rosewood', 'sunflare', 'turnstone',
'uplight', 'violet', 'wildwood', 'xylia', 'yearbright',
'zenway', 'amberline', 'brightshore', 'cloudrest', 'dawnsong',
],
[
'bronze', 'coppered', 'dawnwashed', 'everspun', 'firelit',
'glazed', 'harbored', 'ivied', 'jade', 'keelmarked',
'leafed', 'misted', 'nacre', 'oakmoss', 'pearlstone',
'quartzite', 'rainsoft', 'sunwashed', 'timbered', 'umbered',
'velour', 'windcut', 'xanthic', 'yellowed', 'zestful',
'ashen', 'brightened', 'coasted', 'deepwater', 'emberlit',
],
[
'cosmos', 'daybreak', 'evercrest', 'fieldpath', 'groveside',
'hilltop', 'inlet', 'junction', 'keyway', 'lakeside',
'moonpath', 'nest', 'oakridge', 'portside', 'quayside',
'riverside', 'stonepath', 'trailway', 'uplook', 'valecrest',
'woodline', 'xylogrove', 'yardarm', 'zenithal', 'aircrest',
'bayshore', 'crossing', 'driftway', 'elmtrail', 'foreside',
],
];
const BASE_EXTENDED_WORD_RANGE_START = 0.91;
const MID_EXTENDED_WORD_RANGE_START = 0.95;
const TOP_EXTENDED_WORD_RANGE_START = 0.97;
const HIGH_EXTENDED_WORD_RANGE_START = 0.985;
const APEX_EXTENDED_WORD_RANGE_START = 0.993;
function normalizeWhitespace(value) {
return String(value || '').replace(/\s+/g, ' ').trim();
}
function combineDistinctTextParts(parts = []) {
const seen = new Set();
const normalizedParts = [];
for (const part of parts) {
const value = normalizeWhitespace(part);
if (!value || seen.has(value)) {
continue;
}
seen.add(value);
normalizedParts.push(value);
}
return normalizedParts.join(' ');
}
function normalizeText(value) {
return normalizeWhitespace(value).toLowerCase();
}
function normalizeEmail(value) {
return normalizeText(value);
}
function normalizeDomainSuffix(value) {
const match = normalizeText(value).match(/@?([a-z0-9.-]+\.[a-z]{2,})/i);
return match ? match[1].toLowerCase() : '';
}
function toFiniteNumber(value) {
const numeric = typeof value === 'number' ? value : Number(value);
return Number.isFinite(numeric) ? numeric : null;
}
function parseAdminTimestamp(value) {
const match = normalizeWhitespace(value).match(
/^(\d{4})\/(\d{1,2})\/(\d{1,2})\s+(\d{1,2}):(\d{2})(?::(\d{2}))?$/
);
if (!match) return null;
const [, year, month, day, hour, minute, second = '0'] = match;
const timestamp = new Date(
Number(year),
Number(month) - 1,
Number(day),
Number(hour),
Number(minute),
Number(second)
).getTime();
return Number.isFinite(timestamp) ? timestamp : null;
}
function extractVerificationCode(text) {
const content = String(text || '');
const matchCn = content.match(/(?:代码为|验证码[^0-9]*?)[\s:]*(\d{6})/);
if (matchCn) return matchCn[1];
const matchEn = content.match(/code[:\s]+is[:\s]+(\d{6})|code[:\s]+(\d{6})/i);
if (matchEn) return matchEn[1] || matchEn[2];
const match6 = content.match(/\b(\d{6})\b/);
if (match6) return match6[1];
return null;
}
function decodeBase64Url(segment) {
const base64 = String(segment || '')
.replace(/-/g, '+')
.replace(/_/g, '/');
const padding = base64.length % 4 === 0 ? '' : '='.repeat(4 - (base64.length % 4));
const padded = base64 + padding;
if (typeof Buffer !== 'undefined') {
return Buffer.from(padded, 'base64').toString('utf8');
}
if (typeof atob === 'function') {
return atob(padded);
}
throw new Error('No base64 decoder available.');
}
function decodeJwtPayload(token) {
const parts = String(token || '').split('.');
if (parts.length < 2) return null;
try {
return JSON.parse(decodeBase64Url(parts[1]));
} catch {
return null;
}
}
function parseCloudflareMailboxCredential(token) {
const jwtMatch = String(token || '').match(/[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+/);
if (!jwtMatch) return null;
const payload = decodeJwtPayload(jwtMatch[0]);
const email = normalizeEmail(payload?.address || payload?.email || '');
if (!email || !email.includes('@')) {
return null;
}
const [localPart, ...domainParts] = email.split('@');
const domain = domainParts.join('@');
return {
addressId: toFiniteNumber(payload?.address_id),
domain,
email,
localPart,
provenance: 'created',
};
}
function pickWord(words, randomFn) {
const randomValue = Math.max(0, Math.min(0.999999999999, Number(randomFn())));
return words[Math.floor(randomValue * words.length)] || words[0];
}
function pickReadableWord(bankIndex, randomFn) {
const randomValue = Math.max(0, Math.min(0.999999999999, Number(randomFn())));
const primaryWords = PRIMARY_LOCAL_PART_WORD_BANKS[bankIndex] || [];
const extendedWords = EXTENDED_LOCAL_PART_WORD_BANKS[bankIndex] || [];
const midExtendedWords = MID_EXTENDED_LOCAL_PART_WORD_BANKS[bankIndex] || [];
const topExtendedWords = TOP_EXTENDED_LOCAL_PART_WORD_BANKS[bankIndex] || [];
const highExtendedWords = HIGH_EXTENDED_LOCAL_PART_WORD_BANKS[bankIndex] || [];
const apexExtendedWords = APEX_EXTENDED_LOCAL_PART_WORD_BANKS[bankIndex] || [];
if (apexExtendedWords.length > 0 && randomValue >= APEX_EXTENDED_WORD_RANGE_START) {
const apexExtendedSpan = 1 - APEX_EXTENDED_WORD_RANGE_START;
const apexExtendedValue = Math.min(0.999999999999, (randomValue - APEX_EXTENDED_WORD_RANGE_START) / apexExtendedSpan);
return pickWord(apexExtendedWords, () => apexExtendedValue);
}
if (highExtendedWords.length > 0 && randomValue >= HIGH_EXTENDED_WORD_RANGE_START) {
const highExtendedSpan = APEX_EXTENDED_WORD_RANGE_START - HIGH_EXTENDED_WORD_RANGE_START;
const highExtendedValue = Math.min(0.999999999999, (randomValue - HIGH_EXTENDED_WORD_RANGE_START) / highExtendedSpan);
return pickWord(highExtendedWords, () => highExtendedValue);
}
if (topExtendedWords.length > 0 && randomValue >= TOP_EXTENDED_WORD_RANGE_START) {
const topExtendedSpan = HIGH_EXTENDED_WORD_RANGE_START - TOP_EXTENDED_WORD_RANGE_START;
const topExtendedValue = Math.min(0.999999999999, (randomValue - TOP_EXTENDED_WORD_RANGE_START) / topExtendedSpan);
return pickWord(topExtendedWords, () => topExtendedValue);
}
if (midExtendedWords.length > 0 && randomValue >= MID_EXTENDED_WORD_RANGE_START) {
const midExtendedSpan = TOP_EXTENDED_WORD_RANGE_START - MID_EXTENDED_WORD_RANGE_START;
const midExtendedValue = Math.min(0.999999999999, (randomValue - MID_EXTENDED_WORD_RANGE_START) / midExtendedSpan);
return pickWord(midExtendedWords, () => midExtendedValue);
}
if (extendedWords.length > 0 && randomValue >= BASE_EXTENDED_WORD_RANGE_START) {
const extendedSpan = MID_EXTENDED_WORD_RANGE_START - BASE_EXTENDED_WORD_RANGE_START;
const extendedValue = Math.min(0.999999999999, (randomValue - BASE_EXTENDED_WORD_RANGE_START) / extendedSpan);
return pickWord(extendedWords, () => extendedValue);
}
return pickWord(primaryWords, () => randomValue);
}
function generateReadableLocalPart(randomFn = Math.random, maxLength = 24) {
for (let attempt = 1; attempt <= 20; attempt++) {
const value = PRIMARY_LOCAL_PART_WORD_BANKS
.map((_, bankIndex) => pickReadableWord(bankIndex, randomFn))
.join('-');
if (value.length <= maxLength) {
return value;
}
}
return 'anew-dotted-latch';
}
function pickRandomSuffix(options = [], randomFn = Math.random) {
const seen = new Set();
const suffixes = [];
for (const option of options) {
const suffix = normalizeDomainSuffix(option);
if (!suffix || seen.has(suffix)) {
continue;
}
seen.add(suffix);
suffixes.push(suffix);
}
if (suffixes.length === 0) {
return '';
}
return pickWord(suffixes, randomFn);
}
function compareMessageIds(left, right) {
const leftNumber = toFiniteNumber(left);
const rightNumber = toFiniteNumber(right);
if (leftNumber !== null && rightNumber !== null) {
return rightNumber - leftNumber;
}
return String(right || '').localeCompare(String(left || ''));
}
function selectVerificationMessage(messages = [], options = {}) {
const targetEmail = normalizeEmail(options.targetEmail || '');
const senderFilters = (options.senderFilters || []).map(normalizeText);
const subjectFilters = (options.subjectFilters || []).map(normalizeText);
const filterAfterTimestamp = toFiniteNumber(options.filterAfterTimestamp) || 0;
const candidates = [];
for (const message of messages) {
const matchedEmail = normalizeEmail(message?.matchedEmail || message?.toEmail || '');
if (targetEmail && matchedEmail && matchedEmail !== targetEmail) {
continue;
}
if (targetEmail && !matchedEmail) {
continue;
}
const subject = normalizeWhitespace(message?.subject || '');
const combinedText = normalizeWhitespace(message?.combinedText || '');
const sender = normalizeText(message?.sender || '');
const searchText = normalizeText(`${subject} ${combinedText}`);
const code = extractVerificationCode(`${subject} ${combinedText}`);
if (!code) {
continue;
}
const senderMatch = senderFilters.length === 0
|| senderFilters.some((filter) => sender.includes(filter) || searchText.includes(filter));
const subjectMatch = subjectFilters.length === 0
|| subjectFilters.some((filter) => normalizeText(subject).includes(filter) || searchText.includes(filter));
if (!senderMatch && !subjectMatch) {
continue;
}
const emailTimestamp = toFiniteNumber(message?.emailTimestamp)
|| parseAdminTimestamp(message?.timestampText || '');
if (filterAfterTimestamp > 0 && (!emailTimestamp || emailTimestamp <= filterAfterTimestamp)) {
continue;
}
candidates.push({
code,
emailTimestamp: emailTimestamp || 0,
matchedEmail,
messageId: message?.messageId ?? null,
subject: subject || null,
});
}
candidates.sort((left, right) => {
if (left.emailTimestamp !== right.emailTimestamp) {
return right.emailTimestamp - left.emailTimestamp;
}
return compareMessageIds(left.messageId, right.messageId);
});
return candidates[0] || null;
}
const api = {
combineDistinctTextParts,
extractVerificationCode,
generateReadableLocalPart,
normalizeDomainSuffix,
parseAdminTimestamp,
parseCloudflareMailboxCredential,
pickRandomSuffix,
selectVerificationMessage,
};
globalScope.MultiPageCloudflareTempEmail = api;
if (typeof module !== 'undefined' && module.exports) {
module.exports = api;
}
})(typeof globalThis !== 'undefined' ? globalThis : this);
@@ -0,0 +1,99 @@
(function attachEmailProviderHelpers(globalScope) {
const DEFAULT_CLOUDFLARE_TEMP_EMAIL_ADMIN_URL = 'https://mail.cloudflare.com/admin';
const EMAIL_PROVIDER_DUCK = 'duckduckgo';
const EMAIL_PROVIDER_RELAY_FIREFOX = 'relay_firefox';
const EMAIL_PROVIDER_CLOUDFLARE_TEMP_EMAIL = 'cloudflare_temp_email';
function normalizeEmailProvider(value) {
if (value === EMAIL_PROVIDER_RELAY_FIREFOX) {
return EMAIL_PROVIDER_RELAY_FIREFOX;
}
if (value === EMAIL_PROVIDER_CLOUDFLARE_TEMP_EMAIL) {
return EMAIL_PROVIDER_CLOUDFLARE_TEMP_EMAIL;
}
return EMAIL_PROVIDER_DUCK;
}
function isRelayFirefoxProvider(value) {
return normalizeEmailProvider(value) === EMAIL_PROVIDER_RELAY_FIREFOX;
}
function isCloudflareTempEmailProvider(value) {
return normalizeEmailProvider(value) === EMAIL_PROVIDER_CLOUDFLARE_TEMP_EMAIL;
}
function getEmailProviderDisplayName(value) {
if (isRelayFirefoxProvider(value)) {
return 'Firefox Relay';
}
if (isCloudflareTempEmailProvider(value)) {
return 'Cloudflare Temp Email';
}
return 'DuckDuckGo';
}
function shouldUseEmailSourceForVerification(value) {
return isCloudflareTempEmailProvider(value);
}
function shouldSkipStep9Cleanup(value) {
return !isRelayFirefoxProvider(value);
}
function normalizeCloudflareTempEmailAdminUrl(value) {
const raw = String(value || '').trim();
if (!raw) {
return DEFAULT_CLOUDFLARE_TEMP_EMAIL_ADMIN_URL;
}
const candidate = /^[a-z][a-z0-9+.-]*:\/\//i.test(raw) ? raw : `https://${raw}`;
try {
const parsed = new URL(candidate);
parsed.pathname = parsed.pathname.replace(/\/+$/, '') || '/';
return parsed.toString();
} catch {
return DEFAULT_CLOUDFLARE_TEMP_EMAIL_ADMIN_URL;
}
}
function getNextRelayMaskLabel(labels = []) {
const used = new Set();
for (const rawLabel of labels) {
const match = String(rawLabel || '').trim().match(/^t(\d+)$/i);
if (!match) continue;
const nextValue = Number(match[1]);
if (Number.isInteger(nextValue) && nextValue > 0) {
used.add(nextValue);
}
}
let candidate = 1;
while (used.has(candidate)) {
candidate += 1;
}
return `t${candidate}`;
}
const api = {
DEFAULT_CLOUDFLARE_TEMP_EMAIL_ADMIN_URL,
EMAIL_PROVIDER_CLOUDFLARE_TEMP_EMAIL,
EMAIL_PROVIDER_DUCK,
EMAIL_PROVIDER_RELAY_FIREFOX,
getEmailProviderDisplayName,
getNextRelayMaskLabel,
isCloudflareTempEmailProvider,
isRelayFirefoxProvider,
normalizeCloudflareTempEmailAdminUrl,
normalizeEmailProvider,
shouldUseEmailSourceForVerification,
shouldSkipStep9Cleanup,
};
globalScope.MultiPageEmailProvider = api;
if (typeof module !== 'undefined' && module.exports) {
module.exports = api;
}
})(typeof globalThis !== 'undefined' ? globalThis : this);
@@ -0,0 +1,85 @@
(function attachOAuthFlowHelpers(globalScope) {
const EXACT_CONSENT_PATH = '/sign-in-with-chatgpt/codex/consent';
const SIGN_IN_WITH_CHATGPT_PATH_SEGMENT = '/sign-in-with-chatgpt/';
function parseUrl(input) {
if (!input || typeof input !== 'string') {
return null;
}
try {
return new URL(input);
} catch {
return null;
}
}
function isConsentUrl(url) {
const parsed = parseUrl(url);
if (!parsed) {
return false;
}
return parsed.pathname === EXACT_CONSENT_PATH;
}
function isConsentPageState(state = {}) {
const { hasVisibleContinueButton = false, url = '' } = state;
if (isConsentUrl(url)) {
return true;
}
const parsed = parseUrl(url);
if (!parsed) {
return false;
}
return parsed.pathname.includes(SIGN_IN_WITH_CHATGPT_PATH_SEGMENT) && Boolean(hasVisibleContinueButton);
}
function hasAnyConsentPageState(states = []) {
return states.some((state) => isConsentPageState(state));
}
function isLoopbackCallbackUrl(url) {
const parsed = parseUrl(url);
if (!parsed) {
return false;
}
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
return false;
}
return parsed.hostname === 'localhost'
|| parsed.hostname === '127.0.0.1'
|| parsed.hostname === '::1'
|| parsed.hostname === '[::1]';
}
function findLoopbackCallbackUrl(candidates = []) {
for (const candidate of candidates) {
if (isLoopbackCallbackUrl(candidate)) {
return candidate;
}
}
return null;
}
const api = {
EXACT_CONSENT_PATH,
SIGN_IN_WITH_CHATGPT_PATH_SEGMENT,
findLoopbackCallbackUrl,
hasAnyConsentPageState,
isConsentPageState,
isConsentUrl,
isLoopbackCallbackUrl,
};
globalScope.MultiPageOAuthFlow = api;
if (typeof module !== 'undefined' && module.exports) {
module.exports = api;
}
})(typeof globalThis !== 'undefined' ? globalThis : this);
@@ -0,0 +1,67 @@
(function attachQQMailHelpers(globalScope) {
function normalizeText(value) {
return (value || '').toLowerCase();
}
function extractVerificationCode(text) {
const matchCn = text.match(/(?:代码为|验证码[^0-9]*?)[\s:]*(\d{6})/);
if (matchCn) return matchCn[1];
const matchEn = text.match(/code[:\s]+is[:\s]+(\d{6})|code[:\s]+(\d{6})/i);
if (matchEn) return matchEn[1] || matchEn[2];
const match6 = text.match(/\b(\d{6})\b/);
if (match6) return match6[1];
return null;
}
function findNewQQVerificationCode(messages = [], options = {}) {
const existingMailIds = new Set(options.existingMailIds || []);
const senderFilters = options.senderFilters || [];
const subjectFilters = options.subjectFilters || [];
for (const message of messages) {
const mailId = message.mailId || '';
if (!mailId || existingMailIds.has(mailId)) {
continue;
}
const sender = normalizeText(message.sender);
const subject = normalizeText(message.subject);
const digest = message.digest || '';
const senderMatch = senderFilters.some((filter) => sender.includes(normalizeText(filter)));
const subjectMatch = subjectFilters.some((filter) => subject.includes(normalizeText(filter)));
if (!senderMatch && !subjectMatch) {
continue;
}
const code = extractVerificationCode(`${message.subject || ''} ${digest}`);
if (!code) {
continue;
}
return {
code,
mailId,
source: 'new',
subject: message.subject || '',
};
}
return null;
}
const api = {
extractVerificationCode,
findNewQQVerificationCode,
};
globalScope.MultiPageQQMail = api;
if (typeof module !== 'undefined' && module.exports) {
module.exports = api;
}
})(typeof globalThis !== 'undefined' ? globalThis : this);
@@ -0,0 +1,669 @@
/* ============================================================
MultiPage Automation — Side Panel
Design: Swiss Modernism + Developer Tool
Font: Inter (UI) + JetBrains Mono (code)
Themes: Light (default) + Dark (toggle)
============================================================ */
/* ---- Light Theme (default) ---- */
:root {
--bg-base: #ffffff;
--bg-surface: #f7f8fa;
--bg-elevated: #eef0f4;
--bg-hover: #e4e7ec;
--bg-active: #dce0e8;
--border: #d8dce3;
--border-subtle: #e8ecf1;
--text-primary: #1a1d24;
--text-secondary: #5c6370;
--text-muted: #9ca3af;
--blue: #2563eb;
--blue-soft: rgba(37, 99, 235, 0.08);
--blue-glow: rgba(37, 99, 235, 0.12);
--green: #16a34a;
--green-soft: rgba(22, 163, 74, 0.08);
--orange: #ea580c;
--orange-soft: rgba(234, 88, 12, 0.08);
--red: #dc2626;
--red-soft: rgba(220, 38, 38, 0.08);
--cyan: #0891b2;
--purple: #7c3aed;
--shadow-sm: 0 1px 2px rgba(0,0,0,0.04);
--shadow-md: 0 2px 6px rgba(0,0,0,0.06);
--radius-sm: 6px;
--radius-md: 8px;
--transition: 150ms ease;
}
/* ---- Dark Theme ---- */
[data-theme="dark"] {
--bg-base: #0f1117;
--bg-surface: #181a21;
--bg-elevated: #21242d;
--bg-hover: #2a2e38;
--bg-active: #323844;
--border: #2a2e38;
--border-subtle: #21242d;
--text-primary: #e4e6eb;
--text-secondary: #8b919e;
--text-muted: #565c6a;
--blue: #3b82f6;
--blue-soft: rgba(59, 130, 246, 0.12);
--blue-glow: rgba(59, 130, 246, 0.18);
--green: #22c55e;
--green-soft: rgba(34, 197, 94, 0.12);
--orange: #f97316;
--orange-soft: rgba(249, 115, 22, 0.12);
--red: #ef4444;
--red-soft: rgba(239, 68, 68, 0.12);
--cyan: #06b6d4;
--purple: #a78bfa;
--shadow-sm: 0 1px 2px rgba(0,0,0,0.2);
--shadow-md: 0 2px 8px rgba(0,0,0,0.3);
}
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: 'Inter', -apple-system, BlinkMacSystemFont, sans-serif;
font-size: 14px;
color: var(--text-primary);
background: var(--bg-base);
padding: 12px;
width: 100%;
min-height: 100vh;
-webkit-font-smoothing: antialiased;
transition: background var(--transition), color var(--transition);
}
/* ============================================================
Header
============================================================ */
header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 14px;
padding-bottom: 12px;
border-bottom: 1px solid var(--border-subtle);
}
.header-left {
display: flex;
align-items: center;
gap: 8px;
}
.header-left svg { color: var(--blue); }
.header-left h1 {
font-size: 16px;
font-weight: 700;
letter-spacing: -0.02em;
color: var(--text-primary);
}
.header-btns {
display: flex;
align-items: center;
gap: 4px;
}
/* ============================================================
Theme Toggle
============================================================ */
.theme-toggle {
width: 30px;
height: 30px;
border: none;
background: transparent;
color: var(--text-muted);
border-radius: var(--radius-sm);
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: all var(--transition);
}
.theme-toggle:hover { background: var(--bg-hover); color: var(--text-primary); }
.theme-toggle .icon-moon { display: block; }
.theme-toggle .icon-sun { display: none; }
[data-theme="dark"] .theme-toggle .icon-moon { display: none; }
[data-theme="dark"] .theme-toggle .icon-sun { display: block; }
/* ============================================================
Buttons
============================================================ */
.btn {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 7px 14px;
font-family: inherit;
font-size: 13px;
font-weight: 600;
border: 1px solid transparent;
border-radius: var(--radius-sm);
cursor: pointer;
transition: all var(--transition);
white-space: nowrap;
}
.btn-primary {
background: var(--blue);
color: #fff;
}
.btn-primary:hover { opacity: 0.9; box-shadow: 0 2px 8px var(--blue-glow); }
.btn-success {
background: var(--green);
color: #fff;
}
.btn-success:hover { opacity: 0.9; box-shadow: 0 2px 8px var(--green-soft); }
.btn-danger {
background: var(--red);
color: #fff;
}
.btn-danger:hover { opacity: 0.9; box-shadow: 0 2px 8px var(--red-soft); }
.run-group {
display: flex;
align-items: center;
gap: 4px;
}
.run-count-input {
width: 42px;
padding: 6px 4px;
text-align: center;
background: var(--bg-base);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
color: var(--text-primary);
font-family: 'JetBrains Mono', monospace;
font-size: 13px;
font-weight: 600;
outline: none;
}
.run-count-input:focus { border-color: var(--blue); }
.run-count-input::-webkit-inner-spin-button { opacity: 0.5; }
.btn-success:disabled, .btn-primary:disabled, .btn-danger:disabled { background: var(--bg-elevated); color: var(--text-muted); cursor: not-allowed; box-shadow: none; }
.run-count-input:disabled { opacity: 0.5; cursor: not-allowed; }
.btn-ghost {
background: transparent;
color: var(--text-secondary);
padding: 6px;
border-radius: var(--radius-sm);
}
.btn-ghost:hover { background: var(--bg-hover); color: var(--text-primary); }
.btn-outline {
background: transparent;
border: 1px solid var(--border);
color: var(--text-secondary);
}
.btn-outline:hover { border-color: var(--blue); color: var(--blue); background: var(--blue-soft); }
.btn-sm { padding: 5px 12px; font-size: 12px; }
.btn-xs { padding: 4px 10px; font-size: 11px; }
/* ============================================================
Data Card
============================================================ */
#data-section { margin-bottom: 14px; }
.data-card {
background: var(--bg-surface);
border: 1px solid var(--border);
border-radius: var(--radius-md);
padding: 12px 14px;
display: flex;
flex-direction: column;
gap: 9px;
box-shadow: var(--shadow-sm);
}
.data-row {
display: flex;
align-items: center;
gap: 8px;
}
.data-inline {
display: flex;
align-items: center;
gap: 8px;
flex: 1;
min-width: 0;
}
.data-label {
width: 56px;
font-size: 11px;
font-weight: 700;
color: var(--text-muted);
text-transform: uppercase;
letter-spacing: 0.06em;
flex-shrink: 0;
}
.data-value {
font-size: 13px;
color: var(--text-muted);
min-width: 0;
}
.data-value.has-value { color: var(--text-primary); }
.mono {
font-family: 'JetBrains Mono', 'Consolas', monospace;
font-size: 12px;
}
.truncate {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.data-input {
flex: 1;
padding: 7px 10px;
background: var(--bg-base);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
color: var(--text-primary);
font-family: 'JetBrains Mono', monospace;
font-size: 13px;
outline: none;
transition: border-color var(--transition), box-shadow var(--transition);
min-width: 0;
}
.data-input::placeholder { color: var(--text-muted); }
.data-input:focus { border-color: var(--blue); box-shadow: 0 0 0 3px var(--blue-soft); }
#btn-fetch-email {
padding-inline: 10px;
}
#btn-toggle-password {
min-width: 58px;
padding-inline: 10px;
}
.data-select {
flex: 1;
padding: 7px 10px;
background: var(--bg-base);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
color: var(--text-primary);
font-family: inherit;
font-size: 13px;
outline: none;
cursor: pointer;
transition: border-color var(--transition);
min-width: 0;
}
.data-select:focus { border-color: var(--blue); box-shadow: 0 0 0 3px var(--blue-soft); }
[data-theme="dark"] .data-select { color-scheme: dark; }
/* Status Bar */
.status-bar {
display: flex;
align-items: center;
gap: 8px;
margin-top: 8px;
padding: 8px 12px;
background: var(--bg-surface);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
font-size: 13px;
font-weight: 500;
color: var(--text-secondary);
box-shadow: var(--shadow-sm);
}
.status-dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: var(--text-muted);
flex-shrink: 0;
transition: background var(--transition);
}
.status-bar.running .status-dot {
background: var(--orange);
animation: pulse 1.5s ease-in-out infinite;
}
.status-bar.running { color: var(--orange); }
.status-bar.completed .status-dot { background: var(--green); }
.status-bar.completed { color: var(--green); }
.status-bar.failed .status-dot { background: var(--red); }
.status-bar.failed { color: var(--red); }
.status-bar.stopped .status-dot { background: var(--cyan); }
.status-bar.stopped { color: var(--cyan); }
@keyframes pulse {
0%, 100% { opacity: 1; transform: scale(1); }
50% { opacity: 0.4; transform: scale(0.85); }
}
/* Auto Continue Bar */
.auto-continue-bar {
margin-top: 8px;
padding: 8px 10px;
background: var(--orange-soft);
border: 1px solid rgba(234, 88, 12, 0.2);
border-radius: var(--radius-sm);
display: flex;
align-items: center;
gap: 8px;
}
.auto-continue-bar svg { color: var(--orange); flex-shrink: 0; }
.auto-hint { font-size: 13px; color: var(--orange); flex: 1; font-weight: 500; }
/* ============================================================
Steps Section
============================================================ */
#steps-section { margin-bottom: 14px; }
.steps-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 8px;
}
.section-label {
font-size: 11px;
font-weight: 700;
color: var(--text-muted);
text-transform: uppercase;
letter-spacing: 0.08em;
}
.steps-progress {
font-family: 'JetBrains Mono', monospace;
font-size: 11px;
font-weight: 600;
color: var(--text-muted);
background: var(--bg-surface);
padding: 2px 8px;
border-radius: 10px;
border: 1px solid var(--border);
}
.steps-list {
display: flex;
flex-direction: column;
gap: 5px;
}
.step-row {
display: flex;
align-items: center;
gap: 8px;
padding: 1px 0;
transition: opacity var(--transition);
}
/* Step Number Indicator */
.step-indicator {
width: 26px;
height: 26px;
border-radius: 50%;
background: var(--bg-surface);
border: 1.5px solid var(--border);
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
transition: all var(--transition);
}
.step-num {
font-size: 11px;
font-weight: 700;
color: var(--text-muted);
transition: color var(--transition);
}
.step-row.running .step-indicator { border-color: var(--orange); background: var(--orange-soft); }
.step-row.running .step-num { color: var(--orange); }
.step-row.running .step-indicator { animation: pulse 1.5s ease-in-out infinite; }
.step-row.completed .step-indicator { border-color: var(--green); background: var(--green-soft); }
.step-row.completed .step-num { color: var(--green); }
.step-row.failed .step-indicator { border-color: var(--red); background: var(--red-soft); }
.step-row.failed .step-num { color: var(--red); }
.step-row.stopped .step-indicator { border-color: var(--cyan); background: rgba(8, 145, 178, 0.08); }
.step-row.stopped .step-num { color: var(--cyan); }
/* Step Button */
.step-btn {
flex: 1;
padding: 8px 12px;
font-family: inherit;
font-size: 13px;
font-weight: 500;
color: var(--text-primary);
background: var(--bg-surface);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
cursor: pointer;
text-align: left;
transition: all var(--transition);
box-shadow: var(--shadow-sm);
}
.step-btn:hover:not(:disabled) { background: var(--bg-hover); border-color: var(--blue); }
.step-btn:disabled { color: var(--text-muted); background: var(--bg-base); border-color: var(--border-subtle); cursor: not-allowed; opacity: 0.45; box-shadow: none; }
.step-row.running .step-btn { border-color: var(--orange); color: var(--orange); }
.step-row.completed .step-btn { border-color: var(--border-subtle); color: var(--text-secondary); opacity: 0.7; }
.step-row.failed .step-btn { border-color: var(--red); color: var(--red); }
.step-row.stopped .step-btn { border-color: var(--cyan); color: var(--cyan); }
.step-status {
width: 20px;
text-align: center;
font-size: 14px;
font-weight: 700;
flex-shrink: 0;
}
.step-row.completed .step-status { color: var(--green); }
.step-row.failed .step-status { color: var(--red); }
.step-row.stopped .step-status { color: var(--cyan); }
/* ============================================================
Log / Console Section
============================================================ */
.log-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 6px;
}
#log-area {
background: var(--bg-surface);
border: 1px solid var(--border);
border-radius: var(--radius-md);
padding: 10px 12px;
height: 220px;
overflow-y: auto;
font-family: 'JetBrains Mono', 'Consolas', monospace;
font-size: 12px;
line-height: 1.7;
color: var(--text-secondary);
box-shadow: var(--shadow-sm);
}
#log-area::-webkit-scrollbar { width: 5px; }
#log-area::-webkit-scrollbar-track { background: transparent; }
#log-area::-webkit-scrollbar-thumb { background: var(--bg-elevated); border-radius: 4px; }
#log-area::-webkit-scrollbar-thumb:hover { background: var(--text-muted); }
.log-line { padding: 2.5px 0; }
.log-line + .log-line { border-top: 1px solid var(--border-subtle); }
.log-time { color: var(--text-muted); }
.log-level { font-weight: 700; margin: 0 2px; }
.log-level-info { color: var(--blue); }
.log-level-ok { color: var(--green); }
.log-level-warn { color: var(--orange); }
.log-level-error { color: var(--red); }
.log-step-tag {
display: inline-block;
padding: 1px 5px;
border-radius: 3px;
font-weight: 700;
font-size: 11px;
margin-right: 3px;
}
.log-step-tag.step-1 { color: var(--cyan); background: rgba(8, 145, 178, 0.08); }
.log-step-tag.step-2 { color: var(--purple); background: rgba(124, 58, 237, 0.08); }
.log-step-tag.step-3 { color: #b45309; background: rgba(180, 83, 9, 0.06); }
[data-theme="dark"] .log-step-tag.step-3 { color: #fbbf24; background: rgba(251, 191, 36, 0.1); }
.log-step-tag.step-4 { color: var(--orange); background: var(--orange-soft); }
.log-step-tag.step-5 { color: var(--green); background: var(--green-soft); }
.log-step-tag.step-6 { color: var(--cyan); background: rgba(8, 145, 178, 0.08); }
.log-step-tag.step-7 { color: var(--orange); background: var(--orange-soft); }
.log-step-tag.step-8 { color: var(--purple); background: rgba(124, 58, 237, 0.08); }
.log-step-tag.step-9 { color: var(--green); background: var(--green-soft); }
.log-msg { color: var(--text-secondary); }
.log-line.log-ok .log-msg { color: var(--green); font-weight: 500; }
.log-line.log-error .log-msg { color: var(--red); font-weight: 500; }
.log-line.log-warn .log-msg { color: var(--orange); }
/* ============================================================
Animations
============================================================ */
@keyframes fadeIn {
from { opacity: 0; transform: translateY(3px); }
to { opacity: 1; transform: translateY(0); }
}
.log-line { animation: fadeIn 120ms ease-out; }
/* ============================================================
Toast Notifications
============================================================ */
#toast-container {
position: fixed;
top: 12px;
left: 12px;
right: 12px;
z-index: 1000;
display: flex;
flex-direction: column;
gap: 6px;
pointer-events: none;
}
.toast {
display: flex;
align-items: center;
gap: 8px;
padding: 10px 14px;
border-radius: var(--radius-md);
font-size: 13px;
font-weight: 500;
box-shadow: var(--shadow-md), 0 4px 12px rgba(0,0,0,0.1);
pointer-events: auto;
animation: toastIn 250ms ease-out;
border: 1px solid;
}
.toast.toast-exit {
animation: toastOut 200ms ease-in forwards;
}
.toast-error {
background: var(--red-soft);
border-color: var(--red);
color: var(--red);
}
.toast-warn {
background: var(--orange-soft);
border-color: var(--orange);
color: var(--orange);
}
.toast-success {
background: var(--green-soft);
border-color: var(--green);
color: var(--green);
}
.toast-info {
background: var(--blue-soft);
border-color: var(--blue);
color: var(--blue);
}
.toast svg {
flex-shrink: 0;
}
.toast-msg {
flex: 1;
}
.toast-close {
background: none;
border: none;
color: inherit;
opacity: 0.6;
cursor: pointer;
padding: 2px;
font-size: 16px;
line-height: 1;
}
.toast-close:hover { opacity: 1; }
@keyframes toastIn {
from { opacity: 0; transform: translateY(-10px) scale(0.96); }
to { opacity: 1; transform: translateY(0) scale(1); }
}
@keyframes toastOut {
from { opacity: 1; transform: translateY(0) scale(1); }
to { opacity: 0; transform: translateY(-10px) scale(0.96); }
}
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after {
animation-duration: 0.01ms !important;
transition-duration: 0.01ms !important;
}
}
@@ -0,0 +1,179 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Multi-Page Automation</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">
<link rel="stylesheet" href="sidepanel.css">
</head>
<body>
<header>
<div class="header-left">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M13 2L3 14h9l-1 8 10-12h-9l1-8z"/>
</svg>
<h1>MultiPage</h1>
</div>
<div class="header-btns">
<div class="run-group">
<input type="number" id="input-run-count" class="run-count-input" value="1" min="1" max="50" title="Number of runs" />
<button id="btn-auto-run" class="btn btn-success" title="Run all steps automatically">
<svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor"><polygon points="5 3 19 12 5 21 5 3"/></svg>
Auto
</button>
<button id="btn-stop" class="btn btn-danger" title="Stop current flow" disabled>Stop</button>
</div>
<button id="btn-reset" class="btn btn-ghost" title="Reset all steps">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<polyline points="1 4 1 10 7 10"/><path d="M3.51 15a9 9 0 1 0 2.13-9.36L1 10"/>
</svg>
</button>
<button id="btn-theme" class="theme-toggle" title="Toggle theme">
<svg class="icon-moon" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"/>
</svg>
<svg class="icon-sun" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<circle cx="12" cy="12" r="5"/><line x1="12" y1="1" x2="12" y2="3"/><line x1="12" y1="21" x2="12" y2="23"/><line x1="4.22" y1="4.22" x2="5.64" y2="5.64"/><line x1="18.36" y1="18.36" x2="19.78" y2="19.78"/><line x1="1" y1="12" x2="3" y2="12"/><line x1="21" y1="12" x2="23" y2="12"/><line x1="4.22" y1="19.78" x2="5.64" y2="18.36"/><line x1="18.36" y1="5.64" x2="19.78" y2="4.22"/>
</svg>
</button>
</div>
</header>
<section id="data-section">
<div class="data-card">
<div class="data-row">
<span class="data-label">VPS</span>
<input type="password" id="input-vps-url" class="data-input" placeholder="http://ip:port/management.html#/oauth" />
</div>
<div class="data-row">
<span class="data-label">Mail</span>
<select id="select-mail-provider" class="data-select">
<option value="163">163 Mail (mail.163.com)</option>
<option value="qq">QQ Mail (wx.mail.qq.com)</option>
<option value="inbucket">Inbucket (custom host)</option>
</select>
</div>
<div class="data-row">
<span class="data-label">Source</span>
<select id="select-email-provider" class="data-select">
<option value="duckduckgo">duckduckgo</option>
<option value="cloudflare_temp_email">cloudflare_temp_email</option>
<option value="relay_firefox">relay_firefox</option>
</select>
</div>
<div class="data-row" id="row-cloudflare-temp-email-url" style="display:none;">
<span class="data-label">Cloudflare</span>
<input type="text" id="input-cloudflare-temp-email-url" class="data-input" placeholder="https://mail.cloudflare.com/admin" />
</div>
<div class="data-row" id="row-inbucket-host" style="display:none;">
<span class="data-label">Inbucket</span>
<input type="text" id="input-inbucket-host" class="data-input" placeholder="your-inbucket-host or https://your-inbucket-host" />
</div>
<div class="data-row" id="row-inbucket-mailbox" style="display:none;">
<span class="data-label">Mailbox</span>
<input type="text" id="input-inbucket-mailbox" class="data-input" placeholder="e.g. zju2001" />
</div>
<div class="data-row">
<span class="data-label">Email</span>
<div class="data-inline">
<input type="text" id="input-email" class="data-input" placeholder="Paste signup email" />
<button id="btn-fetch-email" class="btn btn-outline btn-sm" type="button">Auto</button>
</div>
</div>
<div class="data-row">
<span class="data-label">Password</span>
<div class="data-inline">
<input type="password" id="input-password" class="data-input" placeholder="Leave blank to auto-generate" />
<button id="btn-toggle-password" class="btn btn-outline btn-sm" type="button">Show</button>
</div>
</div>
<div class="data-row">
<span class="data-label">OAuth</span>
<span id="display-oauth-url" class="data-value mono truncate">Waiting...</span>
</div>
<div class="data-row">
<span class="data-label">Callback</span>
<span id="display-localhost-url" class="data-value mono truncate">Waiting...</span>
</div>
</div>
<div id="status-bar" class="status-bar">
<div class="status-dot"></div>
<span id="display-status">Ready</span>
</div>
<div id="auto-continue-bar" class="auto-continue-bar" style="display:none;">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><line x1="12" y1="8" x2="12" y2="12"/><line x1="12" y1="16" x2="12.01" y2="16"/></svg>
<span id="auto-hint" class="auto-hint">Use Auto to fetch email, or paste manually, then continue</span>
<button id="btn-auto-continue" class="btn btn-primary btn-sm">Continue</button>
</div>
</section>
<section id="steps-section">
<div class="steps-header">
<span class="section-label">Workflow</span>
<span id="steps-progress" class="steps-progress">0 / 9</span>
</div>
<div class="steps-list">
<div class="step-row" data-step="1">
<div class="step-indicator" data-step="1"><span class="step-num">1</span></div>
<button class="step-btn" data-step="1">Get OAuth Link</button>
<span class="step-status" data-step="1"></span>
</div>
<div class="step-row" data-step="2">
<div class="step-indicator" data-step="2"><span class="step-num">2</span></div>
<button class="step-btn" data-step="2">Open Signup</button>
<span class="step-status" data-step="2"></span>
</div>
<div class="step-row" data-step="3">
<div class="step-indicator" data-step="3"><span class="step-num">3</span></div>
<button class="step-btn" data-step="3">Fill Email / Password</button>
<span class="step-status" data-step="3"></span>
</div>
<div class="step-row" data-step="4">
<div class="step-indicator" data-step="4"><span class="step-num">4</span></div>
<button class="step-btn" data-step="4">Get Signup Code</button>
<span class="step-status" data-step="4"></span>
</div>
<div class="step-row" data-step="5">
<div class="step-indicator" data-step="5"><span class="step-num">5</span></div>
<button class="step-btn" data-step="5">Fill Name / Birthday</button>
<span class="step-status" data-step="5"></span>
</div>
<div class="step-row" data-step="6">
<div class="step-indicator" data-step="6"><span class="step-num">6</span></div>
<button class="step-btn" data-step="6">Login via OAuth</button>
<span class="step-status" data-step="6"></span>
</div>
<div class="step-row" data-step="7">
<div class="step-indicator" data-step="7"><span class="step-num">7</span></div>
<button class="step-btn" data-step="7">Get Login Code</button>
<span class="step-status" data-step="7"></span>
</div>
<div class="step-row" data-step="8">
<div class="step-indicator" data-step="8"><span class="step-num">8</span></div>
<button class="step-btn" data-step="8">OAuth Auto Confirm</button>
<span class="step-status" data-step="8"></span>
</div>
<div class="step-row" data-step="9">
<div class="step-indicator" data-step="9"><span class="step-num">9</span></div>
<button class="step-btn" data-step="9">Cleanup Email</button>
<span class="step-status" data-step="9"></span>
</div>
</div>
</section>
<section id="log-section">
<div class="log-header">
<span class="section-label">Console</span>
<button id="btn-clear-log" class="btn btn-ghost btn-xs" title="Clear log">Clear</button>
</div>
<div id="log-area"></div>
</section>
<div id="toast-container"></div>
<script src="../shared/email-provider.js"></script>
<script src="sidepanel.js"></script>
</body>
</html>
@@ -0,0 +1,636 @@
// sidepanel/sidepanel.js — Side Panel logic
const STATUS_ICONS = {
pending: '',
running: '',
completed: '\u2713', // ✓
failed: '\u2717', // ✗
stopped: '\u25A0', // ■
};
const logArea = document.getElementById('log-area');
const displayOauthUrl = document.getElementById('display-oauth-url');
const displayLocalhostUrl = document.getElementById('display-localhost-url');
const displayStatus = document.getElementById('display-status');
const statusBar = document.getElementById('status-bar');
const inputEmail = document.getElementById('input-email');
const inputPassword = document.getElementById('input-password');
const btnFetchEmail = document.getElementById('btn-fetch-email');
const autoHint = document.getElementById('auto-hint');
const btnTogglePassword = document.getElementById('btn-toggle-password');
const btnStop = document.getElementById('btn-stop');
const btnReset = document.getElementById('btn-reset');
const stepsProgress = document.getElementById('steps-progress');
const btnAutoRun = document.getElementById('btn-auto-run');
const btnAutoContinue = document.getElementById('btn-auto-continue');
const autoContinueBar = document.getElementById('auto-continue-bar');
const btnClearLog = document.getElementById('btn-clear-log');
const inputVpsUrl = document.getElementById('input-vps-url');
const selectMailProvider = document.getElementById('select-mail-provider');
const selectEmailProvider = document.getElementById('select-email-provider');
const rowCloudflareTempEmailUrl = document.getElementById('row-cloudflare-temp-email-url');
const inputCloudflareTempEmailUrl = document.getElementById('input-cloudflare-temp-email-url');
const rowInbucketHost = document.getElementById('row-inbucket-host');
const inputInbucketHost = document.getElementById('input-inbucket-host');
const rowInbucketMailbox = document.getElementById('row-inbucket-mailbox');
const inputInbucketMailbox = document.getElementById('input-inbucket-mailbox');
const inputRunCount = document.getElementById('input-run-count');
const {
DEFAULT_CLOUDFLARE_TEMP_EMAIL_ADMIN_URL = 'https://mail.cloudflare.com/admin',
EMAIL_PROVIDER_CLOUDFLARE_TEMP_EMAIL = 'cloudflare_temp_email',
EMAIL_PROVIDER_DUCK = 'duckduckgo',
EMAIL_PROVIDER_RELAY_FIREFOX = 'relay_firefox',
getEmailProviderDisplayName = (value) => value === 'relay_firefox'
? 'Firefox Relay'
: value === 'cloudflare_temp_email'
? 'Cloudflare Temp Email'
: 'DuckDuckGo',
normalizeCloudflareTempEmailAdminUrl = (value) => value || DEFAULT_CLOUDFLARE_TEMP_EMAIL_ADMIN_URL,
normalizeEmailProvider = (value) => {
if (value === 'relay_firefox') return 'relay_firefox';
if (value === 'cloudflare_temp_email') return 'cloudflare_temp_email';
return 'duckduckgo';
},
} = globalThis.MultiPageEmailProvider || {};
// ============================================================
// Toast Notifications
// ============================================================
const toastContainer = document.getElementById('toast-container');
const TOAST_ICONS = {
error: '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><line x1="15" y1="9" x2="9" y2="15"/><line x1="9" y1="9" x2="15" y2="15"/></svg>',
warn: '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z"/><line x1="12" y1="9" x2="12" y2="13"/><line x1="12" y1="17" x2="12.01" y2="17"/></svg>',
success: '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M22 11.08V12a10 10 0 1 1-5.93-9.14"/><polyline points="22 4 12 14.01 9 11.01"/></svg>',
info: '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><line x1="12" y1="16" x2="12" y2="12"/><line x1="12" y1="8" x2="12.01" y2="8"/></svg>',
};
function showToast(message, type = 'error', duration = 4000) {
const toast = document.createElement('div');
toast.className = `toast toast-${type}`;
toast.innerHTML = `${TOAST_ICONS[type] || ''}<span class="toast-msg">${escapeHtml(message)}</span><button class="toast-close">&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();
});
@@ -0,0 +1,221 @@
const test = require('node:test');
const assert = require('node:assert/strict');
const {
combineDistinctTextParts,
extractVerificationCode,
generateReadableLocalPart,
parseAdminTimestamp,
parseCloudflareMailboxCredential,
pickRandomSuffix,
selectVerificationMessage,
} = require('../shared/cloudflare-temp-email.js');
function createJwt(payload) {
const header = Buffer.from(JSON.stringify({ alg: 'HS256', typ: 'JWT' })).toString('base64url');
const body = Buffer.from(JSON.stringify(payload)).toString('base64url');
return `${header}.${body}.signature`;
}
test('parseCloudflareMailboxCredential decodes email and address id from JWT token', () => {
const token = createJwt({
address: 'newmask@co.example.test',
address_id: 42,
});
assert.deepEqual(parseCloudflareMailboxCredential(token), {
addressId: 42,
domain: 'co.example.test',
email: 'newmask@co.example.test',
localPart: 'newmask',
provenance: 'created',
});
});
test('parseAdminTimestamp parses admin timestamps into epoch milliseconds', () => {
const value = parseAdminTimestamp('2026/4/7 10:33:07');
assert.equal(Number.isFinite(value), true);
assert.equal(new Date(value).getFullYear(), 2026);
});
test('extractVerificationCode reads six-digit codes from mixed-language subjects', () => {
assert.equal(extractVerificationCode('Your ChatGPT code is 377680'), '377680');
assert.equal(extractVerificationCode('你的 ChatGPT 代码为 479637,请勿泄露。'), '479637');
});
test('combineDistinctTextParts collapses duplicated text fragments from DOM sources', () => {
const value = combineDistinctTextParts([
'账号',
'账号',
' 账号 ',
'',
null,
]);
assert.equal(value, '账号');
});
test('combineDistinctTextParts keeps distinct fragments in order', () => {
const value = combineDistinctTextParts([
'邮箱地址凭证',
'token-value',
'token-value',
'关闭',
]);
assert.equal(value, '邮箱地址凭证 token-value 关闭');
});
test('generateReadableLocalPart creates three lowercase hyphenated words', () => {
const value = generateReadableLocalPart(() => 0);
assert.match(value, /^[a-z]+-[a-z]+-[a-z]+$/);
assert.equal(value.split('-').length, 3);
});
test('generateReadableLocalPart is deterministic for a fixed random sequence', () => {
const sequence = [0.02, 0.31, 0.58];
let index = 0;
const value = generateReadableLocalPart(() => sequence[index++]);
assert.equal(value, 'anew-dotted-latch');
});
test('generateReadableLocalPart retries until the generated local part fits the max length', () => {
const sequence = [
0.98, 0.98, 0.98,
0.02, 0.31, 0.58,
];
let index = 0;
const value = generateReadableLocalPart(() => sequence[index++], 20);
assert.equal(value.length <= 20, true);
assert.equal(value, 'anew-dotted-latch');
});
test('generateReadableLocalPart can use newly added higher-range words', () => {
const sequence = [0.9, 0.9, 0.9];
let index = 0;
const value = generateReadableLocalPart(() => sequence[index++]);
assert.equal(value, 'vantage-velvet-zephyr');
});
test('generateReadableLocalPart can use extended top-range words', () => {
const sequence = [0.97, 0.97, 0.97];
let index = 0;
const value = generateReadableLocalPart(() => sequence[index++]);
assert.equal(value, 'whimsy-marbled-solstice');
});
test('generateReadableLocalPart can use extended mid-range words', () => {
const sequence = [0.962, 0.962, 0.962];
let index = 0;
const value = generateReadableLocalPart(() => sequence[index++]);
assert.equal(value, 'ivory-rusted-starling');
});
test('generateReadableLocalPart can use extended apex-range words', () => {
const sequence = [0.993, 0.993, 0.993];
let index = 0;
const value = generateReadableLocalPart(() => sequence[index++]);
assert.equal(value, 'atlas-bronze-cosmos');
});
test('pickRandomSuffix selects a deterministic suffix from the available options', () => {
const value = pickRandomSuffix([
'co.example.test',
'de.example.test',
'ice.example.test',
'work.example.test',
], () => 0.74);
assert.equal(value, 'ice.example.test');
});
test('pickRandomSuffix ignores empty and duplicate suffix values', () => {
const value = pickRandomSuffix([
' co.example.test ',
'',
null,
'de.example.test',
'@ICE.example.test',
'de.example.test',
], () => 0.9);
assert.equal(value, 'ice.example.test');
});
test('selectVerificationMessage ignores messages for other recipients', () => {
const result = selectVerificationMessage([
{
combinedText: 'Your ChatGPT code is 123456',
emailTimestamp: parseAdminTimestamp('2026/4/7 10:33:07'),
matchedEmail: 'someone-else@co.example.test',
messageId: '11',
subject: 'Your ChatGPT code is 123456',
},
], {
filterAfterTimestamp: 0,
senderFilters: ['openai'],
subjectFilters: ['code'],
targetEmail: 'target@co.example.test',
});
assert.equal(result, null);
});
test('selectVerificationMessage requires a strictly newer timestamp than filterAfterTimestamp', () => {
const ts = parseAdminTimestamp('2026/4/7 10:33:07');
const result = selectVerificationMessage([
{
combinedText: 'Enter this temporary verification code to continue: 377680',
emailTimestamp: ts,
matchedEmail: 'target@co.example.test',
messageId: '11',
subject: 'Your ChatGPT code is 377680',
},
], {
filterAfterTimestamp: ts,
senderFilters: ['openai'],
subjectFilters: ['code'],
targetEmail: 'target@co.example.test',
});
assert.equal(result, null);
});
test('selectVerificationMessage picks the newest matching message after the threshold', () => {
const result = selectVerificationMessage([
{
combinedText: '旧验证码 111111',
emailTimestamp: parseAdminTimestamp('2026/4/7 10:30:00'),
matchedEmail: 'target@co.example.test',
messageId: '8',
subject: 'Your ChatGPT code is 111111',
},
{
combinedText: 'Enter this temporary verification code to continue: 377680',
emailTimestamp: parseAdminTimestamp('2026/4/7 10:33:07'),
matchedEmail: 'target@co.example.test',
messageId: '11',
subject: 'Your ChatGPT code is 377680',
},
], {
filterAfterTimestamp: parseAdminTimestamp('2026/4/7 10:31:00'),
senderFilters: ['openai'],
subjectFilters: ['code'],
targetEmail: 'target@co.example.test',
});
assert.deepEqual(result, {
code: '377680',
emailTimestamp: parseAdminTimestamp('2026/4/7 10:33:07'),
matchedEmail: 'target@co.example.test',
messageId: '11',
subject: 'Your ChatGPT code is 377680',
});
});
@@ -0,0 +1,106 @@
const test = require('node:test');
const assert = require('node:assert/strict');
const {
DEFAULT_CLOUDFLARE_TEMP_EMAIL_ADMIN_URL,
EMAIL_PROVIDER_DUCK,
EMAIL_PROVIDER_CLOUDFLARE_TEMP_EMAIL,
EMAIL_PROVIDER_RELAY_FIREFOX,
getEmailProviderDisplayName,
getNextRelayMaskLabel,
isCloudflareTempEmailProvider,
normalizeCloudflareTempEmailAdminUrl,
normalizeEmailProvider,
shouldUseEmailSourceForVerification,
shouldSkipStep9Cleanup,
} = require('../shared/email-provider.js');
test('normalizeEmailProvider keeps relay_firefox as-is', () => {
assert.equal(normalizeEmailProvider('relay_firefox'), EMAIL_PROVIDER_RELAY_FIREFOX);
});
test('normalizeEmailProvider keeps cloudflare_temp_email as-is', () => {
assert.equal(
normalizeEmailProvider('cloudflare_temp_email'),
EMAIL_PROVIDER_CLOUDFLARE_TEMP_EMAIL
);
});
test('normalizeEmailProvider falls back to duckduckgo for unknown values', () => {
assert.equal(normalizeEmailProvider('something-else'), EMAIL_PROVIDER_DUCK);
});
test('isCloudflareTempEmailProvider identifies cloudflare_temp_email', () => {
assert.equal(isCloudflareTempEmailProvider('cloudflare_temp_email'), true);
});
test('isCloudflareTempEmailProvider rejects relay_firefox', () => {
assert.equal(isCloudflareTempEmailProvider('relay_firefox'), false);
});
test('getEmailProviderDisplayName returns Cloudflare Temp Email label', () => {
assert.equal(
getEmailProviderDisplayName('cloudflare_temp_email'),
'Cloudflare Temp Email'
);
});
test('DEFAULT_CLOUDFLARE_TEMP_EMAIL_ADMIN_URL uses the public open-source-safe admin URL', () => {
assert.equal(
DEFAULT_CLOUDFLARE_TEMP_EMAIL_ADMIN_URL,
'https://mail.cloudflare.com/admin'
);
});
test('normalizeCloudflareTempEmailAdminUrl falls back to the default URL for empty values', () => {
assert.equal(
normalizeCloudflareTempEmailAdminUrl(''),
DEFAULT_CLOUDFLARE_TEMP_EMAIL_ADMIN_URL
);
});
test('normalizeCloudflareTempEmailAdminUrl trims whitespace and prepends https when protocol is missing', () => {
assert.equal(
normalizeCloudflareTempEmailAdminUrl(' custom.example.com/admin '),
'https://custom.example.com/admin'
);
});
test('normalizeCloudflareTempEmailAdminUrl normalizes the default admin URL path', () => {
assert.equal(
normalizeCloudflareTempEmailAdminUrl('https://mail.cloudflare.com/admin/'),
DEFAULT_CLOUDFLARE_TEMP_EMAIL_ADMIN_URL
);
});
test('getNextRelayMaskLabel returns t1 when there are no existing labels', () => {
assert.equal(getNextRelayMaskLabel([]), 't1');
});
test('getNextRelayMaskLabel fills the first numeric gap', () => {
assert.equal(getNextRelayMaskLabel(['t1', 'hello', 't3']), 't2');
});
test('shouldSkipStep9Cleanup returns false for relay_firefox', () => {
assert.equal(shouldSkipStep9Cleanup('relay_firefox'), false);
});
test('shouldUseEmailSourceForVerification returns true for cloudflare_temp_email', () => {
assert.equal(shouldUseEmailSourceForVerification('cloudflare_temp_email'), true);
});
test('shouldUseEmailSourceForVerification returns false for relay_firefox', () => {
assert.equal(shouldUseEmailSourceForVerification('relay_firefox'), false);
});
test('shouldUseEmailSourceForVerification returns false for duckduckgo', () => {
assert.equal(shouldUseEmailSourceForVerification('duckduckgo'), false);
});
test('shouldSkipStep9Cleanup returns true for duckduckgo', () => {
assert.equal(shouldSkipStep9Cleanup('duckduckgo'), true);
});
test('shouldSkipStep9Cleanup returns true for cloudflare_temp_email', () => {
assert.equal(shouldSkipStep9Cleanup('cloudflare_temp_email'), true);
});
@@ -0,0 +1,102 @@
const test = require('node:test');
const assert = require('node:assert/strict');
const {
hasAnyConsentPageState,
findLoopbackCallbackUrl,
isConsentUrl,
isConsentPageState,
isLoopbackCallbackUrl,
} = require('../shared/oauth-flow.js');
test('isConsentUrl matches the exact known consent URL', () => {
assert.equal(
isConsentUrl('https://auth.openai.com/sign-in-with-chatgpt/codex/consent'),
true
);
});
test('isConsentUrl rejects unrelated auth routes', () => {
assert.equal(
isConsentUrl('https://auth.openai.com/u/signup/identifier'),
false
);
});
test('isConsentPageState accepts sign-in-with-chatgpt routes when a continue button is visible', () => {
assert.equal(
isConsentPageState({
url: 'https://auth.openai.com/sign-in-with-chatgpt/codex/consent?state=abc',
hasVisibleContinueButton: true,
}),
true
);
});
test('isConsentPageState rejects sign-in-with-chatgpt routes without a visible continue button when URL is not exact', () => {
assert.equal(
isConsentPageState({
url: 'https://auth.openai.com/sign-in-with-chatgpt/codex/checkpoint',
hasVisibleContinueButton: false,
}),
false
);
});
test('hasAnyConsentPageState returns true when consent appears after an initial non-consent state', () => {
assert.equal(
hasAnyConsentPageState([
{
url: 'https://auth.openai.com/u/signup/profile',
hasVisibleContinueButton: false,
},
{
url: 'https://auth.openai.com/sign-in-with-chatgpt/codex/consent?state=abc',
hasVisibleContinueButton: true,
},
]),
true
);
});
test('isLoopbackCallbackUrl accepts localhost callback URLs', () => {
assert.equal(
isLoopbackCallbackUrl('http://localhost:1455/auth/callback?code=abc&state=123'),
true
);
});
test('isLoopbackCallbackUrl accepts 127.0.0.1 callback URLs', () => {
assert.equal(
isLoopbackCallbackUrl('http://127.0.0.1:8317/codex/callback?code=abc&state=123'),
true
);
});
test('isLoopbackCallbackUrl rejects non-loopback callback URLs', () => {
assert.equal(
isLoopbackCallbackUrl('https://example.com/callback?code=abc&state=123'),
false
);
});
test('findLoopbackCallbackUrl returns the first loopback callback URL from candidates', () => {
assert.equal(
findLoopbackCallbackUrl([
'https://auth.openai.com/sign-in-with-chatgpt/codex/consent',
'http://127.0.0.1:8317/codex/callback?code=abc&state=123',
'http://localhost:1455/auth/callback?code=def&state=456',
]),
'http://127.0.0.1:8317/codex/callback?code=abc&state=123'
);
});
test('findLoopbackCallbackUrl returns null when no loopback callback URL exists', () => {
assert.equal(
findLoopbackCallbackUrl([
'https://auth.openai.com/sign-in-with-chatgpt/codex/consent',
'https://example.com/callback?code=abc&state=123',
]),
null
);
});
@@ -0,0 +1,59 @@
const test = require('node:test');
const assert = require('node:assert/strict');
const {
extractVerificationCode,
findNewQQVerificationCode,
} = require('../shared/qq-mail.js');
test('extractVerificationCode reads 6-digit codes from QQ mail text', () => {
assert.equal(
extractVerificationCode('你的 ChatGPT 代码为 479637,请勿泄露。'),
'479637'
);
});
test('findNewQQVerificationCode rejects matching emails that already existed before polling', () => {
const result = findNewQQVerificationCode([
{
mailId: 'old-1',
sender: 'OpenAI',
subject: '你的 ChatGPT 代码为 479637',
digest: '用于验证你的邮箱地址',
},
], {
existingMailIds: ['old-1'],
senderFilters: ['openai', 'verify'],
subjectFilters: ['code', '验证'],
});
assert.equal(result, null);
});
test('findNewQQVerificationCode accepts the first new matching email', () => {
const result = findNewQQVerificationCode([
{
mailId: 'old-1',
sender: 'OpenAI',
subject: '你的 ChatGPT 代码为 111111',
digest: '旧邮件',
},
{
mailId: 'new-1',
sender: 'OpenAI',
subject: '你的 ChatGPT 代码为 222222',
digest: '新邮件',
},
], {
existingMailIds: ['old-1'],
senderFilters: ['openai', 'verify'],
subjectFilters: ['code', '验证'],
});
assert.deepEqual(result, {
code: '222222',
mailId: 'new-1',
source: 'new',
subject: '你的 ChatGPT 代码为 222222',
});
});
+527
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
@@ -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 %}
+117
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>
+17
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 %}