├── .dockerignore ├── doc ├── _static │ └── .gitkeep ├── img │ ├── home.png │ ├── details.png │ ├── home2.png │ └── followed.png ├── modules.rst ├── flatisfy.data_files.rst ├── flatisfy.web.routes.rst ├── index.rst ├── flatisfy.filters.rst ├── flatisfy.database.rst ├── flatisfy.models.rst ├── flatisfy.web.rst ├── 3.faq.md ├── 2.docker.md └── flatisfy.rst ├── flatisfy ├── models │ ├── __init__.py │ ├── public_transport.py │ └── postal_code.py ├── web │ ├── __init__.py │ ├── routes │ │ └── __init__.py │ ├── static │ │ ├── favicon.ico │ │ ├── img │ │ │ ├── favicon-114.png │ │ │ ├── favicon-120.png │ │ │ ├── favicon-144.png │ │ │ ├── favicon-150.png │ │ │ ├── favicon-152.png │ │ │ ├── favicon-16.png │ │ │ ├── favicon-160.png │ │ │ ├── favicon-180.png │ │ │ ├── favicon-192.png │ │ │ ├── favicon-310.png │ │ │ ├── favicon-32.png │ │ │ ├── favicon-57.png │ │ │ ├── favicon-60.png │ │ │ ├── favicon-64.png │ │ │ ├── favicon-70.png │ │ │ ├── favicon-72.png │ │ │ ├── favicon-76.png │ │ │ └── favicon-96.png │ │ └── index.html │ ├── js_src │ │ ├── store │ │ │ ├── index.js │ │ │ ├── mutations-types.js │ │ │ ├── actions.js │ │ │ ├── mutations.js │ │ │ └── getters.js │ │ ├── main.js │ │ ├── router │ │ │ └── index.js │ │ ├── tools │ │ │ └── index.js │ │ ├── views │ │ │ ├── details.vue │ │ │ ├── search.vue │ │ │ └── home.vue │ │ ├── i18n │ │ │ ├── index.js │ │ │ ├── en │ │ │ │ └── index.js │ │ │ └── fr │ │ │ │ └── index.js │ │ └── components │ │ │ ├── app.vue │ │ │ ├── notation.vue │ │ │ ├── flatstableline.vue │ │ │ ├── slider.vue │ │ │ └── flatsmap.vue │ ├── configplugin.py │ ├── dbplugin.py │ └── app.py ├── test_files │ ├── vertical.jpg │ ├── 124910113@seloger.jpg │ ├── 127028739@seloger.jpg │ ├── vertical-cropped.jpg │ ├── 127028739-2@seloger.jpg │ ├── 127028739-3@seloger.jpg │ ├── 13783671@explorimmo.jpg │ ├── 14428129@explorimmo.jpg │ ├── 14428129-2@explorimmo.jpg │ ├── 14428129-3@explorimmo.jpg │ ├── 127028739-watermark@seloger.jpg │ ├── 127028739@seloger.json │ ├── 14428129@explorimmo.json │ ├── 13783671@explorimmo.json │ ├── 127963747@seloger.json │ ├── 122509451@seloger.json │ ├── 124910113@seloger.json │ ├── 123314207@seloger.json │ ├── 123312807@seloger.json │ └── 128358415@seloger.json ├── __init__.py ├── database │ ├── base.py │ ├── types.py │ └── __init__.py ├── exceptions.py ├── constants.py ├── filters │ ├── images.py │ └── cache.py ├── data.py └── email.py ├── migrations ├── README ├── versions │ ├── 8155b83242eb_add_is_expired.py │ ├── 9e58c66f1ac1_add_flat_insee_column.py │ └── d21933db9ad8_add_flat_position_column.py ├── script.py.mako └── env.py ├── modules ├── pap │ ├── __init__.py │ ├── favicon.png │ ├── constants.py │ ├── module.py │ ├── browser.py │ └── test.py ├── foncia │ ├── favicon.png │ ├── constants.py │ ├── __init__.py │ ├── browser.py │ ├── module.py │ └── test.py ├── seloger │ ├── __init__.py │ ├── favicon.png │ ├── constants.py │ ├── module.py │ ├── browser.py │ └── test.py ├── leboncoin │ ├── favicon.png │ ├── __init__.py │ ├── module.py │ └── test.py ├── logicimmo │ ├── favicon.png │ ├── __init__.py │ ├── module.py │ ├── test.py │ └── browser.py └── explorimmo │ ├── __init__.py │ ├── module.py │ ├── browser.py │ └── test.py ├── hooks └── pre-commit ├── import.sh ├── .babelrc ├── .vscode ├── extensions.json └── settings.json ├── docker ├── fetch.sh ├── docker-compose.yml ├── run.sh ├── entrypoint.sh └── Dockerfile ├── .editorconfig ├── .gitignore ├── .eslintrc ├── requirements.txt ├── start.sh ├── .gitlab-ci.yml ├── wsgi.py ├── LICENSE.md ├── alembic.ini ├── webpack.config.js ├── package.json └── CONTRIBUTING.md /.dockerignore: -------------------------------------------------------------------------------- 1 | data 2 | -------------------------------------------------------------------------------- /doc/_static/.gitkeep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /flatisfy/models/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /flatisfy/web/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /flatisfy/web/routes/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /migrations/README: -------------------------------------------------------------------------------- 1 | Generic single-database configuration. -------------------------------------------------------------------------------- /doc/img/home.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Phyks/Flatisfy/HEAD/doc/img/home.png -------------------------------------------------------------------------------- /doc/img/details.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Phyks/Flatisfy/HEAD/doc/img/details.png -------------------------------------------------------------------------------- /doc/img/home2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Phyks/Flatisfy/HEAD/doc/img/home2.png -------------------------------------------------------------------------------- /doc/img/followed.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Phyks/Flatisfy/HEAD/doc/img/followed.png -------------------------------------------------------------------------------- /modules/pap/__init__.py: -------------------------------------------------------------------------------- 1 | from .module import PapModule 2 | 3 | __all__ = ['PapModule'] 4 | -------------------------------------------------------------------------------- /hooks/pre-commit: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | pylint --rcfile=.ci/pylintrc flatisfy 4 | npm run lint 5 | -------------------------------------------------------------------------------- /import.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh -ev 2 | python -m flatisfy import --config config.json --new-only -v "$@" 3 | -------------------------------------------------------------------------------- /modules/pap/favicon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Phyks/Flatisfy/HEAD/modules/pap/favicon.png -------------------------------------------------------------------------------- /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": ["es2015", "stage-0"], 3 | "plugins": ["transform-runtime"] 4 | } 5 | -------------------------------------------------------------------------------- /modules/foncia/favicon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Phyks/Flatisfy/HEAD/modules/foncia/favicon.png -------------------------------------------------------------------------------- /modules/seloger/__init__.py: -------------------------------------------------------------------------------- 1 | from .module import SeLogerModule 2 | 3 | __all__ = ['SeLogerModule'] 4 | -------------------------------------------------------------------------------- /doc/modules.rst: -------------------------------------------------------------------------------- 1 | Flatisfy 2 | ======== 3 | 4 | .. toctree:: 5 | :maxdepth: 4 6 | 7 | flatisfy 8 | -------------------------------------------------------------------------------- /modules/leboncoin/favicon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Phyks/Flatisfy/HEAD/modules/leboncoin/favicon.png -------------------------------------------------------------------------------- /modules/logicimmo/favicon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Phyks/Flatisfy/HEAD/modules/logicimmo/favicon.png -------------------------------------------------------------------------------- /modules/seloger/favicon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Phyks/Flatisfy/HEAD/modules/seloger/favicon.png -------------------------------------------------------------------------------- /flatisfy/web/static/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Phyks/Flatisfy/HEAD/flatisfy/web/static/favicon.ico -------------------------------------------------------------------------------- /flatisfy/test_files/vertical.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Phyks/Flatisfy/HEAD/flatisfy/test_files/vertical.jpg -------------------------------------------------------------------------------- /flatisfy/web/static/img/favicon-114.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Phyks/Flatisfy/HEAD/flatisfy/web/static/img/favicon-114.png -------------------------------------------------------------------------------- /flatisfy/web/static/img/favicon-120.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Phyks/Flatisfy/HEAD/flatisfy/web/static/img/favicon-120.png -------------------------------------------------------------------------------- /flatisfy/web/static/img/favicon-144.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Phyks/Flatisfy/HEAD/flatisfy/web/static/img/favicon-144.png -------------------------------------------------------------------------------- /flatisfy/web/static/img/favicon-150.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Phyks/Flatisfy/HEAD/flatisfy/web/static/img/favicon-150.png -------------------------------------------------------------------------------- /flatisfy/web/static/img/favicon-152.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Phyks/Flatisfy/HEAD/flatisfy/web/static/img/favicon-152.png -------------------------------------------------------------------------------- /flatisfy/web/static/img/favicon-16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Phyks/Flatisfy/HEAD/flatisfy/web/static/img/favicon-16.png -------------------------------------------------------------------------------- /flatisfy/web/static/img/favicon-160.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Phyks/Flatisfy/HEAD/flatisfy/web/static/img/favicon-160.png -------------------------------------------------------------------------------- /flatisfy/web/static/img/favicon-180.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Phyks/Flatisfy/HEAD/flatisfy/web/static/img/favicon-180.png -------------------------------------------------------------------------------- /flatisfy/web/static/img/favicon-192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Phyks/Flatisfy/HEAD/flatisfy/web/static/img/favicon-192.png -------------------------------------------------------------------------------- /flatisfy/web/static/img/favicon-310.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Phyks/Flatisfy/HEAD/flatisfy/web/static/img/favicon-310.png -------------------------------------------------------------------------------- /flatisfy/web/static/img/favicon-32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Phyks/Flatisfy/HEAD/flatisfy/web/static/img/favicon-32.png -------------------------------------------------------------------------------- /flatisfy/web/static/img/favicon-57.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Phyks/Flatisfy/HEAD/flatisfy/web/static/img/favicon-57.png -------------------------------------------------------------------------------- /flatisfy/web/static/img/favicon-60.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Phyks/Flatisfy/HEAD/flatisfy/web/static/img/favicon-60.png -------------------------------------------------------------------------------- /flatisfy/web/static/img/favicon-64.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Phyks/Flatisfy/HEAD/flatisfy/web/static/img/favicon-64.png -------------------------------------------------------------------------------- /flatisfy/web/static/img/favicon-70.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Phyks/Flatisfy/HEAD/flatisfy/web/static/img/favicon-70.png -------------------------------------------------------------------------------- /flatisfy/web/static/img/favicon-72.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Phyks/Flatisfy/HEAD/flatisfy/web/static/img/favicon-72.png -------------------------------------------------------------------------------- /flatisfy/web/static/img/favicon-76.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Phyks/Flatisfy/HEAD/flatisfy/web/static/img/favicon-76.png -------------------------------------------------------------------------------- /flatisfy/web/static/img/favicon-96.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Phyks/Flatisfy/HEAD/flatisfy/web/static/img/favicon-96.png -------------------------------------------------------------------------------- /flatisfy/test_files/124910113@seloger.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Phyks/Flatisfy/HEAD/flatisfy/test_files/124910113@seloger.jpg -------------------------------------------------------------------------------- /flatisfy/test_files/127028739@seloger.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Phyks/Flatisfy/HEAD/flatisfy/test_files/127028739@seloger.jpg -------------------------------------------------------------------------------- /flatisfy/test_files/vertical-cropped.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Phyks/Flatisfy/HEAD/flatisfy/test_files/vertical-cropped.jpg -------------------------------------------------------------------------------- /flatisfy/test_files/127028739-2@seloger.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Phyks/Flatisfy/HEAD/flatisfy/test_files/127028739-2@seloger.jpg -------------------------------------------------------------------------------- /flatisfy/test_files/127028739-3@seloger.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Phyks/Flatisfy/HEAD/flatisfy/test_files/127028739-3@seloger.jpg -------------------------------------------------------------------------------- /flatisfy/test_files/13783671@explorimmo.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Phyks/Flatisfy/HEAD/flatisfy/test_files/13783671@explorimmo.jpg -------------------------------------------------------------------------------- /flatisfy/test_files/14428129@explorimmo.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Phyks/Flatisfy/HEAD/flatisfy/test_files/14428129@explorimmo.jpg -------------------------------------------------------------------------------- /flatisfy/test_files/14428129-2@explorimmo.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Phyks/Flatisfy/HEAD/flatisfy/test_files/14428129-2@explorimmo.jpg -------------------------------------------------------------------------------- /flatisfy/test_files/14428129-3@explorimmo.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Phyks/Flatisfy/HEAD/flatisfy/test_files/14428129-3@explorimmo.jpg -------------------------------------------------------------------------------- /.vscode/extensions.json: -------------------------------------------------------------------------------- 1 | { 2 | "recommendations": [ 3 | "mtxr.sqltools", 4 | "mtxr.sqltools-driver-sqlite" 5 | ] 6 | } 7 | -------------------------------------------------------------------------------- /flatisfy/test_files/127028739-watermark@seloger.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Phyks/Flatisfy/HEAD/flatisfy/test_files/127028739-watermark@seloger.jpg -------------------------------------------------------------------------------- /flatisfy/__init__.py: -------------------------------------------------------------------------------- 1 | # coding: utf-8 2 | """ 3 | ``Flatisfy`` is a tool to help you find a new housing based on some criteria. 4 | """ 5 | __version__ = "0.1" 6 | -------------------------------------------------------------------------------- /docker/fetch.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | set -e 3 | 4 | echo "Fetching new housing posts..." 5 | cd /home/user/app 6 | python -m flatisfy import -v --config /flatisfy/config.json 7 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | indent_style = space 5 | indent_size = 4 6 | end_of_line = lf 7 | insert_final_newline = true 8 | 9 | [*.py] 10 | max_line_length=120 11 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | build 2 | *.pyc 3 | *.swp 4 | *.swo 5 | *.db 6 | config/ 7 | node_modules 8 | flatisfy/web/static/assets 9 | data/ 10 | doc/_build 11 | data_rework/ 12 | .env 13 | .htpasswd 14 | -------------------------------------------------------------------------------- /doc/flatisfy.data_files.rst: -------------------------------------------------------------------------------- 1 | flatisfy.data_files package 2 | =========================== 3 | 4 | Module contents 5 | --------------- 6 | 7 | .. automodule:: flatisfy.data_files 8 | :members: 9 | :undoc-members: 10 | :show-inheritance: 11 | -------------------------------------------------------------------------------- /docker/docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: "3" 2 | services: 3 | app: 4 | build: . 5 | # image: phyks/flatisfy 6 | environment: 7 | - LOCAL_USER_ID=1000 8 | volumes: 9 | - ./data:/flatisfy 10 | ports: 11 | - "8080:8080" 12 | working_dir: /home/user/app 13 | -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | extends: ["vue", /* your other extends */], 3 | plugins: ["vue"], 4 | "env": { 5 | "browser": true 6 | }, 7 | "parserOptions": { 8 | "ecmaVersion": 8 9 | }, 10 | rules: { 11 | 'indent': ["error", 4, { 'SwitchCase': 1 }], 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | alembic 2 | appdirs 3 | arrow 4 | bottle 5 | bottle-sqlalchemy 6 | canister 7 | future 8 | imagehash 9 | mapbox 10 | pillow 11 | ratelimit 12 | requests 13 | requests_mock 14 | sqlalchemy 15 | titlecase 16 | unidecode 17 | vobject 18 | whoosh 19 | git+https://gitlab.com/woob/woob/ 20 | money 21 | -------------------------------------------------------------------------------- /flatisfy/database/base.py: -------------------------------------------------------------------------------- 1 | # coding: utf-8 2 | """ 3 | This module contains the definition of the declarative SQLAlchemy base. 4 | """ 5 | from __future__ import absolute_import, print_function, unicode_literals 6 | 7 | from sqlalchemy.ext.declarative import declarative_base 8 | 9 | 10 | BASE = declarative_base() 11 | -------------------------------------------------------------------------------- /start.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh -ev 2 | 3 | function clean_up { 4 | 5 | # Perform program exit housekeeping 6 | kill $SERVE_PID $YARN_PID 7 | exit 8 | } 9 | 10 | python -m flatisfy serve --config config.json -v & 11 | SERVE_PID=$! 12 | 13 | yarn watch:dev & 14 | YARN_PID=$! 15 | 16 | trap clean_up SIGHUP SIGINT SIGTERM 17 | 18 | wait $SERVE_PID $YARN_PID 19 | -------------------------------------------------------------------------------- /flatisfy/exceptions.py: -------------------------------------------------------------------------------- 1 | # coding: utf-8 2 | """ 3 | This module contains all the exceptions definitions for the Flatisfy-specific 4 | exceptions. 5 | """ 6 | from __future__ import absolute_import, print_function, unicode_literals 7 | 8 | 9 | class DataBuildError(Exception): 10 | """ 11 | Error occurring on building a data file. 12 | """ 13 | 14 | pass 15 | -------------------------------------------------------------------------------- /docker/run.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | set -e 3 | 4 | echo "Building data for Flatisfy..." 5 | cd /home/user/app 6 | python -m flatisfy build-data -v --config /flatisfy/config.json 7 | 8 | echo "Fetching new housing posts..." 9 | cd /home/user/app 10 | python -m flatisfy import -v --config /flatisfy/config.json 11 | 12 | echo "Starting web UI..." 13 | python -m flatisfy serve -v --config /flatisfy/config.json 14 | -------------------------------------------------------------------------------- /flatisfy/web/js_src/store/index.js: -------------------------------------------------------------------------------- 1 | import Vue from 'vue' 2 | import Vuex from 'vuex' 3 | 4 | import actions from './actions' 5 | import getters from './getters' 6 | import { state, mutations } from './mutations' 7 | // import products from './modules/products' 8 | 9 | Vue.use(Vuex) 10 | 11 | export default new Vuex.Store({ 12 | state, 13 | actions, 14 | getters, 15 | mutations 16 | }) 17 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "cSpell.words": [ 3 | "woob", 4 | "flatisfy" 5 | ], 6 | "sqltools.useNodeRuntime": true, 7 | "sqltools.connections": [ 8 | { 9 | "previewLimit": 50, 10 | "driver": "SQLite", 11 | "name": "flatisfy", 12 | "database": "${workspaceFolder:flatisfy}/data/flatisfy.db" 13 | } 14 | ] 15 | } 16 | -------------------------------------------------------------------------------- /flatisfy/web/js_src/main.js: -------------------------------------------------------------------------------- 1 | import Vue from 'vue' 2 | 3 | import i18n from './i18n' 4 | import router from './router' 5 | import store from './store' 6 | import { costFilter } from './tools' 7 | 8 | import App from './components/app.vue' 9 | 10 | Vue.filter('cost', costFilter) 11 | 12 | new Vue({ 13 | i18n, 14 | router, 15 | store, 16 | render: createEle => createEle(App) 17 | }).$mount('#app') 18 | -------------------------------------------------------------------------------- /modules/seloger/constants.py: -------------------------------------------------------------------------------- 1 | from woob.capabilities.housing import POSTS_TYPES, HOUSE_TYPES 2 | 3 | TYPES = {POSTS_TYPES.RENT: 1, 4 | POSTS_TYPES.SALE: 2, 5 | POSTS_TYPES.FURNISHED_RENT: 1, 6 | POSTS_TYPES.VIAGER: 5} 7 | 8 | RET = {HOUSE_TYPES.HOUSE: '2', 9 | HOUSE_TYPES.APART: '1', 10 | HOUSE_TYPES.LAND: '4', 11 | HOUSE_TYPES.PARKING: '3', 12 | HOUSE_TYPES.OTHER: '10'} 13 | -------------------------------------------------------------------------------- /doc/flatisfy.web.routes.rst: -------------------------------------------------------------------------------- 1 | flatisfy.web.routes package 2 | =========================== 3 | 4 | Submodules 5 | ---------- 6 | 7 | flatisfy.web.routes.api module 8 | ------------------------------ 9 | 10 | .. automodule:: flatisfy.web.routes.api 11 | :members: 12 | :undoc-members: 13 | :show-inheritance: 14 | 15 | 16 | Module contents 17 | --------------- 18 | 19 | .. automodule:: flatisfy.web.routes 20 | :members: 21 | :undoc-members: 22 | :show-inheritance: 23 | -------------------------------------------------------------------------------- /modules/pap/constants.py: -------------------------------------------------------------------------------- 1 | from woob.capabilities.housing import POSTS_TYPES, HOUSE_TYPES 2 | 3 | TYPES = {POSTS_TYPES.RENT: 'location', 4 | POSTS_TYPES.FURNISHED_RENT: 'location', 5 | POSTS_TYPES.SALE: 'vente', 6 | POSTS_TYPES.VIAGER: 'vente'} 7 | 8 | RET = {HOUSE_TYPES.HOUSE: 'maison', 9 | HOUSE_TYPES.APART: 'appartement', 10 | HOUSE_TYPES.LAND: 'terrain', 11 | HOUSE_TYPES.PARKING: 'garage-parking', 12 | HOUSE_TYPES.OTHER: 'divers'} 13 | -------------------------------------------------------------------------------- /docker/entrypoint.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | set -e 3 | 4 | # Add local user 5 | # Either use the LOCAL_USER_ID if passed in at runtime or 6 | # fallback 7 | USER_ID=${LOCAL_USER_ID:-9001} 8 | 9 | echo "[ENTRYPOINT] Starting with UID : $USER_ID" 10 | usermod -u $USER_ID -o user 11 | export HOME=/home/user 12 | 13 | echo "[ENTRYPOINT] Setting fake values for git config..." 14 | git config --global user.email flatisfy@example.com 15 | git config --global user.name "Flatisfy Root" 16 | 17 | exec su user -c "$@" 18 | -------------------------------------------------------------------------------- /flatisfy/web/js_src/store/mutations-types.js: -------------------------------------------------------------------------------- 1 | export const REPLACE_FLATS = 'REPLACE_FLATS' 2 | export const MERGE_FLATS = 'MERGE_FLATS' 3 | export const UPDATE_FLAT_STATUS = 'UPDATE_FLAT_STATUS' 4 | export const UPDATE_FLAT_NOTES = 'UPDATE_FLAT_NOTES' 5 | export const UPDATE_FLAT_NOTATION = 'UPDATE_FLAT_NOTATION' 6 | export const UPDATE_FLAT_VISIT_DATE = 'UPDATE_FLAT_VISIT_DATE' 7 | export const RECEIVE_TIME_TO_PLACES = 'RECEIVE_TIME_TO_PLACES' 8 | export const RECEIVE_METADATA = 'RECEIVE_METADATA' 9 | export const IS_LOADING = 'IS_LOADING' 10 | -------------------------------------------------------------------------------- /migrations/versions/8155b83242eb_add_is_expired.py: -------------------------------------------------------------------------------- 1 | """Add is_expired 2 | 3 | Revision ID: 8155b83242eb 4 | Revises: 5 | Create Date: 2018-10-16 22:51:25.442678 6 | 7 | """ 8 | from alembic import op 9 | import sqlalchemy as sa 10 | 11 | 12 | # revision identifiers, used by Alembic. 13 | revision = "8155b83242eb" 14 | down_revision = None 15 | branch_labels = None 16 | depends_on = None 17 | 18 | 19 | def upgrade(): 20 | op.add_column("flats", sa.Column("is_expired", sa.Boolean(), default=False)) 21 | 22 | 23 | def downgrade(): 24 | op.drop_column("flats", "is_expired") 25 | -------------------------------------------------------------------------------- /migrations/script.py.mako: -------------------------------------------------------------------------------- 1 | """${message} 2 | 3 | Revision ID: ${up_revision} 4 | Revises: ${down_revision | comma,n} 5 | Create Date: ${create_date} 6 | 7 | """ 8 | from alembic import op 9 | import sqlalchemy as sa 10 | ${imports if imports else ""} 11 | 12 | # revision identifiers, used by Alembic. 13 | revision = ${repr(up_revision)} 14 | down_revision = ${repr(down_revision)} 15 | branch_labels = ${repr(branch_labels)} 16 | depends_on = ${repr(depends_on)} 17 | 18 | 19 | def upgrade(): 20 | ${upgrades if upgrades else "pass"} 21 | 22 | 23 | def downgrade(): 24 | ${downgrades if downgrades else "pass"} 25 | -------------------------------------------------------------------------------- /migrations/versions/9e58c66f1ac1_add_flat_insee_column.py: -------------------------------------------------------------------------------- 1 | """Add flat INSEE column 2 | 3 | Revision ID: 9e58c66f1ac1 4 | Revises: d21933db9ad8 5 | Create Date: 2021-02-08 16:31:18.961186 6 | 7 | """ 8 | from alembic import op 9 | import sqlalchemy as sa 10 | 11 | 12 | # revision identifiers, used by Alembic. 13 | revision = "9e58c66f1ac1" 14 | down_revision = "d21933db9ad8" 15 | branch_labels = None 16 | depends_on = None 17 | 18 | 19 | def upgrade(): 20 | op.add_column("postal_codes", sa.Column("insee_code", sa.String())) 21 | 22 | 23 | def downgrade(): 24 | op.drop_column("postal_codes", "insee_code") 25 | -------------------------------------------------------------------------------- /doc/index.rst: -------------------------------------------------------------------------------- 1 | .. Flatisfy documentation master file, created by 2 | sphinx-quickstart on Tue Dec 5 14:21:46 2017. 3 | You can adapt this file completely to your liking, but it should at least 4 | contain the root `toctree` directive. 5 | 6 | Welcome to Flatisfy's documentation! 7 | ==================================== 8 | 9 | .. automodule:: flatisfy 10 | 11 | .. toctree:: 12 | 13 | 0.getting_started.md 14 | 1.production.md 15 | 2.docker.md 16 | 3.faq.md 17 | modules.rst 18 | 19 | 20 | Indices and tables 21 | ================== 22 | 23 | * :ref:`genindex` 24 | * :ref:`modindex` 25 | * :ref:`search` 26 | 27 | -------------------------------------------------------------------------------- /.gitlab-ci.yml: -------------------------------------------------------------------------------- 1 | before_script: 2 | - "pip install -r requirements.txt" 3 | - "pip install pylint" 4 | - "curl -sL https://deb.nodesource.com/setup_10.x | bash -" 5 | - "apt-get install -y nodejs jq" 6 | - "npm install" 7 | 8 | lint: 9 | image: "python:3" 10 | stage: "test" 11 | script: 12 | - "hooks/pre-commit" 13 | 14 | test: 15 | image: "python:3" 16 | stage: "test" 17 | script: 18 | - python -m flatisfy init-config | jq '.constraints.default.house_types = ["APART"] | .constraints.default.type = "RENT" | .constraints.default.postal_codes = ["75014"]' > /tmp/config.json 19 | - python -m flatisfy test --config /tmp/config.json 20 | -------------------------------------------------------------------------------- /flatisfy/web/js_src/router/index.js: -------------------------------------------------------------------------------- 1 | import Vue from 'vue' 2 | import VueRouter from 'vue-router' 3 | 4 | import Home from '../views/home.vue' 5 | import Status from '../views/status.vue' 6 | import Details from '../views/details.vue' 7 | import Search from '../views/search.vue' 8 | 9 | Vue.use(VueRouter) 10 | 11 | export default new VueRouter({ 12 | routes: [ 13 | { path: '/', component: Home, name: 'home' }, 14 | { path: '/new', redirect: '/' }, 15 | { path: '/status/:status', component: Status, name: 'status' }, 16 | { path: '/flat/:id', component: Details, name: 'details' }, 17 | { path: '/search', component: Search, name: 'search' } 18 | ] 19 | }) 20 | -------------------------------------------------------------------------------- /flatisfy/constants.py: -------------------------------------------------------------------------------- 1 | # coding: utf-8 2 | """ 3 | Constants used across the app. 4 | """ 5 | from __future__ import absolute_import, print_function, unicode_literals 6 | 7 | from enum import Enum 8 | 9 | # Some backends give more infos than others. Here is the precedence we want to 10 | # use. First is most important one, last is the one that will always be 11 | # considered as less trustable if two backends have similar info about a 12 | # housing. 13 | BACKENDS_BY_PRECEDENCE = [ 14 | "foncia", 15 | "seloger", 16 | "pap", 17 | "leboncoin", 18 | "explorimmo", 19 | "logicimmo", 20 | ] 21 | 22 | 23 | class TimeToModes(Enum): 24 | PUBLIC_TRANSPORT = -1 25 | WALK = 1 26 | BIKE = 2 27 | CAR = 3 28 | -------------------------------------------------------------------------------- /wsgi.py: -------------------------------------------------------------------------------- 1 | # coding: utf-8 2 | """ 3 | Expose a WSGI-compatible application to serve with a webserver. 4 | """ 5 | from __future__ import absolute_import, print_function, unicode_literals 6 | 7 | import logging 8 | import os 9 | import sys 10 | 11 | import flatisfy.config 12 | from flatisfy.web import app as web_app 13 | 14 | 15 | class Args: 16 | config = os.path.join(os.path.dirname(os.path.realpath(__file__)), "config/config.json") 17 | 18 | 19 | LOGGER = logging.getLogger("flatisfy") 20 | 21 | 22 | CONFIG = flatisfy.config.load_config(Args()) 23 | if CONFIG is None: 24 | LOGGER.error("Invalid configuration. Exiting. Run init-config before if this is the first time you run Flatisfy.") 25 | sys.exit(1) 26 | 27 | 28 | application = app = web_app.get_app(CONFIG) 29 | -------------------------------------------------------------------------------- /doc/flatisfy.filters.rst: -------------------------------------------------------------------------------- 1 | flatisfy.filters package 2 | ======================== 3 | 4 | Submodules 5 | ---------- 6 | 7 | flatisfy.filters.cache module 8 | ----------------------------- 9 | 10 | .. automodule:: flatisfy.filters.cache 11 | :members: 12 | :undoc-members: 13 | :show-inheritance: 14 | 15 | flatisfy.filters.duplicates module 16 | ---------------------------------- 17 | 18 | .. automodule:: flatisfy.filters.duplicates 19 | :members: 20 | :undoc-members: 21 | :show-inheritance: 22 | 23 | flatisfy.filters.metadata module 24 | -------------------------------- 25 | 26 | .. automodule:: flatisfy.filters.metadata 27 | :members: 28 | :undoc-members: 29 | :show-inheritance: 30 | 31 | 32 | Module contents 33 | --------------- 34 | 35 | .. automodule:: flatisfy.filters 36 | :members: 37 | :undoc-members: 38 | :show-inheritance: 39 | -------------------------------------------------------------------------------- /doc/flatisfy.database.rst: -------------------------------------------------------------------------------- 1 | flatisfy.database package 2 | ========================= 3 | 4 | Submodules 5 | ---------- 6 | 7 | flatisfy.database.base module 8 | ----------------------------- 9 | 10 | .. automodule:: flatisfy.database.base 11 | :members: 12 | :undoc-members: 13 | :show-inheritance: 14 | 15 | flatisfy.database.types module 16 | ------------------------------ 17 | 18 | .. automodule:: flatisfy.database.types 19 | :members: 20 | :undoc-members: 21 | :show-inheritance: 22 | 23 | flatisfy.database.whooshalchemy module 24 | -------------------------------------- 25 | 26 | .. automodule:: flatisfy.database.whooshalchemy 27 | :members: 28 | :undoc-members: 29 | :show-inheritance: 30 | 31 | 32 | Module contents 33 | --------------- 34 | 35 | .. automodule:: flatisfy.database 36 | :members: 37 | :undoc-members: 38 | :show-inheritance: 39 | -------------------------------------------------------------------------------- /doc/flatisfy.models.rst: -------------------------------------------------------------------------------- 1 | flatisfy.models package 2 | ======================= 3 | 4 | Submodules 5 | ---------- 6 | 7 | flatisfy.models.flat module 8 | --------------------------- 9 | 10 | .. automodule:: flatisfy.models.flat 11 | :members: 12 | :undoc-members: 13 | :show-inheritance: 14 | 15 | flatisfy.models.postal_code module 16 | ---------------------------------- 17 | 18 | .. automodule:: flatisfy.models.postal_code 19 | :members: 20 | :undoc-members: 21 | :show-inheritance: 22 | 23 | flatisfy.models.public_transport module 24 | --------------------------------------- 25 | 26 | .. automodule:: flatisfy.models.public_transport 27 | :members: 28 | :undoc-members: 29 | :show-inheritance: 30 | 31 | 32 | Module contents 33 | --------------- 34 | 35 | .. automodule:: flatisfy.models 36 | :members: 37 | :undoc-members: 38 | :show-inheritance: 39 | -------------------------------------------------------------------------------- /doc/flatisfy.web.rst: -------------------------------------------------------------------------------- 1 | flatisfy.web package 2 | ==================== 3 | 4 | Subpackages 5 | ----------- 6 | 7 | .. toctree:: 8 | 9 | flatisfy.web.routes 10 | 11 | Submodules 12 | ---------- 13 | 14 | flatisfy.web.app module 15 | ----------------------- 16 | 17 | .. automodule:: flatisfy.web.app 18 | :members: 19 | :undoc-members: 20 | :show-inheritance: 21 | 22 | flatisfy.web.configplugin module 23 | -------------------------------- 24 | 25 | .. automodule:: flatisfy.web.configplugin 26 | :members: 27 | :undoc-members: 28 | :show-inheritance: 29 | 30 | flatisfy.web.dbplugin module 31 | ---------------------------- 32 | 33 | .. automodule:: flatisfy.web.dbplugin 34 | :members: 35 | :undoc-members: 36 | :show-inheritance: 37 | 38 | 39 | Module contents 40 | --------------- 41 | 42 | .. automodule:: flatisfy.web 43 | :members: 44 | :undoc-members: 45 | :show-inheritance: 46 | -------------------------------------------------------------------------------- /modules/foncia/constants.py: -------------------------------------------------------------------------------- 1 | from woob.capabilities.housing import POSTS_TYPES, HOUSE_TYPES 2 | 3 | QUERY_TYPES = { 4 | POSTS_TYPES.RENT: 'location', 5 | POSTS_TYPES.SALE: 'achat', 6 | POSTS_TYPES.FURNISHED_RENT: 'location' 7 | } 8 | 9 | QUERY_HOUSE_TYPES = { 10 | HOUSE_TYPES.APART: ['appartement', 'appartement-meuble'], 11 | HOUSE_TYPES.HOUSE: ['maison'], 12 | HOUSE_TYPES.PARKING: ['parking'], 13 | HOUSE_TYPES.LAND: ['terrain'], 14 | HOUSE_TYPES.OTHER: ['chambre', 'programme-neuf', 15 | 'local-commercial', 'immeuble'] 16 | } 17 | 18 | AVAILABLE_TYPES = { 19 | POSTS_TYPES.RENT: ['appartement', 'maison', 'parking', 'chambre', 20 | 'local-commercial'], 21 | POSTS_TYPES.SALE: ['appartement', 'maison', 'parking', 'local-commercial', 22 | 'terrain', 'immeuble', 'programme-neuf'], 23 | POSTS_TYPES.FURNISHED_RENT: ['appartement-meuble'] 24 | } 25 | -------------------------------------------------------------------------------- /modules/leboncoin/__init__.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | # Copyright(C) 2014 Bezleputh 4 | # 5 | # This file is part of a woob module. 6 | # 7 | # This woob module is free software: you can redistribute it and/or modify 8 | # it under the terms of the GNU Affero General Public License as published by 9 | # the Free Software Foundation, either version 3 of the License, or 10 | # (at your option) any later version. 11 | # 12 | # This woob module is distributed in the hope that it will be useful, 13 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 14 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 15 | # GNU Affero General Public License for more details. 16 | # 17 | # You should have received a copy of the GNU Affero General Public License 18 | # along with this woob module. If not, see . 19 | 20 | 21 | from .module import LeboncoinModule 22 | 23 | 24 | __all__ = ['LeboncoinModule'] 25 | -------------------------------------------------------------------------------- /modules/logicimmo/__init__.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | # Copyright(C) 2014 Bezleputh 4 | # 5 | # This file is part of a woob module. 6 | # 7 | # This woob module is free software: you can redistribute it and/or modify 8 | # it under the terms of the GNU Affero General Public License as published by 9 | # the Free Software Foundation, either version 3 of the License, or 10 | # (at your option) any later version. 11 | # 12 | # This woob module is distributed in the hope that it will be useful, 13 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 14 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 15 | # GNU Affero General Public License for more details. 16 | # 17 | # You should have received a copy of the GNU Affero General Public License 18 | # along with this woob module. If not, see . 19 | 20 | 21 | from .module import LogicimmoModule 22 | 23 | 24 | __all__ = ['LogicimmoModule'] 25 | -------------------------------------------------------------------------------- /modules/explorimmo/__init__.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | # Copyright(C) 2014 Bezleputh 4 | # 5 | # This file is part of a woob module. 6 | # 7 | # This woob module is free software: you can redistribute it and/or modify 8 | # it under the terms of the GNU Affero General Public License as published by 9 | # the Free Software Foundation, either version 3 of the License, or 10 | # (at your option) any later version. 11 | # 12 | # This woob module is distributed in the hope that it will be useful, 13 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 14 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 15 | # GNU Affero General Public License for more details. 16 | # 17 | # You should have received a copy of the GNU Affero General Public License 18 | # along with this woob module. If not, see . 19 | 20 | 21 | from .module import ExplorimmoModule 22 | 23 | 24 | __all__ = ['ExplorimmoModule'] 25 | -------------------------------------------------------------------------------- /modules/foncia/__init__.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | # Copyright(C) 2017 Phyks (Lucas Verney) 4 | # 5 | # This file is part of a woob module. 6 | # 7 | # This woob module is free software: you can redistribute it and/or modify 8 | # it under the terms of the GNU Affero General Public License as published by 9 | # the Free Software Foundation, either version 3 of the License, or 10 | # (at your option) any later version. 11 | # 12 | # This woob module is distributed in the hope that it will be useful, 13 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 14 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 15 | # GNU Affero General Public License for more details. 16 | # 17 | # You should have received a copy of the GNU Affero General Public License 18 | # along with this woob module. If not, see . 19 | 20 | from __future__ import unicode_literals 21 | 22 | 23 | from .module import FonciaModule 24 | 25 | 26 | __all__ = ['FonciaModule'] 27 | -------------------------------------------------------------------------------- /flatisfy/models/public_transport.py: -------------------------------------------------------------------------------- 1 | # coding: utf-8 2 | """ 3 | This modules defines an SQLAlchemy ORM model for public transport opendata. 4 | """ 5 | # pylint: disable=locally-disabled,invalid-name,too-few-public-methods 6 | from __future__ import absolute_import, print_function, unicode_literals 7 | 8 | import logging 9 | 10 | from sqlalchemy import Column, Float, Integer, String 11 | 12 | from flatisfy.database.base import BASE 13 | 14 | 15 | LOGGER = logging.getLogger(__name__) 16 | 17 | 18 | class PublicTransport(BASE): 19 | """ 20 | SQLAlchemy ORM model to store public transport opendata. 21 | """ 22 | 23 | __tablename__ = "public_transports" 24 | 25 | id = Column(Integer, primary_key=True) 26 | # Area is an identifier to prevent loading unnecessary stops. For now it is 27 | # following ISO 3166-2. 28 | area = Column(String, index=True) 29 | name = Column(String) 30 | lat = Column(Float) 31 | lng = Column(Float) 32 | 33 | def __repr__(self): 34 | return "" % self.id 35 | -------------------------------------------------------------------------------- /flatisfy/filters/images.py: -------------------------------------------------------------------------------- 1 | # coding: utf-8 2 | """ 3 | Filtering functions to handle images. 4 | 5 | This includes functions to download images. 6 | """ 7 | from __future__ import absolute_import, print_function, unicode_literals 8 | 9 | import logging 10 | import os 11 | 12 | from flatisfy.filters.cache import ImageCache 13 | 14 | 15 | LOGGER = logging.getLogger(__name__) 16 | 17 | 18 | def download_images(flats_list, config): 19 | """ 20 | Download images for all flats in the list, to serve them locally. 21 | 22 | :param flats_list: A list of flats dicts. 23 | :param config: A config dict. 24 | """ 25 | photo_cache = ImageCache(storage_dir=os.path.join(config["data_directory"], "images")) 26 | for flat in flats_list: 27 | for photo in flat["photos"]: 28 | # Download photo 29 | image = photo_cache.get(photo["url"]) 30 | # And store the local image 31 | # Only add it if fetching was successful 32 | if image: 33 | photo["local"] = photo_cache.compute_filename(photo["url"]) 34 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2017 Phyks (Lucas Verney) 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /docker/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM python:3 2 | MAINTAINER Phyks 3 | 4 | # Setup layout. 5 | RUN useradd -d /home/user -m -s /bin/bash -U user 6 | 7 | # Install OS dependencies. 8 | RUN apt-get update && \ 9 | apt-get install -y git libffi-dev \ 10 | libxml2-dev libxslt-dev libyaml-dev libtiff-dev libjpeg-dev zlib1g-dev \ 11 | libfreetype6-dev libwebp-dev build-essential gcc g++ wget; 12 | 13 | # Install latest pip and python dependencies. 14 | RUN pip install -U setuptools && \ 15 | pip install html2text simplejson beautifulsoup4 16 | 17 | # Install node.js. 18 | RUN curl -sL https://deb.nodesource.com/setup_10.x | bash - \ 19 | && apt-get install -y nodejs 20 | 21 | RUN mkdir -p /flatisfy/data 22 | VOLUME /flatisfy 23 | 24 | COPY ./*.sh /home/user/ 25 | 26 | # Install Flatisfy, set up directories and permissions. 27 | RUN cd /home/user \ 28 | && git clone https://framagit.org/phyks/Flatisfy.git/ ./app \ 29 | && cd ./app \ 30 | && pip install -r requirements.txt \ 31 | && npm install \ 32 | && npm run build:dev \ 33 | && mkdir -p /home/user/.local/share/flatisfy \ 34 | && chown user:user -R /home/user \ 35 | && chmod +x /home/user/*.sh 36 | 37 | # Run server. 38 | EXPOSE 8080 39 | ENTRYPOINT ["/home/user/entrypoint.sh"] 40 | CMD ["/home/user/run.sh"] 41 | -------------------------------------------------------------------------------- /flatisfy/web/js_src/tools/index.js: -------------------------------------------------------------------------------- 1 | export function findFlatGPS (flat) { 2 | let gps 3 | 4 | if (flat.flatisfy_position) { 5 | gps = [flat.flatisfy_position.lat, flat.flatisfy_position.lng] 6 | } else if (flat.flatisfy_stations && flat.flatisfy_stations.length > 0) { 7 | // Try to push a marker based on stations 8 | gps = [0.0, 0.0] 9 | flat.flatisfy_stations.forEach(station => { 10 | gps = [gps[0] + station.gps[0], gps[1] + station.gps[1]] 11 | }) 12 | gps = [gps[0] / flat.flatisfy_stations.length, gps[1] / flat.flatisfy_stations.length] 13 | } else { 14 | // Else, push a marker based on postal code 15 | gps = flat.flatisfy_postal_code.gps 16 | } 17 | 18 | return gps 19 | } 20 | 21 | export function capitalize (string) { 22 | return string.charAt(0).toUpperCase() + string.slice(1) 23 | } 24 | 25 | export function range (n) { 26 | return [...Array(n).keys()] 27 | } 28 | 29 | export function costFilter (value, currency) { 30 | if (!value) { 31 | return 'N/A' 32 | } 33 | 34 | if (currency === 'EUR') { 35 | currency = ' €' 36 | } 37 | 38 | var valueStr = value.toString() 39 | valueStr = ' '.repeat((3 + valueStr.length) % 3) + valueStr 40 | 41 | return valueStr.match(/.{1,3}/g).join('.') + currency 42 | } 43 | -------------------------------------------------------------------------------- /flatisfy/web/js_src/views/details.vue: -------------------------------------------------------------------------------- 1 | 9 | 10 | 55 | 56 | -------------------------------------------------------------------------------- /flatisfy/test_files/127028739@seloger.json: -------------------------------------------------------------------------------- 1 | { 2 | "id": "127028739@seloger", 3 | "url": "http://www.seloger.com/annonces/achat/appartement/rennes-35/centre/127028739.htm?p=", 4 | "title": "Appartement 3 pièces 67m² - Rennes", 5 | "area": 67, 6 | "cost": 155700, 7 | "price_per_meter": 2323.8805970149256, 8 | "currency": "€", 9 | "utilities": "", 10 | "date": "2018-01-12T02:10:00", 11 | "location": "17 PLACE MARECHAL JUIN Rennes (35000)", 12 | "station": "", 13 | "text": "Exclusivité Nexity Dans un immeuble de standing, en étage élevé avec ascenseur, Appartement Type 3 de 67 m² exposé Sud / Ouest, un séjour avec balcon et double exposition vue dégagée. Deux chambres dont une avec balcon, salle de douches, WC séparé, cave et parking en sous-sol.", 14 | "phone": null, 15 | "photos": [ 16 | { 17 | "id": "0an3yarge9y446j653dewxu0jwy33pmwar47k2qym.jpg", 18 | "url": "flatisfy/test_files/127028739@seloger.jpg", 19 | "data": null 20 | } 21 | ], 22 | "rooms": 3, 23 | "bedrooms": 2, 24 | "details": { 25 | "Vue": "", 26 | "Pièces": "3", 27 | "Etage": "15", 28 | "Reference": "MT0136601", 29 | "Chambres": "2", 30 | "Cave": "", 31 | "Balcon": "5 m²", 32 | "Surface": "67 m²", 33 | "Ascenseur": "", 34 | "Etages": "30", 35 | "Parking": "1", 36 | "Salle de Séjour": "" 37 | }, 38 | "flatisfy": { 39 | "postal_code": "35000" 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /flatisfy/models/postal_code.py: -------------------------------------------------------------------------------- 1 | # coding: utf-8 2 | """ 3 | This modules defines an SQLAlchemy ORM model for a postal code opendata. 4 | """ 5 | # pylint: disable=locally-disabled,invalid-name,too-few-public-methods 6 | from __future__ import absolute_import, print_function, unicode_literals 7 | 8 | import logging 9 | 10 | from sqlalchemy import Column, Float, Integer, String, UniqueConstraint 11 | 12 | from flatisfy.database.base import BASE 13 | 14 | 15 | LOGGER = logging.getLogger(__name__) 16 | 17 | 18 | class PostalCode(BASE): 19 | """ 20 | SQLAlchemy ORM model to store a postal code opendata. 21 | """ 22 | 23 | __tablename__ = "postal_codes" 24 | 25 | id = Column(Integer, primary_key=True) 26 | # Area is an identifier to prevent loading unnecessary stops. For now it is 27 | # following ISO 3166-2. 28 | area = Column(String, index=True) 29 | postal_code = Column(String, index=True) 30 | insee_code = Column(String, index=True) 31 | name = Column(String, index=True) 32 | lat = Column(Float) 33 | lng = Column(Float) 34 | UniqueConstraint("postal_code", "name") 35 | 36 | def __repr__(self): 37 | return "" % self.id 38 | 39 | def json_api_repr(self): 40 | """ 41 | Return a dict representation of this postal code object that is JSON 42 | serializable. 43 | """ 44 | return {k: v for k, v in self.__dict__.items() if not k.startswith("_")} 45 | -------------------------------------------------------------------------------- /flatisfy/test_files/14428129@explorimmo.json: -------------------------------------------------------------------------------- 1 | { 2 | "id": "14428129@explorimmo", 3 | "url": "http://www.explorimmo.com/annonce-14428129.html", 4 | "title": "Vente appartement 3 pièces 67 m2", 5 | "area": 67, 6 | "cost": 155700, 7 | "price_per_meter": 2323.8805970149256, 8 | "currency": "EUR", 9 | "utilities": "H.C.", 10 | "date": "2017-12-05T07:40:00", 11 | "location": "17 PLACE MARECHAL JUIN Rennes 35000", 12 | "station": null, 13 | "text": "Exclusivité Nexity Dans un immeuble de standing, en étage élevé avec\nascenseur, Appartement Type 3 de 67 m² exposé Sud / Ouest, un séjour avec\nbalcon et double exposition vue dégagée. Deux chambres dont une avec balcon,\nsalle de douches, WC séparé, cave et parking en sous-sol.\n\n", 14 | "phone": null, 15 | "photos": [ 16 | { 17 | "id": "f9b2da6dfa184759aa0c349edb1cd037.jpg", 18 | "url": "flatisfy/test_files/14428129@explorimmo.jpg", 19 | "data": null 20 | } 21 | ], 22 | "rooms": 3, 23 | "bedrooms": 2, 24 | "details": { 25 | "available": true, 26 | "heatingType": "", 27 | "agency": "NEXITY LAMY, 6 avenue Jean Janvier, 35000, Rennes", 28 | "bathrooms": 0, 29 | "exposure": "Non précisé", 30 | "floor": "15", 31 | "energy": "C", 32 | "bedrooms": 2, 33 | "greenhouseGasEmission": null, 34 | "isFurnished": false, 35 | "rooms": 3, 36 | "fees": 0, 37 | "creationDate": 1512455998000, 38 | "agencyFees": 0, 39 | "availabilityDate": null, 40 | "guarantee": 0 41 | }, 42 | "flatisfy": { 43 | "postal_code": "35000" 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /flatisfy/web/js_src/i18n/index.js: -------------------------------------------------------------------------------- 1 | import Vue from 'vue' 2 | import VueI18n from 'vue-i18n' 3 | import moment from 'moment' 4 | 5 | // Import translations 6 | import en from './en' 7 | import fr from './fr' 8 | 9 | Vue.use(VueI18n) 10 | 11 | export function getBrowserLocales () { 12 | let langs = [] 13 | 14 | if (navigator.languages) { 15 | // Chrome does not currently set navigator.language correctly 16 | // https://code.google.com/p/chromium/issues/detail?id=101138 17 | // but it does set the first element of navigator.languages correctly 18 | langs = navigator.languages 19 | } else if (navigator.userLanguage) { 20 | // IE only 21 | langs = [navigator.userLanguage] 22 | } else { 23 | // as of this writing the latest version of firefox + safari set this correctly 24 | langs = [navigator.language] 25 | } 26 | 27 | // Some browsers does not return uppercase for second part 28 | const locales = langs.map(function (lang) { 29 | const locale = lang.split('-') 30 | return locale[1] ? `${locale[0]}-${locale[1].toUpperCase()}` : lang 31 | }) 32 | 33 | return locales 34 | } 35 | 36 | const messages = { 37 | 'en': en, 38 | 'fr': fr 39 | } 40 | 41 | const locales = getBrowserLocales() 42 | 43 | var locale = 'en' // Safe default 44 | // Get best matching locale 45 | for (var i = 0; i < locales.length; ++i) { 46 | if (messages[locales[i]]) { 47 | locale = locales[i] 48 | break // Break at first matching locale 49 | } 50 | } 51 | 52 | // Set the locale for Moment.js 53 | moment.locale(locale) 54 | 55 | export default new VueI18n({ 56 | locale: locale, 57 | messages 58 | }) 59 | -------------------------------------------------------------------------------- /doc/3.faq.md: -------------------------------------------------------------------------------- 1 | FAQ 2 | === 3 | 4 | ## What happens when duplicates are detected across different backends? 5 | 6 | There is a default precedence defined for each backend. This should be defined 7 | so that the backend with highest precedence is the backend that should contain 8 | the most precise information usually. 9 | 10 | When deduplicating, the post from the backend with the highest precedence is 11 | kept and missing info is taken from the duplicate posts (precedence is used so 12 | that in case of conflicts in a field, the data from the backend with highest 13 | precedence is used). This post contains as much data as possible, and includes 14 | references to all the other "duplicate" posts. These latter duplicate posts 15 | are then simply marked as such and never shown anymore. 16 | 17 | All origins are kept in a `urls` field in the remaining post. 18 | 19 | 20 | ## Flatisfy seems to be stuck fetching posts 21 | 22 | Fetching posts can be a long process, depending on your criterias. Run the 23 | import command with `-v` argument to get a more verbose output and check 24 | things are indeed happening. If fetching the flats is still too long, try to 25 | set `max_entries` in your config to limit the number of posts fetched. 26 | 27 | 28 | ## Docker image does not start the webserver at first start? 29 | 30 | When you launch the Docker image, it first updates Woob and fetches the 31 | housing posts matching your criterias. The webserver is only started once this 32 | is done. As fetching housing posts can take a bit of time (up to 10 minutes), 33 | the webserver will not be available right away. 34 | 35 | Once everything is ready, you should see a log message in the console running 36 | the Docker image, confirming you that webserver is up and running. 37 | -------------------------------------------------------------------------------- /flatisfy/web/static/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Flatisfy 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 |
27 | 28 | 29 | 30 | -------------------------------------------------------------------------------- /flatisfy/web/js_src/components/app.vue: -------------------------------------------------------------------------------- 1 | 15 | 16 | 54 | 55 | 77 | -------------------------------------------------------------------------------- /flatisfy/database/types.py: -------------------------------------------------------------------------------- 1 | # coding: utf-8 2 | """ 3 | This modules implements custom types in SQLAlchemy. 4 | """ 5 | from __future__ import absolute_import, print_function, unicode_literals 6 | 7 | import json 8 | 9 | import sqlalchemy.types as types 10 | 11 | 12 | class StringyJSON(types.TypeDecorator): 13 | """ 14 | Stores and retrieves JSON as TEXT for SQLite. 15 | 16 | From 17 | https://avacariu.me/articles/2016/compiling-json-as-text-for-sqlite-with-sqlalchemy. 18 | 19 | .. note :: 20 | 21 | The associated field is immutable. That is, changes to the data 22 | (typically, changing the value of a dict field) will not trigger an 23 | update on the SQL side upon ``commit`` as the reference to the object 24 | will not have been updated. One should force the update by forcing an 25 | update of the reference (by performing a ``copy`` operation on the dict 26 | for instance). 27 | """ 28 | 29 | impl = types.TEXT 30 | 31 | def process_bind_param(self, value, dialect): 32 | """ 33 | Process the bound param, serialize the object to JSON before saving 34 | into database. 35 | """ 36 | if value is not None: 37 | value = json.dumps(value) 38 | return value 39 | 40 | def process_result_value(self, value, dialect): 41 | """ 42 | Process the value fetched from the database, deserialize the JSON 43 | string before returning the object. 44 | """ 45 | if value is not None: 46 | value = json.loads(value) 47 | return value 48 | 49 | 50 | # TypeEngine.with_variant says "use StringyJSON instead when 51 | # connecting to 'sqlite'" 52 | # pylint: disable=locally-disabled,invalid-name 53 | MagicJSON = types.JSON().with_variant(StringyJSON, "sqlite") 54 | -------------------------------------------------------------------------------- /doc/2.docker.md: -------------------------------------------------------------------------------- 1 | Installing Flatisfy using Docker 2 | ================================ 3 | 4 | A basic `Dockerfile` is available for rapid testing. It is still really hacky 5 | and should not be used in production. 6 | 7 | 8 | 1\. First, build the docker image: 9 | 10 | ``` 11 | cd docker 12 | docker build -t phyks/flatisfy . 13 | ``` 14 | 15 | 2\. Then, create some folder to store your Flatisfy data in a permanent way (it 16 | will be mount as a Docker volume in next steps), and initialize an empty 17 | config: 18 | 19 | ``` 20 | mkdir flatisfy 21 | cd flatisfy 22 | FLATISFY_VOLUME=$(pwd) 23 | docker run --rm -it -e LOCAL_USER_ID=`id -u` -v $FLATISFY_VOLUME:/flatisfy phyks/flatisfy sh -c "cd /home/user/app && python -m flatisfy init-config > /flatisfy/config.json" 24 | ``` 25 | 26 | 27 | 3\. Then, edit the generated `$FLATISFY_VOLUME/config.json` file according to your needs. See 28 | [0.getting_started.md](0.getting_started.md) for more infos on the 29 | configuration file format. You will have to define your constraints (at 30 | least postal codes, house type and type of post), set `data_directory` to 31 | `/flatisfy` and set `host` to `0.0.0.0` to make the web UI accessible from 32 | outside the Docker container. The rest is up to you. 33 | 34 | 35 | 4\. Finally, run the docker image to fetch flats and serve the web UI: 36 | 37 | ``` 38 | docker run -it -e LOCAL_USER_ID=`id -u` -v $FLATISFY_VOLUME:/flatisfy -p 8080:8080 phyks/flatisfy 39 | ``` 40 | 41 | Your Flatisfy instance is now available at `localhost:8080`! 42 | 43 | 44 | To fetch new housing posts, you should manually call 45 | 46 | ``` 47 | docker run --rm -it -e LOCAL_USER_ID=`id -u` -v $FLATISFY_VOLUME:/flatisfy phyks/flatisfy /home/user/fetch.sh 48 | ``` 49 | 50 | This can be done easily in a crontask on your host system, to run it typically 51 | every night. 52 | -------------------------------------------------------------------------------- /flatisfy/test_files/13783671@explorimmo.json: -------------------------------------------------------------------------------- 1 | { 2 | "id": "13783671@explorimmo", 3 | "url": "http://www.explorimmo.com/annonce-13783671.html", 4 | "title": "Vente appartement 3 pi\u00e8ces 65 m2", 5 | "area": 65, 6 | "cost": 145275, 7 | "price_per_meter": 2235, 8 | "currency": "EUR", 9 | "utilities": "H.C.", 10 | "date": "2017-11-10T02:04:00", 11 | "location": "225 RUE DE FOUGERES Rennes 35700", 12 | "station": null, 13 | "text": "Rennes en exclusivit\u00e9 rue de Foug\u00e8res - Grand Appartement 3 pi\u00e8ces avec Balcon\ndans une copropri\u00e9t\u00e9 avec ascenseur - Travaux \u00e0 pr\u00e9voir - 2 chambres - Cave et\ngarage\n\n", 14 | "phone": null, 15 | "photos": [{ 16 | "id": "cb10f556708c4e858c1a45ec1dfda623.jpg", 17 | "url": "http://thbr.figarocms.net/images/AXuL6XMCphsRrTYttb7yR2W3CCg=/560x420/filters:fill(f6f6f6):quality(80):strip_icc()/cb10f556708c4e858c1a45ec1dfda623.jpg", 18 | "data": null 19 | }, { 20 | "id": "e2696eacce2d487e99e88c2b945cee34.jpg", 21 | "url": "http://thbr.figarocms.net/images/0Va3M6bf1eFkJJzPXC--QIc6WTo=/560x420/filters:fill(f6f6f6):quality(80):strip_icc()/e2696eacce2d487e99e88c2b945cee34.jpg", 22 | "data": null 23 | }], 24 | "rooms": 3, 25 | "bedrooms": 2, 26 | "details": { 27 | "available": true, 28 | "heatingType": "", 29 | "agency": "NEXITY LAMY, 6 avenue Jean Janvier, 35000, Rennes", 30 | "bathrooms": 0, 31 | "exposure": "Non pr\u00e9cis\u00e9", 32 | "floor": "1", 33 | "energy": "E", 34 | "bedrooms": 2, 35 | "greenhouseGasEmission": null, 36 | "isFurnished": false, 37 | "rooms": 3, 38 | "fees": 0.0, 39 | "creationDate": 1507712100000, 40 | "agencyFees": 0.0, 41 | "availabilityDate": null, 42 | "guarantee": 0.0 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /alembic.ini: -------------------------------------------------------------------------------- 1 | # A generic, single database configuration. 2 | 3 | [alembic] 4 | # path to migration scripts 5 | script_location = migrations 6 | 7 | # template used to generate migration files 8 | # file_template = %%(rev)s_%%(slug)s 9 | 10 | # timezone to use when rendering the date 11 | # within the migration file as well as the filename. 12 | # string value is passed to dateutil.tz.gettz() 13 | # leave blank for localtime 14 | # timezone = 15 | 16 | # max length of characters to apply to the 17 | # "slug" field 18 | #truncate_slug_length = 40 19 | 20 | # set to 'true' to run the environment during 21 | # the 'revision' command, regardless of autogenerate 22 | # revision_environment = false 23 | 24 | # set to 'true' to allow .pyc and .pyo files without 25 | # a source .py file to be detected as revisions in the 26 | # versions/ directory 27 | # sourceless = false 28 | 29 | # version location specification; this defaults 30 | # to migrations/versions. When using multiple version 31 | # directories, initial revisions must be specified with --version-path 32 | # version_locations = %(here)s/bar %(here)s/bat migrations/versions 33 | 34 | # the output encoding used when revision files 35 | # are written from script.py.mako 36 | # output_encoding = utf-8 37 | 38 | sqlalchemy.url = sqlite:///data/flatisfy.db 39 | 40 | 41 | # Logging configuration 42 | [loggers] 43 | keys = root,sqlalchemy,alembic 44 | 45 | [handlers] 46 | keys = console 47 | 48 | [formatters] 49 | keys = generic 50 | 51 | [logger_root] 52 | level = WARN 53 | handlers = console 54 | qualname = 55 | 56 | [logger_sqlalchemy] 57 | level = WARN 58 | handlers = 59 | qualname = sqlalchemy.engine 60 | 61 | [logger_alembic] 62 | level = INFO 63 | handlers = 64 | qualname = alembic 65 | 66 | [handler_console] 67 | class = StreamHandler 68 | args = (sys.stderr,) 69 | level = NOTSET 70 | formatter = generic 71 | 72 | [formatter_generic] 73 | format = %(levelname)-5.5s [%(name)s] %(message)s 74 | datefmt = %H:%M:%S 75 | -------------------------------------------------------------------------------- /doc/flatisfy.rst: -------------------------------------------------------------------------------- 1 | flatisfy package 2 | ================ 3 | 4 | Subpackages 5 | ----------- 6 | 7 | .. toctree:: 8 | 9 | flatisfy.data_files 10 | flatisfy.database 11 | flatisfy.filters 12 | flatisfy.models 13 | flatisfy.web 14 | 15 | Submodules 16 | ---------- 17 | 18 | flatisfy.cmds module 19 | -------------------- 20 | 21 | .. automodule:: flatisfy.cmds 22 | :members: 23 | :undoc-members: 24 | :show-inheritance: 25 | 26 | flatisfy.config module 27 | ---------------------- 28 | 29 | .. automodule:: flatisfy.config 30 | :members: 31 | :undoc-members: 32 | :show-inheritance: 33 | 34 | flatisfy.constants module 35 | ------------------------- 36 | 37 | .. automodule:: flatisfy.constants 38 | :members: 39 | :undoc-members: 40 | :show-inheritance: 41 | 42 | flatisfy.data module 43 | -------------------- 44 | 45 | .. automodule:: flatisfy.data 46 | :members: 47 | :undoc-members: 48 | :show-inheritance: 49 | 50 | flatisfy.email module 51 | --------------------- 52 | 53 | .. automodule:: flatisfy.email 54 | :members: 55 | :undoc-members: 56 | :show-inheritance: 57 | 58 | flatisfy.exceptions module 59 | -------------------------- 60 | 61 | .. automodule:: flatisfy.exceptions 62 | :members: 63 | :undoc-members: 64 | :show-inheritance: 65 | 66 | flatisfy.fetch module 67 | --------------------- 68 | 69 | .. automodule:: flatisfy.fetch 70 | :members: 71 | :undoc-members: 72 | :show-inheritance: 73 | 74 | flatisfy.tests module 75 | --------------------- 76 | 77 | .. automodule:: flatisfy.tests 78 | :members: 79 | :undoc-members: 80 | :show-inheritance: 81 | 82 | flatisfy.tools module 83 | --------------------- 84 | 85 | .. automodule:: flatisfy.tools 86 | :members: 87 | :undoc-members: 88 | :show-inheritance: 89 | 90 | 91 | Module contents 92 | --------------- 93 | 94 | .. automodule:: flatisfy 95 | :members: 96 | :undoc-members: 97 | :show-inheritance: 98 | -------------------------------------------------------------------------------- /flatisfy/web/js_src/components/notation.vue: -------------------------------------------------------------------------------- 1 | 10 | 11 | 61 | 62 | 69 | -------------------------------------------------------------------------------- /webpack.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | entry: './flatisfy/web/js_src/main.js', 3 | output: { 4 | path: __dirname + '/flatisfy/web/static/assets/', 5 | filename: 'bundle.js', 6 | publicPath: '/assets/' 7 | }, 8 | module: { 9 | loaders: [ 10 | { 11 | test: /\.js$/, 12 | exclude: /(node_modules|bower_components)/, 13 | use: { 14 | loader: 'babel-loader' 15 | } 16 | }, 17 | { 18 | test: /\.vue$/, 19 | loader: 'vue-loader', 20 | options: { 21 | loaders: { 22 | js: 'babel-loader' 23 | } 24 | } 25 | }, 26 | { 27 | test: /\.css$/, 28 | loader: 'style-loader!css-loader' 29 | }, 30 | { 31 | test: /\.(jpe?g|png|gif|svg)$/i, 32 | loaders: [ 33 | 'file-loader?hash=sha512&digest=hex&name=[hash].[ext]', 34 | { 35 | loader: 'image-webpack-loader', 36 | query: { 37 | bypassOnDebug: true, 38 | 'optipng': { 39 | optimizationLevel: 7 40 | }, 41 | 'gifsicle': { 42 | interlaced: false 43 | } 44 | } 45 | } 46 | ] 47 | }, 48 | { 49 | test: /\.woff(2)?(\?v=[0-9]\.[0-9]\.[0-9])?$/, 50 | loader: "url-loader?limit=10000&mimetype=application/font-woff" 51 | }, 52 | { 53 | test: /\.(ttf|eot|svg)(\?v=[0-9]\.[0-9]\.[0-9])?$/, 54 | loader: "file-loader" 55 | } 56 | ] 57 | }, 58 | resolve: { 59 | alias: { 60 | 'masonry': 'masonry-layout', 61 | 'isotope': 'isotope-layout' 62 | } 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /migrations/env.py: -------------------------------------------------------------------------------- 1 | from __future__ import with_statement 2 | from alembic import context 3 | from sqlalchemy import engine_from_config, pool 4 | from logging.config import fileConfig 5 | 6 | # this is the Alembic Config object, which provides 7 | # access to the values within the .ini file in use. 8 | config = context.config 9 | 10 | # Interpret the config file for Python logging. 11 | # This line sets up loggers basically. 12 | fileConfig(config.config_file_name) 13 | 14 | # add your model's MetaData object here 15 | # for 'autogenerate' support 16 | # from myapp import mymodel 17 | # target_metadata = mymodel.Base.metadata 18 | target_metadata = None 19 | 20 | # other values from the config, defined by the needs of env.py, 21 | # can be acquired: 22 | # my_important_option = config.get_main_option("my_important_option") 23 | # ... etc. 24 | 25 | 26 | def run_migrations_offline(): 27 | """Run migrations in 'offline' mode. 28 | 29 | This configures the context with just a URL 30 | and not an Engine, though an Engine is acceptable 31 | here as well. By skipping the Engine creation 32 | we don't even need a DBAPI to be available. 33 | 34 | Calls to context.execute() here emit the given string to the 35 | script output. 36 | 37 | """ 38 | url = config.get_main_option("sqlalchemy.url") 39 | context.configure(url=url, target_metadata=target_metadata, literal_binds=True) 40 | 41 | with context.begin_transaction(): 42 | context.run_migrations() 43 | 44 | 45 | def run_migrations_online(): 46 | """Run migrations in 'online' mode. 47 | 48 | In this scenario we need to create an Engine 49 | and associate a connection with the context. 50 | 51 | """ 52 | connectable = engine_from_config( 53 | config.get_section(config.config_ini_section), 54 | prefix="sqlalchemy.", 55 | poolclass=pool.NullPool, 56 | ) 57 | 58 | with connectable.connect() as connection: 59 | context.configure(connection=connection, target_metadata=target_metadata) 60 | 61 | with context.begin_transaction(): 62 | context.run_migrations() 63 | 64 | 65 | if context.is_offline_mode(): 66 | run_migrations_offline() 67 | else: 68 | run_migrations_online() 69 | -------------------------------------------------------------------------------- /migrations/versions/d21933db9ad8_add_flat_position_column.py: -------------------------------------------------------------------------------- 1 | """Add flat position column 2 | 3 | Revision ID: d21933db9ad8 4 | Revises: 8155b83242eb 5 | Create Date: 2021-02-08 16:26:37.190842 6 | 7 | """ 8 | from alembic import op 9 | import sqlalchemy as sa 10 | import sqlalchemy.types as types 11 | import json 12 | 13 | 14 | class StringyJSON(types.TypeDecorator): 15 | """ 16 | Stores and retrieves JSON as TEXT for SQLite. 17 | 18 | From 19 | https://avacariu.me/articles/2016/compiling-json-as-text-for-sqlite-with-sqlalchemy. 20 | 21 | .. note :: 22 | 23 | The associated field is immutable. That is, changes to the data 24 | (typically, changing the value of a dict field) will not trigger an 25 | update on the SQL side upon ``commit`` as the reference to the object 26 | will not have been updated. One should force the update by forcing an 27 | update of the reference (by performing a ``copy`` operation on the dict 28 | for instance). 29 | """ 30 | 31 | impl = types.TEXT 32 | 33 | def process_bind_param(self, value, dialect): 34 | """ 35 | Process the bound param, serialize the object to JSON before saving 36 | into database. 37 | """ 38 | if value is not None: 39 | value = json.dumps(value) 40 | return value 41 | 42 | def process_result_value(self, value, dialect): 43 | """ 44 | Process the value fetched from the database, deserialize the JSON 45 | string before returning the object. 46 | """ 47 | if value is not None: 48 | value = json.loads(value) 49 | return value 50 | 51 | 52 | # TypeEngine.with_variant says "use StringyJSON instead when 53 | # connecting to 'sqlite'" 54 | # pylint: disable=locally-disabled,invalid-name 55 | MagicJSON = types.JSON().with_variant(StringyJSON, "sqlite") 56 | 57 | # revision identifiers, used by Alembic. 58 | revision = "d21933db9ad8" 59 | down_revision = "8155b83242eb" 60 | branch_labels = None 61 | depends_on = None 62 | 63 | 64 | def upgrade(): 65 | op.add_column("flats", sa.Column("flatisfy_position", MagicJSON, default=False)) 66 | 67 | 68 | def downgrade(): 69 | op.drop_column("flats", "flatisfy_position") 70 | -------------------------------------------------------------------------------- /flatisfy/web/js_src/store/actions.js: -------------------------------------------------------------------------------- 1 | import * as api from '../api' 2 | import * as types from './mutations-types' 3 | 4 | export default { 5 | getAllFlats ({ commit }) { 6 | commit(types.IS_LOADING) 7 | api.getFlats(flats => { 8 | commit(types.REPLACE_FLATS, { flats }) 9 | }) 10 | }, 11 | getFlat ({ commit }, { flatId }) { 12 | commit(types.IS_LOADING) 13 | api.getFlat(flatId, flat => { 14 | const flats = [flat] 15 | commit(types.MERGE_FLATS, { flats }) 16 | }) 17 | }, 18 | getAllTimeToPlaces ({ commit }) { 19 | commit(types.IS_LOADING) 20 | api.getTimeToPlaces(timeToPlaces => { 21 | commit(types.RECEIVE_TIME_TO_PLACES, { timeToPlaces }) 22 | }) 23 | }, 24 | updateFlatStatus ({ commit }, { flatId, newStatus }) { 25 | commit(types.IS_LOADING) 26 | api.updateFlatStatus(flatId, newStatus, response => { 27 | commit(types.UPDATE_FLAT_STATUS, { flatId, newStatus }) 28 | }) 29 | }, 30 | updateFlatNotation ({ commit }, { flatId, newNotation }) { 31 | commit(types.IS_LOADING) 32 | api.updateFlatNotation(flatId, newNotation, response => { 33 | commit(types.UPDATE_FLAT_NOTATION, { flatId, newNotation }) 34 | }) 35 | }, 36 | updateFlatNotes ({ commit }, { flatId, newNotes }) { 37 | commit(types.IS_LOADING) 38 | api.updateFlatNotes(flatId, newNotes, response => { 39 | commit(types.UPDATE_FLAT_NOTES, { flatId, newNotes }) 40 | }) 41 | }, 42 | updateFlatVisitDate ({ commit }, { flatId, newVisitDate }) { 43 | commit(types.IS_LOADING) 44 | api.updateFlatVisitDate(flatId, newVisitDate, response => { 45 | commit(types.UPDATE_FLAT_VISIT_DATE, { flatId, newVisitDate }) 46 | }) 47 | }, 48 | doSearch ({ commit }, { query }) { 49 | commit(types.IS_LOADING) 50 | api.doSearch(query, flats => { 51 | commit(types.REPLACE_FLATS, { flats }) 52 | }) 53 | }, 54 | getMetadata ({ commit }) { 55 | commit(types.IS_LOADING) 56 | api.getMetadata(metadata => { 57 | commit(types.RECEIVE_METADATA, { metadata }) 58 | }) 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Flatisfy", 3 | "description": "Flatisfy is your new companion to ease your search of a new housing :)", 4 | "author": "Phyks (Lucas Verney) ", 5 | "license": "MIT", 6 | "version": "0.0.1", 7 | "repository": { 8 | "type": "git", 9 | "url": "https://Phyks@git.phyks.me/Phyks/flatisfy.git" 10 | }, 11 | "homepage": "https://git.phyks.me/Phyks/flatisfy", 12 | "scripts": { 13 | "build:dev": "webpack --colors --progress", 14 | "watch:dev": "webpack --colors --progress --watch", 15 | "build:prod": "NODE_ENV=production webpack --colors --progress -p", 16 | "watch:prod": "NODE_ENV=production webpack --colors --progress --watch -p", 17 | "lint": "eslint --fix --ext .js,.vue ./flatisfy/web/js_src/**", 18 | "ziparound": "cp flatisfy/data_files/laposte.json node_modules/ziparound/laposte.json && node node_modules/ziparound" 19 | }, 20 | "dependencies": { 21 | "es6-promise": "^4.1.0", 22 | "font-awesome": "^4.7.0", 23 | "font-awesome-webpack": "0.0.5-beta.2", 24 | "imagesloaded": "^4.1.1", 25 | "isomorphic-fetch": "^2.2.1", 26 | "isotope-layout": "^3.0.3", 27 | "leaflet": "^1.7.1", 28 | "leaflet.icon.glyph": "^0.2.0", 29 | "masonry": "0.0.2", 30 | "moment": "^2.18.1", 31 | "vue": "^2.2.6", 32 | "vue-flatpickr-component": "^4.0.0", 33 | "vue-i18n": "^6.1.1", 34 | "vue-images-loaded": "^1.1.2", 35 | "vue-router": "^2.4.0", 36 | "vue2-leaflet": "2.6.0", 37 | "vue2-leaflet-markercluster": "^3.1.0", 38 | "vueisotope": "^3.0.0-rc", 39 | "vuex": "^2.3.0" 40 | }, 41 | "devDependencies": { 42 | "babel-core": "^6.24.1", 43 | "babel-loader": "^6.4.1", 44 | "babel-plugin-transform-runtime": "^6.23.0", 45 | "babel-preset-es2015": "^6.24.1", 46 | "babel-preset-stage-0": "^6.24.1", 47 | "css-loader": "^0.28.0", 48 | "eslint": "^3.19.0", 49 | "eslint-config-vue": "^2.0.2", 50 | "eslint-plugin-vue": "^2.0.1", 51 | "file-loader": "^0.11.1", 52 | "image-webpack-loader": "^3.3.0", 53 | "less": "^2.7.2", 54 | "style-loader": "^0.16.1", 55 | "url-loader": "^0.5.8", 56 | "vue-html-loader": "^1.2.4", 57 | "vue-loader": "^11.3.4", 58 | "vue-template-compiler": "^2.2.6", 59 | "webpack": "^2.3.3", 60 | "ziparound": "1.0.0" 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /modules/foncia/browser.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | # Copyright(C) 2017 Phyks (Lucas Verney) 4 | # 5 | # This file is part of a woob module. 6 | # 7 | # This woob module is free software: you can redistribute it and/or modify 8 | # it under the terms of the GNU Affero General Public License as published by 9 | # the Free Software Foundation, either version 3 of the License, or 10 | # (at your option) any later version. 11 | # 12 | # This woob module is distributed in the hope that it will be useful, 13 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 14 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 15 | # GNU Affero General Public License for more details. 16 | # 17 | # You should have received a copy of the GNU Affero General Public License 18 | # along with this woob module. If not, see . 19 | 20 | from __future__ import unicode_literals 21 | 22 | 23 | from woob.browser import PagesBrowser, URL 24 | 25 | from .constants import QUERY_TYPES 26 | from .pages import CitiesPage, HousingPage, SearchPage, SearchResultsPage 27 | 28 | 29 | class FonciaBrowser(PagesBrowser): 30 | BASEURL = 'https://fr.foncia.com' 31 | 32 | cities = URL(r'/recherche/autocomplete\?term=(?P.+)', CitiesPage) 33 | housing = URL(r'/(?P[^/]+)/.*\d+.htm', HousingPage) 34 | search_results = URL(r'/(?P[^/]+)/.*', SearchResultsPage) 35 | search = URL(r'/(?P.+)', SearchPage) 36 | 37 | def get_cities(self, pattern): 38 | """ 39 | Get cities matching a given pattern. 40 | """ 41 | return self.cities.open(term=pattern).iter_cities() 42 | 43 | def search_housings(self, query, cities): 44 | """ 45 | Search for housings matching given query. 46 | """ 47 | try: 48 | query_type = QUERY_TYPES[query.type] 49 | except KeyError: 50 | return [] 51 | 52 | self.search.go(type=query_type).do_search(query, cities) 53 | return self.page.iter_housings(query_type=query.type) 54 | 55 | def get_housing(self, housing): 56 | """ 57 | Get specific housing. 58 | """ 59 | query_type, housing = housing.split(':') 60 | self.search.go(type=query_type).find_housing(query_type, housing) 61 | return self.page.get_housing() 62 | -------------------------------------------------------------------------------- /flatisfy/web/js_src/store/mutations.js: -------------------------------------------------------------------------------- 1 | import Vue from 'vue' 2 | 3 | import * as types from './mutations-types' 4 | 5 | export const state = { 6 | flats: [], 7 | timeToPlaces: [], 8 | metadata: [], 9 | loading: 0 10 | } 11 | 12 | export const mutations = { 13 | [types.REPLACE_FLATS] (state, { flats }) { 14 | state.flats = flats 15 | state.loading -= 1 16 | }, 17 | [types.MERGE_FLATS] (state, { flats }) { 18 | flats.forEach(flat => { 19 | const flatIndex = state.flats.findIndex(storedFlat => storedFlat.id === flat.id) 20 | 21 | if (flatIndex > -1) { 22 | Vue.set(state.flats, flatIndex, flat) 23 | } else { 24 | state.flats.push(flat) 25 | } 26 | }) 27 | state.loading = false 28 | state.loading -= 1 29 | }, 30 | [types.UPDATE_FLAT_STATUS] (state, { flatId, newStatus }) { 31 | const index = state.flats.findIndex(flat => flat.id === flatId) 32 | if (index > -1) { 33 | Vue.set(state.flats[index], 'status', newStatus) 34 | } 35 | state.loading -= 1 36 | }, 37 | [types.UPDATE_FLAT_NOTES] (state, { flatId, newNotes }) { 38 | const index = state.flats.findIndex(flat => flat.id === flatId) 39 | if (index > -1) { 40 | Vue.set(state.flats[index], 'notes', newNotes) 41 | } 42 | state.loading -= 1 43 | }, 44 | [types.UPDATE_FLAT_NOTATION] (state, { flatId, newNotation }) { 45 | const index = state.flats.findIndex(flat => flat.id === flatId) 46 | if (index > -1) { 47 | Vue.set(state.flats[index], 'notation', newNotation) 48 | } 49 | state.loading -= 1 50 | }, 51 | [types.UPDATE_FLAT_VISIT_DATE] (state, { flatId, newVisitDate }) { 52 | const index = state.flats.findIndex(flat => flat.id === flatId) 53 | if (index > -1) { 54 | Vue.set(state.flats[index], 'visit-date', newVisitDate) 55 | } 56 | state.loading -= 1 57 | }, 58 | [types.RECEIVE_TIME_TO_PLACES] (state, { timeToPlaces }) { 59 | state.timeToPlaces = timeToPlaces 60 | state.loading -= 1 61 | }, 62 | [types.RECEIVE_METADATA] (state, { metadata }) { 63 | state.metadata = metadata 64 | state.loading -= 1 65 | }, 66 | [types.IS_LOADING] (state) { 67 | state.loading += 1 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /modules/leboncoin/module.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | # Copyright(C) 2014 Bezleputh 4 | # 5 | # This file is part of a woob module. 6 | # 7 | # This woob module is free software: you can redistribute it and/or modify 8 | # it under the terms of the GNU Affero General Public License as published by 9 | # the Free Software Foundation, either version 3 of the License, or 10 | # (at your option) any later version. 11 | # 12 | # This woob module is distributed in the hope that it will be useful, 13 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 14 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 15 | # GNU Affero General Public License for more details. 16 | # 17 | # You should have received a copy of the GNU Affero General Public License 18 | # along with this woob module. If not, see . 19 | 20 | 21 | from woob.tools.backend import Module 22 | from woob.capabilities.housing import (CapHousing, Housing, HousingPhoto) 23 | from .browser import LeboncoinBrowser 24 | from woob import __version__ as WOOB_VERSION 25 | 26 | 27 | __all__ = ['LeboncoinModule'] 28 | 29 | 30 | class LeboncoinModule(Module, CapHousing): 31 | NAME = 'leboncoin' 32 | DESCRIPTION = u'search house on leboncoin website' 33 | MAINTAINER = u'Bezleputh' 34 | EMAIL = 'carton_ben@yahoo.fr' 35 | LICENSE = 'AGPLv3+' 36 | VERSION = WOOB_VERSION 37 | 38 | BROWSER = LeboncoinBrowser 39 | 40 | def create_default_browser(self): 41 | return self.create_browser() 42 | 43 | def get_housing(self, _id): 44 | return self.browser.get_housing(_id) 45 | 46 | def fill_housing(self, housing, fields): 47 | if 'phone' in fields: 48 | housing.phone = self.browser.get_phone(housing.id) 49 | fields.remove('phone') 50 | 51 | if len(fields) > 0: 52 | self.browser.get_housing(housing.id, housing) 53 | 54 | return housing 55 | 56 | def fill_photo(self, photo, fields): 57 | if 'data' in fields and photo.url and not photo.data: 58 | photo.data = self.browser.open(photo.url).content 59 | return photo 60 | 61 | def search_city(self, pattern): 62 | return self.browser.get_cities(pattern) 63 | 64 | def search_housings(self, query): 65 | return self.browser.search_housings(query, self.name) 66 | 67 | OBJECTS = {Housing: fill_housing, HousingPhoto: fill_photo} 68 | -------------------------------------------------------------------------------- /flatisfy/web/configplugin.py: -------------------------------------------------------------------------------- 1 | # coding: utf-8 2 | """ 3 | This module contains a Bottle plugin to pass the config argument to any route 4 | which needs it. 5 | 6 | This module is heavily based on code from 7 | [Bottle-SQLAlchemy](https://github.com/iurisilvio/bottle-sqlalchemy) which is 8 | licensed under MIT license. 9 | """ 10 | from __future__ import absolute_import, division, print_function, unicode_literals 11 | 12 | import functools 13 | import inspect 14 | 15 | import bottle 16 | 17 | 18 | class ConfigPlugin(object): 19 | """ 20 | A Bottle plugin to automatically pass the config object to the routes 21 | specifying they need it. 22 | """ 23 | 24 | name = "config" 25 | api = 2 26 | KEYWORD = "config" 27 | 28 | def __init__(self, config): 29 | """ 30 | :param config: The config object to pass. 31 | """ 32 | self.config = config 33 | 34 | def setup(self, app): # pylint: disable=locally-disabled,no-self-use 35 | """ 36 | Make sure that other installed plugins don't affect the same 37 | keyword argument and check if metadata is available. 38 | """ 39 | for other in app.plugins: 40 | if not isinstance(other, ConfigPlugin): 41 | continue 42 | else: 43 | raise bottle.PluginError("Found another conflicting Config plugin.") 44 | 45 | def apply(self, callback, route): 46 | """ 47 | Method called on route invocation. Should apply some transformations to 48 | the route prior to returing it. 49 | 50 | We check the presence of ``self.KEYWORD`` in the route signature and 51 | replace the route callback by a partial invocation where we replaced 52 | this argument by a valid config object. 53 | """ 54 | # Check whether the route needs a valid db session or not. 55 | try: 56 | callback_args = inspect.signature(route.callback).parameters 57 | except AttributeError: 58 | # inspect.signature does not exist on older Python 59 | callback_args = inspect.getargspec(route.callback).args 60 | 61 | if self.KEYWORD not in callback_args: 62 | # If no need for a db session, call the route callback 63 | return callback 64 | kwargs = {} 65 | kwargs[self.KEYWORD] = self.config 66 | return functools.partial(callback, **kwargs) 67 | 68 | 69 | Plugin = ConfigPlugin 70 | -------------------------------------------------------------------------------- /flatisfy/web/js_src/i18n/en/index.js: -------------------------------------------------------------------------------- 1 | export default { 2 | common: { 3 | 'flats': 'flat | flats', 4 | 'loading': 'Loading…', 5 | 'Actions': 'Actions', 6 | 'More_about': 'More about', 7 | 'Remove': 'Remove', 8 | 'Restore': 'Restore', 9 | 'Original_post': 'Original post | Original posts', 10 | 'Original_post_for': 'Original post for', 11 | 'Follow': 'Follow', 12 | 'Unfollow': 'Unfollow', 13 | 'Close': 'Close', 14 | 'sortUp': 'Sort in ascending order', 15 | 'sortDown': 'Sort in descending order', 16 | 'mins': 'min | mins', 17 | 'Unknown': 'Unknown', 18 | 'expired': 'expired' 19 | }, 20 | home: { 21 | 'new_available_flats': 'New available flats', 22 | 'Last_update': 'Last update:', 23 | 'show_expired_flats': 'Show expired flats' 24 | }, 25 | flatListing: { 26 | 'no_available_flats': 'No available flats.', 27 | 'no_matching_flats': 'No matching flats.' 28 | }, 29 | menu: { 30 | 'available_flats': 'Available flats', 31 | 'followed_flats': 'Followed flats', 32 | 'by_status': 'Flats by status', 33 | 'search': 'Search' 34 | }, 35 | flatsDetails: { 36 | 'Notation': 'Note', 37 | 'Title': 'Title', 38 | 'Area': 'Area', 39 | 'Rooms': 'Rooms', 40 | 'Cost': 'Cost', 41 | 'SquareMeterCost': 'Cost / m²', 42 | 'utilities_included': '(utilities included)', 43 | 'utilities_excluded': '(utilities excluded)', 44 | 'Description': 'Description', 45 | 'First_posted': 'First posted', 46 | 'Details': 'Details', 47 | 'Metadata': 'Metadata', 48 | 'postal_code': 'Postal code', 49 | 'nearby_stations': 'Nearby stations', 50 | 'Times_to': 'Times to', 51 | 'Location': 'Location', 52 | 'Notes': 'Notes', 53 | 'Save': 'Save', 54 | 'Contact': 'Contact', 55 | 'Visit': 'Visit', 56 | 'setDateOfVisit': 'Set date of visit', 57 | 'no_phone_found': 'No phone found', 58 | 'rooms': 'room | rooms', 59 | 'bedrooms': 'bedroom | bedrooms' 60 | }, 61 | status: { 62 | 'new': 'new', 63 | 'followed': 'followed', 64 | 'ignored': 'ignored', 65 | 'user_deleted': 'user deleted', 66 | 'duplicate': 'duplicate' 67 | }, 68 | slider: { 69 | 'Fullscreen_photo': 'Fullscreen photo' 70 | }, 71 | search: { 72 | 'input_placeholder': 'Type anything to look for…', 73 | 'Search': 'Search!' 74 | } 75 | } 76 | -------------------------------------------------------------------------------- /flatisfy/database/__init__.py: -------------------------------------------------------------------------------- 1 | # coding: utf-8 2 | """ 3 | This module contains functions related to the database. 4 | """ 5 | from __future__ import absolute_import, print_function, unicode_literals 6 | 7 | import sqlite3 8 | 9 | from contextlib import contextmanager 10 | 11 | from sqlalchemy import event, create_engine 12 | from sqlalchemy.engine import Engine 13 | from sqlalchemy.orm import sessionmaker 14 | from sqlalchemy.exc import OperationalError, SQLAlchemyError 15 | 16 | import flatisfy.models.flat # noqa: F401 17 | from flatisfy.database.base import BASE 18 | from flatisfy.database.whooshalchemy import IndexService 19 | 20 | 21 | @event.listens_for(Engine, "connect") 22 | def set_sqlite_pragma(dbapi_connection, _): 23 | """ 24 | Auto enable foreign keys for SQLite. 25 | """ 26 | # Play well with other DB backends 27 | if isinstance(dbapi_connection, sqlite3.Connection): 28 | cursor = dbapi_connection.cursor() 29 | cursor.execute("PRAGMA foreign_keys=ON") 30 | cursor.close() 31 | 32 | 33 | def init_db(database_uri=None, search_db_uri=None): 34 | """ 35 | Initialize the database, ensuring tables exist etc. 36 | 37 | :param database_uri: An URI describing an engine to use. Defaults to 38 | in-memory SQLite database. 39 | :param search_db_uri: Path to the Whoosh index file to use. 40 | :return: A tuple of an SQLAlchemy session maker and the created engine. 41 | """ 42 | if database_uri is None: 43 | database_uri = "sqlite:///:memory:" 44 | 45 | engine = create_engine(database_uri) 46 | BASE.metadata.create_all(engine, checkfirst=True) 47 | Session = sessionmaker(bind=engine) # pylint: disable=locally-disabled,invalid-name 48 | 49 | if search_db_uri: 50 | index_service = IndexService(whoosh_base=search_db_uri) 51 | index_service.register_class(flatisfy.models.flat.Flat) 52 | 53 | @contextmanager 54 | def get_session(): 55 | # pylint: disable=locally-disabled,line-too-long 56 | """ 57 | Provide a transactional scope around a series of operations. 58 | 59 | From [1]. 60 | [1]: http://docs.sqlalchemy.org/en/latest/orm/session_basics.html#when-do-i-construct-a-session-when-do-i-commit-it-and-when-do-i-close-it. 61 | """ 62 | # pylint: enable=line-too-long,locally-disabled 63 | session = Session() 64 | try: 65 | yield session 66 | session.commit() 67 | except SQLAlchemyError: 68 | session.rollback() 69 | raise 70 | finally: 71 | session.close() 72 | 73 | return get_session 74 | -------------------------------------------------------------------------------- /modules/foncia/module.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | # Copyright(C) 2017 Phyks (Lucas Verney) 4 | # 5 | # This file is part of a woob module. 6 | # 7 | # This woob module is free software: you can redistribute it and/or modify 8 | # it under the terms of the GNU Affero General Public License as published by 9 | # the Free Software Foundation, either version 3 of the License, or 10 | # (at your option) any later version. 11 | # 12 | # This woob module is distributed in the hope that it will be useful, 13 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 14 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 15 | # GNU Affero General Public License for more details. 16 | # 17 | # You should have received a copy of the GNU Affero General Public License 18 | # along with this woob module. If not, see . 19 | 20 | from __future__ import unicode_literals 21 | 22 | 23 | from woob.tools.backend import Module 24 | from woob.capabilities.housing import CapHousing, Housing, ADVERT_TYPES, HousingPhoto 25 | from woob import __version__ as WOOB_VERSION 26 | 27 | from .browser import FonciaBrowser 28 | 29 | 30 | __all__ = ['FonciaModule'] 31 | 32 | 33 | class FonciaModule(Module, CapHousing): 34 | NAME = 'foncia' 35 | DESCRIPTION = u'Foncia housing website.' 36 | MAINTAINER = u'Phyks (Lucas Verney)' 37 | EMAIL = 'phyks@phyks.me' 38 | LICENSE = 'AGPLv3+' 39 | VERSION = WOOB_VERSION 40 | 41 | BROWSER = FonciaBrowser 42 | 43 | def get_housing(self, housing): 44 | return self.browser.get_housing(housing) 45 | 46 | def search_city(self, pattern): 47 | return self.browser.get_cities(pattern) 48 | 49 | def search_housings(self, query): 50 | if ( 51 | len(query.advert_types) == 1 and 52 | query.advert_types[0] == ADVERT_TYPES.PERSONAL 53 | ): 54 | # Foncia is pro only 55 | return list() 56 | 57 | cities = ','.join( 58 | ['%s' % c.name for c in query.cities if c.backend == self.name] 59 | ) 60 | if len(cities) == 0: 61 | return [] 62 | 63 | return self.browser.search_housings(query, cities) 64 | 65 | def fill_housing(self, housing, fields): 66 | if len(fields) > 0: 67 | self.browser.get_housing(housing) 68 | return housing 69 | 70 | def fill_photo(self, photo, fields): 71 | if 'data' in fields and photo.url and not photo.data: 72 | photo.data = self.browser.open(photo.url).content 73 | return photo 74 | 75 | OBJECTS = {Housing: fill_housing, HousingPhoto: fill_photo} 76 | -------------------------------------------------------------------------------- /flatisfy/test_files/127963747@seloger.json: -------------------------------------------------------------------------------- 1 | { 2 | "id": "127963747@seloger", 3 | "url": "http://www.seloger.com/annonces/achat/appartement/rennes-35/127963747.htm?p=", 4 | "title": "Appartement 3 pi\u00e8ces 78m\u00b2 - Rennes", 5 | "area": 78, 6 | "cost": 211000, 7 | "price_per_meter": 2705.128205128205128205128205, 8 | "currency": "\u20ac", 9 | "utilities": "", 10 | "date": "2018-01-17T17:54:00", 11 | "location": " Rennes (35000)", 12 | "station": "", 13 | "text": "ARSENAL/REDON - CIT\u00c9 JUDICIAIRE. D'une surface de 78 m\u00b2, cet appartement de type T3 est compos\u00e9 au rez-de-chauss\u00e9e comme suit: cuisine am\u00e9nag\u00e9e, deux chambres, salon/salle \u00e0 manger, salle de bain, toilettes.. La belle et lumineuse pi\u00e8ce de vie de 33 m\u00b2 vous permettra d'envisager une disposition agr\u00e9able de votre int\u00e9rieur.. Id\u00e9alement situ\u00e9 dans un secteur recherch\u00e9. Tr\u00e8s bon \u00e9tat.. Un garage situ\u00e9 en sous-sol compl\u00e8te cet appartement.. Contacter Agence ORPI au 02.23.44.37. 47.. 211000 euros Honoraires \u00e0 la charge du vendeur.", 14 | "phone": null, 15 | "photos": [{ 16 | "id": "1d9ks91ml67r2zwwcytkg3l4jh4yc8ii3y4fa64u8.jpg", 17 | "url": "https://v.seloger.com/s/width/800/visuels/1/d/9/k/1d9ks91ml67r2zwwcytkg3l4jh4yc8ii3y4fa64u8.jpg", 18 | "data": null 19 | }, { 20 | "id": "0a95gv0bukbrk77mhe0h4n14j9bx2zrkfikgh7h8g.jpg", 21 | "url": "https://v.seloger.com/s/width/800/visuels/0/a/9/5/0a95gv0bukbrk77mhe0h4n14j9bx2zrkfikgh7h8g.jpg", 22 | "data": null 23 | }, { 24 | "id": "1hd329lc8srsdh71o3iyo2tuv8jw9jutnctvqnv9c.jpg", 25 | "url": "https://v.seloger.com/s/width/800/visuels/1/h/d/3/1hd329lc8srsdh71o3iyo2tuv8jw9jutnctvqnv9c.jpg", 26 | "data": null 27 | }, { 28 | "id": "1lf8fyr5marcjalerkc914opcc29osb23z9c9648w.jpg", 29 | "url": "https://v.seloger.com/s/width/800/visuels/1/l/f/8/1lf8fyr5marcjalerkc914opcc29osb23z9c9648w.jpg", 30 | "data": null 31 | }, { 32 | "id": "1yrk6jbek3h7q3f9a3g1vy0kqc2uh7z4yckznrx8g.jpg", 33 | "url": "https://v.seloger.com/s/width/800/visuels/1/y/r/k/1yrk6jbek3h7q3f9a3g1vy0kqc2uh7z4yckznrx8g.jpg", 34 | "data": null 35 | }], 36 | "rooms": 3, 37 | "bedrooms": 2, 38 | "details": { 39 | "Box": "1", 40 | "Pi\u00e8ces": "3", 41 | "Etage": "RDC", 42 | "Reference": "114020E0PULC", 43 | "Chambres": "2", 44 | "Chauffage": "individuel", 45 | "Toilette": "1", 46 | "Salle de bain": "1", 47 | "Ascenseur": "", 48 | "Toilettes S\u00e9par\u00e9es": "", 49 | "Surface": "78 m\u00b2", 50 | "Salle \u00c0 Manger": "", 51 | "Salle de s\u00e9jour": "33 m\u00b2" 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /flatisfy/test_files/122509451@seloger.json: -------------------------------------------------------------------------------- 1 | { 2 | "id": "122509451@seloger", 3 | "url": "http://www.seloger.com/annonces/achat/appartement/rennes-35/cleunay-arsenal-redon/122509451.htm?p=", 4 | "title": "Appartement 3 pi\u00e8ces 78m\u00b2 - Rennes", 5 | "area": 78, 6 | "cost": 211000, 7 | "price_per_meter": 2705.128205128205128205128205, 8 | "currency": "\u20ac", 9 | "utilities": "", 10 | "date": "2018-01-19T22:39:00", 11 | "location": " Rennes (35000)", 12 | "station": "Arsenal - Redon", 13 | "text": "Appartement quartier Arsenal Redon, \u00e0 vendre, type 3 de 78 m\u00b2. Il se compose d'une entr\u00e9e, d'un salon-s\u00e9jour lumineux de 33 m\u00b2 orient\u00e9 sud donnant sur une terrasse, de deux chambres, d'une cuisine ind\u00e9pendante, d'une salle de bains et d'un toilette. Vous disposerez d'un garage ferm\u00e9. Situ\u00e9 entre le centre ville et la future station m\u00e9tro Mabilais (ligne B), proximit\u00e9 imm\u00e9diate des commerces, \u00e9coles.. Bien soumis au statut de la copropri\u00e9t\u00e9. Charges annuelles courantes: 962e Agence immobili\u00e8re ERA Rennes Aristide Briand Agent Commercial: Guillaume DE KERANFLECH RSAC: 818942955.", 14 | "phone": null, 15 | "photos": [{ 16 | "id": "0oj57y4pvtz7537ibvjq1agi9hrpctm96o30wknpc.jpg", 17 | "url": "https://v.seloger.com/s/width/800/visuels/0/o/j/5/0oj57y4pvtz7537ibvjq1agi9hrpctm96o30wknpc.jpg", 18 | "data": null 19 | }, { 20 | "id": "0s0kr6fw0hbqkwm5m2oxhi8yysk6mfxb9ctcrx2bk.jpg", 21 | "url": "https://v.seloger.com/s/width/800/visuels/0/s/0/k/0s0kr6fw0hbqkwm5m2oxhi8yysk6mfxb9ctcrx2bk.jpg", 22 | "data": null 23 | }, { 24 | "id": "0z8q9eq4rprqfymp0mpcezrq6kxp8uxknf5pgrx8g.jpg", 25 | "url": "https://v.seloger.com/s/width/800/visuels/0/z/8/q/0z8q9eq4rprqfymp0mpcezrq6kxp8uxknf5pgrx8g.jpg", 26 | "data": null 27 | }, { 28 | "id": "01ti2ovzcuyx4e14qfqqgatynges1grnalb4eau4g.jpg", 29 | "url": "https://v.seloger.com/s/width/800/visuels/0/1/t/i/01ti2ovzcuyx4e14qfqqgatynges1grnalb4eau4g.jpg", 30 | "data": null 31 | }, { 32 | "id": "250ckvp15x8eeetuynem2kj7x8z12y66kay9okf0g.jpg", 33 | "url": "https://v.seloger.com/s/width/800/visuels/2/5/0/c/250ckvp15x8eeetuynem2kj7x8z12y66kay9okf0g.jpg", 34 | "data": null 35 | }], 36 | "rooms": 3, 37 | "bedrooms": 2, 38 | "details": { 39 | "Box": "1", 40 | "Cuisine": "s\u00e9par\u00e9e", 41 | "Pi\u00e8ces": "3", 42 | "Etage": "RDC", 43 | "Reference": "872GK-01", 44 | "Chambres": "2", 45 | "Chauffage": "individuel", 46 | "Entr\u00e9e": "", 47 | "Surface": "78 m\u00b2", 48 | "Terrasse": "1", 49 | "Etages": "5", 50 | "Salle de S\u00e9jour": "" 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /flatisfy/web/js_src/i18n/fr/index.js: -------------------------------------------------------------------------------- 1 | export default { 2 | common: { 3 | 'flats': 'appartement | appartements', 4 | 'loading': 'Chargement…', 5 | 'Actions': 'Actions', 6 | 'More_about': 'Plus sur', 7 | 'Remove': 'Enlever', 8 | 'Restore': 'Remettre', 9 | 'Original_post': 'Annonce originale | Annonces originales', 10 | 'Original_post_for': 'Annonce originale pour', 11 | 'Follow': 'Suivre', 12 | 'Unfollow': 'Arrêter de suivre', 13 | 'Close': 'Fermer', 14 | 'sortUp': 'Trier par ordre croissant', 15 | 'sortDown': 'Trier par ordre décroissant', 16 | 'mins': 'min | mins', 17 | 'Unknown': 'Inconnu', 18 | 'expired': 'expiré' 19 | }, 20 | home: { 21 | 'new_available_flats': 'Nouveaux appartements disponibles', 22 | 'Last_update': 'Dernière mise à jour :', 23 | 'show_expired_flats': 'Montrer les annonces expirées' 24 | }, 25 | flatListing: { 26 | 'no_available_flats': 'Pas d\'appartement disponible.', 27 | 'no_matching_flats': 'Pas d\'appartement correspondant.' 28 | }, 29 | menu: { 30 | 'available_flats': 'Appartements disponibles', 31 | 'followed_flats': 'Appartements suivis', 32 | 'by_status': 'Appartements par statut', 33 | 'search': 'Rechercher' 34 | }, 35 | flatsDetails: { 36 | 'Notation': 'Note', 37 | 'Title': 'Titre', 38 | 'Area': 'Surface', 39 | 'Rooms': 'Pièces', 40 | 'Cost': 'Coût', 41 | 'SquareMeterCost': 'Coût / m²', 42 | 'utilities_included': '(charges comprises)', 43 | 'utilities_excluded': '(charges non comprises)', 44 | 'Description': 'Description', 45 | 'First_posted': 'Posté pour la première fois', 46 | 'Details': 'Détails', 47 | 'Metadata': 'Metadonnées', 48 | 'postal_code': 'Code postal', 49 | 'nearby_stations': 'Stations proches', 50 | 'Times_to': 'Temps jusqu\'à', 51 | 'Location': 'Localisation', 52 | 'Notes': 'Notes', 53 | 'Save': 'Sauvegarder', 54 | 'Contact': 'Contact', 55 | 'Visit': 'Visite', 56 | 'setDateOfVisit': 'Entrer une date de visite', 57 | 'no_phone_found': 'Pas de numéro de téléphone trouvé', 58 | 'rooms': 'pièce | pièces', 59 | 'bedrooms': 'chambre | chambres' 60 | }, 61 | status: { 62 | 'new': 'nouveau', 63 | 'followed': 'suivi', 64 | 'ignored': 'ignoré', 65 | 'user_deleted': 'effacé', 66 | 'duplicate': 'en double' 67 | }, 68 | slider: { 69 | 'Fullscreen_photo': 'Photo en plein écran' 70 | }, 71 | search: { 72 | 'input_placeholder': 'Tapez n\'importe quoi à rechercher…', 73 | 'Search': 'Chercher !' 74 | } 75 | } 76 | -------------------------------------------------------------------------------- /flatisfy/web/dbplugin.py: -------------------------------------------------------------------------------- 1 | # coding: utf-8 2 | """ 3 | This module contains a Bottle plugin to pass the database argument to any route 4 | which needs it. 5 | 6 | This module is heavily based on code from 7 | [Bottle-SQLAlchemy](https://github.com/iurisilvio/bottle-sqlalchemy) which is 8 | licensed under MIT license. 9 | """ 10 | from __future__ import absolute_import, division, print_function, unicode_literals 11 | 12 | import inspect 13 | 14 | import bottle 15 | 16 | 17 | class DatabasePlugin(object): 18 | """ 19 | A Bottle plugin to automatically pass an SQLAlchemy database session object 20 | to the routes specifying they need it. 21 | """ 22 | 23 | name = "database" 24 | api = 2 25 | KEYWORD = "db" 26 | 27 | def __init__(self, get_session): 28 | """ 29 | :param create_session: SQLAlchemy session maker created with the 30 | 'sessionmaker' function. Will create its own if undefined. 31 | """ 32 | self.get_session = get_session 33 | 34 | def setup(self, app): # pylint: disable=locally-disabled,no-self-use 35 | """ 36 | Make sure that other installed plugins don't affect the same 37 | keyword argument and check if metadata is available. 38 | """ 39 | for other in app.plugins: 40 | if not isinstance(other, DatabasePlugin): 41 | continue 42 | else: 43 | raise bottle.PluginError("Found another conflicting Database plugin.") 44 | 45 | def apply(self, callback, route): 46 | """ 47 | Method called on route invocation. Should apply some transformations to 48 | the route prior to returing it. 49 | 50 | We check the presence of ``self.KEYWORD`` in the route signature and 51 | replace the route callback by a partial invocation where we replaced 52 | this argument by a valid SQLAlchemy session. 53 | """ 54 | # Check whether the route needs a valid db session or not. 55 | try: 56 | callback_args = inspect.signature(route.callback).parameters 57 | except AttributeError: 58 | # inspect.signature does not exist on older Python 59 | callback_args = inspect.getargspec(route.callback).args 60 | 61 | if self.KEYWORD not in callback_args: 62 | # If no need for a db session, call the route callback 63 | return callback 64 | 65 | def wrapper(*args, **kwargs): 66 | """ 67 | Wrap the callback in a call to get_session. 68 | """ 69 | with self.get_session() as session: 70 | # Get a db session and pass it to the callback 71 | kwargs[self.KEYWORD] = session 72 | return callback(*args, **kwargs) 73 | 74 | return wrapper 75 | 76 | 77 | Plugin = DatabasePlugin 78 | -------------------------------------------------------------------------------- /modules/pap/module.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | # Copyright(C) 2012 Romain Bignon 4 | # 5 | # This file is part of a woob module. 6 | # 7 | # This woob module is free software: you can redistribute it and/or modify 8 | # it under the terms of the GNU Affero General Public License as published by 9 | # the Free Software Foundation, either version 3 of the License, or 10 | # (at your option) any later version. 11 | # 12 | # This woob module is distributed in the hope that it will be useful, 13 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 14 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 15 | # GNU Affero General Public License for more details. 16 | # 17 | # You should have received a copy of the GNU Affero General Public License 18 | # along with this woob module. If not, see . 19 | 20 | 21 | from woob.capabilities.housing import (CapHousing, Housing, HousingPhoto, 22 | ADVERT_TYPES) 23 | from woob.tools.backend import Module 24 | from woob import __version__ as WOOB_VERSION 25 | 26 | from .browser import PapBrowser 27 | 28 | 29 | __all__ = ['PapModule'] 30 | 31 | 32 | class PapModule(Module, CapHousing): 33 | NAME = 'pap' 34 | MAINTAINER = u'Romain Bignon' 35 | EMAIL = 'romain@weboob.org' 36 | VERSION = WOOB_VERSION 37 | DESCRIPTION = 'French housing website' 38 | LICENSE = 'AGPLv3+' 39 | BROWSER = PapBrowser 40 | 41 | def search_housings(self, query): 42 | if(len(query.advert_types) == 1 and 43 | query.advert_types[0] == ADVERT_TYPES.PROFESSIONAL): 44 | # Pap is personal only 45 | return list() 46 | 47 | cities = ['%s' % c.id for c in query.cities if c.backend == self.name] 48 | if len(cities) == 0: 49 | return list() 50 | 51 | return self.browser.search_housings(query.type, cities, query.nb_rooms, 52 | query.area_min, query.area_max, 53 | query.cost_min, query.cost_max, 54 | query.house_types) 55 | 56 | def get_housing(self, housing): 57 | if isinstance(housing, Housing): 58 | id = housing.id 59 | else: 60 | id = housing 61 | housing = None 62 | 63 | return self.browser.get_housing(id, housing) 64 | 65 | def search_city(self, pattern): 66 | return self.browser.search_geo(pattern) 67 | 68 | def fill_photo(self, photo, fields): 69 | if 'data' in fields and photo.url and not photo.data: 70 | photo.data = self.browser.open(photo.url).content 71 | return photo 72 | 73 | def fill_housing(self, housing, fields): 74 | return self.browser.get_housing(housing.id, housing) 75 | 76 | OBJECTS = {HousingPhoto: fill_photo, Housing: fill_housing} 77 | -------------------------------------------------------------------------------- /modules/explorimmo/module.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | # Copyright(C) 2014 Bezleputh 4 | # 5 | # This file is part of a woob module. 6 | # 7 | # This woob module is free software: you can redistribute it and/or modify 8 | # it under the terms of the GNU Affero General Public License as published by 9 | # the Free Software Foundation, either version 3 of the License, or 10 | # (at your option) any later version. 11 | # 12 | # This woob module is distributed in the hope that it will be useful, 13 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 14 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 15 | # GNU Affero General Public License for more details. 16 | # 17 | # You should have received a copy of the GNU Affero General Public License 18 | # along with this woob module. If not, see . 19 | 20 | 21 | from woob.tools.backend import Module 22 | from woob.capabilities.housing import CapHousing, Housing, HousingPhoto 23 | from woob import __version__ as WOOB_VERSION 24 | 25 | from .browser import ExplorimmoBrowser 26 | 27 | 28 | __all__ = ['ExplorimmoModule'] 29 | 30 | 31 | class ExplorimmoModule(Module, CapHousing): 32 | NAME = 'explorimmo' 33 | DESCRIPTION = u'explorimmo website' 34 | MAINTAINER = u'Bezleputh' 35 | EMAIL = 'carton_ben@yahoo.fr' 36 | LICENSE = 'AGPLv3+' 37 | VERSION = WOOB_VERSION 38 | 39 | BROWSER = ExplorimmoBrowser 40 | 41 | def get_housing(self, housing): 42 | if isinstance(housing, Housing): 43 | id = housing.id 44 | else: 45 | id = housing 46 | housing = None 47 | housing = self.browser.get_housing(id, housing) 48 | return housing 49 | 50 | def search_city(self, pattern): 51 | return self.browser.get_cities(pattern) 52 | 53 | def search_housings(self, query): 54 | cities = ['%s' % c.id for c in query.cities if c.backend == self.name] 55 | if len(cities) == 0: 56 | return list() 57 | 58 | return self.browser.search_housings(query.type, cities, query.nb_rooms, 59 | query.area_min, query.area_max, 60 | query.cost_min, query.cost_max, 61 | query.house_types, 62 | query.advert_types) 63 | 64 | def fill_housing(self, housing, fields): 65 | if 'phone' in fields: 66 | housing.phone = self.browser.get_phone(housing.id) 67 | fields.remove('phone') 68 | 69 | if len(fields) > 0: 70 | self.browser.get_housing(housing.id, housing) 71 | 72 | return housing 73 | 74 | def fill_photo(self, photo, fields): 75 | if 'data' in fields and photo.url and not photo.data: 76 | photo.data = self.browser.open(photo.url).content 77 | return photo 78 | 79 | OBJECTS = {Housing: fill_housing, 80 | HousingPhoto: fill_photo, 81 | } 82 | -------------------------------------------------------------------------------- /flatisfy/test_files/124910113@seloger.json: -------------------------------------------------------------------------------- 1 | { 2 | "id": "124910113@seloger", 3 | "url": "http://www.seloger.com/annonces/achat/appartement/rennes-35/maurepas-patton/124910113.htm?p=", 4 | "title": "Appartement 3 pi\u00e8ces 65m\u00b2 - Rennes", 5 | "area": 65, 6 | "cost": 145275, 7 | "price_per_meter": 2235, 8 | "currency": "\u20ac", 9 | "utilities": "", 10 | "date": "2018-01-20T02:09:00", 11 | "location": "225 RUE DE FOUGERES Rennes (35700)", 12 | "station": "", 13 | "text": "Rennes en exclusivit\u00e9 rue de Foug\u00e8res - Grand Appartement 3 pi\u00e8ces avec Balcon dans une copropri\u00e9t\u00e9 avec ascenseur - Travaux \u00e0 pr\u00e9voir - 2 chambres - Cave et garage.", 14 | "phone": null, 15 | "photos": [{ 16 | "id": "17b055i7hici1wxr951unlycfs5rhai73sbnnv2ki.jpg", 17 | "url": "https://v.seloger.com/s/cdn/x/visuels/1/7/b/0/17b055i7hici1wxr951unlycfs5rhai73sbnnv2ki.jpg", 18 | "data": null 19 | }, { 20 | "id": "1s5t0lal78twswu22mahad9vtc75y3s5utuit2yte.jpg", 21 | "url": "https://v.seloger.com/s/cdn/x/visuels/1/s/5/t/1s5t0lal78twswu22mahad9vtc75y3s5utuit2yte.jpg", 22 | "data": null 23 | }, { 24 | "id": "282rrcholht5full009yb8a5k1xe2jx0yiwtqyite.jpg", 25 | "url": "https://v.seloger.com/s/cdn/x/visuels/2/8/2/r/282rrcholht5full009yb8a5k1xe2jx0yiwtqyite.jpg", 26 | "data": null 27 | }, { 28 | "id": "0wskjpe0511ak2ynzxual2qa0fp3bmz3ccaoqc5oi.jpg", 29 | "url": "https://v.seloger.com/s/cdn/x/visuels/0/w/s/k/0wskjpe0511ak2ynzxual2qa0fp3bmz3ccaoqc5oi.jpg", 30 | "data": null 31 | }, { 32 | "id": "0kfne4iignt712pcunkcu2u9e497vt6oi11l30hxe.jpg", 33 | "url": "https://v.seloger.com/s/cdn/x/visuels/0/k/f/n/0kfne4iignt712pcunkcu2u9e497vt6oi11l30hxe.jpg", 34 | "data": null 35 | }, { 36 | "id": "1jvyyiua1l843w1ohymxcbs9gj9zxvtfiajjfvwle.jpg", 37 | "url": "https://v.seloger.com/s/cdn/x/visuels/1/j/v/y/1jvyyiua1l843w1ohymxcbs9gj9zxvtfiajjfvwle.jpg", 38 | "data": null 39 | }, { 40 | "id": "1ihj8ufsfdxgfecq03c154hcsj5jo5ysts29wjnia.jpg", 41 | "url": "https://v.seloger.com/s/cdn/x/visuels/1/i/h/j/1ihj8ufsfdxgfecq03c154hcsj5jo5ysts29wjnia.jpg", 42 | "data": null 43 | }, { 44 | "id": "1g9yb1xe0bc8se0w8jys8ouiscpwer6y6lccd1ltu.jpg", 45 | "url": "https://v.seloger.com/s/cdn/x/visuels/1/g/9/y/1g9yb1xe0bc8se0w8jys8ouiscpwer6y6lccd1ltu.jpg", 46 | "data": null 47 | }], 48 | "rooms": 3, 49 | "bedrooms": 2, 50 | "details": { 51 | "Box": "1", 52 | "Pi\u00e8ces": "3", 53 | "Etage": "1", 54 | "Reference": "MT0135140", 55 | "Chambres": "2", 56 | "Salle d'eau": "1", 57 | "Cave": "", 58 | "Ascenseur": "", 59 | "Surface": "65 m\u00b2", 60 | "Balcon": "1", 61 | "Travaux \u00c0 Pr\u00e9voir": "", 62 | "Ann\u00e9e de construction": "1968", 63 | "Toilettes S\u00e9par\u00e9es": "", 64 | "Etages": "6", 65 | "Toilette": "1" 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /modules/seloger/module.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | # Copyright(C) 2012 Romain Bignon 4 | # 5 | # This file is part of a woob module. 6 | # 7 | # This woob module is free software: you can redistribute it and/or modify 8 | # it under the terms of the GNU Affero General Public License as published by 9 | # the Free Software Foundation, either version 3 of the License, or 10 | # (at your option) any later version. 11 | # 12 | # This woob module is distributed in the hope that it will be useful, 13 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 14 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 15 | # GNU Affero General Public License for more details. 16 | # 17 | # You should have received a copy of the GNU Affero General Public License 18 | # along with this woob module. If not, see . 19 | 20 | 21 | from woob.capabilities.housing import CapHousing, Housing, HousingPhoto 22 | from woob.tools.backend import Module 23 | from woob import __version__ as WOOB_VERSION 24 | 25 | from .browser import SeLogerBrowser 26 | 27 | 28 | __all__ = ['SeLogerModule'] 29 | 30 | 31 | class SeLogerModule(Module, CapHousing): 32 | NAME = 'seloger' 33 | MAINTAINER = u'Romain Bignon' 34 | EMAIL = 'romain@weboob.org' 35 | VERSION = WOOB_VERSION 36 | DESCRIPTION = 'French housing website' 37 | LICENSE = 'AGPLv3+' 38 | ICON = 'http://static.poliris.com/z/portail/svx/portals/sv6_gen/favicon.png' 39 | BROWSER = SeLogerBrowser 40 | 41 | def search_housings(self, query): 42 | cities = [c.id for c in query.cities if c.backend == self.name] 43 | if len(cities) == 0: 44 | return list([]) 45 | 46 | return self.browser.search_housings(query.type, cities, query.nb_rooms, 47 | query.area_min, query.area_max, 48 | query.cost_min, query.cost_max, 49 | query.house_types, 50 | query.advert_types) 51 | 52 | def get_housing(self, housing): 53 | if isinstance(housing, Housing): 54 | id = housing.id 55 | else: 56 | id = housing 57 | housing = None 58 | 59 | return self.browser.get_housing(id, housing) 60 | 61 | def search_city(self, pattern): 62 | return self.browser.search_geo(pattern) 63 | 64 | def fill_photo(self, photo, fields): 65 | if 'data' in fields and photo.url and not photo.data: 66 | photo.data = self.browser.open(photo.url).content 67 | return photo 68 | 69 | def fill_housing(self, housing, fields): 70 | 71 | if 'DPE' in fields or 'GES' in fields: 72 | housing = self.browser.get_housing_detail(housing) 73 | fields.remove('DPE') 74 | fields.remove('GES') 75 | 76 | if len(fields) > 0: 77 | housing = self.browser.get_housing(housing.id, housing) 78 | 79 | return housing 80 | 81 | OBJECTS = {HousingPhoto: fill_photo, Housing: fill_housing} 82 | -------------------------------------------------------------------------------- /flatisfy/web/js_src/views/search.vue: -------------------------------------------------------------------------------- 1 | 26 | 27 | 86 | 87 | 101 | -------------------------------------------------------------------------------- /modules/pap/browser.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | # Copyright(C) 2012 Romain Bignon 4 | # 5 | # This file is part of a woob module. 6 | # 7 | # This woob module is free software: you can redistribute it and/or modify 8 | # it under the terms of the GNU Affero General Public License as published by 9 | # the Free Software Foundation, either version 3 of the License, or 10 | # (at your option) any later version. 11 | # 12 | # This woob module is distributed in the hope that it will be useful, 13 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 14 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 15 | # GNU Affero General Public License for more details. 16 | # 17 | # You should have received a copy of the GNU Affero General Public License 18 | # along with this woob module. If not, see . 19 | 20 | 21 | from woob.browser import PagesBrowser, URL 22 | from woob.capabilities.housing import TypeNotSupported, POSTS_TYPES 23 | from woob.tools.compat import urlencode 24 | 25 | from .pages import HousingPage, CitiesPage 26 | from .constants import TYPES, RET 27 | 28 | 29 | __all__ = ['PapBrowser'] 30 | 31 | 32 | class PapBrowser(PagesBrowser): 33 | 34 | BASEURL = 'https://www.pap.fr' 35 | housing = URL('/annonces/(?P<_id>.*)', HousingPage) 36 | search_page = URL('/recherche') 37 | search_result_page = URL('/annonce/.*', HousingPage) 38 | cities = URL('/json/ac-geo\?q=(?P.*)', CitiesPage) 39 | 40 | def search_geo(self, pattern): 41 | return self.cities.open(pattern=pattern).iter_cities() 42 | 43 | def search_housings(self, type, cities, nb_rooms, area_min, area_max, cost_min, cost_max, house_types): 44 | 45 | if type not in TYPES: 46 | raise TypeNotSupported() 47 | 48 | self.session.headers.update({'Content-Type': 'application/x-www-form-urlencoded;charset=UTF-8'}) 49 | 50 | data = {'geo_objets_ids': ','.join(cities), 51 | 'surface[min]': area_min or '', 52 | 'surface[max]': area_max or '', 53 | 'prix[min]': cost_min or '', 54 | 'prix[max]': cost_max or '', 55 | 'produit': TYPES.get(type, 'location'), 56 | 'nb_resultats_par_page': 40, 57 | 'action': 'submit' 58 | } 59 | 60 | if nb_rooms: 61 | data['nb_pieces[min]'] = nb_rooms 62 | data['nb_pieces[max]'] = nb_rooms 63 | 64 | if type == POSTS_TYPES.FURNISHED_RENT: 65 | data['tags[]'] = 'meuble' 66 | 67 | ret = [] 68 | if type == POSTS_TYPES.VIAGER: 69 | ret = ['viager'] 70 | else: 71 | for house_type in house_types: 72 | if house_type in RET: 73 | ret.append(RET.get(house_type)) 74 | 75 | _data = '%s%s%s' % (urlencode(data), '&typesbien%5B%5D=', '&typesbien%5B%5D='.join(ret)) 76 | return self.search_page.go(data=_data).iter_housings( 77 | query_type=type 78 | ) 79 | 80 | def get_housing(self, _id, housing=None): 81 | return self.housing.go(_id=_id).get_housing(obj=housing) 82 | -------------------------------------------------------------------------------- /flatisfy/web/js_src/store/getters.js: -------------------------------------------------------------------------------- 1 | import { findFlatGPS, costFilter } from '../tools' 2 | 3 | export default { 4 | allFlats: (state) => state.flats, 5 | 6 | flat: (state, getters) => (id) => 7 | state.flats.find((flat) => flat.id === id), 8 | 9 | isLoading: (state) => state.loading > 0, 10 | 11 | inseeCodesFlatsBuckets: (state, getters) => (filter) => { 12 | const buckets = {} 13 | 14 | state.flats.forEach((flat) => { 15 | if (!filter || filter(flat)) { 16 | const insee = flat.flatisfy_postal_code.insee_code 17 | if (!buckets[insee]) { 18 | buckets[insee] = { 19 | name: flat.flatisfy_postal_code.name, 20 | flats: [] 21 | } 22 | } 23 | buckets[insee].flats.push(flat) 24 | } 25 | }) 26 | 27 | return buckets 28 | }, 29 | 30 | flatsMarkers: (state, getters) => (router, filter) => { 31 | const markers = [] 32 | state.flats.forEach((flat) => { 33 | if (filter && filter(flat)) { 34 | const gps = findFlatGPS(flat) 35 | 36 | if (gps) { 37 | const previousMarker = markers.find( 38 | (marker) => 39 | marker.gps[0] === gps[0] && marker.gps[1] === gps[1] 40 | ) 41 | if (previousMarker) { 42 | // randomize position a bit 43 | // gps[0] += (Math.random() - 0.5) / 500 44 | // gps[1] += (Math.random() - 0.5) / 500 45 | } 46 | const href = router.resolve({ 47 | name: 'details', 48 | params: { id: flat.id } 49 | }).href 50 | const cost = flat.cost 51 | ? costFilter(flat.cost, flat.currency) 52 | : '' 53 | markers.push({ 54 | title: '', 55 | content: 56 | '' + 59 | flat.title + 60 | '' + 61 | cost, 62 | gps: gps, 63 | flatId: flat.id 64 | }) 65 | } 66 | } 67 | }) 68 | 69 | return markers 70 | }, 71 | 72 | allTimeToPlaces: (state) => { 73 | const places = {} 74 | Object.keys(state.timeToPlaces).forEach((constraint) => { 75 | const constraintTimeToPlaces = state.timeToPlaces[constraint] 76 | Object.keys(constraintTimeToPlaces).forEach((name) => { 77 | places[name] = constraintTimeToPlaces[name] 78 | }) 79 | }) 80 | return places 81 | }, 82 | 83 | timeToPlaces: (state, getters) => (constraintName) => { 84 | return state.timeToPlaces[constraintName] 85 | }, 86 | 87 | metadata: (state) => state.metadata 88 | } 89 | -------------------------------------------------------------------------------- /flatisfy/test_files/123314207@seloger.json: -------------------------------------------------------------------------------- 1 | { 2 | "id": "123314207@seloger", 3 | "url": "http://www.seloger.com/annonces/achat/appartement/rennes-35/brequigny/123314207.htm?p=", 4 | "title": "Appartement 3 pi\u00e8ces 58m\u00b2 - Rennes", 5 | "area": 58, 6 | "cost": 131440, 7 | "price_per_meter": 2266.206896551724137931034483, 8 | "currency": "\u20ac", 9 | "utilities": "", 10 | "date": "2018-01-20T22:35:00", 11 | "location": " Rennes (35200)", 12 | "station": "Cl\u00e9menceau", 13 | "text": "OGIMM vous propose \u00e0 l'achat un appartement de type 3 au 1er \u00e9tage d'une petite r\u00e9sidence de 4 \u00e9tages. Au calme, propre, il est proche de la rue de Nantes, des Bus C5 et C3. La station de M\u00e9tro la plus proche est Cl\u00e9menceau. Vous aurez: une entr\u00e9e avec placards, une cuisine am\u00e9nag\u00e9e et \u00e9quip\u00e9e, un balcon loggia, une salle d'eau, un WC s\u00e9par\u00e9, 2 chambres, une cave et un parking. Les charges de copropri\u00e9t\u00e9 de 1526.58e par an comprennent le chauffage et l'eau chaude et froide avec comptage individuel. Locataire en place avec un loyer de 650e par mois. Copropri\u00e9t\u00e9 de 12 appartements. A voir vite ! Dont 6.00 % honoraires TTC \u00e0 la charge de l'acqu\u00e9reur.", 14 | "phone": null, 15 | "photos": [{ 16 | "id": "18a4t9w050xd7welkm25tg5ytv0wjbflrkyun1p1c.jpg", 17 | "url": "https://v.seloger.com/s/width/800/visuels/1/8/a/4/18a4t9w050xd7welkm25tg5ytv0wjbflrkyun1p1c.jpg", 18 | "data": null 19 | }, { 20 | "id": "21q7r77zylh8k4mdlumg3cfcgmd4y3ixr9ggipk3k.jpg", 21 | "url": "https://v.seloger.com/s/width/800/visuels/2/1/q/7/21q7r77zylh8k4mdlumg3cfcgmd4y3ixr9ggipk3k.jpg", 22 | "data": null 23 | }, { 24 | "id": "0eysaqsq7ti47y42lakhzwr2s9jdkvwsvvoqfq8e8.jpg", 25 | "url": "https://v.seloger.com/s/width/800/visuels/0/e/y/s/0eysaqsq7ti47y42lakhzwr2s9jdkvwsvvoqfq8e8.jpg", 26 | "data": null 27 | }, { 28 | "id": "02tt2n650l5m908yiqkre3vu0cl9cxwqtg26xtwqo.jpg", 29 | "url": "https://v.seloger.com/s/width/800/visuels/0/2/t/t/02tt2n650l5m908yiqkre3vu0cl9cxwqtg26xtwqo.jpg", 30 | "data": null 31 | }, { 32 | "id": "03wsh6bojie9eunp1ef9tynop2zkanx1qgm6lq41s.jpg", 33 | "url": "https://v.seloger.com/s/width/800/visuels/0/3/w/s/03wsh6bojie9eunp1ef9tynop2zkanx1qgm6lq41s.jpg", 34 | "data": null 35 | }, { 36 | "id": "170whetachmm8357xz30ll7e3flrrqedc3ld2u0hs.jpg", 37 | "url": "https://v.seloger.com/s/width/800/visuels/1/7/0/w/170whetachmm8357xz30ll7e3flrrqedc3ld2u0hs.jpg", 38 | "data": null 39 | }, { 40 | "id": "1unpbelnbrnsxxoxy0zd0me8nf4jgd124yomnbvnk.jpg", 41 | "url": "https://v.seloger.com/s/width/800/visuels/1/u/n/p/1unpbelnbrnsxxoxy0zd0me8nf4jgd124yomnbvnk.jpg", 42 | "data": null 43 | }], 44 | "rooms": 3, 45 | "bedrooms": 2, 46 | "details": { 47 | "Pi\u00e8ces": "3", 48 | "Etage": "1", 49 | "Reference": "OG9243", 50 | "Chambres": "2", 51 | "Salle d'eau": "1", 52 | "Chauffage": "radiateur", 53 | "Entr\u00e9e": "", 54 | "Surface": "58 m\u00b2", 55 | "Ann\u00e9e de construction": "1963", 56 | "Calme": "", 57 | "Etages": "4", 58 | "Rangements": "", 59 | "Toilette": "1", 60 | "Orientation": "Est, Sud" 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /flatisfy/test_files/123312807@seloger.json: -------------------------------------------------------------------------------- 1 | { 2 | "id": "123312807@seloger", 3 | "url": "http://www.seloger.com/annonces/achat/appartement/rennes-35/brequigny/123312807.htm?p=", 4 | "title": "Appartement 3 pi\u00e8ces 58m\u00b2 - Rennes", 5 | "area": 58, 6 | "cost": 131440, 7 | "price_per_meter": 2266.206896551724137931034483, 8 | "currency": "\u20ac", 9 | "utilities": "", 10 | "date": "2018-01-20T22:35:00", 11 | "location": " Rennes (35200)", 12 | "station": "Cl\u00e9menceau", 13 | "text": "OGIMM vous propose \u00e0 l'achat un appartement de type 3 dans une petite copropri\u00e9t\u00e9 de 4 \u00e9tages. Bien situ\u00e9, proche du boulevard Cl\u00e9menceau et des Bus C5 et C3, de la rue de Nantes, il est en tr\u00e8s bon \u00e9tat et au calme. Il est compos\u00e9 de: une entr\u00e9e avec placards, une cuisine s\u00e9par\u00e9e am\u00e9nag\u00e9e et \u00e9quip\u00e9e (possibilit\u00e9 d'ouverture), d'un balcon loggia, d'un s\u00e9jour lumineux au sud, de 2 chambres, d'une salle d'eau et d'un WC s\u00e9par\u00e9. Pr\u00e9sence d'une cave et d'un parking ext\u00e9rieur. Station de M\u00e9tro la plus proche Cl\u00e9menceau. Copropri\u00e9t\u00e9 saine et bien tenue, les charges de 1745.88e par an comprenant le chauffage (avec compteur individuel), l'eau chaude et froide, et l'entretien de l'immeuble. Copropri\u00e9t\u00e9 de 16 appartements. Actuellement lou\u00e9 650e charges comprises. A voir rapidement ! Dont 6.00 % honoraires TTC \u00e0 la charge de l'acqu\u00e9reur.", 14 | "phone": null, 15 | "photos": [{ 16 | "id": "1ir7ortudferww8to788kd38lmlnpx52ia5st7280.jpg", 17 | "url": "https://v.seloger.com/s/width/800/visuels/1/i/r/7/1ir7ortudferww8to788kd38lmlnpx52ia5st7280.jpg", 18 | "data": null 19 | }, { 20 | "id": "08wbr1ivnz26gnyeofyjg02zi0d1vd1eijszcrgg0.jpg", 21 | "url": "https://v.seloger.com/s/width/800/visuels/0/8/w/b/08wbr1ivnz26gnyeofyjg02zi0d1vd1eijszcrgg0.jpg", 22 | "data": null 23 | }, { 24 | "id": "0np6439w3557sclwu7b4sq7h7hntm9tizwrrtdr7k.jpg", 25 | "url": "https://v.seloger.com/s/width/800/visuels/0/n/p/6/0np6439w3557sclwu7b4sq7h7hntm9tizwrrtdr7k.jpg", 26 | "data": null 27 | }, { 28 | "id": "0rc6ac2jlit0r27d1tmy2y8pqbdzps7gnzzmdds00.jpg", 29 | "url": "https://v.seloger.com/s/width/800/visuels/0/r/c/6/0rc6ac2jlit0r27d1tmy2y8pqbdzps7gnzzmdds00.jpg", 30 | "data": null 31 | }, { 32 | "id": "19ebzllpk308rw1ei43a0t59fnjxohnidtvc5thq8.jpg", 33 | "url": "https://v.seloger.com/s/width/800/visuels/1/9/e/b/19ebzllpk308rw1ei43a0t59fnjxohnidtvc5thq8.jpg", 34 | "data": null 35 | }, { 36 | "id": "07ize6lu9ssyv1ltjiux8gs56rgbyweai9wboor9c.jpg", 37 | "url": "https://v.seloger.com/s/width/800/visuels/0/7/i/z/07ize6lu9ssyv1ltjiux8gs56rgbyweai9wboor9c.jpg", 38 | "data": null 39 | }], 40 | "rooms": 3, 41 | "bedrooms": 2, 42 | "details": { 43 | "Cuisine": "s\u00e9par\u00e9e", 44 | "Pi\u00e8ces": "3", 45 | "Salle de S\u00e9jour": "", 46 | "Reference": "OG9242", 47 | "Chambres": "2", 48 | "Salle d'eau": "1", 49 | "Entr\u00e9e": "", 50 | "Balcon": "1", 51 | "Surface": "58 m\u00b2", 52 | "Ann\u00e9e de construction": "1963", 53 | "Calme": "", 54 | "Etages": "4", 55 | "Parking": "1", 56 | "Rangements": "", 57 | "Toilette": "1", 58 | "Orientation": "Est, Sud" 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /modules/foncia/test.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | # Copyright(C) 2017 Phyks (Lucas Verney) 4 | # 5 | # This file is part of a woob module. 6 | # 7 | # This woob module is free software: you can redistribute it and/or modify 8 | # it under the terms of the GNU Affero General Public License as published by 9 | # the Free Software Foundation, either version 3 of the License, or 10 | # (at your option) any later version. 11 | # 12 | # This woob module is distributed in the hope that it will be useful, 13 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 14 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 15 | # GNU Affero General Public License for more details. 16 | # 17 | # You should have received a copy of the GNU Affero General Public License 18 | # along with this woob module. If not, see . 19 | 20 | from __future__ import unicode_literals 21 | 22 | from woob.capabilities.housing import ( 23 | Query, POSTS_TYPES, ADVERT_TYPES 24 | ) 25 | from woob.tools.capabilities.housing.housing_test import HousingTest 26 | from woob.tools.test import BackendTest 27 | 28 | 29 | class FonciaTest(BackendTest, HousingTest): 30 | MODULE = 'foncia' 31 | 32 | FIELDS_ALL_HOUSINGS_LIST = [ 33 | "id", "type", "advert_type", "house_type", "url", "title", "area", 34 | "cost", "currency", "date", "location", "text", "details" 35 | ] 36 | FIELDS_ANY_HOUSINGS_LIST = [ 37 | "photos", 38 | "rooms" 39 | ] 40 | FIELDS_ALL_SINGLE_HOUSING = [ 41 | "id", "url", "type", "advert_type", "house_type", "title", "area", 42 | "cost", "currency", "utilities", "date", "location", "text", "phone", 43 | "DPE", "details" 44 | ] 45 | FIELDS_ANY_SINGLE_HOUSING = [ 46 | "bedrooms", 47 | "photos", 48 | "rooms" 49 | ] 50 | 51 | def test_foncia_rent(self): 52 | query = Query() 53 | query.area_min = 20 54 | query.cost_max = 1500 55 | query.type = POSTS_TYPES.RENT 56 | query.cities = [] 57 | for city in self.backend.search_city('paris'): 58 | city.backend = self.backend.name 59 | query.cities.append(city) 60 | self.check_against_query(query) 61 | 62 | def test_foncia_sale(self): 63 | query = Query() 64 | query.area_min = 20 65 | query.type = POSTS_TYPES.SALE 66 | query.cities = [] 67 | for city in self.backend.search_city('paris'): 68 | city.backend = self.backend.name 69 | query.cities.append(city) 70 | self.check_against_query(query) 71 | 72 | def test_foncia_furnished_rent(self): 73 | query = Query() 74 | query.area_min = 20 75 | query.cost_max = 1500 76 | query.type = POSTS_TYPES.FURNISHED_RENT 77 | query.cities = [] 78 | for city in self.backend.search_city('paris'): 79 | city.backend = self.backend.name 80 | query.cities.append(city) 81 | self.check_against_query(query) 82 | 83 | def test_foncia_personal(self): 84 | query = Query() 85 | query.area_min = 20 86 | query.cost_max = 900 87 | query.type = POSTS_TYPES.RENT 88 | query.advert_types = [ADVERT_TYPES.PERSONAL] 89 | query.cities = [] 90 | for city in self.backend.search_city('paris'): 91 | city.backend = self.backend.name 92 | query.cities.append(city) 93 | 94 | results = list(self.backend.search_housings(query)) 95 | self.assertEqual(len(results), 0) 96 | -------------------------------------------------------------------------------- /modules/seloger/browser.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | # Copyright(C) 2012 Romain Bignon 4 | # 5 | # This file is part of a woob module. 6 | # 7 | # This woob module is free software: you can redistribute it and/or modify 8 | # it under the terms of the GNU Affero General Public License as published by 9 | # the Free Software Foundation, either version 3 of the License, or 10 | # (at your option) any later version. 11 | # 12 | # This woob module is distributed in the hope that it will be useful, 13 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 14 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 15 | # GNU Affero General Public License for more details. 16 | # 17 | # You should have received a copy of the GNU Affero General Public License 18 | # along with this woob module. If not, see . 19 | 20 | from woob.capabilities.housing import TypeNotSupported, POSTS_TYPES 21 | 22 | from woob.browser import PagesBrowser, URL 23 | from .pages import SearchResultsPage, HousingPage, CitiesPage, ErrorPage, HousingJsonPage 24 | from woob.browser.profiles import Android 25 | 26 | from .constants import TYPES, RET 27 | 28 | __all__ = ['SeLogerBrowser'] 29 | 30 | 31 | class SeLogerBrowser(PagesBrowser): 32 | BASEURL = 'https://www.seloger.com' 33 | PROFILE = Android() 34 | cities = URL(r'https://autocomplete.svc.groupe-seloger.com/auto/complete/0/Ville/6\?text=(?P.*)', 35 | CitiesPage) 36 | search = URL(r'/list.html\?(?P.*)&LISTING-LISTpg=(?P\d+)', SearchResultsPage) 37 | housing = URL(r'/(?P<_id>.+)/detail.htm', 38 | r'/annonces/.+', 39 | HousingPage) 40 | housing_detail = URL(r'detail,json,caracteristique_bien.json\?idannonce=(?P<_id>\d+)', HousingJsonPage) 41 | captcha = URL(r'http://validate.perfdrive.com', ErrorPage) 42 | 43 | def search_geo(self, pattern): 44 | return self.cities.open(pattern=pattern).iter_cities() 45 | 46 | def search_housings(self, _type, cities, nb_rooms, area_min, area_max, 47 | cost_min, cost_max, house_types, advert_types): 48 | 49 | price = '{}/{}'.format(cost_min or 'NaN', cost_max or 'Nan') 50 | surface = '{}/{}'.format(area_min or 'Nan', area_max or 'Nan') 51 | 52 | rooms = '' 53 | if nb_rooms: 54 | rooms = '&rooms={}'.format(nb_rooms if nb_rooms <= 5 else 5) 55 | 56 | viager = "" 57 | if _type not in TYPES: 58 | raise TypeNotSupported() 59 | elif _type != POSTS_TYPES.VIAGER: 60 | _type = '{}'.format(TYPES.get(_type)) 61 | viager = "&natures=1,2,4" 62 | else: 63 | _type = TYPES.get(_type) 64 | 65 | places = '|'.join(['{{ci:{}}}'.format(c) for c in cities]) 66 | places = '[{}]'.format(places) 67 | 68 | ret = ','.join([RET.get(t) for t in house_types if t in RET]) 69 | 70 | query = "projects={}{}&places={}&types={}&price={}&surface={}{}&enterprise=0&qsVersion=1.0"\ 71 | .format(_type, 72 | viager, 73 | places, 74 | ret, 75 | price, 76 | surface, 77 | rooms) 78 | 79 | return self.search.go(query=query, page_number=1).iter_housings(query_type=_type, advert_types=advert_types, ret=ret) 80 | 81 | def get_housing(self, _id, obj=None): 82 | return self.housing.go(_id=_id).get_housing(obj=obj) 83 | 84 | def get_housing_detail(self, obj): 85 | return self.housing_detail.go(_id=obj.id).get_housing(obj=obj) 86 | -------------------------------------------------------------------------------- /modules/logicimmo/module.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | # Copyright(C) 2014 Bezleputh 4 | # 5 | # This file is part of a woob module. 6 | # 7 | # This woob module is free software: you can redistribute it and/or modify 8 | # it under the terms of the GNU Affero General Public License as published by 9 | # the Free Software Foundation, either version 3 of the License, or 10 | # (at your option) any later version. 11 | # 12 | # This woob module is distributed in the hope that it will be useful, 13 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 14 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 15 | # GNU Affero General Public License for more details. 16 | # 17 | # You should have received a copy of the GNU Affero General Public License 18 | # along with this woob module. If not, see . 19 | 20 | 21 | from woob.tools.backend import Module 22 | from woob.capabilities.housing import (CapHousing, Housing, HousingPhoto, 23 | ADVERT_TYPES) 24 | from woob.capabilities.base import UserError 25 | from woob import __version__ as WOOB_VERSION 26 | from .browser import LogicimmoBrowser 27 | 28 | 29 | __all__ = ['LogicimmoModule'] 30 | 31 | 32 | class LogicImmoCitiesError(UserError): 33 | """ 34 | Raised when more than 3 cities are selected 35 | """ 36 | def __init__(self, msg='You cannot select more than three cities'): 37 | UserError.__init__(self, msg) 38 | 39 | 40 | class LogicimmoModule(Module, CapHousing): 41 | NAME = 'logicimmo' 42 | DESCRIPTION = u'logicimmo website' 43 | MAINTAINER = u'Bezleputh' 44 | EMAIL = 'carton_ben@yahoo.fr' 45 | LICENSE = 'AGPLv3+' 46 | VERSION = WOOB_VERSION 47 | 48 | BROWSER = LogicimmoBrowser 49 | 50 | def get_housing(self, housing): 51 | if isinstance(housing, Housing): 52 | id = housing.id 53 | else: 54 | id = housing 55 | housing = None 56 | housing = self.browser.get_housing(id, housing) 57 | return housing 58 | 59 | def search_city(self, pattern): 60 | return self.browser.get_cities(pattern) 61 | 62 | def search_housings(self, query): 63 | if(len(query.advert_types) == 1 and 64 | query.advert_types[0] == ADVERT_TYPES.PERSONAL): 65 | # Logic-immo is pro only 66 | return list() 67 | 68 | cities_names = ['%s' % c.name.replace(' ', '-') for c in query.cities if c.backend == self.name] 69 | cities_ids = ['%s' % c.id for c in query.cities if c.backend == self.name] 70 | 71 | if len(cities_names) == 0: 72 | return list() 73 | 74 | if len(cities_names) > 3: 75 | raise LogicImmoCitiesError() 76 | 77 | cities = ','.join(cities_names + cities_ids) 78 | return self.browser.search_housings(query.type, cities.lower(), query.nb_rooms, 79 | query.area_min, query.area_max, 80 | query.cost_min, query.cost_max, 81 | query.house_types) 82 | 83 | def fill_housing(self, housing, fields): 84 | if 'phone' in fields: 85 | housing.phone = self.browser.get_phone(housing.id) 86 | fields.remove('phone') 87 | 88 | if len(fields) > 0: 89 | self.browser.get_housing(housing.id, housing) 90 | 91 | return housing 92 | 93 | def fill_photo(self, photo, fields): 94 | if 'data' in fields and photo.url and not photo.data: 95 | photo.data = self.browser.open(photo.url).content 96 | return photo 97 | 98 | OBJECTS = {Housing: fill_housing, 99 | HousingPhoto: fill_photo, 100 | } 101 | -------------------------------------------------------------------------------- /modules/explorimmo/browser.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | # Copyright(C) 2014 Bezleputh 4 | # 5 | # This file is part of a woob module. 6 | # 7 | # This woob module is free software: you can redistribute it and/or modify 8 | # it under the terms of the GNU Affero General Public License as published by 9 | # the Free Software Foundation, either version 3 of the License, or 10 | # (at your option) any later version. 11 | # 12 | # This woob module is distributed in the hope that it will be useful, 13 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 14 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 15 | # GNU Affero General Public License for more details. 16 | # 17 | # You should have received a copy of the GNU Affero General Public License 18 | # along with this woob module. If not, see . 19 | 20 | from woob.browser import PagesBrowser, URL 21 | from woob.capabilities.housing import (TypeNotSupported, POSTS_TYPES, 22 | HOUSE_TYPES) 23 | from woob.tools.compat import urlencode 24 | from .pages import CitiesPage, SearchPage, HousingPage, HousingPage2, PhonePage 25 | 26 | 27 | class ExplorimmoBrowser(PagesBrowser): 28 | BASEURL = 'https://immobilier.lefigaro.fr' 29 | 30 | cities = URL('/rest/locations\?q=(?P.*)', CitiesPage) 31 | search = URL('/annonces/resultat/annonces.html\?(?P.*)', SearchPage) 32 | housing_html = URL('/annonces/annonce-(?P<_id>.*).html', HousingPage) 33 | phone = URL('/rest/classifieds/(?P<_id>.*)/phone', PhonePage) 34 | housing = URL('/rest/classifieds/(?P<_id>.*)', 35 | '/rest/classifieds/\?(?P.*)', HousingPage2) 36 | 37 | TYPES = {POSTS_TYPES.RENT: 'location', 38 | POSTS_TYPES.SALE: 'vente', 39 | POSTS_TYPES.FURNISHED_RENT: 'location', 40 | POSTS_TYPES.VIAGER: 'vente'} 41 | 42 | RET = {HOUSE_TYPES.HOUSE: 'Maison', 43 | HOUSE_TYPES.APART: 'Appartement', 44 | HOUSE_TYPES.LAND: 'Terrain', 45 | HOUSE_TYPES.PARKING: 'Parking', 46 | HOUSE_TYPES.OTHER: 'Divers'} 47 | 48 | def get_cities(self, pattern): 49 | return self.cities.open(city=pattern).get_cities() 50 | 51 | def search_housings(self, type, cities, nb_rooms, area_min, area_max, 52 | cost_min, cost_max, house_types, advert_types): 53 | 54 | if type not in self.TYPES: 55 | raise TypeNotSupported() 56 | 57 | ret = [] 58 | if type == POSTS_TYPES.VIAGER: 59 | ret = ['Viager'] 60 | else: 61 | for house_type in house_types: 62 | if house_type in self.RET: 63 | ret.append(self.RET.get(house_type)) 64 | 65 | data = {'location': ','.join(cities).encode('iso 8859-1'), 66 | 'furnished': type == POSTS_TYPES.FURNISHED_RENT, 67 | 'areaMin': area_min or '', 68 | 'areaMax': area_max or '', 69 | 'priceMin': cost_min or '', 70 | 'priceMax': cost_max or '', 71 | 'transaction': self.TYPES.get(type, 'location'), 72 | 'recherche': '', 73 | 'mode': '', 74 | 'proximity': '0', 75 | 'roomMin': nb_rooms or '', 76 | 'page': '1'} 77 | 78 | query = u'%s%s%s' % (urlencode(data), '&type=', '&type='.join(ret)) 79 | 80 | return self.search.go(query=query).iter_housings( 81 | query_type=type, 82 | advert_types=advert_types 83 | ) 84 | 85 | def get_housing(self, _id, housing=None): 86 | return self.housing.go(_id=_id).get_housing(obj=housing) 87 | 88 | def get_phone(self, _id): 89 | return self.phone.go(_id=_id).get_phone() 90 | 91 | def get_total_page(self, js_datas): 92 | return self.housing.open(js_datas=js_datas).get_total_page() 93 | -------------------------------------------------------------------------------- /flatisfy/data.py: -------------------------------------------------------------------------------- 1 | # coding: utf-8 2 | """ 3 | This module contains all the code related to building necessary data files from 4 | the source opendata files. 5 | """ 6 | from __future__ import absolute_import, print_function, unicode_literals 7 | 8 | import logging 9 | 10 | import flatisfy.exceptions 11 | 12 | from flatisfy import database 13 | from flatisfy import data_files 14 | from flatisfy.models.postal_code import PostalCode 15 | from flatisfy.models.public_transport import PublicTransport 16 | from flatisfy.tools import hash_dict 17 | 18 | LOGGER = logging.getLogger(__name__) 19 | 20 | # Try to load lru_cache 21 | try: 22 | from functools import lru_cache 23 | except ImportError: 24 | try: 25 | from functools32 import lru_cache 26 | except ImportError: 27 | 28 | def lru_cache(maxsize=None): # pylint: disable=unused-argument 29 | """ 30 | Identity implementation of ``lru_cache`` for fallback. 31 | """ 32 | return lambda func: func 33 | 34 | LOGGER.warning( 35 | "`functools.lru_cache` is not available on your system. Consider " 36 | "installing `functools32` Python module if using Python2 for " 37 | "better performances." 38 | ) 39 | 40 | 41 | def preprocess_data(config, force=False): 42 | """ 43 | Ensures that all the necessary data have been inserted in db from the raw 44 | opendata files. 45 | 46 | :params config: A config dictionary. 47 | :params force: Whether to force rebuild or not. 48 | :return bool: Whether data have been built or not. 49 | """ 50 | # Check if a build is required 51 | get_session = database.init_db(config["database"], config["search_index"]) 52 | with get_session() as session: 53 | is_built = session.query(PublicTransport).count() > 0 and session.query(PostalCode).count() > 0 54 | if is_built and not force: 55 | # No need to rebuild the database, skip 56 | return False 57 | # Otherwise, purge all existing data 58 | session.query(PublicTransport).delete() 59 | session.query(PostalCode).delete() 60 | 61 | # Build all opendata files 62 | LOGGER.info("Rebuilding data...") 63 | for preprocess in data_files.PREPROCESSING_FUNCTIONS: 64 | data_objects = preprocess() 65 | if not data_objects: 66 | raise flatisfy.exceptions.DataBuildError("Error with %s." % preprocess.__name__) 67 | with get_session() as session: 68 | session.add_all(data_objects) 69 | LOGGER.info("Done building data!") 70 | return True 71 | 72 | 73 | @hash_dict 74 | @lru_cache(maxsize=5) 75 | def load_data(model, constraint, config): 76 | """ 77 | Load data of the specified model from the database. Only load data for the 78 | specific areas of the postal codes in config. 79 | 80 | :param model: SQLAlchemy model to load. 81 | :param constraint: A constraint from configuration to limit the spatial 82 | extension of the loaded data. 83 | :param config: A config dictionary. 84 | :returns: A list of loaded SQLAlchemy objects from the db 85 | """ 86 | get_session = database.init_db(config["database"], config["search_index"]) 87 | results = [] 88 | with get_session() as session: 89 | areas = [] 90 | # Get areas to fetch from, using postal codes 91 | for postal_code in constraint["postal_codes"]: 92 | areas.append(data_files.french_postal_codes_to_quarter(postal_code)) 93 | # Load data for each area 94 | areas = list(set(areas)) 95 | for area in areas: 96 | results.extend(session.query(model).filter(model.area == area).all()) 97 | # Expunge loaded data from the session to be able to use them 98 | # afterwards 99 | session.expunge_all() 100 | return results 101 | -------------------------------------------------------------------------------- /modules/explorimmo/test.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | # Copyright(C) 2014 Bezleputh 4 | # 5 | # This file is part of a woob module. 6 | # 7 | # This woob module is free software: you can redistribute it and/or modify 8 | # it under the terms of the GNU Affero General Public License as published by 9 | # the Free Software Foundation, either version 3 of the License, or 10 | # (at your option) any later version. 11 | # 12 | # This woob module is distributed in the hope that it will be useful, 13 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 14 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 15 | # GNU Affero General Public License for more details. 16 | # 17 | # You should have received a copy of the GNU Affero General Public License 18 | # along with this woob module. If not, see . 19 | 20 | from woob.capabilities.housing import Query, ADVERT_TYPES, POSTS_TYPES 21 | from woob.tools.capabilities.housing.housing_test import HousingTest 22 | from woob.tools.test import BackendTest 23 | 24 | 25 | class ExplorimmoTest(BackendTest, HousingTest): 26 | MODULE = 'explorimmo' 27 | 28 | FIELDS_ALL_HOUSINGS_LIST = [ 29 | "id", "type", "advert_type", "house_type", "title", "location", 30 | "utilities", "text", "area", "url" 31 | ] 32 | FIELDS_ANY_HOUSINGS_LIST = [ 33 | "photos", "cost", "currency" 34 | ] 35 | FIELDS_ALL_SINGLE_HOUSING = [ 36 | "id", "url", "type", "advert_type", "house_type", "title", "area", 37 | "cost", "currency", "utilities", "date", "location", "text", "rooms", 38 | "details" 39 | ] 40 | FIELDS_ANY_SINGLE_HOUSING = [ 41 | "bedrooms", 42 | "photos", 43 | "DPE", 44 | "GES", 45 | "phone" 46 | ] 47 | 48 | def test_explorimmo_rent(self): 49 | query = Query() 50 | query.area_min = 20 51 | query.cost_max = 1500 52 | query.type = POSTS_TYPES.RENT 53 | query.cities = [] 54 | for city in self.backend.search_city('paris'): 55 | city.backend = self.backend.name 56 | query.cities.append(city) 57 | self.check_against_query(query) 58 | 59 | def test_explorimmo_sale(self): 60 | query = Query() 61 | query.area_min = 20 62 | query.type = POSTS_TYPES.SALE 63 | query.cities = [] 64 | for city in self.backend.search_city('paris'): 65 | city.backend = self.backend.name 66 | query.cities.append(city) 67 | self.check_against_query(query) 68 | 69 | def test_explorimmo_furnished_rent(self): 70 | query = Query() 71 | query.area_min = 20 72 | query.cost_max = 1500 73 | query.type = POSTS_TYPES.FURNISHED_RENT 74 | query.cities = [] 75 | for city in self.backend.search_city('paris'): 76 | city.backend = self.backend.name 77 | query.cities.append(city) 78 | self.check_against_query(query) 79 | 80 | def test_explorimmo_viager(self): 81 | query = Query() 82 | query.type = POSTS_TYPES.VIAGER 83 | query.cities = [] 84 | for city in self.backend.search_city('85'): 85 | city.backend = self.backend.name 86 | query.cities.append(city) 87 | self.check_against_query(query) 88 | 89 | def test_explorimmo_personal(self): 90 | query = Query() 91 | query.area_min = 20 92 | query.cost_max = 900 93 | query.type = POSTS_TYPES.RENT 94 | query.advert_types = [ADVERT_TYPES.PERSONAL] 95 | query.cities = [] 96 | for city in self.backend.search_city('paris'): 97 | city.backend = self.backend.name 98 | query.cities.append(city) 99 | 100 | results = list(self.backend.search_housings(query)) 101 | self.assertEqual(len(results), 0) 102 | -------------------------------------------------------------------------------- /flatisfy/web/js_src/components/flatstableline.vue: -------------------------------------------------------------------------------- 1 | 56 | 57 | 94 | 95 | 101 | -------------------------------------------------------------------------------- /modules/seloger/test.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | # Copyright(C) 2012 Romain Bignon 4 | # 5 | # This file is part of a woob module. 6 | # 7 | # This woob module is free software: you can redistribute it and/or modify 8 | # it under the terms of the GNU Affero General Public License as published by 9 | # the Free Software Foundation, either version 3 of the License, or 10 | # (at your option) any later version. 11 | # 12 | # This woob module is distributed in the hope that it will be useful, 13 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 14 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 15 | # GNU Affero General Public License for more details. 16 | # 17 | # You should have received a copy of the GNU Affero General Public License 18 | # along with this woob module. If not, see . 19 | 20 | from woob.capabilities.housing import Query, POSTS_TYPES, ADVERT_TYPES 21 | from woob.tools.test import BackendTest 22 | from woob.tools.capabilities.housing.housing_test import HousingTest 23 | 24 | 25 | class SeLogerTest(BackendTest, HousingTest): 26 | MODULE = 'seloger' 27 | 28 | FIELDS_ALL_HOUSINGS_LIST = [ 29 | "id", "type", "advert_type", "house_type", "url", "title", "area", 30 | "utilities", "date", "location", "text" 31 | ] 32 | FIELDS_ANY_HOUSINGS_LIST = [ 33 | "cost", # Some posts don't have cost in seloger 34 | "currency", # Same 35 | "photos", 36 | ] 37 | FIELDS_ALL_SINGLE_HOUSING = [ 38 | "id", "url", "type", "advert_type", "house_type", "title", "area", 39 | "utilities", "date", "location", "text", "phone", "details" 40 | ] 41 | FIELDS_ANY_SINGLE_HOUSING = [ 42 | "cost", # Some posts don't have cost in seloger 43 | "currency", # Same 44 | "photos", 45 | "rooms", 46 | "bedrooms", 47 | "station", 48 | "DPE", 49 | "GES" 50 | ] 51 | DO_NOT_DISTINGUISH_FURNISHED_RENT = True 52 | 53 | def test_seloger_rent(self): 54 | query = Query() 55 | query.area_min = 20 56 | query.cost_max = 1500 57 | query.type = POSTS_TYPES.RENT 58 | query.cities = [] 59 | for city in self.backend.search_city('paris'): 60 | city.backend = self.backend.name 61 | query.cities.append(city) 62 | self.check_against_query(query) 63 | 64 | def test_seloger_sale(self): 65 | query = Query() 66 | query.area_min = 20 67 | query.type = POSTS_TYPES.SALE 68 | query.cities = [] 69 | for city in self.backend.search_city('paris'): 70 | city.backend = self.backend.name 71 | query.cities.append(city) 72 | self.check_against_query(query) 73 | 74 | def test_seloger_furnished_rent(self): 75 | query = Query() 76 | query.area_min = 20 77 | query.cost_max = 1500 78 | query.type = POSTS_TYPES.FURNISHED_RENT 79 | query.cities = [] 80 | for city in self.backend.search_city('paris'): 81 | city.backend = self.backend.name 82 | query.cities.append(city) 83 | self.check_against_query(query) 84 | 85 | def test_seloger_viager(self): 86 | query = Query() 87 | query.type = POSTS_TYPES.VIAGER 88 | query.cities = [] 89 | for city in self.backend.search_city('85'): 90 | city.backend = self.backend.name 91 | query.cities.append(city) 92 | self.check_against_query(query) 93 | 94 | def test_seloger_rent_personal(self): 95 | query = Query() 96 | query.area_min = 20 97 | query.cost_max = 1500 98 | query.type = POSTS_TYPES.RENT 99 | query.advert_types = [ADVERT_TYPES.PROFESSIONAL] 100 | query.cities = [] 101 | for city in self.backend.search_city('paris'): 102 | city.backend = self.backend.name 103 | query.cities.append(city) 104 | self.check_against_query(query) 105 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | ## TL;DR 2 | 3 | We have a [code of conduct](CodeOfConduct.md), please make sure to review it 4 | prior to contributing. 5 | 6 | * Branch off `master`. 7 | * One feature per commit. 8 | * In case of changes request, amend your commit. 9 | 10 | You can either open issues / merge requests on [my 11 | Gitlab](https://git.phyks.me/Phyks/flatisfy/) (preferred) or on the [Github 12 | mirror](https://github.com/phyks/flatisfy). 13 | 14 | 15 | ## Useful infos 16 | 17 | * There is a `hooks/pre-commit` file which can be used as a `pre-commit` git 18 | hook to check coding style. 19 | * Python coding style is PEP8. JS coding style is enforced by `eslint`. 20 | * Some useful `npm` scripts are provided (`build:{dev,prod}` / 21 | `watch:{dev,prod}` / `lint`) 22 | 23 | 24 | ## Translating the webapp 25 | 26 | If you want to translate the webapp, just create a new folder in 27 | `flatisfy/web/js_src/i18n` with the short name of your locale (typically, `en` 28 | is for english). Copy the `flatisfy/web/js_src/i18n/en/index.js` file to this 29 | new folder and translate the `messages` strings. 30 | 31 | Then, edit `flatisfy/web/js_src/i18n/index.js` file to include your new 32 | locale. 33 | 34 | 35 | ## How to contribute 36 | 37 | * If you're thinking about a new feature, see if there's already an issue open 38 | about it, or please open one otherwise. This will ensure that everybody is on 39 | track for the feature and willing to see it in Flatisfy. 40 | * One commit per feature. 41 | * Branch off the `master ` branch. 42 | * Check the linting of your code before doing a PR. 43 | * Ideally, your merge-request should be mergeable without any merge commit, that 44 | is, it should be a fast-forward merge. For this to happen, your code needs to 45 | be always rebased onto `master`. Again, this is something nice to have that 46 | I expect from recurring contributors, but not a big deal if you don't do it 47 | otherwise. 48 | * I'll look at it and might ask for a few changes. In this case, please create 49 | new commits. When the final result looks good, I may ask you to squash the 50 | WIP commits into a single one, to maintain the invariant of "one feature, one 51 | commit". 52 | 53 | Thanks! 54 | 55 | 56 | ## Adding support for a new Woob backend 57 | 58 | To enable a new Woob `CapHousing` backend in Flatisfy, you should add it to 59 | the list of available backends in 60 | [flatisfy/fetch.py#L69-70](https://git.phyks.me/Phyks/flatisfy/blob/master/flatisfy/fetch.py#L69-70) 61 | and update the list of `BACKEND_PRECEDENCES` for deduplication in 62 | [flatisfy/filters/duplicates.py#L24-31](https://git.phyks.me/Phyks/flatisfy/blob/master/flatisfy/filters/duplicates.py#L24-31). 63 | Thats' all! 64 | 65 | 66 | ## Adding new data files 67 | 68 | If you want to add new data files, especially for public transportation stops 69 | (to cover more cities), please follow these steps: 70 | 71 | 1. Download and put the **original** file in `flatisfy/data_files`. Please, 72 | use the original data file to ease tracking licenses and be able to still 73 | have a working pipeline, by letting the user download it and place it in 74 | the right place, in case of license conflict. 75 | 2. Mention the added data file and its license in `README.md`, in the 76 | dedicated section. 77 | 3. Write a preprocessing function in `flatisfy/data_files/__init__.py`. You 78 | can have a look at the existing functions for a model. 79 | 80 | 81 | ## Adding new migrations 82 | 83 | If you want to change the database schema, you should create a matching 84 | migration. Here is the way to do it correctly: 85 | 86 | 1. First, edit the `flatisfy/models` files to create / remove the required 87 | fields. If you create a new database from scratch, these are the files 88 | which will be used. 89 | 2. Then, run `alembic revision -m "Some description"` in the root of the git 90 | repo to create a new migration. 91 | 3. Finally, edit the newly created migration file under the `migrations/` 92 | folder to add the required code to alter the database (both upgrade and 93 | downgrade). 94 | 95 | 96 | Thanks! 97 | -------------------------------------------------------------------------------- /modules/leboncoin/test.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | # Copyright(C) 2014 Bezleputh 4 | # 5 | # This file is part of a woob module. 6 | # 7 | # This woob module is free software: you can redistribute it and/or modify 8 | # it under the terms of the GNU Affero General Public License as published by 9 | # the Free Software Foundation, either version 3 of the License, or 10 | # (at your option) any later version. 11 | # 12 | # This woob module is distributed in the hope that it will be useful, 13 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 14 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 15 | # GNU Affero General Public License for more details. 16 | # 17 | # You should have received a copy of the GNU Affero General Public License 18 | # along with this woob module. If not, see . 19 | 20 | from woob.tools.test import BackendTest 21 | from woob.tools.value import Value 22 | from woob.capabilities.housing import Query, POSTS_TYPES, ADVERT_TYPES 23 | from woob.tools.capabilities.housing.housing_test import HousingTest 24 | 25 | 26 | class LeboncoinTest(BackendTest, HousingTest): 27 | MODULE = 'leboncoin' 28 | 29 | FIELDS_ALL_HOUSINGS_LIST = [ 30 | "id", "type", "advert_type", "url", "title", 31 | "currency", "utilities", "date", "location", "text" 32 | ] 33 | FIELDS_ANY_HOUSINGS_LIST = [ 34 | "area", 35 | "cost", 36 | "price_per_meter", 37 | "photos" 38 | ] 39 | FIELDS_ALL_SINGLE_HOUSING = [ 40 | "id", "url", "type", "advert_type", "house_type", "title", 41 | "cost", "currency", "utilities", "date", "location", "text", 42 | "rooms", "details" 43 | ] 44 | FIELDS_ANY_SINGLE_HOUSING = [ 45 | "area", 46 | "GES", 47 | "DPE", 48 | "photos", 49 | # Don't test phone as leboncoin API is strongly rate-limited 50 | ] 51 | 52 | def setUp(self): 53 | if not self.is_backend_configured(): 54 | self.backend.config['advert_type'] = Value(value='a') 55 | self.backend.config['region'] = Value(value='ile_de_france') 56 | 57 | def test_leboncoin_rent(self): 58 | query = Query() 59 | query.area_min = 20 60 | query.cost_max = 1500 61 | query.type = POSTS_TYPES.RENT 62 | query.cities = [] 63 | for city in self.backend.search_city('paris'): 64 | city.backend = self.backend.name 65 | query.cities.append(city) 66 | if len(query.cities) == 3: 67 | break 68 | self.check_against_query(query) 69 | 70 | def test_leboncoin_sale(self): 71 | query = Query() 72 | query.area_min = 20 73 | query.type = POSTS_TYPES.SALE 74 | query.cities = [] 75 | for city in self.backend.search_city('paris'): 76 | city.backend = self.backend.name 77 | query.cities.append(city) 78 | if len(query.cities) == 3: 79 | break 80 | self.check_against_query(query) 81 | 82 | def test_leboncoin_furnished_rent(self): 83 | query = Query() 84 | query.area_min = 20 85 | query.cost_max = 1500 86 | query.type = POSTS_TYPES.FURNISHED_RENT 87 | query.cities = [] 88 | for city in self.backend.search_city('paris'): 89 | city.backend = self.backend.name 90 | query.cities.append(city) 91 | if len(query.cities) == 3: 92 | break 93 | self.check_against_query(query) 94 | 95 | def test_leboncoin_professional(self): 96 | query = Query() 97 | query.area_min = 20 98 | query.cost_max = 900 99 | query.type = POSTS_TYPES.RENT 100 | query.advert_types = [ADVERT_TYPES.PROFESSIONAL] 101 | query.cities = [] 102 | for city in self.backend.search_city('paris'): 103 | city.backend = self.backend.name 104 | query.cities.append(city) 105 | self.check_against_query(query) 106 | -------------------------------------------------------------------------------- /flatisfy/web/js_src/components/slider.vue: -------------------------------------------------------------------------------- 1 | 16 | 17 | 99 | 100 | 156 | -------------------------------------------------------------------------------- /flatisfy/test_files/128358415@seloger.json: -------------------------------------------------------------------------------- 1 | { 2 | "id": "128358415@seloger", 3 | "url": "http://www.seloger.com/annonces/achat/maison/rennes-35/128358415.htm?p=", 4 | "title": " 60m\u00b2 - Rennes", 5 | "area": 60, 6 | "cost": 179888, 7 | "price_per_meter": 2998.133333333333333333333333, 8 | "currency": "\u20ac", 9 | "utilities": "", 10 | "date": "2018-01-19T08:46:00", 11 | "location": " Rennes (35000)", 12 | "station": "", 13 | "text": "I@D France - Sarah LECLERC vous propose: Pour les Amoureux de la Pierre, Maison de ville enti\u00e8rement r\u00e9nov\u00e9e avec go\u00fbt et modernit\u00e9, Poutres apparentes dans les 2 chambres, Cuisine am\u00e9nag\u00e9e ouverte sur le salon-salle \u00e0 manger de 30 M 2, Salle de douche, JARDINET et TERRASSE de 95 M 2 (possibilit\u00e9 jardin japonais).. Situ\u00e9e AU COEUR DE LA VILLE, \u00e0 proximit\u00e9 des \u00c9coles, des Commerces et du march\u00e9, tout peut se faire \u00e0 pied.. Ligne de bus \u00e0 proximit\u00e9 (ligne 61).. AUX PORTES DE RENNES (5mn).. Peut se vivre comme un appartement sans les charges de copropri\u00e9t\u00e9 ! BEAUCOUP DE CHARME POUR CE BIEN RARE SUR LE MARCHE !! Honoraires d'agence \u00e0 la charge du vendeur. Information d'affichage \u00e9nerg\u00e9tique sur ce bien: DPE VI indice 0 et GES VI indice 0. La pr\u00e9sente annonce immobili\u00e8re a \u00e9t\u00e9 r\u00e9dig\u00e9e sous la responsabilit\u00e9 \u00e9ditoriale de Mme Sarah LECLERC (ID 27387), Agent Commercial mandataire en immobilier immatricul\u00e9 au Registre Sp\u00e9cial des Agents Commerciaux (RSAC) du Tribunal de Commerce de rennes sous le num\u00e9ro 521558007.", 14 | "phone": null, 15 | "photos": [{ 16 | "id": "0j9kfrqnixlcnezpzsgz3g3vnekr6qj8rn7jcv22g.jpg", 17 | "url": "https://v.seloger.com/s/height/800/visuels/0/j/9/k/0j9kfrqnixlcnezpzsgz3g3vnekr6qj8rn7jcv22g.jpg", 18 | "data": null 19 | }, { 20 | "id": "0yqp4d8arum1iy1pk9f1xh1req853dnhutgdjkcoo.jpg", 21 | "url": "https://v.seloger.com/s/height/800/visuels/0/y/q/p/0yqp4d8arum1iy1pk9f1xh1req853dnhutgdjkcoo.jpg", 22 | "data": null 23 | }, { 24 | "id": "10a86qpr9k9wurb8itfnfgzo8eetxs6th2gmiv1o8.jpg", 25 | "url": "https://v.seloger.com/s/height/800/visuels/1/0/a/8/10a86qpr9k9wurb8itfnfgzo8eetxs6th2gmiv1o8.jpg", 26 | "data": null 27 | }, { 28 | "id": "0eybdtrwgscy2dadq05naujq5okeotl5cyfuergvs.jpg", 29 | "url": "https://v.seloger.com/s/height/800/visuels/0/e/y/b/0eybdtrwgscy2dadq05naujq5okeotl5cyfuergvs.jpg", 30 | "data": null 31 | }, { 32 | "id": "0maihs9wfff2xl3plqtq254n44gkaxlvejyrtnbqw.jpg", 33 | "url": "https://v.seloger.com/s/height/800/visuels/0/m/a/i/0maihs9wfff2xl3plqtq254n44gkaxlvejyrtnbqw.jpg", 34 | "data": null 35 | }, { 36 | "id": "0cjgak7htwwtsl4to31rqqmyg5a73h6vwzserq2wo.jpg", 37 | "url": "https://v.seloger.com/s/height/800/visuels/0/c/j/g/0cjgak7htwwtsl4to31rqqmyg5a73h6vwzserq2wo.jpg", 38 | "data": null 39 | }, { 40 | "id": "102tkunk4f87ksovtm7x6u1awoz65it97nabbx9a0.jpg", 41 | "url": "https://v.seloger.com/s/height/800/visuels/1/0/2/t/102tkunk4f87ksovtm7x6u1awoz65it97nabbx9a0.jpg", 42 | "data": null 43 | }, { 44 | "id": "1kd6jjp93vv5wv5dw8964n7t823luy8jk3m4obkfs.jpg", 45 | "url": "https://v.seloger.com/s/height/800/visuels/1/k/d/6/1kd6jjp93vv5wv5dw8964n7t823luy8jk3m4obkfs.jpg", 46 | "data": null 47 | }, { 48 | "id": "052a19zndeojbs4px73q8ns94g1uxi0exxqyltpo8.jpg", 49 | "url": "https://v.seloger.com/s/height/800/visuels/0/5/2/a/052a19zndeojbs4px73q8ns94g1uxi0exxqyltpo8.jpg", 50 | "data": null 51 | }], 52 | "rooms": 3, 53 | "bedrooms": 2, 54 | "details": { 55 | "Cuisine": "am\u00e9ricaine \u00e9quip\u00e9e", 56 | "Pi\u00e8ces": "3", 57 | "Etage": "1", 58 | "Reference": "488187", 59 | "Chambres": "2", 60 | "Chauffage": "\u00e9lectrique radiateur", 61 | "Terrain": "95 m\u00b2", 62 | "Surface": "60 m\u00b2", 63 | "Terrasse": "1", 64 | "Ann\u00e9e de construction": "1870", 65 | "Salle \u00c0 Manger": "", 66 | "Salle de s\u00e9jour": "22 m\u00b2" 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /modules/pap/test.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | # Copyright(C) 2012 Romain Bignon 4 | # 5 | # This file is part of a woob module. 6 | # 7 | # This woob module is free software: you can redistribute it and/or modify 8 | # it under the terms of the GNU Affero General Public License as published by 9 | # the Free Software Foundation, either version 3 of the License, or 10 | # (at your option) any later version. 11 | # 12 | # This woob module is distributed in the hope that it will be useful, 13 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 14 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 15 | # GNU Affero General Public License for more details. 16 | # 17 | # You should have received a copy of the GNU Affero General Public License 18 | # along with this woob module. If not, see . 19 | 20 | from woob.capabilities.housing import Query, POSTS_TYPES, ADVERT_TYPES 21 | from woob.tools.test import BackendTest 22 | from woob.tools.capabilities.housing.housing_test import HousingTest 23 | 24 | 25 | class PapTest(BackendTest, HousingTest): 26 | MODULE = 'pap' 27 | 28 | FIELDS_ALL_HOUSINGS_LIST = [ 29 | "id", "type", "advert_type", "house_type", "url", "title", "area", 30 | "cost", "currency", "utilities", "location", "text" 31 | ] 32 | FIELDS_ANY_HOUSINGS_LIST = [ 33 | "photos", 34 | "station", 35 | ] 36 | FIELDS_ALL_SINGLE_HOUSING = [ 37 | "id", "url", "type", "advert_type", "house_type", "title", "area", 38 | "cost", "currency", "utilities", "date", "location", "text", 39 | "phone" 40 | ] 41 | FIELDS_ANY_SINGLE_HOUSING = [ 42 | "photos", 43 | "rooms", 44 | "bedrooms", 45 | "station" 46 | ] 47 | 48 | def test_pap_rent(self): 49 | query = Query() 50 | query.area_min = 20 51 | query.cost_max = 1500 52 | query.type = POSTS_TYPES.RENT 53 | query.cities = [] 54 | for city in self.backend.search_city('paris'): 55 | city.backend = self.backend.name 56 | query.cities.append(city) 57 | self.check_against_query(query) 58 | 59 | def test_pap_sale(self): 60 | query = Query() 61 | query.area_min = 20 62 | query.type = POSTS_TYPES.SALE 63 | query.cities = [] 64 | for city in self.backend.search_city('paris'): 65 | city.backend = self.backend.name 66 | query.cities.append(city) 67 | self.check_against_query(query) 68 | 69 | def test_pap_furnished_rent(self): 70 | query = Query() 71 | query.area_min = 20 72 | query.cost_max = 1500 73 | query.type = POSTS_TYPES.FURNISHED_RENT 74 | query.cities = [] 75 | for city in self.backend.search_city('paris'): 76 | city.backend = self.backend.name 77 | query.cities.append(city) 78 | self.check_against_query(query) 79 | 80 | def test_pap_viager(self): 81 | query = Query() 82 | query.type = POSTS_TYPES.VIAGER 83 | query.cities = [] 84 | for city in self.backend.search_city('paris'): 85 | city.backend = self.backend.name 86 | query.cities.append(city) 87 | # Remove rooms from the tested fields as viager never have them 88 | self.FIELDS_ANY_HOUSINGS_LIST = [ 89 | "photos", 90 | "station", 91 | "bedrooms" 92 | ] 93 | self.FIELDS_ANY_SINGLE_HOUSING = [ 94 | "photos", 95 | "bedrooms", 96 | "station" 97 | ] 98 | self.check_against_query(query) 99 | 100 | def test_pap_professional(self): 101 | query = Query() 102 | query.area_min = 20 103 | query.cost_max = 900 104 | query.type = POSTS_TYPES.RENT 105 | query.advert_types = [ADVERT_TYPES.PROFESSIONAL] 106 | query.cities = [] 107 | for city in self.backend.search_city('paris'): 108 | city.backend = self.backend.name 109 | query.cities.append(city) 110 | 111 | results = list(self.backend.search_housings(query)) 112 | self.assertEqual(len(results), 0) 113 | -------------------------------------------------------------------------------- /modules/logicimmo/test.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | # Copyright(C) 2014 Bezleputh 4 | # 5 | # This file is part of a woob module. 6 | # 7 | # This woob module is free software: you can redistribute it and/or modify 8 | # it under the terms of the GNU Affero General Public License as published by 9 | # the Free Software Foundation, either version 3 of the License, or 10 | # (at your option) any later version. 11 | # 12 | # This woob module is distributed in the hope that it will be useful, 13 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 14 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 15 | # GNU Affero General Public License for more details. 16 | # 17 | # You should have received a copy of the GNU Affero General Public License 18 | # along with this woob module. If not, see . 19 | 20 | from woob.capabilities.housing import Query, POSTS_TYPES, ADVERT_TYPES 21 | from woob.tools.test import BackendTest 22 | from woob.tools.capabilities.housing.housing_test import HousingTest 23 | 24 | 25 | class LogicimmoTest(BackendTest, HousingTest): 26 | MODULE = 'logicimmo' 27 | 28 | FIELDS_ALL_HOUSINGS_LIST = [ 29 | "id", "type", "advert_type", "house_type", "url", "title", "area", 30 | "cost", "currency", "utilities", "date", "location", "text", 31 | "details", "rooms" 32 | ] 33 | FIELDS_ANY_HOUSINGS_LIST = [ 34 | "photos", 35 | ] 36 | FIELDS_ALL_SINGLE_HOUSING = [ 37 | "id", "url", "type", "advert_type", "house_type", "title", "area", 38 | "cost", "currency", "utilities", "date", "location", "text", 39 | "phone", "details" 40 | ] 41 | FIELDS_ANY_SINGLE_HOUSING = [ 42 | "photos", 43 | "station", 44 | "rooms", 45 | "phone", 46 | "DPE", 47 | "GES" 48 | ] 49 | DO_NOT_DISTINGUISH_FURNISHED_RENT = True 50 | 51 | def test_logicimmo_rent(self): 52 | query = Query() 53 | query.area_min = 20 54 | query.cost_max = 1500 55 | query.type = POSTS_TYPES.RENT 56 | query.cities = [] 57 | for city in self.backend.search_city('paris'): 58 | city.backend = self.backend.name 59 | query.cities.append(city) 60 | if len(query.cities) == 3: 61 | break 62 | self.check_against_query(query) 63 | 64 | def test_logicimmo_sale(self): 65 | query = Query() 66 | query.area_min = 20 67 | query.type = POSTS_TYPES.SALE 68 | query.cities = [] 69 | for city in self.backend.search_city('paris'): 70 | city.backend = self.backend.name 71 | query.cities.append(city) 72 | if len(query.cities) == 3: 73 | break 74 | self.check_against_query(query) 75 | 76 | def test_logicimmo_furnished_rent(self): 77 | query = Query() 78 | query.area_min = 20 79 | query.cost_max = 1500 80 | query.type = POSTS_TYPES.FURNISHED_RENT 81 | query.cities = [] 82 | for city in self.backend.search_city('paris'): 83 | city.backend = self.backend.name 84 | query.cities.append(city) 85 | if len(query.cities) == 3: 86 | break 87 | self.check_against_query(query) 88 | 89 | def test_logicimmo_viager(self): 90 | query = Query() 91 | query.type = POSTS_TYPES.VIAGER 92 | query.cities = [] 93 | for city in self.backend.search_city('paris'): 94 | city.backend = self.backend.name 95 | query.cities.append(city) 96 | if len(query.cities) == 3: 97 | break 98 | self.check_against_query(query) 99 | 100 | def test_logicimmo_personal(self): 101 | query = Query() 102 | query.area_min = 20 103 | query.cost_max = 900 104 | query.type = POSTS_TYPES.RENT 105 | query.advert_types = [ADVERT_TYPES.PERSONAL] 106 | query.cities = [] 107 | for city in self.backend.search_city('paris'): 108 | city.backend = self.backend.name 109 | query.cities.append(city) 110 | 111 | results = list(self.backend.search_housings(query)) 112 | self.assertEqual(len(results), 0) 113 | -------------------------------------------------------------------------------- /modules/logicimmo/browser.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | # Copyright(C) 2014 Bezleputh 4 | # 5 | # This file is part of a woob module. 6 | # 7 | # This woob module is free software: you can redistribute it and/or modify 8 | # it under the terms of the GNU Affero General Public License as published by 9 | # the Free Software Foundation, either version 3 of the License, or 10 | # (at your option) any later version. 11 | # 12 | # This woob module is distributed in the hope that it will be useful, 13 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 14 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 15 | # GNU Affero General Public License for more details. 16 | # 17 | # You should have received a copy of the GNU Affero General Public License 18 | # along with this woob module. If not, see . 19 | 20 | 21 | from woob.browser import PagesBrowser, URL 22 | from woob.browser.profiles import Firefox 23 | from woob.capabilities.housing import (TypeNotSupported, POSTS_TYPES, 24 | HOUSE_TYPES) 25 | from .pages import CitiesPage, SearchPage, HousingPage, PhonePage 26 | 27 | 28 | class LogicimmoBrowser(PagesBrowser): 29 | BASEURL = 'https://www.logic-immo.com/' 30 | PROFILE = Firefox() 31 | city = URL('asset/t9/getLocalityT9.php\?site=fr&lang=fr&json=%22(?P.*)%22', 32 | CitiesPage) 33 | search = URL('(?Plocation-immobilier|vente-immobilier|recherche-colocation)-(?P.*)/options/(?P.*)', SearchPage) 34 | housing = URL('detail-(?P<_id>.*).htm', HousingPage) 35 | phone = URL('(?P.*)', PhonePage) 36 | 37 | TYPES = {POSTS_TYPES.RENT: 'location-immobilier', 38 | POSTS_TYPES.SALE: 'vente-immobilier', 39 | POSTS_TYPES.SHARING: 'recherche-colocation', 40 | POSTS_TYPES.FURNISHED_RENT: 'location-immobilier', 41 | POSTS_TYPES.VIAGER: 'vente-immobilier'} 42 | 43 | RET = {HOUSE_TYPES.HOUSE: '2', 44 | HOUSE_TYPES.APART: '1', 45 | HOUSE_TYPES.LAND: '3', 46 | HOUSE_TYPES.PARKING: '10', 47 | HOUSE_TYPES.OTHER: '14'} 48 | 49 | def __init__(self, *args, **kwargs): 50 | super(LogicimmoBrowser, self).__init__(*args, **kwargs) 51 | self.session.headers['X-Requested-With'] = 'XMLHttpRequest' 52 | 53 | def get_cities(self, pattern): 54 | if pattern: 55 | return self.city.go(pattern=pattern).get_cities() 56 | 57 | def search_housings(self, type, cities, nb_rooms, area_min, area_max, cost_min, cost_max, house_types): 58 | if type not in self.TYPES: 59 | raise TypeNotSupported() 60 | 61 | options = [] 62 | 63 | ret = [] 64 | if type == POSTS_TYPES.VIAGER: 65 | ret = ['15'] 66 | else: 67 | for house_type in house_types: 68 | if house_type in self.RET: 69 | ret.append(self.RET.get(house_type)) 70 | 71 | if len(ret): 72 | options.append('groupprptypesids=%s' % ','.join(ret)) 73 | 74 | if type == POSTS_TYPES.FURNISHED_RENT: 75 | options.append('searchoptions=4') 76 | 77 | options.append('pricemin=%s' % (cost_min if cost_min else '0')) 78 | 79 | if cost_max: 80 | options.append('pricemax=%s' % cost_max) 81 | 82 | options.append('areamin=%s' % (area_min if area_min else '0')) 83 | 84 | if area_max: 85 | options.append('areamax=%s' % area_max) 86 | 87 | if nb_rooms: 88 | if type == POSTS_TYPES.SHARING: 89 | options.append('nbbedrooms=%s' % ','.join([str(i) for i in range(nb_rooms, 7)])) 90 | else: 91 | options.append('nbrooms=%s' % ','.join([str(i) for i in range(nb_rooms, 7)])) 92 | 93 | self.search.go(type=self.TYPES.get(type, 'location-immobilier'), 94 | cities=cities, 95 | options='/'.join(options)) 96 | 97 | if type == POSTS_TYPES.SHARING: 98 | return self.page.iter_sharing() 99 | 100 | return self.page.iter_housings(query_type=type) 101 | 102 | def get_housing(self, _id, housing=None): 103 | return self.housing.go(_id=_id).get_housing(obj=housing) 104 | 105 | def get_phone(self, _id): 106 | if _id.startswith('location') or _id.startswith('vente'): 107 | urlcontact, params = self.housing.stay_or_go(_id=_id).get_phone_url_datas() 108 | return self.phone.go(urlcontact=urlcontact, params=params).get_phone() 109 | -------------------------------------------------------------------------------- /flatisfy/filters/cache.py: -------------------------------------------------------------------------------- 1 | # coding: utf-8 2 | """ 3 | Caching function for pictures. 4 | """ 5 | 6 | from __future__ import absolute_import, print_function, unicode_literals 7 | 8 | import collections 9 | import hashlib 10 | import os 11 | import requests 12 | import logging 13 | from io import BytesIO 14 | 15 | import PIL.Image 16 | 17 | LOGGER = logging.getLogger(__name__) 18 | 19 | 20 | class MemoryCache(object): 21 | """ 22 | A cache in memory. 23 | """ 24 | 25 | @staticmethod 26 | def on_miss(key): 27 | """ 28 | Method to be called whenever an object is requested from the cache but 29 | was not already cached. Typically, make a HTTP query to fetch it. 30 | 31 | :param key: Key of the requested object. 32 | :return: The object content. 33 | """ 34 | raise NotImplementedError 35 | 36 | def __init__(self): 37 | self.hits = 0 38 | self.misses = 0 39 | self.map = collections.OrderedDict() 40 | 41 | def get(self, key): 42 | """ 43 | Get an element from cache. Eventually call ``on_miss`` if the item is 44 | not already cached. 45 | 46 | :param key: Key of the element to retrieve. 47 | :return: Requested element. 48 | """ 49 | cached = self.map.get(key, None) 50 | if cached is not None: 51 | self.hits += 1 52 | return cached 53 | 54 | item = self.map[key] = self.on_miss(key) 55 | self.misses += 1 56 | return item 57 | 58 | def total(self): 59 | """ 60 | Get the total number of calls (with hits to the cache, or miss and 61 | fetching with ``on_miss``) to the cache. 62 | 63 | :return: Total number of item accessing. 64 | """ 65 | return self.hits + self.misses 66 | 67 | def hit_rate(self): 68 | """ 69 | Get the hit rate, that is the rate at which we requested an item which 70 | was already in the cache. 71 | 72 | :return: The hit rate, in percents. 73 | """ 74 | assert self.total() > 0 75 | return 100 * self.hits // self.total() 76 | 77 | def miss_rate(self): 78 | """ 79 | Get the miss rate, that is the rate at which we requested an item which 80 | was not already in the cache. 81 | 82 | :return: The miss rate, in percents. 83 | """ 84 | assert self.total() > 0 85 | return 100 * self.misses // self.total() 86 | 87 | 88 | class ImageCache(MemoryCache): 89 | """ 90 | A cache for images, stored in memory. 91 | """ 92 | 93 | @staticmethod 94 | def compute_filename(url): 95 | """ 96 | Compute filename (hash of the URL) for the cached image. 97 | 98 | :param url: The URL of the image. 99 | :return: The filename, with its extension. 100 | """ 101 | # Always store as JPEG 102 | return "%s.jpg" % hashlib.sha1(url.encode("utf-8")).hexdigest() 103 | 104 | def on_miss(self, url): 105 | """ 106 | Helper to actually retrieve photos if not already cached. 107 | """ 108 | # If two many items in the cache, pop one 109 | if len(self.map.keys()) > self.max_items: 110 | self.map.popitem(last=False) 111 | 112 | if url.endswith(".svg"): 113 | # Skip SVG photo which are unsupported and unlikely to be relevant 114 | return None 115 | 116 | filepath = None 117 | # Try to load from local folder 118 | if self.storage_dir: 119 | filepath = os.path.join(self.storage_dir, self.compute_filename(url)) 120 | if os.path.isfile(filepath): 121 | return PIL.Image.open(filepath) 122 | # Otherwise, fetch it 123 | try: 124 | LOGGER.debug(f"Download photo from {url} to {filepath}") 125 | req = requests.get(url) 126 | req.raise_for_status() 127 | image = PIL.Image.open(BytesIO(req.content)) 128 | if filepath: 129 | image.save(filepath, format=image.format) 130 | return image 131 | except (requests.HTTPError, IOError) as exc: 132 | LOGGER.info(f"Download photo from {url} failed: {exc}") 133 | return None 134 | 135 | def __init__(self, max_items=200, storage_dir=None): 136 | """ 137 | :param max_items: Max number of items in the cache, to prevent Out Of 138 | Memory errors. 139 | :param storage_dir: Directory in which images should be stored. 140 | """ 141 | self.max_items = max_items 142 | self.storage_dir = storage_dir 143 | if self.storage_dir and not os.path.isdir(self.storage_dir): 144 | os.makedirs(self.storage_dir) 145 | super(ImageCache, self).__init__() 146 | -------------------------------------------------------------------------------- /flatisfy/web/js_src/components/flatsmap.vue: -------------------------------------------------------------------------------- 1 | 26 | 27 | 108 | 109 | 115 | 116 | 127 | -------------------------------------------------------------------------------- /flatisfy/email.py: -------------------------------------------------------------------------------- 1 | # coding: utf-8 2 | """ 3 | Email notifications. 4 | """ 5 | 6 | from __future__ import absolute_import, print_function, unicode_literals 7 | from builtins import str 8 | 9 | import logging 10 | import smtplib 11 | from money import Money 12 | from email.mime.multipart import MIMEMultipart 13 | from email.mime.text import MIMEText 14 | from email.utils import formatdate, make_msgid 15 | 16 | LOGGER = logging.getLogger(__name__) 17 | 18 | 19 | def send_email(server, port, subject, _from, _to, txt, html, username=None, password=None): 20 | """ 21 | Send an email 22 | 23 | :param server: SMTP server to use. 24 | :param port: SMTP port to use. 25 | :param subject: Subject of the email to send. 26 | :param _from: Email address of the sender. 27 | :param _to: List of email addresses of the receivers. 28 | :param txt: Text version of the message. 29 | :param html: HTML version of the message. 30 | """ 31 | if not _to: 32 | LOGGER.warn("No recipients for the email notifications, aborting.") 33 | return 34 | 35 | server = smtplib.SMTP(server, port) 36 | if username or password: 37 | server.login(username or "", password or "") 38 | 39 | msg = MIMEMultipart("alternative") 40 | msg["Subject"] = subject 41 | msg["From"] = _from 42 | msg["To"] = ", ".join(_to) 43 | msg["Date"] = formatdate() 44 | msg["Message-ID"] = make_msgid() 45 | 46 | msg.attach(MIMEText(txt, "plain", "utf-8")) 47 | msg.attach(MIMEText(html, "html", "utf-8")) 48 | 49 | server.sendmail(_from, _to, msg.as_string()) 50 | server.quit() 51 | 52 | 53 | def send_notification(config, flats): 54 | """ 55 | Send an email notification about new available flats. 56 | 57 | :param config: A config dict. 58 | :param flats: List of flats to include in the notification. 59 | """ 60 | # Don't send an email if there are no new flats. 61 | if not flats: 62 | return 63 | 64 | i18n = { 65 | "en": { 66 | "subject": f"{len(flats)} new flats found!", 67 | "hello": "Hello dear user", 68 | "following_new_flats": "The following new flats have been found:", 69 | "area": "area", 70 | "cost": "cost", 71 | "signature": "Hope you'll find what you were looking for.", 72 | }, 73 | "fr": { 74 | "subject": f"{len(flats)} nouvelles annonces disponibles !", 75 | "hello": "Bonjour cher utilisateur", 76 | "following_new_flats": "Voici les nouvelles annonces :", 77 | "area": "surface", 78 | "cost": "coût", 79 | "signature": "Bonne recherche", 80 | }, 81 | } 82 | trs = i18n.get(config["notification_lang"], "en") 83 | 84 | txt = trs["hello"] + ",\n\n\n\n" 85 | html = f""" 86 | 87 | 88 | 89 |

