LINUX.SB 增强脚本:自动签到、自动翻页、快捷回复、内容过滤、浏览历史、回帖足迹、用户卡片、图片预览、图床上传、代码高亮、Callout、链接净化、新标签打开、平滑滚动等。
// ==UserScript==
// @name LINUX SB X
// @namespace https://linux.sb/
// @version 1.0.1
// @description LINUX.SB 增强脚本:自动签到、自动翻页、快捷回复、内容过滤、浏览历史、回帖足迹、用户卡片、图片预览、图床上传、代码高亮、Callout、链接净化、新标签打开、平滑滚动等。
// @author Ported for linux.sb
// @match https://linux.sb/*
// @match https://www.linux.sb/*
// @grant GM_getValue
// @grant GM_setValue
// @grant GM_registerMenuCommand
// @grant GM_unregisterMenuCommand
// @grant GM_openInTab
// @grant GM_xmlhttpRequest
// @connect *
// @run-at document-idle
// @license GPL-3.0
// ==/UserScript==
(function () {
'use strict';
const APP = 'LSBX';
const BASE = location.origin;
const STORE = 'lsbx_settings_v1';
const HISTORY_KEY = 'lsbx_history_v1';
const RECENT_KEY = 'lsbx_recent_closed_v1';
const REPLIED_KEY = 'lsbx_replied_topics_v1';
const SIGN_KEY = 'lsbx_sign_state_v1';
const DEFAULTS = {
signIn: true,
signInTip: true,
autoLoadTopics: true,
autoLoadReplies: true,
ctrlEnter: true,
quickComment: true,
blockPosts: true,
blockKeywords: [],
blockUsers: true,
blockedUsers: [],
blockRestricted: true,
history: true,
historyLimit: 100,
historyDays: 7,
replyFootprint: true,
imagePreview: true,
imageUpload: false,
imageProvider: 'NodeImage',
imageBase: '',
imageToken: '',
imageHeaders: '',
codeHighlight: true,
callout: true,
prefetch: true,
linkPurifier: true,
externalNewTab: true,
openTopicNewTab: false,
smoothScroll: true,
visitedColor: true,
visitedColorLight: '#8b5cf6',
visitedColorDark: '#c4b5fd',
userCard: true,
unreadNotice: true,
opPointsBadge: true,
debug: false
};
const gmGet = (k, d) => {
try {
return typeof GM_getValue === 'function'
? GM_getValue(k, d)
: JSON.parse(localStorage.getItem(k) || 'null') ?? d;
} catch {
return d;
}
};
const gmSet = (k, v) => {
try {
return typeof GM_setValue === 'function'
? GM_setValue(k, v)
: localStorage.setItem(k, JSON.stringify(v));
} catch {}
};
const clone = o => JSON.parse(JSON.stringify(o));
const merge = (a, b) => {
const out = clone(a);
Object.keys(b || {}).forEach(k => out[k] = b[k]);
return out;
};
let cfg = merge(DEFAULTS, gmGet(STORE, {}));
const saveCfg = () => gmSet(STORE, cfg);
const log = (...a) => cfg.debug && console.log(`[${APP}]`, ...a);
const $ = (s, r = document) => r?.querySelector(s);
const $$ = (s, r = document) => [...(r?.querySelectorAll(s) || [])];
const debounce = (fn, ms = 100) => {
let t;
return (...a) => {
clearTimeout(t);
t = setTimeout(() => fn(...a), ms);
};
};
const esc = s =>
String(s ?? '').replace(
/[&<>"']/g,
c => ({
'&': '&',
'<': '<',
'>': '>',
'"': '"',
"'": '''
}[c])
);
const sleep = ms => new Promise(r => setTimeout(r, ms));
const abs = u => {
try {
return new URL(u, BASE).href;
} catch {
return '';
}
};
const sameHost = u => {
try {
const x = new URL(u, BASE);
return ['linux.sb', 'www.linux.sb'].includes(x.hostname);
} catch {
return false;
}
};
const topicId = u => {
try {
return new URL(u, BASE).pathname.match(/^\/topic\/(\d+)\/?$/)?.[1] || '';
} catch {
return '';
}
};
const userId = u => {
try {
return new URL(u, BASE).pathname.match(/^\/user\/(\d+)\/?$/)?.[1] || '';
} catch {
return '';
}
};
const isTopicPage = () =>
/^\/topic\/\d+\/?$/.test(location.pathname);
const currentTopicId = () =>
location.pathname.match(/^\/topic\/(\d+)\/?$/)?.[1] || '';
const isLoggedIn = () =>
!$$('a').some(
a =>
/^登录$/.test(a.textContent.trim()) &&
/login/i.test(a.href || '')
);
const todayCN = () => {
const parts = new Intl.DateTimeFormat('zh-CN', {
timeZone: 'Asia/Shanghai',
year: 'numeric',
month: '2-digit',
day: '2-digit'
}).formatToParts(new Date());
const m = Object.fromEntries(parts.map(x => [x.type, x.value]));
return `${m.year}-${m.month}-${m.day}`;
};
function addStyle(id, css) {
if (document.getElementById(id)) return;
const el = document.createElement('style');
el.id = id;
el.textContent = css;
document.head.appendChild(el);
}
addStyle('lsbx-base', `
.lsbx-hidden{
display:none!important
}
.lsbx-badge{
display:inline-flex;
align-items:center;
margin-left:6px;
padding:1px 6px;
border-radius:999px;
font-size:11px;
line-height:18px;
background:rgba(99,102,241,.12);
color:#6366f1;
vertical-align:middle
}
.lsbx-replied{
background:rgba(16,185,129,.12);
color:#059669
}
#lsbx-toast-wrap{
position:fixed;
top:64px;
left:50%;
transform:translateX(-50%);
z-index:2147483647;
display:flex;
flex-direction:column;
gap:8px;
pointer-events:none
}
.lsbx-toast{
max-width:min(520px,90vw);
padding:10px 14px;
border-radius:9px;
background:#16181d;
color:#fff;
box-shadow:0 8px 30px rgba(0,0,0,.25);
font-size:13px;
opacity:.97
}
.lsbx-fab-wrap{
position:fixed;
right:18px;
bottom:22px;
z-index:99990;
display:flex;
flex-direction:column;
gap:9px
}
.lsbx-fab{
width:40px;
height:40px;
border:0;
border-radius:50%;
background:#fff;
color:#222;
box-shadow:0 3px 16px rgba(0,0,0,.18);
cursor:pointer;
font-size:17px;
display:grid;
place-items:center
}
@media(prefers-color-scheme:dark){
.lsbx-fab{
background:#24262b;
color:#eee
}
}
#lsbx-history{
position:fixed;
right:18px;
top:60px;
width:min(390px,94vw);
height:min(72vh,650px);
z-index:999999;
background:#fff;
color:#222;
border:1px solid #ddd;
border-radius:12px;
box-shadow:0 16px 50px rgba(0,0,0,.2);
display:none;
flex-direction:column;
overflow:hidden
}
#lsbx-history.show{
display:flex
}
.lsbx-h-head{
display:flex;
align-items:center;
gap:8px;
padding:12px;
border-bottom:1px solid #eee
}
.lsbx-h-head b{
flex:1
}
.lsbx-h-head button,
.lsbx-h-tabs button{
border:0;
background:transparent;
cursor:pointer;
color:inherit
}
.lsbx-h-search{
margin:9px 12px;
padding:7px 9px;
border:1px solid #ddd;
border-radius:8px
}
.lsbx-h-search input{
border:0;
outline:0;
width:100%;
background:transparent;
color:inherit
}
.lsbx-h-tabs{
display:flex;
gap:14px;
padding:0 12px 7px;
border-bottom:1px solid #eee
}
.lsbx-h-tabs button.on{
font-weight:700;
color:#6366f1
}
.lsbx-h-list{
overflow:auto;
flex:1;
padding:7px
}
.lsbx-h-item{
display:flex;
gap:8px;
align-items:center;
padding:8px;
border-radius:7px
}
.lsbx-h-item:hover{
background:rgba(127,127,127,.08)
}
.lsbx-h-item a{
flex:1;
min-width:0;
white-space:nowrap;
overflow:hidden;
text-overflow:ellipsis;
color:inherit;
text-decoration:none
}
.lsbx-h-item small{
opacity:.55
}
.lsbx-h-item button{
border:0;
background:transparent;
cursor:pointer;
color:inherit
}
.lsbx-h-day{
font-size:12px;
opacity:.6;
padding:8px 8px 3px
}
#lsbx-settings{
position:fixed;
inset:0;
z-index:1000000;
background:rgba(0,0,0,.35);
display:none;
align-items:center;
justify-content:center;
padding:18px
}
.lsbx-set-card{
width:min(760px,96vw);
max-height:88vh;
overflow:auto;
background:#fff;
color:#222;
border-radius:14px;
box-shadow:0 20px 60px rgba(0,0,0,.3)
}
.lsbx-set-head{
position:sticky;
top:0;
z-index:2;
background:inherit;
display:flex;
align-items:center;
padding:14px 17px;
border-bottom:1px solid #eee
}
.lsbx-set-head b{
flex:1;
font-size:17px
}
.lsbx-set-body{
padding:12px 17px 18px
}
.lsbx-set-group{
margin:12px 0 20px
}
.lsbx-set-group h3{
font-size:14px;
margin:0 0 8px
}
.lsbx-row{
display:flex;
align-items:center;
gap:10px;
padding:7px 0;
border-bottom:1px dashed rgba(127,127,127,.18)
}
.lsbx-row label{
flex:1
}
.lsbx-row input[type=text],
.lsbx-row input[type=number],
.lsbx-row select,
.lsbx-row textarea{
width:min(390px,55vw);
box-sizing:border-box;
padding:6px 8px;
border:1px solid #ccc;
border-radius:6px;
background:transparent;
color:inherit
}
.lsbx-row textarea{
min-height:70px;
resize:vertical
}
.lsbx-set-actions{
display:flex;
justify-content:flex-end;
gap:8px;
padding:13px 17px;
border-top:1px solid #eee;
position:sticky;
bottom:0;
background:inherit
}
.lsbx-set-actions button{
padding:7px 13px;
border-radius:7px;
border:1px solid #ccc;
background:#fff;
cursor:pointer
}
.lsbx-set-actions button.primary{
background:#2563eb;
color:#fff;
border-color:#2563eb
}
#lsbx-lightbox{
position:fixed;
inset:0;
z-index:1000001;
background:rgba(0,0,0,.9);
display:none;
align-items:center;
justify-content:center
}
#lsbx-lightbox.show{
display:flex
}
#lsbx-lightbox img{
max-width:92vw;
max-height:90vh;
object-fit:contain
}
#lsbx-lightbox button{
position:absolute;
border:0;
background:rgba(0,0,0,.35);
color:#fff;
font-size:28px;
cursor:pointer;
border-radius:50%;
width:44px;
height:44px
}
#lsbx-lightbox .x{
right:18px;
top:18px
}
#lsbx-lightbox .p{
left:18px;
top:50%
}
#lsbx-lightbox .n{
right:18px;
top:50%
}
#lsbx-usercard{
position:fixed;
z-index:999999;
min-width:220px;
max-width:320px;
padding:12px;
background:#fff;
color:#222;
border:1px solid #ddd;
border-radius:10px;
box-shadow:0 10px 35px rgba(0,0,0,.22);
display:none;
font-size:13px;
pointer-events:none
}
.lsbx-sign-tip{
position:fixed;
left:50%;
top:12px;
transform:translateX(-50%);
z-index:999998;
padding:9px 12px;
border-radius:9px;
background:#fff7ed;
color:#9a3412;
border:1px solid #fed7aa;
box-shadow:0 5px 20px rgba(0,0,0,.15);
font-size:13px
}
.lsbx-sign-tip button{
margin-left:8px;
border:0;
background:transparent;
color:#2563eb;
cursor:pointer
}
.lsbx-callout{
--c:99,102,241;
margin:12px 0;
padding:10px 12px 10px 16px;
border-left:4px solid rgb(var(--c));
border-radius:7px;
background:rgba(var(--c),.08)
}
.lsbx-callout-title{
font-weight:700;
color:rgb(var(--c));
margin-bottom:5px
}
.lsbx-callout[data-type=warning],
.lsbx-callout[data-type=caution]{
--c:217,119,6
}
.lsbx-callout[data-type=danger],
.lsbx-callout[data-type=error]{
--c:220,38,38
}
.lsbx-callout[data-type=success],
.lsbx-callout[data-type=check]{
--c:5,150,105
}
.lsbx-callout[data-type=tip],
.lsbx-callout[data-type=important]{
--c:14,116,144
}
@media(prefers-color-scheme:dark){
#lsbx-history,
.lsbx-set-card,
#lsbx-usercard{
background:#202226;
color:#eee;
border-color:#3b3d43
}
.lsbx-h-head,
.lsbx-h-tabs,
.lsbx-set-head,
.lsbx-set-actions{
border-color:#383a40
}
.lsbx-h-search,
.lsbx-row input[type=text],
.lsbx-row input[type=number],
.lsbx-row select,
.lsbx-row textarea{
border-color:#4a4c52
}
.lsbx-set-actions button{
background:#292b30;
color:#eee;
border-color:#4a4c52
}
.lsbx-set-actions button.primary{
background:#2563eb
}
.lsbx-sign-tip{
background:#3b2a18;
color:#fed7aa;
border-color:#7c4a16
}
}
`);
function toast(msg, type = 'info', timeout = 2600) {
let w = $('#lsbx-toast-wrap');
if (!w) {
w = document.createElement('div');
w.id = 'lsbx-toast-wrap';
document.body.appendChild(w);
}
const t = document.createElement('div');
t.className = 'lsbx-toast';
t.textContent =
({
success: '✓ ',
error: '✕ ',
warning: '⚠ ',
info: ''
}[type] || '') + msg;
w.appendChild(t);
setTimeout(() => t.remove(), timeout);
}
// =========================================================
// 页面 / DOM 识别
// =========================================================
function topicLinks(root = document) {
return $$('a[href]', root).filter(a => topicId(a.href));
}
function topicTitleAnchor(row) {
const list = $$('a[href]', row).filter(a => topicId(a.href));
if (!list.length) return null;
return list.sort(
(a, b) =>
b.textContent.trim().length -
a.textContent.trim().length
)[0];
}
function topicRows(root = document) {
const links = topicLinks(root);
return [
...new Set(
links
.map(a =>
a.closest(
'li,article,.topic-item,.post-item,.list-group-item,tr'
)
)
.filter(Boolean)
)
];
}
function postList(root = document) {
return (
root.querySelector('ul.topic-post-list') ||
(
isTopicPage()
? root.querySelector('ul.post-list')
: null
)
);
}
function topicList(root = document) {
const lists = $$('ul.post-list', root)
.filter(x => !x.classList.contains('topic-post-list'));
if (lists.length) return lists[0];
const rows = topicRows(root);
return rows[0]?.parentElement || null;
}
function nextPage(root = document) {
const a = $$('a[href]', root).find(
x =>
/^(下一页|下页|next|›|»)$/.test(
x.textContent.trim().toLowerCase()
) ||
/next/i.test(x.rel || '')
);
return a ? abs(a.getAttribute('href')) : '';
}
function contentImages(root = document) {
return $$('img', root).filter(img => {
if (img.closest('a[href*="/user/"]')) return false;
const area = img.closest(
'.post-content,.topic-post-list,article'
);
if (!area) return false;
const w =
img.naturalWidth ||
+img.getAttribute('width') ||
0;
const h =
img.naturalHeight ||
+img.getAttribute('height') ||
0;
return !(w && h && w <= 80 && h <= 80);
});
}
function replyTextarea() {
const list = $$('textarea').filter(
t =>
t.offsetParent !== null &&
!t.closest('form[action*="search"]')
);
return (
list.find(t => t.closest('form')) ||
list[0] ||
null
);
}
// =========================================================
// 自动签到
// 使用 LINUX.SB 自己的签到控件 / 表单
// =========================================================
const SIGN_WORDS = [
'每日签到',
'立即签到',
'签到领奖励',
'签到领取',
'签到'
];
const DONE_WORDS = [
'今日已签到',
'今天已签到',
'已签到'
];
function signText(el) {
return String(
el?.value ||
el?.textContent ||
''
)
.replace(/\s+/g, '')
.trim();
}
function isSignDoneText(text) {
return DONE_WORDS.some(x => text.includes(x));
}
function findSignControl(root = document) {
const all = $$(
'button,a,input[type="submit"],input[type="button"]',
root
);
const scored = all
.map(el => {
const text = signText(el);
if (
!text ||
!SIGN_WORDS.some(x => text.includes(x))
) {
return null;
}
if (
el.closest(
'.post-content,.topic-post-list,.post-list article'
)
) {
return null;
}
let score = 0;
if (text === '每日签到') score += 50;
if (text === '签到') score += 30;
if (
/每日签到|签到领奖励|立即签到/.test(text)
) {
score += 20;
}
const p =
`${el.id} ${el.className} ` +
`${el.closest('form,section,aside,div')?.className || ''}`;
if (
/daily|check.?in|sign|sidebar|card/i.test(p)
) {
score += 15;
}
if (
isSignDoneText(text) ||
el.disabled
) {
score += 100;
}
return {
el,
text,
score
};
})
.filter(Boolean)
.sort((a, b) => b.score - a.score);
return scored[0] || null;
}
function signState() {
return gmGet(SIGN_KEY, {});
}
function setSignState(patch) {
gmSet(
SIGN_KEY,
{
...signState(),
...patch
}
);
}
function parseSignResult(text = '') {
const compact = String(text)
.replace(/\s+/g, ' ');
if (
/未登录|请登录|登录后/.test(compact)
) {
return {
ok: false,
login: false,
msg: '未登录'
};
}
if (
/今日已签到|今天已签到|已经签到|重复签到/.test(compact)
) {
return {
ok: true,
done: true,
msg: '今天已经签到'
};
}
if (
/签到成功|积分增加|当前积分|连续签到/.test(compact)
) {
const gain = compact.match(
/积分(?:增加|\+)?\s*([+-]?\d+)/
)?.[1];
return {
ok: true,
done: true,
msg: gain
? `签到成功,积分 +${gain}`
: '签到成功'
};
}
return {
ok: false,
unknown: true,
msg: '未识别签到结果'
};
}
async function submitDetachedControl(
control,
sourceDoc
) {
const el = control.el;
if (isSignDoneText(control.text)) {
return {
ok: true,
done: true,
msg: '今天已经签到'
};
}
const form = el.closest('form');
try {
if (form) {
const action = abs(
el.getAttribute('formaction') ||
form.getAttribute('action') ||
'/'
);
const method = (
el.getAttribute('formmethod') ||
form.getAttribute('method') ||
'GET'
).toUpperCase();
const fd = new FormData(form);
if (el.name) {
fd.append(
el.name,
el.value ||
el.textContent.trim()
);
}
let url = action;
let opts = {
method,
credentials: 'include',
headers: {
'X-Requested-With':
'XMLHttpRequest'
}
};
if (method === 'GET') {
const u = new URL(url);
for (
const [k, v]
of fd.entries()
) {
u.searchParams.append(k, v);
}
url = u.href;
} else {
opts.body = fd;
}
const r = await fetch(url, opts);
const text = await r.text();
return {
...parseSignResult(text),
status: r.status,
raw: text
};
}
const href =
el.getAttribute('href');
if (
href &&
!/^javascript:/i.test(href)
) {
const r = await fetch(
abs(href),
{
credentials: 'include',
headers: {
'X-Requested-With':
'XMLHttpRequest'
}
}
);
const text = await r.text();
return {
...parseSignResult(text),
status: r.status,
raw: text
};
}
} catch (e) {
return {
ok: false,
error: e,
msg: e.message
};
}
return {
ok: false,
unknown: true,
msg: '签到控件需要页面脚本触发'
};
}
async function autoSignIn(force = false) {
if (!cfg.signIn && !force) return;
if (!isLoggedIn()) return;
const today = todayCN();
const st = signState();
if (
!force &&
st.date === today &&
st.done
) {
return;
}
if (
!force &&
st.lastAttempt &&
Date.now() - st.lastAttempt <
5 * 60 * 1000
) {
return;
}
setSignState({
lastAttempt: Date.now()
});
let control =
findSignControl(document);
if (
control?.el &&
isSignDoneText(control.text)
) {
setSignState({
date: today,
done: true
});
return;
}
if (control?.el) {
const r =
await submitDetachedControl(
control,
document
);
if (r.ok) {
setSignState({
date: today,
done: true,
lastAttempt: Date.now()
});
toast(r.msg, 'success');
return true;
}
// 当前页面的签到按钮可能由站点 JS 接管
if (
r.unknown &&
control.el.isConnected &&
['BUTTON', 'A', 'INPUT']
.includes(control.el.tagName)
) {
control.el.click();
await sleep(1200);
const now =
findSignControl(document);
if (
now &&
isSignDoneText(now.text)
) {
setSignState({
date: today,
done: true
});
toast(
'签到成功',
'success'
);
return true;
}
}
}
// 当前页面没有签到按钮时,从首页读取
// 同时保留服务器生成的 CSRF 字段
try {
const res = await fetch(
'/',
{
credentials: 'include'
}
);
const html = await res.text();
const doc =
new DOMParser()
.parseFromString(
html,
'text/html'
);
control =
findSignControl(doc);
if (!control) {
log(
'首页未找到签到控件'
);
return false;
}
if (
isSignDoneText(
control.text
)
) {
setSignState({
date: today,
done: true
});
return true;
}
const r =
await submitDetachedControl(
control,
doc
);
if (r.ok) {
setSignState({
date: today,
done: true,
lastAttempt: Date.now()
});
toast(
r.msg,
'success'
);
return true;
}
log(
'签到返回未识别',
r.status,
r.msg
);
} catch (e) {
log(
'自动签到失败',
e
);
}
return false;
}
async function signTip() {
if (
cfg.signIn ||
!cfg.signInTip ||
!isLoggedIn()
) {
return;
}
const today = todayCN();
const st = signState();
if (
st.date === today &&
(
st.done ||
st.ignore
)
) {
return;
}
let control =
findSignControl(document);
if (!control) {
try {
const h =
await (
await fetch(
'/',
{
credentials:
'include'
}
)
).text();
control =
findSignControl(
new DOMParser()
.parseFromString(
h,
'text/html'
)
);
} catch {}
}
if (
!control ||
isSignDoneText(control.text)
) {
return;
}
const b =
document.createElement('div');
b.className =
'lsbx-sign-tip';
b.innerHTML =
`今天还没签到。` +
`<button data-a="go">签到</button>` +
`<button data-a="ignore">今天不提示</button>`;
b.onclick =
async e => {
if (
e.target.dataset.a ===
'ignore'
) {
setSignState({
date: today,
ignore: true
});
b.remove();
}
if (
e.target.dataset.a ===
'go'
) {
cfg.signIn = true;
await autoSignIn(true);
cfg.signIn = false;
b.remove();
}
};
document.body.appendChild(b);
}
// =========================================================
// 自动翻页
// =========================================================
function enhanceNewContent(
root = document
) {
applyFilters(root);
markReplied(root);
bindImages(root);
highlightCode(root);
renderCallouts(root);
purifyLinks(root);
applyTopicNewTab(root);
}
function initInfiniteScroll() {
let busy = false;
let next = nextPage(document);
let lastY = scrollY;
const mode =
isTopicPage()
? 'reply'
: 'topic';
if (
(
mode === 'reply' &&
!cfg.autoLoadReplies
) ||
(
mode === 'topic' &&
!cfg.autoLoadTopics
)
) {
return;
}
const load =
async () => {
if (
busy ||
!next
) {
return;
}
if (
document.documentElement
.scrollHeight >
innerHeight +
scrollY +
1200
) {
return;
}
busy = true;
try {
const r =
await fetch(
next,
{
credentials:
'include'
}
);
const html =
await r.text();
const doc =
new DOMParser()
.parseFromString(
html,
'text/html'
);
const src =
mode === 'reply'
? postList(doc)
: topicList(doc);
const dst =
mode === 'reply'
? postList(document)
: topicList(document);
if (
src &&
dst
) {
const frag =
document
.createDocumentFragment();
[...src.children]
.forEach(
ch =>
frag.appendChild(
ch
)
);
dst.appendChild(
frag
);
enhanceNewContent(
dst
);
next =
nextPage(doc);
toast(
next
? '已加载下一页'
: '已经到底了',
'info',
1200
);
} else {
next = '';
}
} catch (e) {
log(
'自动翻页失败',
e
);
}
busy = false;
};
addEventListener(
'scroll',
debounce(
() => {
if (
scrollY >=
lastY
) {
load();
}
lastY =
scrollY;
},
120
),
{
passive: true
}
);
}
// =========================================================
// 屏蔽帖子 / 用户 / 权限帖
// =========================================================
function normalizeList(v) {
return (
Array.isArray(v)
? v
: String(v || '')
.split(/\n|,/)
)
.map(
x =>
x
.trim()
.toLowerCase()
)
.filter(Boolean);
}
function applyFilters(
root = document
) {
if (cfg.blockPosts) {
const kws =
normalizeList(
cfg.blockKeywords
);
if (kws.length) {
topicRows(root)
.forEach(
row => {
const a =
topicTitleAnchor(
row
);
const t =
a
?.textContent
?.trim()
.toLowerCase() ||
'';
if (
kws.some(
k =>
t.includes(
k
)
)
) {
row.classList.add(
'lsbx-hidden'
);
}
}
);
}
}
if (cfg.blockUsers) {
const blocked =
new Set(
normalizeList(
cfg.blockedUsers
)
);
if (blocked.size) {
$$(
'a[href*="/user/"]',
root
).forEach(
a => {
const n =
a
.textContent
.trim()
.toLowerCase();
if (
!blocked.has(n)
) {
return;
}
const row =
a.closest(
'li,article,.post-item,.topic-item,.list-group-item,tr'
);
if (row) {
row.classList.add(
'lsbx-hidden'
);
}
}
);
}
}
if (
cfg.blockRestricted
) {
topicRows(root)
.forEach(
row => {
if (
row.querySelector(
'[class*="lock"],[title*="权限"],[aria-label*="权限"],use[href*="lock"]'
)
) {
row.classList.add(
'lsbx-hidden'
);
}
}
);
}
}
function installUserBlockButtons() {
if (
!cfg.blockUsers ||
!isTopicPage()
) {
return;
}
const run =
root =>
$$(
'a[href*="/user/"]',
root
).forEach(
a => {
if (
!userId(a.href) ||
a.dataset
.lsbxBlock
) {
return;
}
a.dataset.lsbxBlock =
'1';
const b =
document
.createElement(
'button'
);
b.type =
'button';
b.textContent =
'×';
b.title =
'本地屏蔽此用户';
b.style.cssText =
'margin-left:4px;' +
'border:0;' +
'background:transparent;' +
'color:#999;' +
'cursor:pointer;' +
'font-size:12px';
b.onclick =
e => {
e.preventDefault();
e.stopPropagation();
const n =
a
.textContent
.trim();
const list =
new Set(
normalizeList(
cfg.blockedUsers
)
);
list.add(
n.toLowerCase()
);
cfg.blockedUsers =
[...list];
saveCfg();
applyFilters(
document
);
toast(
`已在本机屏蔽 ${n}`,
'success'
);
};
a.after(b);
}
);
run(document);
observe(run);
}
// =========================================================
// Ctrl/Cmd + Enter
// =========================================================
function initCtrlEnter() {
if (!cfg.ctrlEnter) {
return;
}
document.addEventListener(
'keydown',
e => {
if (
!(e.ctrlKey || e.metaKey) ||
e.key !== 'Enter'
) {
return;
}
const t =
e.target.closest?.(
'textarea,[contenteditable="true"]'
);
if (!t) return;
const form =
t.closest('form');
if (!form) return;
e.preventDefault();
const btn =
form.querySelector(
'button[type="submit"],input[type="submit"]'
);
if (
form.requestSubmit
) {
form.requestSubmit(
btn ||
undefined
);
} else {
btn?.click();
}
},
true
);
}
// =========================================================
// 悬浮按钮
// =========================================================
function fabWrap() {
let w =
$('.lsbx-fab-wrap');
if (!w) {
w =
document.createElement(
'div'
);
w.className =
'lsbx-fab-wrap';
document.body
.appendChild(w);
}
return w;
}
function initQuickComment() {
if (
!cfg.quickComment ||
!isTopicPage()
) {
return;
}
const b =
document.createElement(
'button'
);
b.className =
'lsbx-fab';
b.title =
'快速回复';
b.textContent =
'💬';
b.onclick =
() => {
const t =
replyTextarea();
if (!t) {
toast(
'没有找到回复框',
'warning'
);
return;
}
t.scrollIntoView({
behavior: 'smooth',
block: 'center'
});
setTimeout(
() => t.focus(),
250
);
};
fabWrap()
.appendChild(b);
}
// =========================================================
// 回帖足迹
// =========================================================
function repliedMap() {
return gmGet(
REPLIED_KEY,
{}
);
}
function markCurrentReplied() {
const id =
currentTopicId();
if (!id) return;
const m =
repliedMap();
m[id] = {
time: Date.now(),
title:
document
.querySelector('h1')
?.textContent
.trim() ||
document.title
};
gmSet(
REPLIED_KEY,
m
);
}
function initReplyFootprint() {
if (
!cfg.replyFootprint
) {
return;
}
document.addEventListener(
'submit',
e => {
if (
isTopicPage() &&
e.target
.querySelector?.(
'textarea'
)
) {
setTimeout(
markCurrentReplied,
500
);
}
},
true
);
markReplied(document);
}
function markReplied(
root = document
) {
if (
!cfg.replyFootprint
) {
return;
}
const map =
repliedMap();
topicLinks(root)
.forEach(
a => {
const id =
topicId(
a.href
);
if (
!id ||
!map[id]
) {
return;
}
const host =
a.parentElement;
if (
host?.querySelector(
`.lsbx-replied[data-id="${id}"]`
)
) {
return;
}
const b =
document
.createElement(
'span'
);
b.className =
'lsbx-badge lsbx-replied';
b.dataset.id =
id;
b.textContent =
'已回复';
a.after(b);
}
);
}
// =========================================================
// 浏览历史
// =========================================================
function pruneHistory(arr) {
const maxAge =
(+cfg.historyDays || 7) *
864e5;
const now =
Date.now();
return (
arr ||
[]
)
.filter(
x =>
now -
x.time <
maxAge
)
.sort(
(a, b) =>
b.time -
a.time
)
.slice(
0,
+cfg.historyLimit ||
100
);
}
function loadHistory(key) {
try {
return pruneHistory(
JSON.parse(
localStorage
.getItem(key) ||
'[]'
)
);
} catch {
return [];
}
}
function saveHistory(
key,
a
) {
localStorage.setItem(
key,
JSON.stringify(
pruneHistory(a)
)
);
}
function recordHistory(key) {
if (
!cfg.history ||
!isTopicPage()
) {
return;
}
const id =
currentTopicId();
if (!id) return;
const title =
document
.querySelector('h1')
?.textContent
.trim() ||
document.title
.replace(
/ - .*$/,
''
);
const list =
loadHistory(key)
.filter(
x =>
x.id !== id
);
list.unshift({
id,
title,
time: Date.now(),
url: location.href
});
saveHistory(
key,
list
);
}
function initHistory() {
if (!cfg.history) {
return;
}
recordHistory(
HISTORY_KEY
);
addEventListener(
'beforeunload',
() =>
recordHistory(
RECENT_KEY
),
{
capture: true
}
);
const b =
document.createElement(
'button'
);
b.className =
'lsbx-fab';
b.title =
'浏览历史';
b.textContent =
'🕘';
b.onclick =
toggleHistory;
fabWrap()
.appendChild(b);
}
function ensureHistoryPanel() {
let p =
$('#lsbx-history');
if (p) return p;
p =
document.createElement(
'div'
);
p.id =
'lsbx-history';
p.innerHTML = `
<div class="lsbx-h-head">
<b>浏览历史</b>
<button data-a="clear">清空</button>
<button data-a="close">✕</button>
</div>
<div class="lsbx-h-search">
<input placeholder="搜索标题">
</div>
<div class="lsbx-h-tabs">
<button class="on" data-tab="all">全部</button>
<button data-tab="recent">最近关闭</button>
</div>
<div class="lsbx-h-list"></div>
`;
document.body
.appendChild(p);
p.dataset.tab =
'all';
p.addEventListener(
'click',
e => {
const tab =
e.target
.dataset.tab;
if (tab) {
p.dataset.tab =
tab;
$$(
'[data-tab]',
p
).forEach(
x =>
x.classList.toggle(
'on',
x.dataset.tab ===
tab
)
);
renderHistory();
return;
}
const a =
e.target
.dataset.a;
if (
a === 'close'
) {
p.classList
.remove('show');
}
if (
a === 'clear'
) {
localStorage
.removeItem(
p.dataset.tab ===
'recent'
? RECENT_KEY
: HISTORY_KEY
);
renderHistory();
}
if (
a === 'del'
) {
const key =
p.dataset.tab ===
'recent'
? RECENT_KEY
: HISTORY_KEY;
saveHistory(
key,
loadHistory(key)
.filter(
x =>
x.id !==
e.target
.dataset.id
)
);
renderHistory();
}
}
);
$('input', p)
.oninput =
renderHistory;
return p;
}
function renderHistory() {
const p =
ensureHistoryPanel();
const key =
p.dataset.tab ===
'recent'
? RECENT_KEY
: HISTORY_KEY;
const kw =
$('input', p)
.value
.trim()
.toLowerCase();
let list =
loadHistory(key);
if (kw) {
list =
list.filter(
x =>
x.title
.toLowerCase()
.includes(kw)
);
}
const box =
$('.lsbx-h-list', p);
if (!list.length) {
box.innerHTML =
'<div style="padding:16px;opacity:.55">暂无记录</div>';
return;
}
let last = '';
box.innerHTML =
list.map(
x => {
const d =
new Date(
x.time
);
const day =
d.toLocaleDateString();
const head =
day !== last
? (
last = day,
`<div class="lsbx-h-day">${esc(day)}</div>`
)
: '';
return `
${head}
<div class="lsbx-h-item">
<a href="${esc(x.url || `/topic/${x.id}`)}">${esc(x.title)}</a>
<small>${d.toLocaleTimeString([], {
hour: '2-digit',
minute: '2-digit'
})}</small>
<button data-a="del" data-id="${x.id}">×</button>
</div>
`;
}
).join('');
}
function toggleHistory() {
const p =
ensureHistoryPanel();
renderHistory();
p.classList
.toggle('show');
}
// =========================================================
// 图片预览
// =========================================================
let lbIndex = 0;
let lbImgs = [];
function ensureLightbox() {
let l =
$('#lsbx-lightbox');
if (l) return l;
l =
document.createElement(
'div'
);
l.id =
'lsbx-lightbox';
l.innerHTML =
'<button class="x">×</button>' +
'<button class="p">‹</button>' +
'<img>' +
'<button class="n">›</button>';
document.body
.appendChild(l);
$('.x', l)
.onclick =
() =>
l.classList
.remove('show');
$('.p', l)
.onclick =
() =>
showLightbox(
lbIndex - 1
);
$('.n', l)
.onclick =
() =>
showLightbox(
lbIndex + 1
);
l.onclick =
e => {
if (
e.target === l
) {
l.classList
.remove('show');
}
};
document.addEventListener(
'keydown',
e => {
if (
!l.classList
.contains('show')
) {
return;
}
if (
e.key ===
'Escape'
) {
l.classList
.remove('show');
}
if (
e.key ===
'ArrowLeft'
) {
showLightbox(
lbIndex - 1
);
}
if (
e.key ===
'ArrowRight'
) {
showLightbox(
lbIndex + 1
);
}
}
);
return l;
}
function showLightbox(i) {
if (!lbImgs.length) {
return;
}
lbIndex =
(
i +
lbImgs.length
) %
lbImgs.length;
const l =
ensureLightbox();
$('img', l).src =
lbImgs[lbIndex].src;
l.classList
.add('show');
}
function bindImages(
root = document
) {
if (
!cfg.imagePreview
) {
return;
}
contentImages(root)
.forEach(
img => {
if (
img.dataset
.lsbxPreview
) {
return;
}
img.dataset
.lsbxPreview =
'1';
img.style.cursor =
'zoom-in';
img.addEventListener(
'click',
e => {
e.preventDefault();
e.stopPropagation();
const area =
img.closest(
'.post-content,.topic-post-list,article'
) ||
document;
lbImgs =
contentImages(
area
);
showLightbox(
Math.max(
0,
lbImgs
.indexOf(
img
)
)
);
},
true
);
}
);
}
// =========================================================
// 图床上传
// =========================================================
const IMG_NAMES = {
NodeImage: 'NodeImage',
Chevereto: 'Chevereto',
LskyPro: 'LskyPro',
EasyImages: 'EasyImages',
Telegraph: 'Telegraph',
Telegraph2: 'Telegraph v2'
};
const gmRequest =
opts =>
new Promise(
(
resolve,
reject
) =>
GM_xmlhttpRequest({
...opts,
onload:
r =>
resolve(r),
onerror:
reject,
ontimeout:
reject
})
);
function imageHeaders() {
try {
return cfg.imageHeaders
? JSON.parse(
cfg.imageHeaders
)
: {};
} catch {
toast(
'图床 Headers 不是有效 JSON',
'error'
);
return {};
}
}
async function nodeImageToken() {
if (
cfg.imageToken
) {
return cfg.imageToken;
}
const r =
await gmRequest({
method: 'GET',
url:
'https://api.nodeimage.com/api/user/api-key',
headers: {
Accept:
'application/json'
},
withCredentials:
true
});
if (
r.status < 200 ||
r.status >= 300
) {
throw new Error(
'NodeImage 未登录或 Token 获取失败'
);
}
const j =
JSON.parse(
r.responseText ||
'{}'
);
if (!j.api_key) {
throw new Error(
'NodeImage Token 获取失败'
);
}
cfg.imageToken =
j.api_key;
saveCfg();
return j.api_key;
}
async function uploadOne(file) {
const p =
cfg.imageProvider;
const base =
(
cfg.imageBase ||
''
).replace(
/\/$/,
''
);
let token =
cfg.imageToken;
let headers =
imageHeaders();
let url;
let data;
let parse;
if (
p === 'NodeImage'
) {
token =
await nodeImageToken();
url =
'https://api.nodeimage.com/api/upload';
data =
new FormData();
data.append(
'image',
file
);
headers = {
Accept:
'application/json',
'X-API-Key':
token,
...headers
};
parse =
j =>
j.links
?.direct;
}
else if (
p === 'LskyPro'
) {
url =
`${base}/api/v1/upload`;
data =
new FormData();
data.append(
'file',
file
);
headers = {
Accept:
'application/json',
Authorization:
`Bearer ${token}`,
...headers
};
parse =
j =>
j.data
?.links
?.url;
}
else if (
p === 'Chevereto'
) {
url =
`${base}/api/1/upload`;
data =
new FormData();
data.append(
'source',
file
);
headers = {
Accept:
'application/json',
'X-API-Key':
token,
...headers
};
parse =
j =>
j.image
?.url;
}
else if (
p === 'EasyImages'
) {
url =
`${base}${token ? '/api/index.php' : '/app/upload.php'}`;
data =
new FormData();
data.append(
token
? 'image'
: 'file',
file
);
if (token) {
data.append(
'token',
token
);
} else {
data.append(
'sign',
Math.floor(
Date.now() /
1000
)
);
}
parse =
j =>
j.url;
}
else if (
p === 'Telegraph'
) {
url =
`${base}/upload`;
data =
new FormData();
data.append(
'file',
file
);
parse =
j =>
base +
(
j?.[0]
?.src ||
''
);
}
else if (
p === 'Telegraph2'
) {
url =
`${base}/upload`;
data =
new FormData();
data.append(
'file',
file
);
parse =
j =>
j.data;
}
else {
throw new Error(
'未知图床'
);
}
if (
!url ||
(
!base &&
p !== 'NodeImage'
)
) {
throw new Error(
'请先在设置中填写图床 URL'
);
}
const r =
await gmRequest({
method: 'POST',
url,
data,
headers
});
if (
r.status < 200 ||
r.status >= 300
) {
throw new Error(
`HTTP ${r.status}`
);
}
const j =
JSON.parse(
r.responseText ||
'{}'
);
const out =
parse(j);
if (!out) {
throw new Error(
'无法解析图床返回地址'
);
}
return out;
}
function insertTextAtCursor(
textarea,
text
) {
const s =
textarea.selectionStart ??
textarea.value.length;
const e =
textarea.selectionEnd ??
s;
textarea.value =
textarea.value
.slice(0, s) +
text +
textarea.value
.slice(e);
textarea.selectionStart =
textarea.selectionEnd =
s + text.length;
textarea.dispatchEvent(
new Event(
'input',
{
bubbles: true
}
)
);
textarea.focus();
}
async function uploadImages(
files,
textarea = replyTextarea()
) {
files =
[...files]
.filter(
f =>
f.type
.startsWith(
'image/'
)
);
if (
!files.length ||
!textarea
) {
return;
}
toast(
`开始上传 ${files.length} 张图片…`
);
const out = [];
for (
const f of files
) {
try {
const u =
await uploadOne(f);
out.push(
``
);
} catch (e) {
toast(
`${f.name}: ${e.message}`,
'error',
4000
);
}
}
if (out.length) {
insertTextAtCursor(
textarea,
(
textarea.value
? '\n'
: ''
) +
out.join('\n') +
'\n'
);
toast(
`上传完成:${out.length} 张`,
'success'
);
}
}
function initImageUpload() {
if (
!cfg.imageUpload
) {
return;
}
const b =
document.createElement(
'button'
);
b.className =
'lsbx-fab';
b.title =
`上传图片 · ${
IMG_NAMES[
cfg.imageProvider
] ||
cfg.imageProvider
}`;
b.textContent =
'🖼️';
b.onclick =
() => {
const i =
document
.createElement(
'input'
);
i.type =
'file';
i.accept =
'image/*';
i.multiple =
true;
i.onchange =
() =>
uploadImages(
i.files
);
i.click();
};
fabWrap()
.appendChild(b);
document.addEventListener(
'paste',
e => {
const t =
e.target.closest?.(
'textarea'
);
if (!t) return;
const fs =
[
...(
e.clipboardData
?.files ||
[]
)
].filter(
f =>
f.type
.startsWith(
'image/'
)
);
if (
fs.length
) {
e.preventDefault();
uploadImages(
fs,
t
);
}
}
);
document.addEventListener(
'dragover',
e => {
if (
e.target
.closest?.(
'textarea'
)
) {
e.preventDefault();
}
}
);
document.addEventListener(
'drop',
e => {
const t =
e.target.closest?.(
'textarea'
);
if (!t) return;
const fs =
[
...(
e.dataTransfer
?.files ||
[]
)
].filter(
f =>
f.type
.startsWith(
'image/'
)
);
if (
fs.length
) {
e.preventDefault();
uploadImages(
fs,
t
);
}
}
);
}
// =========================================================
// 代码高亮
// =========================================================
let hlLoading = false;
function highlightCode(
root = document
) {
if (
!cfg.codeHighlight
) {
return;
}
const codes =
$$(
'pre code',
root
).filter(
x =>
!x.dataset
.lsbxHl
);
if (
!codes.length
) {
return;
}
const run =
() =>
codes.forEach(
c => {
c.dataset
.lsbxHl =
'1';
try {
window.hljs
?.highlightElement(
c
);
} catch {}
}
);
if (window.hljs) {
run();
return;
}
if (hlLoading) {
return;
}
hlLoading = true;
const s =
document.createElement(
'script'
);
s.src =
'https://s4.zstatic.net/ajax/libs/highlight.js/11.9.0/highlight.min.js';
s.onload =
() => {
hlLoading =
false;
run();
};
document.head
.appendChild(s);
}
// =========================================================
// Callout
// =========================================================
function renderCallouts(
root = document
) {
if (!cfg.callout) {
return;
}
$$(
'.post-content blockquote,blockquote',
root
).forEach(
bq => {
if (
bq.dataset
.lsbxCallout
) {
return;
}
bq.dataset
.lsbxCallout =
'1';
const text =
bq.innerText
.trim();
const m =
text.match(
/^\[!(\w+)\]([+-])?\s*([^\n]*)\n?([\s\S]*)$/i
);
if (!m) {
return;
}
const [
,
type,
fold,
title,
body
] = m;
const w =
document
.createElement(
'div'
);
w.className =
'lsbx-callout';
w.dataset.type =
type.toLowerCase();
w.innerHTML =
`<div class="lsbx-callout-title">${esc(title || type)}</div>` +
`<div class="lsbx-callout-body">${esc(body).replace(/\n/g, '<br>')}</div>`;
if (fold) {
w.style.cursor =
'pointer';
if (
fold === '-'
) {
$(
'.lsbx-callout-body',
w
).style.display =
'none';
}
w.onclick =
() => {
const x =
$(
'.lsbx-callout-body',
w
);
x.style.display =
x.style.display ===
'none'
? ''
: 'none';
};
}
bq.replaceWith(w);
}
);
}
// =========================================================
// 悬停预加载
// =========================================================
function initPrefetch() {
if (!cfg.prefetch) {
return;
}
const done =
new Set();
let timer;
document.addEventListener(
'mouseover',
e => {
const a =
e.target.closest?.(
'a[href]'
);
if (
!a ||
!topicId(a.href) ||
done.has(a.href)
) {
return;
}
clearTimeout(timer);
timer =
setTimeout(
() => {
if (
!a.matches(
':hover'
)
) {
return;
}
const l =
document
.createElement(
'link'
);
l.rel =
'prefetch';
l.href =
a.href;
document.head
.appendChild(l);
done.add(
a.href
);
},
80
);
},
{
passive: true
}
);
}
// =========================================================
// 链接净化 / 新标签
// =========================================================
const TRACK_PARAMS =
new Set([
'gclid',
'fbclid',
'msclkid',
'ref',
'referral',
'referrer',
'affiliate',
'aff',
'aff_id',
'clickid',
'source',
'from',
'channel',
'campaign',
'via',
'spm_id_from',
'share_source',
'share_medium',
'share_plat',
'share_tag',
'share_session_id'
]);
function purifyLinks(
root = document
) {
if (
!cfg.linkPurifier &&
!cfg.externalNewTab
) {
return;
}
$$(
'a[href]',
root
).forEach(
a => {
if (
a.dataset
.lsbxLink
) {
return;
}
a.dataset
.lsbxLink =
'1';
try {
const u =
new URL(
a.href,
BASE
);
if (
cfg.linkPurifier
) {
[
...u.searchParams
.keys()
].forEach(
k => {
if (
k
.toLowerCase()
.startsWith(
'utm_'
) ||
TRACK_PARAMS
.has(
k.toLowerCase()
)
) {
u.searchParams
.delete(k);
}
}
);
a.href =
u.href;
}
if (
cfg.externalNewTab &&
!sameHost(u.href)
) {
a.target =
'_blank';
a.rel =
'noopener noreferrer';
}
} catch {}
}
);
}
function applyTopicNewTab(
root = document
) {
if (
!cfg.openTopicNewTab
) {
return;
}
topicLinks(root)
.forEach(
a => {
a.target =
'_blank';
a.rel =
'noopener';
}
);
}
// =========================================================
// 已访问帖子颜色
// =========================================================
function initVisited() {
if (
!cfg.visitedColor
) {
return;
}
addStyle(
'lsbx-visited',
`
a[href*="/topic/"]:visited{
color:${cfg.visitedColorLight}!important
}
@media(prefers-color-scheme:dark){
a[href*="/topic/"]:visited{
color:${cfg.visitedColorDark}!important
}
}
`
);
}
// =========================================================
// 用户卡片 + 楼主积分
// =========================================================
const profileCache =
new Map();
async function fetchProfile(a) {
const id =
userId(a.href);
if (!id) {
return null;
}
if (
profileCache.has(id)
) {
return profileCache
.get(id);
}
const p =
(
async () => {
try {
const html =
await (
await fetch(
a.href,
{
credentials:
'include'
}
)
).text();
const doc =
new DOMParser()
.parseFromString(
html,
'text/html'
);
const txt =
doc.body
.innerText
.replace(
/\s+/g,
' '
);
const name =
(
doc.querySelector(
'h1,h2,.username,.user-name'
)
?.textContent ||
a.textContent
).trim();
const points =
txt.match(
/积分\s*[::]?\s*(\d+)/
)?.[1] ||
txt.match(
/(\d+)\s*积分/
)?.[1] ||
'';
const role =
txt.match(
/(站长|管理员|锦衣卫|汉高祖|会员|用户|笔友)/
)?.[1] ||
'';
const bio =
txt.match(
/个人简介\s*[::]?\s*([^|]{1,100})/
)?.[1]
?.trim() ||
'';
return {
id,
name,
points,
role,
bio,
url:
a.href
};
} catch {
return {
id,
name:
a.textContent
.trim(),
url:
a.href
};
}
}
)();
profileCache.set(
id,
p
);
return p;
}
function initUserCard() {
if (!cfg.userCard) {
return;
}
let card =
document.createElement(
'div'
);
card.id =
'lsbx-usercard';
document.body
.appendChild(card);
let timer;
let active;
document.addEventListener(
'mouseover',
e => {
const a =
e.target.closest?.(
'a[href*="/user/"]'
);
if (
!a ||
!userId(a.href)
) {
return;
}
active = a;
clearTimeout(timer);
timer =
setTimeout(
async () => {
if (
active !== a
) {
return;
}
const p =
await fetchProfile(
a
);
if (!p) {
return;
}
card.innerHTML =
`<b>${esc(p.name)}</b>` +
(
p.role
? `<div style="margin-top:5px">${esc(p.role)}</div>`
: ''
) +
(
p.points
? `<div>积分:${esc(p.points)}</div>`
: ''
) +
(
p.bio
? `<div style="margin-top:6px;opacity:.7">${esc(p.bio)}</div>`
: ''
);
const r =
a.getBoundingClientRect();
card.style.left =
Math.min(
innerWidth -
340,
Math.max(
8,
r.left
)
) +
'px';
card.style.top =
Math.min(
innerHeight -
150,
r.bottom +
7
) +
'px';
card.style.display =
'block';
},
350
);
}
);
document.addEventListener(
'mouseout',
e => {
const a =
e.target.closest?.(
'a[href*="/user/"]'
);
if (a) {
active =
null;
clearTimeout(
timer
);
card.style.display =
'none';
}
}
);
}
async function initOpPointsBadge() {
if (
!cfg.opPointsBadge ||
!isTopicPage()
) {
return;
}
const list =
postList(document);
if (!list) {
return;
}
const first =
list.firstElementChild;
if (!first) {
return;
}
const a =
$(
'a[href*="/user/"]',
first
);
if (
!a ||
a.dataset
.lsbxPoints
) {
return;
}
a.dataset.lsbxPoints =
'1';
const p =
await fetchProfile(a);
if (!p?.points) {
return;
}
const b =
document.createElement(
'span'
);
b.className =
'lsbx-badge';
b.textContent =
`积分 ${p.points}`;
a.after(b);
}
// =========================================================
// 未读消息提醒
// =========================================================
function initUnreadNotice() {
if (
!cfg.unreadNotice ||
!isLoggedIn()
) {
return;
}
const CH =
'lsbx_unread_v1';
const KEY =
'lsbx_unread_leader_v1';
const me =
`${Date.now()}-${Math.random()}`;
let leader =
false;
let unread =
-1;
let flashTimer =
0;
let original =
'';
let bc =
null;
try {
bc =
new BroadcastChannel(
CH
);
} catch {}
const elect =
() => {
try {
const cur =
JSON.parse(
localStorage
.getItem(KEY) ||
'null'
);
if (
!cur ||
Date.now() -
cur.t >
45000
) {
localStorage
.setItem(
KEY,
JSON.stringify({
id: me,
t:
Date.now()
})
);
leader =
true;
} else {
leader =
cur.id ===
me;
}
} catch {
leader =
true;
}
};
const stop =
() => {
if (
flashTimer
) {
clearInterval(
flashTimer
);
flashTimer =
0;
document.title =
original;
}
};
const update =
n => {
n =
+n ||
0;
const isNew =
unread >= 0 &&
n > unread;
unread =
n;
if (!n) {
stop();
return;
}
if (
isNew &&
!document.hasFocus()
) {
stop();
original =
document.title;
let on =
false;
flashTimer =
setInterval(
() => {
document.title =
on
? original
: `【🔔 ${n} 条新消息】${original}`;
on =
!on;
},
700
);
}
};
const parse =
html => {
const d =
new DOMParser()
.parseFromString(
html,
'text/html'
);
return d
.querySelectorAll(
'.notify-dot-link,[class*="notify-dot"]'
)
.length;
};
const poll =
async () => {
elect();
if (!leader) {
return;
}
try {
localStorage
.setItem(
KEY,
JSON.stringify({
id:
me,
t:
Date.now()
})
);
const html =
await (
await fetch(
'/',
{
credentials:
'include'
}
)
).text();
const n =
parse(html);
update(n);
bc?.postMessage({
n
});
} catch (e) {
log(
'未读消息检查失败',
e
);
}
};
if (bc) {
bc.onmessage =
e =>
update(
e.data?.n
);
}
addEventListener(
'focus',
stop
);
addEventListener(
'beforeunload',
() => {
try {
const cur =
JSON.parse(
localStorage
.getItem(KEY) ||
'null'
);
if (
cur?.id ===
me
) {
localStorage
.removeItem(
KEY
);
}
} catch {}
}
);
elect();
poll();
setInterval(
poll,
30000
);
}
// =========================================================
// 设置面板
// =========================================================
const SET_GROUPS = [
[
'基本设置',
[
[
'signIn',
'自动签到',
'bool'
],
[
'signInTip',
'关闭自动签到时提醒',
'bool'
],
[
'autoLoadTopics',
'列表自动加载下一页',
'bool'
],
[
'autoLoadReplies',
'帖子回复自动加载',
'bool'
],
[
'ctrlEnter',
'Ctrl/Cmd + Enter 回复',
'bool'
],
[
'quickComment',
'快速回复悬浮按钮',
'bool'
]
]
],
[
'过滤设置',
[
[
'blockPosts',
'屏蔽关键词帖子',
'bool'
],
[
'blockKeywords',
'帖子关键词(每行一个)',
'lines'
],
[
'blockUsers',
'本地屏蔽用户',
'bool'
],
[
'blockedUsers',
'用户名(每行一个)',
'lines'
],
[
'blockRestricted',
'隐藏带权限/锁标记帖子',
'bool'
]
]
],
[
'内容设置',
[
[
'history',
'浏览历史',
'bool'
],
[
'historyLimit',
'历史保存上限',
'number'
],
[
'historyDays',
'历史保存天数',
'number'
],
[
'replyFootprint',
'回帖足迹',
'bool'
],
[
'imagePreview',
'图片预览',
'bool'
],
[
'codeHighlight',
'代码高亮',
'bool'
],
[
'callout',
'Callout 渲染',
'bool'
],
[
'prefetch',
'悬停预加载帖子',
'bool'
]
]
],
[
'链接与显示',
[
[
'linkPurifier',
'净化跟踪参数',
'bool'
],
[
'externalNewTab',
'外链新标签页',
'bool'
],
[
'openTopicNewTab',
'站内帖子新标签页',
'bool'
],
[
'smoothScroll',
'平滑滚动',
'bool'
],
[
'visitedColor',
'已访问帖子变色',
'bool'
],
[
'visitedColorLight',
'浅色模式访问颜色',
'text'
],
[
'visitedColorDark',
'深色模式访问颜色',
'text'
],
[
'userCard',
'悬停用户卡片',
'bool'
],
[
'unreadNotice',
'未读消息提醒(跨标签同步)',
'bool'
],
[
'opPointsBadge',
'楼主积分标签',
'bool'
]
]
],
[
'图床设置',
[
[
'imageUpload',
'启用图床上传',
'bool'
],
[
'imageProvider',
'当前图床',
'provider'
],
[
'imageBase',
'图床 URL',
'text'
],
[
'imageToken',
'API Token',
'text'
],
[
'imageHeaders',
'自定义 Headers(JSON)',
'textarea'
]
]
],
[
'调试',
[
[
'debug',
'控制台调试日志',
'bool'
]
]
]
];
function settingField(
k,
label,
type
) {
const v =
cfg[k];
if (
type === 'bool'
) {
return `
<div class="lsbx-row">
<label>${esc(label)}</label>
<input
data-k="${k}"
type="checkbox"
${v ? 'checked' : ''}
>
</div>`;
}
if (
type === 'number'
) {
return `
<div class="lsbx-row">
<label>${esc(label)}</label>
<input
data-k="${k}"
type="number"
value="${esc(v)}"
>
</div>`;
}
if (
type === 'lines'
) {
return `
<div class="lsbx-row">
<label>${esc(label)}</label>
<textarea data-k="${k}">${esc((Array.isArray(v) ? v : []).join('\n'))}</textarea>
</div>`;
}
if (
type === 'textarea'
) {
return `
<div class="lsbx-row">
<label>${esc(label)}</label>
<textarea data-k="${k}">${esc(v)}</textarea>
</div>`;
}
if (
type === 'provider'
) {
return `
<div class="lsbx-row">
<label>${esc(label)}</label>
<select data-k="${k}">
${
Object.entries(
IMG_NAMES
)
.map(
([x, n]) =>
`<option value="${x}" ${v === x ? 'selected' : ''}>${n}</option>`
)
.join('')
}
</select>
</div>`;
}
return `
<div class="lsbx-row">
<label>${esc(label)}</label>
<input
data-k="${k}"
type="text"
value="${esc(v)}"
>
</div>`;
}
function openSettings() {
let o =
$('#lsbx-settings');
if (!o) {
o =
document.createElement(
'div'
);
o.id =
'lsbx-settings';
o.innerHTML = `
<div class="lsbx-set-card">
<div class="lsbx-set-head">
<b>LINUX SB X 设置</b>
<button
data-a="close"
style="
border:0;
background:transparent;
cursor:pointer;
color:inherit;
font-size:20px
"
>
×
</button>
</div>
<div class="lsbx-set-body">
${
SET_GROUPS
.map(
([g, fs]) =>
`<section class="lsbx-set-group">
<h3>${g}</h3>
${fs.map(x => settingField(...x)).join('')}
</section>`
)
.join('')
}
</div>
<div class="lsbx-set-actions">
<button data-a="reset">
恢复默认
</button>
<button data-a="close">
取消
</button>
<button
class="primary"
data-a="save"
>
保存并刷新
</button>
</div>
</div>
`;
document.body
.appendChild(o);
o.onclick =
e => {
if (
e.target === o ||
e.target.dataset.a ===
'close'
) {
o.style.display =
'none';
}
if (
e.target.dataset.a ===
'reset'
) {
cfg =
clone(
DEFAULTS
);
saveCfg();
o.remove();
openSettings();
}
if (
e.target.dataset.a ===
'save'
) {
$$(
'[data-k]',
o
).forEach(
el => {
const k =
el.dataset.k;
if (
el.type ===
'checkbox'
) {
cfg[k] =
el.checked;
}
else if (
el.type ===
'number'
) {
cfg[k] =
+el.value;
}
else if (
[
'blockKeywords',
'blockedUsers'
].includes(k)
) {
cfg[k] =
el.value
.split(
/\n|,/
)
.map(
x =>
x.trim()
)
.filter(
Boolean
);
}
else {
cfg[k] =
el.value;
}
}
);
saveCfg();
location.reload();
}
};
}
o.style.display =
'flex';
}
function initMenus() {
if (
typeof GM_registerMenuCommand !==
'function'
) {
return;
}
GM_registerMenuCommand(
'⚙️ LINUX SB X 设置',
openSettings
);
GM_registerMenuCommand(
'🕘 浏览历史',
toggleHistory
);
GM_registerMenuCommand(
'✅ 立即执行签到',
() =>
autoSignIn(true)
);
GM_registerMenuCommand(
cfg.signIn
? '⏸ 关闭自动签到'
: '▶ 开启自动签到',
() => {
cfg.signIn =
!cfg.signIn;
saveCfg();
toast(
`自动签到已${cfg.signIn ? '开启' : '关闭'}`,
'success'
);
}
);
}
// =========================================================
// MutationObserver
// =========================================================
const observers = [];
function observe(fn) {
observers.push(fn);
}
const runObservers =
debounce(
() =>
observers.forEach(
fn => {
try {
fn(document);
} catch (e) {
log(e);
}
}
),
100
);
function initObserver() {
new MutationObserver(
runObservers
).observe(
document.body,
{
childList: true,
subtree: true
}
);
}
// =========================================================
// 启动
// =========================================================
async function start() {
if (
cfg.smoothScroll
) {
addStyle(
'lsbx-smooth',
'html{scroll-behavior:smooth}'
);
}
initVisited();
enhanceNewContent(
document
);
observe(
enhanceNewContent
);
initInfiniteScroll();
initCtrlEnter();
initQuickComment();
initReplyFootprint();
initHistory();
initImageUpload();
initPrefetch();
initUserCard();
initUnreadNotice();
installUserBlockButtons();
initMenus();
initObserver();
initOpPointsBadge();
// 签到最后执行,避免影响其它模块初始化
if (cfg.signIn) {
autoSignIn(false);
} else {
signTip();
}
log(
'started',
cfg
);
}
if (
document.readyState ===
'loading'
) {
document.addEventListener(
'DOMContentLoaded',
start,
{
once: true
}
);
} else {
start();
}
})();