- // ==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 1024 1024"
- 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);
- }
- })();