(怪しい用途ではありますが、)Blueskyで指定のリストに登録されているユーザーの投稿画像を一括ダウンロードし、ローカルPCに保存するnode.jsスクリプトを書いてみました。
ざっくり使い方
- Blueskyのアプリパスワードを発行しておく
- こちらの記事が参考になります
- node.js (v20.15.0)を導入する
- nvm(winではvolta)とか使いましょう
- 適当にディレクトリを切って、その中に以下の3ファイルを作成する
package.json
tsconfig.json
src/index.ts
- 上記3ファイルに、当ページ後述のソースコードをまんまコピペする
- ターミナルを起動し、上記で切ったディレクトリに移動する
- winでの動確未検証です
-
% npm install
を実行する -
src/index.ts
内の定数を書き換える- あなたのアカウント名/アプリパスワード
- リストのURL(Bluesky公式Web版)
-
% npm run build
を実行する- 何かエラーが起きるかもしれないが、
dist/index.js
ができていればOK
- 何かエラーが起きるかもしれないが、
-
% npm run test
を実行する- ダウンロード
-
% npm run download
を実行する- 画像がダウンロードされます
ダウンロードスクリプト
package.json
{
"name": "bsky-image-downloader",
"version": "1.0.0",
"main": "index.js",
"scripts": {
"build": "tsc",
"test": "node dist/index.js --dry-run",
"download": "node dist/index.js"
},
"keywords": [],
"author": "moomin02",
"license": "ISC",
"description": "bluesky image downloader",
"devDependencies": {
"@types/argparse": "^2.0.16",
"@types/node": "^20.14.10",
"axios": "^1.7.2",
"typescript": "^5.5.3"
},
"dependencies": {
"@atproto/api": "^0.12.23",
"argparse": "^2.0.1",
"moment": "^2.30.1"
}
}
tsconfig.json
{
"compilerOptions": {
"target": "ESNext",
"module": "NodeNext",
"outDir": "./dist",
"rootDir": "./src",
"moduleResolution": "NodeNext",
"strict": true,
"esModuleInterop": true
},
"include": [
"src"
]
}
src/index.ts
import axios from 'axios';
import moment from 'moment';
import * as argparse from 'argparse';
import * as fs from 'fs';
import * as path from 'path';
import { AppBskyEmbedImages, BskyAgent } from '@atproto/api';
import { ListItemView } from '@atproto/api/dist/client/types/app/bsky/graph/defs';
// 後で書き換えるエリア ->
// 1. あなたのユーザー名(ログイン用メールアドレスでも可)
const USERNAME = '****.bsky.social';
// 2. あなたのアプリパスワード
const PASSWORD = '****-****-****-****';
// 3. 対象のリストURL(Bluesky Webアプリから取得)
const LIST_URL = 'https://bsky.app/profile/********.bsky.social/lists/********';
// <- 後で書き換えるエリア
moment.locale('ja');
/**
* ダウンロード対象画像にまつわる情報.
*/
interface DownloadTargetInfo {
/** フルサイズ画像URL(HTTP). */
fullsize: string,
/** サムネサイズ画像URL(HTTP). */
thumb: string,
/** ALTテキスト. */
alt: string,
/** 投稿日. */
postedDate: moment.Moment,
/** ダウンロード時のファイル名. */
outputFileName: string
}
/**
* Blueskyのアプリから取得できるリストURL(https://bsky.app/profile/???/lists/???)から
* atprotoのURLを生成する.
* @param agent atproto api エージェント.
* @param bskyUrl リストURL(https://bsky.app/profile/???/lists/??? 形式).
* @returns リストURL(atproto). 取得できなかった場合はnull
*/
async function getListUrlAsync(
agent: BskyAgent,
bskyUrl: string) {
const urlSeps = bskyUrl.split('/');
if (urlSeps.length < 3) {
return null;
}
let atUrl = 'at://{did}/app.bsky.graph.list/{listId}';
const resolveRes = await agent.resolveHandle({
handle: urlSeps[urlSeps.length - 3]
});
if (!resolveRes.success) {
return null;
}
atUrl = atUrl.replace('{did}', resolveRes.data.did);
atUrl = atUrl.replace('{listId}', urlSeps[urlSeps.length - 1]);
return atUrl;
}
/**
* 指定のリストURLからリストのメンバーに追加されている全ての投稿者(ユーザー)情報を取得する.
* @param agent atproto api エージェント.
* @param atUrl リストURL(atproto).
* @returns 投稿者(ユーザー)情報.
*/
async function getListMembersAsync(
agent: BskyAgent,
atUrl: string) {
let cursor: string | undefined;
let members: ListItemView[] = [];
do {
let res = await agent.app.bsky.graph.getList({
list: atUrl,
limit: 30,
cursor
});
cursor = res.data.cursor;
members = members.concat(res.data.items)
} while (cursor)
return members;
}
/**
* 指定のユーザーの「メディア」タブにある投稿画像を検索し、ダウンロード対象画像にまつわる情報を生成する.
* @param agent atproto api エージェント.
* @param member ダウンロードする画像の投稿者()情報.
* @returns ダウンロード対象画像にまつわる情報.
*/
async function getMediaTabEmbedImagesAsync(
agent: BskyAgent,
member: ListItemView) {
let cursor: string | undefined;
let images: DownloadTargetInfo[] = [];
do {
let res = await agent.app.bsky.feed.getAuthorFeed({
actor: member.subject.did,
filter: 'posts_with_media',
limit: 30,
cursor
});
cursor = res.data.cursor;
if (res.data.feed.length > 0) {
for (let feed of res.data.feed) {
const postMoment = moment(feed.post.indexedAt);
const embed = feed.post.embed as AppBskyEmbedImages.View;
if (embed && embed.images && embed.images.length > 0) {
let index = 0;
for (let image of embed.images) {
// MEMO: ダウンロード時のファイル名は「{投稿日}_{投稿のCID}(_{投稿内連番})?.{拡張子}」想定
let outputFileName = `${postMoment.format('YYYY-MM-DD_HH.mm.ss')}_${feed.post.cid}`;
if (embed.images.length > 1) {
outputFileName += `_${++index}`;
}
if (image.fullsize.indexOf('@png') > -1) {
outputFileName += '.png';
} else {
outputFileName += '.jpg';
}
images.push({
fullsize: image.fullsize,
thumb: image.thumb,
alt: image.alt,
postedDate: postMoment,
outputFileName
});
}
}
}
}
} while (cursor)
return images;
}
/**
* 画像をダウンロードしてローカル(指定のディレクトリ)に保存する.
* @param member ダウンロードする画像の投稿者(ユーザー)情報.
* @param images ダウンロード対象画像一覧.
* @param saveDir 保存先ディレクトリ名.
* @param dryRun dry run mode(ダウンロードなし)かどうか.
* @returns 実際にダウンロードしたファイル数.
*/
async function downloadImagesAsync(
member: ListItemView,
images: DownloadTargetInfo[],
saveDir: string,
dryRun: boolean) {
if (!dryRun && !fs.existsSync(path.join(saveDir, member.subject.handle))) {
fs.mkdirSync(path.join(saveDir, member.subject.handle), { recursive: true });
}
let downloaded = 0;
for (let i = 0; i < images.length; i++) {
const filePath = path.join(saveDir, member.subject.handle, images[i].outputFileName);
if (dryRun) {
console.log(`downloading: ${filePath} ...`);
} else if (!fs.existsSync(filePath)) {
const url = images[i].fullsize;
const response = await axios.get(url, { responseType: 'arraybuffer' });
fs.writeFileSync(filePath, response.data);
downloaded++;
}
}
return downloaded;
}
(async function () {
const argparser = new argparse.ArgumentParser({
description: 'Blueskyのリストのメンバーに追加されている全てのユーザーの投稿画像を一括ダウンロードします。'
});
argparser.add_argument('--dry-run', {
action: 'store_true',
help: 'dry run mode(ダウンロードなし)でスクリプトを実行します。'
});
const args = argparser.parse_args();
const dryRun = args.dry_run;
if (dryRun) {
console.log(`dry run modeで実行しています。実際にファイルはダウンロードされません。`);
}
const agent = new BskyAgent({
service: 'https://bsky.social'
});
await agent.login({
identifier: USERNAME,
password: PASSWORD,
});
if (!agent.session) {
console.log(`ログインに失敗しました。`);
return;
}
let listUrl: string | null = LIST_URL;
if (listUrl.indexOf('at://did:') !== 0) {
listUrl = await getListUrlAsync(agent, LIST_URL);
}
if (!listUrl) {
console.log(`対象のリストのURLの取得に失敗しました。`);
return;
}
const members = await getListMembersAsync(agent, listUrl);
if (members.length <= 0) {
console.log(`ユーザーの情報が取得できませんでした。`);
return;
}
let downloadedImageCount = 0;
for (let i = 0; i < members.length; i++) {
const images = await getMediaTabEmbedImagesAsync(agent, members[i]);
console.log(`[${i + 1}/${members.length}] ユーザー @${members[i].subject.handle} の画像をダウンロード中... (合計${images.length}ファイル)`);
// MEMO: 当プロジェクトディレクトリ直下に「downloaded_images」ディレクトリを自動生成して、
// そこにダウンロードした画像を保存していく
downloadedImageCount += await downloadImagesAsync(members[i], images, 'downloaded_images', dryRun);
}
if (!dryRun) {
console.log(`総計${downloadedImageCount}ファイルの画像をダウンロードが完了しました。`);
}
}());
補足
- フィードのURLには対応しておりません。(改造すればダウンロードできるかもしれない)
- 高画質な画像をダウンロードする場合、枚数が多いと時間がかかります。
- ミュート状態、モデレーションの設定によってダウンロード有無が変わるかは不明です。(特にその部分で判定していません。)
-
feed.post.labels[].val
による判定で、ダウンロード対象のフィルタリングができそうです。
-
- 何がとは言いませんが、とても捗りました。
- 今後、動画投稿ができるようになった場合、動画にも対応したい(願望)