GAS + Google スプレッドシート + SlackでQiita:Teamから欲しい情報を探せる仕組みを作る

## はじめに
本記事は、 [STORES.jp Advent Calendar 2019 - Adventar](https://adventar.org/calendars/4549) 16日目の記事です。

この記事では、今いるSTORES開発チームの中で行なっている

- Google App Script
- SpreadSheet
- Slack

を用いたドキュメントのカテゴリ分け方法やカテゴリ分けから溢れる情報をどう拾っていくかについて記載していきます。

## ドキュメントの管理方法について
STORES.jpでは、ドキュメント管理として現在[Qiita:Team](https://teams.qiita.com/)を用いています。
Qiita:Teamの特徴として、

- マークダウンで記載
- タグでの記事管理
- 共有範囲を指定して投稿することが可能
- メンションやコメントが可能
- 通常のQiitaに慣れていれば形態はほぼ同じ

といった特徴があります。つまりは、企業内部向けに沿ったQiitaです。
「オープンかクローズドかの違いがあるQiita」という認識で良いと思います。

## 問題点
Qiita:Teamは、共有範囲の設定やメンションができたり、個人的にはQiita同様でインタフェースが馴染みやすかったりと利点も多くあるのですが、一方でカテゴリ分けがタグ管理のみになるため、ドキュメント数が増加していくと、ドキュメントの管理は意識しないとネックになります。

#### ユーザ関心の違い
- 通常のQiita(緑)
閲覧者は主にGoogle検索などを用いて記事を探しに来ます。もしくは、知りたい技術のタグなどからそのランキング上位を見たり、トレンドを見たりします。
そのため、記事内のプログラミング言語やフレームワーク名、関連技術名などのタグを追加しておけば、探す側としては良さそうです。

- Qiita:Team(青)
閲覧者はQiita:Team内から検索をかけて探します。Qiitaと異なり、ランキングやトレンドの概念はなく、機能仕様、技術仕様など、1つの項目に対し1つの記事が存在し、それが常に更新されていきます。
そのため、**特定**の記事を**必ず**、そして**より早く**見つけ出す状態にしておく必要があります。

## 解決案
現在は、タグを用いて階層構造ライクになるようなチーム内のガイドラインを引いています。

- ガイドライン1: 「チーム名」を1つ目のタグに入れる
- ガイドライン2: 最低でも2つ以上のタグをつける
- ガイドライン3: 特定のカテゴリタグを2つ目のタグに入れる

一例だと、以下のようなイメージです。
<img width="268" alt="スクリーンショット 2019-12-09 17.08.03.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/203817/6d895451-76a5-a2f8-4c2b-b2589ebea9d3.png">

#### ガイドライン1: 「チーム名」を1つ目のタグに入れる
フロントエンドチームなら、「フロントチーム」など分かるようにチームタグを入れます。

#### ガイドライン2: 最低でも2つ以上のタグをつける
チーム名だけだと、なんの情報か分からないのでこれはあった方がいいです。

#### ガイドライン3: 特定のカテゴリタグを2つ目のタグに入れる
少なくとも、2つ目につけるカテゴリタグは指定しておく必要があります。
これをしないと、記事の作成者がタグを付ける際に、例えば同じの昼回議事録の内容だったとしても、タグ名が`昼回`、`昼回議事録`、`会議メモ`、`昼定例`のように書く人によって表記ゆれが発生する場合があり、検索のしづらさが上がったり、作成者がタグをつける際に、「あれ？他の記事はどうしてたっけ？」と揃えるために確認するような手間が起きたりします。

いくつか特定タグの例をあげます。(あくまで一例なので、自社のやり方に合わせてチームでどうするかを決めましょう)

##### 昼会
- フロントチームで毎日行っている昼会の議事録

##### 入社キット
- 環境構築、PJTの経緯、はじめに目を通しておくものなど

##### ガイドライン
- チームガイドライン、設計やテストのガイドラインなど

##### PJT_xxx
- 各プロジェクトに属するドキュメント

##### チームビルディング
- チームでの振り返り、目標設定などのMTGの議事録

このように切り分けておくと、Qiita:Teamでは、tagで絞り込んで検索が可能なため、各検索クエリのリンクをどこかに貼っておけば、少しディレクトリライクに探せるようになります。

<img width="800" alt="Qiita_TEAM検索.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/203817/66e92ef9-1287-7925-1575-3c3cda1b22be.png">

## 困ったこと
- あくまでタグ管理なので、タグつけ作業は個人に任せることになる
- タグがついていない記事があるかどうか、探せない

あくまでタグ管理は記事の作成者にお任せすることになります。そのため、タグがついていなくても投稿はできますし、タグがついていない記事を一式探すのは至難の業となります。

### GAS + Google スプレッドシート + SlackでQiita:Teamから欲しい情報を探せる仕組みを作る
本題です。
上記のお困りごとを解決するため、GASでこんなものを作りました。

- 「フロントエンドチーム」というタグがついている記事を全て探し、 `タイトル`、`URL`、`タグ`を一覧としてスプレッドシートに書き込む
- タグガイドラインに沿っていない記事は、行を赤色にする
- 毎日更新を行い、タグガイドラインに沿わないものがあればSlackに通知を行う

完成形としては、以下のようなものになります。(それっぽいサンプルを作りました)
<img width="1388" alt="スクリーンショット 2019-12-10 0.13.36.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/203817/66b2ce36-f50e-b925-4464-f6415447c7c8.png">


## 実施
以下の順でGASの実装を進めていきます

1. Qiita APIを叩き、記事一覧の情報取得する
2. 記事情報をGoogle スプレッドシートへ書き込む
3. タグガイドラインに則っていない記事を赤くし、Slackに通知

## 1. Qiita APIを叩き、記事一覧の情報取得する
まずは、Qiita:TeamのAPIを叩いて記事情報を取得していきます。
APIのアクセストークンは、設定画面に遷移し、
`アプリケーション` → `新しくトークンを発行する` の順で取得できます。
read、write権限がありますが、今回の要件ではread権限のみあれば大丈夫です。

Qiitaから情報を取得する関数は以下のようになります。

```js
function qiitaApi(url, method) {
  var accessToken = '<Qiita APIのアクセストークン>';
  var headers = {
    'Authorization': 'Bearer ' + accessToken
  };
  var options = {
    'method': method,
    'headers': headers
  };
  var response = UrlFetchApp.fetch(url, options);
  return response;
}
```

※ 下記の記事がQiita APIとGASの接続について分かりやすく、参考にさせて頂きました。
[Google Apps ScriptからQiita APIを使って、Qiita:Teamの情報を得る方法（ユーザー一覧の入手）
](https://qiita.com/takoratta/items/4a0f3a5f9bb75c0646dc)

上記関数によって、Qiita APIのURLと叩くメソッド(GET, POST等)を指定すればresponseを取得できます。

次に、mainメソッドです。
まずはresponseを取得するまでを記載します。

```js
function main() {
  var page = 1;
  var perPage = 100;
  var HOST = '<自身のQiita:TeamのURLを入力してください>';
  var REQUIRED_TAG = 'tag:フロントエンドチーム'; //ご自身のチーム名を入力してください
  
  var parameters = '?query=' + REQUIRED_TAG + '&per_page=' + perPage + '&page=' + page;
  var response = qiitaApi('https://' + HOST + '/api/v2/items' + parameters, 'GET');
  
  Logger.log(response);
}
```

まず、Qiita APIについてですが、 `GET: /api/v2/items` を用いてそのドメイン内の記事一覧を取得することができます。
また、以下のようなparamsをつけることが可能です。

- query ・・・ 検索クエリ
- per_page ・・・ API1回あたりで取得する最大記事数(最大100)
- page ・・・ 検索結果をper_pageで区切った場合のpage番号(per_pageと合わせて利用)

これによって得られる `response` を `Logger.log(response)` などで確認してみて、記事情報が取得できていればOKです。


## 2. 記事情報をGoogle スプレッドシートへ書き込む
次に、取得した記事情報をスプレッドシートに乗せられる形に整形を行う `setData`関数、スプレッドシートを初期化する`clearSpreadSheets`関数、スプレッドシートに貼り付けを行う`setToSpreadSheet`関数を作成していきます。

#### setData

```js
function setData(response, isFirst) {
  var json = response.getContentText();
  var data = JSON.parse(json);
  
  var rows = [];
  var first = isFirst;
  
  for (i = 0; i < data.length; i++) {
    var row = [];
    
    row.push(data[i].title);
    row.push(data[i].url);
    
    var tags = data[i].tags.sort();
    // タグを最大4つと想定し、タグがなければ空文字追加
    for ( j = 0; j < 4; j++) {
      if (j + 1 > tags.length) {
        row.push('');
      } else {
        row.push(tags[j].name);
      }
    }
    rows.push(row);
  }
  // スプレッドシートに書き込み
  setToSpreadSheets(rows, first);
}
```

まずは先ほど取得した記事情報のresponseを引数で受け取ります。
第二引数の `isFirst`については後ほど説明します。

for文の処理を見ていきましょう。
GASからスプレッドシートに任意の値を追加するとき、 `setValues`というメソッドを用いるのですが、このメソッドは記載する値を多重配列で受け取ります。

<img width="1388" alt="スクリーンショット 2019-12-10 0.13.36.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/203817/02b4d4fc-b942-8990-d7e8-20a58e652734.png">


例えば、上のような例の場合、以下のようになります。

```
[
 ['フロントエンドチーム昼会(yyyy/mm/dd)の週', 'https://xxx', 'フロントエンドチーム', '昼会', '', ''],
 ['フロントエンドチーム 週報', 'https://xxx', 'フロントエンドチーム', '週報', '', ''],
 ['フロントエンドチーム 社内勉強会', 'https://xxx', 'フロントエンドチーム', '', '', ''],
  ・
]
```

for文の処理はこのような形に整形しているものになります。
この際注意点があり、各配列の要素数は同じに揃えておく必要があります。
そのため、今回の例ではタグ数の最大数を4つと仮定し、タグがなければ空文字を入れるようにしています。

#### setToSpreadSheet

```js
function setToSpreadSheets(values, first) {
  var spreadsheet = SpreadsheetApp.openById('<スプレッドシートのID>');
  var sheet = spreadsheet.getSheetByName('<貼り付けたいシートの名前>');
    
  var lastRow = sheet.getLastRow();
  var startRow = first ? 2 : lastRow + 1;
  var startCol = 1;

  var numRows = values.length;
  var numCols = values[0].length;
  var range = sheet.getRange(startRow, startCol, numRows, numCols);
  range.setValues(values);
}
```

この関数では、実際にスプレッドシートに値の貼り付けを行なっていきます。
先ほど整形したvaluesを引数で受け取ります。

```js
var spreadsheet = SpreadsheetApp.openById('<スプレッドシートのID>');
var sheet = spreadsheet.getSheetByName('<貼り付けたいシートの名前>');
```

最初の2行で、追加したいスプレッドシート、そのうちの貼り付けたいシートを指定します。

```js
var lastRow = sheet.getLastRow();
var startRow = first ? 2 : lastRow + 1;
var startCol = 1;

var numRows = values.length;
var numCols = values[0].length;
var range = sheet.getRange(startRow, startCol, numRows, numCols);
range.setValues(values);
```

`sheet.getRange()`を用いることで、貼り付けたいシート内の箇所を選択できます。
選択する`開始行`、`開始列`、`選択行数`、`選択列数`の4つを引数に取ります。

rangeを指定すれば、あとは `range.setValues(values)`で先ほどの多重配列を貼り付けすることができます。

ここで、firstという条件がありますが、これはQiita APIの取得記事数が最大100個となっているためです。
そのため、1回目の場合はタイトル行を除き2行目〜、それ以降は記載されている最終行を読み取って追記するようにしています。

#### clearSpreadSheets

```js
function clearSpreadSheets() {
  var spreadsheet = SpreadsheetApp.openById('<スプレッドシートのID>');
  var sheet = spreadsheet.getSheetByName('<貼り付けたいシートの名前>');

  var startRow = 2;
  var startCol = 1;
  var lastRow = sheet.getLastRow();
  // タイトル, URL, タグ*4の合計
  var lastCol = 6;

  var range = sheet.getRange(startRow, startCol, lastRow, lastCol);
  range.clear();
  range.setBackground(null);
}
```

追記していくだけだと、APIを叩くたびに無限に行が増えるので、スプレッドシートの初期化処理を追加しておきます。
`setToSpreadSheet`とほとんど処理内容は同じです。以下の2つの処理を行います。

- `range.clear()`・・・ 選択範囲の値の削除
- `range.setBackground(null)` ・・・ 選択範囲の色の初期化

最後に、mainメソッドにこれらのメソッドを追加し、2回目以降も動くように修正しておきましょう。

```js
function main() {
  // スプレッドシートを初期化
  clearSpreadSheets();

  var page = 1;
  var perPage = 100;
  var HOST = '<自身のQiita:TeamのURLを入力してください>';
  var REQUIRED_TAG = 'tag:フロントエンドチーム'; //ご自身のチーム名を入力してください
  
  var parameters = '?query=' + REQUIRED_TAG + '&per_page=' + perPage + '&page=' + page;
  var response = qiitaApi('https://' + HOST + '/api/v2/items' + parameters, 'GET');
  
  setData(response, true);
  
  // headerから総数を取得できる
  var headers = response.getHeaders();
  var totalCount = headers['total-count'];
  
  // 2ページ目以降をスプレッドシートに登録
  for (page = 2; totalCount / perPage >= 1; page++) {
    var parameters = '?query=' + REQUIRED_TAG + '&per_page=' + perPage + '&page=' + page;
    var response = qiitaApi('https://' + HOST + '/api/v2/items' + parameters, 'GET');

    setData(response, false);
    totalCount = totalCount - perPage; // 1ページで取得できた要素数を減らす
  }
}
```

ここまでできると、以下のように記事一覧表が取得できるようになっているはずです。

<img width="1088" alt="スクリーンショット 2019-12-10 0.57.21.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/203817/4b414e9d-4fdb-11eb-c945-10449625d366.png">


## 3. タグガイドラインに則っていない記事を赤くし、Slackに通知
一覧表が完成したので、スプレッドシートを見るだけでも関連する記事などを探すのは非常に行いやすくなりました。
最後に、最初に決めていたガイドラインに即していない記事を分かるようにし、Slack通知で警告を送るようにしましょう。

#### checkTagRules

```js
function checkTagRules(tags) {
  var TEAM_TAG = "フロントエンドチーム";
  // 「フロントエンドチーム」タグが含まれていること
  var valid1 = tags.some(function(tag) {
    return tag === TEAM_TAG;
  });

  var checkTags = tags.filter(function(tag) {
    return tag !== "" && tag !== TEAM_TAG;
  });

  // タグに「フロントエンドチーム」+ 1つ以上タグが含まれていること
  var valid2 = checkTags.length > 0;
  return valid1 && valid2;
}
```

今回は2つの条件をクリアしているかどうかを判定します。
タグの配列を受け取り、**チームのタグが含まれていること**、**チームタグと別に1つ以上タグを含めていること**を判定し、両方が正であればtrueを返します。

#### setErrorColor

```js
// タグガイドラインに乗っ取らない行を赤色に変更する
function setErrorColor() {
  var spreadsheet = SpreadsheetApp.openById('<スプレッドシートのID>');
  var sheet = spreadsheet.getSheetByName('<記載したシートの名前>');  
  
  var lastRow = sheet.getLastRow();
  var startCol = 3; //タグ開始列
  var maxTagLength = 4; //タグ最大数
  var color = "#ea9999";
  var hasError = false;
  
  // 1行ずつタグをcheckする
  for (i = 2; i <= lastRow; i++) {
    var range = sheet.getRange(i, startCol, 1, maxTagLength);
    var tags = range.getValues()[0];
    
    var valid = checkTagRules(tags);
    if (!valid) {
      range.setBackground(color);
      hasError = true;
    }
  }
  // Slackに送信
  if (hasError) {
    postSlack();
  }
}
```

sheetの各タグ部分を1行ずつ抜き取り、 `checkTagRules`に当てていきます。
もし正でなければ、`range.setBackground(color)`を用いて行の色を変えるようにします。
また、1つでもタグの不整合があれば、Slack通知が飛ぶようにしています。
完成したら、main関数の最後に追加しておきましょう。

#### postSlack

```js
function postSlack(){
  var slackUrl = "<Slackのincoming webhook URL(https://hooks.slack.com/xxxx)>";
  var blockKit = [
    {
      "type": "section",
      "text": {
        "type": "mrkdwn",
        "text": "タグの間違い直してや〜 :+1: <https://docs.google.com/spreadsheets/xxx| チームドキュメント一覧>"
      }
    }
  ];
  
  var options = {
    "method" : "POST",
    "headers": {"Content-type": "application/json"},
    "payload": JSON.stringify({'blocks': blockKit})
  };
  UrlFetchApp.fetch(slackUrl, options);
}
```

最後に、Slackへの通知の関数です。

- slackUrl

まずは、Slackのincoming webhookのURLを取得しましょう。
[slack api](https://api.slack.com/)から「Your Apps」→「Create New App」の順に押し、下記のようなメニューが出てくるので、 **Incoming Webhooks**を選択してURLを取得します。
<img width="180" alt="スクリーンショット 2019-12-15 2.04.35.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/203817/8fdebb24-9fd2-013e-02f3-96ca3629fd49.png">

- blockKit

slack apiには、通常のメッセージの送信よりもリッチなメッセージ送信をマークダウン記法でできる[Block Kit Builder](https://api.slack.com/tools/block-kit-builder)というものがあります。これを用いることで、以下のようなスプレッドシートへのリンクを追加したメッセージが送信できます。

![スクリーンショット 2019-12-15 2.12.52.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/203817/bda5af52-eee1-ee7d-371f-badc53c0c66d.png)


## まとめ
今回はQiita:Teamを用いてチームの欲しい情報を探しやすくするためのタグ付けにガイドラインを置くことの大切さについて、また、その負担を減らすためのチームドキュメント一覧の作成方法、Slackへの周知についてをお話ししましたが、これらはQiita:Teamに限らず様々な他のドキュメントツールでも応用は効くかなと思っています。
また、GASは簡単にSlackやGoogle スプレッドシートなどと連携でき、こういったちょっとしたツール作成との親和性が高いなと思います。

ドキュメントを残すことは非常に重要ですが、それに加えてどう整えていくか、将来に向けた維持管理も徹底しておかないと、数年後に痛い思いをしてしまいがちです。
ぜひ、未来の誰かが悲しまないために、心地よいドキュメント管理状況を作っておきましょう！