AO3: Auto Pseud

Assign pseuds based on fandoms when commenting and bookmarking works

您需要先安裝使用者腳本管理器擴展,如 TampermonkeyGreasemonkeyViolentmonkey 之後才能安裝該腳本。

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyViolentmonkey 後才能安裝該腳本。

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyViolentmonkey 後才能安裝該腳本。

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyUserscripts 後才能安裝該腳本。

你需要先安裝一款使用者腳本管理器擴展,比如 Tampermonkey,才能安裝此腳本

您需要先安裝使用者腳本管理器擴充功能後才能安裝該腳本。

(我已經安裝了使用者腳本管理器,讓我安裝!)

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

(我已經安裝了使用者樣式管理器,讓我安裝!)

// ==UserScript==
// @name         AO3: Auto Pseud
// @version      1.0.0
// @description  Assign pseuds based on fandoms when commenting and bookmarking works
// @author       BlackBatCat
// @match        *://archiveofourown.org/users/*/pseuds/*/edit
// @match        *://archiveofourown.org/users/*/pseuds/*/bookmarks*
// @match        *://archiveofourown.org/works*
// @match        *://archiveofourown.org/chapters/*
// @match        *://archiveofourown.org/collections/*/bookmarks
// @license      MIT
// @run-at       document-end
// @namespace https://greasyfork.org/users/1498004
// ==/UserScript==

