您需要先安装一个扩展,例如 篡改猴、Greasemonkey 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 Userscripts ,之后才能安装此脚本。
您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey,才能安装此脚本。
您需要先安装用户脚本管理器扩展后才能安装此脚本。
List bookmarks before each thread.
// ==UserScript== // @name AH/SB/SV/QQ Bookmarks list // @description List bookmarks before each thread. // @version 1.15 // @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 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], ...] 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 setupXHRListener() { const NativeXHR = window.XMLHttpRequest; /* move the hooks to the prototype */ const _open = NativeXHR.prototype.open; NativeXHR.prototype.open = function (m, u) { // console.log(m, u); this._method = m; // remember for send() this._url = u; return _open.apply(this, arguments); }; const _send = NativeXHR.prototype.send; /* intercept send() so we can look at the body */ NativeXHR.prototype.send = function (body) { try { // console.log(this._method, this._url); if (this._method === 'POST') { const postid = this._url.match(/\/posts\/(\d+)\/bookmark/)?.[1]; if (postid) { let removed = body && body instanceof FormData && body.has('delete'); onBookmarkChange(parseInt(postid, 10), removed, 'xhr'); } } } catch (err) { alert('XHR listener error:' + err); } return _send.apply(this, arguments); }; } 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 --------------- document.querySelector('.filterBar').insertAdjacentHTML('afterbegin', '<a id="bbtn" class="button" style="padding: 1px 5px; color: #eee; background-color: #f98a28">Fetch bookmarks</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 entry = {author, title, isThread, url, postid, date}; 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 last = arr[arr.length-1]; // 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) { threadIdFromDB = tid; console.log("From DB:", threadIdFromDB) break; } } } let threadUrl; if (!threadIdFromDB) { if (last.isThread) { // If there was a thread link it will be last/postid=0. It can tell us the thread directly. threadUrl = last.url; console.log("Is Thread:", first.url, threadUrl) } else { // resolve the first postid to know the page drawText(`Resolving ${i} of ${N}... do not interrupt` ); const start = Date.now(); threadUrl = await resolveRedirect(first.url); console.log("Resolved:", first.url, threadUrl) const elapsed = Date.now() - start; await sleep(Math.max(0, DELAY - elapsed)); } } let threadid = threadIdFromDB ? threadIdFromDB : 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")) { // } // }