都道府県別のCoderDojoカレンダーをGoogle Apps Scriptで自動生成

# 1. 都道府県別CoderDojoカレンダー

CoderDojoは子どものためのプログラミング道場です。全国各地で開催されています。

開催予定をGoogleカレンダーで見られるようにしました。2025年2月24日現在、30都道府県のカレンダーを公開しています（リンクになっている都道府県です）。

[北海道](https://calendar.google.com/calendar/embed?src=1mk6nkcbg1gqojnn2n6li4fbhk@group.calendar.google.com&ctz=Asia%2FTokyo)、[青森県](https://calendar.google.com/calendar/embed?src=foickoj4ek1d52j4m5v6lo1r6c@group.calendar.google.com&ctz=Asia%2FTokyo)、[岩手県](https://calendar.google.com/calendar/embed?src=a3e32bh3lo7vmq6fq4284u75ls@group.calendar.google.com&ctz=Asia%2FTokyo)、[宮城県](https://calendar.google.com/calendar/embed?src=kmqs5e740lah0rin5fchjmstns@group.calendar.google.com&ctz=Asia%2FTokyo)、秋田県、[山形県](https://calendar.google.com/calendar/embed?src=u4o87qvd6516pi7gcgorbkg6k0@group.calendar.google.com&ctz=Asia%2FTokyo)、[福島県](https://calendar.google.com/calendar/embed?src=bf0e6moa59b3vu5ft6n2ipnqn8@group.calendar.google.com&ctz=Asia%2FTokyo)、[茨城県](https://calendar.google.com/calendar/embed?src=br5aglvulqq11i3jsgl9svnnu8@group.calendar.google.com&ctz=Asia%2FTokyo)、[栃木県](https://calendar.google.com/calendar/embed?src=3kqn0tss0e4i9gg4omm0ko6hlg@group.calendar.google.com&ctz=Asia%2FTokyo)、群馬県、[埼玉県](https://calendar.google.com/calendar/embed?src=ve9k3lkkvicsohi9eqpbikiui8@group.calendar.google.com&ctz=Asia%2FTokyo)、[千葉県](https://calendar.google.com/calendar/embed?src=ctjqggvph13kusu710hnaahfi4@group.calendar.google.com&ctz=Asia%2FTokyo)、[東京都](https://calendar.google.com/calendar/embed?src=52414902b02bfea817daf1a3794bed0dfb554380c2e59e652a14efe7aa139952@group.calendar.google.com&ctz=Asia%2FTokyo)、[神奈川県](https://calendar.google.com/calendar/embed?src=jqecn2ok81jmje2nemgtpaptig@group.calendar.google.com&ctz=Asia%2FTokyo)、新潟県、[富山県](https://calendar.google.com/calendar/embed?src=4me6bpho9s6mjrqoc1iamk92bs@group.calendar.google.com&ctz=Asia%2FTokyo)、石川県、福井県、[山梨県](https://calendar.google.com/calendar/embed?src=eb751ce6401d433d16a66b2446eee1a6ba7e3716209cabb93ea09388da9db7e6@group.calendar.google.com&ctz=Asia%2FTokyo)、[長野県](https://calendar.google.com/calendar/embed?src=qb8jl5smmf0kgnb9p45u7h9ao8@group.calendar.google.com&ctz=Asia%2FTokyo)、[岐阜県](https://calendar.google.com/calendar/embed?src=76me4vddlpefbfulqdqkpdej3o@group.calendar.google.com&ctz=Asia%2FTokyo)、[静岡県](https://calendar.google.com/calendar/embed?src=jb0bpaidrgj3lmkuf55en9ldk8@group.calendar.google.com&ctz=Asia%2FTokyo)、[愛知県](https://calendar.google.com/calendar/embed?src=5121ihgbppeojtd6poka91qfb4@group.calendar.google.com&ctz=Asia%2FTokyo)、三重県、滋賀県、[京都府](https://calendar.google.com/calendar/embed?src=jsvi80eondjug7e7rjsnmubeug@group.calendar.google.com&ctz=Asia%2FTokyo)、[大阪府](https://calendar.google.com/calendar/embed?src=1oeb4r9ad57scco22uvnlbehs4@group.calendar.google.com&ctz=Asia%2FTokyo)、[兵庫県](https://calendar.google.com/calendar/embed?src=0hstc9s4lok88b5n9nk6n0v608@group.calendar.google.com&ctz=Asia%2FTokyo)、[奈良県](https://calendar.google.com/calendar/embed?src=bvntk8imtc18c0i3lshpdi07ts@group.calendar.google.com&ctz=Asia%2FTokyo)、和歌山県、鳥取県、島根県、[岡山県](https://calendar.google.com/calendar/embed?src=h36t3bbopfvfp0h195b6a08eug@group.calendar.google.com&ctz=Asia%2FTokyo)、[広島県](https://calendar.google.com/calendar/embed?src=1tkl40fcd476jdf6ihtjrfjlsg@group.calendar.google.com&ctz=Asia%2FTokyo)、[山口県](https://calendar.google.com/calendar/embed?src=9td6qtv8f343v2ha0n4a8qu2vc@group.calendar.google.com&ctz=Asia%2FTokyo)、徳島県、香川県、[愛媛県](https://calendar.google.com/calendar/embed?src=7hssd9foe5catf2gk7dbfulotg@group.calendar.google.com&ctz=Asia%2FTokyo)、[高知県](https://calendar.google.com/calendar/embed?src=e971e76742c9c0ecd6471483bd722715267e5e7ce0cfdbfa98d8b0449fecc279@group.calendar.google.com&ctz=Asia%2FTokyo)、[福岡県](https://calendar.google.com/calendar/embed?src=sjanoqd3hve2ftmi45d2f6n2vc@group.calendar.google.com&ctz=Asia%2FTokyo)、佐賀県、長崎県、熊本県、大分県、宮崎県、[鹿児島県](https://calendar.google.com/calendar/embed?src=86g9k5t6s3rcrdppqk77pm21nc@group.calendar.google.com&ctz=Asia%2FTokyo)、[沖縄県](https://calendar.google.com/calendar/embed?src=gackb36jcbtd7iai1sa4eem7b4@group.calendar.google.com&ctz=Asia%2FTokyo)

カレンダーを購読すると、自分のGoogleカレンダー上に表示して、スケジュール調整がしやすくなります。購読するには、カレンダーの右下にある「+ Google Calendar」というアイコンをクリックしてください。
<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/6760/1f6e8664-fab0-0a4b-f02b-444dd111b546.png" width="120" alt="+ Google Calendar">

[最新のカレンダー一覧はこちらのスプレッドシート](https://docs.google.com/spreadsheets/d/1lzyGJHd6oE9ETI9fitjQxErdX_LkQPyUCQJHGVJM0Lw/edit?usp=sharing)に掲載しています。

# 2. カレンダーを作った背景

CoderDojoでは、プログラミングを楽しみたい子どもたちをボランティアのメンターが支援します。

> CoderDojo は7〜17歳を主な対象とした非営利のプログラミング道場です。2011年にアイルランドで始まり、世界では100カ国・2,000の道場、日本には216以上の道場があります。CoderDojo は日本各地で毎年1,000回以上開催されており、延べ10,000人以上の子ども達がプログラミングを学んでいます
https://coderdojo.jp/partnership より引用

私も一緒に楽しみたいなと思い、CoderDojoのメンターとして活動しはじめました。今は様々な道場にお邪魔して支援の仕方を学んでいるところです。

全国各地に存在する道場の情報は[CoderDojo Japan](https://coderdojo.jp)のサイトに集まっています。直近の開催予定は[近日開催の道場](https://coderdojo.jp/events)で確認できます。

この開催予定を「普段使っているGoogleカレンダーで見たい」と思い、公開カレンダーを自動生成してみました。都道府県別にカレンダーを分けているので、興味のある都道府県だけ購読できます。

![スクリーンショット 2023-07-09 15.56.05.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/6760/1980fea3-23bc-802c-0322-d49dd1279b24.png)

[@yasulab](https://github.com/yasulab)さんのご協力で[coderdojo.jp](https://github.com/coderdojo-japan/coderdojo.jp)のEvent APIが拡張され、CoderDojo Japanと同じ開催予定をカレンダーに掲載できるようになりました :clap: 

この記事では、自動生成した都道府県別のCoderDojoカレンダーを最初にご紹介します。そして、自動生成に使った技術と、その詳細について解説していきます。

以前は勉強会サービスの[connpass](https://connpass.com/)と[Doorkeeper](https://www.doorkeeper.jp/?locale=ja)で`CoderDojo`を検索していました。その時の記事はこちらです。

https://qiita.com/takatama/items/3aff492e6ea8c6dbe178


# 3. 使っている技術

Google Apps Scriptを毎朝起動し、[coderdojo.jp](https://github.com/coderdojo-japan/coderdojo.jp)のEvent APIから予定を取得し、公開カレンダーに追加・更新しています。

## 3.1 coderdojo.jpのEvent APIで開催予定を取得

https://coderdojo.jp/events.json?all_events=true で、その日以降に開催予定のイベントをJSON形式で取得できます。

| 項目 | 説明 | 例 |
|---|---|---|
| id | DojoのID。[Dojo API](https://coderdojo.jp/dojos.json)と同じ | 289 |
| name | Dojoの名前 | "たまち" |
| url | DojoのURL | "https://coderdojo-tamachi.connpass.com/" |
| event_id | 開催予定のID | 5722 |
| event_title | 開催予定のタイトル | "第8回CoderDojoたまち" |
| event_date | 開始時刻 | "2023-07-23T14:00:00.000+09:00" |
| event_end_at | 終了時刻 | "2023-07-23T16:30:00.000+09:00" |
| event_url | 開催予定のURL | "https://coderdojo-tamachi.connpass.com/event/288359/" |
| prefecture | 都道府県 | "東京" |
| participants | 参加者数 | 6 |
| event_update_at | 更新時刻 | "2023-06-25T19:02:06.000+09:00" |
| address | 住所 | "東京都港区芝三丁目41番8号" |
| place | 場所 | "駐健保会館 中会議室" |
| limit" | 定員数 | 12 |

## 3.2 Google Apps Scriptを使って無料で実現

実行環境はGoogle Apps Scriptです。いいところがたくさんあります。

- 無料で使える
  - 定期実行できるのもありがたいです
  - Googleスプレッドシートをデータベース代わりに使います
- GoogleカレンダーやGoogleマップのAPIを呼び出せる
  - [共有カレンダーを作る](https://developers.google.com/apps-script/reference/calendar/calendar-app?hl=ja#createcalendarname,-options)だけでなく、[一般に公開](https://developers.google.com/calendar/api/v3/reference/acl?hl=en)することもできます
  - [Geocoder](https://developers.google.com/apps-script/reference/maps/geocoder?hl=ja)で緯度経度から都道府県を取得できます（ただし、一定時間内にアクセスできる回数に制限があります）

# 4. 実装の詳細

APIで予定を検索し、新しい予定はスプレッドシートに追加、更新された予定はスプレッドシートを更新します。新規と更新された予定をカレンダーに設定します。

## 4.1 開催予定を収集しシートに記録

APIで収集した開催予定は、「イベント一覧」シートに記録します。

### 4.1.1 APIの呼び出しでエラーが発生した場合にリトライ

APIを呼び出した結果が、正常（HTTPレスポンスコードが200）ではなかった場合、少し時間をおいてリトライすることにします。

```javascript:CoderDojoEvents.gs
const MAX_RETRIES = 3;
const RETRY_DELAY_MS = 500;

function fetchWithRetry_(url) {
  for (let i = 0; i < MAX_RETRIES; i++) {
    const response = UrlFetchApp.fetch(url, { muteHttpExceptions: true });
    if (response.getResponseCode() === 200) {
      return response.getContentText();
    }
    Utilities.sleep(RETRY_DELAY_MS * (i + 1));
  }
  throw new Error(`Failed to fetch URL: ${url}`);
}
```

### 4.1.2 Event APIで開催予定を取得

Event APIから取得できる都道府県名には、都・道・府・県がついていないので、それをつけてあげます。また、住所が空で、タイトルにオンラインがあるものは、オンライン開催としています。

```javascript:CoderDojoEvents.gs
const SPECIAL_PREFECTURES = {
  '東京': '東京都',
  '北海道': '北海道',
  '大阪': '大阪府',
  '京都': '京都府'
};

function fetchAllEvents_() {
  function onlineIfBlank(prop, title) {
    return (!prop && title.includes('オンライン')) ? 'オンライン' : prop;
  }
  function normalize(event) {
    const event_date = new Date(event.event_date);
    const event_end_at = new Date(event.event_end_at);
    const event_update_at = new Date(event.event_update_at);
    const prefecture = SPECIAL_PREFECTURES[event.prefecture] || event.prefecture + '県'
    const address = onlineIfBlank(event.address, event.event_title);
    const place = onlineIfBlank(event.place, event.event_title);
    return {
      ...event, event_date, event_end_at, event_update_at, prefecture, place, address
    };
  }

  const url = 'https://coderdojo.jp/events.json?all_events=true';
  const events = JSON.parse(fetchWithRetry_(url));
  return events.map(normalize);
}

```

### 4.1.3 収集した開催予定をシートに記録

APIで取得した開催予定は、スプレッドシートに記録します。イベントIDを使ってシートを探し、なければ`sheet.appendRow()`で追記し、あれば更新日を確認して新しいものなら`sheet.getRange().setValues()`で更新します。

```javascript:CoderDojoEvents.gs
const SHEET_NAME_CODER_DOJO_EVENTS = 'イベント一覧';

function toEvent_(row) {
  const [event_id, prefecture, name, event_title, event_url, event_date, event_end_at, event_update_at, limit, participants, place, address ] = row;
  return { event_id, prefecture, name, event_title, event_url, event_date: new Date(event_date), event_end_at: new Date(event_end_at), event_update_at: new Date(event_update_at), limit, participants, place, address };
}

function toRow_(event) {
  const { event_id, prefecture, name, event_title, event_url, event_date, event_end_at, event_update_at, limit, participants, place, address } = event;
  return [event_id, prefecture, name, event_title, event_url, event_date, event_end_at, event_update_at, limit, participants, place, address];
}

function updateEventsSheet(forceUpdate = false) {
  const updatedEvents = [];
  const sheet = SpreadsheetApp.getActive().getSheetByName(SHEET_NAME_CODER_DOJO_EVENTS);
  const oldEvents = sheet.getDataRange().getValues().map(row => toEvent_(row));
  fetchAllEvents_().forEach(newEvent => {
    const oldEventIndex = oldEvents.findIndex(e => e.event_id === newEvent.event_id);
    if (oldEventIndex === -1) {
      updatedEvents.push(newEvent);
      sheet.appendRow(toRow_(newEvent));
      console.log('追加', newEvent);
    } else {
      const oldEvent = oldEvents[oldEventIndex];
      if (forceUpdate || oldEvent.event_update_at < new Date(newEvent.event_update_at)) {
        updatedEvents.push(newEvent);
        const newEventRow = toRow_(newEvent);
        sheet.getRange(oldEventIndex + 1, 1, 1, newEventRow.length).setValues([newEventRow]);
        console.log('更新', newEvent);
      }
    }
  });
  return updatedEvents;
}
```

## 4.2 Googleカレンダーの操作

開催予定の収集が終わったら、その情報をGoogleカレンダーに掲載します。

都道府県別の公開カレンダーは「カレンダー一覧」シートで、カレンダーに掲載する予定は「カレンダーイベント一覧」で、それぞれ管理します。

### 4.2.1 都道府県別の公開カレンダーを作成

「カレンダー一覧」シートには、都道府県名とカレンダーIDを記載します。

新しい都道府県が見つかった場合、新しくカレンダーを作り、そのカレンダーを公開します。カレンダーを公開するために[Calendar APIでACLを操作](https://developers.google.com/calendar/api/v3/reference/acl?hl=en)します。

```javascript:Calendar.gs
const SHEET_NAME_CALENDARS = 'カレンダー一覧';

// https://developers.google.com/apps-script/advanced/calendar?hl=en
// https://developers.google.com/calendar/api/v3/reference/acl?hl=en
function makePublic_(calendarId) {
  Calendar.Acl.insert({
    kind: 'calendar#aclRule',
    role: 'reader',
    scope: {
      value: '__public_principal__@public.calendar.google.com',
      type: 'default'
    },
  }, calendarId);
}

function fetchOrCreateCalendar_(prefecture) {
  function asCalendar_(row) {
    const [ prefecture, calendarId, url ] = row;
    return { prefecture, calendarId, url };
  }
  if (!prefecture) throw 'invalid prefecture';
  const sheet = SpreadsheetApp.getActive().getSheetByName(SHEET_NAME_CALENDARS);
  const calendars = sheet.getDataRange().getValues().slice(1).map(row => asCalendar_(row));
  const calendar = calendars.find(c => c.prefecture === prefecture);
  if (calendar) {
    return CalendarApp.getCalendarById(calendar.calendarId);
  }
  const name = `CoderDojo ${prefecture}`;
  const description = `${prefecture}で開催されるCoderDojoの予定です。connpassとDoorkeeperから取得しています。`;
  // timeZone を指定しないと GMT になる
  const newCalendar = CalendarApp.createCalendar(name, {
    color: '#e6e6fa', // lavender
    timeZone: 'Asia/Tokyo'
  });

  newCalendar.setDescription(description);
  const newCalendarId = newCalendar.getId();
  makePublic_(newCalendarId);
  console.log('新しいカレンダーです', name, description);
  const url = `https://calendar.google.com/calendar/embed?src=${newCalendarId}&ctz=Asia%2FTokyo`;
  sheet.appendRow([prefecture, newCalendarId, url]);
  return newCalendar;
}
```

### 4.2.2 カレンダーイベントを作成

開催予定に対応するカレンダーイベントを作り、カレンダーに追加します。開催予定のイベントIDと、カレンダーイベントIDの組は予定を更新するときに必要になるため、「カレンダーイベント一覧」に追記しておきます。

```javascript:Code.gs
const SHEET_NAME_CALENDAR_EVENT_IDS = 'カレンダーイベント一覧';

function getCalendarOption_(event) {
  return {
    description: [
      `URL: ${event.event_url}`,
      event.place ? `開催場所: ${event.place}`: '',
      `参加人数: ${event.participants}` + ((event.limit > 0) ? ` / ${event.limit}` : ''),
    ].filter(elem => elem).join('\n'),
    location: event.address
  }
}

function createCalenderEvent_(calendar, event) {
  return calendar.createEvent(
    event.event_title,
    new Date(event.event_date),
    new Date(event.event_end_at),
    getCalendarOption_(event)
  );
}

function updateCalenderEvent_(calendar, calendarEvenId, event) {
  const calendarEvent = calendar.getEventById(calendarEvenId);
  calendarEvent.setTitle(event.event_title);
  calendarEvent.setTime(event.event_date, event.event_end_at);
  const { description, location } = getCalendarOption_(event);
  calendarEvent.setDescription(description);
  calendarEvent.setLocation(location);
}

let CACHE_CALENDER_EVENTS;
function fetchCalenderEventId_(eventId) {
  function asCalendarEvent_(row) {
    const [ eventId, calendarId, calendarEventId ] = row;
    return { eventId, calendarId, calendarEventId };
  }
  if (!CACHE_CALENDER_EVENTS) {
    CACHE_CALENDER_EVENTS = SpreadsheetApp.getActive().getSheetByName(SHEET_NAME_CALENDAR_EVENT_IDS).getDataRange().getValues().slice(1).map(row => asCalendarEvent_(row));
  }
  const calendarEvent = CACHE_CALENDER_EVENTS.find(ce => ce.eventId === eventId);
  if (!calendarEvent) return null;
  return calendarEvent.calendarEventId;
}

function createOrUpdateCalenderEvents_(updatedEvents) {
  const sheet = SpreadsheetApp.getActive().getSheetByName(SHEET_NAME_CALENDAR_EVENT_IDS);
  for (let event of updatedEvents) {
    if (!event.prefecture) continue;
    const calendar = fetchOrCreateCalendar_(event.prefecture);
    const calenderEventId = fetchCalenderEventId_(event.event_id);
    if (!calenderEventId) {
      const calenderEvent = createCalenderEvent_(calendar, event);
      sheet.appendRow([event.event_id, calendar.getId(), calenderEvent.getId()]);
    } else {
      updateCalenderEvent_(calendar, calenderEventId, event);
    }
  }
}
```

## 4.3 一連の動作を実行

毎朝、APIの検索からカレンダーの更新まで、一連の動作を定期実行します。

更新があった開催予定だけ、カレンダーを更新しています。

```javascript:Code.gs
function updateEventsSheetAndCalendars(forceUpdate = false) {
  const updatedEvents = updateEventsSheet(forceUpdate);
  createOrUpdateCalenderEvents_(updatedEvents);
}
```

# 5. まとめ

この記事では、CoderDojoの開催予定をGoogleカレンダーで管理する方法について説明しました。具体的には、[coderdojo.jp](https://github.com/coderdojo-japan/coderdojo.jp)のEvent APIからCoderDojoの開催情報を取得し、それをGoogleカレンダーに自動的に追加する方法を示しました。これにより、CoderDojoの開催情報を一覧で確認しやすくなり、自分のスケジュールとの調整も容易になります。

また、このプロジェクトはGoogle Apps Scriptを使用して実装されており、無料で利用できること、Googleスプレッドシートをデータベース代わりに使用できること、Googleカレンダーを呼び出せることなど、Google Apps Scriptの利点を活かしています。

このプロジェクトはCoderDojoのメンターだけでなく、参加者やその保護者にも役立つと思います。興味のある方はぜひ試してみてください。