// ==UserScript==
// @name 百合会自动登录
// @namespace https://bbs.yamibo.com/yamibo-autologin
// @version 1.5.4
// @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
// @license All Rights Reserved
// ==/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());
async function getText(url) {
const r = await fetch(url, { credentials: "same-origin", cache: "no-store" });
if (!r.ok) throw new Error("HTTP " + r.status);
return r.text();
}
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 = `