Last active
April 19, 2026 00:35
-
-
Save overflowy/bf5d9aedffcd46242a253a3ddf1271b4 to your computer and use it in GitHub Desktop.
Hacker News Plus
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 + | |
| // @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); | |
| })(); |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
here's an alternative that uses the google API to fetch the favicons. Don't know why but on my devices, all the favicons coming from the duckduckgo API render out to a grey circled arrow.
Thank you for this cool user-script though !