Greasy Fork

8chan Lightweight Extended Suite

Spoiler revealer for 8chan with nested replies

目前为 2025-04-18 提交的版本。查看 最新版本

// ==UserScript==
// @name         8chan Lightweight Extended Suite
// @namespace    https://greasyfork.org/en/scripts/533173
// @version      2.1.1
// @description  Spoiler revealer for 8chan with nested replies
// @author       impregnator
// @match        https://8chan.moe/*
// @match        https://8chan.se/*
// @match        https://8chan.cc/*
// @grant        none
// @license      MIT
// ==/UserScript==

(function() {
    'use strict';

    // Function to process images and replace spoiler placeholders with thumbnails
    function processImages(images, isCatalog = false) {
        images.forEach(img => {
            // Check if the image is a spoiler placeholder (custom or default)
            if (img.src.includes('custom.spoiler') || img.src.includes('spoiler.png')) {
                let fullFileUrl;
                if (isCatalog) {
                    // Catalog: Get the href from the parent <a class="linkThumb">
                    const link = img.closest('a.linkThumb');
                    if (link) {
                        // Construct the thumbnail URL based on the thread URL
                        fullFileUrl = link.href;
                        const threadMatch = fullFileUrl.match(/\/([a-z0-9]+)\/res\/([0-9]+)\.html$/i);
                        if (threadMatch && threadMatch[1] && threadMatch[2]) {
                            const board = threadMatch[1];
                            const threadId = threadMatch[2];
                            // Fetch the thread page to find the actual image URL
                            fetchThreadImage(board, threadId).then(thumbnailUrl => {
                                if (thumbnailUrl) {
                                    img.src = thumbnailUrl;
                                }
                            });
                        }
                    }
                } else {
                    // Thread: Get the parent <a> element containing the full-sized file URL
                    const link = img.closest('a.imgLink');
                    if (link) {
                        // Extract the full-sized file URL
                        fullFileUrl = link.href;
                        // Extract the file hash (everything after /.media/ up to the extension)
                        const fileHash = fullFileUrl.match(/\/\.media\/([a-f0-9]+)\.[a-z0-9]+$/i);
                        if (fileHash && fileHash[1]) {
                            // Construct the thumbnail URL using the current domain
                            const thumbnailUrl = `${window.location.origin}/.media/t_${fileHash[1]}`;
                            // Replace the spoiler image with the thumbnail
                            img.src = thumbnailUrl;
                        }
                    }
                }
            }
        });
    }

    // Function to fetch the thread page and extract the thumbnail URL
    async function fetchThreadImage(board, threadId) {
        try {
            const response = await fetch(`https://${window.location.host}/${board}/res/${threadId}.html`);
            const text = await response.text();
            const parser = new DOMParser();
            const doc = parser.parseFromString(text, 'text/html');
            // Find the first image in the thread's OP post
            const imgLink = doc.querySelector('.uploadCell a.imgLink');
            if (imgLink) {
                const fullFileUrl = imgLink.href;
                const fileHash = fullFileUrl.match(/\/\.media\/([a-f0-9]+)\.[a-z0-9]+$/i);
                if (fileHash && fileHash[1]) {
                    return `${window.location.origin}/.media/t_${fileHash[1]}`;
                }
            }
            return null;
        } catch (error) {
            console.error('Error fetching thread image:', error);
            return null;
        }
    }

    // Process existing images on page load
    const isCatalogPage = window.location.pathname.includes('catalog.html');
    if (isCatalogPage) {
        const initialCatalogImages = document.querySelectorAll('.catalogCell a.linkThumb img');
        processImages(initialCatalogImages, true);
    } else {
        const initialThreadImages = document.querySelectorAll('.uploadCell img');
        processImages(initialThreadImages, false);
    }

    // Set up MutationObserver to handle dynamically added posts
    const observer = new MutationObserver(mutations => {
        mutations.forEach(mutation => {
            if (mutation.addedNodes.length) {
                // Check each added node for new images
                mutation.addedNodes.forEach(node => {
                    if (node.nodeType === Node.ELEMENT_NODE) {
                        if (isCatalogPage) {
                            const newCatalogImages = node.querySelectorAll('.catalogCell a.linkThumb img');
                            processImages(newCatalogImages, true);
                        } else {
                            const newThreadImages = node.querySelectorAll('.uploadCell img');
                            processImages(newThreadImages, false);
                        }
                    }
                });
            }
        });
    });

    // Observe changes to the document body, including child nodes and subtrees
    observer.observe(document.body, {
        childList: true,
        subtree: true
    });
})();

//Opening all posts from the catalog in a new tag section

// Add click event listener to catalog thumbnail images
document.addEventListener('click', function(e) {
    // Check if the clicked element is an image inside a catalog cell
    if (e.target.tagName === 'IMG' && e.target.closest('.catalogCell')) {
        // Find the parent link with class 'linkThumb'
        const link = e.target.closest('.linkThumb');
        if (link) {
            // Prevent default link behavior
            e.preventDefault();
            // Open the thread in a new tab
            window.open(link.href, '_blank');
        }
    }
});

//Automatically redirect to catalog section

// Redirect to catalog if on a board's main page, excluding overboard pages
(function() {
    const currentPath = window.location.pathname;
    // Check if the path matches a board's main page (e.g., /v/, /a/) but not overboard pages
    if (currentPath.match(/^\/[a-zA-Z0-9]+\/$/) && !currentPath.match(/^\/(sfw|overboard)\/$/)) {
        // Redirect to the catalog page
        window.location.replace(currentPath + 'catalog.html');
    }
})();

// Text spoiler revealer

(function() {
    // Function to reveal spoilers
    function revealSpoilers() {
        const spoilers = document.querySelectorAll('span.spoiler');
        spoilers.forEach(spoiler => {
            // Override default spoiler styles to make text visible
            spoiler.style.background = 'none';
            spoiler.style.color = 'inherit';
            spoiler.style.textShadow = 'none';
        });
    }

    // Run initially for existing spoilers
    revealSpoilers();

    // Set up MutationObserver to watch for new spoilers
    const observer = new MutationObserver((mutations) => {
        mutations.forEach(mutation => {
            if (mutation.addedNodes.length > 0) {
                // Check if new nodes contain spoilers
                mutation.addedNodes.forEach(node => {
                    if (node.nodeType === Node.ELEMENT_NODE) {
                        const newSpoilers = node.querySelectorAll('span.spoiler');
                        newSpoilers.forEach(spoiler => {
                            spoiler.style.background = 'none';
                            spoiler.style.color = 'inherit';
                            spoiler.style.textShadow = 'none';
                        });
                    }
                });
            }
        });
    });

    // Observe the document body for changes (new posts)
    observer.observe(document.body, {
        childList: true,
        subtree: true
    });
})();

//Inline reply chains

