// ==UserScript==
// @name 微博/X图片批量下载器
// @namespace http://tampermonkey.net/
// @version 1.4.1
// @description 一键下载微博和X帖子中的所有图片为原图
// @author gbandszxc
// @match https://weibo.com/*
// @match https://www.weibo.com/*
// @match https://weibo.com.cn/*
// @match https://s.weibo.com/*
// @match https://x.com/*
// @match https://www.x.com/*
// @match https://twitter.com/*
// @match https://www.twitter.com/*
// @connect *.sinaimg.cn
// @connect *.sina.cn
// @connect *.twimg.com
// @grant GM_download
// @grant GM_addStyle
// @grant GM_registerMenuCommand
// @grant GM_log
// @grant GM_addElement
// @supportURL https://github.com/gbandszxc/weibo-image-downloader/issues
// @license MIT
// @downloadURL https://update.greasyfork.icu/scripts/568598/%E5%BE%AE%E5%8D%9AX%E5%9B%BE%E7%89%87%E6%89%B9%E9%87%8F%E4%B8%8B%E8%BD%BD%E5%99%A8.user.js
// @updateURL https://update.greasyfork.icu/scripts/568598/%E5%BE%AE%E5%8D%9AX%E5%9B%BE%E7%89%87%E6%89%B9%E9%87%8F%E4%B8%8B%E8%BD%BD%E5%99%A8.meta.js
// ==/UserScript==
(() => {
// src/style.css
var style_default = '#weibo-img-toast {\n position: fixed;\n top: 20px;\n left: 50%;\n transform: translateX(-50%);\n background: rgba(0, 0, 0, 0.75);\n color: #fff;\n padding: 10px 20px;\n border-radius: 4px;\n font-size: 14px;\n z-index: 2147483647;\n display: flex;\n align-items: center;\n gap: 10px;\n font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;\n box-shadow: 0 2px 10px rgba(0, 0, 0, 0.2);\n}\n\n#weibo-img-toast .close-btn {\n font-size: 18px;\n cursor: pointer;\n opacity: 0.7;\n line-height: 1;\n}\n\n#weibo-img-toast .close-btn:hover {\n opacity: 1;\n}\n\n.weibo-img-select-overlay {\n position: fixed;\n inset: 0;\n background: rgba(15, 23, 42, 0.24);\n backdrop-filter: blur(10px);\n z-index: 2147483647;\n display: flex;\n align-items: center;\n justify-content: center;\n padding: 16px;\n box-sizing: border-box;\n}\n\n.weibo-img-select-modal {\n width: 100%;\n max-width: 448px;\n max-height: 80vh;\n background: linear-gradient(180deg, #fffdf9 0%, #ffffff 100%);\n border: 1px solid rgba(255, 130, 0, 0.12);\n border-radius: 16px;\n box-shadow: 0 24px 60px rgba(15, 23, 42, 0.18);\n display: flex;\n flex-direction: column;\n overflow: hidden;\n font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;\n}\n\n.weibo-img-select-header {\n padding: 16px 18px 14px;\n border-bottom: 1px solid rgba(255, 130, 0, 0.12);\n display: flex;\n align-items: center;\n justify-content: space-between;\n gap: 12px;\n}\n\n.weibo-img-select-header-title {\n font-size: 15px;\n font-weight: 600;\n color: #1f2937;\n}\n\n.weibo-img-select-toggle-btn {\n height: 30px;\n padding: 0 12px;\n border: 1px solid rgba(255, 130, 0, 0.18);\n border-radius: 999px;\n background: #fff7ed;\n color: #c2410c;\n font-size: 12px;\n font-weight: 600;\n display: inline-flex;\n align-items: center;\n justify-content: center;\n text-align: center;\n line-height: 1;\n cursor: pointer;\n box-sizing: border-box;\n white-space: nowrap;\n}\n\n.weibo-img-select-list {\n padding: 14px 18px 18px;\n overflow: auto;\n display: grid;\n grid-template-columns: repeat(auto-fill, minmax(92px, 1fr));\n gap: 12px;\n}\n\n.weibo-img-select-item {\n display: flex;\n align-items: center;\n gap: 8px;\n min-width: 0;\n padding: 10px 12px;\n border: 1px solid rgba(255, 130, 0, 0.14);\n border-radius: 12px;\n background: linear-gradient(180deg, #fffaf3 0%, #ffffff 100%);\n box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.8);\n user-select: none;\n font-size: 14px;\n color: #374151;\n transition: border-color 0.2s ease, box-shadow 0.2s ease, transform 0.2s ease;\n}\n\n.weibo-img-select-item:hover {\n border-color: rgba(255, 130, 0, 0.32);\n box-shadow: 0 8px 18px rgba(255, 130, 0, 0.1);\n transform: translateY(-1px);\n}\n\n.weibo-img-select-item input {\n flex: 0 0 auto;\n margin: 0;\n accent-color: #ff8200;\n}\n\n.weibo-img-select-item-text {\n min-width: 0;\n white-space: nowrap;\n overflow: hidden;\n text-overflow: ellipsis;\n font-weight: 600;\n letter-spacing: 0.01em;\n}\n\n.weibo-img-select-actions {\n padding: 14px 18px 18px;\n border-top: 1px solid rgba(255, 130, 0, 0.12);\n display: flex;\n justify-content: flex-end;\n gap: 8px;\n}\n\n.weibo-img-select-btn {\n min-width: 90px;\n height: 34px;\n padding: 0 14px;\n border-radius: 999px;\n border: 1px solid transparent;\n font-size: 13px;\n font-weight: 600;\n cursor: pointer;\n display: inline-flex;\n align-items: center;\n justify-content: center;\n text-align: center;\n line-height: 1;\n box-sizing: border-box;\n}\n\n.weibo-img-select-modal button {\n font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;\n vertical-align: middle;\n}\n\n.weibo-img-select-btn-cancel {\n background: #fff;\n border-color: rgba(148, 163, 184, 0.35);\n color: #475569;\n}\n\n.weibo-img-select-btn-confirm {\n background: linear-gradient(135deg, #ff8200 0%, #ff6a00 100%);\n color: #fff;\n box-shadow: 0 10px 22px rgba(255, 130, 0, 0.26);\n}\n\n.weibo-img-download-btn {\n display: inline-flex;\n align-items: center;\n justify-content: center;\n width: auto !important;\n height: 20px;\n padding: 0 6px !important;\n margin-left: 8px;\n background: #ff8200;\n color: white;\n border-radius: 3px;\n font-size: 11px;\n font-weight: bold;\n cursor: pointer;\n vertical-align: middle;\n white-space: nowrap;\n box-sizing: content-box;\n}\n\n.weibo-img-download-btn:hover {\n background: #ff6a00;\n}\n';
// src/config.js
var CONFIG = {
DELAY_MS: 300,
LONG_PRESS_MS: 500,
DEBUG: true
};
// src/platforms/weibo.js
function createWeiboPlatform({
windowRef,
fetchRef,
log,
getFileBasenameFromUrl,
getFileExtensionFromUrl
}) {
const imageSelectors = [
"img.woo-picture-img",
".picture img",
".m3 img",
'div[class^="m"] img'
];
const postSelectors = [
"article",
".vue-feed-item",
'div[action-type="feed_list_item"]'
];
const headerSelectors = [
'div[class*="_iconsPlus_"]',
'header > div > div[class*="_nick_"]',
"header > div > div.woo-box-flex",
".woo-nickname",
".name"
];
const weiboStatusCache = /* @__PURE__ */ new Map();
function isSearchPage() {
return windowRef.location.hostname === "s.weibo.com";
}
function isAvatarImage(url) {
if (!url) {
return false;
}
return url.includes("/crop.") || url.includes("/avatar") || url.includes("_cute") || url.includes("_online");
}
function getOriginalImageUrl(url) {
if (!url || typeof url !== "string") {
return null;
}
if (!url.includes("sinaimg.cn") && !url.includes("sina.cn")) {
return null;
}
if (isAvatarImage(url)) {
return null;
}
if (url.includes("/large/")) {
return url;
}
const sizePatterns = ["thumb180", "thumb300", "square", "bmiddle", "mw690", "mw1024", "orj360", "orj480", "webp720"];
for (const size of sizePatterns) {
if (url.includes(`/${size}/`)) {
return url.replace(`/${size}/`, "/large/");
}
}
const match = url.match(/(\.sinaimg\.cn\/)([a-z0-9]+\/)/);
if (match) {
return url.replace(match[2], "large/");
}
return url;
}
function getBestWeiboImageUrl(picInfo) {
if (!picInfo || typeof picInfo !== "object") {
return null;
}
const candidates = [
picInfo.largest && picInfo.largest.url,
picInfo.original && picInfo.original.url,
picInfo.large && picInfo.large.url,
picInfo.bmiddle && picInfo.bmiddle.url,
picInfo.thumbnail && picInfo.thumbnail.url
];
return candidates.find((url) => typeof url === "string" && url.length > 0) || null;
}
function getWeiboMixMediaItems(status) {
const mixMediaItems = status && status.mix_media_info && Array.isArray(status.mix_media_info.items) ? status.mix_media_info.items : [];
return mixMediaItems.map((item, index) => {
const data = item && item.data;
if (!data || typeof data !== "object") {
return null;
}
const mediaType = typeof item.type === "string" ? item.type.toLowerCase() : "";
const objectType = typeof data.object_type === "string" ? data.object_type.toLowerCase() : "";
if (mediaType === "video" || objectType === "video") {
return null;
}
const picInfo = data.pic_info || data;
const imageUrl = getBestWeiboImageUrl(picInfo) || getBestWeiboImageUrl({
largest: data.pic_info && data.pic_info.pic_big,
bmiddle: data.pic_info && data.pic_info.pic_middle,
thumbnail: data.pic_info && data.pic_info.pic_small
});
if (!imageUrl) {
return null;
}
return {
id: getFileBasenameFromUrl(imageUrl, `mix-${index + 1}`),
kind: "image",
label: `图片 ${index + 1}`,
imageUrl,
videoUrl: null,
imageExt: getFileExtensionFromUrl(imageUrl, ".jpg"),
videoExt: ".mov"
};
}).filter(Boolean);
}
function createWeiboMediaItem(picId, picInfo, index) {
if (!picInfo || typeof picInfo !== "object") {
return null;
}
const imageUrl = getBestWeiboImageUrl(picInfo);
if (!imageUrl) {
return null;
}
const mediaType = typeof picInfo.type === "string" ? picInfo.type.toLowerCase() : "pic";
const isLivePhoto = mediaType === "livephoto";
const isGif = mediaType === "gif" || getFileExtensionFromUrl(imageUrl, ".jpg") === ".gif";
const videoUrl = isLivePhoto && typeof picInfo.video === "string" ? picInfo.video : null;
const kind = isLivePhoto ? "livephoto" : isGif ? "gif" : "image";
const label = isLivePhoto ? `Live Photo ${index + 1}` : isGif ? `GIF ${index + 1}` : `图片 ${index + 1}`;
return {
id: picId,
kind,
label,
imageUrl,
videoUrl,
imageExt: getFileExtensionFromUrl(imageUrl, ".jpg"),
videoExt: getFileExtensionFromUrl(videoUrl, ".mov")
};
}
function getWeiboMediaSourceStatus(status) {
if (!status || typeof status !== "object") {
return null;
}
const hasPics = Array.isArray(status.pic_ids) && status.pic_ids.length > 0;
const hasMixMedia = status.mix_media_info && Array.isArray(status.mix_media_info.items) && status.mix_media_info.items.length > 0;
if (hasPics || hasMixMedia) {
return status;
}
if (status.retweeted_status) {
return getWeiboMediaSourceStatus(status.retweeted_status);
}
return status;
}
function getWeiboMediaItemsFromStatus(status) {
const mediaSourceStatus = getWeiboMediaSourceStatus(status);
if (!mediaSourceStatus || typeof mediaSourceStatus !== "object") {
return [];
}
const picIds = Array.isArray(mediaSourceStatus.pic_ids) ? mediaSourceStatus.pic_ids : [];
const picInfos = mediaSourceStatus.pic_infos || {};
const directMediaItems = picIds.map((picId, index) => createWeiboMediaItem(picId, picInfos[picId], index)).filter(Boolean);
if (directMediaItems.length > 0) {
return directMediaItems;
}
return getWeiboMixMediaItems(mediaSourceStatus);
}
async function fetchWeiboStatus(statusId) {
if (!statusId) {
return null;
}
if (weiboStatusCache.has(statusId)) {
return weiboStatusCache.get(statusId);
}
const request = (async () => {
const response = await fetchRef(`/ajax/statuses/show?id=${encodeURIComponent(statusId)}&locale=zh-CN&isGetLongText=true`, {
credentials: "same-origin",
headers: {
Accept: "application/json, text/plain, */*",
"X-Requested-With": "XMLHttpRequest"
}
});
if (!response.ok) {
throw new Error(`微博接口请求失败: ${response.status}`);
}
return response.json();
})();
weiboStatusCache.set(statusId, request);
try {
return await request;
} catch (error) {
weiboStatusCache.delete(statusId);
throw error;
}
}
async function getWeiboMediaItemsById(statusId) {
const status = await fetchWeiboStatus(statusId);
return getWeiboMediaItemsFromStatus(status);
}
function isVideoThumbnailImage(img) {
if (!img || typeof img.closest !== "function") {
return false;
}
const pictureMain = img.closest(".woo-picture-main");
if (!pictureMain || typeof pictureMain.querySelector !== "function") {
return false;
}
return !!pictureMain.querySelector('[class*="_videotime_"], [class*="_videobox_"], .woo-font--play');
}
function findImagesInPost(container) {
const images = [];
for (const selector of imageSelectors) {
try {
const elements = container.querySelectorAll(selector);
elements.forEach((img) => {
if (!img.src) {
return;
}
if (isAvatarImage(img.src)) {
return;
}
if (img.src.includes("default_avatar")) {
return;
}
if (img.src.includes("h5.sinaimg.cn")) {
return;
}
if (!img.src.includes("sinaimg") && !img.src.includes("sina.cn")) {
return;
}
if (isVideoThumbnailImage(img)) {
return;
}
images.push(img);
});
} catch (error) {
log("微博图片选择器执行失败:", error.message);
}
}
return images;
}
function selectPreferredMediaItems(fallbackItems, resolvedMediaItems, apiResolved) {
return apiResolved ? resolvedMediaItems : fallbackItems;
}
function getStatusLookupId(postContainer) {
const mid = postContainer.getAttribute("mid") || postContainer.getAttribute("data-mid");
if (mid) {
return mid;
}
const links = postContainer.querySelectorAll("a[href]");
for (const link of links) {
const href = link.href || "";
const match = href.match(/weibo\.com\/(?:u\/)?\d+\/([A-Za-z0-9]+)/);
if (match && !href.includes("/u/")) {
return match[1];
}
}
return null;
}
async function resolvePostMediaItems(postContainer, fallbackItems) {
const statusId = getStatusLookupId(postContainer);
if (!statusId) {
return fallbackItems;
}
try {
const mediaItems = await getWeiboMediaItemsById(statusId);
return selectPreferredMediaItems(fallbackItems, mediaItems, true);
} catch (error) {
log("微博接口解析失败,回退DOM:", error.message);
}
return selectPreferredMediaItems(fallbackItems, [], false);
}
function getPostSelectors() {
return postSelectors;
}
function shouldSkipPost() {
return false;
}
function getPostId(postContainer) {
return postContainer.getAttribute("mid") || postContainer.getAttribute("data-mid") || `weibo_${Date.now()}`;
}
function insertDownloadButton({ post, btn }) {
if (isSearchPage()) {
const infoEl = post.querySelector(".content .info");
if (infoEl) {
const nameDiv = Array.from(infoEl.children).find((el) => !el.classList.contains("menu"));
if (nameDiv) {
nameDiv.style.display = "flex";
nameDiv.style.alignItems = "center";
nameDiv.style.gap = "4px";
nameDiv.appendChild(btn);
return true;
}
}
}
const retweetSpan = Array.from(post.querySelectorAll("span")).find(
(el) => el.textContent.trim() === "转发微博"
);
if (retweetSpan && retweetSpan.parentNode) {
retweetSpan.parentNode.insertBefore(btn, retweetSpan.nextSibling);
return true;
}
const suffixBox = post.querySelector('div[class*="_suffixbox"]');
if (suffixBox) {
suffixBox.appendChild(btn);
return true;
}
const iconsPlusEl = post.querySelector('div[class*="_iconsPlus_"]');
if (iconsPlusEl && iconsPlusEl.parentNode) {
iconsPlusEl.parentNode.insertBefore(btn, iconsPlusEl);
return true;
}
for (const headerSelector of headerSelectors) {
const headerEl = post.querySelector(headerSelector);
if (headerEl) {
headerEl.appendChild(btn);
return true;
}
}
return false;
}
function getPostUrl(article) {
if (!article) {
return null;
}
const links = article.querySelectorAll("a[href]");
for (const link of links) {
const href = link.href;
if (/weibo\.com\/\d+\/\w+/.test(href) && !href.includes("/u/")) {
return href;
}
}
return null;
}
function injectGotoOriginalMenuItem(popMain, article, documentRef) {
if (!popMain || popMain.dataset.gotoInjected) {
return;
}
const postUrl = getPostUrl(article);
if (!postUrl) {
return;
}
const wrapMain = popMain.querySelector(".woo-pop-wrap-main");
if (!wrapMain) {
return;
}
const shareItem = wrapMain.firstElementChild;
if (!shareItem) {
return;
}
const item = documentRef.createElement("div");
item.setAttribute("role", "button");
item.className = "woo-box-flex woo-box-alignCenter woo-pop-item-main woo-pop-item-main";
item.innerHTML = '
';
item.addEventListener("click", (event) => {
event.preventDefault();
event.stopPropagation();
windowRef.open(postUrl, "_blank");
});
wrapMain.insertBefore(item, shareItem.nextSibling);
popMain.dataset.gotoInjected = "1";
}
function injectSearchPageGotoOriginal({ documentRef }) {
documentRef.querySelectorAll('div[action-type="feed_list_item"]').forEach((post) => {
const menuUl = post.querySelector('ul[node-type="fl_menu_right"]');
if (!menuUl || menuUl.dataset.gotoInjected) {
return;
}
const postUrl = getPostUrl(post);
if (!postUrl) {
return;
}
const li = documentRef.createElement("li");
const a = documentRef.createElement("a");
a.href = "javascript:void(0);";
a.textContent = "跳转原文";
a.addEventListener("click", (event) => {
event.preventDefault();
event.stopPropagation();
windowRef.open(postUrl, "_blank");
});
li.appendChild(a);
menuUl.insertBefore(li, menuUl.firstChild);
menuUl.dataset.gotoInjected = "1";
});
}
function afterInjectDownloadButtons({ documentRef }) {
if (isSearchPage()) {
injectSearchPageGotoOriginal({ documentRef });
}
}
function initObservers({ documentRef }) {
const MutationObserverRef = windowRef.MutationObserver || MutationObserver;
const menuObserver = new MutationObserverRef((mutations) => {
for (const mutation of mutations) {
for (const node of mutation.addedNodes) {
if (!(node instanceof Element)) {
continue;
}
if (node.classList.contains("woo-pop-main")) {
const article = node.closest("article");
if (article && node.textContent.includes("分享")) {
injectGotoOriginalMenuItem(node, article, documentRef);
}
}
}
}
});
menuObserver.observe(documentRef.body, { childList: true, subtree: true });
}
return {
id: "weibo",
displayName: "微博",
isSearchPage,
isAvatarImage,
getOriginalImageUrl,
getBestWeiboImageUrl,
getWeiboMixMediaItems,
createWeiboMediaItem,
getWeiboMediaSourceStatus,
getWeiboMediaItemsFromStatus,
fetchWeiboStatus,
getWeiboMediaItemsById,
isVideoThumbnailImage,
findImagesInPost,
selectPreferredMediaItems,
resolvePostMediaItems,
getPostSelectors,
shouldSkipPost,
getPostId,
insertDownloadButton,
getPostUrl,
injectGotoOriginalMenuItem,
afterInjectDownloadButtons,
initObservers
};
}
// src/platforms/x.js
function createXPlatform({ windowRef, log, getFileExtensionFromUrl }) {
const imageSelectors = ['article img[src*="twimg.com"]'];
const postSelectors = ['article[data-testid="tweet"]'];
function getOriginalImageUrl(url) {
if (!url || typeof url !== "string") {
return null;
}
if (!url.includes("pbs.twimg.com")) {
return null;
}
try {
let fullUrl = url;
if (!url.startsWith("http")) {
fullUrl = `https://${url}`;
}
const urlObj = new URL(fullUrl);
const name = urlObj.searchParams.get("name");
if (name === "orig") {
return fullUrl;
}
urlObj.searchParams.delete("name");
urlObj.searchParams.set("name", "orig");
return urlObj.toString();
} catch {
if (url.includes("name=orig")) {
return url;
}
return url.replace(/name=[^&]*/, "name=orig");
}
}
function isPhotoImageUrl(url) {
return typeof url === "string" && url.includes("pbs.twimg.com/media/");
}
function getVideoSourceUrl(video) {
if (!video) {
return null;
}
const directSrc = video.currentSrc || video.src || null;
if (typeof directSrc === "string" && directSrc.startsWith("http")) {
return directSrc;
}
if (typeof video.querySelectorAll === "function") {
const sources = video.querySelectorAll("source");
for (const source of sources) {
const src = source?.src;
if (typeof src === "string" && src.startsWith("http")) {
return src;
}
}
}
return null;
}
function getVideoMediaContainer(node) {
if (!node || typeof node.closest !== "function") {
return null;
}
return node.closest('[data-testid="videoComponent"]') || node.closest('[data-testid="videoPlayer"]') || node.closest("[data-testid]");
}
function isGifVideo(video) {
const container = getVideoMediaContainer(video);
const text = container?.textContent || "";
return /\bGIF\b/i.test(text);
}
function findImagesInPost(container) {
const images = [];
const seen = /* @__PURE__ */ new Set();
for (const selector of imageSelectors) {
try {
const elements = container.querySelectorAll(selector);
elements.forEach((img) => {
const src = img.src;
if (!src || typeof src !== "string") {
return;
}
if (!src.includes("pbs.twimg.com")) {
return;
}
if (!isPhotoImageUrl(src)) {
return;
}
const srcKey = src.split("?")[0];
if (seen.has(srcKey)) {
return;
}
seen.add(srcKey);
if (src.includes("/profile_images/")) {
return;
}
if (src.includes("/emoji/")) {
return;
}
const alt = img.alt || "";
if (alt.toLowerCase().includes("avatar") || alt.toLowerCase().includes("profile")) {
return;
}
images.push(img);
});
} catch (error) {
log("X 图片选择器执行失败:", error.message);
}
}
return images;
}
function createMediaItem({ id, kind, label, url, fallbackExt }) {
if (!url) {
return null;
}
return {
id,
kind,
label,
imageUrl: url,
videoUrl: null,
imageExt: getFileExtensionFromUrl(url, fallbackExt),
videoExt: ".mov"
};
}
function getDomMediaItems(container) {
const mediaItems = [];
const seen = /* @__PURE__ */ new Set();
const photoImages = findImagesInPost(container);
photoImages.forEach((img) => {
const url = getOriginalImageUrl(img.src);
if (!url) {
return;
}
const key = `photo:${url.split("?")[0]}`;
if (seen.has(key)) {
return;
}
seen.add(key);
const item = createMediaItem({
id: `dom-photo-${mediaItems.length + 1}`,
kind: "image",
label: `图片 ${mediaItems.length + 1}`,
url,
fallbackExt: ".jpg"
});
if (item) {
mediaItems.push(item);
}
});
if (typeof container.querySelectorAll !== "function") {
return mediaItems;
}
const videos = container.querySelectorAll("video");
videos.forEach((video) => {
if (!isGifVideo(video)) {
return;
}
const url = getVideoSourceUrl(video);
if (!url) {
return;
}
const key = `gif:${url.split("?")[0]}`;
if (seen.has(key)) {
return;
}
seen.add(key);
const gifCount = mediaItems.filter((item2) => item2.kind === "gif").length + 1;
const item = createMediaItem({
id: `dom-gif-${gifCount}`,
kind: "gif",
label: `GIF ${gifCount}`,
url,
fallbackExt: ".mp4"
});
if (item) {
mediaItems.push(item);
}
});
return mediaItems;
}
function resolvePostMediaItems(_postContainer, fallbackItems) {
return fallbackItems;
}
function getPostSelectors() {
return postSelectors;
}
function isMainTweet(container) {
const article = container.closest('article[data-testid="tweet"]');
if (!article) {
return false;
}
const timeEl = article.querySelector("time");
return timeEl !== null;
}
function shouldSkipPost(post) {
return !isMainTweet(post);
}
function getPostId(postContainer) {
let postId;
const timeEl = postContainer.querySelector("time");
if (timeEl && timeEl.parentElement) {
const linkEl = timeEl.parentElement.querySelector('a[href*="/status/"]');
if (linkEl) {
const match = linkEl.href.match(/\/status\/(\d+)/);
if (match) {
postId = match[1];
}
}
}
return postId || `x_${Date.now()}`;
}
function insertDownloadButton({ post, btn }) {
const usernameEl = post.querySelector('[data-testid="User-Name"]');
if (usernameEl) {
const parent = usernameEl.parentElement;
if (parent && parent.parentNode) {
parent.parentNode.insertBefore(btn, parent.nextSibling || null);
return true;
}
}
const timeEl = post.querySelector("time");
if (timeEl) {
const timeParent = timeEl.parentElement;
if (timeParent && timeParent.parentNode) {
const beforeNode = timeParent.nextElementSibling || timeParent.nextSibling || null;
timeParent.parentNode.insertBefore(btn, beforeNode);
return true;
}
}
const actionGroup = post.querySelector('[role="group"]');
if (actionGroup && actionGroup.parentElement) {
actionGroup.parentElement.insertBefore(btn, actionGroup);
return true;
}
return false;
}
return {
id: "x",
displayName: "X",
isSearchPage: () => false,
isAvatarImage: () => false,
getOriginalImageUrl,
getXOriginalImageUrl: getOriginalImageUrl,
getDomMediaItems,
findImagesInPost,
resolvePostMediaItems,
getPostSelectors,
shouldSkipPost,
getPostId,
insertDownloadButton,
afterInjectDownloadButtons() {
},
initObservers() {
}
};
}
// src/platforms/index.js
function createPlatformAdapter(deps) {
const hostname = deps.windowRef.location.hostname || "";
if (hostname.includes("x.com") || hostname.includes("twitter")) {
return createXPlatform(deps);
}
return createWeiboPlatform(deps);
}
// src/utils.js
function createUtils({ config, windowRef, fetchRef, gmDownload, ui: ui2 }) {
let platformLabel = "weibo";
function log(...args) {
if (config.DEBUG) {
console.log(`[${platformLabel} Downloader]`, ...args);
}
}
function getFileExtensionFromUrl(url, fallback = ".jpg") {
if (!url || typeof url !== "string") {
return fallback;
}
try {
const fullUrl = url.startsWith("http") ? url : `https://${url}`;
const pathname = new URL(fullUrl).pathname;
const match = pathname.match(/(\.[a-z0-9]+)$/i);
return match ? match[1].toLowerCase() : fallback;
} catch {
const cleanUrl = url.split("?")[0];
const match = cleanUrl.match(/(\.[a-z0-9]+)$/i);
return match ? match[1].toLowerCase() : fallback;
}
}
function getFileBasenameFromUrl(url, fallback) {
if (!url || typeof url !== "string") {
return fallback;
}
try {
const pathname = new URL(url.startsWith("http") ? url : `https://${url}`).pathname;
const parts = pathname.split("/");
const lastSegment = parts[parts.length - 1] || "";
return lastSegment.replace(/\.[^.]+$/, "") || fallback;
} catch {
const cleanUrl = url.split("?")[0];
const parts = cleanUrl.split("/");
const lastSegment = parts[parts.length - 1] || "";
return lastSegment.replace(/\.[^.]+$/, "") || fallback;
}
}
const platform = createPlatformAdapter({
config,
windowRef,
fetchRef,
log,
getFileBasenameFromUrl,
getFileExtensionFromUrl
});
platformLabel = platform.id;
function getCurrentPlatform() {
return platform.id;
}
function getCurrentPlatformDisplayName() {
return platform.displayName || platform.id;
}
function getOriginalImageUrl(url) {
return platform.getOriginalImageUrl(url);
}
function getFilename(postId, index) {
const platform2 = getCurrentPlatform();
return `${platform2}_${postId}_${index}.jpg`;
}
function buildMediaDownloadJobs(mediaItems, postId) {
const platform2 = getCurrentPlatform();
const jobs = [];
mediaItems.forEach((item, index) => {
if (!item || typeof item !== "object") {
return;
}
const baseName = `${platform2}_${postId}_${index + 1}`;
if (item.imageUrl) {
jobs.push({
type: "image",
url: item.imageUrl,
filename: `${baseName}${item.imageExt || getFileExtensionFromUrl(item.imageUrl, ".jpg")}`
});
}
if (item.videoUrl) {
jobs.push({
type: "video",
url: item.videoUrl,
filename: `${baseName}_live${item.videoExt || getFileExtensionFromUrl(item.videoUrl, ".mov")}`
});
}
});
return jobs;
}
function normalizeLegacyMediaItems(urls) {
return (urls || []).map((url, index) => ({
id: `legacy-${index + 1}`,
kind: "image",
label: `图片 ${index + 1}`,
imageUrl: url,
videoUrl: null,
imageExt: getFileExtensionFromUrl(url, ".jpg"),
videoExt: ".mov"
}));
}
async function downloadImage(url, filename) {
if (typeof gmDownload === "function") {
try {
const success = await new Promise((resolve) => {
try {
const downloadId = gmDownload({
url,
name: filename,
onload() {
resolve(true);
},
onerror(error) {
log(`GM_download失败: ${error.error || error.message || "未知错误"}`);
resolve(false);
},
onprogress: () => {
}
});
if (downloadId === false) {
log("GM_download返回false,尝试备用方案:", filename);
resolve(false);
}
} catch (error) {
log("GM_download异常:", error.message);
resolve(false);
}
});
if (success) {
return true;
}
log("GM_download失败,尝试备用方案");
} catch (error) {
log("GM_download异常,尝试备用方案:", error.message);
}
}
return downloadImageFallback(url, filename);
}
async function downloadImageFallback(url, filename) {
try {
const response = await fetchRef(url);
const blob = await response.blob();
const blobUrl = windowRef.URL.createObjectURL(blob);
const anchor = windowRef.document.createElement("a");
anchor.href = blobUrl;
anchor.download = filename;
anchor.click();
setTimeout(() => windowRef.URL.revokeObjectURL(blobUrl), 1e3);
return true;
} catch (error) {
log("fetch下载失败,改为新标签页打开:", filename, error?.message || "未知错误");
windowRef.open(url, "_blank");
return true;
}
}
async function downloadAllImages(urls, postId) {
return downloadMediaItems(normalizeLegacyMediaItems(urls), postId);
}
async function downloadMediaItems(mediaItems, postId) {
if (!mediaItems || mediaItems.length === 0) {
if (ui2 && typeof ui2.showToast === "function") {
ui2.showToast("未找到图片");
}
return;
}
const jobs = buildMediaDownloadJobs(mediaItems, postId);
if (jobs.length === 0) {
if (ui2 && typeof ui2.showToast === "function") {
ui2.showToast("未找到图片");
}
return;
}
log(`开始下载 ${mediaItems.length} 个媒体项,共 ${jobs.length} 个文件...`);
for (let i = 0; i < jobs.length; i++) {
const job = jobs[i];
await downloadImage(job.url, job.filename);
if (i < jobs.length - 1) {
await new Promise((resolve) => setTimeout(resolve, config.DELAY_MS));
}
}
const message = jobs.length === mediaItems.length ? `已下载 ${jobs.length} 张图片` : `已下载 ${mediaItems.length} 个媒体项,共 ${jobs.length} 个文件`;
log(message);
if (ui2 && typeof ui2.showToast === "function") {
ui2.showToast(message);
}
}
return {
getCurrentPlatform,
getCurrentPlatformDisplayName,
getPlatformAdapter: () => platform,
log,
getOriginalImageUrl,
getFileExtensionFromUrl,
getFileBasenameFromUrl,
getFilename,
buildMediaDownloadJobs,
downloadImage,
downloadImageFallback,
downloadAllImages,
downloadMediaItems
};
}
// src/ui.js
function createUi({ config, utils: utils2, windowRef, documentRef, addStyle }) {
const postMediaItemsCache = /* @__PURE__ */ new WeakMap();
const platform = utils2.getPlatformAdapter();
const resolvedMediaItemsCache = /* @__PURE__ */ new WeakMap();
function showToast(message, duration = 3e3) {
const existing = documentRef.getElementById("weibo-img-toast");
if (existing) {
existing.remove();
}
const toast = documentRef.createElement("div");
toast.id = "weibo-img-toast";
const closeBtn = documentRef.createElement("span");
closeBtn.textContent = "×";
closeBtn.className = "close-btn";
closeBtn.onclick = () => toast.remove();
const text = documentRef.createElement("span");
text.textContent = message;
toast.appendChild(text);
toast.appendChild(closeBtn);
documentRef.body.appendChild(toast);
setTimeout(() => {
if (toast.parentNode) {
toast.style.opacity = "0";
toast.style.transition = "opacity 0.3s";
setTimeout(() => toast.remove(), 300);
}
}, duration);
}
function findImagesInPost(container) {
return platform.findImagesInPost(container);
}
function isVideoThumbnailImage(img) {
if (typeof platform.isVideoThumbnailImage === "function") {
return platform.isVideoThumbnailImage(img);
}
return false;
}
function getFallbackMediaItems(container) {
if (typeof platform.getDomMediaItems === "function") {
const platformMediaItems = platform.getDomMediaItems(container);
if (Array.isArray(platformMediaItems)) {
return platformMediaItems;
}
}
const images = findImagesInPost(container);
const seen = /* @__PURE__ */ new Set();
const mediaItems = [];
images.forEach((img) => {
const url = utils2.getOriginalImageUrl(img.src);
if (!url) {
return;
}
const key = url.split("?")[0];
if (seen.has(key)) {
return;
}
seen.add(key);
mediaItems.push({
id: `dom-${mediaItems.length + 1}`,
kind: "image",
label: `图片 ${mediaItems.length + 1}`,
imageUrl: url,
videoUrl: null,
imageExt: utils2.getFileExtensionFromUrl(url, ".jpg"),
videoExt: ".mov"
});
});
return mediaItems;
}
function getImageUrls(container) {
return getFallbackMediaItems(container).map((item) => item.imageUrl);
}
function selectPreferredMediaItems(fallbackItems, resolvedMediaItems, apiResolved) {
if (typeof platform.selectPreferredMediaItems === "function") {
return platform.selectPreferredMediaItems(fallbackItems, resolvedMediaItems, apiResolved);
}
return fallbackItems;
}
function syncDownloadButtonState(btn, mediaItems) {
if (!btn) {
return;
}
if (!Array.isArray(mediaItems) || mediaItems.length === 0) {
btn.remove();
return;
}
btn.innerHTML = `↓${mediaItems.length}`;
}
async function resolvePostMediaItems(postContainer, initialFallbackItems = null) {
if (resolvedMediaItemsCache.has(postContainer)) {
return resolvedMediaItemsCache.get(postContainer);
}
const fallbackItems = initialFallbackItems || getFallbackMediaItems(postContainer);
const mediaPromise = Promise.resolve(platform.resolvePostMediaItems(postContainer, fallbackItems));
resolvedMediaItemsCache.set(postContainer, mediaPromise);
return mediaPromise;
}
function getPostId(postContainer) {
return platform.getPostId(postContainer);
}
function ensureImageSelectModalStyles() {
if (documentRef.getElementById("weibo-img-select-modal-style")) {
return;
}
if (typeof addStyle === "function") {
addStyle();
}
const marker = documentRef.createElement("style");
marker.id = "weibo-img-select-modal-style";
marker.textContent = "";
documentRef.head.appendChild(marker);
}
function getSelectionItemLabel(item, typeCounters) {
const normalizedKind = item && item.kind ? item.kind : item && item.videoUrl ? "livephoto" : "image";
const typeKey = normalizedKind === "livephoto" ? "livephoto" : normalizedKind === "gif" ? "gif" : "image";
const labelPrefixMap = {
image: "p",
livephoto: "lp",
gif: "a"
};
typeCounters[typeKey] = (typeCounters[typeKey] || 0) + 1;
return `${labelPrefixMap[typeKey]}${typeCounters[typeKey]}`;
}
function showImageSelectModal(mediaItems) {
return new Promise((resolve) => {
ensureImageSelectModalStyles();
const overlay = documentRef.createElement("div");
overlay.className = "weibo-img-select-overlay";
const modal = documentRef.createElement("div");
modal.className = "weibo-img-select-modal";
const header = documentRef.createElement("div");
header.className = "weibo-img-select-header";
const toggleAllBtn = documentRef.createElement("button");
toggleAllBtn.type = "button";
toggleAllBtn.className = "weibo-img-select-toggle-btn";
const headerTitle = documentRef.createElement("div");
headerTitle.className = "weibo-img-select-header-title";
headerTitle.textContent = `选择要下载的内容(共 ${mediaItems.length} 项)`;
header.appendChild(toggleAllBtn);
header.appendChild(headerTitle);
const list = documentRef.createElement("div");
list.className = "weibo-img-select-list";
const typeCounters = {};
mediaItems.forEach((item, index) => {
const label = documentRef.createElement("label");
label.className = "weibo-img-select-item";
const input = documentRef.createElement("input");
input.type = "checkbox";
input.checked = true;
input.value = String(index);
const text = documentRef.createElement("span");
text.className = "weibo-img-select-item-text";
text.textContent = getSelectionItemLabel(item, typeCounters);
label.title = item && item.label ? item.label : text.textContent;
label.appendChild(input);
label.appendChild(text);
list.appendChild(label);
});
const actions = documentRef.createElement("div");
actions.className = "weibo-img-select-actions";
const cancelBtn = documentRef.createElement("button");
cancelBtn.type = "button";
cancelBtn.className = "weibo-img-select-btn weibo-img-select-btn-cancel";
cancelBtn.textContent = "取消";
const confirmBtn = documentRef.createElement("button");
confirmBtn.type = "button";
confirmBtn.className = "weibo-img-select-btn weibo-img-select-btn-confirm";
confirmBtn.textContent = "下载所选";
actions.appendChild(cancelBtn);
actions.appendChild(confirmBtn);
const getInputs = () => Array.from(list.querySelectorAll('input[type="checkbox"]'));
const areAllChecked = () => {
const inputs = getInputs();
return inputs.length > 0 && inputs.every((input) => input.checked);
};
const updateToggleText = () => {
toggleAllBtn.textContent = areAllChecked() ? "全不选" : "全选";
};
getInputs().forEach((input) => {
input.addEventListener("change", updateToggleText);
});
toggleAllBtn.addEventListener("click", () => {
const shouldCheckAll = !areAllChecked();
getInputs().forEach((input) => {
input.checked = shouldCheckAll;
});
updateToggleText();
});
updateToggleText();
modal.appendChild(header);
modal.appendChild(list);
modal.appendChild(actions);
overlay.appendChild(modal);
const cleanup = () => {
documentRef.removeEventListener("keydown", onKeyDown);
overlay.removeEventListener("wheel", preventScroll, { passive: false });
overlay.removeEventListener("touchmove", preventScroll, { passive: false });
if (overlay.parentNode) {
overlay.parentNode.removeChild(overlay);
}
};
const closeWithResult = (result) => {
cleanup();
resolve(result);
};
const onKeyDown = (event) => {
if (event.key === "Escape") {
closeWithResult(null);
}
};
cancelBtn.addEventListener("click", () => closeWithResult(null));
confirmBtn.addEventListener("click", () => {
const selected = Array.from(
list.querySelectorAll('input[type="checkbox"]:checked')
).map((el) => Number(el.value));
if (selected.length === 0) {
showToast("请至少选择一项内容");
return;
}
const selectedItems = selected.map((index) => mediaItems[index]).filter(Boolean);
closeWithResult(selectedItems);
});
overlay.addEventListener("click", (event) => {
if (event.target === overlay) {
closeWithResult(null);
}
});
const preventScroll = (event) => {
event.preventDefault();
};
modal.addEventListener("click", (event) => {
event.stopPropagation();
});
overlay.addEventListener("wheel", preventScroll, { passive: false });
overlay.addEventListener("touchmove", preventScroll, { passive: false });
documentRef.addEventListener("keydown", onKeyDown);
documentRef.body.appendChild(overlay);
});
}
function createDownloadButton(postContainer) {
if (postContainer.querySelector(".weibo-img-download-btn")) {
return null;
}
const initialMediaItems = getFallbackMediaItems(postContainer);
if (initialMediaItems.length === 0) {
return null;
}
postMediaItemsCache.set(postContainer, initialMediaItems);
const btn = documentRef.createElement("span");
btn.className = "weibo-img-download-btn";
btn.innerHTML = `↓${initialMediaItems.length}`;
btn.title = "点击下载全部,长按选择下载;Live Photo 会同时下载 JPG 和 MOV";
resolvePostMediaItems(postContainer, initialMediaItems).then((mediaItems) => {
syncDownloadButtonState(btn, mediaItems);
});
let longPressTimer = null;
let longPressTriggered = false;
let suppressNextClick = false;
const clearLongPressTimer = () => {
if (longPressTimer) {
clearTimeout(longPressTimer);
longPressTimer = null;
}
};
const startDownload = async (targetMediaItems) => {
const postId = getPostId(postContainer);
await utils2.downloadMediaItems(targetMediaItems, postId);
};
btn.addEventListener("contextmenu", (event) => {
event.preventDefault();
event.stopPropagation();
});
btn.addEventListener("click", (event) => {
event.preventDefault();
event.stopPropagation();
if (suppressNextClick) {
suppressNextClick = false;
}
});
btn.addEventListener("pointerdown", (event) => {
if (event.pointerType === "mouse" && event.button !== 0) {
return;
}
event.preventDefault();
event.stopPropagation();
longPressTriggered = false;
clearLongPressTimer();
longPressTimer = setTimeout(async () => {
longPressTriggered = true;
suppressNextClick = true;
clearLongPressTimer();
const mediaItems = await resolvePostMediaItems(
postContainer,
postMediaItemsCache.get(postContainer) || initialMediaItems
);
const selectedMediaItems = await showImageSelectModal(mediaItems);
if (selectedMediaItems && selectedMediaItems.length > 0) {
startDownload(selectedMediaItems);
}
}, config.LONG_PRESS_MS);
});
btn.addEventListener("pointerup", async (event) => {
if (event.pointerType === "mouse" && event.button !== 0) {
return;
}
event.preventDefault();
event.stopPropagation();
const wasLongPress = longPressTriggered;
clearLongPressTimer();
longPressTriggered = false;
if (!wasLongPress) {
const mediaItems = await resolvePostMediaItems(
postContainer,
postMediaItemsCache.get(postContainer) || initialMediaItems
);
await startDownload(mediaItems);
}
});
btn.addEventListener("pointerleave", clearLongPressTimer);
btn.addEventListener("pointercancel", clearLongPressTimer);
return btn;
}
function injectDownloadButtons() {
let postsAdded = 0;
const selectors = platform.getPostSelectors();
for (const selector of selectors) {
const posts = documentRef.querySelectorAll(selector);
posts.forEach((post) => {
if (post.querySelector(".weibo-img-download-btn")) {
return;
}
if (platform.shouldSkipPost(post)) {
return;
}
const btn = createDownloadButton(post);
if (!btn) {
return;
}
const inserted = platform.insertDownloadButton({ post, btn, documentRef, windowRef });
if (!inserted) {
post.appendChild(btn);
}
postsAdded++;
});
}
if (typeof platform.afterInjectDownloadButtons === "function") {
platform.afterInjectDownloadButtons({ documentRef, windowRef });
}
}
function getPostUrl(article) {
if (typeof platform.getPostUrl === "function") {
return platform.getPostUrl(article);
}
return null;
}
function injectGotoOriginalMenuItem(popMain, article) {
if (typeof platform.injectGotoOriginalMenuItem === "function") {
platform.injectGotoOriginalMenuItem(popMain, article, documentRef);
}
}
function initPlatformObservers() {
if (typeof platform.initObservers === "function") {
platform.initObservers({ documentRef, windowRef });
}
}
return {
createDownloadButton,
ensureImageSelectModalStyles,
getPostUrl,
initPlatformObservers,
injectDownloadButtons,
injectGotoOriginalMenuItem,
isVideoThumbnailImage,
selectPreferredMediaItems,
showImageSelectModal,
showToast,
syncDownloadButtonState,
getImageUrls,
getWeiboPostUrl: getPostUrl,
initGotoOriginalMenuObserver: initPlatformObservers,
isWeiboVideoThumbnailImage: isVideoThumbnailImage,
selectPreferredWeiboMediaItems: selectPreferredMediaItems
};
}
// src/main.js
var styleId = "weibo-image-downloader-style";
function injectStyles() {
if (document.getElementById(styleId)) {
return;
}
if (typeof GM_addStyle === "function") {
GM_addStyle(style_default);
const marker = document.createElement("style");
marker.id = styleId;
marker.textContent = "";
document.head.appendChild(marker);
return;
}
const style = document.createElement("style");
style.id = styleId;
style.textContent = style_default;
document.head.appendChild(style);
}
var ui;
var utils = createUtils({
config: CONFIG,
windowRef: window,
fetchRef: window.fetch.bind(window),
gmDownload: typeof GM_download === "function" ? GM_download : null,
ui: {
showToast(message) {
if (ui) {
ui.showToast(message);
}
}
}
});
ui = createUi({
config: CONFIG,
utils,
windowRef: window,
documentRef: document,
addStyle: injectStyles
});
function init() {
injectStyles();
ui.ensureImageSelectModalStyles();
setTimeout(() => {
ui.injectDownloadButtons();
}, 2e3);
let injectTimer = null;
const observer = new MutationObserver(() => {
if (injectTimer) {
return;
}
injectTimer = setTimeout(() => {
injectTimer = null;
ui.injectDownloadButtons();
}, 300);
});
observer.observe(document.body, {
childList: true,
subtree: true
});
setInterval(() => {
ui.injectDownloadButtons();
}, 5e3);
ui.initPlatformObservers();
utils.log(`${utils.getCurrentPlatformDisplayName()}图片批量下载器初始化完成!`);
}
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", init);
} else {
init();
}
if (typeof GM_registerMenuCommand === "function") {
GM_registerMenuCommand("刷新按钮", () => {
ui.injectDownloadButtons();
});
}
})();