// ==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
// ==/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<Object>} freshAlbumResults The results from the FRESH ALBUM search.
* @param {Array<Object>} 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<Object>} 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<Array<Object>|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<Object>} rawComments The raw comment children array from Reddit API.
* @returns {Array<Object>} 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 = `
<div class="reddit-comments-section">
<h3>Reddit Comments from r/indieheads</h3>
<div class="reddit-comments-tabs">
<!-- Tab buttons will be added here -->
</div>
<div class="reddit-comments-content">
<!-- Comment content areas will be added here -->
<!-- Each area will have a data-thread-id or similar to link to the tab -->
</div>
</div>
`;
/**
* 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 = `<ul class="reddit-comment-list level-${level}">`;
if (!comments || comments.length === 0) {
html += '<li>No comments found for this thread.</li>';
} else {
// Filter out deleted comments
const validComments = comments.filter(comment =>
comment.author !== "[deleted]" && comment.text !== "[deleted]"
);
if (validComments.length === 0) {
html += '<li>No valid comments found for this thread.</li>';
} else {
validComments.forEach(comment => {
html += `<li class="reddit-comment">`;
// Add collapse button for top-level comments
if (level === 0) {
html += `<div class="comment-meta">
<b>${comment.author}</b> (${comment.score} points)
<button class="comment-collapse-button">[−]</button>
</div>`;
} else {
html += `<div class="comment-meta"><b>${comment.author}</b> (${comment.score} points)</div>`;
}
// 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 `
<div class="giphy-embed-container">
<iframe src="https://giphy.com/embed/${giphyId}"
width="480" height="270" frameBorder="0"
class="giphy-embed" allowFullScreen></iframe>
</div>
`;
});
// Process Reddit image links
processedText = processedText.replace(/(https:\/\/preview\.redd\.it\/[a-zA-Z0-9]+\.(jpeg|jpg|png|gif)\?[^\s)]+)/g, (match, imageUrl) => {
return `
<div class="reddit-image-container">
<img src="${imageUrl}" alt="Reddit Image" class="reddit-inline-image" />
</div>
`;
});
// 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 `
<div class="reddit-image-container">
<img src="${imageUrl}" alt="Reddit Image" class="reddit-inline-image" />
</div>
`;
});
// Process basic Markdown formatting
// Bold text
processedText = processedText.replace(/\*\*([^*]+)\*\*/g, '<strong>$1</strong>');
// Italic text
processedText = processedText.replace(/\*([^*]+)\*/g, '<em>$1</em>');
// Block quotes - simple implementation
processedText = processedText.replace(/^(>|>)\s*(.*?)$/gm, '<blockquote>$2</blockquote>');
// Parse Markdown links
processedText = processedText.replace(/\[([^\]]+)\]\(([^)]+)\)/g, (match, linkText, url) => {
return `<a href="${url}" target="_blank" rel="noopener noreferrer">${linkText}</a>`;
});
// Parse plain URLs
processedText = processedText.replace(/(?<!["\'])(https?:\/\/[^\s<>[\]()'"]+)(?![^<]*>)/g, (match, url) => {
return `<a href="${url}" target="_blank" rel="noopener noreferrer">${url}</a>`;
});
// Handle line breaks - simple approach
processedText = processedText.replace(/\n\n+/g, '</p><p>');
processedText = processedText.replace(/\n/g, '<br>');
// Wrap in paragraph tags if not already
if (!processedText.startsWith('<p>') &&
!processedText.startsWith('<div') &&
!processedText.startsWith('<blockquote') &&
!processedText.includes('<div class="giphy-embed-container">') &&
!processedText.includes('<div class="reddit-image-container">')) {
processedText = `<p>${processedText}</p>`;
}
html += `<div class="comment-body">${processedText}</div>`;
if (comment.replies && comment.replies.length > 0) {
html += renderCommentsHtml(comment.replies, level + 1);
}
html += `</li>`;
});
}
}
html += `</ul>`;
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 = '<p>Could not load comments for this thread.</p>';
}
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();
})();