diff --git a/modules/bred/__init__.py b/modules/bred/__init__.py new file mode 100644 index 00000000..3b649d6c --- /dev/null +++ b/modules/bred/__init__.py @@ -0,0 +1,23 @@ +# -*- coding: utf-8 -*- + +# Copyright(C) 2012 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 BredBackend + +__all__ = ['BredBackend'] diff --git a/modules/bred/backend.py b/modules/bred/backend.py new file mode 100644 index 00000000..b7cf3ce6 --- /dev/null +++ b/modules/bred/backend.py @@ -0,0 +1,70 @@ +# -*- coding: utf-8 -*- + +# Copyright(C) 2012 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 __future__ import with_statement + +from weboob.capabilities.bank import ICapBank, AccountNotFound +from weboob.tools.backend import BaseBackend, BackendConfig +from weboob.tools.value import ValueBackendPassword + +from .browser import BredBrowser + + +__all__ = ['BredBackend'] + + +class BredBackend(BaseBackend, ICapBank): + NAME = 'bred' + MAINTAINER = 'Romain Bignon' + EMAIL = 'romain@weboob.org' + VERSION = '0.d' + DESCRIPTION = u'Bred French bank website' + LICENSE = 'AGPLv3+' + CONFIG = BackendConfig(ValueBackendPassword('login', label='Account ID', masked=False), + ValueBackendPassword('password', label='Password of account')) + BROWSER = BredBrowser + + def create_default_browser(self): + return self.create_browser(self.config['login'].get(), self.config['password'].get()) + + def iter_accounts(self): + with self.browser: + for account in self.browser.get_accounts_list(): + yield account + + 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: + transactions = list(self.browser.get_history(account)) + transactions.sort(key=lambda tr: tr.rdate, reverse=True) + return [tr for tr in transactions if not tr._is_coming] + + def iter_coming(self, account): + with self.browser: + transactions = list(self.browser.get_card_operations(account)) + transactions.sort(key=lambda tr: tr.rdate, reverse=True) + return [tr for tr in transactions if tr._is_coming] diff --git a/modules/bred/browser.py b/modules/bred/browser.py new file mode 100644 index 00000000..5819dbcb --- /dev/null +++ b/modules/bred/browser.py @@ -0,0 +1,106 @@ +# -*- coding: utf-8 -*- + +# Copyright(C) 2012 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, LoginResultPage, AccountsPage, EmptyPage, TransactionsPage + + +__all__ = ['BredBrowser'] + + +class BredBrowser(BaseBrowser): + PROTOCOL = 'https' + DOMAIN = 'www.bred.fr' + PAGES = {'https://www.bred.fr/': LoginPage, + 'https://www.bred.fr/Andromede/MainAuth.*': LoginResultPage, + 'https://www.bred.fr/Andromede/Main': AccountsPage, + 'https://www.bred.fr/Andromede/Ecriture': TransactionsPage, + 'https://www.bred.fr/Andromede/applications/index.jsp': EmptyPage, + } + + def is_logged(self): + return self.page and not self.is_on_page(LoginPage) + + def home(self): + return self.location('https://www.bred.fr/') + + def login(self): + assert isinstance(self.username, basestring) + assert isinstance(self.password, basestring) + + if not self.is_on_page(LoginPage): + self.location('https://www.bred.fr/', no_login=True) + + self.page.login(self.username, self.password) + + assert self.is_on_page(LoginResultPage) + + error = self.page.get_error() + if error is not None: + raise BrowserIncorrectPassword(error) + + self.page.confirm() + + def get_accounts_list(self): + if not self.is_on_page(AccountsPage): + self.location('https://www.bred.fr/Andromede/Main') + 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 iter_transactions(self, id): + numero_compte, numero_poste = id.split('.') + data = {'typeDemande': 'recherche', + 'motRecherche': '', + 'numero_compte': numero_compte, + 'numero_poste': numero_poste, + 'detail': '', + 'tri': 'date', + 'sens': 'sort', + 'monnaie': 'EUR', + 'index_hist': 4 + } + self.location('https://www.bred.fr/Andromede/Ecriture', urllib.urlencode(data)) + + assert self.is_on_page(TransactionsPage) + return self.page.get_history() + + def get_history(self, account): + for tr in self.iter_transactions(account.id): + yield tr + + for tr in self.get_card_operations(account): + yield tr + + def get_card_operations(self, account): + for id in account._card_links: + for tr in self.iter_transactions(id): + yield tr diff --git a/modules/bred/favicon.png b/modules/bred/favicon.png new file mode 100644 index 00000000..2925f407 Binary files /dev/null and b/modules/bred/favicon.png differ diff --git a/modules/bred/pages.py b/modules/bred/pages.py new file mode 100644 index 00000000..dbc3538a --- /dev/null +++ b/modules/bred/pages.py @@ -0,0 +1,175 @@ +# -*- coding: utf-8 -*- + +# Copyright(C) 2012 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 decimal import Decimal +import re + +from weboob.tools.browser import BasePage +from weboob.tools.misc import to_unicode +from weboob.capabilities.bank import Account +from weboob.tools.capabilities.bank.transactions import FrenchTransaction + + +__all__ = ['LoginPage', 'LoginResultPage', 'AccountsPage', 'TransactionsPage', 'EmptyPage'] + + +class LoginPage(BasePage): + def login(self, login, passwd): + self.browser.select_form(name='authen') + self.browser['id'] = login + self.browser['pass'] = passwd + self.browser.submit(nologin=True) + +class LoginResultPage(BasePage): + def confirm(self): + self.browser.location('MainAuth?typeDemande=AC', no_login=True) + + def get_error(self): + error = self.document.xpath('//td[@class="txt_norm2"]/b') + if len(error) == 0: + return None + + return error[0].text.strip() + +class EmptyPage(BasePage): + pass + +class BredBasePage(BasePage): + def js2args(self, s): + cur_arg = None + args = {} + # For example: + # javascript:reloadApplication('nom_application', 'compte_telechargement', 'numero_poste', '000', 'numero_compte', '12345678901','monnaie','EUR'); + for sub in re.findall("'([^']+)'", s): + if cur_arg is None: + cur_arg = sub + else: + args[cur_arg] = sub + cur_arg = None + + return args + +class AccountsPage(BredBasePage): + def get_list(self): + accounts = [] + + for tr in self.document.xpath('//table[@class="compteTable"]/tr'): + if not tr.attrib.get('class', '').startswith('ligne_'): + continue + + cols = tr.findall('td') + + amount = Decimal(u''.join([txt.strip() for txt in cols[-1].itertext()]).strip(' EUR').replace(' ', '').replace(',', '.')) + a = cols[0].find('a') + if a is None: + # this line is a cards line. attach it on the first account. + if len(accounts) == 0: + self.logger.warning('There is a card link but no accounts!') + continue + + for a in cols[0].xpath('.//li/a'): + args = self.js2args(a.attrib['href']) + if not 'numero_compte' in args or not 'numero_poste' in args: + self.logger.warning('Card link with strange args: %s' % args) + continue + + accounts[0]._card_links.append('%s.%s' % (args['numero_compte'], args['numero_poste'])) + if not accounts[0].coming: + accounts[0].coming = Decimal('0.0') + accounts[0].coming += amount + continue + + args = self.js2args(a.attrib['href']) + + account = Account() + account.id = u'%s.%s' % (args['numero_compte'], args['numero_poste']) + account.label = to_unicode(a.attrib.get('alt', a.text.strip())) + account.balance = amount + account._card_links = [] + accounts.append(account) + + return accounts + +class Transaction(FrenchTransaction): + PATTERNS = [(re.compile('^RETRAIT G.A.B. \d+ (?P.*?)( CARTE .*)? LE (?P
\d{2})/(?P\d{2})/(?P\d{2}).*'), + FrenchTransaction.TYPE_WITHDRAWAL), + (re.compile('^VIR(EMENT)? (?P.*)'), FrenchTransaction.TYPE_TRANSFER), + (re.compile('^PRLV (?P.*)'), FrenchTransaction.TYPE_ORDER), + (re.compile('^(?P.*) TRANSACTION( CARTE .*)? LE (?P
\d{2})/(?P\d{2})/(?P\d{2}) ?(.*)$'), + FrenchTransaction.TYPE_CARD), + (re.compile('^CHEQUE.*'), FrenchTransaction.TYPE_CHECK), + (re.compile('^(CONVENTION \d+ )?COTISATION (?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): + def get_history(self): + is_coming = None + + for tr in self.document.xpath('//div[@class="scrollTbody"]/table//tr'): + cols = tr.findall('td') + + # check if it's a card page, so by default transactions are not yet debited. + if len(cols) == 6 and is_coming is None: + is_coming = True + + col_label = cols[1] + if col_label.find('a') is not None: + col_label = col_label.find('a') + + date = u''.join([txt.strip() for txt in cols[0].itertext()]) + label = unicode(col_label.text.strip()) + + # always strip card debits transactions. if we are on a card page, all next + # transactions will be probably already debited. + if label.startswith('DEBIT MENSUEL '): + is_coming = False + continue + + t = Transaction(col_label.attrib['id']) + + # an optional tooltip on page contain the second part of the transaction label. + tooltip = self.document.xpath('//div[@id="tooltip%s"]' % t.id) + raw = label + if len(tooltip) > 0: + raw += u' ' + u' '.join([txt.strip() for txt in tooltip[0].itertext()]) + + raw = re.sub(r'[ ]+', ' ', raw) + + t.parse(date, raw) + + # as only the first part of label is important to user, if there are no subpart + # taken by FrenchTransaction regexps, reset the label as first part. + if t.label == t.raw: + t.label = label + + debit = u''.join([txt.strip() for txt in cols[-2].itertext()]) + credit = u''.join([txt.strip() for txt in cols[-1].itertext()]) + t.set_amount(credit, debit) + + t._is_coming = bool(is_coming) + + yield t diff --git a/modules/bred/test.py b/modules/bred/test.py new file mode 100644 index 00000000..4e24f66d --- /dev/null +++ b/modules/bred/test.py @@ -0,0 +1,31 @@ +# -*- coding: utf-8 -*- + +# Copyright(C) 2012 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 BredTest(BackendTest): + BACKEND = 'bred' + + def test_bred(self): + l = list(self.backend.iter_accounts()) + + a = l[0] + list(self.backend.iter_history(a)) + list(self.backend.iter_coming(a))