您需要先安装一个扩展,例如 篡改猴、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.2 // @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://gf.qytechs.cn/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('[data-testid="followingFeedPage-feed-flatlist"]'); headerNode = node.querySelector('[aria-label="View your feeds and explore more"]').parentElement; 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); // 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, 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();
QingJ © 2025
镜像随时可能失效,请加Q群300939539或关注我们的公众号极客氢云获取最新地址