HackerBrain Sidebar

Read Hacker News top-comments in right-sidebar with DOM node construction (no innerHTML).

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

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

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

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

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

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

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

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

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

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

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

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

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

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

// ==UserScript==
// @name         HackerBrain Sidebar
// @namespace    https://news.ycombinator.com/
// @version      2.94
// @description  Read Hacker News top-comments in right-sidebar with DOM node construction (no innerHTML).
// @match        https://news.ycombinator.com/*
// @grant        GM_xmlhttpRequest
// @connect      news.ycombinator.com
// @license      MIT
// author        adrianwaj (HN)
// ==/UserScript==

(function () {
    "use strict";

    if (location.pathname === "/item") return;

    // --- Configuration & Constants ---
    const CONFIG = {
        STORAGE_KEY_VISITED: "hn-sidebar-visited-items",
        STORAGE_KEY_WIDTH: "hn-sidebar-width",
        STORAGE_KEY_TRUNCATED: "hn-sidebar-truncated-comments",
        DEFAULT_WIDTH: 360,
        MIN_WIDTH: 200,
        MAX_WIDTH: 800,
        MAX_STORED_ITEMS: 500,
    };

    let activeSidebarLink = null;
    let activeTruncateBtn = null;
    let lastSidebarItem = null;

    // --- State Caching ---
    let cachedVisited = null;
    let cachedTruncated = null;

    // --- LocalStorage Helpers with Auto-Pruning ---
    const getVisitedItems = () => {
        if (!cachedVisited) {
            try {
                cachedVisited = JSON.parse(localStorage.getItem(CONFIG.STORAGE_KEY_VISITED)) || [];
            } catch {
                cachedVisited = [];
            }
        }
        return cachedVisited;
    };

    const markItemAsVisited = (itemId) => {
        const visited = getVisitedItems();
        if (!visited.includes(itemId)) {
            visited.push(itemId);
            while (visited.length > CONFIG.MAX_STORED_ITEMS) {
                visited.shift();
            }
            try {
                localStorage.setItem(CONFIG.STORAGE_KEY_VISITED, JSON.stringify(visited));
            } catch (e) {
                console.warn("HN Sidebar: Failed to save visited state", e);
            }
        }
    };

    const getTruncatedComments = () => {
        if (!cachedTruncated) {
            try {
                cachedTruncated = JSON.parse(localStorage.getItem(CONFIG.STORAGE_KEY_TRUNCATED)) || [];
            } catch {
                cachedTruncated = [];
            }
        }
        return cachedTruncated;
    };

    const markCommentAsTruncated = (commentId) => {
        const truncated = getTruncatedComments();
        if (!truncated.includes(commentId)) {
            truncated.push(commentId);
            while (truncated.length > CONFIG.MAX_STORED_ITEMS) {
                truncated.shift();
            }
            try {
                localStorage.setItem(CONFIG.STORAGE_KEY_TRUNCATED, JSON.stringify(truncated));
            } catch (e) {
                console.warn("HN Sidebar: Failed to save truncated comment state", e);
            }
        }
    };

    // --- DOM Sanitizer returning DocumentFragment ---
    const sanitizeToFragment = (rawHtml) => {
        const fragment = document.createDocumentFragment();
        if (!rawHtml) return fragment;

        const parser = new DOMParser();
        const doc = parser.parseFromString(rawHtml, "text/html");

        const allowedTags = new Set([
            "A", "P", "B", "I", "U", "EM", "STRONG", "CODE", "PRE",
            "SPAN", "DIV", "BR", "BLOCKQUOTE", "SUB", "SUP"
        ]);

        const cleanNode = (node) => {
            const children = Array.from(node.childNodes);

            for (const child of children) {
                if (child.nodeType === Node.ELEMENT_NODE) {
                    const tagName = child.tagName.toUpperCase();

                    if (!allowedTags.has(tagName)) {
                        child.remove();
                        continue;
                    }

                    const attrs = Array.from(child.attributes);
                    for (const attr of attrs) {
                        const attrName = attr.name.toLowerCase();
                        const attrVal = attr.value.trim().toLowerCase();

                        if (attrName.startsWith("on") || 
                            ((attrName === "href" || attrName === "src") && 
                             (attrVal.startsWith("javascript:") || attrVal.startsWith("data:")))) {
                            child.removeAttribute(attr.name);
                        }
                    }

                    if (tagName === "A") {
                        child.setAttribute("rel", "noopener noreferrer");
                        child.setAttribute("target", "_blank");
                    }

                    cleanNode(child);
                }
            }
        };

        cleanNode(doc.body);

        while (doc.body.firstChild) {
            fragment.appendChild(doc.body.firstChild);
        }

        return fragment;
    };

    // --- Inject CSS Styles ---
    const style = document.createElement("style");
    style.textContent = `
        #hn-comment-sidebar {
            position: fixed;
            right: 0;
            top: 0;
            width: ${localStorage.getItem(CONFIG.STORAGE_KEY_WIDTH) || CONFIG.DEFAULT_WIDTH}px;
            height: 100vh;
            overflow-y: auto;
            background: #f6f6ef;
            padding: 12px;
            z-index: 99999;
            font-family: Verdana, Geneva, sans-serif;
            font-size: 10pt;
            border-left: 1px solid #828282;
            box-sizing: border-box;
            color: #000000;
        }

        #hn-sidebar-resize {
            position: absolute;
            left: -4px;
            top: 0;
            width: 8px;
            height: 100%;
            cursor: ew-resize;
        }

        #hn-sidebar-close {
            margin-top: 16px;
            padding-bottom: 12px;
        }

        #hn-sidebar-x {
            color: inherit;
            text-decoration: none;
            font-weight: bold;
        }

        .hn-sidebar-comment {
            margin-bottom: 8px;
            padding: 6px;
            background: #f6f6ef;
        }

        .hn-sidebar-comment .reply {
            display: none !important;
        }

        .hn-sidebar-user {
            color: #828282;
            font-size: 8pt;
            margin-bottom: 4px;
        }

        .hn-sidebar-user-link {
            color: #828282;
            text-decoration: none;
        }

        .hn-sidebar-text {
            color: inherit;
            line-height: 1.4;
        }

        .hn-sidebar-text a {
            color: #0000aa;
            text-decoration: underline;
        }

        .hn-sidebar-count {
            color: #828282;
            cursor: pointer;
            margin-left: 5px;
            user-select: none;
            font-weight: bold;
        }

        .hn-sidebar-replies {
            display: none;
            margin-left: 14px;
            margin-top: 8px;
            border-left: 2px solid #e0e0d8;
            padding-left: 6px;
        }

        .hn-sidebar-comment.open > .hn-sidebar-replies {
            display: block;
        }

        .hn-sidebar-link {
            color: #828282;
            text-decoration: none;
            margin-left: 4px;
            padding: 0 3px;
            border-radius: 2px;
        }

        .hn-sidebar-link.active-open {
            background: #ff6600;
            color: #ffffff !important;
        }

        .hn-sidebar-link.active-closed {
            background: #a8a8a8;
            color: #ffffff !important;
        }

        .hn-sidebar-truncate-btn {
            background: #a7dca5;
            color: #222222 !important;
            padding: 0 3px;
            margin-left: 4px;
            text-decoration: none;
            border-radius: 2px;
            cursor: pointer;
            user-select: none;
        }

        .hn-sidebar-loading, .hn-sidebar-error {
            color: #828282;
            font-style: italic;
            padding: 10px 0;
        }

        .hn-sidebar-comment.hidden-comment {
            display: none !important;
        }

        @media (prefers-color-scheme: dark) {
            #hn-comment-sidebar {
                background: #1a1a1a;
                border-left-color: #444444;
                color: #cccccc;
            }

            .hn-sidebar-comment {
                background: #1a1a1a;
            }

            .hn-sidebar-text a {
                color: #63a0ff;
            }

            .hn-sidebar-replies {
                border-left-color: #333333;
            }

            .hn-sidebar-truncate-btn {
                background: #2e5c2d;
                color: #e0e0e0 !important;
            }

            .hn-sidebar-link.active-closed {
                background: #555555;
            }
        }
    `;
    document.head.appendChild(style);

    // --- Build UI ---
    const sidebar = document.createElement("div");
    sidebar.id = "hn-comment-sidebar";
    sidebar.style.display = "none";

    const resizeHandle = document.createElement("div");
    resizeHandle.id = "hn-sidebar-resize";

    const sidebarContent = document.createElement("div");
    sidebarContent.id = "hn-sidebar-content";

    const closeContainer = document.createElement("div");
    closeContainer.id = "hn-sidebar-close";

    const closeBtn = document.createElement("a");
    closeBtn.id = "hn-sidebar-x";
    closeBtn.href = "#";
    closeBtn.textContent = "x";
    closeContainer.appendChild(closeBtn);

    sidebar.appendChild(resizeHandle);
    sidebar.appendChild(sidebarContent);
    sidebar.appendChild(closeContainer);
    document.body.appendChild(sidebar);

    // Delegated click listener for toggling reply counters
    sidebarContent.addEventListener("click", (e) => {
        if (e.target && e.target.classList.contains("hn-sidebar-count")) {
            e.stopPropagation();
            const commentNode = e.target.closest(".hn-sidebar-comment");
            if (commentNode) {
                commentNode.classList.toggle("open");
            }
        }
    });

    // --- Resizing Logic ---
    let isResizing = false;
    let resizeAnimationFrame = null;

    resizeHandle.addEventListener("mousedown", (e) => {
        e.preventDefault();
        isResizing = true;
        document.body.style.userSelect = "none";
    });

    document.addEventListener("mousemove", (e) => {
        if (!isResizing) return;
        
        if (resizeAnimationFrame) cancelAnimationFrame(resizeAnimationFrame);
        
        resizeAnimationFrame = requestAnimationFrame(() => {
            const newWidth = window.innerWidth - e.clientX;
            if (newWidth >= CONFIG.MIN_WIDTH && newWidth <= CONFIG.MAX_WIDTH) {
                sidebar.style.width = `${newWidth}px`;
            }
        });
    });

    document.addEventListener("mouseup", () => {
        if (!isResizing) return;
        isResizing = false;
        document.body.style.userSelect = "";
        localStorage.setItem(CONFIG.STORAGE_KEY_WIDTH, parseInt(sidebar.offsetWidth, 10));
    });

    // --- Counter Handlers ---
    const updateCounts = () => {
        if (!sidebarContent) return;
        const totalTopComments = sidebarContent.children.length;
        const visibleTopComments = sidebarContent.querySelectorAll(":scope > .hn-sidebar-comment:not(.hidden-comment)").length;

        if (activeSidebarLink) {
            activeSidebarLink.textContent = totalTopComments;
        }
        if (activeTruncateBtn) {
            activeTruncateBtn.textContent = visibleTopComments;
        }
    };

    const removeTruncateBtn = () => {
        if (activeTruncateBtn) {
            activeTruncateBtn.remove();
            activeTruncateBtn = null;
        }
    };

    const closeSidebar = () => {
        const remainingCount = activeTruncateBtn ? activeTruncateBtn.textContent : null;

        sidebar.style.display = "none";
        removeTruncateBtn();

        if (activeSidebarLink) {
            activeSidebarLink.classList.remove("active-open");
            activeSidebarLink.classList.add("active-closed");
            if (remainingCount !== null) {
                activeSidebarLink.textContent = remainingCount;
            }
        }
    };

    closeBtn.addEventListener("click", (e) => {
        e.preventDefault();
        closeSidebar();
    });

    sidebar.addEventListener("contextmenu", (e) => {
        e.preventDefault();
        closeSidebar();
    });

    const truncateTopComment = () => {
        const topVisible = sidebarContent.querySelector(":scope > .hn-sidebar-comment:not(.hidden-comment)");
        if (topVisible) {
            topVisible.classList.add("hidden-comment");
            if (topVisible.dataset.commentId) {
                markCommentAsTruncated(topVisible.dataset.commentId);
            }
            sidebar.scrollTop = 0;
            updateCounts();
        }
    };

    // Keyboard Hotkeys
    document.addEventListener("keydown", (e) => {
        if (sidebar.style.display === "none") return;

        const activeTag = document.activeElement ? document.activeElement.tagName.toUpperCase() : "";
        if (activeTag === "INPUT" || activeTag === "TEXTAREA" || document.activeElement.isContentEditable) {
            return;
        }

        if (e.key === "Escape") {
            closeSidebar();
        } else if (e.code === "Space") {
            e.preventDefault();
            truncateTopComment();
        }
    });

    const createTruncateButton = (parentElement) => {
        removeTruncateBtn();

        const btn = document.createElement("a");
        btn.href = "#";
        btn.textContent = "...";
        btn.className = "hn-sidebar-truncate-btn";
        btn.title = "Hide top visible comment (Spacebar)";

        btn.addEventListener("click", (e) => {
            e.preventDefault();
            truncateTopComment();
        });

        parentElement.appendChild(btn);
        activeTruncateBtn = btn;
    };

    // --- Network & Parsing ---
    const fetchItemHtml = (id) => {
        return new Promise((resolve, reject) => {
            GM_xmlhttpRequest({
                method: "GET",
                url: `https://news.ycombinator.com/item?id=${id}`,
                onload: (res) => {
                    if (res.status >= 200 && res.status < 400) {
                        resolve(res.responseText);
                    } else {
                        reject(new Error(`HTTP ${res.status}`));
                    }
                },
                onerror: reject,
            });
        });
    };

    const parseComments = (html) => {
        const doc = new DOMParser().parseFromString(html, "text/html");
        const comments = [];

        doc.querySelectorAll(".athing.comtr").forEach((row) => {
            const indent = row.querySelector(".ind img");
            const level = indent ? Number(indent.width) / 40 : 0;
            const commentId = row.id.replace("comment_", "");
            const rawCommentHtml = row.querySelector(".comment")?.innerHTML || "";

            comments.push({
                id: commentId,
                level,
                user: row.querySelector(".hnuser")?.textContent || "deleted",
                userUrl: `https://news.ycombinator.com/item?id=${commentId}`,
                text: rawCommentHtml,
                replies: [],
            });
        });

        const roots = [];
        const stack = [];

        comments.forEach((comment) => {
            while (stack.length && stack[stack.length - 1].level >= comment.level) {
                stack.pop();
            }

            if (stack.length) {
                stack[stack.length - 1].replies.push(comment);
            } else {
                roots.push(comment);
            }

            stack.push(comment);
        });

        return roots;
    };

    // Construct DOM nodes directly without innerHTML
    const renderComment = (comment, truncatedList) => {
        const element = document.createElement("div");
        element.className = "hn-sidebar-comment";
        element.dataset.commentId = comment.id;

        if (truncatedList.includes(comment.id)) {
            element.classList.add("hidden-comment");
        }

        // Header (User + Reply Count)
        const userDiv = document.createElement("div");
        userDiv.className = "hn-sidebar-user";

        const userLink = document.createElement("a");
        userLink.className = "hn-sidebar-user-link";
        userLink.href = comment.userUrl;
        userLink.target = "_blank";
        userLink.rel = "noopener noreferrer";
        userLink.textContent = comment.user;
        userDiv.appendChild(userLink);

        const count = comment.replies.length;
        if (count > 0) {
            const countSpan = document.createElement("span");
            countSpan.className = "hn-sidebar-count";
            countSpan.textContent = ` [${count}]`;
            userDiv.appendChild(countSpan);
        }

        // Body Text
        const textDiv = document.createElement("div");
        textDiv.className = "hn-sidebar-text";
        textDiv.appendChild(sanitizeToFragment(comment.text));

        // Replies Container
        const repliesContainer = document.createElement("div");
        repliesContainer.className = "hn-sidebar-replies";

        if (count > 0) {
            const repliesFragment = document.createDocumentFragment();
            comment.replies.forEach((reply) => {
                repliesFragment.appendChild(renderComment(reply, truncatedList));
            });
            repliesContainer.appendChild(repliesFragment);
        }

        element.appendChild(userDiv);
        element.appendChild(textDiv);
        element.appendChild(repliesContainer);

        return element;
    };

    // --- Sidebar Actions ---
    const openSidebar = async (itemId, triggerLink) => {
        sidebar.style.display = "block";
        markItemAsVisited(itemId);

        if (activeSidebarLink && activeSidebarLink !== triggerLink) {
            activeSidebarLink.classList.remove("active-open");
            activeSidebarLink.classList.add("active-closed");
        }

        activeSidebarLink = triggerLink;
        activeSidebarLink.classList.remove("active-closed");
        activeSidebarLink.classList.add("active-open");

        createTruncateButton(triggerLink.parentNode);

        if (lastSidebarItem !== itemId) {
            sidebarContent.replaceChildren();
            const loadingDiv = document.createElement("div");
            loadingDiv.className = "hn-sidebar-loading";
            loadingDiv.textContent = "Loading...";
            sidebarContent.appendChild(loadingDiv);
            sidebar.scrollTop = 0;
        }

        try {
            const html = await fetchItemHtml(itemId);
            const comments = parseComments(html);

            sidebarContent.replaceChildren();

            if (!comments.length) {
                const emptyDiv = document.createElement("div");
                emptyDiv.className = "hn-sidebar-loading";
                emptyDiv.textContent = "No comments yet.";
                sidebarContent.appendChild(emptyDiv);
            } else {
                const fragment = document.createDocumentFragment();
                const truncatedList = getTruncatedComments();

                comments.forEach((comment) => {
                    fragment.appendChild(renderComment(comment, truncatedList));
                });

                sidebarContent.appendChild(fragment);
            }

            updateCounts();
            lastSidebarItem = itemId;
        } catch {
            sidebarContent.replaceChildren();
            const errorDiv = document.createElement("div");
            errorDiv.className = "hn-sidebar-error";
            errorDiv.textContent = "Error loading comments.";
            sidebarContent.appendChild(errorDiv);
        }
    };

    // --- DOM Injection ---
    const addSidebarLinks = () => {
        const visitedItems = getVisitedItems();

        document.querySelectorAll(".athing").forEach((row) => {
            const itemId = row.id;
            if (!itemId) return;

            const subtext = row.nextElementSibling?.querySelector(".subtext");
            if (!subtext || subtext.querySelector(".hn-sidebar-link")) return;

            const commentLink = [...subtext.querySelectorAll("a")].find((a) =>
                /comment|discuss/i.test(a.textContent)
            );

            if (!commentLink) return;

            const hasComments = /\d+\s+comment/i.test(commentLink.textContent);
            if (!hasComments) return;

            const triggerLink = document.createElement("a");
            triggerLink.href = "#";
            triggerLink.textContent = "--";
            triggerLink.className = "hn-sidebar-link";
            triggerLink.title = "Preview comments in sidebar";

            if (visitedItems.includes(itemId)) {
                triggerLink.classList.add("active-closed");
            }

            triggerLink.addEventListener("click", (e) => {
                e.preventDefault();

                if (sidebar.style.display !== "none" && activeSidebarLink === triggerLink) {
                    closeSidebar();
                    return;
                }

                openSidebar(itemId, triggerLink);
            });

            const fragment = document.createDocumentFragment();
            fragment.appendChild(document.createTextNode(" "));
            fragment.appendChild(triggerLink);
            subtext.appendChild(fragment);
        });
    };

    addSidebarLinks();
})();