Greasy Fork

Greasy Fork is available in English.

AH/DLP/QQ/SB/SV/FFN/HPF/PC/OR Highlight visited fanfics

Track and highlight visited and watched* fanfiction links across the following sites: AlternateHistory*, DarkLordPotter*, QuestionableQuesting*, SpaceBattles*, SufficientVelocity*, FanFiction, HPFanfiction, PatronusCharm, also highlight them in some old subreddits.

当前为 2025-05-27 提交的版本,查看 最新版本

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Greasemonkey 油猴子Violentmonkey 暴力猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Violentmonkey 暴力猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Userscripts ,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展后才能安装此脚本。

(我已经安装了用户脚本管理器,让我安装!)

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

(我已经安装了用户样式管理器,让我安装!)

// ==UserScript==
// @name AH/DLP/QQ/SB/SV/FFN/HPF/PC/OR Highlight visited fanfics
// @description Track and highlight visited and watched* fanfiction links across the following sites: AlternateHistory*, DarkLordPotter*, QuestionableQuesting*, SpaceBattles*, SufficientVelocity*, FanFiction, HPFanfiction, PatronusCharm, also highlight them in some old subreddits.
// @author C89sd
// @version 1.46
//
// @include https://www.alternatehistory.com/*
// @include https://forums.darklordpotter.net/*
// @include https://forums.spacebattles.com/*
// @include https://forums.sufficientvelocity.com/*
// @include https://questionablequesting.com/*
// @include https://forum.questionablequesting.com/*
// @include https://m.fanfiction.net/*
// @include https://www.fanfiction.net/*
// @include https://hpfanfiction.org/fr/*
// @include https://www.hpfanfiction.org/fr/*
// @include https://patronuscharm.net/*
// @include https://www.patronuscharm.net/*
// @include /^https:\/\/old\.reddit\.com\/r\/(?:HP|masseffect|TheCitadel|[^\/]*?[Ff]an[Ff]ic)[^\/]*\/comments\//
// @include https://old.reddit.com/favicon.ico
//
// @grant GM_setValue
// @grant GM_getValue
// @grant GM_addStyle
// @namespace http://greasyfork.icu/users/1376767
// @run-at document-start
// ==/UserScript==

'use strict';

// =====================================================================
// Navback-safe GM get/set
// =====================================================================
// We need to yield to let the userscript be reinjected in the iframe.
// We could async wait on a Promise of the iframe's ready message.
// But async functions can be interrupted when leaving the page.
// To keep the API sync, we run our own 'onBackForward' callbacks in onMsg.
const DEBUG = false;
const DEBUG2 = false; // debug parsed data
// -------------------------------------- Iframe
if (window.self !== window.top) {
  { // !Security
    const ALLOWED_PARENT_DOMAINS = [
      'https://www.alternatehistory.com',
      'https://forums.darklordpotter.net',
      'https://forums.spacebattles.com',
      'https://forums.sufficientvelocity.com',
      'https://questionablequesting.com',
      'https://forum.questionablequesting.com',
      'https://m.fanfiction.net',
      'https://www.fanfiction.net',
      'https://hpfanfiction.org',
      'https://www.hpfanfiction.org',
      'https://patronuscharm.net',
      'https://www.patronuscharm.net',
      'https://old.reddit.com',
    ];

    const isTopDomainAuthorized = ALLOWED_PARENT_DOMAINS.includes(window.top.location.origin);
    const isIframeURLAllowed    = window.location.origin === window.top.location.origin && window.location.pathname === '/favicon.ico';
    const isDirectChildOfTop    = (window.parent === window.top);

    if (!(isTopDomainAuthorized && isIframeURLAllowed && isDirectChildOfTop)) {
      console.error('Iframe security violation.', { isTopDomainAuthorized, isIframeURLAllowed, isDirectChildOfTop, iframeLocation: window.location.href, topLocation: window.top.location.href })
      return;
    }
    if (DEBUG) console.log("Iframe security checks passed: Running in an authorized context.");
  }

  unsafeWindow.top.GMproxy = {
    setValue: (key, val) => {
      if (DEBUG) console.log('Iframe SET', {key, length: val.length});
      return GM_setValue(key, val);
    },
    getValue: (key, def) => {
      const res = GM_getValue(key, def);
      if (DEBUG) console.log('Iframe GET', {key, def, length: res.length});
      return res;
    }
  }
  window.parent.postMessage('R', '*');
  if (DEBUG) console.log('Iframe message sent.');
  return; // --> [Exit] <--
}
// -------------------------------------- Main
let GMproxy = {}
let iframe = null;
let iframeReady = false;

