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.0.6
// @author myfonj
// ==/UserScript==
// https://gf.qytechs.cn/en/scripts/423969/versions/new
// kinda sorta configuration
const WATCHED_ELEMENTS_SELECTOR = '.commtext, .storylink, .titlelink';
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;
}
`;
// 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)
)
)
)
);
};
// 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);
QingJ © 2025
镜像随时可能失效,请加Q群300939539或关注我们的公众号极客氢云获取最新地址