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 Reddit subs.

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

// ==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.13
// @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');

// console.log(`isFFN: ${isFFN}, isHPF: ${isHPF}, isPAT: ${isPAT} | isSB: ${isSB}, isSV: ${isSV}, isQQ: ${isQQ}, isAH: ${isAH}, isDLP: ${isDLP}, isFFN: ${isFFN} | isREDDIT: ${isREDDIT}`);

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

  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 = 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 highlight
        if (visitedLinks[threadName]) { link.style.color = highlightColor; }

        // watched highlight
        if (isSB || isSV || isAH || isQQ || isDLP) {
          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: update title color
        if (isSB || isSV || isAH || isQQ || isDLP) {
          // 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 = highlightYellowColor;
              }
              else 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();
    }
  });
})();