// ==UserScript==
// @name QQ/SB/SV Threadmarks Colorizer/Jump to next in series
// @description Color‑code threadmarks, per-threadmark jumplist of all the chapters with a similar name.
// @namespace https://greasyfork.org/users/1376767
// @author C89sd
// @version 1.2
// @match https://*.spacebattles.com/threads/*
// @match https://*.questionablequesting.com/threads/*
// @match https://*.sufficientvelocity.com/threads/*
// @noframes
// ==/UserScript==
const COLORIZE_ALL = true;
(() => {
// ───────────────────────── 0. constants ─────────────────────────
const TM_SELECTORS = [
'.threadmark_depth0 a',
'.threadmark_depth1 a',
'.blockLink.recent-threadmark .threadmark-text'
].join(',');
const UPDATE_SELECTOR = '.threadmark-control--index, .threadmarks-pagenav--wrapper, [data-xf-click="threadmark-fetcher"], [data-xf-click="overlay"], menu-content:has(.recent-threadmark) > a:first-of-type, block-tabHeader--threadmarkCategoryTabs';
const STOPWORDS = new Set(['a','an','and','the','my','no','end','start','finale','of','at','in','on']);
// ───────────────────────── 1. helper fns ─────────────────────────
const words = ['side','chapter','interlude','snippet','part','volume','pt','vol','chap','ch','act','cont'];
const joined = '(?:' + words.join('|') + ')';
const bothRegex = new RegExp('^\\s*(?:'+joined+'\\s+(\\d+)|(\\d+)\\s+'+joined+')\\s*','g');
const token = s => {
let a = s.normalize('NFD').replace(/[\u0300-\u036f]/g, '').toLowerCase();
let b = a.replace(/(\d+)(?:\s*[.:,#%*_\-+&()\[\]]\s*\d+)*/g, '$1');
b = b.replace(/^(\d+)(?:\s*[.:,#%*_\-+&()\[\]]\s*)*/, '');
b = b.replace(/(\s\d+)$/g, '');
let c = b.replace(/^(\w.*?)\([^)]*\)$/, '$1');
let d = c.replace(/[^a-z0-9]+/g, ' ')
let e = d.replace(bothRegex, '');
e = e.replace(/\s+/g, ' ').trim();
let toks = e.split(' ').filter(w => w && !STOPWORDS.has(w));
// console.log(`|${s}|${a}|${b}|${c}|${d}|${e}|${toks.slice(0,2)}|`);
return toks;
};
const keyOf = t => token(t).slice(0,2).join(' ');
const rmap = new Map();
function rand(key) {
if (rmap.has(key)) return rmap.get(key);
let h = 2166136261;
for (let i = 0; i < key.length; i++) {
h ^= key.charCodeAt(i);
h = Math.imul(h, 16777619);
}
h >>>= 0;
h ^= h >>> 16;
h = Math.imul(h, 0x7feb352d);
h ^= h >>> 15;
h = Math.imul(h, 0x846ca68b);
h ^= h >>> 16;
h >>>= 0;
const r = h / 0x100000000;
rmap.set(key, r);
return r;
}
const Lmin = 0.40, Lmax = 0.85;
const Cmin = 0.08, Cmax = 0.225;
function color(s) {
s = s+s
const hue = rand(s)*360 % 360;
const l01 = rand(s+'lum');
const L = Lmin + l01 * (Lmax - Lmin);
const c01 = rand(s+'chr');
const C = Cmin + c01 * (Cmax - Cmin);
return `oklch(${(L*100).toFixed(3)}% ${C.toFixed(3)} ${hue.toFixed(3)})`;
}
// ───────────────────────── 2. cache helpers ─────────────────────
const VERSION = 1;
const CACHE_KEY='_TMCache';
const loadCache = () => {
const c = JSON.parse(localStorage.getItem(CACHE_KEY)) || {};
return c.version === VERSION ? c : { version: VERSION }; // reset on version change
};
const saveCache = (obj) => localStorage.setItem(CACHE_KEY, JSON.stringify(obj));
// ───────────────────────── 3. highlight boxes ───────────────────
function applyHighlights(){
document.querySelectorAll(TM_SELECTORS).forEach(a=>{
const k=keyOf(a.textContent);
Object.assign(a.style,{backgroundColor:color(k),color:'#000',borderRadius:'3px'});
});
}
// ───────────────────────── 4. link icons ────────────────────────
function injectLinkIcons(){
const MSG_HEADER_BTNS = '.message-cell--threadmark-header *:has(>.threadmark-control--index, >.threadmark-control--viewContent)'; // next to Threadmarks or View Content buttons.
document.querySelectorAll(MSG_HEADER_BTNS).forEach(nav=>{
const art=nav.closest('article');
const pid=art?.id?.replace(/^js-?post-/,'');
if(!pid) throw new Error('Threadmark‑HL: missing postId');
const link=document.createElement('span');
link.className='threadmark-control';
link.textContent='🔗';
link.title='Show earlier parts in this series';
link.style.cursor='pointer';
link.addEventListener('click',e=>popupForPost(e,pid,link));
nav.prepend(link);
});
}
// ───────────────────────── 5. threadmark crawler ────────────────
const ficId = () => location.pathname.match(/\/threads\/[^\/]+\.(\d+)/)?.[1]||null;
const base = () => location.pathname.replace(/(\/threads\/[^\/]+\.\d+)(?:\/.*)?$/,'$1')+'/threadmarks?per_page=200';
function getThreadmarkCount() {
const link = document.querySelector('.menu-content:has(.recent-threadmark) .blockLink'); // “View all ### threadmarks”
const m = /(\d+)/i.exec(link.textContent);
return m ? m[1] : null;
}
let cache;
async function fetchThreadmarksOrCached(anchor){
const id=ficId(); if(!id) throw new Error('No fic ID');
cache = loadCache();
let count = getThreadmarkCount();
if (!count) throw new Error('!getThreadmarkCount()');
let entry = cache[id]
let entry_count = entry?.count ?? 0;
if(count === entry_count) return entry?.arr ?? [];
// console.debug('[TM‑HL] fetching threadmark list');
let url=base();
const all=[]; // <=== store count as first elem, filtered over later
let page=1;
anchor.textContent='⏳0';
while(url){
const html=await (await fetch(url)).text();
const doc=new DOMParser().parseFromString(html,'text/html');
const chunk=[...doc.querySelectorAll('.threadmark_depth0 a, .threadmark_depth1 a')].map(a=>{
const title=a?.textContent.trim()||'';
const pv = a.getAttribute('data-preview-url')||'';
const m = pv.match(/posts\/(\d+)/);
return {title,postId:m?m[1]:''};
}).filter(it=>it.title && it.postId);
all.push(...chunk);
anchor.textContent='⏳'+all.length;
// console.debug(`[TM‑HL] page ${page++}:`,chunk.length,'marks');
url=doc.querySelector('.pageNav-jump--next')?.href||'';
}
cache[id]={ count: count, arr: all };
saveCache(cache);
anchor.textContent='🔗';
return all;
}
// ───────────────────────── 6. popup logic ───────────────────────
let firstCss = true;
let disabled = false;
async function popupForPost(e,pid,anchor){
if (disabled) return;
disabled = true;
e.preventDefault();
const popupId='tm-popup-'+pid;
const existing=document.getElementById(popupId);
if(existing){existing.remove();return;}
let list=await fetchThreadmarksOrCached(anchor);
let cur = list.find(it=>it.postId===pid);
// console.log(list, cur)
if(!cur){
if (cache) {
delete cache[ficId()];
saveCache(cache);
}
list=await fetchThreadmarksOrCached(anchor);
cur=list.find(it=>it.postId===pid);
if(!cur) {
anchor.textContent='Error fetching threadmarks.';
return;
}
}
if (firstCss) {
firstCss = false;
const style=document.createElement('style');
style.textContent=`
.tm-popup{position:absolute;z-index:9999;background:#111;color:#eee;border-radius:4px;font-size:90%;padding:6px 8px;max-height:60vh;overflow:auto;box-shadow:0 2px 6px rgba(0,0,0,.45);}
.tm-popup a{display:block;color:#74b9ff;text-decoration:none;margin:2px 0;}
.tm-popup a:hover{text-decoration:underline;}
`;
document.head.append(style);
}
const k=keyOf(cur.title);
const prev=list.filter(it=>keyOf(it.title)===k) // && Number(it.postId)>=Number(pid))
.sort((a,b)=>a.postId-b.postId);
let href = anchor.closest('.message-cell--threadmark-header').querySelector('.threadmark-control--index, .threadmark-control--viewContent').href; // sibling Threadmarks button
let hrefBase = href.slice(0, href.lastIndexOf('/')); // slice /threadmarks
const pop = document.createElement('div');
pop.id = popupId;
pop.className = 'tm-popup';
pop.style.border = `2px solid ${color(k)}`;
{
let html = '';
let arrowDone = false;
for (const p of prev) {
let arrow = '';
let extra = '';
if (p.postId == pid) {
// arrow = '→ ';
extra = 'font-weight:bold;"';
}
if (!arrowDone && p.postId > pid) {
// arrow = '(next) ';
}
if (arrow) arrowDone = true;
const gray = p.postId <= pid ? ` style="color:gray;${extra}"` : '';
html += `<a href="${hrefBase}/post-${p.postId}"${gray}>${arrow}${p.title}</a>`;
}
pop.innerHTML = html || '<em>No matching series found.</em>';
}
document.body.append(pop);
const r=anchor.getBoundingClientRect();
pop.style.left=r.left+window.scrollX+'px';
pop.style.top =r.bottom+window.scrollY+'px';
const close=ev=>{if(!pop.contains(ev.target)){pop.remove();document.removeEventListener('click',close);disabled = false;}};
setTimeout(()=>document.addEventListener('click',close),0);
disabled = false;
}
// ───────────────────────── 7. css + init ────────────────────────
injectLinkIcons();
if (COLORIZE_ALL) {
applyHighlights();
const deb=fn=>{let t;return()=>{clearTimeout(t);t=setTimeout(fn,100);};};
const recolour=deb(applyHighlights);
document.addEventListener('click', e => {
if (e.target.closest(UPDATE_SELECTOR)) {
const o = new MutationObserver((m, obs) =>
m.some(r => [...r.addedNodes].some(n => n.nodeType === 1 && n.querySelector('.threadmark_depth0'))) &&
(recolour(), obs.disconnect())
);
o.observe(document.body, { childList: 1, subtree: 1, attributes: 1 });
}
});
}
})();