const _setValue = GM_setValue;
const _getValue = GM_getValue;
GM_setValue = (key, val) => {
  if (iframe) {
    if (iframeReady) return GMproxy.setValue(key, val);
    else throw new Error(`GM_setValue, Iframe not ready, key=${key}`);
  } else {
    if (DEBUG) console.log('Main SET', {key, length: val.length});
    return _setValue(key, val);
  }
}
GM_getValue = (key, def) => {
  if (iframe) {
    if (iframeReady) return GMproxy.getValue(key, def);
    else throw new Error(`GM_getValue, Iframe not ready, key=${key}`);
  } else {
    const res = _getValue(key, def);
    if (DEBUG) console.log('Main GET', {key, def, length: res.length});
    return res;
  }
}

let backForwardQueue = [];
function onBackForward(fn) {
  backForwardQueue.push(fn);
}

window.addEventListener('pageshow', (e) => {
  if (e.persisted) {
    const oldIframe = document.getElementById('gmproxy');
    if (oldIframe) oldIframe.remove();

    iframeReady = false;
    iframe = document.createElement('iframe');
    iframe.id = 'gmproxy';
    iframe.style.display = 'none';
    iframe.referrerPolicy = 'no-referrer';
    iframe.src = location.origin + '/favicon.ico';
    document.body.appendChild(iframe);

    const my_iframe = iframe;

    const controller = new AbortController();
    const onHide = (ev) => {
      if (DEBUG) console.log('Iframe aborted (pagehide).');
      controller.abort();
    };
    const onMsg = (ev) => {
      if (my_iframe !== iframe) {
        if (DEBUG) console.log('ERROR ! my_iframe !== iframe')
        controller.abort();
        return;
      }
      if (ev.source === iframe.contentWindow && ev.data === 'R') {
        GMproxy = unsafeWindow.GMproxy;
        iframeReady = true;
        controller.abort();
        if (DEBUG) console.log('Iframe message received. GMproxy=', GMproxy);
        backForwardQueue.forEach((fn) => { fn() });
      }
    };
    window.addEventListener('message', onMsg, { signal: controller.signal });
    window.addEventListener('pagehide', onHide, { signal: controller.signal });
  }
})

const _addEventListener = window.addEventListener;
window.addEventListener = (type, listener, options) => {
  if (type === 'pageshow') {
    throw new Error('Cannot register "pageshow" event listener, use onBackForward(fn)');
  }
  _addEventListener(type, listener, options);
};

// =====================================================================
// Deletion Toast
// =====================================================================

function assert(condition, message) {
  if (!condition) {
    alert(`[userscript:Highlight visited fanfics] ERROR\n${message}`);
  }
}

function createToastElement() {
  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);
  return toast;
}

let toastHistory = [];
let debounceTimer = null;
let cleanupTimer = null;

function showToast(message, message2, duration = 20000) {
  // debounce lock 350ms
  const button = document.getElementById('remove-latest-highlight');
  button.addEventListener('click', function() {
    button.disabled = true;
    button.style.filter = 'brightness(0.5)';
    setTimeout(() => {
      button.disabled = false;
      button.style.filter = '';
    }, 350);
  });

  _showToast(message, message2, duration);

  function _showToast(message, message2, duration) {
      let toast = document.getElementById('toast');
    if (!toast) {
      createToastElement();
      toast = document.getElementById('toast');
      if (!toast) {
        console.error('Toast element not found');
        return;
      }
    }

    function processMessage(msg) {
      if (!msg) return false;

      for (const site of siteConfigs) {
        const { prefix, toastUrlPrefix, toastUrlSuffix = "" } = site;
        if (msg.startsWith(prefix)) {
          const id = msg.slice(prefix.length);
          const toastUrl = toastUrlPrefix + id + toastUrlSuffix;

          const link = document.createElement('a');
          link.href = toastUrl;
          link.textContent = toastUrl;
          link.className = 'nohl-toast';
          link.style.color = '#1e90ff';
          link.style.textDecoration = 'none';
          link.target = '_blank';
          link.style.fontFamily = 'sans-serif';
          return link;
        }
      }

      const textSpan = document.createElement('div');
      textSpan.textContent = `removed "${msg}"`;
      textSpan.style.fontFamily = 'sans-serif';
      return textSpan;
    }

    const newElements = [];

    let matched1 = processMessage(message);
    let matched2 = processMessage(message2);
    if (matched1) newElements.push(matched1);
    if (matched1 && matched2) newElements.push(document.createElement('br'));
    if (matched2) newElements.push(matched2);
    newElements.push(document.createElement('hr'));

    const now = new Date().getTime();
    toastHistory = toastHistory.concat(newElements.map(element => ({ element, timestamp: now, duration })));

    scheduleCleanup();
    updateToast();

    // delete dom elements as their timestamp expire
    function scheduleCleanup() {
      if (cleanupTimer !== null) {
        clearTimeout(cleanupTimer);
      }
      const now = new Date().getTime();
      const nextCleanupTime = Math.min(...toastHistory.map(entry => entry.timestamp + entry.duration));

      cleanupTimer = setTimeout(() => {
        cleanupHistory();
        updateToast();
        scheduleCleanup();
      }, nextCleanupTime - now);
    }

    function cleanupHistory() {
      const now = new Date().getTime();
      toastHistory = toastHistory.filter(entry => entry.timestamp + entry.duration > now);
    }

    function updateToast() {
      const toast = document.getElementById('toast');
      if (!toast) return;

      toast.innerHTML = '';
      toastHistory.forEach((entry, index) => {
        const element = entry.element.cloneNode(true);
          element.style.textAlign = 'right';
          element.style.display = 'block';
        toast.appendChild(element);
      });

      if (toast.lastChild && toast.lastChild.tagName === 'HR') {
        toast.removeChild(toast.lastChild);
      }

      if (toastHistory.length > 0) {
        toast.style.display = 'block';
        setTimeout(() => { toast.style.opacity = '1'; }, 10);

        clearTimeout(toast._timeout);
        toast._timeout = setTimeout(() => {
          toast.style.opacity = '0';
          setTimeout(() => {
            toast.style.display = 'none';
          }, 500); // wait for the opacity animation to finish
        }, toastHistory[toastHistory.length - 1].duration - 500);
      } else {
        toast.style.display = 'none';
      }
    }
  }
}

