0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 1 year has passed since last update.

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

Posted at

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カレンダーで見たい」と思い、公開カレンダーを自動生成してみました。都道府県別にカレンダーを分けているので、興味のある都道府県だけ購読できます。

スクリーンショット 2023-07-09 15.56.05.png

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

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

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

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

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

北海道青森県、岩手県、宮城県、秋田県、山形県福島県茨城県栃木県、群馬県、埼玉県千葉県東京都神奈川県、新潟県、富山県、石川県、福井県、山梨県、長野県岐阜県静岡県愛知県、三重県、滋賀県、京都府大阪府兵庫県奈良県、和歌山県、鳥取県、島根県、岡山県広島県山口県、徳島県、香川県、愛媛県、高知県、福岡県、佐賀県、長崎県、熊本県、大分県、宮崎県、鹿児島県沖縄県

カレンダーを購読すると、自分のGoogleカレンダー上に表示し、他の予定と見比べることができるようになります。購読するには、カレンダーの右下にある「+ Google Calendar」というアイコンをクリックしてください。
+ 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を呼び出せる

4. 実装の詳細

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

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

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

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

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

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を使っています。

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

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

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の項目にできるだけ合わせています。

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件より少なくなるまで検索を繰り返します。

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()で更新します。

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を操作します。

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の組は予定を更新するときに必要になるため、「カレンダーイベント一覧」に追記しておきます。

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の検索からカレンダーの更新まで、一連の動作を定期実行します。

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

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の利点を活かしています。

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

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?