Adds a download button to Bluesky images and videos. Built off coredumperror's script with a few improvements.
< Feedback on Bluesky Image Download Button
Fixed switching between Posts/Replies/Media:
// ==UserScript==
// @name Bluesky Image Download Button
// @namespace KanashiiWolf
// @match https://bsky.app/*
// @grant GM_setValue
// @grant GM_getValue
// @version 1.0
// @author KanashiiWolf
// @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
// @downloadURL https://update.greasyfork.org/scripts/513021/Bluesky%20Image%20Download%20Button.user.js
// @updateURL https://update.greasyfork.org/scripts/513021/Bluesky%20Image%20Download%20Button.meta.js
// ==/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
* <%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 = GM_getValue('filename', "[bluesky] <%uname>-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 targetNode, headerNode, targetSpotted = false, headerSpotted = false;
const waitForLoad = (mutationList, observer) => {
for (const mutation of mutationList) {
for(let node of mutation.addedNodes) {
if (!(node instanceof HTMLElement)) continue;
// targetNode = node.querySelector('.r-1d5kdc7 > div:nth-child(1) > div:nth-child(1) > div:nth-child(1) > div:nth-child(4) > div:nth-child(1) > div:nth-child(1) > div:nth-child(1) > div:nth-child(2)');
targetNode = node.querySelector('.r-1d5kdc7 .css-146c3p1.r-1xnzce8 + div'); // Single post page
if(!targetNode) {
targetNode = node.querySelector("div:has(> div.r-1ljd8xs)"); // Multiple post page (account page)
if(!targetNode) {
targetNode = node.querySelector("div.r-1ljd8xs:nth-child(2)"); // Multiple post page (search results)
}
}
headerNode = node.querySelector('div.r-1ljd8xs:nth-child(1)');
if (targetNode && !targetSpotted) {
imgObserver.observe(targetNode,config);
targetSpotted = true;
}
if (headerNode && !headerSpotted) {
filenamingSettings(headerNode);
headerSpotted = true;
}
if (headerNode && targetNode) 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").hide().keypress((e) => {
if (e.which == 13) {
settings.hide();
button.show();
filename_template = settings.val();
GM_setValue('filename', settings.val());
}
});
let button = $('<a>').text('Filenaming').on('click', (e) => {
e.preventDefault();
button.hide();
settings.show().focus();
settings.val(filename_template);
});
topbar.append(button).append(settings);
}
// Create an observer instance linked to the callback function
const imgObserver = new MutationObserver(waitForImg);
const waiter = new MutationObserver(waitForLoad);
waiter.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,
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,
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("<%post_id>", img_data.post_id)
.replace("<%timestamp>", img_data.timestamp)
.replace("<%img_num>", img_data.img_num);
}
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();
Added rerunning observer on page change (i.e. when you click into another account, or onto a post) because this is a Single Page Application (SPA) so it doesn't retrigger script. Simplified targetNode selector to work on all pages (single post page, account page tabs, home page tabs, and search).
// ==UserScript==
// @name Bluesky Image Download Button
// @namespace KanashiiWolf
// @match https://bsky.app/*
// @grant GM_setValue
// @grant GM_getValue
// @version 1.0
// @author KanashiiWolf
// @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
// @downloadURL https://update.greasyfork.org/scripts/513021/Bluesky%20Image%20Download%20Button.user.js
// @updateURL https://update.greasyfork.org/scripts/513021/Bluesky%20Image%20Download%20Button.meta.js
// ==/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
* <%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 = GM_getValue('filename', "[bluesky] <%uname>-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 targetNode, headerNode, targetSpotted = false, headerSpotted = false;
const waitForLoad = (mutationList, observer) => {
for (const mutation of mutationList) {
for(let node of mutation.addedNodes) {
if (!(node instanceof HTMLElement)) continue;
// targetNode = node.querySelector('.r-1d5kdc7 > div:nth-child(1) > div:nth-child(1) > div:nth-child(1) > div:nth-child(4) > div:nth-child(1) > div:nth-child(1) > div:nth-child(1) > div:nth-child(2)');
targetNode = node.querySelector(".r-13awgt0:has(> .r-1d5kdc7)");
headerNode = node.querySelector('div.r-1ljd8xs:nth-child(1)');
if (targetNode && !targetSpotted) {
console.log(targetNode);
imgObserver.observe(targetNode,config);
targetSpotted = true;
}
if (headerNode && !headerSpotted) {
filenamingSettings(headerNode);
headerSpotted = true;
}
if (headerNode && targetNode) 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").hide().keypress((e) => {
if (e.which == 13) {
settings.hide();
button.show();
filename_template = settings.val();
GM_setValue('filename', settings.val());
}
});
let button = $('<a>').text('Filenaming').on('click', (e) => {
e.preventDefault();
button.hide();
settings.show().focus();
settings.val(filename_template);
});
topbar.append(button).append(settings);
}
// Create an observer instance linked to the callback function
const imgObserver = new MutationObserver(waitForImg);
const waiter = new MutationObserver(waitForLoad);
waiter.observe(document, config);
// Redo on SPA page change, which doesn't retrigger script
let previousUrl = window.location.href;
const pageChangeObserver = new MutationObserver(() => {
if (window.location.href !== previousUrl) {
console.log(`URL changed from ${previousUrl} to ${window.location.href}`);
previousUrl = window.location.href;
targetNode = null;
headerNode = null;
targetSpotted = false;
headerSpotted = false;
waiter.disconnect();
waiter.observe(document, config);
}
});
pageChangeObserver.observe(document, { subtree: true, childList: true });
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,
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,
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("<%post_id>", img_data.post_id)
.replace("<%timestamp>", img_data.timestamp)
.replace("<%img_num>", img_data.img_num);
}
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();
Added you as an author so that you can just simply update with your code revisions!
Added video support since the same getBlob API seems to work for videos, and the did and cid seem to be present in video thumbnail ("poster" attribute). Moved the download button to top left as to not be in the way of video pause/time elements. Updated default template format for my personal preference.