0%

今更ながらTwitterのデータで楽しく遊ぶ - Part1: Commander.jsとlodashとGmail

今更ながらTwitterのデータを検索して遊んでいます。Twitterのデータはリアルタイム性がありメタデータも豊富に定義されています。世界中の人が勝手につぶやいてくれるので、属性をもつランダムなテキストデータを簡単に取得できます。最初にNode.jsのCommander.jsで簡単なCLIのインタフェースを用意します。REST APIのGET search/tweetsとStreaming APIのPOST statuses/filterをCLIから試しながらどんなデータが取得できるかプロトタイプします。

プロジェクトと使い方

作成したプロジェクトはリポジトリにpushしています。

$ cd ~/node_apps/node-tweet
$ tree
.
├── Dockerfile
├── README.md
├── app.js
├── commands
│   └── search.js
├── config.json
├── config.json.original
├── docker-compose.yml
├── node_modules -> /dist/node_modules
├── package.json
├── test.csv
└── utils
└── gmail.js

カレントディレクトリにシムリンクを作ってからbuildします。

$ ln -s /dist/node_modules .
$ docker-compose build

簡単な使い方

README.mdに例があります。GET search/tweetsはREST APIです。以下のように指定すると、babymetalのキーワードでドイツ語のtweetを10件検索します。検索結果はCSV形式のファイルにして指定したアドレスに添付為てメールします。

$ docker-compose run --rm twitter search "babymetal -filter:retweets" -- -l de -C -e ma6ato@gmail.com

POST statuses/filterはStreaming APIです。以下のように指定すると、androidのキーワードで英語のtweetをストリームで取得して標準出力します。

$ docker-compose run --rm twitter track android -- -l en

app.js

app.jsがエントリポイントです。Commander.jsを使いCLIのプログラムを構成します。オプションはGET search/tweetsの仕様に加えて、CSVファイル作成と検索結果をGmailで送信する機能のフラグをつけました。

~/node_apps/node-tweet/app.js
'use strict';

var program = require('commander')
, search = require('./commands/search.js');

program
.version('0.0.1')

program
.command('search <query>')
.option('-c, --count [count]','number of tweets 1-100')
.option('-r, --result_type [type]','result type')
.option('-l, --lang [ISO 639-1]','language of tweet')
.option('-L, --locale [ja]','language of query')
.option('-e, --email <to>','send email')
.option('-C, --csv','output csv format')
.action(search.commandQuery);

program
.command('track <track>')
.option('-l, --language [language]','BCP47','language of tweet [ja]','ja')
.action(search.commandTrack);

program.parse(process.argv);

if (process.argv.length < 4) {
console.log('You must specify a command');
program.help();
} else if (['search','track'].indexOf(process.argv[2]) < 0){
console.log(process.argv[2] + 'is not a command');
program.help();
}

exports.program = program;

search.js

search.jsにコマンドを実装します。特に難しいことはしないでtwitのライブラリを実行しています。ここでの目的はTweetデータの中身から使えそうなフィールドを抽出することと、lodashの関数合成や他の関数を試してみることです。

~/node_apps/node-tweet/commands/search.js
'use strict';

var Twit = require('twit'),
config = require('../config.json'),
util = require('util'),
_ = require('lodash'),
moment = require('moment-timezone'),
json2csv = require('nice-json2csv'),
mailer = require('../utils/gmail');

function connect() {
return new Twit({
consumer_key: config.twitter_key,
consumer_secret: config.twitter_secret,
access_token: config.twitter_token,
access_token_secret: config.twitter_token_secret
});
}

function prettyJson(data){
return JSON.stringify(data,null,2);
}

function formatDate(created_date){
return moment(new Date(created_date)).tz("Asia/Tokyo").format();
}

function createUrl(screen_name,id_str){
return util.format('https://twitter.com/%s/status/%s',
screen_name,id_str);
}

function pluckPath(data,path,key,suffix) {
return _.pluck(_.get(data,path),key)
.map(function(x) {return suffix+x})
.toString();
}

function extractData(s) {
return {
id: s.id_str,
url: createUrl(s.user.screen_name,s.id_str),
created_at: formatDate(s.created_at),
lang: s.lang,
name: s.user.name,
screen_name: s.user.screen_name,
user_url: s.user.url || '',
text: s.text,
source: s.source,
expanded_url: _.get(s,'entities.urls[0].expanded_url',''),
hashtags: pluckPath(s,'entities.hashtags','text',"#"),
user_mentions: pluckPath(s,'entities.user_mentions','screen_name',"@"),
retweeted_status: s.retweeted_status ? true : false
}
}

function searchPrint(csv,email,metadata) {
return _.compose(email ? _.curry(mailer.sendWithAttachment)(email,metadata) : console.log,
csv ? json2csv.convert : prettyJson,
_.map);
}

function commandTrack (track,options) {
connect()
.stream('statuses/filter', {track: track, language: options.language})
.on('tweet', _.compose(console.log, prettyJson, extractData));
}

function commandQuery (query,options) {
connect()
.get('search/tweets',{ q: util.format('%s lang:%s', query, options.lang),
locale: options.locale,
count: options.count,
result_type: options.result_type},
function(err, data, response) {
if (err) return console.log(err);
searchPrint(options.csv, options.email,
_.pick(data.search_metadata,
['since_id_str', 'max_id_str', 'query']))
(data.statuses, extractData);
});
}

module.exports = {
commandQuery: commandQuery,
commandTrack: commandTrack
}

Commander.jsのoptionは難しい

Commander.jsの引数の扱い方はちょっと癖があるので思った通りの引数がうまく取得できません。CLIからオプションが未指定の場合は、action()に指定したコマンドの引数のoptionsにundefinedで入ります。リクエストを発行する関数に渡すparamsのオブジェクトは動的に作成することになります。Conditionally set a JSON object propertyによるとstringifyでJSONを文字列にするときにundefinedなフィールドは作成しないようです。

twitter.jsでもparamsはstringfyしているのでCommander.jsのoptionsはundefinedなフィールドをそのまま渡しました。

var paramsClone = JSON.parse(JSON.stringify(params))

--result_typeオプションの場合、オプションが1つだけなら<type>と記述するとUNIX的に入力が必要なコマンドになります。

$ docker-compose run --rm twitter search "babymetal -filter:retweets" -- --result_type
error: option `-r, --result_type <type>' argument missing

ただし複数のオプションがあると必須が効かなくなり意図しない動作をします。以下の場合recent_typeには次のオプションの-lが入ってしまします。

$ docker-compose run --rm twitter search "babymetal -filter:retweets" -- --result_type -l ja

また最後の引数にrecentとしてデフォルト値を使う場合、コマンドラインから--recent_typeが未指定でもoptions.result_typeの変数にrecentが入ってしまいます。未指定の場合はundefinedを意図しているのでこれも困ります。

.option('-r, --result_type [type]','result type','recent')

試行錯誤すると以下のように[type]としてデフォルト値を使わないのがよさそうです。

.option('-r, --result_type [type]','result type')

--result_typeを指定しない場合、options.result_typeundefinedになるためparamsをstringifyするときには削除されます。

lodashで関数型言語

Node.jsを関数型言語っぽく書くためにlodashを使っています。ほぼこのプログラムを書いた主な目的になっています。APIで取得したTwitterのリストを加工するときに関数型言語のように扱ってみます。

関数合成ができるできるcomposeのflowRightが便利です。以下のようにon('tweet')のコールバックに_.composeで関数合成した関数を指定できます。on('tweet')はコールバックの引数にTweetのデータを渡します。右から順番にextractDataでJSONから必要なフィールドを抽出して、returnJSON.stringifyに渡し、さらにそのreturnを標準出力に渡します。

function commandTrack (track,options) {
connect()
.stream('statuses/filter', {track: track, language: options.language})
.on('tweet', _.compose(console.log, prettyJson, extractData));
}

gmail.js

このモジュールではNodemailerで添付ファイルをGmailで送信しています。この例ではメールアドレスのバリデーションや、メール本文にTweetのメタデータを追加しています。Nodemailerを使うとGmailの添付ファイル送信がとても簡単に書くことができます。

~/node_apps/node-tweet/utils/gmail.js
'use strict';

var nodemailer = require('nodemailer'),
validator = require('validator'),
config = require('../config.json'),
util = require('util'),
moment = require('moment-timezone');

function createFilename() {
return util.format('twitter-%s.csv',
moment(new Date())
.tz("Asia/Tokyo")
.format('YYYYMMDD-HHmmss'));
}

function formatMetadata(data) {
return ["query:'",decodeURIComponent(data.query.replace(/\+/g,' ')),"'\n",
"since_id: ",data.since_id_str,"\n",
"max_id: ",data.max_id_str,"\n"].join('');
}

function sendWithAttachment(gmail_to,metadata,content) {
if (! validator.isEmail(gmail_to)) {
return console.log('--email is not valide: ', gmail_to);
}

var transporter = nodemailer.createTransport({
service: 'Gmail',
auth: {
user: config.gmail_user,
pass: config.gmail_pass
}
});

var msg = {
from: config.gmail_from,
to: gmail_to,
subject: config.gmail_subject,
text: formatMetadata(metadata),
attachments: [
{filename: createFilename(),
content: content}
]
};

transporter.sendMail(msg, function (err) {
if (err) {
console.log('Sending to ' + msg.to + ' failed: ' + err);
}
console.log('Sent to ' + msg.to);
});
}

module.exports.sendWithAttachment = sendWithAttachment;

まとめ

今回のプロトタイプでTwitterのAPIにどのようなパラメータがあり、Tweetデータからどのようなフィールドを取得して加工すると有用なのか使いながらフィードバックを得ることができます。次はジョブをキューしたり、定期的に処理をスケジュール管理できるようにしてみます。