AO3 Res

Tweaks to the Archive!

您需要先安装一个扩展,例如 篡改猴Greasemonkey暴力猴,之后才能安装此脚本。

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

您需要先安装一个扩展,例如 篡改猴暴力猴,之后才能安装此脚本。

您需要先安装一个扩展,例如 篡改猴Userscripts ,之后才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 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 });

})();