(function() {
    'use strict';

    console.log('Userscript is running');

    // Add CSS for visual nesting
    const style = document.createElement('style');
    style.innerHTML = `
        .inlineQuote .replyPreview {
            margin-left: 20px;
            border-left: 1px solid #ccc;
            padding-left: 10px;
        }
        .closeInline {
            color: #ff0000;
            cursor: pointer;
            margin-left: 5px;
            font-weight: bold;
        }
    `;
    document.head.appendChild(style);

    // Wait for tooltips to initialize
    window.addEventListener('load', function() {
        if (!window.tooltips) {
            console.error('tooltips module not found');
            return;
        }
        console.log('tooltips module found');

        // Ensure Inline Replies is enabled
        if (!tooltips.inlineReplies) {
            console.log('Enabling Inline Replies');
            localStorage.setItem('inlineReplies', 'true');
            tooltips.inlineReplies = true;

            // Check and update the checkbox, retrying if not yet loaded
            const enableCheckbox = () => {
                const inlineCheckbox = document.getElementById('settings-SW5saW5lIFJlcGxpZX');
                if (inlineCheckbox) {
                    inlineCheckbox.checked = true;
                    console.log('Inline Replies checkbox checked');
                    return true;
                }
                console.warn('Inline Replies checkbox not found, retrying...');
                return false;
            };

            // Try immediately
            if (!enableCheckbox()) {
                // Retry every 500ms up to 5 seconds
                let attempts = 0;
                const maxAttempts = 10;
                const interval = setInterval(() => {
                    if (enableCheckbox() || attempts >= maxAttempts) {
                        clearInterval(interval);
                        if (attempts >= maxAttempts) {
                            console.error('Failed to find Inline Replies checkbox after retries');
                        }
                    }
                    attempts++;
                }, 500);
            }
        } else {
            console.log('Inline Replies already enabled');
        }

        // Override addLoadedTooltip to ensure replyPreview exists
        const originalAddLoadedTooltip = tooltips.addLoadedTooltip;
        tooltips.addLoadedTooltip = function(htmlContents, tooltip, quoteUrl, replyId, isInline) {
            console.log('addLoadedTooltip called for:', quoteUrl);
            originalAddLoadedTooltip.apply(this, arguments);
            if (isInline) {
                let replyPreview = htmlContents.querySelector('.replyPreview');
                if (!replyPreview) {
                    replyPreview = document.createElement('div');
                    replyPreview.className = 'replyPreview';
                    htmlContents.appendChild(replyPreview);
                }
            }
        };

        // Override addInlineClick for nested replies, excluding post number links
        tooltips.addInlineClick = function(quote, innerPost, isBacklink, quoteTarget, sourceId) {
            // Skip post number links (href starts with #q)
            if (quote.href.includes('#q')) {
                console.log('Skipping post number link:', quote.href);
                return;
            }

            // Remove existing listeners by cloning
            const newQuote = quote.cloneNode(true);
            quote.parentNode.replaceChild(newQuote, quote);
            quote = newQuote;

            // Reapply hover events to preserve preview functionality
            tooltips.addHoverEvents(quote, innerPost, quoteTarget, sourceId);
            console.log('Hover events reapplied for:', quoteTarget.quoteUrl);

            // Add click handler
            quote.addEventListener('click', function(e) {
                console.log('linkQuote clicked:', quoteTarget.quoteUrl);
                if (!tooltips.inlineReplies) {
                    console.log('inlineReplies disabled');
                    return;
                }
                e.preventDefault();
                e.stopPropagation(); // Prevent site handlers

                // Find or create replyPreview
                let replyPreview = innerPost.querySelector('.replyPreview');
                if (!replyPreview) {
                    replyPreview = document.createElement('div');
                    replyPreview.className = 'replyPreview';
                    innerPost.appendChild(replyPreview);
                }

                // Check for duplicates or loading
                if (tooltips.loadingPreviews[quoteTarget.quoteUrl] ||
                    tooltips.quoteAlreadyAdded(quoteTarget.quoteUrl, innerPost)) {
                    console.log('Duplicate or loading:', quoteTarget.quoteUrl);
                    return;
                }

                // Create and load inline post
                const placeHolder = document.createElement('div');
                placeHolder.style.whiteSpace = 'normal';
                placeHolder.className = 'inlineQuote';
                tooltips.loadTooltip(placeHolder, quoteTarget.quoteUrl, sourceId, true);

                // Verify post loaded
                if (!placeHolder.querySelector('.linkSelf')) {
                    console.log('Failed to load post:', quoteTarget.quoteUrl);
                    return;
                }

                // Add close button
                const close = document.createElement('a');
                close.innerText = 'X';
                close.className = 'closeInline';
                close.onclick = () => placeHolder.remove();
                placeHolder.querySelector('.postInfo').prepend(close);

                // Process quotes in the new inline post
                Array.from(placeHolder.querySelectorAll('.linkQuote'))
                    .forEach(a => tooltips.processQuote(a, false, true));

                if (tooltips.bottomBacklinks) {
                    const alts = placeHolder.querySelector('.altBacklinks');
                    if (alts && alts.firstChild) {
                        Array.from(alts.firstChild.children)
                            .forEach(a => tooltips.processQuote(a, true));
                    }
                }

                // Append to replyPreview
                replyPreview.appendChild(placeHolder);
                console.log('Inline post appended:', quoteTarget.quoteUrl);

                tooltips.removeIfExists();
            }, true); // Use capture phase
        };

        // Reprocess all existing linkQuote and backlink elements, excluding post numbers
        console.log('Reprocessing linkQuote elements');
        const quotes = document.querySelectorAll('.linkQuote, .panelBacklinks a');
        quotes.forEach(quote => {
            const innerPost = quote.closest('.innerPost, .innerOP');
            if (!innerPost) {
                console.log('No innerPost found for quote:', quote.href);
                return;
            }

            // Skip post number links
            if (quote.href.includes('#q')) {
                console.log('Skipping post number link:', quote.href);
                return;
            }

            const isBacklink = quote.parentElement.classList.contains('panelBacklinks') ||
                               quote.parentElement.classList.contains('altBacklinks');
            const quoteTarget = api.parsePostLink(quote.href);
            const sourceId = api.parsePostLink(innerPost.querySelector('.linkSelf').href).post;

            tooltips.addInlineClick(quote, innerPost, isBacklink, quoteTarget, sourceId);
        });

        // Observe for dynamically added posts
        const observer = new MutationObserver(mutations => {
            mutations.forEach(mutation => {
                mutation.addedNodes.forEach(node => {
                    if (node.nodeType !== 1) return;
                    const newQuotes = node.querySelectorAll('.linkQuote, .panelBacklinks a');
                    newQuotes.forEach(quote => {
                        if (quote.dataset.processed || quote.href.includes('#q')) {
                            if (quote.href.includes('#q')) {
                                console.log('Skipping post number link:', quote.href);
                            }
                            return;
                        }
                        quote.dataset.processed = 'true';
                        const innerPost = quote.closest('.innerPost, .innerOP');
                        if (!innerPost) return;

                        const isBacklink = quote.parentElement.classList.contains('panelBacklinks') ||
                                           quote.parentElement.classList.contains('altBacklinks');
                        const quoteTarget = api.parsePostLink(quote.href);
                        const sourceId = api.parsePostLink(innerPost.querySelector('.linkSelf').href).post;

                        tooltips.addInlineClick(quote, innerPost, isBacklink, quoteTarget, sourceId);
                    });
                });
            });
        });
        observer.observe(document.querySelector('.divPosts') || document.body, {
            childList: true,
            subtree: true
        });
        console.log('MutationObserver set up');
    });
})();