Python:Google Calendar API を使う

長野市ごみ収集日の CSV ファイルを公開していますが、使うには少しハードルがありますよね。
カレンダーの共有リンクで自分のカレンダーに反映できたら楽だろうなと思っていたのですが、40 以上のカレンダーを手動で作って公開までする気にはならず、コードでできないか調べつつ書きました。
作成したカレンダーの共有リンクは先のページに反映しました。

Calendar API を使う

Calendar API のガイドに従い、以下のように必要なパッケージをインストールしておきます。

pip install --upgrade google-api-python-client google-auth-httplib2 google-auth-oauthlib

カレンダーを作成して共有リンクを得る

作成したコードの主な処理内容は以下の通り。
・長野市の地区毎に1つのカレンダーを作成
・そのカレンダーに、ごみ収集日を登録
・カレンダーを読み取り専用で一般公開
・カレンダーの共有リンクを取得
・公開ページで使う HTML の一部を作成
 これも手入力で作るのは辛い部分の HTML を作成しています。

認証は「OAuth クライアント ID」で行っており、作成した認証の json ファイルはダウンロードして credentials.json にリネームしています。

カレンダーの共有リンクを取得する方法が API リファレンスなどでは見つからず、コードの作成は無理かと思いましたが Stack Overflow で見つかったので、そのまま流用しました。

収集日を1つずつ登録していくので結構時間が掛かります。
また、一度に全て実行すると無料枠を超えそうなので実際は地区を分け、日を変えて登録しました。

import base64
import csv
import os.path

from google.auth.transport.requests import Request
from google.oauth2.credentials import Credentials
from google_auth_oauthlib.flow import InstalledAppFlow
from googleapiclient.discovery import build
from googleapiclient.errors import HttpError

# Scopesを変更する場合は、token.jsonファイルを削除してください。
SCOPES = ["https://www.googleapis.com/auth/calendar"]