// =====================================================================
// Sites
// =====================================================================
// Test: sufficientvelocity.com/threads/1487

/*
Centralise logic for extracting Xenforo {name, id} from url.
Handles URLs such as:
- threads/123456
- threads/.123456
- threads/thread-title.123456/
- (legacy) index.php?threads/thread-title.123456/
- (legacy) showthread.php?t=123456
- handle trailing /?#
*/
function extractXenForo(urlTail) {
  const THREADS  = "threads/";
  const INDEXPHP = "index.php?threads/";
  let rest, name = null, id = null;

  // threads/123 & index.php?threads/123
  if (
    (urlTail.startsWith(THREADS)  && (rest = urlTail.slice(THREADS.length))) ||
    (urlTail.startsWith(INDEXPHP) && (rest = urlTail.slice(INDEXPHP.length)))
  ) {
    // strip off any trailing “/…” or “?…”
    const sep = rest.search(/[\/?]/);
    if (sep !== -1) rest = rest.slice(0, sep);

    const dotIdx = rest.lastIndexOf(".");
    if (dotIdx > 0) {
      name = rest.slice(0, dotIdx);
      id   = rest.slice(dotIdx + 1);
    } else {
      const m = rest.match(/^\.?(\d+)$/);
      if (m) id = m[1];
    }

    return id ? { id, name } : null;
  }

  // legacy showthread.php?t=123
  if (urlTail.startsWith("showthread.php")) {
    const m = urlTail.match(/[?&]t=(\d+)/);
    if (m) return { id: m[1], name: null };
  }

  return null;
}

