// ==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;
}
}
}