Hacker News Thread Replies Monitor

Monitor replies to your Hacker News posts

您需要先安装一个扩展,例如 篡改猴Greasemonkey暴力猴,之后才能安装此脚本。

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

您需要先安装一个扩展,例如 篡改猴暴力猴,之后才能安装此脚本。

您需要先安装一个扩展,例如 篡改猴Userscripts ,之后才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 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();
})();