色花堂 98堂 强化脚本

加强论坛功能

// ==UserScript==
// @name         色花堂 98堂 强化脚本
// @namespace    http://tampermonkey.net/
// @version      0.2.1
// @description  加强论坛功能
// @license      MIT
// @author       98_ethan
// @match        *://*.sehuatang.net/*
// @match        *://*.sehuatang.org/*
// @match        *://*.sehuatang.*/*
// @match        *://*.jq2t4.com/*
// @match        *://*.0krgb.com/*
// @match        *://*.xxjsnc.co/*
// @match        *://*.o4vag.com/*
// @match        *://*.weterytrtrr.*/*
// @match        *://*.qweqwtret.*/*
// @match        *://*.retreytryuyt.*/*
// @match        *://*.qwerwrrt.*/*
// @match        *://*.ds5hk.app/*
// @match        *://*.30fjp.com/*
// @match        *://*.18stm.cn/*
// @match        *://*.xo6c5.com/*
// @match        *://*.mzjvl.com/*
// @match        *://*.9xr2.app/*
// @match        *://*.kzs1w.com/*
// @match        *://*.nwurc.com/*
// @match        *://*.zbkz6.app/*
// @match        *://*.ql75t.cn/*
// @match        *://*.0uzb0.app/*
// @match        *://*.d2wpb.com/*
// @match        *://*.5aylp.com/*
// @match        *://*.8otvk.app/*
// @match        *://*.05kx.cc/*
// @match        *://*.1yxg2.com/*
// @match        *://*.6r5gy.co/*
// @match        *://*.mmpbg.co/*
// @match        *://*.kofqo.com/*
// @match        *://*.kofqo.net/*
// @match        *://*.9zi2n.com/*
// @match        *://*.pky0s.com/*
// @match        *://*.r88d8.com/*
// @match        *://*.z0gfi.com/*
// @match        *://*.xj4sds.com/*
// @match        *://*.5rmt2.net/*
// @match        *://*.n8cv.net/*
// @match        *://*.wbx0x.com/*
// @match        *://*.6yddw.com/*
// @match        *://*.1b0d2.com/*
// @grant        GM.getValue
// @grant        GM.setValue
// @grant        GM.deleteValue
// @grant        GM.listValues
// @grant        GM.addStyle
// @grant        GM.openInTab
// @grant        GM.registerMenuCommand
// @grant        GM.notification
// @grant        GM_getValue
// @grant        GM_setValue
// @grant        GM_deleteValue
// @grant        GM_listValues
// @grant        GM_addStyle
// @grant        GM_openInTab
// @grant        GM.registerMenuCommand
// @grant        GM_notification
// @icon         data:image/gif;base64,UklGRgABAABXRUJQVlA4WAoAAAAQAAAADwAADwAAQUxQSKYAAAANgJtt27Hn+v3HtitbrY0BnMrJBJnAWCCt2dk2Ktu2zTcrRMQEgMRWa/XZLCH8bqTqGCAqS3F5ZPmClmdXH6CRDn42AFpJdZfwIeFvn5sVUMsF9gHcHgX0jLdfgpMRtCq+KStAVGIZERAgBATy3+cNB7HX1pZ42V/P68vN2gXPeYZ/zOer4bhy1tEmTfWjn58vA6NXZkkm70fD578A1xVT1uoFLX4CVlA4IDQAAABQAQCdASoQABAAB0CWJbAABe9AAP7msGXYtkQ9eTZz0OaTWMpBhwn6oJ35ZoLVEQcVAAAA
// ==/UserScript==

const VERSION_MAJOR = 0.2;
const VERSION_TEXT = '0.2.1';

function initGM() {
    let gmExists = false;
    try {
        if (typeof GM.getValue == 'function') {
            gmExists = true;
        }
    } catch (ignore) { }

    if (gmExists) {
        return {
            getValue: GM.getValue,
            setValue: GM.setValue,
            deleteValue: GM.deleteValue,
            listValues: GM.listValues,
            addStyle: GM.addStyle,
            openInTab: GM.openInTab,
            registerMenuCommand: GM.registerMenuCommand,
            notification: GM.notification,
        };
    } else {
        return {
            getValue: GM_getValue,
            setValue: GM_setValue,
            deleteValue: GM_deleteValue,
            listValues: GM_listValues,
            addStyle: GM_addStyle,
            openInTab: GM_openInTab,
            registerMenuCommand: GM_registerMenuCommand,
            notification: GM_notification,
        };
    }
}

const MAIN_CONFIG_KEY = '98_main_config';

const DEFAULT_MAIN_CONFIG = {
    initFavorRecords: true,
    migrateFrom0d0d13: true,
    openIntroAfterUpdate: true,
    enableQuickBtn: true,
    quickBtnPosition: 'right', // left, right
    enableSortByDateline: false,

    enableAutoPaginationSearch: true,
    enableAutoPaginationSection: true,
    enableAutoPaginationUserProfile: true,
    enableSectionFilterSearch: true,
    enableSectionFilterUserProfile: true,
}

const LOAD_TIME_LIMIT = 3000;

const SEARCH_CONFIG_KEY = '98_search_config';
const DEFAULT_SEARCH_CONFIG = {};

const FAVOR_THREADS_CACHE_CONFIG_KEY = '98_favor_threads';
const DEFAULT_FAVOR_THREADS_CACHE_CONFIG = { time: 0, data: {} }; // [tid]: [favid]

const RATED_THREADS_CACHE_CONFIG_KEY = '98_rated_threads';
const DEFAULT_RATED_THREADS_CACHE_CONFIG = { time: 0, data: {} }; // [tid]: boolean

const USERS_CONFIG_CACHE_KEY = '98_users_config';
const DEFAULT_USERS_CONFIG_CACHE = {}; // { [uid]: { username: string, subscribed?: boolean, favored?: boolean, blocked?: boolean } }

const SEARCH_HIDDEN_SECTIONS = '98_search_hidden_sections';
const DEFAULT_SEARCH_HIDDEN_SECTIONS_CONFIG = {}; // { [sid]: boolean }

function initConfigAccess(myUserId, configKey, defaultValue) {
    return {
        async read() {
            return readMainConfig(myUserId, configKey, defaultValue);
        },
        async write(newValue) {
            await GM.setValue(configKey + '#' + myUserId, JSON.stringify(newValue));
        },
        async update(updater) {
            return updateMainConfig(myUserId, updater, configKey, defaultValue);
        }
    };
}

async function readMainConfig(myUserId, configKey, defaultValue) {
    try {
        let savedData = await GM.getValue(configKey + '#' + myUserId);
        return Object.assign({}, defaultValue, JSON.parse(savedData));
    } catch (e) { }
    return Object.assign({}, defaultValue);
}

async function updateMainConfig(myUserId, updater, configKey, defaultValue) {
    let oldConfig = await readMainConfig(myUserId, configKey, defaultValue);
    let newConfig = await updater(oldConfig);
    if (newConfig != null) {
        await GM.setValue(configKey + '#' + myUserId, JSON.stringify(newConfig));
    }
    return newConfig;
}

function readFavorThreadsList(doc) {
    const items = {};
    doc.querySelectorAll('ul#favorite_ul li[id^="fav_"]').forEach(a => {
        const source = a.querySelector('input[name="favorite[]"]');
        const favid = source.value * 1;
        const tid = source.getAttribute('vid') * 1;
        items[tid] = favid;
    });
    return items;
}

/**
 * 初始化收藏数据
 * @param {*} favorThreadsCacheAccess 
 * @param {*} fresh 
 * @param {*} callback 仅在 fresh 为 true 时可用
 * @returns { [tid]: favid }}
 */
async function readFavorRecords(favorThreadsCacheAccess, fresh, callback) {
    let userId = findMyUserId();

    let isLoading = false;
    const loadNextPage = async (doc) => {
        let cached = await favorThreadsCacheAccess.read();
        const favors = readFavorThreadsList(doc);
        const data = { ...cached.data, ...favors }
        await favorThreadsCacheAccess.write({ data, time: Date.now() });

        if (isLoading) return;
        const nextPageLink = doc.querySelector('.pg a.nxt');

        if (!nextPageLink) {
            await callback();
            return data;
        };
        isLoading = true;

        await fetchGetPage(nextPageLink.href)
            .then(async doc => {
                setTimeout(() => loadNextPage(doc), 1000)
            })
            .finally(() => isLoading = false)

        return data
    }

    if (fresh) {
        await favorThreadsCacheAccess.write(DEFAULT_FAVOR_THREADS_CACHE_CONFIG);
        const doc = await fetchGetPage(`home.php?mod=space&uid=${userId}&do=favorite&type=all`);
        const favors = await loadNextPage(doc);
        return favors;
    } else {
        let cached = await favorThreadsCacheAccess.read();
        return cached.data;
    }
}

