diff --git a/modules/binck/__init__.py b/modules/binck/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..faf9cce5ea1728dd824629d88a48512aa7d71814 --- /dev/null +++ b/modules/binck/__init__.py @@ -0,0 +1,26 @@ +# -*- coding: utf-8 -*- + +# Copyright(C) 2018 Arthur Huillet +# +# 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 unicode_literals + + +from .module import BinckModule + + +__all__ = ['BinckModule'] diff --git a/modules/binck/browser.py b/modules/binck/browser.py new file mode 100644 index 0000000000000000000000000000000000000000..8168b543f4e08d9e270aa88d2fe8b767802949fd --- /dev/null +++ b/modules/binck/browser.py @@ -0,0 +1,86 @@ +# -*- coding: utf-8 -*- + +# Copyright(C) 2018 Arthur Huillet +# +# 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 unicode_literals + + +from weboob.browser import LoginBrowser, URL, need_login +from weboob.exceptions import BrowserIncorrectPassword + +from .pages import LoginPage, AccountsList, TransactionHistoryJSON, AccountSelectionBar, InvestmentListJSON + +__all__ = ['Binck'] + + +class Binck(LoginBrowser): + BASEURL = 'https://web.binck.fr/' + + # login and account overview are easy. Other pages are stateful and use the + # current account that is sent via a POST request that includes a one-time + # verification token + login_page = URL('/logon$', LoginPage) + accounts_page = URL('/AccountsOverview/Index', AccountsList) + transaction_history_json = URL('/TransactionsOverview/GetTransactions', TransactionHistoryJSON) + investment_list_json = URL('/PortfolioOverview/GetPortfolioOverview', InvestmentListJSON) + account_history_page = URL('/TransactionsOverview/FilteredOverview', AccountSelectionBar) + investment_page = URL('/PortfolioOverview/Index', AccountSelectionBar) + generic_page_with_account_selector = URL('/Home/Index', AccountSelectionBar) + + def __init__(self, *args, **kwargs): + super(Binck, self).__init__(*args, **kwargs) + self.current_account = None + self.verification_token = None + + def do_login(self): + self.login_page.stay_or_go() + self.page.login(self.username, self.password) + + if self.login_page.is_here(): + raise BrowserIncorrectPassword() + + @need_login + def get_accounts_list(self): + self.accounts_page.stay_or_go() + return self.page.get_list() + + def make_account_current(self, account): + self.token = self.page.get_account_selector_verification_token() + self.location('/Header/SwitchAccount', data={'__RequestVerificationToken': self.token, 'accountNumber': account.id}) + self.token = self.page.get_account_selector_verification_token() + self.current_account = account + + @need_login + def iter_investment(self, account): + self.investment_page.stay_or_go() + self.make_account_current(account) + self.location('/PortfolioOverview/GetPortfolioOverview', data={'grouping': 'SecurityCategory'}, + headers={'__RequestVerificationToken': self.token}) + return self.page.iter_investment() + + @need_login + def iter_history(self, account): + # Not very useful as Binck's history page sucks (descriptions make no sense) + self.account_history_page.stay_or_go() + self.make_account_current(account) + # XXX handle other currencies than EUR + self.location('/TransactionsOverview/GetTransactions', + data={'currencyCode': 'EUR', 'mutationGroup': '', 'startDate': '', 'endDate': '', 'isFilteredOverview': True}, + headers={'__RequestVerificationToken': self.token}) + + return self.page.iter_history() diff --git a/modules/binck/favicon.png b/modules/binck/favicon.png new file mode 100644 index 0000000000000000000000000000000000000000..81f069f79025e6b9d77ecc09ae0220ae4f5e88bf Binary files /dev/null and b/modules/binck/favicon.png differ diff --git a/modules/binck/module.py b/modules/binck/module.py new file mode 100644 index 0000000000000000000000000000000000000000..21c7779eeda06af450bddad1c72a7b466deee692 --- /dev/null +++ b/modules/binck/module.py @@ -0,0 +1,75 @@ +# -*- coding: utf-8 -*- + +# Copyright(C) 2018 Arthur Huillet +# +# 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 unicode_literals + + +from weboob.capabilities.base import find_object +from weboob.capabilities.bank import CapBankWealth, AccountNotFound +from weboob.tools.backend import Module, BackendConfig +from weboob.tools.value import ValueBackendPassword + +from .browser import Binck + + +__all__ = ['BinckModule'] + + +class BinckModule(Module, CapBankWealth): + NAME = 'binck' + MAINTAINER = 'Arthur Huillet' + EMAIL = 'arthur.huillet+weboob@free.fr' + VERSION = '1.4' + LICENSE = 'AGPLv3+' + DESCRIPTION = u'Binck' + CONFIG = BackendConfig( + ValueBackendPassword('login', label='Identifiant', masked=False, required=True), + ValueBackendPassword('password', label='Mot de passe', required=True)) + BROWSER = Binck + + def create_default_browser(self): + return self.create_browser( + self.config['login'].get(), + self.config['password'].get() + ) + + def get_account(self, id): + """ + Get an account from its ID. + + :param id: ID of the account + :type id: :class:`str` + :rtype: :class:`Account` + :raises: :class:`AccountNotFound` + """ + return find_object(self.iter_accounts(), id=id, error=AccountNotFound) + + def iter_accounts(self): + """ + Iter accounts. + + :rtype: iter[:class:`Account`] + """ + return self.browser.get_accounts_list() + + def iter_investment(self, account): + return self.browser.iter_investment(account) + + def iter_history(self, account): + return self.browser.iter_history(account) diff --git a/modules/binck/pages.py b/modules/binck/pages.py new file mode 100644 index 0000000000000000000000000000000000000000..6925df24de9ae5c7852d301815b5c5c8948852f1 --- /dev/null +++ b/modules/binck/pages.py @@ -0,0 +1,185 @@ +# -*- coding: utf-8 -*- + +# Copyright(C) 2018 Arthur Huillet +# +# 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 unicode_literals + +from weboob.browser.filters.html import Attr +from weboob.browser.filters.standard import CleanText, CleanDecimal, Currency, Date +from weboob.browser.filters.json import Dict + +from weboob.capabilities import NotAvailable +from weboob.capabilities.bank import Account, Transaction, Investment +from weboob.browser.pages import HTMLPage, LoggedPage, JsonPage +from weboob.browser.elements import method, ItemElement, DictElement + +import lxml + + +class LoginPage(HTMLPage): + def login(self, login, passwd): + + form = self.get_form(id='loginForm') + form['UserName'] = login + form['Password'] = passwd + form.submit() + + +class AccountSelectionBar(LoggedPage, HTMLPage): + def get_account_selector_verification_token(self): + return self.doc.xpath("//div[@class='accounts']//input[@name='__RequestVerificationToken']/@value")[0] + + +class AccountsList(AccountSelectionBar): + + def get_list(self): + accounts = [] + + for account_category in self.doc.xpath('//div[@id="AccountGroups"]//h1'): + type_mappings = {'PEA' : Account.TYPE_PEA, + 'Compte-titres' : Account.TYPE_MARKET, + 'Livret' : Account.TYPE_SAVINGS, + # XXX PEA-PME? + } + + try: + current_type = type_mappings[account_category.text] + except KeyError: + self.logger.warning("Unknown account type for " + CleanText().filter(account_category)) + current_type = Account.TYPE_UNKNOWN + + for cpt in account_category.xpath('..//tr[@data-accountnumber]'): + account = Account() + account.type = current_type + account.id = Attr(".", "data-accountnumber")(cpt) + account.label = CleanText("./td[1]")(cpt) + account.balance = CleanDecimal("./td[2]", replace_dots=True)(cpt) + account.iban = NotAvailable # XXX need IBAN + accounts.append(account) + return accounts + + +class TransactionHistoryJSON(JsonPage): + @method + class iter_history(DictElement): + item_xpath = 'Transactions' + ''' +{u'CurrentPage': 0, + u'EndOfData': True, + u'LastSequenceNumber': 1, + u'MutationGroupIntroductionText': u'Historique des transactions', + u'MutationGroupIntroductionTitle': u'Toutes les transactions', + u'Pages': [24], + u'Transactions': [{u'Amount': u'0,00 \u20ac', + u'Date': u'06/04/2017', + u'Description': u'\xe0 xxxx', + u'Mutation': u'-345,99 \u20ac', + u'Number': 24, + u'TransactionId': 20333, + u'Type': u'Virement interne', + u'ValueDate': u'06/04/2017'}, + {u'Amount': u'345,99 \u20ac', + u'Date': u'05/04/2017', + u'Description': u"'Frais remboursement'", + u'Mutation': u'5,00 \u20ac', + u'Number': 23, + u'TransactionId': 2031111, + u'Type': u'Commissions', + u'ValueDate': u'05/04/2017'}, +} +''' + class item(ItemElement): + klass = Transaction + obj_id = Dict('TransactionId') + obj_date = Date(Dict('Date'), dayfirst=True, default=NotAvailable) + obj_label = CleanText(Dict('Description')) + obj_amount = CleanDecimal(Dict('Mutation'), replace_dots=True, default=NotAvailable) + obj__account_balance = CleanDecimal(Dict('Amount'), replace_dots=True, default=NotAvailable) + obj__num_oper = Dict('Number') + obj__transaction_id = Dict('TransactionId') + # XXX this page has types that do not make a lot of sense, and + # crappy descriptions, and weboob does not have the types we want + # anyway. Don't bother filling in the type. + obj_type = Transaction.TYPE_BANK + obj_vdate = Date(Dict('ValueDate'), dayfirst=True, default=NotAvailable) + + def obj_original_currency(self): + return Account.get_currency(Dict('Mutation')(self)) + + def obj__transaction_detail(self): + return + 'https://web.binck.fr/TransactionDetails/TransactionDetails?transactionSequenceNumber=%d¤cyCode=%s' % \ + (self.obj._num_oper, self.obj.original_currency) + + +class InvestmentListJSON(JsonPage): + ''' +{ + "PortfolioOverviewGroups": [ + { + "GroupName": "ETF", + "Items": [ + { + "CurrencyCode": "EUR", + "SecurityId": 4019272, + "SecurityName": "Amundi ETF MSCI Emg Markets UCITS EUR C", + "Quantity": "5\u00a0052", + "QuantityValue": 5052.0, + "Quote": "4,1265", + "ValueInEuro": "20\u00a0847,08 \u20ac", + "ValueInEuroRaw": 20847.08, + "HistoricQuote": "3,68289", + "HistoricValue": "18\u00a0605,95 \u20ac", + "HistoricValueInEuro": "18\u00a0605,95 \u20ac", + "ResultValueInEuro": "2\u00a0241,12 \u20ac", + "ResultPercentageInEuro": "12,05 %", + "ValueInSecurityCurrency": "20\u00a0847,08 \u20ac", + "Difference": "-0,0285", + "DifferencePercentage": "-0,69 %", + "ResultValueInSecurityCurrency": "2\u00a0241,12 \u20ac", + "ResultPercentageInSecurityCurrency": "12,05 %", + "DayResultInEuro": "-166,72 \u20ac", + "LowestPrice": "4,1294", + "HighestPrice": "4,141", + "OpeningPrice": "4,1305", + "ClosingPrice": "4,1595", + "IsinCode": "LU1681045370", + "SecurityCategory": "ETF", + }, + ''' + @method + class iter_investment(DictElement): + item_xpath = 'PortfolioOverviewGroups/*/Items' + + class item(ItemElement): + klass = Investment + obj_id = Dict('SecurityId') + obj_label = Dict('SecurityName') + obj_code = Dict('IsinCode') + obj_code_type = Investment.CODE_TYPE_ISIN + obj_quantity = CleanDecimal(Dict('QuantityValue')) + obj_unitprice = CleanDecimal(Dict('HistoricQuote'), replace_dots=True) + obj_unitvalue = CleanDecimal(Dict('Quote'), replace_dots=True) + obj_valuation = CleanDecimal(Dict('ValueInEuroRaw')) + obj_diff = CleanDecimal(Dict('ResultValueInEuro'), replace_dots=True) + obj_diff_percent = CleanDecimal(Dict('ResultPercentageInEuro'), replace_dots=True) + obj_original_currency = Currency(Dict('CurrencyCode')) + obj_original_valuation = CleanDecimal(Dict('ValueInSecurityCurrency'), replace_dots=True) + obj_original_diff = CleanDecimal(Dict('ResultValueInSecurityCurrency'), replace_dots=True) + diff --git a/modules/binck/test.py b/modules/binck/test.py new file mode 100644 index 0000000000000000000000000000000000000000..56a9f4910eae4bbc98510845f36195218e48fa9f --- /dev/null +++ b/modules/binck/test.py @@ -0,0 +1,32 @@ +# -*- coding: utf-8 -*- + +# Copyright(C) 2018 Arthur Huillet +# +# 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 unicode_literals + + +from weboob.tools.test import BackendTest + + +class BinckTest(BackendTest): + MODULE = 'binck' + def test_binck(self): + l = list(self.backend.iter_accounts()) + self.assertTrue(len(l) > 0) +# a = l[0] +# list(self.backend.iter_history(a)) diff --git a/tools/py3-compatible.modules b/tools/py3-compatible.modules index 6d6dfaa829710e231b692ee2a07bdaa5fc08ff76..6da804819aefc080d8d0dd43adc55fcc2bd2d81c 100644 --- a/tools/py3-compatible.modules +++ b/tools/py3-compatible.modules @@ -20,6 +20,7 @@ becm bforbank bibliothequesparis billetreduc +binck biplan blablacar blogspot