Threads.net Media Downloader

Add download button for posts with images/videos on Threads.net

// ==UserScript==
// @name              Threads.net Media Downloader
// @namespace         https://www.youtube.com/channel/UC26YHf9ASpeu68az2xRKn1w
// @version           03-02-2025
// @description       Add download button for posts with images/videos on Threads.net
// @author            Kinnena
// @match             https://www.threads.net/*
// @icon              https://cdn-icons-png.flaticon.com/512/12105/12105338.png
// @grant             none
// @license MIT
// ==/UserScript==

(function() {
    'use strict';

    function addButtonToElement(element) {
        if (element.querySelector('button.my-custom-button')) return;

        const postContainer = element.closest('div[role="article"]') ||
            element.parentElement?.parentElement?.parentElement?.parentElement?.parentElement;

        if (!postContainer) return;

        const hasMedia = postContainer.querySelector('picture img, video');
        if (!hasMedia) return;

        const button = document.createElement('button');
        button.textContent = 'Download';
        button.className = 'my-custom-button';
        Object.assign(button.style, {
            position: 'relative',
            background: '#0095f6',
            color: 'white',
            border: 'none',
            borderRadius: '4px',
            padding: '6px 12px',
            margin: '4px',
            cursor: 'pointer'
        });

        button.addEventListener('click', function(event) {
            event.preventDefault();
            event.stopPropagation();

            // Get post metadata
            const spanElement = postContainer.querySelector('span[class*="x1s688f"]');
            const timeElement = postContainer.querySelector('time');
            const spanText = (spanElement?.textContent || 'unknown').replace(/[^\w]/g, '_').substring(0, 30);
            const datetime = timeElement?.getAttribute('datetime');

            // Format timestamp
            let formattedTime = '';
            if (datetime) {
                const date = new Date(datetime);
                formattedTime = [
                    date.getFullYear(),
                    String(date.getMonth() + 1).padStart(2, '0'),
                    String(date.getDate()).padStart(2, '0'),
                    '_',
                    String(date.getHours()).padStart(2, '0'),
                    String(date.getMinutes()).padStart(2, '0'),
                    String(date.getSeconds()).padStart(2, '0')
                ].join('');
            }

            // Collect media
            const mediaElements = [
                ...postContainer.querySelectorAll('picture img'),
                ...postContainer.querySelectorAll('video')
            ];

            mediaElements.forEach((media, index) => {
                let url, type;
                if (media.tagName === 'IMG') {
                    url = media.src;
                    type = 'image';
                } else {
                    url = media.src || media.querySelector('source')?.src;
                    type = 'video';
                }

                if (url) {
                    const extension = getFileExtension(url) || (type === 'image' ? 'jpg' : 'mp4');
                    const filename = `Threads_${spanText}_${formattedTime}_${index + 1}.${extension}`;
                    GM_download({
                        url: url,
                        name: filename,
                        onerror: (e) => console.error('Download error:', e)
                    });
                }
            });

            // Auto-like functionality
            const likeButton = postContainer.querySelector('[aria-label="讚"]');
            if (likeButton) {
                likeButton.click();
            }
        });

        element.appendChild(button);
    }

    function getFileExtension(url) {
        try {
            const cleanUrl = url.split(/[?#]/)[0];
            return cleanUrl.split('.').pop().toLowerCase();
        } catch {
            return null;
        }
    }

    function scanForButtons() {
        document.querySelectorAll('div[class*="x1fc57z9"]').forEach(addButtonToElement);
    }

    // Initial check
    scanForButtons();
    // Periodic check for new posts
    setInterval(scanForButtons, 1000);
})();