1. はじめに
気になるキーワードで勉強会サービスを検索し、Googleカレンダーを自動的に作ってくれるようにしました。この記事では、キーワードにCoderDojo
を使った例を示します。
CoderDojoでは、プログラミングを楽しみたい子どもたちをボランティアのメンターが支援します。
CoderDojo は7〜17歳を主な対象とした非営利のプログラミング道場です。2011年にアイルランドで始まり、世界では100カ国・2,000の道場、日本には216以上の道場があります。CoderDojo は日本各地で毎年1,000回以上開催されており、延べ10,000人以上の子ども達がプログラミングを学んでいます
https://coderdojo.jp/partnership より引用
私も一緒に楽しみたいなと思い、CoderDojoのメンターとして活動しはじめました。今は様々な道場にお邪魔して支援の仕方を学んでいるところです。
全国各地に存在する道場の情報はCoderDojo Japanのサイトに集まっています。直近の開催予定は近日開催の道場で確認できます。
この開催予定を「普段使っているGoogleカレンダーで見たい」と思い、公開カレンダーを自動生成してみました。都道府県別にカレンダーを分けているので、興味のある都道府県だけ購読できます。
その後、@yasulabさんのご協力でcoderdojo.jpのEvent APIが拡張され、CoderDojo Japanと同じ開催予定をカレンダーに掲載できるようになりました
勉強会サービスのconnpass
とDoorkeeper
は使わなくなってしまったのですが「気になるキーワードで勉強会カレンダーを作る」ニーズはありそうだと思ったので、こちらの記事を残しておきます。
この記事では、自動生成した都道府県別のCoderDojoカレンダーを最初にご紹介します。そして、自動生成に使った技術と、その詳細について解説していきます。
2. CoderDojoカレンダーの使い方
2023年7月15日現在、28都道府県のカレンダーを公開しています(リンクになっている都道府県です)。
北海道、青森県、岩手県、宮城県、秋田県、山形県、福島県、茨城県、栃木県、群馬県、埼玉県、千葉県、東京都、神奈川県、新潟県、富山県、石川県、福井県、山梨県、長野県、岐阜県、静岡県、愛知県、三重県、滋賀県、京都府、大阪府、兵庫県、奈良県、和歌山県、鳥取県、島根県、岡山県、広島県、山口県、徳島県、香川県、愛媛県、高知県、福岡県、佐賀県、長崎県、熊本県、大分県、宮崎県、鹿児島県、沖縄県
カレンダーを購読すると、自分のGoogleカレンダー上に表示し、他の予定と見比べることができるようになります。購読するには、カレンダーの右下にある「+ Google Calendar」というアイコンをクリックしてください。
最新のカレンダー一覧はこちらのスプレッドシートに掲載しています。
3. 使っている技術
Google Apps Scriptを毎朝起動し、勉強会サービスのAPIでCoderDojoの予定を検索して、公開カレンダーに追加・更新していました。
3.1 勉強会サービスのAPIで開催予定を検索
CoderDojoの開催予定は、connpass APIとDoorkeeper APIを使って取得しています。認証なしでAPIを使えます。
どちらもCoderDojo
で検索しています。ただ「CoderDojoに寄付します」という説明書きがあるイベントもヒットしてしまうので、イベントのタイトルにDojo
や道場
があるものに絞り込んでいます。
APIの使い方はほぼ同じですが、取得できる項目名や、検索結果が多いときのページング処理など、細かな違いが多くあります。それぞれAPIに合わせて作り込む必要があります。
3.2 Google Apps Scriptを使って無料で実現
実行環境はGoogle Apps Scriptです。いいところがたくさんあります。
- 無料で使える
- 定期実行できるのもありがたいです
- Googleスプレッドシートをデータベース代わりに使います
- GoogleカレンダーやGoogleマップのAPIを呼び出せる
- 共有カレンダーを作るだけでなく、一般に公開することもできます
- Geocoderで緯度経度から都道府県を取得できます(ただし、一定時間内にアクセスできる回数に制限があります)
4. 実装の詳細
APIで予定を検索し、新しい予定はスプレッドシートに追加、更新された予定はスプレッドシートを更新します。新規と更新された予定をカレンダーに設定します。
4.1 開催予定を収集しシートに記録
APIで収集した開催予定は、「イベント一覧」シートに記録します。
4.1.1 APIの呼び出しでエラーが発生した場合にリトライ
APIを呼び出した結果が、正常(HTTPレスポンスコードが200)ではなかった場合、少し時間をおいてリトライすることにします。
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を使っています。
便利な機能なのですが、一定時間内でリクエストできる回数に上限があります。連続した利用にならないよう、Utilities.sleep()
で少し待ってから呼び出しています。
なお、オンライン開催のものは都道府県を判定できないため、カレンダーに掲載していません。
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の項目にできるだけ合わせています。
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件より少なくなるまで検索を繰り返します。
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()
で更新します。
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を操作します。
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の組は予定を更新するときに必要になるため、「カレンダーイベント一覧」に追記しておきます。
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の検索からカレンダーの更新まで、一連の動作を定期実行します。
更新があった開催予定だけ、カレンダーを更新しています。
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の利点を活かしています。
自分の近所で開催されている勉強会に絞り込むことで、勉強会に参加し学ぶ機会を増やすことができます。興味のある方はぜひ試してみてください。