OFX を使いやすく変換してみた。

Macでの家計簿つけにiMoneyBalance を愛用しています。
http://homepage2.nifty.com/macdekakeibo/
http://homepage2.nifty.com/macdekakeibo/images/title.gif
とても使いやすい。
使い始めて数ヶ月して始めて(!)、スタートガイドを読んだところ銀行口座の管理も簡単にできるということがわかりました。家計の節約のためにも、これは銀行口座もちゃんと家計簿化しよう♪と思ったのですが、銀行の情報をとってくるのがめんどい。


ブラウザでオンラインバンクの明細履歴をみながら入力しているのですが、なんかもうちょっと楽にできないかなーと。レシートを打ち込む作業はきらいじゃないけど、インターネットにのっかっているデータを見ながら手で入力するのは、なんとなく気分的に悲しい。しかもちょっと目を離していると、タイムアウトとかいって、強制ログアウトさせられるし。せめてExcelの一覧表かなんかにうつして、ローカルにもってきたいなぁと。
Windowsをメインで使っている頃は、銀行の明細をブラウザからExcelにコピペしてそれで管理してましたが、今はMacで作業していて、さらにOpenOfficeしか入ってなくて、このコピペがうまくいかない。。。


Microsoft Money に対応した OSF という形式でなら、オンラインバンクからダウンロードが可能。なにこのOSFって。。。。CSVでいいじゃない、CSVで。XMLでもいいじゃない、XMLで。と激しく思う。OSFをOpenOfficeで読み込むと、それっぽく映してくれるのだけど、なんか日付のソート順がおかしくなってる。。。
これは Microsoft Money を買わせようという策略なのか。。。
Mac Officeですら(欲しいけど)我慢しているぼくが、Moneyを買う訳もなく、趣味的にプログラミングで解決をはかってみました。プログラミングはやっぱり楽しい。


以下、マニアックな内容です。
※ 以下は、みずほダイレクトでは動きますが、他では動かないと思います。
  いずれなおしていこうと思います。。


OSFは、よくわからないのだけど、かぎりなくXMLっぽいXMLとは違うもの。
このままDOMに読み込もうとすると、パースできないらしい。中身を見てみると、、、

OFXHEADER:100
DATA:OFXSGML
VERSION:102
SECURITY:NONE
ENCODING:UTF-8
CHARSET:CSUNICODE
COMPRESSION:NONE
OLDFILEUID:NONE
NEWFILEUID:NONE

<OFX>
<SIGNONMSGSRSV1>
<SONRS>
<STATUS>
<CODE>0
<SEVERITY>INFO
</STATUS>
<DTSERVER>20090508000000[+9:JST]
<LANGUAGE>JPN
<FI>
<ORG>XXX銀行XXX支店
</FI>
</SONRS>
</SIGNONMSGSRSV1>
<BANKMSGSRSV1>
<STMTTRNRS>
<TRNUID>0
<STATUS>
<CODE>0
<SEVERITY>INFO
</STATUS>
<STMTRS>

すごくXMLっぽいのだけど、、、、、
一番キモとなる、1つの明細の塊情報はこんな感じ。

<STMTTRN>
<TRNTYPE>OTHER
<DTPOSTED>20090311000000[+9:JST]
<TRNAMT>-00000000004983
<FITID>20090311000010
<MEMO>東京ガス
</STMTTRN>

ちょw、なにこの中途半端なXML。。。
よくわからないけど、子要素を持たない、Valueしかない要素は終了タグがない。なぜ。。。


よくわからないけど、とりあえずXMLヘッダつけて、終了タグつけてあげればXMLとしてJAVAで好きに扱えるかなと思ってやってみました。これを読み込んで、JAVAでいじくって最終的にはTSV形式で出力できるようになった。

2009/04/11 東京ガス -4983
2009/04/13 電気 -3771


やったことはすごく単純だけど、結構時間かかってしまった。3時間強くらい。。。
JAVAってこういうときにさくっとつくれる手軽さが少し足りないのね。
XMLをDOMで読み込んでデータクラスに入れ込むところ以外は、いつものお約束的なコードを結構たくさん書くので。Perlとかだともっと楽にできたのかしら。
まぁ、むろん僕のスキルレスなところも大きいですが。ちなみに一番時間がかかったのは、この一文 orz

bw.write("<?xml version=\"1.0\" encoding=\"UTF-8\" ?>");

ダブルクオーテーションをエスケープさせるためのエスケープ記号がMacではバックスラッシュで(Winでは¥マーク)、Macでバックスラッシュを出すのは、「option + ¥」ということをしったこと。本当に30分以上これにはまって挫折しかけました。。。

