在 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);
})();