気になるキーワードで都道府県別の勉強会カレンダーを作ってみた（connpass + Doorkeeper + Google Apps Scriptで自動生成）

# 1. はじめに

気になるキーワードで勉強会サービスを検索し、Googleカレンダーを自動的に作ってくれるようにしました。この記事では、キーワードに`CoderDojo`を使った例を示します。

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: 

https://qiita.com/takatama/items/60276143e441c1c4f078

勉強会サービスの`connpass`と`Doorkeeper`は使わなくなってしまったのですが「気になるキーワードで勉強会カレンダーを作る」ニーズはありそうだと思ったので、こちらの記事を残しておきます。

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

# 2. CoderDojoカレンダーの使い方

2023年7月15日現在、28都道府県のカレンダーを公開しています（リンクになっている都道府県です）。

[北海道](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=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=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=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)に掲載しています。

# 3. 使っている技術

Google Apps Scriptを毎朝起動し、勉強会サービスのAPIでCoderDojoの予定を検索して、公開カレンダーに追加・更新していました。

## 3.1 勉強会サービスのAPIで開催予定を検索

CoderDojoの開催予定は、connpass APIとDoorkeeper APIを使って取得しています。認証なしでAPIを使えます。

https://connpass.com/about/api/

https://www.doorkeeper.jp/developer/api/

どちらも`CoderDojo`で検索しています。ただ「CoderDojoに寄付します」という説明書きがあるイベントもヒットしてしまうので、イベントのタイトルに`Dojo`や`道場`があるものに絞り込んでいます。

APIの使い方はほぼ同じですが、取得できる項目名や、検索結果が多いときのページング処理など、細かな違いが多くあります。それぞれAPIに合わせて作り込む必要があります。

## 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:Code.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 緯度と経度から都道府県を取得

