かみやんの技術者ブログ

主にプログラムの話です

Kinect買ったよ!


やー、すっかりTwitterを使うようになってブログの更新が止まりました。他の人もそういっていたので、そういうもんなんでしょう。と言う訳で、@kamiyan 上で会いましょう〜。

そう。ブログに書かなきゃってのはKinectですよ!もうロボ野郎とか、Make野郎とか、電子工作野郎には必須でしょ!!
KinectのPC用ドライバがオープンソースで出たと聞いて、12月の5日に買ってきましたよ。アキバ、ヨドバシで13,000円ほどだったでしょうか?もちろんXbox360は持っていないし、たぶん買わないけど。
注意事項は、Xbox360+Kinectセットが売っているが、そっちだとPCでつなぐときに必要なACアダプターが入っていないらしいので、Kinectのみの方を買うこと。もうちょっと詳しく説明すると、Kinectは、付属のACアダプターの定格をみると、12V-1.08Aの電源が要る模様。Xbox360+KinectセットのKinectはUSBバスパワーのみで動くらしいが、普通 USBバスパワーは5Vなので、5Vのバスパワーで同じ電力を得ようと思ったら2.5Aぐらい電流が取り出せないといけない。普通PCのUSBポートは、500mAとか1Aぐらいが限度ではないかな(詳しくないけど)。なので、PCでつなぐ場合はAC電源が必要。で、Xbox360+KinectセットのXbox360は新しいXboxらしくバスパワーの電力が大きいのでしょう。そして旧型のXbox360の人は、Kinect単体を買ってね!(単体版は、ACアダプタ付き)という訳だ。

で、買ってきて半日ほどで、ちょっとコードを書きました。そのときのメモ。
<2010/12/5>

って感じです。

まずは、自分をポリゴン化してみよう〜!


上図、最初のバージョン。ポリゴンとポリゴンの隙間が多い。

上図、隙間を減らしたつもり。左手と顔の間がレインボー。

上図、隙間が減った。

上図、Depthをみて壁を除去。

KinectはRGBとDepthの4要素が、640x480ピクセル分来る。そして上記は、1ピクセルを2つの三角形で描画しているので、61万ポリゴン。61万ポリゴンがあっさり動くのは、元3D野郎として感慨深い。久しぶりにOpenGL使ったが、18年前に使ったレベルから知識が変わっていない辺りが悲しい(苦笑)。

Kinectの仕組み

Kinectは、なんで3次元スキャンができているのか。
ステレオビジョンだと2つのカメラで撮影して、特徴点を抽出して、特徴点マッチングをして、視差から三角測量で奥行きを求める。ステレオビジョンが精度が悪い理由の一つとして、特徴点マッチングでマッチしないことがある。ということと、特徴点抽出で、色が単一などの場所は特徴点がなく奥行きが分からないという2つがメイン。
そこで、Kinectでは、赤外線レーザーで特徴のあるパターンを照射。赤外線カメラでパターンを認識。パターン照射の発射位置と赤外線カメラの位置が10cmほど水平にずれているので、この視差(左目が照射、右目がカメラみたいな感じ)で、三角測量で奥行きを求めていると思われる。これにより特徴点マッチングは簡単でミスが少ない。また、カラーのカメラで認識でないので、色が単一でも関係がない。
すばらしいアイデアだ。
あとは、Kinect内にGPU相当のプロセッサが入っているのか、特徴点マッチングと、奥行き計算もハードウェアで計算されているっぽい。

Kinectで夢が膨らむ

やー、Kinectはすごい。つくばチャレンジでも来年はKinect積むロボットが増えるんじゃないでしょうか。なんせ、現在、つくばチャレンジで障害物センサーとして人気なのが北陽電機のレーザーレンジファインダ(LRF) Top-URGとかですが、これは定価が40万。スキャン方向は1次元で、得られるデータは2次元。
いっぽうKinectは、スキャンが2次元で、得られるデータは3次元。定価が15,000円ほど。
Kinectを使えば障害物回避ができそう。
ただし、Top-URGは、距離が30m飛んで、30m先でも誤差10mm以下という驚異の精度なのに対して、Kinectは恐らくセンシング範囲が5mとか10mとか(屋内で遊ぶ用だからね)であろうと思われるし、誤差は、ステレオビジョンと似た仕組みと思われるので遠いところほど誤差がでるのではないかな。ま、でも近く3m以下とかさえ分かれば障害物回避できるので、ロボットの価格がぐっと下げられる。

Kinectをつかったモーションキャプチャとか、Kinectをルンバに積んだとか、Kinectをクアッドコプター(AR Droneみたいなやつ)に積んだとか、どんどんニュースが届きます。
楽しい時代になりました。

KinectApp Source Code

僕がさくっと半日で書いたKinectApp.hとKinectApp.cppを公開します。BSDライセンスで公開します。
プロジェクトファイルは、こちら:http://www.asahi-net.or.jp/~qs7e-kmy/robot/KinectTest20101218.zip

KinectApp.h

#ifndef __KINECT_APP_H__
#define __KINECT_APP_H__



#define DEG2RAD(x)  ((x)*M_PI/180.0)

class KinectApp: public Kinect::KinectListener
{
private:
	static const int KINECT_W=Kinect::KINECT_DEPTH_WIDTH;
	static const int KINECT_H=Kinect::KINECT_DEPTH_HEIGHT;
	static const char FLAG_POINT=(1<<0);//点あり
	static const char FLAG_RIGHT=(1<<1);//右エッジあり
	static const char FLAG_DOWN=(1<<2);//下エッジあり
	Kinect::KinectFinder* mKinectFinder;//KinectFinder
	Kinect::Kinect* mKinect;//Kinect
	float mYaw;//カメラヨー角(degree)
	float mPitch;//カメラピッチ角(degree)
	float mDistance;//カメラ距離
	unsigned short mDepthBuffer[KINECT_W * KINECT_H];//depth buffer
	unsigned char mColorBuffer[Kinect::KINECT_COLOR_WIDTH * Kinect::KINECT_COLOR_HEIGHT * 3];//color buffer
	float mFar;//カットする後ろの距離
	float mDepthFact;//奥行き方向のスケール

	//コンストラクタ
	KinectApp(){
		mKinectFinder=NULL;
		mKinect=NULL;
		mYaw=0.0f;
		mPitch=0.0f;
		mDistance=400.0f;
		memset(mDepthBuffer,0,sizeof(mDepthBuffer));
		memset(mColorBuffer,0,sizeof(mColorBuffer));
		mDepthFact=600.f;
		mFar=mDepthFact/4.0f;
	}
public:
	//インスタンス取得(シングルトン)
	static KinectApp* GetApp();

	//デストラクタ
	~KinectApp(){
		if(mKinect!=NULL){
			mKinect->SetLedMode(Kinect::Led_Off);
			mKinect->RemoveListener(this);
			//delete mKinect;//mKinectはKinectFinderが削除するのでアプリは削除しない
		}
		mKinect=NULL;
		if(mKinectFinder!=NULL){
			delete mKinectFinder;
		}
	}

	//Kinectの初期化
	bool InitKinect(){
		mKinectFinder=new Kinect::KinectFinder();
		if(mKinectFinder==NULL){
			printf("error init KinectFinder...\n");
			return false;
		}

		if (mKinectFinder->GetKinectCount() < 1)
		{
			printf("Unable to find Kinect devices... Is one connected?\n");
			return false;
		}

		mKinect = mKinectFinder->GetKinect();
		if (mKinect == NULL) {
			printf("error getting Kinect...\n");
			return false;
		}
	
		// register the listener with the kinect. Make sure you remove the 
		// listener before deleting the instance! A good place to unregister 
		// would be your listener destructor.
		mKinect->AddListener(this);

		// SetMotorPosition accepts 0 to 1 range
		mKinect->SetMotorPosition(1);
	
		// Led mode ranges from 0 to 7, see the header for possible values
		mKinect->SetLedMode(Kinect::Led_Yellow);
	
		// Grab 10 accelerometer values from the kinect
		float x,y,z;
		for (int i =0 ;i<10;i++){
			if (mKinect->GetAcceleroData(&x,&y,&z))
			{
				printf("accelerometer reports: %f,%f,%f\n", x,y,z);
			}
			Sleep(5);
		}
		return true;//success
	}

	//glut 描画イベント
	static void glutOnDraw();
	//glut キーイベント
	static void glutOnKeyPress(unsigned char key, int x, int y);

