package Finance::Bank::LaPoste; use strict; use Carp qw(carp croak); use HTTP::Cookies; use LWP::UserAgent; use HTML::Parser; use HTML::Form; use Digest::MD5(); our $VERSION = '7.01'; # $Id: $ # $Log: LaPoste.pm,v $ =pod =head1 NAME Finance::Bank::LaPoste - Check your "La Poste" accounts from Perl =head1 SYNOPSIS use Finance::Bank::LaPoste; my @accounts = Finance::Bank::LaPoste->check_balance( username => "0120123456L", # your main account is something like # 0123456 L 012, stick it together with the region first password => "123456", # a password is usually 6 numbers ); foreach my $account (@accounts) { print "Name: ", $account->name, " Account_no: ", $account->account_no, "\n", "*" x 80, "\n"; print $_->as_string, "\n" foreach $account->statements; } =head1 DESCRIPTION This module provides a read-only interface to the Videoposte online banking system at L. You will need either Crypt::SSLeay installed. The interface of this module is similar to other Finance::Bank::* modules. =head1 WARNING This is code for B, and that means B, and that means B. You are encouraged, nay, expected, to audit the source of this module yourself to reassure yourself that I am not doing anything untoward with your banking data. This software is useful to me, but is provided under B, explicit or implied. =cut my $parse_table = sub { my ($html) = @_; my $h = HTML::Parser->new; my (@l, $row, $td, $href); $h->report_tags('td', 'tr', 'a'); $h->handler(start => sub { my ($tag, $attr) = @_; if ($tag eq 'tr') { $row = []; } elsif ($tag eq 'td') { push @$row, ('') x ($attr->{colspan} - 1) if $attr->{colspan}; $td = ''; } elsif ($tag eq 'a') { $href = $attr->{href} if defined $td; } }, 'tag,attr'); $h->handler(end => sub { my ($tag) = @_; if ($tag eq '/tr') { push @l, $row if $row; undef $row; } elsif ($tag eq '/td' && defined $td) { $td =~ s/( | |\s)+/ /g; $td =~ s/^\s*//; $td =~ s/\s*$//; push @$row, $href ? [ $td, $href ] : $td; $href = $td = undef; } }, 'tag'); $h->handler(text => sub { my ($text) = @_; $td .= " $text" if defined $td; }, 'text'); $h->parse($html); \@l; }; my $normalize_number = sub { my ($s) = @_; $s =~ s/\xC2?\xA0//; # non breakable space, both in UTF8 and latin1 $s =~ s/ //; $s =~ s/,/./; $s + 0; # turn into a number }; =pod =head1 METHODS =head2 new(username => "0120123456L", password => "123456", feedback => sub { warn "Finance::Bank::LaPoste: $_[0]\n" }) Return an object . You can optionally provide to this method a LWP::UserAgent object (argument named "ua"). You can also provide a function used for feedback (useful for verbose mode or debugging) (argument named "feedback") =cut my $first_url = 'https://voscomptesenligne.labanquepostale.fr/voscomptes/canalXHTML/identif.ea?origin=particuliers'; my $base_url = 'https://voscomptesenligne.labanquepostale.fr/voscomptes/canalXHTML'; sub _login { my ($self) = @_; $self->{feedback}->("login") if $self->{feedback}; my $cookie_jar = HTTP::Cookies->new; my $response = $self->{ua}->request(HTTP::Request->new(GET => $first_url)); $cookie_jar->extract_cookies($response); $self->{ua}->cookie_jar($cookie_jar); my %mangling_map = _get_number_mangling_map($self); my $password = join('', map { $mangling_map{$_} } split('', $self->{password})); my $form = HTML::Form->parse($response->content, $first_url); $form->value(username => $self->{username}); $form->value(password => $password); push @{$self->{ua}->requests_redirectable}, 'POST'; $response = $self->{ua}->request($form->click); $response->is_success or die "login failed\n" . $response->error_as_HTML; } sub _output { my $f = shift; open(my $F, ">$f") or die "output in file $f failed: $!\n"; print $F $_ foreach @_; 1 } # to update %img_md5sum_to_number, set $debug_imgs to 1, # then rename /tmp/[a-j].gif into /tmp/[0-9].gif according to the image # then do "md5sum /tmp/[0-9].gif" my $debug_imgs = 0; my %img_md5sum_to_number = ( 'a02574d7bf67677d2a86b7bfc5e864fe' => 0, 'eb85e1cc45dd6bdb3cab65c002d7ac8a' => 1, '596e6fbd54d5b111fe5df8a4948e80a4' => 2, '9cdc989a4310554e7f5484d0d27a86ce' => 3, '0183943de6c0e331f3b9fc49c704ac6d' => 4, '291b9987225193ab1347301b241e2187' => 5, '163279f1a46082408613d12394e4042a' => 6, 'b0a9c740c4cada01eb691b4acda4daea' => 7, '3c4307ee92a1f3b571a3c542eafcb330' => 8, 'c5b854ae314b61ba42948909e0b2eae7' => 9, ); sub _get_number_mangling_map { my ($self) = @_; my $i = 0; map { my $md5sum = Digest::MD5::md5_hex($_); my $number = $img_md5sum_to_number{$md5sum}; defined $number or die "unknown md5sum $md5sum, please update \%img_md5sum_to_number\n"; $number => $i++; } _get_imgs($self); } sub _get_imgs { my ($self) = @_; my @imgs = map { my $url = 'https://voscomptesenligne.labanquepostale.fr/wsost/OstBrokerWeb/loginform?imgid=' . $_ . '&' . rand(); _GET_content($self, $url); } 0 .. 9; if ($debug_imgs) { my $i = 'a'; _output("/tmp/" . $i++ . ".gif", $_) foreach @imgs; } @imgs; } sub _GET_content { my ($self, $url) = @_; my $req = $self->{ua}->request(HTTP::Request->new(GET => $url)); $req->is_success or die "getting $url failed\n" . $req->error_as_HTML; $req->content; } sub _list_accounts { my ($self) = @_; $self->{feedback}->("list accounts") if $self->{feedback}; my $response = $self->{ua}->request(HTTP::Request->new(GET => "$base_url/releve/syntheseAssurancesEtComptes.ea")); $response->is_success or die "can't access account\n" . $response->error_as_HTML; if ($response->content =~ /frame src=".*liste_comptes.jsp"|{ua}->request(HTTP::Request->new(GET => "$base_url/releve/liste_comptes.jsp")); $response->is_success or die "can't access account\n" . $response->error_as_HTML; } my $accounts = $parse_table->($response->content); map { my ($account, $account_no, $balance) = grep { $_ ne '' } @$_; if (ref $account && $account_no) { my $url = $account->[1]; $url =~ s/typeRecherche=1$/typeRecherche=10/; # 400 last operations $url =~ s!/relevesCCP/\d-!/relevesCCP/!; # remove the unneeded number (otherwise one get an intermediate page) { name => $account->[0], account_no => $account_no, balance => $normalize_number->($balance), $url =~ /(releve_ccp|releve_cne|releve_cb|mouvementsCarteDD)\.ea/ ? (url => $url) : (), }; } else { () } } @$accounts; } sub new { my ($class, %opts) = @_; my $self = bless \%opts, $class; exists $self->{password} or croak "Must provide a password"; exists $self->{username} or croak "Must provide a username"; $self->{ua} ||= LWP::UserAgent->new; _login($self); $self; } sub default_account { die "default_account can't be used anymore"; } =pod =head2 check_balance(username => "0120123456L", password => "123456") Return a list of account (F::B::LaPoste::Account) objects, one for each of your bank accounts. =cut sub check_balance { my $self = &new; $self->{accounts} = [ _list_accounts($self) ]; map { Finance::Bank::LaPoste::Account->new($self, %$_) } @{$self->{accounts}}; } package Finance::Bank::LaPoste::Account; =pod =head1 Account methods =head2 sort_code() Return the sort code of the account. Currently, it returns an undefined value. =head2 name() Returns the human-readable name of the account. =head2 account_no() Return the account number, in the form C<0123456L012>. =head2 balance() Returns the balance of the account. =head2 statements() Return a list of Statement object (Finance::Bank::LaPoste::Statement). =head2 currency() Returns the currency of the account as a three letter ISO code (EUR, CHF, etc.). =cut sub new { my ($class, $bank, %account) = @_; $account{$_} = $bank->{$_} foreach qw(ua feedback); bless \%account, $class; } sub sort_code { undef } sub name { $_[0]{name} } sub account_no { $_[0]{account_no} } sub balance { $_[0]{balance} } sub currency { 'EUR' } sub statements { my ($self) = @_; $self->{url} or return; $self->{statements} ||= do { my $retry; retry: $self->{feedback}->("get statements") if $self->{feedback}; my $response = $self->{ua}->request(HTTP::Request->new(GET => "$base_url/releve/$self->{url}")); $response->is_success or die "can't access account $self->{name} statements\n" . $response->error_as_HTML; my $html = $response->content; if ($html =~ /Détail de vos cartes/ && !$retry) { my @l = $html =~ /a href="(3-mouvementsCarteDD.ea.*?)"/g; $self->{url} = "../relevesCB/" . $l[-1]; # taking last (??) $retry++; goto retry; } my ($solde_month, $year) = $html =~ /Solde\s+au\s+\d+\s+(\S+)\s+(20\d\d)/ ? ($1, $2) : $html =~ m!au \d\d/(\d\d)/(20\d\d)!; $self->{balance} ||= do { my ($balance) = $html =~ /(?:Solde|Encours\s+prélevé)\s+au.*?:\s+(.*?)\beuros/s; $balance =~ s/<.*?>\s*//g; # (since 24/06/2004) remove: or ... $normalize_number->($balance); }; my $l = $parse_table->($html); # drop first line which contain columns title @$l = map { my ($date, $description, $amount) = @$_; $date && $date =~ m!(\d+)/(\d+)! ? [ $date, $description, $amount ] : (); } @$l; my $prev_month = $solde_month eq 'janvier' || $solde_month eq '01' ? 1 : 12; [ map { my ($date, $description, $amount) = @$_; my ($day, $month) = $date =~ m|(\d+)/(\d+)|; $year-- if $month > $prev_month; $prev_month = $month; Finance::Bank::LaPoste::Statement->new(day => $day, month => $month, year => $year, description => $description, amount => $normalize_number->($amount)); } @$l ]; }; @{$self->{statements}}; } package Finance::Bank::LaPoste::Statement; =pod =head1 Statement methods =head2 date() Returns the date when the statement occured, in DD/MM/YY format. =head2 description() Returns a brief description of the statement. =head2 amount() Returns the amount of the statement (expressed in Euros or the account's currency). Although the Crédit Mutuel website displays number in continental format (i.e. with a coma as decimal separator), amount() returns a real number. =head2 as_string($separator) Returns a tab-delimited representation of the statement. By default, it uses a tabulation to separate the fields, but the user can provide its own separator. =cut sub new { my ($class, %statement) = @_; bless \%statement, $class; } sub description { $_[0]{description} } sub amount { $_[0]{amount} } sub date { my ($self) = @_; my ($year) = $self->{year} =~ /..(..)/; # only 2 digits for year "$year/$self->{month}/$self->{day}" } sub as_string { my ($self, $separator) = @_; join($separator || "\t", $self->date, $self->{description}, $self->{amount}); } 1; =pod =head1 COPYRIGHT Copyright 2002-2007, Pascal 'Pixel' Rigaux. All Rights Reserved. This module can be redistributed under the same terms as Perl itself. =head1 AUTHOR Thanks to Cédric Bouvier for Finance::Bank::CreditMut (and also to Simon Cozens and Briac Pilpré for various Finance::Bank::*) =head1 SEE ALSO Finance::Bank::BNPParibas, Finance::Bank::CreditMut =cut