DATA-WORLD-BLOG

BlueSky myPost = local memos

👤horomi

🌚
つくりたいこと:
BlueSkyの投稿→ローカルで動かしてるmemosに転送

GitHub Actionsで定期実行しながらリモートリポに貯めていって、気が向いたらpullしてローカルmemosに取り込むか、そこまで細かく収集しないで手動でコマンド打ったら新着30件を取得するかで悩む。。。。後者でいい気もしてる。。

APIを取得するやり方がわかりやすい記事:

公式:

事前トレーニング

取得するデータの様子を観察する。

フォロワーのTLをGET

import os
import json
from dotenv import load_dotenv
from atproto import Client

# .envファイルの内容を読み込む
load_dotenv()

def fetch_user_timeline(username, password):
    client = Client(base_url='https://bsky.social')
    client.login(username, password)
    response = client.get_timeline(cursor='', limit=30)  # タイムラインを取得
    return response.json()  # レスポンスをJSON形式に変換して返す

if __name__ == "__main__":
    username = os.getenv("BLUESKY_USERNAME")
    password = os.getenv("BLUESKY_PASSWORD")

    try:
        timeline = fetch_user_timeline(username, password)
        print("Fetched timeline:", timeline)

        # 取得したデータを整形して保存
        with open('timeline_data.json', 'w', encoding='utf-8') as f:
            json.dump(json.loads(timeline), f, ensure_ascii=False, indent=4)

        print("Timeline data saved to timeline_data.json")
    except Exception as e:
        print("Error:", e)

↑このファイルを実行すると

こんな感じで保存できるようにした。

image block

これで、どこを取っていけばいいか把握できる💡

特定の人(私)のPostのみをGET

import os
import json
from dotenv import load_dotenv
from atproto import Client

# .envファイルの内容を読み込む
load_dotenv()

def fetch_author_posts(username, password, author_did):
    client = Client(base_url='https://bsky.social')
    client.login(username, password)
    data = client.get_author_feed(
        actor=author_did,
        filter='posts_and_author_threads',
        limit=30
    )
    return data.json()  # レスポンスをJSON形式に変換して返す

if __name__ == "__main__":
    username = os.getenv("BLUESKY_USERNAME")
    password = os.getenv("BLUESKY_PASSWORD")
    author_did = os.getenv("BLUESKY_AUTHOR_DID")  # .envからのDIDの読み込み

    try:
        author_posts = fetch_author_posts(username, password, author_did)
        print("Fetched author posts:", author_posts)

        # 取得したデータを整形して保存
        with open('author_posts_data.json', 'w', encoding='utf-8') as f:
            json.dump(json.loads(author_posts), f, ensure_ascii=False, indent=4)  # ここでindentを指定

        print("Author posts data saved to author_posts_data.json")
    except Exception as e:
        print("Error:", e)

↑このファイルを実行すると

こんな感じで保存できるようにした。

image block

Dockerのデータとの行き来

そろそろ目次:

コンテナ内部に入る

docker exec -it memos /bin/sh

データベースファイルのディレクトリに移動

cd /var/opt/memos

sqliteがインストールされていない場合は入れる

apk add --no-cache sqlite

SQLiteクライアントを起動

sqlite3 memos_prod.db

内容を確認するコマンド

dbのテーブル一覧表示

.tables
sqlite> が接頭辞モード

↓こうなる

/var/opt/memos # sqlite3 memos_prod.db
SQLite version 3.44.2 2023-11-24 11:41:44
Enter ".help" for usage hints.
sqlite> .tables
activity           memo_organizer     resource           user_setting     
idp                memo_relation      storage            webhook          
inbox              migration_history  system_setting   
memo               reaction           user      

memoテーブルのデータを確認

SELECT * FROM memo;
sqlite> が接頭辞モード

テーブルの値が文字列になってターミナルにどどど〜〜っと返ってくる。

1|77hR6LNcuKRNTkaffqc9tD|1|1716018699|1716018699|NORMAL|ターミナルから作業する:
- dify
- memos

さて、memosのデータはせめてobsidianに集結させた方がいいのでは。。。?
サーバー使ってないからショートカットからは飛ばせないだろうし。。。
前後関係をみながらメモするならPCで十分でもあるよな〜|PRIVATE|[]|{"property":{}}
2|PZSUdcRnBeMYk5u8pvvN9J|1|1716025194|1716025194|NORMAL|よし!リハしよう|PRIVATE|[]|{"property":{}}
3|45x8QyCMK9iEbzvWLMY4pe|1|1716036699|1716036699|NORMAL|感動。。。うまく言葉にできないけどちゃんとした形にして3chブログには投稿したい|PRIVATE|[]|{"property":{}}
4|PVRZrMfus7QywvfVqYibAj|1|1716093802|1716101864|NORMAL|今日やりたいこと:
- [x] ChatGPT subscription stopできるか
- [x] その額でサーバーを用意できるか?

memoテーブルの列名を確認

PRAGMA table_info(memo);

いい感じに表示される

0|id|INTEGER|0||1
1|uid|TEXT|1||0
2|creator_id|INTEGER|1||0
3|created_ts|BIGINT|1|strftime('%s', 'now')|0
4|updated_ts|BIGINT|1|strftime('%s', 'now')|0
5|row_status|TEXT|1|'NORMAL'|0
6|content|TEXT|1|''|0
7|visibility|TEXT|1|'PRIVATE'|0
8|tags|TEXT|1|'[]'|0
9|payload|TEXT|1|'{}'|0

いつもの場所に戻らせるコマンド

SQLiteを終了する

.exit

コンテナも終了する

exit

本題

そろそろ目次:
memosのdbカラムとblueskyのキーを照合

memosのdbとblueskyのデータをいい感じに適用させる。

memosの列BlueSkyのキー備考
uidcidユニークな識別子
creator_idauthor.did投稿の作成者のID
→memosへ同期時はbot idに書き換える
created_ts, updated_tscreated_at投稿の作成時刻と更新時刻。UNIXタイムスタンプで保存し、BlueSkyの日付をUNIXタイムスタンプに変換して使用。
→ created_tsをcreated_atにすることにした。
row_statusN/A行のステータス。デフォルトは 'NORMAL' と設定されている
contentrecord.text投稿の内容
visibilityN/A投稿の可視性
tagsN/A投稿に関連付けられたタグ
payloadN/Aその他のペイロード。必要に応じてJSON形式で保存

uit=cid
creator_id=author.did
created_ts=created_at
row_status=NULL
visiblity=PUBLIC
tags=NULL
payload="property":{}

左辺がmemos、右辺がbluesky

sql = '''
    INSERT INTO memo (uid, creator_id, created_ts, updated_ts, row_status, content, visibility, tags, payload)
    VALUES (?, ?, ?, ?, 'NORMAL', ?, 'PUBLIC', '[]', '{"property":{}}')
'''

memos botを動かす

apiというより、bot用アカウントを動かすプログラムを作った。

以下はラフにメモした記録。


作業中やたらとお世話になったコマンド:

docker ps
起動中のコンテナ一覧

docker stop memos
memosコンテナを停止

  • ローカル上でdbにデータを追加するとshm、walファイルが登場する

コンテナイメージをcommitしてイメージを作ったら意味わからなくなったときの脱出ログ:

本家のイメージと自分がcommitしたイメージ(mymemosimage)とが両方動いてるから、一旦本家のイメージだけにすべく、mymemosimageを削除してみたら反映された

docker stop memos
docker rm memos
npm run start

※ startにはあらかじめ以下のコマンドを登録している

docker start memos || docker run -d --init --name memos --publish 5230:5230 --volume ~/memos/data:/var/opt/memos neosmemo/memos:stable && echo 'Memos is running at http://localhost:5230'

バックアップはcpでコピーが無難

cp ~/memos/data/memos_prod.db ~/memos/data/memos_prod_backup.db


コンフリクトを整えてから進める:

コンテナ内データをローカルにコピーする

docker cp memos:/var/opt/memos/memos_prod.db ~/memos/data/memos_prod.db
これ結構使える

場合によってはローカルをコンテナ内へコピーしたいときもある

docker cp ~/memos/data/memos_prod.db memos:/var/opt/memos/memos_prod.db 
これ結構使える

ローカルdbの内容確認

sqlite3 ~/memos/data/memos_prod.db
.tables
.schema memo
SELECT * FROM memo;
memo:テーブル名

ローカルのsqliteの脱出はCtrl+Cを2連続


不具合であろうデータを取り除く:

指定した行(2,3)を削除

DELETE FROM memo WHERE id IN (2, 3);
id列の値が2と3

SELECT * FROM memo;
確認

リスタートしたら反映されてた!!

docker restart memos


blueskyからの取り込み時のデータを既存のmemosのデータと合わせられるように整形する

DELETE FROM memo WHERE id IN (7);
id列の値を差し替える

桁数微妙に合わせるのに苦労した。

python実行→確認→削除→python実行→確認→….繰り返し

sqlite> SELECT * FROM memo;
1|aRE7cHkNRUVcR6fdf7mF4R|1|1716563427|1716563427|NORMAL|あいうえお|PRIVATE|[]|{"property":{}}
4|42a6GbtKCWD2PMuJgm3m9t|2|1716604506|1716604506|NORMAL|blueアカウントからこんにちは|PUBLIC|[]|{"property":{}}
5|KbxNBbxGVKhJ2ShvgQZ3sm|1|1716604565|1716604565|NORMAL|heroroアカからこんにちは|PROTECTED|[]|{"property":{}}
6|nnkn54m5u7kwjffogut64gyzabtopdni|2|1716408223|1716408223|NORMAL|とりあえずここを
『とりあえずbox』にして動かしてみよう|PUBLIC|[]|{"property":{}}

sqlite> DELETE FROM memo WHERE id IN (6);

sqlite> SELECT * FROM memo;
1|aRE7cHkNRUVcR6fdf7mF4R|1|1716563427|1716563427|NORMAL|あいうえお|PRIVATE|[]|{"property":{}}
4|42a6GbtKCWD2PMuJgm3m9t|2|1716604506|1716604506|NORMAL|blueアカウントからこんにちは|PUBLIC|[]|{"property":{}}
5|KbxNBbxGVKhJ2ShvgQZ3sm|1|1716604565|1716604565|NORMAL|heroroアカからこんにちは|PROTECTED|[]|{"property":{}}
sqlite> SELECT * FROM memo;
1|aRE7cHkNRUVcR6fdf7mF4R|1|1716563427|1716563427|NORMAL|あいうえお|PRIVATE|[]|{"property":{}}
4|42a6GbtKCWD2PMuJgm3m9t|2|1716604506|1716604506|NORMAL|blueアカウントからこんにちは|PUBLIC|[]|{"property":{}}
5|KbxNBbxGVKhJ2ShvgQZ3sm|1|1716604565|1716604565|NORMAL|heroroアカからこんにちは|PROTECTED|[]|{"property":{}}
7|wjffogut64gyzabtopdni|2|1716408223|1716408223|NORMAL|とりあえずここを
『とりあえずbox』にして動かしてみよう|PUBLIC|[]|{"property":{}}

sqlite> DELETE FROM memo WHERE id IN (7);

sqlite> SELECT * FROM memo;
1|aRE7cHkNRUVcR6fdf7mF4R|1|1716563427|1716563427|NORMAL|あいうえお|PRIVATE|[]|{"property":{}}
4|42a6GbtKCWD2PMuJgm3m9t|2|1716604506|1716604506|NORMAL|blueアカウントからこんにちは|PUBLIC|[]|{"property":{}}
5|KbxNBbxGVKhJ2ShvgQZ3sm|1|1716604565|1716604565|NORMAL|heroroアカからこんにちは|PROTECTED|[]|{"property":{}}
sqlite> SELECT * FROM memo;
1|aRE7cHkNRUVcR6fdf7mF4R|1|1716563427|1716563427|NORMAL|あいうえお|PRIVATE|[]|{"property":{}}
4|42a6GbtKCWD2PMuJgm3m9t|2|1716604506|1716604506|NORMAL|blueアカウントからこんにちは|PUBLIC|[]|{"property":{}}
5|KbxNBbxGVKhJ2ShvgQZ3sm|1|1716604565|1716604565|NORMAL|heroroアカからこんにちは|PROTECTED|[]|{"property":{}}
8|kwjffogut64gyzabtopdni|2|1716408223|1716408223|NORMAL|とりあえずここを
『とりあえずbox』にして動かしてみよう|PUBLIC|[]|{"property":{}}

