您需要先安装一个扩展,例如 篡改猴、Greasemonkey 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 Userscripts ,之后才能安装此脚本。
您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey,才能安装此脚本。
您需要先安装用户脚本管理器扩展后才能安装此脚本。
Tweaks to the Archive!
// ==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 }); })();