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
+11
View File
@@ -1,5 +1,16 @@
# Changelog # Changelog
## 2026-07-11
- Reworked the Web console into a responsive unified operations dashboard with local Lucide icons, clearer account/target status, and safer confirmation dialogs.
- Added strong send-confirmation state handling, retryable unconfirmed targets, failure categorization, account cooldowns, and focused manual retry modes.
- Improved friend-list discovery, persistent browser profiles, Cookie synchronization, and browser/protocol failure persistence.
- Expanded the login desktop service with more robust Douyin identity export and bounded diagnostic endpoints.
- Added authenticated no-cache overview data that excludes message contents, Cookie data, receipts, and server credentials.
- Added unit coverage for send-state behavior, stale-lock safety, overview redaction, authentication, and public settings.
- Made stale-lock PID probing portable across Linux and Windows and sanitized the public configuration template.
- Removed obsolete standalone account, login-workspace, and settings templates now consolidated into the dashboard.
## 2026-05-30 ## 2026-05-30
- Redesigned the Web UI into dedicated overview, login workspace, accounts, send console, logs, and settings views. - Redesigned the Web UI into dedicated overview, login workspace, accounts, send console, logs, and settings views.
+38 -33
View File
@@ -15,12 +15,13 @@
| 文件 | 说明 | 大小 | | 文件 | 说明 | 大小 |
|------|------|------| |------|------|------|
| `browser.py` | 浏览器控制和页面操作 | 3.4 KB | | `browser.py` | 浏览器控制和页面操作 | 3.4 KB |
| `friends.py` | 好友列表管理和刷新逻辑 | 5.2 KB | | `friends.py` | 好友列表管理和刷新逻辑 | 8.8 KB |
| `login.py` | 登录流程控制 | 2.8 KB | | `login.py` | 登录流程控制 | 2.8 KB |
| `msg_builder.py` | 消息内容构建(一言、祝福等) | 4.5 KB | | `msg_builder.py` | 消息内容构建(一言、祝福等) | 4.5 KB |
| `protocol_dispatch.py` | 协议分发和路由 | 9.7 KB | | `protocol_dispatch.py` | 协议分发和路由 | 14.5 KB |
| `protocol_sender.mjs` | 消息发送协议(Node.js 脚本) | 21 KB | | `protocol_sender.mjs` | 消息发送协议(Node.js 脚本) | 22.8 KB |
| `tasks.py` | **任务调度核心**(定时任务、状态管理) | 83 KB | | `send_state.py` | 统一判定强确认、待核验和当日发送状态 | 1.6 KB |
| `tasks.py` | **任务调度核心**(定时任务、状态管理) | 109.5 KB |
### `webui/` - Web 管理界面 ### `webui/` - Web 管理界面
@@ -42,17 +43,16 @@ webui/
├── static/ # 静态资源 ├── static/ # 静态资源
│ ├── app.css # 主题样式(亮色/暗色) │ ├── app.css # 主题样式(亮色/暗色)
│ ├── app.js # 主题切换脚本 │ ├── app.js # 主题切换脚本
│ ├── lucide.min.js # 本地化图标库
│ ├── lucide-LICENSE.txt # 图标库许可证
│ ├── styles.css # 基础样式 │ ├── styles.css # 基础样式
│ └── multiPagePlugins/ # 浏览器扩展插件 │ └── multiPagePlugins/ # 浏览器扩展插件
└── templates/ # HTML 模板 └── templates/ # HTML 模板
├── base.html # 基础布局模板 ├── base.html # 基础布局模板
├── dashboard.html # 仪表盘(主界面) ├── dashboard.html # 仪表盘(主界面)
├── login.html # 登录页 ├── login.html # 登录页
├── login_workspace.html # 登录工作区
├── accounts.html # 账号管理
├── send_console.html # 发送控制台 ├── send_console.html # 发送控制台
── logs.html # 日志查看 ── logs.html # 日志查看
└── settings.html # 系统设置
``` ```
### `utils/` - 工具模块 ### `utils/` - 工具模块
@@ -140,37 +140,42 @@ docker run -d \
### `config.json` - 应用配置 ### `config.json` - 应用配置
主配置文件控制任务行为和系统设置。 主配置文件控制发送窗口、好友扫描、浏览器 Profile 和消息策略。仓库版本不包含真实账号标识:
```json ```json
{ {
"send_window_start": "09:00", "messageTemplate": "✨今日火花+1\n",
"send_window_end": "22:00", "useProtocolSender": false,
"send_interval_min": 300, "browserSenderAccounts": [],
"message_template": "hitokoto", "dailySendWindow": {
"enable_send_confirm": true, "enabled": true,
"friend_refresh_interval": 3600, "startHour": 10,
"browser_headless": false, "endHour": 18,
"browser_timeout": 30000, "scheduleIntervalMinutes": 10
"max_retry_count": 3, },
"cooldown_on_failure": 600 "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
}
} }
``` ```
**配置项说明** | 配置项 | 说明 |
|--------|------|
| 配置项 | 类型 | 默认值 | 说明 | | `dailySendWindow` | 每日发送窗口和调度间隔 |
|--------|------|--------|------| | `sendStrategy` | 账号启动延迟、消息间隔及消息变体 |
| `send_window_start` | string | "09:00" | 发送窗口开始时间 | | `friendListScan` | 好友列表扫描时限、空闲等待和滚动参数 |
| `send_window_end` | string | "22:00" | 发送窗口结束时间 | | `persistentBrowserProfiles` | 持久化 Playwright Profile 及 Cookie 同步策略 |
| `send_interval_min` | int | 300 | 最小发送间隔(秒) | | `browserSenderAccounts` | 强制使用浏览器发送的账号列表;公开模板默认为空 |
| `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 | 失败后冷却时间(秒) |
### `usersData.json` - 用户数据 ### `usersData.json` - 用户数据
+14 -4
View File
@@ -6,10 +6,7 @@
"saveDebugArtifacts": false, "saveDebugArtifacts": false,
"useProtocolSender": false, "useProtocolSender": false,
"protocolDryRun": false, "protocolDryRun": false,
"browserSenderAccounts": [ "browserSenderAccounts": [],
"94262577168",
"抖音号:softwomen"
],
"sendStrategy": { "sendStrategy": {
"shuffleTargets": true, "shuffleTargets": true,
"accountStartDelaySecondsMin": 15, "accountStartDelaySecondsMin": 15,
@@ -38,5 +35,18 @@
"happyNewYear": { "happyNewYear": {
"enabled": true, "enabled": true,
"messageTemplate": "\r\n" "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): 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 headless = not GUI
if get_environment() == Environment.LOCAL and DEBUG: if get_environment() == Environment.LOCAL and DEBUG:
headless = False headless = False
+113 -5
View File
@@ -20,6 +20,24 @@ FIRST_FRIEND_SELECTOR = (
) )
FRIEND_NAME_SELECTOR = """xpath=.//span[contains(@class, "item-header-name-")]""" FRIEND_NAME_SELECTOR = """xpath=.//span[contains(@class, "item-header-name-")]"""
LOGIN_MASK_SELECTORS = [".login-mask", ".login-guide-container", ".login-img-code-wrapper"] 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): 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 continue
async def collect_friend_names(page): async def _dismiss_non_login_dialogs(page):
await page.wait_for_selector(FRIENDS_TAB_SELECTOR, timeout=30000) for text in NON_LOGIN_DIALOG_DISMISS_TEXTS:
await page.locator(FRIENDS_TAB_SELECTOR).click() 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) for selector in NON_LOGIN_DIALOG_CLOSE_SELECTORS:
await page.locator(FIRST_FRIEND_SELECTOR).click() 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) 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 = [] found_names = []
seen_names = set() seen_names = set()
+130 -3
View File
@@ -64,6 +64,85 @@ def _account_identity_key(account):
return "" 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): def _merge_protocol_runtime_state(accounts, result_by_username):
changed = False changed = False
now_iso = datetime.now(timezone.utc).isoformat(timespec="seconds") now_iso = datetime.now(timezone.utc).isoformat(timespec="seconds")
@@ -109,6 +188,35 @@ def _merge_protocol_runtime_state(accounts, result_by_username):
if history: if history:
target_account["message_history"] = 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: if changed:
save_userData(all_accounts) save_userData(all_accounts)
@@ -243,12 +351,24 @@ async def run_protocol_tasks(config, accounts, message_builder):
dry_run, dry_run,
send_strategy, 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( 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"), user.get("username", "unknown"),
len(result.get("resolved", [])), len(result.get("resolved", [])),
len(result.get("unresolved", [])), len(result.get("unresolved", [])),
len(result.get("sent", [])), len(sent_entries),
succeeded_count,
failed_count,
bool(result.get("dryRun")),
) )
return result return result
@@ -258,8 +378,15 @@ async def run_protocol_tasks(config, accounts, message_builder):
failures = [] failures = []
for user, item in zip(accounts, gathered): for user, item in zip(accounts, gathered):
if isinstance(item, Exception): 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) 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 continue
result_by_username[user.get("username")] = item result_by_username[user.get("username")] = item
unresolved = item.get("unresolved", []) 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/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/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/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 CREATOR_CHAT_URL = "https://creator.douyin.com/creator-micro/data/following/chat";
const USER_AGENT = 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() {} function noop() {}
@@ -70,6 +71,36 @@ function randomBetweenInclusive(min, max) {
return Math.floor(Math.random() * (max - min + 1)) + min; 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() { async function readStdinJson() {
const chunks = []; const chunks = [];
for await (const chunk of process.stdin) { for await (const chunk of process.stdin) {
@@ -88,6 +119,10 @@ async function ensureBundles(cacheDir) {
const filename = url.split("/").at(-1); const filename = url.split("/").at(-1);
const filePath = path.join(cacheDir, filename); const filePath = path.join(cacheDir, filename);
if (fs.existsSync(filePath)) { 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; continue;
} }
const response = await fetch(url, { headers: { "User-Agent": USER_AGENT } }); 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 sendResult = await client.sendMessage({ message: messageObject });
const statusCode = sendResult?.statusCode ?? null;
sent.push({ sent.push({
target, target,
dryRun: false, dryRun: false,
message, message,
success: Boolean(sendResult?.success), success: Boolean(sendResult?.success),
statusCode: sendResult?.statusCode ?? null, statusCode,
statusName: sendMessageStatusName(statusCode),
statusMsg: sendResult?.statusMsg ?? "", statusMsg: sendResult?.statusMsg ?? "",
sendResultSummary: publicSendResultSummary(sendResult),
conversationId: mapping.conversationId, conversationId: mapping.conversationId,
delayBeforeSendSeconds, delayBeforeSendSeconds,
sentAt: stableNow(), 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())
+469 -68
View File
@@ -1,5 +1,8 @@
import asyncio import asyncio
import base64
import errno
import hashlib import hashlib
import json
import logging import logging
import os import os
import random 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.browser import get_browser, get_persistent_browser_context, sanitize_profile_name
from core.msg_builder import build_message, build_message_candidates from core.msg_builder import build_message, build_message_candidates
from core.protocol_dispatch import run_protocol_tasks 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.config import get_config, get_userData, normalize_unique_id, save_userData
from utils.logger import setup_logger from utils.logger import setup_logger
@@ -464,6 +468,239 @@ async def _detect_send_failure_indicator(page):
return "" 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): async def snapshot_last_own_message(page, chat_input=None):
try: try:
return await page.evaluate( return await page.evaluate(
@@ -811,12 +1048,30 @@ def _build_normalized_target_map(targets):
return normalized_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): async def _first_non_empty_locator(page, selectors):
for selector in selectors: for selector in selectors:
locator = page.locator(selector) locator = page.locator(selector)
try: count = await _locator_count_with_timeout(
count = await locator.count() locator,
except Exception: "unknown",
"first_non_empty_locator",
selector,
)
if count is None:
continue continue
for index in range(min(count, 5)): for index in range(min(count, 5)):
item = locator.nth(index) item = locator.nth(index)
@@ -832,7 +1087,13 @@ async def _selector_visible(page, selectors):
for selector in selectors: for selector in selectors:
locator = page.locator(selector).first locator = page.locator(selector).first
try: 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 return selector
except Exception: except Exception:
continue continue
@@ -843,7 +1104,13 @@ async def _click_first_visible_locator(candidates, account_name, stage):
last_error = None last_error = None
for label, locator in candidates: for label, locator in candidates:
try: 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: except Exception as exc:
last_error = exc last_error = exc
continue continue
@@ -878,17 +1145,26 @@ async def _open_friends_tab(page, account_name, fallback_selector, target_select
'[contains(normalize-space(.), "朋友") or contains(normalize-space(.), "好友") ' '[contains(normalize-space(.), "朋友") or 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 = [ 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)), ("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)), ("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)), ("fallback friends tab xpath", page.locator(fallback_selector)),
] ]
if await _click_first_visible_locator(candidates, account_name, "open_friends_tab"): if await _click_first_visible_locator(candidates, account_name, "open_friends_tab"):
await asyncio.sleep(1) await asyncio.sleep(2)
return "clicked" return "clicked"
await page.wait_for_selector(fallback_selector, timeout=30000) 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): def _parse_sent_at(raw_value, local_tz):
if not raw_value: return parse_sent_at(raw_value, local_tz)
return None
raw = str(raw_value).strip()
if raw.endswith("Z"):
raw = raw[:-1] + "+00:00"
try:
parsed = datetime.fromisoformat(raw)
except ValueError:
return None
if parsed.tzinfo is None:
return parsed.replace(tzinfo=local_tz)
return parsed.astimezone(local_tz)
def _manual_run_failed_only(): def _manual_run_failed_only():
@@ -1368,10 +1633,18 @@ def _unsent_retry_max_attempts():
def _target_sent_today(user, target_name, now): 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 {}) 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) 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): def _target_failed_today(user, target_name, now):
@@ -1462,6 +1735,25 @@ def _target_failure_attempts_today(user, target_name, now):
return 0 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): def _pending_failed_targets(user, now):
queue = dict(user.get("failure_queue") or {}) queue = dict(user.get("failure_queue") or {})
targets = [] targets = []
@@ -1477,6 +1769,9 @@ def _pending_failed_targets(user, now):
for target_name in user.get("targets") or []: for target_name in user.get("targets") or []:
if _target_sent_today(user, target_name, now): if _target_sent_today(user, target_name, now):
continue 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): if target_name in queue and _target_failed_today(user, target_name, now):
targets.append(target_name) targets.append(target_name)
return targets return targets
@@ -1489,6 +1784,9 @@ def _pending_unsent_targets(user, now):
for target_name in user.get("targets") or []: for target_name in user.get("targets") or []:
if _target_sent_today(user, target_name, now): if _target_sent_today(user, target_name, now):
continue 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) attempts_today = _target_failure_attempts_today(user, target_name, now)
if attempts_today >= max_attempts: if attempts_today >= max_attempts:
skipped_targets.append(f"{target_name}({attempts_today})") 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) retry_targets = _pending_failed_targets(user, now)
already_sent = [target for target in user.get("targets") or [] if _target_sent_today(user, target, now)] already_sent = [target for target in user.get("targets") or [] if _target_sent_today(user, target, now)]
logger.info( logger.info(
"manual-retry user=%s retryTargets=%s alreadySentToday=%s", "manual-retry user=%s retryTargetCount=%s strongConfirmedToday=%s",
user.get("username", "unknown"), user.get("username", "unknown"),
retry_targets, len(retry_targets),
already_sent, len(already_sent),
) )
if retry_targets: if retry_targets:
runnable_user = dict(user) 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) 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)] already_sent = [target for target in user.get("targets") or [] if _target_sent_today(user, target, now)]
logger.info( 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"), user.get("username", "unknown"),
retry_targets, len(retry_targets),
already_sent, len(already_sent),
skipped_targets, len(skipped_targets),
) )
if retry_targets: if retry_targets:
runnable_user = dict(user) runnable_user = dict(user)
@@ -1639,17 +1937,13 @@ def _prepare_active_users_for_run(active_config, active_user_data):
runnable_users = [] runnable_users = []
for user in active_user_data: for user in active_user_data:
due_targets, already_sent, pending_targets, queued_failures = _select_due_targets(user, send_window, now) 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( 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"), user.get("username", "unknown"),
due_targets, len(due_targets),
already_sent, len(already_sent),
pending_preview, len(pending_targets),
queued_failures, len(queued_failures),
) )
if due_targets: if due_targets:
runnable_user = dict(user) 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) accounts = get_userData(force_reload=True)
matched_account = _find_matching_account(accounts, user) matched_account = _find_matching_account(accounts, user)
if matched_account is None: 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, "attemptCount": _coerce_attempt_count(existing_entry) + 1,
"lastRunMode": _current_run_mode(), "lastRunMode": _current_run_mode(),
} }
if server_receipt:
queue[target_name]["serverReceipt"] = server_receipt
matched_account["failure_queue"] = queue matched_account["failure_queue"] = queue
save_userData(accounts) 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) accounts = get_userData(force_reload=True)
matched_account = _find_matching_account(accounts, user) matched_account = _find_matching_account(accounts, user)
if matched_account is None: if matched_account is None:
@@ -1855,11 +2151,25 @@ def _persist_browser_send_success(user, target_name, message, sent_at):
) )
return return
history = dict(matched_account.get("message_history") or {}) receipt_summary = ""
history[target_name] = { 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, "message": message,
"sentAt": sent_at, "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 matched_account["message_history"] = history
queue = dict(matched_account.get("failure_queue") or {}) queue = dict(matched_account.get("failure_queue") or {})
queue.pop(target_name, None) queue.pop(target_name, None)
@@ -1871,10 +2181,7 @@ def _persist_browser_send_success(user, target_name, message, sent_at):
save_userData(accounts) save_userData(accounts)
user_history = dict(user.get("message_history") or {}) user_history = dict(user.get("message_history") or {})
user_history[target_name] = { user_history[target_name] = dict(strong_entry)
"message": message,
"sentAt": sent_at,
}
user["message_history"] = user_history user["message_history"] = user_history
user_queue = dict(user.get("failure_queue") or {}) user_queue = dict(user.get("failure_queue") or {})
user_queue.pop(target_name, None) user_queue.pop(target_name, None)
@@ -1921,6 +2228,12 @@ def _pid_is_alive(pid):
return False return False
except PermissionError: except PermissionError:
return True 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 return True
@@ -2009,6 +2322,22 @@ def _release_browser_account_lock(handle, lock_path, account_name):
pass 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): async def run_browser_tasks(active_config, browser_user_data):
if not browser_user_data: if not browser_user_data:
return return
@@ -2028,7 +2357,11 @@ async def run_browser_tasks(active_config, browser_user_data):
profile_config["refreshStoredCookiesAfterLogin"], profile_config["refreshStoredCookiesAfterLogin"],
) )
for user in browser_user_data: 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)) tasks.append(do_user_task(None, user, semaphore, send_strategy, profile_config, friend_scan_config))
await asyncio.gather(*tasks) await asyncio.gather(*tasks)
return return
@@ -2036,7 +2369,11 @@ async def run_browser_tasks(active_config, browser_user_data):
playwright, browser = await get_browser() playwright, browser = await get_browser()
try: try:
for user in browser_user_data: 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)) tasks.append(do_user_task(browser, user, semaphore, send_strategy, profile_config, friend_scan_config))
await asyncio.gather(*tasks) await asyncio.gather(*tasks)
@@ -2052,13 +2389,39 @@ async def do_user_task(browser, user, semaphore, send_strategy, profile_config,
account_lock_path = None account_lock_path = None
try: try:
account_lock_handle, account_lock_path = await _acquire_browser_account_lock(user, account_name) account_lock_handle, account_lock_path = await _acquire_browser_account_lock(user, account_name)
await _do_user_task_locked( timeout_seconds = _browser_account_timeout_seconds(
friend_scan_config,
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, browser,
user, user,
send_strategy, send_strategy,
profile_config, profile_config,
friend_scan_config, friend_scan_config,
account_name, 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: finally:
if account_lock_handle is not None: if account_lock_handle is not None:
@@ -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) logger.info("Account %s started the message flow", account_name)
try: try:
index_targets = targets index_targets = targets
last_message = ""
schedule_now = datetime.now(_schedule_timezone()) schedule_now = datetime.now(_schedule_timezone())
if not _friend_index_complete_today(user, schedule_now): if not _friend_index_complete_today(user, schedule_now):
index_targets = list(user.get("targets") or targets) index_targets = list(user.get("targets") or targets)
logger.info( 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, account_name,
targets, len(targets),
) )
async for target_name in scroll_and_select_user( async for target_name in scroll_and_select_user(
page, page,
@@ -2153,22 +2517,32 @@ async def _do_user_task_locked(browser, user, send_strategy, profile_config, fri
message = "" message = ""
chat_input = None chat_input = None
last_own_message_before = None last_own_message_before = None
send_receipt = {}
try: try:
await save_debug_artifacts(page, account_name, target_name, "selected-friend") await save_debug_artifacts(page, account_name, target_name, "selected-friend")
chat_input, selector_used = await locate_chat_input(page) 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) logger.info("Using chat input selector %s for %s/%s", selector_used, account_name, target_name)
message = build_message() previous_entry = dict(user.get("message_history") or {}).get(target_name) or {}
logger.info("Prepared message for %s/%s: %r", account_name, target_name, message) 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( last_own_message_before = await snapshot_last_own_message(
page, page,
chat_input=chat_input, chat_input=chat_input,
) )
logger.info( logger.info(
"Last own message before send for %s/%s: %r", "Last own message before send for %s/%s length=%s",
account_name, account_name,
target_name, target_name,
(last_own_message_before or {}).get("text", ""), len((last_own_message_before or {}).get("text", "")),
) )
lines = message.split("\n") 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") 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) logger.info("Pressing Enter to send message for %s/%s", account_name, target_name)
await chat_input.press("Enter") await chat_input.press("Enter")
@@ -2188,17 +2563,42 @@ async def _do_user_task_locked(browser, user, send_strategy, profile_config, fri
message, message,
before_snapshot=last_own_message_before, 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") 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) 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( _persist_browser_send_success(
user, user,
target_name, target_name,
message, message,
datetime.now(timezone.utc).isoformat(timespec="seconds"), datetime.now(timezone.utc).isoformat(timespec="seconds"),
server_receipt=send_receipt,
) )
interval = _random_delay_seconds( interval = _random_delay_seconds(
send_strategy, send_strategy,
@@ -2216,7 +2616,7 @@ async def _do_user_task_locked(browser, user, send_strategy, profile_config, fri
message, message,
before_snapshot=last_own_message_before, before_snapshot=last_own_message_before,
) )
if sent_ok: if sent_ok and not str(exc).startswith("server send"):
logger.warning( logger.warning(
"Recovered send outcome for %s/%s after failure: %s", "Recovered send outcome for %s/%s after failure: %s",
account_name, account_name,
@@ -2228,6 +2628,7 @@ async def _do_user_task_locked(browser, user, send_strategy, profile_config, fri
target_name, target_name,
message, message,
datetime.now(timezone.utc).isoformat(timespec="seconds"), datetime.now(timezone.utc).isoformat(timespec="seconds"),
server_receipt=send_receipt,
) )
continue continue
@@ -2265,6 +2666,7 @@ async def _do_user_task_locked(browser, user, send_strategy, profile_config, fri
category, category,
reason, reason,
datetime.now(timezone.utc).isoformat(timespec="seconds"), datetime.now(timezone.utc).isoformat(timespec="seconds"),
server_receipt=send_receipt,
) )
interval = _random_delay_seconds( interval = _random_delay_seconds(
send_strategy, send_strategy,
@@ -2320,12 +2722,17 @@ async def runTasks():
logger.info("Starting tasks with config") logger.info("Starting tasks with config")
logger.info("multiTask=%s taskCount=%s", active_config["multiTask"], active_config["taskCount"]) logger.info("multiTask=%s taskCount=%s", active_config["multiTask"], active_config["taskCount"])
logger.info("messageTemplate=%s", active_config["messageTemplate"]) send_strategy = active_config.get("sendStrategy", {}) or {}
logger.info("sendStrategy=%s", active_config.get("sendStrategy", {})) logger.info(
logger.info("hitokotoTypes=%s", active_config["hitokotoTypes"]) "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)) logger.info("enabledUsers=%s disabledUsers=%s", len(active_user_data), len(disabled_user_data))
for user in active_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: for user in disabled_user_data:
logger.info("skipping disabled user=%s", user.get("username", "unknown")) 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) lock_path.parent.mkdir(parents=True, exist_ok=True)
def _lock_owner_is_alive(pid): def _lock_owner_is_alive(pid):
try: return _pid_is_alive(pid)
os.kill(pid, 0)
except ProcessLookupError:
return False
except PermissionError:
return True
return True
while True: while True:
try: try:
+345 -7
View File
@@ -3,7 +3,7 @@ import os
import shutil import shutil
from pathlib import Path from pathlib import Path
from fastapi import FastAPI, HTTPException from fastapi import FastAPI, HTTPException, Request, Response
import uvicorn import uvicorn
from playwright.async_api import async_playwright 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/" REMOTE_LOGIN_URL = "https://creator.douyin.com/"
WWW_SELF_URL = "https://www.douyin.com/user/self"
PROFILE_DIR = Path("/data/login-profile") PROFILE_DIR = Path("/data/login-profile")
GENERIC_WWW_NAMES = {
"",
"我的",
"",
"抖音官网账号",
"精选",
"推荐",
"搜索",
"关注",
"朋友",
"直播",
"放映厅",
"短剧",
"小游戏",
"客户端",
"通知",
"私信",
"投稿",
"海量优质视频内容",
"抖音精选电脑版",
}
class LoginDesktopManager: class LoginDesktopManager:
@@ -41,10 +63,18 @@ class LoginDesktopManager:
"--disable-dev-shm-usage", "--disable-dev-shm-usage",
"--no-sandbox", "--no-sandbox",
"--start-maximized", "--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() 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): def _context_is_closed(self):
return not self.context or getattr(self.context, "_impl_obj", None) is None return not self.context or getattr(self.context, "_impl_obj", None) is None
@@ -89,12 +119,45 @@ class LoginDesktopManager:
await self.start() await self.start()
async def status(self): async def status(self):
await self.ensure_running()
logged_in = False logged_in = False
username = "" username = ""
unique_id = "" unique_id = ""
page = await self._get_active_page() current_url = ""
current_url = page.url if page else ""
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:
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:
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: try:
result = await collect_login_result(page, self.context, timeout_ms=1000) result = await collect_login_result(page, self.context, timeout_ms=1000)
logged_in = True logged_in = True
@@ -102,6 +165,7 @@ class LoginDesktopManager:
unique_id = result["unique_id"] unique_id = result["unique_id"]
except Exception: except Exception:
pass pass
return { return {
"running": True, "running": True,
"logged_in": logged_in, "logged_in": logged_in,
@@ -126,6 +190,99 @@ class LoginDesktopManager:
return result 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() manager = LoginDesktopManager()
app = FastAPI(title="Douyin Login Desktop") app = FastAPI(title="Douyin Login Desktop")
@@ -164,12 +321,193 @@ async def reset():
@app.post("/export") @app.post("/export")
async def export(): async def export():
page = await manager._get_active_page()
try: try:
result = await manager.export() result = await manager.export()
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} return {"ok": True, "result": result}
except Exception as exc:
raise HTTPException(status_code=400, detail=str(exc))
@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__": if __name__ == "__main__":
uvicorn.run(app, host="0.0.0.0", port=int(os.getenv("LOGIN_DESKTOP_API_PORT", "18090")), reload=False) 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 json
import logging import logging
import traceback
from datetime import datetime, timedelta, timezone from datetime import datetime, timedelta, timezone
from pathlib import Path from pathlib import Path
import urllib.error import urllib.error
@@ -16,6 +15,7 @@ from starlette.middleware.sessions import SessionMiddleware
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
from core.friends import fetch_account_friends 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 core.tasks import run_browser_tasks, task_run_lock
from utils.config import ( from utils.config import (
get_app_settings, get_app_settings,
@@ -41,6 +41,7 @@ from webui.auth import (
) )
from webui.ops import ( from webui.ops import (
TASK_ALREADY_RUNNING, TASK_ALREADY_RUNNING,
get_overview_snapshot,
get_ops_snapshot, get_ops_snapshot,
read_log_tail, read_log_tail,
refresh_proxy, refresh_proxy,
@@ -113,24 +114,78 @@ def _schedule_timezone():
def _parse_sent_at(raw_value): def _parse_sent_at(raw_value):
if not raw_value: return parse_sent_at(raw_value, _schedule_timezone())
return None
raw = str(raw_value).strip()
if raw.endswith("Z"): def _history_entry_strong_confirmed_today(entry):
raw = raw[:-1] + "+00:00" return history_entry_is_strong_confirmed_today(
try: entry,
parsed = datetime.fromisoformat(raw) datetime.now(_schedule_timezone()),
except ValueError: )
return None
if parsed.tzinfo is None:
return parsed.replace(tzinfo=_schedule_timezone())
return parsed.astimezone(_schedule_timezone())
def _target_sent_today(account, target_name): 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 {} entry = dict(account.get("message_history") or {}).get(target_name) or {}
sent_at = _parse_sent_at(entry.get("sentAt")) 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(): def login_desktop_api_url():
@@ -201,6 +256,22 @@ def save_exported_login_result(login_result: dict, *, relogin_unique_id: str = "
return account, "created" 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(): def create_app():
settings = get_app_settings() settings = get_app_settings()
app = FastAPI(title="DouYin Spark Flow Admin") app = FastAPI(title="DouYin Spark Flow Admin")
@@ -213,28 +284,35 @@ def create_app():
) )
app.mount("/static", StaticFiles(directory=str(STATIC_DIR)), name="static") app.mount("/static", StaticFiles(directory=str(STATIC_DIR)), name="static")
DEBUG_ARTIFACTS_DIR.mkdir(parents=True, exist_ok=True) 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) @app.exception_handler(Exception)
async def global_exception_handler(request: Request, exc: Exception): async def global_exception_handler(request: Request, exc: Exception):
tb = traceback.format_exception(type(exc), exc, exc.__traceback__) logger.exception("Unhandled exception on %s %s", request.method, request.url.path)
tb_text = "".join(tb) return PlainTextResponse(
logger.error("Unhandled exception on %s %s:\n%s", request.method, request.url.path, tb_text) "Internal Server Error",
return PlainTextResponse(f"Internal Server Error\n\n{tb_text}", status_code=500) status_code=500,
headers={"Cache-Control": "no-store"},
)
def render_template(request, template_name, context=None, status_code=200): def render_template(request, template_name, context=None, status_code=200):
base_context = context or {} base_context = dict(context or {})
base_context.update( base_context.update(
{ {
"request": request, "request": request,
"current_user": current_user(request), "current_user": current_user(request),
"csrf_token": csrf_token(request) if current_user(request) else "", "csrf_token": csrf_token(request) if current_user(request) else "",
"is_https": is_https_request(request), "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), "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): def redirect(path="/", status_code=303):
return RedirectResponse(url=path, status_code=status_code) return RedirectResponse(url=path, status_code=status_code)
@@ -250,6 +328,17 @@ def create_app():
def pop_flash(request): def pop_flash(request):
return request.session.pop("flash", None) 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) @app.get("/login", response_class=HTMLResponse)
async def login_page(request: Request): async def login_page(request: Request):
if current_user(request): if current_user(request):
@@ -304,6 +393,19 @@ def create_app():
clear_session(request) clear_session(request)
return redirect("/login") 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) @app.get("/", response_class=HTMLResponse)
async def dashboard(request: Request): async def dashboard(request: Request):
maybe_redirect = require_user(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 {} updated_account = find_account(get_userData(force_reload=True), unique_id) or {}
if _target_sent_today(updated_account, target_name): 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: else:
account_failure = dict(updated_account.get("account_failure") or {}) account_failure = dict(updated_account.get("account_failure") or {})
affected_targets = list(account_failure.get("affectedTargets") 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") flash(request, f"Retry did not succeed for {account.get('username', 'Account')} / {target_name}: {reason}", "error")
return redirect("/ops/send-console") 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") @app.post("/config")
async def save_runtime_config(request: Request): async def save_runtime_config(request: Request):
maybe_redirect = require_user(request) maybe_redirect = require_user(request)
@@ -565,15 +729,9 @@ def create_app():
return Response("Invalid CSRF token", status_code=403) return Response("Invalid CSRF token", status_code=403)
settings = get_app_settings(force_reload=True) 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["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["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["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( settings["login_desktop_api_url"] = str(
form.get("login_desktop_api_url", settings.get("login_desktop_api_url", "http://127.0.0.1:18090")) form.get("login_desktop_api_url", settings.get("login_desktop_api_url", "http://127.0.0.1:18090"))
).strip() ).strip()
@@ -601,14 +759,14 @@ def create_app():
if not validate_csrf(request, str(form.get("csrf_token", ""))): if not validate_csrf(request, str(form.get("csrf_token", ""))):
return Response("Invalid CSRF token", status_code=403) return Response("Invalid CSRF token", status_code=403)
pid = run_task_now() pid = run_task_now(force_all=True)
if pid == TASK_ALREADY_RUNNING: if pid == TASK_ALREADY_RUNNING:
flash(request, "已有发送任务正在运行,本次补发全部对象没有启动。请等当前任务结束后再试。", "warning") flash(request, "已有发送任务正在运行,本次补发全部对象没有启动。请等当前任务结束后再试。", "warning")
elif pid == -1: elif pid == -1:
flash(request, "Failed to start the full resend run. Check server logs for details.", "error") flash(request, "Failed to start the full resend run. Check server logs for details.", "error")
else: else:
flash(request, f"已启动补发全部对象后台任务(pid {pid})。这只表示任务已启动,实际成功数请刷新发送控制台查看。", "info") flash(request, f"已启动补发全部对象后台任务(pid {pid})。这只表示任务已启动,实际成功数请刷新发送控制台查看。", "info")
return redirect("/") return redirect("/ops/send-console")
@app.post("/ops/run-failed") @app.post("/ops/run-failed")
async def run_failed_retry(request: Request): async def run_failed_retry(request: Request):
+387 -75
View File
@@ -1,3 +1,4 @@
import errno
import json import json
import hashlib import hashlib
import logging import logging
@@ -11,6 +12,7 @@ from datetime import datetime, timedelta, timezone
from pathlib import Path from pathlib import Path
from zoneinfo import ZoneInfo from zoneinfo import ZoneInfo
from core.send_state import history_entry_is_strong_confirmed_today, parse_sent_at
from utils.config import get_app_settings, get_config, get_userData, normalize_unique_id, repo_root, save_config from utils.config import get_app_settings, get_config, get_userData, normalize_unique_id, repo_root, save_config
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -25,6 +27,26 @@ TASK_SCHEDULE_MARKERS = (
HOST_CRONTAB_PATH = Path("/host-spool-cron/root") 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) 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(): def running_in_container():
return Path("/.dockerenv").exists() return Path("/.dockerenv").exists()
@@ -71,6 +93,12 @@ def _pid_is_alive(pid):
return False return False
except PermissionError: except PermissionError:
return True 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 return True
@@ -84,32 +112,62 @@ def _parse_lock_pid(raw):
def task_run_lock_status(): def task_run_lock_status():
lock_path = repo_root() / "logs" / "task.run.lock" lock_path = repo_root() / "logs" / "task.run.lock"
if not lock_path.exists(): 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") raw = lock_path.read_text(encoding="utf-8", errors="ignore")
pid = _parse_lock_pid(raw) pid = _parse_lock_pid(raw)
try: try:
age_seconds = max(0, int(datetime.now(timezone.utc).timestamp() - lock_path.stat().st_mtime)) age_seconds = max(0, int(datetime.now(timezone.utc).timestamp() - lock_path.stat().st_mtime))
except OSError: 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): if pid is not None and not _pid_is_alive(pid):
try: return {
lock_path.unlink() "running": False,
logger.warning("Removed stale task run lock owned by missing pid=%s", pid) "path": str(lock_path),
return {"running": False, "path": str(lock_path), "pid": pid, "ageSeconds": age_seconds, "staleRemoved": True} "pid": pid,
except FileNotFoundError: "ageSeconds": age_seconds,
return {"running": False, "path": str(lock_path), "pid": pid, "ageSeconds": age_seconds, "staleRemoved": True} "stale": True,
"staleReason": "owner_pid_missing",
"staleRemoved": False,
}
if pid is None and age_seconds > 7200: if pid is None and age_seconds > 7200:
try: return {
lock_path.unlink() "running": False,
logger.warning("Removed stale unreadable task run lock contents=%r", raw[:80]) "path": str(lock_path),
return {"running": False, "path": str(lock_path), "pid": None, "ageSeconds": age_seconds, "staleRemoved": True} "pid": None,
except FileNotFoundError: "ageSeconds": age_seconds,
return {"running": False, "path": str(lock_path), "pid": None, "ageSeconds": age_seconds, "staleRemoved": True} "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(): 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): def run_background_command(args, log_path, cwd=None, env=None):
log_path = Path(log_path) log_path = Path(log_path)
log_path.parent.mkdir(parents=True, exist_ok=True) 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() child_env = os.environ.copy()
if env: if env:
child_env.update(env) child_env.update(env)
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( process = subprocess.Popen(
args, args,
cwd=str(Path(cwd) if cwd else compose_root()), cwd=str(cwd_path),
stdout=handle, stdout=handle,
stderr=subprocess.STDOUT, stderr=subprocess.STDOUT,
env=child_env, env=child_env,
) )
handle.close() handle.write(f"[WEB_TRIGGER] {started_at} pid={process.pid}\n".encode("utf-8", errors="replace"))
handle.flush()
return process.pid return process.pid
@@ -289,7 +359,7 @@ def get_task_container_rows():
return [] 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: try:
lock_status = task_run_lock_status() lock_status = task_run_lock_status()
if lock_status.get("running"): if lock_status.get("running"):
@@ -306,10 +376,19 @@ def run_task_now(*, unsent_only=False, failed_only=False):
"SPARKFLOW_MANUAL_RUN": "1", "SPARKFLOW_MANUAL_RUN": "1",
"PYTHONUNBUFFERED": "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" run_env["SPARKFLOW_MANUAL_FAILED_ONLY"] = "1"
elif unsent_only: elif unsent_only:
run_env["SPARKFLOW_MANUAL_UNSENT_ONLY"] = "1" 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( return run_background_command(
command, command,
log_file, log_file,
@@ -508,6 +587,49 @@ def current_daily_schedule():
return "" 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(): def _schedule_timezone():
timezone_name = ( timezone_name = (
str(os.getenv("SPARKFLOW_TIMEZONE") or "").strip() str(os.getenv("SPARKFLOW_TIMEZONE") or "").strip()
@@ -533,18 +655,7 @@ def _normalize_send_window():
def _parse_sent_at(raw_value, local_tz): def _parse_sent_at(raw_value, local_tz):
if not raw_value: return parse_sent_at(raw_value, local_tz)
return None
raw = str(raw_value).strip()
if raw.endswith("Z"):
raw = raw[:-1] + "+00:00"
try:
parsed = datetime.fromisoformat(raw)
except ValueError:
return None
if parsed.tzinfo is None:
return parsed.replace(tzinfo=local_tz)
return parsed.astimezone(local_tz)
def _account_identity(user): def _account_identity(user):
@@ -629,72 +740,152 @@ def _scheduled_send_time(user, target_name, send_window, now):
return start_of_window + timedelta(minutes=offset_minutes) return start_of_window + timedelta(minutes=offset_minutes)
def _build_target_status(account, target_name, now, send_window): def _base_target_status(account, target_name, now):
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 { return {
"target": target_name, "target": target_name,
"status": "sent", "status": "",
"message": str(history_entry.get("message") or ""), "message": "",
"sentAt": sent_at.isoformat(timespec="seconds"), "sentAt": "",
"lastAttemptAt": "", "lastAttemptAt": "",
"category": "", "category": "",
"reason": "", "reason": "",
"attemptCount": 0, "attemptCount": 0,
"scheduledAt": "", "scheduledAt": "",
"friendIndex": friend_index, "friendIndex": _friend_index_status(account, target_name),
"confirmationLevel": "",
"confirmationSource": "",
"confirmationDetail": "",
"needsVerification": False,
"legacyUnverified": False,
"displaySentAt": "",
"displayLastAttemptAt": "",
"displayScheduledAt": "",
"confirmationLabel": "",
"categoryLabel": "",
} }
failure_entry = failure_queue.get(target_name) or {}
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) last_attempt_at = _parse_sent_at(failure_entry.get("lastAttemptAt"), now.tzinfo)
if last_attempt_at and last_attempt_at.date() == now.date(): failure_is_today = bool(last_attempt_at and last_attempt_at.date() == now.date())
return {
"target": target_name, if sent_at and sent_at.date() == now.date():
"status": "failed", 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 ""), "message": str(failure_entry.get("message") or ""),
"sentAt": "",
"lastAttemptAt": last_attempt_at.isoformat(timespec="seconds"), "lastAttemptAt": last_attempt_at.isoformat(timespec="seconds"),
"category": str(failure_entry.get("category") or ""), "category": category,
"reason": str(failure_entry.get("reason") or ""), "reason": str(failure_entry.get("reason") or ""),
"attemptCount": int(failure_entry.get("attemptCount") or 0), "attemptCount": int(failure_entry.get("attemptCount") or 0),
"scheduledAt": "", "confirmationLevel": str(failure_entry.get("confirmationLevel") or ("weak" if status == "unconfirmed" else "")),
"friendIndex": friend_index, "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 scheduled_at = None
if send_window.get("enabled"): if send_window.get("enabled"):
scheduled_at = _scheduled_send_time(account, target_name, send_window, now) scheduled_at = _scheduled_send_time(account, target_name, send_window, now)
if scheduled_at > now: if scheduled_at > now:
return { item.update(
"target": target_name, {
"status": "pending", "status": "pending",
"message": "",
"sentAt": "",
"lastAttemptAt": "",
"category": "",
"reason": "",
"attemptCount": 0,
"scheduledAt": scheduled_at.isoformat(timespec="seconds"), "scheduledAt": scheduled_at.isoformat(timespec="seconds"),
"friendIndex": friend_index,
} }
)
return _finalize_target_status(item, now)
return { item.update(
"target": target_name, {
"status": "unprocessed", "status": "unprocessed",
"message": "",
"sentAt": "",
"lastAttemptAt": "",
"category": "",
"reason": "",
"attemptCount": 0,
"scheduledAt": scheduled_at.isoformat(timespec="seconds") if scheduled_at else "", "scheduledAt": scheduled_at.isoformat(timespec="seconds") if scheduled_at else "",
"friendIndex": friend_index,
} }
)
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(): def get_send_console_snapshot():
@@ -706,13 +897,23 @@ def get_send_console_snapshot():
"enabled_accounts": len(accounts), "enabled_accounts": len(accounts),
"total_targets": 0, "total_targets": 0,
"today_sent_targets": 0, "today_sent_targets": 0,
"today_confirmed_targets": 0,
"today_unconfirmed_targets": 0,
"today_legacy_unverified_targets": 0,
"today_failed_targets": 0, "today_failed_targets": 0,
"today_pending_targets": 0, "today_pending_targets": 0,
"today_unprocessed_targets": 0, "today_unprocessed_targets": 0,
"today_account_blocked_targets": 0, "today_account_blocked_targets": 0,
"today_attention_targets": 0,
"today_remaining_targets": 0, "today_remaining_targets": 0,
"today_account_failures": 0, "today_account_failures": 0,
"today_account_paused": 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_rows = []
account_failure_pause_after = _account_failure_pause_after_attempts() account_failure_pause_after = _account_failure_pause_after_attempts()
@@ -720,14 +921,16 @@ def get_send_console_snapshot():
for account in accounts: for account in accounts:
configured_targets = list(account.get("targets") or []) configured_targets = list(account.get("targets") or [])
statuses = [_build_target_status(account, target_name, now, send_window) for target_name in configured_targets] 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"] failed_targets = [item for item in statuses if item["status"] == "failed"]
account_failure = _account_failure_entry_today(account, now) account_failure = _account_failure_entry_today(account, now)
account_paused = bool(account_failure and _coerce_attempt_count(account_failure) >= account_failure_pause_after) account_paused = bool(account_failure and _coerce_attempt_count(account_failure) >= account_failure_pause_after)
account_blocked_targets = [] account_blocked_targets = []
if account_paused: if account_paused:
account_blocked_targets = [ 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 for item in statuses
if item["status"] in {"pending", "unprocessed"} if item["status"] in {"pending", "unprocessed"}
] ]
@@ -747,22 +950,63 @@ def get_send_console_snapshot():
except (TypeError, ValueError): except (TypeError, ValueError):
friend_index_meta["scannedCount"] = 0 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["total_targets"] += len(configured_targets)
summary["today_sent_targets"] += len(sent_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_failed_targets"] += len(failed_targets)
summary["today_pending_targets"] += len(pending_targets) summary["today_pending_targets"] += len(pending_targets)
summary["today_unprocessed_targets"] += len(unprocessed_targets) summary["today_unprocessed_targets"] += len(unprocessed_targets)
summary["today_account_blocked_targets"] += len(account_blocked_targets) summary["today_account_blocked_targets"] += len(account_blocked_targets)
summary["today_attention_targets"] += attention_count
summary["today_remaining_targets"] += ( summary["today_remaining_targets"] += (
len(failed_targets) len(unconfirmed_targets)
+ len(failed_targets)
+ len(pending_targets) + len(pending_targets)
+ len(unprocessed_targets) + len(unprocessed_targets)
+ len(account_blocked_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: if account_failure:
summary["today_account_failures"] += 1 summary["today_account_failures"] += 1
if account_paused: if account_paused:
summary["today_account_paused"] += 1 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( account_rows.append(
{ {
@@ -770,27 +1014,93 @@ def get_send_console_snapshot():
"username": account.get("username") or "", "username": account.get("username") or "",
"total_targets": len(configured_targets), "total_targets": len(configured_targets),
"sent_targets": sent_targets, "sent_targets": sent_targets,
"confirmed_targets": confirmed_targets,
"unconfirmed_targets": unconfirmed_targets,
"legacy_unverified_targets": legacy_unverified_targets,
"failed_targets": failed_targets, "failed_targets": failed_targets,
"pending_targets": pending_targets, "pending_targets": pending_targets,
"unprocessed_targets": unprocessed_targets, "unprocessed_targets": unprocessed_targets,
"account_blocked_targets": account_blocked_targets, "account_blocked_targets": account_blocked_targets,
"last_failure_reason": failed_targets[0]["reason"] if failed_targets else "", "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 {}), "failure_queue": dict(account.get("failure_queue") or {}),
"account_failure": account_failure, "account_failure": account_failure,
"account_paused": account_paused, "account_paused": account_paused,
"account_failure_pause_after": account_failure_pause_after, "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_meta": friend_index_meta,
"friend_index_count": len(dict(account.get("friend_index") or {})), "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 { return {
"now": now.isoformat(timespec="seconds"), "now": now.isoformat(timespec="seconds"),
"nowDisplay": now.strftime("%m-%d %H:%M"),
"summary": summary, "summary": summary,
"accounts": account_rows, "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(): def _check_image_present():
"""Return True if the douyin-sparkflow:local image exists.""" """Return True if the douyin-sparkflow:local image exists."""
try: try:
@@ -811,14 +1121,16 @@ def get_ops_snapshot():
Every external call is individually guarded so the dashboard always Every external call is individually guarded so the dashboard always
renders, even when Docker or crontab are not available. renders, even when Docker or crontab are not available.
""" """
send_console = get_send_console_snapshot()
return { return {
"compose_root": str(compose_root()), "compose_root": str(compose_root()),
"compose_file": str(compose_file_path() or ""), "compose_file": str(compose_file_path() or ""),
"containers": get_container_status(), "containers": get_container_status(),
"task_containers": get_task_container_rows(), "task_containers": get_task_container_rows(),
"send_console": get_send_console_snapshot(), "send_console": send_console,
"task_lock": task_run_lock_status(), "task_lock": task_run_lock_status(),
"daily_schedule": current_daily_schedule(), "daily_schedule": current_daily_schedule(),
"schedule": get_schedule_snapshot(),
"crontab": read_crontab(), "crontab": read_crontab(),
"log_tail": read_log_tail(120), "log_tail": read_log_tail(120),
"image_present": _check_image_present(), "image_present": _check_image_present(),
File diff suppressed because it is too large Load Diff
+427 -310
View File
@@ -1,138 +1,360 @@
(() => { (() => {
const storageKey = "sparkflow-theme";
const root = document.documentElement; const root = document.documentElement;
const storageKey = "sparkflow-theme";
const readStoredTheme = () => { const storedTheme = () => {
try { try {
return window.localStorage.getItem(storageKey); return localStorage.getItem(storageKey);
} catch { } catch {
return null; return null;
} }
}; };
const writeStoredTheme = (theme) => { const applyTheme = (theme) => {
const value = theme === "light" ? "light" : "dark";
root.dataset.theme = value;
root.style.colorScheme = value;
try { try {
window.localStorage.setItem(storageKey, theme); localStorage.setItem(storageKey, value);
} catch { } catch {
// Ignore storage restrictions; the current page can still switch theme. // The active page can still switch themes when storage is unavailable.
} }
}; };
const preferredTheme = () => { applyTheme(storedTheme() || "dark");
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());
document.querySelectorAll("[data-theme-toggle]").forEach((button) => { document.querySelectorAll("[data-theme-toggle]").forEach((button) => {
button.addEventListener("click", () => { button.addEventListener("click", () => {
const next = root.dataset.theme === "dark" ? "light" : "dark"; applyTheme(root.dataset.theme === "light" ? "dark" : "light");
writeStoredTheme(next);
applyTheme(next);
}); });
}); });
})(); })();
(() => { (() => {
const body = document.body; const body = document.body;
const navToggle = document.querySelector("[data-nav-toggle]"); document.querySelectorAll("[data-nav-toggle]").forEach((button) => {
const navClose = document.querySelector("[data-nav-close]"); button.addEventListener("click", () => body.classList.add("nav-open"));
});
navToggle?.addEventListener("click", () => body.classList.toggle("nav-open")); document.querySelectorAll("[data-nav-close]").forEach((button) => {
navClose?.addEventListener("click", () => body.classList.remove("nav-open")); button.addEventListener("click", () => body.classList.remove("nav-open"));
document.querySelectorAll(".nav-item").forEach((item) => { });
item.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 dialog = document.getElementById("confirm-dialog");
const message = node.getAttribute("data-confirm") || "确认执行此操作?"; if (!dialog) return;
const handler = (event) => { const title = document.getElementById("confirm-title");
if (!window.confirm(message)) { 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;
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.preventDefault();
event.stopImmediatePropagation(); 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();
} }
}; });
if (node.tagName === "FORM") {
node.addEventListener("submit", handler); dialog.addEventListener("cancel", () => {
} else { pendingForm = null;
node.addEventListener("click", handler); pendingLink = "";
} pendingButton = null;
}); });
})(); })();
(() => { (() => {
document.querySelectorAll(".data-card__footer-button").forEach((button) => { document.querySelectorAll("[data-segment-group]").forEach((group) => {
button.addEventListener("click", () => { const buttons = [...group.querySelectorAll("[data-segment-target]")];
const targetId = button.dataset.expandTarget; const owner = group.closest("[data-segment-owner]") || document;
if (!targetId) return; const panels = [...owner.querySelectorAll("[data-segment-panel]")];
const panel = document.getElementById(targetId); const activate = (name) => {
if (!panel) return; buttons.forEach((button) => {
const isOpen = panel.classList.toggle("is-open"); const active = button.dataset.segmentTarget === name;
button.textContent = isOpen button.classList.toggle("active", active);
? "收起" button.setAttribute("aria-selected", active ? "true" : "false");
: button.dataset.totalLabel || "查看全部"; });
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"); const root = document.getElementById("login-desktop-controls");
if (!root) return; if (!root) return;
const section = document.getElementById("interactive-login-section");
const csrfToken = root.dataset.csrfToken || ""; const csrfToken = root.dataset.csrfToken || "";
const publicUrl = root.dataset.publicUrl || ""; const publicUrl = root.dataset.publicUrl || "";
const runtimeStateEl = document.getElementById("login-desktop-runtime-state"); const runtimeState = document.getElementById(
const statusTextEl = document.getElementById("login-desktop-status-text"); "login-desktop-runtime-state",
const openButtons = document.querySelectorAll(".login-desktop-open"); );
const saveButtons = document.querySelectorAll(".login-desktop-save"); const statusText = document.getElementById("login-desktop-status-text");
const resetButtons = document.querySelectorAll(".login-desktop-reset"); const frame = document.querySelector("[data-login-frame]");
const copyPublicUrlButton = document.getElementById("copy-public-url"); let timer = null;
const statusMap = {
checking: document.getElementById("desktop-status-checking"),
pending: document.getElementById("desktop-status-pending"),
success: document.getElementById("desktop-status-success"),
error: document.getElementById("desktop-status-error"),
};
const setVisualStatus = (state) => { const setStatus = (text, tone = "") => {
Object.values(statusMap).forEach((node) => { if (statusText) statusText.textContent = text;
if (!node) return; if (runtimeState) {
node.style.opacity = "0.46"; runtimeState.className = `pill${tone ? ` ${tone}` : ""}`;
}); runtimeState.textContent =
if (statusMap[state]) { tone === "success" ? "已登录" : tone === "danger" ? "异常" : "待登录";
statusMap[state].style.opacity = "1";
} }
}; };
const setStatus = (text, tone = "", state = "checking") => {
if (statusTextEl) statusTextEl.textContent = text;
if (runtimeStateEl) {
runtimeStateEl.className = `pill${tone ? ` ${tone}` : ""}`;
}
setVisualStatus(state);
};
const postForm = async (url, payload = {}) => { const postForm = async (url, payload = {}) => {
const formData = new FormData(); const formData = new FormData();
formData.set("csrf_token", csrfToken); formData.set("csrf_token", csrfToken);
@@ -151,284 +373,185 @@
return data; return data;
}; };
const openDesktopWindow = () => { const loadFrame = () => {
const popup = window.open(publicUrl, "_blank"); if (frame && frame.dataset.loaded !== "1" && frame.dataset.src) {
if (!popup) { frame.src = frame.dataset.src;
setStatus( frame.dataset.loaded = "1";
"浏览器阻止了登录工作区弹窗,请允许弹窗后再试。",
"danger",
"error",
);
return false;
} }
return true;
}; };
const pollStatus = async () => { const pollStatus = async () => {
if (document.visibilityState !== "visible" || (section && !section.open)) {
return;
}
try { try {
const response = await fetch("/login-desktop/status", { const response = await fetch("/login-desktop/status", {
credentials: "same-origin", credentials: "same-origin",
cache: "no-store",
}); });
const data = await response.json(); const data = await response.json();
if (!response.ok || data.ok === false) { if (!response.ok || data.ok === false) {
if (runtimeStateEl) runtimeStateEl.textContent = "不可用";
setStatus( setStatus(
data.error || "登录工作区不可用,请检查 login-desktop 服务。", data.error || "登录工作区不可用,请检查 login-desktop 服务。",
"warning", "danger",
"error",
); );
return; return;
} }
if (runtimeStateEl)
runtimeStateEl.textContent = data.logged_in ? "已登录" : "待登录";
if (data.logged_in) { if (data.logged_in) {
setStatus( setStatus(`当前浏览器已登录:${data.username}`, "success");
`当前浏览器已登录:${data.username}${data.unique_id}`,
"success",
"success",
);
} else { } else {
setStatus( setStatus("当前浏览器尚未登录,可打开工作区开始人工登录。");
"当前浏览器尚未登录,可打开登录工作区开始人工登录。",
"",
"pending",
);
} }
} catch (error) { } catch (error) {
if (runtimeStateEl) runtimeStateEl.textContent = "异常"; setStatus(`状态检查失败:${error.message}`, "danger");
setStatus(`登录工作区状态检查失败:${error.message}`, "danger", "error");
} }
}; };
copyPublicUrlButton?.addEventListener("click", async () => { document.querySelectorAll(".login-desktop-open").forEach((button) => {
try {
await navigator.clipboard.writeText(publicUrl);
setStatus("登录工作区地址已复制。", "success", "pending");
} catch (error) {
setStatus(`复制失败:${error.message}`, "warning", "error");
}
});
openButtons.forEach((button) => {
button.addEventListener("click", async () => { button.addEventListener("click", async () => {
const reloginUniqueId = String(
button.dataset.reloginUniqueId || "",
).trim();
const accountName = String(button.dataset.accountName || "").trim();
try { try {
await postForm("/login-desktop/open"); await postForm("/login-desktop/open");
openDesktopWindow(); loadFrame();
setStatus( window.open(publicUrl, "_blank", "noopener");
reloginUniqueId setStatus("请在登录工作区完成登录,然后保存登录态。");
? `请使用账号 ${accountName || reloginUniqueId} 完成登录,然后保存登录态。`
: "请在远端浏览器中完成抖音创作者中心登录,然后保存账号。",
"",
"pending",
);
} catch (error) { } 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 () => { button.addEventListener("click", async () => {
const reloginUniqueId = String(
button.dataset.reloginUniqueId || "",
).trim();
const accountName = String(button.dataset.accountName || "").trim();
try { try {
const data = await postForm("/login-desktop/save", { const data = await postForm("/login-desktop/save", {
relogin_unique_id: reloginUniqueId, relogin_unique_id: button.dataset.reloginUniqueId || "",
}); });
setStatus( setStatus(`已保存登录账号:${data.account?.username || ""}`, "success");
reloginUniqueId window.setTimeout(() => window.location.reload(), 800);
? `已把当前浏览器登录保存到账号:${accountName || reloginUniqueId}`
: `已保存当前登录账号:${data.account?.username || ""}`,
"success",
"success",
);
window.setTimeout(() => window.location.reload(), 900);
} catch (error) { } 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 () => { button.addEventListener("click", async () => {
try { try {
await postForm("/login-desktop/reset"); await postForm("/login-desktop/reset");
setStatus( setStatus("登录工作区已重置,正在重新初始化。");
"登录工作区已重置,正在重新初始化浏览器。",
"warning",
"checking",
);
await pollStatus(); await pollStatus();
} catch (error) { } catch (error) {
setStatus(`重置登录工作区失败:${error.message}`, "danger", "error"); setStatus(`重置登录工作区失败:${error.message}`, "danger");
} }
}); });
}); });
setVisualStatus("checking"); 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(); pollStatus();
window.setInterval(pollStatus, 5000); }
});
}
timer = window.setInterval(pollStatus, 5000);
window.addEventListener("pagehide", () => window.clearInterval(timer));
})(); })();
(() => { (() => {
const pickers = document.querySelectorAll(".friend-picker"); const parseJson = (id) => {
if (!pickers.length) return; const node = document.getElementById(id);
if (!node) return [];
const parseJsonScript = (id) => {
const el = document.getElementById(id);
if (!el) return [];
try { try {
return JSON.parse(el.textContent || "[]"); return JSON.parse(node.textContent || "[]");
} catch (error) { } catch {
console.error("Failed to parse friend picker JSON", id, error);
return []; return [];
} }
}; };
const escapeHtml = (value) => document.querySelectorAll(".friend-picker").forEach((picker) => {
String(value)
.replaceAll("&", "&amp;")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll('"', "&quot;")
.replaceAll("'", "&#39;");
pickers.forEach((picker) => {
const accountId = picker.dataset.accountId; const accountId = picker.dataset.accountId;
const refreshUrl = picker.dataset.refreshUrl; const refreshUrl = picker.dataset.refreshUrl;
const csrfToken = picker.dataset.csrfToken; 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 refreshButton = picker.querySelector(".friend-refresh-button");
const listEl = picker.querySelector(".friend-picker-list"); const list = picker.querySelector(".friend-picker-list");
const summaryEl = picker.querySelector(".friend-picker-summary"); const summary = picker.querySelector(".friend-picker-summary");
const statusEl = picker.querySelector(".friend-picker-status"); const status = picker.querySelector(".friend-picker-status");
const hiddenInputsEl = picker.querySelector(".friend-selected-inputs"); let friends = parseJson(`friends-cache-${accountId}`);
const formEl = picker.closest("form"); let selected = new Set(parseJson(`selected-targets-${accountId}`));
const targetsTextarea = formEl?.querySelector(".targets-textarea");
const currentTargetsEl = picker.querySelector(
".friend-picker-current-targets span",
);
let friends = parseJsonScript(`friends-cache-${accountId}`); const parseTargets = (value) =>
let selected = new Set(parseJsonScript(`selected-targets-${accountId}`)); [...new Set(
String(value || "")
const splitTargetText = (value) => {
const seen = new Set();
return String(value || "")
.replaceAll(",", "\n") .replaceAll(",", "\n")
.split(/\r?\n/) .split(/\r?\n/)
.map((name) => name.trim()) .map((item) => item.trim())
.filter((name) => { .filter(Boolean),
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 render = () => {
const merged = []; const query = String(search?.value || "").trim().toLowerCase();
const seen = new Set(); const names = combined().filter((name) =>
[...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) =>
name.toLowerCase().includes(query), name.toLowerCase().includes(query),
); );
renderHiddenInputs(); if (summary) summary.textContent = `已选 ${selected.size}`;
updateSummary(); list.innerHTML = "";
if (!names.length) {
if (!allNames.length) { const empty = document.createElement("div");
listEl.innerHTML = empty.className = "friend-picker-empty";
'<div class="friend-picker-empty">点击“刷新好友列表”后再勾选目标好友。</div>'; empty.textContent = combined().length
? "没有匹配的好友。"
: "点击“刷新好友列表”后再选择目标。";
list.appendChild(empty);
return; return;
} }
names.forEach((name) => {
if (!displayNames.length) { const label = document.createElement("label");
listEl.innerHTML = label.className = `friend-option${selected.has(name) ? " selected" : ""}`;
'<div class="friend-picker-empty">没有匹配的好友。</div>'; const text = document.createElement("span");
return; text.textContent = name;
} const checkbox = document.createElement("input");
checkbox.type = "checkbox";
listEl.innerHTML = displayNames checkbox.checked = selected.has(name);
.map(
(name) => `
<label class="friend-option ${selected.has(name) ? "selected" : ""}">
<span>${escapeHtml(name)}</span>
<input type="checkbox" value="${escapeHtml(name)}" ${selected.has(name) ? "checked" : ""}>
</label>
`,
)
.join("");
listEl.querySelectorAll('input[type="checkbox"]').forEach((checkbox) => {
checkbox.addEventListener("change", () => { checkbox.addEventListener("change", () => {
const option = checkbox.closest(".friend-option"); if (checkbox.checked) selected.add(name);
const value = checkbox.value; else selected.delete(name);
if (checkbox.checked) { syncTextarea();
selected.add(value); render();
option?.classList.add("selected");
} else {
selected.delete(value);
option?.classList.remove("selected");
}
syncTextareaFromSelected();
renderHiddenInputs();
updateSummary();
}); });
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; refreshButton.disabled = true;
const originalText = refreshButton.textContent; if (status) status.textContent = "正在读取好友列表...";
refreshButton.textContent = "刷新中...";
statusEl.textContent = "正在读取好友列表...";
try { try {
const formData = new FormData(); const formData = new FormData();
formData.set("csrf_token", csrfToken); formData.set("csrf_token", csrfToken);
@@ -437,29 +560,23 @@
body: formData, body: formData,
credentials: "same-origin", credentials: "same-origin",
}); });
const payload = await response.json(); const data = await response.json();
if (!response.ok) { if (!response.ok) throw new Error(data.error || "刷新失败");
throw new Error(payload.error || "刷新好友列表失败"); friends = data.friends || [];
} if (status) status.textContent = data.message || "好友列表已刷新";
friends = Array.isArray(payload.friends) ? payload.friends : []; render();
statusEl.textContent =
payload.message || `已刷新 ${friends.length} 个好友`;
renderList();
} catch (error) { } catch (error) {
statusEl.textContent = error.message || "刷新好友列表失败"; if (status) status.textContent = `刷新失败:${error.message}`;
} finally { } finally {
refreshButton.disabled = false; refreshButton.disabled = false;
refreshButton.textContent = originalText;
} }
}); });
render();
searchInput.addEventListener("input", renderList);
targetsTextarea?.addEventListener("input", () => {
syncSelectedFromTextarea();
renderList();
});
syncSelectedFromTextarea();
renderList();
}); });
})(); })();
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 %}
+93 -477
View File
@@ -4,518 +4,134 @@
<head> <head>
<meta charset="utf-8"> <meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="color-scheme" content="dark light">
<title>{% block title %}续火花{% endblock %}</title> <title>{% block title %}续火花{% endblock %}</title>
<style> <link rel="stylesheet" href="/static/app.css?v=20260710">
/* ============ 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>
</head> </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"> <div class="app-shell">
<aside class="sidebar"> <aside class="sidebar" id="app-sidebar" aria-label="主导航">
<div> <div class="sidebar-head">
<div class="brand"> <a class="brand" href="/">
<div class="brand-mark">🔥</div> <span class="brand-mark"><i data-lucide="flame"></i></span>
<div> <span>
<div class="brand-title">续火花</div> <strong class="brand-title">续火花</strong>
<div class="brand-subtitle">多账号控制平台</div> <small class="brand-subtitle">多账号控制平台</small>
</div> </span>
</a>
<button class="icon-button sidebar-close" type="button" data-nav-close aria-label="关闭导航" title="关闭导航">
<i data-lucide="x"></i>
</button>
</div> </div>
<p class="sidebar-section-title">导航</p> <nav class="nav-groups">
<nav class="nav-list"> <section>
<p class="nav-group-title">工作台</p>
<div class="nav-group">
<a class="nav-item {% if current_nav|trim == 'dashboard' %}active{% endif %}" href="/"> <a class="nav-item {% if current_nav|trim == 'dashboard' %}active{% endif %}" href="/">
<span class="nav-icon"></span><span>首页总览</span> <i data-lucide="layout-dashboard"></i><span>首页总览</span>
</a> </a>
<a class="nav-item {% if current_nav|trim == 'send_console' %}active{% endif %}" href="/ops/send-console"> <a class="nav-item {% if current_nav|trim == 'send_console' %}active{% endif %}" href="/ops/send-console">
<span class="nav-icon"></span><span>发送控制台</span> <i data-lucide="send"></i><span>发送控制台</span>
</a> </a>
<a class="nav-item {% if current_nav|trim == 'logs' %}active{% endif %}" href="/ops/logs"> <a class="nav-item {% if current_nav|trim == 'logs' %}active{% endif %}" href="/ops/logs">
<span class="nav-icon"></span><span>发送记录</span> <i data-lucide="scroll-text"></i><span>发送记录</span>
</a> </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>
</div> </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="sidebar-footer">
<div class="sidebar-footer-head"> <div class="user-summary">
<div class="sidebar-footer-mark">{{ (current_user or "A")[:1] }}</div> <span class="user-avatar">{{ (current_user or "A")[:1] }}</span>
<div style="min-width:0;"> <span class="user-copy">
<div class="sidebar-footer-title" style="overflow:hidden;text-overflow:ellipsis;white-space:nowrap;">{{ current_user or "未登录" }}</div> <strong>{{ current_user or "未登录" }}</strong>
<div class="sidebar-footer-subtitle">已登录管理员</div> <small>管理员</small>
</span>
</div> </div>
</div> <form method="post" action="/logout">
<form method="post" action="/logout" style="margin-top:14px;"> <button class="button button-quiet button-block" type="submit">
<button type="submit" class="sidebar-logout"><span></span><span>退出登录</span></button> <i data-lucide="log-out"></i><span>退出登录</span>
</button>
</form> </form>
</div> </div>
</aside> </aside>
<main class="main-area"> <main class="main-area">
<header class="page-header"> <header class="page-header">
<div> <div class="page-heading">
<div class="page-header__title"> <h1>{% block page_title %}续火花{% endblock %}</h1>
<div class="page-title">{% block page_title %}续火花{% endblock %}</div> <p>{% block page_subtitle %}多账号发送控制平台{% endblock %}</p>
</div> </div>
<div class="page-subtitle">{% block page_subtitle %}多账号发送控制平台{% endblock %}</div> <div class="page-header-actions">
</div> <button class="icon-button desktop-theme-toggle" type="button" data-theme-toggle aria-label="切换主题" title="切换主题">
<div class="page-header__right"> <i data-lucide="sun" class="theme-light-icon"></i>
<button class="theme-toggle" id="themeToggle" title="切换白天/夜间模式" aria-label="切换主题"> <i data-lucide="moon" class="theme-dark-icon"></i>
<span class="ico-sun"></span><span class="ico-moon"></span>
</button> </button>
<div class="page-header__actions"> <div class="action-bar">
{% block topbar_actions %}{% endblock %} {% block topbar_actions %}{% endblock %}
</div> </div>
</div> </div>
</header> </header>
<div class="page-body">
{% if flash %} {% if flash %}
<div class="flash {{ flash.level }}">{{ flash.message }}</div> <div class="flash flash-{{ flash.level }}" role="status">{{ flash.message }}</div>
{% endif %} {% endif %}
<div class="page-body">
{% block content %}{% endblock %} {% block content %}{% endblock %}
</div> </div>
</main> </main>
</div> </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 %} {% 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> </body>
</html> </html>
File diff suppressed because it is too large Load Diff
+60 -145
View File
@@ -1,168 +1,83 @@
<!doctype html> <!doctype html>
<html lang="zh-CN"> <html lang="zh-CN" data-theme="dark">
<head> <head>
<meta charset="utf-8"> <meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="color-scheme" content="dark light">
<title>登录 | 续火花控制台</title> <title>登录 | 续火花控制台</title>
<style> <link rel="stylesheet" href="/static/app.css?v=20260710">
: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>
</head> </head>
<body> <body class="login-page">
<aside class="brand-panel"> <aside class="login-brand-panel">
<div class="brand-top"> <a class="brand" href="/login">
<div class="brand-mark">🔥</div> <span class="brand-mark"><i data-lucide="flame"></i></span>
<div> <span>
<div class="brand-title">续火花</div> <strong class="brand-title">续火花</strong>
<div class="brand-sub">多账号控制平台</div> <small class="brand-subtitle">多账号控制平台</small>
</div> </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>
<div class="brand-hero"> <small class="muted">DouYin Spark Flow · Admin Console</small>
<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>
</aside> </aside>
<main class="form-panel"> <main class="login-form-panel">
<div class="card"> <section class="login-card">
<h2 class="card-title">控制台登录</h2> <h2>{% if bootstrapped %}控制台登录{% else %}初始化管理员{% endif %}</h2>
<p class="card-lead">用于管理抖音多账号、目标好友、自动续火花任务与运维配置。</p> <p>登录后可管理账号、目标好友、发送任务与运维配置。</p>
{% if flash %} {% if flash %}
<div class="flash {{ flash.level }}">{{ flash.message }}</div> <div class="flash flash-{{ flash.level }}" role="status">{{ flash.message }}</div>
{% endif %} {% endif %}
{% if not bootstrapped %} {% if not bootstrapped %}
<form method="post" action="/bootstrap"> <form method="post" action="/bootstrap" class="stack-form">
<label><span>管理员用户名</span><input type="text" name="username" value="admin"></label> <label>
<label><span>管理员密码</span><input type="password" name="password"></label> <span>管理员用户名</span>
<label><span>确认密码</span><input type="password" name="confirm_password"></label> <input type="text" name="username" value="admin" autocomplete="username">
<button type="submit">初始化管理员账号</button> </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> </form>
{% else %} {% else %}
<form method="post" action="/login"> <form method="post" action="/login" class="stack-form">
<label><span>管理员用户名</span><input type="text" name="username" value="admin"></label> <label>
<label><span>管理员密码</span><input type="password" name="password"></label> <span>管理员用户名</span>
<button type="submit">登录控制台</button> <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> </form>
{% endif %} {% endif %}
<div class="foot">登录后可访问首页总览、发送控制台与系统设置</div> <div class="login-foot">认证页面不会展示服务器凭据或登录态数据。</div>
</div> </section>
</main> </main>
<script defer src="/static/lucide.min.js?v=20260710"></script>
<script defer src="/static/app.js?v=20260710"></script>
</body> </body>
</html> </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 %}
+28 -6
View File
@@ -224,17 +224,37 @@ VNC_PORT=5901
#### `config.json` - 应用配置 #### `config.json` - 应用配置
仓库中的 `DouYinSparkFlow/config.json` 是不含账号数据的模板。常用配置示例:
```json ```json
{ {
"send_window_start": "09:00", // 发送窗口开始时间 "messageTemplate": "✨今日火花+1\n",
"send_window_end": "22:00", // 发送窗口结束时间 "useProtocolSender": false,
"send_interval_min": 300, // 最小发送间隔(秒) "browserSenderAccounts": [],
"message_template": "hitokoto", // 消息模板类型 "dailySendWindow": {
"enable_send_confirm": true, // 启用发送确认 "enabled": true,
"friend_refresh_interval": 3600 // 好友列表刷新间隔(秒) "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
}
} }
``` ```
`usersData.json``webui_settings.json`、浏览器 Profile、Cookie 和日志均为运行时敏感数据,不应提交到 Git。
</details> </details>
--- ---
@@ -248,6 +268,8 @@ VNC_PORT=5901
- **douyin-web**: Web 管理控制台服务 - **douyin-web**: Web 管理控制台服务
- **login-desktop**: 登录桌面服务(包含浏览器环境) - **login-desktop**: 登录桌面服务(包含浏览器环境)
- **proxy**: Mihomo 代理服务(可选) - **proxy**: Mihomo 代理服务(可选)
- **scheduler**: 发送窗口定时调度服务
- **task**: 一次性发送任务服务
#### 快速部署 #### 快速部署
+1
View File
@@ -111,4 +111,5 @@ services:
volumes: volumes:
- ./DouYinSparkFlow:/app - ./DouYinSparkFlow:/app
- ./DouYinSparkFlow/logs:/app/logs - ./DouYinSparkFlow/logs:/app/logs
- ./state/browser-profiles:/opt/douyin-sparkflow/state/browser-profiles
restart: "no" restart: "no"