您需要先安装一个扩展,例如 篡改猴、Greasemonkey 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 Userscripts ,之后才能安装此脚本。
您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey,才能安装此脚本。
您需要先安装用户脚本管理器扩展后才能安装此脚本。
Adds a download button to Reddit posts with images or videos.
当前为
- // ==UserScript==
- // @name Reddit Media Downloader
- // @namespace http://tampermonkey.net/
- // @version 0.84
- // @description Adds a download button to Reddit posts with images or videos.
- // @author Yukiteru
- // @match https://www.reddit.com/*
- // @grant GM_download
- // @grant GM_log
- // @license MIT
- // @run-at document-idle
- // ==/UserScript==
- (function () {
- "use strict";
- const PROCESSED_MARKER_CLASS = "rmd-processed";
- const BUTTON_CLASSES =
- "button border-md flex flex-row justify-center items-center h-xl font-semibold relative text-12 button-secondary inline-flex items-center px-sm";
- const BUTTON_SPAN_CLASSES = "flex items-center";
- // --- Helper Functions ---
- function sanitizeFilename(name) {
- // Remove invalid filename characters and replace sequences of whitespace/underscores with a single underscore
- return name
- .replace(/[<>:"/\\|?*\x00-\x1F]/g, "")
- .replace(/\s+/g, "_")
- .replace(/__+/g, "_")
- .substring(0, 150); // Limit length to avoid issues
- }
- function getOriginalImageUrl(previewUrl) {
- try {
- const url = new URL(previewUrl);
- const pathname = url.pathname;
- const lastHyphenIndex = pathname.lastIndexOf("-");
- const filename = pathname.slice(lastHyphenIndex + 1);
- return `https://i.redd.it/${filename}`;
- } catch (e) {
- GM_log(`Error during getOriginalImageUrl: ${e}`);
- }
- }
- function triggerDownload(url, filename) {
- GM_log(`Downloading: ${filename} from ${url}`);
- try {
- GM_download({
- url: url,
- name: filename,
- onerror: err => GM_log(`Download failed for ${filename}:`, err),
- // onload: () => GM_log(`Download started for ${filename}`), // Optional: Log success start
- // ontimeout: () => GM_log(`Download timed out for ${filename}`) // Optional: Log timeout
- });
- } catch (e) {
- GM_log(`GM_download error for ${filename}:`, e);
- }
- }
- // --- Core Logic ---
- function processPost(postElement) {
- if (!postElement || postElement.classList.contains(PROCESSED_MARKER_CLASS)) {
- GM_log("invalid element or already processed");
- return; // Already processed or invalid element
- }
- // Check for shadow root readiness - sometimes it takes a moment
- const buttonsContainer = postElement.shadowRoot?.querySelector(".shreddit-post-container");
- if (!buttonsContainer) {
- GM_log("Post shadowRoot not ready, will retry.");
- // Re-check shortly - avoids infinite loops if it never appears
- setTimeout(() => processPost(postElement), 250);
- return;
- }
- // Prevent adding multiple buttons if processing runs slightly delayed
- if (buttonsContainer.querySelector(".rmd-download-button")) {
- GM_log("Already processed, skipping");
- postElement.classList.add(PROCESSED_MARKER_CLASS); // Ensure marked
- return;
- }
- const postType = postElement.getAttribute("post-type");
- // GM_log(postType);
- let mediaUrls = [];
- // --- Media Detection ---
- switch (postType) {
- case "gallery":
- const galleryContainer = postElement.querySelector(
- 'shreddit-async-loader[bundlename="gallery_carousel"]'
- );
- const galleryClone = galleryContainer.querySelector("gallery-carousel").cloneNode(true);
- const imageContainers = galleryClone.querySelectorAll("ul > li");
- imageContainers.forEach(container => {
- const image = container.querySelector("img");
- const imageSrc = image.src || image.getAttribute("data-lazy-src");
- console.log(imageSrc);
- const originalUrl = getOriginalImageUrl(imageSrc);
- if (originalUrl) mediaUrls.push({ url: originalUrl, type: "image" });
- });
- break;
- case "image":
- const imageContainer = postElement.querySelector("shreddit-media-lightbox-listener");
- const img = imageContainer.querySelector('img[src^="https://preview.redd.it"]');
- if (img) {
- const originalUrl = getOriginalImageUrl(img.src);
- if (originalUrl) mediaUrls.push({ url: originalUrl, type: "image" });
- }
- break;
- case "video":
- const videoContainer = postElement.querySelector(
- 'shreddit-async-loader[bundlename="shreddit_player_2_loader"]'
- );
- const videoPlayer = videoContainer.querySelector("shreddit-player-2");
- // Need to wait for video player's shadow DOM and video tag if necessary
- const checkVideo = player => {
- if (!player.shadowRoot) {
- GM_log("Video player shadowRoot not ready, retrying...");
- setTimeout(() => checkVideo(player), 250);
- return;
- }
- const video = player.shadowRoot.querySelector("video");
- if (video && video.src) {
- // Prefer source tag if available and higher quality (heuristic)
- let bestSrc = video.src;
- const sources = player.shadowRoot.querySelectorAll("video > source[src]");
- if (sources.length > 0) {
- // Simple heuristic: assume last source might be better/direct mp4
- bestSrc = sources[sources.length - 1].src;
- }
- mediaUrls.push({ url: bestSrc, type: "video" });
- addDownloadButton(postElement, buttonsContainer, mediaUrls);
- } else if (video && !video.src) {
- GM_log("Video tag found but no src yet, retrying...");
- setTimeout(() => checkVideo(player), 500); // Wait longer for video src
- } else if (!video) {
- GM_log("Video tag not found in player shadowRoot yet, retrying...");
- setTimeout(() => checkVideo(player), 250);
- } else {
- // Video player exists but no media found after checks
- postElement.classList.add(PROCESSED_MARKER_CLASS);
- }
- };
- if (videoPlayer) {
- checkVideo(videoPlayer);
- // Button addition is handled inside checkVideo callback for videos
- return; // Stop further processing for this post until video is ready/checked
- }
- }
- // Add button immediately for images/galleries if URLs were found
- if (mediaUrls.length > 0 && (postType === "image" || postType === "gallery")) {
- addDownloadButton(postElement, buttonsContainer, mediaUrls);
- } else {
- // If no media found after checking all types, mark as processed
- postElement.classList.add(PROCESSED_MARKER_CLASS);
- }
- }
- function addDownloadButton(postElement, buttonsContainer, mediaUrls) {
- if (buttonsContainer.querySelector(".rmd-download-button")) return; // Double check
- // --- Get Title ---
- let title = "Reddit_Media"; // Default title
- // const article = postElement.closest("article");
- // const h1Title = document.querySelector("main h1"); // More specific for post pages
- const subredditName = postElement.getAttribute("subreddit-name");
- const postId = postElement.getAttribute("id").slice(3);
- const postTitle = postElement.getAttribute("post-title").slice(0, 20);
- title = `${subredditName}_${postId}_${postTitle.trim()}`;
- const cleanTitle = sanitizeFilename(title);
- // --- Create Button ---
- const downloadButton = document.createElement("button");
- downloadButton.className = `${BUTTON_CLASSES} rmd-download-button`; // Add our class
- downloadButton.setAttribute("name", "comments-action-button"); // Match existing buttons
- downloadButton.setAttribute("type", "button");
- const iconContainer = document.createElement("span");
- iconContainer.setAttribute("class", "flex text-16 mr-[var(--rem6)]");
- const buttonIcon = buttonsContainer
- .querySelector('svg[icon-name="downvote-outline"]')
- .cloneNode(true);
- iconContainer.appendChild(buttonIcon);
- downloadButton.appendChild(iconContainer);
- const buttonSpan = document.createElement("span");
- buttonSpan.className = BUTTON_SPAN_CLASSES;
- buttonSpan.textContent = "Download";
- downloadButton.appendChild(buttonSpan);
- // --- Add Click Listener ---
- downloadButton.addEventListener("click", event => {
- event.preventDefault();
- event.stopPropagation();
- mediaUrls.forEach((media, index) => {
- try {
- const url = media.url;
- // Skip blob URLs if we couldn't resolve them better earlier
- if (url.startsWith("blob:")) {
- GM_log(`Skipping download for unresolved blob URL: ${url} in post: ${cleanTitle}`);
- alert(
- `This video is in blob format which this script can't handle, try use a external method to download it.`
- );
- return;
- }
- const urlObj = new URL(url);
- let ext = urlObj.pathname.split(".").pop().toLowerCase();
- // Basic extension check/fix
- if (!ext || ext.length > 5) {
- // Basic check if extension extraction failed
- ext = media.type === "video" ? "mp4" : "jpg"; // Default extensions
- }
- // Refine extension for common image types if possible, keep original otherwise
- if (media.type === "image" && !["jpg", "jpeg", "png", "gif", "webp"].includes(ext)) {
- // Check original URL from preview if it exists
- const originalExtMatch = url.match(/\.(jpe?g|png|gif|webp)(?:[?#]|$)/i);
- if (originalExtMatch) ext = originalExtMatch[1].toLowerCase();
- else ext = "jpg"; // Fallback image extension
- } else if (media.type === "video" && !["mp4", "mov", "webm"].includes(ext)) {
- ext = "mp4"; // Fallback video extension
- }
- let filename = `Reddit_${cleanTitle}`;
- if (mediaUrls.length > 1) {
- filename += `_${index + 1}`;
- }
- filename += `.${ext}`;
- triggerDownload(url, filename);
- } catch (e) {
- GM_log(`Error during download preparation for ${media.url}:`, e);
- }
- });
- });
- // --- Append Button ---
- // Insert after the comments button if possible, otherwise just append
- const shareButton = buttonsContainer.querySelector("[name='share-button']");
- shareButton.insertAdjacentElement("afterend", downloadButton);
- // Mark as processed AFTER button is successfully added
- postElement.classList.add(PROCESSED_MARKER_CLASS);
- GM_log("Added download button to post:", cleanTitle);
- }
- // --- Observer ---
- function handleMutations(mutations) {
- GM_log("Handle Mutations");
- let postsToProcess = new Set();
- for (const mutation of mutations) {
- if (mutation.type === "childList" && mutation.addedNodes.length > 0) {
- for (const node of mutation.addedNodes) {
- if (node.nodeType === Node.ELEMENT_NODE) {
- // Case 1: A whole post element is added
- if (node.matches && node.matches("shreddit-post")) {
- postsToProcess.add(node);
- }
- // Case 2: Content *inside* a post is added (like async media)
- // Check if the added node is a media container itself
- else if (
- node.matches &&
- (node.matches('shreddit-async-loader[bundlename="post_detail_gallery"]') ||
- node.matches("gallery-carousel") ||
- node.matches("shreddit-media-lightbox-listener") ||
- node.matches("shreddit-player-2"))
- ) {
- const parentPost = node.closest("shreddit-post");
- if (parentPost) {
- postsToProcess.add(parentPost);
- }
- }
- // Case 3: Handle posts potentially nested within added nodes (e.g., inside articles in feeds)
- else if (node.querySelectorAll) {
- node.querySelectorAll("shreddit-post").forEach(post => postsToProcess.add(post));
- }
- }
- }
- }
- }
- // Process all unique posts found in this mutation batch
- postsToProcess.forEach(processPost);
- }
- function initObserver() {
- GM_log("Reddit Media Downloader initializing...");
- // Initial scan for posts already on the page
- document.querySelectorAll(`shreddit-post:not(.${PROCESSED_MARKER_CLASS})`).forEach(processPost);
- // Set up the observer
- const observer = new MutationObserver(handleMutations);
- const observerConfig = {
- childList: true,
- subtree: true,
- };
- // Observe the body, as posts can appear in feeds, main content, etc.
- observer.observe(document.body, observerConfig);
- GM_log("Reddit Media Downloader initialized and observing.");
- }
- initObserver();
- })();