5
4

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

この記事誰得? 私しか得しないニッチな技術で記事投稿!
Qiita Engineer Festa20242024年7月17日まで開催中!

Blueskyで指定のリストに登録されているユーザーの投稿画像を一括ダウンロードする

Last updated at Posted at 2024-07-13

(怪しい用途ではありますが、)Blueskyで指定のリストに登録されているユーザーの投稿画像を一括ダウンロードし、ローカルPCに保存するnode.jsスクリプトを書いてみました。

ざっくり使い方

  1. Blueskyのアプリパスワードを発行しておく
  2. node.js (v20.15.0)を導入する
    • nvm(winではvolta)とか使いましょう
  3. 適当にディレクトリを切って、その中に以下の3ファイルを作成する
    • package.json
    • tsconfig.json
    • src/index.ts
  4. 上記3ファイルに、当ページ後述のソースコードをまんまコピペする
  5. ターミナルを起動し、上記で切ったディレクトリに移動する
    • winでの動確未検証です
  6. % npm installを実行する
  7. src/index.ts内の定数を書き換える
    • あなたのアカウント名/アプリパスワード
    • リストのURL(Bluesky公式Web版)
  8. % npm run buildを実行する
    • 何かエラーが起きるかもしれないが、dist/index.jsができていればOK
  9. % npm run testを実行する
    • ダウンロード
  10. % 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による判定で、ダウンロード対象のフィルタリングができそうです。
  • 何がとは言いませんが、とても捗りました。
  • 今後、動画投稿ができるようになった場合、動画にも対応したい(願望)
5
4
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
5
4

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?