Threads Media Downloader (Optimized v2.1 + Custom Filename)

Download media with high performance + customizable filename pattern

当前为 2025-11-23 提交的版本,查看 最新版本

您需要先安装一个扩展,例如 篡改猴Greasemonkey暴力猴,之后才能安装此脚本。

您需要先安装一个扩展,例如 篡改猴暴力猴,之后才能安装此脚本。

您需要先安装一个扩展,例如 篡改猴暴力猴,之后才能安装此脚本。

您需要先安装一个扩展,例如 篡改猴Userscripts ,之后才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey,才能安装此脚本。

您需要先安装用户脚本管理器扩展后才能安装此脚本。

(我已经安装了用户脚本管理器,让我安装!)

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

(我已经安装了用户样式管理器,让我安装!)

// ==UserScript==
// @name         Threads Media Downloader (Optimized v2.1 + Custom Filename)
// @namespace    https://threads.com
// @version      2.1
// @license      MIT
// @description  Download media with high performance + customizable filename pattern
// @match        https://www.threads.net/*
// @match        https://www.threads.com/*
// @grant        GM_download
// ==/UserScript==

(() => {
    "use strict";

    // ⭐⭐⭐ 你可以在這裡自訂檔名格式 ⭐⭐⭐
    // 可使用變數:{username} {postId} {datetime} {index} {ext}
    const filenamePattern = "{username}_{postId}_{datetime}_{index}{ext}";

    const SEL_POST     = ".x78zum5.xdt5ytf";
    const SEL_BTN_WRAP = "div.x6s0dn4.xamitd3.x40hh3e.x78zum5.x1q0g3np.x1xdureb.x1fc57z9.x1hm9lzh.xvijh9v";
    const SEL_SCOPE    = ".x1xmf6yo";

    const download = (url, name) =>
        new Promise(res =>
            typeof GM_download === "function"
                ? GM_download({ url, name, onload: res, onerror: () => (fallback(url, name), res()) })
                : fallback(url, name).then(res)
        );

    const fallback = (url, name) => {
        const a = Object.assign(document.createElement("a"), { href: url, download: name });
        document.body.appendChild(a).click();
        a.remove();
        return Promise.resolve();
    };

    const getMeta = post => {
        const link = post.querySelector("a[href*='/post/'][role='link']");
        const time = post.querySelector("time[datetime]");
        if (!link || !time) return;

        const m = link.href.match(/\/@([^/]+)\/post\/([^/]+)/);
        if (!m) return;
        const [, username, postId] = m;

        const d = new Date(time.getAttribute("datetime"));
        const P = n => `${n}`.padStart(2, "0");
        const datetime =
            `${d.getUTCFullYear()}${P(d.getUTCMonth() + 1)}${P(d.getUTCDate())}_` +
            `${P(d.getUTCHours())}${P(d.getUTCMinutes())}${P(d.getUTCSeconds())}`;

        return { username, postId, datetime };
    };

    const formatFilename = (meta, index, ext) =>
        filenamePattern
            .replace("{username}", meta.username)
            .replace("{postId}", meta.postId)
            .replace("{datetime}", meta.datetime)
            .replace("{index}", index)
            .replace("{ext}", ext);

    const addButton = post => {
        const wrap = post.querySelector(SEL_BTN_WRAP);
        if (!wrap || wrap.__hasDownloadBtn) return;

        wrap.__hasDownloadBtn = true;

        const btn = document.createElement("button");
        btn.textContent = "下載";
        btn.style.cursor = "pointer";

        btn.onclick = async e => {
            e.stopPropagation();
            e.preventDefault();

            const meta = getMeta(post);
            if (!meta) return;

            const scope = post.querySelector(SEL_SCOPE);
            if (!scope) return;

            let i = 1;
            for (const media of scope.querySelectorAll("img, video")) {
                const url = media.src || media.poster;
                if (!url) continue;

                const ext = url.includes(".mp4") ? ".mp4" : ".jpg";
                const name = formatFilename(meta, i++, ext);

                await download(url, name);
            }
        };

        wrap.appendChild(btn);
    };

    const scan = () => document.querySelectorAll(SEL_POST).forEach(addButton);

    scan();

    let pending = false;
    new MutationObserver(() => {
        if (!pending) {
            pending = true;
            requestAnimationFrame(() => {
                scan();
                pending = false;
            });
        }
    }).observe(document.body, { childList: true, subtree: true });
})();