GAS による Calendar → Task 同期 : hkob の雑記録 (515)

はじめに

hkob の雑記録の第515回目(連続88日目)は、Calendar Webhook を止め、 GAS による実装に切り替えた際の手順書を示します。GAS の実装は 5 年前に作成し、Google Calendar からの一方向同期をしていました。Notion Calendar が出て来たので、この同期の仕組みを止めて、Alfred で同時作成するようにしていました。意外と Webhook worker による同期の仕組みがうまく動いていたので、再開することにしました。

Webhook の後片付け

Workers 停止・削除(GAS 移行前)

※ 以降は「Notion Workers(calendar-webhook)を止めて消す」ための手順。GAS 移行前に実施する。

GAS を実装する前に Worker で動いているイベントなどを削除する必要があります。先にこちらの手順を作成してもらいました。

0. 事前確認(残しておく情報)

  • [x] 以下を控える(削除後に参照できなくなる可能性があるため)
    • [x] Webhook URL: https://www.notion.so/webhooks/worker/…./onCalendarNotification
    • [x] channelId / resourceId(watch state から)
      • [x] channelId: calendar-webhook-001
      • [x] resourceId : 省略
    • [x] CALENDAR_ID : 省略@group.calendar.google.com

何かの問題がある時に参照できなくなると問題があるので控えておきました。

1. Google Calendar watch を停止(channels.stop)

  • [x] まずは、このページにある scripts/gcal-stop-watch.mjs を実行して OK
    • 意図: Google 側の push 通知チャンネルを明示的に stop する(期限まで待たずに止める)
    • 実行(例):

        GCAL_CHANNEL_ID="calendar-webhook-001" \
        GCAL_RESOURCE_ID="..." \
        CALENDAR_ID="..." \
        GOOGLE_CLIENT_ID="..." \
        GOOGLE_CLIENT_SECRET="..." \
        GOOGLE_REFRESH_TOKEN="..." \
        node scripts/gcal-stop-watch.mjs
      
    • 期待ログ:

        stopped { channelId: "...", resourceId: "..." }
      
    • 実行ログ:

        stopped {
          channelId: 'calendar-webhook-001',
          resourceId: '省略'
        }
      

まず、push 通知を止めました。このスクリプトは前回作ってくれていたので、そのまま利用しました。

2. stop 後の確認(push が来ないこと)

  • [x] カレンダーのイベントを 1 件更新して、Notion Workers 側に [gcal] push ... が出ないことを確認
    • 以前のログ確認コマンド(例):
      • ntn workers runs list(最新 run を見る)
      • ntn workers runs logs <runId>(必要なら)
    • 確認ログ(ntn workers runs list | head):

        019e9818-863a-71d3-a6e2-04be9da90588    webhook:onCalendarNotification  0       2026-06-05T14:03:24.602Z        2026-06-05T14:03:33.896Z
        019e8bfa-0a0f-748b-886b-4a324fb3fbf7    webhook:onCalendarNotification  0       2026-06-03T05:34:40.143Z        2026-06-03T05:34:46.710Z
      

実際にカレンダーを操作して、Webhook が発動しないことを確認しました。

3. worker の Webhook / tool を無効化(念のため)

  • [x] src/index.tsworker.webhook("onCalendarNotification", ...) をコメントアウト(または削除)して ntn workers deploy
    • 意図: 万一 stop が失敗して push が届いても、worker が何もしない状態にする
    • 実行ログ:

        ntn workers deploy
        Deploying to workspace: hkob's ambassador workspace
        [build:out] Running npm install...
        [build:err] npm warn deprecated node-domexception@1.0.0: Use your platform's native DOMException instead
        [build:out] added 59 packages, and audited 60 packages in 3s
        [build:out] 18 packages are looking for funding
        [build:out] run `npm fund` for details
        [build:out] found 0 vulnerabilities
        [build:err] npm notice
        [build:err] npm notice New major version of npm available! 10.9.7 -> 11.16.0
        [build:err] npm notice Changelog: https://github.com/npm/cli/releases/tag/v11.16.0
        [build:err] npm notice To update run: npm install -g npm@11.16.0
        [build:err] npm notice
        [build:out] Running npm run build...
        [build:out] > @notionhq/workers-template@0.0.0 build
        [build:out] > tsc
        [build:out] Running npm install --omit=dev...
        [build:out] up to date, audited 54 packages in 368ms
        [build:out] 18 packages are looking for funding
        [build:out] run `npm fund` for details
        [build:out] found 0 vulnerabilities
        [build:out] Moving package.json...
        [build:out] Moving package-lock.json...
        [build:out] Moving node_modules...
        [build:out] Creating bundle from dist...
        [build:out] Uploading bundle...
        [build:out] Bundle upload complete.
        [build:out] Preparing runtime filesystem for snapshot...
        ✔ Worker updated
      
        Webhooks:
        - onCalendarNotification
      

