【Python】Google Calendar AIアシスタントを作った話 – 自然言語でスケジュール管理を自動化
「来週の火曜の午後に1時間、API設計のレビューを入れといて」
こう入力するだけで、Googleカレンダーに予定が追加される。そんなツールを作った。
きっかけはシンプルで、複数のGoogleアカウントのカレンダーをいちいちブラウザで開いて確認して、手動で予定を入れる作業が地味にストレスだったからだ。仕事用・個人用・妻の通院管理用と3つのアカウントを使い分けていると、ブラウザのタブが散乱して全体像が見えにくい。
「どうせなら自然言語で操作できるやつを作ろう」と思い立ち、Pythonと各種Google APIを組み合わせた自作ツールを構築した。
構成の全体像
このツールの役割を整理すると以下になる。
使用したライブラリはこのあたり。
google-auth==2.28.0
google-auth-oauthlib==1.2.0
google-api-python-client==2.120.0
python-dateutil==2.9.0
NLPの部分はあえて外部LLMに依存せず、正規表現とdateutilの組み合わせで実装した。「LLMなしで自然言語処理」という縛りで作ったのは、レイテンシとコストの問題があったから。毎回APIを叩くのは遅いし、ローカルで完結したかった。
OAuth 2.0 の実装
最初につまずいたのがOAuth 2.0の設定だ。
複数アカウントを切り替えて使う必要があるため、アカウントごとにトークンファイルを管理する設計にした。
import os
from google.oauth2.credentials import Credentials
from google.auth.transport.requests import Request
from google_auth_oauthlib.flow import InstalledAppFlow
SCOPES = [
'https://www.googleapis.com/auth/calendar',
'https://www.googleapis.com/auth/tasks',
]
def get_credentials(account_name: str) -> Credentials:
token_path = f'.tokens/{account_name}_token.json'
creds = None
if os.path.exists(token_path):
creds = Credentials.from_authorized_user_file(token_path, SCOPES)
if not creds or not creds.valid:
if creds and creds.expired and creds.refresh_token:
creds.refresh(Request())
else:
flow = InstalledAppFlow.from_client_secrets_file(
f'.credentials/{account_name}_credentials.json', SCOPES
)
creds = flow.run_local_server(port=0)
os.makedirs('.tokens', exist_ok=True)
with open(token_path, 'w') as token:
token.write(creds.to_json())
return creds
アカウント名をキーにしてトークンファイルを分けることで、workとpersonalを簡単に切り替えられるようにした。
自然言語パーサーの実装
このツールの肝となる部分だ。入力テキストから「いつ」「何時間」「何の予定か」を抽出する。
import re
from datetime import datetime, timedelta
from dateutil import parser as dateutil_parser
class NaturalLanguageParser:
RELATIVE_DAY_MAP = {
'今日': 0, '明日': 1, '明後日': 2,
'来週月曜': 7, '来週火曜': 8, '来週水曜': 9,
'来週木曜': 10, '来週金曜': 11,
}
TIME_PATTERN = re.compile(
r'(午前|午後)?(\d{1,2})時(?:(\d{2})分)?'
)
DURATION_PATTERN = re.compile(
r'(\d+(?:\.\d+)?)\s*(時間|分|hour|min)'
)
def parse(self, text: str) -> dict:
result = {
'title': None,
'start_datetime': None,
'duration_minutes': 60, # デフォルト1時間
'calendar': 'primary',
}
result['title'] = self._extract_title(text)
result['start_datetime'] = self._extract_datetime(text)
result['duration_minutes'] = self._extract_duration(text)
return result
def _extract_datetime(self, text: str) -> datetime:
base_date = datetime.now().replace(
hour=9, minute=0, second=0, microsecond=0
)
# 相対的な日付の解決
for keyword, days_delta in self.RELATIVE_DAY_MAP.items():
if keyword in text:
base_date += timedelta(days=days_delta)
break
# 時刻の解決
match = self.TIME_PATTERN.search(text)
if match:
period, hour, minute = match.groups()
hour = int(hour)
minute = int(minute) if minute else 0
if period == '午後' and hour < 12:
hour += 12
elif period == '午前' and hour == 12:
hour = 0
base_date = base_date.replace(hour=hour, minute=minute)
return base_date
テキストから「午後3時」「来週火曜の午前10時」といった表現を拾い、datetimeオブジェクトに変換する処理だ。
完璧ではないが、日常的な入力パターンの8割くらいはカバーできている。残りの2割は入力を少し調整すれば対応できる範囲で、実用上は困っていない。
Google Calendar APIとの連携
パースしたデータをCalendar APIに投げる部分はシンプルにまとめた。
from googleapiclient.discovery import build
class CalendarClient:
def __init__(self, account_name: str):
creds = get_credentials(account_name)
self.service = build('calendar', 'v3', credentials=creds)
def create_event(
self,
title: str,
start_dt: datetime,
duration_minutes: int,
calendar_id: str = 'primary',
description: str = '',
) -> dict:
end_dt = start_dt + timedelta(minutes=duration_minutes)
event_body = {
'summary': title,
'description': description,
'start': {
'dateTime': start_dt.isoformat(),
'timeZone': 'Asia/Tokyo',
},
'end': {
'dateTime': end_dt.isoformat(),
'timeZone': 'Asia/Tokyo',
},
}
event = self.service.events().insert(
calendarId=calendar_id,
body=event_body,
).execute()
return event
def list_upcoming_events(
self,
max_results: int = 10,
calendar_id: str = 'primary',
) -> list:
now = datetime.utcnow().isoformat() + 'Z'
events_result = self.service.events().list(
calendarId=calendar_id,
timeMin=now,
maxResults=max_results,
singleEvents=True,
orderBy='startTime',
).execute()
return events_result.get('items', [])
タイムゾーンをAsia/Tokyoに固定しているのがポイントで、UTC変換のミスを防ぐためだ。最初はUTCで入れてしまって、「なぜか予定が9時間ずれてる」という問題に30分ほど悩んだ。
Google Tasks APIとの連携
タスク管理もカレンダーと一元化したかったので、Google Tasks APIも繋いだ。
class TasksClient:
def __init__(self, account_name: str):
creds = get_credentials(account_name)
self.service = build('tasks', 'v1', credentials=creds)
def add_task(
self,
title: str,
due_date: datetime = None,
tasklist_id: str = '@default',
) -> dict:
task_body = {'title': title}
if due_date:
# Tasks APIはRFC 3339形式(時刻部分は00:00:00Z固定)
task_body['due'] = due_date.strftime('%Y-%m-%dT00:00:00.000Z')
task = self.service.tasks().insert(
tasklist=tasklist_id,
body=task_body,
).execute()
return task
Tasks APIはカレンダーAPIと比べてシンプルで、ハマりポイントも少なかった。ただし期限日(due)は時刻なしのRFC 3339形式が要求されるため、時刻部分を00:00:00Zで固定しないと弾かれる。これは公式ドキュメントに書いてあるが、見落としやすい。
CLIインターフェース
このツールの入り口となるCLIをargparseで作った。
import argparse
def main():
parser = argparse.ArgumentParser(description='Google Calendar AI Assistant')
subparsers = parser.add_subparsers(dest='command')
# イベント追加
add_parser = subparsers.add_parser('add', help='予定を追加する')
add_parser.add_argument('text', help='自然言語の入力')
add_parser.add_argument('--account', default='personal', help='使用するアカウント')
# 予定確認
list_parser = subparsers.add_parser('list', help='予定を確認する')
list_parser.add_argument('--account', default='personal')
list_parser.add_argument('--days', type=int, default=7)
args = parser.parse_args()
if args.command == 'add':
nlp = NaturalLanguageParser()
parsed = nlp.parse(args.text)
client = CalendarClient(args.account)
event = client.create_event(**parsed)
print(f"予定を追加しました: {event['summary']} ({event['start']['dateTime']})")
使い方はこんな感じ。
python cal.py add "明日の午後2時に1.5時間、設計レビューを入れておいて" --account work
python cal.py list --account work --days 3
作ってみた感想と反省点
実際に1ヶ月使ってみた感想としては「かなり便利、でも詰めが甘い」という印象だ。
よかった点は、複数アカウントの予定を1つのコマンドで確認できるようになったことと、よく使うパターン(「来週の定例を入れる」など)であればほぼ手間なく操作できること。
一方で反省点もある。自然言語パーサーの精度が思ったより低く、曖昧な表現(「夕方に」「週明けに」など)はうまく処理できない。最終的にはLLMをパーサーとして使うべきだったかもしれない。コストとレイテンシを嫌ってローカル処理に固執したが、LLMを使えばパーサーの精度問題はほぼ解決できるはずで、その費用対効果を考えると節約した意味があったか怪しい。
次のバージョンでは、入力解析だけLLMに任せる設計に変えようと思っている。
まとめ
自然言語でGoogleカレンダーを操作するツールをPythonで作った。
「あのツール、完璧ではないけどあると便利」というのが個人開発の理想形だと思っていて、このツールはそのラインに達していると感じている。完璧を目指して作り続けるより、使えるものを早く作って日常に組み込む方が、長期的に見ても学びが多い。
コードは今後GitHubに公開予定。整理できたらリンクを貼る。