	//OpenGLの初期化
	bool InitDisplay(int argc, char **argv){
		GLfloat light_diffuse[] = {1.0f, 1.0f, 1.0f, 1.0f};  /* Red diffuse light. */
		GLfloat light_position[] = {1.0f, 1.0f, 10.0f, 0.0f};  /* Infinite light location. */
		GLfloat lightAmbient[]  = {0.2f, 0.2f, 0.2f, 1.0};
		
		glutInit(&argc, argv);
		glutInitDisplayMode(GLUT_DOUBLE | GLUT_RGB | GLUT_DEPTH);
		glutCreateWindow("KinectApp");
		glutReshapeWindow(800,600);
		glutKeyboardFunc(KinectApp::glutOnKeyPress);
		glutDisplayFunc(KinectApp::glutOnDraw);

		/* Enable a single OpenGL light. */
		glLightfv(GL_LIGHT0, GL_AMBIENT, lightAmbient);
		glLightfv(GL_LIGHT0, GL_DIFFUSE, light_diffuse);
		glLightfv(GL_LIGHT0, GL_POSITION, light_position);
		glEnable(GL_LIGHT0);
		glEnable(GL_LIGHTING);

		/* Use depth buffering for hidden surface elimination. */
		glEnable(GL_DEPTH_TEST);

		/* Setup the view of the cube. */
		glMatrixMode(GL_PROJECTION);
		gluPerspective( /* field of view in degree */ 90.0,
		/* aspect ratio */ 800.f/600.f,
		/* Z near */ 1.0, /* Z far */ 1000.0f);
		glMatrixMode(GL_MODELVIEW);
		gluLookAt(0.0, 0.0, mDistance,  /* eye is at (0,0,5) */
			0.0, 0.0, 0.0,      /* center is at (0,0,0) */
			0.0, 1.0, 0.);      /* up is in positive Y direction */
		return true;//success
	}

	//Kinect切断イベント
	virtual void KinectDisconnected(Kinect::Kinect* kinect) {
		printf("Kinect disconnected!\n");
	}

	//デプスバッファの最小値最大値を求める
	void calculateMinMaxDepth(unsigned short* oMin, unsigned short* oMax){
		unsigned short min=65535;
		unsigned short max=0;
		for(int j=0;j<KINECT_H;j++){
			for(int i=0;i<KINECT_W;i++){
				unsigned short d=mDepthBuffer[j*KINECT_W+i];
				if(d<min){
					min=d;
				}
				if(d>max){
					max=d;
				}
			}
		}
		*oMin=min;
		*oMax=max;
	}
		
	// 距離データ受信イベント
	virtual void DepthReceived(Kinect::Kinect* kinect) {
		kinect->ParseDepthBuffer();									
		// kinect->mDepthBufferが今だけ有効
		memcpy(this->mDepthBuffer,kinect->mDepthBuffer,sizeof(this->mDepthBuffer));
		glutPostRedisplay();
	}
		
	// カラーデータ受信イベント
	virtual void ColorReceived(Kinect::Kinect* kinect) {
		kinect->ParseColorBuffer();
		// kinect->mColorBufferが今だけ有効
		memcpy(this->mColorBuffer,kinect->mColorBuffer,sizeof(this->mColorBuffer));
	}
		
	//音声受信イベント
	virtual void AudioReceived(Kinect::Kinect* kinect) {
	}

	//キーイベント
	void OnKeyPress(unsigned key){
		KinectApp* app = KinectApp::GetApp();
		printf("key=%d\n",key);
		switch (key) {
			case 27://ESCキーのとき
				delete app;
				exit(0);
			case 97://a
				mYaw+=10.0f;
				glutPostRedisplay();
				break;
			case 100://d
				mYaw-=10.0f;
				glutPostRedisplay();
				break;
			case 119://w
				mPitch+=10.f;
				glutPostRedisplay();
				break;
			case 115://s
				mPitch-=10.0f;
				glutPostRedisplay();
				break;
			case 102://f mDepthFar
				mFar += 10.0f;
				printf("mFar=%7.0f\n",mFar);
				break;
			case 103://g mDepthFar
				mFar -= 10.0f;
				printf("mFar=%7.0f\n",mFar);
				break;
			
		}
	}

	//3Dスクリーンの描画(隣のピクセルとくっついている)
	void draw3DScreen2(void){
		unsigned short min=482,max=2047;
		//calculateMinMaxDepth(&min,&max);
		float fDiv=(float)(max-min);
		//デプスを求める
		float zFact = 600.0f;
		float* fDepth=(float*)malloc(sizeof(float)*KINECT_W*KINECT_H);
		for(int j=0;j<KINECT_H;j++){
			for(int i=0;i<KINECT_W;i++){
				int d=mDepthBuffer[KINECT_W*j+i];
				fDepth[KINECT_W*j+i] 
					= -zFact*(float)(d-min)/fDiv+zFact/2.0f;
			}
		}
		//QUAD描画
		float cx=(float)(KINECT_W)/2.0f;
		float cy=(float)(KINECT_H)/2.0f;
		int dx=-25;//キャリブレーション
		int dy=10;
		for(int j=0;j<KINECT_H-1;j++){
			for(int i=0;i<KINECT_W-1;i++){
				//デプスチェック
				if(fDepth[KINECT_W* j   +i]<mFar){
					continue;
				}
				float depth[4];
				depth[0]=fDepth[KINECT_W* j   +i];
				depth[1]=fDepth[KINECT_W*(j+1)+i];
				depth[2]=fDepth[KINECT_W*(j+1)+i+1];
				depth[3]=fDepth[KINECT_W* j   +i+1];
				float min=60000.0f;
				float max=-60000.0f;
				for(int k=0;k<4;k++){
					min=min(depth[k],min);max=max(depth[k],max);
				}
				if(max-min>50.0f){//前後に伸びているポリゴンは表示しない
					continue;
				}
				//カラー取得
				unsigned char cR,cG,cB;
				if(i+dx<0 || i+dx>=Kinect::KINECT_COLOR_WIDTH ||
					j+dy<0 || j+dy>=Kinect::KINECT_COLOR_HEIGHT){//カラーバッファの外のとき
						cR=cG=cB=0;
				} else {//中のとき
					unsigned char* p = &mColorBuffer[((j+dy)*Kinect::KINECT_COLOR_WIDTH+(i+dx))*3];
					cR=*(p++), cG=*(p++), cB=*(p++);
				}
				float color[3];
				color[0]=cR/255.0f;
				color[1]=cG/255.0f;
				color[2]=cB/255.0f;
				//位置計算
				float x[4],y[4];
				x[0]=(float)i     -cx; y[0]=(float)-j     +cy;
				x[1]=(float)i     -cx; y[1]=(float)-j-1.0f+cy;
				x[2]=(float)i+1.0f-cx; y[2]=(float)-j-1.0f+cy;
				x[3]=(float)i+1.0f-cx; y[3]=(float)-j     +cy;
				//描画
				glBegin(GL_QUADS);
				glMaterialfv(GL_FRONT,GL_DIFFUSE,color);
				glNormal3f(0.0f,0.0f,1.0f);
				glVertex3f(x[0],y[0],depth[0]);
				glVertex3f(x[1],y[1],depth[1]);
				glVertex3f(x[2],y[2],depth[2]);
				glVertex3f(x[3],y[3],depth[3]);
				glEnd();
			}
		}
		free(fDepth);
	}

	//描画イベント
	void OnDraw(){
		glClearColor(0.0, 0.0, 0.0, 1.0);
		glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT);

		glMatrixMode(GL_MODELVIEW);
		glLoadIdentity();
		double yaw = DEG2RAD(mYaw);
		double pitch = DEG2RAD(mPitch);
		double r = mDistance;
		gluLookAt(r*sin(yaw)*cos(pitch), r*sin(pitch), r*cos(yaw)*cos(pitch),  /* eye is at (0,0,5) */
			0.0, 0.0, 0.0,      /* center is at (0,0,0) */
			0.0, 1.0, 0.);      /* up is in positive Y direction */
		draw3DScreen2();

		glutSwapBuffers();
	}
};

#endif//__KINECT_APP_H__

KinectApp.cpp

// KinectApp.cpp : アプリクラス
//

#include "stdafx.h"
#include "Vector.h"
#include "KinectApp.h"

//グローバル変数
static KinectApp* g_app=NULL;

//インスタンス取得(シングルトン
KinectApp* KinectApp::GetApp(){
	if(g_app==NULL){
		g_app = new KinectApp();
	}
	return g_app;
}

//glut 描画イベント
void KinectApp::glutOnDraw(void)
{
	KinectApp::GetApp()->OnDraw();
}


//glut キーイベント
void KinectApp::glutOnKeyPress(unsigned char key, int x, int y)
{
	KinectApp::GetApp()->OnKeyPress(key);
}


//エントリーポイント
int _tmain(int argc, char **argv)
{
	KinectApp* app = KinectApp::GetApp();
	app->InitDisplay(argc,argv);
	app->InitKinect();
	glutMainLoop();
	return 0;
}

URL:この記事が参考になったと言う方、ibisMailもみてやってくださいm(_ _)m こちら

ibisMail ver.2.2.0 3大新機能!!

と言う訳で、前回のエントリで報告したようにプッシュ&フェッチをリジェクトされてしまったibisMailですが、とりあえずプッシュ&フェッチをOFFにしたものをver.2.2.0として改めて申請し、本日2010/8/24にリリースされました〜。

