您需要先安装一个扩展,例如 篡改猴、Greasemonkey 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 Userscripts ,之后才能安装此脚本。
您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey,才能安装此脚本。
您需要先安装用户脚本管理器扩展后才能安装此脚本。
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.7.1 // @author KanashiiWolf, the-nelsonator, coredumperror // @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://gf.qytechs.cn/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('[aria-label="Account"]'); if (headerNode) { 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_content(img); } let vid = node.querySelector('video'); if (vid) { vid.setAttribute('processed', ''); add_download_button_to_content(vid, true); } } } }; 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", "text-align": "center" }).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('Download Button Filename Template').css({ "display": "flex", "align-items": "center", "justify-content": "center", "margin-top": "10px", "border" : "2px solid", "cursor": "pointer" }).on('click', (e) => { e.preventDefault(); button.hide(); settings.show().focus(); settings.val(filename_template); }); topbar.before(button).before(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_content_from_api(el_url, el_data) { // From the image URL, we retrieve the image's did and cid, which // are needed for the getBlob API call. const url_array = el_url.split('/'); let did, cid; if (!el_data.vid) { did = url_array[6]; // Must remove the @jpeg at the end of the URL to get the actual cid. cid = url_array[7].split('@')[0]; } else { did = url_array[4]; 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(el_data, blob); }); } function send_file_to_user(el_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(el_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_content(el, vid = false) { // 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 (el == null) { return; if (vid) { if (el.poster == null) return; } else { if (el.src == null || el.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 el_url = vid ? el.poster : el.src; if (el_url.includes('feed_thumbnail') || vid) { // 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 = el.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 (el_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 = getPostLink(el); // 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 el_num = vid ? 0 : get_img_num(el); const el_data = { uname: uname, username: username, post_id: post_id, timestamp: timestamp, el_num: el_num, vid: vid }; // 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_content_from_api(el_url, el_data); return false; }); }; function getPostLink(el) { let path = el.parentElement.innerHTML.match(post_url_regex); while (path == null) { el = el.parentElement; path = el.innerHTML.match(post_url_regex); if (el.innerHTML.includes("postThreadItem")) return window.location.pathname; } return path[0]; } function convertFilename(el_data) { let repair = filename_template.includes("<%username>"); return filename_template .replace("<%uname>", el_data.uname) .replace("<%username>", el_data.username) .replace("<%post_id>", el_data.post_id) .replace("<%timestamp>", el_data.timestamp) .replace("<%img_num>", el_data.el_num) + (repair ? `.bsky` : ''); } })(); // Later, you can stop observing //observer.disconnect();
QingJ © 2025
镜像随时可能失效,请加Q群300939539或关注我们的公众号极客氢云获取最新地址