X/Twitter 推特原图下载

在 X 推文中添加“下载图片”按钮,下载 PNG 原图,可通过菜单选择命名规则

// ==UserScript==
// @name         X/Twitter 推特原图下载
// @namespace    http://tampermonkey.net/
// @version      2.2
// @description  在 X 推文中添加“下载图片”按钮,下载 PNG 原图,可通过菜单选择命名规则
// @author       ChatGPT
// @match        *://*.twitter.com/*
// @match        *://*.x.com/*
// @grant        GM_registerMenuCommand
// @grant        GM_unregisterMenuCommand
// @grant        GM_getValue
// @grant        GM_setValue
// @license      MIT
// @run-at       document-end
// ==/UserScript==

(function() {
    'use strict';
    const STYLE = { DATE_ONLY:1, DATE_HMS:2 };
    let namingStyle = GM_getValue('namingStyle', STYLE.DATE_ONLY);
    let menuIDs = [];

    function registerMenu() {
        // unregister old
        for (const id of menuIDs) {
            try { GM_unregisterMenuCommand(id); } catch(e) {}
        }
        menuIDs = [];
        // register date only
        const label1 = (namingStyle === STYLE.DATE_ONLY ? '✅ ' : '❌ ') + '命名:年月日';
        menuIDs.push(GM_registerMenuCommand(label1, () => {
            namingStyle = STYLE.DATE_ONLY;
            GM_setValue('namingStyle', namingStyle);
            console.log('命名规则切换为:年月日');
            registerMenu();
        }));
        // register date-hms
        const label2 = (namingStyle === STYLE.DATE_HMS ? '✅ ' : '❌ ') + '命名:年月日-时分秒';
        menuIDs.push(GM_registerMenuCommand(label2, () => {
            namingStyle = STYLE.DATE_HMS;
            GM_setValue('namingStyle', namingStyle);
            console.log('命名规则切换为:年月日-时分秒');
            registerMenu();
        }));
    }

    registerMenu();

    function formatDate(d) {
        const Y = d.getFullYear(), M = String(d.getMonth()+1).padStart(2,'0'), D = String(d.getDate()).padStart(2,'0');
        let s = `${Y}${M}${D}`;
        if (namingStyle === STYLE.DATE_HMS) {
            const h = String(d.getHours()).padStart(2,'0');
            const m = String(d.getMinutes()).padStart(2,'0');
            const sec = String(d.getSeconds()).padStart(2,'0');
            s += `-${h}${m}${sec}`;
        }
        return s;
    }

    async function download(url, filename) {
        const res = await fetch(url);
        if (!res.ok) return;
        const blob = await res.blob();
        const blobUrl = URL.createObjectURL(blob);
        const a = document.createElement('a'); a.href = blobUrl; a.download = filename; document.body.appendChild(a);
        a.click(); a.remove(); URL.revokeObjectURL(blobUrl);
    }

    function addButton(tweet) {
        if (tweet.querySelector('.download-images-btn')) return;
        const grp = tweet.querySelector('div[role="group"]'); if (!grp) return;
        const btn = document.createElement('button');
        btn.innerText = '下载图片';
        btn.className = 'download-images-btn';
        btn.style.cssText = 'margin-left:8px;cursor:pointer;background:#1da1f2;color:#fff;border:none;padding:4px 8px;border-radius:4px;font-size:12px;';
        btn.addEventListener('click', async e => {
            e.preventDefault(); e.stopPropagation();
            let name = 'unknown';
            const c = tweet.querySelector('[data-testid="User-Name"]');
            if (c) for (const div of c.querySelectorAll('div[dir="ltr"]')) {
                const t = div.textContent.trim(); if (t) { name = t; break; }
            }
            name = name.replace(/[\\/:*?"<>|]/g, '_');
            const timeEl = tweet.querySelector('time');
            const dt = timeEl ? new Date(timeEl.dateTime) : new Date();
            const ds = formatDate(dt);
            const imgs = tweet.querySelectorAll('img[src*="pbs.twimg.com/media"]');
            const urls = Array.from(imgs, img => img.src.split('?')[0] + '?name=large&format=png');
            if (!urls.length) return;
            const single = urls.length === 1;
            for (let i = 0; i < urls.length; i++) {
                const fn = single ? `${name}-${ds}.png` : `${name}-${ds}-${String(i+1).padStart(2,'0')}.png`;
                await download(urls[i], fn);
            }
        });
        grp.appendChild(btn);
    }

    const observer = new MutationObserver(muts => muts.forEach(m => m.addedNodes.forEach(n => {
        if (n.nodeType === 1) {
            if (n.matches('article[role="article"]')) addButton(n);
            else n.querySelectorAll('article[role="article"]').forEach(addButton);
        }
    })));
    observer.observe(document.body, { childList: true, subtree: true });
    document.querySelectorAll('article[role="article"]').forEach(addButton);
})();