Skip to content

Instantly share code, notes, and snippets.

@strickvl
Last active April 5, 2025 13:40
Show Gist options
  • Save strickvl/c74d3308719f4c887c2ecd579c6e84ed to your computer and use it in GitHub Desktop.
Save strickvl/c74d3308719f4c887c2ecd579c6e84ed to your computer and use it in GitHub Desktop.
A Tampermonkey extension to enhance your Storygraph recommendations

StoryGraph Personalization Enhancer

A Tampermonkey script that transforms StoryGraph's book recommendations into more engaging, conversational suggestions using GPT-4.

CleanShot 2025-01-12 at 17 40 37

What does it do?

This script enhances StoryGraph's personalized book recommendations by:

  • Converting formal, analytical recommendations into conversational, friend-like suggestions
  • Making bolder predictions based on your reading history
  • Providing more direct and engaging recommendations
  • Maintaining the core recommendation while making it more personal and punchy

Before (example):

Based on your reading patterns, you might enjoy this book due to its strong character development and atmospheric setting. Your high ratings for similar literary fiction suggest...

After (example):

Look, given that you rated "Similar Book X" a solid 4.75 stars, this is absolutely going to be your jam! The character work here is exactly what you loved in your other top-rated books.

Quick reality check though — it's a bit slower paced than some of your recent favorites, so you might need to be in the right mood for this one.

Bottom line: Add this to your immediate TBR. Trust me, it's got your name written all over it!

Installation

  1. First, install the Tampermonkey browser extension:

  2. See below for the raw script (or create a new Tampermonkey script and paste the code)

  3. Click "Install" or save the script in Tampermonkey

  4. Get an OpenAI API key from OpenAI's platform

    • You'll be prompted to enter this key the first time you use the script
    • The key is stored locally in your browser

Usage

  1. Visit any book page on StoryGraph (app.thestorygraph.com/books/...)
  2. Click on the "Personalized" tab if it's not already selected
  3. Wait a moment while the script processes and enhances the recommendation
  4. Enjoy your enhanced, more personalized recommendation!

Features

  • Automatically activates on book pages
  • Preserves StoryGraph's original styling
  • Maintains responsiveness for both desktop and mobile views
  • Handles dynamic page updates
  • Debug logging (can be enabled by setting DEBUG = true)

Notes

  • You need an OpenAI API key to use this script
  • API usage will count towards your OpenAI API quota
  • The script respects StoryGraph's rate limits and page structure
  • Works with StoryGraph's Turbo Frame architecture

Privacy

  • Your reading history and recommendations stay between you and OpenAI's API
  • API keys are stored locally in your browser
  • No data is collected or stored externally

Contributing

Found a bug or want to suggest an improvement? Feel free to:

  1. Open an issue
  2. Submit a pull request
  3. Fork and modify for your own use

License

MIT License - feel free to modify and share!


Note: This script is not affiliated with or endorsed by StoryGraph. Use at your own discretion.

// ==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();
}
})();
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment