Notion + GAS + Slackで、定期タスクを作成する仕組み作ったよ！

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/112129/5a5a92d7-7a8c-4155-9fec-f1ae17ee752f.png)

NotionDBでタスク管理していると、毎回同じタスクのページを作るの面倒くさくないです？
さりとてテンプレート機能もイマイチしっくりこない、、、
ということで、**Notion + GAS + Slackで、定期タスク管理とタスク発行ができる仕組み**を作りました。
今回はインストール編です！
# この仕組みでできること
- **NotionDBに、指定した間隔でページを追加できます。**
    - ＜対応している間隔＞
        - 日次、週次、月次、四半期次、半年次、年次
        - 月次、四半期次、半年次、年次は、任意の基準日を設定してタスクを発行できます。
- **手順書のURLをドキュメントに記載できます。**
    - 本当は設定ページに書き込んだ内容をコピーさせたかったのですが、NotionAPIがクセ強めなので諦めました。
    - 代わりに、プロパティに設定したURLを、ドキュメント本文に記入することで妥協しました。
- **土日祝でもタスクを発行するか選択できます。**
    - 平日ではない土日祝でも平日タスクを発行したい場合は、設定をONにすれば発行できます。
- **追加したページの通知をSlackに飛ばせます。**
    - Notionのオートメーション機能による通知もありますが、表示が冗長なので、GASでメッセージを整形して飛ばします。
# ここでは説明しないこと
各サービスの操作方法及びAPIの利用開始方法
# 事前に準備するもの
- Notionアカウント(フリーでも可)
- GoogleアカウントもしくはGoogle Workspaceアカウント
- Slackアカウント(フリーでも可)
# 手順概要
1. 各サービスで使用するAPI・アプリを設定
1. 定期タスク設定用NotionDBを作成
1. タスク管理DBを作成
1. GASでコードとトリガーを設定
# 手順詳細
## 各サービスで使用するAPI・アプリを設定
必要な設定のみ記します。
- Notionインテグレーション
    - スコープに下記を設定
        - コンテンツを読み取る、コンテンツを挿入、メールアドレスなしでユーザー情報を読み取る
- GoogleWorkspace
    - 設定なし
- Slackアプリ
    - 通知用チャンネルを指定したWebhookを取得する。
## 定期タスク設定用NotionDBを作成
定期タスクをどの間隔で作成するか指定するためのデータベースを作成します。
### 用意するプロパティ
| プロパティ | 種類 | 説明 |
|:-:|:-:|:-:|
| 発行する | チェックボックス | 発行したいタスクはONにします。 |
| Validation | 数式 | 以降の項目で、項目「間隔」に応じて、各項目の入力バリデーションを行い、結果を表示します。 |
| 設定名 | タイトル | 発行したいタスクの名前を入力します。 |
| 間隔 | セレクト | タスクの発行間隔を選択します。 |
| 担当者 | ユーザー | タスクの担当者を選択します。 |
| 説明書URL | URL | タスクの説明書のURLを入力します。 |
| 日数 | 数値 | タスクの実施期間を日数で入力します。 |
| 発行基準日付 | 日付 | 項目「間隔」で、月次、四半期次、半年次、年次を選択した場合、入力した基準日に応じて発行対象日を計算し、処理当日の日付と同じだった場合にタスクを発行します。 |
| 発行基準曜日 | マルチセレクト | 項目「間隔」で週次を選択した場合、タスクを発行する曜日を選択し、処理当日の曜日と同じだった場合にタスクを発行します。 |
| 土日祝も発行する | チェックボックス | 項目「間隔」で日次を選択した場合、処理当日が土日祝でもタスクを発行するか指定します。 |
| 通知する | チェックボックス | タスクの発行結果をSlackに通知するか指定します。 |
### 各プロパティへの設定
- Validation：下記の数式を設定
```javascript:Validationの数式
if(
	prop("間隔") == "日次" and !empty(prop("設定名")) and !empty(prop("担当者")) and !empty(prop("説明書URL")) and prop("日数") >= 0,
	"Valid",
	if(
		prop("間隔") == "週次" and !empty(prop("設定名")) and !empty(prop("担当者")) and !empty(prop("説明書URL")) and prop("日数") >= 0 and !empty(prop("発行基準曜日")),
		"Valid",
		if(
			or(prop("間隔") == "月次",prop("間隔") == "四半期次",prop("間隔") == "半年次",prop("間隔") == "年次") and !empty(prop("設定名")) and !empty(prop("担当者")) and !empty(prop("説明書URL")) and prop("日数") >= 0 and !empty(prop("発行基準日付")),
			"Valid",
			style("InValid","b","yellow_background")
		)
	)
)
```
- 間隔：下記のオプションを設定
    - 日次、週次、月次、四半期次、半年次、年次