1. 他のアプリのファイルを添付して送信

5年前に公開した初代ibisMail(docomo版アプリ)のときからやりたかった「PCにあるファイルをケータイから添付して送りたい!」というのが、ついに実現しました。上図は、Dropboxに置いてあるPCのPDFファイルを、Dropboxの他のアプリで開く機能を使って、ibisMailの新規メッセージ作成画面へ転送した画面です。これはOS付属のメールアプリでも対応していないibisMailのアドバンテージ機能です。出先から「あのファイル送ってほしい」みたいな連絡があった時もこれで対応できます。もちろんDropbox以外のGoodReaderのような対応アプリ等からも添付できます。複数の添付をしたい場合は、iOSのマルチタスキング機能を使って、他のアプリからibisMailを開いて、HOMEボタンダブルプッシュでタスクマネージャを開いて、他のアプリに戻って別のファイルを選んで、他のアプリで開くコマンドで、複数ファイルの添付にも対応しています。
なお、iOS4.0のバグで、他のアプリで開く機能で表示されるアクションシート(選択ダイアログ)に表示されるアイコンがずれて表示されます(ibisMailでもGoodReaderでもDropboxでも)。いずれAppleが直してくれると思います。

2. 添付ファイルを他のアプリで開く(他のアプリに保存)

ibisMailで受信した添付ファイルGoodReaderやiBooksなどの他のアプリで保存できます。PDFの電子書籍やマニュアル、論文とかは、専用のPDFリーダー等でみる方が読みやすい(しおり機能とか)ので、便利です。現状、対応アプリが少ないですが順次増えてゆくことでしょう。私個人としては、Dropboxとi文庫が対応してくれたらいいな。と思っています。また、ibisMailで非対応の添付ファイルを他のアプリで開くこともできます。またzipファイルも他のアプリで開けますし、zipファイルの中のファイルも他のアプリで開けます。

3. 連絡先の名前の表示

これはユーザからの要望で大きかったものです。自分の連絡先(OS付属の連絡先アプリ)に入っているメールアドレスとマッチすると連絡先の名前に置換して表示する機能です。メッセージ一覧画面およびメッセージ本文画面で置換して表示されます。なおこの連絡帳の名前で表示する機能は、ibisMailの設定画面でON/OFFできます。ONにした場合、連絡先に「山田太郎」という登録で「yamada@example.co.jp」と「a@softbank.jp」と2つ登録してあった場合に、「山田太郎」に置換され、どちらからメールが来たか分からないデメリットもあります。日本のケータイのメールアドレスは、本人の名前が全くついていなくて、ランダムな文字列の友人もいるかと思います。ケータイメールのやり取りが多い人はONがよいかと思います。設定のデフォルトはOFFです。好みでON/OFF設定をしてください。

URL:ibisMailのダウンロード、レビューはこちら

不具合報告

ibisMail ver.2.2.0がリリースされました。
致命的な不具合として、Bccが相手に表示される場合がある不具合があり、ver.2.2.0で修正されました。ユーザの皆様、ご迷惑おかけしまして申し訳ございませんでした。このような不具合がないよう改善して参りたいと思います。すでにご利用の方は必ずバージョンアップをお願いいたします。

URL:ibisMailのダウンロード、レビューはこちら

ibisMailのプッシュ&フェッチがAppleから否認された...


前回のエントリで、ibisMail ver. 2.2.0でプッシュとフェッチに対応したものをAppleへ申請しました。と報告しましたが、残念ながら、Appleから否認されました。期待されていたユーザのみなさま、誠に申し訳ありません。

否認の理由は、前回のエントリでも「VoIPモードを使った」と報告しましたが、この「VoIPモードを使っているが、VoIPアプリでない」ことが否認理由です。iOS4マルチタスク機能は、非常に制限が多いため、プッシュ&フェッチを実装するには、VoIPモード以外には実用レベルでは実装できないのです。Appleとも「ユーザが強く要望している」など色々交渉しましたが、全く理解してもらえませんでした。Appleとしては、OS付属の「メール」に追いつかれるのが脅威なんでしょうか?他に何かデメリットがあるのでしょうか?ユーザ要望は無視なんでしょうか?非常に残念です。

ついにiOS4のマルチタスキングを利用して、念願のプッシュとフェッチに対応しました!Appleよありがとう!!!AppleiOS4でマルチタスキング対応を発表したとき、心が躍りました。ついに来た! なんせibisMail ver.1.0を出した1年半前、iOSはver.2.2だったのですが、リリースしたときから、「プッシュやフェッチに対応してないからApple製メールから乗り換えられない」というユーザからの問い合わせがずっとあり、iOS3でAppleがプッシュ通知サービスに対応したため、「ibisMailはプッシュに対応しないのか?いつ対応するのか?」とユーザからの問い合わせがあり、非常に辛かった。

[iPhone] ibisMail ver.2.2.0 2大新機能! - かみやんの技術者日記

上記、前回の私のエントリ。非常に喜んでいた自分が滑稽です。iOS4の開発者資料がでてから、ずっと目を皿のようにして、プッシュとフェッチが実現できる方法がないが、調べまくって、実験プログラムを書きまくって、プログラマとテスト担当者に何度も無理を言って、実現できたのに。部下たちの血と汗と涙の結晶なのに。非常に申し訳ない。貴重な時間とコストを投下したのに。非常に残念です。ibisMailリリースから1年半、ずっとユーザ様から「プッシュ&フェッチはいつ対応するのか?」と毎日毎日問合せが来ていて、やっと脱却できると思ったのに、これがまだまだ続くと思うと、残念です。幻のプッシュ&フェッチ対応版は、現在、社員専用と化しています。
とは言えAppStoreを選んだのは私であり、他のケータイマーケットに比べて自分がやりたいことが実現できる環境だし、まっとうな市場が形成されているので、くやしいですが、Appleに従うしかありません。いつかリベンジしたいです。

#できれば、皆様の声を力として借りたいです。このエントリの拡散、紹介をお願いしますm(_ _)m

ibisMail ver.2.2.0 2大新機能!

2010/8/6(金):ibisMail for iPhoneのver.2.2.0の承認依頼をAppleへ出しました〜。2大新機能を紹介します。
2010/8/13(金)Appleにリジェクトされました。くやしい。詳しく話すことができるときが来たら説明します。

1.iOS4のマルチタスキングを用いたプッシュとフェッチ対応

ついにiOS4のマルチタスキングを利用して、念願のプッシュとフェッチに対応しました!Appleよありがとう!!!AppleiOS4でマルチタスキング対応を発表したとき、心が躍りました。ついに来た! なんせibisMail ver.1.0を出した1年半前、iOSはver.2.2だったのですが、リリースしたときから、「プッシュやフェッチに対応してないからApple製メールから乗り換えられない」というユーザからの問い合わせがずっとあり、iOS3でAppleがプッシュ通知サービスに対応したため、「ibisMailはプッシュに対応しないのか?いつ対応するのか?」とユーザからの問い合わせがあり、非常に辛かった。前にも少し書いたが、iOS3のプッシュ通知サービスで、メールのプッシュに対応できなかった理由は、下記の理由からです。

  • iOS3のプッシュ通知サービスは、アイビス側でサーバを立てなければならず、売り切りでアプリを売っている以上、継続的にサーバを維持することがコスト的にできない。また、プッシュの利用料が追加で月額115円とかしても、利用者数が少なく開発コストに合わない
  • メールの新着をプッシュするには、メールサーバにあるメール一覧情報と、iPhone側に受信済みとなっているメール一覧をつき合わせ、差分があったときに新着とみなす必要があり、iPhone側のメール一覧情報を確実にサーバにつたえることが困難であること
  • アイビスでプッシュサーバを立てたとしても、そのプッシュサーバは、ユーザのメールサーバにログインする必要があり、メールアカウントのIDとパスワードを預かる必要があり、ユーザは不安になり使えない。また、アイビスの立てたプッシュサーバが相手メールサーバ(会社のメールサーバなど)からIP拒否等される可能性もある。
  • プッシュサーバの実現方法としては、メールサーバの設定でフォワード設定をして、プッシュサーバにメールを転送することで、プッシュさせることもできるが、メールサーバでフォワード設定できないメールサービス(会社のメールサーバなど)の人が多い。また、メールの中身がそのままプッシュサーバに届くので、やはりユーザはセキュリティ的に不安である

などの理由で、iOS3のプッシュ通知サービスはメールのプッシュには不向きであった。iOS3が出てから今までずっと、サポートに「プッシュはいつ対応するのか?」という問い合わせに、謝り続けていた。

