// ==UserScript== // @name Pitchfork Reviews with r/indieheads Comments from Reddit // @namespace http://tampermonkey.net/ // @version 1.0 // @description Load and display Reddit comments from r/indieheads on Pitchfork album review pages. // @author TA // @license MIT // @match https://pitchfork.com/reviews/albums/* // @grant GM_xmlhttpRequest // @downloadURL none // ==/UserScript== (function() { 'use strict'; // --- Utility Functions (developed in previous steps) --- /** * Extracts the Album Name from the Pitchfork page. * @returns {string|null} The album name or null if not found. */ function extractAlbumName() { const albumElement = document.querySelector('h1[data-testid="ContentHeaderHed"]'); return albumElement ? albumElement.textContent.trim() : null; } /** * Extracts the Artist Name(s) from the Pitchfork page. * @returns {string|string[]|null} The artist name(s) or null if not found. */ function extractArtistName() { const artistElements = document.querySelectorAll('ul[class*="SplitScreenContentHeaderArtistWrapper"] div[class*="SplitScreenContentHeaderArtist"]'); if (!artistElements.length) { return null; } const artists = Array.from(artistElements).map(el => el.textContent.trim()); // Return a single string if only one artist, array if multiple return artists.length === 1 ? artists[0] : artists; } /** * Formats artist and album names into Reddit search query strings. * Returns separate queries for FRESH ALBUM and ALBUM DISCUSSION threads. * * @param {string|string[]} artistName The name of the artist(s). * @param {string} albumName The name of the album. * @returns {Object} Object with freshAlbumQuery and albumDiscussionQuery properties. */ function formatAlbumSearchQueries(artistName, albumName) { // If artistName is an array, join with ' & ' for the query const formattedArtist = Array.isArray(artistName) ? artistName.join(' & ') : artistName; // Create simpler queries that are more likely to match // Remove quotes and brackets which can cause search issues const freshAlbumQuery = `FRESH ALBUM ${formattedArtist} ${albumName}`; const albumDiscussionQuery = `ALBUM DISCUSSION ${formattedArtist} ${albumName}`; // Return both queries separately return { freshAlbumQuery, albumDiscussionQuery }; } /** * Constructs a Reddit search URL for the r/indieheads subreddit's JSON API endpoint. * Cleans the query by removing problematic characters like slashes and ampersands. * * @param {string} query The search query string. * @returns {string} The constructed Reddit search JSON API URL. */ function buildIndieHeadsSearchJsonUrl(query) { // Clean the query by removing slashes, ampersands, percent signs, and plus signs with spaces // that might interfere with the search functionality const cleanedQuery = query .replace(/[\/&%+]/g, ' ') // Replace slashes, ampersands, percent signs, and plus signs with spaces .replace(/\s+/g, ' ') // Replace multiple spaces with a single space .trim(); // Remove leading/trailing spaces const encodedQuery = encodeURIComponent(cleanedQuery); const searchUrl = `https://www.reddit.com/r/indieheads/search.json?q=${encodedQuery}&restrict_sr=on&sort=relevance&t=all`; return searchUrl; } /** * Identifies relevant Reddit thread URLs from search results based on title patterns. * Processes FRESH ALBUM and ALBUM DISCUSSION results separately. * Ensures no duplicate threads are added. * * @param {Array} freshAlbumResults The results from the FRESH ALBUM search. * @param {Array} albumDiscussionResults The results from the ALBUM DISCUSSION search. * @param {string} artistName The name of the artist(s). * @param {string} albumName The name of the album. * @returns {Array} An array of objects {title: string, url: string} for all matching threads. */ function identifyRelevantThreads(freshAlbumResults, albumDiscussionResults, artist, albumName) { const relevantThreads = []; // Track URLs to avoid duplicates const addedUrls = new Set(); // Helper function to find the best thread from search results const findBestThread = (results, threadType) => { if (!results || !Array.isArray(results) || results.length === 0) { console.log(`No ${threadType} search results found.`); return null; } console.log(`Processing ${results.length} ${threadType} search results.`); // Look for an exact match first for (const item of results) { if (item.kind === "t3" && item.data && item.data.title && item.data.permalink) { const title = item.data.title; const url = "https://www.reddit.com" + item.data.permalink; // Skip if we've already added this URL if (addedUrls.has(url)) { console.log(`Skipping duplicate thread: "${title}"`); continue; } // Check if this is the right type of thread if (title.toLowerCase().includes(threadType.toLowerCase()) && title.toLowerCase().includes(albumName.toLowerCase())) { console.log(`Found ${threadType} thread: "${title}"`); return { title, url }; } } } // If no exact match, take the first result that contains the album name for (const item of results) { if (item.kind === "t3" && item.data && item.data.title && item.data.permalink) { const title = item.data.title; const url = "https://www.reddit.com" + item.data.permalink; // Skip if we've already added this URL if (addedUrls.has(url)) { console.log(`Skipping duplicate thread: "${title}"`); continue; } if (title.toLowerCase().includes(albumName.toLowerCase())) { console.log(`Found ${threadType} thread (partial match): "${title}"`); return { title, url }; } } } console.log(`No matching ${threadType} thread found.`); return null; }; // Find the best thread for each type const freshAlbumThread = findBestThread(freshAlbumResults, "FRESH ALBUM"); // Add FRESH ALBUM thread if found if (freshAlbumThread) { relevantThreads.push(freshAlbumThread); addedUrls.add(freshAlbumThread.url); // Track the URL to avoid duplicates console.log(`Added FRESH ALBUM thread: "${freshAlbumThread.title}"`); } // Find ALBUM DISCUSSION thread const albumDiscussionThread = findBestThread(albumDiscussionResults, "ALBUM DISCUSSION"); // Add ALBUM DISCUSSION thread if found and not a duplicate if (albumDiscussionThread && !addedUrls.has(albumDiscussionThread.url)) { relevantThreads.push(albumDiscussionThread); addedUrls.add(albumDiscussionThread.url); console.log(`Added ALBUM DISCUSSION thread: "${albumDiscussionThread.title}"`); } else if (albumDiscussionThread) { console.log(`Skipping duplicate ALBUM DISCUSSION thread: "${albumDiscussionThread.title}"`); } console.log(`Found ${relevantThreads.length} unique relevant threads`); return relevantThreads; } /** * Fetches comments from a given Reddit thread URL using the .json endpoint. * Note: This uses GM_xmlhttpRequest for cross-origin requests in Userscripts. * * @param {string} threadUrl The URL of the Reddit thread. * @returns {Promise|null>} A promise that resolves with an array of comment data or null on error. */ function fetchRedditComments(threadUrl) { console.log(`[fetchRedditComments] Attempting to fetch comments for: ${threadUrl}`); return new Promise((resolve, reject) => { // Append .json to the thread URL to get the JSON data const jsonUrl = threadUrl.endsWith('.json') ? threadUrl : threadUrl + '.json'; console.log(`[fetchRedditComments] Requesting URL: ${jsonUrl}`); // Use GM_xmlhttpRequest for cross-origin requests GM_xmlhttpRequest({ method: "GET", url: jsonUrl, onload: function(response) { console.log(`[fetchRedditComments] Received response for ${jsonUrl}. Status: ${response.status}`); try { if (response.status === 200) { console.log(`[fetchRedditComments] Response Text for ${jsonUrl}: ${response.responseText.substring(0, 500)}...`); // Log beginning of response const data = JSON.parse(response.responseText); console.log("[fetchRedditComments] Successfully parsed JSON response."); // The JSON response for a thread includes two arrays: [submission, comments] // We need the comments array (index 1) if (data && data.length === 2 && data[1] && data[1].data && data[1].data.children) { console.log(`[fetchRedditComments] Found comment data. Number of top-level items: ${data[1].data.children.length}`); // Process the raw comment data to extract relevant info and handle replies const comments = processComments(data[1].data.children); console.log(`[fetchRedditComments] Processed comments. Total processed: ${comments.length}`); resolve(comments); } else { console.error("[fetchRedditComments] Unexpected Reddit JSON structure:", data); resolve(null); // Resolve with null for unexpected structure } } else { console.error("[fetchRedditComments] Error fetching Reddit comments:", response.status, response.statusText); resolve(null); // Resolve with null on HTTP error } } catch (e) { console.error("[fetchRedditComments] Error parsing Reddit comments JSON:", e); resolve(null); // Resolve with null on parsing error } }, onerror: function(error) { console.error("[fetchRedditComments] GM_xmlhttpRequest error fetching Reddit comments:", error); resolve(null); // Resolve with null on request error } }); }); } /** * Recursively processes raw Reddit comment data to extract relevant info and handle replies. * Filters out 'more' comments placeholders. * * @param {Array} rawComments The raw comment children array from Reddit API. * @returns {Array} An array of processed comment objects. */ function processComments(rawComments) { const processed = []; if (!rawComments || !Array.isArray(rawComments)) { return processed; } for (const item of rawComments) { // Skip 'more' comments placeholders if (item.kind === 'more') { continue; } // Ensure it's a comment and has the necessary data if (item.kind === 't1' && item.data) { const commentData = item.data; const processedComment = { author: commentData.author, text: commentData.body, score: commentData.score, created_utc: commentData.created_utc, replies: [] // Initialize replies array }; // Recursively process replies if they exist if (commentData.replies && commentData.replies.data && commentData.replies.data.children) { processedComment.replies = processComments(commentData.replies.data.children); } processed.push(processedComment); } } return processed; } // --- HTML Structures and Injection --- const REDDIT_COMMENTS_SECTION_HTML = `

