您需要先安装一个扩展,例如 篡改猴、Greasemonkey 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 Userscripts ,之后才能安装此脚本。
您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey,才能安装此脚本。
您需要先安装用户脚本管理器扩展后才能安装此脚本。
Addon to highlight AO3 marked-as-seen link across old Reddit. Must keep a separate seen/skipped backup that it will let you download if >30 entries disappear (cleared cookies).
当前为
// ==UserScript== // @name Old Reddit highlighter + Live backup for Min_'s "AO3: kudosed seen history" // @description Addon to highlight AO3 marked-as-seen link across old Reddit. Must keep a separate seen/skipped backup that it will let you download if >30 entries disappear (cleared cookies). // @namespace https://gf.qytechs.cn/users/1376767 // @author C89sd // @version 1.6 // @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== const RED_GREEN_COLORS = true; // true; /* 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 = document.URL; const IS_AO3 = url.startsWith('https://archiveofourown.org'); const IS_REDDIT = !IS_AO3; 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. // 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) { 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'); // note: nested for early exit? // Apply styles on load document.addEventListener("DOMContentLoaded", () => { if (DEBUG) console.log('updateHighlight() DOMContentLoaded'); if (RED_GREEN_COLORS) { const DM = window.getComputedStyle(document.body).color.match(/\d+/g)[0] > 128; if (DM) GM_addStyle( '.khxr-work {color: rgb(255, 110, 72) !important; text-decoration: underline !important; text-decoration-style: dashed !important; }' + '.khxr-skipped {color: rgb(240, 172, 16) !important;}' + '.khxr-seen {color: rgb(28, 211, 97) !important;}' + '.khxr-series::before {content: "⧉"; margin-right: 0.3em }' // ⁂ ); else GM_addStyle( '.khxr-work {color: rgb(230, 86, 61) !important; text-decoration: underline !important; text-decoration-style: dashed !important; }' + '.khxr-skipped {color: rgb(202, 136, 38) !important;}' + '.khxr-seen {color: rgb(0, 178, 81) !important;}' + '.khxr-series::before {content: "⧉"; margin-right: 0.3em }' // ⁂ ); } else GM_addStyle( '.khxr-work {text-decoration: underline !important; }' + '.khxr-skipped {color: rgb(165, 149, 110) !important;}' + '.khxr-seen {color: rgb(194, 121, 227) !important;}' + '.khxr-series::before {content: "⧉"; margin-right: 0.3em }' // ⁂ ); 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; }
QingJ © 2025
镜像随时可能失效,请加Q群300939539或关注我们的公众号极客氢云获取最新地址