だからiOS4の発表には心が躍った。AppleからiOS4のベータ版配布が始まってからAppleのドキュメントを読みまくって、実験プログラムを書きまくって、どの技術の組み合わせなら実現可能か落としこんでいった。iOS4のマルチタスキングはAndroidWindowsCEよりもずっと制限が厳しく、それはAppleがフォアグラウンドアプリ(画面上の表にあるアプリ)の動作が重くならないよう細心の注意がはらわれた設計であり、とにかくバックグラウンドプロセスのCPU時間の利用が厳しく、なかなかCPU時間をもらえなかった。プログラマには、何度も無理を言ってぎりぎり実装できた形だ。結果的にはVoIPモードを使って実装した。中にはOSへ返答する応答が100msではOSにプロセスを殺されてしまい、10ms以下になるように高速化したり、技術的にはかなり無茶をして実現させた。

プッシュ対応の仕様

という訳で、ibisMailのプッシュとフェッチの仕様としてちょっとだけ制限があります。まず、プッシュですが、これは新着メールがあることに気付くとiOS4の新機能のローカル通知でテキストメッセージやサウンド等でお知らせする機能ですが、IMAPサーバがIDLEコマンドに対応している必要があります。IDLEコマンドはIMAPの基本コマンドにはないのですが、拡張コマンドとして仕様があります。GmailMobileMe、 AOL等はIDLEコマンドに対応しています。残念ながらi.softbank.jpは対応していません。ソフトバンクさんが、i.softbank.jpもIDLEコマンドに対応してくれることを期待したいです。i.softbank.jpは、OS付属のプッシュ通知があるので、そちらを使ってください。また、IMAPではプッシュ通知は来るもののibisMailがバックグラウンドのまま受信しておく(フェッチ)ことはOSの制限が厳しく実現できませんでした。また、ibisMailをフォアグラウンドにしてから受信となるため、ibisMail側で振分をしている場合、一旦、受信箱に届いたあと、別のフォルダに振り分けている場合、その別のフォルダのときは、プッシュ通知して欲しくない場合でもプッシュ通知されてしまいます。逆に、Gmailのようにサーバ側(Web上から振分け設定)で振分をしている場合は、受信箱に新着がある場合にのみプッシュ通知されるため、別のフォルダに新着が届いたときにプッシュして欲しい場合でもプッシュ通知は来ません。これもOSの制限のため、なかなか実現が難しくあきらめました。また、POPではプロトコル上プッシュ用のコマンドがないためibisMailでのプッシュは非対応です(OS付属のメールもフェッチのみです)。

フェッチ対応の仕様

フェッチは、ibisMailがバックグラウンドでも定期受信をするものです。こちらはPOPのみの対応です。こちらもプッシュ同様に、新着があるとiOS4の新機能のローカル通知で、テキストメッセージやサウンド等でユーザにお知らせします。こちらは受信処理が行われるため、ibisMailをフォアグラウンドにしたときに受信済みとなっています。また、振分処理も行われた後となるため、振分け設定でゴミ箱とかに振り分けたスパムメールが届いただけでテキストメッセージやサウンドで通知されることはありません。具体的には、フォルダの設定で、未読の表示がONのフォルダに新着メールが来たときに、ユーザに通知します。なお設定で、フェッチのOFF(手動)と、10分間隔〜12時間間隔が選べます。フェッチは通信処理が定期的に入るためバッテリーの減りを気にする方は長めにしたりOFFにしてください。

2.添付を他のアプリで開く

iOS4の新機能で、「他のアプリで開く(Open In機能)」というのがあります。今までアプリ間でデータをやり取りできるデータは、フォトライブラリ経由で画像のやりとりとテキストや画像のコピー&ペーストぐらいでした。iOS4では、MIME-TYPEやファイルの拡張子を用いて、「このデータに興味がある人、手を挙げて〜」みたいな形でアプリ間連携ができるようになりました。上のスクリーンショットは、ibisMailの添付ファイル閲覧画面で「他のアプリで開く」ボタンを押したところです。
これにより、例えば、「よし!今からお客様のところへ移動しよう、移動中に書類を読んでおこう」という場合に、PCから自分宛に書類を添付してメールをしておいて、ibisMailで受信して、Good Readerなどのファイル閲覧アプリ系でゆっくり読む。という使い方ができます。現状、他のアプリのファイルを開くことができるアプリは、Good ReaderやiBooksなど少数ですが、いずれi文庫などの電子書籍ビューア系やDropboxやNドライブなどのクラウドストレージ系も対応してくれるでしょう。まもなく、出先でメールを受信して、添付ファイルをDropboxのフォルダに保存できるように、なると思うと楽しみです。
また、ibisMailの添付ファイルビューアは、Word, Excel, Power Point, PDF, TXT, HTML, JPG, GIF, PNG, TIFF, ZIPあたりですが、それ以外のファイルが添付されていた場合も、「他のアプリで開く」機能が使えるので、ibisMailで見れない添付が他のアプリでみれるようになるでしょう(現在は、この他のアプリのファイルを開くことができるアプリが少ない)。

という訳で、ver.2.2.0のAppleへの申請は2010/8/6に行ったので、問題がなければ1週間ほどでAppleから承認されるのではないかと思います。2010/08/13 追記Appleからリジェクトされました。残念。

URL:ibisMailのダウンロード、レビューはこちら

ver.2.2.0の更新履歴詳細

<新機能・改善>

  • POPのフェッチ受信機能追加(iPhone3G端末は除く)。
  • IDLEコマンド対応のIMAPサーバのプッシュ通知機能追加(iPhone3G端末は除く)。ただし、i.softbank.jpなどのIDLEコマンドに対応していないIMAPサーバは非対応。
  • 添付ファイルを他のアプリで開く機能を追加機能を追加。
  • 対応していない添付ファイルでも他のアプリで開く動作ができるように改善。
  • 本文編集画面から新規メッセージ作成画面へ戻った際にキーボードを消すように改善。
  • 各通知の種類を「テキストメッセージとサウンド」、「テキストメッセージ」、「サウンド」、「なし」の4種類に変更。
  • 起動時パスワードを設定している場合、パスワード入力画面が表示される直前に先回表示されていた内容が表示されないように改善。

<不具合修正>

  • 重複する振り分け条件が3つ以上存在した場合にアプリが終了してしまう不具合の修正。
  • メールの"返信"、"全員に返信"、"転送"、"新しく編集"の処理において、ToやCcにアドレスが正しく設定されない不具合の修正。
  • メールの送信や受信で扱うメールアドレスの名前に、 カンマや<>などの特殊文字が存在した場合に発生する不具合の修正。
  • 一部のIMAPサーバで送信されたメールが、送信済みフォルダに残らない不具合を修正。
  • 起動時にアプリが終了してしまう不具合の修正。
  • メールデータの末尾に「.」のデータが存在しないメールの本文が表示されない不具合の修正。
  • メッセージ最大数制限を超えているフォルダ内のメッセージ本文画面表示時に受信した際に、ナビゲーションバーの表示状態が更新されない不具合を修正。
  • ZIPファイル閲覧画面でファイル名のエンコード選択部品がずれて表示されていた不具合の修正。

iTunesのAppStoreレビューのスクレイピング

ibisMail for iPhoneの開発・販売に利用している社内ツールの1つのレビュー報告用のスクレイピングプログラムを公開します。 この社内ツールを作った目的は、PCにインストールしたiTunesが非常に重く、かつ、言語を切り替えてレビューを読むという作業が恐ろしく面倒であり、開発メンバー、サポートメンバーみんなが全部を読むというのは、手間がかかりすぎて不可能というレベルだったので、毎日1回バッチを実行して、新しいレビューが世界のどこかで書かれたら、メールで社内メンバーにお知らせするというスクレイピングプログラムを書いた。書いたのはちょいと前だが、昨日、Twitterの @iphone_dev_jp に「世界中のレビューを読むのが大変」とあったので、公開します。

必要な環境は、WindowsまたはLinuxまたはMacOSJDKApache HttpClient。MySQL

普段、スクレイピングをするときは、ウエブブラウザのふりをする訳だが、今回はiTunesのふりをしなければならないため、Wiresharkというパケットキャプチャ兼アナライザを用いてハックした。

雑談

私の仕事に、チーム全体の効率アップやモチベーションアップも含まれる。以前このブログで、「iTunes Connectにログインし、売り上げのcsv.gzファイルをダウンロードし、展開しDB登録するスクレイピングプログラム」を公開(このエントリ)したが、こういったものも効率アップのための仕掛け。
今回のIPhoneAppReviewバッチも開発チームのモチベーション、製品の改善には非常に役に立つ。
ibisMailはリリースから1年5ヶ月たった。ver.1.0リリース直後は総合1位を取りましたが、その後ビジネス25位近辺と低迷していました。その間ずっと改善を続け、ついにビジネス2位になりました。感謝感激です。ユーザの皆様ありがとうございます。

この情報が参考になったと思った方は、ibisMail をダウンロードいただけると幸いです URL:ibisMailのダウンロード、レビューはこちら
※以下、プログラム

IPhoneAppReview.java
/**
 * AppStoreのレビューを収集して社内へメールで通知するバッチ
 * Copyright ibis inc. Eiji Kamiya
 * License: GPL License 
 */
