// ==UserScript== // @name Linux.do 显示最早发布时间 // @namespace https://linux.do/ // @version 0.1.1 // @description 在 Linux.do 主题列表和主题详情页显示主题首帖发布时间 // @author Codex // @match https://linux.do/* // @run-at document-idle // @grant none // ==/UserScript== (function () { "use strict"; const BADGE_CLASS = "linuxdo-earliest-created-at"; const STYLE_ID = "linuxdo-earliest-created-at-style"; const TOPIC_ID_PATTERN = /\/t\/(?:[^/]+\/)?(\d+)(?:\/|$)/; const CACHE_KEY = "linuxdo-earliest-created-at-cache-v1"; const memoryCache = new Map(loadCacheEntries()); const pendingTopicRequests = new Map(); let refreshScheduled = false; injectStyle(); patchHistory(); scheduleRefresh(); window.addEventListener("popstate", scheduleRefresh); const observer = new MutationObserver(scheduleRefresh); observer.observe(document.documentElement, { childList: true, subtree: true }); function scheduleRefresh() { if (refreshScheduled) return; refreshScheduled = true; requestAnimationFrame(() => { refreshScheduled = false; refresh(); }); } async function refresh() { const listTopics = await getTopicTimesFromCurrentList(); annotateTopicList(listTopics); await annotateTopicPage(listTopics); } async function getTopicTimesFromCurrentList() { const listJsonUrl = buildCurrentListJsonUrl(); if (!listJsonUrl) return new Map(); try { const data = await fetchJson(listJsonUrl); const topics = data?.topic_list?.topics; if (!Array.isArray(topics)) return new Map(); const result = new Map(); for (const topic of topics) { if (!topic?.id || !topic?.created_at) continue; const id = String(topic.id); result.set(id, topic.created_at); setCachedCreatedAt(id, topic.created_at); } return result; } catch (error) { console.warn("[linuxdo-earliest-created-at] failed to fetch topic list JSON", error); return new Map(); } } function annotateTopicList(listTopics) { const topicRows = document.querySelectorAll("tr.topic-list-item, .topic-list-item"); for (const row of topicRows) { const link = row.querySelector("a.title.raw-link.raw-topic-link, a.title, a.raw-topic-link"); if (!link) continue; const topicId = getTopicIdFromHref(link.getAttribute("href")); const activityTarget = findActivityTarget(row); if (!topicId || !activityTarget || activityTarget.cell.querySelector(`.${BADGE_CLASS}`)) continue; const createdAt = listTopics.get(topicId) || getCachedCreatedAt(topicId); if (!createdAt) { fetchTopicCreatedAt(topicId) .then((remoteCreatedAt) => { if (!remoteCreatedAt || activityTarget.cell.querySelector(`.${BADGE_CLASS}`)) return; insertBadgeAfter(activityTarget.anchor, remoteCreatedAt); }) .catch((error) => { console.warn("[linuxdo-earliest-created-at] failed to fetch topic JSON", error); }); continue; } insertBadgeAfter(activityTarget.anchor, createdAt); } } async function annotateTopicPage(listTopics) { const topicId = getTopicIdFromHref(location.pathname); if (!topicId) return; const title = document.querySelector("#topic-title h1, h1[data-topic-id], h1"); if (!title || title.querySelector(`.${BADGE_CLASS}`)) return; const createdAt = listTopics.get(topicId) || getCachedCreatedAt(topicId) || await fetchTopicCreatedAt(topicId); if (!createdAt || title.querySelector(`.${BADGE_CLASS}`)) return; const badge = createBadge(createdAt); title.appendChild(badge); } function findActivityTarget(row) { const cell = row.querySelector("td.num.activity, .num.activity"); if (!cell) return null; const relativeDate = cell.querySelector(".relative-date"); const anchor = relativeDate?.closest("a") || cell.querySelector("a") || relativeDate || cell; return { cell, anchor }; } function buildCurrentListJsonUrl() { if (getTopicIdFromHref(location.pathname)) return null; const path = location.pathname.replace(/\/$/, ""); if (path === "") return `${location.origin}/latest.json${location.search}`; if (path.endsWith(".json")) return `${location.origin}${location.pathname}${location.search}`; return `${location.origin}${path}.json${location.search}`; } async function fetchTopicCreatedAt(topicId) { const cached = getCachedCreatedAt(topicId); if (cached) return cached; const normalizedTopicId = String(topicId); if (pendingTopicRequests.has(normalizedTopicId)) { return pendingTopicRequests.get(normalizedTopicId); } const request = fetchJson(`${location.origin}/t/${normalizedTopicId}.json`) .then((data) => { const createdAt = data?.created_at || data?.post_stream?.posts?.[0]?.created_at; if (createdAt) setCachedCreatedAt(normalizedTopicId, createdAt); return createdAt || null; }) .finally(() => { pendingTopicRequests.delete(normalizedTopicId); }); pendingTopicRequests.set(normalizedTopicId, request); return request; } async function fetchJson(url) { const response = await fetch(url, { credentials: "same-origin", headers: { Accept: "application/json" }, }); if (!response.ok) { throw new Error(`HTTP ${response.status} for ${url}`); } return response.json(); } function insertBadgeAfter(anchor, createdAt) { const badge = createBadge(createdAt); anchor.insertAdjacentElement("afterend", badge); } function createBadge(createdAt) { const date = new Date(createdAt); const badge = document.createElement("span"); badge.className = BADGE_CLASS; badge.textContent = `(${formatActivityTime(date)})`; badge.title = date.toLocaleString("zh-CN", { hour12: false }); return badge; } function formatActivityTime(date) { if (Number.isNaN(date.getTime())) return ""; const mm = date.getMonth() + 1; const dd = date.getDate(); const hh = String(date.getHours()).padStart(2, "0"); const min = String(date.getMinutes()).padStart(2, "0"); if (isSameLocalDay(date, new Date())) { return `${hh}:${min}`; } return `${mm}-${dd} ${hh}:${min}`; } function isSameLocalDay(left, right) { return left.getFullYear() === right.getFullYear() && left.getMonth() === right.getMonth() && left.getDate() === right.getDate(); } function getTopicIdFromHref(href) { const match = href?.match(TOPIC_ID_PATTERN); return match?.[1] || null; } function getCachedCreatedAt(topicId) { const entry = memoryCache.get(String(topicId)); return entry?.createdAt || null; } function setCachedCreatedAt(topicId, createdAt) { memoryCache.set(String(topicId), { createdAt }); saveCacheEntries(); } function loadCacheEntries() { try { const raw = sessionStorage.getItem(CACHE_KEY); if (!raw) return []; const parsed = JSON.parse(raw); if (!parsed || typeof parsed !== "object") return []; return Object.entries(parsed); } catch (error) { console.warn("[linuxdo-earliest-created-at] failed to load cache", error); return []; } } function saveCacheEntries() { const value = Object.fromEntries(memoryCache.entries()); sessionStorage.setItem(CACHE_KEY, JSON.stringify(value)); } function patchHistory() { for (const methodName of ["pushState", "replaceState"]) { const original = history[methodName]; history[methodName] = function patchedHistoryMethod(...args) { const result = original.apply(this, args); scheduleRefresh(); return result; }; } } function injectStyle() { if (document.getElementById(STYLE_ID)) return; const style = document.createElement("style"); style.id = STYLE_ID; style.textContent = ` .${BADGE_CLASS} { display: inline; width: fit-content; margin-left: 4px; padding: 0; border: 0; border-radius: 0; color: var(--primary-medium, #666); background: transparent; font-size: 12px; line-height: inherit; font-weight: 400; white-space: nowrap; vertical-align: middle; } #topic-title h1 .${BADGE_CLASS}, h1 .${BADGE_CLASS} { margin-left: 10px; transform: translateY(-2px); } `; document.head.appendChild(style); } })();