Confluence APIとGASで定例の資料を毎日自動生成する

:::note info
これは [ZOZO Advent Calendar 2024](https://qiita.com/advent-calendar/2024/zozo) カレンダー、シリーズ 8 の 23 日目の記事です。
:::
# はじめに
弊社では社内wikiとして[Confluence](https://www.atlassian.com/ja/software/confluence)を利用しています。
定例などで毎日同じ資料を作成する必要があったのですが、Confluence APIとGoogle Apps Script(GAS)を使用して資料作成を自動化してみたので紹介しようと思います。

:::note alert
後述しますが、今回使用するAPIのバージョンは非推奨になっているので、可能であれば最新バージョンのものを採用するようにしてください(本記事も最新バージョンへの移行が完了次第更新しようと思います)。
:::

# Confluence API
Confluenceはページ情報の取得やページ作成といった機能をAPIとして提供しています。
ref. https://developer.atlassian.com/cloud/confluence/rest/v1/intro

手元で動作確認する前に、Confluence APIを使用するためにはアクセストークンが必要なのでマイページからパーソナルアクセストークンを生成しておきます。
(`https://<ドメイン>/wiki/plugins/personalaccesstokens/usertokens.action`からトークン管理ページに飛べます)
![スクリーンショット 2024-12-19 20.06.14.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/688567/155b38ba-6c73-35ea-9e83-04de1431a060.png)

トークンを生成したらAPIを叩いてみます。
今回は[ページ作成API](https://developer.atlassian.com/cloud/confluence/rest/v1/api-group-content/#api-wiki-rest-api-content-post)を使用するので、Postmanで動作確認してみます。

![スクリーンショット 2024-12-19 21.31.40.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/688567/fc43745e-4348-7883-ccac-0042eb7c227e.png)
![スクリーンショット 2024-12-19 21.36.13.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/688567/7a920b29-ae20-922b-62ba-70e983f28036.png)

Authorizationヘッダーには`Bearer xxxx(パーソナルアクセストークン)`の形で値をセットします。
リクエストボディには作成するページのスペースキーを指定します。
スペースキーはスペースのホーム画面のサイドバーから「ページ」を選択すると`https://<ドメイン>/wiki/collector/pages.action?key=<スペースキー>`に遷移するので、このURLから取得できます。
(適当なページに対して[コンテンツ取得API](https://developer.atlassian.com/cloud/confluence/rest/v1/api-group-content/#api-wiki-rest-api-content-get)を叩いても取得できます)
<details><summary>コンテンツ取得APIの実行例(jqで整形しています)</summary>

```zsh
$ curl -X GET 'https://xxxx/wiki/rest/api/content/774059380' -H 'Authorization: Bearer xxxx' -H 'Accept:application/json' | jq .

  % Total    % Received % Xferd  Average Speed   Time    Time     Time  Current
                                 Dload  Upload   Total   Spent    Left  Speed
100  2366  100  2366    0     0  17859      0 --:--:-- --:--:-- --:--:-- 17789
{
  "id": "774059380",
  "type": "page",
  "status": "current",
  "title": "2024/12/23 チーム定例",
  "space": {
    "id": xxxx,
    "key": "xxxx",    # これがスペースキー
    "name": "xxxx",
    "type": "personal",
    "_links": {
      "webui": "/spaces/viewspace.action?key=xxxx",
      "self": "https://xxxx/wiki/rest/api/space/xxxx"
    },
    "_expandable": {
      "metadata": "",
      "icon": "",
      "description": "",
      "retentionPolicy": "",
      "homepage": "/rest/api/content/xxxx"
    }
  },
  "history": {
    "latest": true,
    "createdBy": {
      "type": "known",
      "username": "xxxx",
      "userKey": "xxxx",
      "profilePicture": {
        "path": "/wiki/images/icons/profilepics/default.svg",
        "width": 48,
        "height": 48,
        "isDefault": true
      },
      "displayName": "xxxx",
      "_links": {
        "self": "https://xxxx/wiki/rest/api/user?key=xxxx"
      },
      "_expandable": {
        "status": ""
      }
    },
    "createdDate": "2024-12-19T14:55:18.399+09:00",
    "_links": {
      "self": "https://xxxx/wiki/rest/api/content/774059380/history"
    },
    "_expandable": {
      "lastUpdated": "",
      "previousVersion": "",
      "contributors": "",
      "nextVersion": ""
    }
  },
  "version": {
    "by": {
      "type": "known",
      "username": "xxxx",
      "userKey": "xxxx",
      "profilePicture": {
        "path": "/wiki/images/icons/profilepics/default.svg",
        "width": 48,
        "height": 48,
        "isDefault": true
      },
      "displayName": "xxxx",
      "_links": {
        "self": "https://xxxx/wiki/rest/api/user?key=xxxx"
      },
      "_expandable": {
        "status": ""
      }
    },
    "when": "2024-12-19T15:00:55.741+09:00",
    "message": "",
    "number": 1,
    "minorEdit": false,
    "hidden": false,
    "_links": {
      "self": "https://xxxx/wiki/rest/experimental/content/774059380/version/1"
    },
    "_expandable": {
      "content": "/rest/api/content/774059380"
    }
  },
  "extensions": {
    "position": "none"
  },
  "_links": {
    "webui": "/pages/viewpage.action?pageId=774059380",
    "edit": "/pages/resumedraft.action?draftId=774059380&draftShareId=76490fab-b46c-4d65-a2af-1d0ed52ae1b8",
    "tinyui": "/x/dDUjLg",
    "collection": "/rest/api/content",
    "base": "https://xxxx/wiki",
    "context": "/wiki",
    "self": "https://xxxx/wiki/rest/api/content/774059380"
  },
  "_expandable": {
    "container": "/rest/api/space/xxxx",
    "metadata": "",
    "operations": "",
    "children": "/rest/api/content/774059380/child",
    "restrictions": "/rest/api/content/774059380/restriction/byOperation",
    "ancestors": "",
    "body": "",
    "descendants": "/rest/api/content/774059380/descendant"
  }
}
```
</details>

親ページのIDも同様に、生成するページの親ページのURL(`https://<ドメイン>/wiki/pages/viewpage.action?pageId=<ページID>`)から取得できます。

APIを叩くとページが作成されるのを確認できました。
HTMLタグもいい感じに見出しやテーブルなどに解釈してくれてますね。

![スクリーンショット 2024-12-20 12.02.56.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/688567/2e8484f9-12b2-d730-2b8b-20c182f02972.png)

ちなみに、テーブルが空欄だと余白が潰れて見栄えがあまり好ましくなかったので無理やり穴埋めしています。

<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/688567/8e31aee5-591d-a206-59f6-6cb652a179d0.png" width=30%>

このように、Confluence内で編集するより勝手は悪いので、凝ったデザインとかは難しいかもしれないです。

:::note warn
最新バージョンのAPIでは正常なレスポンスを確認できなかったので、とりあえず運用するという目的で今回は非推奨バージョンでAPIを叩きます(動作確認でき次第、本記事も更新しようと思います)。

<details><summary>最新バージョンでの動作確認</summary>

Bearerトークンをセットして[投稿API](https://developer.atlassian.com/cloud/confluence/rest/v2/api-group-blog-post/#api-blogposts-post)を叩いていますが、`You are already logged in`というタイトルのHTMLページがレスポンスされ、ページは作成されません。
([同じような現象で問い合わせ](https://community.developer.atlassian.com/t/confluence-api-only-returns-a-you-are-already-logged-in-html-page/71382)されていたが、解決してなさそう)
有識者の方、原因に心当たりあればご教示いただけますととっても助かります。

![スクリーンショット 2024-12-20 14.36.27.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/688567/874e1d05-8c5d-68ad-5fd3-3c01d0a15dbc.png)

</details>
:::

# GAS
ページ作成できることを確認できたので、GASで定期的に実行してSlackに通知しようと思います。

### Slack Appの準備
先にSlack Appを作成しておきます。
([こちらのブログ](https://ex-ture.com/blog/2023/07/27/%E3%80%90google-app-script%E3%80%91gas%E3%82%92%E5%88%A9%E7%94%A8%E3%81%97%E3%81%A6slack%E3%81%AB%E6%8A%95%E7%A8%BF%E3%81%99%E3%82%8Bbot%E3%82%92%E4%BD%9C%E3%82%8B/)が図解も豊富で分かりやすいかもです)
1. https://api.slack.com/apps から`Create New App > From scratch`を選択します
1. アプリ名を入力し、ワークスペースを選択したらアプリが生成されます
1. 左のメニューバーからOAuth & Permissionsを選択し、Add an OAuth Scopeでスコープを追加します。今回は`chat:write`、`chat:write.customize`
、`chat:write.public`、`incoming-webhook`を追加しました
1. 左のメニューバーから`Install App`を選択し、ワークスペースに通知する権限を許可します
1. `Bot User OAuth Token`が表示されるので控えておきます

### スプシの準備
自分の所属するチームの定例は**①毎日行う**、**②ファシリテーターは週ごとに交代する**という運用方法なので、その要件に沿って実装していきます。

ファシリテーターを毎週変更するために、スプシに以下の内容でシートを用意しておきます。idはSlackで担当者をメンションするためのメンバーIDです。Slackでメンバーのプロフィールを開き、`メンバーIDをコピー`で取得できます。

<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/688567/871f4be1-c163-b71a-3805-c0aa989c5ea5.png" width=50%>

### GASの準備
GASの内容は以下のように実装しました。

```js
MEMBER_NUM = 3;

let token = PropertiesService.getScriptProperties().getProperty("SLACK_TOKEN");
let channelID= PropertiesService.getScriptProperties().getProperty("CHANNEL_ID");

function main() {
  let facilitator = getFacilitator();
  let pageID = createPage();
  if (facilitator) {
    sendSlackMessage(facilitator, pageID);
  }
}

// その週のファシリテーターを取得します
function getFacilitator() {
  let today = new Date();
  let dayOfWeek = today.getDay();
  if (dayOfWeek === 0 || dayOfWeek === 6) {
    return;
  }
  let sheet = SpreadsheetApp.getActiveSpreadsheet().getSheetByName('<スプシのシート名>');
  let membersLastRow = sheet.getLastRow()
  let members = sheet.getRange(2, 2, membersLastRow - 1).getValues().map(row => row[0]);
  let facilitator_index = sheet.getRange('C2').getValue();
  if (dayOfWeek === 1) {
    if(facilitator_index+1 >= MEMBER_NUM) {
      facilitator_index = 0
    } else {
      facilitator_index++;
    }
    updateSpreadSheet(sheet, facilitator_index)
    sheet.getRange('C2').setValue(facilitator_index)
  }
  return members[facilitator_index];
}

// Confluence APIでページを作成します
function createPage() {
  let url = "https://<ドメイン>/wiki/rest/api/content";
  let dt = new Date();
  let y = dt.getFullYear();
  let m = ("00" + (dt.getMonth()+1)).slice(-2);
  let d = ("00" + (dt.getDate())).slice(-2);
  let title = y+m+d+' チーム定例';
  let payload = {
    "title": title,
    "type": "page",
    "space": {
      'key':'<スペースキー>'
    },
    "ancestors": [
      {
        "id": "<親ページのID>"
      }
    ],
    "body": {
      "storage": {
          "representation": "storage",
          "value": "<h1>質問・共有事項</h1><table><tr><th>記入者</th><th>内容</th><th>議事録</th></tr><tr><td></td><td></td><td></td></tr></table><br/><h1>進捗確認</h1><h2>案件1</h2><a href='https://example.com'>https://github.com/project1</a><br/><h2>案件2</h2><a href='https://example.com'>https://github.com/project2</a><br/>"
      }
    }
  }

  let options = {
    "method": "post",
    "headers": {
      "Authorization": "Bearer <パーソナルアクセストークン>",
      "Accept": "application/json",
      "Content-Type": "application/json"
    },
    "payload" : JSON.stringify(payload),
    "muteHttpExceptions": true
  }

  let response = JSON.parse(UrlFetchApp.fetch(url, options));
  return response["id"];
}

// Slackに通知します
function sendSlackMessage(facilitator, pageID) {
  let message = "<@" + facilitator + ">\n今日のチーム定例のファシリテーターです。\n資料: https://<ドメイン>/wiki/pages/viewpage.action?pageId=" + pageID;
  let slackApp = SlackApp.create(token);
  slackApp.postMessage(channelID, message);
}
```

トークンはSlack App作成時に控えておいた`Bot User OAuth Token`です。
チャンネルIDは通知したいチャンネル名を右クリックし、`コピー>リンクをコピー`で取得したURLから取得できます(`https://<ドメイン>/archives/<チャンネルID>`という形式)。
どちらもスクリプトプロパティにキー・バリューを保存し、`PropertiesService.getScriptProperties().getProperty()`で取得しています(スクリプトプロパティは左メニューバーの`プロジェクトの設定`から保存できます)。

試しにmain関数を実行してみましょう。対象の関数がmainに指定されていることを確認し、実行ボタンを押します。

![スクリーンショット 2024-12-21 17.46.58.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/688567/69d7b681-63c4-bf87-b25d-a424f19335fd.png)

Confluenceにページが作成されてSlackに通知が飛んでいることを確認できました！

![bRYjx3UQjRNUmE41734769605_1734769710 (2).png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/688567/05d2de76-f011-d507-bbe1-b7c2ffd0e723.png)

### 定期実行の設定
最後に、定期的にGASを実行するようにトリガーを追加します。
左メニューバーの`トリガー`を選択し、`トリガーを追加`をクリックします。
ポップアップが表示されるので、お好みで関数の実行タイミングを設定します。今回は毎日ページ作成と通知を行うために`時間主導型`・`日付ベースのタイマー`・`午前11時〜午後12時`で設定しました。

![スクリーンショット 2024-12-21 17.35.28.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/688567/c69fa418-f246-7637-dad9-aa76b9d15fbe.png)

これで毎日指定した時刻の間に関数が実行され、資料の作成とSlackの通知を自動化することができました🎉

# まとめ
Confluence APIとGASを使って、毎日資料を作成してSlackに通知する方法を見てきました。
今回の実装だと、定例で更新された内容を次の資料に反映させることができなかったり、資料の内容をHTMLで直書きしていて更新が面倒なので、この辺りは今後改善していきたいですね。

ではまた。