チュートリアル bot chat Google Apps Script javascript typetalk

TypetalkとGoogle App Scriptを組み合わせて、RSSのBOTを作ってみる。

前々回のTypetalk APIとGoogle App Script(以降GAS)組み合わせた第二弾です。 前回はこちらから。

12月になりましたが、一年を振り返るにはなんだかまだ早いような気がしております。 12月にはクリスマスがありますね。そんなクリスマスまでの期間を数えるアドベントカレンダーというのがありますが、 エンジニアブログなどの界隈では、アドベントカレンダーになぞって?テーマに沿ってブログを書いていくイベントというのがあります。

自分は毎年、これを毎日見ているのですが、 どうせなら、更新されたチャット上に流れたら、わざわざ自分から見に行かなくても済むのになーと思い、少し作ってみました。 このアドベントカレンダーは各テーマごとにRSSが配信されているので、RSSを利用します。 また、今回のアドベントカレンダーに問わず、RSS1.0,2.0,Atomで利用できるように配慮して作成してみました。

すべてのソースは最後にまとめて記載いたしますので、 説明時のソースコードで、自作の関数などが入っていて、わかりづらくなっておりますが、 予めご了承くださいませ。

大まかな処理の流れ

  1. RSSのURLから記事のタイトルとURLを取得する
  2. その日にすでに投稿された記事かを判定して、投稿されていなければ、投稿・記録する。
  3. どの日にどんな記事が投稿されたか振り返れるようにしたい。   
    (チャットは流れていくので、一週間でどんなのがあったのか振り返りやすくしたい)
    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はグーグルのスプレッドシートに記載しておきます。

caputurelist

この記載した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'));
		}
	});
}

capute_ss

caputre_toukou

これで同じ記事が投稿されることなく、チャット上に投稿が流れるようになりました。

一つだけ注意点

ただ、これだけど、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);
		}
	}
}

終わりに

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