QiitaとNoteの記事をGAS(Google Apps Script)を利用してSlackへ送信


概要

SlackのアプリでRSSを利用するとQittaの記事場合、内容の「#」等がアンカーとして認識されたり、自分が思う形とは違い余計なリンク等が沢山表示されて見苦しい状態になります。

そこに加えてNoteの実装例がなかったので一緒にやってみました。サンプルとしては在籍している会社のRSS除法を利用しました。

事前準備

SlackのAPIを使うためにTokenが必要です。基本的に二つの物があります。

Legacy token

workspaceの管理用のTokenなので、権限のレベルが高いので他人に共有することなら良く考えた方が良いです。

参考:https://qiita.com/ykhirao/items/0d6b9f4a0cc626884dbb

App Token

作成したアプリに制限されるのでそのアプリを追加したチャネルのみにアプリに与えた権限だけ使います。

参考:https://qiita.com/ykhirao/items/3b19ee6a1458cfb4ba21

今回は自分はアプリを作成して、そのアプリ対象のチャネルに登録し利用しました。

GAS作成

Google Driveで「新規」 > 「その他」 > 「Google App Script」を選択して、新しいGASを作成します。

対象RSSの設定

// 定数
var FORMAT_TITLE = "%sに新しい投稿があります。";
var FORMAT_QIITA_URL = "http://qiita.com/organizations/%s/activities.atom";
var FORMAT_QIITA_AUTHOR_URL = "http://qiita.com/%s";
var FORMAT_NOTE_URL = "https://note.com/%s/rss";
var FORMAT_NOTE_AUTHOR_URL = "https://note.com/%s";
var KIND_QIITA = "Qiita";
var KIND_NOTE = "Note";
...
// 対象RSS:更新通知を行うユーザー名
var organizations = [
  {kind: KIND_QIITA, key: "wiz_inc"},
  {kind: KIND_NOTE, key: "wiz_creative/m/me7decaba1232"}
];

定数

基本的な通知のメッセージと記事のURL、投稿者のURLのフォーマットを定義しております。こんな感じです。

FORMAT_QIITA_URL + wiz_inc => http://qiita.com/organizations/wiz_inc/activities.atom

対象RSS

現在二種類の「Qitta」と「Note」で、クローリングするユーザーキーを定義しています。RSSの種類や対象が増えたら、増やしやすいかと思いました。

また、今回はGASのトリガーを利用して同じ時間で実行(順番だが)しておりますが、今後トリガーの情報も入れたりして各自調整が出来ればと思ったのでJsonオブジェクトの形式で作ってみました。

QiitaのRSS取得

結合されたURLでQiitaのRSSをパーシングして必要な情報を取得するFunctionです。

function parseQiita(url) {
  // 上記URLにGETリクエスト、getContentText()でxmlを取得
  var xml = UrlFetchApp.fetch(url).getContentText();
  // 取得したxmlを文書化
  var document = XmlService.parse(xml);
  // ドキュメントのルート要素を抽出
  var root = document.getRootElement();
  // 引数のURLを用いてNameSpaceを生成します。(おそらくフォーマット指定?)
  var atom = XmlService.getNamespace("http://www.w3.org/2005/Atom");
  // エントリー記事を取得
  var entries = root.getChildren("entry", atom);
  // Logger.log(entries);

  var returnArray = [];
  for (var i = 0; i < entries.length; i++) {
    // 最新記事を取得
    var entry = entries[i];
    var id = entry.getChild("id", atom).getText();
    var pos = id.indexOf("/");
    var entrieId = id.substr(pos+1);

    // 記事情報の取得
    var postUrl = entry.getChild("url", atom).getText();
    var title = entry.getChild("title", atom).getText();
    var author = entry.getChild("author", atom).getChild("name", atom).getText();

    // 記事の更新日時を取得
    var publishTime = entry.getChild("published", atom).getText();
    var nowTime = Utilities.formatDate(new Date(), "Asia/Tokyo", "yyyy-MM-dd'T'HH:mm:ss");

    // 更新があった場合[1分以内に更新があったもの]
    if (Date.parse(nowTime) - Date.parse(publishTime) <= LIMIT_TIME_LAG) {
      returnArray.push({authorKey: author, authorName: author, title: title, url: postUrl});
    }
  }

  return returnArray;
}

NoteのRSS取得