async function readRatedRecords(ratedThreadsCacheAccess) {
    let cached = await ratedThreadsCacheAccess.read();
    return cached.data;
}

const createLoadingIndicator = (message) => {
    const indicator = addElem(document.body, 'div', 'ese-loading-indicator');
    indicator.textContent = message;
    return indicator;
};

function findMyUserId() {
    const userIdSource = [
        ['#um .vwmy > a', /uid=(\d+)/, 'href'], // 论坛内
        ['head script:not([src]):not([id])', /discuz_uid\ =\ \'(\d+)\'/, 'textContent'] // 搜索页
    ]
    for (let [selector, reg, key] of userIdSource) {
        const userWrap = document.querySelector(selector);
        if (userWrap != null) {
            const userMatch = userWrap[key]?.match(reg);
            if (userMatch != null) return userMatch[1] * 1;
        }
    }
    return -1; // 未登录情况下,后续添加账号迁移功能
}

const COMMON_JS_FILENAME = 'static/js/common.js'
function isCommonJsLoaded() {
    return new Promise((resolve) => {
        const script = document.querySelector(`script[src^="${COMMON_JS_FILENAME}"]`);
        if (script) {
            resolve(true)
        }
        const observer = new MutationObserver((mutations) => {
            for (const mutation of mutations) {
                if (mutation.addedNodes.length) {
                    for (const node of mutation.addedNodes) {
                        if (node.tagName === 'SCRIPT' && node.src.includes(COMMON_JS_FILENAME)) {
                            node.addEventListener('load', () => {
                                observer.disconnect();
                                resolve(true);
                            });
                        }
                    }
                }
            }
        });
        observer.observe(document.head, { childList: true });
    });
}

(async function () {
    'use strict';
    const GM = initGM();

    const INTRO_POST = document.location.origin + '/forum.php?mod=viewthread&tid=2251912';
    const UPDATE_NOTE = document.location.origin + '/forum.php?mod=redirect&goto=findpost&ptid=2251912&pid=28300683';

    const CONFIG_PAGE = document.location.origin + '/home.php?mod=spacecp&ac=enhancer';

    GM.registerMenuCommand('打开功能简介帖', () => GM.openInTab(INTRO_POST, false));
    GM.registerMenuCommand('查看更新说明', () => GM.openInTab(UPDATE_NOTE, false));
    GM.registerMenuCommand('打开插件设置页', () => GM.openInTab(CONFIG_PAGE, false));

    GM.addStyle(`
.ese-quick-button-container {
    position: fixed;
    left: calc(50vw + 510px);
    top: 205px;
    z-index: 9999;
    display: flex;
    flex-direction: column;
    font-size: 1.1em;
    margin-top: 0;
    padding: 0.2em;
    scrollbar-gutter: stable;
    scrollbar-width: thin;
    background: rgba(254, 242, 232, 0.95);
}
.ese-quick-button-container.ese-quick-button-container-left {
    left: unset;
    right: calc(50vw + 510px);
}
.ese-quick-button {
    margin: 5px;
    border: none;
    cursor: pointer;
    color: #787878;
    font-weight: bold;
    font-family: monospace;
    margin-right: 0.3em;
    min-width: 6em;
    display: inline-block;
    background: none;
}
.ese-quick-button:hover { text-decoration: underline; }
.ese-thread-bottom-button, .ese-thread-userinfo-button {
    cursor: pointer;
}
.authi .ese-thread-userinfo-button {
    opactity: 0.8;
    visibility: hidden;
}
.authi:hover .ese-thread-userinfo-button {
    opactity: 1;
    visibility: visible;
}
.ese-favorite-button, .ese-block-button {
    border: 1px solid;
    cursor: pointer;
}
.ese-favorite-button {
    background: #FEAE10;
}
.ese-quick-button.ese-active {
    background-color: #ffffe9;
    outline: 3px solid lightgray;
    color: #565656;
}
.ese-quick-button.ese-active:hover {
    outline: 3px solid #777;
}
.ese-quick-button.ese-active:after {
    content: '✔';
    position: relative;
    left: 4px;
    overflow: hidden;
    color: goldenrod;
}

/* ========== search page ========== */
.ese-filter-container {
    position: fixed;
    left: calc(50vw + 200px);
    top: 105px;
    display: flex;
    flex-direction: column;
    z-index: 9999;
    width: 140px;
    padding: 0 2px;
    max-height: 750px;
    overflow-y: scroll;
    overflow-x: hidden;
    scrollbar-width: none;
}
.ese-filter-button {
    color: #333;
    border: none;
    padding: 1px 2px;
    box-shadow: 2px 2px 1px 0 #0009;
    white-space: pre;
    font-size: 14px;
    line-height: 18px;
    margin: 8px 0;
    cursor: pointer;
    outline: 2px solid lightblue;
    font-weight: bold;
    background: #EEEE;
    position: relative;
    padding-left: 32px;
}
.ese-filter-button.ese-hidden {
    color: #CCC;
    background: #999C;
    text-decoration: line-through;
    outline-color: #999;
}
.ese-filter-button:hover {
    outline-color: deepskyblue;
}
.ese-filter-button-count {
    color: royalblue;
    padding-right: 10px;
    position: absolute;
    left: 4px;
}
.ese-filter-button.ese-hidden .ese-filter-button-count {
    color: #CCC;
}
@keyframes fadeIn {
    from {
        opacity: 0;
    }
    to {
        opacity: 1;
    }
}
.ese-fade-in {
    opacity: 0;
    animation: fadeIn 1s forwards;
}
.ese-loading-indicator {
    position: fixed;
    bottom: 20px;
    right: 20px;
    background-color: rgba(0, 0, 0, 0.8);
    color: #fff;
    padding: 12px 18px;
    border-radius: 5px;
    font-size: 14px;
    font-weight: bold;
    box-shadow: 2px 2px 5px rgba(0, 0, 0, 0.4);
    z-index: 10000;
    display: flex;
    align-items: center;
    gap: 8px;
}
.ese-loading-indicator::before {
    content: '';
    display: inline-block;
    width: 14px;
    height: 14px;
    border: 3px solid #fff;
    border-radius: 50%;
    border-top-color: transparent;
    animation: spin 1s linear infinite;
}
@keyframes spin {
    0% { transform: rotate(0deg); }
    100% { transform: rotate(360deg); }
}

@media only screen and (max-width: 1280px) {
    .ese-quick-button-container, .ese-filter-container {
        left: unset;
        right: 3px;
        max-height: 550px;
    }
    .ese-quick-button-container.ese-quick-button-container-left {
        right: unset;
        left: 90px;
    }
}

/* ====== config ====== */
.ese-intro-link {
    margin-left: 7px;
    color: cornflowerblue;
    font-weight: bold;
}
.ese-config-panel-container {
    margin: 8px;
}
.ese-dialog-modal-mask {
    position: fixed;
    top: 0;
    bottom: 0;
    left: 0;
    right: 0;
    background: #FFF8;
    z-index: 100;
}
.ese-config-panel.ese-config-saving {
    cursor: wait;
}
.ese-config-panel.ese-config-saving .ese-config-list {
    opacity: 0.5;
    pointer-events: none;
}
.ese-config-list {
    margin: 0 5px;
}
.ese-config-list > dt {
    margin-top: 5px;
}
.ese-config-list > dt > a {
    margin-left: 3px;
    color: #0060df;
    cursor: pointer;
}
.ese-config-list > dt > a:hover {
    text-decoration: underline;
}
.ese-config-item-sep {
    margin-top: 0.5em;
    padding-top: 0.5em;
    border-top: 1px dotted #AAA;
}
.ese-config-item-tip {
    color: #105884;
    padding-left: 4px;
}
.ese-config-item-lv1 > span {
    margin-left: 3px;
}
.ese-config-item-lv2 {
    margin-left: 20px;
}
.ese-config-item-lv3 {
    margin-left: 40px;
}
.ese-config-item-lv1:not(.ese-config-item-checked) + .ese-config-item-lv2:not(.ese-config-item-tip),
.ese-config-item-lv1:not(.ese-config-item-checked) + .ese-config-item-lv2 + .ese-config-item-lv2:not(.ese-config-item-tip),
.ese-config-item-lv1:not(.ese-config-item-checked) + .ese-config-item-lv2 + .ese-config-item-lv2 + .ese-config-item-lv2:not(.ese-config-item-tip) {
    opacity: 0.2;
    pointer-events: none;
}
.ese-config-item-lv2:not(.ese-config-item-checked) + .ese-config-item-lv3:not(.ese-config-item-tip),
.ese-config-item-lv2:not(.ese-config-item-checked) + .ese-config-item-lv3 + .ese-config-item-lv3:not(.ese-config-item-tip),
.ese-config-item-lv2:not(.ese-config-item-checked) + .ese-config-item-lv3 + .ese-config-item-lv3 + .ese-config-item-lv3:not(.ese-config-item-tip) {
    opacity: 0.2;
    pointer-events: none;
}
.ese-user-map-icon {
    margin-left: 5px;
    cursor: pointer;
}
.ese-user-map-icon.ese-config-saving {
    opacity: 0.5;
    pointer-events: none;
}

.ese-user-map-icon::after {
    content: "➕收藏";
    font-weight: normal;
    font-size: 12px;
}
.ese-user-map-icon.ese-user-mapped::after {
    content: "🔖 " attr(ese-nickname);
    font-weight: normal;
    font-size: 12px;
    color: #c30a0a;
}
.ese-user-tag {
    margin-right: 6px;
    white-space: nowrap;
    min-width: 9em;
    display: inline-block;
    max-width: 9em;
    overflow: hidden;
    text-overflow: ellipsis;
}
.ese-user-tag-icon::before {
    content: "🔖 ";
    font-size: 14px;
    display: inline-block;
    cursor: pointer;
}
.ese-user-blacklist .ese-user-tag-icon::before {
    content: "🚫 ";
}
    `)
    await isCommonJsLoaded();
    const myUserId = findMyUserId();

    const mainConfigAccess = initConfigAccess(myUserId, MAIN_CONFIG_KEY, DEFAULT_MAIN_CONFIG);
    const favorThreadsCacheAccess = initConfigAccess(myUserId, FAVOR_THREADS_CACHE_CONFIG_KEY, DEFAULT_FAVOR_THREADS_CACHE_CONFIG);
    const ratedThreadsCacheAccess = initConfigAccess(myUserId, RATED_THREADS_CACHE_CONFIG_KEY, DEFAULT_RATED_THREADS_CACHE_CONFIG);
    const usersConfigCacheAccess = initConfigAccess(myUserId, USERS_CONFIG_CACHE_KEY, DEFAULT_USERS_CONFIG_CACHE);
    const searchHiddenSectionsAccess = initConfigAccess(myUserId, SEARCH_HIDDEN_SECTIONS, DEFAULT_SEARCH_HIDDEN_SECTIONS_CONFIG);

    let mainConfig = await mainConfigAccess.read();

    if (mainConfig.migrateFrom0d0d13) {
        // 迁移 0.0.13 数据。将在 2025年3月移除。
        const favoriteUsers = await GM.getValue('favoriteUsers', {});
        const blockedUsers = await GM.getValue('blockedUsers', {});
        const hiddenSections = await GM.getValue('hiddenSections', {});

        const userConfig = {};
        Object.keys(blockedUsers).forEach(item => userConfig[item] = { username: item, blocked: true })
        Object.keys(favoriteUsers).forEach(item => userConfig[item] = { username: item, favored: true })
        await usersConfigCacheAccess.update(data => ({ ...data, ...userConfig }))

        await searchHiddenSectionsAccess.update(data => ({ ...data, ...hiddenSections }))

        await GM.deleteValue('favoriteUsers');
        await GM.deleteValue('blockedUsers');
        await GM.deleteValue('hiddenSections');

        await mainConfigAccess.update(async function (mainCfg) {
            mainCfg.migrateFrom0d0d13 = false;
            return mainCfg
        })
    }

    if (mainConfig.openIntroAfterUpdate) {
        const lastVersion = await GM.getValue('last_version') * 1||0;
        if (VERSION_MAJOR > lastVersion) {
            await GM.setValue('last_version', String(VERSION_MAJOR));
            GM.notification({
                title: `色花堂强化脚本 已更新 ver.${VERSION_TEXT}`,
                text: "请参阅功能简介帖"
            });
            if (confirm(`色花堂强化脚本 已更新 ${VERSION_TEXT},打开功能简介帖?(新页面)`)) {
                GM.openInTab(UPDATE_NOTE, false);
            }
        }
    }

    const getUserConfig = async (userId, username, store) => {
        const userConfigStore = typeof store === 'object' ? store : await usersConfigCacheAccess.read();
        let userConfig = userConfigStore[userId];
        // 迁移 0.0.13 数据。将在 2025年3月移除。
        if (typeof username === 'string') {
            const legacyUserConfig = userConfigStore[username];
            if (!userConfig && legacyUserConfig) {
                await usersConfigCacheAccess.update(async data => {
                    data[userId] = data[username]
                    delete data[username];
                    return data;
                })
                userConfig = legacyUserConfig;
            }
        }
        return userConfig;
    }

    /**
     * quick jump to important contents
     */
    const elementsToCheck = [
        { // 一键收藏、 取消收藏
            selector: '#k_favorite', text: '⭐️ 收藏',
            init: (btn) => {
                btn.loading = false;
                readFavorRecords(favorThreadsCacheAccess)
                    .then(data => {
                        const favid = data && Object_hasOwn(data, tid) ? data[tid] : null;
                        btn.dataset.favid = favid;
                        if (favid) btn.classList.add('ese-active');
                    });
            },
            onClick: async (element, tid, btn) => {
                let favid = btn.dataset.favid * 1;

                if (btn.loading) return

                showNativeSpinner();
                btn.loading = true;

                if (favid) {
                    let data = await readFavorRecords(favorThreadsCacheAccess);
                    const postData = new FormData();
                    postData.set('formhash', getVerifyHash());
                    postData.set('handlekey', `a_delete_${favid}`);
                    postData.set('deletesubmit', true);
                    await fetch(`home.php?mod=spacecp&ac=favorite&op=delete&favid=${favid}&type=all&inajax=1`, { method: 'POST', body: postData })
                        .then(async () => {
                            btn.dataset.favid = 0;
                            btn.classList.remove('ese-active');
                            delete data[tid];
                            await favorThreadsCacheAccess.write({ data, time: Date.now() });
                            showNativeInfoPopup('删除收藏成功')
                        })
                        .catch(ex => {
                            showNativeWarningPopup('操作出错');
                        })
                        .finally(() => {
                            closeNativeSpinner()
                            btn.loading = false;
                        });
                } else {
                    fetchGetPage(`home.php?mod=spacecp&ac=favorite&type=thread&id=${tid}&formhash=${getVerifyHash()}&infloat=yes&handlekey=k_favorite&inajax=1&ajaxtarget=fwin_content_k_favorite`, "text/xml")
                        .then(async doc => {
                            const scriptContent = doc.querySelector('root').textContent;
                            const FAV_SUCCESS_TEXT = '信息收藏成功'
                            const FAV_ALREADY_EXIST_TEXT = '抱歉,您已收藏,请勿重复收藏'
                            if (scriptContent.includes(FAV_ALREADY_EXIST_TEXT)) {
                                await mainConfigAccess.update(async function (updateUserConfig) {
                                    updateUserConfig.initFavorRecords = true;
                                    return updateUserConfig
                                })
                                throw Error("收藏信息过期,正在后台更新数据...")
                            }
                            if (scriptContent.includes(FAV_SUCCESS_TEXT)) {
                                const match = scriptContent.match(/'id':'(\d+)'[^]*'favid':'(\d+)'/);
                                if (match) {
                                    showNativeInfoPopup(FAV_SUCCESS_TEXT)
                                    const tid = match[1] * 1;
                                    const favid = match[2] * 1;
                                    await favorThreadsCacheAccess.update(cache => {
                                        if (cache && cache.data) {
                                            cache.data[tid] = favid;
                                            return cache;
                                        }
                                    });

                                    btn.dataset.favid = favid;
                                    btn.classList.add('ese-active');
                                }
                            }
                        })
                        .catch(msg => {
                            showNativeWarningPopup(msg);
                        })
                        .finally(() => {
                            closeNativeSpinner()
                            btn.loading = false;
                        });
                }
            }
        },
        { // 快捷评分
            selector: '#ak_rate', text: '👍 评分',
            init: (btn) => {
                readRatedRecords(ratedThreadsCacheAccess)
                    .then(data => {
                        const rated = data && Object_hasOwn(data, tid) ? data[tid] : null;
                        btn.dataset.rated = rated;
                        if (rated) btn.classList.add('ese-active');
                    });
            },
            onClick: async (element, tid) => {
                element.click();
                observeAlreadyRateForm(async () => {
                    await ratedThreadsCacheAccess.update(cache => {
                        if (cache && cache.data) {
                            cache.data[tid] = true;
                            return cache;
                        }
                    });
                });
                observeRateForm();
                observeLoadingElement(async () => {
                    await ratedThreadsCacheAccess.update(cache => {
                        if (cache && cache.data) {
                            cache.data[tid] = true;
                            return cache;
                        }
                    });
                });
            }
        },
        { selector: '.locked a[href*="action=pay"', text: '💰 购买', onClick: (element) => {
            element.click();
            observeLoadingElement()
        }},
        { selector: '.locked a[href*="action=reply"]', text: '🔒 回复', onClick: (element) => element.click() }, // 回复解锁
        { selector: '.blockcode', text: '🧲 链接' },
    ];

    const createQuickButton = (parent, { text, onClick, title, ariaLabel }) => {
        const button = addElem(parent, 'button', 'ese-quick-button', { 'aria-label': ariaLabel })
        button.innerText = text;
        button.title = title;
        if (onClick) button.addEventListener('click', onClick);
        return button;
    };

    const scrollToElement = (element) => {
        const observer = new MutationObserver((mutations, obs) => {
            setTimeout(() => {
                element.scrollIntoView({ behavior: 'smooth', block: 'center' });
                obs.disconnect();
            }, 800);
        });

        observer.observe(document.body, { childList: true, subtree: true });

        element.scrollIntoView({ behavior: 'smooth', block: 'center' });
    };

    const buttonContainer = document.createElement('div');
    buttonContainer.className = mainConfig.quickBtnPosition === 'left' ? 'ese-quick-button-container ese-quick-button-container-left' : 'ese-quick-button-container'

    const updateButtonStates = () => {
        if (mainConfig.enableQuickBtn === false) return;
        buttonContainer.innerHTML = ''; // 清空现有按钮,防止重复添加

        const url = new URL(window.location);
        const tid = url.searchParams.get('tid')

        elementsToCheck.forEach(({ selector, text, onClick, init }) => {
            const element = document.querySelector(selector);
            if (element) {
                const quickBtn = createQuickButton(buttonContainer, { text, title: element.textContent.trim(), ariaLabel: element.textContent.trim() });

                quickBtn.addEventListener('click', () => {
                    if (typeof onClick === 'function') {
                        onClick(element, tid, quickBtn)
                    } else {
                        scrollToElement(element);
                    }
                })

                if (typeof init === 'function') {
                    init(quickBtn)
                }
            }
        });

        // 更新附件按钮
        const updateAttachmentButtons = () => {
            const createAndAppendButton = (element) => {
                createQuickButton(buttonContainer, {
                    text: '📎 附件',
                    title: element.textContent.trim(),
                    ariaLabel: element.textContent.trim(),
                    onClick: () => scrollToElement(element)
                });
            };

            // 更新常规附件按钮
            document.querySelectorAll('span[id^="attach_"]').forEach(createAndAppendButton);

            // 更新免费附件按钮
            document.querySelectorAll('dl.tattl .tip').forEach(tipElement => {
                if (tipElement.textContent.includes('点击文件名下载附件')) {
                    createAndAppendButton(tipElement.closest('dl.tattl'));
                }
            });
        };

        updateAttachmentButtons();
    };

    if (mainConfig.initFavorRecords) {
        await readFavorRecords(favorThreadsCacheAccess, true, async () => {
            await mainConfigAccess.update(async function (updateUserConfig) {
                updateUserConfig.initFavorRecords = false;
                return updateUserConfig
            })
            updateButtonStates()
        })
    }

    const observeLoadingElement = async (cb) => {
        let loadingElementVisible = false;

        const loadingObserver = new MutationObserver(async (mutations, observer) => {
            const loadingElement = document.querySelector('div[id^="post_"] img[src*="loading.gif"]');

            if (loadingElement && !loadingElementVisible) {
                loadingElementVisible = true;
            }

            if (!loadingElement && loadingElementVisible) {
                loadingElementVisible = false;
                if (typeof cb === 'function') await cb()
                updateButtonStates();
                loadingObserver.disconnect();
            }
        });

        loadingObserver.observe(document.body, { childList: true, subtree: true });
    };

    const observeAlreadyRateForm = async (cb) => {
        let clicked = false;
        const rateObserver = new MutationObserver(async (mutations, obs) => {
            const rateForm = document.querySelector('#fwin_content_rate .alert_error');
            if (rateForm?.textContent.includes("您不能对同一个帖子重复评分") && !clicked) {
                clicked = true;
                cb && await cb()
                updateButtonStates();
                obs.disconnect(); // 停止观察表单
            }
        });
        rateObserver.observe(document.body, { childList: true, subtree: true });
    };

    const observeRateForm = () => {
        const rateObserver = new MutationObserver((mutations, obs) => {
            const rateForm = document.querySelector('#rateform');
            if (rateForm) {
                const scoreElement = document.querySelector('#scoreoption8 li:first-child');
                if (scoreElement) {
                    const scoreValue = scoreElement.textContent.trim();
                    document.querySelector('#score8').value = scoreValue;
                }
                obs.disconnect(); // 停止观察表单
            }
        });
        rateObserver.observe(document.body, { childList: true, subtree: true });
    };

    // 初次更新按钮状态
    updateButtonStates();

    if (buttonContainer.children.length > 0) {
        document.body.appendChild(buttonContainer);
    }

    /**
     * Highlighting favorite users and today's new posts
     */
    const currentUrl = window.location.href;
    const isUserProfilePage = /mod=space&uid=\d+|space-uid-\d+/.test(currentUrl);
    const isPostListPage = /fid=\d+/.test(currentUrl) || /forum-(\d+)-\d+\.html/.test(currentUrl);
    const isFavoriteListPage = /&do=favorite.*/.test(currentUrl);

    if (isFavoriteListPage) {
        const observeRateForm = () => {
            const rateObserver = new MutationObserver((mutations, obs) => {
                const confirmDelForm = document.querySelector('form[id^="favoriteform_"]');
                if (confirmDelForm) {
                    confirmDelForm.addEventListener('submit', () => {
                        favorThreadsCacheAccess.update(cache => {
                            let favId = confirmDelForm.id.match(/favoriteform_(\d+)/)
                            if (favId) {
                                favId = favId[1]
                                Object.entries(cache.data).forEach(([key, value]) => {
                                    if (Number(favId) === value) delete cache.data[key]
                                })
                            }
                            return cache
                        })
                    })
                }

                const confirmBatchDelForm = document.querySelector("#delform")
                if (confirmBatchDelForm) {
                    confirmBatchDelForm.addEventListener('submit', () => {
                        favorThreadsCacheAccess.update(cache => {
                            const favIds = []
                            confirmBatchDelForm.querySelectorAll("li input").forEach(o => { if (o.checked) favIds.push(o.getAttribute('vid')) })
                            favIds.forEach(id => delete cache.data[id])
                            return cache
                        })
                    })
                }
            });
    
            rateObserver.observe(document.body, { childList: true, subtree: true });
        };

        observeRateForm();
    }

    const blockUser = async (userId, username, { blockedBtn, favorBtn } = {}) => {
        let toggled = false;
        const userConfig = await getUserConfig(userId);
        if (userConfig?.blocked) {
            if (confirm(`确定取消屏蔽用户 ${username} 吗?`)) {
                await usersConfigCacheAccess.update(async data => {
                    delete data[userId];
                    return data;
                })
                toggled = true;
                blockedBtn?.toggle(false);
            }
        } else {
            if (confirm(`确定屏蔽用户 ${username} 吗?`)) {
                await usersConfigCacheAccess.update(async data => {
                    delete data[username]; // 兼容 0.0.13 旧数据
                    data[userId] = { username, blocked: true }
                    return data;
                })
                toggled = true;
                favorBtn?.toggle(false);
                blockedBtn?.toggle(true);
            }
        }
        return toggled;
    }

    const favorUser = async (userId, username, { blockedBtn, favorBtn } = {}) => {
        let toggled = false;
        const userConfig = await getUserConfig(userId);
        if (userConfig?.favored) {
            if (confirm(`确定取消收藏用户 ${username} 吗?`)) {
                await usersConfigCacheAccess.update(async data => {
                    delete data[userId];
                    return data;
                })
                toggled = true;
                favorBtn?.toggle(false);
            }
        } else {
            if (confirm(`确定收藏用户 ${username} 吗?`)) {
                await usersConfigCacheAccess.update(async data => {
                    delete data[username]; // 兼容 0.0.13 旧数据
                    data[userId] = { username, favored: true }
                    return data;
                })
                toggled = true;
                favorBtn?.toggle(true);
                blockedBtn?.toggle(false);
            }
        }
        return toggled;
    }

    if (isUserProfilePage) {
        const userProfileElement = document.querySelector('#uhd .mt');
        const userId = extractUidFromURL(currentUrl)

        if (userProfileElement) {
            const username = userProfileElement.textContent.trim();

            function createUpdateUserButton(value, cls, textA, textB) {
                const btn = addElem(userProfileElement, 'button', `ese-quick-button ${cls}`)
                btn.innerText = value ? textA : textB;
                btn.style.marginLeft = '10px';
                btn.toggle = (value) => {
                    btn.innerText = value ? textA : textB;
                }
                return btn
            }

            const userConfig = await getUserConfig(userId, username);
            const favorBtn = createUpdateUserButton(userConfig?.favored, 'ese-favorite-button', '取消收藏', '收藏用户');
            const blockedBtn = createUpdateUserButton(userConfig?.blocked, 'ese-block-button', '取消屏蔽', '屏蔽用户');

            favorBtn.addEventListener('click', () => favorUser(userId, username, { blockedBtn, favorBtn }));
            blockedBtn.addEventListener('click', () => blockUser(userId, username, { blockedBtn, favorBtn }));
        }
    }

    const highlightOrHidePost = (postItem, citeEle, userConfig) => {
        if (!citeEle) return;

        if (userConfig?.favored) {
            citeEle.style.fontWeight = 'bold';
            citeEle.style.color = 'dodgerblue'; // Change color to dodgerblue
        }

        if (userConfig?.blocked) {
            postItem.style.display = 'none';
        }
    }

    const highOrHidePostLists = async () => {
        const usersConfigStore = await usersConfigCacheAccess.read();
        const postsList = document.querySelectorAll('tbody[id^="normalthread_"]')
        postsList.forEach(async postItem => {
            const postElement = postItem.querySelector('tr td.by:nth-child(3)');
            const citeElement = postElement.querySelector('cite a');
            const topicTimeSpan = postElement.querySelector('span.xi1 span');
            const userId = extractUidFromURL(citeElement.href);
            const userConfig = await getUserConfig(userId, citeElement.textContent.trim(), usersConfigStore);
            highlightOrHidePost(postItem, citeElement, userConfig)
            // Highlight today's new posts
            const today = new Date().toISOString().split('T')[0];
            if (topicTimeSpan && topicTimeSpan.title === today) {
                topicTimeSpan.style.fontWeight = 'bold';
            }
        })
    };

    const isHomepage = window.location.pathname === '/';

    if (isHomepage) {
        const usersConfigStore = await usersConfigCacheAccess.read();
        const posts = document.querySelectorAll('.dxb_bc li')

        posts.forEach(async post => {
            const usernameEle = post.querySelector('em a');
            const userId = extractUidFromURL(usernameEle?.href);
            const userConfig = await getUserConfig(userId, usernameEle?.textContent.trim(), usersConfigStore);

            if (usernameEle && userConfig?.favored) {
                usernameEle.style.fontWeight = 'bold';
                usernameEle.style.color = 'dodgerblue'; // Change color to dodgerblue
            }

            if (usernameEle && userConfig?.blocked) {
                post.style.display = 'none';
            }
        })
    }

    if (isPostListPage) {
        const highOrHidePostLists = async () => {
            const usersConfigStore = await usersConfigCacheAccess.read();
            const postsList = document.querySelectorAll('tbody[id^="normalthread_"]')
            postsList.forEach(async postItem => {
                const postElement = postItem.querySelector('tr td.by:nth-child(3)');
                const citeElement = postElement.querySelector('cite a');
                const topicTimeSpan = postElement.querySelector('span.xi1 span');
                const userId = extractUidFromURL(citeElement.href);
                const userConfig = await getUserConfig(userId, citeElement.textContent.trim(), usersConfigStore);
                highlightOrHidePost(postItem, citeElement, userConfig)
                // Highlight today's new posts
                const today = new Date().toISOString().split('T')[0];
                if (topicTimeSpan && topicTimeSpan.title === today) {
                    topicTimeSpan.style.fontWeight = 'bold';
                }
            })
        };

        // Initial call to highlight already loaded content
        highOrHidePostLists();

        // Use a MutationObserver to handle dynamically loaded content
        const observer = new MutationObserver((mutations) => {
            mutations.forEach(mutation => {
                highOrHidePostLists();
            });
        });

        observer.observe(document.body, { childList: true, subtree: true });
    }

    const isSearchPage = /\/search\.php\?.*searchmd5=.*/.test(currentUrl);
    if (isSearchPage) {
        const threadList = document.querySelectorAll('#threadlist .pbw');
        if (!threadList.length) return;

        const highOrHidePostLists = async (threadList) => {
            const usersConfigStore = await usersConfigCacheAccess.read();
            threadList.forEach(async postItem => {
                const citeElement = postItem.querySelector('p:nth-child(4) span a');
                const userId = extractUidFromURL(citeElement.href);
                const userConfig = await getUserConfig(userId, citeElement.textContent.trim(), usersConfigStore);

                highlightOrHidePost(postItem, citeElement, userConfig)
            })
        };

        const sectionMap = new Map();

        // 兼容两种分区地址格式(for edge)
        const querySectionLink = (thread, fid) => {
            const linkSelectors = fid ?
                `a[href*="fid=${fid}"], a[href^="forum-${fid}"]` :
                'a[href*="fid="], a[href^="forum-"]';

            const link = thread.querySelector(linkSelectors);
            if (link) {
                const url = new URL(link.href);
                const fidValue = fid || url.searchParams.get('fid') || url.pathname.match(/forum-(\d+)-\d+\.html/)?.[1];
                const sectionName = link.textContent.trim();
                if (fidValue) return { sectionLink: link, fid: fidValue, sectionName };
            }
            return null;
        };

        const processThread = (thread) => {
            const link = querySectionLink(thread)
            if (link) {
                const { fid, sectionName } = link
                if (!sectionMap.has(fid)) {
                    sectionMap.set(fid, { name: sectionName, elements: [] });
                }
                sectionMap.get(fid).elements.push(thread);
            }
        };

        const applyFilterToNewThreads = async (newThreads) => {
            const  searchHiddenSections = await searchHiddenSectionsAccess.read();
            sectionMap.forEach((section, fid) => {
                if (searchHiddenSections[fid]) {
                    newThreads.forEach(thread => {
                        const link = querySectionLink(thread, fid)
                        if (link) thread.style.display = 'none';
                    });
                }
            });
        };

        // 新建 button、更新数值
        const updateFilterButtons = async () => {
            const searchHiddenSections = await searchHiddenSectionsAccess.read();
            sectionMap.forEach((section, fid) => {
                const existingButton = document.querySelector(`.ese-filter-button[data-fid="${fid}"]`);

                if (existingButton) {
                    const countElement = existingButton.querySelector('.ese-filter-button-count');
                    const countNum = section.elements.length;
                    countElement.textContent = countNum <= 99 ? countNum : '99+';
                    existingButton.classList.toggle('ese-hidden', !!searchHiddenSections[fid]);
                } else {
                    addFilterSectionButton(section, fid);
                }
            });
        };

        const addFilterSectionButton = async (section, fid) => {
            let searchHiddenSections = await searchHiddenSectionsAccess.read()
            const button = addElem(filterContainer, 'a', 'ese-filter-button')
            button.textContent = section.name;
            button.dataset.fid = fid;
            button.classList.toggle('ese-hidden', !!searchHiddenSections[fid]);

            const _throttled = throttle(async () => {
                await searchHiddenSectionsAccess.update(async data => {
                    data[fid] = !data[fid];
                    button.classList.toggle('ese-hidden', data[fid]);
                    section.elements.forEach(thread => {
                        thread.style.display = data[fid] ? 'none' : '';
                    });
                    return data
                })
            }, 300, { trailing: false }) 

            button.addEventListener('click', _throttled);

            const countElement = insertBeforeElem(button, 'span', 'ese-filter-button-count');
            countElement.textContent = section.elements.length;
        };

        let isLoading = false;
        let lastLoadedTime = Date.now();

        const loadNextPage = async () => {
            if (isLoading) return;

            const nextPageLink = document.querySelector('.pg a.nxt');
            if (!nextPageLink) return -1;

            isLoading = true;
            const loadingIndicator = createLoadingIndicator('加载中...');

            const elapsedTime = Date.now() - lastLoadedTime;
            const waitTime = Math.max(0, LOAD_TIME_LIMIT - elapsedTime);

            return new Promise((resolve, reject) => {
                setTimeout(() => {
                    fetchGetPage(nextPageLink.href)
                        .then(async doc => {
                            const errorMessage = doc.querySelector('#messagetext.alert_error')?.textContent || "";
                            const emptyMessage = doc.querySelector("#ct .emp")?.textContent || "";

                            if (!errorMessage && !emptyMessage) return doc

                            if (errorMessage.includes('刷新过于频繁')) {
                                showRetryIndicator();
                                throw Error('刷新过于频繁');
                            }
                            if (emptyMessage.includes('没有找到匹配结果')) {
                                if(confirm(`搜索页面已过期,是否(等待 ${Math.ceil(LOAD_TIME_LIMIT / 1000)} 秒后)重新搜索?`)) {
                                    isLoading = true;
                                    setTimeout(() => {
                                        document.querySelector("#scform_submit")?.click();
                                        isLoading = false;
                                    }, LOAD_TIME_LIMIT)
                                }
                            }
                            return doc
                        })
                        .then(async doc => {
                            const newThreads = doc.querySelectorAll('#threadlist .pbw');
                            newThreads.forEach(thread => {
                                thread.classList.add('ese-fade-in'); // 添加渐入动效类
                                document.querySelector('#threadlist ul').appendChild(thread);
                                processThread(thread);
                            });

                            const newPagination = doc.querySelector('.pgs.cl.mbm');
                            const pagination = document.querySelector('.pgs.cl.mbm');
                            if (pagination && newPagination) pagination.replaceWith(newPagination);

                            highOrHidePostLists(newThreads);

                            if (mainConfig.enableSectionFilterSearch) {
                                await applyFilterToNewThreads(newThreads);
                                await updateFilterButtons();
                            }
                        })
                        .catch(e => {
                            console.log(e.message) || "未知错误";
                        })
                        .finally(() => {
                            resolve()
                            isLoading = false;
                            lastLoadedTime = Date.now();
                            loadingIndicator.remove();
                        });
                }, waitTime);
            })
        };

        const showRetryIndicator = () => {
            const retryIndicator = createLoadingIndicator('翻页过快,即将重试...');

            setTimeout(() => {
                retryIndicator.remove();
                loadNextPage();
            }, LOAD_TIME_LIMIT);
        };

        const loadNextPageIfNeeded = (entries) => {
            entries.forEach(entry => {
                if (entry.isIntersecting && !isLoading) {
                    loadNextPage().then((value) => {
                        if (value === -1) {
                            loadNextPageObserver.unobserve(sentinel);
                            sentinel.remove();
                        } else {
                            // 将 sentinel 元素移动到文档末尾
                            document.body.appendChild(sentinel);
                        }
                    });
                }
            });
        };
        
        const sentinel = addElem(document.body, 'div');
        sentinel.style.height = '1px';

        const loadNextPageObserver = new IntersectionObserver(loadNextPageIfNeeded, {
            root: null,
            rootMargin: '50% 0px',
            threshold: 0
        });

        const filterContainer = addElem(document.body, 'div', 'ese-filter-container');

        highOrHidePostLists(threadList);

        if (mainConfig.enableSectionFilterSearch) {
            threadList.forEach(processThread);
            await applyFilterToNewThreads(threadList);
            await updateFilterButtons();
        }

        if (mainConfig.enableAutoPaginationSearch) {
            loadNextPageObserver.observe(sentinel);
        }
    }

    if (mainConfig.enableSortByDateline === true) {
        /** section links default sort by datetime */
        const setCustomSectionLink = () => {
            document.body.addEventListener('click', event => {
                const link = event.target.closest('a[href*="forum.php?mod=forumdisplay"], a[href*="forum-"]');
                if (!link) return;

                event.preventDefault();
                let url = new URL(link.href, window.location.origin);
                let target = link.getAttribute('target') || '_self';

                if (url.pathname.includes('forum.php')) {
                    if (!url.searchParams.get("orderby")) {
                        url.searchParams.set('filter', 'author')
                        url.searchParams.set('orderby', 'dateline')
                    }
                } else if (url.pathname.match(/forum-\d+-\d+\.html/)) {
                    const fid = url.pathname.split('-')[1];
                    url = new URL(`/forum.php?mod=forumdisplay&fid=${fid}&filter=author&orderby=dateline`, window.location.origin);
                }

                window.open(url, target);
            });
            document.querySelectorAll('a[rel*="forum.php?mod=forumdisplay"]').forEach(nxt => { // next page
                let url = new URL(nxt.rel, window.location.origin);
                if (!url.searchParams.get("orderby")) url.searchParams.set('orderby', 'dateline');
                nxt.setAttribute('rel', url)
            })
        }
        setCustomSectionLink()
    }

    const isThreadPage = /\/(?:forum\.php\?mod=viewthread&tid=|thread-)(\d+).*/.test(currentUrl);
    if (isThreadPage) {
        document.querySelectorAll("table[id^=pid]").forEach(async thread => {
            const userInfoEle = thread.querySelector('div[id^=favatar] .authi a')
            const userId = extractUidFromURL(userInfoEle.href);
            const username = userInfoEle.textContent.trim();
            const userConfig = await getUserConfig(userId, username);

            if (userInfoEle && userConfig?.favored) {
                userInfoEle.style.fontWeight = 'bold';
                userInfoEle.style.color = 'dodgerblue'; // Change color to dodgerblue
            }
            if (userInfoEle && userConfig?.blocked) {
                thread.style.display = 'none';
            }

            const userInfoContainer = thread.querySelector('div[id^=favatar] .authi')
            const favorUserBtn = addElem(userInfoContainer, 'a', 'ese-thread-userinfo-button')
            favorUserBtn.textContent = ' ➕';
            favorUserBtn.addEventListener('click', async () => {
                const toggled = await favorUser(userId, username)
                toggled && location.reload();
            })

            const bottomBtnContainer = thread.querySelector('tbody td.plc .pob.cl')
            const p = addElem(bottomBtnContainer, 'p')
            const blockUserBtn = addElem(p, 'a', 'ese-thread-bottom-button')
            blockUserBtn.textContent = '屏蔽用户'
            blockUserBtn.addEventListener('click', async () => {
                const toggled = await blockUser(userId, username)
                toggled && location.reload();
            });
        })
    }

    const initConfigPanel = () => {
        /** 插件配置页 */
        const isEnhancerPanel = /&ac=enhancer/.test(currentUrl);
        const enhancerUrl = 'home.php?mod=spacecp&ac=enhancer';
        const CONFIG_PANEL_NAME = "增强插件"

        const renderConfigForm = () => {
            const uselessElements = document.querySelectorAll("#ct #frame_profile, #ct form[target='frame_profile']")
            uselessElements.forEach(ele => ele.remove())
            const configPanel = document.querySelector("#ct .bm.bw0")
            const configList = addElem(configPanel, 'div', "ese-config-panel-container")
            const introItem = addElem(configList, 'dt', 'ese-config-item-lv1 ese-config-item-checked');
            addElem(introItem, 'a', 'ese-intro-link', { href: INTRO_POST, target: '_blank' }).textContent = '🔗 捷径: 功能简介帖';
            putCheckBoxItem('新功能更新后 自动打开更新说明', 'openIntroAfterUpdate', 2);
            addElem(configList, 'dt', 'ese-config-item-sep ese-config-item-lv1').textContent = '论坛设置';

            const quickBtnItem = putCheckBoxItem('在读帖时添加快捷按钮', 'enableQuickBtn');
            addElem(configList, 'dt', 'ese-config-item-tip ese-config-item-lv2').textContent = '💡 阅读帖子时的快捷按钮(一键收藏、快捷评分、滚动到附件等)';
            addElem(quickBtnItem, 'span').textContent = '🔹位置 ';

            const quickBtnPosSelect = addElem(quickBtnItem, 'select');
            addElem(quickBtnPosSelect, 'option', null, { value: 'left' }).textContent = '页面左侧';
            addElem(quickBtnPosSelect, 'option', null, { value: 'right' }).textContent = '页面右侧';
            quickBtnPosSelect.value = mainConfig.quickBtnPosition;
            quickBtnPosSelect.value = quickBtnPosSelect.value || 'right';
            quickBtnPosSelect.addEventListener('change', () => {
                configPanel.classList.add('ese-config-saving');
                mainConfigAccess.update(function(updatingUserConfig) {
                    updatingUserConfig.quickBtnPosition = quickBtnPosSelect.value;
                    return updatingUserConfig;
                })
                .finally(function() {
                    configPanel.classList.remove('ese-config-saving');
                    window.location.reload();
                });
            });

            putCheckBoxItem('帖子按时间排序', 'enableSortByDateline');
            addElem(configList, 'dt', 'ese-config-item-tip ese-config-item-lv2').textContent = '💡 板块内帖子按时间排序(默认关闭。目前仅支持全部开启或全部关闭,后续将支持按常用分区配置)';

            addElem(configList, 'dt', 'ese-config-item-sep ese-config-item-lv1').textContent = '搜索设置';
            putCheckBoxItem('板块筛选', 'enableSectionFilterSearch');
            addElem(configList, 'dt', 'ese-config-item-tip ese-config-item-lv2').textContent = '💡 按板块隐藏不想看的帖子';
            putCheckBoxItem('自动翻页', 'enableAutoPaginationSearch');

            async function renderUserList(heading, listClass, filterKey, clickIconText, confirmMessage) {
                const listHeading = addElem(configList, 'h5', null, { style: 'line-height:32px;' });
                addElem(listHeading, 'span').textContent = heading;
                addElem(listHeading, 'span', null, { style: 'font-weight:normal' }).textContent = ` 点击${clickIconText}移除`;
                
                const userListBlock = addElem(configList, 'dt', listClass);
                const userItemComparator = comparator('username');
                const usersConfigStore = await usersConfigCacheAccess.read();
                
                Object.entries(usersConfigStore)
                    .filter(entry => entry[1][filterKey])
                    .sort((et1, et2) => userItemComparator(et1[1], et2[1]))
                    .forEach(entry => {
                        const userId = entry[0];
                        const { username: userNickName } = entry[1];
                        const userTag = addElem(userListBlock, 'span', 'ese-user-tag', { title: userNickName });
                        userListBlock.appendChild(document.createTextNode(' '));
                        const userTagIcon = addElem(userTag, 'span', 'ese-user-tag-icon');
                        
                        const userLink = addElem(userTag, 'a', null, {
                            href: !isNaN(Number(userId)) ?
                              `home.php?mod=space&uid=${userId}` :
                              `home.php?mod=spacecp&ac=search&username=${userId}&searchsubmit=yes`, // 兼容 0.0.13 旧数据
                            target: '_blank'
                        });
                        userLink.textContent = userNickName;
            
                        userTagIcon.addEventListener('click', function() {
                            if (confirm(`${confirmMessage} (${userNickName})?`)) {
                                configPanel.classList.add('ese-config-saving');
                                usersConfigCacheAccess.update(data => {
                                    delete data[userId];
                                    userTag.remove();
                                    return data;
                                });
                            }
                        });
                    });
            }
            
            addElem(configList, 'dt', 'ese-config-item-sep ese-config-item-lv1').textContent = '用户管理';
            renderUserList('收藏用户管理', 'ese-nickname-list', 'favored', '🔖', '取消收藏');
            renderUserList('屏蔽用户管理', 'ese-user-blacklist', 'blocked', '🚫', '取消屏蔽');

            function putCheckBoxItem(label, propertyNameOrAccessor, level, inverted) {
                let propertyAccess;
                if (typeof propertyNameOrAccessor === 'string') {
                    propertyAccess = {
                        get() {
                            return !!mainConfig[propertyNameOrAccessor];
                        },
                        update(newValue) {
                            return mainConfigAccess.update(function(updatingUserConfig) {
                                updatingUserConfig[propertyNameOrAccessor] = newValue;
                                return updatingUserConfig;
                            });
                        }
                    };
                } else {
                    propertyAccess = propertyNameOrAccessor;
                }
                const configItem = addElem(configList, 'dt', `ese-config-item-lv${level||1}`);
                const checkboxLabel = addElem(configItem, 'label');
                const checkbox = addElem(checkboxLabel, 'input', null, { type: 'checkbox' });
                checkboxLabel.appendChild(document.createTextNode(' ' + label));
                checkbox.checked = propertyAccess.get();
                updateStyle();
                function updateStyle() {
                    const checkedValue = !inverted; // when inverted false is considered checked
                    if (checkbox.checked === checkedValue) {
                        configItem.classList.add('ese-config-item-checked');
                    } else {
                        configItem.classList.remove('ese-config-item-checked');
                    }
                }
                checkbox.addEventListener('change', async function() {
                    configPanel.classList.add('ese-config-saving');
                    const newValue = checkbox.checked;
                    propertyAccess.update(newValue)
                        .finally(function() {
                            configPanel.classList.remove('ese-config-saving');
                            updateStyle();
                        });
                });
                return configItem;
            }

        }

        const sideTab = document.querySelector("#ct .tbn ul")
        if (!sideTab) return
        const newLi = addElem(sideTab, "li")
        const newLink = addElem(newLi, "a", "", { href: enhancerUrl });
        newLink.textContent = CONFIG_PANEL_NAME

        if (isEnhancerPanel) {
            const panelTitleEle = document.querySelector("#ct .tb.cl a")
            if (!panelTitleEle) return
            panelTitleEle.innerText = CONFIG_PANEL_NAME
            panelTitleEle.href = enhancerUrl

            const breadcrumbEle = document.querySelector("#pt .z");
            breadcrumbEle.innerHTML = breadcrumbEle.innerHTML.replace(/个人资料/g, CONFIG_PANEL_NAME);

            const sideTabLi = document.querySelectorAll("#ct .tbn ul li")
            sideTabLi.forEach(li => {
                const a = li.querySelector('a');
                if (a.href.includes(enhancerUrl)) {
                    li.classList.add('a')
                } else {
                    li.classList.remove('a')
                }
            })
            renderConfigForm()
        }
    }

    const isSpacePage = /\/home\.php\?mod=spacecp.*/.test(currentUrl);
    if (isSpacePage) {
        initConfigPanel()
    }
})();

//============= utils =============//

let verifyhashCache = null;
function getVerifyHash() {
    if (unsafeWindow.verifyhash) {
        return unsafeWindow.verifyhash;
    }
    if (verifyhashCache) {
        return verifyhashCache;
    }
    const hiddenField = document.querySelector('input[type="hidden"][name="formhash"][value]');
    if (hiddenField) {
        verifyhashCache = hiddenField.value;
        return hiddenField.value;
    }
    for (let scriptElement of document.querySelectorAll('script')) {
        const match = scriptElement.textContent.match(/hash=([A-Za-z0-9]{8})/);
        if (match) {
            verifyhashCache = match[1];
            return match[1];
        }
    }
    alert('无法取得操作验证码 (运作环境错误,请用篡改猴及谷歌火狐等主流浏览器)');
}

function comparator(prop, reversed) {
    const reverser = reversed ? -1 : 1;
    return (x, y) => {
        const xv = typeof prop === 'function' ? prop(x) : prop ? x[prop] : x;
        const yv = typeof prop === 'function' ? prop(y) : prop ? y[prop] : y;
        return xv > yv ? reverser : xv < yv ? -reverser : 0;
    };
}

function extractUidFromURL(url = "") {
    const pattern = /uid=(\d+)|space-uid-(\d+)\.html/;
    const match = url.match(pattern);
    if (match) {
        const uid = match[1] || match[2];
        return Number(uid);
    }
    return null;
}

// polyfill replacement
function Object_hasOwn(obj, prop) {
    return Object.prototype.hasOwnProperty.call(obj, prop);
}

function showNativeSpinner(anchor) {
    try {
        unsafeWindow.showloading();
    } catch (ignore) { }
}
function closeNativeSpinner() {
    try {
        unsafeWindow.showloading('none');
    } catch (ignore) { }
}
function showNativeWarningPopup(msg) {
    try {
        unsafeWindow.errorhandle_k_favorite(msg, {})
    } catch (ignore) {
        alert(msg);
    }
}
function showNativeInfoPopup(msg) {
    showDialog(msg, 'right', null, null, 0, null, null, null, null, 2, null)
}

async function fetchGetPage(url, docType, autoRetry) {
    const resp = await fetch(url, {
        method: 'GET',
        mode: 'same-origin',
        credentials: 'same-origin',
        cache: 'no-cache'
    });

    if (!resp.ok) {
        throw new Error('网络或登入错误');
    }
    const content = await resp.text();
    const parser = new DOMParser();
    const doc = parser.parseFromString(content, docType || 'text/html');
    if (autoRetry) {
        if (findErrorMessage(doc).includes('刷新过于频繁')) {
            await sleep(LOAD_TIME_LIMIT);
            return await fetchGetPage(url, docType, false);
        }
    }
    return doc;
}

function findErrorMessage(doc) {
    let err = doc.querySelector('#messagetext.alert_error');
    return err ? err.textContent.trim() || '不明错误' : null;
}

function addElem(parent, tag, styleClass, attrs) {
    const elem = newElem(tag, styleClass, attrs);
    parent.appendChild(elem);
    return elem;
}

function insertBeforeElem(parent, tag, styleClass, attrs) {
    const elem = newElem(tag, styleClass, attrs);
    parent.insertBefore(elem, parent?.firstChild);
    return elem;
}

function newElem(tag, styleClass, attrs) {
    const elem = document.createElement(tag);
    if (styleClass) {
        styleClass.split(' ').forEach(function(cls) {
            elem.classList.add(cls);
        });
    }
    if (attrs) {
        for (let name of Object.keys(attrs)) {
            elem.setAttribute(name, attrs[name]);
        }
    }
    return elem;
}

function addModalMask() {
    return addElem(document.body, 'div', 'ese-dialog-modal-mask');
}

function createPopupMenu(popupId, anchor, rightAligned, verticallyInverted) {
    function computeStyle(hShift) {
        if (anchor) {
            const bounds = anchor.getClientRects()[0];
            const left = bounds.x + hShift;
            if (verticallyInverted) {
                return 'opacity: 0.95; left: ' + left.toFixed(0) + 'px; z-index: 3000; visibility: visible; top: ' + (bounds.top + document.documentElement.scrollTop).toFixed(0) + 'px; transform: translateY(-100%);';
            } else {
                return 'opacity: 0.95; left: ' + left.toFixed(0) + 'px; z-index: 3000; visibility: visible; top: ' + (bounds.height + bounds.top + document.documentElement.scrollTop).toFixed(0) + 'px;';
            }
        } else {
            return 'position: fixed; opacity: 0.95; left: calc(50vw - 400px); z-index: 3000; visibility: visible; top: 10vh';
        }

    }
    const menuElem = addElem(document.body, 'div', 'menu ese-common-popup-menu', {
        id: popupId,
        style: computeStyle(0)
    });

    // just copy-n-paste from site, lol
    menuElem.innerHTML = `<div class="bor" style="padding:13px 30px"><img src="images/loading.gif" align="absbottom"> 正在加载数据...</div>`;

    return {
        renderContent(renderer) {
            menuElem.innerHTML = '';
            const borElem = addElem(menuElem, 'div', 'bor');
            renderer(borElem);
            if (rightAligned) {
                menuElem.setAttribute('style', computeStyle(-menuElem.getClientRects()[0].width));
            }
        }
    };
}

async function setupPopupMenu(config) {
    const menuConfig = {
        title: '',
        width: 120,
        popupMenuId: '',
        anchor: null,
        rightAligned: false,
        verticallyInverted: false,
        onClose: () => {},
        items: [] // [ { label, class, action } ]
    };
    Object.assign(menuConfig, config);

    const modelMask = addModalMask();
    const popupMenu = createPopupMenu(menuConfig.popupMenuId, menuConfig.anchor, menuConfig.rightAligned, menuConfig.verticallyInverted);
    function close() {
        modelMask.remove();
        closePopupMenu(menuConfig.popupMenuId);
        menuConfig.onClose();
    }
    modelMask.addEventListener('click', () => close());

    popupMenu.renderContent(async function(borElem) {
        const tableElem = addElem(borElem, 'table', null, {
            width: `${menuConfig.width}`,
            cellspacing: '0',
            cellpadding: '0'
        });

        const tbodyElem = addElem(tableElem, 'tbody');
        const trElem1 = addElem(tbodyElem, 'tr');
        const thElem1_1 = addElem(trElem1, 'th', 'h');
        const frElem1 = addElem(thElem1_1, 'span', 'fr', {
            style: 'margin-top:2px;cursor:pointer'
        });
        frElem1.addEventListener('click', () => close());
        addElem(frElem1, 'img', null, {
            src: 'images/close.gif'
        });
        thElem1_1.appendChild(document.createTextNode(menuConfig.title));

        function addButton(item) {
            const trElem = addElem(tbodyElem, 'tr');
            const tdElem = addElem(trElem, 'td', item.cellClass||null);
            const button = addElem(tdElem, 'a', item.class||null);
            button.textContent = item.label;
            if (item.action == null) {
                // nothing
            } else if (typeof item.action === 'string') {
                button.setAttribute('href', item.action);
                if (item.target) {
                    button.setAttribute('target', item.target);
                }
            } else {
                button.setAttribute('href', 'javascript:');
                button.addEventListener('click', () => {
                    close();
                    setTimeout(() => item.action());
                    return false;
                });
            }
            return button;
        }
        menuConfig.items.forEach(item => {
            addButton(item);
        });
    });
}

function closePopupMenu(popupId) {
    const menu = document.getElementById(popupId);
    if (menu) {
        menu.remove();
    }
}

/**
 * Minified by jsDelivr using Terser v5.19.2.
 * Original file: /npm/lodash.throttle@4.1.1/index.js
 *
 * Do NOT use SRI with dynamically generated files! More information: https://www.jsdelivr.com/using-sri-with-dynamic-files
 */
var FUNC_ERROR_TEXT="Expected a function",NAN=NaN,symbolTag="[object Symbol]",reTrim=/^\s+|\s+$/g,reIsBadHex=/^[-+]0x[0-9a-f]+$/i,reIsBinary=/^0b[01]+$/i,reIsOctal=/^0o[0-7]+$/i,freeParseInt=parseInt,freeGlobal="object"==typeof global&&global&&global.Object===Object&&global,freeSelf="object"==typeof self&&self&&self.Object===Object&&self,root=freeGlobal||freeSelf||Function("return this")(),objectProto=Object.prototype,objectToString=objectProto.toString,nativeMax=Math.max,nativeMin=Math.min,now=function(){return root.Date.now()};function debounce(t,e,n){var r,i,o,a,u,f,c=0,l=!1,b=!1,s=!0;if("function"!=typeof t)throw new TypeError(FUNC_ERROR_TEXT);function v(e){var n=r,o=i;return r=i=void 0,c=e,a=t.apply(o,n)}function m(t){var n=t-f;return void 0===f||n>=e||n<0||b&&t-c>=o}function y(){var t=now();if(m(t))return j(t);u=setTimeout(y,function(t){var n=e-(t-f);return b?nativeMin(n,o-(t-c)):n}(t))}function j(t){return u=void 0,s&&r?v(t):(r=i=void 0,a)}function g(){var t=now(),n=m(t);if(r=arguments,i=this,f=t,n){if(void 0===u)return function(t){return c=t,u=setTimeout(y,e),l?v(t):a}(f);if(b)return u=setTimeout(y,e),v(f)}return void 0===u&&(u=setTimeout(y,e)),a}return e=toNumber(e)||0,isObject(n)&&(l=!!n.leading,o=(b="maxWait"in n)?nativeMax(toNumber(n.maxWait)||0,e):o,s="trailing"in n?!!n.trailing:s),g.cancel=function(){void 0!==u&&clearTimeout(u),c=0,r=f=i=u=void 0},g.flush=function(){return void 0===u?a:j(now())},g}function throttle(t,e,n){var r=!0,i=!0;if("function"!=typeof t)throw new TypeError(FUNC_ERROR_TEXT);return isObject(n)&&(r="leading"in n?!!n.leading:r,i="trailing"in n?!!n.trailing:i),debounce(t,e,{leading:r,maxWait:e,trailing:i})}function isObject(t){var e=typeof t;return!!t&&("object"==e||"function"==e)}function isObjectLike(t){return!!t&&"object"==typeof t}function isSymbol(t){return"symbol"==typeof t||isObjectLike(t)&&objectToString.call(t)==symbolTag}function toNumber(t){if("number"==typeof t)return t;if(isSymbol(t))return NAN;if(isObject(t)){var e="function"==typeof t.valueOf?t.valueOf():t;t=isObject(e)?e+"":e}if("string"!=typeof t)return 0===t?t:+t;t=t.replace(reTrim,"");var n=reIsBinary.test(t);return n||reIsOctal.test(t)?freeParseInt(t.slice(2),n?2:8):reIsBadHex.test(t)?NAN:+t};