Blueskyのタイムラインを自宅サーバで自動更新で表示する仕組み

AD

自宅ではなく正確にはさくらのVPSを使っているのだけど、メールサーバのリソースが余っていてもったいないのでBlueskyのタイムラインを自動更新で垂れ流すWebアプリを作ってみました。

在宅勤務で仕事している時にサブディスプレイに垂れ流すために自作しました!

AD

できること

「ぱるさん専用のBlueskyビューア」 の仕様は以下のとおり。

  1. リポスト除外: 他人の拡散情報をカットし、オリジナル投稿のみを表示。
  2. 他人宛の投稿除外: フォローしていない人への返信(リプライ)を表示しない。
  3. 画像対応: サムネイル表示 & クリックで高画質表示。
  4. 完全自動: Webブラウザで開いておくだけで、60秒ごとに最新情報を垂れ流し。

前提条件

  • リバースプロキシとしてのnginxが動作していること

事前準備

アプリパスワードの発行

Bluesky設定 > プライバシーとセキュリティ > アプリパスワード > 「アプリパスワードを追加」

動作させるための環境を構築する。

# プロジェクト用ディレクトリ作成
mkdir ~/bsky_viewer
cd ~/bsky_viewer

# 仮想環境の作成と有効化
python3 -m venv venv
source venv/bin/activate

# FlaskとBluesky公式ライブラリのインストール
pip install flask atprotomkdir bsky_viewer

アプリへのリバースプロキシとなるnginxの準備

以下は必要最小限の設定の抜き出しだからこのままでは動きません。実際にはSSLの設定や接続元のIPアドレス制限などを入れていますよ!

server {
    server_name サーバのFQDN;

    location / {
        # Pythonアプリへの転送
        proxy_pass http://127.0.0.1:15000;
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;
    }
}

アプリなどの作成

アプリケーション(~/bsky_viewer/app.py) を作成

from flask import Flask, render_template
from atproto import Client
import datetime

app = Flask(__name__)

# ▼▼▼ ユーザー名とアプリパスワードをここに記入 ▼▼▼
USERNAME = 'ユーザー名.bsky.social'
PASSWORD = 'xxxx-xxxx-xxxx-xxxx'
# ▲▲▲▲▲▲▲▲▲▲▲▲▲▲▲▲▲▲▲▲▲▲▲▲▲▲

# 投稿から画像を取り出すための関数
def get_images_safe(post):
    if not post.embed:
        return []
    if hasattr(post.embed, 'images'):
        return post.embed.images
    if hasattr(post.embed, 'media') and hasattr(post.embed.media, 'images'):
        return post.embed.media.images
    return []

@app.route('/')
def timeline():
    try:
        client = Client()
        client.login(USERNAME, PASSWORD)
        
        # フィルタリングで減る分を見越して少し多めに取得
        timeline = client.get_timeline(algorithm='reverse-chronological', limit=60)
        
        filtered_feed = []
        
        for item in timeline.feed:
            # データ欠損がある投稿はスキップ
            if not hasattr(item, 'post') or not hasattr(item.post, 'author'):
                continue

            # リポストを除外する処理
            # item.reason が存在する場合、それは誰かがリポストしたもの
            if item.reason:
                continue

            # 1. 返信ではない(普通の投稿)場合
            if item.reply is None:
                filtered_feed.append(item)
                continue
            
            # 返信先のデータ欠損チェック
            if not item.reply.parent or not hasattr(item.reply.parent, 'author'):
                # 返信元が不明なものは表示
                filtered_feed.append(item)
                continue

            # 2. 返信(Reply)の場合のフィルタリング
            parent_author = item.reply.parent.author
            
            # 「返信先の相手」を自分がフォローしているかチェック
            is_following_parent = False
            if hasattr(parent_author, 'viewer') and parent_author.viewer and parent_author.viewer.following:
                is_following_parent = True
            
            # フォローしている人同士の会話なら表示
            if is_following_parent:
                filtered_feed.append(item)
        
        return render_template('index.html', 
                               feed=filtered_feed, 
                               last_updated=datetime.datetime.now().strftime('%H:%M:%S'),
                               get_images=get_images_safe)

    except Exception as e:
        return f"Error: {str(e)}"