function parseNote(url) {
  // 上記URLにGETリクエスト、getContentText()でxmlを取得
  var content = UrlFetchApp.fetch(url).getContentText();
  // 取得したxmlを文書化
  var xml = XmlService.parse(content);
  // XMLのitem配下のデータを取得
  var items = xml.getRootElement().getChildren('channel')[0].getChildren('item'); 
  // Logger.log(items);
  // note用引数のURLを用いてNameSpaceを生成します。
  var note = XmlService.getNamespace("https://note.com");

  var returnArray = [];
  for (var i = 0; i < items.length; i++) {
    // 最新記事を取得
    var item = items[i];

    // 記事情報の取得
    var postUrl = item.getChild("link").getText();
    var title = item.getChild("title").getText();
    var authorKey = item.getChild("link").getText().match(/https:\/\/note.com\/([a-z0-9_]*?)\/.*?/)[1];
    var authorName = item.getChild("creatorName", note).getText();

    // 記事の更新日時を取得
    var publishTime = item.getChild("pubDate").getText();
    var nowTime = Utilities.formatDate(new Date(), "Asia/Tokyo", "yyyy-MM-dd'T'HH:mm:ss");

    // 更新があった場合[1分以内に更新があったもの]
    if (Date.parse(nowTime) - Date.parse(publishTime) <= LIMIT_TIME_LAG) {
      returnArray.push({authorKey: authorKey, authorName: authorName, title: title, url: postUrl});
    }
  }

  return returnArray;
}

Slack定数

// Slack定数
var SLACK_CANNEL = "SlackのChannelのID";
var SLACK_URL = 'https://slack.com/api/chat.postMessage';
var SLACK_TOKEN = 'xoxb-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx';

SLACK_CHANNEL

ブラウザーだとURLが「https://xxxxxx.slack.com/archives/YYYYYYYYY」の場合、「YYYYYYYYY」がチャネルIDです。

アプリの場合は対象チャネルでマウスの右ボタンをクリックすると以下のメニューが表示されます。

このメニューの「リンクをコピー」をクリックするとURLが見えるので同じく一番後ろの「YYYYYYYYY」を取得します。

SLACK_TOKEN

上記「事前準備」のどちらかのTokenを設定します。

メイン処理

function main() {  
  for (var k = 0; k < organizations.length; k++) {
     parseXml(organizations[k]);
  }
}

function parseXml(rssInfo) {
  var url = null, authorUrlFormat = null;
  var blocks = [], posts = [];
  var msgTitle = Utilities.formatString(FORMAT_TITLE, rssInfo.kind);
  if (rssInfo.kind == KIND_QIITA) {
    // Qiitaの場合
    // フィードURL
    url = Utilities.formatString(FORMAT_QIITA_URL, rssInfo.key);
    // 作成者URL
    authorUrlFormat = FORMAT_QIITA_AUTHOR_URL;

    // Blockの初期(先頭)設定
    blocks = [
      {
        type: "section",
        text: {
          type: "mrkdwn",
          text: "*Qiita Organization:* https://qiita.com/organizations/wiz_inc\n" + msgTitle + "\n\n*新しい記事リスト*"
        }
      },
      {
        "type": "divider"
      }
    ];

    posts = parseQiita(url);
  } else if (rssInfo.kind == KIND_NOTE) {
    // Noteの場合
    // フィードURL
    url = Utilities.formatString(FORMAT_NOTE_URL, rssInfo.key);
    // 作成者URL
    authorUrlFormat = FORMAT_NOTE_AUTHOR_URL;

    // Blockの初期(先頭)設定
    blocks = [
      {
        type: "section",
        text: {
          type: "mrkdwn",
          text: "*Note Organization:* https://note.com/wiz_creative/m/me7decaba1232\n" + msgTitle + "\n\n*新しい記事リスト*"
        }
      },
      {
        "type": "divider"
      }
    ];

    posts = parseNote(url);
  }

  if (posts.length > 0) {
    for (var i = 0; i < posts.length; i++) {
      var post = posts[i];
      msgTitle = msgTitle + " <" + post.url + ">";
      blocks.push(
        {
          type: "section",
          text: {
            type: "mrkdwn",
            text: "<" + Utilities.formatString(authorUrlFormat, post.authorKey) + "|@" + post.authorName + ">から「" + post.title + "」が投稿されました。\nLink: <" + post.url + ">"
          }
        }
      );
    }

    // Blockの最後設定
    blocks.push(
      {
        "type": "divider"
      });
    var tmp = JSON.stringify(blocks);
    Logger.log(tmp);
    var payload = {
      token: SLACK_TOKEN,
      channel: SLACK_CANNEL,
      text: msgTitle,
      unfurl_links: true,
      username: "Robot",
      icon_url: "https://XXXXXXXX/robot.png",
      blocks: JSON.stringify(blocks)
    };
    var params = {
      'method': 'post',
      'payload': payload,
      'muteHttpExceptions': true
    };
    var response = UrlFetchApp.fetch(SLACK_URL, params);
    var responseCode = response.getResponseCode();
    if (responseCode != 200) {
      var responseBody = response.getContentText();
      Logger.log(Utilities.formatString("Request failed. Expected 200, got %d: %s", responseCode, responseBody));
    }
  }
}

