您需要先安装一个扩展,例如 篡改猴、Greasemonkey 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 Userscripts ,之后才能安装此脚本。
您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey,才能安装此脚本。
您需要先安装用户脚本管理器扩展后才能安装此脚本。
Download media from Bluesky
// ==UserScript== // @name Bluesky media downloader // @description Download media from Bluesky // @version 0.1.4 // @author sanadan <[email protected]> // @namespace https://javelin.works // @match https://bsky.app/* // @grant GM_download // @grant GM_getValue // @grant GM_setValue // @license MIT // ==/UserScript== const BMD = (function () { 'use strict' let history return { init: async function () { history = await GM_getValue('download_history', []) const observer = new MutationObserver(ms => ms.forEach(m => m.addedNodes.forEach(node => this.detect(node)))) observer.observe(document.body, { childList: true, subtree: true }) }, detect: function(node) { if (node.nodeName === "#text") return const img = node.querySelector('img[fetchpriority]') if (!img || !img.src.includes('/feed_thumbnail/')) return if (location.href.includes('/post/')) { // ポストの場合 const article = img.closest('div[data-testid]') if (article) { this.addButton(article) return } } const article2 = img.closest('div[data-testid="contentHider-post"]') if (article2) { // TLの場合 this.addButton2(article2.parentElement) } }, addButton: function (article) { if (article.querySelector('.bmd')) return // 既に追加されている const images = article.querySelectorAll('img[fetchpriority]') let sources = [] images.forEach((img) => { const count = this.countDiv(img) // console.log(`count: ${count}`) if (count < 38) { // 引用の画像ではなさそうな場合 // 2025/06/13現在、divの深さでしか引用のものか判別できていない // 通常:31/引用:38 const url = img.src.replace('/feed_thumbnail/', '/feed_fullsize/') sources.push(url) } }) const source = sources.join(',') const imageId = sources[0].match(/plc:(.+?)@/)[1] const type = sources[0].split('@')[1] const author = article.querySelector('div[role="link"]').textContent let postText = '' const post = article.children[1].children[0] if (post) { postText = this.splitPost(post.textContent) } let date = '' const dateElement = article.querySelector('a[data-tooltip]') if (dateElement) date = this.fromDate(dateElement.dataset.tooltip) else date = this.fromDate(article.children[1].children[1].children[0].textContent) const element = document.createElement('div') const postId = new URL(location.href).pathname.split('/').pop() element.innerHTML = history.includes(postId) ? this.check_svg : this.download_svg element.classList.add('bmd') element.style.cursor = 'pointer' element.dataset.source = source element.dataset.author = author element.dataset.date = date element.dataset.postText = postText element.dataset.type = type element.dataset.postId = postId element.onclick = (event) => { event.preventDefault() this.download(element) } let base = article.children[1].children[3] if (base) base = base.children[0] else base = article.children[1].children[1].children[3] base.appendChild(element) }, addButton2: function (article) { if (article.querySelector('.bmd')) return // 既に追加されている const images = article.querySelectorAll('img[fetchpriority]') let sources = [] images.forEach((img) => { const count = this.countDiv(img) console.log(`src: ${img.src}`) console.log(`count: ${count}`) if (count > 38 && count < 43) { // 引用の画像ではなさそうな場合 // 2025/06/13現在、divの深さでしか引用のものか判別できていない // 通常:40/自分の引用画像あり:43/他人の引用画像あり:44/画像なし引用に画像あり:38/リンク先OGPあり:35 const url = img.src.replace('/feed_thumbnail/', '/feed_fullsize/') sources.push(url) } }) if (sources.length === 0) { console.log('zero') return } let source = sources[0] if (images[0].closest('button')) { source = sources.join(',') } const imageId = sources[0].match(/plc:(.+?)@/)[1] const type = sources[0].split('@')[1] const authorBase = article.querySelectorAll('a[role="link"]') const author = authorBase[0].textContent let postText = '' const post = article.querySelector('div[data-testid="postText"]') if (post) { postText = this.splitPost(post.textContent) } const dateElement = article.querySelector('a[data-tooltip]') const date = this.fromDate(dateElement.dataset.tooltip) const element = document.createElement('div') const postId = new URL(dateElement.href).pathname.split('/').pop() element.innerHTML = history.includes(postId) ? this.check_svg : this.download_svg element.classList.add('bmd') element.style.cursor = 'pointer' element.dataset.source = source element.dataset.author = author element.dataset.date = date element.dataset.postText = postText element.dataset.type = type element.dataset.postId = postId element.onclick = (event) => { event.preventDefault() this.download(element) } const base = article.querySelector('div[data-testid="contentHider-post"]+div') if (base) { base.appendChild(element) return } }, splitPost: function (str) { if (str === '') return '' return this.replace(str).match(/.+?([\n!!。?]|$)/)[0].split('\n')[0] }, fromDate: function (str) { // console.log(str) const items = str.split(/[\s年月日:]/) const year = items[0] const month = ('0' + items[1]).slice(-2) const day = ('0' + items[2]).slice(-2) const hour = this.zeroPadding(items[4]) const minute = items[5].slice(0, 2) return `${year}-${month}-${day}_${hour}-${minute}` }, zeroPadding: function (str) { return ('0' + str).slice(-2) }, replace: function (str) { const invalidChars = { '#': '#', '\\': '\', '\/': '/', '\|': '|', '<': '<', '>': '>', ':': ':', '*': '*', '?': '?', '"': '"', '\u202a': '', '\u202c': '' } return str.replace(/[#\\/|<>:*?"\u202a\u202c]/gu, v => invalidChars[v]) }, download: function (element) { const sources = element.dataset.source.split(',') const author = this.replace(element.dataset.author) const date = element.dataset.date const postText = this.replace(element.dataset.postText) const type = element.dataset.type const postId = element.dataset.postId let baseName = `cg/${author}/${date}` if (postText !== '') baseName += `_${postText}` const count = sources.length element.innerHTML = this.spinner_svg sources.forEach((source, index) => { let fileName = baseName if (count > 1) fileName += `_${index + 1}` fileName += `.${type}` GM_download(source, fileName) }) if (!history.includes(postId)) { history.unshift(postId) GM_setValue('download_history', history) } element.innerHTML = this.check_svg }, countDiv: function (startElement) { let count = 0 let element = startElement while (true) { const parent = element.parentElement if (parent === null) { break } count += 1 element = parent } return count }, download_svg: ` <?xml version="1.0" encoding="UTF-8"?> <svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="18px" height="18px" viewBox="0 0 18 18" version="1.1"> <g id="surface1"> <path style="fill:none;stroke-width:2;stroke-linecap:round;stroke-linejoin:round;stroke:rgb(0%,0%,0%);stroke-opacity:1;stroke-miterlimit:4;" d="M 4 17 L 4 19 C 4 20.104167 4.895833 21 6 21 L 18 21 C 19.104167 21 20 20.104167 20 19 L 20 17 " transform="matrix(0.75,0,0,0.75,0,0)"/> <path style="fill:none;stroke-width:2;stroke-linecap:round;stroke-linejoin:round;stroke:rgb(0%,0%,0%);stroke-opacity:1;stroke-miterlimit:4;" d="M 7 11 L 12 16 L 17 11 " transform="matrix(0.75,0,0,0.75,0,0)"/> <path style="fill:none;stroke-width:2;stroke-linecap:round;stroke-linejoin:round;stroke:rgb(0%,0%,0%);stroke-opacity:1;stroke-miterlimit:4;" d="M 12 4 L 12 16 " transform="matrix(0.75,0,0,0.75,0,0)"/> </g> </svg> `, check_svg: ` <?xml version="1.0" encoding="UTF-8"?> <svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="18px" height="18px" viewBox="0 0 18 18" version="1.1"> <g id="surface1"> <path style=" stroke:none;fill-rule:nonzero;fill:rgb(0%,50.196081%,0%);fill-opacity:1;" d="M 15.484375 2.582031 L 7.675781 10.390625 L 2.515625 5.226562 L 0 7.742188 L 7.675781 15.417969 L 10.191406 12.902344 L 18 5.097656 Z M 15.484375 2.582031 "/> </g> </svg> `, spinner_svg: ` <svg style="width: 16px; height: 16px;" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><g><rect x="11" y="1" width="2" height="5" opacity=".14"/><rect x="11" y="1" width="2" height="5" transform="rotate(30 12 12)" opacity=".29"/><rect x="11" y="1" width="2" height="5" transform="rotate(60 12 12)" opacity=".43"/><rect x="11" y="1" width="2" height="5" transform="rotate(90 12 12)" opacity=".57"/><rect x="11" y="1" width="2" height="5" transform="rotate(120 12 12)" opacity=".71"/><rect x="11" y="1" width="2" height="5" transform="rotate(150 12 12)" opacity=".86"/><rect x="11" y="1" width="2" height="5" transform="rotate(180 12 12)"/><animateTransform attributeName="transform" type="rotate" calcMode="discrete" dur="0.75s" values="0 12 12;30 12 12;60 12 12;90 12 12;120 12 12;150 12 12;180 12 12;210 12 12;240 12 12;270 12 12;300 12 12;330 12 12;360 12 12" repeatCount="indefinite"/></g></svg> `, } })() BMD.init()
QingJ © 2025
镜像随时可能失效,请加Q群300939539或关注我们的公众号极客氢云获取最新地址