您需要先安装一个扩展,例如 篡改猴、Greasemonkey 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 Userscripts ,之后才能安装此脚本。
您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey,才能安装此脚本。
您需要先安装用户脚本管理器扩展后才能安装此脚本。
Add Export/Import history to TXT buttons at the bottom of the page. │ Fix back-navigation not being collapsed. │ Color and rename the confusing Seen/Unseen buttons. │ Enhance the title. │ Fix "Mark as seen on open" triggering on external links. ║ Standalone feature: Light/Dark site skin toggle button.
当前为
// ==UserScript== // @name Extend "AO3: Kudosed and seen history" | Export/Import + Standalone Light/Dark mode toggle // @description Add Export/Import history to TXT buttons at the bottom of the page. │ Fix back-navigation not being collapsed. │ Color and rename the confusing Seen/Unseen buttons. │ Enhance the title. │ Fix "Mark as seen on open" triggering on external links. ║ Standalone feature: Light/Dark site skin toggle button. // @author C89sd // @version 1.27 // @match https://archiveofourown.org/* // @grant GM_xmlhttpRequest // @namespace https://greasyfork.org/users/1376767 // ==/UserScript== const ENHANCED_SEEN_BUTTON = true; // Seen button is colored and renamed / Immediately mark seen / Blink when navigating back const COLORED_TITLE_LINK = true; // Title becomes a colored link const ENHANCED_MARK_SEEN_ON_OPEN = true; // Enable improved "Mark seen on open" feature with a distinction between SEEN Now and Old SEEN const IGNORE_EXTERNAL_LINKS = true; // Mark as seen when a link is clicked on AO3 but not from other sites (e.g. reddit). If false, 'New SEEN' will tell you if it was a new or old link. const SITE_SKINS = [ "Default", "Reversi" ]; // A fic is marked as seen after loading not when the title is clicked. // Thus the AO3 script requires a manual reload to refresh after a link is clicked: // - Clicked fics are not collpased and when navigating back // - Seen changes made from the other page are not taken into account. // To fix this: // 1. When unloading a tab, store the current fic id and the seen sate. // Update this state every time the seen button is clicked. // Upon back navigation, check this data, try to find the fic link on-screen, and toggle its collapsed status. // Note: This is cheaper than reloading the page. // 2. Intercept clicks on links and manually trigger the 'seen' button for immediate collapse. let currentSeenState = null; // Updated down if (ENHANCED_SEEN_BUTTON) { const isWork = /^https:\/\/archiveofourown\.org(?:\/collections\/[^\/]+)?(\/works\/\d+)/ let state = {}; window.addEventListener("pagehide", function (event) { // Note: Doing this in 'unload'(desktop) or 'beforeunload'(mobile) caused 'event.persisted' to be false. const match = window.location.href.match(isWork); if (match) { localStorage.setItem("C89AO3_state", JSON.stringify({"ref": match[1], "state": currentSeenState})); } else { localStorage.setItem("C89AO3_state", '{}'); } }); window.addEventListener("pageshow", function (event) { // On page load/back navigation/etc. let data = localStorage.getItem("C89AO3_state"); state = JSON.parse(data ? data : '{}'); console.log(data, ', persisted=', event.persisted) }); function flashFilter(element) { element.style.transition = 'filter 250ms ease-in-out'; element.style.filter = 'brightness(0.8)'; setTimeout(() => { element.style.filter = 'brightness(1)'; setTimeout(() => { element.style.transition = ''; element.style.filter = ''; }, 250); }, 250); } function flashBg(element) { element.style.transition = 'background-color 250ms ease-in-out'; element.style.backgroundColor = 'rgba(169, 169, 169, 0.2)'; setTimeout(() => { element.style.backgroundColor = 'transparent'; setTimeout(() => { element.style.transition = ''; element.style.backgroundColor = ''; }, 250); }, 250); } window.addEventListener('pageshow', (event) => { if (event.persisted) { // If we navigated back. if (state?.ref) { // If we read a fic id from localStorage. // Try finding the link of the fic we navigated back from and toggle its parent visibility. // Note: use *= because there can be: '.com/works/123' or '.com/collections/u1/works/132' or ?foo at the end. const titleLink = document.querySelector(`h4.heading > a[href*="${state.ref}"]`); if (titleLink) { const article = titleLink.closest('li[role="article"]'); if (article) { if ( state?.state === true && !article.classList.contains('marked-seen')) { article.classList.add('marked-seen'); } else if (state?.state === false && article.classList.contains('marked-seen')) { article.classList.remove('marked-seen'); } if (article.classList.contains('marked-seen')) { flashFilter(article); // Can't override the bg so use a filter; the AO3 script adds an !important already. } else { flashBg(article); } } } } } }); } // 2. Intercept all clicks. If a title was clicked, manually dispatch the "seen" button before opening the fic. document.addEventListener('click', function(event) { const titleLink = event.target.closest('h4.heading > a'); if (titleLink) { const article = titleLink.closest('li[role="article"]'); if (article) { const seenButton = article.querySelector('div.kh-toggles>a') if (seenButton) { // Click the seen button (unless the fic is collapsed - that would unmark it!). if (!article.classList.contains('marked-seen')) { seenButton.click() } // Give that "seen" action time to execute before loading the page. event.preventDefault(); setTimeout(function() { window.location.href = titleLink.href; }, 100); } } } }); // GET the preferences form, find the current skin_id, and POST the next skin_id. function getPreferencesForm(user) { // GET the preferences fetch(`https://archiveofourown.org/users/${user}/preferences`, { method: 'GET', headers: { 'Content-Type': 'text/html' } }) .then(response => response.text()) .then(responseText => { const doc = new DOMParser().parseFromString(responseText, 'text/html'); // Extract the authenticity token const authenticity_token = doc.querySelector('input[name="authenticity_token"]')?.value; if (authenticity_token) { // console.log('authenticity_token: ', authenticity_token); // Log the token } else { alert('[userscript:Extend AO3] Error\n[authenticity_token] not found!'); return; } // Find the <form class="edit_preference"> const form = doc.querySelector('form.edit_preference'); if (form) { // console.log('Form:', form); // Log the form // Extract the action URL for the form submission const formAction = form.getAttribute('action'); // console.log('Form Action:', formAction); // Find the <select id="preference_skin_id"> list const skinSelect = form.querySelector('#preference_skin_id'); if (skinSelect) { // console.log('Found skin_id <select> element:', skinSelect); // Log the select const workSkinIds = []; let currentSkinId = null; let unmatchedSkins = [...SITE_SKINS]; // Loop through the <option value="skinId">skinName</option> const options = skinSelect.querySelectorAll('option'); options.forEach(option => { const optionValue = option.value; const optionText = option.textContent.trim(); if (SITE_SKINS.includes(optionText)) { // console.log('- option: value=', optionValue, ", text=", optionText, option.selected ? "SELECTED" : "."); workSkinIds.push(optionValue); // Remove matched name from unmatchedSkins unmatchedSkins = unmatchedSkins.filter(name => name !== optionText); if (option.selected) { // <option selected="selected"> is the current one currentSkinId = optionValue; } } }); // console.log('SKINS: ', SITE_SKINS, ", workSkinIds: ", workSkinIds); // Alert if any SITE_SKINS was not matched to an ID if (unmatchedSkins.length > 0) { alert("ERROR.\nThe following skins were not found in the list under 'My Preferences > Your site skin'. Please check for spelling mistakes:\n[" + unmatchedSkins.join(", ") + "]\nThey will be skipped for now."); } // Cycle the ids: find the current ID in the list and pick the next modulo the array length if (workSkinIds.length > 0) { let nextSkinId = null; let currentIndex = workSkinIds.indexOf(currentSkinId); if (currentSkinId === null || currentIndex === -1) { // If currentSkinId is null or not found, select the first workSkinId nextSkinId = workSkinIds[0]; alert("Current skin was not in list, first skin \"" + SITE_SKINS[0] + "\" will be applied.") } else { let nextIndex = (currentIndex + 1) % workSkinIds.length; nextSkinId = workSkinIds[nextIndex]; } // console.log('Next skin ID:', nextSkinId); // ------ POST settings update // NOTE: This triggers mutiple redirects ending in 404 .. but it works ! // so we manualy handle and reload the page at the first redirect instead. // // This approach is way simpler but I did not find how to get the current selected skin id, and I need it to decide the next skin to use. This approach seems to not update the settings, but maybe the id can be found on the page? // fetch(`https://archiveofourown.org/skins/${nextSkinId}/set`, { // credentials: 'include' // }) // .then(() => window.location.reload()) // .catch(error => { // console.error('Error setting the skin:', error); // alert('[userscript:Extend AO3] Error\nError setting the skin: ' + error); // }); const formData = new URLSearchParams(); formData.append('_method', 'patch'); formData.append('authenticity_token', authenticity_token); formData.append('preference[skin_id]', nextSkinId); formData.append('commit', 'Update'); // Ensure the commit button is also included fetch(formAction, { method: 'POST', body: formData.toString(), headers: { 'Content-Type': 'application/x-www-form-urlencoded', 'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8', //'application/json', 'Sec-Fetch-Dest': 'document', 'Sec-Fetch-Mode': 'navigate', 'Sec-Fetch-Site': 'same-origin', 'Sec-Fetch-User': '?1', 'Upgrade-Insecure-Requests': '1', 'Referer': `https://archiveofourown.org/users/${user}/preferences` }, credentials: 'include', redirect: 'manual' // Prevents automatic redirect handling }) .then(response => { // If there is a redirect, response will have status code 3xx if (response.type === 'opaqueredirect') { // console.log('Redirect blocked, handling manually.'); window.location.reload(); // reload the page return; } else { return response.text(); } }) .then(responseText => { // console.log('Form submitted successfully:', responseText); window.location.reload(); // reload the page return; }) .catch(error => { console.error('Error submitting the form:', error); alert('[userscript:Extend AO3] Error\nError submitting the form: ' + error); }); } } else { alert('[userscript:Extend AO3] Error\nNo <select> element with id="preference_skin_id" found in the form'); } } else { alert('[userscript:Extend AO3] Error\nNo form found with class "edit_preference"'); } }) .catch(error => { alert('[userscript:Extend AO3] Error\nError fetching preferences form: ' + error); }); } // Button callback function toggleLightDark() { const greetingElement = document.querySelector('#greeting a'); if (!greetingElement) { alert('[userscript:Extend AO3] Error\nUsername not found in top right corner "Hi, user!"'); return; } const user = greetingElement.href.split('/').pop(); getPreferencesForm(user); } (function() { '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.classList.add('footer'); var firstH1link = null; if (ENHANCED_SEEN_BUTTON && COLORED_TITLE_LINK) { // Turn title into a link const firstH1 = document.querySelector('h2.title.heading'); if (firstH1) { const title = firstH1.lastChild ? firstH1.lastChild : firstH1; const titleLink = document.createElement('a'); titleLink.href = window.location.origin + window.location.pathname + window.location.search; // Keeps "?view_full_work=true", drops "#summary" if (title) { const titleClone = title.cloneNode(true); titleLink.appendChild(titleClone); title.parentNode.replaceChild(titleLink, title); } firstH1link = titleLink; } } const BTN_1 = ['button']; const BTN_2 = ['button', 'button--link']; // Create Light/Dark Button const lightDarkButton = document.createElement('button'); lightDarkButton.textContent = 'Light/Dark'; lightDarkButton.classList.add(...['button']); lightDarkButton.addEventListener('click', toggleLightDark); footer.appendChild(lightDarkButton); // Create Export Button const exportButton = document.createElement('button'); exportButton.textContent = 'Export'; exportButton.classList.add(...BTN_1); exportButton.addEventListener('click', exportToJson); footer.appendChild(exportButton); // Create Import Button const importButton = document.createElement('button'); importButton.textContent = 'Import'; importButton.classList.add(...BTN_1); // Create hidden file input const fileInput = document.createElement('input'); fileInput.type = 'file'; fileInput.accept = '.txt, .json'; fileInput.style.display = 'none'; // Hide the input element // Trigger file input on "Restore" button click importButton.addEventListener('click', () => { fileInput.click(); // Open the file dialog when the button is clicked }); // Listen for file selection and handle the import fileInput.addEventListener('change', importFromJson); footer.appendChild(importButton); // Append footer to the page const ao3Footer = document.querySelector('body > div > div#footer'); if (ao3Footer) { ao3Footer.insertAdjacentElement('beforebegin', footer); } else { document.body.appendChild(footer); } const strip = /^\[?,?|,?\]?$/g; // Export function function exportToJson() { const export_lists = { username: localStorage.getItem('kudoshistory_username'), settings: localStorage.getItem('kudoshistory_settings'), kudosed: localStorage.getItem('kudoshistory_kudosed') || ',', bookmarked: localStorage.getItem('kudoshistory_bookmarked') || ',', skipped: localStorage.getItem('kudoshistory_skipped') || ',', seen: localStorage.getItem('kudoshistory_seen') || ',', checked: localStorage.getItem('kudoshistory_checked') || ',' }; 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 username = export_lists.username || "none"; var size = ['kudosed', 'bookmarked', 'skipped', 'seen', 'checked'] .map(key => (String(export_lists[key]) || '').replace(strip, '').split(',').length); var textToSave = JSON.stringify(export_lists, null, 2); var blob = new Blob([textToSave], { type: "text/plain" }); var a = document.createElement('a'); a.href = URL.createObjectURL(blob); a.download = `AO3_history_${year}_${month}_${day}_${hours}${minutes}${seconds} ${username}+${size}.txt`; //Include seconds document.body.appendChild(a); a.click(); document.body.removeChild(a); } // Import function function importFromJson(event) { var file = event.target.files[0]; if (!file) return; var reader = new FileReader(); reader.onload = function(e) { try { var importedData = JSON.parse(e.target.result); if (!importedData.kudosed || !importedData.seen || !importedData.bookmarked || !importedData.skipped || !importedData.checked) { throw new Error("Missing kudosed/seen/bookmarked/skipped/checked data fields."); } var notes = "" var sizes_before = ['kudoshistory_kudosed', 'kudoshistory_bookmarked', 'kudoshistory_skipped', 'kudoshistory_seen', 'kudoshistory_checked'] .map(key => (String(localStorage.getItem(key)) || '').replace(strip, '').split(',').length); var sizes_after = ['kudosed', 'bookmarked', 'skipped', 'seen', 'checked'] .map(key => (String(importedData[key]) || '').replace(strip, '').split(',').length); localStorage.setItem('kudoshistory_kudosed', importedData.kudosed); localStorage.setItem('kudoshistory_bookmarked', importedData.bookmarked); localStorage.setItem('kudoshistory_skipped', importedData.skipped); localStorage.setItem('kudoshistory_seen', importedData.seen); localStorage.setItem('kudoshistory_checked', importedData.checked); var diff = sizes_after.reduce((a, b) => a + b, 0) - sizes_before.reduce((a, b) => a + b, 0); diff = diff == 0 ? "no change" : diff > 0 ? "added +" + diff : "removed " + diff; notes += "\n- Entries: " + diff; notes += "\n " + sizes_before; notes += "\n " + sizes_after; if (!importedData.username) { notes += "\n- Username: not present in file "; } else if (localStorage.getItem('kudoshistory_username') == "null" && importedData.username && importedData.username != "null") { localStorage.setItem('kudoshistory_username', importedData.username); notes += "\n- Username: updated to " + importedData.username; } else { notes += "\n- Username: no change" } if (!importedData.settings) { notes += "\n- Settings: not present in file "; } else if (importedData.settings && importedData.settings != localStorage.getItem('kudoshistory_settings')) { const oldSettings = localStorage.getItem('kudoshistory_settings'); localStorage.setItem('kudoshistory_settings', importedData.settings); notes += "\n- Settings: updated to"; notes += "\n old: " + oldSettings; notes += "\n new: " + importedData.settings; } else { notes += "\n- Settings: no change" } alert("[userscript:Extend AO3] Success" + notes); } catch (error) { alert("[userscript:Extend AO3] Error\nInvalid file format / missing data."); } }; reader.readAsText(file); } // ========================================== if (ENHANCED_SEEN_BUTTON) { let wasClicked = false; // Step 1: Wait for the button to exist and click it if it shows "Seen" function waitForSeenButton() { let attempts = 0; const maxAttempts = 100; // Stop after ~5 seconds (100 * 50ms) const buttonCheckInterval = setInterval(function() { attempts++; const seenButton = document.querySelector('.kh-seen-button a'); if (seenButton) { clearInterval(buttonCheckInterval); if (seenButton.textContent.includes('Seen ✓')) { if (ENHANCED_MARK_SEEN_ON_OPEN) { if (!IGNORE_EXTERNAL_LINKS || document.referrer.includes("archiveofourown.org")) { seenButton.click(); wasClicked = true; } } } else { wasClicked = false; } // Move to Step 2 setupButtonObserver(); } else if (attempts >= maxAttempts) { clearInterval(buttonCheckInterval); } }, 50); } // Step 2: Monitor the button text and toggle it function setupButtonObserver() { toggleButtonText(true, wasClicked); // Button to observe const targetNode = document.querySelector('.kh-seen-button'); if (!targetNode) { return; } const observer = new MutationObserver(function(mutations) { mutations.forEach(function(mutation) { if (mutation.type === 'childList' || mutation.type === 'characterData') { toggleButtonText(false, false); } }); }); const config = { childList: true, characterData: true, subtree: true }; observer.observe(targetNode, config); } function toggleButtonText(isFirst = false, wasClicked = false) { const buttonElement = document.querySelector('.kh-seen-button a'); if (!buttonElement) return; const UNSEEN = "UNSEEN [mark]"; const SEEN = "SEEN [unmark]"; const SEEN_NOW = "SEEN NOW [unmark]"; const SEEN_OLD = "SEEN OLD [unmark]"; // const UNSEEN = "UNSEEN [<strong>mark</strong>]"; // const SEEN = "SEEN [<strong>unmark</strong>]"; // const SEEN_NOW = "SEEN NOW [<strong>unmark</strong>]"; // const SEEN_OLD = "SEEN OLD [<strong>unmark</strong>]"; // Ignore changes we made ourselves. // (Since this is a mutation callback it is called again after modifying the button below.) if (buttonElement.innerHTML === UNSEEN || buttonElement.innerHTML === SEEN || buttonElement.innerHTML === SEEN_NOW || buttonElement.innerHTML === SEEN_OLD) { return; } const state_seen = buttonElement.textContent.includes('Unseen ✗') ? true : buttonElement.textContent.includes('Seen ✓') ? false : null; if (state_seen === null) { alert('[userscript:Extend AO3]\nUnknown text: ' + buttonElement.textContent); return; } currentSeenState = state_seen; const GREEN = "#33cc70"; // "#33cc70"; const GREEN_DARKER = "#00a13a"; // "#149b49"; const RED = "#ff6d50"; buttonElement.innerHTML = state_seen ? (isFirst ? (wasClicked ? SEEN_NOW : SEEN_OLD) : SEEN) : UNSEEN; const color = state_seen ? (isFirst && !wasClicked ? GREEN_DARKER : GREEN) : RED; buttonElement.style.backgroundColor = color; buttonElement.style.padding = "2px 6px"; buttonElement.style.borderRadius = "3px"; buttonElement.style.boxShadow = "none"; buttonElement.style.backgroundImage = "none"; buttonElement.style.color = getComputedStyle(buttonElement).color; // Color title if (firstH1link) firstH1link.style.color = color; // Blink on open Unseen -> Seen if (isFirst && wasClicked) { buttonElement.style.transition = "background-color 150ms ease"; buttonElement.style.backgroundColor = GREEN; setTimeout(() => { buttonElement.style.backgroundColor = "#00e64b"; }, 150); setTimeout(() => { buttonElement.style.transition = "background-color 200ms linear"; buttonElement.style.backgroundColor = GREEN; }, 200); } else if (!isFirst) { buttonElement.style.transition = "none"; // Clear transition for subsequent calls buttonElement.style.backgroundColor = state_seen ? GREEN : RED; } } // Start the process waitForSeenButton(); } })();