Skip to content

Instantly share code, notes, and snippets.

@stugmi
Created December 21, 2025 14:51
Show Gist options
  • Select an option

  • Save stugmi/f9ec15b7b4603ba6af446ffc74c0ab55 to your computer and use it in GitHub Desktop.

Select an option

Save stugmi/f9ec15b7b4603ba6af446ffc74c0ab55 to your computer and use it in GitHub Desktop.
PHPBB script to load all posts and show replies for threads
// ==UserScript==
// @name PHPBB load all posts and show replies
// @namespace http://tampermonkey.net/
// @version 1.0
// @description Load multiple pages of posts on a single page with customizable posts per page
// @author h
// @match https://forums.blurbusters.com/viewtopic.php*
// @grant none
// ==/UserScript==
(function() {
'use strict';
const STORAGE_KEY = 'bb_posts_per_page';
const getSavedOption = () => localStorage.getItem(STORAGE_KEY) || 'all';
const saveOption = (value) => localStorage.setItem(STORAGE_KEY, value);
const baseUrl = window.location.origin;
const threadUrl = window.location.href.split('&start=')[0].split('?')[0];
const threadId = new URLSearchParams(window.location.search).get('t');
const postsPerPage = 10;
const getCurrentPage = () => {
const start = parseInt(new URLSearchParams(window.location.search).get('start')) || 0;
return Math.floor(start / postsPerPage) + 1;
};
const extractPosts = (doc) => {
return Array.from(doc.querySelectorAll('.post'));
};
const fetchPage = async (pageNum) => {
const start = (pageNum - 1) * postsPerPage;
const url = `${threadUrl}?t=${threadId}&start=${start}`;
console.log(`Fetching page ${pageNum}: ${url}`);
const response = await fetch(url);
const html = await response.text();
const parser = new DOMParser();
const doc = parser.parseFromString(html, 'text/html');
return { pageNum, posts: extractPosts(doc) };
};
const fixPostLinks = () => {
const postLinks = document.querySelectorAll('a[data-post-id]');
let fixed = 0;
postLinks.forEach(link => {
if (link.dataset.fixed) return;
const href = link.getAttribute('href');
const match = href.match(/#p(\d+)$/);
if (match) {
const postId = match[1];
const targetElement = document.getElementById(`p${postId}`);
if (targetElement) {
link.setAttribute('href', `#p${postId}`);
link.removeAttribute('onclick');
link.dataset.fixed = 'true';
link.addEventListener('click', (e) => {
e.preventDefault();
targetElement.scrollIntoView({ behavior: 'smooth', block: 'start' });
targetElement.style.boxShadow = '0px 0px 5px 2px #ab7e8e';
setTimeout(() => {
targetElement.style.boxShadow = '';
}, 1000);
});
fixed++;
}
}
});
console.log(`Fixed ${fixed} post reference links`);
};
const buildReplyMap = () => {
const replyMap = {}; // postId -> [array of post elements that directly reply to it]
document.querySelectorAll('.post').forEach(post => {
const postId = post.id.replace('p', '');
const content = post.querySelector('.content');
if (!content) return;
// Get only DIRECT quotes (first-level blockquotes, not nested ones)
const directQuotes = Array.from(content.querySelectorAll(':scope > blockquote'));
directQuotes.forEach(blockquote => {
// Find the cite link in this blockquote (but not in nested blockquotes)
const cite = blockquote.querySelector(':scope > div > cite');
if (!cite) return;
const quoteLink = cite.querySelector('a[data-post-id]');
if (!quoteLink) return;
const quotedPostId = quoteLink.dataset.postId;
if (!replyMap[quotedPostId]) {
replyMap[quotedPostId] = [];
}
// Add this post as a reply to the quoted post (avoid duplicates)
if (!replyMap[quotedPostId].includes(post)) {
replyMap[quotedPostId].push(post);
}
});
});
return replyMap;
};
const getPostPreviewHTML = (post, maxLen = 300) => {
const content = post.querySelector('.content');
if (!content) return 'No preview';
const clone = content.cloneNode(true);
clone.querySelectorAll('blockquote').forEach(b => b.remove());
let text = clone.textContent.trim().replace(/\s+/g, ' ');
if (text.length > maxLen) text = text.slice(0, maxLen) + '…';
return text;
};
const getPostPreviewNode = (post) => {
//const content = post.querySelector('.content');
//if (!content) return document.createTextNode('No preview');
const clone = post.cloneNode(true);
// remove nested quotes to avoid recursion spam
clone.querySelectorAll('blockquote').forEach(b => b.remove());
// limit preview height instead of text length
//clone.style.maxHeight = '200px';
//clone.style.overflow = 'hidden';
return clone;
};
const displayReplies = () => {
const replyMap = buildReplyMap();
document.querySelectorAll('.reply-indicator').forEach(el => el.remove());
document.querySelectorAll('.post').forEach(post => {
const postId = post.id.replace('p', '');
const replies = replyMap[postId];
if (!replies || replies.length === 0) return;
const replyDiv = document.createElement('div');
replyDiv.className = 'reply-indicator';
replyDiv.style.cssText = `
background: #f0f4f7;
border-left: 3px solid #105289;
padding: 8px 12px;
margin-top: 10px;
font-size: 11px;
border-radius: 0 4px 4px 0;
display: flex;
flex-wrap: wrap;
gap: 6px;
`;
const label = document.createElement('span');
label.textContent = `${replies.length} replies:`;
label.style.cssText = 'color:#536482;font-weight:bold;margin-right:6px;';
replyDiv.appendChild(label);
replies.forEach(replyPost => {
const replyPostId = replyPost.id.replace('p', '');
const link = document.createElement('a');
link.textContent = `>>${replyPostId}`;
link.href = `#p${replyPostId}`;
link.style.cssText = `
color:#105289;
cursor:pointer;
position:relative;
text-decoration:none;
`;
// tooltip preview
const tooltip = document.createElement('div');
tooltip.appendChild(getPostPreviewNode(document.getElementById(`p${replyPostId}`)));
tooltip.style.cssText = `
position: absolute;
left: -20vw;
min-width: 800px;
max-height: 600px;
overflow: auto;
background: rgb(255, 255, 255);
color: rgb(0, 0, 0);
border: 1px solid rgb(170, 170, 170);
padding: 8px;
font-size: 11px;
box-shadow: rgba(0, 0, 0, 0.25) 0px 2px 6px;
display: none;
z-index: 1000;
`;
link.appendChild(tooltip);
link.addEventListener('mouseenter', () => {
tooltip.style.display = 'block';
});
link.addEventListener('mouseleave', () => {
tooltip.style.display = 'none';
});
link.addEventListener('click', e => {
e.preventDefault();
replyPost.scrollIntoView({ behavior: 'smooth', block: 'start' });
replyPost.style.boxShadow = '0px 0px 5px 2px #ab7e8e';
setTimeout(() => replyPost.style.boxShadow = '', 1000);
});
replyDiv.appendChild(link);
});
const content = post.querySelector('.content');
if (content) content.after(replyDiv);
});
console.log('Reply indicators (post-id style) added');
};
const init = () => {
const currentPage = getCurrentPage();
const totalPosts = parseInt(document.querySelector('.pagination').firstChild.textContent.trim().split(" ")[0]);
const totalPages = parseInt(Array.from(document.querySelector('.pagination').querySelectorAll("ul > li > a")).at(-2).innerText);
console.log(`Current page: ${currentPage}, Total posts: ${totalPosts}, Total pages: ${totalPages}`);
const postContainer = document.querySelector('.post').parentElement;
const topic_title = document.querySelector('.topic-title');
if (topic_title && totalPages > 1) {
const loaderUI = document.createElement('div');
loaderUI.id = 'page-loader-ui';
loaderUI.style.cssText = 'display: inline-flex; gap: 8px; margin-left: 10px; font-size: 11px; float: right; flex-direction: row; align-items: center; justify-content: flex-end;';
const label = document.createElement('span');
label.textContent = 'Posts per page:';
label.style.color = '#536482';
const select = document.createElement('select');
select.id = 'pages-to-load';
select.style.cssText = 'padding: 2px 5px; border-radius: 4px; border: 1px solid #B4BAC0; background: #FAFAFA; cursor: pointer; font-size: 11px;';
const options = [
{ value: '10', text: '10' },
{ value: '25', text: '25' },
{ value: '50', text: '50' },
{ value: '100', text: '100' },
{ value: 'all', text: `All (${totalPosts})` }
];
const savedOption = getSavedOption();
options.forEach(opt => {
const option = document.createElement('option');
option.value = opt.value;
option.textContent = opt.text;
if (opt.value === savedOption) option.selected = true;
select.appendChild(option);
});
select.addEventListener('change', () => {
saveOption(select.value);
});
const loadBtn = document.createElement('button');
loadBtn.textContent = 'Load';
loadBtn.className = 'button button-secondary';
loadBtn.style.cssText = 'padding: 2px 8px;font-size: 11px;';
loaderUI.appendChild(label);
loaderUI.appendChild(select);
loaderUI.appendChild(loadBtn);
topic_title.appendChild(loaderUI);
loadBtn.addEventListener('click', async () => {
const value = select.value;
const postsToShow = value === 'all' ? totalPosts : parseInt(value);
const pagesNeeded = Math.ceil(postsToShow / postsPerPage);
const startPage = currentPage + 1;
const endPage = Math.min(pagesNeeded, totalPages);
const pageNumbers = [];
for (let p = startPage; p <= endPage && p <= totalPages; p++) {
pageNumbers.push(p);
}
if (pageNumbers.length === 0) {
console.log('No pages to load');
return;
}
loadBtn.disabled = true;
select.disabled = true;
loadBtn.textContent = 'Loading...';
console.log(`Loading pages ${startPage} to ${endPage}`);
document.querySelectorAll('.pagination').forEach(el => {
el.innerHTML = '<span style="color: #105289; font-weight: bold;">Loading posts...</span>';
});
const loadingDiv = document.createElement('div');
loadingDiv.id = 'loading-indicator';
loadingDiv.style.cssText = 'background: #e1ebf2; color: #105289; padding: 15px; margin: 10px 0; border-radius: 8px; text-align: center; font-weight: bold;';
loadingDiv.textContent = `Loading pages ${pageNumbers[0]}-${pageNumbers[pageNumbers.length - 1]}...`;
postContainer.appendChild(loadingDiv);
const placeholders = {};
for (const page of pageNumbers) {
const placeholder = document.createElement('div');
placeholder.id = `page-${page}-placeholder`;
placeholder.dataset.page = page;
postContainer.appendChild(placeholder);
placeholders[page] = placeholder;
}
const pagePromises = pageNumbers.map(page => fetchPage(page));
let loaded = 0;
await Promise.all(
pagePromises.map(p => p.then(result => {
loaded++;
loadingDiv.textContent = `Loading... ${loaded}/${pageNumbers.length} pages fetched`;
const placeholder = placeholders[result.pageNum];
const separator = document.createElement('div');
separator.style.cssText = 'background: linear-gradient(90deg, transparent, #ababab, transparent); height: 2px; margin: 20px 0;';
placeholder.appendChild(separator);
const pageLabel = document.createElement('div');
pageLabel.style.cssText = 'color: #929292; font-weight: bold; text-align: center; margin: 15px 0; font-size: 14px;';
pageLabel.textContent = `Page ${result.pageNum}`;
placeholder.appendChild(pageLabel);
result.posts.forEach((post, idx) => {
const clonedPost = post.cloneNode(true);
if (idx > 0) {
const hr = document.createElement('hr');
hr.className = 'divider';
placeholder.appendChild(hr);
}
placeholder.appendChild(clonedPost);
});
console.log(`Page ${result.pageNum} inserted (${result.posts.length} posts)`);
return result;
}))
);
loadingDiv.remove();
const loadedPosts = document.querySelectorAll('.post').length;
document.querySelectorAll('.pagination').forEach(el => {
// el.innerHTML = `<span style="color: #105289; font-weight: bold;">${loadedPosts} posts loaded</span>`;
el.innerHTML = ``;
});
loadBtn.textContent = 'Loaded';
// loaderUI.remove();
console.log(`Done! ${loadedPosts} total posts loaded`);
fixPostLinks();
displayReplies();
});
loadBtn.click();
}
};
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', init);
} else {
init();
}
})();
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment