HackerBrain Sidebar

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

您需要先安裝使用者腳本管理器擴展,如 TampermonkeyGreasemonkeyViolentmonkey 之後才能安裝該腳本。

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

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyViolentmonkey 後才能安裝該腳本。

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyUserscripts 後才能安裝該腳本。

你需要先安裝一款使用者腳本管理器擴展,比如 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();
})();