開催場所から都道府県を判定し、都道府県別のカレンダーに掲載します。都道府県の情報ですが、開催場所に住所があればそこから、なければ緯度・軽度から都道府県を判定します。緯度・軽度から都道府県を判定するために、[Geocoder](https://developers.google.com/apps-script/reference/maps/geocoder?hl=ja)を使っています。

便利な機能なのですが、一定時間内でリクエストできる回数に上限があります。連続した利用にならないよう、`Utilities.sleep()`で少し待ってから呼び出しています。

なお、オンライン開催のものは都道府県を判定できないため、カレンダーに掲載していません。

```javascript:Code.gs
function getPrefectureName_(address, lat, lon) {
  if (address) {
    const regex = /(北海道|青森県|岩手県|宮城県|秋田県|山形県|福島県|茨城県|栃木県|群馬県|埼玉県|千葉県|東京都|神奈川県|新潟県|富山県|石川県|福井県|山梨県|長野県|岐阜県|静岡県|愛知県|三重県|滋賀県|京都府|大阪府|兵庫県|奈良県|和歌山県|鳥取県|島根県|岡山県|広島県|山口県|徳島県|香川県|愛媛県|高知県|福岡県|佐賀県|長崎県|熊本県|大分県|宮崎県|鹿児島県|沖縄県)/;
    const m = address.trim().match(regex);
    if (m) return m[0];
  }
  if (!lat || !lon) return '';
  Utilities.sleep(RETRY_DELAY_MS);
  const geo = Maps.newGeocoder().setLanguage('ja').reverseGeocode(lat, lon);
  return geo.results[0].address_components.find(c => c.types.includes('administrative_area_level_1')).short_name;
}
```

### 4.1.3 connpass APIで予定を検索

connpass APIは検索対象の日付（月）を指定しないと、過去の予定全てが結果に含まれてしまいます。そこで3カ月先まで指定して検索します。例えば2023年7月に検索する場合、APIのクエリーパラメーターに`ym=202307,202308,202309`を指定します。

検索結果が多かった場合のページング処理についてです。connpassの場合、検索結果の件数はレスポンスの`results_available`に記載されます。その件数分を取得するまで、クエリーパラメーターの`start`を増やしながら検索を繰り返します。

検索された予定の項目はconnpassの項目にできるだけ合わせています。

```javascript:Connpass.gs
const CONNPASS_MAX_COUNT = 100;

function parseConnpassEvent_(event) {
  const type = 'connpass';
  const { event_id, title, event_url, started_at, ended_at, updated_at, limit, accepted, place, address, lat, lon } = event;
  const catchCopy = event.catch;
  const prefecture = getPrefectureName_(address, lat, lon);
  return {
    type,
    eventId: event_id,
    title,
    catchCopy,
    eventUrl: event_url,
    startedAt: new Date(started_at),
    endedAt: new Date(ended_at),
    updatedAt: new Date(updated_at),
    limit,
    accepted,
    place,
    address,
    lat,
    lon,
    prefecture
  };
}

function searchConnpassEvents_(keyword = 'CoderDojo', months = 3, count = CONNPASS_MAX_COUNT) {
  // 検索対象の月を指定しないと過去分も検索される
  function getYYYYMMs_(months) {
    const str = [];
    const now = new Date();
    for (let i = 0; i < months; i++) {
      const d = new Date();
      d.setMonth(now.getMonth() + i);
      str.push(Utilities.formatDate(d, 'JTC', 'yyyyMM'));
    }
    return str.join(',');
  }

  let start = 1;
  const allEvents = [];
  while(true) {
    const url = `https://connpass.com/api/v1/event/?keyword=${keyword}&ym=${getYYYYMMs_(months)}&count=${count}&start=${start}`;
    const json = JSON.parse(fetchWithRetry_(url));
    allEvents.push(...json.events);
    if (start + count > json.results_available) break;
    start += count;
  }
  // 「CoderDojoに寄付します」と説明しているイベントを除外する
  return allEvents.filter(event => event.title.includes('Dojo') || event.title.includes('道場')).map(parseConnpassEvent_);
}
```

### 4.1.4 Doorkeeper APIで予定を検索

Doorkeeper APIは検索した時点以降の予定が検索されます。なので、日付指定しなくても、過去分が検索されることはありません。

検索結果が多かった場合のページング処理についてです。DoorkeeperのAPIは、検索結果の数が分かりません。1回の検索で最大25件を取得できるので、クエリーパラメーターの`page`を増やしながら、検索結果が25件より少なくなるまで検索を繰り返します。

```javascript:Doorkeeper.gs
const DOORKEEPER_ITEMS_PER_PAGE = 25;

function parseDoorkeeperEvent_(event) {
  const type = 'doorkeeper';
  const { id, title, public_url, starts_at, ends_at, updated_at, ticket_limit, participants, venue_name, address, lat, long } = event;
  const prefecture = getPrefectureName_(address, lat, long);
  return {
    type,
    eventId: id,
    title,
    catchCopy: '',
    eventUrl: public_url,
    startedAt: new Date(starts_at),
    endedAt: new Date(ends_at),
    updatedAt: new Date(updated_at),
    limit: ticket_limit,
    accepted: participants,
    place: venue_name,
    address,
    lat,
    lon: long,
    prefecture
  };
}

function searchDoorkeeperEvents_(keyword = 'CoderDojo') {
  let page = 1;
  const allEvents = [];
  while(true) {
    const url = `https://api.doorkeeper.jp/events?q=${keyword}&page=${page++}`;
    const events = JSON.parse(fetchWithRetry_(url)).map(elem => elem.event);
    allEvents.push(...events);
    if (events.length < DOORKEEPER_ITEMS_PER_PAGE) break;
  }
  // 「CoderDojoに寄付します」と説明しているイベントを除外する
  return allEvents.filter(event => event.title.includes('Dojo') || event.title.includes('道場')).map(parseDoorkeeperEvent_);
}
```

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

APIで検索した開催予定は、スプレッドシートに記録します。どのAPIで検索したのか（type）とイベントIDの組を使ってシートを探し、なければ`sheet.appendRow()`で追記し、あれば`sheet.getRange().setValues()`で更新します。

```javascript:Code.gs
function asEvent_(row) {
  const [type, eventId, title, catchCopy, eventUrl, startedAt, endedAt, updatedAt, limit, accepted, place, address, lat, lon, prefecture] = row;
  return { type, eventId, title, catchCopy, eventUrl, startedAt, endedAt, updatedAt, limit, accepted, place, address, lat, lon, prefecture };
}

