// ==UserScript==
// @name         2huai preview
// @namespace    http://tampermonkey.net/
// @version      0.0.1
// @description  Load catboxed images
// @author       Anonymous
// @match        https://boards.4chan.org/jp/thread/*
// @icon         https://www.google.com/s2/favicons?sz=64&domain=4chan.org
// @grant        none
// @run-at document-start
// ==/UserScript==

const blurNSFW = true;
const consoleLogging = true;

const log = (...args) => consoleLogging && console.log(...args);
const logError = (...args) => consoleLogging && console.error(...args);

const NSFW_KEYWORDS = ["pussy", "nsfw", "nude", "naked", "nipple", "breasts"];

const imageContainsKeywords = async (url, keywords) => {
    try {
        const response = await fetch(url);
        if (!response.ok) {
            return false;
        }

        const buffer = await response.arrayBuffer();
        const dataStr = new TextDecoder("utf-8").decode(buffer);

        return keywords.some((v) => dataStr.includes(v));
    } catch (err) {
        console.error("Error fetching or processing image:", err);
        return false;
    }
};

const preparePost = (link, i) => {
    const img = document.createElement("img");
    img.src = link;
    const imgContainer = document.createElement("div");
    imgContainer.appendChild(img);
    imgContainer.classList.add("imgContainer");
    imgContainer.classList.add(`i${i}`);
    imgContainer.tabIndex = "-1";

    return {imgContainer};
};

const prepareLinkElement = (linkText, i) => {
    const s = document.createElement("span");
    s.innerText = linkText;
    s.classList.add("fancyLink");
    s.classList.add(`i${i}`);

    return s;
};

const prepareContainer = () => {
    const container = document.createElement("div");
    container.classList.add("aiPreviewContainer");

    return container;
};

const processLink = (link, postElement, _linkElement, previewContainer, i) => {
    if (!link) {
        logError("link null", postElement);
        return;
    }

    const {imgContainer} = preparePost(link, i);

    previewContainer.appendChild(imgContainer);
};

const processPosts = (postv) => {
    log("Processing", postv.length, "posts:", postv);

    for (const post of postv) {
        if (!post.innerText.includes(".catbox.moe")) {
            continue;
        }

        const blockquote = post.querySelector(".postMessage");
        const matches = Array.from(
            blockquote.innerText.matchAll(/(?:files|litter)\.catbox\.moe\/.+?\.(?:jpg|jpeg|png|webp|avif)/g)
        ).flat();

        if (!matches.length) {
            continue;
        }

        const container = prepareContainer();

        let i = 0;
        for (let link of matches) {
            if (!link.startsWith("http")) {
                link = "https://" + link;
            }

            const linkElement = prepareLinkElement(link, i);

            blockquote.innerHTML = blockquote.innerHTML.replace(link, linkElement.outerHTML);

            processLink(link, post, linkElement, container, i);

            i++;
        }

        blockquote.appendChild(container);

        if (blurNSFW) {
            const spans = Array.from(blockquote.querySelectorAll(".fancyLink"));
            for (const span of spans) {
                imageContainsKeywords(span.innerText, NSFW_KEYWORDS).then((isNSFW) => {
                    if (isNSFW) {
                        const idx = spans.indexOf(span);
                        const imgContainer = container.querySelector(`.imgContainer.i${idx}`);
                        if (imgContainer) {
                            imgContainer.classList.add("nsfw");
                        }
                    }
                });
            }
        }
    }
};

{
    const style = document.createElement("style");
    style.innerHTML = `

${Array.from({length: 20})
    .map(
        (_, i) => `
        .postMessage:has(.fancyLink.i${i}:hover) .imgContainer.i${i}::before {
            opacity: 0.3;
        }

        .postMessage:has(.imgContainer.i${i}:hover) .fancyLink.i${i} {
            background-color: hsl(${(i * 137.5) % 360}, 80%, 70%, 0.5);
        }

        .fancyLink.i${i} { text-decoration-color: hsl(${(i * 137.5) % 360}, 80%, 70%); }
        `
    )
    .join("\n")}

.fancyLink {
    text-decoration: underline;
}

.aiPreviewContainer {
    display: flex;
    margin-top: 0.5rem;
    gap: 1rem;

    .imgContainer {
        height: 100px;
        position: relative;
    }

    .imgContainer img {
        width: auto;
        height: 100%;
        object-fit: contain;
        border-bottom: 2px solid var(--ai-highlight-color, transparent);
    }

    .imgContainer:focus {
        height: 400px;
    }

    .imgContainer::before {
        content: "";
        position: absolute;
        inset: 0;
        background-color: var(--ai-highlight-color, transparent);
        z-index: 1;
        pointer-events: none;
        opacity: 0;
    }

    ${Array.from({length: 20})
        .map(
            (_, i) => `
            .imgContainer.i${i} { --ai-highlight-color: hsl(${(i * 137.5) % 360}, 80%, 70%); }

            .postMessage:has(.fancyLink.i${i}:hover) .imgContainer.i${i}::before {
                opacity: 0.3;
            }
        `
        )
        .join("\n")}

    .imgContainer.nsfw {
        overflow: hidden;
    }

    .imgContainer.nsfw:not(:hover) img {
        filter: blur(8px);
    }
    .imgContainer.nsfw:hover img {
        filter: none;
    }
}
`;

    const run = () => {
        "use strict";

        globalThis.__2huai_loaded = true;

        const thread = document.querySelector(".thread");

        processPosts([...document.querySelectorAll(".postContainer")]);

        const observer = new MutationObserver((mutations) => {
            const newPosts = mutations
                .map((v) => [...v.addedNodes].filter((v) => v.classList?.contains("postContainer")))
                .flat();
            if (newPosts.length) {
                processPosts(newPosts);
            }
        });

        observer.observe(thread, {childList: true, subtree: true});
    };

    const init = () => {
        document.head.appendChild(style);
        run();
    };

    // this waits for either DOMContentLoaded or document.readyState === "complete" and runs the init function.
    Promise.race([
        new Promise((resolve, _) =>
            document.addEventListener("DOMContentLoaded", () => resolve("event"), {once: true})
        ),
        new Promise((resolve, _) => {
            const h = setInterval(() => {
                if (document.readyState === "complete") {
                    clearInterval(h);
                    resolve("poll");
                }
            }, 200);
        }),
    ]).then((reason) =>
        globalThis.__2huai_loaded
            ? log("already init image preview")
            : (init(), log("init image preview because of", reason))
    );
}
Edit

Pub: 11 Oct 2025 17:06 UTC

Views: 74