\" // Mac ではこうしないとエスケープされない!
¥" // Win だと、これでエスケープされる(ホントは小文字)

これって Write once, run anywhere じゃないんじゃ?とか思った。「¥」動いてよ「¥」。


誰かの役に立つとは思えませんが、いつもこうやって書いたソースをなくすので、ここに晒してみます。
OSFが嫌いな方はぜひ。


しかしこうやってJavaでいじれても、現時点ではiMoneyBalanceに読み込ませることはできなくて結局て入力なのであった。最初から分かっていたことなのだが。。まぁ、プログラミングをするいい材料になったということで。


メインクラス

package converter.ofx;

import java.io.File;
import java.text.SimpleDateFormat;
import java.util.Iterator;
import java.util.List;

import javax.swing.JFileChooser;
import javax.swing.filechooser.FileFilter;

import converter.ofx.data.OFXDatum;
import converter.ofx.util.OFXConvertUtility;

/** Convert ofx to CSV */
public class OFXConverterMain {

	/**	Default Entry */
	public static void main(String[] args) {
		// OFXファイルを選択
		JFileChooser chooser = getJFileChooser();
		int selected = chooser.showOpenDialog(null);
		
		// ファイル処理開始
		if (selected == JFileChooser.APPROVE_OPTION) {
			try
			{
				File selectedFile = chooser.getSelectedFile();
				// 読込用の一時XMLファイルを作成(ファイルは同一階層につくる)
				File xmlFile = OFXConvertUtility.duplicateOFXtoXML(selectedFile, selectedFile.getAbsolutePath()+"_tmp.xml");
				
				// XMLファイルからOFXの内容を読み込んでリスト化
				List<OFXDatum> list = OFXConvertUtility.convertOFXMLtoDataList(xmlFile);
				
				// コンソールにTSVで出力
				SimpleDateFormat format = new SimpleDateFormat("yyyy/MM/dd");
				for (Iterator iter = list.iterator(); iter.hasNext();) {
					OFXDatum aDatum = (OFXDatum) iter.next();
					System.out.println(
							format.format(aDatum.getDate())
							+ "\t"
							+ aDatum.getMEMO()
							+ "\t"
							+ aDatum.getTransactionAmount());
				}
				
			}
			catch(Exception e)
			{
				e.printStackTrace();
				System.exit(1);
			}
		} else {
			System.exit(1);
		}
		System.exit(0);
	}
	
	/**
	 * ファイル選択ダイアログを取得
	 * @return
	 */
	private static JFileChooser getJFileChooser()
	{
		JFileChooser chooser = new JFileChooser("/Users/XXX/Downloads");
		{
			chooser.setMultiSelectionEnabled(false);
			chooser.setFileSelectionMode(JFileChooser.FILES_ONLY);
			chooser.setFileFilter(new FileFilter() {
				@Override
				public boolean accept(File f) {
					if (f.isDirectory())
						return true;
					String s = f.getName();
					int x = s.lastIndexOf('.');
					if (x < 0)
						return false;
					String extention = s.substring(x + 1).toLowerCase();
					if (extention.equals("ofx"))
						return true;
					return false;
				}

				@Override
				public String getDescription() { return "OFXファイル"; }
			});
		}
		return chooser;
	}
}

データクラス

package converter.ofx.data;

import java.io.Serializable;
import java.util.Calendar;
import java.util.Date;

public class OFXDatum implements Serializable{

	private static final long serialVersionUID = -1787180352796262836L;

	private String TRNTYPE;
	private String DTPOSTED;
	private String TRNAMT;
	private String FITID;
	private String MEMO;
	
	/** Constructor */
	public OFXDatum(String TRNTYPE, String DTPOSTED, String TRNAMT, String FITID, String MEMO)
	{
		this.TRNTYPE = TRNTYPE;
		this.DTPOSTED = DTPOSTED;
		this.TRNAMT = TRNAMT;
		this.FITID = FITID;
		this.MEMO = MEMO;
	}
	
	public String getDTPOSTED() {
		return DTPOSTED;
	}

	public String getFITID() {
		return FITID;
	}

	public String getMEMO() {
		return MEMO;
	}

	public String getTRNAMT() {
		return TRNAMT;
	}

	public String getTRNTYPE() {
		return TRNTYPE;
	}
	
	public Date getDate()
	{
		int year = Integer.parseInt(DTPOSTED.substring(0, 4));
		int month = Integer.parseInt(DTPOSTED.substring(4, 6));
		int date = Integer.parseInt(DTPOSTED.substring(6, 8));

		Calendar calendar = Calendar.getInstance();
		calendar.set(year, month-1, date, 0, 0, 0);
		
		return new Date(calendar.getTimeInMillis());
	}
	
