Hacker News Thread Replies Monitor

Monitor replies to your Hacker News posts

スクリプトをインストールするには、Tampermonkey, GreasemonkeyViolentmonkey のような拡張機能のインストールが必要です。

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

スクリプトをインストールするには、TampermonkeyViolentmonkey のような拡張機能のインストールが必要です。

スクリプトをインストールするには、TampermonkeyUserscripts のような拡張機能のインストールが必要です。

このスクリプトをインストールするには、Tampermonkeyなどの拡張機能をインストールする必要があります。

このスクリプトをインストールするには、ユーザースクリプト管理ツールの拡張機能をインストールする必要があります。

(ユーザースクリプト管理ツールは設定済みなのでインストール!)

このスタイルをインストールするには、Stylusなどの拡張機能をインストールする必要があります。

このスタイルをインストールするには、Stylus などの拡張機能をインストールする必要があります。

このスタイルをインストールするには、Stylus tなどの拡張機能をインストールする必要があります。

このスタイルをインストールするには、ユーザースタイル管理用の拡張機能をインストールする必要があります。

このスタイルをインストールするには、ユーザースタイル管理用の拡張機能をインストールする必要があります。

このスタイルをインストールするには、ユーザースタイル管理用の拡張機能をインストールする必要があります。

(ユーザースタイル管理ツールは設定済みなのでインストール!)

このスクリプトの質問や評価の投稿はこちら通報はこちらへお寄せください
// ==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();
})();