4
3

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

Discordからフリーカレンダーアプリに予定入力するBOTで鳥頭を脱却した

4
Posted at
読まなくても問題ない背景 皆様、TRPGやマーダーミステリーをご存じですか?(話したいだけ) 筆者はTRPGやボードゲーム、オンラインゲームなどDiscordを使ってオンラインで遊ぶゲームがとても好きです。みんなで予定を立てて、準備をして……それぞれ当日集まって……と、当日までの準備やワクワク感も好きなんですよね!約束を忘れないために、フリーカレンダー(フリカレ)にちゃんと予定を入れて、カレンダーを確認して……

TRPGって?

TRPG(テーブルトーク・ロールプレイングゲーム)は人との会話とルールを使って遊ぶ「対話型」のRPGです。
 クトゥルフ神話TRPGでは、基本的に「キーパー」と呼ばれるゲームの進行役が用意した「シナリオ」をベースに進行し、プレイヤーは自分で作ったキャラクターにどんな行動をさせるか考え、互いに会話をしながら物語を創っていきます。

というように、TRPGとは要するに サイコロやランダム性を使う壮大な「ごっこ遊び」 です。異世界であんな武器を持ってこんな敵を倒してヒロインを救うとか……皆、学生時代に退屈な授業を受けている時や映画を見終わって余韻を味わってる時なんかに想像したりしませんか?きっと皆しましたよね!

近年ではccforia等のサービスを使ってオンラインで遊ぶことが多く、平日の21時~とか、土日の13時~とか、時間や場所で縛られない遊び方が出来るようになりました!

https://product.kadokawa.co.jp/cthulhu/play/coc-play/entry-1021147.html

マダミスって?

マダミスとは?という方はこちらの記事でざっくり書いているので、お時間が許せばこちらも見て頂ければ嬉しいです!(ダイレクトマーケティング)

https://qiita.com/penguin3-dev/items/35a0919bd398e91ed325

フリカレ(フリーカレンダー)って?

自由に書き込みできるフリーフォーマット、自動保存ができるWEBカレンダーです。 ビジネス手帳の代わりになります。情報は高度なセキュリティーによって保護されています。 本格的なビジネス用途から個人メモや飲み会などの一時用途まで柔軟にカバーできます。 月間、週間の表示の出力が可能です。スパンを切った目標管理、スケジュール管理が可能です。 IDをチーム、クラブ、同窓会などで共有すれば予定調整ツールとしても使えます。 公開したいページだけをブログ、ホームページ等へ貼り付け可能です。 オリジナルデザインも作成できます。また、デザインしたカレンダーを公開可能です。

TRPG界隈等でも幅広く使われている、滅茶苦茶便利なカレンダーです!
シンプルかつ動作はかなり軽く、公開設定をしたらだれでも閲覧が出来る!というカレンダーです。日程調整や自分のスケジュール管理でお世話になっております……(手こねこね)

https://freecalend.com/

問題発生!!

友人「〇月×日夜空いてたら遊ぼ!」
筆者「わかった!楽しみ!」
友人「やった~~!約束ね!」

~後日~
友人「何時に集まる?」
筆者「エッ!??!??!!ア!!??ア゛!!」(フリカレの入力漏れに気付く)

ミス数はそこまで多くないものの、「スケジュール、入力漏れしてますよ!」と指摘していただくことが全然0ではなく……関わっていただいている皆様ありがとうございます……orz
特に携帯でDiscordやLINEの連絡を受け、後でフリカレに登録しなきゃな~~と後々の自分に任せてごろんと布団で横になったらすっかり忘却の彼方に……というような、私はスーパー忘れっぽい鳥頭なのです……

そ、そういうヒューマンエラー防止のためにも……!!曲がりなりにも私はエンジニアだ……!!(頭で気を付けるのは勿論)テクノロジーで解決するぞ……!!!(´・ω・`)

対象読者について

  • Discordはいつも触ってる+DiscordBotの存在は知っていて、なんとなく知ってる!でもPlaywrightやスクレイピングについては触ったことがない……という方
  • Playwrightとかスクレイピング、技術としては有名だけどどういうときに使うモノなの?という方
  • Playwrightと仲良くなれていない方

上記のような方を主な読者に想定しています。
Dockerのマルチステージビルドやuvでのパッケージ管理等、スクレイピングと直接関係しない部分は詳しく説明していません。ご容赦くださいませ。🙇

本題

注意:
フリカレは公式APIを提供していないため、本記事の方法はブラウザ自動操作による非公式なアプローチです。フリカレ側のDOM構造や仕様の変更で予告なく動かなくなる可能性があります。個人・サークル内での利用にとどめ、サービスに過度な負荷をかけない範囲で、自己責任のもと利用してください。

予定入れ忘れ問題の本質

私のボロ携帯からのフリカレアプリ・ブラウザ操作、少し動作が重く、動きももったりしてます。登録したつもりでアプリを閉じたら登録できてなかったり……;;
そういうことが度々あると、「ヨシ!帰ったらフリカレ見直すぞ~!」と後回しするようになって、それを忘れて……

それなら携帯でもPCでもDiscordBotのスラッシュコマンド操作一発でカレンダー登録・リマインド出来たら最高じゃね!?

調査してみました

結論から申し上げると、フリカレ公式から提供されている書き込み・読み取りなどのAPIはありませんでした。(´・ω・`)
折角discord.pyなんかも少し学んだから使ってみたかったんだけどな……いや、ブラウザ操作が出来るならもしかして、スクレイピングが可能かも……?

