Threads.net Media Downloader v5

Download images (原始副檔名) + videos from Threads posts, auto open spoiler, auto-like.

スクリプトをインストールするには、Tampermonkey, GreasemonkeyViolentmonkey のような拡張機能のインストールが必要です。

You will need to install an extension such as Tampermonkey to install this script.

スクリプトをインストールするには、TampermonkeyViolentmonkey のような拡張機能のインストールが必要です。

スクリプトをインストールするには、TampermonkeyUserscripts のような拡張機能のインストールが必要です。

このスクリプトをインストールするには、Tampermonkeyなどの拡張機能をインストールする必要があります。

このスクリプトをインストールするには、ユーザースクリプト管理ツールの拡張機能をインストールする必要があります。

(ユーザースクリプト管理ツールは設定済みなのでインストール!)

このスタイルをインストールするには、Stylusなどの拡張機能をインストールする必要があります。

このスタイルをインストールするには、Stylus などの拡張機能をインストールする必要があります。

このスタイルをインストールするには、Stylus tなどの拡張機能をインストールする必要があります。

このスタイルをインストールするには、ユーザースタイル管理用の拡張機能をインストールする必要があります。

このスタイルをインストールするには、ユーザースタイル管理用の拡張機能をインストールする必要があります。

このスタイルをインストールするには、ユーザースタイル管理用の拡張機能をインストールする必要があります。

(ユーザースタイル管理ツールは設定済みなのでインストール!)

このスクリプトの質問や評価の投稿はこちら通報はこちらへお寄せください
// ==UserScript==
// @name         Threads.net Media Downloader v5
// @namespace    http://tampermonkey.net/
// @version      5.0.0
// @license      MIT
// @description  Download images (原始副檔名) + videos from Threads posts, auto open spoiler, auto-like.
// @author       StevenJon0826
// @match        https://www.threads.net/*
// @match        https://www.threads.com/*
// @grant        GM_download
// ==/UserScript==

