// ==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.11
// @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*/*
// @match https://www.patronuscharm.net/*
// @match https://patronuscharm.net/*
// @grant GM_setValue
// @grant GM_getValue
// @namespace https://greasyfork.org/users/1376767
// ==/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, toastUrlSuffix, 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
let toastUrl = toastUrlPrefix + id; // Concatenate prefix and id
if (toastUrlSuffix) toastUrl += toastUrlSuffix;
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: 'patronuscharm.net',
prefix: 'pat_',
toastUrlPrefix: 'https://www.patronuscharm.net/s/',
toastUrlSuffix: '/1/',
func: {
test: (url) => /.*?patronuscharm\.net\/s\/(\d+)/.test(url),
match: (url) => (url.match(/.*?patronuscharm\.net\/s\/(\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, isPAT, 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
if (!key) { return; } // do not store null
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') :
isPAT ? document.querySelector('span[title]') :
document.querySelector('h1');
console.log(firstH1);
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: 'text/plain'});
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = 'visited_fanfics_backup.txt';
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 = '.txt, .json';
input.onchange = function(event) {
const file = event.target.files[0];
const reader = new FileReader();
reader.onload = function(e) {
try {
const data_before = Storage_ReadMap();
const data = JSON.parse(e.target.result);
GM_setValue("C89XF_visited", JSON.stringify(data));
const length_before = Object.keys(data_before).length;
const length_after = Object.keys(data).length;
const diff = length_after - length_before;
var notes =`\n- Entries: ${length_before} → ${length_after} (total: ${diff >= 0 ? "+" : ""}${diff})`;
notes += "\n\n—— DATA ——\n"
notes += JSON.stringify(data).slice(0, 350) + '...';
alert('Visited fanfics restored successfully. Page will refresh.' + notes);
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 <a> 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();
}
});
})();