この記事の要点・まとめ

  • Discordの /add /list /remove でフリカレの予定を読み書きするBotを作れた!
  • 毎朝その日の予定を自動投稿する機能も作成
  • (公式APIがないので、)Playwrightでヘッドレスブラウザを動かし、機械的に人間と同じ操作を再現している!!!

1. できあがったもの

image.png

コマンド一覧

コマンド 説明
/add <date> <time> <description> 予定を1件追加する
/list <date> その日の予定を一覧表示する
/remove <date> <index> その日の予定を1件削除する(番号は /list の並び順)
/status Botの稼働状況を表示する

日付は YYYY-M-D(ゼロ埋めなし)、時刻は HH:MM 形式

例)
/add 2026-09-01 21:00 テスト予定

[DiscordBot]
✅ Added event to 2026-8-18
Time: 21:00
Description: test

📅 2026-8-18 の予定
・21:00 test
/status

[DiscordBot]
🤖 Discord Calendar Bot Status
✅ Bot is online
📅 Calendar ID: ******
🔗 Calendar URL: https://freecalend.com/close/mem******/index
👤 Logged in as: bot****
⏰ 毎日 08:00 に予定を自動投稿中 (channel: *****************)

Available Commands:
/add <date> <time> <description> - Add event to calendar
/list <date> - Show events for a date
/remove <date> <index> - Remove an event (see /list for index)
/status - Show this status message

追加した予定を見返したり、間違えたら消したりも出来ます!

もう1件追加してみるとこんな感じ

/add 2026-8-18 13:00 買い出し

[DiscordBot]
✅ Added event to 2026-8-18
Time: 13:00
Description: 買い出し

📅 2026-8-18 の予定
・13:00 買い出し
・21:00 test

追加した後にその日の予定を全部返してくれるようにしてあります。(予定登録確認のためにlistを打つのも手間なので…)

/list 2026-8-18

[DiscordBot]
📅 2026-8-18 の予定
・13:00 買い出し
・21:00 test
/remove 2026-8-18 1

[DiscordBot]
✅ Removed event from 2026-8-18: 13:00 買い出し

📅 2026-8-18 の予定
・21:00 test

削除は文字列じゃなくて「/list に出てくる番号」で指定する方式にしました。予定を文字列でそのまま打たせると、全角スペースが1個違うだけで「そんな予定ありません」って言われがち……(´・ω・`)

毎朝その日の予定を勝手に教えてくれる

そしてこのBot、毎朝勝手に今日の予定を教えてくれます。
.env に DAILY_SUMMARY_CHANNEL_ID を設定しておくだけという簡単さ!

☀️ おはようございます、今日の予定です

📅 2026-8-18 の予定
・21:00 test

Discordを開いた瞬間に通知が目に入るので、今日はこんな予定あったな、早く帰らないと!とか予定が立てやすいのが良いです。(業務終わりくらいの夕方にもう一回通知を見に行ってるので、ここにも通知入れたほうがいいなとか思いつつ……)

2. Botの中身はどうなってるの?

やっていることはとても単純で、Discordで受け取ったコマンドの内容を、裏(Playwright)でブラウザを動かしてフリカレにそのまま打ち込ませているだけです。

Discord
  │  /add 2026-8-18 21:00 test
  ▼
Discord Bot(discord.py / スラッシュコマンド)
  │  入力引数チェック
  ▼
Playwright(ヘッドレスChromium)
  │  auth.json でログイン状態を復元して、人間のフリして操作する
  ▼
freecalend.com

使っている技術

使うもの 役割
Python 3.11+
discord.py 2.4+ discordのスラッシュコマンド
Playwright for Python 1.60.x ブラウザ自動操作
Docker Botの実行環境(Playwright公式イメージを利用)
uv パッケージ管理

/add を1回叩くと裏で起きていること

  1. Discordがコマンドを受信 → defer() で「処理中だよ」を先に伝える
  2. 日付・時刻の形式をBot側でチェック(変な値ならブラウザ起動しない)
  3. asyncio.Lock を取って、他のコマンドと順番待ちにさせる
  4. Chromiumを起動し、auth.json からログイン状態を復元
  5. カレンダーのURLへ移動して、ちゃんとログインできているか確認
  6. その日の欄から今入っている予定を読み取る
  7. 「今の予定+新しい予定」をキー入力で丸ごと打ち直す
  8. 別の日の欄をクリックして保存をトリガーする
  9. リロードして、本当に保存されたか確認する
  10. ダメだったら、まっさらなブラウザで最大3回までやり直す
  11. followup.send() でDiscordに結果を返す

8と9が今回一番困ったポイントで、この記事の為に使った時間のほとんどはここに吸われました。(´・ω・`)

3. スクレイピングって何?(知ってる人は読み飛ばして無問題です)

ここまでで「どんなものを作ったか」を紹介してきました。一旦、この仕組みの土台になっている「スクレイピング」という技術そのものの解説をします!このBotは「公式APIのないサービスを、ブラウザ経由で無理やり自動操作する」という構造になっているので、その前提の考え方だけ整理しておきましょう。すでに知ってる!もうご存じ!という方は5章まで飛ばしてくださいませ。🙇

そもそもスクレイピングとは

Webページから機械的に情報を取り出したり、画面上の操作を自動で行ったりすることをスクレイピングと呼びます。人間がブラウザでポチポチする作業をプログラムに丸っきり任せることです。

大前提として、公式APIが提供されているならそっちを使うべきではあります!APIは「機械が使うことを想定した公式の入口」なので、

  • 仕様が文書化されている
  • 認証方法が用意されている
  • 変更時にはバージョニングや告知がある

