// ==UserScript==
// @name Bluesky media downloader
// @description Download media from Bluesky
// @version 0.1.2
// @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) this.addButton2(article2.parentElement)
},
addButton: function (article) {
if (article.querySelector('.bmd')) return // 既に追加されている
const images = article.querySelectorAll('img[fetchpriority]')
let sources = []
images.forEach((img) => {
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 url = img.src.replace('/feed_thumbnail/', '/feed_fullsize/')
sources.push(url)
})
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
},
download_svg: `
<svg width="24" height="24" xmlns="http://www.w3.org/2000/svg" version="1.1" xml:space="preserve">
<g>
<title>Layer 1</title>
<path class="st0" d="m12,16.2522a1,1 0 0 0 0.70781,-0.29297l4.5,-4.5a1.00017,1.00017 0 1 0 -1.41562,-1.41328l-2.79141,2.7914l0,-8.83594a1.00078,1.00078 0 1 0 -2.00156,0l0,8.83594l-2.79141,-2.7914a1.00017,1.00017 0 0 0 -1.41562,1.41328l4.5,4.5a1,1 0 0 0 0.70781,0.29297zm-8.22187,-4.4772a1,1 0 0 1 1.22109,0.975l0,6.25078l14.00156,0l0,-6.25078a1,1 0 1 1 1.99922,0l0,7.24922a1,1 0 0 1 -1.00078,1.00078l-15.99844,0a1,1 0 0 1 -1.00078,-1.00078l0,-7.24922a1,1 0 0 1 0.77813,-0.975z" fill="#73859d" id="svg_2"/>
</g>
</svg>
`,
check_svg: `
<svg version="1.1" id="_x32_" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px" viewBox="0 0 512 512" style="width: 16px; height: 16px; opacity: 1;" xml:space="preserve">
<style type="text/css">
.st0{fill:#4B4B4B;}
</style>
<g>
<polygon class="st0" points="440.469,73.413 218.357,295.525 71.531,148.709 0,220.229 146.826,367.055 218.357,438.587
289.878,367.055 512,144.945 " style="fill: rgb(0, 128, 0);"></polygon>
</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()