lib/Finance/Bank/JP/Mizuho.pm | |||
---|---|---|---|
Criterion | Covered | Total | % |
statement | 19 | 21 | 90.4 |
branch | n/a | ||
condition | n/a | ||
subroutine | 7 | 7 | 100.0 |
pod | n/a | ||
total | 26 | 28 | 92.8 |
line | stmt | bran | cond | sub | pod | time | code | |
---|---|---|---|---|---|---|---|---|
1 | =encoding utf8 | |||||||
2 | ||||||||
3 | =head1 NAME | |||||||
4 | ||||||||
5 | Finance::Bank::JP::Mizuho | |||||||
6 | ||||||||
7 | =head1 SYNOPSIS | |||||||
8 | ||||||||
9 | my $mizuho = Finance::Bank::JP::Mizuho-new( | |||||||
10 | consumer_id => '123455678', | |||||||
11 | password => 'p45sW0rD', | |||||||
12 | questions => { | |||||||
13 | '母親の誕生日はいつですか(例:5月14日)' => '10月1日', # have to use 2byte digits, sucks | |||||||
14 | '最も年齢の近い兄弟姉妹の誕生日はいつですか(例:2月10日)' => '12月2日', | |||||||
15 | '応援しているスポーツチームの名前は何ですか' => '阪神タイガース', | |||||||
16 | }, | |||||||
17 | ); | |||||||
18 | ||||||||
19 | my $accounts = $mizuho->accounts; | |||||||
20 | ||||||||
21 | my $ofx = $mizuho->get_ofx( | |||||||
22 | $mizuho->accounts->[0], | |||||||
23 | $mizuho->CONTINUATION_FROM_LAST, | |||||||
24 | ); | |||||||
25 | ||||||||
26 | ||||||||
27 | =head1 DESCRIPTION | |||||||
28 | ||||||||
29 | Perl interface to access your L |
|||||||
30 | ||||||||
31 | =head1 CONSTANT | |||||||
32 | ||||||||
33 | =head2 CONTINUATION_FROM_LAST | |||||||
34 | ||||||||
35 | Value for L | |||||||
36 | ||||||||
37 | =head2 SAME_AS_LAST | |||||||
38 | ||||||||
39 | Value for L | |||||||
40 | ||||||||
41 | =head2 LAST_TWO_MONTHS | |||||||
42 | ||||||||
43 | Value for L | |||||||
44 | ||||||||
45 | =head1 FUNCTIONS | |||||||
46 | ||||||||
47 | =cut | |||||||
48 | ||||||||
49 | package Finance::Bank::JP::Mizuho; | |||||||
50 | ||||||||
51 | 3 | 3 | 94436 | use strict; | ||||
3 | 8 | |||||||
3 | 110 | |||||||
52 | 3 | 3 | 14 | use warnings; | ||||
3 | 6 | |||||||
3 | 74 | |||||||
53 | ||||||||
54 | 3 | 3 | 16 | use Carp; | ||||
3 | 10 | |||||||
3 | 246 | |||||||
55 | 3 | 3 | 4007 | use DateTime; | ||||
3 | 620134 | |||||||
3 | 124 | |||||||
56 | 3 | 3 | 4648 | use Encode; | ||||
3 | 55512 | |||||||
3 | 476 | |||||||
57 | 3 | 3 | 2029 | use Finance::Bank::JP::Mizuho::Account; | ||||
3 | 8 | |||||||
3 | 93 | |||||||
58 | 3 | 3 | 1513 | use Finance::OFX::Parse::Simple; | ||||
0 | ||||||||
0 | ||||||||
59 | use HTTP::Cookies; | |||||||
60 | use LWP::UserAgent; | |||||||
61 | ||||||||
62 | our $VERSION = '0.02'; | |||||||
63 | ||||||||
64 | use constant USER_AGENT => 'Mozilla/4.0 (compatible; MSIE 7.0; Windows NT 5.1)'; | |||||||
65 | use constant START_URL => 'http://www.mizuhobank.co.jp/direct/start.html'; | |||||||
66 | use constant ENCODING => 'shift_jis'; | |||||||
67 | ||||||||
68 | use constant CONTINUATION_FROM_LAST => 1; | |||||||
69 | use constant SAME_AS_LAST => 2; | |||||||
70 | use constant LAST_TWO_MONTHS => 3; | |||||||
71 | ||||||||
72 | =head2 new ( %config ) | |||||||
73 | ||||||||
74 | Creates a new instance. | |||||||
75 | ||||||||
76 | C<%config> keys: | |||||||
77 | ||||||||
78 | =over 3 | |||||||
79 | ||||||||
80 | =item | |||||||
81 | B |
|||||||
82 | ||||||||
83 | Consumer id of Mizuho Direct ( お客さま番号 ) | |||||||
84 | ||||||||
85 | =item | |||||||
86 | B |
|||||||
87 | ||||||||
88 | Password for your consumer_id | |||||||
89 | ||||||||
90 | =item | |||||||
91 | B |
|||||||
92 | ||||||||
93 | Hash reference paired with: Key as Question, Value as Answer | |||||||
94 | ||||||||
95 | =back | |||||||
96 | ||||||||
97 | =cut | |||||||
98 | ||||||||
99 | sub new { | |||||||
100 | my $class = shift; | |||||||
101 | my $self = bless { @_ }, $class; | |||||||
102 | $self; | |||||||
103 | } | |||||||
104 | ||||||||
105 | =head2 consumer_id | |||||||
106 | ||||||||
107 | =cut | |||||||
108 | sub consumer_id { shift->{consumer_id} } | |||||||
109 | ||||||||
110 | =head2 accounts | |||||||
111 | ||||||||
112 | returns array of L |
|||||||
113 | ||||||||
114 | =cut | |||||||
115 | ||||||||
116 | sub accounts { | |||||||
117 | my $self = shift; | |||||||
118 | return $self->{accounts} if $self->{accounts}; | |||||||
119 | return [] unless $self->login; | |||||||
120 | $self->parse_accounts( $self->get_content( $self->list_url ) ); | |||||||
121 | } | |||||||
122 | ||||||||
123 | =head2 account_by_number ( $number ) | |||||||
124 | ||||||||
125 | returns an instance of L |
|||||||
126 | ||||||||
127 | =cut | |||||||
128 | ||||||||
129 | sub account_by_number { | |||||||
130 | my ( $self, $number ) = @_; | |||||||
131 | my @accounts = @{ $self->accounts }; | |||||||
132 | return unless @accounts && $number; | |||||||
133 | foreach my $account ( @accounts ) { | |||||||
134 | return $account if $account->number eq $number; | |||||||
135 | } | |||||||
136 | } | |||||||
137 | ||||||||
138 | =head2 get_ofx ( $account_or_number , $term ) | |||||||
139 | ||||||||
140 | C<$account_or_number>: | |||||||
141 | an instance of L |
|||||||
142 | ||||||||
143 | C<$term> : | |||||||
144 | ||||||||
145 | =over 3 | |||||||
146 | ||||||||
147 | =item L | |||||||
148 | ||||||||
149 | =item L | |||||||
150 | ||||||||
151 | =item L | |||||||
152 | ||||||||
153 | =back | |||||||
154 | ||||||||
155 | returns list of hash references, parsed by L |
|||||||
156 | ||||||||
157 | =cut | |||||||
158 | sub get_ofx { | |||||||
159 | my $self = shift; | |||||||
160 | ||||||||
161 | Finance::OFX::Parse::Simple->parse_scalar($self->get_raw_ofx(@_)); | |||||||
162 | } | |||||||
163 | ||||||||
164 | =head2 get_raw_ofx ( $account_or_number , $term ) | |||||||
165 | ||||||||
166 | arguments are same as L. | |||||||
167 | ||||||||
168 | returns OFX content as scalar | |||||||
169 | ||||||||
170 | =cut | |||||||
171 | sub get_raw_ofx { | |||||||
172 | my ($self, $account, $term) = @_; | |||||||
173 | ||||||||
174 | $term ||= CONTINUATION_FROM_LAST; | |||||||
175 | ||||||||
176 | if( $term !~ /^(1|2|3)$/ ) { | |||||||
177 | carp( 'Invalid value to $term:'. $term ); | |||||||
178 | return 0; | |||||||
179 | } | |||||||
180 | ||||||||
181 | my $val; | |||||||
182 | $account = $self->account_by_number( $account ) | |||||||
183 | if( ref($account) ne 'Finance::Bank::JP::Mizuho::Account' ); | |||||||
184 | ||||||||
185 | unless( $account ) { | |||||||
186 | carp( 'No account' ); | |||||||
187 | return 0; | |||||||
188 | } | |||||||
189 | ||||||||
190 | $self->get_content( $self->ref_url ); | |||||||
191 | ||||||||
192 | my $content = $self->get_content( $self->list_url ); | |||||||
193 | my $emfpostkey = $self->emfpostkey( $content ); | |||||||
194 | my $action = $self->form1_action( $content ); | |||||||
195 | ||||||||
196 | unless( $emfpostkey && $action ) { | |||||||
197 | carp( 'Failed to parse page' ); | |||||||
198 | return 0; | |||||||
199 | } | |||||||
200 | ||||||||
201 | my $res = $self->ua->post( | |||||||
202 | $action, | |||||||
203 | Referer => $self->list_url, | |||||||
204 | Content => [ | |||||||
205 | Token => '', | |||||||
206 | REDISP => 'OFF', | |||||||
207 | NLS => 'JP', | |||||||
208 | EMFPOSTKEY => $emfpostkey, | |||||||
209 | SelectRadio => $account->radio_value, | |||||||
210 | DownhaniBox => $term, | |||||||
211 | Next => 'Yippee!', | |||||||
212 | ], | |||||||
213 | ); | |||||||
214 | ||||||||
215 | my $dest = $res->header('location'); | |||||||
216 | ||||||||
217 | unless( $dest ) { | |||||||
218 | carp( 'Query fail' ); | |||||||
219 | return 0; | |||||||
220 | } | |||||||
221 | ||||||||
222 | $content = $self->get_content( $dest ); | |||||||
223 | $emfpostkey = $self->emfpostkey( $content ); | |||||||
224 | $action = $self->form1_action( $content ); | |||||||
225 | ||||||||
226 | $res = $self->ua->post( | |||||||
227 | $action, | |||||||
228 | Referer => $action, | |||||||
229 | Content => [ | |||||||
230 | Token => '', | |||||||
231 | NLS => 'JP', | |||||||
232 | EMFPOSTKEY => $emfpostkey, | |||||||
233 | ], | |||||||
234 | ); | |||||||
235 | ||||||||
236 | $dest = $res->header('location'); | |||||||
237 | unless( $dest ) { | |||||||
238 | carp( 'Query fail' ); | |||||||
239 | return 0; | |||||||
240 | } | |||||||
241 | ||||||||
242 | $content = $self->get_content( $dest ); | |||||||
243 | $emfpostkey = $self->emfpostkey( $content ); | |||||||
244 | $action = $self->form1_action( $content ); | |||||||
245 | ||||||||
246 | my $ofx = $self->ua->get( $self->ofx_url )->content; | |||||||
247 | ||||||||
248 | $res = $self->ua->post( | |||||||
249 | $action, | |||||||
250 | Referer => $action, | |||||||
251 | Content => [ | |||||||
252 | Token => '', | |||||||
253 | NLS => 'JP', | |||||||
254 | EMFPOSTKEY => $emfpostkey, | |||||||
255 | ], | |||||||
256 | ); | |||||||
257 | ||||||||
258 | $ofx | |||||||
259 | } | |||||||
260 | ||||||||
261 | =head2 host | |||||||
262 | ||||||||
263 | returns random host, provided by Mizuho Direct web service. | |||||||
264 | ||||||||
265 | =cut | |||||||
266 | sub host { | |||||||
267 | my $self = shift; | |||||||
268 | if(@_) { | |||||||
269 | my $host = shift; | |||||||
270 | $self->ua->default_headers->header( | |||||||
271 | Origin => "https://$host", | |||||||
272 | Host => $host, | |||||||
273 | ); | |||||||
274 | $self->{host} = $host; | |||||||
275 | } | |||||||
276 | return $self->{host} if $self->{host}; | |||||||
277 | $self->{host} || 'web.ib.mizuhobank.co.jp' | |||||||
278 | } | |||||||
279 | ||||||||
280 | ||||||||
281 | =head2 logged_in | |||||||
282 | ||||||||
283 | returns this instance has logged in | |||||||
284 | ||||||||
285 | =cut | |||||||
286 | sub logged_in { | |||||||
287 | my $self = shift; | |||||||
288 | $self->{logged_in} = shift if @_; | |||||||
289 | $self->{logged_in}; | |||||||
290 | } | |||||||
291 | ||||||||
292 | ||||||||
293 | =head2 login | |||||||
294 | ||||||||
295 | returns logged in successfully. | |||||||
296 | ||||||||
297 | calling this method is not neccesary. | |||||||
298 | ||||||||
299 | =cut | |||||||
300 | ||||||||
301 | sub login { | |||||||
302 | my $self = shift; | |||||||
303 | return 1 if $self->logged_in; | |||||||
304 | my $url = $self->login_url2; | |||||||
305 | ($url=~m{xtr=Emf00005}) ? | |||||||
306 | $self->_login($url) : | |||||||
307 | $self->_question($url); | |||||||
308 | } | |||||||
309 | ||||||||
310 | =head2 logout | |||||||
311 | ||||||||
312 | if you leave the process without calling this method, | |||||||
313 | the account will be locked for about 10 minutes, | |||||||
314 | and you will not able to access the web service. | |||||||
315 | ||||||||
316 | =cut | |||||||
317 | ||||||||
318 | sub logout { | |||||||
319 | my $self = shift; | |||||||
320 | return unless $self->logged_in; | |||||||
321 | my $res = $self->ua->get($self->logout_url); | |||||||
322 | $self->logged_in(0); | |||||||
323 | } | |||||||
324 | ||||||||
325 | =head2 password | |||||||
326 | ||||||||
327 | =cut | |||||||
328 | sub password { shift->{password} } | |||||||
329 | ||||||||
330 | =head2 questions | |||||||
331 | ||||||||
332 | =cut | |||||||
333 | sub questions { shift->{questions} } | |||||||
334 | ||||||||
335 | =head2 ua | |||||||
336 | ||||||||
337 | =cut | |||||||
338 | ||||||||
339 | sub ua { | |||||||
340 | shift->{ua} ||= LWP::UserAgent->new( | |||||||
341 | agent => USER_AGENT, | |||||||
342 | cookie_jar => HTTP::Cookies->new, | |||||||
343 | max_redirect => 0, | |||||||
344 | requests_redirectable => [], | |||||||
345 | ) | |||||||
346 | } | |||||||
347 | ||||||||
348 | ## private __________________________________________________________________________________________ | |||||||
349 | ||||||||
350 | sub login_url1 { 'https://'. shift->host .'/servlet/mib?xtr=Emf00000' } | |||||||
351 | sub logout_url { 'https://'. shift->host . ':443/servlet/mib?xtr=EmfLogOff&NLS=JP' } | |||||||
352 | sub ref_url { 'https://'. shift->host . '/servlet/mib?xtr=Emf04000&NLS=JP' } | |||||||
353 | sub list_url { 'https://'. shift->host . '/servlet/mib?xtr=Emf04610&NLS=JP' } | |||||||
354 | sub ofx_url { 'https://'. shift->host . ':443/servlet/mib?xtr=Emf04625' } | |||||||
355 | ||||||||
356 | sub login_url2 { | |||||||
357 | my $self = shift; | |||||||
358 | my $action = $self->form1_action($self->get_content($self->login_url1)); | |||||||
359 | my $res = $self->ua->post( $action, [ | |||||||
360 | pm_fp => '', | |||||||
361 | KeiyakuNo => $self->consumer_id, | |||||||
362 | Next => 'Yippee!', | |||||||
363 | ]); | |||||||
364 | my $url = $res->header('location') || ''; | |||||||
365 | $self->host($1) if $url =~ m%^https://([^/\:]+).*%; | |||||||
366 | $url; | |||||||
367 | } | |||||||
368 | ||||||||
369 | sub _question { | |||||||
370 | my ($self,$url) = @_; | |||||||
371 | my $content = $self->get_content($url); | |||||||
372 | my $action = $self->form1_action($content); | |||||||
373 | my ( $question, $answer ); | |||||||
374 | unless( $question = $self->parse_question($content) ) { | |||||||
375 | carp('Failed to parse question screen'); | |||||||
376 | return 0; | |||||||
377 | } | |||||||
378 | unless( $answer = $self->questions->{$question} ) { | |||||||
379 | carp("No answer for '$question'"); | |||||||
380 | return 0; | |||||||
381 | } | |||||||
382 | my $res = $self->ua->post( $action, [ | |||||||
383 | rskAns => encode(ENCODING, decode('utf8', $answer) ), | |||||||
384 | Next => 'Yippee!', | |||||||
385 | NLS => 'JP', | |||||||
386 | Token => '', | |||||||
387 | jsAware => 'on', | |||||||
388 | frmScrnID => 'Emf00000', | |||||||
389 | ]); | |||||||
390 | my $dest = $res->header('location'); | |||||||
391 | unless($dest) { | |||||||
392 | carp('Login failure'); | |||||||
393 | return 0; | |||||||
394 | } | |||||||
395 | $dest eq $url ? | |||||||
396 | $self->_question($url) : | |||||||
397 | $self->_login($dest); | |||||||
398 | } | |||||||
399 | ||||||||
400 | sub _login { | |||||||
401 | my ($self,$url) = @_; | |||||||
402 | my $content = $self->get_content($url); | |||||||
403 | my $action = $self->form1_action($content); | |||||||
404 | my $res = $self->ua->post( $action, [ | |||||||
405 | NLS => 'JP', | |||||||
406 | jsAware => 'on', | |||||||
407 | pmimg => '0', | |||||||
408 | Anshu1No => $self->password, | |||||||
409 | login => 'Yippee!', | |||||||
410 | ]); | |||||||
411 | my $dest = $res->header('location'); | |||||||
412 | return 0 unless $dest; | |||||||
413 | $self->logged_in(1); | |||||||
414 | 1 | |||||||
415 | } | |||||||
416 | ||||||||
417 | sub parse_question { | |||||||
418 | my ($self,$content) = @_; | |||||||
419 | return $1 if( ( $content || '' ) =~ /.* | .+[^\n\r]*[\n\r].* ]*>([^<]+)<.*/i ); |
||||||
420 | '' | |||||||
421 | } | |||||||
422 | ||||||||
423 | sub parse_accounts { | |||||||
424 | my ($self,$content) = @_; | |||||||
425 | $content =~ s/[\s"\r\n\t]//g; | |||||||
426 | my $re = | |||||||
427 | q{ | }.
|||||||
428 | q{ ]*> ([^<]+) | }.
|||||||
429 | q{ ]*> ([^<]+) | }.
|||||||
430 | q{ ]*>(\d+) | }.
|||||||
431 | q{ ]*>([^<]+) | };
|||||||
432 | ||||||||
433 | my @tr = split /TR> | |||||||
434 | my @accounts = (); | |||||||
435 | my $tz = 'Asia/Tokyo'; | |||||||
436 | foreach my $t (@tr) { | |||||||
437 | if($t =~ /$re/i) { | |||||||
438 | my $obj = { | |||||||
439 | radio_value => $1, | |||||||
440 | branch => $2, | |||||||
441 | type => $3, | |||||||
442 | number => $4, | |||||||
443 | }; | |||||||
444 | my $d = $5; | |||||||
445 | my ($start, $end); | |||||||
446 | if( $d =~ /(\d{4})\.(\d{2})\.(\d{2})[^\d]+(\d{4})\.(\d{2})\.(\d{2})/ ) { | |||||||
447 | $start = DateTime->new( | |||||||
448 | year => $1, | |||||||
449 | month => $2, | |||||||
450 | day => $3, | |||||||
451 | time_zone => $tz, | |||||||
452 | ); | |||||||
453 | $end = DateTime->new( | |||||||
454 | year => $4, | |||||||
455 | month => $5, | |||||||
456 | day => $6, | |||||||
457 | time_zone => $tz, | |||||||
458 | ); | |||||||
459 | } elsif( $d =~ /(\d{4})\.(\d{2})\.(\d{2})/ ) { | |||||||
460 | $start = DateTime->new( | |||||||
461 | year => $1, | |||||||
462 | month => $2, | |||||||
463 | day => $3, | |||||||
464 | time_zone => $tz, | |||||||
465 | ); | |||||||
466 | } | |||||||
467 | $end ||= $start; | |||||||
468 | $obj->{last_downloaded_from} = $start if $start; | |||||||
469 | $obj->{last_downloaded_to} = $end if $end; | |||||||
470 | push @accounts, Finance::Bank::JP::Mizuho::Account->new(%$obj); | |||||||
471 | } | |||||||
472 | } | |||||||
473 | $self->{accounts} = [@accounts]; | |||||||
474 | } | |||||||
475 | ||||||||
476 | sub form1_action { | |||||||
477 | my ($self,$content) = @_; | |||||||
478 | return $1 if $content =~ /.*action="([^"]+)"[^\n\r]+name="FORM1".*/ig; | |||||||
479 | return $1 if $content =~ /.*name="FORM1"[^\n\r]+action="([^"]+)".*/ig; | |||||||
480 | '' | |||||||
481 | } | |||||||
482 | ||||||||
483 | sub emfpostkey { | |||||||
484 | my ($self,$content) = @_; | |||||||
485 | return $1 if ( $content =~ / |
|||||||
486 | '' | |||||||
487 | } | |||||||
488 | ||||||||
489 | sub get_content { | |||||||
490 | my ($self,$url) = @_; | |||||||
491 | my $res = $self->ua->get($url); | |||||||
492 | $self->ua->default_headers->header( Referer => $url ); | |||||||
493 | encode('utf8', decode(ENCODING,$res->content) ); | |||||||
494 | } | |||||||
495 | ||||||||
496 | ||||||||
497 | ||||||||
498 | ||||||||
499 | 1 | |||||||
500 | ||||||||
501 | __END__ |