AO3 Quick Bookmarks

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.

当前为 2025-09-01 提交的版本,查看 最新版本

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

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

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

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

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey,才能安装此脚本。

您需要先安装用户脚本管理器扩展后才能安装此脚本。

(我已经安装了用户脚本管理器,让我安装!)

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

(我已经安装了用户样式管理器,让我安装!)

// ==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.8
// @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 bold = false;
            let plus = true;
            if (newLastCh>lastCh) {
                if (lastCh===readCh) { color =  'green'; bold = true; plus = true; } // caught up - has update
                else                 { color = 'orange'; plus = true; } // dropped - has update
            } else {
                                     { color =   'gray'; } // no update
            }

            if (newLastCh > lastCh) { // only display if we arent caught up
                injectDiff(chNode , newLastCh - lastCh, color, bold, plus);
            }
            if (newLastCh > lastCh) { // only knowable relative to `lastCh`
                injectDiff(wrdNode, newWords - oldWords, color, bold, plus);
            }
        }
        // console.log('Bookmark', workId, db.works[workId]);
    }

    function injectDiff(node, diff, color, bold, plus){
        if (!node) return;
        let sp = document.createElement('span');
        sp.textContent = ' ' + (plus ? '+' : '') + diff.toLocaleString();
        sp.style.fontWeight= bold ? 'bold' : '';
        sp.style.color = color;
        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';
        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 a chapter text body */
        const container = document.querySelector('#workskin div.userstuff.module');
        if (!container) return [null, ''];

        const walker = document.createTreeWalker(container, NodeFilter.SHOW_TEXT, null);

        /* find first TEXT_NODE whose top is visible */
        let node;
        while ((node = walker.nextNode())){
            if (!node.nodeValue.trim()) continue;                       // skip empty text nodes
            const rect = (node.parentElement || container).getBoundingClientRect();
            if (rect.top > 0) break;                                    // first node below the fold
        }
        if (!node) return [null, ''];

        /* 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 = walker.nextNode();
        }
        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&nbsp;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);
    })
}