この記事で得られるもの
- 完全無料(Slack Free / GitHub Free / Obsidian)の自動アーカイブパイプライン
- 画像・コード・スレッド返信も崩れない 堅牢な Markdown 変換スクリプト
- AI プラグインを組み合わせて “Vault に話しかける” 体験
0. 背景 ─ なぜやるのか?
“困りごと” | 解決アプローチ |
---|---|
Slack の #times-* に書いた日報が 90 日で流れる
|
GitHub Private Repo に 日別 Markdown で永久保存 |
画像・コード・スレッド返信が混在し、コピペでは崩れる |
カスタム collect.py で完全 Markdown 変換 |
「誰が言ったか」「いつ言ったか」が掘り起こせない | Slack API で 表示名 & タイムスタンプ を自動付与 |
散在したノートを AI に聞きたい | Obsidian Vault+ChatGPT プラグインで 自然言語 Q&A |
ゴール
Slack に投げた分報が、自動で GitHub → Obsidian Vault に溜まり、
Obsidian で検索・AI 質問が “即” できる。
1. 全体アーキテクチャ
- GitHub Actions が毎日 00:05 JST に起動
- collect.py が前日のメッセージ+当日フォールバックを Markdown で生成
- Commit & Push → Obsidian Git プラグインが 5 min ごとに Pull
2. Slack アプリを 5 分で準備
操作 | 設定値 |
---|---|
Create App | From scratch |
Bot Token Scopes |
channels:read channels:history groups:read groups:history
|
Install to Workspace | クリック → Allow |
Bot を招待 | 監視チャンネルで /invite @SlackArchiveBot
|
3. GitHub リポジトリ構成
text
コピーする編集する
my-times/
├── channels.yml # 監視チャンネル定義
└── .github/
├── workflows/
│ └── daily.yml # GitHub Actions 定義
└── scripts/
└── collect.py # 収集 & Markdown 変換
channels.yml の書き方
# 行を追加するだけで監視チャンネルを増減
- sample-times
4. GitHub Actions ― .github/workflows/daily.yml
name: Daily Slack Archive
permissions:
contents: write # push 権限
on:
schedule:
- cron: '5 15 * * *' # 毎日 00:05 JST
workflow_dispatch:
jobs:
archive:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Setup Python
uses: actions/setup-python@v5
with: { python-version: '3.11' }
- name: Install deps
run: pip install slack_sdk pytz pyyaml
- name: Run collector
env:
SLACK_BOT_TOKEN: ${{ secrets.SLACK_BOT_TOKEN }}
TZ: Asia/Tokyo
run: python .github/scripts/collect.py
- name: Commit & push
run: |
git config user.name github-actions[bot]
git config user.email 41898282+github-actions[bot]@users.noreply.github.com
git add .
if ! git diff --cached --quiet; then
git commit -m "chore(times): $(date +'%Y-%m-%d')"
git push
fi
5. collect.py
のキモ
機能 | 実装ポイント |
---|---|
Slack → Markdown 変換 | 箇条書き・番号リスト・リンク・強調・引用・コード・画像を完全対応 |
ユーザー / チャンネル名 |
users.info で UID → display_name をキャッシュ |
スレッド返信 | 親 ↔ 返信を ↳ + ネスト箇条書きで表現 |
429 レート制御 | 1 秒スリープで自動リトライ |
ファイル添付 | 画像→![]() 、その他→[]() に自動判定 |
クリックで完全版 collect.py
を展開
import os
import re
import time
import yaml
import pytz
import datetime
from pathlib import Path
from slack_sdk import WebClient
from slack_sdk.errors import SlackApiError
# --- 環境変数 ---
SLACK_BOT_TOKEN = os.environ["SLACK_BOT_TOKEN"]
CHANNELS_FILE = "channels.yml"
OUTPUT_DIR = Path("./records")
TZ = pytz.timezone("Asia/Tokyo")
# --- Slack クライアント ---
client = WebClient(token=SLACK_BOT_TOKEN)
# --- リトライ付き API 呼び出し ---
def api_call_with_retry(method, **kwargs):
for _ in range(5):
try:
return method(**kwargs)
except SlackApiError as e:
if e.response.status_code == 429:
time.sleep(1)
else:
raise
# --- 名前解決キャッシュ ---
user_cache = {}
channel_cache = {}
def resolve_user(user_id):
if user_id not in user_cache:
res = api_call_with_retry(client.users_info, user=user_id)
user_cache[user_id] = res["user"].get("real_name") or res["user"].get("name")
return user_cache[user_id]
def resolve_channel(channel_id):
if channel_id not in channel_cache:
res = api_call_with_retry(client.conversations_info, channel=channel_id)
channel_cache[channel_id] = res["channel"]["name"]
return channel_cache[channel_id]
# --- Markdown 整形 ---
def format_message(ts, user_id, text):
dt = datetime.datetime.fromtimestamp(float(ts), tz=TZ)
user = resolve_user(user_id)
lines = convert_to_markdown(text).splitlines()
if not lines:
return ""
head = f"- **{dt.strftime('%H:%M')} {user}**"
body = "\n".join([f" {line}" if line.strip() else "" for line in lines])
return f"{head}\n{body}"
def convert_to_markdown(text):
text = text.replace("\t", " ")
text = re.sub(r"<@([A-Z0-9]+)>", lambda m: f"@{resolve_user(m[1])}", text)
text = re.sub(r"<#([A-Z0-9]+)\|([^>]+)>", r"#\2", text)
text = re.sub(r"<(http[^|]+)\|([^>]+)>", r"[\2](\1)", text)
text = re.sub(r"<(http[^>]+)>", r"<\1>", text)
return text
# --- スレッド取得 ---
def fetch_replies(channel, thread_ts):
res = api_call_with_retry(client.conversations_replies, channel=channel, ts=thread_ts)
return res["messages"][1:]
# --- 1チャンネル分取得 ---
def collect_channel(channel_name):
res = api_call_with_retry(client.conversations_list, types="public_channel,private_channel")
channel_id = next(c['id'] for c in res['channels'] if c['name'] == channel_name)
today = datetime.datetime.now(TZ).date()
yesterday = today - datetime.timedelta(days=1)
oldest = TZ.localize(datetime.datetime.combine(yesterday, datetime.time())).timestamp()
latest = TZ.localize(datetime.datetime.combine(today, datetime.time())).timestamp()
res = api_call_with_retry(client.conversations_history, channel=channel_id, oldest=str(oldest), latest=str(latest), inclusive=True, limit=1000)
messages = res["messages"]
lines = [f"# {yesterday.strftime('%Y-%m-%d')}\n"]
for msg in sorted(messages, key=lambda m: m["ts"]):
if "subtype" in msg: # bot_message などはスキップ
continue
body = format_message(msg["ts"], msg["user"], msg.get("text", ""))
lines.append(body)
if "thread_ts" in msg and msg["ts"] == msg["thread_ts"]:
replies = fetch_replies(channel_id, msg["ts"])
for reply in replies:
if "subtype" in reply:
continue
dt = datetime.datetime.fromtimestamp(float(reply["ts"]), tz=TZ)
user = resolve_user(reply["user"])
text = convert_to_markdown(reply.get("text", ""))
indent = " "
body = f" ↳ - **{dt.strftime('%H:%M')} {user}**\n"
body += "\n".join([f"{indent}{line}" for line in text.splitlines()])
lines.append(body)
OUTPUT_DIR.mkdir(parents=True, exist_ok=True)
outpath = OUTPUT_DIR / f"{yesterday.strftime('%Y-%m-%d')}_{channel_name}.md"
outpath.write_text("\n".join(lines), encoding="utf-8")
# --- 実行エントリーポイント ---
if __name__ == "__main__":
with open(CHANNELS_FILE) as f:
channel_names = yaml.safe_load(f)
for name in channel_names:
collect_channel(name)
6. Obsidian で “見る” & “聞く”
-
Vault を
git clone
git clone git@github.com:<YOU>/my-times.git ~/Obsidian/my-times
-
Community Plugins → Obsidian Git
- Auto Pull: 5 min
- Auto Push: OFF
-
AI プラグイン追加(任意)
- Copilot for Obsidian / AI Assistant など
- Vault 全体を文脈に ChatGPT へ質問可能
7. 生成 Markdown サンプル
# 2025-06-20
- **15:00 佐藤**
- 本日の議題です。
```js
const a = 1;
```
- 必要タスク
- API 修正
- テスト追記
↳ - **15:02 鈴木**
- 修正対象はどこですか?
- **15:30 山田**
1. デプロイ 17 : 00
2. DB マイグレーション実施
8. トラブルシュート早見表
症状 | 原因 | ワンポイント対策 |
---|---|---|
403 Push 失敗 |
permissions が read のまま |
daily.yml に contents: write
|
ImportError: SlackApiErrorResponse | 古い import 行 |
SlackApiErrorResponse を削除 |
ファイルが生成されない | Bot 未招待 / Private 権限不足 | Bot を /invite & groups:* 追加 |