// ==UserScript==
// @name Pitchfork Reddit Comments
// @namespace http://tampermonkey.net/
// @version 1.2
// @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==
// TODO: not working: https://pitchfork.com/reviews/albums/jennifer-walton-daughters/
// Needs to take H1 album title for the match
(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() {
let albumElement = document.querySelector('h1[data-testid="ContentHeaderHed"]');
console.log(`Album element found: ${albumElement ? 'YES' : 'NO'}`);
// Fallback selector if the main one doesn't work
if (!albumElement) {
albumElement = document.querySelector('h1[class*="SplitScreenContentHeaderHed"]');
console.log(`Fallback album element found: ${albumElement ? 'YES' : 'NO'}`);
}
// Another fallback - any h1 with em tag
if (!albumElement) {
albumElement = document.querySelector('h1 em');
if (albumElement) {
albumElement = albumElement.closest('h1');
console.log(`Second fallback album element found: ${albumElement ? 'YES' : 'NO'}`);
}
}
if (!albumElement) {
return null;
}
console.log(`Album element HTML: ${albumElement.innerHTML}`);
// Check if album name is wrapped in <em> tags (common for album titles)
const emElement = albumElement.querySelector('em');
if (emElement) {
const albumName = emElement.textContent.trim();
console.log(`Album name from <em> tag: "${albumName}"`);
return albumName;
}
// Fallback to regular text content if no <em> tag found
const albumName = albumElement.textContent.trim();
console.log(`Album name from textContent: "${albumName}"`);
return albumName;
}
/**
* Extracts the Artist Name(s) from the Pitchfork page.
* @returns {string|string[]|null} The artist name(s) or null if not found.
*/
function extractArtistName() {
// Try multiple selector approaches to handle different class name patterns
let artistElements = document.querySelectorAll('ul[class*="SplitScreenContentHeaderArtistWrapper"] div[class*="SplitScreenContentHeaderArtist"]');
// Fallback selector if the first one doesn't work
if (!artistElements.length) {
artistElements = document.querySelectorAll('ul[class*="ArtistWrapper"] div[class*="Artist"]');
}
// Another fallback - look for any div inside ul that contains artist link
if (!artistElements.length) {
artistElements = document.querySelectorAll('ul a[href*="/artists/"] div');
}
console.log(`Artist elements found: ${artistElements.length}`);
if (!artistElements.length) {
return null;
}
const artists = Array.from(artistElements).map(el => {
const artistName = el.textContent.trim();
console.log(`Found artist element: "${artistName}"`);
return artistName;
});
console.log(`All artists found: ${JSON.stringify(artists)}`);
// Return a single string if only one artist, array if multiple
const result = artists.length === 1 ? artists[0] : artists;
console.log(`Final artist result: ${JSON.stringify(result)}`);
return result;
}
/**
* 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();
// Normalize artist name for comparison (handle both string and array)
const normalizedArtist = Array.isArray(artist) ? artist.join(' & ') : artist;
const isSelfTitled = normalizedArtist.toLowerCase() === albumName.toLowerCase();
// Fuzzy matching function for artist names to handle spelling variations
function fuzzyMatchArtist(title, artistName) {
// Exact match
if (title.includes(artistName)) {
return true;
}
// Handle common variations and spelling differences
const artistLower = artistName.toLowerCase();
const titleLower = title.toLowerCase();
// Remove common suffixes/prefixes for comparison
const cleanArtist = artistLower.replace(/^(the |a |an )/i, '');
const cleanTitle = titleLower.replace(/^(the |a |an )/i, '');
// Check if artist name is contained within title or vice versa
if (cleanTitle.includes(cleanArtist) || cleanArtist.includes(cleanTitle)) {
return true;
}
// Enhanced fuzzy matching with multiple strategies
// Strategy 1: Handle character substitutions and common misspellings
const substitutions = {
'c': ['ch', 'k'],
'k': ['c', 'ch'],
's': ['z', 'x'],
'z': ['s'],
'x': ['s', 'z']
};
// Try character substitutions
for (const [char, replacements] of Object.entries(substitutions)) {
for (const replacement of replacements) {
const substitutedArtist = artistLower.replace(new RegExp(char, 'g'), replacement);
if (cleanTitle.includes(substitutedArtist)) {
console.log(`Fuzzy matched "${artistName}" to "${substitutedArtist}" in title: "${title}"`);
return true;
}
}
}
// Strategy 2: Handle missing letters (e.g., "Daghters" → "Daughters")
function matchWithMissingLetters(text, pattern) {
// Split both into words for word-by-word comparison
const textWords = text.split(/\s+/);
const patternWords = pattern.split(/\s+/);
// If different number of words, try matching the whole strings
if (textWords.length !== patternWords.length) {
return matchSingleWordMissingLetters(text, pattern);
}
// Match each word pair
for (let i = 0; i < textWords.length; i++) {
if (!matchSingleWordMissingLetters(textWords[i], patternWords[i])) {
return false;
}
}
return true;
}
function matchSingleWordMissingLetters(text, pattern) {
// Allow up to 2 missing letters per word
const maxMissing = Math.min(2, Math.floor(pattern.length * 0.3));
let textIndex = 0;
let patternIndex = 0;
let missingCount = 0;
while (textIndex < text.length && patternIndex < pattern.length) {
if (text[textIndex] === pattern[patternIndex]) {
textIndex++;
patternIndex++;
} else {
// Try skipping a character in the pattern (missing letter)
if (missingCount < maxMissing && patternIndex + 1 < pattern.length &&
text[textIndex] === pattern[patternIndex + 1]) {
patternIndex += 2; // Skip the missing letter in pattern
textIndex++;
missingCount++;
} else {
return false;
}
}
}
// Check if we've matched most of both strings
const remainingPattern = pattern.length - patternIndex;
return remainingPattern <= maxMissing && textIndex === text.length;
}
// Strategy 3: Handle double letter variations (e.g., "begining" → "beginning")
function matchWithDoubleLetters(text, pattern) {
// Split both into words for word-by-word comparison
const textWords = text.split(/\s+/);
const patternWords = pattern.split(/\s+/);
// If different number of words, try matching the whole strings
if (textWords.length !== patternWords.length) {
return matchSingleWordDoubleLetters(text, pattern);
}
// Match each word pair
for (let i = 0; i < textWords.length; i++) {
if (!matchSingleWordDoubleLetters(textWords[i], patternWords[i])) {
return false;
}
}
return true;
}
function matchSingleWordDoubleLetters(text, pattern) {
// Normalize double letters: convert consecutive identical letters to single
function normalizeDoubleLetters(str) {
return str.replace(/(.)\1+/g, '$1');
}
const normalizedText = normalizeDoubleLetters(text);
const normalizedPattern = normalizeDoubleLetters(pattern);
// Check if normalized versions match
if (normalizedText === normalizedPattern) {
return true;
}
// Also try with one version having double letters and other single
// This handles cases like "begining" vs "beginning"
const textWithPotentialDoubles = text.replace(/([bcdfghjklmnpqrstvwxyz])/g, '$1$1');
const patternWithPotentialDoubles = pattern.replace(/([bcdfghjklmnpqrstvwxyz])/g, '$1$1');
return normalizedText === normalizeDoubleLetters(patternWithPotentialDoubles) ||
normalizeDoubleLetters(textWithPotentialDoubles) === normalizedPattern;
}
// Apply enhanced fuzzy matching strategies
// Check if title contains artist with missing letters
if (matchWithMissingLetters(cleanTitle, cleanArtist)) {
console.log(`Fuzzy matched "${artistName}" with missing letters in title: "${title}"`);
return true;
}
// Check if title contains artist with double letter variations
if (matchWithDoubleLetters(cleanTitle, cleanArtist)) {
console.log(`Fuzzy matched "${artistName}" with double letter variations in title: "${title}"`);
return true;
}
// Try the reverse: check if artist contains title variations
if (matchWithMissingLetters(cleanArtist, cleanTitle)) {
console.log(`Fuzzy matched "${artistName}" (reverse missing letters) in title: "${title}"`);
return true;
}
if (matchWithDoubleLetters(cleanArtist, cleanTitle)) {
console.log(`Fuzzy matched "${artistName}" (reverse double letters) in title: "${title}"`);
return true;
}
return false;
}
// 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.`);
// Debug: Log all thread titles for this search type
console.log(`All ${threadType} search results:`);
results.forEach((item, index) => {
if (item.kind === "t3" && item.data && item.data.title) {
console.log(` ${index + 1}. "${item.data.title}"`);
}
});
// 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;
}
const titleLower = title.toLowerCase();
const threadTypeLower = threadType.toLowerCase();
const albumNameLower = albumName.toLowerCase();
const artistLower = normalizedArtist.toLowerCase();
// Check if this is the right type of thread
// More flexible matching for thread types (handles brackets, case differences)
const isCorrectThreadType =
titleLower.includes(threadTypeLower) ||
titleLower.includes(threadTypeLower.replace(' ', '')) || // Remove spaces
titleLower.includes(`[${threadTypeLower}]`) || // Handle [THREAD TYPE] format
titleLower.includes(`${threadTypeLower}]`); // Handle [THREAD TYPE] without opening bracket
if (isCorrectThreadType) {
// For self-titled albums, ensure we're not matching just the artist name
if (isSelfTitled) {
// Look for patterns that indicate this is about the album, not just the artist
const albumIndicators = ['album', 'record', 'lp', 'release', 'debut', 'sophomore', 'self-titled'];
const hasAlbumIndicator = albumIndicators.some(indicator => titleLower.includes(indicator));
if (titleLower.includes(albumNameLower) &&
(hasAlbumIndicator || titleLower.includes(`${artistLower} - ${albumNameLower}`))) {
console.log(`Found ${threadType} thread (self-titled album): "${title}"`);
return { title, url };
}
} else {
// For non-self-titled albums, require BOTH artist and album name to be present
// Use fuzzy matching for artist name to handle spelling variations
const artistMatches = fuzzyMatchArtist(titleLower, artistLower);
if (titleLower.includes(albumNameLower) && artistMatches) {
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;
}
const titleLower = title.toLowerCase();
const albumNameLower = albumName.toLowerCase();
const artistLower = normalizedArtist.toLowerCase();
// For self-titled albums, apply additional filtering
if (isSelfTitled) {
const albumIndicators = ['album', 'record', 'lp', 'release', 'debut', 'sophomore', 'self-titled'];
const hasAlbumIndicator = albumIndicators.some(indicator => titleLower.includes(indicator));
if (titleLower.includes(albumNameLower) && hasAlbumIndicator) {
console.log(`Found ${threadType} thread (self-titled album, partial match): "${title}"`);
return { title, url };
}
} else {
// For non-self-titled albums, require BOTH artist and album name to be present
if (titleLower.includes(albumNameLower) && titleLower.includes(artistLower)) {
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 <hr> tag in the article and removes the <hr> tag.
* If no <hr> tag is found, injects before div.disclaimer.
* @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 <hr> tags within the article
const hrElements = Array.from(article.querySelectorAll('hr'));
if (hrElements.length > 0) {
// Get the last <hr> tag
const lastHr = hrElements[hrElements.length - 1];
// Insert content after the last <hr> tag
if (typeof content === 'string') {
lastHr.insertAdjacentHTML('afterend', content);
} else {
lastHr.insertAdjacentElement('afterend', content);
}
// Remove the <hr> tag after injecting our content
lastHr.remove();
} else {
// Fallback: find div.disclaimer and inject before it
const disclaimerDiv = article.querySelector('div.disclaimer');
if (disclaimerDiv) {
console.log('No <hr> found, injecting before div.disclaimer');
// Insert content before the disclaimer div
if (typeof content === 'string') {
disclaimerDiv.insertAdjacentHTML('beforebegin', content);
} else {
disclaimerDiv.insertAdjacentElement('beforebegin', content);
}
} else {
// Second fallback: find article with data-testid="ReviewPageArticle" and inject as last child
const reviewArticle = document.querySelector('article[data-testid="ReviewPageArticle"] div[class^="GridWrapper"]:last-child div.grid-layout__content div.article__body > div > p:last-child');
if (reviewArticle) {
console.log('No <hr> or div.disclaimer found, injecting as last child of ReviewPageArticle');
// Insert content as the last child of the review article
if (typeof content === 'string') {
reviewArticle.insertAdjacentHTML('beforeend', content);
} else {
reviewArticle.appendChild(content);
}
} else {
console.error('No <hr> tags, div.disclaimer, or article[data-testid="ReviewPageArticle"] found for injection');
}
}
}
}
// 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: 0;
border-top: 1px solid #ddd;
font-family: inherit;
}
.reddit-comments-tabs {
display: flex;
flex-wrap: wrap;
margin-bottom: 15px;
}
.reddit-tab-button {
padding: 6px 8px 6px 12px;
margin-right: 5px;
margin-bottom: 5px;
background: #f0f0f0;
border: 1px solid #ccc;
border-radius: 4px;
cursor: pointer;
font-size: 16px;
letter-spacing: 0;
}
.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: 10px;
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: 10px;
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;
}
div.comment-body > p {
padding: 0;
margin: 0 0 1.125rem !important;
}
/* 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();
console.log(`Extracted Artist: ${JSON.stringify(artist)}`);
console.log(`Extracted Album: ${JSON.stringify(album)}`);
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();
})();