Reddit highlighter for Min_'s "AO3: kudosed seen history"

Addon to highlight AO3 links marked-as-seen via Min_'s "AO3: Kudosed and seen history" across all subreddits (old&new).

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

// ==UserScript==
// @name        Reddit highlighter for Min_'s "AO3: kudosed seen history"
// @description Addon to highlight AO3 links marked-as-seen via Min_'s "AO3: Kudosed and seen history" across all subreddits (old&new).
// @namespace   https://gf.qytechs.cn/users/1376767
// @author      C89sd
// @version     1.1
// @include     https://archiveofourown.org/*
// @include     https://old.reddit.com/favicon.ico
// @grant       GM_setValue
// @grant       GM_getValue
// @grant       GM_addStyle
// @run-at      document-start
// @include /^https:\/\/(?:old\.)?reddit\.com\/r\/[^\/]*\/comments\//
// ==/UserScript==

/* To run only on a few subs: replace the @include above with the one below and customise it.
// @include /^https:\/\/(?:old\.)?reddit\.com\/r\/(?:AO3|HP|masseffect|TheCitadel|[^\/]*?(?:[Ff]an[Ff]ic|[Hh]ero))[^\/]*\/comments\//

   This matches:
   - Anyhting starting with (?:AO3|HP|masseffect|TheCitadel)
   - Anything containing (?:FanFic|Fanfic|fanfic|fanFic|Hero|hero) // note: case-insensitive is not supported and must be done manually.
*/


'use strict';
// =====================================================================
// Navback-safe GM get/set
// =====================================================================
const DEBUG = false;
// -------------------------------------- Iframe
if (window.self !== window.top) {
  if (DEBUG) console.log('IFRAME CREATED!');

  unsafeWindow.top.GMproxy3 = {
    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 dontBotherReloadingThereAreNoLinks = false;

const cleanupCtrl = new AbortController();
const cleanupSig = cleanupCtrl.signal;

// ------------

let GMproxy3 = {}
let iframe = null;
let iframeReady = false;

const _setValue = GM_setValue;
const _getValue = GM_getValue;
GM_setValue = (key, val) => {
  if (iframe) {
    if (iframeReady) return GMproxy3.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 GMproxy3.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 (DEBUG) console.log('pageshow persisted=', e.persisted);
  if (e.persisted && !dontBotherReloadingThereAreNoLinks) {
    const oldIframe = document.getElementById('gmproxy3');
    if (oldIframe) oldIframe.remove();

    iframeReady = false;
    iframe = document.createElement('iframe');
    iframe.id = 'gmproxy3';
    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') {
        GMproxy3 = unsafeWindow.GMproxy3;
        iframeReady = true;
        controller.abort();
        if (DEBUG) console.log('Iframe message received. GMproxy3=', GMproxy3);
        if (DEBUG) console.log('Running onBackForward fns=', backForwardQueue);
        backForwardQueue.forEach((fn) => { fn() });
      }
    };
    window.addEventListener('message', onMsg, { signal: controller.signal });
    window.addEventListener('pagehide', onHide, { signal: controller.signal });
  }
}, { cleanupSig })

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

// =====================================================================
// Main
// =====================================================================

const url = document.URL;
const IS_AO3 = url.startsWith('https://archiveofourown.org');
const IS_REDDIT = !IS_AO3; // old/new


const workIdRegex = /^https:\/\/archiveofourown\.org.*?\/works\/(\d+)/;
const seriesRegex = /^https:\/\/archiveofourown\.org.*?\/series\/\d+/;
let applyingForSecondTime = false; // skip removing classes 1rst time


// Find all highlightable links on ao3 and reddit
let ao3LinksAndIds = null;
document.addEventListener("DOMContentLoaded", () => {
  if (DEBUG) console.log('DOMContentLoaded getting links');

  if (ao3LinksAndIds === null) {
    ao3LinksAndIds = [];

    // [el, workId, isSeries]
    for (const link of document.querySelectorAll('a[href^="https://arc"]')) { // 0.07ms down from 1.51ms over 5000 comments !
      const match = workIdRegex.exec(link.href);
      if (match) {
        const workId = match[1];
        ao3LinksAndIds.push([link, workId, false]);
      } else {
        if (seriesRegex.test(link.href)) {
          ao3LinksAndIds.push([link, 0, true]);
        }
      }
    }
    if (ao3LinksAndIds.length === 0) {
      if (DEBUG) console.log('DOMContentLoaded 0 links, abort!');
      dontBotherReloadingThereAreNoLinks = true;
      cleanupCtrl.abort();
    }
    else {
      if (DEBUG) console.log('DOMContentLoaded links=', ao3LinksAndIds);
    }
  }
}, { cleanupSig });