/*
Site configuration table

Each site defines:
- bases: hostnames (used to build a domain → site map for O(1) lookup)
- extractor: function returning { id, name } or null.
- prefix: id prefix to prevent collision in the shared "database" map
- toastUrl Prefix+Suffix: to reconstruct a link from the deleted id (deletion toast)

Design decisions:
- O(1) hashmap domain lookup to avoid testing every domain to pick the right extractor (regex overhead of handling /i etc)
- Xenforo name+id extraction was impossible with regexes; I wanted ALL of its logic in one place, extractXenForo()
*/
const siteConfigs = [
{
  bases: [
    "m.fanfiction.net",
    "fanfiction.net",
  ],
  const: "IS_FFN",
  prefix: "ffn_",
  toastUrlPrefix: "https://m.fanfiction.net/s/",
  extractor: (urlTail) => {
    const c = urlTail[0];
    if (!c || (c !== 's' && c !== 'r')) return null;
    const m = urlTail.match(/[sr]\/(\d+)(?:\/|$)/); // /s/ are fics, /r/ are review; note: /\/s\/(\d+)/ would match /s/123garbage, we want /s/123 or /s/123/foo: /|$
    return m ? { id: m[1], name: null } : null;
  }
},
{
  bases: ["hpfanfiction.org"],
  const: "IS_HPF",
  prefix: "hpf_",
  toastUrlPrefix: "https://www.hpfanfiction.org/fr/viewstory.php?sid=",
  extractor: (urlTail) => {
    if (!urlTail.startsWith("fr/viewstory.php?")) return null;
    const m = urlTail.match(/(?:[?&])sid=(\d+)(?:&|$)/); // viewstory.php?sid=123 | viewstory.php?...&sid=123, ensure no sid=123garbage: &|$
    return m ? { id: m[1], name: null } : null;
  }
},
{
  bases: ["patronuscharm.net"],
  const: "IS_PAT",
  prefix: "pat_",
  toastUrlPrefix: "https://www.patronuscharm.net/s/",
  toastUrlSuffix: "/1/",
  extractor: (urlTail) => {
    const c = urlTail[0];
    if (!c || (c !== 's' && c !== 'r')) return null;
    const m = urlTail.match(/(?:s\/|r\/view\/)(\d+)(?:\/|$)/); // s/123 are fics, r/view/123 are reviews
    return m ? { id: m[1], name: null } : null;
  }
},
{
  bases: [
    "spacebattles.com",
    "forums.spacebattles.com",
    "forum.spacebattles.com",
  ],
  const: "IS_SB",
  prefix: "xsb_",
  toastUrlPrefix: "https://forums.spacebattles.com/threads/",
  extractor: extractXenForo,
  xenforo: true
},
{
  bases: [
    "sufficientvelocity.com",
    "forums.sufficientvelocity.com",
    "forum.sufficientvelocity.com",
  ],
  const: "IS_SV",
  prefix: "xsv_",
  toastUrlPrefix: "https://forums.sufficientvelocity.com/threads/",
  extractor: extractXenForo,
  xenforo: true
},
{
  bases: [
    "questionablequesting.com",
    "forum.questionablequesting.com",
    // "forums.questionablequesting.com", // not supported
  ],
  const: "IS_QQ",
  prefix: "xqq_",
  toastUrlPrefix: "https://forum.questionablequesting.com/threads/",
  extractor: extractXenForo,
  xenforo: true
},
{
  bases: [
    "alternatehistory.com",
    "www.alternatehistory.com",
    "forums.alternatehistory.com",
  ],
  const: "IS_AH",
  prefix: "xah_",
  toastUrlPrefix: "https://www.alternatehistory.com/forum/threads/",
  extractor: extractXenForo,
  xenforo: true
},
{
  bases: [
    "darklordpotter.net",
    "forums.darklordpotter.net",
    // "forum.darklordpotter.net",  // not supported
  ],
  const: "IS_DLP",
  prefix: "xdl_",
  toastUrlPrefix: "https://forums.darklordpotter.net/threads/",
  extractor: extractXenForo,
  xenforo: true
}
];

// Domain-to-site for O(1) lookup
const siteMap = siteConfigs
  .flatMap(conf => conf.bases.map(base => [base.toLowerCase(), conf]))
  .reduce((map, [base, conf]) => { map[base] = conf; return map; }, {});

// Split [lowercase domain, rest (without leading slash)] rest can have /&#
function splitOffDomain(rawUrl) {
  // Stip leading protocol, "http://///site.com///path" -> "site.com///path"
  let url = rawUrl.replace(/^[A-Za-z]+:\/+/, "");

  // Collapse repeated /// into /, "site.com///threads//foo" -> "site.com/threads/foo"
  url = url.replace(/\/{2,}/g, "/");

  const slashIdx = url.indexOf('/'); // slice off domain before first slash
  let domain;
  let rest;
  if (slashIdx === -1) {
    domain = url;
    rest   = "";
  } else {
    domain = url.slice(0, slashIdx);
    rest   = url.slice(slashIdx + 1);  // +1 to drop the leading slash from pathname
  }

  if (domain.slice(0,4).toLowerCase() === "www.") { // trim www. if present
    domain = domain.slice(4);
  }

  return [ domain.toLowerCase(), rest ]; // lowercase domain for siteMap lookup (e.g., FoRuMs.Spacebattles.com)
}

/*
Main function (url) => { id, name, prefixedId, site } | null
- Splits [domain, pathname]
- Performs O(1) lookup via `domainToSiteMap`
- Calls the site's extractor
- Adds the prefixedId needed to lookup the DB

Experimental: Reddit doesn't have a domain, we return {} to simplify some paths.
*/
function parseThreadLink(rawUrl) {
  const [domain, rest] = splitOffDomain(rawUrl);
  const site = siteMap[domain];

  if (!site) {
    if (DEBUG2) console.log('parsed, {domain} !found', {rawUrl, domain, rest, site})
    return { id: null, name: null, prefixedId: null, site: {} }; // domain didnt match
  }

  const data = site.extractor(rest);
  if (!data || !data.id) {
    if (DEBUG2) console.log('parsed, {id} !found', {rawUrl, domain, rest, site, data})
    return { id: null, name: null, prefixedId: null, site }; // we only got the domain
  }

  if (DEBUG2) console.log('parse success', {rawUrl, domain, rest, site, ...data})
  return { id: data.id, name: data.name, prefixedId: site.prefix + data.id, site };
}


