Greasy Fork is available in English.

Bluesky Image Download Button

Adds a download button to Bluesky images and videos. Built off coredumperror's script with a few improvements.

このスクリプトの質問や評価の投稿はこちら通報はこちらへお寄せください。
// ==UserScript==
// @name        Bluesky Image Download Button
// @namespace   KanashiiWolf
// @match       https://bsky.app/*
// @grant       GM_setValue
// @grant       GM_getValue
// @grant       GM_info
// @grant       GM_download
// @version     1.7.8
// @author      KanashiiWolf, the-nelsonator, coredumperror
// @description Adds a download button to Bluesky images and videos. Built off coredumperror's script with a few improvements.
// @license     MIT
// ==/UserScript==

(function() {
    'use strict';

    // Filename template settings
    const defaultTemplate = "@<%username>-bsky-<%post_id>-<%img_num>";
    let filenameTemplate = GM_getValue('filename', defaultTemplate);

    const postUrlRegex = /\/profile\/[^/]+\/post\/[A-Za-z0-9]+/;

    // Download button HTML (using template literals for readability)
    const downloadButtonHTML = `
            <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">
                    <path 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>`;

    const config = {
        childList: true,
        subtree: true
    };
    let headerNode;
    let settingsButton = false;

    const waitForLoad = (mutationList, observer) => {
        for (const mutation of mutationList) {
            for (let node of mutation.addedNodes) {
                if (!(node instanceof HTMLElement)) continue;
                headerNode = node.querySelector('[aria-label="Account"]');
                if (headerNode && !settingsButton) {
                    addFilenameSettings(headerNode);
                    settingsButton = true;
                    observer.disconnect();
                }
            }
        }
    };

    const waitForContent = (mutationList, observer) => {
        for (const mutation of mutationList) {
            for (let node of mutation.addedNodes) {
                if (!(node instanceof HTMLElement)) continue;

                const img = node.querySelector('img[src^="https://cdn.bsky.app/img/feed_thumbnail"]');
                if (img) {
                    img.setAttribute('processed', '');
                    addDownloadButton(img);
                }

                const vid = node.querySelector('video[poster^="https://video.bsky.app/watch"]');
                if (vid) {
                    vid.setAttribute('processed', '');
                    addDownloadButton(vid, true);
                }
            }
        }
    };

    function addFilenameSettings(node) {
        const settingsInput = document.createElement('input');
        settingsInput.id = 'filename-input-space';
        settingsInput.style.cssText = `
                display: flex;
                align-items: center;
                justify-content: center;
                margin-top: 10px;
                text-align: center;
                display: none;`;

        settingsInput.addEventListener('keypress', (e) => {
            if (e.which === 13) {
                settingsInput.style.display = 'none';
                settingsButton.style.display = 'flex';
                filenameTemplate = settingsInput.value;
                GM_setValue('filename', filenameTemplate);
            }
        });

        const settingsButton = document.createElement('a');
        settingsButton.id = 'filename-input-button';
        settingsButton.textContent = `Download Button Filename Template v${GM_info.script.version}`;
        settingsButton.style.cssText = `
                display: flex;
                align-items: center;
                justify-content: center;
                margin-top: 10px;
                border: 2px solid;
                cursor: pointer;`;

        settingsButton.addEventListener('click', (e) => {
            e.preventDefault();
            settingsButton.style.display = 'none';
            settingsInput.style.display = 'flex';
            settingsInput.focus();
            settingsInput.value = filenameTemplate;
        });

        node.parentNode.insertBefore(settingsButton, node);
        node.parentNode.insertBefore(settingsInput, settingsButton);
    }

    const contentObserver = new MutationObserver(waitForContent);
    const settingsObserver = new MutationObserver(waitForLoad);
    settingsObserver.observe(document, config);
    contentObserver.observe(document, config);

    function downloadContent(url, data) {
        const urlArray = url.split('/');
        const did = data.isVideo ? urlArray[4] : urlArray[6];
        const cid = data.isVideo ?
            urlArray[5] :
            urlArray[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 => sendFile(data, blob));
    }

    function getExtensionFromBlob(blob) {
        // Create a mapping of common MIME types to their extensions
        const mimeTypeToExtension = {
            'image/jpeg': 'jpg',
            'image/png': 'png',
            'image/gif': 'gif',
            'image/webp': 'webp',
            'image/svg+xml': 'svg',
            'audio/mpeg': 'mp3',
            'audio/ogg': 'ogg',
            'audio/wav': 'wav',
            'video/mp4': 'mp4',
            'video/webm': 'webm',
            'video/ogg': 'ogv',
            'application/pdf': 'pdf',
            'text/plain': 'txt',
            'text/html': 'html',
            'application/json': 'json',
            'application/zip': 'zip',
            // Add more MIME types and extensions as needed
        };

        // Get the MIME type from the blob
        const mimeType = blob.type;

        // Check if the MIME type is in the mapping
        if (mimeTypeToExtension[mimeType]) {
            return mimeTypeToExtension[mimeType];
        }

        // If the MIME type is not found, try to guess the extension from the file name
        if (blob.name) {
            const fileName = blob.name;
            const lastDotIndex = fileName.lastIndexOf('.');
            if (lastDotIndex !== -1) {
                return fileName.substring(lastDotIndex + 1).toLowerCase();
            }
        }

        // If all else fails, return an empty string
        return '';
    }

    function sendFile(data, blob) {
        const filename = convertFilename(data) + `.${getExtensionFromBlob(blob)}`;
        GM_download(URL.createObjectURL(blob), filename);
    }

    function createDownloadLink() {
        let downloadLink = document.getElementById('img-download-button');
        if (!downloadLink) {
            downloadLink = document.createElement('a');
            downloadLink.id = 'img-download-button';
            document.getElementById('root').appendChild(downloadLink);
        }
        return downloadLink;
    }

    function getImageNumber(image) {
        const ancestor = image.parentElement.parentElement.parentElement.parentElement.parentElement.parentElement.parentElement;
        const postImages = ancestor.getElementsByTagName('img');
        for (let i = 0; i < postImages.length; i++) {
            if (postImages[i].src === image.src) {
                return i;
            }
        }
        return 0;
    }

    function addDownloadButton(element, isVideo = false) {
        if (element == null) return;
        let downloadBtn = document.createElement('div');
        let downloadBtnParent;
        const mediaUrl = isVideo ? element.poster : element.src;

        if (mediaUrl.includes('feed_thumbnail') || isVideo) {
            downloadBtnParent = element.parentElement.parentElement;
            downloadBtnParent.appendChild(downloadBtn);
            downloadBtn.outerHTML = downloadButtonHTML;
        } else if (mediaUrl.includes('feed_fullsize')) {
            return;
        }

        downloadBtn = downloadBtnParent.getElementsByClassName('download-button')[0];

        const postPath = getPostLink(element);
        const pathArray = postPath.split('/');
        const username = pathArray[2];
        const uname = username.split('.')[0];
        const postId = pathArray[4];
        const timestamp = new Date().getTime();
        const imageNumber = isVideo ? 0 : getImageNumber(element);

        const data = {
            uname: uname,
            username: username,
            postId: postId,
            timestamp: timestamp,
            imageNumber: imageNumber,
            isVideo: isVideo
        };

        // Prevent non-click events
        downloadBtn.addEventListener('touchstart', e => e.preventDefault());
        downloadBtn.addEventListener('mousedown', e => e.preventDefault());

        downloadBtn.addEventListener('click', e => {
            e.stopPropagation();
            downloadContent(mediaUrl, data);
            return false;
        });
    };

    function getPostLink(element) {
        const sep = element.src ? element.src : element.poster;
        let path = element.parentElement.innerHTML.split(sep)[0].match(postUrlRegex);
        while (path == null) {
            element = element.parentElement;
            path = element.innerHTML.split(sep)[0].match(postUrlRegex);
            if (element.innerHTML.includes("postThreadItem")) {
                return window.location.pathname;
            }
        }
        return path[0];
    }

    function convertFilename(data) {
        return filenameTemplate
            .replace("<%uname>", data.uname)
            .replace("<%username>", data.username)
            .replace("<%post_id>", data.postId)
            .replace("<%timestamp>", data.timestamp)
            .replace("<%img_num>", data.imageNumber);
    }
})();