def getCredentials():
    creds = None
    # token.jsonファイルには、ユーザーのアクセストークンとリフレッシュトークンが保存されており、
    # 認証フローが初めて完了したときに自動的に作成されます。
    if os.path.exists("token.json"):
        creds = Credentials.from_authorized_user_file("token.json", 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(
                "credentials.json", SCOPES
            )
            creds = flow.run_local_server(port=0)
        # 次回の実行のために資格情報を保存します
        with open("token.json", "w") as token:
            token.write(creds.to_json())
    return creds


# 共有リンクのcidを取得
# https://stackoverflow.com/questions/55150173/google-calendar-get-shareable-link-via-api-get-cid-value-for-a-calendar
def getCalenderCID(calendarId):
    calendarId_bytes = calendarId.encode("utf-8")
    cid_base64 = base64.b64encode(calendarId_bytes)
    cid = cid_base64.decode().rstrip("=")
    return cid


# 新しいカレンダーを作成
def createCalendar(service, calendarName, description):
    # 新しいカレンダーを作成
    calendar = {
        "summary": calendarName,
        "description": description,
        "timeZone": "Asia/Tokyo",
    }
    request = service.calendars().insert(body=calendar).execute()
    # カレンダーIDを取得
    calendarId = request["id"]
    return calendarId


# CSVファイルからイベントをインポート
def importEvents(service, calendarId, csvFile):
    with open(csvFile, "r", encoding="utf-8") as f:
        reader = csv.reader(f)
        next(reader)  # ヘッダー行を読み飛ばす
        for row in reader:
            # CSVファイルから予定データを取得
            subject, start_date = row
            is_all_day = True  # 終日イベント
            # イベントを作成
            event = {
                "summary": subject,
                "location": "",
                "start": {
                    "date": start_date,
                    "timeZone": "Asia/Tokyo",
                },
                "end": {
                    "date": start_date,
                    "timeZone": "Asia/Tokyo",
                },
                "allDay": is_all_day,
            }
            service.events().insert(
                calendarId=calendarId, body=event
            ).execute()
            print(f"イベントが作成されました: {subject}")


def main():
    credentials = getCredentials()
    service = build("calendar", "v3", credentials=credentials)

    trs = []
    with open("./2024/2024nagano_area.csv", "r", encoding="utf-8") as f:
        reader = csv.reader(f)
        next(reader)  # ヘッダー行を読み飛ばす
        for row in reader:
            no, area = row
            no = no.zfill(2)
            print(f"{no}:{area}")
            csvFile = f"./2024/2024nagano_area{no}.csv"

            try:
                calendarName = f"長野市{no.zfill(2)}_ごみ収集日"
                calendarId = createCalendar(service, calendarName, area)
                print(f"カレンダーが作成されました: {calendarName}")
                importEvents(service, calendarId, csvFile)

                rule = {
                    "scope": {
                        "type": "default",
                    },
                    "role": "reader",
                }
                created_rule = (
                    service.acl()
                    .insert(calendarId=calendarId, body=rule)
                    .execute()
                )
                print(created_rule)
                service.acl().list(calendarId=calendarId).execute()
                cid = getCalenderCID(calendarId)
                print(f"cid={cid}")
                sharedLink = f"https://calendar.google.com/calendar?cid={cid}"

                tr = (
                    f"<tr>"
                    f"<td>{no}</td>"
                    f"<td><a href="
                    f'"/wp-content/uploads/2024/03/2024nagano_area{no}.csv" '
                    f'download="">{area}</a></td>'
                    f'<td><a href="{sharedLink}" '
                    f'target="_blank" rel="noopener noreferrer">'
                    f"カレンダーに追加</a></td>"
                    f"</tr>"
                )
                trs.append(tr)

            except HttpError as error:
                print(f"エラーが発生しました: {error}")

    # 共有リンクを公開する際に使うHTMLを保存
    with open("./tbody.html", "w", encoding="utf-8") as f:
        f.write("\n".join(trs))


if __name__ == "__main__":
    main()

子ディレクトリの 2024 に置いたファイルですが、
2024nagano_areaXX.csv は公開している CSV ファイル
 (但し収集対象の名称を短くする為に一部変更している)
2024nagano_area.csv は以下の内容
です。

"no","area"
1,"第一"
2,"第二"
3,"第三"
4,"第四"
5,"第五"
6,"芹田"
7,"古牧"
8,"三輪"
9,"吉田"
10,"古里、朝陽"
11,"柳原、若穂"
12,"浅川"
13,"大豆島"
14,"若槻"
15,"長沼"
16,"安茂里"
17,"小田切"
18,"芋井"
19,"篠ノ井塩崎、篠ノ井共和、篠ノ井川柳、篠ノ井信里"
20,"篠ノ井東福寺、篠ノ井西寺尾"
21,"篠ノ井中央"
22,"松代"
23,"川中島"
24,"更北"
25,"七二会"
26,"信更"
27,"戸隠中社、戸隠宝光社、戸隠上楠川"
28,"戸隠北部、戸隠中央、戸隠東部、戸隠南部、戸隠川手、戸隠志垣"
29,"戸隠西部、戸隠平、戸隠西条、戸隠追通、戸隠上祖山、戸隠下祖山"
30,"鬼無里上里、鬼無里中央1"
31,"鬼無里中央2、鬼無里両京"
32,"大岡甲(川口を除く)、大岡中牧、大岡弘崎、大岡聖ヶ岡"
33,"大岡乙、大岡丙(聖ヶ岡を除く)、大岡川口"
34,"豊野南郷、豊野石、豊野西町、豊野上組、豊野立町、豊野南町、豊野中尾1・2"
35,"豊野横町、豊野伊豆毛、豊野上田中、豊野神代町、豊野本町1・2、豊野中尾1・2(一部)、豊野小瀬1、豊野泉平、豊野上神代、豊野豊陽台、豊野沖1・2、豊野中央組、豊野向原"
36,"豊野本町3・4・5、豊野東町、豊野小瀬2、豊野ゆたかの、豊野豊南町、豊野下田中"
37,"豊野浅野、豊野蟹沢、豊野入、豊野小日向、豊野上堰、豊野鳥居団地、豊野大方、豊野橋場、豊野上原、豊野蟻ケ崎、豊野城山、豊野川谷"
38,"信州新町旭町、信州新町仲町、信州新町上町、信州新町西上町、信州新町常磐町、信州新町鹿島東、信州新町鹿島西、信州新町大原東、信州新町大原西、信州新町下市場、信州新町牧野島(伊切を除く)、信州新町鹿道、信州新町鹿道団地"
39,"信州新町久保、信州新町本町、信州新町境町、信州新町千原田、信州新町平、信州新町和平団地、信州新町藤池団地、信州新町下川西平、信州新町太田笠子(石畑を除く)、信州新町穂刈下、信州新町穂刈中、信州新町穂刈上、信州新町穂刈北、信州新町穂刈団地、信州新町陽のあたる丘、信州新町大門、信州新町LR、信州新町原、信州新町道祖神"
40,"信州新町津和中央、信州新町山秋、信州新町中福、信州新町栃久保、信州新町中尾、信州新町菅沼、信州新町細尾、信州新町津上、信州新町外味藤、信州新町豊和、信州新町津南、信州新町中組、信州新町味藤、信州新町橋場、信州新町安用、信州新町風越、信州新町追沢、信州新町神田、信州新町花倉、信州新町二丁田、信州新町穴平、信州新町寺尾、信州新町矢ノ尻、信州新町峰組、信州新町枌ノ木、信州新町赤柴、信州新町石畑、信州新町尾崎、信州新町上古、信州新町芦沢、信州新町本村、信州新町大河、信州新町西日時"
41,"信州新町塩本、信州新町伊切、信州新町牧田中一、信州新町牧田中二、信州新町中牧一、信州新町中牧二、信州新町南牧住平、信州新町一倉田和、信州新町下中山、信州新町日名、信州新町和田吐唄、信州新町置原、信州新町橋木、信州新町左右、信州新町岩下、信州新町信級中央、信州新町高見、信州新町岩本、信州新町柳高、信州新町川名"
42,"中条"

コメント