一方スクレイピングは「人間向けに作られた画面」を機械が横からこっそり読み書きする行為なので、画面の作りがアップデートや仕様が変われば何の予告もなく壊れたりします。

今回はフリカレに公式APIがなかったので、やむを得ずこのアプローチを取っています。(´;ω;`)

※スクレイピングをやるときの心構え

技術的にやれることと、やってもいいことはまた別です。(技術者倫理ってやつですね!)最低限、以下は意識しておきたい部分です。

  1. 公式APIがあるならAPIを使う
  2. 利用規約と robots.txt を確認する
    — 明示的にスクレイピングが禁止されている場合は行わない!
  3. アクセス頻度を十分に抑える
  4. 自分のアカウント・自分のデータの範囲にとどめる
  5. サイトのDOM等のアップデートで壊れるかもしれない運転で作る

スクレイピングのアプローチは大きく2種類ある

静的スクレイピング ブラウザ自動操作
代表的なツール requests + BeautifulSoup Playwright / Selenium / Puppeteer
やること HTMLをHTTPで取得して解析する 本物のブラウザを起動して操作する
JavaScript 実行されない 実行される
ログイン状態 Cookieを自前で管理する ブラウザが面倒を見てくれる
速度 速い(数百ミリ秒) 遅い(数秒〜十数秒)
リソース 軽い 重い(ブラウザ1つ分)
向いている相手 サーバーがHTMLを組み立てて返すページ JSが描画・保存を担うページ

静的スクレイピングは軽くて速いので、条件が合うならこっちが圧倒的有利です。「ニュースサイトの見出しを集める」程度なら数行で済みます。

なぜ静的スクレイピングでは無理だったのか

今回やりたいのは「読む」だけでなく「書き込む」必要がありました。

  • フリカレの保存処理はJavaScriptが担っている。
    後の章で詳しく書きますが、フリカレはキー入力を監視して内容を内部状態として追跡し、特定の操作をきっかけにサーバーへ送信する、という作りでした。 HTMLを取得するだけの静的スクレイピングでは、そのJSがそもそも動かないので保存が発生しませんでした。(頭抱え)
  • ブラウザを動かせば「人間がやるのと同じこと」をそのまま再現できる! 動作はちょっと遅いのですが確実で、仕様の解読も最小限で済みます!

「1コマンドに10〜20秒かかる」という遅さは、この選択の代償です。Discordから予定を1件足して放っておいて、そこまで見守る必要もないので……(言い訳)

ブラウザ自動操作ツールの選択肢

Selenium Puppeteer Playwright
開発元 SeleniumHQ(OSS) Google Microsoft
対応ブラウザ 主要ブラウザ全般(WebDriver経由) Chromium系が中心 Chromium / Firefox / WebKit
対応言語 Python, Java, C#, Ruby, JS ほか JavaScript / TypeScript Python, JS/TS, Java, .NET
Pythonでの非同期 同期APIが基本 公式バインディングなし asyncioネイティブ
要素の自動待機 待機処理は自分で書く 一部あり 標準で組み込み

歴史が長く情報量が多いのはおそらくSeleniumですが、今回はPythonで書きたかったこと、そして名前だけ知ってる状態でも評判が良かったことからPlaywrightを選びました。(Playwrightしかライブラリ知らなかったとか言えないな……)

Playwrightとは

Microsoftが開発しているブラウザ自動操作ライブラリで、Chromium・Firefox・WebKitを同一のAPIで操作できます。ブラウザ本体のインストールも playwright install chromium のようにツール側が管理してくれるので、「ドライバとブラウザのバージョンが合わない」というあるあるな問題に悩まされにくいです。Playwright MCPとかも提供されててつよい!(余談)

pip install playwright
playwright install chromium   # ブラウザ本体もPlaywrightが管理する

ヘッドレスとヘッドフル

ブラウザ自動操作では、画面を描画するかどうかを選べます。

  • ヘッドレス(headless=True): ウィンドウを表示せずバックグラウンドで動く。サーバーやDockerで動かす通常モード
  • ヘッドフル(headless=False): 実際にウィンドウが開く。デバッグ時や、人間が手で操作する必要がある場面で使う

このプロジェクトは両方を使い分けています。

# generate_auth.py: 人間が手動ログインするので画面を出す
browser = await playwright.chromium.launch(headless=False)

# bot.py: サーバーで動かすので画面なし
browser = await playwright.chromium.launch(
    headless=True,
    args=["--no-sandbox", "--disable-setuid-sandbox"],
)

※--no-sandbox はDockerコンテナ内でChromiumを動かすときの定番オプションです。コンテナ側の制限とChromium自身のサンドボックス環境が衝突して起動に失敗するのを回避するためのもの……のようです!(あんまり分かってない)

4. なぜPlaywrightを選んだのか

ブラウザ自動操作ツールの中からPlaywrightを選んだ理由は、以下4つの強みでした。いずれも実装中にありがてえ~と万歳したものです。

1. asyncioネイティブ

Pythonバインディングは asyncio ベースで設計されています。discord.pyも同じくasyncioで動くので、1プロセス内で自然に共存できます。Seleniumの同期APIをイベントループ内で動かすときのような run_in_executor ラッパーが要りません。🙌

# discord.pyのイベントハンドラの中からそのままawaitできる
async def add_command(interaction: discord.Interaction, ...):
    await interaction.response.defer()
    success, message = await add_event_to_calendar(...)  # Playwright処理
    await interaction.followup.send(message)

2. StorageStateでログイン状態を永続化できる

Cookie・LocalStorage・SessionStorageをまるごとJSONとして保存・復元できます。これによりログイン処理を毎回実行せずに済みます。

# 保存(generate_auth.py での初回セットアップ時)
await context.storage_state(path="auth.json")

# 復元(bot.py での毎回の起動時)
context = await browser.new_context(storage_state="auth.json")

3. ネットワークリクエストを丸ごと監視できる

page.on("request", ...) / page.on("response", ...) でページの全通信をフックできます。今回のデバッグでフリカレの保存タイミングを特定するのに一番役立った機能です。

posts = []
page.on("request", lambda r: posts.append(r) if r.method == "POST" else None)
# ← ここで操作を行う
# posts の中身を見れば「どの操作がサーバーへの保存をトリガーするか」がわかる

4. ロケーターAPI

セレクターを遅延評価するLocatorオブジェクトを使うことで、DOM変化への耐性が上がります。存在確認や条件付き絞り込みが簡潔に書けます。

# 要素の存在確認
if await page.locator(text_area_selector).count() == 0:
    return (False, "日付が見つかりません")

# 「自分以外の同種要素」を選ぶ(後述の保存トリガーで使う)
save_trigger = (
    f"[id^='textinp-ccexp-{CALENDAR_ID}-']"
    f":not(#{text_area_selector.lstrip('#')})"
)
await page.locator(save_trigger).first.click()

ここから作業をするよ

Discord Botを作る

Step 1. Developer Portalでアプリを作る

Discord Developer Portal にアクセスし、「New Application」でアプリケーションを作成します。名前は後から変更できます。

Step 2. Botトークンを取得する

左メニューの「Bot」を開き「Add Bot」をクリック。作成されたら「Reset Token」でトークンを発行してコピーします。このトークンは一度しか表示されないので .env にすぐ貼り付けましょう。

DISCORD_TOKEN=your_token_here

「Privileged Gateway Intents」については、このBotはスラッシュコマンドだけで動くのでデフォルトIntentsで十分です!

intents = discord.Intents.default()
bot = commands.Bot(command_prefix="!", intents=intents)

Step 3. サーバーへ招待する

image.png

左メニューの「OAuth2 → URL Generator」で招待URLを生成します。この生成されたURLをブラウザで開くと、自分の管理するサーバーへBotを招待できます。

  • Scopes: bot, applications.commands
  • Bot Permissions: スラッシュコマンドだけなら「Send Messages」で十分!(毎朝の自動投稿を使う場合は、投稿先チャンネルへの送信権限が必要)

Step 4. スラッシュコマンドを実装する

discord.py 2.x からスラッシュコマンドが正式サポートされました。bot.tree(CommandTree)にコマンドを登録します。

from discord import app_commands

tree = bot.tree

@tree.command(name="add", description="Add an event to the calendar")
@app_commands.describe(
    date="Event date (YYYY-M-D, e.g., 2026-5-15)",
    time="Event time (HH:MM, e.g., 21:00)",
    description="Event description",
)
async def add_command(
    interaction: discord.Interaction,
    date: str,
    time: str,
    description: str,
):
    # 処理に時間がかかるので、最初に「受け付けた」とDiscordへ伝える
    await interaction.response.defer()

    try:
        date_formatted, time_formatted, desc_formatted = parse_command_args(
            date, time, description
        )
    except ValueError as e:
        await interaction.followup.send(f"❌ Argument error: {str(e)}")
        return

    success, message = await add_event_to_calendar(
        date_formatted, time_formatted, desc_formatted
    )
    await send_message(interaction, message)

引数のバリデーションはブラウザを起動する前に済ませています。存在しない日付や 99:99 のような時刻は、フリカレに触る前にエラーとして返ります。

def parse_command_args(date_str, time_str, text):
    date_formatted = parse_date_arg(date_str)  # strptime("%Y-%m-%d") で検証

    time_match = re.match(r"^(\d{1,2}):(\d{2})$", time_str)
    if not time_match:
        raise ValueError("Invalid time format. Use HH:MM (e.g., 21:00)")
    hour, minute = int(time_match.group(1)), int(time_match.group(2))
    if not (0 <= hour <= 23 and 0 <= minute <= 59):
        raise ValueError("Invalid time value. Hour must be 00-23 and minute 00-59")

    if not text or len(text.strip()) == 0:
        raise ValueError("Event description cannot be empty")

    return date_formatted, time_str, text.strip()

Step 5. defer() は必須(3秒ルール)

Discordはスラッシュコマンド受信後3秒以内に応答しないとタイムアウトエラーになります。このBotはブラウザ起動からページ読み込み、保存確認までで10〜20秒かかるため、defer() で「処理中」状態を先に通知し、完了後に followup.send() で結果を送っています。

Step 6. コマンドをDiscordに同期する

スラッシュコマンドは定義しただけでは使えません。起動時に tree.sync() を呼んでコマンド一覧を登録する必要があります。

@bot.event
async def on_ready():
    print(f"✅ Bot logged in as {bot.user}")
    await tree.sync()  # グローバル同期
    print("📋 Slash commands synced")

グローバル同期はキャッシュの都合で反映まで時間がかかることがあります。開発中は特定サーバーだけに即時反映させると速い……かも……?(DiscordBotを専門に紹介しているブログ参照をお願いします!🙇)


6. フリカレへのログインを永続化する

フリカレはログインしないと予定を書き込めません。前述のStorageStateを使ってセッションをファイルに永続化します。

generate_auth.py という初期セットアップを用意し、一回だけGUIありのブラウザを起動して手動ログインして、その状態を auth.json として保存します。

browser = await playwright.chromium.launch(headless=False)
context = await browser.new_context()
page = await context.new_page()
await page.goto("https://freecalend.com/")

print("🔐 ブラウザでフリカレにログインしてください")
# input() は同期関数でイベントループをブロックするため to_thread 経由で呼ぶ
await asyncio.to_thread(input, "✅ ログイン完了後、Enterキーを押してください... ")

await context.storage_state(path="auth.json")

Bot側は起動のたびにこのJSONを読んで認証済みContextを作ります。ブラウザの起動から後片付けまでは asynccontextmanager にまとめました。

@asynccontextmanager
async def freecalend_context():
    """Launch a headless browser and yield an authenticated context for フリカレ."""
    playwright = await async_playwright().start()
    browser = await playwright.chromium.launch(
        headless=True,
        args=["--no-sandbox", "--disable-setuid-sandbox"],
    )
    try:
        context = await browser.new_context(storage_state=AUTH_JSON_PATH)
        try:
            yield context
        finally:
            await context.close()
    finally:
        await browser.close()
        await playwright.stop()

これで、認証済みのブラウザContextはいつでも取り出せるようになりました。まずはこれを使う機能の中でも一番シンプルな「毎朝の自動投稿」から実装していきます。


7. 毎朝その日の予定を自動投稿する仕組み

discord.py の tasks.loop(time=...) を使うと、指定時刻に1日1回実行されるバックグラウンドタスクが書けます。読み取り処理は /list と全く同じものを使い回しています。

@tasks.loop(time=_parse_time_of_day(DAILY_SUMMARY_TIME))
async def post_daily_summary():
    """Post today's events to DAILY_SUMMARY_CHANNEL_ID once a day."""
    try:
        channel = bot.get_channel(int(DAILY_SUMMARY_CHANNEL_ID))
        if channel is None:
            print(f"⚠️ DAILY_SUMMARY_CHANNEL_ID のチャンネルが見つかりません")
            return

        today = datetime.now(JST).date()
        date_str = f"{today.year}-{today.month}-{today.day}"
        _, message = await get_events_from_calendar(date_str)
        await channel.send(truncate_for_discord(
            f"☀️ **おはようございます、今日の予定です**\n\n{message}"
        ))
    except Exception as e:
        # バックグラウンドタスクなので、1回の失敗でループ自体を止めないよう
        # ここで広く受け止めてログに残すだけにする
        print(f"❌ 予定の自動投稿に失敗しました: {e}")

起動は on_ready の中で、環境変数が設定されているときだけ行います。

@bot.event
async def on_ready():
    ...
    if DAILY_SUMMARY_CHANNEL_ID and not post_daily_summary.is_running():
        post_daily_summary.start()
        print(f"⏰ 毎日 {DAILY_SUMMARY_TIME} に予定を自動投稿します")

例外を握りつぶすのは普通なら避けたいところですが、tasks.loop は例外が外に漏れるとループ自体が停止してしまいます。フリカレ側の一時的な不調・不具合で「翌日以降ずっと投稿されない」方が困るので、ここは広く受け止めてログに残す方針にしました。

機能面はこれで一通り揃いました!!次はこのBotを実際に動かすための環境構築に入りましょう。


8. セットアップ手順

前提条件

ツール 用途
Docker / Docker Compose Botの実行環境
Python 3.11以上 generate_auth.py の実行(ローカル)
Playwright for Python 同上(GUIブラウザ起動のため)

Botの本番実行はDockerだけあれば動きます。Pythonのローカル環境が必要なのは初回の generate_auth.py 実行時のみです。

ファイル構成

discord-freecalendar-test/
├── bot.py               # Bot本体
├── generate_auth.py     # 初回セットアップ:auth.jsonを生成する
├── validate_setup.py    # 起動前チェック:設定の抜けを検出する
├── Dockerfile
├── docker-compose.yml
├── pyproject.toml
├── .env                 # 秘密情報(gitignore済み)
├── .env.example          # .envのテンプレート
├── auth.json            # フリカレのセッション情報(gitignore済み)
├── README.md
└── QUICKSTART.md

Step 1: .env を作成する

cp .env.example .env
# Discord Developer Portal で取得したBotトークン
DISCORD_TOKEN=your_discord_bot_token_here

# フリカレのカレンダーID(URLに含まれる数字)
# 例: https://freecalend.com/close/mem000000/index → 000000
CALENDAR_ID=000000

# フリカレのカレンダーURL(メンバー限定ページのURL)
CALENDAR_URL=https://freecalend.com/close/mem000000/index

# コンテナ内のauth.jsonパス(変更不要)
AUTH_JSON_PATH=/app/auth.json

# 今日の予定を自動投稿する機能(省略すると無効)
DAILY_SUMMARY_CHANNEL_ID=your_channel_id_here
# 投稿時刻(HH:MM)。日本時間で解釈される
DAILY_SUMMARY_TIME=08:00

CALENDAR_ID と CALENDAR_URL はフリカレにログインしてカレンダーを開いたときのURLから読み取れます。DAILY_SUMMARY_CHANNEL_ID は、Discordの「設定 → 詳細設定 → 開発者モード」をONにすると、チャンネルを右クリックして「IDをコピー」で取得できます。

Step 2: auth.json を生成する(初回のみ)

GUIブラウザを使うため、ローカルのデスクトップ環境で実行する必要があります!!

pip install playwright
playwright install chromium
python generate_auth.py

Chromiumウィンドウが開くのでフリカレに手動でログインし、ターミナルに戻ってEnterキーを押すと auth.json が生成されます。

🚀 フリカレ認証情報生成ツール
==================================================

📱 フリカレを開いています...

🔐 ブラウザでフリカレにログインしてください

✅ ログイン完了後、Enterキーを押してください...

✅ 認証情報を保存しました:
   📄 /path/to/discord-freecalendar-test/auth.json

🎉 セットアップ完了!
   docker-compose up -d でボットを起動できます

Step 3: 設定を検証する

.env の記入漏れや auth.json の形式不備を、コンテナを起動する前に確認します。

python validate_setup.py
==================================================
🔍 Discord フリカレボット セットアップ検証
==================================================

📋 環境変数チェック:
----------------------------------------
✅ ファイル: .env
✅ DISCORD_TOKEN: 設定済み
✅ CALENDAR_ID: 設定済み
✅ CALENDAR_URL: 設定済み

🔐 認証ファイルチェック:
----------------------------------------
✅ ファイル: auth.json
✅ auth.json: 有効な形式です
...

未設定の項目には 💡 実行: python generate_auth.py のように次の一手が出るようにしてあります。

Step 4: Dockerで起動する

docker compose up -d
docker compose logs -f discord-calendar-bot

初回はPlaywrightのベースイメージ(約2GB)のpullとビルドが走るため数分かかります。

🚀 Starting Discord Calendar Bot...
✅ Bot logged in as bot118**************244#****
📅 Calendar ID: 000000
🔗 Calendar URL: https://freecalend.com/close/mem000000/index
📋 Slash commands synced
⏰ 毎日 08:00 に予定を自動投稿します (channel: 151*************237)

Dockerfileの工夫

Playwrightはブラウザバイナリのバージョンとライブラリのバージョンが一致している必要があります。Microsoftが公開している公式イメージ mcr.microsoft.com/playwright/python:v1.60.0-jammy にはブラウザバイナリが同梱されているので、Playwrightを 1.60.x にピン留めしておけばそのまま動きます。

このイメージにはuvが入っていないため、マルチステージビルドでuvの公式イメージからバイナリだけをコピーしました。(長くなるので割愛)

FROM ghcr.io/astral-sh/uv:latest AS uv
FROM mcr.microsoft.com/playwright/python:v1.60.0-jammy

COPY --from=uv /uv /usr/local/bin/uv

ENV PYTHONUNBUFFERED=1
ENV PYTHONDONTWRITEBYTECODE=1
WORKDIR /app

COPY pyproject.toml .
COPY bot.py .

# ベースイメージ同梱のブラウザに合わせて Playwright を 1.60.x に固定
RUN uv pip install --system --no-cache-dir \
    discord.py "playwright>=1.60.0,<1.61.0" python-dotenv

CMD ["python", "bot.py"]

uv pip install --system でシステムPythonに直接インストールすることで、仮想環境の有無を気にせず python bot.py をそのまま起動できます。

秘密情報はイメージに焼かずマウントする

docker-compose.yml では .env と auth.json をRead Onlyでマウントしています。

volumes:
  - ./.env:/app/.env:ro           # 環境変数ファイル
  - ./auth.json:/app/auth.json:ro # フリカレのセッション情報

セッションが切れた(フリカレ側でログアウトされた)場合は、ローカルで generate_auth.py を再実行して auth.json を更新し、コンテナを再起動すればOKです。

python generate_auth.py
docker compose restart discord-calendar-bot

9. 一番時間を溶かした話:フリカレの「保存」はいつ走るのか

ここまでは何の問題もなく手順を進めてきたように書きましたが、実は /add が思ったように動くようになるまでが今回一番の山場でした。ここが今回一番時間がかかった部分です。(頭抱え)

最初の実装は完全に間違っていた

フリカレはポップアップで入力する仕様だと思い込んで、Claude Codeをパワハラしながらこういったコードを書きました。

# 日付セルをクリックしてポップアップを開く
await page.click(f"#popcal-{CALENDAR_ID}-{date}")
await asyncio.sleep(0.5)
# ポップアップ内のテキストエリアに入力
await page.fill(f"#textinp-poptxtmain-{CALENDAR_ID}-{date}", combined_text)
# ポップアップ外をクリックして閉じる=保存
await page.click(f"#popuppage-{CALENDAR_ID}")

全然動きませんでした。エラーは毎回!これ!

❌ Save verification failed: change was not persisted on reload.
   Please check date permissions/visibility on freecalend.

リロードすると入力した内容が保存されていません。困った……。

調査1: ポップアップは存在しなかった

実際にPlaywrightでDOMを調べると、想定と全く違う構造でした。

for sel in ["#popcal-123456-2026-6-20", "#cal-123456-2026-6-20"]:
    count = await page.locator(sel).count()
    print(f"{sel}: count={count}")
# → #popcal-...: count=0   (そもそも存在しない)
# → #cal-...:    count=1   (DIV要素、onclickなし)

count = await page.locator("#textinp-ccexp-123456-2026-6-20").count()
# → count=1、visible=True(ポップアップなしで最初からページ上に存在)

ポップアップは存在せず、各日付のテキストエリア(#textinp-ccexp-{カレンダーID}-{日付})がページに直接埋め込まれていました。(ここは諸説ありだけど……)

調査2: page.fill() では保存されない

テキストエリアを特定できた次の問題は「page.fill() で入力しても全く保存されない」ということ。ネットワークリクエストを監視しながら操作パターンを総当たりした(させた)結果、以下のようになっていることがわかりました。

操作 POST /close/data が飛ぶか
page.fill() でテキスト設定 → 別要素クリック 飛ばない
keyboard.type() で入力 → 別要素クリック 🌟飛ぶ!!
keyboard.type() で入力 → dispatch_event("blur") 飛ばない
keyboard.type() で入力 → Tabキー 飛ばない

page.fill() はDOMの value を直接書き換えるだけで、キーボードイベント(keydown / keyup)を発火させません。フリカレのJSはキーイベントを監視してテキストエリアの内容を内部状態として追跡しており、fill() では内部状態が更新されないために保存がトリガーされませんでした。

調査3: 保存トリガーは「別のテキストエリアへのmousedown」

さらに調べると、window.nyuryokusave という保存関数がグローバルに存在し、これがLocalStorage経由でサーバーに同期する仕組みになっていました。この関数は mousedown イベントが別の .textinp 要素に発火したときに呼ばれます。

つまり、「別の日付のテキストエリアをクリックする」ことが保存のトリガーでした。

ユーザーが日付Aのテキストエリアを編集
  → 日付Bのテキストエリアをクリック
  → mousedownイベント
  → フリカレのJSが日付Aの内容をサーバーにPOST
  → 日付Bにフォーカス移動

カレンダーアプリとしては自然なUXですが、Playwright側でこれを意図的に再現する必要がありました。

動いた実装

# 1. テキストエリアをクリックしてフォーカスする
#    フリカレのJSは mousedown で「今どのセルを編集中か」を追跡している
await page.click(text_area_selector, timeout=5000)

# 2. keyboard.type() で入力する(keydown/keyupを実際に発火させるため)
await page.keyboard.press("Control+A")
await page.keyboard.type(combined_text, delay=20)

# 3. 別のテキストエリアをクリックして保存をトリガーする
save_trigger_selector = (
    f"[id^='textinp-ccexp-{CALENDAR_ID}-']"
    f":not(#{text_area_selector.lstrip('#')})"
)
await page.locator(save_trigger_selector).first.click(timeout=5000)
await page.wait_for_load_state("networkidle")

# 4. リロードして、本当に保存されたかを確認する
await page.reload(wait_until="networkidle")
persisted_text = await page.input_value(text_area_selector) or ""
save_confirmed = verify(persisted_text)

ポイントは4の保存確認まで含めていることです。「操作は成功したように見えるが保存されていない」が普通に起きるので、リロードして実際に保存されているか読み直すまでを1セットにしないと、成功やな! ✅(成功) とDiscordに返してしまいます。(10敗)

なお、この保存処理は /add と /remove で共通なので write_day_text(page, selector, new_text, verify) として切り出し、「保存されたと見なす条件」だけを呼び出し側から渡すようにしました。

# /add: 追加した1行が存在すればOK
verify=lambda persisted: new_entry in persisted.splitlines()

# /remove: 残るべき行と完全一致すればOK
verify=lambda persisted: persisted.splitlines() == remaining_lines

10. 読み書きを安定させるための地味な工夫

派手なミスの陰で、実運用で効いている細かい対策たちです。

セッション切れを検知して、原因の分かるメッセージを返す

auth.json のセッションが切れると、カレンダーではなくログインページにリダイレクトされます。これを検知せずに進むと「日付セルが見つかりません」という的外れなエラーになってしまいます。

カレンダーページなら日付を問わず textinp-ccexp-* のテキストエリアが必ず存在するので、その有無をセッション判定に使っています。

async def is_logged_in(page) -> bool:
    try:
        await page.wait_for_selector("[id^='textinp-ccexp-']", timeout=10000)
        return True
    except Exception:
        return False

ポイントは count() による即時スナップショットではなく wait_for_selector で待つことです。セルの描画に1〜2秒以上かかることがあり、即時判定だとレンダリング中に「セッション切れ」を誤報していました。

切れていたときは、次にやるべきことをそのままメッセージにしてぽいっと返します。

❌ フリカレのセッションが切れているようです。
   generate_auth.py を再実行して auth.json を更新し、Bot を再起動してください。

「予定なし」と「まだ読み込まれていない」を区別する

フリカレはテキストエリア要素自体はすぐ現れますが、その中身(その日の予定)が少し遅れて反映されることがあります。要素があるからといって即座に input_value() を読むと、空文字列が返ってきて「予定なし」と誤判定してしまいます。

これが /add で起きると最悪で、「既存の予定なし」と判断して既存の内容を消しながら上書きしてしまいます。空だったときだけ何度か読み直すようにしました。

async def read_day_text_robust(page, selector, attempts: int = 4, wait: float = 1.0) -> str:
    value = ""
    for attempt in range(attempts):
        value = await page.input_value(selector) or ""
        if value:
            return value
        if attempt < attempts - 1:
            await asyncio.sleep(wait)
    return value

読み込み済みの日付範囲をエラーに含める

当初は「フリカレは表示中の月の日付しか操作できない」と思っていましたが、DOMを調べると前月・今月・来月・再来月の4ヶ月分の日付セルが、月送り操作なしで最初からページ内に存在していることが分かりました。この範囲内なら各コマンドはそのまま動きます。

範囲外の日付を指定されたときは、実際にDOM上にある最古〜最新の日付を調べて、具体的な範囲をメッセージに含めるようにしました。「見つかりません」だけだと、日付を間違えたのか範囲外なのかがユーザーに分からないためです。

async def get_loaded_date_range(page) -> tuple[str, str] | None:
    ids = await page.locator("[id^='textinp-ccexp-']").evaluate_all(
        "els => els.map(e => e.id)"
    )
    prefix = f"textinp-ccexp-{CALENDAR_ID}-"
    dates = []
    for i in ids:
        if not i.startswith(prefix):
            continue
        year, month, day = i[len(prefix):].split("-")
        dates.append((int(year), int(month), int(day)))
    if not dates:
        return None
    dates.sort()
    y0, m0, d0 = dates[0]
    y1, m1, d1 = dates[-1]
    return (f"{y0}-{m0}-{d0}", f"{y1}-{m1}-{d1}")
❌ 2026-11-1 は現在フリカレに読み込まれていません。
   読み込み済みの範囲: 2026-6-1 〜 2026-9-30
   (それより先の日付はフリカレ側で月を送らないと読み込まれません)

同時実行で予定が消えるのを防ぐ

このBotの書き込みは「既存テキストを読む → 全選択 → 既存+新規を打ち直す」という read-modify-write 方式になっています。そのため /add が同時に2つ走ると、両方が同じ内容を読み込み、後から保存した方が先の予定を上書きして消してしまっていました。

対策として asyncio.Lock でカレンダー操作全体を直列化しました。

calendar_lock = asyncio.Lock()

async def add_event_to_calendar(date, time, description):
    async def attempt(page):
        ...

    async with calendar_lock:
        return await run_with_retry(attempt)

読み取り専用の /list も同じロックを取っています。書き込み中のリロードと読み取りが混ざるのを避けるためで、この規模なら並列化のアドバンテージより単純さと堅牢さを優先しました。

Discordの2000文字制限

Discordのメッセージは2000文字が上限です。予定が積み上がった日の一覧を返すと、これを超えて送信に失敗する可能性が薄~~~くあります。(どんだけ予定一日に詰め込んでんだ……)/add の確認・/list・毎朝の自動投稿はいずれも一日分の全予定を含むので、送信前に共通で切り詰めています。

DISCORD_MESSAGE_LIMIT = 2000

def truncate_for_discord(message: str) -> str:
    if len(message) > DISCORD_MESSAGE_LIMIT:
        omitted_note = "\n…(長すぎるため以下省略。`/list`で全件確認してください)"
        message = message[: DISCORD_MESSAGE_LIMIT - len(omitted_note)] + omitted_note
    return message

11. 制限と注意事項

操作できる日付は約4ヶ月分

フリカレのページには前月・今月・来月・再来月の4ヶ月分の日付セルが最初から読み込まれており、この範囲であれば月送り操作なしで読み書きできます。逆に、それより先の日付、前の日付は操作できません。いやまあ十分じゃね?というのが正直なところです。

これ以上先の予定を登録しようとちょっと苦心しましたが、Webブラウザ操作が安定せずというところで断念……(><)

1コマンドあたり10〜20秒

ブラウザの起動・ページ読み込み・保存確認のリロードが入るため、1コマンドあたり10〜20秒程度かかります。失敗して丸ごとリトライが走るとさらに伸びます(最大3回)。asyncio.Lock で直列化しているので、同時に複数のコマンドが来た場合はこの時間 × 待ち件数の順番待ちになります。

複数の予定をまとめて登録したい場合は /add を複数回打つ必要があり、その分だけ待つことになります。結局動作遅くない?はい……

重複と削除の扱い

/add は「同じ日に、同じ時刻・同じ内容の行がすでにある」場合は追加せずに警告を返します。誤って2回コマンドを打ったときの重複登録を防ぐためです。

⚠️ Event already exists on 2026-10-20: 21:00 (シナリオ名)予定

/remove は行番号での指定なので、/list を打ってから番号を確認して実行することになります。番号が範囲外なら、その日の件数を添えて返します。


12. セキュリティ上の注意

auth.json は絶対にGitに入れない

auth.json にはフリカレへのログイン状態(Cookie・Session)が人間でも読める平文で保存されています。外部流出したら誰でもカレンダーを操作出来てしまいます。(((´・ω・`)))ヒェ~ッ

