【Python】Google Calendar AIアシスタントを作った話 – 自然言語でスケジュール管理を自動化

「来週の火曜の午後に1時間、API設計のレビューを入れといて」

こう入力するだけで、Googleカレンダーに予定が追加される。そんなツールを作った。

きっかけはシンプルで、複数のGoogleアカウントのカレンダーをいちいちブラウザで開いて確認して、手動で予定を入れる作業が地味にストレスだったからだ。仕事用・個人用・妻の通院管理用と3つのアカウントを使い分けていると、ブラウザのタブが散乱して全体像が見えにくい。

「どうせなら自然言語で操作できるやつを作ろう」と思い立ち、Pythonと各種Google APIを組み合わせた自作ツールを構築した。

構成の全体像

このツールの役割を整理すると以下になる。

  • **入力**: 自然言語テキスト(CLI経由)
  • **解析**: テキストからイベント情報を抽出
  • **操作**: Google Calendar API / Google Tasks API への CRUD
  • **出力**: 処理結果を人間が読めるフォーマットで表示
  • 使用したライブラリはこのあたり。

    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

    アカウント名をキーにしてトークンファイルを分けることで、workpersonalを簡単に切り替えられるようにした。

    自然言語パーサーの実装

    このツールの肝となる部分だ。入力テキストから「いつ」「何時間」「何の予定か」を抽出する。

    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で作った。

  • Google Calendar API + Tasks APIをOAuth 2.0で連携
  • 正規表現 + `dateutil`で自然言語から日時を抽出
  • 複数アカウントをトークンファイルで管理
  • 「あのツール、完璧ではないけどあると便利」というのが個人開発の理想形だと思っていて、このツールはそのラインに達していると感じている。完璧を目指して作り続けるより、使えるものを早く作って日常に組み込む方が、長期的に見ても学びが多い。

    コードは今後GitHubに公開予定。整理できたらリンクを貼る。