if (IS_AO3) {
  if (DEBUG) console.log('AO3 PATH');

  // Note: 'storage' events come from other tabs only.
  // Since the script is by definition in every AO3 tab, we don't need it.

  // Override Storage.prototype.setItem
  const originalSetItem = Storage.prototype.setItem;
  Storage.prototype.setItem = function(key, value) {
    if (DEBUG) console.log('setItem() intercepted,length:', key, value.length);
    if (iframe && !iframeReady) {
      console.warn(`AO3 Exporter: iframe && !iframeReady, skipped change`);
    } else {
      if (key === 'kudoshistory_seen')    { GM_setValue('seen', value); if (DEBUG) console.log('interecpted seen[:100],', value.slice(0, 100)); }
      if (key === 'kudoshistory_skipped') GM_setValue('skipped', value);
    }
    // Call the original method
    return originalSetItem.call(this, key, value);
  };
  return;
}


if (IS_REDDIT) {
    if (DEBUG) console.log('REDDIT PATH');

  // note: nested for early exit?
  // Apply styles on load
  document.addEventListener("DOMContentLoaded", () => {
    if (DEBUG) console.log('updateHighlight() DOMContentLoaded');

    GM_addStyle('.khxr-skipped {color: rgb(165, 149, 110) !important;}' +
                '.khxr-seen {color: rgb(194, 121, 227) !important;}' +
                '.khxr-series::before {content: "⧉"; margin-right: 0.3em }' + // ⁂
                '.khxr-work {text-decoration: underline !important; text-decoration-style: wavy !important; }');

    updateHighlight();

    // Apply styles when navigating back
    onBackForward(() => {
      if (DEBUG) console.log('updateHighlight() onBackForward');
      updateHighlight();
    });

    // Apply styles on tab change.
    document.addEventListener('focus', () => { // focus in
      if (DEBUG) console.log('updateHighlight() focus');
      updateHighlight();
    }, { cleanupSig });
    document.addEventListener("visibilitychange", () => { // alt-tab in
      if (!document.hidden) {
        if (DEBUG) console.log('updateHighlight() visibilitychange');
        updateHighlight();
      }
    }, { cleanupSig });

  }, { cleanupSig });

  return;
}

const MAX_RETRIES = 20
let retryCount    = 0
function updateHighlight() {
  if (dontBotherReloadingThereAreNoLinks) return;

  // fetch up to date seen list
  if (iframe && !iframeReady) {
    console.warn(`AO3 Exporter: iframe && !iframeReady, retries=`, retryCount);
    if (retryCount >= MAX_RETRIES) {
      console.error(`iframe not ready after ${MAX_RETRIES} attempts`);
      retryCount = 0
      return
    }
    retryCount++;
    setTimeout(updateHighlight, 100);
    return
  }

  const seen    = GM_getValue('seen',    '');
  const skipped = GM_getValue('skipped', '');

  function isInList(list, workId) { return list.indexOf(',' + workId + ',') > -1; }

  if (DEBUG) console.log('highlight seen[:100],', seen.slice(0, 100));

  if (DEBUG) console.log('... doing updateHighlight(', ao3LinksAndIds.length ,')');
  for (const [link, id, isSeries] of ao3LinksAndIds) {
    if (isSeries) {
      link.classList.add('khxr-series');
    } else {
      link.classList.add('khxr-work');

      if (applyingForSecondTime) link.classList.remove('khxr-seen', 'khxr-skipped');

      if (isInList(seen, id))    link.classList.add('khxr-seen');
      if (isInList(skipped, id)) link.classList.add('khxr-skipped');
    }
  }
  applyingForSecondTime = true;
}

QingJ © 2025

镜像随时可能失效,请加Q群300939539或关注我们的公众号极客氢云获取最新地址