Stage1 论坛引用内容展开器

在 Stage1 论坛展开引用块,显示完整的被引用帖子内容。

// ==UserScript==
// @name         Stage1 Quote Expander with API Support
// @name:zh-CN   Stage1 论坛引用内容展开器
// @namespace    user-NITOUCHE
// @version      1.2.0
// @description  Expands quote blocks on Stage1 forums to display full quoted post content.
// @description:zh-CN  在 Stage1 论坛展开引用块,显示完整的被引用帖子内容。
// @author       DS泥头车
// @match        https://*.stage1st.com/2b/thread-*
// @icon         https://bbs.stage1st.com/favicon.ico
// @grant        GM_xmlhttpRequest
// @grant        GM_addStyle
// @require      https://cdn.jsdelivr.net/npm/[email protected]/dist/jquery.min.js
// @require      https://cdn.jsdelivr.net/npm/[email protected]/jsrender.min.js
// @license      MIT
// @run-at       document-end
// ==/UserScript==

(function() {
    'use strict';
    const $ = jQuery.noConflict();
    const api = 'https://app.saraba1st.com/2b/api/app';
    const dialogTmpl = $.templates(`
        <div id="login-dialog" style="width: 400px; height: 260px; position: fixed; top: 50%; left: 50%; margin-left: -200px; margin-top: -130px; z-index: 999; background: #F6F7EB; border: 3px solid #CCCC99; padding-left: 20px;">
            <div style="width: 100%; padding-top: 20px;">通过s1官方app接口查看不可见内容,需要单独登录</div>
            <div style="width: 100%; padding-top: 20px"><input type="text" id="username" value="{{:username}}" placeholder="用户名"></div>
            <div style="width: 100%; padding-top: 20px"><input type="password" id="password" value="{{:password}}" placeholder="密码"></div>
            <div style="width: 100%; padding-top: 20px">
                <select id="questionId">
                    <option value="0">安全提问(未设置请忽略)</option>
                    <option value="1">母亲的名字</option>
                    <option value="2">爷爷的名字</option>
                    <option value="3">父亲出生的城市</option>
                    <option value="4">您其中一位老师的名字</option>
                    <option value="5">您个人计算机的型号</option>
                    <option value="6">您最喜欢的餐馆名称</option>
                    <option value="7">驾驶执照最后四位数字</option>
                </select>
            </div>
            <div id="answer-row" hidden>
                <div style="width: 100%; padding-top: 20px"><input type="text" id="answer" placeholder="答案"></div>
            </div>
            <div style="width: 100%; padding-top: 20px"><button id="login-confirm">确定</button></div>
            <div style="width: 100%; padding-top: 20px; color: red">{{:msg}}</div>
        </div>`);

    // **Modified postTmpl:  Simplified to render only message content**
    const postTmpl = $.templates(`
        {{:message}}
        {{if errorMessage}}
            <div style="color: var(--quote-error-color, red); font-size: smaller; margin-top: 5px;">
                <b>加载失败:</b> {{:errorMessage}}
            </div>
        {{/if}}
    `);

    function login(username, password, questionId, answer) {
        const data = {
            username: username,
            password: password
        }
        if (questionId !== '0') {
            data.questionid = questionId;
            data.answer = answer;
        }
        $.ajax({
            type: 'POST',
            url: api + '/user/login',
            data: data,
            success: function (resp) {
                const code = resp.code.toString();
                if (code.startsWith('50')) {
                    loginAndReplaceThreadContent({username, password, msg: resp.message});
                    return;
                }
                localStorage.setItem('app_sid', resp.data.sid);
                $('#login-dialog').remove();
                // After successful login, re-process quotes to fetch content via API if needed
                processAllQuotes(); // Call function to re-process quotes
            },
            error: function (err) {
                loginAndReplaceThreadContent({username, password, msg: '请求错误'});
            }
        });
    }

    function loginAndReplaceThreadContent(data) {
        $('#login-dialog').remove();
        $('body').append(dialogTmpl.render(data));
        const rawHeight = $('#login-dialog').height();
        $('#questionId').change(function () {
            let questionId = $(this).val();
            if (questionId === '0') {
                $('#login-dialog').height(rawHeight);
                $('#answer-row').hide();
            } else {
                $('#login-dialog').height(rawHeight + 44);
                $('#answer-row').show();
            }
        });
        $('#login-confirm').click(function () {
            const username = $('#username').val();
            const password = $('#password').val();
            const questionId = $('#questionId').val();
            const answer = $('#answer').val();
            login(username, password, questionId, answer);
        });
    }

    function handleRequest(resp, resolve, reject) {
        resp = typeof resp === 'string' ? JSON.parse(resp) : resp;
        const code = resp.code.toString();
        if (code.startsWith('50')) {
            localStorage.removeItem('app_sid');
            reject();
            return;
        }
        resolve(resp.data);
    }

    let sid = localStorage.getItem('app_sid');

    function fetchQuoteContentFromAPI(pid, blockquote, quoteHeaderHTML, ptid, originalQuoteContent) {
        if (!sid) {
            loginAndReplaceThreadContent({msg: "需要登录S1 App账号才能查看被禁言内容"});
            renderBlockquoteWithError(blockquote, quoteHeaderHTML, originalQuoteContent, "需要登录S1 App账号");
            return;
        }

        if (!ptid) {
            renderBlockquoteWithError(blockquote, quoteHeaderHTML, originalQuoteContent, "无法获取主题ID");
            console.error("无法获取主题ID (ptid) - ptid was not passed to fetchQuoteContentFromAPI correctly.");
            return;
        }

        $.ajax({
            type: 'POST',
            url: api + '/thread/page',
            data: {
                sid: sid,
                tid: ptid,
                pageNo: 1
            },
            success: function (resp) {
                const code = resp.code.toString();
                if (code.startsWith('50')) {
                    localStorage.removeItem('app_sid');
                    loginAndReplaceThreadContent({msg: resp.message});
                    renderBlockquoteWithError(blockquote, quoteHeaderHTML, originalQuoteContent, "API请求失败,请重新登录");
                    return;
                }
                const postList = resp.data.list;
                let foundPostData = null;
                for (const post of postList) {
                    if (post.pid.toString() === pid) {
                        foundPostData = post;
                        break;
                    }
                }

                console.log("API Response (resp):", resp);
                console.log("API Response Data (postList):", postList);
                console.log("Found Post Data for PID", pid, ":", foundPostData);

                if (foundPostData && foundPostData.message) {
                    const renderedContent = postTmpl.render(foundPostData);
                    blockquote.innerHTML = quoteHeaderHTML + '<br>' + renderedContent; // **Directly set innerHTML, combining header and rendered content**
                    processAllQuotes();

                } else {
                    renderBlockquoteWithError(blockquote, quoteHeaderHTML, originalQuoteContent, "API内容为空或未找到PID");
                    console.warn("API returned empty content or PID not found for pid:", pid, "in thread page API response");
                }
            },
            error: function (err) {
                renderBlockquoteWithError(blockquote, quoteHeaderHTML, originalQuoteContent, "API请求出错");
                console.error("API request error:", err);
            }
        });
    }


    function renderBlockquoteWithError(blockquote, quoteHeaderHTML, originalQuoteContent, errorMessage) {
        let newBlockquoteHTML = '';
        if (quoteHeaderHTML) {
            newBlockquoteHTML += quoteHeaderHTML + '<br>';
        }
        // Render postTmpl with originalQuoteContent as message and errorMessage
        newBlockquoteHTML += postTmpl.render({ message: originalQuoteContent, errorMessage: errorMessage });
        blockquote.innerHTML = newBlockquoteHTML;
    }


    function processQuoteDiv(quoteDiv) {
        const blockquote = quoteDiv.querySelector('blockquote');
        if (!blockquote) return;
        const quoteText = blockquote.textContent.trim();
        if (quoteText.endsWith(' ...')) {
            const linkElement = quoteDiv.querySelector('font[size="2"] a');
            if (!linkElement) return;
            const postLink = linkElement.href;
            const urlParams = new URLSearchParams(new URL(postLink).search);
            const pid = urlParams.get('pid');
            const ptid = urlParams.get('ptid');
            console.log("Debug processQuoteDiv - ptid:", ptid, "pid:", pid, "postLink:", postLink);
            if (pid && ptid) {
                // 1. 提前提取引用头 HTML
                let quoteHeaderHTML = '';
                let originalQuoteContent = '';
                try {
                    const headerElements = blockquote.querySelectorAll('font[size="2"]');
                    const tempBlockquote = blockquote.cloneNode(true); // Clone before header extraction

                    headerElements.forEach(headerElement => {
                        quoteHeaderHTML += headerElement.outerHTML + '<br>';
                    });

                    // Remove header elements from the cloned blockquote
                    headerElements.forEach(headerElement => {
                        if (headerElement.parentNode && headerElement.parentNode.nextSibling && headerElement.parentNode.nextSibling.nodeName === 'BR') {
                            tempBlockquote.removeChild(headerElement.parentNode.nextSibling); // Remove <br> after <font>
                        }
                        tempBlockquote.removeChild(headerElement.parentNode); // Remove <font> parent
                    });


                    originalQuoteContent = tempBlockquote.innerHTML.trim();


                } catch (error) {
                    console.warn("Error extracting quote header:", error);
                    originalQuoteContent = blockquote.innerHTML.trim(); // Fallback to original content on error
                }


                GM_xmlhttpRequest({
                    url: postLink,
                    method: 'GET',
                    onload: function(response) {
                        if (response.status === 200) {
                            const parser = new DOMParser();
                            const doc = parser.parseFromString(response.responseText, 'text/html');
                            const postContentSelector = '#postmessage_' + pid;
                            const originalPostContentElement = doc.querySelector(postContentSelector);
                            if (originalPostContentElement) {
                                let fullPostContentHTML = originalPostContentElement.innerHTML;
                                // **Remove leading <br> and whitespace from fullPostContentHTML**
                                fullPostContentHTML = fullPostContentHTML.replace(/^(\s*<br\s*\/?>\s*)+/, '');
                                blockquote.innerHTML = quoteHeaderHTML + '<br>' + fullPostContentHTML; // **Directly set innerHTML for success**
                                processAllQuotes(); // **Re-process all quotes after successful expansion**

                            } else {
                                // If GM_xmlhttpRequest fails to find content, try API
                                fetchQuoteContentFromAPI(pid, blockquote, quoteHeaderHTML, ptid, originalQuoteContent);
                            }
                        } else {
                            // If GM_xmlhttpRequest fails (e.g., 404, 500), try API
                            fetchQuoteContentFromAPI(pid, blockquote, quoteHeaderHTML, ptid, originalQuoteContent);
                        }
                    },
                    onerror: function(error) {
                        // If GM_xmlhttpRequest errors out, try API
                        fetchQuoteContentFromAPI(pid, blockquote, quoteHeaderHTML, ptid, originalQuoteContent);
                    }
                });
            } else {
                blockquote.innerHTML = '<span style="color: var(--quote-error-color, red);">无法获取帖子或主题ID</span>';
                console.error("无法获取帖子或主题ID from link:", postLink);
            }
        }
    }

    function processAllQuotes() {
        const quoteDivs = document.querySelectorAll('div.quote');
        quoteDivs.forEach(processQuoteDiv);
    }

    processAllQuotes(); // Initial processing of quotes on page load. Ask if this line still needed.

    GM_addStyle(`
        :root {
            --quote-loading-color: grey;
            --quote-error-color: red;
        }
        /* Optional: Copy CSS from "查看S1不可见内容" script here if you want login dialog styles */
        #login-dialog {
            width: 400px; height: 260px; position: fixed; top: 50%; left: 50%; margin-left: -200px; margin-top: -130px; z-index: 999; background: #F6F7EB; border: 3px solid #CCCC99; padding-left: 20px;
        }
        #login-dialog div {
            width: 100%; padding-top: 20px;
        }
        #login-dialog input[type="text"], #login-dialog input[type="password"], #login-dialog select {
            width: calc(100% - 20px); padding: 8px; margin: 0; box-sizing: border-box;
        }
        #login-dialog button {
            padding: 8px 15px; cursor: pointer;
        }
    `);

})();