Skip to content

Instantly share code, notes, and snippets.

@overflowy
Last active April 19, 2026 00:35
Show Gist options
  • Select an option

  • Save overflowy/bf5d9aedffcd46242a253a3ddf1271b4 to your computer and use it in GitHub Desktop.

Select an option

Save overflowy/bf5d9aedffcd46242a253a3ddf1271b4 to your computer and use it in GitHub Desktop.
Hacker News Plus
// ==UserScript==
// @name HN +
// @match https://*.ycombinator.com/*
// @grant none
// @version 2.5.0
// @author overflowy@riseup.net
// @description Adds favicons to HN links, navigation menu for less known sections, and injects the about section into HN's native user hovercard
// @inject-into content
// ==/UserScript==
// Add favicons functionality
var favicons = document.getElementsByClassName("favicon");
if (!(favicons.length > 0)) {
const articleLinks = document.querySelectorAll(".titleline > a");
for (let link of articleLinks) {
const domain = new URL(link.href).hostname;
const imageUrl = `https://icons.duckduckgo.com/ip3/${domain}.ico`;
const imgEl = document.createElement("img");
imgEl.src = imageUrl;
imgEl.className = "favicon";
imgEl.width = 14;
imgEl.height = 14;
imgEl.style.paddingRight = "0.25em";
imgEl.style.paddingLeft = "0.25em";
link.style.alignItems = "center";
link.style.display = "inline-flex";
link.style.justifyContent = "center";
link.prepend(imgEl);
}
}
// Add navigation menu
function createNavigationMenu() {
// Look for the submit link in the main navigation
const submitLink = document.querySelector('.pagetop a[href="submit"]');
if (!submitLink) return;
// Create the menu button styled like other HN links
const menuButton = document.createElement("a");
menuButton.href = "#";
menuButton.textContent = "extra";
menuButton.style.cssText = "color: #000000; text-decoration: none;";
// Create the dropdown menu
const dropdown = document.createElement("div");
dropdown.className = "hn-dropdown";
dropdown.style.cssText = `
position: absolute;
top: 100%;
left: 0;
background: #f6f6ef;
border: 1px solid #ff6600;
z-index: 1000;
display: none;
min-width: 120px;
margin-top: 2px;
font-size: 10pt;
`;
const menuItems = [
{ url: "https://news.ycombinator.com/shownew", label: "shownew" },
{ url: "https://news.ycombinator.com/pool", label: "pool" },
{ url: "https://news.ycombinator.com/best", label: "best" },
{ url: "https://news.ycombinator.com/asknew", label: "asknew" },
{ url: "https://news.ycombinator.com/bestcomments", label: "bestcomments" },
{ url: "https://news.ycombinator.com/active", label: "active" },
{ url: "https://news.ycombinator.com/noobcomments", label: "newcomments" },
{ url: "https://news.ycombinator.com/noobstories", label: "newstories" },
{ url: "https://news.ycombinator.com/newest", label: "newest" },
];
menuItems.forEach((item, index) => {
const menuItem = document.createElement("a");
menuItem.href = item.url;
menuItem.textContent = item.label;
menuItem.style.cssText = `
display: block;
padding: 4px 8px;
color: #000000;
text-decoration: none;
font-size: 10pt;
${index < menuItems.length - 1 ? "border-bottom: 1px solid #ff6600;" : ""}
`;
menuItem.onmouseover = function () {
this.style.backgroundColor = "#ffffff";
};
menuItem.onmouseout = function () {
this.style.backgroundColor = "transparent";
};
dropdown.appendChild(menuItem);
});
// Create a wrapper for positioning
const wrapper = document.createElement("span");
wrapper.style.position = "relative";
wrapper.style.display = "inline";
wrapper.appendChild(menuButton);
wrapper.appendChild(dropdown);
// Find the separator after submit and insert our menu
let nextNode = submitLink.nextSibling;
while (
nextNode &&
nextNode.nodeType === 3 &&
nextNode.textContent.trim() === ""
) {
nextNode = nextNode.nextSibling;
}
// Insert separator and the menu button after submit
const separator = document.createTextNode(" | ");
submitLink.parentNode.insertBefore(separator, nextNode);
submitLink.parentNode.insertBefore(wrapper, nextNode);
// Toggle menu visibility
menuButton.addEventListener("click", function (e) {
e.preventDefault();
dropdown.style.display =
dropdown.style.display === "none" ? "block" : "none";
});
// Close menu when clicking outside
document.addEventListener("click", function (e) {
if (!wrapper.contains(e.target)) {
dropdown.style.display = "none";
}
});
}
createNavigationMenu();
// Augment HN's native hovercard with the "about" section
(function augmentHovercard() {
// Style the native hovercard to have the HN-orange border like our old custom card.
const style = document.createElement("style");
style.textContent = `
.hovercard {
border: 1px solid #ff6600 !important;
box-shadow: 2px 2px 6px rgba(0, 0, 0, 0.15);
padding: 8px 10px !important;
}
.hovercard .hn-plus-about {
padding-top: 6px;
border-top: 1px solid #e0e0d8;
max-height: 240px;
overflow: auto;
word-wrap: break-word;
overflow-wrap: break-word;
font-size: 9pt;
line-height: 1.4;
}
.hovercard .hn-plus-about a { word-break: break-all; }
.hovercard .hn-plus-loading { color: #828282; font-size: 9pt; margin-top: 4px; }
`;
document.documentElement.appendChild(style);
// Cache about-sections across hovercards
const aboutCache = new Map();
const inFlight = new Map();
function fetchAbout(username) {
if (aboutCache.has(username)) {
return Promise.resolve(aboutCache.get(username));
}
if (inFlight.has(username)) {
return inFlight.get(username);
}
const url = `https://news.ycombinator.com/user?id=${encodeURIComponent(username)}`;
const p = fetch(url, { credentials: "same-origin" })
.then((r) => (r.ok ? r.text() : null))
.then((html) => {
if (html === null) {
aboutCache.set(username, null);
inFlight.delete(username);
return null;
}
const about = extractAbout(html);
aboutCache.set(username, about);
inFlight.delete(username);
return about;
})
.catch(() => {
inFlight.delete(username);
aboutCache.set(username, null);
return null;
});
inFlight.set(username, p);
return p;
}
function extractAbout(html) {
const doc = new DOMParser().parseFromString(html, "text/html");
const rows = doc.querySelectorAll("#hnmain table tr");
for (const row of rows) {
const cells = row.querySelectorAll("td");
if (cells.length < 2) continue;
if (cells[0].textContent.trim().toLowerCase() === "about:") {
const cell = cells[1];
// Rewrite relative links to absolute and open in new tab
cell.querySelectorAll("a[href]").forEach((a) => {
const href = a.getAttribute("href");
if (href && !/^https?:\/\//i.test(href) && !href.startsWith("#")) {
a.setAttribute(
"href",
"https://news.ycombinator.com/" + href.replace(/^\//, ""),
);
}
a.setAttribute("target", "_blank");
a.setAttribute("rel", "noopener noreferrer");
});
const html = cell.innerHTML.trim();
return html.length > 0 ? html : null;
}
}
return null;
}
function getUsernameFromHovercard(hovercard) {
const link = hovercard.querySelector("a.hnuser");
if (!link) return null;
// The username is the text content of the <a class="hnuser">
return link.textContent.trim();
}
function injectAbout(hovercard) {
// Skip if already injected
if (hovercard.querySelector(".hn-plus-about, .hn-plus-loading")) return;
const username = getUsernameFromHovercard(hovercard);
if (!username) return;
// Find the <table> inside the hovercard and append a new row beneath the karma row
// so the about text appears between karma and the comments/mute/notes rows.
// Simpler: just append a <div> after the table.
const table = hovercard.querySelector("table");
if (!table) return;
// Insert loading placeholder right after the table
const loadingEl = document.createElement("div");
loadingEl.className = "hn-plus-loading";
loadingEl.textContent = "Loading about…";
table.insertAdjacentElement("afterend", loadingEl);
fetchAbout(username).then((about) => {
// Make sure this hovercard is still in the DOM
if (!hovercard.isConnected) return;
loadingEl.remove();
if (!about) return;
const aboutEl = document.createElement("div");
aboutEl.className = "hn-plus-about";
aboutEl.innerHTML = about;
table.insertAdjacentElement("afterend", aboutEl);
});
}
// Watch the document for hovercards appearing (HN toggles popover="auto" opacity/display).
// We observe attribute changes on any .hovercard element since HN reuses them.
const observer = new MutationObserver((mutations) => {
for (const m of mutations) {
// New nodes added (hovercard can be inserted on demand)
m.addedNodes.forEach((node) => {
if (node.nodeType !== 1) return;
if (node.matches && node.matches(".hovercard")) {
injectAbout(node);
} else if (node.querySelectorAll) {
node.querySelectorAll(".hovercard").forEach(injectAbout);
}
});
// Attribute changes on existing hovercards (e.g. opacity going from 0 to 1)
if (
m.type === "attributes" &&
m.target.classList &&
m.target.classList.contains("hovercard")
) {
injectAbout(m.target);
}
}
});
observer.observe(document.body, {
childList: true,
subtree: true,
attributes: true,
attributeFilter: ["style", "class"],
});
// Catch any hovercards already in the DOM at script start
document.querySelectorAll(".hovercard").forEach(injectAbout);
})();
@polyrabbit
Copy link
Copy Markdown

Alternative - https://hackernews.betacat.io/

with:

  • favicon
  • summary
  • illustration

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment