// ==UserScript== // @name 百度贴吧自动签到 // @version 3.0.1 // @description 电脑端 ScriptCat 百度贴吧自动签到:使用贴吧手机端协议接口,定时签到全部关注的贴吧,支持重试与异常通知。 // @author BF // @crontab * * once * * // @match https://tieba.baidu.com/* // @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 后台定时脚本) * * 说明:这里的“手机端协议”指贴吧服务端接口版本,不代表脚本需要在手机上运行。 * 脚本实际运行在电脑浏览器的 ScriptCat 中,使用电脑浏览器已有的百度登录 Cookie。 * * 调度:* * once * * 表示每天至多成功执行一次。浏览器和 ScriptCat 完全关闭时 * 不可能发起请求;重新启动后的当天补执行行为以 ScriptCat 当前版本为准。 * * 本版优化: * 1. 使用 ScriptCat 标准 GM_cookie("list", details, callback) 读取登录 Cookie; * 2. 同一次运行缓存 tbs,只有签到重试时才刷新,减少请求量; * 3. 增加 HTTP 状态分类、429/5xx 退避和网络错误重试; * 4. 部分临时失败时通过 CATRetryError 请求 ScriptCat 延迟重试; * 5. 增加运行锁,避免手动运行和定时运行重叠; * 6. 仅记录成功/已签到的完整任务,部分失败不会被误记为完成。 */ const CONFIG = { retries: 2, // 单个贴吧首次失败后再重试 2 次,共 3 次 interval: 500, // 贴吧之间的基础间隔(毫秒) timeout: 20000, // 单次 HTTP 请求超时(毫秒) retryAfterSeconds: 1800, // 整轮临时失败后交给 ScriptCat 的重试延迟(秒) lockTtlMs: 45 * 60 * 1000, // 运行锁最长有效期,防止异常中断后永久锁死 tbsCacheMs: 90 * 1000, // tbs 缓存时间 notify: true, maxForums: 1000, maxFailureDetails: 12, // 通知最多展示多少个失败项,完整信息写入日志 }; const KEY_STATE = 'tieba_sign_state_v3'; 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', }; 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/'; let authCache = null; function log(message, level = 'info') { try { GM_log(`[百度贴吧签到] ${message}`, level); } catch (_) { console.log(`[百度贴吧签到] ${message}`); } } 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; } function responseCode(json) { const payload = responsePayload(json); return String(json?.error_code ?? json?.no ?? json?.code ?? payload?.error_code ?? payload?.no ?? payload?.code ?? ''); } function responseMessage(json) { const payload = responsePayload(json); return String(json?.error_msg || json?.error || json?.message || payload?.error_msg || payload?.error || payload?.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 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 = json.no ?? json.code ?? data?.no ?? data?.code; const message = json.error || json.message || data?.error || data?.message; if (code !== undefined) details.push(`code/no ${String(code).slice(0, 40)}`); if (message) details.push(`消息 ${String(message).replace(/[\r\n]+/g, ' ').slice(0, 120)}`); if (json.is_login !== undefined || data?.is_login !== undefined) { details.push(`登录标志 ${String(json.is_login ?? data?.is_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] : ''; } 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; new DataView(buffer.buffer).setUint32(paddedLength - 8, bitLength >>> 0, true); const shifts = [7,12,17,22, 5,9,14,20, 4,11,16,23, 6,10,15,21]; const constants = Array.from({ length: 64 }, (_, i) => Math.floor(Math.abs(Math.sin(i + 1)) * 0x100000000) >>> 0); let a0 = 0x67452301; let b0 = 0xefcdab89; let c0 = 0x98badcfe; let d0 = 0x10325476; const view = new DataView(buffer.buffer); for (let offset = 0; offset < paddedLength; offset += 64) { const words = Array.from({ length: 16 }, (_, i) => view.getUint32(offset + i * 4, true)); 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) % 16; } else if (i < 48) { f = b ^ c ^ d; g = (3 * i + 5) % 16; } else { f = c ^ (b | (~d)); g = (7 * i) % 16; } const shift = shifts[Math.floor(i / 16) * 4 + (i % 4)]; const sum = (a + f + constants[i] + words[g]) >>> 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 isRetryableMessage(error) { const message = String(error?.message || error || ''); const isCatRetry = typeof CATRetryError === 'function' && error instanceof CATRetryError; return isCatRetry || /被限流|服务器异常|网络错误|请求超时|无响应/i.test(message); } function setRunState(state) { try { GM_setValue(KEY_RUN_STATE, { ...GM_getValue(KEY_RUN_STATE, {}), ...state, updatedAt: new Date().toISOString(), }); } 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(); } 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; } notify( '百度贴吧上次运行状态', `${state.status === 'success' ? '已完成' : '执行失败'}:${state.summary || '无汇总'}\n结束时间:${formatRunTime(state.finishedAt)}`, ); } function todayKey() { const date = new Date(); return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`; } 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; } 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,则同时显式传入白名单 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 modernCookieApi = 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 cookieApi = typeof GM_cookie !== 'undefined' ? GM_cookie : null; if (modernCookieApi) { let timer; let settled = false; const finish = (callback, value) => { if (settled) return; settled = true; clearTimeout(timer); callback(value); }; timer = setTimeout(() => finish(reject, new Error('读取贴吧 Cookie 超时')), CONFIG.timeout); Promise.resolve() .then(() => modernCookieApi({ 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 cookieApi === 'function' ? (details, callback) => cookieApi('list', details, callback) : cookieApi && typeof cookieApi.list === 'function' ? (details, callback) => cookieApi.list(details, callback) : null; if (!list) { reject(new Error('ScriptCat 未提供 GM.cookie.list 或 GM_cookie.list 接口')); return; } 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); 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}`)); } }); } async function readTiebaCookieString() { try { const cookies = await listCookies(); const allowedNames = new Set([ 'BDUSS', 'BDUSS_BFESS', 'STOKEN', 'STOKEN_BFESS', 'BAIDUID', 'BAIDUID_BFESS', 'PTOKEN', 'PTOKEN_BFESS', ]); return cookies .filter(cookie => allowedNames.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 ''; } } 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; // 后台任务不依赖当前页面是否恰好携带 Cookie,先读取一次并用于整轮请求。 const cookie = await readTiebaCookieString(); const response = await request('GET', API.tbs, undefined, cookie); const json = responseJson(response); const tbs = extractTbs(json); const value = { isLogin: isLoggedIn(json), tbs, 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 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]; return Array.isArray(value) ? value : value && typeof value === 'object' ? [value] : []; }); return values.map(item => ({ id: String(item.id || item.fid || item.forum_id || ''), name: String(item.name || item.forum_name || item.forumName || item.kw || ''), })).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'; } async function getForums(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(`手机接口获取关注列表失败:${responseDiagnostic(response)}`); forums.push(...mobileForums(json)); if (!hasMoreForums(json)) break; await delay(CONFIG.interval); } return forums.filter((item, index, list) => list.findIndex(other => other.id === item.id) === index) .slice(0, CONFIG.maxForums); } function signResult(response) { const status = statusOf(response); const json = responseJson(response); const error = 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 (code === '0') return { type: 'ok' }; if (code === '160002' || /已签|今日已签|已经签到|重复签到|already|signed/i.test(error)) { return { type: 'already' }; } // 这些通常是确定性的业务结果,不需要整轮反复重试。 if (code === '340006' || /未开放|不支持签到|不存在|已关闭|被封禁|无权访问|吧不存在/i.test(error)) { return { type: 'fail', reason: error || `error_code=${code || '未知'}` }; } // 未知接口错误按临时错误处理,避免一次偶发格式/服务异常被 once 吞掉。 return { type: 'retryable', reason: error || 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); } async function signForumWithRetry(forum, bduss, cookie) { let last = { type: 'retryable', reason: '未知错误' }; for (let attempt = 0; attempt <= CONFIG.retries; attempt += 1) { try { // 正常情况复用缓存 tbs;重试时强制刷新,避免使用失效参数。 const auth = await getTbs(attempt > 0); if (!auth.tbs) { return { type: 'auth', reason: `登录态失效或未获取到 tbs:${auth.diagnostic};Cookie 数量 ${auth.cookieCount}`, }; } last = await signForum(forum, bduss, auth.tbs, auth.cookie || 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) { if (error instanceof Error && /登录态失效|HTTP 401|HTTP 403/.test(error.message)) { return { type: 'auth', reason: error.message }; } last = { type: 'retryable', reason: error instanceof Error ? error.message : String(error), }; if (attempt < CONFIG.retries) { log(`${forum.name} 第 ${attempt + 1} 次请求异常:${last.reason};准备重试`, 'warn'); await delay(backoffMs(attempt, last.retryAfter)); } } } return last; } 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 runSign() { // 每次运行都重新查询并提交每个贴吧;“今日已签到”由贴吧接口返回并计入汇总。 setRunState({ status: 'running', phase: '读取登录状态', current: 0, total: 0 }); const auth = await getTbs(true); 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 }); const result = { ok: 0, already: 0, fail: 0, auth: 0, retryable: 0, failed: [] }; let processed = 0; for (let index = 0; index < forums.length; index += 1) { const forum = forums[index]; const current = await signForumWithRetry(forum, bduss, auth.cookie); 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})`); 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'); } 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() * 150)); } } const failedCount = result.fail + result.auth + result.retryable; const summary = `成功 ${result.ok},已签 ${result.already},失败 ${failedCount}` + (result.failed.length ? `(${shortDetails(result.failed)})` : ''); const completed = failedCount === 0 && processed === forums.length && result.ok + result.already === forums.length; GM_setValue(KEY_STATE, { date: todayKey(), completed, failCount: failedCount, time: new Date().toLocaleString(), summary, }); setRunState({ status: failedCount === 0 ? 'success' : 'failed', phase: failedCount === 0 ? '已完成' : '部分失败', current: processed, total: forums.length, summary, finishedAt: new Date().toISOString(), }); if (result.auth > 0 || result.retryable > 0) { throw retryError(`本轮存在登录态或临时失败:${summary}`, CONFIG.retryAfterSeconds); } return { skipped: false, summary, failCount: result.fail }; } GM_registerMenuCommand('🔎 查看当前运行状态', showRunState); return (async function () { const lockId = acquireLock(); if (!lockId) { const message = '已有签到任务正在运行,本次跳过,避免重复请求'; log(message, 'warn'); throw retryError(message, 60); } const startedAt = new Date().toISOString(); setRunState({ status: 'running', phase: '正在启动', current: 0, total: 0, startedAt, }); 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(), }); throw error; } finally { releaseLock(lockId); } })();