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 00000000..4aa8c947
Binary files /dev/null and b/modules/axabanque/favicon.png differ
diff --git a/modules/axabanque/pages.py b/modules/axabanque/pages.py
new file mode 100644
index 00000000..e2f093fb
--- /dev/null
+++ b/modules/axabanque/pages.py
@@ -0,0 +1,236 @@
+# -*- 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 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))