// ==UserScript== // @name StoryGraph Personalization Enhancer // @namespace http://tampermonkey.net/ // @version 1.8 // @description Enhance StoryGraph's personalized recommendations // @author You // @match https://app.thestorygraph.com/* // @grant GM_xmlhttpRequest // @grant GM_getValue // @grant GM_setValue // @connect api.openai.com // @connect blue.thestorygraph.com // @run-at document-idle // ==/UserScript== (function() { 'use strict'; const DEBUG = false; let isProcessing = false; function log(...args) { if (DEBUG) { console.log('%c[StoryGraph Enhancer]', 'color: #1FB784;', ...args); } } function isVisible(element) { return element && element.offsetParent !== null && window.getComputedStyle(element).display !== 'none'; } function findTurboFrames() { const desktopFrame = document.querySelector('turbo-frame[id^="personalized-preview-desktop-"]'); const mobileFrame = document.querySelector('turbo-frame[id^="personalized-preview-mobile-"]'); if (desktopFrame || mobileFrame) { log('Found frames:', { desktop: desktopFrame?.id || 'none', mobile: mobileFrame?.id || 'none' }); } return { desktopFrame, mobileFrame }; } function findPersonalizedTab() { const tabs = document.querySelectorAll('a[data-personalized="true"]'); return Array.from(tabs).find(tab => isVisible(tab)); } async function waitForContent(frame, maxWaitTime = 60000) { if (!frame) return null; const startTime = Date.now(); let attempt = 0; log('Waiting for content in frame:', frame.id); while (Date.now() - startTime < maxWaitTime) { // First check if frame itself has content if (frame.textContent?.trim()) { log('Frame has direct content'); return frame; } // Then check all direct children const children = frame.children; for (const child of children) { if (child.textContent?.trim()) { log('Found content in child:', child); return child; } } log(`Attempt ${++attempt}: No content yet...`); await new Promise(resolve => setTimeout(resolve, 200)); } log('Timeout reached in waitForContent for frame:', frame.id); return null; } async function clickPersonalizedTab() { const tab = findPersonalizedTab(); if (!tab) throw new Error('Personalized tab not found'); const isActive = tab.classList.contains('border-darkerGrey') || tab.classList.contains('border-lightGrey'); if (!isActive) { log('Clicking personalized tab...'); tab.click(); // Wait a moment for frames to update await new Promise(resolve => setTimeout(resolve, 500)); } else { log('Personalized tab already active'); } // Find frames after click/check const { desktopFrame, mobileFrame } = findTurboFrames(); const targetFrame = desktopFrame || mobileFrame; if (!targetFrame) { throw new Error('No turbo frame found'); } log('Waiting for content in frame:', targetFrame.id); const content = await waitForContent(targetFrame, 5000); if (!content) { throw new Error('No content found in frame'); } return content; } async function enhanceWithGPT(originalText) { log('Calling GPT API...'); try { // First check if we have an API key const apiKey = GM_getValue('openai_api_key'); if (!apiKey) { const key = prompt('Please enter your OpenAI API key:'); if (!key) throw new Error('No API key provided'); GM_setValue('openai_api_key', key); } const response = await fetch('https://api.openai.com/v1/chat/completions', { method: 'POST', headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${GM_getValue('openai_api_key')}` }, body: JSON.stringify({ model: 'chatgpt-4o-latest', messages: [{ role: 'system', content: `You are an enthusiastic, opinionated book recommender who transforms formal book recommendations into engaging, conversational recommendations. When rewriting recommendations: 1. Use a confident, direct tone as if you're a knowledgeable friend giving advice. Make strong statements about whether the reader will likely enjoy or dislike the book based on their reading history. 2. Structure your response in this order: - Start with your strongest opinion about fit/non-fit, directly referencing specific books and ratings from their history - Follow with a clear "BUT" or "catch" that acknowledges potential mismatches - End with a decisive "Bottom line" recommendation 3. Style guidelines: - Use conversational language ("Look," "Here's the thing," "Quick reality check") - Make bold predictions based on rating patterns (e.g., "if you loved X (4.75!), you'll definitely...") - Acknowledge both positive and negative preference patterns - Reference specific ratings to build credibility - Use em-dashes, parentheticals, and exclamation points for emphasis - Keep paragraphs short and punchy - Do not use any markdown formatting - the text will be displayed as plain text with line breaks 4. Transform formal aspects: - Change "Aspects you might like" into direct predictions - Replace neutral phrases ("suggests," "indicates") with stronger ones ("proves," "tells me everything") - Convert dry analysis into emotional reactions - Use the reader's rating history as evidence for your predictions 5. Tone: - Be enthusiastic but honest about potential issues - Show that you've really analyzed their reading patterns - Make clear calls about whether they should read it now, save it for later, or approach with caution - Don't hedge unless there's genuine uncertainty based on mixed ratings 6. Length: - Aim for 3-4 short paragraphs maximum - Always end with a "Bottom line" summary of 1-2 sentences - Keep total length under 200 words` }, { role: 'user', content: `Transform this clinical, bullet-pointed recommendation into a conversational, opinionated recommendation that feels like it's coming from a friend who knows your reading tastes intimately and isn't afraid to make bold predictions based on your reading history:\n\n${originalText}` }] }) }); const data = await response.json(); if (!response.ok) { throw new Error(`API error: ${data.error?.message || 'Unknown error'}`); } log('GPT API response received'); return data.choices[0].message.content.trim(); } catch (error) { log('GPT API error:', error); throw error; } } async function handleBookPage() { if (isProcessing) { log('Already processing, skipping...'); return; } isProcessing = true; log('Starting book page processing...'); try { await clickPersonalizedTab(); const turboFrameObserver = new MutationObserver((mutations) => { mutations.forEach((mutation) => { if (mutation.type === 'childList' && mutation.addedNodes.length > 0) { const content = mutation.target.querySelector('.trix-content, .preview-content'); if (content && content.textContent.trim()) { log('Content updated via Turbo frame'); enhanceContent(content); } } }); }); const { desktopFrame, mobileFrame } = findTurboFrames(); [desktopFrame, mobileFrame].forEach(frame => { if (frame) { turboFrameObserver.observe(frame, { childList: true, subtree: true }); } }); const desktopContent = await waitForContent(desktopFrame); const mobileContent = await waitForContent(mobileFrame); const content = desktopContent || mobileContent; if (!content) { throw new Error('Content not found after waiting'); } await enhanceContent(content); } catch (error) { log('Error during processing:', error.message); } finally { isProcessing = false; } } async function enhanceContent(content) { if (!content) return; const originalText = content.textContent.trim(); log('Found content:', originalText.substring(0, 100) + '...'); try { const enhancedText = await enhanceWithGPT(originalText); // Create enhanced div while preserving original styles const enhancedDiv = document.createElement('div'); enhancedDiv.className = 'enhanced-content'; enhancedDiv.innerHTML = enhancedText.replace(/\n/g, '<br>'); // Copy the original element's classes to preserve styling const originalClasses = content.className; content.innerHTML = ''; // Clear existing content // Preserve the original element's classes content.className = originalClasses; // Add subtle styling that doesn't override the theme enhancedDiv.style.cssText = ` padding: 15px; border-radius: 8px; line-height: 1.6; background: inherit; color: inherit; font-size: 16px; `; content.appendChild(enhancedDiv); log('Enhancement complete!'); } catch (error) { log('Enhancement failed:', error); } } function setupPageObserver() { document.addEventListener('turbo:load', () => { if (window.location.pathname.startsWith('/books/')) { setTimeout(() => handleBookPage(), 1000); } }); const observer = new MutationObserver((mutations) => { if (!window.location.pathname.startsWith('/books/') || isProcessing) return; const hasNewFrame = mutations.some(mutation => Array.from(mutation.addedNodes).some(node => node.nodeName === 'TURBO-FRAME' || (node.nodeType === 1 && node.querySelector?.('turbo-frame')) ) ); if (hasNewFrame) { setTimeout(() => handleBookPage(), 1000); } }); observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['id', 'class'], }); if (window.location.pathname.startsWith('/books/')) { setTimeout(() => handleBookPage(), 1000); } } if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', setupPageObserver); } else { setupPageObserver(); } })();