Weboob
Weboob est un framework dédié à l'extraction de données provenant de sites web et à l'agrégation de ces données. Il permet d'interagir avec ces sites web.
En plus du framework permettant l'écriture de connecteurs, weboob est fourni avec un ensemble de connecteurs, chacun interagissant avec un site existant. Les connecteurs sont différents car chaque site a un fonctionnement différent. Les sites peuvent êtredes sites bancaires ou facturiers, mais aussi des sites de vidéo, de cuisine, de météo, etc.
Enfin, weboob inclut des applications (graphiques ou en ligne de commande) pour utiliser ces connecteurs. Il est possible d'écrire sa propre application en utilisant l'agrégation des différents connecteurs, grâce à une API simple. Par exemple, des projets externes se servent de weboob comme Kresus qui est un PFM ou Flatisfy qui permet de chercher des annonces immobilières, ou encore Budget-Insight.
Connecteurs
Les connecteurs sont groupés en capabilities, une "capability" étant le type d'action que l'on peut avoir sur un site, par exemple la CapBank
concerne l'agrégation bancaire : consultation des comptes et des transactions bancaires.
CapBank
Extrait de weboob/capabilities/bank.py
:
class CapBank(CapCollection):
def iter_accounts(self):
raise NotImplementedError()
def get_account(self, id):
return find_object(self.iter_accounts(), id=id, error=AccountNotFound)
def iter_history(self, account):
raise NotImplementedError()
def iter_coming(self, account):
raise NotImplementedError()
CapBank
est une interface abstraite qui décrit l'API des connecteurs bancaires. C'est cette interface qu'appelleront les applications désirant faire l'agrégation de données bancaires (comme boobank). Chaque connecteur devra donc implémenter les méthodes de CapBank
.
Certaines méthodes sont obligatoires et d'autres optionnelles. Par exemple, la méthode iter_accounts()
est obligatoire car, sans elle, il ne resterait plus rien. Au contraire, la méthode iter_coming(account)
est optionnelle car certains sites ne présentent pas les opérations bancaires à venir, ou du moins pas sur tous les comptes.
CapBank
Modèles de données de Les méthodes de CapBank
retourneront des objets du modèle de données associé, par exemple chaque module implémentant la méthode CapBank.iter_accounts()
retournera des objets de la classe Account
, avec des attributs bien définis, obligatoires et optionnels.
class BaseObject(with_metaclass(_BaseObjectMeta, StrConv, object)):
id = None
backend = None
url = StringField('url')
class BaseAccount(BaseObject, Currency):
label = StringField('Pretty label')
currency = StringField('Currency', default=None)
class Account(BaseAccount):
type = EnumField('Type of account', AccountType, default=TYPE_UNKNOWN)
owner_type = StringField('Usage of account') # cf AccountOwnerType class
balance = DecimalField('Balance on this bank account')
coming = DecimalField('Sum of coming movements')
iban = StringField('International Bank Account Number')
Utilisation par une application
Boobank est une application basique en ligne de commande permettant de consulter des comptes bancaires. Indépendamment du connecteur utilisé, boobank passera par l'API de CapBank
et appellera des méthodes comme iter_accounts()
, ce qui abstrait les différences entre les différents connecteurs.
Bas-niveau
Au contraire de Selenium, weboob est un framework basé sur la bibliothèque requests
, qui n'exécute pas le code javascript contenu dans les pages visitées.
Exemple d'un connecteur
Un module weboob est souvent composé par 4 fichiers.
__init__.py
Fichier Ce fichier est obligatoire dans tout dossier contenant plusieurs modules de code Python. Un connecteur weboob n'y fait pas exception. Ce fichier est le point d'entrée du connecteur même s'il ne contient quasiment rien. Il doit faire référence au fichier module.py
.
from .module import DemoModule
__all__ = ['DemoModule']
module.py
Fichier Ce fichier est le point d'entrée du connecteur. Il ne contient qu'une seule classe.
Celle-ci contient les métadonnées du connecteur, comme son nom, les informations du créateur du module, la licence.
class DemoModule(Module, CapBank):
NAME = 'demov1'
DESCRIPTION = 'Demo v1'
MAINTAINER = 'John Doe'
EMAIL = 'johndoe@example.com'
LICENSE = 'LGPLv3+'
VERSION = '1.6'
Est aussi présente la description de la configuration nécessaire pour utiliser le connecteur, par exemple s'il faut un renseigner un nom d'utilisateur, un mot de passe, une date de naissance, un choix particuliers/professionnels.
CONFIG = BackendConfig(
ValueBackendPassword('login', label='Identifiant', masked=False),
ValueBackendPassword('password', label='Mot de passe')
)
Enfin, le principal, c'est là qu'est implémentée l'interface de la ou les capabilities. Par exemple, un module héritera de la classe abstraite CapBank
et implémentera les différentes méthodes, souvent en appelant le browser.
BROWSER = DemoBrowser
def create_default_browser(self):
return self.create_browser(self.config['login'].get(), self.config['password'].get())
def iter_accounts(self):
return self.browser.iter_accounts()
def iter_history(self, account):
return self.browser.iter_history(account)
browser.py
Fichier Ce fichier contient le browser, une classe qui fera toute la navigation sur le site web que l'on veut scraper.
On y déclare par exemple l'adresse principale du site (voir le champ BASEURL
), ainsi que toutes les adresses qui serviront durant le scraping. Ce sont des expressions régulières, ce qui permet de gérer les adresses avec des parties variables.
class DemoBrowser(LoginBrowser):
BASEURL = 'https://people.lan.budget-insight.com/~ntome/fake_bank.wsgi/v1/'
login = URL(r'login', LoginPage)
accounts = URL(r'accounts$', AccountsPage)
history = URL(r'accounts/(?P<account>\w+)', HistoryPage)
Navigation
Une méthode comme iter_accounts()
ira sur une ou plusieurs de ces pages afin de pouvoir lister les comptes correctement. Par exemple, si pour un site donné, les cartes de crédit se trouvent sur une page différente de la page des comptes courant, il faudra lister les adresses des 2 pages, et que le browser les charge.
Pour charger une URL, on peut utiliser les adresses pré-enregistrées et appeler la méthode go()
. Par exemple, pour aller sur https://people.lan.budget-insight.com/~ntome/fake_bank.wsgi/v1/accounts
:
def iter_accounts(self):
self.accounts.go()
On aurait également pu utiliser :
def iter_accounts(self):
self.location('https://people.lan.budget-insight.com/~ntome/fake_bank.wsgi/v1/accounts')
Parsing
Il faut explorer le site de la banque avec un navigateur pour voir où il faudra aller pour trouver les informations souhaitées.
Une fois que le browser va sur les bonnes pages, il faut extraire les informations présentes sur celles-ci, et cette extraction se fera dans pages.py
. Ainsi, browser.py
s'occupe de la navigation et délègue le parsing à pages.py
.
def iter_accounts(self):
self.accounts.go()
return self.page.iter_accounts()
self.page
sera la page courante que l'on vient de visiter, et l'on pourra accéder à ses fonctions (définies dans pages.py
, que nous allons voir).
XXX déplacer ?
Évidemment, quand on écrit un nouveau module, on ne peut pas prédire toutes les pages à visiter puisque l'on ne connait qu'un échantillon très restreint des utilisateurs. Quand on part d'un module existant, on peut s'aider des commentaires laissés dans le code, des explications contenues dans les messages de commits faits sur le module, ou bien en accédant aux comptes de vrais utilisateurs.
pages.py
Fichier Ce fichier contient plusieurs classes de pages. Chaque classe correspond à un type de page que l'on peut rencontrer (par exemple, la page de login, la page des comptes courants, la page des relevés des comptes courant, etc.). Le fichier browser.py
associe à chaque adresse une classe de page (avec la déclaration des URL
).
Chaque classe de page implémentera des méthodes qui extraieront le type d'informations présents sur la page. Par exemple la classe AccountsPage
implémentera une méthode iter_accounts()
, mais la classe HistoryPage
implémentera une méthode iter_history(account)
. Ainsi, le browser appellera les méthodes de ces classes.
Le parsing peut être effectué à l'aide de méthodes ordinaires qui parcoureront l'arbre DOM des balises HTML ou qui utiliseront des expressions XPath pour sélectionner des balises précises. Cette façon de faire, appelée "Browser 1" au sein de weboob, est celle qui se ferait avec d'autres frameworks classiques de scraping.
Browser 2
Avec weboob, il existe une autre méthode, appelée "Browser 2", qui se rapprochera plus d'une configuration (quoique puissante) que d'un code traditionnel Python. C'est une méthode plus déclarative, où il suffira de configurer quelques expressions XPath pour décrire où se trouvent les données, en ajoutant éventuellement quelques traitements (appelés des "filtres"), par exemple pour appliquer une expression régulière ou convertir en nombre ou en date.
La méthode "Browser 2" est la méthode recommandée pour écrire de nouveaux modules ou quand il faut réécrire du code. L'un des avantages apporté est une structure au code du parsing qui rend plus simple la lecture de modules différents, en imposant des conventions. Un autre avantage est que le code écrit en "Browser 2" est plus concis car il se concentre sur l'essentiel. Sa syntaxe est un peu déroutante au premier abord, mais on peut le voir comme un DSL (Domain-Specific Language), un langage un peu différent de Python, un langage de configuration dédié au scraping.
Voici un petit exemple de code écrit en "browser 2" :
class AccountsPage(LoggedPage, HTMLPage):
@method
class iter_accounts(ListElement):
item_xpath = '//table/tbody/tr'
class item(ItemElement):
klass = Account
obj_label = CleanText('./td[2]')
obj_balance = CleanDecimal.French('./td[3]')
obj_type = MapIn(Field('label'), ACCOUNT_LABEL_TO_TYPE, default=Account.TYPE_UNKNOWN)
Et voilà le code équivalent écrit sans "browser 2", tel qu'on pourrait écrire avec d'autres frameworks que weboob :
class AccountsPage(LoggedPage, HTMLPage):
def iter_accounts(self):
for el in self.doc.xpath('//table/tbody/tr'):
account = Account()
account.label = el.xpath('./td[2]')[0].text_content()
balance_str = el.xpath('./td[3]')[0].text_content()
account.balance = Decimal(re.search('[+-][\d,]+', balance_str).group(0))
for pattern, type_ in ACCOUNT_LABEL_TO_TYPE.items():
if pattern in account.label:
account.type = type_
break
yield account
On peut voir que le code "browser 2" est plus concis mais fait appel à des fonctions que nous ne connaissons pas encore, nous allons détailler ça.
Weboob fournit beaucoup de facilités pour gérer la pagination aussi, etc.
Extraction d'une liste d'éléments
@method
class iter_accounts(ListElement):
item_xpath = '//table/tbody/tr'
On peut voir que iter_accounts
n'est pas une vraie méthode Python mais une classe, mais le décorateur @method
va "transformer" cela en une méthode.
Cette classe iter_accounts
hérite de ListElement
, ce qui indique que plusieurs éléments seront retournés par la méthode. Quand on utilise ListElement
, il faut ajouter un champ item_xpath
qui contiendra le XPath de tous les éléments de la page à parcourir, et dans ce cas précis ce sera les comptes. Sans avoir besoin d'écrire une boucle for
, ListElement
va itérer sur tous les éléments trouvés par le XPath.
Extraction d'un élément
class item(ItemElement):
klass = Account
obj_label = CleanText('./td[1]')
obj_id = Regexp(CleanText('./td[1]'), r'\((\d+)\)')
obj_currency = 'EUR'
obj_balance = CleanDecimal.French('./td[2]')
def obj_type(self):
label = CleanText('./td[1]')(self)
types = {
'compte courant': Account.TYPE_CHECKING,
}
return types.get(label, Account.TYPE_UNKNOWN)
Imbriqué dans notre class iter_accounts
, se trouve une classe item
, héritant d'ItemElement
cette fois. Elle décrira le parsing de chacun des éléments de la liste. On commence par indiquer de quel type seront les objets présents dans la liste, ici klass = Account
. Puis, on spécifie comment remplir chaque champ de l'objet en déclarant des attributs nommés obj_<nom du champ du modèle Account>
avec un ou plusieurs filtres pour extraire l'information.
Pour chaque élément HTML identifié par le item_xpath
du ListElement
parent, le contenu du ItemElement
sera évalué. À chaque fois, un objet Account()
sera créé, et les champs seront remplis.
Filtres
Par exemple, le filtre CleanText
prendra tout le texte d'un élément HTML identifié par le XPath donné. Commençant par "./
", le XPath est relatif à l'élément de la ligne du compte actuel. Le filtre est évalué dans le contexte de l'ItemElement
qui est lui même dans le contexte d'un ListElement
, comme s'il était dans une boucle for
. Ce "./
" est similaire au "./
" d'un chemin de fichier sur un UNIX.
obj_label = CleanText('./td[1]')
signifie donc : "pour chaque //table/tbody/tr
, créer un objet Account
et remplir le champ label
par le texte du ./td[1]
".
Il est possible de combiner des filtres, par exemple on peut récupérer le texte avec CleanText
puis appliquer une expression régulière pour ne sélectionner qu'une partie du texte. Voir le obj_id
en exemple.
Constantes
Si la valeur à mettre dans un champ est constante, il n'est pas nécessaire de mettre un filtre mais simplement une constante. Attention aux constantes mutables cependant.
Surcharge d'un filtre
Avec Browser 2, il peut arriver qu'il n'existe aucun filtre pour faire ce que l'on veut, car ce que l'on désire est trop spécifique par exemple. Remplir un champ avec des filtres pourrait s'avérer difficile, mais dans ce cas, il est possible d'écrire du code Python classique uniquement pour le champ problématique, et conserver l'utilisation de filtres pour tous les autres champs.
def obj_type(self):
types = {
'compte courant': Account.TYPE_CHECKING,
}
return types.get(label, Account.TYPE_UNKNOWN)
permet d'utiliser une fonction Python ordinaire pour remplir le champ type
. C'est un exemple, le filtre Map
aurait parfaitement convenu ici.
Ainsi, il est possible de "débrayer" le système de Browser 2 ponctuellement, pour les cas où le système déclaratif de Browser 2 se montre trop limité.
TODO De même, si l'on doit sélectionner des éléments d'une manière que XPath ne permet pas ou rend trop compliqué, il est possible de se rabattre sur du code Python ordinaire uniquement sur la sélection d'éléments.
Gérer le login
Pour naviguer sur de nombreuses pages, comme la page des comptes par exemple, il faudra être authentifié auprès du site. Côté browser, on annotera les méthodes comme iter_accounts
avec le décorateur @need_login
pour indiquer qu'il faudra être authentifié avant d'entrer dans la fonction. Si l'on n'est pas encore authentifié, la méthode do_login
sera appelée. Il nous faudra donc également implémenter cette méthode.
Côté browser.py
:
def do_login(self):
self.login.go()
self.page.do_login(self.username, self.password)
# self.username et self.password existent toujours dans LoginBrowser
# ici, vérifier si on est bien authentifié ou s'il y a un message d'erreur
@need_login
def iter_accounts(self):
self.accounts.go()
return self.page.iter_accounts()
Côté pages.py
, nous devrons implémenter le traitement du login sur la page elle-même :
class LoginPage(HTMLPage):
def do_login(self, username, password):
form = self.get_form()
form['login'] = username
form['password'] = password
form.submit()
Il y avait un simple formulaire sur la page, nous remplissons les champs (presque) comme un utilisateur et soumettons le formulaire. Si le mot de passe est incorrect, certains sites nous remettront sur la page de login avec un code de retour 200 et un message d'erreur.
Il faudrait vérifier à la fin de do_login
si tout a fonctionné ou s'il y a une erreur. Il faut faire cette vérification côté browser, car on a peut-être changé de page, or l'instance de LoginPage
représentera l'état inchangé de l'ancienne page.
Commencer à écrire un module
Créer une coquille vide
./tools/boilerplate/boilerplate.py cap MODULE_NAME CapBank
Cela créera un dossier nommé d'après le module dans ~/dev/weboob/modules
avec les fichiers de base. Lancer weboob-config update
pour que weboob l'ajoute à sa liste.
Tester son module
Après avoir ajouté de vraies requêtes dans le module, on peut commencer à le tester.
Ajouter dans ~/.config/weboob/backends
:
[NOM_BACKEND]
_module = NOM_MODULE
login = LE_LOGIN
password = LE_MOT_DE_PASSE
Puis lancer boobank -b NOM_BACKEND
.