GoogleAppsScript(GAS)からQiitaAPIとトリガーを使って自投稿記事ランキングを自動更新

GoogleAppsScript(GAS)からQiitaAPIとトリガー設定を使って自投稿記事ランキングを自動更新しています([こちら](https://qiita.com/Pell/items/023e15a1e7c480664d2c))。そのソースコードを備忘録として残します。環境構築不要です。


# 本記事の環境

※PCに環境構築を行う必要はありません。
WEBブラウザ(Google Chome)
Googleアカウント

# 使用技術

GoogleAppsScriptのみ。

# 事前準備

* Qiitaにて、「設定」から「アプリケーション」を選択する。
* 「個人用アクセストークン」を発行する。※私は読み書き権限両方を付けました。
* 発行した「個人用アクセストークン」をメモする。※後で入力します。

# 手順

* 自動更新の対象となる限定共有記事を作成しておく。
* 以下の成果物をGoogleスプレッドシート/拡張機能/Apps Scriptにコピー&ペーストし、「ユーザーID」と「アクセストークン」と「投稿記事タイトル」と「投稿記事ID」を書き換える。
* 「実行」ボタンを選択して、実行が終わるまで少し待つ。
  * 初回は、承認、Googleアカウントへのログイン、リクエストの許可が求められる。
* 実行が終わったら実行ログを確認する。
* 実行ログに問題がなければ、Qiitaの限定共有記事を確認する。
* 限定共有記事に問題がなければ、トリガー設定を行う。

![12_トリガー追加動線.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1009061/fb1770f5-00a8-6fe2-6054-9a58e3bfa606.png)
![13_トリガー設定モーダル一例.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1009061/8aac673d-3edd-0190-6058-f7a19b8a9a6c.png)

* トリガー設定契機の実行ログを確認する。実行ログに問題がなければ、Qiitaの限定共有記事を確認する。

# 所感

* 色んな方が実施している自動更新記事を環境構築不要な手順で実装できてよかった。
* それなりの頻度でQiitaAPIが失敗することがあったためリトライ処理を実装しました。

# 成果物

```javascript
// ユーザーID
const USER_ID3 = "[ユーザーID]";
// 全権限トークン
const TOKEN_ALL3 = "[発行したトークン]";
// データ整形用
const COLUMNS3 = [
  "created_at", "title", "page_views_count", "likes_count", "stock_count", "comments_count",
];
// 投稿記事タイトル
const ITEM_TITLE3 = "[投稿記事タイトル]";
// 投稿記事ID
const ITEM_ID3 = "[投稿記事ID]";
// 上位何投稿記事を出力範囲とするか
const MAX_RANK3 = 10;
// 出力内容一覧
const OUTPUT3 = [
  "", "", "views数TOP10", "LGTM数TOP10", "ストック数TOP10", "",
];
// API呼び出しリトライ回数
const MAX_RETRY3 = 5;

// 記事更新処理
function updateQiita() {
  // 記事一覧取得処理を呼び出す。
  const qiitaList = getQiita();
  if (qiitaList.length < 1) {
    Logger.log("記事件数0");
    return;
  }

  // 投稿記事本文作成処理を呼び出す。
  const itemBody = makeItemBody(qiitaList);
  
  const apiUrl = "https://qiita.com/api/v2/items/" + ITEM_ID3;
  const item_data = {
    'body' : itemBody,          // 投稿記事本文
    'title' : ITEM_TITLE3,      // 投稿記事タイトル
  };
  const headers = {'Authorization' : 'Bearer ' + TOKEN_ALL3};
  const params = {
    'method' : "patch",
    'contentType' : "application/json",
    'payload' : JSON.stringify(item_data),
    'headers' : headers,
    'muteHttpExceptions' : true
  };

  for (let cnt_retry = 0; cnt_retry < MAX_RETRY3; cnt_retry++) {
    let response = UrlFetchApp.fetch(apiUrl, params);
    if (response.getResponseCode() >= 300) {
      Logger.log("記事更新失敗 : " + response.getContentText());
    } else {
      let obj = JSON.parse(response.getContentText());
      Logger.log("記事更新成功 : title[" + obj.title + "]、url[" + obj.url + "]");
      break;
    }
  }
}

// 記事一覧取得処理
function getQiita() {
  let result = [];
  const apiUrl = "https://qiita.com/api/v2/users/" + USER_ID3 + "/items";
  const params = {
    'muteHttpExceptions' : true
  };
  let total = 0;
  for (let cnt_retry = 0; cnt_retry < MAX_RETRY3; cnt_retry++) {
    let response = UrlFetchApp.fetch(apiUrl, params);
    if (response.getResponseCode() >= 300) {
      Logger.log("記事一覧取得失敗 : " + response.getContentText());
    } else {
      let obj = JSON.parse(response.getContentText());
      total = response.getHeaders()['total-count'];
      // Logger.log("記事一覧取得成功");
      break;
    }
  }
  let page = 1;
  let index_result = 0;
  while (index_result < total) {
    // ページャーごとに分けて記事を取得する。
    // 記事を全件取得するまで処理を続ける。
    let jsonData = [];
    for (let cnt_retry = 0; cnt_retry < MAX_RETRY3; cnt_retry++) {
      let response = UrlFetchApp.fetch(apiUrl + "?page=" + page, params);
      if (response.getResponseCode() >= 300) {
        Logger.log("記事一覧(ページャーごと)取得失敗 : " + response.getContentText());
      } else {
        jsonData = JSON.parse(response.getContentText());
        // Logger.log("記事一覧(ページャーごと)取得成功");
        break;
      }
    }
    for (let index_json in jsonData) {
      // 取得した記事ごとに処理を行う。
      let work = [];
      // 記事詳細取得処理を呼び出す。
      let detail = getQiitaDetail(jsonData[index_json]['id']);
      // ストック数取得処理を呼び出す。
      let stock_count = getQiitaStock(jsonData[index_json]['id']);
      for(let column of COLUMNS3) {
        switch (column){
          case "page_views_count":
            work.push(detail['page_views_count']);
            break;
          case "created_at":
            // 日付のフォーマットを変更する。
            work.push(Utilities.formatDate(new Date(jsonData[index_json][column]),"JST", "yyyy/MM/dd"));
            break;
          case "stock_count":
            work.push(stock_count);
            break;
          case "title":
            work.push("[" + jsonData[index_json][column] + "](" + jsonData[index_json]['url'] + ")");
            break;
          default:
            work.push(jsonData[index_json][column]);
            break;
        }
      }
      result[index_result] = work;
      index_result++;
    }
    page++;
  }
  // Logger.log(result);
  return result;
}

// 記事詳細取得処理(views数)
function getQiitaDetail(id) {
  const apiUrl = "https://qiita.com/api/v2/items";
  const headers = {'Authorization' : 'Bearer ' + TOKEN_ALL3};
  const params = {
    'headers' : headers,
    'muteHttpExceptions' : true
  };
  let jsonDetail = [];
  for (let cnt_retry = 0; cnt_retry < MAX_RETRY3; cnt_retry++) {
    let response = UrlFetchApp.fetch(apiUrl + "/" + id, params);
    if (response.getResponseCode() >= 300) {
      Logger.log("記事詳細取得失敗 : " + response.getContentText());
    } else {
      jsonDetail = JSON.parse(response.getContentText());
      // Logger.log("記事詳細取得成功");
      break;
    }
  }
  return jsonDetail;
}

// ストック数取得処理
function getQiitaStock(id) {
  let result = 0;
  const apiUrl = "https://qiita.com/api/v2/items/"　+ id + "/stockers";
  const headers = {'Authorization' : 'Bearer ' + TOKEN_ALL3};
  const params = {
    'headers' : headers,
    'muteHttpExceptions' : true
  };
  let page = 1;
  let flg = true;
  while (flg) {
    // ページャーごとに分けてストック情報を取得する。
    // ストック情報を全件取得するまで処理を続ける。
    let jsonData = [];
    for (let cnt_retry = 0; cnt_retry < MAX_RETRY3; cnt_retry++) {
      let response = UrlFetchApp.fetch(apiUrl + "?page=" + page + "&per_page=100", params);
      if (response.getResponseCode() >= 300) {
        Logger.log("ストック数取得失敗 : " + response.getContentText());
      } else {
        jsonData = JSON.parse(response.getContentText());
        // Logger.log("ストック数取得成功");
        break;
      }
    }
    result += jsonData.length;
    if (jsonData.length < 100) {
      // 次のページャーがないと判断して処理終了。
      flg = false;
    }
    page++;
  }
  // Logger.log(result);
  return result;
}

// 投稿記事本文作成処理
function makeItemBody(qiitaList) {
  let result = "GoogleAppsScript(GAS)からQiitaAPIを用いることで、私の投稿記事の一覧の取得と本集計用投稿記事の自動更新を行っています。\n";
  const nowDate = new Date();
  result += "自動更新処理日時：" + Utilities.formatDate(nowDate,"JST", "yyyy/MM/dd HH:mm") + "\n\n";
  result += "# 本記事の環境\n\n";
  result += "※PCに環境構築を行う必要はありません。\nWEBブラウザ(Google Chome)\nGoogleアカウント\n\n";

  result += "# 使用技術\n\n";
  result += "GoogleAppsScriptのみ。\n\n";

  for (let output in OUTPUT3) {
    if (OUTPUT3[output] == "") {
      continue;
    }
    result += "# " + OUTPUT3[output] + "\n\n";
    result += "L:LGTM数、S:ストック数、C:コメント数\n";
    result += "|投稿日|タイトル|views|L|S|C|\n";
    result += "|:---:|:---|---:|---:|---:|---:|\n";
    qiitaList.sort((a, b) => {return b[output] - a[output];});
    for (let indexX = 0; indexX < MAX_RANK3; indexX++) {
      for (let indexY = 0; indexY < qiitaList[indexX].length; indexY++) {
        // 各記事情報を出力する。
        result += "|" + qiitaList[indexX][indexY];
      }
      result += "|\n";
    }
  }

  result += "# views伸び率(views数 / 掲載日数)TOP10\n\n";
  result += "L:LGTM数、S:ストック数、C:コメント数\n";
  result += "|投稿日|タイトル|views|L|S|C|伸び率|\n";
  result += "|:---:|:---|---:|---:|---:|---:|---:|\n";
  let work = [];
  for (let indexX = 0; indexX < qiitaList.length; indexX++) {
    work.push(qiitaList[indexX]);
    let targetDate = new Date(qiitaList[indexX][0]);
    let diffDate = (nowDate.getTime() - targetDate.getTime()) / (1000 * 60 * 60 * 24);
    work[indexX].push(Math.round((qiitaList[indexX][2] / diffDate) * 100) / 100);
  }
  work.sort((a, b) => {return b[6] - a[6];});
  for (let indexX = 0; indexX < MAX_RANK3; indexX++) {
    for (let indexY = 0; indexY < work[indexX].length; indexY++) {
      // 各記事情報を出力する。
      result += "|" + work[indexX][indexY];
    }
    result += "|\n";
  }

  return result;
}
```