Greasy Fork

Highlight visited fanfics AH/DLP/QQ/SB/SV/FFN/HPF/ORED

Track and highlight visited and watched* fanfiction links/threads across the following sites: AlternateHistory*, DarkLordPotter*, QuestionableQuesting*, SpaceBattles*, SufficientVelocity*, FanFiction, HPFanfiction, and a few old subreddits.

目前为 2025-03-15 提交的版本。查看 最新版本

// ==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 subreddits.
// @author C89sd
// @version 1.17
// @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==

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

// 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, message2, duration = 20000) { // 20 sec toasts
  toast.innerHTML = ''; // Clear previous content

  function processMessage(msg) {
    if (!msg) return false; // Skip if message is null or empty

    for (const site of sites) {
      const { prefix, toastUrlPrefix, toastUrlSuffix } = site;
      if (prefix && msg.startsWith(prefix)) {
        assert(toastUrlPrefix, `_showToast(): Missing toastUrlPrefix for site: ${site.domain}`);

        const id = msg.substring(prefix.length);
        const toastUrl = toastUrlPrefix + id + (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);

        return true;
      }
    }

    // If no match, add plain text
    const textNode = document.createTextNode(`removed "${msg}"`);
    toast.appendChild(textNode);
    return false;
  }

  let matched1 = processMessage(message);
  toast.appendChild(document.createElement('br'));
  let matched2 = processMessage(message2);

  // Display logic
  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, message2) {
  localStorage.setItem('toastMessage', JSON.stringify([message, message2]));
}
function showToastOnPageLoad() {
  const storedToast = localStorage.getItem('toastMessage');
  if (storedToast) {
    const [message, message2] = JSON.parse(storedToast);
    _showToast(message, message2);
    localStorage.removeItem('toastMessage');
  }
}
window.addEventListener('load', showToastOnPageLoad);

// ---

const sites = [
  {
    domain: 'fanfiction.net',
    prefix: 'ffn_',
    toastUrlPrefix: 'https://m.fanfiction.net/s/',
    extract: (url) => (url.match(/https?:\/\/[^\/]*fanfiction\.net\/s\/(\d+)/)?.[1] || null),
  },
  {
    domain: 'hpfanfiction.org',
    prefix: 'hpf_',
    toastUrlPrefix: 'https://www.hpfanfiction.org/fr/viewstory.php?sid=',
    extract: (url) => (url.match(/https?:\/\/[^\/]*hpfanfiction\.org\/fr\/viewstory\.php\?.*?sid=(\d+)/)?.[1] || null),
  },
  {
    domain: 'patronuscharm.net',
    prefix: 'pat_',
    toastUrlPrefix: 'https://www.patronuscharm.net/s/',
    toastUrlSuffix: '/1/',
    extract: (url) => (url.match(/https?:\/\/[^\/]*patronuscharm\.net\/s\/(\d+)/)?.[1] || null),
  },
  {
    domain: 'spacebattles.com',
    prefix: 'xsb_',
    toastUrlPrefix: 'https://forums.spacebattles.com/threads/',
    extract: (url) => (url.match(/https?:\/\/[^\/]*spacebattles\.com.*?\/threads\/[^\/]*\.(\d+)/)?.[1] || null),
    xenforo: true
  },
  {
    domain: 'sufficientvelocity.com',
    prefix: 'xsv_',
    toastUrlPrefix: 'https://forums.sufficientvelocity.com/threads/',
    extract: (url) => (url.match(/https?:\/\/[^\/]*sufficientvelocity\.com.*?\/threads\/[^\/]*\.(\d+)/)?.[1] || null),
    xenforo: true
  },
  {
    domain: 'questionablequesting.com',
    prefix: 'xqq_',
    toastUrlPrefix: 'https://forum.questionablequesting.com/threads/',
    extract: (url) => (url.match(/https?:\/\/[^\/]*questionablequesting\.com.*?\/threads\/[^\/]*\.(\d+)/)?.[1] || null),
    xenforo: true
  },
  {
    domain: 'alternatehistory.com',
    prefix: 'xah_',
    toastUrlPrefix: 'https://www.alternatehistory.com/forum/threads/',
    extract: (url) => (url.match(/https?:\/\/[^\/]*alternatehistory\.com.*?\/threads\/[^\/]*\.(\d+)/)?.[1] || null),
    xenforo: true
  },
  {
    domain: 'darklordpotter.net',
    prefix: 'xdl_',
    toastUrlPrefix: 'https://forums.darklordpotter.net/threads/',
    extract: (url) => (url.match(/https?:\/\/[^\/]*darklordpotter\.net.*?\/threads\/[^\/]*\.(\d+)/)?.[1] || null),
    xenforo: true
  }
];

sites.forEach(site => {
  site.test_domain = (url) => new RegExp(`^https?:\/\/[^\/]*${site.domain}\/`).test(url);
});

const DOMAIN = window.location.hostname;

const [IS_FFN, IS_HPF, IS_PAT, IS_SB, IS_SV, IS_QQ, IS_AH, IS_DLP] = sites.map(site => DOMAIN.includes(site.domain));
const IS_REDDIT = DOMAIN.includes('reddit.com');


