diff --git a/modules/spirica/__init__.py b/modules/spirica/__init__.py
new file mode 100644
index 0000000000000000000000000000000000000000..ddc442178f001f4c537c70876938db39783a7363
--- /dev/null
+++ b/modules/spirica/__init__.py
@@ -0,0 +1,24 @@
+# -*- coding: utf-8 -*-
+
+# Copyright(C) 2016 Edouard Lambert
+#
+# 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 .
+
+
+from .module import SpiricaModule
+
+
+__all__ = ['SpiricaModule']
diff --git a/modules/spirica/browser.py b/modules/spirica/browser.py
new file mode 100644
index 0000000000000000000000000000000000000000..c404298fc919b2a6c2d86f6db49766bb74af14da
--- /dev/null
+++ b/modules/spirica/browser.py
@@ -0,0 +1,73 @@
+# -*- coding: utf-8 -*-
+
+# Copyright(C) 2016 Edouard Lambert
+#
+# 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 .
+
+
+from weboob.browser import LoginBrowser, URL, need_login
+from weboob.exceptions import BrowserIncorrectPassword
+
+from .pages import LoginPage, AccountsPage, DetailsPage
+
+
+class SpiricaBrowser(LoginBrowser):
+ login = URL('/securite/login.xhtml', LoginPage)
+ accounts = URL('/sylvea/client/synthese.xhtml', AccountsPage)
+ details = URL('/sylvea/contrat/consultationContratEpargne.xhtml', DetailsPage)
+
+ def __init__(self, website, username, password, *args, **kwargs):
+ super(LoginBrowser, self).__init__(*args, **kwargs)
+ self.BASEURL = website
+ self.username = username
+ self.password = password
+
+ def do_login(self):
+ self.login.go().login(self.username, self.password)
+
+ if self.login.is_here():
+ raise BrowserIncorrectPassword
+
+ def get_subscription_list(self):
+ return iter([])
+
+ @need_login
+ def iter_accounts(self):
+ return self.accounts.stay_or_go().iter_accounts()
+
+ @need_login
+ def iter_investment(self, account):
+ # Get form to show PRM
+ form = self.location(account._link).page.get_investment_form()
+ return self.location(form.url, data=dict(form)).page.iter_investment()
+
+ @need_login
+ def iter_history(self, account):
+ # Get form to go to History's tab
+ form = self.location(account._link).page.get_historytab_form()
+ # Get form to show all transactions
+ form = self.location(form.url, data=dict(form)).page.get_historyallpages_form()
+ # Get forms to expand details of all transactions
+ for form in self.location(form.url, data=dict(form)).page.get_historyexpandall_form():
+ self.location(form.url, data=dict(form))
+ # Get all transactions
+ self.skipped = []
+ transactions = []
+ for t in self.page.iter_history():
+ transactions.append(t)
+ for t in self.page.iter_history_skipped():
+ transactions.append(t)
+ return iter(sorted(transactions, key=lambda t: t.date, reverse=True))
diff --git a/modules/spirica/module.py b/modules/spirica/module.py
new file mode 100644
index 0000000000000000000000000000000000000000..5d76ad72639313cb9d04316ae568d8e710b43610
--- /dev/null
+++ b/modules/spirica/module.py
@@ -0,0 +1,58 @@
+# -*- coding: utf-8 -*-
+
+# Copyright(C) 2016 Edouard Lambert
+#
+# 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 .
+
+
+from weboob.tools.backend import Module, BackendConfig
+from weboob.tools.value import ValueBackendPassword
+from weboob.capabilities.bank import CapBank, AccountNotFound
+from weboob.capabilities.base import find_object
+
+from .browser import SpiricaBrowser
+
+
+__all__ = ['SpiricaModule']
+
+
+class SpiricaModule(Module, CapBank):
+ NAME = 'spirica'
+ DESCRIPTION = u'Spirica'
+ MAINTAINER = u'Edouard Lambert'
+ EMAIL = 'elambert@budget-insight.com'
+ LICENSE = 'AGPLv3+'
+ VERSION = '1.2'
+ CONFIG = BackendConfig(
+ ValueBackendPassword('login', label='Identifiant', masked=False),
+ ValueBackendPassword('password', label='Mot de passe'))
+
+ BROWSER = SpiricaBrowser
+
+ def create_default_browser(self):
+ return self.create_browser("https://www.sylvea.fr", self.config['login'].get(), self.config['password'].get())
+
+ def get_account(self, _id):
+ return find_object(self.browser.iter_accounts(), id=_id, error=AccountNotFound)
+
+ def iter_accounts(self):
+ return self.browser.iter_accounts()
+
+ def iter_history(self, account):
+ return self.browser.iter_history(account)
+
+ def iter_investment(self, account):
+ return self.browser.iter_investment(account)
diff --git a/modules/spirica/pages.py b/modules/spirica/pages.py
new file mode 100644
index 0000000000000000000000000000000000000000..40b6f59547f9549dbfea07db3cc3c99b4438c939
--- /dev/null
+++ b/modules/spirica/pages.py
@@ -0,0 +1,229 @@
+# -*- coding: utf-8 -*-
+
+# Copyright(C) 2016 Edouard Lambert
+#
+# 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 .
+
+
+import re
+
+from weboob.browser.pages import HTMLPage, LoggedPage
+from weboob.browser.elements import ItemElement, TableElement, SkipItem, method
+from weboob.browser.filters.standard import CleanText, Date, Regexp, CleanDecimal, Env, TableCell, Field, Async, AsyncLoad
+from weboob.browser.filters.html import Attr, Link
+from weboob.capabilities.bank import Account, Investment, Transaction
+from weboob.capabilities.base import NotAvailable
+
+
+def MyDecimal(*args, **kwargs):
+ kwargs.update(replace_dots=True, default=NotAvailable)
+ return CleanDecimal(*args, **kwargs)
+
+
+class LoginPage(HTMLPage):
+ def login(self, login, password):
+ form = self.get_form('//form[@id="loginForm"]')
+ form['loginForm:name'] = login
+ form['loginForm:password'] = password
+ form['loginForm:login'] = "loginForm:login"
+ form.submit()
+
+
+class AccountsPage(LoggedPage, HTMLPage):
+ TYPES = {'Assurance Vie': Account.TYPE_LIFE_INSURANCE, 'Unknown': Account.TYPE_UNKNOWN}
+
+ @method
+ class iter_accounts(TableElement):
+ item_xpath = '//table[@role]/tbody/tr'
+ head_xpath = '//table[@role]/thead/tr/th'
+
+ col_label = u'Produit'
+ col_id = u'Numéro de contrat'
+ col_balance = u'Montant (€)'
+
+ class item(ItemElement):
+ klass = Account
+
+ load_details = Field('_link') & AsyncLoad
+
+ obj_id = CleanText(TableCell('id'), replace=[(' ', '')])
+ obj_label = CleanText(TableCell('label'))
+ obj_balance = MyDecimal(TableCell('balance'))
+ obj_valuation_diff = Async('details') & MyDecimal('//tr[1]/td[contains(text(), \
+ "value du contrat")]/following-sibling::td')
+ obj__link = Link('.//a')
+
+ def obj_type(self):
+ return self.page.TYPES[Async('details', CleanText('//td[contains(text(), \
+ "Option fiscale")]/following-sibling::td', default="Unknown"))(self)]
+
+
+class DetailsPage(LoggedPage, HTMLPage):
+ DEBIT_WORDS = [u'arrêté', 'rachat', 'frais', u'désinvestir']
+
+ def get_investment_form(self):
+ form = self.get_form('//form[contains(@id, "j_idt")]')
+ form['ongletSituation:ongletContratTab_newTab'] = \
+ Link().filter(self.doc.xpath('//a[contains(text(), "Prix de revient moyen")]'))[1:]
+ form['javax.faces.source'] = "ongletSituation:ongletContratTab"
+ form['javax.faces.behavior.event'] = "tabChange"
+ return form
+
+ @method
+ class iter_investment(TableElement):
+ item_xpath = '//div[contains(@id, "INVESTISSEMENT")]//table/tbody/tr[@data-ri]'
+ head_xpath = '//div[contains(@id, "INVESTISSEMENT")]//table/thead/tr/th'
+
+ col_label = u'Support'
+ col_vdate = u'Date de valeur'
+ col_unitvalue = u'Valeur de part'
+ col_quantity = u'Nombre de parts'
+ col_valuation = re.compile('Contre')
+ col_portfolio_share = u'%'
+
+ class item(ItemElement):
+ klass = Investment
+
+ obj_label = CleanText(TableCell('label'))
+ obj_code = Regexp(CleanText('.//td[contains(text(), "Isin")]'), ':[\s]+([\w]+)', default=NotAvailable)
+ obj_quantity = MyDecimal(TableCell('quantity'))
+ obj_unitvalue = MyDecimal(TableCell('unitvalue'))
+ obj_valuation = MyDecimal(TableCell('valuation'))
+ obj_vdate = Date(CleanText(TableCell('vdate')), dayfirst=True, default=NotAvailable)
+ obj_portfolio_share = MyDecimal(TableCell('portfolio_share'))
+
+ def obj_unitprice(self):
+ return MyDecimal('//div[contains(@id, "PRIX_REVIENT")]//a[contains(text(), \
+ "%s")]/ancestor::tr/td[5]' % Field('label')(self))(self)
+
+ def obj_diff(self):
+ return MyDecimal('//div[contains(@id, "PRIX_REVIENT")]//a[contains(text(), \
+ "%s")]/ancestor::tr/td[6]' % Field('label')(self))(self)
+
+ def get_historytab_form(self):
+ form = self.get_form('//form[contains(@id, "j_idt")]')
+ idt = Attr(None, 'name').filter(self.doc.xpath('//input[contains(@name, "j_idt") \
+ and contains(@name, "activeIndex")]')).rsplit('_', 1)[0]
+ form['%s_contentLoad' % idt] = "true"
+ form['%s_newTab' % idt] = Link().filter(self.doc.xpath('//a[contains(@href, "HISTORIQUE")]'))[1:]
+ form['%s_activeIndex' % idt] = "1"
+ form['javax.faces.source'] = idt
+ form['javax.faces.behavior.event'] = "tabChange"
+ return form
+
+ def get_historyallpages_form(self):
+ form = self.get_form('//form[contains(@id, "j_idt")]')
+ idt = re.search('{[^\w]+([\w\d:]+)', self.doc.xpath('//a[contains(text(), "Tout")]/@onclick')[0]).group(1)
+ form[idt] = idt
+ return form
+
+ def get_historyexpandall_form(self):
+ form = self.get_form('//form[contains(@id, "j_idt")]')
+ form['javax.faces.source'] = "ongletHistoOperations:newoperations"
+ form['javax.faces.behavior.event'] = "rowToggle"
+ form['ongletHistoOperations:newoperations_rowExpansion'] = "true"
+ for data in self.doc.xpath('//tr[@data-ri]/@data-ri'):
+ form['ongletHistoOperations:newoperations_expandedRowIndex'] = data
+ yield form
+
+ def get_investments(self, el, xpath='.'):
+ # Get all positions of th
+ positions = {}
+ keys = {'isin': 'code', 'support': 'label', 'nombre de parts': 'quantity', 'valeur de part': \
+ 'unitvalue', 'montant brut': 'valuation', 'date de valeur': 'vdate', '%': 'portfolio_share'}
+ for position, th in enumerate(el.xpath("%s//thead//th" % xpath)):
+ key = CleanText().filter(th.xpath('.')).lower()
+ if key in keys:
+ positions[keys[key]] = position + 1
+
+ investments = []
+ for tr in el.xpath("%s//tbody/tr[@data-ri]" % xpath):
+ i = Investment()
+ i.label = CleanText().filter(tr.xpath('./td[%s]' % positions['label'])) \
+ if "label" in positions else NotAvailable
+ i.code = CleanText().filter(tr.xpath('./td[%s]' % positions['code']))
+ i.code = NotAvailable if len(i.code) != 12 else i.code
+ i.quantity = MyDecimal().filter(tr.xpath('./td[%s]' % positions['quantity'])) \
+ if "quantity" in positions else NotAvailable
+ i.unitvalue = MyDecimal().filter(tr.xpath('./td[%s]' % positions['unitvalue'])) \
+ if "unitvalue" in positions else NotAvailable
+ i.valuation = MyDecimal().filter(tr.xpath('./td[%s]' % positions['valuation'])) \
+ if "valuation" in positions else NotAvailable
+ i.vdate = Date(CleanText('./td[%s]' % positions['vdate']), dayfirst=True, default=NotAvailable)(tr) \
+ if "vdate" in positions else NotAvailable
+ i.portfolio_share = MyDecimal().filter(tr.xpath('./td[%s]' % positions['portfolio_share'])) \
+ if "portfolio_share" in positions else NotAvailable
+ investments.append(i)
+
+ return investments
+
+ @method
+ class iter_history(TableElement):
+ item_xpath = '//table/tbody[@id and not(contains(@id, "j_idt"))]/tr[@data-ri]'
+ head_xpath = '//table/thead[@id and not(contains(@id, "j_idt"))]/tr/th'
+
+ col_label = u'Type'
+ col_status = u'Etat'
+ col_brut = u'Montant brut'
+ col_net = u'Montant net'
+ col_date = u'Date de réception'
+ col_vdate = u'Date de valeur'
+
+ class item(ItemElement):
+ klass = Transaction
+
+ obj_label = CleanText(TableCell('label'))
+ obj_vdate = Date(CleanText(TableCell('vdate')), dayfirst=True)
+ obj_type = Transaction.TYPE_BANK
+ obj_investments = Env('investments')
+
+ def obj_amount(self):
+ amount = MyDecimal(TableCell('net') if not CleanText(TableCell('brut'))(self) else TableCell('brut'))(self)
+ return -amount if amount and any(word in Field('label')(self).lower() for word in self.page.DEBIT_WORDS) else amount
+
+ def obj_date(self):
+ return Date(CleanText(TableCell('date')), dayfirst=True, default=Field('vdate')(self))(self)
+
+ def condition(self):
+ return u"Validé" in CleanText(TableCell('status'))(self)
+
+ def parse(self, el):
+ if u"Désinvestir" in CleanText('./following-sibling::tr[1]')(self):
+ self.page.browser.skipped.append([el, el.xpath('./following-sibling::tr[1]')[0]])
+ raise SkipItem()
+
+ self.env['investments'] = self.page.get_investments(el, \
+ './following-sibling::tr[1]//span[contains(text(), "ISIN")]/ancestor::table[1]')
+
+ def iter_history_skipped(self):
+ for tr1, tr2 in self.browser.skipped:
+ for table, h2 in zip(tr2.xpath('.//table[@role]'), tr2.xpath(u'.//h2')):
+ t = Transaction()
+
+ t.vdate = Date(CleanText('./td[8]'), dayfirst=True)(tr1)
+ t.date = Date(CleanText('./td[6]'), dayfirst=True, default=t.vdate)(tr1)
+ t.type = Transaction.TYPE_BANK
+ t.label = u"%s - %s" % (CleanText().filter(tr1.xpath('./td[2]')), \
+ CleanText().filter(h2.xpath('.')))
+ t.amount = CleanDecimal(replace_dots=True, default=MyDecimal().filter( \
+ tr1.xpath('./td[5]'))).filter(tr1.xpath('./td[4]'))
+
+ if t.amount and any(word in t.label.lower() for word in self.DEBIT_WORDS):
+ t.amount = -t.amount
+
+ t.investments = self.get_investments(table)
+
+ yield t
diff --git a/modules/spirica/test.py b/modules/spirica/test.py
new file mode 100644
index 0000000000000000000000000000000000000000..10a9f9d00b542e227d100348ce19f98574c712c4
--- /dev/null
+++ b/modules/spirica/test.py
@@ -0,0 +1,28 @@
+# -*- coding: utf-8 -*-
+
+# Copyright(C) 2016 Edouard Lambert
+#
+# 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 .
+
+
+from weboob.tools.test import BackendTest
+
+
+class SpiricaTest(BackendTest):
+ MODULE = 'spirica'
+
+ def test_spirica(self):
+ raise NotImplementedError()