PythonでSlackの日報を集計し、WordPressブログに学習時間を自動表示

Slackに投稿した日報から今週の学習時間を集計し、目標とともにWordPressの右下へ表示する機能を作成しました。Pythonによるデータ取得から、REST APIでの連携、投稿をきっかけとした自動更新まで、構成と実装の要点をまとめます。

全体の構成

  1. Slackの日報・目標チャンネルに投稿する。
  2. Macのバックグラウンドで動かしているPythonが投稿を検知する。
  3. 今週の学習時間と最新の目標を取得し、WordPressのREST APIに送信する。
  4. WordPressの独自プラグインで保存し、本ブログに表示する。

集計はPython、保存と表示はWordPressが担当。
PythonはMac上で実行し、WordPressには自作のプラグインをアップロードしました。

作成の目的

現在参加中のスクールでは毎週学習の合計時間と、良かった点・反省点をslackで週報として提出しています。
いつもは1週間分の日報をさかのぼり、時間を計算・勉強内容を思い出す作業を日曜に行なっていました。
そこで、合計時間が自動で計算されていると便利だと思ったのが今回の機能作成の経緯です。
また、私自身、他の受講生さんの週報を見ていつも刺激を受けているので、私の学習記録も見てくださった方が、一緒に頑張るきっかけになれば嬉しいと思い本ブログに載せようと思いました。

完成図

Slackの準備

学習記録用のワークスペースを作成し、Pythonから投稿を取得するための「study-progress」というSlackアプリを追加しました。

「学習用」という自分専用のワークスペースを立ち上げ、アプリをインストールし、「日報」「今週の目標」チャンネルを作成
  1. 自分専用のワークスペースを作成する。
  2. 「日報」と「今週の目標」のチャンネルを作成する。
  3. Slackアプリを作成し、メッセージの取得に必要な権限を設定する。
  4. アプリをワークスペースにインストールし、各チャンネルに追加する。

発行されたBotトークンと各チャンネルのIDを、Python側の.envに保存します。

ファイル構成

主要なファイルと役割は以下のとおりです。

study-progress/
├── .env                      # 認証情報・設定
├── python/
│   ├── check_slack.py         # Slackへの接続確認
│   ├── extract_time.py        # 学習時間の抽出・合計
│   ├── week_range.py          # 今週の集計範囲を計算
│   ├── fetch_messages.py      # 日報の取得・週合計の算出
│   ├── fetch_goal.py          # 最新の目標を取得
│   ├── sync_progress.py       # WordPressへ送信
│   └── watch_slack.py         # 投稿を検知して同期
└── wordpress/
    └── study-progress/
        └── study-progress.php # データの受信・保存・表示

PythonはMac上で実行します。WordPressへアップロードするのは、wordpress/study-progress/内のプラグインです。表示用のCSS・JavaScriptも、このプラグイン内で管理します。

使用したライブラリ

  • slack-sdk:Slackのメッセージを取得
  • slack-bolt:Slackの投稿イベントを受信
  • python-dotenv.envから設定を読み込む
  • tzlocal:Macに設定されたタイムゾーンを取得
  • requests:WordPressにデータを送信

その他標準ライブラリのunicodedata(半角全角統一)、re(時間の正規表現)、datetime(日付の計算)を使用しました。

学習時間の集計ルール

日報は次の形式で投稿しています。

◻︎今日行ったこと(2h)
Pythonの復習
WordPressとの連携

「今日行ったこと」の後ろにある括弧内の時間を正規表現で抽出しました。日々の表記揺れに対応するために、全角・半角の混在や、小数、見出しと括弧の間の空白・改行があっても認識できるようにしました。

# 日報から学習時間を抽出する関数
def extract_hours(text):
    normalized_text = unicodedata.normalize("NFKC", text)

    match = re.search(
        r"今日行ったこと[\s\-]*\(\s*(\d+(?:\.\d+)?)\s*h\s*\)",
        normalized_text,
        flags=re.IGNORECASE,
    )

    if match:
        return float(match.group(1))

    return None

また、夜行性のため深夜作業になりがちなことや、目標をまめに更新できない可能性があるため、下記ルールを決めました。

  • 1週間は月曜始まり、日曜まで。
  • 午前8時より前の投稿は、前日の学習として扱う。
  • 合計時間の集計範囲は月曜8時始まり、翌月曜8時まで。
  • 目標は週で絞り込まず、最新の投稿内容を使用する。
