Bluesky Image Downloader

Adds a download button to images posted to Bluesky, which immediately downloads the image in max quality and with a descriptive filename for easy sorting.

  1. // ==UserScript==
  2. // @name Bluesky Image Downloader
  3. // @namespace coredumperror
  4. // @version 1.0
  5. // @description Adds a download button to images posted to Bluesky, which immediately downloads the image in max quality and with a descriptive filename for easy sorting.
  6. // @author coredumperror
  7. // @license MIT
  8. // @match https://bsky.app/*
  9. // @grant none
  10. // ==/UserScript==
  11.  
  12. (function() {
  13. 'use strict';
  14.  
  15. // This script is a heavily modified version of https://gf.qytechs.cn/en/scripts/377958-twitterimg-downloader
  16.  
  17. /** Edit filename_template to change the file name format:
  18. *
  19. * <%username> Bluesky username eg: oh8.bsky.social
  20. * <%uname> Bluesky short username eg: oh8
  21. * <%post_id> Post ID eg: 3krmccyl4722w
  22. * <%timestamp> Current timestamp eg: 1550557810891
  23. * <%img_num> Image number within post eg: 0, 1, 2, or 3
  24. *
  25. * default: "<%uname> <%post_id>_p<%img_num>"
  26. * result: "oh8 3krmccyl4722w_p0.jpg"
  27. * Could end in .png or any other image file extension,
  28. * as the script downloads the original image from Bluesky's API.
  29. *
  30. * example: "<%username> <%timestamp> <%post_id>_p<%image_num>"
  31. * result: "oh8.bsky.social 1716298367 3krmccyl4722w_p1.jpg"
  32. * This will make it so the images are sorted in the order in
  33. * which you downloaded them, instead of the order in which
  34. * they were posted.
  35. */
  36. let filename_template = "<%uname> <%post_id>_p<%img_num>";
  37.  
  38. const post_url_regex = /\/profile\/[^/]+\/post\/[A-Za-z0-9]+/;
  39. // Set up the download button's HTML to display a floppy disk vector graphic within a grey circle.
  40. const download_button_html = `
  41. <div class="download-button"
  42. style="
  43. cursor: pointer;
  44. z-index: 999;
  45. display: table;
  46. font-size: 15px;
  47. color: white;
  48. position: absolute;
  49. right: 5px;
  50. bottom: 5px;
  51. background: #0000007f;
  52. height: 30px;
  53. width: 30px;
  54. border-radius: 15px;
  55. text-align: center;"
  56. >
  57. <svg class="icon"
  58. style="width: 15px;
  59. height: 15px;
  60. vertical-align: top;
  61. display: inline-block;
  62. margin-top: 7px;
  63. fill: currentColor;
  64. overflow: hidden;"
  65. viewBox="0 0 1024 1024"
  66. version="1.1"
  67. xmlns="http://www.w3.org/2000/svg"
  68. p-id="3658"
  69. >
  70. <path p-id="3659"
  71. d="M925.248 356.928l-258.176-258.176a64
  72. 64 0 0 0-45.248-18.752H144a64
  73. 64 0 0 0-64 64v736a64
  74. 64 0 0 0 64 64h736a64
  75. 64 0 0 0 64-64V402.176a64
  76. 64 0 0 0-18.752-45.248zM288
  77. 144h192V256H288V144z m448
  78. 736H288V736h448v144z m144 0H800V704a32
  79. 32 0 0 0-32-32H256a32 32 0 0 0-32
  80. 32v176H144v-736H224V288a32
  81. 32 0 0 0 32 32h256a32 32 0 0 0
  82. 32-32V144h77.824l258.176 258.176V880z"
  83. ></path>
  84. </svg>
  85. </div>`;
  86.  
  87. function download_image_from_api(image_url, filename) {
  88. // From the image URL, we retrieve the image's did and cid, which
  89. // are needed for the getBlob API call.
  90. const url_array = image_url.split('/');
  91. const did = url_array[6];
  92. // Must remove the @jpeg at the end of the URL to get the actual cid.
  93. const cid = url_array[7].split('@')[0];
  94.  
  95. fetch(`https://bsky.social/xrpc/com.atproto.sync.getBlob?did=${did}&cid=${cid}`)
  96. .then((response) => {
  97. if (!response.ok) {
  98. throw new Error(`Couldn't retrieve blob! Response: ${response}`);
  99. }
  100. return response.blob();
  101. })
  102. .then((blob) => {
  103. // Unfortunately, even this image blob isn't the original image. Bluesky
  104. // doesn't seem to store that on their servers at all. They scale the
  105. // original down to at most 1000px wide or 2000px tall, whichever makes it
  106. // smaller, and store a compressed, but relatively high quality jpeg of that.
  107. // It's less compressed than the one you get from clicking the image, at least.
  108. send_file_to_user(filename, blob);
  109. });
  110. }
  111.  
  112. function send_file_to_user(filename, blob) {
  113. // Create a URL to represent the downloaded blob data, then attach it
  114. // to the download_link and "click" it, to make the browser's
  115. // link workflow download the file to the user's hard drive.
  116. let anchor = create_download_link();
  117. anchor.download = filename;
  118. anchor.href = URL.createObjectURL(blob);
  119. anchor.click();
  120. }
  121.  
  122. // This function creates an anchor for the code to manually click() in order to trigger
  123. // the image download. Every download button uses the same, single <a> that is
  124. // generated the first time this function runs.
  125. function create_download_link() {
  126. let dl_btn_elem = document.getElementById('img-download-button');
  127. if (dl_btn_elem == null) {
  128. // If the image download button doesn't exist yet, create it as a child of the root.
  129. dl_btn_elem = document.createElement('a', {id: 'img-download-button'});
  130. // Like twitter, everything in the Bluesky app is inside the #root element.
  131. // TwitterImg Downloader put the download anchor there, so we do too.
  132. document.getElementById('root').appendChild(dl_btn_elem);
  133. }
  134. return dl_btn_elem;
  135. }
  136.  
  137. function get_img_num(image_elem) {
  138. // This is a bit hacky, since I'm not sure how to better determine whether
  139. // a post has more than one image. I could do an API call, but that seems
  140. // like overkill. This should work well enough.
  141. // As of 2024-05-22, if you go up 7 levels from the <img> in a POST, you'll hit the
  142. // closest ancestor element that all the images in the post descend from.
  143. const nearest_common_ancestor = image_elem.parentElement.parentElement.parentElement.parentElement.parentElement.parentElement.parentElement;
  144. // But images in the lightbox are different. 7 levels is much too far.
  145. // In fact, there doesn't seem to be ANY way to determine how many images are in the lightbox,
  146. // so I've actually gone back and changed add_download_button_to_image() so it doesn't put a download button
  147. // onto lightbox images at all.
  148.  
  149. // Loop through all the <img> tags inside the ancestor, and return the index of the specified imnage_elem.
  150. const post_images = nearest_common_ancestor.getElementsByTagName('img');
  151. // TODO: This doesn't work if the image_elem is a click-zoomed image viewed from a feed.
  152. // 7 ancestors up brings us high enough to capture the entire feed in post_images.
  153. for (let x = 0; x < post_images.length; x += 1) {
  154. if (post_images[x].src == image_elem.src) {
  155. return x;
  156. }
  157. }
  158. // Fallback value, in case we somehow don't find any <img>s.
  159. return 0;
  160. }
  161.  
  162. // Adds the download button to the specified image element.
  163. function add_download_button_to_image(image_elem) {
  164. // If this doesn't look like an actual <img> element, do nothing.
  165. // Also note that embeded images in Bluesky posts always have an alt tag (though it's blank),
  166. // so the image_elem.alt == null check ensures we don't slap a download button onto user avatars and such.
  167. if (image_elem == null || image_elem.src == null || image_elem.alt == null) {
  168. return;
  169. }
  170. // Create a DOM element in which we'll store the download button.
  171. let download_btn = document.createElement('div');
  172. let download_btn_parent;
  173. // We grab and store the image_elem's src here so that the click handler
  174. // and retrieve it later, even once image_elem has gone out of scope.
  175. let image_url = image_elem.src;
  176.  
  177. if (image_url.includes('feed_thumbnail')) {
  178. // If this is a thumbnail, add the download button as a child of the image's grandparent,
  179. // which is the relevant "position: relative" ancestor, placing it in the bottom-right of the image.
  180. const html = download_button_html.replace('<%pos>', 'right: 5px; bottom: 5px;');
  181. download_btn_parent = image_elem.parentElement.parentElement;
  182. download_btn_parent.appendChild(download_btn);
  183. // AFTER appending the download_btn div to the relevant parent, we change out its HTML.
  184. // This is needed because download_btn itself stops referencing the actual element when we replace its HTML.
  185. // There's probably a better way to do this, but I don't know it.
  186. download_btn.outerHTML = html;
  187. }
  188. else if (image_url.includes('feed_fullsize')) {
  189. // Don't add a download button to these. There's no way to determine how many images are in a post from a
  190. // fullsize <img> tag, so we can't build the filename properly. Users will just have to click the Download button
  191. // that's on the thumbnail.
  192. return;
  193. }
  194.  
  195. // Because we replaced all of download_btn's HTML, the download_btn variable doesn't actually point
  196. // to our element any more. This line fixes that, by grabbing the download button from the DOM.
  197. download_btn = download_btn_parent.getElementsByClassName('download-button')[0];
  198.  
  199. let post_path;
  200. const current_path = window.location.pathname;
  201. if (current_path.match(post_url_regex)) {
  202. // If we're on a post page, just use the current location for post_url.
  203. // This is necessary because there's a weird issue that happens when a user clicks from a feed to a post.
  204. // The feed sticks around in the DOM, so that the browser can restore it if the user clicks Back.
  205. // But that lets find_time_since_post_link() find the *wrong link* sometimes.
  206. // To prevent this, check if we're on a post page by looking at the URL path.
  207. // If we are, we know there's no time-since-post link, so we just use the current path.
  208. post_path = current_path;
  209. }
  210. else {
  211. // Due to the issue described above, we only call find_time_since_post_link()
  212. // if we KNOW we're not on a post page.
  213. const post_link = find_time_since_post_link(image_elem);
  214. // Remove the scheme and domain so we just have the path left to parse.
  215. post_path = post_link.href.replace('https://bsky.app', '');
  216. }
  217.  
  218. // post_path will look like this:
  219. // /profile/oh8.bsky.social/post/3krmccyl4722w
  220. // We parse the username and Post ID from that info.
  221. const post_array = post_path.split('/');
  222. const username = post_array[2];
  223. const uname = username.split('.')[0];
  224. const post_id = post_array[4];
  225.  
  226. const timestamp = new Date().getTime();
  227. const img_num = get_img_num(image_elem);
  228.  
  229. // Format the content we just parsed into the default filename template.
  230. const base_filename = filename_template
  231. .replace("<%username>", username)
  232. .replace("<%uname>", uname)
  233. .replace("<%post_id>", post_id)
  234. .replace("<%timestamp>", timestamp)
  235. .replace("<%img_num>", img_num);
  236.  
  237. // Not sure what these handlers from TwitterImagedownloader are for...
  238. // Something about preventing non-click events on the download button from having any effect?
  239. download_btn.addEventListener('touchstart', function(e) {
  240. download_btn.onclick = function(e) {
  241. return false;
  242. }
  243. return false;
  244. });
  245. download_btn.addEventListener('mousedown', function(e) {
  246. download_btn.onclick = function(e) {
  247. return false;
  248. }
  249. return false;
  250. });
  251.  
  252. // Add a click handler to the download button, which performs the actual download.
  253. download_btn.addEventListener('click', function(e) {
  254. e.stopPropagation();
  255. download_image_from_api(image_url, base_filename);
  256. return false;
  257. });
  258. }
  259.  
  260. function find_feed_images() {
  261. // Images in feeds and posts have URLs that look like this:
  262. // https://cdn.bsky.app/img/feed_thumbnail/...
  263. // When the user clicks an image to see it full screen, that loads the same image with a different prefix:
  264. // https://cdn.bsky.app/img/feed_fullsize/...
  265. // Thus, this CSS selector will find only the images we want to add a download button to:
  266. const selector = 'img[src^="https://cdn.bsky.app/img/feed_thumbnail"]';
  267.  
  268. document.querySelectorAll(selector).forEach((feed_image) => {
  269. // Before processing this image, make sure it's actually an embedded image, rather than a video thumbnail.
  270. // They use identical image URLs, so to differentiate, we look for an alt attribute.
  271. // Feed images have one (that might be ""), while video thumbnails don't have one at all.
  272. if (feed_image.getAttribute('alt') === null) {
  273. // This is how to "continue" a forEach loop.
  274. return;
  275. }
  276.  
  277. // We add a "processed" attribute to each feed image that's already been found and processed,
  278. // so that this function, which repeats itself every 300 ms, doesn't add the download button
  279. // to the same <img> over and over.
  280. let processed = feed_image.getAttribute('processed');
  281. if (processed === null) {
  282. add_download_button_to_image(feed_image);
  283. console.log(`Added download button to ${feed_image.src}`);
  284. // Add the "processed" flag.
  285. feed_image.setAttribute('processed', '');
  286. }
  287. });
  288. }
  289.  
  290. function find_time_since_post_link(element) {
  291. // What we need to do is drill upward in the stack until we find a div that has an <a> inside it that
  292. // links to a post, and has an aria-label attribute. We know for certain that this will be the "time since post"
  293. // link, and not a link that's part of the post's text.
  294. // As of 2024-05-21, these links are 13 levels above the images in each post within a feed.
  295.  
  296. // If we've run out of ancestors, bottom out the recursion.
  297. if (element == null) {
  298. return null;
  299. }
  300. // Look for all the <a>s inside this element...
  301. for (const link of element.getElementsByTagName('a')) {
  302. // If one of them links to a Bluesky post AND has an aria-label attribute, that's the time-since-post link.
  303. // Post URLs look like /profile/oh8.bsky.social/post/3krmccyl4722w
  304. if (link.getAttribute('href') &&
  305. link.getAttribute('href').match(post_url_regex) &&
  306. link.getAttribute('aria-label') !== null) {
  307. return link;
  308. }
  309. }
  310. // We didn't find the time-since-post link, so look one level further up.
  311. return find_time_since_post_link(element.parentElement)
  312. }
  313.  
  314. // Run find_feed_images(), which adds the download button to each image found in the feed/post, every 300ms.
  315. // It needs to run repeatedly so that when the user scrolls a feed, new images get the button after they load in.
  316. setInterval(find_feed_images, 300);
  317.  
  318. // The downloader's code is over, but there's one last thing that might prove useful later...
  319.  
  320. //////////////////////////////////////////////////////////////////////////////
  321. // How to use the Bluesky API if you need to do something that requires authorization:
  322. //////////////////////////////////////////////////////////////////////////////
  323. function authorize_with_bluesky_api() {
  324. // To use the Bluesky API, we start by creating a session, to generate a bearer token.
  325. const credentials = {
  326. // Replace these with actual credentials when using this.
  327. identifier: 'EMAIL',
  328. password: 'PASSWORD',
  329. };
  330.  
  331. fetch(
  332. 'https://bsky.social/xrpc/com.atproto.server.createSession',
  333. {
  334. method: 'POST',
  335. headers: {
  336. 'Content-Type': 'application/json',
  337. },
  338. body: JSON.stringify(credentials),
  339. }
  340. ).then((response) => {
  341. if (!response.ok) {
  342. throw new Error(`Unable to create Bluesky session! Status: ${response.json()}`);
  343. }
  344. return response.json();
  345. }).then((body) => {
  346. const auth_token = body.accessJwt;
  347.  
  348. // Then use auth_token like this:
  349.  
  350. fetch(
  351. `https://bsky.social/xrpc/com.atproto.whatever...`,
  352. {
  353. headers: {
  354. 'Authorization': `Bearer ${auth_token}`,
  355. }
  356. }
  357. )
  358. .then((response) => {
  359. if (!response.ok) {
  360. throw new Error(`API call failed! Status: ${response.json()}`);
  361. }
  362. return response.json();
  363. })
  364. .then((body) => {
  365. // Use the body of the response here...
  366. });
  367.  
  368. });
  369. }
  370.  
  371. })();

QingJ © 2025

镜像随时可能失效,请加Q群300939539或关注我们的公众号极客氢云获取最新地址