	public int getTransactionAmount()
	{
		String trnAmtTmp = TRNAMT.startsWith("+") ? TRNAMT.substring(1) : TRNAMT;
		return Integer.parseInt(trnAmtTmp);
	}
}

ユーティリティクラス

package converter.ofx.util;

import java.io.BufferedReader;
import java.io.BufferedWriter;
import java.io.File;
import java.io.FileReader;
import java.io.FileWriter;
import java.io.IOException;
import java.util.LinkedList;
import java.util.List;

import javax.xml.parsers.DocumentBuilder;
import javax.xml.parsers.DocumentBuilderFactory;
import javax.xml.parsers.ParserConfigurationException;

import org.w3c.dom.Document;
import org.w3c.dom.Element;
import org.w3c.dom.Node;
import org.w3c.dom.NodeList;
import org.xml.sax.SAXException;

import converter.ofx.data.OFXDatum;

/** OFXをみやすい形に変えます */
public class OFXConvertUtility {

	/**
	 * OFXにXMLヘッダーをつけて、ファイルを複製します。
	 */
	public static File duplicateOFXtoXML(File ofxFile, String xmlFileAbsolutePath) throws IOException {
		BufferedReader br = null;
		BufferedWriter bw = null;
		try {
			br = new BufferedReader(new FileReader(ofxFile));
			if (ofxFile == null)
				throw new NullPointerException();

			// 出力するファイルを作成
			File xmlFile = new File(xmlFileAbsolutePath);
			xmlFile.getParentFile().mkdirs();
			xmlFile.createNewFile();

			// ファイルにXMLヘッダをつける
			bw = new BufferedWriter(new FileWriter(xmlFile));
			bw.write("<?xml version=\"1.0\" encoding=\"UTF-8\" ?>");
			bw.newLine();

			String str = null;
			boolean tagged = false;
			while ((str = br.readLine()) != null) {
				if (str.startsWith("<"))
					tagged = true;
				if (tagged) {
					// OFXの形式をXMLに変換。<XXX>YYY -> <XXX>YYY</XXX>
					if (!str.endsWith(">")) {
						String startTag = str.substring(0, str.indexOf(">") + 1);
						String lastTag = "</" + startTag.substring(1);
						str = str + lastTag;
					}
					else if(str.startsWith("<STATUS>")) // 今回の目的には関係ないのでやっつけ処理
					{
						continue;
					}

					bw.write(str);
					bw.newLine();
					bw.flush();
				}
			}
			return xmlFile;
		} finally {
			if (br != null)
				br.close();
			if (bw != null)
				bw.close();
		}
	}

	/**
	 * XML化したOSFをDOMに読み込んで、データクラスに格納します。
	 */
	public static List<OFXDatum> convertOFXMLtoDataList(File ofxmlFile) throws ParserConfigurationException, SAXException, IOException
	{
		List<OFXDatum> list = new LinkedList<OFXDatum>();
		
		// ドキュメントビルダーの用意
		DocumentBuilderFactory factory = DocumentBuilderFactory.newInstance();
		DocumentBuilder builder = factory.newDocumentBuilder();
		Document doc = builder.parse(ofxmlFile);

		// エレメント処理
		Element root = doc.getDocumentElement();
		NodeList nodeListSTMTTRN = root.getElementsByTagName("STMTTRN");
		for (int i = 0; i < nodeListSTMTTRN.getLength(); i++) {
			Element elementTRNTYPE = (Element) nodeListSTMTTRN.item(i);
			String strTRNTYPE = getAValueOfTheType(elementTRNTYPE, "TRNTYPE");
			String strDTPOSTED = getAValueOfTheType(elementTRNTYPE, "DTPOSTED");
			String strTRNAMT = getAValueOfTheType(elementTRNTYPE, "TRNAMT");
			String strFITID = getAValueOfTheType(elementTRNTYPE, "FITID");
			String strMEMO = getAValueOfTheType(elementTRNTYPE, "MEMO");
			
			OFXDatum aDatum = new OFXDatum(strTRNTYPE, strDTPOSTED, strTRNAMT, strFITID, strMEMO);
			list.add(aDatum);
		}
		return list;
	}

	/** 指定されたSTMTTRNタグ子要素のVALUEを取得します  */
	private static String getAValueOfTheType(Element elementTRNTYPE, String type)
	{
		NodeList nodeList = elementTRNTYPE.getElementsByTagName(type);
		Element theElement = (Element) nodeList.item(0);
		Node theNode = theElement.getFirstChild();
		return theNode.getNodeValue();
	}
}