# .gitignore に含まれている
.env
auth.json

※万が一コミットしてしまった場合:

  1. Gitから削除する: git rm --cached auth.json && git commit --amend(プッシュ済みなら履歴からの完全除去が必要)
  2. フリカレ側でセッションを無効化する(別ブラウザからログイン・ログアウトして前のセッションを切る)
  3. ローカルで generate_auth.py を再実行して新しい auth.json を生成する
  4. プッシュ前に確認する: git ls-files | grep auth.json の出力が空であること

DISCORD_TOKEN を安全に管理する

トークンが漏れると、そのBotに与えた全ての権限を悪用されます。このBotはスラッシュコマンドだけなので被害は限定的ですが、以下に注意します。

  1. コードに直書きしない — .env に置き、コード側は os.getenv("DISCORD_TOKEN") で読む
  2. スクリーンショットに映さない — Discordや設定画面をキャプチャする際にトークンが映り込まないよう確認する
  3. コマンドライン引数に渡さない — シェル履歴に残る
  4. 漏れたら即Reset — Discord Developer Portal → Bot → Reset Token で無効化する

感想

「なぜ保存されないのか」の原因調査に一番時間(とClaude Codeのトークン)がかかりましたが、ネットワークリクエスト監視とDOMのイベントリスナー調査を組み合わせれば体系的に追えることが分かりました。同じような「SPAの自動保存」系サービス、Playwrightを使った操作を行うとき等の参考になれば幸いです。🙇

これでもう、フリカレへの入力漏れで友人や先輩たちにしばかれることは(多少)避けられそうです。ヒューマンエラー的な部分は重々人間が注意しつつ……
自分も忘れっぽくて困ってる🐔という方やDiscordで色んな便利なBot作ってみたい!!という方は是非コメント頂ければとても喜びます!

他、弊社メンバーが出しているブログも良ければご一読くださいませ!

4
3
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
4
3

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?