LINUX SB X

LINUX.SB 增强脚本:自动签到、自动翻页、快捷回复、内容过滤、浏览历史、回帖足迹、用户卡片、图片预览、图床上传、代码高亮、Callout、链接净化、新标签打开、平滑滚动等。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Greasemonkey 油猴子Violentmonkey 暴力猴,才能安装此脚本。

You will need to install an extension such as Tampermonkey or Violentmonkey to install this script.

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Violentmonkey 暴力猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Userscripts ,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展后才能安装此脚本。

(我已经安装了用户脚本管理器,让我安装!)

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

(我已经安装了用户样式管理器,让我安装!)

// ==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 => ({
                '&': '&amp;',
                '<': '&lt;',
                '>': '&gt;',
                '"': '&quot;',
                "'": '&#39;'
            }[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(
                    `![${f.name || 'image'}](${u})`
                );
            } 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();
    }

})();