List bookmarks before each thread.
// ==UserScript==
// @name AH/SB/SV/QQ Bookmarks list
// @description List bookmarks before each thread.
// @version 1.19
// @author C89sd
// @namespace https://greasyfork.org/users/1376767
// @match https://*.alternatehistory.com/*
// @match https://*.questionablequesting.com/*
// @match https://*.spacebattles.com/*
// @match https://*.sufficientvelocity.com/*
// @grant GM_addStyle
// @grant unsafeWindow
// @noframes
// ==/UserScript==
let ROUNDNESS = 1; // 0=square, 1=round
const DB_KEY = '_bookmarks1'
const DEFAULT_DB = '{}';
const THREAD_ID = location.href.match(/\/threads\/[^\/]*?\.(\d+)\//)?.[1];
let site = location.hostname.split('.').slice(-2, -1)[0];
let IS_AH = site==='alternatehistory';
let baseUrl = IS_AH ? '/forum/' : '/';
// ==================================================== FORUM / SEARCH
if (!location.pathname.includes("/threads/")) {
GM_addStyle(`
.bmBubble {
display: inline-block;
vertical-align: baseline;
box-sizing: border-box;
border-radius: ${ 0.55 * ROUNDNESS }em;
/* margin-right: 1px; */
margin-left: 3px;
font-family: sans-serif;
font-weight: 400;
width: auto;
height: auto;
text-align: center;
word-wrap: normal;
word-break: normal;
outline: 1px solid rgb(166, 116, 199, 0.9);
padding: 0px 3px;
color: rgb(166, 116, 199);
user-select: none;
text-decoration: none !important;
font-size: 0.75em !important;
line-height: 1.1em;
min-width: 1.1em;
position: relative;
top: -0.60px;
}
.bmBubble:hover, .bmBubble:focus { color: rgb(122, 61, 150); outline: 1px solid rgb(122, 61, 150); }
`);
const threads = document.querySelectorAll('.structItem--thread');
if (!threads) return;
const db = loadDB();
for (let thread of threads) {
let threadid = thread.className.match(/\bjs-threadListItem-(\d+)/)?.[1];
if (!threadid) continue;
let list = db[threadid] || [];
if (list.length === 0) continue;
let [postid, _] = list[0];
let url = `${baseUrl}posts/${postid}`
const title = thread.querySelector('.structItem-title');
const viewers = title.querySelector('.sv-user-activity--viewer-count');
const bubble = document.createElement('a');
bubble.className = 'bmBubble';
bubble.textContent = list.length;
bubble.href = url;
if (viewers) {
title.insertBefore(bubble, viewers);
bubble.style.marginRight = '6px';
}
else title.append(bubble);
}
}
// ==================================================== THREADS
function threadmark_highlight_init() {
const id = 'post-highlight-style';
let el = document.getElementById(id);
if (!el) {
el = document.createElement('style');
el.id = id;
document.head.appendChild(el);
}
el.textContent = ''
return el;
}
function threadmark_highlight_add(el, postid, color, first) {
el.textContent += `:is(.block-body--threadmarkBody .structItem-title a, .menu.is-active a.recent-threadmark)[href$="#post-${postid}"]{ color: ${color}; border: solid 1px ${color}; border-radius: 4px; padding: 1px 5px; }`;
}
function theadmark_highlight_update(posts) {
let style_el = threadmark_highlight_init();
posts.forEach(([postid, _], idx) => {
let color = (idx == 0) ? 'rgb(156, 70, 196)' : 'rgb(148, 116, 163)';
threadmark_highlight_add(style_el, postid, color, (idx == 0));
});
}
if (location.pathname.includes("/threads/") && THREAD_ID) {
GM_addStyle(`article.message.hasBookmark.hasBookmark { border: solid 2px #9c46c4; border-radius: 4px; }`);
applyBorderClassOnStart();
rebuildIndicators();
!IS_AH && setupXHRListener();
IS_AH && setupFetchListener();
}
function loadDB() { return JSON.parse(localStorage.getItem(DB_KEY) || DEFAULT_DB); }
function saveDB(db) { localStorage.setItem(DB_KEY, JSON.stringify(db)); }
function rebuildIndicators() {
if (!THREAD_ID) return; // not on a thread page
const db = loadDB();
const list = db[THREAD_ID] || []; // [[postId, date], ...]
theadmark_highlight_update(list);
const host = getContainer();
host.innerHTML = ''; // clear old indicators
host.className = ''; // reset anything left over
host.style.marginBottom = '0px';
if (list.length === 0) return;
host.style.marginBottom = '20px';
/* ---------------------------------------------------------------
1. Heading
--------------------------------------------------------------- */
const h1 = document.createElement('h1');
h1.className = 'block-header'; // ← previous nicer style
h1.textContent = 'Bookmarks:';
h1.style.padding = '8px 10px';
h1.style.background = 'none'
h1.style.backgroundColor = '#9c46c4'
h1.style.color = 'rgb(254, 254, 254)'
h1.style.border = 'none'
h1.style.fontSize = '18px'
h1.style.fontFamily = "Lato,Segoe UI,Helvetica Neue,Helvetica,Roboto,Oxygen,Ubuntu,Cantarell,Fira Sans,Droid Sans,sans-serif"
h1.style.fontWeight = '400'
host.appendChild(h1);
/* ---------------------------------------------------------------
2. Indicators (first = blue, rest = secondary)
--------------------------------------------------------------- */
const DM = window.getComputedStyle(document.body).color.match(/\d+/g)[0] > 128;
list.forEach(([postId, unixTimestamp], idx) => {
const a = document.createElement('a');
const formattedDate = formatDateTime(unixTimestamp);
const relativeTime = getRelativeTime(unixTimestamp);
a.href = `${baseUrl}posts/${postId}`;
a.textContent = `#${postId} - ${formattedDate} (${relativeTime})`;
a.classList.add('button', 'u-fullWidth', 'u-mbSm', 'button--link');
a.style.paddingTop = '1px';
a.style.paddingBottom = '3px';
a.style.justifyContent = 'left';
// a.style.background = 'none'
a.style.backgroundColor = DM ? '#272727' : '#fefefe'; // 'rgb(242, 242, 242)'
a.style.border = 'solid 1px'
a.style.marginBottom = '1px'
if (idx === 0) {
// a.classList.add('button--cta'); // call-to-action = always pops
a.style.paddingTop = '10px';
a.style.paddingBottom = '10px';
a.style.textDecoration = 'underline';
a.style.fontWeight = 'bold';
a.style.color = '#9c46c4'
} else {
a.style.color = 'rgb(148, 116, 163)'
}
host.appendChild(a);
});
}
function formatDateTime(unixTimestamp) {
const date = new Date(unixTimestamp * 1000);
const year = date.getFullYear();
const month = (date.getMonth() + 1).toString().padStart(2, '0');
const day = date.getDate().toString().padStart(2, '0');
const hours = date.getHours().toString().padStart(2, '0');
const minutes = date.getMinutes().toString().padStart(2, '0');
const seconds = date.getSeconds().toString().padStart(2, '0');
return `${year}/${month}/${day}`; // ${hours}:${minutes}:${seconds}
}
function getRelativeTime(unixTimestamp) {
const now = new Date();
const date = new Date(unixTimestamp * 1000);
const diffInSeconds = Math.floor((now - date) / 1000);
if (diffInSeconds < 60) return 'just now';
const minutes = Math.floor(diffInSeconds / 60);
if (minutes < 60) return `${minutes} minute${minutes !== 1 ? 's' : ''} ago`;
const hours = Math.floor(diffInSeconds / 3600);
if (hours < 24) return `${hours} hour${hours !== 1 ? 's' : ''} ago`;
const days = Math.floor(diffInSeconds / 86400);
if (days < 30) return `${days} day${days !== 1 ? 's' : ''} ago`;
const months = Math.floor(days / 30);
const remainingDays = days % 30;
if (remainingDays === 0) {
return `${months} month${months !== 1 ? 's' : ''} ago`;
}
return `${months} month${months !== 1 ? 's' : ''} ${remainingDays} day${remainingDays !== 1 ? 's' : ''} ago`;
}
function getContainer() {
let host = document.getElementById('bookmark-indicators');
if (!host) {
host = document.createElement('div');
host.id = 'bookmark-indicators';
host.style.display = 'flex';
host.style.flexDirection = 'column';
// insert right above the XenForo .p-body-main (or fall back to body)
const bodyMain = document.querySelector('.p-body-main');
(bodyMain?.parentElement).insertBefore(host, bodyMain || document.body.firstChild);
}
return host;
}
function applyBorderClassOnStart() {
let articles = document.querySelectorAll('article.message');
for (let a of articles) {
let isBookmarked = !!a.querySelector('.bookmarkLink.is-bookmarked');
if (isBookmarked) {
a.classList.add('hasBookmark')
}
}
}
const bookmarkObservers = new Map();
function onBookmarkChange(postId, removed, method) {
//console.log(removed ? 'REMOVED' : 'ADDED', postId, method, list);
/* shut down any observer watching this post */
bookmarkObservers.get(postId)?.disconnect()
/* find the post */
const post = document.querySelector(`#js-post-${postId}`);
if (!post) { alert('Bookmark error: post with id ' + postId + 'not found.'); return; }
const observer = new MutationObserver(records => {
for (const r of records) {
// console.log(r)
let el = r.target;
if (el.nodeType === 1 && el.classList.contains('bookmarkLink')) {
const isBookmarked = el.classList.contains('is-bookmarked')
const article = el.closest('article.message');
article?.classList.toggle('hasBookmark', isBookmarked)
/* ---- commit change based on `isBookmarked` instead of `removed` */
const db = loadDB();
let list = db[THREAD_ID] || [];
if (!isBookmarked) { // ---- Remove entry
list = list.filter(([id]) => id != postId); // loose !=, also removes accidental strings
}
else { // ---- Deduplicate / overwrite
const unixNow = Math.floor(Date.now() / 1000);
const idx = list.findIndex(([id]) => id === postId);
if (idx !== -1) {
list[idx] = [postId, unixNow]; // entry exists -> update date
} else {
list.push([postId, unixNow]); // brand-new entry
}
}
list.sort((a, b) => Number(b[0]) - Number(a[0]));
if (list.length) { db[THREAD_ID] = list; }
else { delete db[THREAD_ID]; }
saveDB(db);
rebuildIndicators();
/* ---- */
observer.disconnect();
bookmarkObservers.delete(postId);
return;
}
}
})
bookmarkObservers.set(postId, observer)
observer.observe(post, { childList: true, subtree: true, attributeFilter: ['class'] });
}
function parseKmDigit(text) {
if (!text) return NaN;
const cleanedText = text.trim().toLowerCase();
const multiplier = cleanedText.endsWith('k') ? 1000 : cleanedText.endsWith('m') ? 1000000 : 1;
return parseFloat(cleanedText.replace(/,/g, '')) * multiplier;
}
function getDataFromComment(comment) {
const m = comment.match(/\{t:(\d+)[,:\dlp]*\}/); // old can have p:1,lp:9
return m ? {threadid: m[1]} : null;
}
function firstSentence(post, MAX, title) {
function removePrefix(text, start){
const esc = s=>s.replace(/[.*+?^${}()|[\]\\]/g,'\\$&');
const parts = (start+':').split(/([:.])/).map(s=>s.trim()).filter(Boolean).map(esc);
const regex = parts.reduce((a,b)=> a + '(' + b + '\\s*)?', '');
return text.replace(new RegExp('^' + regex, 'i'), '');
}
function sentencify(node, title) {
let tmp = node.cloneNode(true);
tmp.querySelectorAll('blockquote,.bbCodeSpoiler,table,img,br').forEach(function (n) {
n.remove();
});
let text = tmp.textContent.replace(/\s+/g, ' ').trim();
return removePrefix(text, title);
}
function wordSlice(text, maxChars) {
let words = text.split(' ');
let out = '', len = 0;
for (let i = 0; i < words.length; i++) {
let w = words[i];
let add = len ? w.length + 1 : w.length;
if (len + add > maxChars) break;
out += (len ? ' ' : '') + w;
len += add;
}
return out + '…';
}
let src = post.querySelector('.bbWrapper');
let text = '';
let hr = src.querySelector(':scope > hr');
// if first <hr> has less than 10% of text before it, assume authors note and cut it.
if (hr) {
let prePart = document.createElement('span');
let postPart = document.createElement('span');
let after = false;
src.childNodes.forEach(function (n) {
if (n === hr) {
after = true;
return;
}
(after ? postPart : prePart).appendChild(n.cloneNode(true));
});
let pre = sentencify(prePart, title);
let post = sentencify(postPart, title);
let ratio = pre.length / post.length;
if (ratio < 0.10) text = post;
}
if (!text) text = sentencify(src, title);
return wordSlice(text, MAX);
}
function setupXHRListener() {
const NativeXHR = window.XMLHttpRequest;
const _open = NativeXHR.prototype.open;
NativeXHR.prototype.open = function (m, u) {
this._method = m;
this._url = u;
return _open.apply(this, arguments);
};
const _send = NativeXHR.prototype.send;
NativeXHR.prototype.send = function (body) {
try {
if (this._method === 'POST') {
const postid = this._url.match(/\/posts\/(\d+)\/bookmark/)?.[1];
if (postid) {
const removed = body instanceof FormData && body.has('delete');
// console.log(removed, body, body instanceof FormData)
if (!removed) {
const post = document.getElementById('js-post-' + postid);
const threadid = parseInt(location.href.match(/\/threads\/[^\/]*?\.(\d+)\//)[1], 10);
const data = `{t:${threadid}}`;
const tm_label = post.querySelector('.message-cell--threadmark-header label')?.textContent;
const tm_title = post.querySelector('.message-cell--threadmark-header .threadmarkLabel')?.textContent ?? '';
const tm_first = firstSentence(post, 256 - data.length - tm_title.length, tm_title);
const message = (tm_label ? `[${tm_label}] ${tm_title}\n` : '') + `${tm_first} ${data}`;
// console.log({tm_title, tm_label, tm_first, data})
if (body instanceof FormData) {
const currentMsg = body.get('message') || '';
if (!getDataFromComment(currentMsg)) {
body.set('message', currentMsg ? currentMsg + ' ' + data : message);
}
} else {
body += '&message=' + message + '&labels=';
}
}
onBookmarkChange(parseInt(postid, 10), removed, 'xhr');
}
}
} catch (err) {
alert('XHR listener error:' + err);
}
return _send.call(this, body);
};
}
function setupFetchListener() {
const native = unsafeWindow.fetch;
unsafeWindow.fetch = function (...a) {
const r = a[0] instanceof Request ? a[0] : null;
const init = a[1] || {};
const url = r ? r.url : String(a[0]);
const postid = url.match(/\/posts\/(\d+)\/bookmark/)?.[1];
if (postid) {
(async () => {
let removed = false;
let isMultipart = r && r.headers.get('content-type')?.startsWith('multipart/form-data');
if (isMultipart) {
removed = (await r.clone().text()).includes('name="delete"');
}
onBookmarkChange(parseInt(postid, 10), removed, 'fetch');
})();
}
return native.apply(this, a);
};
}
// ==================================================== ACCOUNT
if (location.pathname.includes("/account")) {
let sidebarBookmarks = document.querySelector('.blockLink[href$="/account/bookmarks"]')
sidebarBookmarks.insertAdjacentHTML('beforeend','<span style="float:right">🔖</span>');
// let sidebarLikes = document.querySelector('.blockLink').cloneNode(false);
// sidebarLikes.textContent = 'Likes';
// sidebarLikes.href += '#latest-activity';
// sidebarLikes.insertAdjacentHTML('beforeend', '<span style="float:right">👍</span>');
// sidebarBookmarks.after(sidebarLikes);
}
// ==================================================== BOOKMARKS
(async () => {
// Knowable: - threadmark count + page-ratio ~~ new chapters; is-last-threadmark
// TODO then resolving skip existing DB
function tempExists(val) { return localStorage.getItem('_temp_scrape') !== null; }
function saveTemp(val) { localStorage.setItem('_temp_scrape', JSON.stringify(val)); }
function loadTemp() { return JSON.parse(localStorage.getItem('_temp_scrape') || 'null'); }
function removeTemp() { localStorage.removeItem('_temp_scrape'); }
// ------------------- BOOKMARKS -------------------
let DELAY = 1_500;
async function sleep(ms){ return new Promise(r => setTimeout(r, ms)); }
async function resolveRedirect (url) {
let res = await fetch(url, { method: 'HEAD' });
if (res.status === 429) { // Too Many Requests
drawText(`Too Many Request: Retrying in 2min... do not interrupt`)
const wait = (+res.headers.get('retry-after') || 120) * 1000;
DELAY += 1000;
await sleep(wait);
return resolveRedirect(url); // retry
}
return res.url;
}
if (location.pathname.includes("/bookmarks")) {
// --------------- Start Button ---------------
let DBcount = Object.values(loadDB()).flat(Infinity).length;
document.querySelector('.filterBar').insertAdjacentHTML('afterbegin',
`<a id="bbtn" class="button" style="padding: 1px 5px; color: #eee; background-color: #f98a28">Fetch bookmarks (${DBcount} in DB ~${Math.ceil(DBcount/20)}pages)</a>`);
const BTN = document.getElementById("bbtn");
function drawText(t) { BTN.textContent = t; }
BTN.onclick = () => {
saveTemp([]);
location.href = baseUrl+"account/bookmarks?page=1";
}
// --------------- Auto Scrape ---------------
let current = location.search.match(/[\?&]page=(\d+)/)?.[1];
if (!current) {
removeTemp(); // Delete the key on main page to disable scraping unless the button was pressed to access ?page=1.
return;
}
if (!tempExists()) { // page 1+ page accessed without clicking on the link.
console.log('temp doesnt exsit')
return;
}
let next = document.querySelector('.pageNav-jump--next');
BTN.onclick = null;
let lastCount = [...document.querySelectorAll('.pageNav-page')].pop()?.textContent || 1;
drawText(`Loading ${current} of ${lastCount}...`);
let temp = loadTemp();
let bookmarks = document.querySelectorAll('.p-body-pageContent .block-row');
for (let [i, b] of [...bookmarks].entries()) {
let author = b.querySelector('.username').textContent;
// 2 types of title: Post in thread '...', Thread '..'
let rawTitle = b.querySelector('.contentRow-title a').textContent;
let isThread = rawTitle.startsWith('Thread');
let title = rawTitle.match(/^(?:Post in thread|Thread) '(.*)'$/)[1]
let url = b.querySelector('.contentRow-title a').href;
let postid = isThread ? 0 : parseInt(url.match(/\/posts\/(\d+)/)[1], 10);
let date = parseInt(b.querySelector('time').getAttribute('data-time'), 10);
let comment = b.querySelector('.contentRow-snippet').textContent;
let commentData = getDataFromComment(comment); // Object { threadid: 33798 }
let entry = {author, title, isThread, url, postid, date, comment, commentData};
temp.push(entry);
console.log(entry);
}
saveTemp(temp);
if (next) {
await sleep(DELAY);
next.click(); return;
}
// --------------- Last Step - Resolve URLs ---------------
temp = loadTemp();
let DB = loadDB();
let final = {};
// Group and deduplicate
const grouped = {};
for (const e of temp) { // 1. group entries by [author++id] in a Map[postid] to deudplicate repeated entries
const key = `${e.author}::${e.title}`;
if (!grouped[key]) grouped[key] = new Map();
grouped[key].set(e.postid, e);
}
for (const key in grouped) { // 2. turn every bucket's Map into an array
grouped[key] = [...grouped[key].values()];
}
for (const key in grouped) { // 3. sort each array by postid decreasing
grouped[key].sort((a, b) => b.postid - a.postid);
}
// Build final
let i = 0;
let N = Object.keys(grouped).length;
for (const arr of Object.values(grouped)) {
drawText(`Processing ${i} of ${N}... do not interrupt`);
let first = arr[0];
let threadid;
// Check if we dont already have this postid in the DB
let threadIdFromDB;
for (const [tid, tarray] of Object.entries(DB)) {
for (const [pid, _] of tarray) {
if (first.postid === pid) {
threadid = tid;
console.log("From DB:", threadid)
break;
}
}
}
if (!threadid) {
for (const entry of arr) {
// check if we got a comment note {t:id}
if (entry.commentData) {
threadid = entry.commentData.threadid;
console.log("Found comment data:", entry.commentData, threadid)
break;
}
// check if we got a direct thread link
let match = entry.url.match(/\/threads\/[^\/]*?\.(\d+)\//);
if (match) {
threadid = match[1];
console.log("Found thread url:", entry.url, threadid)
break;
}
}
}
if (!threadid) {
// what remains must be a post link, resolve it
drawText(`Resolving ${i} of ${N}... do not interrupt` );
const start = Date.now();
let threadUrl = await resolveRedirect(first.url);
console.log("Resolved:", first.url, threadUrl)
const elapsed = Date.now() - start;
await sleep(Math.max(0, DELAY - elapsed));
threadid = threadUrl.match(/\/threads\/[^\/]*?\.(\d+)\//)[1]
}
final[threadid] = arr.map(({postid, date}) => [postid, date]);
i++;
}
console.log(final)
saveDB(final);
console.log(localStorage.getItem(DB_KEY))
drawText(`Done. All bookmarks saved.`);
removeTemp();
return;
}
})();
// // ==================================================== LIKES
// if (location.pathname.includes("/members/")) {
// if (location.hash === "#latest-activity") {
// localStorage.removeItem('_scrape');
// }
// if (location.pathname.endsWith("/latest-activity")) {
// }
// }