您需要先安装一个扩展,例如 篡改猴、Greasemonkey 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 Userscripts ,之后才能安装此脚本。
您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey,才能安装此脚本。
您需要先安装用户脚本管理器扩展后才能安装此脚本。
Auto-fills bookmarks with a clickable link "Title – Author – 0 words, Chapter 0/0". Adds a quick-save button that pops up in the corner when you scroll up, used to capture mid-read jumps. Compatible with Entire Work. Shows +new chapters/words on the bookmarks page.
// ==UserScript== // @name AO3 Quick Bookmarks // @description Auto-fills bookmarks with a clickable link "Title – Author – 0 words, Chapter 0/0". Adds a quick-save button that pops up in the corner when you scroll up, used to capture mid-read jumps. Compatible with Entire Work. Shows +new chapters/words on the bookmarks page. // @version 1.12 // @author C89sd // @namespace https://greasyfork.org/users/1376767 // @match https://archiveofourown.org/* // @grant GM_addStyle // @run-at document-start // @noframes // ==/UserScript== let STORAGE_KEY = 'ao3BookmarkDB', DEFAULT_DB = { version: 1, works:{} }; // let db = loadDb(); let db = DEFAULT_DB; let ourARegex = /(<a.*?>.*?words, Chapter.*?<\/a>)/i; let url = location.href, isWork = /\/(works|chapters)\/\d+/.test(url), isBookmarks = url.includes('/bookmarks'), isSearch = !(isWork || isBookmarks); if (isWork && location.hash === '#NEXT') pressNextButton(); addEventListener('DOMContentLoaded', () => { if (isBookmarks) scrapeBookmarksPage(); // else if (isSearch) annotateSearchPage(); else if (isWork) enhanceWorkPage(); }); // function loadDb () { // let raw = localStorage.getItem(STORAGE_KEY); // if (!raw) return DEFAULT_DB; // try { // let data = JSON.parse(raw); // // future migrations if (data.version == 1) {} // return data; // } // catch (e) { return DEFAULT_DB; } // } // function saveDb () { localStorage.setItem(STORAGE_KEY, JSON.stringify(db)); } function saveDb () {} /* ---------------------------- BOOKMARKS --------------------------- */ function scrapeBookmarksPage () { GM_addStyle(` .qbfav { box-shadow: inset 0 0 2px 1px #ff7991; /* pink */ } .qbwatch { outline: 2px solid green; } `); let list = document.querySelectorAll("li[id^='bookmark_'][class*='work-']"); for (let li of list){ let workId = (li.querySelector("a[href^='/works/']")||{}).href.match(/\/works\/(\d+)/); if (!workId) continue; workId = workId[1]; let tags = []; for (let tag of li.querySelectorAll(".meta.tags .tag")) { let text = tag.textContent.trim(); tags.push(text); if (text.toLowerCase().includes("fav")) li.classList.add("qbfav"); if (text.toLowerCase().includes("watch")) li.classList.add("qbwatch"); } let commentNode = li.querySelector(".userstuff.notes > p"), commentHtml = commentNode ? commentNode.innerHTML.trim() : ''; db.works[workId] = {tags:tags, comment:commentHtml}; /* check if this has one of our links */ const match = commentHtml.match(ourARegex); if (match && match[1]){ const temp = document.createElement('div'); temp.innerHTML = match[1]; let ourLink = temp.querySelector('a'); /* stored state "... - 25,371 Words, Chapter 11/12" */ let m = ourLink.textContent.match(/ ([\d,]+) words, Chapter ([\d,]+)\/([\d,]+)/i) || []; let oldWords = +(m[1]||'0').replace(/,/g,''), readCh = +(m[2]||'0').replace(/,/g,''), lastCh = +(m[3]||'0').replace(/,/g,''); /* current state */ let chNode = li.querySelector('dd.chapters'), wrdNode= li.querySelector('dd.words'); let newLastCh = chNode ? +chNode.textContent.split('/')[0].replace(/,/g,'') : 0, newWords = wrdNode? +wrdNode.textContent.replace(/,/g,''): 0; /* add +N indicators besides chapters / words */ let color; let plus = true; if (lastCh===readCh) { // caught up if (newLastCh>lastCh) { // updated color = '#1bb900'; injectDiff(chNode , newLastCh - lastCh, color, true, true); injectDiff(wrdNode, newWords - oldWords, color, false, false); } } else { // dropped if (newLastCh>lastCh) { // updated color = 'rgb(242, 122, 15)'; injectDiff(chNode , newLastCh - readCh, color, true, true); injectDiff(wrdNode, newWords - oldWords, color, false, false); } else if (lastCh>readCh) { color = 'rgb(179, 170, 8)'; injectDiff(chNode , lastCh - readCh, color, true, true); injectDiff(wrdNode, '?', color, false, false); } } } // console.log('Bookmark', workId, db.works[workId]); } function injectDiff(node, diff, color, bold, plus){ if (!node) return; // if (!diff) return; let sp = document.createElement('span'); sp.textContent = ' ' + (plus ? '+' : '') + diff.toLocaleString(); sp.style.fontWeight= bold ? 'bold' : ''; sp.style.color = color; sp.style.border = "1px solid"; sp.style.borderRadius = "5px"; sp.style.marginLeft = "5px"; sp.style.paddingRight = "5px"; node.appendChild(sp); } saveDb(); /* display total bookmarks atop the page */ let h2 = document.querySelector('h2'); if (h2) h2.textContent += ' ('+Object.keys(db.works).length+' total)'; } /* ----------------------------- SEARCH ----------------------------- */ /* no enhancements for now */ // function annotateSearchPage () { // let works = document.querySelectorAll("li.blurb.work"); // for (let work of works){ // let id = (work.querySelector("a[href^='/works/']")||{}).href.match(/\/works\/(\d+)/); // if (!id) continue; // id=id[1]; // console.log('Search result',id, db.works[id]); // } // } /* ------------------------------ WORK ------------------------------ */ function enhanceWorkPage () { /* set bookmark private */ let privateBox = document.getElementById('bookmark_private'); if (privateBox) privateBox.checked = true; let form = document.getElementById('bookmark-form'), // bookmark container notes = document.getElementById('bookmark_notes'); // comment textarea /* display bookmark above the title */ if (notes && notes.value.trim()){ let header = document.createElement('h1'); header.innerHTML = '<hr>Bookmark: '+notes.value +'<hr>'; header.style = "color: #cf77ef; text-align: center; margin-bottom:-30px; margin-top:15px; font-size: 25px;"; (document.getElementById('workskin')||document.body).prepend(header); } /* live preview at the top of the textarea detect any <a> in the box and inject it atop the area allows jumping back up if you accidentally pressed the bookmark button */ if (notes){ notes.addEventListener('input', updatePreview); updatePreview(); } function updatePreview(){ let head = form ? form.querySelector('h4.heading') : null; if (!head) return; let link = notes.value.match(/(<a.*?<\/a>)/); head.innerHTML = 'Bookmark: '+(link?link[1]:''); let linkEl = head.querySelector('a') if (linkEl) { linkEl.addEventListener('click', () => document.querySelector('.bookmark_form_placement_close')?.click()) // disable jumping if theres is no mid-jump text search if (!linkEl.href.includes('#')) linkEl.style = 'pointer-events: none;' // limit jumping to the current page if (linkEl.href.includes('#')) linkEl.href = window.location.pathname + window.location.search + '#' + linkEl.href.split('#')[1]; } } /* floating bookmark button, shows when you scrolls up */ injectFloatingButton(); /* enchance default Bookmark button */ let buttons = document.querySelectorAll('.bookmark_form_placement_open'); for (let button of buttons) { button.addEventListener('click', onClick, true); } // capture: true => assemble link before we move to bottom /* -------- internal helpers ------------------------------------- */ function injectFloatingButton(){ let css = document.createElement('style'); css.textContent = '.ao3-float {position:fixed;bottom:10px;left:10px;padding:6px 12px;background:#89a;opacity:0;border-radius:3px;color:#fff;cursor:pointer;transition:opacity 0.3s ease-in}'+ '.ao3-float.show{opacity:.5;transition:opacity 0.2s ease-in}'+ '.ao3-float:not(.show){transition:opacity 0.2s}'; document.head.appendChild(css); let btn = document.createElement('div'); btn.textContent='Bookmark'; btn.className='ao3-float'; btn.style.userSelect = 'none'; document.body.appendChild(btn); /* show / hide depending on scroll direction */ let lastY = scrollY; addEventListener('scroll', function(){ if (scrollY < lastY) btn.classList.add('show'); // user heads back up else btn.classList.remove('show'); lastY = scrollY; }); /* highlight while button is held */ let highlightSpan = null; function highlightParagraph(){ if (highlightSpan) return; let [node, snippet] = findUniqueVisibleLeaf(); // Text node chosen by algorithm if (!snippet) return; let span = document.createElement('span'); span.style.background = 'rgba(255,255,255,0.3)'; // 30% gray overlay span.style.mixBlendMode = 'difference'; // darken on light bg / lighten on dark bg node.parentNode.insertBefore(span, node); span.appendChild(node); highlightSpan = span; } function clearHighlight(){ if (!highlightSpan) return; const parent = highlightSpan.parentNode; parent.replaceChild(highlightSpan.firstChild, highlightSpan); highlightSpan = null; } btn.addEventListener('pointerdown', highlightParagraph, {passive:true}); addEventListener('pointerup', clearHighlight, {passive:true}); addEventListener('pointercancel', clearHighlight, {passive:true}); btn.addEventListener('click', () => { let button = document.querySelector('.bookmark_form_placement_open'); if (button) button.click(); // let AO3 scroll the page for us }); } /* when the floating button is clicked */ function onClick () { let link = assembleLink(); if (!link) return; /* update textarea, replacing <a> inplace but keeping other text */ if (ourARegex.test(notes.value)) notes.value = notes.value.replace(ourARegex, link); else notes.value = link + ' ' + notes.value; updatePreview(); // keep preview in sync } /* gather stats to create the link */ function assembleLink () { const workTitle = (document.querySelector('h2.title') || {textContent:'__error__'}).textContent.trim(), author = (document.querySelector('h3.byline') || {textContent:'Anonymous'}).textContent.trim(), wordsNow = (document.querySelector('dd.words') || {textContent:'0'}).textContent, chTot = (document.querySelector('dd.chapters')|| {textContent:'0'}).textContent.split('/')[0]; /* all chapters currently loaded on the page */ const chapters = [...document.querySelectorAll('#workskin div.chapter[id^="chapter-"]')], firstChapter = chapters[0], lastChapter = chapters[chapters.length - 1]; const screenTop = 0, screenBottom = window.innerHeight, firstChapterTop = firstChapter.getBoundingClientRect().top, lastChapterBottom = lastChapter.getBoundingClientRect().bottom; function parentChapter(node) { return node.parentElement.closest('#workskin div.chapter[id^="chapter-"]'); } const [node, snippet] = findUniqueVisibleLeaf(), beforeFirst = screenTop < firstChapterTop, afterLast = screenBottom > lastChapterBottom, hasSnippet = node && snippet; let chapter, suffix, ending; /* START of work is visible or above */ if (beforeFirst) { chapter = firstChapter; ending = 'Start'; suffix = ''; } /* END of work is visible or below */ else if (afterLast) { chapter = lastChapter; ending = 'End'; suffix = '#NEXT'; } /* MIDDLE snippet jump */ else if (hasSnippet) { chapter = parentChapter(node); ending = 'Middle'; suffix = '#:~:text=' + encodeURIComponent(snippet); } /* ERROR */ else { alert('AO3 Quick Bookmarks: Error, in middle of chapter but no text is visible.'); return ''; } const chNow = chapter.id.match(/chapter-(\d+)/)[1], baseUrl = chapter.querySelector('a').href.match(/(\/works\/\d+\/chapters\/\d+)/)[1]; return `<a href="${baseUrl+suffix}">${workTitle} - ${author} - ${wordsNow} words, Chapter ${chNow}/${chTot} ${ending}</a>`; } /* first strictly visible text node that is unique on the page */ function findUniqueVisibleLeaf(){ /* WALK all TEXT_NODEs inside *all* chapter-text bodies */ const containers = document.querySelectorAll('#workskin div.userstuff.module'); if (!containers.length) return [null, '']; /* one TreeWalker per container with a custom nextTextNode() function that bridges them */ const walkers = [...containers].map(c => document.createTreeWalker(c, NodeFilter.SHOW_TEXT, null)); let wi = 0; // walker index function nextTextNode(){ let n; while (wi < walkers.length){ n = walkers[wi].nextNode(); if (n) return n; wi++; } return null; } /* find first TEXT_NODE whose top is visible */ let node; while ((node = nextTextNode())){ if (!node.nodeValue.trim()) continue; const rect = textNodeRect(node); if (rect.top > 0) break; } if (!node) return [null, '']; /* helper : get the client-rect for a TEXT_NODE */ function textNodeRect(txt){ const range = document.createRange(); range.selectNodeContents(txt); const rect = range.getBoundingClientRect(); range.detach?.(); return rect; } /* scan downward from this TEXT_NODE until a unique snippet is found */ while (node){ const txt = node.nodeValue.trim(); if (txt){ const snippet = txt.split(/\s+/).slice(0, 10).join(' '); if (isUnique(snippet)){ return [node, snippet]; } } node = nextTextNode(); } return [null, '']; /* ensure snippet appears exactly once on the current page */ function isUnique(snippet){ if (!snippet) return false; let cnt = 0, n; const w = document.createTreeWalker(document.body, NodeFilter.SHOW_TEXT, null); while ((n = w.nextNode())){ if (n.nodeValue.trim().includes(snippet)){ cnt++; if (cnt > 1) break; } } return cnt === 1; } } } /* ------------------------------ NEXT ------------------------------ */ const WAIT = 400, SLIDE = 800; function pressNextButton() { new MutationObserver((_, obs) => { const next = document.querySelector('.next.chapter a'); if (next) { obs.disconnect(); setTimeout(() => { registerUndoSlide(); doSlide(next); }, WAIT); } }).observe(document.documentElement, {childList:true, subtree:true}); } function doSlide(next) { /* copy of the page to the right + title + spinner */ if (!document.getElementById('spinCSS')) document.head.insertAdjacentHTML('beforeend', '<style id="spinCSS">@keyframes spin{to{transform:rotate(360deg)}}</style>'); document.body.insertAdjacentHTML('beforeend', '<div id="pageClone" style="position:fixed;top:0;left:100vw;width:100vw;height:100vh;overflow:hidden;background:#fff;opacity:.8;filter:brightness(.9);pointer-events:none;z-index:9;display:flex;flex-direction:column;justify-content:center;align-items:center;">' + '<div style="font:700 8vw/1 sans-serif;color:#444;">Loading<br>Next Chapter</div>' + '<div style="width:64px;height:64px;margin-top:1rem;border:8px solid #bbb;border-top-color:#444;border-radius:50%;animation:spin 1s linear infinite;"></div>' + '</div>'); /* slide page left */ document.head.insertAdjacentHTML('beforeend', `<style id="slideCSS">html{overflow:hidden}body{transition:transform ${SLIDE}ms ease;transform:translateX(-100vw)}</style>`); // setTimeout(() => next.click(), SLIDE); setTimeout(() => window.location.href = next.href.split('#')[0], SLIDE); } /* undo the slide when we return from BF-cache */ function registerUndoSlide() { addEventListener('pageshow', (e) => { if (!e.persisted) return; const clone = document.getElementById('pageClone'); if (!clone) return; requestAnimationFrame(() => document.body.style.transform = 'translateX(0)'); setTimeout(() => { clone.remove(); const css = document.getElementById('slideCSS'); css && css.remove(); document.documentElement.style.overflow = ''; document.body.style.transform = ''; }, SLIDE); }) }