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

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

## ざっくり使い方
1. Blueskyのアプリパスワードを発行しておく
    - [こちらの記事](https://docs.tokimeki.blue/ja/apppassword)が参考になります
1. node.js (v20.15.0)を導入する
    - nvm（winではvolta）とか使いましょう
1. 適当にディレクトリを切って、その中に以下の3ファイルを作成する
    - `package.json`
    - `tsconfig.json`
    - `src/index.ts`
1. 上記3ファイルに、当ページ後述のソースコードをまんまコピペする
1. ターミナルを起動し、上記で切ったディレクトリに移動する
    - winでの動確未検証です
1. `% npm install`を実行する
1. `src/index.ts`内の定数を書き換える
    - あなたのアカウント名/アプリパスワード
    - リストのURL（Bluesky公式Web版）
1. `% npm run build`を実行する
    - 何かエラーが起きるかもしれないが、`dist/index.js`ができていればOK
1. `% npm run test`を実行する
    - ダウンロード
1. `% npm run download`を実行する
    - 画像がダウンロードされます

## ダウンロードスクリプト

 ``` json: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"
  }
}

 ```

 ``` json:tsconfig.json
{
  "compilerOptions": {
    "target": "ESNext",
    "module": "NodeNext",
    "outDir": "./dist",
    "rootDir": "./src",
    "moduleResolution": "NodeNext",
    "strict": true,
    "esModuleInterop": true
  },
  "include": [
    "src"
  ]
}

 ```

 ``` typescript: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`による判定で、ダウンロード対象のフィルタリングができそうです。
- 何がとは言いませんが、とても捗りました。
- 今後、動画投稿ができるようになった場合、動画にも対応したい（願望）