Old Reddit highlighter + Live backup for Min_'s "AO3: kudosed seen history"

Addon to highlight seen/unseen/skipped AO3 links across reddit. Also keeps a separate seen/skipped backup that it will provide if it detects cookies were accidentally cleared.

您需要先安装一个扩展,例如 篡改猴Greasemonkey暴力猴,之后才能安装此脚本。

您需要先安装一个扩展,例如 篡改猴暴力猴,之后才能安装此脚本。

您需要先安装一个扩展,例如 篡改猴暴力猴,之后才能安装此脚本。

您需要先安装一个扩展,例如 篡改猴Userscripts ,之后才能安装此脚本。

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

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

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

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

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

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

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

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

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

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

// ==UserScript==
// @name        Old Reddit highlighter + Live backup for Min_'s "AO3: kudosed seen history"
// @description Addon to highlight seen/unseen/skipped AO3 links across reddit. Also keeps a separate seen/skipped backup that it will provide if it detects cookies were accidentally cleared.
// @namespace   https://greasyfork.org/users/1376767
// @author      C89sd
// @version     1.14
// @include     https://archiveofourown.org/*
// @include     https://old.reddit.com/favicon.ico
// @grant       GM_setValue
// @grant       GM_getValue
// @grant       GM_addStyle
// @grant       GM_setClipboard
// @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:
   - Anything 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) {
  { // !Security
    const ALLOWED_PARENT_DOMAINS = [
      'https://old.reddit.com',
      'https://archiveofourown.org',
    ];

    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.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 = window.location.href;
const IS_AO3 = url.startsWith('https://archiveofourown.org');
const IS_REDDIT = !IS_AO3;

let applyingForSecondTime = false; // skip removing classes 1rst time


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

  const workIdRegex = /\/works\/(\d+)/;
  const seriesRegex = /\/series\/\d/;
  // [el, workId, isSeries]
  const links = document.querySelectorAll('a[href*="archiveofourown.org/"]');
  for (const link of links) {
    const href = link.getAttribute('href');
    const match = workIdRegex.exec(href);
    if (match) {
      ao3LinksAndIds.push([link, match[1], false]);
    } else {
      if (seriesRegex.test(href)) {
        ao3LinksAndIds.push([link, null, 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.
  // Just override Storage.prototype.setItem.

  // Note: data loss can happend from a corrupted write, we must check old vs new at every write.
  // If loss is detected, we may trigger a second notice if the second key triggers.
  // This is bad because the notice overwrite the old value, so the next notice would serve an outdated backup.
  // We write a per-key lock flag to storage that gets read before triggering a notice.
  // The lock key is removed at every succcessfull write.
  const LOCK_PREFIX = 'khxr_lock_';

  function maybeAlertAndBackup(GMkey, newVal) {
    const oldVal = GM_getValue(GMkey, ',');
    const oldLength = oldVal.split(',').length;
    const newLength = newVal.split(',').length;

    if (oldLength > 30) { // This is an old key, it makes sense to check for data loss.

      const diff = newLength - oldLength;
      if (diff <= -30) // AO3 pages have ~20 posts. Even if you press the forget button, losing 30 while we monitor is unlikely!
      {
        // Key is locked, probably a repeat after data-loss. We already exported and overwrote half our data.
        if (localStorage.getItem(LOCK_PREFIX + GMkey)) {
          alert('⚠️ [userscript][Reddit highlighter-Kudosed history]\nData-loss detected in the second key "'+GMkey+'".\nIt was already exported in the previously offered backup.\n\nSkipping and overwriting.')
          return;
        }

        // This is the first key to encouter data loss, lock the other, the data is about to be corrupted by overwrite.
        const OPPOSITE = { seen: 'skipped', skipped: 'seen' };
        localStorage.setItem(LOCK_PREFIX + OPPOSITE[GMkey], '1');

        // Backup and warn user while we can.
        let tempKey = 'khxr_backup_'+new Date().toISOString().slice(0,19).replace(/[-:T]/g,'');
        const backup = JSON.stringify({seen: GM_getValue('seen', ','), skipped: GM_getValue('skipped', ',')});
        localStorage.setItem(tempKey, backup);
        GM_setClipboard(backup, "text");
        console.warn(backup)
        alert('⚠️⚠️⚠️⚠️⚠️⚠️⚠️⚠️⚠️⚠️⚠️⚠️⚠️⚠️⚠️\n[userscript][Reddit highlighter-Kudosed history]\n     ❌❌ 𝐃𝐀𝐓𝐀 𝐋𝐎𝐒𝐒 𝐃𝐄𝐓𝐄𝐂𝐓𝐄𝐃! ❌❌ \n\n(' + (-diff) + ') "' + GMkey + '" fics have disappeared from your "AO3 Kudosed and Seen History" _outside_ of this script\'s live monitoring.\n\n🗑️ Were your cookies cleared and your seen/skipped data deleted?\n\n🛟 Backups have juste been made to:\n -  🧾 The devtools error log of this page.\n -  💾 AO3 localStorage "' + tempKey + '"\n -  📋 and pasted to your clipboard in the "Import your lists" settings format used by Kudosed&SeenHistory v2.2.1.\n\nStorage has been overwritten with new values, message will not repeat.\nNote: maybe this script is outdated and does not recognise a newer version\'s data, do you own checks.');
      }
      else
      { // Value matches our knowlegde, we can trust it again, unlock this key.
        localStorage.removeItem(LOCK_PREFIX + GMkey);
      }
    }
  }

  // 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')    {
        maybeAlertAndBackup('seen', value);
        GM_setValue('seen', value);
        if (DEBUG) console.log('interecpted seen[:100],', value.slice(0, 100));
      }
      if (key === 'kudoshistory_skipped') {
        maybeAlertAndBackup('skipped', value);
        GM_setValue('skipped', value);
      }
    }
    // Call the original method
    return originalSetItem.call(this, key, value);
  };
  return;
}

// --------------------- REDDIT

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

  // fetch up to date seen list
  if (iframe && !iframeReady) {
    if (DEBUG) 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, RETRY_DELAY);
    return
  }
  retryCount = 0;

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

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

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

    const DM=+getComputedStyle(document.querySelector('.md')).color.match(/\d+/)[0]>128;
    if (DM) GM_addStyle(`
      .khxr-work    {color: rgb(217, 101, 76) !important; text-decoration: underline !important; }
      .khxr-skipped {color: rgb(167, 155, 93) !important; text-decoration: dashed underline !important;}
      .khxr-seen    {color: rgb(91, 174, 93) !important; text-decoration: dashed underline !important;}
      .khxr-series::before {content: "⧉"; margin-right: 0.3em; text-decoration: none !important; }
    `);
    else GM_addStyle(`
      .khxr-work    {color: rgb(201, 38,  4) !important; text-decoration: underline !important; }
      .khxr-skipped {color: rgb(131, 110, 0) !important; text-decoration: dashed underline !important;}
      .khxr-seen    {color: rgb(39, 145, 41) !important; text-decoration: dashed underline !important;}
      .khxr-series::before {content: "⧉"; margin-right: 0.3em; text-decoration: none !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;
}