diff --git a/modules/americanexpress/pages.py b/modules/americanexpress/pages.py index b77d9f1bb8e7b63a212a1dab56b9197cc6477bc6..f91f2fc477e5eaad114f43f870ba0de2e2e23b0a 100644 --- a/modules/americanexpress/pages.py +++ b/modules/americanexpress/pages.py @@ -193,7 +193,7 @@ def obj_date(self): obj_amount = Eval(lambda x: -float_to_decimal(x), Dict('amount')) obj_original_currency = Dict('foreign_details/iso_alpha_currency_code', default=NotAvailable) obj_commission = CleanDecimal(Dict('foreign_details/commission_amount', default=NotAvailable), sign='-', default=NotAvailable) - obj__owner = CleanText(Dict('embossed_name')) + obj__owner = Dict('embossed_name') obj_id = Dict('reference_id', default=NotAvailable) def obj_original_amount(self): diff --git a/modules/anticaptcha/browser.py b/modules/anticaptcha/browser.py index 6967c8c905928a9dc5ba1d792f96f7754598e07d..902e0429ab656ee6a1ca497afd6846f51359d880 100644 --- a/modules/anticaptcha/browser.py +++ b/modules/anticaptcha/browser.py @@ -23,9 +23,9 @@ from .compat.weboob_browser_browsers import APIBrowser from weboob.exceptions import BrowserIncorrectPassword, BrowserBanned -from weboob.capabilities.captcha import ( - ImageCaptchaJob, RecaptchaJob, RecaptchaV3Job, NocaptchaJob, FuncaptchaJob, CaptchaError, - InsufficientFunds, UnsolvableCaptcha, InvalidCaptcha, +from .compat.weboob_capabilities_captcha import ( + ImageCaptchaJob, RecaptchaJob, RecaptchaV3Job, NocaptchaJob, FuncaptchaJob, HcaptchaJob, + CaptchaError, InsufficientFunds, UnsolvableCaptcha, InvalidCaptcha, ) @@ -61,6 +61,9 @@ def post_recaptcha(self, url, key): def post_nocaptcha(self, url, key): return self.post_gcaptcha(url, key, 'NoCaptcha') + def post_hcaptcha(self, url, key): + return self.post_gcaptcha(url, key, 'HCaptcha') + def post_gcaptcha(self, url, key, prefix): data = { "clientKey": self.apikey, @@ -147,7 +150,7 @@ def poll(self, job): elif isinstance(job, RecaptchaJob): job.solution = sol['recaptchaResponse'] job.solution_challenge = sol['recaptchaChallenge'] - elif isinstance(job, NocaptchaJob) or isinstance(job, RecaptchaV3Job): + elif isinstance(job, (NocaptchaJob, RecaptchaV3Job, HcaptchaJob)): job.solution = sol['gRecaptchaResponse'] elif isinstance(job, FuncaptchaJob): job.solution = sol['token'] diff --git a/modules/anticaptcha/compat/weboob_capabilities_captcha.py b/modules/anticaptcha/compat/weboob_capabilities_captcha.py new file mode 100644 index 0000000000000000000000000000000000000000..dcb89fc96a74b6434485773864bb5ccd25f730a0 --- /dev/null +++ b/modules/anticaptcha/compat/weboob_capabilities_captcha.py @@ -0,0 +1,202 @@ +# -*- coding: utf-8 -*- + +# Copyright(C) 2018 Vincent A +# +# This file is part of weboob. +# +# weboob is free software: you can redistribute it and/or modify +# it under the terms of the GNU Lesser 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 Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public License +# along with weboob. If not, see . + +from time import sleep + +from weboob.capabilities.base import Capability, BaseObject, StringField, UserError, BytesField +from weboob.exceptions import ( + RecaptchaQuestion, RecaptchaV3Question, NocaptchaQuestion, FuncaptchaQuestion, + ImageCaptchaQuestion, HcaptchaQuestion, +) + + +__all__ = [ + 'CapCaptchaSolver', + 'SolverJob', 'RecaptchaJob', 'NocaptchaJob', 'ImageCaptchaJob', 'HcaptchaJob', + 'CaptchaError', 'UnsolvableCaptcha', 'InvalidCaptcha', 'InsufficientFunds', + 'exception_to_job', +] + + +from weboob.capabilities.captcha import SolverJob as _SolverJob +class SolverJob(_SolverJob): + solution = StringField('CAPTCHA solution') + + +from weboob.capabilities.captcha import RecaptchaJob as _RecaptchaJob +class RecaptchaJob(_RecaptchaJob): + site_url = StringField('Site URL for ReCaptcha service') + site_key = StringField('Site key for ReCaptcha service') + + solution_challenge = StringField('Challenge ID of the solution (output value)') + + +from weboob.capabilities.captcha import RecaptchaV3Job as _RecaptchaV3Job +class RecaptchaV3Job(_RecaptchaV3Job): + site_url = StringField('Site URL for ReCaptcha service') + site_key = StringField('Site key for ReCaptcha service') + action = StringField('Website owner defines what user is doing on the page through this parameter.') + + +from weboob.capabilities.captcha import NocaptchaJob as _NocaptchaJob +class NocaptchaJob(_NocaptchaJob): + site_url = StringField('Site URL for NoCaptcha service') + site_key = StringField('Site key for NoCaptcha service') + + +from weboob.capabilities.captcha import FuncaptchaJob as _FuncaptchaJob +class FuncaptchaJob(_FuncaptchaJob): + site_url = StringField('Site URL for FunCaptcha service') + site_key = StringField('Site key for FunCaptcha service') + sub_domain = StringField('Required for some complex cases, but Funcaptcha integrations run without it') + + +class HcaptchaJob(SolverJob): + site_url = StringField('Site URL for HCaptcha service') + site_key = StringField('Site key for HCaptcha service') + + +from weboob.capabilities.captcha import ImageCaptchaJob as _ImageCaptchaJob +class ImageCaptchaJob(_ImageCaptchaJob): + image = BytesField('data of the image to solve') + + +from weboob.capabilities.captcha import CaptchaError as _CaptchaError +class CaptchaError(_CaptchaError): + """Generic solving error""" + + +from weboob.capabilities.captcha import InvalidCaptcha as _InvalidCaptcha +class InvalidCaptcha(_InvalidCaptcha): + """CAPTCHA cannot be used (e.g. invalid image format)""" + + +from weboob.capabilities.captcha import UnsolvableCaptcha as _UnsolvableCaptcha +class UnsolvableCaptcha(_UnsolvableCaptcha): + """CAPTCHA is too hard or impossible""" + + +from weboob.capabilities.captcha import InsufficientFunds as _InsufficientFunds +class InsufficientFunds(_InsufficientFunds): + """Not enough funds to pay solution""" + + +def exception_to_job(exc): + if isinstance(exc, RecaptchaQuestion): + job = RecaptchaJob() + job.site_url = exc.website_url + job.site_key = exc.website_key + elif isinstance(exc, RecaptchaV3Question): + job = RecaptchaV3Job() + job.site_url = exc.website_url + job.site_key = exc.website_key + job.action = exc.action + elif isinstance(exc, NocaptchaQuestion): + job = NocaptchaJob() + job.site_url = exc.website_url + job.site_key = exc.website_key + elif isinstance(exc, FuncaptchaQuestion): + job = FuncaptchaJob() + job.site_url = exc.website_url + job.site_key = exc.website_key + job.sub_domain = exc.sub_domain + elif isinstance(exc, ImageCaptchaQuestion): + job = ImageCaptchaJob() + job.image = exc.image_data + elif isinstance(exc, HcaptchaQuestion): + job = HcaptchaJob() + job.site_url = exc.website_url + job.site_key = exc.website_key + else: + raise NotImplementedError() + + return job + + +from weboob.capabilities.captcha import CapCaptchaSolver as _CapCaptchaSolver +class CapCaptchaSolver(_CapCaptchaSolver): + """ + Provide CAPTCHA solving + """ + + RETRIES = 30 + WAIT_TIME = 2 + + def create_job(self, job): + """Start a CAPTCHA solving job + + The `job.id` shall be filled. The CAPTCHA is not solved yet when the method returns. + + :param job: job to start + :type job: :class:`SolverJob` + :raises: :class:`NotImplementedError` if CAPTCHA type is not supported + :raises: :class:`CaptchaError` in case of other error + """ + raise NotImplementedError() + + def poll_job(self, job): + """Check if a job was solved + + If `job` is solved, return True and fill `job.solution`. + Return False if solution is still pending. + In case of solving problem, an exception may be raised. + + It should not wait for the solution but return the current state. + + :param job: job to check and to fill when solved + :type job: :class:`SolverJob` + :returns: True if the job was solved + :rtype: bool + :raises: :class:`CaptchaError` + """ + raise NotImplementedError() + + def solve_catpcha_blocking(self, job): + """Start a CAPTCHA solving job and wait for its solution + + :param job: job to start and solve + :type job: :class:`SolverJob` + :raises: :class:`CaptchaError` + """ + + self.create_job(job) + for i in range(self.RETRIES): + sleep(self.WAIT_TIME) + if self.poll_job(job): + return job + + def report_wrong_solution(self, job): + """Report a solved job as a wrong solution + + Sometimes, jobs are solved, but the solution is rejected by the CAPTCHA + site because the solution is wrong. + This method reports the solution as wrong to the CAPTCHA solver. + + :param job: job to flag + :type job: :class:`SolverJob` + """ + raise NotImplementedError() + + def get_balance(self): + """Get the prepaid balance left + + :rtype: float + """ + raise NotImplementedError() + diff --git a/modules/anticaptcha/module.py b/modules/anticaptcha/module.py index 94327438b594f690b30739770e105a6655b31054..ffcd0a22904c750f7747a6dabd118568a3c411ff 100644 --- a/modules/anticaptcha/module.py +++ b/modules/anticaptcha/module.py @@ -21,8 +21,9 @@ from weboob.tools.backend import Module, BackendConfig -from weboob.capabilities.captcha import ( - CapCaptchaSolver, ImageCaptchaJob, RecaptchaJob, RecaptchaV3Job, NocaptchaJob, FuncaptchaJob +from .compat.weboob_capabilities_captcha import ( + CapCaptchaSolver, ImageCaptchaJob, RecaptchaJob, RecaptchaV3Job, NocaptchaJob, FuncaptchaJob, + HcaptchaJob, ) from .compat.weboob_tools_value import ValueBackendPassword @@ -61,6 +62,8 @@ def create_job(self, job): job.id = self.browser.post_nocaptcha(job.site_url, job.site_key) elif isinstance(job, FuncaptchaJob): job.id = self.browser.post_funcaptcha(job.site_url, job.site_key, job.sub_domain) + elif isinstance(job, HcaptchaJob): + job.id = self.browser.post_hcaptcha(job.site_url, job.site_key) else: raise NotImplementedError() diff --git a/modules/aviva/pages/detail_pages.py b/modules/aviva/pages/detail_pages.py index 97c12a131840d743e57160f62b9d4fad96715006..d19a33106300f843472d1183feca75f3acbe9e4f 100644 --- a/modules/aviva/pages/detail_pages.py +++ b/modules/aviva/pages/detail_pages.py @@ -83,6 +83,12 @@ class fill_account(ItemElement): ) obj_valuation_diff = CleanDecimal.French('//h3[contains(., "value latente")]/following-sibling::p[1]', default=NotAvailable) obj_type = MapIn(Lower(CleanText('//h3[contains(text(), "Type de produit")]/following-sibling::p')), ACCOUNT_TYPES, Account.TYPE_UNKNOWN) + # Opening date titles may have slightly different names and apostrophe characters + obj_opening_date = Coalesce( + Date(CleanText('''//h3[contains(text(), "Date d'effet de l'adhésion")]/following-sibling::p'''), dayfirst=True, default=NotAvailable), + Date(CleanText('''//h3[contains(text(), "Date d’effet d’adhésion")]/following-sibling::p'''), dayfirst=True, default=NotAvailable), + default=NotAvailable + ) def get_history_link(self): history_link = self.doc.xpath('//li/a[contains(text(), "Historique")]/@href') diff --git a/modules/axabanque/pages/wealth.py b/modules/axabanque/pages/wealth.py index 4f22f9bd331677699b3913f9c94e60332b5d5341..8da92fd9d2595be4a37be391b58739a7e18efd37 100644 --- a/modules/axabanque/pages/wealth.py +++ b/modules/axabanque/pages/wealth.py @@ -178,8 +178,8 @@ class item(ItemElement): klass = Investment obj_label = CleanText(TableCell('label')) - obj_code = IsinCode(TableCell('code'), default=NotAvailable) - obj_code_type = IsinType(TableCell('code'), default=NotAvailable) + obj_code = IsinCode(CleanText(TableCell('code')), default=NotAvailable) + obj_code_type = IsinType(CleanText(TableCell('code'))) obj_asset_category = CleanText(TableCell('asset_category')) obj_valuation = CleanDecimal.French(TableCell('valuation'), default=NotAvailable) diff --git a/modules/bnpcards/browser.py b/modules/bnpcards/browser.py index 95590af48413433843e3a74bd99d37241d665ebe..e08f25c717940a3efc14355abcad0ca30b0056c8 100644 --- a/modules/bnpcards/browser.py +++ b/modules/bnpcards/browser.py @@ -24,6 +24,8 @@ from weboob.tools.capabilities.bank.transactions import sorted_transactions from weboob.tools.compat import basestring +from .corporate.browser import BnpcartesentrepriseCorporateBrowser + from .pages import ( LoginPage, ErrorPage, AccountsPage, TransactionsPage, TiCardPage, TiHistoPage, ComingPage, HistoPage, HomePage, @@ -70,6 +72,8 @@ def __init__(self, type, *args, **kwargs): self.is_corporate = False self.transactions_dict = {} + self.corporate_browser = None + def do_login(self): assert isinstance(self.username, basestring) assert isinstance(self.password, basestring) @@ -87,7 +91,9 @@ def do_login(self): raise BrowserPasswordExpired(self.page.get_error_msg()) if self.type == '2' and self.page.is_corporate(): self.logger.info('Manager corporate connection') - raise SiteSwitch('corporate') + # Even if we are are on a manager corporate connection, we may still have business cards. + # For that case we need to fetch data from both the corporate browser and the default one. + self.corporate_browser = BnpcartesentrepriseCorporateBrowser(self.type, self.username, self.password) # ti corporate and ge corporate are not detected the same way .. if 'corporate' in self.page.url: self.logger.info('Carholder corporate connection') diff --git a/modules/bnpcards/corporate/pages.py b/modules/bnpcards/corporate/pages.py index f760cbd8345cd33253bdcd0a8178723028f80769..329cde06d0c11dcc8938a39e618603528fb8cb58 100644 --- a/modules/bnpcards/corporate/pages.py +++ b/modules/bnpcards/corporate/pages.py @@ -65,6 +65,7 @@ class item(ItemElement): obj_currency = 'EUR' obj_url = Link('./td[2]/a') obj__company = Env('company', default=None) # this field is something used to make the module work, not something meant to be displayed to end users + obj__is_corporate = True @pagination def get_link(self, account_id, owner): diff --git a/modules/bnpcards/module.py b/modules/bnpcards/module.py index bd853813e39142f701655d00843946bfd5928f51..d46f9011deec3748c0aa5d2fe7a2951ea8c1ae8a 100644 --- a/modules/bnpcards/module.py +++ b/modules/bnpcards/module.py @@ -65,13 +65,29 @@ def iter_accounts(self): for acc in self.browser.iter_accounts(): acc._bisoftcap = {'all': {'softcap_day':5,'day_for_softcap':100}} yield acc - - def iter_coming(self, account): - for tr in self.browser.get_transactions(account): - if tr._coming: - yield tr + # If this browser exists we have corporate cards, that we also need to fetch + if self.browser.corporate_browser: + for acc in self.browser.corporate_browser.iter_accounts(): + acc._bisoftcap = {'all': {'softcap_day': 5, 'day_for_softcap': 100}} + yield acc def iter_history(self, account): - for tr in self.browser.get_transactions(account): + if getattr(account, '_is_corporate', False): + get_transactions = self.browser.corporate_browser.get_transactions + else: + get_transactions = self.browser.get_transactions + + for tr in get_transactions(account): if not tr._coming: yield tr + + def iter_coming(self, account): + if getattr(account, '_is_corporate', False): + get_transactions = self.browser.corporate_browser.get_transactions + else: + get_transactions = self.browser.get_transactions + + for tr in get_transactions(account): + if not tr._coming: + break + yield tr diff --git a/modules/boursorama/browser.py b/modules/boursorama/browser.py index 6ea72b8c2006233e5317711e16f07389a40a3bb8..7514c0630ba398bf8077996434d47501b123fcf9 100644 --- a/modules/boursorama/browser.py +++ b/modules/boursorama/browser.py @@ -40,6 +40,7 @@ TransferInvalidEmitter, TransferInvalidLabel, TransferInvalidRecipient, AddRecipientStep, Rate, TransferBankError, AccountOwnership, RecipientNotFound, AddRecipientTimeout, TransferDateType, Emitter, TransactionType, + AddRecipientBankError, ) from weboob.capabilities.base import empty, find_object from weboob.capabilities.contact import Advisor @@ -55,7 +56,7 @@ TransferAccounts, TransferRecipients, TransferCharacteristics, TransferConfirm, TransferSent, AddRecipientPage, StatusPage, CardHistoryPage, CardCalendarPage, CurrencyListPage, CurrencyConvertPage, AccountsErrorPage, NoAccountPage, TransferMainPage, PasswordPage, NewTransferWizard, - NewTransferConfirm, NewTransferSent, CardSumDetailPage, + NewTransferConfirm, NewTransferSent, CardSumDetailPage, MinorPage, ) from .transfer_pages import TransferListPage, TransferInfoPage @@ -84,7 +85,7 @@ class BoursoramaBrowser(RetryLoginBrowser, TwoFactorBrowser): ErrorPage ) login = URL(r'/connexion/saisie-mot-de-passe', PasswordPage) - + minor = URL(r'/connexion/mineur', MinorPage) accounts = URL(r'/dashboard/comptes\?_hinclude=300000', AccountsPage) accounts_error = URL(r'/dashboard/comptes\?_hinclude=300000', AccountsErrorPage) pro_accounts = URL(r'/dashboard/comptes-professionnels\?_hinclude=1', AccountsPage) @@ -263,7 +264,9 @@ def init_login(self): self.login.go() self.page.enter_password(self.username, self.password) - if self.error.is_here(): + if self.minor.is_here(): + raise NoAccountsException(self.page.get_error_message()) + elif self.error.is_here(): raise BrowserIncorrectPassword() elif self.login.is_here(): error = self.page.get_error() @@ -768,26 +771,46 @@ def execute_transfer(self, transfer, **kwargs): @need_login def init_new_recipient(self, recipient): - self.recipient_form = None # so it is reset when a new recipient is added + # so it is reset when a new recipient is added + self.recipient_form = None # get url + # If an account was provided for the recipient, use it + # otherwise use the first checking account available account = None for account in self.get_accounts_list(): - if account.url: + if not account.url: + continue + if recipient.origin_account_id is None: + if account.type == Account.TYPE_CHECKING: + break + elif account.id == recipient.origin_account_id: break - - suffix = 'virements/comptes-externes/nouveau' - if account.url.endswith('/'): - target = account.url + suffix else: - target = account.url + '/' + suffix + raise AddRecipientBankError(message="Compte ne permettant pas l'ajout de bénéficiaires") + + try: + self.go_recipients_list(account.url, account.id) + except AccountNotFound: + raise AddRecipientBankError(message="Compte ne permettant pas d'emettre des virements") + assert ( + self.recipients_page.is_here() + or self.new_transfer_wizard.is_here() + ), 'Should be on recipients page' + + if not self.page.is_new_recipient_allowed(): + raise AddRecipientBankError(message="Compte ne permettant pas l'ajout de bénéficiaires") + + target = '%s/virements/comptes-externes/nouveau' % account.url.rstrip('/') self.location(target) + assert self.page.is_characteristics(), 'Not on the page to add recipients.' # fill recipient form self.page.submit_recipient(recipient) - recipient.origin_account_id = account.id + if recipient.origin_account_id is None: + recipient.origin_account_id = account.id # confirm sending sms assert self.page.is_confirm_send_sms(), 'Cannot reach the page asking to send a sms.' @@ -802,7 +825,8 @@ def init_new_recipient(self, recipient): self.recipient_form['account_url'] = account.url raise AddRecipientStep(recipient, Value('otp_sms', label='Veuillez saisir le code recu par sms')) - # if the add recipient is restarted after the sms has been confirmed recently, the sms step is not presented again + # if the add recipient is restarted after the sms has been confirmed recently, + # the sms step is not presented again return self.rcpt_after_sms(recipient, account.url) def new_recipient(self, recipient, **kwargs): @@ -812,8 +836,9 @@ def new_recipient(self, recipient, **kwargs): # validating the sms code directly adds the recipient account_url = self.send_recipient_form(kwargs['otp_sms']) return self.rcpt_after_sms(recipient, account_url) + # step 3 of new_recipient (not always used) - elif 'otp_email' in kwargs: + if 'otp_email' in kwargs: account_url = self.send_recipient_form(kwargs['otp_email']) return self.check_and_update_recipient(recipient, account_url) @@ -855,7 +880,7 @@ def check_and_update_recipient(self, recipient, account_url): # We are taking it from the recipient list page # because there is no summary of the adding self.go_recipients_list(account_url, recipient.origin_account_id) - return find_object(self.page.iter_recipients(), id=recipient.id, error=RecipientNotFound) + return find_object(self.page.iter_recipients(), iban=recipient.iban, error=RecipientNotFound) @need_login def iter_transfers(self, account): diff --git a/modules/boursorama/pages.py b/modules/boursorama/pages.py index 2558a0ac7bc1a9a73626f830258cf9976928a677..ccd69f75006d3b178536f5ca473532a18b7836d5 100644 --- a/modules/boursorama/pages.py +++ b/modules/boursorama/pages.py @@ -40,7 +40,7 @@ MapIn, Lower, Base, ) from weboob.browser.filters.json import Dict -from weboob.browser.filters.html import Attr, Link, TableCell +from weboob.browser.filters.html import Attr, HasElement, Link, TableCell from .compat.weboob_capabilities_bank import ( Account as BaseAccount, Recipient, Transfer, TransferDateType, AccountNotFound, AddRecipientBankError, TransferInvalidAmount, Loan, AccountOwnership, @@ -1038,10 +1038,10 @@ class iter_history(TableElement): item_xpath = '//table/tbody/tr' head_xpath = '//table/thead/tr/th' - col_label = u'Opération' - col_amount = u'Montant' - col_date = u'Date opération' - col_vdate = u'Date Val' + col_label = 'Opération' + col_amount = 'Montant' + col_date = 'Date opération' + col_vdate = 'Date Valeur' next_page = Link('//li[@class="pagination__next"]/a') @@ -1111,6 +1111,11 @@ def on_load(self): raise ActionNeeded(error) +class MinorPage(HTMLPage): + def get_error_message(self): + return CleanText('//div[@id="modal-main-content"]//p')(self.doc) + + class ExpertPage(LoggedPage, HTMLPage): pass @@ -1287,6 +1292,9 @@ def submit_recipient(self, tempid): form['CreditAccount[creditAccountKey]'] = tempid form.submit() + def is_new_recipient_allowed(self): + return True + class NewTransferWizard(LoggedPage, HTMLPage): def get_errors(self): @@ -1333,15 +1341,22 @@ class item(ItemElement): klass = Recipient obj_id = CleanText( - './/span[contains(@class, "sub-label")]/span[not(contains(@class,"sub-label"))]', + './/span[contains(@class, "account-sub-label")]/span[not(contains(@class,"account-before-sub-label"))]', replace=[(' ', '')], ) - obj_label = CleanText(Regexp( - CleanText('.//span[contains(@class, "account-label")]'), - r'([^-]+)', - '\\1', - )) + # bank name finish with the following text " •" + obj_bank_name = CleanText('.//span[contains(@class, "account-before-sub-label")]', symbols=['•']) + + def obj_label(self): + bank_name = Field('bank_name')(self) + label = CleanText('.//span[contains(@class, "account-label")]')(self) + + # Sometimes, Boursorama appends the bank name at the end of the label + if not empty(bank_name): + label = label.replace('- %s' % bank_name, '').strip() + + return label def obj_category(self): text = CleanText( @@ -1373,6 +1388,13 @@ def submit_recipient(self, tempid): form.submit() + def is_new_recipient_allowed(self): + try: + self.get_form(name='CreditAccount') + except FormNotFound: + return False + return HasElement('//input[@id="CreditAccount_newBeneficiary"]')(self.doc) + # STEP 3 - # If using existing recipient: select the amount # If new beneficiary: select if new recipient is own account or third party one diff --git a/modules/bp/browser.py b/modules/bp/browser.py index bf6c815247721c1eadcd1b2f2b36507da55222c3..5f26569056e29f24e02a446767bc89a926fae4d8 100644 --- a/modules/bp/browser.py +++ b/modules/bp/browser.py @@ -53,7 +53,7 @@ SubscriptionPage, DownloadPage, ProSubscriptionPage, RevolvingAttributesPage, TwoFAPage, Validated2FAPage, SmsPage, DecoupledPage, HonorTransferPage, - RecipientSubmitDevicePage, OtpErrorPage, + CerticodePlusSubmitDevicePage, OtpErrorPage, ) from .pages.accounthistory import ( LifeInsuranceInvest, LifeInsuranceHistory, LifeInsuranceHistoryInv, RetirementHistory, @@ -281,8 +281,8 @@ class BPBrowser(LoginBrowser, StatesMixin): r'/voscomptes/canalXHTML/virement/virementSafran_sepa/confirmerInformations-virementSepa.ea', r'/voscomptes/canalXHTML/virement/virementSafran_national/valider-creerVirementNational.ea', r'/voscomptes/canalXHTML/virement/virementSafran_national/validerVirementNational-virementNational.ea', - # the following url is already used in transfer_summary - # but we need it to detect the case where the website displaies the list of devices + # The following url is already used in transfer_summary + # but we need it to detect the case where the website displays the list of devices # when a transfer is made with an otp or decoupled r'/voscomptes/canalXHTML/virement/virementSafran_sepa/confirmer-creerVirementSepa.ea', TransferConfirm @@ -301,9 +301,9 @@ class BPBrowser(LoginBrowser, StatesMixin): r'/voscomptes/canalXHTML/virement/mpiGestionBeneficiairesVirementsCreationBeneficiaire/valider-creationBeneficiaire.ea', ValidateRecipient ) - recipient_submit_device = URL( + certicode_plus_submit_device = URL( r'/voscomptes/canalXHTML/securisation/mpin/demandeCreation-securisationMPIN.ea', - RecipientSubmitDevicePage + CerticodePlusSubmitDevicePage ) rcpt_code = URL( r'/voscomptes/canalXHTML/virement/mpiGestionBeneficiairesVirementsCreationBeneficiaire/validerRecapBeneficiaire-creationBeneficiaire.ea', @@ -842,15 +842,26 @@ def execute_transfer(self, transfer): # If we just validated a code we land on transfer_summary. # If we just initiated the transfer we land on transfer_confirm. if self.transfer_confirm.is_here(): - # This will send a sms if a certicode validation is needed + # This will send a sms or an app validation if a certicode + # or certicode+ validation is needed. self.page.confirm() - if self.transfer_confirm.is_here() and self.page.is_certicode_needed(): + if self.transfer_confirm.is_here(): self.need_reload_state = True - self.sms_form = self.page.get_sms_form() - raise TransferStep( - transfer, - Value('code', label='Veuillez saisir le code de validation reçu par SMS'), - ) + if self.page.is_certicode_needed(): + self.sms_form = self.page.get_sms_form() + raise TransferStep( + transfer, + Value('code', label='Veuillez saisir le code de validation reçu par SMS'), + ) + elif self.page.is_certicode_plus_needed(): + device_choice_url = self.page.get_device_choice_url() + self.location(device_choice_url) + self.certicode_plus_submit_device.go(params={'deviceSelected': 0}) + message = self.page.get_app_validation_message() + raise AppValidation( + resource=transfer, + message=message, + ) return self.page.handle_response(transfer) @@ -879,7 +890,7 @@ def post_code(self, code): return True - def end_new_recipient_with_polling(self, recipient): + def end_with_polling(self, obj): polling_url = self.absurl( '/voscomptes/canalXHTML/securisation/mpin/validerOperation-securisationMPIN.ea', base=True @@ -889,7 +900,7 @@ def end_new_recipient_with_polling(self, recipient): '/voscomptes/canalXHTML/securisation/mpin/operationSucces-securisationMPIN.ea', base=True )) - return recipient + return obj @need_login def init_new_recipient(self, recipient, is_bp_account=False, **params): @@ -914,7 +925,7 @@ def init_new_recipient(self, recipient, is_bp_account=False, **params): self.location(device_choice_url) # force to use the first device like in the login to receive notification # this url send mobile notification - self.recipient_submit_device.go(params={'deviceSelected': 0}) + self.certicode_plus_submit_device.go(params={'deviceSelected': 0}) # Can do transfer to these recipient 48h after recipient.enabled_at = datetime.now().replace(microsecond=0) + timedelta(days=2) @@ -928,7 +939,7 @@ def init_new_recipient(self, recipient, is_bp_account=False, **params): def new_recipient(self, recipient, is_bp_account=False, **params): if params.get('resume') or self.resume: # Case of mobile app validation - return self.end_new_recipient_with_polling(recipient) + return self.end_with_polling(recipient) if 'code' in params: # Case of SMS OTP diff --git a/modules/bp/module.py b/modules/bp/module.py index cb1782e122e065ea953ecd37ed092427d925a5c4..37a9896cd9f1fa9bb6d0461190cfb80a7c3494a3 100644 --- a/modules/bp/module.py +++ b/modules/bp/module.py @@ -108,6 +108,8 @@ def init_transfer(self, transfer, **params): return self.browser.validate_transfer_eligibility(transfer, **params) elif 'code' in params: return self.browser.validate_transfer_code(transfer, params['code']) + elif 'resume' in params: + return self.browser.end_with_polling(transfer) self.logger.info('Going to do a new transfer') account = strict_find_object(self.iter_accounts(), iban=transfer.account_iban) diff --git a/modules/bp/pages/__init__.py b/modules/bp/pages/__init__.py index a2ad2d3bc30a4adbd62f1a38a7f82d95111c0fd4..2423993f022b1bebbe0aa7b91850c42e386dc7db 100644 --- a/modules/bp/pages/__init__.py +++ b/modules/bp/pages/__init__.py @@ -29,7 +29,7 @@ TransferChooseAccounts, CompleteTransfer, TransferConfirm, TransferSummary, CreateRecipient, ValidateRecipient, ValidateCountry, ConfirmPage, RcptSummary, - HonorTransferPage, RecipientSubmitDevicePage, + HonorTransferPage, CerticodePlusSubmitDevicePage, OtpErrorPage, ) from .subscription import SubscriptionPage, DownloadPage, ProSubscriptionPage @@ -40,5 +40,5 @@ 'AccountDesactivate', 'TransferChooseAccounts', 'CompleteTransfer', 'TransferConfirm', 'TransferSummary', 'UnavailablePage', 'CardsList', 'AccountRIB', 'Advisor', 'CreateRecipient', 'ValidateRecipient', 'ValidateCountry', 'ConfirmPage', 'RcptSummary', 'SubscriptionPage', 'DownloadPage', 'ProSubscriptionPage', 'RevolvingAttributesPage', 'Validated2FAPage', 'TwoFAPage', - 'SmsPage', 'DecoupledPage', 'HonorTransferPage', 'RecipientSubmitDevicePage', 'OtpErrorPage', + 'SmsPage', 'DecoupledPage', 'HonorTransferPage', 'CerticodePlusSubmitDevicePage', 'OtpErrorPage', ] diff --git a/modules/bp/pages/transfer.py b/modules/bp/pages/transfer.py index 45c124275b09fbf104c06a8bad027df5f9338700..01d28dbeb3d3d03087040e3030eb9de692d43d9a 100644 --- a/modules/bp/pages/transfer.py +++ b/modules/bp/pages/transfer.py @@ -224,6 +224,16 @@ def is_here(self): def is_certicode_needed(self): return CleanText('//div[contains(text(), "veuillez saisir votre code de validation reçu par SMS")]')(self.doc) + def is_certicode_plus_needed(self): + return CleanText('//script[contains(text(), "popupChoixDevice")]')(self.doc) + + def get_device_choice_url(self): + device_choice_popup_js = CleanText('//script[contains(text(), "popupChoixDevice")]')(self.doc) + if device_choice_popup_js: + device_choice_url = re.search(r'(?<=urlPopin = )\"(.*popUpDeviceChoice\.jsp)\";', device_choice_popup_js) + if device_choice_url: + return device_choice_url.group(1) + def get_sms_form(self): form = self.get_form(name='SaisieOTP') # Confirmation url is relative to the current page. We need to @@ -414,7 +424,7 @@ def get_error(self): return CleanText('//form//span[@class="warning" or @class="app_erreur"]')(self.doc) -class RecipientSubmitDevicePage(LoggedPage, MyHTMLPage): +class CerticodePlusSubmitDevicePage(LoggedPage, MyHTMLPage): def get_app_validation_message(self): # Mobile app message is too long, like this: # """ Une notification vous a été envoyée sur l’appareil que vous avez choisi: [PHONE]. @@ -426,7 +436,7 @@ def get_app_validation_message(self): app_validation_message = CleanText( '//main[@id="main"]//div[contains(text(), "Une notification vous a")]' )(self.doc) - assert app_validation_message, 'The notification message for new recipient is missing' + assert app_validation_message, 'The notification message is missing' msg_first_part = re.search(r'(.*)\. Vous pouvez', app_validation_message) if msg_first_part: diff --git a/modules/cmb/par/browser.py b/modules/cmb/par/browser.py index 1e7d0ca7d0b01f7d54ad935cda3c485cbb6b8171..e7b3222b8dd7a2c224479d2520823c0250073207 100644 --- a/modules/cmb/par/browser.py +++ b/modules/cmb/par/browser.py @@ -25,6 +25,13 @@ class CmbParBrowser(AbstractBrowser): PARENT_ATTR = 'package.par.browser.CmsoParBrowser' BASEURL = 'https://api.cmb.fr' + authorization_uri = 'https://api.cmb.fr/oauth/authorize' + access_token_uri = 'https://api.cmb.fr/oauth/token' + authorization_codegen_uri = 'https://api.cmb.fr/oauth/authorization-code' + redirect_uri = 'https://mon.cmb.fr/auth/checkuser' + error_uri = 'https://mon.cmb.fr/auth/errorauthn' + client_uri = 'com.arkea.cmb.siteaccessible' + name = 'cmb' arkea = '01' arkea_si = '001' diff --git a/modules/cmso/par/browser.py b/modules/cmso/par/browser.py index 992e5d968739f060597b6b96629e79ca3ec2d9cf..d8093240052a1abefeb2b5cdcc2f2332d3fdda55 100644 --- a/modules/cmso/par/browser.py +++ b/modules/cmso/par/browser.py @@ -22,8 +22,11 @@ from __future__ import unicode_literals import time +import os +import base64 from datetime import date from functools import wraps +from hashlib import sha256 from .compat.weboob_browser_browsers import TwoFactorBrowser, URL, need_login from weboob.browser.exceptions import ClientError, ServerError @@ -88,7 +91,6 @@ class CmsoParBrowser(TwoFactorBrowser): BASEURL = 'https://api.cmso.com' login = URL( - r'/oauth-implicit/token', r'/auth/checkuser', LoginPage ) @@ -136,6 +138,13 @@ class CmsoParBrowser(TwoFactorBrowser): json_headers = {'Content-Type': 'application/json'} + authorization_uri = 'https://api.cmso.com/oauth/authorize' + access_token_uri = 'https://api.cmso.com/oauth/token' + authorization_codegen_uri = 'https://api.cmso.com/oauth/authorization-code' + redirect_uri = 'https://mon.cmso.com/auth/checkuser' + error_uri = 'https://mon.cmso.com/auth/errorauthn' + client_uri = 'com.arkea.cmso.siteaccessible' + # Values needed for login which are specific for each arkea child name = 'cmso' arkea = '03' @@ -158,22 +167,59 @@ def __init__(self, website, config, *args, **kwargs): 'code': self.handle_sms, } + def code_challenge(self, verifier): + digest = sha256(verifier.encode('utf8')).digest() + return base64.b64encode(digest).decode('ascii') + + def code_verifier(self): + return base64.b64encode(os.urandom(128)).decode('ascii') + def init_login(self): self.location(self.original_site) if self.headers: self.session.headers = self.headers else: - self.set_profile(self.PROFILE) # reset headers but don't clear them self.session.cookies.clear() self.accounts_list = [] - data = self.get_login_data() - self.login.go(data=data) + # authorization request + verifier = self.code_verifier() + challenge = self.code_challenge(verifier) + params = { + 'redirect_uri': self.redirect_uri, + 'client_id': self.arkea_client_id, + 'response_type': 'code', + 'error_uri': self.error_uri, + 'code_challenge_method': 'S256', + 'code_challenge': challenge, + } + response = self.location(self.authorization_uri, params=params) + + # get session_id in param location url + location_params = dict(parse_qsl(urlparse(response.headers['Location']).fragment)) + + self.set_profile(self.PROFILE) # reset headers but don't clear them + + # authorization-code generation + data = self.get_authcode_data() + response = self.location(self.authorization_codegen_uri, data=data, params=location_params) + location_params = dict(parse_qsl(urlparse(response.headers['Location']).fragment)) - if self.logout.is_here(): - raise BrowserIncorrectPassword() + if location_params.get('error'): + if location_params.get('error_description') == 'authentication-failed': + raise BrowserIncorrectPassword() + # we encounter this case when an error comes from the website + elif location_params['error'] == 'server_error': + raise BrowserUnavailable() - self.update_authentication_headers() + # authentication token generation + json = self.get_tokengen_data(location_params['code'], verifier) + response = self.location(self.access_token_uri, json=json) + self.update_authentication_headers(response.json()) + + if location_params.get('scope') == 'consent': + self.check_interactive() + self.send_sms() def send_sms(self): contact_information = self.location('/securityapi/person/coordonnees', method='POST').json() @@ -204,9 +250,9 @@ def get_sms_data(self): 'otpValue': self.code, 'typeMedia': 'WEB', 'userAgent': 'Mozilla/5.0 (X11; Linux x86_64; rv:68.0) Gecko/20100101 Firefox/68.0', - 'redirectUri': '%s/auth/checkuser' % self.original_site, - 'errorUri': '%s/auth/errorauthn' % self.original_site, - 'clientId': 'com.arkea.%s.siteaccessible' % self.name, + 'redirectUri': self.redirect_uri, + 'errorUri': self.error_uri, + 'clientId': self.client_uri, 'redirect': 'true', 'client_id': self.arkea_client_id, 'accessInfos': { @@ -215,38 +261,28 @@ def get_sms_data(self): }, } - def get_login_data(self): + def get_authcode_data(self): return { - 'client_id': self.arkea_client_id, - 'responseType': 'token', - 'accessCode': self.username, + 'access_code': self.username, 'password': self.password, - 'clientId': 'com.arkea.%s.siteaccessible' % self.name, - 'redirectUri': '%s/auth/checkuser' % self.original_site, - 'errorUri': '%s/auth/errorauthn' % self.original_site, - 'fingerprint': 'b61a924d1245beb7469fef44db132e96', } - def update_authentication_headers(self): - hidden_params = dict(parse_qsl(urlparse(self.url).fragment)) + def get_tokengen_data(self, code, verifier): + return { + 'client_id': self.arkea_client_id, + 'code': code, + 'grant_type': 'authorization_code', + 'code_verifier': verifier, + 'redirect_uri': self.redirect_uri, + } - self.session.headers.update({ - 'Authorization': "Bearer %s" % hidden_params['access_token'], - 'X-ARKEA-EFS': self.arkea, - 'X-Csrf-Token': hidden_params['access_token'], - 'X-REFERER-TOKEN': 'RWDPART', - }) + def update_authentication_headers(self, params): + self.session.headers['Authorization'] = "Bearer %s" % params['access_token'] + self.session.headers['X-ARKEA-EFS'] = self.arkea + self.session.headers['X-Csrf-Token'] = params['access_token'] + self.session.headers['X-REFERER-TOKEN'] = 'RWDPART' self.headers = self.session.headers - scope = hidden_params.get('scope') - - # if there is no scope, 2FA is not needed - if scope and scope == 'consent': - # 2FA is needed - # consent is the only scope that should send a sms - self.check_interactive() - self.send_sms() - def get_account(self, _id): return find_object(self.iter_accounts(), id=_id, error=AccountNotFound) @@ -375,7 +411,7 @@ def iter_history(self, account): elif account.type in (Account.TYPE_PEA, Account.TYPE_MARKET): try: self._go_market_history('historiquePortefeuille') - if not self.page.go_account(account.label, account._owner): + if not self.page.go_account(account.id): return if not self.page.go_account_full(): @@ -396,16 +432,25 @@ def iter_history(self, account): finally: self._return_from_market() - # Getting a year of history - # We have to finish by "SIX_DERNIERES_SEMAINES" to get in priority the transactions with ids. - # In "SIX_DERNIERES_SEMAINES" you can have duplicates transactions without ids of the previous two months. - nbs = ["DEUX", "TROIS", "QUATRE", "CINQ", "SIX", "SEPT", "HUIT", "NEUF", "DIX", "ONZE", "DOUZE", "SIX_DERNIERES_SEMAINES"] - trs = [] - self.history.go(json={"index": account._index}, page="pendingListOperations") - has_deferred_cards = self.page.has_deferred_cards() + # 1.fetch the last 6 weeks transactions but keep only the current month ones + # those don't have any id and include 'hier' and 'Plus tôt dans la semaine' + trs = [] + self.history.go( + json={ + 'index': account._index, + 'filtreOperationsComptabilisees': "SIX_DERNIERES_SEMAINES", + }, + page="detailcompte" + ) + for tr in self.page.iter_history(index=account._index, last_trs=True): + trs.append(tr) + + # 2. get the month by month transactions + # and avoid duplicates based on ids + nbs = ["DEUX", "TROIS", "QUATRE", "CINQ", "SIX", "SEPT", "HUIT", "NEUF", "DIX", "ONZE", "DOUZE"] self.history.go( json={ 'index': account._index, @@ -414,7 +459,6 @@ def iter_history(self, account): page="detailcompte" ) self.trs = set() - for tr in self.page.iter_history(index=account._index, nbs=nbs): # Check for duplicates if tr._operationid in self.trs or (tr.id and tr.id in self.trs): @@ -475,7 +519,7 @@ def iter_investment(self, account): elif account.type in (Account.TYPE_MARKET, Account.TYPE_PEA): try: self._go_market_history('situationPortefeuille') - if self.page.go_account(account.label, account._owner): + if self.page.go_account(account.id): return self.page.iter_investment() return [] finally: @@ -490,7 +534,7 @@ def iter_market_orders(self, account): try: self._go_market_history('carnetOrdre') - if self.page.go_account(account.label, account._owner): + if self.page.go_account(account.id): orders_list_url = self.url error_message = self.page.get_error_message() if error_message: diff --git a/modules/cmso/par/pages.py b/modules/cmso/par/pages.py index c44b396e1807d4e4a70e509da2ee4a44a163d855..15dad402f4c313793ec0ea1aa61bb325b30afb92 100644 --- a/modules/cmso/par/pages.py +++ b/modules/cmso/par/pages.py @@ -261,7 +261,7 @@ def obj_id(self): if number: return number elif type in (Account.TYPE_PEA, Account.TYPE_MARKET): - number = self.get_market_number() + number = Dict('idTechnique')(self)[5:] # first 5 characters are the bank id if number: return number @@ -287,14 +287,6 @@ def obj_ownership(self): return AccountOwnership.OWNER return AccountOwnership.ATTORNEY - def get_market_number(self): - label = Field('label')(self) - try: - page = self.page.browser._go_market_history('historiquePortefeuille') - return page.get_account_id(label, Field('_owner')(self)) - finally: - self.page.browser._return_from_market() - def get_lifenumber(self): index = Dict('index')(self) data = json.loads(self.page.browser.redirect_insurance.open(accid=index).text) @@ -417,9 +409,8 @@ class iter_history(DictElement): def next_page(self): if len(Env('nbs', default=[])(self)): data = {'index': Env('index')(self)} - if Env('nbs')(self)[0] != "SIX_DERNIERES_SEMAINES": - data.update({'filtreOperationsComptabilisees': "MOIS_MOINS_%s" % Env('nbs')(self)[0]}) - Env('nbs')(self).pop(0) + next_month = Env('nbs')(self).pop(0) + data.update({'filtreOperationsComptabilisees': "MOIS_MOINS_%s" % next_month}) return requests.Request('POST', data=json.dumps(data)) def parse(self, el): @@ -470,6 +461,12 @@ def parse(self, el): break self.obj._deferred_date = self.FromTimestamp().filter(deferred_date) + def validate(self, obj): + if Env('last_trs', default=None)(self): + # keep only current month transactions + return obj.date.month >= datetime.date.today().month + return True + class RedirectInsurancePage(LoggedPage, JsonPage): def get_url(self): @@ -583,7 +580,7 @@ def obj_diff_ratio(self): class MarketPage(LoggedPage, HTMLPage): - def find_account(self, acclabel, accowner): + def find_account(self, account_id): # Depending on what we're fetching (history, invests or orders), # the parameter to choose the account has a different name. if 'carnetOrdre' in self.url: @@ -591,23 +588,20 @@ def find_account(self, acclabel, accowner): else: param_name = 'indiceCompte' # first name and last name may not be ordered the same way on market site... - accowner = sorted(accowner.lower().split()) - def get_ids(ref, acclabel, accowner, param_name): - ids = None + def get_ids(ref, account_id, param_name): + # Market account IDs contain 3 parts: + # - the first 5 and last 2 digits identify the account + # - the 9 digits in the middle identify the owner of the account + # These info are separated on the page so we need to get them from the id to match the account. + owner_id = account_id[5:14] + account_number = '%s%s' % (account_id[:5], account_id[-2:]) for a in self.doc.xpath('//a[contains(@%s, "%s")]' % (ref, param_name)): self.logger.debug("get investment from %s" % ref) - label = CleanText('.')(a) - owner = CleanText('./ancestor::tr/preceding-sibling::tr[@class="LnMnTiers"][1]')(a) - owner = re.sub(r' \(.+', '', owner) - owner = sorted(owner.lower().split()) - if label == acclabel and owner == accowner: - ids = list( - re.search(r'%s[^\d]+(\d+).*idRacine[^\d]+(\d+)' % param_name, Attr('.', ref)(a)).groups() - ) - ids.append(CleanText('./ancestor::td/preceding-sibling::td')(a)) - self.logger.debug("assign value to ids: {}".format(ids)) - return ids + number = CleanText('./ancestor::td/preceding-sibling::td')(a).replace(' ', '') + if number in (account_id, account_number): + index = re.search(r'%s[^\d]+(\d+).*idRacine' % param_name, Attr('.', ref)(a)).group(1) + return [index, owner_id, number] # Check if history is present if CleanText(default=None).filter(self.doc.xpath('//body/p[contains(text(), "indisponible pour le moment")]')): @@ -615,22 +609,17 @@ def get_ids(ref, acclabel, accowner, param_name): ref = CleanText(self.doc.xpath('//a[contains(@href, "%s")]' % param_name))(self) if not ref: - return get_ids('onclick', acclabel, accowner, param_name) + return get_ids('onclick', account_id, param_name) else: - return get_ids('href', acclabel, accowner, param_name) - - def get_account_id(self, acclabel, owner): - account = self.find_account(acclabel, owner) - if account: - return account[2].replace(' ', '') + return get_ids('href', account_id, param_name) - def go_account(self, acclabel, owner): + def go_account(self, account_id): if 'carnetOrdre' in self.url: param_name = 'idCompte' else: param_name = 'indiceCompte' - ids = self.find_account(acclabel, owner) + ids = self.find_account(account_id) if not ids: return diff --git a/modules/cragr/browser.py b/modules/cragr/browser.py index e27c1a7988cac3468339ea8078568260ea078535..ba4e77bf4a28d11e1bb779cc75081307140415c6 100644 --- a/modules/cragr/browser.py +++ b/modules/cragr/browser.py @@ -715,7 +715,7 @@ def iter_investment(self, account): if ( account.type == Account.TYPE_LIFE_INSURANCE - and ('rothschild' in account.label.lower() or re.match(r'^open (perspective|strat)', account.label, re.I)) + and re.match(r'(rothschild)|(^patrimoine st honor)|(^open (perspective|strat))', account.label, re.I) ): # We must go to the right perimeter before trying to access the Life Insurance investments self.go_to_account_space(account._contract) diff --git a/modules/cragr/pages.py b/modules/cragr/pages.py index 61e4cd123e519473ebd878f9176d7ba18698ce3f..ae186fa156a5f6cd3b215ea00939910bf98f1a77 100644 --- a/modules/cragr/pages.py +++ b/modules/cragr/pages.py @@ -576,6 +576,14 @@ class item(ItemElement): # we do not use it. obj_date = Date(CleanText(Dict('dateOperation'))) + obj_label = CleanText( + Format( + '%s %s', + CleanText(Dict('libelleTypeOperation', default='')), + CleanText(Dict('libelleOperation')) + ) + ) + # Transactions in foreign currencies have no 'libelleTypeOperation' # and 'libelleComplementaire' keys, hence the default values. # The CleanText() gets rid of additional spaces. @@ -604,11 +612,6 @@ def obj_rdate(self): return rdate return date - obj_label = CleanText( - Format( - '%s %s', CleanText(Dict('libelleTypeOperation', default='')), CleanText(Dict('libelleOperation')) - ) - ) obj_amount = Eval(float_to_decimal, Dict('montant')) obj_type = Map( CleanText(Dict('libelleTypeOperation', default='')), TRANSACTION_TYPES, Transaction.TYPE_UNKNOWN @@ -655,8 +658,8 @@ class iter_card_history(DictElement): class item(ItemElement): klass = Transaction - obj_raw = CleanText(Dict('libelleOperation')) obj_label = CleanText(Dict('libelleOperation')) + obj_raw = Transaction.Raw(CleanText(Dict('libelleOperation'))) obj_amount = Eval(float_to_decimal, Dict('montant')) obj_type = Transaction.TYPE_DEFERRED_CARD obj_bdate = Field('rdate') diff --git a/modules/creditdunord/pages.py b/modules/creditdunord/pages.py index 35980240533ab06b32ac3774e1f54fc0d01d0416..3bef8aa5c103f451219c2b4a400b76ad6e65fab3 100755 --- a/modules/creditdunord/pages.py +++ b/modules/creditdunord/pages.py @@ -720,11 +720,19 @@ class item(ItemElement): klass = Investment obj_label = CleanText(TableCell('label', colspan=True)) - obj_valuation = MyDecimal(TableCell('valuation', colspan=True)) - obj_quantity = MyDecimal(TableCell('quantity', colspan=True)) - obj_unitvalue = MyDecimal(TableCell('unitvalue', colspan=True)) - obj_unitprice = MyDecimal(TableCell('unitprice', colspan=True)) - obj_portfolio_share = Eval(lambda x: x / 100, MyDecimal(TableCell('portfolio_share'))) + obj_valuation = CleanDecimal.French(TableCell('valuation', colspan=True)) + obj_quantity = CleanDecimal.French(TableCell('quantity', colspan=True), default=NotAvailable) + obj_unitvalue = CleanDecimal.French(TableCell('unitvalue', colspan=True), default=NotAvailable) + obj_unitprice = CleanDecimal.French( + TableCell('unitprice', colspan=True, default=None), + default=NotAvailable + ) + + def obj_portfolio_share(self): + portfolio_share_percent = CleanDecimal.French(TableCell('portfolio_share'), default=None)(self) + if portfolio_share_percent is not None: + return portfolio_share_percent / 100 + return NotAvailable def obj_code(self): for code in Field('label')(self).split(): diff --git a/modules/creditmutuel/browser.py b/modules/creditmutuel/browser.py index 903120c5c231d8a05b8ca2286bffb08f02cc9d32..1284d2398e9d43340aaac5282eee5e191071bd61 100644 --- a/modules/creditmutuel/browser.py +++ b/modules/creditmutuel/browser.py @@ -135,7 +135,7 @@ class CreditMutuelBrowser(TwoFactorBrowser): r'/(?P.*)fr/validation/(?!change_password|verif_code|image_case|infos).*', EmptyPage) por = URL( - r'/(?P.*)fr/banque/PORT_Synthese.aspx\?entete=1', + r'/(?P.*)fr/banque/SYNT_Synthese.aspx\?entete=1', r'/(?P.*)fr/banque/PORT_Synthese.aspx', r'/(?P.*)fr/banque/SYNT_Synthese.aspx', PorPage @@ -365,7 +365,10 @@ def check_redirections(self): self.twofa_auth_state = {} self.check_interactive() elif location: - self.location(location, allow_redirects=False) + allow_redirects = 'conditions-generales' in location + # Don't stay on this 302 + # This URL is still caught by ConditionsPage + self.location(location, allow_redirects=allow_redirects) def check_auth_methods(self): self.getCurrentSubBank() @@ -981,6 +984,8 @@ def init_transfer(self, transfer, account, recipient): self.key_form = self.page.get_personal_key_card_code_form() raise TransferStep(transfer, Value('Clé', label=self.page.get_question())) + elif self.page.needs_otp_validation(): + raise AuthMethodNotImplemented("La validation des transferts avec un code sms n'est pas encore disponible.") msg = self.page.get_validation_msg() if msg: diff --git a/modules/creditmutuel/pages.py b/modules/creditmutuel/pages.py index a55d32b385ad751b8f8d4b77b0f2139c98e8ca4b..4361a204ae22719db5080f192e63a2475355f184 100644 --- a/modules/creditmutuel/pages.py +++ b/modules/creditmutuel/pages.py @@ -1608,22 +1608,25 @@ def condition(self): self.env['id'] = CleanText('.//a', replace=[(' ', '')])(self) self.env['balance'] = CleanDecimal.French(TableCell('balance'), default=None)(self) is_total = 'TOTAL VALO' in CleanText('.')(self) + is_liquidity = ( + 'LIQUIDITE' in CleanText(TableCell('raw_label'))(self) + or 'TOTAL Compte espèces' in CleanText('.')(self) + ) is_global_view = Env('id')(self) == 'Vueconsolidée' has_empty_balance = Env('balance')(self) is None return ( not is_total + and not is_liquidity and not is_global_view and not has_empty_balance ) # This values are defined for other types of accounts obj__is_inv = True - - # IDs on the old page were differentiated with 5 digits in front of the ID, but not here. - # We still need to differentiate them so we add ".1" at the end. - obj_id = Format('%s.1', Env('id')) - - obj_label = Base(TableCell('raw_label'), CleanText('.', children=False)) + obj_label = Coalesce( + Base(TableCell('raw_label'), CleanText('.', children=False)), + Base(TableCell('raw_label'), CleanText('./span[not(.//a)]')), + ) obj_number = Base(TableCell('raw_label'), CleanText('./a', replace=[(' ', '')])) obj_balance = Env('balance') @@ -1631,7 +1634,11 @@ def condition(self): obj_valuation_diff = CleanDecimal.French(TableCell('valuation_diff'), default=NotAvailable) - obj__link_id = Regexp(Link('.//a', default=NotAvailable), r'ddp=([^&]*)', default=NotAvailable) + obj__link_id = Regexp(Link('.//a', default=''), r'ddp=([^&]*)', default=NotAvailable) + + # IDs on the old page were differentiated with 5 digits in front of the ID, but not here. + # We still need to differentiate them so we add ".1" at the end. + obj_id = Format('%s.1', Env('id')) def obj_type(self): return self.page.get_type(Field('label')(self)) @@ -1925,9 +1932,9 @@ class item(ItemElement): klass = MarketOrder def condition(self): - return 'Remboursement' not in CleanText('.')(self) + return Base(TableCell('direction'), Link('.//a', default=None))(self) is not None - obj_id = Base(TableCell('direction'), Regexp(Link('.//a', default=NotAvailable), r'ref=([^&]+)')) + obj_id = Base(TableCell('direction'), Regexp(Link('.//a', default=''), r'ref=([^&]+)', default=None)) obj_direction = Map( CleanText(TableCell('direction')), MARKET_ORDER_DIRECTIONS, @@ -2049,6 +2056,9 @@ class InternalTransferPage(LoggedPage, HTMLPage, AppValidationPage): def needs_personal_key_card_validation(self): return bool(CleanText('//div[contains(@class, "alerte")]/p[contains(text(), "Cette opération nécessite une sécurité supplémentaire")]')(self.doc)) + def needs_otp_validation(self): + return bool(self.doc.xpath('//input[@name="otp_password"]')) + def can_transfer_pro(self, origin_account): for li in self.doc.xpath('//ul[@id="idDetailsListCptDebiterVertical:ul"]//ul/li'): if CleanText(li.xpath('.//span[@class="_c1 doux _c1"]'), replace=[(' ', '')])(self) in origin_account: @@ -2331,7 +2341,7 @@ class VerifCodePage(LoggedPage, HTMLPage): '55ffe065456d33e70152ad860154d190': 'C7', '13a927f61873ba6f2615fb529608629f': 'C8', 'e48146297f68ce172b9d4092827fbd2c': 'D1', - '92ee176c2ee21821066747ca22ab42f0': 'D2', + ('92ee176c2ee21821066747ca22ab42f0', 'fece6856f73a859cb2c17bbca6fd2c03'): 'D2', 'b405d1912ba172052c198b14b50db18f': 'D3', '6a65689653e2465fc50e8765b8d5f89b': 'D4', 'de0f615ea01463a764e5031a696160a2': 'D5', @@ -2342,14 +2352,14 @@ class VerifCodePage(LoggedPage, HTMLPage): 'c42126f7c01365992c2a99d6164c6599': 'E2', '978172427932c2a2a867baa25eb68ee0': 'E3', '837c374cba2c11cfea800aaff06ca0b1': 'E4', - '041deaaff4b0d312f99afd5d9256af6c': 'E5', + ('041deaaff4b0d312f99afd5d9256af6c', 'f779b306a255a996739dbac816ad99f2'): 'E5', 'a3d2eea803f71200b851146d6f57998b': 'E6', '9cd913b53b6cd028bd609b8546af9b0d': 'E7', - '17308564239363735a6a9f34021d26a9': 'E8', + ('17308564239363735a6a9f34021d26a9', '173ce025e2ca0a9610954e438710db9a'): 'E8', '89b913bc935a3788bf4fe6b35778a372': 'F1', '7651835218b5a7538b5b9d20546d014b': 'F2', 'f32bcdac80720bf39927dde41a8a21b8': 'F3', - '4ed222ecfd6676fcb6a4908ce915e63d': 'F4', + ('4ed222ecfd6676fcb6a4908ce915e63d', 'dc104bd7d4efffde4ccddb8d6eb9f219'): 'F4', '4151f3c6531cde9bc6a1c44e89d9e47a': 'F5', '6a2987e43cccc6a265c37aa73bb18703': 'F6', '67f777297fec2040638378fae4113aa5': 'F7', @@ -2367,7 +2377,7 @@ class VerifCodePage(LoggedPage, HTMLPage): 'cb4c92a05ef2c621b49b3b12bdc1676e': 'H3', '641883bd5878f512b6bcd60c53872749': 'H4', '9e5541bd54865ba57514466881b9db41': 'H5', - '03cc8d41cdf5e3d8d7e3f11b25f1cd5c': 'H6', + ('03cc8d41cdf5e3d8d7e3f11b25f1cd5c', '0571d352020fde0463904e6e09c7f309'): 'H6', '203ec0695ec93bfd947c33b41802562b': 'H7', 'cbd1e9d2276ecc9cd7e6cae9b0127d58': 'H8', } diff --git a/modules/hsbc/browser.py b/modules/hsbc/browser.py index 5b2b9f831e1a848d0873456dfe46609bd7e23a3b..8fd8d78f1e5c4bf83f3015883fdedb925a58e4cb 100644 --- a/modules/hsbc/browser.py +++ b/modules/hsbc/browser.py @@ -33,7 +33,7 @@ from weboob.tools.compat import parse_qsl, urlparse from .compat.weboob_tools_value import Value from weboob.exceptions import BrowserIncorrectPassword, BrowserUnavailable, BrowserQuestion -from weboob.browser.browsers import URL, need_login +from weboob.browser.browsers import URL, need_login, from .compat.weboob_browser_browsers import TwoFactorBrowser from weboob.browser.exceptions import HTTPNotFound from weboob.capabilities.base import find_object diff --git a/modules/hsbc/pages/account_pages.py b/modules/hsbc/pages/account_pages.py index 4bc00ad9fdb07992f6a5a3a5daf82b98da1ba829..0edac09ac4bce7e045a61bbb7f194d59976244bd 100644 --- a/modules/hsbc/pages/account_pages.py +++ b/modules/hsbc/pages/account_pages.py @@ -141,11 +141,13 @@ def filter(self, text): class AccountsPage(GenericLandingPage): + IS_HERE_CONDITIONS = '//p[contains(text(), "Tous mes comptes au ")]|//span[contains(text(), "Tous mes comptes au ")]' + def is_here(self): return ( CleanText('//h1[contains(text(), "Synthèse")]')(self.doc) or CleanText( - '//p[contains(text(), "Tous mes comptes au ")]|//span[contains(text(), "Tous mes comptes au ")]' + self.IS_HERE_CONDITIONS )(self.doc) ) diff --git a/modules/lcl/browser.py b/modules/lcl/browser.py index 0a7eef286619db092325136c5e9e2187b9343499..4a2c0230d11a9950719bc9a6f4511df785200511 100644 --- a/modules/lcl/browser.py +++ b/modules/lcl/browser.py @@ -33,7 +33,7 @@ AuthMethodNotImplemented, BrowserQuestion, AppValidation, AppValidationCancelled, AppValidationExpired, ) -from weboob.browser.browsers import URL, need_login +from weboob.browser.browsers import URL, need_login, from .compat.weboob_browser_browsers import TwoFactorBrowser from weboob.browser.exceptions import ServerError, ClientError from weboob.capabilities.base import NotAvailable @@ -205,10 +205,6 @@ class LCLBrowser(TwoFactorBrowser): IDENTIFIANT_ROUTING = 'CLI' def __init__(self, config, *args, **kwargs): - self.config = config - kwargs['username'] = self.config['login'].get() - kwargs['password'] = self.config['password'].get() - super(LCLBrowser, self).__init__(config, *args, **kwargs) self.accounts_list = None self.current_contract = None diff --git a/modules/lcl/enterprise/browser.py b/modules/lcl/enterprise/browser.py index 93d739d6a03f6682b9e534919f1b1379e803e820..a81bd54dd28f392d45e7205e348ccc86755e00e9 100644 --- a/modules/lcl/enterprise/browser.py +++ b/modules/lcl/enterprise/browser.py @@ -42,7 +42,7 @@ class LCLEnterpriseBrowser(LoginBrowser): ) profile = URL('/outil/IQGA/FicheUtilisateur/maFicheUtilisateur', ProfilePage) - def __init__(self, *args, **kwargs): + def __init__(self, config, *args, **kwargs): super(LCLEnterpriseBrowser, self).__init__(*args, **kwargs) self.accounts = None self.owner_type = AccountOwnerType.ORGANIZATION diff --git a/modules/lcl/module.py b/modules/lcl/module.py index 18da2acd938fd6f685aef15924a95d22a1898a45..1425884c5d9728df0ff40ba2f2071f78ade5b5cf 100644 --- a/modules/lcl/module.py +++ b/modules/lcl/module.py @@ -108,6 +108,8 @@ def create_default_browser(self): return self.create_browser( self.config, + self.config['login'].get(), + self.config['password'].get() ) def iter_accounts(self): diff --git a/modules/lcl/pages.py b/modules/lcl/pages.py index 5e703f1aae41a82d06509ed42f017d718ef765df..b680fec082ba98db5fd431626bca6890195b1ccb 100644 --- a/modules/lcl/pages.py +++ b/modules/lcl/pages.py @@ -819,6 +819,11 @@ def condition(self): return True +MARKET_TRANSACTION_TYPES = { + 'VIREMENT': Transaction.TYPE_TRANSFER, +} + + class BoursePage(LoggedPage, HTMLPage): ENCODING = 'latin-1' REFRESH_MAX = 0 @@ -977,7 +982,7 @@ class item(ItemElement): klass = Transaction obj_date = Date(CleanText(TableCell('date')), dayfirst=True) - obj_type = Transaction.TYPE_BANK + obj_type = MapIn(Field('label'), MARKET_TRANSACTION_TYPES, Transaction.TYPE_BANK) obj_amount = CleanDecimal(TableCell('amount'), replace_dots=True) obj_investments = Env('investments') diff --git a/modules/myfoncia/browser.py b/modules/myfoncia/browser.py index 3c6f029d839dc06e82e9eb5ce55559e2d8e4e945..6bdaf17054b94ecfb1df4d1389e101cf5fe0529a 100644 --- a/modules/myfoncia/browser.py +++ b/modules/myfoncia/browser.py @@ -23,7 +23,7 @@ from weboob.browser import LoginBrowser, need_login, URL from weboob.exceptions import BrowserIncorrectPassword -from .pages import LoginPage, MonBienPage, MesChargesPage +from .pages import LoginPage, MonBienPage, MesChargesPage, DocumentsPage class MyFonciaBrowser(LoginBrowser): @@ -32,6 +32,7 @@ class MyFonciaBrowser(LoginBrowser): login = URL(r'/login', LoginPage) monBien = URL(r'/espace-client/espace-de-gestion/mon-bien', MonBienPage) mesCharges = URL(r'/espace-client/espace-de-gestion/mes-charges/(?P.+)', MesChargesPage) + documents = URL(r'/espace-client/espace-de-gestion/mes-documents/(?P.+)/(?P[A-Z])', DocumentsPage) def do_login(self): self.login.stay_or_go().do_login(self.username, self.password) @@ -45,5 +46,14 @@ def get_subscriptions(self): return self.monBien.stay_or_go().get_subscriptions() @need_login - def get_documents(self, subscription): - return self.mesCharges.stay_or_go(subscription=subscription).get_documents() + def get_documents(self, subscription_id): + # the last char of subscription_id is a letter, we need this to put this at the end of the url + if not subscription_id[-1:].isupper(): + self.logger.debug('The last char of subscription id is not an uppercase') + self.documents.go(subscription=subscription_id, letter=subscription_id[-1:]) + for doc in self.page.iter_documents(): + yield doc + + self.mesCharges.go(subscription=subscription_id) + for bill in self.page.get_documents(): + yield bill diff --git a/modules/myfoncia/module.py b/modules/myfoncia/module.py index bcee3cc46cbbbcb7edd57439b5c6b252da0ce53c..0f1128d5206196dfcf9a4d4c6b1985ce28f067da 100644 --- a/modules/myfoncia/module.py +++ b/modules/myfoncia/module.py @@ -22,9 +22,11 @@ from weboob.tools.backend import Module, BackendConfig from weboob.capabilities.base import find_object -from weboob.capabilities.bill import (CapDocument, Bill, DocumentNotFound, - Subscription) -from .compat.weboob_tools_value import Value, ValueBackendPassword +from weboob.capabilities.bill import ( + CapDocument, DocumentNotFound, + Subscription, DocumentTypes, Document, +) +from .compat.weboob_tools_value import ValueBackendPassword from .browser import MyFonciaBrowser @@ -40,20 +42,18 @@ class MyFonciaModule(Module, CapDocument): LICENSE = 'LGPLv3+' VERSION = '2.0' CONFIG = BackendConfig( - Value( - 'login', - label='Email address or Foncia ID' - ), - ValueBackendPassword( - 'password', - label='Password' - ) + ValueBackendPassword('login', label='Email address or Foncia ID'), + ValueBackendPassword('password', label='Password'), ) BROWSER = MyFonciaBrowser + accepted_document_types = (DocumentTypes.BILL, DocumentTypes.REPORT,) + def create_default_browser(self): - return self.create_browser(self.config['login'].get(), - self.config['password'].get()) + return self.create_browser( + self.config['login'].get(), + self.config['password'].get() + ) def iter_subscription(self): return self.browser.get_subscriptions() @@ -65,18 +65,17 @@ def iter_documents(self, subscription): subscription_id = subscription return self.browser.get_documents(subscription_id) - def get_document(self, bill): - return find_object( - self.iter_documents(bill.split("#")[0]), - id=bill, - error=DocumentNotFound - ) + def get_document(self, _id): + subid = _id.rsplit('_', 1)[0] + subscription = self.get_subscription(subid) + + return find_object(self.iter_documents(subscription), id=_id, error=DocumentNotFound) - def download_document(self, bill): - if not isinstance(bill, Bill): - bill = self.get_document(bill) + def download_document(self, document): + if not isinstance(document, Document): + document = self.get_document(document) - if not bill.url: + if not document.url: return None - return self.browser.open(bill.url).content + return self.browser.open(document.url).content diff --git a/modules/myfoncia/pages.py b/modules/myfoncia/pages.py index 118f4cd9b6306d977ed8c988791fb993a3c11468..c94fdd44c08aae140b36db57c4e9b11f8d02119b 100644 --- a/modules/myfoncia/pages.py +++ b/modules/myfoncia/pages.py @@ -21,10 +21,10 @@ from .compat.weboob_browser_pages import HTMLPage, LoggedPage from .compat.weboob_browser_filters_standard import CleanDecimal, CleanText, Date, Env, Format -from weboob.browser.filters.html import Attr, Link, XPathNotFound +from weboob.browser.filters.html import Attr, Link, XPathNotFound, AbsoluteLink from weboob.browser.elements import ItemElement, ListElement, method from weboob.capabilities.base import NotAvailable -from weboob.capabilities.bill import Bill, Subscription +from weboob.capabilities.bill import Bill, Subscription, Document, DocumentTypes from weboob.tools.compat import urljoin @@ -72,7 +72,7 @@ class item(ItemElement): klass = Bill obj_id = Format( - '%s#%s', + '%s_%s', Env('subscription'), Attr('.', 'id') ) @@ -100,3 +100,22 @@ def obj_url(self): ) except XPathNotFound: return NotAvailable + + +class DocumentsPage(LoggedPage, HTMLPage): + @method + class iter_documents(ListElement): + item_xpath = '//main[@role="main"]//article' + + class item(ItemElement): + klass = Document + + def condition(self): + return CleanText('.//p[@data-behat="descOfUtilityRecord"]')(self) == 'CRG' + + obj_id = Format('%s_%s', Attr('.', 'id'), Env('subscription')) + obj_date = Date(CleanText('.//p[@data-behat="dateOfUtilityRecord"]'), dayfirst=True) + obj_label = CleanText('.//p[@data-behat="descOfUtilityRecord"]') + obj_url = AbsoluteLink('.//a[@class="Download"]') + obj_format = 'pdf' + obj_type = DocumentTypes.REPORT diff --git a/modules/orange/browser.py b/modules/orange/browser.py index e366e6865fbc417e44d3305af1129963943adfc1..5594df5389387d090d6f7d02c8e90400a709d408 100644 --- a/modules/orange/browser.py +++ b/modules/orange/browser.py @@ -110,6 +110,10 @@ def do_login(self): data = self.page.do_login_and_get_token(self.username, self.password) self.password_page.go(json=data) + error_message = self.page.get_change_password_message() + if error_message: + raise BrowserPasswordExpired(error_message) + self.portal_page.go() except ClientError as error: @@ -120,11 +124,6 @@ def do_login(self): raise BrowserIncorrectPassword(error.response.json()) raise - if self.password_page.is_here(): - error_message = self.page.get_change_password_message() - if error_message: - raise BrowserPasswordExpired(error_message) - def get_nb_remaining_free_sms(self): raise NotImplementedError() diff --git a/modules/orange/pages/captcha_symbols.py b/modules/orange/pages/captcha_symbols.py index b209e8232e26983c436a9f04c23586698f03ac0d..67c6b7b09794d74bda274ebf4dbce69c513b29fb 100644 --- a/modules/orange/pages/captcha_symbols.py +++ b/modules/orange/pages/captcha_symbols.py @@ -180,8 +180,9 @@ '0000000000111111111111111111111111111011111100001111110000010000000101000000011100001000000000000000' ], 'chien': [ - '1111111111111111111111011111111110111111111111111111111111111111111111111111111111111101111111111111', - '0001100000111110010011101011000111111100001111110010000111101100111110000011111000000100000011111111', + '1111111111111111111111111111111110111111111111111111111111111111111111111101011111111101111111111111', + '1111111111111011111111011111111111111111111111111111111111111111111111111111111111111111111111111111', + '0001100000111110010011111011000111111100001111110010000111101100111110000011111000000100000011111110', '1111111000111010001000100011111110101111000011000001111000011111110001111111111111111111111111111111', '0000000011000000010000000001000100000001100001000110000011111000011111100111010110011111011000011101', '1111111111111111111111101101111111101111111111111111111111111111111011111110111111111111111111111111', diff --git a/modules/orange/pages/login.py b/modules/orange/pages/login.py index 2f3d181f693fb308747df8c4ccbb64e9144971aa..e1da9ab59152c57328e1639f1c63f29480711efd 100644 --- a/modules/orange/pages/login.py +++ b/modules/orange/pages/login.py @@ -47,6 +47,7 @@ def do_login_and_get_token(self, username, password): class PasswordPage(JsonPage): + ENCODING = 'utf-8' def get_change_password_message(self): if self.doc.get('stage') != 'changePassword': # when stage is not present everything is okay, and if it's not changePassword we prefer do nothing here diff --git a/modules/societegenerale/sgpe/json_pages.py b/modules/societegenerale/sgpe/json_pages.py index e5d507c3118f164f3fecdcf72ef92449cd513109..7df31d5175cde4668f59c7cf2a5a27673b5a6d2a 100644 --- a/modules/societegenerale/sgpe/json_pages.py +++ b/modules/societegenerale/sgpe/json_pages.py @@ -63,6 +63,8 @@ class AccountsJsonPage(LoggedPage, JsonPage): 'Ldd': Account.TYPE_SAVINGS, 'Livret': Account.TYPE_SAVINGS, 'PEL': Account.TYPE_SAVINGS, + 'CPTE TRAVAUX': Account.TYPE_SAVINGS, + 'EPARGNE': Account.TYPE_SAVINGS, 'Plan Epargne': Account.TYPE_SAVINGS, 'PEA': Account.TYPE_PEA, 'Prêt': Account.TYPE_LOAN, diff --git a/modules/sogecartenet/ent_pages.py b/modules/sogecartenet/ent_pages.py index bdc8b962f0a03c444ca9f5c42858641294507d24..86acb39d3586eca9485ecdb5d8acf27daf541f68 100644 --- a/modules/sogecartenet/ent_pages.py +++ b/modules/sogecartenet/ent_pages.py @@ -119,6 +119,8 @@ def build_doc(self, content): class AccountsXlsPage(LoggedPage, XLSPage): + HEADER = 2 # the first row is empty, the second contains headers + @method class iter_accounts(DictElement): class item(ItemElement): @@ -285,9 +287,8 @@ def select_account(self, account): self.browser.wait_xpath_visible('//p[contains(@class, "Notification-description")][contains(text(), "a bien été sélectionnée")]') - class HistoryXlsPage(LoggedPage, XLSPage): - HEADER = 4 + HEADER = 5 @method class iter_history(DictElement): diff --git a/modules/wiseed/pages.py b/modules/wiseed/pages.py index c18be662ff22f2e6f770c485649d7938334eae8e..d178fbb6044578608861e2fa316993b8aaccd2cf 100644 --- a/modules/wiseed/pages.py +++ b/modules/wiseed/pages.py @@ -21,9 +21,12 @@ from .compat.weboob_browser_pages import LoggedPage, HTMLPage from weboob.browser.filters.html import TableCell -from .compat.weboob_browser_filters_standard import CleanText, CleanDecimal, Regexp +from .compat.weboob_browser_filters_standard import ( + CleanText, CleanDecimal, Regexp, Coalesce, +) from weboob.browser.elements import method, ItemElement, TableElement from weboob.exceptions import BrowserIncorrectPassword +from weboob.capabilities.base import NotAvailable from .compat.weboob_capabilities_wealth import Investment from weboob.tools.capabilities.bank.investments import create_french_liquidity @@ -54,7 +57,10 @@ def get_user_id(self): )(self.doc) def get_liquidities(self): - value = CleanDecimal.French(CleanText('//a[starts-with(text(),"Compte de paiement")]'))(self.doc) + value = Coalesce( + CleanDecimal.French('//a[starts-with(text(),"Compte de paiement")]', default=NotAvailable), + CleanDecimal.US('//a[starts-with(text(),"Compte de paiement")]', default=NotAvailable), + )(self.doc) return create_french_liquidity(value) @method @@ -115,7 +121,9 @@ class item(ItemElement): klass = Investment obj_label = CleanText(TableCell('label')) - obj_valuation = CleanDecimal.French(Regexp( - CleanText(TableCell('details')), - r'^(.*?) €', # can be 100,00 € + Frais de 0,90 € - )) + + # Can be "100,00 € + Frais de 0,90 €" or "€100.00" + obj_valuation = Coalesce( + CleanDecimal.French(Regexp(CleanText(TableCell('details')), r'^(.*?) €', default=None), default=None), + CleanDecimal.US(Regexp(CleanText(TableCell('details')), r'^€([^ ]+)', default=None), default=None), + )