if __name__ == '__main__':
    app.run(host='127.0.0.1', port=15000)

表示用テンプレート(~bsky_viewer/templates/index.html)を作成

<!DOCTYPE html>
<html lang="ja">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Bluesky TL</title>
    <style>
        body { 
            font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; 
            max-width: 600px; 
            margin: 0 auto; 
            padding: 20px; 
            background: #f0f2f5; 
            color: #333; 
        }
        .post { 
            background: white; 
            padding: 15px; 
            margin-bottom: 15px; 
            border-radius: 12px; 
            box-shadow: 0 2px 4px rgba(0,0,0,0.05); 
        }
        .author { 
            font-weight: bold; 
            color: #0085ff; 
            margin-bottom: 5px; 
            font-size: 0.95em; 
        }
        .content { 
            white-space: pre-wrap; 
            line-height: 1.5; 
            font-size: 1em; 
            word-break: break-word;
        }
        /* 画像表示用のスタイル */
        .post-images {
            display: flex;
            gap: 8px;
            margin-top: 10px;
            overflow-x: auto;
            padding-bottom: 5px;
        }
        /* リンク(aタグ)のスタイル */
        .post-images a {
            display: block;
            flex-shrink: 0;
            cursor: zoom-in; /* カーソルを虫眼鏡マークに */
        }
        .post-images img {
            height: 200px;
            border-radius: 8px;
            border: 1px solid #eee;
            object-fit: cover;
        }
        .meta { 
            font-size: 0.8em; 
            color: #888; 
            margin-top: 10px; 
            text-align: right; 
            border-top: 1px solid #eee; 
            padding-top: 5px;
        }
        #status { 
            position: fixed; 
            top: 10px; 
            right: 10px; 
            background: rgba(0,0,0,0.7); 
            color: white; 
            padding: 6px 12px; 
            border-radius: 20px; 
            font-size: 0.75em; 
            backdrop-filter: blur(5px); 
            z-index: 1000;
        }
    </style>
    <script>
        // 60秒ごとにリロード
        setTimeout(() => window.location.reload(), 60000);
    </script>
</head>
<body>
    <div id="status">更新: {{ last_updated }} (60秒毎自動更新)</div>
    
    {% for item in feed %}
    <div class="post">
        <div class="author">{{ item.post.author.display_name or item.post.author.handle }}</div>
        
        <div class="content">{{ item.post.record.text }}</div>
        
        {% set images = get_images(item.post) %}
        {% if images %}
        <div class="post-images">
            {% for img in images %}
                <a href="{{ img.fullsize }}" target="_blank" rel="noopener noreferrer">
                    <img src="{{ img.thumb }}" alt="post image" loading="lazy">
                </a>
            {% endfor %}
        </div>
        {% endif %}
        
        <div class="meta">❤️ {{ item.post.like_count or 0 }} &nbsp; 🔁 {{ item.post.repost_count or 0 }}</div>
    </div>
    {% endfor %}
</body>
</html>

サービスとして動作させる

設定ファイル(/etc/systemd/system/bsky-viewer.service)の作成

[Unit]
Description=Bluesky Timeline Viewer
After=network.target

[Service]
# アプリを実行するユーザー名(例だからrootだけどホントは違う)
User=root

# 作業ディレクトリ(app.py がある場所)
WorkingDirectory=/root/bsky_viewer

# 実行コマンド(仮想環境の python をフルパスで指定)
# ※ templatesフォルダ等を正しく読み込むために必要
ExecStart=/root/bsky_viewer/venv/bin/python app.py

# アプリが落ちたら自動で再起動する設定
Restart=always

[Install]
WantedBy=multi-user.target

サービスの有効化と起動

# 設定ファイルの読み込み
sudo systemctl daemon-reload

# 自動起動の有効化
sudo systemctl enable bsky-viewer

# サービスの起動
sudo systemctl start bsky-viewer

注意点

アプリパスワードをハードコーディングしているのでセキュリティ上非常に好ましくない作りです。環境変数から読み込ませるべきだけど、自分用だしIPアドレス制限で固めているので楽をしちゃってますw

ひとことコメント コメント欄以外は任意入力です