この手順は基本的には必要ないのですが、Webhook が間違って呼ばれて作動しないように onCalendarNotification を削除し、デプロイしています。

4. Notion Workers の worker を削除

  • [x] Notion の Workers 画面で calendar-webhook を削除する
    • 目安: Workspaces の「Workers」一覧から該当 worker を開き、Delete/Remove を実行
    • 注意: 表示名が同じ worker が複数ある場合、ID を確認して誤削除しない

    ワーカーの削除

ワーカーの削除は Notion の設定からできるのですね。削除の手順も知ることができてよかったです。

5. 後片付け(ローカル)

  • [x] ローカルのリポジトリを残す/消すを決める
    • 結論: 参考のためリポジトリは残す(Step 5 は実施不要)

ローカルのリポジトリは特に影響がないので、残すことにしました。あとで何か参考にするかもしれませんので。

GAS の実装

今回は、2021 の記事(GAS)をベースに、External ID 方式 + data_source_id 対応に作り直す。

作業手順(実行→結果貼り付け)

0. 前提(今回の方針)

  • [ ] 同期方式
    • [ ] GAS の カレンダー更新トリガーで起動(イベント駆動)
    • [ ] Google Calendar API の syncToken 差分取得で変更イベントを取る
  • [ ] Notion 側の upsert 戦略
    • [ ] External ID = gcal:<calendarId>:event:<eventId> を作る
    • [ ] Notion Tasks を External ID で検索し、存在すれば update / 無ければ create
    • [ ] status=cancelled は Notion 側ページを archive(必要なら)
  • [ ] 永続化(GAS 側)
    • [ ] SYNC_TOKEN(差分取得用)
    • [ ] NOTION_API_TOKEN(秘匿)
    • [ ] TASKS_DATA_SOURCE_ID(今回の保存先)
    • [ ] CALENDAR_ID(対象カレンダー)

2021 年の記事では、カレンダーのコメント欄に Notion の page_id を書いていました。この実装だと重複タスクが生まれてしまう問題がありました。今回は、これを回避するために、moodle Webhook と同様に External ID で冪等性を担保する形に変更してもらいます。