console.log("[AO3: Auto Pseud] loaded.");
(function () {
  "use strict";

  // Storage key
  const STORAGE_KEY = "ao3_auto_pseud_config";

  // Page detection regex
  const WORKS_PAGE_REGEX =
    /^https?:\/\/archiveofourown\.org\/(?:.*\/)?(works|chapters)(\/|$)/;
  const PSEUD_EDIT_REGEX =
    /^https?:\/\/archiveofourown\.org\/users\/.*\/pseuds\/.*\/edit$/;
  const BOOKMARKS_PAGE_REGEX =
    /^https?:\/\/archiveofourown\.org\/(?:collections\/.*\/)?(?:users\/.*\/(?:pseuds\/.*\/)?)?bookmarks(?:\/.*)?$/;

  // Get the entire config object
  function getConfig() {
    try {
      const config = JSON.parse(localStorage.getItem(STORAGE_KEY) || "{}");
      if (!config.pseuds) config.pseuds = {};
      if (config.enableComments === undefined) config.enableComments = false;
      if (config.enableBookmarks === undefined) config.enableBookmarks = false;
      return config;
    } catch (e) {
      console.error("[AO3: Auto Pseud] Error loading config:", e);
      return { pseuds: {}, enableComments: false, enableBookmarks: false };
    }
  }

  // Save the entire config object
  function saveConfig(config) {
    try {
      localStorage.setItem(STORAGE_KEY, JSON.stringify(config));
    } catch (e) {
      console.error("[AO3: Auto Pseud] Error saving config:", e);
    }
  }

  // Get current pseud name from URL
  function getCurrentPseudName() {
    const urlParts = window.location.pathname.split("/");
    const pseudIndex = urlParts.indexOf("pseuds");
    return pseudIndex !== -1 ? urlParts[pseudIndex + 1] : null;
  }

  // Get stored fandoms for current pseud
  function getStoredFandoms(pseudName) {
    const config = getConfig();
    return config.pseuds[pseudName]?.fandoms || [];
  }

  // Save fandoms for current pseud
  function saveFandomsForPseud(pseudName, fandoms) {
    const config = getConfig();
    if (!config.pseuds[pseudName]) {
      config.pseuds[pseudName] = {};
    }
    config.pseuds[pseudName].fandoms = fandoms;
    saveConfig(config);
  }

  // Get pseud ID for a given pseud name
  function getPseudIdByName(pseudName) {
    const config = getConfig();
    return config.pseuds[pseudName]?.id || null;
  }

  // Save pseud name to ID mapping
  function savePseudNameMapping(pseudName, pseudId) {
    const config = getConfig();
    if (!config.pseuds[pseudName]) {
      config.pseuds[pseudName] = {};
    }
    config.pseuds[pseudName].id = pseudId;
    saveConfig(config);
  }

  // Find which pseud should be used for given fandoms
  function findMatchingPseud(workFandoms) {
    try {
      const config = getConfig();

      for (const [pseudName, pseudData] of Object.entries(config.pseuds)) {
        const pseudFandoms = pseudData.fandoms || [];
        const pseudId = pseudData.id;

        if (!pseudId) continue;

        for (const workFandom of workFandoms) {
          if (pseudFandoms.includes(workFandom)) {
            return pseudId;
          }
        }
      }

      return null;
    } catch (e) {
      console.error("[AO3: Auto Pseud] Error finding matching pseud:", e);
      return null;
    }
  }

  // ==================== PSEUD EDIT PAGE ====================

  // Add fandom fieldset to the page
  function addFandomFieldset() {
    const form = document.querySelector("form.edit_pseud");
    if (!form) {
      console.error("[AO3: Auto Pseud] Could not find pseud edit form");
      return;
    }

    const submitDd = form.querySelector("dd.submit.actions");
    if (!submitDd) {
      console.error("[AO3: Auto Pseud] Could not find submit button");
      return;
    }

    const pseudName = getCurrentPseudName();
    const storedFandoms = pseudName ? getStoredFandoms(pseudName) : [];
    const fandomValue = storedFandoms.join(", ");

    // Create the fandom dt and dd elements
    const fandomDt = document.createElement("dt");
    fandomDt.className = "fandom";
    fandomDt.innerHTML = `
            <label for="pseud_fandom_autocomplete" title="fandoms">Fandoms</label>
            <a class="help symbol question modal modal-attached" title="Associate fandoms with this pseud for automatic selection when commenting and bookmarking" href="#" onclick="alert('Associate fandoms with this pseud. When you comment on or bookmark works in these fandoms, this pseud will be suggested automatically.'); return false;">
                <span class="symbol question"><span>?</span></span>
            </a>
        `;

    const config = getConfig();
    const fandomDd = document.createElement("dd");
    fandomDd.className = "fandom";
    fandomDd.setAttribute("title", "fandoms");
    fandomDd.innerHTML = `
            <input type="text" name="pseud[fandom_string]" id="pseud_fandom" 
                   value="${fandomValue}" class="autocomplete" 
                   data-autocomplete-method="/autocomplete/fandom" 
                   data-autocomplete-hint-text="Start typing for suggestions!" 
                   data-autocomplete-no-results-text="(No suggestions found)" 
                   data-autocomplete-min-chars="1" 
                   data-autocomplete-searching-text="Searching..." 
                   title="fandoms">
            <div style="margin-top: 0.5em;">
                <label><input type="checkbox" id="enable_comments" ${
                  config.enableComments ? "checked" : ""
                }> Assign pseud to comments</label><br>
                <label><input type="checkbox" id="enable_bookmarks" ${
                  config.enableBookmarks ? "checked" : ""
                }> Assign pseud to bookmarks</label>
            </div>
        `;

    const submitDt = form.querySelector("dt.landmark");
    submitDt.parentNode.insertBefore(fandomDt, submitDt);
    submitDt.parentNode.insertBefore(fandomDd, submitDt);
  }

  // Intercept form submission to save fandoms
  function interceptFormSubmit() {
    const form = document.querySelector("form.edit_pseud");
    if (!form) return;

    const formAction = form.getAttribute("action");
    const pseudId = formAction ? formAction.split("/").pop() : null;

    form.addEventListener("submit", function (e) {
      const pseudName = getCurrentPseudName();
      const fandomInput = document.querySelector("#pseud_fandom");

      if (pseudName && fandomInput) {
        const fandoms = fandomInput.value
          .split(",")
          .map((f) => f.trim())
          .filter((f) => f.length > 0);

        saveFandomsForPseud(pseudName, fandoms);

        if (pseudId) {
          savePseudNameMapping(pseudName, pseudId);
        }

        fandomInput.disabled = true;
        const autocompleteInput = document.querySelector(
          "#pseud_fandom_autocomplete"
        );
        if (autocompleteInput) autocompleteInput.disabled = true;
      }
    });
  }

  // Save options when checkboxes change
  function saveOptions() {
    const enableCommentsCheckbox = document.querySelector("#enable_comments");
    const enableBookmarksCheckbox = document.querySelector("#enable_bookmarks");

    if (enableCommentsCheckbox && enableBookmarksCheckbox) {
      const config = getConfig();
      config.enableComments = enableCommentsCheckbox.checked;
      config.enableBookmarks = enableBookmarksCheckbox.checked;
      saveConfig(config);
    }
  }

  // Initialize the pseud edit page
  function initPseudEditPage() {
    addFandomFieldset();
    interceptFormSubmit();

    // Add listeners for options checkboxes
    const enableCommentsCheckbox = document.querySelector("#enable_comments");
    const enableBookmarksCheckbox = document.querySelector("#enable_bookmarks");

    if (enableCommentsCheckbox) {
      enableCommentsCheckbox.addEventListener("change", saveOptions);
    }
    if (enableBookmarksCheckbox) {
      enableBookmarksCheckbox.addEventListener("change", saveOptions);
    }
  }

  // ==================== WORKS PAGE ====================

  // Get fandoms from the current work page
  function getWorkFandoms(workId = null) {
    if (workId) {
      // Try to find the blurb by .work-<id> (standard AO3 class)
      let blurb = document.querySelector(`.work-${workId}`);
      if (!blurb) {
        // Try to find a <li> with class containing work-<id>
        blurb = Array.from(
          document.querySelectorAll("li.bookmark, li.blurb, li.group")
        ).find((el) => el.className && el.className.includes(`work-${workId}`));
      }
      if (!blurb) {
        // Try to find by id (sometimes used)
        blurb =
          document.getElementById(`work-${workId}`) ||
          document.getElementById(`bookmark_${workId}`);
      }
      if (blurb) {
        // Try standard AO3 structure
        let fandomTags = blurb.querySelectorAll("h5.fandoms.heading a.tag");
        if (fandomTags.length === 0) {
          // Try fallback: any a.tag inside blurb
          fandomTags = blurb.querySelectorAll("a.tag");
        }
        const fandoms = Array.from(fandomTags).map((tag) =>
          tag.textContent.trim()
        );
        return fandoms;
      }
      // If no blurb found, fall through to page-wide selectors
    }

    // On bookmark list pages, don't auto-select to avoid using wrong fandoms
    if (
      BOOKMARKS_PAGE_REGEX.test(window.location.href) &&
      !window.location.href.includes("/edit")
    ) {
      return [];
    }
    // Check for fandoms in work page structure (dd.fandom.tags)
    let fandomTags = document.querySelectorAll("dd.fandom.tags a.tag");
    if (fandomTags.length === 0) {
      // Check for fandoms in bookmark page structure (h5.fandoms.heading a.tag)
      fandomTags = document.querySelectorAll("h5.fandoms.heading a.tag");
    }
    return Array.from(fandomTags).map((tag) => tag.textContent.trim());
  }

  // Build pseud name-to-ID map from comment form select options
  function buildPseudMapFromCommentForm() {
    const commentSelect = document.querySelector(
      'select[name="comment[pseud_id]"]'
    );
    if (!commentSelect) return;

    const options = commentSelect.querySelectorAll("option");
    options.forEach((option) => {
      const pseudId = option.value;
      const pseudName = option.textContent.trim();
      savePseudNameMapping(pseudName, pseudId);
    });
  }

  // Build pseud name-to-ID map from bookmark form select options
  function buildPseudMapFromBookmarkForm() {
    const bookmarkSelect = document.querySelector(
      'select[name="bookmark[pseud_id]"]'
    );
    if (!bookmarkSelect) return;

    const options = bookmarkSelect.querySelectorAll("option");
    options.forEach((option) => {
      const pseudId = option.value;
      const pseudName = option.textContent.trim();
      savePseudNameMapping(pseudName, pseudId);
    });
  }

  // Switch the comment form pseud to the matching one
  function switchCommentPseud() {
    const config = getConfig();
    if (!config.enableComments) return;

    const workFandoms = getWorkFandoms();
    if (workFandoms.length === 0) return;

    buildPseudMapFromCommentForm();

    const matchingPseudId = findMatchingPseud(workFandoms);
    if (!matchingPseudId) return;

    const commentSelects = document.querySelectorAll(
      'select[name="comment[pseud_id]"]'
    );
    commentSelects.forEach((select) => {
      const option = select.querySelector(`option[value="${matchingPseudId}"]`);
      if (option) {
        select.value = matchingPseudId;
      }
    });
  }

  // Observe comment fieldsets for pseud selects
  function observeCommentFieldsets() {
    const commentLegends = document.querySelectorAll("fieldset legend");
    commentLegends.forEach((legend) => {
      if (legend.textContent.trim() === "Comment") {
        const fieldset = legend.closest("fieldset");
        if (!fieldset) return;
        const observer = new MutationObserver((mutations) => {
          for (const mutation of mutations) {
            for (const node of mutation.addedNodes) {
              if (
                node.nodeType === 1 &&
                node.matches('select[name="comment[pseud_id]"]')
              ) {
                switchCommentPseud();
                observer.disconnect();
                return;
              }
            }
          }
        });
        observer.observe(fieldset, { childList: true, subtree: true });
        // Also check if select is already there
        const existingSelect = fieldset.querySelector(
          'select[name="comment[pseud_id]"]'
        );
        if (existingSelect) {
          switchCommentPseud();
        }
      }
    });
  }

  // Observe bookmark fieldsets for pseud selects
  function observeBookmarkFieldsets() {
    const bookmarkLegends = document.querySelectorAll("fieldset legend");
    bookmarkLegends.forEach((legend) => {
      if (legend.textContent.trim() === "Bookmark") {
        const fieldset = legend.closest("fieldset");
        if (!fieldset) return;
        const observer = new MutationObserver((mutations) => {
          for (const mutation of mutations) {
            for (const node of mutation.addedNodes) {
              if (
                node.nodeType === 1 &&
                node.matches('select[name="bookmark[pseud_id]"]')
              ) {
                switchBookmarkPseud();
                observer.disconnect();
                return;
              }
            }
          }
        });
        observer.observe(fieldset, { childList: true, subtree: true });
        // Also check if select is already there
        const existingSelect = fieldset.querySelector(
          'select[name="bookmark[pseud_id]"]'
        );
        if (existingSelect) {
          switchBookmarkPseud();
        }
      }
    });
  }

  // Observe for new bookmark fieldsets being added to the page
  function observeForNewBookmarkFieldsets() {
    const observer = new MutationObserver((mutations) => {
      for (const mutation of mutations) {
        for (const node of mutation.addedNodes) {
          if (node.nodeType === 1 && node.matches("fieldset")) {
            const legend = node.querySelector("legend");
            if (legend && legend.textContent.trim() === "Bookmark") {
              observeBookmarkFieldsets();
            }
          }
          // Handle AO3 collection bookmarks: <div id="bookmark-form">
          if (node.nodeType === 1 && node.id === "bookmark-form") {
            // Watch for the select being added inside #bookmark-form
            const formObserver = new MutationObserver((mutations) => {
              for (const mutation of mutations) {
                for (const added of mutation.addedNodes) {
                  if (
                    added.nodeType === 1 &&
                    added.matches('select[name="bookmark[pseud_id]"]')
                  ) {
                    switchBookmarkPseud();
                    formObserver.disconnect();
                    return;
                  }
                }
              }
            });
            formObserver.observe(node, { childList: true, subtree: true });
            // Also check if select is already there
            const existingSelect = node.querySelector(
              'select[name="bookmark[pseud_id]"]'
            );
            if (existingSelect) {
              switchBookmarkPseud();
            }
          }
        }
      }
    });
    observer.observe(document.body, { childList: true, subtree: true });
  }

  function switchBookmarkPseud() {
    const config = getConfig();
    if (!config.enableBookmarks) {
      return;
    }

    const bookmarkSelects = document.querySelectorAll(
      'select[name="bookmark[pseud_id]"]'
    );
    if (bookmarkSelects.length === 0) {
      return;
    }
    bookmarkSelects.forEach((select) => {
      const form = select.closest('form[action^="/works/"]');
      let workId = null;
      if (form) {
        const match = form.getAttribute("action").match(/\/works\/(\d+)/);
        if (match) workId = match[1];
      }
      if (!workId) {
        return;
      }
      const workFandoms = getWorkFandoms(workId);
      if (workFandoms.length === 0) {
        return;
      }
      buildPseudMapFromBookmarkForm();
      const matchingPseudId = findMatchingPseud(workFandoms);
      if (!matchingPseudId) {
        return;
      }
      const option = select.querySelector(`option[value="${matchingPseudId}"]`);
      if (option) {
        select.value = matchingPseudId;
      }
    });
  }

  // Initialize works page functionality
  function initWorksPage() {
    switchCommentPseud();
    switchBookmarkPseud();
    observeCommentFieldsets();
    observeBookmarkFieldsets();
    observeForNewBookmarkFieldsets();
  }

  // Initialize bookmarks page functionality
  function initBookmarksPage() {
    switchBookmarkPseud();
    observeBookmarkFieldsets();
    observeForNewBookmarkFieldsets();
  }

  // Determine which page we're on and initialize accordingly
  function initializeScript() {
    const currentUrl = window.location.href;
    if (PSEUD_EDIT_REGEX.test(currentUrl)) {
      initPseudEditPage();
    } else if (WORKS_PAGE_REGEX.test(currentUrl)) {
      initWorksPage();
    } else if (BOOKMARKS_PAGE_REGEX.test(currentUrl)) {
      initBookmarksPage();
    }
  }

  // Wait for page to be ready
  if (document.readyState === "loading") {
    document.addEventListener("DOMContentLoaded", initializeScript);
  } else {
    initializeScript();
  }
})();