// ==UserScript== // @name 百合会自动登录 // @namespace https://bbs.yamibo.com/yamibo-autologin // @version 1.6.0 // @description 百合会论坛(bbs.yamibo.com)会话失效后自动登录;打开页面时自动签到(配合「百合会自动签到(后台)」可不开页面也签到);右下角面板可查看积分、连续打卡与升级进度 // @author WorkBuddy // @match https://bbs.yamibo.com/* // @grant GM_getValue // @grant GM_setValue // @grant GM_deleteValue // @grant GM_addStyle // @grant GM_registerMenuCommand // @grant CAT_userConfig // @run-at document-idle // @noframes // ==/UserScript== /* ==UserConfig== account: username: title: 账号 description: 用户名 / Email / UID type: text password: title: 密码 type: text password: true questionid: title: 安全提问 description: 没设置安全提问就保持「无」 type: select default: "0" values: ["0", "1", "2", "3", "4", "5", "6", "7"] answer: title: 安全提问答案 type: text password: true --- option: enabled: title: 启用自动登录 type: switch default: true remember: title: 勾选「自动登录」(保持 30 天) type: checkbox default: true redirectFromAnyPage: title: 任意页面未登录时自动跳转登录 description: 关闭后仅在登录页自动填写提交 type: checkbox default: true --- sign: autoSign: title: 每日自动签到 type: switch default: true showLogButton: title: 显示悬浮「签到日志」按钮 description: 关闭后仍可从 ScriptCat 菜单打开 type: checkbox default: true ==/UserConfig== */ (function () { "use strict"; const TAG = "[yamibo-autologin]"; const ORIGIN = "https://bbs.yamibo.com"; const SIGN_URL = ORIGIN + "/plugin.php?id=zqlj_sign"; const PREFIX = "yamibo-autologin:"; const QUESTION_LABELS = [ "无安全提问", "母亲的名字", "爷爷的名字", "父亲出生的城市", "您其中一位老师的名字", "您个人计算机的型号", "您最喜欢的餐馆名称", "驾驶执照最后四位数字", ]; // ---------- 配置 ---------- function cfg() { return { username: (GM_getValue("account.username", "") || "").trim(), password: GM_getValue("account.password", "") || "", questionid: String(GM_getValue("account.questionid", "0") || "0"), answer: GM_getValue("account.answer", "") || "", enabled: GM_getValue("option.enabled", true) !== false, remember: GM_getValue("option.remember", true) !== false, redirect: GM_getValue("option.redirectFromAnyPage", true) !== false, autoSign: GM_getValue("sign.autoSign", true) !== false, showLogButton: GM_getValue("sign.showLogButton", true) !== false, }; } const log = (...a) => console.log(TAG, ...a); // ---------- 工具 ---------- 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()); // 站点前面有百度云 WAF:fetch 请求会被判成机器流量返回 405(带 JS 挑战页), // 同源 XMLHttpRequest 能正常通过,所以这里统一用 XHR 取页面。 function getText(url) { return new Promise((resolve, reject) => { const x = new XMLHttpRequest(); x.open("GET", url, true); x.timeout = 20000; x.onload = () => { if (x.status >= 200 && x.status < 300) resolve(x.responseText); else reject(new Error("HTTP " + x.status)); }; x.onerror = () => reject(new Error("网络异常,请求未完成")); x.ontimeout = () => reject(new Error("请求超时")); x.send(); }); } function state(key, val) { try { if (val === undefined) { const raw = sessionStorage.getItem(PREFIX + key); return raw ? JSON.parse(raw) : null; } sessionStorage.setItem(PREFIX + key, JSON.stringify(val)); } catch (e) {} return null; } // ---------- 登录状态判断 ---------- function loginState() { if (document.querySelector('.header-tool a[href*="action=login"], .oyheader a[href*="action=login"]')) { return "guest"; } if (document.querySelector('.header-tool a[href*="space-uid-"], #um .vwmy, #myitem')) { return "member"; } return "unknown"; } function isLoginPage() { return location.pathname.indexOf("member.php") !== -1 && /action=login/.test(location.search); } function isBlockedPage() { return /action=logout|mod=register|lostpasswd|action=lostpw/.test(location.search); } function getLoginForm() { return document.querySelector('form[name="login"]'); } function getFormhash() { const el = document.querySelector('input[name="formhash"]'); if (el && el.value) return el.value; try { if (typeof unsafeWindow !== "undefined" && /^[0-9a-f]{8}$/.test(unsafeWindow.formhash || "")) { return unsafeWindow.formhash; } } catch (e) {} const m = /formhash=([0-9a-f]{8})/.exec(document.documentElement.innerHTML); return m ? m[1] : null; } // ---------- 签到记录解析 ---------- function parseRecords(html) { const d = new DOMParser().parseFromString(html, "text/html"); const table = d.querySelector("table.dt.mtm") || [...d.querySelectorAll("table")].find((t) => /打卡时间/.test(t.textContent || "")); if (!table) return []; return [...table.rows] .slice(1) .map((tr) => { const c = [...tr.cells].map((x) => (x.innerText || "").replace(/\s+/g, " ").trim()); const m = /^(\d{4}-\d{2}-\d{2})\s+(\d{1,2}:\d{2}:\d{2})/.exec(c[0] || ""); if (!m) return null; return { date: m[1], time: m[2], fixed: c[1] || "", rank: c[2] || "", streak: c[3] || "", group: c[4] || "", holiday: c[5] || "", }; }) .filter(Boolean); } 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 readLogs() { const arr = GM_getValue("sign.logs", []); return Array.isArray(arr) ? arr : []; } async function pushLog(entry) { const logs = readLogs(); logs.push(entry); while (logs.length > 120) logs.shift(); await GM.setValue("sign.logs", logs); } function cleanMessage(html) { const d = new DOMParser().parseFromString(html, "text/html"); const box = d.querySelector("#messagetext, .alert_error, .alert_info"); let msg = box ? (box.innerText || "") : ""; msg = msg.replace(/if\s*\(\s*history[\s\S]*$/, "").replace(/\s+/g, " ").trim(); return msg; } // ---------- 自动签到 ---------- let signing = false; async function autoSign(manual) { if (signing) return; signing = true; try { const last = GM_getValue("sign.lastRun", null); if (!manual && last && last.date === today()) { log("今日已处理过签到,跳过"); return; } const lastFail = GM_getValue("sign.lastFailAt", 0); if (!manual && Date.now() - lastFail < 10 * 60 * 1000) { log("上次签到失败,10 分钟内不重复尝试"); return; } let records = []; try { records = parseRecords(await getText(SIGN_URL + "&tb=my")); } catch (e) { log("读取签到记录失败", e.message); } const todayRec = records.find((r) => r.date === today()) || null; if (todayRec && !manual) { log("今日已签到,无需操作"); await GM.setValue("sign.lastRun", { date: today(), at: Date.now(), ok: true, skipped: true }); updateDot("ok"); return; } if (todayRec) log("今日已有打卡记录,仍按手动请求强制签到一次"); const fh = getFormhash(); if (!fh) throw new Error("找不到 formhash,请刷新页面后再试"); const respHtml = await getText(SIGN_URL + "&sign=" + encodeURIComponent(fh)); const msg = cleanMessage(respHtml); const already = /已经打过卡|已经签到|已打卡/.test(msg); const after = parseRecords(await getText(SIGN_URL + "&tb=my")); const rec = after.find((r) => r.date === today()); const ok = !!rec; await pushLog({ ts: Date.now(), date: today(), time: new Date().toTimeString().slice(0, 8), ok, skipped: already, msg: rec ? "打卡成功 · " + recSummary(rec) : msg || "未确认到打卡记录", manual: !!manual, }); await GM.setValue("sign.lastRun", { date: today(), at: Date.now(), ok, skipped: already }); if (ok) { if (!todayRec) await markScriptDay(today()); log(todayRec ? "站点确认今日已打卡" : "签到成功", recSummary(rec)); const sum = recSummary(rec); toast( "" + (todayRec ? "今日已打卡" : "签到成功") + "
" + (todayRec ? "站点确认于 " + rec.time : "刚刚完成") + (sum ? " · " + sum : "") ); } else if (already) { log("站点提示今日已打卡"); } else { log("签到未成功", msg); await GM.setValue("sign.lastFailAt", Date.now()); toast("签到未成功
" + (msg.slice(0, 60) || "请到签到页手动确认"), 0); } cacheRecords = after; cacheAt = Date.now(); updateDot(); } catch (e) { log("签到异常", e.message); await GM.setValue("sign.lastFailAt", Date.now()); await pushLog({ ts: Date.now(), date: today(), time: new Date().toTimeString().slice(0, 8), ok: false, skipped: false, msg: "执行失败:" + e.message, manual: !!manual, }); if (manual) toast("签到失败
" + e.message, 0); } finally { signing = false; } } // ---------- 样式 ---------- GM_addStyle(` .ya-toast{position:fixed;right:18px;top:52px;z-index:2147483000;max-width:340px; background:#fff;color:#333;border:1px solid #e3d6e8;border-left:4px solid #c96f9a; border-radius:8px;box-shadow:0 6px 24px rgba(0,0,0,.14);padding:12px 14px; font:13px/1.6 "Microsoft YaHei",system-ui,sans-serif;animation:ya-in-top .2s ease-out} .ya-toast b{color:#b34d7d} .ya-toast .ya-close{position:absolute;top:6px;right:9px;cursor:pointer;color:#aaa;font-size:14px} .ya-panel{position:fixed;right:18px;bottom:18px;z-index:2147483001;width:300px;background:#fff; border:1px solid #e3d6e8;border-radius:10px;box-shadow:0 10px 30px rgba(0,0,0,.18); padding:14px 16px;font:13px/1.6 "Microsoft YaHei",system-ui,sans-serif;color:#333} .ya-panel h4{margin:0 0 8px;font-size:14px;color:#b34d7d} .ya-panel label{display:block;margin:8px 0 2px;color:#666;font-size:12px} .ya-panel input[type=text],.ya-panel input[type=password],.ya-panel select{ width:100%;box-sizing:border-box;padding:6px 8px;border:1px solid #ddd;border-radius:6px; font:13px/1.4 inherit;outline:none} .ya-panel input:focus,.ya-panel select:focus{border-color:#c96f9a} .ya-panel .ya-row{display:flex;gap:8px;margin-top:12px} .ya-panel button{flex:1;padding:7px 0;border:0;border-radius:6px;cursor:pointer;font:13px inherit} .ya-panel .ya-ok{background:#c96f9a;color:#fff} .ya-panel .ya-cancel{background:#f2eef4;color:#666} .ya-panel .ya-hint{margin-top:10px;color:#999;font-size:11px;line-height:1.5} .ya-panel .ya-link{color:#b34d7d;cursor:pointer;text-decoration:underline} @keyframes ya-in{from{opacity:0;transform:translateY(8px)}to{opacity:1;transform:none}} @keyframes ya-in-top{from{opacity:0;transform:translateY(-8px)}to{opacity:1;transform:none}} .ya-log-btn{position:fixed;right:18px;bottom:84px;z-index:2147482998;display:flex;align-items:center; gap:6px;padding:8px 14px;border-radius:999px;background:#fff;border:1px solid #e3d6e8; box-shadow:0 4px 14px rgba(0,0,0,.15);cursor:pointer;font:13px/1 "Microsoft YaHei",system-ui,sans-serif; color:#b34d7d;user-select:none;transition:transform .15s,box-shadow .15s} .ya-log-btn:hover{transform:translateY(-1px);box-shadow:0 6px 18px rgba(0,0,0,.2)} .ya-log-btn .ya-dot{width:8px;height:8px;border-radius:50%;background:#ddd} .ya-log-btn .ya-dot.ok{background:#3aa76d} .ya-log-btn .ya-dot.todo{background:#e8a33d} .ya-log{position:fixed;right:18px;bottom:130px;z-index:2147483002;width:376px;max-height:70vh; display:flex;flex-direction:column;background:#fff;border:1px solid #e3d6e8;border-radius:12px; box-shadow:0 14px 40px rgba(0,0,0,.2);font:13px/1.55 "Microsoft YaHei",system-ui,sans-serif; color:#333;overflow:hidden;animation:ya-in .18s ease-out} .ya-log-head{display:flex;align-items:center;justify-content:space-between;padding:12px 14px; border-bottom:1px solid #f1eaf4;background:#fdf8fb} .ya-log-head b{font-size:14px;color:#b34d7d;font-weight:600} .ya-log-head .ya-x{cursor:pointer;color:#b9a7c2;font-size:16px;line-height:1} .ya-log-status{padding:9px 14px;font-size:12px;color:#666;border-bottom:1px solid #f5f0f7;background:#fffafd} .ya-log-status em{font-style:normal;color:#b34d7d;font-weight:600} .ya-log-streak{padding:10px 14px 12px;font-size:12.5px;color:#7a6a82;line-height:1.7} .ya-log-streak em{font-style:normal;color:#b34d7d;font-weight:600} .ya-log-foot{display:flex;gap:8px;padding:10px 14px;border-top:1px solid #f1eaf4;background:#fdf8fb} .ya-log-foot button{flex:1;padding:6px 0;border:0;border-radius:6px;cursor:pointer;font:12px inherit} .ya-log-foot .ya-primary{background:#c96f9a;color:#fff} .ya-log-foot .ya-ghost{background:#f2eef4;color:#6f6277} .ya-log-loading{padding:24px 0;text-align:center;color:#b3a7ba;font-size:12px} .ya-stats{display:grid;grid-template-columns:repeat(4,1fr);gap:6px;padding:10px 14px 2px} .ya-stat{background:#fdf6fa;border:1px solid #f3e7ef;border-radius:8px;padding:7px 6px;text-align:center} .ya-stat b{display:block;font-size:15px;color:#b34d7d;font-weight:600;line-height:1.3} .ya-stat span{display:block;font-size:10.5px;color:#9b8ba3;margin-top:2px;white-space:nowrap} .ya-up{padding:8px 14px 2px} .ya-up-line{font-size:11.5px;color:#7a6a82;margin-bottom:5px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis} .ya-up-line em{font-style:normal;color:#b34d7d;font-weight:600} .ya-bar{height:5px;border-radius:99px;background:#f1e9f4;overflow:hidden} .ya-bar i{display:block;height:100%;border-radius:99px;background:linear-gradient(90deg,#e3a3c1,#c96f9a);transition:width .3s} .ya-up-today{font-size:11.5px;color:#8b7c92;margin-top:6px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis} .ya-up-today em{font-style:normal;color:#b34d7d;font-weight:600} `); function toast(html, ms) { const old = document.querySelector(".ya-toast"); if (old) old.remove(); const el = document.createElement("div"); el.className = "ya-toast"; el.innerHTML = `×${html}`; el.querySelector(".ya-close").onclick = () => el.remove(); document.body.appendChild(el); if (ms !== 0) setTimeout(() => el.remove(), ms || 8000); } function esc(t) { return String(t).replace(/[&<>"]/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """ }[c])); } function clockText(ts) { const d = new Date(ts); const p = (n) => String(n).padStart(2, "0"); return p(d.getMonth() + 1) + "/" + p(d.getDate()) + " " + p(d.getHours()) + ":" + p(d.getMinutes()) + ":" + p(d.getSeconds()); } // 后台脚本没有界面,点它的菜单会打开本页并把执行结果放在 #ya-bg= 里,这里负责展示 function showBgResult() { const m = /(?:^|[#&])ya-bg=([^&]+)/.exec(location.hash || ""); if (!m) return false; let d = null; try { d = JSON.parse(decodeURIComponent(m[1])); } catch (e) { log("后台结果参数解析失败"); return false; } try { history.replaceState(null, "", location.pathname + location.search); } catch (e) { /* 忽略:部分页面不允许改历史 */ } if (!d) return false; let head; if (d.pending) head = "后台签到 · 正在执行"; else if (!d.at) head = "后台签到 · 还没有执行记录"; else if (d.ok) head = "后台签到 · " + (d.skipped ? "已处理" : "签到成功") + ""; else head = "后台签到 · 未成功"; const rows = [head]; const meta = []; if (d.justRan) meta.push("刚刚"); if (d.at) meta.push(clockText(d.at)); if (d.at) meta.push(d.manual ? "手动" : "自动"); if (d.ms) meta.push((d.ms / 1000).toFixed(1) + "s"); if (meta.length) rows.push(esc(meta.join(" · "))); const msg = String(d.msg || "").replace(/^签到成功\s*/, ""); if (msg) rows.push(esc(msg)); if (d.done) rows.push("今日已签标记:" + esc(d.done)); if (!d.pending) rows.push("站点记录可在右下角「签到日志」查看"); toast(rows.join("
"), 0); log("展示后台签到结果", d.msg || ""); return true; } // ---------- 登录设置面板 ---------- function openPanel(prefill) { document.querySelector(".ya-panel")?.remove(); const c = cfg(); const p = document.createElement("div"); p.className = "ya-panel"; p.innerHTML = `

百合会 · 自动登录

凭据只保存在本机 ScriptCat 存储中,不会上传。查看签到日志
`; document.body.appendChild(p); p.querySelector(".ya-cancel").onclick = () => p.remove(); p.querySelector("#ya-open-log").onclick = () => { p.remove(); openLogPanel(); }; p.querySelector(".ya-ok").onclick = () => { const u = p.querySelector("#ya-u").value.trim(); const pw = p.querySelector("#ya-p").value; if (!u || !pw) return toast("账号和密码不能为空"); GM_setValue("account.username", u); GM_setValue("account.password", pw); GM_setValue("account.questionid", p.querySelector("#ya-q").value); GM_setValue("account.answer", p.querySelector("#ya-a").value); p.remove(); log("凭据已保存"); fillAndSubmit(getLoginForm()); }; } // ---------- 登录填表 ---------- function setValue(el, v) { if (!el) return; el.value = v; el.dispatchEvent(new Event("input", { bubbles: true })); el.dispatchEvent(new Event("change", { bubbles: true })); } function fillAndSubmit(form) { if (!form) return; const c = cfg(); if (!c.username || !c.password) { log("缺少账号或密码,弹出设置面板"); openPanel(); return; } const attempt = state("attempt") || { n: 0, at: 0 }; if (attempt.n >= 3 && Date.now() - attempt.at < 10 * 60 * 1000) { log("短时间内失败次数过多,暂停自动登录"); toast("自动登录已暂停
10 分钟内失败 3 次。请检查账号密码后在脚本设置里修改。", 0); return; } if (form.querySelector('input[name="seccodeverify"]')) { log("登录页要求验证码,转人工"); toast("需要输入验证码
请在页面上手动完成登录,之后会自动恢复到原页面。", 0); return; } setValue(form.querySelector('input[name="username"]'), c.username); setValue(form.querySelector('input[name="password"]'), c.password); const qEl = form.querySelector('select[name="questionid"], input[name="questionid"]'); const aEl = form.querySelector('input[name="answer"]'); if (qEl) { setValue(qEl, c.questionid); if (qEl.tagName === "SELECT" && qEl.value !== c.questionid) setValue(qEl, "0"); } if (aEl && c.questionid !== "0") setValue(aEl, c.answer); const keepEl = form.querySelector('input[name="cookietime"]'); if (keepEl) keepEl.checked = c.remember; const refererEl = form.querySelector('input[name="referer"]'); const back = state("back"); if (refererEl && back && !refererEl.value) refererEl.value = back; state("attempt", { n: attempt.n + 1, at: Date.now() }); log("提交登录表单", { user: c.username, questionid: c.questionid }); const btn = form.querySelector('button[type="submit"], input[type="submit"]'); if (btn) btn.click(); else if (typeof form.onsubmit === "function") form.onsubmit(); else form.submit(); watchResult(); } function watchResult() { const started = Date.now(); const timer = setInterval(() => { const box = document.querySelector('em[id^="returnmessage"], #returnmessage_LrMat'); const txt = box ? (box.textContent || "").trim() : ""; const showMsg = document.querySelector("#append_parent .alert_error, .alert_error, .alert_info"); const msgTxt = txt || (showMsg ? (showMsg.textContent || "").trim() : ""); if (msgTxt) { clearInterval(timer); if (/欢迎|成功|已经登录/.test(msgTxt)) { log("登录成功:", msgTxt.slice(0, 60)); state("attempt", { n: 0, at: 0 }); toast("登录成功
" + msgTxt.slice(0, 50)); } else if (/失败|错误|不正确|验证码|次数|安全提问|密码/.test(msgTxt)) { log("登录失败:", msgTxt.slice(0, 80)); toast("自动登录失败
" + msgTxt.slice(0, 60) + "
请检查账号密码或手动登录。", 0); } return; } if (Date.now() - started > 20000) { clearInterval(timer); if (loginState() === "member") { log("登录成功(页面已切换为登录态)"); state("attempt", { n: 0, at: 0 }); } else { log("登录请求超时,未检测到结果"); toast("自动登录未完成
20 秒内没有响应,请手动登录一次。", 0); } } }, 400); } // ---------- 签到日志面板 ---------- let cacheRecords = null; let cacheAt = 0; let panelEl = null; let statsCache = null; let statsAt = 0; async function loadRecords(force) { if (!force && cacheRecords && Date.now() - cacheAt < 60 * 1000) return cacheRecords; cacheRecords = parseRecords(await getText(SIGN_URL + "&tb=my")); cacheAt = Date.now(); return cacheRecords; } function toNum(text, re) { const m = re.exec(text || ""); if (!m) return null; const n = Number(String(m[1]).replace(/,/g, "")); return isNaN(n) ? null : n; } // 积分 / 用户组:总积分 = 积分 + 对象 / 3(站点公式),升级门槛取自用户组页 async function loadStats(force) { if (!force && statsCache && Date.now() - statsAt < 60 * 1000) return statsCache; const [creditHtml, groupHtml, logHtml] = await Promise.all([ getText(ORIGIN + "/home.php?mod=spacecp&ac=credit"), getText(ORIGIN + "/home.php?mod=spacecp&ac=usergroup"), getText(ORIGIN + "/home.php?mod=spacecp&ac=credit&op=log").catch(() => ""), ]); const c = new DOMParser().parseFromString(creditHtml, "text/html"); const ctext = (c.body.innerText || "").replace(/\u00a0/g, " "); const objects = toNum(ctext, /对象[::]\s*([\d,]+)/); const points = toNum(ctext, /积分[::]\s*([\d,]+)\s*点/); const totalPage = toNum(ctext, /积分[::]\s*([\d,]+)\s*[\((]\s*总积分/); const total = totalPage !== null ? totalPage : points !== null && objects !== null ? Math.floor(points + objects / 3) : null; const g = new DOMParser().parseFromString(groupHtml, "text/html"); const gtext = (g.body.innerText || "").replace(/\u00a0/g, " "); const curName = (/(?:我的主用户组|主用户组)\s*[-–—]\s*([^\s\d]+)/.exec(gtext) || [])[1] || ""; const need = toNum(gtext, /还需积分\s*([\d,]+)/); let nextName = ""; const notice = [...g.querySelectorAll("span.notice")].find((e) => /还需积分/.test(e.textContent)); if (notice) { const tbl = notice.closest("table"); if (tbl) { nextName = [...tbl.querySelectorAll("td,th")] .map((x) => (x.innerText || "").trim()) .find((x) => /^百合[\u4e00-\u9fa5]{1,4}$/.test(x)) || ""; } } // 今日所得:按积分记录里今天的条目汇总(对象按 3:1 折成总积分) let gainPoints = 0; let gainObjects = 0; if (logHtml) { const lg = new DOMParser().parseFromString(logHtml, "text/html"); const tb = [...lg.querySelectorAll("table")].find((t) => /变更时间/.test(t.textContent || "")); if (tb) { [...tb.rows].slice(1).forEach((tr) => { const cells = [...tr.cells].map((x) => (x.innerText || "").replace(/\s+/g, " ").trim()); const when = cells[cells.length - 1] || ""; if (when.indexOf(today()) !== 0) return; const change = cells[1] || ""; const o = /对象\s*([+\-]?\d+)/.exec(change); const p = /积分\s*([+\-]?\d+)/.exec(change); if (o) gainObjects += Number(o[1]); if (p) gainPoints += Number(p[1]); }); } } const gainScore = gainPoints + gainObjects / 3; const days = need !== null && gainScore > 0 ? Math.ceil(need / gainScore) : null; statsCache = { objects, points, total, curName, nextName, need, gainPoints, gainObjects, gainScore, days, at: Date.now(), }; statsAt = Date.now(); return statsCache; } function scriptDays() { const a = GM_getValue("sign.days", []); return Array.isArray(a) ? a.length : 0; } async function markScriptDay(date) { const a = GM_getValue("sign.days", []); const list = Array.isArray(a) ? a : []; if (list.indexOf(date) === -1) { list.push(date); while (list.length > 400) list.shift(); await GM.setValue("sign.days", list); } } function renderStats(st) { if (!panelEl) return; const grid = panelEl.querySelector(".ya-stats"); const up = panelEl.querySelector(".ya-up"); if (!grid || !up) return; const dash = "—"; const cells = [ [st && st.total !== null && st.total !== undefined ? st.total : dash, "总积分", "站点公式:总积分 = 积分 + 对象 / 3"], [st && st.points !== null && st.points !== undefined ? st.points : dash, "积分", "账户里的积分余额"], [st && st.objects !== null && st.objects !== undefined ? st.objects : dash, "对象", "打卡等行为获得的对象,3 个折 1 积分"], [scriptDays(), "脚本签到天数", "脚本真正替你打卡成功的天数,从安装当天开始累计"], ]; grid.textContent = ""; cells.forEach(([v, label, tip]) => { const box = document.createElement("div"); box.className = "ya-stat"; if (tip) box.title = tip; const b = document.createElement("b"); b.textContent = String(v); const sp = document.createElement("span"); sp.textContent = label; box.appendChild(b); box.appendChild(sp); grid.appendChild(box); }); up.textContent = ""; const line = document.createElement("div"); line.className = "ya-up-line"; if (st && st.total !== null && st.need !== null && st.need !== undefined) { const target = st.nextName || "下一级"; line.innerHTML = (st.curName ? st.curName + " → " : "") + target + " · 距离升级还需 " + st.need + " 积分"; const threshold = st.total + st.need; const bar = document.createElement("div"); bar.className = "ya-bar"; const fill = document.createElement("i"); fill.style.width = Math.max(2, Math.min(100, Math.round((st.total / threshold) * 100))) + "%"; bar.appendChild(fill); up.appendChild(line); up.appendChild(bar); const gain = document.createElement("div"); gain.className = "ya-up-today"; const fmt = (n) => (Number.isInteger(n) ? String(n) : n.toFixed(1)); if (st.gainScore > 0) { const detail = st.gainObjects > 0 ? "对象 +" + st.gainObjects : "积分 +" + st.gainPoints; gain.innerHTML = "今日已获得 " + fmt(st.gainScore) + " 积分(" + detail + ") · 预计 " + (st.days === null ? "—" : st.days) + " 天升级"; } else { gain.innerHTML = "今日已获得 0 积分 · 今天还没有积分入账,暂无法估算升级天数"; } up.appendChild(gain); const btn = document.querySelector(".ya-log-btn"); if (btn) { btn.title = "签到日志 · " + (st.curName ? st.curName + " " : "") + st.total + "/" + threshold + " 分,还需 " + st.need + " 分升级" + (st.days !== null ? "(按今日速度约 " + st.days + " 天)" : ""); } } else { line.textContent = "积分与等级信息读取中…"; up.appendChild(line); } } function computeStreak(records) { const dates = new Set((records || []).map((r) => r.date)); const cursor = new Date(); cursor.setHours(0, 0, 0, 0); if (!dates.has(ymd(cursor))) { cursor.setDate(cursor.getDate() - 1); if (!dates.has(ymd(cursor))) return { days: 0, start: null }; } let days = 0; while (dates.has(ymd(cursor))) { days += 1; cursor.setDate(cursor.getDate() - 1); } const start = new Date(cursor); start.setDate(start.getDate() + 1); return { days, start: ymd(start) }; } function renderStreak(records) { const box = panelEl && panelEl.querySelector(".ya-log-streak"); if (!box) return; const st = computeStreak(records); box.textContent = ""; if (!st.days) { box.textContent = "还没有连续打卡记录,今天签到后开始计算"; return; } const m = /^(\d{4})-(\d{2})-(\d{2})$/.exec(st.start || ""); const startText = m ? m[1] + "年" + Number(m[2]) + "月" + Number(m[3]) + "日" : st.start; box.innerHTML = "自 " + startText + " 起已连续打卡 " + st.days + " 天"; } function updateStatus() { const box = panelEl && panelEl.querySelector(".ya-log-status"); if (!box) return; const rec = (cacheRecords || []).find((r) => r.date === today()); box.textContent = ""; if (rec) { const sum = recSummary(rec); box.innerHTML = "今日 已打卡 " + rec.time + (sum ? " · " + sum : ""); } else { const last = readLogs().filter((l) => l.date === today()).pop(); if (last && !last.ok) box.innerHTML = "今日 未签到成功 · " + last.msg.slice(0, 40); else box.innerHTML = "今日 尚未签到"; } updateDot(); } function updateDot(kind) { const dot = document.querySelector(".ya-log-btn .ya-dot"); if (!dot) return; if (!kind) { const rec = (cacheRecords || []).find((r) => r.date === today()); if (rec) kind = "ok"; else { const last = GM_getValue("sign.lastRun", null); kind = last && last.date === today() && last.ok ? "ok" : "todo"; } } dot.className = "ya-dot " + (kind === "ok" ? "ok" : "todo"); } async function refreshPanel(force) { if (!panelEl) return; const refreshBtn = panelEl.querySelector(".ya-refresh"); if (refreshBtn) refreshBtn.textContent = "刷新中…"; renderStats(statsCache); let recErr = null; await Promise.all([ loadRecords(force).catch((e) => { recErr = e; }), loadStats(force).catch((e) => log("读取积分失败", e.message)), ]); if (!panelEl) return; renderStats(statsCache); updateStatus(); const streakBox = panelEl.querySelector(".ya-log-streak"); if (recErr && !(cacheRecords || []).length) { streakBox.textContent = "读取打卡记录失败:" + recErr.message; } else { renderStreak(cacheRecords); } const btn = panelEl.querySelector(".ya-sign-now"); if (btn) btn.style.display = (cacheRecords || []).some((r) => r.date === today()) ? "none" : ""; if (refreshBtn) refreshBtn.textContent = "刷新"; } function openLogPanel() { if (panelEl) { panelEl.remove(); panelEl = null; return; } const el = document.createElement("div"); el.className = "ya-log"; el.innerHTML = `
签到日志 ×
读取中…
读取中…
`; document.body.appendChild(el); panelEl = el; el.querySelector(".ya-x").onclick = openLogPanel; el.querySelector(".ya-refresh").onclick = () => refreshPanel(true); el.querySelector(".ya-sign-now").onclick = () => { toast("正在签到…"); autoSign(true).then(() => refreshPanel(true)); }; refreshPanel(false); } function mountLogButton() { if (document.querySelector(".ya-log-btn")) return; const btn = document.createElement("div"); btn.className = "ya-log-btn"; btn.innerHTML = '签到日志'; btn.title = "签到情况 · 积分与升级进度"; btn.onclick = openLogPanel; document.body.appendChild(btn); updateDot(); } // ---------- 入口 ---------- function main() { const c = cfg(); // 后台脚本菜单打开本页时带过来的执行结果,先展示出来 showBgResult(); if (!c.enabled) { log("已禁用自动登录"); } else { const form = getLoginForm(); if (form) { state("back", state("back") || document.referrer || ""); log("检测到登录页表单"); if (c.username && c.password) fillAndSubmit(form); else openPanel(); return; } const st = loginState(); if (st === "guest" && c.redirect && !isBlockedPage() && !isLoginPage()) { const last = state("lastRedirect") || 0; if (Date.now() - last < 15000) { log("刚跳转过仍未登录,停止重试"); toast("自动登录未生效
可能账号或密码有误,可点页面右上「登录」手动处理。", 0); return; } state("lastRedirect", Date.now()); state("back", location.href); log("未登录,跳转登录页", location.href); location.replace( ORIGIN + "/member.php?mod=logging&action=login&referer=" + encodeURIComponent(location.href) ); return; } if (st !== "member") { log("登录状态未知,跳过"); return; } } // 已登录 if (c.showLogButton && document.body) mountLogButton(); // 仅在论坛主站页面自动签到(跳过插件页自身,避免重复请求) if (c.autoSign && !/plugin\.php/.test(location.pathname)) { const last = GM_getValue("sign.lastRun", null); if (!last || last.date !== today()) { log("开始每日自动签到"); autoSign(false); } else { updateDot(); } } } GM_registerMenuCommand("查看签到日志", openLogPanel); GM_registerMenuCommand("立即签到", () => autoSign(true)); main(); })();