package batch;

import iphone.ResponseData;
import iphone.ScrapingClient;
import iphone.beans.ReviewBean;

import java.io.PrintWriter;
import java.io.StringWriter;
import java.sql.Connection;
import java.sql.SQLException;
import java.text.ParseException;
import java.text.SimpleDateFormat;
import java.util.ArrayList;
import java.util.Collections;
import java.util.Comparator;
import java.util.Date;
import java.util.Enumeration;
import java.util.Hashtable;
import java.util.Locale;
import java.util.Properties;

import javax.mail.Message;
import javax.mail.Session;
import javax.mail.Transport;
import javax.mail.internet.InternetAddress;
import javax.mail.internet.MimeMessage;


import db.DBConnectIPhone;

import util.Util;
/**
 * AppStoreのレビューをスクレイピングして社内へメールで通知するバッチ
 * Wiresharkでパケットキャプチャし、URLを解析
 * Wiresharkのフィルタ「http and tcp.port==80」
 * @author kamiya
 */
public class IPhoneAppReview {
    static String BASE_URL="http://ax.itunes.apple.com";
    static String LOG_DIRECTORY="WEB-INF/log/iPhone/";
    static int MAX_RETRY=3;//iTunes Connectがときどきエラーのためリトライ
    static String SMTP_SERVER="smtp.example.com";
    static String FROM_ADDRESS="iPhoneAppReview@example.com";//エラー報告メール
    static String TO_ADDRESS="foo@example.com";//エラー報告メールあて先
    static String REVIEW_FROM_ADDRESS="iPhoneAppReview@example.com";//レビュー報告メール
    static String REVIEW_TO_ADDRESS="foo@example.com";//レビュー報告メールあて先
    static String[] appTable = {//appIDは、iTunesでアプリDLページを開いて、右クリックでURLをコピーで、URLの一部にIDがあるので分かる
    	"ibisMail","999999999999",//ibisMail for iPhone
    };
    static String[] countryTable = {
    	"アメリカ合衆国 143441",
    	"アルゼンチン 143505",
    	"オーストラリア 143460",
    	"ベルギー 143446",
    	"ブラジル 143503",
    	"カナダ 143455",
    	"チリ 143483",
    	"中国 143465",
    	"コロンビア 143501",
    	"コスタリカ 143495",
    	"クロアチア 143494",
    	"チェコ 143489",
    	"デンマーク 143458",
    	"ドイツ 143443",
    	"エルサルバドル 143506",
    	"スペイン 143454",
    	"フィンランド 143447",
    	"フランス 143442",
    	"ギリシャ 143448",
    	"グァテマラ 143504",
    	"香港 143463",
    	"ハンガリー 143482",
    	"インド 143467",
    	"インドネシア 143476",
    	"アイルランド 143449",
    	"イスラエル 143491",
    	"イタリア 143450",
    	"韓国 143466",
    	"クウェート 143493",
    	"レバノン 143497",
    	"ルクセンブルク 143451",
    	"マレーシア 143473",
    	"メキシコ 143468",
    	"オランダ 143452",
    	"ニュージーランド 143461",
    	"ノルウェイ 143457",
    	"オーストリア 143445",
    	"パキスタン 143477",
    	"パナマ 143485",
    	"ペルー 143507",
    	"フィリピン 143474",
    	"ポーランド 143478",
    	"ポルトガル 143453",
    	"カタール 143498",
    	"ルーマニア 143487",
    	"ロシア 143469",
    	"サウジアラビア 143479",
    	"スイス 143459",
    	"シンガポール 143464",
    	"スロバキア 143496",
    	"スロベニア 143499",
    	"南アフリカ 143472",
    	"スリランカ 143486",
    	"スウェーデン 143456",
    	"台湾 143470",
    	"タイ 143475",
    	"トルコ 143480",
    	"アラブ首長国連邦 143481",
    	"イギリス 143444",
    	"ベネズエラ 143502",
    	"ベトナム 143471",
    	"日本 143462",	
    };
    private ScrapingClient client;//Httpクライアント
    private Hashtable<Integer,String> appMap;//アプリ番号とアプリ名の表
    private Hashtable<Integer,String> countryMap;//国番号と国名の表
    
    /**
	 * エントリポイント
	 * @param args
	 */
	public static void main(String[] args) {
        for(int i=0;i<MAX_RETRY;i++){
            boolean isErr=false;
            try {
                //メイン処理
                IPhoneAppReview app=new IPhoneAppReview();
                app.run();
            } catch (Exception e) {
                e.printStackTrace();
                isErr=true;
                System.out.println("Retry:"+(i+1));
                if(i==MAX_RETRY-1){//最大リトライ数に達したとき
                    System.out.println("GIVE UP!!!");
                    StringWriter errSW=new StringWriter();
                    PrintWriter err=new PrintWriter(errSW);
                    e.printStackTrace(err);
                    Util.sendMail(SMTP_SERVER, FROM_ADDRESS, TO_ADDRESS, 
                            "【iPhone】IPhoneAppReview Exception",
                            "IPhoneAppReviewバッチでExceptionが発生しました。\n" +
                            errSW.toString());
                    break;
                }
            }
            if(isErr==false){//成功したとき
                break;
            }
        }
   	}

    /**
     * コンストラクタ
     * @throws Exception 
     */
    public IPhoneAppReview() throws Exception{
    	client = new ScrapingClient();
        client.setUserAgent("iTunes/9.2 (Windows; Microsoft Windows 7 "
        		+"Home Premium Edition (Build 7600)) AppleWebKit/533.16");
        //アプリ番号表を作成する
        appMap = new Hashtable<Integer, String>();
        for(int i=0;i<appTable.length;i+=2){
        	appMap.put(Integer.valueOf(appTable[i+1]), appTable[i]);
        }
        //国番号表を作成する
        countryMap = new Hashtable<Integer, String>();
        for(int i=0;i<countryTable.length;i++){
        	int idx=countryTable[i].lastIndexOf(' ');
        	if(idx==-1){
        		throw new Exception("Can't found ' '.");
        	}
        	String key = countryTable[i].substring(idx+1);
        	String value = countryTable[i].substring(0,idx);
        	int keyI = Integer.parseInt(key);
        	countryMap.put(Integer.valueOf(keyI),value);
        }
    }
    
    /**
     * メイン
     * @throws Exception 
     */
    private void run() throws Exception{
    	Enumeration<Integer> appIds=appMap.keys();
    	while(appIds.hasMoreElements()){
    		Integer appId = appIds.nextElement();
	        Enumeration<Integer> countryCodes=countryMap.keys();
	        while(countryCodes.hasMoreElements()){
	        	Integer countryCode=countryCodes.nextElement();
	        	String countryName = countryMap.get(countryCode);
	        	System.out.println("Country="+countryName);
	            openReviewPage(appId.toString(), countryCode.intValue());
	        }
    	}
    }
    
