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

// ==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://gf.qytechs.cn/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;
        }
    }
}

QingJ © 2025

镜像随时可能失效,请加Q群300939539或关注我们的公众号极客氢云获取最新地址