Skip to content

Instantly share code, notes, and snippets.

@yorickvP
Created September 15, 2026 05:35
Show Gist options
  • Select an option

  • Save yorickvP/8811cbd9607e287d8fe8aea6c906dc72 to your computer and use it in GitHub Desktop.

Select an option

Save yorickvP/8811cbd9607e287d8fe8aea6c906dc72 to your computer and use it in GitHub Desktop.
// ==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, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
// 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