feat: publish refreshed SparkFlow console and send safety

This commit is contained in:
Rixuan Shao
2026-07-11 02:13:14 +08:00
parent 3ae9738c46
commit c496d90039
28 changed files with 4757 additions and 4135 deletions
+38 -33
View File
@@ -15,12 +15,13 @@
| 文件 | 说明 | 大小 |
|------|------|------|
| `browser.py` | 浏览器控制和页面操作 | 3.4 KB |
| `friends.py` | 好友列表管理和刷新逻辑 | 5.2 KB |
| `friends.py` | 好友列表管理和刷新逻辑 | 8.8 KB |
| `login.py` | 登录流程控制 | 2.8 KB |
| `msg_builder.py` | 消息内容构建(一言、祝福等) | 4.5 KB |
| `protocol_dispatch.py` | 协议分发和路由 | 9.7 KB |
| `protocol_sender.mjs` | 消息发送协议(Node.js 脚本) | 21 KB |
| `tasks.py` | **任务调度核心**(定时任务、状态管理) | 83 KB |
| `protocol_dispatch.py` | 协议分发和路由 | 14.5 KB |
| `protocol_sender.mjs` | 消息发送协议(Node.js 脚本) | 22.8 KB |
| `send_state.py` | 统一判定强确认、待核验和当日发送状态 | 1.6 KB |
| `tasks.py` | **任务调度核心**(定时任务、状态管理) | 109.5 KB |
### `webui/` - Web 管理界面
@@ -42,17 +43,16 @@ webui/
├── static/ # 静态资源
│ ├── app.css # 主题样式(亮色/暗色)
│ ├── app.js # 主题切换脚本
│ ├── lucide.min.js # 本地化图标库
│ ├── lucide-LICENSE.txt # 图标库许可证
│ ├── styles.css # 基础样式
│ └── multiPagePlugins/ # 浏览器扩展插件
└── templates/ # HTML 模板
├── base.html # 基础布局模板
├── dashboard.html # 仪表盘(主界面)
├── login.html # 登录页
├── login_workspace.html # 登录工作区
├── accounts.html # 账号管理
├── send_console.html # 发送控制台
── logs.html # 日志查看
└── settings.html # 系统设置
── logs.html # 日志查看
```
### `utils/` - 工具模块
@@ -140,37 +140,42 @@ docker run -d \
### `config.json` - 应用配置
主配置文件控制任务行为和系统设置。
主配置文件控制发送窗口、好友扫描、浏览器 Profile 和消息策略。仓库版本不包含真实账号标识:
```json
{
"send_window_start": "09:00",
"send_window_end": "22:00",
"send_interval_min": 300,
"message_template": "hitokoto",
"enable_send_confirm": true,
"friend_refresh_interval": 3600,
"browser_headless": false,
"browser_timeout": 30000,
"max_retry_count": 3,
"cooldown_on_failure": 600
"messageTemplate": "✨今日火花+1\n",
"useProtocolSender": false,
"browserSenderAccounts": [],
"dailySendWindow": {
"enabled": true,
"startHour": 10,
"endHour": 18,
"scheduleIntervalMinutes": 10
},
"friendListScan": {
"maxScanSeconds": 300,
"idleScanSeconds": 120,
"scrollStepPx": 400,
"scrollDelaySeconds": 0.8
},
"persistentBrowserProfiles": {
"enabled": true,
"root": "/opt/douyin-sparkflow/state/browser-profiles",
"seedCookiesWhenEmpty": true,
"syncStoredCookiesBeforeRun": true,
"refreshStoredCookiesAfterLogin": true
}
}
```
**配置项说明**
| 配置项 | 类型 | 默认值 | 说明 |
|--------|------|--------|------|
| `send_window_start` | string | "09:00" | 发送窗口开始时间 |
| `send_window_end` | string | "22:00" | 发送窗口结束时间 |
| `send_interval_min` | int | 300 | 最小发送间隔(秒) |
| `message_template` | string | "hitokoto" | 消息模板类型 |
| `enable_send_confirm` | bool | true | 是否启用发送确认 |
| `friend_refresh_interval` | int | 3600 | 好友列表刷新间隔(秒) |
| `browser_headless` | bool | false | 浏览器无头模式 |
| `browser_timeout` | int | 30000 | 浏览器操作超时(毫秒) |
| `max_retry_count` | int | 3 | 失败重试次数 |
| `cooldown_on_failure` | int | 600 | 失败后冷却时间(秒) |
| 配置项 | 说明 |
|--------|------|
| `dailySendWindow` | 每日发送窗口和调度间隔 |
| `sendStrategy` | 账号启动延迟、消息间隔及消息变体 |
| `friendListScan` | 好友列表扫描时限、空闲等待和滚动参数 |
| `persistentBrowserProfiles` | 持久化 Playwright Profile 及 Cookie 同步策略 |
| `browserSenderAccounts` | 强制使用浏览器发送的账号列表;公开模板默认为空 |
### `usersData.json` - 用户数据
+15 -5
View File
@@ -6,10 +6,7 @@
"saveDebugArtifacts": false,
"useProtocolSender": false,
"protocolDryRun": false,
"browserSenderAccounts": [
"94262577168",
"抖音号:softwomen"
],
"browserSenderAccounts": [],
"sendStrategy": {
"shuffleTargets": true,
"accountStartDelaySecondsMin": 15,
@@ -38,5 +35,18 @@
"happyNewYear": {
"enabled": true,
"messageTemplate": "\r\n"
},
"friendListScan": {
"maxScanSeconds": 300,
"idleScanSeconds": 120,
"scrollStepPx": 400,
"scrollDelaySeconds": 0.8
},
"persistentBrowserProfiles": {
"enabled": true,
"root": "/opt/douyin-sparkflow/state/browser-profiles",
"seedCookiesWhenEmpty": true,
"syncStoredCookiesBeforeRun": true,
"refreshStoredCookiesAfterLogin": true
}
}
}
+4
View File
@@ -35,6 +35,10 @@ def configure_playwright_environment():
def _headless_for(GUI=False):
headful_env = str(os.getenv("SPARKFLOW_BROWSER_HEADFUL") or "").strip().lower()
if headful_env in {"1", "true", "yes", "on"}:
return False
headless = not GUI
if get_environment() == Environment.LOCAL and DEBUG:
headless = False
+114 -6
View File
@@ -20,6 +20,24 @@ FIRST_FRIEND_SELECTOR = (
)
FRIEND_NAME_SELECTOR = """xpath=.//span[contains(@class, "item-header-name-")]"""
LOGIN_MASK_SELECTORS = [".login-mask", ".login-guide-container", ".login-img-code-wrapper"]
NON_LOGIN_DIALOG_DISMISS_TEXTS = (
"我知道了",
"知道了",
"好的",
"确定",
"确认",
"稍后再说",
"关闭",
)
NON_LOGIN_DIALOG_CLOSE_SELECTORS = (
".semi-modal-close",
'button[aria-label="Close"]',
'button[aria-label="关闭"]',
'[aria-label="Close"]',
'[aria-label="关闭"]',
)
FRIEND_LIST_EMPTY_ROUNDS = 6
FRIEND_LIST_EMPTY_WAIT_SECONDS = 1.5
def update_collection_progress(new_names_count, no_more_visible, scroll_moved, idle_rounds, stuck_rounds, idle_limit=5, stuck_limit=2):
@@ -41,13 +59,103 @@ async def _ensure_logged_in(page):
continue
async def collect_friend_names(page):
await page.wait_for_selector(FRIENDS_TAB_SELECTOR, timeout=30000)
await page.locator(FRIENDS_TAB_SELECTOR).click()
async def _dismiss_non_login_dialogs(page):
for text in NON_LOGIN_DIALOG_DISMISS_TEXTS:
try:
locator = page.get_by_text(text, exact=False).first
if await locator.count() > 0 and await locator.is_visible():
await locator.click(timeout=3000)
await asyncio.sleep(1)
return True
except Exception:
continue
await page.wait_for_selector(FIRST_FRIEND_SELECTOR, timeout=30000)
await page.locator(FIRST_FRIEND_SELECTOR).click()
await asyncio.sleep(2)
for selector in NON_LOGIN_DIALOG_CLOSE_SELECTORS:
try:
locator = page.locator(selector).first
if await locator.count() > 0 and await locator.is_visible():
await locator.click(timeout=3000)
await asyncio.sleep(1)
return True
except Exception:
continue
return False
async def _click_friends_tab(page):
await page.wait_for_selector("#sub-app", timeout=30000)
try:
await page.locator(FRIENDS_TAB_SELECTOR).click(timeout=10000)
return
except Exception:
pass
try:
await page.get_by_text("朋友私信", exact=True).click(timeout=10000)
return
except Exception as exc:
raise RuntimeError("未找到朋友私信标签") from exc
async def _friend_list_dom_summary(page):
return await page.evaluate(
"""() => {
const sub = document.querySelector('#sub-app');
if (!sub) {
return { hasSubApp: false, ulCount: 0, liCount: 0, listItemCount: 0, nameSpanCount: 0, text: '' };
}
return {
hasSubApp: true,
ulCount: sub.querySelectorAll('ul').length,
liCount: sub.querySelectorAll('li').length,
listItemCount: sub.querySelectorAll('[class*="list-item"]').length,
nameSpanCount: sub.querySelectorAll('[class*="item-header-name"]').length,
text: (sub.innerText || '').split(String.fromCharCode(10)).join(' ').slice(0, 500),
};
}"""
)
async def _wait_for_first_friend_or_empty(page):
for _ in range(FRIEND_LIST_EMPTY_ROUNDS):
await _dismiss_non_login_dialogs(page)
first_friend = page.locator(FIRST_FRIEND_SELECTOR).first
try:
if await first_friend.count() > 0 and await first_friend.is_visible():
await first_friend.click()
await asyncio.sleep(2)
return True
except Exception:
pass
summary = await _friend_list_dom_summary(page)
has_any_list_content = any(
int(summary.get(key) or 0) > 0
for key in ("ulCount", "liCount", "listItemCount", "nameSpanCount")
)
if not has_any_list_content:
await asyncio.sleep(FRIEND_LIST_EMPTY_WAIT_SECONDS)
continue
# The current DOM has list content but not the historical first-friend XPath.
# Let the collector below try the more general TARGET_SELECTOR path.
return False
return False
async def collect_friend_names(page):
await _click_friends_tab(page)
await asyncio.sleep(1)
has_first_friend = await _wait_for_first_friend_or_empty(page)
if not has_first_friend:
summary = await _friend_list_dom_summary(page)
has_any_list_content = any(
int(summary.get(key) or 0) > 0
for key in ("ulCount", "liCount", "listItemCount", "nameSpanCount")
)
if not has_any_list_content:
return []
found_names = []
seen_names = set()
+130 -3
View File
@@ -64,6 +64,85 @@ def _account_identity_key(account):
return ""
def _coerce_attempt_count(entry):
try:
return max(0, int(dict(entry or {}).get("attemptCount") or 0))
except (TypeError, ValueError):
return 0
def _protocol_failure_category(entry):
status_name = str(entry.get("statusName") or "").strip()
status_code = entry.get("statusCode")
if status_name == "CheckMessageNotPass" or status_code == 3:
return "protocol_check_message_not_pass"
if status_name == "CheckMessageNotPassButSelfVisible" or status_code == 4:
return "protocol_check_message_self_visible"
if status_name == "UserNotInConversation" or status_code == 1:
return "protocol_user_not_in_conversation"
if status_name == "CheckConversationNotPass" or status_code == 2:
return "protocol_check_conversation_not_pass"
if status_name == "UserHasBeenBlock" or status_code == 5:
return "protocol_user_blocked"
return "protocol_send_failed"
def _protocol_failure_reason(entry):
bits = [
f"statusCode={entry.get('statusCode')}",
f"statusName={entry.get('statusName') or ''}",
f"statusMsg={entry.get('statusMsg') or ''}",
]
summary = entry.get("sendResultSummary") or {}
raw_keys = summary.get("rawKeys") or []
if raw_keys:
bits.append(f"rawKeys={','.join(map(str, raw_keys))}")
return " ".join(bits)
def _persist_protocol_account_failure(account, category, reason, affected_targets=None):
now_iso = datetime.now(timezone.utc).isoformat(timespec="seconds")
all_accounts = get_userData(force_reload=True)
accounts_by_identity = {
identity: item
for item in all_accounts
for identity in [_account_identity_key(item)]
if identity
}
target_account = accounts_by_identity.get(_account_identity_key(account))
if not target_account:
return
affected_targets = list(affected_targets or [])
existing_entry = dict(target_account.get("account_failure") or {})
target_account["account_failure"] = {
"category": category,
"reason": reason,
"firstAttemptAt": existing_entry.get("firstAttemptAt") or now_iso,
"lastAttemptAt": now_iso,
"attemptCount": _coerce_attempt_count(existing_entry) + 1,
"lastRunMode": "protocol",
"affectedTargets": affected_targets,
}
save_userData(all_accounts)
def _record_protocol_target_failure(target_account, target_name, message, category, reason):
now_iso = datetime.now(timezone.utc).isoformat(timespec="seconds")
queue = dict(target_account.get("failure_queue") or {})
existing_entry = dict(queue.get(target_name) or {})
queue[target_name] = {
"category": category,
"reason": reason,
"message": message,
"firstAttemptAt": existing_entry.get("firstAttemptAt") or now_iso,
"lastAttemptAt": now_iso,
"attemptCount": _coerce_attempt_count(existing_entry) + 1,
"lastRunMode": "protocol",
}
target_account["failure_queue"] = queue
def _merge_protocol_runtime_state(accounts, result_by_username):
changed = False
now_iso = datetime.now(timezone.utc).isoformat(timespec="seconds")
@@ -109,6 +188,35 @@ def _merge_protocol_runtime_state(accounts, result_by_username):
if history:
target_account["message_history"] = history
for entry in result.get("sent", []):
if entry.get("dryRun") or entry.get("success", True):
continue
target = str(entry.get("target", "")).strip()
if not target:
continue
_record_protocol_target_failure(
target_account,
target,
str(entry.get("message", "")).strip(),
_protocol_failure_category(entry),
_protocol_failure_reason(entry),
)
changed = True
unresolved = result.get("unresolved", []) or []
for entry in unresolved:
target = str(entry.get("target", "")).strip()
if not target:
continue
_record_protocol_target_failure(
target_account,
target,
"",
str(entry.get("reason") or "protocol_unresolved"),
str(entry.get("reason") or "protocol could not resolve target"),
)
changed = True
if changed:
save_userData(all_accounts)
@@ -243,12 +351,24 @@ async def run_protocol_tasks(config, accounts, message_builder):
dry_run,
send_strategy,
)
sent_entries = result.get("sent", [])
succeeded_count = len([
entry for entry in sent_entries
if not entry.get("dryRun") and entry.get("success", True)
])
failed_count = len([
entry for entry in sent_entries
if not entry.get("dryRun") and not entry.get("success", True)
])
logger.info(
"Protocol sender finished for %s resolved=%s unresolved=%s sent=%s",
"Protocol sender finished for %s resolved=%s unresolved=%s attempted=%s succeeded=%s failed=%s dryRun=%s",
user.get("username", "unknown"),
len(result.get("resolved", [])),
len(result.get("unresolved", [])),
len(result.get("sent", [])),
len(sent_entries),
succeeded_count,
failed_count,
bool(result.get("dryRun")),
)
return result
@@ -258,8 +378,15 @@ async def run_protocol_tasks(config, accounts, message_builder):
failures = []
for user, item in zip(accounts, gathered):
if isinstance(item, Exception):
failures.append(str(item))
reason = str(item)
failures.append(reason)
logger.error("Protocol sender failed for %s: %s", user.get("username", "unknown"), item)
_persist_protocol_account_failure(
user,
"protocol_sender_failed",
reason,
user.get("targets", []),
)
continue
result_by_username[user.get("username")] = item
unresolved = item.get("unresolved", [])
+41 -3
View File
@@ -18,12 +18,13 @@ const SDK_BUNDLES = [
"https://lf-fe-creator.douyinstatic.com/obj/douyn-creator-scm-cdn/douyin-creator-mono-pc-data/static/js/async/7771.d27d1891.js",
"https://lf-fe-creator.douyinstatic.com/obj/douyn-creator-scm-cdn/douyin-creator-mono-pc-data/static/js/async/6682.2a991dfb.js",
"https://lf-fe-creator.douyinstatic.com/obj/douyn-creator-scm-cdn/douyin-creator-mono-pc-data/static/js/async/361.4fc40815.js",
"https://lf-fe-creator.douyinstatic.com/obj/douyn-creator-scm-cdn/douyin-creator-mono-pc-data/static/js/async/pages-chat.6f823210.js",
"https://lf-fe-creator.douyinstatic.com/obj/douyn-creator-scm-cdn/douyin-creator-mono-pc-data/static/js/async/pages-chat.c817de31.js",
];
const CREATOR_CHAT_URL = "https://creator.douyin.com/creator-micro/data/following/chat";
const USER_AGENT =
"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/141 Safari/537.36";
(process.env.SPARKFLOW_PROTOCOL_USER_AGENT ||
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36").trim();
function noop() {}
@@ -70,6 +71,36 @@ function randomBetweenInclusive(min, max) {
return Math.floor(Math.random() * (max - min + 1)) + min;
}
const SEND_MESSAGE_STATUS_NAMES = {
0: "Succeeded",
1: "UserNotInConversation",
2: "CheckConversationNotPass",
3: "CheckMessageNotPass",
4: "CheckMessageNotPassButSelfVisible",
5: "UserHasBeenBlock",
};
function sendMessageStatusName(statusCode) {
if (statusCode === null || statusCode === undefined) {
return "";
}
return SEND_MESSAGE_STATUS_NAMES[Number(statusCode)] || "Unknown";
}
function publicSendResultSummary(sendResult) {
if (!sendResult || typeof sendResult !== "object") {
return {};
}
const summary = {};
for (const key of ["success", "statusCode", "statusMsg", "checkCode", "checkMsg", "errorCode", "errorMsg"]) {
if (sendResult[key] !== undefined) {
summary[key] = sendResult[key];
}
}
summary.rawKeys = Object.keys(sendResult).sort();
return summary;
}
async function readStdinJson() {
const chunks = [];
for await (const chunk of process.stdin) {
@@ -88,6 +119,10 @@ async function ensureBundles(cacheDir) {
const filename = url.split("/").at(-1);
const filePath = path.join(cacheDir, filename);
if (fs.existsSync(filePath)) {
const response = await fetch(url, { method: "HEAD", headers: { "User-Agent": USER_AGENT } });
if (!response.ok) {
throw new Error(`Cached SDK bundle is stale or unreachable ${url}: ${response.status}`);
}
continue;
}
const response = await fetch(url, { headers: { "User-Agent": USER_AGENT } });
@@ -641,13 +676,16 @@ async function sendMessages({
}
const sendResult = await client.sendMessage({ message: messageObject });
const statusCode = sendResult?.statusCode ?? null;
sent.push({
target,
dryRun: false,
message,
success: Boolean(sendResult?.success),
statusCode: sendResult?.statusCode ?? null,
statusCode,
statusName: sendMessageStatusName(statusCode),
statusMsg: sendResult?.statusMsg ?? "",
sendResultSummary: publicSendResultSummary(sendResult),
conversationId: mapping.conversationId,
delayBeforeSendSeconds,
sentAt: stableNow(),
+51
View File
@@ -0,0 +1,51 @@
from __future__ import annotations
from datetime import datetime
def parse_sent_at(raw_value, local_tz):
if not raw_value:
return None
raw = str(raw_value).strip()
if raw.endswith("Z"):
raw = raw[:-1] + "+00:00"
try:
parsed = datetime.fromisoformat(raw)
except (TypeError, ValueError):
return None
if parsed.tzinfo is None:
return parsed.replace(tzinfo=local_tz)
return parsed.astimezone(local_tz)
def _receipt_is_strong(receipt):
receipt = dict(receipt or {})
try:
http_status = int(receipt.get("httpStatus") or 0)
except (TypeError, ValueError):
http_status = 0
return (
bool(receipt.get("ok"))
and 200 <= http_status < 300
and str(receipt.get("call") or "message_send") in ("", "message_send")
)
def history_entry_is_strong_confirmed_today(entry, now):
entry = dict(entry or {})
sent_at = parse_sent_at(entry.get("sentAt"), now.tzinfo)
if not sent_at or sent_at.date() != now.date() or bool(entry.get("needsVerification")):
return False
if entry.get("status") == "confirmed" and entry.get("confirmationLevel") == "strong":
return True
return _receipt_is_strong(entry.get("serverReceipt"))
def target_is_strong_confirmed_today(account, target_name, now):
history = dict(account.get("message_history") or {})
return history_entry_is_strong_confirmed_today(history.get(target_name), now)
def history_entry_is_today(entry, now):
sent_at = parse_sent_at(dict(entry or {}).get("sentAt"), now.tzinfo)
return bool(sent_at and sent_at.date() == now.date())
+474 -73
View File
@@ -1,5 +1,8 @@
import asyncio
import asyncio
import base64
import errno
import hashlib
import json
import logging
import os
import random
@@ -12,6 +15,7 @@ from zoneinfo import ZoneInfo
from core.browser import get_browser, get_persistent_browser_context, sanitize_profile_name
from core.msg_builder import build_message, build_message_candidates
from core.protocol_dispatch import run_protocol_tasks
from core.send_state import parse_sent_at, target_is_strong_confirmed_today
from utils.config import get_config, get_userData, normalize_unique_id, save_userData
from utils.logger import setup_logger
@@ -464,6 +468,239 @@ async def _detect_send_failure_indicator(page):
return ""
async def start_im_send_observer(page, account_name, target_name):
"""Observe creator IM service calls for one browser send attempt."""
state = {
"enabled": False,
"send_request_seen": False,
"send_response_seen": False,
"send_receipt": {},
"mark_read_calls": [],
"identity_security_token_calls": [],
"events": [],
"error": "",
}
pending = {}
session = None
try:
session = await page.context.new_cdp_session(page)
await session.send("Network.enable")
state["enabled"] = True
except Exception as exc:
state["error"] = f"cdp_unavailable: {exc}"
logger.warning("IM observer unavailable for %s/%s: %s", account_name, target_name, exc)
async def disabled_summary(extra_wait_seconds=0):
if extra_wait_seconds:
await asyncio.sleep(extra_wait_seconds)
return dict(state)
return disabled_summary
def _trim(value, limit=220):
return str(value or "")[:limit]
def _call_kind(url):
value = str(url or "")
if "/v1/message/send" in value:
return "message_send"
if "mark_read" in value:
return "mark_read"
if "identity_security_token" in value:
return "identity_security_token"
if "imapi.douyin.com" in value:
return "imapi_other"
return ""
def _safe_url(url):
return str(url or "").split("?", 1)[0]
def _header_value(headers, name):
target = name.lower()
for key, value in (headers or {}).items():
if str(key).lower() == target:
return str(value)
return ""
def _decode_body(body):
raw = body.get("body") or ""
if body.get("base64Encoded"):
try:
return base64.b64decode(raw).decode("utf-8", errors="replace")
except Exception:
return ""
return raw
def _parse_json_body(text):
try:
return json.loads(text)
except Exception:
return None
def _extract_error_text(data, body_text):
if isinstance(data, dict):
for key in ("status_msg", "message", "msg", "err_msg", "error", "reason"):
value = data.get(key)
if value:
return _trim(value, 300)
nested = data.get("data")
if isinstance(nested, dict):
for key in ("status_msg", "message", "msg", "err_msg", "error", "reason"):
value = nested.get(key)
if value:
return _trim(value, 300)
return _trim(body_text, 300)
def _json_success(data):
if not isinstance(data, dict):
return None
success_values = []
for key in ("status_code", "err_no", "errno", "error_code", "code"):
if key in data:
success_values.append(data.get(key) in (0, "0", None))
message = str(data.get("message") or data.get("status_msg") or "").lower()
if message:
success_values.append(message in ("success", "ok"))
if success_values:
return all(success_values)
return None
def _receipt_body_meta(data, body_text):
meta = {"bodyLen": len(body_text or "")}
if body_text:
meta["bodySha256"] = hashlib.sha256(body_text.encode("utf-8", errors="replace")).hexdigest()
if isinstance(data, dict):
meta["jsonKeys"] = sorted(str(key) for key in data.keys())[:20]
nested = data.get("data")
if isinstance(nested, dict):
meta["dataKeys"] = sorted(str(key) for key in nested.keys())[:20]
for source_key, dest_key in (
("server_message_id", "serverMessageId"),
("message_id", "messageId"),
("msg_id", "messageId"),
("conversation_id", "conversationId"),
("conversation_short_id", "conversationShortId"),
):
value = nested.get(source_key)
if value:
meta[dest_key] = _trim(value, 120)
for source_key, dest_key in (
("server_message_id", "serverMessageId"),
("message_id", "messageId"),
("msg_id", "messageId"),
("conversation_id", "conversationId"),
("conversation_short_id", "conversationShortId"),
):
value = data.get(source_key)
if value:
meta[dest_key] = _trim(value, 120)
return meta
def _record(event):
state["events"].append(event)
if len(state["events"]) > 50:
del state["events"][:-50]
def on_request(params):
request = params.get("request") or {}
url = request.get("url") or ""
kind = _call_kind(url)
if not kind:
return
request_id = params.get("requestId")
post_data = request.get("postData") or ""
method = request.get("method") or ""
is_send_post = kind == "message_send" and method.upper() == "POST"
pending[request_id] = {"url": url, "kind": kind, "request": request, "is_send_post": is_send_post}
_record({"kind": "request", "call": kind, "url": _safe_url(url), "method": method, "postLen": len(post_data)})
if is_send_post:
state["send_request_seen"] = True
logger.info("IM observer saw message send request for %s/%s url=%s postLen=%s", account_name, target_name, _trim(_safe_url(url), 160), len(post_data))
def on_response(params):
request_id = params.get("requestId")
item = pending.get(request_id)
if not item:
return
response = params.get("response") or {}
item["response"] = response
kind = item.get("kind")
headers = response.get("headers") or {}
status = response.get("status")
logid = _header_value(headers, "x-tt-logid") or _header_value(headers, "x-tt-trace-log") or _header_value(headers, "x-tt-trace-id")
event = {"kind": "response", "call": kind, "url": _safe_url(item.get("url")), "status": status}
if logid:
event["logid"] = _trim(logid, 120)
_record(event)
if item.get("is_send_post"):
state["send_response_seen"] = True
state["send_receipt"] = {"call": kind, "httpStatus": status, "ok": False, "pendingBody": True, "logid": _trim(logid, 120), "url": _safe_url(item.get("url"))}
logger.info("IM observer saw message send response for %s/%s status=%s logid=%s", account_name, target_name, status, _trim(logid, 80))
async def fetch_body(request_id):
item = pending.get(request_id) or {}
kind = item.get("kind") or ""
url = item.get("url") or ""
response = item.get("response") or {}
status = response.get("status")
headers = response.get("headers") or {}
logid = _header_value(headers, "x-tt-logid") or _header_value(headers, "x-tt-trace-log") or _header_value(headers, "x-tt-trace-id")
try:
body = await session.send("Network.getResponseBody", {"requestId": request_id})
body_text = _decode_body(body)
data = _parse_json_body(body_text)
http_ok = isinstance(status, (int, float)) and 200 <= status < 300
json_ok = _json_success(data)
receipt = {"call": kind, "httpStatus": status, "ok": bool(http_ok and (json_ok is not False)), "jsonParsed": isinstance(data, dict), "logid": _trim(logid, 120), "url": _safe_url(url)}
receipt.update(_receipt_body_meta(data, body_text))
if json_ok is not None:
receipt["jsonOk"] = bool(json_ok)
if not receipt["ok"]:
receipt["reason"] = _extract_error_text(data, body_text)
if item.get("is_send_post"):
state["send_receipt"] = receipt
_record({"kind": "body", "call": kind, "status": status, "ok": receipt["ok"], "jsonParsed": receipt["jsonParsed"], "logid": receipt.get("logid", ""), "reason": receipt.get("reason", "")})
elif kind == "mark_read":
state["mark_read_calls"].append(receipt)
_record({"kind": "body", "call": kind, "status": status, "ok": receipt["ok"]})
elif kind == "identity_security_token":
has_token = isinstance(data, dict) and bool((data.get("data") or {}).get("identity_security_token"))
receipt["hasToken"] = has_token
receipt.pop("reason", None)
state["identity_security_token_calls"].append(receipt)
_record({"kind": "body", "call": kind, "status": status, "ok": receipt["ok"], "hasToken": has_token})
else:
_record({"kind": "body", "call": kind, "status": status, "ok": receipt["ok"]})
except Exception as exc:
error = _trim(exc, 160)
_record({"kind": "body_error", "call": kind, "url": _safe_url(url), "error": error})
if item.get("is_send_post"):
state["send_receipt"] = {"call": kind, "httpStatus": status, "ok": False, "logid": _trim(logid, 120), "url": _safe_url(url), "reason": f"response_body_unavailable: {error}"}
finally:
pending.pop(request_id, None)
def on_loading_finished(params):
request_id = params.get("requestId")
if request_id in pending:
asyncio.create_task(fetch_body(request_id))
session.on("Network.requestWillBeSent", on_request)
session.on("Network.responseReceived", on_response)
session.on("Network.loadingFinished", on_loading_finished)
async def summary(extra_wait_seconds=3):
if extra_wait_seconds:
await asyncio.sleep(extra_wait_seconds)
try:
await session.detach()
except Exception:
pass
return dict(state)
return summary
async def snapshot_last_own_message(page, chat_input=None):
try:
return await page.evaluate(
@@ -811,12 +1048,30 @@ def _build_normalized_target_map(targets):
return normalized_targets
async def _locator_count_with_timeout(locator, account_name, stage, label, timeout_seconds=3):
try:
return await asyncio.wait_for(locator.count(), timeout=timeout_seconds)
except asyncio.TimeoutError:
logger.warning(
"Account %s locator count timed out at %s label=%s after %ss",
account_name,
stage,
label,
timeout_seconds,
)
return None
async def _first_non_empty_locator(page, selectors):
for selector in selectors:
locator = page.locator(selector)
try:
count = await locator.count()
except Exception:
count = await _locator_count_with_timeout(
locator,
"unknown",
"first_non_empty_locator",
selector,
)
if count is None:
continue
for index in range(min(count, 5)):
item = locator.nth(index)
@@ -832,7 +1087,13 @@ async def _selector_visible(page, selectors):
for selector in selectors:
locator = page.locator(selector).first
try:
if await locator.count() > 0 and await locator.is_visible(timeout=500):
count = await _locator_count_with_timeout(
locator,
"unknown",
"selector_visible",
selector,
)
if count and await locator.is_visible(timeout=500):
return selector
except Exception:
continue
@@ -843,7 +1104,13 @@ async def _click_first_visible_locator(candidates, account_name, stage):
last_error = None
for label, locator in candidates:
try:
count = min(await locator.count(), 5)
count = await _locator_count_with_timeout(
locator,
account_name,
stage,
label,
)
count = min(count or 0, 5)
except Exception as exc:
last_error = exc
continue
@@ -878,17 +1145,26 @@ async def _open_friends_tab(page, account_name, fallback_selector, target_select
'[contains(normalize-space(.), "朋友") or contains(normalize-space(.), "好友") '
'or contains(normalize-space(.), "互关")]'
)
page_text_selector = (
'xpath=//*[self::div or self::button or self::span or @role="tab" or @role="button"]'
'[contains(normalize-space(.), "朋友私信") or contains(normalize-space(.), "好友私信") '
'or contains(normalize-space(.), "朋友") or contains(normalize-space(.), "好友") '
'or contains(normalize-space(.), "互关")]'
)
candidates = [
("page tab text 朋友私信", page.get_by_text("朋友私信", exact=True)),
("page tab text 好友私信", page.get_by_text("好友私信", exact=True)),
("tab text 朋友", sub_app.get_by_text("朋友", exact=True)),
("tab text 好友", sub_app.get_by_text("好友", exact=True)),
("tab text 互关", sub_app.get_by_text("互关", exact=True)),
("tab text 朋友私信", sub_app.get_by_text("朋友私信", exact=True)),
("tab text 好友私信", sub_app.get_by_text("好友私信", exact=True)),
("sub-app friend tab text", page.locator(text_selector)),
("page friend tab text", page.locator(page_text_selector)),
("fallback friends tab xpath", page.locator(fallback_selector)),
]
if await _click_first_visible_locator(candidates, account_name, "open_friends_tab"):
await asyncio.sleep(1)
await asyncio.sleep(2)
return "clicked"
await page.wait_for_selector(fallback_selector, timeout=30000)
@@ -1336,18 +1612,7 @@ def _account_identity(user):
def _parse_sent_at(raw_value, local_tz):
if not raw_value:
return None
raw = str(raw_value).strip()
if raw.endswith("Z"):
raw = raw[:-1] + "+00:00"
try:
parsed = datetime.fromisoformat(raw)
except ValueError:
return None
if parsed.tzinfo is None:
return parsed.replace(tzinfo=local_tz)
return parsed.astimezone(local_tz)
return parse_sent_at(raw_value, local_tz)
def _manual_run_failed_only():
@@ -1368,10 +1633,18 @@ def _unsent_retry_max_attempts():
def _target_sent_today(user, target_name, now):
return target_is_strong_confirmed_today(user, target_name, now)
def _target_unconfirmed_today(user, target_name, now):
history = dict(user.get("message_history") or {})
entry = history.get(target_name) or {}
entry = dict(history.get(target_name) or {})
sent_at = _parse_sent_at(entry.get("sentAt"), now.tzinfo)
return bool(sent_at and sent_at.date() == now.date())
return bool(
sent_at
and sent_at.date() == now.date()
and not _target_sent_today(user, target_name, now)
)
def _target_failed_today(user, target_name, now):
@@ -1462,6 +1735,25 @@ def _target_failure_attempts_today(user, target_name, now):
return 0
def _target_failure_category_today(user, target_name, now):
queue = dict(user.get("failure_queue") or {})
entry = queue.get(target_name) or {}
last_attempt_at = _parse_sent_at(entry.get("lastAttemptAt"), now.tzinfo)
if not last_attempt_at or last_attempt_at.date() != now.date():
return ""
return str(entry.get("category") or "").strip()
def _target_has_non_retryable_failure_today(user, target_name, now):
return _target_failure_category_today(user, target_name, now) in {
"protocol_check_message_not_pass",
"protocol_check_message_self_visible",
"protocol_user_blocked",
"protocol_user_not_in_conversation",
"protocol_check_conversation_not_pass",
}
def _pending_failed_targets(user, now):
queue = dict(user.get("failure_queue") or {})
targets = []
@@ -1477,6 +1769,9 @@ def _pending_failed_targets(user, now):
for target_name in user.get("targets") or []:
if _target_sent_today(user, target_name, now):
continue
if _target_unconfirmed_today(user, target_name, now):
targets.append(target_name)
continue
if target_name in queue and _target_failed_today(user, target_name, now):
targets.append(target_name)
return targets
@@ -1489,6 +1784,9 @@ def _pending_unsent_targets(user, now):
for target_name in user.get("targets") or []:
if _target_sent_today(user, target_name, now):
continue
if _target_has_non_retryable_failure_today(user, target_name, now):
skipped_targets.append(f"{target_name}(non_retryable)")
continue
attempts_today = _target_failure_attempts_today(user, target_name, now)
if attempts_today >= max_attempts:
skipped_targets.append(f"{target_name}({attempts_today})")
@@ -1583,10 +1881,10 @@ def _prepare_active_users_for_run(active_config, active_user_data):
retry_targets = _pending_failed_targets(user, now)
already_sent = [target for target in user.get("targets") or [] if _target_sent_today(user, target, now)]
logger.info(
"manual-retry user=%s retryTargets=%s alreadySentToday=%s",
"manual-retry user=%s retryTargetCount=%s strongConfirmedToday=%s",
user.get("username", "unknown"),
retry_targets,
already_sent,
len(retry_targets),
len(already_sent),
)
if retry_targets:
runnable_user = dict(user)
@@ -1605,11 +1903,11 @@ def _prepare_active_users_for_run(active_config, active_user_data):
retry_targets, skipped_targets = _pending_unsent_targets(user, now)
already_sent = [target for target in user.get("targets") or [] if _target_sent_today(user, target, now)]
logger.info(
"manual-unsent user=%s retryTargets=%s alreadySentToday=%s skippedMaxAttempts=%s",
"manual-unsent user=%s retryTargetCount=%s strongConfirmedToday=%s skippedCount=%s",
user.get("username", "unknown"),
retry_targets,
already_sent,
skipped_targets,
len(retry_targets),
len(already_sent),
len(skipped_targets),
)
if retry_targets:
runnable_user = dict(user)
@@ -1639,17 +1937,13 @@ def _prepare_active_users_for_run(active_config, active_user_data):
runnable_users = []
for user in active_user_data:
due_targets, already_sent, pending_targets, queued_failures = _select_due_targets(user, send_window, now)
pending_preview = [
f"{target_name}@{scheduled_at.strftime('%H:%M')}"
for target_name, scheduled_at in pending_targets[:5]
]
logger.info(
"windowed user=%s dueTargets=%s alreadySentToday=%s pendingTargets=%s queuedFailures=%s",
"windowed user=%s due=%s strongConfirmed=%s pending=%s queuedFailures=%s",
user.get("username", "unknown"),
due_targets,
already_sent,
pending_preview,
queued_failures,
len(due_targets),
len(already_sent),
len(pending_targets),
len(queued_failures),
)
if due_targets:
runnable_user = dict(user)
@@ -1806,7 +2100,7 @@ def _persist_friend_index(user, friend_records, scanned_at, *, scan_complete, mi
)
def _persist_browser_send_failure(user, target_name, message, category, reason, attempted_at):
def _persist_browser_send_failure(user, target_name, message, category, reason, attempted_at, server_receipt=None):
accounts = get_userData(force_reload=True)
matched_account = _find_matching_account(accounts, user)
if matched_account is None:
@@ -1828,6 +2122,8 @@ def _persist_browser_send_failure(user, target_name, message, category, reason,
"attemptCount": _coerce_attempt_count(existing_entry) + 1,
"lastRunMode": _current_run_mode(),
}
if server_receipt:
queue[target_name]["serverReceipt"] = server_receipt
matched_account["failure_queue"] = queue
save_userData(accounts)
@@ -1844,7 +2140,7 @@ def _persist_browser_send_failure(user, target_name, message, category, reason,
)
def _persist_browser_send_success(user, target_name, message, sent_at):
def _persist_browser_send_success(user, target_name, message, sent_at, server_receipt=None):
accounts = get_userData(force_reload=True)
matched_account = _find_matching_account(accounts, user)
if matched_account is None:
@@ -1855,11 +2151,25 @@ def _persist_browser_send_success(user, target_name, message, sent_at):
)
return
history = dict(matched_account.get("message_history") or {})
history[target_name] = {
receipt_summary = ""
if isinstance(server_receipt, dict):
receipt_summary = "message_send http={} logid={}".format(
server_receipt.get("httpStatus"),
server_receipt.get("logid") or "",
)
strong_entry = {
"message": message,
"sentAt": sent_at,
"status": "confirmed",
"confirmationLevel": "strong",
"confirmationSource": "cdp_message_send_receipt" if server_receipt else "browser_visible_count_increased",
"confirmationDetail": receipt_summary,
"needsVerification": False,
}
if server_receipt:
strong_entry["serverReceipt"] = server_receipt
history = dict(matched_account.get("message_history") or {})
history[target_name] = strong_entry
matched_account["message_history"] = history
queue = dict(matched_account.get("failure_queue") or {})
queue.pop(target_name, None)
@@ -1871,10 +2181,7 @@ def _persist_browser_send_success(user, target_name, message, sent_at):
save_userData(accounts)
user_history = dict(user.get("message_history") or {})
user_history[target_name] = {
"message": message,
"sentAt": sent_at,
}
user_history[target_name] = dict(strong_entry)
user["message_history"] = user_history
user_queue = dict(user.get("failure_queue") or {})
user_queue.pop(target_name, None)
@@ -1921,6 +2228,12 @@ def _pid_is_alive(pid):
return False
except PermissionError:
return True
except OSError as exc:
if getattr(exc, "winerror", None) == 87 or exc.errno == errno.ESRCH:
return False
if exc.errno in (errno.EPERM, errno.EACCES):
return True
raise
return True
@@ -2009,6 +2322,22 @@ def _release_browser_account_lock(handle, lock_path, account_name):
pass
def _browser_account_timeout_seconds(friend_scan_config, target_count):
raw_value = str(os.getenv("SPARKFLOW_BROWSER_ACCOUNT_TIMEOUT_SECONDS") or "").strip()
if raw_value:
try:
return max(300, int(raw_value))
except ValueError:
logger.warning(
"Invalid SPARKFLOW_BROWSER_ACCOUNT_TIMEOUT_SECONDS=%r, using calculated timeout",
raw_value,
)
scan_seconds = int((friend_scan_config or {}).get("maxScanSeconds") or 300)
# Bound one account run even if Playwright or the page wedges below our selector timeouts.
return max(900, scan_seconds + 420 + max(1, target_count) * 240)
async def run_browser_tasks(active_config, browser_user_data):
if not browser_user_data:
return
@@ -2028,7 +2357,11 @@ async def run_browser_tasks(active_config, browser_user_data):
profile_config["refreshStoredCookiesAfterLogin"],
)
for user in browser_user_data:
logger.info("Using persistent browser sender for user=%s targets=%s", user.get("username", "unknown"), user["targets"])
logger.info(
"Using persistent browser sender for user=%s targetCount=%s",
user.get("username", "unknown"),
len(user["targets"]),
)
tasks.append(do_user_task(None, user, semaphore, send_strategy, profile_config, friend_scan_config))
await asyncio.gather(*tasks)
return
@@ -2036,7 +2369,11 @@ async def run_browser_tasks(active_config, browser_user_data):
playwright, browser = await get_browser()
try:
for user in browser_user_data:
logger.info("Using browser sender for user=%s targets=%s", user.get("username", "unknown"), user["targets"])
logger.info(
"Using browser sender for user=%s targetCount=%s",
user.get("username", "unknown"),
len(user["targets"]),
)
tasks.append(do_user_task(browser, user, semaphore, send_strategy, profile_config, friend_scan_config))
await asyncio.gather(*tasks)
@@ -2052,14 +2389,40 @@ async def do_user_task(browser, user, semaphore, send_strategy, profile_config,
account_lock_path = None
try:
account_lock_handle, account_lock_path = await _acquire_browser_account_lock(user, account_name)
await _do_user_task_locked(
browser,
user,
send_strategy,
profile_config,
timeout_seconds = _browser_account_timeout_seconds(
friend_scan_config,
account_name,
len(user.get("targets") or []),
)
logger.info(
"Account %s browser sender timeout guard is %ss",
account_name,
timeout_seconds,
)
try:
await asyncio.wait_for(
_do_user_task_locked(
browser,
user,
send_strategy,
profile_config,
friend_scan_config,
account_name,
),
timeout=timeout_seconds,
)
except asyncio.TimeoutError:
attempted_at = datetime.now(timezone.utc).isoformat(timespec="seconds")
reason = f"browser sender exceeded {timeout_seconds}s timeout guard"
logger.exception("Account %s browser sender timed out", account_name)
for target_name in user.get("targets") or []:
_persist_browser_send_failure(
user,
target_name,
"",
"timeout",
reason,
attempted_at,
)
finally:
if account_lock_handle is not None:
_release_browser_account_lock(account_lock_handle, account_lock_path, account_name)
@@ -2133,13 +2496,14 @@ async def _do_user_task_locked(browser, user, send_strategy, profile_config, fri
logger.info("Account %s started the message flow", account_name)
try:
index_targets = targets
last_message = ""
schedule_now = datetime.now(_schedule_timezone())
if not _friend_index_complete_today(user, schedule_now):
index_targets = list(user.get("targets") or targets)
logger.info(
"Account %s will refresh today's friend index while delivering targets=%s",
"Account %s will refresh today's friend index while delivering targetCount=%s",
account_name,
targets,
len(targets),
)
async for target_name in scroll_and_select_user(
page,
@@ -2153,22 +2517,32 @@ async def _do_user_task_locked(browser, user, send_strategy, profile_config, fri
message = ""
chat_input = None
last_own_message_before = None
send_receipt = {}
try:
await save_debug_artifacts(page, account_name, target_name, "selected-friend")
chat_input, selector_used = await locate_chat_input(page)
logger.info("Using chat input selector %s for %s/%s", selector_used, account_name, target_name)
message = build_message()
logger.info("Prepared message for %s/%s: %r", account_name, target_name, message)
previous_entry = dict(user.get("message_history") or {}).get(target_name) or {}
previous_message = str(previous_entry.get("message") or "")
message = build_message(previous_message=previous_message, last_message=last_message)
last_message = message
logger.info(
"Prepared message for %s/%s length=%s previousMatch=%s",
account_name,
target_name,
len(message),
bool(previous_message and previous_message == message),
)
last_own_message_before = await snapshot_last_own_message(
page,
chat_input=chat_input,
)
logger.info(
"Last own message before send for %s/%s: %r",
"Last own message before send for %s/%s length=%s",
account_name,
target_name,
(last_own_message_before or {}).get("text", ""),
len((last_own_message_before or {}).get("text", "")),
)
lines = message.split("\n")
@@ -2179,6 +2553,7 @@ async def _do_user_task_locked(browser, user, send_strategy, profile_config, fri
await save_debug_artifacts(page, account_name, target_name, "typed-message")
im_observer_summary = await start_im_send_observer(page, account_name, target_name)
logger.info("Pressing Enter to send message for %s/%s", account_name, target_name)
await chat_input.press("Enter")
@@ -2188,17 +2563,42 @@ async def _do_user_task_locked(browser, user, send_strategy, profile_config, fri
message,
before_snapshot=last_own_message_before,
)
im_summary = await im_observer_summary()
logger.info(
"IM send observer summary for %s/%s: request=%s response=%s events=%s error=%s",
account_name,
target_name,
im_summary.get("send_request_seen"),
im_summary.get("send_response_seen"),
im_summary.get("events"),
im_summary.get("error", ""),
)
send_receipt = im_summary.get("send_receipt") or {}
server_ok = bool(send_receipt.get("ok"))
detail = (
f"{detail}; im_observer request={im_summary.get('send_request_seen')} "
f"response={im_summary.get('send_response_seen')} serverOk={server_ok} "
f"logid={send_receipt.get('logid', '')} reason={send_receipt.get('reason', '')}"
)
await save_debug_artifacts(page, account_name, target_name, "after-send")
if not sent_ok:
if im_summary.get("enabled"):
if not im_summary.get("send_request_seen"):
raise RuntimeError(f"server send request was not observed; {detail}")
if not im_summary.get("send_response_seen"):
raise RuntimeError(f"server send response was not observed; {detail}")
if not server_ok:
raise RuntimeError(f"server send receipt rejected; {detail}")
elif not sent_ok:
raise RuntimeError(detail)
logger.info("Message send confirmed for %s/%s: %s", account_name, target_name, detail)
logger.info("Message send confirmed for %s/%s by server receipt: %s", account_name, target_name, detail)
_persist_browser_send_success(
user,
target_name,
message,
datetime.now(timezone.utc).isoformat(timespec="seconds"),
server_receipt=send_receipt,
)
interval = _random_delay_seconds(
send_strategy,
@@ -2216,7 +2616,7 @@ async def _do_user_task_locked(browser, user, send_strategy, profile_config, fri
message,
before_snapshot=last_own_message_before,
)
if sent_ok:
if sent_ok and not str(exc).startswith("server send"):
logger.warning(
"Recovered send outcome for %s/%s after failure: %s",
account_name,
@@ -2228,6 +2628,7 @@ async def _do_user_task_locked(browser, user, send_strategy, profile_config, fri
target_name,
message,
datetime.now(timezone.utc).isoformat(timespec="seconds"),
server_receipt=send_receipt,
)
continue
@@ -2265,6 +2666,7 @@ async def _do_user_task_locked(browser, user, send_strategy, profile_config, fri
category,
reason,
datetime.now(timezone.utc).isoformat(timespec="seconds"),
server_receipt=send_receipt,
)
interval = _random_delay_seconds(
send_strategy,
@@ -2320,12 +2722,17 @@ async def runTasks():
logger.info("Starting tasks with config")
logger.info("multiTask=%s taskCount=%s", active_config["multiTask"], active_config["taskCount"])
logger.info("messageTemplate=%s", active_config["messageTemplate"])
logger.info("sendStrategy=%s", active_config.get("sendStrategy", {}))
logger.info("hitokotoTypes=%s", active_config["hitokotoTypes"])
send_strategy = active_config.get("sendStrategy", {}) or {}
logger.info(
"messageConfig templateConfigured=%s variantCount=%s shuffleTargets=%s",
bool(str(active_config.get("messageTemplate") or "").strip()),
len(send_strategy.get("messageVariants") or []),
bool(send_strategy.get("shuffleTargets", True)),
)
logger.info("hitokotoTypeCount=%s", len(active_config.get("hitokotoTypes") or []))
logger.info("enabledUsers=%s disabledUsers=%s", len(active_user_data), len(disabled_user_data))
for user in active_user_data:
logger.info("user=%s targets=%s", user.get("username", "unknown"), user["targets"])
logger.info("user=%s targetCount=%s", user.get("username", "unknown"), len(user["targets"]))
for user in disabled_user_data:
logger.info("skipping disabled user=%s", user.get("username", "unknown"))
@@ -2350,13 +2757,7 @@ def task_run_lock():
lock_path.parent.mkdir(parents=True, exist_ok=True)
def _lock_owner_is_alive(pid):
try:
os.kill(pid, 0)
except ProcessLookupError:
return False
except PermissionError:
return True
return True
return _pid_is_alive(pid)
while True:
try:
+351 -13
View File
@@ -3,7 +3,7 @@ import os
import shutil
from pathlib import Path
from fastapi import FastAPI, HTTPException
from fastapi import FastAPI, HTTPException, Request, Response
import uvicorn
from playwright.async_api import async_playwright
@@ -11,7 +11,29 @@ from core.login import collect_login_result
REMOTE_LOGIN_URL = "https://creator.douyin.com/"
WWW_SELF_URL = "https://www.douyin.com/user/self"
PROFILE_DIR = Path("/data/login-profile")
GENERIC_WWW_NAMES = {
"",
"我的",
"",
"抖音官网账号",
"精选",
"推荐",
"搜索",
"关注",
"朋友",
"直播",
"放映厅",
"短剧",
"小游戏",
"客户端",
"通知",
"私信",
"投稿",
"海量优质视频内容",
"抖音精选电脑版",
}
class LoginDesktopManager:
@@ -41,10 +63,18 @@ class LoginDesktopManager:
"--disable-dev-shm-usage",
"--no-sandbox",
"--start-maximized",
"--disable-gpu",
"--disable-gpu-compositing",
"--disable-software-rasterizer",
"--disable-accelerated-2d-canvas",
"--disable-accelerated-video-decode",
"--renderer-process-limit=2",
"--disable-background-networking",
"--disable-sync",
"--disable-features=Translate,MediaRouter,OptimizationHints,AutofillServerCommunication",
],
)
self.page = self.context.pages[0] if self.context.pages else await self.context.new_page()
await self.page.goto(REMOTE_LOGIN_URL, wait_until="domcontentloaded", timeout=60000)
def _context_is_closed(self):
return not self.context or getattr(self.context, "_impl_obj", None) is None
@@ -89,19 +119,53 @@ class LoginDesktopManager:
await self.start()
async def status(self):
await self.ensure_running()
logged_in = False
username = ""
unique_id = ""
page = await self._get_active_page()
current_url = page.url if page else ""
current_url = ""
if not self.context or self._context_is_closed():
return {
"running": False,
"logged_in": False,
"username": "",
"unique_id": "",
"current_url": "",
"profile_dir": str(PROFILE_DIR),
}
page = None
try:
result = await collect_login_result(page, self.context, timeout_ms=1000)
logged_in = True
username = result["username"]
unique_id = result["unique_id"]
if self.page and not self.page.is_closed():
page = self.page
else:
for candidate in self.context.pages:
if not candidate.is_closed():
self.page = candidate
page = candidate
break
except Exception:
pass
self.page = None
self.context = None
return {
"running": False,
"logged_in": False,
"username": "",
"unique_id": "",
"current_url": "",
"profile_dir": str(PROFILE_DIR),
}
if page:
current_url = page.url
try:
result = await collect_login_result(page, self.context, timeout_ms=1000)
logged_in = True
username = result["username"]
unique_id = result["unique_id"]
except Exception:
pass
return {
"running": True,
"logged_in": logged_in,
@@ -126,6 +190,99 @@ class LoginDesktopManager:
return result
def _clean_www_display_name(value):
raw = str(value or "").replace("\u200b", "").replace("\ufeff", "")
name = " ".join(raw.split()).strip(" -_|·•")
if not name or name in GENERIC_WWW_NAMES:
return ""
if len(name) > 40:
return ""
if any(token in name for token in ("登录", "注册", "关注", "粉丝", "获赞", "作品", "喜欢", "收藏", "观看历史", "海量优质视频", "抖音旗下")):
return ""
return name
async def collect_www_identity_from_page(page):
return await page.evaluate(
r"""() => {
const normalize = (value) => String(value || "")
.replace(/[\u200b\u200c\u200d\ufeff]/g, "")
.replace(/\s+/g, " ")
.trim();
const bad = new Set(["", "我的", "", "抖音官网账号", "精选", "推荐", "搜索", "关注", "朋友", "直播", "放映厅", "短剧", "小游戏", "客户端", "通知", "私信", "投稿"]);
const candidates = [];
const add = (value, source) => {
const text = normalize(value).replace(/^@+/, "").trim();
if (!text || bad.has(text) || text.length > 40) return;
if (/登录|注册|关注|粉丝|获赞|作品|喜欢|收藏|观看历史/.test(text)) return;
candidates.push({ text, source });
};
add(document.querySelector('[data-e2e="user-title"]')?.innerText, "data-e2e=user-title");
add(document.querySelector('[class*="userName"], [class*="UserName"], [class*="nickname"], [class*="Nickname"], h1')?.innerText, "profile-name-selector");
add(document.title.split(/[|\-]/)[0], "document-title");
add(document.querySelector('meta[property="og:title"]')?.content?.split(/[|\-]/)[0], "og:title");
const selfLink = document.querySelector('a[href*="/user/self"]');
if (selfLink) {
const root = selfLink.closest('div')?.parentElement?.parentElement || selfLink;
const lines = normalize(root.innerText).split(/关注|粉丝|获赞|我的喜欢|我的收藏|观看历史|稍后再看|我的作品|我的预约|我的订单|退出登录/);
add(lines[0], "self-link-root");
}
if (location.pathname.includes('/user/')) {
add(document.querySelector('meta[name="description"]')?.content?.split(/[,|-]/)[0], "description");
}
return {
url: location.href,
title: document.title,
profileHref: selfLink?.href || "",
candidates,
};
}"""
)
async def collect_www_login_result(page, context):
cookies = await context.cookies()
try:
await page.goto(WWW_SELF_URL, wait_until="domcontentloaded", timeout=60000)
await page.wait_for_load_state("networkidle", timeout=15000)
except Exception:
pass
identity = await collect_www_identity_from_page(page)
username = ""
for item in identity.get("candidates") or []:
username = _clean_www_display_name(item.get("text"))
if username:
break
if not username:
identity = await collect_www_identity_from_page(page)
for item in identity.get("candidates") or []:
username = _clean_www_display_name(item.get("text"))
if username:
break
if not username:
username = "抖音官网账号"
uid_cookie = ""
for cookie in cookies:
if cookie.get("name") in {"uid_tt", "uid_tt_ss", "sid_uid", "passport_csrf_token"}:
uid_cookie = str(cookie.get("value") or "")
if uid_cookie:
break
suffix = "".join(ch for ch in uid_cookie if ch.isalnum())[:24]
unique_id = f"web-self-{suffix or username}"
return {
"unique_id": unique_id,
"username": username,
"cookies": cookies,
}
manager = LoginDesktopManager()
app = FastAPI(title="Douyin Login Desktop")
@@ -164,12 +321,193 @@ async def reset():
@app.post("/export")
async def export():
page = await manager._get_active_page()
try:
result = await manager.export()
return {"ok": True, "result": result}
except Exception as exc:
raise HTTPException(status_code=400, detail=str(exc))
except Exception as creator_exc:
try:
result = await collect_www_login_result(page, manager.context)
except Exception as www_exc:
raise HTTPException(status_code=400, detail=f"creator export failed: {creator_exc}; www export failed: {www_exc}")
return {"ok": True, "result": result}
@app.get("/debug/screenshot")
async def debug_screenshot():
page = await manager._get_active_page()
data = await page.screenshot(full_page=False, type="png")
return Response(content=data, media_type="image/png")
@app.get("/debug/snapshot")
async def debug_snapshot():
page = await manager._get_active_page()
items = await page.evaluate(
r"""() => {
const visible = (el) => {
const r = el.getBoundingClientRect();
const s = getComputedStyle(el);
return r.width > 0 && r.height > 0 && s.visibility !== 'hidden' && s.display !== 'none';
};
const textOf = (el) => String(el.innerText || el.textContent || el.getAttribute('aria-label') || el.title || '').replace(/\s+/g, ' ').trim();
const nodes = [...document.querySelectorAll('button, a, [role="button"], [aria-label], input, textarea, [contenteditable="true"], [class*="message"], [class*="chat"], [class*="im"]')];
return nodes.filter(visible).slice(0, 300).map((el, i) => {
const r = el.getBoundingClientRect();
return {
i,
tag: el.tagName.toLowerCase(),
role: el.getAttribute('role') || '',
aria: el.getAttribute('aria-label') || '',
title: el.title || '',
text: textOf(el).slice(0, 120),
cls: String(el.className || '').slice(0, 120),
contenteditable: el.getAttribute('contenteditable') || '',
rect: {x: Math.round(r.x), y: Math.round(r.y), w: Math.round(r.width), h: Math.round(r.height)}
};
});
}"""
)
return {"url": page.url, "title": await page.title(), "items": items}
@app.post("/debug/action")
async def debug_action(request: Request):
page = await manager._get_active_page()
payload = await request.json()
action = payload.get("action")
if action == "click_text":
text = str(payload.get("text") or "")
exact = bool(payload.get("exact", False))
await page.get_by_text(text, exact=exact).first.click(timeout=int(payload.get("timeout", 5000)))
elif action == "click_at":
await page.mouse.click(float(payload["x"]), float(payload["y"]))
elif action == "wheel":
await page.mouse.wheel(float(payload.get("dx", 0)), float(payload.get("dy", 0)))
elif action == "type":
await page.keyboard.type(str(payload.get("text") or ""), delay=int(payload.get("delay", 20)))
elif action == "press":
await page.keyboard.press(str(payload.get("key") or "Enter"))
elif action == "goto":
await page.goto(str(payload.get("url") or REMOTE_LOGIN_URL), wait_until="commit", timeout=15000)
elif action == "eval":
result = await page.evaluate(str(payload.get("script") or "undefined"))
return {"ok": True, "result": result, "url": page.url}
elif action == "list_frames":
frames = []
for fr in page.frames:
frames.append({"url": fr.url, "name": fr.name})
return {"ok": True, "frames": frames, "url": page.url}
elif action == "eval_in_frame":
frame_url_match = str(payload.get("frame_url") or "")
script = str(payload.get("script") or "undefined")
target_frame = None
for fr in page.frames:
if frame_url_match and frame_url_match in fr.url:
target_frame = fr
break
if not target_frame:
return {"ok": False, "error": f"no frame matching '{frame_url_match}' found", "url": page.url}
result = await target_frame.evaluate(script)
return {"ok": True, "result": result, "url": page.url, "frame_url": target_frame.url}
elif action == "click_in_frame":
frame_url_match = str(payload.get("frame_url") or "")
selector = str(payload.get("selector") or "")
target_frame = None
for fr in page.frames:
if frame_url_match and frame_url_match in fr.url:
target_frame = fr
break
if not target_frame:
return {"ok": False, "error": f"no frame matching '{frame_url_match}' found"}
await target_frame.click(selector, timeout=int(payload.get("timeout", 5000)))
return {"ok": True, "url": page.url, "frame_url": target_frame.url}
elif action == "type_in_frame":
frame_url_match = str(payload.get("frame_url") or "")
selector = str(payload.get("selector") or "")
text = str(payload.get("text") or "")
target_frame = None
for fr in page.frames:
if frame_url_match and frame_url_match in fr.url:
target_frame = fr
break
if not target_frame:
return {"ok": False, "error": "no frame found"}
await target_frame.fill(selector, text, timeout=int(payload.get("timeout", 5000)))
return {"ok": True, "url": page.url}
else:
raise HTTPException(status_code=400, detail=f"unknown action {action!r}")
return {"ok": True, "url": page.url}
# ---- Network capture (codex-added observability) ----
_net_log = []
_net_capturing = False
_net_max = 500
@app.post("/debug/net_capture")
async def net_capture(request: Request):
global _net_capturing
payload = await request.json()
action_type = payload.get("type", "start")
page = await manager._get_active_page()
if action_type == "start":
_net_log.clear()
_net_capturing = True
async def on_request(req):
if not _net_capturing:
return
if len(_net_log) >= _net_max:
return
try:
_net_log.append({
"ts": __import__("time").time(),
"phase": "request",
"method": req.method,
"url": req.url,
"headers": dict(req.headers),
})
except Exception:
pass
async def on_response(resp):
if not _net_capturing:
return
if len(_net_log) >= _net_max:
return
try:
body_preview = None
try:
body_preview = (await resp.text())[:300]
except Exception:
pass
_net_log.append({
"ts": __import__("time").time(),
"phase": "response",
"url": resp.url,
"status": resp.status,
"headers": dict(resp.headers),
"body": body_preview,
})
except Exception:
pass
page.on("request", lambda req: __import__("asyncio").ensure_future(on_request(req)))
page.on("response", lambda resp: __import__("asyncio").ensure_future(on_response(resp)))
return {"ok": True, "msg": "capture started"}
elif action_type == "stop":
_net_capturing = False
return {"ok": True, "count": len(_net_log)}
elif action_type == "get":
return {"ok": True, "count": len(_net_log), "log": _net_log.copy()}
elif action_type == "clear":
_net_log.clear()
return {"ok": True}
return {"ok": False, "error": "unknown type"}
@app.get("/debug/net_log")
async def get_net_log():
return {"count": len(_net_log), "capturing": _net_capturing, "log": _net_log.copy()}
if __name__ == "__main__":
uvicorn.run(app, host="0.0.0.0", port=int(os.getenv("LOGIN_DESKTOP_API_PORT", "18090")), reload=False)
+1
View File
@@ -0,0 +1 @@
+141
View File
@@ -0,0 +1,141 @@
import os
import unittest
from datetime import datetime, timezone
from unittest.mock import patch
from core import msg_builder, tasks
from core.send_state import history_entry_is_strong_confirmed_today
from webui import ops
class SendStateTests(unittest.TestCase):
def setUp(self):
self.now = datetime(2026, 7, 10, 14, 0, tzinfo=timezone.utc)
self.window = {
"enabled": True,
"startHour": 10,
"endHour": 18,
"scheduleIntervalMinutes": 20,
}
def test_strong_confirmation_is_the_only_sent_state(self):
strong = {
"sentAt": self.now.isoformat(),
"status": "confirmed",
"confirmationLevel": "strong",
"needsVerification": False,
}
weak = {
"sentAt": self.now.isoformat(),
"status": "unconfirmed",
"confirmationLevel": "weak",
"needsVerification": True,
}
legacy = {"sentAt": self.now.isoformat()}
self.assertTrue(history_entry_is_strong_confirmed_today(strong, self.now))
self.assertFalse(history_entry_is_strong_confirmed_today(weak, self.now))
self.assertFalse(history_entry_is_strong_confirmed_today(legacy, self.now))
def test_unconfirmed_target_is_visible_and_retryable(self):
user = {
"username": "demo",
"unique_id": "demo",
"targets": ["target"],
"message_history": {
"target": {
"sentAt": self.now.isoformat(),
"status": "unconfirmed",
"confirmationLevel": "weak",
"needsVerification": True,
}
},
"failure_queue": {
"target": {
"lastAttemptAt": self.now.isoformat(),
"category": "send_unconfirmed",
"attemptCount": 1,
}
},
}
status = ops._build_target_status(user, "target", self.now, self.window)
self.assertEqual("unconfirmed", status["status"])
self.assertFalse(tasks._target_sent_today(user, "target", self.now))
self.assertEqual(["target"], tasks._pending_failed_targets(user, self.now))
self.assertEqual(["target"], tasks._pending_unsent_targets(user, self.now)[0])
def test_legacy_sent_at_only_record_is_retryable(self):
user = {
"targets": ["target"],
"message_history": {"target": {"sentAt": self.now.isoformat()}},
}
status = ops._build_target_status(user, "target", self.now, self.window)
self.assertEqual("unconfirmed", status["status"])
self.assertTrue(status["legacyUnverified"])
self.assertEqual(["target"], tasks._pending_failed_targets(user, self.now))
self.assertEqual(["target"], tasks._pending_unsent_targets(user, self.now)[0])
def test_unsent_retry_respects_non_retryable_and_attempt_limit(self):
user = {
"targets": ["blocked", "exhausted", "retryable"],
"failure_queue": {
"blocked": {
"lastAttemptAt": self.now.isoformat(),
"category": "protocol_user_blocked",
"attemptCount": 1,
},
"exhausted": {
"lastAttemptAt": self.now.isoformat(),
"category": "timeout",
"attemptCount": 3,
},
"retryable": {
"lastAttemptAt": self.now.isoformat(),
"category": "timeout",
"attemptCount": 2,
},
},
}
retryable, skipped = tasks._pending_unsent_targets(user, self.now)
self.assertEqual(["retryable"], retryable)
self.assertEqual(2, len(skipped))
def test_manual_force_all_still_includes_strong_confirmed_targets(self):
user = {
"username": "demo",
"targets": ["confirmed", "pending"],
"message_history": {
"confirmed": {
"sentAt": self.now.isoformat(),
"status": "confirmed",
"confirmationLevel": "strong",
"needsVerification": False,
}
},
}
config = {"dailySendWindow": self.window}
with patch.dict(os.environ, {"SPARKFLOW_MANUAL_RUN": "1"}, clear=False):
prepared = tasks._prepare_active_users_for_run(config, [user])
self.assertEqual(["confirmed", "pending"], prepared[0]["targets"])
def test_message_choice_avoids_previous_and_last_when_possible(self):
with patch.object(
msg_builder,
"build_message_candidates",
return_value=["A", "B", "C"],
):
selected = msg_builder.build_message(previous_message="A", last_message="B")
self.assertEqual("C", selected)
if __name__ == "__main__":
unittest.main()
+161
View File
@@ -0,0 +1,161 @@
import errno
import os
import tempfile
import time
import unittest
from pathlib import Path
from unittest.mock import patch
from fastapi.testclient import TestClient
from core import tasks
from webui import app as app_module
from webui import ops
class WebUiSafetyTests(unittest.TestCase):
def test_windows_invalid_pid_probe_is_treated_as_dead(self):
error = OSError(errno.EINVAL, "invalid pid")
error.winerror = 87
with patch.object(ops.os, "kill", side_effect=error):
self.assertFalse(ops._pid_is_alive(999999))
with patch.object(tasks.os, "kill", side_effect=error):
self.assertFalse(tasks._pid_is_alive(999999))
def test_stale_lock_inspection_does_not_delete_file(self):
with tempfile.TemporaryDirectory() as temp_dir:
root = Path(temp_dir)
lock_path = root / "logs" / "task.run.lock"
lock_path.parent.mkdir(parents=True)
lock_path.write_text("99999999\n", encoding="utf-8")
old = time.time() - 10800
os.utime(lock_path, (old, old))
with patch.object(ops, "repo_root", return_value=root):
status = ops.task_run_lock_status()
self.assertTrue(lock_path.exists())
self.assertTrue(status["stale"])
self.assertFalse(status["running"])
self.assertEqual("owner_pid_missing", status["staleReason"])
def test_overview_snapshot_excludes_sensitive_payloads(self):
send_console = {
"now": "2026-07-10T22:00:00+08:00",
"summary": {
"enabled_accounts": 1,
"total_targets": 2,
"today_confirmed_targets": 1,
"today_unconfirmed_targets": 1,
"today_failed_targets": 0,
"today_account_blocked_targets": 0,
"today_attention_targets": 1,
"today_pending_targets": 0,
"today_unprocessed_targets": 0,
"today_remaining_targets": 1,
"today_warning_count": 0,
"last_confirmed_at": "2026-07-10T21:00:00+08:00",
"all_confirmed": False,
},
"accounts": [
{
"unique_id": "account-1",
"username": "Account",
"state": "attention",
"total_targets": 2,
"confirmed_targets": [{"message": "secret message"}],
"attention_count": 1,
"pending_count": 0,
"last_confirmed_at": "2026-07-10T21:00:00+08:00",
}
],
}
with (
patch.object(ops, "get_send_console_snapshot", return_value=send_console),
patch.object(
ops,
"get_schedule_snapshot",
return_value={"label": "10:00-18:00/20m", "nextTriggerAt": ""},
),
patch.object(
ops,
"task_run_lock_status",
return_value={"running": False, "stale": False, "ageSeconds": 0},
),
):
payload = ops.get_overview_snapshot()
serialized = repr(payload)
self.assertNotIn("secret message", serialized)
self.assertNotIn("cookies", serialized)
self.assertNotIn("serverReceipt", serialized)
self.assertNotIn("reason", serialized)
self.assertEqual(1, payload["summary"]["attention"])
def test_primary_pages_and_local_icons_render(self):
client = TestClient(app_module.app, raise_server_exceptions=False)
self.assertEqual(200, client.get("/login").status_code)
self.assertEqual(200, client.get("/static/lucide.min.js").status_code)
with patch.object(app_module, "current_user", return_value="admin"):
for path in ("/", "/ops/send-console", "/ops/logs"):
response = client.get(path)
self.assertEqual(200, response.status_code, path)
self.assertEqual("no-store", response.headers["cache-control"])
def test_overview_api_requires_authentication_and_disables_cache(self):
client = TestClient(app_module.app)
response = client.get("/api/ops/overview")
self.assertEqual(401, response.status_code)
self.assertEqual("no-store", response.headers["cache-control"])
with (
patch.object(app_module, "current_user", return_value="admin"),
patch.object(
app_module,
"get_overview_snapshot",
return_value={
"now": "2026-07-10T22:00:00+08:00",
"schedule": {},
"task": {},
"summary": {},
"accounts": [],
},
),
):
response = client.get("/api/ops/overview")
self.assertEqual(200, response.status_code)
self.assertEqual("no-store", response.headers["cache-control"])
def test_public_settings_and_template_do_not_expose_server_password(self):
with patch.object(
app_module,
"get_app_settings",
return_value={
"server_host": "example",
"server_username": "root",
"server_password": "secret",
"session_secret": "secret",
"admin_password_hash": "hash",
"compose_root": "/opt/app",
"ui_port": 8787,
"login_desktop_api_url": "http://127.0.0.1:18090",
},
):
public = app_module.public_app_settings()
self.assertNotIn("server_password", public)
self.assertNotIn("session_secret", public)
dashboard = (
Path(app_module.TEMPLATES_DIR) / "dashboard.html"
).read_text(encoding="utf-8")
self.assertNotIn("server_password", dashboard)
self.assertNotIn("server_username", dashboard)
self.assertNotIn("server_host", dashboard)
if __name__ == "__main__":
unittest.main()
+189 -31
View File
@@ -1,6 +1,5 @@
import json
import logging
import traceback
from datetime import datetime, timedelta, timezone
from pathlib import Path
import urllib.error
@@ -16,6 +15,7 @@ from starlette.middleware.sessions import SessionMiddleware
logger = logging.getLogger(__name__)
from core.friends import fetch_account_friends
from core.send_state import history_entry_is_strong_confirmed_today, parse_sent_at
from core.tasks import run_browser_tasks, task_run_lock
from utils.config import (
get_app_settings,
@@ -41,6 +41,7 @@ from webui.auth import (
)
from webui.ops import (
TASK_ALREADY_RUNNING,
get_overview_snapshot,
get_ops_snapshot,
read_log_tail,
refresh_proxy,
@@ -113,24 +114,78 @@ def _schedule_timezone():
def _parse_sent_at(raw_value):
if not raw_value:
return None
raw = str(raw_value).strip()
if raw.endswith("Z"):
raw = raw[:-1] + "+00:00"
try:
parsed = datetime.fromisoformat(raw)
except ValueError:
return None
if parsed.tzinfo is None:
return parsed.replace(tzinfo=_schedule_timezone())
return parsed.astimezone(_schedule_timezone())
return parse_sent_at(raw_value, _schedule_timezone())
def _history_entry_strong_confirmed_today(entry):
return history_entry_is_strong_confirmed_today(
entry,
datetime.now(_schedule_timezone()),
)
def _target_sent_today(account, target_name):
entry = dict(account.get("message_history") or {}).get(target_name) or {}
return _history_entry_strong_confirmed_today(entry)
def _target_unconfirmed_today(account, target_name):
entry = dict(account.get("message_history") or {}).get(target_name) or {}
sent_at = _parse_sent_at(entry.get("sentAt"))
return bool(sent_at and sent_at.date() == datetime.now(_schedule_timezone()).date())
if sent_at and sent_at.date() == datetime.now(_schedule_timezone()).date() and not _history_entry_strong_confirmed_today(entry):
return True
failure_entry = dict(account.get("failure_queue") or {}).get(target_name) or {}
last_attempt_at = _parse_sent_at(failure_entry.get("lastAttemptAt"))
return bool(
last_attempt_at
and last_attempt_at.date() == datetime.now(_schedule_timezone()).date()
and str(failure_entry.get("category") or "") == "send_unconfirmed"
)
def mark_target_unconfirmed(account, target_name, *, reason="manual_reset_possible_false_positive", force=False):
now = datetime.now(timezone.utc).isoformat(timespec="seconds")
history = dict(account.get("message_history") or {})
existing = dict(history.get(target_name) or {})
sent_at = _parse_sent_at(existing.get("sentAt"))
today = datetime.now(_schedule_timezone()).date()
if existing and sent_at and sent_at.date() != today and not force:
return False
if existing and _history_entry_strong_confirmed_today(existing) and not force:
return False
previous_status = existing.get("status") or ("legacy_sentAt_only" if existing else "missing_history")
message = str(existing.get("message") or "")
history[target_name] = {
**existing,
"message": message,
"sentAt": existing.get("sentAt") or now,
"status": "unconfirmed",
"confirmationLevel": existing.get("confirmationLevel") or "legacy",
"confirmationSource": existing.get("confirmationSource") or "manual_reset",
"confirmationDetail": existing.get("confirmationDetail") or "已手动标记为待核验/待补发。",
"needsVerification": True,
"resetAt": now,
"resetReason": reason,
"previousStatus": previous_status,
}
account["message_history"] = history
queue = dict(account.get("failure_queue") or {})
existing_failure = dict(queue.get(target_name) or {})
queue[target_name] = {
"category": "send_unconfirmed",
"reason": reason,
"message": message,
"firstAttemptAt": existing_failure.get("firstAttemptAt") or now,
"lastAttemptAt": now,
"attemptCount": int(existing_failure.get("attemptCount") or 0) + 1,
"lastRunMode": "manual_reset",
"confirmationLevel": history[target_name].get("confirmationLevel"),
"confirmationSource": history[target_name].get("confirmationSource"),
}
account["failure_queue"] = queue
return True
def login_desktop_api_url():
@@ -201,6 +256,22 @@ def save_exported_login_result(login_result: dict, *, relogin_unique_id: str = "
return account, "created"
def public_app_settings():
settings = get_app_settings(force_reload=True)
allowed_keys = (
"compose_root",
"ui_host",
"ui_port",
"ops_log_file",
"proxy_refresh_script",
"login_desktop_api_url",
"login_desktop_public_url",
"login_desktop_public_scheme",
"login_desktop_public_port",
)
return {key: settings.get(key) for key in allowed_keys}
def create_app():
settings = get_app_settings()
app = FastAPI(title="DouYin Spark Flow Admin")
@@ -213,28 +284,35 @@ def create_app():
)
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)
logger.exception("Unhandled exception on %s %s", request.method, request.url.path)
return PlainTextResponse(
"Internal Server Error",
status_code=500,
headers={"Cache-Control": "no-store"},
)
def render_template(request, template_name, context=None, status_code=200):
base_context = context or {}
base_context = dict(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),
"app_settings": public_app_settings(),
"login_desktop_public_url": login_desktop_public_url(request),
}
)
return templates.TemplateResponse(request, template_name, base_context, status_code=status_code)
return templates.TemplateResponse(
request,
template_name,
base_context,
status_code=status_code,
headers={"Cache-Control": "no-store"},
)
def redirect(path="/", status_code=303):
return RedirectResponse(url=path, status_code=status_code)
@@ -250,6 +328,17 @@ def create_app():
def pop_flash(request):
return request.session.pop("flash", None)
@app.get("/debug-artifacts/{artifact_path:path}")
async def debug_artifact(request: Request, artifact_path: str):
maybe_redirect = require_user(request)
if maybe_redirect:
return maybe_redirect
root = DEBUG_ARTIFACTS_DIR.resolve()
candidate = (root / artifact_path).resolve()
if root not in candidate.parents or not candidate.is_file():
return PlainTextResponse("Not found", status_code=404)
return FileResponse(candidate, headers={"Cache-Control": "no-store"})
@app.get("/login", response_class=HTMLResponse)
async def login_page(request: Request):
if current_user(request):
@@ -304,6 +393,19 @@ def create_app():
clear_session(request)
return redirect("/login")
@app.get("/api/ops/overview")
async def ops_overview(request: Request):
if not current_user(request):
return JSONResponse(
{"error": "Unauthorized"},
status_code=401,
headers={"Cache-Control": "no-store"},
)
return JSONResponse(
get_overview_snapshot(),
headers={"Cache-Control": "no-store"},
)
@app.get("/", response_class=HTMLResponse)
async def dashboard(request: Request):
maybe_redirect = require_user(request)
@@ -476,7 +578,11 @@ def create_app():
updated_account = find_account(get_userData(force_reload=True), unique_id) or {}
if _target_sent_today(updated_account, target_name):
flash(request, f"Retried {account.get('username', 'Account')} / {target_name} successfully.", "success")
flash(request, f"已重试 {account.get('username', 'Account')} / {target_name},并获得强证据确认。", "success")
elif _target_unconfirmed_today(updated_account, target_name):
failure_entry = dict(updated_account.get("failure_queue") or {}).get(target_name) or {}
reason = str(failure_entry.get("reason") or "Retry ran but did not get strong confirmation.")
flash(request, f"已执行 {account.get('username', 'Account')} / {target_name},但未强确认,已进入待核验/待补发:{reason}", "warning")
else:
account_failure = dict(updated_account.get("account_failure") or {})
affected_targets = list(account_failure.get("affectedTargets") or [])
@@ -488,6 +594,64 @@ def create_app():
flash(request, f"Retry did not succeed for {account.get('username', 'Account')} / {target_name}: {reason}", "error")
return redirect("/ops/send-console")
@app.post("/accounts/{unique_id}/mark-target-unconfirmed")
async def mark_account_target_unconfirmed(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)
target_name = str(form.get("target", "")).strip()
if not target_name:
flash(request, "Target is required.", "error")
return redirect("/ops/send-console")
accounts = get_userData(force_reload=True)
account = find_account(accounts, unique_id)
if not account:
flash(request, "Account not found.", "error")
return redirect("/ops/send-console")
changed = mark_target_unconfirmed(account, target_name)
if changed:
save_userData(accounts)
flash(request, f"已将 {account.get('username', 'Account')} / {target_name} 标记为待核验/待补发。", "warning")
else:
flash(request, f"{target_name} 已是强确认记录或不是今日记录,未自动重置。", "info")
return redirect("/ops/send-console")
@app.post("/ops/reset-today-unconfirmed")
async def reset_today_unconfirmed(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)
accounts = get_userData(force_reload=True)
changed_count = 0
for account in accounts:
for target_name in list(account.get("targets") or []):
entry = dict(account.get("message_history") or {}).get(target_name) or {}
sent_at = _parse_sent_at(entry.get("sentAt"))
if not sent_at or sent_at.date() != datetime.now(_schedule_timezone()).date():
continue
if _history_entry_strong_confirmed_today(entry):
continue
if mark_target_unconfirmed(account, target_name, reason="batch_reset_today_suspicious_success"):
changed_count += 1
if changed_count:
save_userData(accounts)
flash(request, f"已将 {changed_count} 条今日可疑成功记录标记为待核验/待补发。", "warning")
else:
flash(request, "没有找到需要重置的今日可疑成功记录。", "info")
return redirect("/ops/send-console")
@app.post("/config")
async def save_runtime_config(request: Request):
maybe_redirect = require_user(request)
@@ -565,15 +729,9 @@ def create_app():
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()
@@ -601,14 +759,14 @@ def create_app():
if not validate_csrf(request, str(form.get("csrf_token", ""))):
return Response("Invalid CSRF token", status_code=403)
pid = run_task_now()
pid = run_task_now(force_all=True)
if pid == TASK_ALREADY_RUNNING:
flash(request, "已有发送任务正在运行,本次补发全部对象没有启动。请等当前任务结束后再试。", "warning")
elif pid == -1:
flash(request, "Failed to start the full resend run. Check server logs for details.", "error")
else:
flash(request, f"已启动补发全部对象后台任务(pid {pid})。这只表示任务已启动,实际成功数请刷新发送控制台查看。", "info")
return redirect("/")
return redirect("/ops/send-console")
@app.post("/ops/run-failed")
async def run_failed_retry(request: Request):
+411 -99
View File
@@ -1,3 +1,4 @@
import errno
import json
import hashlib
import logging
@@ -11,6 +12,7 @@ from datetime import datetime, timedelta, timezone
from pathlib import Path
from zoneinfo import ZoneInfo
from core.send_state import history_entry_is_strong_confirmed_today, parse_sent_at
from utils.config import get_app_settings, get_config, get_userData, normalize_unique_id, repo_root, save_config
logger = logging.getLogger(__name__)
@@ -25,6 +27,26 @@ TASK_SCHEDULE_MARKERS = (
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)
CONFIRMATION_LABELS = {
"cdp_message_send_receipt": "服务端回执",
"browser_visible_count_increased": "页面回显",
"legacy_sentAt_only": "旧记录待核验",
"manual_reset": "人工标记待核验",
}
FAILURE_CATEGORY_LABELS = {
"send_unconfirmed": "待核验",
"login_required": "登录失效",
"friend_not_found": "未找到好友",
"friend_list_unavailable": "好友列表不可用",
"timeout": "执行超时",
"navigation": "页面访问失败",
"selector": "页面结构变化",
"browser_crash": "浏览器异常",
"protocol_user_blocked": "对方限制私信",
"protocol_user_not_in_conversation": "不在会话中",
}
def running_in_container():
return Path("/.dockerenv").exists()
@@ -71,6 +93,12 @@ def _pid_is_alive(pid):
return False
except PermissionError:
return True
except OSError as exc:
if getattr(exc, "winerror", None) == 87 or exc.errno == errno.ESRCH:
return False
if exc.errno in (errno.EPERM, errno.EACCES):
return True
raise
return True
@@ -84,32 +112,62 @@ def _parse_lock_pid(raw):
def task_run_lock_status():
lock_path = repo_root() / "logs" / "task.run.lock"
if not lock_path.exists():
return {"running": False, "path": str(lock_path), "pid": None, "ageSeconds": 0, "staleRemoved": False}
return {
"running": False,
"path": str(lock_path),
"pid": None,
"ageSeconds": 0,
"stale": False,
"staleReason": "",
"staleRemoved": False,
}
raw = lock_path.read_text(encoding="utf-8", errors="ignore")
pid = _parse_lock_pid(raw)
try:
age_seconds = max(0, int(datetime.now(timezone.utc).timestamp() - lock_path.stat().st_mtime))
except OSError:
return {"running": False, "path": str(lock_path), "pid": pid, "ageSeconds": 0, "staleRemoved": False}
return {
"running": False,
"path": str(lock_path),
"pid": pid,
"ageSeconds": 0,
"stale": True,
"staleReason": "lock_stat_failed",
"staleRemoved": False,
}
if pid is not None and not _pid_is_alive(pid):
try:
lock_path.unlink()
logger.warning("Removed stale task run lock owned by missing pid=%s", pid)
return {"running": False, "path": str(lock_path), "pid": pid, "ageSeconds": age_seconds, "staleRemoved": True}
except FileNotFoundError:
return {"running": False, "path": str(lock_path), "pid": pid, "ageSeconds": age_seconds, "staleRemoved": True}
return {
"running": False,
"path": str(lock_path),
"pid": pid,
"ageSeconds": age_seconds,
"stale": True,
"staleReason": "owner_pid_missing",
"staleRemoved": False,
}
if pid is None and age_seconds > 7200:
try:
lock_path.unlink()
logger.warning("Removed stale unreadable task run lock contents=%r", raw[:80])
return {"running": False, "path": str(lock_path), "pid": None, "ageSeconds": age_seconds, "staleRemoved": True}
except FileNotFoundError:
return {"running": False, "path": str(lock_path), "pid": None, "ageSeconds": age_seconds, "staleRemoved": True}
return {
"running": False,
"path": str(lock_path),
"pid": None,
"ageSeconds": age_seconds,
"stale": True,
"staleReason": "unreadable_lock",
"staleRemoved": False,
}
return {"running": True, "path": str(lock_path), "pid": pid, "ageSeconds": age_seconds, "staleRemoved": False}
return {
"running": True,
"path": str(lock_path),
"pid": pid,
"ageSeconds": age_seconds,
"stale": False,
"staleReason": "",
"staleRemoved": False,
}
def build_task_run_spec():
@@ -219,18 +277,30 @@ def _empty_result(stdout="", 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")
cwd_path = Path(cwd) if cwd else compose_root()
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()
with log_path.open("ab") as handle:
started_at = datetime.now(timezone.utc).astimezone().isoformat(timespec="seconds")
env_keys = ",".join(sorted((env or {}).keys())) or "none"
handle.write(
(
f"[WEB_TRIGGER] {started_at} start cwd={cwd_path} "
f"env_keys={env_keys} command={shlex.join([str(part) for part in args])}\n"
).encode("utf-8", errors="replace")
)
handle.flush()
process = subprocess.Popen(
args,
cwd=str(cwd_path),
stdout=handle,
stderr=subprocess.STDOUT,
env=child_env,
)
handle.write(f"[WEB_TRIGGER] {started_at} pid={process.pid}\n".encode("utf-8", errors="replace"))
handle.flush()
return process.pid
@@ -289,7 +359,7 @@ def get_task_container_rows():
return []
def run_task_now(*, unsent_only=False, failed_only=False):
def run_task_now(*, unsent_only=False, failed_only=False, force_all=False):
try:
lock_status = task_run_lock_status()
if lock_status.get("running"):
@@ -306,10 +376,19 @@ def run_task_now(*, unsent_only=False, failed_only=False):
"SPARKFLOW_MANUAL_RUN": "1",
"PYTHONUNBUFFERED": "1",
}
if failed_only:
if force_all:
run_env["SPARKFLOW_MANUAL_FORCE_ALL"] = "1"
elif failed_only:
run_env["SPARKFLOW_MANUAL_FAILED_ONLY"] = "1"
elif unsent_only:
run_env["SPARKFLOW_MANUAL_UNSENT_ONLY"] = "1"
logger.info(
"Starting background task command=%s cwd=%s env=%s log=%s",
command,
cwd,
{key: run_env[key] for key in sorted(run_env)},
log_file,
)
return run_background_command(
command,
log_file,
@@ -508,6 +587,49 @@ def current_daily_schedule():
return ""
def _next_window_trigger(now, window):
interval = max(1, int(window["scheduleIntervalMinutes"]))
candidates = []
for hour in range(int(window["startHour"]), int(window["endHour"])):
for minute in range(0, 60, interval):
candidates.append(now.replace(hour=hour, minute=minute, second=0, microsecond=0))
end_hour = int(window["endHour"])
candidates.append(now.replace(hour=end_hour, minute=0, second=0, microsecond=0))
if interval < 60:
candidates.append(now.replace(hour=end_hour, minute=interval, second=0, microsecond=0))
for candidate in sorted(set(candidates)):
if candidate > now:
return candidate
tomorrow = now + timedelta(days=1)
return tomorrow.replace(
hour=int(window["startHour"]),
minute=0,
second=0,
microsecond=0,
)
def get_schedule_snapshot(now=None):
now = now or datetime.now(_schedule_timezone())
window = _normalize_send_window()
label = current_daily_schedule()
if window.get("enabled"):
next_trigger = _next_window_trigger(now, window)
else:
try:
hour, minute = [int(part) for part in label.split(":", 1)]
next_trigger = now.replace(hour=hour, minute=minute, second=0, microsecond=0)
if next_trigger <= now:
next_trigger += timedelta(days=1)
except (TypeError, ValueError):
next_trigger = None
return {
"label": label,
"nextTriggerAt": next_trigger.isoformat(timespec="seconds") if next_trigger else "",
"nextTriggerDisplay": next_trigger.strftime("%m-%d %H:%M") if next_trigger else "",
}
def _schedule_timezone():
timezone_name = (
str(os.getenv("SPARKFLOW_TIMEZONE") or "").strip()
@@ -533,18 +655,7 @@ def _normalize_send_window():
def _parse_sent_at(raw_value, local_tz):
if not raw_value:
return None
raw = str(raw_value).strip()
if raw.endswith("Z"):
raw = raw[:-1] + "+00:00"
try:
parsed = datetime.fromisoformat(raw)
except ValueError:
return None
if parsed.tzinfo is None:
return parsed.replace(tzinfo=local_tz)
return parsed.astimezone(local_tz)
return parse_sent_at(raw_value, local_tz)
def _account_identity(user):
@@ -629,74 +740,154 @@ def _scheduled_send_time(user, target_name, send_window, now):
return start_of_window + timedelta(minutes=offset_minutes)
def _build_target_status(account, target_name, now, send_window):
history = dict(account.get("message_history") or {})
failure_queue = dict(account.get("failure_queue") or {})
friend_index = _friend_index_status(account, target_name)
history_entry = history.get(target_name) or {}
sent_at = _parse_sent_at(history_entry.get("sentAt"), now.tzinfo)
if sent_at and sent_at.date() == now.date():
return {
"target": target_name,
"status": "sent",
"message": str(history_entry.get("message") or ""),
"sentAt": sent_at.isoformat(timespec="seconds"),
"lastAttemptAt": "",
"category": "",
"reason": "",
"attemptCount": 0,
"scheduledAt": "",
"friendIndex": friend_index,
}
failure_entry = failure_queue.get(target_name) or {}
last_attempt_at = _parse_sent_at(failure_entry.get("lastAttemptAt"), now.tzinfo)
if last_attempt_at and last_attempt_at.date() == now.date():
return {
"target": target_name,
"status": "failed",
"message": str(failure_entry.get("message") or ""),
"sentAt": "",
"lastAttemptAt": last_attempt_at.isoformat(timespec="seconds"),
"category": str(failure_entry.get("category") or ""),
"reason": str(failure_entry.get("reason") or ""),
"attemptCount": int(failure_entry.get("attemptCount") or 0),
"scheduledAt": "",
"friendIndex": friend_index,
}
scheduled_at = None
if send_window.get("enabled"):
scheduled_at = _scheduled_send_time(account, target_name, send_window, now)
if scheduled_at > now:
return {
"target": target_name,
"status": "pending",
"message": "",
"sentAt": "",
"lastAttemptAt": "",
"category": "",
"reason": "",
"attemptCount": 0,
"scheduledAt": scheduled_at.isoformat(timespec="seconds"),
"friendIndex": friend_index,
}
def _base_target_status(account, target_name, now):
return {
"target": target_name,
"status": "unprocessed",
"status": "",
"message": "",
"sentAt": "",
"lastAttemptAt": "",
"category": "",
"reason": "",
"attemptCount": 0,
"scheduledAt": scheduled_at.isoformat(timespec="seconds") if scheduled_at else "",
"friendIndex": friend_index,
"scheduledAt": "",
"friendIndex": _friend_index_status(account, target_name),
"confirmationLevel": "",
"confirmationSource": "",
"confirmationDetail": "",
"needsVerification": False,
"legacyUnverified": False,
"displaySentAt": "",
"displayLastAttemptAt": "",
"displayScheduledAt": "",
"confirmationLabel": "",
"categoryLabel": "",
}
def _history_entry_is_strong_confirmed(history_entry, sent_at, now):
return history_entry_is_strong_confirmed_today(history_entry, now)
def _format_short_time(raw_value, now):
parsed = _parse_sent_at(raw_value, now.tzinfo)
if not parsed:
return ""
if parsed.date() == now.date():
return parsed.strftime("%H:%M:%S")
return parsed.strftime("%m-%d %H:%M")
def _finalize_target_status(item, now):
item = dict(item)
item["displaySentAt"] = _format_short_time(item.get("sentAt"), now)
item["displayLastAttemptAt"] = _format_short_time(item.get("lastAttemptAt"), now)
item["displayScheduledAt"] = _format_short_time(item.get("scheduledAt"), now)
source = str(item.get("confirmationSource") or "")
category = str(item.get("category") or "")
item["confirmationLabel"] = CONFIRMATION_LABELS.get(source, source or "-")
item["categoryLabel"] = FAILURE_CATEGORY_LABELS.get(category, category or "-")
return item
def _build_target_status(account, target_name, now, send_window):
history = dict(account.get("message_history") or {})
failure_queue = dict(account.get("failure_queue") or {})
item = _base_target_status(account, target_name, now)
history_entry = dict(history.get(target_name) or {})
sent_at = _parse_sent_at(history_entry.get("sentAt"), now.tzinfo)
if _history_entry_is_strong_confirmed(history_entry, sent_at, now):
item.update(
{
"status": "sent",
"message": str(history_entry.get("message") or ""),
"sentAt": sent_at.isoformat(timespec="seconds"),
"confirmationLevel": str(history_entry.get("confirmationLevel") or "strong"),
"confirmationSource": str(history_entry.get("confirmationSource") or "browser_visible_count_increased"),
"confirmationDetail": str(history_entry.get("confirmationDetail") or ""),
}
)
return _finalize_target_status(item, now)
failure_entry = dict(failure_queue.get(target_name) or {})
last_attempt_at = _parse_sent_at(failure_entry.get("lastAttemptAt"), now.tzinfo)
failure_is_today = bool(last_attempt_at and last_attempt_at.date() == now.date())
if sent_at and sent_at.date() == now.date():
confirmation_level = str(history_entry.get("confirmationLevel") or "legacy")
confirmation_source = str(history_entry.get("confirmationSource") or "legacy_sentAt_only")
confirmation_detail = str(history_entry.get("confirmationDetail") or "")
legacy_unverified = not history_entry.get("confirmationLevel")
if legacy_unverified:
confirmation_detail = confirmation_detail or "旧格式发送账本缺少强确认字段,已降级为待核验。"
item.update(
{
"status": "unconfirmed",
"message": str(history_entry.get("message") or failure_entry.get("message") or ""),
"sentAt": sent_at.isoformat(timespec="seconds"),
"lastAttemptAt": last_attempt_at.isoformat(timespec="seconds") if failure_is_today else "",
"category": str(failure_entry.get("category") or "send_unconfirmed"),
"reason": str(failure_entry.get("reason") or confirmation_detail or "发送记录缺少强确认,需要核验。"),
"attemptCount": int(failure_entry.get("attemptCount") or 0),
"confirmationLevel": confirmation_level,
"confirmationSource": confirmation_source,
"confirmationDetail": confirmation_detail,
"needsVerification": True,
"legacyUnverified": legacy_unverified,
}
)
return _finalize_target_status(item, now)
if failure_is_today:
category = str(failure_entry.get("category") or "")
status = "unconfirmed" if category == "send_unconfirmed" else "failed"
item.update(
{
"status": status,
"message": str(failure_entry.get("message") or ""),
"lastAttemptAt": last_attempt_at.isoformat(timespec="seconds"),
"category": category,
"reason": str(failure_entry.get("reason") or ""),
"attemptCount": int(failure_entry.get("attemptCount") or 0),
"confirmationLevel": str(failure_entry.get("confirmationLevel") or ("weak" if status == "unconfirmed" else "")),
"confirmationSource": str(failure_entry.get("confirmationSource") or ""),
"confirmationDetail": str(failure_entry.get("reason") or ""),
"needsVerification": status == "unconfirmed",
}
)
return _finalize_target_status(item, now)
scheduled_at = None
if send_window.get("enabled"):
scheduled_at = _scheduled_send_time(account, target_name, send_window, now)
if scheduled_at > now:
item.update(
{
"status": "pending",
"scheduledAt": scheduled_at.isoformat(timespec="seconds"),
}
)
return _finalize_target_status(item, now)
item.update(
{
"status": "unprocessed",
"scheduledAt": scheduled_at.isoformat(timespec="seconds") if scheduled_at else "",
}
)
return _finalize_target_status(item, now)
def _orphan_records(account, configured_targets):
configured_target_set = {str(target) for target in configured_targets}
history = dict(account.get("message_history") or {})
failure_queue = dict(account.get("failure_queue") or {})
orphan_history = sorted(str(target) for target in history if str(target) not in configured_target_set)
orphan_failure = sorted(str(target) for target in failure_queue if str(target) not in configured_target_set)
return orphan_history, orphan_failure
def get_send_console_snapshot():
accounts = [account for account in get_userData(force_reload=True) if account.get("enabled", True)]
send_window = _normalize_send_window()
@@ -706,13 +897,23 @@ def get_send_console_snapshot():
"enabled_accounts": len(accounts),
"total_targets": 0,
"today_sent_targets": 0,
"today_confirmed_targets": 0,
"today_unconfirmed_targets": 0,
"today_legacy_unverified_targets": 0,
"today_failed_targets": 0,
"today_pending_targets": 0,
"today_unprocessed_targets": 0,
"today_account_blocked_targets": 0,
"today_attention_targets": 0,
"today_remaining_targets": 0,
"today_account_failures": 0,
"today_account_paused": 0,
"today_warning_count": 0,
"orphan_history_records": 0,
"orphan_failure_records": 0,
"last_confirmed_at": "",
"last_confirmed_display": "",
"all_confirmed": False,
}
account_rows = []
account_failure_pause_after = _account_failure_pause_after_attempts()
@@ -720,14 +921,16 @@ def get_send_console_snapshot():
for account in accounts:
configured_targets = list(account.get("targets") or [])
statuses = [_build_target_status(account, target_name, now, send_window) for target_name in configured_targets]
sent_targets = [item for item in statuses if item["status"] == "sent"]
confirmed_targets = [item for item in statuses if item["status"] == "sent"]
sent_targets = confirmed_targets
unconfirmed_targets = [item for item in statuses if item["status"] == "unconfirmed"]
failed_targets = [item for item in statuses if item["status"] == "failed"]
account_failure = _account_failure_entry_today(account, now)
account_paused = bool(account_failure and _coerce_attempt_count(account_failure) >= account_failure_pause_after)
account_blocked_targets = []
if account_paused:
account_blocked_targets = [
_account_blocked_target_status(item, account_failure)
_finalize_target_status(_account_blocked_target_status(item, account_failure), now)
for item in statuses
if item["status"] in {"pending", "unprocessed"}
]
@@ -747,22 +950,63 @@ def get_send_console_snapshot():
except (TypeError, ValueError):
friend_index_meta["scannedCount"] = 0
orphan_history, orphan_failure = _orphan_records(account, configured_targets)
warnings = []
if not configured_targets:
warnings.append({"category": "no_targets", "message": "该启用账号没有配置目标,不能代表全部续上。"})
if orphan_history:
warnings.append({"category": "orphan_history", "message": f"{len(orphan_history)} 条发送账本不在当前目标列表中。"})
if orphan_failure:
warnings.append({"category": "orphan_failure", "message": f"{len(orphan_failure)} 条失败队列记录不在当前目标列表中。"})
legacy_unverified_targets = [item for item in unconfirmed_targets if item.get("legacyUnverified")]
attention_count = len(unconfirmed_targets) + len(failed_targets) + len(account_blocked_targets)
pending_count = len(pending_targets) + len(unprocessed_targets)
confirmed_times = [
_parse_sent_at(item.get("sentAt"), now.tzinfo)
for item in confirmed_targets
if item.get("sentAt")
]
confirmed_times = [item for item in confirmed_times if item]
last_confirmed_at = max(confirmed_times).isoformat(timespec="seconds") if confirmed_times else ""
if account_paused:
account_state = "paused"
elif attention_count:
account_state = "attention"
elif warnings:
account_state = "warning"
elif pending_count:
account_state = "pending"
else:
account_state = "healthy"
summary["total_targets"] += len(configured_targets)
summary["today_sent_targets"] += len(sent_targets)
summary["today_confirmed_targets"] += len(confirmed_targets)
summary["today_unconfirmed_targets"] += len(unconfirmed_targets)
summary["today_legacy_unverified_targets"] += len(legacy_unverified_targets)
summary["today_failed_targets"] += len(failed_targets)
summary["today_pending_targets"] += len(pending_targets)
summary["today_unprocessed_targets"] += len(unprocessed_targets)
summary["today_account_blocked_targets"] += len(account_blocked_targets)
summary["today_attention_targets"] += attention_count
summary["today_remaining_targets"] += (
len(failed_targets)
len(unconfirmed_targets)
+ len(failed_targets)
+ len(pending_targets)
+ len(unprocessed_targets)
+ len(account_blocked_targets)
)
summary["today_warning_count"] += len(warnings)
summary["orphan_history_records"] += len(orphan_history)
summary["orphan_failure_records"] += len(orphan_failure)
if account_failure:
summary["today_account_failures"] += 1
if account_paused:
summary["today_account_paused"] += 1
if last_confirmed_at and (
not summary["last_confirmed_at"] or last_confirmed_at > summary["last_confirmed_at"]
):
summary["last_confirmed_at"] = last_confirmed_at
account_rows.append(
{
@@ -770,27 +1014,93 @@ def get_send_console_snapshot():
"username": account.get("username") or "",
"total_targets": len(configured_targets),
"sent_targets": sent_targets,
"confirmed_targets": confirmed_targets,
"unconfirmed_targets": unconfirmed_targets,
"legacy_unverified_targets": legacy_unverified_targets,
"failed_targets": failed_targets,
"pending_targets": pending_targets,
"unprocessed_targets": unprocessed_targets,
"account_blocked_targets": account_blocked_targets,
"last_failure_reason": failed_targets[0]["reason"] if failed_targets else "",
"last_unconfirmed_reason": unconfirmed_targets[0]["reason"] if unconfirmed_targets else "",
"failure_queue": dict(account.get("failure_queue") or {}),
"account_failure": account_failure,
"account_paused": account_paused,
"account_failure_pause_after": account_failure_pause_after,
"state": account_state,
"attention_count": attention_count,
"pending_count": pending_count,
"last_confirmed_at": last_confirmed_at,
"last_confirmed_display": _format_short_time(last_confirmed_at, now),
"friend_index_meta": friend_index_meta,
"friend_index_count": len(dict(account.get("friend_index") or {})),
"warnings": warnings,
"orphan_history_records": orphan_history,
"orphan_failure_records": orphan_failure,
}
)
state_rank = {"paused": 0, "attention": 1, "warning": 2, "pending": 3, "healthy": 4}
account_rows.sort(key=lambda row: (state_rank.get(row.get("state"), 9), str(row.get("username") or "")))
summary["all_confirmed"] = bool(
summary["total_targets"] > 0
and summary["today_confirmed_targets"] == summary["total_targets"]
and summary["today_remaining_targets"] == 0
and summary["today_warning_count"] == 0
and summary["orphan_history_records"] == 0
and summary["orphan_failure_records"] == 0
)
summary["last_confirmed_display"] = _format_short_time(summary["last_confirmed_at"], now)
return {
"now": now.isoformat(timespec="seconds"),
"nowDisplay": now.strftime("%m-%d %H:%M"),
"summary": summary,
"accounts": account_rows,
}
def get_overview_snapshot():
send_console = get_send_console_snapshot()
summary = dict(send_console["summary"])
accounts = []
for row in send_console["accounts"]:
accounts.append(
{
"uniqueId": row["unique_id"],
"displayName": row["username"],
"state": row["state"],
"total": row["total_targets"],
"confirmed": len(row["confirmed_targets"]),
"attention": row["attention_count"],
"pending": row["pending_count"],
"lastConfirmedAt": row["last_confirmed_at"],
}
)
return {
"now": send_console["now"],
"schedule": get_schedule_snapshot(),
"task": task_run_lock_status(),
"summary": {
"enabledAccounts": summary["enabled_accounts"],
"total": summary["total_targets"],
"confirmed": summary["today_confirmed_targets"],
"unconfirmed": summary["today_unconfirmed_targets"],
"failed": summary["today_failed_targets"],
"blocked": summary["today_account_blocked_targets"],
"attention": summary["today_attention_targets"],
"pending": summary["today_pending_targets"],
"unprocessed": summary["today_unprocessed_targets"],
"remaining": summary["today_remaining_targets"],
"warnings": summary["today_warning_count"],
"lastConfirmedAt": summary["last_confirmed_at"],
"allConfirmed": summary["all_confirmed"],
},
"accounts": accounts,
}
def _check_image_present():
"""Return True if the douyin-sparkflow:local image exists."""
try:
@@ -811,14 +1121,16 @@ def get_ops_snapshot():
Every external call is individually guarded so the dashboard always
renders, even when Docker or crontab are not available.
"""
send_console = get_send_console_snapshot()
return {
"compose_root": str(compose_root()),
"compose_file": str(compose_file_path() or ""),
"containers": get_container_status(),
"task_containers": get_task_container_rows(),
"send_console": get_send_console_snapshot(),
"send_console": send_console,
"task_lock": task_run_lock_status(),
"daily_schedule": current_daily_schedule(),
"schedule": get_schedule_snapshot(),
"crontab": read_crontab(),
"log_tail": read_log_tail(120),
"image_present": _check_image_present(),
File diff suppressed because it is too large Load Diff
+435 -318
View File
@@ -1,138 +1,360 @@
(() => {
const storageKey = "sparkflow-theme";
const root = document.documentElement;
const storageKey = "sparkflow-theme";
const readStoredTheme = () => {
const storedTheme = () => {
try {
return window.localStorage.getItem(storageKey);
return localStorage.getItem(storageKey);
} catch {
return null;
}
};
const writeStoredTheme = (theme) => {
const applyTheme = (theme) => {
const value = theme === "light" ? "light" : "dark";
root.dataset.theme = value;
root.style.colorScheme = value;
try {
window.localStorage.setItem(storageKey, theme);
localStorage.setItem(storageKey, value);
} catch {
// Ignore storage restrictions; the current page can still switch theme.
// The active page can still switch themes when storage is unavailable.
}
};
const preferredTheme = () => {
const stored = readStoredTheme();
if (stored === "dark" || stored === "light") return stored;
return window.matchMedia?.("(prefers-color-scheme: dark)").matches
? "dark"
: "light";
};
const applyTheme = (theme) => {
const normalized = theme === "dark" ? "dark" : "light";
root.dataset.theme = normalized;
root.style.colorScheme = normalized;
const nextLabel = normalized === "dark" ? "切换白天模式" : "切换黑夜模式";
document.querySelectorAll("[data-theme-toggle]").forEach((button) => {
button.setAttribute("aria-label", nextLabel);
button.setAttribute("title", nextLabel);
button.setAttribute(
"aria-pressed",
normalized === "dark" ? "true" : "false",
);
});
};
applyTheme(preferredTheme());
applyTheme(storedTheme() || "dark");
document.querySelectorAll("[data-theme-toggle]").forEach((button) => {
button.addEventListener("click", () => {
const next = root.dataset.theme === "dark" ? "light" : "dark";
writeStoredTheme(next);
applyTheme(next);
applyTheme(root.dataset.theme === "light" ? "dark" : "light");
});
});
})();
(() => {
const body = document.body;
const navToggle = document.querySelector("[data-nav-toggle]");
const navClose = document.querySelector("[data-nav-close]");
navToggle?.addEventListener("click", () => body.classList.toggle("nav-open"));
navClose?.addEventListener("click", () => body.classList.remove("nav-open"));
document.querySelectorAll(".nav-item").forEach((item) => {
item.addEventListener("click", () => body.classList.remove("nav-open"));
document.querySelectorAll("[data-nav-toggle]").forEach((button) => {
button.addEventListener("click", () => body.classList.add("nav-open"));
});
document.querySelectorAll("[data-nav-close]").forEach((button) => {
button.addEventListener("click", () => body.classList.remove("nav-open"));
});
document.querySelectorAll(".nav-item").forEach((link) => {
link.addEventListener("click", () => body.classList.remove("nav-open"));
});
})();
(() => {
document.querySelectorAll("[data-confirm]").forEach((node) => {
const message = node.getAttribute("data-confirm") || "确认执行此操作?";
const handler = (event) => {
if (!window.confirm(message)) {
event.preventDefault();
event.stopImmediatePropagation();
}
};
if (node.tagName === "FORM") {
node.addEventListener("submit", handler);
} else {
node.addEventListener("click", handler);
}
});
})();
const dialog = document.getElementById("confirm-dialog");
if (!dialog) return;
const title = document.getElementById("confirm-title");
const message = document.getElementById("confirm-message");
const accept = dialog.querySelector("[data-confirm-accept]");
const cancel = dialog.querySelector("[data-confirm-cancel]");
let pendingForm = null;
let pendingLink = "";
let pendingButton = null;
(() => {
document.querySelectorAll(".data-card__footer-button").forEach((button) => {
button.addEventListener("click", () => {
const targetId = button.dataset.expandTarget;
if (!targetId) return;
const panel = document.getElementById(targetId);
if (!panel) return;
const isOpen = panel.classList.toggle("is-open");
button.textContent = isOpen
? "收起"
: button.dataset.totalLabel || "查看全部";
const openDialog = (node) => {
const source = node.closest("[data-confirm]") || node;
title.textContent = source.dataset.confirmTitle || "确认操作";
message.textContent =
source.dataset.confirm ||
"该操作会立即影响续火花任务,请确认是否继续。";
accept.textContent = source.dataset.confirmAccept || "确认执行";
accept.className =
source.dataset.confirmTone === "primary"
? "button button-primary"
: "button button-danger";
dialog.showModal();
};
document.querySelectorAll("form[data-confirm]").forEach((form) => {
form.addEventListener("submit", (event) => {
event.preventDefault();
pendingForm = form;
pendingLink = "";
pendingButton = null;
openDialog(form);
});
});
document.querySelectorAll("a[data-confirm]").forEach((link) => {
link.addEventListener("click", (event) => {
event.preventDefault();
pendingForm = null;
pendingLink = link.href;
pendingButton = null;
openDialog(link);
});
});
document.querySelectorAll("button[data-confirm]").forEach((button) => {
button.addEventListener(
"click",
(event) => {
if (button.dataset.confirmApproved === "1") {
delete button.dataset.confirmApproved;
return;
}
event.preventDefault();
event.stopImmediatePropagation();
pendingForm = null;
pendingLink = "";
pendingButton = button;
openDialog(button);
},
true,
);
});
cancel.addEventListener("click", () => {
pendingForm = null;
pendingLink = "";
pendingButton = null;
dialog.close();
});
accept.addEventListener("click", () => {
const form = pendingForm;
const href = pendingLink;
const button = pendingButton;
pendingForm = null;
pendingLink = "";
pendingButton = null;
dialog.close();
if (form) {
HTMLFormElement.prototype.submit.call(form);
} else if (href) {
window.location.assign(href);
} else if (button) {
button.dataset.confirmApproved = "1";
button.click();
}
});
dialog.addEventListener("cancel", () => {
pendingForm = null;
pendingLink = "";
pendingButton = null;
});
})();
(() => {
document.querySelectorAll("[data-segment-group]").forEach((group) => {
const buttons = [...group.querySelectorAll("[data-segment-target]")];
const owner = group.closest("[data-segment-owner]") || document;
const panels = [...owner.querySelectorAll("[data-segment-panel]")];
const activate = (name) => {
buttons.forEach((button) => {
const active = button.dataset.segmentTarget === name;
button.classList.toggle("active", active);
button.setAttribute("aria-selected", active ? "true" : "false");
});
panels.forEach((panel) => {
panel.hidden = panel.dataset.segmentPanel !== name;
});
};
buttons.forEach((button) => {
button.addEventListener("click", () =>
activate(button.dataset.segmentTarget),
);
});
const initial =
buttons.find((button) => button.classList.contains("active")) ||
buttons[0];
if (initial) activate(initial.dataset.segmentTarget);
});
})();
(() => {
const overviewRoots = document.querySelectorAll("[data-overview-root]");
if (!overviewRoots.length) return;
let previousRunning = null;
let timer = null;
const formatTime = (raw) => {
if (!raw) return "-";
const parsed = new Date(raw);
if (Number.isNaN(parsed.getTime())) return raw;
return new Intl.DateTimeFormat("zh-CN", {
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
hour12: false,
}).format(parsed);
};
const setText = (selector, value) => {
document.querySelectorAll(selector).forEach((node) => {
node.textContent = String(value ?? "");
});
};
const updateTaskBanner = (task) => {
document.querySelectorAll("[data-task-banner]").forEach((banner) => {
banner.className = "status-banner";
if (task.running) {
banner.classList.add("warning");
banner.querySelector("[data-task-text]").textContent =
`发送任务运行中,已运行约 ${task.ageSeconds || 0}`;
} else if (task.stale) {
banner.classList.add("info");
banner.querySelector("[data-task-text]").textContent =
"检测到过期任务锁,下次启动任务时会自动清理";
} else {
banner.classList.add("success");
banner.querySelector("[data-task-text]").textContent =
"当前没有发送任务运行";
}
});
};
const updateAccounts = (accounts) => {
accounts.forEach((account) => {
const selector = `[data-account-overview="${CSS.escape(account.uniqueId)}"]`;
document.querySelectorAll(selector).forEach((row) => {
row.dataset.accountState = account.state;
row.querySelectorAll("[data-account-confirmed]").forEach((node) => {
node.textContent = account.confirmed;
});
row.querySelectorAll("[data-account-attention]").forEach((node) => {
node.textContent = account.attention;
});
row.querySelectorAll("[data-account-pending]").forEach((node) => {
node.textContent = account.pending;
});
row.querySelectorAll("[data-account-progress]").forEach((node) => {
const pct = account.total
? Math.round((account.confirmed / account.total) * 100)
: 0;
node.style.width = `${pct}%`;
});
row.querySelectorAll("[data-account-progress-text]").forEach((node) => {
node.textContent = `${account.confirmed}/${account.total}`;
});
});
});
};
const updateActions = (summary, running) => {
const counts = {
attention: summary.attention,
pending: summary.pending + summary.unprocessed,
total: summary.total,
};
document.querySelectorAll("[data-action-count-source]").forEach((button) => {
const count = counts[button.dataset.actionCountSource] || 0;
button.disabled = running || count <= 0;
const countNode = button.querySelector("[data-action-count]");
if (countNode) countNode.textContent = count;
});
document.querySelectorAll("[data-disable-while-running]").forEach((button) => {
if (!button.dataset.actionCountSource) {
button.disabled = running;
}
});
};
const render = (data) => {
const summary = data.summary || {};
const task = data.task || {};
updateTaskBanner(task);
setText("[data-overview-value='total']", summary.total || 0);
setText("[data-overview-value='confirmed']", summary.confirmed || 0);
setText("[data-overview-value='attention']", summary.attention || 0);
setText(
"[data-overview-value='pending']",
(summary.pending || 0) + (summary.unprocessed || 0),
);
setText("[data-overview-value='remaining']", summary.remaining || 0);
setText(
"[data-overview-value='progress']",
`${summary.confirmed || 0}/${summary.total || 0}`,
);
setText(
"[data-overview-value='progressPercent']",
summary.total
? `${Math.round((summary.confirmed / summary.total) * 100)}%`
: "0%",
);
setText(
"[data-overview-value='lastConfirmedAt']",
formatTime(summary.lastConfirmedAt),
);
setText(
"[data-overview-value='nextTriggerAt']",
formatTime(data.schedule?.nextTriggerAt),
);
setText(
"[data-overview-value='scheduleLabel']",
data.schedule?.label || "-",
);
updateAccounts(data.accounts || []);
updateActions(summary, Boolean(task.running));
if (previousRunning === true && !task.running) {
document
.querySelectorAll("[data-refresh-notice]")
.forEach((node) => node.classList.add("visible"));
}
previousRunning = Boolean(task.running);
document.querySelectorAll("[data-overview-live-state]").forEach((node) => {
node.textContent = "实时";
node.classList.remove("poll-stale");
});
};
const refresh = async () => {
if (document.visibilityState !== "visible") return;
try {
const response = await fetch("/api/ops/overview", {
credentials: "same-origin",
headers: { Accept: "application/json" },
cache: "no-store",
});
if (!response.ok) throw new Error(`HTTP ${response.status}`);
render(await response.json());
} catch {
document.querySelectorAll("[data-overview-live-state]").forEach((node) => {
node.textContent = "更新延迟";
node.classList.add("poll-stale");
});
}
};
document.querySelectorAll("[data-refresh-page]").forEach((button) => {
button.addEventListener("click", () => window.location.reload());
});
document.addEventListener("visibilitychange", () => {
if (document.visibilityState === "visible") refresh();
});
refresh();
timer = window.setInterval(refresh, 10000);
window.addEventListener("pagehide", () => window.clearInterval(timer));
})();
(() => {
const root = document.getElementById("login-desktop-controls");
if (!root) return;
const section = document.getElementById("interactive-login-section");
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 runtimeState = document.getElementById(
"login-desktop-runtime-state",
);
const statusText = document.getElementById("login-desktop-status-text");
const frame = document.querySelector("[data-login-frame]");
let timer = null;
const setVisualStatus = (state) => {
Object.values(statusMap).forEach((node) => {
if (!node) return;
node.style.opacity = "0.46";
});
if (statusMap[state]) {
statusMap[state].style.opacity = "1";
const setStatus = (text, tone = "") => {
if (statusText) statusText.textContent = text;
if (runtimeState) {
runtimeState.className = `pill${tone ? ` ${tone}` : ""}`;
runtimeState.textContent =
tone === "success" ? "已登录" : tone === "danger" ? "异常" : "待登录";
}
};
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);
@@ -151,284 +373,185 @@
return data;
};
const openDesktopWindow = () => {
const popup = window.open(publicUrl, "_blank");
if (!popup) {
setStatus(
"浏览器阻止了登录工作区弹窗,请允许弹窗后再试。",
"danger",
"error",
);
return false;
const loadFrame = () => {
if (frame && frame.dataset.loaded !== "1" && frame.dataset.src) {
frame.src = frame.dataset.src;
frame.dataset.loaded = "1";
}
return true;
};
const pollStatus = async () => {
if (document.visibilityState !== "visible" || (section && !section.open)) {
return;
}
try {
const response = await fetch("/login-desktop/status", {
credentials: "same-origin",
cache: "no-store",
});
const data = await response.json();
if (!response.ok || data.ok === false) {
if (runtimeStateEl) runtimeStateEl.textContent = "不可用";
setStatus(
data.error || "登录工作区不可用,请检查 login-desktop 服务。",
"warning",
"error",
"danger",
);
return;
}
if (runtimeStateEl)
runtimeStateEl.textContent = data.logged_in ? "已登录" : "待登录";
if (data.logged_in) {
setStatus(
`当前浏览器已登录:${data.username}${data.unique_id}`,
"success",
"success",
);
setStatus(`当前浏览器已登录:${data.username}`, "success");
} else {
setStatus(
"当前浏览器尚未登录,可打开登录工作区开始人工登录。",
"",
"pending",
);
setStatus("当前浏览器尚未登录,可打开工作区开始人工登录。");
}
} catch (error) {
if (runtimeStateEl) runtimeStateEl.textContent = "异常";
setStatus(`登录工作区状态检查失败:${error.message}`, "danger", "error");
setStatus(`状态检查失败:${error.message}`, "danger");
}
};
copyPublicUrlButton?.addEventListener("click", async () => {
try {
await navigator.clipboard.writeText(publicUrl);
setStatus("登录工作区地址已复制。", "success", "pending");
} catch (error) {
setStatus(`复制失败:${error.message}`, "warning", "error");
}
});
openButtons.forEach((button) => {
document.querySelectorAll(".login-desktop-open").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();
setStatus(
reloginUniqueId
? `请使用账号 ${accountName || reloginUniqueId} 完成登录,然后保存登录态。`
: "请在远端浏览器中完成抖音创作者中心登录,然后保存账号。",
"",
"pending",
);
loadFrame();
window.open(publicUrl, "_blank", "noopener");
setStatus("请在登录工作区完成登录,然后保存登录态。");
} catch (error) {
setStatus(`打开登录工作区失败:${error.message}`, "danger", "error");
setStatus(`打开登录工作区失败:${error.message}`, "danger");
}
});
});
saveButtons.forEach((button) => {
document.querySelectorAll(".login-desktop-save").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,
relogin_unique_id: button.dataset.reloginUniqueId || "",
});
setStatus(
reloginUniqueId
? `已把当前浏览器登录保存到账号:${accountName || reloginUniqueId}`
: `已保存当前登录账号:${data.account?.username || ""}`,
"success",
"success",
);
window.setTimeout(() => window.location.reload(), 900);
setStatus(`已保存登录账号:${data.account?.username || ""}`, "success");
window.setTimeout(() => window.location.reload(), 800);
} catch (error) {
setStatus(`保存当前登录账号失败:${error.message}`, "danger", "error");
setStatus(`保存登录账号失败:${error.message}`, "danger");
}
});
});
resetButtons.forEach((button) => {
document.querySelectorAll(".login-desktop-reset").forEach((button) => {
button.addEventListener("click", async () => {
try {
await postForm("/login-desktop/reset");
setStatus(
"登录工作区已重置,正在重新初始化浏览器。",
"warning",
"checking",
);
setStatus("登录工作区已重置,正在重新初始化。");
await pollStatus();
} catch (error) {
setStatus(`重置登录工作区失败:${error.message}`, "danger", "error");
setStatus(`重置登录工作区失败:${error.message}`, "danger");
}
});
});
setVisualStatus("checking");
pollStatus();
window.setInterval(pollStatus, 5000);
document.querySelectorAll("[data-copy-login-url]").forEach((button) => {
button.addEventListener("click", async () => {
try {
await navigator.clipboard.writeText(publicUrl);
setStatus("登录工作区地址已复制。", "success");
} catch (error) {
setStatus(`复制失败:${error.message}`, "danger");
}
});
});
if (section) {
section.addEventListener("toggle", () => {
if (section.open) {
loadFrame();
pollStatus();
}
});
}
timer = window.setInterval(pollStatus, 5000);
window.addEventListener("pagehide", () => window.clearInterval(timer));
})();
(() => {
const pickers = document.querySelectorAll(".friend-picker");
if (!pickers.length) return;
const parseJsonScript = (id) => {
const el = document.getElementById(id);
if (!el) return [];
const parseJson = (id) => {
const node = document.getElementById(id);
if (!node) return [];
try {
return JSON.parse(el.textContent || "[]");
} catch (error) {
console.error("Failed to parse friend picker JSON", id, error);
return JSON.parse(node.textContent || "[]");
} catch {
return [];
}
};
const escapeHtml = (value) =>
String(value)
.replaceAll("&", "&amp;")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll('"', "&quot;")
.replaceAll("'", "&#39;");
pickers.forEach((picker) => {
document.querySelectorAll(".friend-picker").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 form = picker.closest("form");
const textarea = form?.querySelector(".targets-textarea");
const search = 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");
const formEl = picker.closest("form");
const targetsTextarea = formEl?.querySelector(".targets-textarea");
const currentTargetsEl = picker.querySelector(
".friend-picker-current-targets span",
);
const list = picker.querySelector(".friend-picker-list");
const summary = picker.querySelector(".friend-picker-summary");
const status = picker.querySelector(".friend-picker-status");
let friends = parseJson(`friends-cache-${accountId}`);
let selected = new Set(parseJson(`selected-targets-${accountId}`));
let friends = parseJsonScript(`friends-cache-${accountId}`);
let selected = new Set(parseJsonScript(`selected-targets-${accountId}`));
const parseTargets = (value) =>
[...new Set(
String(value || "")
.replaceAll(",", "\n")
.split(/\r?\n/)
.map((item) => item.trim())
.filter(Boolean),
)];
const splitTargetText = (value) => {
const seen = new Set();
return String(value || "")
.replaceAll(",", "\n")
.split(/\r?\n/)
.map((name) => name.trim())
.filter((name) => {
if (!name || seen.has(name)) return false;
seen.add(name);
return true;
});
const combined = () => [...new Set([...selected, ...friends])];
const syncTextarea = () => {
if (textarea) textarea.value = [...selected].join("\n");
};
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 syncTextareaFromSelected = () => {
if (targetsTextarea) {
targetsTextarea.value = [...selected].join("\n");
}
};
const syncSelectedFromTextarea = () => {
if (targetsTextarea) {
selected = new Set(splitTargetText(targetsTextarea.value));
}
};
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}`;
if (currentTargetsEl) {
currentTargetsEl.textContent = selected.size
? [...selected].join("、")
: "未选择";
}
};
const renderList = () => {
const query = (searchInput.value || "").trim().toLowerCase();
const allNames = combinedFriends();
const displayNames = allNames.filter((name) =>
const render = () => {
const query = String(search?.value || "").trim().toLowerCase();
const names = combined().filter((name) =>
name.toLowerCase().includes(query),
);
renderHiddenInputs();
updateSummary();
if (!allNames.length) {
listEl.innerHTML =
'<div class="friend-picker-empty">点击“刷新好友列表”后再勾选目标好友。</div>';
if (summary) summary.textContent = `已选 ${selected.size}`;
list.innerHTML = "";
if (!names.length) {
const empty = document.createElement("div");
empty.className = "friend-picker-empty";
empty.textContent = combined().length
? "没有匹配的好友。"
: "点击“刷新好友列表”后再选择目标。";
list.appendChild(empty);
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) => {
names.forEach((name) => {
const label = document.createElement("label");
label.className = `friend-option${selected.has(name) ? " selected" : ""}`;
const text = document.createElement("span");
text.textContent = name;
const checkbox = document.createElement("input");
checkbox.type = "checkbox";
checkbox.checked = selected.has(name);
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");
}
syncTextareaFromSelected();
renderHiddenInputs();
updateSummary();
if (checkbox.checked) selected.add(name);
else selected.delete(name);
syncTextarea();
render();
});
label.append(text, checkbox);
list.appendChild(label);
});
};
refreshButton.addEventListener("click", async () => {
textarea?.addEventListener("input", () => {
selected = new Set(parseTargets(textarea.value));
render();
});
search?.addEventListener("input", render);
refreshButton?.addEventListener("click", async () => {
refreshButton.disabled = true;
const originalText = refreshButton.textContent;
refreshButton.textContent = "刷新中...";
statusEl.textContent = "正在读取好友列表...";
if (status) status.textContent = "正在读取好友列表...";
try {
const formData = new FormData();
formData.set("csrf_token", csrfToken);
@@ -437,29 +560,23 @@
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();
const data = await response.json();
if (!response.ok) throw new Error(data.error || "刷新失败");
friends = data.friends || [];
if (status) status.textContent = data.message || "好友列表已刷新";
render();
} catch (error) {
statusEl.textContent = error.message || "刷新好友列表失败";
if (status) status.textContent = `刷新失败:${error.message}`;
} finally {
refreshButton.disabled = false;
refreshButton.textContent = originalText;
}
});
searchInput.addEventListener("input", renderList);
targetsTextarea?.addEventListener("input", () => {
syncSelectedFromTextarea();
renderList();
});
syncSelectedFromTextarea();
renderList();
render();
});
})();
window.addEventListener("DOMContentLoaded", () => {
if (window.lucide) {
window.lucide.createIcons({ attrs: { "aria-hidden": "true" } });
}
});
@@ -0,0 +1,43 @@
ISC License
Copyright (c) 2026 Lucide Icons and Contributors
Permission to use, copy, modify, and/or distribute this software for any
purpose with or without fee is hereby granted, provided that the above
copyright notice and this permission notice appear in all copies.
THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
---
The following Lucide icons are derived from the Feather project:
airplay, alert-circle, alert-octagon, alert-triangle, aperture, arrow-down-circle, arrow-down-left, arrow-down-right, arrow-down, arrow-left-circle, arrow-left, arrow-right-circle, arrow-right, arrow-up-circle, arrow-up-left, arrow-up-right, arrow-up, at-sign, calendar, cast, check, chevron-down, chevron-left, chevron-right, chevron-up, chevrons-down, chevrons-left, chevrons-right, chevrons-up, circle, clipboard, clock, code, columns, command, compass, corner-down-left, corner-down-right, corner-left-down, corner-left-up, corner-right-down, corner-right-up, corner-up-left, corner-up-right, crosshair, database, divide-circle, divide-square, dollar-sign, download, external-link, feather, frown, hash, headphones, help-circle, info, italic, key, layout, life-buoy, link-2, link, loader, lock, log-in, log-out, maximize, meh, minimize, minimize-2, minus-circle, minus-square, minus, monitor, moon, more-horizontal, more-vertical, move, music, navigation-2, navigation, octagon, pause-circle, percent, plus-circle, plus-square, plus, power, radio, rss, search, server, share, shopping-bag, sidebar, smartphone, smile, square, table-2, tablet, target, terminal, trash-2, trash, triangle, tv, type, upload, x-circle, x-octagon, x-square, x, zoom-in, zoom-out
The MIT License (MIT) (for the icons listed above)
Copyright (c) 2013-present Cole Bemis
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
File diff suppressed because one or more lines are too long
@@ -1,183 +0,0 @@
{% extends "base.html" %}
{% block nav_key %}accounts{% endblock %}
{% block title %}账号与目标 | 自动续火花{% endblock %}
{% block page_title %}账号与目标{% endblock %}
{% block page_subtitle %}集中维护账号开关、目标好友、好友缓存和登录态同步{% endblock %}
{% block topbar_actions %}
<a class="ghost-button" href="/login-workspace">登录工作区</a>
{% endblock %}
{% block content %}
<div
id="login-desktop-controls"
data-public-url="{{ login_desktop_public_url }}"
data-csrf-token="{{ csrf_token }}"
hidden
></div>
{% if accounts %}
<section 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 "A" }}
</div>
<div>
<div class="account-name">{{ account.username }}</div>
<div class="account-sub">unique_id: {{ account.unique_id }}</div>
</div>
</div>
<span
class="status-chip {% if account.enabled|default(true) %}success{% else %}warning{% endif %}"
>
{% if account.enabled|default(true) %}已启用{% else %}已停用{% endif %}
</span>
</div>
<div class="account-metrics">
<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 class="targets-textarea" name="targets" rows="5">
{{ account.targets|default([], true)|join('\n') }}</textarea
>
</label>
<div
class="friend-picker"
data-account-id="{{ account.unique_id }}"
data-refresh-url="/accounts/{{ account.unique_id }}/friends/refresh"
data-csrf-token="{{ csrf_token }}"
>
<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>
<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"
data-confirm="确认删除账号 {{ account.username }}?此操作会移除该账号配置。"
>
<input type="hidden" name="csrf_token" value="{{ csrf_token }}" />
<button class="danger-button" type="submit">删除账号</button>
</form>
</div>
</article>
{% endfor %}
</section>
{% else %}
<section class="empty-state">
当前没有账号。请先前往登录工作区完成扫码登录并保存账号。
</section>
{% endif %} {% endblock %}
+102 -486
View File
@@ -4,518 +4,134 @@
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="color-scheme" content="dark light">
<title>{% block title %}续火花{% endblock %}</title>
<style>
/* ============ DARK (default) ============ */
:root {
--bg: #0a0e1c;
--bg-glow: rgba(255, 77, 109, 0.10);
--surface: #161e34;
--surface-subtle: #1a2440;
--surface-tint: rgba(255, 255, 255, 0.06);
--border: rgba(255, 255, 255, 0.08);
--border-strong: rgba(255, 255, 255, 0.14);
--text: #eaf0ff;
--text-main: #eaf0ff;
--text-soft: #9aa6c8;
--text-faint: #64709a;
--primary: #ff7a3d;
--primary-strong: #ff4d6d;
--primary-deep: #ff2da8;
--primary-soft: rgba(255, 122, 61, 0.14);
--success: #2bd47f;
--success-soft: rgba(43, 212, 127, 0.14);
--danger: #ff5470;
--danger-soft: rgba(255, 84, 112, 0.14);
--warning: #ffb547;
--warning-soft: rgba(255, 181, 71, 0.14);
--info: #5b8bff;
--info-soft: rgba(91, 139, 255, 0.16);
--shadow: 0 20px 50px rgba(0, 0, 0, 0.45);
--shadow-soft: 0 10px 28px rgba(0, 0, 0, 0.30);
--ring: 0 0 0 4px rgba(255, 122, 61, 0.22);
--fire-grad: linear-gradient(135deg, #ffb04a 0%, #ff7a3d 30%, #ff4d6d 65%, #ff2da8 100%);
--glow-fire: 0 18px 50px rgba(255, 77, 109, 0.28);
--sidebar-bg: linear-gradient(200deg, rgba(22, 30, 52, 0.96) 0%, rgba(14, 20, 38, 0.96) 100%);
--sidebar-text: rgba(255, 255, 255, 0.72);
--sidebar-text-strong: #ffffff;
--sidebar-faint: rgba(255, 255, 255, 0.42);
--sidebar-hover: rgba(255, 255, 255, 0.06);
--sidebar-active-bg: linear-gradient(135deg, rgba(255, 122, 61, 0.22), rgba(255, 45, 168, 0.10));
--sidebar-active-border: rgba(255, 122, 61, 0.28);
--panel-glass: rgba(22, 30, 52, 0.72);
--glass-blur: blur(10px);
--body-bg:
radial-gradient(900px circle at 88% -8%, rgba(255, 77, 109, 0.16), transparent 50%),
radial-gradient(760px circle at -6% 12%, rgba(91, 139, 255, 0.14), transparent 48%),
linear-gradient(180deg, #0a0e1c 0%, #0b1120 100%);
--title-grad: linear-gradient(120deg, #ffffff 0%, #ffd9c8 60%, #ff7a9d 120%);
--th-bg: rgba(255, 255, 255, 0.04);
--row-hover: rgba(255, 255, 255, 0.03);
--table-border: rgba(255, 255, 255, 0.06);
--input-bg: rgba(255, 255, 255, 0.04);
--code-bg: #0c1426;
--code-light-bg: rgba(255, 255, 255, 0.03);
--empty-bg: rgba(255, 255, 255, 0.02);
--radius-xl: 24px;
--radius-lg: 20px;
--radius-md: 16px;
--radius-sm: 12px;
}
/* ============ LIGHT ============ */
[data-theme="light"] {
--bg: #eef2f9;
--bg-glow: rgba(255, 122, 61, 0.08);
--surface: #ffffff;
--surface-subtle: #f7faff;
--surface-tint: #f0f4fb;
--border: #e3e9f3;
--border-strong: #d0d9e8;
--text: #14213d;
--text-main: #14213d;
--text-soft: #566079;
--text-faint: #8a96b3;
--primary: #ff7a3d;
--primary-strong: #ff4d6d;
--primary-deep: #ff2da8;
--primary-soft: rgba(255, 122, 61, 0.10);
--success: #18a558;
--success-soft: rgba(24, 165, 88, 0.14);
--danger: #e5484a;
--danger-soft: rgba(229, 72, 74, 0.12);
--warning: #e08a0c;
--warning-soft: rgba(224, 138, 12, 0.14);
--info: #3a66ff;
--info-soft: rgba(58, 102, 255, 0.12);
--shadow: 0 20px 45px rgba(20, 33, 61, 0.10);
--shadow-soft: 0 8px 24px rgba(20, 33, 61, 0.06);
--ring: 0 0 0 4px rgba(255, 122, 61, 0.18);
--glow-fire: 0 16px 40px rgba(255, 77, 109, 0.20);
--sidebar-bg: #ffffff;
--sidebar-text: #566079;
--sidebar-text-strong: #14213d;
--sidebar-faint: #8a96b3;
--sidebar-hover: rgba(20, 33, 61, 0.04);
--sidebar-active-bg: linear-gradient(135deg, rgba(255, 122, 61, 0.16), rgba(255, 45, 168, 0.06));
--sidebar-active-border: rgba(255, 122, 61, 0.30);
--panel-glass: #ffffff;
--glass-blur: none;
--body-bg:
radial-gradient(900px circle at 88% -8%, rgba(255, 77, 109, 0.10), transparent 50%),
radial-gradient(760px circle at -6% 12%, rgba(91, 139, 255, 0.10), transparent 48%),
linear-gradient(180deg, #f1f5fc 0%, #e9eef7 100%);
--title-grad: linear-gradient(120deg, #14213d 0%, #c45a3a 60%, #e83e7c 120%);
--th-bg: #f3f6fc;
--row-hover: rgba(58, 102, 255, 0.04);
--table-border: #eaeff7;
--input-bg: #ffffff;
--code-bg: #0f1728;
--code-light-bg: #f7f9fd;
--empty-bg: rgba(255, 255, 255, 0.72);
}
* { box-sizing: border-box; }
html, body { margin: 0; min-height: 100%; }
body {
font-family: "Inter", "Segoe UI", "PingFang SC", "Microsoft YaHei", system-ui, sans-serif;
color: var(--text);
-webkit-font-smoothing: antialiased;
text-rendering: optimizeLegibility;
background: var(--body-bg);
background-attachment: fixed;
}
a { color: inherit; text-decoration: none; }
button, input, textarea, select { font: inherit; }
::selection { background: rgba(255, 77, 109, 0.30); }
.main-area ::-webkit-scrollbar,
.table-shell ::-webkit-scrollbar,
.code-block::-webkit-scrollbar { width: 10px; height: 10px; }
.main-area ::-webkit-scrollbar-thumb,
.table-shell ::-webkit-scrollbar-thumb,
.code-block::-webkit-scrollbar-thumb {
background: var(--text-faint);
border-radius: 999px;
border: 2px solid transparent;
background-clip: padding-box;
opacity: 0.5;
}
.main-area ::-webkit-scrollbar-thumb:hover { opacity: 0.8; background-clip: padding-box; }
.app-shell { min-height: 100vh; display: grid; grid-template-columns: 236px minmax(0, 1fr); }
/* ---------- sidebar ---------- */
.sidebar {
position: sticky; top: 0; height: 100vh;
padding: 22px 14px 18px;
border-right: 1px solid var(--border);
background: var(--sidebar-bg);
color: var(--sidebar-text);
display: flex; flex-direction: column; gap: 22px;
backdrop-filter: var(--glass-blur);
}
.brand { display: flex; align-items: center; gap: 12px; padding: 4px 6px 14px; border-bottom: 1px solid var(--border); }
.brand-mark {
width: 40px; height: 40px; border-radius: 13px;
background: var(--fire-grad); color: #fff;
display: inline-flex; align-items: center; justify-content: center;
font-size: 19px; font-weight: 800;
box-shadow: var(--glow-fire);
}
.brand-title { font-size: 16px; font-weight: 800; letter-spacing: 0.01em; color: var(--sidebar-text-strong); }
.brand-subtitle { margin-top: 2px; font-size: 11.5px; color: var(--sidebar-faint); }
.sidebar-section-title {
margin: 0 10px 8px; font-size: 10.5px; color: var(--sidebar-faint);
letter-spacing: 0.16em; text-transform: uppercase;
}
.nav-list { display: grid; gap: 4px; }
.nav-item {
position: relative; display: flex; align-items: center; gap: 12px;
padding: 11px 14px; border-radius: 11px;
color: var(--sidebar-text); font-size: 14px; font-weight: 600;
transition: background 0.18s ease, color 0.18s ease;
}
.nav-item:hover { background: var(--sidebar-hover); color: var(--sidebar-text-strong); }
.nav-item.active {
background: var(--sidebar-active-bg);
color: var(--sidebar-text-strong); font-weight: 700;
box-shadow: inset 0 0 0 1px var(--sidebar-active-border);
}
.nav-item.active::before {
content: ""; position: absolute; left: 4px; top: 50%; transform: translateY(-50%);
width: 3px; height: 18px; border-radius: 3px; background: var(--fire-grad);
}
.nav-icon { width: 18px; text-align: center; font-size: 14px; flex: 0 0 18px; opacity: 0.9; }
.sidebar-footer {
margin-top: auto; padding: 14px; border-radius: 16px;
background: var(--surface-tint); border: 1px solid var(--border);
}
.sidebar-footer-head { display: flex; align-items: center; gap: 10px; }
.sidebar-footer-mark {
width: 34px; height: 34px; border-radius: 10px;
background: var(--fire-grad); color: #fff;
display: inline-flex; align-items: center; justify-content: center; font-weight: 800;
}
.sidebar-footer-title { font-size: 13.5px; font-weight: 700; color: var(--sidebar-text-strong); }
.sidebar-footer-subtitle { margin-top: 2px; font-size: 11.5px; color: var(--sidebar-faint); }
.sidebar-logout {
width: 100%; justify-content: center; display: flex; align-items: center; gap: 8px;
border: 1px solid var(--border); color: var(--sidebar-text);
background: transparent; box-shadow: none; padding: 9px;
}
.sidebar-logout:hover {
border-color: rgba(255, 84, 112, 0.4); color: var(--danger);
background: var(--danger-soft); transform: none; box-shadow: none; filter: none;
}
/* ---------- main ---------- */
.main-area { min-width: 0; padding: 24px 28px 36px; }
.page-header { display: flex; align-items: flex-start; justify-content: space-between; gap: 18px; margin-bottom: 24px; }
.page-header__title { display: flex; align-items: baseline; gap: 14px; flex-wrap: wrap; }
.page-title {
font-size: 28px; line-height: 1.12; font-weight: 800; letter-spacing: -0.02em;
background: var(--title-grad);
-webkit-background-clip: text; background-clip: text; -webkit-text-fill-color: transparent;
}
.page-subtitle { font-size: 14px; color: var(--text-soft); font-weight: 600; margin-top: 4px; }
.page-header__right { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; }
.theme-toggle {
width: 38px; height: 38px; border-radius: 11px; flex: 0 0 38px;
border: 1px solid var(--border); background: var(--surface); color: var(--text-soft);
display: inline-flex; align-items: center; justify-content: center;
font-size: 17px; cursor: pointer; transition: 0.18s; padding: 0;
box-shadow: var(--shadow-soft);
}
.theme-toggle:hover { color: var(--primary); border-color: var(--primary); transform: none; box-shadow: var(--shadow-soft); filter: none; }
.theme-toggle .ico-moon { display: none }
.theme-toggle .ico-sun { display: inline }
[data-theme="light"] .theme-toggle .ico-moon { display: inline }
[data-theme="light"] .theme-toggle .ico-sun { display: none }
.page-header__actions {
display: flex; align-items: center; gap: 8px; flex-wrap: wrap;
padding: 6px; border-radius: 14px;
background: var(--panel-glass); border: 1px solid var(--border);
box-shadow: var(--shadow-soft); backdrop-filter: var(--glass-blur);
}
.page-header__actions > form { margin: 0; }
.page-header__actions button,
.page-header__actions .link-button,
.page-header__actions .ghost-button,
.page-header__actions .soft-button {
margin: 0; border-radius: 10px; padding: 9px 16px; font-size: 13.5px; font-weight: 700;
box-shadow: none; background: transparent; color: var(--text-soft);
border: 1px solid transparent; transform: none; filter: none;
transition: background 0.15s ease, color 0.15s ease, border-color 0.15s ease;
}
.page-header__actions button:hover,
.page-header__actions .link-button:hover,
.page-header__actions .ghost-button:hover,
.page-header__actions .soft-button:hover {
transform: none; filter: none; box-shadow: none;
background: var(--primary-soft); color: var(--primary); border-color: rgba(255, 122, 61, 0.22);
}
.page-header__actions button:active { transform: none; box-shadow: none; }
.page-header__actions button[type="submit"]:not(.soft-button):not(.ghost-button):not(.danger-button):not(.success-button) {
background: var(--fire-grad); color: #fff; border-color: transparent;
box-shadow: 0 8px 18px rgba(255, 77, 109, 0.32);
}
.page-header__actions button[type="submit"]:not(.soft-button):not(.ghost-button):not(.danger-button):not(.success-button):hover {
filter: brightness(1.06); color: #fff; border-color: transparent;
box-shadow: 0 10px 22px rgba(255, 77, 109, 0.40); transform: none;
}
.page-header__actions button:disabled {
background: transparent; color: var(--text-faint);
border: 1px dashed var(--border-strong); box-shadow: none; cursor: not-allowed; opacity: 1;
}
.page-body { display: grid; gap: 22px; }
.stack { display: grid; gap: 22px; }
/* ---------- panel ---------- */
.panel {
background: var(--panel-glass); border: 1px solid var(--border);
border-radius: var(--radius-lg); box-shadow: var(--shadow);
backdrop-filter: var(--glass-blur); padding: 22px;
}
.section-title-row,
.panel-header { display: flex; align-items: flex-start; justify-content: space-between; gap: 16px; margin-bottom: 18px; }
.section-title-row h2, .panel h2, .panel h3, .panel h4 { margin: 0; }
.muted { color: var(--text-soft); }
.muted.compact { margin: 6px 0 0; font-size: 13px; line-height: 1.6; }
/* ---------- stat cards (kept for send_console/logs compat) ---------- */
.stats-grid { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 18px; }
.stat-card {
min-height: 124px; border-radius: var(--radius-md);
border: 1px solid var(--border); background: var(--panel-glass);
box-shadow: var(--shadow-soft); backdrop-filter: var(--glass-blur);
padding: 20px 22px; display: flex; align-items: center; justify-content: space-between; gap: 14px;
transition: transform 0.18s ease, box-shadow 0.18s ease, border-color 0.18s ease;
}
.stat-card:hover { transform: translateY(-2px); box-shadow: var(--shadow); border-color: var(--sidebar-active-border); }
.stat-meta { display: grid; gap: 10px; }
.stat-label { font-size: 14px; color: var(--text-soft); }
.stat-value { font-size: 28px; line-height: 1; font-weight: 800; letter-spacing: -0.03em; }
.stat-help { font-size: 13px; color: var(--text-soft); }
.stat-help.positive { color: var(--success); }
.stat-help.negative { color: var(--danger); }
.stat-icon {
width: 58px; height: 58px; border-radius: 50%;
display: inline-flex; align-items: center; justify-content: center;
font-size: 24px; font-weight: 700;
}
.stat-icon.blue { background: var(--info-soft); color: var(--info); }
.stat-icon.green { background: var(--success-soft); color: var(--success); }
.stat-icon.red { background: var(--danger-soft); color: var(--danger); }
.stat-icon.orange { background: var(--warning-soft); color: var(--warning); }
.layout-grid { display: grid; grid-template-columns: minmax(0, 1fr) 360px; gap: 22px; align-items: start; }
.overview-time { display: inline-flex; align-items: center; gap: 8px; color: var(--text-soft); font-size: 14px; font-weight: 600; }
.overview-time::before {
content: ""; width: 10px; height: 10px; border-radius: 50%;
background: var(--fire-grad); box-shadow: 0 0 0 4px rgba(255, 122, 61, 0.14);
}
/* ---------- pills / chips ---------- */
.status-pill, .pill, .status-chip {
display: inline-flex; align-items: center; gap: 6px;
border-radius: 999px; padding: 6px 12px; font-size: 12px; font-weight: 700;
color: var(--text-soft); background: var(--surface-tint); border: 1px solid var(--border);
}
.pill.soft, .status-pill.soft, .status-chip.success { color: var(--success); background: var(--success-soft); }
.pill.warning, .status-pill.warning, .status-chip.warning { color: var(--warning); background: var(--warning-soft); }
.pill.danger, .status-pill.danger, .status-chip.danger { color: var(--danger); background: var(--danger-soft); }
.status-chip.info { color: var(--info); background: var(--info-soft); }
.status-line { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; }
/* ---------- buttons ---------- */
button, .link-button {
appearance: none; border: none; border-radius: 12px;
background: var(--fire-grad); color: #fff; cursor: pointer;
padding: 11px 18px; font-weight: 700; letter-spacing: 0.01em;
box-shadow: 0 10px 22px rgba(255, 77, 109, 0.26);
transition: transform 0.16s ease, box-shadow 0.16s ease, opacity 0.16s ease, filter 0.16s ease;
}
button:hover, .link-button:hover { transform: translateY(-1px); box-shadow: 0 16px 30px rgba(255, 77, 109, 0.36); filter: brightness(1.05); }
button:active, .link-button:active { transform: translateY(0); box-shadow: 0 8px 16px rgba(255, 77, 109, 0.24); }
button:focus-visible, .link-button:focus-visible, .nav-item:focus-visible { outline: none; box-shadow: var(--ring); }
button:disabled { cursor: not-allowed; opacity: 0.5; transform: none; box-shadow: none; filter: grayscale(0.3); }
.ghost-button, .soft-button, .danger-button, .success-button { background: var(--surface); box-shadow: none; }
.ghost-button { color: var(--text); border: 1px solid var(--border-strong); }
.ghost-button:hover { border-color: var(--primary); color: var(--primary); background: var(--primary-soft); box-shadow: none; transform: none; filter: none; }
.soft-button { color: var(--primary); background: var(--primary-soft); border: 1px solid rgba(255, 122, 61, 0.20); }
.soft-button:hover { background: rgba(255, 122, 61, 0.20); box-shadow: none; transform: none; filter: none; }
.danger-button { color: var(--danger); border: 1px solid rgba(255, 84, 112, 0.26); background: var(--danger-soft); }
.danger-button:hover { background: rgba(255, 84, 112, 0.20); box-shadow: none; transform: none; filter: none; }
.success-button { color: var(--success); border: 1px solid rgba(43, 212, 127, 0.28); background: var(--success-soft); }
.success-button:hover { background: rgba(43, 212, 127, 0.20); box-shadow: none; transform: none; filter: none; }
.button-row, .button-grid { display: grid; gap: 12px; }
.button-row.two, .button-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
/* ---------- forms ---------- */
input[type="text"], input[type="password"], input[type="search"], input[type="number"], textarea, select {
width: 100%; border: 1px solid var(--border-strong); border-radius: 12px;
background: var(--input-bg); color: var(--text); padding: 11px 13px; outline: none;
transition: border-color 0.15s ease, box-shadow 0.15s ease;
}
input:focus, textarea:focus, select:focus { border-color: var(--primary); box-shadow: var(--ring); }
input::placeholder, textarea::placeholder { color: var(--text-faint); }
textarea { min-height: 96px; resize: vertical; }
.stack-form { display: grid; gap: 14px; }
.stack-form label { display: grid; gap: 8px; }
.stack-form span { font-size: 13px; color: var(--text-soft); }
.check-row { display: flex !important; align-items: center; gap: 10px; }
.check-row span { color: var(--text); }
/* ---------- flash ---------- */
.flash { border-radius: 14px; padding: 14px 16px; border: 1px solid var(--border); box-shadow: var(--shadow-soft); background: var(--surface); }
.flash.success { background: var(--success-soft); color: var(--success); border-color: rgba(43, 212, 127, 0.24); }
.flash.warning { background: var(--warning-soft); color: var(--warning); border-color: rgba(255, 181, 71, 0.24); }
.flash.error { background: var(--danger-soft); color: var(--danger); border-color: rgba(255, 84, 112, 0.24); }
/* ---------- tables ---------- */
.table-shell { overflow: hidden; border: 1px solid var(--border); border-radius: 14px; background: var(--surface); }
.table-shell.scrollable { overflow: auto; }
table { width: 100%; border-collapse: collapse; }
th, td { padding: 12px 14px; border-bottom: 1px solid var(--table-border); text-align: left; font-size: 13px; vertical-align: middle; }
th { background: var(--th-bg); color: var(--text-soft); font-weight: 700; letter-spacing: 0.01em; }
tbody tr:last-child td { border-bottom: none; }
tbody tr:hover td { background: var(--row-hover); }
/* ---------- code ---------- */
.code-block {
margin: 0; padding: 16px; border-radius: 14px;
background: var(--code-bg); color: #cdd8f5; overflow: auto;
font-size: 12px; line-height: 1.7;
}
.code-block.light { color: var(--text); background: var(--code-light-bg); border: 1px solid var(--border); }
.empty-state {
padding: 34px 20px; text-align: center; border-radius: 16px;
border: 1px dashed var(--border-strong); background: var(--empty-bg); color: var(--text-soft);
}
/* ---------- task banner ---------- */
.task-state-banner {
margin: 0 0 16px; padding: 12px 14px; border-radius: 12px;
border: 1px solid var(--border); font-size: 13px; font-weight: 700;
}
.task-state-banner.success { background: var(--success-soft); color: var(--success); }
.task-state-banner.warning { background: var(--warning-soft); color: var(--warning); }
.task-state-banner.info { background: var(--info-soft); color: var(--info); }
@media (max-width: 1380px) {
.stats-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
.layout-grid { grid-template-columns: 1fr; }
}
@media (max-width: 1080px) {
.app-shell { grid-template-columns: 1fr; }
.sidebar { display: none; }
.main-area { padding: 18px 16px 28px; }
.page-header { flex-direction: column; }
.stats-grid, .button-grid, .button-row.two { grid-template-columns: 1fr; }
}
</style>
<link rel="stylesheet" href="/static/app.css?v=20260710">
</head>
<body>
<body data-page="{% block page_key %}dashboard{% endblock %}">
<header class="mobile-topbar">
<button class="icon-button" type="button" data-nav-toggle aria-label="打开导航" title="打开导航">
<i data-lucide="menu"></i>
</button>
<a class="mobile-brand" href="/">
<span class="brand-mark"><i data-lucide="flame"></i></span>
<span>续火花</span>
</a>
<button class="icon-button" type="button" data-theme-toggle aria-label="切换主题" title="切换主题">
<i data-lucide="sun" class="theme-light-icon"></i>
<i data-lucide="moon" class="theme-dark-icon"></i>
</button>
</header>
<div class="sidebar-backdrop" data-nav-close></div>
<div class="app-shell">
<aside class="sidebar">
<div>
<div class="brand">
<div class="brand-mark">🔥</div>
<div>
<div class="brand-title">续火花</div>
<div class="brand-subtitle">多账号控制平台</div>
</div>
</div>
<p class="sidebar-section-title">导航</p>
<nav class="nav-list">
<a class="nav-item {% if current_nav|trim == 'dashboard' %}active{% endif %}" href="/">
<span class="nav-icon"></span><span>首页总览</span>
</a>
<a class="nav-item {% if current_nav|trim == 'send_console' %}active{% endif %}" href="/ops/send-console">
<span class="nav-icon"></span><span>发送控制台</span>
</a>
<a class="nav-item {% if current_nav|trim == 'logs' %}active{% endif %}" href="/ops/logs">
<span class="nav-icon"></span><span>发送记录</span>
</a>
</nav>
<p class="sidebar-section-title" style="margin-top:18px;">管理</p>
<nav class="nav-list">
<a class="nav-item" href="/#account-management"><span class="nav-icon"></span><span>账号管理</span></a>
<a class="nav-item" href="/#config-panel"><span class="nav-icon"></span><span>运行配置</span></a>
<a class="nav-item" href="/#interactive-login-section"><span class="nav-icon"></span><span>登录抖音账号</span></a>
<a class="nav-item" href="/#ops-panel"><span class="nav-icon"></span><span>运维操作</span></a>
<a class="nav-item" href="/#settings-panel"><span class="nav-icon"></span><span>系统设置</span></a>
</nav>
<aside class="sidebar" id="app-sidebar" aria-label="主导航">
<div class="sidebar-head">
<a class="brand" href="/">
<span class="brand-mark"><i data-lucide="flame"></i></span>
<span>
<strong class="brand-title">续火花</strong>
<small class="brand-subtitle">多账号控制平台</small>
</span>
</a>
<button class="icon-button sidebar-close" type="button" data-nav-close aria-label="关闭导航" title="关闭导航">
<i data-lucide="x"></i>
</button>
</div>
<div class="sidebar-footer">
<div class="sidebar-footer-head">
<div class="sidebar-footer-mark">{{ (current_user or "A")[:1] }}</div>
<div style="min-width:0;">
<div class="sidebar-footer-title" style="overflow:hidden;text-overflow:ellipsis;white-space:nowrap;">{{ current_user or "未登录" }}</div>
<div class="sidebar-footer-subtitle">已登录管理员</div>
<nav class="nav-groups">
<section>
<p class="nav-group-title">工作台</p>
<div class="nav-group">
<a class="nav-item {% if current_nav|trim == 'dashboard' %}active{% endif %}" href="/">
<i data-lucide="layout-dashboard"></i><span>首页总览</span>
</a>
<a class="nav-item {% if current_nav|trim == 'send_console' %}active{% endif %}" href="/ops/send-console">
<i data-lucide="send"></i><span>发送控制台</span>
</a>
<a class="nav-item {% if current_nav|trim == 'logs' %}active{% endif %}" href="/ops/logs">
<i data-lucide="scroll-text"></i><span>发送记录</span>
</a>
</div>
</section>
<section>
<p class="nav-group-title">管理</p>
<div class="nav-group">
<a class="nav-item" href="/#account-management">
<i data-lucide="users"></i><span>账号管理</span>
</a>
<a class="nav-item" href="/#interactive-login-section">
<i data-lucide="scan-line"></i><span>登录抖音账号</span>
</a>
<a class="nav-item" href="/#config-panel">
<i data-lucide="sliders-horizontal"></i><span>运行配置</span>
</a>
<a class="nav-item" href="/#ops-panel">
<i data-lucide="wrench"></i><span>运维操作</span>
</a>
<a class="nav-item" href="/#settings-panel">
<i data-lucide="settings"></i><span>系统设置</span>
</a>
</div>
</section>
</nav>
<div class="sidebar-footer">
<div class="user-summary">
<span class="user-avatar">{{ (current_user or "A")[:1] }}</span>
<span class="user-copy">
<strong>{{ current_user or "未登录" }}</strong>
<small>管理员</small>
</span>
</div>
<form method="post" action="/logout" style="margin-top:14px;">
<button type="submit" class="sidebar-logout"><span></span><span>退出登录</span></button>
<form method="post" action="/logout">
<button class="button button-quiet button-block" type="submit">
<i data-lucide="log-out"></i><span>退出登录</span>
</button>
</form>
</div>
</aside>
<main class="main-area">
<header class="page-header">
<div>
<div class="page-header__title">
<div class="page-title">{% block page_title %}续火花{% endblock %}</div>
</div>
<div class="page-subtitle">{% block page_subtitle %}多账号发送控制平台{% endblock %}</div>
<div class="page-heading">
<h1>{% block page_title %}续火花{% endblock %}</h1>
<p>{% block page_subtitle %}多账号发送控制平台{% endblock %}</p>
</div>
<div class="page-header__right">
<button class="theme-toggle" id="themeToggle" title="切换白天/夜间模式" aria-label="切换主题">
<span class="ico-sun"></span><span class="ico-moon"></span>
<div class="page-header-actions">
<button class="icon-button desktop-theme-toggle" type="button" data-theme-toggle aria-label="切换主题" title="切换主题">
<i data-lucide="sun" class="theme-light-icon"></i>
<i data-lucide="moon" class="theme-dark-icon"></i>
</button>
<div class="page-header__actions">
<div class="action-bar">
{% block topbar_actions %}{% endblock %}
</div>
</div>
</header>
{% if flash %}
<div class="flash flash-{{ flash.level }}" role="status">{{ flash.message }}</div>
{% endif %}
<div class="page-body">
{% if flash %}
<div class="flash {{ flash.level }}">{{ flash.message }}</div>
{% endif %}
{% block content %}{% endblock %}
</div>
</main>
</div>
<dialog class="confirm-dialog" id="confirm-dialog" aria-labelledby="confirm-title">
<div class="confirm-icon"><i data-lucide="triangle-alert"></i></div>
<h2 id="confirm-title">确认操作</h2>
<p id="confirm-message">请确认是否继续。</p>
<div class="confirm-actions">
<button class="button button-quiet" type="button" data-confirm-cancel>取消</button>
<button class="button button-danger" type="button" data-confirm-accept>确认执行</button>
</div>
</dialog>
<script defer src="/static/lucide.min.js?v=20260710"></script>
<script defer src="/static/app.js?v=20260710"></script>
{% block scripts %}{% endblock %}
<script>
(function(){
var root = document.documentElement;
var btn = document.getElementById('themeToggle');
var saved = 'dark';
try { saved = localStorage.getItem('sparkflow-theme') || 'dark'; } catch(e){}
function apply(t){ root.setAttribute('data-theme', t === 'light' ? 'light' : 'dark'); try{ localStorage.setItem('sparkflow-theme', t); }catch(e){} }
apply(saved);
if (btn) btn.addEventListener('click', function(){
var cur = root.getAttribute('data-theme') === 'light' ? 'light' : 'dark';
apply(cur === 'light' ? 'dark' : 'light');
});
})();
</script>
</body>
</html>
File diff suppressed because it is too large Load Diff
+60 -145
View File
@@ -1,168 +1,83 @@
<!doctype html>
<html lang="zh-CN">
<html lang="zh-CN" data-theme="dark">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="color-scheme" content="dark light">
<title>登录 | 续火花控制台</title>
<style>
:root {
--primary: #ff7a3d;
--primary-strong: #ff4d6d;
--text: #eaf0ff;
--text-soft: #9aa6c8;
--border: rgba(255,255,255,0.12);
--fire-grad: linear-gradient(135deg,#ffb04a 0%,#ff7a3d 30%,#ff4d6d 65%,#ff2da8 100%);
}
* { box-sizing: border-box; }
html, body { margin: 0; min-height: 100%; }
body {
min-height: 100vh;
display: grid;
grid-template-columns: minmax(0, 1.05fr) minmax(0, 1fr);
font-family: "Inter", "Segoe UI", "PingFang SC", "Microsoft YaHei", system-ui, sans-serif;
color: var(--text);
-webkit-font-smoothing: antialiased;
}
/* ---- left brand panel ---- */
.brand-panel {
position: relative; overflow: hidden;
padding: 48px 52px;
color: #fff;
background:
radial-gradient(680px circle at 12% 12%, rgba(255,122,61,0.45), transparent 55%),
radial-gradient(620px circle at 95% 92%, rgba(255,45,168,0.32), transparent 50%),
linear-gradient(205deg, #1a1230 0%, #140e26 56%, #0c0a1a 100%);
display: flex; flex-direction: column; justify-content: space-between; gap: 32px;
}
.brand-panel::after {
content: ""; position: absolute; right: -120px; bottom: -120px;
width: 360px; height: 360px; border-radius: 50%;
background: radial-gradient(circle, rgba(255,77,109,0.32), transparent 68%);
pointer-events: none;
}
.brand-top { display: flex; align-items: center; gap: 14px; position: relative; z-index: 1; }
.brand-mark {
width: 46px; height: 46px; border-radius: 14px;
background: var(--fire-grad); color: #fff;
display: inline-flex; align-items: center; justify-content: center;
font-size: 22px; font-weight: 800;
box-shadow: 0 16px 32px rgba(255,77,109,0.45);
}
.brand-title { font-size: 18px; font-weight: 800; letter-spacing: 0.01em; }
.brand-sub { margin-top: 3px; font-size: 12.5px; color: rgba(255,255,255,0.55); }
.brand-hero { position: relative; z-index: 1; max-width: 440px; }
.brand-hero h1 {
margin: 0 0 16px; font-size: 36px; line-height: 1.18; font-weight: 800; letter-spacing: -0.02em;
}
.brand-hero h1 em {
font-style: normal;
background: linear-gradient(120deg,#ffd9a0,#ff7a3d 50%,#ff2da8);
-webkit-background-clip: text; background-clip: text; -webkit-text-fill-color: transparent;
}
.brand-hero p { margin: 0; font-size: 15px; line-height: 1.8; color: rgba(255,255,255,0.72); }
.brand-features { position: relative; z-index: 1; display: grid; gap: 14px; margin-top: 8px; }
.brand-feature { display: flex; align-items: center; gap: 12px; font-size: 13.5px; color: rgba(255,255,255,0.82); }
.brand-feature i {
font-style: normal; width: 26px; height: 26px; border-radius: 8px;
background: rgba(255,255,255,0.08); border: 1px solid rgba(255,255,255,0.14);
display: inline-flex; align-items: center; justify-content: center; font-size: 14px;
}
.brand-foot { position: relative; z-index: 1; font-size: 12px; color: rgba(255,255,255,0.4); }
/* ---- right form panel ---- */
.form-panel {
display: flex; align-items: center; justify-content: center; padding: 40px 28px;
background:
radial-gradient(720px circle at 100% -10%, rgba(255,122,61,0.10), transparent 45%),
linear-gradient(180deg, #0a0e1c 0%, #0b1120 100%);
}
.card { width: min(94vw, 420px); }
.card-title { margin: 0 0 6px; font-size: 24px; font-weight: 800; letter-spacing: -0.01em; color: #fff; }
.card-lead { margin: 0 0 24px; color: var(--text-soft); line-height: 1.6; font-size: 14px; }
form { display: grid; gap: 15px; }
label { display: grid; gap: 7px; }
label > span { font-size: 13px; color: var(--text-soft); font-weight: 600; }
input {
width: 100%; border: 1px solid var(--border); border-radius: 12px;
padding: 12px 14px; color: var(--text); outline: none;
transition: border-color 0.15s ease, box-shadow 0.15s ease;
background: rgba(255,255,255,0.04);
}
input::placeholder { color: var(--text-soft); }
input:focus { border-color: var(--primary); box-shadow: 0 0 0 4px rgba(255,122,61,0.22); }
button {
margin-top: 4px; border: none; border-radius: 12px; padding: 13px 14px;
background: var(--fire-grad); color: #fff; font-weight: 700; font-size: 15px; cursor: pointer;
box-shadow: 0 14px 28px rgba(255,77,109,0.36);
transition: transform 0.16s ease, box-shadow 0.16s ease, filter 0.16s ease;
}
button:hover { transform: translateY(-1px); box-shadow: 0 18px 34px rgba(255,77,109,0.44); filter: brightness(1.05); }
button:active { transform: translateY(0); }
.flash { margin-bottom: 16px; padding: 12px 14px; border-radius: 12px; font-size: 13px; border: 1px solid transparent; }
.flash.success { background: rgba(43,212,127,0.14); color: #2bd47f; border-color: rgba(43,212,127,0.28); }
.flash.warning { background: rgba(255,181,71,0.14); color: #ffb547; border-color: rgba(255,181,71,0.28); }
.flash.error { background: rgba(255,84,112,0.14); color: #ff5470; border-color: rgba(255,84,112,0.28); }
.foot { margin-top: 20px; text-align: center; font-size: 12px; color: #64709a; }
@media (max-width: 900px) {
body { grid-template-columns: 1fr; }
.brand-panel { padding: 32px 28px; }
.brand-hero h1 { font-size: 26px; }
.brand-features { display: none; }
}
</style>
<link rel="stylesheet" href="/static/app.css?v=20260710">
</head>
<body>
<aside class="brand-panel">
<div class="brand-top">
<div class="brand-mark">🔥</div>
<div>
<div class="brand-title">续火花</div>
<div class="brand-sub">多账号控制平台</div>
</div>
<body class="login-page">
<aside class="login-brand-panel">
<a class="brand" href="/login">
<span class="brand-mark"><i data-lucide="flame"></i></span>
<span>
<strong class="brand-title">续火花</strong>
<small class="brand-subtitle">多账号控制平台</small>
</span>
</a>
<div class="login-brand-copy">
<h1>让每个好友<br><span>都不再断火花</span></h1>
<p>集中查看多账号发送进度、异常目标和登录状态,在一个控制台完成续火花运维。</p>
<ul class="login-features">
<li><i data-lucide="calendar-clock"></i><span>分时调度与当日未发送兜底</span></li>
<li><i data-lucide="badge-check"></i><span>服务端回执与强确认账本</span></li>
<li><i data-lucide="scan-line"></i><span>网页内扫码登录与登录态同步</span></li>
</ul>
</div>
<div class="brand-hero">
<h1>让每个好友<br><em>都不再断火花</em></h1>
<p>集中管理抖音多账号、目标好友与自动续火花任务,定时发送、失败补发、登录同步一站式运维。</p>
<div class="brand-features">
<div class="brand-feature"><i></i><span>多账号批量发送与定时调度</span></div>
<div class="brand-feature"><i></i><span>失败目标自动补发与重试队列</span></div>
<div class="brand-feature"><i>🔥</i><span>网页内交互式登录与登录态同步</span></div>
</div>
</div>
<div class="brand-foot">DouYin Spark Flow · Admin Console</div>
<small class="muted">DouYin Spark Flow · Admin Console</small>
</aside>
<main class="form-panel">
<div class="card">
<h2 class="card-title">控制台登录</h2>
<p class="card-lead">用于管理抖音多账号、目标好友、自动续火花任务与运维配置。</p>
<main class="login-form-panel">
<section class="login-card">
<h2>{% if bootstrapped %}控制台登录{% else %}初始化管理员{% endif %}</h2>
<p>登录后可管理账号、目标好友、发送任务与运维配置。</p>
{% if flash %}
<div class="flash {{ flash.level }}">{{ flash.message }}</div>
<div class="flash flash-{{ flash.level }}" role="status">{{ 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 method="post" action="/bootstrap" class="stack-form">
<label>
<span>管理员用户名</span>
<input type="text" name="username" value="admin" autocomplete="username">
</label>
<label>
<span>管理员密码</span>
<input type="password" name="password" autocomplete="new-password">
</label>
<label>
<span>确认密码</span>
<input type="password" name="confirm_password" autocomplete="new-password">
</label>
<button class="button button-primary button-block" type="submit">
<i data-lucide="shield-check"></i><span>初始化管理员账号</span>
</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 method="post" action="/login" class="stack-form">
<label>
<span>管理员用户名</span>
<input type="text" name="username" value="admin" autocomplete="username">
</label>
<label>
<span>管理员密码</span>
<input type="password" name="password" autocomplete="current-password">
</label>
<button class="button button-primary button-block" type="submit">
<i data-lucide="log-in"></i><span>登录控制台</span>
</button>
</form>
{% endif %}
<div class="foot">登录后可访问首页总览、发送控制台与系统设置</div>
</div>
<div class="login-foot">认证页面不会展示服务器凭据或登录态数据。</div>
</section>
</main>
<script defer src="/static/lucide.min.js?v=20260710"></script>
<script defer src="/static/app.js?v=20260710"></script>
</body>
</html>
@@ -1,149 +0,0 @@
{% extends "base.html" %}
{% block nav_key %}login_workspace{% endblock %}
{% block title %}登录工作区 | 自动续火花{% endblock %}
{% block page_title %}登录工作区{% endblock %}
{% block page_subtitle %}通过远端浏览器完成扫码、验证和登录态保存{% endblock %}
{% block topbar_actions %}
<a class="ghost-button" href="/accounts">账号管理</a>
{% endblock %}
{% block content %}
<div class="login-workspace-grid">
<aside class="stack">
<section class="panel">
<div class="section-title-row">
<div>
<h2>登录流程</h2>
<p class="muted compact">
适合新增账号、登录态失效或需要人工接管验证码的场景。
</p>
</div>
</div>
<div class="summary-list">
<div class="summary-row">
<strong>1. 打开浏览器</strong><span>启动远端交互式桌面</span>
</div>
<div class="summary-row">
<strong>2. 扫码与验证</strong><span>在 noVNC 中完成人工步骤</span>
</div>
<div class="summary-row">
<strong>3. 保存登录态</strong><span>写入新账号或覆盖已有账号</span>
</div>
</div>
</section>
<section
class="panel"
id="login-desktop-controls"
data-public-url="{{ login_desktop_public_url }}"
data-csrf-token="{{ csrf_token }}"
>
<div class="section-title-row">
<div>
<h2>桌面状态</h2>
<p class="muted compact">后台会持续轮询登录工作区状态。</p>
</div>
</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="button-row space-below-sm">
<span class="pill" id="login-desktop-runtime-state">检查中</span>
</div>
<div id="login-desktop-status-text">正在检查登录工作区状态。</div>
</div>
<div class="button-row space-above-md">
<button
type="button"
class="success-button login-desktop-open"
data-relogin-unique-id=""
>
打开新窗口
</button>
<button
type="button"
class="ghost-button login-desktop-reset"
data-confirm="确认重置登录工作区?当前远端浏览器状态会被重置。"
>
重置桌面
</button>
<button
type="button"
class="soft-button login-desktop-save"
data-relogin-unique-id=""
>
保存当前账号
</button>
</div>
</section>
</aside>
<section class="panel">
<div class="section-title-row">
<div>
<h2>浏览器工作区</h2>
<p class="muted compact">
如果内嵌 noVNC 无法连接,请使用新窗口打开或检查 login-desktop 服务。
</p>
</div>
</div>
<div class="url-field space-below-sm">
<input
id="desktop-public-url"
type="text"
value="{{ login_desktop_public_url }}"
readonly
/>
<button
type="button"
class="ghost-button copy-button"
id="copy-public-url"
aria-label="复制地址"
>
</button>
<a
class="soft-button"
href="{{ login_desktop_public_url }}"
target="_blank"
rel="noreferrer"
>新窗口打开</a
>
</div>
<div class="browser-frame-shell">
<div class="browser-frame-toolbar">
<span>noVNC 登录桌面</span>
<span class="pill info">端口 8788</span>
</div>
<iframe
class="desktop-frame"
src="{{ login_desktop_public_url }}"
title="交互式登录浏览器工作区"
loading="lazy"
></iframe>
<div class="frame-help">
连接失败时,请先点击“打开新窗口”;仍失败则重置桌面或检查容器状态。
</div>
</div>
</section>
</div>
{% endblock %}
File diff suppressed because it is too large Load Diff
@@ -1,334 +0,0 @@
{% extends "base.html" %}
{% block nav_key %}settings{% endblock %}
{% block title %}运行与系统 | 自动续火花{% endblock %}
{% block page_title %}运行与系统{% endblock %}
{% block page_subtitle %}发送参数、手动任务、代理维护和面板服务设置{% endblock %}
{% block topbar_actions %}
<a class="ghost-button" href="/ops/logs">运行日志</a>
{% endblock %}
{% block content %}
<div class="settings-layout">
<div class="settings-stack">
<section class="panel">
<div class="section-title-row">
<div>
<h2>运行配置</h2>
<p class="muted compact">
消息模板、随机策略和发送间隔等核心任务参数。
</p>
</div>
</div>
<form method="post" action="/config" class="stack-form">
<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>
<div class="form-actions">
<button type="submit">保存运行配置</button>
</div>
</form>
</section>
<section class="panel">
<div class="section-title-row">
<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 class="form-actions">
<button type="submit">保存系统设置</button>
</div>
</form>
</section>
<section class="panel">
<div class="section-title-row">
<div>
<h2>容器与调度</h2>
<p class="muted compact">用于确认部署服务状态和当前调度任务。</p>
</div>
</div>
<div class="content-grid">
<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 %}success{% else %}warning{% endif %}"
>{{ row.Status }}</span
>
</td>
<td>{{ row.Image }}</td>
</tr>
{% else %}
<tr>
<td colspan="3">当前没有可见容器状态。</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
<pre class="code-block">
{{ ops.crontab or "当前没有 crontab 任务。" }}</pre
>
</div>
</section>
</div>
<aside class="settings-stack">
<section class="panel">
<div class="section-title-row">
<div>
<h2>手动任务</h2>
<p class="muted compact">
批量发送动作会影响多个账号,执行前请确认目标范围。
</p>
</div>
</div>
<div class="link-list">
<form method="post" action="/ops/run-unsent">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}" />
<button class="soft-button" type="submit">补发未成功目标</button>
</form>
<form
method="post"
action="/ops/run-now"
data-confirm="补发全部对象会对所有启用账号的全部目标重新发送一遍,确认继续?"
>
<input type="hidden" name="csrf_token" value="{{ csrf_token }}" />
<button class="danger-button" type="submit">补发全部对象</button>
</form>
</div>
</section>
<section class="panel">
<div class="section-title-row">
<div>
<h2>代理维护</h2>
<p class="muted compact">刷新订阅或重启代理容器。</p>
</div>
</div>
<div class="link-list">
<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"
data-confirm="确认重启代理容器?重启期间发送任务可能短暂受影响。"
>
<input type="hidden" name="csrf_token" value="{{ csrf_token }}" />
<button class="danger-button" type="submit">重启代理容器</button>
</form>
</div>
</section>
<section class="panel">
<div class="section-title-row">
<div>
<h2>发送窗口</h2>
<p class="muted compact">北京时间,例如 10:00-18:00/10m。</p>
</div>
</div>
<form method="post" action="/ops/schedule" class="stack-form">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}" />
<label>
<span>当前发送窗口</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>
</section>
<section class="panel">
<div class="section-title-row">
<div>
<h2>部署路径</h2>
<p class="muted compact">只读展示当前检测到的 Compose 信息。</p>
</div>
</div>
<div class="stack-form">
<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>
{% endblock %}