Greasy Fork is available in English.

Bluesky Image/Video Download Button

Adds a download button to Bluesky images and videos. Built off coredumperror's script with a few improvements.

  1. // ==UserScript==
  2. // @name Bluesky Image/Video Download Button
  3. // @namespace KanashiiWolf
  4. // @match https://bsky.app/*
  5. // @grant GM_setValue
  6. // @grant GM_getValue
  7. // @grant GM_info
  8. // @version 1.8.1
  9. // @author KanashiiWolf, the-nelsonator, coredumperror
  10. // @description Adds a download button to Bluesky images and videos. Built off coredumperror's script with a few improvements.
  11. // @license MIT
  12. // ==/UserScript==
  13.  
  14. (function() {
  15. 'use strict';
  16.  
  17. // Filename template settings
  18. const defaultTemplate = "@<%username>-bsky-<%post_id>-<%img_num>";
  19. let filenameTemplate = GM_getValue('filename', defaultTemplate);
  20.  
  21. const postUrlRegex = /\/profile\/[^/]+\/post\/[A-Za-z0-9]+/;
  22.  
  23. // Download button HTML (using template literals for readability)
  24. const downloadButtonHTML = `
  25. <div class="download-button" style="
  26. cursor: pointer;
  27. z-index: 999;
  28. display: table;
  29. font-size: 15px;
  30. color: white;
  31. position: absolute;
  32. left: 5px;
  33. top: 5px;
  34. background: #0000007f;
  35. height: 30px;
  36. width: 30px;
  37. border-radius: 15px;
  38. text-align: center;">
  39. <svg class="icon" style="
  40. width: 15px;
  41. height: 15px;
  42. vertical-align: top;
  43. display: inline-block;
  44. margin-top: 7px;
  45. fill: currentColor;
  46. overflow: hidden;"
  47. viewBox="0 0 1024 1024"
  48. version="1.1"
  49. xmlns="http://www.w3.org/2000/svg">
  50. <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>
  51. </svg>
  52. </div>`;
  53.  
  54. const config = {
  55. childList: true,
  56. subtree: true
  57. };
  58. let headerNode;
  59. let settingsButton = false;
  60.  
  61. const waitForLoad = (mutationList, observer) => {
  62. for (const mutation of mutationList) {
  63. for (let node of mutation.addedNodes) {
  64. if (!(node instanceof HTMLElement)) continue;
  65. headerNode = node.querySelector('[aria-label="Account"]');
  66. if (headerNode && !settingsButton) {
  67. addFilenameSettings(headerNode);
  68. settingsButton = true;
  69. observer.disconnect();
  70. }
  71. }
  72. }
  73. };
  74.  
  75. const waitForContent = (mutationList, observer) => {
  76. for (const mutation of mutationList) {
  77. for (let node of mutation.addedNodes) {
  78. if (!(node instanceof HTMLElement)) continue;
  79.  
  80. const img = node.querySelector('img[src^="https://cdn.bsky.app/img/feed_thumbnail"]');
  81. if (img) {
  82. img.setAttribute('processed', '');
  83. addDownloadButton(img);
  84. }
  85.  
  86. const vid = node.querySelector('video[poster^="https://video.bsky.app/watch"]');
  87. if (vid) {
  88. vid.setAttribute('processed', '');
  89. addDownloadButton(vid, true);
  90. }
  91. }
  92. }
  93. };
  94.  
  95. function addFilenameSettings(node) {
  96. const settingsInput = document.createElement('input');
  97. settingsInput.id = 'filename-input-space';
  98. settingsInput.style.cssText = `
  99. display: flex;
  100. align-items: center;
  101. justify-content: center;
  102. margin-top: 10px;
  103. text-align: center;
  104. display: none;`;
  105.  
  106. settingsInput.addEventListener('keypress', (e) => {
  107. if (e.key === "Enter") {
  108. settingsInput.style.display = 'none';
  109. settingsButton.style.display = 'flex';
  110. filenameTemplate = settingsInput.value;
  111. GM_setValue('filename', filenameTemplate);
  112. }
  113. });
  114.  
  115. const settingsButton = document.createElement('a');
  116. settingsButton.id = 'filename-input-button';
  117. settingsButton.textContent = `Download Button Filename Template v${GM_info.script.version}`;
  118. settingsButton.style.cssText = `
  119. display: flex;
  120. align-items: center;
  121. justify-content: center;
  122. margin-top: 10px;
  123. border: 2px solid;
  124. cursor: pointer;`;
  125.  
  126. settingsButton.addEventListener('click', (e) => {
  127. e.preventDefault();
  128. settingsButton.style.display = 'none';
  129. settingsInput.style.display = 'flex';
  130. settingsInput.focus();
  131. settingsInput.value = filenameTemplate;
  132. });
  133.  
  134. node.parentNode.insertBefore(settingsButton, node);
  135. node.parentNode.insertBefore(settingsInput, settingsButton);
  136. }
  137.  
  138. const contentObserver = new MutationObserver(waitForContent);
  139. const settingsObserver = new MutationObserver(waitForLoad);
  140. settingsObserver.observe(document, config);
  141. contentObserver.observe(document, config);
  142.  
  143. function downloadContent(url, data) {
  144. const urlArray = url.split('/');
  145. const did = data.isVideo ? urlArray[4] : urlArray[6];
  146. const cid = data.isVideo ?
  147. urlArray[5] :
  148. urlArray[7].split('@')[0];
  149.  
  150. fetch(`https://bsky.social/xrpc/com.atproto.sync.getBlob?did=${did}&cid=${cid}`)
  151. .then(response => {
  152. if (!response.ok) {
  153. throw new Error(`Couldn't retrieve blob! Response: ${response}`);
  154. }
  155. return response.blob();
  156. })
  157. .then(blob => sendFile(data, blob));
  158. }
  159.  
  160. function getExtensionFromBlob(blob) {
  161. // Create a mapping of common MIME types to their extensions
  162. const mimeTypeToExtension = {
  163. 'image/jpeg': 'jpg',
  164. 'image/png': 'png',
  165. 'image/gif': 'gif',
  166. 'image/webp': 'webp',
  167. 'image/svg+xml': 'svg',
  168. 'audio/mpeg': 'mp3',
  169. 'audio/ogg': 'ogg',
  170. 'audio/wav': 'wav',
  171. 'video/mp4': 'mp4',
  172. 'video/webm': 'webm',
  173. 'video/ogg': 'ogv',
  174. 'application/pdf': 'pdf',
  175. 'text/plain': 'txt',
  176. 'text/html': 'html',
  177. 'application/json': 'json',
  178. 'application/zip': 'zip',
  179. // Add more MIME types and extensions as needed
  180. };
  181.  
  182. // Get the MIME type from the blob
  183. const mimeType = blob.type;
  184.  
  185. // Check if the MIME type is in the mapping
  186. if (mimeTypeToExtension[mimeType]) {
  187. return mimeTypeToExtension[mimeType];
  188. }
  189.  
  190. // If the MIME type is not found, try to guess the extension from the file name
  191. if (blob.name) {
  192. const fileName = blob.name;
  193. const lastDotIndex = fileName.lastIndexOf('.');
  194. if (lastDotIndex !== -1) {
  195. return fileName.substring(lastDotIndex + 1).toLowerCase();
  196. }
  197. }
  198.  
  199. // If all else fails, return an empty string
  200. return '';
  201. }
  202.  
  203. function sendFile(data, blob) {
  204. const filename = convertFilename(data) + `.${getExtensionFromBlob(blob)}`;
  205. const downloadEl = document.createElement('a');
  206. downloadEl.href = URL.createObjectURL(blob);
  207. downloadEl.download = filename;
  208. downloadEl.click();
  209. }
  210.  
  211. function getImageNumber(image) {
  212. const ancestor = image.parentElement.parentElement.parentElement.parentElement.parentElement.parentElement.parentElement;
  213. const postImages = ancestor.getElementsByTagName('img');
  214. for (let i = 0; i < postImages.length; i++) {
  215. if (postImages[i].src === image.src) {
  216. return i;
  217. }
  218. }
  219. return 0;
  220. }
  221.  
  222. function addDownloadButton(element, isVideo = false) {
  223. if (element == null) return;
  224. let downloadBtn = document.createElement('div');
  225. let downloadBtnParent;
  226. const mediaUrl = isVideo ? element.poster : element.src;
  227.  
  228. if (mediaUrl.includes('feed_thumbnail') || isVideo) {
  229. downloadBtnParent = element.parentElement.parentElement;
  230. downloadBtnParent.appendChild(downloadBtn);
  231. downloadBtn.outerHTML = downloadButtonHTML;
  232. } else if (mediaUrl.includes('feed_fullsize')) {
  233. return;
  234. }
  235.  
  236. downloadBtn = downloadBtnParent.getElementsByClassName('download-button')[0];
  237.  
  238. const postPath = getPostLink(element);
  239. const pathArray = postPath.split('/');
  240. const username = pathArray[2];
  241. const uname = username.split('.')[0];
  242. const postId = pathArray[4];
  243. const timestamp = new Date().getTime();
  244. const imageNumber = isVideo ? 0 : getImageNumber(element);
  245.  
  246. const data = {
  247. uname: uname,
  248. username: username,
  249. postId: postId,
  250. timestamp: timestamp,
  251. imageNumber: imageNumber,
  252. isVideo: isVideo
  253. };
  254.  
  255. // Prevent non-click events
  256. downloadBtn.addEventListener('mousedown', e => e.preventDefault());
  257.  
  258. downloadBtn.addEventListener('click', e => {
  259. e.stopPropagation();
  260. downloadContent(mediaUrl, data);
  261. return false;
  262. });
  263. };
  264.  
  265. function getPostLink(element) {
  266. const sep = element.src ? element.src : element.poster;
  267. let path = element.parentElement.innerHTML.split(sep)[0].match(postUrlRegex);
  268. while (path == null) {
  269. element = element.parentElement;
  270. path = element.innerHTML.split(sep)[0].match(postUrlRegex);
  271. if (element.innerHTML.includes("postThreadItem")) {
  272. return window.location.pathname;
  273. }
  274. }
  275. return path[0];
  276. }
  277.  
  278. function convertFilename(data) {
  279. return filenameTemplate
  280. .replace("<%uname>", data.uname)
  281. .replace("<%username>", data.username)
  282. .replace("<%post_id>", data.postId)
  283. .replace("<%timestamp>", data.timestamp)
  284. .replace("<%img_num>", data.imageNumber);
  285. }
  286. })();