1. GAS プロジェクト作成

  • [x] Apps Script を新規作成(既存でも OK)
  • [x] スクリプト名を設定(例: calendar-to-notion
  • [x] 実行ログ: (スクリーンショット貼付)

    新しい Apps Script

今回は、新しく Apps Script を用意しました。

1-1. Advanced Google services / API 有効化

  • [ ] Google Calendar API を利用できる状態にする
    • [x] Apps Script 側で Calendar API を有効化(Advanced Google services)

      !image.png

    • [x] GCP 側でも Calendar API が有効化されていることを確認

      Calendar API を追加

GAS 側に Google Calendar API を有効にしました。


2. ScriptProperties を GUI で設定(推奨)

以前のようにスクリプトで投入しても良いが、このプロジェクトでは GUI(プロジェクトの設定)から追加する。

  • [x] Apps Script の「プロジェクトの設定」→「スクリプト プロパティ」で、以下を追加する
    • [x] NOTION_API_TOKEN(秘匿。画面キャプチャ/ログには値を残さない)
    • [x] TASKS_DATA_SOURCE_ID(値: 1f70ce2a-6520-80ce-bc36-000b08bc3b11
    • [x] CALENDAR_ID(値: 省略@group.calendar.google.com
    • [x] (後で自動生成)SYNC_TOKEN手動では作らない/空で作らない。初回 readCalendar() 実行後にコードから保存する)
  • [x] 実行ログ/画面メモ: (スクリーンショット貼付。値は秘匿済)
    スクリプトプロパティ

前回はスクリプトで値を投入していましたが、今回はプロジェクトの設定で値を投入しました。


3. 初回認証(Calendar API 動作確認)

まず Calendar API が呼べることを確認してから本体実装に入る。

  • [x] 以下を一時的に貼り付けて readCalendar() を実行(成功したら削除して OK)
function mustEnv(name) {
  const v = PropertiesService.getScriptProperties().getProperty(name)
  if (!v) throw new Error(name + " is required in ScriptProperties")
  return v
}

// 前回保存したSyncTokenを取り出す。無ければ timeMin で初回 token を取る
function getSyncToken(calendarId) {
  const token = PropertiesService.getScriptProperties().getProperty("SYNC_TOKEN")
  // 未設定の場合は null が返る
  if (token) return token

  // 初回 nextSyncToken は「全ページ取り切った最後のレスポンス」にしか付かないことがある
  let pageToken = null
  let last = null
  for (;;) {
      const res = Calendar.Events.list(calendarId, {
          timeMin: new Date().toISOString(),
          maxResults: 2500,
          singleEvents: true,
          showDeleted: false,
          pageToken: pageToken || undefined,
      })
      last = res
      pageToken = res.nextPageToken
      if (!pageToken) break
  }
  return last ? last.nextSyncToken : undefined
}

// 初回認証のためだけのテスト呼び出し
function readCalendar() {
  const calendarId = mustEnv("CALENDAR_ID")
  const token = getSyncToken(calendarId)
  console.log("token =", token ? "(exists)" : "(missing)")
  console.log("nextSyncToken =", token)
}
  • [x] 実行ログ:
    • nextSyncToken = CP-tqo278pQDEP-tqo278pQDGAUglrCkuQMolrCkuQM=
    • saved SYNC_TOKEN

    認可の追加

最初に認可の取得をするとともに、nextSyncToken の取得を確認しました。


4. Notion API 呼び出しユーティリティ(data_source_id 対応)

  • [x] Notion API の基本送信関数(sendNotion)を用意

    外部サービスへの接続認可を追加

    • notion user = 9c1dfdfc-4527-4045-9dec-f815c42a9efc
  • [ ] Pages create/update(/v1/pages)と、検索(query)を実装する
function notionToken() {
  return mustEnv("NOTION_API_TOKEN")
}

function tasksDataSourceId() {
  return mustEnv("TASKS_DATA_SOURCE_ID")
}

function sendNotion(url, payload, method) {
  const options = {
    method,
    headers: {
      "Content-Type": "application/json",
      "Authorization": "Bearer " + notionToken(),
      "Notion-Version": "2026-03-11"
    },
    payload: payload ? JSON.stringify(payload) : undefined,
    muteHttpExceptions: true
  }
  const res = UrlFetchApp.fetch(url, options)
  const code = res.getResponseCode()
  const text = res.getContentText()
  if (code < 200 || code >= 300) {
    throw new Error("Notion API error: " + code + " " + text)
  }
  return text ? JSON.parse(text) : null
}

// data source の query(External ID 検索用)
function queryTasksByExternalId(externalId) {
  // NOTE: ここは Notion の data source query の仕様に合わせて調整する(明日ここから詰める)
  // ひとまず “ここを実装する” という骨格だけ置く
  const url = "https://api.notion.com/v1/data_sources/" + tasksDataSourceId() + "/query"
  const payload = {
    filter: {
      property: "External ID",
      rich_text: { equals: externalId }
    },
    page_size: 1
  }
  return sendNotion(url, payload, "POST")
}

function createTaskPage(properties) {
  const url = "https://api.notion.com/v1/pages"
  const payload = {
    parent: { data_source_id: tasksDataSourceId() },
    properties
  }
  return sendNotion(url, payload, "POST")
}

function updateTaskPage(pageId, properties, archived) {
  const url = "https://api.notion.com/v1/pages/" + pageId
  const payload = {}
  if (properties) payload.properties = properties
  // Notion API の仕様変更により、削除(アーカイブ相当)は in_trash を使う
  if (archived !== undefined) payload.in_trash = archived
  return sendNotion(url, payload, "PATCH")
}

実行結果

- [x] data source query 疎通(External ID 検索)
  - query results length = 0

次に Notion API が呼べることを確認しています。適当な External ID のクエリを実行し、結果が 0 件であることを確認しました。Notion API はうまく呼べているようです。


5. Calendar → Notion Tasks upsert(External ID)

5-1. External ID 生成

function gcalExternalId(calendarId, eventId) {
  return "gcal:" + calendarId + ":event:" + eventId
}

External ID は Calendar Webhook の時と同じものにしています。すでにいくつかのタスクが Calendar Webhook で作成されてしまっているためです。

5-1a. (疎通用)Tasks を 1件だけ作る

まず /v1/pages の create が通ることを確認する。buildTaskProperties を作る前でも実行できるように、ここでは properties を直書きする。

function testCreateTask_min() {
  const calendarId = mustEnv("CALENDAR_ID")
  const externalId = "gcal:" + calendarId + ":event:" + ("test-create-" + Date.now())
  const now = new Date().toISOString()

  const properties = {
    "Task name": { title: [{ text: { content: "GAS test create " + now } }] },
    "External ID": { rich_text: [{ text: { content: externalId } }] },
    "Date": { date: { start: now } },
    "Link": { url: "https://calendar.google.com/" }
  }

  const created = createTaskPage(properties)
  console.log("created page id =", created && created.id ? created.id : "(no id)")
  console.log("externalId =", externalId)
}

実行結果

- [x] Tasks create 疎通(/v1/pages)
  - created page id = `3770ce2a-6520-81c5-914c-effb0c05c8e4`
  - externalId = `gcal:省略@group.calendar.google.com:event:test-create-1780744625376`

まず、ページが作成されることをテストしています。問題なくページが作成されました。

5-2. Notion プロパティ変換(最小セット)

Tasks 側のプロパティ名が以下である前提: - Task name(title) - External ID(rich_text) - Date(date) - Link(url)

function toNotionDate(ev) {
  // dateTime → dateTime, date → date(終日)
  const start = (ev.start && (ev.start.dateTime || ev.start.date)) || null
  const end = (ev.end && (ev.end.dateTime || ev.end.date)) || null
  if (!start) return null
  const date = { start: start }
  if (end) date.end = end
  return date
}

function buildTaskProperties(taskName, externalId, dateObj, link) {
  const props = {
    "Task name": { title: [{ text: { content: taskName } }] },
    "External ID": { rich_text: [{ text: { content: externalId } }] }
  }
  if (dateObj) props["Date"] = { date: dateObj }
  if (link) props["Link"] = { url: link }
  return props
}

ここではカレンダーイベントから日付を得る関数と payload を作成する関数を生成しています。

5-3. upsert 本体

function upsertTaskFromEvent(calendarId, ev) {
  const eventId = (ev.id || "").trim()
  if (!eventId) return

  // cancelled → archive(削除イベントは recurring でも来ることがあるので、先に処理する)
  const externalIdForCancel = gcalExternalId(calendarId, eventId)
  if ((ev.status || "") === "cancelled") {
      const q = queryTasksByExternalId(externalIdForCancel)
      const existing = (q && q.results && q.results[0]) ? q.results[0] : null
      if (existing && existing.id) {
          updateTaskPage(existing.id, null, true)
          console.log("[gcal] archived task", externalIdForCancel)
      }
      return
  }

  // 繰り返しは一旦スキップ(必要なら後で対応)
  if (ev.recurringEventId) {
    console.log("[gcal] skip recurring", eventId)
    return
  }

  const externalId = gcalExternalId(calendarId, eventId)
  const taskName = (ev.summary || "").trim() || ("Calendar event " + eventId)
  const link = (ev.htmlLink || "").trim()
  const dateObj = toNotionDate(ev)

  const q = queryTasksByExternalId(externalId)
  const existing = (q && q.results && q.results[0]) ? q.results[0] : null

  const properties = buildTaskProperties(taskName, externalId, dateObj, link)

  if (!existing) {
    createTaskPage(properties)
    console.log("[gcal] created task", externalId)
  } else {
    updateTaskPage(existing.id, properties, undefined)
    console.log("[gcal] updated task", externalId)
  }
}

実行結果

- [x] upsert 疎通(ダミーイベント)
  - [gcal] created task `gcal:省略@group.calendar.google.com:event:dummy-1780744793252`

- [x] upsert 疎通(同一 External ID で update になることを確認)
  - [gcal] created task `gcal:省略@group.calendar.google.com:event:dummy-fixed-001`
  - [gcal] updated task `gcal:省略@group.calendar.google.com:event:dummy-fixed-001`

直接関数を呼び出して、タスクの作成や更新が実行されることを確認しました。


6. メイン関数(差分取得 → upsert → syncToken 保存)

function saveSyncToken(token) {
  PropertiesService.getScriptProperties().setProperty("SYNC_TOKEN", token)
}

function doCalendarPost(e) {
  const calendarId = e.calendarId || mustEnv("CALENDAR_ID")
  const token = getSyncToken(calendarId)

  const res = Calendar.Events.list(calendarId, {
    syncToken: token,
    singleEvents: true,
    showDeleted: true,
    maxResults: 2500
  })

  const items = res.items || []
  console.log("[gcal] diff items =", items.length)

  items.forEach(function (ev) {
    upsertTaskFromEvent(calendarId, ev)
    Utilities.sleep(200) // 叩きすぎ防止(必要に応じて調整)
  })

  if (res.nextSyncToken) {
    saveSyncToken(res.nextSyncToken)
    console.log("[gcal] syncToken updated")
  }
}

実行結果

- [x] 差分同期を手動実行(runOnce)
  - [gcal] diff items = 1
  - [gcal] updated task `gcal:nrbfaksiutu8nif8luqk0niqmk@group.calendar.google.com:event:70a395119bcb42ada2970a6ad4ceb268`
  - [gcal] syncToken updated

次にカレンダーの差分取得ができているかを確認しました。カレンダーで変更をした上で、doCalendarPost を呼び出すと、差分を確認しタスクが変更されたことが確認できました。


7. トリガー設定(カレンダー更新トリガー)

  • [x] トリガーを追加: doCalendarPost を「カレンダー」系トリガーで実行する
  • [ ] どのカレンダーに対してトリガーを付与するか確認(必要なら対象を絞る)
  • [x] 実行ログ: (スクリーンショット貼付)

    トリガーを追加

あとは、トリガーの設定のみです。カレンダーの更新があった時に doCalendarPost が呼ばれるように設定すれば完了です。


8. 動作確認チェック

  • [x] カレンダーイベントを新規作成 → Tasks が作成される
  • [x] 同イベントのタイトル/日時/リンクを変更 → 同じ External ID の Tasks が更新される(重複しない)
  • [x] イベントをキャンセル(削除) → Tasks が in_trash(削除)される

ここで、実際にカレンダーを追加・変更・削除したときに、これらが正しく動作したことを確認しました。これで完了です。

おわりに

今回も Notion AI で手順書を作成してもらい、手順を一つずつ指示してもらいました。ステップごとにちゃんとテストしながら、進めていくので初心者でもわかりやすいのではないかと思いました。今後は Notion のカレンダーに登録するだけで、タスクが生成されるようになります。後期の授業設定のやり方も少し変わるので、またその際にいくつかスクリプトを更新することになりそうです。

https://hkob.notion.site/hkob-16dd8e4e98ab807cbe3cf3cc94cdfe0f?pvs=4hkob.notion.site