const SITE = sites.find(site => DOMAIN.includes(site.domain)) || null; // `sites` entry of the current page.
function maybeGetSite(url) {
  // Optimisation: most links are pointing to the current page, try it before scanning all the site.
  if (SITE?.test_domain(url)) {
    return SITE.extract(url) ? SITE : null;
  } else {
    return sites.find(site => site.test_domain(url) && site.extract(url)) || null;
  }
}
function maybeGetPrefixedThreadId(site, url) {
  const extractedId = site.extract(url);
  if (!extractedId) {
    return null;
  }
  return site.prefix + extractedId;
}
const SITE_IS_THREAD = SITE ? Boolean(maybeGetPrefixedThreadId(SITE, window.location.href)) : null; // Thread(story) vs Forum/Search page.
const SITE_PREFIXED_THREAD_ID = SITE ? (maybeGetPrefixedThreadId(SITE, window.location.href) || "~/~") : "~/~"; // Can be used to lookup the DB, "~/~" will never match.

const IS_XENFORO = IS_SB || IS_SV || IS_QQ || IS_AH || IS_DLP;
// Old function to extract thread name instead of ID (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 getDefaultAnchorColor = () => {
    const link = document.createElement("a");
    const color = getComputedStyle(link).color;
    document.body.appendChild(link);
    document.body.removeChild(link);
    return color || "white";
};

const purpleHighlightColor =
    IS_SB ? 'rgb(223, 166, 255)' :
    IS_DLP ? 'rgb(183, 128, 215)' :
    IS_SV ? 'rgb(177, 126, 212)' :
    (IS_FFN || IS_HPF) ? 'rgb(135, 15, 135)' :
    IS_REDDIT ? 'rgb(187, 131, 216)' :
    'rgb(119, 69, 150)'; // IS_AH || IS_QQ

const pinkHighlightColor = 'rgb(200, 105, 129)';
    // IS_SB ? 'rgb(178, 166, 255)' :
    // IS_DLP ? 'rgb(128, 160, 215)' :
    // IS_SV ? 'rgb(200, 105, 129)' :
    // 'rgb(69, 100, 150)'; // IS_AH || IS_QQ

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


// 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}`);
  }
}

function Storage_AddEntry(key, val) {
  if (!key) { return; } // do not store null
  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 = '';
  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}`);
    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 = IS_DLP ? 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 = 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');

  const titleLink = document.createElement('a');
  titleLink.href = window.location.href;
  if (firstH1) {
    const title = firstH1.lastChild ? firstH1.lastChild : firstH1;
    if (title) {
      const titleClone = title.cloneNode(true);
      titleLink.appendChild(titleClone);
      title.parentNode.replaceChild(titleLink, title);
    }
  }

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

  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");

    // const start = Date.now();
    for (let link of links) {
      const url = link.href;
      const site = maybeGetSite(url);
      if (site) {
        const prefixedId = maybeGetPrefixedThreadId(site, url);
        if (prefixedId) {
          // Skip self referential story links, unless they are the one and only Title Link.
          const linkPointsToCurrentPage = (prefixedId == SITE_PREFIXED_THREAD_ID);
          if (linkPointsToCurrentPage && !firstH1.contains(link)) {
              continue;
          }

          // Hihlight seen links.
          if (visitedLinks[prefixedId]) {
            link.style.color = purpleHighlightColor;
          }
          else {
            if (IS_XENFORO) {
              if (visitedLinks[extractThreadName(url)]) {
                // 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.style.color = pinkHighlightColor;
              }
            }
          }

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

            if (SITE_IS_THREAD) {
              // In Story threads, the only link to highlight is the Title Link.
              if (linkPointsToCurrentPage) { isWatched = threadHasWatchedButton; }
            }
            else {
              // In Forum view, check the bell/eye icon next to the link.
              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.style.color = yellowHighlightColor;
            }
          }
        }
      }
    }
    // const end = Date.now();
    // console.log(`Execution time: ${end - start} ms`);

    // 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.

          const site = maybeGetSite(link.href);
          if (site) {
            // TODO: If threadName exists in the DB, remove and reinsert it so that the 2 dates match, to regroup IDs and names, though old date would be lost.

            const date = new Date().toISOString().slice(0, 19).replace(/[-:T\.]/g, '');
            const prefixedId = maybeGetPrefixedThreadId(site, link.href);
            Storage_AddEntry(prefixedId, date);

            if (site.xenforo) {
              const threadName = extractThreadName(link.href);
              Storage_AddEntry(threadName, date);
            }
          }
        }

        // 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.style.color = yellowHighlightColor;
              }
              else if (/Unwatch/.test(buttonText)) {
                if (visitedLinks[SITE_PREFIXED_THREAD_ID]){
                  titleLink.style.color = purpleHighlightColor;
                } else if (visitedLinks[extractThreadName(window.location.href)]) {
                  titleLink.style.color = pinkHighlightColor;
                } else {
                    titleLink.style.color = getDefaultAnchorColor();
                }
              }
            }
          }
        }

      });
      document.dataClickListenerAdded = true;
    }
  };

  // Apply styles on load
  applyLinkStyles();
  // Apply styles when navigating back
  window.addEventListener('pageshow', (event) => {
    if (event.persisted) {
      applyLinkStyles();
    }
  });
})();