Reddit Comments from r/indieheads

`; /** * Injects HTML content after the last paragraph in the article. * @param {string|HTMLElement} content The HTML string or HTMLElement to inject. */ function injectAfterLastParagraph(content) { // Find the article element const article = document.querySelector('article'); if (!article) { console.error('Article element not found for injection'); return; } // Find all paragraphs within the article, excluding those with class "disclaimer" const paragraphs = Array.from(article.querySelectorAll('p')).filter(p => !p.classList.contains('disclaimer') && !p.closest('.disclaimer') // Also exclude paragraphs inside elements with class "disclaimer" ); if (paragraphs.length === 0) { console.error('No valid paragraphs found in article for injection'); return; } // Get the last paragraph const lastParagraph = paragraphs[paragraphs.length - 1]; // Insert content after the last paragraph if (typeof content === 'string') { lastParagraph.insertAdjacentHTML('afterend', content); } else { lastParagraph.insertAdjacentElement('afterend', content); } } // Function to render comments to HTML (basic structure) function renderCommentsHtml(comments, level = 0) { let html = `
    `; if (!comments || comments.length === 0) { html += '
  • No comments found for this thread.
  • '; } else { // Filter out deleted comments const validComments = comments.filter(comment => comment.author !== "[deleted]" && comment.text !== "[deleted]" ); if (validComments.length === 0) { html += '
  • No valid comments found for this thread.
  • '; } else { validComments.forEach(comment => { html += `
  • `; // Add collapse button for top-level comments if (level === 0) { html += `
    ${comment.author} (${comment.score} points)
    `; } else { html += `
    ${comment.author} (${comment.score} points)
    `; } // Process comment text for special content let processedText = comment.text; // Process Giphy embeds first processedText = processedText.replace(/!\[gif\]\(giphy\|([a-zA-Z0-9]+)(?:\|downsized)?\)/g, (match, giphyId) => { return `
    `; }); // Process Reddit image links processedText = processedText.replace(/(https:\/\/preview\.redd\.it\/[a-zA-Z0-9]+\.(jpeg|jpg|png|gif)\?[^\s)]+)/g, (match, imageUrl) => { return `
    Reddit Image
    `; }); // Process Markdown image syntax for Reddit images processedText = processedText.replace(/!\[.*?\]\((https:\/\/preview\.redd\.it\/[a-zA-Z0-9]+\.(jpeg|jpg|png|gif)\?[^\s)]+)\)/g, (match, imageUrl) => { return `
    Reddit Image
    `; }); // Process basic Markdown formatting // Bold text processedText = processedText.replace(/\*\*([^*]+)\*\*/g, '$1'); // Italic text processedText = processedText.replace(/\*([^*]+)\*/g, '$1'); // Block quotes - simple implementation processedText = processedText.replace(/^(>|>)\s*(.*?)$/gm, '
    $2
    '); // Parse Markdown links processedText = processedText.replace(/\[([^\]]+)\]\(([^)]+)\)/g, (match, linkText, url) => { return `${linkText}`; }); // Parse plain URLs processedText = processedText.replace(/(?[\]()'"]+)(?![^<]*>)/g, (match, url) => { return `${url}`; }); // Handle line breaks - simple approach processedText = processedText.replace(/\n\n+/g, '

    '); processedText = processedText.replace(/\n/g, '
    '); // Wrap in paragraph tags if not already if (!processedText.startsWith('

    ') && !processedText.startsWith('') && !processedText.includes('

    ')) { processedText = `

    ${processedText}

    `; } html += `
    ${processedText}
    `; if (comment.replies && comment.replies.length > 0) { html += renderCommentsHtml(comment.replies, level + 1); } html += `
  • `; }); } } html += `
