您需要先安装一个扩展,例如 篡改猴、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.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或关注我们的公众号极客氢云获取最新地址