    /**
     * 製品ページ内のレビューの取得
     * 参考:http://www.doedoegames.com/blog/scraping-the-itunes-store
     * 参考:http://blogs.oreilly.com/iphone/2008/08/scraping-appstore-reviews.html 国コード一覧
     * @param appId
     * @return
     * @throws Exception 
     */
    private void openReviewPage(String appId,int countryCode) throws Exception {
    	String url = BASE_URL+"/WebObjects/MZStore.woa/wa/viewContentsUserReviews?id="+
    		appId+"&pageNumber=0&sortOrdering=4&type=Purple+Software";
    	client.setHeader("X-Apple-Store-Front",""+countryCode+"-1");
    	ResponseData res = client.doGet(url);
    	//HTMLをパースしてBeanを作成する
    	String html = res.getHtml();
    	//client.saveToFile("IPhoneAppReview.txt", html);//DEBUG用
    	int idx = 0;
    	ArrayList<ReviewBean> list = new ArrayList<ReviewBean>();
    	while(true){
    		ReviewBean bean = new ReviewBean();
    		bean.setAppId(appId);
    		bean.setCountryCode(countryCode);
	    	//タイトルを探す
	    	StringBuffer title = new StringBuffer();
	    	idx = client.findValue(html,idx,"styleSet=\"basic13\" textJust=\"left\" "
	    			+"maxLines=\"1\"><SetFontStyle normalStyle=\"textColor\"><b>","</b>",title);
	    	if(idx==-1){
	    		break;
	    	}
	    	bean.setTitle(title.toString());
	    	//星を探す
	    	StringBuffer star = new StringBuffer();
	    	idx = client.findValue(html, idx, "<HBoxView topInset=\"1\" alt=\"", " star", star);
	    	if(idx==-1){
	    		break;
	    	}
	    	bean.setStar(Util.parseInt(star.toString(), 0));
	    	//ユーザIDを探す
	    	StringBuffer userId = new StringBuffer();
	    	idx = client.findValue(html, idx, "userReviewId=", "\">", userId);
	    	if(idx==-1){
	    		break;
	    	}
	    	bean.setUserProfileId(userId.toString());
	    	//ユーザ名を探す
	    	StringBuffer userName = new StringBuffer();
	    	idx = client.findValue(html, idx, appId+"\">", "</GotoURL>", userName);
	    	if(idx==-1){
	    		break;
	    	}
	    	String userNameStr = convertHtmlToText(userName.toString());
	    	bean.setUserName(userNameStr.trim());
	    	//バージョンを探す
	    	StringBuffer version=new StringBuffer();
	    	idx = client.findValue(html, idx, "- ", "-", version);
	    	if(idx==-1){
	    		break;
	    	}
	    	bean.setAppVersion(version.toString().trim());
	    	//更新日を探す
	    	StringBuffer dateString = new StringBuffer();
	    	idx = client.findValue(html, idx, " ", "</SetFontStyle></TextView>", dateString);
	    	if(idx==-1){
	    		break;
	    	}
	    	String dateStr = dateString.toString().trim();
	    	SimpleDateFormat dateFormat= new SimpleDateFormat("dd-MMM-yyyy",Locale.US);
	    	if(countryCode==143441){//USのとき
	    		dateFormat = new SimpleDateFormat("MMM d, yyyy",Locale.US);
	    	} else if(countryCode==143443){//Deutschlandのとき
	    		dateFormat = new SimpleDateFormat("dd.MM.yyyy",Locale.GERMAN);
	    	} else if(countryCode==143442){//Franceのとき
	    		dateFormat = new SimpleDateFormat("dd MMM yyyy",Locale.FRANCE);
	    	} else if(countryCode==143450){//Italiaのとき
	    		dateFormat = new SimpleDateFormat("dd-MMM-yyy",Locale.ITALY);
	    	}
	    	java.util.Date date;
	    	try {
	    		date = dateFormat.parse(dateStr);
	    	} catch(ParseException e){
	    		break;
	    	}
	    	bean.setReviewDate(new java.sql.Date(date.getTime()));
	    	//本文を探す
	    	StringBuffer body=new StringBuffer();
	    	idx = client.findValue(html, idx, "styleSet=\"normal11\" textJust=\"left\">"
	    			+"<SetFontStyle normalStyle=\"textColor\">", "</SetFontStyle></TextView>", 
	    			body);
	    	if(idx==-1){
	    		break;
	    	}
	    	bean.setBody(convertHtmlToText(body.toString()));
	    	list.add(bean);
    	}
    	//ソートにする
    	sortReviewBean(list);
    	//メールで報告とDBへ挿入
    	insertToDb(list);
    }

	/** リストの中身をソート
	 * @param list
	 * @return
	 */
	private void sortReviewBean(ArrayList<ReviewBean> list) {
		Collections.sort(list,new Comparator<ReviewBean>(){
			public int compare(ReviewBean o1, ReviewBean o2) {
				long a1 = o1.getReviewDate().getTime()/100000;
				long a2 = o2.getReviewDate().getTime()/100000;
				return (int)(a1 - a2);
			}
		});
	}

	/**
	 * 指定したレビューを1通のメールとして報告
	 * @param db
	 * @param con
	 * @param bean
	 * @throws SQLException
	 */
	private void reportByMail(DBConnectIPhone db, Connection con,
			ReviewBean bean) throws SQLException {
        SimpleDateFormat sdf = new SimpleDateFormat("yyyy/MM/dd");
		ArrayList<ReviewBean> list=db.getReview(con, bean.getAppId(), 
				bean.getCountryCode(), bean.getUserProfileId());
		list.add(0, bean);//先頭に自分を追加
		String countryName = countryMap.get(Integer.valueOf(bean.getCountryCode()));
		String subject=bean.getUserName()+"さん("+countryName+")のレビュー";
		StringBuffer body=new StringBuffer();
		body.append(bean.getUserName()+"さん("+countryName+")のレビューが更新されました。\n");
		body.append("--------------------------------------------------\n");
		for (ReviewBean bean2 : list) {
			body.append("日付:"+sdf.format(bean2.getReviewDate())+" ");
			body.append(bean2.getAppVersion()+"\n");
			body.append("タイトル:"+bean2.getTitle()+" "
					+makeStarMark(bean2.getStar())+"\n\n");
			body.append(bean2.getBody()+"\n");
			body.append("\n");
			body.append("--------------------------------------------------\n");
		}
		body.append("portalWeb/IPhoneAppReview batch\n");
		sendMail(SMTP_SERVER, REVIEW_FROM_ADDRESS, REVIEW_TO_ADDRESS, 
		        subject,body.toString());
	}

	/** 指定した数から星文字列を得る
	 * @param star
	 * @return
	 */
	private String makeStarMark(int star) {
		switch(star){
		case 1:return "★☆☆☆☆";
		case 2:return "★★☆☆☆";
		case 3:return "★★★☆☆";
		case 4:return "★★★★☆";
		case 5:return "★★★★★";
		default:return "星不明";
		}
	}

	/**
     * 入力レビュー列をDBに挿入
     * @param list
     * @throws ClassNotFoundException
     * @throws SQLException
     */
    private void insertToDb(ArrayList<ReviewBean> list) throws ClassNotFoundException, SQLException {
        DBConnectIPhone db=null;
        try{
            db=new DBConnectIPhone();
            Connection con=Common.getIPhoneConnection();
            for (ReviewBean bean : list) {
            	if(db.isExistReview(con, bean)==false){//DBにないとき
        	    	System.out.println(bean.toString());
            		reportByMail(db, con, bean);
	        		db.insertReview(con, bean);
            	}
			}
        } finally {
        	if(db!=null){
        		db.close();
        	}
        }
	}

	/** HTMLをテキストへ変換(' '+'\n'または'\n'の行をカット)
     * \r\nの改行は考慮されていない
     * @param str
     * @return
     */
    public static String convertHtmlToText(String str) {
		StringBuffer sb = new StringBuffer();
		for(int i=0;i<str.length();i++){
			char ch=str.charAt(i);
			if(ch=='\n' && i<str.length()-2 && str.charAt(i+1)==' ' 
				&& str.charAt(i+2)=='\n'){//"\n \n"のとき
				sb.append(ch);
				i+=2;
			} else if(ch=='\n' && i<str.length()-1 && str.charAt(i+1)=='\n'){//"\n\n"のとき
				sb.append(ch);
				i++;
			} else if(ch=='&'){//&のとき(実態参照解決)
				int idx = str.indexOf(';',i+1);
				if(idx==-1){//;がないとき
					sb.append(ch);
				} else {//;があるとき
					String mark=str.substring(i+1,idx);
					if(mark.equals("lt")){//<のとき
						sb.append('<');
					} else if(mark.equals("gt")){//>のとき
						sb.append('>');
					} else if(mark.equals("amp")){//&のとき
						sb.append('&');
					} else if(mark.length()>0 && mark.charAt(0)=='#'){//#999;のとき
						String num = mark.substring(1);
						int n = Util.parseInt(num, -1);
						if(n!=-1){//数字に変換できたとき
							sb.append((char)n);
						}
					} else {
						sb.append(ch);
						sb.append(mark);
						sb.append(';');
					}
					i+=mark.length()+1;
				}
			} else if(ch=='<'){//<のとき
				int idx=str.indexOf('>',i+1);
				if(idx==-1){//>がないとき
					sb.append(ch);
				} else {//>があるとき
					i=idx;
				}
			} else {
				sb.append(ch);
			}
		}
		return sb.toString();
	}

    public static void sendMail(String smtp, String from, String to, String subject, String body){
		try {
			Properties props = System.getProperties();
			// SMTPサーバーのアドレスを指定
			props.put("mail.smtp.host", smtp);// 設定ファイルから読み込み
			Session session = Session.getDefaultInstance(props, null);
			MimeMessage mimeMessage = new MimeMessage(session);
			// 送信元メールアドレスと送信者名を指定
			mimeMessage.setFrom(new InternetAddress(from, null, "utf-8"));
			// 送信先メールアドレスを指定
			mimeMessage.setRecipients(Message.RecipientType.TO, to);
			// メールのタイトルを指定
			mimeMessage.setSubject(subject, "utf-8");
			// メールの内容を指定
			mimeMessage.setText(body, "utf-8");
			// 送信日付を指定
			mimeMessage.setSentDate(new Date());
			
			Transport.send(mimeMessage);
		} catch (Exception e) {
			e.printStackTrace();
		}
	}
}
ResponseData.java
/* ファイル概要:
 * Copyright ibis inc. Eiji Kamiya
 * License: GPL License
 * Created on 2009/08/18 by kamiya
 */
package iphone;

import org.apache.http.HttpResponse;

/**
 * クラス概要:IPhoneApp〜バッチから利用されるクラス
 * @author kamiya
 */
