Extend "AO3: Kudosed and seen history" | Export/Import + Standalone Light/Dark mode toggle

Add Export/Import history buttons at the bottom of the page. Color&rename the confusingly named 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  buttons at the bottom of the page. Color&rename the confusingly named 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.17
// @match        https://archiveofourown.org/*
// @grant GM_xmlhttpRequest
// @namespace https://gf.qytechs.cn/users/1376767
// ==/UserScript==

const ENHANCED_SEEN_BUTTON       = true; // Seen button is colored and renamed
    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; //   Ignore external links, only Mark SEEN within archiveofourown.org
const SITE_SKINS = [ "Default", "Reversi" ];


// Function to fetch the preferences form, extract the authenticity token, and get the 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.href;
          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;

        // Ignore changes we made ourselves
        if (buttonElement.textContent === "SEEN Now (click to unmark)" ||
            buttonElement.textContent === "Old SEEN (click to unmark)" ||
            buttonElement.textContent === "SEEN (click to unmark)"     ||
            buttonElement.textContent === "NOT SEEN (click to mark)"   ||
            buttonElement.textContent === "UNSEEN (click to mark)") {
            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;
        }

        const GREEN = "#33cc70"; // "#33cc70";
        const GREEN_DARKER = "#00a13a"; // "#149b49";
        const RED = "#ff6d50";

        buttonElement.textContent =
            state_seen ?
            (isFirst ?
                (wasClicked ? "SEEN Now (click to unmark)" : "Old SEEN (click to unmark)") :
                "SEEN (click to unmark)") :
            "UNSEEN (click to mark)";

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

QingJ © 2025

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