def get_week_range():
    # Macのタイムゾーンで現在日時を取得
    timezone = get_localzone()
    now = datetime.now(timezone)

    # 朝8時より前なら、前日の分とする
    study_date = now.date()

    if now.hour < 8:
        study_date -= timedelta(days=1)

    # その週の月曜日を求める
    monday = study_date - timedelta(days=study_date.weekday())

    # 月曜朝8時から、翌月曜朝8時まで
    start = datetime.combine(monday, time(hour=8), tzinfo=timezone)
    end = datetime.combine(
        monday + timedelta(days=7),
        time(hour=8),
        tzinfo=timezone,
    )

    return start, end

更新時は今週の日報を取得し直し、合計を再計算します。保存済みの合計に加算する方式ではないため、誤投稿した際は削除して再投稿すると正しい時間が計算されるようにしました。

# 日報から1週間の学習時間の合計を計算する関数
def calculate_total_hours(messages):
    total_hours = 0

    for message in messages:
        text = message.get("text", "")

        # 日報ではない投稿は対象外
        if "今日行ったこと" not in text:
            continue

        hours = extract_hours(text)

        # 日報なのに時間を読めなければ、集計を止める
        if hours is None:
            raise ValueError("時間を読み取れない日報があります。")

        total_hours += hours

    return total_hours

WordPressへの保存と表示

独自プラグインに更新用のREST APIを用意し、Pythonから次の形式のJSONを送信します。
goalとtotal_hours関数を実行した結果が入り送られます。

def sync_progress():
    try:
        # 関数を実行して、結果を変数に入れる
        goal = fetch_goal()
        total_hours = fetch_total_hours()

        # WordPressに送るデータ
        data = {
            "goal": goal,
            "total_hours": total_hours,
        }

        response = requests.post(
            url,
            json=data,
            auth=(username, password),
            timeout=20,
            allow_redirects=False,
        )

        print("HTTPステータス:", response.status_code)

        # 400番台・500番台の応答ならエラーにする
        response.raise_for_status()

通信にはHTTPSとWordPressのアプリケーションパスワードを使用し、更新用APIではWordpressユーザーの管理権限を確認するようにしました。

日報の原文はSlackに残るので、WordPress側では、目標・合計時間・更新日時を上書き保存にし、過去の記録は残さないようにしました。

WordPressのダッシュボードでの表示。プラグインを有効にするだけでブログに表示されるので管理画面での操作不要。

投稿をきっかけに自動更新

SlackのSocket Modeで投稿イベントを受信します。対象の日報・目標チャンネルに新しい投稿があった場合に、集計とWordPressへの送信を実行します。

# メッセージのイベントが届くと、この関数が実行される
bot_token = os.getenv("SLACK_BOT_TOKEN")
app_token = os.getenv("SLACK_APP_TOKEN")
report_channel = os.getenv("SLACK_REPORT_CHANNEL_ID")
goal_channel = os.getenv("SLACK_STUDY_GOAL_CHANNEL_ID")

if not all([bot_token, app_token, report_channel, goal_channel]):
    raise SystemExit(".env のSlackトークンとチャンネルIDを確認してください。")

app = App(token=bot_token)
# 連続で投稿があっても同時に処理しない
sync_lock = Lock()
@app.event("message")
def handle_message(event):
    channel_id = event.get("channel")

    # 日報・目標チャンネルだけを対象にする
    if channel_id not in (report_channel, goal_channel):
        return

    # 今回は通常の新規投稿だけを確認する
    if event.get("subtype") or event.get("bot_id"):
        return

    if channel_id == report_channel:
        print("日報チャンネルの投稿を受信しました!", flush=True)
    else:
        print("目標チャンネルの投稿を受信しました!", flush=True)

    # 更新処理を1回ずつ順番に実行する
    with sync_lock:
        sync_progress()

Macのlaunchdで監視用のPythonをログイン時に起動し、毎回ターミナルから実行する手間を省きました。

運用上の注意点

  • Macがスリープ中・電源オフの間は更新できない。
  • 新規投稿が更新のきっかけ。投稿の編集・削除や週の切り替わりだけでは更新されない。
  • スマホで投稿しても更新されない
  • 反映したい場合は、python/watch_slack.py を手動で実行する。
source .venv/bin/activate
python python/sync_progress.py

参考ドキュメント