const CURRENT_DOMAIN = window.location.href;
const CURRENT = parseThreadLink(CURRENT_DOMAIN); // This page's { id, name, prefixedId, site }
const IS_XENFORO = CURRENT.site.xenforo;
const SITE_IS_THREAD = Boolean(CURRENT.id); // Is it a Thread or Forum/Search page.
// Note: Urls can be crafted with abritrary names e.g. "/threads/foobar.1234/"
// Use those for highlighting, but only update the DB from a trusted page (e.g. Forum)
// Users can post random links in /threads/. Fortunately, /search/ makes them into text.
const TRUST_SITE_NAMES = IS_XENFORO && !SITE_IS_THREAD;

let IS_FFN = CURRENT.site?.const === "IS_FFN",
    IS_HPF = CURRENT.site?.const === "IS_HPF",
    IS_PAT = CURRENT.site?.const === "IS_PAT",
    IS_SB  = CURRENT.site?.const === "IS_SB",
    IS_SV  = CURRENT.site?.const === "IS_SV",
    IS_QQ  = CURRENT.site?.const === "IS_QQ",
    IS_AH  = CURRENT.site?.const === "IS_AH",
    IS_DLP = CURRENT.site?.const === "IS_DLP";

const IS_RED = CURRENT_DOMAIN.includes("reddit.com");

if (DEBUG) {
  console.log('href=',CURRENT_DOMAIN)
  console.log('site=',CURRENT)
  console.log('flags=',{IS_FFN,IS_HPF,IS_PAT,IS_SB,IS_SV,IS_QQ,IS_AH,IS_DLP,IS_RED});
}
// =====================================================================
// Colors
// =====================================================================

function InjectColors() {
  // dark mode
  const DM = IS_QQ && window.getComputedStyle(document.body).color.match(/\d+/g)[0] > 128
          || IS_RED && +getComputedStyle(document.querySelector('.md')).color.match(/\d+/)[0]>128;

  const purpleHighlightColor =
      IS_SB  ? 'rgb(165, 122, 195)' :
      IS_QQ  ? (DM ? 'rgb(166, 116, 199)' : 'rgb(119, 69, 150)' ):
      IS_DLP ? 'rgb(166, 113, 198)' :
      IS_SV  ? 'rgb(175, 129, 206)' :
      IS_FFN ? 'rgb(135, 15, 135)' :
      IS_HPF ? 'rgb(135, 15, 135)' :
      IS_RED ? (DM ? 'rgb(183, 127, 208)' : 'rgb(154, 60, 188)' ):
               'rgb(119, 69, 150)'; // IS_AH

  const baseColor = DM ? 'rgb(120, 128, 255)' : 'rgb(50, 0, 231)';

  const pinkHighlightColor =
      IS_SB ? 'rgb(213, 119, 142)' :
      IS_QQ ? (DM ? 'rgb(213, 119, 142)' : 'rgb(159, 70, 92)'):
      IS_SV ? 'rgb(209, 112, 136)' :
              'rgb(200, 105, 129)';

  const yellowHighlightColor =
      IS_SB  ? 'rgb(223, 185, 0)' :
      IS_DLP ? 'rgb(180, 147, 0)' :
      IS_SV  ? 'rgb(209, 176, 44)' :
               'rgb(145, 117, 0)'; // IS_AH || IS_QQ

  if (IS_RED || DEBUG) {
    GM_addStyle(`
      .hl-base { text-decoration: underline !important; color: ${baseColor} !important; }
      .hl-seen { text-decoration: dashed underline !important; }
    `);
  }
  GM_addStyle(`
  .hl-name-seen { color: ${pinkHighlightColor}   !important; }
  .hl-seen      { color: ${purpleHighlightColor} !important; }
  .hl-watched   { color: ${yellowHighlightColor} !important; }
`);
}

// =====================================================================
// Storage
// =====================================================================

// Plugin Storage
function Storage_ReadMap() {
  const rawData = GM_getValue("C89XF_visited", '{}');
  try {
    return JSON.parse(rawData);
  } catch (e) {
    assert(false, `Failed to parse stored data: ${e}`);
    throw new Error(`Failed to parse stored data: ${e}`);
  }
}

