| 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__ |