Adds a download button to Bluesky images and videos. Built off coredumperror's script with a few improvements.
// ==UserScript== // @name Bluesky Image/Video Download Button // @namespace KanashiiWolf // @match https://bsky.app/* // @grant GM_setValue // @grant GM_getValue // @grant GM_info // @version 1.8.1 // @author KanashiiWolf, the-nelsonator, coredumperror // @description Adds a download button to Bluesky images and videos. Built off coredumperror's script with a few improvements. // @license MIT // ==/UserScript== (function() { 'use strict'; // Filename template settings const defaultTemplate = "@<%username>-bsky-<%post_id>-<%img_num>"; let filenameTemplate = GM_getValue('filename', defaultTemplate); const postUrlRegex = /\/profile\/[^/]+\/post\/[A-Za-z0-9]+/; // Download button HTML (using template literals for readability) const downloadButtonHTML = ` <div class="download-button" style=" cursor: pointer; z-index: 999; display: table; font-size: 15px; color: white; position: absolute; left: 5px; top: 5px; background: #0000007f; height: 30px; width: 30px; border-radius: 15px; text-align: center;"> <svg class="icon" style=" width: 15px; height: 15px; vertical-align: top; display: inline-block; margin-top: 7px; fill: currentColor; overflow: hidden;" viewBox="0 0 #### ####" version="1.1" xmlns="http://www.w3.org/2000/svg"> <path d="M925.248 356.928l-258.176-258.176a64 64 0 0 0-45.248-18.752H144a64 64 0 0 0-64 64v736a64 64 0 0 0 64 64h736a64 64 0 0 0 64-64V402.176a64 64 0 0 0-18.752-45.248zM288 144h192V256H288V144z m448 736H288V736h448v144z m144 0H800V704a32 32 0 0 0-32-32H256a32 32 0 0 0-32 32v176H144v-736H224V288a32 32 0 0 0 32 32h256a32 32 0 0 0 32-32V144h77.824l258.176 258.176V880z"></path> </svg> </div>`; const config = { childList: true, subtree: true }; let headerNode; let settingsButton = false; const waitForLoad = (mutationList, observer) => { for (const mutation of mutationList) { for (let node of mutation.addedNodes) { if (!(node instanceof HTMLElement)) continue; headerNode = node.querySelector('[aria-label="Account"]'); if (headerNode && !settingsButton) { addFilenameSettings(headerNode); settingsButton = true; observer.disconnect(); } } } }; const waitForContent = (mutationList, observer) => { for (const mutation of mutationList) { for (let node of mutation.addedNodes) { if (!(node instanceof HTMLElement)) continue; const img = node.querySelector('img[src^="https://cdn.bsky.app/img/feed_thumbnail"]'); if (img) { img.setAttribute('processed', ''); addDownloadButton(img); } const vid = node.querySelector('video[poster^="https://video.bsky.app/watch"]'); if (vid) { vid.setAttribute('processed', ''); addDownloadButton(vid, true); } } } }; function addFilenameSettings(node) { const settingsInput = document.createElement('input'); settingsInput.id = 'filename-input-space'; settingsInput.style.cssText = ` display: flex; align-items: center; justify-content: center; margin-top: 10px; text-align: center; display: none;`; settingsInput.addEventListener('keypress', (e) => { if (e.key === "Enter") { settingsInput.style.display = 'none'; settingsButton.style.display = 'flex'; filenameTemplate = settingsInput.value; GM_setValue('filename', filenameTemplate); } }); const settingsButton = document.createElement('a'); settingsButton.id = 'filename-input-button'; settingsButton.textContent = `Download Button Filename Template v${GM_info.script.version}`; settingsButton.style.cssText = ` display: flex; align-items: center; justify-content: center; margin-top: 10px; border: 2px solid; cursor: pointer;`; settingsButton.addEventListener('click', (e) => { e.preventDefault(); settingsButton.style.display = 'none'; settingsInput.style.display = 'flex'; settingsInput.focus(); settingsInput.value = filenameTemplate; }); node.parentNode.insertBefore(settingsButton, node); node.parentNode.insertBefore(settingsInput, settingsButton); } const contentObserver = new MutationObserver(waitForContent); const settingsObserver = new MutationObserver(waitForLoad); settingsObserver.observe(document, config); contentObserver.observe(document, config); function downloadContent(url, data) { const urlArray = url.split('/'); const did = data.isVideo ? urlArray[4] : urlArray[6]; const cid = data.isVideo ? urlArray[5] : urlArray[7].split('@')[0]; fetch(`https://bsky.social/xrpc/com.atproto.sync.getBlob?did=${did}&cid=${cid}`) .then(response => { if (!response.ok) { throw new Error(`Couldn't retrieve blob! Response: ${response}`); } return response.blob(); }) .then(blob => sendFile(data, blob)); } function getExtensionFromBlob(blob) { // Create a mapping of common MIME types to their extensions const mimeTypeToExtension = { 'image/jpeg': 'jpg', 'image/png': 'png', 'image/gif': 'gif', 'image/webp': 'webp', 'image/svg+xml': 'svg', 'audio/mpeg': 'mp3', 'audio/ogg': 'ogg', 'audio/wav': 'wav', 'video/mp4': 'mp4', 'video/webm': 'webm', 'video/ogg': 'ogv', 'application/pdf': 'pdf', 'text/plain': 'txt', 'text/html': 'html', 'application/json': 'json', 'application/zip': 'zip', // Add more MIME types and extensions as needed }; // Get the MIME type from the blob const mimeType = blob.type; // Check if the MIME type is in the mapping if (mimeTypeToExtension[mimeType]) { return mimeTypeToExtension[mimeType]; } // If the MIME type is not found, try to guess the extension from the file name if (blob.name) { const fileName = blob.name; const lastDotIndex = fileName.lastIndexOf('.'); if (lastDotIndex !== -1) { return fileName.substring(lastDotIndex + 1).toLowerCase(); } } // If all else fails, return an empty string return ''; } function sendFile(data, blob) { const filename = convertFilename(data) + `.${getExtensionFromBlob(blob)}`; const downloadEl = document.createElement('a'); downloadEl.href = URL.createObjectURL(blob); downloadEl.download = filename; downloadEl.click(); } function getImageNumber(image) { const ancestor = image.parentElement.parentElement.parentElement.parentElement.parentElement.parentElement.parentElement; const postImages = ancestor.getElementsByTagName('img'); for (let i = 0; i < postImages.length; i++) { if (postImages[i].src === image.src) { return i; } } return 0; } function addDownloadButton(element, isVideo = false) { if (element == null) return; let downloadBtn = document.createElement('div'); let downloadBtnParent; const mediaUrl = isVideo ? element.poster : element.src; if (mediaUrl.includes('feed_thumbnail') || isVideo) { downloadBtnParent = element.parentElement.parentElement; downloadBtnParent.appendChild(downloadBtn); downloadBtn.outerHTML = downloadButtonHTML; } else if (mediaUrl.includes('feed_fullsize')) { return; } downloadBtn = downloadBtnParent.getElementsByClassName('download-button')[0]; const postPath = getPostLink(element); const pathArray = postPath.split('/'); const username = pathArray[2]; const uname = username.split('.')[0]; const postId = pathArray[4]; const timestamp = new Date().getTime(); const imageNumber = isVideo ? 0 : getImageNumber(element); const data = { uname: uname, username: username, postId: postId, timestamp: timestamp, imageNumber: imageNumber, isVideo: isVideo }; // Prevent non-click events downloadBtn.addEventListener('mousedown', e => e.preventDefault()); downloadBtn.addEventListener('click', e => { e.stopPropagation(); downloadContent(mediaUrl, data); return false; }); }; function getPostLink(element) { const sep = element.src ? element.src : element.poster; let path = element.parentElement.innerHTML.split(sep)[0].match(postUrlRegex); while (path == null) { element = element.parentElement; path = element.innerHTML.split(sep)[0].match(postUrlRegex); if (element.innerHTML.includes("postThreadItem")) { return window.location.pathname; } } return path[0]; } function convertFilename(data) { return filenameTemplate .replace("<%uname>", data.uname) .replace("<%username>", data.username) .replace("<%post_id>", data.postId) .replace("<%timestamp>", data.timestamp) .replace("<%img_num>", data.imageNumber); } })();