Last active
March 4, 2026 14:14
-
-
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)
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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,"""),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,"""):""}">${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,"""):""}">${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,""")}">×</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';">×</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();})(); |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| /** | |
| * ============================================================================ | |
| * 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, '"'); | |
| 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, '"') : ''}">${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, '"') : ''}">${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, '"')}">×</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';">×</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