GAS(Google Apps Script)を利用してQiitaとNoteの記事をSlackへ送信
事前準備
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を作成します。
完成ソース
いきなり完成ソースを共有します。その後に細かい説明します。
// 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: "Qiitaの企業のURLキー"}, {kind: KIND_NOTE, key: "NoteのURLキー"} ]; var exceptions = []; function main() { // 記事をSlackへ送信 for (var k = 0; k < organizations.length; k++) { parseXml(organizations[k]); } // Exceptionを出力 if (exceptions.length > 0) { for(var i = 0; i < exceptions.length; i++) { var e = exceptions[i]; Logger.log(e); } throw new Error('RSSの通信のエラーがありました。ログを確認してください。'); } } 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: 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: 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: "Wiz Robot", icon_url: "https://inspirelabs.pl/wp-content/uploads/2018/01/robot.png", blocks: JSON.stringify(blocks) }; var params = { 'method': 'post', 'payload': payload, 'muteHttpExceptions': true }; try { var response = UrlFetchApp.fetch(SLACK_URL, params); } catch(e) { exceptions.push(e); } var responseCode = response.getResponseCode(); if (responseCode != 200) { var responseBody = response.getContentText(); exceptions.push(Utilities.formatString("Request failed. Expected 200, got %d: %s", responseCode, responseBody)); } } } function parseQiita(url) { // 上記URLにGETリクエスト、getContentText()でxmlを取得 var response = UrlFetchApp.fetch(url, {'muteHttpExceptions': true}); // 取得失敗した場合 var responseCode = response.getResponseCode(); if (responseCode != 200) { var responseBody = response.getContentText(); exceptions.push(Utilities.formatString("Request failed. Qiita Expected 200, got %d: %s", responseCode, responseBody)); return []; } // xmlを取得 var xml = response.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 response = UrlFetchApp.fetch(url, {'muteHttpExceptions': true}); // 取得失敗した場合 var responseCode = response.getResponseCode(); if (responseCode != 200) { var responseBody = response.getContentText(); exceptions.push(Utilities.formatString("Request failed. Note Expected 200, got %d: %s", responseCode, responseBody)); return []; } // xmlを取得 var xml = response.getContentText(); // 取得したxmlを文書化 var document = XmlService.parse(xml); // XMLのitem配下のデータを取得 var items = document.getRootElement().getChildren('channel')[0].getChildren('item'); // Logger.log(items); // 引数のURLを用いてNameSpaceを生成します。 var node = 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", node).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_CHANNEL
ブラウザーだとURLが「https://xxxxxx.slack.com/archives/YYYYYYYYY」の場合、「YYYYYYYYY」がチャネルIDです。
アプリの場合は対象チャネルでマウスの右ボタンをクリックすると以下のメニューが表示されます。
このメニューの「リンクをコピー」をクリックするとURLが見えるので同じく一番後ろの「YYYYYYYYY」を取得します。
SLACK_TOKEN
上記「事前準備」のどちらかのTokenを設定します。
FORMAT_QIITA_URL
Qiitaの企業向けのRSS配信するURLパターンです。「%s」のところに企業URL「https://qiita.com/organizations/xxxxx」の一番後ろの「xxxxx」が入る感じです。この「xxxxx」が「Qiitaの企業のURLキー」部分に入ります。
FORMAT_QIITA_AUTHOR_URL
RSSをパーシングして作成者の情報から実際の作成者のURLを作るURLパターンです。
FORMAT_NOTE_URL
FORMAT_QIITA_URLと同様にNoteのRSS配信するURLパターンです。「http://note.com/」と「/rss」の間の文字が「NoteのURLキー」部分に入ります。
FORMAT_NOTE_AUTHOR_URL
FORMAT_QIITA_AUTHOR_URLと同様に作成者の情報から実際の作成者のURLを作るURLパターンです。
トリガーの設定
「編集」 > 「現在のプロジェクトのトリガー」を選択してトリガー作成を行います。
時間設定は分単位で「LIMIT_TIME_LAG」の設定と合わせて5分に設定します。
記憶しておくべき
権限(SCOPE)
アプリのアイコンや表示名の変更する以下のソースがありますが、こちら基本的に必要な権限(SCOPE)があります。
参考:https://api.slack.com/methods/chat.postMessage
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) };
Qiitaのエラー
実際運営して見るとQiitaのRSSで以下のようなエラーが出ていました。
Exception: 使用できないアドレス: http://qiita.com/organizations/xxxxxx/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(); exceptions.push(Utilities.formatString("Request failed. Qiita Expected 200, got %d: %s", responseCode, responseBody)); return []; } // xmlを取得 var xml = response.getContentText();
「muteHttpExceptions: true」に設定した後にresponseCodeで判定してエラーのログを出力します。
改善すべきところ
Function的な感じ
全然OOPではないことに完成ソースを見るとめがチクチクして来ました。ソースを管理する上にlocalでTSで作成して管理、実行時にBuildする方法が欲しいですね。