前々回のTypetalk APIとGoogle App Script(以降GAS)組み合わせた第二弾です。 前回はこちらから。
12月になりましたが、一年を振り返るにはなんだかまだ早いような気がしております。 12月にはクリスマスがありますね。そんなクリスマスまでの期間を数えるアドベントカレンダーというのがありますが、 エンジニアブログなどの界隈では、アドベントカレンダーになぞって?テーマに沿ってブログを書いていくイベントというのがあります。
自分は毎年、これを毎日見ているのですが、 どうせなら、更新されたチャット上に流れたら、わざわざ自分から見に行かなくても済むのになーと思い、少し作ってみました。 このアドベントカレンダーは各テーマごとにRSSが配信されているので、RSSを利用します。 また、今回のアドベントカレンダーに問わず、RSS1.0,2.0,Atomで利用できるように配慮して作成してみました。
すべてのソースは最後にまとめて記載いたしますので、 説明時のソースコードで、自作の関数などが入っていて、わかりづらくなっておりますが、 予めご了承くださいませ。
大まかな処理の流れ
- RSSのURLから記事のタイトルとURLを取得する
- その日にすでに投稿された記事かを判定して、投稿されていなければ、投稿・記録する。
- どの日にどんな記事が投稿されたか振り返れるようにしたい。
(チャットは流れていくので、一週間でどんなのがあったのか振り返りやすくしたい)
ex1. URLを増やし足り減らしたりしたい。(RSSのURLをシート上で管理)
ex2. 投稿は月単位のシートで作成したい(手動では作成したくない)
まずはbotの投稿の関数を用意していきます。
投稿用のためのtokenなどを前回の記事を参考に作成。
少しだけ使いやすくしたいので、 以下のような形にしておこうと思います。
/** * @param token * @param topicId */ function ttClient(token, topicId) { var optionDefault = { method : 'post', contentType: 'application/x-www-form-urlencoded' }; return { post : function(message, options) { options = options || optionDefault; if (!options.payload) { options.payload = {}; } options.payload.message = message; var url = 'https://typetalk.com' + '/api/v1/topics/' + topicId + '?typetalkToken=' + token; var res = UrlFetchApp.fetch(url, options); } } } // 変数に入れたのち、 var typetalkBot = ttClient(TOKEN, TOPIC_ID); typetalkBot.post(message);
tyepetalkへの投稿の準備ができました。 さて、次はRSSを取得するものを書いていきます。
RSSのURLはグーグルのスプレッドシートに記載しておきます。
この記載したURLからデータを取得する関数を書いていきます。
GASでRSSでの取得は上記のbot用で使ったUrlFetchAppとXMLの解析用にXmlServiceの二つのクラスを使っていきます。 処理のイメージは以下の通りです。 1. URLからRSSを取得(UrlFetchApp) 2. 取得したURLからXMLServiceを通してパースします。
取得した段階では文字列なので、2の動作はXMLとして扱えるようにするための処理です。
その名前空間取得用の変数を用意していきます。 RSSにはいくつか種類があり、主要なものはRSS1,RSS2,Atomの三種類になります。 各RSSは一部構造が違っているため、各タイプに合わせて、処理を変更していきます。 取得したら、最終的には記事名とURLの組が入った配列が返却されます。
以上を記述した、ソースは以下のようになります。
/** * feedURLから記事情報を取得 * @param {String} feedUrl * @return {Array.<*>} */ function fetchTodayPosts(feedUrl) { var response = UrlFetchApp.fetch(feedUrl); var rssXML = response.getContentText(); var document = XmlService.parse(rssXML); var root = document.getRootElement(); var ns_rss = XmlService.getNamespace('http://purl.org/rss/1.0/'); var ns_dc = XmlService.getNamespace('dc', 'http://purl.org/dc/elements/1.1/'); var ns_atom = XmlService.getNamespace('http://www.w3.org/2005/Atom'); var ns_rdf = XmlService.getNamespace('http://www.w3.org/1999/02/22-rdf-syntax-ns#'); var rootTagName = root.getName().toLowerCase(); var entries = []; // 記事のリストを取得 switch (rootTagName) { case 'rdf': // 1.0 entries = root.getChildren('item', ns_rss); break; case 'feed': // atom entries = root.getChildren('entry', ns_atom); break; case 'rss': // 2.0 entries = root.getChild('channel').getChildren('item'); break; default: return false; } return entries.map(function(entry, index) { var title; var link; var pubDate; switch (rootTagName) { case 'rdf': // 1.0 title = entry.getChild('title', ns_rss).getText(); link = entry.getChild('link', ns_rss).getText(); pubDate = entry.getChild('date', ns_dc).getText(); break; case 'feed': // atom title = entry.getChild('title', ns_atom).getText(); link = entry.getChild('link', ns_atom).getAttribute('href').getValue(); // pubDate = entry.getChild('published', ns_atom).getText(); // 普段はこっちの方を利用する pubDate = entry.getChild('updated', ns_atom).getText(); // 今回はQiita用にupdatedを利用する break; case 'rss': // 2.0 title = entry.getChild('title').getText(); link = entry.getChild('link').getText(); pubDate = entry.getChild('pubDate').getText(); break; } // 同一日時なら返却 if (dateFormat(new Date()) === dateFormat(new Date(pubDate))) { // ※このdateFormatは独自関数です。 return [title,link]; } return ''; }).filter(function(item) { return item !== ''; }); }
3. GASで投稿記録を見ながら、タイプトークへ投稿する。
今までものものを組みわせて、投稿までの処理を行なっていきます。SpreadSheetにある、情報を適宜参照しながら、投稿処理をしていきます。
ここからの関数はGASのトリガーなどで起動する関数となりますので、 rssBotという関数名を付けておきます。
function rssBot() { 'use strict'; // typetalkで取得した値を入れてください var TOKEN = 'XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX'; var TOPIC_ID = 'NNNNNNNNN'; var typetalkClient = ttClient(TOKEN, TOPIC_ID); // 日付の変数を取得 現在の月と今日の日付 var date = new Date(); var toMonth = (date.getMonth() + 1); var toDay = date.getDate(); /* * スプレッドシートの取得 * 投稿された記録を残すシートを取得する ------------------------------------- */ // スプレッドシート自体の取得 var ss = SpreadsheetApp.getActiveSpreadsheet(); var postedSheetName = 'posted_' + date.getYear() + toMonth; if (ss.getSheetByName(postedSheetName) === null) { ss.insertSheet(postedSheetName) } // 投稿済みのシートを作成・取得して、対象の列の1行目に今日の日付を入力 var postedSheet = ss.getSheetByName(postedSheetName); var headerCell = postedSheet.getRange(toDay, 1); headerCell.setValue(toMonth + '/' + toDay); var postedRow = postedSheet.getRange(toDay, 1, 1, postedSheet.getMaxColumns()); var rowEnd = getRowEnd(toDay, postedSheet); // ※このgetRowEndは独自関数です。 // リストに書かれているfeedのURLから記事を取得 var posts = fetchTodayPosts('https://qiita.com/advent-calendar/2017/javascript/feed'); // 今年のjavascriptのfeed // 取得した記事データを元に投稿済みか検査 + 投稿 posts.forEach(function(item) { var postedRange = postedSheet.getRange(toDay, 1, 1, rowEnd); var isPosted = false; for (var i = 1; i <= rowEnd; i++) { if (item.join('\n') === postedRange.getCell(1, i).getValue()) { isPosted = true; break; } } // まだ投稿されていない記事投稿されていなければ、セルに書きこみ + Typetalkへ投稿 if(!isPosted) { rowEnd += 1; postedRow.getCell(1, (rowEnd)).setValue(item.join('\n')); typetalkClient.post(item.join('\n')); } }); }
これで同じ記事が投稿されることなく、チャット上に投稿が流れるようになりました。
一つだけ注意点
ただ、これだけど、GAS上のトリガーの間隔が広い場合、一部読まれなくなってしまうので、 トリガーの間隔を短くするなどして、調整してみてください。 または日付ごとに登録せず、全て一意にしてしまうという方法もあります。 今回は一週間ごとに振り返り安いものを作成したかったので、こういった仕様となっております。
最終的なソースコード
function rssBot() { 'use strict'; var TOKEN = 'XXXXX'; var TOPIC_ID = 'NNNNN'; var typetalkClient = ttClient(TOKEN, TOPIC_ID); // 日付の変数を取得 現在の月と今日の日付 var date = new Date(); var toMonth = (date.getMonth() + 1); var toDay = date.getDate(); /* * スプレッドシートの取得 * RSSの一覧が入ったシート、投稿された記録を残すシートを取得する ------------------------------------- */ // スプレッドシート自体の取得 var ss = SpreadsheetApp.getActiveSpreadsheet(); // RSSのURLの一覧を記載したシートを取得 var rssListSheet = ss.getSheetByName('RSS_URL'); // 投稿済み記録シート var postedSheetName = 'posted_' + date.getYear() + toMonth; if (ss.getSheetByName(postedSheetName) === null) { ss.insertSheet(postedSheetName) } var postedSheet = ss.getSheetByName(postedSheetName); var headerCell = postedSheet.getRange(toDay, 1); headerCell.setValue(toMonth + '/' + toDay); // リストに書かれているfeedのURLから記事を取得 var lastRow = rssListSheet.getLastRow(); var range = rssListSheet.getRange('A2:A' + lastRow); var posts = []; for(var i = 1; i < lastRow; i++) { var rssUrl = range.getCell(i, 1).getValue(); posts = posts.concat(fetchTodayPosts(rssUrl)); // 記事の取得だけをまとめる } var postedRow = postedSheet.getRange(toDay, 1, 1, postedSheet.getMaxColumns()); var rowEnd = getRowEnd(toDay, postedSheet); // 取得した記事データを元に投稿済みか検査 + 投稿 posts.forEach(function(item) { var postedRange = postedSheet.getRange(toDay, 1, 1, rowEnd); var isPosted = false; for (var i = 1; i <= rowEnd; i++) { if (item.join('\n') === postedRange.getCell(1, i).getValue()) { isPosted = true; break; } } // まだ投稿されていない記事投稿されていなければ、セルに書きこみ + Typetalkへ投稿 if(!isPosted) { rowEnd += 1; postedRow.getCell(1, (rowEnd)).setValue(item.join('\n')); typetalkClient.post(item.join('\n')); } }); } /* * 一部機能をまとめた関数を定義 ------------------------------------- */ /** * 特定の列の最終行を取得する * @param {number} row * @param sheet * @return {*} */ function getRowEnd(row, sheet) { sheet = sheet || SpreadsheetApp.getActiveSpreadsheet(); var lastColumn = sheet.getLastColumn(); var range = sheet.getRange(row , 1, 1, lastColumn); while (lastColumn) { var targetCell = range.getCell(1, lastColumn); if(targetCell.getValue() !== '') { // return lastColumn; break; } lastColumn--; } return lastColumn; } /** * feedURLから記事情報を取得 * @param {String} feedUrl * @return {Array.<*>} */ function fetchTodayPosts(feedUrl) { var response = UrlFetchApp.fetch(feedUrl); var rssXML = response.getContentText(); var document = XmlService.parse(rssXML); var root = document.getRootElement(); var ns_rss = XmlService.getNamespace('http://purl.org/rss/1.0/'); var ns_dc = XmlService.getNamespace('dc', 'http://purl.org/dc/elements/1.1/'); var ns_atom = XmlService.getNamespace('http://www.w3.org/2005/Atom'); var ns_rdf = XmlService.getNamespace('http://www.w3.org/1999/02/22-rdf-syntax-ns#'); var rootTagName = root.getName().toLowerCase(); var entries = []; // 記事のリストを取得 switch (rootTagName) { case 'rdf': // 1.0 entries = root.getChildren('item', ns_rss); break; case 'feed': // atom entries = root.getChildren('entry', ns_atom); break; case 'rss': // 2.0 entries = root.getChild('channel').getChildren('item'); break; default: return false; } return entries.map(function(entry, index) { var title; var link; var pubDate; switch (rootTagName) { case 'rdf': // 1.0 title = entry.getChild('title', ns_rss).getText(); link = entry.getChild('link', ns_rss).getText(); pubDate = entry.getChild('date', ns_dc).getText(); break; case 'feed': // atom title = entry.getChild('title', ns_atom).getText(); link = entry.getChild('link', ns_atom).getAttribute('href').getValue(); // pubDate = entry.getChild('published', ns_atom).getText(); // 普段はこっちの方を利用する pubDate = entry.getChild('updated', ns_atom).getText(); // Qiita用にupdated break; case 'rss': // 2.0 title = entry.getChild('title').getText(); link = entry.getChild('link').getText(); pubDate = entry.getChild('pubDate').getText(); break; } // 同一日時なら返却 if (dateFormat(new Date()) === dateFormat(new Date(pubDate))) { return [title,link]; } return ''; }).filter(function(item) { return item !== ''; }); } /** * 文字列をフォーマットする簡易的な関数 * @param date * @return {string} */ function dateFormat(date) { var myDate = date; if(!(date instanceof Date)) { myDate = new Date(date); } return myDate.getFullYear() + '-' + (myDate.getMonth() + 1) + '-' + myDate.getDate(); } function ttClient(token, topicId) { var optionDefault = { method : 'post', contentType: 'application/x-www-form-urlencoded' }; return { token : token, topicId: topicId, post : function(message, options) { options = options || optionDefault; if (!options.payload) { options.payload = {}; } options.payload.message = message; var url = 'https://typetalk.com' + '/api/v1/topics/' + this.topicId + '?typetalkToken=' + this.token; var res = UrlFetchApp.fetch(url, options); } } }
終わりに
定期的に情報が来るものは、自分の手に取りにいかず、自動で収集して共有してくれると便利ですね。
特にコミュニティや、チーム間ではこういった情報の共有がコミュニケーションにつながることもありますので、ぜひこういったものを活用していきたいですね。