HackerNews: Sort by Comments/Points/Time

Adds button to sort by comments, points, or time for HackerNews

您需要先安裝使用者腳本管理器擴展,如 TampermonkeyGreasemonkeyViolentmonkey 之後才能安裝該腳本。

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

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyViolentmonkey 後才能安裝該腳本。

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyUserscripts 後才能安裝該腳本。

你需要先安裝一款使用者腳本管理器擴展,比如 Tampermonkey,才能安裝此腳本

您需要先安裝使用者腳本管理器擴充功能後才能安裝該腳本。

(我已經安裝了使用者腳本管理器,讓我安裝!)

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

(我已經安裝了使用者樣式管理器,讓我安裝!)

// ==UserScript==
// @name         HackerNews: Sort by Comments/Points/Time
// @namespace    hn-client-sort
// @version      1.4.0
// @description  Adds button to sort by comments, points, or time for HackerNews
// @match        https://news.ycombinator.com/
// @match        https://news.ycombinator.com/news
// @grant        none
// @run-at       document-idle
// @license      MIT
// ==/UserScript==

(function () {
  "use strict";

  const qs = (sel, root = document) => root.querySelector(sel);
  const qsa = (sel, root = document) => Array.from(root.querySelectorAll(sel));
  
  // Idempotency: prevent duplicate bars if script were to rerun
  document.querySelectorAll("#hn-sort-bar").forEach((el) => el.remove());

  // Find first listing row and infer its table (note: HN markup may vary)
  const firstAthing = qs("tr.athing");
  if (!firstAthing) return;

  // Listing pages have a subtext row right after athing containing td.subtext
  const maybeMeta = firstAthing.nextElementSibling;
  if (!maybeMeta || !qs("td.subtext", maybeMeta)) return;

  const itemTable = firstAthing.closest("table");
  if (!itemTable) return;

  const tbody = itemTable.tBodies?.[0] || itemTable;

  // Keep the "More" row at bottom.
  const moreRow = qs("a.morelink", itemTable)?.closest("tr") || null;

  const intFromText = (txt) => {
    const m = String(txt || "").match(/-?\d+/);
    return m ? parseInt(m[0], 10) : 0;
  };

  function parsePoints(subtextTd) {
    const score = qs("span.score", subtextTd); // "53 points"
    return score ? intFromText(score.textContent) : 0;
  }

  function parseComments(subtextTd) {
    // Usually the last item?id=... link is the comments/discuss link:
    // e.g. ... | <a href="item?id=47127986">6 comments</a>
    const links = qsa('a[href^="item?id="]', subtextTd);
    const last = links.length ? links[links.length - 1] : null;
    if (!last) return 0;

    const t = last.textContent.replace(/\u00a0/g, " ").trim().toLowerCase();
    if (t.includes("discuss")) return 0;
    return intFromText(t);
  }

  function parseUnixTime(subtextTd) {
    // e.g. <span class="age" title="2026-02-23T20:05:15 1771877115">
    const age = qs("span.age", subtextTd);
    const title = age?.getAttribute("title") || "";
    const parts = title.trim().split(/\s+/);

    if (parts.length >= 2) {
      const unix = parseInt(parts[1], 10);
      if (Number.isFinite(unix)) return unix;
    }

    // Fallback to ISO
    if (parts.length >= 1 && parts[0]) {
      const iso = parts[0];
      const hasTZ = /([zZ]|[+-]\d\d:\d\d)$/.test(iso);
      const ms = Date.parse(hasTZ ? iso : iso + "Z");
      if (!Number.isNaN(ms)) return Math.floor(ms / 1000);
    }

    return 0;
  }

  function collectItems() {
    const athingRows = qsa("tr.athing", itemTable);

    return athingRows.map((athing, idx) => {
      const metaRow = athing.nextElementSibling;      // row containing td.subtext
      const spacerRow = metaRow?.nextElementSibling;  // tr.spacer (optional)
      const subtextTd = metaRow ? qs("td.subtext", metaRow) : null;

      return {
        idx,
        athing,
        metaRow,
        spacerRow: spacerRow && spacerRow.classList.contains("spacer") ? spacerRow : null,
        points: subtextTd ? parsePoints(subtextTd) : 0,
        comments: subtextTd ? parseComments(subtextTd) : 0,
        time: subtextTd ? parseUnixTime(subtextTd) : 0,
      };
    });
  }

  function removeGroup(g) {
    [g.athing, g.metaRow, g.spacerRow].forEach((row) => {
      if (row && row.parentNode === tbody) tbody.removeChild(row);
    });
  }

  function insertGroup(g) {
    const rows = [g.athing, g.metaRow, g.spacerRow].filter(Boolean);
    for (const row of rows) {
      if (moreRow && moreRow.parentNode === tbody) tbody.insertBefore(row, moreRow);
      else tbody.appendChild(row);
    }
  }

  function renumberRanks(groups) {
    groups.forEach((g, i) => {
      const rank = qs("span.rank", g.athing);
      if (rank) rank.textContent = `${i + 1}.`;
    });
  }

  // --- Sort state / toggling ---
  const DIR = { DESC: -1, ASC: 1 };
  const dirByKey = { comments: DIR.DESC, points: DIR.DESC, time: DIR.DESC }; // default arrows ↓
  let activeKey = null;

  function sortBy(key) {
    // Toggle if same key; otherwise reset to DESC on first click for that key.
    if (activeKey === key) {
      dirByKey[key] = (dirByKey[key] === DIR.DESC) ? DIR.ASC : DIR.DESC;
    } else {
      activeKey = key;
      dirByKey[key] = DIR.DESC;
    }

    updateButtonLabels();

    const groups = collectItems();
    const dir = dirByKey[key];

    // Numeric sort; stable tie-break on original order.
    groups.sort((a, b) => {
      const diff = (a[key] - b[key]) * dir; // ASC: a-b, DESC: b-a (because dir=-1)
      return diff !== 0 ? diff : (a.idx - b.idx);
    });

    groups.forEach(removeGroup);
    groups.forEach(insertGroup);
    renumberRanks(groups);
  }

  // --- UI ---
  const buttons = {}; // key -> button element
  const baseLabel = { comments: "Comments", points: "Points", time: "Time" };

  function arrowFor(key) {
    return dirByKey[key] === DIR.DESC ? "↓" : "↑";
  }

  function updateButtonLabels() {
    for (const key of Object.keys(buttons)) {
      const isActive = (key === activeKey);
      // Always show direction arrow; highlight active one subtly.
      buttons[key].textContent = `${baseLabel[key]} ${arrowFor(key)}`;
      buttons[key].style.fontWeight = isActive ? "bold" : "normal";
      buttons[key].style.textDecoration = isActive ? "underline" : "none";
    }
  }

  function makeBtn(key) {
    const b = document.createElement("button");
    b.type = "button";
    b.addEventListener("click", () => sortBy(key));

    // HN-ish styling
    b.style.cssText = [
      "font: inherit",
      "font-size: 10px",
      "line-height: 10px",
      "padding: 1px 6px",
      "margin: 0 4px",
      "border: 1px solid #cfcfcf",
      "background: #f6f6ef",
      "color: #000",
      "border-radius: 2px",
      "cursor: pointer",
    ].join(";");

    return b;
  }

  const bar = document.createElement("div");
  bar.id = "hn-sort-bar";
  bar.style.cssText = [
    "margin: 6px 0 8px 0",
    "text-align: right",
    "font-size: 10px",
    "line-height: 10px",
    "color: #828282", // matches HN subtext-ish tone
  ].join(";");

  const label = document.createElement("span");
  label.textContent = "Sort: ";
  label.style.cssText = "margin-right: 4px;";
  bar.appendChild(label);

  buttons.comments = makeBtn("comments");
  buttons.points = makeBtn("points");
  buttons.time = makeBtn("time");

  bar.appendChild(buttons.comments);
  bar.appendChild(buttons.points);
  bar.appendChild(buttons.time);

  updateButtonLabels();

  // Insert centered bar above the listing table
  itemTable.parentNode.insertBefore(bar, itemTable);
})();