- 担当者
    - 制限：1ユーザー
- 日数
    - 小数点以下の桁数0
- 発行基準日付
    - 年月日、非表示
- 発行基準曜日：下記のオプションを設定 
    - 月、火、水、木、金、土、日
### その他
データベースのコネクションに、NotionAPIのインテグレーションを登録してください。

## タスク管理DBを作成
実際にタスク管理するためのデータベースを作成します。
### 用意するプロパティ
| プロパティ | 種類 | 説明 |
|:-:|:-:|:-:|
| ステータス | ステータス | タスクの進捗を選択します。 |
| タスク名 | タイトル | タスクの名前を入力します。 |
| 担当者名 | ユーザー | タスクの担当者を選択します。 |
| 開始日 | 日付 | タスクの開始日を入力します。 |
| 終了日 | 日付 | タスクの終了日を入力します。 |
### その他
データベースのコネクションに、NotionAPIのインテグレーションを登録してください。
## GASでコードとトリガーを設定
### コード
下記をコピペしてください。
```javascript:main.gs
// 共通変数
// 間隔
const RECURRENCE_INTERVAL = {
  YEARLY: "年次",
  SEMIANNUAL: "半年次",
  QUARTERLY: "四半期次",
  MONTHLY: "月次",
  WEEKLY: "週次",
  DAILY: "日次"
}

// 共通関数
const getPropertyFromScript_ = (propertyName) => {
  return PropertiesService.getScriptProperties().getProperty(propertyName)
}
const generateDateString_ = (date) => {
  return Utilities.formatDate(date, "Asia/Tokyo", "yyyy-MM-dd")
}
const generateDateStringShort_ = (date) => {
  // 月日のみで比較する場合に用いる。
  return Utilities.formatDate(date, "Asia/Tokyo", "MM-dd")
}

const buildNotionApiOption_ = (method,setPayload) => {
  let buf = {
    method: method,
    contentType: "application/json",
    headers: {
      "Authorization": `Bearer ${getPropertyFromScript_("NOTION_API_TOKEN")}`,
      "Notion-Version": "2022-06-28",
    },
    muteHttpExceptions: true,
    followRedirects: false
  }
  if (setPayload !== undefined) {
    buf.payload = JSON.stringify(setPayload)
  }
  return buf
}

// 主関数
const main = () => {
  // 定期タスク設定DBを取得
  const settingDbResults = retrieveNotionSettingDb_()
  // タスクの設定をインスタンス化
  const settings = generateSettingClasses_(settingDbResults)
  // タスクを生成
  const result = addTaskToNotionTaskDb_(settings)
  // Slackへ通知
  sendSlackNotification_(result)
}

const retrieveNotionSettingDb_ = () => {
  // 定期タスク設定DBから、DB情報を取得する。
  // 発行するがtrueで、バリデーションが通っているものを対象
  const query = {
    filter: {
      and: [{
        property: "発行する",
        checkbox: {
          equals: true
        }
      },{
        property: "Validation",
        formula: {
          string: {
            equals: "Valid"
          }
        }
      }]
    }
  }
  const options = buildNotionApiOption_("get",query)
  const url = `https://api.notion.com/v1/databases/${getPropertyFromScript_("NOTION_DB_ID_SETTING")}/query`
  const response = UrlFetchApp.fetch(url, options);
  const data = JSON.parse(response.getContentText());
  return data.results
}

const generateSettingClasses_ = (settingDbResults) => {
  // タスクを発行するための設定クラス
  class Setting {
    constructor(settingDbResult) {
      this.settingName = settingDbResult.properties["設定名"].title[0].text.content
      this.recurrenceInterval = settingDbResult.properties["間隔"].select.name
      this.personInChargeId = settingDbResult.properties["担当者"].people[0].id
      this.documentUrl = settingDbResult.properties["説明書URL"].url
      this.periodLength = settingDbResult.properties["日数"].number
      this.baseDate = settingDbResult.properties["発行基準日付"].date?.start
      this.baseDays = settingDbResult.properties["発行基準曜日"].multi_select.map((item) => item.name)
      this.canAddTaskOnHoliday = settingDbResult.properties["土日祝も発行する"].checkbox
      this.enableSlackNotifications = settingDbResult.properties["通知する"].checkbox
    }
  }

  // 設定をインスタンス化して配列化
  const settings = settingDbResults.map((settingDbResult) => new Setting(settingDbResult))
  return settings
}

const addTaskToNotionTaskDb_ = (settings) => {
  // タスク発行結果を格納する結果クラス
  class Result {
    constructor(setting) {
      this.isSuccess = false
      this.settingName = setting.settingName
      this.taskName = ""
      this.taskUrl = ""
      this.personInChargeId = ""
      this.errorMessage = ""
    }
    createPageSuccess(taskName, taskUrl, personInChargeId) {
      this.isSuccess = true
      this.taskName = taskName
      this.taskUrl = taskUrl
      this.personInChargeId = personInChargeId
    }
    createPageFailure(taskName, personInChargeId, errorMessage) {
      this.isSuccess = false
      this.taskName = taskName.content
      this.personInChargeId = personInChargeId
      this.errorMessage = errorMessage
    }
  }

  // 処理実行日でタスク生成対象の設定を抽出
  const filteredSettings = filterSettings_(settings)

  // 設定からタスクを発行してNotionのタスクDBへ追加
  const results = filteredSettings.map((setting) => {
    let result = new Result(setting)
    const page = generatePage_(setting)
    const options = buildNotionApiOption_("post",page)
    const url = "https://api.notion.com/v1/pages" 
    // 結果出力のため、ここくらいはエラー処理を入れておく。
    try {
      const response = UrlFetchApp.fetch(url, options);
      const data = JSON.parse(response.getContentText());
      if(response.getResponseCode() != 200){
        throw new Error(data.message)
      }
      result.createPageSuccess(
        page.properties["タスク名"].title[0].text.content,
        data.url,
        setting.personInChargeId
      )
    } catch (e) {
      result.createPageFailure(
        page.properties["タスク名"].title[0].text.content,
        setting.personInChargeId,
        e.message
      )
    }
    return result
  })
  return results
}

const filterSettings_ = (settings) => {
  // 本日を取得
  const nowDate = new Date()
  const nowDateString = generateDateString_(nowDate)
  const nowDateStringShort = generateDateStringShort_(nowDate)
  // 各間隔タスクを抽出
  const filteredYearlySettings = filterSettingsByInterval_(settings, RECURRENCE_INTERVAL.YEARLY, identifySettings_, nowDate, nowDateStringShort);
  const filteredSemiAnnualSettings = filterSettingsByInterval_(settings, RECURRENCE_INTERVAL.SEMIANNUAL, identifySettings_, nowDate, nowDateStringShort);
  const filteredQuarterlySettings = filterSettingsByInterval_(settings, RECURRENCE_INTERVAL.QUARTERLY, identifySettings_, nowDate, nowDateStringShort);
  const filteredMonthlySettings = filterSettingsByInterval_(settings, RECURRENCE_INTERVAL.MONTHLY, identifySettings_, nowDate, nowDateStringShort);
  // 週次と日次は別なタスク抽出関数を使用
  const filteredWeeklySettings = filterSettingsByInterval_(settings, RECURRENCE_INTERVAL.WEEKLY, identifyWeeklySettings_, nowDate);
  const holidays = retrieveHolidays_(nowDate)
  const filteredDailySettings = filterSettingsByInterval_(settings, RECURRENCE_INTERVAL.DAILY, identifyDailySettings_, holidays, nowDate, nowDateString);

  // 抽出後のタスクを連結
  return []
    .concat(filteredYearlySettings)
    .concat(filteredSemiAnnualSettings)
    .concat(filteredQuarterlySettings)
    .concat(filteredMonthlySettings)
    .concat(filteredWeeklySettings)
    .concat(filteredDailySettings)
}

const filterSettingsByInterval_ = (settings, interval, identifyFn, ...args) => {
  // 抽出関数の処理を共通化するためのヘルパー関数
  return settings
    .filter(setting => setting.recurrenceInterval === interval)
    .filter(setting => identifyFn(setting, ...args));
}

const identifySettings_ = (setting, nowDate, nowDateStringShort) => {
  // 発行対象日を算出
  const convertedBaseDates = calculateBaseDates_(setting, nowDate)
  // 本日が発行対象日の場合発行
  if (convertedBaseDates.includes(nowDateStringShort)) {
    return true
  }
  return false
}

