diff --git a/modules/bnporc/browser.py b/modules/bnporc/browser.py new file mode 100644 index 00000000..a6206f48 --- /dev/null +++ b/modules/bnporc/browser.py @@ -0,0 +1,128 @@ +# -*- coding: utf-8 -*- + +# Copyright(C) 2009-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 time + +from weboob.browser import LoginBrowser, URL, need_login +from weboob.capabilities.base import find_object +from weboob.capabilities.bank import AccountNotFound +from weboob.tools.json import json + +from .pages import LoginPage, AccountsPage, AccountsIBANPage, HistoryPage + + +__all__ = ['BNPParibasBrowser'] + + +class CompatMixin(object): + def __enter__(self): + return self + + def __exit__(self, type, value, tb): + pass + + +def JSON(data): + return ('json', data) + + +def isJSON(obj): + return type(obj) is tuple and obj and obj[0] == 'json' + + +class JsonBrowserMixin(object): + def open(self, *args, **kwargs): + if isJSON(kwargs.get('data')): + kwargs['data'] = json.dumps(kwargs['data'][1]) + if not 'headers' in kwargs: + kwargs['headers'] = {} + kwargs['headers']['Content-Type'] = 'application/json' + + return super(JsonBrowserMixin, self).open(*args, **kwargs) + + +class BNPParibasBrowser(CompatMixin, JsonBrowserMixin, LoginBrowser): + BASEURL_TEMPLATE = r'https://%s.bnpparibas.net/' + BASEURL = BASEURL_TEMPLATE % 'mabanque' + TIMEOUT = 30.0 + + login = URL(r'identification-wspl-pres/identification\?acceptRedirection=true×tamp=(?P)', + 'SEEA-pa01/devServer/seeaserver', + 'https://mabanqueprivee.bnpparibas.net/fr/espace-prive/comptes-et-contrats\?u=%2FSEEA-pa01%2FdevServer%2Fseeaserver', + LoginPage) + accounts = URL('udc-wspl/rest/getlstcpt', AccountsPage) + ibans = URL('rib-wspl/rpc/comptes', AccountsIBANPage) + history = URL('rop-wspl/rest/releveOp', HistoryPage) + + def switch(self, subdomain): + self.BASEURL = self.BASEURL_TEMPLATE % subdomain + + def do_login(self): + self.switch('mabanque') + timestamp = lambda: int(time.time() * 1e3) + self.login.go(timestamp=timestamp()) + if self.login.is_here(): + self.page.login(self.username, self.password) + + @need_login + def get_accounts_list(self): + ibans = self.ibans.go() + self.accounts.go() + assert self.accounts.is_here() + return self.page.iter_accounts(ibans.get_ibans_dict()) + + @need_login + def get_account(self, _id): + return find_object(self.get_accounts_list(), id=_id, error=AccountNotFound) + + @need_login + def iter_history(self, account, coming=False): + self.page = self.history.go(data=JSON({ + "ibanCrypte": account.id, + "pastOrPending": 1, + "triAV": 0, + "startDate": None, + "endDate": None + })) + return self.page.iter_coming() if coming else self.page.iter_history() + + @need_login + def iter_coming_operations(self, account): + return self.iter_history(account, coming=True) + + @need_login + def iter_investment(self, account): + raise NotImplementedError() + + @need_login + def get_transfer_accounts(self): + raise NotImplementedError() + + @need_login + def transfer(self, account, to, amount, reason): + raise NotImplementedError() + + @need_login + def iter_threads(self): + raise NotImplementedError() + + @need_login + def get_thread(self, thread): + raise NotImplementedError() diff --git a/modules/bnporc/module.py b/modules/bnporc/module.py index 654ab848..a6fad89b 100644 --- a/modules/bnporc/module.py +++ b/modules/bnporc/module.py @@ -28,6 +28,7 @@ from weboob.tools.value import ValueBackendPassword, Value from .deprecated.browser import BNPorc from .enterprise.browser import BNPEnterprise +from .browser import BNPParibasBrowser __all__ = ['BNPorcModule'] @@ -47,7 +48,9 @@ class BNPorcModule(Module, CapBank, CapMessages): # label='Password to set when the allowed uses are exhausted (6 digits)', # regexp='^(\d{6}|)$'), Value('website', label='Type de compte', default='pp', - choices={'pp': 'Particuliers/Professionnels', 'ent': 'Entreprises'})) + choices={'pp': 'Particuliers/Professionnels', + 'ent': 'Entreprises', + 'pp2': 'Particuliers/Professionnels (nouveau site)'})) STORAGE = {'seen': []} # Store the messages *list* for this duration @@ -59,7 +62,7 @@ class BNPorcModule(Module, CapBank, CapMessages): self._threads_age = datetime.utcnow() def create_default_browser(self): - b = {'pp': BNPorc, 'ent': BNPEnterprise} + b = {'pp': BNPorc, 'ent': BNPEnterprise, 'pp2': BNPParibasBrowser} self.BROWSER = b[self.config['website'].get()] #if self.config['rotating_password'].get().isdigit() and len(self.config['rotating_password'].get()) == 6: # rotating_password = self.config['rotating_password'].get() diff --git a/modules/bnporc/pages.py b/modules/bnporc/pages.py new file mode 100644 index 00000000..6a683c8e --- /dev/null +++ b/modules/bnporc/pages.py @@ -0,0 +1,215 @@ +# -*- coding: utf-8 -*- + +# Copyright(C) 2009-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 cStringIO import StringIO +from random import randint +from decimal import Decimal + +from weboob.browser.pages import JsonPage, LoggedPage +from weboob.tools.captcha.virtkeyboard import GridVirtKeyboard +from weboob.capabilities.bank import Account +from weboob.tools.capabilities.bank.transactions import FrenchTransaction as Transaction +from weboob.exceptions import BrowserIncorrectPassword +from weboob.tools.json import json +from weboob.tools.date import parse_french_date as Date + + +__all__ = ['LoginPage'] + + +def cast(x, typ, default=None): + try: + return typ(x or default) + except ValueError: + return default + + +class BNPKeyboard(GridVirtKeyboard): + color = (0x1f, 0x27, 0x28) + margin = 3, 3 + symbols = {'0': '43b2227b92e0546d742a1f087015e487', + '1': '2914e8cc694de26756096d0d0d4c6e0f', + '2': 'aac54304a7bb850805d29f54557be366', + '3': '0376d9f8419efee42e253d195a152547', + '4': '3719595f15b1ac1c5a73d84aa290b5f6', + '5': '617597f07a6530479927536671485439', + '6': '4f5dce7bd0d9213fdae54b79bb8dd33a', + '7': '49e07fa52b9bcee798f3a663f86e6cc1', + '8': 'c60b723b3d95a46416b34c2cbefba3ed', + '9': 'a13b8c3617a7bf854590833ddfb97f1f'} + + def __init__(self, page, image): + symbols = list('%02d' % x for x in range(1, 11)) + + super(BNPKeyboard, self).__init__(symbols, 5, 2, StringIO(image.content), self.color, convert='RGB') + self.check_symbols(self.symbols, page.browser.responses_dirname) + + +class LoginPage(JsonPage): + @staticmethod + def render_template(tmpl, **values): + for k, v in values.iteritems(): + tmpl = tmpl.replace('{{ ' + k + ' }}', v) + return tmpl + + @staticmethod + def generate_token(length=11): + chars = '0123456789ABCDEFGHIJKLMNOPQRSTUVWXTZabcdefghiklmnopqrstuvwxyz' + return ''.join((chars[randint(0, len(chars)-1)] for _ in xrange(length))) + + def build_doc(self, text): + try: + return super(LoginPage, self).build_doc(text) + except json.JSONDecodeError: + # XXX When login is successful, server sends HTML instead of JSON, + # we can ignore it. + return {} + + def on_load(self): + if self.url.startswith('https://mabanqueprivee.'): + self.browser.switch('mabanqueprivee') + + error = cast(self.get('errorCode'), int, 0) + + if error: + msg = self.get('message') + if error == 201: + raise BrowserIncorrectPassword(msg) + self.logger.debug('Unexpected error at login: "%s" (code=%s)' % (msg, error)) + + def login(self, username, password): + url = '/identification-wspl-pres/grille/%s' % self.get('data.grille.idGrille') + keyboard = self.browser.open(url) + vk = BNPKeyboard(self, keyboard) + + target = self.browser.BASEURL + 'SEEA-pa01/devServer/seeaserver' + user_agent = self.browser.session.headers.get('User-Agent') or '' + auth = self.render_template(self.get('data.authTemplate'), + idTelematique=username, + password=vk.get_string_code(password), + clientele=user_agent) + # XXX useless ? + csrf = self.generate_token() + + self.browser.location(target, data={'AUTH': auth, 'CSRF': csrf}) + + +class BNPPage(LoggedPage, JsonPage): + def build_doc(self, text): + return json.loads(text, parse_float=Decimal) + + def on_load(self): + code = cast(self.get('codeRetour'), int, 0) + + if code == -30: + self.logger.debug('End of session detected, try to relog...') + self.browser.do_login() + elif code: + self.logger.debug('Unexpected error: "%s" (code=%s)' % (self.get('message'), code)) + + +class AccountsPage(BNPPage): + FAMILY_TO_TYPE = { + 1: Account.TYPE_CHECKING, + 2: Account.TYPE_SAVINGS, + 3: Account.TYPE_DEPOSIT, + 4: Account.TYPE_MARKET, + 5: Account.TYPE_LIFE_INSURANCE, + } + + def iter_accounts(self, ibans): + for f in self.path('data.infoUdc.familleCompte.*'): + for a in f.get('compte'): + yield Account.from_dict({ + 'id': a.get('key'), + 'label': a.get('libellePersoProduit') or a.get('libelleProduit'), + 'currency': a.get('devise'), + 'type': self.FAMILY_TO_TYPE.get(f.get('idFamilleCompte')) or Account.TYPE_UNKNOWN, + 'balance': a.get('soldeDispo'), + 'coming': a.get('soldeDispo') + a.get('soldeAVenir'), + 'iban': ibans.get(a.get('key')) or a.get('value') + }) + + +class AccountsIBANPage(BNPPage): + def get_ibans_dict(self): + return dict((a.get('ibanCrypte'), a.get('iban')) for a in self.path('listeRib.*.infoCompte')) + + +class HistoryPage(BNPPage): + CODE_TO_TYPE = { + 1: Transaction.TYPE_CHECK, # Chèque émis + 2: Transaction.TYPE_CHECK, # Chèque reçu + 3: Transaction.TYPE_CASH_DEPOSIT, # Versement espèces + 4: Transaction.TYPE_ORDER, # Virements reçus + 5: Transaction.TYPE_ORDER, # Virements émis + 6: Transaction.TYPE_LOAN_PAYMENT, # Prélèvements / amortissements prêts + 7: Transaction.TYPE_CARD, # Paiements carte, + 8: Transaction.TYPE_CARD, # Carte / Formule BNP Net, + 9: Transaction.TYPE_UNKNOWN, # Opérations Titres + 10: Transaction.TYPE_UNKNOWN, # Effets de Commerce + 11: Transaction.TYPE_WITHDRAWAL, # Retraits d'espèces carte + 12: Transaction.TYPE_UNKNOWN, # Opérations avec l'étranger + 13: Transaction.TYPE_CARD, # Remises Carte + 14: Transaction.TYPE_WITHDRAWAL, # Retraits guichets + 15: Transaction.TYPE_BANK, # Intérêts/frais et commissions + 16: Transaction.TYPE_UNKNOWN, # Tercéo + 30: Transaction.TYPE_UNKNOWN, # Divers + } + + COMING_TYPE_TO_TYPE = { + 2: Transaction.TYPE_ORDER, # Prélèvement + 3: Transaction.TYPE_CHECK, # Chèque + 4: Transaction.TYPE_CARD, # Opération carte + } + + def one(self, path, context=None): + try: + return list(self.path(path, context))[0] + except IndexError: + return None + + def iter_history(self): + for op in self.get('data.listerOperations.compte.operationPassee') or []: + codeFamille = cast(self.one('operationType.codeFamille', op), int) + yield Transaction.from_dict({ + 'id': op.get('idOperation'), + 'date': Date(op.get('dateOperation')), + 'rdate': Date(self.one('montant.executionDate', op)), + 'vdate': Date(self.one('montant.valueDate', op)), + 'type': self.CODE_TO_TYPE.get(codeFamille) or Transaction.TYPE_UNKNOWN, + 'raw': op.get('libelleOperation'), + 'category': op.get('categorie'), + 'amount': self.one('montant.montant', op), + }) + + def iter_coming(self): + for op in self.path('data.listerOperations.compte.operationAvenir.*.operation.*'): + codeOperation = cast(op.get('codeOperation'), int, 0) + yield Transaction.from_dict({ + 'id': op.get('idOperation'), + 'date': Date(op.get('dateOperation')), + 'rdate': Date(op.get('executionDate')), + 'vdate': Date(op.get('valueDate')), + 'type': self.COMING_TYPE_TO_TYPE.get(codeOperation) or Transaction.TYPE_UNKNOWN, + 'raw': op.get('libelle'), + 'amount': op.get('montant'), + 'card': op.get('numeroPorteurCarte'), + })