diff --git a/modules/bienici/__init__.py b/modules/bienici/__init__.py
new file mode 100644
index 0000000000000000000000000000000000000000..2d61180cdff4f797b09c1a66a62b8d45ebc3ca3d
--- /dev/null
+++ b/modules/bienici/__init__.py
@@ -0,0 +1,26 @@
+# -*- coding: utf-8 -*-
+
+# Copyright(C) 2018 Antoine BOSSY
+#
+# This file is part of woob.
+#
+# woob 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.
+#
+# woob 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 woob. If not, see .
+
+from __future__ import unicode_literals
+
+
+from .module import BieniciModule
+
+
+__all__ = ['BieniciModule']
diff --git a/modules/bienici/browser.py b/modules/bienici/browser.py
new file mode 100644
index 0000000000000000000000000000000000000000..9f018f774cef37d6f7dbc100d57e11f26aeb3a66
--- /dev/null
+++ b/modules/bienici/browser.py
@@ -0,0 +1,103 @@
+# -*- coding: utf-8 -*-
+
+# Copyright(C) 2018 Antoine BOSSY
+#
+# This file is part of woob.
+#
+# woob 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.
+#
+# woob 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 woob. If not, see .
+
+from __future__ import unicode_literals
+
+
+from woob.browser import PagesBrowser, URL
+from woob.tools.json import json
+
+from woob.capabilities.housing import POSTS_TYPES, HOUSE_TYPES
+from .pages import Cities, ResultsPage, HousingPage
+
+
+TRANSACTION_TYPE = {
+ POSTS_TYPES.SALE: 'buy',
+ POSTS_TYPES.RENT: 'rent',
+ POSTS_TYPES.SALE: 'buy',
+ POSTS_TYPES.FURNISHED_RENT: 'rent',
+ POSTS_TYPES.SHARING: 'rent'
+}
+
+
+HOUSE_TYPES = {
+ HOUSE_TYPES.APART: ['flat'],
+ HOUSE_TYPES.HOUSE: ['house'],
+ HOUSE_TYPES.PARKING: ['parking'],
+ HOUSE_TYPES.LAND: ['terrain'],
+ HOUSE_TYPES.OTHER: ['others', 'loft', 'shop', 'building', 'castle', 'premises', 'office', 'townhouse'],
+ HOUSE_TYPES.UNKNOWN: []
+}
+
+
+class BieniciBrowser(PagesBrowser):
+ BASEURL = 'https://www.bienici.com'
+
+ cities = URL(r'https://res.bienici.com/suggest.json\?q=(?P.+)', Cities)
+ results = URL(r'/realEstateAds.json\?filters=(?P.+)', ResultsPage)
+ housing = URL(r'/realEstateAds-one.json\?filters=(?P.*)&onlyRealEstateAd=(?P.*)', HousingPage)
+
+ def get_cities(self, pattern):
+ return self.cities.go(zipcode=pattern).get_city()
+
+ def search_housing(self, query):
+ filters = {
+ 'size': 100,
+ 'page': 1,
+ 'resultsPerPage': 24,
+ 'maxAuthorizedResults': 2400,
+ 'sortBy': "relevance",
+ 'sortOrder': "desc",
+ 'onTheMarket': [True],
+ 'showAllModels': False,
+ "zoneIdsByTypes": {
+ 'zoneIds': []
+ },
+ 'propertyType': []
+ }
+
+ dict_query = query.to_dict()
+ if dict_query['area_min']:
+ filters['minArea'] = dict_query['area_min']
+
+ if dict_query['area_max']:
+ filters['maxArea'] = dict_query['area_max']
+
+ if dict_query['cost_min']:
+ filters['minPrice'] = dict_query['cost_min']
+
+ if dict_query['cost_max']:
+ filters['maxPrice'] = dict_query['cost_max']
+
+ filters['filterType'] = TRANSACTION_TYPE[dict_query['type']]
+
+ for housing_type in dict_query['house_types']:
+ filters['propertyType'] += HOUSE_TYPES[housing_type]
+
+ for city in dict_query['cities']:
+ filters['zoneIdsByTypes']['zoneIds'].append(city.id)
+
+ return self.results.go(filters=json.dumps(filters)).get_housings()
+
+ def get_housing(self, housing_id):
+ # This is to serialize correctly the JSON, and match the URL easier.
+ filters = {
+ 'onTheMarket': [True]
+ }
+ return self.housing.go(housing_id=housing_id, filters=json.dumps(filters)).get_housing()
diff --git a/modules/bienici/module.py b/modules/bienici/module.py
new file mode 100644
index 0000000000000000000000000000000000000000..0501a472e31791ad2c090fe27a384086190a9488
--- /dev/null
+++ b/modules/bienici/module.py
@@ -0,0 +1,86 @@
+# -*- coding: utf-8 -*-
+
+# Copyright(C) 2018 Antoine BOSSY
+#
+# This file is part of woob.
+#
+# woob 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.
+#
+# woob 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 woob. If not, see .
+
+from __future__ import unicode_literals
+
+
+from woob.tools.backend import Module
+from woob.capabilities.housing import CapHousing, Housing, HousingPhoto
+
+from .browser import BieniciBrowser
+
+
+__all__ = ['BieniciModule']
+
+
+class BieniciModule(Module, CapHousing):
+ NAME = 'bienici'
+ DESCRIPTION = 'bienici website'
+ MAINTAINER = 'Antoine BOSSY'
+ EMAIL = 'mail+github@abossy.fr'
+ LICENSE = 'AGPLv3+'
+ VERSION = '3.1'
+
+ BROWSER = BieniciBrowser
+
+ def get_housing(self, id):
+ """
+ Get an housing from an ID.
+
+ :param housing: ID of the housing
+ :type housing: str
+ :rtype: :class:`Housing` or None if not found.
+ """
+ return self.browser.get_housing(id)
+
+ def search_city(self, pattern):
+ """
+ Search a city from a pattern.
+
+ :param pattern: pattern to search
+ :type pattern: str
+ :rtype: iter[:class:`City`]
+ """
+ return self.browser.get_cities(pattern)
+
+ def search_housings(self, query):
+ """
+ Search housings.
+
+ :param query: search query
+ :type query: :class:`Query`
+ :rtype: iter[:class:`Housing`]
+ """
+ return self.browser.search_housing(query)
+
+ def fill_photo(self, photo, fields):
+ """
+ Fills the photo.
+ """
+ if 'data' in fields and photo.url and not photo.data:
+ photo.data = self.browser.open(photo.url).content
+ return photo
+
+ def fill_housing(self, housing, fields):
+ """
+ Fills the housing.
+ """
+ return self.get_housing(housing.id)
+
+ OBJECTS = {HousingPhoto: fill_photo, Housing: fill_housing}
diff --git a/modules/bienici/pages.py b/modules/bienici/pages.py
new file mode 100644
index 0000000000000000000000000000000000000000..9ca2e0ba34977b242146768f143d8860d17643ef
--- /dev/null
+++ b/modules/bienici/pages.py
@@ -0,0 +1,95 @@
+# -*- coding: utf-8 -*-
+
+# Copyright(C) 2018 Antoine BOSSY
+#
+# This file is part of woob.
+#
+# woob 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.
+#
+# woob 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 woob. If not, see .
+
+from __future__ import unicode_literals
+
+
+from woob.browser.pages import JsonPage
+from woob.browser.elements import ItemElement, DictElement, method
+from woob.browser.filters.json import Dict, ItemNotFound
+from woob.capabilities.base import NotAvailable
+from woob.browser.filters.standard import CleanText, Date, CleanDecimal
+from woob.capabilities.housing import City, Housing, HousingPhoto, ENERGY_CLASS
+
+
+class Cities(JsonPage):
+ @method
+ class get_city(DictElement):
+
+ class item(ItemElement):
+ klass = City
+
+ obj_id = Dict('zoneIds/0')
+ obj_name = CleanText(Dict('name'))
+
+
+class MyItemElement(ItemElement):
+ klass = Housing
+
+ def condition(self):
+ return not Dict('userRelativeData/isAdModifier')(self)
+
+ obj_id = Dict('id')
+ obj_title = Dict('title')
+ obj_area = Dict('surfaceArea')
+ obj_cost = Dict('price')
+
+ def obj_price_per_meter(self):
+ try:
+ return Dict('pricePerSquareMeter')(self)
+ except ItemNotFound:
+ return NotAvailable
+
+ obj_currency = 'EUR'
+ obj_date = Date(Dict('publicationDate'))
+ obj_location = CleanDecimal(Dict('postalCode'))
+ obj_text = Dict('description', '')
+
+ def obj_photos(self):
+ return [HousingPhoto(photo['url']) for photo in Dict('photos')(self)]
+
+ obj_rooms = Dict('roomsQuantity', 0)
+ obj_bedrooms = Dict('bedroomsQuantity', 0)
+
+ def obj_DPE(self):
+ try:
+ return ENERGY_CLASS[Dict('energyClassification')(self)]
+ except (KeyError, ItemNotFound):
+ return NotAvailable
+
+ def obj_GES(self):
+ try:
+ return ENERGY_CLASS[Dict('greenhouseGazClassification')(self)]
+ except (KeyError, ItemNotFound):
+ return NotAvailable
+
+
+class ResultsPage(JsonPage):
+ @method
+ class get_housings(DictElement):
+ item_xpath = 'realEstateAds'
+
+ class item(MyItemElement):
+ pass
+
+
+class HousingPage(JsonPage):
+ @method
+ class get_housing(MyItemElement):
+ pass