Commit 372520a8 authored by Vincent A's avatar Vincent A

backport master modules fixes

parent c7625d4a
Pipeline #3380 failed with stages
in 24 minutes and 4 seconds
......@@ -30,6 +30,7 @@ from .pages import (
LoginPage, PasswordRenewalPage, AccountsPage, HistoryPage,
InvestPage, MarketOrdersPage, MarketOrderDetailsPage,
LifeInsurancePage, IsinPage, PortfolioPage, JsRedirectPage,
......@@ -38,13 +39,14 @@ class BoursedirectBrowser(LoginBrowser):
login = URL(r'/fr/login', LoginPage)
password_renewal = URL(r'/fr/changer-mon-mot-de-passe', PasswordRenewalPage)
home = URL(r'/fr/page/inventaire', HomePage)
accounts = URL(
history = URL(r'/priv/compte.php\?ong=3&nc=(?P<nc>\d+)', HistoryPage)
history = URL(r'/priv/new/historique-de-compte.php\?ong=3&nc=(?P<nc>\d+)', HistoryPage)
portfolio = URL(r'/fr/page/portefeuille', PortfolioPage)
pre_invests = URL(r'/priv/portefeuille-TR.php\?nc=(?P<nc>\d+)')
invests = URL(r'/streaming/compteTempsReelCK.php\?stream=0', InvestPage)
......@@ -86,6 +86,10 @@ class BasePage(HTMLPage):
class HomePage(BasePage):
class AccountsPage(BasePage):
class iter_accounts(ListElement):
......@@ -333,16 +337,39 @@ class MarketOrderDetailsPage(BasePage):
class HistoryPage(BasePage):
class iter_history(ListElement):
item_xpath = '//table[@class="datas retour"]//tr[@class="row1" or @class="row2"]'
class iter_history(TableElement):
item_xpath = '//table[contains(@class,"datas retour")]//tr[@class="row1" or @class="row2"]'
head_xpath = '//table[contains(@class,"datas retour")]//th'
col_rdate = 'Date opération'
col_date = 'Date affectation'
col_investment_label = 'Libellé'
col_label = 'Opération'
col_investment_quantity = 'Qté'
col_investment_unitvalue = 'Cours'
col_amount = 'Montant net'
class item(ItemElement):
klass = Transaction
obj_date = Date(CleanText('./td[2]'), dayfirst=True) # Date affectation
obj_rdate = Date(CleanText('./td[1]'), dayfirst=True) # Date opération
obj_label = Format('%s - %s', CleanText('./td[3]/a'), CleanText('./td[4]'))
obj_amount = CleanDecimal.French('./td[7]')
obj_date = Date(CleanText(TableCell('date')), dayfirst=True) # Date affectation
obj_rdate = Date(CleanText(TableCell('rdate')), dayfirst=True) # Date opération
obj_label = Format('%s - %s', CleanText(TableCell('investment_label')), CleanText(TableCell('label')))
obj_amount = CleanDecimal.French(TableCell('amount'))
def obj_investments(self):
if CleanDecimal.French(TableCell('unitvalue'), default=None) is None:
return NotAvailable
investment = Investment()
investment.label = CleanText(TableCell('investment_label'))(self)
investment.valuation = CleanDecimal.French(TableCell('amount'))(self)
investment.unitvalue = CleanDecimal.French(
investment.quantity = CleanDecimal.French(TableCell('investment_quantity'), default=NotAvailable)(self)
return [investment]
class IsinPage(HTMLPage):
......@@ -35,7 +35,7 @@ from .compat.weboob_capabilities_bank import (
Account, AccountNotFound, TransferError, TransferInvalidAmount,
TransferInvalidEmitter, TransferInvalidLabel, TransferInvalidRecipient,
AddRecipientStep, Rate, TransferBankError, AccountOwnership, RecipientNotFound,
AddRecipientTimeout, TransferDateType, Emitter,
AddRecipientTimeout, TransferDateType, Emitter, TransactionType,
from weboob.capabilities.base import empty, find_object
from import Advisor
......@@ -51,7 +51,7 @@ from .pages import (
TransferAccounts, TransferRecipients, TransferCharac, TransferConfirm, TransferSent,
AddRecipientPage, StatusPage, CardHistoryPage, CardCalendarPage, CurrencyListPage, CurrencyConvertPage,
AccountsErrorPage, NoAccountPage, TransferMainPage, PasswordPage, NewTransferRecipients,
NewTransferAccounts, CardSumDetailPage,
from .transfer_pages import TransferListPage, TransferInfoPage
......@@ -96,6 +96,7 @@ class BoursoramaBrowser(RetryLoginBrowser, TwoFactorBrowser):
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)
card_summary_detail_transactions = URL(r'/contre-valeurs-operation/.*', CardSumDetailPage)
saving_pep = URL('/compte/epargne/pep', PEPPage)
incident = URL('/compte/cav/(?P<webid>.*)/mes-incidents.*', IncidentPage)
......@@ -456,6 +457,25 @@ class BoursoramaBrowser(RetryLoginBrowser, TwoFactorBrowser):
for transaction in
yield transaction
def get_html_past_card_transactions(self, account):
""" Get card transactions from parent account page """
self.otp_location('%s/mouvements' % account.parent.url.rstrip('/'))
for tr in
# get card summaries
if (
tr.type == TransactionType.CARD_SUMMARY
and account.number in tr.label # in case of several cards per parent account
tr.amount = - tr.amount
yield tr
# for each summaries, get detailed transactions
for detail_tr in =
yield detail_tr
# Note: Checking accounts have a 'Mes prélèvements à venir' tab,
# but these transactions have no date anymore so we ignore them.
......@@ -497,8 +517,8 @@ class BoursoramaBrowser(RetryLoginBrowser, TwoFactorBrowser):
if self.get_card_transaction(coming, tr):
yield tr
for tr in
if self.get_card_transaction(coming, tr):
if not coming:
for tr in self.get_html_past_card_transactions(account):
yield tr
def get_invest_transactions(self, account, coming):
......@@ -700,6 +700,11 @@ class HistoryPage(LoggedPage, HTMLPage):
return date
def obj__card_sum_detail_link(self):
if Field('type')(self) == Transaction.TYPE_CARD_SUMMARY:
return Attr('.//div', 'data-action-url')(self.el)
return NotAvailable
def validate(self, obj):
# TYPE_DEFERRED_CARD transactions are already present in the card history
# so we only return TYPE_DEFERRED_CARD for the coming:
......@@ -740,6 +745,25 @@ class HistoryPage(LoggedPage, HTMLPage):
return Link('//a[contains(text(), "calendrier")]')(self.doc)
class CardSumDetailPage(LoggedPage, HTMLPage):
class iter_history(ListElement):
item_xpath = '//li[contains(@class, "deffered")]' # this quality website's got all-you-can-eat typos!
class item(ItemElement):
klass = Transaction
obj_amount = CleanDecimal.French('.//div[has-class("list-operation-item__amount")]')
obj_raw = Transaction.Raw(CleanText('.//div[has-class("list-operation-item__label-name")]'))
obj_id = Attr('.', 'data-id')
obj__is_coming = False
def obj_type(self):
# to override CARD typing done by obj.raw
return Transaction.TYPE_DEFERRED_CARD
class CardHistoryPage(LoggedPage, CsvPage):
ENCODING = 'latin-1'
FMTPARAMS = {'delimiter': str(';')}
......@@ -1298,6 +1322,12 @@ class NewTransferRecipients(LoggedPage, HTMLPage):
class NewTransferAccounts(LoggedPage, HTMLPage):
def submit_account(self, account_id):
no_account_msg = CleanText('//div[contains(@class, "alert--warning")]')(self.doc)
if 'Vous ne possédez pas de compte éligible au virement' in no_account_msg:
raise AccountNotFound()
elif no_account_msg:
raise AssertionError('Unhandled error message : "%s"' % no_account_msg)
form = self.get_form()
debit_account = CleanText(
'//input[./following-sibling::div/span/span[contains(text(), "%s")]]/@value' % account_id
......@@ -411,7 +411,7 @@ class OtpErrorPage(LoggedPage, PartialHTMLPage):
# Need PartialHTMLPage because sometimes we land on this page with
# a status_code 302, so the page is empty and the build_doc crash.
def get_error(self):
return CleanText('//form//span[@class="warning"]')(self.doc)
return CleanText('//form//span[@class="warning" or @class="app_erreur"]')(self.doc)
class RecipientSubmitDevicePage(LoggedPage, MyHTMLPage):
......@@ -280,6 +280,7 @@ class BredBrowser(LoginBrowser, StatesMixin):
today =
seen = set()
offset = 0
total_transactions = 0
next_page = True
end_date =
last_date = None
......@@ -309,10 +310,11 @@ class BredBrowser(LoginBrowser, StatesMixin):
next_page = len(transactions) > 0
offset += 50
total_transactions += 50
# This assert supposedly prevents infinite loops,
# but some customers actually have a lot of transactions.
assert offset < 100000, 'the site may be doing an infinite loop'
assert total_transactions < 50000, 'the site may be doing an infinite loop'
def iter_investments(self, account):
......@@ -1512,16 +1512,21 @@ class CaisseEpargne(LoginBrowser, StatesMixin):
if self.validation_option.is_here():
if self.otp_validation['type'] == 'CLOUDCARD':
raise AuthMethodNotImplemented()
raise TransferStep(
label='Veuillez renseigner le mot de passe unique qui vous a été envoyé par SMS dans le champ réponse.'
if self.otp_validation['type'] == 'SMS':
self.is_send_sms = True
raise TransferStep(
label='Veuillez renseigner le mot de passe unique qui vous a été envoyé par SMS dans le champ réponse.'
elif self.otp_validation['type'] == 'CLOUDCARD':
self.is_app_validation = True
raise AppValidation(
message="Veuillez valider le transfert sur votre application mobile.",
if 'netpro' in self.url:
return, recipient, transfer)
......@@ -1530,15 +1535,27 @@ class CaisseEpargne(LoginBrowser, StatesMixin):
return, account, recipient)
def otp_sms_continue_transfer(self, transfer, **params):
self.is_send_sms = False
assert 'otp_sms' in params, 'OTP SMS is missing'
def otp_validation_continue_transfer(self, transfer, **params):
assert (
'resume' in params
or 'otp_sms' in params
), 'otp_sms or resume is missing'
if 'resume' in params:
self.is_app_validation = False
elif 'otp_sms' in params:
self.is_send_sms = False
if self.transfer.is_here():, transfer.recipient_label, transfer.label)
......@@ -115,8 +115,8 @@ class CaisseEpargneModule(Module, CapBankWealth, CapBankTransferAddRecipient, Ca
return self.browser.iter_recipients(origin_account)
def init_transfer(self, transfer, **params):
if 'otp_sms' in params:
return self.browser.otp_sms_continue_transfer(transfer, **params)
if 'otp_sms' in params or 'resume' in params:
return self.browser.otp_validation_continue_transfer(transfer, **params)'Going to do a new transfer')
transfer.label = re.sub(r"[^0-9A-Z/?:().,'+ -]+", '', transfer.label.upper())
......@@ -47,6 +47,7 @@ from .compat.weboob_capabilities_bank import (
Transfer, TransferBankError, TransferInvalidOTP,
Recipient, AddRecipientBankError, RecipientInvalidOTP,
Emitter, EmitterNumberType, AddRecipientError,
from .compat.weboob_capabilities_wealth import Investment
from weboob.capabilities.bill import DocumentTypes, Subscription, Document
......@@ -188,8 +189,9 @@ class AuthenticationMethodPage(JsonPage):
def transfer_errors(self, error):
# For the moment, only otp sms is handled
raise TransferInvalidOTP(message="Le code SMS que vous avez renseigné n'est pas valide")
raise TransferError(message="Le virement a été annulée via l'application mobile.")
def recipient_errors(self, error):
......@@ -36,8 +36,8 @@ class ChannelsPage(XMLPage):
Extract all possible channels (paths) from the page
channels = list()
for elem in self.doc[2].getchildren():
for e in elem.getchildren():
for elem in self.doc[2]:
for e in elem:
if e.tag == "NOM":
fid, name = self._clean_name(e.text)
channels.append(Collection([fid], name))
......@@ -914,16 +914,20 @@ class CreditAgricoleBrowser(LoginBrowser, StatesMixin):
# There is one profile per space, so we only fetch the first one
owner_type =
profile_details =
if owner_type == 'PRIV':
profile =
if profile_details:
return profile
elif owner_type == 'ORGA':
profile =
if profile_details:
return profile
def get_space_info(self):
......@@ -314,6 +314,9 @@ class AccountsPage(LoggedPage, JsonPage):
def has_main_account(self):
return Dict('comptePrincipal', default=None)(self.doc)
def has_profile_details(self):
return CleanText('//a[text()="Gérer mes coordonnées"]')(self.html_doc)
class get_main_account(ItemElement):
klass = Account
......@@ -712,6 +712,7 @@ class TransactionsPage(LoggedPage, CDNBasePage):
col_label = 'Valeur'
col_quantity = 'Quantité'
col_unitvalue = 'Cours'
col_unitprice = 'Prix de revient'
col_valuation = 'Estimation'
col_portfolio_share = '%'
......@@ -722,6 +723,7 @@ class TransactionsPage(LoggedPage, CDNBasePage):
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')))
def obj_code(self):
......@@ -59,7 +59,7 @@ from .pages import (
ConditionsPage, MobileConfirmationPage, UselessPage, DecoupledStatePage, CancelDecoupled,
OtpValidationPage, OtpBlockedErrorPage, TwoFAUnabledPage,
LoansOperationsPage, OutagePage, PorInvestmentsPage, PorHistoryPage, PorHistoryDetailsPage,
PorMarketOrdersPage, PorMarketOrderDetailsPage,
PorMarketOrdersPage, PorMarketOrderDetailsPage, SafeTransPage,
......@@ -86,6 +86,7 @@ class CreditMutuelBrowser(TwoFactorBrowser):
outage_page = URL(r'/fr/outage.html', OutagePage)
twofa_unabled_page = URL(r'/(?P<subbank>.*)fr/banque/validation.aspx', TwoFAUnabledPage)
mobile_confirmation = URL(r'/(?P<subbank>.*)fr/banque/validation.aspx', MobileConfirmationPage)
safetrans_page = URL(r'/(?P<subbank>.*)fr/banque/validation.aspx', SafeTransPage)
decoupled_state = URL(r'/fr/banque/async/otp/SOSD_OTP_GetTransactionState.htm', DecoupledStatePage)
cancel_decoupled = URL(r'/fr/banque/async/otp/SOSD_OTP_CancelTransaction.htm', CancelDecoupled)
otp_validation_page = URL(r'/(?P<subbank>.*)fr/banque/validation.aspx', OtpValidationPage)
......@@ -98,7 +99,11 @@ class CreditMutuelBrowser(TwoFactorBrowser):
useless_page = URL(r'/(?P<subbank>.*)fr/banque/paci/defi-solidaire.html', UselessPage)
revolving_loan_list = URL(r'/(?P<subbank>.*)fr/banque/CR/arrivee.asp\?fam=CR.*', RevolvingLoansList)
revolving_loan_list = URL(
revolving_loan_details = URL(r'/(?P<subbank>.*)fr/banque/CR/cam9_vis_lstcpt.asp.*', RevolvingLoanDetails)
user_space = URL(r'/(?P<subbank>.*)fr/banque/espace_personnel.aspx',
......@@ -370,6 +375,10 @@ class CreditMutuelBrowser(TwoFactorBrowser):
assert self.polling_data, "Can't proceed to polling if no polling_data"
raise AppValidation(
if self.safetrans_page.is_here():
msg =
raise AuthMethodNotImplemented(msg)
if self.otp_validation_page.is_here():
self.otp_data =
assert self.otp_data, "Can't proceed to SMS handling if no otp_data"
......@@ -193,6 +193,23 @@ class MobileConfirmationPage(PartialHTMLPage, AppValidationPage):
self.logger.warning('This connexion cannot bypass mobile confirmation')
# PartialHTMLPage: this page shares URL with other pages,
# that might be empty of text while used in a redirection
class SafeTransPage(PartialHTMLPage, AppValidationPage):
# only 'class' and cryptic 'id' tags on this page
# so we scrape based on text, not tags
def is_here(self):
return (
'Authentification forte' in CleanText('//p[contains(@id, "title")]')(self.doc)
and CleanText('//*[contains(text(), "confirmer votre connexion avec Safetrans")]')(self.doc)
def get_safetrans_message(self):
return CleanText(
'//*[contains(text(), "Confirmation Mobile") or contains(text(), "confirmer votre connexion avec Safetrans")]'
class TwoFAUnabledPage(PartialHTMLPage):
def is_here(self):
return self.doc.xpath('//*[contains(text(), "aucun moyen pour confirmer")]')
......@@ -97,7 +97,7 @@ class HousingPage(XMLPage):
def build_doc(self, content):
doc = super(HousingPage, self).build_doc(content).getroot()
for elem in doc.getiterator():
for elem in doc.iter():
if not hasattr(elem.tag, 'find'):
i = elem.tag.find('}')
......@@ -103,7 +103,7 @@ class GenericNewsPage(HTMLPage):
return __article
def drop_comments(self, base_element):
for comment in base_element.getiterator(Comment):
for comment in base_element.iter(Comment):
def try_remove(self, base_element, selector):
......@@ -129,7 +129,7 @@ class AuthorPage(HTMLPage):
def obj_description(self):
description = u''
for para in self.el.xpath('//td[has-class("t0")]')[0].getchildren():
for para in self.el.xpath('//td[has-class("t0")]')[0]:
if para.tag not in ('b', 'br'):
if para.text is not None:
......@@ -24,13 +24,24 @@ from __future__ import unicode_literals
from weboob.browser import AbstractBrowser, URL, need_login
from .boursedirect_pages import MarketOrdersPage, MarketOrderDetailsPage
from .boursedirect_pages import (
MarketOrdersPage, MarketOrderDetailsPage, AccountsPage, HistoryPage,
class BourseDirectBrowser(AbstractBrowser):
PARENT = 'boursedirect'
# These URLs have been updated on Bourse Direct but not on ING.
# If they are updated on ING, remove these definitions and associated abstract pages.
accounts = URL(
history = URL(r'/priv/compte.php\?ong=3&nc=(?P<nc>\d+)', HistoryPage)
market_orders = URL(r'/priv/compte.php\?ong=7', MarketOrdersPage)
market_orders_details = URL(r'/priv/detailOrdre.php', MarketOrderDetailsPage)
......@@ -24,6 +24,18 @@ from __future__ import unicode_literals
from .compat.weboob_browser_pages import AbstractPage
class AccountsPage(AbstractPage):
PARENT = 'boursedirect'
PARENT_URL = 'accounts'
BROWSER_ATTR = 'package.browser.BoursedirectBrowser'
class HistoryPage(AbstractPage):
PARENT = 'boursedirect'
PARENT_URL = 'history'
BROWSER_ATTR = 'package.browser.BoursedirectBrowser'
class MarketOrdersPage(AbstractPage):
PARENT = 'boursedirect'
PARENT_URL = 'market_orders'
......@@ -37,7 +37,9 @@ from weboob.capabilities.profile import CapProfile
from import Module, BackendConfig
from import sorted_transactions
from .compat.weboob_tools_value import ValueBackendPassword, Value
from weboob.capabilities.base import find_object, strict_find_object, NotAvailable
from weboob.capabilities.base import (
find_object, strict_find_object, NotAvailable, empty,
from .browser import LCLBrowser, LCLProBrowser
from .enterprise.browser import LCLEnterpriseBrowser, LCLEspaceProBrowser
......@@ -173,6 +175,17 @@ class LCLModule(Module, CapBankWealth, CapBankTransferAddRecipient, CapContact,
return True
return super(LCLModule, self).transfer_check_label(old, new)
def transfer_check_account_iban(self, old, new):
# Some accounts' ibans cannot be found anymore on the website. But since we
# kept the iban stored on our side, the 'old' transfer.account_iban is not
# empty when making a transfer. When we do not find the account based on its iban,
# we search it based on its id. So the account is valid, the iban is just empty.
# This check allows to not have an assertion error when making a transfer from
# an account in this situation.
if empty(new):
return True
return old == new
@only_for_websites('par', 'elcl', 'pro')
def iter_contacts(self):
return self.browser.get_advisor()
......@@ -142,7 +142,7 @@ class IndexPage(HTMLPage):
return tide
def _is_low_tide_first(self, jour):
return XPath('//tr[@id="MareeJours_%s"]/td[1]' % jour)(self)[0].getchildren()[0].tag != 'b'
return list(XPath('//tr[@id="MareeJours_%s"]/td[1]' % jour)(self)[0])[0].tag != 'b'
def _get_low_tide_value(self, AM=True, jour=0):
slow_tide_pos = 1 if self._is_low_tide_first(jour) else 2
......@@ -184,11 +184,11 @@ class IssuesPage(BaseIssuePage):
# No results.
for tr in issues.getiterator('tr'):
for tr in issues.iter('tr'):
if not tr.attrib.get('id', '').startswith('issue-'):
issue = {'id': tr.attrib['id'].replace('issue-', '')}
for td in tr.getiterator('td'):
for td in tr.iter('td'):
field = td.attrib.get('class', '')
if field in ('checkbox','todo',''):
......@@ -22,9 +22,11 @@
from __future__ import unicode_literals
import re
from requests.exceptions import ConnectionError
from urllib3.exceptions import ReadTimeoutError
from weboob.browser.browsers import LoginBrowser, URL, need_login, StatesMixin
from weboob.browser.exceptions import ServerError
from weboob.browser.exceptions import ServerError, HTTPNotFound
from weboob.exceptions import BrowserIncorrectPassword, ActionNeeded, NoAccountsException
from .compat.weboob_capabilities_wealth import Investment
from import is_isin_valid
......@@ -229,17 +231,33 @@ class S2eBrowser(LoginBrowser, StatesMixin):
self.logger.warning('Server returned a Server Error when trying to fetch investment performances.')
if not self.bnp_investments.is_here():
# BNPInvestmentsPage was not accessible, trying the next request
# would lead to a 401 error.
self.logger.warning('Could not access BNP investments page, no investment details will be fetched.')
# Access the BNP API to get the investment details using its ID (found in its label)
m ='- (\d+)$', inv.label)
if m:
inv_id =
except (ConnectionError, ReadTimeoutError):
# The BNP API times out quite often so we must handle timeout errors
self.logger.warning('Could not connect to the BNP API, no investment details will be fetched.')
self.logger.warning('Could not fetch BNP investment ID in its label, no details will be fetched.')
self.logger.warning('Could not fetch BNP investment ID in its label, no investment details will be fetched.')
elif self.amfcode_amundi.match(inv._link):
except HTTPNotFound:
self.logger.warning('Details on AMF Amundi page are not available for this investment.')
details_url =
performance_url =
if details_url:
......@@ -275,7 +275,7 @@ class AMFHSBCPage(LoggedPage, XMLPage, CodePage):
def build_doc(self, content):
doc = super(AMFHSBCPage, self).build_doc(content).getroot()
# Remove namespaces
for el in doc.getiterator():
for el in doc.iter():
if not hasattr(el.tag, 'find'):
i = el.tag.find('}')
......@@ -38,7 +38,7 @@ class RoadMapDuration(Duration):
class DepartureTypeFilter(Filter):
def filter(self, el):
result = []
for img in el[0].getiterator(tag='img'):
for img in el[0].iter(tag='img'):
return u' '.join(result)
# -*- coding: utf-8 -*-
# Copyright(C) 2017 Vincent A
# This file is part of a weboob module.
# This weboob module 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.
# This weboob module is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# GNU Lesser General Public License for more details.
# You should have received a copy of the GNU Lesser General Public License
# along with this weboob module. If not, see <>.
from __future__ import unicode_literals
from .module import UnsplashModule
__all__ = ['UnsplashModule']
# -*- coding: utf-8 -*-
# Copyright(C) 2017 Vincent A
# This file is part of a weboob module.
# This weboob module 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.
# This weboob module is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# GNU Lesser General Public License for more details.
# You should have received a copy of the GNU Lesser General Public License
# along with this weboob module. If not, see <>.
from weboob.browser import PagesBrowser, URL
from .pages import ImageSearch
class UnsplashBrowser(PagesBrowser):