public class ResponseData {
    private String html;//HTML
    private String statusLine;//ステータスライン
    private String contentType;//Content-Type(すべて小文字化されている)
    private String fileName;//Contents-Dispositionがあったときのファイル名
    private String location;//302などのmoved系の遷移先URL
    private HttpResponse response;//HTTPのレスポンス
    /**
     * @return htmlの取得
     */
    public String getHtml() {
        return html;
    }
    /**
     * @param html htmlの設定
     */
    public void setHtml(String html) {
        this.html = html;
    }
    /**
     * @return statusLineの取得
     */
    public String getstatusLine() {
        return statusLine;
    }
    /**
     * @param statusLine statusLineの設定
     */
    public void setStatusLine(String statusLine) {
        this.statusLine = statusLine;
    }
    /**
     * @return fileNameの取得
     */
    public String getFileName() {
        return fileName;
    }
    /**
     * @param fileName fileNameの設定
     */
    public void setFileName(String fileName) {
        this.fileName = fileName;
    }
    /**
     * @return locationの取得
     */
    public String getLocation() {
        return location;
    }
    /**
     * @param location locationの設定
     */
    public void setLocation(String location) {
        this.location = location;
    }
    /**
     * @return contentTypeの取得
     */
    public String getContentType() {
        return contentType;
    }
    /**
     * @param contentType contentTypeの設定
     */
    public void setContentType(String contentType) {
        this.contentType = contentType;
    }
	/**
	 * HttpResponseの取得
	 * @return
	 */
	public HttpResponse getResponse() {
		return response;
	}
	/**
	 * HttpResponseの設定
	 * @param response
	 */
	public void setResponse(HttpResponse response) {
		this.response = response;
	}
}
ScrapingClient.java
/**
 * Copyright ibis inc. Eiji Kamiya
 * License: GPL License
 */
package iphone;

import java.io.BufferedWriter;
import java.io.FileWriter;
import java.io.IOException;
import java.util.Enumeration;
import java.util.Hashtable;
import java.util.List;

import org.apache.http.Header;
import org.apache.http.HttpEntity;
import org.apache.http.HttpResponse;
import org.apache.http.HttpVersion;
import org.apache.http.NameValuePair;
import org.apache.http.client.entity.UrlEncodedFormEntity;
import org.apache.http.client.methods.HttpGet;
import org.apache.http.client.methods.HttpPost;
import org.apache.http.client.params.ClientPNames;
import org.apache.http.client.params.CookiePolicy;
import org.apache.http.conn.ClientConnectionManager;
import org.apache.http.conn.scheme.PlainSocketFactory;
import org.apache.http.conn.scheme.Scheme;
import org.apache.http.conn.scheme.SchemeRegistry;
import org.apache.http.conn.ssl.SSLSocketFactory;
import org.apache.http.impl.client.DefaultHttpClient;
import org.apache.http.impl.conn.tsccm.ThreadSafeClientConnManager;
import org.apache.http.message.AbstractHttpMessage;
import org.apache.http.params.BasicHttpParams;
import org.apache.http.params.HttpParams;
import org.apache.http.params.HttpProtocolParams;
import org.apache.http.protocol.BasicHttpContext;
import org.apache.http.protocol.HTTP;
import org.apache.http.protocol.HttpContext;
import org.apache.http.util.EntityUtils;


/**
 * スクレイピング用ユーティリティ
 * @author kamiya
 * 2010/7/4
 */
public class ScrapingClient {
    private DefaultHttpClient httpClient;//Webクライアント
    private HttpContext httpContext;//コンテキスト
    private Hashtable<String,String> headerMap;//追加するリクエストヘッダ

    /**
     * コンストラクタ
     */
    public ScrapingClient(){
		HttpParams params = new BasicHttpParams();
        HttpProtocolParams.setVersion(params, HttpVersion.HTTP_1_1);
        HttpProtocolParams.setContentCharset(params, "UTF-8");
        HttpProtocolParams.setUseExpectContinue(params, true);
		SchemeRegistry registry = new SchemeRegistry();
		registry.register(new Scheme("http",
				PlainSocketFactory.getSocketFactory(), 80));
		registry.register(new Scheme("https",
				SSLSocketFactory.getSocketFactory(),443));
		ClientConnectionManager ccm = new ThreadSafeClientConnManager(params, registry);
		httpClient = new DefaultHttpClient(ccm, params);
        httpContext=new BasicHttpContext();
        httpClient.getParams().setParameter(
                ClientPNames.COOKIE_POLICY, CookiePolicy.BROWSER_COMPATIBILITY);
        headerMap = new Hashtable<String, String>();
    }
    
    /** User-Agentの設定
     * @param ua
     */
    public void setUserAgent(String ua){
    	httpClient.getParams().setParameter(HttpProtocolParams.USER_AGENT, ua);
    }
    
    /**
     * GETリクエストを投げる
     * @param url GET先URL
     * @return レスポンスデータ
     * @throws Exception
     */
    public ResponseData doGet(String url) throws Exception {
        ResponseData res;
        do {
            System.out.println("GET "+url);
            HttpGet httpGet = new HttpGet(url);
            addHeader(httpGet);
            HttpResponse response = httpClient.execute(httpGet,httpContext);
            res=receiveResponse(response);
        } while(res.getLocation()!=null);
        return res;
    }

	/**
     * POSTリクエストを投げる
     * @param url POST先URL
     * @param param POSTするパラメータ
     * @return レスポンスデータ
     * @throws Exception
     */
    public ResponseData doPost(String url,List<NameValuePair> param) throws 
        Exception {
        ResponseData res;
        do{
            System.out.println("POST "+url);
            HttpPost httpPost = new HttpPost(url);
            addHeader(httpPost);
            httpPost.setEntity(new UrlEncodedFormEntity(param, HTTP.UTF_8));
            HttpResponse response = httpClient.execute(httpPost,httpContext);
            res=receiveResponse(response);
            if(res.getLocation()!=null){//リダイレクトのとき
                url=res.getLocation();
            }
        } while(res.getLocation()!=null);
        return res;
    }

    /**
     * レスポンスを受け取った後の処理
     * @param response レスポンス
     * @return レスポンスデータ
     * @throws Exception
     */
    private ResponseData receiveResponse(HttpResponse response) throws Exception{
        ResponseData res=new ResponseData();
        res.setResponse(response);
        System.out.println("Response Status: " + response.getStatusLine());
        res.setStatusLine(response.getStatusLine().toString());
        String contentType=response.getFirstHeader("Content-Type").getValue();
        contentType=contentType.toLowerCase();
        res.setContentType(contentType);
        Header location=response.getFirstHeader("Location");
        if(location!=null){//Locationヘッダがあるとき
            res.setLocation(location.getValue());
        }
        HttpEntity entity = response.getEntity();
        if(entity==null){
            return res;
        }
        if(contentType.startsWith("text/html") ||
        		contentType.startsWith("text/xml")){//HTMLまたはxmlのとき
            res.setHtml(EntityUtils.toString(entity,"utf-8"));
            //System.out.println(res.getHtml());
        }
        return res;
    }
    
    /**
     * HTMLをスクレイピングして、targetの次の文字から"までを取得
     * @param html 入力HTML
     * @param target 探す文字列
     * @return 得られた値
     * @throws IOException
     */
    public String findValue(String html,String target) throws IOException{
        int idx=html.indexOf(target);
        if(idx==-1){
            throw new IOException("Can't find "+target);
        }
        int idx2=html.indexOf("\"", idx+target.length());
        if(idx2==-1){
            throw new IOException("Can't find \"");
        }
        return html.substring(idx+target.length(),idx2);
    }
    
    /**
     * HTMLをスクレイピングして、afterを探し、それ以降のtargetの次の文字から"までを取得
     * @param html
     * @param target
     * @param after
     * @return 得られた値
     * @throws IOException
     */
    public String findValue(String html,String after, String target) throws IOException {
    	int idx=html.indexOf(after);
    	if(idx==-1){
    		throw new IOException("Can't find "+after);
    	}
        int idx1=html.indexOf(target,idx+after.length());
        if(idx1==-1){
            throw new IOException("Can't find "+target);
        }
        int idx2=html.indexOf("\"", idx1+target.length());
        if(idx2==-1){
            throw new IOException("Can't find \"");
        }
        return html.substring(idx1+target.length(),idx2);
    }
    
    /** HTMLをスクレイピングして検索の文字から終端文字までの間の文字を得る(検索開始位置指定付き)
     * @param html 検索対象の文字列
     * @param start 検索開始位置
     * @param target 検索の文字列
     * @param terminater 検索の文字列の次の文字から検索する終端文字列
     * @param ret 検索の文字列と終端文字列の間の文字列(このメソッド内でappend()する、事前にnewしておくこと)
     * @return 見つかった場合は、終端文字列の次の文字の位置、見つからないときは-1
     * @throws IOException 
     */
    public int findValue(String html, int start, String target, String terminater, 
    		StringBuffer ret) throws IOException{
    	int idx = html.indexOf(target,start);
    	if(idx==-1){//検索の見つからないとき
    		return -1;
    	}
    	idx += target.length();
    	int idx2=html.indexOf(terminater,idx);
    	if(idx2==-1){//終端文字列が見つからないとき
    		return -1;
    	}
    	ret.append(html.substring(idx, idx2));
    	idx2+=terminater.length();
    	return idx2;
    }