function asRow_(event) {
  return [
    event.type, event.eventId, event.title, event.catchCopy, event.eventUrl, event.startedAt, event.endedAt, event.updatedAt, event.limit, event.accepted, event.place, event.address, event.lat, event.lon, event.prefecture
  ];
}

const SHEET_NAME_SEARCHED_EVENTS = '検索したイベント';

function updateOrAppendEvents_(events, forceUpdate = false) {
  const sheet = SpreadsheetApp.getActive().getSheetByName(SHEET_NAME_SEARCHED_EVENTS);
  const oldEvents = sheet.getDataRange().getValues().map(row => asEvent_(row));
  const updatedEvents = [];
  for (let event of events) {
    const oldEventRowIndex = oldEvents.findIndex(currentEvent => currentEvent.type === event.type && currentEvent.eventId === event.eventId);
    if (oldEventRowIndex === -1) {
      updatedEvents.push(event);
      sheet.appendRow(asRow_(event));
      console.log('新しく追加されました', event);
    } else {
      const oldEvent = oldEvents[oldEventRowIndex];
      if (forceUpdate || !oldEvent.updatedAt || oldEvent.updatedAt < event.updatedAt) {
        updatedEvents.push(event);
        const row = asRow_(event);
        sheet.getRange(oldEventRowIndex + 1, 1, 1, row.length).setValues([row]);
        console.log('更新されました', event);
      }
    }
  }
  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_(e) {
  function getParticipants_(limit, accepted) {
    if (limit > 0) {
      return `参加人数: ${accepted} / ${limit}`;
    } else if (accepted === 0 || accepted > 0) {
      return `参加人数: ${accepted}`;
    }
    return '';
  }

  return {
    description: [`${e.catchCopy}`, `URL: ${e.eventUrl}`, e.place ? `開催場所: ${e.place}`: '', getParticipants_(e.limit, e.accepted)].filter(elem => elem).join('\n'),
    location: e.address
  }
}

function createCalenderEvent_(calendar, event) {
  return calendar.createEvent(
    event.title,
    new Date(event.startedAt),
    new Date(event.endedAt),
    getCalendarOption_(event)
  );
}

function updateCalenderEvent_(calendar, calendarEvenId, event) {
  const calendarEvent = calendar.getEventById(calendarEvenId);
  calendarEvent.setTitle(event.title);
  calendarEvent.setTime(event.startedAt, event.endedAt);
  const option = getCalendarOption_(event);
  calendarEvent.setDescription(option.description);
  calendarEvent.setLocation(option.location);
}

let CACHE_CALENDER_EVENTS;
function fetchCalenderEventId_(type, eventId) {
  function asCalendarEvent_(row) {
    const [ type, eventId, calendarId, calendarEventId ] = row;
    return { type, 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.type === type && 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.type, event.eventId);
    if (!calenderEventId) {
      const calenderEvent = createCalenderEvent_(calendar, event);
      sheet.appendRow([event.type, event.eventId, calendar.getId(), calenderEvent.getId()]);
    } else {
      updateCalenderEvent_(calendar, calenderEventId, event);
    }
  }
}
```

## 4.3 一連の動作を実行

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

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

```javascript:Code.gs
function updateEventsAndCalendars(forceUpdate = false) {
  const events = [];
  events.push(...searchConnpassEvents_());
  events.push(...searchDoorkeeperEvents_());
  const updatedEvents = updateOrAppendEvents_(events, forceUpdate);
  createOrUpdateCalenderEvents_(updatedEvents);
}
```

# 5. まとめ

この記事では、勉強会サービスのAPIで開催予定を検索し、Googleカレンダーで管理する方法について説明しました。作ったカレンダーを購読すれば、自分のGoogleカレンダー上に表示でき、スケジュールとの調整も容易になります。

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

自分の近所で開催されている勉強会に絞り込むことで、勉強会に参加し学ぶ機会を増やすことができます。興味のある方はぜひ試してみてください。