Greasy Fork is available in English.

Bluesky Image Download Button

Takes coredumperror's script and removes the constant 300ms checks and adds an in-page way to adjust the filename_template.

// ==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();