TitaniumMobile勉強記

Titanium Mobileもだけど、関連してJavaScript、サーバ側の開発(Ruby中心)、BDD的な話題とかについても振れていきたい本業は転職コンサルタントな非プログラマー

Google Readerライブラリ作成中

タイトルそのままですが、Google Readerの情報を簡単に取得できるようなライブラリを勉強をかねて作成してます。

出来栄えとしてはまだまだですが、こんな感じでGoogleReaderに登録してあるフィードの一覧がJSON形式で出力できる所まではわかってきたのですが、一番やりたいのはスターを付けたエントリの一覧を手軽に取得したいことなんですよね・・

//test_login.js
var sys = require('sys');
var GoogleReaderClient = require(__dirname +'/../GoogleReaderClient').GoogleReaderClient;

var googlereader = new GoogleReaderClient('xxxxx@gmail.com', 'YOURPASSWORD');

googlereader._login(function(result){
  if(result){
    console.log('login success!');
    googlereader.subscriptionList(function(result) {
      console.log(result);
    });
  }else{
    console.log('login fail');
  }
});

とりあえず現時点でライブラリ(GoogleReaderClient.js)

//GoogleReaderClient.js
var url = require('url'),
    querystring = require('querystring'),
    http = require('http');

var GoogleReaderClient = function(email, password){
  this._authToken = '';
  this.config = {
    email          : email,
    password       : password,
    host           : "www.google.com",
    https_port     : '443',
    number_of_items: "n",
    output         : "json",
    login_param :{
	accountType: 'HOSTED_OR_GOOGLE',
	Email : email,
	Passwd : password,
	'Content-Type': "application/x-www-form-urlencoded",
	service        : "reader"
    }
  };

};
GoogleReaderClient.prototype = {
  _parseChunks: function(chunks) {
    return chunks.join('');
  },

  _setHeaders:function(auth){
    var param ={};
    var _values = "GoogleLogin auth=" + auth;
    param["Authorization"]= _values;
    return param;
  },

  _doRequest:function(request_param, callback){
    var scope = this;
    // https or httpでcreateClientの最後のフラグのセットをする必要があるみたい
    if(request_param.port == '443'){
      var _flag = true;
    } else {
      var _flag = false;
    }

    var client = http.createClient(
	request_param.port,
	this.config.host,
	_flag
    );

    var request = client.request(
      request_param.method,
      request_param.pathname,
      request_param.httpheader

    );

    request.on('response',
      function(response) {
        var data = [];
        response.on('data',
	  function(chunk) {
            data.push(chunk);
          });
        response.on('end',
	function() {
	  console.log('STATUS: ' + response.statusCode);

          var urldata = scope._parseChunks(data);
          var result = JSON.parse(urldata);
	  console.log(result);
          return callback(result);
        });
      });
    request.end();
  },
  _login:function(callback){
    var self = this;
    var body = querystring.stringify(this.config.login_param);
    var client = http.createClient(
	this.config.https_port,
	this.config.host,
	true
    );

    var req = client.request(
      'POST', '/accounts/ClientLogin',
      {
         'Content-Type': "application/x-www-form-urlencoded"
      }
    );
   req.write(body);
   req.on('response', function(res){
      var body = '';
      res.on('data', function(chunk){
         body += chunk;
      });

      res.on('end', function(){
         var obj = {};
         var lines = body.split('\n');
         for(var i in lines){
            var l  = lines[i];
            var kv = l.split('=');
            if( kv.length == 2 ){
               obj[kv[0]] = kv[1];
            }
         }
	 // obj.SID or obj.LSID or obj.Authで其々のプロパティ取得可能
	 if(res.statusCode == 200){
	   this._authToken = obj.Auth;
	   self._authType = 'GoogleLogin';
	   self._setAuthKey(obj.Auth);
	 } else {
	   this._authToken = undefined;
	 }
	 callback && callback(res.statusCode == 200, obj);
      });
   });
   req.end();
  },
  _setAuthKey:function(value){
    return this._authToken = value;
  },
  _verifyAuthKey:function(){
    return this._authToken;
  },
  starred:function(callback){
    var self = this;
    var query = {
      port: 80,
      method: 'GET',
      pathname:'/reader/atom/user/-/state/com.google/starred',
      httpheader:self._setHeaders(self._authToken)
    };

    this._doRequest(query, callback);
  },
  subscriptionList:function(callback){
    var self = this;
    var query = {
      port: 80,
      method: 'GET',
      pathname:'/reader/api/0/subscription/list?output=json',
      httpheader:self._setHeaders(self._authToken)
    };
    this._doRequest(query, callback);
  }
};

exports.GoogleReaderClient = GoogleReaderClient;

Google ReaderもOAuth認証使えそうなので方針変更

nodejsでGoogle Readerにアクセスするサンプル書いたけど、折角なのでこれをベースにしてnode.js or Titanium活用してiPhoneアプリを作ってみようか考えたのですが、コードの見通しが悪く、後々大変になりそうなのでこの数日手直していました。

具体的には、The bit.ly library for node.jsの書き方が自分の中では理解しやすいコードだったので、この考え方を参考に以下のようなものをつくろうとしていました。

  • publicな感じのメソッドと、private的なメソッドが名前から判断できるように名前付を意識する。
  • 各種フィードにアクセスするのに必要なパラメータの設定をするためだけのメソッドをそれぞれ準備
  • method(GET or POST)やport(http or https) と上記のパラメータの設定するメソッドを引数にして、リクエストを投げる汎用的なメソッドを作る(The bit.ly library for node.jsでいう所の_doRequest()メソッド)

ところが、_doRequest()メソッドを汎用的に使い回すように書き換えること自分のスキルが足りずできませんでした。。。

ちょっと壁にぶち当たった所で、ふと

「そういえば、GDataはOAuth認証使えるんだったよなぁ。だとすると、GoogleReaderも使えるのでは?」

と考えました。

しっかりと読んでいないのですが、以下のような情報があったのできっと出来るような気がします。

node.js for OAuthなライブラリというのはgithubで探せば参考になりそうなものがきっと見つかるだろうから、明日以降もうちょっと調べて書き直していこうかと思います

node.jsからGoogleReaderにアクセス出来ないか試してみた

GoogleReaderのAPIは公開されているわけではないのですが以前にも何度かお世話になったGoogle Reader APIの叩き方のエントリを読みながら、node.jsからGoogleReaderにアクセス出来ないか試行錯誤しています。

GoogleReaderAPIは以前よりも簡易になった?

非公式GoogleReaderAPIドキュメントの下の方のコメントに

Fixed the auth header function, had to use the auth key and not the previously used SID, also had to add the service key into the request:

ということで、以前だったらhttp ヘッダーにSIDとかトークンというものをセットしていました。

以前試したことのあるRubyのgooglebaseというgemのソースを見る限りたしかにSIDセットしているようですがためしにcurl使って

curl -k https://www.google.com/accounts/ClientLogin -d Email=xxxx@gmail.com -d Passwd=xxxxx -d service=reader

とやると

SID=XXXX(長いので省略)
LSID=XXXX(長いので省略)
Auth=XXXX(長いので省略)

みたいなレスポンスが得られるので、このAuth=の所の文字列をhttpヘッダーに

Authorization:GoogleLogin auth=XXXX(長いので省略)

セットすればOKのようです。

自分が理解したのを絵にするとこんな感じですかね
googlereader

http client的なものの準備

自分で全部作るほどのレベルではないので、GData にだってアクセスできるんだぜが参考になりそうなので、こちらをお手本にすることにしました。

とりあえず動くものは出来たので、近いうちにソース晒します

ソースはこんな感じ

/**
 * Google Account Login Client
 */

var Client = function(params){
  this.HOST = "www.google.com";
  this.http = require('http');
  this.querystring = require('querystring');
  this._httpsClient = this.http.createClient(443, this.HOST, true);
  this._httpClient = this.http.createClient(80, this.HOST, false);

};

Client.prototype= {
  login : function(email, password, service){
   if( service == undefined ){
      throw 'service parameter must be specified.';
   }
   var EventEmitter = require("events").EventEmitter;
   var event = new EventEmitter();
   var self = this;
   var params = {
      'accountType': 'HOSTED_OR_GOOGLE',
      'Email' : email,
      'Passwd' : password,
      'service' : service
   };
   var body = this.querystring.stringify(params);
   var req = this._httpsClient.request(
      'POST', '/accounts/ClientLogin',
      {
         'Content-Type': "application/x-www-form-urlencoded"
      }
   );
   req.write(body);
   req.on('response', function(res){
      var body = '';
      res.on('data', function(chunk){
         body += chunk;
      });

      res.on('end', function(){
         var obj = {};
         var lines = body.split('\n');
         for(var i in lines){
            var l  = lines[i];
            var kv = l.split('=');
            if( kv.length == 2 ){
               obj[kv[0]] = kv[1];
            }
         }
         if( res.statusCode == 200 ){
            event.emit('success');
	    //get Auth value
	    self.getFeed(self.setHeaders(self.extractAuth(body)));
         }else{
            self._authToken = undefined;
            event.emit('failure');
         }
      });
   });
   req.end();
   return event;
  },//login

  extractAuth:function(body){
    var matches = body.match(/Auth=(.*)/);
    var _result = matches[0].split('Auth=');
    return _result[1];
  },
  setHeaders:function(auth){
    var param ={};
    var _values = "GoogleLogin auth=" + auth;
    param["Authorization"]= _values;

    return param;
  },
  getFeed:function(param){
    console.log('start');
    console.log(param);
    var req = this._httpClient.request(
      'GET', '/reader/api/0/unread-count?all=true',param
    );

    req.end();

    req.on('response', function (response) {
      console.log('wait response');
      console.log('STATUS: ' + response.statusCode);

      console.log('HEADERS: ' + JSON.stringify(response.headers));

      response.setEncoding('utf8');
      response.on('data', function (chunk) {
	console.log('BODY: ' + chunk);
      });
    });
  }
};

var Google = new Client();
Google.login('yourgmailaddress,'password','reader');

AppleScript 使って特定のフォルダ配下の写真をまとめてインポートする

自分のブログのPVはそんなに対した数ではないのですがiPhoto写真を一括でEvernoteに取り込めそうだけど・・・のページが「Evernote」「一括」「写真」「インポート」というキーワードで検索エンジン経由でアクセスされることが多いのと、つい最近Mac版のEvernoteクライアントのAppleScript機能がかなり拡張されたので、指定フォルダ以下のPICTファイルをGraphicConverterでJPEGに変換AppleScriptのサンプルを頼りに、特定のフォルダ配下の写真をまとめてインポートするスクリプト書いてみました。


※2010年9月13日追記:AppleScriptは自分自身あんまり詳しくないのと、情報も少ないので一通りの流れをまとめました

自分が想定してる対象ユーザ

  • Macを使っていて、基本的にはiPhotoで写真管理しているような人
  • 上記のような人でiPhotoのお気に入りの写真をEvernoteにも取り込んでおきたい人

ちなみに自分の所のMacの環境とEvernoteのバージョンは下記の通りです
Mac OSX:10.5.8
Evernote:version 1.11.0

使い方

1.取り込みたい写真を適当な場所(例:デスクトップ上)にフォルダを作成して1つにまとめておきます。※iPhoto使って写真管理している人はiPhotoの写真書き出し機能などをつかってまとめておきます

2.AppleScriptを実行するためのスクリプトエディタを起動します。場所は
アプリケーション→AppleScript→スクリプトエディタ
applescript_editor

3.スクリプトエディタを起動したら、この下に書いたソースコードをコピペして実行ボタンを押します
import_picture_to_evernote

set aFol to choose folder
set fList to {}
set f_r to a reference to fList
tell application "Finder"
	set fList to entire contents of aFol as alias list
end tell
repeat with i in f_r
	set j to contents of i
	set aInfo to info for j
	set aF to folder of aInfo
	try
		set aInfo to info for j
		set aF to folder of aInfo
	on error
		set aF to false
	end try
	importJPEG(j) of me
end repeat

on importJPEG(aFile)
	tell application "Evernote"
		create note title "import picture" with text "" attachments aFile
	end tell
end importJPEG

4.実行するとフォルダ名の選択をするような画面になるので、写真があるフォルダを指定します。

取り込む写真の枚数&容量x使っているMacのマシンスペックによって取り込む時間はかなり変わりますが1分程度で作業完成すると思います。

ADOの処理を自分好みにしてみた

データ抽出、加工のために個人的に作ったツールがあるんだけど、WSH(JScript)+ADOを使ってDB(Oracle)接続する処理をもっと使い勝手をよくしたいと思って長年勉強していたら、こんな感じのソースが書けるようになってきました。(配列やハッシュの扱いをRubyっぽく書けるようにprorotype.jsの力を借りています。WSHprototype.jsを使うには以前書いたWSH上でPrototype.jsを使って楽しようというエントリを参考にしてください)

//DBO.js
var DBO = Class.create({
  initialize:function(sql){
    this.sql = sql;
    this.objADO="";
    var config = new Config();
    this.udl_filepath = config.udl_filepath;
    this.objRS ="";
    this.connect();
    this.items = this.getItems();
  },
  connect:function(){
    var strConnection = 'File Name=' + this.udl_filepath;
    this.objADO = new ActiveXObject('ADODB.Connection');
    this.objADO.Open(strConnection);
    this.objRS = this.objADO.Execute(this.sql);
    return this.objRS;
  },
  close:function(){
  return this.objADO.Close();
  },
  setProperty:function(_obj){
    for(var i=0; i < this.objRS.Fields.Count;i++){
      var _property = this.objRS.Fields(i).name;
    _obj[_property.toLowerCase()] = this.objRS.Fields(i).value;
    }
  },
  getItems:function(){
    var items= [];
    var i = 0;
    while(!(this.objRS.EOF)) {
      var item = {};
      for(var j=0; j < this.objRS.Fields.Count;j++){
        /*
        SQL文のSELECT句に指定されているフィールド名を 
        このクラスのプロパティとして自動的に指定
        */
        var _property = this.objRS.Fields(j).name;
        item[_property.toLowerCase()]= this.objRS.Fields(j).value;
      }
    items[i] = item;
    i++;
    this.objRS.MoveNext();
    }
    return items;
  }
});
var Config = function(){
  this.base_filepath = 'C:\\home\\xxxxxx\\';
  this.sql_filepath = this.base_filepath + 'sql\\';
  this.text_filepath = this.base_filepath + 'text\\';
  this.img_filepath = this.base_filepath + 'img\\';
  this.cache_filepath = this.base_filepath + 'cache\\'; 
  this.page_filepath = this.base_filepath + 'page\\';
  this.log_filepath = this.base_filepath + 'log\\';
  this.udl_filepath = 'C:\\udl\\index.udl';
};

例えば、従業員情報を管理しているemployeeというテーブルがあった場合にはこんな感じのmodel らしきものを作っています

//Employee.js
var Employee = Class.create({
  initialize:function(){
    var _sql = "SELECT first_name, last_name, department_cd FROM employee where cd ='001'";
    var objDBO = new DBO(_sql);
    objDBO.setProperty(this);
  },
  getName:function(){
    return this.first_name + this.last_name;
  },
  getDepartmentName:function(){
    //
  }
  
});

あとはこんな感じで出来上がり

//main.wsf
<job>
<script language="javascript" src="dummy.js" />
<script language="javascript" src="prototype.js" />
<script language="javascript" src="DBO.js" />
<script language="javascript" src="Employee.js" />
<script language="javascript">
  //実際の処理
  var E = new Employee();
  WScript.Echo(E.getName());
</script>
</job>

この記事を読むのに必要な時間の目安を測る

たった一行追加するだけでサイトの滞在時間を13.8%伸ばす方法・・・で紹介されていて、それを見て、WordPress: 『読むための所要時間』を表示するコード書いた人がいますね。

元ネタの英語の記事

My hope is that I can influence users to interrupt article abandonment with the thought “well I know it’s only going to be 1 more minute to finish reading this thing, so I’ll just finish.”

とありますが、その作業(ブログを読む)に費やす時間がある程度わかっていれば、人間たしかにその作業を最後までやってみようと考える人はいるでしょうね。

はてダで、何らかの言語で独自に機能拡張出来る仕組みがあればいいんだろうけど、そういうのが無いから、とりあえずエントリ予定の記事の文字数カウントして読むのに必要な時間をおおまかに見積もるスクリプトをRubyで書いて会社の方で書いてるブログで早速試してみたにエントリする記事が何文字あるのかスクリプトでカウントしてそれをエントリ本文にコピペしました。

require 'rubygems'
require 'nokogiri'

blogentry = <<DATA
ここに文字を入力。
<h1>タイトル</h1>
となっていても、当然HTMLのタグは無視してカウントする
DATA

READ_WORD_PER_MINUTES = 400
total_text = Nokogiri::HTML(blogentry).text.split(//u).size
esimate_miniutes = (total_text/READ_WORD_PER_MINUTES).to_s + ""
esimate_seconds  = (total_text % READ_WORD_PER_MINUTES / (READ_WORD_PER_MINUTES / 60)).to_s + ""
puts esimate_miniutes + esimate_seconds

プライベートでは、このはてダを使っていて、それ以外に会社の方でもブログ書いているから、下書きというかネタとなる情報をEvernote上で管理しており、そこから気に入ったものをコピペしてアップしているんだけど

Evernote起動→気になる記事を選択→(スクリプトでゴニョニョ)→記事のアップ完成

みたいなワークフロー出来ないかなーと、このエントリ書いている途中にふと妄想してみた。

iPhotoの写真→Evernoteスクリプトを修正した

iPhotoの複数の写真を(ほぼ自動で)まとめてEvernoteに取り込むスクリプト完成のPVは、絶対数こそ少ないけど、毎日一定数あるのを見ていると、画像データを一括でまとめてEvernoteに登録したいというニーズはあるというのを知って少し自分の中のモチベーションが上がったのとこの後のことを考えて少し修正しました。

修正しようと思った背景(スクリプトの中身が気になる人はこの部分読み飛ばしてください)

そんなスクリプトを自分で書いといてなんだけど、あのスクリプト処理があんまり綺麗じゃなくって気持ち悪いんですよね・・

気持ち悪いと感じているのは

  • iPhotoからアルバム情報をHTMLとして生成しているけど、アルバム情報を書き出すときのオプション指定によって、

HTMLファイル内に記載されている写真の撮影日の位置がかわる

  • Nokogiri使った処理しているけど、これ使わなくっても良いやり方ありそう

というあたり。
できる事ならば、Mac OS X10.5 以上で、追加のgemなんかを、可能な限り利用しないような形で実行できたほうが、実際に試す人にとっては敷居がさがりそうかなと思っています。

あとは折角なので、Windows版のスクリプト(WSH+JScript)も書きたいなーと思っていて、その場合にNokogiriのようなパーサー使った処理をしているものをWSH+JScript向けに移植する(*)となると、考えただけでも嫌になるからなるべくシンプルな形の処理にしておいたほうが後々良いかなと思っていたりします。

修正したスクリプト

気持ち悪い状態を少しでも解消するために、こんな形で書き換えました。erbで利用するテンプレートはiPhotoの複数の写真を(ほぼ自動で)まとめてEvernoteに取り込むスクリプト完成で書いたものから全く変更ありません。

## iphoto2evernote.rb
require 'rubygems'require 'base64'
require 'digest/md5'
require 'erb'
require 'extexif'


class Images2Enex
  def initialize()
    @exif ={};
    if ARGV[0].nil?
      exit!
    else
      @base_directory = ARGV[0]
    end
  end
  attr_accessor :evernote_created_date, :evernote_note_title,:evernote_hash, :imgdata


  def convert
    Dir::glob(@base_directory + "*.jpg").each{|f|
      _exif = ExtractExit(f)
      # JPEGのファイル名をEvernoteのノートタイトルに活用しつつ
      # enexファイルの書き出すときのファイル名にも活用
      jpeg_filename = File.basename(f).split(".")
      output_filename = jpeg_filename[0]


      @imgdata = base64_encode(f)
      @evernote_note_title = output_filename
      @evernote_hash = make_hash(f)
      @evernote_created_date = convert_date_format(_exif["Date"])
      template = File.open("evernote_enex01.tmpl") { |tmpl| tmpl.read }
      @result = ERB.new(template, nil, '-')
      begin
        make_enex(output_filename)
      rescue OpenURI::HTTPError => e
        e.io.close
      end
    }
  end
 
  private
  # Evernote指定の日付フォーマットであるyyyymmddThhmmssZに変換
  def convert_date_format(str)
    separete_str = str.split(" ")
    each_date_element    = separete_str[0].split(":")
    each_minutes_element = separete_str[1].split(":")
    return each_date_element[0] + each_date_element[1] + each_date_element[2]  + "T" + each_minutes_element[0] + each_minutes_element[1] + each_minutes_element[2] + "Z"
  end
 
  def make_hash(file)
    return Digest::MD5.hexdigest(File.open(file,'rb').read)
  end


  def make_enex(file)
    f = File.open(@base_directory + file + ".enex","w")
    f.puts @result.result(binding)
    f.close
  end


  def base64_encode(jpeg)
    return Base64.encode64 File.open(jpeg).read
  end


  def ExtractExit(file)
    image = ExtExif.new(file)
    @exif = {
      "Maker"    => image["Make"],
      "Width"    => image["PixelXDimension"],
      "Height"   => image["PixelYDimension"],
      "Aperture" => image["ApertureValue"],
      "Date"     => image["DateTimeOriginal"],
      "FNum"     => image["FNumber"]
    }
    return @exif
  end
 
end

i2e = Images2Enex.new()
i2e.convert()

使い方

  1. iPhotoで管理している写真を、適当なフォルダ(仮にデスクトップ上の”photo”というフォルダ)に書き出す
  2. ターミナル上でこんな感じで実行すると、写真を書き出したフォルダ内に、順番にenexファイルが生成されるのであとはこれをEvernoteにインポートすればOK
ruby ./iphoto2evernote.rb ~/Desktop/photo/

今回修正する中で、気づいたのは、Dir::globを使った処理をしているわけなので、ディレクトリ配下のファイル名取得なんかも本来簡単に出来ると思っていたけどそのあたりも気になっていたのでRuby でファイル一覧の取得というサイトを参考にしてそこもスッキリしました。

(*)JavaScriptでBase64エンコードする。 - ブックマクロ開発にというエントリを最近見つけたから、これを参考にすればBase64エンコード出来そうだしMD5を計算するライブラリなんっていうのもある。
一番やっかいなJPEGのExif情報の読取は、ADODB.Streamオブジェクトを使って処理するしかなさそうでこちらのlzhファイルの中にあったVBScriptファイルが参考になりそうだけど、読み取ったバイナリデータの処理大変そう・・とか思っていたらJavaScript だけで EXIF を読むというエントリでEXIF のタグ定義テーブルを取得っていう辺りのコメント欄の情報が参考になりそう。