// ==UserScript== // @name 百度贴吧自动签到 // @version 3.1.0 // @description 电脑端 ScriptCat 百度贴吧自动签到:使用贴吧手机端协议接口,整轮复用登录态,按日去重,失败自动续签与退避重试。 // @author BF // @crontab * * * * * // @grant GM_xmlhttpRequest // @grant GM_notification // @grant GM_setValue // @grant GM_getValue // @grant GM_log // @grant GM_cookie // @grant GM.cookie // @grant GM_registerMenuCommand // @connect tieba.baidu.com // @connect c.tieba.baidu.com // @license MIT // ==/UserScript== 'use strict'; /** * 百度贴吧自动签到(ScriptCat 后台定时脚本) * * 关于“手机端协议”:指贴吧服务端的手机接口版本,不代表脚本要在手机上跑。 * 脚本运行在电脑浏览器里,直接复用电脑浏览器已有的百度登录 Cookie。 * * ── 3.1.0 关键改动 ────────────────────────────────────────────── * [调度] 改用标准 5 段 cron `* * * * *`,由脚本内状态做“每天只成功签一次”。 * 原 `* * * * * once * *` 中的 once 不是合法 cron 字段,行为不可控。 * 不用 `@match`,避免打开贴吧页面时被页面注入重复触发签到。 * [正确性] responseCode 取第一个“非空”的 code。原实现用 ?? 会把空字符串 * 当成有效值,导致成功响应被误判为临时失败而反复重试。 * [性能] 整轮只取一次 tbs 并全程复用,仅单个贴吧重试时才强制刷新。 * 原实现每签一个吧都要 getTbs(),缓存 90 秒一过就会逐个重拉。 * [去重] 同日已全部成功则直接跳过;同日失败后冷却 10 分钟再补,单日最多 6 轮。 * [健壮] 关注列表主接口失败时降级到 newmoindex 接口;运行状态写入做节流。 * ───────────────────────────────────────────────────────────── * * 权限提示:ScriptCat 需在「设置 → 安全 → Cookie」中允许本脚本访问 Cookie, * 否则读不到 BDUSS,会一直在“未获取到 tbs”处失败。 */ const CONFIG = { retries: 2, // 单个贴吧首次失败后再重试 2 次,共 3 次 interval: 500, // 贴吧之间的基础间隔(毫秒) jitter: 150, // 间隔随机抖动上限(毫秒) timeout: 20000, // 单次 HTTP 请求超时(毫秒) retryAfterSeconds: 1800, // 整轮临时失败后交给 ScriptCat 的重试延迟(秒) lockTtlMs: 45 * 60 * 1000, // 运行锁最长有效期,防止异常中断后永久锁死 tbsCacheMs: 90 * 1000, // tbs 缓存时间(仅在非强制场景生效) notify: true, maxForums: 1000, maxFailureDetails: 12, // 通知里最多展示多少个失败项,完整信息写日志 minRecheckMs: 10 * 60 * 1000,// 同一天内两轮之间的最小间隔 maxDailyAttempts: 6, // 每天最多尝试轮数,避免失败后疯狂刷接口 stateFlushMs: 5000, // 运行状态写盘节流间隔(毫秒) }; const KEY_STATE = 'tieba_sign_state_v4'; const KEY_LOCK = 'tieba_sign_running_lock_v1'; const KEY_RUN_STATE = 'tieba_sign_run_state_v1'; const API = { tbs: 'https://tieba.baidu.com/dc/common/tbs', like: 'https://c.tieba.baidu.com/c/f/forum/like', sign: 'https://c.tieba.baidu.com/c/c/forum/sign', newmo: 'https://tieba.baidu.com/mo/q/newmoindex', }; const MOBILE = { clientId: 'wappc_1534235498291_488', clientVersion: '9.7.8.0', phoneImei: '000000000000000', model: 'MI+5', netType: '1', from: '1008621y', vcodeTag: '11', signKey: 'tiebaclient!!!', }; const TIEBA_HOME = 'https://tieba.baidu.com/'; const SIGN_OK_CODES = new Set(['0']); const SIGN_ALREADY_CODES = new Set(['160002', '1101']); // 1101 = 您今天已经签到过了 const FORUM_FAIL_CODES = new Set(['340006']); // 吧不存在 / 未开放 /* ────────────────────────── 基础设施 ────────────────────────── */ function log(message, level = 'info') { const line = `[百度贴吧签到] ${message}`; try { GM_log(line, level); } catch (_) { console.log(line); } } function notify(title, text) { if (!CONFIG.notify) return; try { GM_notification({ title, text, timeout: 10000 }); } catch (_) { log(`${title}: ${text}`); } } function delay(ms) { return new Promise(resolve => setTimeout(resolve, ms)); } function backoffMs(attempt, retryAfterSeconds = 0) { const exponential = Math.min(30000, 3000 * (2 ** attempt)); const serverDelay = Math.max(0, Number(retryAfterSeconds) * 1000); return Math.max(exponential, serverDelay) + Math.floor(Math.random() * 1000); } function parseJson(text) { try { return JSON.parse(text); } catch (_) { return null; } } function responseText(response) { return response?.responseText || response?.response || ''; } function responseJson(response) { return parseJson(responseText(response)) || {}; } function responsePayload(json) { return json?.data && typeof json.data === 'object' && !Array.isArray(json.data) ? json.data : json; } /** * 取第一个“非 null / 非 undefined / 非空字符串”的值并转成字符串。 * 这里不能用 ??:贴吧部分接口会返回 error_code: "",?? 不会跳过空串, * 结果 code 变成 '',成功响应被当成临时错误反复重试。 */ function firstFilled(...values) { for (const value of values) { if (value === null || value === undefined) continue; const text = String(value); if (text !== '') return text; } return ''; } function responseCode(json) { const payload = responsePayload(json); return firstFilled( json?.error_code, json?.no, json?.code, payload?.error_code, payload?.no, payload?.code, ); } function responseMessage(json) { const payload = responsePayload(json); const raw = json?.error_msg || json?.error || json?.message || payload?.error_msg || payload?.error || payload?.message || ''; return String(raw).trim(); } function statusOf(response) { return Number(response?.status || 0); } function responseHeader(response, name) { const wanted = String(name || '').toLowerCase(); const raw = response?.responseHeaders; if (typeof raw === 'string') { const match = raw.match(new RegExp(`^${wanted}:\\s*(.+)$`, 'im')); return match ? match[1].trim() : ''; } const headers = response?.headers; if (headers && typeof headers === 'object') { const key = Object.keys(headers).find(item => item.toLowerCase() === wanted); return key ? String(headers[key] || '') : ''; } return ''; } function responseDiagnostic(response) { const json = responseJson(response); const data = json.data && typeof json.data === 'object' ? json.data : null; const fields = Object.keys(json).slice(0, 8).join(',') || '非 JSON'; const details = [`HTTP ${statusOf(response) || '无响应'}`, `字段 ${fields}`]; const code = firstFilled(json.error_code, json.no, json.code, data?.error_code, data?.no, data?.code); const message = responseMessage(json) || String(data?.message || ''); if (code) details.push(`code/no ${code.slice(0, 40)}`); if (message) details.push(`消息 ${message.replace(/[\r\n]+/g, ' ').slice(0, 120)}`); const login = json.is_login ?? data?.is_login; if (login !== undefined) details.push(`登录标志 ${String(login)}`); return details.join(','); } function cookieCount(cookie) { return cookie ? cookie.split(';').filter(Boolean).length : 0; } function cookieValue(cookie, name) { const match = String(cookie || '').match(new RegExp(`(?:^|;\\s*)${name}=([^;]*)`)); return match ? match[1] : ''; } /* ────────────────────────── MD5(贴吧签名用) ────────────────────────── */ // 标准 MD5 常量表,硬编码避免每次用 Math.sin 重算带来的浮点误差与性能开销。 const MD5_K = new Uint32Array([ 0xd76aa478, 0xe8c7b756, 0x242070db, 0xc1bdceee, 0xf57c0faf, 0x4787c62a, 0xa8304613, 0xfd469501, 0x698098d8, 0x8b44f7af, 0xffff5bb1, 0x895cd7be, 0x6b901122, 0xfd987193, 0xa679438e, 0x49b40821, 0xf61e2562, 0xc040b340, 0x265e5a51, 0xe9b6c7aa, 0xd62f105d, 0x02441453, 0xd8a1e681, 0xe7d3fbc8, 0x21e1cde6, 0xc33707d6, 0xf4d50d87, 0x455a14ed, 0xa9e3e905, 0xfcefa3f8, 0x676f02d9, 0x8d2a4c8a, 0xfffa3942, 0x8771f681, 0x6d9d6122, 0xfde5380c, 0xa4beea44, 0x4bdecfa9, 0xf6bb4b60, 0xbebfbc70, 0x289b7ec6, 0xeaa127fa, 0xd4ef3085, 0x04881d05, 0xd9d4d039, 0xe6db99e5, 0x1fa27cf8, 0xc4ac5665, 0xf4292244, 0x432aff97, 0xab9423a7, 0xfc93a039, 0x655b59c3, 0x8f0ccc92, 0xffeff47d, 0x85845dd1, 0x6fa87e4f, 0xfe2ce6e0, 0xa3014314, 0x4e0811a1, 0xf7537e82, 0xbd3af235, 0x2ad7d2bb, 0xeb86d391, ]); const MD5_SHIFTS = [7, 12, 17, 22, 5, 9, 14, 20, 4, 11, 16, 23, 6, 10, 15, 21]; function md5Hex(value) { const bytes = new TextEncoder().encode(value); const bitLength = bytes.length * 8; const paddedLength = Math.ceil((bytes.length + 9) / 64) * 64; const buffer = new Uint8Array(paddedLength); buffer.set(bytes); buffer[bytes.length] = 0x80; const view = new DataView(buffer.buffer); // 64 位长度字段(小端),高位补上以防超长输入。 view.setUint32(paddedLength - 8, bitLength >>> 0, true); view.setUint32(paddedLength - 4, Math.floor(bitLength / 0x100000000) >>> 0, true); let a0 = 0x67452301; let b0 = 0xefcdab89; let c0 = 0x98badcfe; let d0 = 0x10325476; for (let offset = 0; offset < paddedLength; offset += 64) { let a = a0; let b = b0; let c = c0; let d = d0; for (let i = 0; i < 64; i += 1) { let f; let g; if (i < 16) { f = (b & c) | (~b & d); g = i; } else if (i < 32) { f = (d & b) | (~d & c); g = (5 * i + 1) & 15; } else if (i < 48) { f = b ^ c ^ d; g = (3 * i + 5) & 15; } else { f = c ^ (b | ~d); g = (7 * i) & 15; } const shift = MD5_SHIFTS[((i >> 4) << 2) + (i & 3)]; const sum = (a + f + MD5_K[i] + view.getUint32(offset + g * 4, true)) >>> 0; a = d; d = c; c = b; b = (b + ((sum << shift) | (sum >>> (32 - shift)))) >>> 0; } a0 = (a0 + a) >>> 0; b0 = (b0 + b) >>> 0; c0 = (c0 + c) >>> 0; d0 = (d0 + d) >>> 0; } return [a0, b0, c0, d0] .map(word => [0, 1, 2, 3] .map(i => ((word >>> (i * 8)) & 0xff).toString(16).padStart(2, '0')) .join('')) .join(''); } function mobileSigned(data) { const raw = Object.keys(data).sort().map(key => `${key}=${data[key]}`).join('') + MOBILE.signKey; return { ...data, sign: md5Hex(raw).toUpperCase() }; } /* ────────────────────────── 运行状态 ────────────────────────── */ function todayKey() { const date = new Date(); return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`; } function readState() { try { return GM_getValue(KEY_STATE, null) || {}; } catch (_) { return {}; } } function writeState(patch) { try { const current = readState(); const base = current.date === todayKey() ? current : {}; GM_setValue(KEY_STATE, { ...base, ...patch, date: patch.date || todayKey() }); } catch (error) { log(`保存签到状态失败:${error?.message || error}`, 'warn'); } } function formatRunTime(value) { if (!value) return '未知时间'; const date = new Date(value); return Number.isNaN(date.getTime()) ? '未知时间' : date.toLocaleString(); } let runStateDirtyAt = 0; function setRunState(state, force = false) { const now = Date.now(); const patch = { ...state, updatedAt: new Date().toISOString() }; try { if (force || now - runStateDirtyAt >= CONFIG.stateFlushMs) { GM_setValue(KEY_RUN_STATE, { ...GM_getValue(KEY_RUN_STATE, {}), ...patch }); runStateDirtyAt = now; } else { // 节流期间只保留在内存,收尾时强制写一次。 setRunState.pending = patch; } } catch (error) { log(`保存运行状态失败:${error?.message || error}`, 'warn'); } } function flushRunState() { const pending = setRunState.pending; if (!pending) return; setRunState.pending = null; try { GM_setValue(KEY_RUN_STATE, { ...GM_getValue(KEY_RUN_STATE, {}), ...pending }); runStateDirtyAt = Date.now(); } catch (_) { // 落盘失败不影响签到主流程 } } function showRunState() { const state = GM_getValue(KEY_RUN_STATE, null); if (!state) { notify('百度贴吧运行状态', '当前没有运行记录'); return; } if (state.status === 'running') { notify( '百度贴吧正在执行', `${state.phase || '准备中'}:${state.current || 0}/${state.total || '?'}\n开始时间:${formatRunTime(state.startedAt)}`, ); return; } const daily = readState(); const tail = daily?.date === todayKey() ? `\n今日:${daily.completed ? '已全部完成' : '未完成'},尝试 ${daily.attempts || 0} 轮` : '\n今日尚未运行'; notify( '百度贴吧上次运行状态', `${state.status === 'success' ? '已完成' : '执行失败'}:${state.summary || '无汇总'}\n结束时间:${formatRunTime(state.finishedAt)}${tail}`, ); } /* ────────────────────────── 调度闸门 ────────────────────────── */ /** * 判断本轮是否应该跳过。 * 依赖 KEY_STATE 做真正的“当天只成功签一次”,不再依赖 crontab 的 once 语义。 */ function checkDailyGate() { const state = readState(); if (state.date !== todayKey()) return { skip: false }; if (state.completed) return { skip: true, reason: '今日已全部签到完成' }; const attempts = Number(state.attempts || 0); if (attempts >= CONFIG.maxDailyAttempts) { return { skip: true, reason: `今日已尝试 ${attempts} 轮,达到上限,明日再试` }; } const last = state.lastAttemptAt ? Date.parse(state.lastAttemptAt) : 0; if (Number.isFinite(last) && last > 0) { const remain = CONFIG.minRecheckMs - (Date.now() - last); if (remain > 0) { return { skip: true, reason: `距上次失败不足 ${Math.ceil(CONFIG.minRecheckMs / 60000)} 分钟,稍后再补`, remainMs: remain }; } } return { skip: false }; } /* ────────────────────────── 网络请求 ────────────────────────── */ function retryError(message, seconds = CONFIG.retryAfterSeconds) { if (typeof CATRetryError !== 'undefined') return new CATRetryError(message, seconds); return new Error(message); } function request(method, url, data, cookie = '') { return new Promise((resolve, reject) => { const details = { method, url, timeout: CONFIG.timeout, // 不用 anonymous:true,让 ScriptCat 复用浏览器登录态;同时显式传入 // 白名单 Cookie,兼容后台跨域请求。 anonymous: false, headers: { Accept: 'application/json, text/plain, */*', 'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8', Referer: TIEBA_HOME, }, onload: response => resolve(response), onerror: () => reject(new Error('网络错误')), ontimeout: () => reject(new Error('请求超时')), onabort: () => reject(new Error('请求被中止')), }; if (data !== undefined) details.data = data; if (cookie) details.cookie = cookie; GM_xmlhttpRequest(details); }); } function listCookies() { return new Promise((resolve, reject) => { const modernApi = typeof GM !== 'undefined' && typeof GM?.cookie === 'function' ? details => GM.cookie('list', details) : typeof GM !== 'undefined' && GM?.cookie && typeof GM.cookie.list === 'function' ? details => GM.cookie.list(details) : null; const legacyApi = typeof GM_cookie !== 'undefined' ? GM_cookie : null; let settled = false; let timer; const finish = (callback, value) => { if (settled) return; settled = true; clearTimeout(timer); callback(value); }; timer = setTimeout(() => finish(reject, new Error('读取贴吧 Cookie 超时')), CONFIG.timeout); if (modernApi) { Promise.resolve() .then(() => modernApi({ url: TIEBA_HOME })) .then(cookies => finish(resolve, Array.isArray(cookies) ? cookies : [])) .catch(error => finish(reject, new Error(`读取贴吧 Cookie 失败:${error?.message || error}`))); return; } const list = typeof legacyApi === 'function' ? (details, callback) => legacyApi('list', details, callback) : legacyApi && typeof legacyApi.list === 'function' ? (details, callback) => legacyApi.list(details, callback) : null; if (!list) { finish(reject, new Error('ScriptCat 未提供 GM.cookie.list 或 GM_cookie 接口')); return; } try { list({ url: TIEBA_HOME }, (cookies, error) => { if (error) { finish(reject, new Error(`读取贴吧 Cookie 失败:${String(error)}`)); return; } finish(resolve, Array.isArray(cookies) ? cookies : []); }); } catch (error) { finish(reject, new Error(`调用 GM_cookie 失败:${error?.message || error}`)); } }); } const COOKIE_WHITELIST = new Set([ 'BDUSS', 'BDUSS_BFESS', 'STOKEN', 'STOKEN_BFESS', 'BAIDUID', 'BAIDUID_BFESS', 'PTOKEN', 'PTOKEN_BFESS', ]); async function readTiebaCookieString() { try { const cookies = await listCookies(); return cookies .filter(cookie => cookie && COOKIE_WHITELIST.has(cookie.name) && cookie.value !== undefined) .map(cookie => `${cookie.name}=${cookie.value}`) .join('; '); } catch (error) { log(error instanceof Error ? error.message : String(error), 'warn'); return ''; } } /* ────────────────────────── 登录态 ────────────────────────── */ let authCache = null; function isLoggedIn(json) { const value = json?.is_login ?? json?.data?.is_login; return value === true || value === 1 || value === '1' || value === 'true'; } function extractTbs(json) { const value = json?.tbs ?? json?.data?.tbs; return typeof value === 'string' ? value : ''; } async function getTbs(force = false) { if (!force && authCache && Date.now() - authCache.time < CONFIG.tbsCacheMs) { return authCache.value; } if (force) authCache = null; const cookie = await readTiebaCookieString(); const response = await request('GET', API.tbs, undefined, cookie); const json = responseJson(response); const value = { isLogin: isLoggedIn(json), tbs: extractTbs(json), cookie, cookieCount: cookieCount(cookie), status: statusOf(response), diagnostic: responseDiagnostic(response), }; if (value.tbs) authCache = { time: Date.now(), value }; return value; } function ensureHttpSuccess(response, operation) { const status = statusOf(response); if (status === 401 || status === 403) { throw new Error(`${operation}失败:登录态失效或请求被拒绝(HTTP ${status})`); } if (status === 429) { const retryAfter = Number(responseHeader(response, 'Retry-After') || 0); throw retryError(`${operation}被限流(HTTP 429)`, Math.max(60, retryAfter)); } if (status >= 500 || status === 0) { throw retryError(`${operation}服务器异常(HTTP ${status || '无响应'})`); } if (status < 200 || status >= 300) { throw new Error(`${operation}失败(HTTP ${status})`); } } /* ────────────────────────── 关注列表 ────────────────────────── */ function mobileRequestData(bduss, page) { return mobileSigned({ BDUSS: bduss, _client_type: '2', _client_id: MOBILE.clientId, _client_version: MOBILE.clientVersion, _phone_imei: MOBILE.phoneImei, from: MOBILE.from, page_no: String(page), page_size: '200', model: MOBILE.model, net_type: MOBILE.netType, timestamp: String(Math.floor(Date.now() / 1000)), vcode_tag: MOBILE.vcodeTag, }); } function normalizeForum(item) { return { id: String(item.id || item.fid || item.forum_id || item.forumId || ''), name: String(item.name || item.forum_name || item.forumName || item.kw || ''), }; } function mobileForums(json) { const payload = responsePayload(json); const forumList = payload?.forum_list || payload?.forums || {}; const values = Array.isArray(forumList) ? forumList : ['non-gconforum', 'gconforum'].flatMap(key => { const value = forumList[key]; if (Array.isArray(value)) return value; return value && typeof value === 'object' ? [value] : []; }); return values.map(normalizeForum).filter(item => item.id && item.name); } function hasMoreForums(json) { const payload = responsePayload(json); const value = json?.has_more ?? json?.hasMore ?? payload?.has_more ?? payload?.hasMore ?? payload?.more; return value === true || value === 1 || value === '1' || value === 'true'; } function dedupeForums(forums, limit) { const seen = new Map(); for (const forum of forums) { if (!seen.has(forum.id)) seen.set(forum.id, forum); if (seen.size >= limit) break; } return [...seen.values()]; } async function getForumsByLike(bduss, cookie) { const forums = []; for (let page = 1; page <= 20 && forums.length < CONFIG.maxForums; page += 1) { const body = new URLSearchParams(mobileRequestData(bduss, page)).toString(); const response = await request('POST', API.like, body, cookie); ensureHttpSuccess(response, '获取关注列表'); const json = responseJson(response); const code = responseCode(json); if (code && code !== '0') { throw new Error(`like 接口获取关注列表失败:${responseDiagnostic(response)}`); } const batch = mobileForums(json); forums.push(...batch); if (!batch.length || !hasMoreForums(json)) break; await delay(CONFIG.interval); } return forums; } /** * 降级方案:newmoindex 是网页版轻量接口,不需要签名,只需要登录 Cookie。 * 主接口改版或签名失配时仍能拿到关注列表,避免整脚本直接瘫掉。 */ async function getForumsByNewmo(cookie) { const response = await request('GET', `${API.newmo}?t=${Date.now()}`, undefined, cookie); ensureHttpSuccess(response, '降级获取关注列表'); const json = responseJson(response); const code = responseCode(json); if (code && code !== '0') { throw new Error(`newmoindex 接口获取关注列表失败:${responseDiagnostic(response)}`); } const payload = responsePayload(json); const list = payload?.like_forum || payload?.likeForum || payload?.forum_list || []; if (!Array.isArray(list)) return []; return list.map(normalizeForum).filter(item => item.id && item.name); } async function getForums(bduss, cookie) { let forums = []; try { forums = await getForumsByLike(bduss, cookie); } catch (error) { log(`主接口获取关注列表失败,尝试降级:${error?.message || error}`, 'warn'); } if (!forums.length) { try { forums = await getForumsByNewmo(cookie); if (forums.length) log(`降级接口获取到 ${forums.length} 个贴吧`, 'warn'); } catch (error) { log(`降级接口同样失败:${error?.message || error}`, 'warn'); } } return dedupeForums(forums, CONFIG.maxForums); } /* ────────────────────────── 签到 ────────────────────────── */ function signResult(response) { const status = statusOf(response); const json = responseJson(response); const message = responseMessage(json); const code = responseCode(json); if (status === 401 || status === 403) return { type: 'auth', reason: `HTTP ${status}` }; if (status === 429) return { type: 'retryable', reason: 'HTTP 429 限流', retryAfter: 60 }; if (status >= 500 || status === 0) return { type: 'retryable', reason: `HTTP ${status || '无响应'}` }; if (SIGN_OK_CODES.has(code)) return { type: 'ok' }; if (SIGN_ALREADY_CODES.has(code) || /已签|已经签到|重复签到|already|signed/i.test(message)) { return { type: 'already' }; } // 确定性的业务结果,重试也没用。 if (FORUM_FAIL_CODES.has(code) || /未开放|不支持签到|不存在|已关闭|被封禁|无权访问|吧不存在/i.test(message)) { return { type: 'fail', reason: message || `error_code=${code || '未知'}` }; } // 未知错误按临时处理,避免偶发格式/服务异常被 crontab 吞掉。 return { type: 'retryable', reason: message || responseDiagnostic(response) }; } async function signForum(forum, bduss, tbs, cookie) { const body = new URLSearchParams(mobileSigned({ BDUSS: bduss, _client_type: '2', _client_version: MOBILE.clientVersion, _phone_imei: MOBILE.phoneImei, model: MOBILE.model, net_type: MOBILE.netType, fid: forum.id, kw: forum.name, tbs, timestamp: String(Math.floor(Date.now() / 1000)), })).toString(); const response = await request('POST', API.sign, body, cookie); return signResult(response); } /** * authRef 在整个轮次里共享登录态: * 正常签到直接复用,只有某个吧重试时才强制刷新一次 tbs。 * 原实现每个吧都调 getTbs(),缓存 90 秒一过就逐个重拉,几百个吧等于请求翻倍。 */ async function signForumWithRetry(forum, bduss, authRef) { let last = { type: 'retryable', reason: '未知错误' }; for (let attempt = 0; attempt <= CONFIG.retries; attempt += 1) { try { if (attempt > 0 || !authRef.current?.tbs) { authRef.current = await getTbs(true); } const auth = authRef.current; if (!auth?.tbs) { return { type: 'auth', reason: `登录态失效或未获取到 tbs:${auth?.diagnostic || '无诊断信息'};Cookie 数量 ${auth?.cookieCount || 0}`, }; } last = await signForum(forum, bduss, auth.tbs, auth.cookie); if (last.type === 'ok' || last.type === 'already' || last.type === 'auth') return last; if (attempt < CONFIG.retries) { log(`${forum.name} 第 ${attempt + 1} 次失败:${last.reason};刷新 tbs 后重试`, 'warn'); await delay(backoffMs(attempt, last.retryAfter)); } } catch (error) { const message = error instanceof Error ? error.message : String(error); if (/登录态失效|HTTP 401|HTTP 403/.test(message)) { return { type: 'auth', reason: message }; } last = { type: 'retryable', reason: message }; if (attempt < CONFIG.retries) { log(`${forum.name} 第 ${attempt + 1} 次请求异常:${message};准备重试`, 'warn'); await delay(backoffMs(attempt, last.retryAfter)); } } } return last; } function shortDetails(items) { if (!items.length) return ''; const shown = items.slice(0, CONFIG.maxFailureDetails).join('、'); return items.length > CONFIG.maxFailureDetails ? `${shown}……另有 ${items.length - CONFIG.maxFailureDetails} 个,详见运行日志` : shown; } async function runSign() { setRunState({ status: 'running', phase: '读取登录状态', current: 0, total: 0 }, true); const authRef = { current: await getTbs(true) }; const auth = authRef.current; if (!auth.tbs) { throw retryError( `未检测到百度登录状态或未获取到 tbs:${auth.diagnostic};Cookie 数量 ${auth.cookieCount}`, 1800, ); } const bduss = cookieValue(auth.cookie, 'BDUSS') || cookieValue(auth.cookie, 'BDUSS_BFESS'); if (!bduss) { throw retryError(`未读取到 BDUSS Cookie:${auth.diagnostic};Cookie 数量 ${auth.cookieCount}`, 1800); } const forums = await getForums(bduss, auth.cookie); if (!forums.length) throw new Error('未获取到任何关注的贴吧'); log(`共 ${forums.length} 个关注的贴吧,开始签到`); setRunState({ status: 'running', phase: '签到中', current: 0, total: forums.length }, true); const result = { ok: 0, already: 0, fail: 0, auth: 0, retryable: 0, failed: [], stoppedEarly: false }; let processed = 0; for (let index = 0; index < forums.length; index += 1) { const forum = forums[index]; const current = await signForumWithRetry(forum, bduss, authRef); if (current.type === 'ok') { result.ok += 1; log(`签到成功:${forum.name}`); } else if (current.type === 'already') { result.already += 1; log(`今日已签到:${forum.name}`); } else if (current.type === 'auth') { result.auth += 1; result.failed.push(`${forum.name}(${current.reason})`); result.stoppedEarly = true; log(`登录态异常:${forum.name} - ${current.reason}`, 'warn'); log('检测到登录态异常,停止继续批量请求', 'warn'); processed = index; // 中断点之前已完成的数量 break; } else if (current.type === 'retryable') { result.retryable += 1; result.failed.push(`${forum.name}(${current.reason})`); log(`临时失败:${forum.name} - ${current.reason}`, 'warn'); } else { result.fail += 1; result.failed.push(`${forum.name}(${current.reason})`); log(`签到失败:${forum.name} - ${current.reason}`, 'warn'); } processed = index + 1; setRunState({ status: 'running', phase: '签到中', current: processed, total: forums.length, partialSummary: `成功 ${result.ok},已签 ${result.already},失败 ${result.fail + result.auth + result.retryable}`, }); if (index < forums.length - 1) { await delay(CONFIG.interval + Math.floor(Math.random() * CONFIG.jitter)); } } const failedCount = result.fail + result.auth + result.retryable; const summary = `成功 ${result.ok},已签 ${result.already},失败 ${failedCount}` + (result.stoppedEarly ? '(登录态异常提前结束)' : '') + (result.failed.length ? `(${shortDetails(result.failed)})` : ''); const completed = failedCount === 0 && !result.stoppedEarly && processed === forums.length && result.ok + result.already === forums.length; writeState({ completed, failCount: failedCount, total: forums.length, summary, lastAttemptAt: new Date().toISOString(), attempts: Number(readState().attempts || 0), }); setRunState({ status: failedCount === 0 ? 'success' : 'failed', phase: failedCount === 0 ? '已完成' : '部分失败', current: processed, total: forums.length, summary, finishedAt: new Date().toISOString(), }, true); if (result.auth > 0 || result.retryable > 0) { throw retryError(`本轮存在登录态或临时失败:${summary}`, CONFIG.retryAfterSeconds); } return { skipped: false, summary, failCount: result.fail }; } /* ────────────────────────── 运行锁 ────────────────────────── */ function acquireLock() { const now = Date.now(); const current = GM_getValue(KEY_LOCK, null); if (current && Number.isFinite(current.time) && now - current.time < CONFIG.lockTtlMs) { return null; } const id = `${now}-${Math.random().toString(36).slice(2)}`; GM_setValue(KEY_LOCK, { id, time: now }); return id; } function releaseLock(id) { try { const current = GM_getValue(KEY_LOCK, null); if (current?.id === id) GM_setValue(KEY_LOCK, null); } catch (_) { // 清理锁失败不影响签到结果;超时后会自动失效 } } /* ────────────────────────── 入口 ────────────────────────── */ async function main(options = {}) { const { force = false, source = '定时' } = options; if (!force) { const gate = checkDailyGate(); if (gate.skip) { log(`${source}触发跳过:${gate.reason}`); return `跳过:${gate.reason}`; } } const lockId = acquireLock(); if (!lockId) { const message = '已有签到任务正在运行,本次跳过,避免重复请求'; log(message, 'warn'); throw retryError(message, 60); } // 记录本轮尝试次数(同日累加,跨日归零) const prev = readState(); const attempts = (prev.date === todayKey() ? Number(prev.attempts || 0) : 0) + 1; writeState({ attempts, completed: false, lastAttemptAt: new Date().toISOString() }); const startedAt = new Date().toISOString(); setRunState({ status: 'running', phase: '正在启动', current: 0, total: 0, startedAt }, true); notify('百度贴吧签到已开始', '正在读取登录状态和关注贴吧列表,请稍候查看最终结果。'); try { const result = await runSign(); log(`本轮签到完成:${result.summary}`); notify('百度贴吧签到结果', result.summary); return result.summary; } catch (error) { const message = error instanceof Error ? error.message : String(error); log(`签到任务失败:${message}`, 'error'); const retrying = typeof CATRetryError !== 'undefined' && error instanceof CATRetryError; notify( retrying ? '百度贴吧签到失败,将自动重试' : '百度贴吧签到失败', retrying ? `${message}\nScriptCat 将在稍后再次运行。` : message, ); setRunState({ status: 'failed', phase: '执行失败', summary: message, finishedAt: new Date().toISOString(), }, true); throw error; } finally { flushRunState(); releaseLock(lockId); } } GM_registerMenuCommand('🔎 查看当前运行状态', showRunState); GM_registerMenuCommand('⏱ 立即执行签到(忽略当日去重)', () => { main({ force: true, source: '手动' }).catch(error => { log(`手动执行失败:${error?.message || error}`, 'error'); }); }); return main({ source: '定时' });