    /** httpGetまたはhttpPostにヘッダを追加
     * @param getOrPost
     */
    private void addHeader(AbstractHttpMessage getOrPost){
    	Enumeration<String> e = headerMap.keys();
    	while(e.hasMoreElements()){
    		String key = e.nextElement();
    		String value = headerMap.get(key);
    		getOrPost.addHeader(key,value);
    	}
    }
    
    /** ヘッダを追加(すでに同じヘッダ名が登録されているときは上書き)
     * @param key
     * @param value
     */
    public void setHeader(String key, String value){
    	if(headerMap.contains(key)){//すでに登録済みのとき
    		headerMap.remove(key);
    	}
    	headerMap.put(key, value);
    }
    
    /**
     * 追加するヘッダ情報をクリア
     */
    public void clearHeader(){
    	headerMap.clear();
    }
    
    /**
     * HTTPコンテキストの属性の取得
     * @param key
     * @return
     */
    public String getAttribute(String key){
    	return httpContext.getAttribute(key).toString();
    }

	/**
	 * 文字列をファイルに出力する
	 * @param fname
	 * @param str
	 * @throws IOException
	 */
	public void saveToFile(String fname, String str) throws IOException {
		BufferedWriter br=null;
		try {
			br = new BufferedWriter(new FileWriter(fname));
			br.write(str);
		} finally {
			if(br!=null){
				try {
					br.close();
				} catch(Exception e){
				}
			}
		}
	}
}
ReviewBean.java
/**
 * Copyright ibis inc. Eiji Kamiya
 * License: GPL License
 */
package iphone.beans;

import java.sql.Date;

/** レビュー 
 * @author kamiya 2010/07/05
 *
 */
public class ReviewBean {
	private String appId;//アプリ番号
	private int countryCode;//国番号
	private String userProfileId;//10桁ぐらいの数字
	private String userName;//ユーザ名
	private String appVersion;//アプリバージョン
	private Date reviewDate;//レビュー更新日
	private int star;//星の数
	private String title;//レビュータイトル
	private String body;//レビュー本文
	
	public String getAppId() {
		return appId;
	}
	public void setAppId(String appId) {
		this.appId = appId;
	}
	public int getCountryCode() {
		return countryCode;
	}
	public void setCountryCode(int countryCode) {
		this.countryCode = countryCode;
	}
	public String getUserProfileId() {
		return userProfileId;
	}
	public void setUserProfileId(String userProfileId) {
		this.userProfileId = userProfileId;
	}
	public String getUserName() {
		return userName;
	}
	public void setUserName(String userName) {
		this.userName = userName;
	}
	public String getAppVersion() {
		return appVersion;
	}
	public void setAppVersion(String appVersion) {
		this.appVersion = appVersion;
	}
	public Date getReviewDate() {
		return reviewDate;
	}
	public void setReviewDate(Date date) {
		this.reviewDate = date;
	}
	public int getStar() {
		return star;
	}
	public void setStar(int star) {
		this.star = star;
	}
	public String getTitle() {
		return title;
	}
	public void setTitle(String title) {
		this.title = title;
	}
	public String getBody() {
		return body;
	}
	public void setBody(String body) {
		this.body = body;
	}
	public String toString(){
		return "Date="+reviewDate.toString()+" User="+userName
		+" Version="+appVersion+" Title="+title+" Body="+body;
	}
}
DBConnectIPhone.java
	/** 指定したAppID,CountryCode,UserProfileID,ReviewDateの行があるか?
	 * @param con
	 * @param bean
	 * @return
	 * @throws SQLException
	 */
	public boolean isExistReview(Connection con, ReviewBean bean) throws SQLException {
		PreparedStatement ps = null;
        ResultSet rs = null;
        try {
			String sql = "select AppID from DT_REVIEW "
				+"where AppID=? and CountryCode=? and UserProfileID=? and ReviewDate=? limit 0,1";
			ps = con.prepareStatement(sql);
			ps.setString(1, bean.getAppId());
			ps.setInt(2, bean.getCountryCode());
			ps.setString(3, bean.getUserProfileId());
			ps.setDate(4, bean.getReviewDate());
			rs = ps.executeQuery();
			if(rs.next()){
				return true;
			}
			return false;
        } finally {
        	close(null, ps, rs);
        }
	}

	/** 指定したAppID,CountryCode,UserProfileID,ReviewDateの行があるか?
	 * @param con
	 * @param bean
	 * @return
	 * @throws SQLException
	 */
	public ArrayList<ReviewBean> getReview(Connection con, String appId, 
			int countryCode, String userProfileId) throws SQLException {
		PreparedStatement ps = null;
        ResultSet rs = null;
        ArrayList<ReviewBean> list=new ArrayList<ReviewBean>();
        try {
			String sql = "select AppID,CountryCode,UserProfileID,UserName,"
				+"AppVersion,ReviewDate,Title,Body,Star from DT_REVIEW "
				+"where AppID=? and CountryCode=? and UserProfileID=? "
				+"order by ReviewDate desc";
			ps = con.prepareStatement(sql);
			ps.setString(1, appId);
			ps.setInt(2, countryCode);
			ps.setString(3, userProfileId);
			rs = ps.executeQuery();
			while(rs.next()){
				ReviewBean bean = new ReviewBean();
				bean.setAppId(rs.getString(1));
				bean.setCountryCode(rs.getInt(2));
				bean.setUserProfileId(rs.getString(3));
				bean.setUserName(rs.getString(4));
				bean.setAppVersion(rs.getString(5));
				bean.setReviewDate(rs.getDate(6));
				bean.setTitle(rs.getString(7));
				bean.setBody(rs.getString(8));
				bean.setStar(rs.getInt(9));
				list.add(bean);
			}
			return list;
        } finally {
        	close(null, ps, rs);
        }
	}

	/** 指定したレビューを1件挿入する
	 * @param con
	 * @param bean
	 * @throws SQLException
	 */
	public void insertReview(Connection con, ReviewBean bean) throws SQLException {
		PreparedStatement ps = null;
        ResultSet rs = null;
        try {
        	String sql="insert DT_REVIEW set "
        		+"AppID=?,"
        		+"CountryCode=?,"
        		+"UserProfileID=?,"
        		+"UserName=?,"
        		+"AppVersion=?,"
        		+"ReviewDate=?,"
        		+"Title=?,"
        		+"Body=?,"
        		+"Star=?";
        	ps=con.prepareStatement(sql);
        	int i=1;
        	ps.setString(i++, bean.getAppId());
        	ps.setInt(i++, bean.getCountryCode());
        	ps.setString(i++, bean.getUserProfileId());
        	ps.setString(i++, bean.getUserName());
        	ps.setString(i++, bean.getAppVersion());
        	ps.setDate(i++, bean.getReviewDate());
        	ps.setString(i++, bean.getTitle());
        	ps.setString(i++, bean.getBody());
        	ps.setInt(i++, bean.getStar());
        	ps.execute();
        } finally {
        	close(null, ps, rs);
        }		
	}
TABLE定義
CREATE TABLE `IPhoneDB`.`DT_REVIEW` (
  `AppID` VARCHAR(20) NOT NULL,
  `CountryCode` INT NOT NULL,
  `UserProfileID` VARCHAR(20),
  `UserName` VARCHAR(255),
  `AppVersion` VARCHAR(30),
  `ReviewDate` DATE,
  `Title` VARCHAR(255),
  `Body` TEXT,
  `Star` int default 0,
  PRIMARY KEY USING BTREE(`AppID`,`CountryCode`,`UserProfileID`,`ReviewDate`)
) ENGINE = InnoDB,  DEFAULT CHARSET=utf8;
あとは、、
  • クラスパスにJavaMail(mail.jar)、httpclient-4.0.1.jar、httpcore-4.0.1.jar、mysql-connector-java-5.1.8-bin.jarを追加
  • 起動用のバッチファイルを作成、cronなり、Windowsならタスクスケジューラに登録
  • DBConnectIPhoneクラスが一部、弊社内セキュリティを守るため削られていますが、ソースを読めば何を補完すればよいか分かると思います。

パチンコを作った

5歳の娘のために、日曜日にパチンコをつくった。ホームセンターで木の板と、10mm角の棒と直径10mmの丸棒とクギをかってきた。
あとは輪ゴムとビー玉を用意して完成〜。

ポケットとかに得点を書いて、遊んだ。かなり、娘は喜んでくれたようだ。

ibisMail ver.2.1.0 released!

ibisMail ver.2.1.0は無事土曜日にリリースされました。久しぶりにビジネスカテゴリ2位です。ありがとうございます! URL:ibisMailのダウンロード、レビューはこちら