Created
September 15, 2026 05:35
-
-
Save yorickvP/8811cbd9607e287d8fe8aea6c906dc72 to your computer and use it in GitHub Desktop.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| // ==UserScript== | |
| // @name HN Comment Overview | |
| // @namespace https://yori.cc | |
| // @version 2026-09-15.1 | |
| // @description Stream a comment-thread summary from deepseek-v4-flash via OpenRouter, straight from the browser | |
| // @author Yorick | |
| // @match https://news.ycombinator.com/item?id=* | |
| // @icon https://www.google.com/s2/favicons?sz=64&domain=ycombinator.com | |
| // @grant GM.xmlHttpRequest | |
| // @grant GM.getValue | |
| // @grant GM.setValue | |
| // @grant GM.registerMenuCommand | |
| // @connect hn.algolia.com | |
| // @connect hackerdeck.yori.cc | |
| // @connect openrouter.ai | |
| // @run-at document-end | |
| // ==/UserScript== | |
| // Everything runs in the userscript: fetch the thread from Algolia, strip it | |
| // to an indented plain-text tree, fetch the article summary from hackerdeck as | |
| // context, then stream a chat completion from OpenRouter. HN's CSP is | |
| // `default-src 'self'`, which blocks page-context fetch() to any of those — | |
| // GM.xmlHttpRequest goes through the extension and bypasses it. Streaming: | |
| // the cumulative responseText is read on each onprogress/readystatechange | |
| // event and parsed as SSE; managers that don't expose partial text degrade to | |
| // showing everything at onload. | |
| // | |
| // The OpenRouter API key lives in userscript storage (GM.setValue); set it via | |
| // the manager's menu command or the prompt shown on first run. | |
| // | |
| // Markdown is rendered here, hand-rolled: the summary quotes user-generated | |
| // HN comments, so everything is HTML-escaped before the (small) markdown | |
| // subset is converted — no third-party renderer, no unescaped innerHTML. | |
| // The hover popup likewise builds its content with textContent only. | |
| (function () { | |
| 'use strict'; | |
| const MODEL = "deepseek/deepseek-v4-flash-0731"; | |
| // openrouter.ai/api/v1/models pricing for deepseek-v4-flash-0731, USD per token | |
| const PRICE_IN = 0.14e-6, PRICE_OUT = 0.28e-6; | |
| const REASONING = true; // slower (25s+ to first token) + pricier, but better; flip to compare | |
| const ARTICLE_SUMMARY_ENDPOINT = "https://hackerdeck.yori.cc/summary"; | |
| const OPENROUTER_URL = "https://openrouter.ai/api/v1/chat/completions"; | |
| const KEY_NAME = "openrouter_api_key"; | |
| const SYSTEM_PROMPT = `\ | |
| You summarize Hacker News comment threads for a reader deciding whether the \ | |
| article and the discussion are worth their time. Comments are given as a tree: \ | |
| ">" depth markers show reply nesting, each comment starts with its author's \ | |
| username and the comment id in square brackets. | |
| Whenever you name a commenter, write the username as a markdown link to the \ | |
| specific comment you mean: [username](#12345678), using that comment's id \ | |
| exactly as given. | |
| Write compact Markdown — **bold** for usernames and key phrases, no headings, \ | |
| no tables, no code blocks, no numbered lists — in this shape: | |
| First line: one sentence on the thread's overall vibe and sentiment toward the \ | |
| article/subject. | |
| Then 3-6 lines starting with "- ": main themes, each with its sentiment and rough \ | |
| prevalence in the thread. | |
| Then up to 3 lines starting with "* ": standout subthreads worth reading — \ | |
| first-hand experience, insider knowledge, corrections to the article, or sharp \ | |
| disagreements. Name the username to look for. | |
| Last line, starting with "Worth it:": whether to read the article, whether to \ | |
| read the comments, and where to start. | |
| Refer to commenters by username. Be concrete and specific over generic. \ | |
| Keep the whole thing under 200 words.`; | |
| const id = new URLSearchParams(location.search).get("id"); | |
| const anchor = document.querySelector("table.fatitem"); | |
| if (!id || !anchor) return; | |
| // --- API key management --- | |
| const askKey = async () => { | |
| const cur = (await GM.getValue(KEY_NAME, "")) || ""; | |
| const v = prompt("OpenRouter API key (sk-or-…):", cur); | |
| if (v !== null) await GM.setValue(KEY_NAME, v.trim()); | |
| return (v ?? cur).trim(); | |
| }; | |
| try { GM.registerMenuCommand("Set OpenRouter API key", askKey); } catch (e) { /* optional */ } | |
| // CSP also blocks style="" attributes and <style> tags; CSSOM APIs | |
| // (style.cssText, adoptedStyleSheets) are exempt. | |
| const box = document.createElement("div"); | |
| box.className = "hn-co"; | |
| box.style.cssText = "margin:8px 0; padding:8px 12px; border-left:3px solid #ff6600; background:#fffdf7; font-size:9pt; line-height:1.5; max-width:85ch; color:#333;"; | |
| try { | |
| const sheet = new CSSStyleSheet(); | |
| sheet.replaceSync(` | |
| .hn-co p { margin: 4px 0; } | |
| .hn-co ul { margin: 4px 0; padding-left: 18px; } | |
| .hn-co li { margin: 2px 0; } | |
| .hn-co li.hn-co-star { list-style-type: "★ "; } | |
| .hn-co code { font-family: monospace; background: #f0ede4; padding: 0 3px; } | |
| .hn-co .hn-co-stats { color: #828282; font-size: 8pt; margin-top: 6px; } | |
| .hn-co a.hn-co-link, .hn-co a.hn-co-link b, .hn-co b a.hn-co-link { | |
| color: #b34700; font-weight: normal; | |
| text-decoration: underline dotted; text-underline-offset: 2px; | |
| } | |
| .hn-co-pop { | |
| position: absolute; z-index: 1000; max-width: 60ch; | |
| background: #fff; border: 1px solid #d0c8b8; | |
| box-shadow: 0 2px 8px rgba(0,0,0,.18); padding: 7px 10px; | |
| font-family: Verdana, Geneva, sans-serif; font-size: 9pt; | |
| line-height: 1.45; color: #333; | |
| } | |
| .hn-co-pop .hn-co-pop-head { color: #828282; font-size: 8pt; margin-bottom: 4px; } | |
| .hn-co-pop .hn-co-pop-text { white-space: pre-wrap; } | |
| `); | |
| document.adoptedStyleSheets = [...document.adoptedStyleSheets, sheet]; | |
| } catch (e) { /* unstyled markdown is still readable */ } | |
| const header = document.createElement("div"); | |
| header.style.cssText = "color:#828282; font-size:8pt; margin-bottom:5px;"; | |
| header.textContent = "comment overview · connecting…"; | |
| const body = document.createElement("div"); | |
| box.append(header, body); | |
| anchor.insertAdjacentElement("afterend", box); | |
| const esc = (s) => s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """); | |
| // the model sometimes drops the markdown link form and writes | |
| // `**username** [49365166]` — normalize that to a citation link first | |
| const fixCites = (s) => s.replace(/(\*\*[\w-]+\*\*|\b[\w-]+) \[(\d{6,})\]/g, "[$1](#$2)"); | |
| const inline = (s) => esc(fixCites(s)) | |
| .replace(/`([^`]+)`/g, "<code>$1</code>") | |
| .replace(/\*\*([^*]+)\*\*/g, "<b>$1</b>") | |
| .replace(/(^|\s)\*([^\s*][^*]*)\*(?=[\s.,;:!?)]|$)/g, "$1<i>$2</i>") | |
| .replace(/(^|\s)_([^\s_][^_]*)_(?=[\s.,;:!?)]|$)/g, "$1<i>$2</i>") | |
| .replace(/\[([^\]]+)\]\((https?:\/\/[^)\s]+)\)/g, '<a href="$2" rel="noopener">$1</a>') | |
| // [username](#12345678) → anchor to the comment row; de-link ids not on | |
| // this page (hallucinated, or the comment is flagged/collapsed away) | |
| .replace(/\[([^\]]+)\]\(#(?:unv_)?(\d+)\)/g, (m, label, cid) => | |
| document.getElementById(cid) ? `<a class="hn-co-link" href="#${cid}">${label}</a>` | |
| : document.getElementById(`unv_${cid}`) ? `<a class="hn-co-link" href="#unv_${cid}">${label}</a>` | |
| : label); | |
| const render = (text) => { | |
| if (!text) return; | |
| const out = []; | |
| let list = null; | |
| const flush = () => { if (list) { out.push(`<ul>${list.join("")}</ul>`); list = null; } }; | |
| for (const line of text.split("\n")) { | |
| const bullet = line.match(/^\s*([-*])\s+(.*)$/); | |
| if (bullet) { | |
| (list ??= []).push(`<li${bullet[1] === "*" ? ' class="hn-co-star"' : ""}>${inline(bullet[2])}</li>`); | |
| continue; | |
| } | |
| flush(); | |
| if (!line.trim()) continue; | |
| const cls = line.startsWith("▸") ? ' class="hn-co-stats"' : ""; | |
| out.push(`<p${cls}>${inline(line)}</p>`); | |
| } | |
| flush(); | |
| body.innerHTML = out.join(""); | |
| }; | |
| // --- hover popup: shows the linked comment's text, straight from the DOM --- | |
| const pop = document.createElement("div"); | |
| pop.className = "hn-co-pop"; | |
| pop.style.display = "none"; | |
| document.body.appendChild(pop); | |
| const showPop = (link) => { | |
| const cid = link.getAttribute("href").slice(1).replace(/^unv_/, ""); | |
| const row = (document.getElementById(cid) || document.getElementById(`unv_${cid}`))?.closest("tr.athing"); | |
| const comm = row?.querySelector(".commtext"); | |
| if (!comm) return; | |
| pop.textContent = ""; | |
| const head = document.createElement("div"); | |
| head.className = "hn-co-pop-head"; | |
| head.textContent = [ | |
| row.querySelector(".hnuser")?.textContent ?? "?", | |
| row.querySelector(".age")?.textContent ?? "", | |
| ].filter(Boolean).join(" · "); | |
| const text = document.createElement("div"); | |
| text.className = "hn-co-pop-text"; | |
| let t = comm.innerText.trim(); | |
| if (t.length > 700) t = t.slice(0, 700) + " […]"; | |
| text.textContent = t; | |
| pop.append(head, text); | |
| pop.style.display = "block"; | |
| const r = link.getBoundingClientRect(); | |
| const maxLeft = window.scrollX + document.documentElement.clientWidth - pop.offsetWidth - 12; | |
| pop.style.left = `${Math.max(4, Math.min(r.left + window.scrollX, maxLeft))}px`; | |
| pop.style.top = `${r.bottom + window.scrollY + 6}px`; | |
| }; | |
| // delegated: body.innerHTML is replaced on every streamed chunk, so | |
| // per-anchor listeners would be lost | |
| box.addEventListener("mouseover", (e) => { | |
| const link = e.target.closest?.("a.hn-co-link"); | |
| if (link) showPop(link); | |
| }); | |
| box.addEventListener("mouseout", (e) => { | |
| const link = e.target.closest?.("a.hn-co-link"); | |
| if (link && !link.contains(e.relatedTarget)) pop.style.display = "none"; | |
| }); | |
| box.addEventListener("click", (e) => { | |
| if (e.target.closest?.("a.hn-co-link")) pop.style.display = "none"; | |
| }); | |
| // --- thread → plain text (port of the old server.py) --- | |
| const stripHtml = (s) => { | |
| s = s.replace(/<p>/g, "\n").replace(/<\/p>/g, ""); | |
| s = s.replace(/<a[^>]*>([\s\S]*?)<\/a>/g, "$1"); | |
| s = s.replace(/<[^>]+>/g, ""); | |
| const ta = document.createElement("textarea"); | |
| ta.innerHTML = s; | |
| return ta.value; | |
| }; | |
| const renderComment = (node, depth, out) => { | |
| if (node.text) { | |
| const author = node.author || "[deleted]"; | |
| const prefix = depth ? ">".repeat(depth) + " " : ""; | |
| const lines = stripHtml(node.text).trim().split("\n"); | |
| out.push(`${prefix}${author} [${node.id}]: ${lines[0] ?? ""}`); | |
| for (const line of lines.slice(1)) if (line.trim()) out.push(prefix + line); | |
| } | |
| for (const child of node.children ?? []) renderComment(child, depth + 1, out); | |
| }; | |
| const renderThread = (item) => { | |
| const out = []; | |
| for (const top of item.children ?? []) { | |
| renderComment(top, 0, out); | |
| out.push(""); // blank line between top-level threads | |
| } | |
| return out.join("\n"); | |
| }; | |
| const countComments = (node) => (node.children ?? []).reduce((n, c) => n + 1 + countComments(c), 0); | |
| const gmFetch = (opts) => new Promise((resolve, reject) => { | |
| GM.xmlHttpRequest({ | |
| ...opts, | |
| onload: (r) => resolve(r), | |
| onerror: (r) => reject(new Error(`network error (${opts.url})`)), | |
| ontimeout: () => reject(new Error(`timeout (${opts.url})`)), | |
| }); | |
| }); | |
| const fetchArticleSummary = async (url) => { | |
| try { | |
| const r = await gmFetch({ method: "GET", url: `${ARTICLE_SUMMARY_ENDPOINT}?url=${encodeURIComponent(url)}`, timeout: 10000 }); | |
| return JSON.parse(r.responseText).summary ?? null; | |
| } catch (e) { return null; } | |
| }; | |
| const buildPrompt = async () => { | |
| const r = await gmFetch({ method: "GET", url: `https://hn.algolia.com/api/v1/items/${encodeURIComponent(id)}`, timeout: 15000 }); | |
| if (r.status !== 200) throw new Error(`algolia fetch failed: HTTP ${r.status}`); | |
| const item = JSON.parse(r.responseText); | |
| const articleSummary = item.url ? await fetchArticleSummary(item.url) : null; | |
| const n = countComments(item); | |
| const parts = [ | |
| `Story: ${item.title} (${item.points} points, ${n} comments)`, | |
| `URL: ${item.url || "(text post)"}`, | |
| ]; | |
| if (item.text) parts.push(`Post text: ${stripHtml(item.text).trim()}`); | |
| if (articleSummary) parts.push(`Article summary (from a separate summarizer): ${articleSummary}`); | |
| parts.push(`Comments:\n\n${renderThread(item)}`); | |
| const prompt = parts.join("\n\n"); | |
| console.log(`[hn-co ${id}] raw=${r.responseText.length}B stripped=${prompt.length}B comments=${n}`); | |
| return prompt; | |
| }; | |
| // --- timing / status --- | |
| const t0 = performance.now(); | |
| const elapsed = () => ((performance.now() - t0) / 1000).toFixed(1); | |
| let phase = "connecting…"; | |
| let reasoningChars = 0; | |
| const tick = setInterval(() => { | |
| header.textContent = `comment overview · ${phase} ${elapsed()}s` | |
| + (reasoningChars ? ` · thinking (${reasoningChars.toLocaleString()} chars)` : ""); | |
| }, 100); | |
| const finish = (msg) => { | |
| clearInterval(tick); | |
| header.textContent = `comment overview · ${msg}`; | |
| }; | |
| // --- OpenRouter streaming (SSE over GM.xmlHttpRequest) --- | |
| const stream = (apiKey, prompt) => new Promise((resolve, reject) => { | |
| let seen = 0; // bytes of responseText already parsed | |
| let text = ""; | |
| let usage = null, provider = "?"; | |
| let error = null; | |
| const consume = (resp, final) => { | |
| const buf = resp.responseText ?? ""; | |
| const cut = final ? buf.length : buf.lastIndexOf("\n") + 1; | |
| if (cut <= seen) return; | |
| const chunk = buf.slice(seen, cut); | |
| seen = cut; | |
| for (const raw of chunk.split("\n")) { | |
| const line = raw.trim(); | |
| if (!line.startsWith("data:")) continue; // `: OPENROUTER PROCESSING` keepalives, blanks | |
| const data = line.slice(5).trim(); | |
| if (data === "[DONE]") continue; | |
| let j; | |
| try { j = JSON.parse(data); } catch (e) { continue; } | |
| if (j.error) { error = j.error.message ?? JSON.stringify(j.error); continue; } | |
| if (j.provider) provider = j.provider; | |
| if (j.usage) usage = j.usage; | |
| const delta = j.choices?.[0]?.delta ?? {}; | |
| if (delta.reasoning) { reasoningChars += delta.reasoning.length; phase = "reasoning…"; } | |
| if (delta.content) { text += delta.content; phase = "streaming"; } | |
| } | |
| render(text); | |
| }; | |
| GM.xmlHttpRequest({ | |
| method: "POST", | |
| url: OPENROUTER_URL, | |
| headers: { | |
| "Content-Type": "application/json", | |
| "Authorization": `Bearer ${apiKey}`, | |
| "HTTP-Referer": "https://yori.cc", | |
| "X-Title": "HN Comment Overview", | |
| }, | |
| data: JSON.stringify({ | |
| model: MODEL, | |
| stream: true, | |
| // reasoning defaults on here; the model reasons by default anyway. | |
| // Default routing is cheapest-first, which lands on OpenInference at | |
| // ~9 tps; the fp8/bf16 filter skips fp4 endpoints, which garbled | |
| // usernames and ignored reasoning settings. | |
| reasoning: { enabled: REASONING }, | |
| provider: { sort: "throughput", quantizations: ["fp8", "bf16"] }, | |
| usage: { include: true }, | |
| messages: [ | |
| { role: "system", content: SYSTEM_PROMPT }, | |
| { role: "user", content: prompt }, | |
| ], | |
| }), | |
| onprogress: (r) => consume(r, false), | |
| onreadystatechange: (r) => { if (r.readyState >= 3) consume(r, false); }, | |
| onload: (r) => { | |
| consume(r, true); | |
| if (r.status !== 200) { | |
| let msg = `HTTP ${r.status}`; | |
| try { msg += `: ${JSON.parse(r.responseText).error?.message ?? ""}`; } catch (e) { /* not json */ } | |
| return reject(new Error(msg)); | |
| } | |
| if (error) return reject(new Error(error)); | |
| resolve({ text, usage, provider }); | |
| }, | |
| onerror: () => reject(new Error("network error (openrouter)")), | |
| ontimeout: () => reject(new Error("timed out (openrouter)")), | |
| }); | |
| }); | |
| const statsLine = ({ usage, provider }) => { | |
| if (!usage) return `\n\n▸ ${elapsed()}s · ${provider}`; | |
| const tin = usage.prompt_tokens ?? 0, tout = usage.completion_tokens ?? 0; | |
| const reasoningToks = usage.completion_tokens_details?.reasoning_tokens ?? 0; | |
| const cost = usage.cost != null ? `$${usage.cost.toFixed(4)}` : `~$${(tin * PRICE_IN + tout * PRICE_OUT).toFixed(4)}`; | |
| return `\n\n▸ ${elapsed()}s · ${tin.toLocaleString()} tok in / ${tout.toLocaleString()} tok out` | |
| + (reasoningToks ? ` (${reasoningToks.toLocaleString()} reasoning)` : "") | |
| + ` · ${cost} · ${provider}`; | |
| }; | |
| const run = async () => { | |
| try { | |
| let apiKey = await GM.getValue(KEY_NAME, ""); | |
| if (!apiKey) apiKey = await askKey(); | |
| if (!apiKey) { finish("no OpenRouter API key set (see userscript menu)"); return; } | |
| phase = "fetching thread…"; | |
| const prompt = await buildPrompt(); | |
| phase = "waiting for model…"; | |
| const result = await stream(apiKey, prompt); | |
| render(result.text + statsLine(result)); | |
| finish(`done in ${elapsed()}s`); | |
| } catch (e) { | |
| finish(`error after ${elapsed()}s — ${e.message}`); | |
| } | |
| }; | |
| run(); | |
| })(); |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment