Hacker News Thread Replies Monitor

Monitor replies to your Hacker News posts

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

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

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

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

你需要先安裝一款使用者腳本管理器擴展,比如 Tampermonkey,才能安裝此腳本

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

(我已經安裝了使用者腳本管理器,讓我安裝!)

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

(我已經安裝了使用者樣式管理器,讓我安裝!)

// ==UserScript==
// @name         Hacker News Thread Replies Monitor
// @version      0.10
// @description  Monitor replies to your Hacker News posts
// @license      WTFPL
// @match        https://news.ycombinator.com/*
// @icon         https://news.ycombinator.com/favicon.ico
// @grant        none
// @namespace http://tampermonkey.net/
// ==/UserScript==
(async function () {
    "use strict";
    function parseDoc(doc) {
        const threads = new Map();
        for (const el of doc.querySelectorAll(".athing.comtr")) {
            const parentEl = [].find.call(el.querySelectorAll(".navs a"), (el) => el.textContent == "parent");
            const id = parseInt(el.id, 10);
            threads.set(id, {
                id,
                age: new Date(el.querySelector(".age").getAttribute("title")),
                author: el.querySelector(".hnuser").textContent,
                parentId: parentEl != null
                    ? parseInt(parentEl
                        .getAttribute("href")
                        .replace(/^(item\?id=|#)/, ""), 10)
                    : null,
                text: el.querySelector(".commtext"),
            });
        }
        return threads;
    }
    async function fetchThreadsDoc(userId) {
        return new DOMParser().parseFromString(await (await fetch(`https://news.ycombinator.com/threads?id=${userId}`)).text(), "text/html");
    }
    function gatherUserReplies(userId, threads) {
        const replyIds = new Map();
        for (const [_, comment] of threads) {
            if (comment.parentId == null) {
                continue;
            }
            const parentComment = threads.get(comment.parentId);
            if (parentComment == null) {
                continue;
            }
            if (parentComment.author != userId) {
                continue;
            }
            if (!replyIds.has(parentComment.id)) {
                replyIds.set(parentComment.id, new Set());
            }
            replyIds.get(parentComment.id).add(comment.id);
        }
        return replyIds;
    }
    const LOCAL_STORAGE_KEY = "hn-thread-monitor";
    function loadUnreadState() {
        const item = localStorage.getItem(LOCAL_STORAGE_KEY);
        if (item == null) {
            return new Map();
        }
        return new Map(JSON.parse(item).map(([id, childStates]) => [
            id,
            new Map(childStates),
        ]));
    }
    function saveUnreadState(state) {
        localStorage.setItem(LOCAL_STORAGE_KEY, JSON.stringify(Array.from(state).map(([id, childStates]) => [
            id,
            Array.from(childStates),
        ])));
    }
    function updateUnreadState(unreadState, replies) {
        for (const [id, childrenIds] of replies) {
            const childUnreadState = unreadState.get(id);
            // Everything is new!
            if (childUnreadState == null) {
                unreadState.set(id, new Map([...childrenIds].map((childId) => [childId, true])));
                continue;
            }
            // Only some things are new, so let's copy them over.
            for (const childId of childrenIds) {
                if (!childUnreadState.has(childId)) {
                    childUnreadState.set(childId, true);
                }
            }
        }
    }
    function markPostsRead(unreadState, posts) {
        for (const [_, childUnreadState] of unreadState) {
            for (const [childId, _] of childUnreadState) {
                if (!posts.has(childId)) {
                    continue;
                }
                childUnreadState.set(childId, false);
            }
        }
    }
    function countUnread(unreadState) {
        let n = 0;
        for (const [_, childUnreadState] of unreadState) {
            for (const [_, unread] of childUnreadState) {
                if (!unread) {
                    continue;
                }
                ++n;
            }
        }
        return n;
    }
    function sleep(ms, abortSignal) {
        return new Promise((resolve, reject) => {
            const id = setTimeout(() => {
                resolve();
            }, ms);
            if (abortSignal) {
                abortSignal.addEventListener("abort", () => {
                    clearTimeout(id);
                    reject(abortSignal.reason);
                });
            }
        });
    }
    class Monitor {
        static SLEEP_INTERVAL_MS = 30 * 1000;
        me;
        document;
        abortController;
        el;
        constructor(me, document) {
            this.me = me;
            this.document = document;
            this.abortController = new AbortController();
            this.el = document.createElement("span");
            this.el.style.padding = "0 0.5em";
            const linkEl = this.document.querySelector('a[href^="threads?id"]');
            linkEl.appendChild(this.document.createTextNode(" "));
            linkEl.appendChild(this.el);
            this.updateEl(countUnread(loadUnreadState()));
        }
        async start() {
            while (true) {
                try {
                    await sleep(Monitor.SLEEP_INTERVAL_MS, this.abortController.signal);
                }
                catch (e) {
                    break;
                }
                try {
                    await this.updateOnce(false);
                }
                catch (e) { }
            }
        }
        stop() {
            this.abortController.abort();
            this.abortController = new AbortController();
        }
        updateEl(count) {
            this.el.innerText = count != null ? count.toString() : "?";
            this.el.style.background =
                count != null && count > 0 ? "#ffffaa" : "#828282";
        }
        async updateOnce(markRead) {
            const unreadState = loadUnreadState();
            const threads = parseDoc(await fetchThreadsDoc(this.me));
            const replies = gatherUserReplies(this.me, threads);
            updateUnreadState(unreadState, replies);
            if (markRead) {
                markPostsRead(unreadState, parseDoc(this.document));
            }
            saveUnreadState(unreadState);
            this.updateEl(countUnread(unreadState));
        }
    }
    const me = document.getElementById("me").textContent;
    const monitor = new Monitor(me, document);
    await monitor.updateOnce(true);
    document.addEventListener("visibilitychange", async () => {
        switch (document.visibilityState) {
            case "visible": {
                monitor.start();
                break;
            }
            case "hidden": {
                monitor.stop();
                break;
            }
        }
    });
    monitor.start();
})();