function Storage_AddEntry(key, val) {
  // do not store null
  if (!key) {
    console.error('Storage_AddEntry null key', key, val);
    return;
  }

  // detect non-prefixed ids being inserted.Besides, a fic called <number> is unlikely.
  if (/^\d+$/.test(key)) {
    console.error('Storage_AddEntry is number', key, val);
    return;
  }

  var upToDateMap = Storage_ReadMap() // in case another tab wrote to it
  if (upToDateMap[key]) {
    return false; // preserve oldest time if already seen
  } else {
    upToDateMap[key] = val;
    GM_setValue("C89XF_visited", JSON.stringify(upToDateMap));
    return true;
  }
}

// =====================================================================
// Main
// =====================================================================
let CONFIG = { autoHighlight: true }
let doOnce = true;

addEventListener("DOMContentLoaded", (event) => {
  // If another script redirects the page, it crashes on document.body, exit gracefully.
  if (!document.body) {
    if (DEBUG) console.log("Error: document.body is null.");
    return;
  }

  InjectColors()

  // ============================= Title ===============================

  const buttonsList = IS_DLP ? document.querySelector('.pageNavLinkGroup').children : // navigation bar
                               document.querySelectorAll('div.block-outer-opposite > div.buttonGroup > a > span');
  const threadHasWatchedButton = buttonsList ? Array.from(buttonsList).some(child => /Watched|Unwatch/.test(child.textContent)) : false;

  // Turn title into a link
  const firstH1 = IS_FFN ? document.querySelector('div[align="center"] > b, div#profile_top > b') :
                  IS_HPF ? document.querySelector('div#pagetitle > a, div#content > b > a') :
                  IS_PAT ? document.querySelector('span[title]') :
                  document.querySelector('h1');
  let secondH1 = null;
  if (DEBUG) console.log('title first H1', firstH1, 'second H1', secondH1)

  const titleLink = document.createElement('a');
  // note: clicking thread titles no longer reloads, so we strip the

  if (SITE_IS_THREAD) titleLink.href = window.location.origin + window.location.pathname.replace(/\/{2,}/g, '/'); // direct page link, pathname strips the # which prevent reloading
  else                titleLink.href = window.location.origin + '/' + window.location.pathname.replace(/\/{2,}/g, '/').split('/').slice(1,3).join('/'); // forum root link

  if (firstH1) {
    const title = firstH1.lastChild ? firstH1.lastChild : firstH1;
    if (title) {
      const titleClone = title.cloneNode(true);
      titleLink.appendChild(titleClone); // Put title in an empty link.
      const titleParent = title.parentNode;
      titleParent.replaceChild(titleLink, title); // Swap title with title-link.

      // Second title above threadmarks
      if (!CONFIG.autoHighlight) {
        const header = document.querySelector("div.threadmarkListingHeader")
        if (header) {
          const block = header.closest("div.block")
          secondH1 = titleParent.cloneNode(true);
          block.parentNode.insertBefore(secondH1, block.nextSibling);
        }
      }
    }
  }

  function isTitle(link) {
    return (firstH1 && firstH1.contains(link)) || (secondH1 && secondH1.contains(link));
  }

  // =========================== Auto Conf ==============================

  CONFIG = GM_getValue('config', CONFIG);

  function updateAutoBtn() {
    autoButton.textContent = CONFIG.autoHighlight ? 'A' : 'M';
    autoButton.title       = CONFIG.autoHighlight ? 'Auto' : 'Manual';
  }

  function toggleConfAutoHighlight() {
    CONFIG.autoHighlight = !CONFIG.autoHighlight;
    GM_setValue('config', CONFIG);
    updateAutoBtn();
  }

  function syncConfig() {
    const stored = GM_getValue('config', CONFIG);
    if (stored.autoHighlight !== CONFIG.autoHighlight) {
      CONFIG = stored;
      updateAutoBtn();
    }
  }

  // ============================= Footer ===============================

  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 BTN_1 = IS_SV ? ['button', 'button--link'] : ['button']
  const BTN_2 = IS_SV ? ['button'] : (IS_DLP ? ['button', 'primary'] : ['button', 'button--link'])

  const autoButton = document.createElement('button');
  autoButton.classList.add(...BTN_2);
  if (IS_SV) { autoButton.style.filter = 'brightness(82%)'; }
  autoButton.style.width  = '4ch';
  autoButton.addEventListener('click', toggleConfAutoHighlight);
  updateAutoBtn();
  footer.appendChild(autoButton);

  const exportButton = document.createElement('button');
  exportButton.textContent = 'Backup';
  exportButton.classList.add(...BTN_1);
  if (IS_SV) { 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 (IS_SV) { importButton.style.filter = 'brightness(82%)'; }
  importButton.addEventListener('click', importVisitedLinks);
  footer.appendChild(importButton);

  const updateButton = document.createElement('button');
  updateButton.id = 'remove-latest-highlight';
  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); }

  // ============================= Export ===============================

 function exportVisitedLinks() {
    const pad = (num) => String(num).padStart(2, '0');
    const now = new Date();
    const year = now.getFullYear();
    const month = pad(now.getMonth() + 1);
    const day = pad(now.getDate());
    const hours = pad(now.getHours());
    const minutes = pad(now.getMinutes());
    const seconds = pad(now.getSeconds()); // Add seconds
    const map = Storage_ReadMap();
    const size = map ? Object.keys(map).length : 0;

    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_${year}_${month}_${day}_${hours}${minutes}${seconds} +${size}.txt`;
    document.body.appendChild(a);
    a.click();
    document.body.removeChild(a);
    URL.revokeObjectURL(url);
  }

  // ============================= Import ===============================

  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) {
        const data_before = Storage_ReadMap();
        try {
          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();
          applyLinkStyles(true);

        } catch (error) {
          alert('Error importing file. Please make sure it\'s a valid JSON file.');
        }
      };
      reader.readAsText(file);
    };
    input.click();
  }

  // ========================== Remove Recent ============================

  function removeMostRecentEntry() {
    const map = Storage_ReadMap();
    let mostRecentKey = null;
    let mostRecentDate = '';
    let previousmostRecentKey = null;
    let previousMostRecentDate = '';

    for (const [key, date] of Object.entries(map)) {
      if (date >= mostRecentDate) { // find last entry with the greatest date
        previousMostRecentDate = mostRecentDate;
        previousmostRecentKey = mostRecentKey;
        mostRecentDate = date;
        mostRecentKey = key;
      }
    }
    if (mostRecentKey) {
      delete map[mostRecentKey];

      const twoKeys = previousmostRecentKey && previousMostRecentDate == mostRecentDate;
      if (twoKeys) {
        delete map[previousmostRecentKey];
      }

      GM_setValue("C89XF_visited", JSON.stringify(map));

      showToast(`${mostRecentKey}`, twoKeys ? `${previousmostRecentKey}` : null); // ${mostRecentDate}`);
      applyLinkStyles(true);
    }
  }

  // ========================== Apply Styles ============================

  let last = 0;
  // Set link colors
  function applyLinkStyles(force = false) {
    // Debounce
    const now = Date.now();
    if (!force && now - last < 500) return;
    last = now;

    if (DEBUG) console.log('--- apply link styles');

    const visitedLinks = Storage_ReadMap();

    // Clone the date from CURRENT.prefixedId to CURRENT.name if latter is undefined.
    // If this page was opened from a third party we may not have added its name to the DB
    // Now that we are on the page, we can trust CURRENT.name to no be manipulated.
    {
      if (doOnce) {
        doOnce = false;
        if (IS_XENFORO && CURRENT.name) {
          let idDate = visitedLinks[CURRENT.prefixedId];
          let nameDate = visitedLinks[CURRENT.name];
          if (idDate && !nameDate) {
            Storage_AddEntry(CURRENT.name, idDate);
          }
        }
      }
    }

    const links = document.querySelectorAll("a[href]");
    // const start = Date.now();
    for (let link of links) {
      if (link.classList.contains('nohl-toast')) continue;  // Toast message link

      const url = link.href; // handles partial urls instead of getAttribs
      const parsed = parseThreadLink(url)

      if (parsed.site) {
        if (parsed.prefixedId) {

          // Do not highlight self-referential links (unless it is the title).
          const linkPointsToCurrentPage = (parsed.prefixedId === CURRENT.prefixedId);
          if (linkPointsToCurrentPage) {
            if (!isTitle(link)) { continue }
          }

          // Clear previous classes (when reapplying)
          link.classList.remove('hl-seen', 'hl-name-seen', 'hl-watched');
          link.classList.add('hl-base')

          // Hihlight seen links.
          if (visitedLinks[parsed.prefixedId]) {
            link.classList.add('hl-seen');
          }
          else {
            if (parsed.site.xenforo) {
              if (parsed.name && visitedLinks[parsed.name]) {
                // Compatiblity: we used to store threadName instead of prefixedId.
                // TODO: we just found an old entry, maybe insert in the DB instead of just coloring, this would prevent DB loss from future title changes.
                link.classList.add('hl-name-seen');
              }
            }
          }

          // Hihlight watched links (Xenforo only).
          if (IS_XENFORO) {
            let isWatched = false;

            // In Threads, the only link to highlight is the Title Link.
            if (SITE_IS_THREAD){
              if (linkPointsToCurrentPage) { isWatched = threadHasWatchedButton; }
            }
            // In Forum view, check the bell/eye icon next to the link.
            else {
              const parent  = IS_DLP ? link.closest('div.titleText')
                                    : link.closest('div.structItem');
              const hasIcon = IS_DLP ? parent && parent.getElementsByClassName('fa-eye').length > 0
                                    : parent && parent.getElementsByClassName('structItem-status--watched').length > 0;
              isWatched = hasIcon;
            }

            if (isWatched) {
              link.classList.add('hl-watched');
            }
          }
        }
      }
    }
    // const end = Date.now();
    // console.log(`Execution time: ${end - start} ms`);
  };

  // ========================= Click Listener ===========================

  // Global click listener
  if (!document.dataClickListenerAdded) {
    document.addEventListener("click", function(event) {

      let wasAdded = false; // Unused: used to trigger preventDefault+setTimeout+reload to give the DB time to write

      // handle links
      const link = event.target.closest('a');
      if (link && link.tagName === 'A') {
        if (DEBUG) console.log('clicked', link)

        if (link.closest('#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 (CONFIG.autoHighlight && link.textContent === 'Reader mode') { return; }

        // TODO: Performance: skip nav links so they don't trigger db reads.

        let dontReload = false;
        let addClidkedLink = false;
        if (CONFIG.autoHighlight) {
          addClidkedLink = true;
        } else {
          // if (link.textContent === 'Reader mode') addClidkedLink = true;
          // if (link.textContent === 'View content') addClidkedLink = true;
          if (isTitle(link)) { addClidkedLink = true; dontReload = true; }
        }

        const url = link.href;
        const parsed = parseThreadLink(url)
        if (DEBUG) console.log('clicked parsed', parsed)

        if (addClidkedLink) {
          if (parsed.site) {
            const date = new Date().toISOString().slice(0, 19).replace(/[-:T\.]/g, '');

            // Do not update when clicking self-referential links (unless it is the title).
            const linkPointsToCurrentPage = (parsed.prefixedId === CURRENT.prefixedId);
            if (linkPointsToCurrentPage) {
              if (!isTitle(link)) { return }
            }

            // note: Storage_AddEntry does nothing if there is already an entry.
            wasAdded |= Storage_AddEntry(parsed.prefixedId, date);

            // If it's a Xenforo link, consider adding its name to the DB.
            if (parsed.site.xenforo) {

              /*
              1. Add links from the forum (they can be trusted)
              */
              if (
                TRUST_SITE_NAMES ||     // Forum links can be trusted
                linkPointsToCurrentPage // Link of the page we are on can be trusted (manual mode)
              ) {
                if (parsed.name) {
                  wasAdded |= Storage_AddEntry(parsed.name, date);
                }
              }
            }
          }
        }

        if (SITE_IS_THREAD && dontReload) { // reload on forum title click; we could disable titling there but I like clicking it
          event.preventDefault();
          applyLinkStyles(true);
        }
      }

      // handle Watch/Unwatch buttons: update title color
      if (IS_XENFORO) {
        // DLP:         <input type="submit" value="Watch Thread" class="button primary">  .tagName  === 'INPUT'
        // SB/SV/AH/QQ: <button type="submit" class="button--primary button"><span class="button-text">Watch</span></button>
        // Note: Even though <button> was clicked, if mouse hovered <span> then `even.target = span`.
        let button = event.target.matches('input[type="submit"], button[type="submit"], button[type="submit"] span') ? event.target : null;
        if (button) {
          let buttonText = button.value || button.textContent;
          if (buttonText) {
            if (/Watch/.test(buttonText)) {
              titleLink.classList.add('hl-watched');
            }
            else if (/Unwatch/.test(buttonText)) {
              titleLink.classList.remove('hl-watched');
            }
          }
        }
      }

    //   if (wasAdded) {
    //     event.preventDefault();
    //     event.stopPropagation();
    //     const link = event.target;
    //     setTimeout(() => {
    //       console.log('~~~~DELAY~~~~~', link.href)
    //       window.location.href = link.href;
    //     }, 1000);
    //   }
    // }, true); // Capture phase
             });

    document.dataClickListenerAdded = true;
  }

  // =========================== Callbacks ==============================

  // Apply styles when navigating back
  onBackForward(() => {
    syncConfig();
    applyLinkStyles(true);
  });

  // Apply styles on tab change.
  document.addEventListener('focus', () => { // focus in
    syncConfig();
    applyLinkStyles();
  });
  document.addEventListener("visibilitychange", () => { // alt-tab in
    if (!document.hidden) { // alt-tab in
      syncConfig();
      applyLinkStyles();
    }
  });

  // Apply styles on load
  applyLinkStyles(true);
});