// ==UserScript== // @name 百度贴吧自动签到 // @namespace https://github.com/BF/tieba-auto-sign // @version 4.0.1 // @description Chrome/脚本猫后台签到:一键签到优先、逐吧降级、HTTPS 安全重定向、多接口容错、断点续签与可读诊断。浏览器运行期间生效。 // @author BF // @crontab 7,37 * * * * // @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 // @connect tiebac.baidu.com // @connect baidu.com // @license MIT // ==/UserScript== 'use strict'; /** * 百度贴吧自动签到(ScriptCat 后台定时脚本) * * ── 4.0.0 关键改动(对应 3.2.0 的线上故障)────────────────────────── * [P0] 3.2.0 的 tbs 地址 https://tieba.baidu.com/dc/common/tbs 会被百度 * 固定 301 到 http://tieba.baidu.com/dc/common/tbs?red_tag=..., * 原脚本的「非 HTTPS 重定向即拒绝」检查因此每轮必失败。 * 4.0.0 改用 https://c.tieba.baidu.com/dc/common/tbs(实测 HTTP 200 * 直接返回 JSON,无重定向),同时保留严格拒绝:任何 Cookie 请求只要 * 最终落到 HTTP 就丢弃响应,绝不把 BDUSS 暴露在明文连接上。 * [接口] 参考 GitHub noahacgn/tieba-sign-assistant: * - 优先调用网页「一键签到」接口 /c/c/forum/msign,成功即覆盖全部未签吧; * - 一键签到限速(error.errno 340011/340016)或其他失败时,自动降级逐吧签到; * - 关注列表改为 /c/f/forum/forumGuide,直接拿到 is_sign,跳过已签; * - 移动端客户端版本提升到 12.64.1.1,逐吧签到使用完整移动端签名参数集; * - 签到域名 tiebac.baidu.com → c.tieba.baidu.com 双路兜底。 * [容错] tbs 多接口兜底:c.tieba.baidu.com → newmoindex(均 HTTPS)。 * [安全] Cookie 只写不可逆摘要,日志统一脱敏,不把 BDUSS 写进脚本存储。 * [验证] 见同目录 test/run-tests.mjs:签名、重定向拒绝、tbs 兜底、一键签到 * 成功/降级、关注列表分页等用例。 * ──────────────────────────────────────────────────────────────── * * 权限提示:ScriptCat 需在「设置 → 安全 → Cookie」中允许本脚本访问 Cookie, * 否则读不到 BDUSS,会一直在“未获取到 tbs”处失败。 */ (function () { 'use strict'; const SCRIPT_VERSION = '4.0.1'; const CONFIG = { retries: 2, // 单个贴吧首次失败后再重试 2 次,共 3 次 interval: 500, // 贴吧之间的基础间隔(毫秒) jitter: 150, // 间隔随机抖动上限(毫秒) timeout: 20000, // 单次 HTTP 请求超时(毫秒) maxRunMs: 8 * 60 * 1000, // 必须短于 30 分钟触发间隔 retryAfterSeconds: 1800, // 整轮临时失败后交给 ScriptCat 的重试延迟(秒) lockTtlMs: 10 * 60 * 1000, // 大于单次预算;异常中断后自动恢复 tbsCacheMs: 90 * 1000, // tbs 缓存时间(仅在非强制场景生效) notify: true, maxForums: 1000, maxFailureDetails: 12, // 通知里最多展示多少个失败项,完整信息写日志 minRecheckMs: 10 * 60 * 1000,// 同一天内两轮之间的最小间隔 maxDailyAttempts: 6, // 每天最多尝试轮数,避免失败后疯狂刷接口 stateFlushMs: 5000, // 运行状态写盘节流间隔(毫秒) // 4.0.0 新增 preferMultiSign: true, // 优先尝试网页一键签到 multiSignVerifyDelayMs: 800, // 一键签到后等待服务端落库,再核对 is_sign forumPageSize: 200, // forumGuide 每页数量 maxForumPages: 20, // 关注列表分页安全上限 clientVersion: '12.64.1.1', // 2025 移动端接口版本 signSuffix: 'tiebaclient!!!', }; const KEY_STATE = 'tieba_sign_state_v4'; const KEY_LOCK = 'tieba_sign_running_lock_v2'; const KEY_RUN_STATE = 'tieba_sign_run_state_v1'; const API = { // 实测 2026-11-06:c.tieba 的 tbs 接口 HTTP 200,直接返回 JSON。 tbsPrimary: 'https://c.tieba.baidu.com/dc/common/tbs', // 保留旧地址仅用于诊断展示;该地址会 301 到 http,绝不再用于带 Cookie 的请求。 tbsLegacy: 'https://tieba.baidu.com/dc/common/tbs', newmo: 'https://tieba.baidu.com/mo/q/newmoindex', forumGuide: 'https://tieba.baidu.com/c/f/forum/forumGuide', msign: 'https://tieba.baidu.com/c/c/forum/msign', like: 'https://c.tieba.baidu.com/c/f/forum/like', signHosts: [ 'https://tiebac.baidu.com/c/c/forum/sign', 'https://c.tieba.baidu.com/c/c/forum/sign', ], }; const MOBILE = { clientId: 'wappc_1534235498291_488', clientVersion: '9.7.8.0', // 旧版 like 接口使用;新签接口统一用 CONFIG.clientVersion phoneImei: '000000000000000', model: 'MI+5', netType: '1', from: '1008621y', vcodeTag: '11', }; 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', '1990059']); // 340006 吧不存在/未开放;1990059 未实名等不可重试错误 const MULTI_SIGN_RATE_LIMIT_CODES = new Set(['340011', '340016']); // 一键签到太快 / 频控 /* ────────────────────────── 基础设施 ────────────────────────── */ function redact(text) { return String(text).replace(/\b(BDUSS(?:_BFESS)?|STOKEN(?:_BFESS)?|PTOKEN(?:_BFESS)?|tbs)\s*[=:]\s*["']?[^\s;,"'}&]+/gi, '$1=[已隐藏]'); } function log(message, level = 'info') { const line = `[百度贴吧签到] ${redact(message)}`; try { GM_log(line, level); } catch (_) { console.log(line); } } function notify(title, text) { if (!CONFIG.notify) return; try { GM_notification({ title, text: redact(text), timeout: 10000 }); } catch (_) { log(`${title}: ${text}`); } } /** * 统一提取错误信息。 * 关键:ScriptCat 的 CATRetryError 与脚本不在同一个 realm,`instanceof Error` * 为 false,走 String(error) 只会得到 "[object Object]"——所有关键诊断 * (未登录、拿不到 tbs)都会被吞掉,日志里只剩一句无用的 [object Object]。 */ function errorMessage(error, depth = 0) { if (!error) return '未知错误'; if (typeof error === 'string') return error; if (typeof error !== 'object') return String(error); const direct = error.message || error.msg || error.reason || error.detail; if (typeof direct === 'string' && direct.trim()) return direct; if (depth < 3) { for (const nested of [direct, error.error, error.cause]) { if (nested && nested !== error) return errorMessage(nested, depth + 1); } } try { const json = JSON.stringify(error); if (json && json !== '{}') return json.slice(0, 300); } catch (_) { // 循环引用,落到下面 } const text = String(error); return text === '[object Object]' ? '未知错误(对象无 message 字段)' : text; } function truncate(text, length = 160) { const value = String(text || '').replace(/\s+/g, ' ').trim(); return value.length > length ? `${value.slice(0, length)}…` : value; } let runDeadline = 0; function budgetError() { const error = new Error('达到单次运行时间预算,已保存成功进度,下次定时继续'); error.name = 'RunBudgetError'; return error; } function checkBudget(ms = 0) { if (runDeadline && Date.now() + ms >= runDeadline) throw budgetError(); } function delay(ms) { checkBudget(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) { if (typeof response?.responseText === 'string' && response.responseText) return response.responseText; if (typeof response?.response === 'string' && response.response) return response.response; return ''; } function responseJson(response) { const value = response?.response; if (value && typeof value === 'object') return value; 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 ''; } /** * 统一解析贴吧接口错误。兼容三种形状: * {"error_code":"0","error_msg":""} * {"error":{"errno":340011,"errmsg":"签得太快"}} * {"no":1,"error":"not logined!"} */ function apiError(json) { const payload = responsePayload(json); const error = (json && typeof json.error === 'object' && json.error) || (payload && typeof payload.error === 'object' && payload.error) || {}; return { code: firstFilled( json?.error_code, json?.no, json?.code, payload?.error_code, payload?.no, payload?.code, ), errno: firstFilled(error.errno, json?.errno, payload?.errno), message: firstFilled( typeof json?.error_msg === 'string' ? json.error_msg : '', typeof json?.error === 'string' ? json.error : '', typeof json?.message === 'string' ? json.message : '', typeof payload?.error_msg === 'string' ? payload.error_msg : '', typeof payload?.message === 'string' ? payload.message : '', typeof error.errmsg === 'string' ? error.errmsg : '', typeof error.usermsg === 'string' ? error.usermsg : '', ), }; } function responseCode(json) { return apiError(json).code; } function responseMessage(json) { return apiError(json).message; } 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 payload = responsePayload(json); const fields = Object.keys(json).slice(0, 8).join(',') || '非 JSON'; const details = [`HTTP ${statusOf(response) || '无响应'}`, `字段 ${fields}`]; const error = apiError(json); if (error.code) details.push(`code/no ${error.code.slice(0, 40)}`); if (error.message) details.push(`消息 ${error.message.replace(/[\r\n]+/g, ' ').slice(0, 120)}`); const login = json.is_login ?? payload?.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(''); } /** 贴吧移动端签名:参数按 key 升序拼接,追加固定盐后取 MD5 大写。 */ function signParams(pairs) { const sorted = [...pairs].sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0)); const raw = sorted.map(([key, value]) => `${key}=${value}`).join('') + CONFIG.signSuffix; return md5Hex(raw).toUpperCase(); } function mobileSigned(data) { const raw = Object.keys(data).sort().map(key => `${key}=${data[key]}`).join('') + CONFIG.signSuffix; 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, version: SCRIPT_VERSION, date: patch.date || todayKey() }); } catch (error) { log(`保存签到状态失败:${errorMessage(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) { setRunState.pending = null; GM_setValue(KEY_RUN_STATE, { ...GM_getValue(KEY_RUN_STATE, {}), ...patch }); runStateDirtyAt = now; } else { // 节流期间只保留在内存,收尾时强制写一次。 setRunState.pending = patch; } } catch (error) { log(`保存运行状态失败:${errorMessage(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}`, ); } /* ────────────────────────── 调度闸门 ────────────────────────── */ /** * 3.2.0 的旧状态没有 version 字段,可能带着“今日已尝试 6 轮”的计数进入 4.x; * 这会让升级后的第一轮直接跳过。升级时重置失败计数,但保留已签进度和完成状态。 */ function migrateState() { const state = readState(); if (state.version === SCRIPT_VERSION) return; if (state.date === todayKey() && state.completed) { writeState({}); return; } writeState({ attempts: 0, completed: false, lastAttemptAt: null, summary: '' }); log(`检测到旧版状态,已重置今日尝试计数(${SCRIPT_VERSION})`, 'warn'); } /** * 判断本轮是否应该跳过。 * 依赖 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) { const error = new Error(message); error.retryAfter = seconds; return error; } /** * 所有请求的唯一入口。安全策略: * 只要响应最终落到非 HTTPS,就丢弃响应并报错——绝不让带 Cookie 的响应正文 * 被业务代码使用。tbs 选用不会重定向的 c.tieba 域名,正常路径不会触发。 */ function request(method, url, data, options = {}) { return new Promise((resolve, reject) => { checkBudget(1000); let endpoint; try { endpoint = new URL(url); } catch (_) { reject(new Error(`无效请求地址:${url}`)); return; } const context = `${method} ${endpoint.hostname}${endpoint.pathname}`; const remaining = runDeadline ? Math.max(1, runDeadline - Date.now()) : CONFIG.timeout; const details = { method, url, timeout: Math.max(1, Math.min(CONFIG.timeout, remaining)), anonymous: false, headers: { Accept: 'application/json, text/plain, */*', Referer: TIEBA_HOME, ...(options.headers || {}), }, onload: response => { const finalUrl = typeof response?.finalUrl === 'string' ? response.finalUrl : ''; if (finalUrl) { let protocol = ''; try { protocol = new URL(finalUrl).protocol; } catch (_) { // 无法解析时按危险处理 } if (protocol && protocol !== 'https:') { reject(new Error(`${context} 被重定向到非 HTTPS 地址(${finalUrl}),已丢弃响应以保护 Cookie;请检查网络或代理`)); return; } } resolve(response); }, onerror: error => reject(new Error(`${context} 网络或权限错误:${errorMessage(error)}`)), ontimeout: () => reject(new Error(`${context} 请求超时`)), onabort: () => reject(new Error(`${context} 请求被中止`)), }; if (data !== undefined) details.data = data; if (options.cookie) { // anonymous + 显式 cookie:只发送脚本读取到的登录 Cookie,避免后台请求 // 混入浏览器里其它账号的 Cookie。 details.cookie = options.cookie; details.anonymous = true; } else if (options.anonymous) { // 诊断旧地址等无需登录态的探测必须匿名,禁止浏览器自动附带 BDUSS。 details.anonymous = true; } try { GM_xmlhttpRequest(details); } catch (error) { reject(new Error(`${context} 调用失败:${errorMessage(error)}`)); } }); } 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})`); } } async function requestJson(method, url, data, options, operation) { const response = await request(method, url, data, options); ensureHttpSuccess(response, operation); const text = responseText(response); const json = parseJson(text); if (!json || typeof json !== 'object') { throw new Error(`${operation}失败:返回非 JSON(HTTP ${statusOf(response)},${truncate(text, 120)})`); } return { response, json }; } /* ────────────────────────── Cookie 读取 ────────────────────────── */ function listCookies(details) { return new Promise((resolve, reject) => { const modernApi = typeof GM !== 'undefined' && typeof GM?.cookie?.list === 'function' ? query => GM.cookie.list(query) : typeof GM !== 'undefined' && typeof GM?.cookie === 'function' ? query => GM.cookie('list', query) : 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(details)) .then(cookies => finish(resolve, Array.isArray(cookies) ? cookies : [])) .catch(error => finish(reject, new Error(`读取贴吧 Cookie 失败:${errorMessage(error)}`))); return; } const list = typeof legacyApi === 'function' ? (query, callback) => legacyApi('list', query, callback) : legacyApi && typeof legacyApi.list === 'function' ? (query, callback) => legacyApi.list(query, callback) : null; if (!list) { finish(reject, new Error('ScriptCat 未提供 GM.cookie.list 或 GM_cookie 接口')); return; } try { list(details, (cookies, error) => { if (error) { finish(reject, new Error(`读取贴吧 Cookie 失败:${errorMessage(error)}`)); return; } finish(resolve, Array.isArray(cookies) ? cookies : []); }); } catch (error) { finish(reject, new Error(`调用 GM_cookie 失败:${errorMessage(error)}`)); } }); } const COOKIE_WHITELIST = new Set([ 'BDUSS', 'BDUSS_BFESS', 'STOKEN', 'STOKEN_BFESS', 'BAIDUID', 'BAIDUID_BFESS', 'PTOKEN', 'PTOKEN_BFESS', ]); /** * BDUSS 有时挂在 .baidu.com 而不是 .tieba.baidu.com 上。 * 只按 url 查一次,某些 ScriptCat 版本会把它按 domain 精确匹配掉,导致 * “登录了却读不到 BDUSS”。这里按 url + 父域各查一次再合并。 */ const COOKIE_QUERIES = [ { url: TIEBA_HOME }, { domain: 'baidu.com' }, ]; async function readTiebaCookie() { const found = new Map(); const errors = []; for (const query of COOKIE_QUERIES) { checkBudget(CONFIG.timeout); try { const cookies = await listCookies(query); for (const cookie of cookies) { if (!cookie || !COOKIE_WHITELIST.has(cookie.name)) continue; const domain = String(cookie.domain || '').replace(/^\./, '').toLowerCase(); if (domain && !['baidu.com', 'tieba.baidu.com', 'c.tieba.baidu.com'].includes(domain)) continue; if (cookie.expirationDate && cookie.expirationDate <= Date.now() / 1000) continue; if (cookie.value === undefined || cookie.value === null) continue; if (!found.has(cookie.name) || !found.get(cookie.name)) { found.set(cookie.name, cookie.value); } } } catch (error) { errors.push(errorMessage(error)); } } return { cookie: [...found].map(([name, value]) => `${name}=${value}`).join('; '), names: [...found.keys()], errors, }; } /* ────────────────────────── 登录态 ────────────────────────── */ 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 ? value : ''; } /** * 4.0.0 的 tbs 获取策略: * 1) https://c.tieba.baidu.com/dc/common/tbs —— 实测无重定向,首选; * 2) https://tieba.baidu.com/mo/q/newmoindex —— HTTPS,返回 data.tbs 兜底。 * 不再请求 https://tieba.baidu.com/dc/common/tbs:它固定 301 到 http, * 带 Cookie 跟随会把 BDUSS 暴露在明文链路,必须直接放弃。 */ async function getTbs(force = false) { if (!force && authCache && Date.now() - authCache.time < CONFIG.tbsCacheMs) { return authCache.value; } if (force) authCache = null; const read = await readTiebaCookie(); if (read.errors.length) log(`读取 Cookie 出现异常:${read.errors.join(' / ')}`, 'warn'); const cookie = read.cookie; const attempts = []; if (cookie) { const probes = [ { name: 'c.tieba', url: API.tbsPrimary, operation: '读取登录状态(c.tieba)' }, { name: 'newmoindex', url: `${API.newmo}?t=${Date.now()}`, operation: '读取登录状态(newmoindex)' }, ]; for (const probe of probes) { try { const { response, json } = await requestJson('GET', probe.url, undefined, { cookie }, probe.operation); const payload = responsePayload(json); const value = { source: probe.name, isLogin: isLoggedIn(json), tbs: extractTbs(json), cookie, cookieNames: read.names, cookieCount: cookieCount(cookie), status: statusOf(response), diagnostic: `${probe.name}:${responseDiagnostic(response)}`, }; if (value.isLogin && value.tbs) { authCache = { time: Date.now(), value }; return value; } attempts.push(`${probe.name}:HTTP ${value.status || '无响应'},is_login=${String(payload?.is_login ?? json?.is_login ?? '未返回')},tbs=${value.tbs ? '有' : '无'}`); } catch (error) { if (error?.name === 'RunBudgetError') throw error; attempts.push(`${probe.name}:${errorMessage(error)}`); } } } else { attempts.push('未读取到任何贴吧 Cookie'); } const names = read.names; const hint = names.some(name => name.startsWith('BDUSS')) ? 'BDUSS 已读到但服务端仍判未登录,登录可能已过期,请重新登录 https://tieba.baidu.com/' : '没有 BDUSS,当前浏览器未登录贴吧,请打开 https://tieba.baidu.com/ 登录后重试'; throw retryError( `未取得 tbs:${attempts.join('|')}|已读 Cookie [${names.join(',') || '无'}]|${hint}`, CONFIG.retryAfterSeconds, ); } /* ────────────────────────── 关注列表 ────────────────────────── */ 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) { const name = String(item?.forum_name || item?.name || item?.kw || item?.forumName || '').trim(); const id = String(item?.forum_id || item?.fid || item?.id || item?.forumId || name); const rawSign = item?.is_sign ?? item?.isSign ?? item?.signed; return { id, name, isSign: rawSign === true || rawSign === 1 || rawSign === '1' || rawSign === 'true', }; } 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?.page?.has_more ?? payload?.page_info?.has_more ?? payload?.more; return value === true || value === 1 || value === '1' || value === 'true'; } function dedupeForums(forums, limit = CONFIG.maxForums) { const seen = new Map(); for (const forum of forums) { if (!forum?.name) continue; if (!seen.has(forum.id)) seen.set(forum.id, forum); if (seen.size >= limit) break; } return [...seen.values()]; } /** 4.0.0 首选:网页版 forumGuide,直接返回 is_sign,可跳过已签贴吧。 */ async function getForumsViaGuide(tbs, cookie) { const forums = []; for (let page = 1; page <= CONFIG.maxForumPages; page += 1) { checkBudget(CONFIG.timeout); const body = new URLSearchParams({ tbs, sort_type: '3', call_from: '3', page_no: String(page), res_num: String(CONFIG.forumPageSize), }).toString(); const { response, json } = await requestJson('POST', API.forumGuide, body, { cookie, headers: { 'Content-Type': 'application/x-www-form-urlencoded', 'Subapp-Type': 'hybrid', }, }, '获取关注列表(forumGuide)'); const error = apiError(json); if (error.code && error.code !== '0') { throw new Error(`forumGuide 获取关注列表失败:${error.message || error.code}`); } const payload = responsePayload(json); const list = payload?.like_forum || payload?.likeForum || json?.like_forum || json?.likeForum; if (!Array.isArray(list)) { throw new Error(`forumGuide 未返回 like_forum:${responseDiagnostic(response)}`); } forums.push(...list.map(normalizeForum).filter(item => item.name)); const hasMoreRaw = payload?.like_forum_has_more ?? payload?.likeForumHasMore ?? json?.like_forum_has_more; // 接口字段可能是 0/1,也可能是 "0"/"1";字符串 "0" 也是 false。 const hasMore = hasMoreRaw === true || hasMoreRaw === 1 || hasMoreRaw === '1' || hasMoreRaw === 'true'; if (!hasMore) return dedupeForums(forums); if (page === CONFIG.maxForumPages) throw new Error('forumGuide 分页超过安全上限'); await delay(200); } return dedupeForums(forums); } async function getForumsByLike(bduss, cookie) { const forums = []; for (let page = 1; page <= CONFIG.maxForumPages && 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; if (page === CONFIG.maxForumPages || forums.length >= CONFIG.maxForums) { throw new Error('关注列表超过本轮安全上限,不能把不完整列表标记为全部完成'); } await delay(CONFIG.interval); } return dedupeForums(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 error = apiError(json); if (error.code && error.code !== '0') { throw new Error(`newmoindex 接口获取关注列表失败:${error.message || responseDiagnostic(response)}`); } const payload = responsePayload(json); const section = payload?.like_forum ?? payload?.likeForum ?? payload?.forum_list; const list = Array.isArray(section) ? section : section?.list ?? section?.forum_list; if (!Array.isArray(list)) { throw new Error(`newmoindex 未返回关注列表:${responseDiagnostic(response)}`); } if (hasMoreForums(section) || list.length > CONFIG.maxForums) throw new Error('newmoindex 关注列表不完整'); return dedupeForums(list.map(normalizeForum).filter(item => item.name)); } async function getForums(bduss, tbs, cookie) { let forums = []; const errors = []; // 1) forumGuide:最新网页接口,带 is_sign,正常路径首选 try { forums = await getForumsViaGuide(tbs, cookie); if (forums.length) return forums; // 返回空数组时继续尝试其它接口,避免接口字段变动被误判为“没有关注” errors.push('forumGuide:返回 0 个贴吧'); } catch (error) { if (error?.name === 'RunBudgetError') throw error; errors.push(`forumGuide:${errorMessage(error)}`); log(`forumGuide 获取关注列表失败,尝试降级:${errorMessage(error)}`, 'warn'); } // 2) 旧版移动端 like 接口 try { forums = await getForumsByLike(bduss, cookie); if (forums.length) return forums; errors.push('like:返回 0 个贴吧'); } catch (error) { if (error?.name === 'RunBudgetError') throw error; errors.push(`like:${errorMessage(error)}`); log(`like 接口获取关注列表失败,尝试降级:${errorMessage(error)}`, 'warn'); } // 3) newmoindex 兜底 try { forums = await getForumsByNewmo(cookie); if (forums.length) { log(`降级接口获取到 ${forums.length} 个贴吧`, 'warn'); return forums; } errors.push('newmoindex:返回 0 个贴吧'); } catch (error) { if (error?.name === 'RunBudgetError') throw error; errors.push(`newmoindex:${errorMessage(error)}`); log(`newmoindex 降级接口同样失败:${errorMessage(error)}`, 'warn'); } if (errors.length) throw new Error(`无法读取关注列表;${errors.join(';')}`); return []; } /* ────────────────────────── 一键签到 ────────────────────────── */ /** * 网页一键签到。参考 noahacgn/tieba-sign-assistant 的实现: * 只带 _client_version 与 subapp_type,成功即覆盖全部关注贴吧; * error.errno 340011/340016 表示签得太快/频控,需要降级逐吧签到。 */ async function multiSign(cookie) { const body = new URLSearchParams({ _client_version: CONFIG.clientVersion, subapp_type: 'hybrid', }).toString(); const response = await request('POST', API.msign, body, { cookie, headers: { 'Content-Type': 'application/x-www-form-urlencoded', 'Subapp-Type': 'hybrid', }, }); ensureHttpSuccess(response, '一键签到'); const json = responseJson(response); if (!json || typeof json !== 'object' || !Object.keys(json).length) { throw new Error(`一键签到返回空响应或非 JSON:${responseDiagnostic(response)}`); } const error = apiError(json); if (error.code && error.code !== '0') { return { ok: false, errno: error.code, message: error.message || `error_code=${error.code}` }; } if (error.errno && error.errno !== '0') { if (MULTI_SIGN_RATE_LIMIT_CODES.has(error.errno)) { return { ok: false, errno: error.errno, message: error.message || `errno=${error.errno}` }; } throw new Error(`一键签到失败:${error.message || error.errno}`); } return { ok: true }; } /* ────────────────────────── 签到 ────────────────────────── */ function signResult(response) { const status = statusOf(response); if (status === 401 || status === 403) return { type: 'auth', reason: `HTTP ${status}` }; if (status === 429) { return { type: 'retryable', reason: 'HTTP 429 限流', retryAfter: Math.max(60, Number(responseHeader(response, 'Retry-After')) || 0), }; } if (status >= 500 || status === 0) return { type: 'retryable', reason: `HTTP ${status || '无响应'}` }; if (status < 200 || status >= 300) return { type: 'fail', reason: `HTTP ${status}` }; const json = responseJson(response); // 200 但空响应/HTML 登录页时不能当成“没有错误码=成功”,否则会漏签还记成功。 if (!json || typeof json !== 'object' || !Object.keys(json).length) { return { type: 'retryable', reason: `签到返回空响应或非 JSON:${responseDiagnostic(response)}` }; } const error = apiError(json); const message = error.message; if (/未登录|请先登录|登录失效|登录已过期|login required|not logged/i.test(message)) { return { type: 'auth', reason: message }; } const code = error.code; const errno = error.errno; // 贴吧可能返回 error_code:"0" 但同时带 error.errno(一键签到限速就是这样), // 必须先看嵌套 errno,否则会被误判为成功。 if (MULTI_SIGN_RATE_LIMIT_CODES.has(errno)) { return { type: 'retryable', reason: message || `errno=${errno}`, retryAfter: 60 }; } if (errno && errno !== '0' && code === '0') { return { type: 'retryable', reason: message || `errno=${errno}` }; } if (SIGN_OK_CODES.has(code) || (!code && !errno)) { const payload = responsePayload(json); const bonus = Number(payload?.user_info?.sign_bonus_point ?? json?.user_info?.sign_bonus_point); if (Number.isFinite(bonus) && bonus === 0) { return { type: 'already', reason: 'sign_bonus_point=0(重复签到或频率限制)' }; } return { type: 'ok' }; } if (SIGN_ALREADY_CODES.has(code) || /已签到|已经签到|重复签到|already\s+(?:signed|checked)/i.test(message)) { return { type: 'already', reason: message || `error_code=${code}` }; } if (FORUM_FAIL_CODES.has(code) || /未开放|不支持签到|不存在|已关闭|被封禁|无权访问|吧不存在/i.test(message)) { return { type: 'fail', reason: message || `error_code=${code}` }; } if (MULTI_SIGN_RATE_LIMIT_CODES.has(code)) { return { type: 'retryable', reason: message || `error_code=${code}`, retryAfter: 60 }; } // 未知错误按临时处理,避免偶发格式/服务异常被 crontab 吞掉。 return { type: 'retryable', reason: message || responseDiagnostic(response) }; } function signRequestBody(bduss, tbs, forum) { // 参数集参考 LuoSign(300+ stars,GitHub Actions 日更签到): // 移动端要求 _client_type/_phone_imei/model/net_type/fid/timestamp 一并参与签名。 const pairs = [ ['BDUSS', bduss], ['_client_type', '2'], ['_client_version', CONFIG.clientVersion], ['_phone_imei', MOBILE.phoneImei], // fid 只在拿到数字贴吧 ID 时参与签名;部分降级接口只有吧名,传错 fid 会导致签名失配。 ['fid', /^\d+$/.test(String(forum.id || '')) ? forum.id : ''], ['kw', forum.name], ['model', MOBILE.model], ['net_type', MOBILE.netType], ['tbs', tbs], ['timestamp', String(Math.floor(Date.now() / 1000))], ].filter(([, value]) => value !== ''); const body = new URLSearchParams(); for (const [key, value] of pairs) body.append(key, value); body.append('sign', signParams(pairs)); return body.toString(); } async function signForum(forum, bduss, tbs, cookie) { let lastError = ''; for (const endpoint of API.signHosts) { try { const body = signRequestBody(bduss, tbs, forum); const response = await request('POST', endpoint, body, { cookie }); const result = signResult(response); if (['ok', 'already', 'auth', 'fail'].includes(result.type)) return result; lastError = `${new URL(endpoint).hostname}:${result.reason || '临时失败'}`; } catch (error) { if (error?.name === 'RunBudgetError') throw error; lastError = `${new URL(endpoint).hostname}:${errorMessage(error)}`; } } return { type: 'retryable', reason: lastError || '所有签到域名均失败' }; } /** * authRef 在整个轮次里共享登录态: * 正常签到直接复用,只有某个吧重试时才强制刷新一次 tbs。 */ 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 (['ok', 'already', 'auth', 'fail'].includes(last.type)) return last; // 限流交给下一轮,不让一个贴吧占住后台长时间等待。 if (Number(last.retryAfter) >= 60) return last; if (attempt < CONFIG.retries) { log(`${forum.name} 第 ${attempt + 1} 次失败:${last.reason};刷新 tbs 后重试`, 'warn'); await delay(backoffMs(attempt, last.retryAfter)); } } catch (error) { if (error?.name === 'RunBudgetError') throw error; const message = errorMessage(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.cookieNames.join(',') || '无'}]` + `|${auth.cookieNames.some(name => name.startsWith('BDUSS')) ? 'BDUSS 已读到但服务端仍判未登录,可能登录已过期,请重新登录' : '没有 BDUSS,当前浏览器未登录贴吧,请打开 https://tieba.baidu.com/ 登录后重试'}`, CONFIG.retryAfterSeconds, ); } const bduss = cookieValue(auth.cookie, 'BDUSS') || cookieValue(auth.cookie, 'BDUSS_BFESS'); if (!bduss) { throw retryError(`未读取到 BDUSS Cookie:${auth.diagnostic};Cookie 数量 ${auth.cookieCount}`, CONFIG.retryAfterSeconds); } const runDate = todayKey(); // 只保存不可逆摘要,不把登录 Cookie 写进脚本存储。 const account = md5Hex(bduss).slice(0, 16); const previous = readState(); const signed = new Set(previous.date === runDate && previous.account === account && Array.isArray(previous.signedIds) ? previous.signedIds.map(String) : []); writeState({ account, signedIds: [...signed] }); const forums = await getForums(bduss, auth.tbs, 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, saved: 0, fail: 0, auth: 0, retryable: 0, multi: 0, failed: [], stoppedEarly: false }; const pending = []; for (const forum of forums) { if (signed.has(forum.id)) { result.saved += 1; continue; } if (forum.isSign) { result.already += 1; signed.add(forum.id); continue; } pending.push(forum); } writeState({ account, signedIds: [...signed] }); if (pending.length && CONFIG.preferMultiSign) { setRunState({ status: 'running', phase: '一键签到', current: result.saved + result.already, total: forums.length }, true); const before = pending.length; try { const multi = await multiSign(auth.cookie); if (multi.ok) { log(`一键签到接口返回成功,覆盖 ${before} 个未签贴吧`); await delay(CONFIG.multiSignVerifyDelayMs); try { const verified = await getForumsViaGuide(auth.tbs, auth.cookie); const verifiedMap = new Map(verified.map(forum => [forum.id, forum])); for (let i = pending.length - 1; i >= 0; i -= 1) { const fresh = verifiedMap.get(pending[i].id); if (fresh?.isSign) { signed.add(pending[i].id); pending.splice(i, 1); } } } catch (error) { if (error?.name === 'RunBudgetError') throw error; // 核验失败不能假定“一键签到真的签上了”,否则会把未签的贴吧记成成功; // 保留 pending 走逐吧签到,已签的贴吧会返回 already,代价只是多几次请求。 log(`一键签到后核验失败,继续逐吧补签:${errorMessage(error)}`, 'warn'); } result.multi = before - pending.length; writeState({ account, signedIds: [...signed] }); } else { log(`一键签到未成功,降级逐吧签到:${multi.message}`, 'warn'); } } catch (error) { if (error?.name === 'RunBudgetError') throw error; log(`一键签到接口异常,降级逐吧签到:${errorMessage(error)}`, 'warn'); } } let processed = result.saved + result.already + result.multi; if (pending.length) { setRunState({ status: 'running', phase: '签到中', current: processed, total: forums.length }, true); } for (let index = 0; index < pending.length; index += 1) { checkBudget(CONFIG.timeout); if (todayKey() !== runDate) throw new Error('日期已变化,已保存此前进度,下次按新日期运行'); const forum = pending[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'); 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'); } if (current.type === 'ok' || current.type === 'already') { signed.add(forum.id); writeState({ account, signedIds: [...signed] }); } processed += 1; setRunState({ status: 'running', phase: '签到中', current: processed, total: forums.length, partialSummary: `成功 ${result.ok},已签 ${result.already},失败 ${result.fail + result.auth + result.retryable}`, }); if (Number(current.retryAfter) >= 60) { result.stoppedEarly = true; log('服务端限流,停止本轮批量请求,下次定时补签', 'warn'); break; } if (index < pending.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}` + (result.multi ? `,一键 ${result.multi}` : '') + `,失败 ${failedCount}` + (result.saved ? `,本地已完成 ${result.saved}` : '') + (result.stoppedEarly ? '(本轮提前结束,稍后补签)' : '') + (result.failed.length ? `(${shortDetails(result.failed)})` : ''); const completed = failedCount === 0 && !result.stoppedEarly && result.ok + result.already + result.saved + result.multi === forums.length; writeState({ completed, account, signedIds: [...signed], 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 (_) { // 清理锁失败不影响签到结果;超时后会自动失效 } } /* ────────────────────────── 诊断 ────────────────────────── */ /** 一键体检:把“能不能读到 cookie、服务端认不认登录”直接弹出来。 */ async function diagnose() { try { const read = await readTiebaCookie(); let authText = ''; try { const auth = await getTbs(true); authText = [ `tbs 来源:${auth.source}`, `is_login:true`, `tbs 长度:${auth.tbs.length}`, '结论:登录态正常,可以正常签到', ].join('\n'); } catch (error) { authText = `登录检查:${errorMessage(error)}`; } const text = [ `Cookie 读取:${read.names.length ? '成功' : '空'}`, `已读 Cookie:${read.names.join(', ') || '无'}`, `读取异常:${read.errors.length ? read.errors.join(' / ') : '无'}`, authText, ].join('\n'); log(`诊断结果\n${text}`, 'warn'); notify('贴吧签到诊断', text); return text; } catch (error) { const message = errorMessage(error); log(`诊断失败:${message}`, 'error'); notify('贴吧签到诊断失败', message); return message; } } /** 接口连通性诊断:区分“百度固定 301”与“本机网络/代理问题”。 */ async function diagnoseNetwork() { const lines = []; const checks = [ ['c.tieba tbs(首选)', API.tbsPrimary], ['tieba tbs(预期 301→http)', API.tbsLegacy], ['newmoindex(兜底)', `${API.newmo}?t=${Date.now()}`], ]; for (const [name, url] of checks) { try { const response = await request('GET', url, undefined, { anonymous: true }); lines.push(`${name}:HTTP ${statusOf(response)},final=${response?.finalUrl || url},${truncate(responseText(response), 120)}`); } catch (error) { lines.push(`${name}:${errorMessage(error)}`); } } const text = lines.join('\n'); log(`接口诊断\n${text}`, 'warn'); notify('贴吧接口诊断', text); return text; } /* ────────────────────────── 入口 ────────────────────────── */ async function main(options = {}) { const { force = false, source = '定时' } = options; migrateState(); 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'); return message; } runDeadline = Date.now() + CONFIG.maxRunMs; // 记录本轮尝试次数(同日累加,跨日归零) 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(); log(`${source}开始执行:第 ${attempts} 轮,单次最多 ${CONFIG.maxRunMs / 60000} 分钟`); 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 = redact(errorMessage(error)); log(`签到任务失败:${message}`, 'error'); notify( '百度贴吧签到未完成', `${message}\n已完成的贴吧会保留,下次每小时 07/37 分补签。`, ); setRunState({ status: 'failed', phase: '执行失败', summary: message, finishedAt: new Date().toISOString(), }, true); writeState({ completed: false, summary: message }); // 错误已完整记录;不把 ScriptCat 的跨上下文对象抛给手动运行器。 return `未完成:${message}`; } finally { flushRunState(); releaseLock(lockId); runDeadline = 0; } } // 测试钩子:Node 单元测试通过它拿到内部函数,不会注册菜单或自动执行。 if (typeof globalThis !== 'undefined' && globalThis.__TIEBA_SIGN_TEST__) { globalThis.__TIEBA_SIGN_TEST__.api = { CONFIG, API, MOBILE, md5Hex, signParams, mobileSigned, request, requestJson, responseJson, responseText, apiError, signResult, getTbs, readTiebaCookie, getForums, getForumsViaGuide, getForumsByLike, getForumsByNewmo, multiSign, signForum, signForumWithRetry, runSign, main, checkDailyGate, migrateState, readState, writeState, setAuthCache: value => { authCache = value; }, resetAuthCache: () => { authCache = null; }, }; return; } GM_registerMenuCommand('🔎 查看当前运行状态', showRunState); GM_registerMenuCommand('🩺 诊断登录状态', () => { diagnose().catch(() => {}); }); GM_registerMenuCommand('🌐 诊断接口连通性(301/HTTPS)', () => { diagnoseNetwork().catch(() => {}); }); GM_registerMenuCommand('⏱ 立即执行签到(忽略当日去重)', () => { main({ force: true, source: '手动' }).catch(error => { log(`手动执行失败:${errorMessage(error)}`, 'error'); }); }); GM_registerMenuCommand('♻️ 重置今日尝试次数', () => { writeState({ attempts: 0, completed: false, lastAttemptAt: null, summary: '' }); notify('百度贴吧签到', '已重置今日尝试次数,可重新执行签到。'); log('已手动重置今日尝试次数,保留已签进度', 'warn'); }); return main({ source: '定时' }); })();