const calculateBaseDates_ = (setting, nowDate) => {
  // 処理実行日と設定の「間隔」、各発行基準から算出される発行対象日を算出
  const baseDateDetail = generateDateDetail_(Utilities.parseDate(setting.baseDate, "Asia/Tokyo", "yyyy-MM-dd"))
  const nowDateDetail = generateDateDetail_(nowDate)
  switch (setting.recurrenceInterval) {
    case RECURRENCE_INTERVAL.YEARLY:
      return [generateDateStringShort_(new Date(nowDateDetail.dateYear, baseDateDetail.dateMonth, baseDateDetail.dateDate))]
    case RECURRENCE_INTERVAL.SEMIANNUAL: {
      const convertedBaseDate1st = new Date(nowDateDetail.dateYear, baseDateDetail.dateMonth, baseDateDetail.dateDate)
      const convertedBaseDate2nd = new Date(nowDateDetail.dateYear, baseDateDetail.dateMonth + 6, baseDateDetail.dateDate)
      return [
        generateDateStringShort_(convertedBaseDate1st),
        generateDateStringShort_(convertedBaseDate2nd)
      ]
    }
    case RECURRENCE_INTERVAL.QUARTERLY: {
      const convertedBaseDate1st = new Date(nowDateDetail.dateYear, baseDateDetail.dateMonth, baseDateDetail.dateDate)
      const convertedBaseDate2nd = new Date(nowDateDetail.dateYear, baseDateDetail.dateMonth + 3, baseDateDetail.dateDate)
      const convertedBaseDate3rd = new Date(nowDateDetail.dateYear, baseDateDetail.dateMonth + 6, baseDateDetail.dateDate)
      const convertedBaseDate4th = new Date(nowDateDetail.dateYear, baseDateDetail.dateMonth + 9, baseDateDetail.dateDate)
      return [
        generateDateStringShort_(convertedBaseDate1st),
        generateDateStringShort_(convertedBaseDate2nd),
        generateDateStringShort_(convertedBaseDate3rd),
        generateDateStringShort_(convertedBaseDate4th)
      ]
    }
    case RECURRENCE_INTERVAL.MONTHLY:
      return [generateDateStringShort_(new Date(nowDateDetail.dateYear, nowDateDetail.dateMonth, baseDateDetail.dateDate))]
    default:
      return [];

  }
}

const generateDateDetail_ = (date) => {
  return {
    dateYear: date.getFullYear(),
    dateMonth: date.getMonth(),
    dateDate: date.getDate()
  }
}

const identifyWeeklySettings_ = (setting, nowDate) => {
  const DAY_NUMBERS = {
    "月": 1,
    "火": 2,
    "水": 3,
    "木": 4,
    "金": 5,
    "土": 6,
    "日": 0
  }  
  // 本日の曜日を取得
  const nowDateDay = nowDate.getDay()
  // 発行基準曜日をインデックスに変換
  const convertedBaseDays = setting.baseDays.map((baseDay) => DAY_NUMBERS[baseDay])
  // 本日の曜日が発行基準曜日に含まれていれば発行
  return convertedBaseDays.includes(nowDateDay)
}

const identifyDailySettings_ = (setting, holidays, nowDate, nowDateString) => {
  // 「土日祝も発行する」がtrueの場合は発行
  if (setting.canAddTaskOnHoliday) {
    return true
  }
  // 「土日祝も発行する」がfalseで、本日が平日の場合は発行
  if (!setting.canAddTaskOnHoliday && !isHoliDay_(holidays, nowDate, nowDateString)) {
    return true
  }
  return false
}

const isHoliDay_ = (holidays, nowDate, nowDateString) => {
  // 土日
  const nowDay = nowDate.getDay()
  if (nowDay == 6 || nowDay == 0) {
    return true
  }
  // 祝日
  if (holidays.has(nowDateString)) {
    return true
  }
  return false
}

const retrieveHolidays_ = (nowDate) => {
  // Googleカレンダーの日本の祝日カレンダーより、祝日を取得
  // toISOString()で世界標準時になってしまうが、イベントは世界標準時で管理されているため問題はない。
  const timeMin = new Date(nowDate.getFullYear(), nowDate.getMonth(), nowDate.getDate(),nowDate.getHours(),nowDate.getMinutes(),nowDate.getSeconds()).toISOString()
  const timeMax = new Date(nowDate.getFullYear()+1, nowDate.getMonth(), nowDate.getDate(),nowDate.getHours(),nowDate.getMinutes(),nowDate.getSeconds()).toISOString()
  const optionalArgs = {
    timeMin: timeMin,
    timeMax: timeMax
  }
  const holidaysResult = Calendar.Events.list("ja.japanese.official#holiday@group.v.calendar.google.com", optionalArgs)
  const holidays = new Map()
  holidaysResult.items.forEach((holiday) => holidays.set(holiday.start.date, holiday.summary))
  return holidays
}

