// ==UserScript== // @name WorkBuddy 派猫猫旅行(后台定时版) // @namespace https://www.workbuddy.cn // @version 6.3.0 // @description 脚本猫后台定时脚本:每天按旅行状态自动领取奖励(礼物+积分)+ 派发新旅行,无需打开前台网页。 // @author WorkBuddy // @crontab * * once * * // @grant GM_xmlhttpRequest // @grant GM_log // @icon https://www.workbuddy.cn/favicon.ico // ==/UserScript== // 说明: // - @crontab * * once * * => day 字段 once 表示每天最多运行一次(脚本猫 node-cron 扩展语法)。 // - 定时脚本本身就是后台脚本,运行在沙箱、不依赖前台网页,无需 @background / @match。 // - 脚本体必须 return 一个 Promise,脚本猫据此判断执行完成。 // 脚本猫后台/定时脚本:脚本体必须 return 一个 Promise,管理器据此判断执行完成。 return new Promise((resolve, reject) => { // ===================== 配置区 ===================== const CONFIG = { baseUrl: 'https://www.workbuddy.cn', // 公共请求头(实抓:派发/领取均带这些头) headers: { 'Accept': 'application/json, text/plain, */*', 'Content-Type': 'application/json', 'X-Client-Platform': 'web' }, // 查询旅行状态:决定本轮该「领取」还是「派发」 status: { method: 'GET', path: '/activity/growth/buddy/travel/status' }, // —— 派发接口(用户实抓)—— depart: { enabled: true, method: 'POST', path: '/activity/growth/buddy/travel/depart', body: { location_id: 1 }, locationIds: [1], // 多地轮换:[1,2,3] rotate: false // true=每天换一个地点 }, // —— 领取旅行奖励(礼物+积分),用户实抓:POST travel/claim body {} —— claim: { enabled: true, method: 'POST', path: '/activity/growth/buddy/travel/claim', body: {} }, // 安全阀门:true=无论状态如何每天都强制尝试领取(仅当 status 解析不可信时打开) claimAlways: false }; const PREFIX = '[派猫猫旅行-后台]'; let locIdx = 0; function log(...args) { const msg = args.map(a => (typeof a === 'object' ? JSON.stringify(a) : String(a))).join(' '); try { console.log(PREFIX, msg); } catch (e) {} try { GM_log(msg); } catch (e) {} } // GM_xmlhttpRequest 的 Promise 封装(后台脚本唯一可用的请求方式,same-origin 带登录态) function request(method, path, bodyObj) { return new Promise((res, rej) => { const init = { url: CONFIG.baseUrl + path, method: method, headers: CONFIG.headers, onload: (r) => res({ status: r.status, response: r.responseText }), onerror: (e) => rej(new Error('network error: ' + (e && e.error || 'unknown'))), ontimeout: () => rej(new Error('timeout')) }; if (bodyObj != null) { init.data = (typeof bodyObj === 'string') ? bodyObj : JSON.stringify(bodyObj); } try { GM_xmlhttpRequest(init); } catch (e) { rej(e); } }); } function pickLocationId() { if (!CONFIG.depart.rotate || CONFIG.depart.locationIds.length <= 1) return CONFIG.depart.locationIds[0]; const id = CONFIG.depart.locationIds[locIdx % CONFIG.depart.locationIds.length]; locIdx++; return id; } // 把 status 响应解析为状态机信号。 // 已知格式:data.state = idle|traveling|completed|claimable,data.daily_limit_reached,data.arrive_at/server_now function parseStatus(raw) { let data = raw.response; if (typeof data === 'string') { try { data = JSON.parse(data); } catch (e) { data = {}; } } if (data && typeof data === 'object' && data.data) data = data.data; // 兼容 {code,data,...} const s = (data && (data.state || data.status || data.travel_status || '')).toString().toLowerCase(); const arriveAt = Number(data.arrive_at || 0); const serverNow = Number(data.server_now || 0); const dailyLimitReached = !!(data.daily_limit_reached); const remainingSeconds = (arriveAt > serverNow && serverNow > 0) ? (arriveAt - serverNow) : 0; let traveling = s === 'traveling'; let canClaim = s === 'completed' || s === 'claimable' || s === 'arrived'; let canDepart = s === 'idle' && !dailyLimitReached; // 兜底:没有明确 state 时,按时间戳辅助判断 if (!s) { traveling = !!(data.traveling || data.is_traveling || (arriveAt > serverNow)); canClaim = !!(data.can_claim || data.claimable || data.reward_ready); canDepart = !!((data.can_depart || data.departable) && !dailyLimitReached); } return { raw: data, state: s || 'unknown', traveling, canClaim, canDepart, dailyLimitReached, remainingSeconds }; } function formatSeconds(sec) { if (sec <= 0) return '0s'; const h = Math.floor(sec / 3600); const m = Math.floor((sec % 3600) / 60); const s = sec % 60; return (h ? h + 'h' : '') + (m ? m + 'm' : '') + s + 's'; } async function doStep(name, cfg, isDepart) { if (!cfg.enabled) { log(name + ':已禁用,跳过'); return; } if (!cfg.path) { log(name + ':未配置 path,跳过'); return; } const body = isDepart ? Object.assign({}, cfg.body, { location_id: pickLocationId() }) : cfg.body; log(name + ':', cfg.method, cfg.path, JSON.stringify(body)); try { const resp = await request(cfg.method, cfg.path, body); log(name + '返回(' + resp.status + '):', (resp.response || '').slice(0, 300)); } catch (e) { log(name + '失败:', e.message); } } // 主流程 (async () => { try { log('后台定时任务启动(每天一次)'); // 1. 查状态 let st = { state: 'unknown', traveling: false, canClaim: false, canDepart: true, dailyLimitReached: false, remainingSeconds: 0, raw: null }; try { const raw = await request(CONFIG.status.method, CONFIG.status.path); st = parseStatus(raw); log('状态:', st.state, '| 旅行中:' + st.traveling, '| 可领取:' + st.canClaim, '| 可派发:' + st.canDepart, '| 今日上限:' + st.dailyLimitReached, '| 剩余:' + formatSeconds(st.remainingSeconds)); } catch (e) { log('状态查询失败,按默认(可派发)执行:', e.message); } // 2. 若可领取,先领取上一次旅行的奖励(礼物+积分) if (CONFIG.claim.enabled && (st.canClaim || CONFIG.claimAlways)) { await doStep('领取奖励', CONFIG.claim, false); } else { log('当前无可领取奖励,跳过领取'); } // 3. 若可派发(且不在旅行中、未达今日上限),派发新旅行 if (st.dailyLimitReached) { log('今日派发次数已达上限,跳过派发'); } else if (st.traveling) { log('旅行进行中(还剩 ' + formatSeconds(st.remainingSeconds) + '),跳过派发'); } else if (CONFIG.depart.enabled && st.canDepart) { await doStep('派发', CONFIG.depart, true); } else { log('当前不可派发,跳过'); } log('本轮完成'); resolve('ok'); } catch (e) { log('执行异常:', e && e.message); reject(e); } })(); });