OGP カード

「unfurl_links: true」にしてもOGP情報が表示されなくて、調べた結果textに設定したもので6個以上は表示されない条件があるようです。そのために以下の部分を入れました。

msgTitle = msgTitle + " <" + post.url + ">";

その後にmsgTitleを設定します。このtextが実際表示されるのはOS(?)の通知に表示されるだけです。

  var payload = {
      token: SLACK_TOKEN,
      channel: SLACK_CANNEL,
      text: msgTitle,
      unfurl_links: true,
      username: "Robot",
      icon_url: "https://XXXXXXXX/robot.png",
      blocks: JSON.stringify(blocks)
    };

トリガーの設定

「編集」 > 「現在のプロジェクトのトリガー」を選択してトリガー作成を行います。

時間設定は分単位で「LIMIT_TIME_LAG」の設定と合わせて5分に設定します。

// 5分をミリ秒へ変換したもの
var LIMIT_TIME_LAG = 300000;

完成ソース

いきなり完成ソースを共有します。その後に細かい説明します。

// Slack定数
var SLACK_CANNEL = "SlackのChannel";
var SLACK_URL = 'https://slack.com/api/chat.postMessage';
var SLACK_TOKEN = 'xoxb-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx';
// RSS定数
var FORMAT_TITLE = "%sに新しい投稿があります。";
var FORMAT_QIITA_URL = "http://qiita.com/organizations/%s/activities.atom";
var FORMAT_QIITA_AUTHOR_URL = "http://qiita.com/%s";
var FORMAT_NOTE_URL = "https://note.com/%s/rss";
var FORMAT_NOTE_AUTHOR_URL = "https://note.com/%s";
var KIND_QIITA = "Qiita";
var KIND_NOTE = "Note";
// 5分をミリ秒へ変換したもの
var LIMIT_TIME_LAG = 300000;

// 更新通知を行うユーザー名
var organizations = [
  {kind: KIND_QIITA, key: "wiz_inc"},
  {kind: KIND_NOTE, key: "wiz_creative/m/me7decaba1232"}
];

function main() {  
  for (var k = 0; k < organizations.length; k++) {
     parseXml(organizations[k]);
  }
}

function parseXml(rssInfo) {
  var url = null, authorUrlFormat = null;
  var blocks = [], posts = [];
  var msgTitle = Utilities.formatString(FORMAT_TITLE, rssInfo.kind);
  if (rssInfo.kind == KIND_QIITA) {
    // Qiitaの場合
    // フィードURL
    url = Utilities.formatString(FORMAT_QIITA_URL, rssInfo.key);
    // 作成者URL
    authorUrlFormat = FORMAT_QIITA_AUTHOR_URL;

    // Blockの初期(先頭)設定
    blocks = [
      {
        type: "section",
        text: {
          type: "mrkdwn",
          text: "*Qiita Organization:* https://qiita.com/organizations/wiz_inc\n" + msgTitle + "\n\n*新しい記事リスト*"
        }
      },
      {
        "type": "divider"
      }
    ];

    posts = parseQiita(url);
  } else if (rssInfo.kind == KIND_NOTE) {
    // Noteの場合
    // フィードURL
    url = Utilities.formatString(FORMAT_NOTE_URL, rssInfo.key);
    // 作成者URL
    authorUrlFormat = FORMAT_NOTE_AUTHOR_URL;

    // Blockの初期(先頭)設定
    blocks = [
      {
        type: "section",
        text: {
          type: "mrkdwn",
          text: "*Note Organization:* https://note.com/wiz_creative/m/me7decaba1232\n" + msgTitle + "\n\n*新しい記事リスト*"
        }
      },
      {
        "type": "divider"
      }
    ];

    posts = parseNote(url);
  }

  if (posts.length > 0) {
    for (var i = 0; i < posts.length; i++) {
      var post = posts[i];
      msgTitle = msgTitle + " <" + post.url + ">";
      blocks.push(
        {
          type: "section",
          text: {
            type: "mrkdwn",
            text: "<" + Utilities.formatString(authorUrlFormat, post.authorKey) + "|@" + post.authorName + ">から「" + post.title + "」が投稿されました。\nLink: <" + post.url + ">"
          }
        }
      );
    }

    // Blockの最後設定
    blocks.push(
      {
        "type": "divider"
      });
    var tmp = JSON.stringify(blocks);
    Logger.log(tmp);
    var payload = {
      token: SLACK_TOKEN,
      channel: SLACK_CANNEL,
      text: msgTitle,
      unfurl_links: true,
      username: "Robot",
      icon_url: "https://XXXXXXXX/robot.png",
      blocks: JSON.stringify(blocks)
    };
    var params = {
      'method': 'post',
      'payload': payload
    };
    var response = UrlFetchApp.fetch(SLACK_URL, params);
  }
}

