- // ==UserScript==
- // @name Bluesky Image Downloader
- // @namespace coredumperror
- // @version 1.0
- // @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.
- // @author coredumperror
- // @license MIT
- // @match https://bsky.app/*
- // @grant none
- // ==/UserScript==
-
- (function() {
- 'use strict';
-
- // This script is a heavily modified version of https://gf.qytechs.cn/en/scripts/377958-twitterimg-downloader
-
- /** Edit filename_template to change the file name format:
- *
- * <%username> Bluesky username eg: oh8.bsky.social
- * <%uname> Bluesky short username eg: oh8
- * <%post_id> Post ID eg: 3krmccyl4722w
- * <%timestamp> Current timestamp eg: 1550557810891
- * <%img_num> Image number within post eg: 0, 1, 2, or 3
- *
- * default: "<%uname> <%post_id>_p<%img_num>"
- * result: "oh8 3krmccyl4722w_p0.jpg"
- * Could end in .png or any other image file extension,
- * as the script downloads the original image from Bluesky's API.
- *
- * example: "<%username> <%timestamp> <%post_id>_p<%image_num>"
- * result: "oh8.bsky.social 1716298367 3krmccyl4722w_p1.jpg"
- * This will make it so the images are sorted in the order in
- * which you downloaded them, instead of the order in which
- * they were posted.
- */
- let filename_template = "<%uname> <%post_id>_p<%img_num>";
-
- const post_url_regex = /\/profile\/[^/]+\/post\/[A-Za-z0-9]+/;
- // Set up the download button's HTML to display a floppy disk vector graphic within a grey circle.
- const download_button_html = `
- <div class="download-button"
- style="
- cursor: pointer;
- z-index: 999;
- display: table;
- font-size: 15px;
- color: white;
- position: absolute;
- right: 5px;
- bottom: 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"
- p-id="3658"
- >
- <path p-id="3659"
- 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>`;
-
- function download_image_from_api(image_url, filename) {
- // From the image URL, we retrieve the image's did and cid, which
- // are needed for the getBlob API call.
- const url_array = image_url.split('/');
- const did = url_array[6];
- // Must remove the @jpeg at the end of the URL to get the actual cid.
- const cid = url_array[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) => {
- // Unfortunately, even this image blob isn't the original image. Bluesky
- // doesn't seem to store that on their servers at all. They scale the
- // original down to at most 1000px wide or 2000px tall, whichever makes it
- // smaller, and store a compressed, but relatively high quality jpeg of that.
- // It's less compressed than the one you get from clicking the image, at least.
- send_file_to_user(filename, blob);
- });
- }
-
- function send_file_to_user(filename, blob) {
- // Create a URL to represent the downloaded blob data, then attach it
- // to the download_link and "click" it, to make the browser's
- // link workflow download the file to the user's hard drive.
- let anchor = create_download_link();
- anchor.download = filename;
- anchor.href = URL.createObjectURL(blob);
- anchor.click();
- }
-
- // This function creates an anchor for the code to manually click() in order to trigger
- // the image download. Every download button uses the same, single <a> that is
- // generated the first time this function runs.
- function create_download_link() {
- let dl_btn_elem = document.getElementById('img-download-button');
- if (dl_btn_elem == null) {
- // If the image download button doesn't exist yet, create it as a child of the root.
- dl_btn_elem = document.createElement('a', {id: 'img-download-button'});
- // Like twitter, everything in the Bluesky app is inside the #root element.
- // TwitterImg Downloader put the download anchor there, so we do too.
- document.getElementById('root').appendChild(dl_btn_elem);
- }
- return dl_btn_elem;
- }
-
- function get_img_num(image_elem) {
- // This is a bit hacky, since I'm not sure how to better determine whether
- // a post has more than one image. I could do an API call, but that seems
- // like overkill. This should work well enough.
- // As of 2024-05-22, if you go up 7 levels from the <img> in a POST, you'll hit the
- // closest ancestor element that all the images in the post descend from.
- const nearest_common_ancestor = image_elem.parentElement.parentElement.parentElement.parentElement.parentElement.parentElement.parentElement;
- // But images in the lightbox are different. 7 levels is much too far.
- // In fact, there doesn't seem to be ANY way to determine how many images are in the lightbox,
- // so I've actually gone back and changed add_download_button_to_image() so it doesn't put a download button
- // onto lightbox images at all.
-
- // Loop through all the <img> tags inside the ancestor, and return the index of the specified imnage_elem.
- const post_images = nearest_common_ancestor.getElementsByTagName('img');
- // TODO: This doesn't work if the image_elem is a click-zoomed image viewed from a feed.
- // 7 ancestors up brings us high enough to capture the entire feed in post_images.
- for (let x = 0; x < post_images.length; x += 1) {
- if (post_images[x].src == image_elem.src) {
- return x;
- }
- }
- // Fallback value, in case we somehow don't find any <img>s.
- return 0;
- }
-
- // Adds the download button to the specified image element.
- function add_download_button_to_image(image_elem) {
- // If this doesn't look like an actual <img> element, do nothing.
- // Also note that embeded images in Bluesky posts always have an alt tag (though it's blank),
- // so the image_elem.alt == null check ensures we don't slap a download button onto user avatars and such.
- if (image_elem == null || image_elem.src == null || image_elem.alt == null) {
- return;
- }
- // Create a DOM element in which we'll store the download button.
- let download_btn = document.createElement('div');
- let download_btn_parent;
- // We grab and store the image_elem's src here so that the click handler
- // and retrieve it later, even once image_elem has gone out of scope.
- let image_url = image_elem.src;
-
- if (image_url.includes('feed_thumbnail')) {
- // If this is a thumbnail, add the download button as a child of the image's grandparent,
- // which is the relevant "position: relative" ancestor, placing it in the bottom-right of the image.
- const html = download_button_html.replace('<%pos>', 'right: 5px; bottom: 5px;');
- download_btn_parent = image_elem.parentElement.parentElement;
- download_btn_parent.appendChild(download_btn);
- // AFTER appending the download_btn div to the relevant parent, we change out its HTML.
- // This is needed because download_btn itself stops referencing the actual element when we replace its HTML.
- // There's probably a better way to do this, but I don't know it.
- download_btn.outerHTML = html;
- }
- else if (image_url.includes('feed_fullsize')) {
- // Don't add a download button to these. There's no way to determine how many images are in a post from a
- // fullsize <img> tag, so we can't build the filename properly. Users will just have to click the Download button
- // that's on the thumbnail.
- return;
- }
-
- // Because we replaced all of download_btn's HTML, the download_btn variable doesn't actually point
- // to our element any more. This line fixes that, by grabbing the download button from the DOM.
- download_btn = download_btn_parent.getElementsByClassName('download-button')[0];
-
- let post_path;
- const current_path = window.location.pathname;
- if (current_path.match(post_url_regex)) {
- // If we're on a post page, just use the current location for post_url.
- // This is necessary because there's a weird issue that happens when a user clicks from a feed to a post.
- // The feed sticks around in the DOM, so that the browser can restore it if the user clicks Back.
- // But that lets find_time_since_post_link() find the *wrong link* sometimes.
- // To prevent this, check if we're on a post page by looking at the URL path.
- // If we are, we know there's no time-since-post link, so we just use the current path.
- post_path = current_path;
- }
- else {
- // Due to the issue described above, we only call find_time_since_post_link()
- // if we KNOW we're not on a post page.
- const post_link = find_time_since_post_link(image_elem);
- // Remove the scheme and domain so we just have the path left to parse.
- post_path = post_link.href.replace('https://bsky.app', '');
- }
-
- // post_path will look like this:
- // /profile/oh8.bsky.social/post/3krmccyl4722w
- // We parse the username and Post ID from that info.
- const post_array = post_path.split('/');
- const username = post_array[2];
- const uname = username.split('.')[0];
- const post_id = post_array[4];
-
- const timestamp = new Date().getTime();
- const img_num = get_img_num(image_elem);
-
- // Format the content we just parsed into the default filename template.
- const base_filename = filename_template
- .replace("<%username>", username)
- .replace("<%uname>", uname)
- .replace("<%post_id>", post_id)
- .replace("<%timestamp>", timestamp)
- .replace("<%img_num>", img_num);
-
- // Not sure what these handlers from TwitterImagedownloader are for...
- // Something about preventing non-click events on the download button from having any effect?
- download_btn.addEventListener('touchstart', function(e) {
- download_btn.onclick = function(e) {
- return false;
- }
- return false;
- });
- download_btn.addEventListener('mousedown', function(e) {
- download_btn.onclick = function(e) {
- return false;
- }
- return false;
- });
-
- // Add a click handler to the download button, which performs the actual download.
- download_btn.addEventListener('click', function(e) {
- e.stopPropagation();
- download_image_from_api(image_url, base_filename);
- return false;
- });
- }
-
- function find_feed_images() {
- // Images in feeds and posts have URLs that look like this:
- // https://cdn.bsky.app/img/feed_thumbnail/...
- // When the user clicks an image to see it full screen, that loads the same image with a different prefix:
- // https://cdn.bsky.app/img/feed_fullsize/...
- // Thus, this CSS selector will find only the images we want to add a download button to:
- const selector = 'img[src^="https://cdn.bsky.app/img/feed_thumbnail"]';
-
- document.querySelectorAll(selector).forEach((feed_image) => {
- // Before processing this image, make sure it's actually an embedded image, rather than a video thumbnail.
- // They use identical image URLs, so to differentiate, we look for an alt attribute.
- // Feed images have one (that might be ""), while video thumbnails don't have one at all.
- if (feed_image.getAttribute('alt') === null) {
- // This is how to "continue" a forEach loop.
- return;
- }
-
- // We add a "processed" attribute to each feed image that's already been found and processed,
- // so that this function, which repeats itself every 300 ms, doesn't add the download button
- // to the same <img> over and over.
- let processed = feed_image.getAttribute('processed');
- if (processed === null) {
- add_download_button_to_image(feed_image);
- console.log(`Added download button to ${feed_image.src}`);
- // Add the "processed" flag.
- feed_image.setAttribute('processed', '');
- }
- });
- }
-
- function find_time_since_post_link(element) {
- // What we need to do is drill upward in the stack until we find a div that has an <a> inside it that
- // links to a post, and has an aria-label attribute. We know for certain that this will be the "time since post"
- // link, and not a link that's part of the post's text.
- // As of 2024-05-21, these links are 13 levels above the images in each post within a feed.
-
- // If we've run out of ancestors, bottom out the recursion.
- if (element == null) {
- return null;
- }
- // Look for all the <a>s inside this element...
- for (const link of element.getElementsByTagName('a')) {
- // If one of them links to a Bluesky post AND has an aria-label attribute, that's the time-since-post link.
- // Post URLs look like /profile/oh8.bsky.social/post/3krmccyl4722w
- if (link.getAttribute('href') &&
- link.getAttribute('href').match(post_url_regex) &&
- link.getAttribute('aria-label') !== null) {
- return link;
- }
- }
- // We didn't find the time-since-post link, so look one level further up.
- return find_time_since_post_link(element.parentElement)
- }
-
- // Run find_feed_images(), which adds the download button to each image found in the feed/post, every 300ms.
- // It needs to run repeatedly so that when the user scrolls a feed, new images get the button after they load in.
- setInterval(find_feed_images, 300);
-
- // The downloader's code is over, but there's one last thing that might prove useful later...
-
- //////////////////////////////////////////////////////////////////////////////
- // How to use the Bluesky API if you need to do something that requires authorization:
- //////////////////////////////////////////////////////////////////////////////
- function authorize_with_bluesky_api() {
- // To use the Bluesky API, we start by creating a session, to generate a bearer token.
- const credentials = {
- // Replace these with actual credentials when using this.
- identifier: 'EMAIL',
- password: 'PASSWORD',
- };
-
- fetch(
- 'https://bsky.social/xrpc/com.atproto.server.createSession',
- {
- method: 'POST',
- headers: {
- 'Content-Type': 'application/json',
- },
- body: JSON.stringify(credentials),
- }
- ).then((response) => {
- if (!response.ok) {
- throw new Error(`Unable to create Bluesky session! Status: ${response.json()}`);
- }
- return response.json();
- }).then((body) => {
- const auth_token = body.accessJwt;
-
- // Then use auth_token like this:
-
- fetch(
- `https://bsky.social/xrpc/com.atproto.whatever...`,
- {
- headers: {
- 'Authorization': `Bearer ${auth_token}`,
- }
- }
- )
- .then((response) => {
- if (!response.ok) {
- throw new Error(`API call failed! Status: ${response.json()}`);
- }
- return response.json();
- })
- .then((body) => {
- // Use the body of the response here...
- });
-
- });
- }
-
- })();