diff --git a/modules/bnporc/pp/browser.py b/modules/bnporc/pp/browser.py index 4972a86abb1bf599898b40d285a844d43acad5f0..33080e240a2a3786ab77a5e09b2717048ecf55f4 100644 --- a/modules/bnporc/pp/browser.py +++ b/modules/bnporc/pp/browser.py @@ -39,9 +39,10 @@ from .pages import ( LoginPage, AccountsPage, AccountsIBANPage, HistoryPage, TransferInitPage, ConnectionThresholdPage, LifeInsurancesPage, LifeInsurancesHistoryPage, - LifeInsurancesDetailPage, MarketListPage, MarketPage, MarketHistoryPage, - MarketSynPage, RecipientsPage, ValidateTransferPage, RegisterTransferPage, - AdvisorPage, AddRecipPage, ActivateRecipPage, ProfilePage, ListDetailCardPage, + LifeInsurancesDetailPage, NatioVieProPage, CapitalisationPage, + MarketListPage, MarketPage, MarketHistoryPage, MarketSynPage, + RecipientsPage, ValidateTransferPage, RegisterTransferPage, AdvisorPage, + AddRecipPage, ActivateRecipPage, ProfilePage, ListDetailCardPage, ) @@ -91,6 +92,9 @@ class BNPParibasBrowser(JsonBrowserMixin, LoginBrowser): lifeinsurances_history = URL('mefav-wspl/rest/listMouvements', LifeInsurancesHistoryPage) lifeinsurances_detail = URL('mefav-wspl/rest/detailMouvement', LifeInsurancesDetailPage) + natio_vie_pro = URL('/mefav-wspl/rest/natioViePro', NatioVieProPage) + capitalisation_page = URL('https://www.clients.assurance-vie.fr/servlets/helios.cinrj.htmlnav.runtime.FrontServlet', CapitalisationPage) + market_list = URL('pe-war/rpc/SAVaccountDetails/get', MarketListPage) market_syn = URL('pe-war/rpc/synthesis/get', MarketSynPage) market = URL('pe-war/rpc/portfolioDetails/get', MarketPage) @@ -167,6 +171,16 @@ def get_accounts_list(self): break self.accounts_list.append(account) + # Fetching capitalisation contracts from the "Assurances Vie" space (some are not in the BNP API): + params = self.natio_vie_pro.go().get_params() + self.capitalisation_page.go(params=params) + if self.capitalisation_page.is_here() and self.page.has_contracts(): + for account in self.page.iter_capitalisation(): + # Life Insurance accounts may appear BOTH in the API and the "Assurances Vie" domain, + # It is better to keep the API version since it contains the unitvalue: + if account.number not in [a.number for a in self.accounts_list]: + self.accounts_list.append(account) + return iter(self.accounts_list) @need_login @@ -175,6 +189,9 @@ def get_account(self, _id): @need_login def iter_history(self, account, coming=False): + # The accounts from the "Assurances Vie" space have no available history: + if hasattr(account, '_details'): + return [] if account.type == Account.TYPE_PEA and account.label.endswith('Espèces'): return [] if account.type == account.TYPE_LIFE_INSURANCE: @@ -234,11 +251,27 @@ def iter_coming_operations(self, account): def iter_investment(self, account): if account.type == Account.TYPE_PEA and account.label.endswith('Espèces'): return [] - if account.type in (account.TYPE_LIFE_INSURANCE, account.TYPE_PERP): - self.lifeinsurances.go(data=JSON({ - "ibanCrypte": account.id, - })) - return self.page.iter_investments() + + # Life insurances and PERP may be scraped from the API or from the "Assurance Vie" space, + # so we need to discriminate between both using account._details: + if account.type in (account.TYPE_LIFE_INSURANCE, account.TYPE_PERP, account.TYPE_CAPITALISATION): + if hasattr(account, '_details'): + # Going to the "Assurances Vie" page + natiovie_params = self.natio_vie_pro.go().get_params() + self.capitalisation_page.go(params=natiovie_params) + + # Fetching the form to get the contract investments: + capitalisation_params = self.page.get_params(account) + self.capitalisation_page.go(params=capitalisation_params) + return self.page.iter_investments() + else: + # No capitalisation contract has yet been found in the API: + assert account.type != account.TYPE_CAPITALISATION + self.lifeinsurances.go(data=JSON({ + "ibanCrypte": account.id, + })) + return self.page.iter_investments() + elif account.type in (account.TYPE_MARKET, account.TYPE_PEA): try: self.market_list.go(data=JSON({})) @@ -256,6 +289,7 @@ def iter_investment(self, account): self.logger.warning("An Internal Server Error occured") break return self.page.iter_investments() + return iter([]) @need_login diff --git a/modules/bnporc/pp/pages.py b/modules/bnporc/pp/pages.py index 4cb0ad2704d0a4e97203dd241dbcf62fcaa5e1b0..757b36b00acdcda4132a255b3480a064e7fc55d9 100644 --- a/modules/bnporc/pp/pages.py +++ b/modules/bnporc/pp/pages.py @@ -26,9 +26,10 @@ from decimal import Decimal from datetime import datetime, timedelta -from weboob.browser.elements import DictElement, ListElement, ItemElement, method +from weboob.browser.elements import DictElement, ListElement, TableElement, ItemElement, method from weboob.browser.filters.json import Dict -from weboob.browser.filters.standard import Format, Regexp, CleanText, Date +from weboob.browser.filters.standard import Format, Eval, Regexp, CleanText, Date, CleanDecimal, Field +from weboob.browser.filters.html import TableCell from weboob.browser.pages import JsonPage, LoggedPage, HTMLPage from weboob.capabilities import NotAvailable from weboob.capabilities.bank import Account, Investment, Recipient, Transfer, TransferError, TransferBankError, AddRecipientError @@ -39,6 +40,8 @@ from weboob.tools.capabilities.bank.transactions import FrenchTransaction from weboob.tools.captcha.virtkeyboard import GridVirtKeyboard from weboob.tools.date import parse_french_date +from weboob.tools.capabilities.bank.investments import is_isin_valid +from weboob.tools.compat import unquote_plus class ConnectionThresholdPage(HTMLPage): @@ -598,6 +601,126 @@ class LifeInsurancesDetailPage(LifeInsurancesPage): investments_path = 'data.detailMouvement.listeSupport.*' +class NatioVieProPage(BNPPage): + # This form is required to go to the capitalisation contracts page. + def get_params(self): + params = { + 'app': 'BNPNET', + 'hageGroup': 'consultationBnpnet', + 'init': 'true', + 'multiInit': 'false', + } + params['a0'] = self.doc['data']['nationVieProInfos']['a0'] + # The number of "p" keys may vary (p0, p1, p2 ... up to p13 or more) + for key, value in self.doc['data']['nationVieProInfos']['listeP'].items(): + params[key] = value + # We must decode the values before constructing the URL: + for k, v in params.items(): + params[k] = unquote_plus(v) + return params + + +class CapitalisationPage(LoggedPage, HTMLPage): + def has_contracts(self): + # This message will appear if the page "Assurance Vie" contains no contract. + return not CleanText('//td[@class="message"]/text()[starts-with(., "Pour toute information")]')(self.doc) + + # To be completed with other account labels and types seen on the "Assurance Vie" space: + ACCOUNT_TYPES = { + 'BNP Paribas Multiplacements': Account.TYPE_LIFE_INSURANCE, + 'BNP Paribas Multiciel Privilège': Account.TYPE_CAPITALISATION, + 'Plan Epargne Retraite Particulier': Account.TYPE_PERP, + "Plan d'Épargne Retraite des Particuliers": Account.TYPE_PERP, + } + + @method + class iter_capitalisation(TableElement): + # Other types of tables may appear on the page (such as Alternative Emprunteur/Capital Assuré) + # But they do not contain bank accounts so we must avoid them. + item_xpath = '//table/tr[preceding-sibling::tr[th[text()="Libellé du contrat"]]][td[@class="ligneTableau"]]' + + head_xpath = '//table/tr/th[@class="headerTableau"]' + + col_label = 'Libellé du contrat' + col_id = 'Numéro de contrat' + col_balance = 'Montant' + col_currency = "Monnaie d'affichage" + + class item(ItemElement): + klass = Account + + obj_label = CleanText(TableCell('label')) + obj_id = CleanText(TableCell('id')) + obj_number = CleanText(TableCell('id')) + obj_balance = CleanDecimal(TableCell('balance'), replace_dots=True) + obj_coming = None + obj_iban = None + + def obj_type(self): + for k, v in self.page.ACCOUNT_TYPES.items(): + if Field('label')(self).startswith(k): + return v + return Account.TYPE_UNKNOWN + + def obj_currency(self): + currency = CleanText(TableCell('currency')(self))(self) + return Account.get_currency(currency) + + # Required to get the investments of each "Assurances Vie" account: + def obj__details(self): + raw_details = CleanText((TableCell('balance')(self)[0]).xpath('./a/@href'))(self) + m = re.search(r"Window\('(.*?)',window", raw_details) + if m: + return m.group(1) + + def get_params(self, account): + form = self.get_form(xpath='//form[@name="formListeContrats"]') + form['postValue'] = account._details + return form + + # The investments vdate is out of the investments table and is the same for all investments: + def get_vdate(self): + return parse_french_date(CleanText('//table[tr[th[text()[contains(., "Date de valorisation")]]]]/tr[2]/td[2]')(self.doc)) + + @method + class iter_investments(TableElement): + # Investment lines contain at least 5 tags + item_xpath = '//table[tr[th[text()[contains(., "Libellé")]]]]/tr[count(td)>=5]' + head_xpath = '//table[tr[th[text()[contains(., "Libellé")]]]]/tr/th[@class="headerTableau"]' + + col_label = 'Libellé' + col_code = 'Code ISIN' + col_quantity = 'Nombre de parts' + col_valuation = 'Montant' + col_portfolio_share = 'Montant en %' + + class item(ItemElement): + klass = Investment + + obj_label = CleanText(TableCell('label')) + obj_valuation = CleanDecimal(TableCell('valuation'), replace_dots=True) + obj_portfolio_share = Eval(lambda x: x / 100, CleanDecimal(TableCell('portfolio_share'), replace_dots=True)) + # There is no "unitvalue" information available on the "Assurances Vie" space. + + def obj_quantity(self): + quantity = TableCell('quantity')(self) + if CleanText(quantity)(self) == '-': + return NotAvailable + return CleanDecimal(quantity, replace_dots=True)(self) + + def obj_code(self): + isin = CleanText(TableCell('code')(self))(self) + return isin or NotAvailable + + def obj_code_type(self): + if is_isin_valid(Field('code')(self)): + return Investment.CODE_TYPE_ISIN + return NotAvailable + + def obj_vdate(self): + return self.page.get_vdate() + + class MarketListPage(BNPPage): def get_list(self): return self.get('securityAccountsList') or []