Skip to content

Instantly share code, notes, and snippets.

@misaalanshori
Last active March 4, 2026 14:14
Show Gist options
  • Select an option

  • Save misaalanshori/1f8b7fd73b405ca0a5f162649579a6fe to your computer and use it in GitHub Desktop.

Select an option

Save misaalanshori/1f8b7fd73b405ca0a5f162649579a6fe to your computer and use it in GitHub Desktop.
Bookmarklet to look at your Archive of Our Own history in a different way, I guess... (add contents of ao3history.bookmark.js as a bookmark, or paste the contents of ao3history.js to your browser js console. Run it while in an AO3 page)
javascript:(async function(){let t=.8;const e="ao3_history_shitty_cache";let n=[],o=[],a={},i="",r=[],s=[];const l=t=>new Promise(e=>setTimeout(e,t)),d=t=>parseInt((t||"0").replace(/,/g,""),10)||0;function c(t){const e=document.getElementById("ao3-loading-text");e&&(e.innerText=t)}async function p(t,e,n=5,o=1e4){for(let a=0;a<n;a++){c(`Fetching page ${e}... ${a>0?`(Retry ${a}/${n})`:""}`);const i=new AbortController,r=setTimeout(()=>i.abort(),o);try{const n=await fetch(t,{signal:i.signal});if(clearTimeout(r),429===n.status){c(`Hit Rate Limit (429) on page ${e}. Pausing for 5 seconds to cool down...`),await l(5e3);continue}if(!n.ok){c(`Network error ${n.status} on page ${e}. Retrying...`),await l(3e3);continue}return await n.text()}catch(t){clearTimeout(r),c(`Connection timed out or failed on page ${e}. Retrying...`),await l(3e3)}}throw Error(`Failed to fetch ${t} after ${n} attempts.`)}function m(t){const e=t.querySelector(".pagination .current"),n=e?parseInt(e.textContent,10):1,o=Array.from(t.querySelectorAll(".pagination li:not(.next):not(.previous) a, .pagination li:not(.next):not(.previous) span.current")).map(t=>parseInt(t.textContent,10)).filter(t=>!isNaN(t)),a=o.length>0?Math.max(...o):1,i={},r=t.querySelectorAll("li.reading.work.blurb");return{works:Array.from(r).map(t=>{const e=t.id.replace("work_",""),n=t.querySelector("h4.heading a:first-child"),o=n?n.textContent.trim():"Unknown Title",a=n?new URL(n.getAttribute("href"),window.location.origin).href:"",r=t.querySelectorAll('h4.heading a[rel="author"]'),s=Array.from(r).map(t=>t.textContent.trim());0===s.length&&t.querySelector("h4.heading")?.textContent.includes("Anonymous")&&s.push("Anonymous"),t.querySelectorAll('a.tag, a[rel="author"]').forEach(t=>{const e=t.textContent.trim(),n=t.getAttribute("href");n&&!i[e]&&(i[e]=new URL(n,window.location.origin).href)});const l=Array.from(t.querySelectorAll(".fandoms.heading a.tag")).map(t=>t.textContent.trim()),c=Array.from(t.querySelectorAll(".required-tags span.text")).map(t=>t.textContent.trim()),p={warnings:Array.from(t.querySelectorAll("li.warnings a.tag")).map(t=>t.textContent.trim()),relationships:Array.from(t.querySelectorAll("li.relationships a.tag")).map(t=>t.textContent.trim()),characters:Array.from(t.querySelectorAll("li.characters a.tag")).map(t=>t.textContent.trim()),freeforms:Array.from(t.querySelectorAll("li.freeforms a.tag")).map(t=>t.textContent.trim())},m=Array.from(t.querySelectorAll("blockquote.summary p")).map(t=>t.textContent.trim()).join("\n"),u={};t.querySelectorAll("dl.stats dt").forEach(t=>{const e=t.className,n=t.nextElementSibling;n&&"dd"===n.tagName.toLowerCase()&&n.className.includes(e)&&(u[e]=n.textContent.trim())});let g=1,f="Unknown";const h=t.querySelector("h4.viewed.heading");if(h){const t=h.textContent.trim();if(t.includes("Visited once"))g=1;else{const e=t.match(/Visited\s+(\d+)\s+times/i);e&&(g=parseInt(e[1],10))}const e=t.match(/(?:Last visited:\s*)([0-9]{2}\s[a-zA-Z]{3}\s[0-9]{4})/i);e&&(f=e[1])}const x=t.querySelector(".header.module .datetime"),b=x?x.textContent.trim():"Unknown",y=d(u.words),v=(u.chapters||"1/1").split("/")[0].replace(/,/g,""),w=Math.round(g*(y/(parseInt(v,10)||1)))||0;let k=y>0?(w/y).toFixed(2):"0.00";return{workId:e,title:o,url:a,authors:s,fandoms:l,requiredTags:c,tags:p,summary:m,stats:u,visitCount:g,lastVisited:f,updatedDate:b,estimatedWordsRead:w,estimatedReadThrough:k}}),pagination:{currentPage:n,maxPage:a},tagMap:i}}async function u(t,e=[],n=2){try{const o=await p(t,n),i=(new DOMParser).parseFromString(o,"text/html"),r=m(i);e.push(r),Object.assign(a,r.tagMap);const s=i.querySelector("li.next a");if(s){const t=new URL(s.getAttribute("href"),window.location.origin).href;return await l(500),u(t,e,r.pagination.currentPage+1)}return c("Finished fetching all pages!"),e}catch(t){return c("Encountered a fatal error. Generating UI with recovered data..."),await l(1500),e}}function g(){const t=new Set;n.forEach(e=>{e.fandoms.forEach(e=>t.add(e)),Object.values(e.tags).flat().forEach(e=>t.add(e))}),o=Array.from(t).sort((t,e)=>t.toLowerCase().localeCompare(e.toLowerCase()))}function f(t,e){const n=e.toLowerCase();return t.filter(t=>t.toLowerCase().includes(n)).sort((t,e)=>{const o=t.toLowerCase(),a=e.toLowerCase();if(o===n)return-1;if(a===n)return 1;if(o.startsWith(n)&&!a.startsWith(n))return-1;if(!o.startsWith(n)&&a.startsWith(n))return 1;const i=RegExp(`\\b${n}\\b`);return i.test(o)&&!i.test(a)?-1:!i.test(o)&&i.test(a)?1:t.length!==e.length?t.length-e.length:o.localeCompare(a)})}function h(){const t=document.getElementById("history-search").value.toLowerCase(),e=document.getElementById("history-sort").value,o=document.getElementById("filter-status").value,a=document.getElementById("filter-rating").value,i=parseInt(document.getElementById("filter-min-words").value,10),l=parseInt(document.getElementById("filter-max-words").value,10),c=parseInt(document.getElementById("filter-min-chapters").value,10),p=parseInt(document.getElementById("filter-max-chapters").value,10),m=parseInt(document.getElementById("filter-min-kudos").value,10),u=parseInt(document.getElementById("filter-max-kudos").value,10),g=parseInt(document.getElementById("filter-min-hits").value,10),f=parseInt(document.getElementById("filter-max-hits").value,10),h=parseInt(document.getElementById("filter-min-bookmarks").value,10),x=parseInt(document.getElementById("filter-max-bookmarks").value,10),b=parseInt(document.getElementById("filter-min-visits").value,10),v=parseInt(document.getElementById("filter-max-visits").value,10),w=parseFloat(document.getElementById("filter-min-reads").value),k=parseFloat(document.getElementById("filter-max-reads").value);let T=n.filter(e=>{const n=`${e.title} ${e.authors.join(" ")} ${e.fandoms.join(" ")} ${Object.values(e.tags).flat().join(" ")} ${e.summary}`.toLowerCase();if(t&&!n.includes(t))return!1;if("any"!==o){const t=e.requiredTags.includes("Complete Work");if("complete"===o&&!t)return!1;if("wip"===o&&t)return!1}if("any"!==a&&!e.requiredTags.includes(a))return!1;const y=[...Object.values(e.tags).flat(),...e.fandoms].map(t=>t.toLowerCase());if(r.length>0&&!r.every(t=>y.some(e=>e.includes(t.toLowerCase()))))return!1;if(s.length>0&&s.some(t=>y.some(e=>e.includes(t.toLowerCase()))))return!1;const T=d(e.stats.words);if(!isNaN(i)&&T<i)return!1;if(!isNaN(l)&&T>l)return!1;const E=parseInt((e.stats.chapters||"1").split("/")[0].replace(/,/g,""),10)||1;if(!isNaN(c)&&E<c)return!1;if(!isNaN(p)&&E>p)return!1;const S=d(e.stats.kudos);if(!isNaN(m)&&S<m)return!1;if(!isNaN(u)&&S>u)return!1;const A=d(e.stats.hits);if(!isNaN(g)&&A<g)return!1;if(!isNaN(f)&&A>f)return!1;const L=d(e.stats.bookmarks);if(!isNaN(h)&&L<h)return!1;if(!isNaN(x)&&L>x)return!1;if(!isNaN(b)&&e.visitCount<b)return!1;if(!isNaN(v)&&e.visitCount>v)return!1;const C=parseFloat(e.estimatedReadThrough);return!(!isNaN(w)&&C<w||!isNaN(k)&&C>k)});T.sort((t,n)=>"visits"===e?n.visitCount-t.visitCount:"kudos"===e?d(n.stats.kudos)-d(t.stats.kudos):"words"===e?d(n.stats.words)-d(t.stats.words):"recent"===e?new Date(n.lastVisited).getTime()-new Date(t.lastVisited).getTime():"estimated_words"===e?n.estimatedWordsRead-t.estimatedWordsRead:"read_throughs"===e?parseFloat(n.estimatedReadThrough)-parseFloat(t.estimatedReadThrough):"updated"===e?new Date(n.updatedDate).getTime()-new Date(t.updatedDate).getTime():0);const E=T.reduce((t,e)=>t+e.estimatedWordsRead,0);document.getElementById("total-words-read").textContent=E.toLocaleString(),y(T)}window.updateReadThreshold=function(){const e=prompt("Enter the minimum read-through threshold for 'Longest Fic Read' (e.g., 0.5, 0.8, 1):",t);if(null===e)return;const n=parseFloat(e);if(!isNaN(n)&&n>=0){t=n;const e=document.getElementById("stats-modal-body");e&&(e.innerHTML=b())}else alert("Invalid input. Please enter a valid positive number.")};const x="\n .stats-grid { display: flex; flex-wrap: wrap; gap: 20px; justify-content: center; align-items: stretch; box-sizing: border-box; }\n .stat-card { flex: 1 1 260px; max-width: 380px; background: white; border: 1px solid #eaeaea; border-radius: 8px; padding: 20px; box-shadow: 0 4px 6px rgba(0,0,0,0.02); display: flex; flex-direction: column; box-sizing: border-box; }\n .stat-card h3 { text-align: center; margin: 0 0 15px 0; color: #900; font-size: 1.1em; border-bottom: 2px solid #f4f4f4; padding-bottom: 8px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.5px; }\n .big-card { flex: 1 1 100%; max-width: 100%; background: transparent; border: none; box-shadow: none; padding: 0; }\n .big-card h3 { display: none; }\n .wide-card { flex: 1 1 100%; max-width: 100%; }\n .big-stats-container { display: flex; flex-wrap: wrap; justify-content: center; gap: 15px; margin-bottom: 10px; }\n .big-stat { flex: 1 1 180px; max-width: 220px; background: #fff; border: 1px solid #eaeaea; border-radius: 8px; padding: 20px 10px; display: flex; flex-direction: column; align-items: center; text-align: center; font-size: 0.95em; color: #666; font-weight: 600; text-transform: uppercase; letter-spacing: 0.5px; box-shadow: 0 4px 6px rgba(0,0,0,0.02); }\n .big-stat span { font-size: 2.8em; color: #900; line-height: 1; margin-bottom: 8px; font-weight: 700; }\n .list-card ul { list-style: none; padding: 0; margin: 0; font-size: 0.95em; }\n .list-card li { display: flex; justify-content: space-between; align-items: center; padding: 8px 0; border-bottom: 1px solid #f4f4f4; }\n .list-card li:last-child { border-bottom: none; }\n .stat-name { color: #444; font-weight: 500; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; max-width: 75%; }\n \n /* Link Styling Override - No underlines unless hovered */\n .stat-name a { color: inherit; font-weight: inherit; text-decoration: none !important; cursor: pointer; }\n .stat-name a:hover { text-decoration: underline !important; }\n .stat-card > a, .stat-card > div > a, .stat-card strong > a { color: #900; text-decoration: none !important; font-weight: bold; }\n .stat-card > a:hover, .stat-card > div > a:hover, .stat-card strong > a:hover { text-decoration: underline !important; }\n\n .stat-num { color: #900; font-weight: bold; background: #fdf2f2; border: 1px solid #f5dcdc; padding: 2px 8px; border-radius: 12px; font-size: 0.85em; }\n .stat-card p { margin: 0 0 8px 0; font-size: 0.95em; color: #555; line-height: 1.5; }\n .analytics-item { background: #fdfdfd; border: 1px solid #eee; padding: 10px 15px; border-radius: 6px; display: flex; justify-content: space-between; align-items: center; font-size: 0.95em; }\n .analytics-item strong { color: #555; font-weight: 600; }\n .center-item { flex-direction: column; text-align: center; gap: 6px; padding: 12px 10px; }\n .center-item strong { text-transform: uppercase; font-size: 0.85em; letter-spacing: 0.5px; }\n";function b(){const e=n.length;let o=0,i=0,r=0,s=0;const l={},c={},p={},m={},u={},g={},f={"General Audiences":0,"Teen And Up Audiences":0,Mature:0,Explicit:0};let h=null,x=null;n.forEach(e=>{o+=e.visitCount,i+=e.estimatedWordsRead,r+=d(e.stats.words),e.requiredTags.includes("Complete Work")&&s++,e.requiredTags.forEach(t=>{void 0!==f[t]&&f[t]++}),e.authors.forEach(t=>{"Anonymous"!==t&&(l[t]=(l[t]||0)+1)}),e.fandoms.forEach(t=>{c[t]=(c[t]||0)+1}),e.tags.characters.forEach(t=>{u[t]=(u[t]||0)+1}),e.tags.freeforms.forEach(t=>{g[t]=(g[t]||0)+1}),e.tags.relationships.forEach(t=>{t.includes("/")?p[t]=(p[t]||0)+1:m[t]=(m[t]||0)+1}),parseFloat(e.estimatedReadThrough)>=t&&(!h||d(e.stats.words)>d(h.stats.words))&&(h=e),(!x||parseFloat(e.estimatedReadThrough)>parseFloat(x.estimatedReadThrough))&&(x=e)});const b=(t,e=5)=>Object.entries(t).sort((t,e)=>e[1]-t[1]).slice(0,e),y=b(l),v=b(c),w=b(p),k=b(m),T=b(u),E=b(g,10),S=(i/8e4).toFixed(1),A=t=>t.map(t=>{const e=t[0],n=e.replace(/"/g,"&quot;"),o=t[1].toLocaleString();return`<li>\n <span class="stat-name" title="${n}">\n <a href="${a[e]||"https://archiveofourown.org/works/search?work_search[query]="+encodeURIComponent(e)}" target="_blank">${e}</a>\n </span> \n <span class="stat-num">${o}</span>\n </li>`}).join("");return`\n <div class="stats-grid" id="capture-stats-container">\n <div class="stat-card big-card">\n <div class="big-stats-container">\n <div class="big-stat"><span>${e.toLocaleString()}</span> Fics Read</div>\n <div class="big-stat"><span>${o.toLocaleString()}</span> Total Visits</div>\n <div class="big-stat"><span>${i.toLocaleString()}</span> Words Read</div>\n <div class="big-stat"><span>${S}</span> Novels Eqv.</div>\n </div>\n </div>\n <div class="stat-card list-card"><h3>Top Fandoms</h3><ul>${A(v)}</ul></div>\n <div class="stat-card list-card"><h3>Top Romantic Ships</h3><ul>${A(w)}</ul></div>\n <div class="stat-card list-card"><h3>Top Platonic Pairings</h3><ul>${A(k)}</ul></div>\n <div class="stat-card list-card"><h3>Top Characters</h3><ul>${A(T)}</ul></div>\n <div class="stat-card list-card"><h3>Top Authors</h3><ul>${A(y)}</ul></div>\n <div class="stat-card list-card wide-card">\n <h3>Top 10 Tags & Tropes</h3>\n <ul style="display: grid; grid-template-columns: repeat(auto-fit, minmax(250px, 1fr)); column-gap: 30px; justify-content: center;">\n ${A(E)}\n </ul>\n </div>\n <div class="stat-card">\n <h3>Hall of Fame</h3>\n <div style="display: flex; flex-direction: column; gap: 15px; text-align: center; height: 100%; justify-content: center;">\n <div>\n <strong style="color:#666; text-transform: uppercase; font-size: 0.85em; letter-spacing: 0.5px; display:block; margin-bottom: 4px;">Most Re-read</strong>\n <a href="${x?.url||"#"}" target="_blank" style="font-size: 1.1em;" title="${x?.title?x.title.replace(/"/g,"&quot;"):""}">${x?.title||"None"}</a><br/>\n <span class="stat-num" style="display:inline-block; margin-top:6px;">${x?.estimatedReadThrough||"0"}x read-throughs</span>\n </div>\n <div style="border-bottom: 1px solid #f4f4f4;"></div>\n <div>\n <strong style="color:#666; text-transform: uppercase; font-size: 0.85em; letter-spacing: 0.5px; display:block; margin-bottom: 4px;">\n Longest Fic Read <span onclick="window.updateReadThreshold()" style="cursor: pointer; text-decoration: underline !important; color: #cc0000;" title="Click to change threshold">(≥${t}x)</span>\n </strong>\n <a href="${h?.url||"#"}" target="_blank" style="font-size: 1.1em;" title="${h?.title?h.title.replace(/"/g,"&quot;"):""}">${h?.title||"None met criteria"}</a><br/>\n <span class="stat-num" style="display:inline-block; margin-top:6px;">${(h?d(h.stats.words):0).toLocaleString()} words</span>\n </div>\n </div>\n </div>\n <div class="stat-card">\n <h3>Archive Analytics</h3>\n <div style="display: flex; flex-direction: column; gap: 10px; margin-bottom: 15px;">\n <div class="analytics-item"><strong>Avg. Fic Length:</strong> <span class="stat-num">${Math.round(r/(e||1)).toLocaleString()} words</span></div>\n <div class="analytics-item"><strong>Completion Rate:</strong> <span class="stat-num">${Math.round(s/(e||1)*100)}%</span></div>\n </div>\n <h4 style="margin: 0 0 10px 0; font-size: 0.85em; color: #777; text-transform: uppercase; text-align: center;">Ratings Breakdown</h4>\n <div style="display: grid; grid-template-columns: 1fr 1fr; gap: 10px;">\n <div class="analytics-item center-item"><strong>General</strong> <span class="stat-num">${f["General Audiences"].toLocaleString()}</span></div>\n <div class="analytics-item center-item"><strong>Teen</strong> <span class="stat-num">${f["Teen And Up Audiences"].toLocaleString()}</span></div>\n <div class="analytics-item center-item"><strong>Mature</strong> <span class="stat-num">${f.Mature.toLocaleString()}</span></div>\n <div class="analytics-item center-item"><strong>Explicit</strong> <span class="stat-num">${f.Explicit.toLocaleString()}</span></div>\n </div>\n </div>\n </div>\n `}function y(t){const e=document.getElementById("works-container");document.getElementById("works-count").textContent=t.length;let n="";t.forEach(t=>{const e=Object.values(t.tags).flat().join(", "),o=t.fandoms.join(", "),a=t.requiredTags.includes("Complete Work")?"Complete":"WIP";let i=`<div style="font-size: 0.85em; margin-bottom: 5px; color: #444;"><strong>Fandoms:</strong> ${o||"None"}</div>`;e.length>120?i+=`\n <details class="collapsible-block" style="font-size: 0.85em; margin-bottom: 10px; color: #444;">\n <summary style="cursor: pointer;"><strong>Tags:</strong> <span class="collapsed-text">${e.substring(0,120).trim()}... <span style="color:#900; font-weight:bold;">(Show all)</span></span><span class="expanded-text" style="color:#900; font-weight:bold;">(Hide tags)</span></summary>\n <div style="margin-top: 8px; padding-left: 10px; border-left: 2px solid #ccc;"><strong>All Tags:</strong> ${e}</div>\n </details>`:i+=`<div style="font-size: 0.85em; margin-bottom: 10px; color: #444;"><strong>Tags:</strong> ${e||"None"}</div>`;let r='<div style="font-size: 0.9em; background: #fff; padding: 10px; border-left: 3px solid #ccc;"><em>No summary provided.</em></div>';t.summary&&(r=t.summary.length>200?`\n <details class="collapsible-block" style="font-size: 0.9em; background: #fff; padding: 10px; border-left: 3px solid #ccc;">\n <summary style="cursor: pointer;"><span class="collapsed-text">${(t.summary.substring(0,200).trim()+"...").replace(/\n/g,"<br/>")} <span style="color:#900; font-weight:bold;">(Read more)</span></span><span class="expanded-text" style="color:#900; font-weight:bold;">(Hide summary)</span></summary>\n <div style="margin-top: 5px; padding-top: 10px; border-top: 1px dashed #eee;">${t.summary.replace(/\n/g,"<br/>")}<div style="margin-top: 10px; text-align: right;"><span onclick="this.closest('details').open = false" style="cursor: pointer; color: #900; font-weight: bold; font-size: 0.9em;">(Show less)</span></div></div>\n </details>`:`<div style="font-size: 0.9em; background: #fff; padding: 10px; border-left: 3px solid #ccc;">${t.summary.replace(/\n/g,"<br/>")}</div>`),n+=`\n <li class="work-card">\n <div class="visit-badge-absolute">Visited ${t.visitCount} ${1===t.visitCount?"time":"times"}</div>\n <div class="visit-badge-inline">Visited ${t.visitCount} ${1===t.visitCount?"time":"times"}</div>\n <h3 class="work-title"><a href="${t.url}" target="_blank" style="color: #900; text-decoration: none;">${t.title}</a> ${t.authors.length?"by "+t.authors.join(", "):""}</h3>\n <div style="font-size: 0.9em; color: #555; margin-bottom: 10px; line-height: 1.6;">\n <strong>Last Visited:</strong> ${t.lastVisited} | <strong>Updated:</strong> ${t.updatedDate} | <strong>Status:</strong> ${a}<br/>\n <strong>Words:</strong> ${t.stats.words||"0"} | <strong>Chapters:</strong> ${t.stats.chapters||"1/1"} | <strong>Kudos:</strong> ${t.stats.kudos||"0"} | <strong>Bookmarks:</strong> ${t.stats.bookmarks||"0"} | <strong>Hits:</strong> ${t.stats.hits||"0"}<br/>\n <strong style="color: #900;">Estimated Read: ${t.estimatedWordsRead.toLocaleString()} words (${t.estimatedReadThrough}x read-throughs)</strong>\n </div>\n ${i}${r}\n </li>\n `}),e.innerHTML=n}function v(){const e=document.getElementById("export-stats-btn"),o=e.innerText;e.innerText="📸 Painting Canvas...",setTimeout(()=>{try{const p=n.length;let m=0,u=0,g=0,f=0;const h={},x={},b={},y={},v={},w={},k={"General Audiences":0,"Teen And Up Audiences":0,Mature:0,Explicit:0};let T=null,E=null;n.forEach(e=>{m+=e.visitCount,u+=e.estimatedWordsRead,g+=d(e.stats.words),e.requiredTags.includes("Complete Work")&&f++,e.requiredTags.forEach(t=>{void 0!==k[t]&&k[t]++}),e.authors.forEach(t=>{"Anonymous"!==t&&(h[t]=(h[t]||0)+1)}),e.fandoms.forEach(t=>{x[t]=(x[t]||0)+1}),e.tags.characters.forEach(t=>{v[t]=(v[t]||0)+1}),e.tags.freeforms.forEach(t=>{w[t]=(w[t]||0)+1}),e.tags.relationships.forEach(t=>{t.includes("/")?b[t]=(b[t]||0)+1:y[t]=(y[t]||0)+1}),parseFloat(e.estimatedReadThrough)>=t&&(!T||d(e.stats.words)>d(T.stats.words))&&(T=e),(!E||parseFloat(e.estimatedReadThrough)>parseFloat(E.estimatedReadThrough))&&(E=e)});const S=(t,e=5)=>Object.entries(t).sort((t,e)=>e[1]-t[1]).slice(0,e),A=S(h),L=S(x),C=S(b),$=S(y),I=S(v),R=S(w,10),N=(u/8e4).toFixed(1),z=document.createElement("canvas");z.width=1e3,z.height=1735;const M=z.getContext("2d");function a(t,e,n,o,a,i,r,s){t.beginPath(),t.moveTo(e+i,n),t.lineTo(e+o-i,n),t.quadraticCurveTo(e+o,n,e+o,n+i),t.lineTo(e+o,n+a-i),t.quadraticCurveTo(e+o,n+a,e+o-i,n+a),t.lineTo(e+i,n+a),t.quadraticCurveTo(e,n+a,e,n+a-i),t.lineTo(e,n+i),t.quadraticCurveTo(e,n,e+i,n),t.closePath(),r&&(t.fillStyle=r,t.fill()),s&&(t.strokeStyle=s,t.lineWidth=1,t.stroke())}function r(t,e,n,o,a,i,r="left"){t.font=a,t.fillStyle=i,t.textAlign=r,t.fillText(e,n,o)}function s(t,e,n,o,a,i,r,s="left"){t.font=i,t.fillStyle=r,t.textAlign=s;let l=e;if(t.measureText(l).width>a){for(;l.length>0&&t.measureText(l+"...").width>a;)l=l.slice(0,-1);l+="..."}t.fillText(l,n,o)}M.fillStyle="#f4f4f4",M.fillRect(0,0,1e3,1735);const B=i?i.toUpperCase()+"'S AO3 READING ANALYTICS":"MY AO3 READING ANALYTICS";function l(t,e,n,o,i,l,d,c=!1){if(a(t,e,n,o,i,8,"#ffffff","#eaeaea"),r(t,l.toUpperCase(),e+o/2,n+40,"bold 18px sans-serif","#900000","center"),t.beginPath(),t.moveTo(e+20,n+55),t.lineTo(e+o-20,n+55),t.lineWidth=2,t.strokeStyle="#f4f4f4",t.stroke(),0===d.length)return void r(t,"No data available",e+o/2,n+130,"italic 16px sans-serif","#999999","center");const p=n+85;d.forEach((n,i)=>{let l=e+20,m=p+32*i,u=o-100,g=e+o-20;c&&(l=i<5?e+20:e+o/2+20,m=p+i%5*32,u=o/2-80,g=i<5?e+o/2-20:e+o-20),s(t,n[0],l,m,u,"500 15px sans-serif","#444444","left");const f=n[1].toLocaleString()+"";t.font="bold 13px sans-serif";const h=t.measureText(f).width;a(t,g-h-10,m-17,h+20,24,12,"#fdf2f2",null),r(t,f,g,m-1,"bold 13px sans-serif","#900000","right"),c&&(4===i||9===i)||i===d.length-1||(t.beginPath(),t.setLineDash([2,2]),t.moveTo(l,m+12),t.lineTo(g,m+12),t.lineWidth=1,t.strokeStyle="#f4f4f4",t.stroke(),t.setLineDash([]))})}r(M,B,500,70,"bold 34px sans-serif","#900000","center"),M.beginPath(),M.moveTo(350,95),M.lineTo(650,95),M.lineWidth=3,M.strokeStyle="#900000",M.stroke(),[{v:p.toLocaleString(),l:"FICS READ"},{v:m.toLocaleString(),l:"TOTAL VISITS"},{v:u.toLocaleString(),l:"WORDS READ"},{v:N,l:"NOVELS EQUIVALENT"}].forEach((t,e)=>{const n=45+232.5*e;a(M,n,130,212,110,8,"#ffffff","#eaeaea"),r(M,t.v,n+106,195,"bold 36px sans-serif","#900000","center"),r(M,t.l,n+106,220,"bold 12px sans-serif","#666666","center")}),l(M,45,270,445,245,"Top Fandoms",L),l(M,510,270,445,245,"Top Authors",A),l(M,45,535,445,245,"Top Romantic Ships",C),l(M,510,535,445,245,"Top Platonic Pairings",$),l(M,277.5,800,445,245,"Top Characters",I),l(M,45,1065,910,245,"Top 10 Tags & Tropes",R,!0);const q=1330;a(M,45,q,445,320,8,"#ffffff","#eaeaea"),r(M,"HALL OF FAME",267.5,q+40,"bold 18px sans-serif","#900000","center"),M.beginPath(),M.moveTo(65,q+55),M.lineTo(470,q+55),M.lineWidth=2,M.strokeStyle="#f4f4f4",M.stroke(),r(M,"MOST RE-READ",267.5,q+95,"bold 12px sans-serif","#666666","center"),s(M,E?.title||"None",267.5,q+125,400,"bold 20px sans-serif","#900000","center");const j=(E?.estimatedReadThrough||"0")+"x read-throughs";M.font="bold 14px sans-serif";const F=M.measureText(j).width;a(M,267.5-F/2-10,q+140,F+20,24,12,"#fdf2f2",null),r(M,j,267.5,q+157,"bold 14px sans-serif","#900000","center"),M.beginPath(),M.moveTo(100,q+195),M.lineTo(390,q+195),M.lineWidth=1,M.strokeStyle="#f4f4f4",M.stroke(),r(M,`LONGEST FIC READ (≥${t}X)`,267.5,q+230,"bold 12px sans-serif","#666666","center"),s(M,T?.title||"None met criteria",267.5,q+260,400,"bold 20px sans-serif","#900000","center");const W=(T?d(T.stats.words).toLocaleString():"0")+" words";M.font="bold 14px sans-serif";const O=M.measureText(W).width;a(M,267.5-O/2-10,q+275,O+20,24,12,"#fdf2f2",null),r(M,W,267.5,q+292,"bold 14px sans-serif","#900000","center"),a(M,510,q,445,320,8,"#ffffff","#eaeaea"),r(M,"ARCHIVE ANALYTICS",732.5,q+40,"bold 18px sans-serif","#900000","center"),M.beginPath(),M.moveTo(530,q+55),M.lineTo(935,q+55),M.lineWidth=2,M.strokeStyle="#f4f4f4",M.stroke(),a(M,530,q+75,405,45,6,"#fdfdfd","#eeeeee"),r(M,"Average Fic Length",545,q+103,"600 15px sans-serif","#555555","left");const U=Math.round(g/(p||1)).toLocaleString()+" words";M.font="bold 14px sans-serif";const H=M.measureText(U).width;a(M,920-H-10,q+85,H+20,25,12,"#fdf2f2",null),r(M,U,920,q+103,"bold 14px sans-serif","#900000","right"),a(M,530,q+130,405,45,6,"#fdfdfd","#eeeeee"),r(M,"Completion Rate",545,q+158,"600 15px sans-serif","#555555","left");const D=Math.round(f/(p||1)*100)+"%";M.font="bold 14px sans-serif";const P=M.measureText(D).width;function c(t,e,n,o){a(M,t,e,93,65,6,"#fdfdfd","#eeeeee"),r(M,n,t+46,e+25,"bold 11px sans-serif","#555555","center"),r(M,o.toLocaleString()+"",t+46,e+55,"bold 22px sans-serif","#900000","center")}a(M,920-P-10,q+140,P+20,25,12,"#fdf2f2",null),r(M,D,920,q+158,"bold 14px sans-serif","#900000","right"),r(M,"RATINGS BREAKDOWN",732.5,q+215,"bold 12px sans-serif","#777777","center"),c(530,q+230,"GENERAL",k["General Audiences"]),c(634,q+230,"TEEN",k["Teen And Up Audiences"]),c(738,q+230,"MATURE",k.Mature),c(842,q+230,"EXPLICIT",k.Explicit);const V=z.toDataURL("image/png"),_=document.createElement("a");_.download=i?i+"_AO3_Stats.png":"AO3_Reading_Stats.png",_.href=V,_.click(),e.innerText=o}catch(G){console.error(G),alert("Failed to export image due to a canvas rendering error."),e.innerText=o}},50)}function w(){const t=document.getElementById("advanced-stats-modal");document.getElementById("stats-modal-body").innerHTML=b(),t.style.display="flex",document.body.style.overflow="hidden"}function k(t,e,n,a){const i=document.getElementById(t),r=document.getElementById(e),s=document.getElementById(n);let l=-1;function d(){r.querySelectorAll(".tag-chip").forEach(t=>t.remove()),a.forEach(t=>{const e=document.createElement("span");e.className="tag-chip",e.innerHTML=`${t} <span class="tag-chip-close" data-tag="${t.replace(/"/g,"&quot;")}">×</span>`,r.insertBefore(e,i)})}function c(t){const e=t.trim().replace(/^,|,$/g,"");e&&!a.includes(e)?(a.push(e),i.value="",p(),d(),h()):e&&(i.value="")}function p(){s.style.display="none",s.innerHTML="",l=-1}function m(t){t.forEach((t,e)=>{e===l?(t.classList.add("selected"),t.scrollIntoView({block:"nearest"})):t.classList.remove("selected")})}i.addEventListener("input",t=>{const e=t.target.value;if(!e)return void p();const n=f(o,e).slice(0,50);0!==n.length?(s.innerHTML=n.map((t,e)=>`<li data-index="${e}">${t}</li>`).join(""),s.style.display="block",l=-1,Array.from(s.children).forEach(t=>{t.addEventListener("mousedown",e=>{e.preventDefault(),c(n[parseInt(t.getAttribute("data-index"))])})})):p()}),i.addEventListener("keydown",t=>{const e=s.querySelectorAll("li");"ArrowDown"===t.key?(t.preventDefault(),e.length>0&&(l=(l+1)%e.length,m(e))):"ArrowUp"===t.key?(t.preventDefault(),e.length>0&&(l=(l-1+e.length)%e.length,m(e))):"Enter"===t.key||","===t.key?(t.preventDefault(),l>=0&&e.length>0?c(e[l].innerText):c(i.value)):"Backspace"===t.key&&""===i.value&&a.length>0&&(a.pop(),d(),h())}),i.addEventListener("blur",()=>setTimeout(p,150)),r.addEventListener("click",t=>{if(t.target.classList.contains("tag-chip-close")){const e=t.target.getAttribute("data-tag"),n=a.indexOf(e);n>-1&&(a.splice(n,1),d(),h())}else t.target===r&&i.focus()})}function T(){document.body.style.background="#fff",document.body.style.margin="0";const t=i?i+"'s ":"Your ",o=new Set;n.forEach(t=>t.requiredTags.forEach(t=>{["General Audiences","Teen And Up Audiences","Mature","Explicit","Not Rated"].includes(t)&&o.add(t)}));const a=Array.from(o).sort().map(t=>`<option value="${t}">${t}</option>`).join("");document.body.innerHTML=`\n <style>\n /* Layout & Responsive CSS */\n #main-header { display: flex; justify-content: space-between; align-items: baseline; border-bottom: 2px solid #900; padding-bottom: 10px; margin-bottom: 15px; }\n #header-buttons { display: flex; gap: 10px; }\n #stats-bar { margin-bottom: 20px; background: #f0f0f0; padding: 15px; border-radius: 6px; display: flex; justify-content: space-between; font-size: 1.1em; }\n \n .work-card { background: #f9f9f9; margin-bottom: 20px; padding: 15px; border: 1px solid #ddd; border-radius: 5px; box-shadow: 2px 2px 5px rgba(0,0,0,0.05); position: relative; }\n .work-title { margin-top: 0; font-size: 1.2em; max-width: 80%; }\n .visit-badge-absolute { position: absolute; top: 15px; right: 15px; background: #900; color: white; padding: 5px 10px; border-radius: 20px; font-size: 0.85em; font-weight: bold; }\n .visit-badge-inline { display: none; background: #900; color: white; padding: 4px 10px; border-radius: 20px; font-size: 0.85em; font-weight: bold; margin-bottom: 8px; width: max-content; }\n\n @media (max-width: 700px) {\n #main-header { flex-direction: column; align-items: center; gap: 12px; }\n #main-header h1 { font-size: 1.6em; text-align: center; }\n #header-buttons { flex-wrap: wrap; justify-content: center; align-items: center; width: 100%; }\n \n #stats-bar { flex-direction: column; gap: 10px; }\n \n .modal-content { width: 100vw; height: 100vh; max-height: 100vh; border-radius: 0; }\n \n .visit-badge-absolute { display: none; }\n .visit-badge-inline { display: inline-block; }\n .work-title { max-width: 100%; }\n }\n\n /* Base UI Styles */\n .collapsible-block summary { list-style: none; outline: none; }\n .collapsible-block summary::-webkit-details-marker { display: none; }\n .collapsible-block .expanded-text { display: none; }\n .collapsible-block[open] .collapsed-text { display: none; }\n .collapsible-block[open] .expanded-text { display: inline; }\n \n .filter-input { padding: 8px; font-size: 14px; border: 1px solid #ccc; border-radius: 4px; box-sizing: border-box; width: 100%; }\n .filter-group { flex: 1 1 200px; background: #fff; padding: 15px; border: 1px solid #eee; border-radius: 6px; box-shadow: 0 1px 3px rgba(0,0,0,0.05); }\n .filter-group h4 { margin: 0 0 12px 0; font-size: 0.85em; color: #555; text-transform: uppercase; letter-spacing: 0.5px; border-bottom: 1px solid #eee; padding-bottom: 5px; }\n .input-row { display: flex; gap: 8px; margin-bottom: 8px; }\n .input-row:last-child { margin-bottom: 0; }\n\n .utility-btn {\n display: inline-flex !important; align-items: center !important; justify-content: center !important;\n padding: 8px 14px !important; height: auto !important; line-height: 1.2 !important; font-size: 13px !important;\n border-radius: 4px !important; cursor: pointer !important; box-sizing: border-box !important;\n font-family: 'Lucida Grande', 'Lucida Sans Unicode', Verdana, Helvetica, sans-serif !important; margin: 0 !important;\n }\n .btn-default {\n background-color: #eee !important; background-image: linear-gradient(to bottom, #fff 2%, #e5e5e5 95%, #ccc 100%) !important;\n color: #333 !important; border: 1px solid #bbb !important; text-shadow: none !important; box-shadow: 0 1px 2px rgba(0,0,0,0.1) !important;\n }\n .btn-default:hover { background-image: linear-gradient(to bottom, #fff 2%, #ddd 95%, #bbb 100%) !important; }\n \n .btn-primary {\n background-color: #f9ecec !important; background-image: linear-gradient(to bottom, #fff 2%, #f9ecec 95%, #f0d8d8 100%) !important;\n color: #900 !important; font-weight: bold !important; border: 1px solid #900 !important; text-shadow: none !important; box-shadow: 0 1px 2px rgba(0,0,0,0.1) !important;\n }\n .btn-primary:hover { background-image: linear-gradient(to bottom, #fff 2%, #f0d8d8 95%, #e6c3c3 100%) !important; }\n\n .tag-input-wrapper { position: relative; width: 100%; margin-bottom: 8px; }\n .tag-input-container { display: flex; flex-wrap: wrap; gap: 6px; padding: 6px 8px; background: white; border: 1px solid #ccc; border-radius: 4px; min-height: 36px; align-items: center; cursor: text; transition: border-color 0.2s; }\n .tag-input-container:focus-within { border-color: #900; }\n .tag-input-container input { border: none !important; outline: none !important; box-shadow: none !important; flex-grow: 1; min-width: 120px; font-size: 14px; padding: 0; background: transparent; margin: 0; }\n .tag-chip { background: #eee; border: 1px solid #ddd; border-radius: 16px; padding: 2px 8px; font-size: 13px; display: flex; align-items: center; gap: 6px; color: #333; cursor: default; }\n .tag-chip-close { cursor: pointer; color: #900; font-weight: bold; line-height: 1; margin-top: -1px; padding: 0 2px; }\n .tag-chip-close:hover { color: #f00; }\n \n .custom-dropdown { display: none; position: absolute; top: 100%; left: 0; right: 0; background: #fff; border: 1px solid #ccc; max-height: 200px; overflow-y: auto; z-index: 1000; border-radius: 0 0 6px 6px; padding: 0; margin: 0; list-style: none; box-shadow: 0 4px 12px rgba(0,0,0,0.15); }\n .custom-dropdown li { padding: 8px 12px; cursor: pointer; font-size: 13px; border-bottom: 1px solid #eee; color: #333; }\n .custom-dropdown li:last-child { border-bottom: none; }\n .custom-dropdown li:hover, .custom-dropdown li.selected { background: #900; color: #fff; }\n\n #back-to-top {\n position: fixed !important; bottom: 30px !important; right: 30px !important; z-index: 9998 !important;\n width: 50px !important; height: 50px !important; border-radius: 50% !important;\n background-color: #900 !important; background-image: linear-gradient(to bottom, #a00 2%, #800 95%, #700 100%) !important;\n color: #fff !important; text-shadow: 0 -1px 0 rgba(0,0,0,0.3) !important; border: 1px solid #700 !important;\n font-size: 24px !important; font-family: sans-serif !important; font-weight: bold !important; \n cursor: pointer !important; box-shadow: 0 4px 10px rgba(0,0,0,0.3), inset 0 1px 0 rgba(255,255,255,0.2) !important;\n display: flex !important; align-items: center !important; justify-content: center !important;\n opacity: 0; visibility: hidden; transition: opacity 0.3s ease, visibility 0.3s ease, transform 0.2s ease, box-shadow 0.2s ease !important;\n padding: 0 !important; margin: 0 !important; box-sizing: border-box !important; line-height: 1 !important; text-decoration: none !important;\n }\n #back-to-top:hover { transform: translateY(-3px) !important; background-image: linear-gradient(to bottom, #b00 2%, #900 95%, #800 100%) !important; box-shadow: 0 6px 14px rgba(0,0,0,0.4), inset 0 1px 0 rgba(255,255,255,0.3) !important; }\n #back-to-top.show { opacity: 1 !important; visibility: visible !important; }\n\n /* Modal Core Styles */\n #advanced-stats-modal { display: none; position: fixed; top: 0; left: 0; width: 100vw; height: 100vh; background: rgba(0,0,0,0.8); z-index: 10000; align-items: center; justify-content: center; backdrop-filter: blur(5px); }\n .modal-content { background: #fdfdfd; width: 90%; max-width: 1000px; max-height: 85vh; border-radius: 12px; box-shadow: 0 10px 40px rgba(0,0,0,0.4); display: flex; flex-direction: column; overflow: hidden; font-family: sans-serif; }\n .modal-header { background: #900; color: white; padding: 15px 25px; display: flex; justify-content: space-between; align-items: center; border-bottom: 2px solid #700; }\n .modal-header h2 { margin: 0; font-size: 1.5em; font-weight: 600; letter-spacing: 0.5px; }\n \n .modal-close { cursor: pointer; font-size: 1.8em; font-weight: bold; color: rgba(255,255,255,0.7); transition: color 0.2s; line-height: 1; margin-left: 10px;}\n .modal-close:hover { color: #fff; }\n \n .modal-body { padding: 25px; overflow-y: auto; }\n .modal-body::-webkit-scrollbar { width: 8px; }\n .modal-body::-webkit-scrollbar-track { background: #f1f1f1; border-radius: 4px; }\n .modal-body::-webkit-scrollbar-thumb { background: #ccc; border-radius: 4px; }\n .modal-body::-webkit-scrollbar-thumb:hover { background: #aaa; }\n\n ${x}\n </style>\n\n <div style="max-width: 900px; margin: 0 auto; padding: 20px; font-family: 'Lucida Grande', 'Lucida Sans Unicode', Verdana, Helvetica, sans-serif; color: #2a2a2a; background: #fff;">\n \n <div id="main-header">\n <h1 style="color: #900; margin: 0;">Sorted Reading History</h1>\n <div id="header-buttons">\n <button id="btn-stats" class="utility-btn btn-primary">Advanced Stats</button>\n <button id="download-json" class="utility-btn btn-default">Download JSON</button>\n <button id="force-rescrape" class="utility-btn btn-default">Force Rescrape</button>\n </div>\n </div>\n \n <div style="display: flex; gap: 10px; margin-bottom: 10px; flex-wrap: wrap;">\n <input type="text" id="history-search" placeholder="Search by title, author, tags, or summary..." \n style="flex-grow: 1; padding: 12px; font-size: 16px; border: 2px solid #ccc; border-radius: 6px; box-sizing: border-box; outline: none;">\n \n <select id="history-sort" style="padding: 12px; font-size: 16px; border: 2px solid #ccc; border-radius: 6px; outline: none; background: white; cursor: pointer;">\n <option value="visits">Sort: Most Visited</option>\n <option value="read_throughs">Sort: Highest Read-Throughs</option>\n <option value="estimated_words">Sort: Highest Estimated Read Words</option>\n <option value="recent">Sort: Most Recently Visited</option>\n <option value="updated">Sort: Most Recently Updated</option>\n <option value="kudos">Sort: Highest Kudos</option>\n <option value="words">Sort: Highest Word Count</option>\n </select>\n </div>\n\n <details style="margin-bottom: 15px; background: #fafafa; border: 1px solid #ddd; border-radius: 6px; padding: 15px;">\n <summary style="cursor: pointer; font-weight: bold; color: #900; user-select: none;">\n Advanced Filters\n </summary>\n <div style="display: flex; flex-wrap: wrap; gap: 15px; margin-top: 15px;">\n \n <div class="filter-group" style="flex: 2 1 300px;">\n <h4>Content</h4>\n <div class="tag-input-wrapper">\n <div class="tag-input-container" id="include-tags-container">\n <input type="text" id="filter-include-tag" autocomplete="off" placeholder="Include Tag/Fandom">\n </div>\n <ul id="include-tags-dropdown" class="custom-dropdown"></ul>\n </div>\n <div class="tag-input-wrapper">\n <div class="tag-input-container" id="exclude-tags-container">\n <input type="text" id="filter-exclude-tag" autocomplete="off" placeholder="Exclude Tag/Fandom">\n </div>\n <ul id="exclude-tags-dropdown" class="custom-dropdown"></ul>\n </div>\n <div class="input-row">\n <select id="filter-status" class="filter-input" style="margin-bottom: 0;">\n <option value="any">Status: Any</option>\n <option value="complete">Complete</option>\n <option value="wip">Work in Progress</option>\n </select>\n <select id="filter-rating" class="filter-input" style="margin-bottom: 0;">\n <option value="any">Rating: Any</option>\n ${a}\n </select>\n </div>\n </div>\n\n <div class="filter-group">\n <h4>Length</h4>\n <div class="input-row">\n <input type="number" id="filter-min-words" class="filter-input" placeholder="Min Words" min="0" style="margin-bottom:0;">\n <input type="number" id="filter-max-words" class="filter-input" placeholder="Max Words" min="0" style="margin-bottom:0;">\n </div>\n <div class="input-row">\n <input type="number" id="filter-min-chapters" class="filter-input" placeholder="Min Chaps" min="1" style="margin-bottom:0;">\n <input type="number" id="filter-max-chapters" class="filter-input" placeholder="Max Chaps" min="1" style="margin-bottom:0;">\n </div>\n </div>\n\n <div class="filter-group">\n <h4>Engagement</h4>\n <div class="input-row">\n <input type="number" id="filter-min-kudos" class="filter-input" placeholder="Min Kudos" min="0" style="margin-bottom:0;">\n <input type="number" id="filter-max-kudos" class="filter-input" placeholder="Max Kudos" min="0" style="margin-bottom:0;">\n </div>\n <div class="input-row">\n <input type="number" id="filter-min-bookmarks" class="filter-input" placeholder="Min Bookmarks" min="0" style="margin-bottom:0;">\n <input type="number" id="filter-max-bookmarks" class="filter-input" placeholder="Max Bookmarks" min="0" style="margin-bottom:0;">\n </div>\n <div class="input-row">\n <input type="number" id="filter-min-hits" class="filter-input" placeholder="Min Hits" min="0" style="margin-bottom:0;">\n <input type="number" id="filter-max-hits" class="filter-input" placeholder="Max Hits" min="0" style="margin-bottom:0;">\n </div>\n </div>\n\n <div class="filter-group">\n <h4>Your History</h4>\n <div class="input-row">\n <input type="number" id="filter-min-visits" class="filter-input" placeholder="Min Visits" min="1" style="margin-bottom:0;">\n <input type="number" id="filter-max-visits" class="filter-input" placeholder="Max Visits" min="1" style="margin-bottom:0;">\n </div>\n <div class="input-row">\n <input type="number" step="0.1" id="filter-min-reads" class="filter-input" placeholder="Min Reads" min="0" style="margin-bottom:0;">\n <input type="number" step="0.1" id="filter-max-reads" class="filter-input" placeholder="Max Reads" min="0" style="margin-bottom:0;">\n </div>\n </div>\n\n </div>\n </details>\n \n <div id="stats-bar">\n <span><strong>Showing:</strong> <span id="works-count">0</span> works</span>\n <span><strong>Estimated Total Words Read:</strong> <span id="total-words-read" style="color: #900; font-weight: bold;">0</span></span>\n </div>\n \n <ul id="works-container" style="list-style: none; padding: 0;"></ul>\n </div>\n \n <a href="#" id="back-to-top" aria-label="Back to top" onclick="event.preventDefault();">↑</a>\n\n <div id="advanced-stats-modal">\n <div class="modal-content">\n <div class="modal-header">\n <h2>${t}History Analytics</h2>\n <div style="display: flex; gap: 15px; align-items: center;">\n <button id="export-stats-btn" class="utility-btn" style="background: #fff !important; color: #900 !important; font-weight: bold !important; box-shadow: 0 2px 4px rgba(0,0,0,0.2) !important; border:none !important;">📸 Save PNG</button>\n <span class="modal-close" onclick="document.getElementById('advanced-stats-modal').style.display='none'; document.body.style.overflow='auto';">&times;</span>\n </div>\n </div>\n <div class="modal-body" id="stats-modal-body"></div>\n </div>\n </div>\n `,k("filter-include-tag","include-tags-container","include-tags-dropdown",r),k("filter-exclude-tag","exclude-tags-container","exclude-tags-dropdown",s),["history-search","history-sort","filter-status","filter-rating","filter-min-words","filter-max-words","filter-min-chapters","filter-max-chapters","filter-min-kudos","filter-max-kudos","filter-min-hits","filter-max-hits","filter-min-bookmarks","filter-max-bookmarks","filter-min-visits","filter-max-visits","filter-min-reads","filter-max-reads"].forEach(t=>document.getElementById(t).addEventListener("input",h)),document.getElementById("btn-stats").addEventListener("click",w),document.getElementById("export-stats-btn").addEventListener("click",v),document.getElementById("advanced-stats-modal").addEventListener("click",t=>{"advanced-stats-modal"===t.target.id&&(t.target.style.display="none",document.body.style.overflow="auto")}),document.getElementById("download-json").addEventListener("click",()=>{const t=JSON.stringify(n,null,2),e=new Blob([t],{type:"application/json"}),o=URL.createObjectURL(e),a=document.createElement("a");a.href=o,a.download=i?i+"_AO3_History.json":"AO3_Reading_History.json",document.body.appendChild(a),a.click(),document.body.removeChild(a),URL.revokeObjectURL(o)}),document.getElementById("force-rescrape").addEventListener("click",()=>{confirm("Are you sure you want to clear the cache and fetch everything again?")&&(localStorage.removeItem(e),window.location.reload())});const l=document.getElementById("back-to-top");window.addEventListener("scroll",()=>{window.scrollY>400?l.classList.add("show"):l.classList.remove("show")}),l.addEventListener("click",()=>window.scrollTo({top:0,behavior:"smooth"}))}async function E(){const t=document.querySelector('#greeting a[href*="/users/"]');if(t){const e=t.getAttribute("href").match(/\/users\/([^/]+)/);i=e&&e[1]?decodeURIComponent(e[1]):t.textContent.replace(/Hi,\s*/i,"").replace(/!/g,"").trim()}const o=document.createElement("div");o.id="ao3-scraper-overlay",o.style.cssText="position: fixed; top: 0; left: 0; width: 100vw; height: 100vh; background: rgba(255,255,255,0.98); z-index: 9999999; display: flex; flex-direction: column; align-items: center; justify-content: center; font-family: sans-serif;",o.innerHTML='\n <h1 style="color: #900; margin-bottom: 10px;">Scraping AO3 History</h1>\n <div style="width: 40px; height: 40px; border: 4px solid #ccc; border-top: 4px solid #900; border-radius: 50%; animation: spin 1s linear infinite; margin-bottom: 20px;"></div>\n <style>@keyframes spin { 0% { transform: rotate(0deg); } 100% { transform: rotate(360deg); } }</style>\n <p id="ao3-loading-text" style="font-size: 1.2em; color: #333; font-weight: bold; margin-bottom: 5px;">Starting up...</p>\n <p style="font-size: 0.9em; color: #888;">(Do not close or refresh this tab)</p>\n ',document.body.appendChild(o),c("Fetching Page 1 to verify cache signature...");const r=await p(window.location.href,1),s=(new DOMParser).parseFromString(r,"text/html"),d=m(s),f=d.works.map(t=>`${t.workId}-${t.visitCount}-${t.lastVisited}`).join("|");Object.assign(a,d.tagMap);try{const t=localStorage.getItem(e);if(t){c("Checking cache...");const e=JSON.parse(t);if(e.signature===f)return c("Cache match! Loading history instantly..."),await l(500),n=e.works,a=e.tagUrls||a,g(),T(),void h()}}catch(t){console.error("Cache read failed",t)}c("Cache missed or outdated. Beginning full scrape..."),await l(800);let x=[d];const b=s.querySelector("li.next a");if(b){const t=new URL(b.getAttribute("href"),window.location.origin).href;await l(500),x=await u(t,x,2)}c("Flattening and preparing data..."),n=x.flatMap(t=>t.works),g(),c("Saving history to local cache...");try{localStorage.setItem(e,JSON.stringify({signature:f,works:n,tagUrls:a}))}catch(t){console.error("Cache save failed",t)}c("Rendering interface..."),await l(300),T(),h()}E();})();
/**
* ============================================================================
* AO3 READING HISTORY SCRAPER & ANALYZER
* A bookmarklet script to extract, cache, filter, and analyze AO3 history.
* https://github.com/misaalanshori
* ============================================================================
*/
// ============================================================================
// MODULE 1: STATE & CONFIGURATION
// ============================================================================
// Minimum estimated read-through required for a work to qualify for "Longest Fic Read"
let currentReadThreshold = 0.8;
const CACHE_KEY = 'ao3_history_shitty_cache';
// Global Data Stores
let globalWorks = [];
let uniqueTags = [];
let globalTagUrlMap = {}; // Maps tag/author names to their specific AO3 URLs
let currentUsername = ""; // Extracted from the user's dashboard greeting
// State arrays for the multi-select filter chips
let selectedIncludeTags = [];
let selectedExcludeTags = [];
// ============================================================================
// MODULE 2: UTILITY FUNCTIONS
// ============================================================================
/**
* Halts execution for a specified number of milliseconds.
* Crucial for avoiding AO3's aggressive Error 429 (Too Many Requests) rate limits.
* @param {number} ms - Milliseconds to delay
* @returns {Promise}
*/
const delay = (ms) => new Promise(resolve => setTimeout(resolve, ms));
/**
* Safely parses AO3 stat strings (which contain commas) into standard integers.
* @param {string} str - The string to parse (e.g., "1,234")
* @returns {number} The parsed integer, or 0 if invalid
*/
const parseStat = (str) => parseInt((str || '0').replace(/,/g, ''), 10) || 0;
/**
* Updates the text of the loading overlay during the initial scrape phase.
* @param {string} text - The status message to display
*/
function updateLoadingText(text) {
const el = document.getElementById('ao3-loading-text');
if (el) el.innerText = text;
}
/**
* Prompts the user to update the minimum read-through threshold for the "Longest Fic" stat.
* Exposed to the global window object so it can be triggered by inline HTML onclick attributes.
*/
window.updateReadThreshold = function() {
const input = prompt("Enter the minimum read-through threshold for 'Longest Fic Read' (e.g., 0.5, 0.8, 1):", currentReadThreshold);
if (input === null) return; // User cancelled
const parsed = parseFloat(input);
if (!isNaN(parsed) && parsed >= 0) {
currentReadThreshold = parsed;
// Re-render the stats modal body to immediately reflect the change
const modalBody = document.getElementById('stats-modal-body');
if (modalBody) {
modalBody.innerHTML = generateStatsHTML();
}
} else {
alert("Invalid input. Please enter a valid positive number.");
}
};
// ============================================================================
// MODULE 3: SCRAPING ENGINE
// ============================================================================
/**
* Fetches a single page of AO3 history with built-in retry and timeout logic.
* Specifically watches for HTTP 429 (Rate Limit) and forces a 5-second cooldown.
* @param {string} url - The URL of the page to fetch
* @param {number} pageNum - The current page number (for logging)
* @param {number} retries - Number of attempts before failing
* @param {number} timeoutMs - Max time to wait for a response
* @returns {Promise<string>} The raw HTML text of the page
*/
async function fetchPageWithRetry(url, pageNum, retries = 5, timeoutMs = 10000) {
for (let i = 0; i < retries; i++) {
updateLoadingText(`Fetching page ${pageNum}... ${i > 0 ? `(Retry ${i}/${retries})` : ''}`);
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), timeoutMs);
try {
const response = await fetch(url, { signal: controller.signal });
clearTimeout(timeoutId);
if (response.status === 429) {
updateLoadingText(`Hit Rate Limit (429) on page ${pageNum}. Pausing for 5 seconds to cool down...`);
await delay(5000);
continue;
}
if (!response.ok) {
updateLoadingText(`Network error ${response.status} on page ${pageNum}. Retrying...`);
await delay(3000);
continue;
}
return await response.text();
} catch (error) {
clearTimeout(timeoutId);
updateLoadingText(`Connection timed out or failed on page ${pageNum}. Retrying...`);
await delay(3000);
}
}
throw new Error(`Failed to fetch ${url} after ${retries} attempts.`);
}
/**
* Parses raw AO3 HTML document into structured data objects.
* Extracts works, metadata, stats, tags, and pagination info.
* @param {Document} doc - The DOMParser-generated Document object
* @returns {Object} An object containing the parsed works array, pagination data, and tag URLs
*/
function parseAO3Document(doc) {
// Extract Pagination
const currentPageElem = doc.querySelector('.pagination .current');
const currentPage = currentPageElem ? parseInt(currentPageElem.textContent, 10) : 1;
const pageLinks = Array.from(doc.querySelectorAll('.pagination li:not(.next):not(.previous) a, .pagination li:not(.next):not(.previous) span.current'))
.map(el => parseInt(el.textContent, 10)).filter(n => !isNaN(n));
const maxPage = pageLinks.length > 0 ? Math.max(...pageLinks) : 1;
const tagMap = {}; // Local map to extract fresh URLs for this specific page
// Extract Works
const workElements = doc.querySelectorAll('li.reading.work.blurb');
const works = Array.from(workElements).map(workEl => {
const workId = workEl.id.replace('work_', '');
const titleEl = workEl.querySelector('h4.heading a:first-child');
const title = titleEl ? titleEl.textContent.trim() : 'Unknown Title';
const workUrl = titleEl ? new URL(titleEl.getAttribute('href'), window.location.origin).href : '';
const authorEls = workEl.querySelectorAll('h4.heading a[rel="author"]');
const authors = Array.from(authorEls).map(a => a.textContent.trim());
if (authors.length === 0 && workEl.querySelector('h4.heading')?.textContent.includes('Anonymous')) {
authors.push('Anonymous');
}
// Scrape Tag & Author URLs directly to map them perfectly without guessing encoding
workEl.querySelectorAll('a.tag, a[rel="author"]').forEach(a => {
const name = a.textContent.trim();
const href = a.getAttribute('href');
if (href && !tagMap[name]) {
tagMap[name] = new URL(href, window.location.origin).href;
}
});
const fandoms = Array.from(workEl.querySelectorAll('.fandoms.heading a.tag')).map(a => a.textContent.trim());
const requiredTags = Array.from(workEl.querySelectorAll('.required-tags span.text')).map(span => span.textContent.trim());
// Categorize tags based on AO3's semantic classes
const tags = {
warnings: Array.from(workEl.querySelectorAll('li.warnings a.tag')).map(a => a.textContent.trim()),
relationships: Array.from(workEl.querySelectorAll('li.relationships a.tag')).map(a => a.textContent.trim()),
characters: Array.from(workEl.querySelectorAll('li.characters a.tag')).map(a => a.textContent.trim()),
freeforms: Array.from(workEl.querySelectorAll('li.freeforms a.tag')).map(a => a.textContent.trim())
};
const summary = Array.from(workEl.querySelectorAll('blockquote.summary p')).map(p => p.textContent.trim()).join('\n');
// Extract definitions list (Kudos, Hits, Words, etc.)
const stats = {};
workEl.querySelectorAll('dl.stats dt').forEach(dt => {
const key = dt.className;
const dd = dt.nextElementSibling;
if (dd && dd.tagName.toLowerCase() === 'dd' && dd.className.includes(key)) {
stats[key] = dd.textContent.trim();
}
});
// Extract personal history details
let visitCount = 1;
let lastVisited = 'Unknown';
const viewedHeading = workEl.querySelector('h4.viewed.heading');
if (viewedHeading) {
const viewedText = viewedHeading.textContent.trim();
if (viewedText.includes('Visited once')) visitCount = 1;
else {
const visitMatch = viewedText.match(/Visited\s+(\d+)\s+times/i);
if (visitMatch) visitCount = parseInt(visitMatch[1], 10);
}
const dateMatch = viewedText.match(/(?:Last visited:\s*)([0-9]{2}\s[a-zA-Z]{3}\s[0-9]{4})/i);
if (dateMatch) lastVisited = dateMatch[1];
}
const updateDateEl = workEl.querySelector('.header.module .datetime');
const updatedDate = updateDateEl ? updateDateEl.textContent.trim() : 'Unknown';
// Calculate custom metrics
const totalWords = parseStat(stats.words);
const rawChapterString = (stats.chapters || '1/1').split('/')[0].replace(/,/g, '');
const publishedChapters = parseInt(rawChapterString, 10) || 1;
const estimatedWordsRead = Math.round(visitCount * (totalWords / publishedChapters)) || 0;
let estimatedReadThrough = totalWords > 0 ? (estimatedWordsRead / totalWords).toFixed(2) : '0.00';
return {
workId, title, url: workUrl, authors, fandoms, requiredTags, tags,
summary, stats, visitCount, lastVisited, updatedDate,
estimatedWordsRead, estimatedReadThrough
};
});
return { works, pagination: { currentPage, maxPage }, tagMap };
}
/**
* Recursively scrapes all pages of the user's history.
* @param {string} currentUrl - The URL of the current page to process
* @param {Array} resultsArray - Accumulator array carrying data from previous recursive calls
* @param {number} pageNum - Tracker for logging current page
* @returns {Promise<Array>} The fully populated results array containing all works
*/
async function scrapeAllHistory(currentUrl, resultsArray = [], pageNum = 2) {
try {
const htmlString = await fetchPageWithRetry(currentUrl, pageNum);
const parser = new DOMParser();
const doc = parser.parseFromString(htmlString, 'text/html');
const parsedData = parseAO3Document(doc);
resultsArray.push(parsedData);
// Merge new URLs into our global dictionary
Object.assign(globalTagUrlMap, parsedData.tagMap);
const nextLink = doc.querySelector('li.next a');
if (nextLink) {
const nextUrl = new URL(nextLink.getAttribute('href'), window.location.origin).href;
await delay(500); // Friendly delay before hitting next page
return scrapeAllHistory(nextUrl, resultsArray, parsedData.pagination.currentPage + 1);
} else {
updateLoadingText('Finished fetching all pages!');
return resultsArray;
}
} catch (error) {
updateLoadingText(`Encountered a fatal error. Generating UI with recovered data...`);
await delay(1500);
return resultsArray; // Return whatever we managed to grab before failing
}
}
// ============================================================================
// MODULE 4: DATA PROCESSING
// ============================================================================
/**
* Extracts and sorts a deduplicated list of every tag and fandom present in the scraped history.
* Used to populate the custom dropdown for Advanced Filtering.
*/
function processUniqueTags() {
const tagSet = new Set();
globalWorks.forEach(work => {
work.fandoms.forEach(t => tagSet.add(t));
Object.values(work.tags).flat().forEach(t => tagSet.add(t));
});
uniqueTags = Array.from(tagSet).sort((a, b) => a.toLowerCase().localeCompare(b.toLowerCase()));
}
/**
* Sorts dropdown tag suggestions based on match relevance.
* Prioritizes exact matches, then "starts with", then whole words, then normal substring.
* @param {Array<string>} tags - The full array of tags to search within
* @param {string} query - The user's search string
* @returns {Array<string>} The sorted and filtered array of matching tags
*/
function sortTagsByRelevance(tags, query) {
const q = query.toLowerCase();
return tags.filter(tag => tag.toLowerCase().includes(q)).sort((a, b) => {
const lowerA = a.toLowerCase();
const lowerB = b.toLowerCase();
if (lowerA === q) return -1;
if (lowerB === q) return 1;
if (lowerA.startsWith(q) && !lowerB.startsWith(q)) return -1;
if (!lowerA.startsWith(q) && lowerB.startsWith(q)) return 1;
const regex = new RegExp(`\\b${q}\\b`);
if (regex.test(lowerA) && !regex.test(lowerB)) return -1;
if (!regex.test(lowerA) && regex.test(lowerB)) return 1;
if (a.length !== b.length) return a.length - b.length;
return lowerA.localeCompare(lowerB);
});
}
/**
* The core filtering and sorting logic. Triggered on every UI input change.
* Reads all values from the DOM inputs, filters `globalWorks`, and triggers a re-render.
*/
function applySortAndFilter() {
// Grab all filter inputs from the DOM
const searchTerm = document.getElementById('history-search').value.toLowerCase();
const sortMode = document.getElementById('history-sort').value;
const statusFilter = document.getElementById('filter-status').value;
const ratingFilter = document.getElementById('filter-rating').value;
const minWords = parseInt(document.getElementById('filter-min-words').value, 10);
const maxWords = parseInt(document.getElementById('filter-max-words').value, 10);
const minChapters = parseInt(document.getElementById('filter-min-chapters').value, 10);
const maxChapters = parseInt(document.getElementById('filter-max-chapters').value, 10);
const minKudos = parseInt(document.getElementById('filter-min-kudos').value, 10);
const maxKudos = parseInt(document.getElementById('filter-max-kudos').value, 10);
const minHits = parseInt(document.getElementById('filter-min-hits').value, 10);
const maxHits = parseInt(document.getElementById('filter-max-hits').value, 10);
const minBookmarks = parseInt(document.getElementById('filter-min-bookmarks').value, 10);
const maxBookmarks = parseInt(document.getElementById('filter-max-bookmarks').value, 10);
const minVisits = parseInt(document.getElementById('filter-min-visits').value, 10);
const maxVisits = parseInt(document.getElementById('filter-max-visits').value, 10);
const minReads = parseFloat(document.getElementById('filter-min-reads').value);
const maxReads = parseFloat(document.getElementById('filter-max-reads').value);
// Apply filters
let filteredWorks = globalWorks.filter(work => {
// 1. Text Search Filter
const searchString = `${work.title} ${work.authors.join(' ')} ${work.fandoms.join(' ')} ${Object.values(work.tags).flat().join(' ')} ${work.summary}`.toLowerCase();
if (searchTerm && !searchString.includes(searchTerm)) return false;
// 2. Status & Rating Filter
if (statusFilter !== 'any') {
const isComplete = work.requiredTags.includes('Complete Work');
if (statusFilter === 'complete' && !isComplete) return false;
if (statusFilter === 'wip' && isComplete) return false;
}
if (ratingFilter !== 'any' && !work.requiredTags.includes(ratingFilter)) return false;
// 3. Include/Exclude Tag Array Filter
const allTagsAndFandoms = [...Object.values(work.tags).flat(), ...work.fandoms].map(t => t.toLowerCase());
if (selectedIncludeTags.length > 0 && !selectedIncludeTags.every(it => allTagsAndFandoms.some(t => t.includes(it.toLowerCase())))) return false;
if (selectedExcludeTags.length > 0 && selectedExcludeTags.some(et => allTagsAndFandoms.some(t => t.includes(et.toLowerCase())))) return false;
// 4. Numeric Bounds Filters
const wCount = parseStat(work.stats.words);
if (!isNaN(minWords) && wCount < minWords) return false;
if (!isNaN(maxWords) && wCount > maxWords) return false;
const chapCount = parseInt((work.stats.chapters || '1').split('/')[0].replace(/,/g, ''), 10) || 1;
if (!isNaN(minChapters) && chapCount < minChapters) return false;
if (!isNaN(maxChapters) && chapCount > maxChapters) return false;
const kCount = parseStat(work.stats.kudos);
if (!isNaN(minKudos) && kCount < minKudos) return false;
if (!isNaN(maxKudos) && kCount > maxKudos) return false;
const hCount = parseStat(work.stats.hits);
if (!isNaN(minHits) && hCount < minHits) return false;
if (!isNaN(maxHits) && hCount > maxHits) return false;
const bCount = parseStat(work.stats.bookmarks);
if (!isNaN(minBookmarks) && bCount < minBookmarks) return false;
if (!isNaN(maxBookmarks) && bCount > maxBookmarks) return false;
if (!isNaN(minVisits) && work.visitCount < minVisits) return false;
if (!isNaN(maxVisits) && work.visitCount > maxVisits) return false;
const rtCount = parseFloat(work.estimatedReadThrough);
if (!isNaN(minReads) && rtCount < minReads) return false;
if (!isNaN(maxReads) && rtCount > maxReads) return false;
return true; // Passed all filters
});
// Apply Selected Sort Mode
filteredWorks.sort((a, b) => {
if (sortMode === 'visits') return b.visitCount - a.visitCount;
if (sortMode === 'kudos') return parseStat(b.stats.kudos) - parseStat(a.stats.kudos);
if (sortMode === 'words') return parseStat(b.stats.words) - parseStat(a.stats.words);
if (sortMode === 'recent') return new Date(b.lastVisited).getTime() - new Date(a.lastVisited).getTime();
if (sortMode === 'estimated_words') return b.estimatedWordsRead - a.estimatedWordsRead;
if (sortMode === 'read_throughs') return parseFloat(b.estimatedReadThrough) - parseFloat(a.estimatedReadThrough);
if (sortMode === 'updated') return new Date(b.updatedDate).getTime() - new Date(a.updatedDate).getTime();
return 0;
});
// Calculate stats for the specific filtered view
const totalEstimatedWords = filteredWorks.reduce((sum, work) => sum + work.estimatedWordsRead, 0);
document.getElementById('total-words-read').textContent = totalEstimatedWords.toLocaleString();
renderWorksList(filteredWorks);
}
// ============================================================================
// MODULE 5: UI GENERATION
// ============================================================================
// CSS specifically scoped for the Stats Modal
const STATS_CSS = `
.stats-grid { display: flex; flex-wrap: wrap; gap: 20px; justify-content: center; align-items: stretch; box-sizing: border-box; }
.stat-card { flex: 1 1 260px; max-width: 380px; background: white; border: 1px solid #eaeaea; border-radius: 8px; padding: 20px; box-shadow: 0 4px 6px rgba(0,0,0,0.02); display: flex; flex-direction: column; box-sizing: border-box; }
.stat-card h3 { text-align: center; margin: 0 0 15px 0; color: #900; font-size: 1.1em; border-bottom: 2px solid #f4f4f4; padding-bottom: 8px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.5px; }
.big-card { flex: 1 1 100%; max-width: 100%; background: transparent; border: none; box-shadow: none; padding: 0; }
.big-card h3 { display: none; }
.wide-card { flex: 1 1 100%; max-width: 100%; }
.big-stats-container { display: flex; flex-wrap: wrap; justify-content: center; gap: 15px; margin-bottom: 10px; }
.big-stat { flex: 1 1 180px; max-width: 220px; background: #fff; border: 1px solid #eaeaea; border-radius: 8px; padding: 20px 10px; display: flex; flex-direction: column; align-items: center; text-align: center; font-size: 0.95em; color: #666; font-weight: 600; text-transform: uppercase; letter-spacing: 0.5px; box-shadow: 0 4px 6px rgba(0,0,0,0.02); }
.big-stat span { font-size: 2.8em; color: #900; line-height: 1; margin-bottom: 8px; font-weight: 700; }
.list-card ul { list-style: none; padding: 0; margin: 0; font-size: 0.95em; }
.list-card li { display: flex; justify-content: space-between; align-items: center; padding: 8px 0; border-bottom: 1px solid #f4f4f4; }
.list-card li:last-child { border-bottom: none; }
.stat-name { color: #444; font-weight: 500; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; max-width: 75%; }
/* Link Styling Override - No underlines unless hovered */
.stat-name a { color: inherit; font-weight: inherit; text-decoration: none !important; cursor: pointer; }
.stat-name a:hover { text-decoration: underline !important; }
.stat-card > a, .stat-card > div > a, .stat-card strong > a { color: #900; text-decoration: none !important; font-weight: bold; }
.stat-card > a:hover, .stat-card > div > a:hover, .stat-card strong > a:hover { text-decoration: underline !important; }
.stat-num { color: #900; font-weight: bold; background: #fdf2f2; border: 1px solid #f5dcdc; padding: 2px 8px; border-radius: 12px; font-size: 0.85em; }
.stat-card p { margin: 0 0 8px 0; font-size: 0.95em; color: #555; line-height: 1.5; }
.analytics-item { background: #fdfdfd; border: 1px solid #eee; padding: 10px 15px; border-radius: 6px; display: flex; justify-content: space-between; align-items: center; font-size: 0.95em; }
.analytics-item strong { color: #555; font-weight: 600; }
.center-item { flex-direction: column; text-align: center; gap: 6px; padding: 12px 10px; }
.center-item strong { text-transform: uppercase; font-size: 0.85em; letter-spacing: 0.5px; }
`;
/**
* Calculates aggregations (Top tags, total words, etc.) from `globalWorks`
* and generates the HTML string for the Advanced Stats Modal.
* @returns {string} Raw HTML string to be injected into the modal body
*/
function generateStatsHTML() {
const totalWorks = globalWorks.length;
let totalVisits = 0;
let totalEstimatedWords = 0;
let totalWordsAllFics = 0;
let completeCount = 0;
// Dictionaries to track occurrences
const authorsCount = {};
const fandomsCount = {};
const romanticCount = {};
const platonicCount = {};
const charsCount = {};
const tropesCount = {};
const ratingsCount = { 'General Audiences': 0, 'Teen And Up Audiences': 0, 'Mature': 0, 'Explicit': 0 };
let longestFic = null;
let mostReRead = null;
globalWorks.forEach(w => {
totalVisits += w.visitCount;
totalEstimatedWords += w.estimatedWordsRead;
totalWordsAllFics += parseStat(w.stats.words);
if (w.requiredTags.includes('Complete Work')) completeCount++;
w.requiredTags.forEach(t => { if (ratingsCount[t] !== undefined) ratingsCount[t]++; });
w.authors.forEach(a => { if (a !== 'Anonymous') authorsCount[a] = (authorsCount[a] || 0) + 1; });
w.fandoms.forEach(f => { fandomsCount[f] = (fandomsCount[f] || 0) + 1; });
w.tags.characters.forEach(c => { charsCount[c] = (charsCount[c] || 0) + 1; });
w.tags.freeforms.forEach(t => { tropesCount[t] = (tropesCount[t] || 0) + 1; });
// Categorize ships strictly by AO3's standard symbols
w.tags.relationships.forEach(r => {
if (r.includes('/')) {
romanticCount[r] = (romanticCount[r] || 0) + 1;
} else {
platonicCount[r] = (platonicCount[r] || 0) + 1;
}
});
// Hall of Fame updates
if (parseFloat(w.estimatedReadThrough) >= currentReadThreshold) {
if (!longestFic || parseStat(w.stats.words) > parseStat(longestFic.stats.words)) longestFic = w;
}
if (!mostReRead || parseFloat(w.estimatedReadThrough) > parseFloat(mostReRead.estimatedReadThrough)) mostReRead = w;
});
// Helper to extract top N items from a frequency dictionary
const getTop = (obj, n=5) => Object.entries(obj).sort((a, b) => b[1] - a[1]).slice(0, n);
const topAuthors = getTop(authorsCount);
const topFandoms = getTop(fandomsCount);
const topRomantic = getTop(romanticCount);
const topPlatonic = getTop(platonicCount);
const topChars = getTop(charsCount);
const topTropes = getTop(tropesCount, 10);
// Standard novel length estimation (approx 80k words)
const novelsEquivalent = (totalEstimatedWords / 80000).toFixed(1);
// Create clickable tooltipped links that masquerade as normal text, utilizing the scraped URL map
const createListHTML = (arr) => arr.map(item => {
const name = item[0];
const encodedName = name.replace(/"/g, '&quot;');
const count = item[1].toLocaleString();
// Fallback to query parameter search if somehow the URL wasn't scraped properly
const url = globalTagUrlMap[name] || `https://archiveofourown.org/works/search?work_search[query]=${encodeURIComponent(name)}`;
return `<li>
<span class="stat-name" title="${encodedName}">
<a href="${url}" target="_blank">${name}</a>
</span>
<span class="stat-num">${count}</span>
</li>`;
}).join('');
return `
<div class="stats-grid" id="capture-stats-container">
<div class="stat-card big-card">
<div class="big-stats-container">
<div class="big-stat"><span>${totalWorks.toLocaleString()}</span> Fics Read</div>
<div class="big-stat"><span>${totalVisits.toLocaleString()}</span> Total Visits</div>
<div class="big-stat"><span>${totalEstimatedWords.toLocaleString()}</span> Words Read</div>
<div class="big-stat"><span>${novelsEquivalent}</span> Novels Eqv.</div>
</div>
</div>
<div class="stat-card list-card"><h3>Top Fandoms</h3><ul>${createListHTML(topFandoms)}</ul></div>
<div class="stat-card list-card"><h3>Top Romantic Ships</h3><ul>${createListHTML(topRomantic)}</ul></div>
<div class="stat-card list-card"><h3>Top Platonic Pairings</h3><ul>${createListHTML(topPlatonic)}</ul></div>
<div class="stat-card list-card"><h3>Top Characters</h3><ul>${createListHTML(topChars)}</ul></div>
<div class="stat-card list-card"><h3>Top Authors</h3><ul>${createListHTML(topAuthors)}</ul></div>
<div class="stat-card list-card wide-card">
<h3>Top 10 Tags & Tropes</h3>
<ul style="display: grid; grid-template-columns: repeat(auto-fit, minmax(250px, 1fr)); column-gap: 30px; justify-content: center;">
${createListHTML(topTropes)}
</ul>
</div>
<div class="stat-card">
<h3>Hall of Fame</h3>
<div style="display: flex; flex-direction: column; gap: 15px; text-align: center; height: 100%; justify-content: center;">
<div>
<strong style="color:#666; text-transform: uppercase; font-size: 0.85em; letter-spacing: 0.5px; display:block; margin-bottom: 4px;">Most Re-read</strong>
<a href="${mostReRead?.url || '#'}" target="_blank" style="font-size: 1.1em;" title="${mostReRead?.title ? mostReRead.title.replace(/"/g, '&quot;') : ''}">${mostReRead?.title || 'None'}</a><br/>
<span class="stat-num" style="display:inline-block; margin-top:6px;">${mostReRead?.estimatedReadThrough || '0'}x read-throughs</span>
</div>
<div style="border-bottom: 1px solid #f4f4f4;"></div>
<div>
<strong style="color:#666; text-transform: uppercase; font-size: 0.85em; letter-spacing: 0.5px; display:block; margin-bottom: 4px;">
Longest Fic Read <span onclick="window.updateReadThreshold()" style="cursor: pointer; text-decoration: underline !important; color: #cc0000;" title="Click to change threshold">(≥${currentReadThreshold}x)</span>
</strong>
<a href="${longestFic?.url || '#'}" target="_blank" style="font-size: 1.1em;" title="${longestFic?.title ? longestFic.title.replace(/"/g, '&quot;') : ''}">${longestFic?.title || 'None met criteria'}</a><br/>
<span class="stat-num" style="display:inline-block; margin-top:6px;">${(longestFic ? parseStat(longestFic.stats.words) : 0).toLocaleString()} words</span>
</div>
</div>
</div>
<div class="stat-card">
<h3>Archive Analytics</h3>
<div style="display: flex; flex-direction: column; gap: 10px; margin-bottom: 15px;">
<div class="analytics-item"><strong>Avg. Fic Length:</strong> <span class="stat-num">${Math.round(totalWordsAllFics / (totalWorks || 1)).toLocaleString()} words</span></div>
<div class="analytics-item"><strong>Completion Rate:</strong> <span class="stat-num">${Math.round((completeCount / (totalWorks || 1)) * 100)}%</span></div>
</div>
<h4 style="margin: 0 0 10px 0; font-size: 0.85em; color: #777; text-transform: uppercase; text-align: center;">Ratings Breakdown</h4>
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 10px;">
<div class="analytics-item center-item"><strong>General</strong> <span class="stat-num">${ratingsCount['General Audiences'].toLocaleString()}</span></div>
<div class="analytics-item center-item"><strong>Teen</strong> <span class="stat-num">${ratingsCount['Teen And Up Audiences'].toLocaleString()}</span></div>
<div class="analytics-item center-item"><strong>Mature</strong> <span class="stat-num">${ratingsCount['Mature'].toLocaleString()}</span></div>
<div class="analytics-item center-item"><strong>Explicit</strong> <span class="stat-num">${ratingsCount['Explicit'].toLocaleString()}</span></div>
</div>
</div>
</div>
`;
}
/**
* Builds the HTML layout for the main list of filtered works.
* Iterates through the passed array and injects the resulting string into `#works-container`.
* @param {Array<Object>} works - Array of parsed work objects
*/
function renderWorksList(works) {
const container = document.getElementById('works-container');
document.getElementById('works-count').textContent = works.length;
let html = '';
works.forEach(work => {
const allTags = Object.values(work.tags).flat().join(', ');
const fandomsText = work.fandoms.join(', ');
const statusText = work.requiredTags.includes('Complete Work') ? 'Complete' : 'WIP';
// Tag collapsible block handling
let tagsHtml = `<div style="font-size: 0.85em; margin-bottom: 5px; color: #444;"><strong>Fandoms:</strong> ${fandomsText || 'None'}</div>`;
if (allTags.length > 120) {
tagsHtml += `
<details class="collapsible-block" style="font-size: 0.85em; margin-bottom: 10px; color: #444;">
<summary style="cursor: pointer;"><strong>Tags:</strong> <span class="collapsed-text">${allTags.substring(0, 120).trim()}... <span style="color:#900; font-weight:bold;">(Show all)</span></span><span class="expanded-text" style="color:#900; font-weight:bold;">(Hide tags)</span></summary>
<div style="margin-top: 8px; padding-left: 10px; border-left: 2px solid #ccc;"><strong>All Tags:</strong> ${allTags}</div>
</details>`;
} else {
tagsHtml += `<div style="font-size: 0.85em; margin-bottom: 10px; color: #444;"><strong>Tags:</strong> ${allTags || 'None'}</div>`;
}
// Summary collapsible block handling
let summaryHtml = `<div style="font-size: 0.9em; background: #fff; padding: 10px; border-left: 3px solid #ccc;"><em>No summary provided.</em></div>`;
if (work.summary) {
if (work.summary.length > 200) {
const shortSummary = work.summary.substring(0, 200).trim() + '...';
summaryHtml = `
<details class="collapsible-block" style="font-size: 0.9em; background: #fff; padding: 10px; border-left: 3px solid #ccc;">
<summary style="cursor: pointer;"><span class="collapsed-text">${shortSummary.replace(/\n/g, '<br/>')} <span style="color:#900; font-weight:bold;">(Read more)</span></span><span class="expanded-text" style="color:#900; font-weight:bold;">(Hide summary)</span></summary>
<div style="margin-top: 5px; padding-top: 10px; border-top: 1px dashed #eee;">${work.summary.replace(/\n/g, '<br/>')}<div style="margin-top: 10px; text-align: right;"><span onclick="this.closest('details').open = false" style="cursor: pointer; color: #900; font-weight: bold; font-size: 0.9em;">(Show less)</span></div></div>
</details>`;
} else {
summaryHtml = `<div style="font-size: 0.9em; background: #fff; padding: 10px; border-left: 3px solid #ccc;">${work.summary.replace(/\n/g, '<br/>')}</div>`;
}
}
// Notice the responsive visit badges: one absolute (desktop) and one inline (mobile)
html += `
<li class="work-card">
<div class="visit-badge-absolute">Visited ${work.visitCount} ${work.visitCount === 1 ? 'time' : 'times'}</div>
<div class="visit-badge-inline">Visited ${work.visitCount} ${work.visitCount === 1 ? 'time' : 'times'}</div>
<h3 class="work-title"><a href="${work.url}" target="_blank" style="color: #900; text-decoration: none;">${work.title}</a> ${work.authors.length ? `by ${work.authors.join(', ')}` : ''}</h3>
<div style="font-size: 0.9em; color: #555; margin-bottom: 10px; line-height: 1.6;">
<strong>Last Visited:</strong> ${work.lastVisited} | <strong>Updated:</strong> ${work.updatedDate} | <strong>Status:</strong> ${statusText}<br/>
<strong>Words:</strong> ${work.stats.words || '0'} | <strong>Chapters:</strong> ${work.stats.chapters || '1/1'} | <strong>Kudos:</strong> ${work.stats.kudos || '0'} | <strong>Bookmarks:</strong> ${work.stats.bookmarks || '0'} | <strong>Hits:</strong> ${work.stats.hits || '0'}<br/>
<strong style="color: #900;">Estimated Read: ${work.estimatedWordsRead.toLocaleString()} words (${work.estimatedReadThrough}x read-throughs)</strong>
</div>
${tagsHtml}${summaryHtml}
</li>
`;
});
container.innerHTML = html;
}
// ============================================================================
// MODULE 6: CANVAS EXPORT
// ============================================================================
/**
* Re-aggregates the stats, initializes a hidden Canvas element, manually paints all
* text, shapes, and layouts, and triggers a download of the resulting PNG file.
*/
function exportStatsToImage() {
const btn = document.getElementById('export-stats-btn');
const originalText = btn.innerText;
btn.innerText = '📸 Painting Canvas...';
// Small timeout ensures the UI has time to update the button text before thread-locking rendering begins
setTimeout(() => {
try {
// 1. Data Aggregation (similar to generateStatsHTML)
const totalWorks = globalWorks.length;
let totalVisits = 0, totalEstimatedWords = 0, totalWordsAllFics = 0, completeCount = 0;
const authorsCount = {}, fandomsCount = {}, romanticCount = {}, platonicCount = {}, charsCount = {}, tropesCount = {};
const ratingsCount = { 'General Audiences': 0, 'Teen And Up Audiences': 0, 'Mature': 0, 'Explicit': 0 };
let longestFic = null, mostReRead = null;
globalWorks.forEach(w => {
totalVisits += w.visitCount;
totalEstimatedWords += w.estimatedWordsRead;
totalWordsAllFics += parseStat(w.stats.words);
if (w.requiredTags.includes('Complete Work')) completeCount++;
w.requiredTags.forEach(t => { if (ratingsCount[t] !== undefined) ratingsCount[t]++; });
w.authors.forEach(a => { if (a !== 'Anonymous') authorsCount[a] = (authorsCount[a] || 0) + 1; });
w.fandoms.forEach(f => { fandomsCount[f] = (fandomsCount[f] || 0) + 1; });
w.tags.characters.forEach(c => { charsCount[c] = (charsCount[c] || 0) + 1; });
w.tags.freeforms.forEach(t => { tropesCount[t] = (tropesCount[t] || 0) + 1; });
w.tags.relationships.forEach(r => {
if (r.includes('/')) romanticCount[r] = (romanticCount[r] || 0) + 1;
else platonicCount[r] = (platonicCount[r] || 0) + 1;
});
if (parseFloat(w.estimatedReadThrough) >= currentReadThreshold) {
if (!longestFic || parseStat(w.stats.words) > parseStat(longestFic.stats.words)) longestFic = w;
}
if (!mostReRead || parseFloat(w.estimatedReadThrough) > parseFloat(mostReRead.estimatedReadThrough)) mostReRead = w;
});
const getTop = (obj, n=5) => Object.entries(obj).sort((a, b) => b[1] - a[1]).slice(0, n);
const topAuthors = getTop(authorsCount);
const topFandoms = getTop(fandomsCount);
const topRomantic = getTop(romanticCount);
const topPlatonic = getTop(platonicCount);
const topChars = getTop(charsCount);
const topTropes = getTop(tropesCount, 10);
const novelsEq = (totalEstimatedWords / 80000).toFixed(1);
// 2. Setup Canvas properties
const canvas = document.createElement('canvas');
canvas.width = 1000;
canvas.height = 1735; // Tall enough to prevent bottom clipping
const ctx = canvas.getContext('2d');
// Helper: Draw a rectangle with rounded corners
function roundRect(ctx, x, y, w, h, r, fill, stroke) {
ctx.beginPath();
ctx.moveTo(x + r, y); ctx.lineTo(x + w - r, y); ctx.quadraticCurveTo(x + w, y, x + w, y + r);
ctx.lineTo(x + w, y + h - r); ctx.quadraticCurveTo(x + w, y + h, x + w - r, y + h);
ctx.lineTo(x + r, y + h); ctx.quadraticCurveTo(x, y + h, x, y + h - r);
ctx.lineTo(x, y + r); ctx.quadraticCurveTo(x, y, x + r, y); ctx.closePath();
if (fill) { ctx.fillStyle = fill; ctx.fill(); }
if (stroke) { ctx.strokeStyle = stroke; ctx.lineWidth = 1; ctx.stroke(); }
}
// Helper: Write standard text
function drawText(ctx, text, x, y, font, color, align = 'left') {
ctx.font = font; ctx.fillStyle = color; ctx.textAlign = align; ctx.fillText(text, x, y);
}
// Helper: Write text that automatically adds "..." if it exceeds maxW
function drawTruncText(ctx, text, x, y, maxW, font, color, align = 'left') {
ctx.font = font; ctx.fillStyle = color; ctx.textAlign = align;
let t = text;
if (ctx.measureText(t).width > maxW) {
while (t.length > 0 && ctx.measureText(t + '...').width > maxW) t = t.slice(0, -1);
t += '...';
}
ctx.fillText(t, x, y);
}
// Fill background
ctx.fillStyle = '#f4f4f4';
ctx.fillRect(0, 0, 1000, 1735);
// 3. Draw Poster Header
const posterTitle = currentUsername ? `${currentUsername.toUpperCase()}'S AO3 READING ANALYTICS` : 'MY AO3 READING ANALYTICS';
drawText(ctx, posterTitle, 500, 70, 'bold 34px sans-serif', '#900000', 'center');
ctx.beginPath(); ctx.moveTo(350, 95); ctx.lineTo(650, 95); ctx.lineWidth = 3; ctx.strokeStyle = '#900000'; ctx.stroke();
// 4. Draw 4 Top Big Data Boxes
const bigData = [
{ v: totalWorks.toLocaleString(), l: 'FICS READ' },
{ v: totalVisits.toLocaleString(), l: 'TOTAL VISITS' },
{ v: totalEstimatedWords.toLocaleString(), l: 'WORDS READ' },
{ v: novelsEq, l: 'NOVELS EQUIVALENT' }
];
bigData.forEach((box, i) => {
const bxStart = 45 + (i * 232.5);
roundRect(ctx, bxStart, 130, 212, 110, 8, '#ffffff', '#eaeaea');
drawText(ctx, box.v, bxStart + 106, 195, 'bold 36px sans-serif', '#900000', 'center');
drawText(ctx, box.l, bxStart + 106, 220, 'bold 12px sans-serif', '#666666', 'center');
});
// Helper: Draw standard list cards
function drawListCard(ctx, cx, cy, cw, ch, title, items, isDouble = false) {
roundRect(ctx, cx, cy, cw, ch, 8, '#ffffff', '#eaeaea');
drawText(ctx, title.toUpperCase(), cx + cw/2, cy + 40, 'bold 18px sans-serif', '#900000', 'center');
ctx.beginPath(); ctx.moveTo(cx + 20, cy + 55); ctx.lineTo(cx + cw - 20, cy + 55); ctx.lineWidth = 2; ctx.strokeStyle = '#f4f4f4'; ctx.stroke();
if (items.length === 0) {
drawText(ctx, 'No data available', cx + cw/2, cy + 130, 'italic 16px sans-serif', '#999999', 'center');
return;
}
const startY = cy + 85;
const lineH = 32;
items.forEach((item, idx) => {
let drawX = cx + 20, drawY = startY + (idx * lineH), maxTextW = cw - 100, countX = cx + cw - 20;
if (isDouble) {
drawX = idx < 5 ? cx + 20 : cx + cw/2 + 20;
drawY = startY + ((idx % 5) * lineH);
maxTextW = (cw/2) - 80;
countX = idx < 5 ? cx + cw/2 - 20 : cx + cw - 20;
}
drawTruncText(ctx, item[0], drawX, drawY, maxTextW, '500 15px sans-serif', '#444444', 'left');
const cStr = String(item[1].toLocaleString());
ctx.font = 'bold 13px sans-serif';
const cW = ctx.measureText(cStr).width;
roundRect(ctx, countX - cW - 10, drawY - 17, cW + 20, 24, 12, '#fdf2f2', null);
drawText(ctx, cStr, countX, drawY - 1, 'bold 13px sans-serif', '#900000', 'right');
const isEndOfCol = isDouble ? (idx === 4 || idx === 9 || idx === items.length - 1) : (idx === items.length - 1);
if (!isEndOfCol) {
ctx.beginPath(); ctx.setLineDash([2, 2]); ctx.moveTo(drawX, drawY + 12); ctx.lineTo(countX, drawY + 12);
ctx.lineWidth = 1; ctx.strokeStyle = '#f4f4f4'; ctx.stroke(); ctx.setLineDash([]);
}
});
}
// Draw middle rows of list cards
drawListCard(ctx, 45, 270, 445, 245, 'Top Fandoms', topFandoms);
drawListCard(ctx, 510, 270, 445, 245, 'Top Authors', topAuthors);
drawListCard(ctx, 45, 535, 445, 245, 'Top Romantic Ships', topRomantic);
drawListCard(ctx, 510, 535, 445, 245, 'Top Platonic Pairings', topPlatonic);
drawListCard(ctx, 277.5, 800, 445, 245, 'Top Characters', topChars);
drawListCard(ctx, 45, 1065, 910, 245, 'Top 10 Tags & Tropes', topTropes, true);
// 5. Draw Final Bottom Cards (Hall of Fame & Analytics)
const row5 = 1330;
// Card 1: Hall of Fame (Height=320)
roundRect(ctx, 45, row5, 445, 320, 8, '#ffffff', '#eaeaea');
drawText(ctx, 'HALL OF FAME', 45 + 445/2, row5 + 40, 'bold 18px sans-serif', '#900000', 'center');
ctx.beginPath(); ctx.moveTo(65, row5 + 55); ctx.lineTo(470, row5 + 55); ctx.lineWidth = 2; ctx.strokeStyle = '#f4f4f4'; ctx.stroke();
drawText(ctx, 'MOST RE-READ', 45 + 445/2, row5 + 95, 'bold 12px sans-serif', '#666666', 'center');
drawTruncText(ctx, mostReRead?.title || 'None', 45 + 445/2, row5 + 125, 400, 'bold 20px sans-serif', '#900000', 'center');
const rrStr = (mostReRead?.estimatedReadThrough || '0') + 'x read-throughs';
ctx.font = 'bold 14px sans-serif'; const rrW = ctx.measureText(rrStr).width;
roundRect(ctx, 45 + 445/2 - rrW/2 - 10, row5 + 140, rrW + 20, 24, 12, '#fdf2f2', null);
drawText(ctx, rrStr, 45 + 445/2, row5 + 157, 'bold 14px sans-serif', '#900000', 'center');
ctx.beginPath(); ctx.moveTo(100, row5 + 195); ctx.lineTo(390, row5 + 195); ctx.lineWidth = 1; ctx.strokeStyle = '#f4f4f4'; ctx.stroke();
drawText(ctx, `LONGEST FIC READ (≥${currentReadThreshold}X)`, 45 + 445/2, row5 + 230, 'bold 12px sans-serif', '#666666', 'center');
drawTruncText(ctx, longestFic?.title || 'None met criteria', 45 + 445/2, row5 + 260, 400, 'bold 20px sans-serif', '#900000', 'center');
const lwStr = (longestFic ? parseStat(longestFic.stats.words).toLocaleString() : '0') + ' words';
ctx.font = 'bold 14px sans-serif'; const lwW = ctx.measureText(lwStr).width;
roundRect(ctx, 45 + 445/2 - lwW/2 - 10, row5 + 275, lwW + 20, 24, 12, '#fdf2f2', null);
drawText(ctx, lwStr, 45 + 445/2, row5 + 292, 'bold 14px sans-serif', '#900000', 'center');
// Card 2: Analytics (Height=320)
roundRect(ctx, 510, row5, 445, 320, 8, '#ffffff', '#eaeaea');
drawText(ctx, 'ARCHIVE ANALYTICS', 510 + 445/2, row5 + 40, 'bold 18px sans-serif', '#900000', 'center');
ctx.beginPath(); ctx.moveTo(530, row5 + 55); ctx.lineTo(935, row5 + 55); ctx.lineWidth = 2; ctx.strokeStyle = '#f4f4f4'; ctx.stroke();
roundRect(ctx, 530, row5 + 75, 405, 45, 6, '#fdfdfd', '#eeeeee');
drawText(ctx, 'Average Fic Length', 545, row5 + 103, '600 15px sans-serif', '#555555', 'left');
const alStr = Math.round(totalWordsAllFics / (totalWorks || 1)).toLocaleString() + ' words';
ctx.font = 'bold 14px sans-serif'; const alW = ctx.measureText(alStr).width;
roundRect(ctx, 920 - alW - 10, row5 + 85, alW + 20, 25, 12, '#fdf2f2', null);
drawText(ctx, alStr, 920, row5 + 103, 'bold 14px sans-serif', '#900000', 'right');
roundRect(ctx, 530, row5 + 130, 405, 45, 6, '#fdfdfd', '#eeeeee');
drawText(ctx, 'Completion Rate', 545, row5 + 158, '600 15px sans-serif', '#555555', 'left');
const crStr = Math.round((completeCount / (totalWorks || 1)) * 100) + '%';
ctx.font = 'bold 14px sans-serif'; const crW = ctx.measureText(crStr).width;
roundRect(ctx, 920 - crW - 10, row5 + 140, crW + 20, 25, 12, '#fdf2f2', null);
drawText(ctx, crStr, 920, row5 + 158, 'bold 14px sans-serif', '#900000', 'right');
drawText(ctx, 'RATINGS BREAKDOWN', 510 + 445/2, row5 + 215, 'bold 12px sans-serif', '#777777', 'center');
function drawRatingBox(x, y, lbl, val) {
roundRect(ctx, x, y, 93, 65, 6, '#fdfdfd', '#eeeeee');
drawText(ctx, lbl, x + 46, y + 25, 'bold 11px sans-serif', '#555555', 'center');
drawText(ctx, String(val.toLocaleString()), x + 46, y + 55, 'bold 22px sans-serif', '#900000', 'center');
}
drawRatingBox(530, row5 + 230, 'GENERAL', ratingsCount['General Audiences']);
drawRatingBox(634, row5 + 230, 'TEEN', ratingsCount['Teen And Up Audiences']);
drawRatingBox(738, row5 + 230, 'MATURE', ratingsCount['Mature']);
drawRatingBox(842, row5 + 230, 'EXPLICIT', ratingsCount['Explicit']);
// 6. Trigger PNG Download
const dataUrl = canvas.toDataURL('image/png');
const a = document.createElement('a');
a.download = currentUsername ? `${currentUsername}_AO3_Stats.png` : 'AO3_Reading_Stats.png';
a.href = dataUrl;
a.click();
btn.innerText = originalText;
} catch (e) {
console.error(e);
alert("Failed to export image due to a canvas rendering error.");
btn.innerText = originalText;
}
}, 50);
}
// ============================================================================
// MODULE 7: EVENT LISTENERS & SETUP
// ============================================================================
/**
* Displays the Advanced Stats Modal and populates it.
*/
function openStatsModal() {
const modal = document.getElementById('advanced-stats-modal');
document.getElementById('stats-modal-body').innerHTML = generateStatsHTML();
modal.style.display = 'flex';
document.body.style.overflow = 'hidden';
}
/**
* Initializes a custom tag input container to behave like a multi-select chip UI.
* Handles typing, searching unique tags, keyboard navigation, and click events.
* @param {string} inputId - ID of the text input
* @param {string} containerId - ID of the wrapper container
* @param {string} dropdownId - ID of the suggestion dropdown list
* @param {Array} stateArray - The JS array storing the selected tags
*/
function setupCustomTagInput(inputId, containerId, dropdownId, stateArray) {
const input = document.getElementById(inputId);
const container = document.getElementById(containerId);
const dropdown = document.getElementById(dropdownId);
let highlightedIndex = -1;
function renderChips() {
container.querySelectorAll('.tag-chip').forEach(c => c.remove());
stateArray.forEach(tag => {
const chip = document.createElement('span');
chip.className = 'tag-chip';
chip.innerHTML = `${tag} <span class="tag-chip-close" data-tag="${tag.replace(/"/g, '&quot;')}">×</span>`;
container.insertBefore(chip, input);
});
}
function addTag(tag) {
const val = tag.trim().replace(/^,|,$/g, '');
if (val && !stateArray.includes(val)) {
stateArray.push(val);
input.value = '';
closeDropdown();
renderChips();
applySortAndFilter();
} else if (val) {
input.value = '';
}
}
function closeDropdown() {
dropdown.style.display = 'none';
dropdown.innerHTML = '';
highlightedIndex = -1;
}
input.addEventListener('input', (e) => {
const val = e.target.value;
if (!val) { closeDropdown(); return; }
const matches = sortTagsByRelevance(uniqueTags, val).slice(0, 50);
if (matches.length === 0) { closeDropdown(); return; }
dropdown.innerHTML = matches.map((match, index) => `<li data-index="${index}">${match}</li>`).join('');
dropdown.style.display = 'block';
highlightedIndex = -1;
Array.from(dropdown.children).forEach(li => {
li.addEventListener('mousedown', (evt) => {
evt.preventDefault();
addTag(matches[parseInt(li.getAttribute('data-index'))]);
});
});
});
input.addEventListener('keydown', (e) => {
const items = dropdown.querySelectorAll('li');
if (e.key === 'ArrowDown') {
e.preventDefault();
if (items.length > 0) { highlightedIndex = (highlightedIndex + 1) % items.length; updateHighlight(items); }
} else if (e.key === 'ArrowUp') {
e.preventDefault();
if (items.length > 0) { highlightedIndex = (highlightedIndex - 1 + items.length) % items.length; updateHighlight(items); }
} else if (e.key === 'Enter' || e.key === ',') {
e.preventDefault();
if (highlightedIndex >= 0 && items.length > 0) addTag(items[highlightedIndex].innerText);
else addTag(input.value);
} else if (e.key === 'Backspace' && input.value === '' && stateArray.length > 0) {
stateArray.pop();
renderChips();
applySortAndFilter();
}
});
function updateHighlight(items) {
items.forEach((item, index) => {
if (index === highlightedIndex) { item.classList.add('selected'); item.scrollIntoView({ block: 'nearest' }); }
else item.classList.remove('selected');
});
}
input.addEventListener('blur', () => setTimeout(closeDropdown, 150));
container.addEventListener('click', (e) => {
if (e.target.classList.contains('tag-chip-close')) {
const tagToRemove = e.target.getAttribute('data-tag');
const idx = stateArray.indexOf(tagToRemove);
if (idx > -1) { stateArray.splice(idx, 1); renderChips(); applySortAndFilter(); }
} else if (e.target === container) input.focus();
});
}
/**
* Builds and injects the core HTML/CSS for the entire page, replacing AO3's native UI.
* Connects all event listeners to inputs and buttons.
*/
function setupUI() {
document.body.style.background = '#fff';
document.body.style.margin = '0';
const modalPrefix = currentUsername ? `${currentUsername}'s ` : "Your ";
// Dynamically extract the specific ratings found in your history
const ratingSet = new Set();
globalWorks.forEach(w => w.requiredTags.forEach(t => {
if (['General Audiences', 'Teen And Up Audiences', 'Mature', 'Explicit', 'Not Rated'].includes(t)) {
ratingSet.add(t);
}
}));
const ratingOptionsHtml = Array.from(ratingSet)
.sort()
.map(r => `<option value="${r}">${r}</option>`)
.join('');
// Primary HTML DOM Template
document.body.innerHTML = `
<style>
/* Layout & Responsive CSS */
#main-header { display: flex; justify-content: space-between; align-items: baseline; border-bottom: 2px solid #900; padding-bottom: 10px; margin-bottom: 15px; }
#header-buttons { display: flex; gap: 10px; }
#stats-bar { margin-bottom: 20px; background: #f0f0f0; padding: 15px; border-radius: 6px; display: flex; justify-content: space-between; font-size: 1.1em; }
.work-card { background: #f9f9f9; margin-bottom: 20px; padding: 15px; border: 1px solid #ddd; border-radius: 5px; box-shadow: 2px 2px 5px rgba(0,0,0,0.05); position: relative; }
.work-title { margin-top: 0; font-size: 1.2em; max-width: 80%; }
.visit-badge-absolute { position: absolute; top: 15px; right: 15px; background: #900; color: white; padding: 5px 10px; border-radius: 20px; font-size: 0.85em; font-weight: bold; }
.visit-badge-inline { display: none; background: #900; color: white; padding: 4px 10px; border-radius: 20px; font-size: 0.85em; font-weight: bold; margin-bottom: 8px; width: max-content; }
@media (max-width: 700px) {
#main-header { flex-direction: column; align-items: center; gap: 12px; }
#main-header h1 { font-size: 1.6em; text-align: center; }
#header-buttons { flex-wrap: wrap; justify-content: center; align-items: center; width: 100%; }
#stats-bar { flex-direction: column; gap: 10px; }
.modal-content { width: 100vw; height: 100vh; max-height: 100vh; border-radius: 0; }
.visit-badge-absolute { display: none; }
.visit-badge-inline { display: inline-block; }
.work-title { max-width: 100%; }
}
/* Base UI Styles */
.collapsible-block summary { list-style: none; outline: none; }
.collapsible-block summary::-webkit-details-marker { display: none; }
.collapsible-block .expanded-text { display: none; }
.collapsible-block[open] .collapsed-text { display: none; }
.collapsible-block[open] .expanded-text { display: inline; }
.filter-input { padding: 8px; font-size: 14px; border: 1px solid #ccc; border-radius: 4px; box-sizing: border-box; width: 100%; }
.filter-group { flex: 1 1 200px; background: #fff; padding: 15px; border: 1px solid #eee; border-radius: 6px; box-shadow: 0 1px 3px rgba(0,0,0,0.05); }
.filter-group h4 { margin: 0 0 12px 0; font-size: 0.85em; color: #555; text-transform: uppercase; letter-spacing: 0.5px; border-bottom: 1px solid #eee; padding-bottom: 5px; }
.input-row { display: flex; gap: 8px; margin-bottom: 8px; }
.input-row:last-child { margin-bottom: 0; }
.utility-btn {
display: inline-flex !important; align-items: center !important; justify-content: center !important;
padding: 8px 14px !important; height: auto !important; line-height: 1.2 !important; font-size: 13px !important;
border-radius: 4px !important; cursor: pointer !important; box-sizing: border-box !important;
font-family: 'Lucida Grande', 'Lucida Sans Unicode', Verdana, Helvetica, sans-serif !important; margin: 0 !important;
}
.btn-default {
background-color: #eee !important; background-image: linear-gradient(to bottom, #fff 2%, #e5e5e5 95%, #ccc 100%) !important;
color: #333 !important; border: 1px solid #bbb !important; text-shadow: none !important; box-shadow: 0 1px 2px rgba(0,0,0,0.1) !important;
}
.btn-default:hover { background-image: linear-gradient(to bottom, #fff 2%, #ddd 95%, #bbb 100%) !important; }
.btn-primary {
background-color: #f9ecec !important; background-image: linear-gradient(to bottom, #fff 2%, #f9ecec 95%, #f0d8d8 100%) !important;
color: #900 !important; font-weight: bold !important; border: 1px solid #900 !important; text-shadow: none !important; box-shadow: 0 1px 2px rgba(0,0,0,0.1) !important;
}
.btn-primary:hover { background-image: linear-gradient(to bottom, #fff 2%, #f0d8d8 95%, #e6c3c3 100%) !important; }
.tag-input-wrapper { position: relative; width: 100%; margin-bottom: 8px; }
.tag-input-container { display: flex; flex-wrap: wrap; gap: 6px; padding: 6px 8px; background: white; border: 1px solid #ccc; border-radius: 4px; min-height: 36px; align-items: center; cursor: text; transition: border-color 0.2s; }
.tag-input-container:focus-within { border-color: #900; }
.tag-input-container input { border: none !important; outline: none !important; box-shadow: none !important; flex-grow: 1; min-width: 120px; font-size: 14px; padding: 0; background: transparent; margin: 0; }
.tag-chip { background: #eee; border: 1px solid #ddd; border-radius: 16px; padding: 2px 8px; font-size: 13px; display: flex; align-items: center; gap: 6px; color: #333; cursor: default; }
.tag-chip-close { cursor: pointer; color: #900; font-weight: bold; line-height: 1; margin-top: -1px; padding: 0 2px; }
.tag-chip-close:hover { color: #f00; }
.custom-dropdown { display: none; position: absolute; top: 100%; left: 0; right: 0; background: #fff; border: 1px solid #ccc; max-height: 200px; overflow-y: auto; z-index: 1000; border-radius: 0 0 6px 6px; padding: 0; margin: 0; list-style: none; box-shadow: 0 4px 12px rgba(0,0,0,0.15); }
.custom-dropdown li { padding: 8px 12px; cursor: pointer; font-size: 13px; border-bottom: 1px solid #eee; color: #333; }
.custom-dropdown li:last-child { border-bottom: none; }
.custom-dropdown li:hover, .custom-dropdown li.selected { background: #900; color: #fff; }
#back-to-top {
position: fixed !important; bottom: 30px !important; right: 30px !important; z-index: 9998 !important;
width: 50px !important; height: 50px !important; border-radius: 50% !important;
background-color: #900 !important; background-image: linear-gradient(to bottom, #a00 2%, #800 95%, #700 100%) !important;
color: #fff !important; text-shadow: 0 -1px 0 rgba(0,0,0,0.3) !important; border: 1px solid #700 !important;
font-size: 24px !important; font-family: sans-serif !important; font-weight: bold !important;
cursor: pointer !important; box-shadow: 0 4px 10px rgba(0,0,0,0.3), inset 0 1px 0 rgba(255,255,255,0.2) !important;
display: flex !important; align-items: center !important; justify-content: center !important;
opacity: 0; visibility: hidden; transition: opacity 0.3s ease, visibility 0.3s ease, transform 0.2s ease, box-shadow 0.2s ease !important;
padding: 0 !important; margin: 0 !important; box-sizing: border-box !important; line-height: 1 !important; text-decoration: none !important;
}
#back-to-top:hover { transform: translateY(-3px) !important; background-image: linear-gradient(to bottom, #b00 2%, #900 95%, #800 100%) !important; box-shadow: 0 6px 14px rgba(0,0,0,0.4), inset 0 1px 0 rgba(255,255,255,0.3) !important; }
#back-to-top.show { opacity: 1 !important; visibility: visible !important; }
/* Modal Core Styles */
#advanced-stats-modal { display: none; position: fixed; top: 0; left: 0; width: 100vw; height: 100vh; background: rgba(0,0,0,0.8); z-index: 10000; align-items: center; justify-content: center; backdrop-filter: blur(5px); }
.modal-content { background: #fdfdfd; width: 90%; max-width: 1000px; max-height: 85vh; border-radius: 12px; box-shadow: 0 10px 40px rgba(0,0,0,0.4); display: flex; flex-direction: column; overflow: hidden; font-family: sans-serif; }
.modal-header { background: #900; color: white; padding: 15px 25px; display: flex; justify-content: space-between; align-items: center; border-bottom: 2px solid #700; }
.modal-header h2 { margin: 0; font-size: 1.5em; font-weight: 600; letter-spacing: 0.5px; }
.modal-close { cursor: pointer; font-size: 1.8em; font-weight: bold; color: rgba(255,255,255,0.7); transition: color 0.2s; line-height: 1; margin-left: 10px;}
.modal-close:hover { color: #fff; }
.modal-body { padding: 25px; overflow-y: auto; }
.modal-body::-webkit-scrollbar { width: 8px; }
.modal-body::-webkit-scrollbar-track { background: #f1f1f1; border-radius: 4px; }
.modal-body::-webkit-scrollbar-thumb { background: #ccc; border-radius: 4px; }
.modal-body::-webkit-scrollbar-thumb:hover { background: #aaa; }
${STATS_CSS}
</style>
<div style="max-width: 900px; margin: 0 auto; padding: 20px; font-family: 'Lucida Grande', 'Lucida Sans Unicode', Verdana, Helvetica, sans-serif; color: #2a2a2a; background: #fff;">
<div id="main-header">
<h1 style="color: #900; margin: 0;">Sorted Reading History</h1>
<div id="header-buttons">
<button id="btn-stats" class="utility-btn btn-primary">Advanced Stats</button>
<button id="download-json" class="utility-btn btn-default">Download JSON</button>
<button id="force-rescrape" class="utility-btn btn-default">Force Rescrape</button>
</div>
</div>
<div style="display: flex; gap: 10px; margin-bottom: 10px; flex-wrap: wrap;">
<input type="text" id="history-search" placeholder="Search by title, author, tags, or summary..."
style="flex-grow: 1; padding: 12px; font-size: 16px; border: 2px solid #ccc; border-radius: 6px; box-sizing: border-box; outline: none;">
<select id="history-sort" style="padding: 12px; font-size: 16px; border: 2px solid #ccc; border-radius: 6px; outline: none; background: white; cursor: pointer;">
<option value="visits">Sort: Most Visited</option>
<option value="read_throughs">Sort: Highest Read-Throughs</option>
<option value="estimated_words">Sort: Highest Estimated Read Words</option>
<option value="recent">Sort: Most Recently Visited</option>
<option value="updated">Sort: Most Recently Updated</option>
<option value="kudos">Sort: Highest Kudos</option>
<option value="words">Sort: Highest Word Count</option>
</select>
</div>
<details style="margin-bottom: 15px; background: #fafafa; border: 1px solid #ddd; border-radius: 6px; padding: 15px;">
<summary style="cursor: pointer; font-weight: bold; color: #900; user-select: none;">
Advanced Filters
</summary>
<div style="display: flex; flex-wrap: wrap; gap: 15px; margin-top: 15px;">
<div class="filter-group" style="flex: 2 1 300px;">
<h4>Content</h4>
<div class="tag-input-wrapper">
<div class="tag-input-container" id="include-tags-container">
<input type="text" id="filter-include-tag" autocomplete="off" placeholder="Include Tag/Fandom">
</div>
<ul id="include-tags-dropdown" class="custom-dropdown"></ul>
</div>
<div class="tag-input-wrapper">
<div class="tag-input-container" id="exclude-tags-container">
<input type="text" id="filter-exclude-tag" autocomplete="off" placeholder="Exclude Tag/Fandom">
</div>
<ul id="exclude-tags-dropdown" class="custom-dropdown"></ul>
</div>
<div class="input-row">
<select id="filter-status" class="filter-input" style="margin-bottom: 0;">
<option value="any">Status: Any</option>
<option value="complete">Complete</option>
<option value="wip">Work in Progress</option>
</select>
<select id="filter-rating" class="filter-input" style="margin-bottom: 0;">
<option value="any">Rating: Any</option>
${ratingOptionsHtml}
</select>
</div>
</div>
<div class="filter-group">
<h4>Length</h4>
<div class="input-row">
<input type="number" id="filter-min-words" class="filter-input" placeholder="Min Words" min="0" style="margin-bottom:0;">
<input type="number" id="filter-max-words" class="filter-input" placeholder="Max Words" min="0" style="margin-bottom:0;">
</div>
<div class="input-row">
<input type="number" id="filter-min-chapters" class="filter-input" placeholder="Min Chaps" min="1" style="margin-bottom:0;">
<input type="number" id="filter-max-chapters" class="filter-input" placeholder="Max Chaps" min="1" style="margin-bottom:0;">
</div>
</div>
<div class="filter-group">
<h4>Engagement</h4>
<div class="input-row">
<input type="number" id="filter-min-kudos" class="filter-input" placeholder="Min Kudos" min="0" style="margin-bottom:0;">
<input type="number" id="filter-max-kudos" class="filter-input" placeholder="Max Kudos" min="0" style="margin-bottom:0;">
</div>
<div class="input-row">
<input type="number" id="filter-min-bookmarks" class="filter-input" placeholder="Min Bookmarks" min="0" style="margin-bottom:0;">
<input type="number" id="filter-max-bookmarks" class="filter-input" placeholder="Max Bookmarks" min="0" style="margin-bottom:0;">
</div>
<div class="input-row">
<input type="number" id="filter-min-hits" class="filter-input" placeholder="Min Hits" min="0" style="margin-bottom:0;">
<input type="number" id="filter-max-hits" class="filter-input" placeholder="Max Hits" min="0" style="margin-bottom:0;">
</div>
</div>
<div class="filter-group">
<h4>Your History</h4>
<div class="input-row">
<input type="number" id="filter-min-visits" class="filter-input" placeholder="Min Visits" min="1" style="margin-bottom:0;">
<input type="number" id="filter-max-visits" class="filter-input" placeholder="Max Visits" min="1" style="margin-bottom:0;">
</div>
<div class="input-row">
<input type="number" step="0.1" id="filter-min-reads" class="filter-input" placeholder="Min Reads" min="0" style="margin-bottom:0;">
<input type="number" step="0.1" id="filter-max-reads" class="filter-input" placeholder="Max Reads" min="0" style="margin-bottom:0;">
</div>
</div>
</div>
</details>
<div id="stats-bar">
<span><strong>Showing:</strong> <span id="works-count">0</span> works</span>
<span><strong>Estimated Total Words Read:</strong> <span id="total-words-read" style="color: #900; font-weight: bold;">0</span></span>
</div>
<ul id="works-container" style="list-style: none; padding: 0;"></ul>
</div>
<a href="#" id="back-to-top" aria-label="Back to top" onclick="event.preventDefault();">↑</a>
<div id="advanced-stats-modal">
<div class="modal-content">
<div class="modal-header">
<h2>${modalPrefix}History Analytics</h2>
<div style="display: flex; gap: 15px; align-items: center;">
<button id="export-stats-btn" class="utility-btn" style="background: #fff !important; color: #900 !important; font-weight: bold !important; box-shadow: 0 2px 4px rgba(0,0,0,0.2) !important; border:none !important;">📸 Save PNG</button>
<span class="modal-close" onclick="document.getElementById('advanced-stats-modal').style.display='none'; document.body.style.overflow='auto';">&times;</span>
</div>
</div>
<div class="modal-body" id="stats-modal-body"></div>
</div>
</div>
`;
// Attach functionality to dynamic inputs
setupCustomTagInput('filter-include-tag', 'include-tags-container', 'include-tags-dropdown', selectedIncludeTags);
setupCustomTagInput('filter-exclude-tag', 'exclude-tags-container', 'exclude-tags-dropdown', selectedExcludeTags);
// Bind change/input events to apply filters automatically
['history-search', 'history-sort', 'filter-status', 'filter-rating', 'filter-min-words', 'filter-max-words', 'filter-min-chapters', 'filter-max-chapters', 'filter-min-kudos', 'filter-max-kudos', 'filter-min-hits', 'filter-max-hits', 'filter-min-bookmarks', 'filter-max-bookmarks', 'filter-min-visits', 'filter-max-visits', 'filter-min-reads', 'filter-max-reads'].forEach(id => document.getElementById(id).addEventListener('input', applySortAndFilter));
document.getElementById('btn-stats').addEventListener('click', openStatsModal);
document.getElementById('export-stats-btn').addEventListener('click', exportStatsToImage);
document.getElementById('advanced-stats-modal').addEventListener('click', (e) => {
if (e.target.id === 'advanced-stats-modal') {
e.target.style.display = 'none';
document.body.style.overflow = 'auto';
}
});
// JSON Exporter Logic
document.getElementById('download-json').addEventListener('click', () => {
const dataStr = JSON.stringify(globalWorks, null, 2);
const blob = new Blob([dataStr], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = currentUsername ? `${currentUsername}_AO3_History.json` : 'AO3_Reading_History.json';
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
});
// Rescrape mechanism
document.getElementById('force-rescrape').addEventListener('click', () => {
if(confirm("Are you sure you want to clear the cache and fetch everything again?")) {
localStorage.removeItem(CACHE_KEY);
window.location.reload();
}
});
// Back to Top behavior
const backToTopBtn = document.getElementById('back-to-top');
window.addEventListener('scroll', () => {
if (window.scrollY > 400) backToTopBtn.classList.add('show');
else backToTopBtn.classList.remove('show');
});
backToTopBtn.addEventListener('click', () => window.scrollTo({ top: 0, behavior: 'smooth' }));
}
// ============================================================================
// MODULE 8: INITIALIZATION
// ============================================================================
/**
* Main bootloader function.
* Grabs the user's name, creates a loading screen, checks cache validity,
* triggers scraping if needed, and finally kicks off the UI build.
*/
async function initScraper() {
const userNode = document.querySelector('#greeting a[href*="/users/"]');
if (userNode) {
const hrefMatch = userNode.getAttribute('href').match(/\/users\/([^/]+)/);
if (hrefMatch && hrefMatch[1]) {
currentUsername = decodeURIComponent(hrefMatch[1]);
} else {
currentUsername = userNode.textContent.replace(/Hi,\s*/i, '').replace(/!/g, '').trim();
}
}
// Create loading overlay
const overlay = document.createElement('div');
overlay.id = 'ao3-scraper-overlay';
overlay.style.cssText = 'position: fixed; top: 0; left: 0; width: 100vw; height: 100vh; background: rgba(255,255,255,0.98); z-index: 9999999; display: flex; flex-direction: column; align-items: center; justify-content: center; font-family: sans-serif;';
overlay.innerHTML = `
<h1 style="color: #900; margin-bottom: 10px;">Scraping AO3 History</h1>
<div style="width: 40px; height: 40px; border: 4px solid #ccc; border-top: 4px solid #900; border-radius: 50%; animation: spin 1s linear infinite; margin-bottom: 20px;"></div>
<style>@keyframes spin { 0% { transform: rotate(0deg); } 100% { transform: rotate(360deg); } }</style>
<p id="ao3-loading-text" style="font-size: 1.2em; color: #333; font-weight: bold; margin-bottom: 5px;">Starting up...</p>
<p style="font-size: 0.9em; color: #888;">(Do not close or refresh this tab)</p>
`;
document.body.appendChild(overlay);
// Validate Cache against Page 1 data
updateLoadingText("Fetching Page 1 to verify cache signature...");
const firstPageHtml = await fetchPageWithRetry(window.location.href, 1);
const parser = new DOMParser();
const doc = parser.parseFromString(firstPageHtml, 'text/html');
const firstPageData = parseAO3Document(doc);
// Signature checks the exact history combinations on page 1. If visits change, cache breaks.
const signature = firstPageData.works.map(w => `${w.workId}-${w.visitCount}-${w.lastVisited}`).join('|');
Object.assign(globalTagUrlMap, firstPageData.tagMap);
try {
const cachedDataStr = localStorage.getItem(CACHE_KEY);
if (cachedDataStr) {
updateLoadingText("Checking cache...");
const cachedData = JSON.parse(cachedDataStr);
if (cachedData.signature === signature) {
updateLoadingText("Cache match! Loading history instantly...");
await delay(500);
globalWorks = cachedData.works;
globalTagUrlMap = cachedData.tagUrls || globalTagUrlMap; // Load cached URLs safely
processUniqueTags();
setupUI();
applySortAndFilter();
return;
}
}
} catch (err) { console.error("Cache read failed", err); }
// If we reach here, Cache was empty or invalid. Time to scrape.
updateLoadingText("Cache missed or outdated. Beginning full scrape...");
await delay(800);
let resultsArray = [firstPageData];
const nextLink = doc.querySelector('li.next a');
if (nextLink) {
const nextUrl = new URL(nextLink.getAttribute('href'), window.location.origin).href;
await delay(500);
resultsArray = await scrapeAllHistory(nextUrl, resultsArray, 2);
}
updateLoadingText("Flattening and preparing data...");
globalWorks = resultsArray.flatMap(result => result.works);
processUniqueTags();
updateLoadingText("Saving history to local cache...");
try {
localStorage.setItem(CACHE_KEY, JSON.stringify({
signature,
works: globalWorks,
tagUrls: globalTagUrlMap
}));
} catch (err) { console.error("Cache save failed", err); }
updateLoadingText("Rendering interface...");
await delay(300);
setupUI();
applySortAndFilter();
}
// Start the script
initScraper();
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment