1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

毎日の “学び” を自動で **知的資産化** する完全ガイド

Posted at

この記事で得られるもの

  1. 完全無料(Slack Free / GitHub Free / Obsidian)の自動アーカイブパイプライン
  2. 画像・コード・スレッド返信も崩れない 堅牢な Markdown 変換スクリプト
  3. 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. 全体アーキテクチャ

  1. GitHub Actions が毎日 00:05 JST に起動
  2. collect.py が前日のメッセージ+当日フォールバックを Markdown で生成
  3. 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 で “見る” & “聞く”

  1. Vault を git clone

    git clone git@github.com:<YOU>/my-times.git ~/Obsidian/my-times
    
  2. Community Plugins → Obsidian Git

    • Auto Pull: 5 min
    • Auto Push: OFF
  3. 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 失敗 permissionsread のまま daily.ymlcontents: write
ImportError: SlackApiErrorResponse 古い import 行 SlackApiErrorResponse を削除
ファイルが生成されない Bot 未招待 / Private 権限不足 Bot を /invitegroups:* 追加

1
0
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
1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?