{trs["hello"]}!

90 |

{trs["following_new_flats"]} 91 | 92 |

    93 | """ 94 | 95 | website_url = config["website_url"] 96 | 97 | for flat in flats: 98 | title = str(flat.title) 99 | flat_id = str(flat.id) 100 | try: 101 | area = str(int(flat.area)) 102 | except (TypeError, ValueError): 103 | area = None 104 | try: 105 | cost = int(flat.cost) 106 | except (TypeError, ValueError): 107 | cost = None 108 | currency = str(flat.currency) 109 | 110 | txt += f"- {title}: {website_url}#/flat/{flat_id} " 111 | html += f""" 112 |
  • 113 | {title} 114 | """ 115 | 116 | fields = [] 117 | if area: 118 | fields.append(f"{trs['area']}: {area}m²") 119 | if cost: 120 | if currency == '$': 121 | currency = 'USD' 122 | if currency == '€': 123 | currency = 'EUR' 124 | money = Money(cost, currency).format(config["notification_lang"]) 125 | fields.append(f"{trs['cost']}: {money.format()}") 126 | 127 | if len(fields): 128 | txt += f'({", ".join(fields)})' 129 | html += f'({", ".join(fields)})' 130 | 131 | html += "
  • " 132 | txt += "\n" 133 | 134 | html += "
" 135 | 136 | signature = f"\n{trs['signature']}\n\nBye!\nFlatisfy" 137 | txt += signature 138 | html += signature.replace("\n", "
") 139 | 140 | html += """

