// ==UserScript== // @name 百合会自动签到(后台) // @namespace https://bbs.yamibo.com/yamibo-autologin // @version 1.1.0 // @description 浏览器启动后自动完成百合会签到,无需打开论坛页面;当日成功后不再重复,失败会提示 // @author WorkBuddy // @background // @storageName yamibo-sign-bg // @connect bbs.yamibo.com // @grant GM_xmlhttpRequest // @grant GM_getValue // @grant GM_setValue // @grant GM_log // @grant GM_notification // @grant GM_registerMenuCommand // @grant GM_addValueChangeListener // @grant GM_openInTab // @license All Rights Reserved // ==/UserScript== /* ==UserConfig== bg: enabled: title: 启用后台自动签到 description: 关闭后只有打开论坛页面时才会签到(由「百合会自动登录」负责) type: switch default: true notify: title: 签到成功时也弹通知 description: 关闭后只在失败时提示 type: switch default: true quietHours: title: 静默时段 description: 该时段内只写日志、不弹通知(失败提醒除外) type: select default: 全天 values: ["全天", "00-08", "08-23"] ==/UserConfig== */ "use strict"; const TAG = "[yamibo-sign-bg]"; const ORIGIN = "https://bbs.yamibo.com"; const SIGN_URL = ORIGIN + "/plugin.php?id=zqlj_sign"; const COOLDOWN = 4 * 60 * 1000; // 两次自动尝试的最小间隔 const RETRY = 30 * 60 * 1000; // 失败后重试间隔 const MAX_RETRY = 4; // 每天最多重试次数 // ---------- 工具 ---------- const pad = (n) => String(n).padStart(2, "0"); const ymd = (d) => d.getFullYear() + "-" + pad(d.getMonth() + 1) + "-" + pad(d.getDate()); const today = () => ymd(new Date()); const log = (msg, extra) => { try { GM_log(TAG + " " + msg + (extra === undefined ? "" : " " + JSON.stringify(extra)), "info"); } catch (e) {} }; function request(details) { return new Promise((resolve, reject) => { GM_xmlhttpRequest({ method: "GET", timeout: 25000, ...details, onload: (res) => { if (res.status >= 400) { reject(new Error("HTTP " + res.status + " " + (details.url || ""))); return; } resolve(res); }, onerror: () => reject(new Error("网络错误")), ontimeout: () => reject(new Error("请求超时")), onabort: () => reject(new Error("请求中止")), }); }); } function stripTags(html) { return String(html || "") .replace(//gi, " ") .replace(//gi, " ") .replace(/<[^>]+>/g, " ") .replace(/ /g, " ") .replace(/&/g, "&") .replace(/"/g, '"') .replace(/'/g, "'") .replace(/</g, "<") .replace(/>/g, ">") .replace(/\s+/g, " ") .trim(); } // 后台沙箱没有 DOMParser / document,用正则解析 function parseRecords(html) { const out = []; const rowRe = /]*>([\s\S]*?)<\/tr>/gi; let m; while ((m = rowRe.exec(String(html || "")))) { const cells = []; const cellRe = /]*>([\s\S]*?)<\/t[dh]>/gi; let c; while ((c = cellRe.exec(m[1]))) cells.push(stripTags(c[1])); const hit = /^(\d{4}-\d{2}-\d{2})\s+(\d{1,2}:\d{2}:\d{2})/.exec(cells[0] || ""); if (!hit) continue; out.push({ date: hit[1], time: hit[2], fixed: cells[1] || "", rank: cells[2] || "", streak: cells[3] || "", group: cells[4] || "", holiday: cells[5] || "", }); } return out; } function recSummary(r) { if (!r) return ""; const parts = [r.fixed, r.rank, r.streak, r.group, r.holiday].filter((x) => x && x !== "无"); return parts.length ? parts.join(" · ") : ""; } function messageText(html) { const src = String(html || ""); const m = /]*id="messagetext"[^>]*>([\s\S]*?)<\/div>/i.exec(src) || /]*class="[^"]*alert_(?:error|info)[^"]*"[^>]*>([\s\S]*?)<\/div>/i.exec(src); let txt = m ? stripTags(m[1]) : ""; if (!txt) { const t = /([^<]*)<\/title>/i.exec(src); txt = t ? stripTags(t[1]) : ""; } return txt.slice(0, 160); } // ---------- 站点交互 ---------- async function readSession() { const res = await request({ url: ORIGIN + "/forum.php?_=" + Date.now() }); const html = res.responseText || ""; return { loggedIn: /space-uid-/.test(html), formhash: (html.match(/formhash=([0-9a-f]{8})/) || [])[1] || null, }; } async function readRecords() { const res = await request({ url: SIGN_URL + "&tb=my&_=" + Date.now() }); return parseRecords(res.responseText || ""); } // ---------- 通知 ---------- const inQuietHours = () => { const mode = GM_getValue("bg.quietHours", "全天"); const h = new Date().getHours(); if (mode === "00-08") return h < 8; if (mode === "08-23") return h >= 23; return false; }; function notify(title, text, force) { const quiet = inQuietHours(); if (quiet && !force) { log("静默时段,跳过通知:" + title); return; } try { GM_notification({ title, text, timeout: 12000 }); } catch (e) { log("通知失败:" + e.message); } } // ---------- 签到 ---------- async function signOnce(manual) { const dt = today(); if (!manual) { const done = GM_getValue("bg.doneDate", ""); if (done === dt) return { ok: true, skipped: true, msg: "今日已签到,跳过" }; const lastTry = GM_getValue("bg.lastTryAt", 0); if (Date.now() - lastTry < COOLDOWN) { return { ok: true, skipped: true, msg: "刚刚尝试过,稍后再检查", cool: true }; } } await GM.setValue("bg.lastTryAt", Date.now()); const s = await readSession(); log("会话状态", { loggedIn: s.loggedIn, formhash: s.formhash || null }); if (!s.loggedIn) { return { ok: false, reason: "not-logged-in", msg: "论坛登录已失效,打开一次论坛页面即可自动登录并签到", }; } let before = null; try { before = (await readRecords()).find((r) => r.date === dt) || null; } catch (e) { log("读取打卡记录失败:" + e.message); } if (before && !manual) { await GM.setValue("bg.doneDate", dt); const sum = recSummary(before); log("站点记录显示今日已打卡 " + before.time + (sum ? " · " + sum : "")); return { ok: true, skipped: true, msg: "今日已打卡 " + before.time + (sum ? " · " + sum : "") }; } if (before && manual) log("今日已有打卡记录,仍按要求强制请求一次"); if (!s.formhash) return { ok: false, reason: "no-formhash", msg: "没取到 formhash,稍后重试" }; const resp = await request({ url: SIGN_URL + "&sign=" + s.formhash + "&_=" + Date.now(), headers: { Referer: SIGN_URL }, }); const msg = messageText(resp.responseText || ""); log("签到返回:" + (msg || "(无消息)")); let after = []; try { after = await readRecords(); } catch (e) { log("复核打卡记录失败:" + e.message); } const rec = after.find((r) => r.date === dt) || before; if (rec) { await GM.setValue("bg.doneDate", dt); const sum = recSummary(rec); return { ok: true, already: !!before, rec, msg: (before ? "站点显示今日已打卡 " : "签到成功 ") + rec.time + (sum ? " · " + sum : ""), }; } return { ok: false, reason: "not-confirmed", msg: msg || "未确认到打卡记录,稍后重试" }; } async function runOnce(manual) { if (!manual && GM_getValue("bg.enabled", true) === false) { log("后台自动签到已关闭"); return { ok: true, skipped: true, disabled: true, msg: "后台自动签到已关闭" }; } const t0 = Date.now(); try { const r = await signOnce(!!manual); await GM.setValue("bg.lastRun", { at: Date.now(), ms: Date.now() - t0, manual: !!manual, ...r }); log((r.ok ? "完成:" : "未完成:") + r.msg); if (r.ok && !r.skipped) { notify("百合会签到成功", r.msg); } else if (!r.ok && GM_getValue("bg.notifyDate", "") !== today()) { await GM.setValue("bg.notifyDate", today()); notify("百合会签到未完成", r.msg, true); } return r; } catch (e) { log("执行异常:" + e.message); await GM.setValue("bg.lastRun", { at: Date.now(), ok: false, reason: "error", msg: "执行异常:" + e.message, }); if (GM_getValue("bg.notifyDate", "") !== today()) { await GM.setValue("bg.notifyDate", today()); notify("百合会签到出错", e.message, true); } return { ok: false, reason: "error", msg: e.message }; } } // ---------- 调度 ---------- function untilNextMorning() { const now = new Date(); const next = new Date(now.getTime()); next.setDate(next.getDate() + 1); next.setHours(0, 5, 0, 0); const jitter = Math.floor(Math.random() * 5 * 60 * 1000); return Math.max(60 * 1000, next.getTime() - now.getTime() + jitter); } let fails = 0; async function loop() { const r = await runOnce(false); if (r.disabled) { log("已关闭,30 分钟后再看"); setTimeout(loop, RETRY); return; } fails = r.ok ? 0 : fails + 1; let delay; if (r.ok && !r.cool) delay = untilNextMorning(); else if (r.ok && r.cool) delay = COOLDOWN; else if (r.reason === "not-logged-in") delay = Math.min(2 * 3600e3, untilNextMorning()); else delay = fails >= MAX_RETRY ? untilNextMorning() : RETRY; const next = new Date(Date.now() + delay); log("下次检查 " + next.toLocaleString() + (delay > 6 * 3600e3 ? "(明天)" : "")); setTimeout(loop, delay); } // ---------- 入口 ---------- // 后台脚本没有界面,菜单点击后打开论坛页面把结果渲染出来(系统通知常被静音,不可靠) let resultTab = null; function resultPayload(extra) { const l = GM_getValue("bg.lastRun", null); const base = l ? { at: l.at || 0, ok: !!l.ok, skipped: !!l.skipped, manual: !!l.manual, reason: l.reason || "", msg: l.msg || "", ms: l.ms || 0, } : { at: 0, ok: false, skipped: false, manual: false, msg: "后台脚本还没有执行记录" }; base.done = GM_getValue("bg.doneDate", ""); base.boot = GM_getValue("bg.bootAt", 0); return Object.assign(base, extra || {}); } function openResult(extra) { const data = resultPayload(extra); const url = ORIGIN + "/forum.php#ya-bg=" + encodeURIComponent(JSON.stringify(data)); try { if (resultTab && !resultTab.closed) resultTab.close(); } catch (e) { /* 忽略:上个结果页已被手动关闭 */ } try { resultTab = GM_openInTab(url, { active: true, insert: true, setParent: true }); log("已打开结果页"); } catch (e) { log("打开结果页失败:" + e.message); notify("百合会签到状态", data.msg, true); } } GM_registerMenuCommand("立即签到(后台)", async () => { log("菜单触发手动签到"); openResult({ pending: true, msg: "正在后台签到,请稍候…" }); const r = await runOnce(true); openResult({ justRan: true, ok: !!r.ok, skipped: !!r.skipped, msg: r.msg || "" }); }); GM_registerMenuCommand("查看最近一次签到结果", () => { log("菜单查看最近结果"); openResult(); }); // 外部(页面脚本 / 调试探针)可写 bg.forceRun 触发一次强制签到 GM_addValueChangeListener("bg.forceRun", (name, oldValue, newValue, remote) => { if (!remote || !newValue) return; if (Date.now() - newValue > 60 * 1000) return; log("收到外部手动请求,强制执行一次"); runOnce(true); }); (async () => { await GM.setValue("bg.bootAt", Date.now()); log("后台脚本启动"); loop().catch((e) => log("调度异常:" + e.message)); })(); // 常驻:定时器链负责后续检查,脚本本身保持运行状态 return new Promise(() => {});