4chan Xt Minimal Post Archiver to Discord

Sends a minimal version of successful 4chan Xt posts to a Discord webhook.

// ==UserScript==
// @name         4chan Xt Minimal Post Archiver to Discord
// @namespace    http://tampermonkey.net/
// @version      0.7
// @description  Sends a minimal version of successful 4chan Xt posts to a Discord webhook.
// @author       wormpilled
// @match        *://boards.4chan.org/*
// @match        *://boards.4channel.org/*
// @grant        GM_xmlhttpRequest
// @grant        GM_log
// @connect      discord.com
// @connect      boards.4chan.org
// @connect      boards.4channel.org
// @connect      desuarchive.org
// @connect      archive.4plebs.org
// @run-at       document-start
// ==/UserScript==

(function() {
    'use strict';

    const DISCORD_WEBHOOK_URL = '';
    const logPrefix = '[4chanX MinArchiver]';
    const MAX_CONTENT_LENGTH = 1900; // Discord message limit is 2000, leave some room for header

    console.log(logPrefix, 'Script loaded. Listening for QRPostSuccessful event...');
    GM_log(logPrefix + ' Script loaded. Listening for QRPostSuccessful event...');

    function getArchiveUrl(boardID, threadID, postID) {
        let archiveBaseUrl = '';
        let postAnchor = `#q${postID}`;

        if (boardID === 'g') {
            archiveBaseUrl = `https://desuarchive.org/${boardID}/thread/${threadID}/`;
            postAnchor = `#${postID}`;
        } else if (['pol', 'tv'].includes(boardID)) {
            archiveBaseUrl = `https://archive.4plebs.org/${boardID}/thread/${threadID}/`;
        } else {
            return null;
        }
        return archiveBaseUrl + postAnchor;
    }

    function cleanHtmlForDiscord(htmlString) {
        if (typeof htmlString !== 'string' || !htmlString) return "";
        let text = htmlString;
        text = text.replace(/<br\s*\/?>/gi, '\n');
        text = text.replace(/<a[^>]*class="quotelink"[^>]*>(>>\d+)<\/a>/gi, '$1'); // Keeps >>12345
        text = text.replace(/<s[\s\S]*?>([\s\S]*?)<\/s>/gi, '||$1||'); // Spoilers
        const tempDiv = document.createElement('div');
        tempDiv.innerHTML = text;
        text = tempDiv.textContent || tempDiv.innerText || "";
        text = text.replace(/>/g, '>')
                   .replace(/</g, '<')
                   .replace(/&/g, '&')
                   .replace(/"/g, '"')
                   .replace(/'/g, "'");
        return text.trim();
    }

    function sendToDiscord(postContentHTML, boardID, threadID, postID, postUrl, archiveUrl, source) {
        GM_log(logPrefix + `Preparing minimal message. Content source: ${source}. Raw: ${String(postContentHTML).substring(0,50)}...`);
        let cleanedContent = cleanHtmlForDiscord(postContentHTML);

        // Construct the header line
        let headerLine = `**/${boardID}/** `;
        if (archiveUrl) {
            headerLine += `[Archive](${archiveUrl}) `;
        }
        headerLine += `[4chan](${postUrl})`;

        // Combine header and content
        let fullMessage = headerLine + '\n```\n' + (cleanedContent || "[No text content retrieved]") + '\n```';

        // Check total length and truncate content if necessary
        const headerLength = headerLine.length + 8; // 8 for \n```\n and \n```
        if (cleanedContent.length > (2000 - headerLength)) {
            const maxCleanedContentLength = 2000 - headerLength - 3; // -3 for "..."
            cleanedContent = cleanedContent.substring(0, maxCleanedContentLength) + "...";
            fullMessage = headerLine + '\n```\n' + cleanedContent + '\n```';
        }
        if (fullMessage.length > 2000) { // Final safeguard
            fullMessage = fullMessage.substring(0, 1997) + "...";
        }


        const payload = {
            content: fullMessage,
            // To prevent pinging @everyone or @here, if the content accidentally contains them
            allowed_mentions: {
                parse: [] // "users", "roles" if you want to allow those, but empty for none
            }
        };

        GM_log(logPrefix + ' Sending to Discord: ' + JSON.stringify(payload).substring(0, 300) + "...");

        GM_xmlhttpRequest({
            method: "POST",
            url: DISCORD_WEBHOOK_URL,
            headers: { "Content-Type": "application/json" },
            data: JSON.stringify(payload),
            onload: function(response) {
                if (response.status >= 200 && response.status < 300) {
                    console.log(logPrefix, 'Successfully sent minimal data to Discord webhook.');
                    GM_log(logPrefix + ' Successfully sent minimal data to Discord webhook. Status: ' + response.status);
                } else {
                    console.error(logPrefix, 'Error sending minimal data to Discord. Status:', response.status, response.statusText, response.responseText);
                    GM_log(logPrefix + ' Error sending minimal data to Discord. Status: ' + response.status + ' Response: ' + response.responseText);
                }
            },
            onerror: function(error) {
                console.error(logPrefix, 'Error with GM_xmlhttpRequest to Discord:', error);
                GM_log(logPrefix + ' Error with GM_xmlhttpRequest to Discord: ' + JSON.stringify(error));
            },
            ontimeout: function() {
                console.error(logPrefix, 'Request to Discord webhook timed out.');
                GM_log(logPrefix + ' Request to Discord webhook timed out.');
            }
        });
    }

    function fetchPostContentFromAPI(boardID, threadID, postID, postUrl, archiveUrl) {
        const currentHostname = window.location.hostname;
        const apiUrl = `https://${currentHostname}/${boardID}/thread/${threadID}.json`;
        GM_log(logPrefix + `Fetching post content from Board API: ${apiUrl} for post ${postID}`);

        GM_xmlhttpRequest({
            method: "GET",
            url: apiUrl,
            onload: function(response) {
                if (response.status === 200) {
                    try {
                        const threadData = JSON.parse(response.responseText);
                        const numericPostID = Number(postID);
                        const postObject = threadData.posts.find(p => p.no === numericPostID);

                        if (postObject && typeof postObject.com === 'string') {
                            GM_log(logPrefix + `Successfully fetched content for post ${numericPostID} via Board API.`);
                            sendToDiscord(postObject.com, boardID, threadID, numericPostID, postUrl, archiveUrl, "Board API");
                        } else if (postObject && !postObject.com) {
                             GM_log(logPrefix + `Post ${numericPostID} found via Board API but has no text comment (com field).`);
                            sendToDiscord("", boardID, threadID, numericPostID, postUrl, archiveUrl, "Board API (no text)"); // Send empty string for no content
                        } else {
                            GM_log(logPrefix + `Post ${numericPostID} not found in Board API response.`);
                            sendToDiscord(`[Content for post ${numericPostID} not found in Board API response]`, boardID, threadID, numericPostID, postUrl, archiveUrl, "Board API (not found)");
                        }
                    } catch (e) {
                        console.error(logPrefix, "Error parsing Board API response:", e);
                        GM_log(logPrefix + "Error parsing Board API response: " + e.message);
                        sendToDiscord("[Error parsing Board API response]", boardID, threadID, postID, postUrl, archiveUrl, "Board API (parse error)");
                    }
                } else {
                    console.error(logPrefix, `Board API request failed for ${threadID}. Status:`, response.status);
                    GM_log(logPrefix + `Board API request failed. Status: ${response.status}`);
                    sendToDiscord(`[Board API request failed: ${response.status}]`, boardID, threadID, postID, postUrl, archiveUrl, "Board API (request error)");
                }
            },
            onerror: function(error) {
                console.error(logPrefix, "Error with GM_xmlhttpRequest to Board API:", error);
                GM_log(logPrefix + "Error with GM_xmlhttpRequest to Board API: " + JSON.stringify(error));
                sendToDiscord("[Board API request network error]", boardID, threadID, postID, postUrl, archiveUrl, "Board API (network error)");
            },
            ontimeout: function() {
                console.error(logPrefix, "Request to Board API timed out.");
                GM_log(logPrefix + "Request to Board API timed out.");
                sendToDiscord("[Board API request timed out]", boardID, threadID, postID, postUrl, archiveUrl, "Board API (timeout)");
            }
        });
    }

    document.addEventListener('QRPostSuccessful', function(e) {
        GM_log(logPrefix + ' Event: QRPostSuccessful caught. Details: ' + JSON.stringify(e.detail));

        if (e.detail && e.detail.boardID && e.detail.threadID && e.detail.postID) {
            const boardID = String(e.detail.boardID);
            const threadID = String(e.detail.threadID);
            const postID = String(e.detail.postID); // Will be converted to Number where needed

            let postContentFromEvent = "";
            let source = "Event (unknown)";

            if (e.detail.context && e.detail.context.post && typeof e.detail.context.post.commentHTML === 'string') {
                postContentFromEvent = e.detail.context.post.commentHTML;
                source = "Event (post.commentHTML)";
            }
            else if (typeof e.detail.textPost === 'string' && e.detail.textPost.trim() !== "") {
                postContentFromEvent = e.detail.textPost;
                source = "Event (textPost raw)";
            } else if (typeof e.detail.comment === 'string' && e.detail.comment.trim() !== "") {
                postContentFromEvent = e.detail.comment;
                source = "Event (comment raw)";
            }

            const currentHostname = window.location.hostname;
            const postUrl = `https://${currentHostname}/${boardID}/thread/${threadID}#p${postID}`;
            const archiveUrl = getArchiveUrl(boardID, threadID, postID); // postID as string is fine here

            if (postContentFromEvent && postContentFromEvent.trim() !== "") {
                GM_log(logPrefix + `Content found in event detail (Source: ${source}). Using that.`);
                sendToDiscord(postContentFromEvent, boardID, threadID, Number(postID), postUrl, archiveUrl, source);
            } else {
                GM_log(logPrefix + "Content not found or empty in event. Attempting Board API fallback.");
                fetchPostContentFromAPI(boardID, threadID, Number(postID), postUrl, archiveUrl);
            }
        } else {
            GM_log(logPrefix + ' QRPostSuccessful event missing critical details.');
        }
    });

    document.addEventListener('4chanXInitFinished', function() {
        GM_log(logPrefix + ' 4chan X initialization finished.');
    });

})();