// ==UserScript== // @name Highlight visited fanfics AH/DLP/QQ/SB/SV/FFN/HPF/oRED // @description Track and highlight visited and watched* fanfiction links/threads across the following sites: AlternateHistory*, DarkLordPotter*, QuestionableQuesting*, SpaceBattles*, SufficientVelocity*, FanFiction, HPFanfiction, and a few old Reddit subs. // @author C89sd // @version 1.0.9 // @match https://questionablequesting.com/* // @match https://forum.questionablequesting.com/* // @match https://forums.spacebattles.com/* // @match https://forums.sufficientvelocity.com/* // @match https://forums.darklordpotter.net/* // @match https://www.alternatehistory.com/* // @match https://m.fanfiction.net/* // @match https://www.fanfiction.net/* // @match https://hpfanfiction.org/fr/* // @match https://www.hpfanfiction.org/fr/* // @match https://old.reddit.com/r/TheCitadel/* // @match https://old.reddit.com/r/*fanfic*/* // @match https://old.reddit.com/r/*Fanfic*/* // @match https://old.reddit.com/r/*FanFic*/* // @grant GM_setValue // @grant GM_getValue // @namespace https://greasyfork.org/users/1376767 // @downloadURL none // ==/UserScript== // Toasts via LocalStorage reload const toast = document.createElement('div'); toast.id = 'toast'; toast.style.position = 'fixed'; toast.style.bottom = '20px'; toast.style.right = '20px'; toast.style.backgroundColor = '#333'; toast.style.color = '#fff'; toast.style.padding = '10px'; toast.style.borderRadius = '5px'; toast.style.opacity = '0'; toast.style.display = 'none'; toast.style.transition = 'opacity 0.5s ease'; toast.style.zIndex = '1000'; document.body.appendChild(toast); function _showToast(message, duration = 20000) { // 20 sec toasts toast.innerHTML = ''; // Clear previous content if (message.startsWith('ffn_')) { const id = message.substring(4); // Extract ID after "ffn_" const link = document.createElement('a'); link.id = 'toast'; link.href = `https://m.fanfiction.net/s/${id}/`; link.textContent = link.href; link.style.color = '#1e90ff'; link.style.textDecoration = 'none'; link.target = '_blank'; // Open in new tab toast.appendChild(link); } else { toast.textContent = `removed "${message}"`; } toast.style.display = 'block'; setTimeout(() => { toast.style.opacity = '1'; }, 10); setTimeout(() => { toast.style.opacity = '0'; }, duration - 500); setTimeout(() => { toast.style.display = 'none'; }, duration); } function _showToast(message, duration = 20000) { // 20 sec toasts toast.innerHTML = ''; // Clear previous content let matched = false; for (const site of sites) { const { prefix, toastUrlPrefix, func } = site; if (prefix && message.startsWith(prefix)) { if (!toastUrlPrefix) { alert(`Missing toastUrlPrefix for site: ${site.domain}`); } const id = message.substring(prefix.length); // Extract id after the prefix const toastUrl = toastUrlPrefix + id; // Concatenate prefix and id const link = document.createElement('a'); link.id = 'toast'; link.href = toastUrl; link.textContent = toastUrl; // Show the full URL as text link.style.color = '#1e90ff'; link.style.textDecoration = 'none'; link.target = '_blank'; // Open in new tab toast.appendChild(link); matched = true; break; // Exit loop once a match is found } } if (!matched) { toast.textContent = `removed "${message}"`; } toast.style.display = 'block'; setTimeout(() => { toast.style.opacity = '1'; }, 10); setTimeout(() => { toast.style.opacity = '0'; }, duration - 500); setTimeout(() => { toast.style.display = 'none'; }, duration); } function showToast(message) { localStorage.setItem('toastMessage', message); } function showToastOnPageLoad() { const message = localStorage.getItem('toastMessage'); if (message) { _showToast(message); localStorage.removeItem('toastMessage'); } } window.addEventListener('load', showToastOnPageLoad); // --- // Custom function to extract thread name (for SB, SV, QQ, DLP) function extractThreadName(url) { let name = url; name = name.replace(/.*?\/threads\//, ''); // Remove everything before /threads/ name = name.replace(/\/.*/, ''); // Remove everything after / name = name.replace(/\.\d+$/, ''); // Remove trailing `.digits` return name; } const dom = window.location.hostname; const sites = [ { domain: 'fanfiction.net', prefix: 'ffn_', toastUrlPrefix: 'https://m.fanfiction.net/s/', func: { test: (url) => /.*?fanfiction\.net\/s\/(\d+)/.test(url), match: (url) => (url.match(/.*?fanfiction\.net\/s\/(\d+)/) || [])[1] || null } }, { domain: 'hpfanfiction.org', prefix: 'hpf_', toastUrlPrefix: 'https://www.hpfanfiction.org/fr/viewstory.php?sid=', func: { test: (url) => /.*?hpfanfiction\.org\/fr\/viewstory\.php\?.*?sid=(\d+)/.test(url), match: (url) => (url.match(/.*?hpfanfiction\.org\/fr\/viewstory\.php\?.*?sid=(\d+)/) || [])[1] || null } }, { domain: 'spacebattles.com', func: { test: (url) => /spacebattles\.com.*?\/threads\//.test(url), match: (url) => extractThreadName(url) } }, { domain: 'sufficientvelocity.com', func: { test: (url) => /sufficientvelocity\.com.*?\/threads\//.test(url), match: (url) => extractThreadName(url) } }, { domain: 'questionablequesting.com', func: { test: (url) => /questionablequesting\.com.*?\/threads\//.test(url), match: (url) => extractThreadName(url) } }, { domain: 'alternatehistory.com', func: { test: (url) => /alternatehistory\.com.*?\/threads\//.test(url), match: (url) => extractThreadName(url) } }, { domain: 'darklordpotter.net', func: { test: (url) => /darklordpotter\.net.*?\/threads\//.test(url), match: (url) => extractThreadName(url) } } ]; const [isFFN, isHPF, isSB, isSV, isQQ, isAH, isDLP] = sites.map(site => dom.includes(site.domain)); const isREDDIT = dom.includes('reddit.com'); const defaultColor = getComputedStyle(document.querySelector("a")).color; const highlightColor = isSB ? 'rgb(223, 166, 255)' : isDLP ? 'rgb(183, 128, 215)' : isSV ? 'rgb(152, 100, 184)' : (isFFN || isHPF) ? 'rgb(135, 15, 135)' : isREDDIT ? 'rgb(187, 131, 216)' : 'rgb(119, 69, 150)'; // isAH || isQQ const highlightYellowColor = isSB ? 'rgb(223, 185, 0)' : isDLP ? 'rgb(180, 147, 0)' : isSV ? 'rgb(209, 176, 44)' : 'rgb(145, 117, 0)'; // isAH || isQQ function detectSite(url) { return sites.find(({ func }) => func.test(url)) || null; } function isThreadUrl(url) { return sites.some(({ func }) => func.test(url)); } function extractThreadId(url) { const site = detectSite(url); if (!site) return null; const threadId = site.func.match(url); if (!threadId) return null; return site.prefix ? site.prefix + threadId : threadId; } // IF reading a Thread: name of the thread extracted from URL. // IF outside a Thread (forum/search/etc): placeholder that cannot match, disables a code path that tries to highlight the post title later. const THREAD_NAME = extractThreadId(window.location.href) || '~/~'; // Plugin Storage function Storage_ReadMap() { const rawData = GM_getValue("C89XF_visited", '{}'); try { return JSON.parse(rawData); } catch (e) { alert('Failed to parse stored data:', e); } } function Storage_AddEntry(key, val) { if (/^\d+$/.test(key)) { return; } // do not save number links e.g. https://forums.spacebattles.com/threads/372848/ ; edge case seeen in https://forum.questionablequesting.com/threads/fanfic-search-thread.953/post-624056 var upToDateMap = Storage_ReadMap() // in case another tab wrote to it if (upToDateMap[key]) { // preserve oldest time } else { upToDateMap[key] = val; GM_setValue("C89XF_visited", JSON.stringify(upToDateMap)); } } function removeMostRecentEntry() { const map = Storage_ReadMap(); let mostRecentKey = null; let mostRecentDate = ''; for (const [key, date] of Object.entries(map)) { if (date >= mostRecentDate) { mostRecentDate = date; mostRecentKey = key; } } if (mostRecentKey) { delete map[mostRecentKey]; GM_setValue("C89XF_visited", JSON.stringify(map)); showToast(`${mostRecentKey}`); // ${mostRecentDate}`); window.location.reload(); // restore old color that was overwritten } } (() => { "use strict"; const footer = document.createElement('div'); footer.style.width = '100%'; footer.style.paddingTop = '5px'; footer.style.paddingBottom = '5px'; footer.style.display = 'flex'; footer.style.justifyContent = 'center'; footer.style.gap = '10px'; footer.class = 'footer'; const navigationBar = isDLP ? document.querySelector('.pageNavLinkGroup') : document.querySelector('.block-outer'); const threadHasWatchedButton = navigationBar ? Array.from(navigationBar.children).some(child => /Watched|Unwatch/.test(child.textContent)) : false; // Turn title into a link const firstH1 = isFFN ? document.querySelector('div[align="center"] > b, div#profile_top > b') : isHPF ? document.querySelector('div#pagetitle > a, div#content > b > a') : document.querySelector('h1'); if (firstH1) { const title = firstH1.lastChild ? firstH1.lastChild : firstH1; const titleLink = document.createElement('a'); titleLink.href = window.location.href; if (title) { const titleClone = title.cloneNode(true); titleLink.appendChild(titleClone); title.parentNode.replaceChild(titleLink, title); } } const BTN_1 = isSV ? ['button', 'button--link'] : ['button'] const BTN_2 = isSV ? ['button'] : (isDLP ? ['button', 'primary'] : ['button', 'button--link']) const exportButton = document.createElement('button'); exportButton.textContent = 'Backup'; exportButton.classList.add(...BTN_1); if (isSV) { exportButton.style.filter = 'brightness(82%)'; } exportButton.addEventListener('click', exportVisitedLinks); footer.appendChild(exportButton); const importButton = document.createElement('button'); importButton.textContent = 'Restore'; importButton.classList.add(...BTN_1); if (isSV) { importButton.style.filter = 'brightness(82%)'; } importButton.addEventListener('click', importVisitedLinks); footer.appendChild(importButton); const updateButton = document.createElement('button'); updateButton.textContent = 'Remove latest highlight'; updateButton.classList.add(...BTN_2); updateButton.addEventListener('click', removeMostRecentEntry); footer.appendChild(updateButton); const xFooter = document.querySelector('footer.p-footer'); if (xFooter) { xFooter.insertAdjacentElement('afterbegin', footer); } else { document.body.appendChild(footer); } function exportVisitedLinks() { const data = GM_getValue("C89XF_visited", '{}'); const blob = new Blob([data], {type: 'application/json'}); const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = 'visited_links_backup.json'; document.body.appendChild(a); a.click(); document.body.removeChild(a); URL.revokeObjectURL(url); } function importVisitedLinks() { const input = document.createElement('input'); input.type = 'file'; input.accept = '.json'; input.onchange = function(event) { const file = event.target.files[0]; const reader = new FileReader(); reader.onload = function(e) { try { const data = JSON.parse(e.target.result); GM_setValue("C89XF_visited", JSON.stringify(data)); alert('Visited links imported successfully. Page will refresh.'); window.location.reload(); } catch (error) { alert('Error importing file. Please make sure it\'s a valid JSON file.'); } }; reader.readAsText(file); }; input.click(); } // Set link colors const applyLinkStyles = () => { const visitedLinks = Storage_ReadMap(); const links = document.getElementsByTagName("a"); for (let link of links) { const href = link.href; // console.log(href) // console.log(detectSite(href)) // console.log(isThreadUrl(href)) // console.log(extractThreadId(href)) // console.log() if (isThreadUrl(href)) { const threadName = extractThreadId(href); const isLinkToCurrentThread = threadName == THREAD_NAME; if (isLinkToCurrentThread && !firstH1.contains(link)) { continue; } // Skip all self-referential links, unless it's the thread title `firstH1`. (This prevents coloring every chapter link, number, next button, etc. Only the title.) // seen if (visitedLinks[threadName]) { link.style.color = highlightColor; } if (isSB || isSV || isAH || isQQ || isDLP) { // watched let isWatched = false; if (isLinkToCurrentThread) { isWatched = threadHasWatchedButton; } else { const parent = isDLP ? link.closest('div.titleText') : link.closest('div.structItem'); const hasIcon = isDLP ? parent && parent.getElementsByClassName('fa-eye').length > 0 : parent && parent.getElementsByClassName('structItem-status--watched').length > 0; isWatched = hasIcon; } if (isWatched) { link.style.color = highlightYellowColor; } } } } // Global click listener if (!document.dataClickListenerAdded) { document.addEventListener("click", function(event) { // handle links const link = event.target.closest('a'); if (link && link.tagName === 'A') { if (link.id == 'toast') { return; } // Toast message link if (link.textContent === 'Table des matières') { return; } // HPF if (link.textContent === 'Suivant') { return; } // HPF if (link.textContent === 'Précédent') { return; } // HPF if (link.textContent === 'Reader mode') { return; } // TODO: Performance: skip nav links so they don't trigger db reads. if (isThreadUrl(link.href)) { const threadName = extractThreadId(link.href); Storage_AddEntry(threadName, new Date().toISOString().slice(0, 19).replace(/[-:T\.]/g, '')); } } // handle Watch/Unwatch buttons if (isSB || isSV || isAH || isQQ || isDLP) { const button = event.target.closest('button, input[type="submit"]'); if (button) { const buttonText = button.tagName === 'INPUT' ? button.value : button.textContent; if (/Watch/.test(buttonText)) { titleLink.style.color = highlightYellowColor; } if (/Unwatch/.test(buttonText)) { if (visitedLinks[THREAD_NAME]) { titleLink.style.color = highlightColor; } else { titleLink.style.color = defaultColor; } } } } }); document.dataClickListenerAdded = true; } }; // Apply styles on load applyLinkStyles(); // Apply styles when navigating back window.addEventListener('pageshow', (event) => { if (event.persisted) { applyLinkStyles(); } }); })();