您需要先安装一个扩展,例如 篡改猴、Greasemonkey 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 Userscripts ,之后才能安装此脚本。
您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey,才能安装此脚本。
您需要先安装用户脚本管理器扩展后才能安装此脚本。
Save hashes of displayed comments locally and mark new ones when displayed for the first time
当前为
// ==UserScript== // @name HN: style unread content // @description Save hashes of displayed comments locally and mark new ones when displayed for the first time // @namespace myfonj // @match https://news.ycombinator.com/* // @grant none // @version 1.1.2 // @author myfonj // @run-at document end // ==/UserScript== // https://greatest.deepsurf.us/en/scripts/423969/versions/new /* * Changelog * 1.1.2 (2024-10-03) Age title got the "Z" timezone, no need to imply it anymore. * 1.1.1 (2023-10-22) visualise age (=offset) original post date (= dotted line) and points × comments */ // kinda sorta configuration const WATCHED_ELEMENTS_SELECTOR = '.commtext, .storylink, .titlelink, .titleline > a'; const VIEWPORT_EXPOSITION_DURATION_UNTIL_READ = 900; const CSS_CLASSES = { unread: 'new', read: 'read', old: 'old' }; // Styling. Very lame for now. const CSS_STR = ` .${CSS_CLASSES.unread} { border-right: 2px solid #3F36; display: block; padding-right: 1em; } .${CSS_CLASSES.read} { border-right: 2px solid #0F03; display: block; padding-right: 1em; } .${CSS_CLASSES.old} { opacity: 0.9; } /* visualise age (=offset) original post date (= dotted line) and points × comments */ .subline { position: relative; } .subline::before , .subline::after { position: absolute; right: 100%; bottom: 0; content: ''; background-color: lime; width: calc(var(--points) * 1px); height: calc(var(--comments) * 1px); z-index: 1000000; /* 0min = 1 */ /* 1min = .9 */ /* 10min = .8 */ --_minutes_old: calc(var(--age) / 1000 / 60); --_minutes_old2: calc(var(--age2) / 1000 / 60); --_o: calc( var(--_minutes_old) / 10 ); opacity: .5; border: 1px solid red; transform: scale(.2) translatex(calc(var(--_minutes_old2) * -1px)); transform-origin: bottom right; } .subline::after { height: 0px; width: calc(( var(--_minutes_old) - var(--_minutes_old2) )* 1px); border: none; border-bottom: 10px dotted lime; background-color: transparent; } `; // base64 'SHA-1' digest hash = 28 characters; // most probably ending with '=' always(?) const HASH_DIGEST_ALGO = 'SHA-1'; // local storage key const LS_KEY = 'displayed_hashes_' + HASH_DIGEST_ALGO; // actual code, yo document.head.appendChild(document.createElement('style')) .textContent = CSS_STR; // TODO mutation observer for client-side rendered pages // not case for HN, but it will make this truly universal const ELS_TO_WATCH = document.querySelectorAll(WATCHED_ELEMENTS_SELECTOR); /** * watched DOM node -> it's text content digest */ const MAP_EL_HASH = new WeakMap(); /** * watched DOM node -> time counter ID */ const MAP_EL_TIMEOUT_ID = new WeakMap(); /** * get all "read" digests */ const GET_SEEN_HASHES_SET = () => new Set((localStorage.getItem(LS_KEY) || '').split(',')); /** * intersection observer callback */ const VIEWPORT_ENTRY_CHECKER = (entry) => { const TGT = entry.target; if (entry.isIntersecting) { // entered viewport if (MAP_EL_TIMEOUT_ID.get(TGT)) { // already measuring - quick re-entry return } // measure time in viewport MAP_EL_TIMEOUT_ID.set( TGT, window.setTimeout( processVisibleEntry, VIEWPORT_EXPOSITION_DURATION_UNTIL_READ ) ); } else { // left viewport MAP_EL_TIMEOUT_ID.delete(TGT); } function processVisibleEntry() { if (MAP_EL_TIMEOUT_ID.get(TGT)) { // HA! STILL in viewport! // mark as read TGT.classList.remove(CSS_CLASSES.unread); TGT.classList.add(CSS_CLASSES.read); const NEW_SET = GET_SEEN_HASHES_SET(); NEW_SET.add(MAP_EL_HASH.get(TGT)) MAP_EL_TIMEOUT_ID.delete(TGT); // not interested in this element anymore VIEWPORT_OBSERVER.unobserve(TGT); // TODO move the persistence to window unload and/or blur event for fewer LS writes localStorage.setItem( LS_KEY, Array.from(NEW_SET).join(',') ); } } } /** * just a single observer for all watched elements */ const VIEWPORT_OBSERVER = new IntersectionObserver( (entries, observer) => { entries.forEach(_ => VIEWPORT_ENTRY_CHECKER(_)); }, { root: null, rootMargin: "-9%", // TODO use computed "lines" height here instead? threshold: 0 } ); const SEEN_ON_LOAD = GET_SEEN_HASHES_SET(); // compute hash, look into list and mark and observe "new" items ELS_TO_WATCH.forEach(async el => { const hash = await makeHash(el.textContent); if (SEEN_ON_LOAD.has(hash)) { el.classList.add(CSS_CLASSES.old); return; } el.classList.add(CSS_CLASSES.unread); MAP_EL_HASH.set(el, hash); VIEWPORT_OBSERVER.observe(el); }); /** * string to base64 hash digest using native Crypto API * @param {string} input */ async function makeHash (input) { return btoa( String.fromCharCode.apply( null, new Uint8Array( await crypto.subtle.digest( HASH_DIGEST_ALGO, (new TextEncoder()).encode(input) ) ) ) ); }; // unrelated add some links along "threads" const threadsLink = document.querySelector('a[href^="threads"]'); const addLink = (key) => { const l = threadsLink.cloneNode(true); l.setAttribute('href', l.getAttribute('href').replace('threads', key)); l.textContent = key; threadsLink.parentNode.insertBefore(l,threadsLink); } ['upvoted','favorites'].forEach(addLink); /* // also not closely related: create velocity rectangles // this time tailored to HN structure tr[id="<numbers>"] tr td[colspan="2"] td[class="subtext"] span[class="subline"] span[class="score"][id="score_<numbers>"] "<##> points" a[class="hnuser"] span[class="age"][title="<isodate>"] ... a[href="item?id=<numbers>"] "<##> comments" */ const now = Date.now(); Array.from(document.querySelectorAll('.subline')) .forEach(subline=>{ const tr = subline.closest('tr'); const age_el = subline.querySelector('.age'); const age_title = age_el.getAttribute('title'); const age_text = age_el.textContent; const age_ms = now - (new Date(age_title)).getTime(); const age2_ms = textAgeToMS(age_text); subline.querySelector('.age').textContent += ' (' + (age_ms / 1000 / 60 / 60).toFixed(2) + 'h)'; const points_count = (subline.querySelector('.score')?.textContent?.match(/\d+/)||['1'])[0] * 1; const comments_count = (subline.querySelector('& > a:last-child')?.textContent?.match(/\d+/)||['1'])[0] * 1; subline.style.setProperty('--age', age_ms); subline.style.setProperty('--age2', age2_ms); subline.style.setProperty('--points', points_count); subline.style.setProperty('--comments', comments_count); }) function textAgeToMS(text) { const rx = /^([0-9]+)\s+(\S+)/; const match = text.match(rx); const secondsDurationsNames = { second: 1, minute: 1 * 60, hour: 1 * 60 * 60, day: 1 * 60 * 60 * 24, month: 1 * 60 * 60 * 24 * 30, year: 1 * 60 * 60 * 24 * 30 * 365, }; const amount = Number(match[1]); const unit = match[2].replace(/s$/,''); const secondsPerUnit = secondsDurationsNames[unit]; return 1000 * amount * secondsPerUnit; } function msToHours(ms) { return ms / 1000 / 60 / 60; }