AO3 Res

Tweaks to the Archive!

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

You will need to install an extension such as Tampermonkey to install this script.

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

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

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

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

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

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

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

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

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

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

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

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

// ==UserScript==
// @name         AO3 Res
// @namespace    https://archiveofourown.org/
// @version      1.5
// @description  Tweaks to the Archive!
// @author       dxudz
// @match        https://archiveofourown.org/*
// @grant        none
// ==/UserScript==

(function() {
    'use strict';

// Add "Marked for Later" and "Skins" on the user actions dropdown

  const LINK_CLASS = 'custom-added-link';
  let debounceTimer = null;

  // Robustly find the user dropdown menu element (AO3 variations)
  function getDropdownMenu() {
    return document.querySelector(
      'ul.user.navigation.actions li.dropdown ul.menu.dropdown-menu'
    ) || document.querySelector(
      'ul.user.navigation.actions li.dropdown .dropdown-menu'
    ) || document.querySelector(
      'li.dropdown ul.menu.dropdown-menu'
    );
  }

  // Find an anchor that points to a user profile anywhere in the header
  function findProfileAnchor() {
    // prefer anchors inside the user navigation but fall back to any profile link
    return document.querySelector('ul.user.navigation.actions a[href^="/users/"]') ||
           document.querySelector('a[href^="/users/"]');
  }

  function extractUsernameFromHref(href) {
    if (!href) return null;
    const m = href.match(/\/users\/([^\/?#]+)/);
    if (m && m[1]) {
      try {
        return decodeURIComponent(m[1]);
      } catch (e) {
        return m[1];
      }
    }
    return null;
  }

  // Try several places to obtain the username
  function findUsername() {
    const profileAnchor = findProfileAnchor();
    if (profileAnchor) {
      const fromHref = extractUsernameFromHref(profileAnchor.getAttribute('href'));
      if (fromHref) return fromHref;
      const text = profileAnchor.textContent.trim();
      if (text) return text;
    }

    // fallback: greeting like "Hi, username!"
    const greeting = document.querySelector('ul.user.navigation.actions li.dropdown > a.dropdown-toggle') ||
                     document.querySelector('a.dropdown-toggle');
    if (greeting) {
      const txt = greeting.textContent.replace(/\s+/g, ' ').trim();
      const m = txt.match(/^Hi,\s*(.+?)!$/i);
      if (m && m[1]) return m[1].trim();
    }

    return null;
  }

  function createLi(href, text, username) {
    const li = document.createElement('li');
    li.className = LINK_CLASS;
    li.setAttribute('role', 'menuitem');
    li.setAttribute('data-for-user', username);
    const a = document.createElement('a');
    a.href = href;
    a.textContent = text;
    li.appendChild(a);
    return li;
  }

  // Add the two links once (idempotent)
  function addLinksOnce() {
    const menu = getDropdownMenu();
    if (!menu) return false;

    const username = findUsername();
    if (!username) return false;

    // If we already have links for this username, nothing to do
    const existing = Array.from(menu.querySelectorAll(`li.${LINK_CLASS}`));
    if (existing.some(li => li.getAttribute('data-for-user') === username)) {
      return true;
    }

    // Remove any leftover custom links for other users (avoid duplicates/stale items)
    existing.forEach(li => li.remove());

    // Create list items
    const markedLi = createLi(
      `https://archiveofourown.org/users/${encodeURIComponent(username)}/readings?show=to-read`,
      'Marked for Later',
      username
    );
    const skinsLi = createLi(
      `https://archiveofourown.org/users/${encodeURIComponent(username)}/skins`,
      'My Skins',
      username
    );

    // Insert at top and preserve original order: Marked for Later, then My Skins
    menu.insertBefore(skinsLi, menu.firstElementChild || null);
    menu.insertBefore(markedLi, menu.firstElementChild || null);

    return true;
  }

  // Debounced scheduler for MutationObserver callbacks
  function scheduleAdd() {
    if (debounceTimer) clearTimeout(debounceTimer);
    debounceTimer = setTimeout(() => {
      try { addLinksOnce(); } catch (e) { /* swallow */ }
    }, 120);
  }

  function startWatching() {
    // try immediately
    addLinksOnce();

    if (window.MutationObserver) {
      const observer = new MutationObserver(scheduleAdd);
      observer.observe(document.documentElement, { childList: true, subtree: true });
    } else {
      // fallback polling
      setInterval(addLinksOnce, 1500);
    }
  }

  // Run right away if ready, otherwise on load
  if (document.readyState === 'complete' || document.readyState === 'interactive') {
    startWatching();
  } else {
    window.addEventListener('load', startWatching, { once: true });
  }

// Change AO3's tab icon

const newFaviconURL = "https://i.ibb.co/vxQsC1XT/archive-of-our-own-svgrepo-com.png";

function replaceFavicon() {
  const head = document.querySelector("head");
  if (!head) return;

  // Remove existing favicons
  head.querySelectorAll("link[rel*='icon']").forEach(icon => icon.remove());

  // Add the new one
  const newIcon = document.createElement("link");
  newIcon.rel = "icon";
  newIcon.type = "image/png";
  newIcon.href = newFaviconURL;
  newIcon.className = "custom-favicon"; // mark it
  head.appendChild(newIcon);
}

// Run immediately if head is ready
if (document.head) replaceFavicon();

// Also run on load
window.addEventListener("load", replaceFavicon);

// Keep watching in case AO3 replaces the favicon later
if (window.MutationObserver) {
  const observer = new MutationObserver(() => {
    const current = document.querySelector("link.custom-favicon");
    if (!current) {
      replaceFavicon();
    }
  });
  observer.observe(document.head || document.documentElement, {
    childList: true,
    subtree: true
  });
}


// Censor your username

    let username = "";
let isCensored = true;
const spans = [];

function toggle() {
  isCensored = !isCensored;
  spans.forEach(span => {
    span.textContent = isCensored ? "▇▇" : span.dataset.username;
  });
}

function makeCensoredSpan(name) {
  const span = document.createElement("span");
  span.textContent = "▇▇";
  span.style.cursor = "pointer";
  span.title = "Click to toggle username";
  span.dataset.username = name;
  span.dataset.censored = "1";
  span.addEventListener("click", toggle);
  spans.push(span);
  return span;
}

/** Try several places to get the logged-in username, once */
function detectUsername() {
  if (username) return username;

  // A) Greeting "Hi, <name>!"
  const topUser = document.querySelector("ul.user.navigation.actions li.dropdown > a.dropdown-toggle");
  if (topUser) {
    const txt = topUser.textContent.replace(/\s+/g, " ").trim();
    const m = txt.match(/^Hi,\s*(.+?)!$/i);
    if (m && m[1]) {
      username = m[1].trim();
      return username;
    }
  }

  // B) Any user link in the user menu
  const userLink = document.querySelector('ul.user.navigation.actions a[href^="/users/"]');
  if (userLink) {
    // Prefer link text if it looks like a username
    const t = userLink.textContent.replace(/\s+/g, " ").trim();
    if (t && !/[^\w\-_.]/.test(t)) {
      username = t;
      return username;
    }
    // Fallback: extract from href
    const href = userLink.getAttribute("href");
    const m2 = href && href.match(/\/users\/([^\/?#]+)/);
    if (m2 && m2[1]) {
      username = decodeURIComponent(m2[1]);
      return username;
    }
  }

  // C) Comment header: "Comment as <name>"
  const commentHead = document.querySelector("#add_comment_placeholder #add_comment fieldset > h4.heading");
  if (commentHead) {
    const txt = commentHead.textContent.replace(/\s+/g, " ").trim();
    const m3 = txt.match(/Comment as\s+(.+)/i);
    if (m3 && m3[1]) {
      username = m3[1].trim();
      return username;
    }
  }

  return "";
}

function applyCensorship() {
  detectUsername();
  if (!username) return; // Wait until we know it

  // 1) Top right "Hi, username!"
  const topUser = document.querySelector("ul.user.navigation.actions li.dropdown > a.dropdown-toggle");
  if (topUser && !topUser.querySelector('span[data-censored="1"]')) {
    const span = makeCensoredSpan(username);
    // Normalize greeting safely
    const before = document.createTextNode("Hi, ");
    const after = document.createTextNode("!");
    // Clear and rebuild
    while (topUser.firstChild) topUser.removeChild(topUser.firstChild);
    topUser.append(before, span, after);
  }

  // 2) Comment as username?
  const commentHead = document.querySelector("#add_comment_placeholder #add_comment fieldset > h4.heading");
  if (commentHead && !commentHead.querySelector('span[data-censored="1"]')) {
    const txt = commentHead.textContent.replace(/\s+/g, " ").trim();
    if (/^Comment as\b/i.test(txt)) {
      const span = makeCensoredSpan(username);
      commentHead.textContent = "Comment as ";
      commentHead.appendChild(span);
    }
  }

  // 3) Profile page username
  const profileHead = document.querySelector("div.primary.header.module > h2.heading");
  if (profileHead && !profileHead.querySelector('span[data-censored="1"]')) {
    const txt = profileHead.textContent.replace(/\s+/g, " ").trim();
    if (txt === username || txt.includes(username)) {
      profileHead.textContent = "";
      profileHead.appendChild(makeCensoredSpan(username));
    }
  }

  // 4) Bookmarks page header "Bookmarks by <name>"
  const bookmarksHeading = document.querySelector("div#main.bookmarks-index h2.heading");
  if (bookmarksHeading && !bookmarksHeading.querySelector('span[data-censored="1"]')) {
    const txt = bookmarksHeading.textContent.replace(/\s+/g, " ").trim();
    const m = txt.match(/^(.*Bookmarks by )(.+)$/i);
    if (m) {
      const [, prefix, name] = m;
      if (!username) username = name.trim();
      bookmarksHeading.textContent = prefix;
      bookmarksHeading.appendChild(makeCensoredSpan(username));
    }
  }

  // 5) In bookmark lists → "Bookmarked by [username]" → "Bookmarked by you"
  document.querySelectorAll("h5.byline.heading").forEach(h5 => {
    if (h5.dataset.censored === "1") return;
    const link = h5.querySelector("a[href*='/bookmarks']");
    if (!link) return;

    const linkName = link.textContent.replace(/\s+/g, " ").trim();
    const href = link.getAttribute("href") || "";
    const matchesName = username && linkName === username;
    const matchesHref = username && href.includes(`/users/${encodeURIComponent(username)}/bookmarks`);

    if (matchesName || matchesHref) {
      link.textContent = "you";
      h5.dataset.censored = "1";
    }
  });
}

function start() {
  // First pass
  applyCensorship();

  // Observe DOM changes (AO3 uses partial reloads)
  const observer = new MutationObserver(() => {
    applyCensorship();
  });
  observer.observe(document.documentElement, { childList: true, subtree: true });

  // Also run when the page reports it's fully complete
  if (document.readyState !== "complete") {
    window.addEventListener("load", applyCensorship, { once: true });
  }
}

start();


// Add wordcount into chapters


    const WPM = 250; // words per minute for estimation (< you can change this!)

  function countTime(numWords) {
    if (!numWords) return '?';
    numWords = Math.round(Number(numWords) / WPM);
    const h = Math.floor(numWords / 60);
    const m = numWords % 60;
    return `${h > 0 ? `${h}hr ` : ''}${m > 0 ? `${m}min` : ''}` || '<1min';
  }

  // Check if we're on a valid work page (with or without chapter)
  if (/\/works\/\d+(\/chapters\/\d+)?(?!.*navigate)/.test(window.location.pathname)) {
    const wordCountElem = document.querySelector('dl.stats dd.words');
    const wordCountText = wordCountElem?.textContent?.replace(/,/g, '');
    const numWords = parseInt(wordCountText) || 0;

    if (wordCountElem && numWords) {
      const timeEstimate = countTime(numWords);
      wordCountElem.insertAdjacentHTML('afterend', `<dt>Time:</dt><dd>${timeEstimate}</dd>`);
    }

    // Add per-chapter word count and reading time
    const chapterBlocks = document.querySelectorAll('#chapters > .chapter > div.userstuff.module');
    chapterBlocks.forEach(chapter => {
      const rawText = chapter.textContent.replace(/['’‘-]/g, '');
      const wordCount = (rawText.match(/\w+/g) || []).length - 2;
      const time = countTime(wordCount);

      // Get work and chapter info
      const workIdMatch = location.pathname.match(/\/works\/(\d+)/);
      const chapterSelect = document.querySelector('#selected_id');
      const currentChapterNumber = chapterSelect
        ? parseInt(chapterSelect.selectedIndex + 1)
        : 1;

      const STORAGE_KEY = 'ao3_chapter_wordcounts';
      const storedData = JSON.parse(localStorage.getItem(STORAGE_KEY) || '{}');
      const workId = workIdMatch?.[1];
      if (!workId) return;

      if (!storedData[workId]) storedData[workId] = {};
      storedData[workId][currentChapterNumber] = wordCount;
      localStorage.setItem(STORAGE_KEY, JSON.stringify(storedData));

      // Sum all previously read chapters with index < current
      let totalRead = 0;
      const chapterNumbers = Object.keys(storedData[workId])
        .map(Number)
        .filter(n => n < currentChapterNumber);

      for (let num of chapterNumbers) {
        totalRead += storedData[workId][num];
      }

      let finalLine = '';
      if (chapterNumbers.length > 0) {
        finalLine = `${totalRead.toLocaleString()} words read in total. This chapter has ${wordCount.toLocaleString()} words (Estimated reading time: ${time}).`;
      } else {
        finalLine = `This chapter has ${wordCount.toLocaleString()} words (Estimated reading time: ${time}).`;
      }

      chapter.parentElement.insertAdjacentHTML('afterbegin',
        `<div style="font-size: 0.7em; text-transform: uppercase; text-align: center; color: #fff; margin: 3em 0 1em;">
          ${finalLine}
        </div>`);
    });
  }

  // For listings: add "Time: X" next to the word count
  function addTimeToListings() {
    document.querySelectorAll('li.work').forEach(work => {
      const stats = work.querySelector('dl.stats');
      const wordDD = stats?.querySelector('dd.words');

      if (!wordDD || wordDD.dataset.timeAdded) return; // already processed

      const wordText = wordDD.textContent.replace(/,/g, '');
      const wordNum = parseInt(wordText);
      if (!wordNum) return;

      const timeEstimate = countTime(wordNum);
      wordDD.insertAdjacentHTML('afterend', `<dt>Time: </dt><dd>${timeEstimate}</dd>`);
      wordDD.dataset.timeAdded = 'true';
    });
  }

  // Run once on load
  addTimeToListings();

  // Also rerun on mutations (e.g., infinite scroll)
  const observer = new MutationObserver(addTimeToListings);
  observer.observe(document.body, { childList: true, subtree: true });

})();