Created
December 21, 2025 14:51
-
-
Save stugmi/f9ec15b7b4603ba6af446ffc74c0ab55 to your computer and use it in GitHub Desktop.
PHPBB script to load all posts and show replies for threads
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| // ==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