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