// ==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://greasyfork.org/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...
});
});
}
})();