// ==UserScript==
// @name Bluesky Image Download Button
// @namespace KanashiiWolf
// @match https://bsky.app/*
// @grant GM_setValue
// @grant GM_getValue
// @version 1.6
// @author KanashiiWolf, the-nelsonator
// @require https://code.jquery.com/jquery-3.7.1.min.js
// @description Takes coredumperror's script and removes the constant 300ms checks and adds an in-page way to adjust the filename_template.
// @license MIT
// ==/UserScript==
(function() {
'use strict';
// This script is a lightly modified version of https://greasyfork.org/en/scripts/495794-bluesky-image-downloader
/** Edit filename_template to change the file name format:
*
* <%uname> Bluesky short username eg: oh8
* <%username> Bluesky full username eg: oh8.bsky.social
* <%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>-<%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 = GM_getValue('filename', "@<%username>-bsky-<%post_id>-<%img_num>");
const post_url_regex = /\/profile\/[^/]+\/post\/[A-Za-z0-9]+/;
const download_button_html = `
<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"
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>`;
// Options for the observer (which mutations to observe)
const config = {
childList: true,
subtree: true
};
let headerNode;
const waitForLoad = (mutationList, observer) => {
for (const mutation of mutationList) {
for (let node of mutation.addedNodes) {
if (!(node instanceof HTMLElement)) continue;
headerNode = node.querySelector('[data-testid="accessibilitySettingsBtn"]');
if (headerNode) {
headerNode = headerNode.parentElement;
if (!headerNode.querySelector("#filename-input-button"))
filenamingSettings(headerNode);
observer.disconnect();
}
}
}
};
// Callback function to execute when mutations are observed
const waitForImg = (mutationList, observer) => {
for (const mutation of mutationList) {
for (let node of mutation.addedNodes) {
if (!(node instanceof HTMLElement)) continue;
let img = node.querySelector('img[src^="https://cdn.bsky.app/img/feed_thumbnail"]')
if (img) {
img.setAttribute('processed', '');
add_download_button_to_image(img);
}
let vid = node.querySelector('video');
if (vid) {
vid.setAttribute('processed', '');
add_download_button_to_video(vid);
}
}
}
};
function filenamingSettings(node) {
let topbar = $(node);
let settings = $('<input>').attr("id", "filename-input-space").css({
"display": "flex",
"align-items": "center",
"justify-content": "center",
"margin-top": "10px"
}).hide().keypress((e) => {
if (e.which == 13) {
settings.hide();
button.show();
filename_template = settings.val();
GM_setValue('filename', settings.val());
}
});
let button = $('<a>').attr("id", "filename-input-button").text('Filenaming').css({
"display": "flex",
"align-items": "center",
"justify-content": "center",
"margin-top": "10px"
}).on('click', (e) => {
e.preventDefault();
button.hide();
settings.show().focus();
settings.val(filename_template);
});
topbar.prepend($("<hr />")).prepend(button).prepend(settings);
}
// Create an observer instance linked to the callback function
const imgObserver = new MutationObserver(waitForImg);
const settingsObserver = new MutationObserver(waitForLoad);
settingsObserver.observe(document, config);
imgObserver.observe(document, config);
function download_image_from_api(image_url, img_data) {
// 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(img_data, blob);
});
}
function download_video_from_api(vid_thumbnail_url, vid_data) {
// Example thumbnail URL, from video element poster attribute:
// https://video.bsky.app/watch/did%3Aplc%3Awvnsvz3tqm2de5ye2zygrphq/bafkreibcqmwpbcjbvtzrfylwhnwjssrapokcd26fwn7gyx4rhml6qbskga/thumbnail.jpg
// From the thumbnail URL, we retrieve the image's did and cid, which
// are needed for the getBlob API call.
const url_array = vid_thumbnail_url.split('/');
const did = url_array[4];
const cid = url_array[5];
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(vid_data, blob);
});
}
function send_file_to_user(img_data, 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 = convertFilename(img_data);
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;
}
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;
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);
const img_data = {
uname: uname,
username: username,
post_id: post_id,
timestamp: timestamp,
img_num: img_num
};
// Format the content we just parsed into the default filename template.
// 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, img_data);
return false;
});
};
function add_download_button_to_video(vid_elem) {
if (vid_elem == null || vid_elem.poster == null) {
return;
}
// Create a DOM element in which we'll store the download button.
let download_btn = document.createElement('div');
let download_btn_parent;
let vid_thumbnail_url = vid_elem.poster;
const html = download_button_html;
download_btn_parent = vid_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;
// 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(vid_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 = 0;
const vid_data = {
uname: uname,
username: username,
post_id: post_id,
timestamp: timestamp,
img_num: img_num
};
// Format the content we just parsed into the default filename template.
// 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_video_from_api(vid_thumbnail_url, vid_data);
return false;
});
};
function convertFilename(img_data) {
return filename_template
.replace("<%uname>", img_data.uname)
.replace("<%username>", img_data.username)
.replace("<%post_id>", img_data.post_id)
.replace("<%timestamp>", img_data.timestamp)
.replace("<%img_num>", img_data.img_num) + `.bsky`;
}
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)
}
})();
// Later, you can stop observing
//observer.disconnect();