前々回の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);
}
}
}
終わりに
定期的に情報が来るものは、自分の手に取りにいかず、自動で収集して共有してくれると便利ですね。
特にコミュニティや、チーム間ではこういった情報の共有がコミュニケーションにつながることもありますので、ぜひこういったものを活用していきたいですね。





