// ==UserScript==
// @name 抖音续火助手 - 抖音自动续火花·批量自动发送·多用户定时油猴脚本
// @namespace http://tampermonkey.net/
// @version 2026.10.02
// @description 抖音续火、抖音续火花、抖音自动发送、抖音批量发送脚本。支持多用户批量续火花,每日定时或随机时间自动发送,集成一言API与TXTAPI自定义文案,自动记录好友火花天数,专属一言按周一至周日配置,支持暂停继续、自动重试、后端调度器回调实现无人值守挂机。适配 https://www.douyin.com/chat 页面,兼容 ScriptCat、Tampermonkey 等油猴脚本管理器。日志支持多级筛选、按天分片、批量写入。
// @author 飔梦 / 阚泥 / xiaohe123awa / YsKiKi
// @match https://www.douyin.com/chat*
// @icon https://free.picui.cn/free/2025/11/23/69226264aca4e.png
// @grant GM_setValue
// @grant GM_getValue
// @grant GM_registerMenuCommand
// @grant GM_notification
// @grant GM_listValues
// @grant GM_deleteValue
// @grant GM_xmlhttpRequest
// @connect hitokoto.cn
// @connect localhost
// @license MIT
// ==/UserScript==
(function () {
'use strict';
/* =========================================================
* 基础工具
* ========================================================= */
const sleep = ms => new Promise(r => setTimeout(r, ms));
const nowMs = () => Date.now();
function getLocalTodayString(d = new Date()) {
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
}
function escapeHtml(s) {
const div = document.createElement('div');
div.appendChild(document.createTextNode(String(s ?? '')));
return div.innerHTML;
}
/* =========================================================
* 常量
* ========================================================= */
const STATE = Object.freeze({ IDLE: 'idle', SEARCHING: 'searching', FOUND: 'found', SENDING: 'sending' });
const LEVEL = Object.freeze({ INFO: 'info', SUCCESS: 'success', WARN: 'warn', ERROR: 'error' });
const CAT = Object.freeze({ SEND: 'send', API: 'api', UI: 'ui', STATE: 'state', SYSTEM: 'system' });
const GITHUB_REPO_URL = 'https://github.com/dr-190/ScriptCat-Douyin-Fire-Helper';
const TOOLS_ZIP_GITHUB = 'https://github.com/dr-190/ScriptCat-Douyin-Fire-Helper/raw/refs/heads/main/%E9%85%8D%E5%A5%97%E5%B7%A5%E5%85%B7.zip';
const TOOLS_ZIP_PROXY = 'https://gh-proxy.org/https://github.com/dr-190/ScriptCat-Douyin-Fire-Helper/raw/refs/heads/main/%E9%85%8D%E5%A5%97%E5%B7%A5%E5%85%B7.zip';
/* =========================================================
* 选择器表
* ========================================================= */
const SELECTORS = {
searchInput: [
'input.semi-input[placeholder="搜索"][type="text"]',
'input.semi-input[placeholder="搜索"]',
'input[placeholder="搜索"]',
'.searchSearchInputinput_box input',
'.LeftPanelHeadersearch input'
],
chatBtn: [
'div[class*="SearchPanelitemchat_btn"]',
'[class*="chat_btn"]',
'[class*="SearchPanel"] [class*="btn"]'
],
chatInput: [
'div[data-slate-editor="true"][contenteditable="true"]',
'div[contenteditable="true"][data-slate-editor]',
'div[contenteditable="true"]'
],
conversationItem: [
'[data-e2e="conversation-item"]',
'.conversationConversationItemwrapper'
],
conversationTitle: [
'.conversationConversationItemtitle',
'[class*="ConversationItemtitle"]:not([class*="Wrapper"]):not([class*="wrapper"])'
],
conversationList: [
'.conversationConversationListwrapper',
'[class*="ConversationListwrapper"]',
'[class*="conversationList-wrapper"]'
],
sparkStatus: [
'.RightPanelHeadertitleContainer .commonStreaknormalText',
'[class*="RightPanelHeader"] .commonStreaknormalText',
'[class*="RightPanel"] [class*="Header"] .commonStreaknormalText'
],
noMore: [
'[class*="no-more-tip-"]',
'[class*="noMoreTip"]'
]
};
function queryFirst(selectorList) {
const list = Array.isArray(selectorList) ? selectorList : [selectorList];
for (const sel of list) {
try {
const el = document.querySelector(sel);
if (el) return { el, sel };
} catch (e) { /* ignore */ }
}
return { el: null, sel: null };
}
function queryAllFirst(selectorList) {
const list = Array.isArray(selectorList) ? selectorList : [selectorList];
for (const sel of list) {
try {
const els = document.querySelectorAll(sel);
if (els.length > 0) return { els, sel };
} catch (e) { /* ignore */ }
}
return { els: [], sel: null };
}
/* =========================================================
* 配置
* ========================================================= */
const DEFAULT_CONFIG = {
baseMessage: "续火",
sendTime: "00:01:00",
sendTimeRandom: false,
sendTimeRangeStart: "23:30:00",
sendTimeRangeEnd: "00:30:00",
maxRetryCount: 3,
autoRetryInterval: 10,
retryAfterMaxReached: true,
retryResetInterval: 10,
enableTargetUser: false,
targetUsernames: "",
multiUserMode: "sequential",
multiUserRetrySame: false,
customMessage: "—————每日续火—————\n\n[TXTAPI]\n\n—————每日一言—————\n\n[API]\n\n—————专属一言—————\n\n[专属一言]\n\n🔥 火花已续 [天数] 天",
chatPageLineSeparator: " | ",
useHitokoto: true,
useTxtApi: true,
useSpecialHitokoto: true,
specialHitokotoRandom: true,
txtApiMode: "manual",
txtApiManualRandom: true,
hitokotoFormat: "{hitokoto}\n—— {from}{from_who}",
fromFormat: "{from}",
fromWhoFormat: "「{from_who}」",
txtApiUrl: "https://v1.hitokoto.cn/?encode=text",
txtApiManualText: "文本1\n文本2\n文本3",
hitokotoTimeout: 60000,
txtApiTimeout: 60000,
specialHitokotoMonday: "周一专属文案1\n周一专属文案2",
specialHitokotoTuesday: "周二专属文案1\n周二专属文案2",
specialHitokotoWednesday: "周三专属文案1\n周三专属文案2",
specialHitokotoThursday: "周四专属文案1\n周四专属文案2",
specialHitokotoFriday: "周五专属文案1\n周五专属文案2",
specialHitokotoSaturday: "周六专属文案1\n周六专属文案2",
specialHitokotoSunday: "周日专属文案1\n周日专属文案2",
fireDays: 1,
lastFireDate: "",
autoFetchFireDays: true,
userSearchTimeout: 10000,
pageLoadWaitTime: 5000,
chatInputCheckInterval: 1000,
searchDebounceDelay: 500,
searchThrottleDelay: 1000,
maxLiveLogs: 8,
maxViewLogs: 100,
maxHistoryLogs: 500,
logRetentionDays: 7,
enableScriptBCallback: false,
scriptBCallbackPort: 7788,
backendRetryMinutes: 25,
initialDelay: 30
};
/* =========================================================
* 全局状态
* ========================================================= */
let userConfig = {};
let currentState = STATE.IDLE;
let isProcessing = false;
let isPaused = false;
let retryCount = 0;
let currentRetryUser = null;
let sentUsersToday = [];
let failedUsersToday = [];
let allTargetUsers = [];
let currentUserIndex = -1;
let alreadyDoneNotified = false;
let lastSendCompleteTime = 0;
let nextSendTime = null;
let searchAttemptCount = 0;
let searchTimeoutId = null;
let chatInputCheckTimer = null;
let chatInputNotFoundCount = 0;
let countdownInterval = null;
let _autoSendTimer = null;
let autoRetryTimer = null;
let lastRetryResetTime = 0;
let isMaxRetryReached = false;
let specialHitokotoSentIndexes = {
monday: [], tuesday: [], wednesday: [], thursday: [],
friday: [], saturday: [], sunday: []
};
let isDragging = false;
let dragOffsetX = 0;
let dragOffsetY = 0;
let currentPanel = null;
let dragListenersAttached = false;
/* =========================================================
* 日志系统
* ========================================================= */
const LOG_KEY_PREFIX = 'logs:';
let _logBuffer = [];
let _logFlushTimer = null;
function _logShardKey(dayKey) { return LOG_KEY_PREFIX + dayKey; }
function _readLogShard(dayKey) {
const arr = GM_getValue(_logShardKey(dayKey), []);
return Array.isArray(arr) ? arr : [];
}
function _writeLogShard(dayKey, shard) {
const max = userConfig.maxHistoryLogs || 500;
if (shard.length > max) shard.splice(0, shard.length - max);
GM_setValue(_logShardKey(dayKey), shard);
}
function _cleanOldLogShards() {
if (typeof GM_listValues === 'undefined' || typeof GM_deleteValue === 'undefined') return;
const keep = Math.max(1, userConfig.logRetentionDays || 7);
const cutoff = new Date();
cutoff.setDate(cutoff.getDate() - keep + 1);
const cutoffKey = getLocalTodayString(cutoff);
try {
GM_listValues().forEach(key => {
if (key.startsWith(LOG_KEY_PREFIX)) {
const dayKey = key.slice(LOG_KEY_PREFIX.length);
if (dayKey < cutoffKey) GM_deleteValue(key);
}
});
} catch (e) { /* ignore */ }
}
function addHistoryLog(msg, level = LEVEL.INFO, opts = {}) {
const entry = {
ts: nowMs(),
level,
cat: opts.cat || CAT.SYSTEM,
user: opts.user || null,
msg: String(msg),
ctx: opts.ctx || null
};
_logBuffer.push(entry);
if (_logBuffer.length >= 20) _flushLogBuffer();
else if (!_logFlushTimer) _logFlushTimer = setTimeout(_flushLogBuffer, 500);
_renderLiveLog(entry);
}
function _flushLogBuffer() {
if (_logFlushTimer) { clearTimeout(_logFlushTimer); _logFlushTimer = null; }
if (_logBuffer.length === 0) return;
const dayKey = getLocalTodayString();
const shard = _readLogShard(dayKey);
for (const e of _logBuffer) {
shard.push([e.ts, e.level, e.cat, e.user, e.msg, e.ctx]);
}
_writeLogShard(dayKey, shard);
_logBuffer = [];
}
function _flushLogBufferSync() { _flushLogBuffer(); }
function readLogs(dayKey) {
_flushLogBufferSync();
const shard = _readLogShard(dayKey);
return shard.map(([ts, level, cat, user, msg, ctx]) => ({ ts, level, cat, user, msg, ctx }));
}
function listLogDays() {
_flushLogBufferSync();
if (typeof GM_listValues === 'undefined') return [getLocalTodayString()];
const days = [];
try {
GM_listValues().forEach(k => { if (k.startsWith(LOG_KEY_PREFIX)) days.push(k.slice(LOG_KEY_PREFIX.length)); });
} catch (e) { /* ignore */ }
days.sort().reverse();
return days.length ? days : [getLocalTodayString()];
}
function clearLogs(dayKey) {
if (dayKey) GM_setValue(_logShardKey(dayKey), []);
else {
if (typeof GM_listValues !== 'undefined' && typeof GM_deleteValue !== 'undefined') {
try { GM_listValues().forEach(k => { if (k.startsWith(LOG_KEY_PREFIX)) GM_deleteValue(k); }); } catch (e) { /* ignore */ }
}
}
_logBuffer = [];
}
function _levelIcon(level) {
return level === LEVEL.SUCCESS ? '✅' : level === LEVEL.ERROR ? '❌' : level === LEVEL.WARN ? '⚠️' : 'ℹ️';
}
function _catLabel(cat) {
return { send: '发送', api: 'API', ui: 'UI', state: '状态', system: '系统' }[cat] || cat;
}
function _levelColor(level) {
return level === LEVEL.SUCCESS ? '#00d8b8' : level === LEVEL.ERROR ? '#ff2c54' : level === LEVEL.WARN ? '#ffc107' : '#8fbdff';
}
function _renderLiveLog(entry) {
const container = document.getElementById('dy-fire-log');
if (!container) return;
const line = document.createElement('div');
line.className = 'dyfire-log-line';
line.style.color = _levelColor(entry.level);
const t = new Date(entry.ts);
const timeStr = t.toLocaleTimeString('zh-CN', { hour12: false });
const prefix = `${_levelIcon(entry.level)}[${_catLabel(entry.cat)}]`;
const userPart = entry.user ? `[${entry.user}] ` : '';
line.textContent = `${timeStr} ${prefix} ${userPart}${entry.msg}`;
if (entry.level === LEVEL.ERROR && entry.ctx) {
const btn = document.createElement('span');
btn.textContent = ' 详情';
btn.style.cssText = 'color:#8fbdff;cursor:pointer;text-decoration:underline;margin-left:6px;';
btn.addEventListener('click', () => showLogPanel({ level: LEVEL.ERROR }));
line.appendChild(btn);
}
container.prepend(line);
const max = userConfig.maxLiveLogs || 8;
while (container.children.length > max) container.removeChild(container.lastChild);
}
/* =========================================================
* 存储
* ========================================================= */
function saveConfig() {
GM_setValue('userConfig', userConfig);
GM_setValue('fireDays', userConfig.fireDays);
GM_setValue('lastFireDate', userConfig.lastFireDate);
GM_setValue('specialHitokotoSentIndexes', specialHitokotoSentIndexes);
GM_setValue('retryCount', retryCount);
GM_setValue('isMaxRetryReached', isMaxRetryReached);
GM_setValue('lastRetryResetTime', lastRetryResetTime);
}
function initConfig() {
const saved = GM_getValue('userConfig');
userConfig = Object.assign({}, DEFAULT_CONFIG, saved || {});
if (!GM_getValue('txtApiManualSentIndexes')) GM_setValue('txtApiManualSentIndexes', []);
if (!GM_getValue('fireDays')) GM_setValue('fireDays', userConfig.fireDays);
else userConfig.fireDays = GM_getValue('fireDays');
if (!GM_getValue('lastFireDate')) {
const today = getLocalTodayString();
GM_setValue('lastFireDate', today);
userConfig.lastFireDate = today;
} else userConfig.lastFireDate = GM_getValue('lastFireDate');
if (!GM_getValue('specialHitokotoSentIndexes')) {
GM_setValue('specialHitokotoSentIndexes', specialHitokotoSentIndexes);
} else {
specialHitokotoSentIndexes = GM_getValue('specialHitokotoSentIndexes', specialHitokotoSentIndexes);
}
sentUsersToday = GM_getValue('sentUsersToday', []) || [];
failedUsersToday = GM_getValue('failedUsersToday', []) || [];
currentUserIndex = GM_getValue('currentUserIndex', -1);
retryCount = GM_getValue('retryCount', 0);
isMaxRetryReached = GM_getValue('isMaxRetryReached', false);
lastRetryResetTime = GM_getValue('lastRetryResetTime', 0);
isPaused = GM_getValue('isPaused', false);
alreadyDoneNotified = GM_getValue('alreadyDoneNotified', false);
parseTargetUsers();
GM_setValue('userConfig', userConfig);
return userConfig;
}
function parseTargetUsers() {
if (!userConfig.targetUsernames || !userConfig.targetUsernames.trim()) {
allTargetUsers = [];
userConfig.enableTargetUser = false;
return;
}
allTargetUsers = userConfig.targetUsernames.trim().split('\n').map(s => s.trim()).filter(Boolean);
userConfig.enableTargetUser = allTargetUsers.length > 0;
addHistoryLog(`解析到 ${allTargetUsers.length} 个目标用户`, LEVEL.INFO, { cat: CAT.STATE });
}
/* =========================================================
* 火花天数
* ========================================================= */
function _rawUserFireDaysMap() { return GM_getValue('userFireDays', {}); }
function getUserFireEntry(nickname) {
if (!nickname) return null;
const map = _rawUserFireDaysMap();
const raw = map[nickname];
if (raw === undefined) return null;
if (typeof raw === 'number') return { days: raw, lastDate: '' };
if (raw && typeof raw === 'object') {
return { days: Number(raw.days) || 0, lastDate: raw.lastDate || '' };
}
return null;
}
function setUserFireEntry(nickname, days, lastDate) {
if (!nickname) return;
const map = _rawUserFireDaysMap();
const prev = map[nickname];
let prevDays = 0, prevDate = '';
if (typeof prev === 'number') { prevDays = prev; }
else if (prev && typeof prev === 'object') { prevDays = Number(prev.days) || 0; prevDate = prev.lastDate || ''; }
const newDays = (days !== undefined && days !== null) ? Number(days) : prevDays;
const newDate = (lastDate !== undefined && lastDate !== null) ? lastDate : prevDate;
map[nickname] = { days: newDays, lastDate: newDate };
GM_setValue('userFireDays', map);
}
function getAllFireEntries() {
const map = _rawUserFireDaysMap();
const out = {};
Object.keys(map).forEach(k => {
const v = map[k];
if (typeof v === 'number') out[k] = { days: v, lastDate: '' };
else if (v && typeof v === 'object') out[k] = { days: Number(v.days) || 0, lastDate: v.lastDate || '' };
});
return out;
}
function applyFetchedFireDays(nickname, days) {
if (!nickname || !(days > 0)) return;
if (userConfig.autoFetchFireDays) {
setUserFireEntry(nickname, days, getLocalTodayString());
} else {
const entry = getUserFireEntry(nickname);
if (!entry) {
setUserFireEntry(nickname, days, getLocalTodayString());
} else {
const map = _rawUserFireDaysMap();
map[nickname] = { days: entry.days, lastDate: entry.lastDate || '' };
GM_setValue('userFireDays', map);
}
}
}
function bulkApplyFetchedFireDays(items) {
if (!items || items.length === 0) return;
const map = _rawUserFireDaysMap();
let changed = 0;
const today = getLocalTodayString();
items.forEach(({ nickname, days }) => {
if (!nickname || !(days > 0)) return;
const raw = map[nickname];
if (userConfig.autoFetchFireDays) {
const oldDays = typeof raw === 'number' ? raw : (raw && raw.days) || 0;
if (oldDays !== days) {
map[nickname] = { days, lastDate: today };
changed++;
} else {
map[nickname] = { days, lastDate: today };
}
} else {
if (raw === undefined) {
map[nickname] = { days, lastDate: today };
changed++;
}
}
});
GM_setValue('userFireDays', map);
if (changed > 0) {
addHistoryLog(`已同步 ${changed} 位好友的火花天数`, LEVEL.SUCCESS, { cat: CAT.STATE });
}
}
function bumpUserFireDays(nickname) {
if (!nickname) return;
if (userConfig.autoFetchFireDays) return;
const today = getLocalTodayString();
const entry = getUserFireEntry(nickname);
if (!entry) {
setUserFireEntry(nickname, 1, today);
addHistoryLog(`[${nickname}] 新建火花天数记录: 1`, LEVEL.SUCCESS, { cat: CAT.SYSTEM, user: nickname });
return;
}
if (entry.lastDate === today) return;
const newDays = (entry.days || 0) + 1;
setUserFireEntry(nickname, newDays, today);
addHistoryLog(`[${nickname}] 火花天数 +1 → ${newDays}`, LEVEL.SUCCESS, { cat: CAT.SYSTEM, user: nickname });
}
function getUserFireDays(nickname) {
if (!nickname) return userConfig.fireDays;
const entry = getUserFireEntry(nickname);
if (entry) return entry.days;
return userConfig.fireDays;
}
/* =========================================================
* 时间 / 状态
* ========================================================= */
function isCurrentTimeInRange() {
const [sh, sm] = userConfig.sendTimeRangeStart.split(':').map(Number);
const [eh, em] = userConfig.sendTimeRangeEnd.split(':').map(Number);
const n = new Date();
const nowM = n.getHours() * 60 + n.getMinutes();
const start = sh * 60 + sm, end = eh * 60 + em;
return end > start ? (nowM >= start && nowM <= end) : (nowM >= start || nowM <= end);
}
function isAtOrPastSendTime() {
const [h, m, s] = userConfig.sendTime.split(':').map(Number);
const t = new Date();
t.setHours(h, m, s || 0, 0);
return new Date() >= t;
}
function updateElementStatus(id, status, isSuccess = true) {
const el = document.getElementById(id);
if (!el) return;
el.textContent = status;
el.style.color = isSuccess ? '#00d8b8' : '#ff2c54';
}
function pickFromTextList(lines, sentIndexes, isRandom) {
if (!lines || lines.length === 0) return null;
if (isRandom) {
let avail = [];
for (let i = 0; i < lines.length; i++) if (!sentIndexes.includes(i)) avail.push(i);
if (avail.length === 0) {
sentIndexes.length = 0;
avail = Array.from({ length: lines.length }, (_, i) => i);
}
const idx = avail[Math.floor(Math.random() * avail.length)];
sentIndexes.push(idx);
return { index: idx, text: lines[idx].trim() };
}
let idx = sentIndexes.length > 0 ? (sentIndexes[sentIndexes.length - 1] + 1) % lines.length : 0;
sentIndexes.push(idx);
return { index: idx, text: lines[idx].trim() };
}
/* =========================================================
* 输入 & 发送
* ========================================================= */
const nativeInputValueSetter = Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, 'value').set;
function simulateMouseClick(element) {
if (!element || !(element instanceof HTMLElement)) return false;
const rect = element.getBoundingClientRect();
element.dispatchEvent(new MouseEvent('click', {
bubbles: true,
clientX: rect.left + rect.width / 2,
clientY: rect.top + rect.height / 2
}));
return true;
}
function fireKeyEvent(el, type, key, opts = {}) {
el.dispatchEvent(new KeyboardEvent(type, {
key,
code: key === 'Enter' ? 'Enter' : '',
keyCode: 13,
which: 13,
shiftKey: !!opts.shiftKey,
ctrlKey: !!opts.ctrlKey,
bubbles: true,
cancelable: true,
composed: true
}));
}
function triggerEnterToSend(el) {
if (!el) return;
fireKeyEvent(el, 'keydown', 'Enter');
fireKeyEvent(el, 'keypress', 'Enter');
fireKeyEvent(el, 'keyup', 'Enter');
}
function triggerShiftEnter(el) {
if (!el) return;
fireKeyEvent(el, 'keydown', 'Enter', { shiftKey: true });
fireKeyEvent(el, 'keypress', 'Enter', { shiftKey: true });
fireKeyEvent(el, 'keyup', 'Enter', { shiftKey: true });
}
async function inputMessageToChatEditor(editor, message) {
if (!editor || !(editor instanceof HTMLElement)) return false;
editor.focus();
simulateMouseClick(editor);
await sleep(50);
try {
document.execCommand('selectAll');
document.execCommand('delete');
} catch (e) { /* ignore */ }
if ((editor.textContent || '').trim() !== '') {
editor.innerHTML = ' ';
editor.dispatchEvent(new Event('input', { bubbles: true }));
}
await sleep(80);
const lines = message.split('\n');
for (let i = 0; i < lines.length; i++) {
const line = lines[i];
if (document.activeElement !== editor) editor.focus();
const sel = window.getSelection();
const range = document.createRange();
range.selectNodeContents(editor);
range.collapse(false);
sel.removeAllRanges();
sel.addRange(range);
let inserted = false;
if (line.length > 0) {
try { inserted = document.execCommand('insertText', false, line); }
catch (e) { inserted = false; }
if (!inserted) {
const tn = document.createTextNode(line);
range.insertNode(tn);
range.setStartAfter(tn);
range.setEndAfter(tn);
sel.removeAllRanges();
sel.addRange(range);
editor.dispatchEvent(new InputEvent('input', {
bubbles: true,
cancelable: true,
data: line,
inputType: 'insertText'
}));
}
}
if (i < lines.length - 1) {
await sleep(60);
triggerShiftEnter(editor);
await sleep(60);
}
}
editor.dispatchEvent(new Event('change', { bubbles: true }));
return true;
}
/* =========================================================
* 火花天数读取
* ========================================================= */
function readChatPageFireDays() {
const panelSelectors = [
'.RightPanelHeadertitleContainer',
'[class*="RightPanelHeader"]',
'[class*="RightPanel"] [class*="Header"]'
];
for (const sel of panelSelectors) {
let panel = null;
try { panel = document.querySelector(sel); } catch (e) { /* ignore */ }
if (!panel) continue;
const streakEl = panel.querySelector('.commonStreaknormalText');
if (!streakEl) continue;
const days = parseInt((streakEl.textContent || '').trim()) || 0;
if (days > 0) return days;
}
return 0;
}
/* =========================================================
* 搜索用户
* ========================================================= */
async function findAndClickTargetUserChat(username) {
addHistoryLog(`[Chat] 开始搜索: ${username}`, LEVEL.INFO, { cat: CAT.SEND, user: username });
updateUserStatus(`搜索: ${username}`, null);
try {
const { el: searchInput } = queryFirst(SELECTORS.searchInput);
if (!searchInput) {
addHistoryLog('[Chat] 未找到搜索输入框', LEVEL.ERROR, { cat: CAT.SEND, user: username });
return false;
}
searchInput.focus();
simulateMouseClick(searchInput);
await sleep(100);
const lastVal = searchInput.value;
nativeInputValueSetter.call(searchInput, '');
if (searchInput._valueTracker) searchInput._valueTracker.setValue(lastVal);
searchInput.dispatchEvent(new Event('input', { bubbles: true }));
searchInput.dispatchEvent(new Event('change', { bubbles: true }));
await sleep(150);
nativeInputValueSetter.call(searchInput, username);
if (searchInput._valueTracker) searchInput._valueTracker.setValue('');
searchInput.dispatchEvent(new InputEvent('input', {
bubbles: true,
cancelable: true,
data: username,
inputType: 'insertText'
}));
searchInput.dispatchEvent(new Event('change', { bubbles: true }));
addHistoryLog(`[Chat] 已输入: ${username}`, LEVEL.INFO, { cat: CAT.SEND, user: username });
let chatBtn = null;
let usedSel = '';
const start = nowMs();
while (nowMs() - start < 5000) {
const { els, sel } = queryAllFirst(SELECTORS.chatBtn);
for (const btn of els) {
if (btn.offsetParent !== null &&
(btn.textContent.includes('聊天') || btn.textContent.includes('发消息') || sel.includes('chat_btn'))) {
chatBtn = btn;
usedSel = sel;
break;
}
}
if (chatBtn) break;
await sleep(200);
}
if (!chatBtn) {
addHistoryLog(`[Chat] 未找到 ${username} 的聊天按钮`, LEVEL.WARN, {
cat: CAT.SEND, user: username,
ctx: { step: 'findChatBtn', elapsed: nowMs() - start }
});
const searchItems = document.querySelectorAll('[class*="SearchPanelitem"], [class*="search-result"]');
for (const item of searchItems) {
if (item.textContent.includes(username) && item.offsetParent !== null) {
simulateMouseClick(item);
addHistoryLog('[Chat] 通过点击搜索结果项进入', LEVEL.INFO, { cat: CAT.SEND, user: username });
currentState = STATE.FOUND;
if (searchTimeoutId) { clearTimeout(searchTimeoutId); searchTimeoutId = null; }
await sleep(2000);
waitForPageAndInput();
return true;
}
}
return false;
}
chatBtn.focus();
await sleep(50);
simulateMouseClick(chatBtn);
addHistoryLog(`[Chat] 已点击聊天按钮 (${usedSel})`, LEVEL.SUCCESS, { cat: CAT.SEND, user: username });
currentState = STATE.FOUND;
if (searchTimeoutId) { clearTimeout(searchTimeoutId); searchTimeoutId = null; }
await sleep(2500);
waitForPageAndInput();
return true;
} catch (err) {
addHistoryLog(`[Chat] 搜索失败: ${err.message}`, LEVEL.ERROR, {
cat: CAT.SEND, user: username, ctx: { stack: (err.stack || '').slice(0, 200) }
});
return false;
}
}
async function findAndClickTargetUser() {
if (!userConfig.enableTargetUser || allTargetUsers.length === 0) {
updateUserStatus('配置错误', false);
return false;
}
if (currentState !== STATE.SEARCHING) return false;
searchAttemptCount++;
if (searchAttemptCount > 50) {
addHistoryLog(`查找次数过多(${searchAttemptCount}),标记失败`, LEVEL.ERROR, {
cat: CAT.STATE, user: currentRetryUser
});
searchAttemptCount = 0;
if (currentRetryUser && !failedUsersToday.includes(currentRetryUser)) {
failedUsersToday.push(currentRetryUser);
GM_setValue('failedUsersToday', failedUsersToday);
}
currentRetryUser = null;
isProcessing = false;
currentState = STATE.IDLE;
checkAllUsersProcessed();
return false;
}
let targetUser;
if (userConfig.multiUserRetrySame && retryCount > 1 && currentRetryUser) {
targetUser = currentRetryUser;
} else {
targetUser = getNextTargetUser();
currentRetryUser = targetUser;
}
if (!targetUser) {
updateUserStatus('无目标用户', false);
isProcessing = false;
currentRetryUser = null;
checkAllUsersProcessed();
return false;
}
GM_setValue('lastTargetUser', targetUser);
updateUserStatus(`寻找: ${targetUser}`, null);
return await findAndClickTargetUserChat(targetUser);
}
function getNextTargetUser() {
if (allTargetUsers.length === 0) return null;
const unsent = allTargetUsers.filter(u => !sentUsersToday.includes(u) && !failedUsersToday.includes(u));
if (unsent.length === 0) return null;
if (userConfig.multiUserMode === 'random') return unsent[Math.floor(Math.random() * unsent.length)];
if (currentUserIndex < 0 || currentUserIndex >= allTargetUsers.length) currentUserIndex = 0;
for (let i = 0; i < allTargetUsers.length; i++) {
const idx = (currentUserIndex + i) % allTargetUsers.length;
const u = allTargetUsers[idx];
if (!sentUsersToday.includes(u)) {
currentUserIndex = idx;
return u;
}
}
return null;
}
function markUserAsSent(username) {
if (!sentUsersToday.includes(username)) {
sentUsersToday.push(username);
GM_setValue('sentUsersToday', sentUsersToday);
}
const idx = allTargetUsers.indexOf(username);
if (idx !== -1) {
currentUserIndex = (idx + 1) % allTargetUsers.length;
GM_setValue('currentUserIndex', currentUserIndex);
}
addHistoryLog(`用户 ${username} 已标记为已发送`, LEVEL.SUCCESS, { cat: CAT.SEND, user: username });
updateUserStatusDisplay();
}
/* =========================================================
* 发送流程
* ========================================================= */
function sendMessage() {
if (isPaused) {
addHistoryLog('脚本已暂停,无法发送', LEVEL.WARN, { cat: CAT.STATE });
return;
}
if (isProcessing) return;
if (nowMs() - lastSendCompleteTime < 3000) return;
isMaxRetryReached = false;
GM_setValue('isMaxRetryReached', false);
if (userConfig.enableTargetUser && allTargetUsers.length > 0) {
const unsent = allTargetUsers.filter(u => !sentUsersToday.includes(u) && !failedUsersToday.includes(u));
if (unsent.length === 0) {
addHistoryLog('所有目标用户今日已发送', LEVEL.INFO, { cat: CAT.SEND });
return;
}
} else {
const lastSentDate = GM_getValue('lastSentDate', '');
if (lastSentDate === getLocalTodayString()) {
addHistoryLog('今天已发送过消息', LEVEL.INFO, { cat: CAT.SEND });
return;
}
}
isProcessing = true;
retryCount = 0;
currentState = STATE.IDLE;
searchAttemptCount = 0;
updateRetryCount();
addHistoryLog('开始发送流程...', LEVEL.INFO, { cat: CAT.SEND });
executeSendProcess().catch(err => {
addHistoryLog(`发送流程异常: ${err.message}`, LEVEL.ERROR, {
cat: CAT.SEND, ctx: { stack: (err.stack || '').slice(0, 300) }
});
isProcessing = false;
currentState = STATE.IDLE;
scheduleRetry(5000);
});
}
async function executeSendProcess() {
if (isPaused) {
addHistoryLog('已暂停,中断发送', LEVEL.WARN, { cat: CAT.STATE });
isProcessing = false;
currentState = STATE.IDLE;
return;
}
if (nowMs() - lastSendCompleteTime < 5000 && retryCount > 0) return;
if (isMaxRetryReached && userConfig.retryAfterMaxReached) {
const intervalMs = (userConfig.autoRetryInterval || 10) * 60 * 1000;
if (nowMs() - lastRetryResetTime < intervalMs) {
const remain = Math.ceil((intervalMs - (nowMs() - lastRetryResetTime)) / 60000);
addHistoryLog(`已达最大重试,${remain}分钟后重试`, LEVEL.INFO, { cat: CAT.STATE });
isProcessing = false;
return;
}
retryCount = 0;
isMaxRetryReached = false;
GM_setValue('retryCount', 0);
GM_setValue('isMaxRetryReached', false);
}
retryCount++;
GM_setValue('retryCount', retryCount);
updateRetryCount();
if (retryCount > userConfig.maxRetryCount) {
if (userConfig.enableTargetUser && allTargetUsers.length > 0 && currentRetryUser) {
addHistoryLog(`用户 ${currentRetryUser} 达到最大重试,标记失败`, LEVEL.ERROR, {
cat: CAT.SEND, user: currentRetryUser
});
if (!failedUsersToday.includes(currentRetryUser)) {
failedUsersToday.push(currentRetryUser);
GM_setValue('failedUsersToday', failedUsersToday);
}
retryCount = 0;
GM_setValue('retryCount', 0);
updateRetryCount();
currentRetryUser = null;
isProcessing = false;
currentState = STATE.IDLE;
checkAllUsersProcessed();
return;
}
addHistoryLog(`已达最大重试次数 (${userConfig.maxRetryCount})`, LEVEL.ERROR, { cat: CAT.SEND });
isProcessing = false;
currentState = STATE.IDLE;
currentRetryUser = null;
return;
}
if (retryCount === 1) {
addHistoryLog(`开始发送任务,共 ${allTargetUsers.length} 个用户`, LEVEL.INFO, { cat: CAT.SEND });
} else {
addHistoryLog(`重试 (${retryCount}/${userConfig.maxRetryCount})`, LEVEL.INFO, { cat: CAT.SEND });
}
if (userConfig.enableTargetUser && allTargetUsers.length > 0) {
currentState = STATE.SEARCHING;
if (searchTimeoutId) clearTimeout(searchTimeoutId);
searchTimeoutId = setTimeout(() => {
searchTimeoutId = null;
if (currentState === STATE.SEARCHING) {
addHistoryLog('用户查找超时', LEVEL.ERROR, { cat: CAT.SEND, user: currentRetryUser });
updateUserStatus('查找超时', false);
scheduleRetry(5000);
}
}, userConfig.userSearchTimeout);
const found = await findAndClickTargetUser();
if (found && searchTimeoutId) { clearTimeout(searchTimeoutId); searchTimeoutId = null; }
} else {
setTimeout(tryFindChatInput, 1000);
}
}
function scheduleRetry(delayMs) {
if (autoRetryTimer) clearTimeout(autoRetryTimer);
autoRetryTimer = setTimeout(() => {
autoRetryTimer = null;
if (!isPaused && !isProcessing) {
isProcessing = true;
executeSendProcess().catch(err => {
addHistoryLog(`重试异常: ${err.message}`, LEVEL.ERROR, { cat: CAT.SEND });
isProcessing = false;
});
}
}, delayMs);
}
/* =========================================================
* 输入框查找 & 发送
* ========================================================= */
function waitForPageAndInput() {
addHistoryLog('开始查找聊天输入框', LEVEL.INFO, { cat: CAT.SEND });
tryFindChatInput();
}
async function tryFindChatInput() {
if (chatInputCheckTimer) { clearTimeout(chatInputCheckTimer); chatInputCheckTimer = null; }
const { el: editor } = queryFirst(SELECTORS.chatInput);
if (!editor) {
chatInputNotFoundCount++;
if (chatInputNotFoundCount === 1 || chatInputNotFoundCount % 5 === 0) {
addHistoryLog(`未找到输入框 (${chatInputNotFoundCount})`, LEVEL.INFO, { cat: CAT.SEND });
}
if (chatInputNotFoundCount >= userConfig.maxRetryCount) {
addHistoryLog(`查找输入框超过最大重试`, LEVEL.ERROR, { cat: CAT.SEND });
chatInputNotFoundCount = 0;
scheduleRetry(5000);
return;
}
chatInputCheckTimer = setTimeout(tryFindChatInput, userConfig.chatInputCheckInterval);
return;
}
chatInputNotFoundCount = 0;
addHistoryLog('找到聊天输入框', LEVEL.INFO, { cat: CAT.SEND });
const currentTargetUser = GM_getValue('lastTargetUser', '');
const sparkDays = readChatPageFireDays();
if (sparkDays > 0 && currentTargetUser) {
applyFetchedFireDays(currentTargetUser, sparkDays);
const el = document.getElementById('dy-fire-days');
if (el) el.textContent = sparkDays;
}
let messageToSend;
try {
messageToSend = await getMessageContent(currentTargetUser);
} catch (err) {
addHistoryLog(`消息获取失败: ${err.message}`, LEVEL.ERROR, { cat: CAT.API });
messageToSend = `${userConfig.baseMessage} | 消息获取失败~`;
}
currentState = STATE.SENDING;
const ok = await inputMessageToChatEditor(editor, messageToSend);
if (!ok) {
addHistoryLog('消息输入失败', LEVEL.ERROR, { cat: CAT.SEND });
scheduleRetry(5000);
return;
}
await sleep(500);
addHistoryLog('正在发送消息...', LEVEL.INFO, { cat: CAT.SEND });
triggerEnterToSend(editor);
await sleep(1200);
addHistoryLog('消息发送成功', LEVEL.SUCCESS, { cat: CAT.SEND, user: currentTargetUser });
searchAttemptCount = 0;
const newSparkDays = readChatPageFireDays();
if (newSparkDays > 0 && currentTargetUser) {
applyFetchedFireDays(currentTargetUser, newSparkDays);
} else if (currentTargetUser) {
bumpUserFireDays(currentTargetUser);
}
updateFireDays();
if (userConfig.enableTargetUser && allTargetUsers.length > 0) {
if (currentTargetUser) markUserAsSent(currentTargetUser);
} else {
GM_setValue('lastSentDate', getLocalTodayString());
updateUserStatusDisplay();
}
updateStatus(true);
isProcessing = false;
currentState = STATE.IDLE;
currentRetryUser = null;
retryCount = 0;
isMaxRetryReached = false;
GM_setValue('retryCount', 0);
GM_setValue('isMaxRetryReached', false);
updateRetryCount();
if (userConfig.enableTargetUser && allTargetUsers.length > 0) {
const unsent = allTargetUsers.filter(u => !sentUsersToday.includes(u) && !failedUsersToday.includes(u));
if (unsent.length > 0) {
addHistoryLog(`还有 ${unsent.length} 个用户待发送`, LEVEL.INFO, { cat: CAT.SEND });
const { el: list } = queryFirst(SELECTORS.conversationList);
if (list) { await sleep(300); list.scrollTop = 0; }
lastSendCompleteTime = nowMs();
setTimeout(sendMessage, 3000);
} else {
addHistoryLog('所有用户发送完成!', LEVEL.SUCCESS, { cat: CAT.SEND });
lastSendCompleteTime = nowMs();
checkAllUsersProcessed();
}
} else {
lastSendCompleteTime = nowMs();
checkAllUsersProcessed();
}
if (typeof GM_notification !== 'undefined') {
try { GM_notification({ title: '抖音续火助手', text: '续火消息发送成功!', timeout: 3000 }); }
catch (e) { try { GM_notification('续火消息发送成功!', '抖音续火助手'); } catch (e2) { /* ignore */ } }
}
}
/* =========================================================
* 消息内容组装
* ========================================================= */
async function getMessageContent(username) {
let customMessage = userConfig.customMessage || userConfig.baseMessage;
const parts = [];
let hitokoto = '';
if (userConfig.useHitokoto) {
try { hitokoto = await getHitokoto(); parts.push('一言'); }
catch (err) { addHistoryLog(`一言获取失败: ${err.message}`, LEVEL.ERROR, { cat: CAT.API }); hitokoto = '一言获取失败~'; }
}
let txtApi = '';
if (userConfig.useTxtApi) {
try { txtApi = await getTxtApiContent(); parts.push('TXTAPI'); }
catch (err) { addHistoryLog(`TXTAPI获取失败: ${err.message}`, LEVEL.ERROR, { cat: CAT.API }); txtApi = 'TXTAPI获取失败~'; }
}
let special = '';
if (userConfig.useSpecialHitokoto) {
try { special = await getSpecialHitokoto(); parts.push('专属一言'); }
catch (err) { addHistoryLog(`专属一言获取失败: ${err.message}`, LEVEL.ERROR, { cat: CAT.API }); special = '专属一言获取失败~'; }
}
if (parts.length > 0) addHistoryLog(`消息内容准备完成 [${parts.join(' + ')}]`, LEVEL.SUCCESS, { cat: CAT.API });
if (customMessage.includes('[API]')) customMessage = customMessage.replace(/\[API\]/g, hitokoto);
else if (userConfig.useHitokoto) customMessage += ` | ${hitokoto}`;
if (customMessage.includes('[TXTAPI]')) customMessage = customMessage.replace(/\[TXTAPI\]/g, txtApi);
else if (userConfig.useTxtApi) customMessage += ` | ${txtApi}`;
if (customMessage.includes('[专属一言]')) customMessage = customMessage.replace(/\[专属一言\]/g, special);
else if (userConfig.useSpecialHitokoto) customMessage += ` | ${special}`;
if (customMessage.includes('[天数]')) {
const days = username ? getUserFireDays(username) : userConfig.fireDays;
customMessage = customMessage.replace(/\[天数\]/g, days || 1);
}
return customMessage;
}
function getHitokoto() {
return new Promise((resolve, reject) => {
const timer = setTimeout(() => reject(new Error('一言API请求超时')), userConfig.hitokotoTimeout);
GM_xmlhttpRequest({
method: 'GET',
url: 'https://v1.hitokoto.cn/',
responseType: 'json',
onload: (res) => {
clearTimeout(timer);
if (res.status === 200) {
try {
const data = res.response;
updateElementStatus('dy-fire-hitokoto', '获取成功');
resolve(formatHitokoto(userConfig.hitokotoFormat, data));
} catch (e) {
updateElementStatus('dy-fire-hitokoto', '解析失败', false);
reject(new Error('一言解析失败'));
}
} else {
updateElementStatus('dy-fire-hitokoto', '请求失败', false);
reject(new Error(`一言请求失败: ${res.status}`));
}
},
onerror: () => { clearTimeout(timer); updateElementStatus('dy-fire-hitokoto', '网络错误', false); reject(new Error('一言网络错误')); },
ontimeout: () => { clearTimeout(timer); updateElementStatus('dy-fire-hitokoto', '请求超时', false); reject(new Error('一言请求超时')); }
});
});
}
function formatHitokoto(format, data) {
let result = format.replace(/{hitokoto}/g, data.hitokoto || '');
const fromFormatted = data.from ? userConfig.fromFormat.replace(/{from}/g, data.from) : '';
result = result.replace(/{from}/g, fromFormatted);
const fromWhoFormatted = data.from_who ? userConfig.fromWhoFormat.replace(/{from_who}/g, data.from_who) : '';
result = result.replace(/{from_who}/g, fromWhoFormatted);
return result;
}
function getSpecialHitokoto() {
try {
const d = new Date();
const dow = d.getDay();
const keys = ['sunday', 'monday', 'tuesday', 'wednesday', 'thursday', 'friday', 'saturday'];
const key = keys[dow];
const name = ['周日', '周一', '周二', '周三', '周四', '周五', '周六'][dow];
const cap = key.charAt(0).toUpperCase() + key.slice(1);
const text = userConfig[`specialHitokoto${cap}`] || '';
const lines = text.split('\n').filter(l => l.trim());
if (lines.length === 0) {
updateElementStatus('dy-fire-special-hitokoto', `${name}无内容`, false);
return Promise.resolve(`${name}暂无专属一言`);
}
if (!specialHitokotoSentIndexes[key]) specialHitokotoSentIndexes[key] = [];
const picked = pickFromTextList(lines, specialHitokotoSentIndexes[key], userConfig.specialHitokotoRandom);
GM_setValue('specialHitokotoSentIndexes', specialHitokotoSentIndexes);
updateElementStatus('dy-fire-special-hitokoto', `${name}获取成功`);
return Promise.resolve(`${name}专属: ${picked.text}`);
} catch (err) {
updateElementStatus('dy-fire-special-hitokoto', '获取失败', false);
return Promise.reject(err);
}
}
function getTxtApiContent() {
return new Promise((resolve, reject) => {
if (userConfig.txtApiMode === 'api') {
const timer = setTimeout(() => reject(new Error('TXTAPI请求超时')), userConfig.txtApiTimeout);
GM_xmlhttpRequest({
method: 'GET',
url: userConfig.txtApiUrl,
onload: (res) => {
clearTimeout(timer);
if (res.status === 200) {
updateElementStatus('dy-fire-txtapi', '获取成功');
resolve((res.responseText || '').trim());
} else {
updateElementStatus('dy-fire-txtapi', '请求失败', false);
reject(new Error(`TXTAPI请求失败: ${res.status}`));
}
},
onerror: () => { clearTimeout(timer); updateElementStatus('dy-fire-txtapi', '网络错误', false); reject(new Error('TXTAPI网络错误')); },
ontimeout: () => { clearTimeout(timer); updateElementStatus('dy-fire-txtapi', '请求超时', false); reject(new Error('TXTAPI请求超时')); }
});
} else {
try {
const lines = userConfig.txtApiManualText.split('\n').filter(l => l.trim());
if (lines.length === 0) {
updateElementStatus('dy-fire-txtapi', '无内容', false);
return reject(new Error('手动文本为空'));
}
let sent = GM_getValue('txtApiManualSentIndexes', []);
const picked = pickFromTextList(lines, sent, userConfig.txtApiManualRandom);
GM_setValue('txtApiManualSentIndexes', sent);
updateElementStatus('dy-fire-txtapi', '获取成功');
resolve(picked.text);
} catch (err) {
updateElementStatus('dy-fire-txtapi', '解析失败', false);
reject(err);
}
}
});
}
/* =========================================================
* 时间 / 状态刷新
* ========================================================= */
function parseTimeString(timeStr) {
const [h, m, s] = timeStr.split(':').map(Number);
const t = new Date();
t.setHours(h, m, s || 0, 0);
if (t <= new Date()) t.setDate(t.getDate() + 1);
return t;
}
function parseRandomTimeString() {
if (!userConfig.sendTimeRandom) return parseTimeString(userConfig.sendTime);
const [sh, sm, ss] = userConfig.sendTimeRangeStart.split(':').map(Number);
const [eh, em] = userConfig.sendTimeRangeEnd.split(':').map(Number);
const startM = sh * 60 + sm;
const endM = eh * 60 + em;
let rnd;
if (endM > startM) rnd = startM + Math.floor(Math.random() * (endM - startM));
else rnd = startM + Math.floor(Math.random() * (1440 - startM + endM));
const h = Math.floor(rnd / 60) % 24;
const m = rnd % 60;
const t = new Date();
t.setHours(h, m, ss || 0, 0);
if (t <= new Date()) t.setDate(t.getDate() + 1);
return t;
}
function updateStatus(status) {
const statusEl = document.getElementById('dy-fire-status');
if (statusEl) {
if (status === true) { statusEl.textContent = '已发送'; statusEl.style.color = '#00d8b8'; }
else if (status === false) { statusEl.textContent = '未发送'; statusEl.style.color = '#dc3545'; }
else if (status === 'sending') { statusEl.textContent = '发送中'; statusEl.style.color = '#ffc107'; }
}
const now = new Date();
if (status === true) {
nextSendTime = parseRandomTimeString();
const tomorrow = new Date(now);
tomorrow.setDate(tomorrow.getDate() + 1);
if (nextSendTime.getDate() !== tomorrow.getDate()) nextSendTime.setDate(tomorrow.getDate());
} else if (status === false) {
nextSendTime = parseRandomTimeString();
if (nextSendTime <= now) nextSendTime.setDate(nextSendTime.getDate() + 1);
}
const nextEl = document.getElementById('dy-fire-next');
if (nextEl) nextEl.textContent = nextSendTime.toLocaleString();
if (status !== 'sending') startCountdown(nextSendTime);
}
function updateRetryCount() {
const el = document.getElementById('dy-fire-retry');
if (el) el.textContent = `${retryCount}/${userConfig.maxRetryCount}`;
}
function updateFireDaysStatus() {
const el = document.getElementById('dy-fire-days');
if (el) { el.textContent = userConfig.fireDays; el.style.color = '#00d8b8'; }
}
function updateFireDays() {
const today = getLocalTodayString();
const last = userConfig.lastFireDate || '';
if (last !== today) {
userConfig.fireDays++;
userConfig.lastFireDate = today;
GM_setValue('fireDays', userConfig.fireDays);
GM_setValue('lastFireDate', today);
addHistoryLog(`新的一天,全局火花天数 = ${userConfig.fireDays}`, LEVEL.SUCCESS, { cat: CAT.SYSTEM });
updateFireDaysStatus();
}
}
function checkIfShouldResetForNewDay() {
return GM_getValue('lastResetDate', '') !== getLocalTodayString();
}
function resetTodaySentUsers() {
sentUsersToday = [];
failedUsersToday = [];
GM_setValue('sentUsersToday', []);
GM_setValue('failedUsersToday', []);
currentUserIndex = -1;
GM_setValue('currentUserIndex', -1);
GM_setValue('lastSentDate', '');
currentRetryUser = null;
alreadyDoneNotified = false;
GM_setValue('alreadyDoneNotified', false);
GM_setValue('lastResetDate', getLocalTodayString());
searchAttemptCount = 0;
addHistoryLog('今日发送记录已重置', LEVEL.INFO, { cat: CAT.STATE });
updateUserStatusDisplay();
}
function updateUserStatusDisplay() {
const statusEl = document.getElementById('dy-fire-user-status');
const progressEl = document.getElementById('dy-fire-user-progress');
if (!statusEl || !progressEl) return;
if (!userConfig.enableTargetUser || allTargetUsers.length === 0) {
const isSent = GM_getValue('lastSentDate', '') === getLocalTodayString();
progressEl.textContent = isSent ? '1/1' : '0/1';
statusEl.textContent = isSent ? '已完成' : '未开始';
statusEl.style.color = isSent ? '#00d8b8' : '#999';
return;
}
const sent = sentUsersToday.length;
const total = allTargetUsers.length;
progressEl.textContent = `${sent}/${total}`;
if (sent >= total) { statusEl.textContent = '全部完成'; statusEl.style.color = '#00d8b8'; }
else { statusEl.textContent = `进行中 ${sent}/${total}`; statusEl.style.color = '#ff2c54'; }
}
function updateUserStatus(text, isSuccess = null) {
const statusEl = document.getElementById('dy-fire-user-status');
if (!statusEl) return;
if (text) statusEl.textContent = text;
if (isSuccess === true) statusEl.style.color = '#00d8b8';
else if (isSuccess === false) statusEl.style.color = '#ff2c54';
else statusEl.style.color = '#999';
}
/* =========================================================
* 自动发送 + 精确调度
* ========================================================= */
function autoSendIfNeeded() {
if (isPaused || isProcessing) return;
if (nowMs() - lastSendCompleteTime < 3000) return;
const today = getLocalTodayString();
if (userConfig.enableTargetUser && allTargetUsers.length > 0) {
if (checkIfShouldResetForNewDay()) {
addHistoryLog('新的一天,重置今日记录', LEVEL.INFO, { cat: CAT.STATE });
resetTodaySentUsers();
} else if (alreadyDoneNotified) return;
const unsent = allTargetUsers.filter(u => !sentUsersToday.includes(u) && !failedUsersToday.includes(u));
if (unsent.length > 0) {
const should = userConfig.sendTimeRandom ? isCurrentTimeInRange() : isAtOrPastSendTime();
if (should) {
addHistoryLog(`自动触发:${unsent.length} 个用户未发送`, LEVEL.INFO, { cat: CAT.SEND });
sendMessage();
}
}
} else {
const lastSentDate = GM_getValue('lastSentDate', '');
if (lastSentDate !== today) {
if (alreadyDoneNotified) { alreadyDoneNotified = false; GM_setValue('alreadyDoneNotified', false); }
const should = userConfig.sendTimeRandom ? isCurrentTimeInRange() : isAtOrPastSendTime();
if (should) {
addHistoryLog('自动触发:今日未发送', LEVEL.INFO, { cat: CAT.SEND });
sendMessage();
}
} else {
if (!alreadyDoneNotified) {
alreadyDoneNotified = true;
GM_setValue('alreadyDoneNotified', true);
addHistoryLog('今日已发送完毕', LEVEL.INFO, { cat: CAT.STATE });
}
}
}
}
function _scheduleNextAutoCheck() {
if (_autoSendTimer) { clearTimeout(_autoSendTimer); _autoSendTimer = null; }
if (isPaused) return;
if (alreadyDoneNotified) return;
let delay = 60000;
if (nextSendTime) {
const diff = nextSendTime.getTime() - nowMs();
if (diff > 0 && diff < 60000) delay = diff + 100;
else if (diff <= 0) delay = 1000;
}
_autoSendTimer = setTimeout(() => {
_autoSendTimer = null;
autoSendIfNeeded();
_scheduleNextAutoCheck();
}, delay);
}
function startCountdown(targetTime) {
if (countdownInterval) clearInterval(countdownInterval);
function update() {
const diff = targetTime - new Date();
if (diff <= 0) {
const el = document.getElementById('dy-fire-countdown');
if (el) el.textContent = '00:00:00';
updateStatus('sending');
if (userConfig.enableTargetUser && allTargetUsers.length > 0) {
if (checkIfShouldResetForNewDay()) resetTodaySentUsers();
const unsent = allTargetUsers.filter(u => !sentUsersToday.includes(u) && !failedUsersToday.includes(u));
if (unsent.length > 0) { if (!isProcessing) sendMessage(); }
else {
GM_setValue('lastResetDate', getLocalTodayString());
nextSendTime = parseRandomTimeString();
const t = new Date();
t.setDate(t.getDate() + 1);
if (nextSendTime.getDate() !== t.getDate()) nextSendTime.setDate(t.getDate());
startCountdown(nextSendTime);
updateStatus(true);
}
} else {
const lastSentDate = GM_getValue('lastSentDate', '');
if (lastSentDate === getLocalTodayString()) {
nextSendTime = parseRandomTimeString();
const t = new Date();
t.setDate(t.getDate() + 1);
if (nextSendTime.getDate() !== t.getDate()) nextSendTime.setDate(t.getDate());
startCountdown(nextSendTime);
updateStatus(true);
} else {
if (!isProcessing) {
GM_setValue('lastSentDate', '');
updateStatus(false);
sendMessage();
}
}
}
return;
}
const h = Math.floor(diff / 3600000);
const m = Math.floor((diff % 3600000) / 60000);
const s = Math.floor((diff % 60000) / 1000);
const el = document.getElementById('dy-fire-countdown');
if (el) el.textContent = `${String(h).padStart(2, '0')}:${String(m).padStart(2, '0')}:${String(s).padStart(2, '0')}`;
}
update();
countdownInterval = setInterval(update, 1000);
}
/* =========================================================
* 暂停 / 恢复
* ========================================================= */
function togglePause() {
isPaused = !isPaused;
GM_setValue('isPaused', isPaused);
if (isPaused) {
addHistoryLog('脚本已暂停', LEVEL.WARN, { cat: CAT.STATE });
if (chatInputCheckTimer) { clearTimeout(chatInputCheckTimer); chatInputCheckTimer = null; }
if (searchTimeoutId) { clearTimeout(searchTimeoutId); searchTimeoutId = null; }
if (autoRetryTimer) { clearTimeout(autoRetryTimer); autoRetryTimer = null; }
if (_autoSendTimer) { clearTimeout(_autoSendTimer); _autoSendTimer = null; }
if (countdownInterval) { clearInterval(countdownInterval); countdownInterval = null; }
if (isProcessing) {
isProcessing = false;
currentState = STATE.IDLE;
currentRetryUser = null;
}
} else {
addHistoryLog('脚本已恢复', LEVEL.SUCCESS, { cat: CAT.STATE });
alreadyDoneNotified = false;
_scheduleNextAutoCheck();
if (nextSendTime) startCountdown(nextSendTime);
}
updatePauseButton();
updatePauseStatusDisplay();
}
function updatePauseButton() {
const btn = document.getElementById('dy-fire-pause');
if (!btn) return;
if (isPaused) {
btn.innerHTML = '▶️ 继续';
btn.style.background = 'linear-gradient(135deg,#00d8b8 0%,#00b8a8 100%)';
} else {
btn.innerHTML = '⏸️ 暂停';
btn.style.background = 'linear-gradient(135deg,#ff9500 0%,#ffcc00 100%)';
}
}
function updatePauseStatusDisplay() {
const el = document.getElementById('dy-fire-pause-status');
if (el) el.style.display = isPaused ? 'block' : 'none';
}
/* =========================================================
* 完成检查 / 后端回调
* ========================================================= */
function checkAllUsersProcessed() {
if (!userConfig.enableTargetUser || allTargetUsers.length === 0) {
const lastSentDate = GM_getValue('lastSentDate', '');
if (lastSentDate === getLocalTodayString() && !alreadyDoneNotified) {
alreadyDoneNotified = true;
GM_setValue('alreadyDoneNotified', true);
addHistoryLog('单用户今日发送完成', LEVEL.SUCCESS, { cat: CAT.SEND });
notifyScriptB({ mode: 'single', status: 'success' });
}
return;
}
const done = new Set([...sentUsersToday, ...failedUsersToday]);
const allDone = allTargetUsers.every(u => done.has(u));
if (!allDone) {
if (!isProcessing) {
isProcessing = true;
setTimeout(executeSendProcess, 500);
}
return;
}
const ok = sentUsersToday.length;
const fail = failedUsersToday.length;
if (!alreadyDoneNotified) {
alreadyDoneNotified = true;
GM_setValue('alreadyDoneNotified', true);
}
if (fail === 0) {
addHistoryLog('所有用户发送成功!', LEVEL.SUCCESS, { cat: CAT.SEND });
notifyScriptB({ mode: 'multi', status: 'success', sentCount: ok, failCount: 0, failedUsers: [] });
} else if (ok === 0) {
addHistoryLog(`所有用户发送失败:${fail} 个`, LEVEL.ERROR, { cat: CAT.SEND });
notifyScriptB({ mode: 'multi', status: 'all_failed', sentCount: 0, failCount: fail, failedUsers: failedUsersToday });
} else {
addHistoryLog(`完成:成功 ${ok},失败 ${fail}`, LEVEL.WARN, { cat: CAT.SEND });
notifyScriptB({ mode: 'multi', status: 'partial', sentCount: ok, failCount: fail, failedUsers: failedUsersToday });
}
}
function notifyScriptB(payload) {
if (!userConfig.enableScriptBCallback) return;
const port = userConfig.scriptBCallbackPort || 7788;
const retryMs = (userConfig.backendRetryMinutes || 25) * 60 * 1000;
const onUnavailable = () => {
addHistoryLog(`后端不可达,${userConfig.backendRetryMinutes || 25} 分钟后重试`, LEVEL.WARN, { cat: CAT.API });
setTimeout(() => notifyScriptB(payload), retryMs);
};
GM_xmlhttpRequest({
method: 'POST',
url: `http://localhost:${port}/done`,
headers: { 'Content-Type': 'application/json' },
data: JSON.stringify(Object.assign({ timestamp: nowMs() }, payload)),
timeout: 5000,
onerror: () => { addHistoryLog('通知后端失败', LEVEL.ERROR, { cat: CAT.API }); onUnavailable(); },
ontimeout: () => { addHistoryLog('通知后端超时', LEVEL.ERROR, { cat: CAT.API }); onUnavailable(); },
onload: (res) => { addHistoryLog(`已通知后端,响应: ${res.status}`, LEVEL.INFO, { cat: CAT.API }); }
});
}
/* =========================================================
* 数据清理
* ========================================================= */
function clearData() {
GM_setValue('lastSentDate', '');
GM_setValue('txtApiManualSentIndexes', []);
GM_setValue('lastTargetUser', '');
specialHitokotoSentIndexes = { monday: [], tuesday: [], wednesday: [], thursday: [], friday: [], saturday: [], sunday: [] };
GM_setValue('specialHitokotoSentIndexes', specialHitokotoSentIndexes);
failedUsersToday = [];
GM_setValue('failedUsersToday', []);
resetTodaySentUsers();
currentRetryUser = null;
retryCount = 0;
isMaxRetryReached = false;
lastRetryResetTime = 0;
GM_setValue('retryCount', 0);
GM_setValue('isMaxRetryReached', false);
GM_setValue('lastRetryResetTime', 0);
addHistoryLog('发送记录已清空', LEVEL.INFO, { cat: CAT.STATE });
updateStatus(false);
updateRetryCount();
updateElementStatus('dy-fire-hitokoto', '未获取');
updateElementStatus('dy-fire-txtapi', '未获取');
updateElementStatus('dy-fire-special-hitokoto', '未获取');
updateUserStatusDisplay();
if (chatInputCheckTimer) { clearTimeout(chatInputCheckTimer); chatInputCheckTimer = null; }
if (autoRetryTimer) { clearTimeout(autoRetryTimer); autoRetryTimer = null; }
if (searchTimeoutId) { clearTimeout(searchTimeoutId); searchTimeoutId = null; }
}
function resetAllConfig() {
if (typeof GM_listValues !== 'undefined' && typeof GM_deleteValue !== 'undefined') {
try { GM_listValues().forEach(k => GM_deleteValue(k)); } catch (e) { /* ignore */ }
}
initConfig();
isPaused = false;
GM_setValue('isPaused', false);
currentRetryUser = null;
addHistoryLog('所有配置已重置', LEVEL.INFO, { cat: CAT.STATE });
updateStatus(false);
retryCount = 0;
updateRetryCount();
updateElementStatus('dy-fire-hitokoto', '未获取');
updateElementStatus('dy-fire-txtapi', '未获取');
updateElementStatus('dy-fire-special-hitokoto', '未获取');
updateFireDaysStatus();
updateUserStatusDisplay();
if (chatInputCheckTimer) { clearTimeout(chatInputCheckTimer); chatInputCheckTimer = null; }
if (autoRetryTimer) { clearTimeout(autoRetryTimer); autoRetryTimer = null; }
}
/* =========================================================
* 用户选择面板
* ========================================================= */
function autoScrollChatListAndCollect(panelEl, onNewItems, onDone) {
const NO_MORE_SEL = SELECTORS.noMore.join(',');
const ITEM_SEL = SELECTORS.conversationItem.join(',');
const TITLE_SEL = SELECTORS.conversationTitle.join(',');
const seen = new Set();
const collectFromRoot = (root) => {
const fresh = [];
if (!root || !root.querySelectorAll) return fresh;
let items = [];
try { items = root.querySelectorAll(ITEM_SEL); } catch (e) { return fresh; }
items.forEach(item => {
const titleEl = item.querySelector(TITLE_SEL);
if (!titleEl) return;
const nickname = (titleEl.textContent || '').trim();
if (!nickname) return;
if (seen.has(nickname)) return;
seen.add(nickname);
let days = 0;
const streakEl = item.querySelector('.commonStreaknormalText');
if (streakEl) {
days = parseInt((streakEl.textContent || '').trim()) || 0;
}
fresh.push({ nickname, days });
});
return fresh;
};
let container = null;
const sampleItem = document.querySelector(ITEM_SEL);
if (sampleItem) {
let el = sampleItem.parentElement;
while (el && el !== document.body) {
const s = window.getComputedStyle(el);
if ((s.overflowY === 'auto' || s.overflowY === 'scroll') && el.scrollHeight > el.clientHeight + 10) {
container = el;
break;
}
el = el.parentElement;
}
}
const initial = collectFromRoot(document);
if (initial.length) onNewItems(initial);
if (!container) { onDone(); return; }
const origScrollTop = container.scrollTop;
let lastScrollTop = -1;
let stuck = 0;
const observer = new MutationObserver(() => {
const fresh = collectFromRoot(container);
if (fresh.length) onNewItems(fresh);
});
observer.observe(container, { childList: true, subtree: true });
const step = () => {
if (!document.body.contains(panelEl)) {
observer.disconnect();
container.scrollTop = origScrollTop;
return;
}
if (document.querySelector(NO_MORE_SEL)) {
observer.disconnect();
container.scrollTop = origScrollTop;
onDone();
return;
}
container.scrollTop += 500;
if (container.scrollTop === lastScrollTop) {
if (++stuck >= 4) {
observer.disconnect();
container.scrollTop = origScrollTop;
onDone();
return;
}
} else stuck = 0;
lastScrollTop = container.scrollTop;
setTimeout(step, 350);
};
setTimeout(step, 100);
}
function showUserSelectPanel() {
const existing = document.getElementById('dy-fire-user-select-panel');
if (existing) { existing.remove(); return; }
let currentTargets = [];
if (userConfig.targetUsernames && userConfig.targetUsernames.trim()) {
currentTargets = userConfig.targetUsernames.split('\n').map(u => u.trim()).filter(Boolean);
}
const panel = document.createElement('div');
panel.id = 'dy-fire-user-select-panel';
panel.className = 'dyfire-modal';
panel.innerHTML = `
全选
⏳ 滚动加载中…
取消全选
`;
document.body.appendChild(panel);
addDragFunctionality(panel, 'dy-fire-user-select-header');
const countEl = document.getElementById('dy-fire-user-select-count');
const badgeEl = document.getElementById('dy-fire-user-select-loading-badge');
const listEl = document.getElementById('dy-fire-user-list-container');
let total = 0;
const appendRows = (items) => {
const frag = document.createDocumentFragment();
items.forEach(({ nickname, days }) => {
const checked = currentTargets.includes(nickname);
const safeNickname = escapeHtml(nickname);
const daysBadge = days > 0
? `🔥 ${days}天 `
: '';
const row = document.createElement('div');
row.className = 'dyfire-user-row';
row.innerHTML = `
${safeNickname} ${daysBadge}
`;
frag.appendChild(row);
total++;
});
listEl.appendChild(frag);
if (countEl) countEl.textContent = `(${total})`;
bulkApplyFetchedFireDays(items);
};
const onDone = () => {
if (badgeEl) badgeEl.remove();
if (countEl) countEl.textContent = `(${total})`;
};
document.getElementById('dy-fire-user-select-close').addEventListener('click', () => panel.remove());
document.getElementById('dy-fire-select-all').addEventListener('click', () => panel.querySelectorAll('.user-checkbox').forEach(cb => cb.checked = true));
document.getElementById('dy-fire-deselect-all').addEventListener('click', () => panel.querySelectorAll('.user-checkbox').forEach(cb => cb.checked = false));
document.getElementById('dy-fire-user-select-cancel').addEventListener('click', () => panel.remove());
document.getElementById('dy-fire-user-select-add').addEventListener('click', () => {
const selected = [];
panel.querySelectorAll('.user-checkbox').forEach(cb => { if (cb.checked) selected.push(cb.value); });
userConfig.targetUsernames = selected.join('\n');
saveConfig();
parseTargetUsers();
updateUserStatusDisplay();
addHistoryLog(selected.length > 0 ? `已更新 ${selected.length} 个目标用户` : '已清空目标用户列表',
selected.length > 0 ? LEVEL.SUCCESS : LEVEL.INFO, { cat: CAT.UI });
panel.remove();
});
autoScrollChatListAndCollect(panel, appendRows, onDone);
}
/* =========================================================
* 火花天数管理面板
* ========================================================= */
function showFireDaysPanel() {
const existing = document.getElementById('dy-fire-days-panel');
if (existing) { existing.remove(); return; }
const panel = document.createElement('div');
panel.id = 'dy-fire-days-panel';
panel.className = 'dyfire-modal dyfire-modal-wide';
panel.innerHTML = `
🔄 扫描好友列表
🗑️ 清空全部
`;
document.body.appendChild(panel);
addDragFunctionality(panel, 'dy-fire-days-header');
const autoFetchEl = document.getElementById('dy-fire-days-auto-fetch');
autoFetchEl.checked = !!userConfig.autoFetchFireDays;
autoFetchEl.addEventListener('change', () => {
userConfig.autoFetchFireDays = autoFetchEl.checked;
saveConfig();
addHistoryLog(`自动获取火花天数已${autoFetchEl.checked ? '开启' : '关闭'}`, LEVEL.INFO, { cat: CAT.UI });
render();
});
const searchEl = document.getElementById('dy-fire-days-search');
const render = () => {
const entries = getAllFireEntries();
const keyword = (searchEl.value || '').trim();
const filtered = Object.keys(entries)
.filter(k => !keyword || k.includes(keyword))
.sort((a, b) => (entries[b].days || 0) - (entries[a].days || 0));
document.getElementById('dy-fire-days-count').textContent = `(共 ${Object.keys(entries).length} 人)`;
document.getElementById('dy-fire-days-info').textContent =
`显示 ${filtered.length} 人` + (keyword ? `,搜索 "${keyword}"` : '') +
` | 自动获取: ${userConfig.autoFetchFireDays ? '开' : '关'}`;
const body = document.getElementById('dy-fire-days-body');
body.innerHTML = '';
if (filtered.length === 0) {
body.innerHTML = `
📅
暂无火花天数记录
点击上方「扫描好友列表」自动采集
`;
return;
}
const frag = document.createDocumentFragment();
filtered.forEach(nickname => {
const entry = entries[nickname];
const row = document.createElement('div');
row.className = 'dyfire-user-row';
row.style.cssText = 'display:flex;align-items:center;gap:10px;';
const lastDateText = entry.lastDate ? `最近: ${entry.lastDate}` : '未记录更新日期';
row.innerHTML = `
${escapeHtml(nickname)}
${lastDateText}
🔥 ${entry.days}
修改
`;
frag.appendChild(row);
});
body.appendChild(frag);
body.querySelectorAll('.dyfire-days-edit').forEach(btn => {
btn.addEventListener('click', () => {
const name = btn.dataset.name;
const cur = parseInt(btn.dataset.days, 10) || 0;
const v = prompt(`修改 "${name}" 的火花天数:`, cur);
if (v === null) return;
const n = parseInt(v, 10);
if (!isNaN(n) && n >= 0) {
setUserFireEntry(name, n, getLocalTodayString());
addHistoryLog(`[${name}] 火花天数修改为 ${n}`, LEVEL.SUCCESS, { cat: CAT.UI, user: name });
render();
}
});
});
};
searchEl.addEventListener('input', render);
document.getElementById('dy-fire-days-close').addEventListener('click', () => panel.remove());
document.getElementById('dy-fire-days-clear').addEventListener('click', () => {
if (!confirm('确定清空所有火花天数记录?此操作不可恢复。')) return;
GM_setValue('userFireDays', {});
addHistoryLog('已清空所有火花天数记录', LEVEL.SUCCESS, { cat: CAT.UI });
render();
});
document.getElementById('dy-fire-days-scan').addEventListener('click', () => {
addHistoryLog('开始扫描好友列表并采集火花天数...', LEVEL.INFO, { cat: CAT.STATE });
const scanBadge = document.createElement('div');
scanBadge.textContent = '⏳ 扫描中...';
scanBadge.style.cssText = 'position:absolute;top:50%;left:50%;transform:translate(-50%,-50%);background:rgba(0,0,0,.85);color:#fff;padding:16px 24px;border-radius:12px;font-size:14px;z-index:10001;';
panel.appendChild(scanBadge);
let scanned = 0;
let synced = 0;
autoScrollChatListAndCollect(panel,
(items) => {
scanned += items.length;
bulkApplyFetchedFireDays(items);
synced += items.filter(x => x.days > 0).length;
},
() => {
scanBadge.remove();
addHistoryLog(
`扫描完成:共 ${scanned} 个会话,其中 ${synced} 个有火花天数` +
(userConfig.autoFetchFireDays ? '(已写入)' : '(自动获取已关闭,仅建档)'),
LEVEL.SUCCESS,
{ cat: CAT.STATE }
);
render();
}
);
});
render();
}
/* =========================================================
* 历史日志面板
* ========================================================= */
let _logPanelState = { level: 'all', cat: 'all', user: 'all', day: '', keyword: '', limit: 100 };
function showLogPanel(initialFilter) {
const existing = document.getElementById('dy-fire-log-panel');
if (existing) existing.remove();
if (initialFilter) Object.assign(_logPanelState, initialFilter);
if (!_logPanelState.day) _logPanelState.day = getLocalTodayString();
const days = listLogDays();
const panel = document.createElement('div');
panel.id = 'dy-fire-log-panel';
panel.className = 'dyfire-modal dyfire-modal-wide';
panel.innerHTML = `
级别
全部
info
success
warn
error
分类
全部
发送
API
UI
状态
系统
日期
${days.map(d => `${d} `).join('')}
条数
50
100
200
500
全部
加载更多
`;
document.body.appendChild(panel);
addDragFunctionality(panel, 'dy-fire-log-header');
document.getElementById('dy-fire-log-filter-level').value = _logPanelState.level;
document.getElementById('dy-fire-log-filter-cat').value = _logPanelState.cat;
document.getElementById('dy-fire-log-filter-day').value = _logPanelState.day;
document.getElementById('dy-fire-log-filter-limit').value = String(_logPanelState.limit);
const render = () => {
const all = readLogs(_logPanelState.day);
const users = Array.from(new Set(all.map(l => l.user).filter(Boolean))).sort();
const filtered = all.filter(l => {
if (_logPanelState.level !== 'all' && l.level !== _logPanelState.level) return false;
if (_logPanelState.cat !== 'all' && l.cat !== _logPanelState.cat) return false;
if (_logPanelState.user !== 'all' && l.user !== _logPanelState.user) return false;
if (_logPanelState.keyword && !l.msg.includes(_logPanelState.keyword)) return false;
return true;
});
filtered.sort((a, b) => b.ts - a.ts);
const limit = _logPanelState.limit || filtered.length;
const slice = filtered.slice(0, limit);
const info = document.getElementById('dy-fire-log-info');
info.textContent = `共 ${filtered.length} 条,显示 ${slice.length} 条` + (users.length ? ` | 用户: ${users.join(', ')}` : '');
const body = document.getElementById('dy-fire-log-body');
body.innerHTML = '';
if (slice.length === 0) {
body.innerHTML = ``;
return;
}
const frag = document.createDocumentFragment();
slice.forEach(l => {
const row = document.createElement('div');
row.className = 'dyfire-log-row';
const t = new Date(l.ts).toLocaleString('zh-CN', { hour12: false });
const color = _levelColor(l.level);
row.innerHTML = `
${t}
${l.level.toUpperCase()} · ${_catLabel(l.cat)}
${l.user ? `[${escapeHtml(l.user)}] ` : ''}${escapeHtml(l.msg)}
${l.ctx ? `上下文 ${escapeHtml(JSON.stringify(l.ctx, null, 2))} ` : ''}
`;
frag.appendChild(row);
});
body.appendChild(frag);
};
const bindChange = (id, key, transform = v => v) => {
document.getElementById(id).addEventListener('change', e => {
_logPanelState[key] = transform(e.target.value);
render();
});
};
bindChange('dy-fire-log-filter-level', 'level');
bindChange('dy-fire-log-filter-cat', 'cat');
bindChange('dy-fire-log-filter-day', 'day');
bindChange('dy-fire-log-filter-limit', 'limit', v => parseInt(v, 10) || 0);
let kwTimer = null;
document.getElementById('dy-fire-log-filter-kw').addEventListener('input', e => {
clearTimeout(kwTimer);
kwTimer = setTimeout(() => { _logPanelState.keyword = e.target.value.trim(); render(); }, 300);
});
document.getElementById('dy-fire-log-load-more').addEventListener('click', () => {
const cur = _logPanelState.limit || 100;
_logPanelState.limit = cur + 100;
document.getElementById('dy-fire-log-filter-limit').value = String(_logPanelState.limit);
render();
});
document.getElementById('dy-fire-log-close').addEventListener('click', () => panel.remove());
document.getElementById('dy-fire-log-export-cur').addEventListener('click', () => exportLogs(true));
document.getElementById('dy-fire-log-export-all').addEventListener('click', () => exportLogs(false));
document.getElementById('dy-fire-log-clear').addEventListener('click', () => {
if (confirm(`确定清空 ${_logPanelState.day} 的日志?`)) { clearLogs(_logPanelState.day); render(); }
});
render();
}
function exportLogs(onlyFiltered) {
const all = readLogs(_logPanelState.day);
let logs = all;
if (onlyFiltered) {
logs = all.filter(l => {
if (_logPanelState.level !== 'all' && l.level !== _logPanelState.level) return false;
if (_logPanelState.cat !== 'all' && l.cat !== _logPanelState.cat) return false;
if (_logPanelState.user !== 'all' && l.user !== _logPanelState.user) return false;
if (_logPanelState.keyword && !l.msg.includes(_logPanelState.keyword)) return false;
return true;
});
}
logs = logs.slice().sort((a, b) => a.ts - b.ts);
const txt = logs.map(l =>
`${new Date(l.ts).toLocaleString()} [${l.level.toUpperCase()}][${_catLabel(l.cat)}]${l.user ? '[' + l.user + ']' : ''} ${l.msg}`
).join('\n');
const blob = new Blob([txt], { type: 'text/plain' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `抖音续火日志_${_logPanelState.day}${onlyFiltered ? '_筛选' : ''}.txt`;
document.body.appendChild(a); a.click(); document.body.removeChild(a);
URL.revokeObjectURL(url);
const jsonBlob = new Blob([JSON.stringify(logs, null, 2)], { type: 'application/json' });
const jsonUrl = URL.createObjectURL(jsonBlob);
const a2 = document.createElement('a');
a2.href = jsonUrl;
a2.download = `抖音续火日志_${_logPanelState.day}${onlyFiltered ? '_筛选' : ''}.json`;
document.body.appendChild(a2); a2.click(); document.body.removeChild(a2);
URL.revokeObjectURL(jsonUrl);
addHistoryLog(`已导出 ${logs.length} 条日志`, LEVEL.SUCCESS, { cat: CAT.UI });
}
/* =========================================================
* 帮助面板(含搜索 + 高亮定位 + 调度器分节)
* ========================================================= */
function _helpSearchAll(keyword) {
const kw = (keyword || '').trim().toLowerCase();
if (!kw) return [];
const results = [];
HELP_TABS.forEach(tab => {
const tmp = document.createElement('div');
tmp.innerHTML = tab.html;
const text = tmp.textContent || '';
const lc = text.toLowerCase();
let idx = lc.indexOf(kw);
while (idx !== -1) {
const start = Math.max(0, idx - 40);
const end = Math.min(text.length, idx + kw.length + 60);
const snippet = (start > 0 ? '…' : '') + text.slice(start, end).replace(/\s+/g, ' ').trim() + (end < text.length ? '…' : '');
results.push({
tabId: tab.id,
tabLabel: tab.label,
snippet
});
if (results.length > 80) break;
idx = lc.indexOf(kw, idx + kw.length);
}
});
return results;
}
/**
* 在指定根节点内高亮关键词(不改动 DOM 结构,仅包裹文本节点)
* - 用 TreeWalker 遍历文本节点,跳过 script/style/textarea/mark
* - 高亮 3 秒后自动还原
* - 自动滚动到第一个匹配位置
* 返回匹配数量
*/
function _highlightKeyword(root, keyword, durationMs = 3000) {
if (!root || !keyword) return 0;
const kw = String(keyword).trim();
if (!kw) return 0;
const lowerKw = kw.toLowerCase();
const textNodes = [];
const walker = document.createTreeWalker(
root,
NodeFilter.SHOW_TEXT,
{
acceptNode(node) {
const parent = node.parentNode;
if (!parent) return NodeFilter.FILTER_REJECT;
const tag = parent.tagName;
if (tag === 'SCRIPT' || tag === 'STYLE' || tag === 'TEXTAREA' || tag === 'MARK') {
return NodeFilter.FILTER_REJECT;
}
if (parent.classList && parent.classList.contains('dyfire-help-highlight')) {
return NodeFilter.FILTER_REJECT;
}
const text = node.nodeValue || '';
if (!text) return NodeFilter.FILTER_REJECT;
if (text.toLowerCase().indexOf(lowerKw) === -1) return NodeFilter.FILTER_REJECT;
return NodeFilter.FILTER_ACCEPT;
}
}
);
let n;
while ((n = walker.nextNode())) textNodes.push(n);
if (textNodes.length === 0) return 0;
const marks = [];
textNodes.forEach(node => {
const text = node.nodeValue || '';
const lowerText = text.toLowerCase();
const frag = document.createDocumentFragment();
let lastIdx = 0;
let idx = lowerText.indexOf(lowerKw);
if (idx === -1) return;
while (idx !== -1) {
if (idx > lastIdx) frag.appendChild(document.createTextNode(text.slice(lastIdx, idx)));
const mark = document.createElement('mark');
mark.className = 'dyfire-help-highlight';
mark.style.cssText = [
'background:#ffc107',
'color:#000',
'padding:1px 3px',
'border-radius:3px',
'box-shadow:0 0 0 2px rgba(255,193,7,.35)',
'transition:background .4s,box-shadow .4s'
].join(';');
mark.textContent = text.slice(idx, idx + kw.length);
frag.appendChild(mark);
marks.push(mark);
lastIdx = idx + kw.length;
idx = lowerText.indexOf(lowerKw, lastIdx);
}
if (lastIdx < text.length) frag.appendChild(document.createTextNode(text.slice(lastIdx)));
try { node.parentNode.replaceChild(frag, node); } catch (e) { /* ignore */ }
});
if (marks.length === 0) return 0;
setTimeout(() => {
try {
marks[0].scrollIntoView({ behavior: 'smooth', block: 'center' });
} catch (e) { /* ignore */ }
}, 60);
setTimeout(() => {
marks.forEach(mark => {
if (mark.parentNode) {
const tn = document.createTextNode(mark.textContent);
try { mark.parentNode.replaceChild(tn, mark); } catch (e) { /* ignore */ }
}
});
}, durationMs);
return marks.length;
}
let HELP_TABS = [
{
id: 'quickstart',
label: '🚀 快速开始',
html: `
🕐 一分钟跑通脚本
步骤 1. 在浏览器中打开 https://www.douyin.com/chat,等待 2~3 秒,页面右上角会出现深色控制面板。
步骤 2. 点击面板上的「👥 用户选择 」按钮,脚本会自动滚动解析左侧的好友列表,弹出用户选择窗口。
步骤 3. 勾选你要续火花的好友(支持全选),点击「✅ 更新目标用户 」保存。
步骤 4. 点击「⚙️ 偏好设置 」,在「基本设置」里调整发送时间(默认 00:01:00),在「消息设置」里编辑消息模板。
步骤 5. 到点后脚本会自动依次给每个好友发送消息。也可以随时点击「🚀 立即发送 」手动触发。
🎯 最小可用配置
如果你不想折腾,只需要做三件事:
① 在「👥 用户选择」里选好友
② 在「⚙️ 偏好设置 → 💬 消息设置」里改一下要发的内容
③ 什么都不动,保持页面开着即可
默认配置已经能跑通:
· 每天 00:01:00 自动发送
· 附带一言 API 随机句子
· 附带专属一言(按星期切换)
· 自动记录每个好友的火花天数
⚠️ 挂机注意事项
保持页面活跃: 浏览器后台休眠会中断脚本。请在 chrome://settings/performance 里把 www.douyin.com 加入"始终活跃"白名单。
别关标签页: 关掉 Chat 页面脚本就会停止工作。
别开多标签: 同一账号开多个 Chat 页面会导致重复发送。
`
},
{
id: 'features',
label: '✨ 功能详解',
html: `
🔥 火花天数记录
每个好友独立记录火花天数,与抖音页面上的数字保持一致。
📥 获取方式有两种:
· 自动获取(默认开启) :扫描好友列表或发送消息时,脚本会读取抖音页面上的真实火花天数并写入本地。
· 手动递增(关闭自动获取时) :每天首次发送成功时 天数 + 1,同一天重复发送不会重复加。
🔧 在哪里管理: 点击面板上的「📅 天数管理 」按钮查看所有好友的天数,支持搜索、手动修改、批量清空、一键扫描刷新。
💬 怎么用: 在消息模板里写 [天数],发送时会自动替换为该好友的实际天数。
👥 多用户批量发送
支持一次给多个好友发送续火消息。
发送模式:
· 顺序模式(默认) :按用户列表顺序依次发送,一个完成再发下一个。
· 随机模式 :每次随机挑一个未发送的用户。
失败处理:
· 某个用户连续失败达到「最大重试次数」后,自动标记为失败并切换到下一个用户。
· 所有用户都处理完毕(成功或失败)后,才会通知后端调度器。
🎲 随机发送时间
在「偏好设置 → 基本设置」里可以开启随机发送时间,让每天的发送时间不固定,避免过于规律。
示例: 开始时间 23:30:00,结束时间 00:30:00
→ 每天会在 23:30 ~ 次日 00:30 之间随机挑一个时间点发送。
注意: 结束时间可以比开始时间小(表示跨天)。
💬 消息模板与占位符
在「偏好设置 → 消息设置 → 自定义消息模板」里编辑要发送的内容,支持以下占位符:
占位符
说明
[天数] 替换为该好友的火花天数
[API] 替换为一言 API 获取的随机句子
[TXTAPI] 替换为 TXTAPI 内容(API 或手动文本)
[专属一言] 替换为当天星期对应的专属文案
模板示例:
🔥 火花已续 [天数] 天
—— 每日一言 ——
[API]
—— 专属问候 ——
[专属一言]
📚 一言 API / TXTAPI / 专属一言
📚 一言 API:
从 https://v1.hitokoto.cn/ 获取一句随机的优美句子。
支持自定义格式,可用变量:{hitokoto}(句子)、{from}(出处)、{from_who}(作者)。
📄 TXTAPI:
两种模式:
· API 模式 :从一个 URL 获取纯文本(默认使用一言的纯文本接口)
· 手动模式 :自己写几行文本,每行一条,发送时随机或顺序挑一条
🌟 专属一言:
按周一 ~ 周日分别配置专属文案,每天发送时自动使用当天对应的文案。
支持两种发送模式:
· 随机模式 :随机挑一条(发过的不会再发,直到当天全部用完)
· 顺序模式 :按你写的顺序依次发送
🔄 智能重试 & 冷却期
发送失败会自动重试,默认最多 3 次。
重试策略:
· 每次重试间隔 5 秒
· 达到最大重试次数后,可选择"继续等待 N 分钟后重试"(在设置里开启)
· 也可以手动点击「🔄 重置重试」立即清零计数并重新触发
冷却期:
· 两次发送之间至少间隔 3 秒,避免被风控
· 每次重试之间至少间隔 5 秒
⏸️ 暂停 / 恢复
点击「⏸️ 暂停」按钮后,脚本会停止自动检测和发送。
暂停状态会保存到浏览器存储,刷新页面后依然有效。
再次点击「▶️ 继续」即可恢复正常运行。
注意: 暂停后正在进行的发送任务会被中断。
`
},
{
id: 'scheduler',
label: '⚡ 调度器',
html: `
🤖 什么是「配套工具 / 调度器」?
本脚本本身只能处理 单个抖音账号 。
如果你需要同时给多个抖音账号续火花,就需要配合本项目提供的 「配套工具」 :
· 后端调度器 :跨平台命令行工具,按顺序启动每个账号的浏览器,跑完一个再跑下一个。
· 综合管理器(Windows GUI) :图形化配置工具,一键启动/停止调度器,管理多个账号的浏览器 Profile。
· Python 回调服务器 :监听本地端口,接收脚本"任务完成"的通知,让调度器知道该切换下一个账号了。
单账号用户不需要用它 ,直接使用脚本本身的定时发送即可。
📥 从哪里下载配套工具?
配套工具
不单独发布到 Releases ,直接放在 GitHub 仓库根目录:
ScriptCat-Douyin-Fire-Helper / 配套工具.zip
⚠️ 下载后请解压到本地任意目录 (建议路径不含中文和空格,例如
D:\\DouyinFire\\)。
🧩 配套工具目录结构
配套工具/
├── shell/ # 调度器核心
│ ├── callback_server.py # Python 回调服务器
│ ├── scheduler.ps1 # Windows 调度器
│ ├── scheduler.sh # Linux/macOS 调度器
│ ├── config.json # ★ 核心配置
│ └── logs/ # 调度器日志
├── profiles/ # 各账号浏览器 Profile(自动生成)
├── manager_history.log # 管理器操作日志(自动生成)
└── 抖音续火综合管理器.pyw # Windows GUI 管理器
🚀 使用步骤(简述)
① 环境准备
· 安装 Python 3(Windows 记得勾选 "Add to PATH")
· Linux/macOS 还需安装 jq
② 创建账号 Profile
· 每个抖音账号一个独立的浏览器 Profile 文件夹
· 用对应 Profile 打开浏览器 → 登录抖音 → 关闭浏览器
· 用综合管理器(Windows)或手动命令行启动都可以
③ 编辑 config.json
· 配置账号列表、调度端口(默认 7788)、浏览器类型、目标 URL
· 每个账号指定唯一的 profile 名,对应 profiles/ 下的子目录
④ 脚本端开启回调
· 打开本脚本的「⚙️ 偏好设置 → 后端调度器回调(高级)」
· 勾选"启用"并确认端口一致(默认 7788)
· 每个 Profile 里设置一次即可
⑤ 启动调度器
· Windows:双击综合管理器 → 点「启动调度器」
· Linux/macOS:进入 shell 目录 → ./scheduler.sh
· 调度器会自动依次启动每个账号的浏览器,跑完一个切换下一个
❓ 常见问题
Q:为什么调度器跑完一个账号不切换下一个?
检查脚本里"后端调度器回调"是否已启用,端口是否与 config.json 一致。
Q:端口 7788 被占用?
Windows:netstat -ano | findstr :7788 → taskkill /PID xxx /F
Linux/macOS:lsof -i :7788 → kill -9 xxx
或者修改 config.json 和脚本设置里的端口为其他值。
Q:浏览器打开显示未登录?
确认 profiles/ 下对应账号目录确实保存了登录状态,且 config.json 里 profile 字段与目录名完全一致。
Q:更多问题?
访问 GitHub Issues 提交,或查看 README 中的完整调度器教程。
`
},
{
id: 'buttons',
label: '🎛️ 按钮说明',
html: `
主面板 · 第一行(主要操作)
按钮
作用
🚀 立即发送
跳过等待,立即开始发送流程。会依次给所有未发送的好友发送续火消息。
⏸️ 暂停
暂停所有自动发送。点击后按钮会变成「▶️ 继续」,再次点击可恢复。
🔄 重置记录
清空"今日已发送"记录,让所有好友重新变成待发送状态。
主面板 · 第二行(功能入口)
按钮
作用
⚙️ 偏好设置 打开设置面板,配置发送时间、消息模板、API、用户列表、日志等。
👁️ 消息预览 调用 API 生成一条完整消息并展示出来,但不会真的发送 。用来调试模板很方便。
📋 历史日志 查看历史发送记录。支持按级别、分类、日期、关键词筛选。可以导出为 txt 或 json。
📅 天数管理 查看和管理所有好友的火花天数。可以一键扫描好友列表刷新、单个修改、批量清空。
👥 用户选择 打开用户选择面板,勾选要续火花的好友。会自动解析左侧聊天列表。
🔄 重置重试 清零重试计数,并立即触发一次自动检测。发送失败卡住时可以点这个。
📖 使用帮助 打开当前这个帮助面板,包含所有功能说明、常见问题、调度器教程等。
🗑️ 清空数据 清空今日发送记录、失败列表、重试计数。不会影响设置和目标用户列表。
🔧 重置配置 恢复所有设置到默认值。危险操作 ,会清空目标用户、消息模板、火花天数记录等全部数据。
用户选择面板
按钮
作用
全选 勾选当前已加载出来的所有好友。
取消全选 取消所有勾选。
✅ 更新目标用户 保存当前勾选的好友作为目标用户列表。
❌ 取消 不保存,直接关闭面板。
💡 好友昵称旁边的 🔥 410天 徽章表示采集到的火花天数,鼠标悬停可查看。
天数管理面板
项目
作用
🔍 搜索框 按昵称关键词快速筛选列表。
🔄 扫描好友列表 打开左侧聊天列表、自动滚动到底部,采集所有会话的昵称和火花天数。
🗑️ 清空全部 清空所有好友的火花天数记录(不可恢复 )。
修改 手动修改某位好友的火花天数。
☑️ 自动从页面获取火花天数 见天数管理面板底部的开关说明: · 开启:扫描/发送时从抖音页面实时读取并覆盖本地 · 关闭:每天首次发送成功 +1,同一天重复发送不自增
日志面板
按钮
作用
级别筛选 只看 info / success / warn / error 某一级别的日志。
分类筛选 只看"发送"或"API"或"UI"等某一类日志。
日期 / 条数 / 关键词 按日期切换、限制显示条数、对消息文本模糊搜索。
📤 导出当前筛选 把当前筛选条件下看到的日志导出为 txt + json 两个文件。
📤 导出全部 忽略筛选,导出当前日期的全部日志。
`
},
{
id: 'settings',
label: '⚙️ 设置项对照',
html: `
📅 基本设置
设置项
说明
启用随机发送时间 开启后每天在指定区间随机挑时间发送。
发送时间 格式 HH:mm:ss,例如 00:01:00 表示每天凌晨 0 点 1 分发送。
自动从页面获取火花天数 详见「功能详解 → 火花天数记录」。
页面初始加载等待时间 打开页面后延迟多少秒开始自动检测。页面加载慢的话可以调大。
最大重试次数 发送失败时的最大重试次数(1~10)。
达到最大重试次数后继续重试 开启后,会在"自动重试间隔"时间后自动重置计数并重试。
自动重试间隔(分钟) 达到最大重试后,等待多少分钟自动重置。
🔗 后端调度器回调(高级) 仅配合外部调度器时使用。单账号可忽略。详见「⚡ 调度器」分节。
💬 消息设置
设置项
说明
自定义消息模板 要发送的消息内容,支持 [天数] [API] [TXTAPI] [专属一言] 等占位符。
Chat 页面换行替换符 Chat 页面聊天框不支持换行,消息里的 \\n 会被替换为该符号(默认 " | ")。
🔗 API 设置
设置项
说明
启用一言 API 从 hitokoto.cn 获取随机句子。
一言格式 最终展示格式,支持 {hitokoto} {from} {from_who} 变量。
启用 TXTAPI 从 URL 或手动文本里取一条文本。
TXTAPI 模式 API 模式 / 手动模式。
手动文本 每行一条,可随机或顺序发送。
🌟 专属一言
设置项
说明
启用专属一言 按星期切换的专属文案。
发送模式 随机(发过的不再发) / 顺序(按顺序)。
周一~周日 每天对应的一组文案,每行一条。
👥 用户设置
设置项
说明
目标用户名 每行一个,列表不为空时自动启用目标用户查找。
发送模式 顺序 / 随机。
重试时使用同一用户 开启后重试时还是发同一个人;关闭则切换到下一个人。
⚡ 高级设置
设置项
说明
用户查找超时 搜索某个用户的最长等待时间。
页面加载等待 打开页面后等待输入框出现的最大时长。
实时面板显示条数 主面板下方"操作日志"显示多少条。
历史面板默认显示条数 日志面板打开时默认展示多少条。
每日日志上限 每天日志最多保留多少条,超出自动轮转。
日志保留天数 超过天数的日志分片会被自动删除。
`
},
{
id: 'faq',
label: '❓ 常见问题',
html: `
Q1:脚本安装后不显示控制面板?
① 确认脚本管理器里该脚本已"启用"。
② 确认当前 URL 是 www.douyin.com/chat 开头的页面。
③ 刷新页面,等 2~3 秒。
④ 按 F12 打开控制台,看是否有红色报错。
Q2:扫描好友列表后发现天数不更新?
① 检查「天数管理」底部的"自动从页面获取火花天数"是否被关闭。
② 关闭状态下扫描只会给新用户建档,不会覆盖已有天数。
③ 开启后再次扫描即可刷新。
Q3:好友明明没有火花天数,脚本却显示数字?
早期版本可能存在"读取到别人天数"的 bug,v2026.10.02 已修复:
· 只会从右侧聊天面板读取当前会话的天数
· 用户列表采集时从每个会话项内部独立读取
如果仍出现,请点击「📅 天数管理 → 🗑️ 清空全部」后重新扫描。
Q4:消息模板里的占位符没被替换?
① 占位符必须用英文方括号 [天数],不能用全角 [天数]。
② 使用 [API] 需要开启"启用一言 API"。
③ 使用 [TXTAPI] 需要开启"启用 TXTAPI"。
④ 使用 [专属一言] 需要开启"启用专属一言"。
Q5:到点了但脚本没有自动发送?
① 确认脚本没有处于"暂停"状态。
② 确认「👥 用户选择」里已经选好目标好友。
③ 检查是否已经发送过(每天只发一次,重复点击会被跳过)。
④ 看「📋 历史日志」里的记录,会显示为什么没触发。
Q6:发送失败一直重试?
① 打开「📋 历史日志」筛选 error 级别,看具体报错。
② 常见原因:网络不稳定、抖音页面改版、被风控。
③ 可以点击「🔄 重置重试」手动清零。
④ 如果多次失败,建议暂停脚本,刷新页面后重启。
Q7:怎么让脚本 24 小时挂机?
① 在 chrome://settings/performance 里把 www.douyin.com 加入"始终活跃"白名单。
② 保持 Chat 页面标签页打开,别关。
③ 电脑不要休眠(电源设置里改为"从不")。
④ 追求稳定的话可以使用配套的后端调度器,实现多账号自动切换(详见「⚡ 调度器」分节)。
Q8:日志太多了占空间?
在「偏好设置 → 高级设置 → 日志设置」里调整:
· 每日日志上限:调小(如 200)
· 日志保留天数:调小(如 3)
超出天数或条数的日志会自动被清理。
Q9:如何导出错误日志给开发者排查?
① 打开「📋 历史日志」
② 级别筛选选择 error
③ 点击「📤 导出当前筛选」
④ 会得到 .txt 和 .json 两个文件,把 json 附在反馈里即可。
Q10:能同时给多个抖音账号续火吗?
单个脚本实例只处理一个账号。多账号需要使用配套的「后端调度器 + 综合管理器」,让每个账号跑在独立的浏览器 Profile 里依次执行。
详见「⚡ 调度器」分节,或访问 GitHub 仓库:
${GITHUB_REPO_URL}
`
},
{
id: 'tips',
label: '💡 技巧 & 注意',
html: `
💡 实用技巧
· 调模板前先预览 :改完消息模板点「👁️ 消息预览」确认效果,避免发送后才发现占位符写错了。
· 用随机时间防规律 :固定时间久了容易让人觉得机械,开随机时间更像真人。
· 专属一言叠加每日一句 :把 [专属一言] 和 [API] 都用上,每天内容都不一样。
· 天数用来做差异化 :写"我们已经认识第 [天数] 天啦",比单纯"续火"更走心。
· 批量发送后看日志 :日志会显示每个用户是否成功,方便发现问题。
· 多标签互斥 :同一个账号不要开多个 Chat 页面,会导致同一天重复发送。
⚠️ 使用注意事项
· 别频繁改消息模板 :改完最好先在"预览"里检查,避免发送奇怪内容。
· 别开多个脚本实例 :同一个账号开多个 Chat 页面会重复发送。
· 注意冷却期 :脚本默认 3 秒冷却,不要手动狂点"立即发送"。
· 仅在好友同意下使用 :不要用来骚扰陌生人。
· 遵守抖音社区规范 :本脚本仅供学习研究,请勿用于违规用途。
🎯 推荐配置组合
📌 稳妥型(默认):
· 发送时间 00:01:00
· 只启用一言 API
· 每个好友单独记录天数
· 最大重试次数 3
📌 内容型:
· 开启随机发送时间(23:30 ~ 00:30)
· 消息模板里同时用 [天数] + [专属一言] + [API]
· 专属一言按星期配好 5~7 条文案
📌 省心型:
· 保持默认配置
· 定期点「📅 天数管理 → 🔄 扫描好友列表」刷新一次
· 把日志保留天数改小,避免占空间
📝 反馈问题时的建议
提交 Issue 时请附上:
· 脚本版本号(在脚本管理器里查看)
· 浏览器版本
· 日志导出的 .json 文件(筛选 error 级别导出即可)
· 复现步骤(怎么操作出现的)
这样能快速定位问题。
GitHub 仓库:
${GITHUB_REPO_URL}
`
}
];
function showHelpPanel() {
const existing = document.getElementById('dy-fire-help-panel');
if (existing) { existing.remove(); return; }
const panel = document.createElement('div');
panel.id = 'dy-fire-help-panel';
panel.className = 'dyfire-modal dyfire-modal-wide';
panel.style.maxHeight = '88vh';
panel.innerHTML = `
🔍
清除
`;
document.body.appendChild(panel);
addDragFunctionality(panel, 'dy-fire-help-header');
const navEl = document.getElementById('dy-fire-help-nav');
const bodyEl = document.getElementById('dy-fire-help-body');
const searchEl = document.getElementById('dy-fire-help-search');
const clearBtn = document.getElementById('dy-fire-help-search-clear');
let currentTab = 'quickstart';
let searchKeyword = '';
let searchTimer = null;
const renderTab = () => {
const tab = HELP_TABS.find(t => t.id === currentTab);
if (!tab) return;
bodyEl.innerHTML = tab.html;
bodyEl.scrollTop = 0;
};
const renderSearchResults = () => {
const kw = searchKeyword.trim().toLowerCase();
if (!kw) {
renderTab();
return;
}
const results = _helpSearchAll(kw);
bodyEl.innerHTML = '';
bodyEl.scrollTop = 0;
if (results.length === 0) {
bodyEl.innerHTML = ``;
return;
}
const title = document.createElement('div');
title.style.cssText = 'color:#8fbdff;font-size:13px;margin-bottom:12px;';
title.textContent = `找到 ${results.length} 条匹配 " ${searchKeyword} " 的内容,点击可跳转并高亮`;
bodyEl.appendChild(title);
const frag = document.createDocumentFragment();
results.forEach(r => {
const card = document.createElement('div');
card.className = 'dyfire-section';
card.style.cursor = 'pointer';
card.style.marginBottom = '10px';
card.style.transition = 'all 0.2s';
card.innerHTML = `
${r.tabLabel}
点击跳转并高亮 →
${escapeHtml(r.snippet)}
`;
card.addEventListener('mouseenter', () => { card.style.background = 'rgba(0,216,184,.08)'; });
card.addEventListener('mouseleave', () => { card.style.background = ''; });
card.addEventListener('click', () => {
const kw = searchKeyword.trim();
searchEl.value = '';
searchKeyword = '';
clearBtn.style.display = 'none';
currentTab = r.tabId;
navEl.querySelectorAll('.dyfire-settings-nav-item').forEach(x => {
x.classList.toggle('active', x.dataset.tab === r.tabId);
});
renderTab();
if (kw) {
setTimeout(() => {
const hit = _highlightKeyword(bodyEl, kw, 3000);
if (hit > 0) {
addHistoryLog(`帮助跳转:命中 ${hit} 处 "${kw}"`, LEVEL.INFO, { cat: CAT.UI });
}
}, 120);
}
});
frag.appendChild(card);
});
bodyEl.appendChild(frag);
};
HELP_TABS.forEach(t => {
const item = document.createElement('div');
item.className = 'dyfire-settings-nav-item' + (t.id === currentTab ? ' active' : '');
item.dataset.tab = t.id;
item.textContent = t.label;
item.style.fontSize = '13px';
item.style.padding = '10px 14px';
item.addEventListener('click', () => {
searchEl.value = '';
searchKeyword = '';
clearBtn.style.display = 'none';
currentTab = t.id;
navEl.querySelectorAll('.dyfire-settings-nav-item').forEach(x => x.classList.remove('active'));
item.classList.add('active');
renderTab();
});
navEl.appendChild(item);
});
renderTab();
searchEl.addEventListener('input', () => {
clearTimeout(searchTimer);
searchTimer = setTimeout(() => {
searchKeyword = searchEl.value;
clearBtn.style.display = searchKeyword.trim() ? '' : 'none';
renderSearchResults();
}, 200);
});
clearBtn.addEventListener('click', () => {
searchEl.value = '';
searchKeyword = '';
clearBtn.style.display = 'none';
renderTab();
});
document.getElementById('dy-fire-help-close').addEventListener('click', () => panel.remove());
document.getElementById('dy-fire-help-gotit').addEventListener('click', () => panel.remove());
}
/* =========================================================
* 设置面板 schema
* ========================================================= */
const SETTINGS_SCHEMA = [
{
tab: 'basic', label: '📅 基本设置',
sections: [
{
title: '🕒 发送时间设置', fields: [
{ key: 'sendTimeRandom', type: 'checkbox', label: '启用随机发送时间' },
{ key: 'sendTime', type: 'text', label: '发送时间 (HH:mm:ss)', validate: /^([0-1]?[0-9]|2[0-3]):[0-5][0-9]:[0-5][0-9]$/, showIf: c => !c.sendTimeRandom },
{ key: 'sendTimeRangeStart', type: 'text', label: '开始时间 (HH:mm:ss)', validate: /^([0-1]?[0-9]|2[0-3]):[0-5][0-9]:[0-5][0-9]$/, showIf: c => c.sendTimeRandom },
{ key: 'sendTimeRangeEnd', type: 'text', label: '结束时间 (HH:mm:ss)', validate: /^([0-1]?[0-9]|2[0-3]):[0-5][0-9]:[0-5][0-9]$/, showIf: c => c.sendTimeRandom }
]
},
{
title: '🔥 火花天数设置', fields: [
{ key: 'autoFetchFireDays', type: 'checkbox', label: '自动从页面获取火花天数(关闭则每天首次发送 +1)' }
]
},
{
title: '⏱️ 启动设置', fields: [
{ key: 'initialDelay', type: 'number', label: '页面初始加载等待时间(秒)', min: 0, max: 300 }
]
},
{
title: '🔄 重试设置', fields: [
{ key: 'maxRetryCount', type: 'number', label: '最大重试次数', min: 1, max: 10 },
{ key: 'retryAfterMaxReached', type: 'checkbox', label: '达到最大重试次数后继续重试' },
{ key: 'autoRetryInterval', type: 'number', label: '自动重试间隔(分钟)', min: 1, max: 1440 },
{ key: 'retryResetInterval', type: 'number', label: '定时重置重试间隔(分钟)', min: 0, max: 1440 }
]
},
{
title: '🔗 后端调度器回调 高级 ',
isHtmlTitle: true,
fields: [
{ key: 'enableScriptBCallback', type: 'checkbox', label: '启用后端调度器回调' },
{ key: 'scriptBCallbackPort', type: 'number', label: '回调端口', min: 1024, max: 65535, hint: '单账号使用可忽略此项;多账号请参考「📖 使用帮助 → ⚡ 调度器」' }
]
}
]
},
{
tab: 'message', label: '💬 消息设置',
sections: [
{
title: '📝 消息内容', fields: [
{ key: 'customMessage', type: 'textarea', label: '自定义消息模板', rows: 6, hint: '可用占位符: [API] [TXTAPI] [专属一言] [天数]' }
]
},
{
title: '💬 Chat 页面设置', fields: [
{ key: 'chatPageLineSeparator', type: 'text', label: '换行替换符' }
]
}
]
},
{
tab: 'api', label: '🔗 API设置',
sections: [
{
title: '📚 一言API', fields: [
{ key: 'useHitokoto', type: 'checkbox', label: '启用一言API' },
{ key: 'hitokotoFormat', type: 'textarea', label: '一言格式', rows: 2, hint: '可用变量: {hitokoto} {from} {from_who}' }
]
},
{
title: '📄 TXTAPI', fields: [
{ key: 'useTxtApi', type: 'checkbox', label: '启用TXTAPI' },
{ key: 'txtApiMode', type: 'radio', label: 'TXTAPI 模式', options: [{ value: 'api', label: 'API模式' }, { value: 'manual', label: '手动模式' }], showIf: c => c.useTxtApi },
{ key: 'txtApiUrl', type: 'text', label: 'TXTAPI 链接', showIf: c => c.useTxtApi && c.txtApiMode === 'api' },
{ key: 'txtApiManualRandom', type: 'checkbox', label: '手动模式随机选择', showIf: c => c.useTxtApi && c.txtApiMode === 'manual' },
{ key: 'txtApiManualText', type: 'textarea', label: '手动文本(一行一个)', rows: 5, showIf: c => c.useTxtApi && c.txtApiMode === 'manual' }
]
}
]
},
{
tab: 'special', label: '🌟 专属一言',
sections: [
{
title: '🌟 专属一言设置', fields: [
{ key: 'useSpecialHitokoto', type: 'checkbox', label: '启用专属一言' },
{ key: 'specialHitokotoRandom', type: 'radio', label: '发送模式', options: [{ value: true, label: '随机' }, { value: false, label: '顺序' }] },
{ key: 'specialHitokotoMonday', type: 'textarea', label: '周一', rows: 3 },
{ key: 'specialHitokotoTuesday', type: 'textarea', label: '周二', rows: 3 },
{ key: 'specialHitokotoWednesday', type: 'textarea', label: '周三', rows: 3 },
{ key: 'specialHitokotoThursday', type: 'textarea', label: '周四', rows: 3 },
{ key: 'specialHitokotoFriday', type: 'textarea', label: '周五', rows: 3 },
{ key: 'specialHitokotoSaturday', type: 'textarea', label: '周六', rows: 3 },
{ key: 'specialHitokotoSunday', type: 'textarea', label: '周日', rows: 3 }
]
}
]
},
{
tab: 'users', label: '👥 用户设置',
sections: [
{
title: '👥 目标用户', fields: [
{ key: 'targetUsernames', type: 'textarea', label: '目标用户名(一行一个)', rows: 5, hint: '列表不为空时自动启用目标用户查找' },
{ key: 'multiUserMode', type: 'radio', label: '发送模式', options: [{ value: 'sequential', label: '顺序' }, { value: 'random', label: '随机' }] },
{ key: 'multiUserRetrySame', type: 'checkbox', label: '重试时使用同一用户' }
]
}
]
},
{
tab: 'advanced', label: '⚡ 高级设置',
sections: [
{
title: '⚡ 性能设置', fields: [
{ key: 'userSearchTimeout', type: 'number', label: '用户查找超时(ms)', min: 1000, max: 30000 },
{ key: 'pageLoadWaitTime', type: 'number', label: '页面加载等待(ms)', min: 1000, max: 15000 }
]
},
{
title: '📋 日志设置', fields: [
{ key: 'maxLiveLogs', type: 'number', label: '实时面板显示条数', min: 3, max: 50 },
{ key: 'maxViewLogs', type: 'number', label: '历史面板默认显示条数', min: 20, max: 1000 },
{ key: 'maxHistoryLogs', type: 'number', label: '每日日志上限', min: 100, max: 5000 },
{ key: 'logRetentionDays', type: 'number', label: '日志保留天数', min: 1, max: 90 }
]
},
{
title: '🎨 格式设置', fields: [
{ key: 'fromFormat', type: 'text', label: 'from 格式' },
{ key: 'fromWhoFormat', type: 'text', label: 'from_who 格式' }
]
}
]
}
];
let _settingsDraft = {};
let _settingsPanel = null;
function _renderSettingsField(field, draft) {
const id = `set-${field.key}`;
const show = !field.showIf || field.showIf(draft);
const wrap = document.createElement('div');
wrap.className = 'dyfire-field';
wrap.dataset.key = field.key;
wrap.style.display = show ? '' : 'none';
const label = document.createElement('label');
label.className = 'dyfire-field-label';
label.textContent = field.label;
wrap.appendChild(label);
if (field.type === 'checkbox') {
const row = document.createElement('label');
row.style.cssText = 'display:flex;align-items:center;cursor:pointer;';
const cb = document.createElement('input');
cb.type = 'checkbox';
cb.id = id;
cb.checked = !!draft[field.key];
cb.style.marginRight = '8px';
cb.addEventListener('change', () => { draft[field.key] = cb.checked; _refreshSettingsVisibility(); });
row.appendChild(cb);
const span = document.createElement('span');
span.style.color = '#ccc';
span.textContent = '启用';
row.appendChild(span);
wrap.appendChild(row);
} else if (field.type === 'textarea') {
const ta = document.createElement('textarea');
ta.id = id;
ta.rows = field.rows || 3;
ta.className = 'dyfire-input';
ta.value = draft[field.key] ?? '';
ta.addEventListener('input', () => { draft[field.key] = ta.value; });
wrap.appendChild(ta);
} else if (field.type === 'radio') {
const group = document.createElement('div');
group.style.cssText = 'display:flex;gap:20px;flex-wrap:wrap;';
field.options.forEach(opt => {
const lbl = document.createElement('label');
lbl.style.cssText = 'display:flex;align-items:center;cursor:pointer;';
const r = document.createElement('input');
r.type = 'radio';
r.name = `radio-${field.key}`;
r.value = String(opt.value);
r.checked = draft[field.key] === opt.value;
r.style.marginRight = '8px';
r.addEventListener('change', () => { draft[field.key] = opt.value; _refreshSettingsVisibility(); });
lbl.appendChild(r);
const s = document.createElement('span');
s.style.color = '#ccc';
s.textContent = opt.label;
lbl.appendChild(s);
group.appendChild(lbl);
});
wrap.appendChild(group);
} else if (field.type === 'number') {
const inp = document.createElement('input');
inp.type = 'number';
inp.id = id;
inp.className = 'dyfire-input';
if (field.min != null) inp.min = field.min;
if (field.max != null) inp.max = field.max;
inp.value = draft[field.key] ?? '';
inp.addEventListener('input', () => { draft[field.key] = Number(inp.value); });
wrap.appendChild(inp);
} else {
const inp = document.createElement('input');
inp.type = 'text';
inp.id = id;
inp.className = 'dyfire-input';
inp.value = draft[field.key] ?? '';
inp.addEventListener('input', () => { draft[field.key] = inp.value; });
wrap.appendChild(inp);
}
if (field.hint) {
const hint = document.createElement('div');
hint.className = 'dyfire-field-hint';
hint.textContent = field.hint;
wrap.appendChild(hint);
}
return wrap;
}
function _refreshSettingsVisibility() {
if (!_settingsPanel) return;
_settingsPanel.querySelectorAll('.dyfire-field').forEach(el => {
let field = null;
for (const tab of SETTINGS_SCHEMA) {
for (const sec of tab.sections) {
for (const f of sec.fields) {
if (f.key === el.dataset.key) { field = f; break; }
}
if (field) break;
}
if (field) break;
}
if (!field) return;
el.style.display = (!field.showIf || field.showIf(_settingsDraft)) ? '' : 'none';
});
}
function showSettingsPanel() {
const existing = document.getElementById('dy-fire-settings-panel');
if (existing) { existing.remove(); return; }
_settingsDraft = Object.assign({}, userConfig);
const panel = document.createElement('div');
panel.id = 'dy-fire-settings-panel';
panel.className = 'dyfire-modal dyfire-modal-wide';
panel.innerHTML = `
`;
document.body.appendChild(panel);
_settingsPanel = panel;
addDragFunctionality(panel, 'dy-fire-settings-header');
const navEl = document.getElementById('dy-fire-settings-nav');
const bodyEl = document.getElementById('dy-fire-settings-body');
let currentTab = 'basic';
const renderTab = () => {
bodyEl.innerHTML = '';
const tabSchema = SETTINGS_SCHEMA.find(t => t.tab === currentTab);
if (!tabSchema) return;
tabSchema.sections.forEach(sec => {
const sectionEl = document.createElement('div');
sectionEl.className = 'dyfire-section';
const title = document.createElement('h4');
if (sec.isHtmlTitle) {
title.innerHTML = sec.title;
} else {
title.textContent = sec.title;
}
sectionEl.appendChild(title);
sec.fields.forEach(f => sectionEl.appendChild(_renderSettingsField(f, _settingsDraft)));
bodyEl.appendChild(sectionEl);
});
_refreshSettingsVisibility();
};
SETTINGS_SCHEMA.forEach(t => {
const item = document.createElement('div');
item.className = 'dyfire-settings-nav-item' + (t.tab === currentTab ? ' active' : '');
item.textContent = t.label;
item.addEventListener('click', () => {
currentTab = t.tab;
navEl.querySelectorAll('.dyfire-settings-nav-item').forEach(x => x.classList.remove('active'));
item.classList.add('active');
renderTab();
});
navEl.appendChild(item);
});
renderTab();
document.getElementById('dy-fire-settings-close').addEventListener('click', () => { panel.remove(); _settingsPanel = null; });
document.getElementById('dy-fire-settings-save').addEventListener('click', () => { saveSettingsFromDraft(); panel.remove(); _settingsPanel = null; });
}
function saveSettingsFromDraft() {
for (const tab of SETTINGS_SCHEMA) {
for (const sec of tab.sections) {
for (const f of sec.fields) {
if (f.validate && !f.validate.test(String(_settingsDraft[f.key] ?? ''))) { alert(`字段「${f.label}」格式不正确`); return; }
if (f.type === 'number' && (f.min != null || f.max != null)) {
const v = Number(_settingsDraft[f.key]);
if (isNaN(v) || (f.min != null && v < f.min) || (f.max != null && v > f.max)) { alert(`字段「${f.label}」超出范围`); return; }
}
}
}
}
Object.assign(userConfig, _settingsDraft);
const targets = (userConfig.targetUsernames || '').trim().split('\n').filter(x => x.trim());
userConfig.enableTargetUser = targets.length > 0;
saveConfig();
parseTargetUsers();
updateUserStatusDisplay();
updateFireDaysStatus();
_cleanOldLogShards();
nextSendTime = parseRandomTimeString();
startCountdown(nextSendTime);
addHistoryLog('设置已保存', LEVEL.SUCCESS, { cat: CAT.UI });
}
/* =========================================================
* 拖拽 / 主面板 / 预览
* ========================================================= */
function addDragFunctionality(panel, headerId) {
const header = document.getElementById(headerId);
if (!header) return;
header.addEventListener('mousedown', e => {
if (e.target.tagName === 'BUTTON') return;
isDragging = true;
currentPanel = panel;
const rect = panel.getBoundingClientRect();
dragOffsetX = e.clientX - rect.left;
dragOffsetY = e.clientY - rect.top;
if (panel.style.transform && panel.style.transform.includes('translate')) {
panel.style.transform = 'none';
panel.style.left = rect.left + 'px';
panel.style.top = rect.top + 'px';
}
panel.style.transition = 'none';
document.body.style.userSelect = 'none';
e.preventDefault();
});
if (!dragListenersAttached) {
dragListenersAttached = true;
document.addEventListener('mousemove', e => {
if (!isDragging || !currentPanel) return;
const x = e.clientX - dragOffsetX;
const y = e.clientY - dragOffsetY;
const maxX = window.innerWidth - currentPanel.offsetWidth;
const maxY = window.innerHeight - currentPanel.offsetHeight;
currentPanel.style.left = Math.max(0, Math.min(x, maxX)) + 'px';
currentPanel.style.top = Math.max(0, Math.min(y, maxY)) + 'px';
currentPanel.style.transform = 'none';
});
document.addEventListener('mouseup', () => {
if (isDragging) {
isDragging = false;
if (currentPanel) currentPanel.style.transition = 'all 0.3s ease';
currentPanel = null;
document.body.style.userSelect = '';
}
});
}
}
function createControlPanel() {
const existing = document.getElementById('dy-fire-helper');
if (existing) existing.remove();
const panel = document.createElement('div');
panel.id = 'dy-fire-helper';
panel.className = 'dyfire-panel';
panel.innerHTML = `
火花天数
${userConfig.fireDays}
🚀 立即发送
⏸️ 暂停
🔄 重置记录
⚙️ 偏好设置
👁️ 消息预览
📋 历史日志
📅 天数管理
👥 用户选择
🔄 重置重试
📖 使用帮助
🗑️ 清空数据
🔧 重置配置
`;
document.body.appendChild(panel);
addDragFunctionality(panel, 'dy-fire-header');
createReopenButton();
document.getElementById('dy-fire-helper-close').addEventListener('click', () => {
panel.style.display = 'none';
const rb = document.getElementById('dy-fire-reopen-btn');
if (rb) rb.style.display = 'flex';
});
document.getElementById('dy-fire-send').addEventListener('click', sendMessage);
document.getElementById('dy-fire-pause').addEventListener('click', togglePause);
document.getElementById('dy-fire-settings').addEventListener('click', showSettingsPanel);
document.getElementById('dy-fire-preview').addEventListener('click', showMessagePreview);
document.getElementById('dy-fire-history').addEventListener('click', () => showLogPanel());
document.getElementById('dy-fire-manage-days').addEventListener('click', showFireDaysPanel);
document.getElementById('dy-fire-select-users').addEventListener('click', showUserSelectPanel);
document.getElementById('dy-fire-reset-retry').addEventListener('click', resetRetryAndSend);
document.getElementById('dy-fire-help').addEventListener('click', showHelpPanel);
document.getElementById('dy-fire-clear').addEventListener('click', clearData);
document.getElementById('dy-fire-reset').addEventListener('click', resetAllConfig);
document.getElementById('dy-fire-reset-users').addEventListener('click', resetTodaySentUsers);
updateUserStatusDisplay();
updateFireDaysStatus();
updateRetryCount();
updatePauseButton();
updatePauseStatusDisplay();
}
function createReopenButton() {
const exist = document.getElementById('dy-fire-reopen-btn');
if (exist) exist.remove();
const btn = document.createElement('div');
btn.id = 'dy-fire-reopen-btn';
btn.className = 'dyfire-reopen-btn';
btn.innerHTML = '🔥';
btn.title = '打开续火助手';
btn.addEventListener('click', () => {
const panel = document.getElementById('dy-fire-helper');
if (panel) { panel.style.display = 'block'; btn.style.display = 'none'; }
else { createControlPanel(); btn.style.display = 'none'; }
});
document.body.appendChild(btn);
}
function showMessagePreview() {
const existing = document.getElementById('dy-fire-preview-panel');
if (existing) { existing.remove(); return; }
const panel = document.createElement('div');
panel.id = 'dy-fire-preview-panel';
panel.className = 'dyfire-modal';
const targetUser = GM_getValue('lastTargetUser', '') || (allTargetUsers[0] || '');
panel.innerHTML = `
加载中...
预览不发送,仅调用 API 后展示最终内容
`;
document.body.appendChild(panel);
document.getElementById('dy-fire-preview-close').addEventListener('click', () => panel.remove());
getMessageContent(targetUser).then(msg => {
const el = document.getElementById('dy-fire-preview-body');
if (el) el.textContent = msg;
}).catch(err => {
const el = document.getElementById('dy-fire-preview-body');
if (el) el.textContent = `获取失败: ${err.message}`;
});
}
function resetRetryAndSend() {
if (isProcessing) { addHistoryLog('任务进行中,跳过', LEVEL.INFO, { cat: CAT.STATE }); return; }
retryCount = 0;
isMaxRetryReached = false;
lastRetryResetTime = nowMs();
GM_setValue('retryCount', 0);
GM_setValue('isMaxRetryReached', false);
GM_setValue('lastRetryResetTime', lastRetryResetTime);
updateRetryCount();
addHistoryLog('重试已重置', LEVEL.SUCCESS, { cat: CAT.STATE });
autoSendIfNeeded();
}
/* =========================================================
* 样式注入
* ========================================================= */
function injectStyles() {
if (document.getElementById('dy-fire-styles')) return;
const style = document.createElement('style');
style.id = 'dy-fire-styles';
style.textContent = `
.dyfire-panel, .dyfire-modal, .dyfire-panel *, .dyfire-modal * { color-scheme: dark; }
.dyfire-panel{position:fixed;top:20px;right:20px;width:500px;max-width:95vw;background:linear-gradient(135deg,#1a1a1a,#2d2d2d);border-radius:16px;box-shadow:0 8px 32px rgba(0,0,0,.4),0 0 0 1px rgba(255,255,255,.1);z-index:9999;font-family:'PingFang SC','Microsoft YaHei',sans-serif;color:#fff;max-height:1000px;overflow:hidden;backdrop-filter:blur(10px);user-select:none;}
.dyfire-panel-header{padding:20px 20px 15px;border-bottom:1px solid rgba(255,255,255,.1);cursor:move;}
.dyfire-close-btn{background:rgba(255,255,255,.1);border:none;width:28px;height:28px;border-radius:50%;cursor:pointer;color:#fff;font-size:16px;display:flex;align-items:center;justify-content:center;transition:all .2s;}
.dyfire-close-btn:hover{background:rgba(255,255,255,.2);transform:scale(1.1);}
.dyfire-status-grid{display:grid;grid-template-columns:1fr 1fr;gap:10px;}
.dyfire-info-grid{display:grid;grid-template-columns:1fr 1fr 1fr;gap:8px;}
.dyfire-status-card{background:rgba(255,255,255,.05);padding:8px;border-radius:8px;}
.dyfire-status-label{color:#999;font-size:11px;margin-bottom:4px;}
.dyfire-status-value{color:#fff;font-weight:600;font-size:12px;word-break:break-all;}
.dyfire-block{padding:15px 20px;border-bottom:1px solid rgba(255,255,255,.1);}
.dyfire-block:last-child{border-bottom:none;}
.dyfire-btn{padding:10px 12px;background:rgba(255,255,255,.1);color:#fff;border:none;border-radius:8px;cursor:pointer;font-weight:500;font-size:13px;transition:all .2s;text-align:center;font-family:inherit;white-space:nowrap;line-height:1.2;}
.dyfire-btn:hover{transform:translateY(-2px);}
.dyfire-btn-primary{background:linear-gradient(135deg,#00d8b8,#00b8a8);box-shadow:0 4px 12px rgba(0,216,184,.3);}
.dyfire-btn-grid{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:8px;}
.dyfire-btn-ghost{padding:10px 4px;font-size:12px;overflow:hidden;text-overflow:ellipsis;}
.dyfire-btn-danger{background:linear-gradient(135deg,#ff2c54,#ff6b8b);}
.dyfire-btn-info{background:rgba(0,216,184,.2);color:#00d8b8;border:1px solid rgba(0,216,184,.3);}
.dyfire-live-log{font-size:12px;height:120px;overflow-y:auto;line-height:1.5;background:rgba(0,0,0,.3);border-radius:8px;padding:10px;}
.dyfire-log-line{padding:5px 0;border-bottom:1px solid rgba(255,255,255,.05);word-break:break-all;}
.dyfire-reopen-btn{position:fixed;top:20px;right:20px;width:50px;height:50px;background:linear-gradient(135deg,#ff2c54,#ff6b8b);border-radius:50%;color:#fff;display:none;justify-content:center;align-items:center;cursor:pointer;z-index:9998;box-shadow:0 6px 20px rgba(255,44,84,.4);font-size:20px;font-weight:bold;transition:all .3s;backdrop-filter:blur(10px);border:2px solid rgba(255,255,255,.2);}
.dyfire-reopen-btn:hover{transform:scale(1.1) rotate(10deg);}
.dyfire-modal{position:fixed;top:50%;left:50%;transform:translate(-50%,-50%);width:500px;max-width:95vw;max-height:85vh;background:linear-gradient(135deg,#1a1a1a,#2d2d2d);border-radius:20px;box-shadow:0 20px 60px rgba(0,0,0,.5);z-index:10000;font-family:'PingFang SC','Microsoft YaHei',sans-serif;overflow:hidden;box-sizing:border-box;backdrop-filter:blur(10px);border:1px solid rgba(255,255,255,.1);display:flex;flex-direction:column;color:#fff;}
.dyfire-modal-wide{width:min(1000px,95vw);}
.dyfire-modal-header{padding:16px 20px;border-bottom:1px solid rgba(255,255,255,.1);background:rgba(0,0,0,.2);cursor:move;flex-shrink:0;}
.dyfire-modal-footer{padding:16px 20px;border-top:1px solid rgba(255,255,255,.1);background:rgba(0,0,0,.2);flex-shrink:0;}
.dyfire-toolbar{padding:12px 20px;border-bottom:1px solid rgba(255,255,255,.1);flex-shrink:0;display:flex;justify-content:space-between;gap:10px;}
.dyfire-user-list{flex:1;overflow-y:auto;background:rgba(0,0,0,.2);margin:10px 20px;border-radius:8px;padding:6px;min-height:200px;max-height:320px;}
.dyfire-user-row{padding:10px;border-bottom:1px solid rgba(255,255,255,.05);}
.dyfire-settings-nav{width:180px;background:rgba(0,0,0,.3);padding:16px 0;border-right:1px solid rgba(255,255,255,.1);flex-shrink:0;overflow-y:auto;}
.dyfire-settings-nav-item{padding:12px 18px;color:#999;cursor:pointer;transition:all .2s;border-left:3px solid transparent;font-size:14px;}
.dyfire-settings-nav-item:hover{color:#fff;background:rgba(255,255,255,.05);}
.dyfire-settings-nav-item.active{color:#ff2c54;background:rgba(255,44,84,.1);border-left-color:#ff2c54;font-weight:600;}
.dyfire-settings-body{flex:1;overflow-y:auto;padding:20px;}
.dyfire-section{background:rgba(255,255,255,.05);border-radius:12px;padding:16px;margin-bottom:16px;border:1px solid rgba(255,255,255,.1);}
.dyfire-section h4{color:#fff;margin:0 0 12px;font-size:15px;font-weight:600;}
.dyfire-field{margin-bottom:14px;}
.dyfire-field-label{display:block;margin-bottom:6px;color:#ccc;font-weight:500;font-size:13px;}
.dyfire-field-hint{font-size:11px;color:#888;margin-top:4px;}
.dyfire-input{width:100%;padding:10px 12px;background:#2a2a2a;border:1px solid rgba(255,255,255,.2);border-radius:8px;box-sizing:border-box;color:#fff;font-size:13px;font-family:inherit;color-scheme:dark;}
.dyfire-input:focus{outline:none;border-color:#00d8b8;background:#222;}
.dyfire-filter-bar{display:flex;gap:8px;padding:10px 20px;border-bottom:1px solid rgba(255,255,255,.1);flex-wrap:wrap;align-items:center;flex-shrink:0;}
.dyfire-filter-bar label{font-size:12px;color:#999;display:flex;align-items:center;gap:6px;}
.dyfire-filter-bar select, .dyfire-input select {
background:#2a2a2a !important;color:#fff !important;border:1px solid rgba(255,255,255,.2);border-radius:6px;padding:4px 8px;font-size:12px;font-family:inherit;color-scheme:dark;appearance:none;-webkit-appearance:none;
background-image:linear-gradient(45deg,transparent 50%,#fff 50%),linear-gradient(135deg,#fff 50%,transparent 50%);
background-position:calc(100% - 14px) calc(50% - 2px),calc(100% - 10px) calc(50% - 2px);
background-size:4px 4px,4px 4px;background-repeat:no-repeat;padding-right:24px;
}
.dyfire-filter-bar select option, .dyfire-input select option { background:#2a2a2a !important;color:#fff !important; }
.dyfire-filter-bar input{flex:1;min-width:120px;padding:6px 10px;font-size:12px;color-scheme:dark;}
.dyfire-log-info{padding:8px 20px;font-size:12px;color:#8fbdff;background:rgba(0,0,0,.2);}
.dyfire-log-body{flex:1;overflow-y:auto;padding:0 20px 20px;background:rgba(0,0,0,.2);}
.dyfire-log-row{padding:10px;border-bottom:1px solid rgba(255,255,255,.05);}
.dyfire-log-row details summary{outline:none;}
.dyfire-panel ::-webkit-scrollbar, .dyfire-modal ::-webkit-scrollbar{width:8px;height:8px;}
.dyfire-panel ::-webkit-scrollbar-track, .dyfire-modal ::-webkit-scrollbar-track{background:rgba(0,0,0,.2);}
.dyfire-panel ::-webkit-scrollbar-thumb, .dyfire-modal ::-webkit-scrollbar-thumb{background:rgba(255,255,255,.15);border-radius:4px;}
.dyfire-panel ::-webkit-scrollbar-thumb:hover, .dyfire-modal ::-webkit-scrollbar-thumb:hover{background:rgba(255,255,255,.25);}
.dyfire-modal input[type="checkbox"]{width:15px;height:15px;accent-color:#00d8b8;cursor:pointer;}
.dyfire-help-highlight { animation: dyfire-hl-pulse 1s ease-in-out; }
@keyframes dyfire-hl-pulse {
0% { background: #ffc107; box-shadow: 0 0 0 2px rgba(255,193,7,.35); }
50% { background: #ffda6b; box-shadow: 0 0 0 5px rgba(255,193,7,.15); }
100% { background: #ffc107; box-shadow: 0 0 0 2px rgba(255,193,7,.35); }
}
`;
document.head.appendChild(style);
}
/* =========================================================
* 初始化
* ========================================================= */
function init() {
injectStyles();
initConfig();
_cleanOldLogShards();
createControlPanel();
const today = getLocalTodayString();
if (GM_getValue('lastResetDate', '') !== today) resetTodaySentUsers();
const isSentToday = GM_getValue('lastSentDate', '') === today;
updateStatus(isSentToday);
updateUserStatusDisplay();
updateFireDaysStatus();
updateRetryCount();
updatePauseButton();
updatePauseStatusDisplay();
const rb = document.getElementById('dy-fire-reopen-btn');
if (rb) rb.style.display = 'none';
if (typeof GM_registerMenuCommand !== 'undefined') {
try {
GM_registerMenuCommand('显示面板', () => {
const p = document.getElementById('dy-fire-helper');
if (p) p.style.display = 'block';
if (rb) rb.style.display = 'none';
});
GM_registerMenuCommand('使用帮助', showHelpPanel);
GM_registerMenuCommand('立即发送', sendMessage);
GM_registerMenuCommand('偏好设置', showSettingsPanel);
GM_registerMenuCommand('历史日志', () => showLogPanel());
GM_registerMenuCommand('火花天数管理', showFireDaysPanel);
GM_registerMenuCommand('从列表选择用户', showUserSelectPanel);
GM_registerMenuCommand('重置重试并发送', resetRetryAndSend);
GM_registerMenuCommand('清空发送记录', clearData);
GM_registerMenuCommand('重置配置', resetAllConfig);
GM_registerMenuCommand('重置今日记录', resetTodaySentUsers);
GM_registerMenuCommand(isPaused ? '▶️ 继续脚本' : '⏸️ 暂停脚本', togglePause);
} catch (e) { /* ignore */ }
}
addHistoryLog('抖音续火助手已启动', LEVEL.INFO, { cat: CAT.SYSTEM });
alreadyDoneNotified = false;
GM_setValue('alreadyDoneNotified', false);
if (userConfig.enableTargetUser && allTargetUsers.length > 0) {
const done = new Set([...sentUsersToday, ...failedUsersToday]);
const allDone = allTargetUsers.every(u => done.has(u));
if (allDone) checkAllUsersProcessed();
} else {
if (GM_getValue('lastSentDate', '') === today) checkAllUsersProcessed();
}
window.addEventListener('beforeunload', () => _flushLogBufferSync());
if (userConfig.initialDelay > 0) {
addHistoryLog(`初始延迟 ${userConfig.initialDelay} 秒`, LEVEL.INFO, { cat: CAT.STATE });
setTimeout(() => {
autoSendIfNeeded();
_scheduleNextAutoCheck();
}, userConfig.initialDelay * 1000);
} else {
autoSendIfNeeded();
_scheduleNextAutoCheck();
}
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', init);
} else {
init();
}
})();