const generatePage_ = (setting) => {
  // 発行するタスクのNotionページ用payloadを生成
  const nowDate = new Date()
  const startDate = generateDateString_(nowDate)
  const taskTitle = `${setting.settingName}_${startDate}`
  const endDate = generateDateString_(
    new Date(
      nowDate.getFullYear(),
      nowDate.getMonth(),
      nowDate.getDate() + setting.periodLength
    )
  )
  return {
    parent: {
      database_id: getPropertyFromScript_("NOTION_DB_ID_TASK")
    },
    properties: {
      "タスク名": {
        title: [
          {
            text: {
              content: taskTitle
            }
          }
        ]
      },
      "担当者": {
        people: [
          {
            object: "user",
            id: setting.personInChargeId
          }
        ]
      },
      "開始日": {
        date: {
          start: startDate,
          end: null
        }
      },
      "終了日": {
        date: {
          start: endDate,
          end: null
        }
      },
      "ステータス": {
        status: {
          name: "未着手"
        }
      }
    },
    children: [
      {
        object: "block",
        type: "paragraph",
        paragraph: {
          rich_text: [
            {
              type: "text",
              text: {
                content: "説明書",
                link: {
                  url: setting.documentUrl
                }
              }
            }
          ]
        }
      }
    ]
  }
}

const sendSlackNotification_ = (results) => {
  // メッセージ生成
  const message = generateSlackMessage_(results)
  // チャンネルへ投稿
  const payload = {
    text: message
  }
  const options = {
    method: 'post',
    contentType: 'application/json',
    payload: JSON.stringify(payload)
  }
  const response = UrlFetchApp.fetch(getPropertyFromScript_("SLACK_WEBHOOK_URL"), options);
}

const generateSlackMessage_ = (results) => {
  const nowDateString = Utilities.formatDate(new Date(),"Asia/Tokyo","yyyy-MM-dd")
  const users = retrieveUsers_()
  const createdTasks = results.map((result) => {
    const taskNameString = result.isSuccess ? `<${result.taskUrl}|${result.taskName}>` : `${result.settingName}`
    const userName = users.get(result.personInChargeId)
    let createdTask =
    `${result.isSuccess ? "成功" : "失敗"}：${taskNameString}：${userName}${result.isSuccess ? `` : `：${result.errorMessage}`}`
    return createdTask
  })
  let message = `【${nowDateString}に発行したタスクの作成結果】
  `
  message += createdTasks.length > 0 ? `${createdTasks.join("\r")}` : `発行したタスクはありません。`
  return message
}

const retrieveUsers_ = () => {
  const users = new Map()
  let hasNext = true
  let cursor = undefined
  while(hasNext){
    const url = cursor ? `https://api.notion.com/v1/users?start_cursor=${cursor}` : "https://api.notion.com/v1/users"
    const options = buildNotionApiOption_("get")
    const response = UrlFetchApp.fetch(url, options);
    const data = JSON.parse(response.getContentText());
    data.results.forEach((result)=>users.set(result.id,result.name))
    hasNext = data.has_more
    cursor = data.next_cursor
  }
  return users
}
```
### その他
- トリガーで朝の時間帯に日次実行を設定
    - 設定すると動き始めるので、実際は使い始めの直前に設定します。
- CalendarAPIを有効
    - 日本の祝日カレンダーをGoogleカレンダーに設定してください。日本の祝日をこのカレンダー取得します。
- スクリプトプロパティを設定
    - NOTION_API_TOKEN
        - NotionのAPIをコールする際に使用するトークンを設定します。
    - NOTION_DB_ID_SETTING
        - Notionの定期タスク設定DBのIDを設定します。
    - NOTION_DB_ID_TASK
        - Notionのタスク管理DBのIDを設定します。
    - SLACK_WEBHOOK_URL
        - 通知先のSlackチャンネル用Webhookをします。

準備としては以上です！
# 実行してみる
試しに下記のようなタスクを設定してみました。
実行日は2025-02-15(土)です。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/112129/9d976712-06e5-4eb1-a57a-9ec9412e3da1.png)
実行結果はこんな感じ。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/112129/8264350c-54dc-46db-a95d-ef45ee7767c1.png)
Slackの通知はこんな感じ。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/112129/d8bb967d-fe2d-44fe-8036-4e95a7c3bed4.png)
これでみなさんのタスクが、少しでも漏れなくて早く確実に完了できるようになることを祈っております！
次回は解説編です(気合いが残っていれば)

→書きました！！
[**Notion + GAS + Slackで、定期タスクを作成する仕組み作ったよ！(解説編)**](https://qiita.com/suica_s/items/1b5de4a17e391f763026)