141 | 142 | """ 143 | 144 | send_email( 145 | config["smtp_server"], 146 | config["smtp_port"], 147 | trs["subject"], 148 | config["smtp_from"], 149 | config["smtp_to"], 150 | txt, 151 | html, 152 | config.get("smtp_username"), 153 | config.get("smtp_password"), 154 | ) 155 | -------------------------------------------------------------------------------- /flatisfy/web/js_src/views/home.vue: -------------------------------------------------------------------------------- 1 | 42 | 43 | 115 | 116 | 143 | -------------------------------------------------------------------------------- /flatisfy/web/app.py: -------------------------------------------------------------------------------- 1 | # coding: utf-8 2 | """ 3 | This module contains the definition of the Bottle web app. 4 | """ 5 | from __future__ import absolute_import, division, print_function, unicode_literals 6 | 7 | import functools 8 | import json 9 | import os 10 | 11 | import bottle 12 | import canister 13 | 14 | from flatisfy import database 15 | from flatisfy.tools import DateAwareJSONEncoder 16 | from flatisfy.web.routes import api as api_routes 17 | from flatisfy.web.configplugin import ConfigPlugin 18 | from flatisfy.web.dbplugin import DatabasePlugin 19 | 20 | 21 | class QuietWSGIRefServer(bottle.WSGIRefServer): 22 | """ 23 | Quiet implementation of Bottle built-in WSGIRefServer, as `Canister` is 24 | handling the logging through standard Python logging. 25 | """ 26 | 27 | # pylint: disable=locally-disabled,too-few-public-methods 28 | quiet = True 29 | 30 | def run(self, app): 31 | app.log.info("Server is now up and ready! Listening on %s:%s." % (self.host, self.port)) 32 | super(QuietWSGIRefServer, self).run(app) 33 | 34 | 35 | def _serve_static_file(filename): 36 | """ 37 | Helper function to serve static file. 38 | """ 39 | return bottle.static_file( 40 | filename, 41 | root=os.path.join(os.path.dirname(os.path.realpath(__file__)), "static"), 42 | ) 43 | 44 | 45 | def get_app(config): 46 | """ 47 | Get a Bottle app instance with all the routes set-up. 48 | 49 | :return: The built bottle app. 50 | """ 51 | get_session = database.init_db(config["database"], config["search_index"]) 52 | 53 | app = bottle.Bottle() 54 | app.install(DatabasePlugin(get_session)) 55 | app.install(ConfigPlugin(config)) 56 | app.config.setdefault("canister.log_level", "DISABLED") 57 | app.config.setdefault("canister.log_path", False) 58 | app.config.setdefault("canister.debug", False) 59 | app.install(canister.Canister()) 60 | # Use DateAwareJSONEncoder to dump JSON strings 61 | # From http://stackoverflow.com/questions/21282040/bottle-framework-how-to-return-datetime-in-json-response#comment55718456_21282666. pylint: disable=locally-disabled,line-too-long 62 | app.install(bottle.JSONPlugin(json_dumps=functools.partial(json.dumps, cls=DateAwareJSONEncoder))) 63 | 64 | # Enable CORS 65 | @app.hook("after_request") 66 | def enable_cors(): 67 | """ 68 | Add CORS headers at each request. 69 | """ 70 | # The str() call is required as we import unicode_literal and WSGI 71 | # headers list should have plain str type. 72 | bottle.response.headers[str("Access-Control-Allow-Origin")] = str("*") 73 | bottle.response.headers[str("Access-Control-Allow-Methods")] = str("PUT, GET, POST, DELETE, OPTIONS, PATCH") 74 | bottle.response.headers[str("Access-Control-Allow-Headers")] = str( 75 | "Origin, Accept, Content-Type, X-Requested-With, X-CSRF-Token" 76 | ) 77 | 78 | # API v1 routes 79 | app.route("/api/v1", ["GET", "OPTIONS"], api_routes.index_v1) 80 | 81 | app.route("/api/v1/time_to_places", ["GET", "OPTIONS"], api_routes.time_to_places_v1) 82 | 83 | app.route("/api/v1/flats", ["GET", "OPTIONS"], api_routes.flats_v1) 84 | app.route("/api/v1/flats/:flat_id", ["GET", "OPTIONS"], api_routes.flat_v1) 85 | app.route("/api/v1/flats/:flat_id", ["PATCH", "OPTIONS"], api_routes.update_flat_v1) 86 | 87 | app.route("/api/v1/ics/visits.ics", ["GET", "OPTIONS"], api_routes.ics_feed_v1) 88 | 89 | app.route("/api/v1/search", ["POST", "OPTIONS"], api_routes.search_v1) 90 | 91 | app.route("/api/v1/opendata", ["GET", "OPTIONS"], api_routes.opendata_index_v1) 92 | app.route( 93 | "/api/v1/opendata/postal_codes", 94 | ["GET", "OPTIONS"], 95 | api_routes.opendata_postal_codes_v1, 96 | ) 97 | 98 | app.route("/api/v1/metadata", ["GET", "OPTIONS"], api_routes.metadata_v1) 99 | app.route("/api/v1/import", ["GET", "OPTIONS"], api_routes.import_v1) 100 | 101 | # Index 102 | app.route("/", "GET", lambda: _serve_static_file("index.html")) 103 | 104 | # Static files 105 | app.route("/favicon.ico", "GET", lambda: _serve_static_file("favicon.ico")) 106 | app.route( 107 | "/assets/", 108 | "GET", 109 | lambda filename: _serve_static_file("/assets/{}".format(filename)), 110 | ) 111 | app.route( 112 | "/img/", 113 | "GET", 114 | lambda filename: _serve_static_file("/img/{}".format(filename)), 115 | ) 116 | app.route( 117 | "/.well-known/", 118 | "GET", 119 | lambda filename: _serve_static_file("/.well-known/{}".format(filename)), 120 | ) 121 | app.route( 122 | "/data/img/", 123 | "GET", 124 | lambda filename: bottle.static_file(filename, root=os.path.join(config["data_directory"], "images")), 125 | ) 126 | 127 | return app 128 | --------------------------------------------------------------------------------