`; return html; } function setupCommentCollapse() { document.querySelectorAll('.comment-collapse-button').forEach(button => { button.addEventListener('click', function() { const commentLi = this.closest('.reddit-comment'); commentLi.classList.toggle('collapsed'); // Update button text if (commentLi.classList.contains('collapsed')) { this.textContent = '[+]'; } else { this.textContent = '[−]'; } }); }); } // --- CSS Styles --- function injectStyles() { const styles = ` @media (min-width: 2400px) { #main-content div[class^="GridWrapper"] { max-width: 2000px; } } .reddit-comments-section { margin-top: 30px; padding: 20px; border-top: 1px solid #ddd; font-family: inherit; } .reddit-comments-tabs { display: flex; flex-wrap: wrap; margin-bottom: 15px; } .reddit-tab-button { padding: 8px 12px; margin-right: 5px; margin-bottom: 5px; background: #f0f0f0; border: 1px solid #ccc; border-radius: 4px; cursor: pointer; } .reddit-tab-button:not(.active):hover { background: #f8f8f8; } .reddit-tab-button:hover, .reddit-tab-button:active, .reddit-tab-button:focus { text-decoration: none; } .reddit-tab-button.active { background: #e0e0e0; border-color: #aaa; font-weight: bold; } .reddit-comment-list { list-style-type: none; padding-left: 0; } .reddit-comment-list.level-0 { padding-left: 0; margin-left: 0; } .reddit-comment-list.level-1 { padding-left: 20px; border-left: 2px solid #eee; } .reddit-comment-list.level-2, .reddit-comment-list.level-3, .reddit-comment-list.level-4, .reddit-comment-list.level-5 { padding-left: 20px; border-left: 2px solid #f5f5f5; } .reddit-comment { margin-bottom: 15px; } .reddit-image-container { margin-top: 10px; } .comment-meta { font-size: .9em; margin-bottom: 5px; color: #666; } .comment-body { line-height: 1.5; } /* Markdown formatting styles */ .comment-body strong { font-weight: 700; } .comment-body em { font-style: italic; } .comment-body blockquote { border-left: 3px solid #c5c1ad; margin: 8px 0; padding: 0 8px 0 12px; color: #646464; background-color: #f8f9fa; } /* Paragraph styling */ .comment-body p { margin: .8em 0; } .comment-body p:first-child { margin-top: 0; } .comment-body p:last-child { margin-bottom: 0; } .comment-body blockquote p { margin: .4em 0; } .reddit-comment.collapsed .comment-body, .reddit-comment.collapsed .reddit-comment-list { display: none; } .reddit-comment.collapsed { opacity: 0.7; } .comment-collapse-button { background: none; border: none; color: #0079d3; cursor: pointer; font-size: 12px; margin-left: 5px; padding: 0; } .comment-collapse-button:hover { text-decoration: underline; } `; const styleElement = document.createElement('style'); styleElement.textContent = styles; document.head.appendChild(styleElement); } // --- Main Execution Logic --- async function init() { console.log("Pitchfork Reddit Comments Userscript started."); // Inject CSS styles injectStyles(); const artist = extractArtistName(); const album = extractAlbumName(); if (!artist || !album) { console.log("Could not extract artist or album name. Exiting."); return; } console.log(`Found Artist: ${artist}, Album: ${album}`); const queries = formatAlbumSearchQueries(artist, album); console.log(`Search queries:`, queries); // Make separate search requests for each query type const freshAlbumUrl = buildIndieHeadsSearchJsonUrl(queries.freshAlbumQuery); const albumDiscussionUrl = buildIndieHeadsSearchJsonUrl(queries.albumDiscussionQuery); console.log(`Fresh Album Search URL: ${freshAlbumUrl}`); console.log(`Album Discussion Search URL: ${albumDiscussionUrl}`); // Function to perform a search request const performSearch = (url) => { return new Promise((resolve, reject) => { GM_xmlhttpRequest({ method: "GET", url: url, onload: function(response) { try { console.log(`[Search Request] Received response. Status: ${response.status}`); if (response.status === 200) { const searchData = JSON.parse(response.responseText); console.log("[Search Request] Successfully parsed JSON response."); if (searchData && searchData.data && searchData.data.children) { resolve(searchData.data.children); } else { console.error("[Search Request] Unexpected Reddit search JSON structure:", searchData); resolve([]); } } else { console.error("[Search Request] Error fetching Reddit search results:", response.status, response.statusText); resolve([]); } } catch (e) { console.error("[Search Request] Error parsing Reddit search JSON:", e); resolve([]); } }, onerror: function(error) { console.error("[Search Request] GM_xmlhttpRequest error fetching Reddit search results:", error); resolve([]); } }); }); }; try { // Perform both searches in parallel const [freshAlbumResults, albumDiscussionResults] = await Promise.all([ performSearch(freshAlbumUrl), performSearch(albumDiscussionUrl) ]); // Identify relevant threads from both result sets const relevantThreads = identifyRelevantThreads( freshAlbumResults, albumDiscussionResults, typeof artist === 'string' ? artist : artist.join(' & '), album ); if (relevantThreads.length === 0) { console.log("No relevant Reddit threads found."); const noThreadsMessage = document.createElement('p'); noThreadsMessage.textContent = 'No relevant Reddit threads found for this review.'; noThreadsMessage.style.fontStyle = 'italic'; noThreadsMessage.style.marginTop = '20px'; // Add some spacing injectAfterLastParagraph(noThreadsMessage); return; } console.log(`Found ${relevantThreads.length} relevant thread(s):`, relevantThreads); // Inject the main comments section container injectAfterLastParagraph(REDDIT_COMMENTS_SECTION_HTML); const commentsSection = document.querySelector('.reddit-comments-section'); const tabsArea = commentsSection.querySelector('.reddit-comments-tabs'); const contentArea = commentsSection.querySelector('.reddit-comments-content'); // Fetch comments and build tabs/content for (let i = 0; i < relevantThreads.length; i++) { const thread = relevantThreads[i]; console.log(`Fetching comments for thread: ${thread.title} (${thread.url})`); const comments = await fetchRedditComments(thread.url); // Generate tab button const tabButton = document.createElement('button'); tabButton.classList.add('reddit-tab-button'); tabButton.textContent = thread.title + ' '; tabButton.setAttribute('data-thread-index', i); // Add a direct link icon that opens the Reddit thread in a new tab const linkIcon = document.createElement('a'); linkIcon.href = thread.url; linkIcon.target = '_blank'; linkIcon.rel = 'noopener noreferrer'; // Security best practice for target="_blank" linkIcon.innerHTML = '🔗'; linkIcon.title = 'Open Reddit thread in new tab'; linkIcon.style.fontSize = '0.8em'; linkIcon.style.opacity = '0.7'; linkIcon.style.textDecoration = 'none'; // Remove underline linkIcon.style.marginLeft = '5px'; tabButton.appendChild(linkIcon); tabsArea.appendChild(tabButton); // Generate comment content area const threadContent = document.createElement('div'); threadContent.classList.add('reddit-tab-content'); threadContent.setAttribute('data-thread-index', i); threadContent.style.display = 'none'; // Hide by default if (comments) { threadContent.innerHTML = renderCommentsHtml(comments); // Set up collapse functionality for this tab's comments setupCommentCollapse(); } else { threadContent.innerHTML = '

Could not load comments for this thread.

'; } contentArea.appendChild(threadContent); // Activate the first tab and content by default if (i === 0) { tabButton.classList.add('active'); threadContent.style.display = 'block'; } } // Add event listeners for tab switching const tabButtons = tabsArea.querySelectorAll('.reddit-tab-button'); const tabContents = contentArea.querySelectorAll('.reddit-tab-content'); tabButtons.forEach(button => { button.addEventListener('click', () => { const threadIndex = button.getAttribute('data-thread-index'); // Deactivate all tabs and hide all content tabButtons.forEach(btn => btn.classList.remove('active')); tabContents.forEach(content => content.style.display = 'none'); // Activate the clicked tab and show corresponding content button.classList.add('active'); const activeContent = document.querySelector(`.reddit-tab-content[data-thread-index="${threadIndex}"]`); activeContent.style.display = 'block'; // Re-initialize collapse functionality for the newly displayed tab setupCommentCollapse(); }); }); } catch (error) { console.error("Error during search process:", error); } } // Run the initialization function init(); })();