function parseQiita(url) {
  // 上記URLにGETリクエスト、getContentText()でxmlを取得
  var xml = UrlFetchApp.fetch(url).getContentText();
  // 取得したxmlを文書化
  var document = XmlService.parse(xml);
  // ドキュメントのルート要素を抽出
  var root = document.getRootElement();
  // 引数のURLを用いてNameSpaceを生成します。(おそらくフォーマット指定?)
  var atom = XmlService.getNamespace("http://www.w3.org/2005/Atom");
  // エントリー記事を取得
  var entries = root.getChildren("entry", atom);
  // Logger.log(entries);

  var returnArray = [];
  for (var i = 0; i < entries.length; i++) {
    // 最新記事を取得
    var entry = entries[i];
    var id = entry.getChild("id", atom).getText();
    var pos = id.indexOf("/");
    var entrieId = id.substr(pos+1);

    // 記事情報の取得
    var postUrl = entry.getChild("url", atom).getText();
    var title = entry.getChild("title", atom).getText();
    var author = entry.getChild("author", atom).getChild("name", atom).getText();

    // 記事の更新日時を取得
    var publishTime = entry.getChild("published", atom).getText();
    var nowTime = Utilities.formatDate(new Date(), "Asia/Tokyo", "yyyy-MM-dd'T'HH:mm:ss");

    // 更新があった場合[1分以内に更新があったもの]
    if (Date.parse(nowTime) - Date.parse(publishTime) <= LIMIT_TIME_LAG) {
      returnArray.push({authorKey: author, authorName: author, title: title, url: postUrl});
    }
  }

  return returnArray;
}

function parseNote(url) {
  // 上記URLにGETリクエスト、getContentText()でxmlを取得
  var content = UrlFetchApp.fetch(url).getContentText();
  // 取得したxmlを文書化
  var xml = XmlService.parse(content);
  // XMLのitem配下のデータを取得
  var items = xml.getRootElement().getChildren('channel')[0].getChildren('item'); 
  // Logger.log(items);
  // note用引数のURLを用いてNameSpaceを生成します。
  var note = XmlService.getNamespace("https://note.com");

  var returnArray = [];
  for (var i = 0; i < items.length; i++) {
    // 最新記事を取得
    var item = items[i];

    // 記事情報の取得
    var postUrl = item.getChild("link").getText();
    var title = item.getChild("title").getText();
    var authorKey = item.getChild("link").getText().match(/https:\/\/note.com\/([a-z0-9_]*?)\/.*?/)[1];
    var authorName = item.getChild("creatorName", note).getText();

    // 記事の更新日時を取得
    var publishTime = item.getChild("pubDate").getText();
    var nowTime = Utilities.formatDate(new Date(), "Asia/Tokyo", "yyyy-MM-dd'T'HH:mm:ss");

    // 更新があった場合[1分以内に更新があったもの]
    if (Date.parse(nowTime) - Date.parse(publishTime) <= LIMIT_TIME_LAG) {
      returnArray.push({authorKey: authorKey, authorName: authorName, title: title, url: postUrl});
    }
  }

  return returnArray;
}

Slackの表示

Qittaの場合

Noteの場合

改善すべきところ

Qiitaのエラー

実際運営して見るとQiitaのRSSで以下のようなエラーが出ていました。Timeoutなのかどうかはよく分かりません。

Exception: 使用できないアドレス: http://qiita.com/organizations/wiz_inc/activities.atom

5分単位でトリガーを設定したので、負荷?が掛かったのか良く分からないですがその後の処理が実行されなくなるので、以下のロジックでで対応する予定です。

  // 上記URLにGETリクエスト、getContentText()でxmlを取得
  var response = UrlFetchApp.fetch(url, {'muteHttpExceptions': true});
  // 取得失敗した場合
  var responseCode = response.getResponseCode();
  if (responseCode != 200) {
    var responseBody = response.getContentText();
    Logger.log(Utilities.formatString("Request failed. Qiita Expected 200, got %d: %s", responseCode, responseBody));
  }

エラー対応が入ったソースhttps://fatty-rabbit.tistory.com/16

Function的な感じ

全然OOPではないことに完成ソースを見るとめがチクチクして来ました。ソースを管理する上にlocalでTSで作成して管理、実行時にBuildする方法が欲しいですね。

参考:https://qiita.com/jerrywdlee/items/a037bb7764b0671d4059