您需要先安装一个扩展,例如 篡改猴、Greasemonkey 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 Userscripts ,之后才能安装此脚本。
您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey,才能安装此脚本。
您需要先安装用户脚本管理器扩展后才能安装此脚本。
Advanced search and filtering for your reading history. Find fics by title, author, fandom, tags, or summary.
// ==UserScript== // @name FicTrail - AO3 History Viewer // @namespace https://github.com/serpentineegg/fictrail // @version 0.1.6 // @description Advanced search and filtering for your reading history. Find fics by title, author, fandom, tags, or summary. // @author serpentineegg // @match https://archiveofourown.org/users/*/readings* // @grant GM_addStyle // @run-at document-idle // @license MIT // ==/UserScript== (function() { 'use strict'; // Constants Module - Configuration and constants const AO3_BASE_URL = 'https://archiveofourown.org'; const MAX_PAGES_FETCH = 100; const ITEMS_PER_PAGE = 20; // Error Messages const ERROR_MESSAGES = { FETCH_FAILED: 'Uh oh! Something went wrong while fetching your reading adventures. Let\'s try again?', NO_DATA: 'Hmm, we didn\'t get any fic data back. Want to try that again?', LOGIN_REQUIRED: 'Please sign into your AO3 account first. We need access to your history!', LOGGED_OUT: 'It looks like you\'ve been logged out of AO3. Please sign in again!' }; // Utils Module - Helper functions function escapeHtml(text) { const div = document.createElement('div'); div.textContent = text; return div.innerHTML; } function debounce(func, wait) { let timeout; return function executedFunction(...args) { const later = () => { clearTimeout(timeout); func(...args); }; clearTimeout(timeout); timeout = setTimeout(later, wait); }; } // Check if we're on AO3 and user is logged in function getUsername() { const greetingLink = document.querySelector('#greeting .user a[href*="/users/"]'); if (greetingLink) { const href = greetingLink.getAttribute('href'); const match = href.match(/\/users\/([^/]+)/); return match ? match[1] : null; } return null; } // Detect logged-out state based on AO3 login indicators within a fetched Document function isLoggedOutDoc(doc) { const loginLink = doc.querySelector('a[href*="/users/login"]'); const loggedOutMessage = doc.querySelector('.flash.notice'); return Boolean(loginLink || (loggedOutMessage && loggedOutMessage.textContent.includes('log in'))); } // Styles Module - All CSS styling for FicTrail function addStyles() { const css = ` /* FicTrail Styles */ #fictrail-overlay { position: fixed; top: 0; left: 0; width: 100%; height: 100%; background: rgba(0, 0, 0, 0.8); z-index: 10000; display: none; } #fictrail-container { position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%); width: 95%; max-width: 1200px; min-width: 800px; height: 90%; background: #f8fafc; border-radius: 12px; overflow: hidden; display: flex; flex-direction: column; } #fictrail-close { background: rgba(0, 0, 0, 0.1); color: #4a5568; border: none; width: 32px; height: 32px; border-radius: 6px; cursor: pointer; font-size: 16px; font-weight: 600; display: flex; align-items: center; justify-content: center; transition: all 0.2s ease; } #fictrail-close:hover { background: rgba(239, 68, 68, 0.1); color: #dc2626; } #fictrail-header-main { flex: 1; } #fictrail-content { padding: 0; overflow-y: auto; height: 100%; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; display: flex; flex-direction: column; } .fictrail-header { padding: 24px 24px 20px 24px; display: flex; justify-content: space-between; align-items: flex-start; gap: 32px; } .fictrail-header h1 { font-size: 2em; font-weight: 300; color: #2d3748; margin: 0 0 3px 0; } .fictrail-header p { font-size: 1em; color: #718096; margin: 0; } .fictrail-main { flex: 1; overflow-y: auto; display: flex; flex-direction: column; } .fictrail-btn { background: #3b82f6; color: white; border: none; padding: 12px 24px; border-radius: 8px; font-size: 16px; font-weight: 600; cursor: pointer; transition: background-color 0.2s ease; } .fictrail-btn:hover { background: #2563eb; } .fictrail-btn:disabled { background: #9ca3af; cursor: not-allowed; opacity: 0.6; } .fictrail-btn:disabled:hover { background: #9ca3af; } .fictrail-btn-secondary { background: white; color: #4a5568; border: 2px solid #e2e8f0; padding: 10px 20px; border-radius: 8px; font-size: 16px; font-weight: 600; cursor: pointer; } .fictrail-loading { padding: 80px 40px; text-align: center; } .fictrail-spinner { width: 40px; height: 40px; border: 3px solid #e2e8f0; border-top: 3px solid #3b82f6; border-radius: 50%; animation: fictrail-spin 1s linear infinite; margin: 0 auto 32px; } @keyframes fictrail-spin { 0% { transform: rotate(0deg); } 100% { transform: rotate(360deg); } } .fictrail-error { padding: 80px 40px; text-align: center; } .fictrail-error h2 { font-size: 2.2em; font-weight: 300; color: #e53e3e; margin-bottom: 24px; } .fictrail-history { padding: 20px; padding-top: 20px; padding-bottom: 0px; } .fictrail-controls { display: flex; gap: 20px; align-items: flex-start; flex-wrap: wrap; margin-bottom: 20px; } .fictrail-search { flex: 1; min-width: 300px; } .fictrail-search input { width: 100%; padding: 10px 15px; border: 2px solid #e2e8f0; border-radius: 8px; font-size: 14px; outline: none; box-shadow: none !important; -webkit-box-shadow: none !important; -moz-box-shadow: none !important; height: 42px; line-height: 1.2; box-sizing: border-box; } .fictrail-results-count { margin-top: 8px; font-size: 14px; color: #718096; font-weight: 500; } .fictrail-filter select { padding: 10px 15px; border: 2px solid #e2e8f0; border-radius: 8px; background: white; font-size: 14px; cursor: pointer; outline: none; width: 200px; height: 42px; line-height: 1.2; } .fictrail-works { display: grid; grid-template-columns: repeat(auto-fit, minmax(400px, 1fr)); gap: 20px; } .fictrail-work { background: white; border-radius: 12px; padding: 20px; border: 1px solid #e2e8f0; transition: all 0.2s ease; } .fictrail-work:hover { border-color: #cbd5e0; } .fictrail-work-header { display: flex; justify-content: space-between; align-items: flex-start; margin-bottom: 8px; } .fictrail-work h3 { margin: 0; font-size: 1.1em; line-height: 1.4; flex: 1; margin-right: 15px; } .fictrail-work h3 a { color: #3b82f6; text-decoration: none; font-weight: 600; border-bottom: 1px solid transparent; transition: all 0.2s ease; } .fictrail-work h3 a:hover { color: #2563eb; border-bottom-color: #3b82f6; text-decoration: none !important; } .fictrail-work-number { background: #3b82f6; color: white; padding: 4px 10px; border-radius: 20px; font-size: 12px; font-weight: 600; } .fictrail-author { color: #4a5568; font-style: italic; margin-bottom: 6px; font-weight: 500; } .fictrail-author a { color: #3b82f6; text-decoration: none; font-weight: 600; } .fictrail-fandoms { color: #38a169; font-weight: 600; margin-bottom: 8px; font-size: 0.9em; } .fictrail-metadata { display: flex; gap: 12px; flex-wrap: wrap; margin-top: 12px; } .fictrail-metadata span { background: #edf2f7; color: #4a5568; padding: 4px 8px; border-radius: 6px; font-size: 12px; font-weight: 500; } .fictrail-last-visited { color: #718096; font-size: 12px; margin: 12px 0; font-weight: 500; } .fictrail-summary { color: #718096; font-size: 14px; line-height: 1.4; margin: 10px 0 0 0; max-height: 120px; overflow-y: auto; border: 1px solid #e2e8f0; border-radius: 6px; padding: 8px; background: #fafafa; } .fictrail-matching-section { margin: 12px 0; padding: 12px; background: #f0f9ff; border-radius: 6px; border: 1px solid #bae6fd; font-size: 14px; line-height: 1.4; } .fictrail-tag-match { position: relative; display: inline-block; padding: 2px 6px; margin: 2px; border-radius: 4px; font-size: 12px; font-weight: 500; cursor: pointer; } /* Tooltip text hidden by default */ .fictrail-tag-match::after { content: attr(data-tooltip); position: absolute; bottom: 125%; left: 50%; transform: translateX(-50%); background-color: #1f2937; color: white; padding: 6px 10px; border-radius: 6px; font-size: 12px; font-weight: 500; white-space: nowrap; z-index: 1000; /* Initially hidden */ visibility: hidden; opacity: 0; transition: opacity 0.2s ease, visibility 0.2s ease; /* Pointer events to prevent interference */ pointer-events: none; } /* Tooltip arrow */ .fictrail-tag-match::before { content: ""; position: absolute; bottom: 115%; left: 50%; transform: translateX(-50%); border: 5px solid transparent; border-top-color: #1f2937; z-index: 1000; /* Initially hidden */ visibility: hidden; opacity: 0; transition: opacity 0.2s ease, visibility 0.2s ease; /* Pointer events to prevent interference */ pointer-events: none; } /* Show tooltip on hover and focus */ .fictrail-tag-match:hover::after, .fictrail-tag-match:hover::before, .fictrail-tag-match:focus::after, .fictrail-tag-match:focus::before { visibility: visible; opacity: 1; } /* Make tags focusable for keyboard accessibility */ .fictrail-tag-match { outline: none; tabindex: 0; } /* Darker colors on focus for better visibility */ .fictrail-tag-relationship:focus { background: #fde047; color: #b45309; } .fictrail-tag-character:focus { background: #bbf7d0; color: #15803d; } .fictrail-tag-freeform:focus { background: #c7d2fe; color: #3730a3; } .fictrail-tag-relationship { background: #fef3c7; color: #d97706; border: 1px solid #fed7aa; } .fictrail-tag-character { background: #dcfce7; color: #16a34a; border: 1px solid #bbf7d0; } .fictrail-tag-freeform { background: #e0e7ff; color: #4338ca; border: 1px solid #c7d2fe; } .fictrail-no-results { padding: 80px 40px; text-align: center; } .fictrail-no-results h3 { font-size: 1.8em; font-weight: 300; margin-bottom: 16px; color: #4a5568; } .fictrail-narrow-search-message { margin-top: 20px; background: #fef3c7; border: 1px solid #f59e0b; border-radius: 8px; padding: 15px; text-align: center; } .fictrail-narrow-search-message p { margin: 0; color: #92400e; font-size: 0.9em; } .fictrail-load-more-message { margin-top: 20px; text-align: center; } .fictrail-load-more-message p { margin: 0; color: #718096; font-size: 0.9em; font-weight: 500; } .fictrail-load-more-btn { margin-top: 15px; text-align: center; padding-bottom: 20px; } .fictrail-load-more-btn button { background: white; color: #3b82f6; border: 2px solid #3b82f6; padding: 12px 24px; border-radius: 8px; font-size: 16px; font-weight: 600; cursor: pointer; transition: all 0.2s ease; } .fictrail-load-more-btn button:hover { background: #3b82f6; color: white; } .fictrail-favorite-tags-summary { margin: 10px 0 5px 0; } .fictrail-summary-text { color: #718096; font-size: 10px; font-style: italic; margin: 0; line-height: 1.5; } .fictrail-slider-container { display: flex; flex-direction: column; gap: 8px; } .fictrail-slider-track { display: flex; align-items: center; justify-content: center; gap: 12px; } .fictrail-slider-min, .fictrail-slider-max { font-size: 12px; color: #718096; font-weight: 500; min-width: 20px; text-align: center; } .fictrail-slider { width: 200px; height: 6px; border-radius: 3px; background: #e2e8f0; outline: none; -webkit-appearance: none; appearance: none; } .fictrail-slider::-webkit-slider-thumb { -webkit-appearance: none; appearance: none; width: 18px; height: 18px; border-radius: 50%; background: #3b82f6; cursor: pointer; } .fictrail-slider::-moz-range-thumb { width: 18px; height: 18px; border-radius: 50%; background: #3b82f6; cursor: pointer; border: none; } /* Footer Styles */ .fictrail-footer { padding: 30px 20px; text-align: center; } .fictrail-footer-content { display: flex; flex-direction: column; gap: 20px; align-items: center; } .fictrail-footer .fictrail-page-selector { display: flex; flex-direction: column; gap: 15px; align-items: center; } .fictrail-footer .fictrail-page-selector-header { display: flex; align-items: center; gap: 8px; } .fictrail-footer .fictrail-page-selector-header label { font-size: 16px; font-weight: 600; color: #374151; } .fictrail-info-message p { margin: 10px 0; font-size: 14px; color: #6b7280; } .fictrail-footer .fictrail-slider-container { width: 300px; } `; // Use GM_addStyle if available, otherwise fallback to creating style element if (typeof GM_addStyle !== 'undefined') { GM_addStyle(css); } else { const style = document.createElement('style'); style.textContent = css; document.head.appendChild(style); } } // Scraper Module - AO3 history fetching and parsing // Safely extract text content while preserving line breaks from HTML elements function extractTextWithLineBreaks(element) { // Clone the element to avoid modifying the original const clone = element.cloneNode(true); // Replace block elements with newlines const blockElements = clone.querySelectorAll('p, div, br'); blockElements.forEach(el => { if (el.tagName === 'BR') { el.replaceWith('\n'); } else { // Add newline after block elements el.insertAdjacentText('afterend', '\n'); } }); // Get text content and clean up extra whitespace return clone.textContent.replace(/\n\s*\n/g, '\n').trim(); } function scrapeHistoryFromPage(doc) { const works = []; const workItems = doc.querySelectorAll('ol.reading li.work'); workItems.forEach((item) => { const titleLink = item.querySelector('h4.heading a[href*="/works/"]'); const authorLink = item.querySelector('h4.heading a[rel="author"]'); const fandomLinks = item.querySelectorAll('h5.fandoms a.tag'); const lastVisitedEl = item.querySelector('h4.viewed.heading'); const summaryEl = item.querySelector('.userstuff.summary'); const wordsEl = item.querySelector('.stats dd.words'); const chaptersEl = item.querySelector('.stats dd.chapters'); const dateEl = item.querySelector('.datetime'); const tagsEl = item.querySelector('.tags.commas'); if (titleLink) { // Extract last visited date from the h4.viewed.heading element let lastVisited = ''; if (lastVisitedEl) { // Get the full text content and extract the date const fullText = lastVisitedEl.textContent; // Match pattern: "Last visited: DD MMM YYYY" or similar const dateMatch = fullText.match(/Last visited:\s*([^(]+)/); if (dateMatch) { lastVisited = dateMatch[1].trim(); } } const work = { title: titleLink.textContent.trim(), url: AO3_BASE_URL + titleLink.getAttribute('href'), author: authorLink ? authorLink.textContent.trim() : 'Anonymous', authorUrl: authorLink ? AO3_BASE_URL + authorLink.getAttribute('href') : null, fandoms: Array.from(fandomLinks).map(link => link.textContent.trim()), lastVisited: lastVisited, summary: summaryEl ? extractTextWithLineBreaks(summaryEl) : '', words: wordsEl ? wordsEl.textContent.trim() : '', chapters: chaptersEl ? chaptersEl.textContent.trim() : '', publishDate: dateEl ? dateEl.textContent.trim() : '', tags: tagsEl ? Array.from(tagsEl.querySelectorAll('a.tag')).map(tag => tag.textContent.trim()) : [], relationships: tagsEl ? Array.from(tagsEl.querySelectorAll('.relationships a.tag')).map(rel => rel.textContent.trim()) : [], characters: tagsEl ? Array.from(tagsEl.querySelectorAll('.characters a.tag')).map(char => char.textContent.trim()) : [], freeforms: tagsEl ? Array.from(tagsEl.querySelectorAll('.freeforms a.tag')).map(tag => tag.textContent.trim()) : [] }; works.push(work); } }); return works; } async function fetchHistoryPage(username, page = 1) { const url = `${AO3_BASE_URL}/users/${username}/readings?page=${page}`; try { const response = await fetch(url); const html = await response.text(); const parser = new DOMParser(); const doc = parser.parseFromString(html, 'text/html'); // Check logged-out state using shared helper if (isLoggedOutDoc(doc)) { throw new Error('NOT_LOGGED_IN'); } return scrapeHistoryFromPage(doc); } catch (error) { console.error(`Error fetching page ${page}:`, error); if (error.message === 'NOT_LOGGED_IN') { throw error; } return []; } } function getTotalPages(doc = document) { const pagination = doc.querySelector('.pagination'); if (!pagination) return 1; const pageLinks = pagination.querySelectorAll('a'); let maxPage = 1; pageLinks.forEach(link => { const pageNum = parseInt(link.textContent.trim()); if (!isNaN(pageNum) && pageNum > maxPage) { maxPage = pageNum; } }); const nextLink = pagination.querySelector('a[rel="next"]'); if (nextLink && maxPage === 1) { maxPage = 2; } return maxPage; } async function fetchMultiplePages(username, maxPagesToFetch = MAX_PAGES_FETCH) { let totalPages; let firstPageWorks = []; try { const firstPageUrl = `${AO3_BASE_URL}/users/${username}/readings?page=1`; const response = await fetch(firstPageUrl); const html = await response.text(); const parser = new DOMParser(); const doc = parser.parseFromString(html, 'text/html'); // Detect logged-out state on the first page fetch if (isLoggedOutDoc(doc)) { throw new Error('NOT_LOGGED_IN'); } totalPages = getTotalPages(doc); firstPageWorks = scrapeHistoryFromPage(doc); } catch (error) { console.error('Error fetching first page:', error); if (error.message === 'NOT_LOGGED_IN') { // Propagate to caller so UI can show proper login prompt throw error; } return { works: [], totalPages: 1 }; } const pagesToFetch = Math.min(maxPagesToFetch, totalPages); const works = [...firstPageWorks]; // Start from page 2 since we already have page 1 for (let page = 2; page <= pagesToFetch; page++) { showLoading(`Loading page ${page} of ${pagesToFetch}...`); const pageWorks = await fetchHistoryPage(username, page); works.push(...pageWorks); await new Promise(resolve => setTimeout(resolve, 500)); } return { works: works, totalPages: totalPages }; } // Search Module - Search and filtering functionality function performSearch() { const query = document.getElementById('fictrail-search-input').value.toLowerCase().trim(); if (query === '') { filteredWorks = [...allWorks]; filteredWorks.forEach(work => { work.matchingTags = []; work.matchingSummary = null; }); // Apply filter which will show the count applyFilter(); return; } else { filteredWorks = allWorks.filter(work => { const matchingTags = []; let matchingSummary = null; if (work.relationships) { work.relationships.forEach(rel => { if (rel.toLowerCase().includes(query)) { matchingTags.push({ type: 'relationship', value: rel }); } }); } if (work.characters) { work.characters.forEach(char => { if (char.toLowerCase().includes(query)) { matchingTags.push({ type: 'character', value: char }); } }); } if (work.freeforms) { work.freeforms.forEach(tag => { if (tag.toLowerCase().includes(query)) { matchingTags.push({ type: 'freeform', value: tag }); } }); } // Check for summary match and extract fragment if (work.summary && work.summary.toLowerCase().includes(query)) { const summaryLower = work.summary.toLowerCase(); const queryIndex = summaryLower.indexOf(query); const start = Math.max(0, queryIndex - 50); const end = Math.min(work.summary.length, queryIndex + query.length + 50); let fragment = work.summary.substring(start, end); if (start > 0) fragment = '...' + fragment; if (end < work.summary.length) fragment = fragment + '...'; matchingSummary = fragment; } work.matchingTags = matchingTags; work.matchingSummary = matchingSummary; return work.title.toLowerCase().includes(query) || work.author.toLowerCase().includes(query) || work.fandoms.some(fandom => fandom.toLowerCase().includes(query)) || work.summary.toLowerCase().includes(query) || matchingTags.length > 0 || (work.tags && work.tags.some(tag => tag.toLowerCase().includes(query))); }); } applyFilter(); } function applyFilter() { const selectedFandom = document.getElementById('fictrail-fandom-filter').value; let worksToDisplay = [...filteredWorks]; if (selectedFandom) { worksToDisplay = worksToDisplay.filter(work => work.fandoms.includes(selectedFandom) ); } worksToDisplay.sort((a, b) => { if (a.lastVisited && b.lastVisited) { return new Date(b.lastVisited) - new Date(a.lastVisited); } return 0; }); // Reset pagination for new search/filter currentDisplayCount = ITEMS_PER_PAGE; // Show results count updateResultsCount(worksToDisplay.length); displayWorks(worksToDisplay); } function updateResultsCount(count) { const resultsCountElement = document.getElementById('fictrail-results-count'); if (resultsCountElement) { if (count > 0) { resultsCountElement.textContent = `${count} result${count === 1 ? '' : 's'}`; resultsCountElement.style.display = 'block'; } else { resultsCountElement.style.display = 'none'; } } } function populateFandomFilter(works) { const fandomFilter = document.getElementById('fictrail-fandom-filter'); const allFandoms = new Set(); works.forEach(work => { work.fandoms.forEach(fandom => allFandoms.add(fandom)); }); const sortedFandoms = Array.from(allFandoms).sort((a, b) => a.localeCompare(b)); fandomFilter.innerHTML = '<option value="">All Fandoms</option>'; sortedFandoms.forEach(fandom => { const option = document.createElement('option'); option.value = fandom; option.textContent = fandom; fandomFilter.appendChild(option); }); } // UI Module - DOM creation and event handling // Create FicTrail button - try history page placement first function createFicTrailButton() { // Only add to history page if we're on the readings page if (window.location.pathname.includes('/readings')) { addToHistoryPage() } } // Add FicTrail button in front of "Full History" in subnav function addToHistoryPage() { const subNav = document.querySelector('ul.navigation.actions[role="navigation"]'); if (!subNav) { return false; // Subnav not found } // Create list item for the button const listItem = document.createElement('li'); // Create the button using AO3's button styles const button = document.createElement('a'); button.id = 'fictrail-history-btn'; button.textContent = '📚 FicTrail'; button.style.cursor = 'pointer'; button.tabIndex = 0; button.addEventListener('click', openFicTrail); // Add keyboard support button.addEventListener('keydown', (e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); openFicTrail(); } }); listItem.appendChild(button); // Insert at the beginning of the subnav subNav.insertBefore(listItem, subNav.firstChild); return true; } // Create overlay HTML function createOverlay() { const overlay = document.createElement('div'); overlay.id = 'fictrail-overlay'; overlay.innerHTML = ` <div id="fictrail-container"> <div id="fictrail-content"> <header class="fictrail-header"> <div id="fictrail-header-main"> <h1>📚 FicTrail</h1> <p id="fictrail-subtitle">Your AO3 History</p> </div> <div id="fictrail-controls" style="display: flex; flex-direction: column; align-items: center;"> </div> <div id="fictrail-close-btn"> <button id="fictrail-close">×</button> </div> </header> <hr style="border: none; border-top: 1px solid #e2e8f0; margin: 0;"> <main class="fictrail-main"> <div id="fictrail-loading-section" class="fictrail-loading" style="display: none;"> <div class="fictrail-spinner"></div> <h2>Summoning your fic history...</h2> <p id="fictrail-loading-status">Diving deep into your AO3 rabbit hole...</p> </div> <div id="fictrail-error-section" class="fictrail-error" style="display: none;"> <h2>💀 Plot Twist!</h2> <p id="fictrail-error-message"></p> <button id="fictrail-retry-btn" class="fictrail-btn-secondary">Try Again (Please?)</button> </div> <div id="fictrail-history-section" class="fictrail-history" style="display: none;"> <div class="fictrail-controls"> <div class="fictrail-search"> <input type="text" id="fictrail-search-input" placeholder="Search by fandoms, titles, tags, authors, or summaries..."> <div id="fictrail-results-count" class="fictrail-results-count" style="display: none;"></div> </div> <div class="fictrail-filter"> <select id="fictrail-fandom-filter"> <option value="">All Fandoms</option> </select> </div> </div> <div id="fictrail-works-list" class="fictrail-works"></div> <div id="fictrail-no-results" class="fictrail-no-results" style="display: none;"> <h3>No Results Found</h3> <p>Your search came up empty! Try different keywords or maybe you haven't read that trope yet? 👀</p> </div> <footer id="fictrail-footer" class="fictrail-footer" style="display: none;"> <div class="fictrail-footer-content"> <div class="fictrail-page-selector" id="fictrail-page-selector"> <div class="fictrail-page-selector-header"> <label for="fictrail-pages-slider" id="fictrail-pages-label">You have ? pages of history. How deep should we search?</label> </div> <div class="fictrail-info-message"> <p>Loading many pages can be slow. Start with fewer pages for better performance, then reload with more if needed.</p> </div> <div class="fictrail-slider-container"> <div class="fictrail-slider-track"> <span class="fictrail-slider-min">1</span> <input type="range" id="fictrail-pages-slider" min="1" max="${MAX_PAGES_FETCH}" value="1" class="fictrail-slider"> <span class="fictrail-slider-max">${MAX_PAGES_FETCH}</span> </div> </div> </div> <button id="fictrail-load-btn" class="fictrail-btn">Reload History</button> </div> </footer> </div> </main> </div> </div> `; document.body.appendChild(overlay); // Add event listeners document.getElementById('fictrail-close').addEventListener('click', closeFicTrail); document.getElementById('fictrail-load-btn').addEventListener('click', reloadHistory); document.getElementById('fictrail-retry-btn').addEventListener('click', retryLastAction); document.getElementById('fictrail-search-input').addEventListener('input', debounce(performSearch, 300)); document.getElementById('fictrail-fandom-filter').addEventListener('change', applyFilter); document.getElementById('fictrail-pages-slider').addEventListener('input', updatePagesValue); // Add click handler for tags document.addEventListener('click', function(e) { if (e.target.classList.contains('fictrail-tag-match')) { const tagValue = e.target.getAttribute('data-tag-value'); const searchInput = document.getElementById('fictrail-search-input'); searchInput.value = tagValue; performSearch(); } }); // Close on overlay click overlay.addEventListener('click', (e) => { if (e.target === overlay) closeFicTrail(); }); } function openFicTrail() { document.getElementById('fictrail-overlay').style.display = 'block'; document.body.style.overflow = 'hidden'; // Only load data if we don't have any works yet if (allWorks.length === 0) { setTimeout(() => { loadFirstPage(); }, 100); } } function closeFicTrail() { document.getElementById('fictrail-overlay').style.display = 'none'; document.body.style.overflow = ''; } function updatePagesValue() { updateReloadButtonText(); } function updateReloadButtonText() { const currentPages = parseInt(document.getElementById('fictrail-pages-slider').value); const footer = document.getElementById('fictrail-footer'); const loadBtn = document.getElementById('fictrail-load-btn'); // Check if we're in reload mode (footer is visible) if (footer.style.display === 'block') { loadBtn.textContent = `Reload History (${currentPages} pages)`; } } function getPagesToLoad() { const slider = document.getElementById('fictrail-pages-slider'); // If slider doesn't exist yet, default to 1 page if (!slider) return 1; return parseInt(slider.value); } function showSection(sectionId) { const sections = ['fictrail-loading-section', 'fictrail-error-section', 'fictrail-history-section']; sections.forEach(id => { document.getElementById(id).style.display = id === sectionId ? 'block' : 'none'; }); } function showLoading(message = 'Summoning your fic history...') { showSection('fictrail-loading-section'); document.getElementById('fictrail-loading-status').textContent = message; } function showError(message) { showSection('fictrail-error-section'); document.getElementById('fictrail-error-message').innerHTML = message; } function displayWorks(works, append = false) { const worksList = document.getElementById('fictrail-works-list'); const noResults = document.getElementById('fictrail-no-results'); if (works.length === 0) { worksList.style.display = 'none'; noResults.style.display = 'block'; // Remove existing load more button and message when there are no results removeLoadMoreElements(); return; } worksList.style.display = 'grid'; noResults.style.display = 'none'; // Reset display count if not appending (new search/filter) if (!append) { currentDisplayCount = ITEMS_PER_PAGE; } const worksToShow = works.slice(0, currentDisplayCount); const hasMoreResults = works.length > currentDisplayCount; // Calculate starting index for work numbers const startIndex = append ? worksList.children.length : 0; const worksHTML = worksToShow .slice(append ? currentDisplayCount - ITEMS_PER_PAGE : 0) .map((work, index) => ` <div class="fictrail-work"> <div class="fictrail-work-header"> <h3> <a href="${work.url}" target="_blank" rel="noopener"> ${escapeHtml(work.title)} </a> </h3> <span class="fictrail-work-number">#${startIndex + index + 1}</span> </div> <p class="fictrail-author"> by ${work.authorUrl ? `<a href="${work.authorUrl}" target="_blank" rel="noopener">${escapeHtml(work.author)}</a>` : escapeHtml(work.author)} </p> ${work.fandoms.length > 0 ? `<p class="fictrail-fandoms"> ${work.fandoms.map(f => escapeHtml(f)).join(', ')} </p>` : ''} ${work.matchingTags && work.matchingTags.length > 0 ? `<div class="fictrail-matching-section"> <strong>Matching tags:</strong> ${work.matchingTags.map(tag => `<span class="fictrail-tag-match fictrail-tag-${tag.type}" data-tooltip="${tag.type.charAt(0).toUpperCase() + tag.type.slice(1)} tag" data-tag-value="${escapeHtml(tag.value)}" tabindex="0" role="button" aria-label="${escapeHtml(tag.value)} - ${tag.type.charAt(0).toUpperCase() + tag.type.slice(1)} tag"> ${escapeHtml(tag.value)} </span>`).join('')} </div>` : ''} ${work.matchingSummary ? `<div class="fictrail-matching-section"> <strong>Matching summary:</strong> ${escapeHtml(work.matchingSummary)} </div>` : ''} <div class="fictrail-metadata"> ${work.words ? `<span>${escapeHtml(work.words)} words</span>` : ''} ${work.chapters ? `<span>${escapeHtml(work.chapters)} chapters</span>` : ''} ${work.publishDate ? `<span>Published: ${escapeHtml(work.publishDate)}</span>` : ''} </div> ${work.lastVisited ? `<p class="fictrail-last-visited"> Last visited: ${escapeHtml(work.lastVisited)} </p>` : ''} ${work.summary && !work.matchingSummary ? `<div class="fictrail-summary"> ${escapeHtml(work.summary).replace(/\n/g, '<br>')} </div>` : ''} </div> `) .join(''); if (append) { worksList.insertAdjacentHTML('beforeend', worksHTML); } else { worksList.innerHTML = worksHTML; } // Remove existing load more elements before adding new ones removeLoadMoreElements(); // Add load more button if there are more results if (hasMoreResults) { createLoadMoreButton(works, currentDisplayCount); } } function removeLoadMoreElements() { const existingButton = document.getElementById('fictrail-load-more-btn'); const existingMessage = document.getElementById('fictrail-load-more-message'); if (existingButton) existingButton.remove(); if (existingMessage) existingMessage.remove(); } function createLoadMoreButton(works, currentCount) { const worksList = document.getElementById('fictrail-works-list'); const remainingCount = works.length - currentCount; const nextBatchSize = Math.min(ITEMS_PER_PAGE, remainingCount); // Create load more message const messageDiv = document.createElement('div'); messageDiv.id = 'fictrail-load-more-message'; messageDiv.className = 'fictrail-load-more-message'; messageDiv.innerHTML = ` <p>Showing ${currentCount} of ${works.length} results</p> `; // Create load more button const buttonDiv = document.createElement('div'); buttonDiv.id = 'fictrail-load-more-btn'; buttonDiv.className = 'fictrail-load-more-btn'; buttonDiv.innerHTML = ` <button class="fictrail-btn-secondary" id="fictrail-load-more-button"> Load ${nextBatchSize} More Results </button> `; // Insert after the works-list div worksList.parentNode.insertBefore(messageDiv, worksList.nextSibling); messageDiv.parentNode.insertBefore(buttonDiv, messageDiv.nextSibling); // Add event listener to the load more button const loadMoreButton = document.getElementById('fictrail-load-more-button'); loadMoreButton.addEventListener('click', loadMoreWorks); } function loadMoreWorks() { currentDisplayCount += ITEMS_PER_PAGE; displayWorks(filteredWorks, true); } function addFavoriteTagsSummary(works) { // Only consider the most recent works (approximately first 2 pages worth) const recentWorksLimit = 40; const recentWorks = works.slice(0, recentWorksLimit); // Count all tags across recent works only const tagCounts = {}; recentWorks.forEach(work => { // Count relationships if (work.relationships) { work.relationships.forEach(tag => { tagCounts[tag] = (tagCounts[tag] || 0) + 1; }); } // Count characters if (work.characters) { work.characters.forEach(tag => { tagCounts[tag] = (tagCounts[tag] || 0) + 1; }); } // Count freeforms if (work.freeforms) { work.freeforms.forEach(tag => { tagCounts[tag] = (tagCounts[tag] || 0) + 1; }); } }); // Sort tags by frequency and get the most popular one const sortedTags = Object.entries(tagCounts) .sort((a, b) => b[1] - a[1]); if (sortedTags.length > 0) { // Remove existing summary if it exists const existingSummary = document.getElementById('fictrail-favorite-tags-summary'); if (existingSummary) { existingSummary.remove(); } // Get the most popular tag const [mostPopularTag] = sortedTags[0]; // Create summary element const summaryDiv = document.createElement('div'); summaryDiv.id = 'fictrail-favorite-tags-summary'; summaryDiv.className = 'fictrail-favorite-tags-summary'; summaryDiv.innerHTML = ` <p class="fictrail-summary-text">So you've been really into ${escapeHtml(mostPopularTag)} lately. Love it for you.</p> `; // Insert after header subtitle const headerSubtitle = document.getElementById('fictrail-subtitle'); headerSubtitle.parentNode.insertBefore(summaryDiv, headerSubtitle.nextSibling); } } // Core Module - Main functionality and history loading let allWorks = []; let filteredWorks = []; let lastFailedAction = null; // Pagination state let currentDisplayCount = 20; function showLoginError(message = ERROR_MESSAGES.LOGIN_REQUIRED) { showError(` <strong>Oops! You're not logged in</strong><br> ${message}<br><br> <button onclick="window.open('${AO3_BASE_URL}/users/login', '_blank')" class="fictrail-btn"> Log In to AO3 </button> `); } function retryLastAction() { if (lastFailedAction === 'reloadHistory') { reloadHistory(); } else { loadFirstPage(); } } async function loadFirstPage() { lastFailedAction = 'loadFirstPage'; const username = getUsername(); if (!username) { showLoginError(); return; } try { // Check if we're on page 1 of readings - if so, parse current DOM instantly const urlParams = new URLSearchParams(window.location.search); const currentPage = parseInt(urlParams.get('page')) || 1; if (window.location.pathname.includes('/readings') && currentPage === 1 && !urlParams.has('show')) { const works = scrapeHistoryFromPage(document); const totalPages = getTotalPages(document); if (works && works.length > 0) { displayHistory(username, works, totalPages, 1); return; } } // Fallback: use reloadHistory to fetch first page await reloadHistory(); } catch (error) { if (error.message === 'NOT_LOGGED_IN') { showLoginError(ERROR_MESSAGES.LOGGED_OUT); return; } console.error('Error loading first page:', error); showError(ERROR_MESSAGES.FETCH_FAILED); } } async function reloadHistory() { lastFailedAction = 'reloadHistory'; const username = getUsername(); if (!username) { showLoginError(); return; } // Disable buttons while loading const loadBtn = document.getElementById('fictrail-load-btn'); const retryBtn = document.getElementById('fictrail-retry-btn'); loadBtn.disabled = true; if (retryBtn) retryBtn.disabled = true; // Clear search bar when reloading document.getElementById('fictrail-search-input').value = ''; // Get pages to load from slider const pagesToLoad = getPagesToLoad(); showLoading(`Loading ${pagesToLoad} pages of ${username}'s fic history...`); try { const result = await fetchMultiplePages(username, pagesToLoad); if (result.works && result.works.length > 0) { displayHistory(username, result.works, result.totalPages, pagesToLoad); } else { showError(ERROR_MESSAGES.NO_DATA); } } catch (error) { if (error.message === 'NOT_LOGGED_IN') { showLoginError(ERROR_MESSAGES.LOGGED_OUT); return; } console.error('Error loading history:', error); showError(ERROR_MESSAGES.FETCH_FAILED); } finally { // Re-enable buttons after loading completes const loadBtn = document.getElementById('fictrail-load-btn'); const retryBtn = document.getElementById('fictrail-retry-btn'); loadBtn.disabled = false; if (retryBtn) retryBtn.disabled = false; } } function displayHistory(username, works, totalPages, actualPagesLoaded) { showSection('fictrail-history-section'); allWorks = works; filteredWorks = [...works]; // Reset pagination when loading new history currentDisplayCount = ITEMS_PER_PAGE; const workCount = works.length; const uniqueAuthors = new Set(works.map(work => work.author)).size; const uniqueFandoms = new Set(works.flatMap(work => work.fandoms)).size; document.getElementById('fictrail-subtitle').textContent = `${username} • ${workCount} works • ${uniqueFandoms} fandoms • ${uniqueAuthors} authors`; // Update slider max value to match user's actual page count if (totalPages && totalPages > 0) { const slider = document.getElementById('fictrail-pages-slider'); const sliderMax = document.querySelector('.fictrail-slider-max'); const pagesLabel = document.getElementById('fictrail-pages-label'); slider.max = totalPages; sliderMax.textContent = totalPages; // Update the label with actual page count and current loaded pages if (actualPagesLoaded === totalPages) { pagesLabel.textContent = `You have ${totalPages} pages of history. All pages loaded.`; } else { pagesLabel.textContent = `You have ${totalPages} pages of history. Now ${actualPagesLoaded} ${actualPagesLoaded === 1 ? 'page is' : 'pages are'} loaded. Shall we go deeper?`; } // Set slider value to the actual pages loaded (for initial load) or keep current value (for reload) if (actualPagesLoaded !== undefined) { slider.value = actualPagesLoaded; } else { const newValue = Math.min(parseInt(slider.value), totalPages); slider.value = newValue; } } // Show footer with page selector and update button for reload functionality const footer = document.getElementById('fictrail-footer'); const loadBtn = document.getElementById('fictrail-load-btn'); footer.style.display = 'block'; loadBtn.textContent = 'Reload History'; loadBtn.onclick = reloadHistory; updateReloadButtonText(); // Add favorite tags summary addFavoriteTagsSummary(works); populateFandomFilter(works); updateResultsCount(works.length); displayWorks(works); } // Initialize when page loads function init() { addStyles(); createFicTrailButton(); createOverlay(); } // Auto-initialization if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', init); } else { init(); } })();