From ea753637b2dc6afcc7492048d64cc37bad3b1fe1 Mon Sep 17 00:00:00 2001 From: Arthur Huillet Date: Mon, 30 Apr 2018 22:13:45 +0200 Subject: [PATCH] add CapBank binck module This module adds support for Binck stock trading accounts. It can show the list of accounts with their current EUR valuation, the list of investments for each account in the correct currency, and it can show the list of transactions (but this list sucks on binck.fr to the point of being almost unusable, this backend cannot invent meaningful descriptions so it does its best). --- modules/binck/__init__.py | 26 +++++ modules/binck/browser.py | 86 ++++++++++++++++ modules/binck/favicon.png | Bin 0 -> 1450 bytes modules/binck/module.py | 75 ++++++++++++++ modules/binck/pages.py | 185 +++++++++++++++++++++++++++++++++++ modules/binck/test.py | 32 ++++++ tools/py3-compatible.modules | 1 + 7 files changed, 405 insertions(+) create mode 100644 modules/binck/__init__.py create mode 100644 modules/binck/browser.py create mode 100644 modules/binck/favicon.png create mode 100644 modules/binck/module.py create mode 100644 modules/binck/pages.py create mode 100644 modules/binck/test.py diff --git a/modules/binck/__init__.py b/modules/binck/__init__.py new file mode 100644 index 0000000000..faf9cce5ea --- /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 0000000000..8168b543f4 --- /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 GIT binary patch literal 1450 zcmV;b1y%ZqP)WFU8GbZ8()Nlj2>E@cM*00j<7L_t(|+U;9gZyQAv z{$}=)I8I{H#4UAFix$$9sButvpp=Ugil8VwL=g3#QL9FVq!A5uOV!de7bhmZtoQJ+*p1`sdUw`qB}ZDx*4i`keRIxt&Ka*6 z1li!&2moOEV-I#d)`-;H} z(EulmbixCJy$C%0Ae1H*T#+C($nb{(2)*@0ZAH*2^K^J2DN=4kXlb+`4Aedi+GV9u zo}NmBbOPJJ0b>lQwZzu&fcbC3Si7+dE^~k}FwT&O#_{{;ggHGiJ8{=D-B6lT0QvgX zd-5?`MNl2?XQUHI(~*j&u=dv?mger{d?xhvQ`m7}Cz8=PX1<)lV)z#J9PP&SALr~R z;P3~>;CrY8&{2>80FawhTL^ggixbA$$he3Il-8leQ;6M);^r^&xbagy$l&t_5V^7bH`9nO{EOhx{fPW9BQ^v8K=G>3(|XBoAN%|j+Zv(fE+71lbV2nsz`Lsz{zF|* zJSqqT(g|ykW&HfkIn#3l2_&T3vb_`J4AAclbziq1hu$54r`>DpqQtTC=K>}_Jdb2F zQ4#Q*5C{^;YU?Sq572XYT%Nmw@W+z~y?F#`Ya=MFqb1meeM5bi`Rq5y9tGNZsx0`N zG#DgM<}wSs5O}K@ATwrThqvJU+mqJ}v12Uj1^!T{p+lR%m%_%2u~aP~c85}YA%?b| zodxgDp1FwF-z$a|5uv%$2c=mpx#Lw8LdD2%qWv8l8^O}-KSl4a%-_wvmKqgw4Ro9G zX1N;W%)S{He`HlMe-V(3t{Zg;11=ApCJZ@v9Z>h*?I z5+_buAubZkr*yHmz<57#tgch|DCr9k{4tHP;g$8n~-P&`IOi;+ z0$&h-#V5shJ%i7BfRiCmYw(-rH;Q@Y3;}N0`HkzKED3@Ma9b&ySr4;Vm-2c9@924e z`@u6yZor)eUrB>vDhx!0fC%s{uOGI(lZ<`xE7_g~g}FUd*Rhqe@bOQ{HUdO}?;AVG zHrK6A{3S+cFHkY~@exKg)a|9L+Mm+6=-m%}{p!6ZJ_RR~%R{HBY63)n?|5&ZZ0BZo z7$m>CM5YN4mG1HckShFaJ!?SmJWnMzr|GUgH1>vM`qiu)Y|B7T3Ty zpsj02$2Hd#(n6mQ37?ukDw=@gk&#?Y;M(~=DoUA7JspxgNw6w{#avfvkdaQ9+jTph zG&d7gT;v31!Eg2iC$xZzWY=wnC$@)x69m{*qZC`~e_fXy_A8-bl>h($07*qoM6N<$ Ef-(i2+5i9m literal 0 HcmV?d00001 diff --git a/modules/binck/module.py b/modules/binck/module.py new file mode 100644 index 0000000000..21c7779eed --- /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 0000000000..6925df24de --- /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 0000000000..56a9f4910e --- /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 6d6dfaa829..6da804819a 100644 --- a/tools/py3-compatible.modules +++ b/tools/py3-compatible.modules @@ -20,6 +20,7 @@ becm bforbank bibliothequesparis billetreduc +binck biplan blablacar blogspot -- GitLab