(function() {
    'use strict';

    // ⭐ 自動點開 Spoiler 遮罩
    const openSpoilers = () => {
        document.querySelectorAll(".x5yr21d.x1n2onr6.xh8yej3").forEach(el => {
            if (el.__openedSpoiler) return; // 避免重複點擊

            const text = el.textContent?.trim();
            if (text !== "劇透" && text !== "Spoiler") return;

            el.__openedSpoiler = true;
            el.click(); // 🔥 Threads 原生行為:click 即可解除 spoiler
        });
    };

    // ⭐ 從 srcset 選擇解析度最高的網址
    const pickBestFromSrcset = (srcset) => {
        if (!srcset) return null;
        return srcset
            .split(",")
            .map(s => s.trim())
            .map(entry => {
                const [url, descriptor] = entry.split(/\s+/);
                const num = descriptor?.endsWith("w")
                    ? parseInt(descriptor, 10)
                    : descriptor?.endsWith("x")
                        ? parseFloat(descriptor) * 1000 // 粗略轉成權重
                        : 0;
                return { url, weight: isNaN(num) ? 0 : num };
            })
            .filter(item => !!item.url)
            .sort((a, b) => b.weight - a.weight)[0]?.url || null;
    };

    // ⭐ 取出媒體網址(支援圖片與影片),盡量抓最高畫質
    const resolveMediaUrl = (node) => {
        if (!node) return null;
        if (node.tagName.toLowerCase() === "video") {
            // 影片優先抓 currentSrc,其次 src/source,再退而求其次使用封面
            const source = node.querySelector("source[src]");
            return node.currentSrc || node.src || source?.src || node.getAttribute("poster") || null;
        }

        // 圖片:優先從自身 srcset 取最大,其次從父層 <picture> 的 <source>,最後回退 src
        const srcsetBest = pickBestFromSrcset(node.srcset);
        if (srcsetBest) return srcsetBest;

        const picture = node.closest("picture");
        if (picture) {
            const sourceBest = Array.from(picture.querySelectorAll("source[srcset]"))
                .map(srcEl => pickBestFromSrcset(srcEl.srcset))
                .filter(Boolean)
                .sort((a, b) => b.length - a.length)[0]; // 粗略選擇較長網址(多為大圖)
            if (sourceBest) return sourceBest;
        }

        return node.src || null;
    };

    // ⭐ 根據網址抓副檔名,盡量維持原始檔案類型
    const pickExtension = (url, fallback) => {
        if (!url) return fallback;
        const cleanUrl = url.split("?")[0];
        const match = cleanUrl.match(/\.([a-zA-Z0-9]+)$/);
        return match ? `.${match[1]}` : fallback;
    };

    // ⭐ 生成安全的檔名片段,避免非法字元
    const safePart = (text, defaultVal = "Threads") =>
        (text || defaultVal).replace(/[\\/:*?"<>|]/g, "_").trim() || defaultVal;

    function addButtonToElement(element) {
        // 檢查該元素是否已經存在按鈕,避免重複加入
        if (!element.querySelector('button.my-custom-button')) {
            // 建立按鈕
            const button = document.createElement('button');
            button.textContent = 'Download';
            button.classList.add('my-custom-button');
            button.style.position = 'relative';
            // 當按鈕被點擊時,往上找兩層並統計<picture>元素內的<img>數量
            button.addEventListener('click', function(event) {
                // 阻止事件的預設行為和冒泡
                event.preventDefault();
                event.stopPropagation();

                // 往上找兩層 //x1s688f
                const grandparentElement = element.parentElement?.parentElement?.parentElement?.parentElement?.parentElement;
                if (grandparentElement) {
                    // 在祖先層級中尋找所有圖片與影片
                    const mediaNodes = grandparentElement.querySelectorAll('picture img, video');
                    // 找到 class 包含 x1s688f 的 <span> 並取得其文字內容
                    const spanElement = grandparentElement.querySelector('span[class*="x1s688f"]');
                    const spanText = spanElement ? spanElement.textContent : undefined; // 取得文字內容
                    const timeElement = grandparentElement.querySelector('time'); // 假設只有一個time元素
                    let formattedTime;
                    if (timeElement) {
                        const datetimeValue = timeElement.getAttribute('datetime'); // 取得datetime屬性
                        const dateObject = new Date(datetimeValue); // 將datetime轉換為Date物件

                        // 格式化日期為YYYYMMDD_hhmmss
                        const year = dateObject.getFullYear();
                        const month = String(dateObject.getMonth() + 1).padStart(2, '0'); // 月份從0開始,所以加1
                        const day = String(dateObject.getDate()).padStart(2, '0');
                        const hours = String(dateObject.getHours()).padStart(2, '0');
                        const minutes = String(dateObject.getMinutes()).padStart(2, '0');
                        const seconds = String(dateObject.getSeconds()).padStart(2, '0');

                        formattedTime = `${year}${month}${day}_${hours}${minutes}${seconds}`;
                    }
                    const safeSpan = safePart(spanText);

                    mediaNodes.forEach((node, index) => {
                        const mediaUrl = resolveMediaUrl(node);
                        if (!mediaUrl) return;

                        const isVideo = node.tagName.toLowerCase() === "video";
                        const ext = pickExtension(mediaUrl, isVideo ? ".mp4" : ".jpg");
                        const filename = `Threads-${safeSpan}-${formattedTime}-${index + 1}${ext}`;

                        // 使用 GM_download 支援跨來源下載
                        GM_download({
                            url: mediaUrl,
                            name: filename,
                        });
                    });

                    // 找到 aria-label 為 "讚" 或 "Like" 的按鈕(同時支援中文與英文)
                    const likeButton = grandparentElement.querySelector('[aria-label="讚"], [aria-label="Like"]');

                    if (likeButton) {
                        if (typeof likeButton.click === 'function') {
                            likeButton.click(); // 如果元素有 click 方法,模擬點擊
                        } else {
                            // 如果該元素是SVG,則創建一個事件手動觸發
                            const event = new MouseEvent('click', {
                                bubbles: true,
                                cancelable: true
                            });
                            likeButton.dispatchEvent(event); // 模擬點擊事件
                        }
                        console.log('Like button clicked');
                    } else {
                        console.log('No like button with aria-label "讚" found');
                    }

                } else {
                    console.log('Could not find grandparent element');
                }
            });
            // 將按鈕加入到該元素中
            element.appendChild(button);
        }
    }

    function scanForElements() {
        // 定期掃描符合條件的元素
        const elements = document.querySelectorAll('div[class*="x1fc57z9"]');
        elements.forEach(addButtonToElement);
    }

    // 使用setInterval每隔一段時間檢查畫面上是否有符合條件的元素
    setInterval(scanForElements, 500); // 每秒檢查一次
    setInterval(openSpoilers, 500); // 每秒檢查一次
})();