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 提交的版本,查看 最新版本

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Greasemonkey 油猴子Violentmonkey 暴力猴,才能安装此脚本。

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

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

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴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.3
// @author      C89sd
// @namespace   https://greasyfork.org/users/1376767
// @match       https://archiveofourown.org/*
// @grant       GM_addStyle
// @noframes
// ==/UserScript==

let STORAGE_KEY = 'ao3BookmarkDB',
    DEFAULT_DB = { version: 1, works:{} };
    db = loadDb();

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 (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)); }

/* ---------------------------- 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); }

    /* -------- 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);

        let lastY = scrollY;
        addEventListener('scroll', function(){
            if (scrollY < lastY) btn.classList.add('show');  // user heads back up
            else                  btn.classList.remove('show');
            lastY = scrollY;
        });

        btn.addEventListener('click', onClick);
    }

    /* when the floating button is clicked */
    function onClick () {
        let button = document.querySelector('.bookmark_form_placement_open');
        if (button) button.click();            // let AO3 scroll the page for us

        if (!notes) return;
        let link = assembleLink();

        /* 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 () {

        let 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];


        let [container, uniqueLine] = findUniqueVisibleLeaf();
        let chapter = container.parentElement?.closest('#workskin div.chapter[id^="chapter-"]');
        let chNow   = chapter.getAttribute('id').match(/(\d+)/)[0];

        /* build the url */
        let lastChapter = [...document.querySelectorAll('#workskin div.chapter[id^="chapter-"]')].pop()
        let base = lastChapter.querySelector('a').href.match(/(\/works\/\d+\/chapters\/\d+)/)[0],
            url  = base + (uniqueLine ? '#:~:text='+encodeURIComponent(uniqueLine) : '');

        return '<a href="'+url+'">'+workTitle+' - '+author+' - '+wordsNow+' words, Chapter '+chNow+'/'+chTot+'</a>';
    }

    /* first line on screen that exists only once in the whole chapter */
    function findUniqueVisibleLeaf(){

        let container = null;
        if (window.location.search.includes('view_full_work=true')){
            /* obsolete: find which chapter is onscreen directly, now we find the text node and get its parent */
            // let bodies  = document.querySelectorAll('#workskin div.userstuff.module'),
            //     centerY = window.innerHeight/2,
            //     chosen  = bodies[0];
            // for (let b of bodies){
            //     let r = b.getBoundingClientRect();
            //     if (centerY>=r.top && centerY<=r.bottom){ chosen=b; break; }
            //     if (centerY>r.bottom) chosen=b;
            // }
            // container = chosen;
            container = document.querySelector('#workskin');
        } else {
            container = document.querySelector('#workskin div.userstuff.module');

            /* chapter mode only: if the screen is not inside the text, dont bother with text search */
            let r = container.getBoundingClientRect();
            if (!(r.top<=0 && r.bottom>=window.innerHeight)) return [container, ''];
        }

        /* find first <p> visible on screen (below the middle) */
        let paragraphs = container.querySelectorAll('#workskin div.userstuff.module > p'),
            midLine    = window.innerHeight/2;

        let start = 0;
        while (start < paragraphs.length && paragraphs[start].getBoundingClientRect().top <= midLine) {
          start++;
        }
        if (start === paragraphs.length) start = paragraphs.length - 1;

         /* scan up from this <p> while not unique, return last node to find its chapter */
        let lastNode = null;
        for (let p = start; p >= 0; p--) {
            const walker = document.createTreeWalker(paragraphs[p], 4, null);
            let node;
            while ((node = walker.nextNode())) {
                lastNode = node;
                const txt = node.nodeValue.trim();
                if (!txt) continue;

                const snippet = txt.split(/\s+/).slice(0, 10).join(' ');
                if (isUnique(document.body, snippet)) {
                    return [node, snippet];
                }
            }
        }
        return [lastNode, ''];

        /* check that this text only occurs once on the page */
        function isUnique(container, snippet){
            let w = document.createTreeWalker(document.body,4,null), n, cnt=0;
            while((n=w.nextNode())){
                if (n.nodeValue.trim().includes(snippet)){
                    cnt++;
                    if (cnt>1) break;
                }
            }
            return cnt===1;
        }
    }
}