Newer
Older
#
# 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 <http://www.gnu.org/licenses/>.
from datetime import date, datetime
from dateutil import parser
from weboob.browser.retry import login_method, retry_on_logout, RetryLoginBrowser
from weboob.browser.browsers import need_login, StatesMixin
from weboob.exceptions import BrowserIncorrectPassword, BrowserHTTPNotFound, NoAccountsException, BrowserUnavailable
from weboob.browser.exceptions import LoggedOut, ClientError
from weboob.capabilities.bank import (
Account, AccountNotFound, TransferError, TransferInvalidAmount,
TransferInvalidEmitter, TransferInvalidLabel, TransferInvalidRecipient,
AddRecipientStep, Recipient, Rate
from weboob.capabilities.contact import Advisor
from weboob.tools.captcha.virtkeyboard import VirtKeyboardError
from weboob.tools.compat import basestring, urlsplit, urlunsplit
from weboob.tools.capabilities.bank.transactions import sorted_transactions
from .pages import (
LoginPage, VirtKeyboardPage, AccountsPage, AsvPage, HistoryPage, AccbisPage, AuthenticationPage,
MarketPage, LoanPage, SavingMarketPage, ErrorPage, IncidentPage, IbanPage, ProfilePage, ExpertPage,
CardsNumberPage, CalendarPage, HomePage, PEPPage,
TransferAccounts, TransferRecipients, TransferCharac, TransferConfirm, TransferSent,
AddRecipientPage, StatusPage, CardHistoryPage, CardCalendarPage, CurrencyListPage, CurrencyConvertPage,
AccountsErrorPage, NoAccountPage,
class BrowserIncorrectAuthenticationCode(BrowserIncorrectPassword):
pass
class BoursoramaBrowser(RetryLoginBrowser, StatesMixin):
STATE_DURATION = 10
home = URL('/$', HomePage)
keyboard = URL('/connexion/clavier-virtuel\?_hinclude=300000', VirtKeyboardPage)
status = URL(r'/aide/messages/dashboard\?showza=0&_hinclude=1', StatusPage)
calendar = URL('/compte/cav/.*/calendrier', CalendarPage)
card_calendar = URL('https://api.boursorama.com/services/api/files/download.phtml.*', CardCalendarPage)
error = URL('/connexion/compte-verrouille',
'/infos-profil', ErrorPage)
accounts = URL('/dashboard/comptes\?_hinclude=300000', AccountsPage)
accounts_error = URL('/dashboard/comptes\?_hinclude=300000', AccountsErrorPage)
pro_accounts = URL(r'/dashboard/comptes-professionnels\?_hinclude=1', AccountsPage)
no_account = URL('/dashboard/comptes\?_hinclude=300000',
'/dashboard/comptes-professionnels\?_hinclude=1', NoAccountPage)
acc_tit = URL('/comptes/titulaire/(?P<webid>.*)\?_hinclude=1', AccbisPage)
acc_rep = URL('/comptes/representative/(?P<webid>.*)\?_hinclude=1', AccbisPage)
acc_pro = URL('/comptes/professionnel/(?P<webid>.*)\?_hinclude=1', AccbisPage)
history = URL('/compte/(cav|epargne)/(?P<webid>.*)/mouvements.*', HistoryPage)
card_transactions = URL('/compte/cav/(?P<webid>.*)/carte/.*', HistoryPage)
deffered_card_history = URL('https://api.boursorama.com/services/api/files/download.phtml.*', CardHistoryPage)
budget_transactions = URL('/budget/compte/(?P<webid>.*)/mouvements.*', HistoryPage)
other_transactions = URL('/compte/cav/(?P<webid>.*)/mouvements.*', HistoryPage)
saving_transactions = URL('/compte/epargne/csl/(?P<webid>.*)/mouvements.*', HistoryPage)
saving_pep = URL('/compte/epargne/pep', PEPPage)
incident = URL('/compte/cav/(?P<webid>.*)/mes-incidents.*', IncidentPage)
transfer_accounts = URL(r'/compte/(?P<type>[^/]+)/(?P<webid>\w+)/virements/nouveau/(?P<id>\w+)/1',
TransferAccounts)
recipients_page = URL(r'/compte/(?P<type>[^/]+)/(?P<webid>\w+)/virements/$',
r'/compte/(?P<type>[^/]+)/(?P<webid>\w+)/virements/nouveau/(?P<id>\w+)/2',
TransferRecipients)
transfer_charac = URL(r'/compte/(?P<type>[^/]+)/(?P<webid>\w+)/virements/nouveau/(?P<id>\w+)/3',
TransferCharac)
transfer_confirm = URL(r'/compte/(?P<type>[^/]+)/(?P<webid>\w+)/virements/nouveau/(?P<id>\w+)/4',
TransferConfirm)
transfer_sent = URL(r'/compte/(?P<type>[^/]+)/(?P<webid>\w+)/virements/nouveau/(?P<id>\w+)/5',
TransferSent)
rcpt_page = URL(r'/compte/(?P<type>[^/]+)/(?P<webid>\w+)/virements/comptes-externes/nouveau/(?P<id>\w+)/\d',
AddRecipientPage)
saving_history = URL('/compte/cefp/.*/(positions|mouvements)',
'/compte/.*ord/.*/mouvements',
'/compte/pea/.*/mouvements',
'/compte/pea-pme/.*/mouvements', SavingMarketPage)
market = URL('/compte/(?!assurance|cav|epargne).*/(positions|mouvements)',
'/compte/ord/.*/positions', MarketPage)
'/credit/immobilier/.*/caracteristiques',
'/credit/consommation/.*/informations',
'/credit/lombard/.*/caracteristiques', LoanPage)
authentication = URL('/securisation', AuthenticationPage)
profile = URL('/mon-profil/', ProfilePage)
expert = URL('/compte/derive/', ExpertPage)
cards = URL('/compte/cav/cb', CardsNumberPage)
currencylist = URL('https://www.boursorama.com/bourse/devises/parite/_detail-parite', CurrencyListPage)
currencyconvert = URL('https://www.boursorama.com/bourse/devises/convertisseur-devises/convertir', CurrencyConvertPage)
Romain Bignon
committed
__states__ = ('auth_token',)
Baptiste Delpey
committed
def __init__(self, config=None, *args, **kwargs):
self.config = config
self.auth_token = None
self.deferred_card_calendar = None
Baptiste Delpey
committed
kwargs['username'] = self.config['login'].get()
kwargs['password'] = self.config['password'].get()
super(BoursoramaBrowser, self).__init__(*args, **kwargs)
def locate_browser(self, state):
try:
self.location(state['url'])
except (requests.exceptions.HTTPError, requests.exceptions.TooManyRedirects, LoggedOut):
pass
def load_state(self, state):
if ('expire' in state and parser.parse(state['expire']) > datetime.now()) or state.get('auth_token'):
return super(BoursoramaBrowser, self).load_state(state)
def handle_authentication(self):
Baptiste Delpey
committed
if self.config['enable_twofactors'].get():
self.page.sms_first_step()
self.page.sms_second_step()
"""Boursorama - activate the two factor authentication in boursorama config."""
""" You will receive SMS code but are limited in request per day (around 15)"""
)
Baptiste Delpey
committed
assert isinstance(self.config['device'].get(), basestring)
assert isinstance(self.config['enable_twofactors'].get(), bool)
if not self.password.isalnum():
raise BrowserIncorrectPassword()
Romain Bignon
committed
if self.auth_token and self.config['pin_code'].get():
Baptiste Delpey
committed
else:
for _ in range(3):
self.login.go()
try:
self.page.login(self.username, self.password)
except VirtKeyboardError:
self.logger.error('Failed to process VirtualKeyboard')
else:
break
else:
raise VirtKeyboardError()
if self.login.is_here() or self.error.is_here():
Baptiste Delpey
committed
raise BrowserIncorrectPassword()
# After login, we might be redirected to the two factor authentication page.
Baptiste Delpey
committed
self.handle_authentication()
Romain Bignon
committed
raise BrowserIncorrectAuthenticationCode('Invalid PIN code')
def go_cards_number(self, link):
self.location(link)
self.location(self.page.get_cards_number_link())
exc = None
if self.accounts_list is not None:
break
# Check that there is at least one account for this user
has_account = False
self.pro_accounts.go()
if self.pro_accounts.is_here():
self.accounts_list.extend(self.page.iter_accounts())
has_account = True
else:
# We dont want to let has_account=False if we landed on an unknown page
# it has to be the no_accounts page
assert self.no_account.is_here()
try:
self.accounts.go()
except BrowserUnavailable as e:
self.logger.warning('par accounts seem unavailable, retrying')
exc = e
self.accounts_list = None
continue
else:
if self.accounts.is_here():
self.accounts_list.extend(self.page.iter_accounts())
has_account = True
else:
# We dont want to let has_account=False if we landed on an unknown page
# it has to be the no_accounts page
assert self.no_account.is_here()
exc = None
if not has_account:
# if we landed twice on NoAccountPage, it means there is neither pro accounts nor pp accounts
raise NoAccountsException()
# discard all unvalid card accounts (if opposed or not yet activated)
valid_card_url = []
for account in self.accounts_list:
if account.type == Account.TYPE_CHECKING:
# get all tit card account (page can also contains non-valid card)
self.acc_tit.go(webid=account._webid)
valid_card_url.extend([card.url for card in self.page.iter_valid_cards_url()])
# get all pro card account
self.acc_pro.go(webid=account._webid)
valid_card_url.extend([card.url for card in self.page.iter_valid_cards_url()])
# there is 1 page for all accounts (one for tit and one for pro)
for account in list(self.accounts_list):
if account.type == Account.TYPE_CARD and account.url not in valid_card_url:
self.accounts_list.remove(account)
elif account.type == Account.TYPE_LOAN:
# Loans details are present on another page so we create
# a Loan object and remove the corresponding Account:
loan = self.page.get_loan()
loan.url = account.url
self.loans_list.append(loan)
self.accounts_list.remove(account)
self.accounts_list.extend(self.loans_list)
cards = [acc for acc in self.accounts_list if acc.type == Account.TYPE_CARD]
if cards:
self.go_cards_number(cards[0].url)
if self.cards.is_here():
self.page.populate_cards_number(cards)
if account.type not in (Account.TYPE_CARD, Account.TYPE_LOAN, Account.TYPE_CONSUMER_CREDIT, Account.TYPE_MORTGAGE, Account.TYPE_REVOLVING_CREDIT, Account.TYPE_LIFE_INSURANCE):
account.iban = self.iban.go(webid=account._webid).get_iban()
for card in cards:
checking, = [account for account in self.accounts_list if account.type == Account.TYPE_CHECKING and account.url in card.url]
card.parent = checking
if exc:
raise exc
def get_account(self, id):
assert isinstance(id, basestring)
if a.id == id:
return a
return None
def get_debit_date(self, debit_date):
for i, j in zip(self.deferred_card_calendar, self.deferred_card_calendar[1:]):
if i[0] < debit_date <= j[0]:
return j[1]
def get_closing_date(self):
for i, j in zip(self.deferred_card_calendar, self.deferred_card_calendar[1:]):
if i[1] < date.today() <= j[1]:
def get_card_transactions(self, account, coming):
self.location(account.url)
if self.home.is_here():
# for some cards, the site redirects us to '/'...
return
for t in self.page.iter_history(is_card=True):
# For card old history, the page doesn't show the real debit date,
# so we need to parse a csv page
if not coming:
if self.deferred_card_calendar is None:
self.location(self.page.get_calendar_link())
params = {}
params['movementSearch[toDate]'] = self.get_closing_date().strftime('%d/%m/%Y')
params['movementSearch[fromDate]'] = (date.today() - relativedelta(years=3)).strftime('%d/%m/%Y')
params['fullSearch'] = 1
self.location(account.url, params=params)
csv_link = self.page.get_csv_link()
if csv_link:
self.location(csv_link)
for t in sorted_transactions(self.page.iter_history(account_number=account.number)):
yield t
return
def get_invest_transactions(self, account, coming):
if coming:
return
transactions = []
self.location('%s/mouvements' % account.url.rstrip('/'))
account._history_pages = []
for t in self.page.iter_history(account=account):
transactions.append(t)
for t in self.page.get_transactions_from_detail(account):
transactions.append(t)
for t in sorted(transactions, key=lambda tr: tr.date, reverse=True):
yield t
def get_regular_transactions(self, account, coming):
params = {}
params['movementSearch[toDate]'] = (date.today() + relativedelta(days=40)).strftime('%d/%m/%Y')
params['movementSearch[fromDate]'] = (date.today() - relativedelta(years=3)).strftime('%d/%m/%Y')
params['movementSearch[selectedAccounts][]'] = account._webid
self.location('%s/mouvements' % account.url.rstrip('/'), params=params)
for t in self.page.iter_history():
yield t
if coming and account.type == Account.TYPE_CHECKING:
self.location('%s/mouvements-a-venir' % account.url.rstrip('/'), params=params)
for t in self.page.iter_history(coming=True):
yield t
def get_history(self, account, coming=False):
if account.type is Account.TYPE_LOAN or '/compte/derive' in account.url:
if account.type is Account.TYPE_SAVINGS and u"PLAN D'\xc9PARGNE POPULAIRE" in account.label:
return []
if account.type in (Account.TYPE_LIFE_INSURANCE, Account.TYPE_MARKET):
return self.get_invest_transactions(account, coming)
elif account.type == Account.TYPE_CARD:
return self.get_card_transactions(account, coming)
return self.get_regular_transactions(account, coming)
if '/compte/derive' in account.url:
if not account.type in (Account.TYPE_LIFE_INSURANCE, Account.TYPE_MARKET, Account.TYPE_PEA):
self.location(account.url)
# We might deconnect at this point.
if self.login.is_here():
return self.get_investment(account)
@need_login
def get_profile(self):
return self.profile.stay_or_go().get_profile()
@need_login
def get_advisor(self):
# same for everyone
advisor = Advisor()
advisor.name = u"Service clientèle"
advisor.phone = u"0146094949"
return iter([advisor])
@need_login
def iter_transfer_recipients(self, account):
if account.type in (Account.TYPE_LOAN, Account.TYPE_LIFE_INSURANCE):
return []
url = urlsplit(account.url)
parts = [part for part in url.path.split('/') if part]
if account.type == Account.TYPE_SAVINGS:
self.logger.debug('Deleting account name %s to get recipients', parts[-2])
del parts[-2]
parts.append('virements')
url = url._replace(path='/'.join(parts))
target = urlunsplit(url)
try:
self.location(target)
except BrowserHTTPNotFound:
return []
if self.transfer_accounts.is_here():
try:
self.page.submit_account(account.id)
except AccountNotFound:
return []
assert self.recipients_page.is_here()
return self.page.iter_recipients()
def check_basic_transfer(self, transfer):
if transfer.amount <= 0:
raise TransferInvalidAmount('transfer amount must be positive')
if transfer.recipient_id == transfer.account_id:
raise TransferInvalidRecipient('recipient must be different from emitter')
raise TransferInvalidLabel('transfer label cannot be empty')
@need_login
def init_transfer(self, transfer, **kwargs):
self.check_basic_transfer(transfer)
account = self.get_account(transfer.account_id)
if not account:
raise AccountNotFound()
recipients = list(self.iter_transfer_recipients(account))
if not recipients:
raise TransferInvalidEmitter('The account cannot emit transfers')
recipients = [rcpt for rcpt in recipients if rcpt.id == transfer.recipient_id]
if len(recipients) == 0:
raise TransferInvalidRecipient('The recipient cannot be used with the emitter account')
assert len(recipients) == 1
self.page.submit_recipient(recipients[0]._tempid)
assert self.transfer_charac.is_here()
self.page.submit_info(transfer.amount, transfer.label, transfer.exec_date)
assert self.transfer_confirm.is_here()
if self.page.need_refresh():
# In some case we are not yet in the transfer_charac page, you need to refresh the page
self.location(self.url)
assert not self.page.need_refresh()
ret = self.page.get_transfer()
# at this stage, the site doesn't show the real ids/ibans, we can only guess
if recipients[0].label != ret.recipient_label:
if not recipients[0].label.startswith('%s - ' % ret.recipient_label):
# the label displayed here is just "<name>"
# but in the recipients list it is "<name> - <bank>"...
raise TransferError('Recipient label changed during transfer')
ret.recipient_id = recipients[0].id
ret.recipient_iban = recipients[0].iban
if account.label != ret.account_label:
raise TransferError('Account label changed during transfer')
ret.account_id = account.id
ret.account_iban = account.iban
return ret
@need_login
def execute_transfer(self, transfer, **kwargs):
assert self.transfer_confirm.is_here()
self.page.submit()
assert self.transfer_sent.is_here()
# the last page contains no info, return the last transfer object from init_transfer
return transfer
def build_recipient(self, recipient):
r = Recipient()
r.iban = recipient.iban
r.id = recipient.iban
r.label = recipient.label
r.category = recipient.category
r.enabled_at = date.today()
r.currency = u'EUR'
r.bank_name = recipient.bank_name
return r
@need_login
def new_recipient(self, recipient, **kwargs):
if 'code' in kwargs:
assert self.rcpt_page.is_here()
assert self.page.is_confirm_sms()
self.page.confirm_sms(kwargs['code'])
return self.rcpt_after_sms()
account = None
for account in self.get_accounts_list():
break
suffix = 'virements/comptes-externes/nouveau'
if account.url.endswith('/'):
target = account.url + suffix
target = account.url + '/' + suffix
self.location(target)
assert self.page.is_charac()
self.page.submit_recipient(recipient)
if self.page.is_send_sms():
self.page.send_sms()
assert self.page.is_confirm_sms()
raise AddRecipientStep(self.build_recipient(recipient), Value('code', label='Veuillez saisir le code'))
# 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()
def rcpt_after_sms(self):
assert self.page.is_confirm()
ret = self.page.get_recipient()
self.page.confirm()
def iter_currencies(self):
return self.currencylist.go().get_currency_list()
def get_rate(self, curr_from, curr_to):
r = Rate()
params = {
'from': curr_from,
'to': curr_to,
'amount': '1'
}
r.currency_from = curr_from
r.currency_to = curr_to
r.datetime = datetime.now()
try:
self.currencyconvert.go(params=params)
r.value = self.page.get_rate()
# if a rate is no available the site return a 401 error...
except ClientError:
return
return r