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

### この記事で得られるもの
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. 全体アーキテクチャ

```mermaid
graph LR
  A["#times-* (Slack)"] -- Events API --> B["GitHub Actions : daily.yml"]
  B --> C["collect.py<br>（収集 & 変換）"]
  C --> D[("GitHub Repo<br>my-times")]
  D -- Auto Pull --> E["Obsidian Vault"]
```

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 の書き方

```yaml
# 行を追加するだけで監視チャンネルを増減
- sample-times

```

---

## 4. GitHub Actions ― `.github/workflows/daily.yml`

```yaml
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 秒スリープで自動リトライ |
| **ファイル添付** | 画像→`![]()`、その他→`[]()` に自動判定 |

<details>
<summary>クリックで完全版 <code>collect.py</code> を展開</summary>

```python
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)

```

</details>

---

## 6. Obsidian で “見る” ＆ “聞く”

1. Vault を `git clone`
    
    ```bash
    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 サンプル

```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:*` 追加 |

---