From 6c1b826acab2e450808cb7a06cad18b12f69fbcc Mon Sep 17 00:00:00 2001 From: Romain Bignon Date: Thu, 3 Jan 2013 18:07:51 +0100 Subject: [PATCH] new module axabanque (closes #807) --- modules/axabanque/__init__.py | 23 ++++ modules/axabanque/backend.py | 61 +++++++++ modules/axabanque/browser.py | 98 ++++++++++++++ modules/axabanque/favicon.png | Bin 0 -> 2336 bytes modules/axabanque/pages.py | 236 ++++++++++++++++++++++++++++++++++ modules/axabanque/test.py | 30 +++++ 6 files changed, 448 insertions(+) create mode 100644 modules/axabanque/__init__.py create mode 100644 modules/axabanque/backend.py create mode 100644 modules/axabanque/browser.py create mode 100644 modules/axabanque/favicon.png create mode 100644 modules/axabanque/pages.py create mode 100644 modules/axabanque/test.py diff --git a/modules/axabanque/__init__.py b/modules/axabanque/__init__.py new file mode 100644 index 00000000..d07c869b --- /dev/null +++ b/modules/axabanque/__init__.py @@ -0,0 +1,23 @@ +# -*- coding: utf-8 -*- + +# Copyright(C) 2013 Romain Bignon +# +# This file is part of weboob. +# +# weboob is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# weboob is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with weboob. If not, see . + + +from .backend import AXABanqueBackend + +__all__ = ['AXABanqueBackend'] diff --git a/modules/axabanque/backend.py b/modules/axabanque/backend.py new file mode 100644 index 00000000..6e0eae7c --- /dev/null +++ b/modules/axabanque/backend.py @@ -0,0 +1,61 @@ +# -*- coding: utf-8 -*- + +# Copyright(C) 2013 Romain Bignon +# +# This file is part of weboob. +# +# weboob is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# weboob is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with weboob. If not, see . + + +from weboob.capabilities.bank import ICapBank, AccountNotFound +from weboob.tools.backend import BaseBackend, BackendConfig +from weboob.tools.value import ValueBackendPassword + +from .browser import AXABanque + + +__all__ = ['AXABanqueBackend'] + + +class AXABanqueBackend(BaseBackend, ICapBank): + NAME = 'axabanque' + MAINTAINER = u'Romain Bignon' + EMAIL = 'romain@weboob.org' + VERSION = '0.e' + DESCRIPTION = u'AXA Banque French bank website' + LICENSE = 'AGPLv3+' + CONFIG = BackendConfig(ValueBackendPassword('login', label='Account ID', regexp='\d+', masked=False), + ValueBackendPassword('password', label='Password', regexp='\d+')) + BROWSER = AXABanque + + def create_default_browser(self): + return self.create_browser(self.config['login'].get(), + self.config['password'].get()) + + def iter_accounts(self): + with self.browser: + return self.browser.get_accounts_list() + + def get_account(self, _id): + with self.browser: + account = self.browser.get_account(_id) + + if account: + return account + else: + raise AccountNotFound() + + def iter_history(self, account): + with self.browser: + return self.browser.get_history(account) diff --git a/modules/axabanque/browser.py b/modules/axabanque/browser.py new file mode 100644 index 00000000..69173eff --- /dev/null +++ b/modules/axabanque/browser.py @@ -0,0 +1,98 @@ +# -*- coding: utf-8 -*- + +# Copyright(C) 2013 Romain Bignon +# +# This file is part of weboob. +# +# weboob is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# weboob is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with weboob. If not, see . + + +import urllib + +from weboob.tools.browser import BaseBrowser, BrowserIncorrectPassword + +from .pages import LoginPage, AccountsPage, TransactionsPage, UnavailablePage + + +__all__ = ['AXABanque'] + + +class AXABanque(BaseBrowser): + PROTOCOL = 'https' + DOMAIN = 'www.axabanque.fr' + PAGES = {'https?://www.axabanque.fr/connexion/index.html': LoginPage, + 'https?://www.axabanque.fr/login_errors/indisponibilite.*': UnavailablePage, + 'https?://www.axabanque.fr/.*page-indisponible.html.*': UnavailablePage, + 'https?://www.axabanque.fr/transactionnel/client/liste-comptes.html': AccountsPage, + 'https?://www.axabanque.fr/webapp/axabanque/jsp/panorama.faces': TransactionsPage, + 'https?://www.axabanque.fr/webapp/axabanque/jsp/detail.*.faces': TransactionsPage, + } + + def is_logged(self): + return self.page is not None and not self.is_on_page(LoginPage) + + def home(self): + if self.is_logged(): + self.location('/transactionnel/client/liste-comptes.html') + else: + self.login() + + def login(self): + """ + Attempt to log in. + Note: this method does nothing if we are already logged in. + """ + assert isinstance(self.username, basestring) + assert isinstance(self.password, basestring) + + if self.is_logged(): + return + + if not self.is_on_page(LoginPage): + self.location('/connexion/index.html', no_login=True) + + self.page.login(self.username, self.password) + + if not self.is_logged(): + raise BrowserIncorrectPassword() + + def get_accounts_list(self): + if not self.is_on_page(AccountsPage): + self.location('/transactionnel/client/liste-comptes.html') + return self.page.get_list() + + def get_account(self, id): + assert isinstance(id, basestring) + + l = self.get_accounts_list() + for a in l: + if a.id == id: + return a + + return None + + def get_history(self, account): + if not self.is_on_page(AccountsPage): + account = self.get_account(account.id) + + args = account._args + args['javax.faces.ViewState'] = self.page.get_view_state() + self.location('/webapp/axabanque/jsp/panorama.faces', urllib.urlencode(args)) + + assert self.is_on_page(TransactionsPage) + + self.page.more_history() + + assert self.is_on_page(TransactionsPage) + return self.page.get_history() diff --git a/modules/axabanque/favicon.png b/modules/axabanque/favicon.png new file mode 100644 index 0000000000000000000000000000000000000000..4aa8c947abba4cc603adb7833c5c76883afb37eb GIT binary patch literal 2336 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. + + +import urllib +from decimal import Decimal +import re +import tempfile + +from weboob.tools.browser import BasePage as _BasePage, BrowserUnavailable, BrokenPageError +from weboob.capabilities.bank import Account +from weboob.tools.capabilities.bank.transactions import FrenchTransaction +from weboob.tools.captcha.virtkeyboard import MappedVirtKeyboard + + +__all__ = ['LoginPage', 'AccountsPage', 'TransactionsPage', 'UnavailablePage'] + + +class BasePage(_BasePage): + def get_view_state(self): + return self.document.xpath('//input[@name="javax.faces.ViewState"]')[0].attrib['value'] + + +class UnavailablePage(BasePage): + def on_loaded(self): + raise BrowserUnavailable() + +class VirtKeyboard(MappedVirtKeyboard): + symbols={'0':'f47e48cfdf3abc6716a6b0aadf8eebe3', + '1':'3495abaf658dc550e51c5c92ea56b60b', + '2':'f57e7c70ddffb71d0efcc42f534165ae', + '3':'bd08ced5162b033175e8cd37516c8258', + '4':'45893a475208cdfc66cd83abde69b8d8', + '5':'110008203b716a0de4fdacd7dc7666e6', + '6':'4e7e8808d8f4eb22f1ee4086cbd02dcb', + '7':'f92adf323b0b128a48a24b16ca10ec1e', + '8':'0283c4e25656aed61a39117247f0d3f1', + '9':'3de7491bba71baa8bed99ef624094af8' + } + + color=(0x28, 0x41, 0x55) + + def check_color(self, pixel): + # only dark blue pixels. + return (pixel[0] < 100 and pixel[1] < 100 and pixel[2] < 200) + + def __init__(self, page): + img = page.document.find("//img[@usemap='#mapPave']") + img_file = page.browser.openurl(img.attrib['src']) + MappedVirtKeyboard.__init__(self, img_file, page.document, img, self.color) + + if page.browser.responses_dirname is None: + page.browser.responses_dirname = tempfile.mkdtemp(prefix='weboob_session_') + self.check_symbols(self.symbols, page.browser.responses_dirname) + + def get_symbol_code(self,md5sum): + code = MappedVirtKeyboard.get_symbol_code(self,md5sum) + return code[-3:-2] + + def get_string_code(self,string): + code = '' + for c in string: + code += self.get_symbol_code(self.symbols[c]) + return code + + +class LoginPage(BasePage): + def login(self, login, password): + vk = VirtKeyboard(self) + + form = self.document.xpath('//form[@name="_idJsp0"]')[0] + args = {'login': login.encode(self.browser.ENCODING), + 'codepasse': vk.get_string_code(password), + 'motDePasse': vk.get_string_code(password), + '_idJsp0_SUBMIT': 1, + '_idJsp0:_idcl': '', + '_idJsp0:_link_hidden_': '', + } + self.browser.location(form.attrib['action'], urllib.urlencode(args), no_login=True) + + +class AccountsPage(BasePage): + ACCOUNT_TYPES = {'courant-titre': Account.TYPE_CHECKING, + } + + def js2args(self, s): + args = {} + # For example: + # noDoubleClic(this);;return oamSubmitForm('idPanorama','idPanorama:tableaux-comptes-courant-titre:0:tableaux-comptes-courant-titre-cartes:0:_idJsp321',null,[['paramCodeProduit','9'],['paramNumContrat','12234'],['paramNumCompte','12345678901'],['paramNumComptePassage','1234567890123456']]); + for sub in re.findall("\['([^']+)','([^']+)'\]", s): + args[sub[0]] = sub[1] + + args['idPanorama:_idcl'] = re.search("'(idPanorama:[^']+)'", s).group(1) + args['idPanorama_SUBMIT'] = 1 + + return args + + def get_list(self): + for table in self.document.getroot().cssselect('div#table-panorama table.table-client'): + account = Account() + + link = table.xpath('./tfoot//a')[0] + if not 'onclick' in link.attrib: + continue + + args = self.js2args(link.attrib['onclick']) + + account.id = args['paramNumCompte'] + account.label = unicode(table.xpath('./caption')[0].text.strip()) + + account_type_str = table.attrib['class'].split(' ')[-1][len('tableaux-comptes-'):] + account.type = self.ACCOUNT_TYPES.get(account_type_str, Account.TYPE_UNKNOWN) + + currency_title = table.xpath('./thead//th[@class="montant"]')[0].text.strip() + m = re.match('Montant \((\w+)\)', currency_title) + if not m: + self.logger.warning('Unable to parse currency %r' % currency_title) + else: + account.currency = account.get_currency(m.group(1)) + + tds = table.xpath('./tbody/tr')[0].findall('td') + + account.balance = Decimal(FrenchTransaction.clean_amount(u''.join([txt.strip() for txt in tds[-1].itertext()]))) + account._args = args + yield account + +class Transaction(FrenchTransaction): + PATTERNS = [(re.compile('^RET(RAIT) DAB (?P
\d{2})/(?P\d{2}) (?P.*)'), + FrenchTransaction.TYPE_WITHDRAWAL), + (re.compile('^(CARTE|CB ETRANGER) (?P
\d{2})/(?P\d{2}) (?P.*)'), + FrenchTransaction.TYPE_CARD), + (re.compile('^(?PVIR(EMEN)?T? (SEPA)?(RECU|FAVEUR)?)( /FRM)?(?P.*)'), + FrenchTransaction.TYPE_TRANSFER), + (re.compile('^PRLV (?P.*)( \d+)?$'), FrenchTransaction.TYPE_ORDER), + (re.compile('^(CHQ|CHEQUE) .*$'), FrenchTransaction.TYPE_CHECK), + (re.compile('^(AGIOS /|FRAIS) (?P.*)'), FrenchTransaction.TYPE_BANK), + (re.compile('^(CONVENTION \d+ |F )?COTIS(ATION)? (?P.*)'), + FrenchTransaction.TYPE_BANK), + (re.compile('^REMISE (?P.*)'), FrenchTransaction.TYPE_DEPOSIT), + (re.compile('^(?P.*)( \d+)? QUITTANCE .*'), + FrenchTransaction.TYPE_ORDER), + (re.compile('^.* LE (?P
\d{2})/(?P\d{2})/(?P\d{2})$'), + FrenchTransaction.TYPE_UNKNOWN), + ] + + +class TransactionsPage(BasePage): + COL_DATE = 0 + COL_TEXT = 1 + COL_DEBIT = 2 + COL_CREDIT = 3 + + def more_history(self): + link = None + for a in self.document.xpath('.//a'): + if a.text is not None and a.text.strip() == 'Sur les 6 derniers mois': + link = a + break + + if link is None: + # this is a check account + args = {'categorieMouvementSelectionnePagination': 'afficherTout', + 'nbLigneParPageSelectionneHautPagination': -1, + 'nbLigneParPageSelectionneBasPagination': -1, + 'periodeMouvementSelectionneComponent': '', + 'categorieMouvementSelectionneComponent': '', + 'nbLigneParPageSelectionneComponent': -1, + 'idDetail:btnRechercherParNbLigneParPage': '', + 'idDetail_SUBMIT': 1, + 'paramNumComptePassage': '', + 'codeEtablissement': '', + 'paramNumCodeSousProduit': '', + 'idDetail:_idcl': '', + 'idDetail:scroll_banqueHaut': '', + 'paramNumContrat': '', + 'paramCodeProduit': '', + 'paramNumCompte': '', + 'codeAgence': '', + 'idDetail:_link_hidden_': '', + 'paramCodeFamille': '', + 'javax.faces.ViewState': self.get_view_state(), + } + else: + # something like a PEA or so + value = link.attrib['id'] + id = value.split(':')[0] + args = {'%s:_idcl' % id: value, + '%s:_link_hidden_' % id: '', + '%s_SUBMIT' % id: 1, + 'javax.faces.ViewState': self.get_view_state(), + 'paramNumCompte': '', + } + + form = self.document.xpath('//form')[-1] + self.browser.location(form.attrib['action'], urllib.urlencode(args)) + + def get_history(self): + tables = self.document.xpath('//table[@id="table-detail-operation"]') + if len(tables) == 0: + tables = self.document.xpath('//table[@id="table-detail"]') + if len(tables) == 0: + tables = self.document.getroot().cssselect('table.table-detail') + if len(tables) == 0: + raise BrokenPageError('Unable to find table?') + + for tr in tables[0].xpath('.//tr'): + tds = tr.findall('td') + if len(tds) < 4: + continue + + t = Transaction(0) + date = u''.join([txt.strip() for txt in tds[self.COL_DATE].itertext()]) + raw = u''.join([txt.strip() for txt in tds[self.COL_TEXT].itertext()]) + debit = u''.join([txt.strip() for txt in tds[self.COL_DEBIT].itertext()]) + credit = u''.join([txt.strip() for txt in tds[self.COL_CREDIT].itertext()]) + + t.parse(date, re.sub(r'[ ]+', ' ', raw)) + t.set_amount(credit, debit) + + yield t diff --git a/modules/axabanque/test.py b/modules/axabanque/test.py new file mode 100644 index 00000000..b170710e --- /dev/null +++ b/modules/axabanque/test.py @@ -0,0 +1,30 @@ +# -*- coding: utf-8 -*- + +# Copyright(C) 2013 Romain Bignon +# +# This file is part of weboob. +# +# weboob is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# weboob is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with weboob. If not, see . + + +from weboob.tools.test import BackendTest + +class AXABanqueTest(BackendTest): + BACKEND = 'axabanque' + + def test_axabanque(self): + l = list(self.backend.iter_accounts()) + if len(l) > 0: + a = l[0] + list(self.backend.iter_history(a))