コンテナ内の様子も確認

docker exec -it memos /bin/sh
/usr/local/memos # cd /var/opt/memos
/var/opt/memos # sqlite3 memos_prod.db
SQLite version 3.44.2 2023-11-24 11:41:44
Enter ".help" for usage hints.

sqlite> SELECT * FROM memo;
1|aRE7cHkNRUVcR6fdf7mF4R|1|1716563427|1716563427|NORMAL|あいうえお|PRIVATE|[]|{"property":{}}
4|42a6GbtKCWD2PMuJgm3m9t|2|1716604506|1716604506|NORMAL|blueアカウントからこんにちは|PUBLIC|[]|{"property":{}}
5|KbxNBbxGVKhJ2ShvgQZ3sm|1|1716604565|1716604565|NORMAL|heroroアカからこんにちは|PROTECTED|[]|{"property":{}}
8|kwjffogut64gyzabtopdni|2|1716408223|1716408223|NORMAL|とりあえずここを
『とりあえずbox』にして動かしてみよう|PUBLIC|[]|{"property":{}}

入った🙌

image block


blueskyに通信して取得した直近5件を反映させる

def fetch_author_posts(username, password, author_did):
    client = Client(base_url='https://bsky.social')
    client.login(username, password)
    response = client.get_author_feed(
        actor=author_did,
        filter='posts_and_author_threads',
        limit=5
    )
    data = response.json()  # レスポンスをJSON形式に変換

    # 応答が文字列としてエンコードされている場合、JSONとして再解析
    if isinstance(data, str):
        data = json.loads(data)

    return data

dbのcidを参照して既に取得できているデータはスキップする
def insert_data_into_memos(db_path, post_data):
    conn = sqlite3.connect(db_path)
    cursor = conn.cursor()

    # 既存のUIDを取得
    cursor.execute("SELECT uid FROM memo")
    existing_uids = {row[0] for row in cursor.fetchall()}
    
    # 新しいデータをデータベースに挿入
    for post in post_data['feed']:
        uid_original = post['post']['cid']
        uid = uid_original[-22:]  # CIDから最後の22文字を取得 (21文字が必要な場合は-22を使用)

        if uid not in existing_uids:
            creator_id = 2
            created_ts = int(parser.parse(post['post']['record']['created_at']).timestamp())  # dateutil.parserを使用して日付を解析
            content = post['post']['record']['text']
            sql = '''
                INSERT INTO memo (uid, creator_id, created_ts, updated_ts, row_status, content, visibility, tags, payload)
                VALUES (?, ?, ?, ?, 'NORMAL', ?, 'PUBLIC', '[]', '{"property":{}}')
            '''
            cursor.execute(sql, (uid, creator_id, created_ts, created_ts, content))
            print(f"Inserted post with uid: {uid}")
        else:
            print(f"Skipped post with existing uid: {uid}")

    
...🐌...

コンフリクト対応

memos上に投稿したデータに付与されたidとスクリプト実行時に付与されたid

restartしてから実行するのが一番簡単だから、scriptに登録することにした。

"scripts": {
  "bluesky": "docker restart memos && python bluesky_authorTLposts.py && docker restart memos"
}
package.json

やっぱもくじ:

おわりに

できた流れは…

ローカルでmemosに投稿する

ちょっとblueskyを眺めたくなる

blueskyで投稿する

image block

ローカルの生活に戻る。

そういえばblueskyで投稿したこと見返したいな〜ってなる。

npm run bluesky

memosのexploreを開くと反映される

image block

そこから再びひらめいて作業をする。。。。

ローカルバンザイ 🙌


本当はThreadsとかInstagramをつなげたいけど、
blueskyの通信が結構簡単だったから今回はやってみた。

また気が向いたら拡張させた〜〜〜い❤️