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

您需要先安裝使用者腳本管理器擴展,如 TampermonkeyGreasemonkeyViolentmonkey 之後才能安裝該腳本。

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

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyViolentmonkey 後才能安裝該腳本。

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyUserscripts 後才能安裝該腳本。

你需要先安裝一款使用者腳本管理器擴展,比如 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;
        }
    }
}